diff --git a/assets/data/search-index.json b/assets/data/search-index.json index 9425670..4b5fea4 100644 --- a/assets/data/search-index.json +++ b/assets/data/search-index.json @@ -1 +1 @@ -{"v":1,"n":1340,"r":[{"u":"/docs/adr/index.html","d":"Architecture Decision Records","k":"Architecture Decision Records","x":"Accepted ADRs explaining why the core cross-cutting patterns exist. Read these before changing a pattern they describe: they capture context and trade-offs that aren't obvious…","i":"ApplicationLayer_DoesNotUseRawQueryableSurfaces AsyncMethodsDeclareTrailingCancellationToken MessageBusSettings.EnableDelayedRedelivery PushNotificationSettings.ChannelKeyPattern ApplicationDbContext.ConfigureConventions Microsoft.CodeAnalysis.PublicApiAnalyzers RegisterUpcastedIntegrationEventConsumer ApiParameterDescriptorBackfillProvider IWriteRepository.SetOriginalRowVersion ServiceInfoVersioningContractTestsBase ApplicationDbContext.OnModelCreating MMCA.Common.LayerEnforcement.targets"},{"u":"/docs/adr/index.html#writing-a-new-adr","d":"Architecture Decision Records","k":"Architecture Decision Records","t":"Writing a new ADR","x":"Copy the structure of an existing record: Status (Proposed / Accepted / Superseded, date and link when superseding), Context (the forces and the problem), Decision (what we…"},{"u":"/docs/adr/001-manual-dto-mapping.html","d":"ADR-001: Manual DTO Mapping over AutoMapper","k":"Architecture Decision Records"},{"u":"/docs/adr/001-manual-dto-mapping.html#status","d":"ADR-001: Manual DTO Mapping over AutoMapper","k":"Architecture Decision Records","t":"Status","x":"Accepted. Mechanism clarified 2026-06-26: the per-entity mappers are Riok.Mapperly source-generated (compile-time), not hand-written line by line. The decision to avoid runtime…"},{"u":"/docs/adr/001-manual-dto-mapping.html#context","d":"ADR-001: Manual DTO Mapping over AutoMapper","k":"Architecture Decision Records","t":"Context","x":"Domain entities must be mapped to DTOs for API responses. The two common approaches are: 1. Manual mapping classes (IEntityDTOMapper ) 2. Convention-based reflection mapping…","i":"IEntityDTOMapper TEntity TDTO TId"},{"u":"/docs/adr/001-manual-dto-mapping.html#decision","d":"ADR-001: Manual DTO Mapping over AutoMapper","k":"Architecture Decision Records","t":"Decision","x":"Use explicit, per-entity DTO mappers (each a Riok.Mapperly [Mapper] partial class whose MapToDTO body is source-generated at compile time) registered via Scrutor assembly…","i":"IEntityRequestMapper IEntityDTOMapper SpeakerDTOMapper TIdentifierType TCreateRequest UserMapping TEntityDTO MapToDTOs UseMapper MapToDTO partial TEntity"},{"u":"/docs/adr/001-manual-dto-mapping.html#rationale","d":"ADR-001: Manual DTO Mapping over AutoMapper","k":"Architecture Decision Records","t":"Rationale","x":"- Compile-time safety: Mapping errors surface at build time, not runtime. Property renames break the build rather than silently mapping null. - Testability: Each mapper is a…","i":"SpeakerDTOMapper MapToDTO null"},{"u":"/docs/adr/001-manual-dto-mapping.html#trade-offs","d":"ADR-001: Manual DTO Mapping over AutoMapper","k":"Architecture Decision Records","t":"Trade-offs","x":"- More files (31 DTO mappers across Store + ADC: 20 in ADC, 11 in Store, plus the parallel IEntityRequestMapper classes). The interface's default MapToDTOs implementation is…","i":"IEntityRequestMapper MapToDTOs"},{"u":"/docs/adr/002-navigation-populators.html","d":"ADR-002: NavigationPopulators for Cross-Container Loading","k":"Architecture Decision Records"},{"u":"/docs/adr/002-navigation-populators.html#status","d":"ADR-002: NavigationPopulators for Cross-Container Loading","k":"Architecture Decision Records","t":"Status","x":"Accepted"},{"u":"/docs/adr/002-navigation-populators.html#context","d":"ADR-002: NavigationPopulators for Cross-Container Loading","k":"Architecture Decision Records","t":"Context","x":"The application supports multiple database backends (SQL Server, Cosmos DB, SQLite). EF Core's .Include() works for SQL Server but fails for Cosmos DB cross-container…","i":"IDataSourceService.HaveIncludeSupport NavigationMetadataProvider declaringType IsCollection Navigation targetType Include"},{"u":"/docs/adr/002-navigation-populators.html#decision","d":"ADR-002: NavigationPopulators for Cross-Container Loading","k":"Architecture Decision Records","t":"Decision","x":"Each entity that has unsupported navigations gets a INavigationPopulator implementation. A DeclarativeNavigationPopulator base class (added in MMCA.Common) allows populators to…","i":"DeclarativeNavigationPopulator ChildNavigationDescriptor FKNavigationDescriptor INavigationDescriptor INavigationPopulator NavigationLoader Product.Category Event.Rooms TEntity WHERE"},{"u":"/docs/adr/002-navigation-populators.html#rationale","d":"ADR-002: NavigationPopulators for Cross-Container Loading","k":"Architecture Decision Records","t":"Rationale","x":"- Multi-DB support: The query pipeline automatically falls back from Include to NavigationPopulator when the data source reports navigations as unsupported. - Batch efficiency:…","i":"DeclarativeNavigationPopulator"},{"u":"/docs/adr/002-navigation-populators.html#trade-offs","d":"ADR-002: NavigationPopulators for Cross-Container Loading","k":"Architecture Decision Records","t":"Trade-offs","x":"- Extra abstraction layer for SQL Server (where Include works fine). Mitigated: the populator is only called when the query pipeline's metadata says navigations are unsupported.…","i":"NullNavigationPopulator"},{"u":"/docs/adr/003-outbox-dual-dispatch.html","d":"ADR-003: Outbox Pattern with Dual Dispatch","k":"Architecture Decision Records"},{"u":"/docs/adr/003-outbox-dual-dispatch.html#status","d":"ADR-003: Outbox Pattern with Dual Dispatch","k":"Architecture Decision Records","t":"Status","x":"Accepted. Revised 2026-07-19 (integration-event routing via IMessageBus, lease-based claims for safe scale-out, dead-letter visibility, post-commit dispatch; see Revision below).…","i":"IMessageBus"},{"u":"/docs/adr/003-outbox-dual-dispatch.html#context","d":"ADR-003: Outbox Pattern with Dual Dispatch","k":"Architecture Decision Records","t":"Context","x":"Domain events must be reliably published after aggregate changes are persisted. Two failure modes exist: 1. In-process dispatch fails (e.g., handler throws): the event is lost if…"},{"u":"/docs/adr/003-outbox-dual-dispatch.html#decision","d":"ADR-003: Outbox Pattern with Dual Dispatch","k":"Architecture Decision Records","t":"Decision","x":"Use a dual-dispatch strategy: 1. Outbox persistence: Domain events are serialized into OutboxMessage rows within the same database transaction as the aggregate changes. This…","i":"DomainEventDispatcher BackgroundService SaveChangesAsync OutboxProcessor OutboxMessage"},{"u":"/docs/adr/003-outbox-dual-dispatch.html#rationale","d":"ADR-003: Outbox Pattern with Dual Dispatch","k":"Architecture Decision Records","t":"Rationale","x":"- Guaranteed delivery: The outbox table is written atomically with the aggregate changes. Even if the process crashes after persistence, the background processor catches up. -…","i":"OutboxPollFilterProcessor ProcessingDelaySeconds BrokerMessageBus OutboxProcessor BrokerEventBus IMessageBus OutboxPoll"},{"u":"/docs/adr/003-outbox-dual-dispatch.html#trade-offs","d":"ADR-003: Outbox Pattern with Dual Dispatch","k":"Architecture Decision Records","t":"Trade-offs","x":"- Domain event handlers must be idempotent (this is a good practice regardless). - The outbox table grows until processed entries are cleaned up: OutboxCleanupService purges rows…","i":"OutboxCleanupService HasMoreEligibleWork ProcessedOn MaxRetries RetryCount"},{"u":"/docs/adr/003-outbox-dual-dispatch.html#revision-2026-07-19","d":"ADR-003: Outbox Pattern with Dual Dispatch","k":"Architecture Decision Records","t":"Revision (2026-07-19)","x":"Four changes from the 2026-07-19 full review: 1. Integration events route through the outbox to IMessageBus, never local dispatch. An IIntegrationEvent raised via AddDomainEvent…","i":"DomainEventSaveChangesInterceptor outbox.dead_letter.count OutboxCleanupService ExecuteUpdateAsync IIntegrationEvent type_unresolvable integrationEvent OutboxProcessor AddDomainEvent OutboxMessage IMessageBus LockedUntil"},{"u":"/docs/adr/003-outbox-dual-dispatch.html#revision-2026-07-24","d":"ADR-003: Outbox Pattern with Dual Dispatch","k":"Architecture Decision Records","t":"Revision (2026-07-24)","x":"Three capture-side corrections found in a code review. None change the dual-dispatch decision; they close gaps between what it promised and what the interceptor did. 1. Capture…","i":"ExecuteInTransactionAsync RemoveDomainEvents IAggregateRoot SavingChanges RetryCount DbContext LastError catch"},{"u":"/docs/adr/003-outbox-dual-dispatch.html#revision-2026-08-01","d":"ADR-003: Outbox Pattern with Dual Dispatch","k":"Architecture Decision Records","t":"Revision (2026-08-01)","x":"One retry-pacing correction. The dual-dispatch decision is unchanged; the Trade-offs above described a cadence the processor no longer has. 1. Retry backoff is explicit, and it…","i":"RetryBackoffBaseSeconds"},{"u":"/docs/adr/003-outbox-dual-dispatch.html#revision-2026-08-07","d":"ADR-003: Outbox Pattern with Dual Dispatch","k":"Architecture Decision Records","t":"Revision (2026-08-07)","x":"One retry-pacing refinement. The decision and the curve are unchanged; the waits are no longer identical across a batch. 1. The retry backoff carries random jitter. The…"},{"u":"/docs/adr/004-authentication-dual-fetch.html","d":"ADR-004: Cross-Service Token Validation via JWKS / OIDC Discovery","k":"Architecture Decision Records"},{"u":"/docs/adr/004-authentication-dual-fetch.html#status","d":"ADR-004: Cross-Service Token Validation via JWKS / OIDC Discovery","k":"Architecture Decision Records","t":"Status","x":"Accepted."},{"u":"/docs/adr/004-authentication-dual-fetch.html#context","d":"ADR-004: Cross-Service Token Validation via JWKS / OIDC Discovery","k":"Architecture Decision Records","t":"Context","x":"When the modular monolith is extracted into per-module service hosts behind a gateway (ADR-008), every service must authenticate the same end-user JWT, but only one service…"},{"u":"/docs/adr/004-authentication-dual-fetch.html#decision","d":"ADR-004: Cross-Service Token Validation via JWKS / OIDC Discovery","k":"Architecture Decision Records","t":"Decision","x":"Validate cross-service tokens with asymmetric (RS256) signatures plus JWKS / OIDC discovery, keeping the symmetric (HS256) shared-secret path as the in-process monolith default.…","i":"TokenValidationParameters.ValidAlgorithms id_token_signing_alg_values_supported OpenIdConnectMetadataWarmupTask JwtSettings.SigningAlgorithm BuildValidationParameters MapOidcDiscoveryEndpoint response_types_supported AddCommonAuthentication subject_types_supported AddForwardedJwtBearer WithJwksDiscovery RsaPublicKeyPath"},{"u":"/docs/adr/004-authentication-dual-fetch.html#rationale","d":"ADR-004: Cross-Service Token Validation via JWKS / OIDC Discovery","k":"Architecture Decision Records","t":"Rationale","x":"- No shared signing key. Only Identity can mint tokens; every other service holds only the public key it fetched, so a compromised non-Identity service cannot forge tokens, and…"},{"u":"/docs/adr/004-authentication-dual-fetch.html#trade-offs","d":"ADR-004: Cross-Service Token Validation via JWKS / OIDC Discovery","k":"Architecture Decision Records","t":"Trade-offs","x":"- More moving parts than a shared secret. RS256 needs key generation, distribution of the public half, a JWKS endpoint, and discovery wiring, versus one symmetric string. -…"},{"u":"/docs/adr/004-authentication-dual-fetch.html#related","d":"ADR-004: Cross-Service Token Validation via JWKS / OIDC Discovery","k":"Architecture Decision Records","t":"Related","x":"ADR-007 (gRPC calls forward the validated JWT downstream via JwtForwardingClientInterceptor), ADR-008 (the extraction that split issuer and validator into separate processes),…","i":"JwtForwardingClientInterceptor"},{"u":"/docs/adr/005-soft-delete-vs-erasure.html","d":"ADR-005: Soft-Delete vs. Right-to-Erasure","k":"Architecture Decision Records"},{"u":"/docs/adr/005-soft-delete-vs-erasure.html#status","d":"ADR-005: Soft-Delete vs. Right-to-Erasure","k":"Architecture Decision Records","t":"Status","x":"Accepted. Updated 2026-06-27 (documented the [Pii] → IAnonymizable build-time guard and PiiRedactor).","i":"IAnonymizable PiiRedactor Pii"},{"u":"/docs/adr/005-soft-delete-vs-erasure.html#context","d":"ADR-005: Soft-Delete vs. Right-to-Erasure","k":"Architecture Decision Records","t":"Context","x":"The framework's default deletion model is soft-delete: AuditableBaseEntity.Delete() sets IsDeleted = true and EF Core global query filters exclude the row from normal queries.…","i":"AuditableBaseEntity.Delete OutboxMessage IsDeleted true"},{"u":"/docs/adr/005-soft-delete-vs-erasure.html#decision","d":"ADR-005: Soft-Delete vs. Right-to-Erasure","k":"Architecture Decision Records","t":"Decision","x":"Separate the two concerns and provide an extension point for each, rather than overloading soft-delete: 1. Soft-delete stays the default for lifecycle/state management (hide +…","i":"MMCA.Common.Domain.Attributes.PiiAttribute MMCA.Common.Domain.Interfaces MMCA.Common.Domain.Privacy EncryptedStringConverter PiiConventionTestsBase OutboxCleanupService IAnonymizable PiiRedactor Anonymize Result User Pii"},{"u":"/docs/adr/005-soft-delete-vs-erasure.html#rationale","d":"ADR-005: Soft-Delete vs. Right-to-Erasure","k":"Architecture Decision Records","t":"Rationale","x":"- Right tool per concern: soft-delete answers \"is this record active?\"; erasure answers \"has this person's data been removed?\". Conflating them (e.g. hard-deleting inside…","i":"Delete"},{"u":"/docs/adr/005-soft-delete-vs-erasure.html#trade-offs","d":"ADR-005: Soft-Delete vs. Right-to-Erasure","k":"Architecture Decision Records","t":"Trade-offs","x":"- Erasure is opt-in per entity, but the opt-in is guarded: an aggregate exposing a [Pii]-marked property that does not implement IAnonymizable fails the architecture fitness…","i":"IAnonymizable Pii"},{"u":"/docs/adr/006-database-per-service.html","d":"ADR-006: Database per Service","k":"Architecture Decision Records"},{"u":"/docs/adr/006-database-per-service.html#status","d":"ADR-006: Database per Service","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-07). Supersedes the earlier \"deliberately one shared database\" stance. Clarified 2026-06-27: the single context class became one sealed context class per engine…","i":"Name"},{"u":"/docs/adr/006-database-per-service.html#context","d":"ADR-006: Database per Service","k":"Architecture Decision Records","t":"Context","x":"When the modules were first extracted into independently-deployable services, all services in an app still pointed at a single shared SQL database with a single OutboxMessages…","i":"CrossDataSourceDegradeConvention EntityDataSourceRegistry DataSourceResolver DbContextFactory OutboxProcessor OutboxMessages"},{"u":"/docs/adr/006-database-per-service.html#decision","d":"ADR-006: Database per Service","k":"Architecture Decision Records","t":"Decision","x":"Adopt database-per-service: each service owns its own physical database with its own OutboxMessages table. - One sealed concrete context class per engine, one instance per…","i":"CrossDataSourceDegradeConvention PhysicalDbContextFactory ApplicationDbContext INavigationPopulator DataSourceResolver SQLServerDbContext ADC_Notification CosmosDbContext OutboxProcessor SqliteDbContext ADC_Conference ADC_Engagement"},{"u":"/docs/adr/006-database-per-service.html#rationale","d":"ADR-006: Database per Service","k":"Architecture Decision Records","t":"Rationale","x":"- Removes the shared-outbox race (the sharpest cost of the shared DB) without an OriginService filter: physical isolation is simpler and stronger than a logical filter. - Real…","i":"OriginService"},{"u":"/docs/adr/006-database-per-service.html#trade-offs","d":"ADR-006: Database per Service","k":"Architecture Decision Records","t":"Trade-offs","x":"- No cross-database FKs or transactions. Relationships that span services degrade to scalar IDs; consistency across services is eventual (outbox + broker), not transactional. -…"},{"u":"/docs/adr/007-grpc-extraction.html","d":"ADR-007: Synchronous Cross-Service Calls via gRPC Contracts","k":"Architecture Decision Records"},{"u":"/docs/adr/007-grpc-extraction.html#status","d":"ADR-007: Synchronous Cross-Service Calls via gRPC Contracts","k":"Architecture Decision Records","t":"Status","x":"Accepted. Revised 2026-08-23 (the [ServiceContract] marker now has a dedicated fitness rule behind it, ServiceContractPurityTestsBase, subclassed in all four repos; it is a…","i":"ServiceContractPurityTestsBase ServiceContract"},{"u":"/docs/adr/007-grpc-extraction.html#context","d":"ADR-007: Synchronous Cross-Service Calls via gRPC Contracts","k":"Architecture Decision Records","t":"Context","x":"Once modules became separate service processes, the in-process interface calls between them (e.g. Conference → Engagement's IBookmarkCountService, Engagement → Conference's…","i":"ISessionBookmarkValidationService IBookmarkCountService Result"},{"u":"/docs/adr/007-grpc-extraction.html#decision","d":"ADR-007: Synchronous Cross-Service Calls via gRPC Contracts","k":"Architecture Decision Records","t":"Decision","x":"Use gRPC, exposed through MMCA.Common.Grpc, with a contract-package convention: - .Contracts projects hold the .proto definitions plus a gRPC adapter that implements the same…","i":"SessionBookmarkValidationServiceGrpcAdapter GrpcResultExceptionInterceptor JwtForwardingClientInterceptor Directory.Build.props AddTypedGrpcClient SocketsHttpHandler MMCA.Common.Grpc HandleFailure IReadOnlyList RpcException serviceName Contracts"},{"u":"/docs/adr/007-grpc-extraction.html#rationale","d":"ADR-007: Synchronous Cross-Service Calls via gRPC Contracts","k":"Architecture Decision Records","t":"Rationale","x":"- No business-logic rewrite: the gRPC adapter implements the interface modules already depend on; swapping in-process for cross-process is a registration change. - Transport…","i":"MicroserviceExtractionTests ServiceContract MassTransit version proto"},{"u":"/docs/adr/007-grpc-extraction.html#trade-offs","d":"ADR-007: Synchronous Cross-Service Calls via gRPC Contracts","k":"Architecture Decision Records","t":"Trade-offs","x":"- Bidirectional pairs need care. Conference ↔ Engagement is a mutual gRPC pair; the AppHost deliberately omits a reciprocal WaitFor to avoid a startup deadlock: transient \"peer…","i":"Http1AndHttp2 WaitFor Http2 grpc"},{"u":"/docs/adr/008-service-extraction-topology.html","d":"ADR-008: Extraction of the Modular Monolith into Per-Module Services + Gateway","k":"Architecture Decision Records"},{"u":"/docs/adr/008-service-extraction-topology.html#status","d":"ADR-008: Extraction of the Modular Monolith into Per-Module Services + Gateway","k":"Architecture Decision Records","t":"Status","x":"Accepted. Amended by ADR-089 (2026-08-18): the Gateway keeps the route-to-service map this record gave it, but stops expressing it as MapForwarder calls in code. YARP…","i":"MapForwarder ReverseProxy"},{"u":"/docs/adr/008-service-extraction-topology.html#context","d":"ADR-008: Extraction of the Modular Monolith into Per-Module Services + Gateway","k":"Architecture Decision Records","t":"Context","x":"ADC began as a modular monolith: one MMCA.ADC.WebAPI host loaded every module (Identity, Conference, Engagement, Notification) in-process via the ModuleLoader, sharing one…","i":"MMCA.ADC.WebAPI ModuleLoader"},{"u":"/docs/adr/008-service-extraction-topology.html#decision","d":"ADR-008: Extraction of the Modular Monolith into Per-Module Services + Gateway","k":"Architecture Decision Records","t":"Decision","x":"Extract one service host per module: MMCA.ADC.{Identity,Conference,Engagement,Notification}.Service and front them with a single YARP reverse-proxy Gateway (MMCA.ADC.Gateway,…","i":"MicroserviceExtractionTests MMCA.ADC.Gateway MMCA.ADC.WebAPI ModuleLoader Notification Conference Engagement Identity MMCA.ADC Modules Service Module"},{"u":"/docs/adr/008-service-extraction-topology.html#rationale","d":"ADR-008: Extraction of the Modular Monolith into Per-Module Services + Gateway","k":"Architecture Decision Records","t":"Rationale","x":"- No business-logic rewrite. Because a service is just the monolith with one module enabled, extraction was a hosting/wiring change, not a domain change, and the module-isolation…","i":"ModuleLoader"},{"u":"/docs/adr/008-service-extraction-topology.html#trade-offs","d":"ADR-008: Extraction of the Modular Monolith into Per-Module Services + Gateway","k":"Architecture Decision Records","t":"Trade-offs","x":"- Operational complexity. Four deployables plus a Gateway, service discovery, a broker, and per-service databases, versus one process. Mitigated locally by Aspire orchestration…","i":"MMCA.Common.API ServiceDefaults Http1AndHttp2 Http2"},{"u":"/docs/adr/008-service-extraction-topology.html#applicability","d":"ADR-008: Extraction of the Modular Monolith into Per-Module Services + Gateway","k":"Architecture Decision Records","t":"Applicability","x":"This ADR is framed around ADC (the first repo extracted), but the same topology is now the framework's standard extraction shape, not an ADC-only choice. MMCA.Store followed it:…","i":"MMCA.Store.Gateway MMCA.Store.WebAPI MMCA.Store Identity Catalog Service Sales"},{"u":"/docs/adr/008-service-extraction-topology.html#related","d":"ADR-008: Extraction of the Modular Monolith into Per-Module Services + Gateway","k":"Architecture Decision Records","t":"Related","x":"ADR-003 (outbox dual dispatch), ADR-004 (cross-service token validation via JWKS), ADR-006 (database per service), and ADR-007 (gRPC cross-service calls) are the facet decisions…"},{"u":"/docs/adr/009-resilience-and-recovery-objectives.html","d":"ADR-009: Resilience Policies & Recovery Objectives","k":"Architecture Decision Records"},{"u":"/docs/adr/009-resilience-and-recovery-objectives.html#status","d":"ADR-009: Resilience Policies & Recovery Objectives","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-14). Amended by ADR-087 (2026-08-18): the resilience objective extends past outbound HTTP and gRPC clients for the first time, to the outbox's broker publish,…"},{"u":"/docs/adr/009-resilience-and-recovery-objectives.html#context","d":"ADR-009: Resilience Policies & Recovery Objectives","k":"Architecture Decision Records","t":"Context","x":"The framework already supplies the mechanisms for surviving partial failure: a standard Polly resilience handler (timeout / retry / circuit breaker), the outbox for at-least-once…","i":"ConfigureHttpClientDefaults AddTypedServiceClient AddTypedGrpcClient MMCA.Common.Aspire"},{"u":"/docs/adr/009-resilience-and-recovery-objectives.html#decision","d":"ADR-009: Resilience Policies & Recovery Objectives","k":"Architecture Decision Records","t":"Decision","x":"1. Resilience is a framework invariant, not a per-call choice. Every outbound HttpClient and gRPC client registered through the framework's extension methods (AddTypedGrpcClient,…","i":"MMCA.Common.Grpc.Tests ResilienceHandlerTests AddTypedServiceClient AddTypedGrpcClient MMCA.Common.Aspire HttpClient"},{"u":"/docs/adr/009-resilience-and-recovery-objectives.html#rationale","d":"ADR-009: Resilience Policies & Recovery Objectives","k":"Architecture Decision Records","t":"Rationale","x":"- Invariant over discipline. A fitness function turns \"remember to add resilience\" into a build gate: the same approach the framework already uses for the layer rules and the…"},{"u":"/docs/adr/009-resilience-and-recovery-objectives.html#trade-offs","d":"ADR-009: Resilience Policies & Recovery Objectives","k":"Architecture Decision Records","t":"Trade-offs","x":"- The named gate (ResilienceHandlerTests, MMCA.Common.Grpc.Tests) asserts that the gRPC client path (AddTypedGrpcClient) registers the standard handler, not the runtime behavior…","i":"ResilienceCircuitBreakerFaultInjectionTests MMCA.Common.Grpc.Tests ResilienceHandlerTests AddTypedServiceClient AddTypedGrpcClient"},{"u":"/docs/adr/009-resilience-and-recovery-objectives.html#revision-2026-08-18","d":"ADR-009: Resilience Policies & Recovery Objectives","k":"Architecture Decision Records","t":"Revision (2026-08-18)","x":"This record's first Decision point scoped resilience to \"every outbound HttpClient and gRPC client registered through the framework's extension methods\". That scope was accurate…","i":"BrokerResilienceDefaults BrokenCircuitException HttpResilienceDefaults CommandTimeoutSeconds EnableRetryOnFailure ResiliencePipeline DbContextFactory OutboxProcessor HttpClient"},{"u":"/docs/adr/010-integration-event-schema-versioning.html","d":"ADR-010: Integration-Event Schema Versioning & Upcaster Policy","k":"Architecture Decision Records"},{"u":"/docs/adr/010-integration-event-schema-versioning.html#status","d":"ADR-010: Integration-Event Schema Versioning & Upcaster Policy","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-19). Updated 2026-06-27 (Helpdesk enforcement gap closed; all three consumers now gate the convention). Updated 2026-08-14 (ADC now gates seven events, and a…","i":"OutputCacheEvictionRequested"},{"u":"/docs/adr/010-integration-event-schema-versioning.html#context","d":"ADR-010: Integration-Event Schema Versioning & Upcaster Policy","k":"Architecture Decision Records","t":"Context","x":"Integration events cross service boundaries (Identity → Conference, Conference ↔ Engagement, …) and are resolved by consumers solely by their type string: the outbox serializes…","i":"OutboxMessage.FromDomainEvent DateOccurred EventType MessageId"},{"u":"/docs/adr/010-integration-event-schema-versioning.html#decision","d":"ADR-010: Integration-Event Schema Versioning & Upcaster Policy","k":"Architecture Decision Records","t":"Decision","x":"1. Every integration event carries an explicit SchemaVersion. BaseIntegrationEvent exposes public virtual int SchemaVersion = 1;. It is serialized with the payload…","i":"MMCA.Common.Testing.Architecture EventConventionTestsBase BaseIntegrationEvent IIntegrationEvent UserRegisteredV2 SchemaVersion virtual public int"},{"u":"/docs/adr/010-integration-event-schema-versioning.html#rationale","d":"ADR-010: Integration-Event Schema Versioning & Upcaster Policy","k":"Architecture Decision Records","t":"Rationale","x":"- A signal, enforced. A version field plus a build-gating convention test turns \"remember the contract\" into something the tooling checks: the same invariant-over-discipline…","i":"virtual"},{"u":"/docs/adr/010-integration-event-schema-versioning.html#trade-offs","d":"ADR-010: Integration-Event Schema Versioning & Upcaster Policy","k":"Architecture Decision Records","t":"Trade-offs","x":"- SchemaVersion is a signal, not a mechanism: by itself it does not stop a consumer breaking on a real reshape. The load-bearing half is the discipline (new type + upcaster). At…","i":"RegisterUpcastedIntegrationEventConsumer MMCA.Helpdesk.Architecture.Tests EventVersioningConventionTests ProductCreatedIntegrationEvent MMCA.Store.Architecture.Tests OutputCacheEvictionRequested TicketOpenedIntegrationEvent MMCA.ADC.Architecture.Tests OrderPlacedIntegrationEvent EventConventionTestsBase CommonArchitectureMap map.ModuleNames.Count"},{"u":"/docs/adr/011-single-locale-i18n.html","d":"ADR-011: Single-Locale by Design (No Internationalization)","k":"Architecture Decision Records"},{"u":"/docs/adr/011-single-locale-i18n.html#status","d":"ADR-011: Single-Locale by Design (No Internationalization)","k":"Architecture Decision Records","t":"Status","x":"Superseded by ADR-027 (2026-06-27). Originally Accepted (2026-06-19). The \"if multi-locale is ever required\" scope below is the blueprint ADR-027 implements; this record is…"},{"u":"/docs/adr/011-single-locale-i18n.html#context","d":"ADR-011: Single-Locale by Design (No Internationalization)","k":"Architecture Decision Records","t":"Context","x":"The MMCA applications (the ADC conference app, the Store) and the MMCA.Common.UI library currently ship a single locale (en-US). The architecture rubric scores…","i":"MMCA.Common.UI"},{"u":"/docs/adr/011-single-locale-i18n.html#decision","d":"ADR-011: Single-Locale by Design (No Internationalization)","k":"Architecture Decision Records","t":"Decision","x":"1. Single-locale (en-US) is an explicit non-goal for now. User-facing strings are inline in markup; dates/numbers use invariant or fixed formatting where appropriate. 2. The…","i":"RequestLocalization"},{"u":"/docs/adr/011-single-locale-i18n.html#rationale","d":"ADR-011: Single-Locale by Design (No Internationalization)","k":"Architecture Decision Records","t":"Rationale","x":"- Recording the decision converts an implicit rubric-zero into a conscious, revisitable choice: the same posture as the single-region DR acceptance in ADR-009. - Premature i18n…"},{"u":"/docs/adr/011-single-locale-i18n.html#trade-offs","d":"ADR-011: Single-Locale by Design (No Internationalization)","k":"Architecture Decision Records","t":"Trade-offs","x":"- Adding a locale later touches every view plus the formatting paths: a real but bounded effort, accepted. - Hard-coded strings make a future extraction larger; mitigated by the…"},{"u":"/docs/adr/012-grpc-host-transport.html","d":"ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)","k":"Architecture Decision Records"},{"u":"/docs/adr/012-grpc-host-transport.html#status","d":"ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)","k":"Architecture Decision Records","t":"Status","x":"Accepted (re-verified against source 2026-08-14)."},{"u":"/docs/adr/012-grpc-host-transport.html#update-2026-06-22-store-converged-to-profile-a","d":"ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)","k":"Architecture Decision Records","t":"Update (2026-06-22): Store converged to Profile A","x":"Store originally chose Profile B, but its cross-service gRPC failed in Azure Container Apps. With Http1AndHttp2 Kestrel + transport: 'auto' ingress on a cleartext endpoint there…","i":"IProductVariantService.ExistsAsync IUserSalesExportService HTTP_1_1_REQUIRED WithJwksDiscovery AddItemCommand Http1AndHttp2 transport identity gateway httpGet Http2"},{"u":"/docs/adr/012-grpc-host-transport.html#update-2026-07-09-adc-notification-adds-a-mixed-endpoint-profile-per-endpoint-protocols","d":"ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)","k":"Architecture Decision Records","t":"Update (2026-07-09): ADC Notification adds a mixed-endpoint profile (per-endpoint protocols)","x":"The live-channel push pipeline (ADR-039) gave ADC's Notification service an inbound cleartext gRPC server (LiveChannelPushService.PushToChannel, called best-effort by Engagement…","i":"LiveChannelPushService.PushToChannel engagementService.WithReference services__notification__grpc__0 appsettings.Development.json additionalPortMappings notificationService Http1AndHttp2 httpGet WaitFor Http2 grpc http"},{"u":"/docs/adr/012-grpc-host-transport.html#update-2026-07-25-probe-listeners-are-adcs-answer-not-tcp-probes-and-gateway-routed-jwks-is-a-local-only-rule","d":"ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)","k":"Architecture Decision Records","t":"Update (2026-07-25): probe listeners are ADC's answer, not TCP probes; and gateway-routed JWKS is a local-only rule","x":"Two claims above were written from an earlier state of the code and no longer describe either app. 1. ADC probes never touch the traffic endpoint; TCP probes were then…","i":"HTTP_1_1_REQUIRED WithJwksDiscovery identityApp.name Program.cs tcpSocket transport identity gateway httpGet Http1 grpc"},{"u":"/docs/adr/012-grpc-host-transport.html#update-2026-07-28-the-probe-listener-is-the-single-pattern-in-both-apps-no-tcp-probes-anywhere","d":"ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)","k":"Architecture Decision Records","t":"Update (2026-07-28): the probe listener is the single pattern in both apps (no TCP probes anywhere)","x":"Store PR 55 (commit 297064bb, merged 2026-07-27) ported ADC's dedicated probe listener to Store, so the Store-only tcpSocket exception recorded in the 2026-07-25 update above is…","i":"HealthProbe__Port Http1AndHttp2 tcpSocket httpGet Http1 Http2"},{"u":"/docs/adr/012-grpc-host-transport.html#update-2026-08-07-the-probe-listener-moved-into-mmcacommon-and-notifications-grpc-endpoint-carries-a-second-service","d":"ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)","k":"Architecture Decision Records","t":"Update (2026-08-07): the probe listener moved into MMCA.Common, and Notification's gRPC endpoint carries a second service","x":"1. One shared framework method, not a per-service file. The KestrelConfiguration.cs copies the two updates above cite no longer exist in either app. The pattern was extracted…","i":"UserNotificationExportGrpcService MMCA.ADC.Notification.Contracts services__notification__grpc__0 identityService.WithReference appsettings.Development.json HttpProtocols.Http1AndHttp2 redeclareCleartextEndpoint ConfigureEndpointDefaults KestrelConfiguration.cs additionalPortMappings ASPNETCORE_ENVIRONMENT LiveChannelGrpcService"},{"u":"/docs/adr/012-grpc-host-transport.html#update-2026-08-14-stores-sales-runs-the-mixed-endpoint-profile-too-so-no-pure-profile-b-host-remains","d":"ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)","k":"Architecture Decision Records","t":"Update (2026-08-14): Store's Sales runs the mixed-endpoint profile too, so no pure Profile B host remains","x":"Sales gained an inbound gRPC edge of its own (IUserSalesExportService, the Identity-driven data-subject export), and it resolved that the same way ADC's Notification did: not by…","i":"identityService.WithReference appsettings.Development.json UserSalesExportGrpcService AddSalesUserExportClient services__sales__grpc__0 IUserSalesExportService additionalPortMappings RequireAuthorization HealthProbe__Port Http1AndHttp2 salesService _grpc.sales"},{"u":"/docs/adr/012-grpc-host-transport.html#context","d":"ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)","k":"Architecture Decision Records","t":"Context","x":"Once modules were extracted into separate service hosts (ADR-008) that call each other synchronously over gRPC (ADR-007), each service's Kestrel had to serve both REST traffic…","i":"HTTP_1_1_REQUIRED Http1AndHttp2 HttpClient Http2"},{"u":"/docs/adr/012-grpc-host-transport.html#decision","d":"ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)","k":"Architecture Decision Records","t":"Decision","x":"Pick one of two coherent transport profiles per app, and wire the gateway forwarder and JWKS discovery to match. Use when services must serve gRPC on cleartext (any bidirectional…","i":"builder.ConfigureEndpointsWithHealthProbe UserNotificationExportGrpcService HttpProtocols.Http1AndHttp2 ConfigureEndpointDefaults LiveChannelGrpcService HttpVersion.Version20 RequestVersionOrLower HttpProtocols.Http2 RequestVersionExact HTTP_1_1_REQUIRED WithJwksDiscovery Http1AndHttp2"},{"u":"/docs/adr/012-grpc-host-transport.html#rationale","d":"ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)","k":"Architecture Decision Records","t":"Rationale","x":"- The Kestrel protocol choice is the root constraint; the gateway-forward mode and the JWKS authority are downstream consequences, not independent knobs. Documenting them as a…","i":"HTTP_1_1_REQUIRED Http2"},{"u":"/docs/adr/012-grpc-host-transport.html#trade-offs","d":"ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)","k":"Architecture Decision Records","t":"Trade-offs","x":"- Two profiles to keep straight. A service that gains an inbound gRPC edge must migrate from Profile B to Profile A and flip ForwardHttp2 and the JWKS wiring together, or it…","i":"appsettings.Development.json additionalPortMappings appsettings.json Http1AndHttp2 ForwardHttp2 transport http2"},{"u":"/docs/adr/012-grpc-host-transport.html#related","d":"ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)","k":"Architecture Decision Records","t":"Related","x":"- ADR-004 (cross-service token validation via JWKS / OIDC discovery), ADR-007 (gRPC cross-service calls), ADR-008 (monolith → services + gateway topology), ADR-039 (live-channel…"},{"u":"/docs/adr/013-result-pattern.html","d":"ADR-013: Result Pattern over Exceptions for Flow Control","k":"Architecture Decision Records"},{"u":"/docs/adr/013-result-pattern.html#status","d":"ADR-013: Result Pattern over Exceptions for Flow Control","k":"Architecture Decision Records","t":"Status","x":"Accepted. Revised 2026-07-21 (exception-handler chain / ProblemDetails edge contract documented)."},{"u":"/docs/adr/013-result-pattern.html#context","d":"ADR-013: Result Pattern over Exceptions for Flow Control","k":"Architecture Decision Records","t":"Context","x":"Operations at every layer fail in expected ways: input is invalid, a domain invariant is broken, a requested entity is missing, a uniqueness conflict occurs, the caller lacks…"},{"u":"/docs/adr/013-result-pattern.html#decision","d":"ADR-013: Result Pattern over Exceptions for Flow Control","k":"Architecture Decision Records","t":"Decision","x":"Model expected failures as values using Result / Result (MMCA.Common.Shared.Abstractions), not exceptions. - A Result is either success or failure; a failure carries one or more…","i":"OperationCanceledExceptionHandler ApiControllerBase.HandleFailure MMCA.Common.Shared.Abstractions GrpcResultExceptionInterceptor AddCommonExceptionHandlers OperationCanceledException ValidationExceptionHandler DbUpdateExceptionHandler DomainExceptionHandler GlobalExceptionHandler UnprocessableEntity ValidationException"},{"u":"/docs/adr/013-result-pattern.html#rationale","d":"ADR-013: Result Pattern over Exceptions for Flow Control","k":"Architecture Decision Records","t":"Rationale","x":"- Failures are in the signature. A method that can fail returns Result , so the caller cannot silently ignore the failure path the way an uncaught exception allows. - Category,…","i":"Result.Failure HandleFailure ErrorType IsFailure requestId Result"},{"u":"/docs/adr/013-result-pattern.html#trade-offs","d":"ADR-013: Result Pattern over Exceptions for Flow Control","k":"Architecture Decision Records","t":"Trade-offs","x":"- More ceremony at call sites than letting an exception bubble; the combinators absorb most of it. - Two error channels coexist (Result for expected, exceptions for exceptional).…","i":"GlobalExceptionHandler ErrorType Result"},{"u":"/docs/adr/013-result-pattern.html#related","d":"ADR-013: Result Pattern over Exceptions for Flow Control","k":"Architecture Decision Records","t":"Related","x":"ADR-007 (Result over the wire via gRPC), ADR-014 (the decorator pipeline returns Result.Failure to short-circuit a command before it reaches the handler).","i":"Result.Failure"},{"u":"/docs/adr/014-cqrs-decorator-pipeline.html","d":"ADR-014: CQRS Handlers with a Decorator Pipeline","k":"Architecture Decision Records"},{"u":"/docs/adr/014-cqrs-decorator-pipeline.html#status","d":"ADR-014: CQRS Handlers with a Decorator Pipeline","k":"Architecture Decision Records","t":"Status","x":"Accepted. Revised 2026-07-19 (Transactional semantics: rollback on business failure + post-commit event dispatch; see Revision below). Revised 2026-08-18 (the pipeline order…"},{"u":"/docs/adr/014-cqrs-decorator-pipeline.html#context","d":"ADR-014: CQRS Handlers with a Decorator Pipeline","k":"Architecture Decision Records","t":"Context","x":"Commands and queries share cross-cutting concerns: validation, transactions, cache invalidation, logging / timing, and feature gating. Putting that logic inside each handler…"},{"u":"/docs/adr/014-cqrs-decorator-pipeline.html#decision","d":"ADR-014: CQRS Handlers with a Decorator Pipeline","k":"Architecture Decision Records","t":"Decision","x":"Use single-responsibility handlers behind a Scrutor-composed decorator pipeline. - ICommandHandler and IQueryHandler (MMCA.Common.Application) are one handler per use case, each…","i":"ModuleLoader.DiscoverAndRegister ScanModuleApplicationServices ProfilingCommandDecorator AddApplicationDecorators AddApplicationProfiling MMCA.Common.Application ProfilingQueryDecorator ICacheInvalidating AddInfrastructure ICommandHandler IQueryCacheable AddApplication"},{"u":"/docs/adr/014-cqrs-decorator-pipeline.html#rationale","d":"ADR-014: CQRS Handlers with a Decorator Pipeline","k":"Architecture Decision Records","t":"Rationale","x":"- Thin, testable handlers. A handler has no transaction, logging, or caching plumbing, so it is unit-tested in isolation. - One place to read and change the pipeline. The order…"},{"u":"/docs/adr/014-cqrs-decorator-pipeline.html#trade-offs","d":"ADR-014: CQRS Handlers with a Decorator Pipeline","k":"Architecture Decision Records","t":"Trade-offs","x":"- Registration order is the reverse of execution order (a Scrutor foot-gun), mitigated by the inline ordering comments in AddApplicationDecorators(). - Decorators must be…","i":"AddApplicationDecorators"},{"u":"/docs/adr/014-cqrs-decorator-pipeline.html#revision-2026-07-19","d":"ADR-014: CQRS Handlers with a Decorator Pipeline","k":"Architecture Decision Records","t":"Revision (2026-07-19)","x":"Two Transactional-decorator semantics changed with the 2026-07-19 full review: - A returned business failure now rolls the transaction back. Previously a handler returning…","i":"DbContextFactory.ExecuteInTransactionAsync DomainEventSaveChangesInterceptor RollbackTransaction Result.Failure IsFailure Result"},{"u":"/docs/adr/014-cqrs-decorator-pipeline.html#revision-2026-08-18","d":"ADR-014: CQRS Handlers with a Decorator Pipeline","k":"Architecture Decision Records","t":"Revision (2026-08-18)","x":"Two decorators were added to both chains, so the order recorded in the Decision above is no longer the shipped one. The registration site is unchanged in kind:…","i":"CancellationTokenSource.CreateLinkedTokenSource cqrs.authorization.denied.count DecoratorPipelineOrderTestsBase AuthorizationCommandDecorator ExpectedCommandDecorators AddApplicationDecorators ExpectedQueryDecorators AuthorizationDenied ICurrentUserService IPermissionRegistry IRequiresPermission budget.CancelAfter"},{"u":"/docs/adr/014-cqrs-decorator-pipeline.html#related","d":"ADR-014: CQRS Handlers with a Decorator Pipeline","k":"Architecture Decision Records","t":"Related","x":"ADR-013 (Result, the short-circuit currency of the pipeline, and the Failure error type the timeout decorator reuses because the taxonomy has no timeout member), ADR-003…","i":"IPermissionRegistry MMCA.Common.Cqrs HasPermission SaveChanges Failure"},{"u":"/docs/adr/015-architecture-fitness-functions.html","d":"ADR-015: Architecture Invariants Enforced as Fitness Functions","k":"Architecture Decision Records"},{"u":"/docs/adr/015-architecture-fitness-functions.html#status","d":"ADR-015: Architecture Invariants Enforced as Fitness Functions","k":"Architecture Decision Records","t":"Status","x":"Accepted. Revised 2026-08-18 (two new rule families, namespace dependency cycles and trailing CancellationToken declarations, plus a third enforcement layer: a compile-time…","i":"CancellationToken proto"},{"u":"/docs/adr/015-architecture-fitness-functions.html#context","d":"ADR-015: Architecture Invariants Enforced as Fitness Functions","k":"Architecture Decision Records","t":"Context","x":"The codebase rests on invariants that are easy to state and easy to erode by accident: clean- architecture layer flow (Domain depends on nothing above it), module isolation (no…","i":"SchemaVersion"},{"u":"/docs/adr/015-architecture-fitness-functions.html#decision","d":"ADR-015: Architecture Invariants Enforced as Fitness Functions","k":"Architecture Decision Records","t":"Decision","x":"Enforce architectural invariants as automated checks that gate the build, in two layers (a third joined them on 2026-08-18: see the Revision at the end). 1. Compile-time guard.…","i":"MMCA.Common.LayerEnforcement.targets MMCA.Common.Testing.Architecture HelpdeskArchitectureMap CommonArchitectureMap StoreArchitectureMap AdcArchitectureMap IArchitectureMap ProjectReference dotnet test"},{"u":"/docs/adr/015-architecture-fitness-functions.html#rationale","d":"ADR-015: Architecture Invariants Enforced as Fitness Functions","k":"Architecture Decision Records","t":"Rationale","x":"- Invariant over discipline. Turning \"do not do X\" into a red build is the only enforcement that scales. It is the same lever used by the layer rules, the resilience gate…","i":"IArchitectureMap"},{"u":"/docs/adr/015-architecture-fitness-functions.html#trade-offs","d":"ADR-015: Architecture Invariants Enforced as Fitness Functions","k":"Architecture Decision Records","t":"Trade-offs","x":"- The tests assert structure / registration, not runtime behavior. ADR-009's test proves a client wires resilience, not that its policy values are correct; parameter tuning stays…","i":"FrameworkSanityTests IArchitectureMap"},{"u":"/docs/adr/015-architecture-fitness-functions.html#revision-2026-08-18","d":"ADR-015: Architecture Invariants Enforced as Fitness Functions","k":"Architecture Decision Records","t":"Revision (2026-08-18)","x":"Two new rule families joined the shared library, and a third enforcement layer joined the two the Decision above describes. The counts in MMCA.Common/FACTS.md move with them: 102…","i":"AsyncMethodsDeclareTrailingCancellationToken Microsoft.CodeAnalysis.PublicApiAnalyzers ArchitectureRules.CancellationTokens dotnet_analyzer_diagnostic.severity NamespacesHaveNoDependencyCycles MMCA.Common.Infrastructure Context.ConnectionAborted IHostedService.StartAsync ArchitectureRules.Cycles TenancySettingsValidator InternalAPI.Shipped.txt NamespaceCycleTestsBase"},{"u":"/docs/adr/015-architecture-fitness-functions.html#revision-2026-08-18-section-b-rule-families","d":"ADR-015: Architecture Invariants Enforced as Fitness Functions","k":"Architecture Decision Records","t":"Revision (2026-08-18): Section B rule families","x":"A second entry on the same date, kept separate rather than folded into the one above because it lands with a different wave and changes a number that revision states. Two rule…","i":"IdempotencyConventionTestsBase ArchitectureRules.Protos ProtoContractTestsBase PublicAPI.Shipped.txt FrozenProtoContracts csharp_namespace SolutionFileName FactsGenerator justification NonIdempotent Idempotent ProtoFiles"},{"u":"/docs/adr/015-architecture-fitness-functions.html#revision-2026-08-23-superseded-counts-a-re-anchored-citation-and-the-real-test-floor","d":"ADR-015: Architecture Invariants Enforced as Fitness Functions","k":"Architecture Decision Records","t":"Revision (2026-08-23): superseded counts, a re-anchored citation, and the real test floor","x":"No rule family joined or left the library in this entry. It corrects three things the two 2026-08-18 revisions above state, and it is kept as its own entry rather than edited…","i":"Microsoft.CodeAnalysis.PublicApiAnalyzers SpecificationFitnessTests MMCA.Common.UI.E2E.Tests FrameworkSanityTests FactsGenerator FACTS.md"},{"u":"/docs/adr/015-architecture-fitness-functions.html#related","d":"ADR-015: Architecture Invariants Enforced as Fitness Functions","k":"Architecture Decision Records","t":"Related","x":"ADR-009 (resilience gate), ADR-010 (event-version gate), ADR-016 (MassTransit pin gate, and the lockstep release cadence the public API baseline is pinned to), ADR-006/007/008…","i":"CancellationToken"},{"u":"/docs/adr/016-lockstep-versioning-masstransit-pin.html","d":"ADR-016: Lockstep Package Versioning and the MassTransit-v8 Pin","k":"Architecture Decision Records"},{"u":"/docs/adr/016-lockstep-versioning-masstransit-pin.html#status","d":"ADR-016: Lockstep Package Versioning and the MassTransit-v8 Pin","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-15). Amended (2026-07-28): the fitness function now gates two commercial-license majors (MassTransit and SixLabors.ImageSharp), so the decision is restated as…","i":"MassTransit.Azure.ServiceBus.Core Directory.Packages.props SixLabors.ImageSharp"},{"u":"/docs/adr/016-lockstep-versioning-masstransit-pin.html#context","d":"ADR-016: Lockstep Package Versioning and the MassTransit-v8 Pin","k":"Architecture Decision Records","t":"Context","x":"MMCA.Common publishes its MMCA.Common. NuGet package set (see FACTS.md for the authoritative list and count) consumed by three downstream repos: the two production apps (Store,…","i":"Directory.Packages.props Infrastructure MassTransit MT_LICENSE FACTS.md Domain"},{"u":"/docs/adr/016-lockstep-versioning-masstransit-pin.html#decision","d":"ADR-016: Lockstep Package Versioning and the MassTransit-v8 Pin","k":"Architecture Decision Records","t":"Decision","x":"1. Version the whole MMCA.Common. package set in lockstep. All packages share one version (MinVer, derived from a single vX.Y.Z git tag); a release tags every package (see…","i":"MassTransit.Azure.ServiceBus.Core RestorePackagesWithLockFile DependencyVersionTestsBase MMCA.Common.Infrastructure Directory.Packages.props MassTransit.RabbitMQ SixLabors.ImageSharp MassTransit MT_LICENSE FACTS.md Obsolete vX.Y.Z"},{"u":"/docs/adr/016-lockstep-versioning-masstransit-pin.html#rationale","d":"ADR-016: Lockstep Package Versioning and the MassTransit-v8 Pin","k":"Architecture Decision Records","t":"Rationale","x":"- One version, one compatibility story. Lockstep removes the N-package matrix: \"everything on vX.Y.Z\" is the only supported combination, which is the right trade for a small…","i":"vX.Y.Z"},{"u":"/docs/adr/016-lockstep-versioning-masstransit-pin.html#trade-offs","d":"ADR-016: Lockstep Package Versioning and the MassTransit-v8 Pin","k":"Architecture Decision Records","t":"Trade-offs","x":"- A consumer cannot adopt a single package in isolation: it takes the whole set at the new version. - Lockstep will bump a package whose code did not change (acceptable: the…","i":"MassTransit.Azure.ServiceBus.Core Directory.Packages.props dependabot.yml"},{"u":"/docs/adr/016-lockstep-versioning-masstransit-pin.html#related","d":"ADR-016: Lockstep Package Versioning and the MassTransit-v8 Pin","k":"Architecture Decision Records","t":"Related","x":"ADR-015 (the fitness function that enforces the pins), ADR-003 / ADR-006 (MassTransit is the broker transport behind the outbox and database-per-service flows)."},{"u":"/docs/adr/017-request-idempotency.html","d":"ADR-017: HTTP Request Idempotency via Client-Supplied Keys","k":"Architecture Decision Records"},{"u":"/docs/adr/017-request-idempotency.html#status","d":"ADR-017: HTTP Request Idempotency via Client-Supplied Keys","k":"Architecture Decision Records","t":"Status","x":"Accepted. Revised 2026-08-01: the guard around execute-and-store is now an IDistributedLock resolved from DI (Redis-backed wherever a connection multiplexer is registered, which…","i":"IdempotencyConventionTestsBase IDistributedLock justification NonIdempotent ObjectResult Idempotent NoContent HttpPost"},{"u":"/docs/adr/017-request-idempotency.html#context","d":"ADR-017: HTTP Request Idempotency via Client-Supplied Keys","k":"Architecture Decision Records","t":"Context","x":"Write endpoints (POST / PUT / PATCH) are exposed to client retries and double-submits: a flaky network, an impatient user double-clicking, or a resilience pipeline re-issuing a…","i":"Result"},{"u":"/docs/adr/017-request-idempotency.html#decision","d":"ADR-017: HTTP Request Idempotency via Client-Supplied Keys","k":"Architecture Decision Records","t":"Decision","x":"Provide opt-in, client-driven request idempotency as an MVC action filter in MMCA.Common.API. - Opt-in per action. [Idempotent] (IdempotentAttribute, a ServiceFilterAttribute…","i":"IdempotencySettings.CacheExpirationHours InProcessDistributedLock IConnectionMultiplexer ServiceFilterAttribute KeyedSemaphoreStripe RedisDistributedLock IdempotentAttribute AddInfrastructure IdempotencyFilter IDistributedLock StatusCodeResult MMCA.Common.API"},{"u":"/docs/adr/017-request-idempotency.html#rationale","d":"ADR-017: HTTP Request Idempotency via Client-Supplied Keys","k":"Architecture Decision Records","t":"Rationale","x":"- Safety at the edge, not in every handler. Deduplication lives in one filter, so a handler stays a thin use case (ADR-014) and does not grow ad-hoc \"did I already do this?\"…","i":"Idempotent"},{"u":"/docs/adr/017-request-idempotency.html#trade-offs","d":"ADR-017: HTTP Request Idempotency via Client-Supplied Keys","k":"Architecture Decision Records","t":"Trade-offs","x":"- Cross-instance mutual exclusion follows Redis, so it is a deployment property, not a guarantee. Every ADC and Store service host registers a Redis IConnectionMultiplexer when a…","i":"IConnectionMultiplexer StatusCodeResult IAnonymizable ObjectResult Idempotent Location redis"},{"u":"/docs/adr/017-request-idempotency.html#revision-2026-08-18","d":"ADR-017: HTTP Request Idempotency via Client-Supplied Keys","k":"Architecture Decision Records","t":"Revision (2026-08-18)","x":"The last Trade-off above is the one this revision addresses, and it does so by changing what is required. Nothing here makes an endpoint idempotent. What it requires is that…","i":"PostActions_ShouldDeclare_IdempotencyIntent IdempotencyConventionTestsBase AttributeTargets.Method NonIdempotentAttribute GetCustomAttributes AuthControllerBase EnableRateLimiting MMCA.Common.API AttributeUsage justification Justification NonIdempotent"},{"u":"/docs/adr/017-request-idempotency.html#related","d":"ADR-017: HTTP Request Idempotency via Client-Supplied Keys","k":"Architecture Decision Records","t":"Related","x":"ADR-003 (handler idempotency for outbox/event consumers, a distinct concern), ADR-013 (Result is the response the filter caches/replays), ADR-014 (the filter keeps the handler…","i":"ICacheService NonIdempotent"},{"u":"/docs/adr/018-polyglot-persistence.html","d":"ADR-018: Polyglot Persistence (Multiple Storage Engines Behind One Model)","k":"Architecture Decision Records"},{"u":"/docs/adr/018-polyglot-persistence.html#status","d":"ADR-018: Polyglot Persistence (Multiple Storage Engines Behind One Model)","k":"Architecture Decision Records","t":"Status","x":"Accepted. The framework plumbing is complete, covered by unit and integration tests (DataSourceResolverTests, CrossDataSourceDegradeConventionTests, EntityTypeConfigurationTests,…","i":"CrossDataSourceDegradeConventionTests CosmosConfigurationPortabilityTests MultiSourceSqliteIntegrationTests EntityTypeConfigurationTests DataSourceResolverTests FACTS.md Session Room"},{"u":"/docs/adr/018-polyglot-persistence.html#context","d":"ADR-018: Polyglot Persistence (Multiple Storage Engines Behind One Model)","k":"Architecture Decision Records","t":"Context","x":"ADR-006 (database-per-service) splits storage along the Name axis: several physically separate databases, all on the same engine (SQL Server), one per service. A second,…","i":"DataSourceKey Engine Name"},{"u":"/docs/adr/018-polyglot-persistence.html#decision","d":"ADR-018: Polyglot Persistence (Multiple Storage Engines Behind One Model)","k":"Architecture Decision Records","t":"Decision","x":"Support three storage engines behind one entity model and one set of repository abstractions, selected per entity configuration. 1. DataSource engine enum: SQLServer (full…","i":"CrossDataSourceDegradeConvention EntityTypeConfigurationSQLServer EntityTypeConfigurationCosmos EntityTypeConfigurationSqlite SQLServerMigrationsAssembly CosmosIntIdValueGenerator SQLServerConnectionString EntityDataSourceRegistry EntityTypeConfiguration CosmosConnectionString SqliteConnectionString ApplicationDbContext"},{"u":"/docs/adr/018-polyglot-persistence.html#rationale","d":"ADR-018: Polyglot Persistence (Multiple Storage Engines Behind One Model)","k":"Architecture Decision Records","t":"Rationale","x":"- Right store per access pattern, as a configuration decision. The engine becomes an attribute on a configuration class, not a rewrite. The same domain entity, application…"},{"u":"/docs/adr/018-polyglot-persistence.html#trade-offs","d":"ADR-018: Polyglot Persistence (Multiple Storage Engines Behind One Model)","k":"Architecture Decision Records","t":"Trade-offs","x":"- No cross-engine JOINs, FKs, or transactions. This is the ADR-006 cost made sharper: across engines it is a hard limit, not a deployment choice. A query spanning engines (for…","i":"SpecificationsDoNotNavigateToOtherEntities CrossSourceSpecification specifications keys"},{"u":"/docs/adr/018-polyglot-persistence.html#related","d":"ADR-018: Polyglot Persistence (Multiple Storage Engines Behind One Model)","k":"Architecture Decision Records","t":"Related","x":"ADR-006 (database-per-service: the Name axis this ADR's Engine axis is orthogonal to; they share DataSourceKey), ADR-002 (navigation populators bridge the relationships the…","i":"DataSourceKey"},{"u":"/docs/adr/019-rate-limiting.html","d":"ADR-019: Layered Rate Limiting with an Authenticated-Only Global Limiter","k":"Architecture Decision Records"},{"u":"/docs/adr/019-rate-limiting.html#status","d":"ADR-019: Layered Rate Limiting with an Authenticated-Only Global Limiter","k":"Architecture Decision Records","t":"Status","x":"Accepted. Revised 2026-08-01 (the auth-ip per-IP anonymous-authentication limiter, which the shared auth controller applies to login and register by default, is recorded as the…","i":"RateLimitingSettings UserPolicy"},{"u":"/docs/adr/019-rate-limiting.html#context","d":"ADR-019: Layered Rate Limiting with an Authenticated-Only Global Limiter","k":"Architecture Decision Records","t":"Context","x":"Every service exposes read and write endpoints to the public internet through the gateway (ADR-008). Abusive or runaway clients (scrapers, credential stuffing, retry storms, a…"},{"u":"/docs/adr/019-rate-limiting.html#decision","d":"ADR-019: Layered Rate Limiting with an Authenticated-Only Global Limiter","k":"Architecture Decision Records","t":"Decision","x":"Rate limiting is layered, and the always-on global limiter is authenticated-only. 1. A global limiter that only caps authenticated callers. AddCommonRateLimiting…","i":"HttpContext.Connection.RemoteIpAddress EnableRateLimitingAttribute UseCommonMiddlewarePipeline AttributeUsage.Inherited LoginProtectionService AddCommonRateLimiting RateLimitPolicyAuthIp GetCustomAttributes UseForwardedHeaders AuthControllerBase EnableRateLimiting EndpointDataSource"},{"u":"/docs/adr/019-rate-limiting.html#rationale","d":"ADR-019: Layered Rate Limiting with an Authenticated-Only Global Limiter","k":"Architecture Decision Records","t":"Rationale","x":"- Limit the traffic that is both attributable and expensive. An authenticated request is tied to a principal and usually drives the database; capping per-principal stops a single…"},{"u":"/docs/adr/019-rate-limiting.html#trade-offs","d":"ADR-019: Layered Rate Limiting with an Authenticated-Only Global Limiter","k":"Architecture Decision Records","t":"Trade-offs","x":"- The global limiter only protects the authenticated surface. The anonymous surface is covered endpoint by endpoint instead: login and register carry the auth-ip limiter by…","i":"ForwardLimit"},{"u":"/docs/adr/019-rate-limiting.html#revision-2026-08-18","d":"ADR-019: Layered Rate Limiting with an Authenticated-Only Global Limiter","k":"Architecture Decision Records","t":"Revision (2026-08-18)","x":"The layering above is unchanged: the global limiter is still authenticated-only, infrastructure and anonymous traffic are still exempt, auth-ip still covers login and register by…","i":"RateLimitAlgorithm.FixedWindow RedisFixedWindowRateLimiter IConnectionMultiplexer Interlocked.Exchange RateLimitingSettings StringIncrementAsync PerUserPermitLimit AuthIpPermitLimit GlobalPermitLimit SegmentsPerWindow allowDistributed SlidingWindow"},{"u":"/docs/adr/019-rate-limiting.html#related","d":"ADR-019: Layered Rate Limiting with an Authenticated-Only Global Limiter","k":"Architecture Decision Records","t":"Related","x":"ADR-004 (the JWKS/discovery traffic the limiter exempts, and the authenticated principal it keys on), ADR-008 (the gateway edge this protects), ADR-017 (request idempotency, the…","i":"RateLimitingSettings IncrementAsync UseRateLimiter Distributed INCR"},{"u":"/docs/adr/020-permission-based-authorization.html","d":"ADR-020: Permission-Based Authorization Layered over Roles","k":"Architecture Decision Records"},{"u":"/docs/adr/020-permission-based-authorization.html#status","d":"ADR-020: Permission-Based Authorization Layered over Roles","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-25, amended 2026-07-10 and 2026-08-23)."},{"u":"/docs/adr/020-permission-based-authorization.html#context","d":"ADR-020: Permission-Based Authorization Layered over Roles","k":"Architecture Decision Records","t":"Context","x":"Authorization started as pure role-based access control (RBAC). Endpoints declared the role they required with [Authorize(Policy = ...)] against named policies: RequireOrganizer,…","i":"RequireAuthenticatedUser RequireAuthenticated RequireOrganizer RequireAttendee RequireAdmin RequireRole Authorize Policy"},{"u":"/docs/adr/020-permission-based-authorization.html#decision","d":"ADR-020: Permission-Based Authorization Layered over Roles","k":"Architecture Decision Records","t":"Decision","x":"Add a permission (capability) layer over RBAC, opt-in and backward-compatible. - A central registry maps roles to permissions. IPermissionRegistry / PermissionRegistry…","i":"DefaultAuthorizationPolicyProvider PermissionAuthorizationHandler AuthClaimTypes.Permission PermissionRegistryBuilder AddAuthorizationPolicies PermissionPolicyProvider RequireAuthenticatedUser MMCA.Common.Shared.Auth RoleNames.ContentEditor HasPermissionAttribute PermissionRequirement IPermissionRegistry"},{"u":"/docs/adr/020-permission-based-authorization.html#rationale","d":"ADR-020: Permission-Based Authorization Layered over Roles","k":"Architecture Decision Records","t":"Rationale","x":"- Capabilities decouple endpoints from roles. A route says what it does (conference:sessions:manage), and who may do it is a registry decision, so adding ContentEditor with a…","i":"ContentEditor"},{"u":"/docs/adr/020-permission-based-authorization.html#trade-offs","d":"ADR-020: Permission-Based Authorization Layered over Roles","k":"Architecture Decision Records","t":"Trade-offs","x":"- It is still RBAC, not ABAC. The model resolves role to permission; it does not evaluate resource or attribute conditions. Per-resource ownership (\"a customer may read only…","i":"ConferencePermissions OwnerOrAdminFilter AddPermissions IAnonymizable Idempotent Grant"},{"u":"/docs/adr/020-permission-based-authorization.html#related","d":"ADR-020: Permission-Based Authorization Layered over Roles","k":"Architecture Decision Records","t":"Related","x":"ADR-004 (the authenticated principal and claims this keys on, including the optional permission claim), ADR-008 (each extracted service authorizes independently, so the registry…","i":"permission"},{"u":"/docs/adr/021-consumer-inbox-idempotency.html","d":"ADR-021: Consumer-Side Inbox for Integration-Event Idempotency","k":"Architecture Decision Records"},{"u":"/docs/adr/021-consumer-inbox-idempotency.html#status","d":"ADR-021: Consumer-Side Inbox for Integration-Event Idempotency","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-09; adoption reviewed 2026-07-15). Revised 2026-08-18 (the inbox stays opt-in, but being off is no longer silent: a broker-connected host running NoOpInboxStore…","i":"NoOpInboxStore InboxMessages"},{"u":"/docs/adr/021-consumer-inbox-idempotency.html#context","d":"ADR-021: Consumer-Side Inbox for Integration-Event Idempotency","k":"Architecture Decision Records","t":"Context","x":"ADR-003 makes integration-event delivery at-least-once: the outbox guarantees a published event is not lost, and the MassTransit broker redelivers on consumer failure.…"},{"u":"/docs/adr/021-consumer-inbox-idempotency.html#decision","d":"ADR-021: Consumer-Side Inbox for Integration-Event Idempotency","k":"Architecture Decision Records","t":"Decision","x":"Add an opt-in inbox that records each successfully-processed integration event by its MessageId and skips redeliveries. - Every event carries a MessageId. BaseDomainEvent stamps…","i":"IX_InboxMessages_MessageId IntegrationEventConsumer SessionFeedbackSubmitted SpeakerUnlinkedFromUser EventFeedbackSubmitted AlreadyProcessedAsync ProductVariantChanged OutboxCleanupService SpeakerLinkedToUser AddBrokerMessaging MarkProcessedAsync AttendeeCheckedIn"},{"u":"/docs/adr/021-consumer-inbox-idempotency.html#rationale","d":"ADR-021: Consumer-Side Inbox for Integration-Event Idempotency","k":"Architecture Decision Records","t":"Rationale","x":"- Dedup once, not in every handler. A single consume-edge check turns \"every handler author must remember to be idempotent against redelivery\" into a framework guarantee for the…","i":"NoOpInboxStore"},{"u":"/docs/adr/021-consumer-inbox-idempotency.html#trade-offs","d":"ADR-021: Consumer-Side Inbox for Integration-Event Idempotency","k":"Architecture Decision Records","t":"Trade-offs","x":"- Not exactly-once. The crash-after-handler-before-inbox window reprocesses once, so handlers must stay idempotent for it; the inbox narrows the duplicate window, it does not…","i":"InboxMessages EnableInbox MessageId"},{"u":"/docs/adr/021-consumer-inbox-idempotency.html#related","d":"ADR-021: Consumer-Side Inbox for Integration-Event Idempotency","k":"Architecture Decision Records","t":"Related","x":"ADR-003 (the outbox and at-least-once delivery whose consumer side this deduplicates; handler idempotency is still required for the crash window), ADR-006 (the inbox lives in the…","i":"OutboxCleanupService InProcess"},{"u":"/docs/adr/021-consumer-inbox-idempotency.html#revision-2026-08-18","d":"ADR-021: Consumer-Side Inbox for Integration-Event Idempotency","k":"Architecture Decision Records","t":"Revision (2026-08-18)","x":"The decision is unchanged: the inbox is still opt-in and NoOpInboxStore is still the default. What changed is that the default is now loud. 1. A broker-connected host with no…","i":"ApplicationDbContext.OnModelCreating MessageBusProvider.InProcess InboxDisabledWarningService IX_InboxMessages_MessageId IEntityTypeConfiguration base.OnModelCreating AddBrokerMessaging SQLServerDbContext AddInboxMessages CosmosDbContext SqliteDbContext ConfigureInbox"},{"u":"/docs/adr/022-browser-session-cookie-auth.html","d":"ADR-022: Browser Session-Cookie Authentication for Blazor SSR","k":"Architecture Decision Records"},{"u":"/docs/adr/022-browser-session-cookie-auth.html#status","d":"ADR-022: Browser Session-Cookie Authentication for Blazor SSR","k":"Architecture Decision Records","t":"Status","x":"Accepted."},{"u":"/docs/adr/022-browser-session-cookie-auth.html#context","d":"ADR-022: Browser Session-Cookie Authentication for Blazor SSR","k":"Architecture Decision Records","t":"Context","x":"The apps are Blazor Web Apps: a server-rendered (SSR) prerender pass runs on the first request, then an interactive phase (Blazor Server or WebAssembly) takes over.…","i":"Authorization localStorage Authorize"},{"u":"/docs/adr/022-browser-session-cookie-auth.html#decision","d":"ADR-022: Browser Session-Cookie Authentication for Blazor SSR","k":"Architecture Decision Records","t":"Decision","x":"Carry the session in HttpOnly cookies and add an authentication scheme that reads them during SSR prerender. The mechanism ships in MMCA.Common.API (SessionCookies/) with a…","i":"SessionCookieAuthenticationHandler CookieSessionRefresher mmca_auth_refresh HttpContext.User mmca_auth_access SessionCookieJar MMCA.Common.API MMCA.Common.UI Authorize DELETE POST"},{"u":"/docs/adr/022-browser-session-cookie-auth.html#rationale","d":"ADR-022: Browser Session-Cookie Authentication for Blazor SSR","k":"Architecture Decision Records","t":"Rationale","x":"- Fixes the fresh-GET prerender gap. Without a server-readable session, every deep-link or F5 to an [Authorize] page would redirect to /login despite a valid session; the cookie…","i":"Authorize"},{"u":"/docs/adr/022-browser-session-cookie-auth.html#trade-offs","d":"ADR-022: Browser Session-Cookie Authentication for Blazor SSR","k":"Architecture Decision Records","t":"Trade-offs","x":"- A non-validating auth scheme exists. SessionCookieAuthenticationHandler trusts a cookie it does not cryptographically verify. This is sound only because (a) the cookie is…","i":"SessionCookieAuthenticationHandler ISessionCookieSync"},{"u":"/docs/adr/022-browser-session-cookie-auth.html#related","d":"ADR-022: Browser Session-Cookie Authentication for Blazor SSR","k":"Architecture Decision Records","t":"Related","x":"ADR-004 (the JWT/JWKS validation the API performs on every call, which is why the SSR handler can skip signature validation), ADR-008 (the gateway and topology the UI talks to),…"},{"u":"/docs/adr/023-security-response-headers.html","d":"ADR-023: Centralized Security-Response-Headers Middleware with a Pluggable CSP","k":"Architecture Decision Records"},{"u":"/docs/adr/023-security-response-headers.html#status","d":"ADR-023: Centralized Security-Response-Headers Middleware with a Pluggable CSP","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-02)."},{"u":"/docs/adr/023-security-response-headers.html#context","d":"ADR-023: Centralized Security-Response-Headers Middleware with a Pluggable CSP","k":"Architecture Decision Records","t":"Context","x":"Every client-facing host (the YARP Gateway and the Blazor UI web host in each app) must stamp the same hardened HTTP response headers: X-Content-Type-Options, X-Frame-Options,…"},{"u":"/docs/adr/023-security-response-headers.html#decision","d":"ADR-023: Centralized Security-Response-Headers Middleware with a Pluggable CSP","k":"Architecture Decision Records","t":"Decision","x":"Ship one security-headers middleware in MMCA.Common.Aspire (MMCA.Common.Aspire.Security), registered with AddCommonSecurityHeaders(configuration?, configure?) and inserted early…","i":"SecurityHeadersSettings.ContentSecurityPolicy SecurityHeadersMiddlewareTests MMCA.Common.Aspire.Security SecurityHeadersMiddleware AddCommonSecurityHeaders MMCA.Common.Aspire.Tests UseCommonSecurityHeaders BlazorCspPolicyProvider SecurityHeadersSettings StaticCspPolicyProvider AddCommonBlazorCsp ICspPolicyProvider"},{"u":"/docs/adr/023-security-response-headers.html#rationale","d":"ADR-023: Centralized Security-Response-Headers Middleware with a Pluggable CSP","k":"Architecture Decision Records","t":"Rationale","x":"- One hardened default, defined once. Centralizing the header set removes per-host drift and makes a new edge host secure by default rather than by remembering to copy headers. -…","i":"ICspPolicyProvider"},{"u":"/docs/adr/023-security-response-headers.html#trade-offs","d":"ADR-023: Centralized Security-Response-Headers Middleware with a Pluggable CSP","k":"Architecture Decision Records","t":"Trade-offs","x":"- The baseline CSP is intentionally incomplete. An API/Gateway host gets default-src 'self'-style protection but no script-src/style-src discipline unless it registers a fuller…","i":"SecurityHeadersSettings.ContentSecurityPolicy AddCommonSecurityHeaders BlazorCspPolicyProvider AddCommonBlazorCsp ICspPolicyProvider MMCA.Common.UI.Web TryAddSingleton ApiSettings"},{"u":"/docs/adr/023-security-response-headers.html#related","d":"ADR-023: Centralized Security-Response-Headers Middleware with a Pluggable CSP","k":"Architecture Decision Records","t":"Related","x":"ADR-019 (rate limiting, the other always-on edge protection living in the same Aspire layer), ADR-022 (browser session-cookie auth, the other browser-edge security control),…"},{"u":"/docs/adr/024-push-notifications.html","d":"ADR-024: Two-Channel User Notifications (Transient SignalR Push + Durable Inbox)","k":"Architecture Decision Records"},{"u":"/docs/adr/024-push-notifications.html#status","d":"ADR-024: Two-Channel User Notifications (Transient SignalR Push + Durable Inbox)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-27, amended 2026-07-15). Revised 2026-08-07 (transactional email recorded as an app-level concern outside the channel model; see Revision below). Revised…","i":"Enabled"},{"u":"/docs/adr/024-push-notifications.html#context","d":"ADR-024: Two-Channel User Notifications (Transient SignalR Push + Durable Inbox)","k":"Architecture Decision Records","t":"Context","x":"The framework needs to deliver user-facing notifications (an organizer broadcasting a schedule change, a per-user alert). Two delivery models each fail on their own. A pure…"},{"u":"/docs/adr/024-push-notifications.html#decision","d":"ADR-024: Two-Channel User Notifications (Transient SignalR Push + Durable Inbox)","k":"Architecture Decision Records","t":"Decision","x":"Deliver notifications over two channels from one application use case, with the transport and the recipient policy both behind abstractions. - A durable per-user inbox plus a…","i":"NullNotificationRecipientProvider PushNotificationSettings.Enabled INotificationRecipientProvider SignalRPushNotificationSender SendPushNotificationHandler SignalRLiveChannelPublisher MMCA.Common.Infrastructure NullPushNotificationSender IPushNotificationSender MMCA.Common.Application CancellationToken.None NotificationHubService"},{"u":"/docs/adr/024-push-notifications.html#rationale","d":"ADR-024: Two-Channel User Notifications (Transient SignalR Push + Durable Inbox)","k":"Architecture Decision Records","t":"Rationale","x":"- Each channel covers the other's failure mode. The inbox guarantees eventual delivery to offline users; the push gives connected users immediacy. Persisting the inbox before…","i":"INotificationRecipientProvider IPushNotificationSender IMessageBus"},{"u":"/docs/adr/024-push-notifications.html#trade-offs","d":"ADR-024: Two-Channel User Notifications (Transient SignalR Push + Durable Inbox)","k":"Architecture Decision Records","t":"Trade-offs","x":"- Fan-out write amplification. One UserNotification row is written per recipient, so a broadcast to a large audience is a large insert. This is fine for the current per-event /…","i":"NullPushNotificationSender AddPushNotifications PushNotification UserNotification Authorization access_token IsRead ReadOn"},{"u":"/docs/adr/024-push-notifications.html#related","d":"ADR-024: Two-Channel User Notifications (Transient SignalR Push + Durable Inbox)","k":"Architecture Decision Records","t":"Related","x":"ADR-003 (the outbox dual-dispatch path, which is distinct: that carries service-to-service integration events, this carries user-facing notifications), ADR-004 (the /hubs…","i":"MMCA.ADC.Notification.Service SendPushNotificationHandler NullNativePushSender Http1AndHttp2 access_token Http2"},{"u":"/docs/adr/024-push-notifications.html#revision-2026-08-07","d":"ADR-024: Two-Channel User Notifications (Transient SignalR Push + Durable Inbox)","k":"Architecture Decision Records","t":"Revision (2026-08-07)","x":"Records transactional email, a delivery path the channel model above never mentions. The decision is unchanged: this closes a documentation gap so the asymmetry reads as…","i":"OrderPaymentFailedSagaHandler SendPushNotificationHandler IPushNotificationSender ILiveChannelPublisher IPushDeviceRegistrar IDomainEventHandler AddInfrastructure INativePushSender OrderPaidHandler PushNotification UserNotification SmtpEmailSender"},{"u":"/docs/adr/025-startup-warmup-readiness.html","d":"ADR-025: Startup Warm-Up and Readiness Gating for Cold-Start Mitigation","k":"Architecture Decision Records"},{"u":"/docs/adr/025-startup-warmup-readiness.html#status","d":"ADR-025: Startup Warm-Up and Readiness Gating for Cold-Start Mitigation","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-27). Revised 2026-07-28: /health/ready now excludes optional-tagged checks as well as live-tagged ones (see Decision), and the absence of a warm-up timeout was…","i":"optional live"},{"u":"/docs/adr/025-startup-warmup-readiness.html#context","d":"ADR-025: Startup Warm-Up and Readiness Gating for Cold-Start Mitigation","k":"Architecture Decision Records","t":"Context","x":"On the Azure Container Apps Consumption plan a replica that has been idle is CPU-throttled, and a scale-from-zero or scaled-out replica starts cold. The first authenticated…"},{"u":"/docs/adr/025-startup-warmup-readiness.html#decision","d":"ADR-025: Startup Warm-Up and Readiness Gating for Cold-Start Mitigation","k":"Architecture Decision Records","t":"Decision","x":"Ship a small warm-up subsystem in MMCA.Common.Aspire, wired into AddServiceDefaults() so every host gets it. - A readiness gate that starts closed. WarmupReadinessGate…","i":"OpenIdConnectMetadataWarmupTask OperationCanceledException WarmupReadinessHealthCheck MapDefaultEndpoints WarmupHostedService WarmupReadinessGate AddServiceDefaults AddWarmupReadiness IHttpClientFactory MMCA.Common.Aspire TaskTimeoutSeconds cancellationToken"},{"u":"/docs/adr/025-startup-warmup-readiness.html#rationale","d":"ADR-025: Startup Warm-Up and Readiness Gating for Cold-Start Mitigation","k":"Architecture Decision Records","t":"Rationale","x":"- Keep cold replicas out of rotation, briefly. Gating readiness on warm-up means the platform does not send a user request to a replica that is still doing its first handshakes,…","i":"AddServiceDefaults"},{"u":"/docs/adr/025-startup-warmup-readiness.html#trade-offs","d":"ADR-025: Startup Warm-Up and Readiness Gating for Cold-Start Mitigation","k":"Architecture Decision Records","t":"Trade-offs","x":"- A replica can enter rotation not fully warm. The gate is opened in a finally once the Task.WhenAll over every registered task returns, that is, once each task has completed,…","i":"ConfigurationManager TimeoutException stoppingToken Task.WhenAll WaitAsync finally"},{"u":"/docs/adr/025-startup-warmup-readiness.html#related","d":"ADR-025: Startup Warm-Up and Readiness Gating for Cold-Start Mitigation","k":"Architecture Decision Records","t":"Related","x":"ADR-004 (the OIDC discovery document the built-in task pre-fetches, and the auth-side view of the same cold-start), ADR-009 (the Polly resilience pipeline that absorbs the lazy…"},{"u":"/docs/adr/026-caching-strategy.html","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","i":"ICacheService"},{"u":"/docs/adr/026-caching-strategy.html#status","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-27, amended 2026-07-10, 2026-07-23, 2026-07-25, 2026-08-14). Amended by ADR-077 (2026-08-13): Tier 1's substrate gains a third, opt-in implementation…","i":"OutputCacheEvictionRequested MMCA.Common.OutputCache HybridCacheService ICacheService remarks INCR"},{"u":"/docs/adr/026-caching-strategy.html#context","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Context","x":"The framework needs caching in two distinct places. Inside the application pipeline, query results are memoized and invalidated on mutation (the Caching decorators of ADR-014,…","i":"ICacheInvalidating IQueryCacheable"},{"u":"/docs/adr/026-caching-strategy.html#decision","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Decision","x":"Cache in two tiers, each with its own substrate. - One abstraction. ICacheService (MMCA.Common.Application/Interfaces/ICacheService.cs) exposes GetAsync / SetAsync / RemoveAsync…","i":"builder.Services.AddStackExchangeRedisOutputCache OutputCacheOptions.AddPublicEndpointPolicy MiddlewarePipelineStepNames.OutputCache AbsoluteExpirationRelativeToNow PublicEndpointOutputCachePolicy CacheOptions.DefaultExpiration CacheOptions.DefaultDuration DistributedCacheEntryOptions DistributedCacheService IConnectionMultiplexer LoginProtectionService MemoryDistributedCache"},{"u":"/docs/adr/026-caching-strategy.html#rationale","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Rationale","x":"- One substrate, swapped by environment. Keeping ICacheService as the only thing application code sees lets the deployment decide memory vs distributed. The auto-swap (presence…","i":"ICacheInvalidating IDistributedCache ICacheService"},{"u":"/docs/adr/026-caching-strategy.html#trade-offs","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Trade-offs","x":"- Memory mode is per-replica. In the in-process store each replica caches independently; a scaled-out deployment that did not wire Redis would see cross-replica staleness bounded…","i":"ICacheService.IncrementAsync AddRedisDistributedCache DistributedCacheService StackExchangeRedisCache IConnectionMultiplexer AddOutputCache AddRedisClient RemoveAsync WRONGTYPE NoCache remarks absexp"},{"u":"/docs/adr/026-caching-strategy.html#related","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Related","x":"ADR-014 (the Caching decorators and IQueryCacheable / ICacheInvalidating markers that consume this substrate), ADR-019 (output caching as the anonymous-traffic lever, and…","i":"RegisterUpcastedIntegrationEventConsumer OutputCacheEvictionRequested LoginProtectionService HybridCacheService ICacheInvalidating IQueryCacheable IncrementAsync ICacheService WRONGTYPE TEvent"},{"u":"/docs/adr/026-caching-strategy.html#revision-2026-07-24","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Revision (2026-07-24)","x":"Three substrate corrections from a code review. 1. An optional key namespace (Cache:KeyPrefix). Services sharing one cache instance also share one keyspace, and nothing stopped…","i":"RedisCacheOptions.InstanceName ICacheService.IncrementAsync DistributedCacheService EvictionReason.Replaced KeyedSemaphoreStripe RemoveByPrefixAsync MemoryCacheService IMemoryCache InstanceName CacheKey INCR"},{"u":"/docs/adr/026-caching-strategy.html#revision-2026-07-25","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Revision (2026-07-25)","x":"An audit against the code. No behavior changed; the ADR text did. 1. The IncrementAsync entry above was wrong. It described a Redis INCR override. There is no such override, and…","i":"DistributedCacheService StackExchangeRedisCache IncrementAsync AddCaching INCR"},{"u":"/docs/adr/026-caching-strategy.html#revision-2026-07-28","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Revision (2026-07-28)","x":"Line anchors only, re-verified against the current source. No decision, no behavior, and no substantive prose changed. 1. Tier 2. Store Catalog's…","i":"AddStackExchangeRedisOutputCache DistributedCacheService MMCA.Common.API ICacheService AddCaching"},{"u":"/docs/adr/026-caching-strategy.html#revision-2026-08-01","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Revision (2026-08-01)","x":"Line anchors only, re-verified against the current source. No decision, no behavior, and no substantive prose changed. 1. AddCaching. Now at…","i":"AddStackExchangeRedisOutputCache DistributedCacheService RemoveByPrefixAsync ScanAndDeleteAsync IncrementAsync AddCaching remarks returns"},{"u":"/docs/adr/026-caching-strategy.html#revision-2026-08-07","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Revision (2026-08-07)","x":"Line anchors only, re-verified against the current source. No decision, no behavior, and no substantive prose changed. 1. AddCaching. Now at…","i":"AddStackExchangeRedisOutputCache DistributedCacheService MemoryDistributedCache TimeSpan.FromSeconds MemoryCacheService IDistributedCache MMCA.Common.API CacheOptions AddCaching"},{"u":"/docs/adr/026-caching-strategy.html#revision-2026-08-13","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Revision (2026-08-13)","x":"Tier 1 is amended by ADR-077, which is where the decision and its trade-offs are recorded. The three points that change the reading of this record: 1. A third substrate, opted…","i":"Microsoft.Extensions.Caching.Hybrid DistributedCacheService StackExchangeRedisCache AddCommonHybridCache HybridCacheService MemoryCacheService IDistributedCache IncrementAsync AddCaching WRONGTYPE prefix INCR"},{"u":"/docs/adr/026-caching-strategy.html#revision-2026-08-14","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Revision (2026-08-14)","x":"One substrate correction plus a line-anchor re-verification. No decision and no behavior changed. 1. The 30-second default now has a named home, CacheOptions.DefaultDuration.…","i":"AddStackExchangeRedisOutputCache AbsoluteExpirationRelativeToNow CacheOptions.DefaultDuration DistributedCacheEntryOptions WebApplicationExtensions.cs AddRedisDistributedCache DistributedCacheService HybridCacheEntryOptions TimeSpan.FromSeconds app.UseOutputCache HybridCacheService DefaultExpiration"},{"u":"/docs/adr/026-caching-strategy.html#revision-2026-08-18","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Revision (2026-08-18)","x":"Every previous revision moved Tier 1. This one moves Tier 2, and it is the first change to the output-cache edge since ADR-040. Tier 2 as decided here is per-process by…","i":"RegisterUpcastedIntegrationEventConsumer RegisterOutputCacheEvictionConsumer RegisterIntegrationEventConsumer AddOutputCacheEvictionHandler OutputCacheEvictionRequested OperationCanceledException OutputCacheEvictionHandler BestEffort.ExecuteAsync MMCA.Common.OutputCache MMCA.Common.BestEffort cache.eviction.failed registerFaultConsumer"},{"u":"/docs/adr/026-caching-strategy.html#revision-2026-08-23","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Revision (2026-08-23)","x":"One correction of substance plus a line-anchor re-verification. No decision and no behavior changed. 1. The counter trade-off now names the contradiction a reader will hit.…","i":"RegisterUpcastedIntegrationEventConsumer MiddlewarePipelineStepNames.OutputCache RegisterOutputCacheEvictionConsumer BaseIntegrationEvent.SchemaVersion AddStackExchangeRedisOutputCache OutputCacheEvictionRequested DistributedCacheService app.UseOutputCache IncrementAsync UseOutputCache ICacheService AddCaching"},{"u":"/docs/adr/027-multi-locale-i18n.html","d":"ADR-027: Multi-Locale Internationalization (Supersedes ADR-011)","k":"Architecture Decision Records"},{"u":"/docs/adr/027-multi-locale-i18n.html#status","d":"ADR-027: Multi-Locale Internationalization (Supersedes ADR-011)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-27, amended 2026-07-02, 2026-07-03, 2026-07-09, and 2026-07-29; corrected 2026-08-01: the pseudo-locale CI gate is required on all three browser engines, and…"},{"u":"/docs/adr/027-multi-locale-i18n.html#context","d":"ADR-027: Multi-Locale Internationalization (Supersedes ADR-011)","k":"Architecture Decision Records","t":"Context","x":"ADR-011 recorded single-locale (en-US) as a deliberate, revisitable non-goal and sketched what re-introducing i18n would entail. That revisit has now happened: the framework adds…","i":"InteractiveAuto Error Code"},{"u":"/docs/adr/027-multi-locale-i18n.html#decision","d":"ADR-027: Multi-Locale Internationalization (Supersedes ADR-011)","k":"Architecture Decision Records","t":"Decision","x":"1. Supported cultures are an explicit allowlist: en-US (default) + es. Adding a locale is adding a .es.resx sibling set and one allowlist entry, not new infrastructure. 2.…","i":"MmcaCultureBootstrap.SetBrowserCultureAsync ErrorHttpMapping.BuildErrorsExtension DomainInvariantViolationException CultureInfo.DefaultThreadCurrent LocalizedTextConventionTestsBase MMCA.Common.Testing.Architecture SupportedCultures.ResolveClosest ApiControllerBase.HandleFailure ResourceTranslationsAreComplete SupportedCultures.PseudoLocale CookieRequestCultureProvider CultureInfo.InvariantCulture"},{"u":"/docs/adr/027-multi-locale-i18n.html#rationale","d":"ADR-027: Multi-Locale Internationalization (Supersedes ADR-011)","k":"Architecture Decision Records","t":"Rationale","x":"- Keying error localization on the existing Error.Code is the cheapest correct extension point. The codes are already stable and already cross the wire; localizing at the edge…","i":"ResourcesPath Error.Code resx"},{"u":"/docs/adr/027-multi-locale-i18n.html#trade-offs","d":"ADR-027: Multi-Locale Internationalization (Supersedes ADR-011)","k":"Architecture Decision Records","t":"Trade-offs","x":"- Every view and every user-facing message is touched: a large, mostly mechanical sweep, accepted as the cost ADR-011 always named. - WASM Spanish formatting needs ICU…","i":"InvariantGlobalization ResxMudLocalizer MudTranslations BlazorWebView MudLocalizer"},{"u":"/docs/adr/027-multi-locale-i18n.html#related","d":"ADR-027: Multi-Locale Internationalization (Supersedes ADR-011)","k":"Architecture Decision Records","t":"Related","x":"ADR-011 (superseded), ADR-013 (the Error.Code this localizes on), ADR-015 (the i18n gates now live here: the MA0076 culture-less formatting build gate and the…","i":"ResourceTranslationsAreComplete BlazorWebView Error.Code MA0076"},{"u":"/docs/adr/028-dark-theme-mode.html","d":"ADR-028: Day/Dark Theme Mode","k":"Architecture Decision Records"},{"u":"/docs/adr/028-dark-theme-mode.html#status","d":"ADR-028: Day/Dark Theme Mode","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-27; revised 2026-07-15)."},{"u":"/docs/adr/028-dark-theme-mode.html#context","d":"ADR-028: Day/Dark Theme Mode","k":"Architecture Decision Records","t":"Context","x":"MMCATheme (MMCA.Common.UI/Theme/MMCATheme.cs) has always defined a complete, brand-tuned PaletteDark alongside PaletteLight, but MudThemeProvider was hard-wired to light: no…","i":"MudThemeProvider InteractiveAuto PaletteLight PaletteDark IsDarkMode MMCATheme ref"},{"u":"/docs/adr/028-dark-theme-mode.html#decision","d":"ADR-028: Day/Dark Theme Mode","k":"Architecture Decision Records","t":"Decision","x":"1. Bind the existing theme. The shared MainLayout renders a single component (MMCA.Common.UI/Layout/MainLayout.razor:14), which owns the four Mud providers plus the Day/Dark…","i":"ThemeService.InitializeAsync User.PreferredCulture User.PreferredTheme MMCATheme.Instance MmcaThemeProviders OnAfterRenderAsync InteractiveServer systemPrefersDark window.matchMedia MudThemeProvider MMCA.Common.UI ThemeService"},{"u":"/docs/adr/028-dark-theme-mode.html#rationale","d":"ADR-028: Day/Dark Theme Mode","k":"Architecture Decision Records","t":"Rationale","x":"- Reusing the i18n cookie/profile machinery means one persistence model for both user preferences, instead of two subtly different ones. Theme and locale are the same shape of…","i":"BrandColorTokenTests"},{"u":"/docs/adr/028-dark-theme-mode.html#trade-offs","d":"ADR-028: Day/Dark Theme Mode","k":"Architecture Decision Records","t":"Trade-offs","x":"- The same FOUC hazard as locale is not yet closed for theme. The SSR data-theme/inline-script read is unimplemented (Decision 3), so the first paint can briefly flash the wrong…","i":"MainLayout User"},{"u":"/docs/adr/028-dark-theme-mode.html#related","d":"ADR-028: Day/Dark Theme Mode","k":"Architecture Decision Records","t":"Related","x":"ADR-027 (shares the cookie source-of-truth and the User preference migration, and is the model for the theme no-flash SSR bootstrap that is not yet wired), ADR-022 (the SSR…","i":"User"},{"u":"/docs/adr/029-authentication-brute-force-protection.html","d":"ADR-029: Authentication Brute-Force Protection and Registration Throttling","k":"Architecture Decision Records"},{"u":"/docs/adr/029-authentication-brute-force-protection.html#status","d":"ADR-029: Authentication Brute-Force Protection and Registration Throttling","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-27). Updated 2026-07-02 (the check/increment/reset call sequence was hoisted into AuthenticationServiceBase ; the adoption note and the \"convention the consumer…","i":"AuthenticationServiceBase TUser"},{"u":"/docs/adr/029-authentication-brute-force-protection.html#context","d":"ADR-029: Authentication Brute-Force Protection and Registration Throttling","k":"Architecture Decision Records","t":"Context","x":"ADR-019's global rate limiter is authenticated-only: it caps requests per authenticated principal and deliberately exempts anonymous traffic. The highest-value anonymous attack…","i":"RateLimitPolicyAuthIp AuthControllerBase"},{"u":"/docs/adr/029-authentication-brute-force-protection.html#decision","d":"ADR-029: Authentication Brute-Force Protection and Registration Throttling","k":"Architecture Decision Records","t":"Decision","x":"Provide a framework ILoginProtectionService (MMCA.Common.Application.Auth) with a single implementation LoginProtectionService (MMCA.Common.Infrastructure.Auth), registered…","i":"RegistrationRateLimitWindowMinutes CheckRegistrationRateLimitAsync IncrementRegistrationCountAsync MMCA.Common.Infrastructure.Auth ICacheService.IncrementAsync IncrementFailedAttemptsAsync MaxRegistrationsPerIpPerHour MMCA.Common.Application.Auth FailedAttemptWindowMinutes AuthenticationServiceBase ResetFailedAttemptsAsync DistributedCacheService"},{"u":"/docs/adr/029-authentication-brute-force-protection.html#rationale","d":"ADR-029: Authentication Brute-Force Protection and Registration Throttling","k":"Architecture Decision Records","t":"Rationale","x":"- Complements ADR-019 rather than duplicating it. ADR-019 carries two limiter layers and this is the third on top of them: its global limiter caps authenticated throughput per…","i":"Result"},{"u":"/docs/adr/029-authentication-brute-force-protection.html#trade-offs","d":"ADR-029: Authentication Brute-Force Protection and Registration Throttling","k":"Architecture Decision Records","t":"Trade-offs","x":"- Cache-scoped state weakens under scale-out without Redis. In memory mode the counters are per-replica and evaporate on restart, so a multi-replica deployment that did not wire…","i":"AuthenticationServiceBase ILoginProtectionService AuthenticationService TUser"},{"u":"/docs/adr/029-authentication-brute-force-protection.html#related","d":"ADR-029: Authentication Brute-Force Protection and Registration Throttling","k":"Architecture Decision Records","t":"Related","x":"ADR-019 (the layered limiter: an authenticated-only global cap that exempts this anonymous surface, plus the per-IP auth-ip window that now sits on the same two endpoints),…","i":"ICacheService Result Error"},{"u":"/docs/adr/030-startup-sole-migrator.html","d":"ADR-030: Each Service Self-Applies Its Migrations at Startup (Sole Migrator)","k":"Architecture Decision Records"},{"u":"/docs/adr/030-startup-sole-migrator.html#status","d":"ADR-030: Each Service Self-Applies Its Migrations at Startup (Sole Migrator)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-27)."},{"u":"/docs/adr/030-startup-sole-migrator.html#context","d":"ADR-030: Each Service Self-Applies Its Migrations at Startup (Sole Migrator)","k":"Architecture Decision Records","t":"Context","x":"Under database-per-service (ADR-006), each service owns its own database and its own migrations project, so something must apply pending migrations on every deploy. The…","i":"ApplicationSettings.DatabaseInitStrategy DatabaseInitializationExtensions EnsureCreated"},{"u":"/docs/adr/030-startup-sole-migrator.html#decision","d":"ADR-030: Each Service Self-Applies Its Migrations at Startup (Sole Migrator)","k":"Architecture Decision Records","t":"Decision","x":"In Azure Container Apps, every service host runs ApplicationSettingsDatabaseInitStrategy = Migrate in production and is the sole migrator of its own database: it applies its…","i":"ApplicationSettings__DatabaseInitStrategy __EFMigrationsHistory DatabaseInitStrategy MigrateAsync minReplicas deploy.yml migrations database Migrate dotnet sqlcmd update"},{"u":"/docs/adr/030-startup-sole-migrator.html#rationale","d":"ADR-030: Each Service Self-Applies Its Migrations at Startup (Sole Migrator)","k":"Architecture Decision Records","t":"Rationale","x":"- One migrator, one mechanism. The code that owns the schema applies the schema; there is no second tool to keep in lockstep and no ordering race between a deploy step and…","i":"__EFMigrationsHistory"},{"u":"/docs/adr/030-startup-sole-migrator.html#trade-offs","d":"ADR-030: Each Service Self-Applies Its Migrations at Startup (Sole Migrator)","k":"Architecture Decision Records","t":"Trade-offs","x":"- Auto-migrate-in-production is what \"None\" exists to prevent. An unintended or destructive migration would ship itself on the next deploy. The apps accept this; the build-time…","i":"minReplicas"},{"u":"/docs/adr/030-startup-sole-migrator.html#related","d":"ADR-030: Each Service Self-Applies Its Migrations at Startup (Sole Migrator)","k":"Architecture Decision Records","t":"Related","x":"ADR-006 (database-per-service: why each service owns and migrates its own database), ADR-025 (readiness gating keeps traffic off a still-migrating replica), ADR-009 (RTO/RPO +…"},{"u":"/docs/adr/030-startup-sole-migrator.html#revision-2026-08-07","d":"ADR-030: Each Service Self-Applies Its Migrations at Startup (Sole Migrator)","k":"Architecture Decision Records","t":"Revision (2026-08-07)","x":"The sole-migrator decision extends to seed data: the same startup owner that applies the schema also runs the module seeders, in the same call, on the same boot. The Decision…","i":"moduleLoader.SeedAllAsync ModuleLoader.SeedAllAsync ConferenceModuleDbSeeder InitializeDatabaseAsync __EFMigrationsHistory DatabaseInitStrategy builder.Build IModuleSeeder ExistsAsync DbSeeder Guid int"},{"u":"/docs/adr/031-feature-flag-management.html","d":"ADR-031: Config-Driven Feature Flags with Dual-Surface Enforcement","k":"Architecture Decision Records"},{"u":"/docs/adr/031-feature-flag-management.html#status","d":"ADR-031: Config-Driven Feature Flags with Dual-Surface Enforcement","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-27). Revised 2026-08-18 (a targeting-context accessor is now registered, so the built-in Targeting and Percentage filters give consistent per-user bucketing…"},{"u":"/docs/adr/031-feature-flag-management.html#context","d":"ADR-031: Config-Driven Feature Flags with Dual-Surface Enforcement","k":"Architecture Decision Records","t":"Context","x":"The apps need to decouple release from deploy: ship code dark, flip a kill switch, or roll a feature out to a percentage of users without a redeploy. A flag has to be enforceable…","i":"FeatureGate"},{"u":"/docs/adr/031-feature-flag-management.html#decision","d":"ADR-031: Config-Driven Feature Flags with Dual-Surface Enforcement","k":"Architecture Decision Records","t":"Decision","x":"Standardize on Microsoft.FeatureManagement, configured from the \"FeatureManagement\" configuration section and registered once in AddAPI (services.AddFeatureManagement() +…","i":"ApiControllerBase.HandleFailure Microsoft.FeatureManagement.Mvc IFeatureManager.IsEnabledAsync services.AddFeatureManagement FeatureGateCommandDecorator Microsoft.FeatureManagement FeatureGateQueryDecorator Error.NotFoundError ConferenceFeatures EngagementFeatures ErrorType.NotFound CatalogFeatures"},{"u":"/docs/adr/031-feature-flag-management.html#rationale","d":"ADR-031: Config-Driven Feature Flags with Dual-Surface Enforcement","k":"Architecture Decision Records","t":"Rationale","x":"- Release decoupled from deploy. A kill switch or a percentage rollout becomes a configuration change, not a code change: the central reason feature management exists. - Two…"},{"u":"/docs/adr/031-feature-flag-management.html#trade-offs","d":"ADR-031: Config-Driven Feature Flags with Dual-Surface Enforcement","k":"Architecture Decision Records","t":"Trade-offs","x":"- The two enforcement points must agree. A flag gated on the controller but not the handler (or vice versa) is a half-protected feature; no fitness rule asserts both are wired,…","i":"IsEnabledAsync"},{"u":"/docs/adr/031-feature-flag-management.html#revision-2026-08-18","d":"ADR-031: Config-Driven Feature Flags with Dual-Surface Enforcement","k":"Architecture Decision Records","t":"Revision (2026-08-18)","x":"Progressive rollout is now usable, because the targeting context exists. The Decision above listed the Percentage / TimeWindow / Targeting filters as \"available\", and the last…","i":"CurrentUserTargetingContextAccessor FeatureGateCommandDecorator ITargetingContextAccessor featureGated.FeatureName AddHttpContextAccessor IHttpContextAccessor ICurrentUserService ClaimTypes.Role IFeatureManager IsEnabledAsync Identity.Name WithTargeting"},{"u":"/docs/adr/031-feature-flag-management.html#related","d":"ADR-031: Config-Driven Feature Flags with Dual-Surface Enforcement","k":"Architecture Decision Records","t":"Related","x":"ADR-014 (the decorator pipeline whose outermost slot FeatureGate fills, and the ordering that puts it first, now with Authorization registered directly inside it so a disabled…","i":"FeatureGate Groups Result Error"},{"u":"/docs/adr/032-password-hashing.html","d":"ADR-032: Password Hashing (PBKDF2-HMAC-SHA512) with Legacy-Hash Backward Compatibility","k":"Architecture Decision Records"},{"u":"/docs/adr/032-password-hashing.html#status","d":"ADR-032: Password Hashing (PBKDF2-HMAC-SHA512) with Legacy-Hash Backward Compatibility","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-29, adoption note revised 2026-07-06, registration note revised 2026-08-01, call-site hoist recorded 2026-08-23)."},{"u":"/docs/adr/032-password-hashing.html#context","d":"ADR-032: Password Hashing (PBKDF2-HMAC-SHA512) with Legacy-Hash Backward Compatibility","k":"Architecture Decision Records","t":"Context","x":"Identity stores a credential as a (salt, hash) pair, never plaintext. The framework needs one canonical hasher that every consuming Identity flow shares, so the key-derivation…"},{"u":"/docs/adr/032-password-hashing.html#decision","d":"ADR-032: Password Hashing (PBKDF2-HMAC-SHA512) with Legacy-Hash Backward Compatibility","k":"Architecture Decision Records","t":"Decision","x":"Provide a single IPasswordHasher (MMCA.Common.Application.Interfaces.Infrastructure, IPasswordHasher.cs:6) with one implementation PasswordHasher…","i":"MMCA.Common.Application.Interfaces.Infrastructure CryptographicOperations.FixedTimeEquals RandomNumberGenerator.GetBytes IdentityModuleDbSeederBase AuthenticationServiceBase ChangePasswordHandlerBase Rfc2898DeriveBytes.Pbkdf2 HashAlgorithmName.SHA512 IdentityModuleDbSeeder AuthenticationService ChangePasswordHandler LegacyHmacSaltSize"},{"u":"/docs/adr/032-password-hashing.html#rationale","d":"ADR-032: Password Hashing (PBKDF2-HMAC-SHA512) with Legacy-Hash Backward Compatibility","k":"Architecture Decision Records","t":"Rationale","x":"- One framework-owned primitive, not per-app crypto. Putting the algorithm, work factor, salt size, and comparison in a single shared type means a future hardening (raising…","i":"IsLegacy"},{"u":"/docs/adr/032-password-hashing.html#trade-offs","d":"ADR-032: Password Hashing (PBKDF2-HMAC-SHA512) with Legacy-Hash Backward Compatibility","k":"Architecture Decision Records","t":"Trade-offs","x":"- The legacy branch is a permanent correctness dependency that looks deletable. Its load-bearing role is invisible from the method body alone, so it is the single most…","i":"VerifyPassword Iterations"},{"u":"/docs/adr/032-password-hashing.html#related","d":"ADR-032: Password Hashing (PBKDF2-HMAC-SHA512) with Legacy-Hash Backward Compatibility","k":"Architecture Decision Records","t":"Related","x":"ADR-004 (cross-service JWT / JWKS authentication: the hasher gates credential verification that issues the tokens that ADR-004 then validates across services), ADR-005…","i":"EncryptedStringConverter"},{"u":"/docs/adr/032-password-hashing.html#revision-2026-08-23","d":"ADR-032: Password Hashing (PBKDF2-HMAC-SHA512) with Legacy-Hash Backward Compatibility","k":"Architecture Decision Records","t":"Revision (2026-08-23)","x":"The decision is unchanged: one framework-owned IPasswordHasher, PBKDF2-HMAC-SHA512 for new passwords, salt-length dispatch on verification. What changed is where the last two…","i":"IdentityModuleDbSeederBase ChangePasswordHandlerBase IdentityModuleDbSeeder AuthenticationService ChangePasswordHandler IPasswordHasher ChangePassword VerifyPassword HashPassword HandlerName CreateUser Accounts"},{"u":"/docs/adr/033-resource-ownership-authorization.html","d":"ADR-033: Resource-Ownership Authorization (Row-Level + Action Filter)","k":"Architecture Decision Records"},{"u":"/docs/adr/033-resource-ownership-authorization.html#status","d":"ADR-033: Resource-Ownership Authorization (Row-Level + Action Filter)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-02, revised 2026-07-25)."},{"u":"/docs/adr/033-resource-ownership-authorization.html#context","d":"ADR-033: Resource-Ownership Authorization (Row-Level + Action Filter)","k":"Architecture Decision Records","t":"Context","x":"ADR-020 added a permission (capability) layer over RBAC: it answers \"what may this role do\", resolving a role to a permission so an endpoint can require a capability instead of a…","i":"OwnerOrAdminFilter GET"},{"u":"/docs/adr/033-resource-ownership-authorization.html#decision","d":"ADR-033: Resource-Ownership Authorization (Row-Level + Action Filter)","k":"Architecture Decision Records","t":"Decision","x":"Provide a row/resource-level ownership axis in MMCA.Common.API (the Authorization folder), with two enforcement points keyed on the caller's owner claim (customerid by default)…","i":"ShoppingCartsController.GetAllForLookupAsync ShoppingCartByCustomerSpecification ShoppingCartsController.GetAllAsync AggregateRootEntityControllerBase CustomersController.CreateAsync CustomersController.GetAllAsync OrdersByCustomerSpecification GetOwnershipSpecification OwnerOrAdminFilterOptions ICurrentUserService.Role OwnershipHelper.IsAdmin settings.OwnerClaimType"},{"u":"/docs/adr/033-resource-ownership-authorization.html#rationale","d":"ADR-033: Resource-Ownership Authorization (Row-Level + Action Filter)","k":"Architecture Decision Records","t":"Rationale","x":"- Reject-one and filter-many are genuinely two mechanisms. A single-resource route has an id to compare, so a short action filter that 403s on a mismatch is the cheapest correct…","i":"IEntityQueryService Specification Criteria TEntity And TId"},{"u":"/docs/adr/033-resource-ownership-authorization.html#trade-offs","d":"ADR-033: Resource-Ownership Authorization (Row-Level + Action Filter)","k":"Architecture Decision Records","t":"Trade-offs","x":"- Opt-in per controller/handler. Neither point is automatic: a controller that forgets the [ServiceFilter] or omits the ownership spec from a query leaks across customers, the…","i":"OwnerOrAdminFilter ServiceFilter customer_id null"},{"u":"/docs/adr/033-resource-ownership-authorization.html#related","d":"ADR-033: Resource-Ownership Authorization (Row-Level + Action Filter)","k":"Architecture Decision Records","t":"Related","x":"ADR-020 (the role/permission RBAC layer this complements, and whose explicit 020-permission-based-authorization.md:78 scope-out this fills), ADR-034 (the generic entity query…","i":"IEntityQueryService Specification ForbidResult Result"},{"u":"/docs/adr/033-resource-ownership-authorization.html#revision-2026-07-25","d":"ADR-033: Resource-Ownership Authorization (Row-Level + Action Filter)","k":"Architecture Decision Records","t":"Revision (2026-07-25)","x":"An audit against the code. No behavior changed; the ADR text did. 1. The per-mutation check's failure shape was described as one branch, and it is two. ValidateOwnershipAsync was…","i":"ICurrentUserService.Role ValidateOwnershipAsync OwnerOrAdminFilter AllowMissingOwner OrdersController Error.Forbidden"},{"u":"/docs/adr/033-resource-ownership-authorization.html#revision-2026-08-01","d":"ADR-033: Resource-Ownership Authorization (Row-Level + Action Filter)","k":"Architecture Decision Records","t":"Revision (2026-08-01)","x":"Anchor-only correction. No behavior changed; OrdersController was refactored (a constructor parameter added, GetOwnershipSpecification() and the IsAdmin property extracted,…","i":"GetOwnershipSpecification ValidateOwnershipAsync OrdersController Error.Forbidden Error.NotFound IsAdmin"},{"u":"/docs/adr/034-generic-entity-query-layer.html","d":"ADR-034: Generic Entity Controllers with a Dynamic Query Contract","k":"Architecture Decision Records"},{"u":"/docs/adr/034-generic-entity-query-layer.html#status","d":"ADR-034: Generic Entity Controllers with a Dynamic Query Contract","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-30). Amended (2026-07-23): the filter strategy registry now also covers long/long? via LongFilterStrategy. Amended (2026-07-25): the keyed by-id fast path is…","i":"TryGetFastPathIncludes LongFilterStrategy long"},{"u":"/docs/adr/034-generic-entity-query-layer.html#context","d":"ADR-034: Generic Entity Controllers with a Dynamic Query Contract","k":"Architecture Decision Records","t":"Context","x":"Every module exposes many entities, and most of them need the same read and write surface: list, page, look up for a dropdown, fetch by id, create, delete. Hand writing a…"},{"u":"/docs/adr/034-generic-entity-query-layer.html#decision","d":"ADR-034: Generic Entity Controllers with a Dynamic Query Contract","k":"Architecture Decision Records","t":"Decision","x":"Give every entity a generic REST resource surface and a bounded dynamic query contract, supplied by two controller bases over a shared query pipeline. 1. Generic read controller.…","i":"EntityQueryPipeline.MaxUnboundedResultLimit QueryFieldService.ApplyFieldSelection QueryFilterService.RegisterStrategy IApplicationSettings.MaxPageSize QueryFilterService.ApplyFilters QueryFieldService.ApplySorting MaxUnboundedResultLimit QueryFilterModelBinder INavigationPopulator EntityQueryPipeline SupportedOperators IFilterStrategy"},{"u":"/docs/adr/034-generic-entity-query-layer.html#rationale","d":"ADR-034: Generic Entity Controllers with a Dynamic Query Contract","k":"Architecture Decision Records","t":"Rationale","x":"- Write the resource surface once, inherit it everywhere. The verbs, routes, filter contract, pagination, and header are defined once on the two bases; a concrete controller…","i":"QueryFilterService.ValidateFilters MaxUnboundedResultLimit DTOToEntityPropertyMap INavigationPopulator DTOMapper.MapToDTOs SupportedOperators IEntityDTOMapper IFilterStrategy MaxPageSize"},{"u":"/docs/adr/034-generic-entity-query-layer.html#trade-offs","d":"ADR-034: Generic Entity Controllers with a Dynamic Query Contract","k":"Architecture Decision Records","t":"Trade-offs","x":"- The wire contract tracks the entity model. Filterable, sortable, and projectable surface is the entity's property set. A model change is an API change unless mediated by the…","i":"QueryFilterService.ValidateFilters MaxUnboundedResultLimit DTOToEntityPropertyMap IFilterStrategy virtual"},{"u":"/docs/adr/034-generic-entity-query-layer.html#related","d":"ADR-034: Generic Entity Controllers with a Dynamic Query Contract","k":"Architecture Decision Records","t":"Related","x":"ADR-001 (manual DTO mapping: the generic controllers project through IEntityDTOMapper), ADR-002 (navigation populators: the unsupported-include path), ADR-013 (Result pattern at…","i":"IEntityDTOMapper HandleFailure result.Errors Idempotent"},{"u":"/docs/adr/034-generic-entity-query-layer.html#revision-2026-07-24","d":"ADR-034: Generic Entity Controllers with a Dynamic Query Contract","k":"Architecture Decision Records","t":"Revision (2026-07-24)","x":"Filter and pagination corrections from a code review. The contract shape is unchanged; these close cases where the engine widened a result set instead of narrowing it, or…","i":"IFilterStrategy.CanParseValue PaginationMetadata.PageSize MaxUnboundedResultLimit DTOToEntityPropertyMap Filter.Value.Invalid ValidateFilters FirstOrDefault TotalItemCount ApplyFilters GetByIdAsync int.MaxValue includeFKs"},{"u":"/docs/adr/034-generic-entity-query-layer.html#revision-2026-07-25","d":"ADR-034: Generic Entity Controllers with a Dynamic Query Contract","k":"Architecture Decision Records","t":"Revision (2026-07-25)","x":"Citation maintenance from an ADR audit. No decision or behavior changed; the source anchors above were rebased to their current declaration and call-site lines. 1. The fast-path…","i":"IsPrimaryKeyOnlyLookup TryGetFastPathIncludes"},{"u":"/docs/adr/035-optimistic-concurrency.html","d":"ADR-035: Optimistic Concurrency via RowVersion Round-Trip","k":"Architecture Decision Records"},{"u":"/docs/adr/035-optimistic-concurrency.html#status","d":"ADR-035: Optimistic Concurrency via RowVersion Round-Trip","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-02). Amended 2026-07-16: a child-entity overload of SetOriginalRowVersion was added (see Decision). Revised 2026-08-18: the same token gains an HTTP-native…","i":"SetOriginalRowVersion IConcurrencyAware SupportsIfMatch GetByIdAsync RowVersion ETag"},{"u":"/docs/adr/035-optimistic-concurrency.html#context","d":"ADR-035: Optimistic Concurrency via RowVersion Round-Trip","k":"Architecture Decision Records","t":"Context","x":"Every mutable aggregate in the framework is edited through a load-modify-save handler: the update use case fetches the tracked entity, applies the request, and calls…","i":"SaveChangesAsync Conflict"},{"u":"/docs/adr/035-optimistic-concurrency.html#decision","d":"ADR-035: Optimistic Concurrency via RowVersion Round-Trip","k":"Architecture Decision Records","t":"Decision","x":"Give every auditable entity a database-managed RowVersion concurrency token, round-trip it through the client on updates, and stamp the client's last-seen value as EF's original…","i":"MMCA.Common.Domain.Interfaces.IRowVersioned IWriteRepository.SetOriginalRowVersion ConcurrencyConventionTestsBase MMCA.Store.Architecture.Tests DbUpdateConcurrencyException MMCA.ADC.Architecture.Tests AddRowVersionToAllEntities ConfigureConcurrencyTokens DbUpdateExceptionHandler SetOriginalRowVersion AuditableBaseEntity ErrorType.Conflict"},{"u":"/docs/adr/035-optimistic-concurrency.html#rationale","d":"ADR-035: Optimistic Concurrency via RowVersion Round-Trip","k":"Architecture Decision Records","t":"Rationale","x":"- Database-managed token over a hand-maintained version field. A SQL Server rowversion auto-increments on the server on every write; no domain code sets or reads it (the setter…","i":"DbUpdateExceptionHandler SetOriginalRowVersion DbUpdateException rowversion WHERE"},{"u":"/docs/adr/035-optimistic-concurrency.html#trade-offs","d":"ADR-035: Optimistic Concurrency via RowVersion Round-Trip","k":"Architecture Decision Records","t":"Trade-offs","x":"- Opt-in at the caller, not just the type. A null or empty RowVersion skips the check, so a client that never echoes the token still gets last-write-wins. The fitness function…","i":"AddRowVersionToAllEntities DbUpdateExceptionHandler IsConcurrencyToken DbUpdateException UpdateRequest rowversion RowVersion byte"},{"u":"/docs/adr/035-optimistic-concurrency.html#revision-2026-08-18","d":"ADR-035: Optimistic Concurrency via RowVersion Round-Trip","k":"Architecture Decision Records","t":"Revision (2026-08-18)","x":"This record chose a body round-trip: the client echoes RowVersion on the update request. HTTP has had a standard way to say the same thing since long before this framework, ETag…","i":"RewriteConflictToPreconditionFailed EntityControllerBase.GetByIdAsync UpdateRequestsAreConcurrencyAware DbUpdateConcurrencyException DbUpdateExceptionHandler SupportsIfMatchAttribute ServiceFilterAttribute SetOriginalRowVersion IAsyncActionFilter SetConcurrencyETag HttpContext.Items IConcurrencyAware"},{"u":"/docs/adr/035-optimistic-concurrency.html#related","d":"ADR-035: Optimistic Concurrency via RowVersion Round-Trip","k":"Architecture Decision Records","t":"Related","x":"ADR-017 (HTTP request idempotency, which dedups retries of the same request, the mirror-image concern to two distinct edits racing here, and whose own 2026-08-18 revision adds…","i":"AuditableBaseEntity GetByIdAsync RowVersion"},{"u":"/docs/adr/036-external-oauth-login.html","d":"ADR-036: External OAuth Login (Federated Google/GitHub) with Local-JWT Exchange","k":"Architecture Decision Records"},{"u":"/docs/adr/036-external-oauth-login.html#status","d":"ADR-036: External OAuth Login (Federated Google/GitHub) with Local-JWT Exchange","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-02, migration attribution corrected 2026-07-06, native-callback redirect branch added 2026-07-17 per ADR-043, email-verified account-takeover guard before…"},{"u":"/docs/adr/036-external-oauth-login.html#context","d":"ADR-036: External OAuth Login (Federated Google/GitHub) with Local-JWT Exchange","k":"Architecture Decision Records","t":"Context","x":"The framework's Identity story so far is entirely first-party: a user registers with an email and password, the credentials are hashed (ADR-032), and Identity mints its own RS256…","i":"AddPermissions User"},{"u":"/docs/adr/036-external-oauth-login.html#decision","d":"ADR-036: External OAuth Login (Federated Google/GitHub) with Local-JWT Exchange","k":"Architecture Decision Records","t":"Decision","x":"Add an opt-in external-login path that federates Google/GitHub sign-in at the edge and immediately exchanges the external identity for the app's own local JWT pair, linking the…","i":"IAuthenticationService.ExternalLoginAsync OAuthControllerBase.CompleteAsync AddExternalLoginProviderFields Auth.ExternalLoginNotSupported Auth.ExternalEmailNotVerified ConfigurationOAuthUISettings Auth.ExternalEmailInvalid User.LinkExternalProvider AddExternalAuthProviders AddCommonAuthentication AuthenticationResponse IAuthenticationService"},{"u":"/docs/adr/036-external-oauth-login.html#rationale","d":"ADR-036: External OAuth Login (Federated Google/GitHub) with Local-JWT Exchange","k":"Architecture Decision Records","t":"Rationale","x":"- Terminate federation at the edge, keep one internal identity. Exchanging the external principal for a local JWT the moment the callback returns means every downstream concern…","i":"ExternalLoginAsync ClientId POST User GET"},{"u":"/docs/adr/036-external-oauth-login.html#trade-offs","d":"ADR-036: External OAuth Login (Federated Google/GitHub) with Local-JWT Exchange","k":"Architecture Decision Records","t":"Trade-offs","x":"- Opt-in per app, and easy to half-wire. The flow needs four cooperating pieces (scheme registration, the controller subclass, the service override, and the OAuthUIBaseUrl…","i":"Auth.ExternalLoginNotSupported Auth.ExternalEmailNotVerified IExternalLoginEmailVerifier OAuth__UIBaseUrl IsExternalLogin email_verified ExternalLogin LoginProvider ProviderKey ClientId User"},{"u":"/docs/adr/036-external-oauth-login.html#related","d":"ADR-036: External OAuth Login (Federated Google/GitHub) with Local-JWT Exchange","k":"Architecture Decision Records","t":"Related","x":"ADR-004 (the RS256/JWKS token this flow exchanges the external identity for, and validates everywhere after), ADR-022 (the browser cookies that carry the resulting session),…","i":"User.Anonymize CompleteAsync"},{"u":"/docs/adr/037-field-level-encryption-at-rest.html","d":"ADR-037: Field-Level Encryption at Rest (AES-256-GCM EF Converter)","k":"Architecture Decision Records"},{"u":"/docs/adr/037-field-level-encryption-at-rest.html#status","d":"ADR-037: Field-Level Encryption at Rest (AES-256-GCM EF Converter)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-06; revised 2026-07-24, 2026-07-25, 2026-08-15, 2026-08-18). Revised 2026-08-18: the versioned-envelope converter is no longer unpublished, it is included in…"},{"u":"/docs/adr/037-field-level-encryption-at-rest.html#context","d":"ADR-037: Field-Level Encryption at Rest (AES-256-GCM EF Converter)","k":"Architecture Decision Records","t":"Context","x":"Transparent database encryption (TDE) protects the data files as a whole, but it decrypts transparently for anyone who can query the database, so a leaked backup restored on a…"},{"u":"/docs/adr/037-field-level-encryption-at-rest.html#decision","d":"ADR-037: Field-Level Encryption at Rest (AES-256-GCM EF Converter)","k":"Architecture Decision Records","t":"Decision","x":"Provide a single framework-owned EF Core value converter that transparently encrypts string columns at rest with authenticated encryption, applied per property in an entity…","i":"MMCA.Common.Infrastructure.Persistence.Encryption ArgumentNullException.ThrowIfNull RandomNumberGenerator.GetBytes EncryptedStringConverterTests MMCA.Common.Infrastructure EncryptedStringConverter CryptographicException IReadOnlyDictionary ArgumentException FromBase64String FrozenDictionary AesGcm.Decrypt"},{"u":"/docs/adr/037-field-level-encryption-at-rest.html#rationale","d":"ADR-037: Field-Level Encryption at Rest (AES-256-GCM EF Converter)","k":"Architecture Decision Records","t":"Rationale","x":"- Authenticated, not merely confidential. AES-GCM binds a 128-bit tag to the ciphertext (EncryptedStringConverter.cs:81, :201), so a tampered or truncated value fails to decrypt…","i":"AesGcm.Decrypt string"},{"u":"/docs/adr/037-field-level-encryption-at-rest.html#trade-offs","d":"ADR-037: Field-Level Encryption at Rest (AES-256-GCM EF Converter)","k":"Architecture Decision Records","t":"Trade-offs","x":"- Latent today, proven by tests rather than production. The plumbing is complete and unit-tested, but no entity configuration wires it, so the encrypt/decrypt round-trip, the…","i":"EncryptedStringConverterTests CryptographicException HasConversion byte"},{"u":"/docs/adr/037-field-level-encryption-at-rest.html#related","d":"ADR-037: Field-Level Encryption at Rest (AES-256-GCM EF Converter)","k":"Architecture Decision Records","t":"Related","x":"ADR-005 (soft-delete vs erasure: the other sensitive-data control, which names this converter as the mechanism for erasure fields that must stay retrievable,…"},{"u":"/docs/adr/037-field-level-encryption-at-rest.html#revision-2026-07-24","d":"ADR-037: Field-Level Encryption at Rest (AES-256-GCM EF Converter)","k":"Architecture Decision Records","t":"Revision (2026-07-24)","x":"Documented a constraint the converter always had but did not state: the ciphertext is non-deterministic. Every write uses a fresh random nonce, which is the correct property for…","i":"Email Where"},{"u":"/docs/adr/037-field-level-encryption-at-rest.html#revision-2026-07-25","d":"ADR-037: Field-Level Encryption at Rest (AES-256-GCM EF Converter)","k":"Architecture Decision Records","t":"Revision (2026-07-25)","x":"Documentation-only correction, no behavior change. Item 1 of the Decision still illustrated the converter with builder.Property(e = e.Email), contradicting the 2026-07-24…","i":"EncryptedStringConverter.cs SocialSecurityNumber builder.Property e.Email"},{"u":"/docs/adr/037-field-level-encryption-at-rest.html#revision-2026-08-15","d":"ADR-037: Field-Level Encryption at Rest (AES-256-GCM EF Converter)","k":"Architecture Decision Records","t":"Revision (2026-08-15)","x":"Behavior change, not a documentation correction. The stored layout is now a versioned envelope: Base64 of [key version (1)] [nonce (12)] [ciphertext (N)] [tag (16)] rather than…","i":"SaveChanges ciphertext DbContext version nonce main key tag"},{"u":"/docs/adr/038-supply-chain-provenance.html","d":"ADR-038: Supply-Chain Provenance (SBOM Release Gate + Lock Files + Vulnerability Audit)","k":"Architecture Decision Records"},{"u":"/docs/adr/038-supply-chain-provenance.html#status","d":"ADR-038: Supply-Chain Provenance (SBOM Release Gate + Lock Files + Vulnerability Audit)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-06; revised 2026-07-21)."},{"u":"/docs/adr/038-supply-chain-provenance.html#context","d":"ADR-038: Supply-Chain Provenance (SBOM Release Gate + Lock Files + Vulnerability Audit)","k":"Architecture Decision Records","t":"Context","x":"MMCA.Common is a published framework: it packs its NuGet packages and pushes them to GitHub Packages on every v tag (release.yml:3-5), where the two production apps and the…","i":"Directory.Build.props nuget.config"},{"u":"/docs/adr/038-supply-chain-provenance.html#decision","d":"ADR-038: Supply-Chain Provenance (SBOM Release Gate + Lock Files + Vulnerability Audit)","k":"Architecture Decision Records","t":"Decision","x":"Treat supply-chain integrity as a set of build-gating controls, the same invariant-over-discipline posture ADR-015 applies to architecture rules. Four controls, each a hard gate:…","i":"SQLitePCLRaw.bundle_e_sqlite3 RestorePackagesWithLockFile MMCA.Common.Infrastructure Directory.Packages.props Directory.Build.props TreatWarningsAsErrors packageSourceMapping NuGetAuditSuppress packages.lock.json MMCA.Common.slnx nuget.config NuGetAudit"},{"u":"/docs/adr/038-supply-chain-provenance.html#rationale","d":"ADR-038: Supply-Chain Provenance (SBOM Release Gate + Lock Files + Vulnerability Audit)","k":"Architecture Decision Records","t":"Rationale","x":"- Provenance is a gate, not a document. A hard-failing SBOM step means the bill of materials cannot silently go missing on a release: the artifact is produced or the release…","i":"Directory.Build.props NuGetAuditSuppress dotnet list"},{"u":"/docs/adr/038-supply-chain-provenance.html#trade-offs","d":"ADR-038: Supply-Chain Provenance (SBOM Release Gate + Lock Files + Vulnerability Audit)","k":"Architecture Decision Records","t":"Trade-offs","x":"- The SBOM is generated and archived, not yet signed or attested. The gate proves a bill of materials exists for each release (release.yml:58); it does not add cryptographic…","i":"NuGetAuditSuppress nuget.config dotnet list"},{"u":"/docs/adr/038-supply-chain-provenance.html#related","d":"ADR-038: Supply-Chain Provenance (SBOM Release Gate + Lock Files + Vulnerability Audit)","k":"Architecture Decision Records","t":"Related","x":"ADR-016 (lockstep versioning + the MassTransit-v8 license pin; this record extends dependency governance from versioning and licensing into supply-chain provenance and…"},{"u":"/docs/adr/039-live-channel-push.html","d":"ADR-039: Live channel push (ephemeral events over the notification hub)","k":"Architecture Decision Records"},{"u":"/docs/adr/039-live-channel-push.html#status","d":"ADR-039: Live channel push (ephemeral events over the notification hub)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-09)."},{"u":"/docs/adr/039-live-channel-push.html#context","d":"ADR-039: Live channel push (ephemeral events over the notification hub)","k":"Architecture Decision Records","t":"Context","x":"Conference-day features (live polls, session Q&A, live result counters) need sub-second fan-out of small events to whoever is looking at a page right now. The existing…"},{"u":"/docs/adr/039-live-channel-push.html#decision","d":"ADR-039: Live channel push (ephemeral events over the notification hub)","k":"Architecture Decision Records","t":"Decision","x":"One realtime transport, two publisher boundaries: - NotificationHub stays the single hub and gains its first client-invokable methods: JoinChannel / LeaveChannel map the calling…","i":"PushNotificationSettings.ChannelKeyPattern SignalRLiveChannelPublisher NullLiveChannelPublisher IPushNotificationSender NotificationHubService ILiveChannelPublisher AddPushNotifications ReceiveChannelEvent LeaveChannelAsync JoinChannelAsync NotificationHub OnChannelEvent"},{"u":"/docs/adr/039-live-channel-push.html#rationale","d":"ADR-039: Live channel push (ephemeral events over the notification hub)","k":"Architecture Decision Records","t":"Rationale","x":"- One WebSocket per client keeps connection management, token refresh, reconnect, and backplane behavior in one place; channel membership is a property of the existing…","i":"IMessageBus"},{"u":"/docs/adr/039-live-channel-push.html#trade-offs","d":"ADR-039: Live channel push (ephemeral events over the notification hub)","k":"Architecture Decision Records","t":"Trade-offs","x":"- Ephemeral means lossy: a client that connects after an event was published never sees it. Features must treat channel events as cache-invalidation hints over fetchable state,…","i":"NotificationCallback"},{"u":"/docs/adr/039-live-channel-push.html#revision-2026-07-24","d":"ADR-039: Live channel push (ephemeral events over the notification hub)","k":"Architecture Decision Records","t":"Revision (2026-07-24)","x":"Two corrections from a code review; the best-effort, per-session-ordered decision is unchanged. 1. Broadcasts are enqueued after commit, not during the command. CastVoteHandler…","i":"BoundedChannelFullMode.DropOldest SessionQuestionUpvoteChanged LivePollVoteChanged ToggleUpvoteHandler CastVoteHandler DroppedCount itemDropped TryWrite"},{"u":"/docs/adr/040-authenticated-output-caching-for-public-reads.html","d":"ADR-040: Authenticated output caching for public reads","k":"Architecture Decision Records"},{"u":"/docs/adr/040-authenticated-output-caching-for-public-reads.html#status","d":"ADR-040: Authenticated output caching for public reads","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-10). Amended (2026-07-10): explicit query-string variance parity with the built-in default policy (the initial release accidentally dropped it, collapsing every…","i":"OutputCacheEvictionRequested ContentEditor SponsorsCache NowNextCache bypassRoles Organizer"},{"u":"/docs/adr/040-authenticated-output-caching-for-public-reads.html#context","d":"ADR-040: Authenticated output caching for public reads","k":"Architecture Decision Records","t":"Context","x":"The framework's read-scaling design leans on ASP.NET Core output caching: anonymous-readable endpoints ([AllowAnonymous] GETs like event/session/speaker catalogs) carry named…","i":"AuthDelegatingHandler BookmarkCountsCache AllowAnonymous Authorization NowNextCache"},{"u":"/docs/adr/040-authenticated-output-caching-for-public-reads.html#decision","d":"ADR-040: Authenticated output caching for public reads","k":"Architecture Decision Records","t":"Decision","x":"MMCA.Common.API ships PublicEndpointOutputCachePolicy, an IOutputCachePolicy that mirrors the built-in default policy with one deliberate difference: it does not disable cache…","i":"ConferenceReadAudience.PrivilegedRoles PublicEndpointOutputCachePolicy DbUpdateConcurrencyException IOutputCachePolicy MMCA.Common.API AllowAnonymous Authorization ContentEditor NowNextCache extension Organizer reference"},{"u":"/docs/adr/040-authenticated-output-caching-for-public-reads.html#rationale","d":"ADR-040: Authenticated output caching for public reads","k":"Architecture Decision Records","t":"Rationale","x":"- The response payload, not the request's auth state, is what determines cacheability. For a user-independent payload, Authorization is noise; refusing to cache on it turns the…","i":"Authorization"},{"u":"/docs/adr/040-authenticated-output-caching-for-public-reads.html#trade-offs","d":"ADR-040: Authenticated output caching for public reads","k":"Architecture Decision Records","t":"Trade-offs","x":"- Consumers must audit which named policies move to AddPublicEndpointPolicy. Policies on permission-gated endpoints (e.g. an organizer dashboard) must NOT move; if such an…","i":"UserSessionBookmarkCacheEvictionHandler RegisterOutputCacheEvictionConsumer AddStackExchangeRedisOutputCache AddOutputCacheEvictionHandler OutputCacheEvictionRequested AddRedisDistributedCache AddPublicEndpointPolicy BookmarkCountsCache IDistributedCache EvictByTagAsync AddOutputCache NowNextCache"},{"u":"/docs/adr/041-observability-and-telemetry.html","d":"ADR-041: Observability and Telemetry Strategy","k":"Architecture Decision Records"},{"u":"/docs/adr/041-observability-and-telemetry.html#status","d":"ADR-041: Observability and Telemetry Strategy","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-10). Amended (2026-07-23) to document the Telemetry:DisableHttpClientMetrics and Telemetry:DisableRuntimeMetrics cost knobs and to correct the…","i":"MMCA.Common.OutputCache MMCA.Common.BestEffort OutboxProcessor RecordDuration OutboxMetrics OutboxProcess HttpClient finally reason"},{"u":"/docs/adr/041-observability-and-telemetry.html#context","d":"ADR-041: Observability and Telemetry Strategy","k":"Architecture Decision Records","t":"Context","x":"The framework is a modular monolith whose modules extract into standalone services (ADR-008), so the same telemetry has to make sense whether a request stays in one process or…","i":"HttpClient"},{"u":"/docs/adr/041-observability-and-telemetry.html#decision","d":"ADR-041: Observability and Telemetry Strategy","k":"Architecture Decision Records","t":"Decision","x":"Standardize telemetry in the shared Aspire service defaults, add framework-specific instrumentation for the CQRS and outbox paths, and expose cost knobs with fail-safe defaults.…","i":"APPLICATIONINSIGHTS_CONNECTION_STRING CqrsMetrics.CommandDuration.Record HttpContext.TraceIdentifier OTEL_EXPORTER_OTLP_ENDPOINT AddOpenTelemetryExporters IsInstrumentationDisabled OutboxPollFilterProcessor outbox.dead_letter.count TraceIdRatioBasedSampler CorrelationIdMiddleware ConfigureOpenTelemetry TryGetTraceSampleRatio"},{"u":"/docs/adr/041-observability-and-telemetry.html#rationale","d":"ADR-041: Observability and Telemetry Strategy","k":"Architecture Decision Records","t":"Rationale","x":"- Instrument only where auto-instrumentation is blind. The CQRS RED histograms and the outbox dead-letter counter cover the two framework-owned hot paths; everything else (HTTP,…","i":"ParentBased HttpClient true"},{"u":"/docs/adr/041-observability-and-telemetry.html#trade-offs","d":"ADR-041: Observability and Telemetry Strategy","k":"Architecture Decision Records","t":"Trade-offs","x":"- Custom instrumentation carries a maintenance cost. The Aspire package has no reference to Application or Infrastructure by design, so the meter and activity-source names are…","i":"OutboxProcess ParentBased"},{"u":"/docs/adr/041-observability-and-telemetry.html#revision-2026-08-18","d":"ADR-041: Observability and Telemetry Strategy","k":"Architecture Decision Records","t":"Revision (2026-08-18)","x":"Two meters and one hop. Two new failure counters, each on its own meter. cache.eviction.failed, tagged cachetag, on MMCA.Common.OutputCache…","i":"GatewayCorrelationMiddleware besteffort.dispatch.failed CorrelationIdMiddleware MMCA.Common.Idempotency MMCA.Common.OutputCache MMCA.Common.BestEffort cache.eviction.failed MMCA.Common.Scheduler MMCA.Common.Broker MMCA.Common.Outbox MMCA.Common.Cqrs Idempotency"},{"u":"/docs/adr/041-observability-and-telemetry.html#related","d":"ADR-041: Observability and Telemetry Strategy","k":"Architecture Decision Records","t":"Related","x":"ADR-003 (the outbox whose dead-letter counter and poll-span filtering this defines), ADR-014 (the CQRS decorator pipeline that emits the RED histograms as a byproduct of its…","i":"AddServiceDefaults"},{"u":"/docs/adr/042-device-capability-abstraction.html","d":"ADR-042: Device Capability Abstraction (MAUI Blazor Hybrid)","k":"Architecture Decision Records"},{"u":"/docs/adr/042-device-capability-abstraction.html#status","d":"ADR-042: Device Capability Abstraction (MAUI Blazor Hybrid)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-10, amended 2026-07-17, 2026-07-23 and 2026-08-14)."},{"u":"/docs/adr/042-device-capability-abstraction.html#context","d":"ADR-042: Device Capability Abstraction (MAUI Blazor Hybrid)","k":"Architecture Decision Records","t":"Context","x":"The consumer apps ship the same Blazor component set through three heads: MAUI Blazor Hybrid (Android/iOS/MacCatalyst/Windows), Blazor Server SSR, and WebAssembly. Native device…","i":"builder.Services.AddCommonMauiTokenStorage ITokenStorageService navigator.clipboard navigator.onLine navigator.share MMCA.Common.UI AddUIShared"},{"u":"/docs/adr/042-device-capability-abstraction.html#decision","d":"ADR-042: Device Capability Abstraction (MAUI Blazor Hybrid)","k":"Architecture Decision Records","t":"Decision","x":"Add a per-capability contract layer to MMCA.Common.UI and a fifteenth package, MMCA.Common.UI.Maui, carrying the native implementations. - One small interface per capability, no…","i":"IExternalLinkService.InterceptsLinks AddBrowserDeviceCapabilities AddDeviceCapabilityDefaults EnforceUIMauiLayerBoundary IConnectivityStatusService AddMauiDeviceCapabilities ILocalNotificationService UseMauiDeviceCapabilities Directory.Packages.props IPushDeviceTokenProvider IPushRegistrationService MauiBackNavigationBridge"},{"u":"/docs/adr/042-device-capability-abstraction.html#rationale","d":"ADR-042: Device Capability Abstraction (MAUI Blazor Hybrid)","k":"Architecture Decision Records","t":"Rationale","x":"- A god IDeviceCapabilities interface would force every head to implement everything and turn each new capability into a breaking change; per-capability contracts are open/closed…","i":"IDeviceCapabilities AddUIShared"},{"u":"/docs/adr/042-device-capability-abstraction.html#trade-offs","d":"ADR-042: Device Capability Abstraction (MAUI Blazor Hybrid)","k":"Architecture Decision Records","t":"Trade-offs","x":"- A fifteenth package raises release surface: two runners must both succeed for a whole release. Accepted; the publish-maui job is gated by the same tag and SBOM discipline. -…","i":"AddMauiDeviceCapabilities UseMauiDeviceCapabilities MauiExternalAuthBroker AddUIShared IsAvailable IsSupported false"},{"u":"/docs/adr/043-mobile-deep-links-and-native-oauth-callback.html","d":"ADR-043: Mobile Deep Links, App Association, and the Native OAuth Callback","k":"Architecture Decision Records"},{"u":"/docs/adr/043-mobile-deep-links-and-native-oauth-callback.html#status","d":"ADR-043: Mobile Deep Links, App Association, and the Native OAuth Callback","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-15). Revised 2026-07-28 (the Android https App Links leg is recorded as shipped, the outstanding Android item is restated as the served certificate fingerprint,…","i":"REPLACE_WITH_PLAY_APP_SIGNING_SHA256_FINGERPRINT WebAuthenticatorCallbackActivity MapAppAssociationEndpoints sha256_cert_fingerprints MauiExternalAuthBroker AppAssociationOptions IDeepLinkDispatcher assetlinks.json MMCA.ADC.UI.Web CompleteAsync MainActivity AutoVerify"},{"u":"/docs/adr/043-mobile-deep-links-and-native-oauth-callback.html#context","d":"ADR-043: Mobile Deep Links, App Association, and the Native OAuth Callback","k":"Architecture Decision Records","t":"Context","x":"Three mobile flows all need a URL to leave the web world and land inside the MAUI app: 1. Shared links and QR codes. The share sheet and QR codes carry ordinary https web URLs.…","i":"OAuthControllerBase.CompleteAsync IDeepLinkDispatcher WebAuthenticator assetlinks.json CompleteAsync"},{"u":"/docs/adr/043-mobile-deep-links-and-native-oauth-callback.html#decision","d":"ADR-043: Mobile Deep Links, App Association, and the Native OAuth Callback","k":"Architecture Decision Records","t":"Decision","x":"- Custom-scheme returnUrl allowlist in the framework. CompleteAsync consults OAuth:AllowedReturnUrlSchemes (a config array, default empty). When the challenge's stashed returnUrl…","i":"IAuthUIService.ExchangeOAuthCodeAsync WebAuthenticatorCallbackActivity ITokenStorageService IDeepLinkDispatcher IExternalAuthBroker Uri.OriginalString CFBundleURLTypes WebAuthenticator assetlinks.json CompleteAsync AutoVerify returnUrl"},{"u":"/docs/adr/043-mobile-deep-links-and-native-oauth-callback.html#rationale","d":"ADR-043: Mobile Deep Links, App Association, and the Native OAuth Callback","k":"Architecture Decision Records","t":"Rationale","x":"- Reusing the single-use-code exchange keeps the token-never-in-URL invariant identical across web and native; the only new surface is WHERE the code lands. - A scheme allowlist…"},{"u":"/docs/adr/043-mobile-deep-links-and-native-oauth-callback.html#trade-offs","d":"ADR-043: Mobile Deep Links, App Association, and the Native OAuth Callback","k":"Architecture Decision Records","t":"Trade-offs","x":"- The app-facing hostname is baked into store binaries (intent filters, entitlements). The apps currently ride the Azure Container Apps default domain, which changes if the…","i":"appsettings.json EmbeddedResource PublicWebHost"},{"u":"/docs/adr/043-mobile-deep-links-and-native-oauth-callback.html#revision-2026-07-28","d":"ADR-043: Mobile Deep Links, App Association, and the Native OAuth Callback","k":"Architecture Decision Records","t":"Revision (2026-07-28)","x":"Correction pass from an ADR audit. No decision or behavior changed; the Status section had the Android leg backwards and the Decision section attributed the token exchange to the…","i":"IAuthUIService.ExchangeOAuthCodeAsync ITokenStorageService.SetTokensAsync MapAppAssociationEndpoints sha256_cert_fingerprints AndroidCertFingerprints BuildSuccessRedirectUrl IDeepLinkDispatcher WebAuthenticator CompleteAsync IntentFilter MainActivity OnNewIntent"},{"u":"/docs/adr/043-mobile-deep-links-and-native-oauth-callback.html#revision-2026-08-01","d":"ADR-043: Mobile Deep Links, App Association, and the Native OAuth Callback","k":"Architecture Decision Records","t":"Revision (2026-08-01)","x":"Status pass from an ADR audit. No decision and no behavior changed; the one item the previous revision left open is closed, and the anchor that revision itself introduced had…","i":"MapAppAssociationEndpoints sha256_cert_fingerprints AndroidCertFingerprints AndroidPackageName assetlinks.json ApplicationId Program.cs d5fd0e9"},{"u":"/docs/adr/043-mobile-deep-links-and-native-oauth-callback.html#revision-2026-08-07","d":"ADR-043: Mobile Deep Links, App Association, and the Native OAuth Callback","k":"Architecture Decision Records","t":"Revision (2026-08-07)","x":"Anchor and precision pass from an ADR audit. No decision and no behavior changed. 1. The two Program.cs anchors moved one line. MMCA.ADC commit 886fa189 (PR 100, merged…","i":"app.MapAppAssociationEndpoints AndroidCertFingerprints AppAssociationOptions AndroidPackageName PublicWebHost GetSection Program.cs new"},{"u":"/docs/adr/044-native-push-delivery.html","d":"ADR-044: Native Push Delivery (the Third Notification Channel)","k":"Architecture Decision Records"},{"u":"/docs/adr/044-native-push-delivery.html#status","d":"ADR-044: Native Push Delivery (the Third Notification Channel)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-11). Amends ADR-024. The framework pipeline is implemented and inert by default; each consumer switches it on by provisioning a notification hub with platform…","i":"NativePush"},{"u":"/docs/adr/044-native-push-delivery.html#context","d":"ADR-044: Native Push Delivery (the Third Notification Channel)","k":"Architecture Decision Records","t":"Context","x":"ADR-024 established two notification channels: a durable per-user UserNotification inbox (the source of truth) and a transient SignalR push behind IPushNotificationSender. Both…","i":"IPushNotificationSender UserNotification"},{"u":"/docs/adr/044-native-push-delivery.html#decision","d":"ADR-044: Native Push Delivery (the Third Notification Channel)","k":"Architecture Decision Records","t":"Decision","x":"- Azure Notification Hubs as the delivery fan-out. One hub abstracts both platforms behind one API, holds the platform credentials outside our code, and its installation model…","i":"INativePushSender.SendToUsersAsync Notification.PushNotifications MauiPushRegistrationService NullPushDeviceTokenProvider SendPushNotificationHandler AddNativePushNotifications AddNotificationControllers AuthUIService.LogoutAsync IPushDeviceTokenProvider IPushRegistrationService PushRegistrationListener IPushDeviceRegistrar"},{"u":"/docs/adr/044-native-push-delivery.html#consequences","d":"ADR-044: Native Push Delivery (the Third Notification Channel)","k":"Architecture Decision Records","t":"Consequences","x":"- Sends fan out per 20-user chunk and per platform: an audience of N users costs ceil(N/20) 2 hub calls. Acceptable at conference scale; a template-based send can consolidate…","i":"SendPushNotificationHandler ceil"},{"u":"/docs/adr/045-managed-file-storage-and-avatars.html","d":"ADR-045: Managed File Storage and User Avatars","k":"Architecture Decision Records"},{"u":"/docs/adr/045-managed-file-storage-and-avatars.html#status","d":"ADR-045: Managed File Storage and User Avatars","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-11). Records the BR-116 amendment (ADC): avatar photos are IN scope, powered by two new framework extension points. The framework legs are implemented; each…"},{"u":"/docs/adr/045-managed-file-storage-and-avatars.html#context","d":"ADR-045: Managed File Storage and User Avatars","k":"Architecture Decision Records","t":"Context","x":"The MAUI capability program (ADR-042) brought MediaPicker/camera within reach, and ADC amended BR-116 to include user avatar photos. That needs binary blob storage (the databases…"},{"u":"/docs/adr/045-managed-file-storage-and-avatars.html#decision","d":"ADR-045: Managed File Storage and User Avatars","k":"Architecture Decision Records","t":"Decision","x":"- IFileStorageService (Application): upload-by-blob-name returning the public URI, plus idempotent delete. Default is an unconfigured Null implementation whose uploads fail with…","i":"ImageSharpImageProcessor AddAzureBlobFileStorage IFileStorageService IMediaPickerService ConnectionString IImageProcessor configuration ContainerName FileStorage IsSupported ServiceUri InputFile"},{"u":"/docs/adr/045-managed-file-storage-and-avatars.html#consequences","d":"ADR-045: Managed File Storage and User Avatars","k":"Architecture Decision Records","t":"Consequences","x":"- The avatars container is public-read by design: avatar URLs render in tags on anonymous-visible surfaces without SAS plumbing. The random blob suffix prevents enumeration; the…","i":"DefaultAzureCredential img"},{"u":"/docs/adr/046-http-api-versioning.html","d":"ADR-046: HTTP API Versioning Strategy","k":"Architecture Decision Records"},{"u":"/docs/adr/046-http-api-versioning.html#status","d":"ADR-046: HTTP API Versioning Strategy","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-15). Revised 2026-08-01 (anonymity is granted by each per-service subclass, not by ServiceInfoControllerBase; corrected the ADR-034 cross-reference, which puts…","i":"ServiceInfoControllerBase AddCommonApiVersioning EntityControllerBase DefaultApiVersion Asp.Versioning"},{"u":"/docs/adr/046-http-api-versioning.html#context","d":"ADR-046: HTTP API Versioning Strategy","k":"Architecture Decision Records","t":"Context","x":"The framework's REST surface is served by controllers hosted in extracted service processes behind a YARP gateway. As those services evolve, a response shape has to be able to…","i":"Asp.Versioning SchemaVersion v1.0"},{"u":"/docs/adr/046-http-api-versioning.html#decision","d":"ADR-046: HTTP API Versioning Strategy","k":"Architecture Decision Records","t":"Decision","x":"Standardize one header-based API-versioning setup in MMCA.Common.API, adopt it in every service host through a single registration call, and keep it exercised by a shared fitness…","i":"ApiParameterDescription.ParameterDescriptor ApiParameterDescriptorBackfillProvider ServiceInfoVersioningContractTestsBase AssumeDefaultVersionWhenUnspecified ServiceInfoControllerBase SubstituteApiVersionInUrl IApiDescriptionProvider AddCommonApiVersioning Asp.Versioning.OpenApi HeaderApiVersionReader ServiceInfoController ServiceInfoV2Response"},{"u":"/docs/adr/046-http-api-versioning.html#rationale","d":"ADR-046: HTTP API Versioning Strategy","k":"Architecture Decision Records","t":"Rationale","x":"- Header selection keeps URLs stable. Routing stays version-free, so gateway route maps, client URL builders, and OpenAPI paths do not fork per version; a caller opts into a…","i":"AssumeDefaultVersionWhenUnspecified ServiceInfoControllerBase AddCommonApiVersioning ReportApiVersions ServiceInfo"},{"u":"/docs/adr/046-http-api-versioning.html#trade-offs","d":"ADR-046: HTTP API Versioning Strategy","k":"Architecture Decision Records","t":"Trade-offs","x":"- The class-level version attributes are not inherited. Each per-service subclass must repeat the [ApiVersion(...)] and routing attributes (the same inheritance caveat ADR-036…","i":"AddCommonApiVersioning MapCommonOpenApi OAuthController ApiVersion"},{"u":"/docs/adr/046-http-api-versioning.html#related","d":"ADR-046: HTTP API Versioning Strategy","k":"Architecture Decision Records","t":"Related","x":"ADR-010 (integration-event schema versioning: the asynchronous, SchemaVersion-carried, consumer-resolved axis this deliberately contrasts with; HTTP versioning here is…","i":"ServiceInfoVersioningContractTestsBase OAuthController ApiController SchemaVersion ApiVersion controller Route"},{"u":"/docs/adr/047-soft-deleted-user-session-revocation.html","d":"ADR-047: Runtime Revocation of Soft-Deleted Users' Active Sessions","k":"Architecture Decision Records"},{"u":"/docs/adr/047-soft-deleted-user-session-revocation.html#status","d":"ADR-047: Runtime Revocation of Soft-Deleted Users' Active Sessions","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-15). Revised 2026-08-07 (validator hoisted into a shared generic, the 30-second constant moved, the two apps revoke at different speeds). Revised 2026-08-23:…","i":"MiddlewarePipelineBuilder WebApplicationExtensions"},{"u":"/docs/adr/047-soft-deleted-user-session-revocation.html#context","d":"ADR-047: Runtime Revocation of Soft-Deleted Users' Active Sessions","k":"Architecture Decision Records","t":"Context","x":"Soft-delete is the framework's default deletion model (ADR-005): AuditableBaseEntity.Delete() sets IsDeleted = true and EF global query filters hide the row, but the record…","i":"AuditableBaseEntity.Delete HttpContext.User IsDeleted true"},{"u":"/docs/adr/047-soft-deleted-user-session-revocation.html#decision","d":"ADR-047: Runtime Revocation of Soft-Deleted Users' Active Sessions","k":"Architecture Decision Records","t":"Decision","x":"Add a shared-pipeline middleware, SoftDeletedUserMiddleware (Source/Presentation/MMCA.Common.API/Middleware/SoftDeletedUserMiddleware.cs:31, BR-133), that rejects an…","i":"DeleteUserHandler.OnAfterSoftDeleteAsync MiddlewarePipelineBuilder.CreateDefault SoftDeletedUserCache.MarkDeletedAsync SoftDeletedUserCache.MarkerDuration context.RequestServices.GetService MiddlewarePipelineBuilder.Build SoftDeletedUserMiddlewareTests AuditableAggregateRootEntity SoftDeletedUserCache.KeyFor UseCommonMiddlewarePipeline ICurrentUserService.UserId TenantResolutionMiddleware"},{"u":"/docs/adr/047-soft-deleted-user-session-revocation.html#rationale","d":"ADR-047: Runtime Revocation of Soft-Deleted Users' Active Sessions","k":"Architecture Decision Records","t":"Rationale","x":"- Bounds the stateless-JWT revocation gap cheaply. Stateless JWT (ADR-004) has no built-in revocation, so a deactivated account would otherwise stay usable for the full remaining…","i":"ISoftDeletedUserValidator SoftDeletedUserValidator MMCA.Common.API TUser User"},{"u":"/docs/adr/047-soft-deleted-user-session-revocation.html#trade-offs","d":"ADR-047: Runtime Revocation of Soft-Deleted Users' Active Sessions","k":"Architecture Decision Records","t":"Trade-offs","x":"- Revocation is bounded, not immediate. A soft-deleted user whose status is cached as not-deleted keeps passing until that cache entry expires (up to 30 seconds), unless the…","i":"SoftDeletedUserCache.MarkDeletedAsync SoftDeletedUserCache.MarkerDuration ISoftDeletedUserValidator DeleteUserHandler"},{"u":"/docs/adr/047-soft-deleted-user-session-revocation.html#related","d":"ADR-047: Runtime Revocation of Soft-Deleted Users' Active Sessions","k":"Architecture Decision Records","t":"Related","x":"ADR-005 (soft-delete is the deletion model whose still-authenticated tokens this middleware revokes; deleting a user is a soft-delete, not a row removal), ADR-004 (the stateless…","i":"TenantResolutionMiddleware"},{"u":"/docs/adr/047-soft-deleted-user-session-revocation.html#revision-2026-08-07","d":"ADR-047: Runtime Revocation of Soft-Deleted Users' Active Sessions","k":"Architecture Decision Records","t":"Revision (2026-08-07)","x":"Re-verified against current source. The decision is unchanged, but three things it described have moved: the validator implementation, the home of the 30-second constant, and the…","i":"SoftDeletedUserCache.MarkerDuration SoftDeletedUserMiddleware SoftDeletedUserValidator TimeSpan.FromSeconds DeleteUserHandler CacheDuration UserId TUser true User"},{"u":"/docs/adr/047-soft-deleted-user-session-revocation.html#revision-2026-08-23","d":"ADR-047: Runtime Revocation of Soft-Deleted Users' Active Sessions","k":"Architecture Decision Records","t":"Revision (2026-08-23)","x":"Re-verified against current source. The decision, the cache design, the fail-open policy and the per-app asymmetry are all unchanged; what moved is where the middleware's…","i":"MiddlewarePipelineBuilder.CreateDefault app.UseCommonMiddlewarePipeline MiddlewarePipelineBuilder.Build UseCommonMiddlewarePipeline WebApplicationExtensions.cs TenantResolutionMiddleware ISoftDeletedUserValidator SoftDeletedUserMiddleware SoftDeletedUserFilter UseAuthentication TenantResolution UseAuthorization"},{"u":"/docs/adr/048-primitive-identifier-type-aliases.html","d":"ADR-048: Primitive Identifier Type Aliases over Strongly-Typed ID Structs","k":"Architecture Decision Records"},{"u":"/docs/adr/048-primitive-identifier-type-aliases.html#status","d":"ADR-048: Primitive Identifier Type Aliases over Strongly-Typed ID Structs","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-15). Revised 2026-07-21 (corrected the empty-placeholder-folder inventory and the Directory.Build.props and ADC User source citations). Revised 2026-07-28…","i":"ActivityIdentifierType Directory.Build.props SponsorIdentifierType UserIdentifierType StronglyTypedIds User"},{"u":"/docs/adr/048-primitive-identifier-type-aliases.html#context","d":"ADR-048: Primitive Identifier Type Aliases over Strongly-Typed ID Structs","k":"Architecture Decision Records","t":"Context","x":"Every entity needs an identity type. The framework's base entity is generic over that type: BaseEntity constrains it to notnull and exposes a single required init Id…","i":"UserIdentifierType TIdentifierType IBaseEntity BaseEntity readonly required notnull record struct UserId Value Guid"},{"u":"/docs/adr/048-primitive-identifier-type-aliases.html#decision","d":"ADR-048: Primitive Identifier Type Aliases over Strongly-Typed ID Structs","k":"Architecture Decision Records","t":"Decision","x":"Model every identifier as a primitive named through a global-using alias, declared per module, not as a wrapper struct. - Identity is a primitive behind an alias. Each module…","i":"EntityTypeConfigurationSQLServer AuditableAggregateRootEntity AuthenticationServiceBase Directory.Build.props SpeakerIdentifierType AuditableBaseEntity UserIdentifierType LinkedSpeakerId IdentifierType LastModifiedBy GetRepository System.Guid"},{"u":"/docs/adr/048-primitive-identifier-type-aliases.html#rationale","d":"ADR-048: Primitive Identifier Type Aliases over Strongly-Typed ID Structs","k":"Architecture Decision Records","t":"Rationale","x":"- Readable signatures at zero runtime cost. GetRepository () reads as intent while the CLR sees a plain int. There is no allocation, boxing, or wrapper indirection per…","i":"UserIdentifierType IEntityDTOMapper System.Text.Json GetRepository JsonConverter IBaseEntity BaseEntity IBaseDTO Shared Guid User int"},{"u":"/docs/adr/048-primitive-identifier-type-aliases.html#trade-offs","d":"ADR-048: Primitive Identifier Type Aliases over Strongly-Typed ID Structs","k":"Architecture Decision Records","t":"Trade-offs","x":"- No compile-time protection against swapping same-typed identifiers. An alias is a type synonym, not a distinct type. Because most aliases resolve to int, the compiler will not…","i":"SessionIdentifierType SpeakerIdentifierType UserIdentifierType Shared Guid int"},{"u":"/docs/adr/048-primitive-identifier-type-aliases.html#related","d":"ADR-048: Primitive Identifier Type Aliases over Strongly-Typed ID Structs","k":"Architecture Decision Records","t":"Related","x":"ADR-001 (the per-entity DTO mappers are parameterized by this identifier type, IEntityDTOMapper ), ADR-034 (the generic entity controllers and query contract ride on the same…","i":"IEntityDTOMapper TIdentifierType TEntityDTO TEntity Shared"},{"u":"/docs/adr/048-primitive-identifier-type-aliases.html#revision-2026-08-18","d":"ADR-048: Primitive Identifier Type Aliases over Strongly-Typed ID Structs","k":"Architecture Decision Records","t":"Revision (2026-08-18)","x":"No decision, no behavior and no citation in this record changed. What changed is the standing of the deferral it records. The last Trade-offs entry above (\"Revisiting the trade…","i":"UserIdentifierType TIdentifierType CheckIn Source razor int"},{"u":"/docs/adr/048-primitive-identifier-type-aliases.html#revision-2026-08-23","d":"ADR-048: Primitive Identifier Type Aliases over Strongly-Typed ID Structs","k":"Architecture Decision Records","t":"Revision (2026-08-23)","x":"No decision and no rationale changed. Two counts did, both because Conference gained an alias. Conference's alias file declares seventeen aliases, ActivityIdentifierType = int…","i":"ActivityIdentifierType SpeakerIdentifierType System.Guid Guid int"},{"u":"/docs/adr/049-library-configureawait-policy.html","d":"ADR-049: Library-Scoped ConfigureAwait(false) Policy (CA2007)","k":"Architecture Decision Records"},{"u":"/docs/adr/049-library-configureawait-policy.html#status","d":"ADR-049: Library-Scoped ConfigureAwait(false) Policy (CA2007)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-20; measurements re-anchored 2026-08-07, 2026-08-14, 2026-08-18 and 2026-08-23)."},{"u":"/docs/adr/049-library-configureawait-policy.html#context","d":"ADR-049: Library-Scoped ConfigureAwait(false) Policy (CA2007)","k":"Architecture Decision Records","t":"Context","x":"MMCA.Common ships as NuGet packages consumed by host applications, not as an application itself. Library code that awaits without ConfigureAwait(false) captures the caller's…","i":"SynchronizationContext MMCA.Common.UI.Maui ConfigureAwait editorconfig VSTHRD111 RCS1090 CA2007 MA0004 false"},{"u":"/docs/adr/049-library-configureawait-policy.html#decision","d":"ADR-049: Library-Scoped ConfigureAwait(false) Policy (CA2007)","k":"Architecture Decision Records","t":"Decision","x":"Packaged non-UI framework code awaits with ConfigureAwait(false); UI component packages and application code do not. - Enforcement is a build gate, not a convention. The…","i":"TreatWarningsAsErrors MMCA.Common.UI.Maui MMCA.Common.UI.Web ConfigureAwait MMCA.Common.UI editorconfig VSTHRD111 RCS1090 warning CA2007 MA0004 false"},{"u":"/docs/adr/049-library-configureawait-policy.html#rationale","d":"ADR-049: Library-Scoped ConfigureAwait(false) Policy (CA2007)","k":"Architecture Decision Records","t":"Rationale","x":"- Correctness for the one consumer that already has a context. The MAUI head consumes Infrastructure/Application/API packages through DI; a sync-over-async call anywhere in that…","i":"ConfigureAwait GetAwaiter GetResult script batch false fixes place step but"},{"u":"/docs/adr/049-library-configureawait-policy.html#trade-offs","d":"ADR-049: Library-Scoped ConfigureAwait(false) Policy (CA2007)","k":"Architecture Decision Records","t":"Trade-offs","x":"- Visual noise in framework source. Every await in Source/ (except UI packages) carries .ConfigureAwait(false) (324 sites at adoption; 767 gated sites as of the 2026-08-23…","i":"ConfigureAwait editorconfig false"},{"u":"/docs/adr/049-library-configureawait-policy.html#related","d":"ADR-049: Library-Scoped ConfigureAwait(false) Policy (CA2007)","k":"Architecture Decision Records","t":"Related","x":"ADR-042 (the MAUI package whose synchronization context motivates the policy), ADR-027 (the same \"machine-boundary hygiene as a build gate\" posture applied to culture-explicit…"},{"u":"/docs/adr/049-library-configureawait-policy.html#revision-2026-08-07","d":"ADR-049: Library-Scoped ConfigureAwait(false) Policy (CA2007)","k":"Architecture Decision Records","t":"Revision (2026-08-07)","x":"An audit against the code. The policy did not change; three statements about it did. 1. The exemption covers three packages, not the two the Decision named. The glob is…","i":"CodeAnalysisTreatWarningsAsErrors ServerTokenStorageService TreatWarningsAsErrors MMCA.Common.UI.Maui MMCA.Common.UI.Web ConfigureAwait MMCA.Common.UI analyzers PackageId foreach warning CA2007"},{"u":"/docs/adr/049-library-configureawait-policy.html#revision-2026-08-14","d":"ADR-049: Library-Scoped ConfigureAwait(false) Policy (CA2007)","k":"Architecture Decision Records","t":"Revision (2026-08-14)","x":"A re-measurement only. The policy, the gate and the exemption are unchanged; the counts the document quotes were a week old and had moved by roughly 9%. 1. Framework site counts,…","i":"CodeAnalysisTreatWarningsAsErrors ServerTokenStorageService TreatWarningsAsErrors MMCA.Common.UI.Maui MMCA.Common.UI.Web ConfigureAwait MMCA.Common.UI analyzers PackageId warning CA2007 dotnet"},{"u":"/docs/adr/049-library-configureawait-policy.html#revision-2026-08-18","d":"ADR-049: Library-Scoped ConfigureAwait(false) Policy (CA2007)","k":"Architecture Decision Records","t":"Revision (2026-08-18)","x":"A re-measurement only, in the same terms as the 2026-08-14 pass. The policy, the gate and the exemption are unchanged; two of the three counted figures moved. 1. Framework site…","i":"CodeAnalysisTreatWarningsAsErrors TreatWarningsAsErrors MMCA.Common.UI.Maui MMCA.Common.UI.Web ConfigureAwait MMCA.Common.UI analyzers warning CA2007 dotnet format await"},{"u":"/docs/adr/049-library-configureawait-policy.html#revision-2026-08-23","d":"ADR-049: Library-Scoped ConfigureAwait(false) Policy (CA2007)","k":"Architecture Decision Records","t":"Revision (2026-08-23)","x":"A re-measurement only, in the same terms as the 2026-08-18 pass. The policy, the gate and the exemption are unchanged; both counted figures moved. 1. Framework site counts,…","i":"CodeAnalysisTreatWarningsAsErrors ServerTokenStorageService TreatWarningsAsErrors MMCA.Common.UI.Maui MMCA.Common.UI.Web ConfigureAwait MMCA.Common.UI analyzers PackageId warning CA2007 dotnet"},{"u":"/docs/adr/050-jwt-refresh-token-rotation.html","d":"ADR-050: JWT Access Tokens with a Single Rotating Refresh Token and Reuse Detection","k":"Architecture Decision Records"},{"u":"/docs/adr/050-jwt-refresh-token-rotation.html#status","d":"ADR-050: JWT Access Tokens with a Single Rotating Refresh Token and Reuse Detection","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-21)."},{"u":"/docs/adr/050-jwt-refresh-token-rotation.html#context","d":"ADR-050: JWT Access Tokens with a Single Rotating Refresh Token and Reuse Detection","k":"Architecture Decision Records","t":"Context","x":"Identity issues two credentials on every successful sign-in: a short-lived, stateless JWT access token that every service validates by signature and expiry (ADR-004), and a…","i":"AuthenticationServiceBase TUser"},{"u":"/docs/adr/050-jwt-refresh-token-rotation.html#decision","d":"ADR-050: JWT Access Tokens with a Single Rotating Refresh Token and Reuse Detection","k":"Architecture Decision Records","t":"Decision","x":"Mint a stateless JWT access token plus a single, server-stored refresh token that rotates on every use, with a token mismatch triggering revocation. - Access token is stateless;…","i":"TokenService.GetPrincipalFromExpiredToken JwtSettings.AccessTokenExpirationMinutes JwtSettings.RefreshTokenExpirationDays TokenService.GenerateRefreshToken TokenService.RefreshTokenLifetime TokenService.GenerateAccessToken RandomNumberGenerator.GetBytes user.RevokeRefreshToken user.UpdateRefreshToken AuthenticationService RefreshTokenLifetime RefreshTokenExpiry"},{"u":"/docs/adr/050-jwt-refresh-token-rotation.html#rationale","d":"ADR-050: JWT Access Tokens with a Single Rotating Refresh Token and Reuse Detection","k":"Architecture Decision Records","t":"Rationale","x":"- Short access token plus refresh keeps the hot path stateless. Every service validates the access token with no store lookup (ADR-004); the short exp bounds the revocation gap,…","i":"exp"},{"u":"/docs/adr/050-jwt-refresh-token-rotation.html#trade-offs","d":"ADR-050: JWT Access Tokens with a Single Rotating Refresh Token and Reuse Detection","k":"Architecture Decision Records","t":"Trade-offs","x":"- One refresh token per user means one live session. A new login overwrites the single stored token (AuthenticationServiceBase.cs:298), so signing in on a second device…","i":"JwtSettings.RefreshTokenExpirationDays RefreshTokenExpirationDays RefreshTokenLifetime TimeSpan.Zero TokenService"},{"u":"/docs/adr/050-jwt-refresh-token-rotation.html#related","d":"ADR-050: JWT Access Tokens with a Single Rotating Refresh Token and Reuse Detection","k":"Architecture Decision Records","t":"Related","x":"ADR-004 (the stateless RS256/JWKS access token this refresh flow reissues, and the algorithm pinning GetPrincipalFromExpiredToken relies on), ADR-032 (the password hashing that…","i":"GetPrincipalFromExpiredToken AuthenticationServiceBase TUser"},{"u":"/docs/adr/051-client-auth-token-lifecycle.html","d":"ADR-051: Client-Side Authentication Token Lifecycle Across Render Modes","k":"Architecture Decision Records"},{"u":"/docs/adr/051-client-auth-token-lifecycle.html#status","d":"ADR-051: Client-Side Authentication Token Lifecycle Across Render Modes","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-23). Revised 2026-08-14 (SetTokensAsync now writes the refresh token and the access token under one shared guard, so a failed refresh-token write also drops…","i":"SetTokensAsync"},{"u":"/docs/adr/051-client-auth-token-lifecycle.html#context","d":"ADR-051: Client-Side Authentication Token Lifecycle Across Render Modes","k":"Architecture Decision Records","t":"Context","x":"ADR-022 and ADR-050 describe the two server halves of authentication: the Blazor host's HttpOnly session cookie that survives SSR prerender (ADR-022), and the Identity service's…","i":"HttpContext"},{"u":"/docs/adr/051-client-auth-token-lifecycle.html#decision","d":"ADR-051: Client-Side Authentication Token Lifecycle Across Render Modes","k":"Architecture Decision Records","t":"Decision","x":"Model the client token lifecycle as two small abstractions (ITokenStorageService for persistence, ITokenRefresher for reacquisition) plus a shared bearer-attaching handler and a…","i":"AddClientAuthSessionCookieSync JwtAuthenticationStateProvider SameOriginProxyTokenRefresher ISessionCookieSync.SyncAsync AddCommonServerTokenStorage AddCommonMauiTokenStorage ServerTokenStorageService mmcaAuthSession.getToken NotifyUserAuthentication AcquireAccessTokenAsync DirectApiTokenRefresher WasmTokenStorageService"},{"u":"/docs/adr/051-client-auth-token-lifecycle.html#rationale","d":"ADR-051: Client-Side Authentication Token Lifecycle Across Render Modes","k":"Architecture Decision Records","t":"Rationale","x":"- One application surface, three storage stories. Pages, services, and the HTTP pipeline talk to ITokenStorageService and AuthenticationStateProvider only; the head-specific…","i":"AuthenticationStateProvider ITokenStorageService"},{"u":"/docs/adr/051-client-auth-token-lifecycle.html#trade-offs","d":"ADR-051: Client-Side Authentication Token Lifecycle Across Render Modes","k":"Architecture Decision Records","t":"Trade-offs","x":"- The browser heads depend on the same-origin UI host. SameOriginProxyTokenRefresher only works where the UI host serves the /auth/session/ endpoints; a browser head deployed…","i":"JwtAuthenticationStateProvider SameOriginProxyTokenRefresher MMCA.Common.UI.Maui MMCA.Common.UI.Web MMCA.Common.slnx MMCA.Common.UI AuthorizeView SecureStorage"},{"u":"/docs/adr/051-client-auth-token-lifecycle.html#related","d":"ADR-051: Client-Side Authentication Token Lifecycle Across Render Modes","k":"Architecture Decision Records","t":"Related","x":"ADR-022 (the Blazor host's HttpOnly session cookie and the /auth/session/ endpoints the browser refresher proxies through), ADR-050 (the single rotating refresh token with reuse…","i":"DirectApiTokenRefresher MauiTokenStorageService"},{"u":"/docs/adr/051-client-auth-token-lifecycle.html#revision-2026-08-07","d":"ADR-051: Client-Side Authentication Token Lifecycle Across Render Modes","k":"Architecture Decision Records","t":"Revision (2026-08-07)","x":"The MAUI half of ITokenStorageService is no longer app-local. The original Decision left the SecureStorage-backed implementation in each app because it depends on the MAUI…","i":"JwtAuthenticationStateProvider MauiTokenStorageService.cs AddCommonMauiTokenStorage DirectApiTokenRefresher MauiTokenStorageService SecureStorage.Default ITokenStorageService MMCA.Common.UI.Maui auth_refresh_token auth_access_token ClearTokensAsync MMCA.Common.slnx"},{"u":"/docs/adr/051-client-auth-token-lifecycle.html#revision-2026-08-14","d":"ADR-051: Client-Side Authentication Token Lifecycle Across Render Modes","k":"Architecture Decision Records","t":"Revision (2026-08-14)","x":"SetTokensAsync closed a gap the original hoist left open. Point 3 above previously described the method as writing the refresh token first and dropping both tokens only when the…","i":"SetTokensAsync catch try"},{"u":"/docs/adr/052-background-job-execution.html","d":"ADR-052: Background Job Execution (Bounded Queue plus Hosted Drain)","k":"Architecture Decision Records"},{"u":"/docs/adr/052-background-job-execution.html#status","d":"ADR-052: Background Job Execution (Bounded Queue plus Hosted Drain)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-24). Revised 2026-08-23 (post-commit enqueue is recorded as two patterns, not one: see the revision at the end)."},{"u":"/docs/adr/052-background-job-execution.html#context","d":"ADR-052: Background Job Execution (Bounded Queue plus Hosted Drain)","k":"Architecture Decision Records","t":"Context","x":"Some requests trigger work that cannot run inside the request: an AI scoring pass over an event's sessions takes minutes and issues one paid API call per session, and a…","i":"RunScoringInBackgroundAsync IHostApplicationLifetime eventId"},{"u":"/docs/adr/052-background-job-execution.html#decision","d":"ADR-052: Background Job Execution (Bounded Queue plus Hosted Drain)","k":"Architecture Decision Records","t":"Decision","x":"In-process background work runs as a bounded queue plus a single-reader hosted drain. Nothing starts an untracked Task from a request. - A bounded Channel per job kind,…","i":"BoundedChannelFullMode.DropOldest LiveChannelPublishProcessor unitOfWork.SaveChangesAsync LiveChannelPublishQueue SessionScoringProcessor sp.GetRequiredService IServiceScopeFactory SessionScoringQueue BackgroundService SaveChangesAsync TryAddSingleton ITransactional"},{"u":"/docs/adr/052-background-job-execution.html#rationale","d":"ADR-052: Background Job Execution (Bounded Queue plus Hosted Drain)","k":"Architecture Decision Records","t":"Rationale","x":"- The host lifetime is the point. A BackgroundService is the only in-process shape the host can cancel and await. Everything else in the decision follows from wanting that. - The…","i":"BackgroundService TryEnqueue"},{"u":"/docs/adr/052-background-job-execution.html#trade-offs","d":"ADR-052: Background Job Execution (Bounded Queue plus Hosted Drain)","k":"Architecture Decision Records","t":"Trade-offs","x":"- In-process only. The queue does not survive a restart and does not span replicas. Accepted because every current job is either ephemeral (a lost broadcast is a missed UI…","i":"DropOldest Wait"},{"u":"/docs/adr/052-background-job-execution.html#related","d":"ADR-052: Background Job Execution (Bounded Queue plus Hosted Drain)","k":"Architecture Decision Records","t":"Related","x":"ADR-003 (the outbox, for work that must be durable and cross-process, and the post-commit deferral this relies on), ADR-008 (the broker, for cross-service work), ADR-014 (the…"},{"u":"/docs/adr/052-background-job-execution.html#revision-2026-08-23","d":"ADR-052: Background Job Execution (Bounded Queue plus Hosted Drain)","k":"Architecture Decision Records","t":"Revision (2026-08-23)","x":"The decision is unchanged: post-commit work is still enqueued only once the write is durable. What changed is the record of how. This ADR stated the domain-event handler as the…","i":"SessionQuestionUpvoteChangedHandler TransactionalCommandDecorator SessionQuestionUpvoteChanged unitOfWork.SaveChangesAsync LivePollVoteChangedHandler BestEffort.ExecuteAsync ModerateQuestionHandler EnqueueModeratedAsync EnqueueSubmittedAsync SubmitQuestionHandler CloseLivePollHandler IDomainEventHandler"},{"u":"/docs/adr/053-dual-registry-package-publishing.html","d":"ADR-053: Dual-Registry Package Publishing (nuget.org plus GitHub Packages)","k":"Architecture Decision Records"},{"u":"/docs/adr/053-dual-registry-package-publishing.html#status","d":"ADR-053: Dual-Registry Package Publishing (nuget.org plus GitHub Packages)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-25). Amended (2026-07-25) to put the pre-decision Context statements in the past tense, to record the MMCA. ID prefix reservation as then-pending, to scope the…","i":"Directory.Build.props MMCA"},{"u":"/docs/adr/053-dual-registry-package-publishing.html#context","d":"ADR-053: Dual-Registry Package Publishing (nuget.org plus GitHub Packages)","k":"Architecture Decision Records","t":"Context","x":"The fifteen MMCA.Common. packages have shipped to GitHub Packages since the first release. That was the right default while the framework had exactly one consumer group (this…","i":"MMCA.Common.API nuget.config local.props MMCA.Common totalHits package dotnet add"},{"u":"/docs/adr/053-dual-registry-package-publishing.html#decision","d":"ADR-053: Dual-Registry Package Publishing (nuget.org plus GitHub Packages)","k":"Architecture Decision Records","t":"Decision","x":"Every release publishes to both registries, from the same tag, in the same workflow run. - release.yml keeps its existing dotnet nuget push to…","i":"github.repository_owner Directory.Build.props PackageProjectUrl PackageReadmeFile Description MMCA.Common PackageIcon PackageTags permissions release.yml README.md ivanball"},{"u":"/docs/adr/053-dual-registry-package-publishing.html#rationale","d":"ADR-053: Dual-Registry Package Publishing (nuget.org plus GitHub Packages)","k":"Architecture Decision Records","t":"Rationale","x":"- The install line has to be true. Documentation that cannot be followed is worse than no documentation, because the reader concludes the project is broken rather than that the…","i":"MMCA"},{"u":"/docs/adr/053-dual-registry-package-publishing.html#trade-offs","d":"ADR-053: Dual-Registry Package Publishing (nuget.org plus GitHub Packages)","k":"Architecture Decision Records","t":"Trade-offs","x":"- A published version can never be withdrawn. nuget.org allows unlisting, not deletion. A bad release is now permanent public history, which raises the stakes on the release…","i":"release.yml ivanball"},{"u":"/docs/adr/053-dual-registry-package-publishing.html#related","d":"ADR-053: Dual-Registry Package Publishing (nuget.org plus GitHub Packages)","k":"Architecture Decision Records","t":"Related","x":"ADR-016 (lockstep versioning: every package ships at one version, so both registries receive the same fifteen ids per release), ADR-038 (supply-chain provenance: the SBOM hard…"},{"u":"/docs/adr/054-saga-compensation-and-reconciliation.html","d":"ADR-054: Choreographed Saga Compensation with a Reconciliation Backstop","k":"Architecture Decision Records"},{"u":"/docs/adr/054-saga-compensation-and-reconciliation.html#status","d":"ADR-054: Choreographed Saga Compensation with a Reconciliation Backstop","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-25). Amended (2026-07-28): Store's reconciliation sweep now derives from PeriodicBackgroundService, so the shared-loop and adoption paragraphs are rewritten and…","i":"PeriodicBackgroundService SafeDomainEventHandler TDomainEvent IUnitOfWork maxReplicas"},{"u":"/docs/adr/054-saga-compensation-and-reconciliation.html#context","d":"ADR-054: Choreographed Saga Compensation with a Reconciliation Backstop","k":"Architecture Decision Records","t":"Context","x":"Checkout spans a boundary no transaction covers. CheckOutHandler commits the order insert, the cart transition and the atomic conditional stock decrements in one local…","i":"PaymentInitiated CheckOutHandler"},{"u":"/docs/adr/054-saga-compensation-and-reconciliation.html#decision","d":"ADR-054: Choreographed Saga Compensation with a Reconciliation Backstop","k":"Architecture Decision Records","t":"Decision","x":"Multi-step workflows are choreographed sagas: each step raises a domain event, and the follow-up or compensating action lives in its own handler. A periodic reconciliation sweep…","i":"OrderPaymentFailedSagaHandler DbUpdateConcurrencyException PaymentReconciliationService OperationCanceledException OrderCancelledSagaHandler PeriodicBackgroundService Order.InventoryRestored SafeDomainEventHandler MarkInventoryRestored IServiceScopeFactory IDomainEventHandler MarkAsPaymentFailed"},{"u":"/docs/adr/054-saga-compensation-and-reconciliation.html#rationale","d":"ADR-054: Choreographed Saga Compensation with a Reconciliation Backstop","k":"Architecture Decision Records","t":"Rationale","x":"- No two-phase commit is available, and none is wanted. Transactions are per data source and best-effort sequential (ADR-006), and an external payment provider cannot enlist in a…","i":"Order.InventoryRestored Order.Status SaveChanges Result catch"},{"u":"/docs/adr/054-saga-compensation-and-reconciliation.html#trade-offs","d":"ADR-054: Choreographed Saga Compensation with a Reconciliation Backstop","k":"Architecture Decision Records","t":"Trade-offs","x":"- Inconsistency is bounded, not eliminated. Between the cancellation commit and the compensation commit, stock is held against a cancelled order. Between a dropped webhook and…","i":"PaymentInitiated RestoreInventory InventoryItem maxReplicas"},{"u":"/docs/adr/054-saga-compensation-and-reconciliation.html#related","d":"ADR-054: Choreographed Saga Compensation with a Reconciliation Backstop","k":"Architecture Decision Records","t":"Related","x":"ADR-003 (the outbox delivery and retry this leans on for compensation redelivery; this record says what the redelivered handler must do), ADR-006 (which accepts \"no…","i":"RowVersion Result"},{"u":"/docs/adr/055-repository-and-specification-contract.html","d":"ADR-055: Repository plus Specification as the Data-Access Contract","k":"Architecture Decision Records"},{"u":"/docs/adr/055-repository-and-specification-contract.html#status","d":"ADR-055: Repository plus Specification as the Data-Access Contract","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-25). Revised 2026-08-01 (qualified the \"referenced nowhere\" claim about IEntityReader / IEntityQuerier: an ADC doc comment now names IEntityQuerier, though no…","i":"DependencyInjection.cs DependencyInjection EFReadRepository.cs IEntityQueryService QuerySpecification SessionsController Expression.Invoke EFReadRepository IEntityQuerier IRepository.cs IEntityReader ListAsync"},{"u":"/docs/adr/055-repository-and-specification-contract.html#context","d":"ADR-055: Repository plus Specification as the Data-Access Contract","k":"Architecture Decision Records","t":"Context","x":"Every read an application handler performs has to come from somewhere, and the shape of that contract decides whether the module can still be lifted into its own service later…","i":"TIdentifierType IQueryable DbSet"},{"u":"/docs/adr/055-repository-and-specification-contract.html#decision","d":"ADR-055: Repository plus Specification as the Data-Access Contract","k":"Architecture Decision Records","t":"Decision","x":"Data access is repository plus specification: interface-segregated read interfaces for the operations, expression-tree specifications for the predicates, and a build-failing…","i":"CrossSourceSpecification.BuildAsync IUnitOfWork.GetReadRepository OrdersByCustomerSpecification PublishedEventSpecification TableNoTrackingSingleQuery ParameterReplacer.Replace TableNoTrackingSplitQuery OwnedByUserSpecification publicSpecification.And EntityQueryService.cs ProductVariantService SpecificationComposer"},{"u":"/docs/adr/055-repository-and-specification-contract.html#rationale","d":"ADR-055: Repository plus Specification as the Data-Access Contract","k":"Architecture Decision Records","t":"Rationale","x":"- A narrow interface is the enforcement, not a style preference. A handler that asks for IEntityReader cannot reach TableNoTracking, because the member is not on the interface.…","i":"GetProjectedAsync TableNoTracking IEntityReader IsSatisfiedBy AllowedFiles GetByIdAsync CountAsync IQueryable Criteria"},{"u":"/docs/adr/055-repository-and-specification-contract.html#trade-offs","d":"ADR-055: Repository plus Specification as the Data-Access Contract","k":"Architecture Decision Records","t":"Trade-offs","x":"- The ISP split is guidance, not yet a wired dependency. (Superseded by the Revision (2026-08-21) below: the split has real dependents shipped in both MMCA.ADC and MMCA.Store,…","i":"QuerySpecification Expression.Invoke ParameterReplacer Specification.cs ISpecification IEntityReader IUnitOfWork Criteria"},{"u":"/docs/adr/055-repository-and-specification-contract.html#related","d":"ADR-055: Repository plus Specification as the Data-Access Contract","k":"Architecture Decision Records","t":"Related","x":"ADR-007 and ADR-008 (the extraction promise the queryable ban exists to protect), ADR-015 (the fitness-function machinery that runs this rule and its per-repo maps), ADR-014 (the…","i":"SpecificationsDoNotNavigateToOtherEntities TIdentifierType"},{"u":"/docs/adr/055-repository-and-specification-contract.html#revision-2026-08-18","d":"ADR-055: Repository plus Specification as the Data-Access Contract","k":"Architecture Decision Records","t":"Revision (2026-08-18)","x":"Five changes, four of them widening the contract and one of them fixing a correctness defect. The decision this record states is unchanged: data access is still repository plus…","i":"NavigationMetadata.UnsupportedIncludes QueryFieldService.ApplySorting PushNotificationDTOProjection PushNotificationDTOProjector KeysetQueryBuilder.Compare PaginationTieBreakProperty EFReadRepositoryDecorator CrossSourceSpecification Error.InvalidEntityField SpecificationExtensions KeysetCollectionResult ExecuteProjectedAsync"},{"u":"/docs/adr/055-repository-and-specification-contract.html#revision-2026-08-21","d":"ADR-055: Repository plus Specification as the Data-Access Contract","k":"Architecture Decision Records","t":"Revision (2026-08-21)","x":"Nothing in the contract changed; its consumers did. This revision records the first real adoption of the two surfaces this record had honestly flagged as unconsumed: the narrow…","i":"PublicConferenceVisibility SpecificationExtensions specification.Criteria ProductVariantService GetPageByCursorAsync GetProjectedAsync GetReadRepository AndSpecification IReadRepository IEntityQuerier IEntityReader specification"},{"u":"/docs/adr/056-blazor-render-mode-strategy.html","d":"ADR-056: Blazor Render-Mode Strategy for the Web Heads","k":"Architecture Decision Records"},{"u":"/docs/adr/056-blazor-render-mode-strategy.html#status","d":"ADR-056: Blazor Render-Mode Strategy for the Web Heads","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-28). Revised 2026-08-14: re-anchored the host, base-class and AppHost citations to their current lines; scoped the \"only @rendermode attributes\" enumeration to…","i":"InteractiveServer rendermode App.razor MudTable ADCHome"},{"u":"/docs/adr/056-blazor-render-mode-strategy.html#context","d":"ADR-056: Blazor Render-Mode Strategy for the Web Heads","k":"Architecture Decision Records","t":"Context","x":"Both web applications are Blazor Web Apps: a static server-rendered (SSR) prerender pass produces the first HTML, then an interactive runtime takes over, either a Blazor Server…","i":"InteractiveAuto App.razor Authorize"},{"u":"/docs/adr/056-blazor-render-mode-strategy.html#decision","d":"ADR-056: Blazor Render-Mode Strategy for the Web Heads","k":"Architecture Decision Records","t":"Decision","x":"Run one render mode for the entire routable component tree, chosen at the application root, default InteractiveAuto, with prerendering left on and the resulting double fetch…","i":"AddInteractiveWebAssemblyComponents AddInteractiveWebAssemblyRenderMode AddInteractiveServerComponents AddInteractiveServerRenderMode RendererInfo.IsInteractive RenderMode.InteractiveAuto PersistentComponentState PrerenderFetchTimeoutMs InteractiveWebAssembly DataGridListPageBase OnParametersSetAsync RegisterOnPersisting"},{"u":"/docs/adr/056-blazor-render-mode-strategy.html#rationale","d":"ADR-056: Blazor Render-Mode Strategy for the Web Heads","k":"Architecture Decision Records","t":"Rationale","x":"- InteractiveAuto gets both halves without asking page authors to choose. The first visit gets the Server circuit's immediate interactivity while the WASM bundle downloads in the…","i":"InteractiveServer InteractiveAuto CatalogBrowse Authorize"},{"u":"/docs/adr/056-blazor-render-mode-strategy.html#trade-offs","d":"ADR-056: Blazor Render-Mode Strategy for the Web Heads","k":"Architecture Decision Records","t":"Trade-offs","x":"- Everything shared has to run in both runtimes. The WASM-compatibility layer rule (MMCA.Common.LayerEnforcement.targets:75-88) forbids the shared UI package from touching…","i":"RendererInfo.IsInteractive AddAdditionalAssemblies DataGridListPageBase MMCA.Common.UI.Web OnAfterRenderAsync InteractiveServer InteractiveAuto CatalogBrowse AddUIShared Program.cs Routes"},{"u":"/docs/adr/056-blazor-render-mode-strategy.html#related","d":"ADR-056: Blazor Render-Mode Strategy for the Web Heads","k":"Architecture Decision Records","t":"Related","x":"ADR-022 (reads the HttpOnly session cookie during the SSR prerender pass this decision keeps enabled), ADR-027 (flows one culture through the SSR to Server to WASM sequence this…","i":"InteractiveAuto"},{"u":"/docs/adr/057-expand-contract-schema-evolution-gate.html","d":"ADR-057: Expand/Contract Schema Evolution Enforced as a CI Gate","k":"Architecture Decision Records"},{"u":"/docs/adr/057-expand-contract-schema-evolution-gate.html#status","d":"ADR-057: Expand/Contract Schema Evolution Enforced as a CI Gate","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-28). Revised 2026-08-01: the diff now fails closed in both repos (the true is gone and MMCA.Store's build-and-test checkout sets fetch-depth: 0), so the…","i":"true"},{"u":"/docs/adr/057-expand-contract-schema-evolution-gate.html#context","d":"ADR-057: Expand/Contract Schema Evolution Enforced as a CI Gate","k":"Architecture Decision Records","t":"Context","x":"ADR-030 decides who applies a migration: every service host runs DatabaseInitStrategy = Migrate and self-applies its pending EF Core migrations at startup as the sole migrator,…","i":"DatabaseInitStrategy containerapp DropColumn migrations adee5058 revision Migrate dotnet sqlcmd copy"},{"u":"/docs/adr/057-expand-contract-schema-evolution-gate.html#decision","d":"ADR-057: Expand/Contract Schema Evolution Enforced as a CI Gate","k":"Architecture Decision Records","t":"Decision","x":"Schema changes follow expand/contract, and a CI step enforces the contract half. - Expand now, contract later, as a written rule. Adding nullable columns, new tables and new…","i":"OutboxMessages InboxMessages pull_request CreateIndex DropColumn Migrations DropIndex DropTable IsDeleted base_ref release added"},{"u":"/docs/adr/057-expand-contract-schema-evolution-gate.html#rationale","d":"ADR-057: Expand/Contract Schema Evolution Enforced as a CI Gate","k":"Architecture Decision Records","t":"Rationale","x":"- Rollback is one-way for schema, so the check belongs where the drop is still cheap. The only moment a destructive migration can be reconsidered for free is the PR that adds it;…","i":"Down"},{"u":"/docs/adr/057-expand-contract-schema-evolution-gate.html#trade-offs","d":"ADR-057: Expand/Contract Schema Evolution Enforced as a CI Gate","k":"Architecture Decision Records","t":"Trade-offs","x":"- Three operations, not a model of compatibility. AlterColumn narrowing a type or flipping a column to NOT NULL, DropForeignKey, DropPrimaryKey, DropSchema, RenameColumn and a…","i":"migrationBuilder.Sql DropForeignKey DropPrimaryKey RenameColumn AlterColumn DropSchema diff main true with git"},{"u":"/docs/adr/057-expand-contract-schema-evolution-gate.html#related","d":"ADR-057: Expand/Contract Schema Evolution Enforced as a CI Gate","k":"Architecture Decision Records","t":"Related","x":"ADR-030 (decides that each service self-applies its migrations at startup, which is precisely why a rolled-back revision meets the new schema; this ADR constrains what those…"},{"u":"/docs/adr/058-runtime-conformance-suites-as-a-package.html","d":"ADR-058: Runtime Conformance Suites Shipped as a Package","k":"Architecture Decision Records"},{"u":"/docs/adr/058-runtime-conformance-suites-as-a-package.html#status","d":"ADR-058: Runtime Conformance Suites Shipped as a Package","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-28; revised 2026-08-14, 2026-08-18, and 2026-08-23)."},{"u":"/docs/adr/058-runtime-conformance-suites-as-a-package.html#context","d":"ADR-058: Runtime Conformance Suites Shipped as a Package","k":"Architecture Decision Records","t":"Context","x":"ADR-015 turned the architecture invariants into build-gating tests, and drew its own boundary explicitly: the fitness suite asserts \"structure / registration, not runtime…"},{"u":"/docs/adr/058-runtime-conformance-suites-as-a-package.html#decision","d":"ADR-058: Runtime Conformance Suites Shipped as a Package","k":"Architecture Decision Records","t":"Decision","x":"Ship the runtime conformance suites in the MMCA.Common.Testing package as abstract behavioral bases that each consuming host subclasses, and run every one of them against a host…","i":"ServiceInfoVersioningContractTestsBase SqlServerIntegrationTestFixtureBase MiddlewarePipelineOrderTestsBase MMCA.Common.Testing.Architecture ProductionHostApplicationFactory DecoratorPipelineOrderTestsBase MiddlewarePipelineOrderTests.cs ProblemDetailsContractTestsBase AssertProblemDetailsShapeAsync GracefulShutdownTestsBase AddApplicationDecorators ChangePreferencesCommand"},{"u":"/docs/adr/058-runtime-conformance-suites-as-a-package.html#rationale","d":"ADR-058: Runtime Conformance Suites Shipped as a Package","k":"Architecture Decision Records","t":"Rationale","x":"- Runtime conformance is the half ADR-015 excluded. Structural rules answer \"is the code shaped correctly\"; these suites answer \"does the composed host behave correctly\". A host…","i":"Development Production"},{"u":"/docs/adr/058-runtime-conformance-suites-as-a-package.html#trade-offs","d":"ADR-058: Runtime Conformance Suites Shipped as a Package","k":"Architecture Decision Records","t":"Trade-offs","x":"- Opt-in per host, exactly like ADR-015. The framework ships the suites; a host gets the gate only once someone writes the subclass. That is the same audit-the-inventory caveat,…","i":"CorePublicResources MinimumPathCount status title"},{"u":"/docs/adr/058-runtime-conformance-suites-as-a-package.html#related","d":"ADR-058: Runtime Conformance Suites Shipped as a Package","k":"Architecture Decision Records","t":"Related","x":"ADR-015 (the structural / registration fitness layer this complements; its stated non-goal, \"not runtime behavior\", is exactly this ADR's scope, and the two tiers ship as two…","i":"DecoratorPipelineOrderTestsBase ProblemDetailsContractTestsBase"},{"u":"/docs/adr/059-module-contract-and-composition.html","d":"ADR-059: The IModule Contract and Reflection-Based Module Composition","k":"Architecture Decision Records"},{"u":"/docs/adr/059-module-contract-and-composition.html#status","d":"ADR-059: The IModule Contract and Reflection-Based Module Composition","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-28; revised 2026-08-14)."},{"u":"/docs/adr/059-module-contract-and-composition.html#context","d":"ADR-059: The IModule Contract and Reflection-Based Module Composition","k":"Architecture Decision Records","t":"Context","x":"The framework's headline claim is that an application is built as a modular monolith and later extracted into services without rewriting business logic. ADR-008 states the…","i":"ModuleLoader"},{"u":"/docs/adr/059-module-contract-and-composition.html#decision","d":"ADR-059: The IModule Contract and Reflection-Based Module Composition","k":"Architecture Decision Records","t":"Decision","x":"Make IModule the single composition contract, discover implementations by reflection, register them in topological dependency order, and represent a disabled module by stub…","i":"DisabledSessionBookmarkValidationService AppDomain.CurrentDomain.GetAssemblies DisabledEventLiveValidationService ModuleControllerFeatureProvider DisabledUserSalesExportService DisabledProductVariantService SalesUserDataExportSection ValidateRemoteDependencies InvalidOperationException Activator.CreateInstance AddUserDataExportSection DisabledCustomerService"},{"u":"/docs/adr/059-module-contract-and-composition.html#rationale","d":"ADR-059: The IModule Contract and Reflection-Based Module Composition","k":"Architecture Decision Records","t":"Rationale","x":"- Reflection discovery keeps hosts out of the module registry business. A host calls one method and gets whatever modules its assembly graph contains; adding a module is a…","i":"RequiresDependencies RemoteDependencies appsettings.json Dependencies Modules true"},{"u":"/docs/adr/059-module-contract-and-composition.html#trade-offs","d":"ADR-059: The IModule Contract and Reflection-Based Module Composition","k":"Architecture Decision Records","t":"Trade-offs","x":"- The AppDomain scan is the fragile default and the one everybody uses. The loader's own documentation warns that the AppDomain scan sees only assemblies already loaded, so a…","i":"ModuleConformanceTestsBase ValidateRemoteDependencies Activator.CreateInstance IBookmarkCountService RegisterDisabledStubs RequiresDependencies AddTypedGrpcClient Dependencies ModuleName Complete Register Enabled"},{"u":"/docs/adr/059-module-contract-and-composition.html#related","d":"ADR-059: The IModule Contract and Reflection-Based Module Composition","k":"Architecture Decision Records","t":"Related","x":"ADR-008 (the extraction topology that consumes this model: \"a service is the monolith with one module enabled\" is a statement about ModuleLoader plus the Disabled stubs, cited…","i":"AddApplicationDecorators ModuleLoader"},{"u":"/docs/adr/060-performance-regression-gate.html","d":"ADR-060: Performance-Regression Gate (Committed Benchmark Baseline in CI)","k":"Architecture Decision Records"},{"u":"/docs/adr/060-performance-regression-gate.html#status","d":"ADR-060: Performance-Regression Gate (Committed Benchmark Baseline in CI)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-28). Revised 2026-08-01 (corrected the count in Trade-offs: the single ratio floor names two of the eight benchmarks, so six, not seven, are gated on…","i":"ci.yml"},{"u":"/docs/adr/060-performance-regression-gate.html#context","d":"ADR-060: Performance-Regression Gate (Committed Benchmark Baseline in CI)","k":"Architecture Decision Records","t":"Context","x":"Rubric section 12 asks for hot-path efficiency that is measured, not assumed (Website/docs-src/governance/ArchitectureEvaluationCriteria.md:355). MMCA.Common has a…","i":"IsSatisfiedBy"},{"u":"/docs/adr/060-performance-regression-gate.html#decision","d":"ADR-060: Performance-Regression Gate (Committed Benchmark Baseline in CI)","k":"Architecture Decision Records","t":"Decision","x":"Measure the hot-path suite on every code PR and verify the results against a committed baseline that carries two rule kinds: absolute allocation ceilings where the measurement is…","i":"ApplyFilters_ThreeMixedOperators IsSatisfiedBy_RecompileEachCall IsSatisfiedBy_CachedCompile allocationCeilingsBytes MMCA.Common.slnx PackageReference System.Text.Json BenchmarkDotNet MemoryDiagnoser fastBenchmark slowBenchmark Performance"},{"u":"/docs/adr/060-performance-regression-gate.html#rationale","d":"ADR-060: Performance-Regression Gate (Committed Benchmark Baseline in CI)","k":"Architecture Decision Records","t":"Rationale","x":"- A ratio is a property of the code; an absolute nanosecond count is a property of the runner. Both benchmarks in a floor run in the same process, on the same machine, in the…","i":"MemoryDiagnoser Specification TEntity TId"},{"u":"/docs/adr/060-performance-regression-gate.html#trade-offs","d":"ADR-060: Performance-Regression Gate (Committed Benchmark Baseline in CI)","k":"Architecture Decision Records","t":"Trade-offs","x":"- The Short job cannot see small latency regressions. Three warmup and three iterations (ci.yml:360) give wide confidence intervals: enough for a 1000x floor and for counting…","i":"ApplyFilters release.yml changes main push"},{"u":"/docs/adr/060-performance-regression-gate.html#related","d":"ADR-060: Performance-Regression Gate (Committed Benchmark Baseline in CI)","k":"Architecture Decision Records","t":"Related","x":"ADR-015 (structural fitness functions, which explicitly stop at structure and registration; this is their runtime-cost counterpart), ADR-038 (the other build-gating control set,…"},{"u":"/docs/adr/061-runtime-secret-management.html","d":"ADR-061: Runtime Secret Management via Key Vault References and Managed Identity","k":"Architecture Decision Records"},{"u":"/docs/adr/061-runtime-secret-management.html#status","d":"ADR-061: Runtime Secret Management via Key Vault References and Managed Identity","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-01; vault-backed configuration source recorded and citations re-anchored 2026-08-23)."},{"u":"/docs/adr/061-runtime-secret-management.html#context","d":"ADR-061: Runtime Secret Management via Key Vault References and Managed Identity","k":"Architecture Decision Records","t":"Context","x":"A Container App can hold a credential two ways: as a literal value in the app's own secrets collection, or as a reference to a Key Vault secret that the platform resolves at…","i":"DefaultAzureCredential secrets"},{"u":"/docs/adr/061-runtime-secret-management.html#decision","d":"ADR-061: Runtime Secret Management via Key Vault References and Managed Identity","k":"Architecture Decision Records","t":"Decision","x":"Every production secret lives in Azure Key Vault and reaches the app as a keyVaultUrl secret reference resolved by a user-assigned managed identity; the same identity also lets a…","i":"AddCommonKeyVaultConfiguration azureADOnlyAuthentication USE_MANAGED_IDENTITY_SQL DefaultAzureCredential useManagedIdentitySql AZURE_CLIENT_ID hasSmtpPassword IConfiguration MMCA.Templates KeyVault__Uri keyVaultUrl claude.yml"},{"u":"/docs/adr/061-runtime-secret-management.html#rationale","d":"ADR-061: Runtime Secret Management via Key Vault References and Managed Identity","k":"Architecture Decision Records","t":"Rationale","x":"- A reference has one home; a literal has as many homes as it has consumers. Three vault secrets in each repo are referenced by more than one app: Redis and the broker by all…"},{"u":"/docs/adr/061-runtime-secret-management.html#trade-offs","d":"ADR-061: Runtime Secret Management via Key Vault References and Managed Identity","k":"Architecture Decision Records","t":"Trade-offs","x":"- One identity means vault-wide read for every app that carries it. A Key Vault Secrets User grant is scoped to the vault, so any app running as the shared identity can read…","i":"AZURE_CLIENT_ID main.bicep EXTERNAL listKeys PROVIDER secrets CREATE secure unused FROM USER"},{"u":"/docs/adr/061-runtime-secret-management.html#related","d":"ADR-061: Runtime Secret Management via Key Vault References and Managed Identity","k":"Architecture Decision Records","t":"Related","x":"ADR-037 (037-field-level-encryption-at-rest.md:108-110 directs a consumer to keep the field-encryption key in Key Vault but decides no delivery mechanism, and nothing wires that…"},{"u":"/docs/adr/062-slo-alerting-as-code.html","d":"ADR-062: SLO Alerting as Code with an Alert-to-Runbook Build Gate","k":"Architecture Decision Records"},{"u":"/docs/adr/062-slo-alerting-as-code.html#status","d":"ADR-062: SLO Alerting as Code with an Alert-to-Runbook Build Gate","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-01). Revised 2026-08-18: Store's two operational extras (the outbox-dead-letter scheduled query rule and the outside-in Gateway availability web test with its…","i":"OPERATIONS.md main.bicep main"},{"u":"/docs/adr/062-slo-alerting-as-code.html#context","d":"ADR-062: SLO Alerting as Code with an Alert-to-Runbook Build Gate","k":"Architecture Decision Records","t":"Context","x":"ADR-041 standardized what the fleet emits: RED histograms off the CQRS pipeline, an outbox dead-letter counter, correlation ids, exporters, and the cost knobs that keep ingestion…"},{"u":"/docs/adr/062-slo-alerting-as-code.html#decision","d":"ADR-062: SLO Alerting as Code with an Alert-to-Runbook Build Gate","k":"Architecture Decision Records","t":"Decision","x":"Declare each consumer's SLO alerts as data in its Bicep template, materialize them as Log Analytics scheduled query rules, and make the alert-to-runbook pairing a build gate…","i":"EveryRunbookAlertSection_MapsToAProvisionedAlert SloAlertSpecs_AreDiscovered_GateIsNotVacuous ObservabilityConventionTestsBaseTests ObservabilityConventionTestsBase legacySloMetricAlertSpecs infra.OPERATIONS.md metricMeasureColumn RunbookHeadingRegex alertEmailAddress MinimumAlertSpecs infra.main.bicep ResourceAssembly"},{"u":"/docs/adr/062-slo-alerting-as-code.html#rationale","d":"ADR-062: SLO Alerting as Code with an Alert-to-Runbook Build Gate","k":"Architecture Decision Records","t":"Rationale","x":"- Alerts as data, not as portal state. One array is reviewable in a PR, diffable across environments, and re-deployable; the rules, the workbook, and the notification channel are…","i":"enabled false"},{"u":"/docs/adr/062-slo-alerting-as-code.html#trade-offs","d":"ADR-062: SLO Alerting as Code with an Alert-to-Runbook Build Gate","k":"Architecture Decision Records","t":"Trade-offs","x":"- It is a text gate over IaC, not a check against deployed state. The base matches literal anchors and regexes in the template and headings in markdown. It proves the two files…","i":"environmentName sloAlertSpecs metricAlerts prefix env key"},{"u":"/docs/adr/062-slo-alerting-as-code.html#related","d":"ADR-062: SLO Alerting as Code with an Alert-to-Runbook Build Gate","k":"Architecture Decision Records","t":"Related","x":"ADR-041 (the telemetry this alerts on top of: it defines emission, instrumentation and cost knobs and stops before thresholds, severities and runbooks), ADR-009 (recovery…"},{"u":"/docs/adr/063-accessibility-conformance-gate.html","d":"ADR-063: WCAG 2.1 AA Accessibility as a Shipped Test Contract and CI Gate","k":"Architecture Decision Records"},{"u":"/docs/adr/063-accessibility-conformance-gate.html#status","d":"ADR-063: WCAG 2.1 AA Accessibility as a Shipped Test Contract and CI Gate","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-01). Revised 2026-08-14: refreshed the E2ETestBase helper line anchors (explanatory comments were added above ScanGridAsync), the two consumer suite scan counts…","i":"ScanGridAsync E2ETestBase"},{"u":"/docs/adr/063-accessibility-conformance-gate.html#context","d":"ADR-063: WCAG 2.1 AA Accessibility as a Shipped Test Contract and CI Gate","k":"Architecture Decision Records","t":"Context","x":"Accessibility was documented before it was enforced. The narrative guide (common-ACCESSIBILITY.md, rubric section 21) named WCAG 2.1 AA as the target for the shared…","i":"MMCA.Common.UI"},{"u":"/docs/adr/063-accessibility-conformance-gate.html#decision","d":"ADR-063: WCAG 2.1 AA Accessibility as a Shipped Test Contract and CI Gate","k":"Architecture Decision Records","t":"Decision","x":"Ship WCAG 2.1 AA as a named, versioned test contract in MMCA.Common.Testing.E2E, assert it from the package's own workflow bases, and wire it as a required merge check and a…","i":"AxeOptions.Wcag21AaExceptMudPagerCombobox AssertNoAccessibilityViolationsAsync AccessibilityViolationException PasswordResetTestsBase.cs MMCA.Common.Testing.E2E ProfileManagementTests AxeOptions.Wcag21Aa PrimaryContrastText WarningContrastText GalleryAxeTestBase ErrorContrastText AxeRunOptions"},{"u":"/docs/adr/063-accessibility-conformance-gate.html#rationale","d":"ADR-063: WCAG 2.1 AA Accessibility as a Shipped Test Contract and CI Gate","k":"Architecture Decision Records","t":"Rationale","x":"- A named constant is the contract. Putting the rule set in a shipped, referenced symbol rather than in each repo's test setup means \"what WCAG 2.1 AA means here\" has exactly one…","i":"ProfileManagementTestsBase UserRegistrationTestsBase PasswordResetTestsBase UserLoginTestsBase"},{"u":"/docs/adr/063-accessibility-conformance-gate.html#trade-offs","d":"ADR-063: WCAG 2.1 AA Accessibility as a Shipped Test Contract and CI Gate","k":"Architecture Decision Records","t":"Trade-offs","x":"- Best-practice rules are out of scope, deliberately. Findings axe would classify as best practice (and anything WCAG AAA) are not measured at all, so the gate can be green on a…","i":"Wcag21AaExceptMudPagerCombobox AccessibilityTests ScanGridAsync skipped success deploy"},{"u":"/docs/adr/063-accessibility-conformance-gate.html#related","d":"ADR-063: WCAG 2.1 AA Accessibility as a Shipped Test Contract and CI Gate","k":"Architecture Decision Records","t":"Related","x":"ADR-015 (architecture fitness functions: the structural tier this parallels at the browser tier, and the same invariant-over-discipline posture), ADR-058 (runtime conformance…"},{"u":"/docs/adr/064-deploy-recency-gates.html","d":"ADR-064: Deploy Preconditions as Proof-of-Recency Gates","k":"Architecture Decision Records"},{"u":"/docs/adr/064-deploy-recency-gates.html#status","d":"ADR-064: Deploy Preconditions as Proof-of-Recency Gates","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-01). Revised 2026-08-07: the MMCA.Helpdesk workflow inventory below was corrected (it also carries release-templates.yml, and its ci.yml runs two jobs, not…","i":"ci.yml"},{"u":"/docs/adr/064-deploy-recency-gates.html#context","d":"ADR-064: Deploy Preconditions as Proof-of-Recency Gates","k":"Architecture Decision Records","t":"Context","x":"A production rollout in both deployed apps waits on a list of jobs in deploy.needs (MMCA.ADC/.github/workflows/deploy.yml:866, MMCA.Store/.github/workflows/deploy.yml:862). Most…","i":"deploy.needs"},{"u":"/docs/adr/064-deploy-recency-gates.html#decision","d":"ADR-064: Deploy Preconditions as Proof-of-Recency Gates","k":"Architecture Decision Records","t":"Decision","x":"A production deploy is blocked not only on green tests but on proof of recency for out-of-band verification: three gates assert that a real drill, a real load run and a real…","i":"skip_freshness_gates skip_justification github.event_name workflow_dispatch FRESHNESS_DAYS workflow_runs deploy.needs release.yml foundation updated_at cancelled contents"},{"u":"/docs/adr/064-deploy-recency-gates.html#rationale","d":"ADR-064: Deploy Preconditions as Proof-of-Recency Gates","k":"Architecture Decision Records","t":"Rationale","x":"- A proof with no expiry date is documentation, not a control. ADR-009 already required the drill to be recorded, and recording it was the honest half of the problem; a record…"},{"u":"/docs/adr/064-deploy-recency-gates.html#trade-offs","d":"ADR-064: Deploy Preconditions as Proof-of-Recency Gates","k":"Architecture Decision Records","t":"Trade-offs","x":"- An unrelated stale proof blocks an unrelated deploy. A one-line hotfix does not ship when the monthly k6 cron did not fire, and the failure surfaces after merge: the gate job…","i":"deploy"},{"u":"/docs/adr/064-deploy-recency-gates.html#related","d":"ADR-064: Deploy Preconditions as Proof-of-Recency Gates","k":"Architecture Decision Records","t":"Related","x":"ADR-009 (states the recovery objectives and requires that a restore be drilled and recorded; this record decides that a deploy is blocked on how recently that drill, and the…"},{"u":"/docs/adr/065-scaffolding-templates.html","d":"ADR-065: Scaffolding templates derived from the reference app","k":"Architecture Decision Records","x":"Status: Accepted (2026-08-02). Revised 2026-08-07: the staged analyzer delta relaxes three rules rather than one; mmca-module prints seven wire-ups rather than five, and a…","i":"Directory.Packages.props"},{"u":"/docs/adr/065-scaffolding-templates.html#context","d":"ADR-065: Scaffolding templates derived from the reference app","k":"Architecture Decision Records","t":"Context","x":"Build by hand is accurate and complete, and phases 1 through 6 of it are transcription work (common-BUILD-BY-HAND.md:96 through :1049). Its own instruction for the load-bearing…","i":"AddApplicationDecorators Directory.Packages.props Directory.Build.targets Directory.Build.props launchSettings.json IArchitectureMap MMCA.Templates editorconfig nuget.config global.json install WaitFor"},{"u":"/docs/adr/065-scaffolding-templates.html#decision","d":"ADR-065: Scaffolding templates derived from the reference app","k":"Architecture Decision Records","t":"Decision","x":"Ship a dotnet new template pack, MMCA.Templates, containing four templates: The template content is the MMCA.Helpdesk reference application itself, staged at pack time.…","i":"IntegrationEventContractTestsBase IntegrationEventContractTests SQLServerMigrationsAssembly WithSQLServerDataSource TreatWarningsAsErrors AddErrorResources appsettings.json IArchitectureMap Contoso.Support RequesterUserId MMCA.Templates MMCA.Helpdesk"},{"u":"/docs/adr/065-scaffolding-templates.html#rationale","d":"ADR-065: Scaffolding templates derived from the reference app","k":"Architecture Decision Records","t":"Rationale","x":"Deriving from the seed rather than maintaining a template tree is the whole design. A hand-maintained copy of a 12-project solution drifts within one release, and drift in a…","i":"MMCA.Common.Templates MMCA.Templates MMCA.Helpdesk sourceName Helpdesk install Tickets dotnet Ticket new"},{"u":"/docs/adr/065-scaffolding-templates.html#trade-offs","d":"ADR-065: Scaffolding templates derived from the reference app","k":"Architecture Decision Records","t":"Trade-offs","x":"- Two documented one-time fixups in every generated app, above (one of them covering all three relaxed rules). The alternative to the SA1210 half of the delta was moving every…","i":"IntegrationEventContractTestsBase IntegrationEventContractTests MMCA.Common copyOnly dotnet SA1210 using Fact new"},{"u":"/docs/adr/066-broker-transport-selection.html","d":"ADR-066: Broker Transport Selection and Dev/Prod Parity","k":"Architecture Decision Records"},{"u":"/docs/adr/066-broker-transport-selection.html#status","d":"ADR-066: Broker Transport Selection and Dev/Prod Parity","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-07). Revised 2026-08-14 (source citations re-anchored; the ADC AppHost comment that used to say no WithBroker() was wired has been corrected in code, so the…","i":"WithBroker"},{"u":"/docs/adr/066-broker-transport-selection.html#context","d":"ADR-066: Broker Transport Selection and Dev/Prod Parity","k":"Architecture Decision Records","t":"Context","x":"ADR-003 decides that integration events leave an aggregate through the outbox and are published by OutboxProcessor via IMessageBus, and it settles the dispatch question…","i":"OutboxProcessor IMessageBus"},{"u":"/docs/adr/066-broker-transport-selection.html#decision","d":"ADR-066: Broker Transport Selection and Dev/Prod Parity","k":"Architecture Decision Records","t":"Decision","x":"Keep one IMessageBus abstraction with a three-value transport selector, choose the value at the deployment edge (never in application code), configure both broker transports…","i":"Bus.Factory.CreateUsingAzureServiceBus ResolveBrokerConnectionString MessageBus__ConnectionString ConnectionStrings__rabbitmq RootManageSharedAccessKey ConfigureBrokerTransport EnableDelayedRedelivery RetryMaxIntervalSeconds RetryMinIntervalSeconds builder.Configuration UseDelayedRedelivery UsingAzureServiceBus"},{"u":"/docs/adr/066-broker-transport-selection.html#rationale","d":"ADR-066: Broker Transport Selection and Dev/Prod Parity","k":"Architecture Decision Records","t":"Rationale","x":"- The transport is a deployment fact, so it lives at the deployment edge. The only difference between a laptop and production is two environment variables set by the AppHost or…","i":"Listen Send"},{"u":"/docs/adr/066-broker-transport-selection.html#trade-offs","d":"ADR-066: Broker Transport Selection and Dev/Prod Parity","k":"Architecture Decision Records","t":"Trade-offs","x":"- Two brokers means two behaviors to keep aligned. Configuration parity is enforced by one code path, but the products still differ (Service Bus supports delayed redelivery…","i":"MessageBus__Provider ConfigureEndpoints WithBroker Manage rabbit"},{"u":"/docs/adr/066-broker-transport-selection.html#related","d":"ADR-066: Broker Transport Selection and Dev/Prod Parity","k":"Architecture Decision Records","t":"Related","x":"ADR-003 (the outbox that feeds IMessageBus; this ADR picks the transport underneath it), ADR-016 (the MassTransit v8 pin the emulator tier must work within, which is why the…","i":"IMessageBus Host"},{"u":"/docs/adr/067-ui-module-shell-composition.html","d":"ADR-067: Shared Blazor Application Shell and IUIModule Composition","k":"Architecture Decision Records"},{"u":"/docs/adr/067-ui-module-shell-composition.html#status","d":"ADR-067: Shared Blazor Application Shell and IUIModule Composition","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-07)."},{"u":"/docs/adr/067-ui-module-shell-composition.html#context","d":"ADR-067: Shared Blazor Application Shell and IUIModule Composition","k":"Architecture Decision Records","t":"Context","x":"ADR-059 decided how a module plugs into the server: an IModule implementation is discovered by reflection, registered in topological order, and a host composes an application out…","i":"MMCA.Common.UI IModule Routes App"},{"u":"/docs/adr/067-ui-module-shell-composition.html#decision","d":"ADR-067: Shared Blazor Application Shell and IUIModule Composition","k":"Architecture Decision Records","t":"Decision","x":"Ship the application shell in the framework package and let each module plug into it by implementing IUIModule, resolved from DI as IEnumerable . - The contract is four members,…","i":"AdditionalAssemblies AppBarComponentTypes LayoutComponentTypes AuthorizeRouteView MapRazorComponents DynamicComponent UIModules.Select RedirectToLogin DeviceUIModule RequiredClaim TitleResource AddSingleton"},{"u":"/docs/adr/067-ui-module-shell-composition.html#rationale","d":"ADR-067: Shared Blazor Application Shell and IUIModule Composition","k":"Architecture Decision Records","t":"Rationale","x":"- One composition model across both tiers. A module already declares its server-side surface through IModule (ADR-059); declaring its UI surface through IUIModule means \"add a…","i":"AppBarComponentTypes LayoutComponentTypes AuthorizeView Components IUIModule IModule NavMenu"},{"u":"/docs/adr/067-ui-module-shell-composition.html#trade-offs","d":"ADR-067: Shared Blazor Application Shell and IUIModule Composition","k":"Architecture Decision Records","t":"Trade-offs","x":"- Assembly is required even when it carries no route. A host-only module that contributes only a layout component still has to return an assembly, which then joins…","i":"AddAdditionalAssemblies AdditionalAssemblies AuthorizeRouteView RequiredClaim MauiUIModule RequiredRole Program.cs IUIModule Assembly NavItems NavMenu page"},{"u":"/docs/adr/067-ui-module-shell-composition.html#related","d":"ADR-067: Shared Blazor Application Shell and IUIModule Composition","k":"Architecture Decision Records","t":"Related","x":"ADR-059 (the server-side IModule contract this mirrors in the presentation layer), ADR-056 (the render-mode strategy for the web heads, which decides how these components render…","i":"TitleResource IModule"},{"u":"/docs/adr/068-value-objects-as-validated-primitives.html","d":"ADR-068: Value Objects as Validated Domain Primitives","k":"Architecture Decision Records"},{"u":"/docs/adr/068-value-objects-as-validated-primitives.html#status","d":"ADR-068: Value Objects as Validated Domain Primitives","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-07)."},{"u":"/docs/adr/068-value-objects-as-validated-primitives.html#context","d":"ADR-068: Value Objects as Validated Domain Primitives","k":"Architecture Decision Records","t":"Context","x":"A domain model has two kinds of small type: the identity of a thing, and a value the thing carries. ADR-048 recorded the identity half: identifiers stay primitives named through…","i":"decimal string"},{"u":"/docs/adr/068-value-objects-as-validated-primitives.html#decision","d":"ADR-068: Value Objects as Validated Domain Primitives","k":"Architecture Decision Records","t":"Decision","x":"Model a domain value that carries an invariant as an immutable record value object with a Result-returning factory; keep identifiers primitive (ADR-048). - One abstract record…","i":"PhoneNumberInvariants.EnsurePhoneNumberIsValid ArchitectureRules.DomainFactoriesReturnResult AddressInvariants.EnsureAddressLine1IsValid EmailInvariants.EnsureEmailIsValid NullablePhoneNumberValueConverter NullableEmailValueConverter EmailInvariants.MaxLength PhoneNumberValueConverter DataContractSerializer GetEqualityComponents DateTimeRange.Create ProductVariant.Price"},{"u":"/docs/adr/068-value-objects-as-validated-primitives.html#rationale","d":"ADR-068: Value Objects as Validated Domain Primitives","k":"Architecture Decision Records","t":"Rationale","x":"- The invariant belongs to the type, not to every caller. A string email can be validated in one handler and not the next; an Email cannot exist unvalidated, because the only…","i":"NullReferenceException Currency.None Money.Zero OwnsMoney record Result string Email Money"},{"u":"/docs/adr/068-value-objects-as-validated-primitives.html#trade-offs","d":"ADR-068: Value Objects as Validated Domain Primitives","k":"Architecture Decision Records","t":"Trade-offs","x":"- The pattern is not uniformly applied. Only three of the seven types have a companion Invariants class; the rest inline their checks. Only Money has a shipped owned-type helper,…","i":"InvalidOperationException DateTimeRange Currency.All PhoneNumber DateRange Money.Add operator Address OwnsOne Create Result string"},{"u":"/docs/adr/068-value-objects-as-validated-primitives.html#related","d":"ADR-068: Value Objects as Validated Domain Primitives","k":"Architecture Decision Records","t":"Related","x":"ADR-048 (the deliberate opposite call for identifiers: primitives behind aliases, wrapper structs rejected, because identifiers cross process boundaries constantly and carry no…","i":"Create Result"},{"u":"/docs/adr/069-shared-data-protection-key-ring.html","d":"ADR-069: Shared DataProtection Key Ring for Scaled-Out Hosts","k":"Architecture Decision Records"},{"u":"/docs/adr/069-shared-data-protection-key-ring.html#status","d":"ADR-069: Shared DataProtection Key Ring for Scaled-Out Hosts","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-07). Updated 2026-08-14: Store's adoption has landed and is live (its own dedicated storage account, gated on dataProtectionStorageReady), and the ADC call-site…","i":"dataProtectionStorageReady AddServiceDefaults"},{"u":"/docs/adr/069-shared-data-protection-key-ring.html#context","d":"ADR-069: Shared DataProtection Key Ring for Scaled-Out Hosts","k":"Architecture Decision Records","t":"Context","x":"ASP.NET Core's DataProtection default keeps the key ring in memory, per process. That is correct for a single-process host and wrong for a scaled-out one: every replica generates…","i":"DefaultAzureCredential maxReplicas"},{"u":"/docs/adr/069-shared-data-protection-key-ring.html#decision","d":"ADR-069: Shared DataProtection Key Ring for Scaled-Out Hosts","k":"Architecture Decision Records","t":"Decision","x":"Add one opt-in registration call, AddCommonDataProtection, that persists the key ring to a single Azure blob so every replica of a host shares one ring…","i":"Azure.Extensions.AspNetCore.DataProtection.Blobs KeyManagementOptions.XmlRepository System.Security.Cryptography.Xml AddCommonKeyVaultConfiguration DataProtection__BlobStorageUri DataProtection__KeyVaultKeyUri grantDataProtectionStorageRole PersistKeysToAzureBlobStorage ProtectKeysWithAzureKeyVault dataProtectionStorageReady AddCommonDataProtection IDataProtectionProvider"},{"u":"/docs/adr/069-shared-data-protection-key-ring.html#rationale","d":"ADR-069: Shared DataProtection Key Ring for Scaled-Out Hosts","k":"Architecture Decision Records","t":"Rationale","x":"- The key ring is the smallest thing that has to be shared. Sticky sessions would paper over the symptom while making a replica restart a mass sign-out, and a shared cache would…"},{"u":"/docs/adr/069-shared-data-protection-key-ring.html#trade-offs","d":"ADR-069: Shared DataProtection Key Ring for Scaled-Out Hosts","k":"Architecture Decision Records","t":"Trade-offs","x":"- The key ring is not encrypted at rest today. Gate 2 is implemented but configured nowhere, so the ring is protected by the container being private and the account grant being…","i":"AddCommonDataProtection AZURE_CLIENT_ID MMCA.ADC"},{"u":"/docs/adr/069-shared-data-protection-key-ring.html#related","d":"ADR-069: Shared DataProtection Key Ring for Scaled-Out Hosts","k":"Architecture Decision Records","t":"Related","x":"ADR-022 (the browser session cookies whose decryption this makes replica-independent, together with the antiforgery tokens the SSR pages mint), ADR-008 (the multi-host topology…","i":"DefaultAzureCredential"},{"u":"/docs/adr/070-fail-fast-configuration-contract.html","d":"ADR-070: Fail-Fast Configuration Contract","k":"Architecture Decision Records"},{"u":"/docs/adr/070-fail-fast-configuration-contract.html#status","d":"ADR-070: Fail-Fast Configuration Contract","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-07). Revised 2026-08-14 (source citations re-anchored; the consumer-repo facade claim narrowed to production code, with the controller-test exception recorded).…","i":"IValidateOptions IOptions"},{"u":"/docs/adr/070-fail-fast-configuration-contract.html#context","d":"ADR-070: Fail-Fast Configuration Contract","k":"Architecture Decision Records","t":"Context","x":"Every host in the workspace reads a dozen or more configuration sections: connection strings, SMTP, JWT key material, outbox tuning, message-bus provider, module enablement,…","i":"IOptions"},{"u":"/docs/adr/070-fail-fast-configuration-contract.html#decision","d":"ADR-070: Fail-Fast Configuration Contract","k":"Architecture Decision Records","t":"Decision","x":"Bind every settings section through a validating chain that runs at startup, and expose a settings type through a read-only interface when it must be read above Infrastructure. -…","i":"CreateCheckoutSessionCommandValidator GatewayRateLimitingSettings ForgotPasswordHandlerBase IConnectionStringSettings IPushNotificationSettings CheckoutRedirectSettings ConnectionStringSettings PushNotificationSettings RecordRoomCheckInHandler AddCommonAuthentication LoginProtectionSettings SecurityHeadersSettings"},{"u":"/docs/adr/070-fail-fast-configuration-contract.html#rationale","d":"ADR-070: Fail-Fast Configuration Contract","k":"Architecture Decision Records","t":"Rationale","x":"- A boot failure is cheaper than a first-use failure. A host that will not start is caught by the deployment, by a local dotnet run, or by CI. A host that starts and fails on the…","i":"Microsoft.Extensions.Options ValidateDataAnnotations EntityControllerBase IApplicationSettings ApplicationSettings IValidatableObject RepositoryFactory ValidateOnStart JwtSettings IOptions dotnet init"},{"u":"/docs/adr/070-fail-fast-configuration-contract.html#trade-offs","d":"ADR-070: Fail-Fast Configuration Contract","k":"Architecture Decision Records","t":"Trade-offs","x":"- Nothing enforces it. There is no architecture fitness test asserting that a new AddOptions call carries ValidateDataAnnotations().ValidateOnStart(). The uniformity above is…","i":"TenancySettingsValidator ValidateDataAnnotations IDataSourceResolver IValidatableObject IValidateOptions IOptionsMonitor TenancySettings ValidateOnStart JwtSettings AddOptions IOptions Value"},{"u":"/docs/adr/070-fail-fast-configuration-contract.html#related","d":"ADR-070: Fail-Fast Configuration Contract","k":"Architecture Decision Records","t":"Related","x":"ADR-025 (startup warm-up and readiness gating: this contract decides what happens before a host reaches that machinery), ADR-031 (feature flags read from configuration, whose…"},{"u":"/docs/adr/071-barcode-scanning-and-qr-display.html","d":"ADR-071: Device Capability Pattern for Barcode Scanning and QR Display","k":"Architecture Decision Records"},{"u":"/docs/adr/071-barcode-scanning-and-qr-display.html#status","d":"ADR-071: Device Capability Pattern for Barcode Scanning and QR Display","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-12). Amended 2026-08-13: the composition-time string trade-off below was resolved in v1.147.0 by a deferred-resolution overload; see the updated trade-off…"},{"u":"/docs/adr/071-barcode-scanning-and-qr-display.html#context","d":"ADR-071: Device Capability Pattern for Barcode Scanning and QR Display","k":"Architecture Decision Records","t":"Context","x":"ADC's badge check-in feature (ADR-072) needs two things that look like one thing: an attendee's device has to show a QR code, and an organizer's device has to read one. They are…","i":"AddDeviceCapabilityDefaults NSCameraUsageDescription MMCA.Common.UI System.Drawing AddUIShared CAMERA"},{"u":"/docs/adr/071-barcode-scanning-and-qr-display.html#decision","d":"ADR-071: Device Capability Pattern for Barcode Scanning and QR Display","k":"Architecture Decision Records","t":"Decision","x":"Split the feature by what it actually depends on: QR display ships as a shared component, barcode scanning ships as an ADR-042 capability whose native half is opt-in per head. -…","i":"AddDeviceCapabilityDefaults DeviceInfo.Current.Platform MauiBarcodeScannerService NullBarcodeScannerService UseMauiDeviceCapabilities Permissions.RequestAsync ZXing.Net.Maui.Controls IBarcodeScannerService QrErrorCorrectionLevel ScanOnMainThreadAsync TaskCompletionSource MMCA.Common.UI.Maui"},{"u":"/docs/adr/071-barcode-scanning-and-qr-display.html#rationale","d":"ADR-071: Device Capability Pattern for Barcode Scanning and QR Display","k":"Architecture Decision Records","t":"Rationale","x":"- Rendering a QR is not a device concern, so making it one would have been ceremony. As a capability it would have needed an interface, a null fallback and a native override for…","i":"UseMauiDeviceCapabilities MMCA.Common.UI PngByteQRCode IsSupported MauiProgram null try"},{"u":"/docs/adr/071-barcode-scanning-and-qr-display.html#trade-offs","d":"ADR-071: Device Capability Pattern for Barcode Scanning and QR Display","k":"Architecture Decision Records","t":"Trade-offs","x":"- The scan page's strings were resolved at composition, not per call (resolved in v1.147.0). As shipped in v1.145.0, cancelText and cameraDescription were captured into the…","i":"UseCommonBarcodeScanner cameraDescription OnParametersSet MMCA.Common.UI IsSupported QrCodeImage cancelText QRCoder string catch false Func"},{"u":"/docs/adr/071-barcode-scanning-and-qr-display.html#related","d":"ADR-071: Device Capability Pattern for Barcode Scanning and QR Display","k":"Architecture Decision Records","t":"Related","x":"ADR-042 (the capability pattern this extends: contract in MMCA.Common.UI, native implementation in MMCA.Common.UI.Maui, override after AddUIShared), ADR-072 (the ADC feature that…","i":"MMCA.Common.UI.Maui MMCA.Common.UI AddUIShared"},{"u":"/docs/adr/072-qr-badge-check-in-and-points.html","d":"ADR-072: QR Badge Check-In and Points Gamification in ADC","k":"Architecture Decision Records"},{"u":"/docs/adr/072-qr-badge-check-in-and-points.html#status","d":"ADR-072: QR Badge Check-In and Points Gamification in ADC","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-13). Amended (2026-08-14): ADC shipped two attendee-self-recorded scan surfaces (sponsor booth visits and room self check-in), a third CheckInScope, a sixth…","i":"CheckInScope"},{"u":"/docs/adr/072-qr-badge-check-in-and-points.html#context","d":"ADR-072: QR Badge Check-In and Points Gamification in ADC","k":"Architecture Decision Records","t":"Context","x":"ADC wanted two conference-day capabilities that turn out to be one mechanism. Organizers want to know who actually attended which session, which the schedule cannot tell them: a…"},{"u":"/docs/adr/072-qr-badge-check-in-and-points.html#decision","d":"ADR-072: QR Badge Check-In and Points Gamification in ADC","k":"Architecture Decision Records","t":"Decision","x":"AttendeeBadge (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Badges/AttendeeBadge.cs:17-24) is one row per user holding a single Guid Credential, minted on first…","i":"CheckInInvariants.EnsureTargetMatchesScope CheckInSettings.RoomCheckInGraceMinutes EngagementPermissions.CheckInManage CheckInProcessor.FindExistingAsync EngagementFeatures.SponsorVisits EngagementPointsEntryExportItem PointsActivityType.SponsorVisit EngagementFeatures.RoomCheckIn user_engagement_export.proto EngagementCheckInExportItem Engagement.SponsorVisits leaderboard_display_name"},{"u":"/docs/adr/072-qr-badge-check-in-and-points.html#rationale","d":"ADR-072: QR Badge Check-In and Points Gamification in ADC","k":"Architecture Decision Records","t":"Rationale","x":"- An opaque credential makes the server the only interpreter. A JWT or HMAC badge would verify offline, but the scanning device is online by necessity (it has to write a check-in…","i":"SessionCheckIn EventCheckIn Regenerate"},{"u":"/docs/adr/072-qr-badge-check-in-and-points.html#trade-offs","d":"ADR-072: QR Badge Check-In and Points Gamification in ADC","k":"Architecture Decision Records","t":"Trade-offs","x":"- The badge credential is a bearer value. Anyone who photographs an attendee's screen can be checked in as that attendee. The mitigations are that a badge scan is organizer-side,…","i":"DuplicateKeyDetection.IsDuplicateKey SetLeaderboardParticipationHandler GetLeaderboardHandler AttendeeCheckedIn Engagement.Points SessionFeedback SessionCheckIn activity_type IFeatureGated PointsAwarder QuestionAsked FeatureGate"},{"u":"/docs/adr/072-qr-badge-check-in-and-points.html#related","d":"ADR-072: QR Badge Check-In and Points Gamification in ADC","k":"Architecture Decision Records","t":"Related","x":"ADR-071 (the framework halves this consumes: the QR component on /my-badge and the scanner capability behind /check-in), ADR-003 (the outbox path AttendeeCheckedIn and the two…","i":"AttendeeCheckedIn EraseDisplayName"},{"u":"/docs/adr/073-multi-tenancy-model.html","d":"ADR-073: Multi-Tenancy (Shared-Schema Query Filter plus DB-per-Tenant Routing)","k":"Architecture Decision Records"},{"u":"/docs/adr/073-multi-tenancy-model.html#status","d":"ADR-073: Multi-Tenancy (Shared-Schema Query Filter plus DB-per-Tenant Routing)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-13). The implementation lands in the MMCA.Common enterprise capability wave release, alongside the scheduler, audit trail, DSAR export, and CSV export work. It…","i":"MiddlewarePipelineOrderTestsBase MiddlewarePipelineStepNames DesignTimeDbContextHelper MiddlewarePipelineBuilder ApplicationDbContext IgnoreQueryFilters EFReadRepository AddMultiTenancy configuration ITenantEntity OnConfiguring GetService"},{"u":"/docs/adr/073-multi-tenancy-model.html#context","d":"ADR-073: Multi-Tenancy (Shared-Schema Query Filter plus DB-per-Tenant Routing)","k":"Architecture Decision Records","t":"Context","x":"MMCA.Common already partitions data along two axes and neither of them is a tenant. ADR-006 partitions by source name (every entity resolves to a DataSourceKey(Engine, Name),…","i":"ApplySoftDeleteFilters SoftDeleteFilterName modelBuilder.Entity OnModelCreating HasQueryFilter DataSourceKey OnConfiguring TenantId clrType Engine filter Where"},{"u":"/docs/adr/073-multi-tenancy-model.html#decision","d":"ADR-073: Multi-Tenancy (Shared-Schema Query Filter plus DB-per-Tenant Routing)","k":"Architecture Decision Records","t":"Decision","x":"Ship shared-schema tenancy as a second named query filter, with per-tenant database routing as a configuration override on the same source key, both opt-in and both inert until a…","i":"CrossTenantWriteException.ForUnresolvedTenant MiddlewarePipelineBuilder.CreateDefault IPhysicalDbContextFactory.Create MiddlewarePipelineOrderTestsBase CosmosDbContext.OnModelCreating TenantSaveChangesInterceptor UseCommonMiddlewarePipeline TenantResolutionMiddleware CrossTenantWriteException DesignTimeDbContextHelper ITenantContext.SetTenant CachingCommandDecorator"},{"u":"/docs/adr/073-multi-tenancy-model.html#rationale","d":"ADR-073: Multi-Tenancy (Shared-Schema Query Filter plus DB-per-Tenant Routing)","k":"Architecture Decision Records","t":"Rationale","x":"- A query filter is the only place the rule cannot be forgotten. Per-handler Where clauses are correct until the tenth handler, and the tenth handler is a data leak rather than a…","i":"IgnoreQueryFilters DataSourceKey ICacheService RequireTenant Where"},{"u":"/docs/adr/073-multi-tenancy-model.html#trade-offs","d":"ADR-073: Multi-Tenancy (Shared-Schema Query Filter plus DB-per-Tenant Routing)","k":"Architecture Decision Records","t":"Trade-offs","x":"- Reads are on discipline where writes are on an invariant. A consumer calling EF's own parameterless IgnoreQueryFilters() on a raw Table surface drops the tenant filter along…","i":"ApplicationLayer_DoesNotUseRawQueryableSurfaces DefaultSqlServerDbContextFactory TenantSaveChangesInterceptor IgnoreQueryFilters ICacheService ITenantEntity tenant_id Table"},{"u":"/docs/adr/073-multi-tenancy-model.html#related","d":"ADR-073: Multi-Tenancy (Shared-Schema Query Filter plus DB-per-Tenant Routing)","k":"Architecture Decision Records","t":"Related","x":"ADR-006 (the source-name axis this composes with: an override re-points a DataSourceKey without changing it, and the per-source outbox this record drains once per tenant),…","i":"IgnoreQueryFilters CosmosDbContext TenancySettings DataSourceKey tenantId TenantId string"},{"u":"/docs/adr/074-recurring-job-scheduler.html","d":"ADR-074: Recurring Job Scheduler (Persistent Cron Jobs on the Outbox Claim-Lease Pattern)","k":"Architecture Decision Records"},{"u":"/docs/adr/074-recurring-job-scheduler.html#status","d":"ADR-074: Recurring Job Scheduler (Persistent Cron Jobs on the Outbox Claim-Lease Pattern)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-13; revised 2026-08-14, 2026-08-18). The implementation lands in the MMCA.Common \"enterprise capability wave\" release and is opt-in: a host calls…","i":"AddScheduledJobs configuration"},{"u":"/docs/adr/074-recurring-job-scheduler.html#context","d":"ADR-074: Recurring Job Scheduler (Persistent Cron Jobs on the Outbox Claim-Lease Pattern)","k":"Architecture Decision Records","t":"Context","x":"The framework had two kinds of background work and neither of them is a schedule. OutboxProcessor…","i":"PeriodicBackgroundService OutboxProcessor"},{"u":"/docs/adr/074-recurring-job-scheduler.html#decision","d":"ADR-074: Recurring Job Scheduler (Persistent Cron Jobs on the Outbox Claim-Lease Pattern)","k":"Architecture Decision Records","t":"Decision","x":"A persistent job store plus a single-runner claim lease, reusing the exact idiom the outbox proved. The outbox claims a batch with an ExecuteUpdateAsync that sets LockedUntil and…","i":"DesignTimeDbContextOptions.EnableScheduler ScheduledJobOverrideSettings.Cron DesignTimeDbContextHelper PeriodicBackgroundService Directory.Packages.props EnsurePermissionRegistry ValidateDataAnnotations PollingIntervalSeconds SchedulerSettings.Jobs SyncRegistrationsAsync ResolveCronExpression AuditTrailCleanupJob"},{"u":"/docs/adr/074-recurring-job-scheduler.html#rationale","d":"ADR-074: Recurring Job Scheduler (Persistent Cron Jobs on the Outbox Claim-Lease Pattern)","k":"Architecture Decision Records","t":"Rationale","x":"- The lease is already proven under production load. Multi-replica correctness for recurring work is the hard part, and it was solved once for the outbox: an atomic claim update,…","i":"AddScheduledJobs IUnitOfWork LastRunOn NextRunOn DateTime"},{"u":"/docs/adr/074-recurring-job-scheduler.html#trade-offs","d":"ADR-074: Recurring Job Scheduler (Persistent Cron Jobs on the Outbox Claim-Lease Pattern)","k":"Architecture Decision Records","t":"Trade-offs","x":"- A polling loop is not a real-time scheduler. Worst-case start lag is one polling interval, 30 seconds at the default, so sub-minute precision is not on offer. A job that must…","i":"LeaseSeconds"},{"u":"/docs/adr/074-recurring-job-scheduler.html#related","d":"ADR-074: Recurring Job Scheduler (Persistent Cron Jobs on the Outbox Claim-Lease Pattern)","k":"Architecture Decision Records","t":"Related","x":"ADR-003 (the outbox whose claim-lease idiom and smart wait this reuses verbatim, and whose at-least-once posture it inherits along with the idempotency obligation on job bodies),…","i":"SchedulerSettings SchedulerMetrics OutboxMetrics"},{"u":"/docs/adr/075-audit-trail.html","d":"ADR-075: Audit Trail (Same-Transaction Field-Level Change History)","k":"Architecture Decision Records"},{"u":"/docs/adr/075-audit-trail.html#status","d":"ADR-075: Audit Trail (Same-Transaction Field-Level Change History)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-13; corrected 2026-08-14: the adoption sweep and the ApplicationDbContext line citations). The implementation lands in the MMCA.Common \"enterprise capability…","i":"ApplicationDbContext IAuditedEntity AddAuditTrail configuration"},{"u":"/docs/adr/075-audit-trail.html#context","d":"ADR-075: Audit Trail (Same-Transaction Field-Level Change History)","k":"Architecture Decision Records","t":"Context","x":"The framework already answers \"who touched this row last\". Every AuditableBaseEntity carries CreatedOn/By and LastModifiedOn/By, stamped by AuditSaveChangesInterceptor on the way…","i":"AuditSaveChangesInterceptor AuditableBaseEntity SaveChangesAsync LastModifiedBy"},{"u":"/docs/adr/075-audit-trail.html#decision","d":"ADR-075: Audit Trail (Same-Transaction Field-Level Change History)","k":"Architecture Decision Records","t":"Decision","x":"AuditTrailSaveChangesInterceptor (Infrastructure Persistence/AuditTrail/) joins the interceptors ApplicationDbContext.OnConfiguring already passes to…","i":"ApplicationDbContext.OnModelCreating ApplicationDbContext.OnConfiguring DomainEventSaveChangesInterceptor AuditTrailSaveChangesInterceptor optionsBuilder.AddInterceptors TenantSaveChangesInterceptor AuditSaveChangesInterceptor DesignTimeDbContextHelper PeriodicBackgroundService PiiRedactor.RedactedToken DiscardAbandonedCapture DependencyInjection.cs"},{"u":"/docs/adr/075-audit-trail.html#rationale","d":"ADR-075: Audit Trail (Same-Transaction Field-Level Change History)","k":"Architecture Decision Records","t":"Rationale","x":"- IAuditableEntity is a statement about a business row, and an audit row is not one. The interface means \"this row stamps who created and last modified it and participates in…","i":"IAuditableEntity LastModifiedBy IScheduledJob OutboxMessage TenantId Pii"},{"u":"/docs/adr/075-audit-trail.html#trade-offs","d":"ADR-075: Audit Trail (Same-Transaction Field-Level Change History)","k":"Architecture Decision Records","t":"Trade-offs","x":"- Write amplification is real and it is on the caller's latency path. An entity with twenty changed properties writes twenty rows inside the caller's transaction, so an audited…","i":"IAuditTrailReader AddAuditTrail RetentionDays Pii"},{"u":"/docs/adr/075-audit-trail.html#related","d":"ADR-075: Audit Trail (Same-Transaction Field-Level Change History)","k":"Architecture Decision Records","t":"Related","x":"ADR-003 (the same-transaction write this copies wholesale, including the retry-discard and the Add-only mutation rule), ADR-005 (soft-delete, [Pii] and erasure: why the trail…","i":"AuditTrailSettings RowVersion TenantId Add Pii"},{"u":"/docs/adr/076-data-subject-export.html","d":"ADR-076: Data-Subject Export (DSAR) as a Framework Contract","k":"Architecture Decision Records"},{"u":"/docs/adr/076-data-subject-export.html#status","d":"ADR-076: Data-Subject Export (DSAR) as a Framework Contract","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-13). Revised 2026-08-14 (the API-surface section corrected to the shipped mechanism, an abstract DataExportControllerBase a subclass mounts, not an…","i":"ExportUserDataHandlerBase DataExportControllerBase IUserDataExportSection"},{"u":"/docs/adr/076-data-subject-export.html#context","d":"ADR-076: Data-Subject Export (DSAR) as a Framework Contract","k":"Architecture Decision Records","t":"Context","x":"A data-subject access request is a legal obligation with a clock on it: the person asks for a copy of the personal data held about them, and the operator has a deadline to hand…","i":"DeleteUserHandlerBase UserOwnershipRule IAnonymizable"},{"u":"/docs/adr/076-data-subject-export.html#decision","d":"ADR-076: Data-Subject Export (DSAR) as a Framework Contract","k":"Architecture Decision Records","t":"Decision","x":"The framework takes the part that is the same in both apps; the app keeps the part that is not. A consumer's export handler becomes a subclass that supplies a role test and a set…","i":"AuthorizationPolicies.RequireAuthenticated EntitiesWithPiiImplementAnonymizable UserOwnershipRule.CheckOwnership AuditableAggregateRootEntity IUserEngagementExportService AddNotificationControllers PrivacyFeatures.DataExport AuthenticationServiceBase ExportUserDataHandlerBase DataExportControllerBase PiiEntitiesAreExportable IUserDataExportSection"},{"u":"/docs/adr/076-data-subject-export.html#rationale","d":"ADR-076: Data-Subject Export (DSAR) as a Framework Contract","k":"Architecture Decision Records","t":"Rationale","x":"- The two halves of a handler have different owners. The ownership gate, the aggregate load, the fan-out, the per-section catch and the envelope are the same decisions in both…","i":"IUserEngagementExportService IUserSalesExportService ExportUserDataQuery UserOwnershipRule User"},{"u":"/docs/adr/076-data-subject-export.html#trade-offs","d":"ADR-076: Data-Subject Export (DSAR) as a Framework Contract","k":"Architecture Decision Records","t":"Trade-offs","x":"- Best-effort degradation can return a quietly incomplete package. Available = false is the only signal, and nothing forces a caller, a UI, or the subject to read it. A section…","i":"DataExportControllerBase UserDataExportDTO UserOwnershipRule CurrentUserId FeatureGate Available false"},{"u":"/docs/adr/076-data-subject-export.html#related","d":"ADR-076: Data-Subject Export (DSAR) as a Framework Contract","k":"Architecture Decision Records","t":"Related","x":"ADR-005 (the erasure half of the same privacy obligation, whose IAnonymizable opt-in and [Pii] guard are this contract's mirror: one erases what the other copies), ADR-033 (the…","i":"PiiEntitiesAreExportable UserOwnershipRule IAnonymizable FeatureGate Result Pii"},{"u":"/docs/adr/077-hybridcache-substrate.html","d":"ADR-077: HybridCache as an Opt-In ICacheService Substrate (Amends ADR-026)","k":"Architecture Decision Records","i":"ICacheService"},{"u":"/docs/adr/077-hybridcache-substrate.html#status","d":"ADR-077: HybridCache as an Opt-In ICacheService Substrate (Amends ADR-026)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-13). Amends ADR-026: Tier 1's substrate gains a third implementation beside MemoryCacheService and DistributedCacheService. It is opt-in through…","i":"OutputCacheEvictionRequested DistributedCacheService MMCA.Common.OutputCache AddCommonHybridCache MemoryCacheService"},{"u":"/docs/adr/077-hybridcache-substrate.html#context","d":"ADR-077: HybridCache as an Opt-In ICacheService Substrate (Amends ADR-026)","k":"Architecture Decision Records","t":"Context","x":"ADR-026 settled Tier 1 as one abstraction (ICacheService) over two implementations chosen at startup: in-process memory when no real IDistributedCache is present, Redis…","i":"Microsoft.Extensions.Caching.Hybrid ICacheService.IncrementAsync StackExchangeRedisCache IDistributedCache ICacheService HybridCache WRONGTYPE Result INCR"},{"u":"/docs/adr/077-hybridcache-substrate.html#decision","d":"ADR-077: HybridCache as an Opt-In ICacheService Substrate (Amends ADR-026)","k":"Architecture Decision Records","t":"Decision","x":"Ship HybridCacheService as a third ICacheService implementation, opt-in per host, under a disjoint keyspace. This is the structural rule the design is built around, and…","i":"HybridCacheEntryFlags.DisableUnderlyingData Microsoft.Extensions.Caching.Hybrid CacheOptions.DefaultDuration HybridCache.GetOrCreateAsync HybridCache.RemoveByTagAsync MMCA.Common.Infrastructure Directory.Packages.props DistributedCacheService DisableLocalCacheWrite IConnectionMultiplexer CachingQueryDecorator DisableLocalCacheRead"},{"u":"/docs/adr/077-hybridcache-substrate.html#rationale","d":"ADR-077: HybridCache as an Opt-In ICacheService Substrate (Amends ADR-026)","k":"Architecture Decision Records","t":"Rationale","x":"- The disjoint keyspace is the decision; everything else is implementation. Rather than trusting a second implementation to write a shape compatible with the first, this record…","i":"DisableUnderlyingData LocalCacheExpiration IncrementAsync GetAsync"},{"u":"/docs/adr/077-hybridcache-substrate.html#trade-offs","d":"ADR-077: HybridCache as an Opt-In ICacheService Substrate (Amends ADR-026)","k":"Architecture Decision Records","t":"Trade-offs","x":"- Invalidation does not reach other replicas' L1 immediately. A remove evicts the L2 entry and the calling replica's L1; every other replica keeps its copy for up to…","i":"AddCommonHybridCache LocalCacheExpiration GetOrCreateAsync IncrementAsync ICacheService RemoveAll"},{"u":"/docs/adr/077-hybridcache-substrate.html#related","d":"ADR-077: HybridCache as an Opt-In ICacheService Substrate (Amends ADR-026)","k":"Architecture Decision Records","t":"Related","x":"ADR-026 (amended by this record: its Tier 1 substrate gains a third implementation, its 30-second default TTL becomes the local-cache bound as well, its prefix-invalidation model…","i":"IncrementAsync GetAsync"},{"u":"/docs/adr/078-csv-export-endpoint.html","d":"ADR-078: CSV Export as a Dedicated Endpoint, Not Content Negotiation","k":"Architecture Decision Records"},{"u":"/docs/adr/078-csv-export-endpoint.html#status","d":"ADR-078: CSV Export as a Dedicated Endpoint, Not Content Negotiation","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-13; revised 2026-08-18). The implementation lands in the MMCA.Common \"enterprise capability wave\" release. Unlike the wave's other features this one is NOT…","i":"EntityControllerBase"},{"u":"/docs/adr/078-csv-export-endpoint.html#context","d":"ADR-078: CSV Export as a Dedicated Endpoint, Not Content Negotiation","k":"Architecture Decision Records","t":"Context","x":"The request is \"export what you filtered\". The generic entity surface of ADR-034 already accepts a full query vocabulary on the paged route…","i":"EntityQueryPipeline.MaxUnboundedResultLimit context.CacheVaryByRules.QueryKeys options.ReturnHttpNotAcceptable PublicEndpointOutputCachePolicy ReturnHttpNotAcceptable QueryFilterModelBinder IAsyncEnumerable OutputFormatter sortDirection sortColumn Accept AddAPI"},{"u":"/docs/adr/078-csv-export-endpoint.html#decision","d":"ADR-078: CSV Export as a Dedicated Endpoint, Not Content Negotiation","k":"Architecture Decision Records","t":"Decision","x":"EntityControllerBase gains a virtual [HttpGet(\"export\")] ExportAsync(...) action (Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs). It accepts the same…","i":"QueryFieldService.ShapeCollectionData IPhysicalDbContextFactory.Create ApplicationSettings.MaxPageSize IEntityQueryService.GetAllAsync UnhandledResultFailureFilter JsonNamingPolicy.CamelCase ExportRowLimitHeaderName OpenApiContractTestsBase MaxUnboundedResultLimit QueryFilterModelBinder IEntityControllerBase EntityControllerBase"},{"u":"/docs/adr/078-csv-export-endpoint.html#rationale","d":"ADR-078: CSV Export as a Dedicated Endpoint, Not Content Negotiation","k":"Architecture Decision Records","t":"Rationale","x":"- A route is an unambiguous request; an Accept header is a preference. Given a cache policy that ignores Accept and a pipeline configured to never return 406, a client that…","i":"OutputFormatter Accept"},{"u":"/docs/adr/078-csv-export-endpoint.html#trade-offs","d":"ADR-078: CSV Export as a Dedicated Endpoint, Not Content Negotiation","k":"Architecture Decision Records","t":"Trade-offs","x":"- Every derivative gains a bulk read whether its owner wanted one or not. The only gate is the controller's existing authorization posture. A resource that was safe to page 20…","i":"GetExportSpecification MaxExportRows ExportAsync MaxPageSize Accept Skip Take"},{"u":"/docs/adr/078-csv-export-endpoint.html#related","d":"ADR-078: CSV Export as a Dedicated Endpoint, Not Content Negotiation","k":"Architecture Decision Records","t":"Related","x":"ADR-034 (the generic entity surface and query contract this extends, and the MaxUnboundedResultLimit ceiling that forced the page loop), ADR-040 (the output-cache policy whose…","i":"MaxUnboundedResultLimit Accept Result"},{"u":"/docs/adr/079-shared-http-middleware-pipeline.html","d":"ADR-079: One Shared, Ordered HTTP Middleware Pipeline for Every Service Host","k":"Architecture Decision Records"},{"u":"/docs/adr/079-shared-http-middleware-pipeline.html#status","d":"ADR-079: One Shared, Ordered HTTP Middleware Pipeline for Every Service Host","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-14). Revised 2026-08-19 (refreshed the WebApplicationBuilderExtensions.cs cross-reference anchor, which moved to :555). Revised 2026-08-21: the order became…","i":"MiddlewarePipelineBuilder.CreateDefault WebApplicationBuilderExtensions.cs MiddlewarePipelineOrderTestsBase"},{"u":"/docs/adr/079-shared-http-middleware-pipeline.html#context","d":"ADR-079: One Shared, Ordered HTTP Middleware Pipeline for Every Service Host","k":"Architecture Decision Records","t":"Context","x":"In ASP.NET Core, middleware order is behavior, not style: a rate limiter placed before authentication partitions every request as anonymous, an HTTPS redirect placed in front of…","i":"TenantResolutionMiddleware SoftDeletedUserMiddleware UseAuthentication HttpContext.User"},{"u":"/docs/adr/079-shared-http-middleware-pipeline.html#decision","d":"ADR-079: One Shared, Ordered HTTP Middleware Pipeline for Every Service Host","k":"Architecture Decision Records","t":"Decision","x":"Ship the edge as one ordered pipeline in the framework, UseCommonMiddlewarePipeline (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:46), and…","i":"MiddlewarePipelineBuilder.CreateDefault MiddlewarePipelineOrderTestsBase DecoratorPipelineOrderTestsBase UseCommonRequestLocalization MiddlewarePipelineStepNames UseCommonMiddlewarePipeline TenantResolutionMiddleware ISoftDeletedUserValidator MiddlewarePipelineBuilder SoftDeletedUserMiddleware MiddlewarePipelineStep OidcDiscoveryEndpoint"},{"u":"/docs/adr/079-shared-http-middleware-pipeline.html#rationale","d":"ADR-079: One Shared, Ordered HTTP Middleware Pipeline for Every Service Host","k":"Architecture Decision Records","t":"Rationale","x":"- Order is behavior, so it belongs to the framework, not to each host. Four of the adjacencies above fail silently when reversed: the limiter stops limiting, the tenant resolver…","i":"Program.cs"},{"u":"/docs/adr/079-shared-http-middleware-pipeline.html#trade-offs","d":"ADR-079: One Shared, Ordered HTTP Middleware Pipeline for Every Service Host","k":"Architecture Decision Records","t":"Trade-offs","x":"- Two of this record's original costs are retired (2026-08-21). As accepted, nothing froze the order (no test referenced the method; the adjacencies were protected by comments…","i":"MiddlewarePipelineOrderTestsBase MiddlewarePipelineStepNames MapOidcDiscoveryEndpoint UseCommonSecurityHeaders PreForwardedCapture HttpContext.Items KnownIPNetworks InsertBefore KnownProxies PreForwarded Controllers jwks_uri"},{"u":"/docs/adr/079-shared-http-middleware-pipeline.html#related","d":"ADR-079: One Shared, Ordered HTTP Middleware Pipeline for Every Service Host","k":"Architecture Decision Records","t":"Related","x":"ADR-014 (the in-process sibling: one fixed decorator order for commands and queries), ADR-019 (depends on forwarded headers before the limiter and on the limiter after…"},{"u":"/docs/adr/080-deploy-rollout-revision-rollback.html","d":"ADR-080: Production Rollout with Automatic Revision-Only Rollback","k":"Architecture Decision Records"},{"u":"/docs/adr/080-deploy-rollout-revision-rollback.html#status","d":"ADR-080: Production Rollout with Automatic Revision-Only Rollback","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-14)."},{"u":"/docs/adr/080-deploy-rollout-revision-rollback.html#context","d":"ADR-080: Production Rollout with Automatic Revision-Only Rollback","k":"Architecture Decision Records","t":"Context","x":"Both production apps deploy to Azure Container Apps from a single deploy.yml job on push to main, and every gate runs before anything rolls out: the deploy job waits on…","i":"deploy.yml foundation deploy main"},{"u":"/docs/adr/080-deploy-rollout-revision-rollback.html#decision","d":"ADR-080: Production Rollout with Automatic Revision-Only Rollback","k":"Architecture Decision Records","t":"Decision","x":"Roll out one revision at a time, verify it from outside, and auto-revert the image only when the verification fails. - Single-revision rollout. Every container app runs…","i":"activeRevisionsMode rollback_failed containerapp createdTime Provisioned pipefail revision rollback failure sqlcmd probe Smoke"},{"u":"/docs/adr/080-deploy-rollout-revision-rollback.html#rationale","d":"ADR-080: Production Rollout with Automatic Revision-Only Rollback","k":"Architecture Decision Records","t":"Rationale","x":"- ARM success is the wrong success signal. The smoke gate converts \"the control plane accepted the template\" into \"the fleet answers requests\", which is the only claim a deploy…","i":"deploy"},{"u":"/docs/adr/080-deploy-rollout-revision-rollback.html#trade-offs","d":"ADR-080: Production Rollout with Automatic Revision-Only Rollback","k":"Architecture Decision Records","t":"Trade-offs","x":"- Schema is never rolled back, so a bad migration is fix-forward only. The image reverts and the database does not, so the previous release resumes against the new schema. This…","i":"rollback_failed Provisioned revision APPS copy"},{"u":"/docs/adr/080-deploy-rollout-revision-rollback.html#related","d":"ADR-080: Production Rollout with Automatic Revision-Only Rollback","k":"Architecture Decision Records","t":"Related","x":"ADR-057 (built on this model: revision-only rollback is why every migration must be backward compatible one release back), ADR-030 (startup migration as sole migrator, the reason…"},{"u":"/docs/adr/081-cost-baseline-deploy-gate.html","d":"ADR-081: The Cost Baseline as a Hard Deploy Gate","k":"Architecture Decision Records"},{"u":"/docs/adr/081-cost-baseline-deploy-gate.html#status","d":"ADR-081: The Cost Baseline as a Hard Deploy Gate","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-14)."},{"u":"/docs/adr/081-cost-baseline-deploy-gate.html#context","d":"ADR-081: The Cost Baseline as a Hard Deploy Gate","k":"Architecture Decision Records","t":"Context","x":"Both deployed apps run a deliberately small production footprint: every Container App is declared with maxReplicas: 2 and every SQL database with the Basic tier…","i":"maxReplicas Basic"},{"u":"/docs/adr/081-cost-baseline-deploy-gate.html#decision","d":"ADR-081: The Cost Baseline as a Hard Deploy Gate","k":"Architecture Decision Records","t":"Decision","x":"The cost baseline is asserted by a read-only reusable workflow that both runs weekly and sits in deploy.needs, so an un-reverted scale-up blocks the next production deploy. - One…","i":"properties.template.scale.maxReplicas BASELINE_MAX_REPLICAS AZURE_RESOURCE_GROUP github.event_name workflow_dispatch workflow_call deploy.needs environment release.yml main.bicep production MMCAStore"},{"u":"/docs/adr/081-cost-baseline-deploy-gate.html#rationale","d":"ADR-081: The Cost Baseline as a Hard Deploy Gate","k":"Architecture Decision Records","t":"Rationale","x":"- Configuration drift is the leading indicator; spend is the lagging one. The budget notification fires at 80% of actual spend, after the money is gone, and names a number rather…","i":"workflow_call deploy.needs maxReplicas deploy.yml sku.tier"},{"u":"/docs/adr/081-cost-baseline-deploy-gate.html#trade-offs","d":"ADR-081: The Cost Baseline as a Hard Deploy Gate","k":"Architecture Decision Records","t":"Trade-offs","x":"- A legitimate scale-up blocks deploys until the baseline is edited. Standing up extra capacity for a real event and then shipping a fix during it requires a pull request against…","i":"BASELINE_MAX_REPLICAS skip_freshness_gates skip_justification workflow_dispatch deploy.needs maxReplicas deploy.yml sku.tier Standard deploy Basic write"},{"u":"/docs/adr/081-cost-baseline-deploy-gate.html#related","d":"ADR-081: The Cost Baseline as a Hard Deploy Gate","k":"Architecture Decision Records","t":"Related","x":"ADR-064 (the sibling deploy-precondition record, which decides the three proof-of-recency gates and enumerates this one only in passing; its break-glass input does not apply…","i":"deploy.needs"},{"u":"/docs/adr/082-two-tier-cors-posture.html","d":"ADR-082: Two-Tier Cross-Origin Posture: Allow-Listed Service Policies, an Any-Header Gateway Policy","k":"Architecture Decision Records"},{"u":"/docs/adr/082-two-tier-cors-posture.html#status","d":"ADR-082: Two-Tier Cross-Origin Posture: Allow-Listed Service Policies, an Any-Header Gateway Policy","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-14)."},{"u":"/docs/adr/082-two-tier-cors-posture.html#context","d":"ADR-082: Two-Tier Cross-Origin Posture: Allow-Listed Service Policies, an Any-Header Gateway Policy","k":"Architecture Decision Records","t":"Context","x":"Both deployed applications put a YARP gateway in front of per-module service hosts (ADR-008), and the browser and MAUI clients talk to the gateway origin while the services…"},{"u":"/docs/adr/082-two-tier-cors-posture.html#decision","d":"ADR-082: Two-Tier Cross-Origin Posture: Allow-Listed Service Policies, an Any-Header Gateway Policy","k":"Architecture Decision Records","t":"Decision","x":"Ship two cross-origin policies from the framework: an allow-listed one for service hosts and a deliberately broader one for gateways. - Service hosts register two named policies…","i":"CorsPolicyAllowSpecificOrigins app.Environment.IsDevelopment UseCommonMiddlewarePipeline Cors__AllowedOrigins__0 _allowSpecificOrigins AddCommonGatewayCors CorsPolicyAllowAll AddDefaultPolicy AllowCredentials IHostEnvironment AllowAnyHeader AllowAnyMethod"},{"u":"/docs/adr/082-two-tier-cors-posture.html#rationale","d":"ADR-082: Two-Tier Cross-Origin Posture: Allow-Listed Service Policies, an Any-Header Gateway Policy","k":"Architecture Decision Records","t":"Rationale","x":"- A proxy cannot allow-list what it does not own. The gateway has no controllers and no knowledge of which headers the fronted services accept, so a header allow-list there would…","i":"UseCommonMiddlewarePipeline AllowCredentials IHostEnvironment AllowAnyOrigin AddCommonCors UseCors"},{"u":"/docs/adr/082-two-tier-cors-posture.html#trade-offs","d":"ADR-082: Two-Tier Cross-Origin Posture: Allow-Listed Service Policies, an Any-Header Gateway Policy","k":"Architecture Decision Records","t":"Trade-offs","x":"- The gateway policy is broad on two of three axes. Any header and any method are accepted for an allow-listed origin. The origin list is the only lever there, so a mistake in…","i":"ProductionHostApplicationFactory IHostEnvironment.IsDevelopment configuration.GetSection ValidateOnStart UseEnvironment AddCommonCors UseCors string Get"},{"u":"/docs/adr/082-two-tier-cors-posture.html#related","d":"ADR-082: Two-Tier Cross-Origin Posture: Allow-Listed Service Policies, an Any-Header Gateway Policy","k":"Architecture Decision Records","t":"Related","x":"ADR-079 (the shared middleware pipeline whose fixed order places the environment-selected CORS policy between routing and authentication), ADR-008 (the gateway plus per-module…"},{"u":"/docs/adr/083-crud-lifecycle-event-taxonomy.html","d":"ADR-083: One CRUD Lifecycle Event per Entity with a State Discriminator","k":"Architecture Decision Records"},{"u":"/docs/adr/083-crud-lifecycle-event-taxonomy.html#status","d":"ADR-083: One CRUD Lifecycle Event per Entity with a State Discriminator","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-14). Revised 2026-08-23: the adopter counts were refreshed (ADC Conference's ActivityChanged joined the base-derived set) and three source citations were…","i":"ActivityChanged"},{"u":"/docs/adr/083-crud-lifecycle-event-taxonomy.html#context","d":"ADR-083: One CRUD Lifecycle Event per Entity with a State Discriminator","k":"Architecture Decision Records","t":"Context","x":"ADR-003 decides how a domain event moves: captured into the outbox inside SaveChangesAsync, dispatched in-process after commit, or published to the broker when it is an…","i":"SaveChangesAsync SessionChanged SessionCreated SessionDeleted Changed Created Deleted Session Entity"},{"u":"/docs/adr/083-crud-lifecycle-event-taxonomy.html#decision","d":"ADR-083: One CRUD Lifecycle Event per Entity with a State Discriminator","k":"Architecture Decision Records","t":"Decision","x":"Every generic CRUD lifecycle transition of an entity raises one event type for that entity, carrying a DomainEntityState discriminator; handlers filter on State. - One base…","i":"ProductVariantPriceChanged TicketChangedAuditHandler ProductVariantSkuChanged ShoppingCartItemChanged SessionQuestionChanged ShoppingCartCheckedOut ProductVariantRemoved SessionCreatedHandler BaseIntegrationEvent ProductVariantAdded EntityChangedEvent DomainEntityState"},{"u":"/docs/adr/083-crud-lifecycle-event-taxonomy.html#rationale","d":"ADR-083: One CRUD Lifecycle Event per Entity with a State Discriminator","k":"Architecture Decision Records","t":"Rationale","x":"- One type per entity is one subscription surface. A subscriber declares interest in the entity, then decides which transitions matter, instead of the container deciding for it…","i":"SessionChanged SessionCreated SessionDeleted OrderPaid"},{"u":"/docs/adr/083-crud-lifecycle-event-taxonomy.html#trade-offs","d":"ADR-083: One CRUD Lifecycle Event per Entity with a State Discriminator","k":"Architecture Decision Records","t":"Trade-offs","x":"- Every selective handler pays a filter. A handler that cares about one transition has to open with a State guard and return (SessionCreatedHandler.cs:17-18 is the shape to…","i":"EntityChangedEvent PointsEntryChanged BaseDomainEvent LivePollChanged LivePollStatus Unchanged Added State TId"},{"u":"/docs/adr/083-crud-lifecycle-event-taxonomy.html#related","d":"ADR-083: One CRUD Lifecycle Event per Entity with a State Discriminator","k":"Architecture Decision Records","t":"Related","x":"ADR-003 (how these events are captured and dispatched; this ADR decides only their shape), ADR-010 (schema versioning for the discriminator once it crosses a service boundary),…","i":"MessageId"},{"u":"/docs/adr/084-stripe-webhook-ingress.html","d":"ADR-084: Stripe Webhook Ingress (Acceptance-Coded Responses and Startup Self-Registration)","k":"Architecture Decision Records"},{"u":"/docs/adr/084-stripe-webhook-ingress.html#status","d":"ADR-084: Stripe Webhook Ingress (Acceptance-Coded Responses and Startup Self-Registration)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-14)."},{"u":"/docs/adr/084-stripe-webhook-ingress.html#context","d":"ADR-084: Stripe Webhook Ingress (Acceptance-Coded Responses and Startup Self-Registration)","k":"Architecture Decision Records","t":"Context","x":"Four ADRs already cover how a message crosses a boundary in this workspace. ADR-003 decides how an event leaves a service (outbox, at-least-once). ADR-021 decides how a…","i":"Warning"},{"u":"/docs/adr/084-stripe-webhook-ingress.html#decision","d":"ADR-084: Stripe Webhook Ingress (Acceptance-Coded Responses and Startup Self-Registration)","k":"Architecture Decision Records","t":"Decision","x":"Treat third-party webhook ingress as its own contract with two halves: an acceptance-coded endpoint and a self-registering, self-provisioning endpoint registration at startup. -…","i":"StripeWebhookRegistrationService EventUtility.ValidateSignature payment_intent.payment_failed AddModuleSalesInfrastructure SignatureVerificationFailed StripeWebhookSecretProvider checkout.session.completed throwOnApiVersionMismatch checkout.session.expired HttpContext.Request.Body EventUtility.ParseEvent additionalPortMappings"},{"u":"/docs/adr/084-stripe-webhook-ingress.html#rationale","d":"ADR-084: Stripe Webhook Ingress (Acceptance-Coded Responses and Startup Self-Registration)","k":"Architecture Decision Records","t":"Rationale","x":"- The caller's protocol decides the response vocabulary. Stripe reads a status code as \"keep retrying\" or \"stop\", not as \"this succeeded\" or \"this failed\". Mapping every…","i":"Critical Warning"},{"u":"/docs/adr/084-stripe-webhook-ingress.html#trade-offs","d":"ADR-084: Stripe Webhook Ingress (Acceptance-Coded Responses and Startup Self-Registration)","k":"Architecture Decision Records","t":"Trade-offs","x":"- A startup service that writes to a live third-party account. Booting a Sales replica creates and deletes webhook endpoints in the real Stripe account…","i":"StripeWebhookRegistrationService PaymentReconciliationService PaymentsController WebhookBaseUrl SecretKey Critical Warning"},{"u":"/docs/adr/084-stripe-webhook-ingress.html#related","d":"ADR-084: Stripe Webhook Ingress (Acceptance-Coded Responses and Startup Self-Registration)","k":"Architecture Decision Records","t":"Related","x":"ADR-003 (outbound at-least-once delivery, the other end of the same family), ADR-021 (broker-side inbound dedup, which never sees a webhook), ADR-017 (client-supplied idempotency…"},{"u":"/docs/adr/085-identifier-type-aliases-revisited.html","d":"ADR-085: Identifier Type Aliases Revisited (Wrapper Structs Deferred Again, With Triggers)","k":"Architecture Decision Records"},{"u":"/docs/adr/085-identifier-type-aliases-revisited.html#status","d":"ADR-085: Identifier Type Aliases Revisited (Wrapper Structs Deferred Again, With Triggers)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-18). Revised 2026-08-23 (the alias count and the migration-surface census were recounted, the census gained a stated methodology, and the CheckIn and…","i":"CheckIn"},{"u":"/docs/adr/085-identifier-type-aliases-revisited.html#context","d":"ADR-085: Identifier Type Aliases Revisited (Wrapper Structs Deferred Again, With Triggers)","k":"Architecture Decision Records","t":"Context","x":"ADR-048 decided that every entity identity is a primitive named through a per-module global using {Entity}IdentifierType = ... alias, and recorded the cost in one line of…","i":"SpeakerIdentifierType UserIdentifierType StronglyTypedId IdentifierType Notification System.Guid Entity global Source using Vogen and"},{"u":"/docs/adr/085-identifier-type-aliases-revisited.html#decision","d":"ADR-085: Identifier Type Aliases Revisited (Wrapper Structs Deferred Again, With Triggers)","k":"Architecture Decision Records","t":"Decision","x":"Keep the aliases. The wrapper-struct alternative is evaluated in this record, priced, and deferred again, this time against explicit triggers. Inside a module an identifier is…","i":"SessionIdentifierType SponsorIdentifierType EventIdentifierType UserIdentifierType checkedInByUserId IEntityDTOMapper TIdentifierType IdentifierType ValueConverter JsonConverter CheckInScope BaseEntity"},{"u":"/docs/adr/085-identifier-type-aliases-revisited.html#rationale","d":"ADR-085: Identifier Type Aliases Revisited (Wrapper Structs Deferred Again, With Triggers)","k":"Architecture Decision Records","t":"Rationale","x":"- The cost is paid once and the benefit accrues per defect avoided, and the defect count is currently zero. No production incident in any of the four repos has been traced to a…","i":"System.Text.Json Guid int"},{"u":"/docs/adr/085-identifier-type-aliases-revisited.html#trade-offs","d":"ADR-085: Identifier Type Aliases Revisited (Wrapper Structs Deferred Again, With Triggers)","k":"Architecture Decision Records","t":"Trade-offs","x":"- The exposure is unmitigated, not reduced. This record buys no safety whatsoever. Every transposition ADR-048 could not catch is still uncatchable today, and the CheckIn…","i":"CheckIn.Create CheckIn Create int"},{"u":"/docs/adr/085-identifier-type-aliases-revisited.html#related","d":"ADR-085: Identifier Type Aliases Revisited (Wrapper Structs Deferred Again, With Triggers)","k":"Architecture Decision Records","t":"Related","x":"ADR-048 (the decision this record revisits and upholds; its Status now points here), ADR-068 (the deliberate opposite case: domain values carry invariants and therefore do get…"},{"u":"/docs/adr/085-identifier-type-aliases-revisited.html#revision-2026-08-23","d":"ADR-085: Identifier Type Aliases Revisited (Wrapper Structs Deferred Again, With Triggers)","k":"Architecture Decision Records","t":"Revision (2026-08-23)","x":"The decision, the priced alternative, the three triggers and the trade-offs are unchanged. What changed is arithmetic and three citations. The alias count is 44 across 10 files,…","i":"ActivityIdentifierType SpeakerIdentifierType IEntityDTOMapper TIdentifierType IdentifierType CheckInScope System.Guid TEntityDTO sponsorId IBaseDTO CheckIn TEntity"},{"u":"/docs/adr/086-process-manager-deferred.html","d":"ADR-086: A Process Manager Is Deferred, Not Absent (Relates to ADR-054)","k":"Architecture Decision Records"},{"u":"/docs/adr/086-process-manager-deferred.html#status","d":"ADR-086: A Process Manager Is Deferred, Not Absent (Relates to ADR-054)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-18) as a documented deferral. Nothing ships with this record: no state machine, no correlation store, no new package. What ships is the shape the coordinator…"},{"u":"/docs/adr/086-process-manager-deferred.html#context","d":"ADR-086: A Process Manager Is Deferred, Not Absent (Relates to ADR-054)","k":"Architecture Decision Records","t":"Context","x":"ADR-054 decided how this workspace achieves cross-boundary consistency without two-phase commit: choreography. Each step of a workflow raises a domain event, each compensating…","i":"PaymentReconciliationService PeriodicBackgroundService SagaStateMachineInstance MassTransitStateMachine Order.InventoryRestored InMemorySagaRepository Order.Status SaveChanges Source ISaga"},{"u":"/docs/adr/086-process-manager-deferred.html#decision","d":"ADR-086: A Process Manager Is Deferred, Not Absent (Relates to ADR-054)","k":"Architecture Decision Records","t":"Decision","x":"Defer the process manager, and record its shape so the deferral is a design decision rather than an omission. A durable multi-step workflow coordinator in this workspace is a…","i":"MassTransit.Azure.ServiceBus.Core MassTransitStateMachine MassTransit.RabbitMQ CorrelationId MassTransit InProcess TInstance Result"},{"u":"/docs/adr/086-process-manager-deferred.html#rationale","d":"ADR-086: A Process Manager Is Deferred, Not Absent (Relates to ADR-054)","k":"Architecture Decision Records","t":"Rationale","x":"- Choreography is genuinely correct for the workflow that exists. This is not a case of the simpler option being tolerated. Checkout's saga state is two fields on Order, and an…","i":"Order"},{"u":"/docs/adr/086-process-manager-deferred.html#trade-offs","d":"ADR-086: A Process Manager Is Deferred, Not Absent (Relates to ADR-054)","k":"Architecture Decision Records","t":"Trade-offs","x":"- The first workflow to hit the trigger pays the full cost at once, under whatever deadline made it appear. Deferral moves the work onto the critical path of the feature that…","i":"SQLServerDbContext"},{"u":"/docs/adr/086-process-manager-deferred.html#related","d":"ADR-086: A Process Manager Is Deferred, Not Absent (Relates to ADR-054)","k":"Architecture Decision Records","t":"Related","x":"ADR-054 (the accepted mechanism this record defers an alternative to: choreographed compensation, the persisted aggregate marker, and the reconciliation sweep that would remain…"},{"u":"/docs/adr/087-broker-poison-message-handling.html","d":"ADR-087: Broker Poison-Message Handling: Second-Level Redelivery and Fault Observability","k":"Architecture Decision Records"},{"u":"/docs/adr/087-broker-poison-message-handling.html#status","d":"ADR-087: Broker Poison-Message Handling: Second-Level Redelivery and Fault Observability","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-18). Amends ADR-009: the outbox's broker publish gains a circuit breaker, which is the first resilience policy this workspace applies to something other than an…"},{"u":"/docs/adr/087-broker-poison-message-handling.html#context","d":"ADR-087: Broker Poison-Message Handling: Second-Level Redelivery and Fault Observability","k":"Architecture Decision Records","t":"Context","x":"Delivery in this workspace has always been at-least-once with retries on both legs: the outbox retries a failed publish with jittered exponential backoff and eventually…","i":"rabbitmq_delayed_message_exchange DeadLetterRetentionDays"},{"u":"/docs/adr/087-broker-poison-message-handling.html#decision","d":"ADR-087: Broker Poison-Message Handling: Second-Level Redelivery and Fault Observability","k":"Architecture Decision Records","t":"Decision","x":"Three changes, each scoped to one failure: second-level redelivery configured per transport, a fault consumer with its own meter, and a circuit breaker around the outbox's broker…","i":"RegisterIntegrationEventConsumer settings.EnableDelayedRedelivery FaultIntegrationEventConsumer OperationCanceledException RedeliveryIntervalsSeconds broker.circuit.open.count BuildRedeliveryIntervals cfg.UseDelayedRedelivery ConfigureBrokerTransport EnableDelayedRedelivery BrokenCircuitException fault.FaultedMessageId"},{"u":"/docs/adr/087-broker-poison-message-handling.html#rationale","d":"ADR-087: Broker Poison-Message Handling: Second-Level Redelivery and Fault Observability","k":"Architecture Decision Records","t":"Rationale","x":"- The transport asymmetry follows a real capability difference, not a preference. RabbitMQ needs a plugin the dev container lacks; Service Bus does not. A single default would be…","i":"BrokenCircuitException true"},{"u":"/docs/adr/087-broker-poison-message-handling.html#trade-offs","d":"ADR-087: Broker Poison-Message Handling: Second-Level Redelivery and Fault Observability","k":"Architecture Decision Records","t":"Trade-offs","x":"- Delayed redelivery is off where the plugin problem lives. RabbitMQ is the local transport and also a plausible self-hosted production transport; both get default-off, so the…","i":"RegisterIntegrationEventConsumer RedeliveryIntervalsSeconds broker.fault.count MMCA.Common.Aspire BrokerMetrics internal"},{"u":"/docs/adr/087-broker-poison-message-handling.html#related","d":"ADR-087: Broker Poison-Message Handling: Second-Level Redelivery and Fault Observability","k":"Architecture Decision Records","t":"Related","x":"ADR-003 (the outbox publish leg this breaker wraps, and the retry, jittered backoff and dead-lettering that BrokenCircuitException reuses unchanged), ADR-066 (the transport…","i":"RedeliveryIntervalsSeconds BrokenCircuitException MMCA.Common.Broker MMCA.Common.Outbox MMCA.Common.Cqrs"},{"u":"/docs/adr/088-gateway-edge-responsibilities.html","d":"ADR-088: Gateway Edge Responsibilities (and the Three It Declines)","k":"Architecture Decision Records"},{"u":"/docs/adr/088-gateway-edge-responsibilities.html#status","d":"ADR-088: Gateway Edge Responsibilities (and the Three It Declines)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-18). Extends ADR-019 with a fourth, edge-tier layer whose posture is the deliberate opposite of the service tier's authenticated-only global limiter; nothing in…"},{"u":"/docs/adr/088-gateway-edge-responsibilities.html#context","d":"ADR-088: Gateway Edge Responsibilities (and the Three It Declines)","k":"Architecture Decision Records","t":"Context","x":"ADR-008 made the Gateway the only client entry point and gave it three jobs: the route-to-service map, CORS, and forwarding the caller's Authorization header. Nothing was added…","i":"CorrelationIdMiddleware AddCommonRateLimiting MMCA.Common.Aspire MMCA.Common.API Authorization"},{"u":"/docs/adr/088-gateway-edge-responsibilities.html#decision","d":"ADR-088: Gateway Edge Responsibilities (and the Three It Declines)","k":"Architecture Decision Records","t":"Decision","x":"Ship a gateway edge kit in a Gateway namespace inside MMCA.Common.Aspire, owning exactly three responsibilities, and record three more as deliberately declined. 1. Correlation is…","i":"PartitionedRateLimiter.CreateChained AddGatewayDownstreamHealthChecks RateLimitPartition.GetNoLimiter GatewayCorrelationMiddleware GatewayRateLimitingSettings HttpContext.TraceIdentifier Connection.RemoteIpAddress Validator.ValidateObject ValidateDataAnnotations AddGatewayRateLimiting GlobalConcurrencyLimit UseGatewayCorrelation"},{"u":"/docs/adr/088-gateway-edge-responsibilities.html#rationale","d":"ADR-088: Gateway Edge Responsibilities (and the Three It Declines)","k":"Architecture Decision Records","t":"Rationale","x":"- The edge is the only place that sees a request exactly once. That is what makes ensure-at-the-edge correct and mint-per-service wrong: not that the service version is broken,…","i":"Ready Live"},{"u":"/docs/adr/088-gateway-edge-responsibilities.html#trade-offs","d":"ADR-088: Gateway Edge Responsibilities (and the Three It Declines)","k":"Architecture Decision Records","t":"Trade-offs","x":"- The limiter closes over an eagerly-bound copy of the settings (GatewayRateLimitingExtensions.cs:154, consumed at :164-172), so an IOptionsMonitor reload never reaches it.…","i":"BypassPathPrefixes IOptionsMonitor PermitLimit IOptions"},{"u":"/docs/adr/088-gateway-edge-responsibilities.html#related","d":"ADR-088: Gateway Edge Responsibilities (and the Three It Declines)","k":"Architecture Decision Records","t":"Related","x":"ADR-008 (the record that made the Gateway the only entry point and gave it routing, CORS and auth forwarding; this is the first record to add cross-cutting behavior to it),…"},{"u":"/docs/adr/089-gateway-topology-owned-by-configuration.html","d":"ADR-089: Gateway Topology Owned by Configuration","k":"Architecture Decision Records"},{"u":"/docs/adr/089-gateway-topology-owned-by-configuration.html#status","d":"ADR-089: Gateway Topology Owned by Configuration","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-18; revised 2026-08-23: ADC's table gained a 27th route, /Activities, on 2026-08-19, and the bicep anchors below are corrected). Amends ADR-008: that record…","i":"MapForwarder ReverseProxy"},{"u":"/docs/adr/089-gateway-topology-owned-by-configuration.html#context","d":"ADR-089: Gateway Topology Owned by Configuration","k":"Architecture Decision Records","t":"Context","x":"Before this record, both gateways built their route table by hand, in code. ADC made 26 MapForwarder calls and Store made 10. Neither host called AddReverseProxy or…","i":"HttpResilienceDefaults.TotalRequestTimeout AddServiceDiscoveryDestinationResolver ForwarderRequestConfig.ActivityTimeout AddHttpForwarderWithServiceDiscovery ForwarderRequestConfig HttpVersion.Version20 RequestVersionExact appsettings.json AddReverseProxy IHttpForwarder LoadFromConfig ForwardHttp2"},{"u":"/docs/adr/089-gateway-topology-owned-by-configuration.html#decision","d":"ADR-089: Gateway Topology Owned by Configuration","k":"Architecture Decision Records","t":"Decision","x":"Make configuration the single source of the gateway route table, and pin it with a test. Each gateway calls…","i":"HttpResilienceDefaults.TotalRequestTimeout Http2ForwardingConfigFilter RequestVersionExact appsettings.json MapReverseProxy IHttpForwarder RouteMapTests ForwardHttp2 IProxyConfig MapForwarder ReverseProxy HttpRequest"},{"u":"/docs/adr/089-gateway-topology-owned-by-configuration.html#rationale","d":"ADR-089: Gateway Topology Owned by Configuration","k":"Architecture Decision Records","t":"Rationale","x":"- The drift already happened, in the repository that has the most gateway tests. ADC is the careful consumer, and it still carried three unpinned routes, an off-by-one comment…"},{"u":"/docs/adr/089-gateway-topology-owned-by-configuration.html#trade-offs","d":"ADR-089: Gateway Topology Owned by Configuration","k":"Architecture Decision Records","t":"Trade-offs","x":"- The compiler stopped helping. A misspelled cluster reference, a malformed path pattern or a route that shadows another is a runtime failure, discovered as a 404 or a 502, where…","i":"appsettings.json ForwardHttp2 IProxyConfig MapForwarder Order"},{"u":"/docs/adr/089-gateway-topology-owned-by-configuration.html#related","d":"ADR-089: Gateway Topology Owned by Configuration","k":"Architecture Decision Records","t":"Related","x":"ADR-008 (amended: the Gateway keeps the route-to-service map it was given, now expressed as configuration rather than as forwarder registrations), ADR-088 (the other half of this…","i":"HttpResilienceDefaults.TotalRequestTimeout"},{"u":"/docs/adr/090-event-upcaster-registration.html","d":"ADR-090: Event Upcaster Registration Extension Point","k":"Architecture Decision Records"},{"u":"/docs/adr/090-event-upcaster-registration.html#status","d":"ADR-090: Event Upcaster Registration Extension Point","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-21). Completes the follow-up named in ADR-010: that record established the versioning policy (a SchemaVersion signal plus a new-type-and-upcaster discipline for…","i":"SchemaVersion"},{"u":"/docs/adr/090-event-upcaster-registration.html#context","d":"ADR-090: Event Upcaster Registration Extension Point","k":"Architecture Decision Records","t":"Context","x":"ADR-010 splits event evolution into a signal and a discipline. The signal (SchemaVersion, a fitness-function-gated property on every integration event) shipped with ADR-010…","i":"InProcessMessageBus SchemaVersion"},{"u":"/docs/adr/090-event-upcaster-registration.html#decision","d":"ADR-090: Event Upcaster Registration Extension Point","k":"Architecture Decision Records","t":"Decision","x":"1. A typed upcaster abstraction, in the Application layer. IEventUpcaster (Source/Core/MMCA.Common.Application/Interfaces/IEventUpcaster.cs) is a pure payload mapping from a…","i":"RegisterUpcastedIntegrationEventConsumer EventUpcastersHaveUniqueSourceTypes EventUpcastersIncreaseSchemaVersion UpcastingIntegrationEventConsumer ArchitectureRules.Upcasters.cs services.AddEventUpcaster AddUserDataExportSection EventConventionTestsBase IIntegrationEventHandler IntegrationEventConsumer DomainEventDispatcher EventUpcasterRegistry"},{"u":"/docs/adr/090-event-upcaster-registration.html#rationale","d":"ADR-090: Event Upcaster Registration Extension Point","k":"Architecture Decision Records","t":"Rationale","x":"- The discipline becomes a mechanism. ADR-010's own framing (a thing that matters is a check, not a comment) now applies to the upcaster half: the transform has a first-class…","i":"SchemaVersion MessageId"},{"u":"/docs/adr/090-event-upcaster-registration.html#trade-offs","d":"ADR-090: Event Upcaster Registration Extension Point","k":"Architecture Decision Records","t":"Trade-offs","x":"- The upcast walk advances by declared target type, not runtime type. A misbehaving upcaster that returns an instance of some other type cannot send the walk into an unvalidated…","i":"UpcastingIntegrationEventConsumer OutboxMessage.DeserializeEvent OutputCacheEvictionRequested DomainEventDispatcher AddEventUpcaster"},{"u":"/docs/adr/091-cache-backed-password-reset.html","d":"ADR-091: Cache-Backed Password Reset","k":"Architecture Decision Records"},{"u":"/docs/adr/091-cache-backed-password-reset.html#status","d":"ADR-091: Cache-Backed Password Reset","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-22). Extends ADR-029 (the cache-backed login-protection idiom this record reuses) and ADR-032 (which decided how a password is stored, never how a user who has…"},{"u":"/docs/adr/091-cache-backed-password-reset.html#context","d":"ADR-091: Cache-Backed Password Reset","k":"Architecture Decision Records","t":"Context","x":"Both consumer apps shipped authenticated password change (PUT /Auth/password) and nothing for a user who cannot sign in at all. The recorded fallback in MMCA.ADC's specification…","i":"ResetTokenExpiresAt ResetTokenHash ResetAttempts PUT"},{"u":"/docs/adr/091-cache-backed-password-reset.html#decision","d":"ADR-091: Cache-Backed Password Reset","k":"Architecture Decision Records","t":"Decision","x":"1. The reset token is a cache record, not a schema change. IPasswordResetTokenService (Source/Core/MMCA.Common.Application/Auth/IPasswordResetTokenService.cs) is two methods,…","i":"CryptographicOperations.FixedTimeEquals PasswordResetAuthControllerBase IPasswordResetTokenService FindUntrackedByEmailAsync ForgotPasswordHandlerBase ResetPasswordHandlerBase PasswordReset__ResetUrl PasswordResetController ValidateAndConsumeAsync Auth.InvalidResetToken LoginProtectionService ForgotPasswordCommand"},{"u":"/docs/adr/091-cache-backed-password-reset.html#rationale","d":"ADR-091: Cache-Backed Password Reset","k":"Architecture Decision Records","t":"Rationale","x":"- No migration is the whole point. A reset credential is short-lived by nature, and the expiry semantics a reset needs (a TTL, a single use, an attempt cap) are native to a cache…","i":"Result.Success"},{"u":"/docs/adr/091-cache-backed-password-reset.html#trade-offs","d":"ADR-091: Cache-Backed Password Reset","k":"Architecture Decision Records","t":"Trade-offs","x":"- Cache eviction invalidates outstanding tokens. A Redis restart, an eviction under memory pressure, or a fall back to the in-memory store on a different replica all silently…","i":"IncrementAsync"},{"u":"/docs/adr/092-web-vitals-budget-gate.html","d":"ADR-092: Core Web Vitals Budget as a Shipped Test Contract and Deploy Gate","k":"Architecture Decision Records"},{"u":"/docs/adr/092-web-vitals-budget-gate.html#status","d":"ADR-092: Core Web Vitals Budget as a Shipped Test Contract and Deploy Gate","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-23)."},{"u":"/docs/adr/092-web-vitals-budget-gate.html#context","d":"ADR-092: Core Web Vitals Budget as a Shipped Test Contract and Deploy Gate","k":"Architecture Decision Records","t":"Context","x":"Rubric section 23 asks for client-side performance that is measured rather than assumed, naming Core Web Vitals (LCP, INP, CLS) or an equivalent as the evidence…"},{"u":"/docs/adr/092-web-vitals-budget-gate.html#decision","d":"ADR-092: Core Web Vitals Budget as a Shipped Test Contract and Deploy Gate","k":"Architecture Decision Records","t":"Decision","x":"Ship the measurement infrastructure and the assert mechanics in MMCA.Common.Testing.E2E, default the budget to the Core Web Vitals good band, and let the assertions ride the…","i":"MMCA.Common.Testing.E2E WEB_VITALS_OUTPUT_DIR WebVitalsBudgetTests BeLessThanOrEqualTo PerformanceObserver AssertWithinBudget WebVitalsCollector WriteArtifactAsync durationThreshold WebVitalsArtifact WebVitalsE2ETests E2E.Tests.csproj"},{"u":"/docs/adr/092-web-vitals-budget-gate.html#rationale","d":"ADR-092: Core Web Vitals Budget as a Shipped Test Contract and Deploy Gate","k":"Architecture Decision Records","t":"Rationale","x":"- The good band is an external contract, which is what makes an absolute ceiling defensible here. ADR-060 refused absolute latency because a nanosecond count is a property of the…"},{"u":"/docs/adr/092-web-vitals-budget-gate.html#trade-offs","d":"ADR-092: Core Web Vitals Budget as a Shipped Test Contract and Deploy Gate","k":"Architecture Decision Records","t":"Trade-offs","x":"- The gate is ui-scoped and may legitimately skip. Both apps gate e2e-gate on a ui change filter (ADC deploy.yml:538, Store :544) and deploy accepts skipped for it (ADC :896,…","i":"WEB_VITALS_OUTPUT_DIR InteractiveServer InteractiveAuto skipped deploy"},{"u":"/docs/adr/092-web-vitals-budget-gate.html#related","d":"ADR-092: Core Web Vitals Budget as a Shipped Test Contract and Deploy Gate","k":"Architecture Decision Records","t":"Related","x":"ADR-063 (the structural sibling: the same package, the same Playwright suite and the same deploy gate, applied to WCAG 2.1 AA instead of load performance), ADR-060 (the backend…"},{"u":"/docs/adr/093-container-image-posture.html","d":"ADR-093: Container Image Build and Runtime Posture","k":"Architecture Decision Records"},{"u":"/docs/adr/093-container-image-posture.html#status","d":"ADR-093: Container Image Build and Runtime Posture","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-23) for the three build decisions below. The two runtime postures in \"Open postures\" are recorded as undecided: they describe what the images do today and the…"},{"u":"/docs/adr/093-container-image-posture.html#context","d":"ADR-093: Container Image Build and Runtime Posture","k":"Architecture Decision Records","t":"Context","x":"Eleven Dockerfiles produce every deployable container in the two Azure-hosted applications: six in MMCA.ADC (four services, the Gateway, the Blazor web host) and five in…","i":"publish latest build final base COPY"},{"u":"/docs/adr/093-container-image-posture.html#decision","d":"ADR-093: Container Image Build and Runtime Posture","k":"Architecture Decision Records","t":"Decision","x":"1. The GitHub Packages credential is a BuildKit secret, never an ARG or ENV. Both applications' nuget.config source-maps MMCA. to GitHub Packages, so every restore inside an…","i":"Directory.Packages.props TreatWarningsAsErrors GITHUB_TOKEN nuget.config ENTRYPOINT history publish secrets docker dotnet build final"},{"u":"/docs/adr/093-container-image-posture.html#open-postures-undecided","d":"ADR-093: Container Image Build and Runtime Posture","k":"Architecture Decision Records","t":"Open postures (undecided)","x":"The base image is a floating tag, not a digest. All eleven images start from mcr.microsoft.com/dotnet/aspnet:10.0 with no digest pin (.../MMCA.ADC.Conference.Service/Dockerfile:1…","i":"aspnet latest final USER app"},{"u":"/docs/adr/093-container-image-posture.html#rationale","d":"ADR-093: Container Image Build and Runtime Posture","k":"Architecture Decision Records","t":"Rationale","x":"- A secret that is never a layer cannot leak from a layer. BuildKit secret mounts are the only mechanism that keeps the credential out of the image, the build cache and docker…","i":"history docker ARG ENV RUN"},{"u":"/docs/adr/093-container-image-posture.html#trade-offs","d":"ADR-093: Container Image Build and Runtime Posture","k":"Architecture Decision Records","t":"Trade-offs","x":"- Eleven copies drift independently. There is no shared base Dockerfile and no test that compares them, so a fix applied to one image is applied to one image. The ReadyToRun…","i":"csproj"},{"u":"/docs/adr/093-container-image-posture.html#related","d":"ADR-093: Container Image Build and Runtime Posture","k":"Architecture Decision Records","t":"Related","x":"ADR-038 (supply-chain provenance: it gates the package graph with lock files, a vulnerability audit and an SBOM, and stops at the repository boundary, so the image layers this…"},{"u":"/docs/adr/094-client-entity-data-access.html","d":"ADR-094: Client-Side Entity Data-Access Contract","k":"Architecture Decision Records"},{"u":"/docs/adr/094-client-entity-data-access.html#status","d":"ADR-094: Client-Side Entity Data-Access Contract","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-23)."},{"u":"/docs/adr/094-client-entity-data-access.html#context","d":"ADR-094: Client-Side Entity Data-Access Contract","k":"Architecture Decision Records","t":"Context","x":"ADR-034 decided the server half of entity data access: a generic controller base with a dynamic query contract, where filters arrive as filters[Property].operator /…","i":"QueryFilterModelBinder MMCA.Common.UI operator Property filters value"},{"u":"/docs/adr/094-client-entity-data-access.html#decision","d":"ADR-094: Client-Side Entity Data-Access Contract","k":"Architecture Decision Records","t":"Decision","x":"Client-side entity data access goes through one hand-written base hierarchy in MMCA.Common.UI. - One HTTP root: AuthenticatedServiceBase…","i":"DomainInvariantViolationException IdempotencyHeaders.IdempotencyKey EntityServiceBase.GetPagedAsync CreateAuthenticatedClientAsync ResetCancellationTokenAsync ListPageQueryStateService AuthenticatedServiceBase CultureDelegatingHandler Directory.Packages.props PersistentComponentState EnsureSuccessStatusCode ObjectDisposedException"},{"u":"/docs/adr/094-client-entity-data-access.html#rationale","d":"ADR-094: Client-Side Entity Data-Access Contract","k":"Architecture Decision Records","t":"Rationale","x":"- A hand-written typed base beats a generated client here because the surface is already generic. ADR-034 collapsed N entity endpoints into one shape, so there is exactly one…","i":"EnsureSuccessStatusCode"},{"u":"/docs/adr/094-client-entity-data-access.html#trade-offs","d":"ADR-094: Client-Side Entity Data-Access Contract","k":"Architecture Decision Records","t":"Trade-offs","x":"- No generated client means drift is caught at runtime, not at build time. A server-side rename of a query parameter or a DTO property does not fail the UI build; it fails the…","i":"ChildEntityServiceBase.PostAsync ConfigureHttpClientDefaults EntityServiceBaseTests.cs AuthenticatedServiceBase ChildEntityServiceBase AddServiceDefaults CartStateService RetryPolicy protected AddAsync readonly static"},{"u":"/docs/adr/094-client-entity-data-access.html#related","d":"ADR-094: Client-Side Entity Data-Access Contract","k":"Architecture Decision Records","t":"Related","x":"ADR-034 (the server surface this contract calls, and the filter grammar the client constructs), ADR-017 (the server-side filter whose client half is specified here: who mints the…","i":"DataGridListPageBase TDto"},{"u":"/docs/adr/095-soft-delete-unique-indexes.html","d":"ADR-095: Uniqueness Under Soft Delete (Filtered Unique Indexes)","k":"Architecture Decision Records"},{"u":"/docs/adr/095-soft-delete-unique-indexes.html#status","d":"ADR-095: Uniqueness Under Soft Delete (Filtered Unique Indexes)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-23)."},{"u":"/docs/adr/095-soft-delete-unique-indexes.html#context","d":"ADR-095: Uniqueness Under Soft Delete (Filtered Unique Indexes)","k":"Architecture Decision Records","t":"Context","x":"ADR-005 makes deletion soft: an IAuditableEntity sets IsDeleted = true (MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IAuditableEntity.cs:11) and a named global query…","i":"IAuditableEntity HasFilter IsDeleted true"},{"u":"/docs/adr/095-soft-delete-unique-indexes.html#decision","d":"ADR-095: Uniqueness Under Soft Delete (Filtered Unique Indexes)","k":"Architecture Decision Records","t":"Decision","x":"Make the filter a convention: every unique index on a soft-deletable entity excludes deleted rows, automatically, in every context of every consumer. - A model-finalizing…","i":"ApplicationDbContext.ConfigureConventions SoftDeleteUniqueIndexConvention SoftDeleteFilterSql.Build DataSource.CosmosDB HasSoftDeleteFilter additionalFilter IAuditableEntity HasColumnName filter null AND"},{"u":"/docs/adr/095-soft-delete-unique-indexes.html#rationale","d":"ADR-095: Uniqueness Under Soft Delete (Filtered Unique Indexes)","k":"Architecture Decision Records","t":"Rationale","x":"- The database should agree with what the application shows. The query filter already says a soft-deleted row does not exist; a unique index that disagrees is the one place the…","i":"DedupKey Build NULL NOT"},{"u":"/docs/adr/095-soft-delete-unique-indexes.html#trade-offs","d":"ADR-095: Uniqueness Under Soft Delete (Filtered Unique Indexes)","k":"Architecture Decision Records","t":"Trade-offs","x":"- It moves schema in consumers, invisibly from the entity configuration. Adopting the convention is a database-contract change: nothing in an entity configuration changed, but…","i":"IX_CategoryItem_CategoryId_Name ignoreQueryFilters builder.HasIndex index.GetFilter IX_User_Email IsDeleted IsUnique x.Email false true"},{"u":"/docs/adr/095-soft-delete-unique-indexes.html#related","d":"ADR-095: Uniqueness Under Soft Delete (Filtered Unique Indexes)","k":"Architecture Decision Records","t":"Related","x":"ADR-005 (decides soft-delete over erasure and owns the query filter that hides the row, but says nothing about uniqueness: this ADR closes that gap), ADR-057 (the expand/contract…","i":"ApplicationDbContext"},{"u":"/docs/adr/096-best-effort-side-effects.html","d":"ADR-096: Best-Effort Side-Effect Contract","k":"Architecture Decision Records"},{"u":"/docs/adr/096-best-effort-side-effects.html#status","d":"ADR-096: Best-Effort Side-Effect Contract","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-23)."},{"u":"/docs/adr/096-best-effort-side-effects.html#context","d":"ADR-096: Best-Effort Side-Effect Contract","k":"Architecture Decision Records","t":"Context","x":"A command that has already committed often has follow-up work attached to it: evict the output-cache entries the write invalidated, broadcast the new state to a live channel,…","i":"MarkAsFailed Exception catch"},{"u":"/docs/adr/096-best-effort-side-effects.html#decision","d":"ADR-096: Best-Effort Side-Effect Contract","k":"Architecture Decision Records","t":"Decision","x":"One framework helper defines the contract, and a swallow that does not go through it is a deliberate, documented exception. - BestEffort.ExecuteAsync(operation, logger, action,…","i":"BestEffortLog.DispatchFailed besteffort.dispatch.failed OperationCanceledException OutputCacheEvictionHandler BestEffort.ExecuteAsync MMCA.Common.OutputCache CancellationToken.None MMCA.Common.BestEffort ArgumentNullException cache.eviction.failed ProductVariantChanged TryEvictByTagAsync"},{"u":"/docs/adr/096-best-effort-side-effects.html#rationale","d":"ADR-096: Best-Effort Side-Effect Contract","k":"Architecture Decision Records","t":"Rationale","x":"- One policy beats five local leniencies. Each feature record is still right about its own degradation; what they could not each decide is the shape of the swallow. A single…","i":"AddVariantHandler"},{"u":"/docs/adr/096-best-effort-side-effects.html#trade-offs","d":"ADR-096: Best-Effort Side-Effect Contract","k":"Architecture Decision Records","t":"Trade-offs","x":"- Nothing gates use of the helper. There is no fitness rule, analyzer or architecture test that fails a build for a hand-rolled catch (Exception) that should have been a…","i":"besteffort.dispatch.failed cache.eviction.failed SubmitQuestionHandler MMCA.Common.Aspire BestEffort Exception catch"},{"u":"/docs/adr/096-best-effort-side-effects.html#related","d":"ADR-096: Best-Effort Side-Effect Contract","k":"Architecture Decision Records","t":"Related","x":"ADR-024 (push delivery failure is non-fatal and recorded rather than raised, one of the local leniencies this policy generalizes), ADR-026 (eviction is best-effort, and its…","i":"besteffort.dispatch.failed OutputCacheEvictionHandler"},{"u":"/docs/onboarding/index.html","d":"MMCA.Common + MMCA.ADC, Onboarding Guide","k":"Onboarding Guide","x":"A teaching guide for an experienced .NET engineer who is new to this codebase. It walks every first-party type, explaining not just what each type is but how it works and why it…","i":"CLAUDE.md dotnet new"},{"u":"/docs/onboarding/index.html#how-the-guide-is-organized-two-axes","d":"MMCA.Common + MMCA.ADC, Onboarding Guide","k":"Onboarding Guide","t":"How the guide is organized, two axes","x":"The guide has two organizing axes that work together. 1. Primary axis, functional grouping. Every type lives in exactly one functional group: the capability or cross-cutting…","i":"SelfHttpWarmupTask GateTestContext MMCA.Common MMCA.ADC Priority"},{"u":"/docs/onboarding/index.html#chapters","d":"MMCA.Common + MMCA.ADC, Onboarding Guide","k":"Onboarding Guide","t":"Chapters","x":"---","i":"AuthenticationServiceBase HttpResilienceDefaults AuthenticationService ConferencePermissions ApplicationDbContext IdentityPermissions SQLServerDbContext HealthCheckTags OutboxFinalizer HasPermission ThemeService Contracts"},{"u":"/docs/onboarding/index.html#legend-how-to-read-a-type-section","d":"MMCA.Common + MMCA.ADC, Onboarding Guide","k":"Onboarding Guide","t":"Legend, how to read a type section","x":"Every type gets one section using this template: {TypeName} {Assembly} · {namespace} · {file:line} · Level {n} · {kind} - What it is: one or two plain-language sentences. -…","i":"namespace Result Rubric Name"},{"u":"/docs/onboarding/index.html#suggested-reading-paths","d":"MMCA.Common + MMCA.ADC, Onboarding Guide","k":"Onboarding Guide","t":"Suggested reading paths","x":"- Framework-first (recommended). Primer → group-01 → upward. You meet the MMCA.Common foundations before the MMCA.ADC features that build on them; this matches dependency order…","i":"MMCA.Common MMCA.ADC Rubric"},{"u":"/docs/onboarding/index.html#the-companion-projects-context","d":"MMCA.Common + MMCA.ADC, Onboarding Guide","k":"Onboarding Guide","t":"The companion projects (context)","x":"This guide covers MMCA.Common (the framework) and MMCA.ADC (one consumer). MMCA.Store is out of scope. The dependency arrow is why the Common framework groups (1–16) come before…","i":"MMCA.Store"},{"u":"/docs/onboarding/00-dependency-manifest.html","d":"Phase 1: Dependency Manifest & Leveling","k":"Onboarding Guide","x":"Each distinct type node is assigned a Level by longest-path layering over its first-party dependencies (base/interface, generic constraints, field/property/param/return types,…","i":"System.Guid global static using Using int"},{"u":"/docs/onboarding/00-dependency-manifest.html#manifest-by-level-then-assembly","d":"Phase 1: Dependency Manifest & Leveling","k":"Onboarding Guide","t":"Manifest (by level, then assembly)","i":"DefaultEntityConfigurationAssemblyProviderTests GetPublicSessionCategoryItemFilterHandlerTests GetPublicSpeakerCategoryItemFilterHandlerTests AddSessionQuestionAnswerCommandValidatorTests ConferenceCategoryCreateRequestValidatorTests ConferenceCategoryUpdateRequestValidatorTests UserNotificationExportServiceGrpcAdapterTests MarkAllNotificationsReadHandlerTrackingTests SignalRPushNotificationSenderAdditionalTests UserSessionBookmarkCacheEvictionHandlerTests AddEventQuestionAnswerCommandValidatorTests AddSessionCategoryItemCommandValidatorTests"},{"u":"/docs/onboarding/00-group-taxonomy.html","d":"Phase 1b - Functional Group Taxonomy","k":"Onboarding Guide","x":"This is the primary axis of the guide. Every one of the 3,465 distinct first-party type nodes from 00-inventory.md is assigned to exactly one functional group - its primary home:…","i":"MMCA.Common MMCA.ADC Result"},{"u":"/docs/onboarding/00-group-taxonomy.html#design-notes-boundary-decisions-worth-stating-up-front","d":"Phase 1b - Functional Group Taxonomy","k":"Onboarding Guide","t":"Design notes (boundary decisions worth stating up front)","x":"- Cycles are kept whole. The 13 dependency cycles (SCCs) from the manifest are never split across groups. Notably the ApplicationDbContext AuditSaveChangesInterceptor…","i":"DomainEventSaveChangesInterceptor DataSourceModelCacheKeyFactory AuditSaveChangesInterceptor MMCA.ADC.Notification ApplicationDbContext MMCA.Common.Testing IAnonymizable PiiAttribute Gallery Rubric Fact S30"},{"u":"/docs/onboarding/00-group-taxonomy.html#the-groups-ordered","d":"Phase 1b - Functional Group Taxonomy","k":"Onboarding Guide","t":"The groups (ordered)","x":"Reconciliation: 1685 production types across 26 groups + 1780 test/testing types in G25 = 3465 (matches the inventory's distinct-node count). No type appears twice; none dropped.…"},{"u":"/docs/onboarding/00-group-taxonomy.html#group-membership","d":"Phase 1b - Functional Group Taxonomy","k":"Onboarding Guide","t":"Group membership","x":"group-01-result-error-handling.md 14 types The Result/Error railway that every operation returns instead of throwing; pagination result shapes. group-02-domain-building-blocks.md…","i":"MMCA.ADC.ServiceBusEmulator.IntegrationTests SessionBookmarkValidationServiceGrpcAdapter DefaultEntityConfigurationAssemblyProvider GetPublicSessionCategoryItemFilterHandler GetPublicSpeakerCategoryItemFilterHandler SessionQuestionPendingCountChangedPayload AddSessionQuestionAnswerCommandValidator ConferenceCategoryCreateRequestValidator ConferenceCategoryUpdateRequestValidator CookieSessionRefreshMiddlewareExtensions DisabledSessionBookmarkValidationService MMCA.ADC.Conference.Infrastructure.Tests"},{"u":"/docs/onboarding/00-inventory.html","d":"Phase 0: Type Inventory","k":"Onboarding Guide","x":"Generated mechanically by a Roslyn syntactic parse of every in-scope .cs file under MMCA.Common/Source, MMCA.Common/Tests, MMCA.ADC/Source, MMCA.ADC/Tests. - Files scanned: 2810…","i":"extension"},{"u":"/docs/onboarding/00-inventory.html#full-inventory","d":"Phase 0: Type Inventory","k":"Onboarding Guide","t":"Full inventory","i":"MMCA.ADC.Conference.Application.Events.Sessionize MMCA.ADC.Conference.Application.Events.Validation MMCA.ADC.Conference.Application.Tests.Events.DTOs MMCA.ADC.Conference.Domain.Questions.DomainEvents MMCA.ADC.Conference.Infrastructure.Tests.Services MMCA.ADC.Conference.IntegrationTests.CrossService MMCA.ADC.Engagement.Application.CheckIns.Services MMCA.ADC.Engagement.Domain.LivePolls.DomainEvents MMCA.ADC.Engagement.Domain.Tests.SessionQuestions MMCA.ADC.Identity.IntegrationTests.Infrastructure MMCA.Common.Application.Interfaces.Infrastructure MMCA.Common.Application.Users.UseCases.DeleteUser"},{"u":"/docs/onboarding/00-inventory.html#extensiont-blocks","d":"Phase 0: Type Inventory","k":"Onboarding Guide","t":"extension(T) blocks","i":"IDistributedApplicationBuilder IBusRegistrationConfigurator AuthenticationBuilder IEndpointRouteBuilder WebApplicationBuilder IApplicationBuilder ICurrentUserService IReadOnlyCollection currentUserService IServiceCollection OutputCacheOptions IResourceBuilder"},{"u":"/docs/onboarding/00-inventory.html#generated--excluded-artifacts-no-type-sections-written","d":"Phase 0: Type Inventory","k":"Onboarding Guide","t":"Generated / excluded artifacts (no type sections written)","x":"118 files excluded as generated (EF migrations, snapshots, .g.cs, AssemblyInfo)."},{"u":"/docs/onboarding/00-primer.html","d":"Primer, the concepts, stack, and conventions you need first","k":"Onboarding Guide","x":"This chapter teaches the cross-cutting things once, so the per-type chapters can stay focused. Read it before the group chapters (start with group-01). Everything here is either…"},{"u":"/docs/onboarding/00-primer.html#1-the-big-picture","d":"Primer, the concepts, stack, and conventions you need first","k":"Onboarding Guide","t":"1. The big picture","x":"Two codebases are in scope: - MMCA.Common: a framework, published as fifteen NuGet packages to nuget.org (the documented install path) and mirrored to GitHub Packages (ADR-053)…","i":"Testing.Architecture Aspire.Hosting Infrastructure Application MMCA.Common Testing.E2E references Testing.UI MMCA.ADC Testing UI.Maui Aspire"},{"u":"/docs/onboarding/00-primer.html#2-architectural-styles-this-codebase-commits-to","d":"Primer, the concepts, stack, and conventions you need first","k":"Onboarding Guide","t":"2. Architectural styles this codebase commits to","x":"These are the recurring ideas. Each is taught fully at its first concrete appearance in a group chapter; here is the orientation so the vocabulary is familiar. - Domain-Driven…","i":"EntityTypeConfigurationSQLServer PublicEndpointOutputCachePolicy JwtForwardingClientInterceptor FaultIntegrationEventConsumer GatewayCorrelationMiddleware JwtSettings.SigningAlgorithm EntityTypeConfigurationBase UseCommonMiddlewarePipeline TenantResolutionMiddleware ExportUserDataHandlerBase ISoftDeletedUserValidator ServiceInfoControllerBase"},{"u":"/docs/onboarding/00-primer.html#3-the-external-stack-bcl--nuget-external-level-0","d":"Primer, the concepts, stack, and conventions you need first","k":"Onboarding Guide","t":"3. The external stack (BCL / NuGet, \"external Level 0\")","x":"These are not first-party and get no per-type sections. Versions are from MMCA.Common/Directory.Packages.props and MMCA.ADC/Directory.Packages.props (Central Package Management,…","i":"Microsoft.Extensions.ServiceDiscovery.Yarp Microsoft.Extensions.Http.Resilience Notification.PushNotifications IEntityTypeConfiguration MMCA.Common.UI global.json IMessageBus SaveChanges TryDecorate DbContext OrderBy vX.Y.Z"},{"u":"/docs/onboarding/00-primer.html#4-c-build-and-code-style-conventions","d":"Primer, the concepts, stack, and conventions you need first","k":"Onboarding Guide","t":"4. C#, build, and code-style conventions","x":"- .NET 10.0, LangVersion: preview: required because the codebase uses C extension types (extension(T) syntax, see below). - Central Package Management (CPM). All NuGet versions…","i":"csharp_style_namespace_declarations MMCA.Common.Testing.Architecture ManagePackageVersionsCentrally Directory.Packages.props DependencyInjection.cs DependencyVersionTests TreatWarningsAsErrors csharp_prefer_braces EntityTypeExtensions packageSourceMapping IServiceCollection IArchitectureMap"},{"u":"/docs/onboarding/00-primer.html#5-the-solution--test-layout","d":"Primer, the concepts, stack, and conventions you need first","k":"Onboarding Guide","t":"5. The solution / test layout","x":"- .slnx: the human solution (XML format). .slnf, a solution filter used in CI to build a subset fast (MMCA.Store.CI.slnf, MMCA.ADC.CI.slnf). - Microsoft Testing Platform, not…","i":"MMCA.Common.UI.E2E.Tests MMCA.Common.UI.Gallery MMCA.Store.CI.slnf MMCA.ADC.CI.slnf csproj slnx"},{"u":"/docs/onboarding/00-primer.html#6-the-34-category-architecture-evaluation-lens","d":"Primer, the concepts, stack, and conventions you need first","k":"Onboarding Guide","t":"6. The 34-category architecture-evaluation lens","x":"This codebase is also scored against a 34-category rubric (Website/docs-src/governance/ArchitectureEvaluationCriteria.md, published at ). This guide weaves the rubric in so you…","i":"Rubric Name"},{"u":"/docs/onboarding/group-01-result-error-handling.html","d":"1. Result & Error Handling","k":"Onboarding Guide","x":"This is the first capability chapter, and it is deliberately first because the pattern it teaches underpins almost every other one in the guide. Before you read a command…","i":"ArgumentOutOfRangeException.ThrowIfNegative ArgumentNullException.ThrowIfNull DomainInvariantViolationException MMCA.Common.Shared.Serialization MMCA.Common.Shared.Abstractions System.Text.Json.Utf8JsonReader GrpcResultExceptionInterceptor System.Text.Json.Serialization MMCA.Common.Shared.Exceptions System.Buffers.Text.Base64Url Base64Url.TryDecodeFromChars ValidationFailureExtensions"},{"u":"/docs/onboarding/group-02-domain-building-blocks.html","d":"2. Domain Building Blocks (Entities, Value Objects, Aggregates)","k":"Onboarding Guide","x":"What this group covers. This is the DDD heart of the framework, the small, dependency-light primitives every business model in MMCA.Common and MMCA.ADC is built from. There are…","i":"EnumerationJsonConverterFactory AuditableAggregateRootEntity IdValueGeneratedAttribute CurrencyJsonConverter PhoneNumberInvariants EntityTypeExtensions EnumerationConverter AuditableBaseEntity MMCA.Common.Domain MMCA.Common.Shared RedactableProperty AddressInvariants"},{"u":"/docs/onboarding/group-02-domain-building-blocks.html#the-entity-chain-one-capability-per-rung","d":"2. Domain Building Blocks (Entities, Value Objects, Aggregates)","k":"Onboarding Guide","t":"The entity chain, one capability per rung","x":"Read the chain bottom-up. BaseEntity (MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/BaseEntity.cs:14) is almost nothing: a single required init identifier of the per-entity…","i":"AuditableAggregateRootEntity AuditSaveChangesInterceptor ChangeTracker.Entries AuditableBaseEntity GetChildOrNotFound RemoveDomainEvents ClearDomainEvents IAuditableEntity ValidateSetItems TIdentifierType AddDomainEvent entry.Property"},{"u":"/docs/onboarding/group-02-domain-building-blocks.html#two-opt-in-markers-beside-the-chain","d":"2. Domain Building Blocks (Entities, Value Objects, Aggregates)","k":"Onboarding Guide","t":"Two opt-in markers beside the chain","x":"Not every cross-cutting capability belongs on the inheritance chain, because not every entity should pay for it. ITenantEntity…","i":"AuditTrailSaveChangesInterceptor TenantSaveChangesInterceptor entity.HasQueryFilter ApplyTenantFilters IAuditableEntity TenantFilterName AddMultiTenancy AuditTrailEntry IAuditedEntity AddAuditTrail configuration ITenantEntity"},{"u":"/docs/onboarding/group-02-domain-building-blocks.html#how-a-domain-event-leaves-an-aggregate","d":"2. Domain Building Blocks (Entities, Value Objects, Aggregates)","k":"Onboarding Guide","t":"How a domain event leaves an aggregate","x":"The runtime flow ties this group to the events/outbox group. A command handler loads an aggregate, calls a business method, and that method calls AddDomainEvent(...); the event…","i":"DomainEventSaveChangesInterceptor context.ChangeTracker.Entries RemoveDomainEvents DomainEntityState IIntegrationEvent DeferredDispatch OutboxProcessor AddDomainEvent IAggregateRoot OutboxMessage IDomainEvent Unchanged"},{"u":"/docs/onboarding/group-02-domain-building-blocks.html#value-objects-invalid-instances-cannot-exist","d":"2. Domain Building Blocks (Entities, Value Objects, Aggregates)","k":"Onboarding Guide","t":"Value objects, invalid instances cannot exist","x":"The second family models concepts with no identity: two Money(10, USD) are equal because their values match, not because they are the same row. ValueObject is the cheapest…","i":"EnsurePreferredCultureIsValid EnsurePreferredThemeIsValid EnsureCollectionIsNotEmpty InvalidOperationException PhoneNumberValueConverter EnsureMoneyIsNotNegative EnsureBytesAreNotEmpty EnsureStringIsNotEmpty CurrencyJsonConverter EnsureStringMaxLength PhoneNumberInvariants EnsureIdIsNotDefault"},{"u":"/docs/onboarding/group-02-domain-building-blocks.html#smart-enumerations-a-closed-set-that-can-carry-behavior","d":"2. Domain Building Blocks (Entities, Value Objects, Aggregates)","k":"Onboarding Guide","t":"Smart enumerations, a closed set that can carry behavior","x":"Enumeration (MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Enumeration.cs:71) is the answer to a recurring shape a CLR enum handles badly: a closed set of named members…","i":"ValueObjectsAreImmutableSealedInShared JsonSerializerOptions.Converters EnumerationJsonConverterFactory Enumeration.UnknownValue Enumeration.UnknownName CurrencyJsonConverter EnumerationConverter ReadOnlyCollection FrozenDictionary JsonConverter JsonException TEnumeration"},{"u":"/docs/onboarding/group-02-domain-building-blocks.html#governance-markers-metadata-that-other-layers-act-on","d":"2. Domain Building Blocks (Entities, Value Objects, Aggregates)","k":"Onboarding Guide","t":"Governance markers, metadata that other layers act on","x":"The last family is tiny attributes and helpers that carry intent the rest of the stack reads reflectively. PiiAttribute…","i":"AuditTrailSaveChangesInterceptor CultureInfo.InvariantCulture IdValueGeneratedAttribute PiiRedactor.RedactedToken EncryptedStringConverter PiiConventionTestsBase ConcurrentDictionary EntityTypeExtensions GetCustomAttribute IsIdValueGenerated MMCA.Common.Domain PiiConventionTests"},{"u":"/docs/onboarding/group-02-domain-building-blocks.html#where-this-group-sits","d":"2. Domain Building Blocks (Entities, Value Objects, Aggregates)","k":"Onboarding Guide","t":"Where this group sits","x":"Everything above is consumed by the layers that follow: every module entity (for example the Conference domain, Engagement, and Identity modules) derives from one of the three…","i":"EnumerationJsonConverterFactory.CreateConverter PhoneNumberInvariants.EnsurePhoneNumberIsValid AddressInvariants.EnsureAddressLine1IsValid MMCA.Common.Domain.Interfaces.IAnonymizable AddressInvariants.AddressLine1MaxLength EntityTypeExtensions.IsIdValueGenerated AddressInvariants.EnsureAddressIsValid EventInvariants.EnsureDateRangeIsValid ValueObjectsAreImmutableSealedInShared EntityTypeBuilderExtensions.OwnsMoney EntitiesWithPiiImplementAnonymizable EmailInvariants.EnsureEmailIsValid"},{"u":"/docs/onboarding/group-03-querying-specifications.html","d":"3. Querying: Specifications, Filtering & the Entity Query Service","k":"Onboarding Guide","x":"What this group covers. Every read in MMCA.Common and ADC (\"list the published events\", \"get session 42\", \"the speakers in Atlanta, page 3, sorted by name, with only the name and…","i":"QuerySpecification Expression IQueryable TEntity OFFSET SELECT ORDER WHERE bool Func name bio"},{"u":"/docs/onboarding/group-03-querying-specifications.html#the-specification-pattern-the-trusted-predicate","d":"3. Querying: Specifications, Filtering & the Entity Query Service","k":"Onboarding Guide","t":"The Specification pattern, the trusted predicate","x":"ISpecification (MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/ISpecification.cs:12) exposes two faces of one rule: a Criteria expression tree that EF Core translates to…","i":"PublicSessionStatusSpecification.StatusCriteria GetPublicSessionFilterHandler PublishedEventSpecification CrossSourceSpecification OwnedByUserSpecification SpecificationExtensions SpecificationComposer dependent.ForeignKey InvocationExpression Enumerable.Contains InlineSpecification s.Event.IsPublished"},{"u":"/docs/onboarding/group-03-querying-specifications.html#queryspecification-a-whole-read-in-one-object","d":"3. Querying: Specifications, Filtering & the Entity Query Service","k":"Onboarding Guide","t":"QuerySpecification, a whole read in one object","x":"A plain specification is only a predicate, which leaves includes, ordering, paging, and tracking to be threaded through every layer as loose arguments. QuerySpecification…","i":"IgnoreQueryFilters QuerySpecification EFReadRepository LambdaExpression OrderExpression TIdentifierType WithSoftDeleted specification Specification BaseQueryFor IncludePaths WithTracking"},{"u":"/docs/onboarding/group-03-querying-specifications.html#dynamic-filtering-one-strategy-per-clr-type","d":"3. Querying: Specifications, Filtering & the Entity Query Service","k":"Onboarding Guide","t":"Dynamic filtering, one Strategy per CLR type","x":"User filters arrive as a Dictionary , property name to operator key plus raw string value, parsed from the query string by QueryFilterModelBinder at the API edge, which caps a…","i":"Filter.Operator.NotSupported QueryParameterizationTests Filter.Property.NotFound Filter.Type.NotSupported datetimefilterstrategy QueryFilterModelBinder ResolveFilterValueType decimalfilterstrategy Filter.Value.Invalid StringFilterStrategy ResolvePropertyInfo boolfilterstrategy"},{"u":"/docs/onboarding/group-03-querying-specifications.html#sorting-sparse-fieldsets-and-paging-arithmetic","d":"3. Querying: Specifications, Filtering & the Entity Query Service","k":"Onboarding Guide","t":"Sorting, sparse fieldsets, and paging arithmetic","x":"QueryFieldService (MMCA.Common/Source/Core/MMCA.Common.Application/Services/QueryFieldService.cs:16) owns the rest of read shaping. ApplySorting (QueryFieldService.cs:155)…","i":"PropertyInfo.GetValue ValidateSortDirection ApplyFieldSelection ShapeCollectionData GetShapedAccessors Expression.Lambda QueryFieldService PagingMath.Clamp PropertyAccessor MaxCacheEntries ExpandoObject ApplySorting"},{"u":"/docs/onboarding/group-03-querying-specifications.html#the-pipeline-two-entity-paths-plus-projection-pushdown","d":"3. Querying: Specifications, Filtering & the Entity Query Service","k":"Onboarding Guide","t":"The pipeline: two entity paths plus projection pushdown","x":"IEntityQueryPipeline (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/IEntityQueryPipeline.cs:10) is the execution contract, implemented by the sealed…","i":"ApplyIncludesCriteriaAndFilters inavigationmetadataprovider NavigationMetadataProvider MaxUnboundedResultLimit NavigationPropertyInfo CountUnpaginatedAsync EntityQueryParameters ExecuteProjectedAsync IEntityQueryPipeline INavigationPopulator entityquerypipeline IEntityDTOProjector"},{"u":"/docs/onboarding/group-03-querying-specifications.html#the-query-service-the-public-face","d":"3. Querying: Specifications, Filtering & the Entity Query Service","k":"Onboarding Guide","t":"The query service, the public face","x":"IEntityQueryService (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEntityQueryService.cs:19) and its concrete EntityQueryService…","i":"SpeakerEntityQueryService BuildPaginationMetadata MaxUnboundedResultLimit TryGetByIdFastPathAsync DTOToEntityPropertyMap TryGetFastPathIncludes EntityQueryParameters ExecuteProjectedAsync PagedCollectionResult GetAllForLookupAsync INavigationPopulator DTOMapper.MapToDTOs"},{"u":"/docs/onboarding/group-03-querying-specifications.html#end-to-end-one-list-request","d":"3. Querying: Specifications, Filtering & the Entity Query Service","k":"Onboarding Guide","t":"End to end, one list request","x":"The request reaches a read controller, EntityControllerBase (Group 12), which resolves MaxPageSize per request from IApplicationSettings, falling back to 500 when unset…","i":"IEntityQueryService.GetAllAsync EntityQueryParameters PagedCollectionResult EntityControllerBase IApplicationSettings EntityQueryPipeline TIdentifierType MaxPageSize PagingMath TEntityDTO requested TEntity"},{"u":"/docs/onboarding/group-03-querying-specifications.html#also-filed-here-the-best-effort-side-effect-helper","d":"3. Querying: Specifications, Filtering & the Entity Query Service","k":"Onboarding Guide","t":"Also filed here: the best-effort side-effect helper","x":"Three types in this group are not part of the read path at all; they are co-located in MMCA.Common.Application/Services and are grouped by that folder. BestEffort…","i":"Filtering.DynamicQueryConfig.Parameterized IEntityQueryPipeline.ExecuteProjectedAsync MMCA.Common.Application.Services.Filtering SpecificationsDoNotNavigateToOtherEntities ArgumentException.ThrowIfNullOrWhiteSpace QueryFilterService.ResolveFilterValueType Microsoft.Extensions.DependencyInjection NavigationMetadataProvider.BuildIncludes CrossSourceSpecification.BuildCriteria MMCA.Common.Application.Services.Query MMCA.Common.Application.Specifications QueryFieldService.ApplyFieldSelection"},{"u":"/docs/onboarding/group-04-events-outbox.html","d":"4. Domain & Integration Events + Outbox Dual-Dispatch","k":"Onboarding Guide","x":"What this chapter covers. This group is the codebase's event spine: how an aggregate says \"something happened\", how that fact is persisted so it cannot be lost, and how it…"},{"u":"/docs/onboarding/group-04-events-outbox.html#the-two-kinds-of-event","d":"4. Domain & Integration Events + Outbox Dual-Dispatch","k":"Onboarding Guide","t":"The two kinds of event","x":"Everything starts with two marker interfaces in the Domain layer. IDomainEvent is the base contract: a DateOccurred timestamp (when the business action happened, not when it was…","i":"BaseIntegrationEvent EntityChangedEvent DomainEntityState IIntegrationEvent BaseDomainEvent TIdentifierType Infrastructure UserRegistered SchemaVersion Architecture DateOccurred IDomainEvent"},{"u":"/docs/onboarding/group-04-events-outbox.html#raising-and-capturing-where-the-outbox-is-written","d":"4. Domain & Integration Events + Outbox Dual-Dispatch","k":"Onboarding Guide","t":"Raising and capturing: where the outbox is written","x":"Aggregates raise events by calling AddDomainEvent() (see AuditableAggregateRootEntity in G02), which simply buffers them on the entity. Nothing is dispatched yet; the events ride…","i":"DomainEventSaveChangesInterceptor OutboxMessage.FromDomainEvent AuditableAggregateRootEntity TIdentifierType AddDomainEvent OutboxMessages OutboxMessage SavingChanges Architecture DbContext Rubric Data"},{"u":"/docs/onboarding/group-04-events-outbox.html#the-routing-split-local-events-dispatch-in-process-integration-events-wait-for-the-bus","d":"4. Domain & Integration Events + Outbox Dual-Dispatch","k":"Onboarding Guide","t":"The routing split: local events dispatch in-process, integration events wait for the bus","x":"Here is the detail that most people get wrong, and it is the heart of the design. After the transaction commits (SavedChanges), the interceptor does not treat all captured events…","i":"IIntegrationEventHandler IDomainEventDispatcher SafeDomainEventHandler DomainEventDispatcher someIntegrationEvent IDomainEventHandler TIntegrationEvent DbContextFactory OutboxFinalizer OutboxProcessor AddDomainEvent ExecuteUpdate"},{"u":"/docs/onboarding/group-04-events-outbox.html#the-safety-net-how-the-processor-schedules-itself","d":"4. Domain & Integration Events + Outbox Dual-Dispatch","k":"Onboarding Guide","t":"The safety net: how the processor schedules itself","x":"The OutboxProcessor is a BackgroundService and the most intricate type in the group; most of its complexity is about not wasting work. It exists because the steps between commit…","i":"PollingIntervalSeconds ProcessingDelaySeconds BackgroundService OutboxCycleResult ComputeWaitTime OutboxProcessor OutboxSettings ExecuteUpdate IOutboxSignal SemaphoreSlim LeaseSeconds OutboxSignal"},{"u":"/docs/onboarding/group-04-events-outbox.html#failures-dead-letters-and-keeping-the-table-and-telemetry-bounded","d":"4. Domain & Integration Events + Outbox Dual-Dispatch","k":"Onboarding Guide","t":"Failures, dead-letters, and keeping the table (and telemetry) bounded","x":"Delivery failures split into two very different outcomes, worth keeping straight. A transient failure (a handler or broker publish throwing) increments the row's RetryCount,…","i":"OutboxPollFilterProcessor outbox.dead_letter.count DeadLetterRetentionDays RetryBackoffBaseSeconds CleanupIntervalHours OutboxCleanupService MMCA.Common.Outbox Observability OutboxMetrics RetentionDays TimeProvider Operability"},{"u":"/docs/onboarding/group-04-events-outbox.html#the-pluggable-transport-in-process-versus-broker","d":"4. Domain & Integration Events + Outbox Dual-Dispatch","k":"Onboarding Guide","t":"The pluggable transport: in-process versus broker","x":"Here is the boundary that makes a module extractable without rewriting its handlers. Application code that wants to publish an integration event depends on IEventBus (or on the…","i":"InProcessMessageBus AddBrokerMessaging IIntegrationEvent InProcessEventBus BrokerMessageBus OutboxFinalizer OutboxProcessor BrokerEventBus Microservices Application IMessageBus IEventBus"},{"u":"/docs/onboarding/group-04-events-outbox.html#consuming-from-the-broker-the-inbox-and-the-generic-consumer","d":"4. Domain & Integration Events + Outbox Dual-Dispatch","k":"Onboarding Guide","t":"Consuming from the broker: the inbox and the generic consumer","x":"On the receiving side of a broker hop, application code keeps writing plain IIntegrationEventHandler implementations; there is no MassTransit-specific consumer class to author…","i":"IntegrationEventConsumerExtensions RegisterIntegrationEventConsumer IBusRegistrationConfigurator IIntegrationEventHandler IntegrationEventConsumer AlreadyProcessedAsync MarkProcessedAsync DbUpdateException NoOpInboxStore EfInboxStore InboxMessage AddConsumer"},{"u":"/docs/onboarding/group-04-events-outbox.html#putting-it-together-one-events-life","d":"4. Domain & Integration Events + Outbox Dual-Dispatch","k":"Onboarding Guide","t":"Putting it together, one event's life","x":"To see the whole spine at once, follow a single integration event from a producer service to a consumer service in broker mode. (1) A command mutates an aggregate, which raises…","i":"MMCA.Common.Infrastructure.Persistence.Outbox MMCA.Common.Infrastructure.Persistence.Inbox Microsoft.Extensions.Hosting.IHostedService OutboxProcessor.ProcessPendingMessagesAsync UserSessionBookmarkCacheEvictionHandler services.AddOutputCacheEvictionHandler Microsoft.Extensions.Logging.ILogger MMCA.Common.Application.DomainEvents MMCA.Common.Domain.IntegrationEvents ApplicationDbContext.ConfigureInbox domainEventDispatcher.DispatchAsync MMCA.Common.Infrastructure.Services"},{"u":"/docs/onboarding/group-05-cqrs-pipeline.html","d":"5. CQRS: Commands, Queries & the Decorator Pipeline","k":"Onboarding Guide","x":"What this group covers. Every write and every read in an MMCA application is a use case: a small, single-purpose object (a command or a query) handed to a handler that does…","i":"AuthorizationCommandDecorator TransactionalCommandDecorator AuthorizationQueryDecorator FeatureGateCommandDecorator ValidatingCommandDecorator FeatureGateQueryDecorator ProfilingCommandDecorator CachingCommandDecorator LoggingCommandDecorator ProfilingQueryDecorator TimeoutCommandDecorator CachingQueryDecorator"},{"u":"/docs/onboarding/group-05-cqrs-pipeline.html#the-shape-thin-handlers-fat-pipeline","d":"5. CQRS: Commands, Queries & the Decorator Pipeline","k":"Onboarding Guide","t":"The shape: thin handlers, fat pipeline","x":"A handler is deliberately tiny. ICommandHandler (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/ICommandHandler.cs:9) and IQueryHandler…","i":"cancellationToken CancellationToken ICommandHandler IQueryHandler HandleAsync Patterns TCommand default TResult Design Result Rubric"},{"u":"/docs/onboarding/group-05-cqrs-pipeline.html#how-the-pipeline-is-assembled-scrutor-registration-versus-execution-order","d":"5. CQRS: Commands, Queries & the Decorator Pipeline","k":"Onboarding Guide","t":"How the pipeline is assembled (Scrutor, registration versus execution order)","x":"The wiring lives in DependencyInjection.cs (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:21), exposed as extension(IServiceCollection services) members…","i":"DecoratorPipelineOrderTestsBase ScanModuleApplicationServices ProfilingCommandDecorator AddApplicationDecorators DependencyInjectionTests AddApplicationProfiling ProfilingQueryDecorator DependencyInjection.cs EntityQueryPipeline IServiceCollection ServiceCollection ICommandHandler"},{"u":"/docs/onboarding/group-05-cqrs-pipeline.html#why-this-exact-order-and-what-each-layer-guards","d":"5. CQRS: Commands, Queries & the Decorator Pipeline","k":"Onboarding Guide","t":"Why this exact order, and what each layer guards","x":"The nesting order is a deliberate cost-and-correctness argument, spelled out in the registration XML-doc (DependencyInjection.cs:76-98): - Feature-gating is outermost so a…","i":"TransactionCommitAmbiguousException ICacheService.RemoveByPrefixAsync Authorization.PermissionDenied IFeatureManager.IsEnabledAsync AuthorizationCommandDecorator TransactionalCommandDecorator AuthorizationQueryDecorator FeatureGateCommandDecorator OperationCanceledException ValidatingCommandDecorator CqrsMetrics.QueryDuration ExecuteInTransactionAsync"},{"u":"/docs/onboarding/group-05-cqrs-pipeline.html#opt-in-by-marker-interface-pay-only-for-what-you-use","d":"5. CQRS: Commands, Queries & the Decorator Pipeline","k":"Onboarding Guide","t":"Opt-in by marker interface, pay only for what you use","x":"The pipeline is registered for every handler, but most decorators are dormant unless the use case asks for them. The switch is a set of tiny marker / role interfaces in…","i":"MMCA.Common.Application.UseCases GetProductByIdQuery IRequiresPermission GetTicketByIdQuery ICacheInvalidating FeatureManagement GetOrderByIdQuery GetNowNextQuery IQueryCacheable ITransactional CacheDuration IFeatureGated"},{"u":"/docs/onboarding/group-05-cqrs-pipeline.html#tenant-scoping-and-the-two-lock-tables","d":"5. CQRS: Commands, Queries & the Decorator Pipeline","k":"Onboarding Guide","t":"Tenant scoping and the two lock tables","x":"Both caching decorators are multi-tenant aware, and the reason is a nice illustration of where a cross-cutting concern has to live. ICacheService is a singleton and therefore…","i":"ICacheService.GetOrCreateAsync CachingQueryDecorator KeyedSemaphoreStripe QueryCacheKeyLocks ITenantContext TenantCacheKey CacheKeyLocks ICacheService IsResolved tenantId TenantId TResult"},{"u":"/docs/onboarding/group-05-cqrs-pipeline.html#two-supporting-pieces-and-a-worked-example","d":"5. CQRS: Commands, Queries & the Decorator Pipeline","k":"Onboarding Guide","t":"Two supporting pieces, and a worked example","x":"Two small helpers make the short-circuit decorators possible. ResultFailureFactory…","i":"cqrs.authorization.denied.count AuditableAggregateRootEntity TypeInitializationException InvalidOperationException RecordAuthorizationDenied DeleteSessionCommand DeleteSpeakerCommand ResultFailureFactory DeleteEntityCommand DeleteEntityHandler cqrs.timeout.count ICacheInvalidating"},{"u":"/docs/onboarding/group-05-cqrs-pipeline.html#the-other-application-layer-contracts-in-this-group","d":"5. CQRS: Commands, Queries & the Decorator Pipeline","k":"Onboarding Guide","t":"The other Application-layer contracts in this group","x":"Five contracts sit beside the pipeline rather than inside it. They are declared here, in the Application layer, and implemented in Infrastructure or the composition root, which…","i":"AuditTrailSaveChangesInterceptor InProcessDistributedLock IEntityRequestMapper RedisDistributedLock ICommandWithRequest IEntityDTOProjector EntityQueryService ScheduledJobRunner cancellationToken IAuditTrailReader AuditTrailReader IAsyncDisposable"},{"u":"/docs/onboarding/group-05-cqrs-pipeline.html#where-this-fits-and-the-failure-mode-contract","d":"5. CQRS: Commands, Queries & the Decorator Pipeline","k":"Onboarding Guide","t":"Where this fits, and the failure-mode contract","x":"These contracts sit in the Application layer of Clean Architecture (primer §1), above Domain and below Infrastructure and the API. The API layer (G12) resolves a closed handler…","i":"Microsoft.FeatureManagement.IFeatureManager MMCA.Common.Application.UseCases.Decorators Microsoft.Extensions.DependencyInjection CqrsMetrics.RecordAuthorizationDenied QueryCacheKeyLocks.Locks.AcquireAsync MMCA.Common.Application.Extensions MMCA.Common.Application.Interfaces ICacheService.RemoveByPrefixAsync correlationContext.CorrelationId MMCA.Common.Application.UseCases System.Diagnostics.Metrics.Meter ConferenceCategoryCreateRequest"},{"u":"/docs/onboarding/group-06-validation.html","d":"6. Validation","k":"Onboarding Guide","x":"This chapter covers the small, framework-level validation kit that MMCA.Common.Application ships so that every consuming module validates command input the same way: a set of…","i":"AddressInvariants.AddressLine1MaxLength AddressInvariants.AddressLine2MaxLength ValidationFailureExtensions.ToErrors AddValidatorsFromAssemblyContaining AddressInvariants.CountryMaxLength AddressInvariants.ZipCodeMaxLength MMCA.Common.Application.Extensions MMCA.Common.Application.Validation System.Linq.Expressions.Expression AddressInvariants.StateMaxLength AddressInvariants.CityMaxLength MMCA.Common.Shared.ValueObjects"},{"u":"/docs/onboarding/group-07-persistence-ef-core.html","d":"7. Persistence & EF Core","k":"Onboarding Guide","x":"What this group covers. This is the framework's data-access engine: everything between a domain aggregate and a row in a database. It is the single largest group in the guide…","i":"ApplicationDbContext SQLServerDbContext IWriteRepository SaveChangesAsync CosmosDbContext IReadRepository SqliteDbContext TIdentifierType IRepository UnitOfWork TEntity"},{"u":"/docs/onboarding/group-07-persistence-ef-core.html#one-base-context-one-class-per-engine-one-instance-per-database","d":"7. Persistence & EF Core","k":"Onboarding Guide","t":"One base context, one class per engine, one instance per database","x":"ApplicationDbContext (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:39) is an abstract primary-constructor class over EF's…","i":"Database.CreateExecutionStrategy DataSourceModelCacheKeyFactory IX_AuditTrailEntries_Entity IX_OutboxMessages_Processed IX_InboxMessages_MessageId IX_ScheduledJobs_NextRunOn PendingModelChangesWarning IX_OutboxMessages_Pending BeginTransactionAsync ChangeTracker.Entries ApplicationDbContext EnableRetryOnFailure"},{"u":"/docs/onboarding/group-07-persistence-ef-core.html#savechanges-as-an-interceptor-pipeline","d":"7. Persistence & EF Core","k":"Onboarding Guide","t":"SaveChanges as an interceptor pipeline","x":"The base context resolves its interceptors from DI in OnConfiguring (ApplicationDbContext.cs:236-261), and registration order is execution order. The audit interceptor runs…","i":"DomainEventSaveChangesInterceptor AuditSaveChangesInterceptor DiscardAbandonedCapture IDomainEventDispatcher BeginCaptureExclusion ConditionalWeakTable EndCaptureExclusion FlushDeferredAsync GetRequiredService RemoveDomainEvents CurrentSaveUserId IIntegrationEvent"},{"u":"/docs/onboarding/group-07-persistence-ef-core.html#the-tenant-boundary-read-filter-plus-write-guard","d":"7. Persistence & EF Core","k":"Onboarding Guide","t":"The tenant boundary, read filter plus write guard","x":"Multi-tenancy (ADR-073) is two independent halves that meet in this group. The read half is the named Tenant query filter the base context applies to every non-owned…","i":"TenantSaveChangesInterceptor CrossTenantWriteException InvalidOperationException TenantDataSourceTargets TenantDataSourceTarget ApplicationDbContext IgnoreQueryFilters CurrentTenantId ITenantEntity TenantContext e.TenantId SoftDelete"},{"u":"/docs/onboarding/group-07-persistence-ef-core.html#recording-what-changed-the-audit-trail","d":"7. Persistence & EF Core","k":"Onboarding Guide","t":"Recording what changed, the audit trail","x":"AuditTrailSaveChangesInterceptor (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailSaveChangesInterceptor.cs:62) is the fourth interceptor and…","i":"AuditTrailSaveChangesInterceptor AuditTrailCleanupJob AuditTrailReader AuditTrailEntry IAuditedEntity AddAuditTrail ExecuteDelete RedactedToken RetentionDays PiiAttribute PropertyName PiiRedactor"},{"u":"/docs/onboarding/group-07-persistence-ef-core.html#repositories-and-the-unit-of-work","d":"7. Persistence & EF Core","k":"Onboarding Guide","t":"Repositories and the unit of work","x":"Handlers do not touch a DbContext directly. They ask a UnitOfWork (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/UnitOfWork.cs:13) for a repository. The…","i":"TransactionCommitAmbiguousException DefaultSqlServerDbContextFactory ApplicationDbContextEFFactory DefaultCosmosDbContextFactory DefaultSqliteDbContextFactory UpdatePropertySetterBuilder EFReadRepositoryDecorator ExecuteInTransactionAsync HasPendingMigrationsAsync IPhysicalDbContextFactory ChangeTracker.HasChanges PhysicalDbContextFactory"},{"u":"/docs/onboarding/group-07-persistence-ef-core.html#routing-an-entity-to-its-database","d":"7. Persistence & EF Core","k":"Onboarding Guide","t":"Routing an entity to its database","x":"The heart of ADR-006 is that every entity resolves to a DataSourceKey (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/DataSourceKey.cs:15), a (Engine,…","i":"IEntityDataSourceRegistry EntityDataSourceRegistry UseDataSourceAttribute NamespaceConventions UseDatabaseAttribute IDataSourceResolver DataSourceResolver DataSourceService DataSourceKey GetModuleName DataSources DataSource"},{"u":"/docs/onboarding/group-07-persistence-ef-core.html#two-model-finalizing-conventions","d":"7. Persistence & EF Core","k":"Onboarding Guide","t":"Two model-finalizing conventions","x":"The base context adds both of its conventions in ConfigureConventions (ApplicationDbContext.cs:282-297), and each exists because a cross-cutting policy above would otherwise…","i":"CrossDataSourceDegradeConvention SoftDeleteUniqueIndexConvention IndexBuilderExtensions ConfigureConventions INavigationPopulator HasSoftDeleteFilter SoftDeleteFilterSql IndexBuilder extension IsDeleted TEntity Build"},{"u":"/docs/onboarding/group-07-persistence-ef-core.html#entity-configuration-and-engine-portability","d":"7. Persistence & EF Core","k":"Onboarding Guide","t":"Entity configuration and engine portability","x":"Concrete entity configurations derive from the engine-aware EntityTypeConfiguration…","i":"DefaultEntityConfigurationAssemblyProvider IEntityConfigurationAssemblyProvider IEntityTypeConfigurationSQLServer NullableEnumerationValueConverter NullablePhoneNumberValueConverter EntityTypeConfigurationSQLServer IEntityTypeConfigurationCosmos IEntityTypeConfigurationSqlite EntityTypeConfigurationCosmos EntityTypeConfigurationSqlite PushNotificationConfiguration UserNotificationConfiguration"},{"u":"/docs/onboarding/group-07-persistence-ef-core.html#encryption-seeding-design-time-and-the-shared-helpers","d":"7. Persistence & EF Core","k":"Onboarding Guide","t":"Encryption, seeding, design time, and the shared helpers","x":"A handful of supporting pieces round out the EF side. EncryptedStringConverter…","i":"PaymentReconciliationService IDesignTimeDbContextFactory DesignTimeDbContextOptions IdentityModuleDbSeederBase DesignTimeDbContextHelper NullDomainEventDispatcher PeriodicBackgroundService EncryptedStringConverter EntityDataSourceRegistry ExplicitAssemblyProvider EFQueryableExecutor DataSourceResolver"},{"u":"/docs/onboarding/group-07-persistence-ef-core.html#blobs-images-and-native-push","d":"7. Persistence & EF Core","k":"Onboarding Guide","t":"Blobs, images, and native push","x":"The group also carries the storage-adjacent infrastructure services that are not EF at all, each behind an Application-layer interface with a null default so a host that has not…","i":"AzureNotificationHubNativePushSender AzureNotificationHubDeviceRegistrar AzureBlobFileStorageService ImageSharpImageProcessor NullPushDeviceRegistrar NullFileStorageService IPushDeviceRegistrar NullNativePushSender IFileStorageService ImageContentSniffer NativePushPayloads INativePushSender"},{"u":"/docs/onboarding/group-07-persistence-ef-core.html#where-this-group-sits","d":"7. Persistence & EF Core","k":"Onboarding Guide","t":"Where this group sits","x":"Persistence is the concrete floor the abstract domain stands on. The entity bases and audit contracts from Group 02 are what the interceptors stamp and the query filters hide;…","i":"MMCA.Common.Application.Interfaces.Infrastructure MMCA.Common.Infrastructure.Persistence.AuditTrail MMCA.Common.Infrastructure.Persistence.DbContexts MMCA.Common.Infrastructure.Persistence.Encryption DomainEventSaveChangesInterceptor.DropDeferred EntityTypeConfiguration.ApplyEngineConventions Microsoft.Extensions.Hosting.BackgroundService AddInfrastructure_RegistersIRepositoryFactory CrossTenantWriteException.ForUnresolvedTenant ModelBuilderExtensions.ApplyAllConfigurations DangerousAcceptAnyServerCertificateValidator RelationalEventId.PendingModelChangesWarning"},{"u":"/docs/onboarding/group-08-auth.html","d":"8. Authentication & Authorization","k":"Onboarding Guide","x":"What this group covers. This is the security spine of the framework: how a caller proves who they are (authentication), how the system decides what they may do (authorization),…","i":"SessionCookieAuthenticationHandler PermissionAuthorizationHandler AuthenticationServiceBase AuthenticationValidators ClaimBasedUserIdProvider AuthorizationExtensions ILoginProtectionService IPasswordChangeableUser LoginProtectionSettings CookieSessionRefresher IAuthenticationService LoginProtectionService"},{"u":"/docs/onboarding/group-08-auth.html#tokens-one-signing-switch-two-validation-worlds","d":"8. Authentication & Authorization","k":"Onboarding Guide","t":"Tokens: one signing switch, two validation worlds","x":"The framework mints two credentials on every successful login: a short-lived access token (a JWT, 15 minutes by default,…","i":"OidcDiscoveryEndpointExtensions OpenIdConnectMetadataWarmupTask GetPrincipalFromExpiredToken ExecutionAndPublication JwksEndpointExtensions RandomNumberGenerator JwtSigningAlgorithm IValidatableObject additionalClaims SigningAlgorithm PublicationOnly RsaJwksProvider"},{"u":"/docs/onboarding/group-08-auth.html#the-shared-authentication-workflow","d":"8. Authentication & Authorization","k":"Onboarding Guide","t":"The shared authentication workflow","x":"Login, registration, refresh, and revocation are not re-implemented per app. They live once in AuthenticationServiceBase…","i":"RefreshTokenRequestValidator AuthenticationServiceBase FindUntrackedByEmailAsync AuthenticationValidators OAuthCodeExchangeRequest AuthenticationResponse CancellationToken.None IAuthenticationService AuthenticationRequest AuthenticationService ChangePasswordRequest LoginRequestValidator"},{"u":"/docs/onboarding/group-08-auth.html#what-the-apps-user-aggregate-must-expose","d":"8. Authentication & Authorization","k":"Onboarding Guide","t":"What the app's User aggregate must expose","x":"The shared workflows never see an app's User class. They see four small Domain-layer contracts, each sized to one workflow, which is the [Rubric §1, SOLID] interface-segregation…","i":"GetUserPreferencesHandlerBase ChangePreferencesHandlerBase ChangePasswordHandlerBase ChangePreferencesRequest IPasswordChangeableUser UserPreferencesResponse DeleteUserHandlerBase AuditableBaseEntity RevokeRefreshToken UpdateRefreshToken UpdatePreferences IUserPreferences"},{"u":"/docs/onboarding/group-08-auth.html#passwords-and-brute-force-protection","d":"8. Authentication & Authorization","k":"Onboarding Guide","t":"Passwords and brute-force protection","x":"Password material is handled by PasswordHasher (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/PasswordHasher.cs:12), which hashes with PBKDF2-HMAC-SHA512 at 600,000…","i":"CryptographicOperations.FixedTimeEquals ILoginProtectionService LoginProtectionSettings LoginProtectionService IDistributedCache MaxFailedAttempts MaxLockoutSeconds IPasswordHasher PasswordHasher ICacheService Email Range"},{"u":"/docs/onboarding/group-08-auth.html#reading-identity-from-claims","d":"8. Authentication & Authorization","k":"Onboarding Guide","t":"Reading identity from claims","x":"Once a request is authenticated, downstream code needs the caller's identity without re-parsing the JWT. CurrentUserService…","i":"CultureInfo.InvariantCulture ClaimBasedUserIdProvider IHttpContextAccessor ICurrentUserService CurrentUserService ClaimsPrincipal IUserIdProvider AuthClaimTypes GetClaimValue Clients.User TokenService IsInRole"},{"u":"/docs/onboarding/group-08-auth.html#authorization-roles-permissions-ownership","d":"8. Authentication & Authorization","k":"Onboarding Guide","t":"Authorization: roles, permissions, ownership","x":"The framework supports three overlapping authorization styles, wired together by the single AddAuthorizationPolicies() extension in AuthorizationExtensions…","i":"PermissionAuthorizationHandler AllowMissingOwnerAttribute OwnerOrAdminFilterOptions PermissionRegistryBuilder AddAuthorizationPolicies PermissionPolicyProvider AuthorizationExtensions HasPermissionAttribute AuthorizationPolicies PermissionRequirement RequireAuthenticated IPermissionRegistry"},{"u":"/docs/onboarding/group-08-auth.html#session-cookies-keeping-ssr-authenticated","d":"8. Authentication & Authorization","k":"Onboarding Guide","t":"Session cookies: keeping SSR authenticated","x":"The final cluster solves a Blazor-specific problem: an interactive Blazor app keeps its access token in browser memory, but a cold server-side render (a new tab, an F5, an…","i":"CookieSessionRefreshMiddlewareExtensions SessionCookieAuthenticationExtensions SessionCookieAuthenticationHandler CookieSessionRefreshMiddleware ICookieSessionRefresher CookieSessionRefresher SessionCookieEndpoints KeyedSemaphoreStripe SessionCookieRequest SessionTokenResponse SessionTokenResult CookieTokenReader"},{"u":"/docs/onboarding/group-08-auth.html#privacy-the-data-subject-export-package","d":"8. Authentication & Authorization","k":"Onboarding Guide","t":"Privacy: the data-subject export package","x":"Three members of this group belong to the privacy surface that sits beside erasure. UserDataExportDTO (MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:15)…","i":"ExportUserDataHandlerBase DataExportControllerBase UserDataExportSectionDTO IUserDataExportSection Privacy.DataExport UserDataExportDTO PrivacyFeatures FormatVersion FeatureGate Authorize Available Subject"},{"u":"/docs/onboarding/group-08-auth.html#shared-primitives-and-adjacent-members","d":"8. Authentication & Authorization","k":"Onboarding Guide","t":"Shared primitives and adjacent members","x":"Four group members are general-purpose primitives that landed in this chapter because of how the dependency grouping fell, though one of them is now load-bearing for auth.…","i":"MMCA.Common.Application.Interfaces.Infrastructure AuthorizationExtensions.AddAuthorizationPolicies context.ActionDescriptor.EndpointMetadata.OfType Microsoft.AspNetCore.Http.IHttpContextAccessor JsonWebKeyConverter.ConvertFromRSASecurityKey SessionCookieAuthenticationHandler.SchemeName Microsoft.AspNetCore.SignalR.IUserIdProvider Microsoft.IdentityModel.Tokens.JsonWebKeySet services.AddValidatorsFromAssemblyContaining ArgumentException.ThrowIfNullOrWhiteSpace CookieTokenReader.FreshAccessTokenItemKey ICookieSessionRefresher.GetOrRefreshAsync"},{"u":"/docs/onboarding/group-09-caching.html","d":"9. Caching","k":"Onboarding Guide","x":"What this group covers. Caching in this codebase is small, deliberate, and woven into the CQRS pipeline rather than scattered across handlers. The group is eight types: one port…","i":"Microsoft.Extensions.Caching.Hybrid.HybridCache HybridCacheEntryFlags.DisableUnderlyingData StackExchange.Redis.IConnectionMultiplexer Microsoft.Extensions.Options.IOptions MMCA.Common.Application.Interfaces MMCA.Common.Infrastructure.Caching DistributedCacheServiceRedisTests connectionMultiplexer.GetServers AbsoluteExpirationRelativeToNow PublicEndpointOutputCachePolicy System.Text.Json.JsonSerializer LogPrefixEvictionNoMultiplexer"},{"u":"/docs/onboarding/group-10-notifications.html","d":"10. Notifications (Push + In-App Inbox + Email)","k":"Onboarding Guide","x":"What this group covers. This is the notification subsystem, the machinery that turns \"an organizer wants to tell every attendee something\" into messages that actually reach…","i":"INotificationRecipientProvider NullPushNotificationSender NullLiveChannelPublisher IPushNotificationSender ILiveChannelPublisher NotificationModule DevicesController INativePushSender UserNotification NotificationHub SmtpEmailSender IEmailSender"},{"u":"/docs/onboarding/group-10-notifications.html#the-layering-and-why-the-pieces-sit-where-they-do","d":"10. Notifications (Push + In-App Inbox + Email)","k":"Onboarding Guide","t":"The layering, and why the pieces sit where they do","x":"The dependency flow of the group mirrors the framework's Clean Architecture story ([Rubric §3, Clean Architecture]). The Domain layer holds the two aggregates, PushNotification…","i":"NullNotificationRecipientProvider Notification.PushNotifications SignalRPushNotificationSender SendPushNotificationRequest SignalRLiveChannelPublisher NullPushNotificationSender PushNotificationInvariants DeviceInstallationRequest NullLiveChannelPublisher NotificationsController PushNotificationCreated PushNotificationStatus"},{"u":"/docs/onboarding/group-10-notifications.html#the-broadcast-send-flow-end-to-end","d":"10. Notifications (Push + In-App Inbox + Email)","k":"Onboarding Guide","t":"The broadcast send flow, end to end","x":"Sending a notification is a command-side vertical slice ([Rubric §5, Vertical Slice], [Rubric §6, CQRS & Event-Driven]). An organizer POSTs to NotificationsController, which is…","i":"NotificationFeatures.PushNotifications AttendeeNotificationRecipientProvider AddNotificationApplicationServices NullNotificationRecipientProvider INotificationRecipientProvider PushNotification.NoRecipients unitOfWork.GetReadRepository SendPushNotificationCommand SendPushNotificationHandler PushNotificationDTOMapper IPushNotificationSender NotificationsController"},{"u":"/docs/onboarding/group-10-notifications.html#the-inbox-side","d":"10. Notifications (Push + In-App Inbox + Email)","k":"Onboarding Guide","t":"The inbox side","x":"Reading and acknowledging notifications is the query/command counterpart, served by InboxController under the same feature gate and [Authorize(RequireAuthenticated)], so any user…","i":"GetUnreadNotificationCountQuery MarkAllNotificationsReadCommand MarkNotificationReadCommand MarkNotificationReadHandler ICurrentUserService.UserId GetMyNotificationsHandler UserNotification.NotFound GetMyNotificationsQuery RequireAuthenticated PushNotification UserNotification InboxController"},{"u":"/docs/onboarding/group-10-notifications.html#the-signalr-transport-and-how-it-survives-extraction","d":"10. Notifications (Push + In-App Inbox + Email)","k":"Onboarding Guide","t":"The SignalR transport, and how it survives extraction","x":"NotificationHub is intentionally thin (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Hubs/NotificationHub.cs:16-17): it is [Authorize]d, and beyond ASP.NET's built-in…","i":"LiveChannelPublisherGrpcAdapter services__notification__grpc__0 SignalRPushNotificationSender SignalRLiveChannelPublisher NullLiveChannelPublisher LiveChannelGrpcService ILiveChannelPublisher AddPushNotifications RequireAuthorization _grpc.notification MapNotificationHub NotificationHub"},{"u":"/docs/onboarding/group-10-notifications.html#the-module-host-native-device-registration-and-the-privacy-export","d":"10. Notifications (Push + In-App Inbox + Email)","k":"Onboarding Guide","t":"The module host, native-device registration, and the privacy export","x":"On the ADC side the whole capability is packaged by NotificationModule (MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.API/NotificationModule.cs:15), an IModule that…","i":"UserNotificationExportServiceGrpcAdapter DisabledUserNotificationExportService UserNotificationExportGrpcService IUserNotificationExportService UserNotificationExportItemDTO UserNotificationExportService currentUserService.UserId DeviceInstallationRequest AddNotificationModule IPushDeviceRegistrar RequiresDependencies DependencyInjection"},{"u":"/docs/onboarding/group-10-notifications.html#where-this-group-sits","d":"10. Notifications (Push + In-App Inbox + Email)","k":"Onboarding Guide","t":"Where this group sits","x":"Upstream, this group depends on the domain building blocks of Group 02 (both aggregates derive from AuditableAggregateRootEntity ), the Result pattern of Group 01, the CQRS…","i":"LiveChannelPushService.LiveChannelPushServiceBase MMCA.Common.Application.Interfaces.Infrastructure MMCA.ADC.Notification.Shared.UserNotifications attendeeQueryService.GetAttendeeUserIdsAsync services.AddNotificationApplicationServices PushNotificationDTOProjection.ProjectToDTO PushNotificationProjectionTranslationTests MMCA.Common.API.Controllers.Notifications NotificationHub.ReceiveNotificationMethod PushNotificationInvariants.TitleMaxLength Microsoft.Extensions.DependencyInjection PushNotificationInvariants.BodyMaxLength"},{"u":"/docs/onboarding/group-11-navigation-populators.html","d":"11. Navigation Metadata & Populators (EF-decoupled eager loading)","k":"Onboarding Guide","x":"EF Core gives you .Include() for eager loading, and for a single SQL Server database that is the right tool. But this codebase is a database-per-service modular monolith…","i":"navigationMetadata.UnsupportedIncludes.Count MMCA.Common.Application.Services.Navigation NavigationLoader.LoadChildrenPropertyAsync NavigationMetadataProvider.BuildIncludes NavigationMetadata.UnsupportedIncludes IDataSourceService.HaveIncludeSupport NavigationLoader.LoadFKPropertyAsync INavigationPopulator.PopulateAsync MMCA.Common.Application.Interfaces DeclarativeNavigationPopulator.cs CrossDataSourceDegradeConvention EntityQueryPipeline.ExecuteAsync"},{"u":"/docs/onboarding/group-12-api-hosting-mapping.html","d":"12. API Hosting, Middleware, Idempotency & DTO/Contract Mapping","k":"Onboarding Guide","x":"What this group covers. This is the ASP.NET Core edge of the framework: the layer that turns an HTTP request into a domain call and turns a Result back into an HTTP response.…","i":"Microsoft.Extensions.Localization.LocalizedString Microsoft.AspNetCore.Http.IProblemDetailsService Microsoft.EntityFrameworkCore.DbUpdateException Microsoft.AspNetCore.Http.IHttpContextAccessor Microsoft.IdentityModel.Tokens.JsonWebKeySet System.Threading.RateLimiting.RateLimitLease IDbContextFactory.HasPendingMigrationsAsync AuthorizationPolicies.RequireAuthenticated Microsoft.AspNetCore.Authentication.Google StackExchange.Redis.IConnectionMultiplexer ArgumentException.ThrowIfNullOrWhiteSpace System.Threading.RateLimiting.RateLimiter"},{"u":"/docs/onboarding/group-13-grpc-contracts.html","d":"13. gRPC & Inter-Service Contracts","k":"Onboarding Guide","x":"What this chapter is about. Once the ADC modules stopped sharing a process and became four separate service hosts (Identity, Conference, Engagement, Notification), the in-process…","i":"Microsoft.AspNetCore.Http.IHttpContextAccessor ArgumentException.ThrowIfNullOrWhiteSpace Microsoft.Extensions.DependencyInjection ErrorHttpMapping.ErrorTypeToStatusCode AddConferenceSessionValidationClient Microsoft.Extensions.Http.Resilience Microsoft.Extensions.Logging.ILogger ResultGrpcExtensions.ThrowIfFailure ResultGrpcExtensions.ToRpcException Grpc.Core.Interceptors.Interceptor ArgumentNullException.ThrowIfNull ISessionBookmarkValidationService"},{"u":"/docs/onboarding/group-14-module-system-composition.html","d":"14. Module System, Composition & Configuration","k":"Onboarding Guide","x":"What this chapter covers. This is the wiring layer, the code that turns a pile of layered assemblies into a running host. It answers three questions a new host author asks: how…","i":"ConnectionStringSettings InProcessDistributedLock PushNotificationSettings UseDataSourceAttribute RedisDistributedLock UseDatabaseAttribute ApplicationSettings DataSourcesSettings DependencyInjection FileStorageSettings PersistenceSettings AuditTrailSettings"},{"u":"/docs/onboarding/group-14-module-system-composition.html#the-module-contract-and-the-boundary-it-creates","d":"14. Module System, Composition & Configuration","k":"Onboarding Guide","t":"The module contract and the boundary it creates","x":"A module is the unit of cohesion above a feature slice: Conference, Engagement, Identity, Notification. Each one implements IModule…","i":"DisabledSessionBookmarkValidationService DisabledEventLiveValidationService GetSessionBookmarkCountHandler IBookmarkCountService RegisterDisabledStubs RequiresDependencies AddConferenceModule applicationSettings ConferenceModule moduleEnabled Dependencies Register"},{"u":"/docs/onboarding/group-14-module-system-composition.html#discovery-and-kahn-ordered-registration","d":"14. Module System, Composition & Configuration","k":"Onboarding Guide","t":"Discovery and Kahn-ordered registration","x":"ModuleLoader (MMCA.Common/Source/Core/MMCA.Common.Application/Modules/ModuleLoader.cs:15) is the engine. Its DiscoverAndRegister comes in two overloads: the short one…","i":"AppDomain.CurrentDomain.GetAssemblies ModulesSettings.IsModuleEnabled ValidateModuleDependencies ValidateRemoteDependencies Activator.CreateInstance IModuleSeeder.SeedAsync RegisterDisabledStubs RegisterEnabledModule RequiresDependencies DisabledModuleNames DiscoverAndRegister RemoteDependencies"},{"u":"/docs/onboarding/group-14-module-system-composition.html#the-two-composition-roots-and-the-ordering-they-enforce","d":"14. Module System, Composition & Configuration","k":"Onboarding Guide","t":"The two composition roots and the ordering they enforce","x":"Service registration itself lives in two static DependencyInjection classes, each using a C extension(IServiceCollection services) block (see primer §4 for the extension(T)…","i":"ScanModuleApplicationServices FeatureGateCommandDecorator INavigationMetadataProvider AddNativePushNotifications AddApplicationDecorators InProcessDistributedLock AddApplicationProfiling AddAzureBlobFileStorage CommandRequestValidator LoggingCommandDecorator IConnectionMultiplexer IDomainEventDispatcher"},{"u":"/docs/onboarding/group-14-module-system-composition.html#opt-in-platform-features-are-composed-the-same-way","d":"14. Module System, Composition & Configuration","k":"Onboarding Guide","t":"Opt-in platform features are composed the same way","x":"Four newer capabilities are registered beside the roots rather than inside them, and they share one discipline: registering a feature is not the same as turning it on.…","i":"AuditTrailSaveChangesInterceptor TenantSaveChangesInterceptor AddUserDataExportSection TenancySettingsValidator IUserDataExportSection MMCA.Common.Scheduler AuditTrailEntryDTO AuditTrailSettings ScheduledJobRunner AddInfrastructure BackgroundService ScheduledJobEntry"},{"u":"/docs/onboarding/group-14-module-system-composition.html#assembly-anchors","d":"14. Module System, Composition & Configuration","k":"Onboarding Guide","t":"Assembly anchors","x":"Several pieces of machinery need a Type whose Assembly identifies a layer: Scrutor's FromAssemblyOf () scans, FluentValidation's AddValidatorsFromAssemblyContaining (), and…","i":"AddValidatorsFromAssemblyContaining AddInfrastructure AssemblyReference AddApplication ClassReference FromAssemblyOf AssemblyName Assembly static class Type"},{"u":"/docs/onboarding/group-14-module-system-composition.html#configuration-binding-the-settings-family","d":"14. Module System, Composition & Configuration","k":"Onboarding Guide","t":"Configuration binding, the Settings family","x":"Everything a host operator tunes arrives as a strongly-typed settings object bound from an appsettings.json section, each carrying a static readonly string SectionName so the…","i":"TenantDataSourceOverrideSettings EffectiveExcludedPathPrefixes ScheduledJobOverrideSettings IValidatableObject.Validate IConnectionStringSettings IPushNotificationSettings SQLServerConnectionString ConnectionStringSettings EffectiveResolutionOrder PushNotificationSettings TenancySettingsValidator TenantResolutionStrategy"},{"u":"/docs/onboarding/group-14-module-system-composition.html#the-two-routing-attributes","d":"14. Module System, Composition & Configuration","k":"Onboarding Guide","t":"The two routing attributes","x":"Two attributes, both in MMCA.Common.Infrastructure, both Inherited = true so they ride down a configuration class hierarchy, encode where an entity is stored declaratively: the…","i":"MMCA.Common.Infrastructure EntityDataSourceRegistry UseDataSourceAttribute UseDatabaseAttribute DataSourceResolver DbContextFactory DataSource Inherited Domain true"},{"u":"/docs/onboarding/group-14-module-system-composition.html#shared-user-use-case-bases-composition-in-the-other-direction","d":"14. Module System, Composition & Configuration","k":"Onboarding Guide","t":"Shared user use-case bases: composition in the other direction","x":"The chapter's last family is composition at the handler level rather than the container level. ADC and Store each own an Identity module, and five of their account use cases had…","i":"UserOwnershipRule.CheckOwnership GetUserPreferencesHandlerBase UserDataExportSectionDefaults ChangePreferencesHandlerBase UserDataExportSectionResult ChangePasswordHandlerBase ExportUserDataHandlerBase ISoftDeletedUserValidator SoftDeletedUserValidator GetUserPreferencesQuery IUserDataExportSection DeleteUserHandlerBase"},{"u":"/docs/onboarding/group-14-module-system-composition.html#end-to-end-one-hosts-boot","d":"14. Module System, Composition & Configuration","k":"Onboarding Guide","t":"End-to-end: one host's boot","x":"Reading MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs top to bottom shows the whole chapter cooperating. The host binds and validates ApplicationSettings and…","i":"MMCA.Common.Application.Interfaces.Infrastructure MMCA.Common.Application.Users.UseCases.DeleteUser UserDataExportSectionDefaults.UnavailableReason TenancySettingsValidator.ConnectionStringFor DefaultEntityConfigurationAssemblyProvider ArgumentException.ThrowIfNullOrWhiteSpace InProcessDistributedLock.TryAcquireAsync Microsoft.Extensions.DependencyInjection ScheduledJobRunner.ResolveCronExpression UserDataExportSectionResult.Unavailable UserUseCaseLog.ExportSectionUnavailable DbContextFactory.ResolveTenantOverride"},{"u":"/docs/onboarding/group-15-common-ui-framework.html","d":"15. Common UI Framework (MudBlazor components, theme, base pages)","k":"Onboarding Guide","x":"What this chapter covers. MMCA.Common.UI is the Blazor presentation package, and it is one of the two layers (with Grpc) allowed to reference Shared only (see primer §1). It…","i":"Microsoft.AspNetCore.Components.NavigationManager Microsoft.Extensions.Configuration.IConfiguration Microsoft.AspNetCore.Builder.IApplicationBuilder Microsoft.AspNetCore.Components.DynamicComponent MauiBackNavigationBridge.HandleBackPressedAsync Microsoft.AspNetCore.WebUtilities.QueryHelpers WasmTokenStorageService.GetAccessTokenAsync HttpResilienceDefaults.TotalRequestTimeout ArgumentException.ThrowIfNullOrWhiteSpace CultureInfo.DefaultThreadCurrentUICulture ITokenStorageService.GetAccessTokenAsync Microsoft.Extensions.DependencyInjection"},{"u":"/docs/onboarding/group-16-aspire-orchestration.html","d":"16. Aspire Orchestration & Service Defaults","k":"Onboarding Guide","x":"This chapter covers the hosting boundary: how the distributed MMCA system is composed and run, locally as a single dotnet run, and in Azure Container Apps (ACA) as a set of…","i":"MMCA.Common.Aspire.Gateway MMCA.Common.Aspire.Hosting AddServiceDefaults MMCA.Common.Aspire MMCA.Common.Shared MMCA.ADC.AppHost Aspire.Hosting dotnet run"},{"u":"/docs/onboarding/group-16-aspire-orchestration.html#the-orchestrator-declaring-the-resource-graph","d":"16. Aspire Orchestration & Service Defaults","k":"Onboarding Guide","t":"The orchestrator: declaring the resource graph","x":"When you run dotnet run --project Source/Hosting/MMCA.ADC.AppHost, Aspire executes Program.cs top to bottom, building a resource model rather than starting anything immediately.…","i":"LoginProtection__MaxRegistrationsPerIpPerHour ConnectionStrings__SQLServerConnectionString Authentication__JwtBearer__Authority WithE2eRegistrationThrottleLift E2E_LIFT_REGISTRATION_THROTTLE E2eRegistrationsPerIpPerHour __SQLServerConnectionString MMCA.Common.Aspire.Hosting DefaultBrokerResourceName E2E_JWT_PRIVATE_KEY_PEM WithSQLServerDataSource E2E_JWT_PUBLIC_KEY_PEM"},{"u":"/docs/onboarding/group-16-aspire-orchestration.html#startup-ordering-and-the-grpc-deadlock-avoidance-trick","d":"16. Aspire Orchestration & Service Defaults","k":"Onboarding Guide","t":"Startup ordering and the gRPC deadlock-avoidance trick","x":"Aspire's WaitFor builds a startup dependency graph from the resource model: a service does not start until the resources it waits on report healthy (/health/ready for projects,…","i":"ISessionBookmarkValidationService IBookmarkCountService AddTypedGrpcClient WaitFor"},{"u":"/docs/onboarding/group-16-aspire-orchestration.html#the-service-baseline-addservicedefaults","d":"16. Aspire Orchestration & Service Defaults","k":"Onboarding Guide","t":"The service baseline: AddServiceDefaults()","x":"Every running host calls one method first in Program.cs: AddServiceDefaults() from the single framework-grade Extensions in MMCA.Common.Aspire (Level 2,…","i":"EnableMultipleHttp2Connections ConfigureHttpClientDefaults AddDefaultHealthChecks ConfigureOpenTelemetry AddServiceDiscovery AddServiceDefaults AddWarmupReadiness MMCA.Common.Aspire SocketsHttpHandler HttpClient Program.cs TBuilder"},{"u":"/docs/onboarding/group-16-aspire-orchestration.html#one-source-of-truth-for-resilience-numbers","d":"16. Aspire Orchestration & Service Defaults","k":"Onboarding Guide","t":"One source of truth for resilience numbers","x":"The numbers that pipeline uses are not literals in the Aspire package. They live in HttpResilienceDefaults (Level 0,…","i":"MMCA.Common.Infrastructure MMCA.Common.Grpc Continuity properties Resilience RetryCount including Business Concerns lifetime sampling attempt"},{"u":"/docs/onboarding/group-16-aspire-orchestration.html#listeners-and-probes-one-kestrel-profile-per-host-shape","d":"16. Aspire Orchestration & Service Defaults","k":"Onboarding Guide","t":"Listeners and probes: one Kestrel profile per host shape","x":"Before a service can answer a health probe it has to be listening on a protocol the prober speaks, and that is not free when the same port serves inbound gRPC.…","i":"redeclareCleartextEndpoint ASPNETCORE_HTTP_PORTS HttpProtocols.Http2 MapDefaultEndpoints BuildListenerPlan HTTP_1_1_REQUIRED Http1AndHttp2 Deployment Protocols deployed profiles httpGet"},{"u":"/docs/onboarding/group-16-aspire-orchestration.html#health-checks-liveness-readiness-and-the-optional-tag","d":"16. Aspire Orchestration & Service Defaults","k":"Onboarding Guide","t":"Health checks: liveness, readiness, and the \"optional\" tag","x":"MapDefaultEndpoints() (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:336) exposes the three-probe surface the platform reads: /health (every check, for humans and…","i":"AddInfrastructureHealthChecks AddDefaultHealthChecks MapDefaultEndpoints requireSqlServer Observability Operability Deployment optional Optional DevOps Rubric Ready"},{"u":"/docs/onboarding/group-16-aspire-orchestration.html#telemetry-what-gets-exported-and-what-it-costs","d":"16. Aspire Orchestration & Service Defaults","k":"Onboarding Guide","t":"Telemetry: what gets exported, and what it costs","x":"ConfigureOpenTelemetry() (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:121) wires logging with formatted messages and scopes (:123-127), metrics, and tracing. It…","i":"APPLICATIONINSIGHTS_CONNECTION_STRING ActivityTraceFlags.Recorded OTEL_EXPORTER_OTLP_ENDPOINT AddOpenTelemetryExporters IsInstrumentationDisabled TraceIdRatioBasedSampler MMCA.Common.Idempotency MMCA.Common.OutputCache ConfigureOpenTelemetry MMCA.Common.BestEffort TryGetTraceSampleRatio MMCA.Common.Scheduler"},{"u":"/docs/onboarding/group-16-aspire-orchestration.html#warm-up-defeating-aca-cold-start","d":"16. Aspire Orchestration & Service Defaults","k":"Onboarding Guide","t":"Warm-up: defeating ACA cold-start","x":"The warm-up subsystem exists for one concrete failure mode: the \"first request fails, second succeeds\" pattern on a CPU-throttled idle ACA replica, where lazy initialization…","i":"RequireSuccessStatusCode HealthCheckTags.Ready WebApplicationFactory Interlocked.Exchange RequestVersionPolicy AddServiceDefaults AddWarmupReadiness ApplicationStarted IHttpClientFactory BackgroundService ResolveWarmupPort WithJwksDiscovery"},{"u":"/docs/onboarding/group-16-aspire-orchestration.html#configuration-secrets-the-vault-as-one-more-configuration-source","d":"16. Aspire Orchestration & Service Defaults","k":"Onboarding Guide","t":"Configuration secrets: the vault as one more configuration source","x":"Connection strings, RSA signing keys and OAuth client secrets have to reach the process somehow, and KeyVaultConfigurationExtensions (Level 0,…","i":"AddCommonDataProtection DefaultAzureCredential builder.Configuration ConfigurationManager AddServiceDefaults IConfiguration Deployment Security answer DevOps Rubric the"},{"u":"/docs/onboarding/group-16-aspire-orchestration.html#security-headers-cors-and-the-shared-key-ring-at-the-host-edge","d":"16. Aspire Orchestration & Service Defaults","k":"Onboarding Guide","t":"Security headers, CORS, and the shared key ring at the host edge","x":"The next boundary in this group hardens the request and response edge. The shared response-header middleware is defined entirely in…","i":"AddCommonSecurityHeaders UseCommonSecurityHeaders AddCommonDataProtection DefaultAzureCredential AddCommonGatewayCors AddCommonBlazorCsp PermissionsPolicy MMCA.Common.API TryAddSingleton ReferrerPolicy AddCommonCors FrameOptions"},{"u":"/docs/onboarding/group-16-aspire-orchestration.html#the-gateway-edge-kit-correlation-rate-limiting-downstream-readiness","d":"16. Aspire Orchestration & Service Defaults","k":"Onboarding Guide","t":"The gateway edge kit: correlation, rate limiting, downstream readiness","x":"A YARP gateway is the one process every client request passes through, and it is also the one host that has no application container: no DbContext, no module loader, no…","i":"AddServiceDiscoveryDestinationResolver AddGatewayDownstreamHealthChecks Connection.RemoteIpAddress MMCA.Common.Aspire.Gateway Validator.ValidateObject HttpResponse.OnStarting ValidateDataAnnotations AddGatewayRateLimiting GlobalConcurrencyLimit UseGatewayRateLimiting ConfigureClusterAsync RequestVersionOrLower"},{"u":"/docs/onboarding/group-16-aspire-orchestration.html#how-it-all-fits-at-runtime","d":"16. Aspire Orchestration & Service Defaults","k":"Onboarding Guide","t":"How it all fits at runtime","x":"Putting the pieces in sequence: the AppHost declares the graph and injects per-service env vars (WithSQLServerDataSource, WithBroker, WithJwksDiscovery, the E2E helpers, and the…","i":"Azure.Extensions.AspNetCore.Configuration.Secrets Azure.Extensions.AspNetCore.DataProtection.Blobs identityService.WithE2eRegistrationThrottleLift AddGatewayDownstreamHealthChecks_IsIdempotent LoginProtection__MaxRegistrationsPerIpPerHour ConnectionStrings__SQLServerConnectionString database.Resource.ConnectionStringExpression Readiness_IncludesADownstreamCheckPerService ResilienceCircuitBreakerFaultInjectionTests WarmupReadinessHealthCheck.CheckHealthAsync HttpKeepAlivePingPolicy.WithActiveRequests cancellationToken.IsCancellationRequested"},{"u":"/docs/onboarding/group-17-conference-domain.html","d":"17. ADC Conference - Domain Model & Module Contracts","k":"Onboarding Guide","x":"What this chapter covers. This is the heart of the Atlanta Developers Conference application, the Conference bounded context, the largest and richest domain in MMCA.ADC. It…","i":"AuditableAggregateRootEntity MMCA.ADC.Conference.Shared IdValueGeneratedAttribute INavigationPopulator EntityChangedEvent DomainEntityState TIdentifierType IAuditedEntity IModule TEntity Design Result"},{"u":"/docs/onboarding/group-17-conference-domain.html#two-packages-one-bounded-context","d":"17. ADC Conference - Domain Model & Module Contracts","k":"Onboarding Guide","t":"Two packages, one bounded context","x":"The Conference context spans two of the module's projects, and the split is deliberate Clean Architecture ([Rubric §3, Clean Architecture]). MMCA.ADC.Conference.Domain holds the…","i":"ISessionBookmarkValidationService IEventLiveValidationService MMCA.ADC.Conference.Domain MMCA.ADC.Conference.Shared SessionFeedbackSubmitted SpeakerUnlinkedFromUser EventFeedbackSubmitted SpeakerLinkedToUser MMCA.Common.Domain AssemblyReference ClassReference Architecture"},{"u":"/docs/onboarding/group-17-conference-domain.html#seven-aggregates-and-their-ownership-boundaries","d":"17. ADC Conference - Domain Model & Module Contracts","k":"Onboarding Guide","t":"Seven aggregates and their ownership boundaries","x":"An aggregate is a root entity plus the children it exclusively owns; invariants are enforced inside the boundary, and references across aggregates are by ID, never by object…","i":"AuditableAggregateRootEntity RecordSessionizeRefresh SessionQuestionAnswer SpeakerQuestionAnswer EventQuestionAnswer SessionCategoryItem SpeakerCategoryItem IDENTITY_INSERT TIdentifierType IAuditedEntity QuestionEntity QuestionSource"},{"u":"/docs/onboarding/group-17-conference-domain.html#the-aggregate-shape-taught-once","d":"17. ADC Conference - Domain Model & Module Contracts","k":"Onboarding Guide","t":"The aggregate shape, taught once","x":"Open any of the roots and you will see the same skeleton; this repetition is the point, and it is what makes the per-type sections that follow read quickly. The shape, using…","i":"Session.SessionQuestionAnswers Event.EventQuestionAnswers IReadOnlyCollection RestoreEventSpeaker isIdValueGenerated _rooms.AsReadOnly Result.Combine Architecture IsCollection base.Delete Performance RestoreRoom"},{"u":"/docs/onboarding/group-17-conference-domain.html#invariants-business-rules-as-testable-units","d":"17. ADC Conference - Domain Model & Module Contracts","k":"Onboarding Guide","t":"Invariants, business rules as testable units","x":"Each aggregate has a co-located static invariant class, EventInvariants (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:10),…","i":"System.Net.Mail.MailAddress CategoryInvariants QuestionInvariants SessionInvariants SpeakerInvariants SponsorInvariants CommonInvariants EventInvariants SessionStatuses Result.Combine Decline_Queue Accept_Queue"},{"u":"/docs/onboarding/group-17-conference-domain.html#domain-events-and-the-outbox-spine","d":"17. ADC Conference - Domain Model & Module Contracts","k":"Onboarding Guide","t":"Domain events and the outbox spine","x":"Every state-changing method raises a domain event through the inherited AddDomainEvent(...). The events come in two shapes. The aggregate-level ones, EventChanged,…","i":"SessionCategoryItemChanged SpeakerCategoryItemChanged SessionSpeakerChanged PreviousLinkedUserId CategoryItemChanged EventSpeakerChanged EntityChangedEvent DomainEntityState SaveChangesAsync CategoryChanged QuestionChanged TIdentifierType"},{"u":"/docs/onboarding/group-17-conference-domain.html#the-cross-aggregate-cascade-a-pure-domain-service","d":"17. ADC Conference - Domain Model & Module Contracts","k":"Onboarding Guide","t":"The cross-aggregate cascade: a pure domain service","x":"One business rule cannot live inside a single aggregate: deleting an Event must also delete every Session belonging to it (BR-127) and every Sponsor sold against it, but sessions…","i":"IEventCascadeDeletionDomainService EventCascadeDeletionDomainService EventId Session Sponsor Design Rubric Event List"},{"u":"/docs/onboarding/group-17-conference-domain.html#read-models-and-the-ai-decision-support-feature","d":"17. ADC Conference - Domain Model & Module Contracts","k":"Onboarding Guide","t":"Read models and the AI decision-support feature","x":"The largest cluster in Conference.Shared is the DTO layer, the wire contracts that decouple the API from the domain entities ([Rubric §9, API & Contract Design]; ADR-001 chose…","i":"RefreshFromSessionizeResultDTO RefreshFromSessionizeCommand SessionSelectionDashboardDTO ScoreEventSessionsResultDTO CategoryGroupDistribution Conference.Infrastructure CategoryItemDistribution SessionQuestionAnswerDTO SpeakerQuestionAnswerDTO SpeakerSessionOverlapDTO CategoryDistributionDTO ConcurrencyTokenRequest"},{"u":"/docs/onboarding/group-17-conference-domain.html#authorization-vocabulary-and-current-event-selection","d":"17. ADC Conference - Domain Model & Module Contracts","k":"Onboarding Guide","t":"Authorization vocabulary and current-event selection","x":"Two more Shared helpers deserve a mention because they encode policy the whole module relies on. ConferencePermissions…","i":"TimeZoneInfo.ConvertTimeToUtc ConferenceReadAudience ConferencePermissions CurrentEventDefaults CurrentEventSelector ContentManagement ContentEditor HasPermission Organizer RoleNames StartDate EventDTO"},{"u":"/docs/onboarding/group-17-conference-domain.html#crossing-the-module-boundary-contracts-stubs-and-integration-events","d":"17. ADC Conference - Domain Model & Module Contracts","k":"Onboarding Guide","t":"Crossing the module boundary: contracts, stubs, and integration events","x":"Conference does not live alone. Three kinds of connection point join it to other modules, and all live in Conference.Shared so neither side reaches into the other's domain…","i":"DisabledSessionBookmarkValidationService DisabledEventLiveValidationService ISessionBookmarkValidationService IEventLiveValidationService QuestionModerationDefault SessionFeedbackSubmitted SpeakerUnlinkedFromUser Conference.Application EventFeedbackSubmitted BaseIntegrationEvent User.LinkedSpeakerId SpeakerLinkedToUser"},{"u":"/docs/onboarding/group-17-conference-domain.html#end-to-end-one-organizer-action","d":"17. ADC Conference - Domain Model & Module Contracts","k":"Onboarding Guide","t":"End-to-end: one organizer action","x":"To see the chapter cooperate, follow an organizer renaming a room on an event. The application handler loads the Event aggregate (with its Rooms hydrated by the navigation…","i":"CategoryInvariants.EnsureCategoryItemNameIsUnique IEventLiveValidationService.GetEventLiveInfoAsync MMCA.ADC.Conference.Domain.Questions.DomainEvents MMCA.ADC.Conference.Domain.Sessions.DomainEvents MMCA.ADC.Conference.Domain.Speakers.DomainEvents MMCA.ADC.Conference.Domain.Sponsors.DomainEvents IUserSpeakerLinkService.ClearLinkedSpeakerAsync MMCA.ADC.Conference.Domain.Events.DomainEvents SessionInvariants.EnsureAnswerValueIsValid SpeakerInvariants.EnsureAnswerValueIsValid CurrentEventSelector.SelectCurrentOrNext DisabledSessionBookmarkValidationService"},{"u":"/docs/onboarding/group-18-conference-application.html","d":"18. ADC Conference - Application & Use Cases","k":"Onboarding Guide","x":"What this chapter covers. This is the application layer of the Conference module, the largest single application assembly in the codebase (this group covers 251 distinct types).…","i":"MMCA.Common.Application ics"},{"u":"/docs/onboarding/group-18-conference-application.html#the-vertical-slice-anatomy-of-a-use-case","d":"18. ADC Conference - Application & Use Cases","k":"Onboarding Guide","t":"The vertical-slice anatomy of a use case","x":"Open any feature folder under Sessions/UseCases/, Events/UseCases/, Speakers/UseCases/, Sponsors/UseCases/, Categories/UseCases/, or Questions/UseCases/ and you will find the…","i":"ValidateRoomAssignmentAsync BuildOverlapPredicate EventQuestionAnswers UnprocessableEntity EventSpeakers int.MinValue HandleAsync s.StartsAt DbContext s.EndsAt startsAt Session"},{"u":"/docs/onboarding/group-18-conference-application.html#manual-mapping-validation-rule-fragments-and-authorization-specifications","d":"18. ADC Conference - Application & Use Cases","k":"Onboarding Guide","t":"Manual mapping, validation rule fragments, and authorization specifications","x":"Three sibling families recur across every aggregate. DTO mappers (SessionDTOMapper, EventDTOMapper, SpeakerDTOMapper, SponsorDTOMapper, RoomDTOMapper, CategoryItemDTOMapper, and…","i":"TimeZoneInfo.FindSystemTimeZoneById s.Event.IsPublished AbstractValidator GetProjectedAsync GetReadRepository Session.EventId SessionSpeaker e.IsPublished EventSpeaker Expression IsEligible StartDate"},{"u":"/docs/onboarding/group-18-conference-application.html#query-services-navigation-populators-and-the-composition-root","d":"18. ADC Conference - Application & Use Cases","k":"Onboarding Guide","t":"Query services, navigation populators, and the composition root","x":"Read paths do not get bespoke handlers for the common cases; they go through the framework's generic IEntityQueryService , which supplies filtering, sorting, paging, and field…","i":"ScanModuleApplicationServices IServiceCollection ClassReference extension FirstName FullName LastName Question Sponsor"},{"u":"/docs/onboarding/group-18-conference-application.html#event-driven-reactions-domain-and-integration-handlers","d":"18. ADC Conference - Application & Use Cases","k":"Onboarding Guide","t":"Event-driven reactions: domain and integration handlers","x":"The application layer is also where the module reacts to events. Domain event handlers implement IDomainEventHandler and run in-process after the aggregate's SaveChangesAsync.…","i":"EnsureNotServiceSession SpeakerUnlinkedFromUser EnsureStatusIsEligible User.LinkedSpeakerId SpeakerLinkedToUser GetLiveWindowUtc SaveChangesAsync SessionChanged UserRegistered LogAndRethrow IEventBus Deleted"},{"u":"/docs/onboarding/group-18-conference-application.html#attendee-facing-read-models-calendar-export-and-nownext","d":"18. ADC Conference - Application & Use Cases","k":"Onboarding Guide","t":"Attendee-facing read models: calendar export and Now/Next","x":"A small cluster of queries serves the public schedule surfaces without going through the generic query service, because their output is not a DTO list. ExportEventCalendarHandler…","i":"CalendarExportMapper.IsExportable DateTimeOffset.UtcNow GetNowNextHandler GetLiveWindowUtc Error.NotFound IsExportable TimeProvider DTSTAMP Result string ics"},{"u":"/docs/onboarding/group-18-conference-application.html#the-sessionize-import-strategy-pattern-orchestration","d":"18. ADC Conference - Application & Use Cases","k":"Onboarding Guide","t":"The Sessionize import: Strategy-pattern orchestration","x":"The single most involved use case is importing a conference agenda from Sessionize.com. Sessionize returns one JSON payload covering five interdependent entity families…","i":"ThrowIfCancellationRequested TimeoutRejectedException BrokenCircuitException NotSupportedException RequestIdentityInsert HttpRequestException SaveChangesAsync JsonException TimeProvider Create Update catch"},{"u":"/docs/onboarding/group-18-conference-application.html#decision-support-ai-scoring-and-content-analytics","d":"18. ADC Conference - Application & Use Cases","k":"Onboarding Guide","t":"Decision support: AI scoring and content analytics","x":"The last cluster is session-selection decision support, analytics that help organizers triage proposals. GetSessionSelectionDashboardHandler is a composite query: it validates…","i":"MMCA.ADC.Conference.Application.Events.Sessionize MMCA.ADC.Conference.Application.Events.Validation SessionRoomScheduling.ValidateRoomAssignmentAsync currentUserService.IsPrivilegedConferenceReader eventCascadeDeletionDomainService.CascadeDelete IUserSpeakerLinkService.ClearLinkedSpeakerAsync MMCA.ADC.Conference.Application.Categories.DTOs MMCA.ADC.Engagement.Shared.UserSessionBookmarks PublicSessionStatusSpecification.StatusCriteria SessionSimilarityCalculator.CalculateSimilarity cancellationToken.ThrowIfCancellationRequested EventInvariants.OrganizerContactEmailMaxLength"},{"u":"/docs/onboarding/group-19-conference-infrastructure.html","d":"19. ADC Conference - Infrastructure & Persistence","k":"Onboarding Guide","x":"What this chapter covers. This is the adapter layer of the Conference module, the place where the engine-agnostic domain meets concrete technology. Three concerns live here: (1)…","i":"SessionScoringQueue ISessionizeService IAiScoringService Architecture DbContext Rubric Clean DbSet"},{"u":"/docs/onboarding/group-19-conference-infrastructure.html#engine-agnostic-entities-engine-chosen-by-the-config-base-class","d":"19. ADC Conference - Infrastructure & Persistence","k":"Onboarding Guide","t":"Engine-agnostic entities, engine chosen by the config base class","x":"The most important idea in this chapter is one the entities themselves never express: what storage engine each entity uses is decided here, not in the domain. A Conference domain…","i":"EntityTypeConfigurationSQLServer EntityDataSourceRegistry EntityTypeConfiguration DataSource.SQLServer SessionConfiguration SpeakerConfiguration SponsorConfiguration EventConfiguration TIdentifierType UseDataSource Session Speaker"},{"u":"/docs/onboarding/group-19-conference-infrastructure.html#each-config-inherits-the-cross-cutting-behavior-then-adds-entity-specifics","d":"19. ADC Conference - Infrastructure & Persistence","k":"Onboarding Guide","t":"Each config inherits the cross-cutting behavior, then adds entity specifics","x":"Every configuration's Configure method begins with base.Configure(builder) (for example SessionConfiguration.cs:18) and then adds its own mappings. That one base call is where…","i":"SessionQuestionAnswerConfiguration SpeakerQuestionAnswerConfiguration EventQuestionAnswerConfiguration SessionCategoryItemConfiguration SessionInvariants.TitleMaxLength SpeakerCategoryItemConfiguration ConferenceCategoryConfiguration SoftDeleteUniqueIndexConvention SponsorInvariants.NameMaxLength EventInvariants.NameMaxLength EventQuestionAnswer.EventId NullableEmailValueConverter"},{"u":"/docs/onboarding/group-19-conference-infrastructure.html#dbsets-the-context-shape-and-how-the-configurations-are-actually-found","d":"19. ADC Conference - Infrastructure & Persistence","k":"Onboarding Guide","t":"DbSets, the context shape, and how the configurations are actually found","x":"ModuleApplicationDbContext (MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:19) is the Conference module's abstract DbContext. It does one…","i":"IEntityTypeConfigurationSQLServer MMCA.ADC.Conference.Service ModuleApplicationDbContext SessionQuestionAnswers SpeakerQuestionAnswer ApplicationDbContext EventQuestionAnswers SessionCategoryItems SpeakerCategoryItems dbo.OutboxMessages SQLServerDbContext SaveChangesAsync"},{"u":"/docs/onboarding/group-19-conference-infrastructure.html#seeding-two-real-events-always-sample-data-only-in-dev-and-ci","d":"19. ADC Conference - Infrastructure & Persistence","k":"Onboarding Guide","t":"Seeding: two real events always, sample data only in dev and CI","x":"ConferenceModuleDbSeeder (MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/Seeding/ConferenceModuleDbSeeder.cs:24) derives from the framework's DbSeeder and runs after…","i":"ConferenceModuleDbSeeder ConferenceModuleSeeder ManualIdRangeStart QuestionInvariants includeSampleData SessionInvariants ExistsAsync DbSeeder sf1nopko z1ecmzux Migrate"},{"u":"/docs/onboarding/group-19-conference-infrastructure.html#the-sessionize-adapter","d":"19. ADC Conference - Infrastructure & Persistence","k":"Onboarding Guide","t":"The Sessionize adapter","x":"SessionizeService (MMCA.ADC.Conference.Infrastructure/Services/SessionizeService.cs:10) is a deliberately thin HTTP client: the whole class is one method. Given a Sessionize…","i":"EnsureSuccessStatusCode DependencyInjection SessionizeResponse SessionizeService HttpClient GetAsync code"},{"u":"/docs/onboarding/group-19-conference-infrastructure.html#the-anthropic-ai-scoring-adapter","d":"19. ADC Conference - Infrastructure & Persistence","k":"Onboarding Guide","t":"The Anthropic AI scoring adapter","x":"AnthropicScoringService (MMCA.ADC.Conference.Infrastructure/Services/AnthropicScoringService.cs:16) is the richer of the two adapters: it scores one session proposal against a…","i":"CultureInfo.InvariantCulture OperationCanceledException AnthropicScoringService AnthropicContentBlock SessionScoringResult AnthropicResponse IAiScoringService AnthropicMessage AnthropicRequest JsonPropertyName AiScoreResponse LoggerMessage"},{"u":"/docs/onboarding/group-19-conference-infrastructure.html#scoring-runs-on-a-hosted-drain-guarded-across-replicas","d":"19. ADC Conference - Infrastructure & Persistence","k":"Onboarding Guide","t":"Scoring runs on a hosted drain, guarded across replicas","x":"SessionScoringProcessor (MMCA.ADC.Conference.Infrastructure/Services/SessionScoringProcessor.cs:49) is the piece that makes a multi-minute paid AI pass safe to trigger from an…","i":"MMCA.ADC.Conference.Scoring scoring.run.failed.terminal ScoreEventSessionsCommand SessionScoringProcessor queue.MarkCompleted SessionScoringQueue BackgroundService CreateAsyncScope IDistributedLock TryAcquireAsync conferenceApp MarkCompleted"},{"u":"/docs/onboarding/group-19-conference-infrastructure.html#di-wiring-and-a-deliberate-resilience-override","d":"19. ADC Conference - Infrastructure & Persistence","k":"Onboarding Guide","t":"DI wiring and a deliberate resilience override","x":"DependencyInjection (MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:11) is a single extension(IServiceCollection) block (the codebase's standard DI-registration idiom,…","i":"AddModuleConferenceInfrastructure RemoveAllResilienceHandlers StandardResilienceHandler DependencyInjection HttpClient.Timeout IServiceCollection extension"},{"u":"/docs/onboarding/group-19-conference-infrastructure.html#how-it-fits-together-at-runtime","d":"19. ADC Conference - Infrastructure & Persistence","k":"Onboarding Guide","t":"How it fits together at runtime","x":"Three flows tie the chapter together. Persistence flow: a Conference command handler mutates an aggregate and the unit of work saves; that resolves the concrete…","i":"Microsoft.EntityFrameworkCore.Metadata.Builders System.Text.Json.Serialization.JsonPropertyName CategoryInvariants.CategoryItemNameMaxLength MMCA.ADC.Conference.Infrastructure.Services Microsoft.Extensions.DependencyInjection MMCA.ADC.Migrations.SqlServer.Conference ApplyConfigurationsForEntitiesInContext SessionInvariants.AnswerValueMaxLength SpeakerInvariants.AnswerValueMaxLength QuestionInvariants.ManualIdRangeStart SpeakerQuestionAnswerConfiguration.cs EventInvariants.AnswerValueMaxLength"},{"u":"/docs/onboarding/group-20-conference-api-grpc.html","d":"20. ADC Conference - API, gRPC Contracts & Service Host","k":"Onboarding Guide","x":"This chapter is the edge of the Conference bounded context, the layer that turns the rich Conference domain (G17) and its CQRS slices (G18) into a running HTTP + gRPC surface,…","i":"MMCA.ADC.Conference.Contracts MMCA.ADC.Conference.Service MMCA.ADC.Conference.API ConferenceModuleSeeder ConferenceModule Microservices Readiness Contract Vertical IModule Design Rubric"},{"u":"/docs/onboarding/group-20-conference-api-grpc.html#the-controller-hierarchy-almost-everything-is-inherited","d":"20. ADC Conference - API, gRPC Contracts & Service Host","k":"Onboarding Guide","t":"The controller hierarchy, almost everything is inherited","x":"The Conference API exposes sixteen controllers, and the striking thing about them is how little code each carries. They split into three structural families, all built on the…","i":"sessionquestionanswerscontroller conferencecategoriescontroller ConferenceCategoriesController eventquestionanswerscontroller sessioncategoryitemscontroller speakercategoryitemscontroller SessionSelectionController sessionspeakerscontroller categoryitemscontroller eventspeakerscontroller PagedCollectionResult ServiceInfoController"},{"u":"/docs/onboarding/group-20-conference-api-grpc.html#authorization-at-the-edge-three-shapes-not-one","d":"20. ADC Conference - API, gRPC Contracts & Service Host","k":"Onboarding Guide","t":"Authorization at the edge, three shapes not one","x":"Authorization is capability-based by default but not uniform, and the differences are the interesting part. Most write-bearing controllers carry a class-level…","i":"AuthorizationPolicies.RequireAuthenticated ConferencePermissions.SpeakersManage SessionQuestionAnswersController EventQuestionAnswersController CurrentUserServiceExtensions IsPrivilegedConferenceReader AddModuleConferenceAPI ConferenceReadAudience HasPermissionAttribute SessionSelectionManage ConferencePermissions ICurrentUserService"},{"u":"/docs/onboarding/group-20-conference-api-grpc.html#the-request-records-the-inbound-write-shapes","d":"20. ADC Conference - API, gRPC Contracts & Service Host","k":"Onboarding Guide","t":"The request records, the inbound write shapes","x":"Several controllers declare small record class request types alongside themselves, co-located in the same file: AddRoomRequest/UpdateRoomRequest…","i":"updatesessionquestionanswerrequest updateeventquestionanswerrequest addsessionquestionanswerrequest addeventquestionanswerrequest addsessioncategoryitemrequest addspeakercategoryitemrequest updatecategoryitemrequest addsessionspeakerrequest addcategoryitemrequest addeventspeakerrequest SessionCreateRequest SessionsController"},{"u":"/docs/onboarding/group-20-conference-api-grpc.html#where-the-generic-shape-gives-way-filtering-warnings-and-calendars","d":"20. ADC Conference - API, gRPC Contracts & Service Host","k":"Onboarding Guide","t":"Where the generic shape gives way: filtering, warnings, and calendars","x":"SessionsController is the best illustration of how a controller earns its overrides. Every read action is [AllowAnonymous] and [OutputCache(PolicyName = \"SessionsCache\")]…","i":"BuildPublicSessionSpecificationAsync BuildPagedSessionSpecificationAsync GetSessionsBySpeakerFilterQuery GetPublicSessionFilterQuery GetPublicSponsorFilterQuery PublishedEventSpecification ExportSessionCalendarQuery EvictSessionsCacheAsync UpdateSponsorCommand HasDateRangeWarning IdempotentAttribute IOutputCacheFeature"},{"u":"/docs/onboarding/group-20-conference-api-grpc.html#two-more-deviations-versioning-and-decision-support","d":"20. ADC Conference - API, gRPC Contracts & Service Host","k":"Onboarding Guide","t":"Two more deviations, versioning and decision support","x":"ServiceInfoController exists to prove the API-versioning machinery works beyond a single version ([Rubric §9, API & Contract Design]). It is a one-member shell over Common's…","i":"ConferencePermissions.SessionSelectionManage SessionScoringEnqueueResult SessionSelectionController ServiceInfoControllerBase SessionScoringProcessor ServiceInfoController ISessionScoringQueue minimumSimilarity ConferenceCache AllowAnonymous AlreadyPending HandleFailure"},{"u":"/docs/onboarding/group-20-conference-api-grpc.html#the-module-entry-point-and-seeder-how-conference-plugs-in","d":"20. ADC Conference - API, gRPC Contracts & Service Host","k":"Onboarding Guide","t":"The module entry point and seeder, how Conference plugs in","x":"ConferenceModule is the Conference implementation of IModule. It is tiny by design: Register(...) calls the DependencyInjection extension's AddConferenceModule(...)…","i":"DisabledSessionBookmarkValidationService DisabledEventLiveValidationService ISessionBookmarkValidationService IEventLiveValidationService ConferenceErrorResources ConferenceModuleDbSeeder ConferenceModuleSeeder AuthorizationPolicies RegisterDisabledStubs RequireAuthenticated AddConferenceModule DependencyInjection"},{"u":"/docs/onboarding/group-20-conference-api-grpc.html#the-grpc-edge-conference-as-both-server-and-client","d":"20. ADC Conference - API, gRPC Contracts & Service Host","k":"Onboarding Guide","t":"The gRPC edge, Conference as both server and client","x":"When Conference is extracted into its own process, two of its in-process collaborations must cross a network boundary, and both are handled by the G13 transport boundary (Result…","i":"SessionBookmarkValidationServiceGrpcAdapter AddConferenceEventLiveValidationClient eventlivevalidationservicegrpcadapter AddConferenceSessionValidationClient ISessionBookmarkValidationService AddEngagementBookmarkCountClient ModuleLoader.DiscoverAndRegister eventlivevalidationgrpcservice GrpcResultExceptionInterceptor MMCA.ADC.Conference.Contracts MMCA.ADC.Conference.Service SessionBookmarksGrpcService"},{"u":"/docs/onboarding/group-20-conference-api-grpc.html#the-service-host-kestrel-first-and-why","d":"20. ADC Conference - API, gRPC Contracts & Service Host","k":"Onboarding Guide","t":"The service host: Kestrel first, and why","x":"The MMCA.ADC.Conference.Service Program.cs boots only the Conference module (Modules:Conference:Enabled=true). Kestrel is configured before anything else, and the whole of it is…","i":"builder.ConfigureEndpointsWithHealthProbe MMCA.ADC.Conference.Scoring MMCA.ADC.Conference.Service KestrelEndpointExtensions HttpProtocols.Http2 MapDefaultEndpoints HTTP_1_1_REQUIRED Http1AndHttp2 Program.cs UseSerilog httpGet GOAWAY"},{"u":"/docs/onboarding/group-20-conference-api-grpc.html#output-caching-and-warm-up-the-two-performance-extension-points","d":"20. ADC Conference - API, gRPC Contracts & Service Host","k":"Onboarding Guide","t":"Output caching and warm-up, the two performance extension points","x":"Output caching is where this host carries the most bespoke configuration (Program.cs:191-255). The base policy is deny-by-default NoCache (Program.cs:198), so only explicitly…","i":"ConferenceReadAudience.PrivilegedRoles PublicEndpointOutputCachePolicy SelfHttpOutputCacheWarmupTask DbUpdateConcurrencyException SessionSelectionController ConferenceErrorResources AddPublicEndpointPolicy SelfHttpWarmupTaskBase ConferencePublicCache BookmarkCountsCache AddErrorResources Event.Name.Empty"},{"u":"/docs/onboarding/group-20-conference-api-grpc.html#the-runtime-picture-one-host-two-transports","d":"20. ADC Conference - API, gRPC Contracts & Service Host","k":"Onboarding Guide","t":"The runtime picture, one host, two transports","x":"After module discovery (Program.cs:313-319) the host wires the Engagement gRPC client (Program.cs:329), the broker (AddBrokerMessaging registering the UserRegistered…","i":"MMCA.Common.Application.Interfaces.Infrastructure currentUserService.IsPrivilegedConferenceReader ConferencePermissions.SessionSelectionManage AuthorizationPolicies.RequireAuthenticated ConferenceReadAudience.PrivilegedRoles.Any builder.ConfigureEndpointsWithHealthProbe DisabledSessionBookmarkValidationService MMCA.ADC.Conference.Shared.Authorization ConferencePermissions.ContentManagement GetPublicSessionCategoryItemFilterQuery GetPublicSpeakerCategoryItemFilterQuery AddConferenceEventLiveValidationClient"},{"u":"/docs/onboarding/group-21-conference-ui.html","d":"21. ADC Conference - UI","k":"Onboarding Guide","x":"What this chapter covers. This is the consumer half of the \"write-once UI, render everywhere\" story (primer §2): the Blazor pages and per-page HTTP services that turn the…","i":"MMCA.ADC.Conference.UI Architecture Responsive Component Design Rubric"},{"u":"/docs/onboarding/group-21-conference-ui.html#the-layering-inside-the-ui-a-page-never-touches-httpclient","d":"21. ADC Conference - UI","k":"Onboarding Guide","t":"The layering inside the UI: a page never touches HttpClient","x":"Each page is a .razor + .razor.cs code-behind pair that depends only on a UI service interface, never on HttpClient and never on the API's internals. The eight CRUD-shaped…","i":"IConferenceCategoryUIService RefreshFromSessionizeAsync ConferenceCategoryService EnsureSuccessStatusCode ICategoryItemUIService ServiceExceptionHelper PagedCollectionResult SponsorIdentifierType CategoryItemService IQuestionUIService EntityServiceBase ISessionUIService"},{"u":"/docs/onboarding/group-21-conference-ui.html#the-list-pages-derive-from-datagridlistpagebasetdto-get-everything-for-free","d":"21. ADC Conference - UI","k":"Onboarding Guide","t":"The list pages: derive from DataGridListPageBase, get everything for free","x":"Ten list screens, the organizer EventList, SessionList, SpeakerList, ConferenceCategoryList, QuestionList, RoomList, SponsorList, and the public PublicEventList,…","i":"MobileInfiniteScrollList ConferenceCategoryList DataGridListPageBase PublicSessionList PublicSpeakerList PublicSponsorList FetchMobilePage ListPageActions PublicEventList LoadServerData RestoreFilters GetPagedAsync"},{"u":"/docs/onboarding/group-21-conference-ui.html#container-and-presentational-split","d":"21. ADC Conference - UI","k":"Onboarding Guide","t":"Container and presentational split","x":"The behaviour-heavy screens do not keep everything in one code-behind: the page stays the container (data fetching, filter and paging state, service calls) and hands rendering to…","i":"SessionSelectionSpeakerOverlap PublicSessionListFilterBar SpeakerCategoryItemsPanel SessionSelectionAiScores SessionSelectionDisplay PublicSessionListView PublicSessionList Architecture ReloadAsync Changed Testing Rubric"},{"u":"/docs/onboarding/group-21-conference-ui.html#child-and-join-entities-a-thin-postdelete-base","d":"21. ADC Conference - UI","k":"Onboarding Guide","t":"Child-and-join entities: a thin POST/DELETE base","x":"Sessions, speakers, and events own join relationships (a speaker added to a session, a category item to a speaker) that the generic CRUD base cannot model, because the write…","i":"ISessionCategoryItemUIService ISpeakerCategoryItemUIService SessionCategoryItemService SpeakerCategoryItemService ISessionSpeakerUIService ChildEntityServiceBase IEventSpeakerUIService SessionSpeakerService EventSpeakerService MMCA.Common.UI DeleteAsync Validation"},{"u":"/docs/onboarding/group-21-conference-ui.html#display-enrichment-lookups-the-getall-vs-getbyid-populator-gap-worked-around-in-the-ui","d":"21. ADC Conference - UI","k":"Onboarding Guide","t":"Display-enrichment lookups: the GetAll-vs-GetById populator gap, worked around in the UI","x":"Because the API's list endpoints do not always populate every cross-entity navigation, several pages need a cheap id-to-name map to render speaker names beside a session or an…","i":"ICategoryItemLookupService CategoryItemLookupService ISpeakerLookupService SpeakerLookupService SponsorshipPacketUrl IEventLookupService EventLookupService PublicSessionList CategoryItemInfo SessionSpeakers SpeakerInfo Dictionary"},{"u":"/docs/onboarding/group-21-conference-ui.html#three-feature-areas-that-go-beyond-crud","d":"21. ADC Conference - UI","k":"Onboarding Guide","t":"Three feature areas that go beyond CRUD","x":"First, the speaker self-service dashboard: SpeakerDashboard is gated on the speakerid JWT claim (read from the cascaded authentication state and parsed as a Guid,…","i":"IOrganizerSessionFeedbackUIService IOrganizerEventFeedbackUIService OrganizerSessionFeedbackService OrganizerEventFeedbackService ISpeakerDashboardUIService AuthenticatedServiceBase OrganizerSessionFeedback SpeakerDashboardService OrganizerEventFeedback ServiceExceptionHelper IPublicLinkBuilder SpeakerDashboard"},{"u":"/docs/onboarding/group-21-conference-ui.html#session-selection-decision-support-the-asynchronous-edge","d":"21. ADC Conference - UI","k":"Onboarding Guide","t":"Session-selection decision support, the asynchronous edge","x":"The most behaviour-rich page is the organizer-only SessionSelectionDashboard, which renders category distribution, speaker overlap, locality breakdown, and AI content-similarity…","i":"SessionSelectionFilterOptions ScoreEventSessionsResultDTO ISessionSelectionUIService ScoreEventSessionsCommand SessionSelectionDashboard SessionSelectionService CurrentEventSelector ScorePollTracker ScorePollSignal SessionsScored Resilience inherited"},{"u":"/docs/onboarding/group-21-conference-ui.html#public-versus-authenticated-rendering-and-the-device-capability-path","d":"21. ADC Conference - UI","k":"Onboarding Guide","t":"Public versus authenticated rendering, and the device-capability path","x":"A recurring [Rubric §11, Security] pattern: the same conference entity is exposed through two page families. The public family (PublicEventList/PublicEventDetail,…","i":"IServiceProvider.GetService IConnectivityStatusService ISessionBookmarkUIService ConferenceReadAudience IHapticFeedbackService CurrentEventDefaults PublicSessionDetail PublicSpeakerDetail IScreenshotService CachedSessionPage PublicEventDetail PublicSessionList"},{"u":"/docs/onboarding/group-21-conference-ui.html#sponsors-a-feature-area-in-miniature","d":"21. ADC Conference - UI","k":"Onboarding Guide","t":"Sponsors, a feature area in miniature","x":"The sponsor surface is worth reading as a compact tour of every pattern above, because it is the newest and touches all of them. Organizers manage the roster through SponsorList…","i":"ADCSponsorCollectionResult DataGridListPageBase EventLookupService PublicSponsorList ADCSponsorInfo Enum.GetValues SponsorCreate SponsorDetail SponsorList SponsorTier SponsorDTO ADCHome"},{"u":"/docs/onboarding/group-21-conference-ui.html#the-landing-page","d":"21. ADC Conference - UI","k":"Onboarding Guide","t":"The landing page","x":"ADCHome is the conference front door, shared by the web and MAUI heads; both serve the editorial images from their own site root today, so neither overrides the ImageBasePath…","i":"CurrentEventSelector ADCCollectionResult ConferenceTrackInfo KeynoteSpeakerInfo ImageBasePath ADCEventInfo Performance EventPhase Rendering ADCHome Rubric Timer"},{"u":"/docs/onboarding/group-21-conference-ui.html#routes-and-navigation","d":"21. ADC Conference - UI","k":"Onboarding Guide","t":"Routes and navigation","x":"All paths are centralized in ConferenceRoutePaths, a static catalogue of literal routes and id-parameterized builder methods (EventDetails(id), PublicSessionDetails(id),…","i":"ConferenceRoutePaths.EventDetails NavigationManager.NavigateTo NavigationPublicLinkBuilder EventFeedbackOrganizer ConferenceRoutePaths Internationalization PublicSessionDetails IPublicLinkBuilder IStringLocalizer SponsorVisitLink RoomCheckInLink SponsorDetails"},{"u":"/docs/onboarding/group-21-conference-ui.html#how-it-all-plugs-into-the-shell","d":"21. ADC Conference - UI","k":"Onboarding Guide","t":"How it all plugs into the shell","x":"Two registration types wire the area in. ConferenceUIModule implements Common's IUIModule (the front-end counterpart of the IModule back-end contract): it declares the module's…","i":"MMCA.ADC.Conference.UI.Pages.ConferenceCategory ConferenceRoutePaths.SessionSelectionDashboard MMCA.ADC.Conference.UI.Pages.SessionSelection ListPageActions.DeleteWithConfirmationAsync ArgumentException.ThrowIfNullOrWhiteSpace CurrentEventDefaults.SelectCurrentOrNext CurrentEventSelector.SelectCurrentOrNext DashboardService.GetSpeakerSessionsAsync ListPageActions.ReloadActiveLayoutAsync MMCA.ADC.Conference.UI.Pages.Feedback MMCA.ADC.Conference.UI.Pages.Question MMCA.ADC.Conference.UI.Pages.Session"},{"u":"/docs/onboarding/group-22-engagement-module.html","d":"22. ADC Engagement Module (Session Bookmarks)","k":"Onboarding Guide","x":"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…","i":"MMCA.ADC.Engagement.Application.CheckIns.Services BookmarkCountService.BookmarkCountServiceClient MMCA.ADC.Engagement.Application.Points.Services MMCA.ADC.Engagement.Domain.UserSessionBookmarks MMCA.ADC.Engagement.Shared.UserSessionBookmarks MMCA.ADC.Engagement.Domain.Points.DomainEvents BookmarkCountService.BookmarkCountServiceBase MMCA.ADC.Engagement.Application.CheckIns.DTOs UserSessionBookmarkCacheEvictionHandlerTests assemblyProvider.GetConfigurationAssemblies AuthorizationPolicies.RequireAuthenticated CheckInsController.GetAttendanceStatsAsync"},{"u":"/docs/onboarding/group-23-engagement-live-layer.html","d":"23. ADC Engagement Live Layer (Real-Time Polls & Session Q&A)","k":"Onboarding Guide","x":"What this chapter covers. This is the conference-day layer of the Engagement bounded context: the features that only matter while an event is actually happening in the room.…","i":"SessionQuestion PresenterView HappeningNow SessionLive LivePoll"},{"u":"/docs/onboarding/group-23-engagement-live-layer.html#the-two-aggregates-and-their-invariants","d":"23. ADC Engagement Live Layer (Real-Time Polls & Session Q&A)","k":"Onboarding Guide","t":"The two aggregates and their invariants","x":"Both aggregates are sealed AuditableAggregateRootEntity subclasses that follow the framework's factory-plus-Result discipline (primer §2). LivePoll…","i":"AuditableAggregateRootEntity SessionQuestion.Create SessionQuestionChanged SessionQuestionUpvote ToggleUpvoteHandler LivePollInvariants DomainEntityState LiveWindowEndUtc BaseDomainEvent CanAcceptUpvote CastVoteHandler LivePollChanged"},{"u":"/docs/onboarding/group-23-engagement-live-layer.html#the-write-path-and-where-the-realtime-broadcast-actually-happens","d":"23. ADC Engagement Live Layer (Real-Time Polls & Session Q&A)","k":"Onboarding Guide","t":"The write path, and where the realtime broadcast actually happens","x":"Each operation is a vertical slice under Application/{LivePollsSessionQuestions}/UseCases/{Op}/, and every command handler shares the first two beats: mutate the aggregate…","i":"SessionQuestionUpvoteChangedHandler ILiveChannelPublishQueue.Enqueue SessionQuestionUpvoteChanged LiveChannelPublishWorkItem LivePollVoteChangedHandler ILiveChannelPublishQueue ModerateQuestionHandler SessionQuestionChannel CreateLivePollHandler LivePollClosedPayload SubmitQuestionHandler CloseLivePollHandler"},{"u":"/docs/onboarding/group-23-engagement-live-layer.html#one-websocket-one-publisher-port-and-a-cross-service-ingress","d":"23. ADC Engagement Live Layer (Real-Time Polls & Session Q&A)","k":"Onboarding Guide","t":"One WebSocket, one publisher port, and a cross-service ingress","x":"The transport itself is framework-owned (ADR-039, Group 10). The single NotificationHub carries both durable notifications and channel events on one connection, and the…","i":"LiveChannelPublisherGrpcAdapter LiveChannelPublishProcessor SignalRLiveChannelPublisher RendererInfo.IsInteractive NullLiveChannelPublisher IPushNotificationSender LiveChannelGrpcService NotificationHubService ILiveChannelPublisher OnAfterRenderAsync OnInitializedAsync LeaveChannelAsync"},{"u":"/docs/onboarding/group-23-engagement-live-layer.html#the-read-path-and-how-the-ui-reacts","d":"23. ADC Engagement Live Layer (Real-Time Polls & Session Q&A)","k":"Onboarding Guide","t":"The read path and how the UI reacts","x":"Reads do not go through the generic entity-query machinery; the live views need shaped projections. LivePollResultsBuilder…","i":"LivePollNavigationPopulator SessionLiveModerationPanel SessionQuestionViewBuilder SessionLiveQuestionPanel LivePollResultsBuilder ISessionLiveUIService LivePollConfiguration CurrentEventSelector SessionLivePollPanel SessionLiveUIService GetOpenPollsHandler LivePollDTOMapper"},{"u":"/docs/onboarding/group-23-engagement-live-layer.html#authorization-feature-gating-and-the-cross-service-dependency-on-conference","d":"23. ADC Engagement Live Layer (Real-Time Polls & Session Q&A)","k":"Onboarding Guide","t":"Authorization, feature gating, and the cross-service dependency on Conference","x":"Both controllers, LivePollsController (MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:42) and SessionQuestionsController…","i":"MMCA.ADC.Engagement.Domain.LivePolls.DomainEvents MMCA.ADC.Engagement.Application.LivePolls.DTOs SessionQuestionChannel.QuestionUpvoteChanged MMCA.ADC.Engagement.Domain.SessionQuestions MMCA.ADC.Engagement.Shared.SessionQuestions AuthorizationPolicies.RequireAuthenticated LivePollInvariants.EnsureOptionTextIsValid PushNotificationSettings.ChannelKeyPattern MMCA.ADC.Engagement.UI.Pages.HappeningNow SessionQuestionPendingCountChangedPayload SessionQuestionUpvote.QuestionId.Required CurrentEventSelector.SelectCurrentOrNext"},{"u":"/docs/onboarding/group-24-identity-module.html","d":"24. ADC Identity Module (Users, Profiles, GDPR Export/Erasure)","k":"Onboarding Guide","x":"What this chapter covers. This is the Identity bounded context of MMCA.ADC, the module that owns who a person is across every ADC surface: web, WebAssembly, and MAUI. It is a…","i":"GetUserPreferencesHandlerBase AuditableAggregateRootEntity AuthenticationServiceBase ChangePasswordHandlerBase ExportUserDataHandlerBase HasPermissionAttribute DeleteUserHandlerBase BaseIntegrationEvent SoftDeletedUserCache TIdentifierType IAnonymizable PiiAttribute"},{"u":"/docs/onboarding/group-24-identity-module.html#projects-one-bounded-context","d":"24. ADC Identity Module (Users, Profiles, GDPR Export/Erasure)","k":"Onboarding Guide","t":"Projects, one bounded context","x":"The module is split along the standard Clean Architecture layering ([Rubric §3, Clean Architecture]), each project pinned by a trivial AssemblyReference / ClassReference anchor…","i":"MMCA.ADC.Identity.Infrastructure MMCA.ADC.Identity.Application ScanModuleApplicationServices MaxRegistrationsPerIpPerHour MMCA.ADC.Identity.Contracts ModuleApplicationDbContext MMCA.ADC.Identity.Service MMCA.ADC.Identity.Domain MMCA.ADC.Identity.Shared SoftDeletedUserValidator IdentityErrorResources IdentityModuleDbSeeder"},{"u":"/docs/onboarding/group-24-identity-module.html#the-user-aggregate-credentials-profile-and-cross-context-links-in-one-root","d":"24. ADC Identity Module (Users, Profiles, GDPR Export/Erasure)","k":"Onboarding Guide","t":"The User aggregate: credentials, profile, and cross-context links in one root","x":"User (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:33) is the only aggregate root in the module, and it carries more responsibility than most: it is…","i":"RegisterRequestValidator IPasswordChangeableUser DeviceFieldMaxLength UserPasswordChanged FirstNameMaxLength RefreshTokenExpiry RevokeRefreshToken UpdateRefreshToken LastNameMaxLength UpdatePreferences UserConfiguration CommonInvariants"},{"u":"/docs/onboarding/group-24-identity-module.html#authentication-a-thin-subclass-over-the-shared-engine","d":"24. ADC Identity Module (Users, Profiles, GDPR Export/Erasure)","k":"Onboarding Guide","t":"Authentication: a thin subclass over the shared engine","x":"The login / registration / refresh / revocation workflow is not re-implemented here. It lives in AuthenticationServiceBase (G08), which owns the validate-first flow, the lockout…","i":"HttpContextExternalLoginEmailVerifier UnitOfWork.ExecuteInTransactionAsync CreateChangePreferencesCommand Auth.ExternalEmailNotVerified IdentityPermissions.UsersRead UserAccountAuthControllerBase CreateChangePasswordCommand IExternalLoginEmailVerifier AuthenticationServiceBase GetUserPreferencesHandler TChangePreferencesCommand ChangePreferencesCommand"},{"u":"/docs/onboarding/group-24-identity-module.html#the-privacy-pair-export-and-erasure","d":"24. ADC Identity Module (Users, Profiles, GDPR Export/Erasure)","k":"Onboarding Guide","t":"The privacy pair: export and erasure","x":"Two use cases make this module the codebase's clearest [Rubric §30, Compliance / Privacy / Data Governance] story, and both are now thin ADC specializations of a G14 base. The…","i":"UserDataExportEngagementSectionDTO NotificationUserDataExportSection EngagementUserDataExportSection IUserNotificationExportService IUserEngagementExportService BuildSubjectSnapshotAsync ExportUserDataHandlerBase UserDataExportSectionDTO UserDataExportSubjectDTO IUserDataExportSection OnAfterSoftDeleteAsync DeleteUserHandlerBase"},{"u":"/docs/onboarding/group-24-identity-module.html#avatars-the-third-mutating-slice","d":"24. ADC Identity Module (Users, Profiles, GDPR Export/Erasure)","k":"Onboarding Guide","t":"Avatars: the third mutating slice","x":"The avatar trio is a small but complete example of a file-handling slice ([Rubric §11, Security] at the content boundary, ADR-045). UsersController caps the multipart upload at 2…","i":"RemoveUserAvatarHandler Avatar.InvalidUpload GetUserAvatarHandler SetUserAvatarHandler IFileStorageService ImageContentSniffer RequestSizeLimit IImageProcessor UsersController MaxAvatarBytes"},{"u":"/docs/onboarding/group-24-identity-module.html#persistence-seeding-and-the-disabled-stub","d":"24. ADC Identity Module (Users, Profiles, GDPR Export/Erasure)","k":"Onboarding Guide","t":"Persistence, seeding, and the disabled stub","x":"ModuleApplicationDbContext (ModuleApplicationDbContext.cs:15) is the abstract, engine-agnostic context declaring the single Users set (:22); the concrete per-engine class…","i":"EntityTypeConfigurationSQLServer DisabledAttendeeQueryService IdentityModuleDbSeederBase ModuleApplicationDbContext IdentityModuleDbSeeder RegisterDisabledStubs ApplicationDbContext IdentityModuleSeeder EmailValueConverter dbo.OutboxMessages SQLServerDbContext UserConfiguration"},{"u":"/docs/onboarding/group-24-identity-module.html#crossing-the-service-boundary-grpc-and-integration-events","d":"24. ADC Identity Module (Users, Profiles, GDPR Export/Erasure)","k":"Onboarding Guide","t":"Crossing the service boundary: gRPC and integration events","x":"Identity talks to its peers two ways, and both live in Shared and Contracts so neither side reaches into the other's domain ([Rubric §7, Microservices Readiness]). Synchronously,…","i":"ConfigureEndpointsWithHealthProbe ModuleLoader.DiscoverAndRegister AttendeeQueryServiceGrpcAdapter SpeakerUnlinkedFromUserHandler SpeakerLinkedToUserHandler AddIdentityAttendeeClient KestrelEndpointExtensions RequireSuccessStatusCode SpeakerUnlinkedFromUser SelfHttpWarmupTaskBase AuthenticationService IAttendeeQueryService"},{"u":"/docs/onboarding/group-24-identity-module.html#the-ui-edge","d":"24. ADC Identity Module (Users, Profiles, GDPR Export/Erasure)","k":"Onboarding Guide","t":"The UI edge","x":"The Blazor surface is registered as an IdentityUIModule (MMCA.ADC.Identity.UI/IdentityUIModule.cs:13), an IUIModule descriptor that contributes two NavItems as resource keys, \"My…","i":"AuthenticatedServiceBase MobileInfiniteScrollList RetryPolicy.ExecuteAsync MMCA.Common.Testing.E2E DataGridListPageBase DependencyInjection IMediaPickerService IdentityRoutePaths IdentityUIModule ListPageActions IUserUIService UserListDTO"},{"u":"/docs/onboarding/group-24-identity-module.html#end-to-end-one-registration","d":"24. ADC Identity Module (Users, Profiles, GDPR Export/Erasure)","k":"Onboarding Guide","t":"End-to-end: one registration","x":"To see the chapter cooperate, follow a new attendee signing up. AuthController receives the register POST, captures the client IP for BR-213 rate limiting (AuthController.cs:57),…","i":"MMCA.ADC.Identity.Shared.Users.IntegrationEvents AttendeeQueryService.AttendeeQueryServiceClient System.Diagnostics.CodeAnalysis.SuppressMessage MMCA.ADC.Identity.Application.Users.Validation AttendeeQueryService.AttendeeQueryServiceBase AuthStateProvider.GetAuthenticationStateAsync LoginProtection__MaxRegistrationsPerIpPerHour ServiceCollectionDescriptorExtensions.Replace ListPageActions.DeleteWithConfirmationAsync MMCA.ADC.Identity.Domain.Users.DomainEvents ExternalAuthExtensions.ExternalLoginScheme System.Collections.Frozen.FrozenDictionary"},{"u":"/docs/onboarding/group-25-adc-host-composition.html","d":"25. ADC Application Host, UI Shell & Cross-Module Composition","k":"Onboarding Guide","x":"What this chapter covers. Every ADC module described so far, Conference, Engagement, Identity, Notification, is consumed somewhere. This chapter is that somewhere: the client…","i":"Microsoft.Extensions.Configuration.IConfiguration ArgumentException.ThrowIfNullOrWhiteSpace NowNextWidgetProvider.FetchSnapshotAsync MMCA.Common.UI.Components.Capabilities IPlatformApplication.Current.Services UIModuleConfiguration.IsModuleEnabled RemoteCertificateValidationCallback SessionCookieAuthenticationHandler EngagementRoutePaths.HappeningNow NowNextWidgetProvider.BuildViews System.Resources.ResourceManager WebAuthenticatorCallbackActivity"},{"u":"/docs/onboarding/group-26-device-capability-layer.html","d":"26. Device Capability Abstraction Layer (Native Contracts, MAUI, Browser & Fallback Adapters)","k":"Onboarding Guide","x":"What this chapter covers. One Blazor component library in MMCA.Common.UI renders on three very different heads: Blazor Server (server-side prerender plus interactive Server…","i":"MauiBackNavigationBridge.HandleBackPressedAsync MMCA.Common.UI.Services.Capabilities.Fallbacks MMCA.Common.UI.Services.Capabilities.Browser builder.Services.AddMauiDeviceCapabilities MauiLocalNotificationService.ScheduleAsync WebAuthenticator.Default.AuthenticateAsync ArgumentException.ThrowIfNullOrWhiteSpace Battery.Default.EnergySaverStatusChanged CommunityToolkit.Maui.Media.SpeechToText Connectivity.Current.ConnectivityChanged CultureInfo.DefaultThreadCurrentCulture ILocalNotificationService.ScheduleAsync"},{"u":"/docs/onboarding/group-27-testing-infrastructure.html","d":"27. Testing & Quality Infrastructure","k":"Onboarding Guide","x":"What this group covers. Everything the codebase uses to prove itself: the four reusable test-support packages that ship out of MMCA.Common/Source/Hosting (MMCA.Common.Testing,…","i":"ServiceInfoVersioningContractTestsBase SqlServerIntegrationTestFixtureBase MMCA.Common.Testing.Architecture ProductionHostApplicationFactory AccessibilityViolationException DecoratorPipelineOrderTestsBase FeatureManagementTestExtensions ProblemDetailsContractTestsBase CapturingHttpMessageHandler CrossEntityNavigationFinder RouteAuthorizationTestsBase BunitInteractionExtensions"},{"u":"/docs/onboarding/group-27-testing-infrastructure.html#integration-tests-a-real-host-a-throwaway-database-a-per-test-reset","d":"27. Testing & Quality Infrastructure","k":"Onboarding Guide","t":"Integration tests: a real host, a throwaway database, a per-test reset","x":"The integration tier boots the actual application, not a mock of it. The abstraction at its center is IIntegrationTestFixture (MMCA.Common.Testing/IIntegrationTestFixture.cs:8):…","i":"SqlServerIntegrationTestFixtureBase ProductionHostApplicationFactory FeatureManagementTestExtensions appsettings.Development.json SqlBaseEnvironmentVariable ConfigureTestFeatureFlags IEntityDataSourceRegistry CrossServiceFixtureBase IIntegrationTestFixture CrossServiceDataSource __EFMigrationsHistory WebApplicationFactory"},{"u":"/docs/onboarding/group-27-testing-infrastructure.html#architecture-fitness-functions-rules-that-gate-the-build","d":"27. Testing & Quality Infrastructure","k":"Onboarding Guide","t":"Architecture fitness functions: rules that gate the build","x":"The layering and DDD conventions this codebase commits to are not left to code review, they are executed as tests. The reusable rule library lives in…","i":"CancellationTokenConventionTestsBase ConstructorDependencyCountTestsBase IntegrationEventContractTestsBase MMCA.Common.Testing.Architecture ObservabilityConventionTestsBase AggregateRootsHaveResultFactory MicroserviceExtractionTestsBase RawQueryableConventionTestsBase IdempotencyConventionTestsBase ArchitectureRules.Entities.cs AggregateConventionTestsBase CrossEntityNavigationFinder"},{"u":"/docs/onboarding/group-27-testing-infrastructure.html#component-tests-real-mudblazor-faked-edges","d":"27. Testing & Quality Infrastructure","k":"Onboarding Guide","t":"Component tests: real MudBlazor, faked edges","x":"The bUnit tier renders a single Blazor component in-process with its real dependencies but stubbed network and auth. BunitComponentTestBase…","i":"IsAuthenticatedAuthorizationService AuthenticationStateProvider CapturingHttpMessageHandler BunitInteractionExtensions StubTokenStorageService BunitComponentTestBase FreshApiClientFactory MarkupSnapshotResult UiHttpServiceHarness AuthenticationState HttpMessageHandler IRenderedComponent"},{"u":"/docs/onboarding/group-27-testing-infrastructure.html#end-to-end-tests-a-real-browser-accessibility-and-performance-as-gates","d":"27. Testing & Quality Infrastructure","k":"Onboarding Guide","t":"End-to-end tests: a real browser, accessibility and performance as gates","x":"The E2E tier drives a real browser through Playwright. PlaywrightFixture (MMCA.Common.Testing.E2E/Infrastructure/PlaywrightFixture.cs:6) is an xUnit collection fixture (its…","i":"AssertNoAccessibilityViolationsAsync AccessibilityViolationException Wcag21AaExceptMudPagerCombobox ProfileManagementTestsBase GotoAndWaitForBlazorAsync UserRegistrationTestsBase UserPreferencesTestsBase ClickAndWaitForUrlAsync window.Blazor._internal AuthorizationTestsBase WaitForAuthResultAsync AuthenticatedUserPath"},{"u":"/docs/onboarding/group-27-testing-infrastructure.html#the-gallery-harness","d":"27. Testing & Quality Infrastructure","k":"Onboarding Guide","t":"The Gallery harness","x":"Component and E2E coverage of MMCA.Common's own UI needs a page to render, but the framework is not a runnable app. MMCA.Common.UI.Gallery is a deliberately backend-less Blazor…","i":"GalleryAuthenticationStateProvider GalleryFakeAuthenticationHandler StubNotificationInboxUIService StubPushNotificationUIService MMCA.Common.UI.E2E.Tests NullTokenStorageService MMCA.Common.UI.Gallery MapRazorComponents NullTokenRefresher NoOpAuthUIService MMCA.Common.slnx GalleryUIModule"},{"u":"/docs/onboarding/group-27-testing-infrastructure.html#contract-pipeline-and-benchmark-bases","d":"27. Testing & Quality Infrastructure","k":"Onboarding Guide","t":"Contract, pipeline, and benchmark bases","x":"The last family pins guarantees that live in the composition of the stack rather than in any one type, and it is the subject of ADR-058: these suites ship in MMCA.Common.Testing…","i":"Application_ShouldNotDependOn_EntityFrameworkCore Controllers_ShouldNotDependOn_EntityFrameworkCore DataSubject_DeclaresPii_SoTheContractIsNotVacuous MMCA.Common.Architecture.Tests.CycleFixtures.Left Module_ShouldDeclare_ExpectedRequiresDependencies PiiRedactor_LeaksNoClearTextPii_ToLogsOrTelemetry CultureSwitch_ToSpanish_ShouldLocalizeAndPersist EveryRunbookAlertSection_MapsToAProvisionedAlert MobileViewport_CultureAndTheme_ShouldBeReachable ModuleShared_ShouldNotDependOn_OwnInternalLayers OpenApiDocument_DescribesEveryCorePublicResource Register_WithMismatchedPasswords_ShouldShowError"},{"u":"/docs/onboarding/group-27-testing-infrastructure.html#per-project-test-rollup","d":"27. Testing & Quality Infrastructure","k":"Onboarding Guide","t":"Per-project test rollup","x":"This guide treats tests as grouped, not sectioned per [Fact] (the logged exception in the charter): the reusable test bases, the shared architecture-fitness library and its…","i":"MMCA.ADC.ServiceBusEmulator.IntegrationTests UserSessionBookmarkCacheEvictionHandlerTests PushNotificationProjectionTranslationTests SpecificationsDoNotNavigateToOtherEntities CachingDecoratorConstructorSelectionTests CurrentUserTargetingContextAccessorTests Microsoft.Extensions.DependencyInjection MMCA.ADC.Conference.Infrastructure.Tests MMCA.ADC.Engagement.Infrastructure.Tests MMCA.ADC.Notification.Application.Tests EntityServiceBaseIdempotencyRetryTests MMCA.ADC.CrossService.IntegrationTests"},{"u":"/docs/onboarding/devops-aspire.html","d":"Aspire Orchestration and Containers","k":"Onboarding Guide","x":"This chapter teaches how the MMCA.ADC system goes from a single dotnet run on your workstation to a running stack of six .NET processes plus four containers: databases, a broker,…","i":"MMCA.Common.Aspire ServiceDefaults WithReference dotnet run"},{"u":"/docs/onboarding/devops-aspire.html#the-one-command-local-run","d":"Aspire Orchestration and Containers","k":"Onboarding Guide","t":"The one-command local run","x":"That command brings up everything the application needs locally: four SQL Server databases, Redis, RabbitMQ with management UI, a MailDev SMTP interceptor, four extracted…"},{"u":"/docs/onboarding/devops-aspire.html#mmcaadcapphost-the-orchestration-project","d":"Aspire Orchestration and Containers","k":"Onboarding Guide","t":"MMCA.ADC.AppHost, the orchestration project","x":"Source file: MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs Extension helpers: MMCA.Common.Aspire.Hosting/Extensions.cs (AddMessageBroker, WithBroker, WithJwksDiscovery,…","i":"identityService.WithE2eRegistrationThrottleLift LoginProtection__MaxRegistrationsPerIpPerHour ConnectionStrings__SQLServerConnectionString ConnectionStrings__CosmosConnectionString ConnectionStrings__SqliteConnectionString Authentication__JwtBearer__Authority identityService.WithEnvironment services__notification__grpc__0 WithE2eRegistrationThrottleLift E2E_LIFT_REGISTRATION_THROTTLE GrpcResultExceptionInterceptor JwtForwardingClientInterceptor"},{"u":"/docs/onboarding/devops-aspire.html#where-service-defaults-come-from-mmcacommonaspire-not-a-local-project","d":"Aspire Orchestration and Containers","k":"Onboarding Guide","t":"Where service defaults come from, MMCA.Common.Aspire, not a local project","x":"There is no MMCA.ADC.ServiceDefaults project. The conventional Aspire \"ServiceDefaults\" shared project that scaffolding generates has been deleted; each service host (and the UI)…","i":"AddCommonKeyVaultConfiguration scoring.run.failed.terminal MMCA.ADC.ServiceDefaults AddCommonDataProtection DefaultAzureCredential builder.Configuration AuditTrailCleanupJob ConfigurationManager MapDefaultEndpoints AddServiceDefaults MMCA.Common.Aspire ScheduledJobRunner"},{"u":"/docs/onboarding/devops-aspire.html#mmcacommonaspire-the-framework-service-defaults-package","d":"Aspire Orchestration and Containers","k":"Onboarding Guide","t":"MMCA.Common.Aspire, the framework service-defaults package","x":"Source: MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs Telemetry: MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Telemetry/OutboxPollFilterProcessor.cs Security:…","i":"APPLICATIONINSIGHTS_CONNECTION_STRING OpenIdConnectMetadataWarmupTask EnableMultipleHttp2Connections AddInfrastructureHealthChecks Services.AddServiceDiscovery Telemetry__TracesSampleRatio ActivityTraceFlags.Recorded ConfigureHttpClientDefaults OTEL_EXPORTER_OTLP_ENDPOINT PooledConnectionIdleTimeout MMCA.Common.Infrastructure WarmupReadinessHealthCheck"},{"u":"/docs/onboarding/devops-aspire.html#mmcacommonaspirehosting-the-apphost-extensions-package","d":"Aspire Orchestration and Containers","k":"Onboarding Guide","t":"MMCA.Common.Aspire.Hosting, the AppHost extensions package","x":"Source: MMCA.Common/Source/Hosting/MMCA.Common.Aspire.Hosting/Extensions.cs This package lives in a separate assembly from MMCA.Common.Aspire so running services do not pull in…","i":"identityService.WithE2eRegistrationThrottleLift LoginProtection__MaxRegistrationsPerIpPerHour Authentication__JwtBearer__Authority WithE2eRegistrationThrottleLift E2E_LIFT_REGISTRATION_THROTTLE E2eRegistrationsPerIpPerHour builder.AddMessageBroker E2E_JWT_PRIVATE_KEY_PEM WithSQLServerDataSource E2E_JWT_PUBLIC_KEY_PEM Jwks__RsaPublicKeyPem Jwt__RsaPrivateKeyPem"},{"u":"/docs/onboarding/devops-aspire.html#the-six-dockerfiles","d":"Aspire Orchestration and Containers","k":"Onboarding Guide","t":"The six Dockerfiles","x":"All six Dockerfiles share the same multi-stage structure (base → build → publish → final) and the same base images. None build the AppHost, it is a local-only orchestration…","i":"MMCA.ADC.Notification.Service.dll MMCA.ADC.Conference.Service.dll MMCA.ADC.Engagement.Service.dll GlobalUsings.IdentifierType.cs MMCA.ADC.Identity.Service.dll MMCA.ADC.UI.Web.Client Directory.Build.props TreatWarningsAsErrors MMCA.ADC.Gateway.dll MMCA.ADC.UI.Web.dll MMCA.Common.Aspire MMCA.ADC.UI.Web"},{"u":"/docs/onboarding/devops-aspire.html#local-to-cloud-parity","d":"Aspire Orchestration and Containers","k":"Onboarding Guide","t":"Local-to-cloud parity","x":"The AppHost topology maps directly to the Azure infrastructure provisioned by infra/main.bicep. The table below cross-references the local resource with its Azure equivalent: The…","i":"ConnectionStrings__SQLServerMigrationsAssembly APPLICATIONINSIGHTS_CONNECTION_STRING __SQLServerMigrationsAssembly OTEL_EXPORTER_OTLP_ENDPOINT ConnectionStrings__redis WithSQLServerDataSource Outbox__DatabaseName AddBrokerMessaging MessageBusProvider ADC_Notification AzureServiceBus ADC_Conference"},{"u":"/docs/onboarding/devops-aspire.html#the-yarp-gateways-role","d":"Aspire Orchestration and Containers","k":"Onboarding Guide","t":"The YARP Gateway's role","x":"The gateway (Source/Hosts/MMCA.ADC.Gateway) is a pure YARP reverse proxy. It has no DbContext, no ModuleLoader, no REST controllers, and no broker connection. Its Program.cs is…","i":"HttpResilienceDefaults.TotalRequestTimeout notificationRestConfig HttpVersion.Version20 RequestVersionOrLower RequestVersionExact restActivityTimeout ActivityTimeout Http1AndHttp2 VersionPolicy ForwardHttp2 MapForwarder ModuleLoader"},{"u":"/docs/onboarding/devops-aspire.html#startup-ordering-summary","d":"Aspire Orchestration and Containers","k":"Onboarding Guide","t":"Startup ordering summary","x":"The health-based WaitFor chain imposes this ordering. Note that three of the four services wait on Identity without any explicit WaitFor in the AppHost: WithJwksDiscovery adds it…","i":"WithJwksDiscovery WithReference WaitFor"},{"u":"/docs/onboarding/devops-aspire.html#not-determinable-from-source","d":"Aspire Orchestration and Containers","k":"Onboarding Guide","t":"Not determinable from source","x":"- The specific integration events that flow over the broker (e.g., UserRegistered, SpeakerLinkedToUser) are cited from AppHost inline comments (Program.cs:46-51, 130-136), not…","i":"SpeakerLinkedToUser UserRegistered CLAUDE.md"},{"u":"/docs/onboarding/devops-cicd.html","d":"CI/CD and Operations","k":"Onboarding Guide","x":"This chapter walks the GitHub Actions workflows that govern MMCA, from the framework's continuous integration and lockstep NuGet release in MMCA.Common, through the ADC…","i":"MMCA.Common"},{"u":"/docs/onboarding/devops-cicd.html#mmcacommon-ciyml","d":"CI/CD and Operations","k":"Onboarding Guide","t":"MMCA.Common, ci.yml","x":"File: MMCA.Common/.github/workflows/ci.yml The continuous-integration workflow for the MMCA.Common framework. Because the fifteen packages are consumed by every downstream…","i":"MMCA.Common.Infrastructure.Redis.Tests RestorePackagesWithLockFile Deque.AxeCore.Playwright Directory.Packages.props PLAYWRIGHT_BROWSERS_PATH DistributedCacheService MMCA.Common.Testing.E2E MMCA.Common.UI.Gallery Directory.Build.props TreatWarningsAsErrors Infrastructure.Tests MMCA.Common.UI.Tests"},{"u":"/docs/onboarding/devops-cicd.html#mmcacommon-releaseyml","d":"CI/CD and Operations","k":"Onboarding Guide","t":"MMCA.Common, release.yml","x":"File: MMCA.Common/.github/workflows/release.yml The lockstep NuGet release workflow. When a maintainer pushes a vX.Y.Z git tag, this workflow deterministically derives the…","i":"Directory.Packages.props github.repository_owner DependencyVersionTests Testing.Architecture MMCA.Common.UI.Maui MMCA.Common.slnx GITHUB_REF_NAME Aspire.Hosting Infrastructure GITHUB_TOKEN Application release.yml"},{"u":"/docs/onboarding/devops-cicd.html#mmcaadc-deployyml","d":"CI/CD and Operations","k":"Onboarding Guide","t":"MMCA.ADC, deploy.yml","x":"File: MMCA.ADC/.github/workflows/deploy.yml The primary CI/CD pipeline for the Atlanta Developers Conference application. It runs on every push to main, on every pull request…","i":"needs.foundation.outputs.acrLoginServer coverage.integration.cobertura.xml MMCA.ADC.Integration.slnf Directory.Packages.props USE_MANAGED_IDENTITY_SQL JWT_RSA_PRIVATE_KEY_PEM MMCA.ADC.Services.Tests __EFMigrationsHistory Directory.Build.props SQL_LOCATION_OVERRIDE WebApplicationFactory skip_freshness_gates"},{"u":"/docs/onboarding/devops-cicd.html#mmcaadc-e2eyml","d":"CI/CD and Operations","k":"Onboarding Guide","t":"MMCA.ADC, e2e.yml","x":"File: MMCA.ADC/.github/workflows/e2e.yml The full-stack Playwright E2E test workflow. It brings up the complete Aspire stack (SQL Server + Redis + RabbitMQ + four services +…","i":"PLAYWRIGHT_BROWSERS_PATH MMCA.Common.Testing.E2E github.event.schedule WEB_VITALS_OUTPUT_DIR PlaywrightFixture workflow_dispatch matrix.browser WebVitalsTests workflow_call E2E_BASE_URL GITHUB_TOKEN E2E_BROWSER"},{"u":"/docs/onboarding/devops-cicd.html#mmcaadc-cost-guardyml","d":"CI/CD and Operations","k":"Onboarding Guide","t":"MMCA.ADC, cost-guard.yml","x":"File: MMCA.ADC/.github/workflows/cost-guard.yml A read-only FinOps check that confirms the production Azure footprint is at its cost baseline. It detects a specific operational…","i":"project_adc_2026_actual_load.md BASELINE_MAX_REPLICAS workflow_dispatch workflow_call deploy.yml production"},{"u":"/docs/onboarding/devops-cicd.html#mmcaadc-load-testyml","d":"CI/CD and Operations","k":"Onboarding Guide","t":"MMCA.ADC, load-test.yml","x":"File: MMCA.ADC/.github/workflows/load-test.yml A k6 load test targeting the output-cached Conference read endpoints through the production Gateway. It establishes a repeatable…","i":"project_adc_2026_actual_load.md workflow_dispatch inputs.peak_vus production base_url BASE_URL peak_vus PEAK_VUS"},{"u":"/docs/onboarding/devops-cicd.html#mmcaadc-cutover-per-service-dbsyml","d":"CI/CD and Operations","k":"Onboarding Guide","t":"MMCA.ADC, cutover-per-service-dbs.yml","x":"File: MMCA.ADC/.github/workflows/cutover-per-service-dbs.yml A one-time, manually-triggered workflow that migrated the four empty per-service databases (ADCIdentity,…","i":"inputs.freeze_traffic ADC_Notification OutboxProcessor ADC_Conference ADC_Engagement freeze_traffic OutboxMessages ADC_Identity containerapp GITHUB_TOKEN SqlBulkCopy deploy.yml"},{"u":"/docs/onboarding/devops-cicd.html#cross-workflow-summary","d":"CI/CD and Operations","k":"Onboarding Guide","t":"Cross-workflow summary","x":"(dr-drill.yml is the ADR-009 §29 restore drill: it PITR-restores a copy of a chosen database, times the restore for the RTO record, verifies it comes back Online, then deletes…","i":"workflow_call deploy.needs deploy.yml federated because e2e.yml subject deploy scoped false slnx the"},{"u":"/docs/onboarding/devops-cicd.html#rubric-category-index-for-this-chapter","d":"CI/CD and Operations","k":"Onboarding Guide","t":"Rubric category index for this chapter","i":"WebVitalsTests deploy.needs environment release.yml deploy.yml foundation production coverage cutover e2e.yml ci.yml deploy"},{"u":"/docs/onboarding/devops-iac.html","d":"Infrastructure as Code, ADC Azure Deployment","k":"Onboarding Guide","x":"This chapter teaches the Azure Infrastructure-as-Code layer for the MMCA.ADC application: what resources are provisioned, why they are shaped the way they are, how secrets reach…","i":"azure.yaml deploy.yml"},{"u":"/docs/onboarding/devops-iac.html#how-the-pieces-fit-together","d":"Infrastructure as Code, ADC Azure Deployment","k":"Onboarding Guide","t":"How the pieces fit together","x":"Before diving into individual files, here is the end-to-end picture: Phases 1 and 2 are their own jobs (deploy.yml:747, deploy.yml:795) rather than steps inside deploy, so they…","i":"AZURE_RESOURCE_GROUP resourceGroup foundation main.bicep AtlDevCon deploy"},{"u":"/docs/onboarding/devops-iac.html#azureyaml-the-azd-project-definition","d":"Infrastructure as Code, ADC Azure Deployment","k":"Onboarding Guide","t":"azure.yaml, the azd project definition","x":"File: MMCA.ADC/azure.yaml azure.yaml is the Azure Developer CLI (azd) manifest for the project. It declares six deployable services and points azd at the Bicep infrastructure…","i":"Directory.Packages.props foundation.bicep containerapp notification azure.yaml conference engagement main.bicep identity language provider context"},{"u":"/docs/onboarding/devops-iac.html#infrafoundationbicep-long-lived-shared-infrastructure","d":"Infrastructure as Code, ADC Azure Deployment","k":"Onboarding Guide","t":"infra/foundation.bicep, long-lived shared infrastructure","x":"File: MMCA.ADC/infra/foundation.bicep Foundation is deployed first (CI/CD chapter: deploy.yml:773-779) on every run. It provisions three resources: the Azure Container Registry,…","i":"reference_log_analytics_sku_limits.md needs.foundation.outputs.acrName workspaceCapping.dailyQuotaGb appLogsConfiguration adminUserEnabled logAnalyticsName environmentName acrLoginServer resourceGroup resourceToken timerTriggers acrPurgeTask"},{"u":"/docs/onboarding/devops-iac.html#deployment-parameters-assembled-at-deploy-time-not-committed","d":"Infrastructure as Code, ADC Azure Deployment","k":"Onboarding Guide","t":"Deployment parameters, assembled at deploy time, not committed","x":"There is no infra/main.parameters.json file in the repository, the infra/ directory holds only foundation.bicep, main.bicep, DISASTER-RECOVERY.md, OPERATIONS.md,…","i":"USE_MANAGED_IDENTITY_SQL useManagedIdentitySql deploymentParameters SQL_ADMIN_PASSWORD alertEmailAddress foundation.bicep logAnalyticsName sqlAdminPassword environmentName Microsoft.Sql OPERATIONS.md hasAnthropic"},{"u":"/docs/onboarding/devops-iac.html#inframainbicep-the-full-application-infrastructure","d":"Infrastructure as Code, ADC Azure Deployment","k":"Onboarding Guide","t":"infra/main.bicep, the full application infrastructure","x":"File: MMCA.ADC/infra/main.bicep main.bicep declares every application-layer Azure resource: Application Insights, the SLO scheduled query rules and their action group, two…","i":"ApplicationSettings__DatabaseInitStrategy Logging__OpenTelemetry__LogLevel__Default APPLICATIONINSIGHTS_CONNECTION_STRING AddHttpForwarderWithServiceDiscovery Authentication__JwtBearer__Authority project_outbox_cost_optimization.md Telemetry__DisableHttpClientMetrics project_adc_no_broker_in_azure.md Scheduler__PollingIntervalSeconds ObservabilityConventionTestsBase Telemetry__DisableRuntimeMetrics DataProtection__ApplicationName"},{"u":"/docs/onboarding/devops-iac.html#deployment-model-summary","d":"Infrastructure as Code, ADC Azure Deployment","k":"Onboarding Guide","t":"Deployment model summary","x":"The complete credential chain: No static credential exists at any link in this chain. The GitHub secrets AZURECLIENTID, AZURETENANTID, AZURESUBSCRIPTIONID are the OIDC…","i":"AZURE_SUBSCRIPTION_ID SQL_ADMIN_PASSWORD AZURE_CLIENT_ID AZURE_TENANT_ID secure"},{"u":"/docs/onboarding/devops-iac.html#rubric-category-cross-reference","d":"Infrastructure as Code, ADC Azure Deployment","k":"Onboarding Guide","t":"Rubric category cross-reference","x":"---","i":"useManagedIdentitySql OTEL_SERVICE_NAME adminUserEnabled KeyVault__Uri dailyQuotaGb minReplicas commonTags secrets secure false grpc"},{"u":"/docs/onboarding/devops-iac.html#not-determinable-from-source","d":"Infrastructure as Code, ADC Azure Deployment","k":"Onboarding Guide","t":"Not determinable from source","x":"- The exact AcrPull and Key Vault Secrets User role-assignment commands used in the out-of- band bootstrap are referenced in comments (main.bicep:915-919, main.bicep:933-936) but…","i":"USE_MANAGED_IDENTITY_SQL AZURE_RESOURCE_GROUP SQL_AAD_ADMIN_LOGIN AZURE_SQL_LOCATION SQL_AAD_ADMIN_OID deploymentMode deploy.yml main.bicep AcrPull Secrets westus2 false"},{"u":"/docs/onboarding/devops-runbooks.html","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","x":"This chapter covers every operational script and runbook in MMCA.ADC: the one-time Azure bootstrap, the database-per-service cutover story (how the legacy AtlDevCon monolith DB…","i":"MMCA.Store AtlDevCon MMCAStore MMCA.ADC ib_rg"},{"u":"/docs/onboarding/devops-runbooks.html#azure-setupsh-one-time-azure-bootstrap","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"azure-setup.sh, One-time Azure bootstrap","x":"File: MMCA.ADC/scripts/azure-setup.sh What it is. A bash script that creates every Azure identity and OIDC credential the GitHub Actions deploy pipeline needs. It is idempotent:…","i":"feedback_azure_cli_role_bug.md JWT_RSA_PRIVATE_KEY_PEM JWT_RSA_PUBLIC_KEY_PEM AZURE_SUBSCRIPTION_ID create_or_replace_fic AZURE_RESOURCE_GROUP MissingSubscription SQL_ADMIN_PASSWORD AZURE_CLIENT_ID AZURE_TENANT_ID Technologies assign_role"},{"u":"/docs/onboarding/devops-runbooks.html#the-database-per-service-cutover-story","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"The database-per-service cutover story","x":"Before the cutover scripts make sense, the story behind them does. Before ADR-006. All four modules (Identity, Conference, Engagement, Notification) pointed at a single shared…","i":"DataSources__Identity__SQLServerConnectionString CrossDataSourceDegradeConvention project_outbox_race_shared_db.md AtlDevCon.dbo.OutboxMessages inputs.freeze_traffic dbo.OutboxMessages workflow_dispatch ADC_Notification OutboxProcessor ADC_Conference ADC_Engagement freeze_traffic"},{"u":"/docs/onboarding/devops-runbooks.html#copy-atldevcon-to-per-service-dbsazureps1-azure-data-copy","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"copy-atldevcon-to-per-service-dbs.azure.ps1, Azure data copy","x":"File: MMCA.ADC/scripts/copy-atldevcon-to-per-service-dbs.azure.ps1 What it is. A PowerShell script that streams rows from AtlDevCon into the four per-service Azure SQL databases…","i":"Microsoft.Data.SqlClient AtlDevCon.schema.Table QUOTED_IDENTIFIER OutboxMessages KeepIdentity is_computed SqlBulkCopy sys.columns CHECKIDENT rowversion RowVersion AtlDevCon"},{"u":"/docs/onboarding/devops-runbooks.html#migrate-atldevcon-to-per-service-dbsps1-local-data-copy-wrapper","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"migrate-atldevcon-to-per-service-dbs.ps1, local data copy wrapper","x":"File: MMCA.ADC/scripts/migrate-atldevcon-to-per-service-dbs.ps1 What it is. A thin PowerShell wrapper that invokes the companion SQL script via sqlcmd against the local Aspire…","i":"QUOTED_IDENTIFIER AtlDevCon localhost sqlcmd error exit sql"},{"u":"/docs/onboarding/devops-runbooks.html#migrate-atldevcon-to-per-service-dbssql-local-sql-copy-script","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"migrate-atldevcon-to-per-service-dbs.sql, local SQL copy script","x":"File: MMCA.ADC/scripts/migrate-atldevcon-to-per-service-dbs.sql What it is. The T-SQL script that performs the actual per-row copy from AtlDevCon into the four per-service…","i":"AtlDevCon.sys.columns sys.identity_columns IDENTITY_INSERT OutboxMessages CHECKIDENT SchemaName XACT_ABORT AtlDevCon TableName timestamp TargetDb EXISTS"},{"u":"/docs/onboarding/devops-runbooks.html#infradisaster-recoverymd-dr-runbook","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"infra/DISASTER-RECOVERY.md, DR runbook","x":"File: MMCA.ADC/infra/DISASTER-RECOVERY.md (175 lines; not the Store file of the same name) What it is. The authoritative disaster-recovery runbook for the ADC production…","i":"publicNetworkAccess scheduledQueryRules serviceDatabaseLtr workflow_dispatch ADC_Notification ADC_Conference ADC_Engagement resourceToken sloAlertSpecs ADC_Identity containerapp keyVaultUrl"},{"u":"/docs/onboarding/devops-runbooks.html#dr-drillyml-and-dr-restore-drillps1-the-adr-009-restore-drill","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"dr-drill.yml and dr-restore-drill.ps1, the ADR-009 restore drill","x":"Files: MMCA.ADC/.github/workflows/dr-drill.yml, MMCA.ADC/scripts/dr-restore-drill.ps1 What it is. The automation behind the drill requirement above: the workflow picks a target…","i":"workflow_dispatch SourceDatabase ADC_Identity deploy.needs deploy.yml AtlDevCon finally restore Online status exit show"},{"u":"/docs/onboarding/devops-runbooks.html#infraoperationsmd-day-2-alert-triage-runbook","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"infra/OPERATIONS.md, day-2 alert triage runbook","x":"File: MMCA.ADC/infra/OPERATIONS.md What it is. The alert-to-action companion to the provisioned observability: what to do when each SLO alert fires, how to read the SLO workbook,…","i":"MMCA.Common.Testing.Architecture ObservabilityConventionTestsBase legacySloMetricAlertSpecs infra.OPERATIONS.md MinimumAlertSpecs infra.main.bicep OPERATIONS.md sloAlertSpecs ALERT_EMAIL AppTraces sloAlerts resource"},{"u":"/docs/onboarding/devops-runbooks.html#infrasql-managed-identitymd-staged-passwordless-sql-runbook","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"infra/SQL-MANAGED-IDENTITY.md, staged passwordless-SQL runbook","x":"File: MMCA.ADC/infra/SQL-MANAGED-IDENTITY.md What it is. The runbook for moving the four service apps from SQL-login (password) auth to Entra managed-identity auth against their…","i":"vars.USE_MANAGED_IDENTITY_SQL USE_MANAGED_IDENTITY_SQL SQL_AAD_ADMIN_LOGIN SQL_AAD_ADMIN_OID Directory db_owner EXTERNAL Identity PROVIDER Managed Active CREATE"},{"u":"/docs/onboarding/devops-runbooks.html#infrapost-cutover-atldevcon-downgrademd-archive-downgrade-runbook","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"infra/POST-CUTOVER-atldevcon-downgrade.md, archive downgrade runbook","x":"File: MMCA.ADC/infra/POST-CUTOVER-atldevcon-downgrade.md What it is. A step-by-step runbook for the third and final commit of the database-per-service rollout: downgrading…","i":"maxSizeBytes ProcessedOn deploy.yml main.bicep AtlDevCon capacity against bacpac update query name NULL"},{"u":"/docs/onboarding/devops-runbooks.html#play-store-captureps1-android-screenshot-capture","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"play-store-capture.ps1, Android screenshot capture","x":"File: MMCA.ADC/scripts/play-store-capture.ps1 What it is. A PowerShell 7 script that captures a screenshot from an attached Android device or emulator via adb screencap and saves…","i":"screencap Files shell PATH slug adb png x86"},{"u":"/docs/onboarding/devops-runbooks.html#play-store-composeps1-play-store-screenshot-compositor","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"play-store-compose.ps1, Play Store screenshot compositor","x":"File: MMCA.ADC/scripts/play-store-compose.ps1 What it is. A PowerShell 7 script that reads raw captures from store-assets/play-store/raw/, wraps each into a 1080×1920 branded…","i":"System.Drawing.Common LinearGradientBrush brandTealDark brandCyan brandTeal imageMaxH imageMaxW slug png"},{"u":"/docs/onboarding/devops-runbooks.html#docsmobilereleaserunbookmd-store-submission-runbook","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"Docs/MobileReleaseRunbook.md, store-submission runbook","x":"File: MMCA.ADC/Docs/MobileReleaseRunbook.md What it is. The manual, credential-holding steps around a store submission that code and CI cannot perform, each tagged with when it…","i":"ADC_ANDROID_SIGNING_PASSWORD FileStorage.UploadFailed sha256_cert_fingerprints AndroidSigningStorePass com.ivanball.atldevcon grantAvatarStorageRole AndroidSigningKeyPass deployNotificationHub TargetPlatformVersion InternalServerError Entitlements.plist ivanball.AtlDevCon"},{"u":"/docs/onboarding/devops-runbooks.html#the-database-per-service-cutover-in-full-context","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"The database-per-service cutover in full context","x":"The five database-related artifacts above form a single coherent story, and the resilience artifacts extend it past the cutover: The AtlDevCon database is the thread that runs…","i":"CrossDataSourceDegradeConvention OPERATIONS.md deploy.yml main.bicep AtlDevCon delete NEVER sql"},{"u":"/docs/onboarding/devops-runbooks.html#rubric-tag-summary","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"Rubric tag summary","x":"---","i":"OPERATIONS.md"},{"u":"/docs/onboarding/devops-runbooks.html#not-determinable-from-source","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"Not determinable from source","x":"- ALERTEMAIL variable: DISASTER-RECOVERY.md:55-57 and OPERATIONS.md:8-11 both route alert notifications through the alertEmailAddress action-group receiver fed by the ALERTEMAIL…","i":"alertEmailAddress ALERT_EMAIL"},{"u":"/docs/onboarding/devops-testing.html","d":"Testing Architecture & Solution Composition","k":"Onboarding Guide","x":"Chapter scope note. The tier chapters (tier-00 through the sweep) document every type in the production codebase one by one. Test types are the logged exception: this chapter…","i":"Fact"},{"u":"/docs/onboarding/devops-testing.html#1-solution-composition-and-the-test-runner","d":"Testing Architecture & Solution Composition","k":"Onboarding Guide","t":"1. Solution composition and the test runner","x":"The two deployed apps use the same two-file pattern; MMCA.Common and MMCA.Helpdesk ship a .slnx only, because their solutions are already fast enough not to need a CI subset:…","i":"MMCA.ADC.ServiceBusEmulator.IntegrationTests MMCA.ADC.CrossService.IntegrationTests MMCA.ADC.Architecture.Tests MMCA.Store.Integration.slnf MMCA.ADC.Integration.slnf MMCA.ADC.IntegrationTests DistributedCacheService MMCA.ADC.Services.Tests MMCA.ADC.Gateway.Tests MMCA.ADC.WebAPI.Tests MMCA.Common.API.Tests WebApplicationFactory"},{"u":"/docs/onboarding/devops-testing.html#2-test-project-layout","d":"Testing Architecture & Solution Composition","k":"Onboarding Guide","t":"2. Test project layout","x":"The inventory below is drawn from 00-inventory.md:23-117 (test-assembly counts) and the solution files above. Counts are distinct types per project as reported by the Roslyn…","i":"MMCA.ADC.ServiceBusEmulator.IntegrationTests CurrentEventNotificationScopeProviderTests MMCA.ADC.Conference.Infrastructure.Tests MMCA.ADC.Engagement.Infrastructure.Tests MMCA.ADC.Notification.Application.Tests MMCA.ADC.CrossService.IntegrationTests MMCA.ADC.Identity.Infrastructure.Tests MMCA.ADC.Notification.IntegrationTests MMCA.Common.Infrastructure.Redis.Tests NotificationUserDataExportSectionTests MMCA.ADC.Conference.Application.Tests MMCA.ADC.Engagement.Application.Tests"},{"u":"/docs/onboarding/devops-testing.html#3-shipped-testing-infrastructure-packages","d":"Testing Architecture & Solution Composition","k":"Onboarding Guide","t":"3. Shipped testing-infrastructure packages","x":"MMCA.Common ships four of its fifteen packages as testing infrastructure that downstream apps consume as NuGet references rather than writing their own harness…","i":"AxeOptions.Wcag21AaExceptMudPagerCombobox WebApplicationFactory.ConfigureServices ServiceInfoVersioningContractTestsBase AssertNoAccessibilityViolationsAsync IsAuthenticatedAuthorizationService SqlServerIntegrationTestFixtureBase MutableAuthenticationStateProvider PageExtensions.FillAndVerifyAsync MMCA.Common.Testing.Architecture ProductionHostApplicationFactory AccessibilityViolationException DecoratorPipelineOrderTestsBase"},{"u":"/docs/onboarding/devops-testing.html#4-architecture-fitness-tests-executable-governance","d":"Testing Architecture & Solution Composition","k":"Onboarding Guide","t":"4. Architecture fitness tests, executable governance","x":"[Rubric §34, Architecture Governance & Documentation]: §34 assesses whether architectural decisions are documented, enforced, and kept honest over time; fitness functions are the…","i":"AggregateRoots_ShouldHave_NoPublicConstructors SpecificationsDoNotNavigateToOtherEntities ArchitectureRules.PinnedPackageMajorBelow LayerMap_ModulesDeclareEveryExpectedLayer MassTransit_MustNotExceed_MajorVersion8 CoreLayers_ShouldNotDependOn_Transport ImageSharp_MustNotExceed_MajorVersion3 ObservabilityConventionTestsBaseTests Infrastructure_ShouldNotDependOn_Api ConstructorDependencyCountTestsBase DomainFactories_ShouldReturn_Result FakeDependentModuleConformanceTests"},{"u":"/docs/onboarding/devops-testing.html#5-integration-and-e2e-strategy","d":"Testing Architecture & Solution Composition","k":"Onboarding Guide","t":"5. Integration and E2E strategy","x":"The four integration test projects (Identity, Conference, Engagement, Notification) each boot their service in-process with WebApplicationFactory . The lifecycle is not written…","i":"MMCA.Store.ServiceBusEmulator.IntegrationTests MMCA.ADC.ServiceBusEmulator.IntegrationTests MMCA.Store.CrossService.IntegrationTests MMCA.ADC.CrossService.IntegrationTests MMCA.Common.Infrastructure.Redis.Tests AssertNoAccessibilityViolationsAsync IntegrationTestBase.InitializeAsync SqlServerIntegrationTestFixtureBase MMCA.Common.Infrastructure.Tests IdentityIntegrationTestFixture appsettings.Development.json DatabaseInitStrategy.Migrate"},{"u":"/docs/onboarding/devops-testing.html#6-worked-examples","d":"Testing Architecture & Solution Composition","k":"Onboarding Guide","t":"6. Worked examples","x":"Three examples tie the infrastructure above to real test code. The per-repo class is a bare subclass; the facts, the package lists and the parsing live once in the shared base:…","i":"IdentityIntegrationTestFixture.DisposeAsync ImageSharp_MustNotExceed_MajorVersion3 IntegrationTestBase.InitializeAsync MutableAuthenticationStateProvider IntegrationTestBase.DisposeAsync IdentityIntegrationTestFixture AuthenticationStateProvider GetAuthenticationStateAsync IdentityIntegrationTestBase Fixture.ResetDatabaseAsync Directory.Packages.props AuthenticateAsAttendee"},{"u":"/docs/onboarding/devops-testing.html#7-the-tiers-and-the-gates-that-run-them","d":"Testing Architecture & Solution Composition","k":"Onboarding Guide","t":"7. The tiers and the gates that run them","x":"A test tier only means something once you know what it blocks. This is the map. MMCA.Common's ui-e2e job (MMCA.Common/.github/workflows/ci.yml:228) builds the out-of-slnx gallery…","i":"Integration.slnf MemoryDiagnoser E2E_BROWSER browsers chromium coverage CI.slnf e2e.yml firefox skipped success deploy"},{"u":"/docs/onboarding/devops-testing.html#quick-reference-rubric-categories-touched-in-this-chapter","d":"Testing Architecture & Solution Composition","k":"Onboarding Guide","t":"Quick reference: rubric categories touched in this chapter","x":"---"},{"u":"/docs/onboarding/devops-testing.html#cross-links","d":"Testing Architecture & Solution Composition","k":"Onboarding Guide","t":"Cross-links","x":"- Primer: 00-primer.md5-the-solution--test-layout , solution files, MTP runner, slnx-excluded UI projects - Primer:…","i":"MMCA.ADC.Integration.slnf IIntegrationTestFixture"},{"u":"/docs/onboarding/99-coverage-audit.html","d":"Phase 4, Coverage Audit","k":"Onboarding Guide","x":"This audit reconciles the written guide against the mechanically-extracted inventory, logs every deliberate exception, verifies the grouping/ordering rules, proves all 34 rubric…","i":"classify.ps1 verify.ps1 plan.ps1"},{"u":"/docs/onboarding/99-coverage-audit.html#1-coverage-reconciliation","d":"Phase 4, Coverage Audit","k":"Onboarding Guide","t":"1. Coverage reconciliation","x":"Cross-check result: verify.ps1 confirms 0 of the 1,890 individually-sectioned types are missing from their group chapter, every one appears as a heading or in a sibling-family…","i":"SpecificationsDoNotNavigateToOtherEntities UserSessionBookmarkCacheEvictionHandler MMCA.Common.Infrastructure.Redis.Tests DatabaseInitializationExtensionsTests HttpContextExternalLoginEmailVerifier MMCA.ADC.Conference.Application.Tests MMCA.ADC.Engagement.Application.Tests ObservabilityConventionTestsBaseTests OwnSessionQuestionAnswerSpecification AnonymousAuthenticationStateProvider AzureNotificationHubNativePushSender CancellationTokenConventionTestsBase"},{"u":"/docs/onboarding/99-coverage-audit.html#2-exceptions-log-every-deliberate-omission-with-reason","d":"Phase 4, Coverage Audit","k":"Onboarding Guide","t":"2. Exceptions log (every deliberate omission, with reason)","x":"EF Core migrations (/Migrations/, .Migrations.SqlServer), ModelSnapshot, .Designer.cs, .g.cs, GlobalUsings.g.cs, and AssemblyInfo.cs are excluded by rule (Tools/invtool…","i":"ObservabilityConventionTestsBase ProductionHostApplicationFactory RouteAuthorizationTestsBase ModuleConformanceTestsBase DependencyInjectionAssert GracefulShutdownTestsBase MMCA.Common.Benchmarks Migrations.SqlServer Testing.Architecture MMCA.Common.Testing GlobalUsings.g.cs AssemblyInfo.cs"},{"u":"/docs/onboarding/99-coverage-audit.html#3-grouping--ordering-verification","d":"Phase 4, Coverage Audit","k":"Onboarding Guide","t":"3. Grouping & ordering verification","x":"- Every type in exactly one group. classify.ps1 assigns all 3,465 nodes via name-level overrides (for the grab-bag MMCA.Common.Interfaces/Services namespaces) + ordered…","i":"MidSaveContextCreatingDbContext OutboxRoutingTestDbContext ReentrantSaveInterceptor FailingSaveInterceptor INavigationPopulator ResultGrpcExtensions EntityQueryService SelfHttpWarmupTask ApiControllerBase DeferredDispatch ErrorHttpMapping _typemap.tsv"},{"u":"/docs/onboarding/99-coverage-audit.html#4-rubric-coverage-matrix","d":"Phase 4, Coverage Audit","k":"Onboarding Guide","t":"4. Rubric coverage matrix","x":"Every one of the 34 categories is explained at least once against real code. \"First explained in\" is the earliest group chapter (by order) that tags it; many recur and several…","i":"ThemeService verify.ps1 token"},{"u":"/docs/onboarding/99-coverage-audit.html#5-open-questions--not-determinable-from-source","d":"Phase 4, Coverage Audit","k":"Onboarding Guide","t":"5. Open questions / not determinable from source","x":"1. IDbSeeder host invocation (group-07). The seeding contract and implementations are in MMCA.Common, but the IHostedService/startup invoker that actually runs seeding at boot…","i":"MMCA.ADC.Identity.Contracts.DependencyInjection ModuleApplicationDbContext CrossSourceSpecification ReadRepositoryExtensions EntityTypeConfiguration DependencyInjection DbContexts.Factory ChangePassword ExportUserData IHostedService EnsureCreated IUnitOfWork"},{"u":"/docs/onboarding/99-coverage-audit.html#6-how-to-regenerate-this-audit","d":"Phase 4, Coverage Audit","k":"Onboarding Guide","t":"6. How to regenerate this audit","x":"Then copy the refreshed out/00-inventory.md and out/00-dependency-manifest.md into Docs/Onboarding/ (the 00-group-taxonomy.md is written there directly by classify.ps1).","i":"classify.ps1"},{"u":"/docs/onboarding/CONCEPT-MAPS.html","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","x":"Mermaid diagrams distilled from the Onboarding guide (primer, group taxonomy, dependency manifest, and the 27 group chapters). Each diagram captures a relationship between the…"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#1-system-context-two-codebases--the-15-packages","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"1. System context, two codebases + the 15 packages","x":"MMCA.Common is a framework published as fifteen NuGet packages in lockstep, to nuget.org and GitHub Packages from one tag (ADR-053); MMCA.ADC and MMCA.Store consume them. The…","i":"MMCA.Common.slnx MMCA.Common MMCA.Store MMCA.ADC UI.Maui"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#2-clean-architecture-the-layered-dependency-rule","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"2. Clean Architecture, the layered dependency rule","x":"Source dependencies point inward toward the Domain; each layer references only layers below it. Deliberate exceptions: UI and Grpc depend on Shared only (UI for Blazor WASM…","i":"ProjectReference UI.Maui Aspire Blazor bridge depend Shared above host only sits and"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#3-the-27-functional-groups-dependency--build-order","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"3. The 27 functional groups, dependency / build order","x":"The primary axis of the guide: every type lives in exactly one of 27 chapter groups, ordered roughly topologically. Foundational, widely-depended-on concerns first (Result →…"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#4-core-framework-patterns-how-the-building-blocks-compose","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"4. Core framework patterns, how the building blocks compose","x":"The pattern-level view of the same backbone: the ideas the primer commits to and how they feed each other. Result is the pervasive currency; DDD blocks produce domain events;…","i":"Result"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#5-request-lifecycle-the-cqrs-decorator-pipeline-adr-014","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"5. Request lifecycle, the CQRS decorator pipeline (ADR-014)","x":"Handlers are thin (one method); every cross-cutting concern is a decorator wrapping the next. Scrutor TryDecorate composes them in reverse registration order (last registered =…","i":"AddApplicationDecorators TryDecorate"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#6-event-driven-integration-outbox-dual-dispatch-adr-003--010--021","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"6. Event-driven integration, outbox dual-dispatch (ADR-003 / 010 / 021)","x":"Domain events are captured into an OutboxMessage row in the same transaction as the data (no dual-write bug). The two event kinds then part ways: local domain events are…","i":"IIntegrationEventPublisher IEventBus.PublishAsync OutboxProcessor OutboxMessage SchemaVersion IMessageBus MessageId"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#7-modular-monolith--extractable-services-adr-006--007--008--012","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"7. Modular monolith → extractable services (ADR-006 / 007 / 008 / 012)","x":"Modules implement IModule and are discovered + Kahn-ordered by ModuleLoader (ADR-059). The same module code runs as a single monolith host or as N service processes behind a YARP…","i":"MMCA.ADC.WebAPI ModuleLoader IMessageBus IModule"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#8-persistence-database-per-service--polyglot-engines-adr-006--018--030","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"8. Persistence, database-per-service + polyglot engines (ADR-006 / 018 / 030)","x":"One concrete SQLServerDbContext over the abstract ApplicationDbContext, one instance per database. Each entity is engine-agnostic; a single [UseDataSource(engine)] attribute on…","i":"ApplicationDbContext SQLServerDbContext UseDataSource engine"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#9-authentication--authorization-stack","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"9. Authentication & Authorization stack","x":"The auth concern (G08) spans token validation, session cookies, federated sign-in, password hashing, brute-force protection, refresh-token rotation and revocation, and a layered…"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#10-notifications-three-channels-behind-one-send-pipeline-adr-024--044","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"10. Notifications, three channels behind one send pipeline (ADR-024 / 044)","x":"One use case (SendPushNotificationHandler) writes a durable per-user inbox, fires a transient SignalR push, and then an OS-level native push that reaches a backgrounded or killed…","i":"SendPushNotificationHandler MMCA.ADC.Notification SmtpEmailSender IEmailSender"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#11-ui-write-once-render-everywhere--i18n--theming","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"11. UI, write-once render everywhere + i18n + theming","x":"A page is authored once as a Razor component in a per-module UI library; both the Blazor web host (Server + WASM) and the .NET MAUI host reference the same libraries, so it…","i":"IStringLocalizer InteractiveAuto MMCA.Common.UI ThemeService rendermode"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#12-adc-business-modules-bounded-contexts-end-to-end","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"12. ADC business modules, bounded contexts end-to-end","x":"Each ADC module is a vertical slice through all layers. Conference is large enough to split across five chapters (G17-G21); Engagement takes two (G22 session bookmarks, G23 the…"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#13-the-adrs-grouped-by-theme","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"13. The ADRs, grouped by theme","x":"Every accepted ADR in Website/docs-src/adr/, clustered by the concern it governs. That directory's README.md is the canonical index and owns the count and range; this map only…","i":"README.md"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#14-the-34-category-evaluation-rubric","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"14. The 34-category evaluation rubric","x":"The lens the guide tags code against ([Rubric §N]). Scored on two axes: Maturity (0-4, process) and Implementation (0-10, substance). Three parts. ---","i":"Rubric"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#15-how-the-axes-fit-together-reading-map","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"15. How the axes fit together (reading map)","x":"The guide is organized on two axes at once. This ties the diagrams above back to the guide's navigation. --- - Group-to-group arrows in §3 show the dominant \"builds on\" direction…","i":"ApplicationDbContext"},{"u":"/docs/governance/index.html","d":"Architecture Governance","k":"Architecture Governance","x":"The governance artifacts behind the MMCA platform: the shared 34-category evaluation rubric, and each repo's evidence-based scorecard plus its remediation backlog. Every score…"},{"u":"/docs/governance/index.html#the-rubric","d":"Architecture Governance","k":"Architecture Governance","t":"The rubric","x":"- Architecture Evaluation Criteria: the 34-category rubric (Maturity 0-4 and Implementation 0-10 per category) that all three application repos are scored against."},{"u":"/docs/governance/index.html#how-these-are-maintained","d":"Architecture Governance","k":"Architecture Governance","t":"How these are maintained","x":"Scores are re-verified from source on a cadence: each category is scored by reading the current code, config, and CI (never rolled forward), and any change lands with the…"},{"u":"/docs/governance/adc-ArchitectureScorecard.html","d":"MMCA.ADC: Architecture Scorecard","k":"Architecture Governance","x":"Canonical, version-controlled scorecard for this repo: the single source of truth for MMCA.ADC's architecture scores (replaces the former single-axis snapshot; see git history).…","i":"dotnet_analyzer_diagnostic.severity SessionSelectionDashboard.razor.cs ArchitectureEvaluationCriteria.md MMCA.ADC.Notification.Application MMCA.Common.Testing.Architecture UIArchitectureConventionTests.cs StateManagementConventionTests SQLitePCLRaw.bundle_e_sqlite3 UIArchitectureConventionTests ObservabilityConventionTests Event.OrganizerContactEmail PseudoLocalizationTests"},{"u":"/docs/governance/adc-ArchitectureScorecard.html#executive-summary","d":"MMCA.ADC: Architecture Scorecard","k":"Architecture Governance","t":"Executive summary","x":"MMCA.ADC is a mature, evidence-driven .NET 10.0 (LangVersion preview) DDD/Clean Architecture system for the Atlanta Developers Conference, deliberately extracted from a modular…","i":"MMCA.ADC.CrossService.IntegrationTests MMCA.ADC.Notification.IntegrationTests SessionIncludeChildrenRegressionTests UIArchitectureConventionTestsBase FrameworkVersionConsistencyTests LocalizedTextConventionTestsBase MMCA.Common.Testing.Architecture ErrorMessages.ValidationError LocalizedTextConventionTests ObservabilityConventionTests SpecificationConventionTests BlazorCspPolicyProvider.cs"},{"u":"/docs/governance/adc-ArchitectureScorecard.html#scorecard","d":"MMCA.ADC: Architecture Scorecard","k":"Architecture Governance","t":"Scorecard","x":"Weighted column = Maturity·weight / Implementation·weight per row. Axis-gap findings: the former §21 gap (excellent-but-not-enforced, impl 7 over maturity 2) closed on 2026-07-02…","i":"MmcaCultureBootstrap.SetBrowserCultureAsync ResilienceCircuitBreakerFaultInjectionTests MMCA.ADC.CrossService.IntegrationTests dotnet_analyzer_diagnostic.severity FrameworkVersionConsistencyTests.cs StateManagementConventionTestsBase MMCA.ADC.Notification.Application UIArchitectureConventionTestsBase MMCA.Common.Testing.Architecture ConstructorDependencyCountTests LocalizedTextConventionTests.cs ObservabilityConventionTests.cs"},{"u":"/docs/governance/adc-ArchitectureScorecard.html#indices","d":"MMCA.ADC: Architecture Scorecard","k":"Architecture Governance","t":"Indices","x":"- Maturity index = Σ(maturity×weight) ÷ Σ(weight×4) = 311 ÷ 320 = 97.2% (down from 97.8%, 313/320: the twenty-second-cycle full re-score (2026-07-21, pin v1.121.0) corrected §22…","i":"OrganizerContactEmail"},{"u":"/docs/governance/adc-ArchitectureScorecard.html#top-5-strengths","d":"MMCA.ADC: Architecture Scorecard","k":"Architecture Governance","t":"Top 5 strengths","x":"1. Architecture intent is executable, not prose: 29 architecture-test classes / 91 methods gate CI (§34 Governance, mat 4 / impl 9, and §3 Clean Architecture, mat 4 / impl 9):…","i":"SessionIncludeChildrenRegressionTests MMCA.Common.Testing.Architecture SpecificationConventionTests.cs AddSessionCookieAuthentication StateManagementConventionTests MicroserviceExtractionTests AddCommonSecurityHeaders ArchitecturalAnalysis.md LayerDependencyTests AddCommonBlazorCsp DataResidencyTests DomainPurityTests"},{"u":"/docs/governance/adc-ArchitectureScorecard.html#top-5-risks","d":"MMCA.ADC: Architecture Scorecard","k":"Architecture Governance","t":"Top 5 risks","x":"Expected-delta note (updated 2026-08-01): several entries below record an expected lift of \"impl 9→10\". Under the 2026-08-01 recalibration those are attainable, not aspirational:…","i":"publicNetworkAccess packages.lock.json MMCA.ADC.CI.slnf deploy.needs maxReplicas MMCA.ADC.UI CI.slnf"},{"u":"/docs/governance/adc-ArchitectureScorecard.html#cross-repo-comparison","d":"MMCA.ADC: Architecture Scorecard","k":"Architecture Governance","t":"Cross-repo comparison","x":"How this repo's quality relates to the other consumers (where the framework's quality propagates, where a repo diverges or under-uses it, and where a consumer does better than…"},{"u":"/docs/governance/adc-RemediationBacklog.html","d":"MMCA.ADC: Architecture Remediation Backlog","k":"Architecture Governance","x":"Derived from ArchitectureScorecard.md (single-axis 0-4, baseline 75%, 241/320, dated 2026-06-08). Current authoritative two-axis scores (twenty-seventh-cycle full re-score,…","i":"MMCA.ADC.CrossService.IntegrationTests SqlServerIntegrationTestFixtureBase SessionSelectionDashboard.razor.cs MMCA.ADC.Notification.Application FrameworkVersionConsistencyTests StateManagementConventionTests UIArchitectureConventionTests IntegrationTestReworkPlan.md LocalizedTextConventionTests ObservabilityConventionTests TranslationCompletenessTests Event.OrganizerContactEmail"},{"u":"/docs/governance/adc-RemediationBacklog.html#priority-6-highest-leverage","d":"MMCA.ADC: Architecture Remediation Backlog","k":"Architecture Governance","t":"🔴 Priority 6: highest leverage","x":"Token handling uses two rubric-named anti-patterns, with no CSP defense-in-depth. Status (2026-06-27): cookie-only refresh + in-memory access (auth-path BFF), OAuth…","i":"ResilienceCircuitBreakerFaultInjectionTests DisconnectedCircuitRetentionPeriod ManagementRouteAuthorizationTests GatewaySecurityHeadersMiddleware E2E_LIFT_REGISTRATION_THROTTLE OAuthController.CompleteAsync OAuthController.ExchangeAsync SameOriginProxyTokenRefresher MMCA.ADC.Conference.UI.Tests AuthenticationStateProvider EventDetailPage.StatusChip InvalidOperationException"},{"u":"/docs/governance/adc-RemediationBacklog.html#priority-4","d":"MMCA.ADC: Architecture Remediation Backlog","k":"Architecture Governance","t":"🟠 Priority 4","x":"The (4−score)×weight formula puts this at 4, but the High flag is a contractual/regulatory exposure that contradicts a shipped, publicly-served policy: treat it as do-soon. -…","i":"user_notification_export.proto LocalizedTextConventionTests TranslationCompletenessTests user_engagement_export.proto ExportUserDataHandlerTests ErasureAndPiiLoggingTests DeleteUserHandlerTests ErrorMessages.Success SessionQuestionAnswer User.PreferredCulture UserRegisteredHandler EventQuestionAnswer"},{"u":"/docs/governance/adc-RemediationBacklog.html#priority-3-score-3-weight-3-one-rung-from-a-4","d":"MMCA.ADC: Architecture Remediation Backlog","k":"Architecture Governance","t":"🟡 Priority 3: score 3, weight 3 (one rung from a 4)","x":"- ~~(High) The 258-test Testcontainers integration tier references the deleted MMCA.ADC.WebAPI host, won't build, and is excluded.~~ RESOLVED: reworked as per-service…","i":"Update_WithStaleRowVersion_ReturnsConflict MMCA.ADC.CrossService.IntegrationTests SessionSelectionDashboard.razor.cs StateManagementConventionTestsBase ManagementRouteAuthorizationTests UIArchitectureConventionTestsBase InProcessEventBus.PublishAsync SessionSelectionSpeakerOverlap StateManagementConventionTests UIArchitectureConventionTests DbUpdateConcurrencyException PublicSessionList.razor.cs"},{"u":"/docs/governance/adc-RemediationBacklog.html#priority-2-score-3-weight-2-polish--hardening","d":"MMCA.ADC: Architecture Remediation Backlog","k":"Architecture Governance","t":"🟢 Priority 2: score 3, weight 2 (polish / hardening)","x":"- ~~(Medium) No OpenAPI served by any running service, yet CLAUDE.md still advertises 4 doc UIs (/swagger, /nswag-swagger, /api-docs, /scalar/v1).~~ - [x] Serve OpenAPI per…","i":"Microsoft.AspNetCore.Authorization.Authorize AuthorizationPolicies.RequireOrganizer MMCA.ADC.Conference.IntegrationTests ManagementRouteAuthorizationTests FrameworkVersionConsistencyTests IdentityRouteAuthorizationTests IntegrationEventContractTests MMCA.ADC.Migrations.SqlServer Microsoft.AspNetCore.OpenApi ObservabilityConventionTests MicroserviceExtractionTests Validation.CorrectFollowing"},{"u":"/docs/governance/adc-RemediationBacklog.html#implementation-band-implementation--8-ranked-by-implpriority","d":"MMCA.ADC: Architecture Remediation Backlog","k":"Architecture Governance","t":"🔵 Implementation band (implementation <= 8, ranked by implPriority)","x":"Added 2026-07-28 when the ledger gained its second ranked axis. Until then implementation gaps appeared only as unranked sub-bullets inside maturity items, so they were never…","i":"SessionSelectionDashboard.razor.cs MMCA.ADC.Notification.Application AdcArchitectureMap.DefineLayers ConferenceCategoryDetail.razor PublicSessionList.razor.cs needs.changes.outputs.ui PrimitivesSnapshotTests MMCA.Common.Testing.UI OrganizerContactEmail TreatWarningsAsErrors DeviceSettings.razor EventDetail.razor.cs"},{"u":"/docs/governance/adc-RemediationBacklog.html#resolved-2026-07-25-performance-program-2","d":"MMCA.ADC: Architecture Remediation Backlog","k":"Architecture Governance","t":"🟢 Resolved 2026-07-25 (performance program 2)","x":"Second evidence-led performance pass over Common/ADC/Store. ADC's share shipped as three PRs plus the v1.127.0 framework sweep. - [x] Output cache shared across replicas.…","i":"Session.SessionQuestionAnswers Event.EventQuestionAnswers SessionQuestionViewBuilder CategoryItemLookupService SessionScoringProcessor SpeakerDashboardService SessionQuestionAnswers EventQuestionAnswers SessionCategoryItems SpeakerCategoryItems GetOpenPollsHandler PublicSessionDetail"},{"u":"/docs/governance/adc-RemediationBacklog.html#deliberate--accepted-recorded-decisions-not-scheduled-work","d":"MMCA.ADC: Architecture Remediation Backlog","k":"Architecture Governance","t":"Deliberate / accepted (recorded decisions, not scheduled work)","x":"Conscious, recorded choices, not pending work (the former TECHDEBT.md accepted-risk section): - Single-region deployment (no multi-region failover): accepted in…","i":"SessionRoomScheduling.ValidateRoomAssignmentAsync MMCA.ADC.Notification.Application ConstructorDependencyCountTests LocalizedTextConventionTests TranslationCompletenessTests ArchitecturalAnalysis.md PseudoLocalizationTests AuthenticationService OrganizerContactEmail BrandColorTokenTests DeviceSettings.razor skip_freshness_gates"},{"u":"/docs/governance/adc-RemediationBacklog.html#already-at-level-4-protect-dont-regress","d":"MMCA.ADC: Architecture Remediation Backlog","k":"Architecture Governance","t":"✅ Already at level 4: protect, don't regress","x":"1 SOLID · 2 Design Patterns · 3 Clean Architecture · 4 Domain-Driven Design · 5 Vertical Slice Architecture · 6 CQRS & Event-Driven · 7 Microservices Readiness · 8 Data…","i":"AnthropicScoringService.ScoreSessionAsync MMCA.ADC.CrossService.IntegrationTests GetSessionSelectionDashboardHandler SessionSelectionDashboard.razor.cs GetSpeakerSessionOverlapHandler GetCategoryDistributionHandler Session.AddSessionCategoryItem Session.CategoryItem.Duplicate Speaker.AddSpeakerCategoryItem Speaker.CategoryItem.Duplicate ObservabilityConventionTests OperationCanceledException"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html","d":"Architecture Evaluation Criteria","k":"Architecture Governance","x":"A structured rubric for evaluating the architecture of an enterprise application. Each category defines what is being assessed, concrete criteria to check, red flags that signal…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#how-to-use-this-rubric","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"How to Use This Rubric","x":"Score each category 0–4. Use the same scale everywhere so totals are comparable. Alongside the maturity level, rate how well each category is actually implemented on a finer 0–10…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#1-solid-principles","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"1. SOLID Principles","x":"Intent: Object/module-level design discipline that keeps code flexible and decoupled. Criteria - SRP: each class/handler has one reason to change; no \"god\" services orchestrating…","i":"NotSupportedException switch new"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#2-design-patterns","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"2. Design Patterns","x":"Intent: Appropriate, idiomatic use of patterns, solving real problems, not pattern theater. Criteria - Creational (Factory methods on entities, Builder, Options) used where…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#3-clean-architecture","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"3. Clean Architecture","x":"Intent: Dependencies point inward; business rules are independent of frameworks, UI, and data stores. Criteria - Dependency rule enforced: Domain → (nothing); Application →…","i":"JsonProperty DbContext Table"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#4-domain-driven-design","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"4. Domain-Driven Design","x":"Intent: The model reflects the business; boundaries follow capability boundaries, not technical layers. Criteria - Ubiquitous language: type/method names match business terms…","i":"decimal Result string Guid"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#5-vertical-slice-architecture","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"5. Vertical Slice Architecture","x":"Intent: Code is organized by feature/capability, so a change touches one cohesive slice. Criteria - Features grouped by use case (command/query + handler + validator + DTO…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#6-cqrs--event-driven-design","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"6. CQRS & Event-Driven Design","x":"Intent: Reads and writes are separated where it pays off; integration via events is reliable. Criteria - Commands (mutate, return Result) and queries (read, side-effect-free) are…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#7-microservices-readiness","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"7. Microservices Readiness","x":"Intent: Whether services (or future-extractable modules) are independently deployable and own their data. Criteria - Service boundaries align with bounded contexts; one team can…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#8-data-architecture","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"8. Data Architecture","x":"Intent: Persistence, consistency, and migrations are deliberate and safe. Criteria - Transaction boundaries match aggregate boundaries; unit-of-work scope is clear. - Migrations…","i":"Include"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#9-api--contract-design","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"9. API & Contract Design","x":"Intent: External and inter-service contracts are clear, stable, and evolvable. Criteria - Consistent resource/endpoint design (REST/minimal APIs/gRPC) with predictable shapes. -…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#10-cross-cutting-concerns","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"10. Cross-Cutting Concerns","x":"Intent: Validation, caching, resilience, configuration, and mapping are centralized and consistent. Criteria - Validation, logging, transactions handled by pipeline…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#11-security","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"11. Security","x":"Intent: AuthN/AuthZ, secrets, and data protection are correct by construction. Criteria - Authentication centralized; tokens validated; identity flows documented (e.g.,…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#12-performance--scalability","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"12. Performance & Scalability","x":"Intent: The system meets latency/throughput goals and scales horizontally. Criteria - Async I/O throughout; no sync-over-async; no blocking the request thread. - Hot-path query…","i":"Result Wait"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#13-observability--operability","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"13. Observability & Operability","x":"Intent: You can understand and operate the system in production. Criteria - Structured logging with correlation/trace IDs flowing across module/service boundaries. - Distributed…","i":"Console.WriteLine"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#14-testability--test-strategy","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"14. Testability & Test Strategy","x":"Intent: The design supports fast, reliable, meaningful tests at the right levels. Criteria - Healthy test pyramid: many fast unit tests on domain/application, fewer integration,…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#15-best-practices--code-quality","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"15. Best Practices & Code Quality","x":"Intent: Day-to-day craftsmanship that keeps the codebase healthy. Criteria - Analyzers at error severity (style, security, threading, maintainability) enforced in CI;…","i":"disable warning pragma"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#16-maintainability--evolvability","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"16. Maintainability & Evolvability","x":"Intent: The system absorbs change cheaply and ages well. (The governance/documentation depth behind this (ADRs, fitness functions, diagrams) is scored separately in §34.)…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#17-devops--deployment","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"17. DevOps & Deployment","x":"Intent: Building, releasing, and provisioning are automated, repeatable, and safe. (The local developer experience / inner loop behind this (local orchestration, cross-repo dev,…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#18-ui-architecture--component-design","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"18. UI Architecture & Component Design","x":"Intent: Components are cohesive, reusable, and composed cleanly, the UI has a deliberate structure, not page-sized blobs. Criteria - Container/presentational split: smart…","i":"EventCallback ShouldRender razor key"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#19-state-management--data-flow","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"19. State Management & Data Flow","x":"Intent: Client state has a clear owner and predictable flow; server state is cached and invalidated deliberately. Criteria - Single source of truth per piece of state; ownership…","i":"StateHasChanged IsDirty"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#20-design-system-theming--ui-consistency","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"20. Design System, Theming & UI Consistency","x":"Intent: A coherent visual language enforced by a component library, not re-implemented per screen. Criteria - Component library used consistently (e.g., MudBlazor): teams build…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#21-accessibility-a11y","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"21. Accessibility (a11y)","x":"Intent: The UI is usable by everyone, including assistive-technology users, and ideally enforced, not aspirational. Criteria - Semantic structure: correct…","i":"span div"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#22-responsive-design--cross-browserdevice","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"22. Responsive Design & Cross-Browser/Device","x":"Intent: The UI works across viewport sizes, input modes, and supported browsers. Criteria - Fluid/responsive layouts via the design system's grid/breakpoints; no fixed-width…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#23-front-end-performance--rendering","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"23. Front-End Performance & Rendering","x":"Intent: The UI loads and responds fast; rendering work is bounded. (Complements §12: this is the client side.) Criteria - Initial load: bundle/payload size controlled;…","i":"ShouldRender key"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#24-forms-validation--ux-safety","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"24. Forms, Validation & UX Safety","x":"Intent: Data entry is safe, forgiving, and consistent, users don't lose work or get confused by errors. Criteria - Validation parity: client-side validation for fast feedback…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#25-navigation-routing--information-architecture","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"25. Navigation, Routing & Information Architecture","x":"Intent: Users can find their way; routes are meaningful, guarded, and role-aware. Criteria - Route design: clean, bookmarkable, deep-linkable URLs; parameters typed and…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#26-front-end-security","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"26. Front-End Security","x":"Intent: The client doesn't become the weak link, XSS, token handling, and trust boundaries are correct. (Complements §11.) Criteria - Output encoding / XSS: no unsanitized HTML…","i":"MarkupString innerHTML"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#27-internationalization--localization","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"27. Internationalization & Localization","x":"Intent: The UI can be translated and respects culture, if in scope. (Score weight 0–1 if single-locale by design.) Criteria - Externalized strings: UI text in resource files, not…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#28-front-end-testing--quality","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"28. Front-End Testing & Quality","x":"Intent: The UI is verified at the right levels with stable, meaningful tests. (Complements §14.) Criteria - Component tests (e.g., bUnit) for rendering logic, parameters, events,…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#29-resilience-reliability--business-continuity","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"29. Resilience, Reliability & Business Continuity","x":"Intent: The system survives partial failure and recovers from disaster within defined objectives. (Extends the resilience facets of §7/§12 into a first-class recovery story.)…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#30-compliance-privacy--data-governance","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"30. Compliance, Privacy & Data Governance","x":"Intent: Personal and regulated data is classified, governed, and handled lawfully across its lifecycle. (§11 defends against attackers; this answers to regulators.) Criteria -…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#31-cost-efficiency--finops","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"31. Cost Efficiency / FinOps","x":"Intent: Cloud spend is proportional to value and driven by data, not guesswork. (§17 mentions cost; this makes it a first-class axis.) Criteria - Right-sizing: compute/database…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#32-dependency--supply-chain-management","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"32. Dependency & Supply-Chain Management","x":"Intent: Third-party and inter-package dependencies are controlled, auditable, and evolve safely, especially critical for a framework that publishes packages. (Elevates §15's…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#33-developer-experience--inner-loop","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"33. Developer Experience & Inner Loop","x":"Intent: Developers build, run, test, and iterate locally with fast, low-friction feedback. (Promoted out of §17: that scores release/ops automation; this scores the inner loop.)…","i":"editorconfig local.props"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#34-architecture-governance--documentation","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"34. Architecture Governance & Documentation","x":"Intent: Decisions are recorded, conformance is enforced, and the system is documented so it stays coherent as it evolves. (Promoted out of §16: that scores the property of…","i":"CLAUDE.md"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#appendix-quick-scan-checklist","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"Appendix: Quick-Scan Checklist","x":"A 2-minute triage before the full evaluation: any \"no\" warrants a deeper look. - [ ] Can you draw the dependency graph and is it acyclic and inward-pointing? - [ ] Is the domain…"},{"u":"/docs/governance/common-ArchitectureScorecard.html","d":"MMCA.Common: Architecture Scorecard","k":"Architecture Governance","x":"Canonical, version-controlled scorecard for this repo: the single source of truth for MMCA.Common's architecture scores (replaces the former single-axis snapshot; see git…","i":"ResilienceCircuitBreakerFaultInjectionTests SpecificationsDoNotNavigateToOtherEntities CrossSourceSpecification.BuildAsync BaseIntegrationEvent.SchemaVersion AggregateRootEntityControllerBase ArchitectureEvaluationCriteria.md DomainInvariantViolationException LocalizedTextConventionTestsBase MMCA.Common.Testing.Architecture OpenIdConnectMetadataWarmupTask ResourceTranslationsAreComplete EventVersioningConventionTests"},{"u":"/docs/governance/common-ArchitectureScorecard.html#scorecard","d":"MMCA.Common: Architecture Scorecard","k":"Architecture Governance","t":"Scorecard","x":"Weighted column = Maturity·weight / Implementation·weight per row. Axis-gap findings: §17/§8 are mature-but-execution-deferred (mechanism shipped, deeper proof lives downstream);…","i":"SpecificationsDoNotNavigateToOtherEntities CrossSourceSpecification.BuildAsync BaseIntegrationEvent.SchemaVersion TransportDoesNotLeakIntoCoreLayers ArchitectureEvaluationCriteria.md CrossDataSourceDegradeConvention MMCA.Common.Testing.Architecture OpenIdConnectMetadataWarmupTask SoftDeleteUniqueIndexConvention EventVersioningConventionTests ListPageQueryStateServiceTests PermissionAuthorizationHandler"},{"u":"/docs/governance/common-ArchitectureScorecard.html#indices","d":"MMCA.Common: Architecture Scorecard","k":"Architecture Governance","t":"Indices","x":"- Maturity index = Σ(maturity×weight) ÷ Σ(weight×4) = 316 ÷ 324 = 97.5% (up from 96.9% (314/324) on the targeted 2026-08-22 update: §9 API & Contract Design Maturity 3→4 on…","i":"ServiceContract"},{"u":"/docs/governance/common-ArchitectureScorecard.html#top-5-strengths","d":"MMCA.Common: Architecture Scorecard","k":"Architecture Governance","t":"Top 5 strengths","x":"1. Dual-enforced Clean Architecture dependency rule (compile-time + fitness functions), §3 (impl 9): Source/Build/MMCA.Common.LayerEnforcement.targets:1-90 fails the build on…","i":"BaseIntegrationEvent.SchemaVersion MMCA.Common.Testing.Architecture EventVersioningConventionTests ResolveProjectReferences packages.lock.json FixedTimeEquals Result.Failure BeforeTargets Theory Fact"},{"u":"/docs/governance/common-ArchitectureScorecard.html#top-5-risks","d":"MMCA.Common: Architecture Scorecard","k":"Architecture Governance","t":"Top 5 risks","x":"Note (twenty-first wave, v1.121.0): earlier waves closed risks previously listed here (§29's restore drill, the §27 i18n train, §24 forms enforcement, §22's firefox gate, §23's…","i":"PiiErasureContractFitnessTests ServiceContractPurityTestsBase OutboxPollFilterProcessor NavigationContractTests required_status_checks PiiConventionTests CONTRIBUTING.md ServiceContract DEPLOYMENT.md IAnonymizable PiiRedactor COST.md"},{"u":"/docs/governance/common-ArchitectureScorecard.html#cross-repo-comparison","d":"MMCA.Common: Architecture Scorecard","k":"Architecture Governance","t":"Cross-repo comparison","x":"How this repo's quality relates to the other consumers (where the framework's quality propagates, where a repo diverges or under-uses it, and where a consumer does better than…"},{"u":"/docs/governance/common-RemediationBacklog.html","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","x":"Derived from ArchitectureScorecard.md (canonical two-axis scoring: Maturity 97.5% / Implementation 84.8%, framework v1.160.0. Twenty-eighth-wave full re-score, 2026-08-23 (git…","i":"ServiceContractPurityTestsBase ArchitectureScorecard.md required_status_checks RedisDistributedLock IDistributedLock BenchmarkDotNet IsDirtyAccessor ServiceContract Performance baseline c911480 d12cc4d"},{"u":"/docs/governance/common-RemediationBacklog.html#implementation-band-implementation--8-ranked-by-implpriority","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Implementation band (implementation <= 8, ranked by implPriority)","x":"Added 2026-07-28 when the ledger gained its second ranked axis. Until then implementation gaps were never ranked or scheduled, which is why consecutive steady-state cycles moved…","i":"ArchitecturalAnalysis.md"},{"u":"/docs/governance/common-RemediationBacklog.html#progress-first-wave-2026-06-08","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress: first wave (2026-06-08)","x":"Implemented in MMCA.Common, ✅ verified 2026-06-09: dotnet build -c Release is clean (0 warnings / 0 errors, all analyzers) and all 9 test projects pass (~1,611 tests, 0…","i":"MessageBusSettings.RetryLimit ConfigureBrokerTransport Directory.Packages.props IntegrationEventConsumer RetryMaxIntervalSeconds RetryMinIntervalSeconds DependencyVersionTests OutboxCleanupService UseMessageRetry MobileCardList BunitTestBase IAnonymizable"},{"u":"/docs/governance/common-RemediationBacklog.html#progress-second-wave-2026-06-09","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress: second wave (2026-06-09)","x":"✅ Verified: dotnet build -c Release clean (0/0) and all 9 test projects pass (1,511 tests, 0 failures). - ✅ 32 / 16: supply-chain. NuGet lock files (RestorePackagesWithLockFile,…","i":"RestorePackagesWithLockFile ServiceContractAttribute nuget.config CqrsMetrics WithMetrics AddMeter package Release dotnet snupkg build list"},{"u":"/docs/governance/common-RemediationBacklog.html#progress-third-wave-front-end-2026-06-09","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress: third wave (front-end, 2026-06-09)","x":"✅ Verified: build clean (0/0) and all 9 test projects pass (1,519 tests, 0 failures); UI tests 90 → 98 (8 new bUnit tests). - ✅ 19: UnsavedChangesGuard live-accessor. Added…","i":"Page.AssertNoAccessibilityViolationsAsync Deque.AxeCore.Playwright MobileInfiniteScrollList UnsavedChangesGuard MaxRenderedItems IsDirtyAccessor CurrentIsDirty PageLoading PageHeader Virtualize MMCATheme PageError"},{"u":"/docs/governance/common-RemediationBacklog.html#progress-fourth-wave-breaking-changes--consumer-sweep-2026-06-09","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress: fourth wave (breaking changes + consumer sweep, 2026-06-09)","x":"✅ Verified across all three repos (built/tested via local.props against Common source, no token): Common 1,523, ADC 1,241, Store 1,088 tests, 0 failures; all CI solutions build…","i":"AggregateConventionTests IntegrationEventConsumer UserNotification.Create EntityConventionTests OutboxCleanupService AddInboxMessages UserNotification BaseDomainEvent NoOpInboxStore InboxMessages EfInboxStore IDomainEvent"},{"u":"/docs/governance/common-RemediationBacklog.html#progress-v1800-2026-06-26","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress: v1.80.0 (2026-06-26)","x":"The single-axis backlog above is from the 2026-06-08/09 review (index 80%). The framework has since reached v1.82.0 and the canonical scoring was the in-repo, two-axis…","i":"PermissionAuthorizationHandler BaseDomainEvent.DateOccurred UserNotification.MarkAsRead PermissionRegistryBuilder AddAuthorizationPolicies ArchitectureScorecard.md GlobalRateLimitPartition PermissionPolicyProvider RateLimitPartitionTests RoleNames.ContentEditor UserNotification.ReadOn IPermissionRegistry"},{"u":"/docs/governance/common-RemediationBacklog.html#progress-v1810v1820--governance-pass-2026-06-26","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress: v1.81.0/v1.82.0 + governance pass (2026-06-26)","x":"Released since v1.80.0 (v1.81.0, v1.82.0) plus a sixth governance pass currently in flight (uncommitted). All of it lands in categories already scored 9-10, so the two-axis…","i":"ArchitectureEvaluationCriteria.md MMCA.Common.Aspire.Security SecurityHeadersMiddleware AddCommonSecurityHeaders ICspPolicyProvider MapCommonScalarUi Scalar.AspNetCore ValidAlgorithms RsaSha256 FACTS.md b9a6a28 COST.md"},{"u":"/docs/governance/common-RemediationBacklog.html#progress-v1830v1840-2026-06-27","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress: v1.83.0/v1.84.0 (2026-06-27)","x":"Released since v1.82.0 (v1.83.0, v1.84.0) plus a docs-only governance pass currently in flight (uncommitted). One score moved at this wave: §30 Implementation 7→8. The canonical…","i":"OpenIdConnectMetadataWarmupTask INotificationRecipientProvider ArchitectureScorecard.md IPushNotificationSender WarmupHostedService WarmupReadinessGate AddServiceDefaults PiiConventionTests PiiRedactorTests UserNotification IWarmupTask PiiRedactor"},{"u":"/docs/governance/common-RemediationBacklog.html#progress-v1850-eighth-wave-under-8-implementation-remediation-2026-06-27","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress, v1.85.0 (eighth wave: under-8 Implementation remediation, 2026-06-27)","x":"The under-8 Implementation remediation (commit 78e5312, tag v1.85.0, HEAD 7082a5f) lifted every category scored Implementation one maturity score. Re-verified against current…","i":"MMCA.Common.Testing.Architecture ArchitectureRules.Slices.cs PasswordComplexityAttribute ArchitectureScorecard.md AuthModelValidationTests DataAnnotationsValidator ServiceContractAttribute TraceIdRatioBasedSampler SliceCohesionTestsBase ParentBasedSampler SliceCohesionTests NavigationFlow.md"},{"u":"/docs/governance/common-RemediationBacklog.html#progress-v1860v1920-ninth-wave-i18n--re-score-2026-06-29","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress, v1.86.0→v1.92.0 (ninth wave: i18n + re-score, 2026-06-29)","x":"Re-scored against current source at framework v1.92.0 (HEAD 93ffcac, dirty tree). Canonical scoring is now Maturity 91.7% / Implementation 84.1% (was 92.8% / 85.0%) per the…","i":"PiiErasureContractFitnessTests WebApplicationExtensions.cs ArchitectureScorecard.md ConfigureBrokerTransport IntegrationEventConsumer User.PreferredCulture UseDelayedRedelivery cfg.UseMessageRetry PiiConventionTests DataSubjectSample PasswordHasher.cs IStringLocalizer"},{"u":"/docs/governance/common-RemediationBacklog.html#progress-tenth-wave-focused-in-repo-remediation-2026-06-30","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress: tenth wave (focused in-repo remediation, 2026-06-30)","x":"Four scores moved up on shipped, tested in-repo evidence; both indices rose for the first time in several waves: Maturity 91.7% → 92.9% (301/324), Implementation 84.1% → 84.9%…","i":"MMCA.Common.Testing.Architecture PaletteDark.PrimaryContrastText ResourceTranslationsAreComplete DatabaseRestoreDrillTests LocalizationResourceTests Directory.Packages.props PrimitivesSnapshotTests SupportedCultures.All PaletteDark.Primary WarningContrastText ErrorContrastText ACCESSIBILITY.md"},{"u":"/docs/governance/common-RemediationBacklog.html#progress-eleventh-wave-adr-governance-2026-06-30","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress: eleventh wave (ADR governance, 2026-06-30)","x":"No score moves. A full 34-category evidence re-score at framework v1.93.0 (HEAD 3e72bfa, dirty tree) re-confirmed every category at its tenth-wave value; indices hold at Maturity…","i":"AggregateRootEntityControllerBase EntityControllerBase OwnerOrAdminFilter OwnershipHelper Specification customer_id FACTS.md"},{"u":"/docs/governance/common-RemediationBacklog.html#progress-twelfth-wave-under-8-implementation-lift-v1940-pending-2026-06-30","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress: twelfth wave (under-8 Implementation lift, v1.94.0 pending, 2026-06-30)","x":"Two Implementation scores move up, Maturity holds: Implementation 84.9% → 85.3% (691/810), Maturity 92.9% (301/324) unchanged. Full Release build clean, 1685 tests pass. Held for…","i":"LocalizedTextConventionTestsBase ListPageQueryStateServiceTests SupportedCultures.PseudoLocale LocalizedTextConventionTests PseudoStringLocalizerFactory UseCommonRequestLocalization PseudoLocalizationE2ETests ListPageStateServiceTests LocalizationResourceTests PseudoLocalizer.Transform IStringLocalizerFactory PseudoLocalizationTests"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---fourteenth-wave-clean-tree-evidence-re-score-at-v11010-2026-07-03","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - fourteenth wave (clean-tree evidence re-score at v1.101.0, 2026-07-03)","x":"A full 34-category, two-pass evidence re-score (per-category scorer plus adversarial verifier) at framework v1.101.0 (HEAD 5e55be2, working tree clean: the recurring…","i":"ArchitectureScorecard.md FormsConventionTestsBase RegisterFormTests.cs Testing.Architecture Scalar.AspNetCore ValidationMessage FACTS.md slnx"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---defect-fix-wave-c-1c-7-2026-07-05","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - defect-fix wave C-1..C-7 (2026-07-05)","x":"Seven approved defect fixes, each behavior change landed with its pinning test flipped (or a new regression test) in the same change; build 0/0 and the full .slnx suite green.…","i":"Microsoft.Extensions.TimeProvider.Testing EntityServiceBase.GetAllForLookupAsync SessionCookieAuthenticationHandler OAuthControllerBase.CompleteAsync AuthenticatedServiceBase ChildEntityServiceBase LoginProtectionService LoggingQueryDecorator ITokenStorageService KeyNotFoundException OutboxCleanupService Uri.EscapeDataString"},{"u":"/docs/governance/common-RemediationBacklog.html#progress-sixteenth-wave-clean-tree-re-score-at-v11060-2026-07-06","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress: sixteenth wave (clean-tree re-score at v1.106.0, 2026-07-06)","x":"A full 34-category, two-pass evidence re-score (per-category scorer plus adversarial verifier) at framework v1.106.0 (HEAD 6f8b917, one commit past the v1.106.0 tag, working tree…","i":"ArchitecturalAnalysis.md ArchitectureScorecard.md Directory.Packages.props EncryptedStringConverter SECURITY.md FACTS.md b75fa8f Theory Fact"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---seventeenth-wave-evidence-re-score-at-v11080-2026-07-09","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - seventeenth wave (evidence re-score at v1.108.0, 2026-07-09)","x":"A full 34-category, two-pass evidence re-score (per-category scorer plus adversarial verifier) at framework v1.108.0 (git HEAD 6c3b3bc, working tree clean, one commit ahead of…","i":"ILiveChannelPublisher ACCESSIBILITY.md FACTS.md ci.yml"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---eighteenth-wave-runtime-performance-wave-2026-07-10","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - eighteenth wave (runtime performance wave, 2026-07-10)","x":"A cross-repo runtime-performance audit (4 parallel auditors: framework, ADC, Store, hosting/config) found the framework strong on read-path fundamentals (no-tracking, SQL…","i":"PublicEndpointOutputCachePolicy EFReadRepository.ApplyIncludes PooledConnectionLifetime HttpResilienceDefaults CachingQueryDecorator LocalView.FindEntry ExecuteUpdateAsync InProcessEventBus AllowAnonymous DetectChanges ExpandoObject CHANGELOG.md"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---remediation-wave-1-cross-repo-wave-plan-2026-07-11","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - remediation wave 1 (cross-repo wave plan, 2026-07-11)","x":"First wave of the 2026-07-11 cross-repo remediation plan (workspace plan file). Ships the shared §18/§19 fitness bases the ADC/Store maturity lifts need, closes the tenth-wave 20…","i":"StateManagementConventionTestsBase UIArchitectureConventionTestsBase ErrorMessages._localizer MobileInfiniteScrollList AllowedStaticMembers PrimaryContrastText WebVitalsCollector ErrorContrastText WebVitalsE2ETests DarkModeE2ETests NotificationBell CHANGELOG.md"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---eighteenth-wave-evidence-re-score-at-v11150-2026-07-12","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - eighteenth wave (evidence re-score at v1.115.0, 2026-07-12)","x":"A full 34-category, two-pass evidence re-score (per-category scorer plus adversarial verifier) at framework v1.115.0 (HEAD 37d0a3b, working tree clean, at the release tag). Three…","i":"ArchitectureScorecard.md MMCA.Common.UI.Maui PrimaryContrastText ErrorContrastText WebVitalsE2ETests DarkModeE2ETests MudDataGrid FACTS.md rgba"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---twentieth-wave-evidence-re-score-at-v11170-2026-07-17","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - twentieth wave (evidence re-score at v1.117.0, 2026-07-17)","x":"A full 34-category, two-pass evidence re-score (per-category scorer plus adversarial verifier) at framework v1.117.0 (HEAD 76d70cf, working tree clean). Four scores move.…","i":"ArchitectureScorecard.md NavigationContractTests required_status_checks AuthorizeAttribute NavigationFlow.md MMCA.Common.UI RouteAttribute RESPONSIVE.md FACTS.md bicep build Short"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---twenty-first-wave-evidence-re-score-at-v11210-2026-07-21","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - twenty-first wave (evidence re-score at v1.121.0, 2026-07-21)","x":"A full 34-category, two-pass evidence re-score (per-category scorer plus adversarial verifier) at framework v1.121.0 (HEAD 4a4fc05, working tree clean). One score moves.…","i":"ArchitectureScorecard.md required_status_checks BenchmarkDotNet CONTRIBUTING.md Notifications Performance baseline FACTS.md COST.md verify Short gate"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---twenty-second-wave-evidence-re-score-at-v11230-2026-07-23","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - twenty-second wave (evidence re-score at v1.123.0, 2026-07-23)","x":"A full 34-category, two-pass evidence re-score (per-category scorer plus adversarial verifier) at framework v1.123.0 (HEAD c911480, working tree clean). No score moves. Canonical…","i":"UnsavedChangesGuard.IsDirtyAccessor PiiErasureContractFitnessTests PasswordComplexityAttribute IIntegrationEventPublisher ArchitectureScorecard.md OpenApiContractTestsBase IConnectionMultiplexer EntityQueryPipeline IEventBus EditForm FACTS.md c911480"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---twenty-third-wave-evidence-re-score-at-v11280-2026-07-25","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - twenty-third wave (evidence re-score at v1.128.0, 2026-07-25)","x":"A full 34-category, two-pass evidence re-score (per-category scorer plus adversarial verifier) at framework v1.128.0 (HEAD 3dff29b, working tree clean). No score moves, the third…","i":"ArchitectureScorecard.md WebVitalsE2ETests ICommandHandler IQueryHandler pull_request permissions Unreleased FACTS.md TResult ci.yml github Result"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---twenty-fourth-wave-evidence-re-score-at-v11310-2026-07-28","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - twenty-fourth wave (evidence re-score at v1.131.0, 2026-07-28)","x":"A full 34-category, two-pass evidence re-score (per-category scorer plus adversarial verifier) at framework v1.131.0 (HEAD 2c52aa9, working tree clean). No score moves, the…","i":"ArchitectureScorecard.md OpenApiContractTestsBase AddCommonApiVersioning MMCA.Common.UI.Maui ICommandHandler ServiceContract AllowAnonymous AllowAnyOrigin IQueryHandler FACTS.md TResult CS1591"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---twenty-fifth-wave-evidence-re-score-at-v11350-2026-08-01","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - twenty-fifth wave (evidence re-score at v1.135.0, 2026-08-01)","x":"A full 34-category, two-pass evidence re-score (per-category scorer plus adversarial verifier) at framework v1.135.0 (HEAD f292233, working tree clean). One score moves, ending…","i":"EntityQueryService.GetAllForLookupAsync DomainInvariantViolationException ArchitectureScorecard.md InProcessDistributedLock HttpResilienceDefaults IConnectionMultiplexer RedisDistributedLock NuGetAuditSuppress IdempotencyFilter IDistributedLock v1.128.0..HEAD AddCaching"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---twenty-sixth-wave-evidence-re-score-at-v11420-2026-08-07","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - twenty-sixth wave (evidence re-score at v1.142.0, 2026-08-07)","x":"Full 34-category two-pass re-score at HEAD 710d29d (clean tree). No scores move: 27 categories re-confirmed fresh, and seven first-pass lift proposals were refuted on the…","i":"GetAllForLookupAsync packages.lock.json AddMeter FACTS.md orderBy OrderBy secrets l.Name navbar NoWarn where"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---security-invariants-wave-11-hardening-2026-08-22","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - security invariants wave (§11 hardening, 2026-08-22)","x":"Closes the two §11 gaps surfaced by the Article 16 (JWKS dual-fetch) review: the insecure dev defaults that no two-axis entry named as scheduled work, and the absent security…","i":"AnonymousEndpointTestsBase AddForwardedJwtBearer requireHttpsMetadata RequireHttpsMetadata RsaJwksProvider AllowAnonymous configuration environment authority audience string false"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---9-contract-surface-gates-2026-08-22","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - §9 contract-surface gates (2026-08-22)","x":"Closes both halves of 9, the last weight-2 Maturity-3 item that had a named in-repo lever. Landed via MMCA.Common PR 271 (squash 8a6c603, merged 2026-08-22). - ✅ OpenAPI…","i":"ServiceContractsDoNotDependOnServiceInternals OpenApiBaselineTests AddCommonOpenApi MapCommonOpenApi ServiceContract ProblemDetails FACTS.md"},{"u":"/docs/governance/common-RemediationBacklog.html#deferred---2026-07-19-full-review-recorded-not-scheduled","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Deferred - 2026-07-19 full review (recorded, not scheduled)","x":"The 2026-07-19 full framework review shipped its accepted fixes on the review branch (rollback on business failure + post-commit dispatch, outbox leases + dead-letter visibility,…","i":"MMCA.Common.Infrastructure MMCA.Common.UI.Tests MMCA.Common.UI.Maui IServiceCollection IMessageBus LangVersion extension IsDeleted IsFailure preview TResult CS1591"},{"u":"/docs/governance/common-RemediationBacklog.html#recorded---2026-07-31-consumer-discovered-defect-not-scheduled","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Recorded - 2026-07-31 consumer-discovered defect (not scheduled)","x":"Found downstream while implementing MMCA.ADC BR-239 (public speaker visibility), which needed a filtered lookup read. Recorded rather than fixed in place: the consumer already…","i":"EntityQueryService.GetAllForLookupAsync MMCA.Common.Shared.ValueObjects.Email IRepository.GetAllForLookupAsync QueryFieldService.Validate InvalidOperationException GetOrBuildLookupSelector BaseLookup.Name nameProperty asTracking ToString orderBy OrderBy"},{"u":"/docs/governance/common-RemediationBacklog.html#priority-6-highest-leverage","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"🔴 Priority 6: highest leverage","x":"The package ships reusable Blazor primitives with no fast test tier. - ~~(medium) No component tests for the UI library~~ RESOLVED: Tests/Presentation/MMCA.Common.UI.Tests…","i":"Page.AssertNoAccessibilityViolationsAsync PiiErasureContractFitnessTests AuditableBaseEntity.Delete Deque.AxeCore.Playwright EncryptedStringConverter MobileInfiniteScrollList MMCA.Common.Testing.E2E MMCA.Common.Testing.UI MMCA.Common.UI.Tests OutboxCleanupService UnsavedChangesGuard DeleteConfirmation"},{"u":"/docs/governance/common-RemediationBacklog.html#priority-3-score-3-weight-3-one-rung-from-a-4","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"🟠 Priority 3: score 3, weight 3 (one rung from a 4)","x":"- ~~(medium) No broker retry policy on the extracted-microservice path~~ RESOLVED (re-verified 2026-06-29): ConfigureBrokerTransport applies cfg.UseMessageRetry (exponential) on…","i":"DomainAggregateRootsHaveNoPublicConstructors ResilienceCircuitBreakerFaultInjectionTests Add_DifferentCurrencies_ReturnsFailure HandleBeforeInternalNavigationAsync MobileInfiniteScrollListTests.cs AggregateRootsHaveResultFactory MessageBusSettings.RetryLimit AggregateConventionTestsBase DomainExposesAggregateRoots DomainFactoriesReturnResult RestorePackagesWithLockFile UnsavedChangesGuardTests.cs"},{"u":"/docs/governance/common-RemediationBacklog.html#priority-2-score-3-weight-2-polish--hardening","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"🟡 Priority 2: score 3, weight 2 (polish / hardening)","x":"- (medium) No consumer-side idempotency/inbox for at-least-once broker delivery: duplicate side effects possible in any non-idempotent consumer. (low) ~~Same misleading…","i":"ServiceContractsDoNotDependOnServiceInternals EntityQueryPipeline.MaxUnboundedResultLimit ApplicationSettings.MaxPageSize MessageBusSettings.EnableInbox ServiceContractPurityTestsBase ArchitectureRules.Slices.cs MobileInfiniteScrollList OpenApiContractTestsBase ServiceContractAttribute Directory.Build.targets AddCommonApiVersioning required_status_checks"},{"u":"/docs/governance/common-RemediationBacklog.html#already-at-level-4-protect-dont-regress","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"✅ Already at level 4: protect, don't regress","x":"1 SOLID · 2 Design Patterns · 3 Clean Architecture · 5 Vertical Slice (maturity 3→4 on the slice-cohesion fitness function) · 7 Microservices Readiness · 8 Data Architecture · 10…","i":"MessageBusSettings.EnableInbox NavigationContractTests IConnectionMultiplexer required_status_checks WebVitalsE2ETests IDistributedLock BenchmarkDotNet ServiceContract EditorRequired Performance baseline navbar"},{"u":"/docs/governance/common-RemediationBacklog.html#deliberate--accepted-documented-caps-not-scheduled-work","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"🔒 Deliberate / accepted (documented caps, not scheduled work)","x":"Moved out of the active priority queue on 2026-07-02 (user-approved). Its computed priority = (4 − 2) × 2 = 4 is the highest weighted gap of any open category, but the unmet §31…","i":"NavigationFlow.md ACCESSIBILITY.md CONTRIBUTING.md NUGET_API_KEY RESILIENCE.md RESPONSIVE.md CHANGELOG.md release.yml SECURITY.md main.bicep CLAUDE.md README.md"},{"u":"/docs/governance/common-RemediationBacklog.html#mostly-consumer-assessed-the-shared-commonui-surface-is-scored-here","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"⚪ Mostly consumer-assessed (the shared Common.UI surface is scored here)","x":"21 Accessibility · 26 Front-End Security (Assessable mainly in consumer apps; 26 shared surface is covered under 11.) - 22 Responsive: CLOSED at Maturity 4 / Implementation 9…","i":"LocalizedTextConventionTests PseudoLocalizationE2ETests AuthModelValidationTests NavigationContractTests PasswordComplexity NavigationFlow.md RegisterFormTests ValidationMessage ResxMudLocalizer Forbidden EditForm slnx"},{"u":"/docs/governance/store-ArchitectureScorecard.html","d":"MMCA.Store: Architecture Scorecard","k":"Architecture Governance","x":"Canonical, version-controlled scorecard for this repo: the single source of truth for MMCA.Store's architecture scores. This is Store's first in-repo governance artifact…","i":"CK_InventoryItem_AvailableQuantity_NonNegative ArchitectureEvaluationCriteria.md ConstructorDependencyCountTests StateManagementConventionTests UIArchitectureConventionTests MMCA.Store.Identity.UI.Tests ObservabilityConventionTests MobileInfiniteScrollList PseudoLocalizationTests GracefulShutdownTests Money.ToDisplayString RemediationBacklog.md"},{"u":"/docs/governance/store-ArchitectureScorecard.html#executive-summary","d":"MMCA.Store: Architecture Scorecard","k":"Architecture Governance","t":"Executive summary","x":"MMCA.Store is a .NET 10.0 (LangVersion preview) DDD/Clean Architecture e-commerce system (Catalog, Sales, Identity modules; Stripe checkout) extracted into independently-hosted…","i":"MMCA.Common.Testing.Architecture IntegrationEventContractTests LocalizedTextConventionTests TreatWarningsAsErrors DataResidencyTests dbo.OutboxMessages PiiConventionTests Store_Identity Store_Catalog Store_Sales MMCAStore"},{"u":"/docs/governance/store-ArchitectureScorecard.html#scorecard","d":"MMCA.Store: Architecture Scorecard","k":"Architecture Governance","t":"Scorecard","x":"Weighted = Maturity·weight / Implementation·weight. Axis-gap finding: §21 Accessibility is honestly M3/I8 (the chromium axe gate earns Implementation 8; Maturity caps at 3…","i":"CK_InventoryItem_AvailableQuantity_NonNegative MMCA.Store.CrossService.IntegrationTests FrameworkVersionConsistencyTests IntegrationEventContractTests.cs ConstructorDependencyCountTests StateManagementConventionTests SQLitePCLRaw.bundle_e_sqlite3 UIArchitectureConventionTests CultureInfo.InvariantCulture LocalizedTextConventionTests ObservabilityConventionTests TranslationCompletenessTests"},{"u":"/docs/governance/store-ArchitectureScorecard.html#indices","d":"MMCA.Store: Architecture Scorecard","k":"Architecture Governance","t":"Indices","x":"- Maturity index = Σ(maturity×weight) ÷ Σ(weight×4) = 313 ÷ 320 = 97.8% (re-confirmed with no moves on the 2026-08-23 re-score; §12, §21, and §22 are the three categories at…"},{"u":"/docs/governance/store-ArchitectureScorecard.html#top-5-strengths","d":"MMCA.Store: Architecture Scorecard","k":"Architecture Governance","t":"Top 5 strengths","x":"1. Clean Architecture + DDD + CQRS depth, fitness-enforced: §3/§4 (impl 9): inherited framework substance, guarded by the shared NetArchTest suite (23 test classes,…","i":"GracefulShutdownTests IAnonymizable"},{"u":"/docs/governance/store-ArchitectureScorecard.html#top-5-risks","d":"MMCA.Store: Architecture Scorecard","k":"Architecture Governance","t":"Top 5 risks","x":"1. Accessibility maturity is capped pending a human pass: §21 (mat 3, weight 3): the 23-scan axe suite gates the deploy (impl 8), but the rubric pairs axe-in-CI with a recorded…","i":"BrandColorTokenTests FormsConventionTests workflow_dispatch deploy.needs a1de5a89 MudForm"},{"u":"/docs/governance/store-ArchitectureScorecard.html#cross-repo-comparison","d":"MMCA.Store: Architecture Scorecard","k":"Architecture Governance","t":"Cross-repo comparison","x":"How Store relates to MMCA.Common (the framework) and MMCA.ADC (the sibling consumer) is maintained once, for all three repos, in the workspace-internal…"},{"u":"/docs/governance/store-RemediationBacklog.html","d":"MMCA.Store: Architecture Remediation Backlog","k":"Architecture Governance","x":"Derived from ArchitectureScorecard.md (two-axis: Maturity 97.8% / Implementation 83.9%, full re-score 2026-07-28, re-confirmed with no score moves on the 2026-08-14 and…","i":"ArchitectureScorecard.md"},{"u":"/docs/governance/store-RemediationBacklog.html#priority-a11y--e2e-merge-gate-21-28-22","d":"MMCA.Store: Architecture Remediation Backlog","k":"Architecture Governance","t":"🔴 Priority: a11y / E2E merge gate (#21, #28, #22)","x":"The former single biggest maturity lever: 28 cleared 2026-07-03; 22 cleared on the 2026-07-17 re-score (the gate flip verified live) and reopened on the 2026-07-28 re-score when…","i":"needs.changes.outputs.ui github.event_name matrix.browser workflow_call deploy.needs deploy.yml browsers d057afc e2e.yml skipped success always"},{"u":"/docs/governance/store-RemediationBacklog.html#priority-execution-quality-gaps-impl-not-maturity","d":"MMCA.Store: Architecture Remediation Backlog","k":"Architecture Governance","t":"🟠 Priority: execution-quality gaps (impl, not maturity)","x":"Ranked 2026-07-28 when the ledger gained its second ranked axis. Until then the items in this section were closed history plus two open levers, with no ranking and no inclusion…","i":"MMCA.Store.CrossService.IntegrationTests IdentityModuleDbSeederBase.ShouldSeed SqlServerIntegrationTestFixtureBase CultureInfo.InvariantCulture MobileInfiniteScrollList ProductVariantChanged NotifyStateChanged workflow_dispatch EmailExistsAsync CatalogBrowse GetPagedAsync InventoryItem"},{"u":"/docs/governance/store-RemediationBacklog.html#priority-minor--accept-or-polish","d":"MMCA.Store: Architecture Remediation Backlog","k":"Architecture Governance","t":"🟡 Priority: minor / accept-or-polish","x":"- [x] 32 Dependency & Supply-Chain, impl 7 → 8. DONE (2026-07-03, drift plan D8 + D9). Vulnerability gate is NuGetAudit + TreatWarningsAsErrors at restore, which fails…","i":"ServiceInfoController TreatWarningsAsErrors BrandColorTokenTests FormsConventionTests CustomerEmailRules NuGetAuditSuppress Store_Identity Store_Catalog Store_Sales ApiVersion Deprecated MMCAStore"},{"u":"/docs/governance/store-RemediationBacklog.html#defect-fix-wave-2026-07-05","d":"MMCA.Store: Architecture Remediation Backlog","k":"Architecture Governance","t":"🐞 Defect-fix wave (2026-07-05)","x":"Four reviewed product defects fixed in one wave; every behavior change flipped its pinning test in the same change. - [x] S-1 Stripe network errors escaped the Result pattern.…","i":"Payment.Stripe.SessionRetrievalFailed Payment.Stripe.SessionCreationFailed Payment.Stripe.UnsupportedCurrency CartStateService.InitializeAsync ExportUserDataHandler HttpRequestException StripePaymentService CheckoutAndPayAsync DeleteUserHandler UserRole.IsAdmin CheckoutOutcome UserRole.Admin"},{"u":"/docs/governance/store-RemediationBacklog.html#deliberate--accepted-record-the-choice-dont-silently-leave-low","d":"MMCA.Store: Architecture Remediation Backlog","k":"Architecture Governance","t":"Deliberate / accepted (record the choice; don't silently leave low)","x":"- ADR-042 device capability abstraction (latent, drift plan D8). The core extension point is converged (Store wires browser + MAUI capabilities via…","i":"AddBrowserDeviceCapabilities CultureInfo.InvariantCulture LocalizedTextConventionTests TranslationCompletenessTests UseMauiDeviceCapabilities Money.ToDisplayString ProductVariantChanged MMCA.Common.UI.Maui SliceCohesionTests DeepLinkListener ResxMudLocalizer DeviceUIModule"},{"u":"/docs/governance/store-RemediationBacklog.html#below-maturity-4-tracking-inclusion-policy-categories-scoring--4-maturity","d":"MMCA.Store: Architecture Remediation Backlog","k":"Architecture Governance","t":"🟠 Below-maturity-4 tracking (inclusion policy: categories scoring < 4 maturity)","x":"These categories score maturity 3; the ledger records them per its own line-4 inclusion policy (mirrors ADC's equivalent entries). - [x] 19 · State Management & Data Flow ·…","i":"StateManagementConventionTestsBase UIArchitectureConventionTestsBase StateManagementConventionTests UIArchitectureConventionTests ProductDetail.razor.cs OrderDetail.razor.cs ProductVariantsPanel StoreArchitectureMap OrderSummaryPanel workflow_dispatch OrderLinesPanel OPERATIONS.md"},{"u":"/docs/governance/store-RemediationBacklog.html#resolved-this-cycle-2026-07-28-drift-wave-d1d2d5d6d7--e2e4e7e8","d":"MMCA.Store: Architecture Remediation Backlog","k":"Architecture Governance","t":"🟢 Resolved this cycle (2026-07-28, drift wave: D1/D2/D5/D6/D7 + E2/E4/E7/E8)","x":"- [x] 29 Resilience: the DR drill was restoring a RETIRED database. The weekly dr-drill.yml had no rotation and fell through to its input default MMCAStore, the legacy archive no…","i":"AuthControllerBase.LoginAsync HandlerResultConventionTests PaymentReconciliationService DecoratorPipelineOrderTests PeriodicBackgroundService AddCommonRateLimiting skip_freshness_gates alertEmailAddress authIpPermitLimit Store_Identity RegisterAsync Store_Catalog"},{"u":"/docs/governance/store-RemediationBacklog.html#resolved-this-cycle-2026-07-25-performance-program-2","d":"MMCA.Store: Architecture Remediation Backlog","k":"Architecture Governance","t":"🟢 Resolved this cycle (2026-07-25, performance program 2)","x":"Second evidence-led performance pass over Common/ADC/Store. Store's share shipped as two PRs plus the v1.127.0 framework sweep. - [x] Output cache shared across replicas. Catalog…","i":"AddStackExchangeRedisOutputCache Filter.Operator.NotSupported GetVariantCartInfoHandler BulkSetInventoryHandler IProductVariantService GetUnitPricesAsync IDistributedCache IntFilterStrategy OrderLines.Count PaymentInitiated EvictByTagAsync ProductVariants"},{"u":"/docs/governance/store-RemediationBacklog.html#resolved-this-cycle-2026-07-11-drift-convergence-drift-plan-d1-d13","d":"MMCA.Store: Architecture Remediation Backlog","k":"Architecture Governance","t":"🟢 Resolved this cycle (2026-07-11 drift-convergence, drift plan D1-D13)","x":"- [x] 29 DR gates (drift plan D3). dr-freshness is now in deploy.needs (fails a deploy when the last successful dr-drill is stale), dr-drill.yml gained a weekly cron, and…","i":"ConstructorDependencyCountTests MMCA.Store.Gateway.Tests GracefulShutdownTests MMCA.Store.CI.slnf Store_Identity Store_Catalog workflow_call deploy.needs TimeProvider Store_Sales Directory db_owner"},{"u":"/docs/governance/store-RemediationBacklog.html#already-at-level-4-protect-dont-regress","d":"MMCA.Store: Architecture Remediation Backlog","k":"Architecture Governance","t":"✅ Already at level 4 (protect, don't regress)","x":"Both axes satisfied (maturity 4 AND implementation = 9), the true protect list: SOLID (1), Design Patterns (2), Clean Architecture (3), DDD (4), Data (8), API (9), Observability…","i":"CK_InventoryItem_AvailableQuantity_NonNegative FormsConventionTests IQueryable"},{"u":"/docs/guides/index.html","d":"Guides & Specifications","k":"Guides & Specifications","x":"The narrative documentation for the MMCA platform: adoption guides, business specifications, workflow analyses, and per-concern reference notes. Files are prefixed by the repo…"},{"u":"/docs/guides/index.html#framework-mmcacommon","d":"Guides & Specifications","k":"Guides & Specifications","t":"Framework (MMCA.Common)","x":"- Getting Started: stand up a new application from the MMCA.Templates scaffold, in six steps. - Build MMCA.ECommerce: the two-module store sample (Products + Orders) built end to…","i":"MMCA.Templates"},{"u":"/docs/guides/index.html#mmcastore-e-commerce","d":"Guides & Specifications","k":"Guides & Specifications","t":"MMCA.Store (e-commerce)","x":"- Business Specification - Business Workflow Analysis - Navigation Flow - Manual Screen-Reader Pass Runbook"},{"u":"/docs/guides/index.html#mmcaadc-conference","d":"Guides & Specifications","k":"Guides & Specifications","t":"MMCA.ADC (conference)","x":"- Business Specifications - Navigation Flow - Manual Screen-Reader Pass Runbook - Integration-Test Tier Rework Plan Related reading: the Architecture Decision Records and the…"},{"u":"/docs/guides/adc-ACCESSIBILITY-SCREENREADER-PASS.html","d":"Manual Screen-Reader Pass: Runbook (§21 Accessibility)","k":"Guides & Specifications","x":"The automated layer (axe-core WCAG 2.1 AA scans in Tests/E2E/MMCA.ADC.E2E.Tests/AccessibilityTests.cs plus the shared Login/Register/Profile bases in MMCA.Common.Testing.E2E)…","i":"MMCA.Common.Testing.E2E RemediationBacklog.md"},{"u":"/docs/guides/adc-ACCESSIBILITY-SCREENREADER-PASS.html#tools","d":"Manual Screen-Reader Pass: Runbook (§21 Accessibility)","k":"Guides & Specifications","t":"Tools","x":"Run against the app started via Aspire (dotnet run --project Source/Hosting/MMCA.ADC.AppHost), reaching the UI through the Gateway. Test with the keyboard only (no mouse) for the…","i":"dotnet start Ctrl Alt Cmd run"},{"u":"/docs/guides/adc-ACCESSIBILITY-SCREENREADER-PASS.html#what-to-verify-on-every-flow","d":"Manual Screen-Reader Pass: Runbook (§21 Accessibility)","k":"Guides & Specifications","t":"What to verify on every flow","x":"1. Landmarks and headings. The SR's landmark/heading list (NVDA Insert+F7) exposes a logical banner / navigation / main structure and a sensible h1...hN outline (one h1 per…","i":"MainLayout.razor navigation h1...hN banner Insert Shift main Tab"},{"u":"/docs/guides/adc-ACCESSIBILITY-SCREENREADER-PASS.html#results-log","d":"Manual Screen-Reader Pass: Runbook (§21 Accessibility)","k":"Guides & Specifications","t":"Results log","x":"Record one row per pass. A flow with any unresolved blocker is a FAIL (open a backlog item for it)."},{"u":"/docs/guides/adc-ACCESSIBILITY-SCREENREADER-PASS.html#after-a-pass","d":"Manual Screen-Reader Pass: Runbook (§21 Accessibility)","k":"Guides & Specifications","t":"After a pass","x":"- File any defect as a remediation item; cross-link it from the §21 row's evidence. - Update the dated row above so the scorecard can cite a real, current manual pass (this is…"},{"u":"/docs/guides/adc-IntegrationTestReworkPlan.html","d":"Integration-Test Tier Rework Plan (RemediationBacklog #14)","k":"Guides & Specifications","x":"Status: complete (Phase 4 broker-transport tier landed 2026-07-06; Phase 5 residual = coverlet only). - Phase 0 ✅: Tests/WebAPI revived as MMCA.Common.API middleware unit tests…","i":"Microsoft.Testing.Extensions.CodeCoverage ISessionBookmarkValidationService IdentityIntegrationTestFixture SpeakerUnlinkedFromUserHandler AnonymousConferenceReadTests SpeakerLinkedToUserHandler MMCA.ADC.Integration.slnf MMCA.ADC.IntegrationTests IIntegrationEventHandler AddForwardedJwtBearer AttendeeBookmarkTests IBookmarkCountService"},{"u":"/docs/guides/adc-IntegrationTestReworkPlan.html#recommended-strategy-two-tiers","d":"Integration-Test Tier Rework Plan (RemediationBacklog #14)","k":"Guides & Specifications","t":"Recommended strategy: two tiers","x":"1. Primary: per-service WebApplicationFactory : one in-process host per service (Identity / Conference / Engagement), cross-service edges mocked. AddBrokerMessaging…","i":"DistributedApplicationTestingBuilder SpeakerUnlinkedFromUser WebApplicationFactory SpeakerLinkedToUser AddBrokerMessaging UserRegistered Program"},{"u":"/docs/guides/adc-IntegrationTestReworkPlan.html#three-code-facts-that-shape-the-rework-verified","d":"Integration-Test Tier Rework Plan (RemediationBacklog #14)","k":"Guides & Specifications","t":"Three code facts that shape the rework (verified)","x":"- Only Conference.Service is WAF-incompatible: it ends with StartAsync() + self-HTTP/2 WarmupViaHttpAsync + WaitForShutdownAsync(). Identity/Engagement/Notification use…","i":"AddCommonAuthentication AddForwardedJwtBearer WebApplicationFactory WaitForShutdownAsync Conference.Service WarmupViaHttpAsync JwtTokenGenerator IssuerSigningKey JwtBearerOptions app.RunAsync StartAsync authority"},{"u":"/docs/guides/adc-IntegrationTestReworkPlan.html#databases","d":"Integration-Test Tier Rework Plan (RemediationBacklog #14)","k":"Guides & Specifications","t":"Databases","x":"- SQLite in-memory for the fast bulk tier (no Docker, CI-friendly; DatabaseInitStrategy=EnsureCreated). - MsSql Testcontainers for a tagged SQL-fidelity subset (soft-delete…","i":"SQLServerDbContext DataSources migrations"},{"u":"/docs/guides/adc-IntegrationTestReworkPlan.html#project-structure","d":"Integration-Test Tier Rework Plan (RemediationBacklog #14)","k":"Guides & Specifications","t":"Project structure","x":"- One WAF test project per service (MMCA.ADC.{Identity,Conference,Engagement}.IntegrationTests): can't reference two Program-bearing hosts in one project. - One…","i":"MMCA.ADC.CrossService.IntegrationTests IntegrationTestBase MMCA.Common.Testing JwtTokenGenerator IntegrationTests ProjectReference MMCA.Common.API WebAPI.Tests Conference Engagement Identity MMCA.ADC"},{"u":"/docs/guides/adc-IntegrationTestReworkPlan.html#ci","d":"Integration-Test Tier Rework Plan (RemediationBacklog #14)","k":"Guides & Specifications","t":"CI","x":"- Add the SQLite per-service tier to CI.slnf (seconds, no Docker) → restores the authz/CRUD merge gate (11) with no workflow change. - Keep the container-based MsSql + RabbitMQ…","i":"CI.slnf"},{"u":"/docs/guides/adc-IntegrationTestReworkPlan.html#phased-sequencing-fastest-win-first","d":"Integration-Test Tier Rework Plan (RemediationBacklog #14)","k":"Guides & Specifications","t":"Phased sequencing (fastest win first)","x":"- Phase 0: re-home WebAPI.Tests middleware unit tests; drop the dead WebAPI reference; re-add to slnx+CI.slnf. ~16 tests green; removes a non-building project (16). - Phase 1:…","i":"ISessionBookmarkValidationService IBookmarkCountService OwnerOrAdminFilter ServiceTestFixture JwtBearerOptions AttendeeClaims OrganizerUser WebAPI.Tests TProgram CI.slnf slnx"},{"u":"/docs/guides/adc-IntegrationTestReworkPlan.html#key-risks","d":"Integration-Test Tier Rework Plan (RemediationBacklog #14)","k":"Guides & Specifications","t":"Key risks","x":"- The non-Identity JwtBearerOptions in-process override is the trickiest piece: prove it on one Conference auth test before fanning out. - SQLite vs SQL-Server fidelity (owned…","i":"JwtBearerOptions"},{"u":"/docs/guides/adc-IntegrationTestReworkPlan.html#critical-files","d":"Integration-Test Tier Rework Plan (RemediationBacklog #14)","k":"Guides & Specifications","t":"Critical files","x":"- Tests/Integration/MMCA.ADC.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs (combined-host factory → split into per-service fixtures; its JWT config block is the…","i":"AddCommonAuthentication AddForwardedJwtBearer JwtTokenGenerator.cs MMCA.ADC.CI.slnf MMCA.ADC.slnx StartAsync partial Program public class"},{"u":"/docs/guides/adc-NavigationFlow.html","d":"Navigation Flow","k":"Guides & Specifications","x":"This document maps the site navigation flow for each actor in the MMCA.ADC application. Each mermaid diagram shows the pages accessible to that actor and the directional…"},{"u":"/docs/guides/adc-NavigationFlow.html#actors","d":"Navigation Flow","k":"Guides & Specifications","t":"Actors","x":"Roles & menu: Organizer is the only elevated role (default is Attendee). A Speaker is an attendee whose account is linked to a Speaker, surfaced via the speakerid claim. The left…","i":"IUIModule.NavItems speaker_id Organizer Attendee"},{"u":"/docs/guides/adc-NavigationFlow.html#1-anonymous-user","d":"Navigation Flow","k":"Guides & Specifications","t":"1. Anonymous User","x":"Pages accessible without authentication: home, login, register, the two password-reset pages, and all public conference pages. ---"},{"u":"/docs/guides/adc-NavigationFlow.html#2-attendee-authenticated-user","d":"Navigation Flow","k":"Guides & Specifications","t":"2. Attendee (Authenticated User)","x":"Inherits all anonymous pages. Gains access to profile, feedback submission, and session bookmarking. Unauthenticated visitors are redirected to login. ---"},{"u":"/docs/guides/adc-NavigationFlow.html#3-speaker","d":"Navigation Flow","k":"Guides & Specifications","t":"3. Speaker","x":"Inherits all attendee pages. Gains access to the speaker dashboard for managing their own profile, viewing assigned sessions, and reviewing feedback ratings. ---"},{"u":"/docs/guides/adc-NavigationFlow.html#4-organizer","d":"Navigation Flow","k":"Guides & Specifications","t":"4. Organizer","x":"Authenticated users with the Organizer role. Inherits all attendee and public pages. Adds CRUD management for every conference entity (events, sessions, speakers, categories,…","i":"Organizer"},{"u":"/docs/guides/adc-NavigationFlow.html#5-functionality-flows-attendee--speaker","d":"Navigation Flow","k":"Guides & Specifications","t":"5. Functionality Flows (Attendee & Speaker)","x":"The diagrams in sections 1-4 map which pages each actor can reach. The diagrams below map how attendees and speakers accomplish each functionality, including inline actions…","i":"DeviceUIModule speaker_id route"},{"u":"/docs/guides/adc-NavigationFlow.html#navigation-patterns","d":"Navigation Flow","k":"Guides & Specifications","t":"Navigation Patterns","x":"- Unauthenticated users accessing protected pages are redirected to /login via the RedirectToLogin component. - Successful login/register redirects to Home (/) with a full page…","i":"RegisteredUser_AdminPages_ShouldBeForbidden Engagement.CheckIn IUIModule.NavItems Engagement.Points EventList.razor RedirectToLogin DeviceUIModule UserList.razor Routes.razor speaker_id attribute Authorize"},{"u":"/docs/guides/adc-specifications.html","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","x":"---"},{"u":"/docs/guides/adc-specifications.html#1-system-overview","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","t":"1. System Overview","x":"ADC is a conference management system for the Atlanta Developers Conference. It provides backend services to manage multi-day conference events, sessions, speakers, rooms,…"},{"u":"/docs/guides/adc-specifications.html#2-domain-model","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","t":"2. Domain Model","x":"Relationships: - Owns many Rooms (child entities) - Owns many EventSpeakers (child join entities linking Event ↔ Speaker) - Owns many EventQuestionAnswers (child feedback…","i":"Engagement.LivePolls Engagement.SessionQA User.LinkedSpeakerId Event.StartDate Session.EventId ContentEditor Event.EndDate EventSpeaker QuestionType Waitlisted Nominated Organizer"},{"u":"/docs/guides/adc-specifications.html#3-business-rules","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","t":"3. Business Rules","x":"Reading guide: Some rules reference other rules defined later in the document (e.g., BR-63, BR-80 are defined in Section 10). Forward references use the BR- numbering…","i":"Event.QuestionModerationDefault Event.LastSessionizeRefreshBy Event.LastSessionizeRefreshOn SessionQuestionAnswerChanged SpeakerQuestionAnswerChanged EventQuestionAnswerChanged SessionCategoryItemChanged SpeakerCategoryItemChanged UserSessionBookmarkChanged Session.AccessibilityInfo Session.IsServiceSession SessionFeedbackSubmitted"},{"u":"/docs/guides/adc-specifications.html#4-use-cases--business-processes","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","t":"4. Use Cases / Business Processes","x":"See UC-30 (Registration) and UC-31 (Login) in Section 12.2 for the current email + password authentication flows. UC-34 (Request password reset) and UC-35 (Reset password) in the…","i":"SpeakerQuestionAnswersController Event.LastSessionizeRefreshBy Event.LastSessionizeRefreshOn SpeakerQuestionAnswerChanged Engagement.LivePolls Engagement.SessionQA UserSessionBookmark skippedSoftDeleted IsServiceSession IsPlenumSession AllowAnonymous QuestionEntity"},{"u":"/docs/guides/adc-specifications.html#5-workflows--state-transitions","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","t":"5. Workflows & State Transitions","x":"The Session.Status field is a free-text string imported from Sessionize. Default: null (for manually created sessions). Known Sessionize values: Accepted, Waitlisted, Accept…","i":"Session.Status ContentEditor IsConfirmed IsInformed Waitlisted Nominated Organizer Accepted Declined Decline Accept Queue"},{"u":"/docs/guides/adc-specifications.html#6-events--side-effects","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","t":"6. Events & Side Effects","x":"Domain events are raised for entity mutations. Not all events have registered handlers: events without handlers serve as extension points for future requirements. Note: Only…","i":"SessionQuestionAnswerChanged SpeakerQuestionAnswerChanged EventQuestionAnswerChanged SessionCategoryItemChanged SpeakerCategoryItemChanged UserSessionBookmarkChanged SessionSpeakerChanged User.LinkedSpeakerId CategoryItemChanged EventSpeakerChanged UserPasswordChanged CategoryChanged"},{"u":"/docs/guides/adc-specifications.html#7-business-constraints--invariants","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","t":"7. Business Constraints & Invariants","x":"---","i":"Speaker.LinkedUserId User.LinkedSpeakerId IsServiceSession Session.EventId QuestionEntity ContentEditor EventSpeaker nameProperty Waitlisted CreatedBy FirstName Nominated"},{"u":"/docs/guides/adc-specifications.html#8-external-integrations-business-perspective","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","t":"8. External Integrations (Business Perspective)","x":"---","i":"Speaker.ProfilePicture Event.VenueMapUrl IsServiceSession IsPlenumSession QuestionSource SessionizeCode IsTopSpeaker RecordingUrl LiveUrl POST"},{"u":"/docs/guides/adc-specifications.html#9-glossary","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","t":"9. Glossary","x":"---","i":"Event.IsPublished IsServiceSession IsPlenumSession ContentEditor IsTopSpeaker Organizer User.Role Admin Role true"},{"u":"/docs/guides/adc-specifications.html#ddd-structural-summary","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","t":"DDD Structural Summary","x":"Why three bounded contexts instead of two: The original Events + Identity split grouped all conference-related entities together regardless of write profile. Separating…","i":"SessionQuestionAnswer SpeakerQuestionAnswer EventQuestionAnswer Question.IsRequired Session.EventId QuestionEntity SpeakerChanged ContentEditor QuestionType Room.EventId RoomChanged Organizer"},{"u":"/docs/guides/adc-specifications.html#10-specification-clarifications--addenda","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","t":"10. Specification Clarifications & Addenda","x":"This section addresses gaps, ambiguities, and implicit design decisions identified during implementation review. New business rules are numbered BR-61+. API contract…","i":"SessionQuestionAnswersController SpeakerQuestionAnswersController TimeZoneInfo.ConvertTimeFromUtc EventQuestionAnswersController MMCA.ADC.Modules.Engagement RemoveSpeakerQuestionAnswer UpdateSpeakerQuestionAnswer AddSpeakerQuestionAnswer SessionFeedbackSubmitted EventFeedbackSubmitted CreateQuestionHandler SessionQuestionAnswer"},{"u":"/docs/guides/adc-specifications.html#11-api-contract-specifications","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","t":"11. API Contract Specifications","x":"This section documents API design decisions that apply across all endpoints. --- All error responses use the RFC 9457 ProblemDetails format (the successor to RFC 7807, same…","i":"SessionQuestionAnswer SpeakerQuestionAnswer EventQuestionAnswer PaginationMetadata Session.Duration Speaker.FullName DomainException includeChildren FirstRowOnPage LastModifiedOn QuestionEntity TotalPageCount"},{"u":"/docs/guides/adc-specifications.html#12-authentication--identity-architecture","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","t":"12. Authentication & Identity Architecture","x":"This section defines the authentication mechanism for both the Web UI (Blazor) and MAUI (mobile) clients, which share a common Razor class library. It replaces the device-based…","i":"CascadingAuthenticationState AuthenticationStateProvider PasswordReset__ResetUrl PasswordResetController Auth.InvalidResetToken MaxValidationAttempts RequestWindowMinutes Speaker.LinkedUserId TokenLifetimeMinutes User.LinkedSpeakerId MaxRequestsPerEmail UserPasswordChanged"},{"u":"/docs/guides/common-ACCESSIBILITY.html","d":"Accessibility (rubric §21)","k":"Guides & Specifications","x":"The shared MMCA.Common.UI surface targets WCAG 2.1 AA. Accessibility is enforced two ways: an automated axe-core gate in CI (the bulk of coverage) and a documented manual…","i":"MMCA.Common.UI"},{"u":"/docs/guides/common-ACCESSIBILITY.html#automated-coverage-axe-core-wcag-21-aa","d":"Accessibility (rubric §21)","k":"Guides & Specifications","t":"Automated coverage (axe-core, WCAG 2.1 AA)","x":"The ui-e2e CI job runs Playwright + axe-core against the backend-less gallery; chromium is the blocking merge gate (firefox/webkit advisory). Scanned states: Component render is…","i":"PrimitivesSnapshotTests RegisterPageE2ETests PrimaryContrastText ErrorContrastText DarkModeE2ETests PageLoadingState MMCA.Common.UI progressbar mmca_theme div"},{"u":"/docs/guides/common-ACCESSIBILITY.html#manual-screen-reader-pass","d":"Accessibility (rubric §21)","k":"Guides & Specifications","t":"Manual screen-reader pass","x":"Automation cannot judge reading order, focus management, or announcement quality, so the shared surface is walked manually. Checklist (re-run on any change to MainLayout, the…","i":"ValidationMessage MainLayout.razor PageLoadingState MainLayout EditForm main"},{"u":"/docs/guides/common-ACCESSIBILITY.html#known-limitations-tracked","d":"Accessibility (rubric §21)","k":"Guides & Specifications","t":"Known limitations (tracked)","x":"- ~~Dark-mode contrast (§20, not §21).~~ RESOLVED (2026-07-11). The two dark-palette WCAG AA contrast failures the prototype scan flagged (filled-primary button label ~2.65:1 on…","i":"PaletteDark.PrimaryContrastText WarningContrastText ErrorContrastText DarkModeE2ETests EF5350 rgba"},{"u":"/docs/guides/common-BUILD-BY-HAND.html","d":"Building on MMCA.Common by Hand","k":"Guides & Specifications","x":"This is the long-form walkthrough: every project, every file, and every load-bearing line that goes into an application on the MMCA.Common framework, in the order you would…","i":"Contoso.Support Tickets dotnet Orders Order new"},{"u":"/docs/guides/common-BUILD-BY-HAND.html#what-you-will-build","d":"Building on MMCA.Common by Hand","k":"Guides & Specifications","t":"What you will build","x":"A modular monolith with one business module and two hosts: - Orders (your business module): an Order aggregate with OrderComment children, opened through a Result-returning…","i":"AllowAnonymous OrderComment Result Order sql web"},{"u":"/docs/guides/common-BUILD-BY-HAND.html#phase-0-prerequisites-and-decisions","d":"Building on MMCA.Common by Hand","k":"Guides & Specifications","t":"Phase 0: Prerequisites and decisions","x":"Install: - .NET 10 SDK (the framework targets net10.0 with LangVersion: preview for C extension types). - SQL Server reachable locally (LocalDB, a container, or the one Aspire…","i":"FrameworkVersionConsistencyTestsBase Directory.Packages.props MMCA.Common.API UseLocalMMCA LangVersion local.props install net10.0 package preview CS0103 dotnet"},{"u":"/docs/guides/common-BUILD-BY-HAND.html#phase-1-create-the-solution-and-the-build-plumbing","d":"Building on MMCA.Common by Hand","k":"Guides & Specifications","t":"Phase 1: Create the solution and the build plumbing","x":"Scaffolded. dotnet new mmca-app writes every file in this phase. Read it to know what each one does; you do not need to type any of it. The plumbing files are the load-bearing,…","i":"Directory.Packages.props Directory.Build.props Contoso.Support.slnx local.props.template OrderIdentifierType PackageReference MMCA.Helpdesk auditSources editorconfig nuget.config global.json Contracts"},{"u":"/docs/guides/common-BUILD-BY-HAND.html#phase-2-scaffold-the-module-project-set","d":"Building on MMCA.Common by Hand","k":"Guides & Specifications","t":"Phase 2: Scaffold the module project set","x":"Scaffolded. dotnet new mmca-app creates this project set for your first module, and pwsh build/add-module.ps1 adds another one later: it drives dotnet new mmca-module and then…","i":"Contoso.Support.Orders.Infrastructure Contoso.Support.Orders.Application Contoso.Support.Orders.Domain Contoso.Support.Orders.Shared Contoso.Support.Orders.API MMCA.Common.Infrastructure MMCA.Common.Application MMCA.Common.Domain MMCA.Common.Shared AddErrorResources MMCA.Common.API AllowAnonymous"},{"u":"/docs/guides/common-BUILD-BY-HAND.html#phase-3-the-vertical-slice-end-to-end-the-heart-of-it","d":"Building on MMCA.Common by Hand","k":"Guides & Specifications","t":"Phase 3: The vertical slice end-to-end (the heart of it)","x":"Scaffolded. The generated module already contains this slice and six more, worked end to end. dotnet new mmca-command and dotnet new mmca-query add another one. This phase is the…","i":"EntityTypeConfigurationSQLServer ConcurrencyConventionTestsBase AddModuleOrdersInfrastructure ScanModuleApplicationServices AuditableAggregateRootEntity OrderOpenedIntegrationEvent OrderCommentIdentifierType IUnitOfWork.GetRepository AddApplicationDecorators IIntegrationEventHandler DomainEventDispatcher EntityControllerBase"},{"u":"/docs/guides/common-BUILD-BY-HAND.html#phase-4-dbcontext-model-and-migrations","d":"Building on MMCA.Common by Hand","k":"Guides & Specifications","t":"Phase 4: DbContext model and migrations","x":"Partly scaffolded. The migrations project and its design-time factory are generated. Running dotnet ef migrations add InitialCreate is still yours, and for a module added later…","i":"ApplicationSettings.DatabaseInitStrategy InitializeDatabaseAsync SQLServerDbContext EnsureCreated InitialCreate DataSources migrations Migrate dotnet None add"},{"u":"/docs/guides/common-BUILD-BY-HAND.html#phase-5-compose-the-monolith-host-and-run-it","d":"Building on MMCA.Common by Hand","k":"Guides & Specifications","t":"Phase 5: Compose the monolith host and run it","x":"Scaffolded. Both hosts, the AppHost, and the .resx pairs are generated. Read this phase before you touch any of them: the DI sequence, WaitFor(sql) rather than the database…","i":"MmcaCultureBootstrap.SetBrowserCultureAsync LocalizedTextConventionTestsBase LocalizationResourceTestsBase UseCommonRequestLocalization OrderOpenedIntegrationEvent UseCommonMiddlewarePipeline services.AddErrorResources AddApplicationDecorators YourModuleErrorResources EnsureSuccessStatusCode EndpointCultureApplier UseRequestLocalization"},{"u":"/docs/guides/common-BUILD-BY-HAND.html#phase-6-tests-and-the-architecture-fitness-map","d":"Building on MMCA.Common by Hand","k":"Guides & Specifications","t":"Phase 6: Tests and the architecture-fitness map","x":"Scaffolded, with one deliberate gap. All three test projects and the map are generated. The IntegrationEventContractTests subclass is NOT: its frozen literal lists members…","i":"FrameworkVersionConsistencyTestsBase ConstructorDependencyCountTestsBase IntegrationEventContractTestsBase LocalizedTextConventionTestsBase MMCA.Common.Testing.Architecture SpecificationConventionTestsBase MicroserviceExtractionTestsBase ConcurrencyConventionTestsBase ControllerConventionTestsBase IntegrationEventContractTests LocalizationResourceTestsBase HandlerConventionTestsBase"},{"u":"/docs/guides/common-BUILD-BY-HAND.html#phase-7-upgrading-the-framework-version","d":"Building on MMCA.Common by Hand","k":"Guides & Specifications","t":"Phase 7: Upgrading the framework version","x":"Not scaffolded. dotnet new mmca-app --framework-version picks the version you START on; moving to a later one is this phase. When a new MMCA.Common release ships, upgrade in one…","i":"FrameworkVersionConsistencyTestsBase Directory.Packages.props packages.lock.json UseLocalMMCA local.props your.slnx restore dotnet new"},{"u":"/docs/guides/common-BUILD-BY-HAND.html#phase-8-extract-a-module-into-its-own-service-the-payoff","d":"Building on MMCA.Common by Hand","k":"Guides & Specifications","t":"Phase 8: Extract a module into its own service (the payoff)","x":"Not scaffolded. The generated solution carries the plumbing (the .Contracts proto convention and the .Service OpenAPI block in Directory.Build.props), but the extraction itself…","i":"GrpcResultExceptionInterceptor OrderOpenedIntegrationEvent MMCA.Common.Aspire.Hosting WithSQLServerDataSource AddGrpcServiceDefaults Directory.Build.props RequestVersionExact AddTypedGrpcClient WithJwksDiscovery MMCA.Common.Grpc Support_Identity OutboxMessages"},{"u":"/docs/guides/common-BUILD-BY-HAND.html#verification-checklist","d":"Building on MMCA.Common by Hand","k":"Guides & Specifications","t":"Verification checklist","x":"1. Build green: dotnet build Contoso.Support.slnx with no warnings (TreatWarningsAsErrors + five analyzers). This is the primary automatable gate. 2. Unit + architecture tests…","i":"OrderOpenedIntegrationEvent Contoso.Support.slnx IArchitectureMap OutboxMessages InitialCreate OrderComment migrations AppHost dotnet build Order test"},{"u":"/docs/guides/common-BUILD-BY-HAND.html#where-to-look-next","d":"Building on MMCA.Common by Hand","k":"Guides & Specifications","t":"Where to look next","x":"- Getting Started: the one-command path that writes phases 1 through 6 for you. If you are starting a new solution rather than adding the framework to an existing one, that is…","i":"CLAUDE.md README.md Helpdesk Tickets Ticket"},{"u":"/docs/guides/common-COST.html","d":"Cost & FinOps Notes (rubric §31)","k":"Guides & Specifications","x":"MMCA.Common is a library, so it cannot provision anything: right-sizing, scale rules, budgets, and per-service cost attribution live in the consumer apps' IaC (e.g. MMCA.ADC's…"},{"u":"/docs/guides/common-COST.html#what-the-framework-does-for-cost","d":"Cost & FinOps Notes (rubric §31)","k":"Guides & Specifications","t":"What the framework does for cost","x":"- Telemetry ingestion is the real line item, so high-volume / low-value spans are dropped. OutboxPollFilterProcessor (MMCA.Common.Aspire) suppresses the recurring OutboxPoll…","i":"http.client.open_connections OutboxPollFilterProcessor TraceIdRatioBasedSampler ConfigureOpenTelemetry OutboxCleanupService AddServiceDefaults MMCA.Common.Aspire ParentBasedSampler SocketsHttpHandler request.duration active_requests AppDependencies"},{"u":"/docs/guides/common-COST.html#recommended-consumer-defaults-set-these-downstream","d":"Cost & FinOps Notes (rubric §31)","k":"Guides & Specifications","t":"Recommended consumer defaults (set these downstream)","x":"- Telemetry retention & sampling. Tune Log Analytics retention to the minimum the consumer's compliance window allows, and set Telemetry:TracesSampleRatio (the built-in…"},{"u":"/docs/guides/common-COST.html#cost-attribution--guardrail-samples-distilled-from-mmcaadc","d":"Cost & FinOps Notes (rubric §31)","k":"Guides & Specifications","t":"Cost-attribution & guardrail samples (distilled from MMCA.ADC)","x":"These belong in the consumer's IaC, not the library, but the framework documents the shape so every consumer attributes spend and guards surges the same way. The worked, deployed…"},{"u":"/docs/guides/common-COST.html#out-of-scope-for-the-framework-by-design","d":"Cost & FinOps Notes (rubric §31)","k":"Guides & Specifications","t":"Out of scope for the framework (by design)","x":"Provisioning, scale rules, budgets, per-service cost attribution, and surge/revert automation are consumer/IaC concerns and are not added to the library: see also ADR-009…"},{"u":"/docs/guides/common-ECOMMERCE-SAMPLE.html","d":"Build MMCA.ECommerce: a Two-Module Store from the Templates","k":"Guides & Specifications","x":"MMCA.ECommerce is the simplest e-commerce application on the MMCA.Common framework: a Products catalog module and an Orders module with line items, behind a REST API host and a…","i":"MMCA.Templates dotnet new"},{"u":"/docs/guides/common-ECOMMERCE-SAMPLE.html#before-you-start","d":"Build MMCA.ECommerce: a Two-Module Store from the Templates","k":"Guides & Specifications","t":"Before you start","x":"- .NET 10 SDK (the framework targets net10.0 with LangVersion: preview). - Docker Desktop (Aspire provisions SQL Server as a container). - EF Core tools: dotnet tool install…","i":"MMCA.Templates LangVersion install net10.0 preview dotnet pwsh tool ps1"},{"u":"/docs/guides/common-ECOMMERCE-SAMPLE.html#2-generate-the-solution-with-the-products-module","d":"Build MMCA.ECommerce: a Two-Module Store from the Templates","k":"Guides & Specifications","t":"2. Generate the solution with the Products module","x":"Five options do most of this guide's old work. Three remove an axis a catalog product does not have, and the code for an axis you turn off is never generated: --flat drops the…","i":"ProductCreatedIntegrationEvent ProductCreatedHandler RequesterUserId Created Opened Name"},{"u":"/docs/guides/common-ECOMMERCE-SAMPLE.html#3-add-the-orders-module","d":"Build MMCA.ECommerce: a Two-Module Store from the Templates","k":"Guides & Specifications","t":"3. Add the Orders module","x":"build/add-module.ps1 ships inside the solution you just generated. It runs dotnet new mmca-module with the shape options passed through, then performs every wire-up the template…","i":"ECommerceArchitectureMap.cs SQLServerMigrationsAssembly services.AddErrorResources OrderItemIdentifierType WithSQLServerDataSource Directory.Build.props OrdersErrorResources MMCA.ECommerce.slnx ChangeItemQuantity ECommerce_Products appsettings.json ProjectReference"},{"u":"/docs/guides/common-ECOMMERCE-SAMPLE.html#4-reshape-products-into-a-catalog-product","d":"Build MMCA.ECommerce: a Two-Module Store from the Templates","k":"Guides & Specifications","t":"4. Reshape Products into a catalog product","x":"The scaffolded module arrives as the template's worked example in your namespaces, already shaped by the flags in step 2: no children, no status, no requester, Name instead of…","i":"UpdateRequestsAreConcurrencyAware Microsoft.EntityFrameworkCore Product.Description.TooLong ModuleApplicationDbContext ProductCreateRequestMapper DomainEntityState.Updated Product.Description.Empty Directory.Packages.props DependencyInjection.cs TreatWarningsAsErrors Product.InvalidPrice Product.Name.TooLong"},{"u":"/docs/guides/common-ECOMMERCE-SAMPLE.html#5-reshape-orders-into-an-order-with-line-items","d":"Build MMCA.ECommerce: a Two-Module Store from the Templates","k":"Guides & Specifications","t":"5. Reshape Orders into an order with line items","x":"Orders keeps the child-collection pattern the template scaffolded, retargeted. -Child Item already did the naming (the entity is OrderItem, the slices are AddItem / EditItem /…","i":"UpdateRequestsAreConcurrencyAware Order.Item.ProductName.TooLong Total_ExcludesSoftDeletedItems EnsureStatusAllowsItemChanges Microsoft.EntityFrameworkCore Order.InvalidStatusTransition Order.Item.ProductName.Empty ChangeOrderStatusRequest.cs OrderPlacedIntegrationEvent ModuleApplicationDbContext Order.CustomerName.TooLong ChangeItemQuantityCommand"},{"u":"/docs/guides/common-ECOMMERCE-SAMPLE.html#6-point-the-ui-at-the-new-domain","d":"Build MMCA.ECommerce: a Two-Module Store from the Templates","k":"Guides & Specifications","t":"6. Point the UI at the new domain","x":"The scaffolded Blazor host already has the load-bearing parts: the typed ECommerceApiClient calling the API server-side through Aspire service discovery (no CORS, no token), the…","i":"MMCA.ECommerce.Orders.Shared string.IsNullOrWhiteSpace Snackbar.RequiredFields Dialog.Delete.Heading System.Globalization GetProductsAsync ProjectReference missingRequired SectionHeading PageHeading es.resx _field"},{"u":"/docs/guides/common-ECOMMERCE-SAMPLE.html#7-create-the-migrations","d":"Build MMCA.ECommerce: a Two-Module Store from the Templates","k":"Guides & Specifications","t":"7. Create the migrations","x":"Neither module has a migration yet: any shape flag makes mmca-app drop the template's sample one (it described the sample shape), and -SkipMigration deferred the Orders one to…","i":"editorconfig migrations Migrations dotnet add"},{"u":"/docs/guides/common-ECOMMERCE-SAMPLE.html#8-the-two-one-time-fixups-then-run-it","d":"Build MMCA.ECommerce: a Two-Module Store from the Templates","k":"Guides & Specifications","t":"8. The two one-time fixups, then run it","x":"Apply the two fixups the scaffold deliberately leaves to you (they are name-dependent, so no generated value could be right). First, sort the using directives and the identifier…","i":"ProductCreatedIntegrationEvent IntegrationEventContractTests OrderPlacedIntegrationEvent ArchitectureTests.cs AllowAnonymous editorconfig SCAFFOLD IDE0021 SA1210 SA1211 DELTA Open"},{"u":"/docs/guides/common-ECOMMERCE-SAMPLE.html#verification-checklist","d":"Build MMCA.ECommerce: a Two-Module Store from the Templates","k":"Guides & Specifications","t":"Verification checklist","x":"1. Baseline green immediately after mmca-app, before any edit: 81 tests. 2. After build/add-module.ps1: still green at 99 tests, both modules' scaffolded suites running. 3. After…","i":"MMCA.ECommerce.slnx OutboxMessages InitialCreate dotnet build test"},{"u":"/docs/guides/common-ECOMMERCE-SAMPLE.html#where-to-look-next","d":"Build MMCA.ECommerce: a Two-Module Store from the Templates","k":"Guides & Specifications","t":"Where to look next","x":"- MMCA.ECommerce: the finished result of this guide, build- and test-verified. - Getting started: the single-module path, the vertical-slice templates (mmca-command /…"},{"u":"/docs/guides/common-GETTING-STARTED.html","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","x":"MMCA.Common is a .NET 10 framework for DDD, Clean Architecture, and CQRS, shipped as a set of lockstep-versioned NuGet packages (the authoritative list and count live in…"},{"u":"/docs/guides/common-GETTING-STARTED.html#before-you-start","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","t":"Before you start","x":"- .NET 10 SDK. The framework targets net10.0 with LangVersion: preview for C extension types. - Docker Desktop. Aspire provisions SQL Server as a container, so you do not install…","i":"MMCA.Templates LangVersion install net10.0 preview dotnet tool"},{"u":"/docs/guides/common-GETTING-STARTED.html#1-install-the-template-pack","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","t":"1. Install the template pack","x":"Four templates arrive: mmca-app (a whole solution), mmca-module (a business module across all five layers), and mmca-command / mmca-query (a single vertical slice)."},{"u":"/docs/guides/common-GETTING-STARTED.html#2-generate-the-solution","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","t":"2. Generate the solution","x":"Three names, and they are independent: the solution (also your root namespace), the first module in plural PascalCase, and that module's aggregate root in singular PascalCase.…","i":"ProjectReference local.props Billing Invoice"},{"u":"/docs/guides/common-GETTING-STARTED.html#3-build-and-test-before-you-change-anything","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","t":"3. Build and test before you change anything","x":"That is a warning-free build with TreatWarningsAsErrors and all five analyzers at error severity, and a passing test run including the architecture-fitness rules, with no…","i":"TreatWarningsAsErrors"},{"u":"/docs/guides/common-GETTING-STARTED.html#4-create-the-first-migration","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","t":"4. Create the first migration","x":"The scaffold ships the migrations project and its design-time factory; the migration itself describes your entities, so it is yours to generate: Always pass --context…","i":"SQLServerDbContext DbSet"},{"u":"/docs/guides/common-GETTING-STARTED.html#5-run-it","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","t":"5. Run it","x":"Run this from a real, interactive terminal. Launched from a headless or background shell the Aspire AppHost stalls at control-plane init and no dashboard appears. The dashboard…","i":"OrderOpenedIntegrationEvent AllowAnonymous POST GET sql web"},{"u":"/docs/guides/common-GETTING-STARTED.html#6-the-two-one-time-fixups","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","t":"6. The two one-time fixups","x":"The scaffold deliberately does not hand these over, because renaming invalidates them and no fixed value is right for every name you could pick. Both are covered in full in the…","i":"IntegrationEventContractTestsBase Contoso.Support.Orders.Shared Zeta.App.Orders.Shared ArchitectureTests.cs editorconfig SCAFFOLD IDE0021 SA1211 Ticket DELTA using"},{"u":"/docs/guides/common-GETTING-STARTED.html#what-you-were-handed","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","t":"What you were handed","x":"The Order aggregate arrives fully worked: a Result-returning factory, invariants, guarded mutations raising domain events, a child entity, soft-delete cascade, the caching pair,…","i":"AddApplicationDecorators Directory.Build.props OrderIdentifierType IArchitectureMap HandleFailure ModuleLoader ErrorType WaitFor global Result DbSet Order"},{"u":"/docs/guides/common-GETTING-STARTED.html#add-your-next-feature","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","t":"Add your next feature","x":"A vertical slice (the path every feature follows) is one command, run from the module's UseCases folder: Handlers, validators, and mappers are convention-scanned, so there is no…","i":"order.TransferToRequester AddErrorResources RequesterUserId AddDomainEvent Result.Combine ChangeStatus GetByIdAsync SaveChanges definition IsFailure CacheKey Comments"},{"u":"/docs/guides/common-GETTING-STARTED.html#surface-the-slice-at-the-edge","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","t":"Surface the slice at the edge","x":"The scaffold stops at the handler, and the template's closing instructions tell you to map the command in your module's controller. Every write in the generated app follows the…","i":"ThrowIfDomainExceptionAsync _transferRequesterUserId ChangeOrderStatusRequest Api.TransferOrderAsync EntityControllerBase TransferOrderCommand OrderDetail.es.resx ICacheInvalidating ChangeStatusAsync OrderDetail.razor OrderDetail.resx SupportApiClient"},{"u":"/docs/guides/common-GETTING-STARTED.html#then-what","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","t":"Then what","x":"- Upgrade the framework. Bump every MMCA.Common. entry in Directory.Packages.props together, in one pass. See Phase 7 and the versioning policy. - Add real authentication. Copy…","i":"Directory.Packages.props Authorize Contracts Service"},{"u":"/docs/guides/common-GETTING-STARTED.html#verification-checklist","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","t":"Verification checklist","x":"1. dotnet new mmca-app -n produced a solution that builds and tests green before you changed anything. 2. dotnet build .slnx is warning-free (TreatWarningsAsErrors + five…","i":"OutboxMessages InitialCreate migrations healthy YourApp dotnet build slnx test then add new"},{"u":"/docs/guides/common-GETTING-STARTED.html#where-to-look-next","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","t":"Where to look next","x":"- Templates: every parameter of all four templates, dropping the Blazor UI host, and how the pack is built. ADR-065 explains why it is derived from the reference app rather than…"},{"u":"/docs/guides/common-RESILIENCE.html","d":"Resilience & Business Continuity (rubric §29)","k":"Guides & Specifications","x":"MMCA.Common is a library, so it cannot operate a deployment: restores, RTO/RPO, and SLO alerting are executed in the consumer apps' IaC (e.g. MMCA.ADC's…"},{"u":"/docs/guides/common-RESILIENCE.html#what-the-framework-provides-and-verifies-in-repo","d":"Resilience & Business Continuity (rubric §29)","k":"Guides & Specifications","t":"What the framework provides (and verifies in-repo)","x":"Failure isolation, graceful degradation, graceful startup, and the restore procedure itself are therefore demonstrated and tested centrally: the framework drills backup→restore…","i":"ResilienceCircuitBreakerFaultInjectionTests OpenIdConnectMetadataWarmupTask WarmupReadinessHealthCheckTests AddStandardResilienceHandler ConfigureHttpClientDefaults WarmupReadinessHealthCheck DatabaseRestoreDrillTests ConfigureBrokerTransport WarmupHostedServiceTests WarmupReadinessGateTests ResilienceHandlerTests WarmupHostedService"},{"u":"/docs/guides/common-RESILIENCE.html#baseline-slo--error-budget-template-consumers-fill-in","d":"Resilience & Business Continuity (rubric §29)","k":"Guides & Specifications","t":"Baseline SLO / error-budget template (consumers fill in)","x":"Adopt and tune per app; ADC's filled-in version lives in infra/DISASTER-RECOVERY.md + the SLO metric-alerts in infra/main.bicep. Define RTO/RPO per service (ADC's worked…","i":"requests"},{"u":"/docs/guides/common-RESILIENCE.html#restore-drill-runbook-reference","d":"Resilience & Business Continuity (rubric §29)","k":"Guides & Specifications","t":"Restore-drill runbook (reference)","x":"The only evidence backups actually restore is a periodic drill: restore a throwaway copy, confirm it comes back Online, record the measured restore time, then delete the copy.…"},{"u":"/docs/guides/common-RESPONSIVE.html","d":"Responsive Design & Cross-Browser Support (rubric §22)","k":"Guides & Specifications","x":"This document is the supported-device and browser matrix for the shared MMCA.Common.UI component library. It makes the responsive contract explicit (the rubric §22 note that it…","i":"MMCA.Common.UI"},{"u":"/docs/guides/common-RESPONSIVE.html#breakpoints","d":"Responsive Design & Cross-Browser Support (rubric §22)","k":"Guides & Specifications","t":"Breakpoints","x":"The framework keeps C viewport detection and CSS media queries aligned around one mobile threshold. The C 960px mobile cutoff and the CSS 1023.98px cutoff intentionally differ:…","i":"BreakpointConstants.IsMobileBreakpoint media i.e"},{"u":"/docs/guides/common-RESPONSIVE.html#touch-targets","d":"Responsive Design & Cross-Browser Support (rubric §22)","k":"Guides & Specifications","t":"Touch targets","x":"Interactive controls on mobile surfaces meet a 48px minimum hit area (Material Design), exceeding both WCAG 2.5.8 Target Size (Minimum, AA, 24px) and WCAG 2.5.5 Target Size…"},{"u":"/docs/guides/common-RESPONSIVE.html#grid-density","d":"Responsive Design & Cross-Browser Support (rubric §22)","k":"Guides & Specifications","t":"Grid density","x":"DataGridListPageBase exposes a DenseGrid property and a ToggleDensity() method. Derived list pages bind Dense=\"@DenseGrid\" on their MudDataGrid and surface a toggle. The chosen…","i":"ListPageQueryStateServiceTests ListPageStateServiceTests DataGridListPageBase ToggleDensity MudDataGrid DenseGrid TDto"},{"u":"/docs/guides/common-RESPONSIVE.html#browser-matrix","d":"Responsive Design & Cross-Browser Support (rubric §22)","k":"Guides & Specifications","t":"Browser matrix","x":"The shared UI is tested against three Playwright engines in CI (.github/workflows/ci.yml, ui-e2e job): a real-browser axe (WCAG 2.1 AA) + render smoke against the backend-less…","i":"false"},{"u":"/docs/guides/common-TEMPLATES.html","d":"Templates: scaffolding an MMCA app","k":"Guides & Specifications","x":"MMCA.Templates is a dotnet new pack that scaffolds solutions, modules, and vertical slices on the MMCA.Common framework. It exists because standing up a new app by hand meant 12…","i":"MMCA.Templates UseCases dotnet new"},{"u":"/docs/guides/common-TEMPLATES.html#mmca-app","d":"Templates: scaffolding an MMCA app","k":"Guides & Specifications","t":"mmca-app","x":"The module and aggregate names are independent, so --module Billing --aggregate Invoice is fine. Everything derived from them follows: routes, the Aspire database resource, the…","i":"IntegrationEventContractTestsBase Contoso.Support.Orders.Shared ShipmentLineIdentifierType Zeta.App.Orders.Shared ArchitectureTests.cs builder.AddProject ProjectReference Contoso.Support EditLineRequest RequesterUserId AddLineRequest AppHost.csproj"},{"u":"/docs/guides/common-TEMPLATES.html#mmca-module","d":"Templates: scaffolding an MMCA app","k":"Guides & Specifications","t":"mmca-module","x":"All six behave exactly as they do for mmca-app, and they are per module: a solution can hold a flat, status-less catalog module beside one whose aggregate owns a growing child…","i":"SQLServerMigrationsAssembly services.AddErrorResources Architecture.Tests.csproj OrderItemIdentifierType Directory.Build.props Migrations.SqlServer Contoso.Support ErrorResources ModuleLoader DataSources FirstModule RemoveItem"},{"u":"/docs/guides/common-TEMPLATES.html#buildadd-moduleps1","d":"Templates: scaffolding an MMCA app","k":"Guides & Specifications","t":"build/add-module.ps1","x":"Since 1.4.0 every solution mmca-app generates ships this script, and it is the supported way to add a second module. It runs mmca-module with your shape options passed through,…","i":"IntegrationEventContractTests migrations copyOnly dotnet diff Name slnx add git"},{"u":"/docs/guides/common-TEMPLATES.html#mmca-command-and-mmca-query","d":"Templates: scaffolding an MMCA app","k":"Guides & Specifications","t":"mmca-command and mmca-query","x":"Run these from the module's UseCases folder. Each creates a folder named after the slice holding its two files. --child-collection exists because both handlers load through…","i":"EntityControllerBase MMCA.Templates GetByIdAsync definition CacheKey Comments includes UseCases contain dotnet nameof Result"},{"u":"/docs/guides/common-TEMPLATES.html#how-the-pack-is-built","d":"Templates: scaffolding an MMCA app","k":"Guides & Specifications","t":"How the pack is built","x":"The template content is the MMCA.Helpdesk reference application itself, staged at pack time. There is no second copy of the solution, so the template cannot drift from the app…"},{"u":"/docs/guides/common-VERSIONING.html","d":"Versioning & Breaking-Change Policy","k":"Guides & Specifications","x":"MMCA.Common publishes fifteen NuGet packages that are versioned and released together as a single unit. They share one version number so a consumer never has to reason about…","i":"MMCA.Common.UI.Maui release.yml"},{"u":"/docs/guides/common-VERSIONING.html#semantic-versioning","d":"Versioning & Breaking-Change Policy","k":"Guides & Specifications","t":"Semantic Versioning","x":"Versions follow SemVer 2.0: MAJOR.MINOR.PATCH: - MAJOR: reserved (see \"Breaking changes within 1.x\" below). - MINOR: new capability, and the channel breaking changes currently…","i":"vMAJOR.MINOR.PATCH MAJOR.MINOR.PATCH v1.51.0"},{"u":"/docs/guides/common-VERSIONING.html#what-counts-as-breaking","d":"Versioning & Breaking-Change Policy","k":"Guides & Specifications","t":"What counts as breaking","x":"A change is breaking if it is any of: - Removing or renaming a public type/member, or changing a signature. - Changing the meaning of an existing configuration key, or changing a…","i":"Result"},{"u":"/docs/guides/common-VERSIONING.html#breaking-changes-within-1x","d":"Versioning & Breaking-Change Policy","k":"Guides & Specifications","t":"Breaking changes within 1.x","x":"Breaking changes ship as MINOR bumps, not MAJOR ones, and the version number is therefore not a reliable breakage signal on its own. This is deliberate and follows from the…","i":"IIntegrationEventPublisher IntegrationEventPublisher WithSQLServerDataSource WithDataSource IEventBus v1.123.0 v1.79.0"},{"u":"/docs/guides/common-VERSIONING.html#consumer-rollout","d":"Versioning & Breaking-Change Policy","k":"Guides & Specifications","t":"Consumer rollout","x":"Per project convention, framework upgrades are swept across all consumers in one pass: there are no opt-in flags or phased rollouts for a MMCA.Common change. When a release…"},{"u":"/docs/guides/common-VERSIONING.html#deprecation","d":"Versioning & Breaking-Change Policy","k":"Guides & Specifications","t":"Deprecation","x":"There is no [Obsolete] grace period today. Because the lockstep sweep updates every first-party caller in the same change set, a superseded API is removed in the release that…","i":"Obsolete"},{"u":"/docs/guides/common-VERSIONING.html#supply-chain","d":"Versioning & Breaking-Change Policy","k":"Guides & Specifications","t":"Supply chain","x":"- All package versions are centrally pinned (Directory.Packages.props). - NuGet lock files are committed for reproducible restores. - MassTransit is pinned to v8 by policy (v9…","i":"Directory.Packages.props MassTransit"},{"u":"/docs/guides/store-ACCESSIBILITY-SCREENREADER-PASS.html","d":"Manual Screen-Reader Pass: Runbook (§21 Accessibility)","k":"Guides & Specifications","x":"The automated layer (axe-core WCAG 2.1 AA scans in Tests/E2E/MMCA.Store.E2E.Tests/Workflows/AccessibilityTests.cs plus the shared Login/Register/Profile bases in…","i":"MMCA.Common.Testing.E2E"},{"u":"/docs/guides/store-ACCESSIBILITY-SCREENREADER-PASS.html#tools","d":"Manual Screen-Reader Pass: Runbook (§21 Accessibility)","k":"Guides & Specifications","t":"Tools","x":"Run against the app started via Aspire (dotnet run --project Source/Hosting/MMCA.Store.AppHost), reaching the Web UI at https://localhost:6002. Test with the keyboard only (no…","i":"dotnet start Ctrl Alt Cmd run"},{"u":"/docs/guides/store-ACCESSIBILITY-SCREENREADER-PASS.html#what-to-verify-on-every-flow","d":"Manual Screen-Reader Pass: Runbook (§21 Accessibility)","k":"Guides & Specifications","t":"What to verify on every flow","x":"1. Landmarks and headings. The SR's landmark/heading list (NVDA Insert+F7) exposes a logical banner / navigation / main structure and a sensible h1...hN outline (one h1 per…","i":"Wcag21AaExceptMudPagerCombobox MainLayout.razor MMCA.Common.UI navigation h1...hN banner Insert Shift main Tab"},{"u":"/docs/guides/store-ACCESSIBILITY-SCREENREADER-PASS.html#results-log","d":"Manual Screen-Reader Pass: Runbook (§21 Accessibility)","k":"Guides & Specifications","t":"Results log","x":"Record one row per pass. A flow with any unresolved blocker is a FAIL (open a backlog item for it)."},{"u":"/docs/guides/store-ACCESSIBILITY-SCREENREADER-PASS.html#after-a-pass","d":"Manual Screen-Reader Pass: Runbook (§21 Accessibility)","k":"Guides & Specifications","t":"After a pass","x":"- File any defect as a remediation item; cross-link it from the §21 row's evidence. - Update the dated row above so the scorecard can cite a real, current manual pass (this is…"},{"u":"/docs/guides/store-BusinessWorkflows.html","d":"MMCA Business Workflow Analysis","k":"Guides & Specifications"},{"u":"/docs/guides/store-BusinessWorkflows.html#workflow-list-summary","d":"MMCA Business Workflow Analysis","k":"Guides & Specifications","t":"Workflow List Summary","x":"---","i":"productId variantId imageId DELETE userId POST GET PUT"},{"u":"/docs/guides/store-BusinessWorkflows.html#1-identity-module-workflows","d":"MMCA Business Workflow Analysis","k":"Guides & Specifications","t":"1. Identity Module Workflows","x":"Entry Point: POST /auth/register, AuthController.RegisterAsync(), AllowAnonymous Execution Path: Business Steps: 1. Validate registration input (email, password, first name, last…","i":"AuthController.RegisterAsync AuthController.LoginAsync User.RefreshTokenExpiry Customer.ChangeAddress CustomerAddressChanged Customer.ChangeEmail CustomerEmailChanged RequireAuthenticated Customer.ChangeName CustomerNameChanged User.RefreshToken CustomerCreated"},{"u":"/docs/guides/store-BusinessWorkflows.html#2-catalog-module-workflows","d":"MMCA Business Workflow Analysis","k":"Guides & Specifications","t":"2. Catalog Module Workflows","x":"Entry Point: POST /categories, Admin only, [Idempotent] Response: 201 Created with CategoryDTO Entry Point: PUT /categories/{id}/name, Admin only Entry Point: PUT…","i":"CatalogFeatures.ProductImages ProductVariantPriceChanged ProductVariantCartInfoDTO ProductVariantSkuChanged IProductVariantService ProductVariantRemoved ProductNameChanged ParentCategoryId ProductImageData CategoryDeleted ProductImageDTO ProductDeleted"},{"u":"/docs/guides/store-BusinessWorkflows.html#3-sales-module-workflows","d":"MMCA Business Workflow Analysis","k":"Guides & Specifications","t":"3. Sales Module Workflows","x":"Entry Point: POST /shoppingcarts/{customerId}/shoppingcartitems, Authenticated (owner or admin via OwnerOrAdminFilter) Decision Points: - Product variant doesn't exist - NotFound…","i":"ShoppingCartItemQuantityAdjusted InventoryItem.AvailableQuantity OrderPaymentFailedSagaHandler BulkSetInventoryResultDTO Order.InventoryRestored ProductVariant.NotFound ShoppingCartItemRemoved IProductVariantService ShoppingCartCheckedOut StripePaymentIntentId ShoppingCart.Status ShoppingCartCleared"},{"u":"/docs/guides/store-BusinessWorkflows.html#4-ui-workflows","d":"MMCA Business Workflow Analysis","k":"Guides & Specifications","t":"4. UI Workflows","x":"The UI provides a complete shopping experience through the CartDrawer component and Blazor pages. The CartDrawer is the only cart UI: there is no dedicated cart page. It is a…","i":"ICartStateService IUIModule OnChange"},{"u":"/docs/guides/store-BusinessWorkflows.html#5-cross-module-interactions","d":"MMCA Business Workflow Analysis","k":"Guides & Specifications","t":"5. Cross-Module Interactions","x":"Module dependency: Sales declares a hard dependency on Catalog (RequiresDependencies = true). When Catalog is disabled, a DisabledProductVariantService stub is registered and…","i":"DisabledProductVariantService IProductVariantService UserRegisteredHandler RequiresDependencies GetUnitPricesAsync GetIdBySkuAsync SkuExistsAsync ExistsAsync true"},{"u":"/docs/guides/store-BusinessWorkflows.html#6-external-interactions","d":"MMCA Business Workflow Analysis","k":"Guides & Specifications","t":"6. External Interactions","x":"---","i":"StripePaymentService IDbContextFactory SmtpEmailSender"},{"u":"/docs/guides/store-BusinessWorkflows.html#7-cross-cutting-concerns-participating-in-workflows","d":"MMCA Business Workflow Analysis","k":"Guides & Specifications","t":"7. Cross-Cutting Concerns Participating in Workflows","x":"---","i":"TransactionalCommandDecorator ProfilingCommandDecorator CachingCommandDecorator ProfilingQueryDecorator ICacheInvalidating OwnerOrAdminFilter IdempotencyFilter ITransactional ApiVersion Idempotent"},{"u":"/docs/guides/store-BusinessWorkflows.html#8-end-to-end-customer-journey","d":"MMCA Business Workflow Analysis","k":"Guides & Specifications","t":"8. End-to-End Customer Journey","x":"Alternative Flows: - Payment fails - Order status PaymentFailed - customer can retry (create new Stripe session) - Cancel order - Status Cancelled (from PendingPayment,…","i":"StripePaymentIntentId PaymentFailed Cancelled"},{"u":"/docs/guides/store-BusinessWorkflows.html#9-potentially-missing-or-incomplete-workflows","d":"MMCA Business Workflow Analysis","k":"Guides & Specifications","t":"9. Potentially Missing or Incomplete Workflows","x":"--- This document is derived from source code analysis. All workflows, decisions, and behaviors described above are confirmed implementations traceable to the referenced source…","i":"OrderPaymentFailedSagaHandler OrderCancelledSagaHandler MarkAsDelivered SmtpEmailSender User.Deactivate UserDeactivated"},{"u":"/docs/guides/store-NavigationFlow.html","d":"Navigation Flow","k":"Guides & Specifications","x":"This document maps the site navigation flow for each actor in the MMCA.Store application. Each mermaid diagram shows the pages accessible to that actor and the directional…","i":"NavigationFlow.md"},{"u":"/docs/guides/store-NavigationFlow.html#actors","d":"Navigation Flow","k":"Guides & Specifications","t":"Actors","x":"Roles and enforcement: Admin is the only elevated role (registration creates a Customer). The 14 admin pages carry page-level [Authorize(Roles = \"Admin\")], regression-gated in CI…","i":"customer_id Authorize Customer Admin Roles"},{"u":"/docs/guides/store-NavigationFlow.html#1-anonymous-user","d":"Navigation Flow","k":"Guides & Specifications","t":"1. Anonymous User","x":"Pages accessible without authentication: home, login, register, the two password-reset pages, and the public catalog. Add-to-cart on the product detail page sits inside an…","i":"AuthorizeView Authorize"},{"u":"/docs/guides/store-NavigationFlow.html#2-customer-authenticated-user","d":"Navigation Flow","k":"Guides & Specifications","t":"2. Customer (Authenticated User)","x":"Inherits all anonymous pages. Gains the profile page, the cart drawer (a layout component, not a route), checkout, and their own orders. Unauthenticated visitors deep-linking to…","i":"OrphanOrderRecovery Specification Authorize"},{"u":"/docs/guides/store-NavigationFlow.html#3-admin","d":"Navigation Flow","k":"Guides & Specifications","t":"3. Admin","x":"Inherits all customer pages, plus the admin CRUD surfaces for all three modules. Every page below carries [Authorize(Roles = \"Admin\")]; a customer deep-linking to any of them…","i":"Authorize Roles"},{"u":"/docs/guides/store-NavigationFlow.html#authorization-model","d":"Navigation Flow","k":"Guides & Specifications","t":"Authorization Model","x":"Three cooperating layers; the API is always the boundary: 1. Page-level route guards. The 14 admin pages carry [Authorize(Roles = \"Admin\")] and /profile / /orders carry…","i":"OwnershipHelper.GetOwnershipSpecification OwnerOrAdminFilter mmca_auth_access AuthorizeView customer_id Authorize c4adff2 Roles"},{"u":"/docs/guides/store-Specification.html","d":"MMCA Business Specification Document","k":"Guides & Specifications"},{"u":"/docs/guides/store-Specification.html#1-system-overview","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"1. System Overview","x":"MMCA is an e-commerce platform built with .NET 10.0 using DDD and Clean Architecture. The business logic is organized as modules (Catalog, Sales, Identity) that have been…"},{"u":"/docs/guides/store-Specification.html#2-core-business-entities","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"2. Core Business Entities","x":"Description: A classification grouping for products. Supports hierarchical (parent-child) structures for nested categorization (e.g., \"Jewelry\" \"Rings\"). Key Properties:…"},{"u":"/docs/guides/store-Specification.html#3-business-workflows","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"3. Business Workflows","x":"Trigger: A new user submits registration with first name, last name, email, and password. Steps: 1. Validate registration request (email format, password requirements) 2. Verify…","i":"IInventoryAllocationService.DecrementAsync CatalogFeatures.ProductImages payment_intent.payment_failed EventUtility.ConstructEvent IProductImageStorageService checkout.session.completed ProductImageStorageService OrderCancelledSagaHandler checkout.session.expired Order.InventoryRestored Auth.InvalidResetToken IProductVariantService"},{"u":"/docs/guides/store-Specification.html#4-order-status-state-machine","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"4. Order Status State Machine","x":"Cancellable States: PendingPayment, PaymentInitiated, PaymentFailed Manual Payment States: PendingPayment, PaymentInitiated, PaymentFailed Terminal States: Cancelled, Delivered ---"},{"u":"/docs/guides/store-Specification.html#5-business-rules","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"5. Business Rules","x":"---","i":"ForgotPasswordRequestValidator ProductVariantConfiguration.cs ResetPasswordRequestValidator InventoryItemInvariants.cs AdjustInventoryHandler.cs PasswordResetTokenService ShoppingCartInvariants.cs CategoryConfiguration.cs CheckOutDomainService.cs CustomerConfiguration.cs ForgotPasswordHandler.cs UserRegisteredHandler.cs"},{"u":"/docs/guides/store-Specification.html#6-use-cases","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"6. Use Cases","x":"---"},{"u":"/docs/guides/store-Specification.html#7-domain-events-and-state-changes","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"7. Domain Events and State Changes","x":"---"},{"u":"/docs/guides/store-Specification.html#8-external-integrations","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"8. External Integrations","x":"Purpose: Processes online customer payments for orders. Business Impact: Enables the system to collect payments from customers and confirm payment success or failure…","i":"OrderPaymentFailedSagaHandler payment_intent.payment_failed EventUtility.ConstructEvent checkout.session.completed checkout.session.expired OrderPaidHandler Result.Failure StripeSettings WebhookSecret IEmailSender SecretKey"},{"u":"/docs/guides/store-Specification.html#9-authorization-model","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"9. Authorization Model","x":"Ownership Enforcement: The OwnerOrAdminFilter validates that the route parameter id (CustomerIdentifierType) matches the authenticated user's customer ID, or that the user has…","i":"OwnerOrAdminFilter customer_id user_id email POST role iat jti sub"},{"u":"/docs/guides/store-Specification.html#10-cross-module-communication","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"10. Cross-Module Communication","x":"The system enforces strict module boundaries. Modules communicate only through shared interface contracts: Confirmed behaviors: - Sales module cannot directly access Catalog…","i":"DisabledProductVariantService IProductVariantService RequiresDependencies true"},{"u":"/docs/guides/store-Specification.html#11-user-interface","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"11. User Interface","x":"The UI is a Blazor Server + WebAssembly hybrid (InteractiveAuto render mode) using MudBlazor component library. It supports multiple hosting targets: - Web (Server + WASM):…","i":"UIModuleConfiguration.IsModuleEnabled ICartStateService InteractiveAuto configuration moduleName IUIModule Assembly NavItems"},{"u":"/docs/guides/store-Specification.html#12-cross-cutting-infrastructure","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"12. Cross-Cutting Infrastructure","x":"The IdempotencyFilter (applied via [Idempotent] attribute on Create endpoints) caches the first response for a given Idempotency-Key header value for 24 hours. Duplicate requests…","i":"TransactionalCommandDecorator ProfilingCommandDecorator CachingCommandDecorator ProfilingQueryDecorator ICacheInvalidating IDataSourceService IDbContextFactory IdempotencyFilter ITransactional SemaphoreSlim UseDataSource Idempotent"},{"u":"/docs/guides/store-Specification.html#13-testing","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"13. Testing","x":"- Full customer journey: Register - Browse - Add to Cart - Checkout - Admin Pay - Deliver - Order lifecycle: all state transitions including cancellation with inventory…","i":"MMCA.Store.Integration.slnf MMCA.Store.IntegrationTests WebApplicationFactory STORE_TEST_SQL_BASE"},{"u":"/docs/guides/store-Specification.html#14-missing-or-unclear-business-logic","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"14. Missing or Unclear Business Logic","x":"Observation: The SMTP email service infrastructure is implemented, but no domain event handlers trigger email notifications for events like order confirmation, payment receipt,…","i":"InventoryItemsController InventoryItemList MarkAsDelivered User.Deactivate UserDeactivated CategoryId Delivered GetPaged GetById GetAll Lookup Paid"},{"u":"/docs/guides/store-Specification.html#15-seed-data-initial-system-state","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"15. Seed Data (Initial System State)","x":"The system seeds the following data at startup: Users: - Admin: one seeded administrator account (Admin role, no Customer record; credentials are environment-specific and not…","i":"ExistsAsync"},{"u":"/","d":"Ivan Ball-llovera · Senior Software Architect","k":"Site","x":"Senior Software Architect Ivan Ball-llovera Cloud-native enterprise architecture on the Microsoft stack I design and ship production-grade .NET platforms: modular monoliths that…"},{"u":"/","t":"Architecture that earns its keep","d":"Ivan Ball-llovera · Senior Software Architect","k":"Site","x":"I am a Senior Software Architect with more than 25 years designing and delivering scalable, cloud-native systems on the Microsoft stack. My focus is Domain-Driven Design, Clean…"},{"u":"/","t":"The MMCA platform","d":"Ivan Ball-llovera · Senior Software Architect","k":"Site","x":"A production-grade .NET 10 framework and a set of reference apps that demonstrate modern enterprise architecture end to end. It is built as a modular monolith that extracts…"},{"u":"/","t":"Deep dives on enterprise .NET","d":"Ivan Ball-llovera · Senior Software Architect","k":"Site","x":"A long-form series turning the framework's decisions into teachable patterns, every claim grounded in real source. The three most recent: Proof & getting started · No. 50 The…"},{"u":"/","t":"Speaking & giving back","d":"Ivan Ball-llovera · Senior Software Architect","k":"Site","x":"I help run two community-driven Atlanta technology conferences and keep production-grade patterns free and in the open. See talks & community work Organizer & speaker Two Atlanta…"},{"u":"/resume.html","d":"Résumé","k":"Site","x":"Résumé Ivan Ball-llovera Senior Software Architect 25+ years designing and delivering scalable, cloud-native systems on the Microsoft stack: Domain-Driven Design, Clean…"},{"u":"/resume.html","t":"Professional summary","d":"Résumé","k":"Site","x":"Senior Software Architect with 25+ years designing and delivering scalable, cloud-native systems on the Microsoft stack. Deep expertise in Domain-Driven Design, Clean…"},{"u":"/resume.html","t":"Core competencies","d":"Résumé","k":"Site","x":"Architecture & design Domain-Driven Design, Clean Architecture, CQRS, Modular Monolith → Microservices, Event-Driven Architecture, Outbox Pattern, gRPC, API Gateway (YARP),…"},{"u":"/resume.html","t":"Professional experience","d":"Résumé","k":"Site","x":"Senior Software Engineer · Assurant June 2025 – Present · Architect-level scope: platform, security, and cross-team technical decisions Re-architected the AR.com renters quote…"},{"u":"/resume.html","t":"Featured project · MMCA platform","d":"Résumé","k":"Site","x":"Personal / open source · github.com/ivanball/MMCA.Common A production-grade .NET 10 reference platform demonstrating modern enterprise architecture end-to-end. The conference…"},{"u":"/resume.html","t":"Education","d":"Résumé","k":"Site","x":"B.S., Computer Science University of Havana (Faculty of Mathematics), Havana, Cuba (1994 – 1999)"},{"u":"/resume.html","t":"Languages","d":"Résumé","k":"Site","x":"English · Spanish (bilingual)"},{"u":"/resume.html","t":"Certifications","d":"Résumé","k":"Site","x":"✓ Azure Administrator Associate (AZ-104, 2025) ✓ Azure AI Fundamentals (AI-900, 2024) ✓ Azure Data Fundamentals (DP-900, 2021) ✓ Azure Fundamentals (AZ-900, 2021) → In progress…"},{"u":"/resume.html","t":"Professional development","d":"Résumé","k":"Site","x":"Continuously prototypes emerging technologies, with a current focus on Clean Architecture using .NET 10, Blazor, .NET MAUI, and ASP.NET Core Web API, and on AI-assisted…"},{"u":"/platform.html","d":"The MMCA Platform","k":"Site","x":"Featured work · Open source The MMCA platform A production-grade .NET 10 platform that demonstrates modern enterprise architecture end to end: an open-source framework of fifteen…"},{"u":"/platform.html","t":"MMCA.Common","d":"The MMCA Platform","k":"Site","x":"A NuGet package framework for building modular-monolith applications with DDD, Clean Architecture, and CQRS, and the extension points to extract a module into its own…"},{"u":"/platform.html","t":"Three reference applications","d":"The MMCA Platform","k":"Site","x":"The framework is consumed by real apps. Each one exercises the patterns differently, and the conference app ran a live event on Azure. Conference MMCA.ADC A production-deployed…"},{"u":"/platform.html","t":"From one graph, laptop to cloud","d":"The MMCA Platform","k":"Site","x":"Orchestrated locally with .NET Aspire, then deployed to Azure Container Apps from the same declarative model. The .NET Aspire dashboard: services, databases, and the broker as…"},{"u":"/platform.html","t":"Architectural styles the codebase commits to","d":"The MMCA Platform","k":"Site","x":"The recurring ideas every repo is built on, summarized from the onboarding primer's orientation chapter. Each is taught in full at its first concrete appearance in the reference…"},{"u":"/platform.html","t":"A two-axis architecture scorecard","d":"The MMCA Platform","k":"Site","x":"Every repo is scored against a 34-category rubric on two axes, Maturity (how complete the decision is) and Implementation (how well it is realized in code), with evidence and…"},{"u":"/platform.html","t":"Architecture Decision Records","d":"The MMCA Platform","k":"Site","x":"96 ADRs capture the context and trade-offs behind each cross-cutting pattern, so the design is teachable, not tribal knowledge. Every entry below links to the full record. 001…"},{"u":"/platform.html","t":"The reference library","d":"The MMCA Platform","k":"Site","x":"The full architecture documentation, maintained in this site's repository as its canonical home and rendered as browsable pages: every Architecture Decision Record, the…"},{"u":"/platform.html","t":"Use it, read it, or follow along","d":"The MMCA Platform","k":"Site","x":"The framework is Apache 2.0 and published to nuget.org. The fastest way in is the reference app: one module wired end to end through all five layers, with the extraction path…"},{"u":"/platform.html","t":"Get each deep dive by email","d":"The MMCA Platform","k":"Site","x":"One message per article, no digests and no other mail. Email address Subscribe"},{"u":"/writing.html","d":"Writing","k":"Site","x":"Writing Deep dives on enterprise .NET A long-form series that turns the MMCA framework's architecture decisions into teachable patterns, every claim grounded in real source. Read…"},{"u":"/writing.html","t":"Get each deep dive by email","d":"Writing","k":"Site","x":"One message per article, no digests and no other mail. Email address Subscribe"},{"u":"/speaking.html","d":"Speaking & Community","k":"Site","x":"Speaking & community Talks and giving back For more than 20 years I have been an active contributor to the Microsoft developer communities in Atlanta and South Florida: teaching,…"},{"u":"/speaking.html","t":"Recent sessions","d":"Speaking & Community","k":"Site","x":"Atlanta Cloud + AI Conference · 2026 The App You're Using Right Now Building Atlanta Cloud + AI's own platform with Claude in the loop A field report, not a slide deck about…"},{"u":"/speaking.html","t":"Organizing two Atlanta conferences","d":"Speaking & Community","k":"Site","x":"I help convene developers in person, giving the local community direct, no-cost access to expert content on the Microsoft platform. Lead organizer Atlanta Cloud + AI Conference…"},{"u":"/speaking.html","t":"User groups","d":"Speaking & Community","k":"Site","x":"An active participant in Atlanta's Microsoft technology user-group ecosystem, the same community network from which the conferences draw their speakers and attendees. • Atlanta…"},{"u":"/speaking.html","t":"Open source & mentorship","d":"Speaking & Community","k":"Site","x":"My MMCA framework is Apache-2.0 licensed and documented with architecture decision records, so the patterns are not just usable but teachable. I mentor developers one on one,…"},{"u":"/speaking.html","t":"What I speak on","d":"Speaking & Community","k":"Site","x":"Sessions and workshops for conferences, user groups, and teams. Clean Architecture & DDD on .NET Modular monolith → microservices The transactional outbox Database-per-service…"},{"u":"/contact.html","d":"Contact","k":"Site","x":"Contact Let's connect Happy to talk architecture, the MMCA platform, speaking at your conference or user group, or comparing notes on .NET and Azure. The fastest ways to reach…"},{"u":"/contact.html","t":"Three places to start","d":"Contact","k":"Site","x":"Open source The MMCA platform A .NET 10 framework and three reference apps, graded in the open against a 34-category rubric. See the architecture → Writing Deep dives on…"},{"u":"https://medium.com/@ivanball76/the-mmca-series-every-pattern-one-place-28cf2cee7be8","t":"The series index","d":"Article no. 50","k":"Proof & getting started","x":"The full series index and recommended reading order.","e":1},{"u":"https://medium.com/@ivanball76/undo-is-a-feature-saga-compensation-and-the-reconciliation-backstop-fa017f9591b8","t":"Saga compensation and the reconciliation backstop","d":"Article no. 49","k":"Core patterns","x":"Undo as a first-class event handler: give back stock a committed transaction already took, with a periodic sweep as the saga-timeout backstop.","e":1},{"u":"https://medium.com/@ivanball76/observability-by-default-opentelemetry-and-azure-monitor-in-mmca-673c1886e9e0","t":"Observability by default","d":"Article no. 48","k":"Run & extract","x":"A shared OpenTelemetry baseline with CQRS duration metrics, correlation IDs, and outbox-poll span filtering, exported to Azure Monitor.","e":1},{"u":"https://medium.com/@ivanball76/security-headers-and-csp-for-blazor-one-middleware-every-host-af82df95236e","t":"Security headers and CSP for Blazor","d":"Article no. 47","k":"Auth & the edge","x":"One middleware stamps hardened response headers on every host, with the Blazor CSP resolved through a pluggable provider.","e":1},{"u":"https://medium.com/@ivanball76/field-level-encryption-in-ef-core-aes-gcm-for-pii-columns-06ece340ea25","t":"Field-level encryption in EF Core","d":"Article no. 46","k":"Data & persistence","x":"An AES-256-GCM value converter that keeps a PII column ciphertext even for someone who can query the database.","e":1},{"u":"https://medium.com/@ivanball76/feature-flags-in-the-cqrs-pipeline-gate-commands-not-code-e58b9ea8d098","t":"Feature flags in the CQRS pipeline","d":"Article no. 45","k":"Core patterns","x":"Gate commands and queries at the outermost decorator, so a handler never checks a flag and a disabled feature is rejected before any work runs.","e":1},{"u":"https://medium.com/@ivanball76/http-api-versioning-proven-not-just-claimed-2b0381e4b533","t":"HTTP API versioning, proven not just claimed","d":"Article no. 44","k":"Auth & the edge","x":"Header-based versioning introduced without breaking a single caller, plus a fitness contract that proves two live versions coexist.","e":1},{"u":"https://medium.com/@ivanball76/managed-file-storage-uploads-you-dont-have-to-trust-8dfe8bf016bc","t":"Managed file storage: uploads you don't have to trust","d":"Article no. 43","k":"Data & persistence","x":"Attacker-controlled bytes become safe avatars: content sniffing, metadata stripping, re-encoding, and pluggable blob storage.","e":1},{"u":"https://medium.com/@ivanball76/one-blazor-ui-two-hosts-a-device-capability-layer-that-stays-resolvable-everywhere-b85444693161","t":"One Blazor UI, two hosts","d":"Article no. 42","k":"Proof & getting started","x":"The same Blazor components run in a browser and inside a MAUI hybrid app; small per-capability contracts reach native hardware without ever asking 'am I on mobile?'.","e":1},{"u":"https://medium.com/@ivanball76/two-real-apps-on-one-framework-a-conference-platform-and-a-store-12f694d2a361","t":"Two real apps on one framework","d":"Article no. 41","k":"Proof & getting started","x":"A case study: a conference platform and an e-commerce store built on the same kernel.","e":1},{"u":"https://medium.com/@ivanball76/write-your-first-architecture-fitness-test-d4e25e6a4741","t":"Write your first fitness test","d":"Article no. 40","k":"Proof & getting started","x":"Author your first architecture fitness test and watch it fail the build on a violation.","e":1},{"u":"https://medium.com/@ivanball76/scaffold-a-net-modular-monolith-in-one-command-then-build-your-first-module-b10aacd16d33","t":"Build your first module","d":"Article no. 39","k":"Proof & getting started","x":"A hands-on walkthrough of building a new module across all five layers.","e":1},{"u":"https://medium.com/@ivanball76/one-preference-two-switches-shipping-i18n-and-dark-mode-on-a-single-cookie-and-profile-pipeline-f97186038909","t":"i18n and theming on one preference pipeline","d":"Article no. 38","k":"Proof & getting started","x":"A culture choice and a theme choice ride the same cookie, profile column, and login reconciliation: one persistence path, two switches.","e":1},{"u":"https://medium.com/@ivanball76/a-list-page-in-a-few-lines-a-reusable-blazor-ui-framework-with-the-same-discipline-as-the-backend-c66fa16cd561","t":"A reusable Blazor UI framework","d":"Article no. 37","k":"Proof & getting started","x":"A shared Blazor and MudBlazor UI layer with accessibility enforced by axe in CI.","e":1},{"u":"https://medium.com/@ivanball76/soft-delete-vs-the-right-to-erasure-the-gdpr-conflict-and-the-erasure-pathway-e1d350007509","t":"Soft-delete vs the right to erasure","d":"Article no. 36","k":"Proof & getting started","x":"Soft-delete for lifecycle, anonymization plus outbox purge for GDPR/CCPA erasure, and why both exist.","e":1},{"u":"https://medium.com/@ivanball76/the-test-pyramid-not-the-ice-cream-cone-1-880-fast-tests-zero-docker-cb459fda73d9","t":"The test pyramid","d":"Article no. 35","k":"Proof & getting started","x":"How the framework's tests stack up: fast unit and architecture tests at the base, E2E at the tip.","e":1},{"u":"https://medium.com/@ivanball76/architecture-fitness-functions-rules-that-fail-the-build-not-a-wiki-page-6562940deceb","t":"Architecture fitness functions","d":"Article no. 34","k":"Proof & getting started","x":"Architecture rules that fail the build: a compile-time layer guard plus a shared NetArchTest rule library.","e":1},{"u":"https://medium.com/@ivanball76/retries-are-not-a-recovery-plan-resilience-handlers-rto-rpo-and-a-restore-you-actually-drilled-3c7474814123","t":"Resilience and recovery objectives","d":"Article no. 33","k":"Run & extract","x":"Standard resilience on every outbound client, plus declared RTO/RPO and a drilled restore.","e":1},{"u":"https://medium.com/@ivanball76/extracting-a-module-to-a-grpc-service-live-799926cf8a32","t":"Extracting a module to a gRPC service","d":"Article no. 32","k":"Run & extract","x":"A step-by-step extraction of an in-process module into its own gRPC service, database, and auth.","e":1},{"u":"https://medium.com/@ivanball76/aspire-one-command-brings-up-the-whole-distributed-app-379b5cffdeed","t":"Aspire: one command","d":"Article no. 31","k":"Run & extract","x":"Model services, databases, and the broker as one Aspire graph that runs from laptop to Azure with one command.","e":1},{"u":"https://medium.com/@ivanball76/defending-the-api-edge-three-controls-that-cover-the-whole-surface-d958ecae1091","t":"Rate limiting and brute-force protection","d":"Article no. 30","k":"Auth & the edge","x":"Two layers that cover the whole API edge: endpoint rate limits plus lockout-based brute-force defense on identity.","e":1},{"u":"https://medium.com/@ivanball76/resource-ownership-authorization-which-rows-you-may-touch-not-just-which-actions-cb8e78867bae","t":"Resource-ownership authorization","d":"Article no. 29","k":"Auth & the edge","x":"Beyond roles and permissions: which rows you may touch, enforced per resource.","e":1},{"u":"https://medium.com/@ivanball76/generic-entity-controllers-and-the-dynamic-query-contract-adr-034-2b5c799bc69f","t":"Generic entity controllers","d":"Article no. 28","k":"Auth & the edge","x":"A write-once REST surface every entity inherits, plus a bounded dynamic query contract that is never open SQL.","e":1},{"u":"https://medium.com/@ivanball76/one-rotating-refresh-token-and-reuse-detection-that-makes-theft-self-limiting-fab42234a04a","t":"One rotating refresh token","d":"Article no. 27","k":"Auth & the edge","x":"A short-lived JWT plus one server-stored refresh token that rotates on every use, with reuse detection that makes a stolen token end its own session.","e":1},{"u":"https://medium.com/@ivanball76/google-and-github-login-without-leaking-tokens-external-oauth-behind-your-own-jwts-d68ba5e3aca4","t":"External OAuth login behind your own JWTs","d":"Article no. 26","k":"Auth & the edge","x":"Sign in with Google or GitHub without leaking provider tokens: external identity exchanged for your own JWTs at the boundary.","e":1},{"u":"https://medium.com/@ivanball76/browser-session-cookie-auth-for-blazor-ssr-surviving-the-f5-eb0ea317820e","t":"Browser session-cookie auth for Blazor SSR","d":"Article no. 25","k":"Auth & the edge","x":"HttpOnly session cookies and an SSR-time scheme so [Authorize] passes during prerender, with the API still the boundary.","e":1},{"u":"https://medium.com/@ivanball76/permission-based-authorization-capabilities-over-role-checks-ea6574cbee27","t":"Permission-based authorization over roles","d":"Article no. 24","k":"Auth & the edge","x":"A capability layer over RBAC: permission policies that resolve on demand from a central registry.","e":1},{"u":"https://medium.com/@ivanball76/delete-automapper-explicit-compile-time-dto-mapping-that-you-can-actually-test-9c7013cc5d3f","t":"Delete AutoMapper: manual DTO mapping","d":"Article no. 23","k":"Auth & the edge","x":"Why source-generated, per-entity mappers beat reflection-based mapping for clarity and speed.","e":1},{"u":"https://medium.com/@ivanball76/ephemeral-by-design-sub-second-live-channels-over-one-signalr-hub-0248050e0c8b","t":"Live channels over one SignalR hub","d":"Article no. 22","k":"Auth & the edge","x":"Sub-second ephemeral events (polls, Q&A, live counts) fanned out over the existing notification hub, with nothing persisted.","e":1},{"u":"https://medium.com/@ivanball76/notifications-as-a-vertical-slice-in-app-inbox-real-time-push-native-push-and-email-c59d5a4f3b69","t":"Notifications as a vertical slice","d":"Article no. 21","k":"Auth & the edge","x":"A notifications feature built as a clean vertical slice across every layer.","e":1},{"u":"https://medium.com/@ivanball76/problem-details-across-http-and-grpc-rfc-9457-9f20157cf7de","t":"Problem Details across HTTP and gRPC","d":"Article no. 20","k":"Auth & the edge","x":"One error contract mapped consistently to HTTP Problem Details and gRPC status.","e":1},{"u":"https://medium.com/@ivanball76/the-self-invalidating-cache-that-lives-in-the-pipeline-not-your-handlers-e11548062d2f","t":"The self-invalidating cache","d":"Article no. 19","k":"Auth & the edge","x":"A caching decorator where commands invalidate and queries populate, plus an authenticated output-cache tier at the API edge.","e":1},{"u":"https://medium.com/@ivanball76/idempotency-in-one-attribute-safe-retries-for-http-apis-065848fd03f4","t":"Idempotency in one attribute","d":"Article no. 18","k":"Auth & the edge","x":"Dedup client retries with an Idempotency-Key header and cached replay, plus a consumer-side inbox for brokers.","e":1},{"u":"https://medium.com/@ivanball76/password-hashing-done-right-pbkdf2-sha512-600k-iterations-timing-safe-d64ddb802403","t":"Password hashing done right","d":"Article no. 17","k":"Auth & the edge","x":"The non-negotiables of password storage in .NET, done correctly and tested.","e":1},{"u":"https://medium.com/@ivanball76/cross-service-auth-without-a-shared-secret-jwks-dual-fetch-478e6f688c7e","t":"JWKS cross-service auth","d":"Article no. 16","k":"Auth & the edge","x":"Validate another service's RS256 tokens via JWKS discovery, with no shared secret crossing a boundary.","e":1},{"u":"https://medium.com/@ivanball76/event-schema-versioning-never-silently-reshape-an-event-93cd5d4a156d","t":"Event-schema versioning","d":"Article no. 15","k":"Data & persistence","x":"Every integration event carries a schema version; breaking changes get a new event type and an upcaster, never a silent reshape.","e":1},{"u":"https://medium.com/@ivanball76/self-ordering-modules-discovered-kahn-ordered-and-extractable-2ce7283a26b5","t":"Self-ordering modules","d":"Article no. 14","k":"Data & persistence","x":"Modules declare their dependencies and load in topological order, so registration is never hand-sequenced.","e":1},{"u":"https://medium.com/@ivanball76/optimistic-concurrency-that-survives-the-round-trip-rowversion-from-database-to-dto-and-back-93d4a794716f","t":"Optimistic concurrency: RowVersion round-trips","d":"Article no. 13","k":"Data & persistence","x":"Carry the RowVersion from database to DTO and back, so a concurrent edit fails fast as a conflict instead of silently overwriting.","e":1},{"u":"https://medium.com/@ivanball76/ef-core-include-chains-are-a-trap-navigation-populators-decouple-eager-loading-c378fa4497ac","t":"Navigation populators","d":"Article no. 12","k":"Data & persistence","x":"Eager-load relationships that cross containers and data sources without N+1 or a leaky abstraction.","e":1},{"u":"https://medium.com/@ivanball76/one-entity-model-three-databases-polyglot-persistence-behind-a-single-attribute-760e77974d5d","t":"Polyglot persistence: one model, three engines","d":"Article no. 11","k":"Data & persistence","x":"SQL Server, Cosmos, and SQLite behind a single entity model, with the engine chosen by attribute.","e":1},{"u":"https://medium.com/@ivanball76/database-per-service-inside-a-monolith-and-why-265092eb03f1","t":"Database-per-service inside a monolith","d":"Article no. 10","k":"Data & persistence","x":"Give each module its own database and outbox before you extract it, so extraction changes hosting, not data.","e":1},{"u":"https://medium.com/@ivanball76/the-transactional-outbox-in-net-10-never-lose-an-event-again-f5a9b7a89e51","t":"The transactional outbox","d":"Article no. 9","k":"Core patterns","x":"Events that survive a crash: persist them atomically with your data, then dispatch at least once.","e":1},{"u":"https://medium.com/@ivanball76/compose-validators-dont-copy-them-a-reusable-fluentvalidation-kit-8865a6003a9c","t":"Compose validators, don't copy them","d":"Article no. 8","k":"Core patterns","x":"A validation kit that composes FluentValidation rules instead of copy-pasting them across features.","e":1},{"u":"https://medium.com/@ivanball76/the-cqrs-decorator-pipeline-logging-caching-and-transactions-without-touching-a-handler-fb7679b8bde8","t":"The CQRS decorator pipeline","d":"Article no. 7","k":"Core patterns","x":"Thin command and query handlers wrapped by a Scrutor decorator chain whose order is load-bearing.","e":1},{"u":"https://medium.com/@ivanball76/specifications-over-linq-spaghetti-composable-reusable-query-intent-8a40dafcbd3d","t":"Specifications over LINQ spaghetti","d":"Article no. 6","k":"Core patterns","x":"Compose queries from reusable specification objects instead of scattering LINQ across handlers.","e":1},{"u":"https://medium.com/@ivanball76/kill-the-anemic-domain-model-rich-aggregates-with-factory-methods-that-return-result-44f2e3d89794","t":"Kill the anemic domain model","d":"Article no. 5","k":"Core patterns","x":"Push behavior into rich aggregates with factory methods and invariants instead of bags of public setters.","e":1},{"u":"https://medium.com/@ivanball76/stop-throwing-exceptions-for-control-flow-the-result-railway-in-c-7a02050b554e","t":"The Result railway in C#","d":"Article no. 4","k":"Core patterns","x":"Model expected failures as Result values with a transport-agnostic error type, and keep exceptions for the genuinely exceptional.","e":1},{"u":"https://medium.com/@ivanball76/what-good-architecture-actually-means-a-34-category-rubric-you-can-score-yourself-against-4002291a6b6a","t":"The 34-category architecture rubric","d":"Article no. 3","k":"Orientation","x":"A two-axis rubric for scoring architecture on maturity and implementation, so 'good architecture' stops being a vibe.","e":1},{"u":"https://medium.com/@ivanball76/modular-monolith-to-microservices-without-the-rewrite-8c3603614f12","t":"Modular monolith to microservices","d":"Article no. 2","k":"Orientation","x":"The cornerstone idea: build the monolith now and extract a service later with no rewrite, via module discovery, gRPC contracts, and a YARP gateway.","e":1},{"u":"https://medium.com/@ivanball76/i-open-sourced-the-enterprise-net-77f9200f3728","t":"Open-sourced and graded against 34 categories","d":"Article no. 1","k":"Orientation","x":"Why I open-sourced a production .NET framework and scored it against a 34-category architecture rubric, gaps and all.","e":1}]} \ No newline at end of file +{"v":1,"n":1343,"r":[{"u":"/docs/adr/index.html","d":"Architecture Decision Records","k":"Architecture Decision Records","x":"Accepted ADRs explaining why the core cross-cutting patterns exist. Read these before changing a pattern they describe: they capture context and trade-offs that aren't obvious…","i":"ApplicationLayer_DoesNotUseRawQueryableSurfaces AsyncMethodsDeclareTrailingCancellationToken MessageBusSettings.EnableDelayedRedelivery PushNotificationSettings.ChannelKeyPattern ApplicationDbContext.ConfigureConventions Microsoft.CodeAnalysis.PublicApiAnalyzers RegisterUpcastedIntegrationEventConsumer ApiParameterDescriptorBackfillProvider IWriteRepository.SetOriginalRowVersion ServiceInfoVersioningContractTestsBase ApplicationDbContext.OnModelCreating MMCA.Common.LayerEnforcement.targets"},{"u":"/docs/adr/index.html#writing-a-new-adr","d":"Architecture Decision Records","k":"Architecture Decision Records","t":"Writing a new ADR","x":"Copy the structure of an existing record: Status (Proposed / Accepted / Superseded, date and link when superseding), Context (the forces and the problem), Decision (what we…"},{"u":"/docs/adr/001-manual-dto-mapping.html","d":"ADR-001: Manual DTO Mapping over AutoMapper","k":"Architecture Decision Records"},{"u":"/docs/adr/001-manual-dto-mapping.html#status","d":"ADR-001: Manual DTO Mapping over AutoMapper","k":"Architecture Decision Records","t":"Status","x":"Accepted. Mechanism clarified 2026-06-26: the per-entity mappers are Riok.Mapperly source-generated (compile-time), not hand-written line by line. The decision to avoid runtime…"},{"u":"/docs/adr/001-manual-dto-mapping.html#context","d":"ADR-001: Manual DTO Mapping over AutoMapper","k":"Architecture Decision Records","t":"Context","x":"Domain entities must be mapped to DTOs for API responses. The two common approaches are: 1. Manual mapping classes (IEntityDTOMapper ) 2. Convention-based reflection mapping…","i":"IEntityDTOMapper TEntity TDTO TId"},{"u":"/docs/adr/001-manual-dto-mapping.html#decision","d":"ADR-001: Manual DTO Mapping over AutoMapper","k":"Architecture Decision Records","t":"Decision","x":"Use explicit, per-entity DTO mappers (each a Riok.Mapperly [Mapper] partial class whose MapToDTO body is source-generated at compile time) registered via Scrutor assembly…","i":"IEntityRequestMapper IEntityDTOMapper SpeakerDTOMapper TIdentifierType TCreateRequest UserMapping TEntityDTO MapToDTOs UseMapper MapToDTO partial TEntity"},{"u":"/docs/adr/001-manual-dto-mapping.html#rationale","d":"ADR-001: Manual DTO Mapping over AutoMapper","k":"Architecture Decision Records","t":"Rationale","x":"- Compile-time safety: Mapping errors surface at build time, not runtime. Property renames break the build rather than silently mapping null. - Testability: Each mapper is a…","i":"SpeakerDTOMapper MapToDTO null"},{"u":"/docs/adr/001-manual-dto-mapping.html#trade-offs","d":"ADR-001: Manual DTO Mapping over AutoMapper","k":"Architecture Decision Records","t":"Trade-offs","x":"- More files (31 DTO mappers across Store + ADC: 20 in ADC, 11 in Store, plus the parallel IEntityRequestMapper classes). The interface's default MapToDTOs implementation is…","i":"IEntityRequestMapper MapToDTOs"},{"u":"/docs/adr/002-navigation-populators.html","d":"ADR-002: NavigationPopulators for Cross-Container Loading","k":"Architecture Decision Records"},{"u":"/docs/adr/002-navigation-populators.html#status","d":"ADR-002: NavigationPopulators for Cross-Container Loading","k":"Architecture Decision Records","t":"Status","x":"Accepted"},{"u":"/docs/adr/002-navigation-populators.html#context","d":"ADR-002: NavigationPopulators for Cross-Container Loading","k":"Architecture Decision Records","t":"Context","x":"The application supports multiple database backends (SQL Server, Cosmos DB, SQLite). EF Core's .Include() works for SQL Server but fails for Cosmos DB cross-container…","i":"IDataSourceService.HaveIncludeSupport NavigationMetadataProvider declaringType IsCollection Navigation targetType Include"},{"u":"/docs/adr/002-navigation-populators.html#decision","d":"ADR-002: NavigationPopulators for Cross-Container Loading","k":"Architecture Decision Records","t":"Decision","x":"Each entity that has unsupported navigations gets a INavigationPopulator implementation. A DeclarativeNavigationPopulator base class (added in MMCA.Common) allows populators to…","i":"DeclarativeNavigationPopulator ChildNavigationDescriptor FKNavigationDescriptor INavigationDescriptor INavigationPopulator NavigationLoader Product.Category Event.Rooms TEntity WHERE"},{"u":"/docs/adr/002-navigation-populators.html#rationale","d":"ADR-002: NavigationPopulators for Cross-Container Loading","k":"Architecture Decision Records","t":"Rationale","x":"- Multi-DB support: The query pipeline automatically falls back from Include to NavigationPopulator when the data source reports navigations as unsupported. - Batch efficiency:…","i":"DeclarativeNavigationPopulator"},{"u":"/docs/adr/002-navigation-populators.html#trade-offs","d":"ADR-002: NavigationPopulators for Cross-Container Loading","k":"Architecture Decision Records","t":"Trade-offs","x":"- Extra abstraction layer for SQL Server (where Include works fine). Mitigated: the populator is only called when the query pipeline's metadata says navigations are unsupported.…","i":"NullNavigationPopulator"},{"u":"/docs/adr/003-outbox-dual-dispatch.html","d":"ADR-003: Outbox Pattern with Dual Dispatch","k":"Architecture Decision Records"},{"u":"/docs/adr/003-outbox-dual-dispatch.html#status","d":"ADR-003: Outbox Pattern with Dual Dispatch","k":"Architecture Decision Records","t":"Status","x":"Accepted. Revised 2026-07-19 (integration-event routing via IMessageBus, lease-based claims for safe scale-out, dead-letter visibility, post-commit dispatch; see Revision below).…","i":"IMessageBus"},{"u":"/docs/adr/003-outbox-dual-dispatch.html#context","d":"ADR-003: Outbox Pattern with Dual Dispatch","k":"Architecture Decision Records","t":"Context","x":"Domain events must be reliably published after aggregate changes are persisted. Two failure modes exist: 1. In-process dispatch fails (e.g., handler throws): the event is lost if…"},{"u":"/docs/adr/003-outbox-dual-dispatch.html#decision","d":"ADR-003: Outbox Pattern with Dual Dispatch","k":"Architecture Decision Records","t":"Decision","x":"Use a dual-dispatch strategy: 1. Outbox persistence: Domain events are serialized into OutboxMessage rows within the same database transaction as the aggregate changes. This…","i":"DomainEventDispatcher BackgroundService SaveChangesAsync OutboxProcessor OutboxMessage"},{"u":"/docs/adr/003-outbox-dual-dispatch.html#rationale","d":"ADR-003: Outbox Pattern with Dual Dispatch","k":"Architecture Decision Records","t":"Rationale","x":"- Guaranteed delivery: The outbox table is written atomically with the aggregate changes. Even if the process crashes after persistence, the background processor catches up. -…","i":"OutboxPollFilterProcessor ProcessingDelaySeconds BrokerMessageBus OutboxProcessor BrokerEventBus IMessageBus OutboxPoll"},{"u":"/docs/adr/003-outbox-dual-dispatch.html#trade-offs","d":"ADR-003: Outbox Pattern with Dual Dispatch","k":"Architecture Decision Records","t":"Trade-offs","x":"- Domain event handlers must be idempotent (this is a good practice regardless). - The outbox table grows until processed entries are cleaned up: OutboxCleanupService purges rows…","i":"OutboxCleanupService HasMoreEligibleWork ProcessedOn MaxRetries RetryCount"},{"u":"/docs/adr/003-outbox-dual-dispatch.html#revision-2026-07-19","d":"ADR-003: Outbox Pattern with Dual Dispatch","k":"Architecture Decision Records","t":"Revision (2026-07-19)","x":"Four changes from the 2026-07-19 full review: 1. Integration events route through the outbox to IMessageBus, never local dispatch. An IIntegrationEvent raised via AddDomainEvent…","i":"DomainEventSaveChangesInterceptor outbox.dead_letter.count OutboxCleanupService ExecuteUpdateAsync IIntegrationEvent type_unresolvable integrationEvent OutboxProcessor AddDomainEvent OutboxMessage IMessageBus LockedUntil"},{"u":"/docs/adr/003-outbox-dual-dispatch.html#revision-2026-07-24","d":"ADR-003: Outbox Pattern with Dual Dispatch","k":"Architecture Decision Records","t":"Revision (2026-07-24)","x":"Three capture-side corrections found in a code review. None change the dual-dispatch decision; they close gaps between what it promised and what the interceptor did. 1. Capture…","i":"ExecuteInTransactionAsync RemoveDomainEvents IAggregateRoot SavingChanges RetryCount DbContext LastError catch"},{"u":"/docs/adr/003-outbox-dual-dispatch.html#revision-2026-08-01","d":"ADR-003: Outbox Pattern with Dual Dispatch","k":"Architecture Decision Records","t":"Revision (2026-08-01)","x":"One retry-pacing correction. The dual-dispatch decision is unchanged; the Trade-offs above described a cadence the processor no longer has. 1. Retry backoff is explicit, and it…","i":"RetryBackoffBaseSeconds"},{"u":"/docs/adr/003-outbox-dual-dispatch.html#revision-2026-08-07","d":"ADR-003: Outbox Pattern with Dual Dispatch","k":"Architecture Decision Records","t":"Revision (2026-08-07)","x":"One retry-pacing refinement. The decision and the curve are unchanged; the waits are no longer identical across a batch. 1. The retry backoff carries random jitter. The…"},{"u":"/docs/adr/004-authentication-dual-fetch.html","d":"ADR-004: Cross-Service Token Validation via JWKS / OIDC Discovery","k":"Architecture Decision Records"},{"u":"/docs/adr/004-authentication-dual-fetch.html#status","d":"ADR-004: Cross-Service Token Validation via JWKS / OIDC Discovery","k":"Architecture Decision Records","t":"Status","x":"Accepted."},{"u":"/docs/adr/004-authentication-dual-fetch.html#context","d":"ADR-004: Cross-Service Token Validation via JWKS / OIDC Discovery","k":"Architecture Decision Records","t":"Context","x":"When the modular monolith is extracted into per-module service hosts behind a gateway (ADR-008), every service must authenticate the same end-user JWT, but only one service…"},{"u":"/docs/adr/004-authentication-dual-fetch.html#decision","d":"ADR-004: Cross-Service Token Validation via JWKS / OIDC Discovery","k":"Architecture Decision Records","t":"Decision","x":"Validate cross-service tokens with asymmetric (RS256) signatures plus JWKS / OIDC discovery, keeping the symmetric (HS256) shared-secret path as the in-process monolith default.…","i":"TokenValidationParameters.ValidAlgorithms id_token_signing_alg_values_supported OpenIdConnectMetadataWarmupTask JwtSettings.SigningAlgorithm BuildValidationParameters MapOidcDiscoveryEndpoint response_types_supported AddCommonAuthentication subject_types_supported AddForwardedJwtBearer WithJwksDiscovery RsaPublicKeyPath"},{"u":"/docs/adr/004-authentication-dual-fetch.html#rationale","d":"ADR-004: Cross-Service Token Validation via JWKS / OIDC Discovery","k":"Architecture Decision Records","t":"Rationale","x":"- No shared signing key. Only Identity can mint tokens; every other service holds only the public key it fetched, so a compromised non-Identity service cannot forge tokens, and…"},{"u":"/docs/adr/004-authentication-dual-fetch.html#trade-offs","d":"ADR-004: Cross-Service Token Validation via JWKS / OIDC Discovery","k":"Architecture Decision Records","t":"Trade-offs","x":"- More moving parts than a shared secret. RS256 needs key generation, distribution of the public half, a JWKS endpoint, and discovery wiring, versus one symmetric string. -…"},{"u":"/docs/adr/004-authentication-dual-fetch.html#related","d":"ADR-004: Cross-Service Token Validation via JWKS / OIDC Discovery","k":"Architecture Decision Records","t":"Related","x":"ADR-007 (gRPC calls forward the validated JWT downstream via JwtForwardingClientInterceptor), ADR-008 (the extraction that split issuer and validator into separate processes),…","i":"JwtForwardingClientInterceptor"},{"u":"/docs/adr/005-soft-delete-vs-erasure.html","d":"ADR-005: Soft-Delete vs. Right-to-Erasure","k":"Architecture Decision Records"},{"u":"/docs/adr/005-soft-delete-vs-erasure.html#status","d":"ADR-005: Soft-Delete vs. Right-to-Erasure","k":"Architecture Decision Records","t":"Status","x":"Accepted. Updated 2026-06-27 (documented the [Pii] → IAnonymizable build-time guard and PiiRedactor).","i":"IAnonymizable PiiRedactor Pii"},{"u":"/docs/adr/005-soft-delete-vs-erasure.html#context","d":"ADR-005: Soft-Delete vs. Right-to-Erasure","k":"Architecture Decision Records","t":"Context","x":"The framework's default deletion model is soft-delete: AuditableBaseEntity.Delete() sets IsDeleted = true and EF Core global query filters exclude the row from normal queries.…","i":"AuditableBaseEntity.Delete OutboxMessage IsDeleted true"},{"u":"/docs/adr/005-soft-delete-vs-erasure.html#decision","d":"ADR-005: Soft-Delete vs. Right-to-Erasure","k":"Architecture Decision Records","t":"Decision","x":"Separate the two concerns and provide an extension point for each, rather than overloading soft-delete: 1. Soft-delete stays the default for lifecycle/state management (hide +…","i":"MMCA.Common.Domain.Attributes.PiiAttribute MMCA.Common.Domain.Interfaces MMCA.Common.Domain.Privacy EncryptedStringConverter PiiConventionTestsBase OutboxCleanupService IAnonymizable PiiRedactor Anonymize Result User Pii"},{"u":"/docs/adr/005-soft-delete-vs-erasure.html#rationale","d":"ADR-005: Soft-Delete vs. Right-to-Erasure","k":"Architecture Decision Records","t":"Rationale","x":"- Right tool per concern: soft-delete answers \"is this record active?\"; erasure answers \"has this person's data been removed?\". Conflating them (e.g. hard-deleting inside…","i":"Delete"},{"u":"/docs/adr/005-soft-delete-vs-erasure.html#trade-offs","d":"ADR-005: Soft-Delete vs. Right-to-Erasure","k":"Architecture Decision Records","t":"Trade-offs","x":"- Erasure is opt-in per entity, but the opt-in is guarded: an aggregate exposing a [Pii]-marked property that does not implement IAnonymizable fails the architecture fitness…","i":"IAnonymizable Pii"},{"u":"/docs/adr/006-database-per-service.html","d":"ADR-006: Database per Service","k":"Architecture Decision Records"},{"u":"/docs/adr/006-database-per-service.html#status","d":"ADR-006: Database per Service","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-07). Supersedes the earlier \"deliberately one shared database\" stance. Clarified 2026-06-27: the single context class became one sealed context class per engine…","i":"Name"},{"u":"/docs/adr/006-database-per-service.html#context","d":"ADR-006: Database per Service","k":"Architecture Decision Records","t":"Context","x":"When the modules were first extracted into independently-deployable services, all services in an app still pointed at a single shared SQL database with a single OutboxMessages…","i":"CrossDataSourceDegradeConvention EntityDataSourceRegistry DataSourceResolver DbContextFactory OutboxProcessor OutboxMessages"},{"u":"/docs/adr/006-database-per-service.html#decision","d":"ADR-006: Database per Service","k":"Architecture Decision Records","t":"Decision","x":"Adopt database-per-service: each service owns its own physical database with its own OutboxMessages table. - One sealed concrete context class per engine, one instance per…","i":"CrossDataSourceDegradeConvention PhysicalDbContextFactory ApplicationDbContext INavigationPopulator DataSourceResolver SQLServerDbContext ADC_Notification CosmosDbContext OutboxProcessor SqliteDbContext ADC_Conference ADC_Engagement"},{"u":"/docs/adr/006-database-per-service.html#rationale","d":"ADR-006: Database per Service","k":"Architecture Decision Records","t":"Rationale","x":"- Removes the shared-outbox race (the sharpest cost of the shared DB) without an OriginService filter: physical isolation is simpler and stronger than a logical filter. - Real…","i":"OriginService"},{"u":"/docs/adr/006-database-per-service.html#trade-offs","d":"ADR-006: Database per Service","k":"Architecture Decision Records","t":"Trade-offs","x":"- No cross-database FKs or transactions. Relationships that span services degrade to scalar IDs; consistency across services is eventual (outbox + broker), not transactional. -…"},{"u":"/docs/adr/007-grpc-extraction.html","d":"ADR-007: Synchronous Cross-Service Calls via gRPC Contracts","k":"Architecture Decision Records"},{"u":"/docs/adr/007-grpc-extraction.html#status","d":"ADR-007: Synchronous Cross-Service Calls via gRPC Contracts","k":"Architecture Decision Records","t":"Status","x":"Accepted. Revised 2026-08-23 (the [ServiceContract] marker now has a dedicated fitness rule behind it, ServiceContractPurityTestsBase, subclassed in all four repos; it is a…","i":"ServiceContractPurityTestsBase ServiceContract"},{"u":"/docs/adr/007-grpc-extraction.html#context","d":"ADR-007: Synchronous Cross-Service Calls via gRPC Contracts","k":"Architecture Decision Records","t":"Context","x":"Once modules became separate service processes, the in-process interface calls between them (e.g. Conference → Engagement's IBookmarkCountService, Engagement → Conference's…","i":"ISessionBookmarkValidationService IBookmarkCountService Result"},{"u":"/docs/adr/007-grpc-extraction.html#decision","d":"ADR-007: Synchronous Cross-Service Calls via gRPC Contracts","k":"Architecture Decision Records","t":"Decision","x":"Use gRPC, exposed through MMCA.Common.Grpc, with a contract-package convention: - .Contracts projects hold the .proto definitions plus a gRPC adapter that implements the same…","i":"SessionBookmarkValidationServiceGrpcAdapter GrpcResultExceptionInterceptor JwtForwardingClientInterceptor Directory.Build.props AddTypedGrpcClient SocketsHttpHandler MMCA.Common.Grpc HandleFailure IReadOnlyList RpcException serviceName Contracts"},{"u":"/docs/adr/007-grpc-extraction.html#rationale","d":"ADR-007: Synchronous Cross-Service Calls via gRPC Contracts","k":"Architecture Decision Records","t":"Rationale","x":"- No business-logic rewrite: the gRPC adapter implements the interface modules already depend on; swapping in-process for cross-process is a registration change. - Transport…","i":"MicroserviceExtractionTests ServiceContract MassTransit version proto"},{"u":"/docs/adr/007-grpc-extraction.html#trade-offs","d":"ADR-007: Synchronous Cross-Service Calls via gRPC Contracts","k":"Architecture Decision Records","t":"Trade-offs","x":"- Bidirectional pairs need care. Conference ↔ Engagement is a mutual gRPC pair; the AppHost deliberately omits a reciprocal WaitFor to avoid a startup deadlock: transient \"peer…","i":"Http1AndHttp2 WaitFor Http2 grpc"},{"u":"/docs/adr/008-service-extraction-topology.html","d":"ADR-008: Extraction of the Modular Monolith into Per-Module Services + Gateway","k":"Architecture Decision Records"},{"u":"/docs/adr/008-service-extraction-topology.html#status","d":"ADR-008: Extraction of the Modular Monolith into Per-Module Services + Gateway","k":"Architecture Decision Records","t":"Status","x":"Accepted. Amended by ADR-089 (2026-08-18): the Gateway keeps the route-to-service map this record gave it, but stops expressing it as MapForwarder calls in code. YARP…","i":"MapForwarder ReverseProxy"},{"u":"/docs/adr/008-service-extraction-topology.html#context","d":"ADR-008: Extraction of the Modular Monolith into Per-Module Services + Gateway","k":"Architecture Decision Records","t":"Context","x":"ADC began as a modular monolith: one MMCA.ADC.WebAPI host loaded every module (Identity, Conference, Engagement, Notification) in-process via the ModuleLoader, sharing one…","i":"MMCA.ADC.WebAPI ModuleLoader"},{"u":"/docs/adr/008-service-extraction-topology.html#decision","d":"ADR-008: Extraction of the Modular Monolith into Per-Module Services + Gateway","k":"Architecture Decision Records","t":"Decision","x":"Extract one service host per module: MMCA.ADC.{Identity,Conference,Engagement,Notification}.Service and front them with a single YARP reverse-proxy Gateway (MMCA.ADC.Gateway,…","i":"MicroserviceExtractionTests MMCA.ADC.Gateway MMCA.ADC.WebAPI ModuleLoader Notification Conference Engagement Identity MMCA.ADC Modules Service Module"},{"u":"/docs/adr/008-service-extraction-topology.html#rationale","d":"ADR-008: Extraction of the Modular Monolith into Per-Module Services + Gateway","k":"Architecture Decision Records","t":"Rationale","x":"- No business-logic rewrite. Because a service is just the monolith with one module enabled, extraction was a hosting/wiring change, not a domain change, and the module-isolation…","i":"ModuleLoader"},{"u":"/docs/adr/008-service-extraction-topology.html#trade-offs","d":"ADR-008: Extraction of the Modular Monolith into Per-Module Services + Gateway","k":"Architecture Decision Records","t":"Trade-offs","x":"- Operational complexity. Four deployables plus a Gateway, service discovery, a broker, and per-service databases, versus one process. Mitigated locally by Aspire orchestration…","i":"MMCA.Common.API ServiceDefaults Http1AndHttp2 Http2"},{"u":"/docs/adr/008-service-extraction-topology.html#applicability","d":"ADR-008: Extraction of the Modular Monolith into Per-Module Services + Gateway","k":"Architecture Decision Records","t":"Applicability","x":"This ADR is framed around ADC (the first repo extracted), but the same topology is now the framework's standard extraction shape, not an ADC-only choice. MMCA.Store followed it:…","i":"MMCA.Store.Gateway MMCA.Store.WebAPI MMCA.Store Identity Catalog Service Sales"},{"u":"/docs/adr/008-service-extraction-topology.html#related","d":"ADR-008: Extraction of the Modular Monolith into Per-Module Services + Gateway","k":"Architecture Decision Records","t":"Related","x":"ADR-003 (outbox dual dispatch), ADR-004 (cross-service token validation via JWKS), ADR-006 (database per service), and ADR-007 (gRPC cross-service calls) are the facet decisions…"},{"u":"/docs/adr/009-resilience-and-recovery-objectives.html","d":"ADR-009: Resilience Policies & Recovery Objectives","k":"Architecture Decision Records"},{"u":"/docs/adr/009-resilience-and-recovery-objectives.html#status","d":"ADR-009: Resilience Policies & Recovery Objectives","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-14). Amended by ADR-087 (2026-08-18): the resilience objective extends past outbound HTTP and gRPC clients for the first time, to the outbox's broker publish,…"},{"u":"/docs/adr/009-resilience-and-recovery-objectives.html#context","d":"ADR-009: Resilience Policies & Recovery Objectives","k":"Architecture Decision Records","t":"Context","x":"The framework already supplies the mechanisms for surviving partial failure: a standard Polly resilience handler (timeout / retry / circuit breaker), the outbox for at-least-once…","i":"ConfigureHttpClientDefaults AddTypedServiceClient AddTypedGrpcClient MMCA.Common.Aspire"},{"u":"/docs/adr/009-resilience-and-recovery-objectives.html#decision","d":"ADR-009: Resilience Policies & Recovery Objectives","k":"Architecture Decision Records","t":"Decision","x":"1. Resilience is a framework invariant, not a per-call choice. Every outbound HttpClient and gRPC client registered through the framework's extension methods (AddTypedGrpcClient,…","i":"MMCA.Common.Grpc.Tests ResilienceHandlerTests AddTypedServiceClient AddTypedGrpcClient MMCA.Common.Aspire HttpClient"},{"u":"/docs/adr/009-resilience-and-recovery-objectives.html#rationale","d":"ADR-009: Resilience Policies & Recovery Objectives","k":"Architecture Decision Records","t":"Rationale","x":"- Invariant over discipline. A fitness function turns \"remember to add resilience\" into a build gate: the same approach the framework already uses for the layer rules and the…"},{"u":"/docs/adr/009-resilience-and-recovery-objectives.html#trade-offs","d":"ADR-009: Resilience Policies & Recovery Objectives","k":"Architecture Decision Records","t":"Trade-offs","x":"- The named gate (ResilienceHandlerTests, MMCA.Common.Grpc.Tests) asserts that the gRPC client path (AddTypedGrpcClient) registers the standard handler, not the runtime behavior…","i":"ResilienceCircuitBreakerFaultInjectionTests MMCA.Common.Grpc.Tests ResilienceHandlerTests AddTypedServiceClient AddTypedGrpcClient"},{"u":"/docs/adr/009-resilience-and-recovery-objectives.html#revision-2026-08-18","d":"ADR-009: Resilience Policies & Recovery Objectives","k":"Architecture Decision Records","t":"Revision (2026-08-18)","x":"This record's first Decision point scoped resilience to \"every outbound HttpClient and gRPC client registered through the framework's extension methods\". That scope was accurate…","i":"BrokerResilienceDefaults BrokenCircuitException HttpResilienceDefaults CommandTimeoutSeconds EnableRetryOnFailure ResiliencePipeline DbContextFactory OutboxProcessor HttpClient"},{"u":"/docs/adr/010-integration-event-schema-versioning.html","d":"ADR-010: Integration-Event Schema Versioning & Upcaster Policy","k":"Architecture Decision Records"},{"u":"/docs/adr/010-integration-event-schema-versioning.html#status","d":"ADR-010: Integration-Event Schema Versioning & Upcaster Policy","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-19). Updated 2026-06-27 (Helpdesk enforcement gap closed; all three consumers now gate the convention). Updated 2026-08-14 (ADC now gates seven events, and a…","i":"OutputCacheEvictionRequested"},{"u":"/docs/adr/010-integration-event-schema-versioning.html#context","d":"ADR-010: Integration-Event Schema Versioning & Upcaster Policy","k":"Architecture Decision Records","t":"Context","x":"Integration events cross service boundaries (Identity → Conference, Conference ↔ Engagement, …) and are resolved by consumers solely by their type string: the outbox serializes…","i":"OutboxMessage.FromDomainEvent DateOccurred EventType MessageId"},{"u":"/docs/adr/010-integration-event-schema-versioning.html#decision","d":"ADR-010: Integration-Event Schema Versioning & Upcaster Policy","k":"Architecture Decision Records","t":"Decision","x":"1. Every integration event carries an explicit SchemaVersion. BaseIntegrationEvent exposes public virtual int SchemaVersion = 1;. It is serialized with the payload…","i":"MMCA.Common.Testing.Architecture EventConventionTestsBase BaseIntegrationEvent IIntegrationEvent UserRegisteredV2 SchemaVersion virtual public int"},{"u":"/docs/adr/010-integration-event-schema-versioning.html#rationale","d":"ADR-010: Integration-Event Schema Versioning & Upcaster Policy","k":"Architecture Decision Records","t":"Rationale","x":"- A signal, enforced. A version field plus a build-gating convention test turns \"remember the contract\" into something the tooling checks: the same invariant-over-discipline…","i":"virtual"},{"u":"/docs/adr/010-integration-event-schema-versioning.html#trade-offs","d":"ADR-010: Integration-Event Schema Versioning & Upcaster Policy","k":"Architecture Decision Records","t":"Trade-offs","x":"- SchemaVersion is a signal, not a mechanism: by itself it does not stop a consumer breaking on a real reshape. The load-bearing half is the discipline (new type + upcaster). At…","i":"RegisterUpcastedIntegrationEventConsumer MMCA.Helpdesk.Architecture.Tests EventVersioningConventionTests ProductCreatedIntegrationEvent MMCA.Store.Architecture.Tests OutputCacheEvictionRequested TicketOpenedIntegrationEvent MMCA.ADC.Architecture.Tests OrderPlacedIntegrationEvent EventConventionTestsBase CommonArchitectureMap map.ModuleNames.Count"},{"u":"/docs/adr/011-single-locale-i18n.html","d":"ADR-011: Single-Locale by Design (No Internationalization)","k":"Architecture Decision Records"},{"u":"/docs/adr/011-single-locale-i18n.html#status","d":"ADR-011: Single-Locale by Design (No Internationalization)","k":"Architecture Decision Records","t":"Status","x":"Superseded by ADR-027 (2026-06-27). Originally Accepted (2026-06-19). The \"if multi-locale is ever required\" scope below is the blueprint ADR-027 implements; this record is…"},{"u":"/docs/adr/011-single-locale-i18n.html#context","d":"ADR-011: Single-Locale by Design (No Internationalization)","k":"Architecture Decision Records","t":"Context","x":"The MMCA applications (the ADC conference app, the Store) and the MMCA.Common.UI library currently ship a single locale (en-US). The architecture rubric scores…","i":"MMCA.Common.UI"},{"u":"/docs/adr/011-single-locale-i18n.html#decision","d":"ADR-011: Single-Locale by Design (No Internationalization)","k":"Architecture Decision Records","t":"Decision","x":"1. Single-locale (en-US) is an explicit non-goal for now. User-facing strings are inline in markup; dates/numbers use invariant or fixed formatting where appropriate. 2. The…","i":"RequestLocalization"},{"u":"/docs/adr/011-single-locale-i18n.html#rationale","d":"ADR-011: Single-Locale by Design (No Internationalization)","k":"Architecture Decision Records","t":"Rationale","x":"- Recording the decision converts an implicit rubric-zero into a conscious, revisitable choice: the same posture as the single-region DR acceptance in ADR-009. - Premature i18n…"},{"u":"/docs/adr/011-single-locale-i18n.html#trade-offs","d":"ADR-011: Single-Locale by Design (No Internationalization)","k":"Architecture Decision Records","t":"Trade-offs","x":"- Adding a locale later touches every view plus the formatting paths: a real but bounded effort, accepted. - Hard-coded strings make a future extraction larger; mitigated by the…"},{"u":"/docs/adr/012-grpc-host-transport.html","d":"ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)","k":"Architecture Decision Records"},{"u":"/docs/adr/012-grpc-host-transport.html#status","d":"ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)","k":"Architecture Decision Records","t":"Status","x":"Accepted (re-verified against source 2026-08-14)."},{"u":"/docs/adr/012-grpc-host-transport.html#update-2026-06-22-store-converged-to-profile-a","d":"ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)","k":"Architecture Decision Records","t":"Update (2026-06-22): Store converged to Profile A","x":"Store originally chose Profile B, but its cross-service gRPC failed in Azure Container Apps. With Http1AndHttp2 Kestrel + transport: 'auto' ingress on a cleartext endpoint there…","i":"IProductVariantService.ExistsAsync IUserSalesExportService HTTP_1_1_REQUIRED WithJwksDiscovery AddItemCommand Http1AndHttp2 transport identity gateway httpGet Http2"},{"u":"/docs/adr/012-grpc-host-transport.html#update-2026-07-09-adc-notification-adds-a-mixed-endpoint-profile-per-endpoint-protocols","d":"ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)","k":"Architecture Decision Records","t":"Update (2026-07-09): ADC Notification adds a mixed-endpoint profile (per-endpoint protocols)","x":"The live-channel push pipeline (ADR-039) gave ADC's Notification service an inbound cleartext gRPC server (LiveChannelPushService.PushToChannel, called best-effort by Engagement…","i":"LiveChannelPushService.PushToChannel engagementService.WithReference services__notification__grpc__0 appsettings.Development.json additionalPortMappings notificationService Http1AndHttp2 httpGet WaitFor Http2 grpc http"},{"u":"/docs/adr/012-grpc-host-transport.html#update-2026-07-25-probe-listeners-are-adcs-answer-not-tcp-probes-and-gateway-routed-jwks-is-a-local-only-rule","d":"ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)","k":"Architecture Decision Records","t":"Update (2026-07-25): probe listeners are ADC's answer, not TCP probes; and gateway-routed JWKS is a local-only rule","x":"Two claims above were written from an earlier state of the code and no longer describe either app. 1. ADC probes never touch the traffic endpoint; TCP probes were then…","i":"HTTP_1_1_REQUIRED WithJwksDiscovery identityApp.name Program.cs tcpSocket transport identity gateway httpGet Http1 grpc"},{"u":"/docs/adr/012-grpc-host-transport.html#update-2026-07-28-the-probe-listener-is-the-single-pattern-in-both-apps-no-tcp-probes-anywhere","d":"ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)","k":"Architecture Decision Records","t":"Update (2026-07-28): the probe listener is the single pattern in both apps (no TCP probes anywhere)","x":"Store PR 55 (commit 297064bb, merged 2026-07-27) ported ADC's dedicated probe listener to Store, so the Store-only tcpSocket exception recorded in the 2026-07-25 update above is…","i":"HealthProbe__Port Http1AndHttp2 tcpSocket httpGet Http1 Http2"},{"u":"/docs/adr/012-grpc-host-transport.html#update-2026-08-07-the-probe-listener-moved-into-mmcacommon-and-notifications-grpc-endpoint-carries-a-second-service","d":"ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)","k":"Architecture Decision Records","t":"Update (2026-08-07): the probe listener moved into MMCA.Common, and Notification's gRPC endpoint carries a second service","x":"1. One shared framework method, not a per-service file. The KestrelConfiguration.cs copies the two updates above cite no longer exist in either app. The pattern was extracted…","i":"UserNotificationExportGrpcService MMCA.ADC.Notification.Contracts services__notification__grpc__0 identityService.WithReference appsettings.Development.json HttpProtocols.Http1AndHttp2 redeclareCleartextEndpoint ConfigureEndpointDefaults KestrelConfiguration.cs additionalPortMappings ASPNETCORE_ENVIRONMENT LiveChannelGrpcService"},{"u":"/docs/adr/012-grpc-host-transport.html#update-2026-08-14-stores-sales-runs-the-mixed-endpoint-profile-too-so-no-pure-profile-b-host-remains","d":"ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)","k":"Architecture Decision Records","t":"Update (2026-08-14): Store's Sales runs the mixed-endpoint profile too, so no pure Profile B host remains","x":"Sales gained an inbound gRPC edge of its own (IUserSalesExportService, the Identity-driven data-subject export), and it resolved that the same way ADC's Notification did: not by…","i":"identityService.WithReference appsettings.Development.json UserSalesExportGrpcService AddSalesUserExportClient services__sales__grpc__0 IUserSalesExportService additionalPortMappings RequireAuthorization HealthProbe__Port Http1AndHttp2 salesService _grpc.sales"},{"u":"/docs/adr/012-grpc-host-transport.html#context","d":"ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)","k":"Architecture Decision Records","t":"Context","x":"Once modules were extracted into separate service hosts (ADR-008) that call each other synchronously over gRPC (ADR-007), each service's Kestrel had to serve both REST traffic…","i":"HTTP_1_1_REQUIRED Http1AndHttp2 HttpClient Http2"},{"u":"/docs/adr/012-grpc-host-transport.html#decision","d":"ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)","k":"Architecture Decision Records","t":"Decision","x":"Pick one of two coherent transport profiles per app, and wire the gateway forwarder and JWKS discovery to match. Use when services must serve gRPC on cleartext (any bidirectional…","i":"builder.ConfigureEndpointsWithHealthProbe UserNotificationExportGrpcService HttpProtocols.Http1AndHttp2 ConfigureEndpointDefaults LiveChannelGrpcService HttpVersion.Version20 RequestVersionOrLower HttpProtocols.Http2 RequestVersionExact HTTP_1_1_REQUIRED WithJwksDiscovery Http1AndHttp2"},{"u":"/docs/adr/012-grpc-host-transport.html#rationale","d":"ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)","k":"Architecture Decision Records","t":"Rationale","x":"- The Kestrel protocol choice is the root constraint; the gateway-forward mode and the JWKS authority are downstream consequences, not independent knobs. Documenting them as a…","i":"HTTP_1_1_REQUIRED Http2"},{"u":"/docs/adr/012-grpc-host-transport.html#trade-offs","d":"ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)","k":"Architecture Decision Records","t":"Trade-offs","x":"- Two profiles to keep straight. A service that gains an inbound gRPC edge must migrate from Profile B to Profile A and flip ForwardHttp2 and the JWKS wiring together, or it…","i":"appsettings.Development.json additionalPortMappings appsettings.json Http1AndHttp2 ForwardHttp2 transport http2"},{"u":"/docs/adr/012-grpc-host-transport.html#related","d":"ADR-012: gRPC-Host Transport Convention (Http2-only h2c vs. Http1AndHttp2 + ALPN)","k":"Architecture Decision Records","t":"Related","x":"- ADR-004 (cross-service token validation via JWKS / OIDC discovery), ADR-007 (gRPC cross-service calls), ADR-008 (monolith → services + gateway topology), ADR-039 (live-channel…"},{"u":"/docs/adr/013-result-pattern.html","d":"ADR-013: Result Pattern over Exceptions for Flow Control","k":"Architecture Decision Records"},{"u":"/docs/adr/013-result-pattern.html#status","d":"ADR-013: Result Pattern over Exceptions for Flow Control","k":"Architecture Decision Records","t":"Status","x":"Accepted. Revised 2026-07-21 (exception-handler chain / ProblemDetails edge contract documented)."},{"u":"/docs/adr/013-result-pattern.html#context","d":"ADR-013: Result Pattern over Exceptions for Flow Control","k":"Architecture Decision Records","t":"Context","x":"Operations at every layer fail in expected ways: input is invalid, a domain invariant is broken, a requested entity is missing, a uniqueness conflict occurs, the caller lacks…"},{"u":"/docs/adr/013-result-pattern.html#decision","d":"ADR-013: Result Pattern over Exceptions for Flow Control","k":"Architecture Decision Records","t":"Decision","x":"Model expected failures as values using Result / Result (MMCA.Common.Shared.Abstractions), not exceptions. - A Result is either success or failure; a failure carries one or more…","i":"OperationCanceledExceptionHandler ApiControllerBase.HandleFailure MMCA.Common.Shared.Abstractions GrpcResultExceptionInterceptor AddCommonExceptionHandlers OperationCanceledException ValidationExceptionHandler DbUpdateExceptionHandler DomainExceptionHandler GlobalExceptionHandler UnprocessableEntity ValidationException"},{"u":"/docs/adr/013-result-pattern.html#rationale","d":"ADR-013: Result Pattern over Exceptions for Flow Control","k":"Architecture Decision Records","t":"Rationale","x":"- Failures are in the signature. A method that can fail returns Result , so the caller cannot silently ignore the failure path the way an uncaught exception allows. - Category,…","i":"Result.Failure HandleFailure ErrorType IsFailure requestId Result"},{"u":"/docs/adr/013-result-pattern.html#trade-offs","d":"ADR-013: Result Pattern over Exceptions for Flow Control","k":"Architecture Decision Records","t":"Trade-offs","x":"- More ceremony at call sites than letting an exception bubble; the combinators absorb most of it. - Two error channels coexist (Result for expected, exceptions for exceptional).…","i":"GlobalExceptionHandler ErrorType Result"},{"u":"/docs/adr/013-result-pattern.html#related","d":"ADR-013: Result Pattern over Exceptions for Flow Control","k":"Architecture Decision Records","t":"Related","x":"ADR-007 (Result over the wire via gRPC), ADR-014 (the decorator pipeline returns Result.Failure to short-circuit a command before it reaches the handler).","i":"Result.Failure"},{"u":"/docs/adr/014-cqrs-decorator-pipeline.html","d":"ADR-014: CQRS Handlers with a Decorator Pipeline","k":"Architecture Decision Records"},{"u":"/docs/adr/014-cqrs-decorator-pipeline.html#status","d":"ADR-014: CQRS Handlers with a Decorator Pipeline","k":"Architecture Decision Records","t":"Status","x":"Accepted. Revised 2026-07-19 (Transactional semantics: rollback on business failure + post-commit event dispatch; see Revision below). Revised 2026-08-18 (the pipeline order…"},{"u":"/docs/adr/014-cqrs-decorator-pipeline.html#context","d":"ADR-014: CQRS Handlers with a Decorator Pipeline","k":"Architecture Decision Records","t":"Context","x":"Commands and queries share cross-cutting concerns: validation, transactions, cache invalidation, logging / timing, and feature gating. Putting that logic inside each handler…"},{"u":"/docs/adr/014-cqrs-decorator-pipeline.html#decision","d":"ADR-014: CQRS Handlers with a Decorator Pipeline","k":"Architecture Decision Records","t":"Decision","x":"Use single-responsibility handlers behind a Scrutor-composed decorator pipeline. - ICommandHandler and IQueryHandler (MMCA.Common.Application) are one handler per use case, each…","i":"ModuleLoader.DiscoverAndRegister ScanModuleApplicationServices ProfilingCommandDecorator AddApplicationDecorators AddApplicationProfiling MMCA.Common.Application ProfilingQueryDecorator ICacheInvalidating AddInfrastructure ICommandHandler IQueryCacheable AddApplication"},{"u":"/docs/adr/014-cqrs-decorator-pipeline.html#rationale","d":"ADR-014: CQRS Handlers with a Decorator Pipeline","k":"Architecture Decision Records","t":"Rationale","x":"- Thin, testable handlers. A handler has no transaction, logging, or caching plumbing, so it is unit-tested in isolation. - One place to read and change the pipeline. The order…"},{"u":"/docs/adr/014-cqrs-decorator-pipeline.html#trade-offs","d":"ADR-014: CQRS Handlers with a Decorator Pipeline","k":"Architecture Decision Records","t":"Trade-offs","x":"- Registration order is the reverse of execution order (a Scrutor foot-gun), mitigated by the inline ordering comments in AddApplicationDecorators(). - Decorators must be…","i":"AddApplicationDecorators"},{"u":"/docs/adr/014-cqrs-decorator-pipeline.html#revision-2026-07-19","d":"ADR-014: CQRS Handlers with a Decorator Pipeline","k":"Architecture Decision Records","t":"Revision (2026-07-19)","x":"Two Transactional-decorator semantics changed with the 2026-07-19 full review: - A returned business failure now rolls the transaction back. Previously a handler returning…","i":"DbContextFactory.ExecuteInTransactionAsync DomainEventSaveChangesInterceptor RollbackTransaction Result.Failure IsFailure Result"},{"u":"/docs/adr/014-cqrs-decorator-pipeline.html#revision-2026-08-18","d":"ADR-014: CQRS Handlers with a Decorator Pipeline","k":"Architecture Decision Records","t":"Revision (2026-08-18)","x":"Two decorators were added to both chains, so the order recorded in the Decision above is no longer the shipped one. The registration site is unchanged in kind:…","i":"CancellationTokenSource.CreateLinkedTokenSource cqrs.authorization.denied.count DecoratorPipelineOrderTestsBase AuthorizationCommandDecorator ExpectedCommandDecorators AddApplicationDecorators ExpectedQueryDecorators AuthorizationDenied ICurrentUserService IPermissionRegistry IRequiresPermission budget.CancelAfter"},{"u":"/docs/adr/014-cqrs-decorator-pipeline.html#related","d":"ADR-014: CQRS Handlers with a Decorator Pipeline","k":"Architecture Decision Records","t":"Related","x":"ADR-013 (Result, the short-circuit currency of the pipeline, and the Failure error type the timeout decorator reuses because the taxonomy has no timeout member), ADR-003…","i":"IPermissionRegistry MMCA.Common.Cqrs HasPermission SaveChanges Failure"},{"u":"/docs/adr/015-architecture-fitness-functions.html","d":"ADR-015: Architecture Invariants Enforced as Fitness Functions","k":"Architecture Decision Records"},{"u":"/docs/adr/015-architecture-fitness-functions.html#status","d":"ADR-015: Architecture Invariants Enforced as Fitness Functions","k":"Architecture Decision Records","t":"Status","x":"Accepted. Revised 2026-08-18 (two new rule families, namespace dependency cycles and trailing CancellationToken declarations, plus a third enforcement layer: a compile-time…","i":"CancellationToken proto"},{"u":"/docs/adr/015-architecture-fitness-functions.html#context","d":"ADR-015: Architecture Invariants Enforced as Fitness Functions","k":"Architecture Decision Records","t":"Context","x":"The codebase rests on invariants that are easy to state and easy to erode by accident: clean- architecture layer flow (Domain depends on nothing above it), module isolation (no…","i":"SchemaVersion"},{"u":"/docs/adr/015-architecture-fitness-functions.html#decision","d":"ADR-015: Architecture Invariants Enforced as Fitness Functions","k":"Architecture Decision Records","t":"Decision","x":"Enforce architectural invariants as automated checks that gate the build, in two layers (a third joined them on 2026-08-18: see the Revision at the end). 1. Compile-time guard.…","i":"MMCA.Common.LayerEnforcement.targets MMCA.Common.Testing.Architecture HelpdeskArchitectureMap CommonArchitectureMap StoreArchitectureMap AdcArchitectureMap IArchitectureMap ProjectReference dotnet test"},{"u":"/docs/adr/015-architecture-fitness-functions.html#rationale","d":"ADR-015: Architecture Invariants Enforced as Fitness Functions","k":"Architecture Decision Records","t":"Rationale","x":"- Invariant over discipline. Turning \"do not do X\" into a red build is the only enforcement that scales. It is the same lever used by the layer rules, the resilience gate…","i":"IArchitectureMap"},{"u":"/docs/adr/015-architecture-fitness-functions.html#trade-offs","d":"ADR-015: Architecture Invariants Enforced as Fitness Functions","k":"Architecture Decision Records","t":"Trade-offs","x":"- The tests assert structure / registration, not runtime behavior. ADR-009's test proves a client wires resilience, not that its policy values are correct; parameter tuning stays…","i":"FrameworkSanityTests IArchitectureMap"},{"u":"/docs/adr/015-architecture-fitness-functions.html#revision-2026-08-18","d":"ADR-015: Architecture Invariants Enforced as Fitness Functions","k":"Architecture Decision Records","t":"Revision (2026-08-18)","x":"Two new rule families joined the shared library, and a third enforcement layer joined the two the Decision above describes. The counts in MMCA.Common/FACTS.md move with them: 102…","i":"AsyncMethodsDeclareTrailingCancellationToken Microsoft.CodeAnalysis.PublicApiAnalyzers ArchitectureRules.CancellationTokens dotnet_analyzer_diagnostic.severity NamespacesHaveNoDependencyCycles MMCA.Common.Infrastructure Context.ConnectionAborted IHostedService.StartAsync ArchitectureRules.Cycles TenancySettingsValidator InternalAPI.Shipped.txt NamespaceCycleTestsBase"},{"u":"/docs/adr/015-architecture-fitness-functions.html#revision-2026-08-18-section-b-rule-families","d":"ADR-015: Architecture Invariants Enforced as Fitness Functions","k":"Architecture Decision Records","t":"Revision (2026-08-18): Section B rule families","x":"A second entry on the same date, kept separate rather than folded into the one above because it lands with a different wave and changes a number that revision states. Two rule…","i":"IdempotencyConventionTestsBase ArchitectureRules.Protos ProtoContractTestsBase PublicAPI.Shipped.txt FrozenProtoContracts csharp_namespace SolutionFileName FactsGenerator justification NonIdempotent Idempotent ProtoFiles"},{"u":"/docs/adr/015-architecture-fitness-functions.html#revision-2026-08-23-superseded-counts-a-re-anchored-citation-and-the-real-test-floor","d":"ADR-015: Architecture Invariants Enforced as Fitness Functions","k":"Architecture Decision Records","t":"Revision (2026-08-23): superseded counts, a re-anchored citation, and the real test floor","x":"No rule family joined or left the library in this entry. It corrects three things the two 2026-08-18 revisions above state, and it is kept as its own entry rather than edited…","i":"Microsoft.CodeAnalysis.PublicApiAnalyzers SpecificationFitnessTests MMCA.Common.UI.E2E.Tests FrameworkSanityTests FactsGenerator FACTS.md"},{"u":"/docs/adr/015-architecture-fitness-functions.html#related","d":"ADR-015: Architecture Invariants Enforced as Fitness Functions","k":"Architecture Decision Records","t":"Related","x":"ADR-009 (resilience gate), ADR-010 (event-version gate), ADR-016 (MassTransit pin gate, and the lockstep release cadence the public API baseline is pinned to), ADR-006/007/008…","i":"CancellationToken"},{"u":"/docs/adr/016-lockstep-versioning-masstransit-pin.html","d":"ADR-016: Lockstep Package Versioning and the MassTransit-v8 Pin","k":"Architecture Decision Records"},{"u":"/docs/adr/016-lockstep-versioning-masstransit-pin.html#status","d":"ADR-016: Lockstep Package Versioning and the MassTransit-v8 Pin","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-15). Amended (2026-07-28): the fitness function now gates two commercial-license majors (MassTransit and SixLabors.ImageSharp), so the decision is restated as…","i":"MassTransit.Azure.ServiceBus.Core Directory.Packages.props SixLabors.ImageSharp"},{"u":"/docs/adr/016-lockstep-versioning-masstransit-pin.html#context","d":"ADR-016: Lockstep Package Versioning and the MassTransit-v8 Pin","k":"Architecture Decision Records","t":"Context","x":"MMCA.Common publishes its MMCA.Common. NuGet package set (see FACTS.md for the authoritative list and count) consumed by three downstream repos: the two production apps (Store,…","i":"Directory.Packages.props Infrastructure MassTransit MT_LICENSE FACTS.md Domain"},{"u":"/docs/adr/016-lockstep-versioning-masstransit-pin.html#decision","d":"ADR-016: Lockstep Package Versioning and the MassTransit-v8 Pin","k":"Architecture Decision Records","t":"Decision","x":"1. Version the whole MMCA.Common. package set in lockstep. All packages share one version (MinVer, derived from a single vX.Y.Z git tag); a release tags every package (see…","i":"MassTransit.Azure.ServiceBus.Core RestorePackagesWithLockFile DependencyVersionTestsBase MMCA.Common.Infrastructure Directory.Packages.props MassTransit.RabbitMQ SixLabors.ImageSharp MassTransit MT_LICENSE FACTS.md Obsolete vX.Y.Z"},{"u":"/docs/adr/016-lockstep-versioning-masstransit-pin.html#rationale","d":"ADR-016: Lockstep Package Versioning and the MassTransit-v8 Pin","k":"Architecture Decision Records","t":"Rationale","x":"- One version, one compatibility story. Lockstep removes the N-package matrix: \"everything on vX.Y.Z\" is the only supported combination, which is the right trade for a small…","i":"vX.Y.Z"},{"u":"/docs/adr/016-lockstep-versioning-masstransit-pin.html#trade-offs","d":"ADR-016: Lockstep Package Versioning and the MassTransit-v8 Pin","k":"Architecture Decision Records","t":"Trade-offs","x":"- A consumer cannot adopt a single package in isolation: it takes the whole set at the new version. - Lockstep will bump a package whose code did not change (acceptable: the…","i":"MassTransit.Azure.ServiceBus.Core Directory.Packages.props dependabot.yml"},{"u":"/docs/adr/016-lockstep-versioning-masstransit-pin.html#related","d":"ADR-016: Lockstep Package Versioning and the MassTransit-v8 Pin","k":"Architecture Decision Records","t":"Related","x":"ADR-015 (the fitness function that enforces the pins), ADR-003 / ADR-006 (MassTransit is the broker transport behind the outbox and database-per-service flows)."},{"u":"/docs/adr/017-request-idempotency.html","d":"ADR-017: HTTP Request Idempotency via Client-Supplied Keys","k":"Architecture Decision Records"},{"u":"/docs/adr/017-request-idempotency.html#status","d":"ADR-017: HTTP Request Idempotency via Client-Supplied Keys","k":"Architecture Decision Records","t":"Status","x":"Accepted. Revised 2026-08-01: the guard around execute-and-store is now an IDistributedLock resolved from DI (Redis-backed wherever a connection multiplexer is registered, which…","i":"IdempotencyConventionTestsBase IDistributedLock justification NonIdempotent ObjectResult Idempotent NoContent HttpPost"},{"u":"/docs/adr/017-request-idempotency.html#context","d":"ADR-017: HTTP Request Idempotency via Client-Supplied Keys","k":"Architecture Decision Records","t":"Context","x":"Write endpoints (POST / PUT / PATCH) are exposed to client retries and double-submits: a flaky network, an impatient user double-clicking, or a resilience pipeline re-issuing a…","i":"Result"},{"u":"/docs/adr/017-request-idempotency.html#decision","d":"ADR-017: HTTP Request Idempotency via Client-Supplied Keys","k":"Architecture Decision Records","t":"Decision","x":"Provide opt-in, client-driven request idempotency as an MVC action filter in MMCA.Common.API. - Opt-in per action. [Idempotent] (IdempotentAttribute, a ServiceFilterAttribute…","i":"IdempotencySettings.CacheExpirationHours InProcessDistributedLock IConnectionMultiplexer ServiceFilterAttribute KeyedSemaphoreStripe RedisDistributedLock IdempotentAttribute AddInfrastructure IdempotencyFilter IDistributedLock StatusCodeResult MMCA.Common.API"},{"u":"/docs/adr/017-request-idempotency.html#rationale","d":"ADR-017: HTTP Request Idempotency via Client-Supplied Keys","k":"Architecture Decision Records","t":"Rationale","x":"- Safety at the edge, not in every handler. Deduplication lives in one filter, so a handler stays a thin use case (ADR-014) and does not grow ad-hoc \"did I already do this?\"…","i":"Idempotent"},{"u":"/docs/adr/017-request-idempotency.html#trade-offs","d":"ADR-017: HTTP Request Idempotency via Client-Supplied Keys","k":"Architecture Decision Records","t":"Trade-offs","x":"- Cross-instance mutual exclusion follows Redis, so it is a deployment property, not a guarantee. Every ADC and Store service host registers a Redis IConnectionMultiplexer when a…","i":"IConnectionMultiplexer StatusCodeResult IAnonymizable ObjectResult Idempotent Location redis"},{"u":"/docs/adr/017-request-idempotency.html#revision-2026-08-18","d":"ADR-017: HTTP Request Idempotency via Client-Supplied Keys","k":"Architecture Decision Records","t":"Revision (2026-08-18)","x":"The last Trade-off above is the one this revision addresses, and it does so by changing what is required. Nothing here makes an endpoint idempotent. What it requires is that…","i":"PostActions_ShouldDeclare_IdempotencyIntent IdempotencyConventionTestsBase AttributeTargets.Method NonIdempotentAttribute GetCustomAttributes AuthControllerBase EnableRateLimiting MMCA.Common.API AttributeUsage justification Justification NonIdempotent"},{"u":"/docs/adr/017-request-idempotency.html#related","d":"ADR-017: HTTP Request Idempotency via Client-Supplied Keys","k":"Architecture Decision Records","t":"Related","x":"ADR-003 (handler idempotency for outbox/event consumers, a distinct concern), ADR-013 (Result is the response the filter caches/replays), ADR-014 (the filter keeps the handler…","i":"ICacheService NonIdempotent"},{"u":"/docs/adr/018-polyglot-persistence.html","d":"ADR-018: Polyglot Persistence (Multiple Storage Engines Behind One Model)","k":"Architecture Decision Records"},{"u":"/docs/adr/018-polyglot-persistence.html#status","d":"ADR-018: Polyglot Persistence (Multiple Storage Engines Behind One Model)","k":"Architecture Decision Records","t":"Status","x":"Accepted. The framework plumbing is complete, covered by unit and integration tests (DataSourceResolverTests, CrossDataSourceDegradeConventionTests, EntityTypeConfigurationTests,…","i":"CrossDataSourceDegradeConventionTests CosmosConfigurationPortabilityTests MultiSourceSqliteIntegrationTests EntityTypeConfigurationTests DataSourceResolverTests FACTS.md Session Room"},{"u":"/docs/adr/018-polyglot-persistence.html#context","d":"ADR-018: Polyglot Persistence (Multiple Storage Engines Behind One Model)","k":"Architecture Decision Records","t":"Context","x":"ADR-006 (database-per-service) splits storage along the Name axis: several physically separate databases, all on the same engine (SQL Server), one per service. A second,…","i":"DataSourceKey Engine Name"},{"u":"/docs/adr/018-polyglot-persistence.html#decision","d":"ADR-018: Polyglot Persistence (Multiple Storage Engines Behind One Model)","k":"Architecture Decision Records","t":"Decision","x":"Support three storage engines behind one entity model and one set of repository abstractions, selected per entity configuration. 1. DataSource engine enum: SQLServer (full…","i":"CrossDataSourceDegradeConvention EntityTypeConfigurationSQLServer EntityTypeConfigurationCosmos EntityTypeConfigurationSqlite SQLServerMigrationsAssembly CosmosIntIdValueGenerator SQLServerConnectionString EntityDataSourceRegistry EntityTypeConfiguration CosmosConnectionString SqliteConnectionString ApplicationDbContext"},{"u":"/docs/adr/018-polyglot-persistence.html#rationale","d":"ADR-018: Polyglot Persistence (Multiple Storage Engines Behind One Model)","k":"Architecture Decision Records","t":"Rationale","x":"- Right store per access pattern, as a configuration decision. The engine becomes an attribute on a configuration class, not a rewrite. The same domain entity, application…"},{"u":"/docs/adr/018-polyglot-persistence.html#trade-offs","d":"ADR-018: Polyglot Persistence (Multiple Storage Engines Behind One Model)","k":"Architecture Decision Records","t":"Trade-offs","x":"- No cross-engine JOINs, FKs, or transactions. This is the ADR-006 cost made sharper: across engines it is a hard limit, not a deployment choice. A query spanning engines (for…","i":"SpecificationsDoNotNavigateToOtherEntities CrossSourceSpecification specifications keys"},{"u":"/docs/adr/018-polyglot-persistence.html#related","d":"ADR-018: Polyglot Persistence (Multiple Storage Engines Behind One Model)","k":"Architecture Decision Records","t":"Related","x":"ADR-006 (database-per-service: the Name axis this ADR's Engine axis is orthogonal to; they share DataSourceKey), ADR-002 (navigation populators bridge the relationships the…","i":"DataSourceKey"},{"u":"/docs/adr/019-rate-limiting.html","d":"ADR-019: Layered Rate Limiting with an Authenticated-Only Global Limiter","k":"Architecture Decision Records"},{"u":"/docs/adr/019-rate-limiting.html#status","d":"ADR-019: Layered Rate Limiting with an Authenticated-Only Global Limiter","k":"Architecture Decision Records","t":"Status","x":"Accepted. Revised 2026-08-01 (the auth-ip per-IP anonymous-authentication limiter, which the shared auth controller applies to login and register by default, is recorded as the…","i":"RateLimitingSettings UserPolicy"},{"u":"/docs/adr/019-rate-limiting.html#context","d":"ADR-019: Layered Rate Limiting with an Authenticated-Only Global Limiter","k":"Architecture Decision Records","t":"Context","x":"Every service exposes read and write endpoints to the public internet through the gateway (ADR-008). Abusive or runaway clients (scrapers, credential stuffing, retry storms, a…"},{"u":"/docs/adr/019-rate-limiting.html#decision","d":"ADR-019: Layered Rate Limiting with an Authenticated-Only Global Limiter","k":"Architecture Decision Records","t":"Decision","x":"Rate limiting is layered, and the always-on global limiter is authenticated-only. 1. A global limiter that only caps authenticated callers. AddCommonRateLimiting…","i":"HttpContext.Connection.RemoteIpAddress EnableRateLimitingAttribute UseCommonMiddlewarePipeline AttributeUsage.Inherited LoginProtectionService AddCommonRateLimiting RateLimitPolicyAuthIp GetCustomAttributes UseForwardedHeaders AuthControllerBase EnableRateLimiting EndpointDataSource"},{"u":"/docs/adr/019-rate-limiting.html#rationale","d":"ADR-019: Layered Rate Limiting with an Authenticated-Only Global Limiter","k":"Architecture Decision Records","t":"Rationale","x":"- Limit the traffic that is both attributable and expensive. An authenticated request is tied to a principal and usually drives the database; capping per-principal stops a single…"},{"u":"/docs/adr/019-rate-limiting.html#trade-offs","d":"ADR-019: Layered Rate Limiting with an Authenticated-Only Global Limiter","k":"Architecture Decision Records","t":"Trade-offs","x":"- The global limiter only protects the authenticated surface. The anonymous surface is covered endpoint by endpoint instead: login and register carry the auth-ip limiter by…","i":"ForwardLimit"},{"u":"/docs/adr/019-rate-limiting.html#revision-2026-08-18","d":"ADR-019: Layered Rate Limiting with an Authenticated-Only Global Limiter","k":"Architecture Decision Records","t":"Revision (2026-08-18)","x":"The layering above is unchanged: the global limiter is still authenticated-only, infrastructure and anonymous traffic are still exempt, auth-ip still covers login and register by…","i":"RateLimitAlgorithm.FixedWindow RedisFixedWindowRateLimiter IConnectionMultiplexer Interlocked.Exchange RateLimitingSettings StringIncrementAsync PerUserPermitLimit AuthIpPermitLimit GlobalPermitLimit SegmentsPerWindow allowDistributed SlidingWindow"},{"u":"/docs/adr/019-rate-limiting.html#related","d":"ADR-019: Layered Rate Limiting with an Authenticated-Only Global Limiter","k":"Architecture Decision Records","t":"Related","x":"ADR-004 (the JWKS/discovery traffic the limiter exempts, and the authenticated principal it keys on), ADR-008 (the gateway edge this protects), ADR-017 (request idempotency, the…","i":"RateLimitingSettings IncrementAsync UseRateLimiter Distributed INCR"},{"u":"/docs/adr/020-permission-based-authorization.html","d":"ADR-020: Permission-Based Authorization Layered over Roles","k":"Architecture Decision Records"},{"u":"/docs/adr/020-permission-based-authorization.html#status","d":"ADR-020: Permission-Based Authorization Layered over Roles","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-25, amended 2026-07-10 and 2026-08-23)."},{"u":"/docs/adr/020-permission-based-authorization.html#context","d":"ADR-020: Permission-Based Authorization Layered over Roles","k":"Architecture Decision Records","t":"Context","x":"Authorization started as pure role-based access control (RBAC). Endpoints declared the role they required with [Authorize(Policy = ...)] against named policies: RequireOrganizer,…","i":"RequireAuthenticatedUser RequireAuthenticated RequireOrganizer RequireAttendee RequireAdmin RequireRole Authorize Policy"},{"u":"/docs/adr/020-permission-based-authorization.html#decision","d":"ADR-020: Permission-Based Authorization Layered over Roles","k":"Architecture Decision Records","t":"Decision","x":"Add a permission (capability) layer over RBAC, opt-in and backward-compatible. - A central registry maps roles to permissions. IPermissionRegistry / PermissionRegistry…","i":"DefaultAuthorizationPolicyProvider PermissionAuthorizationHandler AuthClaimTypes.Permission PermissionRegistryBuilder AddAuthorizationPolicies PermissionPolicyProvider RequireAuthenticatedUser MMCA.Common.Shared.Auth RoleNames.ContentEditor HasPermissionAttribute PermissionRequirement IPermissionRegistry"},{"u":"/docs/adr/020-permission-based-authorization.html#rationale","d":"ADR-020: Permission-Based Authorization Layered over Roles","k":"Architecture Decision Records","t":"Rationale","x":"- Capabilities decouple endpoints from roles. A route says what it does (conference:sessions:manage), and who may do it is a registry decision, so adding ContentEditor with a…","i":"ContentEditor"},{"u":"/docs/adr/020-permission-based-authorization.html#trade-offs","d":"ADR-020: Permission-Based Authorization Layered over Roles","k":"Architecture Decision Records","t":"Trade-offs","x":"- It is still RBAC, not ABAC. The model resolves role to permission; it does not evaluate resource or attribute conditions. Per-resource ownership (\"a customer may read only…","i":"ConferencePermissions OwnerOrAdminFilter AddPermissions IAnonymizable Idempotent Grant"},{"u":"/docs/adr/020-permission-based-authorization.html#related","d":"ADR-020: Permission-Based Authorization Layered over Roles","k":"Architecture Decision Records","t":"Related","x":"ADR-004 (the authenticated principal and claims this keys on, including the optional permission claim), ADR-008 (each extracted service authorizes independently, so the registry…","i":"permission"},{"u":"/docs/adr/021-consumer-inbox-idempotency.html","d":"ADR-021: Consumer-Side Inbox for Integration-Event Idempotency","k":"Architecture Decision Records"},{"u":"/docs/adr/021-consumer-inbox-idempotency.html#status","d":"ADR-021: Consumer-Side Inbox for Integration-Event Idempotency","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-09; adoption reviewed 2026-07-15). Revised 2026-08-18 (the inbox stays opt-in, but being off is no longer silent: a broker-connected host running NoOpInboxStore…","i":"NoOpInboxStore InboxMessages"},{"u":"/docs/adr/021-consumer-inbox-idempotency.html#context","d":"ADR-021: Consumer-Side Inbox for Integration-Event Idempotency","k":"Architecture Decision Records","t":"Context","x":"ADR-003 makes integration-event delivery at-least-once: the outbox guarantees a published event is not lost, and the MassTransit broker redelivers on consumer failure.…"},{"u":"/docs/adr/021-consumer-inbox-idempotency.html#decision","d":"ADR-021: Consumer-Side Inbox for Integration-Event Idempotency","k":"Architecture Decision Records","t":"Decision","x":"Add an opt-in inbox that records each successfully-processed integration event by its MessageId and skips redeliveries. - Every event carries a MessageId. BaseDomainEvent stamps…","i":"IX_InboxMessages_MessageId IntegrationEventConsumer SessionFeedbackSubmitted SpeakerUnlinkedFromUser EventFeedbackSubmitted AlreadyProcessedAsync ProductVariantChanged OutboxCleanupService SpeakerLinkedToUser AddBrokerMessaging MarkProcessedAsync AttendeeCheckedIn"},{"u":"/docs/adr/021-consumer-inbox-idempotency.html#rationale","d":"ADR-021: Consumer-Side Inbox for Integration-Event Idempotency","k":"Architecture Decision Records","t":"Rationale","x":"- Dedup once, not in every handler. A single consume-edge check turns \"every handler author must remember to be idempotent against redelivery\" into a framework guarantee for the…","i":"NoOpInboxStore"},{"u":"/docs/adr/021-consumer-inbox-idempotency.html#trade-offs","d":"ADR-021: Consumer-Side Inbox for Integration-Event Idempotency","k":"Architecture Decision Records","t":"Trade-offs","x":"- Not exactly-once. The crash-after-handler-before-inbox window reprocesses once, so handlers must stay idempotent for it; the inbox narrows the duplicate window, it does not…","i":"InboxMessages EnableInbox MessageId"},{"u":"/docs/adr/021-consumer-inbox-idempotency.html#related","d":"ADR-021: Consumer-Side Inbox for Integration-Event Idempotency","k":"Architecture Decision Records","t":"Related","x":"ADR-003 (the outbox and at-least-once delivery whose consumer side this deduplicates; handler idempotency is still required for the crash window), ADR-006 (the inbox lives in the…","i":"OutboxCleanupService InProcess"},{"u":"/docs/adr/021-consumer-inbox-idempotency.html#revision-2026-08-18","d":"ADR-021: Consumer-Side Inbox for Integration-Event Idempotency","k":"Architecture Decision Records","t":"Revision (2026-08-18)","x":"The decision is unchanged: the inbox is still opt-in and NoOpInboxStore is still the default. What changed is that the default is now loud. 1. A broker-connected host with no…","i":"ApplicationDbContext.OnModelCreating MessageBusProvider.InProcess InboxDisabledWarningService IX_InboxMessages_MessageId IEntityTypeConfiguration base.OnModelCreating AddBrokerMessaging SQLServerDbContext AddInboxMessages CosmosDbContext SqliteDbContext ConfigureInbox"},{"u":"/docs/adr/022-browser-session-cookie-auth.html","d":"ADR-022: Browser Session-Cookie Authentication for Blazor SSR","k":"Architecture Decision Records"},{"u":"/docs/adr/022-browser-session-cookie-auth.html#status","d":"ADR-022: Browser Session-Cookie Authentication for Blazor SSR","k":"Architecture Decision Records","t":"Status","x":"Accepted."},{"u":"/docs/adr/022-browser-session-cookie-auth.html#context","d":"ADR-022: Browser Session-Cookie Authentication for Blazor SSR","k":"Architecture Decision Records","t":"Context","x":"The apps are Blazor Web Apps: a server-rendered (SSR) prerender pass runs on the first request, then an interactive phase (Blazor Server or WebAssembly) takes over.…","i":"Authorization localStorage Authorize"},{"u":"/docs/adr/022-browser-session-cookie-auth.html#decision","d":"ADR-022: Browser Session-Cookie Authentication for Blazor SSR","k":"Architecture Decision Records","t":"Decision","x":"Carry the session in HttpOnly cookies and add an authentication scheme that reads them during SSR prerender. The mechanism ships in MMCA.Common.API (SessionCookies/) with a…","i":"SessionCookieAuthenticationHandler CookieSessionRefresher mmca_auth_refresh HttpContext.User mmca_auth_access SessionCookieJar MMCA.Common.API MMCA.Common.UI Authorize DELETE POST"},{"u":"/docs/adr/022-browser-session-cookie-auth.html#rationale","d":"ADR-022: Browser Session-Cookie Authentication for Blazor SSR","k":"Architecture Decision Records","t":"Rationale","x":"- Fixes the fresh-GET prerender gap. Without a server-readable session, every deep-link or F5 to an [Authorize] page would redirect to /login despite a valid session; the cookie…","i":"Authorize"},{"u":"/docs/adr/022-browser-session-cookie-auth.html#trade-offs","d":"ADR-022: Browser Session-Cookie Authentication for Blazor SSR","k":"Architecture Decision Records","t":"Trade-offs","x":"- A non-validating auth scheme exists. SessionCookieAuthenticationHandler trusts a cookie it does not cryptographically verify. This is sound only because (a) the cookie is…","i":"SessionCookieAuthenticationHandler ISessionCookieSync"},{"u":"/docs/adr/022-browser-session-cookie-auth.html#related","d":"ADR-022: Browser Session-Cookie Authentication for Blazor SSR","k":"Architecture Decision Records","t":"Related","x":"ADR-004 (the JWT/JWKS validation the API performs on every call, which is why the SSR handler can skip signature validation), ADR-008 (the gateway and topology the UI talks to),…"},{"u":"/docs/adr/023-security-response-headers.html","d":"ADR-023: Centralized Security-Response-Headers Middleware with a Pluggable CSP","k":"Architecture Decision Records"},{"u":"/docs/adr/023-security-response-headers.html#status","d":"ADR-023: Centralized Security-Response-Headers Middleware with a Pluggable CSP","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-02)."},{"u":"/docs/adr/023-security-response-headers.html#context","d":"ADR-023: Centralized Security-Response-Headers Middleware with a Pluggable CSP","k":"Architecture Decision Records","t":"Context","x":"Every client-facing host (the YARP Gateway and the Blazor UI web host in each app) must stamp the same hardened HTTP response headers: X-Content-Type-Options, X-Frame-Options,…"},{"u":"/docs/adr/023-security-response-headers.html#decision","d":"ADR-023: Centralized Security-Response-Headers Middleware with a Pluggable CSP","k":"Architecture Decision Records","t":"Decision","x":"Ship one security-headers middleware in MMCA.Common.Aspire (MMCA.Common.Aspire.Security), registered with AddCommonSecurityHeaders(configuration?, configure?) and inserted early…","i":"SecurityHeadersSettings.ContentSecurityPolicy SecurityHeadersMiddlewareTests MMCA.Common.Aspire.Security SecurityHeadersMiddleware AddCommonSecurityHeaders MMCA.Common.Aspire.Tests UseCommonSecurityHeaders BlazorCspPolicyProvider SecurityHeadersSettings StaticCspPolicyProvider AddCommonBlazorCsp ICspPolicyProvider"},{"u":"/docs/adr/023-security-response-headers.html#rationale","d":"ADR-023: Centralized Security-Response-Headers Middleware with a Pluggable CSP","k":"Architecture Decision Records","t":"Rationale","x":"- One hardened default, defined once. Centralizing the header set removes per-host drift and makes a new edge host secure by default rather than by remembering to copy headers. -…","i":"ICspPolicyProvider"},{"u":"/docs/adr/023-security-response-headers.html#trade-offs","d":"ADR-023: Centralized Security-Response-Headers Middleware with a Pluggable CSP","k":"Architecture Decision Records","t":"Trade-offs","x":"- The baseline CSP is intentionally incomplete. An API/Gateway host gets default-src 'self'-style protection but no script-src/style-src discipline unless it registers a fuller…","i":"SecurityHeadersSettings.ContentSecurityPolicy AddCommonSecurityHeaders BlazorCspPolicyProvider AddCommonBlazorCsp ICspPolicyProvider MMCA.Common.UI.Web TryAddSingleton ApiSettings"},{"u":"/docs/adr/023-security-response-headers.html#related","d":"ADR-023: Centralized Security-Response-Headers Middleware with a Pluggable CSP","k":"Architecture Decision Records","t":"Related","x":"ADR-019 (rate limiting, the other always-on edge protection living in the same Aspire layer), ADR-022 (browser session-cookie auth, the other browser-edge security control),…"},{"u":"/docs/adr/024-push-notifications.html","d":"ADR-024: Two-Channel User Notifications (Transient SignalR Push + Durable Inbox)","k":"Architecture Decision Records"},{"u":"/docs/adr/024-push-notifications.html#status","d":"ADR-024: Two-Channel User Notifications (Transient SignalR Push + Durable Inbox)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-27, amended 2026-07-15). Revised 2026-08-07 (transactional email recorded as an app-level concern outside the channel model; see Revision below). Revised…","i":"Enabled"},{"u":"/docs/adr/024-push-notifications.html#context","d":"ADR-024: Two-Channel User Notifications (Transient SignalR Push + Durable Inbox)","k":"Architecture Decision Records","t":"Context","x":"The framework needs to deliver user-facing notifications (an organizer broadcasting a schedule change, a per-user alert). Two delivery models each fail on their own. A pure…"},{"u":"/docs/adr/024-push-notifications.html#decision","d":"ADR-024: Two-Channel User Notifications (Transient SignalR Push + Durable Inbox)","k":"Architecture Decision Records","t":"Decision","x":"Deliver notifications over two channels from one application use case, with the transport and the recipient policy both behind abstractions. - A durable per-user inbox plus a…","i":"NullNotificationRecipientProvider PushNotificationSettings.Enabled INotificationRecipientProvider SignalRPushNotificationSender SendPushNotificationHandler SignalRLiveChannelPublisher MMCA.Common.Infrastructure NullPushNotificationSender IPushNotificationSender MMCA.Common.Application CancellationToken.None NotificationHubService"},{"u":"/docs/adr/024-push-notifications.html#rationale","d":"ADR-024: Two-Channel User Notifications (Transient SignalR Push + Durable Inbox)","k":"Architecture Decision Records","t":"Rationale","x":"- Each channel covers the other's failure mode. The inbox guarantees eventual delivery to offline users; the push gives connected users immediacy. Persisting the inbox before…","i":"INotificationRecipientProvider IPushNotificationSender IMessageBus"},{"u":"/docs/adr/024-push-notifications.html#trade-offs","d":"ADR-024: Two-Channel User Notifications (Transient SignalR Push + Durable Inbox)","k":"Architecture Decision Records","t":"Trade-offs","x":"- Fan-out write amplification. One UserNotification row is written per recipient, so a broadcast to a large audience is a large insert. This is fine for the current per-event /…","i":"NullPushNotificationSender AddPushNotifications PushNotification UserNotification Authorization access_token IsRead ReadOn"},{"u":"/docs/adr/024-push-notifications.html#related","d":"ADR-024: Two-Channel User Notifications (Transient SignalR Push + Durable Inbox)","k":"Architecture Decision Records","t":"Related","x":"ADR-003 (the outbox dual-dispatch path, which is distinct: that carries service-to-service integration events, this carries user-facing notifications), ADR-004 (the /hubs…","i":"MMCA.ADC.Notification.Service SendPushNotificationHandler NullNativePushSender Http1AndHttp2 access_token Http2"},{"u":"/docs/adr/024-push-notifications.html#revision-2026-08-07","d":"ADR-024: Two-Channel User Notifications (Transient SignalR Push + Durable Inbox)","k":"Architecture Decision Records","t":"Revision (2026-08-07)","x":"Records transactional email, a delivery path the channel model above never mentions. The decision is unchanged: this closes a documentation gap so the asymmetry reads as…","i":"OrderPaymentFailedSagaHandler SendPushNotificationHandler IPushNotificationSender ILiveChannelPublisher IPushDeviceRegistrar IDomainEventHandler AddInfrastructure INativePushSender OrderPaidHandler PushNotification UserNotification SmtpEmailSender"},{"u":"/docs/adr/025-startup-warmup-readiness.html","d":"ADR-025: Startup Warm-Up and Readiness Gating for Cold-Start Mitigation","k":"Architecture Decision Records"},{"u":"/docs/adr/025-startup-warmup-readiness.html#status","d":"ADR-025: Startup Warm-Up and Readiness Gating for Cold-Start Mitigation","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-27). Revised 2026-07-28: /health/ready now excludes optional-tagged checks as well as live-tagged ones (see Decision), and the absence of a warm-up timeout was…","i":"optional live"},{"u":"/docs/adr/025-startup-warmup-readiness.html#context","d":"ADR-025: Startup Warm-Up and Readiness Gating for Cold-Start Mitigation","k":"Architecture Decision Records","t":"Context","x":"On the Azure Container Apps Consumption plan a replica that has been idle is CPU-throttled, and a scale-from-zero or scaled-out replica starts cold. The first authenticated…"},{"u":"/docs/adr/025-startup-warmup-readiness.html#decision","d":"ADR-025: Startup Warm-Up and Readiness Gating for Cold-Start Mitigation","k":"Architecture Decision Records","t":"Decision","x":"Ship a small warm-up subsystem in MMCA.Common.Aspire, wired into AddServiceDefaults() so every host gets it. - A readiness gate that starts closed. WarmupReadinessGate…","i":"OpenIdConnectMetadataWarmupTask OperationCanceledException WarmupReadinessHealthCheck MapDefaultEndpoints WarmupHostedService WarmupReadinessGate AddServiceDefaults AddWarmupReadiness IHttpClientFactory MMCA.Common.Aspire TaskTimeoutSeconds cancellationToken"},{"u":"/docs/adr/025-startup-warmup-readiness.html#rationale","d":"ADR-025: Startup Warm-Up and Readiness Gating for Cold-Start Mitigation","k":"Architecture Decision Records","t":"Rationale","x":"- Keep cold replicas out of rotation, briefly. Gating readiness on warm-up means the platform does not send a user request to a replica that is still doing its first handshakes,…","i":"AddServiceDefaults"},{"u":"/docs/adr/025-startup-warmup-readiness.html#trade-offs","d":"ADR-025: Startup Warm-Up and Readiness Gating for Cold-Start Mitigation","k":"Architecture Decision Records","t":"Trade-offs","x":"- A replica can enter rotation not fully warm. The gate is opened in a finally once the Task.WhenAll over every registered task returns, that is, once each task has completed,…","i":"ConfigurationManager TimeoutException stoppingToken Task.WhenAll WaitAsync finally"},{"u":"/docs/adr/025-startup-warmup-readiness.html#related","d":"ADR-025: Startup Warm-Up and Readiness Gating for Cold-Start Mitigation","k":"Architecture Decision Records","t":"Related","x":"ADR-004 (the OIDC discovery document the built-in task pre-fetches, and the auth-side view of the same cold-start), ADR-009 (the Polly resilience pipeline that absorbs the lazy…"},{"u":"/docs/adr/026-caching-strategy.html","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","i":"ICacheService"},{"u":"/docs/adr/026-caching-strategy.html#status","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-27, amended 2026-07-10, 2026-07-23, 2026-07-25, 2026-08-14). Amended by ADR-077 (2026-08-13): Tier 1's substrate gains a third, opt-in implementation…","i":"OutputCacheEvictionRequested MMCA.Common.OutputCache HybridCacheService ICacheService remarks INCR"},{"u":"/docs/adr/026-caching-strategy.html#context","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Context","x":"The framework needs caching in two distinct places. Inside the application pipeline, query results are memoized and invalidated on mutation (the Caching decorators of ADR-014,…","i":"ICacheInvalidating IQueryCacheable"},{"u":"/docs/adr/026-caching-strategy.html#decision","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Decision","x":"Cache in two tiers, each with its own substrate. - One abstraction. ICacheService (MMCA.Common.Application/Interfaces/ICacheService.cs) exposes GetAsync / SetAsync / RemoveAsync…","i":"builder.Services.AddStackExchangeRedisOutputCache OutputCacheOptions.AddPublicEndpointPolicy MiddlewarePipelineStepNames.OutputCache AbsoluteExpirationRelativeToNow PublicEndpointOutputCachePolicy CacheOptions.DefaultExpiration CacheOptions.DefaultDuration DistributedCacheEntryOptions DistributedCacheService IConnectionMultiplexer LoginProtectionService MemoryDistributedCache"},{"u":"/docs/adr/026-caching-strategy.html#rationale","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Rationale","x":"- One substrate, swapped by environment. Keeping ICacheService as the only thing application code sees lets the deployment decide memory vs distributed. The auto-swap (presence…","i":"ICacheInvalidating IDistributedCache ICacheService"},{"u":"/docs/adr/026-caching-strategy.html#trade-offs","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Trade-offs","x":"- Memory mode is per-replica. In the in-process store each replica caches independently; a scaled-out deployment that did not wire Redis would see cross-replica staleness bounded…","i":"ICacheService.IncrementAsync AddRedisDistributedCache DistributedCacheService StackExchangeRedisCache IConnectionMultiplexer AddOutputCache AddRedisClient RemoveAsync WRONGTYPE NoCache remarks absexp"},{"u":"/docs/adr/026-caching-strategy.html#related","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Related","x":"ADR-014 (the Caching decorators and IQueryCacheable / ICacheInvalidating markers that consume this substrate), ADR-019 (output caching as the anonymous-traffic lever, and…","i":"RegisterUpcastedIntegrationEventConsumer OutputCacheEvictionRequested LoginProtectionService HybridCacheService ICacheInvalidating IQueryCacheable IncrementAsync ICacheService WRONGTYPE TEvent"},{"u":"/docs/adr/026-caching-strategy.html#revision-2026-07-24","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Revision (2026-07-24)","x":"Three substrate corrections from a code review. 1. An optional key namespace (Cache:KeyPrefix). Services sharing one cache instance also share one keyspace, and nothing stopped…","i":"RedisCacheOptions.InstanceName ICacheService.IncrementAsync DistributedCacheService EvictionReason.Replaced KeyedSemaphoreStripe RemoveByPrefixAsync MemoryCacheService IMemoryCache InstanceName CacheKey INCR"},{"u":"/docs/adr/026-caching-strategy.html#revision-2026-07-25","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Revision (2026-07-25)","x":"An audit against the code. No behavior changed; the ADR text did. 1. The IncrementAsync entry above was wrong. It described a Redis INCR override. There is no such override, and…","i":"DistributedCacheService StackExchangeRedisCache IncrementAsync AddCaching INCR"},{"u":"/docs/adr/026-caching-strategy.html#revision-2026-07-28","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Revision (2026-07-28)","x":"Line anchors only, re-verified against the current source. No decision, no behavior, and no substantive prose changed. 1. Tier 2. Store Catalog's…","i":"AddStackExchangeRedisOutputCache DistributedCacheService MMCA.Common.API ICacheService AddCaching"},{"u":"/docs/adr/026-caching-strategy.html#revision-2026-08-01","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Revision (2026-08-01)","x":"Line anchors only, re-verified against the current source. No decision, no behavior, and no substantive prose changed. 1. AddCaching. Now at…","i":"AddStackExchangeRedisOutputCache DistributedCacheService RemoveByPrefixAsync ScanAndDeleteAsync IncrementAsync AddCaching remarks returns"},{"u":"/docs/adr/026-caching-strategy.html#revision-2026-08-07","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Revision (2026-08-07)","x":"Line anchors only, re-verified against the current source. No decision, no behavior, and no substantive prose changed. 1. AddCaching. Now at…","i":"AddStackExchangeRedisOutputCache DistributedCacheService MemoryDistributedCache TimeSpan.FromSeconds MemoryCacheService IDistributedCache MMCA.Common.API CacheOptions AddCaching"},{"u":"/docs/adr/026-caching-strategy.html#revision-2026-08-13","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Revision (2026-08-13)","x":"Tier 1 is amended by ADR-077, which is where the decision and its trade-offs are recorded. The three points that change the reading of this record: 1. A third substrate, opted…","i":"Microsoft.Extensions.Caching.Hybrid DistributedCacheService StackExchangeRedisCache AddCommonHybridCache HybridCacheService MemoryCacheService IDistributedCache IncrementAsync AddCaching WRONGTYPE prefix INCR"},{"u":"/docs/adr/026-caching-strategy.html#revision-2026-08-14","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Revision (2026-08-14)","x":"One substrate correction plus a line-anchor re-verification. No decision and no behavior changed. 1. The 30-second default now has a named home, CacheOptions.DefaultDuration.…","i":"AddStackExchangeRedisOutputCache AbsoluteExpirationRelativeToNow CacheOptions.DefaultDuration DistributedCacheEntryOptions WebApplicationExtensions.cs AddRedisDistributedCache DistributedCacheService HybridCacheEntryOptions TimeSpan.FromSeconds app.UseOutputCache HybridCacheService DefaultExpiration"},{"u":"/docs/adr/026-caching-strategy.html#revision-2026-08-18","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Revision (2026-08-18)","x":"Every previous revision moved Tier 1. This one moves Tier 2, and it is the first change to the output-cache edge since ADR-040. Tier 2 as decided here is per-process by…","i":"RegisterUpcastedIntegrationEventConsumer RegisterOutputCacheEvictionConsumer RegisterIntegrationEventConsumer AddOutputCacheEvictionHandler OutputCacheEvictionRequested OperationCanceledException OutputCacheEvictionHandler BestEffort.ExecuteAsync MMCA.Common.OutputCache MMCA.Common.BestEffort cache.eviction.failed registerFaultConsumer"},{"u":"/docs/adr/026-caching-strategy.html#revision-2026-08-23","d":"ADR-026: Two-Tier Caching: a Swappable ICacheService Substrate plus an HTTP Output-Cache Edge","k":"Architecture Decision Records","t":"Revision (2026-08-23)","x":"One correction of substance plus a line-anchor re-verification. No decision and no behavior changed. 1. The counter trade-off now names the contradiction a reader will hit.…","i":"RegisterUpcastedIntegrationEventConsumer MiddlewarePipelineStepNames.OutputCache RegisterOutputCacheEvictionConsumer BaseIntegrationEvent.SchemaVersion AddStackExchangeRedisOutputCache OutputCacheEvictionRequested DistributedCacheService app.UseOutputCache IncrementAsync UseOutputCache ICacheService AddCaching"},{"u":"/docs/adr/027-multi-locale-i18n.html","d":"ADR-027: Multi-Locale Internationalization (Supersedes ADR-011)","k":"Architecture Decision Records"},{"u":"/docs/adr/027-multi-locale-i18n.html#status","d":"ADR-027: Multi-Locale Internationalization (Supersedes ADR-011)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-27, amended 2026-07-02, 2026-07-03, 2026-07-09, and 2026-07-29; corrected 2026-08-01: the pseudo-locale CI gate is required on all three browser engines, and…"},{"u":"/docs/adr/027-multi-locale-i18n.html#context","d":"ADR-027: Multi-Locale Internationalization (Supersedes ADR-011)","k":"Architecture Decision Records","t":"Context","x":"ADR-011 recorded single-locale (en-US) as a deliberate, revisitable non-goal and sketched what re-introducing i18n would entail. That revisit has now happened: the framework adds…","i":"InteractiveAuto Error Code"},{"u":"/docs/adr/027-multi-locale-i18n.html#decision","d":"ADR-027: Multi-Locale Internationalization (Supersedes ADR-011)","k":"Architecture Decision Records","t":"Decision","x":"1. Supported cultures are an explicit allowlist: en-US (default) + es. Adding a locale is adding a .es.resx sibling set and one allowlist entry, not new infrastructure. 2.…","i":"MmcaCultureBootstrap.SetBrowserCultureAsync ErrorHttpMapping.BuildErrorsExtension DomainInvariantViolationException CultureInfo.DefaultThreadCurrent LocalizedTextConventionTestsBase MMCA.Common.Testing.Architecture SupportedCultures.ResolveClosest ApiControllerBase.HandleFailure ResourceTranslationsAreComplete SupportedCultures.PseudoLocale CookieRequestCultureProvider CultureInfo.InvariantCulture"},{"u":"/docs/adr/027-multi-locale-i18n.html#rationale","d":"ADR-027: Multi-Locale Internationalization (Supersedes ADR-011)","k":"Architecture Decision Records","t":"Rationale","x":"- Keying error localization on the existing Error.Code is the cheapest correct extension point. The codes are already stable and already cross the wire; localizing at the edge…","i":"ResourcesPath Error.Code resx"},{"u":"/docs/adr/027-multi-locale-i18n.html#trade-offs","d":"ADR-027: Multi-Locale Internationalization (Supersedes ADR-011)","k":"Architecture Decision Records","t":"Trade-offs","x":"- Every view and every user-facing message is touched: a large, mostly mechanical sweep, accepted as the cost ADR-011 always named. - WASM Spanish formatting needs ICU…","i":"InvariantGlobalization ResxMudLocalizer MudTranslations BlazorWebView MudLocalizer"},{"u":"/docs/adr/027-multi-locale-i18n.html#related","d":"ADR-027: Multi-Locale Internationalization (Supersedes ADR-011)","k":"Architecture Decision Records","t":"Related","x":"ADR-011 (superseded), ADR-013 (the Error.Code this localizes on), ADR-015 (the i18n gates now live here: the MA0076 culture-less formatting build gate and the…","i":"ResourceTranslationsAreComplete BlazorWebView Error.Code MA0076"},{"u":"/docs/adr/028-dark-theme-mode.html","d":"ADR-028: Day/Dark Theme Mode","k":"Architecture Decision Records"},{"u":"/docs/adr/028-dark-theme-mode.html#status","d":"ADR-028: Day/Dark Theme Mode","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-27; revised 2026-07-15)."},{"u":"/docs/adr/028-dark-theme-mode.html#context","d":"ADR-028: Day/Dark Theme Mode","k":"Architecture Decision Records","t":"Context","x":"MMCATheme (MMCA.Common.UI/Theme/MMCATheme.cs) has always defined a complete, brand-tuned PaletteDark alongside PaletteLight, but MudThemeProvider was hard-wired to light: no…","i":"MudThemeProvider InteractiveAuto PaletteLight PaletteDark IsDarkMode MMCATheme ref"},{"u":"/docs/adr/028-dark-theme-mode.html#decision","d":"ADR-028: Day/Dark Theme Mode","k":"Architecture Decision Records","t":"Decision","x":"1. Bind the existing theme. The shared MainLayout renders a single component (MMCA.Common.UI/Layout/MainLayout.razor:14), which owns the four Mud providers plus the Day/Dark…","i":"ThemeService.InitializeAsync User.PreferredCulture User.PreferredTheme MMCATheme.Instance MmcaThemeProviders OnAfterRenderAsync InteractiveServer systemPrefersDark window.matchMedia MudThemeProvider MMCA.Common.UI ThemeService"},{"u":"/docs/adr/028-dark-theme-mode.html#rationale","d":"ADR-028: Day/Dark Theme Mode","k":"Architecture Decision Records","t":"Rationale","x":"- Reusing the i18n cookie/profile machinery means one persistence model for both user preferences, instead of two subtly different ones. Theme and locale are the same shape of…","i":"BrandColorTokenTests"},{"u":"/docs/adr/028-dark-theme-mode.html#trade-offs","d":"ADR-028: Day/Dark Theme Mode","k":"Architecture Decision Records","t":"Trade-offs","x":"- The same FOUC hazard as locale is not yet closed for theme. The SSR data-theme/inline-script read is unimplemented (Decision 3), so the first paint can briefly flash the wrong…","i":"MainLayout User"},{"u":"/docs/adr/028-dark-theme-mode.html#related","d":"ADR-028: Day/Dark Theme Mode","k":"Architecture Decision Records","t":"Related","x":"ADR-027 (shares the cookie source-of-truth and the User preference migration, and is the model for the theme no-flash SSR bootstrap that is not yet wired), ADR-022 (the SSR…","i":"User"},{"u":"/docs/adr/029-authentication-brute-force-protection.html","d":"ADR-029: Authentication Brute-Force Protection and Registration Throttling","k":"Architecture Decision Records"},{"u":"/docs/adr/029-authentication-brute-force-protection.html#status","d":"ADR-029: Authentication Brute-Force Protection and Registration Throttling","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-27). Updated 2026-07-02 (the check/increment/reset call sequence was hoisted into AuthenticationServiceBase ; the adoption note and the \"convention the consumer…","i":"AuthenticationServiceBase TUser"},{"u":"/docs/adr/029-authentication-brute-force-protection.html#context","d":"ADR-029: Authentication Brute-Force Protection and Registration Throttling","k":"Architecture Decision Records","t":"Context","x":"ADR-019's global rate limiter is authenticated-only: it caps requests per authenticated principal and deliberately exempts anonymous traffic. The highest-value anonymous attack…","i":"RateLimitPolicyAuthIp AuthControllerBase"},{"u":"/docs/adr/029-authentication-brute-force-protection.html#decision","d":"ADR-029: Authentication Brute-Force Protection and Registration Throttling","k":"Architecture Decision Records","t":"Decision","x":"Provide a framework ILoginProtectionService (MMCA.Common.Application.Auth) with a single implementation LoginProtectionService (MMCA.Common.Infrastructure.Auth), registered…","i":"RegistrationRateLimitWindowMinutes CheckRegistrationRateLimitAsync IncrementRegistrationCountAsync MMCA.Common.Infrastructure.Auth ICacheService.IncrementAsync IncrementFailedAttemptsAsync MaxRegistrationsPerIpPerHour MMCA.Common.Application.Auth FailedAttemptWindowMinutes AuthenticationServiceBase ResetFailedAttemptsAsync DistributedCacheService"},{"u":"/docs/adr/029-authentication-brute-force-protection.html#rationale","d":"ADR-029: Authentication Brute-Force Protection and Registration Throttling","k":"Architecture Decision Records","t":"Rationale","x":"- Complements ADR-019 rather than duplicating it. ADR-019 carries two limiter layers and this is the third on top of them: its global limiter caps authenticated throughput per…","i":"Result"},{"u":"/docs/adr/029-authentication-brute-force-protection.html#trade-offs","d":"ADR-029: Authentication Brute-Force Protection and Registration Throttling","k":"Architecture Decision Records","t":"Trade-offs","x":"- Cache-scoped state weakens under scale-out without Redis. In memory mode the counters are per-replica and evaporate on restart, so a multi-replica deployment that did not wire…","i":"AuthenticationServiceBase ILoginProtectionService AuthenticationService TUser"},{"u":"/docs/adr/029-authentication-brute-force-protection.html#related","d":"ADR-029: Authentication Brute-Force Protection and Registration Throttling","k":"Architecture Decision Records","t":"Related","x":"ADR-019 (the layered limiter: an authenticated-only global cap that exempts this anonymous surface, plus the per-IP auth-ip window that now sits on the same two endpoints),…","i":"ICacheService Result Error"},{"u":"/docs/adr/030-startup-sole-migrator.html","d":"ADR-030: Each Service Self-Applies Its Migrations at Startup (Sole Migrator)","k":"Architecture Decision Records"},{"u":"/docs/adr/030-startup-sole-migrator.html#status","d":"ADR-030: Each Service Self-Applies Its Migrations at Startup (Sole Migrator)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-27)."},{"u":"/docs/adr/030-startup-sole-migrator.html#context","d":"ADR-030: Each Service Self-Applies Its Migrations at Startup (Sole Migrator)","k":"Architecture Decision Records","t":"Context","x":"Under database-per-service (ADR-006), each service owns its own database and its own migrations project, so something must apply pending migrations on every deploy. The…","i":"ApplicationSettings.DatabaseInitStrategy DatabaseInitializationExtensions EnsureCreated"},{"u":"/docs/adr/030-startup-sole-migrator.html#decision","d":"ADR-030: Each Service Self-Applies Its Migrations at Startup (Sole Migrator)","k":"Architecture Decision Records","t":"Decision","x":"In Azure Container Apps, every service host runs ApplicationSettingsDatabaseInitStrategy = Migrate in production and is the sole migrator of its own database: it applies its…","i":"ApplicationSettings__DatabaseInitStrategy __EFMigrationsHistory DatabaseInitStrategy MigrateAsync minReplicas deploy.yml migrations database Migrate dotnet sqlcmd update"},{"u":"/docs/adr/030-startup-sole-migrator.html#rationale","d":"ADR-030: Each Service Self-Applies Its Migrations at Startup (Sole Migrator)","k":"Architecture Decision Records","t":"Rationale","x":"- One migrator, one mechanism. The code that owns the schema applies the schema; there is no second tool to keep in lockstep and no ordering race between a deploy step and…","i":"__EFMigrationsHistory"},{"u":"/docs/adr/030-startup-sole-migrator.html#trade-offs","d":"ADR-030: Each Service Self-Applies Its Migrations at Startup (Sole Migrator)","k":"Architecture Decision Records","t":"Trade-offs","x":"- Auto-migrate-in-production is what \"None\" exists to prevent. An unintended or destructive migration would ship itself on the next deploy. The apps accept this; the build-time…","i":"minReplicas"},{"u":"/docs/adr/030-startup-sole-migrator.html#related","d":"ADR-030: Each Service Self-Applies Its Migrations at Startup (Sole Migrator)","k":"Architecture Decision Records","t":"Related","x":"ADR-006 (database-per-service: why each service owns and migrates its own database), ADR-025 (readiness gating keeps traffic off a still-migrating replica), ADR-009 (RTO/RPO +…"},{"u":"/docs/adr/030-startup-sole-migrator.html#revision-2026-08-07","d":"ADR-030: Each Service Self-Applies Its Migrations at Startup (Sole Migrator)","k":"Architecture Decision Records","t":"Revision (2026-08-07)","x":"The sole-migrator decision extends to seed data: the same startup owner that applies the schema also runs the module seeders, in the same call, on the same boot. The Decision…","i":"moduleLoader.SeedAllAsync ModuleLoader.SeedAllAsync ConferenceModuleDbSeeder InitializeDatabaseAsync __EFMigrationsHistory DatabaseInitStrategy builder.Build IModuleSeeder ExistsAsync DbSeeder Guid int"},{"u":"/docs/adr/031-feature-flag-management.html","d":"ADR-031: Config-Driven Feature Flags with Dual-Surface Enforcement","k":"Architecture Decision Records"},{"u":"/docs/adr/031-feature-flag-management.html#status","d":"ADR-031: Config-Driven Feature Flags with Dual-Surface Enforcement","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-27). Revised 2026-08-18 (a targeting-context accessor is now registered, so the built-in Targeting and Percentage filters give consistent per-user bucketing…"},{"u":"/docs/adr/031-feature-flag-management.html#context","d":"ADR-031: Config-Driven Feature Flags with Dual-Surface Enforcement","k":"Architecture Decision Records","t":"Context","x":"The apps need to decouple release from deploy: ship code dark, flip a kill switch, or roll a feature out to a percentage of users without a redeploy. A flag has to be enforceable…","i":"FeatureGate"},{"u":"/docs/adr/031-feature-flag-management.html#decision","d":"ADR-031: Config-Driven Feature Flags with Dual-Surface Enforcement","k":"Architecture Decision Records","t":"Decision","x":"Standardize on Microsoft.FeatureManagement, configured from the \"FeatureManagement\" configuration section and registered once in AddAPI (services.AddFeatureManagement() +…","i":"ApiControllerBase.HandleFailure Microsoft.FeatureManagement.Mvc IFeatureManager.IsEnabledAsync services.AddFeatureManagement FeatureGateCommandDecorator Microsoft.FeatureManagement FeatureGateQueryDecorator Error.NotFoundError ConferenceFeatures EngagementFeatures ErrorType.NotFound CatalogFeatures"},{"u":"/docs/adr/031-feature-flag-management.html#rationale","d":"ADR-031: Config-Driven Feature Flags with Dual-Surface Enforcement","k":"Architecture Decision Records","t":"Rationale","x":"- Release decoupled from deploy. A kill switch or a percentage rollout becomes a configuration change, not a code change: the central reason feature management exists. - Two…"},{"u":"/docs/adr/031-feature-flag-management.html#trade-offs","d":"ADR-031: Config-Driven Feature Flags with Dual-Surface Enforcement","k":"Architecture Decision Records","t":"Trade-offs","x":"- The two enforcement points must agree. A flag gated on the controller but not the handler (or vice versa) is a half-protected feature; no fitness rule asserts both are wired,…","i":"IsEnabledAsync"},{"u":"/docs/adr/031-feature-flag-management.html#revision-2026-08-18","d":"ADR-031: Config-Driven Feature Flags with Dual-Surface Enforcement","k":"Architecture Decision Records","t":"Revision (2026-08-18)","x":"Progressive rollout is now usable, because the targeting context exists. The Decision above listed the Percentage / TimeWindow / Targeting filters as \"available\", and the last…","i":"CurrentUserTargetingContextAccessor FeatureGateCommandDecorator ITargetingContextAccessor featureGated.FeatureName AddHttpContextAccessor IHttpContextAccessor ICurrentUserService ClaimTypes.Role IFeatureManager IsEnabledAsync Identity.Name WithTargeting"},{"u":"/docs/adr/031-feature-flag-management.html#related","d":"ADR-031: Config-Driven Feature Flags with Dual-Surface Enforcement","k":"Architecture Decision Records","t":"Related","x":"ADR-014 (the decorator pipeline whose outermost slot FeatureGate fills, and the ordering that puts it first, now with Authorization registered directly inside it so a disabled…","i":"FeatureGate Groups Result Error"},{"u":"/docs/adr/032-password-hashing.html","d":"ADR-032: Password Hashing (PBKDF2-HMAC-SHA512) with Legacy-Hash Backward Compatibility","k":"Architecture Decision Records"},{"u":"/docs/adr/032-password-hashing.html#status","d":"ADR-032: Password Hashing (PBKDF2-HMAC-SHA512) with Legacy-Hash Backward Compatibility","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-29, adoption note revised 2026-07-06, registration note revised 2026-08-01, call-site hoist recorded 2026-08-23)."},{"u":"/docs/adr/032-password-hashing.html#context","d":"ADR-032: Password Hashing (PBKDF2-HMAC-SHA512) with Legacy-Hash Backward Compatibility","k":"Architecture Decision Records","t":"Context","x":"Identity stores a credential as a (salt, hash) pair, never plaintext. The framework needs one canonical hasher that every consuming Identity flow shares, so the key-derivation…"},{"u":"/docs/adr/032-password-hashing.html#decision","d":"ADR-032: Password Hashing (PBKDF2-HMAC-SHA512) with Legacy-Hash Backward Compatibility","k":"Architecture Decision Records","t":"Decision","x":"Provide a single IPasswordHasher (MMCA.Common.Application.Interfaces.Infrastructure, IPasswordHasher.cs:6) with one implementation PasswordHasher…","i":"MMCA.Common.Application.Interfaces.Infrastructure CryptographicOperations.FixedTimeEquals RandomNumberGenerator.GetBytes IdentityModuleDbSeederBase AuthenticationServiceBase ChangePasswordHandlerBase Rfc2898DeriveBytes.Pbkdf2 HashAlgorithmName.SHA512 IdentityModuleDbSeeder AuthenticationService ChangePasswordHandler LegacyHmacSaltSize"},{"u":"/docs/adr/032-password-hashing.html#rationale","d":"ADR-032: Password Hashing (PBKDF2-HMAC-SHA512) with Legacy-Hash Backward Compatibility","k":"Architecture Decision Records","t":"Rationale","x":"- One framework-owned primitive, not per-app crypto. Putting the algorithm, work factor, salt size, and comparison in a single shared type means a future hardening (raising…","i":"IsLegacy"},{"u":"/docs/adr/032-password-hashing.html#trade-offs","d":"ADR-032: Password Hashing (PBKDF2-HMAC-SHA512) with Legacy-Hash Backward Compatibility","k":"Architecture Decision Records","t":"Trade-offs","x":"- The legacy branch is a permanent correctness dependency that looks deletable. Its load-bearing role is invisible from the method body alone, so it is the single most…","i":"VerifyPassword Iterations"},{"u":"/docs/adr/032-password-hashing.html#related","d":"ADR-032: Password Hashing (PBKDF2-HMAC-SHA512) with Legacy-Hash Backward Compatibility","k":"Architecture Decision Records","t":"Related","x":"ADR-004 (cross-service JWT / JWKS authentication: the hasher gates credential verification that issues the tokens that ADR-004 then validates across services), ADR-005…","i":"EncryptedStringConverter"},{"u":"/docs/adr/032-password-hashing.html#revision-2026-08-23","d":"ADR-032: Password Hashing (PBKDF2-HMAC-SHA512) with Legacy-Hash Backward Compatibility","k":"Architecture Decision Records","t":"Revision (2026-08-23)","x":"The decision is unchanged: one framework-owned IPasswordHasher, PBKDF2-HMAC-SHA512 for new passwords, salt-length dispatch on verification. What changed is where the last two…","i":"IdentityModuleDbSeederBase ChangePasswordHandlerBase IdentityModuleDbSeeder AuthenticationService ChangePasswordHandler IPasswordHasher ChangePassword VerifyPassword HashPassword HandlerName CreateUser Accounts"},{"u":"/docs/adr/033-resource-ownership-authorization.html","d":"ADR-033: Resource-Ownership Authorization (Row-Level + Action Filter)","k":"Architecture Decision Records"},{"u":"/docs/adr/033-resource-ownership-authorization.html#status","d":"ADR-033: Resource-Ownership Authorization (Row-Level + Action Filter)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-02, revised 2026-07-25)."},{"u":"/docs/adr/033-resource-ownership-authorization.html#context","d":"ADR-033: Resource-Ownership Authorization (Row-Level + Action Filter)","k":"Architecture Decision Records","t":"Context","x":"ADR-020 added a permission (capability) layer over RBAC: it answers \"what may this role do\", resolving a role to a permission so an endpoint can require a capability instead of a…","i":"OwnerOrAdminFilter GET"},{"u":"/docs/adr/033-resource-ownership-authorization.html#decision","d":"ADR-033: Resource-Ownership Authorization (Row-Level + Action Filter)","k":"Architecture Decision Records","t":"Decision","x":"Provide a row/resource-level ownership axis in MMCA.Common.API (the Authorization folder), with two enforcement points keyed on the caller's owner claim (customerid by default)…","i":"ShoppingCartsController.GetAllForLookupAsync ShoppingCartByCustomerSpecification ShoppingCartsController.GetAllAsync AggregateRootEntityControllerBase CustomersController.CreateAsync CustomersController.GetAllAsync OrdersByCustomerSpecification GetOwnershipSpecification OwnerOrAdminFilterOptions ICurrentUserService.Role OwnershipHelper.IsAdmin settings.OwnerClaimType"},{"u":"/docs/adr/033-resource-ownership-authorization.html#rationale","d":"ADR-033: Resource-Ownership Authorization (Row-Level + Action Filter)","k":"Architecture Decision Records","t":"Rationale","x":"- Reject-one and filter-many are genuinely two mechanisms. A single-resource route has an id to compare, so a short action filter that 403s on a mismatch is the cheapest correct…","i":"IEntityQueryService Specification Criteria TEntity And TId"},{"u":"/docs/adr/033-resource-ownership-authorization.html#trade-offs","d":"ADR-033: Resource-Ownership Authorization (Row-Level + Action Filter)","k":"Architecture Decision Records","t":"Trade-offs","x":"- Opt-in per controller/handler. Neither point is automatic: a controller that forgets the [ServiceFilter] or omits the ownership spec from a query leaks across customers, the…","i":"OwnerOrAdminFilter ServiceFilter customer_id null"},{"u":"/docs/adr/033-resource-ownership-authorization.html#related","d":"ADR-033: Resource-Ownership Authorization (Row-Level + Action Filter)","k":"Architecture Decision Records","t":"Related","x":"ADR-020 (the role/permission RBAC layer this complements, and whose explicit 020-permission-based-authorization.md:78 scope-out this fills), ADR-034 (the generic entity query…","i":"IEntityQueryService Specification ForbidResult Result"},{"u":"/docs/adr/033-resource-ownership-authorization.html#revision-2026-07-25","d":"ADR-033: Resource-Ownership Authorization (Row-Level + Action Filter)","k":"Architecture Decision Records","t":"Revision (2026-07-25)","x":"An audit against the code. No behavior changed; the ADR text did. 1. The per-mutation check's failure shape was described as one branch, and it is two. ValidateOwnershipAsync was…","i":"ICurrentUserService.Role ValidateOwnershipAsync OwnerOrAdminFilter AllowMissingOwner OrdersController Error.Forbidden"},{"u":"/docs/adr/033-resource-ownership-authorization.html#revision-2026-08-01","d":"ADR-033: Resource-Ownership Authorization (Row-Level + Action Filter)","k":"Architecture Decision Records","t":"Revision (2026-08-01)","x":"Anchor-only correction. No behavior changed; OrdersController was refactored (a constructor parameter added, GetOwnershipSpecification() and the IsAdmin property extracted,…","i":"GetOwnershipSpecification ValidateOwnershipAsync OrdersController Error.Forbidden Error.NotFound IsAdmin"},{"u":"/docs/adr/034-generic-entity-query-layer.html","d":"ADR-034: Generic Entity Controllers with a Dynamic Query Contract","k":"Architecture Decision Records"},{"u":"/docs/adr/034-generic-entity-query-layer.html#status","d":"ADR-034: Generic Entity Controllers with a Dynamic Query Contract","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-06-30). Amended (2026-07-23): the filter strategy registry now also covers long/long? via LongFilterStrategy. Amended (2026-07-25): the keyed by-id fast path is…","i":"TryGetFastPathIncludes LongFilterStrategy long"},{"u":"/docs/adr/034-generic-entity-query-layer.html#context","d":"ADR-034: Generic Entity Controllers with a Dynamic Query Contract","k":"Architecture Decision Records","t":"Context","x":"Every module exposes many entities, and most of them need the same read and write surface: list, page, look up for a dropdown, fetch by id, create, delete. Hand writing a…"},{"u":"/docs/adr/034-generic-entity-query-layer.html#decision","d":"ADR-034: Generic Entity Controllers with a Dynamic Query Contract","k":"Architecture Decision Records","t":"Decision","x":"Give every entity a generic REST resource surface and a bounded dynamic query contract, supplied by two controller bases over a shared query pipeline. 1. Generic read controller.…","i":"EntityQueryPipeline.MaxUnboundedResultLimit QueryFieldService.ApplyFieldSelection QueryFilterService.RegisterStrategy IApplicationSettings.MaxPageSize QueryFilterService.ApplyFilters QueryFieldService.ApplySorting MaxUnboundedResultLimit QueryFilterModelBinder INavigationPopulator EntityQueryPipeline SupportedOperators IFilterStrategy"},{"u":"/docs/adr/034-generic-entity-query-layer.html#rationale","d":"ADR-034: Generic Entity Controllers with a Dynamic Query Contract","k":"Architecture Decision Records","t":"Rationale","x":"- Write the resource surface once, inherit it everywhere. The verbs, routes, filter contract, pagination, and header are defined once on the two bases; a concrete controller…","i":"QueryFilterService.ValidateFilters MaxUnboundedResultLimit DTOToEntityPropertyMap INavigationPopulator DTOMapper.MapToDTOs SupportedOperators IEntityDTOMapper IFilterStrategy MaxPageSize"},{"u":"/docs/adr/034-generic-entity-query-layer.html#trade-offs","d":"ADR-034: Generic Entity Controllers with a Dynamic Query Contract","k":"Architecture Decision Records","t":"Trade-offs","x":"- The wire contract tracks the entity model. Filterable, sortable, and projectable surface is the entity's property set. A model change is an API change unless mediated by the…","i":"QueryFilterService.ValidateFilters MaxUnboundedResultLimit DTOToEntityPropertyMap IFilterStrategy virtual"},{"u":"/docs/adr/034-generic-entity-query-layer.html#related","d":"ADR-034: Generic Entity Controllers with a Dynamic Query Contract","k":"Architecture Decision Records","t":"Related","x":"ADR-001 (manual DTO mapping: the generic controllers project through IEntityDTOMapper), ADR-002 (navigation populators: the unsupported-include path), ADR-013 (Result pattern at…","i":"IEntityDTOMapper HandleFailure result.Errors Idempotent"},{"u":"/docs/adr/034-generic-entity-query-layer.html#revision-2026-07-24","d":"ADR-034: Generic Entity Controllers with a Dynamic Query Contract","k":"Architecture Decision Records","t":"Revision (2026-07-24)","x":"Filter and pagination corrections from a code review. The contract shape is unchanged; these close cases where the engine widened a result set instead of narrowing it, or…","i":"IFilterStrategy.CanParseValue PaginationMetadata.PageSize MaxUnboundedResultLimit DTOToEntityPropertyMap Filter.Value.Invalid ValidateFilters FirstOrDefault TotalItemCount ApplyFilters GetByIdAsync int.MaxValue includeFKs"},{"u":"/docs/adr/034-generic-entity-query-layer.html#revision-2026-07-25","d":"ADR-034: Generic Entity Controllers with a Dynamic Query Contract","k":"Architecture Decision Records","t":"Revision (2026-07-25)","x":"Citation maintenance from an ADR audit. No decision or behavior changed; the source anchors above were rebased to their current declaration and call-site lines. 1. The fast-path…","i":"IsPrimaryKeyOnlyLookup TryGetFastPathIncludes"},{"u":"/docs/adr/035-optimistic-concurrency.html","d":"ADR-035: Optimistic Concurrency via RowVersion Round-Trip","k":"Architecture Decision Records"},{"u":"/docs/adr/035-optimistic-concurrency.html#status","d":"ADR-035: Optimistic Concurrency via RowVersion Round-Trip","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-02). Amended 2026-07-16: a child-entity overload of SetOriginalRowVersion was added (see Decision). Revised 2026-08-18: the same token gains an HTTP-native…","i":"SetOriginalRowVersion IConcurrencyAware SupportsIfMatch GetByIdAsync RowVersion ETag"},{"u":"/docs/adr/035-optimistic-concurrency.html#context","d":"ADR-035: Optimistic Concurrency via RowVersion Round-Trip","k":"Architecture Decision Records","t":"Context","x":"Every mutable aggregate in the framework is edited through a load-modify-save handler: the update use case fetches the tracked entity, applies the request, and calls…","i":"SaveChangesAsync Conflict"},{"u":"/docs/adr/035-optimistic-concurrency.html#decision","d":"ADR-035: Optimistic Concurrency via RowVersion Round-Trip","k":"Architecture Decision Records","t":"Decision","x":"Give every auditable entity a database-managed RowVersion concurrency token, round-trip it through the client on updates, and stamp the client's last-seen value as EF's original…","i":"MMCA.Common.Domain.Interfaces.IRowVersioned IWriteRepository.SetOriginalRowVersion ConcurrencyConventionTestsBase MMCA.Store.Architecture.Tests DbUpdateConcurrencyException MMCA.ADC.Architecture.Tests AddRowVersionToAllEntities ConfigureConcurrencyTokens DbUpdateExceptionHandler SetOriginalRowVersion AuditableBaseEntity ErrorType.Conflict"},{"u":"/docs/adr/035-optimistic-concurrency.html#rationale","d":"ADR-035: Optimistic Concurrency via RowVersion Round-Trip","k":"Architecture Decision Records","t":"Rationale","x":"- Database-managed token over a hand-maintained version field. A SQL Server rowversion auto-increments on the server on every write; no domain code sets or reads it (the setter…","i":"DbUpdateExceptionHandler SetOriginalRowVersion DbUpdateException rowversion WHERE"},{"u":"/docs/adr/035-optimistic-concurrency.html#trade-offs","d":"ADR-035: Optimistic Concurrency via RowVersion Round-Trip","k":"Architecture Decision Records","t":"Trade-offs","x":"- Opt-in at the caller, not just the type. A null or empty RowVersion skips the check, so a client that never echoes the token still gets last-write-wins. The fitness function…","i":"AddRowVersionToAllEntities DbUpdateExceptionHandler IsConcurrencyToken DbUpdateException UpdateRequest rowversion RowVersion byte"},{"u":"/docs/adr/035-optimistic-concurrency.html#revision-2026-08-18","d":"ADR-035: Optimistic Concurrency via RowVersion Round-Trip","k":"Architecture Decision Records","t":"Revision (2026-08-18)","x":"This record chose a body round-trip: the client echoes RowVersion on the update request. HTTP has had a standard way to say the same thing since long before this framework, ETag…","i":"RewriteConflictToPreconditionFailed EntityControllerBase.GetByIdAsync UpdateRequestsAreConcurrencyAware DbUpdateConcurrencyException DbUpdateExceptionHandler SupportsIfMatchAttribute ServiceFilterAttribute SetOriginalRowVersion IAsyncActionFilter SetConcurrencyETag HttpContext.Items IConcurrencyAware"},{"u":"/docs/adr/035-optimistic-concurrency.html#related","d":"ADR-035: Optimistic Concurrency via RowVersion Round-Trip","k":"Architecture Decision Records","t":"Related","x":"ADR-017 (HTTP request idempotency, which dedups retries of the same request, the mirror-image concern to two distinct edits racing here, and whose own 2026-08-18 revision adds…","i":"AuditableBaseEntity GetByIdAsync RowVersion"},{"u":"/docs/adr/036-external-oauth-login.html","d":"ADR-036: External OAuth Login (Federated Google/GitHub) with Local-JWT Exchange","k":"Architecture Decision Records"},{"u":"/docs/adr/036-external-oauth-login.html#status","d":"ADR-036: External OAuth Login (Federated Google/GitHub) with Local-JWT Exchange","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-02, migration attribution corrected 2026-07-06, native-callback redirect branch added 2026-07-17 per ADR-043, email-verified account-takeover guard before…"},{"u":"/docs/adr/036-external-oauth-login.html#context","d":"ADR-036: External OAuth Login (Federated Google/GitHub) with Local-JWT Exchange","k":"Architecture Decision Records","t":"Context","x":"The framework's Identity story so far is entirely first-party: a user registers with an email and password, the credentials are hashed (ADR-032), and Identity mints its own RS256…","i":"AddPermissions User"},{"u":"/docs/adr/036-external-oauth-login.html#decision","d":"ADR-036: External OAuth Login (Federated Google/GitHub) with Local-JWT Exchange","k":"Architecture Decision Records","t":"Decision","x":"Add an opt-in external-login path that federates Google/GitHub sign-in at the edge and immediately exchanges the external identity for the app's own local JWT pair, linking the…","i":"IAuthenticationService.ExternalLoginAsync OAuthControllerBase.CompleteAsync AddExternalLoginProviderFields Auth.ExternalLoginNotSupported Auth.ExternalEmailNotVerified ConfigurationOAuthUISettings Auth.ExternalEmailInvalid User.LinkExternalProvider AddExternalAuthProviders AddCommonAuthentication AuthenticationResponse IAuthenticationService"},{"u":"/docs/adr/036-external-oauth-login.html#rationale","d":"ADR-036: External OAuth Login (Federated Google/GitHub) with Local-JWT Exchange","k":"Architecture Decision Records","t":"Rationale","x":"- Terminate federation at the edge, keep one internal identity. Exchanging the external principal for a local JWT the moment the callback returns means every downstream concern…","i":"ExternalLoginAsync ClientId POST User GET"},{"u":"/docs/adr/036-external-oauth-login.html#trade-offs","d":"ADR-036: External OAuth Login (Federated Google/GitHub) with Local-JWT Exchange","k":"Architecture Decision Records","t":"Trade-offs","x":"- Opt-in per app, and easy to half-wire. The flow needs four cooperating pieces (scheme registration, the controller subclass, the service override, and the OAuthUIBaseUrl…","i":"Auth.ExternalLoginNotSupported Auth.ExternalEmailNotVerified IExternalLoginEmailVerifier OAuth__UIBaseUrl IsExternalLogin email_verified ExternalLogin LoginProvider ProviderKey ClientId User"},{"u":"/docs/adr/036-external-oauth-login.html#related","d":"ADR-036: External OAuth Login (Federated Google/GitHub) with Local-JWT Exchange","k":"Architecture Decision Records","t":"Related","x":"ADR-004 (the RS256/JWKS token this flow exchanges the external identity for, and validates everywhere after), ADR-022 (the browser cookies that carry the resulting session),…","i":"User.Anonymize CompleteAsync"},{"u":"/docs/adr/037-field-level-encryption-at-rest.html","d":"ADR-037: Field-Level Encryption at Rest (AES-256-GCM EF Converter)","k":"Architecture Decision Records"},{"u":"/docs/adr/037-field-level-encryption-at-rest.html#status","d":"ADR-037: Field-Level Encryption at Rest (AES-256-GCM EF Converter)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-06; revised 2026-07-24, 2026-07-25, 2026-08-15, 2026-08-18). Revised 2026-08-18: the versioned-envelope converter is no longer unpublished, it is included in…"},{"u":"/docs/adr/037-field-level-encryption-at-rest.html#context","d":"ADR-037: Field-Level Encryption at Rest (AES-256-GCM EF Converter)","k":"Architecture Decision Records","t":"Context","x":"Transparent database encryption (TDE) protects the data files as a whole, but it decrypts transparently for anyone who can query the database, so a leaked backup restored on a…"},{"u":"/docs/adr/037-field-level-encryption-at-rest.html#decision","d":"ADR-037: Field-Level Encryption at Rest (AES-256-GCM EF Converter)","k":"Architecture Decision Records","t":"Decision","x":"Provide a single framework-owned EF Core value converter that transparently encrypts string columns at rest with authenticated encryption, applied per property in an entity…","i":"MMCA.Common.Infrastructure.Persistence.Encryption ArgumentNullException.ThrowIfNull RandomNumberGenerator.GetBytes EncryptedStringConverterTests MMCA.Common.Infrastructure EncryptedStringConverter CryptographicException IReadOnlyDictionary ArgumentException FromBase64String FrozenDictionary AesGcm.Decrypt"},{"u":"/docs/adr/037-field-level-encryption-at-rest.html#rationale","d":"ADR-037: Field-Level Encryption at Rest (AES-256-GCM EF Converter)","k":"Architecture Decision Records","t":"Rationale","x":"- Authenticated, not merely confidential. AES-GCM binds a 128-bit tag to the ciphertext (EncryptedStringConverter.cs:81, :201), so a tampered or truncated value fails to decrypt…","i":"AesGcm.Decrypt string"},{"u":"/docs/adr/037-field-level-encryption-at-rest.html#trade-offs","d":"ADR-037: Field-Level Encryption at Rest (AES-256-GCM EF Converter)","k":"Architecture Decision Records","t":"Trade-offs","x":"- Latent today, proven by tests rather than production. The plumbing is complete and unit-tested, but no entity configuration wires it, so the encrypt/decrypt round-trip, the…","i":"EncryptedStringConverterTests CryptographicException HasConversion byte"},{"u":"/docs/adr/037-field-level-encryption-at-rest.html#related","d":"ADR-037: Field-Level Encryption at Rest (AES-256-GCM EF Converter)","k":"Architecture Decision Records","t":"Related","x":"ADR-005 (soft-delete vs erasure: the other sensitive-data control, which names this converter as the mechanism for erasure fields that must stay retrievable,…"},{"u":"/docs/adr/037-field-level-encryption-at-rest.html#revision-2026-07-24","d":"ADR-037: Field-Level Encryption at Rest (AES-256-GCM EF Converter)","k":"Architecture Decision Records","t":"Revision (2026-07-24)","x":"Documented a constraint the converter always had but did not state: the ciphertext is non-deterministic. Every write uses a fresh random nonce, which is the correct property for…","i":"Email Where"},{"u":"/docs/adr/037-field-level-encryption-at-rest.html#revision-2026-07-25","d":"ADR-037: Field-Level Encryption at Rest (AES-256-GCM EF Converter)","k":"Architecture Decision Records","t":"Revision (2026-07-25)","x":"Documentation-only correction, no behavior change. Item 1 of the Decision still illustrated the converter with builder.Property(e = e.Email), contradicting the 2026-07-24…","i":"EncryptedStringConverter.cs SocialSecurityNumber builder.Property e.Email"},{"u":"/docs/adr/037-field-level-encryption-at-rest.html#revision-2026-08-15","d":"ADR-037: Field-Level Encryption at Rest (AES-256-GCM EF Converter)","k":"Architecture Decision Records","t":"Revision (2026-08-15)","x":"Behavior change, not a documentation correction. The stored layout is now a versioned envelope: Base64 of [key version (1)] [nonce (12)] [ciphertext (N)] [tag (16)] rather than…","i":"SaveChanges ciphertext DbContext version nonce main key tag"},{"u":"/docs/adr/038-supply-chain-provenance.html","d":"ADR-038: Supply-Chain Provenance (SBOM Release Gate + Lock Files + Vulnerability Audit)","k":"Architecture Decision Records"},{"u":"/docs/adr/038-supply-chain-provenance.html#status","d":"ADR-038: Supply-Chain Provenance (SBOM Release Gate + Lock Files + Vulnerability Audit)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-06; revised 2026-07-21)."},{"u":"/docs/adr/038-supply-chain-provenance.html#context","d":"ADR-038: Supply-Chain Provenance (SBOM Release Gate + Lock Files + Vulnerability Audit)","k":"Architecture Decision Records","t":"Context","x":"MMCA.Common is a published framework: it packs its NuGet packages and pushes them to GitHub Packages on every v tag (release.yml:3-5), where the two production apps and the…","i":"Directory.Build.props nuget.config"},{"u":"/docs/adr/038-supply-chain-provenance.html#decision","d":"ADR-038: Supply-Chain Provenance (SBOM Release Gate + Lock Files + Vulnerability Audit)","k":"Architecture Decision Records","t":"Decision","x":"Treat supply-chain integrity as a set of build-gating controls, the same invariant-over-discipline posture ADR-015 applies to architecture rules. Four controls, each a hard gate:…","i":"SQLitePCLRaw.bundle_e_sqlite3 RestorePackagesWithLockFile MMCA.Common.Infrastructure Directory.Packages.props Directory.Build.props TreatWarningsAsErrors packageSourceMapping NuGetAuditSuppress packages.lock.json MMCA.Common.slnx nuget.config NuGetAudit"},{"u":"/docs/adr/038-supply-chain-provenance.html#rationale","d":"ADR-038: Supply-Chain Provenance (SBOM Release Gate + Lock Files + Vulnerability Audit)","k":"Architecture Decision Records","t":"Rationale","x":"- Provenance is a gate, not a document. A hard-failing SBOM step means the bill of materials cannot silently go missing on a release: the artifact is produced or the release…","i":"Directory.Build.props NuGetAuditSuppress dotnet list"},{"u":"/docs/adr/038-supply-chain-provenance.html#trade-offs","d":"ADR-038: Supply-Chain Provenance (SBOM Release Gate + Lock Files + Vulnerability Audit)","k":"Architecture Decision Records","t":"Trade-offs","x":"- The SBOM is generated and archived, not yet signed or attested. The gate proves a bill of materials exists for each release (release.yml:58); it does not add cryptographic…","i":"NuGetAuditSuppress nuget.config dotnet list"},{"u":"/docs/adr/038-supply-chain-provenance.html#related","d":"ADR-038: Supply-Chain Provenance (SBOM Release Gate + Lock Files + Vulnerability Audit)","k":"Architecture Decision Records","t":"Related","x":"ADR-016 (lockstep versioning + the MassTransit-v8 license pin; this record extends dependency governance from versioning and licensing into supply-chain provenance and…"},{"u":"/docs/adr/039-live-channel-push.html","d":"ADR-039: Live channel push (ephemeral events over the notification hub)","k":"Architecture Decision Records"},{"u":"/docs/adr/039-live-channel-push.html#status","d":"ADR-039: Live channel push (ephemeral events over the notification hub)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-09)."},{"u":"/docs/adr/039-live-channel-push.html#context","d":"ADR-039: Live channel push (ephemeral events over the notification hub)","k":"Architecture Decision Records","t":"Context","x":"Conference-day features (live polls, session Q&A, live result counters) need sub-second fan-out of small events to whoever is looking at a page right now. The existing…"},{"u":"/docs/adr/039-live-channel-push.html#decision","d":"ADR-039: Live channel push (ephemeral events over the notification hub)","k":"Architecture Decision Records","t":"Decision","x":"One realtime transport, two publisher boundaries: - NotificationHub stays the single hub and gains its first client-invokable methods: JoinChannel / LeaveChannel map the calling…","i":"PushNotificationSettings.ChannelKeyPattern SignalRLiveChannelPublisher NullLiveChannelPublisher IPushNotificationSender NotificationHubService ILiveChannelPublisher AddPushNotifications ReceiveChannelEvent LeaveChannelAsync JoinChannelAsync NotificationHub OnChannelEvent"},{"u":"/docs/adr/039-live-channel-push.html#rationale","d":"ADR-039: Live channel push (ephemeral events over the notification hub)","k":"Architecture Decision Records","t":"Rationale","x":"- One WebSocket per client keeps connection management, token refresh, reconnect, and backplane behavior in one place; channel membership is a property of the existing…","i":"IMessageBus"},{"u":"/docs/adr/039-live-channel-push.html#trade-offs","d":"ADR-039: Live channel push (ephemeral events over the notification hub)","k":"Architecture Decision Records","t":"Trade-offs","x":"- Ephemeral means lossy: a client that connects after an event was published never sees it. Features must treat channel events as cache-invalidation hints over fetchable state,…","i":"NotificationCallback"},{"u":"/docs/adr/039-live-channel-push.html#revision-2026-07-24","d":"ADR-039: Live channel push (ephemeral events over the notification hub)","k":"Architecture Decision Records","t":"Revision (2026-07-24)","x":"Two corrections from a code review; the best-effort, per-session-ordered decision is unchanged. 1. Broadcasts are enqueued after commit, not during the command. CastVoteHandler…","i":"BoundedChannelFullMode.DropOldest SessionQuestionUpvoteChanged LivePollVoteChanged ToggleUpvoteHandler CastVoteHandler DroppedCount itemDropped TryWrite"},{"u":"/docs/adr/040-authenticated-output-caching-for-public-reads.html","d":"ADR-040: Authenticated output caching for public reads","k":"Architecture Decision Records"},{"u":"/docs/adr/040-authenticated-output-caching-for-public-reads.html#status","d":"ADR-040: Authenticated output caching for public reads","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-10). Amended (2026-07-10): explicit query-string variance parity with the built-in default policy (the initial release accidentally dropped it, collapsing every…","i":"OutputCacheEvictionRequested ContentEditor SponsorsCache NowNextCache bypassRoles Organizer"},{"u":"/docs/adr/040-authenticated-output-caching-for-public-reads.html#context","d":"ADR-040: Authenticated output caching for public reads","k":"Architecture Decision Records","t":"Context","x":"The framework's read-scaling design leans on ASP.NET Core output caching: anonymous-readable endpoints ([AllowAnonymous] GETs like event/session/speaker catalogs) carry named…","i":"AuthDelegatingHandler BookmarkCountsCache AllowAnonymous Authorization NowNextCache"},{"u":"/docs/adr/040-authenticated-output-caching-for-public-reads.html#decision","d":"ADR-040: Authenticated output caching for public reads","k":"Architecture Decision Records","t":"Decision","x":"MMCA.Common.API ships PublicEndpointOutputCachePolicy, an IOutputCachePolicy that mirrors the built-in default policy with one deliberate difference: it does not disable cache…","i":"ConferenceReadAudience.PrivilegedRoles PublicEndpointOutputCachePolicy DbUpdateConcurrencyException IOutputCachePolicy MMCA.Common.API AllowAnonymous Authorization ContentEditor NowNextCache extension Organizer reference"},{"u":"/docs/adr/040-authenticated-output-caching-for-public-reads.html#rationale","d":"ADR-040: Authenticated output caching for public reads","k":"Architecture Decision Records","t":"Rationale","x":"- The response payload, not the request's auth state, is what determines cacheability. For a user-independent payload, Authorization is noise; refusing to cache on it turns the…","i":"Authorization"},{"u":"/docs/adr/040-authenticated-output-caching-for-public-reads.html#trade-offs","d":"ADR-040: Authenticated output caching for public reads","k":"Architecture Decision Records","t":"Trade-offs","x":"- Consumers must audit which named policies move to AddPublicEndpointPolicy. Policies on permission-gated endpoints (e.g. an organizer dashboard) must NOT move; if such an…","i":"UserSessionBookmarkCacheEvictionHandler RegisterOutputCacheEvictionConsumer AddStackExchangeRedisOutputCache AddOutputCacheEvictionHandler OutputCacheEvictionRequested AddRedisDistributedCache AddPublicEndpointPolicy BookmarkCountsCache IDistributedCache EvictByTagAsync AddOutputCache NowNextCache"},{"u":"/docs/adr/041-observability-and-telemetry.html","d":"ADR-041: Observability and Telemetry Strategy","k":"Architecture Decision Records"},{"u":"/docs/adr/041-observability-and-telemetry.html#status","d":"ADR-041: Observability and Telemetry Strategy","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-10). Amended (2026-07-23) to document the Telemetry:DisableHttpClientMetrics and Telemetry:DisableRuntimeMetrics cost knobs and to correct the…","i":"MMCA.Common.OutputCache MMCA.Common.BestEffort OutboxProcessor RecordDuration OutboxMetrics OutboxProcess HttpClient finally reason"},{"u":"/docs/adr/041-observability-and-telemetry.html#context","d":"ADR-041: Observability and Telemetry Strategy","k":"Architecture Decision Records","t":"Context","x":"The framework is a modular monolith whose modules extract into standalone services (ADR-008), so the same telemetry has to make sense whether a request stays in one process or…","i":"HttpClient"},{"u":"/docs/adr/041-observability-and-telemetry.html#decision","d":"ADR-041: Observability and Telemetry Strategy","k":"Architecture Decision Records","t":"Decision","x":"Standardize telemetry in the shared Aspire service defaults, add framework-specific instrumentation for the CQRS and outbox paths, and expose cost knobs with fail-safe defaults.…","i":"APPLICATIONINSIGHTS_CONNECTION_STRING CqrsMetrics.CommandDuration.Record HttpContext.TraceIdentifier OTEL_EXPORTER_OTLP_ENDPOINT AddOpenTelemetryExporters IsInstrumentationDisabled OutboxPollFilterProcessor outbox.dead_letter.count TraceIdRatioBasedSampler CorrelationIdMiddleware ConfigureOpenTelemetry TryGetTraceSampleRatio"},{"u":"/docs/adr/041-observability-and-telemetry.html#rationale","d":"ADR-041: Observability and Telemetry Strategy","k":"Architecture Decision Records","t":"Rationale","x":"- Instrument only where auto-instrumentation is blind. The CQRS RED histograms and the outbox dead-letter counter cover the two framework-owned hot paths; everything else (HTTP,…","i":"ParentBased HttpClient true"},{"u":"/docs/adr/041-observability-and-telemetry.html#trade-offs","d":"ADR-041: Observability and Telemetry Strategy","k":"Architecture Decision Records","t":"Trade-offs","x":"- Custom instrumentation carries a maintenance cost. The Aspire package has no reference to Application or Infrastructure by design, so the meter and activity-source names are…","i":"OutboxProcess ParentBased"},{"u":"/docs/adr/041-observability-and-telemetry.html#revision-2026-08-18","d":"ADR-041: Observability and Telemetry Strategy","k":"Architecture Decision Records","t":"Revision (2026-08-18)","x":"Two meters and one hop. Two new failure counters, each on its own meter. cache.eviction.failed, tagged cachetag, on MMCA.Common.OutputCache…","i":"GatewayCorrelationMiddleware besteffort.dispatch.failed CorrelationIdMiddleware MMCA.Common.Idempotency MMCA.Common.OutputCache MMCA.Common.BestEffort cache.eviction.failed MMCA.Common.Scheduler MMCA.Common.Broker MMCA.Common.Outbox MMCA.Common.Cqrs Idempotency"},{"u":"/docs/adr/041-observability-and-telemetry.html#related","d":"ADR-041: Observability and Telemetry Strategy","k":"Architecture Decision Records","t":"Related","x":"ADR-003 (the outbox whose dead-letter counter and poll-span filtering this defines), ADR-014 (the CQRS decorator pipeline that emits the RED histograms as a byproduct of its…","i":"AddServiceDefaults"},{"u":"/docs/adr/042-device-capability-abstraction.html","d":"ADR-042: Device Capability Abstraction (MAUI Blazor Hybrid)","k":"Architecture Decision Records"},{"u":"/docs/adr/042-device-capability-abstraction.html#status","d":"ADR-042: Device Capability Abstraction (MAUI Blazor Hybrid)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-10, amended 2026-07-17, 2026-07-23 and 2026-08-14)."},{"u":"/docs/adr/042-device-capability-abstraction.html#context","d":"ADR-042: Device Capability Abstraction (MAUI Blazor Hybrid)","k":"Architecture Decision Records","t":"Context","x":"The consumer apps ship the same Blazor component set through three heads: MAUI Blazor Hybrid (Android/iOS/MacCatalyst/Windows), Blazor Server SSR, and WebAssembly. Native device…","i":"builder.Services.AddCommonMauiTokenStorage ITokenStorageService navigator.clipboard navigator.onLine navigator.share MMCA.Common.UI AddUIShared"},{"u":"/docs/adr/042-device-capability-abstraction.html#decision","d":"ADR-042: Device Capability Abstraction (MAUI Blazor Hybrid)","k":"Architecture Decision Records","t":"Decision","x":"Add a per-capability contract layer to MMCA.Common.UI and a fifteenth package, MMCA.Common.UI.Maui, carrying the native implementations. - One small interface per capability, no…","i":"IExternalLinkService.InterceptsLinks AddBrowserDeviceCapabilities AddDeviceCapabilityDefaults EnforceUIMauiLayerBoundary IConnectivityStatusService AddMauiDeviceCapabilities ILocalNotificationService UseMauiDeviceCapabilities Directory.Packages.props IPushDeviceTokenProvider IPushRegistrationService MauiBackNavigationBridge"},{"u":"/docs/adr/042-device-capability-abstraction.html#rationale","d":"ADR-042: Device Capability Abstraction (MAUI Blazor Hybrid)","k":"Architecture Decision Records","t":"Rationale","x":"- A god IDeviceCapabilities interface would force every head to implement everything and turn each new capability into a breaking change; per-capability contracts are open/closed…","i":"IDeviceCapabilities AddUIShared"},{"u":"/docs/adr/042-device-capability-abstraction.html#trade-offs","d":"ADR-042: Device Capability Abstraction (MAUI Blazor Hybrid)","k":"Architecture Decision Records","t":"Trade-offs","x":"- A fifteenth package raises release surface: two runners must both succeed for a whole release. Accepted; the publish-maui job is gated by the same tag and SBOM discipline. -…","i":"AddMauiDeviceCapabilities UseMauiDeviceCapabilities MauiExternalAuthBroker AddUIShared IsAvailable IsSupported false"},{"u":"/docs/adr/043-mobile-deep-links-and-native-oauth-callback.html","d":"ADR-043: Mobile Deep Links, App Association, and the Native OAuth Callback","k":"Architecture Decision Records"},{"u":"/docs/adr/043-mobile-deep-links-and-native-oauth-callback.html#status","d":"ADR-043: Mobile Deep Links, App Association, and the Native OAuth Callback","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-15). Revised 2026-07-28 (the Android https App Links leg is recorded as shipped, the outstanding Android item is restated as the served certificate fingerprint,…","i":"REPLACE_WITH_PLAY_APP_SIGNING_SHA256_FINGERPRINT WebAuthenticatorCallbackActivity MapAppAssociationEndpoints sha256_cert_fingerprints MauiExternalAuthBroker AppAssociationOptions IDeepLinkDispatcher assetlinks.json MMCA.ADC.UI.Web CompleteAsync MainActivity AutoVerify"},{"u":"/docs/adr/043-mobile-deep-links-and-native-oauth-callback.html#context","d":"ADR-043: Mobile Deep Links, App Association, and the Native OAuth Callback","k":"Architecture Decision Records","t":"Context","x":"Three mobile flows all need a URL to leave the web world and land inside the MAUI app: 1. Shared links and QR codes. The share sheet and QR codes carry ordinary https web URLs.…","i":"OAuthControllerBase.CompleteAsync IDeepLinkDispatcher WebAuthenticator assetlinks.json CompleteAsync"},{"u":"/docs/adr/043-mobile-deep-links-and-native-oauth-callback.html#decision","d":"ADR-043: Mobile Deep Links, App Association, and the Native OAuth Callback","k":"Architecture Decision Records","t":"Decision","x":"- Custom-scheme returnUrl allowlist in the framework. CompleteAsync consults OAuth:AllowedReturnUrlSchemes (a config array, default empty). When the challenge's stashed returnUrl…","i":"IAuthUIService.ExchangeOAuthCodeAsync WebAuthenticatorCallbackActivity ITokenStorageService IDeepLinkDispatcher IExternalAuthBroker Uri.OriginalString CFBundleURLTypes WebAuthenticator assetlinks.json CompleteAsync AutoVerify returnUrl"},{"u":"/docs/adr/043-mobile-deep-links-and-native-oauth-callback.html#rationale","d":"ADR-043: Mobile Deep Links, App Association, and the Native OAuth Callback","k":"Architecture Decision Records","t":"Rationale","x":"- Reusing the single-use-code exchange keeps the token-never-in-URL invariant identical across web and native; the only new surface is WHERE the code lands. - A scheme allowlist…"},{"u":"/docs/adr/043-mobile-deep-links-and-native-oauth-callback.html#trade-offs","d":"ADR-043: Mobile Deep Links, App Association, and the Native OAuth Callback","k":"Architecture Decision Records","t":"Trade-offs","x":"- The app-facing hostname is baked into store binaries (intent filters, entitlements). The apps currently ride the Azure Container Apps default domain, which changes if the…","i":"appsettings.json EmbeddedResource PublicWebHost"},{"u":"/docs/adr/043-mobile-deep-links-and-native-oauth-callback.html#revision-2026-07-28","d":"ADR-043: Mobile Deep Links, App Association, and the Native OAuth Callback","k":"Architecture Decision Records","t":"Revision (2026-07-28)","x":"Correction pass from an ADR audit. No decision or behavior changed; the Status section had the Android leg backwards and the Decision section attributed the token exchange to the…","i":"IAuthUIService.ExchangeOAuthCodeAsync ITokenStorageService.SetTokensAsync MapAppAssociationEndpoints sha256_cert_fingerprints AndroidCertFingerprints BuildSuccessRedirectUrl IDeepLinkDispatcher WebAuthenticator CompleteAsync IntentFilter MainActivity OnNewIntent"},{"u":"/docs/adr/043-mobile-deep-links-and-native-oauth-callback.html#revision-2026-08-01","d":"ADR-043: Mobile Deep Links, App Association, and the Native OAuth Callback","k":"Architecture Decision Records","t":"Revision (2026-08-01)","x":"Status pass from an ADR audit. No decision and no behavior changed; the one item the previous revision left open is closed, and the anchor that revision itself introduced had…","i":"MapAppAssociationEndpoints sha256_cert_fingerprints AndroidCertFingerprints AndroidPackageName assetlinks.json ApplicationId Program.cs d5fd0e9"},{"u":"/docs/adr/043-mobile-deep-links-and-native-oauth-callback.html#revision-2026-08-07","d":"ADR-043: Mobile Deep Links, App Association, and the Native OAuth Callback","k":"Architecture Decision Records","t":"Revision (2026-08-07)","x":"Anchor and precision pass from an ADR audit. No decision and no behavior changed. 1. The two Program.cs anchors moved one line. MMCA.ADC commit 886fa189 (PR 100, merged…","i":"app.MapAppAssociationEndpoints AndroidCertFingerprints AppAssociationOptions AndroidPackageName PublicWebHost GetSection Program.cs new"},{"u":"/docs/adr/044-native-push-delivery.html","d":"ADR-044: Native Push Delivery (the Third Notification Channel)","k":"Architecture Decision Records"},{"u":"/docs/adr/044-native-push-delivery.html#status","d":"ADR-044: Native Push Delivery (the Third Notification Channel)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-11). Amends ADR-024. The framework pipeline is implemented and inert by default; each consumer switches it on by provisioning a notification hub with platform…","i":"NativePush"},{"u":"/docs/adr/044-native-push-delivery.html#context","d":"ADR-044: Native Push Delivery (the Third Notification Channel)","k":"Architecture Decision Records","t":"Context","x":"ADR-024 established two notification channels: a durable per-user UserNotification inbox (the source of truth) and a transient SignalR push behind IPushNotificationSender. Both…","i":"IPushNotificationSender UserNotification"},{"u":"/docs/adr/044-native-push-delivery.html#decision","d":"ADR-044: Native Push Delivery (the Third Notification Channel)","k":"Architecture Decision Records","t":"Decision","x":"- Azure Notification Hubs as the delivery fan-out. One hub abstracts both platforms behind one API, holds the platform credentials outside our code, and its installation model…","i":"INativePushSender.SendToUsersAsync Notification.PushNotifications MauiPushRegistrationService NullPushDeviceTokenProvider SendPushNotificationHandler AddNativePushNotifications AddNotificationControllers AuthUIService.LogoutAsync IPushDeviceTokenProvider IPushRegistrationService PushRegistrationListener IPushDeviceRegistrar"},{"u":"/docs/adr/044-native-push-delivery.html#consequences","d":"ADR-044: Native Push Delivery (the Third Notification Channel)","k":"Architecture Decision Records","t":"Consequences","x":"- Sends fan out per 20-user chunk and per platform: an audience of N users costs ceil(N/20) 2 hub calls. Acceptable at conference scale; a template-based send can consolidate…","i":"SendPushNotificationHandler ceil"},{"u":"/docs/adr/045-managed-file-storage-and-avatars.html","d":"ADR-045: Managed File Storage and User Avatars","k":"Architecture Decision Records"},{"u":"/docs/adr/045-managed-file-storage-and-avatars.html#status","d":"ADR-045: Managed File Storage and User Avatars","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-11). Records the BR-116 amendment (ADC): avatar photos are IN scope, powered by two new framework extension points. The framework legs are implemented; each…"},{"u":"/docs/adr/045-managed-file-storage-and-avatars.html#context","d":"ADR-045: Managed File Storage and User Avatars","k":"Architecture Decision Records","t":"Context","x":"The MAUI capability program (ADR-042) brought MediaPicker/camera within reach, and ADC amended BR-116 to include user avatar photos. That needs binary blob storage (the databases…"},{"u":"/docs/adr/045-managed-file-storage-and-avatars.html#decision","d":"ADR-045: Managed File Storage and User Avatars","k":"Architecture Decision Records","t":"Decision","x":"- IFileStorageService (Application): upload-by-blob-name returning the public URI, plus idempotent delete. Default is an unconfigured Null implementation whose uploads fail with…","i":"ImageSharpImageProcessor AddAzureBlobFileStorage IFileStorageService IMediaPickerService ConnectionString IImageProcessor configuration ContainerName FileStorage IsSupported ServiceUri InputFile"},{"u":"/docs/adr/045-managed-file-storage-and-avatars.html#consequences","d":"ADR-045: Managed File Storage and User Avatars","k":"Architecture Decision Records","t":"Consequences","x":"- The avatars container is public-read by design: avatar URLs render in tags on anonymous-visible surfaces without SAS plumbing. The random blob suffix prevents enumeration; the…","i":"DefaultAzureCredential img"},{"u":"/docs/adr/046-http-api-versioning.html","d":"ADR-046: HTTP API Versioning Strategy","k":"Architecture Decision Records"},{"u":"/docs/adr/046-http-api-versioning.html#status","d":"ADR-046: HTTP API Versioning Strategy","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-15). Revised 2026-08-01 (anonymity is granted by each per-service subclass, not by ServiceInfoControllerBase; corrected the ADR-034 cross-reference, which puts…","i":"ServiceInfoControllerBase AddCommonApiVersioning EntityControllerBase DefaultApiVersion Asp.Versioning"},{"u":"/docs/adr/046-http-api-versioning.html#context","d":"ADR-046: HTTP API Versioning Strategy","k":"Architecture Decision Records","t":"Context","x":"The framework's REST surface is served by controllers hosted in extracted service processes behind a YARP gateway. As those services evolve, a response shape has to be able to…","i":"Asp.Versioning SchemaVersion v1.0"},{"u":"/docs/adr/046-http-api-versioning.html#decision","d":"ADR-046: HTTP API Versioning Strategy","k":"Architecture Decision Records","t":"Decision","x":"Standardize one header-based API-versioning setup in MMCA.Common.API, adopt it in every service host through a single registration call, and keep it exercised by a shared fitness…","i":"ApiParameterDescription.ParameterDescriptor ApiParameterDescriptorBackfillProvider ServiceInfoVersioningContractTestsBase AssumeDefaultVersionWhenUnspecified ServiceInfoControllerBase SubstituteApiVersionInUrl IApiDescriptionProvider AddCommonApiVersioning Asp.Versioning.OpenApi HeaderApiVersionReader ServiceInfoController ServiceInfoV2Response"},{"u":"/docs/adr/046-http-api-versioning.html#rationale","d":"ADR-046: HTTP API Versioning Strategy","k":"Architecture Decision Records","t":"Rationale","x":"- Header selection keeps URLs stable. Routing stays version-free, so gateway route maps, client URL builders, and OpenAPI paths do not fork per version; a caller opts into a…","i":"AssumeDefaultVersionWhenUnspecified ServiceInfoControllerBase AddCommonApiVersioning ReportApiVersions ServiceInfo"},{"u":"/docs/adr/046-http-api-versioning.html#trade-offs","d":"ADR-046: HTTP API Versioning Strategy","k":"Architecture Decision Records","t":"Trade-offs","x":"- The class-level version attributes are not inherited. Each per-service subclass must repeat the [ApiVersion(...)] and routing attributes (the same inheritance caveat ADR-036…","i":"AddCommonApiVersioning MapCommonOpenApi OAuthController ApiVersion"},{"u":"/docs/adr/046-http-api-versioning.html#related","d":"ADR-046: HTTP API Versioning Strategy","k":"Architecture Decision Records","t":"Related","x":"ADR-010 (integration-event schema versioning: the asynchronous, SchemaVersion-carried, consumer-resolved axis this deliberately contrasts with; HTTP versioning here is…","i":"ServiceInfoVersioningContractTestsBase OAuthController ApiController SchemaVersion ApiVersion controller Route"},{"u":"/docs/adr/047-soft-deleted-user-session-revocation.html","d":"ADR-047: Runtime Revocation of Soft-Deleted Users' Active Sessions","k":"Architecture Decision Records"},{"u":"/docs/adr/047-soft-deleted-user-session-revocation.html#status","d":"ADR-047: Runtime Revocation of Soft-Deleted Users' Active Sessions","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-15). Revised 2026-08-07 (validator hoisted into a shared generic, the 30-second constant moved, the two apps revoke at different speeds). Revised 2026-08-23:…","i":"MiddlewarePipelineBuilder WebApplicationExtensions"},{"u":"/docs/adr/047-soft-deleted-user-session-revocation.html#context","d":"ADR-047: Runtime Revocation of Soft-Deleted Users' Active Sessions","k":"Architecture Decision Records","t":"Context","x":"Soft-delete is the framework's default deletion model (ADR-005): AuditableBaseEntity.Delete() sets IsDeleted = true and EF global query filters hide the row, but the record…","i":"AuditableBaseEntity.Delete HttpContext.User IsDeleted true"},{"u":"/docs/adr/047-soft-deleted-user-session-revocation.html#decision","d":"ADR-047: Runtime Revocation of Soft-Deleted Users' Active Sessions","k":"Architecture Decision Records","t":"Decision","x":"Add a shared-pipeline middleware, SoftDeletedUserMiddleware (Source/Presentation/MMCA.Common.API/Middleware/SoftDeletedUserMiddleware.cs:31, BR-133), that rejects an…","i":"DeleteUserHandler.OnAfterSoftDeleteAsync MiddlewarePipelineBuilder.CreateDefault SoftDeletedUserCache.MarkDeletedAsync SoftDeletedUserCache.MarkerDuration context.RequestServices.GetService MiddlewarePipelineBuilder.Build SoftDeletedUserMiddlewareTests AuditableAggregateRootEntity SoftDeletedUserCache.KeyFor UseCommonMiddlewarePipeline ICurrentUserService.UserId TenantResolutionMiddleware"},{"u":"/docs/adr/047-soft-deleted-user-session-revocation.html#rationale","d":"ADR-047: Runtime Revocation of Soft-Deleted Users' Active Sessions","k":"Architecture Decision Records","t":"Rationale","x":"- Bounds the stateless-JWT revocation gap cheaply. Stateless JWT (ADR-004) has no built-in revocation, so a deactivated account would otherwise stay usable for the full remaining…","i":"ISoftDeletedUserValidator SoftDeletedUserValidator MMCA.Common.API TUser User"},{"u":"/docs/adr/047-soft-deleted-user-session-revocation.html#trade-offs","d":"ADR-047: Runtime Revocation of Soft-Deleted Users' Active Sessions","k":"Architecture Decision Records","t":"Trade-offs","x":"- Revocation is bounded, not immediate. A soft-deleted user whose status is cached as not-deleted keeps passing until that cache entry expires (up to 30 seconds), unless the…","i":"SoftDeletedUserCache.MarkDeletedAsync SoftDeletedUserCache.MarkerDuration ISoftDeletedUserValidator DeleteUserHandler"},{"u":"/docs/adr/047-soft-deleted-user-session-revocation.html#related","d":"ADR-047: Runtime Revocation of Soft-Deleted Users' Active Sessions","k":"Architecture Decision Records","t":"Related","x":"ADR-005 (soft-delete is the deletion model whose still-authenticated tokens this middleware revokes; deleting a user is a soft-delete, not a row removal), ADR-004 (the stateless…","i":"TenantResolutionMiddleware"},{"u":"/docs/adr/047-soft-deleted-user-session-revocation.html#revision-2026-08-07","d":"ADR-047: Runtime Revocation of Soft-Deleted Users' Active Sessions","k":"Architecture Decision Records","t":"Revision (2026-08-07)","x":"Re-verified against current source. The decision is unchanged, but three things it described have moved: the validator implementation, the home of the 30-second constant, and the…","i":"SoftDeletedUserCache.MarkerDuration SoftDeletedUserMiddleware SoftDeletedUserValidator TimeSpan.FromSeconds DeleteUserHandler CacheDuration UserId TUser true User"},{"u":"/docs/adr/047-soft-deleted-user-session-revocation.html#revision-2026-08-23","d":"ADR-047: Runtime Revocation of Soft-Deleted Users' Active Sessions","k":"Architecture Decision Records","t":"Revision (2026-08-23)","x":"Re-verified against current source. The decision, the cache design, the fail-open policy and the per-app asymmetry are all unchanged; what moved is where the middleware's…","i":"MiddlewarePipelineBuilder.CreateDefault app.UseCommonMiddlewarePipeline MiddlewarePipelineBuilder.Build UseCommonMiddlewarePipeline WebApplicationExtensions.cs TenantResolutionMiddleware ISoftDeletedUserValidator SoftDeletedUserMiddleware SoftDeletedUserFilter UseAuthentication TenantResolution UseAuthorization"},{"u":"/docs/adr/048-primitive-identifier-type-aliases.html","d":"ADR-048: Primitive Identifier Type Aliases over Strongly-Typed ID Structs","k":"Architecture Decision Records"},{"u":"/docs/adr/048-primitive-identifier-type-aliases.html#status","d":"ADR-048: Primitive Identifier Type Aliases over Strongly-Typed ID Structs","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-15). Revised 2026-07-21 (corrected the empty-placeholder-folder inventory and the Directory.Build.props and ADC User source citations). Revised 2026-07-28…","i":"ActivityIdentifierType Directory.Build.props SponsorIdentifierType UserIdentifierType StronglyTypedIds User"},{"u":"/docs/adr/048-primitive-identifier-type-aliases.html#context","d":"ADR-048: Primitive Identifier Type Aliases over Strongly-Typed ID Structs","k":"Architecture Decision Records","t":"Context","x":"Every entity needs an identity type. The framework's base entity is generic over that type: BaseEntity constrains it to notnull and exposes a single required init Id…","i":"UserIdentifierType TIdentifierType IBaseEntity BaseEntity readonly required notnull record struct UserId Value Guid"},{"u":"/docs/adr/048-primitive-identifier-type-aliases.html#decision","d":"ADR-048: Primitive Identifier Type Aliases over Strongly-Typed ID Structs","k":"Architecture Decision Records","t":"Decision","x":"Model every identifier as a primitive named through a global-using alias, declared per module, not as a wrapper struct. - Identity is a primitive behind an alias. Each module…","i":"EntityTypeConfigurationSQLServer AuditableAggregateRootEntity AuthenticationServiceBase Directory.Build.props SpeakerIdentifierType AuditableBaseEntity UserIdentifierType LinkedSpeakerId IdentifierType LastModifiedBy GetRepository System.Guid"},{"u":"/docs/adr/048-primitive-identifier-type-aliases.html#rationale","d":"ADR-048: Primitive Identifier Type Aliases over Strongly-Typed ID Structs","k":"Architecture Decision Records","t":"Rationale","x":"- Readable signatures at zero runtime cost. GetRepository () reads as intent while the CLR sees a plain int. There is no allocation, boxing, or wrapper indirection per…","i":"UserIdentifierType IEntityDTOMapper System.Text.Json GetRepository JsonConverter IBaseEntity BaseEntity IBaseDTO Shared Guid User int"},{"u":"/docs/adr/048-primitive-identifier-type-aliases.html#trade-offs","d":"ADR-048: Primitive Identifier Type Aliases over Strongly-Typed ID Structs","k":"Architecture Decision Records","t":"Trade-offs","x":"- No compile-time protection against swapping same-typed identifiers. An alias is a type synonym, not a distinct type. Because most aliases resolve to int, the compiler will not…","i":"SessionIdentifierType SpeakerIdentifierType UserIdentifierType Shared Guid int"},{"u":"/docs/adr/048-primitive-identifier-type-aliases.html#related","d":"ADR-048: Primitive Identifier Type Aliases over Strongly-Typed ID Structs","k":"Architecture Decision Records","t":"Related","x":"ADR-001 (the per-entity DTO mappers are parameterized by this identifier type, IEntityDTOMapper ), ADR-034 (the generic entity controllers and query contract ride on the same…","i":"IEntityDTOMapper TIdentifierType TEntityDTO TEntity Shared"},{"u":"/docs/adr/048-primitive-identifier-type-aliases.html#revision-2026-08-18","d":"ADR-048: Primitive Identifier Type Aliases over Strongly-Typed ID Structs","k":"Architecture Decision Records","t":"Revision (2026-08-18)","x":"No decision, no behavior and no citation in this record changed. What changed is the standing of the deferral it records. The last Trade-offs entry above (\"Revisiting the trade…","i":"UserIdentifierType TIdentifierType CheckIn Source razor int"},{"u":"/docs/adr/048-primitive-identifier-type-aliases.html#revision-2026-08-23","d":"ADR-048: Primitive Identifier Type Aliases over Strongly-Typed ID Structs","k":"Architecture Decision Records","t":"Revision (2026-08-23)","x":"No decision and no rationale changed. Two counts did, both because Conference gained an alias. Conference's alias file declares seventeen aliases, ActivityIdentifierType = int…","i":"ActivityIdentifierType SpeakerIdentifierType System.Guid Guid int"},{"u":"/docs/adr/049-library-configureawait-policy.html","d":"ADR-049: Library-Scoped ConfigureAwait(false) Policy (CA2007)","k":"Architecture Decision Records"},{"u":"/docs/adr/049-library-configureawait-policy.html#status","d":"ADR-049: Library-Scoped ConfigureAwait(false) Policy (CA2007)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-20; measurements re-anchored 2026-08-07, 2026-08-14, 2026-08-18 and 2026-08-23)."},{"u":"/docs/adr/049-library-configureawait-policy.html#context","d":"ADR-049: Library-Scoped ConfigureAwait(false) Policy (CA2007)","k":"Architecture Decision Records","t":"Context","x":"MMCA.Common ships as NuGet packages consumed by host applications, not as an application itself. Library code that awaits without ConfigureAwait(false) captures the caller's…","i":"SynchronizationContext MMCA.Common.UI.Maui ConfigureAwait editorconfig VSTHRD111 RCS1090 CA2007 MA0004 false"},{"u":"/docs/adr/049-library-configureawait-policy.html#decision","d":"ADR-049: Library-Scoped ConfigureAwait(false) Policy (CA2007)","k":"Architecture Decision Records","t":"Decision","x":"Packaged non-UI framework code awaits with ConfigureAwait(false); UI component packages and application code do not. - Enforcement is a build gate, not a convention. The…","i":"TreatWarningsAsErrors MMCA.Common.UI.Maui MMCA.Common.UI.Web ConfigureAwait MMCA.Common.UI editorconfig VSTHRD111 RCS1090 warning CA2007 MA0004 false"},{"u":"/docs/adr/049-library-configureawait-policy.html#rationale","d":"ADR-049: Library-Scoped ConfigureAwait(false) Policy (CA2007)","k":"Architecture Decision Records","t":"Rationale","x":"- Correctness for the one consumer that already has a context. The MAUI head consumes Infrastructure/Application/API packages through DI; a sync-over-async call anywhere in that…","i":"ConfigureAwait GetAwaiter GetResult script batch false fixes place step but"},{"u":"/docs/adr/049-library-configureawait-policy.html#trade-offs","d":"ADR-049: Library-Scoped ConfigureAwait(false) Policy (CA2007)","k":"Architecture Decision Records","t":"Trade-offs","x":"- Visual noise in framework source. Every await in Source/ (except UI packages) carries .ConfigureAwait(false) (324 sites at adoption; 767 gated sites as of the 2026-08-23…","i":"ConfigureAwait editorconfig false"},{"u":"/docs/adr/049-library-configureawait-policy.html#related","d":"ADR-049: Library-Scoped ConfigureAwait(false) Policy (CA2007)","k":"Architecture Decision Records","t":"Related","x":"ADR-042 (the MAUI package whose synchronization context motivates the policy), ADR-027 (the same \"machine-boundary hygiene as a build gate\" posture applied to culture-explicit…"},{"u":"/docs/adr/049-library-configureawait-policy.html#revision-2026-08-07","d":"ADR-049: Library-Scoped ConfigureAwait(false) Policy (CA2007)","k":"Architecture Decision Records","t":"Revision (2026-08-07)","x":"An audit against the code. The policy did not change; three statements about it did. 1. The exemption covers three packages, not the two the Decision named. The glob is…","i":"CodeAnalysisTreatWarningsAsErrors ServerTokenStorageService TreatWarningsAsErrors MMCA.Common.UI.Maui MMCA.Common.UI.Web ConfigureAwait MMCA.Common.UI analyzers PackageId foreach warning CA2007"},{"u":"/docs/adr/049-library-configureawait-policy.html#revision-2026-08-14","d":"ADR-049: Library-Scoped ConfigureAwait(false) Policy (CA2007)","k":"Architecture Decision Records","t":"Revision (2026-08-14)","x":"A re-measurement only. The policy, the gate and the exemption are unchanged; the counts the document quotes were a week old and had moved by roughly 9%. 1. Framework site counts,…","i":"CodeAnalysisTreatWarningsAsErrors ServerTokenStorageService TreatWarningsAsErrors MMCA.Common.UI.Maui MMCA.Common.UI.Web ConfigureAwait MMCA.Common.UI analyzers PackageId warning CA2007 dotnet"},{"u":"/docs/adr/049-library-configureawait-policy.html#revision-2026-08-18","d":"ADR-049: Library-Scoped ConfigureAwait(false) Policy (CA2007)","k":"Architecture Decision Records","t":"Revision (2026-08-18)","x":"A re-measurement only, in the same terms as the 2026-08-14 pass. The policy, the gate and the exemption are unchanged; two of the three counted figures moved. 1. Framework site…","i":"CodeAnalysisTreatWarningsAsErrors TreatWarningsAsErrors MMCA.Common.UI.Maui MMCA.Common.UI.Web ConfigureAwait MMCA.Common.UI analyzers warning CA2007 dotnet format await"},{"u":"/docs/adr/049-library-configureawait-policy.html#revision-2026-08-23","d":"ADR-049: Library-Scoped ConfigureAwait(false) Policy (CA2007)","k":"Architecture Decision Records","t":"Revision (2026-08-23)","x":"A re-measurement only, in the same terms as the 2026-08-18 pass. The policy, the gate and the exemption are unchanged; both counted figures moved. 1. Framework site counts,…","i":"CodeAnalysisTreatWarningsAsErrors ServerTokenStorageService TreatWarningsAsErrors MMCA.Common.UI.Maui MMCA.Common.UI.Web ConfigureAwait MMCA.Common.UI analyzers PackageId warning CA2007 dotnet"},{"u":"/docs/adr/050-jwt-refresh-token-rotation.html","d":"ADR-050: JWT Access Tokens with a Single Rotating Refresh Token and Reuse Detection","k":"Architecture Decision Records"},{"u":"/docs/adr/050-jwt-refresh-token-rotation.html#status","d":"ADR-050: JWT Access Tokens with a Single Rotating Refresh Token and Reuse Detection","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-21)."},{"u":"/docs/adr/050-jwt-refresh-token-rotation.html#context","d":"ADR-050: JWT Access Tokens with a Single Rotating Refresh Token and Reuse Detection","k":"Architecture Decision Records","t":"Context","x":"Identity issues two credentials on every successful sign-in: a short-lived, stateless JWT access token that every service validates by signature and expiry (ADR-004), and a…","i":"AuthenticationServiceBase TUser"},{"u":"/docs/adr/050-jwt-refresh-token-rotation.html#decision","d":"ADR-050: JWT Access Tokens with a Single Rotating Refresh Token and Reuse Detection","k":"Architecture Decision Records","t":"Decision","x":"Mint a stateless JWT access token plus a single, server-stored refresh token that rotates on every use, with a token mismatch triggering revocation. - Access token is stateless;…","i":"TokenService.GetPrincipalFromExpiredToken JwtSettings.AccessTokenExpirationMinutes JwtSettings.RefreshTokenExpirationDays TokenService.GenerateRefreshToken TokenService.RefreshTokenLifetime TokenService.GenerateAccessToken RandomNumberGenerator.GetBytes user.RevokeRefreshToken user.UpdateRefreshToken AuthenticationService RefreshTokenLifetime RefreshTokenExpiry"},{"u":"/docs/adr/050-jwt-refresh-token-rotation.html#rationale","d":"ADR-050: JWT Access Tokens with a Single Rotating Refresh Token and Reuse Detection","k":"Architecture Decision Records","t":"Rationale","x":"- Short access token plus refresh keeps the hot path stateless. Every service validates the access token with no store lookup (ADR-004); the short exp bounds the revocation gap,…","i":"exp"},{"u":"/docs/adr/050-jwt-refresh-token-rotation.html#trade-offs","d":"ADR-050: JWT Access Tokens with a Single Rotating Refresh Token and Reuse Detection","k":"Architecture Decision Records","t":"Trade-offs","x":"- One refresh token per user means one live session. A new login overwrites the single stored token (AuthenticationServiceBase.cs:298), so signing in on a second device…","i":"JwtSettings.RefreshTokenExpirationDays RefreshTokenExpirationDays RefreshTokenLifetime TimeSpan.Zero TokenService"},{"u":"/docs/adr/050-jwt-refresh-token-rotation.html#related","d":"ADR-050: JWT Access Tokens with a Single Rotating Refresh Token and Reuse Detection","k":"Architecture Decision Records","t":"Related","x":"ADR-004 (the stateless RS256/JWKS access token this refresh flow reissues, and the algorithm pinning GetPrincipalFromExpiredToken relies on), ADR-032 (the password hashing that…","i":"GetPrincipalFromExpiredToken AuthenticationServiceBase TUser"},{"u":"/docs/adr/051-client-auth-token-lifecycle.html","d":"ADR-051: Client-Side Authentication Token Lifecycle Across Render Modes","k":"Architecture Decision Records"},{"u":"/docs/adr/051-client-auth-token-lifecycle.html#status","d":"ADR-051: Client-Side Authentication Token Lifecycle Across Render Modes","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-23). Revised 2026-08-14 (SetTokensAsync now writes the refresh token and the access token under one shared guard, so a failed refresh-token write also drops…","i":"SetTokensAsync"},{"u":"/docs/adr/051-client-auth-token-lifecycle.html#context","d":"ADR-051: Client-Side Authentication Token Lifecycle Across Render Modes","k":"Architecture Decision Records","t":"Context","x":"ADR-022 and ADR-050 describe the two server halves of authentication: the Blazor host's HttpOnly session cookie that survives SSR prerender (ADR-022), and the Identity service's…","i":"HttpContext"},{"u":"/docs/adr/051-client-auth-token-lifecycle.html#decision","d":"ADR-051: Client-Side Authentication Token Lifecycle Across Render Modes","k":"Architecture Decision Records","t":"Decision","x":"Model the client token lifecycle as two small abstractions (ITokenStorageService for persistence, ITokenRefresher for reacquisition) plus a shared bearer-attaching handler and a…","i":"AddClientAuthSessionCookieSync JwtAuthenticationStateProvider SameOriginProxyTokenRefresher ISessionCookieSync.SyncAsync AddCommonServerTokenStorage AddCommonMauiTokenStorage ServerTokenStorageService mmcaAuthSession.getToken NotifyUserAuthentication AcquireAccessTokenAsync DirectApiTokenRefresher WasmTokenStorageService"},{"u":"/docs/adr/051-client-auth-token-lifecycle.html#rationale","d":"ADR-051: Client-Side Authentication Token Lifecycle Across Render Modes","k":"Architecture Decision Records","t":"Rationale","x":"- One application surface, three storage stories. Pages, services, and the HTTP pipeline talk to ITokenStorageService and AuthenticationStateProvider only; the head-specific…","i":"AuthenticationStateProvider ITokenStorageService"},{"u":"/docs/adr/051-client-auth-token-lifecycle.html#trade-offs","d":"ADR-051: Client-Side Authentication Token Lifecycle Across Render Modes","k":"Architecture Decision Records","t":"Trade-offs","x":"- The browser heads depend on the same-origin UI host. SameOriginProxyTokenRefresher only works where the UI host serves the /auth/session/ endpoints; a browser head deployed…","i":"JwtAuthenticationStateProvider SameOriginProxyTokenRefresher MMCA.Common.UI.Maui MMCA.Common.UI.Web MMCA.Common.slnx MMCA.Common.UI AuthorizeView SecureStorage"},{"u":"/docs/adr/051-client-auth-token-lifecycle.html#related","d":"ADR-051: Client-Side Authentication Token Lifecycle Across Render Modes","k":"Architecture Decision Records","t":"Related","x":"ADR-022 (the Blazor host's HttpOnly session cookie and the /auth/session/ endpoints the browser refresher proxies through), ADR-050 (the single rotating refresh token with reuse…","i":"DirectApiTokenRefresher MauiTokenStorageService"},{"u":"/docs/adr/051-client-auth-token-lifecycle.html#revision-2026-08-07","d":"ADR-051: Client-Side Authentication Token Lifecycle Across Render Modes","k":"Architecture Decision Records","t":"Revision (2026-08-07)","x":"The MAUI half of ITokenStorageService is no longer app-local. The original Decision left the SecureStorage-backed implementation in each app because it depends on the MAUI…","i":"JwtAuthenticationStateProvider MauiTokenStorageService.cs AddCommonMauiTokenStorage DirectApiTokenRefresher MauiTokenStorageService SecureStorage.Default ITokenStorageService MMCA.Common.UI.Maui auth_refresh_token auth_access_token ClearTokensAsync MMCA.Common.slnx"},{"u":"/docs/adr/051-client-auth-token-lifecycle.html#revision-2026-08-14","d":"ADR-051: Client-Side Authentication Token Lifecycle Across Render Modes","k":"Architecture Decision Records","t":"Revision (2026-08-14)","x":"SetTokensAsync closed a gap the original hoist left open. Point 3 above previously described the method as writing the refresh token first and dropping both tokens only when the…","i":"SetTokensAsync catch try"},{"u":"/docs/adr/052-background-job-execution.html","d":"ADR-052: Background Job Execution (Bounded Queue plus Hosted Drain)","k":"Architecture Decision Records"},{"u":"/docs/adr/052-background-job-execution.html#status","d":"ADR-052: Background Job Execution (Bounded Queue plus Hosted Drain)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-24). Revised 2026-08-23 (post-commit enqueue is recorded as two patterns, not one: see the revision at the end)."},{"u":"/docs/adr/052-background-job-execution.html#context","d":"ADR-052: Background Job Execution (Bounded Queue plus Hosted Drain)","k":"Architecture Decision Records","t":"Context","x":"Some requests trigger work that cannot run inside the request: an AI scoring pass over an event's sessions takes minutes and issues one paid API call per session, and a…","i":"RunScoringInBackgroundAsync IHostApplicationLifetime eventId"},{"u":"/docs/adr/052-background-job-execution.html#decision","d":"ADR-052: Background Job Execution (Bounded Queue plus Hosted Drain)","k":"Architecture Decision Records","t":"Decision","x":"In-process background work runs as a bounded queue plus a single-reader hosted drain. Nothing starts an untracked Task from a request. - A bounded Channel per job kind,…","i":"BoundedChannelFullMode.DropOldest LiveChannelPublishProcessor unitOfWork.SaveChangesAsync LiveChannelPublishQueue SessionScoringProcessor sp.GetRequiredService IServiceScopeFactory SessionScoringQueue BackgroundService SaveChangesAsync TryAddSingleton ITransactional"},{"u":"/docs/adr/052-background-job-execution.html#rationale","d":"ADR-052: Background Job Execution (Bounded Queue plus Hosted Drain)","k":"Architecture Decision Records","t":"Rationale","x":"- The host lifetime is the point. A BackgroundService is the only in-process shape the host can cancel and await. Everything else in the decision follows from wanting that. - The…","i":"BackgroundService TryEnqueue"},{"u":"/docs/adr/052-background-job-execution.html#trade-offs","d":"ADR-052: Background Job Execution (Bounded Queue plus Hosted Drain)","k":"Architecture Decision Records","t":"Trade-offs","x":"- In-process only. The queue does not survive a restart and does not span replicas. Accepted because every current job is either ephemeral (a lost broadcast is a missed UI…","i":"DropOldest Wait"},{"u":"/docs/adr/052-background-job-execution.html#related","d":"ADR-052: Background Job Execution (Bounded Queue plus Hosted Drain)","k":"Architecture Decision Records","t":"Related","x":"ADR-003 (the outbox, for work that must be durable and cross-process, and the post-commit deferral this relies on), ADR-008 (the broker, for cross-service work), ADR-014 (the…"},{"u":"/docs/adr/052-background-job-execution.html#revision-2026-08-23","d":"ADR-052: Background Job Execution (Bounded Queue plus Hosted Drain)","k":"Architecture Decision Records","t":"Revision (2026-08-23)","x":"The decision is unchanged: post-commit work is still enqueued only once the write is durable. What changed is the record of how. This ADR stated the domain-event handler as the…","i":"SessionQuestionUpvoteChangedHandler TransactionalCommandDecorator SessionQuestionUpvoteChanged unitOfWork.SaveChangesAsync LivePollVoteChangedHandler BestEffort.ExecuteAsync ModerateQuestionHandler EnqueueModeratedAsync EnqueueSubmittedAsync SubmitQuestionHandler CloseLivePollHandler IDomainEventHandler"},{"u":"/docs/adr/053-dual-registry-package-publishing.html","d":"ADR-053: Dual-Registry Package Publishing (nuget.org plus GitHub Packages)","k":"Architecture Decision Records"},{"u":"/docs/adr/053-dual-registry-package-publishing.html#status","d":"ADR-053: Dual-Registry Package Publishing (nuget.org plus GitHub Packages)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-25). Amended (2026-07-25) to put the pre-decision Context statements in the past tense, to record the MMCA. ID prefix reservation as then-pending, to scope the…","i":"Directory.Build.props MMCA"},{"u":"/docs/adr/053-dual-registry-package-publishing.html#context","d":"ADR-053: Dual-Registry Package Publishing (nuget.org plus GitHub Packages)","k":"Architecture Decision Records","t":"Context","x":"The fifteen MMCA.Common. packages have shipped to GitHub Packages since the first release. That was the right default while the framework had exactly one consumer group (this…","i":"MMCA.Common.API nuget.config local.props MMCA.Common totalHits package dotnet add"},{"u":"/docs/adr/053-dual-registry-package-publishing.html#decision","d":"ADR-053: Dual-Registry Package Publishing (nuget.org plus GitHub Packages)","k":"Architecture Decision Records","t":"Decision","x":"Every release publishes to both registries, from the same tag, in the same workflow run. - release.yml keeps its existing dotnet nuget push to…","i":"github.repository_owner Directory.Build.props PackageProjectUrl PackageReadmeFile Description MMCA.Common PackageIcon PackageTags permissions release.yml README.md ivanball"},{"u":"/docs/adr/053-dual-registry-package-publishing.html#rationale","d":"ADR-053: Dual-Registry Package Publishing (nuget.org plus GitHub Packages)","k":"Architecture Decision Records","t":"Rationale","x":"- The install line has to be true. Documentation that cannot be followed is worse than no documentation, because the reader concludes the project is broken rather than that the…","i":"MMCA"},{"u":"/docs/adr/053-dual-registry-package-publishing.html#trade-offs","d":"ADR-053: Dual-Registry Package Publishing (nuget.org plus GitHub Packages)","k":"Architecture Decision Records","t":"Trade-offs","x":"- A published version can never be withdrawn. nuget.org allows unlisting, not deletion. A bad release is now permanent public history, which raises the stakes on the release…","i":"release.yml ivanball"},{"u":"/docs/adr/053-dual-registry-package-publishing.html#related","d":"ADR-053: Dual-Registry Package Publishing (nuget.org plus GitHub Packages)","k":"Architecture Decision Records","t":"Related","x":"ADR-016 (lockstep versioning: every package ships at one version, so both registries receive the same fifteen ids per release), ADR-038 (supply-chain provenance: the SBOM hard…"},{"u":"/docs/adr/054-saga-compensation-and-reconciliation.html","d":"ADR-054: Choreographed Saga Compensation with a Reconciliation Backstop","k":"Architecture Decision Records"},{"u":"/docs/adr/054-saga-compensation-and-reconciliation.html#status","d":"ADR-054: Choreographed Saga Compensation with a Reconciliation Backstop","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-25). Amended (2026-07-28): Store's reconciliation sweep now derives from PeriodicBackgroundService, so the shared-loop and adoption paragraphs are rewritten and…","i":"PeriodicBackgroundService SafeDomainEventHandler TDomainEvent IUnitOfWork maxReplicas"},{"u":"/docs/adr/054-saga-compensation-and-reconciliation.html#context","d":"ADR-054: Choreographed Saga Compensation with a Reconciliation Backstop","k":"Architecture Decision Records","t":"Context","x":"Checkout spans a boundary no transaction covers. CheckOutHandler commits the order insert, the cart transition and the atomic conditional stock decrements in one local…","i":"PaymentInitiated CheckOutHandler"},{"u":"/docs/adr/054-saga-compensation-and-reconciliation.html#decision","d":"ADR-054: Choreographed Saga Compensation with a Reconciliation Backstop","k":"Architecture Decision Records","t":"Decision","x":"Multi-step workflows are choreographed sagas: each step raises a domain event, and the follow-up or compensating action lives in its own handler. A periodic reconciliation sweep…","i":"OrderPaymentFailedSagaHandler DbUpdateConcurrencyException PaymentReconciliationService OperationCanceledException OrderCancelledSagaHandler PeriodicBackgroundService Order.InventoryRestored SafeDomainEventHandler MarkInventoryRestored IServiceScopeFactory IDomainEventHandler MarkAsPaymentFailed"},{"u":"/docs/adr/054-saga-compensation-and-reconciliation.html#rationale","d":"ADR-054: Choreographed Saga Compensation with a Reconciliation Backstop","k":"Architecture Decision Records","t":"Rationale","x":"- No two-phase commit is available, and none is wanted. Transactions are per data source and best-effort sequential (ADR-006), and an external payment provider cannot enlist in a…","i":"Order.InventoryRestored Order.Status SaveChanges Result catch"},{"u":"/docs/adr/054-saga-compensation-and-reconciliation.html#trade-offs","d":"ADR-054: Choreographed Saga Compensation with a Reconciliation Backstop","k":"Architecture Decision Records","t":"Trade-offs","x":"- Inconsistency is bounded, not eliminated. Between the cancellation commit and the compensation commit, stock is held against a cancelled order. Between a dropped webhook and…","i":"PaymentInitiated RestoreInventory InventoryItem maxReplicas"},{"u":"/docs/adr/054-saga-compensation-and-reconciliation.html#related","d":"ADR-054: Choreographed Saga Compensation with a Reconciliation Backstop","k":"Architecture Decision Records","t":"Related","x":"ADR-003 (the outbox delivery and retry this leans on for compensation redelivery; this record says what the redelivered handler must do), ADR-006 (which accepts \"no…","i":"RowVersion Result"},{"u":"/docs/adr/055-repository-and-specification-contract.html","d":"ADR-055: Repository plus Specification as the Data-Access Contract","k":"Architecture Decision Records"},{"u":"/docs/adr/055-repository-and-specification-contract.html#status","d":"ADR-055: Repository plus Specification as the Data-Access Contract","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-25). Revised 2026-08-01 (qualified the \"referenced nowhere\" claim about IEntityReader / IEntityQuerier: an ADC doc comment now names IEntityQuerier, though no…","i":"DependencyInjection.cs DependencyInjection EFReadRepository.cs IEntityQueryService QuerySpecification SessionsController Expression.Invoke EFReadRepository IEntityQuerier IRepository.cs IEntityReader ListAsync"},{"u":"/docs/adr/055-repository-and-specification-contract.html#context","d":"ADR-055: Repository plus Specification as the Data-Access Contract","k":"Architecture Decision Records","t":"Context","x":"Every read an application handler performs has to come from somewhere, and the shape of that contract decides whether the module can still be lifted into its own service later…","i":"TIdentifierType IQueryable DbSet"},{"u":"/docs/adr/055-repository-and-specification-contract.html#decision","d":"ADR-055: Repository plus Specification as the Data-Access Contract","k":"Architecture Decision Records","t":"Decision","x":"Data access is repository plus specification: interface-segregated read interfaces for the operations, expression-tree specifications for the predicates, and a build-failing…","i":"CrossSourceSpecification.BuildAsync IUnitOfWork.GetReadRepository OrdersByCustomerSpecification PublishedEventSpecification TableNoTrackingSingleQuery ParameterReplacer.Replace TableNoTrackingSplitQuery OwnedByUserSpecification publicSpecification.And EntityQueryService.cs ProductVariantService SpecificationComposer"},{"u":"/docs/adr/055-repository-and-specification-contract.html#rationale","d":"ADR-055: Repository plus Specification as the Data-Access Contract","k":"Architecture Decision Records","t":"Rationale","x":"- A narrow interface is the enforcement, not a style preference. A handler that asks for IEntityReader cannot reach TableNoTracking, because the member is not on the interface.…","i":"GetProjectedAsync TableNoTracking IEntityReader IsSatisfiedBy AllowedFiles GetByIdAsync CountAsync IQueryable Criteria"},{"u":"/docs/adr/055-repository-and-specification-contract.html#trade-offs","d":"ADR-055: Repository plus Specification as the Data-Access Contract","k":"Architecture Decision Records","t":"Trade-offs","x":"- The ISP split is guidance, not yet a wired dependency. (Superseded by the Revision (2026-08-21) below: the split has real dependents shipped in both MMCA.ADC and MMCA.Store,…","i":"QuerySpecification Expression.Invoke ParameterReplacer Specification.cs ISpecification IEntityReader IUnitOfWork Criteria"},{"u":"/docs/adr/055-repository-and-specification-contract.html#related","d":"ADR-055: Repository plus Specification as the Data-Access Contract","k":"Architecture Decision Records","t":"Related","x":"ADR-007 and ADR-008 (the extraction promise the queryable ban exists to protect), ADR-015 (the fitness-function machinery that runs this rule and its per-repo maps), ADR-014 (the…","i":"SpecificationsDoNotNavigateToOtherEntities TIdentifierType"},{"u":"/docs/adr/055-repository-and-specification-contract.html#revision-2026-08-18","d":"ADR-055: Repository plus Specification as the Data-Access Contract","k":"Architecture Decision Records","t":"Revision (2026-08-18)","x":"Five changes, four of them widening the contract and one of them fixing a correctness defect. The decision this record states is unchanged: data access is still repository plus…","i":"NavigationMetadata.UnsupportedIncludes QueryFieldService.ApplySorting PushNotificationDTOProjection PushNotificationDTOProjector KeysetQueryBuilder.Compare PaginationTieBreakProperty EFReadRepositoryDecorator CrossSourceSpecification Error.InvalidEntityField SpecificationExtensions KeysetCollectionResult ExecuteProjectedAsync"},{"u":"/docs/adr/055-repository-and-specification-contract.html#revision-2026-08-21","d":"ADR-055: Repository plus Specification as the Data-Access Contract","k":"Architecture Decision Records","t":"Revision (2026-08-21)","x":"Nothing in the contract changed; its consumers did. This revision records the first real adoption of the two surfaces this record had honestly flagged as unconsumed: the narrow…","i":"PublicConferenceVisibility SpecificationExtensions specification.Criteria ProductVariantService GetPageByCursorAsync GetProjectedAsync GetReadRepository AndSpecification IReadRepository IEntityQuerier IEntityReader specification"},{"u":"/docs/adr/056-blazor-render-mode-strategy.html","d":"ADR-056: Blazor Render-Mode Strategy for the Web Heads","k":"Architecture Decision Records"},{"u":"/docs/adr/056-blazor-render-mode-strategy.html#status","d":"ADR-056: Blazor Render-Mode Strategy for the Web Heads","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-28). Revised 2026-08-14: re-anchored the host, base-class and AppHost citations to their current lines; scoped the \"only @rendermode attributes\" enumeration to…","i":"InteractiveServer rendermode App.razor MudTable ADCHome"},{"u":"/docs/adr/056-blazor-render-mode-strategy.html#context","d":"ADR-056: Blazor Render-Mode Strategy for the Web Heads","k":"Architecture Decision Records","t":"Context","x":"Both web applications are Blazor Web Apps: a static server-rendered (SSR) prerender pass produces the first HTML, then an interactive runtime takes over, either a Blazor Server…","i":"InteractiveAuto App.razor Authorize"},{"u":"/docs/adr/056-blazor-render-mode-strategy.html#decision","d":"ADR-056: Blazor Render-Mode Strategy for the Web Heads","k":"Architecture Decision Records","t":"Decision","x":"Run one render mode for the entire routable component tree, chosen at the application root, default InteractiveAuto, with prerendering left on and the resulting double fetch…","i":"AddInteractiveWebAssemblyComponents AddInteractiveWebAssemblyRenderMode AddInteractiveServerComponents AddInteractiveServerRenderMode RendererInfo.IsInteractive RenderMode.InteractiveAuto PersistentComponentState PrerenderFetchTimeoutMs InteractiveWebAssembly DataGridListPageBase OnParametersSetAsync RegisterOnPersisting"},{"u":"/docs/adr/056-blazor-render-mode-strategy.html#rationale","d":"ADR-056: Blazor Render-Mode Strategy for the Web Heads","k":"Architecture Decision Records","t":"Rationale","x":"- InteractiveAuto gets both halves without asking page authors to choose. The first visit gets the Server circuit's immediate interactivity while the WASM bundle downloads in the…","i":"InteractiveServer InteractiveAuto CatalogBrowse Authorize"},{"u":"/docs/adr/056-blazor-render-mode-strategy.html#trade-offs","d":"ADR-056: Blazor Render-Mode Strategy for the Web Heads","k":"Architecture Decision Records","t":"Trade-offs","x":"- Everything shared has to run in both runtimes. The WASM-compatibility layer rule (MMCA.Common.LayerEnforcement.targets:75-88) forbids the shared UI package from touching…","i":"RendererInfo.IsInteractive AddAdditionalAssemblies DataGridListPageBase MMCA.Common.UI.Web OnAfterRenderAsync InteractiveServer InteractiveAuto CatalogBrowse AddUIShared Program.cs Routes"},{"u":"/docs/adr/056-blazor-render-mode-strategy.html#related","d":"ADR-056: Blazor Render-Mode Strategy for the Web Heads","k":"Architecture Decision Records","t":"Related","x":"ADR-022 (reads the HttpOnly session cookie during the SSR prerender pass this decision keeps enabled), ADR-027 (flows one culture through the SSR to Server to WASM sequence this…","i":"InteractiveAuto"},{"u":"/docs/adr/057-expand-contract-schema-evolution-gate.html","d":"ADR-057: Expand/Contract Schema Evolution Enforced as a CI Gate","k":"Architecture Decision Records"},{"u":"/docs/adr/057-expand-contract-schema-evolution-gate.html#status","d":"ADR-057: Expand/Contract Schema Evolution Enforced as a CI Gate","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-28). Revised 2026-08-01: the diff now fails closed in both repos (the true is gone and MMCA.Store's build-and-test checkout sets fetch-depth: 0), so the…","i":"true"},{"u":"/docs/adr/057-expand-contract-schema-evolution-gate.html#context","d":"ADR-057: Expand/Contract Schema Evolution Enforced as a CI Gate","k":"Architecture Decision Records","t":"Context","x":"ADR-030 decides who applies a migration: every service host runs DatabaseInitStrategy = Migrate and self-applies its pending EF Core migrations at startup as the sole migrator,…","i":"DatabaseInitStrategy containerapp DropColumn migrations adee5058 revision Migrate dotnet sqlcmd copy"},{"u":"/docs/adr/057-expand-contract-schema-evolution-gate.html#decision","d":"ADR-057: Expand/Contract Schema Evolution Enforced as a CI Gate","k":"Architecture Decision Records","t":"Decision","x":"Schema changes follow expand/contract, and a CI step enforces the contract half. - Expand now, contract later, as a written rule. Adding nullable columns, new tables and new…","i":"OutboxMessages InboxMessages pull_request CreateIndex DropColumn Migrations DropIndex DropTable IsDeleted base_ref release added"},{"u":"/docs/adr/057-expand-contract-schema-evolution-gate.html#rationale","d":"ADR-057: Expand/Contract Schema Evolution Enforced as a CI Gate","k":"Architecture Decision Records","t":"Rationale","x":"- Rollback is one-way for schema, so the check belongs where the drop is still cheap. The only moment a destructive migration can be reconsidered for free is the PR that adds it;…","i":"Down"},{"u":"/docs/adr/057-expand-contract-schema-evolution-gate.html#trade-offs","d":"ADR-057: Expand/Contract Schema Evolution Enforced as a CI Gate","k":"Architecture Decision Records","t":"Trade-offs","x":"- Three operations, not a model of compatibility. AlterColumn narrowing a type or flipping a column to NOT NULL, DropForeignKey, DropPrimaryKey, DropSchema, RenameColumn and a…","i":"migrationBuilder.Sql DropForeignKey DropPrimaryKey RenameColumn AlterColumn DropSchema diff main true with git"},{"u":"/docs/adr/057-expand-contract-schema-evolution-gate.html#related","d":"ADR-057: Expand/Contract Schema Evolution Enforced as a CI Gate","k":"Architecture Decision Records","t":"Related","x":"ADR-030 (decides that each service self-applies its migrations at startup, which is precisely why a rolled-back revision meets the new schema; this ADR constrains what those…"},{"u":"/docs/adr/058-runtime-conformance-suites-as-a-package.html","d":"ADR-058: Runtime Conformance Suites Shipped as a Package","k":"Architecture Decision Records"},{"u":"/docs/adr/058-runtime-conformance-suites-as-a-package.html#status","d":"ADR-058: Runtime Conformance Suites Shipped as a Package","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-28; revised 2026-08-14, 2026-08-18, and 2026-08-23)."},{"u":"/docs/adr/058-runtime-conformance-suites-as-a-package.html#context","d":"ADR-058: Runtime Conformance Suites Shipped as a Package","k":"Architecture Decision Records","t":"Context","x":"ADR-015 turned the architecture invariants into build-gating tests, and drew its own boundary explicitly: the fitness suite asserts \"structure / registration, not runtime…"},{"u":"/docs/adr/058-runtime-conformance-suites-as-a-package.html#decision","d":"ADR-058: Runtime Conformance Suites Shipped as a Package","k":"Architecture Decision Records","t":"Decision","x":"Ship the runtime conformance suites in the MMCA.Common.Testing package as abstract behavioral bases that each consuming host subclasses, and run every one of them against a host…","i":"ServiceInfoVersioningContractTestsBase SqlServerIntegrationTestFixtureBase MiddlewarePipelineOrderTestsBase MMCA.Common.Testing.Architecture ProductionHostApplicationFactory DecoratorPipelineOrderTestsBase MiddlewarePipelineOrderTests.cs ProblemDetailsContractTestsBase AssertProblemDetailsShapeAsync GracefulShutdownTestsBase AddApplicationDecorators ChangePreferencesCommand"},{"u":"/docs/adr/058-runtime-conformance-suites-as-a-package.html#rationale","d":"ADR-058: Runtime Conformance Suites Shipped as a Package","k":"Architecture Decision Records","t":"Rationale","x":"- Runtime conformance is the half ADR-015 excluded. Structural rules answer \"is the code shaped correctly\"; these suites answer \"does the composed host behave correctly\". A host…","i":"Development Production"},{"u":"/docs/adr/058-runtime-conformance-suites-as-a-package.html#trade-offs","d":"ADR-058: Runtime Conformance Suites Shipped as a Package","k":"Architecture Decision Records","t":"Trade-offs","x":"- Opt-in per host, exactly like ADR-015. The framework ships the suites; a host gets the gate only once someone writes the subclass. That is the same audit-the-inventory caveat,…","i":"CorePublicResources MinimumPathCount status title"},{"u":"/docs/adr/058-runtime-conformance-suites-as-a-package.html#related","d":"ADR-058: Runtime Conformance Suites Shipped as a Package","k":"Architecture Decision Records","t":"Related","x":"ADR-015 (the structural / registration fitness layer this complements; its stated non-goal, \"not runtime behavior\", is exactly this ADR's scope, and the two tiers ship as two…","i":"DecoratorPipelineOrderTestsBase ProblemDetailsContractTestsBase"},{"u":"/docs/adr/059-module-contract-and-composition.html","d":"ADR-059: The IModule Contract and Reflection-Based Module Composition","k":"Architecture Decision Records"},{"u":"/docs/adr/059-module-contract-and-composition.html#status","d":"ADR-059: The IModule Contract and Reflection-Based Module Composition","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-28; revised 2026-08-14)."},{"u":"/docs/adr/059-module-contract-and-composition.html#context","d":"ADR-059: The IModule Contract and Reflection-Based Module Composition","k":"Architecture Decision Records","t":"Context","x":"The framework's headline claim is that an application is built as a modular monolith and later extracted into services without rewriting business logic. ADR-008 states the…","i":"ModuleLoader"},{"u":"/docs/adr/059-module-contract-and-composition.html#decision","d":"ADR-059: The IModule Contract and Reflection-Based Module Composition","k":"Architecture Decision Records","t":"Decision","x":"Make IModule the single composition contract, discover implementations by reflection, register them in topological dependency order, and represent a disabled module by stub…","i":"DisabledSessionBookmarkValidationService AppDomain.CurrentDomain.GetAssemblies DisabledEventLiveValidationService ModuleControllerFeatureProvider DisabledUserSalesExportService DisabledProductVariantService SalesUserDataExportSection ValidateRemoteDependencies InvalidOperationException Activator.CreateInstance AddUserDataExportSection DisabledCustomerService"},{"u":"/docs/adr/059-module-contract-and-composition.html#rationale","d":"ADR-059: The IModule Contract and Reflection-Based Module Composition","k":"Architecture Decision Records","t":"Rationale","x":"- Reflection discovery keeps hosts out of the module registry business. A host calls one method and gets whatever modules its assembly graph contains; adding a module is a…","i":"RequiresDependencies RemoteDependencies appsettings.json Dependencies Modules true"},{"u":"/docs/adr/059-module-contract-and-composition.html#trade-offs","d":"ADR-059: The IModule Contract and Reflection-Based Module Composition","k":"Architecture Decision Records","t":"Trade-offs","x":"- The AppDomain scan is the fragile default and the one everybody uses. The loader's own documentation warns that the AppDomain scan sees only assemblies already loaded, so a…","i":"ModuleConformanceTestsBase ValidateRemoteDependencies Activator.CreateInstance IBookmarkCountService RegisterDisabledStubs RequiresDependencies AddTypedGrpcClient Dependencies ModuleName Complete Register Enabled"},{"u":"/docs/adr/059-module-contract-and-composition.html#related","d":"ADR-059: The IModule Contract and Reflection-Based Module Composition","k":"Architecture Decision Records","t":"Related","x":"ADR-008 (the extraction topology that consumes this model: \"a service is the monolith with one module enabled\" is a statement about ModuleLoader plus the Disabled stubs, cited…","i":"AddApplicationDecorators ModuleLoader"},{"u":"/docs/adr/060-performance-regression-gate.html","d":"ADR-060: Performance-Regression Gate (Committed Benchmark Baseline in CI)","k":"Architecture Decision Records"},{"u":"/docs/adr/060-performance-regression-gate.html#status","d":"ADR-060: Performance-Regression Gate (Committed Benchmark Baseline in CI)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-07-28). Revised 2026-08-01 (corrected the count in Trade-offs: the single ratio floor names two of the eight benchmarks, so six, not seven, are gated on…","i":"ci.yml"},{"u":"/docs/adr/060-performance-regression-gate.html#context","d":"ADR-060: Performance-Regression Gate (Committed Benchmark Baseline in CI)","k":"Architecture Decision Records","t":"Context","x":"Rubric section 12 asks for hot-path efficiency that is measured, not assumed (Website/docs-src/governance/ArchitectureEvaluationCriteria.md:355). MMCA.Common has a…","i":"IsSatisfiedBy"},{"u":"/docs/adr/060-performance-regression-gate.html#decision","d":"ADR-060: Performance-Regression Gate (Committed Benchmark Baseline in CI)","k":"Architecture Decision Records","t":"Decision","x":"Measure the hot-path suite on every code PR and verify the results against a committed baseline that carries two rule kinds: absolute allocation ceilings where the measurement is…","i":"ApplyFilters_ThreeMixedOperators IsSatisfiedBy_RecompileEachCall IsSatisfiedBy_CachedCompile allocationCeilingsBytes MMCA.Common.slnx PackageReference System.Text.Json BenchmarkDotNet MemoryDiagnoser fastBenchmark slowBenchmark Performance"},{"u":"/docs/adr/060-performance-regression-gate.html#rationale","d":"ADR-060: Performance-Regression Gate (Committed Benchmark Baseline in CI)","k":"Architecture Decision Records","t":"Rationale","x":"- A ratio is a property of the code; an absolute nanosecond count is a property of the runner. Both benchmarks in a floor run in the same process, on the same machine, in the…","i":"MemoryDiagnoser Specification TEntity TId"},{"u":"/docs/adr/060-performance-regression-gate.html#trade-offs","d":"ADR-060: Performance-Regression Gate (Committed Benchmark Baseline in CI)","k":"Architecture Decision Records","t":"Trade-offs","x":"- The Short job cannot see small latency regressions. Three warmup and three iterations (ci.yml:360) give wide confidence intervals: enough for a 1000x floor and for counting…","i":"ApplyFilters release.yml changes main push"},{"u":"/docs/adr/060-performance-regression-gate.html#related","d":"ADR-060: Performance-Regression Gate (Committed Benchmark Baseline in CI)","k":"Architecture Decision Records","t":"Related","x":"ADR-015 (structural fitness functions, which explicitly stop at structure and registration; this is their runtime-cost counterpart), ADR-038 (the other build-gating control set,…"},{"u":"/docs/adr/061-runtime-secret-management.html","d":"ADR-061: Runtime Secret Management via Key Vault References and Managed Identity","k":"Architecture Decision Records"},{"u":"/docs/adr/061-runtime-secret-management.html#status","d":"ADR-061: Runtime Secret Management via Key Vault References and Managed Identity","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-01; vault-backed configuration source recorded and citations re-anchored 2026-08-23)."},{"u":"/docs/adr/061-runtime-secret-management.html#context","d":"ADR-061: Runtime Secret Management via Key Vault References and Managed Identity","k":"Architecture Decision Records","t":"Context","x":"A Container App can hold a credential two ways: as a literal value in the app's own secrets collection, or as a reference to a Key Vault secret that the platform resolves at…","i":"DefaultAzureCredential secrets"},{"u":"/docs/adr/061-runtime-secret-management.html#decision","d":"ADR-061: Runtime Secret Management via Key Vault References and Managed Identity","k":"Architecture Decision Records","t":"Decision","x":"Every production secret lives in Azure Key Vault and reaches the app as a keyVaultUrl secret reference resolved by a user-assigned managed identity; the same identity also lets a…","i":"AddCommonKeyVaultConfiguration azureADOnlyAuthentication USE_MANAGED_IDENTITY_SQL DefaultAzureCredential useManagedIdentitySql AZURE_CLIENT_ID hasSmtpPassword IConfiguration MMCA.Templates KeyVault__Uri keyVaultUrl claude.yml"},{"u":"/docs/adr/061-runtime-secret-management.html#rationale","d":"ADR-061: Runtime Secret Management via Key Vault References and Managed Identity","k":"Architecture Decision Records","t":"Rationale","x":"- A reference has one home; a literal has as many homes as it has consumers. Three vault secrets in each repo are referenced by more than one app: Redis and the broker by all…"},{"u":"/docs/adr/061-runtime-secret-management.html#trade-offs","d":"ADR-061: Runtime Secret Management via Key Vault References and Managed Identity","k":"Architecture Decision Records","t":"Trade-offs","x":"- One identity means vault-wide read for every app that carries it. A Key Vault Secrets User grant is scoped to the vault, so any app running as the shared identity can read…","i":"AZURE_CLIENT_ID main.bicep EXTERNAL listKeys PROVIDER secrets CREATE secure unused FROM USER"},{"u":"/docs/adr/061-runtime-secret-management.html#related","d":"ADR-061: Runtime Secret Management via Key Vault References and Managed Identity","k":"Architecture Decision Records","t":"Related","x":"ADR-037 (037-field-level-encryption-at-rest.md:108-110 directs a consumer to keep the field-encryption key in Key Vault but decides no delivery mechanism, and nothing wires that…"},{"u":"/docs/adr/062-slo-alerting-as-code.html","d":"ADR-062: SLO Alerting as Code with an Alert-to-Runbook Build Gate","k":"Architecture Decision Records"},{"u":"/docs/adr/062-slo-alerting-as-code.html#status","d":"ADR-062: SLO Alerting as Code with an Alert-to-Runbook Build Gate","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-01). Revised 2026-08-18: Store's two operational extras (the outbox-dead-letter scheduled query rule and the outside-in Gateway availability web test with its…","i":"OPERATIONS.md main.bicep main"},{"u":"/docs/adr/062-slo-alerting-as-code.html#context","d":"ADR-062: SLO Alerting as Code with an Alert-to-Runbook Build Gate","k":"Architecture Decision Records","t":"Context","x":"ADR-041 standardized what the fleet emits: RED histograms off the CQRS pipeline, an outbox dead-letter counter, correlation ids, exporters, and the cost knobs that keep ingestion…"},{"u":"/docs/adr/062-slo-alerting-as-code.html#decision","d":"ADR-062: SLO Alerting as Code with an Alert-to-Runbook Build Gate","k":"Architecture Decision Records","t":"Decision","x":"Declare each consumer's SLO alerts as data in its Bicep template, materialize them as Log Analytics scheduled query rules, and make the alert-to-runbook pairing a build gate…","i":"EveryRunbookAlertSection_MapsToAProvisionedAlert SloAlertSpecs_AreDiscovered_GateIsNotVacuous ObservabilityConventionTestsBaseTests ObservabilityConventionTestsBase legacySloMetricAlertSpecs infra.OPERATIONS.md metricMeasureColumn RunbookHeadingRegex alertEmailAddress MinimumAlertSpecs infra.main.bicep ResourceAssembly"},{"u":"/docs/adr/062-slo-alerting-as-code.html#rationale","d":"ADR-062: SLO Alerting as Code with an Alert-to-Runbook Build Gate","k":"Architecture Decision Records","t":"Rationale","x":"- Alerts as data, not as portal state. One array is reviewable in a PR, diffable across environments, and re-deployable; the rules, the workbook, and the notification channel are…","i":"enabled false"},{"u":"/docs/adr/062-slo-alerting-as-code.html#trade-offs","d":"ADR-062: SLO Alerting as Code with an Alert-to-Runbook Build Gate","k":"Architecture Decision Records","t":"Trade-offs","x":"- It is a text gate over IaC, not a check against deployed state. The base matches literal anchors and regexes in the template and headings in markdown. It proves the two files…","i":"environmentName sloAlertSpecs metricAlerts prefix env key"},{"u":"/docs/adr/062-slo-alerting-as-code.html#related","d":"ADR-062: SLO Alerting as Code with an Alert-to-Runbook Build Gate","k":"Architecture Decision Records","t":"Related","x":"ADR-041 (the telemetry this alerts on top of: it defines emission, instrumentation and cost knobs and stops before thresholds, severities and runbooks), ADR-009 (recovery…"},{"u":"/docs/adr/063-accessibility-conformance-gate.html","d":"ADR-063: WCAG 2.1 AA Accessibility as a Shipped Test Contract and CI Gate","k":"Architecture Decision Records"},{"u":"/docs/adr/063-accessibility-conformance-gate.html#status","d":"ADR-063: WCAG 2.1 AA Accessibility as a Shipped Test Contract and CI Gate","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-01). Revised 2026-08-14: refreshed the E2ETestBase helper line anchors (explanatory comments were added above ScanGridAsync), the two consumer suite scan counts…","i":"ScanGridAsync E2ETestBase"},{"u":"/docs/adr/063-accessibility-conformance-gate.html#context","d":"ADR-063: WCAG 2.1 AA Accessibility as a Shipped Test Contract and CI Gate","k":"Architecture Decision Records","t":"Context","x":"Accessibility was documented before it was enforced. The narrative guide (common-ACCESSIBILITY.md, rubric section 21) named WCAG 2.1 AA as the target for the shared…","i":"MMCA.Common.UI"},{"u":"/docs/adr/063-accessibility-conformance-gate.html#decision","d":"ADR-063: WCAG 2.1 AA Accessibility as a Shipped Test Contract and CI Gate","k":"Architecture Decision Records","t":"Decision","x":"Ship WCAG 2.1 AA as a named, versioned test contract in MMCA.Common.Testing.E2E, assert it from the package's own workflow bases, and wire it as a required merge check and a…","i":"AxeOptions.Wcag21AaExceptMudPagerCombobox AssertNoAccessibilityViolationsAsync AccessibilityViolationException PasswordResetTestsBase.cs MMCA.Common.Testing.E2E ProfileManagementTests AxeOptions.Wcag21Aa PrimaryContrastText WarningContrastText GalleryAxeTestBase ErrorContrastText AxeRunOptions"},{"u":"/docs/adr/063-accessibility-conformance-gate.html#rationale","d":"ADR-063: WCAG 2.1 AA Accessibility as a Shipped Test Contract and CI Gate","k":"Architecture Decision Records","t":"Rationale","x":"- A named constant is the contract. Putting the rule set in a shipped, referenced symbol rather than in each repo's test setup means \"what WCAG 2.1 AA means here\" has exactly one…","i":"ProfileManagementTestsBase UserRegistrationTestsBase PasswordResetTestsBase UserLoginTestsBase"},{"u":"/docs/adr/063-accessibility-conformance-gate.html#trade-offs","d":"ADR-063: WCAG 2.1 AA Accessibility as a Shipped Test Contract and CI Gate","k":"Architecture Decision Records","t":"Trade-offs","x":"- Best-practice rules are out of scope, deliberately. Findings axe would classify as best practice (and anything WCAG AAA) are not measured at all, so the gate can be green on a…","i":"Wcag21AaExceptMudPagerCombobox AccessibilityTests ScanGridAsync skipped success deploy"},{"u":"/docs/adr/063-accessibility-conformance-gate.html#related","d":"ADR-063: WCAG 2.1 AA Accessibility as a Shipped Test Contract and CI Gate","k":"Architecture Decision Records","t":"Related","x":"ADR-015 (architecture fitness functions: the structural tier this parallels at the browser tier, and the same invariant-over-discipline posture), ADR-058 (runtime conformance…"},{"u":"/docs/adr/064-deploy-recency-gates.html","d":"ADR-064: Deploy Preconditions as Proof-of-Recency Gates","k":"Architecture Decision Records"},{"u":"/docs/adr/064-deploy-recency-gates.html#status","d":"ADR-064: Deploy Preconditions as Proof-of-Recency Gates","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-01). Revised 2026-08-07: the MMCA.Helpdesk workflow inventory below was corrected (it also carries release-templates.yml, and its ci.yml runs two jobs, not…","i":"ci.yml"},{"u":"/docs/adr/064-deploy-recency-gates.html#context","d":"ADR-064: Deploy Preconditions as Proof-of-Recency Gates","k":"Architecture Decision Records","t":"Context","x":"A production rollout in both deployed apps waits on a list of jobs in deploy.needs (MMCA.ADC/.github/workflows/deploy.yml:866, MMCA.Store/.github/workflows/deploy.yml:862). Most…","i":"deploy.needs"},{"u":"/docs/adr/064-deploy-recency-gates.html#decision","d":"ADR-064: Deploy Preconditions as Proof-of-Recency Gates","k":"Architecture Decision Records","t":"Decision","x":"A production deploy is blocked not only on green tests but on proof of recency for out-of-band verification: three gates assert that a real drill, a real load run and a real…","i":"skip_freshness_gates skip_justification github.event_name workflow_dispatch FRESHNESS_DAYS workflow_runs deploy.needs release.yml foundation updated_at cancelled contents"},{"u":"/docs/adr/064-deploy-recency-gates.html#rationale","d":"ADR-064: Deploy Preconditions as Proof-of-Recency Gates","k":"Architecture Decision Records","t":"Rationale","x":"- A proof with no expiry date is documentation, not a control. ADR-009 already required the drill to be recorded, and recording it was the honest half of the problem; a record…"},{"u":"/docs/adr/064-deploy-recency-gates.html#trade-offs","d":"ADR-064: Deploy Preconditions as Proof-of-Recency Gates","k":"Architecture Decision Records","t":"Trade-offs","x":"- An unrelated stale proof blocks an unrelated deploy. A one-line hotfix does not ship when the monthly k6 cron did not fire, and the failure surfaces after merge: the gate job…","i":"deploy"},{"u":"/docs/adr/064-deploy-recency-gates.html#related","d":"ADR-064: Deploy Preconditions as Proof-of-Recency Gates","k":"Architecture Decision Records","t":"Related","x":"ADR-009 (states the recovery objectives and requires that a restore be drilled and recorded; this record decides that a deploy is blocked on how recently that drill, and the…"},{"u":"/docs/adr/065-scaffolding-templates.html","d":"ADR-065: Scaffolding templates derived from the reference app","k":"Architecture Decision Records","x":"Status: Accepted (2026-08-02). Revised 2026-08-07: the staged analyzer delta relaxes three rules rather than one; mmca-module prints seven wire-ups rather than five, and a…","i":"Directory.Packages.props"},{"u":"/docs/adr/065-scaffolding-templates.html#context","d":"ADR-065: Scaffolding templates derived from the reference app","k":"Architecture Decision Records","t":"Context","x":"Build by hand is accurate and complete, and phases 1 through 6 of it are transcription work (common-BUILD-BY-HAND.md:96 through :1049). Its own instruction for the load-bearing…","i":"AddApplicationDecorators Directory.Packages.props Directory.Build.targets Directory.Build.props launchSettings.json IArchitectureMap MMCA.Templates editorconfig nuget.config global.json install WaitFor"},{"u":"/docs/adr/065-scaffolding-templates.html#decision","d":"ADR-065: Scaffolding templates derived from the reference app","k":"Architecture Decision Records","t":"Decision","x":"Ship a dotnet new template pack, MMCA.Templates, containing four templates: The template content is the MMCA.Helpdesk reference application itself, staged at pack time.…","i":"IntegrationEventContractTestsBase IntegrationEventContractTests SQLServerMigrationsAssembly WithSQLServerDataSource TreatWarningsAsErrors AddErrorResources appsettings.json IArchitectureMap Contoso.Support RequesterUserId MMCA.Templates MMCA.Helpdesk"},{"u":"/docs/adr/065-scaffolding-templates.html#rationale","d":"ADR-065: Scaffolding templates derived from the reference app","k":"Architecture Decision Records","t":"Rationale","x":"Deriving from the seed rather than maintaining a template tree is the whole design. A hand-maintained copy of a 12-project solution drifts within one release, and drift in a…","i":"MMCA.Common.Templates MMCA.Templates MMCA.Helpdesk sourceName Helpdesk install Tickets dotnet Ticket new"},{"u":"/docs/adr/065-scaffolding-templates.html#trade-offs","d":"ADR-065: Scaffolding templates derived from the reference app","k":"Architecture Decision Records","t":"Trade-offs","x":"- Two documented one-time fixups in every generated app, above (one of them covering all three relaxed rules). The alternative to the SA1210 half of the delta was moving every…","i":"IntegrationEventContractTestsBase IntegrationEventContractTests MMCA.Common copyOnly dotnet SA1210 using Fact new"},{"u":"/docs/adr/066-broker-transport-selection.html","d":"ADR-066: Broker Transport Selection and Dev/Prod Parity","k":"Architecture Decision Records"},{"u":"/docs/adr/066-broker-transport-selection.html#status","d":"ADR-066: Broker Transport Selection and Dev/Prod Parity","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-07). Revised 2026-08-14 (source citations re-anchored; the ADC AppHost comment that used to say no WithBroker() was wired has been corrected in code, so the…","i":"WithBroker"},{"u":"/docs/adr/066-broker-transport-selection.html#context","d":"ADR-066: Broker Transport Selection and Dev/Prod Parity","k":"Architecture Decision Records","t":"Context","x":"ADR-003 decides that integration events leave an aggregate through the outbox and are published by OutboxProcessor via IMessageBus, and it settles the dispatch question…","i":"OutboxProcessor IMessageBus"},{"u":"/docs/adr/066-broker-transport-selection.html#decision","d":"ADR-066: Broker Transport Selection and Dev/Prod Parity","k":"Architecture Decision Records","t":"Decision","x":"Keep one IMessageBus abstraction with a three-value transport selector, choose the value at the deployment edge (never in application code), configure both broker transports…","i":"Bus.Factory.CreateUsingAzureServiceBus ResolveBrokerConnectionString MessageBus__ConnectionString ConnectionStrings__rabbitmq RootManageSharedAccessKey ConfigureBrokerTransport EnableDelayedRedelivery RetryMaxIntervalSeconds RetryMinIntervalSeconds builder.Configuration UseDelayedRedelivery UsingAzureServiceBus"},{"u":"/docs/adr/066-broker-transport-selection.html#rationale","d":"ADR-066: Broker Transport Selection and Dev/Prod Parity","k":"Architecture Decision Records","t":"Rationale","x":"- The transport is a deployment fact, so it lives at the deployment edge. The only difference between a laptop and production is two environment variables set by the AppHost or…","i":"Listen Send"},{"u":"/docs/adr/066-broker-transport-selection.html#trade-offs","d":"ADR-066: Broker Transport Selection and Dev/Prod Parity","k":"Architecture Decision Records","t":"Trade-offs","x":"- Two brokers means two behaviors to keep aligned. Configuration parity is enforced by one code path, but the products still differ (Service Bus supports delayed redelivery…","i":"MessageBus__Provider ConfigureEndpoints WithBroker Manage rabbit"},{"u":"/docs/adr/066-broker-transport-selection.html#related","d":"ADR-066: Broker Transport Selection and Dev/Prod Parity","k":"Architecture Decision Records","t":"Related","x":"ADR-003 (the outbox that feeds IMessageBus; this ADR picks the transport underneath it), ADR-016 (the MassTransit v8 pin the emulator tier must work within, which is why the…","i":"IMessageBus Host"},{"u":"/docs/adr/067-ui-module-shell-composition.html","d":"ADR-067: Shared Blazor Application Shell and IUIModule Composition","k":"Architecture Decision Records"},{"u":"/docs/adr/067-ui-module-shell-composition.html#status","d":"ADR-067: Shared Blazor Application Shell and IUIModule Composition","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-07)."},{"u":"/docs/adr/067-ui-module-shell-composition.html#context","d":"ADR-067: Shared Blazor Application Shell and IUIModule Composition","k":"Architecture Decision Records","t":"Context","x":"ADR-059 decided how a module plugs into the server: an IModule implementation is discovered by reflection, registered in topological order, and a host composes an application out…","i":"MMCA.Common.UI IModule Routes App"},{"u":"/docs/adr/067-ui-module-shell-composition.html#decision","d":"ADR-067: Shared Blazor Application Shell and IUIModule Composition","k":"Architecture Decision Records","t":"Decision","x":"Ship the application shell in the framework package and let each module plug into it by implementing IUIModule, resolved from DI as IEnumerable . - The contract is four members,…","i":"AdditionalAssemblies AppBarComponentTypes LayoutComponentTypes AuthorizeRouteView MapRazorComponents DynamicComponent UIModules.Select RedirectToLogin DeviceUIModule RequiredClaim TitleResource AddSingleton"},{"u":"/docs/adr/067-ui-module-shell-composition.html#rationale","d":"ADR-067: Shared Blazor Application Shell and IUIModule Composition","k":"Architecture Decision Records","t":"Rationale","x":"- One composition model across both tiers. A module already declares its server-side surface through IModule (ADR-059); declaring its UI surface through IUIModule means \"add a…","i":"AppBarComponentTypes LayoutComponentTypes AuthorizeView Components IUIModule IModule NavMenu"},{"u":"/docs/adr/067-ui-module-shell-composition.html#trade-offs","d":"ADR-067: Shared Blazor Application Shell and IUIModule Composition","k":"Architecture Decision Records","t":"Trade-offs","x":"- Assembly is required even when it carries no route. A host-only module that contributes only a layout component still has to return an assembly, which then joins…","i":"AddAdditionalAssemblies AdditionalAssemblies AuthorizeRouteView RequiredClaim MauiUIModule RequiredRole Program.cs IUIModule Assembly NavItems NavMenu page"},{"u":"/docs/adr/067-ui-module-shell-composition.html#related","d":"ADR-067: Shared Blazor Application Shell and IUIModule Composition","k":"Architecture Decision Records","t":"Related","x":"ADR-059 (the server-side IModule contract this mirrors in the presentation layer), ADR-056 (the render-mode strategy for the web heads, which decides how these components render…","i":"TitleResource IModule"},{"u":"/docs/adr/068-value-objects-as-validated-primitives.html","d":"ADR-068: Value Objects as Validated Domain Primitives","k":"Architecture Decision Records"},{"u":"/docs/adr/068-value-objects-as-validated-primitives.html#status","d":"ADR-068: Value Objects as Validated Domain Primitives","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-07)."},{"u":"/docs/adr/068-value-objects-as-validated-primitives.html#context","d":"ADR-068: Value Objects as Validated Domain Primitives","k":"Architecture Decision Records","t":"Context","x":"A domain model has two kinds of small type: the identity of a thing, and a value the thing carries. ADR-048 recorded the identity half: identifiers stay primitives named through…","i":"decimal string"},{"u":"/docs/adr/068-value-objects-as-validated-primitives.html#decision","d":"ADR-068: Value Objects as Validated Domain Primitives","k":"Architecture Decision Records","t":"Decision","x":"Model a domain value that carries an invariant as an immutable record value object with a Result-returning factory; keep identifiers primitive (ADR-048). - One abstract record…","i":"PhoneNumberInvariants.EnsurePhoneNumberIsValid ArchitectureRules.DomainFactoriesReturnResult AddressInvariants.EnsureAddressLine1IsValid EmailInvariants.EnsureEmailIsValid NullablePhoneNumberValueConverter NullableEmailValueConverter EmailInvariants.MaxLength PhoneNumberValueConverter DataContractSerializer GetEqualityComponents DateTimeRange.Create ProductVariant.Price"},{"u":"/docs/adr/068-value-objects-as-validated-primitives.html#rationale","d":"ADR-068: Value Objects as Validated Domain Primitives","k":"Architecture Decision Records","t":"Rationale","x":"- The invariant belongs to the type, not to every caller. A string email can be validated in one handler and not the next; an Email cannot exist unvalidated, because the only…","i":"NullReferenceException Currency.None Money.Zero OwnsMoney record Result string Email Money"},{"u":"/docs/adr/068-value-objects-as-validated-primitives.html#trade-offs","d":"ADR-068: Value Objects as Validated Domain Primitives","k":"Architecture Decision Records","t":"Trade-offs","x":"- The pattern is not uniformly applied. Only three of the seven types have a companion Invariants class; the rest inline their checks. Only Money has a shipped owned-type helper,…","i":"InvalidOperationException DateTimeRange Currency.All PhoneNumber DateRange Money.Add operator Address OwnsOne Create Result string"},{"u":"/docs/adr/068-value-objects-as-validated-primitives.html#related","d":"ADR-068: Value Objects as Validated Domain Primitives","k":"Architecture Decision Records","t":"Related","x":"ADR-048 (the deliberate opposite call for identifiers: primitives behind aliases, wrapper structs rejected, because identifiers cross process boundaries constantly and carry no…","i":"Create Result"},{"u":"/docs/adr/069-shared-data-protection-key-ring.html","d":"ADR-069: Shared DataProtection Key Ring for Scaled-Out Hosts","k":"Architecture Decision Records"},{"u":"/docs/adr/069-shared-data-protection-key-ring.html#status","d":"ADR-069: Shared DataProtection Key Ring for Scaled-Out Hosts","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-07). Updated 2026-08-14: Store's adoption has landed and is live (its own dedicated storage account, gated on dataProtectionStorageReady), and the ADC call-site…","i":"dataProtectionStorageReady AddServiceDefaults"},{"u":"/docs/adr/069-shared-data-protection-key-ring.html#context","d":"ADR-069: Shared DataProtection Key Ring for Scaled-Out Hosts","k":"Architecture Decision Records","t":"Context","x":"ASP.NET Core's DataProtection default keeps the key ring in memory, per process. That is correct for a single-process host and wrong for a scaled-out one: every replica generates…","i":"DefaultAzureCredential maxReplicas"},{"u":"/docs/adr/069-shared-data-protection-key-ring.html#decision","d":"ADR-069: Shared DataProtection Key Ring for Scaled-Out Hosts","k":"Architecture Decision Records","t":"Decision","x":"Add one opt-in registration call, AddCommonDataProtection, that persists the key ring to a single Azure blob so every replica of a host shares one ring…","i":"Azure.Extensions.AspNetCore.DataProtection.Blobs KeyManagementOptions.XmlRepository System.Security.Cryptography.Xml AddCommonKeyVaultConfiguration DataProtection__BlobStorageUri DataProtection__KeyVaultKeyUri grantDataProtectionStorageRole PersistKeysToAzureBlobStorage ProtectKeysWithAzureKeyVault dataProtectionStorageReady AddCommonDataProtection IDataProtectionProvider"},{"u":"/docs/adr/069-shared-data-protection-key-ring.html#rationale","d":"ADR-069: Shared DataProtection Key Ring for Scaled-Out Hosts","k":"Architecture Decision Records","t":"Rationale","x":"- The key ring is the smallest thing that has to be shared. Sticky sessions would paper over the symptom while making a replica restart a mass sign-out, and a shared cache would…"},{"u":"/docs/adr/069-shared-data-protection-key-ring.html#trade-offs","d":"ADR-069: Shared DataProtection Key Ring for Scaled-Out Hosts","k":"Architecture Decision Records","t":"Trade-offs","x":"- The key ring is not encrypted at rest today. Gate 2 is implemented but configured nowhere, so the ring is protected by the container being private and the account grant being…","i":"AddCommonDataProtection AZURE_CLIENT_ID MMCA.ADC"},{"u":"/docs/adr/069-shared-data-protection-key-ring.html#related","d":"ADR-069: Shared DataProtection Key Ring for Scaled-Out Hosts","k":"Architecture Decision Records","t":"Related","x":"ADR-022 (the browser session cookies whose decryption this makes replica-independent, together with the antiforgery tokens the SSR pages mint), ADR-008 (the multi-host topology…","i":"DefaultAzureCredential"},{"u":"/docs/adr/070-fail-fast-configuration-contract.html","d":"ADR-070: Fail-Fast Configuration Contract","k":"Architecture Decision Records"},{"u":"/docs/adr/070-fail-fast-configuration-contract.html#status","d":"ADR-070: Fail-Fast Configuration Contract","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-07). Revised 2026-08-14 (source citations re-anchored; the consumer-repo facade claim narrowed to production code, with the controller-test exception recorded).…","i":"IValidateOptions IOptions"},{"u":"/docs/adr/070-fail-fast-configuration-contract.html#context","d":"ADR-070: Fail-Fast Configuration Contract","k":"Architecture Decision Records","t":"Context","x":"Every host in the workspace reads a dozen or more configuration sections: connection strings, SMTP, JWT key material, outbox tuning, message-bus provider, module enablement,…","i":"IOptions"},{"u":"/docs/adr/070-fail-fast-configuration-contract.html#decision","d":"ADR-070: Fail-Fast Configuration Contract","k":"Architecture Decision Records","t":"Decision","x":"Bind every settings section through a validating chain that runs at startup, and expose a settings type through a read-only interface when it must be read above Infrastructure. -…","i":"CreateCheckoutSessionCommandValidator GatewayRateLimitingSettings ForgotPasswordHandlerBase IConnectionStringSettings IPushNotificationSettings CheckoutRedirectSettings ConnectionStringSettings PushNotificationSettings RecordRoomCheckInHandler AddCommonAuthentication LoginProtectionSettings SecurityHeadersSettings"},{"u":"/docs/adr/070-fail-fast-configuration-contract.html#rationale","d":"ADR-070: Fail-Fast Configuration Contract","k":"Architecture Decision Records","t":"Rationale","x":"- A boot failure is cheaper than a first-use failure. A host that will not start is caught by the deployment, by a local dotnet run, or by CI. A host that starts and fails on the…","i":"Microsoft.Extensions.Options ValidateDataAnnotations EntityControllerBase IApplicationSettings ApplicationSettings IValidatableObject RepositoryFactory ValidateOnStart JwtSettings IOptions dotnet init"},{"u":"/docs/adr/070-fail-fast-configuration-contract.html#trade-offs","d":"ADR-070: Fail-Fast Configuration Contract","k":"Architecture Decision Records","t":"Trade-offs","x":"- Nothing enforces it. There is no architecture fitness test asserting that a new AddOptions call carries ValidateDataAnnotations().ValidateOnStart(). The uniformity above is…","i":"TenancySettingsValidator ValidateDataAnnotations IDataSourceResolver IValidatableObject IValidateOptions IOptionsMonitor TenancySettings ValidateOnStart JwtSettings AddOptions IOptions Value"},{"u":"/docs/adr/070-fail-fast-configuration-contract.html#related","d":"ADR-070: Fail-Fast Configuration Contract","k":"Architecture Decision Records","t":"Related","x":"ADR-025 (startup warm-up and readiness gating: this contract decides what happens before a host reaches that machinery), ADR-031 (feature flags read from configuration, whose…"},{"u":"/docs/adr/071-barcode-scanning-and-qr-display.html","d":"ADR-071: Device Capability Pattern for Barcode Scanning and QR Display","k":"Architecture Decision Records"},{"u":"/docs/adr/071-barcode-scanning-and-qr-display.html#status","d":"ADR-071: Device Capability Pattern for Barcode Scanning and QR Display","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-12). Amended 2026-08-13: the composition-time string trade-off below was resolved in v1.147.0 by a deferred-resolution overload; see the updated trade-off…"},{"u":"/docs/adr/071-barcode-scanning-and-qr-display.html#context","d":"ADR-071: Device Capability Pattern for Barcode Scanning and QR Display","k":"Architecture Decision Records","t":"Context","x":"ADC's badge check-in feature (ADR-072) needs two things that look like one thing: an attendee's device has to show a QR code, and an organizer's device has to read one. They are…","i":"AddDeviceCapabilityDefaults NSCameraUsageDescription MMCA.Common.UI System.Drawing AddUIShared CAMERA"},{"u":"/docs/adr/071-barcode-scanning-and-qr-display.html#decision","d":"ADR-071: Device Capability Pattern for Barcode Scanning and QR Display","k":"Architecture Decision Records","t":"Decision","x":"Split the feature by what it actually depends on: QR display ships as a shared component, barcode scanning ships as an ADR-042 capability whose native half is opt-in per head. -…","i":"AddDeviceCapabilityDefaults DeviceInfo.Current.Platform MauiBarcodeScannerService NullBarcodeScannerService UseMauiDeviceCapabilities Permissions.RequestAsync ZXing.Net.Maui.Controls IBarcodeScannerService QrErrorCorrectionLevel ScanOnMainThreadAsync TaskCompletionSource MMCA.Common.UI.Maui"},{"u":"/docs/adr/071-barcode-scanning-and-qr-display.html#rationale","d":"ADR-071: Device Capability Pattern for Barcode Scanning and QR Display","k":"Architecture Decision Records","t":"Rationale","x":"- Rendering a QR is not a device concern, so making it one would have been ceremony. As a capability it would have needed an interface, a null fallback and a native override for…","i":"UseMauiDeviceCapabilities MMCA.Common.UI PngByteQRCode IsSupported MauiProgram null try"},{"u":"/docs/adr/071-barcode-scanning-and-qr-display.html#trade-offs","d":"ADR-071: Device Capability Pattern for Barcode Scanning and QR Display","k":"Architecture Decision Records","t":"Trade-offs","x":"- The scan page's strings were resolved at composition, not per call (resolved in v1.147.0). As shipped in v1.145.0, cancelText and cameraDescription were captured into the…","i":"UseCommonBarcodeScanner cameraDescription OnParametersSet MMCA.Common.UI IsSupported QrCodeImage cancelText QRCoder string catch false Func"},{"u":"/docs/adr/071-barcode-scanning-and-qr-display.html#related","d":"ADR-071: Device Capability Pattern for Barcode Scanning and QR Display","k":"Architecture Decision Records","t":"Related","x":"ADR-042 (the capability pattern this extends: contract in MMCA.Common.UI, native implementation in MMCA.Common.UI.Maui, override after AddUIShared), ADR-072 (the ADC feature that…","i":"MMCA.Common.UI.Maui MMCA.Common.UI AddUIShared"},{"u":"/docs/adr/072-qr-badge-check-in-and-points.html","d":"ADR-072: QR Badge Check-In and Points Gamification in ADC","k":"Architecture Decision Records"},{"u":"/docs/adr/072-qr-badge-check-in-and-points.html#status","d":"ADR-072: QR Badge Check-In and Points Gamification in ADC","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-13). Amended (2026-08-14): ADC shipped two attendee-self-recorded scan surfaces (sponsor booth visits and room self check-in), a third CheckInScope, a sixth…","i":"CheckInScope"},{"u":"/docs/adr/072-qr-badge-check-in-and-points.html#context","d":"ADR-072: QR Badge Check-In and Points Gamification in ADC","k":"Architecture Decision Records","t":"Context","x":"ADC wanted two conference-day capabilities that turn out to be one mechanism. Organizers want to know who actually attended which session, which the schedule cannot tell them: a…"},{"u":"/docs/adr/072-qr-badge-check-in-and-points.html#decision","d":"ADR-072: QR Badge Check-In and Points Gamification in ADC","k":"Architecture Decision Records","t":"Decision","x":"AttendeeBadge (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Badges/AttendeeBadge.cs:17-24) is one row per user holding a single Guid Credential, minted on first…","i":"CheckInInvariants.EnsureTargetMatchesScope CheckInSettings.RoomCheckInGraceMinutes EngagementPermissions.CheckInManage CheckInProcessor.FindExistingAsync EngagementFeatures.SponsorVisits EngagementPointsEntryExportItem PointsActivityType.SponsorVisit EngagementFeatures.RoomCheckIn user_engagement_export.proto EngagementCheckInExportItem Engagement.SponsorVisits leaderboard_display_name"},{"u":"/docs/adr/072-qr-badge-check-in-and-points.html#rationale","d":"ADR-072: QR Badge Check-In and Points Gamification in ADC","k":"Architecture Decision Records","t":"Rationale","x":"- An opaque credential makes the server the only interpreter. A JWT or HMAC badge would verify offline, but the scanning device is online by necessity (it has to write a check-in…","i":"SessionCheckIn EventCheckIn Regenerate"},{"u":"/docs/adr/072-qr-badge-check-in-and-points.html#trade-offs","d":"ADR-072: QR Badge Check-In and Points Gamification in ADC","k":"Architecture Decision Records","t":"Trade-offs","x":"- The badge credential is a bearer value. Anyone who photographs an attendee's screen can be checked in as that attendee. The mitigations are that a badge scan is organizer-side,…","i":"DuplicateKeyDetection.IsDuplicateKey SetLeaderboardParticipationHandler GetLeaderboardHandler AttendeeCheckedIn Engagement.Points SessionFeedback SessionCheckIn activity_type IFeatureGated PointsAwarder QuestionAsked FeatureGate"},{"u":"/docs/adr/072-qr-badge-check-in-and-points.html#related","d":"ADR-072: QR Badge Check-In and Points Gamification in ADC","k":"Architecture Decision Records","t":"Related","x":"ADR-071 (the framework halves this consumes: the QR component on /my-badge and the scanner capability behind /check-in), ADR-003 (the outbox path AttendeeCheckedIn and the two…","i":"AttendeeCheckedIn EraseDisplayName"},{"u":"/docs/adr/073-multi-tenancy-model.html","d":"ADR-073: Multi-Tenancy (Shared-Schema Query Filter plus DB-per-Tenant Routing)","k":"Architecture Decision Records"},{"u":"/docs/adr/073-multi-tenancy-model.html#status","d":"ADR-073: Multi-Tenancy (Shared-Schema Query Filter plus DB-per-Tenant Routing)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-13). The implementation lands in the MMCA.Common enterprise capability wave release, alongside the scheduler, audit trail, DSAR export, and CSV export work. It…","i":"MiddlewarePipelineOrderTestsBase MiddlewarePipelineStepNames DesignTimeDbContextHelper MiddlewarePipelineBuilder ApplicationDbContext IgnoreQueryFilters EFReadRepository AddMultiTenancy configuration ITenantEntity OnConfiguring GetService"},{"u":"/docs/adr/073-multi-tenancy-model.html#context","d":"ADR-073: Multi-Tenancy (Shared-Schema Query Filter plus DB-per-Tenant Routing)","k":"Architecture Decision Records","t":"Context","x":"MMCA.Common already partitions data along two axes and neither of them is a tenant. ADR-006 partitions by source name (every entity resolves to a DataSourceKey(Engine, Name),…","i":"ApplySoftDeleteFilters SoftDeleteFilterName modelBuilder.Entity OnModelCreating HasQueryFilter DataSourceKey OnConfiguring TenantId clrType Engine filter Where"},{"u":"/docs/adr/073-multi-tenancy-model.html#decision","d":"ADR-073: Multi-Tenancy (Shared-Schema Query Filter plus DB-per-Tenant Routing)","k":"Architecture Decision Records","t":"Decision","x":"Ship shared-schema tenancy as a second named query filter, with per-tenant database routing as a configuration override on the same source key, both opt-in and both inert until a…","i":"CrossTenantWriteException.ForUnresolvedTenant MiddlewarePipelineBuilder.CreateDefault IPhysicalDbContextFactory.Create MiddlewarePipelineOrderTestsBase CosmosDbContext.OnModelCreating TenantSaveChangesInterceptor UseCommonMiddlewarePipeline TenantResolutionMiddleware CrossTenantWriteException DesignTimeDbContextHelper ITenantContext.SetTenant CachingCommandDecorator"},{"u":"/docs/adr/073-multi-tenancy-model.html#rationale","d":"ADR-073: Multi-Tenancy (Shared-Schema Query Filter plus DB-per-Tenant Routing)","k":"Architecture Decision Records","t":"Rationale","x":"- A query filter is the only place the rule cannot be forgotten. Per-handler Where clauses are correct until the tenth handler, and the tenth handler is a data leak rather than a…","i":"IgnoreQueryFilters DataSourceKey ICacheService RequireTenant Where"},{"u":"/docs/adr/073-multi-tenancy-model.html#trade-offs","d":"ADR-073: Multi-Tenancy (Shared-Schema Query Filter plus DB-per-Tenant Routing)","k":"Architecture Decision Records","t":"Trade-offs","x":"- Reads are on discipline where writes are on an invariant. A consumer calling EF's own parameterless IgnoreQueryFilters() on a raw Table surface drops the tenant filter along…","i":"ApplicationLayer_DoesNotUseRawQueryableSurfaces DefaultSqlServerDbContextFactory TenantSaveChangesInterceptor IgnoreQueryFilters ICacheService ITenantEntity tenant_id Table"},{"u":"/docs/adr/073-multi-tenancy-model.html#related","d":"ADR-073: Multi-Tenancy (Shared-Schema Query Filter plus DB-per-Tenant Routing)","k":"Architecture Decision Records","t":"Related","x":"ADR-006 (the source-name axis this composes with: an override re-points a DataSourceKey without changing it, and the per-source outbox this record drains once per tenant),…","i":"IgnoreQueryFilters CosmosDbContext TenancySettings DataSourceKey tenantId TenantId string"},{"u":"/docs/adr/074-recurring-job-scheduler.html","d":"ADR-074: Recurring Job Scheduler (Persistent Cron Jobs on the Outbox Claim-Lease Pattern)","k":"Architecture Decision Records"},{"u":"/docs/adr/074-recurring-job-scheduler.html#status","d":"ADR-074: Recurring Job Scheduler (Persistent Cron Jobs on the Outbox Claim-Lease Pattern)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-13; revised 2026-08-14, 2026-08-18). The implementation lands in the MMCA.Common \"enterprise capability wave\" release and is opt-in: a host calls…","i":"AddScheduledJobs configuration"},{"u":"/docs/adr/074-recurring-job-scheduler.html#context","d":"ADR-074: Recurring Job Scheduler (Persistent Cron Jobs on the Outbox Claim-Lease Pattern)","k":"Architecture Decision Records","t":"Context","x":"The framework had two kinds of background work and neither of them is a schedule. OutboxProcessor…","i":"PeriodicBackgroundService OutboxProcessor"},{"u":"/docs/adr/074-recurring-job-scheduler.html#decision","d":"ADR-074: Recurring Job Scheduler (Persistent Cron Jobs on the Outbox Claim-Lease Pattern)","k":"Architecture Decision Records","t":"Decision","x":"A persistent job store plus a single-runner claim lease, reusing the exact idiom the outbox proved. The outbox claims a batch with an ExecuteUpdateAsync that sets LockedUntil and…","i":"DesignTimeDbContextOptions.EnableScheduler ScheduledJobOverrideSettings.Cron DesignTimeDbContextHelper PeriodicBackgroundService Directory.Packages.props EnsurePermissionRegistry ValidateDataAnnotations PollingIntervalSeconds SchedulerSettings.Jobs SyncRegistrationsAsync ResolveCronExpression AuditTrailCleanupJob"},{"u":"/docs/adr/074-recurring-job-scheduler.html#rationale","d":"ADR-074: Recurring Job Scheduler (Persistent Cron Jobs on the Outbox Claim-Lease Pattern)","k":"Architecture Decision Records","t":"Rationale","x":"- The lease is already proven under production load. Multi-replica correctness for recurring work is the hard part, and it was solved once for the outbox: an atomic claim update,…","i":"AddScheduledJobs IUnitOfWork LastRunOn NextRunOn DateTime"},{"u":"/docs/adr/074-recurring-job-scheduler.html#trade-offs","d":"ADR-074: Recurring Job Scheduler (Persistent Cron Jobs on the Outbox Claim-Lease Pattern)","k":"Architecture Decision Records","t":"Trade-offs","x":"- A polling loop is not a real-time scheduler. Worst-case start lag is one polling interval, 30 seconds at the default, so sub-minute precision is not on offer. A job that must…","i":"LeaseSeconds"},{"u":"/docs/adr/074-recurring-job-scheduler.html#related","d":"ADR-074: Recurring Job Scheduler (Persistent Cron Jobs on the Outbox Claim-Lease Pattern)","k":"Architecture Decision Records","t":"Related","x":"ADR-003 (the outbox whose claim-lease idiom and smart wait this reuses verbatim, and whose at-least-once posture it inherits along with the idempotency obligation on job bodies),…","i":"SchedulerSettings SchedulerMetrics OutboxMetrics"},{"u":"/docs/adr/075-audit-trail.html","d":"ADR-075: Audit Trail (Same-Transaction Field-Level Change History)","k":"Architecture Decision Records"},{"u":"/docs/adr/075-audit-trail.html#status","d":"ADR-075: Audit Trail (Same-Transaction Field-Level Change History)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-13; corrected 2026-08-14: the adoption sweep and the ApplicationDbContext line citations). The implementation lands in the MMCA.Common \"enterprise capability…","i":"ApplicationDbContext IAuditedEntity AddAuditTrail configuration"},{"u":"/docs/adr/075-audit-trail.html#context","d":"ADR-075: Audit Trail (Same-Transaction Field-Level Change History)","k":"Architecture Decision Records","t":"Context","x":"The framework already answers \"who touched this row last\". Every AuditableBaseEntity carries CreatedOn/By and LastModifiedOn/By, stamped by AuditSaveChangesInterceptor on the way…","i":"AuditSaveChangesInterceptor AuditableBaseEntity SaveChangesAsync LastModifiedBy"},{"u":"/docs/adr/075-audit-trail.html#decision","d":"ADR-075: Audit Trail (Same-Transaction Field-Level Change History)","k":"Architecture Decision Records","t":"Decision","x":"AuditTrailSaveChangesInterceptor (Infrastructure Persistence/AuditTrail/) joins the interceptors ApplicationDbContext.OnConfiguring already passes to…","i":"ApplicationDbContext.OnModelCreating ApplicationDbContext.OnConfiguring DomainEventSaveChangesInterceptor AuditTrailSaveChangesInterceptor optionsBuilder.AddInterceptors TenantSaveChangesInterceptor AuditSaveChangesInterceptor DesignTimeDbContextHelper PeriodicBackgroundService PiiRedactor.RedactedToken DiscardAbandonedCapture DependencyInjection.cs"},{"u":"/docs/adr/075-audit-trail.html#rationale","d":"ADR-075: Audit Trail (Same-Transaction Field-Level Change History)","k":"Architecture Decision Records","t":"Rationale","x":"- IAuditableEntity is a statement about a business row, and an audit row is not one. The interface means \"this row stamps who created and last modified it and participates in…","i":"IAuditableEntity LastModifiedBy IScheduledJob OutboxMessage TenantId Pii"},{"u":"/docs/adr/075-audit-trail.html#trade-offs","d":"ADR-075: Audit Trail (Same-Transaction Field-Level Change History)","k":"Architecture Decision Records","t":"Trade-offs","x":"- Write amplification is real and it is on the caller's latency path. An entity with twenty changed properties writes twenty rows inside the caller's transaction, so an audited…","i":"IAuditTrailReader AddAuditTrail RetentionDays Pii"},{"u":"/docs/adr/075-audit-trail.html#related","d":"ADR-075: Audit Trail (Same-Transaction Field-Level Change History)","k":"Architecture Decision Records","t":"Related","x":"ADR-003 (the same-transaction write this copies wholesale, including the retry-discard and the Add-only mutation rule), ADR-005 (soft-delete, [Pii] and erasure: why the trail…","i":"AuditTrailSettings RowVersion TenantId Add Pii"},{"u":"/docs/adr/076-data-subject-export.html","d":"ADR-076: Data-Subject Export (DSAR) as a Framework Contract","k":"Architecture Decision Records"},{"u":"/docs/adr/076-data-subject-export.html#status","d":"ADR-076: Data-Subject Export (DSAR) as a Framework Contract","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-13). Revised 2026-08-14 (the API-surface section corrected to the shipped mechanism, an abstract DataExportControllerBase a subclass mounts, not an…","i":"ExportUserDataHandlerBase DataExportControllerBase IUserDataExportSection"},{"u":"/docs/adr/076-data-subject-export.html#context","d":"ADR-076: Data-Subject Export (DSAR) as a Framework Contract","k":"Architecture Decision Records","t":"Context","x":"A data-subject access request is a legal obligation with a clock on it: the person asks for a copy of the personal data held about them, and the operator has a deadline to hand…","i":"DeleteUserHandlerBase UserOwnershipRule IAnonymizable"},{"u":"/docs/adr/076-data-subject-export.html#decision","d":"ADR-076: Data-Subject Export (DSAR) as a Framework Contract","k":"Architecture Decision Records","t":"Decision","x":"The framework takes the part that is the same in both apps; the app keeps the part that is not. A consumer's export handler becomes a subclass that supplies a role test and a set…","i":"AuthorizationPolicies.RequireAuthenticated EntitiesWithPiiImplementAnonymizable UserOwnershipRule.CheckOwnership AuditableAggregateRootEntity IUserEngagementExportService AddNotificationControllers PrivacyFeatures.DataExport AuthenticationServiceBase ExportUserDataHandlerBase DataExportControllerBase PiiEntitiesAreExportable IUserDataExportSection"},{"u":"/docs/adr/076-data-subject-export.html#rationale","d":"ADR-076: Data-Subject Export (DSAR) as a Framework Contract","k":"Architecture Decision Records","t":"Rationale","x":"- The two halves of a handler have different owners. The ownership gate, the aggregate load, the fan-out, the per-section catch and the envelope are the same decisions in both…","i":"IUserEngagementExportService IUserSalesExportService ExportUserDataQuery UserOwnershipRule User"},{"u":"/docs/adr/076-data-subject-export.html#trade-offs","d":"ADR-076: Data-Subject Export (DSAR) as a Framework Contract","k":"Architecture Decision Records","t":"Trade-offs","x":"- Best-effort degradation can return a quietly incomplete package. Available = false is the only signal, and nothing forces a caller, a UI, or the subject to read it. A section…","i":"DataExportControllerBase UserDataExportDTO UserOwnershipRule CurrentUserId FeatureGate Available false"},{"u":"/docs/adr/076-data-subject-export.html#related","d":"ADR-076: Data-Subject Export (DSAR) as a Framework Contract","k":"Architecture Decision Records","t":"Related","x":"ADR-005 (the erasure half of the same privacy obligation, whose IAnonymizable opt-in and [Pii] guard are this contract's mirror: one erases what the other copies), ADR-033 (the…","i":"PiiEntitiesAreExportable UserOwnershipRule IAnonymizable FeatureGate Result Pii"},{"u":"/docs/adr/077-hybridcache-substrate.html","d":"ADR-077: HybridCache as an Opt-In ICacheService Substrate (Amends ADR-026)","k":"Architecture Decision Records","i":"ICacheService"},{"u":"/docs/adr/077-hybridcache-substrate.html#status","d":"ADR-077: HybridCache as an Opt-In ICacheService Substrate (Amends ADR-026)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-13). Amends ADR-026: Tier 1's substrate gains a third implementation beside MemoryCacheService and DistributedCacheService. It is opt-in through…","i":"OutputCacheEvictionRequested DistributedCacheService MMCA.Common.OutputCache AddCommonHybridCache MemoryCacheService"},{"u":"/docs/adr/077-hybridcache-substrate.html#context","d":"ADR-077: HybridCache as an Opt-In ICacheService Substrate (Amends ADR-026)","k":"Architecture Decision Records","t":"Context","x":"ADR-026 settled Tier 1 as one abstraction (ICacheService) over two implementations chosen at startup: in-process memory when no real IDistributedCache is present, Redis…","i":"Microsoft.Extensions.Caching.Hybrid ICacheService.IncrementAsync StackExchangeRedisCache IDistributedCache ICacheService HybridCache WRONGTYPE Result INCR"},{"u":"/docs/adr/077-hybridcache-substrate.html#decision","d":"ADR-077: HybridCache as an Opt-In ICacheService Substrate (Amends ADR-026)","k":"Architecture Decision Records","t":"Decision","x":"Ship HybridCacheService as a third ICacheService implementation, opt-in per host, under a disjoint keyspace. This is the structural rule the design is built around, and…","i":"HybridCacheEntryFlags.DisableUnderlyingData Microsoft.Extensions.Caching.Hybrid CacheOptions.DefaultDuration HybridCache.GetOrCreateAsync HybridCache.RemoveByTagAsync MMCA.Common.Infrastructure Directory.Packages.props DistributedCacheService DisableLocalCacheWrite IConnectionMultiplexer CachingQueryDecorator DisableLocalCacheRead"},{"u":"/docs/adr/077-hybridcache-substrate.html#rationale","d":"ADR-077: HybridCache as an Opt-In ICacheService Substrate (Amends ADR-026)","k":"Architecture Decision Records","t":"Rationale","x":"- The disjoint keyspace is the decision; everything else is implementation. Rather than trusting a second implementation to write a shape compatible with the first, this record…","i":"DisableUnderlyingData LocalCacheExpiration IncrementAsync GetAsync"},{"u":"/docs/adr/077-hybridcache-substrate.html#trade-offs","d":"ADR-077: HybridCache as an Opt-In ICacheService Substrate (Amends ADR-026)","k":"Architecture Decision Records","t":"Trade-offs","x":"- Invalidation does not reach other replicas' L1 immediately. A remove evicts the L2 entry and the calling replica's L1; every other replica keeps its copy for up to…","i":"AddCommonHybridCache LocalCacheExpiration GetOrCreateAsync IncrementAsync ICacheService RemoveAll"},{"u":"/docs/adr/077-hybridcache-substrate.html#related","d":"ADR-077: HybridCache as an Opt-In ICacheService Substrate (Amends ADR-026)","k":"Architecture Decision Records","t":"Related","x":"ADR-026 (amended by this record: its Tier 1 substrate gains a third implementation, its 30-second default TTL becomes the local-cache bound as well, its prefix-invalidation model…","i":"IncrementAsync GetAsync"},{"u":"/docs/adr/078-csv-export-endpoint.html","d":"ADR-078: CSV Export as a Dedicated Endpoint, Not Content Negotiation","k":"Architecture Decision Records"},{"u":"/docs/adr/078-csv-export-endpoint.html#status","d":"ADR-078: CSV Export as a Dedicated Endpoint, Not Content Negotiation","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-13; revised 2026-08-18). The implementation lands in the MMCA.Common \"enterprise capability wave\" release. Unlike the wave's other features this one is NOT…","i":"EntityControllerBase"},{"u":"/docs/adr/078-csv-export-endpoint.html#context","d":"ADR-078: CSV Export as a Dedicated Endpoint, Not Content Negotiation","k":"Architecture Decision Records","t":"Context","x":"The request is \"export what you filtered\". The generic entity surface of ADR-034 already accepts a full query vocabulary on the paged route…","i":"EntityQueryPipeline.MaxUnboundedResultLimit context.CacheVaryByRules.QueryKeys options.ReturnHttpNotAcceptable PublicEndpointOutputCachePolicy ReturnHttpNotAcceptable QueryFilterModelBinder IAsyncEnumerable OutputFormatter sortDirection sortColumn Accept AddAPI"},{"u":"/docs/adr/078-csv-export-endpoint.html#decision","d":"ADR-078: CSV Export as a Dedicated Endpoint, Not Content Negotiation","k":"Architecture Decision Records","t":"Decision","x":"EntityControllerBase gains a virtual [HttpGet(\"export\")] ExportAsync(...) action (Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs). It accepts the same…","i":"QueryFieldService.ShapeCollectionData IPhysicalDbContextFactory.Create ApplicationSettings.MaxPageSize IEntityQueryService.GetAllAsync UnhandledResultFailureFilter JsonNamingPolicy.CamelCase ExportRowLimitHeaderName OpenApiContractTestsBase MaxUnboundedResultLimit QueryFilterModelBinder IEntityControllerBase EntityControllerBase"},{"u":"/docs/adr/078-csv-export-endpoint.html#rationale","d":"ADR-078: CSV Export as a Dedicated Endpoint, Not Content Negotiation","k":"Architecture Decision Records","t":"Rationale","x":"- A route is an unambiguous request; an Accept header is a preference. Given a cache policy that ignores Accept and a pipeline configured to never return 406, a client that…","i":"OutputFormatter Accept"},{"u":"/docs/adr/078-csv-export-endpoint.html#trade-offs","d":"ADR-078: CSV Export as a Dedicated Endpoint, Not Content Negotiation","k":"Architecture Decision Records","t":"Trade-offs","x":"- Every derivative gains a bulk read whether its owner wanted one or not. The only gate is the controller's existing authorization posture. A resource that was safe to page 20…","i":"GetExportSpecification MaxExportRows ExportAsync MaxPageSize Accept Skip Take"},{"u":"/docs/adr/078-csv-export-endpoint.html#related","d":"ADR-078: CSV Export as a Dedicated Endpoint, Not Content Negotiation","k":"Architecture Decision Records","t":"Related","x":"ADR-034 (the generic entity surface and query contract this extends, and the MaxUnboundedResultLimit ceiling that forced the page loop), ADR-040 (the output-cache policy whose…","i":"MaxUnboundedResultLimit Accept Result"},{"u":"/docs/adr/079-shared-http-middleware-pipeline.html","d":"ADR-079: One Shared, Ordered HTTP Middleware Pipeline for Every Service Host","k":"Architecture Decision Records"},{"u":"/docs/adr/079-shared-http-middleware-pipeline.html#status","d":"ADR-079: One Shared, Ordered HTTP Middleware Pipeline for Every Service Host","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-14). Revised 2026-08-19 (refreshed the WebApplicationBuilderExtensions.cs cross-reference anchor, which moved to :555). Revised 2026-08-21: the order became…","i":"MiddlewarePipelineBuilder.CreateDefault WebApplicationBuilderExtensions.cs MiddlewarePipelineOrderTestsBase"},{"u":"/docs/adr/079-shared-http-middleware-pipeline.html#context","d":"ADR-079: One Shared, Ordered HTTP Middleware Pipeline for Every Service Host","k":"Architecture Decision Records","t":"Context","x":"In ASP.NET Core, middleware order is behavior, not style: a rate limiter placed before authentication partitions every request as anonymous, an HTTPS redirect placed in front of…","i":"TenantResolutionMiddleware SoftDeletedUserMiddleware UseAuthentication HttpContext.User"},{"u":"/docs/adr/079-shared-http-middleware-pipeline.html#decision","d":"ADR-079: One Shared, Ordered HTTP Middleware Pipeline for Every Service Host","k":"Architecture Decision Records","t":"Decision","x":"Ship the edge as one ordered pipeline in the framework, UseCommonMiddlewarePipeline (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:46), and…","i":"MiddlewarePipelineBuilder.CreateDefault MiddlewarePipelineOrderTestsBase DecoratorPipelineOrderTestsBase UseCommonRequestLocalization MiddlewarePipelineStepNames UseCommonMiddlewarePipeline TenantResolutionMiddleware ISoftDeletedUserValidator MiddlewarePipelineBuilder SoftDeletedUserMiddleware MiddlewarePipelineStep OidcDiscoveryEndpoint"},{"u":"/docs/adr/079-shared-http-middleware-pipeline.html#rationale","d":"ADR-079: One Shared, Ordered HTTP Middleware Pipeline for Every Service Host","k":"Architecture Decision Records","t":"Rationale","x":"- Order is behavior, so it belongs to the framework, not to each host. Four of the adjacencies above fail silently when reversed: the limiter stops limiting, the tenant resolver…","i":"Program.cs"},{"u":"/docs/adr/079-shared-http-middleware-pipeline.html#trade-offs","d":"ADR-079: One Shared, Ordered HTTP Middleware Pipeline for Every Service Host","k":"Architecture Decision Records","t":"Trade-offs","x":"- Two of this record's original costs are retired (2026-08-21). As accepted, nothing froze the order (no test referenced the method; the adjacencies were protected by comments…","i":"MiddlewarePipelineOrderTestsBase MiddlewarePipelineStepNames MapOidcDiscoveryEndpoint UseCommonSecurityHeaders PreForwardedCapture HttpContext.Items KnownIPNetworks InsertBefore KnownProxies PreForwarded Controllers jwks_uri"},{"u":"/docs/adr/079-shared-http-middleware-pipeline.html#related","d":"ADR-079: One Shared, Ordered HTTP Middleware Pipeline for Every Service Host","k":"Architecture Decision Records","t":"Related","x":"ADR-014 (the in-process sibling: one fixed decorator order for commands and queries), ADR-019 (depends on forwarded headers before the limiter and on the limiter after…"},{"u":"/docs/adr/080-deploy-rollout-revision-rollback.html","d":"ADR-080: Production Rollout with Automatic Revision-Only Rollback","k":"Architecture Decision Records"},{"u":"/docs/adr/080-deploy-rollout-revision-rollback.html#status","d":"ADR-080: Production Rollout with Automatic Revision-Only Rollback","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-14)."},{"u":"/docs/adr/080-deploy-rollout-revision-rollback.html#context","d":"ADR-080: Production Rollout with Automatic Revision-Only Rollback","k":"Architecture Decision Records","t":"Context","x":"Both production apps deploy to Azure Container Apps from a single deploy.yml job on push to main, and every gate runs before anything rolls out: the deploy job waits on…","i":"deploy.yml foundation deploy main"},{"u":"/docs/adr/080-deploy-rollout-revision-rollback.html#decision","d":"ADR-080: Production Rollout with Automatic Revision-Only Rollback","k":"Architecture Decision Records","t":"Decision","x":"Roll out one revision at a time, verify it from outside, and auto-revert the image only when the verification fails. - Single-revision rollout. Every container app runs…","i":"activeRevisionsMode rollback_failed containerapp createdTime Provisioned pipefail revision rollback failure sqlcmd probe Smoke"},{"u":"/docs/adr/080-deploy-rollout-revision-rollback.html#rationale","d":"ADR-080: Production Rollout with Automatic Revision-Only Rollback","k":"Architecture Decision Records","t":"Rationale","x":"- ARM success is the wrong success signal. The smoke gate converts \"the control plane accepted the template\" into \"the fleet answers requests\", which is the only claim a deploy…","i":"deploy"},{"u":"/docs/adr/080-deploy-rollout-revision-rollback.html#trade-offs","d":"ADR-080: Production Rollout with Automatic Revision-Only Rollback","k":"Architecture Decision Records","t":"Trade-offs","x":"- Schema is never rolled back, so a bad migration is fix-forward only. The image reverts and the database does not, so the previous release resumes against the new schema. This…","i":"rollback_failed Provisioned revision APPS copy"},{"u":"/docs/adr/080-deploy-rollout-revision-rollback.html#related","d":"ADR-080: Production Rollout with Automatic Revision-Only Rollback","k":"Architecture Decision Records","t":"Related","x":"ADR-057 (built on this model: revision-only rollback is why every migration must be backward compatible one release back), ADR-030 (startup migration as sole migrator, the reason…"},{"u":"/docs/adr/081-cost-baseline-deploy-gate.html","d":"ADR-081: The Cost Baseline as a Hard Deploy Gate","k":"Architecture Decision Records"},{"u":"/docs/adr/081-cost-baseline-deploy-gate.html#status","d":"ADR-081: The Cost Baseline as a Hard Deploy Gate","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-14)."},{"u":"/docs/adr/081-cost-baseline-deploy-gate.html#context","d":"ADR-081: The Cost Baseline as a Hard Deploy Gate","k":"Architecture Decision Records","t":"Context","x":"Both deployed apps run a deliberately small production footprint: every Container App is declared with maxReplicas: 2 and every SQL database with the Basic tier…","i":"maxReplicas Basic"},{"u":"/docs/adr/081-cost-baseline-deploy-gate.html#decision","d":"ADR-081: The Cost Baseline as a Hard Deploy Gate","k":"Architecture Decision Records","t":"Decision","x":"The cost baseline is asserted by a read-only reusable workflow that both runs weekly and sits in deploy.needs, so an un-reverted scale-up blocks the next production deploy. - One…","i":"properties.template.scale.maxReplicas BASELINE_MAX_REPLICAS AZURE_RESOURCE_GROUP github.event_name workflow_dispatch workflow_call deploy.needs environment release.yml main.bicep production MMCAStore"},{"u":"/docs/adr/081-cost-baseline-deploy-gate.html#rationale","d":"ADR-081: The Cost Baseline as a Hard Deploy Gate","k":"Architecture Decision Records","t":"Rationale","x":"- Configuration drift is the leading indicator; spend is the lagging one. The budget notification fires at 80% of actual spend, after the money is gone, and names a number rather…","i":"workflow_call deploy.needs maxReplicas deploy.yml sku.tier"},{"u":"/docs/adr/081-cost-baseline-deploy-gate.html#trade-offs","d":"ADR-081: The Cost Baseline as a Hard Deploy Gate","k":"Architecture Decision Records","t":"Trade-offs","x":"- A legitimate scale-up blocks deploys until the baseline is edited. Standing up extra capacity for a real event and then shipping a fix during it requires a pull request against…","i":"BASELINE_MAX_REPLICAS skip_freshness_gates skip_justification workflow_dispatch deploy.needs maxReplicas deploy.yml sku.tier Standard deploy Basic write"},{"u":"/docs/adr/081-cost-baseline-deploy-gate.html#related","d":"ADR-081: The Cost Baseline as a Hard Deploy Gate","k":"Architecture Decision Records","t":"Related","x":"ADR-064 (the sibling deploy-precondition record, which decides the three proof-of-recency gates and enumerates this one only in passing; its break-glass input does not apply…","i":"deploy.needs"},{"u":"/docs/adr/082-two-tier-cors-posture.html","d":"ADR-082: Two-Tier Cross-Origin Posture: Allow-Listed Service Policies, an Any-Header Gateway Policy","k":"Architecture Decision Records"},{"u":"/docs/adr/082-two-tier-cors-posture.html#status","d":"ADR-082: Two-Tier Cross-Origin Posture: Allow-Listed Service Policies, an Any-Header Gateway Policy","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-14)."},{"u":"/docs/adr/082-two-tier-cors-posture.html#context","d":"ADR-082: Two-Tier Cross-Origin Posture: Allow-Listed Service Policies, an Any-Header Gateway Policy","k":"Architecture Decision Records","t":"Context","x":"Both deployed applications put a YARP gateway in front of per-module service hosts (ADR-008), and the browser and MAUI clients talk to the gateway origin while the services…"},{"u":"/docs/adr/082-two-tier-cors-posture.html#decision","d":"ADR-082: Two-Tier Cross-Origin Posture: Allow-Listed Service Policies, an Any-Header Gateway Policy","k":"Architecture Decision Records","t":"Decision","x":"Ship two cross-origin policies from the framework: an allow-listed one for service hosts and a deliberately broader one for gateways. - Service hosts register two named policies…","i":"CorsPolicyAllowSpecificOrigins app.Environment.IsDevelopment UseCommonMiddlewarePipeline Cors__AllowedOrigins__0 _allowSpecificOrigins AddCommonGatewayCors CorsPolicyAllowAll AddDefaultPolicy AllowCredentials IHostEnvironment AllowAnyHeader AllowAnyMethod"},{"u":"/docs/adr/082-two-tier-cors-posture.html#rationale","d":"ADR-082: Two-Tier Cross-Origin Posture: Allow-Listed Service Policies, an Any-Header Gateway Policy","k":"Architecture Decision Records","t":"Rationale","x":"- A proxy cannot allow-list what it does not own. The gateway has no controllers and no knowledge of which headers the fronted services accept, so a header allow-list there would…","i":"UseCommonMiddlewarePipeline AllowCredentials IHostEnvironment AllowAnyOrigin AddCommonCors UseCors"},{"u":"/docs/adr/082-two-tier-cors-posture.html#trade-offs","d":"ADR-082: Two-Tier Cross-Origin Posture: Allow-Listed Service Policies, an Any-Header Gateway Policy","k":"Architecture Decision Records","t":"Trade-offs","x":"- The gateway policy is broad on two of three axes. Any header and any method are accepted for an allow-listed origin. The origin list is the only lever there, so a mistake in…","i":"ProductionHostApplicationFactory IHostEnvironment.IsDevelopment configuration.GetSection ValidateOnStart UseEnvironment AddCommonCors UseCors string Get"},{"u":"/docs/adr/082-two-tier-cors-posture.html#related","d":"ADR-082: Two-Tier Cross-Origin Posture: Allow-Listed Service Policies, an Any-Header Gateway Policy","k":"Architecture Decision Records","t":"Related","x":"ADR-079 (the shared middleware pipeline whose fixed order places the environment-selected CORS policy between routing and authentication), ADR-008 (the gateway plus per-module…"},{"u":"/docs/adr/083-crud-lifecycle-event-taxonomy.html","d":"ADR-083: One CRUD Lifecycle Event per Entity with a State Discriminator","k":"Architecture Decision Records"},{"u":"/docs/adr/083-crud-lifecycle-event-taxonomy.html#status","d":"ADR-083: One CRUD Lifecycle Event per Entity with a State Discriminator","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-14). Revised 2026-08-23: the adopter counts were refreshed (ADC Conference's ActivityChanged joined the base-derived set) and three source citations were…","i":"ActivityChanged"},{"u":"/docs/adr/083-crud-lifecycle-event-taxonomy.html#context","d":"ADR-083: One CRUD Lifecycle Event per Entity with a State Discriminator","k":"Architecture Decision Records","t":"Context","x":"ADR-003 decides how a domain event moves: captured into the outbox inside SaveChangesAsync, dispatched in-process after commit, or published to the broker when it is an…","i":"SaveChangesAsync SessionChanged SessionCreated SessionDeleted Changed Created Deleted Session Entity"},{"u":"/docs/adr/083-crud-lifecycle-event-taxonomy.html#decision","d":"ADR-083: One CRUD Lifecycle Event per Entity with a State Discriminator","k":"Architecture Decision Records","t":"Decision","x":"Every generic CRUD lifecycle transition of an entity raises one event type for that entity, carrying a DomainEntityState discriminator; handlers filter on State. - One base…","i":"ProductVariantPriceChanged TicketChangedAuditHandler ProductVariantSkuChanged ShoppingCartItemChanged SessionQuestionChanged ShoppingCartCheckedOut ProductVariantRemoved SessionCreatedHandler BaseIntegrationEvent ProductVariantAdded EntityChangedEvent DomainEntityState"},{"u":"/docs/adr/083-crud-lifecycle-event-taxonomy.html#rationale","d":"ADR-083: One CRUD Lifecycle Event per Entity with a State Discriminator","k":"Architecture Decision Records","t":"Rationale","x":"- One type per entity is one subscription surface. A subscriber declares interest in the entity, then decides which transitions matter, instead of the container deciding for it…","i":"SessionChanged SessionCreated SessionDeleted OrderPaid"},{"u":"/docs/adr/083-crud-lifecycle-event-taxonomy.html#trade-offs","d":"ADR-083: One CRUD Lifecycle Event per Entity with a State Discriminator","k":"Architecture Decision Records","t":"Trade-offs","x":"- Every selective handler pays a filter. A handler that cares about one transition has to open with a State guard and return (SessionCreatedHandler.cs:17-18 is the shape to…","i":"EntityChangedEvent PointsEntryChanged BaseDomainEvent LivePollChanged LivePollStatus Unchanged Added State TId"},{"u":"/docs/adr/083-crud-lifecycle-event-taxonomy.html#related","d":"ADR-083: One CRUD Lifecycle Event per Entity with a State Discriminator","k":"Architecture Decision Records","t":"Related","x":"ADR-003 (how these events are captured and dispatched; this ADR decides only their shape), ADR-010 (schema versioning for the discriminator once it crosses a service boundary),…","i":"MessageId"},{"u":"/docs/adr/084-stripe-webhook-ingress.html","d":"ADR-084: Stripe Webhook Ingress (Acceptance-Coded Responses and Startup Self-Registration)","k":"Architecture Decision Records"},{"u":"/docs/adr/084-stripe-webhook-ingress.html#status","d":"ADR-084: Stripe Webhook Ingress (Acceptance-Coded Responses and Startup Self-Registration)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-14)."},{"u":"/docs/adr/084-stripe-webhook-ingress.html#context","d":"ADR-084: Stripe Webhook Ingress (Acceptance-Coded Responses and Startup Self-Registration)","k":"Architecture Decision Records","t":"Context","x":"Four ADRs already cover how a message crosses a boundary in this workspace. ADR-003 decides how an event leaves a service (outbox, at-least-once). ADR-021 decides how a…","i":"Warning"},{"u":"/docs/adr/084-stripe-webhook-ingress.html#decision","d":"ADR-084: Stripe Webhook Ingress (Acceptance-Coded Responses and Startup Self-Registration)","k":"Architecture Decision Records","t":"Decision","x":"Treat third-party webhook ingress as its own contract with two halves: an acceptance-coded endpoint and a self-registering, self-provisioning endpoint registration at startup. -…","i":"StripeWebhookRegistrationService EventUtility.ValidateSignature payment_intent.payment_failed AddModuleSalesInfrastructure SignatureVerificationFailed StripeWebhookSecretProvider checkout.session.completed throwOnApiVersionMismatch checkout.session.expired HttpContext.Request.Body EventUtility.ParseEvent additionalPortMappings"},{"u":"/docs/adr/084-stripe-webhook-ingress.html#rationale","d":"ADR-084: Stripe Webhook Ingress (Acceptance-Coded Responses and Startup Self-Registration)","k":"Architecture Decision Records","t":"Rationale","x":"- The caller's protocol decides the response vocabulary. Stripe reads a status code as \"keep retrying\" or \"stop\", not as \"this succeeded\" or \"this failed\". Mapping every…","i":"Critical Warning"},{"u":"/docs/adr/084-stripe-webhook-ingress.html#trade-offs","d":"ADR-084: Stripe Webhook Ingress (Acceptance-Coded Responses and Startup Self-Registration)","k":"Architecture Decision Records","t":"Trade-offs","x":"- A startup service that writes to a live third-party account. Booting a Sales replica creates and deletes webhook endpoints in the real Stripe account…","i":"StripeWebhookRegistrationService PaymentReconciliationService PaymentsController WebhookBaseUrl SecretKey Critical Warning"},{"u":"/docs/adr/084-stripe-webhook-ingress.html#related","d":"ADR-084: Stripe Webhook Ingress (Acceptance-Coded Responses and Startup Self-Registration)","k":"Architecture Decision Records","t":"Related","x":"ADR-003 (outbound at-least-once delivery, the other end of the same family), ADR-021 (broker-side inbound dedup, which never sees a webhook), ADR-017 (client-supplied idempotency…"},{"u":"/docs/adr/085-identifier-type-aliases-revisited.html","d":"ADR-085: Identifier Type Aliases Revisited (Wrapper Structs Deferred Again, With Triggers)","k":"Architecture Decision Records"},{"u":"/docs/adr/085-identifier-type-aliases-revisited.html#status","d":"ADR-085: Identifier Type Aliases Revisited (Wrapper Structs Deferred Again, With Triggers)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-18). Revised 2026-08-23 (the alias count and the migration-surface census were recounted, the census gained a stated methodology, and the CheckIn and…","i":"CheckIn"},{"u":"/docs/adr/085-identifier-type-aliases-revisited.html#context","d":"ADR-085: Identifier Type Aliases Revisited (Wrapper Structs Deferred Again, With Triggers)","k":"Architecture Decision Records","t":"Context","x":"ADR-048 decided that every entity identity is a primitive named through a per-module global using {Entity}IdentifierType = ... alias, and recorded the cost in one line of…","i":"SpeakerIdentifierType UserIdentifierType StronglyTypedId IdentifierType Notification System.Guid Entity global Source using Vogen and"},{"u":"/docs/adr/085-identifier-type-aliases-revisited.html#decision","d":"ADR-085: Identifier Type Aliases Revisited (Wrapper Structs Deferred Again, With Triggers)","k":"Architecture Decision Records","t":"Decision","x":"Keep the aliases. The wrapper-struct alternative is evaluated in this record, priced, and deferred again, this time against explicit triggers. Inside a module an identifier is…","i":"SessionIdentifierType SponsorIdentifierType EventIdentifierType UserIdentifierType checkedInByUserId IEntityDTOMapper TIdentifierType IdentifierType ValueConverter JsonConverter CheckInScope BaseEntity"},{"u":"/docs/adr/085-identifier-type-aliases-revisited.html#rationale","d":"ADR-085: Identifier Type Aliases Revisited (Wrapper Structs Deferred Again, With Triggers)","k":"Architecture Decision Records","t":"Rationale","x":"- The cost is paid once and the benefit accrues per defect avoided, and the defect count is currently zero. No production incident in any of the four repos has been traced to a…","i":"System.Text.Json Guid int"},{"u":"/docs/adr/085-identifier-type-aliases-revisited.html#trade-offs","d":"ADR-085: Identifier Type Aliases Revisited (Wrapper Structs Deferred Again, With Triggers)","k":"Architecture Decision Records","t":"Trade-offs","x":"- The exposure is unmitigated, not reduced. This record buys no safety whatsoever. Every transposition ADR-048 could not catch is still uncatchable today, and the CheckIn…","i":"CheckIn.Create CheckIn Create int"},{"u":"/docs/adr/085-identifier-type-aliases-revisited.html#related","d":"ADR-085: Identifier Type Aliases Revisited (Wrapper Structs Deferred Again, With Triggers)","k":"Architecture Decision Records","t":"Related","x":"ADR-048 (the decision this record revisits and upholds; its Status now points here), ADR-068 (the deliberate opposite case: domain values carry invariants and therefore do get…"},{"u":"/docs/adr/085-identifier-type-aliases-revisited.html#revision-2026-08-23","d":"ADR-085: Identifier Type Aliases Revisited (Wrapper Structs Deferred Again, With Triggers)","k":"Architecture Decision Records","t":"Revision (2026-08-23)","x":"The decision, the priced alternative, the three triggers and the trade-offs are unchanged. What changed is arithmetic and three citations. The alias count is 44 across 10 files,…","i":"ActivityIdentifierType SpeakerIdentifierType IEntityDTOMapper TIdentifierType IdentifierType CheckInScope System.Guid TEntityDTO sponsorId IBaseDTO CheckIn TEntity"},{"u":"/docs/adr/086-process-manager-deferred.html","d":"ADR-086: A Process Manager Is Deferred, Not Absent (Relates to ADR-054)","k":"Architecture Decision Records"},{"u":"/docs/adr/086-process-manager-deferred.html#status","d":"ADR-086: A Process Manager Is Deferred, Not Absent (Relates to ADR-054)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-18) as a documented deferral. Nothing ships with this record: no state machine, no correlation store, no new package. What ships is the shape the coordinator…"},{"u":"/docs/adr/086-process-manager-deferred.html#context","d":"ADR-086: A Process Manager Is Deferred, Not Absent (Relates to ADR-054)","k":"Architecture Decision Records","t":"Context","x":"ADR-054 decided how this workspace achieves cross-boundary consistency without two-phase commit: choreography. Each step of a workflow raises a domain event, each compensating…","i":"PaymentReconciliationService PeriodicBackgroundService SagaStateMachineInstance MassTransitStateMachine Order.InventoryRestored InMemorySagaRepository Order.Status SaveChanges Source ISaga"},{"u":"/docs/adr/086-process-manager-deferred.html#decision","d":"ADR-086: A Process Manager Is Deferred, Not Absent (Relates to ADR-054)","k":"Architecture Decision Records","t":"Decision","x":"Defer the process manager, and record its shape so the deferral is a design decision rather than an omission. A durable multi-step workflow coordinator in this workspace is a…","i":"MassTransit.Azure.ServiceBus.Core MassTransitStateMachine MassTransit.RabbitMQ CorrelationId MassTransit InProcess TInstance Result"},{"u":"/docs/adr/086-process-manager-deferred.html#rationale","d":"ADR-086: A Process Manager Is Deferred, Not Absent (Relates to ADR-054)","k":"Architecture Decision Records","t":"Rationale","x":"- Choreography is genuinely correct for the workflow that exists. This is not a case of the simpler option being tolerated. Checkout's saga state is two fields on Order, and an…","i":"Order"},{"u":"/docs/adr/086-process-manager-deferred.html#trade-offs","d":"ADR-086: A Process Manager Is Deferred, Not Absent (Relates to ADR-054)","k":"Architecture Decision Records","t":"Trade-offs","x":"- The first workflow to hit the trigger pays the full cost at once, under whatever deadline made it appear. Deferral moves the work onto the critical path of the feature that…","i":"SQLServerDbContext"},{"u":"/docs/adr/086-process-manager-deferred.html#related","d":"ADR-086: A Process Manager Is Deferred, Not Absent (Relates to ADR-054)","k":"Architecture Decision Records","t":"Related","x":"ADR-054 (the accepted mechanism this record defers an alternative to: choreographed compensation, the persisted aggregate marker, and the reconciliation sweep that would remain…"},{"u":"/docs/adr/087-broker-poison-message-handling.html","d":"ADR-087: Broker Poison-Message Handling: Second-Level Redelivery and Fault Observability","k":"Architecture Decision Records"},{"u":"/docs/adr/087-broker-poison-message-handling.html#status","d":"ADR-087: Broker Poison-Message Handling: Second-Level Redelivery and Fault Observability","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-18). Amends ADR-009: the outbox's broker publish gains a circuit breaker, which is the first resilience policy this workspace applies to something other than an…"},{"u":"/docs/adr/087-broker-poison-message-handling.html#context","d":"ADR-087: Broker Poison-Message Handling: Second-Level Redelivery and Fault Observability","k":"Architecture Decision Records","t":"Context","x":"Delivery in this workspace has always been at-least-once with retries on both legs: the outbox retries a failed publish with jittered exponential backoff and eventually…","i":"rabbitmq_delayed_message_exchange DeadLetterRetentionDays"},{"u":"/docs/adr/087-broker-poison-message-handling.html#decision","d":"ADR-087: Broker Poison-Message Handling: Second-Level Redelivery and Fault Observability","k":"Architecture Decision Records","t":"Decision","x":"Three changes, each scoped to one failure: second-level redelivery configured per transport, a fault consumer with its own meter, and a circuit breaker around the outbox's broker…","i":"RegisterIntegrationEventConsumer settings.EnableDelayedRedelivery FaultIntegrationEventConsumer OperationCanceledException RedeliveryIntervalsSeconds broker.circuit.open.count BuildRedeliveryIntervals cfg.UseDelayedRedelivery ConfigureBrokerTransport EnableDelayedRedelivery BrokenCircuitException fault.FaultedMessageId"},{"u":"/docs/adr/087-broker-poison-message-handling.html#rationale","d":"ADR-087: Broker Poison-Message Handling: Second-Level Redelivery and Fault Observability","k":"Architecture Decision Records","t":"Rationale","x":"- The transport asymmetry follows a real capability difference, not a preference. RabbitMQ needs a plugin the dev container lacks; Service Bus does not. A single default would be…","i":"BrokenCircuitException true"},{"u":"/docs/adr/087-broker-poison-message-handling.html#trade-offs","d":"ADR-087: Broker Poison-Message Handling: Second-Level Redelivery and Fault Observability","k":"Architecture Decision Records","t":"Trade-offs","x":"- Delayed redelivery is off where the plugin problem lives. RabbitMQ is the local transport and also a plausible self-hosted production transport; both get default-off, so the…","i":"RegisterIntegrationEventConsumer RedeliveryIntervalsSeconds broker.fault.count MMCA.Common.Aspire BrokerMetrics internal"},{"u":"/docs/adr/087-broker-poison-message-handling.html#related","d":"ADR-087: Broker Poison-Message Handling: Second-Level Redelivery and Fault Observability","k":"Architecture Decision Records","t":"Related","x":"ADR-003 (the outbox publish leg this breaker wraps, and the retry, jittered backoff and dead-lettering that BrokenCircuitException reuses unchanged), ADR-066 (the transport…","i":"RedeliveryIntervalsSeconds BrokenCircuitException MMCA.Common.Broker MMCA.Common.Outbox MMCA.Common.Cqrs"},{"u":"/docs/adr/088-gateway-edge-responsibilities.html","d":"ADR-088: Gateway Edge Responsibilities (and the Three It Declines)","k":"Architecture Decision Records"},{"u":"/docs/adr/088-gateway-edge-responsibilities.html#status","d":"ADR-088: Gateway Edge Responsibilities (and the Three It Declines)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-18). Extends ADR-019 with a fourth, edge-tier layer whose posture is the deliberate opposite of the service tier's authenticated-only global limiter; nothing in…"},{"u":"/docs/adr/088-gateway-edge-responsibilities.html#context","d":"ADR-088: Gateway Edge Responsibilities (and the Three It Declines)","k":"Architecture Decision Records","t":"Context","x":"ADR-008 made the Gateway the only client entry point and gave it three jobs: the route-to-service map, CORS, and forwarding the caller's Authorization header. Nothing was added…","i":"CorrelationIdMiddleware AddCommonRateLimiting MMCA.Common.Aspire MMCA.Common.API Authorization"},{"u":"/docs/adr/088-gateway-edge-responsibilities.html#decision","d":"ADR-088: Gateway Edge Responsibilities (and the Three It Declines)","k":"Architecture Decision Records","t":"Decision","x":"Ship a gateway edge kit in a Gateway namespace inside MMCA.Common.Aspire, owning exactly three responsibilities, and record three more as deliberately declined. 1. Correlation is…","i":"PartitionedRateLimiter.CreateChained AddGatewayDownstreamHealthChecks RateLimitPartition.GetNoLimiter GatewayCorrelationMiddleware GatewayRateLimitingSettings HttpContext.TraceIdentifier Connection.RemoteIpAddress Validator.ValidateObject ValidateDataAnnotations AddGatewayRateLimiting GlobalConcurrencyLimit UseGatewayCorrelation"},{"u":"/docs/adr/088-gateway-edge-responsibilities.html#rationale","d":"ADR-088: Gateway Edge Responsibilities (and the Three It Declines)","k":"Architecture Decision Records","t":"Rationale","x":"- The edge is the only place that sees a request exactly once. That is what makes ensure-at-the-edge correct and mint-per-service wrong: not that the service version is broken,…","i":"Ready Live"},{"u":"/docs/adr/088-gateway-edge-responsibilities.html#trade-offs","d":"ADR-088: Gateway Edge Responsibilities (and the Three It Declines)","k":"Architecture Decision Records","t":"Trade-offs","x":"- The limiter closes over an eagerly-bound copy of the settings (GatewayRateLimitingExtensions.cs:154, consumed at :164-172), so an IOptionsMonitor reload never reaches it.…","i":"BypassPathPrefixes IOptionsMonitor PermitLimit IOptions"},{"u":"/docs/adr/088-gateway-edge-responsibilities.html#related","d":"ADR-088: Gateway Edge Responsibilities (and the Three It Declines)","k":"Architecture Decision Records","t":"Related","x":"ADR-008 (the record that made the Gateway the only entry point and gave it routing, CORS and auth forwarding; this is the first record to add cross-cutting behavior to it),…"},{"u":"/docs/adr/089-gateway-topology-owned-by-configuration.html","d":"ADR-089: Gateway Topology Owned by Configuration","k":"Architecture Decision Records"},{"u":"/docs/adr/089-gateway-topology-owned-by-configuration.html#status","d":"ADR-089: Gateway Topology Owned by Configuration","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-18; revised 2026-08-23: ADC's table gained a 27th route, /Activities, on 2026-08-19, and the bicep anchors below are corrected). Amends ADR-008: that record…","i":"MapForwarder ReverseProxy"},{"u":"/docs/adr/089-gateway-topology-owned-by-configuration.html#context","d":"ADR-089: Gateway Topology Owned by Configuration","k":"Architecture Decision Records","t":"Context","x":"Before this record, both gateways built their route table by hand, in code. ADC made 26 MapForwarder calls and Store made 10. Neither host called AddReverseProxy or…","i":"HttpResilienceDefaults.TotalRequestTimeout AddServiceDiscoveryDestinationResolver ForwarderRequestConfig.ActivityTimeout AddHttpForwarderWithServiceDiscovery ForwarderRequestConfig HttpVersion.Version20 RequestVersionExact appsettings.json AddReverseProxy IHttpForwarder LoadFromConfig ForwardHttp2"},{"u":"/docs/adr/089-gateway-topology-owned-by-configuration.html#decision","d":"ADR-089: Gateway Topology Owned by Configuration","k":"Architecture Decision Records","t":"Decision","x":"Make configuration the single source of the gateway route table, and pin it with a test. Each gateway calls…","i":"HttpResilienceDefaults.TotalRequestTimeout Http2ForwardingConfigFilter RequestVersionExact appsettings.json MapReverseProxy IHttpForwarder RouteMapTests ForwardHttp2 IProxyConfig MapForwarder ReverseProxy HttpRequest"},{"u":"/docs/adr/089-gateway-topology-owned-by-configuration.html#rationale","d":"ADR-089: Gateway Topology Owned by Configuration","k":"Architecture Decision Records","t":"Rationale","x":"- The drift already happened, in the repository that has the most gateway tests. ADC is the careful consumer, and it still carried three unpinned routes, an off-by-one comment…"},{"u":"/docs/adr/089-gateway-topology-owned-by-configuration.html#trade-offs","d":"ADR-089: Gateway Topology Owned by Configuration","k":"Architecture Decision Records","t":"Trade-offs","x":"- The compiler stopped helping. A misspelled cluster reference, a malformed path pattern or a route that shadows another is a runtime failure, discovered as a 404 or a 502, where…","i":"appsettings.json ForwardHttp2 IProxyConfig MapForwarder Order"},{"u":"/docs/adr/089-gateway-topology-owned-by-configuration.html#related","d":"ADR-089: Gateway Topology Owned by Configuration","k":"Architecture Decision Records","t":"Related","x":"ADR-008 (amended: the Gateway keeps the route-to-service map it was given, now expressed as configuration rather than as forwarder registrations), ADR-088 (the other half of this…","i":"HttpResilienceDefaults.TotalRequestTimeout"},{"u":"/docs/adr/090-event-upcaster-registration.html","d":"ADR-090: Event Upcaster Registration Extension Point","k":"Architecture Decision Records"},{"u":"/docs/adr/090-event-upcaster-registration.html#status","d":"ADR-090: Event Upcaster Registration Extension Point","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-21). Completes the follow-up named in ADR-010: that record established the versioning policy (a SchemaVersion signal plus a new-type-and-upcaster discipline for…","i":"SchemaVersion"},{"u":"/docs/adr/090-event-upcaster-registration.html#context","d":"ADR-090: Event Upcaster Registration Extension Point","k":"Architecture Decision Records","t":"Context","x":"ADR-010 splits event evolution into a signal and a discipline. The signal (SchemaVersion, a fitness-function-gated property on every integration event) shipped with ADR-010…","i":"InProcessMessageBus SchemaVersion"},{"u":"/docs/adr/090-event-upcaster-registration.html#decision","d":"ADR-090: Event Upcaster Registration Extension Point","k":"Architecture Decision Records","t":"Decision","x":"1. A typed upcaster abstraction, in the Application layer. IEventUpcaster (Source/Core/MMCA.Common.Application/Interfaces/IEventUpcaster.cs) is a pure payload mapping from a…","i":"RegisterUpcastedIntegrationEventConsumer EventUpcastersHaveUniqueSourceTypes EventUpcastersIncreaseSchemaVersion UpcastingIntegrationEventConsumer ArchitectureRules.Upcasters.cs services.AddEventUpcaster AddUserDataExportSection EventConventionTestsBase IIntegrationEventHandler IntegrationEventConsumer DomainEventDispatcher EventUpcasterRegistry"},{"u":"/docs/adr/090-event-upcaster-registration.html#rationale","d":"ADR-090: Event Upcaster Registration Extension Point","k":"Architecture Decision Records","t":"Rationale","x":"- The discipline becomes a mechanism. ADR-010's own framing (a thing that matters is a check, not a comment) now applies to the upcaster half: the transform has a first-class…","i":"SchemaVersion MessageId"},{"u":"/docs/adr/090-event-upcaster-registration.html#trade-offs","d":"ADR-090: Event Upcaster Registration Extension Point","k":"Architecture Decision Records","t":"Trade-offs","x":"- The upcast walk advances by declared target type, not runtime type. A misbehaving upcaster that returns an instance of some other type cannot send the walk into an unvalidated…","i":"UpcastingIntegrationEventConsumer OutboxMessage.DeserializeEvent OutputCacheEvictionRequested DomainEventDispatcher AddEventUpcaster"},{"u":"/docs/adr/091-cache-backed-password-reset.html","d":"ADR-091: Cache-Backed Password Reset","k":"Architecture Decision Records"},{"u":"/docs/adr/091-cache-backed-password-reset.html#status","d":"ADR-091: Cache-Backed Password Reset","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-22). Extends ADR-029 (the cache-backed login-protection idiom this record reuses) and ADR-032 (which decided how a password is stored, never how a user who has…"},{"u":"/docs/adr/091-cache-backed-password-reset.html#context","d":"ADR-091: Cache-Backed Password Reset","k":"Architecture Decision Records","t":"Context","x":"Both consumer apps shipped authenticated password change (PUT /Auth/password) and nothing for a user who cannot sign in at all. The recorded fallback in MMCA.ADC's specification…","i":"ResetTokenExpiresAt ResetTokenHash ResetAttempts PUT"},{"u":"/docs/adr/091-cache-backed-password-reset.html#decision","d":"ADR-091: Cache-Backed Password Reset","k":"Architecture Decision Records","t":"Decision","x":"1. The reset token is a cache record, not a schema change. IPasswordResetTokenService (Source/Core/MMCA.Common.Application/Auth/IPasswordResetTokenService.cs) is two methods,…","i":"CryptographicOperations.FixedTimeEquals PasswordResetAuthControllerBase IPasswordResetTokenService FindUntrackedByEmailAsync ForgotPasswordHandlerBase ResetPasswordHandlerBase PasswordReset__ResetUrl PasswordResetController ValidateAndConsumeAsync Auth.InvalidResetToken LoginProtectionService ForgotPasswordCommand"},{"u":"/docs/adr/091-cache-backed-password-reset.html#rationale","d":"ADR-091: Cache-Backed Password Reset","k":"Architecture Decision Records","t":"Rationale","x":"- No migration is the whole point. A reset credential is short-lived by nature, and the expiry semantics a reset needs (a TTL, a single use, an attempt cap) are native to a cache…","i":"Result.Success"},{"u":"/docs/adr/091-cache-backed-password-reset.html#trade-offs","d":"ADR-091: Cache-Backed Password Reset","k":"Architecture Decision Records","t":"Trade-offs","x":"- Cache eviction invalidates outstanding tokens. A Redis restart, an eviction under memory pressure, or a fall back to the in-memory store on a different replica all silently…","i":"IncrementAsync"},{"u":"/docs/adr/092-web-vitals-budget-gate.html","d":"ADR-092: Core Web Vitals Budget as a Shipped Test Contract and Deploy Gate","k":"Architecture Decision Records"},{"u":"/docs/adr/092-web-vitals-budget-gate.html#status","d":"ADR-092: Core Web Vitals Budget as a Shipped Test Contract and Deploy Gate","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-23)."},{"u":"/docs/adr/092-web-vitals-budget-gate.html#context","d":"ADR-092: Core Web Vitals Budget as a Shipped Test Contract and Deploy Gate","k":"Architecture Decision Records","t":"Context","x":"Rubric section 23 asks for client-side performance that is measured rather than assumed, naming Core Web Vitals (LCP, INP, CLS) or an equivalent as the evidence…"},{"u":"/docs/adr/092-web-vitals-budget-gate.html#decision","d":"ADR-092: Core Web Vitals Budget as a Shipped Test Contract and Deploy Gate","k":"Architecture Decision Records","t":"Decision","x":"Ship the measurement infrastructure and the assert mechanics in MMCA.Common.Testing.E2E, default the budget to the Core Web Vitals good band, and let the assertions ride the…","i":"MMCA.Common.Testing.E2E WEB_VITALS_OUTPUT_DIR WebVitalsBudgetTests BeLessThanOrEqualTo PerformanceObserver AssertWithinBudget WebVitalsCollector WriteArtifactAsync durationThreshold WebVitalsArtifact WebVitalsE2ETests E2E.Tests.csproj"},{"u":"/docs/adr/092-web-vitals-budget-gate.html#rationale","d":"ADR-092: Core Web Vitals Budget as a Shipped Test Contract and Deploy Gate","k":"Architecture Decision Records","t":"Rationale","x":"- The good band is an external contract, which is what makes an absolute ceiling defensible here. ADR-060 refused absolute latency because a nanosecond count is a property of the…"},{"u":"/docs/adr/092-web-vitals-budget-gate.html#trade-offs","d":"ADR-092: Core Web Vitals Budget as a Shipped Test Contract and Deploy Gate","k":"Architecture Decision Records","t":"Trade-offs","x":"- The gate is ui-scoped and may legitimately skip. Both apps gate e2e-gate on a ui change filter (ADC deploy.yml:538, Store :544) and deploy accepts skipped for it (ADC :896,…","i":"WEB_VITALS_OUTPUT_DIR InteractiveServer InteractiveAuto skipped deploy"},{"u":"/docs/adr/092-web-vitals-budget-gate.html#related","d":"ADR-092: Core Web Vitals Budget as a Shipped Test Contract and Deploy Gate","k":"Architecture Decision Records","t":"Related","x":"ADR-063 (the structural sibling: the same package, the same Playwright suite and the same deploy gate, applied to WCAG 2.1 AA instead of load performance), ADR-060 (the backend…"},{"u":"/docs/adr/093-container-image-posture.html","d":"ADR-093: Container Image Build and Runtime Posture","k":"Architecture Decision Records"},{"u":"/docs/adr/093-container-image-posture.html#status","d":"ADR-093: Container Image Build and Runtime Posture","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-23) for the three build decisions below. The two runtime postures in \"Open postures\" are recorded as undecided: they describe what the images do today and the…"},{"u":"/docs/adr/093-container-image-posture.html#context","d":"ADR-093: Container Image Build and Runtime Posture","k":"Architecture Decision Records","t":"Context","x":"Eleven Dockerfiles produce every deployable container in the two Azure-hosted applications: six in MMCA.ADC (four services, the Gateway, the Blazor web host) and five in…","i":"publish latest build final base COPY"},{"u":"/docs/adr/093-container-image-posture.html#decision","d":"ADR-093: Container Image Build and Runtime Posture","k":"Architecture Decision Records","t":"Decision","x":"1. The GitHub Packages credential is a BuildKit secret, never an ARG or ENV. Both applications' nuget.config source-maps MMCA. to GitHub Packages, so every restore inside an…","i":"Directory.Packages.props TreatWarningsAsErrors GITHUB_TOKEN nuget.config ENTRYPOINT history publish secrets docker dotnet build final"},{"u":"/docs/adr/093-container-image-posture.html#open-postures-undecided","d":"ADR-093: Container Image Build and Runtime Posture","k":"Architecture Decision Records","t":"Open postures (undecided)","x":"The base image is a floating tag, not a digest. All eleven images start from mcr.microsoft.com/dotnet/aspnet:10.0 with no digest pin (.../MMCA.ADC.Conference.Service/Dockerfile:1…","i":"aspnet latest final USER app"},{"u":"/docs/adr/093-container-image-posture.html#rationale","d":"ADR-093: Container Image Build and Runtime Posture","k":"Architecture Decision Records","t":"Rationale","x":"- A secret that is never a layer cannot leak from a layer. BuildKit secret mounts are the only mechanism that keeps the credential out of the image, the build cache and docker…","i":"history docker ARG ENV RUN"},{"u":"/docs/adr/093-container-image-posture.html#trade-offs","d":"ADR-093: Container Image Build and Runtime Posture","k":"Architecture Decision Records","t":"Trade-offs","x":"- Eleven copies drift independently. There is no shared base Dockerfile and no test that compares them, so a fix applied to one image is applied to one image. The ReadyToRun…","i":"csproj"},{"u":"/docs/adr/093-container-image-posture.html#related","d":"ADR-093: Container Image Build and Runtime Posture","k":"Architecture Decision Records","t":"Related","x":"ADR-038 (supply-chain provenance: it gates the package graph with lock files, a vulnerability audit and an SBOM, and stops at the repository boundary, so the image layers this…"},{"u":"/docs/adr/094-client-entity-data-access.html","d":"ADR-094: Client-Side Entity Data-Access Contract","k":"Architecture Decision Records"},{"u":"/docs/adr/094-client-entity-data-access.html#status","d":"ADR-094: Client-Side Entity Data-Access Contract","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-23)."},{"u":"/docs/adr/094-client-entity-data-access.html#context","d":"ADR-094: Client-Side Entity Data-Access Contract","k":"Architecture Decision Records","t":"Context","x":"ADR-034 decided the server half of entity data access: a generic controller base with a dynamic query contract, where filters arrive as filters[Property].operator /…","i":"QueryFilterModelBinder MMCA.Common.UI operator Property filters value"},{"u":"/docs/adr/094-client-entity-data-access.html#decision","d":"ADR-094: Client-Side Entity Data-Access Contract","k":"Architecture Decision Records","t":"Decision","x":"Client-side entity data access goes through one hand-written base hierarchy in MMCA.Common.UI. - One HTTP root: AuthenticatedServiceBase…","i":"DomainInvariantViolationException IdempotencyHeaders.IdempotencyKey EntityServiceBase.GetPagedAsync CreateAuthenticatedClientAsync ResetCancellationTokenAsync ListPageQueryStateService AuthenticatedServiceBase CultureDelegatingHandler Directory.Packages.props PersistentComponentState EnsureSuccessStatusCode ObjectDisposedException"},{"u":"/docs/adr/094-client-entity-data-access.html#rationale","d":"ADR-094: Client-Side Entity Data-Access Contract","k":"Architecture Decision Records","t":"Rationale","x":"- A hand-written typed base beats a generated client here because the surface is already generic. ADR-034 collapsed N entity endpoints into one shape, so there is exactly one…","i":"EnsureSuccessStatusCode"},{"u":"/docs/adr/094-client-entity-data-access.html#trade-offs","d":"ADR-094: Client-Side Entity Data-Access Contract","k":"Architecture Decision Records","t":"Trade-offs","x":"- No generated client means drift is caught at runtime, not at build time. A server-side rename of a query parameter or a DTO property does not fail the UI build; it fails the…","i":"ChildEntityServiceBase.PostAsync ConfigureHttpClientDefaults EntityServiceBaseTests.cs AuthenticatedServiceBase ChildEntityServiceBase AddServiceDefaults CartStateService RetryPolicy protected AddAsync readonly static"},{"u":"/docs/adr/094-client-entity-data-access.html#related","d":"ADR-094: Client-Side Entity Data-Access Contract","k":"Architecture Decision Records","t":"Related","x":"ADR-034 (the server surface this contract calls, and the filter grammar the client constructs), ADR-017 (the server-side filter whose client half is specified here: who mints the…","i":"DataGridListPageBase TDto"},{"u":"/docs/adr/095-soft-delete-unique-indexes.html","d":"ADR-095: Uniqueness Under Soft Delete (Filtered Unique Indexes)","k":"Architecture Decision Records"},{"u":"/docs/adr/095-soft-delete-unique-indexes.html#status","d":"ADR-095: Uniqueness Under Soft Delete (Filtered Unique Indexes)","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-23)."},{"u":"/docs/adr/095-soft-delete-unique-indexes.html#context","d":"ADR-095: Uniqueness Under Soft Delete (Filtered Unique Indexes)","k":"Architecture Decision Records","t":"Context","x":"ADR-005 makes deletion soft: an IAuditableEntity sets IsDeleted = true (MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IAuditableEntity.cs:11) and a named global query…","i":"IAuditableEntity HasFilter IsDeleted true"},{"u":"/docs/adr/095-soft-delete-unique-indexes.html#decision","d":"ADR-095: Uniqueness Under Soft Delete (Filtered Unique Indexes)","k":"Architecture Decision Records","t":"Decision","x":"Make the filter a convention: every unique index on a soft-deletable entity excludes deleted rows, automatically, in every context of every consumer. - A model-finalizing…","i":"ApplicationDbContext.ConfigureConventions SoftDeleteUniqueIndexConvention SoftDeleteFilterSql.Build DataSource.CosmosDB HasSoftDeleteFilter additionalFilter IAuditableEntity HasColumnName filter null AND"},{"u":"/docs/adr/095-soft-delete-unique-indexes.html#rationale","d":"ADR-095: Uniqueness Under Soft Delete (Filtered Unique Indexes)","k":"Architecture Decision Records","t":"Rationale","x":"- The database should agree with what the application shows. The query filter already says a soft-deleted row does not exist; a unique index that disagrees is the one place the…","i":"DedupKey Build NULL NOT"},{"u":"/docs/adr/095-soft-delete-unique-indexes.html#trade-offs","d":"ADR-095: Uniqueness Under Soft Delete (Filtered Unique Indexes)","k":"Architecture Decision Records","t":"Trade-offs","x":"- It moves schema in consumers, invisibly from the entity configuration. Adopting the convention is a database-contract change: nothing in an entity configuration changed, but…","i":"IX_CategoryItem_CategoryId_Name ignoreQueryFilters builder.HasIndex index.GetFilter IX_User_Email IsDeleted IsUnique x.Email false true"},{"u":"/docs/adr/095-soft-delete-unique-indexes.html#related","d":"ADR-095: Uniqueness Under Soft Delete (Filtered Unique Indexes)","k":"Architecture Decision Records","t":"Related","x":"ADR-005 (decides soft-delete over erasure and owns the query filter that hides the row, but says nothing about uniqueness: this ADR closes that gap), ADR-057 (the expand/contract…","i":"ApplicationDbContext"},{"u":"/docs/adr/096-best-effort-side-effects.html","d":"ADR-096: Best-Effort Side-Effect Contract","k":"Architecture Decision Records"},{"u":"/docs/adr/096-best-effort-side-effects.html#status","d":"ADR-096: Best-Effort Side-Effect Contract","k":"Architecture Decision Records","t":"Status","x":"Accepted (2026-08-23)."},{"u":"/docs/adr/096-best-effort-side-effects.html#context","d":"ADR-096: Best-Effort Side-Effect Contract","k":"Architecture Decision Records","t":"Context","x":"A command that has already committed often has follow-up work attached to it: evict the output-cache entries the write invalidated, broadcast the new state to a live channel,…","i":"MarkAsFailed Exception catch"},{"u":"/docs/adr/096-best-effort-side-effects.html#decision","d":"ADR-096: Best-Effort Side-Effect Contract","k":"Architecture Decision Records","t":"Decision","x":"One framework helper defines the contract, and a swallow that does not go through it is a deliberate, documented exception. - BestEffort.ExecuteAsync(operation, logger, action,…","i":"BestEffortLog.DispatchFailed besteffort.dispatch.failed OperationCanceledException OutputCacheEvictionHandler BestEffort.ExecuteAsync MMCA.Common.OutputCache CancellationToken.None MMCA.Common.BestEffort ArgumentNullException cache.eviction.failed ProductVariantChanged TryEvictByTagAsync"},{"u":"/docs/adr/096-best-effort-side-effects.html#rationale","d":"ADR-096: Best-Effort Side-Effect Contract","k":"Architecture Decision Records","t":"Rationale","x":"- One policy beats five local leniencies. Each feature record is still right about its own degradation; what they could not each decide is the shape of the swallow. A single…","i":"AddVariantHandler"},{"u":"/docs/adr/096-best-effort-side-effects.html#trade-offs","d":"ADR-096: Best-Effort Side-Effect Contract","k":"Architecture Decision Records","t":"Trade-offs","x":"- Nothing gates use of the helper. There is no fitness rule, analyzer or architecture test that fails a build for a hand-rolled catch (Exception) that should have been a…","i":"besteffort.dispatch.failed cache.eviction.failed SubmitQuestionHandler MMCA.Common.Aspire BestEffort Exception catch"},{"u":"/docs/adr/096-best-effort-side-effects.html#related","d":"ADR-096: Best-Effort Side-Effect Contract","k":"Architecture Decision Records","t":"Related","x":"ADR-024 (push delivery failure is non-fatal and recorded rather than raised, one of the local leniencies this policy generalizes), ADR-026 (eviction is best-effort, and its…","i":"besteffort.dispatch.failed OutputCacheEvictionHandler"},{"u":"/docs/onboarding/index.html","d":"MMCA.Common + MMCA.ADC, Onboarding Guide","k":"Onboarding Guide","x":"A teaching guide for an experienced .NET engineer who is new to this codebase. It walks every first-party type, explaining not just what each type is but how it works and why it…","i":"CLAUDE.md dotnet new"},{"u":"/docs/onboarding/index.html#how-the-guide-is-organized-two-axes","d":"MMCA.Common + MMCA.ADC, Onboarding Guide","k":"Onboarding Guide","t":"How the guide is organized, two axes","x":"The guide has two organizing axes that work together. 1. Primary axis, functional grouping. Every type lives in exactly one functional group: the capability or cross-cutting…","i":"SelfHttpWarmupTask GateTestContext MMCA.Common MMCA.ADC Priority"},{"u":"/docs/onboarding/index.html#chapters","d":"MMCA.Common + MMCA.ADC, Onboarding Guide","k":"Onboarding Guide","t":"Chapters","x":"---","i":"AuthenticationServiceBase HttpResilienceDefaults AuthenticationService ConferencePermissions ApplicationDbContext IdentityPermissions SQLServerDbContext HealthCheckTags OutboxFinalizer HasPermission ThemeService Contracts"},{"u":"/docs/onboarding/index.html#legend-how-to-read-a-type-section","d":"MMCA.Common + MMCA.ADC, Onboarding Guide","k":"Onboarding Guide","t":"Legend, how to read a type section","x":"Every type gets one section using this template: {TypeName} {Assembly} · {namespace} · {file:line} · Level {n} · {kind} - What it is: one or two plain-language sentences. -…","i":"namespace Result Rubric Name"},{"u":"/docs/onboarding/index.html#suggested-reading-paths","d":"MMCA.Common + MMCA.ADC, Onboarding Guide","k":"Onboarding Guide","t":"Suggested reading paths","x":"- Framework-first (recommended). Primer → group-01 → upward. You meet the MMCA.Common foundations before the MMCA.ADC features that build on them; this matches dependency order…","i":"MMCA.Common MMCA.ADC Rubric"},{"u":"/docs/onboarding/index.html#the-companion-projects-context","d":"MMCA.Common + MMCA.ADC, Onboarding Guide","k":"Onboarding Guide","t":"The companion projects (context)","x":"This guide covers MMCA.Common (the framework) and MMCA.ADC (one consumer). MMCA.Store is out of scope. The dependency arrow is why the Common framework groups (1–16) come before…","i":"MMCA.Store"},{"u":"/docs/onboarding/00-dependency-manifest.html","d":"Phase 1: Dependency Manifest & Leveling","k":"Onboarding Guide","x":"Each distinct type node is assigned a Level by longest-path layering over its first-party dependencies (base/interface, generic constraints, field/property/param/return types,…","i":"System.Guid global static using Using int"},{"u":"/docs/onboarding/00-dependency-manifest.html#manifest-by-level-then-assembly","d":"Phase 1: Dependency Manifest & Leveling","k":"Onboarding Guide","t":"Manifest (by level, then assembly)","i":"DefaultEntityConfigurationAssemblyProviderTests GetPublicSessionCategoryItemFilterHandlerTests GetPublicSpeakerCategoryItemFilterHandlerTests AddSessionQuestionAnswerCommandValidatorTests ConferenceCategoryCreateRequestValidatorTests ConferenceCategoryUpdateRequestValidatorTests SessionQuestionAnswerNavigationPopulatorTests SpeakerQuestionAnswerNavigationPopulatorTests UserNotificationExportServiceGrpcAdapterTests MarkAllNotificationsReadHandlerTrackingTests SignalRPushNotificationSenderAdditionalTests UserSessionBookmarkCacheEvictionHandlerTests"},{"u":"/docs/onboarding/00-group-taxonomy.html","d":"Phase 1b - Functional Group Taxonomy","k":"Onboarding Guide","x":"This is the primary axis of the guide. Every one of the 3,668 distinct first-party type nodes from 00-inventory.md is assigned to exactly one functional group - its primary home:…","i":"MMCA.Common MMCA.ADC Result"},{"u":"/docs/onboarding/00-group-taxonomy.html#design-notes-boundary-decisions-worth-stating-up-front","d":"Phase 1b - Functional Group Taxonomy","k":"Onboarding Guide","t":"Design notes (boundary decisions worth stating up front)","x":"- Cycles are kept whole. The 13 dependency cycles (SCCs) from the manifest are never split across groups. Notably the ApplicationDbContext AuditSaveChangesInterceptor…","i":"DomainEventSaveChangesInterceptor DataSourceModelCacheKeyFactory AuditSaveChangesInterceptor MMCA.ADC.Notification ApplicationDbContext MMCA.Common.Testing IAnonymizable PiiAttribute Gallery Rubric Fact S30"},{"u":"/docs/onboarding/00-group-taxonomy.html#the-groups-ordered","d":"Phase 1b - Functional Group Taxonomy","k":"Onboarding Guide","t":"The groups (ordered)","x":"Reconciliation: 1761 production types across 26 groups + 1907 test/testing types in G25 = 3668 (matches the inventory's distinct-node count). No type appears twice; none dropped.…"},{"u":"/docs/onboarding/00-group-taxonomy.html#group-membership","d":"Phase 1b - Functional Group Taxonomy","k":"Onboarding Guide","t":"Group membership","x":"group-01-result-error-handling.md 14 types The Result/Error railway that every operation returns instead of throwing; pagination result shapes. group-02-domain-building-blocks.md…","i":"MMCA.ADC.ServiceBusEmulator.IntegrationTests SessionBookmarkValidationServiceGrpcAdapter DefaultEntityConfigurationAssemblyProvider GetPublicSessionCategoryItemFilterHandler GetPublicSpeakerCategoryItemFilterHandler SessionQuestionPendingCountChangedPayload AddSessionQuestionAnswerCommandValidator ConferenceCategoryCreateRequestValidator ConferenceCategoryUpdateRequestValidator CookieSessionRefreshMiddlewareExtensions DisabledSessionBookmarkValidationService MMCA.ADC.Conference.Infrastructure.Tests"},{"u":"/docs/onboarding/00-inventory.html","d":"Phase 0: Type Inventory","k":"Onboarding Guide","x":"Generated mechanically by a Roslyn syntactic parse of every in-scope .cs file under MMCA.Common/Source, MMCA.Common/Tests, MMCA.ADC/Source, MMCA.ADC/Tests. - Files scanned: 2950…","i":"extension"},{"u":"/docs/onboarding/00-inventory.html#full-inventory","d":"Phase 0: Type Inventory","k":"Onboarding Guide","t":"Full inventory","i":"MMCA.ADC.Conference.Application.Events.Sessionize MMCA.ADC.Conference.Application.Events.Validation MMCA.ADC.Conference.Application.Tests.Events.DTOs MMCA.ADC.Conference.Domain.Questions.DomainEvents MMCA.ADC.Conference.Infrastructure.Tests.Services MMCA.ADC.Conference.IntegrationTests.CrossService MMCA.ADC.Engagement.Application.CheckIns.Services MMCA.ADC.Engagement.Domain.LivePolls.DomainEvents MMCA.ADC.Engagement.Domain.Tests.SessionQuestions MMCA.ADC.Identity.IntegrationTests.Infrastructure MMCA.Common.Application.Interfaces.Infrastructure MMCA.Common.Application.Users.UseCases.DeleteUser"},{"u":"/docs/onboarding/00-inventory.html#extensiont-blocks","d":"Phase 0: Type Inventory","k":"Onboarding Guide","t":"extension(T) blocks","i":"IDistributedApplicationBuilder IBusRegistrationConfigurator AuthenticationBuilder IEndpointRouteBuilder WebApplicationBuilder IApplicationBuilder ICurrentUserService IReadOnlyCollection currentUserService IServiceCollection OutputCacheOptions IResourceBuilder"},{"u":"/docs/onboarding/00-inventory.html#generated--excluded-artifacts-no-type-sections-written","d":"Phase 0: Type Inventory","k":"Onboarding Guide","t":"Generated / excluded artifacts (no type sections written)","x":"122 files excluded as generated (EF migrations, snapshots, .g.cs, AssemblyInfo)."},{"u":"/docs/onboarding/00-primer.html","d":"Primer, the concepts, stack, and conventions you need first","k":"Onboarding Guide","x":"This chapter teaches the cross-cutting things once, so the per-type chapters can stay focused. Read it before the group chapters (start with group-01). Everything here is either…"},{"u":"/docs/onboarding/00-primer.html#1-the-big-picture","d":"Primer, the concepts, stack, and conventions you need first","k":"Onboarding Guide","t":"1. The big picture","x":"Two codebases are in scope: - MMCA.Common: a framework, published as fifteen NuGet packages to nuget.org (the documented install path) and mirrored to GitHub Packages (ADR-053)…","i":"Testing.Architecture Aspire.Hosting Infrastructure Application MMCA.Common Testing.E2E references Testing.UI MMCA.ADC Testing UI.Maui Aspire"},{"u":"/docs/onboarding/00-primer.html#2-architectural-styles-this-codebase-commits-to","d":"Primer, the concepts, stack, and conventions you need first","k":"Onboarding Guide","t":"2. Architectural styles this codebase commits to","x":"These are the recurring ideas. Each is taught fully at its first concrete appearance in a group chapter; here is the orientation so the vocabulary is familiar. - Domain-Driven…","i":"ApplicationDbContext.ConfigureConventions EntityTypeConfigurationSQLServer PublicEndpointOutputCachePolicy SoftDeleteUniqueIndexConvention JwtForwardingClientInterceptor FaultIntegrationEventConsumer GatewayCorrelationMiddleware JwtSettings.SigningAlgorithm EntityTypeConfigurationBase UseCommonMiddlewarePipeline besteffort.dispatch.failed TenantResolutionMiddleware"},{"u":"/docs/onboarding/00-primer.html#3-the-external-stack-bcl--nuget-external-level-0","d":"Primer, the concepts, stack, and conventions you need first","k":"Onboarding Guide","t":"3. The external stack (BCL / NuGet, \"external Level 0\")","x":"These are not first-party and get no per-type sections. Versions are from MMCA.Common/Directory.Packages.props and MMCA.ADC/Directory.Packages.props (Central Package Management,…","i":"Microsoft.Extensions.ServiceDiscovery.Yarp Microsoft.Extensions.Http.Resilience Notification.PushNotifications IEntityTypeConfiguration MMCA.Common.UI global.json IMessageBus SaveChanges TryDecorate DbContext OrderBy vX.Y.Z"},{"u":"/docs/onboarding/00-primer.html#4-c-build-and-code-style-conventions","d":"Primer, the concepts, stack, and conventions you need first","k":"Onboarding Guide","t":"4. C#, build, and code-style conventions","x":"- .NET 10.0, LangVersion: preview: required because the codebase uses C extension types (extension(T) syntax, see below). - Central Package Management (CPM). All NuGet versions…","i":"csharp_style_namespace_declarations MMCA.Common.Testing.Architecture ManagePackageVersionsCentrally Directory.Packages.props DependencyInjection.cs DependencyVersionTests TreatWarningsAsErrors csharp_prefer_braces EntityTypeExtensions packageSourceMapping IServiceCollection IArchitectureMap"},{"u":"/docs/onboarding/00-primer.html#5-the-solution--test-layout","d":"Primer, the concepts, stack, and conventions you need first","k":"Onboarding Guide","t":"5. The solution / test layout","x":"- .slnx: the human solution (XML format). .slnf, a solution filter used in CI to build a subset fast (MMCA.Store.CI.slnf, MMCA.ADC.CI.slnf). - Microsoft Testing Platform, not…","i":"MMCA.Common.UI.E2E.Tests MMCA.Common.UI.Gallery MMCA.Store.CI.slnf MMCA.ADC.CI.slnf csproj slnx"},{"u":"/docs/onboarding/00-primer.html#6-the-34-category-architecture-evaluation-lens","d":"Primer, the concepts, stack, and conventions you need first","k":"Onboarding Guide","t":"6. The 34-category architecture-evaluation lens","x":"This codebase is also scored against a 34-category rubric (Website/docs-src/governance/ArchitectureEvaluationCriteria.md, published at ). This guide weaves the rubric in so you…","i":"Rubric Name"},{"u":"/docs/onboarding/group-01-result-error-handling.html","d":"1. Result & Error Handling","k":"Onboarding Guide","x":"This is the first capability chapter, and it is deliberately first because the pattern it teaches underpins almost every other one in the guide. Before you read a command…","i":"ArgumentOutOfRangeException.ThrowIfNegative ArgumentNullException.ThrowIfNull DomainInvariantViolationException MMCA.Common.Shared.Serialization MMCA.Common.Shared.Abstractions System.Text.Json.Utf8JsonReader GrpcResultExceptionInterceptor System.Text.Json.Serialization MMCA.Common.Shared.Exceptions System.Buffers.Text.Base64Url Base64Url.TryDecodeFromChars ValidationFailureExtensions"},{"u":"/docs/onboarding/group-02-domain-building-blocks.html","d":"2. Domain Building Blocks (Entities, Value Objects, Aggregates)","k":"Onboarding Guide","x":"What this group covers. This is the DDD heart of the framework, the small, dependency-light primitives every business model in MMCA.Common and MMCA.ADC is built from. There are…","i":"EnumerationJsonConverterFactory AuditableAggregateRootEntity IdValueGeneratedAttribute CurrencyJsonConverter PhoneNumberInvariants EntityTypeExtensions EnumerationConverter AuditableBaseEntity MMCA.Common.Domain MMCA.Common.Shared RedactableProperty AddressInvariants"},{"u":"/docs/onboarding/group-02-domain-building-blocks.html#the-entity-chain-one-capability-per-rung","d":"2. Domain Building Blocks (Entities, Value Objects, Aggregates)","k":"Onboarding Guide","t":"The entity chain, one capability per rung","x":"Read the chain bottom-up. BaseEntity (MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/BaseEntity.cs:14) is almost nothing: a single required init identifier of the per-entity…","i":"AuditableAggregateRootEntity AuditSaveChangesInterceptor ChangeTracker.Entries AuditableBaseEntity GetChildOrNotFound RemoveDomainEvents ClearDomainEvents IAuditableEntity ValidateSetItems TIdentifierType AddDomainEvent entry.Property"},{"u":"/docs/onboarding/group-02-domain-building-blocks.html#two-opt-in-markers-beside-the-chain","d":"2. Domain Building Blocks (Entities, Value Objects, Aggregates)","k":"Onboarding Guide","t":"Two opt-in markers beside the chain","x":"Not every cross-cutting capability belongs on the inheritance chain, because not every entity should pay for it. ITenantEntity…","i":"AuditTrailSaveChangesInterceptor TenantSaveChangesInterceptor entity.HasQueryFilter ApplyTenantFilters IAuditableEntity TenantFilterName AddMultiTenancy AuditTrailEntry IAuditedEntity AddAuditTrail configuration ITenantEntity"},{"u":"/docs/onboarding/group-02-domain-building-blocks.html#how-a-domain-event-leaves-an-aggregate","d":"2. Domain Building Blocks (Entities, Value Objects, Aggregates)","k":"Onboarding Guide","t":"How a domain event leaves an aggregate","x":"The runtime flow ties this group to the events/outbox group. A command handler loads an aggregate, calls a business method, and that method calls AddDomainEvent(...); the event…","i":"DomainEventSaveChangesInterceptor context.ChangeTracker.Entries RemoveDomainEvents DomainEntityState IIntegrationEvent DeferredDispatch OutboxProcessor AddDomainEvent IAggregateRoot OutboxMessage IDomainEvent Unchanged"},{"u":"/docs/onboarding/group-02-domain-building-blocks.html#value-objects-invalid-instances-cannot-exist","d":"2. Domain Building Blocks (Entities, Value Objects, Aggregates)","k":"Onboarding Guide","t":"Value objects, invalid instances cannot exist","x":"The second family models concepts with no identity: two Money(10, USD) are equal because their values match, not because they are the same row. ValueObject is the cheapest…","i":"EnsurePreferredCultureIsValid EnsurePreferredThemeIsValid EnsureCollectionIsNotEmpty InvalidOperationException PhoneNumberValueConverter EnsureMoneyIsNotNegative EnsureBytesAreNotEmpty EnsureStringIsNotEmpty CurrencyJsonConverter EnsureStringMaxLength PhoneNumberInvariants EnsureIdIsNotDefault"},{"u":"/docs/onboarding/group-02-domain-building-blocks.html#smart-enumerations-a-closed-set-that-can-carry-behavior","d":"2. Domain Building Blocks (Entities, Value Objects, Aggregates)","k":"Onboarding Guide","t":"Smart enumerations, a closed set that can carry behavior","x":"Enumeration (MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Enumeration.cs:71) is the answer to a recurring shape a CLR enum handles badly: a closed set of named members…","i":"ValueObjectsAreImmutableSealedInShared JsonSerializerOptions.Converters EnumerationJsonConverterFactory Enumeration.UnknownValue Enumeration.UnknownName CurrencyJsonConverter EnumerationConverter ReadOnlyCollection FrozenDictionary JsonConverter JsonException TEnumeration"},{"u":"/docs/onboarding/group-02-domain-building-blocks.html#governance-markers-metadata-that-other-layers-act-on","d":"2. Domain Building Blocks (Entities, Value Objects, Aggregates)","k":"Onboarding Guide","t":"Governance markers, metadata that other layers act on","x":"The last family is tiny attributes and helpers that carry intent the rest of the stack reads reflectively. PiiAttribute…","i":"AuditTrailSaveChangesInterceptor CultureInfo.InvariantCulture IdValueGeneratedAttribute PiiRedactor.RedactedToken EncryptedStringConverter PiiConventionTestsBase ConcurrentDictionary EntityTypeExtensions GetCustomAttribute IsIdValueGenerated MMCA.Common.Domain PiiConventionTests"},{"u":"/docs/onboarding/group-02-domain-building-blocks.html#where-this-group-sits","d":"2. Domain Building Blocks (Entities, Value Objects, Aggregates)","k":"Onboarding Guide","t":"Where this group sits","x":"Everything above is consumed by the layers that follow: every module entity (for example the Conference domain, Engagement, and Identity modules) derives from one of the three…","i":"EnumerationJsonConverterFactory.CreateConverter PhoneNumberInvariants.EnsurePhoneNumberIsValid AddressInvariants.EnsureAddressLine1IsValid MMCA.Common.Domain.Interfaces.IAnonymizable AddressInvariants.AddressLine1MaxLength EntityTypeExtensions.IsIdValueGenerated AddressInvariants.EnsureAddressIsValid EventInvariants.EnsureDateRangeIsValid ValueObjectsAreImmutableSealedInShared EntityTypeBuilderExtensions.OwnsMoney EntitiesWithPiiImplementAnonymizable EmailInvariants.EnsureEmailIsValid"},{"u":"/docs/onboarding/group-03-querying-specifications.html","d":"3. Querying: Specifications, Filtering & the Entity Query Service","k":"Onboarding Guide","x":"What this group covers. Every read in MMCA.Common and ADC (\"list the published events\", \"get session 42\", \"the speakers in Atlanta, page 3, sorted by name, with only the name and…","i":"QuerySpecification Expression IQueryable TEntity OFFSET SELECT ORDER WHERE bool Func name bio"},{"u":"/docs/onboarding/group-03-querying-specifications.html#the-specification-pattern-the-trusted-predicate","d":"3. Querying: Specifications, Filtering & the Entity Query Service","k":"Onboarding Guide","t":"The Specification pattern, the trusted predicate","x":"ISpecification (MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/ISpecification.cs:12) exposes two faces of one rule: a Criteria expression tree that EF Core translates to…","i":"PublicSessionStatusSpecification.StatusCriteria GetPublicSessionFilterHandler PublishedEventSpecification CrossSourceSpecification OwnedByUserSpecification SpecificationExtensions SpecificationComposer dependent.ForeignKey InvocationExpression Enumerable.Contains InlineSpecification s.Event.IsPublished"},{"u":"/docs/onboarding/group-03-querying-specifications.html#queryspecification-a-whole-read-in-one-object","d":"3. Querying: Specifications, Filtering & the Entity Query Service","k":"Onboarding Guide","t":"QuerySpecification, a whole read in one object","x":"A plain specification is only a predicate, which leaves includes, ordering, paging, and tracking to be threaded through every layer as loose arguments. QuerySpecification…","i":"IgnoreQueryFilters QuerySpecification EFReadRepository LambdaExpression OrderExpression TIdentifierType WithSoftDeleted specification Specification BaseQueryFor IncludePaths WithTracking"},{"u":"/docs/onboarding/group-03-querying-specifications.html#dynamic-filtering-one-strategy-per-clr-type","d":"3. Querying: Specifications, Filtering & the Entity Query Service","k":"Onboarding Guide","t":"Dynamic filtering, one Strategy per CLR type","x":"User filters arrive as a Dictionary , property name to operator key plus raw string value, parsed from the query string by QueryFilterModelBinder at the API edge, which caps a…","i":"Filter.Operator.NotSupported QueryParameterizationTests Filter.Property.NotFound Filter.Type.NotSupported datetimefilterstrategy QueryFilterModelBinder ResolveFilterValueType decimalfilterstrategy Filter.Value.Invalid StringFilterStrategy ResolvePropertyInfo boolfilterstrategy"},{"u":"/docs/onboarding/group-03-querying-specifications.html#sorting-sparse-fieldsets-and-paging-arithmetic","d":"3. Querying: Specifications, Filtering & the Entity Query Service","k":"Onboarding Guide","t":"Sorting, sparse fieldsets, and paging arithmetic","x":"QueryFieldService (MMCA.Common/Source/Core/MMCA.Common.Application/Services/QueryFieldService.cs:16) owns the rest of read shaping. ApplySorting (QueryFieldService.cs:155)…","i":"PropertyInfo.GetValue ValidateSortDirection ApplyFieldSelection ShapeCollectionData GetShapedAccessors Expression.Lambda QueryFieldService PagingMath.Clamp PropertyAccessor MaxCacheEntries ExpandoObject ApplySorting"},{"u":"/docs/onboarding/group-03-querying-specifications.html#the-pipeline-two-entity-paths-plus-projection-pushdown","d":"3. Querying: Specifications, Filtering & the Entity Query Service","k":"Onboarding Guide","t":"The pipeline: two entity paths plus projection pushdown","x":"IEntityQueryPipeline (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/IEntityQueryPipeline.cs:10) is the execution contract, implemented by the sealed…","i":"ApplyIncludesCriteriaAndFilters inavigationmetadataprovider NavigationMetadataProvider MaxUnboundedResultLimit NavigationPropertyInfo CountUnpaginatedAsync EntityQueryParameters ExecuteProjectedAsync IEntityQueryPipeline INavigationPopulator entityquerypipeline IEntityDTOProjector"},{"u":"/docs/onboarding/group-03-querying-specifications.html#the-query-service-the-public-face","d":"3. Querying: Specifications, Filtering & the Entity Query Service","k":"Onboarding Guide","t":"The query service, the public face","x":"IEntityQueryService (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEntityQueryService.cs:19) and its concrete EntityQueryService…","i":"SpeakerEntityQueryService BuildPaginationMetadata MaxUnboundedResultLimit TryGetByIdFastPathAsync DTOToEntityPropertyMap TryGetFastPathIncludes EntityQueryParameters ExecuteProjectedAsync PagedCollectionResult GetAllForLookupAsync INavigationPopulator DTOMapper.MapToDTOs"},{"u":"/docs/onboarding/group-03-querying-specifications.html#end-to-end-one-list-request","d":"3. Querying: Specifications, Filtering & the Entity Query Service","k":"Onboarding Guide","t":"End to end, one list request","x":"The request reaches a read controller, EntityControllerBase (Group 12), which resolves MaxPageSize per request from IApplicationSettings, falling back to 500 when unset…","i":"IEntityQueryService.GetAllAsync EntityQueryParameters PagedCollectionResult EntityControllerBase IApplicationSettings EntityQueryPipeline TIdentifierType MaxPageSize PagingMath TEntityDTO requested TEntity"},{"u":"/docs/onboarding/group-03-querying-specifications.html#also-filed-here-best-effort-dispatch-and-the-upcaster-registry","d":"3. Querying: Specifications, Filtering & the Entity Query Service","k":"Onboarding Guide","t":"Also filed here: best-effort dispatch and the upcaster registry","x":"Four types in this group are not part of the read path at all; they are co-located in MMCA.Common.Application/Services and are grouped by that folder. BestEffort…","i":"Filtering.DynamicQueryConfig.Parameterized IEntityQueryPipeline.ExecuteProjectedAsync MMCA.Common.Application.Services.Filtering SpecificationsDoNotNavigateToOtherEntities ArgumentException.ThrowIfNullOrWhiteSpace QueryFilterService.ResolveFilterValueType Microsoft.Extensions.DependencyInjection NavigationMetadataProvider.BuildIncludes CrossSourceSpecification.BuildCriteria MMCA.Common.Application.Services.Query MMCA.Common.Application.Specifications QueryFieldService.ApplyFieldSelection"},{"u":"/docs/onboarding/group-04-events-outbox.html","d":"4. Domain & Integration Events + Outbox Dual-Dispatch","k":"Onboarding Guide","x":"What this chapter covers. This group is the codebase's event spine: how an aggregate says \"something happened\", how that fact is persisted so it cannot be lost, and how it…"},{"u":"/docs/onboarding/group-04-events-outbox.html#the-two-kinds-of-event","d":"4. Domain & Integration Events + Outbox Dual-Dispatch","k":"Onboarding Guide","t":"The two kinds of event","x":"Everything starts with two marker interfaces in the Domain layer. IDomainEvent is the base contract: a DateOccurred timestamp (when the business action happened, not when it was…","i":"BaseIntegrationEvent EntityChangedEvent DomainEntityState IIntegrationEvent BaseDomainEvent TIdentifierType Infrastructure UserRegistered SchemaVersion Architecture DateOccurred IDomainEvent"},{"u":"/docs/onboarding/group-04-events-outbox.html#raising-and-capturing-where-the-outbox-is-written","d":"4. Domain & Integration Events + Outbox Dual-Dispatch","k":"Onboarding Guide","t":"Raising and capturing: where the outbox is written","x":"Aggregates raise events by calling AddDomainEvent() (see AuditableAggregateRootEntity in G02), which simply buffers them on the entity. Nothing is dispatched yet; the events ride…","i":"DomainEventSaveChangesInterceptor OutboxMessage.FromDomainEvent AuditableAggregateRootEntity TIdentifierType AddDomainEvent OutboxMessages OutboxMessage SavingChanges Architecture DbContext Rubric Data"},{"u":"/docs/onboarding/group-04-events-outbox.html#the-routing-split-local-events-dispatch-in-process-integration-events-wait-for-the-bus","d":"4. Domain & Integration Events + Outbox Dual-Dispatch","k":"Onboarding Guide","t":"The routing split: local events dispatch in-process, integration events wait for the bus","x":"Here is the detail that most people get wrong, and it is the heart of the design. After the transaction commits (SavedChanges), the interceptor does not treat all captured events…","i":"IIntegrationEventHandler IDomainEventDispatcher SafeDomainEventHandler DomainEventDispatcher someIntegrationEvent IDomainEventHandler TIntegrationEvent DbContextFactory OutboxFinalizer OutboxProcessor AddDomainEvent ExecuteUpdate"},{"u":"/docs/onboarding/group-04-events-outbox.html#the-safety-net-how-the-processor-schedules-itself","d":"4. Domain & Integration Events + Outbox Dual-Dispatch","k":"Onboarding Guide","t":"The safety net: how the processor schedules itself","x":"The OutboxProcessor is a BackgroundService and the most intricate type in the group; most of its complexity is about not wasting work. It exists because the steps between commit…","i":"PollingIntervalSeconds ProcessingDelaySeconds BackgroundService OutboxCycleResult ComputeWaitTime OutboxProcessor OutboxSettings ExecuteUpdate IOutboxSignal SemaphoreSlim LeaseSeconds OutboxSignal"},{"u":"/docs/onboarding/group-04-events-outbox.html#failures-dead-letters-and-keeping-the-table-and-telemetry-bounded","d":"4. Domain & Integration Events + Outbox Dual-Dispatch","k":"Onboarding Guide","t":"Failures, dead-letters, and keeping the table (and telemetry) bounded","x":"Delivery failures split into two very different outcomes, worth keeping straight. A transient failure (a handler or broker publish throwing) increments the row's RetryCount,…","i":"OutboxPollFilterProcessor outbox.dead_letter.count DeadLetterRetentionDays RetryBackoffBaseSeconds CleanupIntervalHours OutboxCleanupService MMCA.Common.Outbox Observability OutboxMetrics RetentionDays TimeProvider Operability"},{"u":"/docs/onboarding/group-04-events-outbox.html#the-pluggable-transport-in-process-versus-broker","d":"4. Domain & Integration Events + Outbox Dual-Dispatch","k":"Onboarding Guide","t":"The pluggable transport: in-process versus broker","x":"Here is the boundary that makes a module extractable without rewriting its handlers. Application code that wants to publish an integration event depends on IEventBus (or on the…","i":"InProcessMessageBus AddBrokerMessaging IIntegrationEvent InProcessEventBus BrokerMessageBus OutboxFinalizer OutboxProcessor BrokerEventBus Microservices Application IMessageBus IEventBus"},{"u":"/docs/onboarding/group-04-events-outbox.html#consuming-from-the-broker-the-inbox-and-the-generic-consumer","d":"4. Domain & Integration Events + Outbox Dual-Dispatch","k":"Onboarding Guide","t":"Consuming from the broker: the inbox and the generic consumer","x":"On the receiving side of a broker hop, application code keeps writing plain IIntegrationEventHandler implementations; there is no MassTransit-specific consumer class to author…","i":"IntegrationEventConsumerExtensions RegisterIntegrationEventConsumer IBusRegistrationConfigurator IIntegrationEventHandler IntegrationEventConsumer AlreadyProcessedAsync MarkProcessedAsync DbUpdateException NoOpInboxStore EfInboxStore InboxMessage AddConsumer"},{"u":"/docs/onboarding/group-04-events-outbox.html#putting-it-together-one-events-life","d":"4. Domain & Integration Events + Outbox Dual-Dispatch","k":"Onboarding Guide","t":"Putting it together, one event's life","x":"To see the whole spine at once, follow a single integration event from a producer service to a consumer service in broker mode. (1) A command mutates an aggregate, which raises…","i":"MMCA.Common.Infrastructure.Persistence.Outbox MMCA.Common.Infrastructure.Persistence.Inbox Microsoft.Extensions.Hosting.IHostedService OutboxProcessor.ProcessPendingMessagesAsync UserSessionBookmarkCacheEvictionHandler services.AddOutputCacheEvictionHandler Microsoft.Extensions.Logging.ILogger MMCA.Common.Application.DomainEvents MMCA.Common.Domain.IntegrationEvents ApplicationDbContext.ConfigureInbox domainEventDispatcher.DispatchAsync MMCA.Common.Infrastructure.Services"},{"u":"/docs/onboarding/group-05-cqrs-pipeline.html","d":"5. CQRS: Commands, Queries & the Decorator Pipeline","k":"Onboarding Guide","x":"What this group covers. Every write and every read in an MMCA application is a use case: a small, single-purpose object (a command or a query) handed to a handler that does…","i":"AuthorizationCommandDecorator TransactionalCommandDecorator AuthorizationQueryDecorator FeatureGateCommandDecorator ValidatingCommandDecorator FeatureGateQueryDecorator ProfilingCommandDecorator CachingCommandDecorator LoggingCommandDecorator ProfilingQueryDecorator TimeoutCommandDecorator IEventUpcasterRegistry"},{"u":"/docs/onboarding/group-05-cqrs-pipeline.html#the-shape-thin-handlers-fat-pipeline","d":"5. CQRS: Commands, Queries & the Decorator Pipeline","k":"Onboarding Guide","t":"The shape: thin handlers, fat pipeline","x":"A handler is deliberately tiny. ICommandHandler (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/ICommandHandler.cs:9) and IQueryHandler…","i":"cancellationToken CancellationToken ICommandHandler IQueryHandler HandleAsync Patterns TCommand default TResult Design Result Rubric"},{"u":"/docs/onboarding/group-05-cqrs-pipeline.html#how-the-pipeline-is-assembled-scrutor-registration-versus-execution-order","d":"5. CQRS: Commands, Queries & the Decorator Pipeline","k":"Onboarding Guide","t":"How the pipeline is assembled (Scrutor, registration versus execution order)","x":"The wiring lives in DependencyInjection.cs (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:22), exposed as extension(IServiceCollection services) members…","i":"DecoratorPipelineOrderTestsBase ScanModuleApplicationServices ProfilingCommandDecorator AddApplicationDecorators DependencyInjectionTests AddApplicationProfiling ProfilingQueryDecorator DependencyInjection.cs EntityQueryPipeline IServiceCollection ServiceCollection ICommandHandler"},{"u":"/docs/onboarding/group-05-cqrs-pipeline.html#why-this-exact-order-and-what-each-layer-guards","d":"5. CQRS: Commands, Queries & the Decorator Pipeline","k":"Onboarding Guide","t":"Why this exact order, and what each layer guards","x":"The nesting order is a deliberate cost-and-correctness argument, spelled out in the registration XML-doc (DependencyInjection.cs:87-105): - Feature-gating is outermost so a…","i":"TransactionCommitAmbiguousException ICacheService.RemoveByPrefixAsync Authorization.PermissionDenied IFeatureManager.IsEnabledAsync AuthorizationCommandDecorator TransactionalCommandDecorator AuthorizationQueryDecorator FeatureGateCommandDecorator OperationCanceledException ValidatingCommandDecorator CqrsMetrics.QueryDuration ExecuteInTransactionAsync"},{"u":"/docs/onboarding/group-05-cqrs-pipeline.html#opt-in-by-marker-interface-pay-only-for-what-you-use","d":"5. CQRS: Commands, Queries & the Decorator Pipeline","k":"Onboarding Guide","t":"Opt-in by marker interface, pay only for what you use","x":"The pipeline is registered for every handler, but most decorators are dormant unless the use case asks for them. The switch is a set of tiny marker / role interfaces in…","i":"MMCA.Common.Application.UseCases GetProductByIdQuery IRequiresPermission GetTicketByIdQuery ICacheInvalidating FeatureManagement GetOrderByIdQuery GetNowNextQuery IQueryCacheable ITransactional CacheDuration IFeatureGated"},{"u":"/docs/onboarding/group-05-cqrs-pipeline.html#tenant-scoping-and-the-two-lock-tables","d":"5. CQRS: Commands, Queries & the Decorator Pipeline","k":"Onboarding Guide","t":"Tenant scoping and the two lock tables","x":"Both caching decorators are multi-tenant aware, and the reason is a nice illustration of where a cross-cutting concern has to live. ICacheService is a singleton and therefore…","i":"ICacheService.GetOrCreateAsync CachingQueryDecorator KeyedSemaphoreStripe QueryCacheKeyLocks ITenantContext TenantCacheKey CacheKeyLocks ICacheService IsResolved tenantId TenantId TResult"},{"u":"/docs/onboarding/group-05-cqrs-pipeline.html#two-supporting-pieces-and-a-worked-example","d":"5. CQRS: Commands, Queries & the Decorator Pipeline","k":"Onboarding Guide","t":"Two supporting pieces, and a worked example","x":"Two small helpers make the short-circuit decorators possible. ResultFailureFactory…","i":"cqrs.authorization.denied.count AuditableAggregateRootEntity TypeInitializationException InvalidOperationException RecordAuthorizationDenied DeleteSessionCommand DeleteSpeakerCommand ResultFailureFactory DeleteEntityCommand DeleteEntityHandler cqrs.timeout.count ICacheInvalidating"},{"u":"/docs/onboarding/group-05-cqrs-pipeline.html#the-other-application-layer-contracts-in-this-group","d":"5. CQRS: Commands, Queries & the Decorator Pipeline","k":"Onboarding Guide","t":"The other Application-layer contracts in this group","x":"Seven contracts sit beside the pipeline rather than inside it. They are declared here, in the Application layer, and implemented in Infrastructure or the composition root, which…","i":"UpcastingIntegrationEventConsumer AuditTrailSaveChangesInterceptor services.AddEventUpcaster InProcessDistributedLock IEventUpcasterRegistry DomainEventDispatcher IEntityRequestMapper RedisDistributedLock ICommandWithRequest IEntityDTOProjector EntityQueryService ScheduledJobRunner"},{"u":"/docs/onboarding/group-05-cqrs-pipeline.html#where-this-fits-and-the-failure-mode-contract","d":"5. CQRS: Commands, Queries & the Decorator Pipeline","k":"Onboarding Guide","t":"Where this fits, and the failure-mode contract","x":"These contracts sit in the Application layer of Clean Architecture (primer §1), above Domain and below Infrastructure and the API. The API layer (G12) resolves a closed handler…","i":"Microsoft.FeatureManagement.IFeatureManager MMCA.Common.Application.UseCases.Decorators Microsoft.Extensions.DependencyInjection CqrsMetrics.RecordAuthorizationDenied QueryCacheKeyLocks.Locks.AcquireAsync EventUpcastersHaveUniqueSourceTypes EventUpcastersIncreaseSchemaVersion IAggregateRootEntityControllerBase MMCA.Common.Application.Extensions MMCA.Common.Application.Interfaces ICacheService.RemoveByPrefixAsync correlationContext.CorrelationId"},{"u":"/docs/onboarding/group-06-validation.html","d":"6. Validation","k":"Onboarding Guide","x":"This chapter covers the small, framework-level validation kit that MMCA.Common.Application ships so that every consuming module validates command input the same way: a set of…","i":"AddressInvariants.AddressLine1MaxLength AddressInvariants.AddressLine2MaxLength ValidationFailureExtensions.ToErrors AddValidatorsFromAssemblyContaining AddressInvariants.CountryMaxLength AddressInvariants.ZipCodeMaxLength MMCA.Common.Application.Extensions MMCA.Common.Application.Validation System.Linq.Expressions.Expression AddressInvariants.StateMaxLength AddressInvariants.CityMaxLength MMCA.Common.Shared.ValueObjects"},{"u":"/docs/onboarding/group-07-persistence-ef-core.html","d":"7. Persistence & EF Core","k":"Onboarding Guide","x":"What this group covers. This is the framework's data-access engine: everything between a domain aggregate and a row in a database. It is the single largest group in the guide…","i":"SpecificationEvaluator ApplicationDbContext KeysetQueryBuilder SQLServerDbContext IWriteRepository SaveChangesAsync CosmosDbContext IReadRepository SqliteDbContext TIdentifierType IRepository UnitOfWork"},{"u":"/docs/onboarding/group-07-persistence-ef-core.html#one-base-context-one-class-per-engine-one-instance-per-database","d":"7. Persistence & EF Core","k":"Onboarding Guide","t":"One base context, one class per engine, one instance per database","x":"ApplicationDbContext (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:39) is an abstract primary-constructor class over EF's…","i":"Database.CreateExecutionStrategy DataSourceModelCacheKeyFactory IX_AuditTrailEntries_ChangedOn IX_InboxMessages_ProcessedOn IX_AuditTrailEntries_Entity IX_OutboxMessages_Processed IX_InboxMessages_MessageId IX_ScheduledJobs_NextRunOn PendingModelChangesWarning IX_OutboxMessages_Pending BeginTransactionAsync ChangeTracker.Entries"},{"u":"/docs/onboarding/group-07-persistence-ef-core.html#savechanges-as-an-interceptor-pipeline","d":"7. Persistence & EF Core","k":"Onboarding Guide","t":"SaveChanges as an interceptor pipeline","x":"The base context resolves its interceptors from DI in OnConfiguring (ApplicationDbContext.cs:236-261), and registration order is execution order. The audit interceptor runs…","i":"DomainEventSaveChangesInterceptor AuditSaveChangesInterceptor DispatchAndFinalizeAsync DiscardAbandonedCapture IDomainEventDispatcher BeginCaptureExclusion ConditionalWeakTable EndCaptureExclusion FlushDeferredAsync GetRequiredService RemoveDomainEvents CurrentSaveUserId"},{"u":"/docs/onboarding/group-07-persistence-ef-core.html#the-tenant-boundary-read-filter-plus-write-guard","d":"7. Persistence & EF Core","k":"Onboarding Guide","t":"The tenant boundary, read filter plus write guard","x":"Multi-tenancy (ADR-073) is two independent halves that meet in this group. The read half is the named Tenant query filter the base context applies to every non-owned…","i":"TenantSaveChangesInterceptor CrossTenantWriteException InvalidOperationException TenantDataSourceTargets TenantDataSourceTarget ApplicationDbContext IgnoreQueryFilters CurrentTenantId ITenantEntity TenantContext EF.Property e.TenantId"},{"u":"/docs/onboarding/group-07-persistence-ef-core.html#recording-what-changed-the-audit-trail","d":"7. Persistence & EF Core","k":"Onboarding Guide","t":"Recording what changed, the audit trail","x":"AuditTrailSaveChangesInterceptor (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailSaveChangesInterceptor.cs:62) is the fourth interceptor and…","i":"AuditTrailSaveChangesInterceptor TenantDataSourceTargets AuditTrailCleanupJob AuditTrailReader PendingEntityKey AuditTrailEntry CaptureContext IAuditedEntity AddAuditTrail ExecuteDelete IScheduledJob RedactedToken"},{"u":"/docs/onboarding/group-07-persistence-ef-core.html#repositories-specifications-and-the-unit-of-work","d":"7. Persistence & EF Core","k":"Onboarding Guide","t":"Repositories, specifications, and the unit of work","x":"Handlers do not touch a DbContext directly. They ask a UnitOfWork (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/UnitOfWork.cs:13) for a repository. The…","i":"TransactionCommitAmbiguousException DefaultSqlServerDbContextFactory EFReadRepository.ApplyIncludes ApplicationDbContextEFFactory DefaultCosmosDbContextFactory DefaultSqliteDbContextFactory UpdatePropertySetterBuilder EFReadRepositoryDecorator ExecuteInTransactionAsync HasPendingMigrationsAsync IPhysicalDbContextFactory ChangeTracker.HasChanges"},{"u":"/docs/onboarding/group-07-persistence-ef-core.html#routing-an-entity-to-its-database","d":"7. Persistence & EF Core","k":"Onboarding Guide","t":"Routing an entity to its database","x":"The heart of ADR-006 is that every entity resolves to a DataSourceKey (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/DataSourceKey.cs:15), a (Engine,…","i":"IEntityDataSourceRegistry EntityDataSourceRegistry UseDataSourceAttribute NamespaceConventions UseDatabaseAttribute IDataSourceResolver DataSourceResolver DataSourceService DataSourceKey GetModuleName DataSources DataSource"},{"u":"/docs/onboarding/group-07-persistence-ef-core.html#two-model-finalizing-conventions","d":"7. Persistence & EF Core","k":"Onboarding Guide","t":"Two model-finalizing conventions","x":"The base context adds both of its conventions in ConfigureConventions (ApplicationDbContext.cs:282-297), and each exists because a cross-cutting policy above would otherwise…","i":"CrossDataSourceDegradeConvention SoftDeleteUniqueIndexConvention IndexBuilderExtensions ConfigureConventions INavigationPopulator HasSoftDeleteFilter SoftDeleteFilterSql IndexBuilder extension IsDeleted TEntity Build"},{"u":"/docs/onboarding/group-07-persistence-ef-core.html#entity-configuration-and-engine-portability","d":"7. Persistence & EF Core","k":"Onboarding Guide","t":"Entity configuration and engine portability","x":"Concrete entity configurations derive from the engine-aware EntityTypeConfiguration…","i":"DefaultEntityConfigurationAssemblyProvider IEntityConfigurationAssemblyProvider IEntityTypeConfigurationSQLServer NullableEnumerationValueConverter NullablePhoneNumberValueConverter EntityTypeConfigurationSQLServer IEntityTypeConfigurationCosmos IEntityTypeConfigurationSqlite EntityTypeConfigurationCosmos EntityTypeConfigurationSqlite PushNotificationConfiguration UserNotificationConfiguration"},{"u":"/docs/onboarding/group-07-persistence-ef-core.html#encryption-seeding-design-time-and-the-shared-helpers","d":"7. Persistence & EF Core","k":"Onboarding Guide","t":"Encryption, seeding, design time, and the shared helpers","x":"A handful of supporting pieces round out the EF side. EncryptedStringConverter…","i":"PaymentReconciliationService IDesignTimeDbContextFactory DesignTimeDbContextOptions IdentityModuleDbSeederBase DesignTimeDbContextHelper NullDomainEventDispatcher PeriodicBackgroundService EncryptedStringConverter EntityDataSourceRegistry ExplicitAssemblyProvider EFQueryableExecutor DataSourceResolver"},{"u":"/docs/onboarding/group-07-persistence-ef-core.html#blobs-images-and-native-push","d":"7. Persistence & EF Core","k":"Onboarding Guide","t":"Blobs, images, and native push","x":"The group also carries the storage-adjacent infrastructure services that are not EF at all, each behind an Application-layer interface with a null default so a host that has not…","i":"AzureNotificationHubNativePushSender AzureNotificationHubDeviceRegistrar UpcastingIntegrationEventConsumer EventUpcasterStartupValidator FaultIntegrationEventConsumer AzureBlobFileStorageService ImageSharpImageProcessor NullPushDeviceRegistrar IEventUpcasterRegistry NullFileStorageService IPushDeviceRegistrar NullNativePushSender"},{"u":"/docs/onboarding/group-07-persistence-ef-core.html#where-this-group-sits","d":"7. Persistence & EF Core","k":"Onboarding Guide","t":"Where this group sits","x":"Persistence is the concrete floor the abstract domain stands on. The entity bases and audit contracts from Group 02 are what the interceptors stamp and the query filters hide;…","i":"MMCA.Common.Application.Interfaces.Infrastructure MMCA.Common.Infrastructure.Persistence.AuditTrail MMCA.Common.Infrastructure.Persistence.DbContexts MMCA.Common.Infrastructure.Persistence.Encryption DomainEventSaveChangesInterceptor.DropDeferred EntityTypeConfiguration.ApplyEngineConventions Microsoft.Extensions.Hosting.BackgroundService AddInfrastructure_RegistersIRepositoryFactory CrossTenantWriteException.ForUnresolvedTenant ModelBuilderExtensions.ApplyAllConfigurations DangerousAcceptAnyServerCertificateValidator RelationalEventId.PendingModelChangesWarning"},{"u":"/docs/onboarding/group-08-auth.html","d":"8. Authentication & Authorization","k":"Onboarding Guide","x":"What this group covers. This is the security spine of the framework: how a caller proves who they are (authentication), how the system decides what they may do (authorization),…","i":"SessionCookieAuthenticationHandler PermissionAuthorizationHandler IPasswordResetTokenService AuthenticationServiceBase PasswordResetTokenService AuthenticationValidators ClaimBasedUserIdProvider AuthorizationExtensions ILoginProtectionService IPasswordChangeableUser LoginProtectionSettings CookieSessionRefresher"},{"u":"/docs/onboarding/group-08-auth.html#tokens-one-signing-switch-two-validation-worlds","d":"8. Authentication & Authorization","k":"Onboarding Guide","t":"Tokens: one signing switch, two validation worlds","x":"The framework mints two credentials on every successful login: a short-lived access token (a JWT, 15 minutes by default,…","i":"OidcDiscoveryEndpointExtensions OpenIdConnectMetadataWarmupTask GetPrincipalFromExpiredToken ExecutionAndPublication JwksEndpointExtensions RandomNumberGenerator JwtSigningAlgorithm IValidatableObject additionalClaims SigningAlgorithm PublicationOnly RsaJwksProvider"},{"u":"/docs/onboarding/group-08-auth.html#the-shared-authentication-workflow","d":"8. Authentication & Authorization","k":"Onboarding Guide","t":"The shared authentication workflow","x":"Login, registration, refresh, and revocation are not re-implemented per app. They live once in AuthenticationServiceBase…","i":"RefreshTokenRequestValidator AuthenticationServiceBase FindUntrackedByEmailAsync AuthenticationValidators OAuthCodeExchangeRequest AuthenticationResponse CancellationToken.None IAuthenticationService AuthenticationRequest AuthenticationService ChangePasswordRequest ForgotPasswordRequest"},{"u":"/docs/onboarding/group-08-auth.html#what-the-apps-user-aggregate-must-expose","d":"8. Authentication & Authorization","k":"Onboarding Guide","t":"What the app's User aggregate must expose","x":"The shared workflows never see an app's User class. They see four small Domain-layer contracts, each sized to one workflow, which is the [Rubric §1, SOLID] interface-segregation…","i":"GetUserPreferencesHandlerBase ChangePreferencesHandlerBase ChangePasswordHandlerBase ChangePreferencesRequest ResetPasswordHandlerBase IPasswordChangeableUser UserPreferencesResponse DeleteUserHandlerBase AuditableBaseEntity RevokeRefreshToken UpdateRefreshToken UpdatePreferences"},{"u":"/docs/onboarding/group-08-auth.html#passwords-and-brute-force-protection","d":"8. Authentication & Authorization","k":"Onboarding Guide","t":"Passwords and brute-force protection","x":"Password material is handled by PasswordHasher (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/PasswordHasher.cs:12), which hashes with PBKDF2-HMAC-SHA512 at 600,000…","i":"CryptographicOperations.FixedTimeEquals ILoginProtectionService LoginProtectionSettings LoginProtectionService IDistributedCache MaxFailedAttempts MaxLockoutSeconds IPasswordHasher PasswordHasher ICacheService Email Range"},{"u":"/docs/onboarding/group-08-auth.html#forgot-password-a-cache-backed-single-use-token","d":"8. Authentication & Authorization","k":"Onboarding Guide","t":"Forgot password: a cache-backed single-use token","x":"A user who has lost the password cannot present one, so this flow is anonymous by necessity, which makes every one of its responses a potential account-enumeration oracle. It is…","i":"CryptographicOperations.FixedTimeEquals PasswordResetAuthControllerBase ForgotPasswordRequestValidator PasswordResetSettings.ResetUrl ResetPasswordRequestValidator IPasswordResetTokenService ForgotPasswordHandlerBase PasswordResetTokenService ResetPasswordHandlerBase ValidateAndConsumeAsync LoginProtectionService TForgotPasswordCommand"},{"u":"/docs/onboarding/group-08-auth.html#reading-identity-from-claims","d":"8. Authentication & Authorization","k":"Onboarding Guide","t":"Reading identity from claims","x":"Once a request is authenticated, downstream code needs the caller's identity without re-parsing the JWT. CurrentUserService…","i":"CultureInfo.InvariantCulture ClaimBasedUserIdProvider IHttpContextAccessor ICurrentUserService CurrentUserService ClaimsPrincipal IUserIdProvider AuthClaimTypes GetClaimValue Clients.User TokenService IsInRole"},{"u":"/docs/onboarding/group-08-auth.html#authorization-roles-permissions-ownership","d":"8. Authentication & Authorization","k":"Onboarding Guide","t":"Authorization: roles, permissions, ownership","x":"The framework supports three overlapping authorization styles, wired together by the single AddAuthorizationPolicies() extension in AuthorizationExtensions…","i":"PermissionAuthorizationHandler AllowMissingOwnerAttribute OwnerOrAdminFilterOptions PermissionRegistryBuilder AddAuthorizationPolicies PermissionPolicyProvider AuthorizationExtensions HasPermissionAttribute AuthorizationPolicies PermissionRequirement RequireAuthenticated IPermissionRegistry"},{"u":"/docs/onboarding/group-08-auth.html#session-cookies-keeping-ssr-authenticated","d":"8. Authentication & Authorization","k":"Onboarding Guide","t":"Session cookies: keeping SSR authenticated","x":"The final cluster solves a Blazor-specific problem: an interactive Blazor app keeps its access token in browser memory, but a cold server-side render (a new tab, an F5, an…","i":"CookieSessionRefreshMiddlewareExtensions SessionCookieAuthenticationExtensions SessionCookieAuthenticationHandler CookieSessionRefreshMiddleware ICookieSessionRefresher CookieSessionRefresher SessionCookieEndpoints KeyedSemaphoreStripe SessionCookieRequest SessionTokenResponse SessionTokenResult CookieTokenReader"},{"u":"/docs/onboarding/group-08-auth.html#privacy-the-data-subject-export-package","d":"8. Authentication & Authorization","k":"Onboarding Guide","t":"Privacy: the data-subject export package","x":"Three members of this group belong to the privacy surface that sits beside erasure. UserDataExportDTO (MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:15)…","i":"ExportUserDataHandlerBase DataExportControllerBase UserDataExportSectionDTO IUserDataExportSection Privacy.DataExport UserDataExportDTO PrivacyFeatures FormatVersion FeatureGate Authorize Available Subject"},{"u":"/docs/onboarding/group-08-auth.html#shared-primitives-and-adjacent-members","d":"8. Authentication & Authorization","k":"Onboarding Guide","t":"Shared primitives and adjacent members","x":"Four group members are general-purpose primitives that landed in this chapter because of how the dependency grouping fell, though one of them is now load-bearing for auth.…","i":"MMCA.Common.Application.Interfaces.Infrastructure AuthorizationExtensions.AddAuthorizationPolicies context.ActionDescriptor.EndpointMetadata.OfType Microsoft.AspNetCore.Http.IHttpContextAccessor JsonWebKeyConverter.ConvertFromRSASecurityKey SessionCookieAuthenticationHandler.SchemeName Microsoft.AspNetCore.SignalR.IUserIdProvider Microsoft.IdentityModel.Tokens.JsonWebKeySet services.AddValidatorsFromAssemblyContaining ArgumentException.ThrowIfNullOrWhiteSpace CookieTokenReader.FreshAccessTokenItemKey ICookieSessionRefresher.GetOrRefreshAsync"},{"u":"/docs/onboarding/group-09-caching.html","d":"9. Caching","k":"Onboarding Guide","x":"What this group covers. Caching in this codebase is small, deliberate, and woven into the CQRS pipeline rather than scattered across handlers. The group is eight types: one port…","i":"Microsoft.Extensions.Caching.Hybrid.HybridCache HybridCacheEntryFlags.DisableUnderlyingData StackExchange.Redis.IConnectionMultiplexer Microsoft.Extensions.Options.IOptions MMCA.Common.Application.Interfaces MMCA.Common.Infrastructure.Caching DistributedCacheServiceRedisTests connectionMultiplexer.GetServers AbsoluteExpirationRelativeToNow PublicEndpointOutputCachePolicy System.Text.Json.JsonSerializer LogPrefixEvictionNoMultiplexer"},{"u":"/docs/onboarding/group-10-notifications.html","d":"10. Notifications (Push + In-App Inbox + Email)","k":"Onboarding Guide","x":"What this group covers. This is the notification subsystem, the machinery that turns \"an organizer wants to tell every attendee something\" into messages that actually reach…","i":"INotificationRecipientProvider NullPushNotificationSender NullLiveChannelPublisher IPushNotificationSender ILiveChannelPublisher NotificationModule DevicesController INativePushSender UserNotification NotificationHub SmtpEmailSender IEmailSender"},{"u":"/docs/onboarding/group-10-notifications.html#the-layering-and-why-the-pieces-sit-where-they-do","d":"10. Notifications (Push + In-App Inbox + Email)","k":"Onboarding Guide","t":"The layering, and why the pieces sit where they do","x":"The dependency flow of the group mirrors the framework's Clean Architecture story ([Rubric §3, Clean Architecture]). The Domain layer holds the two aggregates, PushNotification…","i":"NullNotificationRecipientProvider Notification.PushNotifications SignalRPushNotificationSender SendPushNotificationRequest SignalRLiveChannelPublisher NullPushNotificationSender PushNotificationInvariants DeviceInstallationRequest NullLiveChannelPublisher NotificationsController PushNotificationCreated PushNotificationStatus"},{"u":"/docs/onboarding/group-10-notifications.html#the-broadcast-send-flow-end-to-end","d":"10. Notifications (Push + In-App Inbox + Email)","k":"Onboarding Guide","t":"The broadcast send flow, end to end","x":"Sending a notification is a command-side vertical slice ([Rubric §5, Vertical Slice], [Rubric §6, CQRS & Event-Driven]). An organizer POSTs to NotificationsController, which is…","i":"NotificationFeatures.PushNotifications AttendeeNotificationRecipientProvider AddNotificationApplicationServices NullNotificationRecipientProvider INotificationRecipientProvider PushNotification.NoRecipients unitOfWork.GetReadRepository SendPushNotificationCommand SendPushNotificationHandler PushNotificationDTOMapper IPushNotificationSender NotificationsController"},{"u":"/docs/onboarding/group-10-notifications.html#the-inbox-side","d":"10. Notifications (Push + In-App Inbox + Email)","k":"Onboarding Guide","t":"The inbox side","x":"Reading and acknowledging notifications is the query/command counterpart, served by InboxController under the same feature gate and [Authorize(RequireAuthenticated)], so any user…","i":"GetUnreadNotificationCountQuery MarkAllNotificationsReadCommand MarkNotificationReadCommand MarkNotificationReadHandler ICurrentUserService.UserId GetMyNotificationsHandler UserNotification.NotFound GetMyNotificationsQuery RequireAuthenticated PushNotification UserNotification InboxController"},{"u":"/docs/onboarding/group-10-notifications.html#the-signalr-transport-and-how-it-survives-extraction","d":"10. Notifications (Push + In-App Inbox + Email)","k":"Onboarding Guide","t":"The SignalR transport, and how it survives extraction","x":"NotificationHub is intentionally thin (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Hubs/NotificationHub.cs:16-17): it is [Authorize]d, and beyond ASP.NET's built-in…","i":"LiveChannelPublisherGrpcAdapter services__notification__grpc__0 SignalRPushNotificationSender SignalRLiveChannelPublisher NullLiveChannelPublisher LiveChannelGrpcService ILiveChannelPublisher AddPushNotifications RequireAuthorization _grpc.notification MapNotificationHub NotificationHub"},{"u":"/docs/onboarding/group-10-notifications.html#the-module-host-native-device-registration-and-the-privacy-export","d":"10. Notifications (Push + In-App Inbox + Email)","k":"Onboarding Guide","t":"The module host, native-device registration, and the privacy export","x":"On the ADC side the whole capability is packaged by NotificationModule (MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.API/NotificationModule.cs:15), an IModule that…","i":"UserNotificationExportServiceGrpcAdapter DisabledUserNotificationExportService UserNotificationExportGrpcService IUserNotificationExportService UserNotificationExportItemDTO UserNotificationExportService currentUserService.UserId DeviceInstallationRequest AddNotificationModule IPushDeviceRegistrar RequiresDependencies DependencyInjection"},{"u":"/docs/onboarding/group-10-notifications.html#where-this-group-sits","d":"10. Notifications (Push + In-App Inbox + Email)","k":"Onboarding Guide","t":"Where this group sits","x":"Upstream, this group depends on the domain building blocks of Group 02 (both aggregates derive from AuditableAggregateRootEntity ), the Result pattern of Group 01, the CQRS…","i":"LiveChannelPushService.LiveChannelPushServiceBase MMCA.Common.Application.Interfaces.Infrastructure MMCA.ADC.Notification.Shared.UserNotifications attendeeQueryService.GetAttendeeUserIdsAsync services.AddNotificationApplicationServices PushNotificationDTOProjection.ProjectToDTO PushNotificationProjectionTranslationTests MMCA.Common.API.Controllers.Notifications NotificationHub.ReceiveNotificationMethod PushNotificationInvariants.TitleMaxLength Microsoft.Extensions.DependencyInjection PushNotificationInvariants.BodyMaxLength"},{"u":"/docs/onboarding/group-11-navigation-populators.html","d":"11. Navigation Metadata & Populators (EF-decoupled eager loading)","k":"Onboarding Guide","x":"EF Core gives you .Include() for eager loading, and for a single SQL Server database that is the right tool. But this codebase is a database-per-service modular monolith…","i":"navigationMetadata.UnsupportedIncludes.Count MMCA.Common.Application.Services.Navigation NavigationLoader.LoadChildrenPropertyAsync NavigationMetadataProvider.BuildIncludes NavigationMetadata.UnsupportedIncludes IDataSourceService.HaveIncludeSupport NavigationLoader.LoadFKPropertyAsync INavigationPopulator.PopulateAsync MMCA.Common.Application.Interfaces DeclarativeNavigationPopulator.cs CrossDataSourceDegradeConvention EntityQueryPipeline.ExecuteAsync"},{"u":"/docs/onboarding/group-12-api-hosting-mapping.html","d":"12. API Hosting, Middleware, Idempotency & DTO/Contract Mapping","k":"Onboarding Guide","x":"What this group covers. This is the ASP.NET Core edge of the framework: the layer that turns an HTTP request into a domain call and turns a Result back into an HTTP response.…","i":"Microsoft.Extensions.Localization.LocalizedString Microsoft.AspNetCore.Http.IProblemDetailsService Microsoft.EntityFrameworkCore.DbUpdateException Microsoft.AspNetCore.Http.IHttpContextAccessor Microsoft.IdentityModel.Tokens.JsonWebKeySet MiddlewarePipelineStepNames.TenantResolution System.Threading.RateLimiting.RateLimitLease IDbContextFactory.HasPendingMigrationsAsync Microsoft.AspNetCore.Hosting.IStartupFilter AuthorizationPolicies.RequireAuthenticated Microsoft.AspNetCore.Authentication.Google StackExchange.Redis.IConnectionMultiplexer"},{"u":"/docs/onboarding/group-13-grpc-contracts.html","d":"13. gRPC & Inter-Service Contracts","k":"Onboarding Guide","x":"What this chapter is about. Once the ADC modules stopped sharing a process and became four separate service hosts (Identity, Conference, Engagement, Notification), the in-process…","i":"Microsoft.AspNetCore.Http.IHttpContextAccessor ArgumentException.ThrowIfNullOrWhiteSpace Microsoft.Extensions.DependencyInjection ErrorHttpMapping.ErrorTypeToStatusCode AddConferenceSessionValidationClient Microsoft.Extensions.Http.Resilience Microsoft.Extensions.Logging.ILogger ResultGrpcExtensions.ThrowIfFailure ResultGrpcExtensions.ToRpcException Grpc.Core.Interceptors.Interceptor ArgumentNullException.ThrowIfNull ISessionBookmarkValidationService"},{"u":"/docs/onboarding/group-14-module-system-composition.html","d":"14. Module System, Composition & Configuration","k":"Onboarding Guide","x":"What this chapter covers. This is the wiring layer, the code that turns a pile of layered assemblies into a running host. It answers three questions a new host author asks: how…","i":"ConnectionStringSettings InProcessDistributedLock PushNotificationSettings UseDataSourceAttribute RedisDistributedLock UseDatabaseAttribute ApplicationSettings DataSourcesSettings DependencyInjection FileStorageSettings PersistenceSettings AuditTrailSettings"},{"u":"/docs/onboarding/group-14-module-system-composition.html#the-module-contract-and-the-boundary-it-creates","d":"14. Module System, Composition & Configuration","k":"Onboarding Guide","t":"The module contract and the boundary it creates","x":"A module is the unit of cohesion above a feature slice: Conference, Engagement, Identity, Notification. Each one implements IModule…","i":"DisabledSessionBookmarkValidationService DisabledEventLiveValidationService GetSessionBookmarkCountHandler IBookmarkCountService RegisterDisabledStubs RequiresDependencies AddConferenceModule applicationSettings ConferenceModule moduleEnabled Dependencies Register"},{"u":"/docs/onboarding/group-14-module-system-composition.html#discovery-and-kahn-ordered-registration","d":"14. Module System, Composition & Configuration","k":"Onboarding Guide","t":"Discovery and Kahn-ordered registration","x":"ModuleLoader (MMCA.Common/Source/Core/MMCA.Common.Application/Modules/ModuleLoader.cs:15) is the engine. Its DiscoverAndRegister comes in two overloads: the short one…","i":"AppDomain.CurrentDomain.GetAssemblies ModulesSettings.IsModuleEnabled ValidateModuleDependencies ValidateRemoteDependencies Activator.CreateInstance IModuleSeeder.SeedAsync RegisterDisabledStubs RegisterEnabledModule RequiresDependencies DisabledModuleNames DiscoverAndRegister RemoteDependencies"},{"u":"/docs/onboarding/group-14-module-system-composition.html#the-two-composition-roots-and-the-ordering-they-enforce","d":"14. Module System, Composition & Configuration","k":"Onboarding Guide","t":"The two composition roots and the ordering they enforce","x":"Service registration itself lives in two static DependencyInjection classes, each using a C extension(IServiceCollection services) block (see primer §4 for the extension(T)…","i":"AuthorizationCommandDecorator ScanModuleApplicationServices FeatureGateCommandDecorator INavigationMetadataProvider AddNativePushNotifications AddApplicationDecorators InProcessDistributedLock AddApplicationProfiling AddAzureBlobFileStorage CommandRequestValidator TimeoutCommandDecorator IConnectionMultiplexer"},{"u":"/docs/onboarding/group-14-module-system-composition.html#opt-in-platform-features-are-composed-the-same-way","d":"14. Module System, Composition & Configuration","k":"Onboarding Guide","t":"Opt-in platform features are composed the same way","x":"Four capabilities are registered beside the roots rather than inside them, and they share one discipline: registering a feature is not the same as turning it on.…","i":"AuditTrailSaveChangesInterceptor TenantSaveChangesInterceptor AddUserDataExportSection TenancySettingsValidator IUserDataExportSection MMCA.Common.Scheduler AuditTrailEntryDTO AuditTrailSettings ExecuteUpdateAsync MMCA.Common.Broker ScheduledJobRunner AddInfrastructure"},{"u":"/docs/onboarding/group-14-module-system-composition.html#assembly-anchors","d":"14. Module System, Composition & Configuration","k":"Onboarding Guide","t":"Assembly anchors","x":"Several pieces of machinery need a Type whose Assembly identifies a layer: Scrutor's FromAssemblyOf () scans, FluentValidation's AddValidatorsFromAssemblyContaining (), and…","i":"AddValidatorsFromAssemblyContaining AddInfrastructure AssemblyReference AddApplication ClassReference FromAssemblyOf AssemblyName Assembly static class Type"},{"u":"/docs/onboarding/group-14-module-system-composition.html#configuration-binding-the-settings-family","d":"14. Module System, Composition & Configuration","k":"Onboarding Guide","t":"Configuration binding, the Settings family","x":"Everything a host operator tunes arrives as a strongly-typed settings object bound from an appsettings.json section, each carrying a static readonly string SectionName so the…","i":"TenantDataSourceOverrideSettings EffectiveExcludedPathPrefixes ScheduledJobOverrideSettings IValidatableObject.Validate IConnectionStringSettings IPushNotificationSettings SQLServerConnectionString ConnectionStringSettings EffectiveResolutionOrder PushNotificationSettings TenancySettingsValidator TenantResolutionStrategy"},{"u":"/docs/onboarding/group-14-module-system-composition.html#the-two-routing-attributes","d":"14. Module System, Composition & Configuration","k":"Onboarding Guide","t":"The two routing attributes","x":"Two attributes, both in MMCA.Common.Infrastructure, both Inherited = true so they ride down a configuration class hierarchy, encode where an entity is stored declaratively: the…","i":"MMCA.Common.Infrastructure EntityDataSourceRegistry UseDataSourceAttribute UseDatabaseAttribute DataSourceResolver DbContextFactory DataSource Inherited Domain true"},{"u":"/docs/onboarding/group-14-module-system-composition.html#shared-user-use-case-bases-composition-in-the-other-direction","d":"14. Module System, Composition & Configuration","k":"Onboarding Guide","t":"Shared user use-case bases: composition in the other direction","x":"The chapter's last family is composition at the handler level rather than the container level. ADC and Store each own an Identity module, and seven of their account use cases had…","i":"UserOwnershipRule.CheckOwnership GetUserPreferencesHandlerBase UserDataExportSectionDefaults ChangePreferencesHandlerBase UserDataExportSectionResult IPasswordResetTokenService ChangePasswordHandlerBase ExportUserDataHandlerBase ForgotPasswordHandlerBase ISoftDeletedUserValidator ResetPasswordHandlerBase SoftDeletedUserValidator"},{"u":"/docs/onboarding/group-14-module-system-composition.html#end-to-end-one-hosts-boot","d":"14. Module System, Composition & Configuration","k":"Onboarding Guide","t":"End-to-end: one host's boot","x":"Reading MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs top to bottom shows the whole chapter cooperating. The host binds and validates ApplicationSettings and…","i":"MMCA.Common.Application.Interfaces.Infrastructure MMCA.Common.Application.Users.UseCases.DeleteUser UserDataExportSectionDefaults.UnavailableReason TenancySettingsValidator.ConnectionStringFor DefaultEntityConfigurationAssemblyProvider ArgumentException.ThrowIfNullOrWhiteSpace HandleAsync_ConsumesTheTokenBeforeSaving InProcessDistributedLock.TryAcquireAsync Microsoft.Extensions.DependencyInjection ScheduledJobRunner.ResolveCronExpression UserDataExportSectionResult.Unavailable UserUseCaseLog.ExportSectionUnavailable"},{"u":"/docs/onboarding/group-15-common-ui-framework.html","d":"15. Common UI Framework (MudBlazor components, theme, base pages)","k":"Onboarding Guide","x":"What this chapter covers. MMCA.Common.UI is the Blazor presentation package, and it is one of the two layers (with Grpc) allowed to reference Shared only: its single…","i":"Microsoft.AspNetCore.Components.NavigationManager Microsoft.Extensions.Configuration.IConfiguration Microsoft.AspNetCore.Builder.IApplicationBuilder Microsoft.AspNetCore.Components.DynamicComponent MauiBackNavigationBridge.HandleBackPressedAsync Microsoft.AspNetCore.WebUtilities.QueryHelpers AuthenticatedServiceBase.NewIdempotencyKey HttpResilienceDefaults.TotalRequestTimeout ArgumentException.ThrowIfNullOrWhiteSpace CultureInfo.DefaultThreadCurrentUICulture ITokenStorageService.GetAccessTokenAsync Microsoft.Extensions.DependencyInjection"},{"u":"/docs/onboarding/group-16-aspire-orchestration.html","d":"16. Aspire Orchestration & Service Defaults","k":"Onboarding Guide","x":"This chapter covers the hosting boundary: how the distributed MMCA system is composed and run, locally as a single dotnet run, and in Azure Container Apps (ACA) as a set of…","i":"MMCA.Common.Aspire.Gateway MMCA.Common.Aspire.Hosting AddServiceDefaults MMCA.Common.Aspire MMCA.Common.Shared MMCA.ADC.AppHost Aspire.Hosting dotnet run"},{"u":"/docs/onboarding/group-16-aspire-orchestration.html#the-orchestrator-declaring-the-resource-graph","d":"16. Aspire Orchestration & Service Defaults","k":"Onboarding Guide","t":"The orchestrator: declaring the resource graph","x":"When you run dotnet run --project Source/Hosting/MMCA.ADC.AppHost, Aspire executes Program.cs top to bottom, building a resource model rather than starting anything immediately.…","i":"LoginProtection__MaxRegistrationsPerIpPerHour ConnectionStrings__SQLServerConnectionString Authentication__JwtBearer__Authority WithE2eRegistrationThrottleLift E2E_LIFT_REGISTRATION_THROTTLE E2eRegistrationsPerIpPerHour __SQLServerConnectionString MMCA.Common.Aspire.Hosting DefaultBrokerResourceName E2E_JWT_PRIVATE_KEY_PEM WithSQLServerDataSource E2E_JWT_PUBLIC_KEY_PEM"},{"u":"/docs/onboarding/group-16-aspire-orchestration.html#startup-ordering-and-the-grpc-deadlock-avoidance-trick","d":"16. Aspire Orchestration & Service Defaults","k":"Onboarding Guide","t":"Startup ordering and the gRPC deadlock-avoidance trick","x":"Aspire's WaitFor builds a startup dependency graph from the resource model: a service does not start until the resources it waits on report healthy (/health/ready for projects,…","i":"ISessionBookmarkValidationService IBookmarkCountService AddTypedGrpcClient WaitFor"},{"u":"/docs/onboarding/group-16-aspire-orchestration.html#the-service-baseline-addservicedefaults","d":"16. Aspire Orchestration & Service Defaults","k":"Onboarding Guide","t":"The service baseline: AddServiceDefaults()","x":"Every running host calls one method first in Program.cs: AddServiceDefaults() from the single framework-grade Extensions in MMCA.Common.Aspire (Level 2,…","i":"EnableMultipleHttp2Connections ConfigureHttpClientDefaults AddDefaultHealthChecks ConfigureOpenTelemetry AddServiceDiscovery AddServiceDefaults AddWarmupReadiness MMCA.Common.Aspire SocketsHttpHandler HttpClient Program.cs TBuilder"},{"u":"/docs/onboarding/group-16-aspire-orchestration.html#one-source-of-truth-for-resilience-numbers","d":"16. Aspire Orchestration & Service Defaults","k":"Onboarding Guide","t":"One source of truth for resilience numbers","x":"The numbers that pipeline uses are not literals in the Aspire package. They live in HttpResilienceDefaults (Level 0,…","i":"MMCA.Common.Infrastructure MMCA.Common.Grpc Continuity properties Resilience RetryCount including Business Concerns lifetime sampling attempt"},{"u":"/docs/onboarding/group-16-aspire-orchestration.html#listeners-and-probes-one-kestrel-profile-per-host-shape","d":"16. Aspire Orchestration & Service Defaults","k":"Onboarding Guide","t":"Listeners and probes: one Kestrel profile per host shape","x":"Before a service can answer a health probe it has to be listening on a protocol the prober speaks, and that is not free when the same port serves inbound gRPC.…","i":"redeclareCleartextEndpoint ASPNETCORE_HTTP_PORTS HttpProtocols.Http2 MapDefaultEndpoints BuildListenerPlan HTTP_1_1_REQUIRED Http1AndHttp2 Deployment Protocols deployed profiles httpGet"},{"u":"/docs/onboarding/group-16-aspire-orchestration.html#health-checks-liveness-readiness-and-the-optional-tag","d":"16. Aspire Orchestration & Service Defaults","k":"Onboarding Guide","t":"Health checks: liveness, readiness, and the \"optional\" tag","x":"MapDefaultEndpoints() (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:336) exposes the three-probe surface the platform reads: /health (every check, for humans and…","i":"AddInfrastructureHealthChecks AddDefaultHealthChecks MapDefaultEndpoints requireSqlServer Observability Operability Deployment optional Optional DevOps Rubric Ready"},{"u":"/docs/onboarding/group-16-aspire-orchestration.html#telemetry-what-gets-exported-and-what-it-costs","d":"16. Aspire Orchestration & Service Defaults","k":"Onboarding Guide","t":"Telemetry: what gets exported, and what it costs","x":"ConfigureOpenTelemetry() (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:121) wires logging with formatted messages and scopes (:123-127), metrics, and tracing. It…","i":"APPLICATIONINSIGHTS_CONNECTION_STRING ActivityTraceFlags.Recorded OTEL_EXPORTER_OTLP_ENDPOINT AddOpenTelemetryExporters IsInstrumentationDisabled TraceIdRatioBasedSampler MMCA.Common.Idempotency MMCA.Common.OutputCache ConfigureOpenTelemetry MMCA.Common.BestEffort TryGetTraceSampleRatio MMCA.Common.Scheduler"},{"u":"/docs/onboarding/group-16-aspire-orchestration.html#warm-up-defeating-aca-cold-start","d":"16. Aspire Orchestration & Service Defaults","k":"Onboarding Guide","t":"Warm-up: defeating ACA cold-start","x":"The warm-up subsystem exists for one concrete failure mode: the \"first request fails, second succeeds\" pattern on a CPU-throttled idle ACA replica, where lazy initialization…","i":"RequireSuccessStatusCode HealthCheckTags.Ready WebApplicationFactory Interlocked.Exchange RequestVersionPolicy AddServiceDefaults AddWarmupReadiness ApplicationStarted IHttpClientFactory BackgroundService ResolveWarmupPort WithJwksDiscovery"},{"u":"/docs/onboarding/group-16-aspire-orchestration.html#configuration-secrets-the-vault-as-one-more-configuration-source","d":"16. Aspire Orchestration & Service Defaults","k":"Onboarding Guide","t":"Configuration secrets: the vault as one more configuration source","x":"Connection strings, RSA signing keys and OAuth client secrets have to reach the process somehow, and KeyVaultConfigurationExtensions (Level 0,…","i":"AddCommonDataProtection DefaultAzureCredential builder.Configuration ConfigurationManager AddServiceDefaults IConfiguration Deployment Security answer DevOps Rubric the"},{"u":"/docs/onboarding/group-16-aspire-orchestration.html#security-headers-cors-and-the-shared-key-ring-at-the-host-edge","d":"16. Aspire Orchestration & Service Defaults","k":"Onboarding Guide","t":"Security headers, CORS, and the shared key ring at the host edge","x":"The next boundary in this group hardens the request and response edge. The shared response-header middleware is defined entirely in…","i":"AddCommonSecurityHeaders UseCommonSecurityHeaders AddCommonDataProtection DefaultAzureCredential AddCommonGatewayCors AddCommonBlazorCsp PermissionsPolicy MMCA.Common.API TryAddSingleton ReferrerPolicy AddCommonCors FrameOptions"},{"u":"/docs/onboarding/group-16-aspire-orchestration.html#the-gateway-edge-kit-correlation-rate-limiting-downstream-readiness","d":"16. Aspire Orchestration & Service Defaults","k":"Onboarding Guide","t":"The gateway edge kit: correlation, rate limiting, downstream readiness","x":"A YARP gateway is the one process every client request passes through, and it is also the one host that has no application container: no DbContext, no module loader, no…","i":"AddServiceDiscoveryDestinationResolver AddGatewayDownstreamHealthChecks Connection.RemoteIpAddress MMCA.Common.Aspire.Gateway Validator.ValidateObject HttpResponse.OnStarting ValidateDataAnnotations AddGatewayRateLimiting GlobalConcurrencyLimit UseGatewayRateLimiting ConfigureClusterAsync RequestVersionOrLower"},{"u":"/docs/onboarding/group-16-aspire-orchestration.html#how-it-all-fits-at-runtime","d":"16. Aspire Orchestration & Service Defaults","k":"Onboarding Guide","t":"How it all fits at runtime","x":"Putting the pieces in sequence: the AppHost declares the graph and injects per-service env vars (WithSQLServerDataSource, WithBroker, WithJwksDiscovery, the E2E helpers, and the…","i":"Azure.Extensions.AspNetCore.Configuration.Secrets Azure.Extensions.AspNetCore.DataProtection.Blobs identityService.WithE2eRegistrationThrottleLift AddGatewayDownstreamHealthChecks_IsIdempotent LoginProtection__MaxRegistrationsPerIpPerHour ConnectionStrings__SQLServerConnectionString database.Resource.ConnectionStringExpression Readiness_IncludesADownstreamCheckPerService ResilienceCircuitBreakerFaultInjectionTests WarmupReadinessHealthCheck.CheckHealthAsync HttpKeepAlivePingPolicy.WithActiveRequests cancellationToken.IsCancellationRequested"},{"u":"/docs/onboarding/group-17-conference-domain.html","d":"17. ADC Conference - Domain Model & Module Contracts","k":"Onboarding Guide","x":"What this chapter covers. This is the heart of the Atlanta Developers Conference application, the Conference bounded context, the largest and richest domain in MMCA.ADC. It…","i":"AuditableAggregateRootEntity MMCA.ADC.Conference.Shared IdValueGeneratedAttribute INavigationPopulator EntityChangedEvent DomainEntityState TIdentifierType IAuditedEntity IModule TEntity Design Result"},{"u":"/docs/onboarding/group-17-conference-domain.html#two-packages-one-bounded-context","d":"17. ADC Conference - Domain Model & Module Contracts","k":"Onboarding Guide","t":"Two packages, one bounded context","x":"The Conference context spans two of the module's projects, and the split is deliberate Clean Architecture ([Rubric §3, Clean Architecture]). MMCA.ADC.Conference.Domain holds the…","i":"ISessionBookmarkValidationService IEventLiveValidationService MMCA.ADC.Conference.Domain MMCA.ADC.Conference.Shared SessionFeedbackSubmitted SpeakerUnlinkedFromUser EventFeedbackSubmitted SpeakerLinkedToUser MMCA.Common.Domain AssemblyReference ClassReference Architecture"},{"u":"/docs/onboarding/group-17-conference-domain.html#eight-aggregates-and-their-ownership-boundaries","d":"17. ADC Conference - Domain Model & Module Contracts","k":"Onboarding Guide","t":"Eight aggregates and their ownership boundaries","x":"An aggregate is a root entity plus the children it exclusively owns; invariants are enforced inside the boundary, and references across aggregates are by ID, never by object…","i":"AuditableAggregateRootEntity RecordSessionizeRefresh SessionQuestionAnswer SpeakerQuestionAnswer EventQuestionAnswer SessionCategoryItem SpeakerCategoryItem Session.StartsAt IDENTITY_INSERT TIdentifierType IAuditedEntity QuestionEntity"},{"u":"/docs/onboarding/group-17-conference-domain.html#the-aggregate-shape-taught-once","d":"17. ADC Conference - Domain Model & Module Contracts","k":"Onboarding Guide","t":"The aggregate shape, taught once","x":"Open any of the roots and you will see the same skeleton; this repetition is the point, and it is what makes the per-type sections that follow read quickly. The shape, using…","i":"Session.SessionQuestionAnswers Event.EventQuestionAnswers IReadOnlyCollection RestoreEventSpeaker isIdValueGenerated _rooms.AsReadOnly Result.Combine Architecture IsCollection Room.EventId base.Delete Performance"},{"u":"/docs/onboarding/group-17-conference-domain.html#invariants-business-rules-as-testable-units","d":"17. ADC Conference - Domain Model & Module Contracts","k":"Onboarding Guide","t":"Invariants, business rules as testable units","x":"Each aggregate has a co-located static invariant class, EventInvariants (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:10),…","i":"System.Net.Mail.MailAddress ActivityInvariants CategoryInvariants QuestionInvariants SessionInvariants SpeakerInvariants SponsorInvariants CommonInvariants EventInvariants SessionStatuses Result.Combine Decline_Queue"},{"u":"/docs/onboarding/group-17-conference-domain.html#domain-events-and-the-outbox-spine","d":"17. ADC Conference - Domain Model & Module Contracts","k":"Onboarding Guide","t":"Domain events and the outbox spine","x":"Every state-changing method raises a domain event through the inherited AddDomainEvent(...). The events come in two shapes. The aggregate-level ones, EventChanged,…","i":"SessionCategoryItemChanged SpeakerCategoryItemChanged SessionSpeakerChanged PreviousLinkedUserId CategoryItemChanged EventSpeakerChanged EntityChangedEvent DomainEntityState SaveChangesAsync ActivityChanged CategoryChanged QuestionChanged"},{"u":"/docs/onboarding/group-17-conference-domain.html#the-cross-aggregate-cascade-a-pure-domain-service","d":"17. ADC Conference - Domain Model & Module Contracts","k":"Onboarding Guide","t":"The cross-aggregate cascade: a pure domain service","x":"One business rule cannot live inside a single aggregate: deleting an Event must also delete every Session belonging to it (BR-127), every Sponsor sold against it, and every…","i":"IEventCascadeDeletionDomainService EventCascadeDeletionDomainService Activity EventId Session Sponsor Design Rubric Event List"},{"u":"/docs/onboarding/group-17-conference-domain.html#read-models-and-the-ai-decision-support-feature","d":"17. ADC Conference - Domain Model & Module Contracts","k":"Onboarding Guide","t":"Read models and the AI decision-support feature","x":"The largest cluster in Conference.Shared is the DTO layer, the wire contracts that decouple the API from the domain entities ([Rubric §9, API & Contract Design]; ADR-001 chose…","i":"RefreshFromSessionizeResultDTO RefreshFromSessionizeCommand SessionSelectionDashboardDTO ScoreEventSessionsResultDTO CategoryGroupDistribution Conference.Infrastructure CategoryItemDistribution SessionQuestionAnswerDTO SpeakerQuestionAnswerDTO SpeakerSessionOverlapDTO CategoryDistributionDTO ConcurrencyTokenRequest"},{"u":"/docs/onboarding/group-17-conference-domain.html#authorization-vocabulary-and-current-event-selection","d":"17. ADC Conference - Domain Model & Module Contracts","k":"Onboarding Guide","t":"Authorization vocabulary and current-event selection","x":"Two more Shared helpers deserve a mention because they encode policy the whole module relies on. ConferencePermissions…","i":"TimeZoneInfo.ConvertTimeToUtc ConferenceReadAudience ConferencePermissions CurrentEventDefaults CurrentEventSelector ContentManagement ContentEditor HasPermission Organizer RoleNames StartDate EventDTO"},{"u":"/docs/onboarding/group-17-conference-domain.html#crossing-the-module-boundary-contracts-stubs-and-integration-events","d":"17. ADC Conference - Domain Model & Module Contracts","k":"Onboarding Guide","t":"Crossing the module boundary: contracts, stubs, and integration events","x":"Conference does not live alone. Three kinds of connection point join it to other modules, and all live in Conference.Shared so neither side reaches into the other's domain…","i":"DisabledSessionBookmarkValidationService DisabledEventLiveValidationService ISessionBookmarkValidationService IEventLiveValidationService QuestionModerationDefault SessionFeedbackSubmitted SpeakerUnlinkedFromUser Conference.Application EventFeedbackSubmitted BaseIntegrationEvent User.LinkedSpeakerId SpeakerLinkedToUser"},{"u":"/docs/onboarding/group-17-conference-domain.html#end-to-end-one-organizer-action","d":"17. ADC Conference - Domain Model & Module Contracts","k":"Onboarding Guide","t":"End-to-end: one organizer action","x":"To see the chapter cooperate, follow an organizer renaming a room on an event. The application handler loads the Event aggregate (with its Rooms hydrated by the navigation…","i":"CategoryInvariants.EnsureCategoryItemNameIsUnique IEventLiveValidationService.GetEventLiveInfoAsync MMCA.ADC.Conference.Domain.Questions.DomainEvents MMCA.ADC.Conference.Domain.Sessions.DomainEvents MMCA.ADC.Conference.Domain.Speakers.DomainEvents MMCA.ADC.Conference.Domain.Sponsors.DomainEvents IUserSpeakerLinkService.ClearLinkedSpeakerAsync MMCA.ADC.Conference.Domain.Events.DomainEvents SessionInvariants.EnsureAnswerValueIsValid CurrentEventSelector.SelectCurrentOrNext DisabledSessionBookmarkValidationService EventInvariants.EnsureAnswerValueIsValid"},{"u":"/docs/onboarding/group-18-conference-application.html","d":"18. ADC Conference - Application & Use Cases","k":"Onboarding Guide","x":"What this chapter covers. This is the application layer of the Conference module, the largest single application assembly in the codebase (this group covers 285 entries in the…","i":"MMCA.Common.Application StatusBucket ics"},{"u":"/docs/onboarding/group-18-conference-application.html#the-vertical-slice-anatomy-of-a-use-case","d":"18. ADC Conference - Application & Use Cases","k":"Onboarding Guide","t":"The vertical-slice anatomy of a use case","x":"Open any feature folder under Sessions/UseCases/, Events/UseCases/, Speakers/UseCases/, Sponsors/UseCases/, Activities/UseCases/, Categories/UseCases/, or Questions/UseCases/ and…","i":"ValidateRoomAssignmentAsync BuildOverlapPredicate SessionizeIntegration EventQuestionAnswers UnprocessableEntity Event.Publish EventSpeakers int.MinValue HandleAsync s.StartsAt DbContext Activity"},{"u":"/docs/onboarding/group-18-conference-application.html#manual-mapping-validation-rule-fragments-and-authorization-specifications","d":"18. ADC Conference - Application & Use Cases","k":"Onboarding Guide","t":"Manual mapping, validation rule fragments, and authorization specifications","x":"Three sibling families recur across every aggregate. DTO mappers (SessionDTOMapper, EventDTOMapper, SpeakerDTOMapper, SponsorDTOMapper, ActivityDTOMapper, RoomDTOMapper,…","i":"TimeZoneInfo.FindSystemTimeZoneById s.Event.IsPublished AbstractValidator GetProjectedAsync GetReadRepository Session.EventId SessionSpeaker e.IsPublished EventSpeaker Expression IsEligible StartDate"},{"u":"/docs/onboarding/group-18-conference-application.html#query-services-navigation-populators-and-the-composition-root","d":"18. ADC Conference - Application & Use Cases","k":"Onboarding Guide","t":"Query services, navigation populators, and the composition root","x":"Read paths do not get bespoke handlers for the common cases; they go through the framework's generic IEntityQueryService , which supplies filtering, sorting, paging, and field…","i":"ScanModuleApplicationServices IServiceCollection ClassReference extension FirstName FullName LastName Question"},{"u":"/docs/onboarding/group-18-conference-application.html#event-driven-reactions-domain-and-integration-handlers","d":"18. ADC Conference - Application & Use Cases","k":"Onboarding Guide","t":"Event-driven reactions: domain and integration handlers","x":"The application layer is also where the module reacts to events. Domain event handlers implement IDomainEventHandler and run in-process after the aggregate's SaveChangesAsync.…","i":"EnsureNotServiceSession SpeakerUnlinkedFromUser EnsureStatusIsEligible User.LinkedSpeakerId SpeakerLinkedToUser GetLiveWindowUtc SaveChangesAsync SessionChanged UserRegistered LogAndRethrow IEventBus Deleted"},{"u":"/docs/onboarding/group-18-conference-application.html#attendee-facing-read-models-calendar-export-and-nownext","d":"18. ADC Conference - Application & Use Cases","k":"Onboarding Guide","t":"Attendee-facing read models: calendar export and Now/Next","x":"A small cluster of queries serves the public schedule surfaces without going through the generic query service, because their output is not a DTO list. ExportEventCalendarHandler…","i":"CalendarExportMapper.IsExportable DateTimeOffset.UtcNow GetNowNextHandler GetLiveWindowUtc Error.NotFound IsExportable TimeProvider IsEligible DTSTAMP Result string ics"},{"u":"/docs/onboarding/group-18-conference-application.html#the-sessionize-import-strategy-pattern-orchestration","d":"18. ADC Conference - Application & Use Cases","k":"Onboarding Guide","t":"The Sessionize import: Strategy-pattern orchestration","x":"The single most involved use case is importing a conference agenda from Sessionize.com. Sessionize returns one JSON payload covering five interdependent entity families…","i":"ThrowIfCancellationRequested TimeoutRejectedException BrokenCircuitException NotSupportedException RequestIdentityInsert HttpRequestException SaveChangesAsync JsonException TimeProvider Create Update catch"},{"u":"/docs/onboarding/group-18-conference-application.html#decision-support-ai-scoring-and-content-analytics","d":"18. ADC Conference - Application & Use Cases","k":"Onboarding Guide","t":"Decision support: AI scoring and content analytics","x":"The last cluster is session-selection decision support, analytics that help organizers triage proposals. GetSessionSelectionDashboardHandler is a composite query: it validates…","i":"CategoryInvariants.EnsureCategoryItemNameIsUnique MMCA.ADC.Conference.Application.AssemblyReference MMCA.ADC.Conference.Application.Events.Sessionize MMCA.ADC.Conference.Application.Events.Validation SessionRoomScheduling.ValidateRoomAssignmentAsync currentUserService.IsPrivilegedConferenceReader MMCA.ADC.Conference.Application.Activities.DTOs MMCA.ADC.Conference.Application.Categories.DTOs MMCA.ADC.Engagement.Shared.UserSessionBookmarks PublicSessionStatusSpecification.StatusCriteria EventInvariants.OrganizerContactEmailMaxLength EventInvariants.RoomAccessibilityInfoMaxLength"},{"u":"/docs/onboarding/group-19-conference-infrastructure.html","d":"19. ADC Conference - Infrastructure & Persistence","k":"Onboarding Guide","x":"What this chapter covers. This is the adapter layer of the Conference module, the place where the engine-agnostic domain meets concrete technology. Three concerns live here: (1)…","i":"SessionScoringQueue ISessionizeService IAiScoringService Architecture DbContext Rubric Clean DbSet"},{"u":"/docs/onboarding/group-19-conference-infrastructure.html#engine-agnostic-entities-engine-chosen-by-the-config-base-class","d":"19. ADC Conference - Infrastructure & Persistence","k":"Onboarding Guide","t":"Engine-agnostic entities, engine chosen by the config base class","x":"The most important idea in this chapter is one the entities themselves never express: what storage engine each entity uses is decided here, not in the domain. A Conference domain…","i":"EntityTypeConfigurationSQLServer EntityDataSourceRegistry EntityTypeConfiguration ActivityConfiguration DataSource.SQLServer SessionConfiguration SpeakerConfiguration SponsorConfiguration EventConfiguration TIdentifierType UseDataSource Activity"},{"u":"/docs/onboarding/group-19-conference-infrastructure.html#each-config-inherits-the-cross-cutting-behavior-then-adds-entity-specifics","d":"19. ADC Conference - Infrastructure & Persistence","k":"Onboarding Guide","t":"Each config inherits the cross-cutting behavior, then adds entity specifics","x":"Every configuration's Configure method begins with base.Configure(builder) (for example SessionConfiguration.cs:18, ActivityConfiguration.cs:17) and then adds its own mappings.…","i":"SessionQuestionAnswerConfiguration SpeakerQuestionAnswerConfiguration ActivityInvariants.NameMaxLength EventQuestionAnswerConfiguration SessionCategoryItemConfiguration SessionInvariants.TitleMaxLength SpeakerCategoryItemConfiguration ConferenceCategoryConfiguration SoftDeleteUniqueIndexConvention SponsorInvariants.NameMaxLength EventInvariants.NameMaxLength EventQuestionAnswer.EventId"},{"u":"/docs/onboarding/group-19-conference-infrastructure.html#dbsets-the-context-shape-and-how-the-configurations-are-actually-found","d":"19. ADC Conference - Infrastructure & Persistence","k":"Onboarding Guide","t":"DbSets, the context shape, and how the configurations are actually found","x":"ModuleApplicationDbContext (MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:20) is the Conference module's abstract DbContext. It does one…","i":"IEntityTypeConfigurationSQLServer MMCA.ADC.Conference.Service ModuleApplicationDbContext SessionQuestionAnswers SpeakerQuestionAnswer ApplicationDbContext EventQuestionAnswers SessionCategoryItems SpeakerCategoryItems SQLServerDbContext SaveChangesAsync SessionSpeakers"},{"u":"/docs/onboarding/group-19-conference-infrastructure.html#seeding-two-real-events-always-sample-data-only-in-dev-and-ci","d":"19. ADC Conference - Infrastructure & Persistence","k":"Onboarding Guide","t":"Seeding: two real events always, sample data only in dev and CI","x":"ConferenceModuleDbSeeder (MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/Seeding/ConferenceModuleDbSeeder.cs:25) derives from the framework's DbSeeder and runs after…","i":"ConferenceModuleDbSeeder ConferenceModuleSeeder ManualIdRangeStart QuestionInvariants includeSampleData SessionInvariants ExistsAsync DbSeeder sf1nopko z1ecmzux Migrate"},{"u":"/docs/onboarding/group-19-conference-infrastructure.html#the-sessionize-adapter","d":"19. ADC Conference - Infrastructure & Persistence","k":"Onboarding Guide","t":"The Sessionize adapter","x":"SessionizeService (MMCA.ADC.Conference.Infrastructure/Services/SessionizeService.cs:10) is a deliberately thin HTTP client: the whole class is one method. Given a Sessionize…","i":"EnsureSuccessStatusCode DependencyInjection SessionizeResponse SessionizeService HttpClient GetAsync code"},{"u":"/docs/onboarding/group-19-conference-infrastructure.html#the-anthropic-ai-scoring-adapter","d":"19. ADC Conference - Infrastructure & Persistence","k":"Onboarding Guide","t":"The Anthropic AI scoring adapter","x":"AnthropicScoringService (MMCA.ADC.Conference.Infrastructure/Services/AnthropicScoringService.cs:16) is the richer of the two adapters: it scores one session proposal against a…","i":"CultureInfo.InvariantCulture OperationCanceledException AnthropicScoringService AnthropicContentBlock SessionScoringResult AnthropicResponse IAiScoringService AnthropicMessage AnthropicRequest JsonPropertyName AiScoreResponse LoggerMessage"},{"u":"/docs/onboarding/group-19-conference-infrastructure.html#scoring-runs-on-a-hosted-drain-guarded-across-replicas","d":"19. ADC Conference - Infrastructure & Persistence","k":"Onboarding Guide","t":"Scoring runs on a hosted drain, guarded across replicas","x":"SessionScoringProcessor (MMCA.ADC.Conference.Infrastructure/Services/SessionScoringProcessor.cs:49) is the piece that makes a multi-minute paid AI pass safe to trigger from an…","i":"MMCA.ADC.Conference.Scoring scoring.run.failed.terminal ScoreEventSessionsCommand SessionScoringProcessor queue.MarkCompleted SessionScoringQueue BackgroundService CreateAsyncScope IDistributedLock TryAcquireAsync conferenceApp MarkCompleted"},{"u":"/docs/onboarding/group-19-conference-infrastructure.html#the-sweep-that-finishes-what-a-crash-interrupted","d":"19. ADC Conference - Infrastructure & Persistence","k":"Onboarding Guide","t":"The sweep that finishes what a crash interrupted","x":"The drain is fast but not durable: the channel lives in one replica's memory, so a deploy, a scale-in or a crash between the organizer's click and the last session's score leaves…","i":"SessionScoringCandidate SessionScoringSweepJob SessionScoreStamp RecoveryWindow IScheduledJob Continuity Resilience Business Rubric Event"},{"u":"/docs/onboarding/group-19-conference-infrastructure.html#di-wiring-and-a-deliberate-resilience-override","d":"19. ADC Conference - Infrastructure & Persistence","k":"Onboarding Guide","t":"DI wiring and a deliberate resilience override","x":"DependencyInjection (MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:12) is a single extension(IServiceCollection) block (the codebase's standard DI-registration idiom,…","i":"AddModuleConferenceInfrastructure MMCA.ADC.Conference.Service RemoveAllResilienceHandlers StandardResilienceHandler DependencyInjection HttpClient.Timeout IServiceCollection AddScheduledJobs extension"},{"u":"/docs/onboarding/group-19-conference-infrastructure.html#how-it-fits-together-at-runtime","d":"19. ADC Conference - Infrastructure & Persistence","k":"Onboarding Guide","t":"How it fits together at runtime","x":"Three flows tie the chapter together. Persistence flow: a Conference command handler mutates an aggregate and the unit of work saves; that resolves the concrete…","i":"Microsoft.EntityFrameworkCore.Metadata.Builders System.Text.Json.Serialization.JsonPropertyName CategoryInvariants.CategoryItemNameMaxLength MMCA.ADC.Conference.Infrastructure.Services Microsoft.Extensions.DependencyInjection MMCA.ADC.Migrations.SqlServer.Conference ActivityInvariants.DescriptionMaxLength SessionInvariants.AnswerValueMaxLength SpeakerInvariants.AnswerValueMaxLength EventInvariants.AnswerValueMaxLength Microsoft.Extensions.Http.Resilience MMCA.ADC.Conference.Infrastructure"},{"u":"/docs/onboarding/group-20-conference-api-grpc.html","d":"20. ADC Conference - API, gRPC Contracts & Service Host","k":"Onboarding Guide","x":"What this chapter covers. This is the edge of the Conference bounded context, the layer that turns the rich Conference domain (G17) and its CQRS slices (G18) into a running HTTP…","i":"MMCA.ADC.Conference.Contracts MMCA.ADC.Conference.Service MMCA.ADC.Conference.API ConferenceModuleSeeder ConferenceModule Microservices Readiness Contract Vertical IModule Design Rubric"},{"u":"/docs/onboarding/group-20-conference-api-grpc.html#the-controller-hierarchy-almost-everything-is-inherited","d":"20. ADC Conference - API, gRPC Contracts & Service Host","k":"Onboarding Guide","t":"The controller hierarchy, almost everything is inherited","x":"The Conference API exposes seventeen controllers, and the striking thing about them is how little code each carries. They split into three structural families, all built on the…","i":"sessionquestionanswerscontroller conferencecategoriescontroller ConferenceCategoriesController eventquestionanswerscontroller sessioncategoryitemscontroller speakercategoryitemscontroller SessionSelectionController sessionspeakerscontroller categoryitemscontroller eventspeakerscontroller PagedCollectionResult ServiceInfoController"},{"u":"/docs/onboarding/group-20-conference-api-grpc.html#authorization-at-the-edge-three-shapes-not-one","d":"20. ADC Conference - API, gRPC Contracts & Service Host","k":"Onboarding Guide","t":"Authorization at the edge, three shapes not one","x":"Authorization is capability-based by default but not uniform, and the differences are the interesting part. Most write-bearing controllers carry a class-level…","i":"AuthorizationPolicies.RequireAuthenticated ConferencePermissions.SpeakersManage SessionQuestionAnswersController ConferenceCategoriesController EventQuestionAnswersController CurrentUserServiceExtensions IsPrivilegedConferenceReader ConferencePermissions.All OwnedByUserSpecification CategoryItemsController AddModuleConferenceAPI ConferenceReadAudience"},{"u":"/docs/onboarding/group-20-conference-api-grpc.html#the-request-records-the-inbound-write-shapes","d":"20. ADC Conference - API, gRPC Contracts & Service Host","k":"Onboarding Guide","t":"The request records, the inbound write shapes","x":"Several controllers declare small record class request types alongside themselves, co-located in the same file: AddRoomRequest/UpdateRoomRequest…","i":"updatesessionquestionanswerrequest updateeventquestionanswerrequest addsessionquestionanswerrequest addeventquestionanswerrequest addsessioncategoryitemrequest addspeakercategoryitemrequest updatecategoryitemrequest addsessionspeakerrequest addcategoryitemrequest addeventspeakerrequest SessionCreateRequest SessionsController"},{"u":"/docs/onboarding/group-20-conference-api-grpc.html#where-the-generic-shape-gives-way-filtering-warnings-and-calendars","d":"20. ADC Conference - API, gRPC Contracts & Service Host","k":"Onboarding Guide","t":"Where the generic shape gives way: filtering, warnings, and calendars","x":"SessionsController is the best illustration of how a controller earns its overrides. Every read action is [AllowAnonymous] and [OutputCache(PolicyName = \"SessionsCache\")]…","i":"BuildPublicSessionSpecificationAsync BuildPagedSessionSpecificationAsync GetSessionsBySpeakerFilterQuery GetPublicActivityFilterQuery GetPublicSessionFilterQuery GetPublicSponsorFilterQuery PublishedEventSpecification ExportSessionCalendarQuery EvictSessionsCacheAsync specification.Criteria UpdateActivityCommand ActivitiesController"},{"u":"/docs/onboarding/group-20-conference-api-grpc.html#two-more-deviations-versioning-and-decision-support","d":"20. ADC Conference - API, gRPC Contracts & Service Host","k":"Onboarding Guide","t":"Two more deviations, versioning and decision support","x":"ServiceInfoController exists to prove the API-versioning machinery works beyond a single version ([Rubric §9, API & Contract Design]). It is a one-member shell over Common's…","i":"ConferencePermissions.SessionSelectionManage SessionScoringEnqueueResult SessionSelectionController ServiceInfoControllerBase SessionScoringProcessor ServiceInfoController ISessionScoringQueue minimumSimilarity ConferenceCache AllowAnonymous AlreadyPending HandleFailure"},{"u":"/docs/onboarding/group-20-conference-api-grpc.html#the-module-entry-point-and-seeder-how-conference-plugs-in","d":"20. ADC Conference - API, gRPC Contracts & Service Host","k":"Onboarding Guide","t":"The module entry point and seeder, how Conference plugs in","x":"ConferenceModule is the Conference implementation of IModule. It is tiny by design: Register(...) calls the DependencyInjection extension's AddConferenceModule(...)…","i":"DisabledSessionBookmarkValidationService DisabledEventLiveValidationService ISessionBookmarkValidationService IEventLiveValidationService ConferenceErrorResources ConferenceModuleSeeder AuthorizationPolicies RegisterDisabledStubs RequireAuthenticated AddConferenceModule DependencyInjection AssemblyReference"},{"u":"/docs/onboarding/group-20-conference-api-grpc.html#the-grpc-edge-conference-as-both-server-and-client","d":"20. ADC Conference - API, gRPC Contracts & Service Host","k":"Onboarding Guide","t":"The gRPC edge, Conference as both server and client","x":"When Conference runs in its own process, two of its in-process collaborations must cross a network boundary, and both are handled by the G13 transport boundary (Result over the…","i":"SessionBookmarkValidationServiceGrpcAdapter AddConferenceEventLiveValidationClient eventlivevalidationservicegrpcadapter AddConferenceSessionValidationClient ISessionBookmarkValidationService AddEngagementBookmarkCountClient ModuleLoader.DiscoverAndRegister eventlivevalidationgrpcservice GrpcResultExceptionInterceptor MMCA.ADC.Conference.Contracts MMCA.ADC.Conference.Service SessionBookmarksGrpcService"},{"u":"/docs/onboarding/group-20-conference-api-grpc.html#the-service-host-kestrel-first-and-why","d":"20. ADC Conference - API, gRPC Contracts & Service Host","k":"Onboarding Guide","t":"The service host: Kestrel first, and why","x":"The MMCA.ADC.Conference.Service Program.cs boots only the Conference module (Modules:Conference:Enabled=true). Kestrel is configured before anything else, and the whole of it is…","i":"builder.ConfigureEndpointsWithHealthProbe MMCA.ADC.Conference.Scoring MMCA.ADC.Conference.Service KestrelEndpointExtensions HttpProtocols.Http2 MapDefaultEndpoints HTTP_1_1_REQUIRED Http1AndHttp2 Program.cs UseSerilog httpGet GOAWAY"},{"u":"/docs/onboarding/group-20-conference-api-grpc.html#output-caching-and-warm-up-the-two-performance-extension-points","d":"20. ADC Conference - API, gRPC Contracts & Service Host","k":"Onboarding Guide","t":"Output caching and warm-up, the two performance extension points","x":"Output caching is where this host carries the most bespoke configuration (Program.cs:196-265). The base policy is deny-by-default NoCache (Program.cs:198), so only explicitly…","i":"ConferenceReadAudience.PrivilegedRoles RegisterOutputCacheEvictionConsumer PublicEndpointOutputCachePolicy AddOutputCacheEvictionHandler SelfHttpOutputCacheWarmupTask DbUpdateConcurrencyException OutputCacheEvictionRequested SessionSelectionController ConferenceErrorResources AddPublicEndpointPolicy SelfHttpWarmupTaskBase ConferencePublicCache"},{"u":"/docs/onboarding/group-20-conference-api-grpc.html#the-runtime-picture-one-host-two-transports","d":"20. ADC Conference - API, gRPC Contracts & Service Host","k":"Onboarding Guide","t":"The runtime picture, one host, two transports","x":"After module discovery (Program.cs:335-339) the host wires the Engagement gRPC client (Program.cs:350), the broker (AddBrokerMessaging registering the UserRegistered…","i":"MMCA.Common.Application.Interfaces.Infrastructure currentUserService.IsPrivilegedConferenceReader ConferencePermissions.SessionSelectionManage AuthorizationPolicies.RequireAuthenticated ConferenceReadAudience.PrivilegedRoles.Any builder.ConfigureEndpointsWithHealthProbe DisabledSessionBookmarkValidationService MMCA.ADC.Conference.Shared.Authorization ConferencePermissions.ContentManagement GetPublicSessionCategoryItemFilterQuery GetPublicSpeakerCategoryItemFilterQuery AddConferenceEventLiveValidationClient"},{"u":"/docs/onboarding/group-21-conference-ui.html","d":"21. ADC Conference - UI","k":"Onboarding Guide","x":"What this chapter covers. This is the consumer half of the \"write-once UI, render everywhere\" story (primer §2): the Blazor pages and per-page HTTP services that turn the…","i":"MMCA.ADC.Conference.UI Architecture Responsive Component Design Rubric"},{"u":"/docs/onboarding/group-21-conference-ui.html#the-layering-inside-the-ui-a-page-never-touches-httpclient","d":"21. ADC Conference - UI","k":"Onboarding Guide","t":"The layering inside the UI: a page never touches HttpClient","x":"Each page is a .razor + .razor.cs code-behind pair that depends only on a UI service interface, never on HttpClient and never on the API's internals. The nine CRUD-shaped…","i":"IConferenceCategoryUIService RefreshFromSessionizeAsync ConferenceCategoryService EnsureSuccessStatusCode ActivityIdentifierType ICategoryItemUIService ServiceExceptionHelper PagedCollectionResult CategoryItemService IActivityUIService IQuestionUIService EntityServiceBase"},{"u":"/docs/onboarding/group-21-conference-ui.html#the-list-pages-derive-from-datagridlistpagebasetdto-get-everything-for-free","d":"21. ADC Conference - UI","k":"Onboarding Guide","t":"The list pages: derive from DataGridListPageBase, get everything for free","x":"Eleven list screens, the organizer EventList, SessionList, SpeakerList, ConferenceCategoryList, QuestionList, RoomList, SponsorList, ActivityList, and the public PublicEventList,…","i":"ConferenceCategoryCreate ConferenceCategoryDetail MobileInfiniteScrollList CancellationTokenSource ConferenceCategoryList InfiniteScrollSentinel DataGridListPageBase OnInitializedAsync PublicActivityList PublicSessionList PublicSpeakerList PublicSponsorList"},{"u":"/docs/onboarding/group-21-conference-ui.html#container-and-presentational-split","d":"21. ADC Conference - UI","k":"Onboarding Guide","t":"Container and presentational split","x":"The behaviour-heavy screens do not keep everything in one code-behind: the page stays the container (data fetching, filter and paging state, service calls) and hands rendering to…","i":"SessionSelectionSpeakerOverlap PublicSessionListFilterBar SpeakerCategoryItemsPanel SessionSelectionAiScores SessionSelectionDisplay PublicSessionListView PublicSessionList Architecture ReloadAsync Changed Testing Rubric"},{"u":"/docs/onboarding/group-21-conference-ui.html#child-and-join-entities-a-thin-postdelete-base","d":"21. ADC Conference - UI","k":"Onboarding Guide","t":"Child-and-join entities: a thin POST/DELETE base","x":"Sessions, speakers, and events own join relationships (a speaker added to a session, a category item to a speaker) that the generic CRUD base cannot model, because the write…","i":"ISessionCategoryItemUIService ISpeakerCategoryItemUIService SessionCategoryItemService SpeakerCategoryItemService ISessionSpeakerUIService ChildEntityServiceBase IEventSpeakerUIService SessionSpeakerService EventSpeakerService MMCA.Common.UI DeleteAsync Validation"},{"u":"/docs/onboarding/group-21-conference-ui.html#display-enrichment-lookups-the-getall-vs-getbyid-populator-gap-worked-around-in-the-ui","d":"21. ADC Conference - UI","k":"Onboarding Guide","t":"Display-enrichment lookups: the GetAll-vs-GetById populator gap, worked around in the UI","x":"Because the API's list endpoints do not always populate every cross-entity navigation, several pages need a cheap id-to-name map to render speaker names beside a session or an…","i":"ICategoryItemLookupService CategoryItemLookupService ISpeakerLookupService SpeakerLookupService SponsorshipPacketUrl IEventLookupService EventLookupService PublicSessionList CategoryItemInfo SessionSpeakers SpeakerInfo Dictionary"},{"u":"/docs/onboarding/group-21-conference-ui.html#three-feature-areas-that-go-beyond-crud","d":"21. ADC Conference - UI","k":"Onboarding Guide","t":"Three feature areas that go beyond CRUD","x":"First, the speaker self-service dashboard: SpeakerDashboard is gated on the speakerid JWT claim (read from the cascaded authentication state and parsed as a Guid,…","i":"IOrganizerSessionFeedbackUIService IOrganizerEventFeedbackUIService OrganizerSessionFeedbackService OrganizerEventFeedbackService ISpeakerDashboardUIService AuthenticatedServiceBase OrganizerSessionFeedback SpeakerDashboardService OrganizerEventFeedback ServiceExceptionHelper IPublicLinkBuilder SpeakerDashboard"},{"u":"/docs/onboarding/group-21-conference-ui.html#session-selection-decision-support-the-asynchronous-edge","d":"21. ADC Conference - UI","k":"Onboarding Guide","t":"Session-selection decision support, the asynchronous edge","x":"The most behaviour-rich page is the organizer-only SessionSelectionDashboard (MMCA.ADC.Conference.UI/Pages/SessionSelection/SessionSelectionDashboard.razor:2 carries the…","i":"SessionSelectionFilterOptions SessionSelectionDashboardDTO ScoreEventSessionsResultDTO ISessionSelectionUIService ScoreEventSessionsCommand SessionSelectionDashboard SessionSelectionService CurrentEventSelector ScoreSessionsAsync GetDashboardAsync ScorePollTracker ScorePollSignal"},{"u":"/docs/onboarding/group-21-conference-ui.html#public-versus-authenticated-rendering-and-the-device-capability-path","d":"21. ADC Conference - UI","k":"Onboarding Guide","t":"Public versus authenticated rendering, and the device-capability path","x":"A recurring [Rubric §11, Security] pattern: the same conference entity is exposed through two page families. The public family (PublicEventList/PublicEventDetail,…","i":"IServiceProvider.GetService IConnectivityStatusService ISessionBookmarkUIService PublicScheduleRoomOptions ConferenceReadAudience IHapticFeedbackService IMapNavigationService CurrentEventDefaults PublicSessionDetail PublicSpeakerDetail IScreenshotService PublicActivityList"},{"u":"/docs/onboarding/group-21-conference-ui.html#sponsors-and-activities-two-feature-areas-in-miniature","d":"21. ADC Conference - UI","k":"Onboarding Guide","t":"Sponsors and activities, two feature areas in miniature","x":"The sponsor surface is worth reading as a compact tour of every pattern above. Organizers manage the roster through SponsorList / SponsorCreate / SponsorDetail: the list is a…","i":"ADCSponsorCollectionResult DataGridListPageBase EventLookupService PublicActivityList PublicSponsorList ActivityCreate ActivityDetail ADCSponsorInfo Enum.GetValues SponsorCreate SponsorDetail ActivityList"},{"u":"/docs/onboarding/group-21-conference-ui.html#the-landing-page","d":"21. ADC Conference - UI","k":"Onboarding Guide","t":"The landing page","x":"ADCHome is the conference front door, shared by the web and MAUI heads; both serve the editorial images from their own site root today, so neither overrides the ImageBasePath…","i":"CurrentEventSelector.ToUtc PreConferenceWorkshopInfo CurrentEventSelector ADCCollectionResult ConferenceTrackInfo KeynoteSpeakerInfo ImageBasePath ADCEventInfo Performance EventPhase Rendering ADCHome"},{"u":"/docs/onboarding/group-21-conference-ui.html#routes-navigation-and-localized-strings","d":"21. ADC Conference - UI","k":"Onboarding Guide","t":"Routes, navigation, and localized strings","x":"All paths are centralized in ConferenceRoutePaths, a static catalogue of literal routes and id-parameterized builder methods (EventDetails(id), PublicSessionDetails(id),…","i":"ConferenceRoutePaths.EventDetails NavigationManager.NavigateTo NavigationPublicLinkBuilder EventFeedbackOrganizer ConferenceRoutePaths Internationalization PublicSessionDetails IPublicLinkBuilder IStringLocalizer SponsorVisitLink ActivityDetails RoomCheckInLink"},{"u":"/docs/onboarding/group-21-conference-ui.html#how-it-all-plugs-into-the-shell","d":"21. ADC Conference - UI","k":"Onboarding Guide","t":"How it all plugs into the shell","x":"Two registration types wire the area in. ConferenceUIModule implements Common's IUIModule (the front-end counterpart of the IModule back-end contract): it declares the module's…","i":"MMCA.ADC.Conference.UI.Pages.ConferenceCategory ConferenceRoutePaths.SessionSelectionDashboard MMCA.ADC.Conference.UI.Pages.SessionSelection ListPageActions.DeleteWithConfirmationAsync ArgumentException.ThrowIfNullOrWhiteSpace ConferenceRoutePaths.ConferenceCategories CurrentEventDefaults.SelectCurrentOrNext CurrentEventSelector.SelectCurrentOrNext DashboardService.GetSpeakerSessionsAsync ListPageActions.ReloadActiveLayoutAsync MMCA.ADC.Conference.Shared.Activities MMCA.ADC.Conference.UI.Pages.Activity"},{"u":"/docs/onboarding/group-22-engagement-module.html","d":"22. ADC Engagement Module (Session Bookmarks)","k":"Onboarding Guide","x":"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…","i":"MMCA.ADC.Engagement.Application.CheckIns.Services BookmarkCountService.BookmarkCountServiceClient MMCA.ADC.Engagement.Application.Points.Services MMCA.ADC.Engagement.Domain.UserSessionBookmarks MMCA.ADC.Engagement.Shared.UserSessionBookmarks MMCA.ADC.Engagement.Domain.Points.DomainEvents BookmarkCountService.BookmarkCountServiceBase MMCA.ADC.Engagement.Application.CheckIns.DTOs UserSessionBookmarkCacheEvictionHandlerTests assemblyProvider.GetConfigurationAssemblies AuthorizationPolicies.RequireAuthenticated CheckInsController.GetAttendanceStatsAsync"},{"u":"/docs/onboarding/group-23-engagement-live-layer.html","d":"23. ADC Engagement Live Layer (Real-Time Polls & Session Q&A)","k":"Onboarding Guide","x":"What this chapter covers. This is the conference-day layer of the Engagement bounded context: the features that only matter while an event is actually happening in the room.…","i":"SessionQuestion PresenterView HappeningNow SessionLive LivePoll"},{"u":"/docs/onboarding/group-23-engagement-live-layer.html#the-two-aggregates-and-their-invariants","d":"23. ADC Engagement Live Layer (Real-Time Polls & Session Q&A)","k":"Onboarding Guide","t":"The two aggregates and their invariants","x":"Both aggregates are sealed AuditableAggregateRootEntity subclasses that follow the framework's factory-plus-Result discipline (primer §2). LivePoll…","i":"AuditableAggregateRootEntity SessionQuestion.Create SessionQuestionChanged SessionQuestionUpvote ToggleUpvoteHandler LivePollInvariants DomainEntityState LiveWindowEndUtc BaseDomainEvent CanAcceptUpvote CastVoteHandler LivePollChanged"},{"u":"/docs/onboarding/group-23-engagement-live-layer.html#the-write-path-and-where-the-realtime-broadcast-actually-happens","d":"23. ADC Engagement Live Layer (Real-Time Polls & Session Q&A)","k":"Onboarding Guide","t":"The write path, and where the realtime broadcast actually happens","x":"Each operation is a vertical slice under Application/{LivePollsSessionQuestions}/UseCases/{Op}/, and every command handler shares the first two beats: mutate the aggregate…","i":"SessionQuestionUpvoteChangedHandler SessionQuestionUpvoteChanged ArgumentOutOfRangeException besteffort.dispatch.failed LiveChannelPublishWorkItem LivePollVoteChangedHandler ILiveChannelPublishQueue BestEffort.ExecuteAsync ModerateQuestionHandler MMCA.Common.BestEffort SessionQuestionChannel CreateLivePollHandler"},{"u":"/docs/onboarding/group-23-engagement-live-layer.html#one-websocket-one-publisher-port-and-a-cross-service-ingress","d":"23. ADC Engagement Live Layer (Real-Time Polls & Session Q&A)","k":"Onboarding Guide","t":"One WebSocket, one publisher port, and a cross-service ingress","x":"The transport itself is framework-owned (ADR-039, Group 10). The single NotificationHub carries both durable notifications and channel events on one connection, and the…","i":"LiveChannelPublisherGrpcAdapter LiveChannelPublishProcessor SignalRLiveChannelPublisher RendererInfo.IsInteractive System.Threading.Channels NullLiveChannelPublisher IPushNotificationSender LiveChannelPublishQueue LiveChannelGrpcService NotificationHubService ILiveChannelPublisher OnAfterRenderAsync"},{"u":"/docs/onboarding/group-23-engagement-live-layer.html#the-read-path-and-how-the-ui-reacts","d":"23. ADC Engagement Live Layer (Real-Time Polls & Session Q&A)","k":"Onboarding Guide","t":"The read path and how the UI reacts","x":"Reads do not go through the generic entity-query machinery; the live views need shaped projections. LivePollResultsBuilder…","i":"LivePollNavigationPopulator GetSessionQuestionsHandler SessionLiveModerationPanel SessionQuestionViewBuilder SessionLiveQuestionPanel LivePollResultsBuilder ISessionLiveUIService ISessionLookupService LivePollConfiguration CurrentEventSelector SessionLivePollPanel SessionLiveUIService"},{"u":"/docs/onboarding/group-23-engagement-live-layer.html#authorization-feature-gating-and-the-cross-service-dependency-on-conference","d":"23. ADC Engagement Live Layer (Real-Time Polls & Session Q&A)","k":"Onboarding Guide","t":"Authorization, feature gating, and the cross-service dependency on Conference","x":"Both controllers, LivePollsController (MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:44) and SessionQuestionsController…","i":"MMCA.ADC.Engagement.Domain.LivePolls.DomainEvents MMCA.ADC.Engagement.Application.LivePolls.DTOs SessionQuestionChannel.QuestionUpvoteChanged MMCA.ADC.Engagement.Domain.SessionQuestions MMCA.ADC.Engagement.Shared.SessionQuestions AuthorizationPolicies.RequireAuthenticated LivePollInvariants.EnsureOptionTextIsValid PushNotificationSettings.ChannelKeyPattern MMCA.ADC.Engagement.UI.Pages.HappeningNow SessionQuestionPendingCountChangedPayload SessionQuestionUpvote.QuestionId.Required CurrentEventSelector.SelectCurrentOrNext"},{"u":"/docs/onboarding/group-24-identity-module.html","d":"24. ADC Identity Module (Users, Profiles, GDPR Export/Erasure)","k":"Onboarding Guide","x":"What this chapter covers. This is the Identity bounded context of MMCA.ADC, the module that owns who a person is across every ADC surface: web, WebAssembly, and MAUI. It is a…","i":"GetUserPreferencesHandlerBase AuditableAggregateRootEntity IPasswordResetTokenService AuthenticationServiceBase ChangePasswordHandlerBase ExportUserDataHandlerBase ForgotPasswordHandlerBase ResetPasswordHandlerBase HasPermissionAttribute DeleteUserHandlerBase BaseIntegrationEvent SoftDeletedUserCache"},{"u":"/docs/onboarding/group-24-identity-module.html#projects-one-bounded-context","d":"24. ADC Identity Module (Users, Profiles, GDPR Export/Erasure)","k":"Onboarding Guide","t":"Projects, one bounded context","x":"The module is split along the standard Clean Architecture layering ([Rubric §3, Clean Architecture]), each project pinned by a trivial AssemblyReference / ClassReference anchor…","i":"MMCA.ADC.Identity.Infrastructure ChangePasswordRequestValidator MMCA.ADC.Identity.Application ScanModuleApplicationServices MaxRegistrationsPerIpPerHour MMCA.ADC.Identity.Contracts ModuleApplicationDbContext MMCA.ADC.Identity.Service MMCA.ADC.Identity.Domain MMCA.ADC.Identity.Shared RegisterRequestValidator SoftDeletedUserValidator"},{"u":"/docs/onboarding/group-24-identity-module.html#the-user-aggregate-credentials-profile-and-cross-context-links-in-one-root","d":"24. ADC Identity Module (Users, Profiles, GDPR Export/Erasure)","k":"Onboarding Guide","t":"The User aggregate: credentials, profile, and cross-context links in one root","x":"User (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:33) is the only aggregate root in the module, and it carries more responsibility than most: it is…","i":"RegisterRequestValidator IPasswordChangeableUser DeviceFieldMaxLength UserPasswordChanged FirstNameMaxLength RefreshTokenExpiry RevokeRefreshToken UpdateRefreshToken LastNameMaxLength UpdatePreferences UserConfiguration CommonInvariants"},{"u":"/docs/onboarding/group-24-identity-module.html#authentication-a-thin-subclass-over-the-shared-engine","d":"24. ADC Identity Module (Users, Profiles, GDPR Export/Erasure)","k":"Onboarding Guide","t":"Authentication: a thin subclass over the shared engine","x":"The login / registration / refresh / revocation workflow is not re-implemented here. It lives in AuthenticationServiceBase (G08), which owns the validate-first flow, the lockout…","i":"HttpContextExternalLoginEmailVerifier UnitOfWork.ExecuteInTransactionAsync CreateChangePreferencesCommand Auth.ExternalEmailNotVerified IdentityPermissions.UsersRead UserAccountAuthControllerBase CreateChangePasswordCommand IExternalLoginEmailVerifier AuthenticationServiceBase GetUserPreferencesHandler TChangePreferencesCommand ChangePreferencesCommand"},{"u":"/docs/onboarding/group-24-identity-module.html#password-recovery-the-anonymous-half-of-the-credential-lifecycle","d":"24. ADC Identity Module (Users, Profiles, GDPR Export/Erasure)","k":"Onboarding Guide","t":"Password recovery: the anonymous half of the credential lifecycle","x":"PUT /Auth/password only serves a user who can already sign in. The recovery pair that serves one who cannot is a second, anonymous vertical, and it is assembled the same way: two…","i":"PasswordResetAuthControllerBase IPasswordResetTokenService ForgotPasswordHandlerBase ResetPasswordHandlerBase ILoginProtectionService PasswordReset__ResetUrl PasswordResetController TForgotPasswordCommand AuthenticationService ForgotPasswordCommand ForgotPasswordHandler PasswordResetSettings"},{"u":"/docs/onboarding/group-24-identity-module.html#the-privacy-pair-export-and-erasure","d":"24. ADC Identity Module (Users, Profiles, GDPR Export/Erasure)","k":"Onboarding Guide","t":"The privacy pair: export and erasure","x":"Two use cases make this module the codebase's clearest [Rubric §30, Compliance / Privacy / Data Governance] story, and both are thin ADC specializations of a G14 base. The…","i":"UserDataExportNotificationSectionDTO UserDataExportEngagementSectionDTO UserDataExportSubmittedQuestionDTO NotificationUserDataExportSection EngagementUserDataExportSection IUserNotificationExportService UserDataExportNotificationDTO IUserEngagementExportService UserDataExportPointsEntryDTO BuildSubjectSnapshotAsync ExportUserDataHandlerBase UserDataExportBookmarkDTO"},{"u":"/docs/onboarding/group-24-identity-module.html#avatars-the-third-mutating-slice","d":"24. ADC Identity Module (Users, Profiles, GDPR Export/Erasure)","k":"Onboarding Guide","t":"Avatars: the third mutating slice","x":"The avatar trio is a small but complete example of a file-handling slice ([Rubric §11, Security] at the content boundary, ADR-045). UsersController caps the multipart upload at 2…","i":"RemoveUserAvatarCommand RemoveUserAvatarHandler Avatar.InvalidUpload GetUserAvatarHandler SetUserAvatarCommand SetUserAvatarHandler IFileStorageService ImageContentSniffer GetUserAvatarQuery RequestSizeLimit IImageProcessor UsersController"},{"u":"/docs/onboarding/group-24-identity-module.html#persistence-seeding-and-the-disabled-stub","d":"24. ADC Identity Module (Users, Profiles, GDPR Export/Erasure)","k":"Onboarding Guide","t":"Persistence, seeding, and the disabled stub","x":"ModuleApplicationDbContext (ModuleApplicationDbContext.cs:15) is the abstract, engine-agnostic context declaring the single Users set (:22); the concrete per-engine class…","i":"EntityTypeConfigurationSQLServer DisabledAttendeeQueryService IdentityModuleDbSeederBase ModuleApplicationDbContext IdentityModuleDbSeeder RegisterDisabledStubs ApplicationDbContext IdentityModuleSeeder EmailValueConverter SQLServerDbContext UserConfiguration IsExternalLogin"},{"u":"/docs/onboarding/group-24-identity-module.html#crossing-the-service-boundary-grpc-and-integration-events","d":"24. ADC Identity Module (Users, Profiles, GDPR Export/Erasure)","k":"Onboarding Guide","t":"Crossing the service boundary: gRPC and integration events","x":"Identity talks to its peers two ways, and both live in Shared and Contracts so neither side reaches into the other's domain ([Rubric §7, Microservices Readiness]). Synchronously,…","i":"ConfigureEndpointsWithHealthProbe ModuleLoader.DiscoverAndRegister AttendeeQueryServiceGrpcAdapter SpeakerUnlinkedFromUserHandler SpeakerLinkedToUserHandler AddIdentityAttendeeClient KestrelEndpointExtensions RequireSuccessStatusCode SpeakerUnlinkedFromUser SelfHttpWarmupTaskBase AuthenticationService IAttendeeQueryService"},{"u":"/docs/onboarding/group-24-identity-module.html#the-ui-edge","d":"24. ADC Identity Module (Users, Profiles, GDPR Export/Erasure)","k":"Onboarding Guide","t":"The UI edge","x":"The Blazor surface is registered as an IdentityUIModule (MMCA.ADC.Identity.UI/IdentityUIModule.cs:13), an IUIModule descriptor that contributes two NavItems as resource keys, \"My…","i":"AuthenticatedServiceBase MobileInfiniteScrollList RetryPolicy.ExecuteAsync MMCA.Common.Testing.E2E DataGridListPageBase DependencyInjection IMediaPickerService IdentityRoutePaths IdentityUIModule ListPageActions IUserUIService UserListDTO"},{"u":"/docs/onboarding/group-24-identity-module.html#end-to-end-one-registration","d":"24. ADC Identity Module (Users, Profiles, GDPR Export/Erasure)","k":"Onboarding Guide","t":"End-to-end: one registration","x":"To see the chapter cooperate, follow a new attendee signing up. AuthController receives the register POST, captures the client IP for BR-213 rate limiting (AuthController.cs:57),…","i":"MMCA.ADC.Identity.Shared.Users.IntegrationEvents AttendeeQueryService.AttendeeQueryServiceClient System.Diagnostics.CodeAnalysis.SuppressMessage MMCA.ADC.Identity.Application.Users.Validation AttendeeQueryService.AttendeeQueryServiceBase AuthStateProvider.GetAuthenticationStateAsync LoginProtection__MaxRegistrationsPerIpPerHour ServiceCollectionDescriptorExtensions.Replace ListPageActions.DeleteWithConfirmationAsync MMCA.ADC.Identity.Domain.Users.DomainEvents ExternalAuthExtensions.ExternalLoginScheme System.Collections.Frozen.FrozenDictionary"},{"u":"/docs/onboarding/group-25-adc-host-composition.html","d":"25. ADC Application Host, UI Shell & Cross-Module Composition","k":"Onboarding Guide","x":"What this chapter covers. Every ADC module described so far, Conference, Engagement, Identity, Notification, is consumed somewhere. This chapter is that somewhere: the client…","i":"Microsoft.Extensions.Configuration.IConfiguration ArgumentException.ThrowIfNullOrWhiteSpace NowNextWidgetProvider.FetchSnapshotAsync MMCA.Common.UI.Components.Capabilities IPlatformApplication.Current.Services UIModuleConfiguration.IsModuleEnabled RemoteCertificateValidationCallback SessionCookieAuthenticationHandler EngagementRoutePaths.HappeningNow NowNextWidgetProvider.BuildViews System.Resources.ResourceManager WebAuthenticatorCallbackActivity"},{"u":"/docs/onboarding/group-26-device-capability-layer.html","d":"26. Device Capability Abstraction Layer (Native Contracts, MAUI, Browser & Fallback Adapters)","k":"Onboarding Guide","x":"What this chapter covers. One Blazor component library in MMCA.Common.UI renders on three very different heads: Blazor Server (server-side prerender plus interactive Server…","i":"MauiBackNavigationBridge.HandleBackPressedAsync MMCA.Common.UI.Services.Capabilities.Fallbacks MMCA.Common.UI.Services.Capabilities.Browser builder.Services.AddMauiDeviceCapabilities MauiLocalNotificationService.ScheduleAsync WebAuthenticator.Default.AuthenticateAsync ArgumentException.ThrowIfNullOrWhiteSpace Battery.Default.EnergySaverStatusChanged CommunityToolkit.Maui.Media.SpeechToText Connectivity.Current.ConnectivityChanged CultureInfo.DefaultThreadCurrentCulture ILocalNotificationService.ScheduleAsync"},{"u":"/docs/onboarding/group-27-testing-infrastructure.html","d":"27. Testing & Quality Infrastructure","k":"Onboarding Guide","x":"What this group covers. Everything the codebase uses to prove itself: the four reusable test-support packages that ship out of MMCA.Common/Source/Hosting (MMCA.Common.Testing,…","i":"ServiceInfoVersioningContractTestsBase SqlServerIntegrationTestFixtureBase MiddlewarePipelineOrderTestsBase MMCA.Common.Testing.Architecture ProductionHostApplicationFactory AccessibilityViolationException DecoratorPipelineOrderTestsBase FeatureManagementTestExtensions ProblemDetailsContractTestsBase CapturingHttpMessageHandler CrossEntityNavigationFinder RouteAuthorizationTestsBase"},{"u":"/docs/onboarding/group-27-testing-infrastructure.html#integration-tests-a-real-host-a-throwaway-database-a-per-test-reset","d":"27. Testing & Quality Infrastructure","k":"Onboarding Guide","t":"Integration tests: a real host, a throwaway database, a per-test reset","x":"The integration tier boots the actual application, not a mock of it. The abstraction at its center is IIntegrationTestFixture (MMCA.Common.Testing/IIntegrationTestFixture.cs:8):…","i":"SqlServerIntegrationTestFixtureBase ConfigureInProcessTokenValidation ProductionHostApplicationFactory FeatureManagementTestExtensions appsettings.Development.json SqlBaseEnvironmentVariable ConfigureTestFeatureFlags IEntityDataSourceRegistry ConfigureTestEnvironment CrossServiceFixtureBase IIntegrationTestFixture CrossServiceDataSource"},{"u":"/docs/onboarding/group-27-testing-infrastructure.html#architecture-fitness-functions-rules-that-gate-the-build","d":"27. Testing & Quality Infrastructure","k":"Onboarding Guide","t":"Architecture fitness functions: rules that gate the build","x":"The layering and DDD conventions this codebase commits to are not left to code review, they are executed as tests. The reusable rule library lives in…","i":"CancellationTokenConventionTestsBase ConstructorDependencyCountTestsBase IntegrationEventContractTestsBase MMCA.Common.Testing.Architecture ObservabilityConventionTestsBase AggregateRootsHaveResultFactory MicroserviceExtractionTestsBase RawQueryableConventionTestsBase IdempotencyConventionTestsBase ArchitectureRules.Entities.cs AggregateConventionTestsBase CrossEntityNavigationFinder"},{"u":"/docs/onboarding/group-27-testing-infrastructure.html#component-tests-real-mudblazor-faked-edges","d":"27. Testing & Quality Infrastructure","k":"Onboarding Guide","t":"Component tests: real MudBlazor, faked edges","x":"The bUnit tier renders a single Blazor component in-process with its real dependencies but stubbed network and auth. BunitComponentTestBase…","i":"IsAuthenticatedAuthorizationService AuthenticationStateProvider CapturingHttpMessageHandler BunitInteractionExtensions StubTokenStorageService BunitComponentTestBase FreshApiClientFactory MarkupSnapshotResult UiHttpServiceHarness AuthenticationState HttpMessageHandler IRenderedComponent"},{"u":"/docs/onboarding/group-27-testing-infrastructure.html#end-to-end-tests-a-real-browser-accessibility-and-performance-as-gates","d":"27. Testing & Quality Infrastructure","k":"Onboarding Guide","t":"End-to-end tests: a real browser, accessibility and performance as gates","x":"The E2E tier drives a real browser through Playwright. PlaywrightFixture (MMCA.Common.Testing.E2E/Infrastructure/PlaywrightFixture.cs:6) is an xUnit collection fixture (its…","i":"AssertNoAccessibilityViolationsAsync AccessibilityViolationException Wcag21AaExceptMudPagerCombobox ProfileManagementTestsBase GotoAndWaitForBlazorAsync UserRegistrationTestsBase UserPreferencesTestsBase ClickAndWaitForUrlAsync window.Blazor._internal AuthorizationTestsBase PasswordResetTestsBase WaitForAuthResultAsync"},{"u":"/docs/onboarding/group-27-testing-infrastructure.html#the-gallery-harness","d":"27. Testing & Quality Infrastructure","k":"Onboarding Guide","t":"The Gallery harness","x":"Component and E2E coverage of MMCA.Common's own UI needs a page to render, but the framework is not a runnable app. MMCA.Common.UI.Gallery is a deliberately backend-less Blazor…","i":"GalleryAuthenticationStateProvider GalleryFakeAuthenticationHandler StubNotificationInboxUIService StubPushNotificationUIService MMCA.Common.UI.E2E.Tests NullTokenStorageService MMCA.Common.UI.Gallery MapRazorComponents NullTokenRefresher NoOpAuthUIService MMCA.Common.slnx GalleryUIModule"},{"u":"/docs/onboarding/group-27-testing-infrastructure.html#contract-pipeline-and-benchmark-bases","d":"27. Testing & Quality Infrastructure","k":"Onboarding Guide","t":"Contract, pipeline, and benchmark bases","x":"The last family pins guarantees that live in the composition of the stack rather than in any one type, and it is the subject of ADR-058: these suites ship in MMCA.Common.Testing…","i":"Application_ShouldNotDependOn_EntityFrameworkCore Controllers_ShouldNotDependOn_EntityFrameworkCore DataSubject_DeclaresPii_SoTheContractIsNotVacuous MMCA.Common.Architecture.Tests.CycleFixtures.Left Module_ShouldDeclare_ExpectedRequiresDependencies PiiRedactor_LeaksNoClearTextPii_ToLogsOrTelemetry CultureSwitch_ToSpanish_ShouldLocalizeAndPersist EveryRunbookAlertSection_MapsToAProvisionedAlert MobileViewport_CultureAndTheme_ShouldBeReachable ModuleShared_ShouldNotDependOn_OwnInternalLayers OpenApiDocument_DescribesEveryCorePublicResource Register_WithMismatchedPasswords_ShouldShowError"},{"u":"/docs/onboarding/group-27-testing-infrastructure.html#per-project-test-rollup","d":"27. Testing & Quality Infrastructure","k":"Onboarding Guide","t":"Per-project test rollup","x":"This guide treats tests as grouped, not sectioned per [Fact] (the logged exception in the charter): the reusable test bases, the shared architecture-fitness library and its…","i":"MMCA.ADC.ServiceBusEmulator.IntegrationTests UserSessionBookmarkCacheEvictionHandlerTests PushNotificationProjectionTranslationTests SpecificationsDoNotNavigateToOtherEntities CachingDecoratorConstructorSelectionTests CurrentUserTargetingContextAccessorTests Microsoft.Extensions.DependencyInjection MMCA.ADC.Conference.Infrastructure.Tests MMCA.ADC.Engagement.Infrastructure.Tests MMCA.ADC.Notification.Application.Tests EntityServiceBaseIdempotencyRetryTests MMCA.ADC.CrossService.IntegrationTests"},{"u":"/docs/onboarding/devops-aspire.html","d":"Aspire Orchestration and Containers","k":"Onboarding Guide","x":"This chapter teaches how the MMCA.ADC system goes from a single dotnet run on your workstation to a running stack of six .NET processes plus four containers: databases, a broker,…","i":"MMCA.Common.Aspire ServiceDefaults WithReference dotnet run"},{"u":"/docs/onboarding/devops-aspire.html#the-one-command-local-run","d":"Aspire Orchestration and Containers","k":"Onboarding Guide","t":"The one-command local run","x":"That command brings up everything the application needs locally: four SQL Server databases, Redis, RabbitMQ with management UI, a MailDev SMTP interceptor, four extracted…"},{"u":"/docs/onboarding/devops-aspire.html#mmcaadcapphost-the-orchestration-project","d":"Aspire Orchestration and Containers","k":"Onboarding Guide","t":"MMCA.ADC.AppHost, the orchestration project","x":"Source file: MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs Extension helpers: MMCA.Common.Aspire.Hosting/Extensions.cs (AddMessageBroker, WithBroker, WithJwksDiscovery,…","i":"identityService.WithE2eRegistrationThrottleLift LoginProtection__MaxRegistrationsPerIpPerHour ConnectionStrings__SQLServerConnectionString ConnectionStrings__CosmosConnectionString ConnectionStrings__SqliteConnectionString Authentication__JwtBearer__Authority identityService.WithEnvironment services__notification__grpc__0 WithE2eRegistrationThrottleLift E2E_LIFT_REGISTRATION_THROTTLE GrpcResultExceptionInterceptor JwtForwardingClientInterceptor"},{"u":"/docs/onboarding/devops-aspire.html#where-service-defaults-come-from-mmcacommonaspire-not-a-local-project","d":"Aspire Orchestration and Containers","k":"Onboarding Guide","t":"Where service defaults come from, MMCA.Common.Aspire, not a local project","x":"There is no MMCA.ADC.ServiceDefaults project. The conventional Aspire \"ServiceDefaults\" shared project that scaffolding generates has been deleted; each service host (and the UI)…","i":"AddCommonKeyVaultConfiguration scoring.run.failed.terminal MMCA.ADC.ServiceDefaults AddCommonDataProtection DefaultAzureCredential builder.Configuration AuditTrailCleanupJob ConfigurationManager MapDefaultEndpoints AddServiceDefaults MMCA.Common.Aspire ScheduledJobRunner"},{"u":"/docs/onboarding/devops-aspire.html#mmcacommonaspire-the-framework-service-defaults-package","d":"Aspire Orchestration and Containers","k":"Onboarding Guide","t":"MMCA.Common.Aspire, the framework service-defaults package","x":"Source: MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs Telemetry: MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Telemetry/OutboxPollFilterProcessor.cs Security:…","i":"APPLICATIONINSIGHTS_CONNECTION_STRING OpenIdConnectMetadataWarmupTask EnableMultipleHttp2Connections AddInfrastructureHealthChecks Services.AddServiceDiscovery Telemetry__TracesSampleRatio ActivityTraceFlags.Recorded ConfigureHttpClientDefaults OTEL_EXPORTER_OTLP_ENDPOINT PooledConnectionIdleTimeout MMCA.Common.Infrastructure WarmupReadinessHealthCheck"},{"u":"/docs/onboarding/devops-aspire.html#mmcacommonaspirehosting-the-apphost-extensions-package","d":"Aspire Orchestration and Containers","k":"Onboarding Guide","t":"MMCA.Common.Aspire.Hosting, the AppHost extensions package","x":"Source: MMCA.Common/Source/Hosting/MMCA.Common.Aspire.Hosting/Extensions.cs This package lives in a separate assembly from MMCA.Common.Aspire so running services do not pull in…","i":"identityService.WithE2eRegistrationThrottleLift LoginProtection__MaxRegistrationsPerIpPerHour Authentication__JwtBearer__Authority WithE2eRegistrationThrottleLift E2E_LIFT_REGISTRATION_THROTTLE E2eRegistrationsPerIpPerHour builder.AddMessageBroker E2E_JWT_PRIVATE_KEY_PEM WithSQLServerDataSource E2E_JWT_PUBLIC_KEY_PEM Jwks__RsaPublicKeyPem Jwt__RsaPrivateKeyPem"},{"u":"/docs/onboarding/devops-aspire.html#the-six-dockerfiles","d":"Aspire Orchestration and Containers","k":"Onboarding Guide","t":"The six Dockerfiles","x":"All six Dockerfiles share the same multi-stage structure (base → build → publish → final) and the same base images. None build the AppHost, it is a local-only orchestration…","i":"MMCA.ADC.Notification.Service.dll MMCA.ADC.Conference.Service.dll MMCA.ADC.Engagement.Service.dll GlobalUsings.IdentifierType.cs MMCA.ADC.Identity.Service.dll MMCA.ADC.UI.Web.Client Directory.Build.props TreatWarningsAsErrors MMCA.ADC.Gateway.dll MMCA.ADC.UI.Web.dll MMCA.Common.Aspire MMCA.ADC.UI.Web"},{"u":"/docs/onboarding/devops-aspire.html#local-to-cloud-parity","d":"Aspire Orchestration and Containers","k":"Onboarding Guide","t":"Local-to-cloud parity","x":"The AppHost topology maps directly to the Azure infrastructure provisioned by infra/main.bicep. The table below cross-references the local resource with its Azure equivalent: The…","i":"ConnectionStrings__SQLServerMigrationsAssembly APPLICATIONINSIGHTS_CONNECTION_STRING __SQLServerMigrationsAssembly OTEL_EXPORTER_OTLP_ENDPOINT ConnectionStrings__redis WithSQLServerDataSource Outbox__DatabaseName AddBrokerMessaging MessageBusProvider ADC_Notification AzureServiceBus ADC_Conference"},{"u":"/docs/onboarding/devops-aspire.html#the-yarp-gateways-role","d":"Aspire Orchestration and Containers","k":"Onboarding Guide","t":"The YARP Gateway's role","x":"The gateway (Source/Hosts/MMCA.ADC.Gateway) is a pure YARP reverse proxy. It has no DbContext, no ModuleLoader, no REST controllers, and no broker connection. Its Program.cs is…","i":"HttpResilienceDefaults.TotalRequestTimeout notificationRestConfig HttpVersion.Version20 RequestVersionOrLower RequestVersionExact restActivityTimeout ActivityTimeout Http1AndHttp2 VersionPolicy ForwardHttp2 MapForwarder ModuleLoader"},{"u":"/docs/onboarding/devops-aspire.html#startup-ordering-summary","d":"Aspire Orchestration and Containers","k":"Onboarding Guide","t":"Startup ordering summary","x":"The health-based WaitFor chain imposes this ordering. Note that three of the four services wait on Identity without any explicit WaitFor in the AppHost: WithJwksDiscovery adds it…","i":"WithJwksDiscovery WithReference WaitFor"},{"u":"/docs/onboarding/devops-aspire.html#not-determinable-from-source","d":"Aspire Orchestration and Containers","k":"Onboarding Guide","t":"Not determinable from source","x":"- The specific integration events that flow over the broker (e.g., UserRegistered, SpeakerLinkedToUser) are cited from AppHost inline comments (Program.cs:46-51, 130-136), not…","i":"SpeakerLinkedToUser UserRegistered CLAUDE.md"},{"u":"/docs/onboarding/devops-cicd.html","d":"CI/CD and Operations","k":"Onboarding Guide","x":"This chapter walks the GitHub Actions workflows that govern MMCA, from the framework's continuous integration and lockstep NuGet release in MMCA.Common, through the ADC…","i":"MMCA.Common"},{"u":"/docs/onboarding/devops-cicd.html#mmcacommon-ciyml","d":"CI/CD and Operations","k":"Onboarding Guide","t":"MMCA.Common, ci.yml","x":"File: MMCA.Common/.github/workflows/ci.yml The continuous-integration workflow for the MMCA.Common framework. Because the fifteen packages are consumed by every downstream…","i":"MMCA.Common.Infrastructure.Redis.Tests RestorePackagesWithLockFile Deque.AxeCore.Playwright Directory.Packages.props PLAYWRIGHT_BROWSERS_PATH DistributedCacheService MMCA.Common.Testing.E2E MMCA.Common.UI.Gallery Directory.Build.props TreatWarningsAsErrors Infrastructure.Tests MMCA.Common.UI.Tests"},{"u":"/docs/onboarding/devops-cicd.html#mmcacommon-releaseyml","d":"CI/CD and Operations","k":"Onboarding Guide","t":"MMCA.Common, release.yml","x":"File: MMCA.Common/.github/workflows/release.yml The lockstep NuGet release workflow. When a maintainer pushes a vX.Y.Z git tag, this workflow deterministically derives the…","i":"Directory.Packages.props github.repository_owner DependencyVersionTests Testing.Architecture MMCA.Common.UI.Maui MMCA.Common.slnx GITHUB_REF_NAME Aspire.Hosting Infrastructure GITHUB_TOKEN Application release.yml"},{"u":"/docs/onboarding/devops-cicd.html#mmcaadc-deployyml","d":"CI/CD and Operations","k":"Onboarding Guide","t":"MMCA.ADC, deploy.yml","x":"File: MMCA.ADC/.github/workflows/deploy.yml The primary CI/CD pipeline for the Atlanta Developers Conference application. It runs on every push to main, on every pull request…","i":"needs.foundation.outputs.acrLoginServer coverage.integration.cobertura.xml MMCA.ADC.Integration.slnf Directory.Packages.props USE_MANAGED_IDENTITY_SQL JWT_RSA_PRIVATE_KEY_PEM MMCA.ADC.Services.Tests __EFMigrationsHistory Directory.Build.props SQL_LOCATION_OVERRIDE WebApplicationFactory skip_freshness_gates"},{"u":"/docs/onboarding/devops-cicd.html#mmcaadc-e2eyml","d":"CI/CD and Operations","k":"Onboarding Guide","t":"MMCA.ADC, e2e.yml","x":"File: MMCA.ADC/.github/workflows/e2e.yml The full-stack Playwright E2E test workflow. It brings up the complete Aspire stack (SQL Server + Redis + RabbitMQ + four services +…","i":"PLAYWRIGHT_BROWSERS_PATH MMCA.Common.Testing.E2E github.event.schedule WEB_VITALS_OUTPUT_DIR PlaywrightFixture workflow_dispatch matrix.browser WebVitalsTests workflow_call E2E_BASE_URL GITHUB_TOKEN E2E_BROWSER"},{"u":"/docs/onboarding/devops-cicd.html#mmcaadc-cost-guardyml","d":"CI/CD and Operations","k":"Onboarding Guide","t":"MMCA.ADC, cost-guard.yml","x":"File: MMCA.ADC/.github/workflows/cost-guard.yml A read-only FinOps check that confirms the production Azure footprint is at its cost baseline. It detects a specific operational…","i":"project_adc_2026_actual_load.md BASELINE_MAX_REPLICAS workflow_dispatch workflow_call deploy.yml production"},{"u":"/docs/onboarding/devops-cicd.html#mmcaadc-load-testyml","d":"CI/CD and Operations","k":"Onboarding Guide","t":"MMCA.ADC, load-test.yml","x":"File: MMCA.ADC/.github/workflows/load-test.yml A k6 load test targeting the output-cached Conference read endpoints through the production Gateway. It establishes a repeatable…","i":"project_adc_2026_actual_load.md workflow_dispatch inputs.peak_vus production base_url BASE_URL peak_vus PEAK_VUS"},{"u":"/docs/onboarding/devops-cicd.html#mmcaadc-cutover-per-service-dbsyml","d":"CI/CD and Operations","k":"Onboarding Guide","t":"MMCA.ADC, cutover-per-service-dbs.yml","x":"File: MMCA.ADC/.github/workflows/cutover-per-service-dbs.yml A one-time, manually-triggered workflow that migrated the four empty per-service databases (ADCIdentity,…","i":"inputs.freeze_traffic ADC_Notification OutboxProcessor ADC_Conference ADC_Engagement freeze_traffic OutboxMessages ADC_Identity containerapp GITHUB_TOKEN SqlBulkCopy deploy.yml"},{"u":"/docs/onboarding/devops-cicd.html#cross-workflow-summary","d":"CI/CD and Operations","k":"Onboarding Guide","t":"Cross-workflow summary","x":"(dr-drill.yml is the ADR-009 §29 restore drill: it PITR-restores a copy of a chosen database, times the restore for the RTO record, verifies it comes back Online, then deletes…","i":"workflow_call deploy.needs deploy.yml federated because e2e.yml subject deploy scoped false slnx the"},{"u":"/docs/onboarding/devops-cicd.html#rubric-category-index-for-this-chapter","d":"CI/CD and Operations","k":"Onboarding Guide","t":"Rubric category index for this chapter","i":"WebVitalsTests deploy.needs environment release.yml deploy.yml foundation production coverage cutover e2e.yml ci.yml deploy"},{"u":"/docs/onboarding/devops-iac.html","d":"Infrastructure as Code, ADC Azure Deployment","k":"Onboarding Guide","x":"This chapter teaches the Azure Infrastructure-as-Code layer for the MMCA.ADC application: what resources are provisioned, why they are shaped the way they are, how secrets reach…","i":"azure.yaml deploy.yml"},{"u":"/docs/onboarding/devops-iac.html#how-the-pieces-fit-together","d":"Infrastructure as Code, ADC Azure Deployment","k":"Onboarding Guide","t":"How the pieces fit together","x":"Before diving into individual files, here is the end-to-end picture: Phases 1 and 2 are their own jobs (deploy.yml:747, deploy.yml:795) rather than steps inside deploy, so they…","i":"AZURE_RESOURCE_GROUP resourceGroup foundation main.bicep AtlDevCon deploy"},{"u":"/docs/onboarding/devops-iac.html#azureyaml-the-azd-project-definition","d":"Infrastructure as Code, ADC Azure Deployment","k":"Onboarding Guide","t":"azure.yaml, the azd project definition","x":"File: MMCA.ADC/azure.yaml azure.yaml is the Azure Developer CLI (azd) manifest for the project. It declares six deployable services and points azd at the Bicep infrastructure…","i":"Directory.Packages.props foundation.bicep containerapp notification azure.yaml conference engagement main.bicep identity language provider context"},{"u":"/docs/onboarding/devops-iac.html#infrafoundationbicep-long-lived-shared-infrastructure","d":"Infrastructure as Code, ADC Azure Deployment","k":"Onboarding Guide","t":"infra/foundation.bicep, long-lived shared infrastructure","x":"File: MMCA.ADC/infra/foundation.bicep Foundation is deployed first (CI/CD chapter: deploy.yml:773-779) on every run. It provisions three resources: the Azure Container Registry,…","i":"reference_log_analytics_sku_limits.md needs.foundation.outputs.acrName workspaceCapping.dailyQuotaGb appLogsConfiguration adminUserEnabled logAnalyticsName environmentName acrLoginServer resourceGroup resourceToken timerTriggers acrPurgeTask"},{"u":"/docs/onboarding/devops-iac.html#deployment-parameters-assembled-at-deploy-time-not-committed","d":"Infrastructure as Code, ADC Azure Deployment","k":"Onboarding Guide","t":"Deployment parameters, assembled at deploy time, not committed","x":"There is no infra/main.parameters.json file in the repository, the tracked infra/ directory holds only foundation.bicep, main.bicep, DISASTER-RECOVERY.md, OPERATIONS.md,…","i":"USE_MANAGED_IDENTITY_SQL useManagedIdentitySql deploymentParameters SQL_ADMIN_PASSWORD alertEmailAddress foundation.bicep logAnalyticsName sqlAdminPassword environmentName Microsoft.Sql OPERATIONS.md hasAnthropic"},{"u":"/docs/onboarding/devops-iac.html#inframainbicep-the-full-application-infrastructure","d":"Infrastructure as Code, ADC Azure Deployment","k":"Onboarding Guide","t":"infra/main.bicep, the full application infrastructure","x":"File: MMCA.ADC/infra/main.bicep main.bicep declares every application-layer Azure resource: Application Insights, the SLO scheduled query rules and their action group, two…","i":"Authentication__JwtBearer__RequireHttpsMetadata ApplicationSettings__DatabaseInitStrategy Logging__OpenTelemetry__LogLevel__Default APPLICATIONINSIGHTS_CONNECTION_STRING AddHttpForwarderWithServiceDiscovery Authentication__JwtBearer__Authority project_outbox_cost_optimization.md Telemetry__DisableHttpClientMetrics Scheduler__PollingIntervalSeconds ObservabilityConventionTestsBase Telemetry__DisableRuntimeMetrics DataProtection__ApplicationName"},{"u":"/docs/onboarding/devops-iac.html#deployment-model-summary","d":"Infrastructure as Code, ADC Azure Deployment","k":"Onboarding Guide","t":"Deployment model summary","x":"The complete credential chain: No static credential exists at any link in this chain. The GitHub secrets AZURECLIENTID, AZURETENANTID, AZURESUBSCRIPTIONID are the OIDC…","i":"AZURE_SUBSCRIPTION_ID SQL_ADMIN_PASSWORD AZURE_CLIENT_ID AZURE_TENANT_ID secure"},{"u":"/docs/onboarding/devops-iac.html#rubric-category-cross-reference","d":"Infrastructure as Code, ADC Azure Deployment","k":"Onboarding Guide","t":"Rubric category cross-reference","x":"---","i":"useManagedIdentitySql RequireHttpsMetadata OTEL_SERVICE_NAME adminUserEnabled KeyVault__Uri dailyQuotaGb minReplicas commonTags secrets secure false grpc"},{"u":"/docs/onboarding/devops-iac.html#not-determinable-from-source","d":"Infrastructure as Code, ADC Azure Deployment","k":"Onboarding Guide","t":"Not determinable from source","x":"- The exact AcrPull and Key Vault Secrets User role-assignment commands used in the out-of- band bootstrap are referenced in comments (main.bicep:926-930, main.bicep:944-947) but…","i":"USE_MANAGED_IDENTITY_SQL AZURE_RESOURCE_GROUP SQL_AAD_ADMIN_LOGIN AZURE_SQL_LOCATION SQL_AAD_ADMIN_OID deploymentMode deploy.yml main.bicep AcrPull Secrets westus2 false"},{"u":"/docs/onboarding/devops-runbooks.html","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","x":"This chapter covers every operational script and runbook in MMCA.ADC: the one-time Azure bootstrap, the database-per-service cutover story (how the legacy AtlDevCon monolith DB…","i":"MMCA.Store AtlDevCon MMCAStore MMCA.ADC ib_rg"},{"u":"/docs/onboarding/devops-runbooks.html#azure-setupsh-one-time-azure-bootstrap","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"azure-setup.sh, One-time Azure bootstrap","x":"File: MMCA.ADC/scripts/azure-setup.sh What it is. A bash script that creates every Azure identity and OIDC credential the GitHub Actions deploy pipeline needs. It is idempotent:…","i":"feedback_azure_cli_role_bug.md JWT_RSA_PRIVATE_KEY_PEM JWT_RSA_PUBLIC_KEY_PEM AZURE_SUBSCRIPTION_ID create_or_replace_fic AZURE_RESOURCE_GROUP MissingSubscription SQL_ADMIN_PASSWORD AZURE_CLIENT_ID AZURE_TENANT_ID Technologies assign_role"},{"u":"/docs/onboarding/devops-runbooks.html#the-database-per-service-cutover-story","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"The database-per-service cutover story","x":"Before the cutover scripts make sense, the story behind them does. Before ADR-006. All four modules (Identity, Conference, Engagement, Notification) pointed at a single shared…","i":"DataSources__Identity__SQLServerConnectionString CrossDataSourceDegradeConvention project_outbox_race_shared_db.md AtlDevCon.dbo.OutboxMessages inputs.freeze_traffic dbo.OutboxMessages workflow_dispatch ADC_Notification OutboxProcessor ADC_Conference ADC_Engagement freeze_traffic"},{"u":"/docs/onboarding/devops-runbooks.html#copy-atldevcon-to-per-service-dbsazureps1-azure-data-copy","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"copy-atldevcon-to-per-service-dbs.azure.ps1, Azure data copy","x":"File: MMCA.ADC/scripts/copy-atldevcon-to-per-service-dbs.azure.ps1 What it is. A PowerShell script that streams rows from AtlDevCon into the four per-service Azure SQL databases…","i":"Microsoft.Data.SqlClient AtlDevCon.schema.Table QUOTED_IDENTIFIER OutboxMessages KeepIdentity is_computed SqlBulkCopy sys.columns CHECKIDENT rowversion RowVersion AtlDevCon"},{"u":"/docs/onboarding/devops-runbooks.html#migrate-atldevcon-to-per-service-dbsps1-local-data-copy-wrapper","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"migrate-atldevcon-to-per-service-dbs.ps1, local data copy wrapper","x":"File: MMCA.ADC/scripts/migrate-atldevcon-to-per-service-dbs.ps1 What it is. A thin PowerShell wrapper that invokes the companion SQL script via sqlcmd against the local Aspire…","i":"QUOTED_IDENTIFIER AtlDevCon localhost sqlcmd error exit sql"},{"u":"/docs/onboarding/devops-runbooks.html#migrate-atldevcon-to-per-service-dbssql-local-sql-copy-script","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"migrate-atldevcon-to-per-service-dbs.sql, local SQL copy script","x":"File: MMCA.ADC/scripts/migrate-atldevcon-to-per-service-dbs.sql What it is. The T-SQL script that performs the actual per-row copy from AtlDevCon into the four per-service…","i":"AtlDevCon.sys.columns sys.identity_columns IDENTITY_INSERT OutboxMessages CHECKIDENT SchemaName XACT_ABORT AtlDevCon TableName timestamp TargetDb EXISTS"},{"u":"/docs/onboarding/devops-runbooks.html#infradisaster-recoverymd-dr-runbook","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"infra/DISASTER-RECOVERY.md, DR runbook","x":"File: MMCA.ADC/infra/DISASTER-RECOVERY.md (175 lines; not the Store file of the same name) What it is. The authoritative disaster-recovery runbook for the ADC production…","i":"publicNetworkAccess scheduledQueryRules serviceDatabaseLtr workflow_dispatch ADC_Notification ADC_Conference ADC_Engagement resourceToken sloAlertSpecs ADC_Identity containerapp keyVaultUrl"},{"u":"/docs/onboarding/devops-runbooks.html#dr-drillyml-and-dr-restore-drillps1-the-adr-009-restore-drill","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"dr-drill.yml and dr-restore-drill.ps1, the ADR-009 restore drill","x":"Files: MMCA.ADC/.github/workflows/dr-drill.yml, MMCA.ADC/scripts/dr-restore-drill.ps1 What it is. The automation behind the drill requirement above: the workflow picks a target…","i":"workflow_dispatch SourceDatabase ADC_Identity deploy.needs deploy.yml AtlDevCon finally restore Online status exit show"},{"u":"/docs/onboarding/devops-runbooks.html#infraoperationsmd-day-2-alert-triage-runbook","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"infra/OPERATIONS.md, day-2 alert triage runbook","x":"File: MMCA.ADC/infra/OPERATIONS.md What it is. The alert-to-action companion to the provisioned observability: what to do when each SLO alert fires, how to read the SLO workbook,…","i":"MMCA.Common.Testing.Architecture ObservabilityConventionTestsBase legacySloMetricAlertSpecs infra.OPERATIONS.md MinimumAlertSpecs infra.main.bicep OPERATIONS.md sloAlertSpecs ALERT_EMAIL AppTraces sloAlerts resource"},{"u":"/docs/onboarding/devops-runbooks.html#infrasql-managed-identitymd-staged-passwordless-sql-runbook","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"infra/SQL-MANAGED-IDENTITY.md, staged passwordless-SQL runbook","x":"File: MMCA.ADC/infra/SQL-MANAGED-IDENTITY.md What it is. The runbook for moving the four service apps from SQL-login (password) auth to Entra managed-identity auth against their…","i":"vars.USE_MANAGED_IDENTITY_SQL USE_MANAGED_IDENTITY_SQL SQL_AAD_ADMIN_LOGIN SQL_AAD_ADMIN_OID Directory db_owner EXTERNAL Identity PROVIDER Managed Active CREATE"},{"u":"/docs/onboarding/devops-runbooks.html#infrapost-cutover-atldevcon-downgrademd-archive-downgrade-runbook","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"infra/POST-CUTOVER-atldevcon-downgrade.md, archive downgrade runbook","x":"File: MMCA.ADC/infra/POST-CUTOVER-atldevcon-downgrade.md What it is. A step-by-step runbook for the third and final commit of the database-per-service rollout: downgrading…","i":"maxSizeBytes ProcessedOn deploy.yml main.bicep AtlDevCon capacity against bacpac update query name NULL"},{"u":"/docs/onboarding/devops-runbooks.html#play-store-captureps1-android-screenshot-capture","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"play-store-capture.ps1, Android screenshot capture","x":"File: MMCA.ADC/scripts/play-store-capture.ps1 What it is. A PowerShell 7 script that captures a screenshot from an attached Android device or emulator via adb screencap and saves…","i":"screencap Files shell PATH slug adb png x86"},{"u":"/docs/onboarding/devops-runbooks.html#play-store-composeps1-play-store-screenshot-compositor","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"play-store-compose.ps1, Play Store screenshot compositor","x":"File: MMCA.ADC/scripts/play-store-compose.ps1 What it is. A PowerShell 7 script that reads raw captures from store-assets/play-store/raw/, wraps each into a 1080×1920 branded…","i":"System.Drawing.Common LinearGradientBrush brandTealDark brandCyan brandTeal imageMaxH imageMaxW slug png"},{"u":"/docs/onboarding/devops-runbooks.html#docsmobilereleaserunbookmd-store-submission-runbook","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"Docs/MobileReleaseRunbook.md, store-submission runbook","x":"File: MMCA.ADC/Docs/MobileReleaseRunbook.md What it is. The manual, credential-holding steps around a store submission that code and CI cannot perform, each tagged with when it…","i":"ADC_ANDROID_SIGNING_PASSWORD FileStorage.UploadFailed sha256_cert_fingerprints AndroidSigningStorePass com.ivanball.atldevcon grantAvatarStorageRole AndroidSigningKeyPass deployNotificationHub TargetPlatformVersion InternalServerError Entitlements.plist ivanball.AtlDevCon"},{"u":"/docs/onboarding/devops-runbooks.html#the-database-per-service-cutover-in-full-context","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"The database-per-service cutover in full context","x":"The five database-related artifacts above form a single coherent story, and the resilience artifacts extend it past the cutover: The AtlDevCon database is the thread that runs…","i":"CrossDataSourceDegradeConvention OPERATIONS.md deploy.yml main.bicep AtlDevCon delete NEVER sql"},{"u":"/docs/onboarding/devops-runbooks.html#rubric-tag-summary","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"Rubric tag summary","x":"---","i":"OPERATIONS.md"},{"u":"/docs/onboarding/devops-runbooks.html#not-determinable-from-source","d":"Operational Scripts & Runbooks","k":"Onboarding Guide","t":"Not determinable from source","x":"- ALERTEMAIL variable: DISASTER-RECOVERY.md:55-57 and OPERATIONS.md:8-11 both route alert notifications through the alertEmailAddress action-group receiver fed by the ALERTEMAIL…","i":"alertEmailAddress ALERT_EMAIL"},{"u":"/docs/onboarding/devops-testing.html","d":"Testing Architecture & Solution Composition","k":"Onboarding Guide","x":"Chapter scope note. The tier chapters (tier-00 through the sweep) document every type in the production codebase one by one. Test types are the logged exception: this chapter…","i":"Fact"},{"u":"/docs/onboarding/devops-testing.html#1-solution-composition-and-the-test-runner","d":"Testing Architecture & Solution Composition","k":"Onboarding Guide","t":"1. Solution composition and the test runner","x":"The two deployed apps use the same two-file pattern; MMCA.Common and MMCA.Helpdesk ship a .slnx only, because their solutions are already fast enough not to need a CI subset:…","i":"MMCA.ADC.ServiceBusEmulator.IntegrationTests MMCA.ADC.CrossService.IntegrationTests MMCA.ADC.Architecture.Tests MMCA.Store.Integration.slnf MMCA.ADC.Integration.slnf MMCA.ADC.IntegrationTests DistributedCacheService MMCA.ADC.Services.Tests MMCA.ADC.Gateway.Tests MMCA.ADC.WebAPI.Tests MMCA.Common.API.Tests WebApplicationFactory"},{"u":"/docs/onboarding/devops-testing.html#2-test-project-layout","d":"Testing Architecture & Solution Composition","k":"Onboarding Guide","t":"2. Test project layout","x":"The inventory below is drawn from 00-inventory.md:23-117 (test-assembly counts) and the solution files above. Counts are distinct types per project as reported by the Roslyn…","i":"MMCA.ADC.ServiceBusEmulator.IntegrationTests CurrentEventNotificationScopeProviderTests MMCA.ADC.Conference.Infrastructure.Tests MMCA.ADC.Engagement.Infrastructure.Tests MMCA.ADC.Notification.Application.Tests MMCA.ADC.CrossService.IntegrationTests MMCA.ADC.Identity.Infrastructure.Tests MMCA.ADC.Notification.IntegrationTests MMCA.Common.Infrastructure.Redis.Tests NotificationUserDataExportSectionTests MMCA.ADC.Conference.Application.Tests MMCA.ADC.Engagement.Application.Tests"},{"u":"/docs/onboarding/devops-testing.html#3-shipped-testing-infrastructure-packages","d":"Testing Architecture & Solution Composition","k":"Onboarding Guide","t":"3. Shipped testing-infrastructure packages","x":"MMCA.Common ships four of its fifteen packages as testing infrastructure that downstream apps consume as NuGet references rather than writing their own harness…","i":"AxeOptions.Wcag21AaExceptMudPagerCombobox WebApplicationFactory.ConfigureServices ServiceInfoVersioningContractTestsBase AssertNoAccessibilityViolationsAsync IsAuthenticatedAuthorizationService SqlServerIntegrationTestFixtureBase MutableAuthenticationStateProvider PageExtensions.FillAndVerifyAsync MMCA.Common.Testing.Architecture ProductionHostApplicationFactory AccessibilityViolationException DecoratorPipelineOrderTestsBase"},{"u":"/docs/onboarding/devops-testing.html#4-architecture-fitness-tests-executable-governance","d":"Testing Architecture & Solution Composition","k":"Onboarding Guide","t":"4. Architecture fitness tests, executable governance","x":"[Rubric §34, Architecture Governance & Documentation]: §34 assesses whether architectural decisions are documented, enforced, and kept honest over time; fitness functions are the…","i":"AggregateRoots_ShouldHave_NoPublicConstructors SpecificationsDoNotNavigateToOtherEntities ArchitectureRules.PinnedPackageMajorBelow LayerMap_ModulesDeclareEveryExpectedLayer MassTransit_MustNotExceed_MajorVersion8 CoreLayers_ShouldNotDependOn_Transport ImageSharp_MustNotExceed_MajorVersion3 ObservabilityConventionTestsBaseTests Infrastructure_ShouldNotDependOn_Api ConstructorDependencyCountTestsBase DomainFactories_ShouldReturn_Result FakeDependentModuleConformanceTests"},{"u":"/docs/onboarding/devops-testing.html#5-integration-and-e2e-strategy","d":"Testing Architecture & Solution Composition","k":"Onboarding Guide","t":"5. Integration and E2E strategy","x":"The four integration test projects (Identity, Conference, Engagement, Notification) each boot their service in-process with WebApplicationFactory . The lifecycle is not written…","i":"MMCA.Store.ServiceBusEmulator.IntegrationTests MMCA.ADC.ServiceBusEmulator.IntegrationTests MMCA.Store.CrossService.IntegrationTests MMCA.ADC.CrossService.IntegrationTests MMCA.Common.Infrastructure.Redis.Tests AssertNoAccessibilityViolationsAsync IntegrationTestBase.InitializeAsync SqlServerIntegrationTestFixtureBase MMCA.Common.Infrastructure.Tests IdentityIntegrationTestFixture appsettings.Development.json DatabaseInitStrategy.Migrate"},{"u":"/docs/onboarding/devops-testing.html#6-worked-examples","d":"Testing Architecture & Solution Composition","k":"Onboarding Guide","t":"6. Worked examples","x":"Three examples tie the infrastructure above to real test code. The per-repo class is a bare subclass; the facts, the package lists and the parsing live once in the shared base:…","i":"IdentityIntegrationTestFixture.DisposeAsync ImageSharp_MustNotExceed_MajorVersion3 IntegrationTestBase.InitializeAsync MutableAuthenticationStateProvider IntegrationTestBase.DisposeAsync IdentityIntegrationTestFixture AuthenticationStateProvider GetAuthenticationStateAsync IdentityIntegrationTestBase Fixture.ResetDatabaseAsync Directory.Packages.props AuthenticateAsAttendee"},{"u":"/docs/onboarding/devops-testing.html#7-the-tiers-and-the-gates-that-run-them","d":"Testing Architecture & Solution Composition","k":"Onboarding Guide","t":"7. The tiers and the gates that run them","x":"A test tier only means something once you know what it blocks. This is the map. MMCA.Common's ui-e2e job (MMCA.Common/.github/workflows/ci.yml:228) builds the out-of-slnx gallery…","i":"Integration.slnf MemoryDiagnoser E2E_BROWSER browsers chromium coverage CI.slnf e2e.yml firefox skipped success deploy"},{"u":"/docs/onboarding/devops-testing.html#quick-reference-rubric-categories-touched-in-this-chapter","d":"Testing Architecture & Solution Composition","k":"Onboarding Guide","t":"Quick reference: rubric categories touched in this chapter","x":"---"},{"u":"/docs/onboarding/devops-testing.html#cross-links","d":"Testing Architecture & Solution Composition","k":"Onboarding Guide","t":"Cross-links","x":"- Primer: 00-primer.md5-the-solution--test-layout , solution files, MTP runner, slnx-excluded UI projects - Primer:…","i":"MMCA.ADC.Integration.slnf IIntegrationTestFixture"},{"u":"/docs/onboarding/99-coverage-audit.html","d":"Phase 4, Coverage Audit","k":"Onboarding Guide","x":"This audit reconciles the written guide against the mechanically-extracted inventory, logs every deliberate exception, verifies the grouping/ordering rules, proves all 34 rubric…","i":"classify.ps1 verify.ps1 plan.ps1"},{"u":"/docs/onboarding/99-coverage-audit.html#1-coverage-reconciliation","d":"Phase 4, Coverage Audit","k":"Onboarding Guide","t":"1. Coverage reconciliation","x":"Cross-check result: verify.ps1 confirms 0 of the 2,001 individually-sectioned types are missing from their group chapter, every one appears as a heading or in a sibling-family…","i":"SpecificationsDoNotNavigateToOtherEntities InsecureJwtMetadataWarningStartupFilter UserSessionBookmarkCacheEvictionHandler MMCA.Common.Infrastructure.Redis.Tests DatabaseInitializationExtensionsTests HttpContextExternalLoginEmailVerifier MMCA.ADC.Conference.Application.Tests MMCA.ADC.Engagement.Application.Tests ObservabilityConventionTestsBaseTests OwnSessionQuestionAnswerSpecification AnonymousAuthenticationStateProvider AzureNotificationHubNativePushSender"},{"u":"/docs/onboarding/99-coverage-audit.html#2-exceptions-log-every-deliberate-omission-with-reason","d":"Phase 4, Coverage Audit","k":"Onboarding Guide","t":"2. Exceptions log (every deliberate omission, with reason)","x":"EF Core migrations (/Migrations/, .Migrations.SqlServer), ModelSnapshot, .Designer.cs, .g.cs, GlobalUsings.g.cs, and AssemblyInfo.cs are excluded by rule (Tools/invtool…","i":"ObservabilityConventionTestsBase ProductionHostApplicationFactory RouteAuthorizationTestsBase ModuleConformanceTestsBase DependencyInjectionAssert GracefulShutdownTestsBase MMCA.Common.Benchmarks Migrations.SqlServer Testing.Architecture MMCA.Common.Testing GlobalUsings.g.cs AssemblyInfo.cs"},{"u":"/docs/onboarding/99-coverage-audit.html#3-grouping--ordering-verification","d":"Phase 4, Coverage Audit","k":"Onboarding Guide","t":"3. Grouping & ordering verification","x":"- Every type in exactly one group. classify.ps1 assigns all 3,668 nodes via name-level overrides (for the grab-bag MMCA.Common.Interfaces/Services namespaces) + ordered…","i":"MidSaveContextCreatingDbContext OutboxRoutingTestDbContext ReentrantSaveInterceptor FailingSaveInterceptor INavigationPopulator ResultGrpcExtensions EntityQueryService SelfHttpWarmupTask ApiControllerBase DeferredDispatch ErrorHttpMapping _typemap.tsv"},{"u":"/docs/onboarding/99-coverage-audit.html#4-rubric-coverage-matrix","d":"Phase 4, Coverage Audit","k":"Onboarding Guide","t":"4. Rubric coverage matrix","x":"Every one of the 34 categories is explained at least once against real code. \"First explained in\" is the earliest group chapter (by order) that tags it; many recur and several…","i":"ThemeService verify.ps1 token"},{"u":"/docs/onboarding/99-coverage-audit.html#5-open-questions--not-determinable-from-source","d":"Phase 4, Coverage Audit","k":"Onboarding Guide","t":"5. Open questions / not determinable from source","x":"1. IDbSeeder host invocation (group-07). The seeding contract and implementations are in MMCA.Common, but the IHostedService/startup invoker that actually runs seeding at boot…","i":"MMCA.ADC.Identity.Contracts.DependencyInjection ModuleApplicationDbContext CrossSourceSpecification ReadRepositoryExtensions EntityTypeConfiguration DependencyInjection DbContexts.Factory ChangePassword ExportUserData IHostedService EnsureCreated IUnitOfWork"},{"u":"/docs/onboarding/99-coverage-audit.html#6-how-to-regenerate-this-audit","d":"Phase 4, Coverage Audit","k":"Onboarding Guide","t":"6. How to regenerate this audit","x":"Then copy the refreshed out/00-inventory.md and out/00-dependency-manifest.md into Docs/Onboarding/ (the 00-group-taxonomy.md is written there directly by classify.ps1).","i":"classify.ps1"},{"u":"/docs/onboarding/CONCEPT-MAPS.html","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","x":"Mermaid diagrams distilled from the Onboarding guide (primer, group taxonomy, dependency manifest, and the 27 group chapters). Each diagram captures a relationship between the…"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#1-system-context-two-codebases--the-15-packages","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"1. System context, two codebases + the 15 packages","x":"MMCA.Common is a framework published as fifteen NuGet packages in lockstep, to nuget.org and GitHub Packages from one tag (ADR-053); MMCA.ADC and MMCA.Store consume them. The…","i":"MMCA.Common.slnx MMCA.Common MMCA.Store MMCA.ADC UI.Maui"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#2-clean-architecture-the-layered-dependency-rule","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"2. Clean Architecture, the layered dependency rule","x":"Source dependencies point inward toward the Domain; each layer references only layers below it. Deliberate exceptions: UI and Grpc depend on Shared only (UI for Blazor WASM…","i":"ProjectReference UI.Maui Aspire Blazor bridge depend Shared above host only sits and"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#3-the-27-functional-groups-dependency--build-order","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"3. The 27 functional groups, dependency / build order","x":"The primary axis of the guide: every type lives in exactly one of 27 chapter groups, ordered roughly topologically. Foundational, widely-depended-on concerns first (Result →…"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#4-core-framework-patterns-how-the-building-blocks-compose","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"4. Core framework patterns, how the building blocks compose","x":"The pattern-level view of the same backbone: the ideas the primer commits to and how they feed each other. Result is the pervasive currency; DDD blocks produce domain events;…","i":"Result"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#5-request-lifecycle-the-cqrs-decorator-pipeline-adr-014","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"5. Request lifecycle, the CQRS decorator pipeline (ADR-014)","x":"Handlers are thin (one method); every cross-cutting concern is a decorator wrapping the next. Scrutor TryDecorate composes them in reverse registration order (last registered =…","i":"AddApplicationDecorators TryDecorate"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#6-event-driven-integration-outbox-dual-dispatch-adr-003--010--021","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"6. Event-driven integration, outbox dual-dispatch (ADR-003 / 010 / 021)","x":"Domain events are captured into an OutboxMessage row in the same transaction as the data (no dual-write bug). The two event kinds then part ways: local domain events are…","i":"IIntegrationEventPublisher IEventBus.PublishAsync OutboxProcessor OutboxMessage SchemaVersion IMessageBus MessageId"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#7-modular-monolith--extractable-services-adr-006--007--008--012","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"7. Modular monolith → extractable services (ADR-006 / 007 / 008 / 012)","x":"Modules implement IModule and are discovered + Kahn-ordered by ModuleLoader (ADR-059). The same module code runs as a single monolith host or as N service processes behind a YARP…","i":"MMCA.ADC.WebAPI ModuleLoader IMessageBus IModule"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#8-persistence-database-per-service--polyglot-engines-adr-006--018--030","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"8. Persistence, database-per-service + polyglot engines (ADR-006 / 018 / 030)","x":"One concrete SQLServerDbContext over the abstract ApplicationDbContext, one instance per database. Each entity is engine-agnostic; a single [UseDataSource(engine)] attribute on…","i":"ApplicationDbContext SQLServerDbContext UseDataSource engine"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#9-authentication--authorization-stack","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"9. Authentication & Authorization stack","x":"The auth concern (G08) spans token validation, session cookies, federated sign-in, password hashing, brute-force protection, refresh-token rotation and revocation, and a layered…"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#10-notifications-three-channels-behind-one-send-pipeline-adr-024--044","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"10. Notifications, three channels behind one send pipeline (ADR-024 / 044)","x":"One use case (SendPushNotificationHandler) writes a durable per-user inbox, fires a transient SignalR push, and then an OS-level native push that reaches a backgrounded or killed…","i":"SendPushNotificationHandler MMCA.ADC.Notification SmtpEmailSender IEmailSender"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#11-ui-write-once-render-everywhere--i18n--theming","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"11. UI, write-once render everywhere + i18n + theming","x":"A page is authored once as a Razor component in a per-module UI library; both the Blazor web host (Server + WASM) and the .NET MAUI host reference the same libraries, so it…","i":"IStringLocalizer InteractiveAuto MMCA.Common.UI ThemeService rendermode"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#12-adc-business-modules-bounded-contexts-end-to-end","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"12. ADC business modules, bounded contexts end-to-end","x":"Each ADC module is a vertical slice through all layers. Conference is large enough to split across five chapters (G17-G21); Engagement takes two (G22 session bookmarks, G23 the…"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#13-the-adrs-grouped-by-theme","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"13. The ADRs, grouped by theme","x":"Every accepted ADR in Website/docs-src/adr/, clustered by the concern it governs. That directory's README.md is the canonical index and owns the count and range; this map only…","i":"README.md"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#14-the-34-category-evaluation-rubric","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"14. The 34-category evaluation rubric","x":"The lens the guide tags code against ([Rubric §N]). Scored on two axes: Maturity (0-4, process) and Implementation (0-10, substance). Three parts. ---","i":"Rubric"},{"u":"/docs/onboarding/CONCEPT-MAPS.html#15-how-the-axes-fit-together-reading-map","d":"MMCA Concept & Pattern Maps","k":"Onboarding Guide","t":"15. How the axes fit together (reading map)","x":"The guide is organized on two axes at once. This ties the diagrams above back to the guide's navigation. --- - Group-to-group arrows in §3 show the dominant \"builds on\" direction…","i":"ApplicationDbContext"},{"u":"/docs/governance/index.html","d":"Architecture Governance","k":"Architecture Governance","x":"The governance artifacts behind the MMCA platform: the shared 34-category evaluation rubric, and each repo's evidence-based scorecard plus its remediation backlog. Every score…"},{"u":"/docs/governance/index.html#the-rubric","d":"Architecture Governance","k":"Architecture Governance","t":"The rubric","x":"- Architecture Evaluation Criteria: the 34-category rubric (Maturity 0-4 and Implementation 0-10 per category) that all three application repos are scored against."},{"u":"/docs/governance/index.html#how-these-are-maintained","d":"Architecture Governance","k":"Architecture Governance","t":"How these are maintained","x":"Scores are re-verified from source on a cadence: each category is scored by reading the current code, config, and CI (never rolled forward), and any change lands with the…"},{"u":"/docs/governance/adc-ArchitectureScorecard.html","d":"MMCA.ADC: Architecture Scorecard","k":"Architecture Governance","x":"Canonical, version-controlled scorecard for this repo: the single source of truth for MMCA.ADC's architecture scores (replaces the former single-axis snapshot; see git history).…","i":"dotnet_analyzer_diagnostic.severity SessionSelectionDashboard.razor.cs ArchitectureEvaluationCriteria.md MMCA.ADC.Notification.Application MMCA.Common.Testing.Architecture UIArchitectureConventionTests.cs StateManagementConventionTests SQLitePCLRaw.bundle_e_sqlite3 UIArchitectureConventionTests ObservabilityConventionTests Event.OrganizerContactEmail PseudoLocalizationTests"},{"u":"/docs/governance/adc-ArchitectureScorecard.html#executive-summary","d":"MMCA.ADC: Architecture Scorecard","k":"Architecture Governance","t":"Executive summary","x":"MMCA.ADC is a mature, evidence-driven .NET 10.0 (LangVersion preview) DDD/Clean Architecture system for the Atlanta Developers Conference, deliberately extracted from a modular…","i":"MMCA.ADC.CrossService.IntegrationTests MMCA.ADC.Notification.IntegrationTests SessionIncludeChildrenRegressionTests UIArchitectureConventionTestsBase FrameworkVersionConsistencyTests LocalizedTextConventionTestsBase MMCA.Common.Testing.Architecture ErrorMessages.ValidationError LocalizedTextConventionTests ObservabilityConventionTests SpecificationConventionTests BlazorCspPolicyProvider.cs"},{"u":"/docs/governance/adc-ArchitectureScorecard.html#scorecard","d":"MMCA.ADC: Architecture Scorecard","k":"Architecture Governance","t":"Scorecard","x":"Weighted column = Maturity·weight / Implementation·weight per row. Axis-gap findings: the former §21 gap (excellent-but-not-enforced, impl 7 over maturity 2) closed on 2026-07-02…","i":"MmcaCultureBootstrap.SetBrowserCultureAsync ResilienceCircuitBreakerFaultInjectionTests MMCA.ADC.CrossService.IntegrationTests dotnet_analyzer_diagnostic.severity FrameworkVersionConsistencyTests.cs StateManagementConventionTestsBase MMCA.ADC.Notification.Application UIArchitectureConventionTestsBase MMCA.Common.Testing.Architecture ConstructorDependencyCountTests LocalizedTextConventionTests.cs ObservabilityConventionTests.cs"},{"u":"/docs/governance/adc-ArchitectureScorecard.html#indices","d":"MMCA.ADC: Architecture Scorecard","k":"Architecture Governance","t":"Indices","x":"- Maturity index = Σ(maturity×weight) ÷ Σ(weight×4) = 311 ÷ 320 = 97.2% (down from 97.8%, 313/320: the twenty-second-cycle full re-score (2026-07-21, pin v1.121.0) corrected §22…","i":"OrganizerContactEmail"},{"u":"/docs/governance/adc-ArchitectureScorecard.html#top-5-strengths","d":"MMCA.ADC: Architecture Scorecard","k":"Architecture Governance","t":"Top 5 strengths","x":"1. Architecture intent is executable, not prose: 29 architecture-test classes / 91 methods gate CI (§34 Governance, mat 4 / impl 9, and §3 Clean Architecture, mat 4 / impl 9):…","i":"SessionIncludeChildrenRegressionTests MMCA.Common.Testing.Architecture SpecificationConventionTests.cs AddSessionCookieAuthentication StateManagementConventionTests MicroserviceExtractionTests AddCommonSecurityHeaders ArchitecturalAnalysis.md LayerDependencyTests AddCommonBlazorCsp DataResidencyTests DomainPurityTests"},{"u":"/docs/governance/adc-ArchitectureScorecard.html#top-5-risks","d":"MMCA.ADC: Architecture Scorecard","k":"Architecture Governance","t":"Top 5 risks","x":"Expected-delta note (updated 2026-08-01): several entries below record an expected lift of \"impl 9→10\". Under the 2026-08-01 recalibration those are attainable, not aspirational:…","i":"publicNetworkAccess packages.lock.json MMCA.ADC.CI.slnf deploy.needs maxReplicas MMCA.ADC.UI CI.slnf"},{"u":"/docs/governance/adc-ArchitectureScorecard.html#cross-repo-comparison","d":"MMCA.ADC: Architecture Scorecard","k":"Architecture Governance","t":"Cross-repo comparison","x":"How this repo's quality relates to the other consumers (where the framework's quality propagates, where a repo diverges or under-uses it, and where a consumer does better than…"},{"u":"/docs/governance/adc-RemediationBacklog.html","d":"MMCA.ADC: Architecture Remediation Backlog","k":"Architecture Governance","x":"Derived from ArchitectureScorecard.md (single-axis 0-4, baseline 75%, 241/320, dated 2026-06-08). Current authoritative two-axis scores (twenty-seventh-cycle full re-score,…","i":"MMCA.ADC.CrossService.IntegrationTests SqlServerIntegrationTestFixtureBase SessionSelectionDashboard.razor.cs MMCA.ADC.Notification.Application FrameworkVersionConsistencyTests StateManagementConventionTests UIArchitectureConventionTests IntegrationTestReworkPlan.md LocalizedTextConventionTests ObservabilityConventionTests TranslationCompletenessTests Event.OrganizerContactEmail"},{"u":"/docs/governance/adc-RemediationBacklog.html#priority-6-highest-leverage","d":"MMCA.ADC: Architecture Remediation Backlog","k":"Architecture Governance","t":"🔴 Priority 6: highest leverage","x":"Token handling uses two rubric-named anti-patterns, with no CSP defense-in-depth. Status (2026-06-27): cookie-only refresh + in-memory access (auth-path BFF), OAuth…","i":"ResilienceCircuitBreakerFaultInjectionTests DisconnectedCircuitRetentionPeriod ManagementRouteAuthorizationTests GatewaySecurityHeadersMiddleware E2E_LIFT_REGISTRATION_THROTTLE OAuthController.CompleteAsync OAuthController.ExchangeAsync SameOriginProxyTokenRefresher MMCA.ADC.Conference.UI.Tests AuthenticationStateProvider EventDetailPage.StatusChip InvalidOperationException"},{"u":"/docs/governance/adc-RemediationBacklog.html#priority-4","d":"MMCA.ADC: Architecture Remediation Backlog","k":"Architecture Governance","t":"🟠 Priority 4","x":"The (4−score)×weight formula puts this at 4, but the High flag is a contractual/regulatory exposure that contradicts a shipped, publicly-served policy: treat it as do-soon. -…","i":"user_notification_export.proto LocalizedTextConventionTests TranslationCompletenessTests user_engagement_export.proto ExportUserDataHandlerTests ErasureAndPiiLoggingTests DeleteUserHandlerTests ErrorMessages.Success SessionQuestionAnswer User.PreferredCulture UserRegisteredHandler EventQuestionAnswer"},{"u":"/docs/governance/adc-RemediationBacklog.html#priority-3-score-3-weight-3-one-rung-from-a-4","d":"MMCA.ADC: Architecture Remediation Backlog","k":"Architecture Governance","t":"🟡 Priority 3: score 3, weight 3 (one rung from a 4)","x":"- ~~(High) The 258-test Testcontainers integration tier references the deleted MMCA.ADC.WebAPI host, won't build, and is excluded.~~ RESOLVED: reworked as per-service…","i":"Update_WithStaleRowVersion_ReturnsConflict MMCA.ADC.CrossService.IntegrationTests SessionSelectionDashboard.razor.cs StateManagementConventionTestsBase ManagementRouteAuthorizationTests UIArchitectureConventionTestsBase InProcessEventBus.PublishAsync SessionSelectionSpeakerOverlap StateManagementConventionTests UIArchitectureConventionTests DbUpdateConcurrencyException PublicSessionList.razor.cs"},{"u":"/docs/governance/adc-RemediationBacklog.html#priority-2-score-3-weight-2-polish--hardening","d":"MMCA.ADC: Architecture Remediation Backlog","k":"Architecture Governance","t":"🟢 Priority 2: score 3, weight 2 (polish / hardening)","x":"- ~~(Medium) No OpenAPI served by any running service, yet CLAUDE.md still advertises 4 doc UIs (/swagger, /nswag-swagger, /api-docs, /scalar/v1).~~ - [x] Serve OpenAPI per…","i":"Microsoft.AspNetCore.Authorization.Authorize AuthorizationPolicies.RequireOrganizer MMCA.ADC.Conference.IntegrationTests ManagementRouteAuthorizationTests FrameworkVersionConsistencyTests IdentityRouteAuthorizationTests IntegrationEventContractTests MMCA.ADC.Migrations.SqlServer Microsoft.AspNetCore.OpenApi ObservabilityConventionTests MicroserviceExtractionTests Validation.CorrectFollowing"},{"u":"/docs/governance/adc-RemediationBacklog.html#implementation-band-implementation--8-ranked-by-implpriority","d":"MMCA.ADC: Architecture Remediation Backlog","k":"Architecture Governance","t":"🔵 Implementation band (implementation <= 8, ranked by implPriority)","x":"Added 2026-07-28 when the ledger gained its second ranked axis. Until then implementation gaps appeared only as unranked sub-bullets inside maturity items, so they were never…","i":"SessionSelectionDashboard.razor.cs MMCA.ADC.Notification.Application AdcArchitectureMap.DefineLayers ConferenceCategoryDetail.razor PublicSessionList.razor.cs needs.changes.outputs.ui PrimitivesSnapshotTests MMCA.Common.Testing.UI OrganizerContactEmail TreatWarningsAsErrors DeviceSettings.razor EventDetail.razor.cs"},{"u":"/docs/governance/adc-RemediationBacklog.html#resolved-2026-07-25-performance-program-2","d":"MMCA.ADC: Architecture Remediation Backlog","k":"Architecture Governance","t":"🟢 Resolved 2026-07-25 (performance program 2)","x":"Second evidence-led performance pass over Common/ADC/Store. ADC's share shipped as three PRs plus the v1.127.0 framework sweep. - [x] Output cache shared across replicas.…","i":"Session.SessionQuestionAnswers Event.EventQuestionAnswers SessionQuestionViewBuilder CategoryItemLookupService SessionScoringProcessor SpeakerDashboardService SessionQuestionAnswers EventQuestionAnswers SessionCategoryItems SpeakerCategoryItems GetOpenPollsHandler PublicSessionDetail"},{"u":"/docs/governance/adc-RemediationBacklog.html#deliberate--accepted-recorded-decisions-not-scheduled-work","d":"MMCA.ADC: Architecture Remediation Backlog","k":"Architecture Governance","t":"Deliberate / accepted (recorded decisions, not scheduled work)","x":"Conscious, recorded choices, not pending work (the former TECHDEBT.md accepted-risk section): - Single-region deployment (no multi-region failover): accepted in…","i":"SessionRoomScheduling.ValidateRoomAssignmentAsync MMCA.ADC.Notification.Application ConstructorDependencyCountTests LocalizedTextConventionTests TranslationCompletenessTests ArchitecturalAnalysis.md PseudoLocalizationTests AuthenticationService OrganizerContactEmail BrandColorTokenTests DeviceSettings.razor skip_freshness_gates"},{"u":"/docs/governance/adc-RemediationBacklog.html#already-at-level-4-protect-dont-regress","d":"MMCA.ADC: Architecture Remediation Backlog","k":"Architecture Governance","t":"✅ Already at level 4: protect, don't regress","x":"1 SOLID · 2 Design Patterns · 3 Clean Architecture · 4 Domain-Driven Design · 5 Vertical Slice Architecture · 6 CQRS & Event-Driven · 7 Microservices Readiness · 8 Data…","i":"AnthropicScoringService.ScoreSessionAsync MMCA.ADC.CrossService.IntegrationTests GetSessionSelectionDashboardHandler SessionSelectionDashboard.razor.cs GetSpeakerSessionOverlapHandler GetCategoryDistributionHandler Session.AddSessionCategoryItem Session.CategoryItem.Duplicate Speaker.AddSpeakerCategoryItem Speaker.CategoryItem.Duplicate ObservabilityConventionTests OperationCanceledException"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html","d":"Architecture Evaluation Criteria","k":"Architecture Governance","x":"A structured rubric for evaluating the architecture of an enterprise application. Each category defines what is being assessed, concrete criteria to check, red flags that signal…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#how-to-use-this-rubric","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"How to Use This Rubric","x":"Score each category 0–4. Use the same scale everywhere so totals are comparable. Alongside the maturity level, rate how well each category is actually implemented on a finer 0–10…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#1-solid-principles","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"1. SOLID Principles","x":"Intent: Object/module-level design discipline that keeps code flexible and decoupled. Criteria - SRP: each class/handler has one reason to change; no \"god\" services orchestrating…","i":"NotSupportedException switch new"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#2-design-patterns","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"2. Design Patterns","x":"Intent: Appropriate, idiomatic use of patterns, solving real problems, not pattern theater. Criteria - Creational (Factory methods on entities, Builder, Options) used where…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#3-clean-architecture","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"3. Clean Architecture","x":"Intent: Dependencies point inward; business rules are independent of frameworks, UI, and data stores. Criteria - Dependency rule enforced: Domain → (nothing); Application →…","i":"JsonProperty DbContext Table"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#4-domain-driven-design","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"4. Domain-Driven Design","x":"Intent: The model reflects the business; boundaries follow capability boundaries, not technical layers. Criteria - Ubiquitous language: type/method names match business terms…","i":"decimal Result string Guid"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#5-vertical-slice-architecture","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"5. Vertical Slice Architecture","x":"Intent: Code is organized by feature/capability, so a change touches one cohesive slice. Criteria - Features grouped by use case (command/query + handler + validator + DTO…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#6-cqrs--event-driven-design","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"6. CQRS & Event-Driven Design","x":"Intent: Reads and writes are separated where it pays off; integration via events is reliable. Criteria - Commands (mutate, return Result) and queries (read, side-effect-free) are…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#7-microservices-readiness","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"7. Microservices Readiness","x":"Intent: Whether services (or future-extractable modules) are independently deployable and own their data. Criteria - Service boundaries align with bounded contexts; one team can…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#8-data-architecture","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"8. Data Architecture","x":"Intent: Persistence, consistency, and migrations are deliberate and safe. Criteria - Transaction boundaries match aggregate boundaries; unit-of-work scope is clear. - Migrations…","i":"Include"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#9-api--contract-design","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"9. API & Contract Design","x":"Intent: External and inter-service contracts are clear, stable, and evolvable. Criteria - Consistent resource/endpoint design (REST/minimal APIs/gRPC) with predictable shapes. -…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#10-cross-cutting-concerns","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"10. Cross-Cutting Concerns","x":"Intent: Validation, caching, resilience, configuration, and mapping are centralized and consistent. Criteria - Validation, logging, transactions handled by pipeline…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#11-security","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"11. Security","x":"Intent: AuthN/AuthZ, secrets, and data protection are correct by construction. Criteria - Authentication centralized; tokens validated; identity flows documented (e.g.,…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#12-performance--scalability","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"12. Performance & Scalability","x":"Intent: The system meets latency/throughput goals and scales horizontally. Criteria - Async I/O throughout; no sync-over-async; no blocking the request thread. - Hot-path query…","i":"Result Wait"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#13-observability--operability","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"13. Observability & Operability","x":"Intent: You can understand and operate the system in production. Criteria - Structured logging with correlation/trace IDs flowing across module/service boundaries. - Distributed…","i":"Console.WriteLine"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#14-testability--test-strategy","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"14. Testability & Test Strategy","x":"Intent: The design supports fast, reliable, meaningful tests at the right levels. Criteria - Healthy test pyramid: many fast unit tests on domain/application, fewer integration,…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#15-best-practices--code-quality","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"15. Best Practices & Code Quality","x":"Intent: Day-to-day craftsmanship that keeps the codebase healthy. Criteria - Analyzers at error severity (style, security, threading, maintainability) enforced in CI;…","i":"disable warning pragma"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#16-maintainability--evolvability","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"16. Maintainability & Evolvability","x":"Intent: The system absorbs change cheaply and ages well. (The governance/documentation depth behind this (ADRs, fitness functions, diagrams) is scored separately in §34.)…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#17-devops--deployment","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"17. DevOps & Deployment","x":"Intent: Building, releasing, and provisioning are automated, repeatable, and safe. (The local developer experience / inner loop behind this (local orchestration, cross-repo dev,…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#18-ui-architecture--component-design","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"18. UI Architecture & Component Design","x":"Intent: Components are cohesive, reusable, and composed cleanly, the UI has a deliberate structure, not page-sized blobs. Criteria - Container/presentational split: smart…","i":"EventCallback ShouldRender razor key"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#19-state-management--data-flow","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"19. State Management & Data Flow","x":"Intent: Client state has a clear owner and predictable flow; server state is cached and invalidated deliberately. Criteria - Single source of truth per piece of state; ownership…","i":"StateHasChanged IsDirty"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#20-design-system-theming--ui-consistency","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"20. Design System, Theming & UI Consistency","x":"Intent: A coherent visual language enforced by a component library, not re-implemented per screen. Criteria - Component library used consistently (e.g., MudBlazor): teams build…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#21-accessibility-a11y","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"21. Accessibility (a11y)","x":"Intent: The UI is usable by everyone, including assistive-technology users, and ideally enforced, not aspirational. Criteria - Semantic structure: correct…","i":"span div"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#22-responsive-design--cross-browserdevice","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"22. Responsive Design & Cross-Browser/Device","x":"Intent: The UI works across viewport sizes, input modes, and supported browsers. Criteria - Fluid/responsive layouts via the design system's grid/breakpoints; no fixed-width…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#23-front-end-performance--rendering","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"23. Front-End Performance & Rendering","x":"Intent: The UI loads and responds fast; rendering work is bounded. (Complements §12: this is the client side.) Criteria - Initial load: bundle/payload size controlled;…","i":"ShouldRender key"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#24-forms-validation--ux-safety","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"24. Forms, Validation & UX Safety","x":"Intent: Data entry is safe, forgiving, and consistent, users don't lose work or get confused by errors. Criteria - Validation parity: client-side validation for fast feedback…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#25-navigation-routing--information-architecture","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"25. Navigation, Routing & Information Architecture","x":"Intent: Users can find their way; routes are meaningful, guarded, and role-aware. Criteria - Route design: clean, bookmarkable, deep-linkable URLs; parameters typed and…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#26-front-end-security","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"26. Front-End Security","x":"Intent: The client doesn't become the weak link, XSS, token handling, and trust boundaries are correct. (Complements §11.) Criteria - Output encoding / XSS: no unsanitized HTML…","i":"MarkupString innerHTML"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#27-internationalization--localization","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"27. Internationalization & Localization","x":"Intent: The UI can be translated and respects culture, if in scope. (Score weight 0–1 if single-locale by design.) Criteria - Externalized strings: UI text in resource files, not…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#28-front-end-testing--quality","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"28. Front-End Testing & Quality","x":"Intent: The UI is verified at the right levels with stable, meaningful tests. (Complements §14.) Criteria - Component tests (e.g., bUnit) for rendering logic, parameters, events,…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#29-resilience-reliability--business-continuity","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"29. Resilience, Reliability & Business Continuity","x":"Intent: The system survives partial failure and recovers from disaster within defined objectives. (Extends the resilience facets of §7/§12 into a first-class recovery story.)…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#30-compliance-privacy--data-governance","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"30. Compliance, Privacy & Data Governance","x":"Intent: Personal and regulated data is classified, governed, and handled lawfully across its lifecycle. (§11 defends against attackers; this answers to regulators.) Criteria -…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#31-cost-efficiency--finops","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"31. Cost Efficiency / FinOps","x":"Intent: Cloud spend is proportional to value and driven by data, not guesswork. (§17 mentions cost; this makes it a first-class axis.) Criteria - Right-sizing: compute/database…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#32-dependency--supply-chain-management","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"32. Dependency & Supply-Chain Management","x":"Intent: Third-party and inter-package dependencies are controlled, auditable, and evolve safely, especially critical for a framework that publishes packages. (Elevates §15's…"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#33-developer-experience--inner-loop","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"33. Developer Experience & Inner Loop","x":"Intent: Developers build, run, test, and iterate locally with fast, low-friction feedback. (Promoted out of §17: that scores release/ops automation; this scores the inner loop.)…","i":"editorconfig local.props"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#34-architecture-governance--documentation","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"34. Architecture Governance & Documentation","x":"Intent: Decisions are recorded, conformance is enforced, and the system is documented so it stays coherent as it evolves. (Promoted out of §16: that scores the property of…","i":"CLAUDE.md"},{"u":"/docs/governance/ArchitectureEvaluationCriteria.html#appendix-quick-scan-checklist","d":"Architecture Evaluation Criteria","k":"Architecture Governance","t":"Appendix: Quick-Scan Checklist","x":"A 2-minute triage before the full evaluation: any \"no\" warrants a deeper look. - [ ] Can you draw the dependency graph and is it acyclic and inward-pointing? - [ ] Is the domain…"},{"u":"/docs/governance/common-ArchitectureScorecard.html","d":"MMCA.Common: Architecture Scorecard","k":"Architecture Governance","x":"Canonical, version-controlled scorecard for this repo: the single source of truth for MMCA.Common's architecture scores (replaces the former single-axis snapshot; see git…","i":"ResilienceCircuitBreakerFaultInjectionTests SpecificationsDoNotNavigateToOtherEntities CrossSourceSpecification.BuildAsync BaseIntegrationEvent.SchemaVersion AggregateRootEntityControllerBase ArchitectureEvaluationCriteria.md DomainInvariantViolationException LocalizedTextConventionTestsBase MMCA.Common.Testing.Architecture OpenIdConnectMetadataWarmupTask ResourceTranslationsAreComplete EventVersioningConventionTests"},{"u":"/docs/governance/common-ArchitectureScorecard.html#scorecard","d":"MMCA.Common: Architecture Scorecard","k":"Architecture Governance","t":"Scorecard","x":"Weighted column = Maturity·weight / Implementation·weight per row. Axis-gap findings: §17/§8 are mature-but-execution-deferred (mechanism shipped, deeper proof lives downstream);…","i":"SpecificationsDoNotNavigateToOtherEntities CrossSourceSpecification.BuildAsync BaseIntegrationEvent.SchemaVersion TransportDoesNotLeakIntoCoreLayers ArchitectureEvaluationCriteria.md CrossDataSourceDegradeConvention MMCA.Common.Testing.Architecture OpenIdConnectMetadataWarmupTask SoftDeleteUniqueIndexConvention EventVersioningConventionTests ListPageQueryStateServiceTests PermissionAuthorizationHandler"},{"u":"/docs/governance/common-ArchitectureScorecard.html#indices","d":"MMCA.Common: Architecture Scorecard","k":"Architecture Governance","t":"Indices","x":"- Maturity index = Σ(maturity×weight) ÷ Σ(weight×4) = 316 ÷ 324 = 97.5% (up from 96.9% (314/324) on the targeted 2026-08-22 update: §9 API & Contract Design Maturity 3→4 on…","i":"ServiceContract"},{"u":"/docs/governance/common-ArchitectureScorecard.html#top-5-strengths","d":"MMCA.Common: Architecture Scorecard","k":"Architecture Governance","t":"Top 5 strengths","x":"1. Dual-enforced Clean Architecture dependency rule (compile-time + fitness functions), §3 (impl 9): Source/Build/MMCA.Common.LayerEnforcement.targets:1-90 fails the build on…","i":"BaseIntegrationEvent.SchemaVersion MMCA.Common.Testing.Architecture EventVersioningConventionTests ResolveProjectReferences packages.lock.json FixedTimeEquals Result.Failure BeforeTargets Theory Fact"},{"u":"/docs/governance/common-ArchitectureScorecard.html#top-5-risks","d":"MMCA.Common: Architecture Scorecard","k":"Architecture Governance","t":"Top 5 risks","x":"Note (twenty-first wave, v1.121.0): earlier waves closed risks previously listed here (§29's restore drill, the §27 i18n train, §24 forms enforcement, §22's firefox gate, §23's…","i":"PiiErasureContractFitnessTests ServiceContractPurityTestsBase OutboxPollFilterProcessor NavigationContractTests required_status_checks PiiConventionTests CONTRIBUTING.md ServiceContract DEPLOYMENT.md IAnonymizable PiiRedactor COST.md"},{"u":"/docs/governance/common-ArchitectureScorecard.html#cross-repo-comparison","d":"MMCA.Common: Architecture Scorecard","k":"Architecture Governance","t":"Cross-repo comparison","x":"How this repo's quality relates to the other consumers (where the framework's quality propagates, where a repo diverges or under-uses it, and where a consumer does better than…"},{"u":"/docs/governance/common-RemediationBacklog.html","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","x":"Derived from ArchitectureScorecard.md (canonical two-axis scoring: Maturity 97.5% / Implementation 84.8%, framework v1.160.0. Twenty-eighth-wave full re-score, 2026-08-23 (git…","i":"ServiceContractPurityTestsBase ArchitectureScorecard.md required_status_checks RedisDistributedLock IDistributedLock BenchmarkDotNet IsDirtyAccessor ServiceContract Performance baseline c911480 d12cc4d"},{"u":"/docs/governance/common-RemediationBacklog.html#implementation-band-implementation--8-ranked-by-implpriority","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Implementation band (implementation <= 8, ranked by implPriority)","x":"Added 2026-07-28 when the ledger gained its second ranked axis. Until then implementation gaps were never ranked or scheduled, which is why consecutive steady-state cycles moved…","i":"ArchitecturalAnalysis.md"},{"u":"/docs/governance/common-RemediationBacklog.html#progress-first-wave-2026-06-08","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress: first wave (2026-06-08)","x":"Implemented in MMCA.Common, ✅ verified 2026-06-09: dotnet build -c Release is clean (0 warnings / 0 errors, all analyzers) and all 9 test projects pass (~1,611 tests, 0…","i":"MessageBusSettings.RetryLimit ConfigureBrokerTransport Directory.Packages.props IntegrationEventConsumer RetryMaxIntervalSeconds RetryMinIntervalSeconds DependencyVersionTests OutboxCleanupService UseMessageRetry MobileCardList BunitTestBase IAnonymizable"},{"u":"/docs/governance/common-RemediationBacklog.html#progress-second-wave-2026-06-09","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress: second wave (2026-06-09)","x":"✅ Verified: dotnet build -c Release clean (0/0) and all 9 test projects pass (1,511 tests, 0 failures). - ✅ 32 / 16: supply-chain. NuGet lock files (RestorePackagesWithLockFile,…","i":"RestorePackagesWithLockFile ServiceContractAttribute nuget.config CqrsMetrics WithMetrics AddMeter package Release dotnet snupkg build list"},{"u":"/docs/governance/common-RemediationBacklog.html#progress-third-wave-front-end-2026-06-09","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress: third wave (front-end, 2026-06-09)","x":"✅ Verified: build clean (0/0) and all 9 test projects pass (1,519 tests, 0 failures); UI tests 90 → 98 (8 new bUnit tests). - ✅ 19: UnsavedChangesGuard live-accessor. Added…","i":"Page.AssertNoAccessibilityViolationsAsync Deque.AxeCore.Playwright MobileInfiniteScrollList UnsavedChangesGuard MaxRenderedItems IsDirtyAccessor CurrentIsDirty PageLoading PageHeader Virtualize MMCATheme PageError"},{"u":"/docs/governance/common-RemediationBacklog.html#progress-fourth-wave-breaking-changes--consumer-sweep-2026-06-09","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress: fourth wave (breaking changes + consumer sweep, 2026-06-09)","x":"✅ Verified across all three repos (built/tested via local.props against Common source, no token): Common 1,523, ADC 1,241, Store 1,088 tests, 0 failures; all CI solutions build…","i":"AggregateConventionTests IntegrationEventConsumer UserNotification.Create EntityConventionTests OutboxCleanupService AddInboxMessages UserNotification BaseDomainEvent NoOpInboxStore InboxMessages EfInboxStore IDomainEvent"},{"u":"/docs/governance/common-RemediationBacklog.html#progress-v1800-2026-06-26","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress: v1.80.0 (2026-06-26)","x":"The single-axis backlog above is from the 2026-06-08/09 review (index 80%). The framework has since reached v1.82.0 and the canonical scoring was the in-repo, two-axis…","i":"PermissionAuthorizationHandler BaseDomainEvent.DateOccurred UserNotification.MarkAsRead PermissionRegistryBuilder AddAuthorizationPolicies ArchitectureScorecard.md GlobalRateLimitPartition PermissionPolicyProvider RateLimitPartitionTests RoleNames.ContentEditor UserNotification.ReadOn IPermissionRegistry"},{"u":"/docs/governance/common-RemediationBacklog.html#progress-v1810v1820--governance-pass-2026-06-26","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress: v1.81.0/v1.82.0 + governance pass (2026-06-26)","x":"Released since v1.80.0 (v1.81.0, v1.82.0) plus a sixth governance pass currently in flight (uncommitted). All of it lands in categories already scored 9-10, so the two-axis…","i":"ArchitectureEvaluationCriteria.md MMCA.Common.Aspire.Security SecurityHeadersMiddleware AddCommonSecurityHeaders ICspPolicyProvider MapCommonScalarUi Scalar.AspNetCore ValidAlgorithms RsaSha256 FACTS.md b9a6a28 COST.md"},{"u":"/docs/governance/common-RemediationBacklog.html#progress-v1830v1840-2026-06-27","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress: v1.83.0/v1.84.0 (2026-06-27)","x":"Released since v1.82.0 (v1.83.0, v1.84.0) plus a docs-only governance pass currently in flight (uncommitted). One score moved at this wave: §30 Implementation 7→8. The canonical…","i":"OpenIdConnectMetadataWarmupTask INotificationRecipientProvider ArchitectureScorecard.md IPushNotificationSender WarmupHostedService WarmupReadinessGate AddServiceDefaults PiiConventionTests PiiRedactorTests UserNotification IWarmupTask PiiRedactor"},{"u":"/docs/governance/common-RemediationBacklog.html#progress-v1850-eighth-wave-under-8-implementation-remediation-2026-06-27","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress, v1.85.0 (eighth wave: under-8 Implementation remediation, 2026-06-27)","x":"The under-8 Implementation remediation (commit 78e5312, tag v1.85.0, HEAD 7082a5f) lifted every category scored Implementation one maturity score. Re-verified against current…","i":"MMCA.Common.Testing.Architecture ArchitectureRules.Slices.cs PasswordComplexityAttribute ArchitectureScorecard.md AuthModelValidationTests DataAnnotationsValidator ServiceContractAttribute TraceIdRatioBasedSampler SliceCohesionTestsBase ParentBasedSampler SliceCohesionTests NavigationFlow.md"},{"u":"/docs/governance/common-RemediationBacklog.html#progress-v1860v1920-ninth-wave-i18n--re-score-2026-06-29","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress, v1.86.0→v1.92.0 (ninth wave: i18n + re-score, 2026-06-29)","x":"Re-scored against current source at framework v1.92.0 (HEAD 93ffcac, dirty tree). Canonical scoring is now Maturity 91.7% / Implementation 84.1% (was 92.8% / 85.0%) per the…","i":"PiiErasureContractFitnessTests WebApplicationExtensions.cs ArchitectureScorecard.md ConfigureBrokerTransport IntegrationEventConsumer User.PreferredCulture UseDelayedRedelivery cfg.UseMessageRetry PiiConventionTests DataSubjectSample PasswordHasher.cs IStringLocalizer"},{"u":"/docs/governance/common-RemediationBacklog.html#progress-tenth-wave-focused-in-repo-remediation-2026-06-30","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress: tenth wave (focused in-repo remediation, 2026-06-30)","x":"Four scores moved up on shipped, tested in-repo evidence; both indices rose for the first time in several waves: Maturity 91.7% → 92.9% (301/324), Implementation 84.1% → 84.9%…","i":"MMCA.Common.Testing.Architecture PaletteDark.PrimaryContrastText ResourceTranslationsAreComplete DatabaseRestoreDrillTests LocalizationResourceTests Directory.Packages.props PrimitivesSnapshotTests SupportedCultures.All PaletteDark.Primary WarningContrastText ErrorContrastText ACCESSIBILITY.md"},{"u":"/docs/governance/common-RemediationBacklog.html#progress-eleventh-wave-adr-governance-2026-06-30","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress: eleventh wave (ADR governance, 2026-06-30)","x":"No score moves. A full 34-category evidence re-score at framework v1.93.0 (HEAD 3e72bfa, dirty tree) re-confirmed every category at its tenth-wave value; indices hold at Maturity…","i":"AggregateRootEntityControllerBase EntityControllerBase OwnerOrAdminFilter OwnershipHelper Specification customer_id FACTS.md"},{"u":"/docs/governance/common-RemediationBacklog.html#progress-twelfth-wave-under-8-implementation-lift-v1940-pending-2026-06-30","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress: twelfth wave (under-8 Implementation lift, v1.94.0 pending, 2026-06-30)","x":"Two Implementation scores move up, Maturity holds: Implementation 84.9% → 85.3% (691/810), Maturity 92.9% (301/324) unchanged. Full Release build clean, 1685 tests pass. Held for…","i":"LocalizedTextConventionTestsBase ListPageQueryStateServiceTests SupportedCultures.PseudoLocale LocalizedTextConventionTests PseudoStringLocalizerFactory UseCommonRequestLocalization PseudoLocalizationE2ETests ListPageStateServiceTests LocalizationResourceTests PseudoLocalizer.Transform IStringLocalizerFactory PseudoLocalizationTests"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---fourteenth-wave-clean-tree-evidence-re-score-at-v11010-2026-07-03","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - fourteenth wave (clean-tree evidence re-score at v1.101.0, 2026-07-03)","x":"A full 34-category, two-pass evidence re-score (per-category scorer plus adversarial verifier) at framework v1.101.0 (HEAD 5e55be2, working tree clean: the recurring…","i":"ArchitectureScorecard.md FormsConventionTestsBase RegisterFormTests.cs Testing.Architecture Scalar.AspNetCore ValidationMessage FACTS.md slnx"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---defect-fix-wave-c-1c-7-2026-07-05","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - defect-fix wave C-1..C-7 (2026-07-05)","x":"Seven approved defect fixes, each behavior change landed with its pinning test flipped (or a new regression test) in the same change; build 0/0 and the full .slnx suite green.…","i":"Microsoft.Extensions.TimeProvider.Testing EntityServiceBase.GetAllForLookupAsync SessionCookieAuthenticationHandler OAuthControllerBase.CompleteAsync AuthenticatedServiceBase ChildEntityServiceBase LoginProtectionService LoggingQueryDecorator ITokenStorageService KeyNotFoundException OutboxCleanupService Uri.EscapeDataString"},{"u":"/docs/governance/common-RemediationBacklog.html#progress-sixteenth-wave-clean-tree-re-score-at-v11060-2026-07-06","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress: sixteenth wave (clean-tree re-score at v1.106.0, 2026-07-06)","x":"A full 34-category, two-pass evidence re-score (per-category scorer plus adversarial verifier) at framework v1.106.0 (HEAD 6f8b917, one commit past the v1.106.0 tag, working tree…","i":"ArchitecturalAnalysis.md ArchitectureScorecard.md Directory.Packages.props EncryptedStringConverter SECURITY.md FACTS.md b75fa8f Theory Fact"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---seventeenth-wave-evidence-re-score-at-v11080-2026-07-09","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - seventeenth wave (evidence re-score at v1.108.0, 2026-07-09)","x":"A full 34-category, two-pass evidence re-score (per-category scorer plus adversarial verifier) at framework v1.108.0 (git HEAD 6c3b3bc, working tree clean, one commit ahead of…","i":"ILiveChannelPublisher ACCESSIBILITY.md FACTS.md ci.yml"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---eighteenth-wave-runtime-performance-wave-2026-07-10","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - eighteenth wave (runtime performance wave, 2026-07-10)","x":"A cross-repo runtime-performance audit (4 parallel auditors: framework, ADC, Store, hosting/config) found the framework strong on read-path fundamentals (no-tracking, SQL…","i":"PublicEndpointOutputCachePolicy EFReadRepository.ApplyIncludes PooledConnectionLifetime HttpResilienceDefaults CachingQueryDecorator LocalView.FindEntry ExecuteUpdateAsync InProcessEventBus AllowAnonymous DetectChanges ExpandoObject CHANGELOG.md"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---remediation-wave-1-cross-repo-wave-plan-2026-07-11","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - remediation wave 1 (cross-repo wave plan, 2026-07-11)","x":"First wave of the 2026-07-11 cross-repo remediation plan (workspace plan file). Ships the shared §18/§19 fitness bases the ADC/Store maturity lifts need, closes the tenth-wave 20…","i":"StateManagementConventionTestsBase UIArchitectureConventionTestsBase ErrorMessages._localizer MobileInfiniteScrollList AllowedStaticMembers PrimaryContrastText WebVitalsCollector ErrorContrastText WebVitalsE2ETests DarkModeE2ETests NotificationBell CHANGELOG.md"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---eighteenth-wave-evidence-re-score-at-v11150-2026-07-12","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - eighteenth wave (evidence re-score at v1.115.0, 2026-07-12)","x":"A full 34-category, two-pass evidence re-score (per-category scorer plus adversarial verifier) at framework v1.115.0 (HEAD 37d0a3b, working tree clean, at the release tag). Three…","i":"ArchitectureScorecard.md MMCA.Common.UI.Maui PrimaryContrastText ErrorContrastText WebVitalsE2ETests DarkModeE2ETests MudDataGrid FACTS.md rgba"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---twentieth-wave-evidence-re-score-at-v11170-2026-07-17","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - twentieth wave (evidence re-score at v1.117.0, 2026-07-17)","x":"A full 34-category, two-pass evidence re-score (per-category scorer plus adversarial verifier) at framework v1.117.0 (HEAD 76d70cf, working tree clean). Four scores move.…","i":"ArchitectureScorecard.md NavigationContractTests required_status_checks AuthorizeAttribute NavigationFlow.md MMCA.Common.UI RouteAttribute RESPONSIVE.md FACTS.md bicep build Short"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---twenty-first-wave-evidence-re-score-at-v11210-2026-07-21","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - twenty-first wave (evidence re-score at v1.121.0, 2026-07-21)","x":"A full 34-category, two-pass evidence re-score (per-category scorer plus adversarial verifier) at framework v1.121.0 (HEAD 4a4fc05, working tree clean). One score moves.…","i":"ArchitectureScorecard.md required_status_checks BenchmarkDotNet CONTRIBUTING.md Notifications Performance baseline FACTS.md COST.md verify Short gate"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---twenty-second-wave-evidence-re-score-at-v11230-2026-07-23","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - twenty-second wave (evidence re-score at v1.123.0, 2026-07-23)","x":"A full 34-category, two-pass evidence re-score (per-category scorer plus adversarial verifier) at framework v1.123.0 (HEAD c911480, working tree clean). No score moves. Canonical…","i":"UnsavedChangesGuard.IsDirtyAccessor PiiErasureContractFitnessTests PasswordComplexityAttribute IIntegrationEventPublisher ArchitectureScorecard.md OpenApiContractTestsBase IConnectionMultiplexer EntityQueryPipeline IEventBus EditForm FACTS.md c911480"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---twenty-third-wave-evidence-re-score-at-v11280-2026-07-25","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - twenty-third wave (evidence re-score at v1.128.0, 2026-07-25)","x":"A full 34-category, two-pass evidence re-score (per-category scorer plus adversarial verifier) at framework v1.128.0 (HEAD 3dff29b, working tree clean). No score moves, the third…","i":"ArchitectureScorecard.md WebVitalsE2ETests ICommandHandler IQueryHandler pull_request permissions Unreleased FACTS.md TResult ci.yml github Result"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---twenty-fourth-wave-evidence-re-score-at-v11310-2026-07-28","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - twenty-fourth wave (evidence re-score at v1.131.0, 2026-07-28)","x":"A full 34-category, two-pass evidence re-score (per-category scorer plus adversarial verifier) at framework v1.131.0 (HEAD 2c52aa9, working tree clean). No score moves, the…","i":"ArchitectureScorecard.md OpenApiContractTestsBase AddCommonApiVersioning MMCA.Common.UI.Maui ICommandHandler ServiceContract AllowAnonymous AllowAnyOrigin IQueryHandler FACTS.md TResult CS1591"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---twenty-fifth-wave-evidence-re-score-at-v11350-2026-08-01","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - twenty-fifth wave (evidence re-score at v1.135.0, 2026-08-01)","x":"A full 34-category, two-pass evidence re-score (per-category scorer plus adversarial verifier) at framework v1.135.0 (HEAD f292233, working tree clean). One score moves, ending…","i":"EntityQueryService.GetAllForLookupAsync DomainInvariantViolationException ArchitectureScorecard.md InProcessDistributedLock HttpResilienceDefaults IConnectionMultiplexer RedisDistributedLock NuGetAuditSuppress IdempotencyFilter IDistributedLock v1.128.0..HEAD AddCaching"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---twenty-sixth-wave-evidence-re-score-at-v11420-2026-08-07","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - twenty-sixth wave (evidence re-score at v1.142.0, 2026-08-07)","x":"Full 34-category two-pass re-score at HEAD 710d29d (clean tree). No scores move: 27 categories re-confirmed fresh, and seven first-pass lift proposals were refuted on the…","i":"GetAllForLookupAsync packages.lock.json AddMeter FACTS.md orderBy OrderBy secrets l.Name navbar NoWarn where"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---security-invariants-wave-11-hardening-2026-08-22","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - security invariants wave (§11 hardening, 2026-08-22)","x":"Closes the two §11 gaps surfaced by the Article 16 (JWKS dual-fetch) review: the insecure dev defaults that no two-axis entry named as scheduled work, and the absent security…","i":"AnonymousEndpointTestsBase AddForwardedJwtBearer requireHttpsMetadata RequireHttpsMetadata RsaJwksProvider AllowAnonymous configuration environment authority audience string false"},{"u":"/docs/governance/common-RemediationBacklog.html#progress---9-contract-surface-gates-2026-08-22","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Progress - §9 contract-surface gates (2026-08-22)","x":"Closes both halves of 9, the last weight-2 Maturity-3 item that had a named in-repo lever. Landed via MMCA.Common PR 271 (squash 8a6c603, merged 2026-08-22). - ✅ OpenAPI…","i":"ServiceContractsDoNotDependOnServiceInternals OpenApiBaselineTests AddCommonOpenApi MapCommonOpenApi ServiceContract ProblemDetails FACTS.md"},{"u":"/docs/governance/common-RemediationBacklog.html#deferred---2026-07-19-full-review-recorded-not-scheduled","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Deferred - 2026-07-19 full review (recorded, not scheduled)","x":"The 2026-07-19 full framework review shipped its accepted fixes on the review branch (rollback on business failure + post-commit dispatch, outbox leases + dead-letter visibility,…","i":"MMCA.Common.Infrastructure MMCA.Common.UI.Tests MMCA.Common.UI.Maui IServiceCollection IMessageBus LangVersion extension IsDeleted IsFailure preview TResult CS1591"},{"u":"/docs/governance/common-RemediationBacklog.html#recorded---2026-07-31-consumer-discovered-defect-not-scheduled","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"Recorded - 2026-07-31 consumer-discovered defect (not scheduled)","x":"Found downstream while implementing MMCA.ADC BR-239 (public speaker visibility), which needed a filtered lookup read. Recorded rather than fixed in place: the consumer already…","i":"EntityQueryService.GetAllForLookupAsync MMCA.Common.Shared.ValueObjects.Email IRepository.GetAllForLookupAsync QueryFieldService.Validate InvalidOperationException GetOrBuildLookupSelector BaseLookup.Name nameProperty asTracking ToString orderBy OrderBy"},{"u":"/docs/governance/common-RemediationBacklog.html#priority-6-highest-leverage","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"🔴 Priority 6: highest leverage","x":"The package ships reusable Blazor primitives with no fast test tier. - ~~(medium) No component tests for the UI library~~ RESOLVED: Tests/Presentation/MMCA.Common.UI.Tests…","i":"Page.AssertNoAccessibilityViolationsAsync PiiErasureContractFitnessTests AuditableBaseEntity.Delete Deque.AxeCore.Playwright EncryptedStringConverter MobileInfiniteScrollList MMCA.Common.Testing.E2E MMCA.Common.Testing.UI MMCA.Common.UI.Tests OutboxCleanupService UnsavedChangesGuard DeleteConfirmation"},{"u":"/docs/governance/common-RemediationBacklog.html#priority-3-score-3-weight-3-one-rung-from-a-4","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"🟠 Priority 3: score 3, weight 3 (one rung from a 4)","x":"- ~~(medium) No broker retry policy on the extracted-microservice path~~ RESOLVED (re-verified 2026-06-29): ConfigureBrokerTransport applies cfg.UseMessageRetry (exponential) on…","i":"DomainAggregateRootsHaveNoPublicConstructors ResilienceCircuitBreakerFaultInjectionTests Add_DifferentCurrencies_ReturnsFailure HandleBeforeInternalNavigationAsync MobileInfiniteScrollListTests.cs AggregateRootsHaveResultFactory MessageBusSettings.RetryLimit AggregateConventionTestsBase DomainExposesAggregateRoots DomainFactoriesReturnResult RestorePackagesWithLockFile UnsavedChangesGuardTests.cs"},{"u":"/docs/governance/common-RemediationBacklog.html#priority-2-score-3-weight-2-polish--hardening","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"🟡 Priority 2: score 3, weight 2 (polish / hardening)","x":"- (medium) No consumer-side idempotency/inbox for at-least-once broker delivery: duplicate side effects possible in any non-idempotent consumer. (low) ~~Same misleading…","i":"ServiceContractsDoNotDependOnServiceInternals EntityQueryPipeline.MaxUnboundedResultLimit ApplicationSettings.MaxPageSize MessageBusSettings.EnableInbox ServiceContractPurityTestsBase ArchitectureRules.Slices.cs MobileInfiniteScrollList OpenApiContractTestsBase ServiceContractAttribute Directory.Build.targets AddCommonApiVersioning required_status_checks"},{"u":"/docs/governance/common-RemediationBacklog.html#already-at-level-4-protect-dont-regress","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"✅ Already at level 4: protect, don't regress","x":"1 SOLID · 2 Design Patterns · 3 Clean Architecture · 5 Vertical Slice (maturity 3→4 on the slice-cohesion fitness function) · 7 Microservices Readiness · 8 Data Architecture · 10…","i":"MessageBusSettings.EnableInbox NavigationContractTests IConnectionMultiplexer required_status_checks WebVitalsE2ETests IDistributedLock BenchmarkDotNet ServiceContract EditorRequired Performance baseline navbar"},{"u":"/docs/governance/common-RemediationBacklog.html#deliberate--accepted-documented-caps-not-scheduled-work","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"🔒 Deliberate / accepted (documented caps, not scheduled work)","x":"Moved out of the active priority queue on 2026-07-02 (user-approved). Its computed priority = (4 − 2) × 2 = 4 is the highest weighted gap of any open category, but the unmet §31…","i":"NavigationFlow.md ACCESSIBILITY.md CONTRIBUTING.md NUGET_API_KEY RESILIENCE.md RESPONSIVE.md CHANGELOG.md release.yml SECURITY.md main.bicep CLAUDE.md README.md"},{"u":"/docs/governance/common-RemediationBacklog.html#mostly-consumer-assessed-the-shared-commonui-surface-is-scored-here","d":"MMCA.Common: Architecture Remediation Backlog","k":"Architecture Governance","t":"⚪ Mostly consumer-assessed (the shared Common.UI surface is scored here)","x":"21 Accessibility · 26 Front-End Security (Assessable mainly in consumer apps; 26 shared surface is covered under 11.) - 22 Responsive: CLOSED at Maturity 4 / Implementation 9…","i":"LocalizedTextConventionTests PseudoLocalizationE2ETests AuthModelValidationTests NavigationContractTests PasswordComplexity NavigationFlow.md RegisterFormTests ValidationMessage ResxMudLocalizer Forbidden EditForm slnx"},{"u":"/docs/governance/store-ArchitectureScorecard.html","d":"MMCA.Store: Architecture Scorecard","k":"Architecture Governance","x":"Canonical, version-controlled scorecard for this repo: the single source of truth for MMCA.Store's architecture scores. This is Store's first in-repo governance artifact…","i":"CK_InventoryItem_AvailableQuantity_NonNegative ArchitectureEvaluationCriteria.md ConstructorDependencyCountTests StateManagementConventionTests UIArchitectureConventionTests MMCA.Store.Identity.UI.Tests ObservabilityConventionTests MobileInfiniteScrollList PseudoLocalizationTests GracefulShutdownTests Money.ToDisplayString RemediationBacklog.md"},{"u":"/docs/governance/store-ArchitectureScorecard.html#executive-summary","d":"MMCA.Store: Architecture Scorecard","k":"Architecture Governance","t":"Executive summary","x":"MMCA.Store is a .NET 10.0 (LangVersion preview) DDD/Clean Architecture e-commerce system (Catalog, Sales, Identity modules; Stripe checkout) extracted into independently-hosted…","i":"MMCA.Common.Testing.Architecture IntegrationEventContractTests LocalizedTextConventionTests TreatWarningsAsErrors DataResidencyTests dbo.OutboxMessages PiiConventionTests Store_Identity Store_Catalog Store_Sales MMCAStore"},{"u":"/docs/governance/store-ArchitectureScorecard.html#scorecard","d":"MMCA.Store: Architecture Scorecard","k":"Architecture Governance","t":"Scorecard","x":"Weighted = Maturity·weight / Implementation·weight. Axis-gap finding: §21 Accessibility is honestly M3/I8 (the chromium axe gate earns Implementation 8; Maturity caps at 3…","i":"CK_InventoryItem_AvailableQuantity_NonNegative MMCA.Store.CrossService.IntegrationTests FrameworkVersionConsistencyTests IntegrationEventContractTests.cs ConstructorDependencyCountTests StateManagementConventionTests SQLitePCLRaw.bundle_e_sqlite3 UIArchitectureConventionTests CultureInfo.InvariantCulture LocalizedTextConventionTests ObservabilityConventionTests TranslationCompletenessTests"},{"u":"/docs/governance/store-ArchitectureScorecard.html#indices","d":"MMCA.Store: Architecture Scorecard","k":"Architecture Governance","t":"Indices","x":"- Maturity index = Σ(maturity×weight) ÷ Σ(weight×4) = 313 ÷ 320 = 97.8% (re-confirmed with no moves on the 2026-08-23 re-score; §12, §21, and §22 are the three categories at…"},{"u":"/docs/governance/store-ArchitectureScorecard.html#top-5-strengths","d":"MMCA.Store: Architecture Scorecard","k":"Architecture Governance","t":"Top 5 strengths","x":"1. Clean Architecture + DDD + CQRS depth, fitness-enforced: §3/§4 (impl 9): inherited framework substance, guarded by the shared NetArchTest suite (23 test classes,…","i":"GracefulShutdownTests IAnonymizable"},{"u":"/docs/governance/store-ArchitectureScorecard.html#top-5-risks","d":"MMCA.Store: Architecture Scorecard","k":"Architecture Governance","t":"Top 5 risks","x":"1. Accessibility maturity is capped pending a human pass: §21 (mat 3, weight 3): the 23-scan axe suite gates the deploy (impl 8), but the rubric pairs axe-in-CI with a recorded…","i":"BrandColorTokenTests FormsConventionTests workflow_dispatch deploy.needs a1de5a89 MudForm"},{"u":"/docs/governance/store-ArchitectureScorecard.html#cross-repo-comparison","d":"MMCA.Store: Architecture Scorecard","k":"Architecture Governance","t":"Cross-repo comparison","x":"How Store relates to MMCA.Common (the framework) and MMCA.ADC (the sibling consumer) is maintained once, for all three repos, in the workspace-internal…"},{"u":"/docs/governance/store-RemediationBacklog.html","d":"MMCA.Store: Architecture Remediation Backlog","k":"Architecture Governance","x":"Derived from ArchitectureScorecard.md (two-axis: Maturity 97.8% / Implementation 83.9%, full re-score 2026-07-28, re-confirmed with no score moves on the 2026-08-14 and…","i":"ArchitectureScorecard.md"},{"u":"/docs/governance/store-RemediationBacklog.html#priority-a11y--e2e-merge-gate-21-28-22","d":"MMCA.Store: Architecture Remediation Backlog","k":"Architecture Governance","t":"🔴 Priority: a11y / E2E merge gate (#21, #28, #22)","x":"The former single biggest maturity lever: 28 cleared 2026-07-03; 22 cleared on the 2026-07-17 re-score (the gate flip verified live) and reopened on the 2026-07-28 re-score when…","i":"needs.changes.outputs.ui github.event_name matrix.browser workflow_call deploy.needs deploy.yml browsers d057afc e2e.yml skipped success always"},{"u":"/docs/governance/store-RemediationBacklog.html#priority-execution-quality-gaps-impl-not-maturity","d":"MMCA.Store: Architecture Remediation Backlog","k":"Architecture Governance","t":"🟠 Priority: execution-quality gaps (impl, not maturity)","x":"Ranked 2026-07-28 when the ledger gained its second ranked axis. Until then the items in this section were closed history plus two open levers, with no ranking and no inclusion…","i":"MMCA.Store.CrossService.IntegrationTests IdentityModuleDbSeederBase.ShouldSeed SqlServerIntegrationTestFixtureBase CultureInfo.InvariantCulture MobileInfiniteScrollList ProductVariantChanged NotifyStateChanged workflow_dispatch EmailExistsAsync CatalogBrowse GetPagedAsync InventoryItem"},{"u":"/docs/governance/store-RemediationBacklog.html#priority-minor--accept-or-polish","d":"MMCA.Store: Architecture Remediation Backlog","k":"Architecture Governance","t":"🟡 Priority: minor / accept-or-polish","x":"- [x] 32 Dependency & Supply-Chain, impl 7 → 8. DONE (2026-07-03, drift plan D8 + D9). Vulnerability gate is NuGetAudit + TreatWarningsAsErrors at restore, which fails…","i":"ServiceInfoController TreatWarningsAsErrors BrandColorTokenTests FormsConventionTests CustomerEmailRules NuGetAuditSuppress Store_Identity Store_Catalog Store_Sales ApiVersion Deprecated MMCAStore"},{"u":"/docs/governance/store-RemediationBacklog.html#defect-fix-wave-2026-07-05","d":"MMCA.Store: Architecture Remediation Backlog","k":"Architecture Governance","t":"🐞 Defect-fix wave (2026-07-05)","x":"Four reviewed product defects fixed in one wave; every behavior change flipped its pinning test in the same change. - [x] S-1 Stripe network errors escaped the Result pattern.…","i":"Payment.Stripe.SessionRetrievalFailed Payment.Stripe.SessionCreationFailed Payment.Stripe.UnsupportedCurrency CartStateService.InitializeAsync ExportUserDataHandler HttpRequestException StripePaymentService CheckoutAndPayAsync DeleteUserHandler UserRole.IsAdmin CheckoutOutcome UserRole.Admin"},{"u":"/docs/governance/store-RemediationBacklog.html#deliberate--accepted-record-the-choice-dont-silently-leave-low","d":"MMCA.Store: Architecture Remediation Backlog","k":"Architecture Governance","t":"Deliberate / accepted (record the choice; don't silently leave low)","x":"- ADR-042 device capability abstraction (latent, drift plan D8). The core extension point is converged (Store wires browser + MAUI capabilities via…","i":"AddBrowserDeviceCapabilities CultureInfo.InvariantCulture LocalizedTextConventionTests TranslationCompletenessTests UseMauiDeviceCapabilities Money.ToDisplayString ProductVariantChanged MMCA.Common.UI.Maui SliceCohesionTests DeepLinkListener ResxMudLocalizer DeviceUIModule"},{"u":"/docs/governance/store-RemediationBacklog.html#below-maturity-4-tracking-inclusion-policy-categories-scoring--4-maturity","d":"MMCA.Store: Architecture Remediation Backlog","k":"Architecture Governance","t":"🟠 Below-maturity-4 tracking (inclusion policy: categories scoring < 4 maturity)","x":"These categories score maturity 3; the ledger records them per its own line-4 inclusion policy (mirrors ADC's equivalent entries). - [x] 19 · State Management & Data Flow ·…","i":"StateManagementConventionTestsBase UIArchitectureConventionTestsBase StateManagementConventionTests UIArchitectureConventionTests ProductDetail.razor.cs OrderDetail.razor.cs ProductVariantsPanel StoreArchitectureMap OrderSummaryPanel workflow_dispatch OrderLinesPanel OPERATIONS.md"},{"u":"/docs/governance/store-RemediationBacklog.html#resolved-this-cycle-2026-07-28-drift-wave-d1d2d5d6d7--e2e4e7e8","d":"MMCA.Store: Architecture Remediation Backlog","k":"Architecture Governance","t":"🟢 Resolved this cycle (2026-07-28, drift wave: D1/D2/D5/D6/D7 + E2/E4/E7/E8)","x":"- [x] 29 Resilience: the DR drill was restoring a RETIRED database. The weekly dr-drill.yml had no rotation and fell through to its input default MMCAStore, the legacy archive no…","i":"AuthControllerBase.LoginAsync HandlerResultConventionTests PaymentReconciliationService DecoratorPipelineOrderTests PeriodicBackgroundService AddCommonRateLimiting skip_freshness_gates alertEmailAddress authIpPermitLimit Store_Identity RegisterAsync Store_Catalog"},{"u":"/docs/governance/store-RemediationBacklog.html#resolved-this-cycle-2026-07-25-performance-program-2","d":"MMCA.Store: Architecture Remediation Backlog","k":"Architecture Governance","t":"🟢 Resolved this cycle (2026-07-25, performance program 2)","x":"Second evidence-led performance pass over Common/ADC/Store. Store's share shipped as two PRs plus the v1.127.0 framework sweep. - [x] Output cache shared across replicas. Catalog…","i":"AddStackExchangeRedisOutputCache Filter.Operator.NotSupported GetVariantCartInfoHandler BulkSetInventoryHandler IProductVariantService GetUnitPricesAsync IDistributedCache IntFilterStrategy OrderLines.Count PaymentInitiated EvictByTagAsync ProductVariants"},{"u":"/docs/governance/store-RemediationBacklog.html#resolved-this-cycle-2026-07-11-drift-convergence-drift-plan-d1-d13","d":"MMCA.Store: Architecture Remediation Backlog","k":"Architecture Governance","t":"🟢 Resolved this cycle (2026-07-11 drift-convergence, drift plan D1-D13)","x":"- [x] 29 DR gates (drift plan D3). dr-freshness is now in deploy.needs (fails a deploy when the last successful dr-drill is stale), dr-drill.yml gained a weekly cron, and…","i":"ConstructorDependencyCountTests MMCA.Store.Gateway.Tests GracefulShutdownTests MMCA.Store.CI.slnf Store_Identity Store_Catalog workflow_call deploy.needs TimeProvider Store_Sales Directory db_owner"},{"u":"/docs/governance/store-RemediationBacklog.html#already-at-level-4-protect-dont-regress","d":"MMCA.Store: Architecture Remediation Backlog","k":"Architecture Governance","t":"✅ Already at level 4 (protect, don't regress)","x":"Both axes satisfied (maturity 4 AND implementation = 9), the true protect list: SOLID (1), Design Patterns (2), Clean Architecture (3), DDD (4), Data (8), API (9), Observability…","i":"CK_InventoryItem_AvailableQuantity_NonNegative FormsConventionTests IQueryable"},{"u":"/docs/guides/index.html","d":"Guides & Specifications","k":"Guides & Specifications","x":"The narrative documentation for the MMCA platform: adoption guides, business specifications, workflow analyses, and per-concern reference notes. Files are prefixed by the repo…"},{"u":"/docs/guides/index.html#framework-mmcacommon","d":"Guides & Specifications","k":"Guides & Specifications","t":"Framework (MMCA.Common)","x":"- Getting Started: stand up a new application from the MMCA.Templates scaffold, in six steps. - Build MMCA.ECommerce: the two-module store sample (Products + Orders) built end to…","i":"MMCA.Templates"},{"u":"/docs/guides/index.html#mmcastore-e-commerce","d":"Guides & Specifications","k":"Guides & Specifications","t":"MMCA.Store (e-commerce)","x":"- Business Specification - Business Workflow Analysis - Navigation Flow - Manual Screen-Reader Pass Runbook"},{"u":"/docs/guides/index.html#mmcaadc-conference","d":"Guides & Specifications","k":"Guides & Specifications","t":"MMCA.ADC (conference)","x":"- Business Specifications - Navigation Flow - Manual Screen-Reader Pass Runbook - Integration-Test Tier Rework Plan Related reading: the Architecture Decision Records and the…"},{"u":"/docs/guides/adc-ACCESSIBILITY-SCREENREADER-PASS.html","d":"Manual Screen-Reader Pass: Runbook (§21 Accessibility)","k":"Guides & Specifications","x":"The automated layer (axe-core WCAG 2.1 AA scans in Tests/E2E/MMCA.ADC.E2E.Tests/AccessibilityTests.cs plus the shared Login/Register/Profile bases in MMCA.Common.Testing.E2E)…","i":"MMCA.Common.Testing.E2E RemediationBacklog.md"},{"u":"/docs/guides/adc-ACCESSIBILITY-SCREENREADER-PASS.html#tools","d":"Manual Screen-Reader Pass: Runbook (§21 Accessibility)","k":"Guides & Specifications","t":"Tools","x":"Run against the app started via Aspire (dotnet run --project Source/Hosting/MMCA.ADC.AppHost), reaching the UI through the Gateway. Test with the keyboard only (no mouse) for the…","i":"dotnet start Ctrl Alt Cmd run"},{"u":"/docs/guides/adc-ACCESSIBILITY-SCREENREADER-PASS.html#what-to-verify-on-every-flow","d":"Manual Screen-Reader Pass: Runbook (§21 Accessibility)","k":"Guides & Specifications","t":"What to verify on every flow","x":"1. Landmarks and headings. The SR's landmark/heading list (NVDA Insert+F7) exposes a logical banner / navigation / main structure and a sensible h1...hN outline (one h1 per…","i":"MainLayout.razor navigation h1...hN banner Insert Shift main Tab"},{"u":"/docs/guides/adc-ACCESSIBILITY-SCREENREADER-PASS.html#results-log","d":"Manual Screen-Reader Pass: Runbook (§21 Accessibility)","k":"Guides & Specifications","t":"Results log","x":"Record one row per pass. A flow with any unresolved blocker is a FAIL (open a backlog item for it)."},{"u":"/docs/guides/adc-ACCESSIBILITY-SCREENREADER-PASS.html#after-a-pass","d":"Manual Screen-Reader Pass: Runbook (§21 Accessibility)","k":"Guides & Specifications","t":"After a pass","x":"- File any defect as a remediation item; cross-link it from the §21 row's evidence. - Update the dated row above so the scorecard can cite a real, current manual pass (this is…"},{"u":"/docs/guides/adc-IntegrationTestReworkPlan.html","d":"Integration-Test Tier Rework Plan (RemediationBacklog #14)","k":"Guides & Specifications","x":"Status: complete (Phase 4 broker-transport tier landed 2026-07-06; Phase 5 residual = coverlet only). - Phase 0 ✅: Tests/WebAPI revived as MMCA.Common.API middleware unit tests…","i":"Microsoft.Testing.Extensions.CodeCoverage ISessionBookmarkValidationService IdentityIntegrationTestFixture SpeakerUnlinkedFromUserHandler AnonymousConferenceReadTests SpeakerLinkedToUserHandler MMCA.ADC.Integration.slnf MMCA.ADC.IntegrationTests IIntegrationEventHandler AddForwardedJwtBearer AttendeeBookmarkTests IBookmarkCountService"},{"u":"/docs/guides/adc-IntegrationTestReworkPlan.html#recommended-strategy-two-tiers","d":"Integration-Test Tier Rework Plan (RemediationBacklog #14)","k":"Guides & Specifications","t":"Recommended strategy: two tiers","x":"1. Primary: per-service WebApplicationFactory : one in-process host per service (Identity / Conference / Engagement), cross-service edges mocked. AddBrokerMessaging…","i":"DistributedApplicationTestingBuilder SpeakerUnlinkedFromUser WebApplicationFactory SpeakerLinkedToUser AddBrokerMessaging UserRegistered Program"},{"u":"/docs/guides/adc-IntegrationTestReworkPlan.html#three-code-facts-that-shape-the-rework-verified","d":"Integration-Test Tier Rework Plan (RemediationBacklog #14)","k":"Guides & Specifications","t":"Three code facts that shape the rework (verified)","x":"- Only Conference.Service is WAF-incompatible: it ends with StartAsync() + self-HTTP/2 WarmupViaHttpAsync + WaitForShutdownAsync(). Identity/Engagement/Notification use…","i":"AddCommonAuthentication AddForwardedJwtBearer WebApplicationFactory WaitForShutdownAsync Conference.Service WarmupViaHttpAsync JwtTokenGenerator IssuerSigningKey JwtBearerOptions app.RunAsync StartAsync authority"},{"u":"/docs/guides/adc-IntegrationTestReworkPlan.html#databases","d":"Integration-Test Tier Rework Plan (RemediationBacklog #14)","k":"Guides & Specifications","t":"Databases","x":"- SQLite in-memory for the fast bulk tier (no Docker, CI-friendly; DatabaseInitStrategy=EnsureCreated). - MsSql Testcontainers for a tagged SQL-fidelity subset (soft-delete…","i":"SQLServerDbContext DataSources migrations"},{"u":"/docs/guides/adc-IntegrationTestReworkPlan.html#project-structure","d":"Integration-Test Tier Rework Plan (RemediationBacklog #14)","k":"Guides & Specifications","t":"Project structure","x":"- One WAF test project per service (MMCA.ADC.{Identity,Conference,Engagement}.IntegrationTests): can't reference two Program-bearing hosts in one project. - One…","i":"MMCA.ADC.CrossService.IntegrationTests IntegrationTestBase MMCA.Common.Testing JwtTokenGenerator IntegrationTests ProjectReference MMCA.Common.API WebAPI.Tests Conference Engagement Identity MMCA.ADC"},{"u":"/docs/guides/adc-IntegrationTestReworkPlan.html#ci","d":"Integration-Test Tier Rework Plan (RemediationBacklog #14)","k":"Guides & Specifications","t":"CI","x":"- Add the SQLite per-service tier to CI.slnf (seconds, no Docker) → restores the authz/CRUD merge gate (11) with no workflow change. - Keep the container-based MsSql + RabbitMQ…","i":"CI.slnf"},{"u":"/docs/guides/adc-IntegrationTestReworkPlan.html#phased-sequencing-fastest-win-first","d":"Integration-Test Tier Rework Plan (RemediationBacklog #14)","k":"Guides & Specifications","t":"Phased sequencing (fastest win first)","x":"- Phase 0: re-home WebAPI.Tests middleware unit tests; drop the dead WebAPI reference; re-add to slnx+CI.slnf. ~16 tests green; removes a non-building project (16). - Phase 1:…","i":"ISessionBookmarkValidationService IBookmarkCountService OwnerOrAdminFilter ServiceTestFixture JwtBearerOptions AttendeeClaims OrganizerUser WebAPI.Tests TProgram CI.slnf slnx"},{"u":"/docs/guides/adc-IntegrationTestReworkPlan.html#key-risks","d":"Integration-Test Tier Rework Plan (RemediationBacklog #14)","k":"Guides & Specifications","t":"Key risks","x":"- The non-Identity JwtBearerOptions in-process override is the trickiest piece: prove it on one Conference auth test before fanning out. - SQLite vs SQL-Server fidelity (owned…","i":"JwtBearerOptions"},{"u":"/docs/guides/adc-IntegrationTestReworkPlan.html#critical-files","d":"Integration-Test Tier Rework Plan (RemediationBacklog #14)","k":"Guides & Specifications","t":"Critical files","x":"- Tests/Integration/MMCA.ADC.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs (combined-host factory → split into per-service fixtures; its JWT config block is the…","i":"AddCommonAuthentication AddForwardedJwtBearer JwtTokenGenerator.cs MMCA.ADC.CI.slnf MMCA.ADC.slnx StartAsync partial Program public class"},{"u":"/docs/guides/adc-NavigationFlow.html","d":"Navigation Flow","k":"Guides & Specifications","x":"This document maps the site navigation flow for each actor in the MMCA.ADC application. Each mermaid diagram shows the pages accessible to that actor and the directional…"},{"u":"/docs/guides/adc-NavigationFlow.html#actors","d":"Navigation Flow","k":"Guides & Specifications","t":"Actors","x":"Roles & menu: Organizer is the only elevated role (default is Attendee). A Speaker is an attendee whose account is linked to a Speaker, surfaced via the speakerid claim. The left…","i":"IUIModule.NavItems speaker_id Organizer Attendee"},{"u":"/docs/guides/adc-NavigationFlow.html#1-anonymous-user","d":"Navigation Flow","k":"Guides & Specifications","t":"1. Anonymous User","x":"Pages accessible without authentication: home, login, register, the two password-reset pages, and all public conference pages. ---"},{"u":"/docs/guides/adc-NavigationFlow.html#2-attendee-authenticated-user","d":"Navigation Flow","k":"Guides & Specifications","t":"2. Attendee (Authenticated User)","x":"Inherits all anonymous pages. Gains access to profile, feedback submission, and session bookmarking. Unauthenticated visitors are redirected to login. ---"},{"u":"/docs/guides/adc-NavigationFlow.html#3-speaker","d":"Navigation Flow","k":"Guides & Specifications","t":"3. Speaker","x":"Inherits all attendee pages. Gains access to the speaker dashboard for managing their own profile, viewing assigned sessions, and reviewing feedback ratings. ---"},{"u":"/docs/guides/adc-NavigationFlow.html#4-organizer","d":"Navigation Flow","k":"Guides & Specifications","t":"4. Organizer","x":"Authenticated users with the Organizer role. Inherits all attendee and public pages. Adds CRUD management for every conference entity (events, sessions, speakers, categories,…","i":"Organizer"},{"u":"/docs/guides/adc-NavigationFlow.html#5-functionality-flows-attendee--speaker","d":"Navigation Flow","k":"Guides & Specifications","t":"5. Functionality Flows (Attendee & Speaker)","x":"The diagrams in sections 1-4 map which pages each actor can reach. The diagrams below map how attendees and speakers accomplish each functionality, including inline actions…","i":"DeviceUIModule speaker_id route"},{"u":"/docs/guides/adc-NavigationFlow.html#navigation-patterns","d":"Navigation Flow","k":"Guides & Specifications","t":"Navigation Patterns","x":"- Unauthenticated users accessing protected pages are redirected to /login via the RedirectToLogin component. - Successful login/register redirects to Home (/) with a full page…","i":"RegisteredUser_AdminPages_ShouldBeForbidden Engagement.CheckIn IUIModule.NavItems Engagement.Points EventList.razor RedirectToLogin DeviceUIModule UserList.razor Routes.razor speaker_id attribute Authorize"},{"u":"/docs/guides/adc-specifications.html","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","x":"---"},{"u":"/docs/guides/adc-specifications.html#1-system-overview","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","t":"1. System Overview","x":"ADC is a conference management system for the Atlanta Developers Conference. It provides backend services to manage multi-day conference events, sessions, speakers, rooms,…"},{"u":"/docs/guides/adc-specifications.html#2-domain-model","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","t":"2. Domain Model","x":"Relationships: - Owns many Rooms (child entities) - Owns many EventSpeakers (child join entities linking Event ↔ Speaker) - Owns many EventQuestionAnswers (child feedback…","i":"Engagement.LivePolls Engagement.SessionQA User.LinkedSpeakerId Event.StartDate Session.EventId ContentEditor Event.EndDate EventSpeaker QuestionType Waitlisted Nominated Organizer"},{"u":"/docs/guides/adc-specifications.html#3-business-rules","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","t":"3. Business Rules","x":"Reading guide: Some rules reference other rules defined later in the document (e.g., BR-63, BR-80 are defined in Section 10). Forward references use the BR- numbering…","i":"Event.QuestionModerationDefault Event.LastSessionizeRefreshBy Event.LastSessionizeRefreshOn SessionQuestionAnswerChanged SpeakerQuestionAnswerChanged EventQuestionAnswerChanged SessionCategoryItemChanged SpeakerCategoryItemChanged UserSessionBookmarkChanged Session.AccessibilityInfo Session.IsServiceSession SessionFeedbackSubmitted"},{"u":"/docs/guides/adc-specifications.html#4-use-cases--business-processes","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","t":"4. Use Cases / Business Processes","x":"See UC-30 (Registration) and UC-31 (Login) in Section 12.2 for the current email + password authentication flows. UC-34 (Request password reset) and UC-35 (Reset password) in the…","i":"SpeakerQuestionAnswersController Event.LastSessionizeRefreshBy Event.LastSessionizeRefreshOn SpeakerQuestionAnswerChanged Engagement.LivePolls Engagement.SessionQA UserSessionBookmark skippedSoftDeleted IsServiceSession IsPlenumSession AllowAnonymous QuestionEntity"},{"u":"/docs/guides/adc-specifications.html#5-workflows--state-transitions","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","t":"5. Workflows & State Transitions","x":"The Session.Status field is a free-text string imported from Sessionize. Default: null (for manually created sessions). Known Sessionize values: Accepted, Waitlisted, Accept…","i":"Session.Status ContentEditor IsConfirmed IsInformed Waitlisted Nominated Organizer Accepted Declined Decline Accept Queue"},{"u":"/docs/guides/adc-specifications.html#6-events--side-effects","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","t":"6. Events & Side Effects","x":"Domain events are raised for entity mutations. Not all events have registered handlers: events without handlers serve as extension points for future requirements. Note: Only…","i":"SessionQuestionAnswerChanged SpeakerQuestionAnswerChanged EventQuestionAnswerChanged SessionCategoryItemChanged SpeakerCategoryItemChanged UserSessionBookmarkChanged SessionSpeakerChanged User.LinkedSpeakerId CategoryItemChanged EventSpeakerChanged UserPasswordChanged CategoryChanged"},{"u":"/docs/guides/adc-specifications.html#7-business-constraints--invariants","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","t":"7. Business Constraints & Invariants","x":"---","i":"Speaker.LinkedUserId User.LinkedSpeakerId IsServiceSession Session.EventId QuestionEntity ContentEditor EventSpeaker nameProperty Waitlisted CreatedBy FirstName Nominated"},{"u":"/docs/guides/adc-specifications.html#8-external-integrations-business-perspective","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","t":"8. External Integrations (Business Perspective)","x":"---","i":"Speaker.ProfilePicture Event.VenueMapUrl IsServiceSession IsPlenumSession QuestionSource SessionizeCode IsTopSpeaker RecordingUrl LiveUrl POST"},{"u":"/docs/guides/adc-specifications.html#9-glossary","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","t":"9. Glossary","x":"---","i":"Event.IsPublished IsServiceSession IsPlenumSession ContentEditor IsTopSpeaker Organizer User.Role Admin Role true"},{"u":"/docs/guides/adc-specifications.html#ddd-structural-summary","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","t":"DDD Structural Summary","x":"Why three bounded contexts instead of two: The original Events + Identity split grouped all conference-related entities together regardless of write profile. Separating…","i":"SessionQuestionAnswer SpeakerQuestionAnswer EventQuestionAnswer Question.IsRequired Session.EventId QuestionEntity SpeakerChanged ContentEditor QuestionType Room.EventId RoomChanged Organizer"},{"u":"/docs/guides/adc-specifications.html#10-specification-clarifications--addenda","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","t":"10. Specification Clarifications & Addenda","x":"This section addresses gaps, ambiguities, and implicit design decisions identified during implementation review. New business rules are numbered BR-61+. API contract…","i":"SessionQuestionAnswersController SpeakerQuestionAnswersController TimeZoneInfo.ConvertTimeFromUtc EventQuestionAnswersController MMCA.ADC.Modules.Engagement RemoveSpeakerQuestionAnswer UpdateSpeakerQuestionAnswer AddSpeakerQuestionAnswer SessionFeedbackSubmitted EventFeedbackSubmitted CreateQuestionHandler SessionQuestionAnswer"},{"u":"/docs/guides/adc-specifications.html#11-api-contract-specifications","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","t":"11. API Contract Specifications","x":"This section documents API design decisions that apply across all endpoints. --- All error responses use the RFC 9457 ProblemDetails format (the successor to RFC 7807, same…","i":"SessionQuestionAnswer SpeakerQuestionAnswer EventQuestionAnswer PaginationMetadata Session.Duration Speaker.FullName DomainException includeChildren FirstRowOnPage LastModifiedOn QuestionEntity TotalPageCount"},{"u":"/docs/guides/adc-specifications.html#12-authentication--identity-architecture","d":"ADC (Atlanta Developers Conference) - Business Specifications","k":"Guides & Specifications","t":"12. Authentication & Identity Architecture","x":"This section defines the authentication mechanism for both the Web UI (Blazor) and MAUI (mobile) clients, which share a common Razor class library. It replaces the device-based…","i":"CascadingAuthenticationState AuthenticationStateProvider PasswordReset__ResetUrl PasswordResetController Auth.InvalidResetToken MaxValidationAttempts RequestWindowMinutes Speaker.LinkedUserId TokenLifetimeMinutes User.LinkedSpeakerId MaxRequestsPerEmail UserPasswordChanged"},{"u":"/docs/guides/common-ACCESSIBILITY.html","d":"Accessibility (rubric §21)","k":"Guides & Specifications","x":"The shared MMCA.Common.UI surface targets WCAG 2.1 AA. Accessibility is enforced two ways: an automated axe-core gate in CI (the bulk of coverage) and a documented manual…","i":"MMCA.Common.UI"},{"u":"/docs/guides/common-ACCESSIBILITY.html#automated-coverage-axe-core-wcag-21-aa","d":"Accessibility (rubric §21)","k":"Guides & Specifications","t":"Automated coverage (axe-core, WCAG 2.1 AA)","x":"The ui-e2e CI job runs Playwright + axe-core against the backend-less gallery; chromium is the blocking merge gate (firefox/webkit advisory). Scanned states: Component render is…","i":"PrimitivesSnapshotTests RegisterPageE2ETests PrimaryContrastText ErrorContrastText DarkModeE2ETests PageLoadingState MMCA.Common.UI progressbar mmca_theme div"},{"u":"/docs/guides/common-ACCESSIBILITY.html#manual-screen-reader-pass","d":"Accessibility (rubric §21)","k":"Guides & Specifications","t":"Manual screen-reader pass","x":"Automation cannot judge reading order, focus management, or announcement quality, so the shared surface is walked manually. Checklist (re-run on any change to MainLayout, the…","i":"ValidationMessage MainLayout.razor PageLoadingState MainLayout EditForm main"},{"u":"/docs/guides/common-ACCESSIBILITY.html#known-limitations-tracked","d":"Accessibility (rubric §21)","k":"Guides & Specifications","t":"Known limitations (tracked)","x":"- ~~Dark-mode contrast (§20, not §21).~~ RESOLVED (2026-07-11). The two dark-palette WCAG AA contrast failures the prototype scan flagged (filled-primary button label ~2.65:1 on…","i":"PaletteDark.PrimaryContrastText WarningContrastText ErrorContrastText DarkModeE2ETests EF5350 rgba"},{"u":"/docs/guides/common-BUILD-BY-HAND.html","d":"Building on MMCA.Common by Hand","k":"Guides & Specifications","x":"This is the long-form walkthrough: every project, every file, and every load-bearing line that goes into an application on the MMCA.Common framework, in the order you would…","i":"Contoso.Support Tickets dotnet Orders Order new"},{"u":"/docs/guides/common-BUILD-BY-HAND.html#what-you-will-build","d":"Building on MMCA.Common by Hand","k":"Guides & Specifications","t":"What you will build","x":"A modular monolith with one business module and two hosts: - Orders (your business module): an Order aggregate with OrderComment children, opened through a Result-returning…","i":"AllowAnonymous OrderComment Result Order sql web"},{"u":"/docs/guides/common-BUILD-BY-HAND.html#phase-0-prerequisites-and-decisions","d":"Building on MMCA.Common by Hand","k":"Guides & Specifications","t":"Phase 0: Prerequisites and decisions","x":"Install: - .NET 10 SDK (the framework targets net10.0 with LangVersion: preview for C extension types). - SQL Server reachable locally (LocalDB, a container, or the one Aspire…","i":"FrameworkVersionConsistencyTestsBase Directory.Packages.props MMCA.Common.API UseLocalMMCA LangVersion local.props install net10.0 package preview CS0103 dotnet"},{"u":"/docs/guides/common-BUILD-BY-HAND.html#phase-1-create-the-solution-and-the-build-plumbing","d":"Building on MMCA.Common by Hand","k":"Guides & Specifications","t":"Phase 1: Create the solution and the build plumbing","x":"Scaffolded. dotnet new mmca-app writes every file in this phase. Read it to know what each one does; you do not need to type any of it. The plumbing files are the load-bearing,…","i":"Directory.Packages.props Directory.Build.props Contoso.Support.slnx local.props.template OrderIdentifierType PackageReference MMCA.Helpdesk auditSources editorconfig nuget.config global.json Contracts"},{"u":"/docs/guides/common-BUILD-BY-HAND.html#phase-2-scaffold-the-module-project-set","d":"Building on MMCA.Common by Hand","k":"Guides & Specifications","t":"Phase 2: Scaffold the module project set","x":"Scaffolded. dotnet new mmca-app creates this project set for your first module, and pwsh build/add-module.ps1 adds another one later: it drives dotnet new mmca-module and then…","i":"Contoso.Support.Orders.Infrastructure Contoso.Support.Orders.Application Contoso.Support.Orders.Domain Contoso.Support.Orders.Shared Contoso.Support.Orders.API MMCA.Common.Infrastructure MMCA.Common.Application MMCA.Common.Domain MMCA.Common.Shared AddErrorResources MMCA.Common.API AllowAnonymous"},{"u":"/docs/guides/common-BUILD-BY-HAND.html#phase-3-the-vertical-slice-end-to-end-the-heart-of-it","d":"Building on MMCA.Common by Hand","k":"Guides & Specifications","t":"Phase 3: The vertical slice end-to-end (the heart of it)","x":"Scaffolded. The generated module already contains this slice and six more, worked end to end. dotnet new mmca-command and dotnet new mmca-query add another one. This phase is the…","i":"EntityTypeConfigurationSQLServer ConcurrencyConventionTestsBase AddModuleOrdersInfrastructure ScanModuleApplicationServices AuditableAggregateRootEntity OrderOpenedIntegrationEvent OrderCommentIdentifierType IUnitOfWork.GetRepository AddApplicationDecorators IIntegrationEventHandler DomainEventDispatcher EntityControllerBase"},{"u":"/docs/guides/common-BUILD-BY-HAND.html#phase-4-dbcontext-model-and-migrations","d":"Building on MMCA.Common by Hand","k":"Guides & Specifications","t":"Phase 4: DbContext model and migrations","x":"Partly scaffolded. The migrations project and its design-time factory are generated. Running dotnet ef migrations add InitialCreate is still yours, and for a module added later…","i":"ApplicationSettings.DatabaseInitStrategy InitializeDatabaseAsync SQLServerDbContext EnsureCreated InitialCreate DataSources migrations Migrate dotnet None add"},{"u":"/docs/guides/common-BUILD-BY-HAND.html#phase-5-compose-the-monolith-host-and-run-it","d":"Building on MMCA.Common by Hand","k":"Guides & Specifications","t":"Phase 5: Compose the monolith host and run it","x":"Scaffolded. Both hosts, the AppHost, and the .resx pairs are generated. Read this phase before you touch any of them: the DI sequence, WaitFor(sql) rather than the database…","i":"MmcaCultureBootstrap.SetBrowserCultureAsync LocalizedTextConventionTestsBase LocalizationResourceTestsBase UseCommonRequestLocalization OrderOpenedIntegrationEvent UseCommonMiddlewarePipeline services.AddErrorResources AddApplicationDecorators YourModuleErrorResources EnsureSuccessStatusCode EndpointCultureApplier UseRequestLocalization"},{"u":"/docs/guides/common-BUILD-BY-HAND.html#phase-6-tests-and-the-architecture-fitness-map","d":"Building on MMCA.Common by Hand","k":"Guides & Specifications","t":"Phase 6: Tests and the architecture-fitness map","x":"Scaffolded, with one deliberate gap. All three test projects and the map are generated. The IntegrationEventContractTests subclass is NOT: its frozen literal lists members…","i":"FrameworkVersionConsistencyTestsBase ConstructorDependencyCountTestsBase IntegrationEventContractTestsBase LocalizedTextConventionTestsBase MMCA.Common.Testing.Architecture SpecificationConventionTestsBase MicroserviceExtractionTestsBase ConcurrencyConventionTestsBase ControllerConventionTestsBase IntegrationEventContractTests LocalizationResourceTestsBase HandlerConventionTestsBase"},{"u":"/docs/guides/common-BUILD-BY-HAND.html#phase-7-upgrading-the-framework-version","d":"Building on MMCA.Common by Hand","k":"Guides & Specifications","t":"Phase 7: Upgrading the framework version","x":"Not scaffolded. dotnet new mmca-app --framework-version picks the version you START on; moving to a later one is this phase. When a new MMCA.Common release ships, upgrade in one…","i":"FrameworkVersionConsistencyTestsBase Directory.Packages.props packages.lock.json UseLocalMMCA local.props your.slnx restore dotnet new"},{"u":"/docs/guides/common-BUILD-BY-HAND.html#phase-8-extract-a-module-into-its-own-service-the-payoff","d":"Building on MMCA.Common by Hand","k":"Guides & Specifications","t":"Phase 8: Extract a module into its own service (the payoff)","x":"Not scaffolded. The generated solution carries the plumbing (the .Contracts proto convention and the .Service OpenAPI block in Directory.Build.props), but the extraction itself…","i":"GrpcResultExceptionInterceptor OrderOpenedIntegrationEvent MMCA.Common.Aspire.Hosting WithSQLServerDataSource AddGrpcServiceDefaults Directory.Build.props RequestVersionExact AddTypedGrpcClient WithJwksDiscovery MMCA.Common.Grpc Support_Identity OutboxMessages"},{"u":"/docs/guides/common-BUILD-BY-HAND.html#verification-checklist","d":"Building on MMCA.Common by Hand","k":"Guides & Specifications","t":"Verification checklist","x":"1. Build green: dotnet build Contoso.Support.slnx with no warnings (TreatWarningsAsErrors + five analyzers). This is the primary automatable gate. 2. Unit + architecture tests…","i":"OrderOpenedIntegrationEvent Contoso.Support.slnx IArchitectureMap OutboxMessages InitialCreate OrderComment migrations AppHost dotnet build Order test"},{"u":"/docs/guides/common-BUILD-BY-HAND.html#where-to-look-next","d":"Building on MMCA.Common by Hand","k":"Guides & Specifications","t":"Where to look next","x":"- Getting Started: the one-command path that writes phases 1 through 6 for you. If you are starting a new solution rather than adding the framework to an existing one, that is…","i":"CLAUDE.md README.md Helpdesk Tickets Ticket"},{"u":"/docs/guides/common-COST.html","d":"Cost & FinOps Notes (rubric §31)","k":"Guides & Specifications","x":"MMCA.Common is a library, so it cannot provision anything: right-sizing, scale rules, budgets, and per-service cost attribution live in the consumer apps' IaC (e.g. MMCA.ADC's…"},{"u":"/docs/guides/common-COST.html#what-the-framework-does-for-cost","d":"Cost & FinOps Notes (rubric §31)","k":"Guides & Specifications","t":"What the framework does for cost","x":"- Telemetry ingestion is the real line item, so high-volume / low-value spans are dropped. OutboxPollFilterProcessor (MMCA.Common.Aspire) suppresses the recurring OutboxPoll…","i":"http.client.open_connections OutboxPollFilterProcessor TraceIdRatioBasedSampler ConfigureOpenTelemetry OutboxCleanupService AddServiceDefaults MMCA.Common.Aspire ParentBasedSampler SocketsHttpHandler request.duration active_requests AppDependencies"},{"u":"/docs/guides/common-COST.html#recommended-consumer-defaults-set-these-downstream","d":"Cost & FinOps Notes (rubric §31)","k":"Guides & Specifications","t":"Recommended consumer defaults (set these downstream)","x":"- Telemetry retention & sampling. Tune Log Analytics retention to the minimum the consumer's compliance window allows, and set Telemetry:TracesSampleRatio (the built-in…"},{"u":"/docs/guides/common-COST.html#cost-attribution--guardrail-samples-distilled-from-mmcaadc","d":"Cost & FinOps Notes (rubric §31)","k":"Guides & Specifications","t":"Cost-attribution & guardrail samples (distilled from MMCA.ADC)","x":"These belong in the consumer's IaC, not the library, but the framework documents the shape so every consumer attributes spend and guards surges the same way. The worked, deployed…"},{"u":"/docs/guides/common-COST.html#out-of-scope-for-the-framework-by-design","d":"Cost & FinOps Notes (rubric §31)","k":"Guides & Specifications","t":"Out of scope for the framework (by design)","x":"Provisioning, scale rules, budgets, per-service cost attribution, and surge/revert automation are consumer/IaC concerns and are not added to the library: see also ADR-009…"},{"u":"/docs/guides/common-ECOMMERCE-SAMPLE.html","d":"Build MMCA.ECommerce: a Two-Module Store from the Templates","k":"Guides & Specifications","x":"MMCA.ECommerce is the simplest e-commerce application on the MMCA.Common framework: a Products catalog module and an Orders module with line items, behind a REST API host and a…","i":"MMCA.Templates dotnet new"},{"u":"/docs/guides/common-ECOMMERCE-SAMPLE.html#before-you-start","d":"Build MMCA.ECommerce: a Two-Module Store from the Templates","k":"Guides & Specifications","t":"Before you start","x":"- .NET 10 SDK (the framework targets net10.0 with LangVersion: preview). - Docker Desktop (Aspire provisions SQL Server as a container). - EF Core tools: dotnet tool install…","i":"MMCA.Templates LangVersion install net10.0 preview dotnet pwsh tool ps1"},{"u":"/docs/guides/common-ECOMMERCE-SAMPLE.html#2-generate-the-solution-with-the-products-module","d":"Build MMCA.ECommerce: a Two-Module Store from the Templates","k":"Guides & Specifications","t":"2. Generate the solution with the Products module","x":"Five options do most of this guide's old work. Three remove an axis a catalog product does not have, and the code for an axis you turn off is never generated: --flat drops the…","i":"ProductCreatedIntegrationEvent ProductCreatedHandler RequesterUserId Created Opened Name"},{"u":"/docs/guides/common-ECOMMERCE-SAMPLE.html#3-add-the-orders-module","d":"Build MMCA.ECommerce: a Two-Module Store from the Templates","k":"Guides & Specifications","t":"3. Add the Orders module","x":"build/add-module.ps1 ships inside the solution you just generated. It runs dotnet new mmca-module with the shape options passed through, then performs every wire-up the template…","i":"ECommerceArchitectureMap.cs SQLServerMigrationsAssembly services.AddErrorResources OrderItemIdentifierType WithSQLServerDataSource Directory.Build.props OrdersErrorResources MMCA.ECommerce.slnx ChangeItemQuantity ECommerce_Products appsettings.json ProjectReference"},{"u":"/docs/guides/common-ECOMMERCE-SAMPLE.html#4-reshape-products-into-a-catalog-product","d":"Build MMCA.ECommerce: a Two-Module Store from the Templates","k":"Guides & Specifications","t":"4. Reshape Products into a catalog product","x":"The scaffolded module arrives as the template's worked example in your namespaces, already shaped by the flags in step 2: no children, no status, no requester, Name instead of…","i":"UpdateRequestsAreConcurrencyAware Microsoft.EntityFrameworkCore Product.Description.TooLong ModuleApplicationDbContext ProductCreateRequestMapper DomainEntityState.Updated Product.Description.Empty Directory.Packages.props DependencyInjection.cs TreatWarningsAsErrors Product.InvalidPrice Product.Name.TooLong"},{"u":"/docs/guides/common-ECOMMERCE-SAMPLE.html#5-reshape-orders-into-an-order-with-line-items","d":"Build MMCA.ECommerce: a Two-Module Store from the Templates","k":"Guides & Specifications","t":"5. Reshape Orders into an order with line items","x":"Orders keeps the child-collection pattern the template scaffolded, retargeted. -Child Item already did the naming (the entity is OrderItem, the slices are AddItem / EditItem /…","i":"UpdateRequestsAreConcurrencyAware Order.Item.ProductName.TooLong Total_ExcludesSoftDeletedItems EnsureStatusAllowsItemChanges Microsoft.EntityFrameworkCore Order.InvalidStatusTransition Order.Item.ProductName.Empty ChangeOrderStatusRequest.cs OrderPlacedIntegrationEvent ModuleApplicationDbContext Order.CustomerName.TooLong ChangeItemQuantityCommand"},{"u":"/docs/guides/common-ECOMMERCE-SAMPLE.html#6-point-the-ui-at-the-new-domain","d":"Build MMCA.ECommerce: a Two-Module Store from the Templates","k":"Guides & Specifications","t":"6. Point the UI at the new domain","x":"The scaffolded Blazor host already has the load-bearing parts: the typed ECommerceApiClient calling the API server-side through Aspire service discovery (no CORS, no token), the…","i":"MMCA.ECommerce.Orders.Shared string.IsNullOrWhiteSpace Snackbar.RequiredFields Dialog.Delete.Heading System.Globalization GetProductsAsync ProjectReference missingRequired SectionHeading PageHeading es.resx _field"},{"u":"/docs/guides/common-ECOMMERCE-SAMPLE.html#7-create-the-migrations","d":"Build MMCA.ECommerce: a Two-Module Store from the Templates","k":"Guides & Specifications","t":"7. Create the migrations","x":"Neither module has a migration yet: any shape flag makes mmca-app drop the template's sample one (it described the sample shape), and -SkipMigration deferred the Orders one to…","i":"editorconfig migrations Migrations dotnet add"},{"u":"/docs/guides/common-ECOMMERCE-SAMPLE.html#8-the-two-one-time-fixups-then-run-it","d":"Build MMCA.ECommerce: a Two-Module Store from the Templates","k":"Guides & Specifications","t":"8. The two one-time fixups, then run it","x":"Apply the two fixups the scaffold deliberately leaves to you (they are name-dependent, so no generated value could be right). First, sort the using directives and the identifier…","i":"ProductCreatedIntegrationEvent IntegrationEventContractTests OrderPlacedIntegrationEvent ArchitectureTests.cs AllowAnonymous editorconfig SCAFFOLD IDE0021 SA1210 SA1211 DELTA Open"},{"u":"/docs/guides/common-ECOMMERCE-SAMPLE.html#verification-checklist","d":"Build MMCA.ECommerce: a Two-Module Store from the Templates","k":"Guides & Specifications","t":"Verification checklist","x":"1. Baseline green immediately after mmca-app, before any edit: 81 tests. 2. After build/add-module.ps1: still green at 99 tests, both modules' scaffolded suites running. 3. After…","i":"MMCA.ECommerce.slnx OutboxMessages InitialCreate dotnet build test"},{"u":"/docs/guides/common-ECOMMERCE-SAMPLE.html#where-to-look-next","d":"Build MMCA.ECommerce: a Two-Module Store from the Templates","k":"Guides & Specifications","t":"Where to look next","x":"- MMCA.ECommerce: the finished result of this guide, build- and test-verified. - Getting started: the single-module path, the vertical-slice templates (mmca-command /…"},{"u":"/docs/guides/common-GETTING-STARTED.html","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","x":"MMCA.Common is a .NET 10 framework for DDD, Clean Architecture, and CQRS, shipped as a set of lockstep-versioned NuGet packages (the authoritative list and count live in…"},{"u":"/docs/guides/common-GETTING-STARTED.html#before-you-start","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","t":"Before you start","x":"- .NET 10 SDK. The framework targets net10.0 with LangVersion: preview for C extension types. - Docker Desktop. Aspire provisions SQL Server as a container, so you do not install…","i":"MMCA.Templates LangVersion install net10.0 preview dotnet tool"},{"u":"/docs/guides/common-GETTING-STARTED.html#1-install-the-template-pack","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","t":"1. Install the template pack","x":"Four templates arrive: mmca-app (a whole solution), mmca-module (a business module across all five layers), and mmca-command / mmca-query (a single vertical slice)."},{"u":"/docs/guides/common-GETTING-STARTED.html#2-generate-the-solution","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","t":"2. Generate the solution","x":"Three names, and they are independent: the solution (also your root namespace), the first module in plural PascalCase, and that module's aggregate root in singular PascalCase.…","i":"ProjectReference local.props Billing Invoice"},{"u":"/docs/guides/common-GETTING-STARTED.html#3-build-and-test-before-you-change-anything","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","t":"3. Build and test before you change anything","x":"That is a warning-free build with TreatWarningsAsErrors and all five analyzers at error severity, and a passing test run including the architecture-fitness rules, with no…","i":"TreatWarningsAsErrors"},{"u":"/docs/guides/common-GETTING-STARTED.html#4-create-the-first-migration","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","t":"4. Create the first migration","x":"The scaffold ships the migrations project and its design-time factory; the migration itself describes your entities, so it is yours to generate: Always pass --context…","i":"SQLServerDbContext DbSet"},{"u":"/docs/guides/common-GETTING-STARTED.html#5-run-it","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","t":"5. Run it","x":"Run this from a real, interactive terminal. Launched from a headless or background shell the Aspire AppHost stalls at control-plane init and no dashboard appears. The dashboard…","i":"OrderOpenedIntegrationEvent AllowAnonymous POST GET sql web"},{"u":"/docs/guides/common-GETTING-STARTED.html#6-the-two-one-time-fixups","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","t":"6. The two one-time fixups","x":"The scaffold deliberately does not hand these over, because renaming invalidates them and no fixed value is right for every name you could pick. Both are covered in full in the…","i":"IntegrationEventContractTestsBase Contoso.Support.Orders.Shared Zeta.App.Orders.Shared ArchitectureTests.cs editorconfig SCAFFOLD IDE0021 SA1211 Ticket DELTA using"},{"u":"/docs/guides/common-GETTING-STARTED.html#what-you-were-handed","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","t":"What you were handed","x":"The Order aggregate arrives fully worked: a Result-returning factory, invariants, guarded mutations raising domain events, a child entity, soft-delete cascade, the caching pair,…","i":"AddApplicationDecorators Directory.Build.props OrderIdentifierType IArchitectureMap HandleFailure ModuleLoader ErrorType WaitFor global Result DbSet Order"},{"u":"/docs/guides/common-GETTING-STARTED.html#add-your-next-feature","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","t":"Add your next feature","x":"A vertical slice (the path every feature follows) is one command, run from the module's UseCases folder: Handlers, validators, and mappers are convention-scanned, so there is no…","i":"order.TransferToRequester AddErrorResources RequesterUserId AddDomainEvent Result.Combine ChangeStatus GetByIdAsync SaveChanges definition IsFailure CacheKey Comments"},{"u":"/docs/guides/common-GETTING-STARTED.html#surface-the-slice-at-the-edge","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","t":"Surface the slice at the edge","x":"The scaffold stops at the handler, and the template's closing instructions tell you to map the command in your module's controller. Every write in the generated app follows the…","i":"ThrowIfDomainExceptionAsync _transferRequesterUserId ChangeOrderStatusRequest Api.TransferOrderAsync EntityControllerBase TransferOrderCommand OrderDetail.es.resx ICacheInvalidating ChangeStatusAsync OrderDetail.razor OrderDetail.resx SupportApiClient"},{"u":"/docs/guides/common-GETTING-STARTED.html#then-what","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","t":"Then what","x":"- Upgrade the framework. Bump every MMCA.Common. entry in Directory.Packages.props together, in one pass. See Phase 7 and the versioning policy. - Add real authentication. Copy…","i":"Directory.Packages.props Authorize Contracts Service"},{"u":"/docs/guides/common-GETTING-STARTED.html#verification-checklist","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","t":"Verification checklist","x":"1. dotnet new mmca-app -n produced a solution that builds and tests green before you changed anything. 2. dotnet build .slnx is warning-free (TreatWarningsAsErrors + five…","i":"OutboxMessages InitialCreate migrations healthy YourApp dotnet build slnx test then add new"},{"u":"/docs/guides/common-GETTING-STARTED.html#where-to-look-next","d":"Getting Started: Build a New App on MMCA.Common","k":"Guides & Specifications","t":"Where to look next","x":"- Templates: every parameter of all four templates, dropping the Blazor UI host, and how the pack is built. ADR-065 explains why it is derived from the reference app rather than…"},{"u":"/docs/guides/common-RESILIENCE.html","d":"Resilience & Business Continuity (rubric §29)","k":"Guides & Specifications","x":"MMCA.Common is a library, so it cannot operate a deployment: restores, RTO/RPO, and SLO alerting are executed in the consumer apps' IaC (e.g. MMCA.ADC's…"},{"u":"/docs/guides/common-RESILIENCE.html#what-the-framework-provides-and-verifies-in-repo","d":"Resilience & Business Continuity (rubric §29)","k":"Guides & Specifications","t":"What the framework provides (and verifies in-repo)","x":"Failure isolation, graceful degradation, graceful startup, and the restore procedure itself are therefore demonstrated and tested centrally: the framework drills backup→restore…","i":"ResilienceCircuitBreakerFaultInjectionTests OpenIdConnectMetadataWarmupTask WarmupReadinessHealthCheckTests AddStandardResilienceHandler ConfigureHttpClientDefaults WarmupReadinessHealthCheck DatabaseRestoreDrillTests ConfigureBrokerTransport WarmupHostedServiceTests WarmupReadinessGateTests ResilienceHandlerTests WarmupHostedService"},{"u":"/docs/guides/common-RESILIENCE.html#baseline-slo--error-budget-template-consumers-fill-in","d":"Resilience & Business Continuity (rubric §29)","k":"Guides & Specifications","t":"Baseline SLO / error-budget template (consumers fill in)","x":"Adopt and tune per app; ADC's filled-in version lives in infra/DISASTER-RECOVERY.md + the SLO metric-alerts in infra/main.bicep. Define RTO/RPO per service (ADC's worked…","i":"requests"},{"u":"/docs/guides/common-RESILIENCE.html#restore-drill-runbook-reference","d":"Resilience & Business Continuity (rubric §29)","k":"Guides & Specifications","t":"Restore-drill runbook (reference)","x":"The only evidence backups actually restore is a periodic drill: restore a throwaway copy, confirm it comes back Online, record the measured restore time, then delete the copy.…"},{"u":"/docs/guides/common-RESPONSIVE.html","d":"Responsive Design & Cross-Browser Support (rubric §22)","k":"Guides & Specifications","x":"This document is the supported-device and browser matrix for the shared MMCA.Common.UI component library. It makes the responsive contract explicit (the rubric §22 note that it…","i":"MMCA.Common.UI"},{"u":"/docs/guides/common-RESPONSIVE.html#breakpoints","d":"Responsive Design & Cross-Browser Support (rubric §22)","k":"Guides & Specifications","t":"Breakpoints","x":"The framework keeps C viewport detection and CSS media queries aligned around one mobile threshold. The C 960px mobile cutoff and the CSS 1023.98px cutoff intentionally differ:…","i":"BreakpointConstants.IsMobileBreakpoint media i.e"},{"u":"/docs/guides/common-RESPONSIVE.html#touch-targets","d":"Responsive Design & Cross-Browser Support (rubric §22)","k":"Guides & Specifications","t":"Touch targets","x":"Interactive controls on mobile surfaces meet a 48px minimum hit area (Material Design), exceeding both WCAG 2.5.8 Target Size (Minimum, AA, 24px) and WCAG 2.5.5 Target Size…"},{"u":"/docs/guides/common-RESPONSIVE.html#grid-density","d":"Responsive Design & Cross-Browser Support (rubric §22)","k":"Guides & Specifications","t":"Grid density","x":"DataGridListPageBase exposes a DenseGrid property and a ToggleDensity() method. Derived list pages bind Dense=\"@DenseGrid\" on their MudDataGrid and surface a toggle. The chosen…","i":"ListPageQueryStateServiceTests ListPageStateServiceTests DataGridListPageBase ToggleDensity MudDataGrid DenseGrid TDto"},{"u":"/docs/guides/common-RESPONSIVE.html#browser-matrix","d":"Responsive Design & Cross-Browser Support (rubric §22)","k":"Guides & Specifications","t":"Browser matrix","x":"The shared UI is tested against three Playwright engines in CI (.github/workflows/ci.yml, ui-e2e job): a real-browser axe (WCAG 2.1 AA) + render smoke against the backend-less…","i":"false"},{"u":"/docs/guides/common-TEMPLATES.html","d":"Templates: scaffolding an MMCA app","k":"Guides & Specifications","x":"MMCA.Templates is a dotnet new pack that scaffolds solutions, modules, and vertical slices on the MMCA.Common framework. It exists because standing up a new app by hand meant 12…","i":"MMCA.Templates UseCases dotnet new"},{"u":"/docs/guides/common-TEMPLATES.html#mmca-app","d":"Templates: scaffolding an MMCA app","k":"Guides & Specifications","t":"mmca-app","x":"The module and aggregate names are independent, so --module Billing --aggregate Invoice is fine. Everything derived from them follows: routes, the Aspire database resource, the…","i":"IntegrationEventContractTestsBase Contoso.Support.Orders.Shared ShipmentLineIdentifierType Zeta.App.Orders.Shared ArchitectureTests.cs builder.AddProject ProjectReference Contoso.Support EditLineRequest RequesterUserId AddLineRequest AppHost.csproj"},{"u":"/docs/guides/common-TEMPLATES.html#mmca-module","d":"Templates: scaffolding an MMCA app","k":"Guides & Specifications","t":"mmca-module","x":"All six behave exactly as they do for mmca-app, and they are per module: a solution can hold a flat, status-less catalog module beside one whose aggregate owns a growing child…","i":"SQLServerMigrationsAssembly services.AddErrorResources Architecture.Tests.csproj OrderItemIdentifierType Directory.Build.props Migrations.SqlServer Contoso.Support ErrorResources ModuleLoader DataSources FirstModule RemoveItem"},{"u":"/docs/guides/common-TEMPLATES.html#buildadd-moduleps1","d":"Templates: scaffolding an MMCA app","k":"Guides & Specifications","t":"build/add-module.ps1","x":"Since 1.4.0 every solution mmca-app generates ships this script, and it is the supported way to add a second module. It runs mmca-module with your shape options passed through,…","i":"IntegrationEventContractTests migrations copyOnly dotnet diff Name slnx add git"},{"u":"/docs/guides/common-TEMPLATES.html#mmca-command-and-mmca-query","d":"Templates: scaffolding an MMCA app","k":"Guides & Specifications","t":"mmca-command and mmca-query","x":"Run these from the module's UseCases folder. Each creates a folder named after the slice holding its two files. --child-collection exists because both handlers load through…","i":"EntityControllerBase MMCA.Templates GetByIdAsync definition CacheKey Comments includes UseCases contain dotnet nameof Result"},{"u":"/docs/guides/common-TEMPLATES.html#how-the-pack-is-built","d":"Templates: scaffolding an MMCA app","k":"Guides & Specifications","t":"How the pack is built","x":"The template content is the MMCA.Helpdesk reference application itself, staged at pack time. There is no second copy of the solution, so the template cannot drift from the app…"},{"u":"/docs/guides/common-VERSIONING.html","d":"Versioning & Breaking-Change Policy","k":"Guides & Specifications","x":"MMCA.Common publishes fifteen NuGet packages that are versioned and released together as a single unit. They share one version number so a consumer never has to reason about…","i":"MMCA.Common.UI.Maui release.yml"},{"u":"/docs/guides/common-VERSIONING.html#semantic-versioning","d":"Versioning & Breaking-Change Policy","k":"Guides & Specifications","t":"Semantic Versioning","x":"Versions follow SemVer 2.0: MAJOR.MINOR.PATCH: - MAJOR: reserved (see \"Breaking changes within 1.x\" below). - MINOR: new capability, and the channel breaking changes currently…","i":"vMAJOR.MINOR.PATCH MAJOR.MINOR.PATCH v1.51.0"},{"u":"/docs/guides/common-VERSIONING.html#what-counts-as-breaking","d":"Versioning & Breaking-Change Policy","k":"Guides & Specifications","t":"What counts as breaking","x":"A change is breaking if it is any of: - Removing or renaming a public type/member, or changing a signature. - Changing the meaning of an existing configuration key, or changing a…","i":"Result"},{"u":"/docs/guides/common-VERSIONING.html#breaking-changes-within-1x","d":"Versioning & Breaking-Change Policy","k":"Guides & Specifications","t":"Breaking changes within 1.x","x":"Breaking changes ship as MINOR bumps, not MAJOR ones, and the version number is therefore not a reliable breakage signal on its own. This is deliberate and follows from the…","i":"IIntegrationEventPublisher IntegrationEventPublisher WithSQLServerDataSource WithDataSource IEventBus v1.123.0 v1.79.0"},{"u":"/docs/guides/common-VERSIONING.html#consumer-rollout","d":"Versioning & Breaking-Change Policy","k":"Guides & Specifications","t":"Consumer rollout","x":"Per project convention, framework upgrades are swept across all consumers in one pass: there are no opt-in flags or phased rollouts for a MMCA.Common change. When a release…"},{"u":"/docs/guides/common-VERSIONING.html#deprecation","d":"Versioning & Breaking-Change Policy","k":"Guides & Specifications","t":"Deprecation","x":"There is no [Obsolete] grace period today. Because the lockstep sweep updates every first-party caller in the same change set, a superseded API is removed in the release that…","i":"Obsolete"},{"u":"/docs/guides/common-VERSIONING.html#supply-chain","d":"Versioning & Breaking-Change Policy","k":"Guides & Specifications","t":"Supply chain","x":"- All package versions are centrally pinned (Directory.Packages.props). - NuGet lock files are committed for reproducible restores. - MassTransit is pinned to v8 by policy (v9…","i":"Directory.Packages.props MassTransit"},{"u":"/docs/guides/store-ACCESSIBILITY-SCREENREADER-PASS.html","d":"Manual Screen-Reader Pass: Runbook (§21 Accessibility)","k":"Guides & Specifications","x":"The automated layer (axe-core WCAG 2.1 AA scans in Tests/E2E/MMCA.Store.E2E.Tests/Workflows/AccessibilityTests.cs plus the shared Login/Register/Profile bases in…","i":"MMCA.Common.Testing.E2E"},{"u":"/docs/guides/store-ACCESSIBILITY-SCREENREADER-PASS.html#tools","d":"Manual Screen-Reader Pass: Runbook (§21 Accessibility)","k":"Guides & Specifications","t":"Tools","x":"Run against the app started via Aspire (dotnet run --project Source/Hosting/MMCA.Store.AppHost), reaching the Web UI at https://localhost:6002. Test with the keyboard only (no…","i":"dotnet start Ctrl Alt Cmd run"},{"u":"/docs/guides/store-ACCESSIBILITY-SCREENREADER-PASS.html#what-to-verify-on-every-flow","d":"Manual Screen-Reader Pass: Runbook (§21 Accessibility)","k":"Guides & Specifications","t":"What to verify on every flow","x":"1. Landmarks and headings. The SR's landmark/heading list (NVDA Insert+F7) exposes a logical banner / navigation / main structure and a sensible h1...hN outline (one h1 per…","i":"Wcag21AaExceptMudPagerCombobox MainLayout.razor MMCA.Common.UI navigation h1...hN banner Insert Shift main Tab"},{"u":"/docs/guides/store-ACCESSIBILITY-SCREENREADER-PASS.html#results-log","d":"Manual Screen-Reader Pass: Runbook (§21 Accessibility)","k":"Guides & Specifications","t":"Results log","x":"Record one row per pass. A flow with any unresolved blocker is a FAIL (open a backlog item for it)."},{"u":"/docs/guides/store-ACCESSIBILITY-SCREENREADER-PASS.html#after-a-pass","d":"Manual Screen-Reader Pass: Runbook (§21 Accessibility)","k":"Guides & Specifications","t":"After a pass","x":"- File any defect as a remediation item; cross-link it from the §21 row's evidence. - Update the dated row above so the scorecard can cite a real, current manual pass (this is…"},{"u":"/docs/guides/store-BusinessWorkflows.html","d":"MMCA Business Workflow Analysis","k":"Guides & Specifications"},{"u":"/docs/guides/store-BusinessWorkflows.html#workflow-list-summary","d":"MMCA Business Workflow Analysis","k":"Guides & Specifications","t":"Workflow List Summary","x":"---","i":"productId variantId imageId DELETE userId POST GET PUT"},{"u":"/docs/guides/store-BusinessWorkflows.html#1-identity-module-workflows","d":"MMCA Business Workflow Analysis","k":"Guides & Specifications","t":"1. Identity Module Workflows","x":"Entry Point: POST /auth/register, AuthController.RegisterAsync(), AllowAnonymous Execution Path: Business Steps: 1. Validate registration input (email, password, first name, last…","i":"AuthController.RegisterAsync AuthController.LoginAsync User.RefreshTokenExpiry Customer.ChangeAddress CustomerAddressChanged Customer.ChangeEmail CustomerEmailChanged RequireAuthenticated Customer.ChangeName CustomerNameChanged User.RefreshToken CustomerCreated"},{"u":"/docs/guides/store-BusinessWorkflows.html#2-catalog-module-workflows","d":"MMCA Business Workflow Analysis","k":"Guides & Specifications","t":"2. Catalog Module Workflows","x":"Entry Point: POST /categories, Admin only, [Idempotent] Response: 201 Created with CategoryDTO Entry Point: PUT /categories/{id}/name, Admin only Entry Point: PUT…","i":"CatalogFeatures.ProductImages ProductVariantPriceChanged ProductVariantCartInfoDTO ProductVariantSkuChanged IProductVariantService ProductVariantRemoved ProductNameChanged ParentCategoryId ProductImageData CategoryDeleted ProductImageDTO ProductDeleted"},{"u":"/docs/guides/store-BusinessWorkflows.html#3-sales-module-workflows","d":"MMCA Business Workflow Analysis","k":"Guides & Specifications","t":"3. Sales Module Workflows","x":"Entry Point: POST /shoppingcarts/{customerId}/shoppingcartitems, Authenticated (owner or admin via OwnerOrAdminFilter) Decision Points: - Product variant doesn't exist - NotFound…","i":"ShoppingCartItemQuantityAdjusted InventoryItem.AvailableQuantity OrderPaymentFailedSagaHandler BulkSetInventoryResultDTO Order.InventoryRestored ProductVariant.NotFound ShoppingCartItemRemoved IProductVariantService ShoppingCartCheckedOut StripePaymentIntentId ShoppingCart.Status ShoppingCartCleared"},{"u":"/docs/guides/store-BusinessWorkflows.html#4-ui-workflows","d":"MMCA Business Workflow Analysis","k":"Guides & Specifications","t":"4. UI Workflows","x":"The UI provides a complete shopping experience through the CartDrawer component and Blazor pages. The CartDrawer is the only cart UI: there is no dedicated cart page. It is a…","i":"ICartStateService IUIModule OnChange"},{"u":"/docs/guides/store-BusinessWorkflows.html#5-cross-module-interactions","d":"MMCA Business Workflow Analysis","k":"Guides & Specifications","t":"5. Cross-Module Interactions","x":"Module dependency: Sales declares a hard dependency on Catalog (RequiresDependencies = true). When Catalog is disabled, a DisabledProductVariantService stub is registered and…","i":"DisabledProductVariantService IProductVariantService UserRegisteredHandler RequiresDependencies GetUnitPricesAsync GetIdBySkuAsync SkuExistsAsync ExistsAsync true"},{"u":"/docs/guides/store-BusinessWorkflows.html#6-external-interactions","d":"MMCA Business Workflow Analysis","k":"Guides & Specifications","t":"6. External Interactions","x":"---","i":"StripePaymentService IDbContextFactory SmtpEmailSender"},{"u":"/docs/guides/store-BusinessWorkflows.html#7-cross-cutting-concerns-participating-in-workflows","d":"MMCA Business Workflow Analysis","k":"Guides & Specifications","t":"7. Cross-Cutting Concerns Participating in Workflows","x":"---","i":"TransactionalCommandDecorator ProfilingCommandDecorator CachingCommandDecorator ProfilingQueryDecorator ICacheInvalidating OwnerOrAdminFilter IdempotencyFilter ITransactional ApiVersion Idempotent"},{"u":"/docs/guides/store-BusinessWorkflows.html#8-end-to-end-customer-journey","d":"MMCA Business Workflow Analysis","k":"Guides & Specifications","t":"8. End-to-End Customer Journey","x":"Alternative Flows: - Payment fails - Order status PaymentFailed - customer can retry (create new Stripe session) - Cancel order - Status Cancelled (from PendingPayment,…","i":"StripePaymentIntentId PaymentFailed Cancelled"},{"u":"/docs/guides/store-BusinessWorkflows.html#9-potentially-missing-or-incomplete-workflows","d":"MMCA Business Workflow Analysis","k":"Guides & Specifications","t":"9. Potentially Missing or Incomplete Workflows","x":"--- This document is derived from source code analysis. All workflows, decisions, and behaviors described above are confirmed implementations traceable to the referenced source…","i":"OrderPaymentFailedSagaHandler OrderCancelledSagaHandler MarkAsDelivered SmtpEmailSender User.Deactivate UserDeactivated"},{"u":"/docs/guides/store-NavigationFlow.html","d":"Navigation Flow","k":"Guides & Specifications","x":"This document maps the site navigation flow for each actor in the MMCA.Store application. Each mermaid diagram shows the pages accessible to that actor and the directional…","i":"NavigationFlow.md"},{"u":"/docs/guides/store-NavigationFlow.html#actors","d":"Navigation Flow","k":"Guides & Specifications","t":"Actors","x":"Roles and enforcement: Admin is the only elevated role (registration creates a Customer). The 14 admin pages carry page-level [Authorize(Roles = \"Admin\")], regression-gated in CI…","i":"customer_id Authorize Customer Admin Roles"},{"u":"/docs/guides/store-NavigationFlow.html#1-anonymous-user","d":"Navigation Flow","k":"Guides & Specifications","t":"1. Anonymous User","x":"Pages accessible without authentication: home, login, register, the two password-reset pages, and the public catalog. Add-to-cart on the product detail page sits inside an…","i":"AuthorizeView Authorize"},{"u":"/docs/guides/store-NavigationFlow.html#2-customer-authenticated-user","d":"Navigation Flow","k":"Guides & Specifications","t":"2. Customer (Authenticated User)","x":"Inherits all anonymous pages. Gains the profile page, the cart drawer (a layout component, not a route), checkout, and their own orders. Unauthenticated visitors deep-linking to…","i":"OrphanOrderRecovery Specification Authorize"},{"u":"/docs/guides/store-NavigationFlow.html#3-admin","d":"Navigation Flow","k":"Guides & Specifications","t":"3. Admin","x":"Inherits all customer pages, plus the admin CRUD surfaces for all three modules. Every page below carries [Authorize(Roles = \"Admin\")]; a customer deep-linking to any of them…","i":"Authorize Roles"},{"u":"/docs/guides/store-NavigationFlow.html#authorization-model","d":"Navigation Flow","k":"Guides & Specifications","t":"Authorization Model","x":"Three cooperating layers; the API is always the boundary: 1. Page-level route guards. The 14 admin pages carry [Authorize(Roles = \"Admin\")] and /profile / /orders carry…","i":"OwnershipHelper.GetOwnershipSpecification OwnerOrAdminFilter mmca_auth_access AuthorizeView customer_id Authorize c4adff2 Roles"},{"u":"/docs/guides/store-Specification.html","d":"MMCA Business Specification Document","k":"Guides & Specifications"},{"u":"/docs/guides/store-Specification.html#1-system-overview","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"1. System Overview","x":"MMCA is an e-commerce platform built with .NET 10.0 using DDD and Clean Architecture. The business logic is organized as modules (Catalog, Sales, Identity) that have been…"},{"u":"/docs/guides/store-Specification.html#2-core-business-entities","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"2. Core Business Entities","x":"Description: A classification grouping for products. Supports hierarchical (parent-child) structures for nested categorization (e.g., \"Jewelry\" \"Rings\"). Key Properties:…"},{"u":"/docs/guides/store-Specification.html#3-business-workflows","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"3. Business Workflows","x":"Trigger: A new user submits registration with first name, last name, email, and password. Steps: 1. Validate registration request (email format, password requirements) 2. Verify…","i":"IInventoryAllocationService.DecrementAsync CatalogFeatures.ProductImages payment_intent.payment_failed EventUtility.ConstructEvent IProductImageStorageService checkout.session.completed ProductImageStorageService OrderCancelledSagaHandler checkout.session.expired Order.InventoryRestored Auth.InvalidResetToken IProductVariantService"},{"u":"/docs/guides/store-Specification.html#4-order-status-state-machine","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"4. Order Status State Machine","x":"Cancellable States: PendingPayment, PaymentInitiated, PaymentFailed Manual Payment States: PendingPayment, PaymentInitiated, PaymentFailed Terminal States: Cancelled, Delivered ---"},{"u":"/docs/guides/store-Specification.html#5-business-rules","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"5. Business Rules","x":"---","i":"ForgotPasswordRequestValidator ProductVariantConfiguration.cs ResetPasswordRequestValidator InventoryItemInvariants.cs AdjustInventoryHandler.cs PasswordResetTokenService ShoppingCartInvariants.cs CategoryConfiguration.cs CheckOutDomainService.cs CustomerConfiguration.cs ForgotPasswordHandler.cs UserRegisteredHandler.cs"},{"u":"/docs/guides/store-Specification.html#6-use-cases","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"6. Use Cases","x":"---"},{"u":"/docs/guides/store-Specification.html#7-domain-events-and-state-changes","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"7. Domain Events and State Changes","x":"---"},{"u":"/docs/guides/store-Specification.html#8-external-integrations","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"8. External Integrations","x":"Purpose: Processes online customer payments for orders. Business Impact: Enables the system to collect payments from customers and confirm payment success or failure…","i":"OrderPaymentFailedSagaHandler payment_intent.payment_failed EventUtility.ConstructEvent checkout.session.completed checkout.session.expired OrderPaidHandler Result.Failure StripeSettings WebhookSecret IEmailSender SecretKey"},{"u":"/docs/guides/store-Specification.html#9-authorization-model","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"9. Authorization Model","x":"Ownership Enforcement: The OwnerOrAdminFilter validates that the route parameter id (CustomerIdentifierType) matches the authenticated user's customer ID, or that the user has…","i":"OwnerOrAdminFilter customer_id user_id email POST role iat jti sub"},{"u":"/docs/guides/store-Specification.html#10-cross-module-communication","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"10. Cross-Module Communication","x":"The system enforces strict module boundaries. Modules communicate only through shared interface contracts: Confirmed behaviors: - Sales module cannot directly access Catalog…","i":"DisabledProductVariantService IProductVariantService RequiresDependencies true"},{"u":"/docs/guides/store-Specification.html#11-user-interface","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"11. User Interface","x":"The UI is a Blazor Server + WebAssembly hybrid (InteractiveAuto render mode) using MudBlazor component library. It supports multiple hosting targets: - Web (Server + WASM):…","i":"UIModuleConfiguration.IsModuleEnabled ICartStateService InteractiveAuto configuration moduleName IUIModule Assembly NavItems"},{"u":"/docs/guides/store-Specification.html#12-cross-cutting-infrastructure","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"12. Cross-Cutting Infrastructure","x":"The IdempotencyFilter (applied via [Idempotent] attribute on Create endpoints) caches the first response for a given Idempotency-Key header value for 24 hours. Duplicate requests…","i":"TransactionalCommandDecorator ProfilingCommandDecorator CachingCommandDecorator ProfilingQueryDecorator ICacheInvalidating IDataSourceService IDbContextFactory IdempotencyFilter ITransactional SemaphoreSlim UseDataSource Idempotent"},{"u":"/docs/guides/store-Specification.html#13-testing","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"13. Testing","x":"- Full customer journey: Register - Browse - Add to Cart - Checkout - Admin Pay - Deliver - Order lifecycle: all state transitions including cancellation with inventory…","i":"MMCA.Store.Integration.slnf MMCA.Store.IntegrationTests WebApplicationFactory STORE_TEST_SQL_BASE"},{"u":"/docs/guides/store-Specification.html#14-missing-or-unclear-business-logic","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"14. Missing or Unclear Business Logic","x":"Observation: The SMTP email service infrastructure is implemented, but no domain event handlers trigger email notifications for events like order confirmation, payment receipt,…","i":"InventoryItemsController InventoryItemList MarkAsDelivered User.Deactivate UserDeactivated CategoryId Delivered GetPaged GetById GetAll Lookup Paid"},{"u":"/docs/guides/store-Specification.html#15-seed-data-initial-system-state","d":"MMCA Business Specification Document","k":"Guides & Specifications","t":"15. Seed Data (Initial System State)","x":"The system seeds the following data at startup: Users: - Admin: one seeded administrator account (Admin role, no Customer record; credentials are environment-specific and not…","i":"ExistsAsync"},{"u":"/","d":"Ivan Ball-llovera · Senior Software Architect","k":"Site","x":"Senior Software Architect Ivan Ball-llovera Cloud-native enterprise architecture on the Microsoft stack I design and ship production-grade .NET platforms: modular monoliths that…"},{"u":"/","t":"Architecture that earns its keep","d":"Ivan Ball-llovera · Senior Software Architect","k":"Site","x":"I am a Senior Software Architect with more than 25 years designing and delivering scalable, cloud-native systems on the Microsoft stack. My focus is Domain-Driven Design, Clean…"},{"u":"/","t":"The MMCA platform","d":"Ivan Ball-llovera · Senior Software Architect","k":"Site","x":"A production-grade .NET 10 framework and a set of reference apps that demonstrate modern enterprise architecture end to end. It is built as a modular monolith that extracts…"},{"u":"/","t":"Deep dives on enterprise .NET","d":"Ivan Ball-llovera · Senior Software Architect","k":"Site","x":"A long-form series turning the framework's decisions into teachable patterns, every claim grounded in real source. The three most recent: Proof & getting started · No. 50 The…"},{"u":"/","t":"Speaking & giving back","d":"Ivan Ball-llovera · Senior Software Architect","k":"Site","x":"I help run two community-driven Atlanta technology conferences and keep production-grade patterns free and in the open. See talks & community work Organizer & speaker Two Atlanta…"},{"u":"/resume.html","d":"Résumé","k":"Site","x":"Résumé Ivan Ball-llovera Senior Software Architect 25+ years designing and delivering scalable, cloud-native systems on the Microsoft stack: Domain-Driven Design, Clean…"},{"u":"/resume.html","t":"Professional summary","d":"Résumé","k":"Site","x":"Senior Software Architect with 25+ years designing and delivering scalable, cloud-native systems on the Microsoft stack. Deep expertise in Domain-Driven Design, Clean…"},{"u":"/resume.html","t":"Core competencies","d":"Résumé","k":"Site","x":"Architecture & design Domain-Driven Design, Clean Architecture, CQRS, Modular Monolith → Microservices, Event-Driven Architecture, Outbox Pattern, gRPC, API Gateway (YARP),…"},{"u":"/resume.html","t":"Professional experience","d":"Résumé","k":"Site","x":"Senior Software Engineer · Assurant June 2025 – Present · Architect-level scope: platform, security, and cross-team technical decisions Re-architected the AR.com renters quote…"},{"u":"/resume.html","t":"Featured project · MMCA platform","d":"Résumé","k":"Site","x":"Personal / open source · github.com/ivanball/MMCA.Common A production-grade .NET 10 reference platform demonstrating modern enterprise architecture end-to-end. The conference…"},{"u":"/resume.html","t":"Education","d":"Résumé","k":"Site","x":"B.S., Computer Science University of Havana (Faculty of Mathematics), Havana, Cuba (1994 – 1999)"},{"u":"/resume.html","t":"Languages","d":"Résumé","k":"Site","x":"English · Spanish (bilingual)"},{"u":"/resume.html","t":"Certifications","d":"Résumé","k":"Site","x":"✓ Azure Administrator Associate (AZ-104, 2025) ✓ Azure AI Fundamentals (AI-900, 2024) ✓ Azure Data Fundamentals (DP-900, 2021) ✓ Azure Fundamentals (AZ-900, 2021) → In progress…"},{"u":"/resume.html","t":"Professional development","d":"Résumé","k":"Site","x":"Continuously prototypes emerging technologies, with a current focus on Clean Architecture using .NET 10, Blazor, .NET MAUI, and ASP.NET Core Web API, and on AI-assisted…"},{"u":"/platform.html","d":"The MMCA Platform","k":"Site","x":"Featured work · Open source The MMCA platform A production-grade .NET 10 platform that demonstrates modern enterprise architecture end to end: an open-source framework of fifteen…"},{"u":"/platform.html","t":"MMCA.Common","d":"The MMCA Platform","k":"Site","x":"A NuGet package framework for building modular-monolith applications with DDD, Clean Architecture, and CQRS, and the extension points to extract a module into its own…"},{"u":"/platform.html","t":"Three reference applications","d":"The MMCA Platform","k":"Site","x":"The framework is consumed by real apps. Each one exercises the patterns differently, and the conference app ran a live event on Azure. Conference MMCA.ADC A production-deployed…"},{"u":"/platform.html","t":"From one graph, laptop to cloud","d":"The MMCA Platform","k":"Site","x":"Orchestrated locally with .NET Aspire, then deployed to Azure Container Apps from the same declarative model. The .NET Aspire dashboard: services, databases, and the broker as…"},{"u":"/platform.html","t":"Architectural styles the codebase commits to","d":"The MMCA Platform","k":"Site","x":"The recurring ideas every repo is built on, summarized from the onboarding primer's orientation chapter. Each is taught in full at its first concrete appearance in the reference…"},{"u":"/platform.html","t":"A two-axis architecture scorecard","d":"The MMCA Platform","k":"Site","x":"Every repo is scored against a 34-category rubric on two axes, Maturity (how complete the decision is) and Implementation (how well it is realized in code), with evidence and…"},{"u":"/platform.html","t":"Architecture Decision Records","d":"The MMCA Platform","k":"Site","x":"96 ADRs capture the context and trade-offs behind each cross-cutting pattern, so the design is teachable, not tribal knowledge. Every entry below links to the full record. 001…"},{"u":"/platform.html","t":"The reference library","d":"The MMCA Platform","k":"Site","x":"The full architecture documentation, maintained in this site's repository as its canonical home and rendered as browsable pages: every Architecture Decision Record, the…"},{"u":"/platform.html","t":"Use it, read it, or follow along","d":"The MMCA Platform","k":"Site","x":"The framework is Apache 2.0 and published to nuget.org. The fastest way in is the reference app: one module wired end to end through all five layers, with the extraction path…"},{"u":"/platform.html","t":"Get each deep dive by email","d":"The MMCA Platform","k":"Site","x":"One message per article, no digests and no other mail. Email address Subscribe"},{"u":"/writing.html","d":"Writing","k":"Site","x":"Writing Deep dives on enterprise .NET A long-form series that turns the MMCA framework's architecture decisions into teachable patterns, every claim grounded in real source. Read…"},{"u":"/writing.html","t":"Get each deep dive by email","d":"Writing","k":"Site","x":"One message per article, no digests and no other mail. Email address Subscribe"},{"u":"/speaking.html","d":"Speaking & Community","k":"Site","x":"Speaking & community Talks and giving back For more than 20 years I have been an active contributor to the Microsoft developer communities in Atlanta and South Florida: teaching,…"},{"u":"/speaking.html","t":"Recent sessions","d":"Speaking & Community","k":"Site","x":"Atlanta Cloud + AI Conference · 2026 The App You're Using Right Now Building Atlanta Cloud + AI's own platform with Claude in the loop A field report, not a slide deck about…"},{"u":"/speaking.html","t":"Organizing two Atlanta conferences","d":"Speaking & Community","k":"Site","x":"I help convene developers in person, giving the local community direct, no-cost access to expert content on the Microsoft platform. Lead organizer Atlanta Cloud + AI Conference…"},{"u":"/speaking.html","t":"User groups","d":"Speaking & Community","k":"Site","x":"An active participant in Atlanta's Microsoft technology user-group ecosystem, the same community network from which the conferences draw their speakers and attendees. • Atlanta…"},{"u":"/speaking.html","t":"Open source & mentorship","d":"Speaking & Community","k":"Site","x":"My MMCA framework is Apache-2.0 licensed and documented with architecture decision records, so the patterns are not just usable but teachable. I mentor developers one on one,…"},{"u":"/speaking.html","t":"What I speak on","d":"Speaking & Community","k":"Site","x":"Sessions and workshops for conferences, user groups, and teams. Clean Architecture & DDD on .NET Modular monolith → microservices The transactional outbox Database-per-service…"},{"u":"/contact.html","d":"Contact","k":"Site","x":"Contact Let's connect Happy to talk architecture, the MMCA platform, speaking at your conference or user group, or comparing notes on .NET and Azure. The fastest ways to reach…"},{"u":"/contact.html","t":"Three places to start","d":"Contact","k":"Site","x":"Open source The MMCA platform A .NET 10 framework and three reference apps, graded in the open against a 34-category rubric. See the architecture → Writing Deep dives on…"},{"u":"https://medium.com/@ivanball76/the-mmca-series-every-pattern-one-place-28cf2cee7be8","t":"The series index","d":"Article no. 50","k":"Proof & getting started","x":"The full series index and recommended reading order.","e":1},{"u":"https://medium.com/@ivanball76/undo-is-a-feature-saga-compensation-and-the-reconciliation-backstop-fa017f9591b8","t":"Saga compensation and the reconciliation backstop","d":"Article no. 49","k":"Core patterns","x":"Undo as a first-class event handler: give back stock a committed transaction already took, with a periodic sweep as the saga-timeout backstop.","e":1},{"u":"https://medium.com/@ivanball76/observability-by-default-opentelemetry-and-azure-monitor-in-mmca-673c1886e9e0","t":"Observability by default","d":"Article no. 48","k":"Run & extract","x":"A shared OpenTelemetry baseline with CQRS duration metrics, correlation IDs, and outbox-poll span filtering, exported to Azure Monitor.","e":1},{"u":"https://medium.com/@ivanball76/security-headers-and-csp-for-blazor-one-middleware-every-host-af82df95236e","t":"Security headers and CSP for Blazor","d":"Article no. 47","k":"Auth & the edge","x":"One middleware stamps hardened response headers on every host, with the Blazor CSP resolved through a pluggable provider.","e":1},{"u":"https://medium.com/@ivanball76/field-level-encryption-in-ef-core-aes-gcm-for-pii-columns-06ece340ea25","t":"Field-level encryption in EF Core","d":"Article no. 46","k":"Data & persistence","x":"An AES-256-GCM value converter that keeps a PII column ciphertext even for someone who can query the database.","e":1},{"u":"https://medium.com/@ivanball76/feature-flags-in-the-cqrs-pipeline-gate-commands-not-code-e58b9ea8d098","t":"Feature flags in the CQRS pipeline","d":"Article no. 45","k":"Core patterns","x":"Gate commands and queries at the outermost decorator, so a handler never checks a flag and a disabled feature is rejected before any work runs.","e":1},{"u":"https://medium.com/@ivanball76/http-api-versioning-proven-not-just-claimed-2b0381e4b533","t":"HTTP API versioning, proven not just claimed","d":"Article no. 44","k":"Auth & the edge","x":"Header-based versioning introduced without breaking a single caller, plus a fitness contract that proves two live versions coexist.","e":1},{"u":"https://medium.com/@ivanball76/managed-file-storage-uploads-you-dont-have-to-trust-8dfe8bf016bc","t":"Managed file storage: uploads you don't have to trust","d":"Article no. 43","k":"Data & persistence","x":"Attacker-controlled bytes become safe avatars: content sniffing, metadata stripping, re-encoding, and pluggable blob storage.","e":1},{"u":"https://medium.com/@ivanball76/one-blazor-ui-two-hosts-a-device-capability-layer-that-stays-resolvable-everywhere-b85444693161","t":"One Blazor UI, two hosts","d":"Article no. 42","k":"Proof & getting started","x":"The same Blazor components run in a browser and inside a MAUI hybrid app; small per-capability contracts reach native hardware without ever asking 'am I on mobile?'.","e":1},{"u":"https://medium.com/@ivanball76/two-real-apps-on-one-framework-a-conference-platform-and-a-store-12f694d2a361","t":"Two real apps on one framework","d":"Article no. 41","k":"Proof & getting started","x":"A case study: a conference platform and an e-commerce store built on the same kernel.","e":1},{"u":"https://medium.com/@ivanball76/write-your-first-architecture-fitness-test-d4e25e6a4741","t":"Write your first fitness test","d":"Article no. 40","k":"Proof & getting started","x":"Author your first architecture fitness test and watch it fail the build on a violation.","e":1},{"u":"https://medium.com/@ivanball76/scaffold-a-net-modular-monolith-in-one-command-then-build-your-first-module-b10aacd16d33","t":"Build your first module","d":"Article no. 39","k":"Proof & getting started","x":"A hands-on walkthrough of building a new module across all five layers.","e":1},{"u":"https://medium.com/@ivanball76/one-preference-two-switches-shipping-i18n-and-dark-mode-on-a-single-cookie-and-profile-pipeline-f97186038909","t":"i18n and theming on one preference pipeline","d":"Article no. 38","k":"Proof & getting started","x":"A culture choice and a theme choice ride the same cookie, profile column, and login reconciliation: one persistence path, two switches.","e":1},{"u":"https://medium.com/@ivanball76/a-list-page-in-a-few-lines-a-reusable-blazor-ui-framework-with-the-same-discipline-as-the-backend-c66fa16cd561","t":"A reusable Blazor UI framework","d":"Article no. 37","k":"Proof & getting started","x":"A shared Blazor and MudBlazor UI layer with accessibility enforced by axe in CI.","e":1},{"u":"https://medium.com/@ivanball76/soft-delete-vs-the-right-to-erasure-the-gdpr-conflict-and-the-erasure-pathway-e1d350007509","t":"Soft-delete vs the right to erasure","d":"Article no. 36","k":"Proof & getting started","x":"Soft-delete for lifecycle, anonymization plus outbox purge for GDPR/CCPA erasure, and why both exist.","e":1},{"u":"https://medium.com/@ivanball76/the-test-pyramid-not-the-ice-cream-cone-1-880-fast-tests-zero-docker-cb459fda73d9","t":"The test pyramid","d":"Article no. 35","k":"Proof & getting started","x":"How the framework's tests stack up: fast unit and architecture tests at the base, E2E at the tip.","e":1},{"u":"https://medium.com/@ivanball76/architecture-fitness-functions-rules-that-fail-the-build-not-a-wiki-page-6562940deceb","t":"Architecture fitness functions","d":"Article no. 34","k":"Proof & getting started","x":"Architecture rules that fail the build: a compile-time layer guard plus a shared NetArchTest rule library.","e":1},{"u":"https://medium.com/@ivanball76/retries-are-not-a-recovery-plan-resilience-handlers-rto-rpo-and-a-restore-you-actually-drilled-3c7474814123","t":"Resilience and recovery objectives","d":"Article no. 33","k":"Run & extract","x":"Standard resilience on every outbound client, plus declared RTO/RPO and a drilled restore.","e":1},{"u":"https://medium.com/@ivanball76/extracting-a-module-to-a-grpc-service-live-799926cf8a32","t":"Extracting a module to a gRPC service","d":"Article no. 32","k":"Run & extract","x":"A step-by-step extraction of an in-process module into its own gRPC service, database, and auth.","e":1},{"u":"https://medium.com/@ivanball76/aspire-one-command-brings-up-the-whole-distributed-app-379b5cffdeed","t":"Aspire: one command","d":"Article no. 31","k":"Run & extract","x":"Model services, databases, and the broker as one Aspire graph that runs from laptop to Azure with one command.","e":1},{"u":"https://medium.com/@ivanball76/defending-the-api-edge-three-controls-that-cover-the-whole-surface-d958ecae1091","t":"Rate limiting and brute-force protection","d":"Article no. 30","k":"Auth & the edge","x":"Two layers that cover the whole API edge: endpoint rate limits plus lockout-based brute-force defense on identity.","e":1},{"u":"https://medium.com/@ivanball76/resource-ownership-authorization-which-rows-you-may-touch-not-just-which-actions-cb8e78867bae","t":"Resource-ownership authorization","d":"Article no. 29","k":"Auth & the edge","x":"Beyond roles and permissions: which rows you may touch, enforced per resource.","e":1},{"u":"https://medium.com/@ivanball76/generic-entity-controllers-and-the-dynamic-query-contract-adr-034-2b5c799bc69f","t":"Generic entity controllers","d":"Article no. 28","k":"Auth & the edge","x":"A write-once REST surface every entity inherits, plus a bounded dynamic query contract that is never open SQL.","e":1},{"u":"https://medium.com/@ivanball76/one-rotating-refresh-token-and-reuse-detection-that-makes-theft-self-limiting-fab42234a04a","t":"One rotating refresh token","d":"Article no. 27","k":"Auth & the edge","x":"A short-lived JWT plus one server-stored refresh token that rotates on every use, with reuse detection that makes a stolen token end its own session.","e":1},{"u":"https://medium.com/@ivanball76/google-and-github-login-without-leaking-tokens-external-oauth-behind-your-own-jwts-d68ba5e3aca4","t":"External OAuth login behind your own JWTs","d":"Article no. 26","k":"Auth & the edge","x":"Sign in with Google or GitHub without leaking provider tokens: external identity exchanged for your own JWTs at the boundary.","e":1},{"u":"https://medium.com/@ivanball76/browser-session-cookie-auth-for-blazor-ssr-surviving-the-f5-eb0ea317820e","t":"Browser session-cookie auth for Blazor SSR","d":"Article no. 25","k":"Auth & the edge","x":"HttpOnly session cookies and an SSR-time scheme so [Authorize] passes during prerender, with the API still the boundary.","e":1},{"u":"https://medium.com/@ivanball76/permission-based-authorization-capabilities-over-role-checks-ea6574cbee27","t":"Permission-based authorization over roles","d":"Article no. 24","k":"Auth & the edge","x":"A capability layer over RBAC: permission policies that resolve on demand from a central registry.","e":1},{"u":"https://medium.com/@ivanball76/delete-automapper-explicit-compile-time-dto-mapping-that-you-can-actually-test-9c7013cc5d3f","t":"Delete AutoMapper: manual DTO mapping","d":"Article no. 23","k":"Auth & the edge","x":"Why source-generated, per-entity mappers beat reflection-based mapping for clarity and speed.","e":1},{"u":"https://medium.com/@ivanball76/ephemeral-by-design-sub-second-live-channels-over-one-signalr-hub-0248050e0c8b","t":"Live channels over one SignalR hub","d":"Article no. 22","k":"Auth & the edge","x":"Sub-second ephemeral events (polls, Q&A, live counts) fanned out over the existing notification hub, with nothing persisted.","e":1},{"u":"https://medium.com/@ivanball76/notifications-as-a-vertical-slice-in-app-inbox-real-time-push-native-push-and-email-c59d5a4f3b69","t":"Notifications as a vertical slice","d":"Article no. 21","k":"Auth & the edge","x":"A notifications feature built as a clean vertical slice across every layer.","e":1},{"u":"https://medium.com/@ivanball76/problem-details-across-http-and-grpc-rfc-9457-9f20157cf7de","t":"Problem Details across HTTP and gRPC","d":"Article no. 20","k":"Auth & the edge","x":"One error contract mapped consistently to HTTP Problem Details and gRPC status.","e":1},{"u":"https://medium.com/@ivanball76/the-self-invalidating-cache-that-lives-in-the-pipeline-not-your-handlers-e11548062d2f","t":"The self-invalidating cache","d":"Article no. 19","k":"Auth & the edge","x":"A caching decorator where commands invalidate and queries populate, plus an authenticated output-cache tier at the API edge.","e":1},{"u":"https://medium.com/@ivanball76/idempotency-in-one-attribute-safe-retries-for-http-apis-065848fd03f4","t":"Idempotency in one attribute","d":"Article no. 18","k":"Auth & the edge","x":"Dedup client retries with an Idempotency-Key header and cached replay, plus a consumer-side inbox for brokers.","e":1},{"u":"https://medium.com/@ivanball76/password-hashing-done-right-pbkdf2-sha512-600k-iterations-timing-safe-d64ddb802403","t":"Password hashing done right","d":"Article no. 17","k":"Auth & the edge","x":"The non-negotiables of password storage in .NET, done correctly and tested.","e":1},{"u":"https://medium.com/@ivanball76/cross-service-auth-without-a-shared-secret-jwks-dual-fetch-478e6f688c7e","t":"JWKS cross-service auth","d":"Article no. 16","k":"Auth & the edge","x":"Validate another service's RS256 tokens via JWKS discovery, with no shared secret crossing a boundary.","e":1},{"u":"https://medium.com/@ivanball76/event-schema-versioning-never-silently-reshape-an-event-93cd5d4a156d","t":"Event-schema versioning","d":"Article no. 15","k":"Data & persistence","x":"Every integration event carries a schema version; breaking changes get a new event type and an upcaster, never a silent reshape.","e":1},{"u":"https://medium.com/@ivanball76/self-ordering-modules-discovered-kahn-ordered-and-extractable-2ce7283a26b5","t":"Self-ordering modules","d":"Article no. 14","k":"Data & persistence","x":"Modules declare their dependencies and load in topological order, so registration is never hand-sequenced.","e":1},{"u":"https://medium.com/@ivanball76/optimistic-concurrency-that-survives-the-round-trip-rowversion-from-database-to-dto-and-back-93d4a794716f","t":"Optimistic concurrency: RowVersion round-trips","d":"Article no. 13","k":"Data & persistence","x":"Carry the RowVersion from database to DTO and back, so a concurrent edit fails fast as a conflict instead of silently overwriting.","e":1},{"u":"https://medium.com/@ivanball76/ef-core-include-chains-are-a-trap-navigation-populators-decouple-eager-loading-c378fa4497ac","t":"Navigation populators","d":"Article no. 12","k":"Data & persistence","x":"Eager-load relationships that cross containers and data sources without N+1 or a leaky abstraction.","e":1},{"u":"https://medium.com/@ivanball76/one-entity-model-three-databases-polyglot-persistence-behind-a-single-attribute-760e77974d5d","t":"Polyglot persistence: one model, three engines","d":"Article no. 11","k":"Data & persistence","x":"SQL Server, Cosmos, and SQLite behind a single entity model, with the engine chosen by attribute.","e":1},{"u":"https://medium.com/@ivanball76/database-per-service-inside-a-monolith-and-why-265092eb03f1","t":"Database-per-service inside a monolith","d":"Article no. 10","k":"Data & persistence","x":"Give each module its own database and outbox before you extract it, so extraction changes hosting, not data.","e":1},{"u":"https://medium.com/@ivanball76/the-transactional-outbox-in-net-10-never-lose-an-event-again-f5a9b7a89e51","t":"The transactional outbox","d":"Article no. 9","k":"Core patterns","x":"Events that survive a crash: persist them atomically with your data, then dispatch at least once.","e":1},{"u":"https://medium.com/@ivanball76/compose-validators-dont-copy-them-a-reusable-fluentvalidation-kit-8865a6003a9c","t":"Compose validators, don't copy them","d":"Article no. 8","k":"Core patterns","x":"A validation kit that composes FluentValidation rules instead of copy-pasting them across features.","e":1},{"u":"https://medium.com/@ivanball76/the-cqrs-decorator-pipeline-logging-caching-and-transactions-without-touching-a-handler-fb7679b8bde8","t":"The CQRS decorator pipeline","d":"Article no. 7","k":"Core patterns","x":"Thin command and query handlers wrapped by a Scrutor decorator chain whose order is load-bearing.","e":1},{"u":"https://medium.com/@ivanball76/specifications-over-linq-spaghetti-composable-reusable-query-intent-8a40dafcbd3d","t":"Specifications over LINQ spaghetti","d":"Article no. 6","k":"Core patterns","x":"Compose queries from reusable specification objects instead of scattering LINQ across handlers.","e":1},{"u":"https://medium.com/@ivanball76/kill-the-anemic-domain-model-rich-aggregates-with-factory-methods-that-return-result-44f2e3d89794","t":"Kill the anemic domain model","d":"Article no. 5","k":"Core patterns","x":"Push behavior into rich aggregates with factory methods and invariants instead of bags of public setters.","e":1},{"u":"https://medium.com/@ivanball76/stop-throwing-exceptions-for-control-flow-the-result-railway-in-c-7a02050b554e","t":"The Result railway in C#","d":"Article no. 4","k":"Core patterns","x":"Model expected failures as Result values with a transport-agnostic error type, and keep exceptions for the genuinely exceptional.","e":1},{"u":"https://medium.com/@ivanball76/what-good-architecture-actually-means-a-34-category-rubric-you-can-score-yourself-against-4002291a6b6a","t":"The 34-category architecture rubric","d":"Article no. 3","k":"Orientation","x":"A two-axis rubric for scoring architecture on maturity and implementation, so 'good architecture' stops being a vibe.","e":1},{"u":"https://medium.com/@ivanball76/modular-monolith-to-microservices-without-the-rewrite-8c3603614f12","t":"Modular monolith to microservices","d":"Article no. 2","k":"Orientation","x":"The cornerstone idea: build the monolith now and extract a service later with no rewrite, via module discovery, gRPC contracts, and a YARP gateway.","e":1},{"u":"https://medium.com/@ivanball76/i-open-sourced-the-enterprise-net-77f9200f3728","t":"Open-sourced and graded against 34 categories","d":"Article no. 1","k":"Orientation","x":"Why I open-sourced a production .NET framework and scored it against a 34-category architecture rubric, gaps and all.","e":1}]} \ No newline at end of file diff --git a/docs-src/onboarding/00-dependency-manifest.md b/docs-src/onboarding/00-dependency-manifest.md index 96a1ff5..9a9d682 100644 --- a/docs-src/onboarding/00-dependency-manifest.md +++ b/docs-src/onboarding/00-dependency-manifest.md @@ -16,10 +16,10 @@ candidate exists but the bare name is **globally unique** among first-party type is still linked (only one possible target). Names that are neither visible nor unique are dropped as unresolvable without full semantic binding. -- Edges resolved by namespace visibility: **12293** (~96%) -- Edges resolved by globally-unique name (fallback): **493** +- Edges resolved by namespace visibility: **13146** (~96%) +- Edges resolved by globally-unique name (fallback): **540** - References dropped as ambiguous (matched >1 type, none visible): **29** -- Sensitivity: **518 / 3465** type levels would change if the globally-unique fallback +- Sensitivity: **753 / 3668** type levels would change if the globally-unique fallback were excluded; the fallback is retained because a globally-unique first-party name is unambiguous, so excluding it would under-count real dependencies. @@ -32,28 +32,28 @@ alias `using`s name a target whose bare name already matches (so they resolve re | Level | Distinct types | |-------|------| -| 0 | 708 | -| 1 | 422 | -| 2 | 268 | -| 3 | 241 | -| 4 | 296 | -| 5 | 224 | -| 6 | 125 | -| 7 | 138 | -| 8 | 267 | -| 9 | 251 | -| 10 | 275 | -| 11 | 101 | -| 12 | 32 | -| 13 | 17 | -| 14 | 11 | -| 15 | 7 | -| 16 | 6 | -| 17 | 15 | -| 18 | 60 | +| 0 | 729 | +| 1 | 430 | +| 2 | 273 | +| 3 | 265 | +| 4 | 322 | +| 5 | 231 | +| 6 | 120 | +| 7 | 107 | +| 8 | 213 | +| 9 | 245 | +| 10 | 205 | +| 11 | 73 | +| 12 | 57 | +| 13 | 108 | +| 14 | 55 | +| 15 | 137 | +| 16 | 14 | +| 17 | 20 | +| 18 | 63 | | 19 | 1 | -## Cycles (SCC size > 1): 30 +## Cycles (SCC size > 1): 34 | Level | Size | Members | |-------|------|---------| @@ -64,29 +64,33 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 2 | 2 | Service:SelfHttpWarmupTask, Service:SelfHttpWarmupTask | | 3 | 3 | Shared:Enumeration, Shared:EnumerationJsonConverterFactory, Shared:EnumerationConverter | | 3 | 2 | Shared:Currency, Shared:CurrencyJsonConverter | +| 3 | 2 | API:InsecureJwtMetadataWarningStartupFilter, API:WebApplicationBuilderExtensions | | 3 | 2 | Shared:Address, Shared:AddressInvariants | +| 4 | 4 | Tests:AnonymousEndpointTestsBaseTests, Tests:DriftedTests, Tests:StaleAllowListTests, Tests:ConformantTests | | 4 | 2 | Tests:Priority, Tests:Priority | | 5 | 2 | Tests:CancellationTokenFitnessTests, Tests:CancellationTestMap | | 5 | 2 | Tests:IdempotencyFitnessTests, Tests:IdempotencyTestMap | | 5 | 2 | Tests:NamespaceCycleFitnessTests, Tests:CycleTestMap | | 5 | 2 | Tests:DegradeOrder, Tests:DegradeCustomer | -| 6 | 8 | Infrastructure:AuditTrailSaveChangesInterceptor, Infrastructure:ApplicationDbContext, Infrastructure:DataSourceModelCacheKeyFactory, Infrastructure:AuditSaveChangesInterceptor, Infrastructure:DomainEventSaveChangesInterceptor, Infrastructure:DeferredDispatch, Infrastructure:TenantSaveChangesInterceptor, Infrastructure:OutboxFinalizer | | 6 | 3 | Domain:Category, Domain:CategoryInvariants, Domain:CategoryItem | | 6 | 2 | Tests:ModelBuilderExtensionsTests, Tests:TestModelBuilderDbContext | | 6 | 2 | Domain:LeaderboardOptIn, Domain:LeaderboardOptInInvariants | | 7 | 4 | Domain:Event, Domain:EventQuestionAnswer, Domain:EventSpeaker, Domain:Room | | 7 | 3 | Domain:Speaker, Domain:SpeakerCategoryItem, Domain:SpeakerQuestionAnswer | | 7 | 2 | Tests:SpecificationFitnessTests, Tests:SpecTestMap | -| 7 | 2 | Tests:MidSaveContextCreatingDbContext, Tests:ReentrantSaveInterceptor | -| 7 | 2 | Tests:CommitFailingDbContext, Tests:FailingDatabaseFacade | -| 7 | 2 | Tests:FailingSaveInterceptor, Tests:OutboxRoutingTestDbContext | -| 7 | 2 | Tests:GateTestContext, Tests:GateTestContext | | 8 | 4 | Domain:Session, Domain:SessionCategoryItem, Domain:SessionQuestionAnswer, Domain:SessionSpeaker | -| 8 | 2 | Tests:EventScopeFitnessTests, Tests:FakeConsumerMap | -| 8 | 2 | Tests:AuditTrailTestContext, Tests:FailingSaveInterceptor | | 8 | 2 | Domain:LivePoll, Domain:LivePollOption | -| 10 | 2 | Tests:DatabaseInitializationExtensionsTests, Tests:FixedAssemblyProvider | -| 11 | 3 | Tests:CosmosConfigurationPortabilityTests, Tests:FixedAssemblyProvider, Tests:MultiSourceSqliteIntegrationTests | +| 10 | 2 | API:MiddlewarePipelineBuilder, API:WebApplicationExtensions | +| 11 | 8 | Infrastructure:AuditTrailSaveChangesInterceptor, Infrastructure:ApplicationDbContext, Infrastructure:DataSourceModelCacheKeyFactory, Infrastructure:AuditSaveChangesInterceptor, Infrastructure:DomainEventSaveChangesInterceptor, Infrastructure:DeferredDispatch, Infrastructure:TenantSaveChangesInterceptor, Infrastructure:OutboxFinalizer | +| 12 | 2 | Tests:MidSaveContextCreatingDbContext, Tests:ReentrantSaveInterceptor | +| 12 | 2 | Tests:CommitFailingDbContext, Tests:FailingDatabaseFacade | +| 12 | 2 | Tests:FailingSaveInterceptor, Tests:OutboxRoutingTestDbContext | +| 12 | 2 | Tests:GateTestContext, Tests:GateTestContext | +| 13 | 2 | Tests:EventScopeFitnessTests, Tests:FakeConsumerMap | +| 13 | 2 | Tests:EventUpcasterFitnessTests, Tests:UpcasterTestMap | +| 13 | 2 | Tests:AuditTrailTestContext, Tests:FailingSaveInterceptor | +| 14 | 3 | Tests:CosmosConfigurationPortabilityTests, Tests:FixedAssemblyProvider, Tests:MultiSourceSqliteIntegrationTests | +| 14 | 2 | Tests:DatabaseInitializationExtensionsTests, Tests:FixedAssemblyProvider | ## Manifest (by level, then assembly) @@ -107,6 +111,9 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 0 | `UpdateEventQuestionAnswerRequest` | MMCA.ADC.Conference.API | 0 | (none) | | 0 | `UpdateRoomRequest` | MMCA.ADC.Conference.API | 0 | (none) | | 0 | `UpdateSessionQuestionAnswerRequest` | MMCA.ADC.Conference.API | 0 | (none) | +| 0 | `ActivityEventIdRules` | MMCA.ADC.Conference.Application | 0 | (none) | +| 0 | `ActivitySortOrderRules` | MMCA.ADC.Conference.Application | 0 | (none) | +| 0 | `ActivityTimeRangeRules` | MMCA.ADC.Conference.Application | 0 | (none) | | 0 | `AssemblyReference` | MMCA.ADC.Conference.Application | 0 | (none) | | 0 | `CategoryItemSortRules` | MMCA.ADC.Conference.Application | 0 | (none) | | 0 | `ClassReference` | MMCA.ADC.Conference.Application | 0 | (none) | @@ -115,7 +122,9 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 0 | `ExportSessionCalendarQuery` | MMCA.ADC.Conference.Application | 0 | (none) | | 0 | `GetCategoryDistributionQuery` | MMCA.ADC.Conference.Application | 0 | (none) | | 0 | `GetContentSimilarityQuery` | MMCA.ADC.Conference.Application | 0 | (none) | +| 0 | `GetPublicActivityFilterQuery` | MMCA.ADC.Conference.Application | 0 | (none) | | 0 | `GetPublicEventSpeakerFilterQuery` | MMCA.ADC.Conference.Application | 0 | (none) | +| 0 | `GetPublicRoomFilterQuery` | MMCA.ADC.Conference.Application | 0 | (none) | | 0 | `GetPublicSessionCategoryItemFilterQuery` | MMCA.ADC.Conference.Application | 0 | (none) | | 0 | `GetPublicSessionFilterQuery` | MMCA.ADC.Conference.Application | 0 | (none) | | 0 | `GetPublicSessionSpeakerFilterQuery` | MMCA.ADC.Conference.Application | 0 | (none) | @@ -195,8 +204,10 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 0 | `ConferenceTrackInfo` | MMCA.ADC.Conference.UI | 0 | (none) | | 0 | `EventInfo` | MMCA.ADC.Conference.UI | 0 | (none) | | 0 | `EventPhase` | MMCA.ADC.Conference.UI | 0 | (none) | +| 0 | `InfiniteScrollSentinel` | MMCA.ADC.Conference.UI | 0 | (none) | | 0 | `IPublicLinkBuilder` | MMCA.ADC.Conference.UI | 0 | (none) | | 0 | `KeynoteSpeakerInfo` | MMCA.ADC.Conference.UI | 0 | (none) | +| 0 | `PreConferenceWorkshopInfo` | MMCA.ADC.Conference.UI | 0 | (none) | | 0 | `ScorePollSignal` | MMCA.ADC.Conference.UI | 0 | (none) | | 0 | `SessionSelectionDisplay` | MMCA.ADC.Conference.UI | 0 | (none) | | 0 | `SpeakerInfo` | MMCA.ADC.Conference.UI | 0 | (none) | @@ -377,7 +388,6 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 0 | `UserNotificationExportItemDTO` | MMCA.ADC.Notification.Shared | 0 | (none) | | 0 | `FakeServerCallContext` | MMCA.ADC.Services.Tests | 0 | (none) | | 0 | `NowNextSession` | MMCA.ADC.UI | 0 | (none) | -| 0 | `WebAuthenticatorCallbackActivity` | MMCA.ADC.UI | 0 | (none) | | 0 | `AllowMissingOwnerAttribute` | MMCA.Common.API | 0 | (none) | | 0 | `ApiParameterDescriptorBackfillProvider` | MMCA.Common.API | 0 | (none) | | 0 | `AppAssociationOptions` | MMCA.Common.API | 0 | (none) | @@ -396,6 +406,8 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 0 | `IdempotencyRecord` | MMCA.Common.API | 0 | (none) | | 0 | `IdempotencySettings` | MMCA.Common.API | 0 | (none) | | 0 | `IErrorLocalizer` | MMCA.Common.API | 0 | (none) | +| 0 | `MiddlewarePipelineStep` | MMCA.Common.API | 0 | (none) | +| 0 | `MiddlewarePipelineStepNames` | MMCA.Common.API | 0 | (none) | | 0 | `NonIdempotentAttribute` | MMCA.Common.API | 0 | (none) | | 0 | `OpenApiEndpointExtensions` | MMCA.Common.API | 0 | (none) | | 0 | `OperationCanceledExceptionHandler` | MMCA.Common.API | 0 | (none) | @@ -416,7 +428,9 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 0 | `FakeCategoriesController` | MMCA.Common.API.Tests | 0 | (none) | | 0 | `NextDelegateSpy` | MMCA.Common.API.Tests | 0 | (none) | | 0 | `NonSeekableStream` | MMCA.Common.API.Tests | 0 | (none) | +| 0 | `ProbeControllerFeatureProvider` | MMCA.Common.API.Tests | 0 | (none) | | 0 | `SingleServiceProvider` | MMCA.Common.API.Tests | 0 | (none) | +| 0 | `StubHostEnvironment` | MMCA.Common.API.Tests | 0 | (none) | | 0 | `StubHttpClientFactory` | MMCA.Common.API.Tests | 0 | (none) | | 0 | `StubHttpMessageHandler` | MMCA.Common.API.Tests | 0 | (none) | | 0 | `SubjectSnapshot` | MMCA.Common.API.Tests | 0 | (none) | @@ -472,6 +486,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 0 | `NonNegativeIntRules` | MMCA.Common.Application | 0 | (none) | | 0 | `OptionalStringRules` | MMCA.Common.Application | 0 | (none) | | 0 | `PagingMath` | MMCA.Common.Application | 0 | (none) | +| 0 | `PasswordResetSettings` | MMCA.Common.Application | 0 | (none) | | 0 | `PasswordRules` | MMCA.Common.Application | 0 | (none) | | 0 | `PositiveDecimalRules` | MMCA.Common.Application | 0 | (none) | | 0 | `PositiveIntRules` | MMCA.Common.Application | 0 | (none) | @@ -518,7 +533,9 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 0 | `UnguardedCommand` | MMCA.Common.Application.Tests | 0 | (none) | | 0 | `UnguardedQuery` | MMCA.Common.Application.Tests | 0 | (none) | | 0 | `Widget` | MMCA.Common.Application.Tests | 0 | (none) | +| 0 | `AbstractAnonymousFixtureControllerBase` | MMCA.Common.Architecture.Tests | 0 | (none) | | 0 | `AbstractFitnessControllerBase` | MMCA.Common.Architecture.Tests | 0 | (none) | +| 0 | `AnonymousFixtureController` | MMCA.Common.Architecture.Tests | 0 | (none) | | 0 | `CompliantFixtureService` | MMCA.Common.Architecture.Tests | 0 | (none) | | 0 | `ExemptableFixtureService` | MMCA.Common.Architecture.Tests | 0 | (none) | | 0 | `ExternalContractFixtureService` | MMCA.Common.Architecture.Tests | 0 | (none) | @@ -529,11 +546,11 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 0 | `MisplacedTokenFixtureService` | MMCA.Common.Architecture.Tests | 0 | (none) | | 0 | `MissingTokenFixtureService` | MMCA.Common.Architecture.Tests | 0 | (none) | | 0 | `NonIdempotentFitnessController` | MMCA.Common.Architecture.Tests | 0 | (none) | +| 0 | `TypeLevelAnonymousFixtureController` | MMCA.Common.Architecture.Tests | 0 | (none) | | 0 | `UndeclaredFitnessController` | MMCA.Common.Architecture.Tests | 0 | (none) | | 0 | `CspPolicy` | MMCA.Common.Aspire | 0 | (none) | | 0 | `DataProtectionExtensions` | MMCA.Common.Aspire | 0 | (none) | | 0 | `DownstreamServiceHealthCheck` | MMCA.Common.Aspire | 0 | (none) | -| 0 | `GatewayCorrelationMiddleware` | MMCA.Common.Aspire | 0 | (none) | | 0 | `GatewayCorsExtensions` | MMCA.Common.Aspire | 0 | (none) | | 0 | `GatewayDownstreamRegistry` | MMCA.Common.Aspire | 0 | (none) | | 0 | `GatewayRateLimitingSettings` | MMCA.Common.Aspire | 0 | (none) | @@ -541,7 +558,6 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 0 | `IWarmupTask` | MMCA.Common.Aspire | 0 | (none) | | 0 | `KestrelListenerSpec` | MMCA.Common.Aspire | 0 | (none) | | 0 | `KeyVaultConfigurationExtensions` | MMCA.Common.Aspire | 0 | (none) | -| 0 | `OutboxPollFilterProcessor` | MMCA.Common.Aspire | 0 | (none) | | 0 | `SecurityHeadersSettings` | MMCA.Common.Aspire | 0 | (none) | | 0 | `WarmupReadinessGate` | MMCA.Common.Aspire | 0 | (none) | | 0 | `Extensions` | MMCA.Common.Aspire.Hosting | 0 | (none) | @@ -554,6 +570,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 0 | `SourceCollectingConfigurationManager` | MMCA.Common.Aspire.Tests | 0 | (none) | | 0 | `StubHandler` | MMCA.Common.Aspire.Tests | 0 | (none) | | 0 | `StubHostEnvironment` | MMCA.Common.Aspire.Tests | 0 | (none) | +| 0 | `StubHostEnvironment` | MMCA.Common.Aspire.Tests | 0 | (none) | | 0 | `StubHttpClientFactory` | MMCA.Common.Aspire.Tests | 0 | (none) | | 0 | `StubLoggingBuilder` | MMCA.Common.Aspire.Tests | 0 | (none) | | 0 | `StubMetricsBuilder` | MMCA.Common.Aspire.Tests | 0 | (none) | @@ -629,6 +646,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 0 | `NativePushSettings` | MMCA.Common.Infrastructure | 0 | (none) | | 0 | `OutboxCycleResult` | MMCA.Common.Infrastructure | 0 | (none) | | 0 | `OutboxMetrics` | MMCA.Common.Infrastructure | 0 | (none) | +| 0 | `PasswordResetEntry` | MMCA.Common.Infrastructure | 0 | (none) | | 0 | `PeriodicBackgroundService` | MMCA.Common.Infrastructure | 0 | (none) | | 0 | `PersistenceSettings` | MMCA.Common.Infrastructure | 0 | (none) | | 0 | `ProfilingHelper` | MMCA.Common.Infrastructure | 0 | (none) | @@ -667,6 +685,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 0 | `DomainException` | MMCA.Common.Shared | 0 | (none) | | 0 | `DomainHelper` | MMCA.Common.Shared | 0 | (none) | | 0 | `ErrorType` | MMCA.Common.Shared | 0 | (none) | +| 0 | `ForgotPasswordRequest` | MMCA.Common.Shared | 0 | (none) | | 0 | `HttpResilienceDefaults` | MMCA.Common.Shared | 0 | (none) | | 0 | `IBaseDTO` | MMCA.Common.Shared | 0 | (none) | | 0 | `IConcurrencyAware` | MMCA.Common.Shared | 0 | (none) | @@ -683,6 +702,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 0 | `PropertyReader` | MMCA.Common.Shared | 0 | (none) | | 0 | `RefreshTokenRequest` | MMCA.Common.Shared | 0 | (none) | | 0 | `Releaser` | MMCA.Common.Shared | 0 | (none) | +| 0 | `ResetPasswordRequest` | MMCA.Common.Shared | 0 | (none) | | 0 | `RoleNames` | MMCA.Common.Shared | 0 | (none) | | 0 | `SendPushNotificationRequest` | MMCA.Common.Shared | 0 | (none) | | 0 | `ServiceContractAttribute` | MMCA.Common.Shared | 0 | (none) | @@ -702,6 +722,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 0 | `ProductionHostApplicationFactory` | MMCA.Common.Testing | 0 | (none) | | 0 | `SecurityHeadersTestsBase` | MMCA.Common.Testing | 0 | (none) | | 0 | `TestPolling` | MMCA.Common.Testing | 0 | (none) | +| 0 | `AnonymousEndpointTestsBase` | MMCA.Common.Testing.Architecture | 0 | (none) | | 0 | `ArchitectureAssert` | MMCA.Common.Testing.Architecture | 0 | (none) | | 0 | `BrandColorTokenTestsBase` | MMCA.Common.Testing.Architecture | 0 | (none) | | 0 | `CrossEntityNavigationFinder` | MMCA.Common.Testing.Architecture | 0 | (none) | @@ -715,9 +736,11 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 0 | `AdminCredentials` | MMCA.Common.Testing.E2E | 0 | (none) | | 0 | `AxeOptions` | MMCA.Common.Testing.E2E | 0 | (none) | | 0 | `E2ETestConfiguration` | MMCA.Common.Testing.E2E | 0 | (none) | +| 0 | `ForgotPasswordPage` | MMCA.Common.Testing.E2E | 0 | (none) | | 0 | `LoginPage` | MMCA.Common.Testing.E2E | 0 | (none) | | 0 | `ProfilePage` | MMCA.Common.Testing.E2E | 0 | (none) | | 0 | `RegisterPage` | MMCA.Common.Testing.E2E | 0 | (none) | +| 0 | `ResetPasswordPage` | MMCA.Common.Testing.E2E | 0 | (none) | | 0 | `UserCredentials` | MMCA.Common.Testing.E2E | 0 | (none) | | 0 | `WebVitalsSample` | MMCA.Common.Testing.E2E | 0 | (none) | | 0 | `FakeHandler` | MMCA.Common.Testing.Tests | 0 | (none) | @@ -739,6 +762,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 0 | `ChannelReferenceCounter` | MMCA.Common.UI | 0 | (none) | | 0 | `CultureDelegatingHandler` | MMCA.Common.UI | 0 | (none) | | 0 | `DevicePreferenceKeys` | MMCA.Common.UI | 0 | (none) | +| 0 | `ForgotPasswordModel` | MMCA.Common.UI | 0 | (none) | | 0 | `GeoPoint` | MMCA.Common.UI | 0 | (none) | | 0 | `IAccessibilityAnnouncer` | MMCA.Common.UI | 0 | (none) | | 0 | `IApiSettings` | MMCA.Common.UI | 0 | (none) | @@ -784,6 +808,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 0 | `PushDeviceToken` | MMCA.Common.UI | 0 | (none) | | 0 | `QrErrorCorrectionLevel` | MMCA.Common.UI | 0 | (none) | | 0 | `RegisterModel` | MMCA.Common.UI | 0 | (none) | +| 0 | `ResetPasswordModel` | MMCA.Common.UI | 0 | (none) | | 0 | `ReturnUrlProtector` | MMCA.Common.UI | 0 | (none) | | 0 | `RoutePaths` | MMCA.Common.UI | 0 | (none) | | 0 | `SharedResource` | MMCA.Common.UI | 0 | (none) | @@ -804,6 +829,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 1 | `ObservabilityConventionTests` | MMCA.ADC.Architecture.Tests | 1 | ObservabilityConventionTestsBase | | 1 | `ConferenceErrorResourcesTests` | MMCA.ADC.Conference.API.Tests | 2 | ConferenceErrorResources, IErrorLocalizer | | 1 | `ConferencePermissionGrantsTests` | MMCA.ADC.Conference.API.Tests | 3 | ConferencePermissions, IPermissionRegistry, RoleNames | +| 1 | `ActivityUpdateRequest` | MMCA.ADC.Conference.Application | 1 | IConcurrencyAware | | 1 | `ConferenceCategoryUpdateRequest` | MMCA.ADC.Conference.Application | 1 | IConcurrencyAware | | 1 | `EventUpdateRequest` | MMCA.ADC.Conference.Application | 2 | IConcurrencyAware, QuestionModerationDefault | | 1 | `ISessionScoringQueue` | MMCA.ADC.Conference.Application | 1 | SessionScoringEnqueueResult | @@ -821,6 +847,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 1 | `Handle` | MMCA.ADC.Conference.Infrastructure.Tests | 1 | RecordingDistributedLock | | 1 | `RecordingDistributedLock` | MMCA.ADC.Conference.Infrastructure.Tests | 2 | Handle, IDistributedLock | | 1 | `FakeBookmarkCountService` | MMCA.ADC.Conference.IntegrationTests | 1 | IBookmarkCountService | +| 1 | `ActivityDTO` | MMCA.ADC.Conference.Shared | 2 | IBaseDTO, IConcurrencyAware | | 1 | `CategoryGroupDistribution` | MMCA.ADC.Conference.Shared | 1 | CategoryItemDistribution | | 1 | `CategoryItemDTO` | MMCA.ADC.Conference.Shared | 1 | IBaseDTO | | 1 | `ConferenceReadAudience` | MMCA.ADC.Conference.Shared | 1 | RoleNames | @@ -890,6 +917,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 1 | `HttpContextExternalLoginEmailVerifier` | MMCA.ADC.Identity.API | 2 | ExternalAuthExtensions, IExternalLoginEmailVerifier | | 1 | `IdentityErrorResourcesTests` | MMCA.ADC.Identity.API.Tests | 2 | IdentityErrorResources, IErrorLocalizer | | 1 | `ChangePasswordRequestValidator` | MMCA.ADC.Identity.Application | 2 | ChangePasswordRequest, StrongPasswordRules | +| 1 | `ForgotPasswordCommand` | MMCA.ADC.Identity.Application | 2 | ForgotPasswordRequest, ICommandWithRequest | | 1 | `PiiCaptureLogger` | MMCA.ADC.Identity.IntegrationTests | 1 | PiiLogCapture | | 1 | `DisabledAttendeeQueryService` | MMCA.ADC.Identity.Shared | 1 | IAttendeeQueryService | | 1 | `UserDataExportEngagementSectionDTO` | MMCA.ADC.Identity.Shared | 4 | UserDataExportBookmarkDTO, UserDataExportCheckInDTO, UserDataExportPointsEntryDTO, UserDataExportSubmittedQuestionDTO | @@ -907,7 +935,6 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 1 | `NowNextSnapshot` | MMCA.ADC.UI | 1 | NowNextSession | | 1 | `ADCHomePageContent` | MMCA.ADC.UI.Web.Client | 1 | IHomePageContent | | 1 | `AppAssociationEndpointExtensions` | MMCA.Common.API | 1 | AppAssociationOptions | -| 1 | `CorrelationIdMiddleware` | MMCA.Common.API | 1 | ICorrelationContext | | 1 | `DomainExceptionHandler` | MMCA.Common.API | 1 | DomainException | | 1 | `ErrorLocalizer` | MMCA.Common.API | 2 | ErrorResourceSource, IErrorLocalizer | | 1 | `HasPermissionAttribute` | MMCA.Common.API | 1 | PermissionPolicy | @@ -928,7 +955,9 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 1 | `ExportTestDTO` | MMCA.Common.API.Tests | 1 | IBaseDTO | | 1 | `ExternalAuthExtensionsTests` | MMCA.Common.API.Tests | 1 | ExternalAuthExtensions | | 1 | `IdempotencySettingsTests` | MMCA.Common.API.Tests | 1 | IdempotencySettings | +| 1 | `OpenApiProbeHost` | MMCA.Common.API.Tests | 1 | ProbeControllerFeatureProvider | | 1 | `PlainDTO` | MMCA.Common.API.Tests | 1 | IBaseDTO | +| 1 | `ProblemDetailsProbeController` | MMCA.Common.API.Tests | 1 | Route | | 1 | `PublicEndpointOutputCachePolicyTests` | MMCA.Common.API.Tests | 1 | PublicEndpointOutputCachePolicy | | 1 | `QueryFilterModelBinderTests` | MMCA.Common.API.Tests | 1 | QueryFilterModelBinder | | 1 | `SegmentVersionedProbeController` | MMCA.Common.API.Tests | 1 | Route | @@ -938,6 +967,8 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 1 | `TestCreateRequest` | MMCA.Common.API.Tests | 1 | ICreateRequest | | 1 | `TestDomainException` | MMCA.Common.API.Tests | 1 | DomainException | | 1 | `TestDTO` | MMCA.Common.API.Tests | 1 | IBaseDTO | +| 1 | `TestForgotPasswordCommand` | MMCA.Common.API.Tests | 2 | ForgotPasswordRequest, ICommandWithRequest | +| 1 | `TestResetPasswordCommand` | MMCA.Common.API.Tests | 2 | ICommandWithRequest, ResetPasswordRequest | | 1 | `UnboundRouteTokenProbeController` | MMCA.Common.API.Tests | 1 | Route | | 1 | `UpdateThingRequest` | MMCA.Common.API.Tests | 1 | IConcurrencyAware | | 1 | `VersionedDTO` | MMCA.Common.API.Tests | 2 | IBaseDTO, IConcurrencyAware | @@ -949,6 +980,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 1 | `DateTimeFilterStrategy` | MMCA.Common.Application | 3 | DynamicQueryConfig, FilterValueParser, IFilterStrategy | | 1 | `DecimalFilterStrategy` | MMCA.Common.Application | 3 | DynamicQueryConfig, FilterValueParser, IFilterStrategy | | 1 | `DeleteEntityCommand` | MMCA.Common.Application | 1 | ICacheInvalidating | +| 1 | `ForgotPasswordRequestValidator` | MMCA.Common.Application | 1 | ForgotPasswordRequest | | 1 | `GetUserPreferencesQuery` | MMCA.Common.Application | 1 | IUserScopedRequest | | 1 | `GuidFilterStrategy` | MMCA.Common.Application | 3 | DynamicQueryConfig, FilterValueParser, IFilterStrategy | | 1 | `IAuditTrailReader` | MMCA.Common.Application | 1 | AuditTrailEntryDTO | @@ -965,6 +997,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 1 | `ProfilingCommandDecorator` | MMCA.Common.Application | 1 | ICommandHandler | | 1 | `ProfilingQueryDecorator` | MMCA.Common.Application | 1 | IQueryHandler | | 1 | `RefreshTokenRequestValidator` | MMCA.Common.Application | 1 | RefreshTokenRequest | +| 1 | `ResetPasswordRequestValidator` | MMCA.Common.Application | 2 | ResetPasswordRequest, StrongPasswordRules | | 1 | `SendPushNotificationCommand` | MMCA.Common.Application | 2 | ICommandWithRequest, SendPushNotificationRequest | | 1 | `StringFilterStrategy` | MMCA.Common.Application | 3 | DynamicQueryConfig, FilterValueParser, IFilterStrategy | | 1 | `TenantCacheKey` | MMCA.Common.Application | 1 | ITenantContext | @@ -1004,17 +1037,19 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 1 | `ResolvedEntityDTO` | MMCA.Common.Application.Tests | 1 | IBaseDTO | | 1 | `StampedeTestQuery` | MMCA.Common.Application.Tests | 1 | IQueryCacheable | | 1 | `TestCommandWithRequest` | MMCA.Common.Application.Tests | 2 | ICommandWithRequest, TestRequest | +| 1 | `TestForgotPasswordCommand` | MMCA.Common.Application.Tests | 2 | ForgotPasswordRequest, ICommandWithRequest | | 1 | `TestRequestValidator` | MMCA.Common.Application.Tests | 1 | TestRequest | +| 1 | `TestResetPasswordCommand` | MMCA.Common.Application.Tests | 2 | ICommandWithRequest, ResetPasswordRequest | | 1 | `TestStrategy` | MMCA.Common.Application.Tests | 1 | IFilterStrategy | | 1 | `TransactionalCommand` | MMCA.Common.Application.Tests | 1 | ITransactional | | 1 | `TransactionalPipelineTestCommand` | MMCA.Common.Application.Tests | 1 | ITransactional | | 1 | `ValidationFailureExtensionsTests` | MMCA.Common.Application.Tests | 1 | ErrorType | | 1 | `DisabledFakeExportService` | MMCA.Common.Architecture.Tests | 1 | IFakeExportService | | 1 | `InheritingFitnessController` | MMCA.Common.Architecture.Tests | 1 | AbstractFitnessControllerBase | +| 1 | `InheritingFixtureController` | MMCA.Common.Architecture.Tests | 1 | AbstractAnonymousFixtureControllerBase | | 1 | `NavigationContractTests` | MMCA.Common.Architecture.Tests | 1 | UISharedAssemblyReference | | 1 | `ObservabilityConventionTestsBaseTests` | MMCA.Common.Architecture.Tests | 1 | ObservabilityConventionTestsBase | | 1 | `RightModel` | MMCA.Common.Architecture.Tests | 1 | LeftModelBase | -| 1 | `GatewayCorrelationExtensions` | MMCA.Common.Aspire | 1 | GatewayCorrelationMiddleware | | 1 | `GatewayHealthCheckExtensions` | MMCA.Common.Aspire | 3 | DownstreamServiceHealthCheck, GatewayDownstreamRegistry, HealthCheckTags | | 1 | `GatewayRateLimitingExtensions` | MMCA.Common.Aspire | 1 | GatewayRateLimitingSettings | | 1 | `ICspPolicyProvider` | MMCA.Common.Aspire | 1 | CspPolicy | @@ -1023,9 +1058,8 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 1 | `SelfHttpWarmupTaskBase` | MMCA.Common.Aspire | 1 | IWarmupTask | | 1 | `WarmupHostedService` | MMCA.Common.Aspire | 2 | IWarmupTask, WarmupReadinessGate | | 1 | `WarmupReadinessHealthCheck` | MMCA.Common.Aspire | 1 | WarmupReadinessGate | -| 1 | `GatewayCorrelationMiddlewareTests` | MMCA.Common.Aspire.Tests | 2 | GatewayCorrelationMiddleware, RecordingHttpResponseFeature | +| 1 | `GatewayCorsExtensionsTests` | MMCA.Common.Aspire.Tests | 1 | StubHostEnvironment | | 1 | `HangingTask` | MMCA.Common.Aspire.Tests | 1 | IWarmupTask | -| 1 | `OutboxPollFilterProcessorTests` | MMCA.Common.Aspire.Tests | 1 | OutboxPollFilterProcessor | | 1 | `RecordingTask` | MMCA.Common.Aspire.Tests | 1 | IWarmupTask | | 1 | `SourceCollectingHostApplicationBuilder` | MMCA.Common.Aspire.Tests | 4 | SourceCollectingConfigurationManager, StubHostEnvironment, StubLoggingBuilder, StubMetricsBuilder | | 1 | `ThrowingTask` | MMCA.Common.Aspire.Tests | 1 | IWarmupTask | @@ -1060,7 +1094,6 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 1 | `NullLiveChannelPublisher` | MMCA.Common.Infrastructure | 1 | ILiveChannelPublisher | | 1 | `NullNativePushSender` | MMCA.Common.Infrastructure | 1 | INativePushSender | | 1 | `NullPushNotificationSender` | MMCA.Common.Infrastructure | 1 | IPushNotificationSender | -| 1 | `OutboxMessage` | MMCA.Common.Infrastructure | 1 | IDomainEvent | | 1 | `OutboxSignal` | MMCA.Common.Infrastructure | 1 | IOutboxSignal | | 1 | `PasswordHasher` | MMCA.Common.Infrastructure | 1 | IPasswordHasher | | 1 | `PendingEntityKey` | MMCA.Common.Infrastructure | 1 | AuditTrailEntry | @@ -1316,12 +1349,12 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 2 | `OidcDiscoveryEndpointExtensions` | MMCA.Common.API | 1 | JwksEndpointExtensions | | 2 | `SessionCookieEndpoints` | MMCA.Common.API | 4 | ICookieSessionRefresher, SessionCookieJar, SessionCookieRequest, SessionTokenResponse | | 2 | `SessionCookieJar` | MMCA.Common.API | 1 | SessionCookieEndpoints | +| 2 | `ApiParameterDescriptorBackfillProviderTests` | MMCA.Common.API.Tests | 4 | ApiParameterDescriptorBackfillProvider, OpenApiProbeHost, SegmentVersionedProbeController, UnboundRouteTokenProbeController | | 2 | `AppAssociationEndpointTests` | MMCA.Common.API.Tests | 2 | AppAssociationEndpointExtensions, AppAssociationOptions | -| 2 | `CorrelationIdMiddlewareTests` | MMCA.Common.API.Tests | 2 | CorrelationIdMiddleware, ICorrelationContext | | 2 | `ExceptionHandlerTests` | MMCA.Common.API.Tests | 7 | DbUpdateExceptionHandler, DomainExceptionHandler, DomainInvariantViolationException, GlobalExceptionHandler, OperationCanceledExceptionHandler, TestDomainException, ValidationExceptionHandler | | 2 | `JwksEndpointTests` | MMCA.Common.API.Tests | 4 | IJwksProvider, JwksEndpointExtensions, JwksSettings, RsaJwksProvider | +| 2 | `OpenApiBaselineTests` | MMCA.Common.API.Tests | 4 | OpenApiProbeHost, ProblemDetailsProbeController, SegmentVersionedProbeController, UnboundRouteTokenProbeController | | 2 | `PermissionPolicyProviderTests` | MMCA.Common.API.Tests | 2 | PermissionPolicyProvider, PermissionRequirement | -| 2 | `ProbeControllerFeatureProvider` | MMCA.Common.API.Tests | 2 | SegmentVersionedProbeController, UnboundRouteTokenProbeController | | 2 | `RateLimitingSettingsTests` | MMCA.Common.API.Tests | 2 | RateLimitAlgorithm, RateLimitingSettings | | 2 | `RedisFixedWindowRateLimiterTests` | MMCA.Common.API.Tests | 2 | FakeTimeProvider, RedisFixedWindowRateLimiter | | 2 | `StubRefresher` | MMCA.Common.API.Tests | 2 | ICookieSessionRefresher, SessionTokenResult | @@ -1331,6 +1364,8 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 2 | `CacheKeyLocks` | MMCA.Common.Application | 1 | KeyedSemaphoreStripe | | 2 | `IDataSourceService` | MMCA.Common.Application | 2 | DataSource, DataSourceKey | | 2 | `IEventBus` | MMCA.Common.Application | 1 | IIntegrationEvent | +| 2 | `IEventUpcaster` | MMCA.Common.Application | 1 | IIntegrationEvent | +| 2 | `IEventUpcasterRegistry` | MMCA.Common.Application | 1 | IIntegrationEvent | | 2 | `IIntegrationEventHandler` | MMCA.Common.Application | 1 | IIntegrationEvent | | 2 | `IMessageBus` | MMCA.Common.Application | 1 | IIntegrationEvent | | 2 | `IModule` | MMCA.Common.Application | 1 | ApplicationSettings | @@ -1344,11 +1379,13 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 2 | `AuditTrailEntryDTOTests` | MMCA.Common.Application.Tests | 2 | AuditTrailEntryDTO, IAuditTrailReader | | 2 | `BestEffortTests` | MMCA.Common.Application.Tests | 2 | BestEffort, RecordingLogger | | 2 | `CommandRequestValidatorTests` | MMCA.Common.Application.Tests | 5 | CommandRequestValidator, PermissiveTestRequestValidator, TestCommandWithRequest, TestRequest, TestRequestValidator | +| 2 | `ForgotPasswordRequestValidatorTests` | MMCA.Common.Application.Tests | 2 | ForgotPasswordRequest, ForgotPasswordRequestValidator | | 2 | `LoginRequestValidatorTests` | MMCA.Common.Application.Tests | 2 | LoginRequest, LoginRequestValidator | | 2 | `ModulesSettingsTests` | MMCA.Common.Application.Tests | 2 | ModuleSettings, ModulesSettings | | 2 | `MultiHandlerEvent` | MMCA.Common.Application.Tests | 1 | BaseDomainEvent | | 2 | `NullNotificationRecipientProviderTests` | MMCA.Common.Application.Tests | 1 | NullNotificationRecipientProvider | | 2 | `RefreshTokenRequestValidatorTests` | MMCA.Common.Application.Tests | 2 | RefreshTokenRequest, RefreshTokenRequestValidator | +| 2 | `ResetPasswordRequestValidatorTests` | MMCA.Common.Application.Tests | 2 | ResetPasswordRequest, ResetPasswordRequestValidator | | 2 | `TestChangePasswordCommand` | MMCA.Common.Application.Tests | 2 | ChangePasswordRequest, IUserScopedCommand | | 2 | `TestChangePreferencesCommand` | MMCA.Common.Application.Tests | 2 | ChangePreferencesRequest, IUserScopedCommand | | 2 | `TestDeleteUserCommand` | MMCA.Common.Application.Tests | 1 | IUserOwnedRequest | @@ -1356,7 +1393,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 2 | `TestExportUserDataQuery` | MMCA.Common.Application.Tests | 1 | IUserOwnedRequest | | 2 | `TestSafeDomainEvent` | MMCA.Common.Application.Tests | 1 | BaseDomainEvent | | 2 | `LeftService` | MMCA.Common.Architecture.Tests | 1 | RightModel | -| 2 | `Extensions` | MMCA.Common.Aspire | 8 | HealthCheckTags, HttpResilienceDefaults, IWarmupTask, OpenIdConnectMetadataWarmupTask, OutboxPollFilterProcessor, WarmupHostedService, WarmupReadinessGate, WarmupReadinessHealthCheck | +| 2 | `PasswordHashingFitnessTests` | MMCA.Common.Architecture.Tests | 2 | ArchitectureAssert, PasswordHasher | | 2 | `SecurityHeadersMiddleware` | MMCA.Common.Aspire | 2 | ICspPolicyProvider, SecurityHeadersSettings | | 2 | `StaticCspPolicyProvider` | MMCA.Common.Aspire | 3 | CspPolicy, ICspPolicyProvider, SecurityHeadersSettings | | 2 | `ConfigurableWarmupTask` | MMCA.Common.Aspire.Tests | 1 | SelfHttpWarmupTaskBase | @@ -1408,6 +1445,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 2 | `NullLiveChannelPublisherTests` | MMCA.Common.Infrastructure.Tests | 1 | NullLiveChannelPublisher | | 2 | `NullPushNotificationSenderTests` | MMCA.Common.Infrastructure.Tests | 1 | NullPushNotificationSender | | 2 | `OutboxSignalTests` | MMCA.Common.Infrastructure.Tests | 1 | OutboxSignal | +| 2 | `PasswordHasherSecurityTests` | MMCA.Common.Infrastructure.Tests | 1 | PasswordHasher | | 2 | `PasswordHasherTests` | MMCA.Common.Infrastructure.Tests | 1 | PasswordHasher | | 2 | `PeriodicBackgroundServiceTests` | MMCA.Common.Infrastructure.Tests | 2 | CountingSweep, FakeTimeProvider | | 2 | `PushNotificationSettingsTests` | MMCA.Common.Infrastructure.Tests | 2 | IPushNotificationSettings, PushNotificationSettings | @@ -1497,6 +1535,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 3 | `UpdateSessionResult` | MMCA.ADC.Conference.Application | 1 | SessionDTO | | 3 | `RecordingEventBus` | MMCA.ADC.Conference.Application.Tests | 2 | IEventBus, IIntegrationEvent | | 3 | `SessionScoringQueueTests` | MMCA.ADC.Conference.Application.Tests | 2 | SessionScoringEnqueueResult, SessionScoringQueue | +| 3 | `ActivityChanged` | MMCA.ADC.Conference.Domain | 2 | DomainEntityState, EntityChangedEvent | | 3 | `CategoryChanged` | MMCA.ADC.Conference.Domain | 2 | DomainEntityState, EntityChangedEvent | | 3 | `EventChanged` | MMCA.ADC.Conference.Domain | 2 | DomainEntityState, EntityChangedEvent | | 3 | `QuestionChanged` | MMCA.ADC.Conference.Domain | 2 | DomainEntityState, EntityChangedEvent | @@ -1521,6 +1560,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 3 | `CategoryItemLookupService` | MMCA.ADC.Conference.UI | 6 | CategoryItemDTO, CategoryItemInfo, CollectionResult, ConferenceCategoryDTO, ICategoryItemLookupService, PagedCollectionResult | | 3 | `ConferenceUIModule` | MMCA.ADC.Conference.UI | 5 | ConferenceRoutePaths, IUIModule, NavItem, NavSection, RoleNames | | 3 | `EventLookupService` | MMCA.ADC.Conference.UI | 4 | EventDTO, EventInfo, IEventLookupService, PagedCollectionResult | +| 3 | `IActivityUIService` | MMCA.ADC.Conference.UI | 2 | ActivityDTO, IEntityService | | 3 | `ICategoryItemUIService` | MMCA.ADC.Conference.UI | 2 | CategoryItemDTO, IEntityService | | 3 | `IConferenceCategoryUIService` | MMCA.ADC.Conference.UI | 2 | ConferenceCategoryDTO, IEntityService | | 3 | `IEventUIService` | MMCA.ADC.Conference.UI | 3 | EventDTO, IEntityService, RefreshFromSessionizeResultDTO | @@ -1532,6 +1572,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 3 | `ISponsorUIService` | MMCA.ADC.Conference.UI | 2 | IEntityService, SponsorDTO | | 3 | `OrganizerEventFeedbackService` | MMCA.ADC.Conference.UI | 6 | AuthenticatedServiceBase, EventQuestionAnswerDTO, IOrganizerEventFeedbackUIService, ITokenStorageService, PagedCollectionResult, ServiceExceptionHelper | | 3 | `OrganizerSessionFeedbackService` | MMCA.ADC.Conference.UI | 6 | AuthenticatedServiceBase, IOrganizerSessionFeedbackUIService, ITokenStorageService, PagedCollectionResult, ServiceExceptionHelper, SessionQuestionAnswerDTO | +| 3 | `PublicScheduleRoomOptions` | MMCA.ADC.Conference.UI | 2 | EventDTO, RoomDTO | | 3 | `SpeakerLookupService` | MMCA.ADC.Conference.UI | 4 | ISpeakerLookupService, PagedCollectionResult, SpeakerDTO, SpeakerInfo | | 3 | `BunitTestBase` | MMCA.ADC.Conference.UI.Tests | 26 | AlwaysOnlineConnectivityStatusService, ApiSettings, BunitComponentTestBase, IClipboardService, IConnectivityStatusService, IExternalLinkService, IGeocodingService, IGeolocationService, IHapticFeedbackService, ILocalCacheStore, IMapNavigationService, IPublicLinkBuilder, IScreenshotService, IShareService, ITextToSpeechService, NavigationPublicLinkBuilder, NullClipboardService, NullExternalLinkService, NullGeocodingService, NullGeolocationService …(+6) | | 3 | `E2ETestCollection` | MMCA.ADC.E2E.Tests | 2 | E2ETestCollection, PlaywrightFixture | @@ -1568,28 +1609,29 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 3 | `BunitTestBase` | MMCA.ADC.Identity.UI.Tests | 3 | BunitComponentTestBase, IMediaPickerService, NullMediaPickerService | | 3 | `NotificationModule` | MMCA.ADC.Notification.API | 4 | ApplicationSettings, DisabledUserNotificationExportService, IModule, IUserNotificationExportService | | 3 | `DeviceUIModule` | MMCA.ADC.UI | 2 | IUIModule, NavItem | -| 3 | `MainActivity` | MMCA.ADC.UI | 1 | IDeepLinkDispatcher | | 3 | `MainPage` | MMCA.ADC.UI | 1 | MainPageBase | | 3 | `ApiControllerBase` | MMCA.Common.API | 3 | Error, ErrorHttpMapping, IErrorLocalizer | | 3 | `AuthorizationExtensions` | MMCA.Common.API | 6 | AuthorizationPolicies, IPermissionRegistry, PermissionAuthorizationHandler, PermissionPolicyProvider, PermissionRegistryBuilder, RoleNames | | 3 | `CookieSessionRefreshMiddlewareExtensions` | MMCA.Common.API | 1 | CookieSessionRefreshMiddleware | | 3 | `CookieTokenReader` | MMCA.Common.API | 1 | SessionCookieEndpoints | | 3 | `IAggregateRootEntityControllerBase` | MMCA.Common.API | 3 | IBaseDTO, ICreateRequest, IEntityControllerBase | +| 3 | `InsecureJwtMetadataWarningStartupFilter` | MMCA.Common.API | 1 | WebApplicationBuilderExtensions | | 3 | `SignalRExtensions` | MMCA.Common.API | 2 | NotificationHub, PushNotificationSettings | | 3 | `TenantResolutionMiddleware` | MMCA.Common.API | 3 | ITenantContext, TenancySettings, TenantResolutionStrategy | | 3 | `UnhandledResultFailureFilter` | MMCA.Common.API | 4 | Error, ErrorHttpMapping, IErrorLocalizer, Result | -| 3 | `WebApplicationBuilderExtensions` | MMCA.Common.API | 6 | ApiParameterDescriptorBackfillProvider, JwtSettings, JwtSigningAlgorithm, RateLimitAlgorithm, RateLimitingSettings, RedisFixedWindowRateLimiter | -| 3 | `ApiParameterDescriptorBackfillProviderTests` | MMCA.Common.API.Tests | 2 | ApiParameterDescriptorBackfillProvider, ProbeControllerFeatureProvider | +| 3 | `WebApplicationBuilderExtensions` | MMCA.Common.API | 7 | ApiParameterDescriptorBackfillProvider, InsecureJwtMetadataWarningStartupFilter, JwtSettings, JwtSigningAlgorithm, RateLimitAlgorithm, RateLimitingSettings, RedisFixedWindowRateLimiter | | 3 | `OidcDiscoveryEndpointTests` | MMCA.Common.API.Tests | 2 | JwksEndpointExtensions, OidcDiscoveryEndpointExtensions | | 3 | `PermissionAuthorizationHandlerTests` | MMCA.Common.API.Tests | 5 | AuthClaimTypes, PermissionAuthorizationHandler, PermissionRegistryBuilder, PermissionRequirement, RoleNames | | 3 | `SessionCookieEndpointsTests` | MMCA.Common.API.Tests | 6 | ICookieSessionRefresher, SessionCookieEndpoints, SessionCookieRequest, SessionTokenResponse, SessionTokenResult, StubRefresher | | 3 | `SessionCookieJarTests` | MMCA.Common.API.Tests | 2 | SessionCookieEndpoints, SessionCookieJar | | 3 | `SupportsIfMatchAttributeTests` | MMCA.Common.API.Tests | 4 | ConcurrencyETag, Result, SupportsIfMatchAttribute, UpdateThingRequest | -| 3 | `DomainEventDispatcher` | MMCA.Common.Application | 5 | IDomainEvent, IDomainEventDispatcher, IDomainEventHandler, IIntegrationEvent, IIntegrationEventHandler | +| 3 | `DomainEventDispatcher` | MMCA.Common.Application | 6 | IDomainEvent, IDomainEventDispatcher, IDomainEventHandler, IEventUpcasterRegistry, IIntegrationEvent, IIntegrationEventHandler | +| 3 | `EventUpcasterRegistry` | MMCA.Common.Application | 4 | IDomainEvent, IEventUpcaster, IEventUpcasterRegistry, IIntegrationEvent | | 3 | `ICacheService` | MMCA.Common.Application | 1 | CacheKeyLocks | | 3 | `IFileStorageService` | MMCA.Common.Application | 1 | Result | | 3 | `IImageProcessor` | MMCA.Common.Application | 1 | Result | | 3 | `ILoginProtectionService` | MMCA.Common.Application | 1 | Result | +| 3 | `IPasswordResetTokenService` | MMCA.Common.Application | 1 | Result | | 3 | `IPushDeviceRegistrar` | MMCA.Common.Application | 2 | DeviceInstallationRequest, Result | | 3 | `LoggingCommandDecorator` | MMCA.Common.Application | 4 | CqrsMetrics, ICommandHandler, ICorrelationContext, Result | | 3 | `LoggingQueryDecorator` | MMCA.Common.Application | 4 | CqrsMetrics, ICorrelationContext, IQueryHandler, Result | @@ -1601,6 +1643,9 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 3 | `CancellingSection` | MMCA.Common.Application.Tests | 2 | IUserDataExportSection, UserDataExportSectionResult | | 3 | `CtorProbeCommandHandler` | MMCA.Common.Application.Tests | 3 | CtorProbeCommand, ICommandHandler, Result | | 3 | `CtorProbeQueryHandler` | MMCA.Common.Application.Tests | 3 | CtorProbeQuery, IQueryHandler, Result | +| 3 | `CustomerRenamedV1` | MMCA.Common.Application.Tests | 1 | BaseIntegrationEvent | +| 3 | `CustomerRenamedV2` | MMCA.Common.Application.Tests | 1 | BaseIntegrationEvent | +| 3 | `CustomerRenamedV3` | MMCA.Common.Application.Tests | 1 | BaseIntegrationEvent | | 3 | `FakeConsumerModule` | MMCA.Common.Application.Tests | 3 | ApplicationSettings, FakeModuleTracker, IModule | | 3 | `FakeCycleModuleOne` | MMCA.Common.Application.Tests | 3 | ApplicationSettings, FakeModuleTracker, IModule | | 3 | `FakeCycleModuleTwo` | MMCA.Common.Application.Tests | 3 | ApplicationSettings, FakeModuleTracker, IModule | @@ -1613,21 +1658,31 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 3 | `MultiHandlerEventHandler2` | MMCA.Common.Application.Tests | 2 | IDomainEventHandler, MultiHandlerEvent | | 3 | `ProfilingCommandDecoratorTests` | MMCA.Common.Application.Tests | 5 | Error, ICommandHandler, ProfilingCommandDecorator, ProfilingTestCommand, Result | | 3 | `ProfilingQueryDecoratorTests` | MMCA.Common.Application.Tests | 5 | Error, IQueryHandler, ProfilingQueryDecorator, ProfilingTestQuery, Result | +| 3 | `RecordingIntegrationHandler` | MMCA.Common.Application.Tests | 2 | IIntegrationEvent, IIntegrationEventHandler | | 3 | `RecordingSection` | MMCA.Common.Application.Tests | 2 | IUserDataExportSection, UserDataExportSectionResult | +| 3 | `RetiredEvent` | MMCA.Common.Application.Tests | 1 | BaseIntegrationEvent | +| 3 | `SuccessorEvent` | MMCA.Common.Application.Tests | 1 | BaseIntegrationEvent | | 3 | `TestEventHandler` | MMCA.Common.Application.Tests | 2 | IDomainEventHandler, TestEvent | | 3 | `TestIntegrationEvent` | MMCA.Common.Application.Tests | 3 | BaseDomainEvent, BaseIntegrationEvent, IIntegrationEvent | | 3 | `TestSafeDomainEventHandler` | MMCA.Common.Application.Tests | 2 | SafeDomainEventHandler, TestSafeDomainEvent | | 3 | `ThrowingSection` | MMCA.Common.Application.Tests | 2 | IUserDataExportSection, UserDataExportSectionResult | +| 3 | `UnrelatedEvent` | MMCA.Common.Application.Tests | 1 | BaseIntegrationEvent | | 3 | `UserOwnershipRuleTests` | MMCA.Common.Application.Tests | 4 | Error, ErrorType, TestDeleteUserCommand, UserOwnershipRule | | 3 | `AcyclicConsumer` | MMCA.Common.Architecture.Tests | 1 | LeftService | +| 3 | `EmptyScanTests` | MMCA.Common.Architecture.Tests | 2 | AnonymousEndpointTestsBase, Result | | 3 | `FakeDependentModule` | MMCA.Common.Architecture.Tests | 4 | ApplicationSettings, DisabledFakeExportService, IFakeExportService, IModule | | 3 | `FakeLeafModule` | MMCA.Common.Architecture.Tests | 2 | ApplicationSettings, IModule | +| 3 | `FixtureBackwardsV1` | MMCA.Common.Architecture.Tests | 1 | BaseIntegrationEvent | +| 3 | `FixtureBackwardsV2` | MMCA.Common.Architecture.Tests | 1 | BaseIntegrationEvent | +| 3 | `FixtureCompliantV1` | MMCA.Common.Architecture.Tests | 1 | BaseIntegrationEvent | +| 3 | `FixtureCompliantV2` | MMCA.Common.Architecture.Tests | 1 | BaseIntegrationEvent | +| 3 | `FixtureCompliantV3` | MMCA.Common.Architecture.Tests | 1 | BaseIntegrationEvent | +| 3 | `FixtureContestedV1` | MMCA.Common.Architecture.Tests | 1 | BaseIntegrationEvent | +| 3 | `FixtureContestedV2` | MMCA.Common.Architecture.Tests | 1 | BaseIntegrationEvent | +| 3 | `FixtureContestedV3` | MMCA.Common.Architecture.Tests | 1 | BaseIntegrationEvent | | 3 | `SecurityHeadersExtensions` | MMCA.Common.Aspire | 4 | ICspPolicyProvider, SecurityHeadersMiddleware, SecurityHeadersSettings, StaticCspPolicyProvider | -| 3 | `InfrastructureHealthChecksTests` | MMCA.Common.Aspire.Tests | 2 | Extensions, HealthCheckTags | -| 3 | `MetricsInstrumentationToggleTests` | MMCA.Common.Aspire.Tests | 1 | Extensions | | 3 | `SecurityHeadersMiddlewareTests` | MMCA.Common.Aspire.Tests | 6 | CspPolicy, ICspPolicyProvider, SecurityHeadersMiddleware, SecurityHeadersSettings, StubCspProvider, StubWebHostEnvironment | | 3 | `SelfHttpWarmupTaskBaseTests` | MMCA.Common.Aspire.Tests | 7 | CapturingLogger, ConfigurableWarmupTask, FakeEnvironment, FakeLifetime, FakeServer, SelfHttpWarmupTaskBase, TestServerHost | -| 3 | `TracesSampleRatioTests` | MMCA.Common.Aspire.Tests | 1 | Extensions | | 3 | `ActiveSpec` | MMCA.Common.Benchmarks | 2 | SampleItem, Specification | | 3 | `MinValueSpec` | MMCA.Common.Benchmarks | 2 | SampleItem, Specification | | 3 | `AndSpecification` | MMCA.Common.Domain | 4 | IBaseEntity, ISpecification, Specification, SpecificationComposer | @@ -1650,28 +1705,35 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 3 | `ResultFailureExceptionTests` | MMCA.Common.Grpc.Tests | 2 | Error, ResultFailureException | | 3 | `ResultGrpcExtensionsTests` | MMCA.Common.Grpc.Tests | 4 | Error, ErrorType, Result, ResultFailureException | | 3 | `BrokerMessageBus` | MMCA.Common.Infrastructure | 2 | IIntegrationEvent, IMessageBus | -| 3 | `CapturedState` | MMCA.Common.Infrastructure | 3 | AggregateCapture, IDomainEvent, OutboxMessage | | 3 | `CrossDataSourceDegradeConvention` | MMCA.Common.Infrastructure | 3 | DataSource, DataSourceKey, IEntityDataSourceRegistry | | 3 | `DataSourceService` | MMCA.Common.Infrastructure | 4 | DataSource, DataSourceKey, IDataSourceService, IEntityDataSourceRegistry | +| 3 | `EventUpcasterStartupValidator` | MMCA.Common.Infrastructure | 2 | IEventUpcasterRegistry, IIntegrationEvent | | 3 | `IDataSourceResolver` | MMCA.Common.Infrastructure | 3 | DataSource, DataSourceKey, PhysicalDataSource | | 3 | `InProcessMessageBus` | MMCA.Common.Infrastructure | 3 | IDomainEventDispatcher, IIntegrationEvent, IMessageBus | | 3 | `IntegrationEventConsumer` | MMCA.Common.Infrastructure | 3 | IInboxStore, IIntegrationEvent, IIntegrationEventHandler | | 3 | `SignalRLiveChannelPublisher` | MMCA.Common.Infrastructure | 2 | ILiveChannelPublisher, NotificationHub | | 3 | `SignalRPushNotificationSender` | MMCA.Common.Infrastructure | 2 | IPushNotificationSender, NotificationHub | +| 3 | `UpcastingIntegrationEventConsumer` | MMCA.Common.Infrastructure | 4 | IEventUpcasterRegistry, IInboxStore, IIntegrationEvent, IIntegrationEventHandler | | 3 | `DbSeederTests` | MMCA.Common.Infrastructure.Tests | 1 | TestableDbSeeder | | 3 | `EmptyEntityDataSourceRegistry` | MMCA.Common.Infrastructure.Tests | 2 | DataSourceKey, IEntityDataSourceRegistry | | 3 | `JwtSettingsTests` | MMCA.Common.Infrastructure.Tests | 2 | IJwtSettings, JwtSettings | | 3 | `MapRegistry` | MMCA.Common.Infrastructure.Tests | 2 | DataSourceKey, IEntityDataSourceRegistry | | 3 | `NotificationHubTests` | MMCA.Common.Infrastructure.Tests | 2 | NotificationHub, PushNotificationSettings | +| 3 | `OrderPlacedV2` | MMCA.Common.Infrastructure.Tests | 1 | BaseIntegrationEvent | | 3 | `OtherIntegrationEvent` | MMCA.Common.Infrastructure.Tests | 1 | BaseIntegrationEvent | -| 3 | `OutboxMessageTests` | MMCA.Common.Infrastructure.Tests | 3 | OutboxMessage, TestDomainEvent, TestDomainEventWithData | | 3 | `OutboxSettingsTests` | MMCA.Common.Infrastructure.Tests | 2 | DataSource, OutboxSettings | | 3 | `RedisDistributedLockTests` | MMCA.Common.Infrastructure.Tests | 1 | RedisDistributedLock | +| 3 | `RetiredOrderPlaced` | MMCA.Common.Infrastructure.Tests | 1 | BaseIntegrationEvent | +| 3 | `RetiredTestIntegrationEvent` | MMCA.Common.Infrastructure.Tests | 1 | BaseIntegrationEvent | | 3 | `TestDataSourceService` | MMCA.Common.Infrastructure.Tests | 3 | DataSource, DataSourceKey, IDataSourceService | | 3 | `TestFaultedEvent` | MMCA.Common.Infrastructure.Tests | 1 | BaseIntegrationEvent | | 3 | `TestIntegrationEvent` | MMCA.Common.Infrastructure.Tests | 2 | BaseIntegrationEvent, IIntegrationEvent | +| 3 | `TestIntegrationEventV2` | MMCA.Common.Infrastructure.Tests | 1 | BaseIntegrationEvent | | 3 | `TestPhysicalDataSources` | MMCA.Common.Infrastructure.Tests | 3 | DataSource, DataSourceKey, PhysicalDataSource | | 3 | `TokenServiceTests` | MMCA.Common.Infrastructure.Tests | 3 | JwtSettings, JwtSigningAlgorithm, TokenService | +| 3 | `ValidatorSampleV1` | MMCA.Common.Infrastructure.Tests | 1 | BaseIntegrationEvent | +| 3 | `ValidatorSampleV2` | MMCA.Common.Infrastructure.Tests | 1 | BaseIntegrationEvent | +| 3 | `ValidatorSampleV3` | MMCA.Common.Infrastructure.Tests | 1 | BaseIntegrationEvent | | 3 | `Address` | MMCA.Common.Shared | 3 | AddressInvariants, Result, ValueObject | | 3 | `AddressInvariants` | MMCA.Common.Shared | 3 | Address, Error, Result | | 3 | `Currency` | MMCA.Common.Shared | 4 | CurrencyJsonConverter, Error, Result, ValueObject | @@ -1698,7 +1760,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 3 | `DeepLinkDispatcher` | MMCA.Common.UI | 2 | DeepLinkRouteEventArgs, IDeepLinkDispatcher | | 3 | `EntityServiceBase` | MMCA.Common.UI | 10 | AuthenticatedServiceBase, BaseLookup, CollectionResult, IBaseDTO, IdempotencyHeaders, IEntityService, ITokenStorageService, PagedCollectionResult, PaginationMetadata, ServiceExceptionHelper | | 3 | `NotificationBell` | MMCA.Common.UI | 4 | INotificationInboxUIService, NotificationRoutePaths, NotificationState, SharedResource | -| 3 | `NotificationInboxService` | MMCA.Common.UI | 7 | AuthenticatedServiceBase, INotificationInboxUIService, INotificationScopeProvider, ITokenStorageService, PagedCollectionResult, ServiceExceptionHelper, UserNotificationDTO | +| 3 | `NotificationInboxService` | MMCA.Common.UI | 8 | AuthenticatedServiceBase, INotificationInboxUIService, INotificationScopeProvider, ITokenRefresher, ITokenStorageService, PagedCollectionResult, ServiceExceptionHelper, UserNotificationDTO | | 3 | `GalleryUIModule` | MMCA.Common.UI.Gallery | 2 | IUIModule, NavItem | | 3 | `StubNotificationInboxUIService` | MMCA.Common.UI.Gallery | 4 | INotificationInboxUIService, PagedCollectionResult, PaginationMetadata, UserNotificationDTO | | 3 | `StubPushNotificationUIService` | MMCA.Common.UI.Gallery | 5 | IPushNotificationUIService, PagedCollectionResult, PaginationMetadata, PushNotificationDTO, SendPushNotificationRequest | @@ -1743,6 +1805,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 4 | `FakeSessionizeService` | MMCA.ADC.Conference.IntegrationTests | 3 | ISessionizeService, SessionizeResponse, SessionizeSession | | 4 | `DisabledEventLiveValidationService` | MMCA.ADC.Conference.Shared | 7 | EventLiveInfo, IEventLiveValidationService, QuestionModerationDefault, Result, RoomSessionInfo, SessionLiveInfo, SponsorLiveInfo | | 4 | `DisabledSessionBookmarkValidationService` | MMCA.ADC.Conference.Shared | 2 | ISessionBookmarkValidationService, Result | +| 4 | `ActivityService` | MMCA.ADC.Conference.UI | 4 | ActivityDTO, EntityServiceBase, IActivityUIService, ITokenStorageService | | 4 | `CategoryItemService` | MMCA.ADC.Conference.UI | 4 | CategoryItemDTO, EntityServiceBase, ICategoryItemUIService, ITokenStorageService | | 4 | `ConferenceCategoryService` | MMCA.ADC.Conference.UI | 4 | ConferenceCategoryDTO, EntityServiceBase, IConferenceCategoryUIService, ITokenStorageService | | 4 | `EventService` | MMCA.ADC.Conference.UI | 6 | EntityServiceBase, EventDTO, EventTransitionRequest, IEventUIService, ITokenStorageService, RefreshFromSessionizeResultDTO | @@ -1767,7 +1830,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 4 | `AccessibilityTests` | MMCA.ADC.E2E.Tests | 30 | CheckInScanPage, ConferenceCategoryCreatePage, ConferenceCategoryListPage, E2ETestBase, E2ETestCollection, EventCreatePage, EventListPage, HappeningNowPage, MyBadgePage, MyPointsPage, OrganizerAttendancePage, OrganizerPointsOverviewPage, PlaywrightFixture, PublicEventListPage, PublicSessionListPage, PublicSpeakerListPage, PublicSponsorListPage, QuestionCreatePage, QuestionListPage, RoomCheckInPage …(+10) | | 4 | `AccountDeletionTests` | MMCA.ADC.E2E.Tests | 4 | E2ETestBase, E2ETestCollection, PlaywrightFixture, ProfilePage | | 4 | `AttendeeBookmarkTests` | MMCA.ADC.E2E.Tests | 5 | E2ETestBase, E2ETestCollection, PlaywrightFixture, PublicSessionListPage, SessionCreatePage | -| 4 | `AttendeeFeedbackTests` | MMCA.ADC.E2E.Tests | 13 | E2ETestBase, E2ETestCollection, EventCreatePage, EventDetailPage, EventFeedbackPage, PlaywrightFixture, PublicEventDetailPage, PublicEventListPage, PublicSessionDetailPage, PublicSessionListPage, QuestionCreatePage, SessionCreatePage, SessionFeedbackPage | +| 4 | `AttendeeFeedbackTests` | MMCA.ADC.E2E.Tests | 12 | E2ETestBase, E2ETestCollection, EventCreatePage, EventDetailPage, EventFeedbackPage, PlaywrightFixture, PublicEventDetailPage, PublicSessionDetailPage, PublicSessionListPage, QuestionCreatePage, SessionCreatePage, SessionFeedbackPage | | 4 | `AttendeeShareAndExportTests` | MMCA.ADC.E2E.Tests | 8 | E2ETestBase, E2ETestCollection, PlaywrightFixture, PublicEventDetailPage, PublicEventListPage, PublicSessionDetailPage, PublicSpeakerDetailPage, PublicSpeakerListPage | | 4 | `CheckInAndPointsTests` | MMCA.ADC.E2E.Tests | 15 | CheckInScanPage, E2ETestBase, E2ETestCollection, EventCreatePage, EventDetailPage, MyBadgePage, MyPointsPage, OrganizerAttendancePage, OrganizerPointsOverviewPage, PlaywrightFixture, RoomCheckInPage, RoomCreatePage, RoomDetailPage, SessionCreatePage, SponsorVisitPage | | 4 | `DataIntegrityTests` | MMCA.ADC.E2E.Tests | 11 | E2ETestBase, E2ETestCollection, EventCreatePage, EventDetailPage, PlaywrightFixture, PublicSessionListPage, PublicSpeakerDetailPage, RoomCreatePage, RoomDetailPage, SessionCreatePage, SessionDetailPage | @@ -1775,8 +1838,8 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 4 | `LiveSessionQaTests` | MMCA.ADC.E2E.Tests | 10 | E2ETestBase, E2ETestCollection, E2ETestConfiguration, EventCreatePage, EventDetailPage, HappeningNowPage, LiveEventFixture, LiveSessionPage, PlaywrightFixture, PresenterViewPage | | 4 | `NotificationTests` | MMCA.ADC.E2E.Tests | 4 | E2ETestBase, E2ETestCollection, E2ETestConfiguration, PlaywrightFixture | | 4 | `OrganizerCategoryManagementTests` | MMCA.ADC.E2E.Tests | 6 | ConferenceCategoryCreatePage, ConferenceCategoryDetailPage, ConferenceCategoryListPage, E2ETestBase, E2ETestCollection, PlaywrightFixture | -| 4 | `OrganizerEventManagementTests` | MMCA.ADC.E2E.Tests | 10 | E2ETestBase, E2ETestCollection, EventCreatePage, EventDetailPage, EventListPage, PlaywrightFixture, PublicEventDetailPage, PublicEventListPage, SessionCreatePage, SessionDetailPage | -| 4 | `OrganizerFeedbackAnalyticsTests` | MMCA.ADC.E2E.Tests | 12 | E2ETestBase, E2ETestCollection, EventCreatePage, EventDetailPage, EventFeedbackPage, OrganizerEventFeedbackPage, OrganizerSessionFeedbackPage, PlaywrightFixture, PublicEventDetailPage, PublicEventListPage, SessionCreatePage, SessionDetailPage | +| 4 | `OrganizerEventManagementTests` | MMCA.ADC.E2E.Tests | 9 | E2ETestBase, E2ETestCollection, EventCreatePage, EventDetailPage, EventListPage, PlaywrightFixture, PublicEventDetailPage, SessionCreatePage, SessionDetailPage | +| 4 | `OrganizerFeedbackAnalyticsTests` | MMCA.ADC.E2E.Tests | 11 | E2ETestBase, E2ETestCollection, EventCreatePage, EventDetailPage, EventFeedbackPage, OrganizerEventFeedbackPage, OrganizerSessionFeedbackPage, PlaywrightFixture, PublicEventDetailPage, SessionCreatePage, SessionDetailPage | | 4 | `OrganizerQuestionManagementTests` | MMCA.ADC.E2E.Tests | 6 | E2ETestBase, E2ETestCollection, PlaywrightFixture, QuestionCreatePage, QuestionDetailPage, QuestionListPage | | 4 | `OrganizerRelationshipManagementTests` | MMCA.ADC.E2E.Tests | 9 | ConferenceCategoryCreatePage, ConferenceCategoryDetailPage, E2ETestBase, E2ETestCollection, EventCreatePage, PlaywrightFixture, SessionCreatePage, SessionDetailPage, SpeakerCreatePage | | 4 | `OrganizerRoomManagementTests` | MMCA.ADC.E2E.Tests | 7 | E2ETestBase, E2ETestCollection, EventCreatePage, PlaywrightFixture, RoomCreatePage, RoomDetailPage, RoomListPage | @@ -1795,8 +1858,10 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 4 | `PointsController` | MMCA.ADC.Engagement.API | 15 | ApiControllerBase, AuthorizationPolicies, EngagementFeatures, EngagementPermissions, GetLeaderboardQuery, GetMyPointsQuery, GetPointsOverviewQuery, ICommandHandler, IQueryHandler, LeaderboardEntryDTO, MyPointsDTO, PointsOverviewDTO, Result, Route, SetLeaderboardParticipationRequest | | 4 | `EventFeedbackSubmittedPointsHandler` | MMCA.ADC.Engagement.Application | 5 | EventFeedbackSubmitted, IIntegrationEventHandler, IPointsAwarder, PointsActivityType, PointsSubjectKeys | | 4 | `SessionFeedbackSubmittedPointsHandler` | MMCA.ADC.Engagement.Application | 5 | IIntegrationEventHandler, IPointsAwarder, PointsActivityType, PointsSubjectKeys, SessionFeedbackSubmitted | +| 4 | `SessionQuestionSubmittedPointsHandler` | MMCA.ADC.Engagement.Application | 6 | DomainEntityState, IDomainEventHandler, IPointsAwarder, PointsActivityType, PointsSubjectKeys, SessionQuestionChanged | | 4 | `UserSessionBookmarkCacheEvictionHandler` | MMCA.ADC.Engagement.Application | 5 | BestEffort, IDomainEventHandler, IEventBus, OutputCacheEvictionRequested, UserSessionBookmarkChanged | | 4 | `RecordingPointsAwarder` | MMCA.ADC.Engagement.Application.Tests | 4 | AwardCall, IPointsAwarder, PointsActivityType, Result | +| 4 | `ThrowingPointsAwarder` | MMCA.ADC.Engagement.Application.Tests | 3 | IPointsAwarder, PointsActivityType, Result | | 4 | `DependencyInjection` | MMCA.ADC.Engagement.Infrastructure | 1 | LiveChannelPublishProcessor | | 4 | `LiveChannelPublishProcessorTests` | MMCA.ADC.Engagement.Infrastructure.Tests | 5 | ILiveChannelPublisher, LiveChannelPublishProcessor, LiveChannelPublishQueue, LiveChannelPublishWorkItem, RecordingPublisher | | 4 | `FakeEventLiveValidationService` | MMCA.ADC.Engagement.IntegrationTests | 8 | Error, EventLiveInfo, IEventLiveValidationService, QuestionModerationDefault, Result, RoomSessionInfo, SessionLiveInfo, SponsorLiveInfo | @@ -1823,7 +1888,6 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 4 | `TestSupport` | MMCA.ADC.Notification.Application.Tests | 2 | AuditableBaseEntity, BaseEntity | | 4 | `ServiceBusEmulatorFixture` | MMCA.ADC.ServiceBusEmulator.IntegrationTests | 2 | SpeakerLinkedToUser, UserRegistered | | 4 | `App` | MMCA.ADC.UI | 1 | MainPage | -| 4 | `NowNextWidgetProvider` | MMCA.ADC.UI | 3 | MainActivity, NowNextSession, NowNextSnapshot | | 4 | `CookieSessionRefresher` | MMCA.Common.API | 8 | AuthenticationResponse, CookieTokenReader, ICookieSessionRefresher, KeyedSemaphoreStripe, RefreshTokenRequest, SessionCookieEndpoints, SessionCookieJar, SessionTokenResult | | 4 | `CurrencyJsonConverter` | MMCA.Common.API | 1 | Currency | | 4 | `IdempotencyFilter` | MMCA.Common.API | 7 | ICacheService, IdempotencyHeaders, IdempotencyMetrics, IdempotencyRecord, IdempotencySettings, IDistributedLock, KeyedSemaphoreStripe | @@ -1834,6 +1898,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 4 | `CurrencyJsonConverterTests` | MMCA.Common.API.Tests | 1 | Currency | | 4 | `ExportMoney` | MMCA.Common.API.Tests | 1 | Currency | | 4 | `ExportTestEntity` | MMCA.Common.API.Tests | 1 | AuditableBaseEntity | +| 4 | `ForwardedJwtBearerSecurityTests` | MMCA.Common.API.Tests | 2 | StubHostEnvironment, WebApplicationBuilderExtensions | | 4 | `PlainEntity` | MMCA.Common.API.Tests | 1 | AuditableBaseEntity | | 4 | `RateLimitAlgorithmSelectionTests` | MMCA.Common.API.Tests | 4 | RateLimitAlgorithm, RateLimitingSettings, RedisFixedWindowRateLimiter, WebApplicationBuilderExtensions | | 4 | `RateLimitPartitionTests` | MMCA.Common.API.Tests | 1 | WebApplicationBuilderExtensions | @@ -1877,6 +1942,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 4 | `DateTimeFilterStrategyTests` | MMCA.Common.Application.Tests | 2 | Item, QueryFilterService | | 4 | `DecimalFilterStrategyTests` | MMCA.Common.Application.Tests | 2 | Item, QueryFilterService | | 4 | `Dependent` | MMCA.Common.Application.Tests | 1 | AuditableBaseEntity | +| 4 | `EnvelopeCopyingV1ToV2Upcaster` | MMCA.Common.Application.Tests | 3 | CustomerRenamedV1, CustomerRenamedV2, IEventUpcaster | | 4 | `FakeEntity` | MMCA.Common.Application.Tests | 1 | AuditableBaseEntity | | 4 | `GuidFilterStrategyTests` | MMCA.Common.Application.Tests | 2 | Item, QueryFilterService | | 4 | `IntFilterStrategyTests` | MMCA.Common.Application.Tests | 2 | Item, QueryFilterService | @@ -1897,13 +1963,17 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 4 | `QueryFilterServiceTests` | MMCA.Common.Application.Tests | 3 | Product, QueryFilterService, TestStrategy | | 4 | `QueryFilterServiceValidateTests` | MMCA.Common.Application.Tests | 2 | Product, QueryFilterService | | 4 | `RecordingCacheService` | MMCA.Common.Application.Tests | 1 | ICacheService | +| 4 | `RecordingDomainHandlerForRetired` | MMCA.Common.Application.Tests | 2 | IDomainEventHandler, RetiredEvent | | 4 | `RelatedA` | MMCA.Common.Application.Tests | 1 | AuditableBaseEntity | | 4 | `RelatedB` | MMCA.Common.Application.Tests | 1 | AuditableBaseEntity | | 4 | `RelatedC` | MMCA.Common.Application.Tests | 1 | AuditableBaseEntity | | 4 | `RelatedEntity` | MMCA.Common.Application.Tests | 1 | AuditableBaseEntity | | 4 | `ResolvedEntity` | MMCA.Common.Application.Tests | 1 | AuditableBaseEntity | | 4 | `ResultFailureFactoryTests` | MMCA.Common.Application.Tests | 3 | Error, LoggingCommandDecorator, Result | +| 4 | `RetiredToSuccessorUpcaster` | MMCA.Common.Application.Tests | 3 | IEventUpcaster, RetiredEvent, SuccessorEvent | +| 4 | `RivalV1ToV3Upcaster` | MMCA.Common.Application.Tests | 3 | CustomerRenamedV1, CustomerRenamedV3, IEventUpcaster | | 4 | `SafeDomainEventHandlerTests` | MMCA.Common.Application.Tests | 3 | RecordingLogger, TestSafeDomainEvent, TestSafeDomainEventHandler | +| 4 | `SelfMappingUpcaster` | MMCA.Common.Application.Tests | 2 | CustomerRenamedV1, IEventUpcaster | | 4 | `StringFilterStrategyTests` | MMCA.Common.Application.Tests | 2 | Item, QueryFilterService | | 4 | `StubChild` | MMCA.Common.Application.Tests | 1 | AuditableBaseEntity | | 4 | `StubEntity` | MMCA.Common.Application.Tests | 1 | AuditableBaseEntity | @@ -1913,10 +1983,22 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 4 | `TestIntegrationEventDomainHandler` | MMCA.Common.Application.Tests | 2 | IDomainEventHandler, TestIntegrationEvent | | 4 | `TestIntegrationEventHandler` | MMCA.Common.Application.Tests | 2 | IIntegrationEventHandler, TestIntegrationEvent | | 4 | `TestReadEntity` | MMCA.Common.Application.Tests | 1 | AuditableBaseEntity | -| 4 | `DriftedTests` | MMCA.Common.Architecture.Tests | 2 | FakeDependentModule, ModuleConformanceTestsBase | +| 4 | `V1ToV2Upcaster` | MMCA.Common.Application.Tests | 3 | CustomerRenamedV1, CustomerRenamedV2, IEventUpcaster | +| 4 | `V2ToV1Upcaster` | MMCA.Common.Application.Tests | 3 | CustomerRenamedV1, CustomerRenamedV2, IEventUpcaster | +| 4 | `V2ToV3Upcaster` | MMCA.Common.Application.Tests | 3 | CustomerRenamedV2, CustomerRenamedV3, IEventUpcaster | +| 4 | `AnonymousEndpointTests` | MMCA.Common.Architecture.Tests | 3 | AnonymousEndpointTestsBase, ApiControllerBase, UISharedAssemblyReference | +| 4 | `AnonymousEndpointTestsBaseTests` | MMCA.Common.Architecture.Tests | 7 | AbstractAnonymousFixtureControllerBase, AnonymousFixtureController, ConformantTests, DriftedTests, EmptyScanTests, InheritingFixtureController, StaleAllowListTests | +| 4 | `ConformantTests` | MMCA.Common.Architecture.Tests | 5 | AbstractAnonymousFixtureControllerBase, AnonymousEndpointTestsBase, AnonymousEndpointTestsBaseTests, AnonymousFixtureController, TypeLevelAnonymousFixtureController | +| 4 | `DriftedTests` | MMCA.Common.Architecture.Tests | 4 | AnonymousEndpointTestsBase, AnonymousEndpointTestsBaseTests, FakeDependentModule, ModuleConformanceTestsBase | | 4 | `FakeDependentModuleConformanceTests` | MMCA.Common.Architecture.Tests | 4 | DisabledFakeExportService, FakeDependentModule, IFakeExportService, ModuleConformanceTestsBase | | 4 | `FakeLeafModuleConformanceTests` | MMCA.Common.Architecture.Tests | 2 | FakeLeafModule, ModuleConformanceTestsBase | | 4 | `FitnessPrincipal` | MMCA.Common.Architecture.Tests | 1 | AuditableBaseEntity | +| 4 | `FixtureBackwardsVersionUpcaster` | MMCA.Common.Architecture.Tests | 3 | FixtureBackwardsV1, FixtureBackwardsV2, IEventUpcaster | +| 4 | `FixtureCompliantV1ToV2Upcaster` | MMCA.Common.Architecture.Tests | 3 | FixtureCompliantV1, FixtureCompliantV2, IEventUpcaster | +| 4 | `FixtureCompliantV2ToV3Upcaster` | MMCA.Common.Architecture.Tests | 3 | FixtureCompliantV2, FixtureCompliantV3, IEventUpcaster | +| 4 | `FixtureContestedClaimUpcaster` | MMCA.Common.Architecture.Tests | 3 | FixtureContestedV1, FixtureContestedV2, IEventUpcaster | +| 4 | `FixtureRivalClaimUpcaster` | MMCA.Common.Architecture.Tests | 3 | FixtureContestedV1, FixtureContestedV3, IEventUpcaster | +| 4 | `StaleAllowListTests` | MMCA.Common.Architecture.Tests | 2 | AnonymousEndpointTestsBase, AnonymousEndpointTestsBaseTests | | 4 | `QueryPipelineBenchmarks` | MMCA.Common.Benchmarks | 3 | ProductRow, QueryFieldService, QueryFilterService | | 4 | `SpecificationBenchmarks` | MMCA.Common.Benchmarks | 5 | ActiveSpec, AndSpecification, MinValueSpec, OrSpecification, SampleItem | | 4 | `AuditableAggregateRootEntity` | MMCA.Common.Domain | 6 | AuditableBaseEntity, Error, IAggregateRoot, IAuditableEntity, IDomainEvent, Result | @@ -1946,7 +2028,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 4 | `HybridCacheService` | MMCA.Common.Infrastructure | 4 | CacheKeyNamespace, CacheOptions, ICacheService, RedisPrefixScanner | | 4 | `IEntityTypeConfigurationBase` | MMCA.Common.Infrastructure | 1 | AuditableBaseEntity | | 4 | `ImageSharpImageProcessor` | MMCA.Common.Infrastructure | 3 | Error, IImageProcessor, Result | -| 4 | `IntegrationEventConsumerExtensions` | MMCA.Common.Infrastructure | 4 | FaultIntegrationEventConsumer, IIntegrationEvent, IntegrationEventConsumer, OutputCacheEvictionRequested | +| 4 | `IntegrationEventConsumerExtensions` | MMCA.Common.Infrastructure | 5 | FaultIntegrationEventConsumer, IIntegrationEvent, IntegrationEventConsumer, OutputCacheEvictionRequested, UpcastingIntegrationEventConsumer | | 4 | `MemoryCacheService` | MMCA.Common.Infrastructure | 2 | ICacheService, KeyedSemaphoreStripe | | 4 | `NullableEnumerationValueConverter` | MMCA.Common.Infrastructure | 1 | Enumeration | | 4 | `NullFileStorageService` | MMCA.Common.Infrastructure | 3 | Error, IFileStorageService, Result | @@ -1968,8 +2050,13 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 4 | `ProjectedTestEntity` | MMCA.Common.Infrastructure.Tests | 1 | AuditableBaseEntity | | 4 | `RecordingDomainHandler` | MMCA.Common.Infrastructure.Tests | 2 | IDomainEventHandler, TestIntegrationEvent | | 4 | `RecordingIntegrationHandler` | MMCA.Common.Infrastructure.Tests | 2 | IIntegrationEventHandler, TestIntegrationEvent | +| 4 | `RecordingOriginalHandler` | MMCA.Common.Infrastructure.Tests | 2 | IIntegrationEventHandler, TestIntegrationEvent | +| 4 | `RecordingSuccessorHandler` | MMCA.Common.Infrastructure.Tests | 2 | IIntegrationEventHandler, TestIntegrationEventV2 | | 4 | `RegistryUnattributed` | MMCA.Common.Infrastructure.Tests | 1 | AuditableBaseEntity | | 4 | `RenamedFlagEntity` | MMCA.Common.Infrastructure.Tests | 1 | AuditableBaseEntity | +| 4 | `RetiredToV2Upcaster` | MMCA.Common.Infrastructure.Tests | 5 | IEventUpcaster, OrderPlacedV2, RetiredOrderPlaced, RetiredTestIntegrationEvent, TestIntegrationEventV2 | +| 4 | `RivalV1ToV3Upcaster` | MMCA.Common.Infrastructure.Tests | 3 | IEventUpcaster, ValidatorSampleV1, ValidatorSampleV3 | +| 4 | `SampleV1ToV2Upcaster` | MMCA.Common.Infrastructure.Tests | 3 | IEventUpcaster, ValidatorSampleV1, ValidatorSampleV2 | | 4 | `SignalRLiveChannelPublisherTests` | MMCA.Common.Infrastructure.Tests | 2 | NotificationHub, SignalRLiveChannelPublisher | | 4 | `SignalRPushNotificationSenderAdditionalTests` | MMCA.Common.Infrastructure.Tests | 2 | NotificationHub, SignalRPushNotificationSender | | 4 | `SignalRPushNotificationSenderTests` | MMCA.Common.Infrastructure.Tests | 2 | NotificationHub, SignalRPushNotificationSender | @@ -2009,6 +2096,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 4 | `UIArchitectureConventionTestsBase` | MMCA.Common.Testing.Architecture | 2 | ArchitectureMapBase, IArchitectureMap | | 4 | `AuthorizationTestsBase` | MMCA.Common.Testing.E2E | 2 | E2ETestBase, PlaywrightFixture | | 4 | `LogoutTestsBase` | MMCA.Common.Testing.E2E | 2 | E2ETestBase, PlaywrightFixture | +| 4 | `PasswordResetTestsBase` | MMCA.Common.Testing.E2E | 6 | AxeOptions, E2ETestBase, ForgotPasswordPage, LoginPage, PlaywrightFixture, ResetPasswordPage | | 4 | `ProfileManagementTestsBase` | MMCA.Common.Testing.E2E | 4 | AxeOptions, E2ETestBase, PlaywrightFixture, ProfilePage | | 4 | `UserLoginTestsBase` | MMCA.Common.Testing.E2E | 4 | AxeOptions, E2ETestBase, LoginPage, PlaywrightFixture | | 4 | `UserPreferencesTestsBase` | MMCA.Common.Testing.E2E | 2 | E2ETestBase, PlaywrightFixture | @@ -2021,9 +2109,9 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 4 | `DeepLinkDispatcherTests` | MMCA.Common.UI.Tests | 2 | DeepLinkDispatcher, DeepLinkRouteEventArgs | | 4 | `DeepLinkListenerTests` | MMCA.Common.UI.Tests | 3 | BunitTestBase, DeepLinkDispatcher, IDeepLinkDispatcher | | 4 | `MembershipService` | MMCA.Common.UI.Tests | 2 | ChildEntityServiceBase, ITokenStorageService | -| 4 | `NotificationBellTests` | MMCA.Common.UI.Tests | 4 | BunitTestBase, INotificationInboxUIService, NotificationBell, NotificationState | +| 4 | `NotificationBellHost` | MMCA.Common.UI.Tests | 1 | NotificationBell | | 4 | `NotificationHubServiceTests` | MMCA.Common.UI.Tests | 5 | ApiSettings, CapturingLogger, ConcurrencyTrackingTokenStorage, ITokenStorageService, NotificationHubService | -| 4 | `NotificationInboxServiceTests` | MMCA.Common.UI.Tests | 11 | DomainInvariantViolationException, ITokenStorageService, Mocks, Mocks, NotificationInboxService, PagedCollectionResult, PaginationMetadata, StubHttpClientFactory, StubHttpMessageHandler, StubScopeProvider, UserNotificationDTO | +| 4 | `NotificationInboxServiceTests` | MMCA.Common.UI.Tests | 12 | DomainInvariantViolationException, ITokenRefresher, ITokenStorageService, Mocks, Mocks, NotificationInboxService, PagedCollectionResult, PaginationMetadata, StubHttpClientFactory, StubHttpMessageHandler, StubScopeProvider, UserNotificationDTO | | 4 | `SharedHttpTestDoublesTests` | MMCA.Common.UI.Tests | 3 | CapturingHttpMessageHandler, HttpTestDoubles, UiHttpServiceHarness | | 4 | `WidgetService` | MMCA.Common.UI.Tests | 3 | EntityServiceBase, ITokenStorageService, WidgetDto | | 4 | `ServerTokenStorageService` | MMCA.Common.UI.Web | 5 | CookieTokenReader, ISessionCookieSync, ITokenRefresher, ITokenStorageService, JwtTokenInfo | @@ -2040,7 +2128,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 5 | `EventCreate` | MMCA.ADC.Conference.UI | 6 | ConferenceRoutePaths, ErrorMessages, EventDTO, EventService, IEventUIService, Severity | | 5 | `OrganizerEventFeedback` | MMCA.ADC.Conference.UI | 9 | ConferenceRoutePaths, EventLookupService, EventQuestionAnswerDTO, IEventLookupService, IOrganizerEventFeedbackUIService, IQuestionUIService, QuestionDTO, QuestionService, Severity | | 5 | `OrganizerSessionFeedback` | MMCA.ADC.Conference.UI | 9 | ConferenceRoutePaths, IOrganizerSessionFeedbackUIService, IQuestionUIService, ISessionUIService, QuestionDTO, QuestionService, SessionQuestionAnswerDTO, SessionService, Severity | -| 5 | `PublicSessionListFilterBar` | MMCA.ADC.Conference.UI | 4 | EventDTO, IScreenshotService, IShareService, Severity | +| 5 | `PublicSessionListFilterBar` | MMCA.ADC.Conference.UI | 5 | EventDTO, IScreenshotService, IShareService, RoomDTO, Severity | | 5 | `QuestionCreate` | MMCA.ADC.Conference.UI | 6 | ConferenceRoutePaths, ErrorMessages, IQuestionUIService, QuestionDTO, QuestionService, Severity | | 5 | `RoomCreate` | MMCA.ADC.Conference.UI | 9 | ConferenceRoutePaths, ErrorMessages, EventInfo, EventLookupService, IEventLookupService, IRoomUIService, RoomDTO, RoomService, Severity | | 5 | `SessionCreate` | MMCA.ADC.Conference.UI | 12 | ConferenceRoutePaths, ErrorMessages, EventInfo, EventLookupService, IEventLookupService, IRoomUIService, ISessionUIService, RoomDTO, RoomService, SessionDTO, SessionService, Severity | @@ -2051,6 +2139,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 5 | `SessionSelectionAiScoresTests` | MMCA.ADC.Conference.UI.Tests | 6 | BunitTestBase, CategoryDistributionDTO, SessionAiScoreDTO, SessionSelectionAiScores, SessionSelectionDashboardDTO, SpeakerSessionOverlapDTO | | 5 | `AuthorizationTests` | MMCA.ADC.E2E.Tests | 2 | AuthorizationTestsBase, PlaywrightFixture | | 5 | `LogoutTests` | MMCA.ADC.E2E.Tests | 2 | LogoutTestsBase, PlaywrightFixture | +| 5 | `PasswordResetTests` | MMCA.ADC.E2E.Tests | 2 | PasswordResetTestsBase, PlaywrightFixture | | 5 | `UserLoginTests` | MMCA.ADC.E2E.Tests | 2 | PlaywrightFixture, UserLoginTestsBase | | 5 | `UserPreferencesTests` | MMCA.ADC.E2E.Tests | 2 | PlaywrightFixture, UserPreferencesTestsBase | | 5 | `UserRegistrationTests` | MMCA.ADC.E2E.Tests | 2 | PlaywrightFixture, UserRegistrationTestsBase | @@ -2098,9 +2187,10 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 5 | `CachingDecoratorTenantScopingTests` | MMCA.Common.Application.Tests | 9 | CacheableTestQuery, CacheInvalidatingTestCommand, CachingCommandDecorator, CachingQueryDecorator, ICacheService, ICommandHandler, IQueryHandler, ITenantContext, Result | | 5 | `CachingQueryDecoratorTests` | MMCA.Common.Application.Tests | 15 | CacheableTestQuery, CacheDoubleCheckMetricQuery, CacheHitMetricQuery, CacheMissMetricQuery, CacheReadCanceledQuery, CacheReadFailureMetricQuery, CacheReadFailureQuery, CachingQueryDecorator, CapturedCounter, Error, ICacheService, IQueryHandler, NonCacheableTestQuery, Result, StampedeTestQuery | | 5 | `CachingTestEntity` | MMCA.Common.Application.Tests | 1 | AuditableAggregateRootEntity | -| 5 | `DomainEventDispatcherAdditionalTests` | MMCA.Common.Application.Tests | 9 | DomainEventDispatcher, IDomainEventHandler, IIntegrationEventHandler, MultiHandlerEvent, MultiHandlerEventHandler1, MultiHandlerEventHandler2, TestDomainEventHandlerForIntegration, TestIntegrationEvent, TestIntegrationEventHandler | +| 5 | `DomainEventDispatcherAdditionalTests` | MMCA.Common.Application.Tests | 16 | DomainEventDispatcher, EventUpcasterRegistry, IDomainEventHandler, IEventUpcasterRegistry, IIntegrationEventHandler, MultiHandlerEvent, MultiHandlerEventHandler1, MultiHandlerEventHandler2, RecordingDomainHandlerForRetired, RecordingIntegrationHandler, RetiredEvent, RetiredToSuccessorUpcaster, SuccessorEvent, TestDomainEventHandlerForIntegration, TestIntegrationEvent, TestIntegrationEventHandler | | 5 | `DomainEventDispatcherTests` | MMCA.Common.Application.Tests | 8 | DomainEventDispatcher, IDomainEventHandler, IIntegrationEventHandler, TestEvent, TestEventHandler, TestIntegrationEvent, TestIntegrationEventDomainHandler, TestIntegrationEventHandler | | 5 | `EntityQueryParametersTests` | MMCA.Common.Application.Tests | 2 | EntityQueryParameters, TestEntity | +| 5 | `EventUpcasterRegistryTests` | MMCA.Common.Application.Tests | 13 | CustomerRenamedV1, CustomerRenamedV2, CustomerRenamedV3, EnvelopeCopyingV1ToV2Upcaster, EventUpcasterRegistry, IEventUpcaster, IIntegrationEvent, RivalV1ToV3Upcaster, SelfMappingUpcaster, UnrelatedEvent, V1ToV2Upcaster, V2ToV1Upcaster, V2ToV3Upcaster | | 5 | `FakeEntityDTOMapper` | MMCA.Common.Application.Tests | 3 | FakeEntity, FakeEntityDTO, IEntityDTOMapper | | 5 | `FeatureGateCommandDecoratorTests` | MMCA.Common.Application.Tests | 6 | FeatureGateCommandDecorator, FeatureGatedCommand, FeatureGatedCommandWithValue, ICommandHandler, PlainCommand, Result | | 5 | `FeatureGateQueryDecoratorTests` | MMCA.Common.Application.Tests | 6 | FeatureGatedQuery, FeatureGatedQueryNonGeneric, FeatureGateQueryDecorator, IQueryHandler, PlainQuery, Result | @@ -2163,6 +2253,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 5 | `LoginProtectionService` | MMCA.Common.Infrastructure | 6 | Email, Error, ICacheService, ILoginProtectionService, LoginProtectionSettings, Result | | 5 | `NullableEmailValueConverter` | MMCA.Common.Infrastructure | 1 | Email | | 5 | `NullablePhoneNumberValueConverter` | MMCA.Common.Infrastructure | 1 | PhoneNumber | +| 5 | `PasswordResetTokenService` | MMCA.Common.Infrastructure | 7 | Email, Error, ICacheService, IPasswordResetTokenService, PasswordResetEntry, PasswordResetSettings, Result | | 5 | `PhoneNumberValueConverter` | MMCA.Common.Infrastructure | 1 | PhoneNumber | | 5 | `TenantDataSourceTargets` | MMCA.Common.Infrastructure | 4 | DataSourceKey, TenancySettings, TenancySettingsValidator, TenantDataSourceTarget | | 5 | `DistributedCacheServiceRedisTests` | MMCA.Common.Infrastructure.Redis.Tests | 1 | DistributedCacheService | @@ -2175,6 +2266,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 5 | `DesignAlphaEntity` | MMCA.Common.Infrastructure.Tests | 1 | AuditableAggregateRootEntity | | 5 | `DesignBetaEntity` | MMCA.Common.Infrastructure.Tests | 1 | AuditableAggregateRootEntity | | 5 | `EnumerationValueConverterTests` | MMCA.Common.Infrastructure.Tests | 3 | EnumerationValueConverter, NullableEnumerationValueConverter, Priority | +| 5 | `EventUpcasterStartupValidatorTests` | MMCA.Common.Infrastructure.Tests | 8 | EventUpcasterRegistry, EventUpcasterStartupValidator, IEventUpcaster, IEventUpcasterRegistry, RivalV1ToV3Upcaster, SampleV1ToV2Upcaster, ValidatorSampleV1, ValidatorSampleV2 | | 5 | `ExclusionAggregate` | MMCA.Common.Infrastructure.Tests | 1 | AuditableAggregateRootEntity | | 5 | `FakeAggregate` | MMCA.Common.Infrastructure.Tests | 1 | AuditableAggregateRootEntity | | 5 | `FakeAggregateEntity` | MMCA.Common.Infrastructure.Tests | 1 | AuditableAggregateRootEntity | @@ -2198,6 +2290,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 5 | `TestAggregateEntity` | MMCA.Common.Infrastructure.Tests | 1 | AuditableAggregateRootEntity | | 5 | `TestEntity` | MMCA.Common.Infrastructure.Tests | 2 | AuditableAggregateRootEntity, AuditableBaseEntity | | 5 | `TestSeedUser` | MMCA.Common.Infrastructure.Tests | 1 | AuditableAggregateRootEntity | +| 5 | `UpcastingIntegrationEventConsumerTests` | MMCA.Common.Infrastructure.Tests | 8 | EventUpcasterRegistry, IEventUpcaster, IInboxStore, IIntegrationEventHandler, OrderPlacedV2, RetiredOrderPlaced, RetiredToV2Upcaster, UpcastingIntegrationEventConsumer | | 5 | `WarningCountingLogger` | MMCA.Common.Infrastructure.Tests | 1 | DistributedCacheService | | 5 | `Alert` | MMCA.Common.Shared.Tests | 1 | Severity | | 5 | `EmailTests` | MMCA.Common.Shared.Tests | 1 | Email | @@ -2228,6 +2321,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 5 | `NamingConventionTestsBase` | MMCA.Common.Testing.Architecture | 2 | ArchitectureRules, IArchitectureMap | | 5 | `PiiConventionTestsBase` | MMCA.Common.Testing.Architecture | 2 | ArchitectureRules, IArchitectureMap | | 5 | `ProtoContractTestsBase` | MMCA.Common.Testing.Architecture | 1 | ArchitectureRules | +| 5 | `ServiceContractPurityTestsBase` | MMCA.Common.Testing.Architecture | 2 | ArchitectureRules, IArchitectureMap | | 5 | `SharedLayerTestsBase` | MMCA.Common.Testing.Architecture | 2 | ArchitectureRules, IArchitectureMap | | 5 | `SliceCohesionTestsBase` | MMCA.Common.Testing.Architecture | 2 | ArchitectureRules, IArchitectureMap | | 5 | `SpecificationConventionTestsBase` | MMCA.Common.Testing.Architecture | 2 | ArchitectureRules, IArchitectureMap | @@ -2245,15 +2339,18 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 5 | `EntityServiceBaseIdempotencyRetryTests` | MMCA.Common.UI.Tests | 5 | FreshApiClientFactory, ScriptedHandler, StubTokenStorageService, WidgetDto, WidgetService | | 5 | `EntityServiceBaseTests` | MMCA.Common.UI.Tests | 11 | BaseLookup, CollectionResult, DomainInvariantViolationException, ITokenStorageService, Mocks, PagedCollectionResult, PaginationMetadata, StubHttpClientFactory, StubHttpMessageHandler, WidgetDto, WidgetService | | 5 | `MoneyExtensionsTests` | MMCA.Common.UI.Tests | 2 | Currency, Money | +| 5 | `NotificationBellTests` | MMCA.Common.UI.Tests | 5 | BunitTestBase, INotificationInboxUIService, NotificationBell, NotificationBellHost, NotificationState | | 5 | `NotificationListenerTests` | MMCA.Common.UI.Tests | 7 | ApiSettings, BunitTestBase, ITokenStorageService, NotificationHubService, NotificationState, Severity, TestPrincipal | | 5 | `PseudoLocalizationTests` | MMCA.Common.UI.Tests | 7 | FakeStringLocalizer, FakeStringLocalizerFactory, PseudoLocalizationTests, PseudoLocalizer, PseudoStringLocalizer, PseudoStringLocalizerFactory, SupportedCultures | | 5 | `PushNotificationServiceTests` | MMCA.Common.UI.Tests | 11 | ITokenStorageService, Mocks, Mocks, PagedCollectionResult, PaginationMetadata, PushNotificationDTO, PushNotificationService, SendPushNotificationRequest, StubHttpClientFactory, StubHttpMessageHandler, StubScopeProvider | | 5 | `DependencyInjection` | MMCA.Common.UI.Web | 6 | BlazorCspPolicyProvider, ICspPolicyProvider, IFormFactor, ITokenStorageService, ServerTokenStorageService, WebFormFactor | | 5 | `ServerTokenStorageServiceTests` | MMCA.Common.UI.Web.Tests | 6 | CookieTokenReader, ISessionCookieSync, ITokenRefresher, Mocks, ServerTokenStorageService, SessionCookieEndpoints | | 5 | `WebFormFactorTests` | MMCA.Common.UI.Web.Tests | 5 | ICspPolicyProvider, IFormFactor, ITokenStorageService, ServerTokenStorageService, WebFormFactor | +| 6 | `AnonymousEndpointTests` | MMCA.ADC.Architecture.Tests | 4 | AnonymousEndpointTestsBase, ConferenceModule, EngagementModule, IdentityModule | | 6 | `ProtoContractTests` | MMCA.ADC.Architecture.Tests | 1 | ProtoContractTestsBase | | 6 | `TranslationCompletenessTests` | MMCA.ADC.Architecture.Tests | 1 | LocalizationResourceTestsBase | | 6 | `SpeakerDeletedHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | DomainEntityState, IEventBus, IIntegrationEvent, Mocks, SpeakerChanged, SpeakerDeletedHandler, SpeakerUnlinkedFromUser | +| 6 | `ActivityInvariants` | MMCA.ADC.Conference.Domain | 3 | CommonInvariants, Error, Result | | 6 | `Category` | MMCA.ADC.Conference.Domain | 7 | AuditableAggregateRootEntity, CategoryChanged, CategoryInvariants, CategoryItem, CategoryItemChanged, DomainEntityState, Result | | 6 | `CategoryInvariants` | MMCA.ADC.Conference.Domain | 4 | CategoryItem, CommonInvariants, Error, Result | | 6 | `CategoryItem` | MMCA.ADC.Conference.Domain | 4 | AuditableBaseEntity, Category, CategoryInvariants, Result | @@ -2325,17 +2422,9 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 6 | `SpecificationCompositionTests` | MMCA.Common.Domain.Tests | 8 | AgeGreaterThanSpecification, AndSpecification, CompositionTestEntity, InvocationFinder, NameStartsWithSpecification, NotSpecification, OrSpecification, ParameterFinder | | 6 | `SpecificationTests` | MMCA.Common.Domain.Tests | 6 | AgeGreaterThanSpec, AndSpecification, NameStartsWithSpec, NotSpecification, OrSpecification, TestEntity | | 6 | `UserNotificationTests` | MMCA.Common.Domain.Tests | 1 | UserNotification | -| 6 | `ApplicationDbContext` | MMCA.Common.Infrastructure | 26 | AuditableBaseEntity, AuditSaveChangesInterceptor, AuditTrailEntry, AuditTrailSaveChangesInterceptor, AuditTrailSettings, CrossDataSourceDegradeConvention, DataSource, DataSourceKey, DataSourceModelCacheKeyFactory, DetectChangesScope, DomainEventSaveChangesInterceptor, IAuditableEntity, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, IEntityTypeConfigurationCosmos, IEntityTypeConfigurationSqlite, IEntityTypeConfigurationSQLServer, InboxMessage, ITenantEntity, OutboxMessage …(+6) | -| 6 | `AuditSaveChangesInterceptor` | MMCA.Common.Infrastructure | 2 | ApplicationDbContext, IAuditableEntity | -| 6 | `AuditTrailSaveChangesInterceptor` | MMCA.Common.Infrastructure | 10 | ApplicationDbContext, AuditTrailEntry, CaptureContext, IAuditedEntity, InboxMessage, OutboxMessage, PendingEntityKey, PiiAttribute, PiiRedactor, ScheduledJobEntry | -| 6 | `DataSourceModelCacheKeyFactory` | MMCA.Common.Infrastructure | 1 | ApplicationDbContext | -| 6 | `DeferredDispatch` | MMCA.Common.Infrastructure | 2 | CapturedState, DomainEventSaveChangesInterceptor | -| 6 | `DomainEventSaveChangesInterceptor` | MMCA.Common.Infrastructure | 11 | AggregateCapture, ApplicationDbContext, CapturedState, DeferredDispatch, IAggregateRoot, IDomainEvent, IDomainEventDispatcher, IIntegrationEvent, IOutboxSignal, OutboxFinalizer, OutboxMessage | | 6 | `EFReadRepository` | MMCA.Common.Infrastructure | 12 | AuditableBaseEntity, BaseLookup, Error, IReadRepository, ISpecification, KeysetCollectionResult, KeysetCursor, KeysetPageRequest, KeysetQueryBuilder, QuerySpecification, Result, SpecificationEvaluator | | 6 | `EFReadRepositoryDecorator` | MMCA.Common.Infrastructure | 8 | AuditableBaseEntity, BaseLookup, IReadRepository, ISpecification, KeysetCollectionResult, KeysetPageRequest, ProfilingHelper, Result | | 6 | `EntityTypeConfiguration` | MMCA.Common.Infrastructure | 9 | AuditableBaseEntity, CosmosIntIdValueGenerator, DataSource, EntityTypeConfigurationBase, IEntityTypeConfigurationCosmos, IEntityTypeConfigurationSqlite, IEntityTypeConfigurationSQLServer, NamespaceConventions, UseDataSourceAttribute | -| 6 | `OutboxFinalizer` | MMCA.Common.Infrastructure | 2 | ApplicationDbContext, OutboxMessage | -| 6 | `TenantSaveChangesInterceptor` | MMCA.Common.Infrastructure | 3 | ApplicationDbContext, CrossTenantWriteException, ITenantEntity | | 6 | `AllSpecification` | MMCA.Common.Infrastructure.Tests | 2 | Specification, SpecTestEntity | | 6 | `BbbSpecification` | MMCA.Common.Infrastructure.Tests | 2 | Specification, SpecTestEntity | | 6 | `BetaSpecification` | MMCA.Common.Infrastructure.Tests | 2 | Specification, SpecTestEntity | @@ -2353,6 +2442,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 6 | `NoMatchSpecification` | MMCA.Common.Infrastructure.Tests | 2 | Specification, SpecTestEntity | | 6 | `OrderedSpecification` | MMCA.Common.Infrastructure.Tests | 2 | QuerySpecification, SpecTestEntity | | 6 | `PagedSpecification` | MMCA.Common.Infrastructure.Tests | 2 | QuerySpecification, SpecTestEntity | +| 6 | `PasswordResetTokenServiceTests` | MMCA.Common.Infrastructure.Tests | 6 | ErrorType, FakeCacheService, PasswordResetEntry, PasswordResetSettings, PasswordResetTokenService, Result | | 6 | `PhoneNumberValueConverterTests` | MMCA.Common.Infrastructure.Tests | 3 | NullablePhoneNumberValueConverter, PhoneNumber, PhoneNumberValueConverter | | 6 | `PlainDbContext` | MMCA.Common.Infrastructure.Tests | 1 | StampedEntity | | 6 | `PortableThing` | MMCA.Common.Infrastructure.Tests | 2 | AuditableAggregateRootEntity, PortablePrincipal | @@ -2367,7 +2457,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 6 | `TrackedSpecification` | MMCA.Common.Infrastructure.Tests | 2 | QuerySpecification, SpecTestEntity | | 6 | `UnorderedQuerySpecification` | MMCA.Common.Infrastructure.Tests | 2 | QuerySpecification, SpecTestEntity | | 6 | `EnumerationSerializationTests` | MMCA.Common.Shared.Tests | 4 | Alert, EnumerationJsonConverterFactory, Grade, Severity | -| 6 | `AuthUIService` | MMCA.Common.UI | 10 | AuthenticationResponse, ChangePasswordRequest, IAuthUIService, IPushRegistrationService, ITokenRefresher, ITokenStorageService, JwtAuthenticationStateProvider, LoginRequest, OAuthCodeExchangeRequest, RegisterRequest | +| 6 | `AuthUIService` | MMCA.Common.UI | 12 | AuthenticationResponse, ChangePasswordRequest, ForgotPasswordRequest, IAuthUIService, IPushRegistrationService, ITokenRefresher, ITokenStorageService, JwtAuthenticationStateProvider, LoginRequest, OAuthCodeExchangeRequest, RegisterRequest, ResetPasswordRequest | | 6 | `NoOpAuthUIService` | MMCA.Common.UI.Gallery | 4 | AuthenticationResponse, IAuthUIService, LoginRequest, RegisterRequest | | 6 | `MobileInfiniteScrollListTests` | MMCA.Common.UI.Tests | 2 | BunitTestBase, MobileInfiniteScrollList | | 6 | `NavMenuTests` | MMCA.Common.UI.Tests | 8 | BunitTestBase, IAuthUIService, IUIModule, LayoutSettings, NavItem, NavSection, StubUiModule, TestPrincipal | @@ -2376,6 +2466,11 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 6 | `NotificationSendTests` | MMCA.Common.UI.Tests | 5 | BunitTestBase, IPushNotificationUIService, NotificationSend, PushNotificationDTO, SendPushNotificationRequest | | 6 | `RegisterFormTests` | MMCA.Common.UI.Tests | 3 | BunitTestBase, IAuthUIService, RegisterRequest | | 6 | `TestGridPage` | MMCA.Common.UI.Tests | 2 | DataGridListPageBase, WidgetRow | +| 7 | `ActivityDescriptionRules` | MMCA.ADC.Conference.Application | 2 | ActivityInvariants, OptionalStringRules | +| 7 | `ActivityNameRules` | MMCA.ADC.Conference.Application | 2 | ActivityInvariants, RequiredStringRules | +| 7 | `ActivityVenueAddressRules` | MMCA.ADC.Conference.Application | 2 | ActivityInvariants, OptionalStringRules | +| 7 | `ActivityVenueNameRules` | MMCA.ADC.Conference.Application | 2 | ActivityInvariants, OptionalStringRules | +| 7 | `ActivityVenueUrlRules` | MMCA.ADC.Conference.Application | 2 | ActivityInvariants, OptionalStringRules | | 7 | `AddCategoryItemCommand` | MMCA.ADC.Conference.Application | 2 | Category, ICacheInvalidating | | 7 | `CategoryItemDTOMapper` | MMCA.ADC.Conference.Application | 3 | CategoryItem, CategoryItemDTO, IEntityDTOMapper | | 7 | `CategoryItemNameRules` | MMCA.ADC.Conference.Application | 1 | CategoryInvariants | @@ -2384,6 +2479,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 7 | `EventNameRules` | MMCA.ADC.Conference.Application | 2 | EventInvariants, RequiredStringRules | | 7 | `EventOrganizerContactEmailRules` | MMCA.ADC.Conference.Application | 2 | EmailRules, EventInvariants | | 7 | `EventSponsorshipPacketUrlRules` | MMCA.ADC.Conference.Application | 2 | EventInvariants, OptionalStringRules | +| 7 | `EventTicketingUrlRules` | MMCA.ADC.Conference.Application | 2 | EventInvariants, OptionalStringRules | | 7 | `EventTimeZoneRules` | MMCA.ADC.Conference.Application | 1 | EventInvariants | | 7 | `QuestionTextRules` | MMCA.ADC.Conference.Application | 1 | QuestionInvariants | | 7 | `RemoveCategoryItemCommand` | MMCA.ADC.Conference.Application | 2 | Category, ICacheInvalidating | @@ -2418,6 +2514,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 7 | `Speaker` | MMCA.ADC.Conference.Domain | 12 | AuditableAggregateRootEntity, DomainEntityState, Email, Error, IAuditedEntity, Result, SpeakerCategoryItem, SpeakerCategoryItemChanged, SpeakerChanged, SpeakerInvariants, SpeakerQuestionAnswer, SpeakerQuestionAnswerChanged | | 7 | `SpeakerCategoryItem` | MMCA.ADC.Conference.Domain | 3 | AuditableBaseEntity, Result, Speaker | | 7 | `SpeakerQuestionAnswer` | MMCA.ADC.Conference.Domain | 4 | AuditableBaseEntity, Result, Speaker, SpeakerInvariants | +| 7 | `ActivityInvariantsTests` | MMCA.ADC.Conference.Domain.Tests | 1 | ActivityInvariants | | 7 | `CategoryInvariantsTests` | MMCA.ADC.Conference.Domain.Tests | 2 | Category, CategoryInvariants | | 7 | `CategoryTests` | MMCA.ADC.Conference.Domain.Tests | 3 | Category, CategoryChanged, DomainEntityState | | 7 | `EventInvariantsTests` | MMCA.ADC.Conference.Domain.Tests | 1 | EventInvariants | @@ -2428,7 +2525,6 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 7 | `ConferenceCategoryDetail` | MMCA.ADC.Conference.UI | 9 | Category, CategoryItemDTO, CategoryItemService, ConferenceCategoryDTO, ConferenceRoutePaths, ErrorMessages, ICategoryItemUIService, IConferenceCategoryUIService, Severity | | 7 | `ConferenceCategoryList` | MMCA.ADC.Conference.UI | 7 | ConferenceCategoryDTO, ConferenceRoutePaths, DataGridListPageBase, ErrorMessages, IConferenceCategoryUIService, ListPageActions, MobileInfiniteScrollList | | 7 | `EventList` | MMCA.ADC.Conference.UI | 8 | ConferenceRoutePaths, DataGridListPageBase, ErrorMessages, EventDTO, EventService, IEventUIService, ListPageActions, MobileInfiniteScrollList | -| 7 | `PublicEventList` | MMCA.ADC.Conference.UI | 7 | ConferenceRoutePaths, DataGridListPageBase, EventDTO, EventService, IEventUIService, ListPageActions, MobileInfiniteScrollList | | 7 | `PublicSessionListView` | MMCA.ADC.Conference.UI | 9 | BookmarkService, ConferenceRoutePaths, IHapticFeedbackService, ISessionBookmarkUIService, ListPageActions, MobileInfiniteScrollList, SessionDTO, Severity, SpeakerInfo | | 7 | `QuestionList` | MMCA.ADC.Conference.UI | 8 | ConferenceRoutePaths, DataGridListPageBase, ErrorMessages, IQuestionUIService, ListPageActions, MobileInfiniteScrollList, QuestionDTO, QuestionService | | 7 | `CreateLivePollRequestValidator` | MMCA.ADC.Engagement.Application | 2 | CreateLivePollRequest, LivePollInvariants | @@ -2460,62 +2556,26 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 7 | `VersionedEntityController` | MMCA.Common.API.Tests | 4 | EntityControllerBase, IEntityQueryService, VersionedDTO, VersionedEntity | | 7 | `IUnitOfWork` | MMCA.Common.Application | 4 | AuditableAggregateRootEntity, AuditableBaseEntity, IReadRepository, IRepository | | 7 | `NavigationLoaderTests` | MMCA.Common.Application.Tests | 4 | IReadRepository, NavigationLoader, StubChild, StubParent | -| 7 | `CommonArchitectureMap` | MMCA.Common.Architecture.Tests | 10 | ApiControllerBase, ApplicationDbContext, ArchitectureMapBase, BaseEntity, DomainEventDispatcher, Layer, LayerRef, Result, ResultGrpcExtensions, UISharedAssemblyReference | -| 7 | `FrameworkSanityTests` | MMCA.Common.Architecture.Tests | 7 | ApplicationDbContext, ArchitectureAssert, DomainEventDispatcher, IJwksProvider, ILiveChannelPublisher, IMessageBus, ResultGrpcExtensions | | 7 | `SpecificationFitnessTests` | MMCA.Common.Architecture.Tests | 6 | ArchitectureRules, NavigatingQuerySpec, NavigatingSpec, ScalarOnlyQuerySpec, ScalarOnlySpec, SpecTestMap | | 7 | `SpecTestMap` | MMCA.Common.Architecture.Tests | 4 | ArchitectureMapBase, Layer, LayerRef, SpecificationFitnessTests | | 7 | `PushNotification` | MMCA.Common.Domain | 6 | AuditableAggregateRootEntity, CommonInvariants, PushNotificationCreated, PushNotificationInvariants, PushNotificationStatus, Result | | 7 | `PushNotificationInvariantsTests` | MMCA.Common.Domain.Tests | 2 | PushNotificationInvariants, Result | -| 7 | `CosmosDbContext` | MMCA.Common.Infrastructure | 5 | ApplicationDbContext, DataSource, IEntityConfigurationAssemblyProvider, OutboxMessage, PhysicalDataSource | | 7 | `EFRepositoryDecorator` | MMCA.Common.Infrastructure | 6 | AuditableBaseEntity, EFReadRepositoryDecorator, IRepository, IRowVersioned, IUpdatePropertySetter, ProfilingHelper | | 7 | `EntityTypeConfigurationCosmos` | MMCA.Common.Infrastructure | 3 | AuditableBaseEntity, DataSource, EntityTypeConfiguration | | 7 | `EntityTypeConfigurationSqlite` | MMCA.Common.Infrastructure | 3 | AuditableBaseEntity, DataSource, EntityTypeConfiguration | | 7 | `EntityTypeConfigurationSQLServer` | MMCA.Common.Infrastructure | 3 | AuditableBaseEntity, DataSource, EntityTypeConfiguration | -| 7 | `IDbContextFactory` | MMCA.Common.Infrastructure | 3 | ApplicationDbContext, DataSource, DataSourceKey | -| 7 | `IPhysicalDbContextFactory` | MMCA.Common.Infrastructure | 3 | ApplicationDbContext, DataSourceKey, PhysicalDataSource | | 7 | `IRepositoryFactory` | MMCA.Common.Infrastructure | 4 | AuditableAggregateRootEntity, AuditableBaseEntity, IReadRepository, IRepository | -| 7 | `SqliteDbContext` | MMCA.Common.Infrastructure | 4 | ApplicationDbContext, DataSource, IEntityConfigurationAssemblyProvider, PhysicalDataSource | -| 7 | `SQLServerDbContext` | MMCA.Common.Infrastructure | 5 | ApplicationDbContext, DataSource, IEntityConfigurationAssemblyProvider, PersistenceSettings, PhysicalDataSource | -| 7 | `AddMultiTenancyTests` | MMCA.Common.Infrastructure.Tests | 11 | ConnectionStringSettings, DataSourceResolver, DataSourcesSettings, ITenantContext, TenancySettings, TenancySettingsValidator, TenantContext, TenantDataSourceOverrideSettings, TenantEntrySettings, TenantResolutionStrategy, TenantSaveChangesInterceptor | -| 7 | `CleanupTestContext` | MMCA.Common.Infrastructure.Tests | 11 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, InboxMessage, IOutboxSignal, NullAssemblyProvider, OutboxMessage, TestPhysicalDataSources | -| 7 | `CommitFailingDbContext` | MMCA.Common.Infrastructure.Tests | 12 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, FailingDatabaseFacade, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, OutboxMessage, TestAggregate, TestPhysicalDataSources | -| 7 | `DegradeTestContext` | MMCA.Common.Infrastructure.Tests | 8 | ApplicationDbContext, DataSourceKey, DegradeCustomer, DegradeOrder, EmptyAssemblyProvider, EmptyAssemblyProvider, IEntityDataSourceRegistry, PhysicalDataSource | -| 7 | `DetectionTestDbContext` | MMCA.Common.Infrastructure.Tests | 10 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, TestPhysicalDataSources, Widget | | 7 | `EFReadRepositoryDecoratorAdditionalTests` | MMCA.Common.Infrastructure.Tests | 3 | EFReadRepositoryDecorator, FakeEntity, IReadRepository | | 7 | `EFReadRepositoryDecoratorTests` | MMCA.Common.Infrastructure.Tests | 4 | BaseLookup, EFReadRepositoryDecorator, FakeEntity, IReadRepository | -| 7 | `ExclusionTestDbContext` | MMCA.Common.Infrastructure.Tests | 8 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, ExclusionAggregate, IEntityDataSourceRegistry, NullAssemblyProvider, TestPhysicalDataSources | -| 7 | `FailingDatabaseFacade` | MMCA.Common.Infrastructure.Tests | 2 | AlwaysRetryExecutionStrategy, CommitFailingDbContext | -| 7 | `FailingSaveInterceptor` | MMCA.Common.Infrastructure.Tests | 1 | OutboxRoutingTestDbContext | -| 7 | `GateTestContext` | MMCA.Common.Infrastructure.Tests | 13 | ApplicationDbContext, AuditSaveChangesInterceptor, DataSource, DataSourceKey, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, GateTestContext, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, PhysicalDataSource, SchedulerSettings | -| 7 | `GateTestContext` | MMCA.Common.Infrastructure.Tests | 14 | ApplicationDbContext, AuditSaveChangesInterceptor, AuditTrailSettings, DataSource, DataSourceKey, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, GateTestContext, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, NullAssemblyProvider, PhysicalDataSource | -| 7 | `InboxTestDbContext` | MMCA.Common.Infrastructure.Tests | 4 | ApplicationDbContext, IEntityConfigurationAssemblyProvider, InboxMessage, TestPhysicalDataSources | -| 7 | `IntegrityTestDbContext` | MMCA.Common.Infrastructure.Tests | 11 | ApplicationDbContext, AuditSaveChangesInterceptor, DataSourceKey, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IntegrityAggregate, IOutboxSignal, NullAssemblyProvider, PhysicalDataSource | -| 7 | `MidSaveContextCreatingDbContext` | MMCA.Common.Infrastructure.Tests | 10 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, IOutboxSignal, ReentrantSaveInterceptor, TestPhysicalDataSources | -| 7 | `NamedSoftDeleteTestDbContext` | MMCA.Common.Infrastructure.Tests | 2 | ApplicationDbContext, ProjectedTestEntity | -| 7 | `OutboxRoutingTestDbContext` | MMCA.Common.Infrastructure.Tests | 10 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, FailingSaveInterceptor, IEntityDataSourceRegistry, NullAssemblyProvider, OutboxMessage, TestAggregate, TestPhysicalDataSources | -| 7 | `OutboxTestDbContext` | MMCA.Common.Infrastructure.Tests | 4 | ApplicationDbContext, IEntityConfigurationAssemblyProvider, OutboxMessage, TestPhysicalDataSources | | 7 | `OwnsMoneyTests` | MMCA.Common.Infrastructure.Tests | 6 | Currency, HandRolledOwner, HelperOwner, Money, MoneyTestDbContext, PropertyFacets | | 7 | `PortableThingConfiguration` | MMCA.Common.Infrastructure.Tests | 3 | DataSource, EntityTypeConfiguration, PortableThing | -| 7 | `QueryShapeTestDbContext` | MMCA.Common.Infrastructure.Tests | 10 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, Product, TestPhysicalDataSources | -| 7 | `ReentrantSaveInterceptor` | MMCA.Common.Infrastructure.Tests | 1 | MidSaveContextCreatingDbContext | -| 7 | `SchedulerTestContext` | MMCA.Common.Infrastructure.Tests | 10 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, ScheduledJobEntry, TestPhysicalDataSources | -| 7 | `SoftDeleteTestDbContext` | MMCA.Common.Infrastructure.Tests | 11 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, SoftDeletableEntity, SoftDeletableTestEntity, TestPhysicalDataSources | -| 7 | `SpecificationTestDbContext` | MMCA.Common.Infrastructure.Tests | 3 | ApplicationDbContext, SpecTestChild, SpecTestEntity | -| 7 | `StampTestDbContext` | MMCA.Common.Infrastructure.Tests | 10 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, StampedEntity, TestPhysicalDataSources | -| 7 | `TenantTestContext` | MMCA.Common.Infrastructure.Tests | 16 | ApplicationDbContext, AuditSaveChangesInterceptor, AuditTrailEntry, AuditTrailSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, NullAssemblyProvider, PlainThing, TenantSaveChangesInterceptor, TenantThing, TestPhysicalDataSources, TrailedTenantThing | -| 7 | `TestApplicationDbContext` | MMCA.Common.Infrastructure.Tests | 10 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, IOutboxSignal, TestEntity, TestPhysicalDataSources | -| 7 | `TestAuditDbContext` | MMCA.Common.Infrastructure.Tests | 10 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, TestAuditEntity, TestPhysicalDataSources | | 7 | `TestConfigDbContext` | MMCA.Common.Infrastructure.Tests | 2 | TestAggregateEntity, TestAggregateEntityConfiguration | -| 7 | `TestDomainEventDbContext` | MMCA.Common.Infrastructure.Tests | 8 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IEntityDataSourceRegistry, NullAssemblyProvider, TestAggregate, TestPhysicalDataSources | | 7 | `TestNonAggregateConfigDbContext` | MMCA.Common.Infrastructure.Tests | 2 | TestNonAggregateEntity, TestNonAggregateEntityConfiguration | -| 7 | `TestNonOutboxContext` | MMCA.Common.Infrastructure.Tests | 9 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, TestPhysicalDataSources | -| 7 | `TestOutboxContext` | MMCA.Common.Infrastructure.Tests | 10 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, OutboxMessage, TestPhysicalDataSources | -| 7 | `TransactionTestDbContext` | MMCA.Common.Infrastructure.Tests | 11 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, OutboxMessage, TestAggregate, TestPhysicalDataSources | -| 7 | `UniqueIndexTestDbContext` | MMCA.Common.Infrastructure.Tests | 12 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, FilteredIndexEntity, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, NullAssemblyProvider, TestPhysicalDataSources, UniqueNamedEntity | | 7 | `DependencyInjection` | MMCA.Common.UI | 27 | ApiSettings, ApiUserPreferenceReader, ApiUserPreferenceWriter, AuthDelegatingHandler, AuthUIService, CultureDelegatingHandler, DefaultOAuthUISettings, EndpointCultureApplier, HttpResilienceDefaults, IAuthUIService, ICultureApplier, IEntityService, IFormFactor, IOAuthUISettings, ISessionCookieSync, IUIModule, IUserPreferenceReader, IUserPreferenceWriter, JsFetchSessionCookieSync, LayoutSettings …(+7) | | 7 | `DataGridListPageBaseTests` | MMCA.Common.UI.Tests | 6 | BunitTestBase, ListPageQueryStateService, ListPageStateService, Severity, TestGridPage, WidgetRow | | 8 | `CategoryItemsController` | MMCA.ADC.Conference.API | 17 | AddCategoryItemCommand, AddCategoryItemRequest, BaseLookup, CategoryItem, CategoryItemDTO, CollectionResult, ConferencePermissions, EntityControllerBase, ICommandHandler, IEntityQueryService, PagedCollectionResult, QueryFilterModelBinder, RemoveCategoryItemCommand, Result, Route, UpdateCategoryItemCommand, UpdateCategoryItemRequest | | 8 | `ConferenceCategoriesController` | MMCA.ADC.Conference.API | 16 | AggregateRootEntityControllerBase, BaseLookup, Category, CollectionResult, ConferenceCategoryCreateRequest, ConferenceCategoryDTO, ConferenceCategoryUpdateRequest, ConferencePermissions, DeleteEntityCommand, ICommandHandler, IEntityQueryService, PagedCollectionResult, QueryFilterModelBinder, Result, Route, UpdateConferenceCategoryCommand | +| 8 | `ActivityUpdateRequestValidator` | MMCA.ADC.Conference.Application | 8 | ActivityDescriptionRules, ActivityNameRules, ActivitySortOrderRules, ActivityTimeRangeRules, ActivityUpdateRequest, ActivityVenueAddressRules, ActivityVenueNameRules, ActivityVenueUrlRules | | 8 | `AddCategoryItemCommandValidator` | MMCA.ADC.Conference.Application | 3 | AddCategoryItemCommand, CategoryItemNameRules, CategoryItemSortRules | | 8 | `AddCategoryItemHandler` | MMCA.ADC.Conference.Application | 8 | AddCategoryItemCommand, Category, CategoryItemDTO, CategoryItemDTOMapper, Error, ICommandHandler, IUnitOfWork, Result | | 8 | `AddEventQuestionAnswerCommand` | MMCA.ADC.Conference.Application | 2 | Event, ICacheInvalidating | @@ -2529,7 +2589,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 8 | `EventCreateRequest` | MMCA.ADC.Conference.Application | 3 | Event, ICacheInvalidating, ICreateRequest | | 8 | `EventQuestionAnswerDTOMapper` | MMCA.ADC.Conference.Application | 3 | EventQuestionAnswer, EventQuestionAnswerDTO, IEntityDTOMapper | | 8 | `EventSpeakerDTOMapper` | MMCA.ADC.Conference.Application | 3 | EventSpeaker, EventSpeakerDTO, IEntityDTOMapper | -| 8 | `EventUpdateRequestValidator` | MMCA.ADC.Conference.Application | 6 | EventDateRangeRules, EventNameRules, EventOrganizerContactEmailRules, EventSponsorshipPacketUrlRules, EventTimeZoneRules, EventUpdateRequest | +| 8 | `EventUpdateRequestValidator` | MMCA.ADC.Conference.Application | 7 | EventDateRangeRules, EventNameRules, EventOrganizerContactEmailRules, EventSponsorshipPacketUrlRules, EventTicketingUrlRules, EventTimeZoneRules, EventUpdateRequest | | 8 | `LinkUserToSpeakerCommand` | MMCA.ADC.Conference.Application | 3 | ICacheInvalidating, ITransactional, Speaker | | 8 | `PublishedEventSpecification` | MMCA.ADC.Conference.Application | 2 | Event, Specification | | 8 | `PublishEventCommand` | MMCA.ADC.Conference.Application | 2 | Event, ICacheInvalidating | @@ -2560,7 +2620,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 8 | `UpdateQuestionCommand` | MMCA.ADC.Conference.Application | 4 | ICacheInvalidating, ICommandWithRequest, Question, QuestionUpdateRequest | | 8 | `UpdateRoomCommand` | MMCA.ADC.Conference.Application | 2 | Event, ICacheInvalidating | | 8 | `UpdateSpeakerCommand` | MMCA.ADC.Conference.Application | 4 | ICacheInvalidating, ICommandWithRequest, Speaker, SpeakerUpdateRequest | -| 8 | `UserRegisteredHandler` | MMCA.ADC.Conference.Application | 8 | Email, IEventBus, IIntegrationEventHandler, IRepository, IUnitOfWork, Speaker, SpeakerLinkedToUser, UserRegistered | +| 8 | `UserRegisteredHandler` | MMCA.ADC.Conference.Application | 8 | Email, IEntityQuerier, IEventBus, IIntegrationEventHandler, IUnitOfWork, Speaker, SpeakerLinkedToUser, UserRegistered | | 8 | `CategoryItemDTOMapperTests` | MMCA.ADC.Conference.Application.Tests | 3 | Category, CategoryItem, CategoryItemDTOMapper | | 8 | `RecordingUnitOfWork` | MMCA.ADC.Conference.Application.Tests | 6 | AuditableAggregateRootEntity, AuditableBaseEntity, InMemoryRepository, IReadRepository, IRepository, IUnitOfWork | | 8 | `TestCategoryItemValidator` | MMCA.ADC.Conference.Application.Tests | 3 | CategoryItemNameRules, CategoryItemSortRules, TestCategoryItemModel | @@ -2570,6 +2630,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 8 | `TestRoomValidator` | MMCA.ADC.Conference.Application.Tests | 7 | RoomAccessibilityInfoRules, RoomCapacityRules, RoomFloorRules, RoomLocationRules, RoomNameRules, RoomSortRules, TestRoomModel | | 8 | `TestSessionValidator` | MMCA.ADC.Conference.Application.Tests | 3 | SessionEventIdRules, SessionTitleRules, TestSessionModel | | 8 | `TestSpeakerValidator` | MMCA.ADC.Conference.Application.Tests | 3 | SpeakerFirstNameRules, SpeakerLastNameRules, TestSpeakerModel | +| 8 | `Activity` | MMCA.ADC.Conference.Domain | 6 | ActivityChanged, ActivityInvariants, AuditableAggregateRootEntity, DomainEntityState, Event, Result | | 8 | `Session` | MMCA.ADC.Conference.Domain | 15 | AuditableAggregateRootEntity, DomainEntityState, Error, Event, IAuditedEntity, Result, Room, SessionCategoryItem, SessionCategoryItemChanged, SessionChanged, SessionInvariants, SessionQuestionAnswer, SessionQuestionAnswerChanged, SessionSpeaker, SessionSpeakerChanged | | 8 | `SessionCategoryItem` | MMCA.ADC.Conference.Domain | 3 | AuditableBaseEntity, Result, Session | | 8 | `SessionQuestionAnswer` | MMCA.ADC.Conference.Domain | 4 | AuditableBaseEntity, Result, Session, SessionInvariants | @@ -2599,7 +2660,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 8 | `SessionizeServiceTests` | MMCA.ADC.Conference.Infrastructure.Tests | 6 | Question, SessionizeCategory, SessionizeQuestion, SessionizeResponse, SessionizeRoom, SessionizeService | | 8 | `CurrentEventSelector` | MMCA.ADC.Conference.Shared | 1 | Event | | 8 | `EventDetail` | MMCA.ADC.Conference.UI | 9 | ConferenceRoutePaths, ErrorMessages, Event, EventDTO, EventService, IEventUIService, QuestionModerationDefault, RefreshFromSessionizeResultDTO, Severity | -| 8 | `PublicEventDetail` | MMCA.ADC.Conference.UI | 10 | ConferenceRoutePaths, Event, EventDTO, EventService, IClipboardService, IEventUIService, IGeocodingService, IGeolocationService, IMapNavigationService, Severity | +| 8 | `PublicEventDetail` | MMCA.ADC.Conference.UI | 11 | ConferenceReadAudience, ConferenceRoutePaths, Event, EventDTO, EventService, IClipboardService, IEventUIService, IGeocodingService, IGeolocationService, IMapNavigationService, Severity | | 8 | `PublicSpeakerDetail` | MMCA.ADC.Conference.UI | 9 | ConferenceRoutePaths, ISessionUIService, ISpeakerUIService, SessionDTO, SessionService, Severity, Speaker, SpeakerDTO, SpeakerService | | 8 | `QuestionDetail` | MMCA.ADC.Conference.UI | 7 | ConferenceRoutePaths, ErrorMessages, IQuestionUIService, Question, QuestionDTO, QuestionService, Severity | | 8 | `RoomDetail` | MMCA.ADC.Conference.UI | 10 | ConferenceRoutePaths, ErrorMessages, EventInfo, EventLookupService, IEventLookupService, IRoomUIService, Room, RoomDTO, RoomService, Severity | @@ -2613,10 +2674,9 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 8 | `GetBookmarkedSessionIdsHandler` | MMCA.ADC.Engagement.Application | 5 | GetBookmarkedSessionIdsQuery, IQueryHandler, IUnitOfWork, Result, UserSessionBookmark | | 8 | `GetPointsOverviewHandler` | MMCA.ADC.Engagement.Application | 9 | GetPointsOverviewQuery, IQueryHandler, IUnitOfWork, OverviewRow, PointsActivityTotalDTO, PointsEntry, PointsEntryDTO, PointsOverviewDTO, Result | | 8 | `ModerateQuestionHandler` | MMCA.ADC.Engagement.Application | 19 | BestEffort, Error, ICommandHandler, IEventLiveValidationService, ILiveChannelPublishQueue, IUnitOfWork, LiveChannelPublishWorkItem, LivePollAuthorization, LivePollChannel, ModerateQuestionCommand, ModerationAction, QuestionStatus, Result, SessionQuestion, SessionQuestionAnsweredPayload, SessionQuestionApprovedPayload, SessionQuestionChannel, SessionQuestionDismissedPayload, SessionQuestionPendingCountChangedPayload | -| 8 | `SessionQuestionSubmittedPointsHandler` | MMCA.ADC.Engagement.Application | 8 | DomainEntityState, IDomainEventHandler, IPointsAwarder, IUnitOfWork, PointsActivityType, PointsSubjectKeys, SessionQuestion, SessionQuestionChanged | | 8 | `SessionQuestionUpvoteChangedHandler` | MMCA.ADC.Engagement.Application | 11 | BestEffort, IDomainEventHandler, ILiveChannelPublishQueue, IUnitOfWork, LiveChannelPublishWorkItem, LivePollChannel, SessionQuestion, SessionQuestionChannel, SessionQuestionUpvote, SessionQuestionUpvoteChanged, SessionQuestionUpvoteChangedPayload | | 8 | `SessionQuestionViewBuilder` | MMCA.ADC.Engagement.Application | 5 | IQueryableExecutor, IUnitOfWork, SessionQuestion, SessionQuestionDTO, SessionQuestionUpvote | -| 8 | `ToggleUpvoteHandler` | MMCA.ADC.Engagement.Application | 8 | Error, ICommandHandler, IRepository, IUnitOfWork, Result, SessionQuestion, SessionQuestionUpvote, ToggleUpvoteCommand | +| 8 | `ToggleUpvoteHandler` | MMCA.ADC.Engagement.Application | 9 | Error, ICommandHandler, IEntityReader, IRepository, IUnitOfWork, Result, SessionQuestion, SessionQuestionUpvote, ToggleUpvoteCommand | | 8 | `UserDeletedPointsHandler` | MMCA.ADC.Engagement.Application | 4 | IIntegrationEventHandler, IUnitOfWork, LeaderboardOptIn, UserDeleted | | 8 | `UserSessionBookmarkDTOMapper` | MMCA.ADC.Engagement.Application | 3 | IEntityDTOMapper, UserSessionBookmark, UserSessionBookmarkDTO | | 8 | `AwarderMocks` | MMCA.ADC.Engagement.Application.Tests | 2 | IRepository, PointsEntry | @@ -2654,6 +2714,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 8 | `DeleteUserCommand` | MMCA.ADC.Identity.Application | 3 | ICacheInvalidating, IUserOwnedRequest, User | | 8 | `GetUserAvatarHandler` | MMCA.ADC.Identity.Application | 7 | Error, GetUserAvatarQuery, IQueryHandler, IUnitOfWork, Result, User, UserAvatarDTO | | 8 | `GetUsersHandler` | MMCA.ADC.Identity.Application | 11 | Email, GetUsersQuery, IQueryableExecutor, IQueryHandler, IUnitOfWork, PagedCollectionResult, PaginationMetadata, PagingMath, Result, User, UserListDTO | +| 8 | `ResetPasswordCommand` | MMCA.ADC.Identity.Application | 4 | ICacheInvalidating, ICommandWithRequest, ResetPasswordRequest, User | | 8 | `SetUserAvatarHandler` | MMCA.ADC.Identity.Application | 10 | Error, ICommandHandler, IFileStorageService, IImageProcessor, ImageContentSniffer, IUnitOfWork, Result, SetUserAvatarCommand, User, UserAvatarDTO | | 8 | `SpeakerLinkedToUserHandler` | MMCA.ADC.Identity.Application | 4 | IIntegrationEventHandler, IUnitOfWork, SpeakerLinkedToUser, User | | 8 | `SpeakerUnlinkedFromUserHandler` | MMCA.ADC.Identity.Application | 4 | IIntegrationEventHandler, IUnitOfWork, SpeakerUnlinkedFromUser, User | @@ -2665,14 +2726,12 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 8 | `UserBuilder` | MMCA.ADC.Identity.Domain.Tests | 3 | EntityBuilderBase, User, UserRole | | 8 | `UserInvariantsAndRoleTests` | MMCA.ADC.Identity.Domain.Tests | 4 | Result, User, UserDeleted, UserRole | | 8 | `UserTests` | MMCA.ADC.Identity.Domain.Tests | 3 | User, UserPasswordChanged, UserRole | -| 8 | `ModuleApplicationDbContext` | MMCA.ADC.Identity.Infrastructure | 4 | ApplicationDbContext, IEntityConfigurationAssemblyProvider, PhysicalDataSource, User | | 8 | `UserConfiguration` | MMCA.ADC.Identity.Infrastructure | 4 | EmailValueConverter, EntityTypeConfigurationSQLServer, User, UserInvariants | | 8 | `SeederMocks` | MMCA.ADC.Identity.Infrastructure.Tests | 4 | IPasswordHasher, IRepository, IUnitOfWork, User | | 8 | `IdentityRouteAuthorizationTests` | MMCA.ADC.Identity.UI.Tests | 3 | RouteAuthorizationTestsBase, User, UserList | | 8 | `UserListTests` | MMCA.ADC.Identity.UI.Tests | 8 | BunitTestBase, Email, IUserUIService, ListPageQueryStateService, ListPageStateService, User, UserList, UserListDTO | | 8 | `UserNotificationExportService` | MMCA.ADC.Notification.Application | 6 | IQueryableExecutor, IUnitOfWork, IUserNotificationExportService, PushNotification, UserNotification, UserNotificationExportItemDTO | | 8 | `CurrentUserTargetingContextAccessor` | MMCA.Common.API | 1 | User | -| 8 | `DatabaseInitializationExtensions` | MMCA.Common.API | 11 | ApplicationSettings, DataSource, DataSourceKey, IDataSourceResolver, IDbContextFactory, IEntityDataSourceRegistry, ITenantContext, ModuleLoader, TenancySettings, TenantDataSourceTarget, TenantDataSourceTargets | | 8 | `EntityControllerBaseETagTests` | MMCA.Common.API.Tests | 12 | ConcurrencyETag, EntityControllerBase, Error, IEntityQueryService, PlainDTO, PlainEntity, PlainEntityController, Result, Specification, VersionedDTO, VersionedEntity, VersionedEntityController | | 8 | `EntityControllerBaseExportColumnTests` | MMCA.Common.API.Tests | 12 | EntityControllerBase, ExportMoney, ExportShapeTestController, ExportShapeTestDTO, ExportTestEntity, FakeTimeProvider, IApplicationSettings, IEntityQueryService, PagedCollectionResult, PaginationMetadata, Result, Specification | | 8 | `EntityControllerBaseExportTests` | MMCA.Common.API.Tests | 15 | EntityControllerBase, Error, ExportTestController, ExportTestDTO, ExportTestEntity, FakeTimeProvider, IApplicationSettings, IEntityQueryService, InlineSpecification, PagedCollectionResult, PaginationMetadata, Result, ScopedExportTestController, Specification, SpecificationHonoringQueryService | @@ -2688,6 +2747,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 8 | `DeleteUserHandlerBase` | MMCA.Common.Application | 9 | AuditableAggregateRootEntity, Error, ICommandHandler, IErasableUser, IUnitOfWork, IUserOwnedRequest, Result, UserOwnershipRule, UserUseCaseLog | | 8 | `EntityQueryService` | MMCA.Common.Application | 21 | AuditableBaseEntity, BaseLookup, EntityQueryParameters, Error, IBaseDTO, IEntityDTOMapper, IEntityDTOProjector, IEntityQueryPipeline, IEntityQueryService, INavigationMetadataProvider, INavigationPopulator, IReadRepository, ISpecification, IUnitOfWork, NavigationMetadata, NavigationMetadataProvider, PagedCollectionResult, PaginationMetadata, QueryFieldService, QueryFilterService …(+1) | | 8 | `ExportUserDataHandlerBase` | MMCA.Common.Application | 13 | AuditableAggregateRootEntity, Error, IQueryHandler, IUnitOfWork, IUserDataExportSection, IUserOwnedRequest, Result, Subject, UserDataExportDTO, UserDataExportSectionDefaults, UserDataExportSectionDTO, UserOwnershipRule, UserUseCaseLog | +| 8 | `ForgotPasswordHandlerBase` | MMCA.Common.Application | 11 | AuditableAggregateRootEntity, Email, ForgotPasswordRequest, ICommandHandler, ICommandWithRequest, IEmailSender, IPasswordResetTokenService, IUnitOfWork, PasswordResetSettings, Result, UserUseCaseLog | | 8 | `GetMyNotificationsHandler` | MMCA.Common.Application | 11 | GetMyNotificationsQuery, IQueryableExecutor, IQueryHandler, IUnitOfWork, PagedCollectionResult, PaginationMetadata, PagingMath, PushNotification, Result, UserNotification, UserNotificationDTO | | 8 | `GetUnreadNotificationCountHandler` | MMCA.Common.Application | 7 | GetUnreadNotificationCountQuery, IQueryableExecutor, IQueryHandler, IUnitOfWork, PushNotification, Result, UserNotification | | 8 | `GetUserPreferencesHandlerBase` | MMCA.Common.Application | 8 | AuditableBaseEntity, Error, GetUserPreferencesQuery, IQueryHandler, IUnitOfWork, IUserPreferences, Result, UserPreferencesResponse | @@ -2697,101 +2757,47 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 8 | `MarkNotificationReadHandler` | MMCA.Common.Application | 7 | Error, ICommandHandler, IQueryableExecutor, IUnitOfWork, MarkNotificationReadCommand, Result, UserNotification | | 8 | `PushNotificationDTOMapper` | MMCA.Common.Application | 4 | IEntityDTOMapper, PushNotification, PushNotificationDTO, PushNotificationStatus | | 8 | `PushNotificationDTOProjection` | MMCA.Common.Application | 2 | PushNotification, PushNotificationDTO | +| 8 | `ResetPasswordHandlerBase` | MMCA.Common.Application | 12 | AuditableAggregateRootEntity, Error, ICommandHandler, ICommandWithRequest, ILoginProtectionService, IPasswordChangeableUser, IPasswordHasher, IPasswordResetTokenService, IUnitOfWork, ResetPasswordRequest, Result, UserUseCaseLog | | 8 | `SendPushNotificationRequestValidator` | MMCA.Common.Application | 3 | PushNotification, PushNotificationInvariants, SendPushNotificationRequest | | 8 | `SoftDeletedUserValidator` | MMCA.Common.Application | 3 | AuditableAggregateRootEntity, ISoftDeletedUserValidator, IUnitOfWork | | 8 | `TransactionalCommandDecorator` | MMCA.Common.Application | 3 | ICommandHandler, ITransactional, IUnitOfWork | | 8 | `HandlerMocks` | MMCA.Common.Application.Tests | 9 | INativePushSender, INotificationRecipientProvider, IPushNotificationSender, IQueryableExecutor, IReadRepository, IRepository, IUnitOfWork, PushNotification, UserNotification | -| 8 | `HandlerMocks` | MMCA.Common.Application.Tests | 6 | IPasswordHasher, IReadRepository, IRepository, IUnitOfWork, TestHidingDeleteUser, TestIdentityUser | +| 8 | `HandlerMocks` | MMCA.Common.Application.Tests | 9 | IEmailSender, ILoginProtectionService, IPasswordHasher, IPasswordResetTokenService, IReadRepository, IRepository, IUnitOfWork, TestHidingDeleteUser, TestIdentityUser | | 8 | `ServiceMocks` | MMCA.Common.Application.Tests | 6 | ILoginProtectionService, IPasswordHasher, IRepository, ITokenService, IUnitOfWork, TestAuthUser | -| 8 | `AggregateConventionTests` | MMCA.Common.Architecture.Tests | 3 | AggregateConventionTestsBase, CommonArchitectureMap, IArchitectureMap | -| 8 | `CancellationTokenConventionTests` | MMCA.Common.Architecture.Tests | 3 | CancellationTokenConventionTestsBase, CommonArchitectureMap, IArchitectureMap | -| 8 | `DomainPurityTests` | MMCA.Common.Architecture.Tests | 3 | CommonArchitectureMap, DomainPurityTestsBase, IArchitectureMap | -| 8 | `EventScopeFitnessTests` | MMCA.Common.Architecture.Tests | 3 | ArchitectureRules, CommonArchitectureMap, FakeConsumerMap | -| 8 | `EventVersioningConventionTests` | MMCA.Common.Architecture.Tests | 3 | CommonArchitectureMap, EventConventionTestsBase, IArchitectureMap | -| 8 | `FakeConsumerMap` | MMCA.Common.Architecture.Tests | 5 | ArchitectureMapBase, BaseIntegrationEvent, EventScopeFitnessTests, Layer, LayerRef | -| 8 | `HandlerResultConventionTests` | MMCA.Common.Architecture.Tests | 3 | CommonArchitectureMap, HandlerResultConventionTestsBase, IArchitectureMap | -| 8 | `IdempotencyConventionTests` | MMCA.Common.Architecture.Tests | 3 | CommonArchitectureMap, IArchitectureMap, IdempotencyConventionTestsBase | -| 8 | `LayerDependencyTests` | MMCA.Common.Architecture.Tests | 3 | CommonArchitectureMap, IArchitectureMap, LayerDependencyTestsBase | -| 8 | `LocalizedTextConventionTests` | MMCA.Common.Architecture.Tests | 3 | CommonArchitectureMap, IArchitectureMap, LocalizedTextConventionTestsBase | -| 8 | `MicroserviceExtractionTests` | MMCA.Common.Architecture.Tests | 3 | CommonArchitectureMap, IArchitectureMap, MicroserviceExtractionTestsBase | -| 8 | `NamespaceCycleTests` | MMCA.Common.Architecture.Tests | 3 | CommonArchitectureMap, IArchitectureMap, NamespaceCycleTestsBase | -| 8 | `PiiConventionTests` | MMCA.Common.Architecture.Tests | 3 | CommonArchitectureMap, IArchitectureMap, PiiConventionTestsBase | -| 8 | `RawQueryableConventionTests` | MMCA.Common.Architecture.Tests | 4 | ArchitectureMapBase, CommonArchitectureMap, IArchitectureMap, RawQueryableConventionTestsBase | -| 8 | `SliceCohesionTests` | MMCA.Common.Architecture.Tests | 3 | CommonArchitectureMap, IArchitectureMap, SliceCohesionTestsBase | -| 8 | `StateManagementConventionTests` | MMCA.Common.Architecture.Tests | 3 | CommonArchitectureMap, IArchitectureMap, StateManagementConventionTestsBase | -| 8 | `UIArchitectureConventionTests` | MMCA.Common.Architecture.Tests | 3 | CommonArchitectureMap, IArchitectureMap, UIArchitectureConventionTestsBase | | 8 | `PushNotificationTests` | MMCA.Common.Domain.Tests | 3 | PushNotification, PushNotificationCreated, PushNotificationStatus | -| 8 | `ApplicationDbContextEFFactory` | MMCA.Common.Infrastructure | 6 | ApplicationDbContext, CosmosDbContext, DataSource, IDbContextFactory, SqliteDbContext, SQLServerDbContext | -| 8 | `AuditTrailCleanupJob` | MMCA.Common.Infrastructure | 10 | AuditTrailEntry, AuditTrailSettings, DataSource, IDbContextFactory, IEntityDataSourceRegistry, IScheduledJob, ITenantContext, TenancySettings, TenantDataSourceTarget, TenantDataSourceTargets | -| 8 | `AuditTrailReader` | MMCA.Common.Infrastructure | 7 | AuditTrailEntry, AuditTrailEntryDTO, AuditTrailSettings, DataSourceKey, IAuditTrailReader, IDataSourceResolver, IDbContextFactory | -| 8 | `BrokerEventBus` | MMCA.Common.Infrastructure | 7 | IDataSourceResolver, IDbContextFactory, IEventBus, IIntegrationEvent, IOutboxSignal, OutboxMessage, OutboxSettings | -| 8 | `DefaultCosmosDbContextFactory` | MMCA.Common.Infrastructure | 5 | CosmosDbContext, DataSource, DataSourceKey, IDbContextFactory, IPhysicalDbContextFactory | -| 8 | `DefaultSqliteDbContextFactory` | MMCA.Common.Infrastructure | 5 | DataSource, DataSourceKey, IDbContextFactory, IPhysicalDbContextFactory, SqliteDbContext | -| 8 | `DefaultSqlServerDbContextFactory` | MMCA.Common.Infrastructure | 5 | DataSource, DataSourceKey, IDbContextFactory, IPhysicalDbContextFactory, SQLServerDbContext | -| 8 | `DesignTimeDbContextHelper` | MMCA.Common.Infrastructure | 22 | AuditSaveChangesInterceptor, AuditTrailSaveChangesInterceptor, AuditTrailSettings, DataSource, DataSourceKey, DataSourceResolver, DataSourcesSettings, DesignTimeDbContextOptions, DomainEventSaveChangesInterceptor, EntityDataSourceRegistry, ExplicitAssemblyProvider, IDataSourceResolver, IDomainEventDispatcher, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, IOutboxSignal, NullDomainEventDispatcher, OutboxSignal, SchedulerSettings, SQLServerDbContext …(+2) | -| 8 | `EfInboxStore` | MMCA.Common.Infrastructure | 6 | ApplicationDbContext, IDataSourceResolver, IDbContextFactory, IInboxStore, InboxMessage, OutboxSettings | -| 8 | `InProcessEventBus` | MMCA.Common.Infrastructure | 8 | IDataSourceResolver, IDbContextFactory, IDomainEventDispatcher, IEventBus, IIntegrationEvent, OutboxFinalizer, OutboxMessage, OutboxSettings | -| 8 | `OutboxCleanupService` | MMCA.Common.Infrastructure | 13 | DataSource, DataSourceKey, IDataSourceResolver, IDbContextFactory, IEntityDataSourceRegistry, InboxMessage, ITenantContext, MessageBusSettings, OutboxMessage, OutboxSettings, TenancySettings, TenantDataSourceTarget, TenantDataSourceTargets | -| 8 | `OutboxProcessor` | MMCA.Common.Infrastructure | 21 | ApplicationDbContext, BrokerMetrics, BrokerResilienceDefaults, DataSource, DataSourceKey, Event, IDataSourceResolver, IDbContextFactory, IDomainEventDispatcher, IEntityDataSourceRegistry, IIntegrationEvent, IMessageBus, IOutboxSignal, ITenantContext, OutboxCycleResult, OutboxMessage, OutboxMetrics, OutboxSettings, TenancySettings, TenantDataSourceTarget …(+1) | -| 8 | `PhysicalDbContextFactory` | MMCA.Common.Infrastructure | 10 | ApplicationDbContext, CosmosDbContext, DataSource, DataSourceKey, IDataSourceResolver, IEntityConfigurationAssemblyProvider, IPhysicalDbContextFactory, PhysicalDataSource, SqliteDbContext, SQLServerDbContext | | 8 | `PushNotificationConfiguration` | MMCA.Common.Infrastructure | 3 | EntityTypeConfigurationSQLServer, PushNotification, PushNotificationInvariants | -| 8 | `ScheduledJobRunner` | MMCA.Common.Infrastructure | 9 | ApplicationDbContext, DataSourceKey, IDataSourceResolver, IDbContextFactory, IScheduledJob, JobClaim, ScheduledJobEntry, SchedulerMetrics, SchedulerSettings | -| 8 | `UnitOfWork` | MMCA.Common.Infrastructure | 8 | AuditableAggregateRootEntity, AuditableBaseEntity, IDataSourceService, IDbContextFactory, IReadRepository, IRepository, IRepositoryFactory, IUnitOfWork | | 8 | `UserNotificationConfiguration` | MMCA.Common.Infrastructure | 2 | EntityTypeConfigurationSQLServer, UserNotification | -| 8 | `ApplicationDbContextTenantFilterTests` | MMCA.Common.Infrastructure.Tests | 6 | ApplicationDbContext, EFReadRepository, PlainThing, TenantDetail, TenantTestContext, TenantThing | -| 8 | `ApplicationDbContextTests` | MMCA.Common.Infrastructure.Tests | 4 | ApplicationDbContext, DataSource, TestApplicationDbContext, TestEntity | -| 8 | `AuditSaveChangesInterceptorTests` | MMCA.Common.Infrastructure.Tests | 4 | AuditSaveChangesInterceptor, FakeTimeProvider, TestAuditDbContext, TestAuditEntity | -| 8 | `AuditTrailModelGateTests` | MMCA.Common.Infrastructure.Tests | 3 | AuditTrailEntry, DataSourceKey, GateTestContext | -| 8 | `AuditTrailTestContext` | MMCA.Common.Infrastructure.Tests | 17 | ApplicationDbContext, AuditedThing, AuditSaveChangesInterceptor, AuditTrailEntry, AuditTrailSaveChangesInterceptor, CompositeKeyThing, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, FailingSaveInterceptor, FailingSaveInterceptor, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, NullAssemblyProvider, PlainThing, TestPhysicalDataSources | -| 8 | `CrossDataSourceDegradeConventionTests` | MMCA.Common.Infrastructure.Tests | 14 | AuditSaveChangesInterceptor, DataSource, DataSourceKey, DataSourceModelCacheKeyFactory, DegradeCustomer, DegradeOrder, DegradeTestContext, DomainEventSaveChangesInterceptor, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, MapRegistry, OutboxSignal, PhysicalDataSource | -| 8 | `DependencyInjectionAdditionalTests` | MMCA.Common.Infrastructure.Tests | 6 | EntityConfigurationOptions, IDataSourceService, IDbContextFactory, IQueryableExecutor, IRepositoryFactory, IUnitOfWork | | 8 | `DesignAlphaEntityConfiguration` | MMCA.Common.Infrastructure.Tests | 2 | DesignAlphaEntity, EntityTypeConfigurationSQLServer | | 8 | `DesignBetaEntityConfiguration` | MMCA.Common.Infrastructure.Tests | 2 | DesignBetaEntity, EntityTypeConfigurationSQLServer | -| 8 | `DomainEventCaptureExclusionTests` | MMCA.Common.Infrastructure.Tests | 7 | DomainEventSaveChangesInterceptor, ExclusionAggregate, ExclusionEvent, ExclusionTestDbContext, IDomainEvent, IDomainEventDispatcher, IOutboxSignal | -| 8 | `DomainEventSaveChangesInterceptorOutboxRoutingTests` | MMCA.Common.Infrastructure.Tests | 9 | DomainEventSaveChangesInterceptor, IDomainEvent, IDomainEventDispatcher, IOutboxSignal, OutboxMessage, OutboxRoutingTestDbContext, TestAggregate, TestIntegrationEvent, TestLocalEvent | -| 8 | `DomainEventSaveChangesInterceptorTests` | MMCA.Common.Infrastructure.Tests | 7 | DomainEventSaveChangesInterceptor, IDomainEvent, IDomainEventDispatcher, IOutboxSignal, TestAggregate, TestDomainEvent, TestDomainEventDbContext | -| 8 | `EFReadRepositoryGetByIdFilterTests` | MMCA.Common.Infrastructure.Tests | 3 | EFReadRepository, SoftDeletableTestEntity, SoftDeleteTestDbContext | -| 8 | `EFReadRepositoryKeysetPagingTests` | MMCA.Common.Infrastructure.Tests | 8 | BbbSpecification, Category, EFReadRepository, ErrorType, KeysetCursor, KeysetPageRequest, SpecificationTestDbContext, SpecTestEntity | -| 8 | `EFReadRepositoryProjectedFilterTests` | MMCA.Common.Infrastructure.Tests | 3 | EFReadRepository, NamedSoftDeleteTestDbContext, ProjectedTestEntity | -| 8 | `EFReadRepositorySpecificationTests` | MMCA.Common.Infrastructure.Tests | 15 | AllSpecification, BetaSpecification, Category, DeletedByNameSpecification, EFReadRepository, HighRankSpecification, IncludingSoftDeletedSpecification, IncludingSpecification, ISpecification, NoMatchSpecification, SpecificationTestDbContext, SpecTestChild, SpecTestEntity, TopTwoByRankSpecification, TrackedSpecification | | 8 | `EFRepositoryDecoratorAdditionalTests` | MMCA.Common.Infrastructure.Tests | 3 | EFRepositoryDecorator, FakeAggregateEntity, IRepository | | 8 | `EFRepositoryDecoratorTests` | MMCA.Common.Infrastructure.Tests | 3 | EFRepositoryDecorator, FakeAggregateEntity, IRepository | | 8 | `EntityTypeConfigurationBaseTests` | MMCA.Common.Infrastructure.Tests | 6 | TestAggregateEntity, TestAggregateEntityConfiguration, TestConfigDbContext, TestNonAggregateConfigDbContext, TestNonAggregateEntity, TestNonAggregateEntityConfiguration | -| 8 | `FailingSaveInterceptor` | MMCA.Common.Infrastructure.Tests | 1 | AuditTrailTestContext | -| 8 | `Mocks` | MMCA.Common.Infrastructure.Tests | 4 | IDataSourceResolver, IDbContextFactory, IDomainEventDispatcher, IOutboxSignal | | 8 | `MultiSourceCustomerConfiguration` | MMCA.Common.Infrastructure.Tests | 2 | EntityTypeConfigurationSqlite, MultiSourceCustomer | | 8 | `MultiSourceOrderConfiguration` | MMCA.Common.Infrastructure.Tests | 2 | EntityTypeConfigurationSqlite, MultiSourceOrder | | 8 | `NotificationTestDbContext` | MMCA.Common.Infrastructure.Tests | 2 | PushNotification, UserNotification | | 8 | `PortablePrincipalConfiguration` | MMCA.Common.Infrastructure.Tests | 2 | EntityTypeConfigurationSQLServer, PortablePrincipal | | 8 | `ProjectionTestDbContext` | MMCA.Common.Infrastructure.Tests | 1 | PushNotification | -| 8 | `QueryParameterizationTests` | MMCA.Common.Infrastructure.Tests | 3 | QueryFieldService, QueryFilterService, QueryShapeTestDbContext | | 8 | `RegistryDuplicateConfigurationA` | MMCA.Common.Infrastructure.Tests | 2 | EntityTypeConfigurationSqlite, RegistryDuplicate | | 8 | `RegistryDuplicateConfigurationB` | MMCA.Common.Infrastructure.Tests | 2 | EntityTypeConfigurationSqlite, RegistryDuplicate | | 8 | `RegistryInvoiceConfiguration` | MMCA.Common.Infrastructure.Tests | 2 | EntityTypeConfigurationSqlite, RegistryInvoice | | 8 | `RegistryOrderConfiguration` | MMCA.Common.Infrastructure.Tests | 2 | EntityTypeConfigurationSqlite, RegistryOrder | | 8 | `RegistrySqlServerEntityConfiguration` | MMCA.Common.Infrastructure.Tests | 2 | EntityTypeConfigurationSQLServer, RegistrySqlServerEntity | -| 8 | `SaveChangeDetectionTests` | MMCA.Common.Infrastructure.Tests | 2 | DetectionTestDbContext, Widget | -| 8 | `SchedulerModelGateTests` | MMCA.Common.Infrastructure.Tests | 3 | DataSourceKey, GateTestContext, ScheduledJobEntry | | 8 | `SeederMocks` | MMCA.Common.Infrastructure.Tests | 4 | IPasswordHasher, IRepository, IUnitOfWork, TestSeedUser | -| 8 | `SoftDeleteQueryFilterTests` | MMCA.Common.Infrastructure.Tests | 2 | SoftDeletableEntity, SoftDeleteTestDbContext | -| 8 | `SoftDeleteUniqueIndexConventionTests` | MMCA.Common.Infrastructure.Tests | 3 | FilteredIndexEntity, UniqueIndexTestDbContext, UniqueNamedEntity | -| 8 | `SpecificationEvaluatorTests` | MMCA.Common.Infrastructure.Tests | 11 | BetaSpecification, Category, IncludingSpecification, OrderedSpecification, PagedSpecification, RankDescendingSpecification, SpecificationEvaluator, SpecificationTestDbContext, SpecTestChild, SpecTestEntity, UnorderedQuerySpecification | | 8 | `SqliteTestEntityConfig` | MMCA.Common.Infrastructure.Tests | 2 | EntityTypeConfigurationSqlite, SqliteTestEntity | -| 8 | `SQLServerDbContextTests` | MMCA.Common.Infrastructure.Tests | 11 | AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyAssemblyProvider, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, OutboxSignal, PersistenceSettings, SQLServerDbContext, TestPhysicalDataSources | -| 8 | `TenantSaveChangesInterceptorTests` | MMCA.Common.Infrastructure.Tests | 5 | CrossTenantWriteException, PlainThing, TenantTestContext, TenantThing, TrailedTenantThing | | 8 | `TestConnectionContext` | MMCA.Common.Infrastructure.Tests | 2 | TestDuplexPipe, User | | 8 | `GalleryAuthenticationStateProvider` | MMCA.Common.UI.Gallery | 1 | User | -| 9 | `AdcArchitectureMap` | MMCA.ADC.Architecture.Tests | 17 | ApiControllerBase, ApplicationDbContext, ArchitectureMapBase, BaseEntity, ConferenceModule, EngagementModule, EntityQueryService, Event, EventDTO, IdentityModule, Layer, LayerRef, Result, User, UserDTO, UserSessionBookmark, UserSessionBookmarkDTO | | 9 | `DecoratorPipelineOrderTests` | MMCA.ADC.Architecture.Tests | 11 | ChangePreferencesCommand, ClassReference, DecoratorPipelineOrderTestsBase, GetUserPreferencesQuery, ICacheService, ICorrelationContext, ICurrentUserService, IPermissionRegistry, IUnitOfWork, Result, UserPreferencesResponse | | 9 | `CurrentUserServiceExtensions` | MMCA.ADC.Conference.API | 2 | ConferenceReadAudience, ICurrentUserService | | 9 | `EventQuestionAnswersController` | MMCA.ADC.Conference.API | 20 | AddEventQuestionAnswerCommand, AddEventQuestionAnswerRequest, AuthorizationPolicies, BaseLookup, CollectionResult, EntityControllerBase, EventQuestionAnswer, EventQuestionAnswerDTO, ICommandHandler, ICurrentUserService, IEntityQueryService, OwnedByUserSpecification, PagedCollectionResult, QueryFilterModelBinder, RemoveEventQuestionAnswerCommand, Result, RoleNames, Route, UpdateEventQuestionAnswerCommand, UpdateEventQuestionAnswerRequest | | 9 | `EventSpeakersController` | MMCA.ADC.Conference.API | 19 | AddEventSpeakerCommand, AddEventSpeakerRequest, BaseLookup, CollectionResult, ConferencePermissions, EntityControllerBase, EventSpeaker, EventSpeakerDTO, GetPublicEventSpeakerFilterQuery, ICommandHandler, ICurrentUserService, IEntityQueryService, IQueryHandler, PagedCollectionResult, QueryFilterModelBinder, RemoveEventSpeakerCommand, Result, Route, Specification | | 9 | `QuestionsController` | MMCA.ADC.Conference.API | 16 | AggregateRootEntityControllerBase, BaseLookup, CollectionResult, ConferencePermissions, DeleteEntityCommand, ICommandHandler, IEntityQueryService, PagedCollectionResult, QueryFilterModelBinder, Question, QuestionCreateRequest, QuestionDTO, QuestionUpdateRequest, Result, Route, UpdateQuestionCommand | -| 9 | `RoomsController` | MMCA.ADC.Conference.API | 17 | AddRoomCommand, AddRoomRequest, BaseLookup, CollectionResult, ConferencePermissions, EntityControllerBase, ICommandHandler, IEntityQueryService, PagedCollectionResult, QueryFilterModelBinder, RemoveRoomCommand, Result, Room, RoomDTO, Route, UpdateRoomCommand, UpdateRoomRequest | +| 9 | `RoomsController` | MMCA.ADC.Conference.API | 21 | AddRoomCommand, AddRoomRequest, BaseLookup, CollectionResult, ConferencePermissions, EntityControllerBase, GetPublicRoomFilterQuery, ICommandHandler, ICurrentUserService, IEntityQueryService, IQueryHandler, PagedCollectionResult, QueryFilterModelBinder, RemoveRoomCommand, Result, Room, RoomDTO, Route, Specification, UpdateRoomCommand …(+1) | | 9 | `SpeakerCategoryItemsController` | MMCA.ADC.Conference.API | 19 | AddSpeakerCategoryItemCommand, AddSpeakerCategoryItemRequest, BaseLookup, CollectionResult, ConferencePermissions, EntityControllerBase, GetPublicSpeakerCategoryItemFilterQuery, ICommandHandler, ICurrentUserService, IEntityQueryService, IQueryHandler, PagedCollectionResult, QueryFilterModelBinder, RemoveSpeakerCategoryItemCommand, Result, Route, SpeakerCategoryItem, SpeakerCategoryItemDTO, Specification | -| 9 | `SpeakersController` | MMCA.ADC.Conference.API | 31 | AggregateRootEntityControllerBase, AndSpecification, BaseLookup, CollectionResult, ConferencePermissions, DeleteEntityCommand, Error, GetPublicSpeakerFilterQuery, GetSessionBookmarkCountQuery, GetSessionBookmarkCountsQuery, GetSessionFeedbackQuery, GetSpeakersByEventFilterQuery, ICommandHandler, ICurrentUserService, IEntityQueryService, IQueryHandler, LinkUserRequest, LinkUserToSpeakerCommand, PagedCollectionResult, QueryFilterModelBinder …(+11) | +| 9 | `SpeakersController` | MMCA.ADC.Conference.API | 30 | AggregateRootEntityControllerBase, BaseLookup, CollectionResult, ConferencePermissions, DeleteEntityCommand, Error, GetPublicSpeakerFilterQuery, GetSessionBookmarkCountQuery, GetSessionBookmarkCountsQuery, GetSessionFeedbackQuery, GetSpeakersByEventFilterQuery, ICommandHandler, ICurrentUserService, IEntityQueryService, IQueryHandler, LinkUserRequest, LinkUserToSpeakerCommand, PagedCollectionResult, QueryFilterModelBinder, Result …(+10) | | 9 | `CategoryItemsControllerTests` | MMCA.ADC.Conference.API.Tests | 12 | AddCategoryItemCommand, AddCategoryItemRequest, CategoryItem, CategoryItemDTO, CategoryItemsController, Error, ICommandHandler, IEntityQueryService, RemoveCategoryItemCommand, Result, UpdateCategoryItemCommand, UpdateCategoryItemRequest | | 9 | `ConferenceCategoriesControllerTests` | MMCA.ADC.Conference.API.Tests | 11 | Category, ConferenceCategoriesController, ConferenceCategoryCreateRequest, ConferenceCategoryDTO, ConferenceCategoryUpdateRequest, DeleteEntityCommand, Error, ICommandHandler, IEntityQueryService, Result, UpdateConferenceCategoryCommand | +| 9 | `ActivityCreateRequest` | MMCA.ADC.Conference.Application | 3 | Activity, ICacheInvalidating, ICreateRequest | +| 9 | `ActivityDTOMapper` | MMCA.ADC.Conference.Application | 3 | Activity, ActivityDTO, IEntityDTOMapper | | 9 | `AddEventQuestionAnswerCommandValidator` | MMCA.ADC.Conference.Application | 1 | AddEventQuestionAnswerCommand | | 9 | `AddEventQuestionAnswerHandler` | MMCA.ADC.Conference.Application | 14 | AddEventQuestionAnswerCommand, Error, Event, EventFeedbackSubmitted, EventInvariants, EventQuestionAnswer, EventQuestionAnswerDTO, EventQuestionAnswerDTOMapper, ICommandHandler, ICurrentUserService, IUnitOfWork, Question, QuestionInvariants, Result | | 9 | `AddEventSpeakerCommandValidator` | MMCA.ADC.Conference.Application | 1 | AddEventSpeakerCommand | @@ -2808,7 +2814,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 9 | `CreateQuestionHandler` | MMCA.ADC.Conference.Application | 10 | Error, ICommandHandler, IEntityRequestMapper, IUnitOfWork, Question, QuestionCreateRequest, QuestionDTO, QuestionDTOMapper, QuestionInvariants, Result | | 9 | `DeleteSessionHandler` | MMCA.ADC.Conference.Application | 6 | DeleteEntityCommand, Error, ICommandHandler, IUnitOfWork, Result, Session | | 9 | `EventCreateRequestMapper` | MMCA.ADC.Conference.Application | 4 | Event, EventCreateRequest, IEntityRequestMapper, Result | -| 9 | `EventCreateRequestValidator` | MMCA.ADC.Conference.Application | 6 | EventCreateRequest, EventDateRangeRules, EventNameRules, EventOrganizerContactEmailRules, EventSponsorshipPacketUrlRules, EventTimeZoneRules | +| 9 | `EventCreateRequestValidator` | MMCA.ADC.Conference.Application | 7 | EventCreateRequest, EventDateRangeRules, EventNameRules, EventOrganizerContactEmailRules, EventSponsorshipPacketUrlRules, EventTicketingUrlRules, EventTimeZoneRules | | 9 | `EventDTOMapper` | MMCA.ADC.Conference.Application | 6 | Event, EventDTO, EventQuestionAnswerDTOMapper, EventSpeakerDTOMapper, IEntityDTOMapper, RoomDTOMapper | | 9 | `GetCategoryDistributionHandler` | MMCA.ADC.Conference.Application | 11 | Category, CategoryDistributionDTO, CategoryGroupDistribution, CategoryItemDistribution, GetCategoryDistributionQuery, IQueryHandler, IUnitOfWork, Result, Session, SessionStatuses, StatusBucket | | 9 | `GetContentSimilarityHandler` | MMCA.ADC.Conference.Application | 10 | Category, ContentSimilarityDTO, GetContentSimilarityQuery, IQueryHandler, IUnitOfWork, Result, Session, SessionSimilarityCalculator, SessionStatuses, SimilarSessionPair | @@ -2838,7 +2844,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 9 | `SessionCategoryItemDTOMapper` | MMCA.ADC.Conference.Application | 3 | IEntityDTOMapper, SessionCategoryItem, SessionCategoryItemDTO | | 9 | `SessionCreateRequest` | MMCA.ADC.Conference.Application | 3 | ICacheInvalidating, ICreateRequest, Session | | 9 | `SessionQuestionAnswerDTOMapper` | MMCA.ADC.Conference.Application | 3 | IEntityDTOMapper, SessionQuestionAnswer, SessionQuestionAnswerDTO | -| 9 | `SessionRoomScheduling` | MMCA.ADC.Conference.Application | 5 | Error, Event, IRepository, Result, Session | +| 9 | `SessionRoomScheduling` | MMCA.ADC.Conference.Application | 5 | Error, Event, IEntityReader, Result, Session | | 9 | `SessionSpeakerDTOMapper` | MMCA.ADC.Conference.Application | 3 | IEntityDTOMapper, SessionSpeaker, SessionSpeakerDTO | | 9 | `SpeakerCreateRequestMapper` | MMCA.ADC.Conference.Application | 4 | IEntityRequestMapper, Result, Speaker, SpeakerCreateRequest | | 9 | `SpeakerCreateRequestValidator` | MMCA.ADC.Conference.Application | 3 | SpeakerCreateRequest, SpeakerFirstNameRules, SpeakerLastNameRules | @@ -2847,6 +2853,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 9 | `SponsorDTOMapper` | MMCA.ADC.Conference.Application | 3 | IEntityDTOMapper, Sponsor, SponsorDTO | | 9 | `UnlinkUserFromSpeakerHandler` | MMCA.ADC.Conference.Application | 7 | Error, ICommandHandler, IUnitOfWork, Result, Speaker, SpeakerUnlinkedFromUser, UnlinkUserFromSpeakerCommand | | 9 | `UnpublishEventHandler` | MMCA.ADC.Conference.Application | 6 | Error, Event, ICommandHandler, IUnitOfWork, Result, UnpublishEventCommand | +| 9 | `UpdateActivityCommand` | MMCA.ADC.Conference.Application | 4 | Activity, ActivityUpdateRequest, ICacheInvalidating, ICommandWithRequest | | 9 | `UpdateConferenceCategoryHandler` | MMCA.ADC.Conference.Application | 8 | Category, ConferenceCategoryDTO, ConferenceCategoryDTOMapper, Error, ICommandHandler, IUnitOfWork, Result, UpdateConferenceCategoryCommand | | 9 | `UpdateEventQuestionAnswerHandler` | MMCA.ADC.Conference.Application | 9 | Error, Event, EventQuestionAnswer, ICommandHandler, ICurrentUserService, IUnitOfWork, Result, RoleNames, UpdateEventQuestionAnswerCommand | | 9 | `UpdateQuestionHandler` | MMCA.ADC.Conference.Application | 11 | Error, EventQuestionAnswer, ICommandHandler, IUnitOfWork, Question, QuestionDTO, QuestionDTOMapper, Result, SessionQuestionAnswer, SpeakerQuestionAnswer, UpdateQuestionCommand | @@ -2855,6 +2862,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 9 | `UpdateSessionCommand` | MMCA.ADC.Conference.Application | 4 | ICacheInvalidating, ICommandWithRequest, Session, SessionUpdateRequest | | 9 | `UpdateSessionQuestionAnswerCommand` | MMCA.ADC.Conference.Application | 2 | ICacheInvalidating, Session | | 9 | `UpdateSponsorCommand` | MMCA.ADC.Conference.Application | 4 | ICacheInvalidating, ICommandWithRequest, Sponsor, SponsorUpdateRequest | +| 9 | `ActivityUpdateRequestValidatorTests` | MMCA.ADC.Conference.Application.Tests | 3 | ActivityInvariants, ActivityUpdateRequest, ActivityUpdateRequestValidator | | 9 | `AddCategoryItemCommandValidatorTests` | MMCA.ADC.Conference.Application.Tests | 3 | AddCategoryItemCommand, AddCategoryItemCommandValidator, CategoryInvariants | | 9 | `ConferenceCategoryCreateRequestValidatorTests` | MMCA.ADC.Conference.Application.Tests | 3 | CategoryInvariants, ConferenceCategoryCreateRequest, ConferenceCategoryCreateRequestValidator | | 9 | `ConferenceCategoryDTOMapperTests` | MMCA.ADC.Conference.Application.Tests | 3 | Category, CategoryItemDTOMapper, ConferenceCategoryDTOMapper | @@ -2871,6 +2879,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 9 | `QuestionValidationRulesTests` | MMCA.ADC.Conference.Application.Tests | 3 | QuestionInvariants, TestQuestionModel, TestQuestionTextValidator | | 9 | `RoomDTOMapperTests` | MMCA.ADC.Conference.Application.Tests | 3 | Event, Room, RoomDTOMapper | | 9 | `RoomValidationRulesTests` | MMCA.ADC.Conference.Application.Tests | 3 | EventInvariants, TestRoomModel, TestRoomValidator | +| 9 | `SessionRoomFilterTests` | MMCA.ADC.Conference.Application.Tests | 2 | QueryFilterService, Session | | 9 | `SessionUpdateRequestValidatorTests` | MMCA.ADC.Conference.Application.Tests | 3 | SessionInvariants, SessionUpdateRequest, SessionUpdateRequestValidator | | 9 | `SessionValidationRulesTests` | MMCA.ADC.Conference.Application.Tests | 3 | SessionInvariants, TestSessionModel, TestSessionValidator | | 9 | `SpeakerCategoryItemDTOMapperTests` | MMCA.ADC.Conference.Application.Tests | 3 | Speaker, SpeakerCategoryItem, SpeakerCategoryItemDTOMapper | @@ -2880,12 +2889,13 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 9 | `SpeakerValidationRulesTests` | MMCA.ADC.Conference.Application.Tests | 3 | SpeakerInvariants, TestSpeakerModel, TestSpeakerValidator | | 9 | `SponsorUpdateRequestValidatorTests` | MMCA.ADC.Conference.Application.Tests | 4 | SponsorInvariants, SponsorTier, SponsorUpdateRequest, SponsorUpdateRequestValidator | | 9 | `UpdateCategoryItemCommandValidatorTests` | MMCA.ADC.Conference.Application.Tests | 3 | CategoryInvariants, UpdateCategoryItemCommand, UpdateCategoryItemCommandValidator | -| 9 | `IEventCascadeDeletionDomainService` | MMCA.ADC.Conference.Domain | 4 | Event, Result, Session, Sponsor | +| 9 | `IEventCascadeDeletionDomainService` | MMCA.ADC.Conference.Domain | 5 | Activity, Event, Result, Session, Sponsor | +| 9 | `ActivityBuilder` | MMCA.ADC.Conference.Domain.Tests | 2 | Activity, EntityBuilderBase | | 9 | `SessionBuilder` | MMCA.ADC.Conference.Domain.Tests | 2 | EntityBuilderBase, Session | | 9 | `SessionTests` | MMCA.ADC.Conference.Domain.Tests | 5 | DomainEntityState, Session, SessionCategoryItemChanged, SessionChanged, SessionSpeakerChanged | | 9 | `SponsorBuilder` | MMCA.ADC.Conference.Domain.Tests | 3 | EntityBuilderBase, Sponsor, SponsorTier | -| 9 | `ConferenceModuleDbSeeder` | MMCA.ADC.Conference.Infrastructure | 11 | DbSeeder, Event, IRepository, IUnitOfWork, Question, QuestionInvariants, Session, SessionInvariants, Speaker, Sponsor, SponsorTier | -| 9 | `ModuleApplicationDbContext` | MMCA.ADC.Conference.Infrastructure | 17 | ApplicationDbContext, Category, CategoryItem, Event, EventQuestionAnswer, EventSpeaker, IEntityConfigurationAssemblyProvider, PhysicalDataSource, Question, Room, Session, SessionCategoryItem, SessionQuestionAnswer, SessionSpeaker, Speaker, SpeakerCategoryItem, Sponsor | +| 9 | `ActivityConfiguration` | MMCA.ADC.Conference.Infrastructure | 3 | Activity, ActivityInvariants, EntityTypeConfigurationSQLServer | +| 9 | `ConferenceModuleDbSeeder` | MMCA.ADC.Conference.Infrastructure | 12 | Activity, DbSeeder, Event, IRepository, IUnitOfWork, Question, QuestionInvariants, Session, SessionInvariants, Speaker, Sponsor, SponsorTier | | 9 | `SessionCategoryItemConfiguration` | MMCA.ADC.Conference.Infrastructure | 2 | EntityTypeConfigurationSQLServer, SessionCategoryItem | | 9 | `SessionConfiguration` | MMCA.ADC.Conference.Infrastructure | 3 | EntityTypeConfigurationSQLServer, Session, SessionInvariants | | 9 | `SessionQuestionAnswerConfiguration` | MMCA.ADC.Conference.Infrastructure | 3 | EntityTypeConfigurationSQLServer, SessionInvariants, SessionQuestionAnswer | @@ -2894,9 +2904,14 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 9 | `SponsorConfiguration` | MMCA.ADC.Conference.Infrastructure | 3 | EntityTypeConfigurationSQLServer, Sponsor, SponsorInvariants | | 9 | `CurrentEventDefaults` | MMCA.ADC.Conference.Shared | 2 | CurrentEventSelector, EventDTO | | 9 | `CurrentEventSelectorTests` | MMCA.ADC.Conference.Shared.Tests | 2 | CurrentEventSelector, TestEvent | -| 9 | `ADCHome` | MMCA.ADC.Conference.UI | 9 | ADCCollectionResult, ADCEventInfo, ADCSponsorCollectionResult, ADCSponsorInfo, ConferenceTrackInfo, CurrentEventSelector, EventPhase, KeynoteSpeakerInfo, SponsorTier | +| 9 | `ActivityCreate` | MMCA.ADC.Conference.UI | 10 | ActivityDTO, ActivityService, ConferenceRoutePaths, CurrentEventSelector, ErrorMessages, EventInfo, EventLookupService, IActivityUIService, IEventLookupService, Severity | +| 9 | `ActivityDetail` | MMCA.ADC.Conference.UI | 10 | Activity, ActivityDTO, ActivityService, ConferenceRoutePaths, ErrorMessages, EventInfo, EventLookupService, IActivityUIService, IEventLookupService, Severity | +| 9 | `ActivityList` | MMCA.ADC.Conference.UI | 12 | ActivityDTO, ActivityService, ConferenceRoutePaths, CurrentEventSelector, DataGridListPageBase, ErrorMessages, EventInfo, EventLookupService, IActivityUIService, IEventLookupService, ListPageActions, MobileInfiniteScrollList | +| 9 | `ADCHome` | MMCA.ADC.Conference.UI | 10 | ADCCollectionResult, ADCEventInfo, ADCSponsorCollectionResult, ADCSponsorInfo, ConferenceTrackInfo, CurrentEventSelector, EventPhase, KeynoteSpeakerInfo, PreConferenceWorkshopInfo, SponsorTier | +| 9 | `PublicActivityList` | MMCA.ADC.Conference.UI | 8 | ActivityDTO, ActivityService, CurrentEventSelector, EventLookupService, IActivityUIService, IEventLookupService, IMapNavigationService, Severity | +| 9 | `PublicEventList` | MMCA.ADC.Conference.UI | 12 | ConferenceReadAudience, ConferenceRoutePaths, CurrentEventSelector, DataGridListPageBase, EventDTO, EventInfo, EventLookupService, EventService, IEventLookupService, IEventUIService, ListPageActions, MobileInfiniteScrollList | | 9 | `PublicSessionDetail` | MMCA.ADC.Conference.UI | 17 | BookmarkService, ConferenceRoutePaths, ICategoryItemLookupService, IHapticFeedbackService, IRoomUIService, ISessionBookmarkUIService, ISessionLiveUIService, ISessionUIService, ISpeakerLookupService, ITextToSpeechService, RoomDTO, RoomService, Session, SessionDTO, SessionLive, SessionService, Severity | -| 9 | `PublicSpeakerList` | MMCA.ADC.Conference.UI | 12 | ConferenceReadAudience, ConferenceRoutePaths, CurrentEventSelector, DataGridListPageBase, EventInfo, EventLookupService, IEventLookupService, ISpeakerUIService, ListPageActions, MobileInfiniteScrollList, SpeakerDTO, SpeakerService | +| 9 | `PublicSpeakerList` | MMCA.ADC.Conference.UI | 9 | ConferenceReadAudience, CurrentEventSelector, DataGridListPageBase, EventInfo, EventLookupService, IEventLookupService, ISpeakerUIService, SpeakerDTO, SpeakerService | | 9 | `PublicSponsorList` | MMCA.ADC.Conference.UI | 7 | CurrentEventSelector, EventLookupService, IEventLookupService, ISponsorUIService, SponsorDTO, SponsorService, SponsorTier | | 9 | `RoomList` | MMCA.ADC.Conference.UI | 12 | ConferenceRoutePaths, CurrentEventSelector, DataGridListPageBase, ErrorMessages, EventInfo, EventLookupService, IEventLookupService, IRoomUIService, ListPageActions, MobileInfiniteScrollList, RoomDTO, RoomService | | 9 | `SessionDetail` | MMCA.ADC.Conference.UI | 23 | CategoryItemInfo, CategoryItemLookupService, ConferenceRoutePaths, ErrorMessages, EventInfo, EventLookupService, ICategoryItemLookupService, IEventLookupService, IRoomUIService, ISessionCategoryItemUIService, ISessionSpeakerUIService, ISessionUIService, ISpeakerLookupService, RoomDTO, RoomService, Session, SessionCategoryItemService, SessionDTO, SessionService, SessionSpeakerService …(+3) | @@ -2921,13 +2936,13 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 9 | `CreateBookmarkHandler` | MMCA.ADC.Engagement.Application | 11 | CreateBookmarkRequest, DuplicateKeyDetection, Error, IBookmarkManagementDomainService, ICommandHandler, ISessionBookmarkValidationService, IUnitOfWork, Result, UserSessionBookmark, UserSessionBookmarkDTO, UserSessionBookmarkDTOMapper | | 9 | `GetModerationQueueHandler` | MMCA.ADC.Engagement.Application | 10 | GetModerationQueueQuery, IEventLiveValidationService, IQueryableExecutor, IQueryHandler, IUnitOfWork, LivePollAuthorization, Result, SessionQuestion, SessionQuestionDTO, SessionQuestionViewBuilder | | 9 | `GetMyPointsHandler` | MMCA.ADC.Engagement.Application | 11 | Error, GetMyPointsQuery, ICurrentUserService, IQueryHandler, IUnitOfWork, LeaderboardOptIn, MyPointsDTO, PagingMath, PointsEntry, PointsEntryDTO, Result | -| 9 | `GetOrCreateMyBadgeHandler` | MMCA.ADC.Engagement.Application | 10 | AttendeeBadge, DuplicateKeyDetection, Error, GetOrCreateMyBadgeCommand, ICommandHandler, ICurrentUserService, IRepository, IUnitOfWork, MyBadgeDTO, Result | +| 9 | `GetOrCreateMyBadgeHandler` | MMCA.ADC.Engagement.Application | 10 | AttendeeBadge, DuplicateKeyDetection, Error, GetOrCreateMyBadgeCommand, ICommandHandler, ICurrentUserService, IEntityQuerier, IUnitOfWork, MyBadgeDTO, Result | | 9 | `GetSessionQuestionsHandler` | MMCA.ADC.Engagement.Application | 10 | GetSessionQuestionsQuery, IQueryableExecutor, IQueryHandler, IUnitOfWork, QuestionStatus, Result, SessionQuestion, SessionQuestionDTO, SessionQuestionUpvote, SessionQuestionViewBuilder | | 9 | `GetUserBookmarksHandler` | MMCA.ADC.Engagement.Application | 12 | GetUserBookmarksQuery, IQueryableExecutor, IQueryHandler, ISessionBookmarkValidationService, IUnitOfWork, PagedCollectionResult, PaginationMetadata, PagingMath, Result, UserSessionBookmark, UserSessionBookmarkDTO, UserSessionBookmarkDTOMapper | | 9 | `LivePollDTOMapper` | MMCA.ADC.Engagement.Application | 3 | IEntityDTOMapper, LivePoll, LivePollDTO | | 9 | `LivePollResultsBuilder` | MMCA.ADC.Engagement.Application | 7 | IQueryableExecutor, IUnitOfWork, LivePoll, LivePollOptionResultDTO, LivePollResultsDTO, LivePollVote, Question | | 9 | `OpenLivePollHandler` | MMCA.ADC.Engagement.Application | 12 | Error, ICommandHandler, IEventLiveValidationService, ILiveChannelPublishQueue, IUnitOfWork, LiveChannelPublishWorkItem, LivePoll, LivePollAuthorization, LivePollChannel, LivePollOpenedPayload, OpenLivePollCommand, Result | -| 9 | `SetLeaderboardParticipationHandler` | MMCA.ADC.Engagement.Application | 9 | DuplicateKeyDetection, Error, ICommandHandler, ICurrentUserService, IRepository, IUnitOfWork, LeaderboardOptIn, Result, SetLeaderboardParticipationRequest | +| 9 | `SetLeaderboardParticipationHandler` | MMCA.ADC.Engagement.Application | 10 | DuplicateKeyDetection, Error, ICommandHandler, ICurrentUserService, IEntityQuerier, IRepository, IUnitOfWork, LeaderboardOptIn, Result, SetLeaderboardParticipationRequest | | 9 | `SubmitQuestionHandler` | MMCA.ADC.Engagement.Application | 19 | BestEffort, Error, ICommandHandler, IEventLiveValidationService, ILiveChannelPublishQueue, IUnitOfWork, LiveChannelPublishWorkItem, LivePollChannel, QuestionModerationDefault, QuestionStatus, Result, SessionQuestion, SessionQuestionApprovedPayload, SessionQuestionChannel, SessionQuestionDTO, SessionQuestionInvariants, SessionQuestionPendingCountChangedPayload, SessionQuestionViewBuilder, SubmitQuestionCommand | | 9 | `CreateLivePollCommandValidatorTests` | MMCA.ADC.Engagement.Application.Tests | 5 | CreateLivePollCommand, CreateLivePollCommandValidator, CreateLivePollRequest, LivePollInvariants, Question | | 9 | `HandlerMocks` | MMCA.ADC.Engagement.Application.Tests | 6 | IEventLiveValidationService, ILiveChannelPublishQueue, IRepository, IUnitOfWork, LivePoll, LivePollVote | @@ -2947,13 +2962,13 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 9 | `SessionLiveModerationPanelTests` | MMCA.ADC.Engagement.UI.Tests | 11 | BunitComponentTestBase, CreateLivePollRequest, ILivePollUIService, ISessionQuestionUIService, LivePollDTO, LivePollResultsDTO, LivePollStatus, Question, QuestionStatus, SessionLiveModerationPanel, SessionQuestionDTO | | 9 | `SessionReminderPlannerTests` | MMCA.ADC.Engagement.UI.Tests | 3 | Session, SessionInfo, SessionReminderPlanner | | 9 | `UsersController` | MMCA.ADC.Identity.API | 18 | ApiControllerBase, DeleteUserCommand, Error, ExportUserDataQuery, GetUserAvatarQuery, GetUsersQuery, ICommandHandler, ICurrentUserService, IdentityPermissions, IQueryHandler, PagedCollectionResult, RemoveUserAvatarCommand, Result, Route, SetUserAvatarCommand, UserAvatarDTO, UserDataExportDTO, UserListDTO | -| 9 | `AuthenticationService` | MMCA.ADC.Identity.Application | 18 | AuthenticationResponse, AuthenticationServiceBase, AuthenticationValidators, Email, Error, IAuthenticationService, IExternalLoginEmailVerifier, ILoginProtectionService, IPasswordHasher, ITokenService, IUnitOfWork, RegisterRequest, Result, TokenService, UnitOfWork, User, UserRegistered, UserRole | | 9 | `ChangePasswordHandler` | MMCA.ADC.Identity.Application | 5 | ChangePasswordCommand, ChangePasswordHandlerBase, IPasswordHasher, IUnitOfWork, User | | 9 | `ChangePreferencesHandler` | MMCA.ADC.Identity.Application | 4 | ChangePreferencesCommand, ChangePreferencesHandlerBase, IUnitOfWork, User | | 9 | `DeleteUserHandler` | MMCA.ADC.Identity.Application | 10 | DeleteUserCommand, DeleteUserHandlerBase, ICacheService, IFileStorageService, IUnitOfWork, Result, SoftDeletedUserCache, User, UserDeleted, UserRole | | 9 | `ExportUserDataHandler` | MMCA.ADC.Identity.Application | 8 | Email, ExportUserDataHandlerBase, ExportUserDataQuery, IUnitOfWork, IUserDataExportSection, User, UserDataExportSubjectDTO, UserRole | | 9 | `GetUserPreferencesHandler` | MMCA.ADC.Identity.Application | 3 | GetUserPreferencesHandlerBase, IUnitOfWork, User | | 9 | `RemoveUserAvatarHandler` | MMCA.ADC.Identity.Application | 8 | Error, ICommandHandler, IFileStorageService, IUnitOfWork, RemoveUserAvatarCommand, Result, SetUserAvatarHandler, User | +| 9 | `ResetPasswordHandler` | MMCA.ADC.Identity.Application | 7 | ILoginProtectionService, IPasswordHasher, IPasswordResetTokenService, IUnitOfWork, ResetPasswordCommand, ResetPasswordHandlerBase, User | | 9 | `Fakes` | MMCA.ADC.Identity.Application.Tests | 3 | InMemoryRepository, RecordingUnitOfWork, User | | 9 | `SetUserAvatarHandlerTests` | MMCA.ADC.Identity.Application.Tests | 11 | Error, IFileStorageService, IImageProcessor, ImageContentSniffer, IRepository, IUnitOfWork, Result, SetUserAvatarCommand, SetUserAvatarHandler, User, UserRole | | 9 | `SoftDeletedUserValidatorTests` | MMCA.ADC.Identity.Application.Tests | 4 | IRepository, IUnitOfWork, SoftDeletedUserValidator, User | @@ -2965,6 +2980,9 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 9 | `DependencyInjectionTests` | MMCA.ADC.Notification.Application.Tests | 6 | ApplicationSettings, AttendeeNotificationRecipientProvider, DependencyInjectionAssert, INotificationRecipientProvider, IUserNotificationExportService, UserNotificationExportService | | 9 | `UserNotificationExportServiceGrpcAdapter` | MMCA.ADC.Notification.Contracts | 3 | IUserNotificationExportService, UserNotificationExportItemDTO, UserNotificationExportService | | 9 | `UserNotificationExportGrpcService` | MMCA.ADC.Notification.Service | 2 | IUserNotificationExportService, UserNotificationExportService | +| 9 | `MainActivity` | MMCA.ADC.UI | 2 | Activity, IDeepLinkDispatcher | +| 9 | `WebAuthenticatorCallbackActivity` | MMCA.ADC.UI | 1 | Activity | +| 9 | `CorrelationIdMiddleware` | MMCA.Common.API | 2 | Activity, ICorrelationContext | | 9 | `DevicesController` | MMCA.Common.API | 8 | ApiControllerBase, DeviceInstallationRequest, Error, ICurrentUserService, IPushDeviceRegistrar, NotificationFeatures, Result, Route | | 9 | `InboxController` | MMCA.Common.API | 16 | ApiControllerBase, AuthorizationPolicies, Error, GetMyNotificationsQuery, GetUnreadNotificationCountQuery, ICommandHandler, ICurrentUserService, IQueryHandler, MarkAllNotificationsReadCommand, MarkNotificationReadCommand, NotificationFeatures, PagedCollectionResult, PushNotification, Result, Route, UserNotificationDTO | | 9 | `NotificationsController` | MMCA.Common.API | 15 | ApiControllerBase, AuthorizationPolicies, Error, GetNotificationHistoryQuery, ICommandHandler, ICurrentUserService, IdempotencyHeaders, IQueryHandler, NotificationFeatures, PagedCollectionResult, PushNotificationDTO, Result, Route, SendPushNotificationCommand, SendPushNotificationRequest | @@ -2999,151 +3017,100 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 9 | `TestChangePreferencesHandler` | MMCA.Common.Application.Tests | 4 | ChangePreferencesHandlerBase, IUnitOfWork, TestChangePreferencesCommand, TestIdentityUser | | 9 | `TestDeleteUserHandler` | MMCA.Common.Application.Tests | 5 | DeleteUserHandlerBase, IUnitOfWork, Result, TestDeleteUserCommand, TestHidingDeleteUser | | 9 | `TestExportUserDataHandler` | MMCA.Common.Application.Tests | 6 | ExportUserDataHandlerBase, IUnitOfWork, IUserDataExportSection, TestExportUserDataQuery, TestIdentityUser, UserDataExportDTO | +| 9 | `TestForgotPasswordHandler` | MMCA.Common.Application.Tests | 8 | Email, ForgotPasswordHandlerBase, IEmailSender, IPasswordResetTokenService, IUnitOfWork, PasswordResetSettings, TestForgotPasswordCommand, TestIdentityUser | | 9 | `TestGetUserPreferencesHandler` | MMCA.Common.Application.Tests | 3 | GetUserPreferencesHandlerBase, IUnitOfWork, TestIdentityUser | +| 9 | `TestResetPasswordHandler` | MMCA.Common.Application.Tests | 7 | ILoginProtectionService, IPasswordHasher, IPasswordResetTokenService, IUnitOfWork, ResetPasswordHandlerBase, TestIdentityUser, TestResetPasswordCommand | | 9 | `TransactionalCommandDecoratorTests` | MMCA.Common.Application.Tests | 6 | ICommandHandler, IUnitOfWork, NonTransactionalCommand, Result, TransactionalCommand, TransactionalCommandDecorator | +| 9 | `GatewayCorrelationMiddleware` | MMCA.Common.Aspire | 1 | Activity | +| 9 | `OutboxPollFilterProcessor` | MMCA.Common.Aspire | 1 | Activity | | 9 | `CurrentUserService` | MMCA.Common.Infrastructure | 2 | ICurrentUserService, User | -| 9 | `DbContextFactory` | MMCA.Common.Infrastructure | 18 | ApplicationDbContext, CosmosDbContext, DataSource, DataSourceKey, DomainEventSaveChangesInterceptor, ICurrentUserService, IDataSourceResolver, IDbContextFactory, IdentityInsertGroup, IEntityDataSourceRegistry, IPhysicalDbContextFactory, ITenantContext, PhysicalDataSource, Result, SQLServerDbContext, TenancySettings, TenancySettingsValidator, TransactionCommitAmbiguousException | -| 9 | `EFRepository` | MMCA.Common.Infrastructure | 9 | ApplicationDbContext, AuditableBaseEntity, EFReadRepository, IAuditableEntity, ICurrentUserService, IRepository, IRowVersioned, IUpdatePropertySetter, UpdatePropertySetterBuilder | -| 9 | `IdentityModuleDbSeederBase` | MMCA.Common.Infrastructure | 9 | AuditableAggregateRootEntity, DbSeeder, Email, IPasswordHasher, IUnitOfWork, PasswordHasher, Result, SeedAccount, UnitOfWork | -| 9 | `AddAuditTrailTests` | MMCA.Common.Infrastructure.Tests | 6 | AuditTrailCleanupJob, AuditTrailReader, AuditTrailSaveChangesInterceptor, AuditTrailSettings, IAuditTrailReader, IScheduledJob | -| 9 | `AddScheduledJobsTests` | MMCA.Common.Infrastructure.Tests | 5 | FirstJob, IScheduledJob, ScheduledJobRunner, SchedulerSettings, SecondJob | -| 9 | `ApplicationDbContextEFFactoryTests` | MMCA.Common.Infrastructure.Tests | 5 | ApplicationDbContextEFFactory, CosmosDbContext, IDbContextFactory, SqliteDbContext, SQLServerDbContext | -| 9 | `AuditTrailSaveChangesInterceptorTests` | MMCA.Common.Infrastructure.Tests | 13 | AuditedThing, AuditTrailEntry, AuditTrailSaveChangesInterceptor, AuditTrailTestContext, AuditTrailTestHarness, CompositeKeyThing, Email, FakeTimeProvider, InboxMessage, OutboxMessage, PiiRedactor, PlainThing, ScheduledJobEntry | -| 9 | `BrokerEventBusTests` | MMCA.Common.Infrastructure.Tests | 19 | ApplicationDbContext, AuditSaveChangesInterceptor, BrokerEventBus, DataSource, DataSourceKey, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDataSourceResolver, IDbContextFactory, IDomainEventDispatcher, IEntityDataSourceRegistry, IIntegrationEvent, IOutboxSignal, Mocks, OutboxMessage, OutboxSettings, TestIntegrationEvent, TestNonOutboxContext, TestOutboxContext | -| 9 | `BrokerMessageBusTests` | MMCA.Common.Infrastructure.Tests | 5 | BrokerMessageBus, IIntegrationEvent, Mocks, OtherIntegrationEvent, TestIntegrationEvent | +| 9 | `OutboxMessage` | MMCA.Common.Infrastructure | 2 | Activity, IDomainEvent | | 9 | `ClaimBasedUserIdProviderTests` | MMCA.Common.Infrastructure.Tests | 2 | ClaimBasedUserIdProvider, TestConnectionContext | -| 9 | `CronosNextOccurrenceTests` | MMCA.Common.Infrastructure.Tests | 1 | ScheduledJobRunner | -| 9 | `DependencyInjectionBrokerMessagingTests` | MMCA.Common.Infrastructure.Tests | 4 | EfInboxStore, IInboxStore, InboxDisabledWarningService, NoOpInboxStore | -| 9 | `DependencyInjectionInfrastructureTests` | MMCA.Common.Infrastructure.Tests | 16 | AuditSaveChangesInterceptor, ConnectionStringSettings, DomainEventSaveChangesInterceptor, EntityConfigurationOptions, IConnectionStringSettings, IDataSourceService, IEntityConfigurationAssemblyProvider, IJwtSettings, IQueryableExecutor, IRepository, IRepositoryFactory, ISmtpSettings, IUnitOfWork, OutboxProcessor, OutboxSettings, SmtpSettings | -| 9 | `DesignTimeDbContextHelperTests` | MMCA.Common.Infrastructure.Tests | 8 | ConnectionStringSettings, DataSource, DataSourceEntrySettings, DataSourceKey, DesignAlphaEntity, DesignBetaEntity, DesignTimeDbContextHelper, DesignTimeDbContextOptions | -| 9 | `EfInboxStoreTests` | MMCA.Common.Infrastructure.Tests | 16 | ApplicationDbContext, AuditSaveChangesInterceptor, DataSource, DataSourceKey, DomainEventSaveChangesInterceptor, EfInboxStore, EmptyEntityDataSourceRegistry, IDataSourceResolver, IDbContextFactory, IDomainEventDispatcher, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, InboxMessage, InboxTestDbContext, IOutboxSignal, OutboxSettings | -| 9 | `InProcessEventBusOutboxTests` | MMCA.Common.Infrastructure.Tests | 11 | DataSource, DataSourceKey, IDataSourceResolver, IDbContextFactory, IDomainEvent, IDomainEventDispatcher, InProcessEventBus, OutboxMessage, OutboxSettings, TestIntegrationEvent, TestOutboxContext | -| 9 | `InProcessEventBusTests` | MMCA.Common.Infrastructure.Tests | 10 | DataSource, DataSourceKey, IDataSourceResolver, IDbContextFactory, IDomainEvent, IDomainEventDispatcher, IIntegrationEvent, InProcessEventBus, OutboxSettings, TestNonOutboxContext | -| 9 | `InProcessMessageBusTests` | MMCA.Common.Infrastructure.Tests | 11 | DomainEventDispatcher, IDomainEvent, IDomainEventDispatcher, IDomainEventHandler, IIntegrationEvent, IIntegrationEventHandler, InProcessMessageBus, Mocks, RecordingDomainHandler, RecordingIntegrationHandler, TestIntegrationEvent | -| 9 | `Mocks` | MMCA.Common.Infrastructure.Tests | 6 | IDataSourceResolver, IDataSourceService, IDbContextFactory, IEntityDataSourceRegistry, IRepositoryFactory, OutboxCleanupService | | 9 | `NullUserService` | MMCA.Common.Infrastructure.Tests | 1 | ICurrentUserService | -| 9 | `OutboxProcessorTests` | MMCA.Common.Infrastructure.Tests | 23 | AuditSaveChangesInterceptor, BrokerResilienceDefaults, DataSource, DataSourceKey, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, FakeTimeProvider, IDataSourceResolver, IDbContextFactory, IDomainEvent, IDomainEventDispatcher, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, IIntegrationEvent, IMessageBus, IOutboxSignal, OutboxCycleResult, OutboxMessage, OutboxProcessor, OutboxSettings …(+3) | -| 9 | `OutboxProcessorWaitTests` | MMCA.Common.Infrastructure.Tests | 1 | OutboxProcessor | | 9 | `PushNotificationTestDbContext` | MMCA.Common.Infrastructure.Tests | 1 | PushNotificationConfiguration | | 9 | `RoleOnlyService` | MMCA.Common.Infrastructure.Tests | 1 | ICurrentUserService | -| 9 | `ScheduledJobRunnerTests` | MMCA.Common.Infrastructure.Tests | 10 | ApplicationDbContext, DataSource, DelegateScheduledJob, FakeTimeProvider, IDataSourceResolver, ScheduledJobEntry, ScheduledJobOverrideSettings, ScheduledJobRunner, SchedulerSettings, SchedulerTestContext | -| 9 | `SchedulerTestHarness` | MMCA.Common.Infrastructure.Tests | 9 | ApplicationDbContext, DataSource, DataSourceKey, FakeTimeProvider, IDataSourceResolver, IDbContextFactory, IScheduledJob, ScheduledJobRunner, SchedulerSettings | | 9 | `SqliteTestDbContext` | MMCA.Common.Infrastructure.Tests | 2 | SqliteTestEntity, SqliteTestEntityConfig | -| 9 | `TenantDataSourceTargetTests` | MMCA.Common.Infrastructure.Tests | 14 | DataSource, DataSourceKey, IDataSourceResolver, IEntityDataSourceRegistry, IOutboxSignal, MessageBusSettings, OutboxCleanupService, OutboxProcessor, OutboxSettings, TenancySettings, TenantDataSourceOverrideSettings, TenantDataSourceTarget, TenantDataSourceTargets, TenantEntrySettings | -| 9 | `HandlerTestBase` | MMCA.Common.Testing | 6 | AuditableAggregateRootEntity, AuditableBaseEntity, IReadRepository, IRepository, IUnitOfWork, UnitOfWork | | 9 | `GalleryHost` | MMCA.Common.UI.Gallery | 16 | GalleryAuthenticationStateProvider, GalleryFakeAuthenticationHandler, GalleryUIModule, IAuthUIService, INotificationInboxUIService, IPushNotificationUIService, ITokenRefresher, ITokenStorageService, IUIModule, NoOpAuthUIService, NotificationState, NullTokenRefresher, NullTokenStorageService, StubNotificationInboxUIService, StubPushNotificationUIService, SupportedCultures | -| 10 | `ConcurrencyConventionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, ConcurrencyConventionTestsBase, IArchitectureMap | -| 10 | `ConstructorDependencyCountTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, ConstructorDependencyCountTestsBase, IArchitectureMap | -| 10 | `ControllerConventionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, ControllerConventionTestsBase, IArchitectureMap | -| 10 | `DataResidencyTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, DataResidencyTestsBase, IArchitectureMap | -| 10 | `DomainPurityTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, DomainPurityTestsBase, IArchitectureMap | -| 10 | `EntityConventionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, EntityConventionTestsBase, IArchitectureMap | -| 10 | `EventConventionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, EventConventionTestsBase, IArchitectureMap | -| 10 | `FormsConventionTests` | MMCA.ADC.Architecture.Tests | 4 | AdcArchitectureMap, ArchitectureMapBase, FormsConventionTestsBase, IArchitectureMap | -| 10 | `FrameworkVersionConsistencyTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, FrameworkVersionConsistencyTestsBase, IArchitectureMap | -| 10 | `HandlerConventionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, HandlerConventionTestsBase, IArchitectureMap | -| 10 | `HandlerResultConventionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, HandlerResultConventionTestsBase, IArchitectureMap | -| 10 | `IdempotencyConventionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, IdempotencyConventionTestsBase | -| 10 | `ImmutabilityTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, ImmutabilityTestsBase | -| 10 | `IntegrationEventContractTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, IntegrationEventContractTestsBase | -| 10 | `LayerDependencyTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, LayerDependencyTestsBase | -| 10 | `LocalizedTextConventionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, LocalizedTextConventionTestsBase | -| 10 | `MicroserviceExtractionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, MicroserviceExtractionTestsBase | -| 10 | `ModuleIsolationTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, ModuleIsolationTestsBase | -| 10 | `NamingConventionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, NamingConventionTestsBase | -| 10 | `PiiConventionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, PiiConventionTestsBase | -| 10 | `RawQueryableConventionTests` | MMCA.ADC.Architecture.Tests | 4 | AdcArchitectureMap, ArchitectureMapBase, IArchitectureMap, RawQueryableConventionTestsBase | -| 10 | `SharedLayerTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, SharedLayerTestsBase | -| 10 | `SliceCohesionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, SliceCohesionTestsBase | -| 10 | `SpecificationConventionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, SpecificationConventionTestsBase | -| 10 | `StateManagementConventionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, StateManagementConventionTestsBase | -| 10 | `UIArchitectureConventionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, UIArchitectureConventionTestsBase | +| 10 | `ActivitiesController` | MMCA.ADC.Conference.API | 20 | Activity, ActivityCreateRequest, ActivityDTO, ActivityUpdateRequest, AggregateRootEntityControllerBase, BaseLookup, CollectionResult, ConferencePermissions, DeleteEntityCommand, GetPublicActivityFilterQuery, ICommandHandler, ICurrentUserService, IEntityQueryService, IQueryHandler, PagedCollectionResult, QueryFilterModelBinder, Result, Route, Specification, UpdateActivityCommand | | 10 | `ConferenceModuleSeeder` | MMCA.ADC.Conference.API | 3 | ConferenceModuleDbSeeder, IModuleSeeder, IUnitOfWork | | 10 | `EventsController` | MMCA.ADC.Conference.API | 28 | AggregateRootEntityControllerBase, BaseLookup, CollectionResult, ConferencePermissions, DeleteEntityCommand, Event, EventCreateRequest, EventDTO, EventTransitionRequest, EventUpdateRequest, ExportEventCalendarQuery, GetNowNextQuery, ICommandHandler, ICurrentUserService, IEntityQueryService, IQueryHandler, NowNextDTO, PagedCollectionResult, PublishedEventSpecification, PublishEventCommand …(+8) | | 10 | `SessionCategoryItemsController` | MMCA.ADC.Conference.API | 19 | AddSessionCategoryItemCommand, AddSessionCategoryItemRequest, BaseLookup, CollectionResult, ConferencePermissions, EntityControllerBase, GetPublicSessionCategoryItemFilterQuery, ICommandHandler, ICurrentUserService, IEntityQueryService, IQueryHandler, PagedCollectionResult, QueryFilterModelBinder, RemoveSessionCategoryItemCommand, Result, Route, SessionCategoryItem, SessionCategoryItemDTO, Specification | | 10 | `SessionQuestionAnswersController` | MMCA.ADC.Conference.API | 20 | AddSessionQuestionAnswerCommand, AddSessionQuestionAnswerRequest, AuthorizationPolicies, BaseLookup, CollectionResult, EntityControllerBase, ICommandHandler, ICurrentUserService, IEntityQueryService, OwnedByUserSpecification, PagedCollectionResult, QueryFilterModelBinder, RemoveSessionQuestionAnswerCommand, Result, RoleNames, Route, SessionQuestionAnswer, SessionQuestionAnswerDTO, UpdateSessionQuestionAnswerCommand, UpdateSessionQuestionAnswerRequest | -| 10 | `SessionsController` | MMCA.ADC.Conference.API | 26 | AggregateRootEntityControllerBase, AndSpecification, BaseLookup, CollectionResult, ConferencePermissions, DeleteEntityCommand, Event, EventDTO, ExportSessionCalendarQuery, GetPublicSessionFilterQuery, GetSessionsBySpeakerFilterQuery, ICommandHandler, ICurrentUserService, IEntityQueryService, IQueryHandler, PagedCollectionResult, QueryFilterModelBinder, Result, Route, Session …(+6) | +| 10 | `SessionsController` | MMCA.ADC.Conference.API | 25 | AggregateRootEntityControllerBase, BaseLookup, CollectionResult, ConferencePermissions, DeleteEntityCommand, Event, EventDTO, ExportSessionCalendarQuery, GetPublicSessionFilterQuery, GetSessionsBySpeakerFilterQuery, ICommandHandler, ICurrentUserService, IEntityQueryService, IQueryHandler, PagedCollectionResult, QueryFilterModelBinder, Result, Route, Session, SessionCreateRequest …(+5) | | 10 | `SessionSpeakersController` | MMCA.ADC.Conference.API | 19 | AddSessionSpeakerCommand, AddSessionSpeakerRequest, BaseLookup, CollectionResult, ConferencePermissions, EntityControllerBase, GetPublicSessionSpeakerFilterQuery, ICommandHandler, ICurrentUserService, IEntityQueryService, IQueryHandler, PagedCollectionResult, QueryFilterModelBinder, RemoveSessionSpeakerCommand, Result, Route, SessionSpeaker, SessionSpeakerDTO, Specification | | 10 | `SponsorsController` | MMCA.ADC.Conference.API | 20 | AggregateRootEntityControllerBase, BaseLookup, CollectionResult, ConferencePermissions, DeleteEntityCommand, GetPublicSponsorFilterQuery, ICommandHandler, ICurrentUserService, IEntityQueryService, IQueryHandler, PagedCollectionResult, QueryFilterModelBinder, Result, Route, Specification, Sponsor, SponsorCreateRequest, SponsorDTO, SponsorUpdateRequest, UpdateSponsorCommand | | 10 | `EventQuestionAnswersControllerTests` | MMCA.ADC.Conference.API.Tests | 18 | AddEventQuestionAnswerCommand, AddEventQuestionAnswerRequest, CollectionResult, Error, EventQuestionAnswer, EventQuestionAnswerDTO, EventQuestionAnswersController, ICommandHandler, ICurrentUserService, IEntityQueryService, PagedCollectionResult, PaginationMetadata, RemoveEventQuestionAnswerCommand, Result, RoleNames, Specification, UpdateEventQuestionAnswerCommand, UpdateEventQuestionAnswerRequest | | 10 | `EventSpeakersControllerTests` | MMCA.ADC.Conference.API.Tests | 19 | AddEventSpeakerCommand, AddEventSpeakerRequest, BaseLookup, Error, EventSpeaker, EventSpeakerDTO, EventSpeakersController, GetPublicEventSpeakerFilterQuery, ICommandHandler, ICurrentUserService, IEntityQueryService, InlineSpecification, IQueryHandler, ISpecification, PagedCollectionResult, RemoveEventSpeakerCommand, Result, RoleNames, Specification | | 10 | `QuestionsControllerTests` | MMCA.ADC.Conference.API.Tests | 11 | DeleteEntityCommand, Error, ICommandHandler, IEntityQueryService, Question, QuestionCreateRequest, QuestionDTO, QuestionsController, QuestionUpdateRequest, Result, UpdateQuestionCommand | -| 10 | `RoomsControllerTests` | MMCA.ADC.Conference.API.Tests | 12 | AddRoomCommand, AddRoomRequest, Error, ICommandHandler, IEntityQueryService, RemoveRoomCommand, Result, Room, RoomDTO, RoomsController, UpdateRoomCommand, UpdateRoomRequest | +| 10 | `RoomsControllerTests` | MMCA.ADC.Conference.API.Tests | 22 | AddRoomCommand, AddRoomRequest, BaseLookup, CollectionResult, Error, GetPublicRoomFilterQuery, ICommandHandler, ICurrentUserService, IEntityQueryService, InlineSpecification, IQueryHandler, ISpecification, PagedCollectionResult, RemoveRoomCommand, Result, RoleNames, Room, RoomDTO, RoomsController, Specification …(+2) | | 10 | `SpeakerCategoryItemsControllerTests` | MMCA.ADC.Conference.API.Tests | 19 | AddSpeakerCategoryItemCommand, AddSpeakerCategoryItemRequest, BaseLookup, Error, GetPublicSpeakerCategoryItemFilterQuery, ICommandHandler, ICurrentUserService, IEntityQueryService, InlineSpecification, IQueryHandler, ISpecification, PagedCollectionResult, RemoveSpeakerCategoryItemCommand, Result, RoleNames, SpeakerCategoryItem, SpeakerCategoryItemDTO, SpeakerCategoryItemsController, Specification | | 10 | `SpeakersControllerTests` | MMCA.ADC.Conference.API.Tests | 29 | AndSpecification, BaseLookup, DeleteEntityCommand, Error, GetPublicSpeakerFilterQuery, GetSessionBookmarkCountQuery, GetSessionBookmarkCountsQuery, GetSessionFeedbackQuery, GetSpeakersByEventFilterQuery, ICommandHandler, ICurrentUserService, IEntityQueryService, InlineSpecification, IQueryHandler, ISpecification, LinkUserRequest, LinkUserToSpeakerCommand, PagedCollectionResult, Result, RoleNames …(+9) | +| 10 | `ActivityCreateRequestMapper` | MMCA.ADC.Conference.Application | 4 | Activity, ActivityCreateRequest, IEntityRequestMapper, Result | +| 10 | `ActivityCreateRequestValidator` | MMCA.ADC.Conference.Application | 9 | ActivityCreateRequest, ActivityDescriptionRules, ActivityEventIdRules, ActivityNameRules, ActivitySortOrderRules, ActivityTimeRangeRules, ActivityVenueAddressRules, ActivityVenueNameRules, ActivityVenueUrlRules | +| 10 | `ActivityNavigationPopulator` | MMCA.ADC.Conference.Application | 5 | Activity, DeclarativeNavigationPopulator, Event, FKNavigationDescriptor, IUnitOfWork | | 10 | `AddSessionCategoryItemCommandValidator` | MMCA.ADC.Conference.Application | 1 | AddSessionCategoryItemCommand | | 10 | `AddSessionCategoryItemHandler` | MMCA.ADC.Conference.Application | 8 | AddSessionCategoryItemCommand, Error, ICommandHandler, IUnitOfWork, Result, Session, SessionCategoryItemDTO, SessionCategoryItemDTOMapper | | 10 | `AddSessionQuestionAnswerCommandValidator` | MMCA.ADC.Conference.Application | 1 | AddSessionQuestionAnswerCommand | | 10 | `AddSessionQuestionAnswerHandler` | MMCA.ADC.Conference.Application | 16 | AddSessionQuestionAnswerCommand, Error, Event, EventInvariants, ICommandHandler, ICurrentUserService, IUnitOfWork, Question, QuestionInvariants, Result, Session, SessionFeedbackSubmitted, SessionInvariants, SessionQuestionAnswer, SessionQuestionAnswerDTO, SessionQuestionAnswerDTOMapper | | 10 | `AddSessionSpeakerCommandValidator` | MMCA.ADC.Conference.Application | 1 | AddSessionSpeakerCommand | | 10 | `AddSessionSpeakerHandler` | MMCA.ADC.Conference.Application | 8 | AddSessionSpeakerCommand, Error, ICommandHandler, IUnitOfWork, Result, Session, SessionSpeakerDTO, SessionSpeakerDTOMapper | +| 10 | `CategoryItemNavigationPopulator` | MMCA.ADC.Conference.Application | 5 | Category, CategoryItem, DeclarativeNavigationPopulator, FKNavigationDescriptor, IUnitOfWork | | 10 | `CategorySyncStrategy` | MMCA.ADC.Conference.Application | 7 | Category, ISessionizeSyncStrategy, SessionizeCategory, SessionizeCategoryItem, SessionizeSyncContext, SessionizeSyncResult, SessionizeSyncWarnings | | 10 | `ConferenceCategoryNavigationPopulator` | MMCA.ADC.Conference.Application | 5 | Category, CategoryItem, ChildNavigationDescriptor, DeclarativeNavigationPopulator, IUnitOfWork | +| 10 | `CreateActivityHandler` | MMCA.ADC.Conference.Application | 8 | Activity, ActivityCreateRequest, ActivityDTO, ActivityDTOMapper, ICommandHandler, IEntityRequestMapper, IUnitOfWork, Result | | 10 | `CreateEventHandler` | MMCA.ADC.Conference.Application | 8 | Event, EventCreateRequest, EventDTO, EventDTOMapper, ICommandHandler, IEntityRequestMapper, IUnitOfWork, Result | | 10 | `CreateSpeakerHandler` | MMCA.ADC.Conference.Application | 8 | ICommandHandler, IEntityRequestMapper, IUnitOfWork, Result, Speaker, SpeakerCreateRequest, SpeakerDTO, SpeakerDTOMapper | | 10 | `CreateSponsorHandler` | MMCA.ADC.Conference.Application | 8 | ICommandHandler, IEntityRequestMapper, IUnitOfWork, Result, Sponsor, SponsorCreateRequest, SponsorDTO, SponsorDTOMapper | -| 10 | `DeleteEventHandler` | MMCA.ADC.Conference.Application | 9 | DeleteEntityCommand, Error, Event, ICommandHandler, IEventCascadeDeletionDomainService, IUnitOfWork, Result, Session, Sponsor | +| 10 | `DeleteEventHandler` | MMCA.ADC.Conference.Application | 10 | Activity, DeleteEntityCommand, Error, Event, ICommandHandler, IEventCascadeDeletionDomainService, IUnitOfWork, Result, Session, Sponsor | | 10 | `EventLiveValidationService` | MMCA.ADC.Conference.Application | 14 | CalendarExportMapper, CurrentEventSelector, Error, Event, EventLiveInfo, IEventLiveValidationService, IUnitOfWork, Result, RoomSessionInfo, Session, SessionInvariants, SessionLiveInfo, Sponsor, SponsorLiveInfo | | 10 | `EventNavigationPopulator` | MMCA.ADC.Conference.Application | 7 | ChildNavigationDescriptor, DeclarativeNavigationPopulator, Event, EventQuestionAnswer, EventSpeaker, IUnitOfWork, Room | +| 10 | `EventQuestionAnswerNavigationPopulator` | MMCA.ADC.Conference.Application | 5 | DeclarativeNavigationPopulator, Event, EventQuestionAnswer, FKNavigationDescriptor, IUnitOfWork | +| 10 | `EventSpeakerNavigationPopulator` | MMCA.ADC.Conference.Application | 5 | DeclarativeNavigationPopulator, Event, EventSpeaker, FKNavigationDescriptor, IUnitOfWork | | 10 | `ExportEventCalendarHandler` | MMCA.ADC.Conference.Application | 9 | CalendarExportMapper, Error, Event, ExportEventCalendarQuery, IcsCalendarBuilder, IQueryHandler, IUnitOfWork, Result, Session | | 10 | `ExportSessionCalendarHandler` | MMCA.ADC.Conference.Application | 9 | CalendarExportMapper, Error, Event, ExportSessionCalendarQuery, IcsCalendarBuilder, IQueryHandler, IUnitOfWork, Result, Session | | 10 | `GetNowNextHandler` | MMCA.ADC.Conference.Application | 11 | CalendarExportMapper, CurrentEventSelector, Error, Event, GetNowNextQuery, IQueryHandler, IUnitOfWork, NowNextDTO, NowNextSessionDTO, Result, Session | | 10 | `GetPublicSessionFilterHandler` | MMCA.ADC.Conference.Application | 9 | CrossSourceSpecification, Event, GetPublicSessionFilterQuery, IQueryHandler, IUnitOfWork, PublicSessionStatusSpecification, Result, Session, Specification | -| 10 | `PublicConferenceVisibility` | MMCA.ADC.Conference.Application | 8 | AndSpecification, CrossSourceSpecification, Event, InlineSpecification, IUnitOfWork, PublicSessionStatusSpecification, Session, SessionSpeaker | +| 10 | `PublicConferenceVisibility` | MMCA.ADC.Conference.Application | 8 | CrossSourceSpecification, Event, IEntityQuerier, InlineSpecification, IUnitOfWork, PublicSessionStatusSpecification, Session, SessionSpeaker | | 10 | `QuestionSyncStrategy` | MMCA.ADC.Conference.Application | 5 | ISessionizeSyncStrategy, Question, QuestionInvariants, SessionizeSyncContext, SessionizeSyncResult | | 10 | `RemoveSessionCategoryItemHandler` | MMCA.ADC.Conference.Application | 6 | Error, ICommandHandler, IUnitOfWork, RemoveSessionCategoryItemCommand, Result, Session | | 10 | `RemoveSessionQuestionAnswerHandler` | MMCA.ADC.Conference.Application | 9 | Error, ICommandHandler, ICurrentUserService, IUnitOfWork, RemoveSessionQuestionAnswerCommand, Result, RoleNames, Session, SessionQuestionAnswer | | 10 | `RemoveSessionSpeakerHandler` | MMCA.ADC.Conference.Application | 6 | Error, ICommandHandler, IUnitOfWork, RemoveSessionSpeakerCommand, Result, Session | -| 10 | `RoomSyncStrategy` | MMCA.ADC.Conference.Application | 4 | ISessionizeSyncStrategy, Room, SessionizeSyncContext, SessionizeSyncResult | +| 10 | `RoomNavigationPopulator` | MMCA.ADC.Conference.Application | 5 | DeclarativeNavigationPopulator, Event, FKNavigationDescriptor, IUnitOfWork, Room | +| 10 | `RoomSyncStrategy` | MMCA.ADC.Conference.Application | 9 | Event, EventInvariants, ISessionizeSyncStrategy, Result, Room, SessionizeRoom, SessionizeSyncContext, SessionizeSyncResult, SessionizeSyncWarnings | +| 10 | `SessionCategoryItemNavigationPopulator` | MMCA.ADC.Conference.Application | 5 | DeclarativeNavigationPopulator, FKNavigationDescriptor, IUnitOfWork, Session, SessionCategoryItem | | 10 | `SessionCreateRequestMapper` | MMCA.ADC.Conference.Application | 4 | IEntityRequestMapper, Result, Session, SessionCreateRequest | | 10 | `SessionCreateRequestValidator` | MMCA.ADC.Conference.Application | 9 | SessionAccessibilityInfoRules, SessionCreateRequest, SessionDescriptionRules, SessionEventIdRules, SessionLiveUrlRules, SessionRecordingUrlRules, SessionResourceLinksRules, SessionStatusRules, SessionTitleRules | | 10 | `SessionDTOMapper` | MMCA.ADC.Conference.Application | 6 | IEntityDTOMapper, Session, SessionCategoryItemDTOMapper, SessionDTO, SessionQuestionAnswerDTOMapper, SessionSpeakerDTOMapper | -| 10 | `SessionNavigationPopulator` | MMCA.ADC.Conference.Application | 7 | ChildNavigationDescriptor, DeclarativeNavigationPopulator, IUnitOfWork, Session, SessionCategoryItem, SessionQuestionAnswer, SessionSpeaker | +| 10 | `SessionNavigationPopulator` | MMCA.ADC.Conference.Application | 10 | ChildNavigationDescriptor, DeclarativeNavigationPopulator, Event, FKNavigationDescriptor, IUnitOfWork, Room, Session, SessionCategoryItem, SessionQuestionAnswer, SessionSpeaker | +| 10 | `SessionQuestionAnswerNavigationPopulator` | MMCA.ADC.Conference.Application | 5 | DeclarativeNavigationPopulator, FKNavigationDescriptor, IUnitOfWork, Session, SessionQuestionAnswer | +| 10 | `SessionSpeakerNavigationPopulator` | MMCA.ADC.Conference.Application | 5 | DeclarativeNavigationPopulator, FKNavigationDescriptor, IUnitOfWork, Session, SessionSpeaker | | 10 | `SessionSyncStrategy` | MMCA.ADC.Conference.Application | 7 | ISessionizeSyncStrategy, Session, SessionizeQuestionAnswer, SessionizeSession, SessionizeSyncContext, SessionizeSyncResult, SessionizeSyncWarnings | +| 10 | `SpeakerCategoryItemNavigationPopulator` | MMCA.ADC.Conference.Application | 5 | DeclarativeNavigationPopulator, FKNavigationDescriptor, IUnitOfWork, Speaker, SpeakerCategoryItem | | 10 | `SpeakerEntityQueryService` | MMCA.ADC.Conference.Application | 8 | EntityQueryService, IEntityQueryPipeline, INavigationMetadataProvider, INavigationPopulator, IUnitOfWork, Speaker, SpeakerDTO, SpeakerDTOMapper | | 10 | `SpeakerNavigationPopulator` | MMCA.ADC.Conference.Application | 6 | ChildNavigationDescriptor, DeclarativeNavigationPopulator, IUnitOfWork, Speaker, SpeakerCategoryItem, SpeakerQuestionAnswer | +| 10 | `SpeakerQuestionAnswerNavigationPopulator` | MMCA.ADC.Conference.Application | 5 | DeclarativeNavigationPopulator, FKNavigationDescriptor, IUnitOfWork, Speaker, SpeakerQuestionAnswer | | 10 | `SpeakerSyncStrategy` | MMCA.ADC.Conference.Application | 9 | EventSpeaker, ISessionizeSyncStrategy, SessionizeLink, SessionizeQuestionAnswer, SessionizeSpeaker, SessionizeSyncContext, SessionizeSyncResult, SessionizeSyncWarnings, Speaker | | 10 | `SponsorCreateRequestMapper` | MMCA.ADC.Conference.Application | 4 | IEntityRequestMapper, Result, Sponsor, SponsorCreateRequest | | 10 | `SponsorCreateRequestValidator` | MMCA.ADC.Conference.Application | 10 | SponsorBoothNumberRules, SponsorCreateRequest, SponsorDescriptionRules, SponsorEventIdRules, SponsorLinkedInUrlRules, SponsorLogoUrlRules, SponsorNameRules, SponsorSortRules, SponsorTwitterHandleRules, SponsorWebsiteUrlRules | +| 10 | `SponsorNavigationPopulator` | MMCA.ADC.Conference.Application | 5 | DeclarativeNavigationPopulator, Event, FKNavigationDescriptor, IUnitOfWork, Sponsor | +| 10 | `UpdateActivityHandler` | MMCA.ADC.Conference.Application | 8 | Activity, ActivityDTO, ActivityDTOMapper, Error, ICommandHandler, IUnitOfWork, Result, UpdateActivityCommand | | 10 | `UpdateEventHandler` | MMCA.ADC.Conference.Application | 9 | Error, Event, EventDTOMapper, ICommandHandler, IUnitOfWork, Result, Session, UpdateEventCommand, UpdateEventResult | | 10 | `UpdateSessionQuestionAnswerHandler` | MMCA.ADC.Conference.Application | 9 | Error, ICommandHandler, ICurrentUserService, IUnitOfWork, Result, RoleNames, Session, SessionQuestionAnswer, UpdateSessionQuestionAnswerCommand | | 10 | `UpdateSpeakerHandler` | MMCA.ADC.Conference.Application | 8 | Error, ICommandHandler, IUnitOfWork, Result, Speaker, SpeakerDTO, SpeakerDTOMapper, UpdateSpeakerCommand | | 10 | `UpdateSponsorHandler` | MMCA.ADC.Conference.Application | 8 | Error, ICommandHandler, IUnitOfWork, Result, Sponsor, SponsorDTO, SponsorDTOMapper, UpdateSponsorCommand | -| 10 | `AddCategoryItemHandlerTests` | MMCA.ADC.Conference.Application.Tests | 8 | AddCategoryItemCommand, AddCategoryItemHandler, Category, CategoryItemDTOMapper, ErrorType, HandlerTestBase, IRepository, UnitOfWork | +| 10 | `ActivityDTOMapperTests` | MMCA.ADC.Conference.Application.Tests | 2 | Activity, ActivityDTOMapper | | 10 | `AddEventQuestionAnswerCommandValidatorTests` | MMCA.ADC.Conference.Application.Tests | 2 | AddEventQuestionAnswerCommand, AddEventQuestionAnswerCommandValidator | -| 10 | `AddEventQuestionAnswerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 10 | AddEventQuestionAnswerCommand, AddEventQuestionAnswerHandler, ErrorType, Event, EventQuestionAnswerDTOMapper, HandlerTestBase, ICurrentUserService, IRepository, Question, UnitOfWork | | 10 | `AddEventSpeakerCommandValidatorTests` | MMCA.ADC.Conference.Application.Tests | 2 | AddEventSpeakerCommand, AddEventSpeakerCommandValidator | -| 10 | `AddEventSpeakerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 8 | AddEventSpeakerCommand, AddEventSpeakerHandler, ErrorType, Event, EventSpeakerDTOMapper, HandlerTestBase, IRepository, UnitOfWork | | 10 | `AddRoomCommandValidatorTests` | MMCA.ADC.Conference.Application.Tests | 3 | AddRoomCommand, AddRoomCommandValidator, EventInvariants | -| 10 | `AddRoomHandlerTests` | MMCA.ADC.Conference.Application.Tests | 12 | AddRoomCommand, AddRoomHandler, ErrorType, Event, EventInvariants, HandlerTestBase, IReadRepository, IRepository, IUnitOfWork, Room, RoomDTOMapper, UnitOfWork | | 10 | `AddSpeakerCategoryItemCommandValidatorTests` | MMCA.ADC.Conference.Application.Tests | 2 | AddSpeakerCategoryItemCommand, AddSpeakerCategoryItemCommandValidator | -| 10 | `AddSpeakerCategoryItemHandlerTests` | MMCA.ADC.Conference.Application.Tests | 8 | AddSpeakerCategoryItemCommand, AddSpeakerCategoryItemHandler, ErrorType, HandlerTestBase, IRepository, Speaker, SpeakerCategoryItemDTOMapper, UnitOfWork | | 10 | `CalendarExportMapperTests` | MMCA.ADC.Conference.Application.Tests | 4 | CalendarExportMapper, Event, Session, SessionStatuses | -| 10 | `CreateConferenceCategoryHandlerTests` | MMCA.ADC.Conference.Application.Tests | 11 | Category, CategoryItemDTOMapper, ConferenceCategoryCreateRequest, ConferenceCategoryDTOMapper, CreateConferenceCategoryHandler, Error, HandlerTestBase, IEntityRequestMapper, IRepository, Result, UnitOfWork | -| 10 | `CreateQuestionHandlerTests` | MMCA.ADC.Conference.Application.Tests | 11 | CreateQuestionHandler, HandlerTestBase, IEntityRequestMapper, IRepository, IUnitOfWork, Question, QuestionCreateRequest, QuestionDTOMapper, QuestionInvariants, Result, UnitOfWork | -| 10 | `DeleteSessionHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | DeleteEntityCommand, DeleteSessionHandler, ErrorType, HandlerTestBase, IRepository, Session, UnitOfWork | | 10 | `EventCreateRequestValidatorTests` | MMCA.ADC.Conference.Application.Tests | 2 | EventCreateRequest, EventCreateRequestValidator | | 10 | `EventDTOMapperTests` | MMCA.ADC.Conference.Application.Tests | 5 | Event, EventDTOMapper, EventQuestionAnswerDTOMapper, EventSpeakerDTOMapper, RoomDTOMapper | -| 10 | `GetCategoryDistributionHandlerTests` | MMCA.ADC.Conference.Application.Tests | 8 | Category, GetCategoryDistributionHandler, GetCategoryDistributionQuery, HandlerTestBase, IRepository, Session, SessionStatuses, UnitOfWork | -| 10 | `GetContentSimilarityHandlerTests` | MMCA.ADC.Conference.Application.Tests | 8 | Category, GetContentSimilarityHandler, GetContentSimilarityQuery, HandlerTestBase, IRepository, Session, SessionStatuses, UnitOfWork | | 10 | `GetNowNextQueryCacheTests` | MMCA.ADC.Conference.Application.Tests | 3 | GetNowNextQuery, IQueryCacheable, Session | | 10 | `GetSessionBookmarkCountHandlerTests` | MMCA.ADC.Conference.Application.Tests | 8 | ErrorType, GetSessionBookmarkCountHandler, GetSessionBookmarkCountQuery, IBookmarkCountService, IRepository, IUnitOfWork, Result, Session | | 10 | `GetSessionBookmarkCountsHandlerTests` | MMCA.ADC.Conference.Application.Tests | 6 | GetSessionBookmarkCountsHandler, GetSessionBookmarkCountsQuery, IBookmarkCountService, IReadRepository, IUnitOfWork, Session | | 10 | `GetSessionFeedbackHandlerTests` | MMCA.ADC.Conference.Application.Tests | 9 | ErrorType, GetSessionFeedbackHandler, GetSessionFeedbackQuery, IRepository, IUnitOfWork, Question, Result, Session, SessionFeedbackDTO | -| 10 | `GetSessionsBySpeakerFilterHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | GetSessionsBySpeakerFilterHandler, GetSessionsBySpeakerFilterQuery, HandlerTestBase, IReadRepository, Session, SessionSpeaker, UnitOfWork | -| 10 | `GetSessionSelectionDashboardHandlerTests` | MMCA.ADC.Conference.Application.Tests | 12 | Category, ErrorType, Event, GetSessionSelectionDashboardHandler, GetSessionSelectionDashboardQuery, HandlerTestBase, IRepository, Session, SessionAiScore, SessionStatuses, Speaker, UnitOfWork | -| 10 | `GetSpeakersByEventFilterHandlerTests` | MMCA.ADC.Conference.Application.Tests | 9 | EventSpeaker, GetSpeakersByEventFilterHandler, GetSpeakersByEventFilterQuery, HandlerTestBase, IReadRepository, Session, SessionSpeaker, Speaker, UnitOfWork | -| 10 | `GetSpeakerSessionOverlapHandlerTests` | MMCA.ADC.Conference.Application.Tests | 9 | Category, GetSpeakerSessionOverlapHandler, GetSpeakerSessionOverlapQuery, HandlerTestBase, IRepository, Session, SessionStatuses, Speaker, UnitOfWork | -| 10 | `LinkUserToSpeakerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 8 | ErrorType, HandlerTestBase, IRepository, LinkUserToSpeakerCommand, LinkUserToSpeakerHandler, Speaker, SpeakerLinkedToUser, UnitOfWork | -| 10 | `PublishEventHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | ErrorType, Event, HandlerTestBase, IRepository, PublishEventCommand, PublishEventHandler, UnitOfWork | | 10 | `QuestionCreateRequestValidatorTests` | MMCA.ADC.Conference.Application.Tests | 3 | QuestionCreateRequest, QuestionCreateRequestValidator, QuestionInvariants | -| 10 | `RemoveCategoryItemHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | Category, ErrorType, HandlerTestBase, IRepository, RemoveCategoryItemCommand, RemoveCategoryItemHandler, UnitOfWork | -| 10 | `RemoveEventQuestionAnswerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 9 | ErrorType, Event, HandlerTestBase, ICurrentUserService, IRepository, RemoveEventQuestionAnswerCommand, RemoveEventQuestionAnswerHandler, RoleNames, UnitOfWork | -| 10 | `RemoveEventSpeakerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | ErrorType, Event, HandlerTestBase, IRepository, RemoveEventSpeakerCommand, RemoveEventSpeakerHandler, UnitOfWork | -| 10 | `RemoveRoomHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | ErrorType, Event, HandlerTestBase, IRepository, RemoveRoomCommand, RemoveRoomHandler, UnitOfWork | -| 10 | `RemoveSpeakerCategoryItemHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | ErrorType, HandlerTestBase, IRepository, RemoveSpeakerCategoryItemCommand, RemoveSpeakerCategoryItemHandler, Speaker, UnitOfWork | -| 10 | `ScoreEventSessionsHandlerTests` | MMCA.ADC.Conference.Application.Tests | 12 | HandlerTestBase, IAiScoringService, IRepository, ScoreEventSessionsCommand, ScoreEventSessionsHandler, Session, SessionAiScore, SessionScoringInput, SessionScoringResult, SessionStatuses, Speaker, UnitOfWork | -| 10 | `SessionBookmarkValidationServiceTests` | MMCA.ADC.Conference.Application.Tests | 6 | ErrorType, HandlerTestBase, IRepository, Session, SessionBookmarkValidationService, UnitOfWork | | 10 | `SessionCategoryItemDTOMapperTests` | MMCA.ADC.Conference.Application.Tests | 3 | Session, SessionCategoryItem, SessionCategoryItemDTOMapper | | 10 | `SessionQuestionAnswerDTOMapperTests` | MMCA.ADC.Conference.Application.Tests | 3 | Session, SessionQuestionAnswer, SessionQuestionAnswerDTOMapper | | 10 | `SessionRoomSchedulingTests` | MMCA.ADC.Conference.Application.Tests | 3 | ErrorType, Session, SessionRoomScheduling | @@ -3151,17 +3118,11 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 10 | `SpeakerCreateRequestValidatorTests` | MMCA.ADC.Conference.Application.Tests | 3 | Email, SpeakerCreateRequest, SpeakerCreateRequestValidator | | 10 | `SpeakerDTOMapperTests` | MMCA.ADC.Conference.Application.Tests | 5 | ICurrentUserService, Speaker, SpeakerCategoryItemDTOMapper, SpeakerDTOMapper, SpeakerQuestionAnswerDTOMapper | | 10 | `SponsorDTOMapperTests` | MMCA.ADC.Conference.Application.Tests | 3 | Sponsor, SponsorDTOMapper, SponsorTier | -| 10 | `UnlinkUserFromSpeakerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 8 | ErrorType, HandlerTestBase, IRepository, Speaker, SpeakerUnlinkedFromUser, UnitOfWork, UnlinkUserFromSpeakerCommand, UnlinkUserFromSpeakerHandler | -| 10 | `UnpublishEventHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | ErrorType, Event, HandlerTestBase, IRepository, UnitOfWork, UnpublishEventCommand, UnpublishEventHandler | -| 10 | `UpdateCategoryItemHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | Category, ErrorType, HandlerTestBase, IRepository, UnitOfWork, UpdateCategoryItemCommand, UpdateCategoryItemHandler | -| 10 | `UpdateConferenceCategoryHandlerTests` | MMCA.ADC.Conference.Application.Tests | 10 | Category, CategoryItemDTOMapper, ConferenceCategoryDTOMapper, ConferenceCategoryUpdateRequest, ErrorType, HandlerTestBase, IRepository, UnitOfWork, UpdateConferenceCategoryCommand, UpdateConferenceCategoryHandler | -| 10 | `UpdateEventQuestionAnswerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 9 | ErrorType, Event, HandlerTestBase, ICurrentUserService, IRepository, RoleNames, UnitOfWork, UpdateEventQuestionAnswerCommand, UpdateEventQuestionAnswerHandler | -| 10 | `UpdateQuestionHandlerTests` | MMCA.ADC.Conference.Application.Tests | 13 | ErrorType, EventQuestionAnswer, HandlerTestBase, IReadRepository, IRepository, Question, QuestionDTOMapper, QuestionUpdateRequest, SessionQuestionAnswer, SpeakerQuestionAnswer, UnitOfWork, UpdateQuestionCommand, UpdateQuestionHandler | | 10 | `UpdateRoomCommandValidatorTests` | MMCA.ADC.Conference.Application.Tests | 3 | EventInvariants, UpdateRoomCommand, UpdateRoomCommandValidator | -| 10 | `UpdateRoomHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | ErrorType, Event, HandlerTestBase, IRepository, UnitOfWork, UpdateRoomCommand, UpdateRoomHandler | | 10 | `UserRegisteredHandlerTests` | MMCA.ADC.Conference.Application.Tests | 10 | Fakes, IEventBus, InMemoryRepository, IUnitOfWork, RecordingEventBus, RecordingUnitOfWork, Speaker, SpeakerLinkedToUser, UserRegistered, UserRegisteredHandler | | 10 | `SessionBookmarkValidationServiceGrpcAdapter` | MMCA.ADC.Conference.Contracts | 5 | Error, GrpcErrorTrailerParser, ISessionBookmarkValidationService, Result, SessionBookmarkValidationService | -| 10 | `EventCascadeDeletionDomainService` | MMCA.ADC.Conference.Domain | 5 | Event, IEventCascadeDeletionDomainService, Result, Session, Sponsor | +| 10 | `EventCascadeDeletionDomainService` | MMCA.ADC.Conference.Domain | 6 | Activity, Event, IEventCascadeDeletionDomainService, Result, Session, Sponsor | +| 10 | `ActivityTests` | MMCA.ADC.Conference.Domain.Tests | 6 | Activity, ActivityBuilder, ActivityChanged, ActivityInvariants, DomainEntityState, Result | | 10 | `SessionCategoryItemTests` | MMCA.ADC.Conference.Domain.Tests | 5 | DomainEntityState, ErrorType, SessionBuilder, SessionCategoryItem, SessionCategoryItemChanged | | 10 | `SessionQuestionAnswerTests` | MMCA.ADC.Conference.Domain.Tests | 6 | DomainEntityState, ErrorType, SessionBuilder, SessionInvariants, SessionQuestionAnswer, SessionQuestionAnswerChanged | | 10 | `SessionSpeakerTests` | MMCA.ADC.Conference.Domain.Tests | 5 | DomainEntityState, ErrorType, SessionBuilder, SessionSpeaker, SessionSpeakerChanged | @@ -3172,11 +3133,18 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 10 | `SessionScoringSweepJobTests` | MMCA.ADC.Conference.Infrastructure.Tests | 11 | FixedTimeProvider, IReadRepository, ISessionScoringQueue, IUnitOfWork, Session, SessionAiScore, SessionScoreStamp, SessionScoringCandidate, SessionScoringEnqueueResult, SessionScoringQueue, SessionScoringSweepJob | | 10 | `SessionBookmarksGrpcService` | MMCA.ADC.Conference.Service | 2 | ISessionBookmarkValidationService, SessionBookmarkValidationService | | 10 | `CurrentEventDefaultsTests` | MMCA.ADC.Conference.Shared.Tests | 3 | CurrentEventDefaults, Event, EventDTO | -| 10 | `PublicSessionList` | MMCA.ADC.Conference.UI | 18 | BookmarkService, CachedSessionPage, ConferenceReadAudience, CurrentEventDefaults, DataGridListPageBase, EventDTO, EventService, IConnectivityStatusService, IEventUIService, ILocalCacheStore, ISessionBookmarkUIService, ISessionUIService, ISpeakerLookupService, PublicSessionListView, SessionDTO, SessionService, Severity, SpeakerInfo | +| 10 | `PublicSessionList` | MMCA.ADC.Conference.UI | 20 | BookmarkService, CachedSessionPage, ConferenceReadAudience, CurrentEventDefaults, DataGridListPageBase, EventDTO, EventService, IConnectivityStatusService, IEventUIService, ILocalCacheStore, ISessionBookmarkUIService, ISessionUIService, ISpeakerLookupService, PublicScheduleRoomOptions, PublicSessionListView, RoomDTO, SessionDTO, SessionService, Severity, SpeakerInfo | | 10 | `SessionList` | MMCA.ADC.Conference.UI | 14 | ConferenceRoutePaths, CurrentEventDefaults, DataGridListPageBase, ErrorMessages, EventDTO, EventService, IEventUIService, ISessionUIService, ISpeakerLookupService, ListPageActions, MobileInfiniteScrollList, SessionDTO, SessionService, SpeakerInfo | +| 10 | `ActivityCreateTests` | MMCA.ADC.Conference.UI.Tests | 6 | ActivityCreate, ActivityDTO, BunitTestBase, EventInfo, IActivityUIService, IEventLookupService | +| 10 | `ActivityDetailTests` | MMCA.ADC.Conference.UI.Tests | 7 | Activity, ActivityDetail, ActivityDTO, BunitTestBase, EventInfo, IActivityUIService, IEventLookupService | +| 10 | `ADCHomeTests` | MMCA.ADC.Conference.UI.Tests | 2 | ADCHome, BunitTestBase | +| 10 | `ADCHomeTicketingTests` | MMCA.ADC.Conference.UI.Tests | 4 | ADCHome, BunitTestBase, CapturingHttpMessageHandler, HttpTestDoubles | +| 10 | `PublicActivityListTests` | MMCA.ADC.Conference.UI.Tests | 6 | ActivityDTO, BunitTestBase, EventInfo, IActivityUIService, IEventLookupService, PublicActivityList | +| 10 | `PublicEventListRedirectTests` | MMCA.ADC.Conference.UI.Tests | 11 | BunitTestBase, EventDTO, EventInfo, IEventLookupService, IEventUIService, ListPageQueryStateService, ListPageStateService, MobileInfiniteScrollList, PublicEventList, RoleNames, TestPrincipal | | 10 | `PublicSessionDetailBookmarkTests` | MMCA.ADC.Conference.UI.Tests | 13 | BunitTestBase, CategoryItemInfo, ICategoryItemLookupService, IRoomUIService, ISessionBookmarkUIService, ISessionLiveUIService, ISessionUIService, ISpeakerLookupService, PublicSessionDetail, SessionDTO, Severity, SpeakerInfo, UserSessionBookmarkDTO | | 10 | `PublicSessionDetailLiveButtonTests` | MMCA.ADC.Conference.UI.Tests | 11 | BunitTestBase, CategoryItemInfo, ICategoryItemLookupService, IRoomUIService, ISessionBookmarkUIService, ISessionLiveUIService, ISessionUIService, ISpeakerLookupService, PublicSessionDetail, SessionDTO, SpeakerInfo | | 10 | `PublicSessionDetailTests` | MMCA.ADC.Conference.UI.Tests | 12 | BunitTestBase, CategoryItemInfo, ICategoryItemLookupService, IHapticFeedbackService, IRoomUIService, ISessionBookmarkUIService, ISessionLiveUIService, ISessionUIService, ISpeakerLookupService, PublicSessionDetail, SessionDTO, SpeakerInfo | +| 10 | `PublicSpeakerListCardGridTests` | MMCA.ADC.Conference.UI.Tests | 10 | BunitTestBase, EventInfo, IEventLookupService, InfiniteScrollSentinel, ISpeakerUIService, ListPageQueryStateService, ListPageStateService, PublicSpeakerList, Speaker, SpeakerDTO | | 10 | `PublicSpeakerListEventFilterTests` | MMCA.ADC.Conference.UI.Tests | 9 | BunitTestBase, EventInfo, IEventLookupService, ISpeakerUIService, ListPageQueryStateService, ListPageStateService, PublicSpeakerList, RoleNames, TestPrincipal | | 10 | `PublicSponsorListTests` | MMCA.ADC.Conference.UI.Tests | 7 | BunitTestBase, EventInfo, IEventLookupService, ISponsorUIService, PublicSponsorList, SponsorDTO, SponsorTier | | 10 | `SessionDetailRoomCacheTests` | MMCA.ADC.Conference.UI.Tests | 16 | BunitTestBase, CategoryItemInfo, EventInfo, ICategoryItemLookupService, IEventLookupService, IRoomUIService, ISessionCategoryItemUIService, ISessionSpeakerUIService, ISessionUIService, ISpeakerLookupService, Room, RoomDTO, SessionDetail, SessionDTO, SpeakerInfo, TestPrincipal | @@ -3191,36 +3159,19 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 10 | `PointsControllerTests` | MMCA.ADC.Engagement.API.Tests | 21 | AuthorizationPolicies, ControllerMocks, EngagementFeatures, EngagementPermissions, Error, GetLeaderboardQuery, GetMyPointsQuery, GetPointsOverviewQuery, HasPermissionAttribute, ICommandHandler, IQueryHandler, LeaderboardEntryDTO, MyPoints, MyPointsDTO, PointsActivityTotalDTO, PointsActivityType, PointsController, PointsEntryDTO, PointsOverviewDTO, Result …(+1) | | 10 | `SessionQuestionsControllerTests` | MMCA.ADC.Engagement.API.Tests | 17 | ControllerMocks, Error, GetModerationQueueQuery, GetSessionQuestionsQuery, ICommandHandler, ICurrentUserService, IdempotentAttribute, IQueryHandler, ModerateQuestionCommand, ModerationAction, QuestionStatus, Result, SessionQuestionDTO, SessionQuestionsController, SubmitQuestionCommand, SubmitQuestionRequest, ToggleUpvoteCommand | | 10 | `AttendeeCheckedInPointsHandler` | MMCA.ADC.Engagement.Application | 6 | AttendeeCheckedIn, CheckInScopeNames, IIntegrationEventHandler, IPointsAwarder, PointsActivityType, PointsSubjectKeys | -| 10 | `CastVoteHandler` | MMCA.ADC.Engagement.Application | 10 | CastVoteCommand, Error, ICommandHandler, IRepository, IUnitOfWork, LivePoll, LivePollResultsBuilder, LivePollResultsDTO, LivePollVote, Result | +| 10 | `CastVoteHandler` | MMCA.ADC.Engagement.Application | 10 | CastVoteCommand, Error, ICommandHandler, IEntityReader, IUnitOfWork, LivePoll, LivePollResultsBuilder, LivePollResultsDTO, LivePollVote, Result | | 10 | `CreateLivePollHandler` | MMCA.ADC.Engagement.Application | 10 | CreateLivePollCommand, Error, ICommandHandler, IEventLiveValidationService, IUnitOfWork, LivePoll, LivePollAuthorization, LivePollDTO, LivePollDTOMapper, Result | | 10 | `GetEventPollsHandler` | MMCA.ADC.Engagement.Application | 7 | GetEventPollsQuery, IQueryHandler, IUnitOfWork, LivePoll, LivePollDTO, LivePollDTOMapper, Result | | 10 | `GetLeaderboardHandler` | MMCA.ADC.Engagement.Application | 10 | GetLeaderboardQuery, IQueryHandler, IUnitOfWork, LeaderboardEntryDTO, LeaderboardOptIn, OptInRow, PointsEntry, PointsRow, PointsSettings, Result | | 10 | `GetOpenPollsHandler` | MMCA.ADC.Engagement.Application | 9 | Error, GetOpenPollsQuery, IQueryHandler, IUnitOfWork, LivePoll, LivePollResultsBuilder, LivePollResultsDTO, LivePollStatus, Result | | 10 | `GetPollResultsHandler` | MMCA.ADC.Engagement.Application | 8 | Error, GetPollResultsQuery, IQueryHandler, IUnitOfWork, LivePoll, LivePollResultsBuilder, LivePollResultsDTO, Result | | 10 | `LivePollNavigationPopulator` | MMCA.ADC.Engagement.Application | 5 | ChildNavigationDescriptor, DeclarativeNavigationPopulator, IUnitOfWork, LivePoll, LivePollOption | +| 10 | `LivePollOptionNavigationPopulator` | MMCA.ADC.Engagement.Application | 5 | DeclarativeNavigationPopulator, FKNavigationDescriptor, IUnitOfWork, LivePoll, LivePollOption | | 10 | `LivePollVoteChangedHandler` | MMCA.ADC.Engagement.Application | 9 | BestEffort, IDomainEventHandler, ILiveChannelPublishQueue, IUnitOfWork, LiveChannelPublishWorkItem, LivePoll, LivePollChannel, LivePollResultsBuilder, LivePollVoteChanged | | 10 | `PointsAwarder` | MMCA.ADC.Engagement.Application | 7 | DuplicateKeyDetection, IPointsAwarder, IUnitOfWork, PointsActivityType, PointsEntry, PointsSettings, Result | -| 10 | `BookmarkCountServiceTests` | MMCA.ADC.Engagement.Application.Tests | 5 | BookmarkCountService, HandlerTestBase, InMemoryQueryableExecutor, UnitOfWork, UserSessionBookmark | -| 10 | `CloseLivePollHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 16 | CloseLivePollCommand, CloseLivePollHandler, Error, ErrorType, HandlerMocks, HandlerTestBase, IEventLiveValidationService, ILiveChannelPublishQueue, LiveChannelPublishWorkItem, LivePoll, LivePollChannel, LivePollStatus, QuestionModerationDefault, Result, SessionLiveInfo, UnitOfWork | -| 10 | `CreateBookmarkHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 12 | BookmarkManagementDomainService, CreateBookmarkHandler, CreateBookmarkRequest, Error, ErrorType, HandlerMocks, HandlerTestBase, ISessionBookmarkValidationService, Result, UnitOfWork, UserSessionBookmark, UserSessionBookmarkDTOMapper | -| 10 | `EventFeedbackSubmittedPointsHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 9 | Error, EventFeedbackSubmitted, EventFeedbackSubmittedPointsHandler, HandlerTestBase, IPointsAwarder, PointsActivityType, RecordingPointsAwarder, Result, TestSupport | -| 10 | `GetBookmarkedSessionIdsHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 5 | GetBookmarkedSessionIdsHandler, GetBookmarkedSessionIdsQuery, HandlerTestBase, UnitOfWork, UserSessionBookmark | -| 10 | `GetModerationQueueHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 16 | Error, ErrorType, GetModerationQueueHandler, GetModerationQueueQuery, HandlerMocks, HandlerTestBase, IEventLiveValidationService, InMemoryQueryableExecutor, QuestionModerationDefault, QuestionStatus, Result, SessionLiveInfo, SessionQuestion, SessionQuestionUpvote, SessionQuestionViewBuilder, UnitOfWork | -| 10 | `GetMyPointsHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 9 | ErrorType, GetMyPointsHandler, GetMyPointsQuery, HandlerTestBase, ICurrentUserService, LeaderboardOptIn, PointsActivityType, PointsEntry, UnitOfWork | -| 10 | `GetPointsOverviewHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 7 | GetPointsOverviewHandler, GetPointsOverviewQuery, HandlerTestBase, PointsActivityType, PointsEntry, PointsEntryDTO, UnitOfWork | -| 10 | `GetSessionQuestionsHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 9 | GetSessionQuestionsHandler, GetSessionQuestionsQuery, HandlerTestBase, InMemoryQueryableExecutor, QuestionStatus, SessionQuestion, SessionQuestionUpvote, SessionQuestionViewBuilder, UnitOfWork | -| 10 | `GetUserBookmarksHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 11 | Error, GetUserBookmarksHandler, GetUserBookmarksQuery, HandlerMocks, HandlerTestBase, IQueryableExecutor, ISessionBookmarkValidationService, Result, UnitOfWork, UserSessionBookmark, UserSessionBookmarkDTOMapper | | 10 | `LivePollDTOMapperTests` | MMCA.ADC.Engagement.Application.Tests | 3 | LivePoll, LivePollDTOMapper, LivePollStatus | | 10 | `LivePollResultsBuilderTests` | MMCA.ADC.Engagement.Application.Tests | 7 | AuditableBaseEntity, InMemoryQueryableExecutor, IReadRepository, IUnitOfWork, LivePoll, LivePollResultsBuilder, LivePollVote | -| 10 | `ModerateQuestionHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 17 | Error, ErrorType, HandlerMocks, HandlerTestBase, IEventLiveValidationService, ILiveChannelPublishQueue, LiveChannelPublishWorkItem, ModerateQuestionCommand, ModerateQuestionHandler, ModerationAction, QuestionModerationDefault, QuestionStatus, Result, SessionLiveInfo, SessionQuestion, SessionQuestionChannel, UnitOfWork | | 10 | `MutableOptions` | MMCA.ADC.Engagement.Application.Tests | 1 | PointsSettings | -| 10 | `OpenLivePollHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 18 | Error, ErrorType, EventLiveInfo, FixedTimeProvider, HandlerMocks, HandlerTestBase, IEventLiveValidationService, ILiveChannelPublishQueue, LiveChannelPublishWorkItem, LivePoll, LivePollChannel, LivePollStatus, OpenLivePollCommand, OpenLivePollHandler, QuestionModerationDefault, Result, SessionLiveInfo, UnitOfWork | -| 10 | `SessionFeedbackSubmittedPointsHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 9 | Error, HandlerTestBase, IPointsAwarder, PointsActivityType, RecordingPointsAwarder, Result, SessionFeedbackSubmitted, SessionFeedbackSubmittedPointsHandler, TestSupport | -| 10 | `SessionQuestionSubmittedPointsHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 14 | DomainEntityState, Error, HandlerTestBase, IPointsAwarder, PointsActivityType, Question, QuestionStatus, RecordingPointsAwarder, Result, SessionQuestion, SessionQuestionChanged, SessionQuestionSubmittedPointsHandler, TestSupport, UnitOfWork | -| 10 | `SetLeaderboardParticipationHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 8 | ErrorType, HandlerMocks, HandlerTestBase, ICurrentUserService, LeaderboardOptIn, SetLeaderboardParticipationHandler, SetLeaderboardParticipationRequest, UnitOfWork | -| 10 | `SubmitQuestionHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 23 | Error, FixedTimeProvider, HandlerMocks, HandlerTestBase, IEventLiveValidationService, ILiveChannelPublishQueue, InMemoryQueryableExecutor, IReadRepository, LiveChannelPublishWorkItem, QuestionModerationDefault, QuestionStatus, Result, SessionLiveInfo, SessionQuestion, SessionQuestionApprovedPayload, SessionQuestionChannel, SessionQuestionInvariants, SessionQuestionPendingCountChangedPayload, SessionQuestionUpvote, SessionQuestionViewBuilder …(+3) | -| 10 | `ToggleUpvoteHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 11 | ErrorType, FixedTimeProvider, HandlerMocks, HandlerTestBase, IRepository, QuestionStatus, SessionQuestion, SessionQuestionUpvote, ToggleUpvoteCommand, ToggleUpvoteHandler, UnitOfWork | -| 10 | `UserDeletedPointsHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 7 | HandlerTestBase, IRepository, LeaderboardOptIn, TestSupport, UnitOfWork, UserDeleted, UserDeletedPointsHandler | | 10 | `CheckIn` | MMCA.ADC.Engagement.Domain | 7 | AttendeeCheckedIn, AuditableAggregateRootEntity, CheckInInvariants, CheckInScope, CheckInScopeNames, IAuditedEntity, Result | | 10 | `BookmarkManagementDomainServiceTests` | MMCA.ADC.Engagement.Domain.Tests | 2 | BookmarkManagementDomainService, UserSessionBookmark | | 10 | `EngagementTestDbContext` | MMCA.ADC.Engagement.Infrastructure.Tests | 12 | LivePoll, LivePollConfiguration, LivePollOption, LivePollOptionConfiguration, LivePollVote, LivePollVoteConfiguration, SessionQuestion, SessionQuestionConfiguration, SessionQuestionUpvote, SessionQuestionUpvoteConfiguration, UserSessionBookmark, UserSessionBookmarkConfiguration | @@ -3231,39 +3182,28 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 10 | `LiveEventListener` | MMCA.ADC.Engagement.UI | 9 | EngagementRoutePaths, IAccessibilityAnnouncer, IBatteryStatusService, ILiveEventUIService, LiveEventService, LivePollChannel, LivePollOpenedPayload, NotificationHubService, Severity | | 10 | `OrganizerAttendance` | MMCA.ADC.Engagement.UI | 8 | AttendanceStatsDTO, CheckInService, ICheckInUIService, ILiveEventUIService, ISessionLookupService, LiveEventService, SessionAttendanceDTO, SessionAttendanceRow | | 10 | `UsersControllerTests` | MMCA.ADC.Identity.API.Tests | 20 | DeleteUserCommand, Email, Error, ExportUserDataQuery, GetUserAvatarQuery, GetUsersQuery, ICommandHandler, ICurrentUserService, IQueryHandler, PagedCollectionResult, PaginationMetadata, RemoveUserAvatarCommand, Result, SetUserAvatarCommand, Subject, UserAvatarDTO, UserDataExportDTO, UserDataExportSubjectDTO, UserListDTO, UsersController | -| 10 | `DependencyInjection` | MMCA.ADC.Identity.Application | 13 | ApplicationSettings, AttendeeQueryService, AuthenticationService, AuthenticationValidators, ClassReference, ClassReference, EngagementUserDataExportSection, IAttendeeQueryService, IAuthenticationService, ISoftDeletedUserValidator, NotificationUserDataExportSection, SoftDeletedUserValidator, User | -| 10 | `AttendeeQueryServiceTests` | MMCA.ADC.Identity.Application.Tests | 5 | AttendeeQueryService, HandlerTestBase, IRepository, UnitOfWork, User | -| 10 | `AuthenticationServiceTests` | MMCA.ADC.Identity.Application.Tests | 19 | AuthenticationResponse, AuthenticationService, AuthenticationValidators, Error, ErrorType, IExternalLoginEmailVerifier, ILoginProtectionService, IPasswordHasher, IRepository, ITokenService, IUnitOfWork, LoginRequest, RefreshTokenRequest, RegisterRequest, Result, ServiceMocks, User, UserRegistered, UserRole | -| 10 | `ChangePasswordHandlerTests` | MMCA.ADC.Identity.Application.Tests | 10 | ChangePasswordCommand, ChangePasswordHandler, ChangePasswordRequest, ErrorType, HandlerTestBase, IPasswordHasher, IRepository, UnitOfWork, User, UserRole | -| 10 | `ChangePreferencesHandlerTests` | MMCA.ADC.Identity.Application.Tests | 9 | ChangePreferencesCommand, ChangePreferencesHandler, ChangePreferencesRequest, ErrorType, HandlerTestBase, IRepository, UnitOfWork, User, UserRole | -| 10 | `DeleteUserHandlerTests` | MMCA.ADC.Identity.Application.Tests | 13 | DeleteUserCommand, DeleteUserHandler, ErrorType, FixedTimeProvider, HandlerTestBase, ICacheService, IFileStorageService, IRepository, Result, SoftDeletedUserCache, UnitOfWork, User, UserRole | -| 10 | `ExportUserDataHandlerTests` | MMCA.ADC.Identity.Application.Tests | 26 | EngagementUserDataExportSection, ErrorType, ExportUserDataHandler, ExportUserDataHandlerBase, ExportUserDataQuery, HandlerTestBase, IRepository, IUserDataExportSection, IUserEngagementExportService, IUserNotificationExportService, NotificationUserDataExportSection, Subject, ThrowingExportSection, UnitOfWork, User, UserDataExportDTO, UserDataExportEngagementSectionDTO, UserDataExportNotificationSectionDTO, UserDataExportSectionDefaults, UserDataExportSectionDTO …(+6) | | 10 | `ExportUserDataRegistrationTests` | MMCA.ADC.Identity.Application.Tests | 10 | ClassReference, ClassReference, EngagementUserDataExportSection, ExportUserDataHandler, ExportUserDataQuery, IQueryHandler, IUserDataExportSection, NotificationUserDataExportSection, Result, UserDataExportDTO | -| 10 | `GetUserPreferencesHandlerTests` | MMCA.ADC.Identity.Application.Tests | 12 | ChangePreferencesCommand, ChangePreferencesHandler, ChangePreferencesRequest, ErrorType, GetUserPreferencesHandler, GetUserPreferencesQuery, HandlerTestBase, IRepository, UnitOfWork, User, UserPreferencesResponse, UserRole | -| 10 | `GetUsersHandlerTests` | MMCA.ADC.Identity.Application.Tests | 10 | Email, GetUsersHandler, GetUsersQuery, HandlerTestBase, IQueryableExecutor, IRepository, UnitOfWork, User, UserListDTO, UserRole | | 10 | `SpeakerLinkedToUserHandlerTests` | MMCA.ADC.Identity.Application.Tests | 8 | Fakes, InMemoryRepository, IUnitOfWork, RecordingUnitOfWork, SpeakerLinkedToUser, SpeakerLinkedToUserHandler, User, UserRole | | 10 | `SpeakerUnlinkedFromUserHandlerTests` | MMCA.ADC.Identity.Application.Tests | 8 | Fakes, InMemoryRepository, IUnitOfWork, RecordingUnitOfWork, SpeakerUnlinkedFromUser, SpeakerUnlinkedFromUserHandler, User, UserRole | | 10 | `DependencyInjection` | MMCA.ADC.Identity.Contracts | 3 | AttendeeQueryService, AttendeeQueryServiceGrpcAdapter, IAttendeeQueryService | -| 10 | `IdentityModuleDbSeeder` | MMCA.ADC.Identity.Infrastructure | 9 | Email, IdentityModuleDbSeederBase, IPasswordHasher, IUnitOfWork, Result, SeedAccount, UnitOfWork, User, UserRole | | 10 | `IdentityEntityConfigurationTests` | MMCA.ADC.Identity.Infrastructure.Tests | 3 | IdentityTestDbContext, User, UserInvariants | -| 10 | `UserNotificationExportServiceTests` | MMCA.ADC.Notification.Application.Tests | 7 | HandlerTestBase, InMemoryQueryableExecutor, IRepository, PushNotification, UnitOfWork, UserNotification, UserNotificationExportService | | 10 | `DependencyInjection` | MMCA.ADC.Notification.Contracts | 5 | ILiveChannelPublisher, IUserNotificationExportService, LiveChannelPublisherGrpcAdapter, UserNotificationExportService, UserNotificationExportServiceGrpcAdapter | | 10 | `UserNotificationExportGrpcServiceTests` | MMCA.ADC.Services.Tests | 4 | FakeServerCallContext, IUserNotificationExportService, UserNotificationExportGrpcService, UserNotificationExportItemDTO | | 10 | `UserNotificationExportServiceGrpcAdapterTests` | MMCA.ADC.Services.Tests | 3 | UserNotificationExportItemDTO, UserNotificationExportService, UserNotificationExportServiceGrpcAdapter | | 10 | `ADCHomePageContent` | MMCA.ADC.UI | 2 | ADCHome, IHomePageContent | -| 10 | `AuthControllerBase` | MMCA.Common.API | 10 | ApiControllerBase, AuthenticationResponse, AuthenticationService, CurrentUserService, IAuthenticationService, ICurrentUserService, LoginRequest, RefreshTokenRequest, RegisterRequest, WebApplicationBuilderExtensions | +| 10 | `NowNextWidgetProvider` | MMCA.ADC.UI | 3 | MainActivity, NowNextSession, NowNextSnapshot | | 10 | `DataExportControllerBase` | MMCA.Common.API | 10 | ApiControllerBase, AuthorizationPolicies, CurrentUserService, Error, ICurrentUserService, IQueryHandler, IUserOwnedRequest, PrivacyFeatures, Result, UserDataExportDTO | | 10 | `DependencyInjection` | MMCA.Common.API | 1 | NotificationsController | +| 10 | `MiddlewarePipelineBuilder` | MMCA.Common.API | 7 | CorrelationIdMiddleware, MiddlewarePipelineStep, MiddlewarePipelineStepNames, SoftDeletedUserMiddleware, TenantResolutionMiddleware, WebApplicationBuilderExtensions, WebApplicationExtensions | | 10 | `OwnerOrAdminFilter` | MMCA.Common.API | 4 | AllowMissingOwnerAttribute, ICurrentUserService, OwnerOrAdminFilterOptions, OwnershipHelper | -| 10 | `WebApplicationExtensions` | MMCA.Common.API | 5 | CorrelationIdMiddleware, SoftDeletedUserMiddleware, SupportedCultures, TenantResolutionMiddleware, WebApplicationBuilderExtensions | -| 10 | `DatabaseInitializationExtensionsTests` | MMCA.Common.API.Tests | 23 | ApplicationSettings, AuditSaveChangesInterceptor, ConnectionStringSettings, DataSource, DataSourceEntrySettings, DataSourceResolver, DataSourcesSettings, DbContextFactory, DomainEventSaveChangesInterceptor, EntityDataSourceRegistry, FixedAssemblyProvider, ICurrentUserService, IDataSourceResolver, IDbContextFactory, IDomainEventDispatcher, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, InitTestWidget, IOutboxSignal, IPhysicalDbContextFactory …(+3) | +| 10 | `WebApplicationExtensions` | MMCA.Common.API | 2 | MiddlewarePipelineBuilder, SupportedCultures | +| 10 | `CorrelationIdMiddlewareTests` | MMCA.Common.API.Tests | 2 | CorrelationIdMiddleware, ICorrelationContext | | 10 | `DevicesControllerTests` | MMCA.Common.API.Tests | 6 | DeviceInstallationRequest, DevicesController, Error, ICurrentUserService, IPushDeviceRegistrar, Result | -| 10 | `FixedAssemblyProvider` | MMCA.Common.API.Tests | 2 | DatabaseInitializationExtensionsTests, IEntityConfigurationAssemblyProvider | | 10 | `NotificationInboxControllerTests` | MMCA.Common.API.Tests | 13 | Error, GetMyNotificationsQuery, GetUnreadNotificationCountQuery, ICommandHandler, ICurrentUserService, InboxController, IQueryHandler, MarkAllNotificationsReadCommand, MarkNotificationReadCommand, PagedCollectionResult, PaginationMetadata, Result, UserNotificationDTO | | 10 | `NotificationsControllerTests` | MMCA.Common.API.Tests | 13 | Error, GetNotificationHistoryQuery, ICommandHandler, ICurrentUserService, IdempotencyHeaders, IQueryHandler, NotificationsController, PagedCollectionResult, PaginationMetadata, PushNotificationDTO, Result, SendPushNotificationCommand, SendPushNotificationRequest | | 10 | `OwnershipHelperTests` | MMCA.Common.API.Tests | 3 | ICurrentUserService, OwnershipHelper, TestOwnerSpecification | | 10 | `SoftDeletedUserMiddlewareTests` | MMCA.Common.API.Tests | 5 | ICacheService, ICurrentUserService, ISoftDeletedUserValidator, SoftDeletedUserCache, SoftDeletedUserMiddleware | -| 10 | `DependencyInjection` | MMCA.Common.Application | 33 | ApplicationSettings, AuthorizationCommandDecorator, AuthorizationQueryDecorator, CachingCommandDecorator, CachingQueryDecorator, ClassReference, CommandRequestValidator, DomainEventDispatcher, EntityQueryPipeline, FeatureGateCommandDecorator, FeatureGateQueryDecorator, IApplicationSettings, ICommandHandler, ICommandWithRequest, IDomainEventDispatcher, IDomainEventHandler, IEntityDTOMapper, IEntityDTOProjector, IEntityQueryPipeline, IEntityRequestMapper …(+13) | +| 10 | `DependencyInjection` | MMCA.Common.Application | 37 | ApplicationSettings, AuthorizationCommandDecorator, AuthorizationQueryDecorator, CachingCommandDecorator, CachingQueryDecorator, ClassReference, CommandRequestValidator, DomainEventDispatcher, EntityQueryPipeline, EventUpcasterRegistry, FeatureGateCommandDecorator, FeatureGateQueryDecorator, IApplicationSettings, ICommandHandler, ICommandWithRequest, IDomainEventDispatcher, IDomainEventHandler, IEntityDTOMapper, IEntityDTOProjector, IEntityQueryPipeline …(+17) | | 10 | `DependencyInjection` | MMCA.Common.Application | 30 | EntityQueryService, GetMyNotificationsHandler, GetMyNotificationsQuery, GetNotificationHistoryHandler, GetNotificationHistoryQuery, GetUnreadNotificationCountHandler, GetUnreadNotificationCountQuery, ICommandHandler, IEntityDTOMapper, IEntityDTOProjector, IEntityQueryService, INavigationPopulator, INotificationRecipientProvider, IQueryHandler, MarkAllNotificationsReadCommand, MarkAllNotificationsReadHandler, MarkNotificationReadCommand, MarkNotificationReadHandler, NullNavigationPopulator, NullNotificationRecipientProvider …(+10) | | 10 | `AuthenticationServiceBaseTests` | MMCA.Common.Application.Tests | 17 | AuthenticationResponse, AuthenticationValidators, Error, ErrorType, FixedTimeProvider, ILoginProtectionService, IPasswordHasher, IRepository, ITokenService, IUnitOfWork, LoginRequest, RefreshTokenRequest, RegisterRequest, Result, ServiceMocks, TestAuthenticationService, TestAuthUser | | 10 | `AuthorizationCommandDecoratorTests` | MMCA.Common.Application.Tests | 9 | AuthorizationCommandDecorator, ErrorType, GuardedCommand, GuardedCommandWithValue, ICommandHandler, ICurrentUserService, IPermissionRegistry, Result, UnguardedCommand | @@ -3276,39 +3216,29 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 10 | `EntityQueryServiceTests` | MMCA.Common.Application.Tests | 17 | BaseLookup, EntityQueryParameters, EntityQueryPipeline, EntityQueryService, FakeEntity, FakeEntityDTO, FakeEntityDTOMapper, IEntityDTOMapper, IEntityQueryPipeline, INavigationMetadataProvider, INavigationPopulator, InMemoryQueryableExecutor, IReadRepository, IUnitOfWork, MappedEntityQueryService, NavigationMetadata, TestableEntityQueryService | | 10 | `ExportUserDataHandlerBaseTests` | MMCA.Common.Application.Tests | 16 | CancellingSection, ErrorType, FakeTimeProvider, HandlerMocks, IReadRepository, IUnitOfWork, IUserDataExportSection, RecordingSection, Result, TestExportUserDataHandler, TestExportUserDataQuery, TestIdentityUser, ThrowingSection, UserDataExportDTO, UserDataExportSectionDefaults, UserDataExportSectionResult | | 10 | `FKNavigationDescriptorTests` | MMCA.Common.Application.Tests | 6 | FKNavigationDescriptor, INavigationDescriptor, IReadRepository, IUnitOfWork, ParentEntity, RelatedEntity | +| 10 | `ForgotPasswordHandlerBaseTests` | MMCA.Common.Application.Tests | 8 | Error, ForgotPasswordRequest, HandlerMocks, PasswordResetSettings, Result, TestForgotPasswordCommand, TestForgotPasswordHandler, TestIdentityUser | | 10 | `GetNotificationHistoryHandlerTests` | MMCA.Common.Application.Tests | 10 | GetNotificationHistoryHandler, GetNotificationHistoryQuery, IQueryableExecutor, IRepository, IUnitOfWork, PagedCollectionResult, PushNotification, PushNotificationDTO, PushNotificationDTOMapper, Result | | 10 | `GetUserPreferencesHandlerBaseTests` | MMCA.Common.Application.Tests | 9 | ErrorType, GetUserPreferencesQuery, HandlerMocks, IReadRepository, IUnitOfWork, Result, TestGetUserPreferencesHandler, TestIdentityUser, UserPreferencesResponse | | 10 | `NotificationDependencyInjectionTests` | MMCA.Common.Application.Tests | 23 | GetMyNotificationsHandler, GetMyNotificationsQuery, GetNotificationHistoryHandler, GetNotificationHistoryQuery, GetUnreadNotificationCountHandler, GetUnreadNotificationCountQuery, ICommandHandler, INavigationPopulator, INotificationRecipientProvider, IQueryHandler, MarkAllNotificationsReadCommand, MarkAllNotificationsReadHandler, MarkNotificationReadCommand, MarkNotificationReadHandler, NullNotificationRecipientProvider, PagedCollectionResult, PushNotification, PushNotificationDTO, PushNotificationDTOMapper, Result …(+3) | | 10 | `PushNotificationDTOProjectorTests` | MMCA.Common.Application.Tests | 6 | IEntityDTOProjector, PushNotification, PushNotificationDTO, PushNotificationDTOMapper, PushNotificationDTOProjector, PushNotificationStatus | +| 10 | `ResetPasswordHandlerBaseTests` | MMCA.Common.Application.Tests | 14 | Email, Error, ErrorType, HandlerMocks, ILoginProtectionService, IPasswordHasher, IPasswordResetTokenService, IRepository, IUnitOfWork, ResetPasswordRequest, Result, TestIdentityUser, TestResetPasswordCommand, TestResetPasswordHandler | | 10 | `SendPushNotificationHandlerTests` | MMCA.Common.Application.Tests | 16 | HandlerMocks, INativePushSender, INotificationRecipientProvider, IPushNotificationSender, IReadRepository, IRepository, IUnitOfWork, PushNotification, PushNotificationDTO, PushNotificationDTOMapper, PushNotificationStatus, Result, SendPushNotificationCommand, SendPushNotificationHandler, SendPushNotificationRequest, UserNotification | -| 10 | `RepositoryFactory` | MMCA.Common.Infrastructure | 10 | AuditableAggregateRootEntity, AuditableBaseEntity, EFReadRepository, EFReadRepositoryDecorator, EFRepository, EFRepositoryDecorator, IApplicationSettings, IReadRepository, IRepository, IRepositoryFactory | -| 10 | `AuditTrailCleanupJobTests` | MMCA.Common.Infrastructure.Tests | 13 | ApplicationDbContext, AuditedThing, AuditTrailCleanupJob, AuditTrailEntry, AuditTrailSettings, AuditTrailTestContext, AuditTrailTestHarness, DataSource, DataSourceKey, FakeTimeProvider, IDbContextFactory, IEntityDataSourceRegistry, SchedulerTestHarness | -| 10 | `AuditTrailReaderTests` | MMCA.Common.Infrastructure.Tests | 12 | ApplicationDbContext, AuditTrailEntry, AuditTrailReader, AuditTrailSettings, AuditTrailTestContext, AuditTrailTestHarness, DataSource, DataSourceKey, FakeTimeProvider, IDataSourceResolver, IDbContextFactory, SchedulerTestHarness | +| 10 | `Extensions` | MMCA.Common.Aspire | 8 | HealthCheckTags, HttpResilienceDefaults, IWarmupTask, OpenIdConnectMetadataWarmupTask, OutboxPollFilterProcessor, WarmupHostedService, WarmupReadinessGate, WarmupReadinessHealthCheck | +| 10 | `GatewayCorrelationExtensions` | MMCA.Common.Aspire | 1 | GatewayCorrelationMiddleware | +| 10 | `GatewayCorrelationMiddlewareTests` | MMCA.Common.Aspire.Tests | 3 | Activity, GatewayCorrelationMiddleware, RecordingHttpResponseFeature | +| 10 | `OutboxPollFilterProcessorTests` | MMCA.Common.Aspire.Tests | 2 | Activity, OutboxPollFilterProcessor | +| 10 | `CapturedState` | MMCA.Common.Infrastructure | 3 | AggregateCapture, IDomainEvent, OutboxMessage | | 10 | `CurrentUserServiceAdditionalTests` | MMCA.Common.Infrastructure.Tests | 2 | CurrentUserService, User | | 10 | `CurrentUserServiceTests` | MMCA.Common.Infrastructure.Tests | 5 | CurrentUserService, ICurrentUserService, NullUserService, RoleOnlyService, User | -| 10 | `DbContextFactoryAdditionalTests` | MMCA.Common.Infrastructure.Tests | 8 | DataSource, DataSourceKey, DbContextFactory, ICurrentUserService, IDataSourceResolver, IEntityDataSourceRegistry, IPhysicalDbContextFactory, MidSaveContextCreatingDbContext | -| 10 | `DbContextFactoryCommitAmbiguityTests` | MMCA.Common.Infrastructure.Tests | 14 | CommitFailingDbContext, DataSource, DataSourceKey, DbContextFactory, ICurrentUserService, IDataSourceResolver, IDomainEvent, IDomainEventDispatcher, IEntityDataSourceRegistry, IPhysicalDbContextFactory, Result, TestAggregate, TestLocalEvent, TransactionCommitAmbiguousException | -| 10 | `DbContextFactorySaveIntegrityTests` | MMCA.Common.Infrastructure.Tests | 12 | DataSource, DataSourceKey, DbContextFactory, ICurrentUserService, IDataSourceResolver, IDomainEvent, IDomainEventDispatcher, IEntityDataSourceRegistry, IntegrityAggregate, IntegrityEvent, IntegrityTestDbContext, IPhysicalDbContextFactory | -| 10 | `DbContextFactoryTenantTests` | MMCA.Common.Infrastructure.Tests | 15 | ApplicationDbContext, DataSource, DataSourceKey, DbContextFactory, ICurrentUserService, IDataSourceResolver, IEntityDataSourceRegistry, IPhysicalDbContextFactory, ITenantContext, MutableTenantContext, PhysicalDataSource, TenancySettings, TenantDataSourceOverrideSettings, TenantEntrySettings, TenantTestContext | -| 10 | `DbContextFactoryTests` | MMCA.Common.Infrastructure.Tests | 8 | ApplicationDbContext, DataSource, DataSourceKey, DbContextFactory, ICurrentUserService, IDataSourceResolver, IEntityDataSourceRegistry, IPhysicalDbContextFactory | -| 10 | `DbContextFactoryTransactionTests` | MMCA.Common.Infrastructure.Tests | 15 | DataSource, DataSourceKey, DbContextFactory, Error, ICurrentUserService, IDataSourceResolver, IDomainEvent, IDomainEventDispatcher, IEntityDataSourceRegistry, IPhysicalDbContextFactory, OutboxMessage, Result, TestAggregate, TestLocalEvent, TransactionTestDbContext | -| 10 | `DependencyInjectionTests` | MMCA.Common.Infrastructure.Tests | 23 | CorrelationContext, CurrentUserService, DistributedCacheService, EntityConfigurationOptions, ICacheService, ICorrelationContext, ICurrentUserService, IDistributedLock, IEmailSender, IEventBus, ILiveChannelPublisher, InProcessDistributedLock, InProcessEventBus, IPasswordHasher, IPushNotificationSender, ITokenService, MemoryCacheService, NullLiveChannelPublisher, NullPushNotificationSender, PasswordHasher …(+3) | -| 10 | `EFRepositoryAdditionalTests` | MMCA.Common.Infrastructure.Tests | 3 | EFRepository, TestDbContext, TestEntity | -| 10 | `EFRepositoryAuditStampTests` | MMCA.Common.Infrastructure.Tests | 5 | EFRepository, ICurrentUserService, PlainDbContext, StampedEntity, StampTestDbContext | -| 10 | `EFRepositoryIntegrationTests` | MMCA.Common.Infrastructure.Tests | 7 | EFReadRepository, EFRepository, FakeTimeProvider, ICurrentUserService, TestChildEntity, TestDbContext, TestEntity | | 10 | `EntityTypeConfigurationTests` | MMCA.Common.Infrastructure.Tests | 2 | SqliteTestDbContext, SqliteTestEntity | -| 10 | `MarkAllNotificationsReadHandlerTrackingTests` | MMCA.Common.Infrastructure.Tests | 10 | EFQueryableExecutor, EFRepository, IUnitOfWork, MarkAllNotificationsReadCommand, MarkAllNotificationsReadHandler, NotificationTestDbContext, PushNotification, Result, SeededIds, UserNotification | -| 10 | `OutboxCleanupServiceTests` | MMCA.Common.Infrastructure.Tests | 14 | ApplicationDbContext, CleanupTestContext, DataSource, DataSourceKey, FakeTimeProvider, IDataSourceResolver, IDbContextFactory, IEntityDataSourceRegistry, InboxMessage, MessageBusSettings, Mocks, OutboxCleanupService, OutboxMessage, OutboxSettings | +| 10 | `OutboxMessageTests` | MMCA.Common.Infrastructure.Tests | 3 | OutboxMessage, TestDomainEvent, TestDomainEventWithData | | 10 | `PushNotificationConfigurationTests` | MMCA.Common.Infrastructure.Tests | 2 | PushNotification, PushNotificationTestDbContext | | 10 | `PushNotificationProjectionTranslationTests` | MMCA.Common.Infrastructure.Tests | 5 | ProjectionTestDbContext, PushNotification, PushNotificationDTOMapper, PushNotificationDTOProjector, PushNotificationStatus | -| 10 | `TestIdentityModuleDbSeeder` | MMCA.Common.Infrastructure.Tests | 8 | Email, Error, IdentityModuleDbSeederBase, IPasswordHasher, IUnitOfWork, Result, SeedAccount, TestSeedUser | -| 10 | `UnitOfWorkAdditionalTests` | MMCA.Common.Infrastructure.Tests | 12 | ApplicationDbContext, DataSource, DataSourceKey, FakeAggregate, FakeEntity, IDataSourceService, IDbContextFactory, IReadRepository, IRepository, IRepositoryFactory, Mocks, UnitOfWork | -| 10 | `UnitOfWorkTests` | MMCA.Common.Infrastructure.Tests | 12 | ApplicationDbContext, DataSource, DataSourceKey, FakeAggregate, FakeEntity, IDataSourceService, IDbContextFactory, IReadRepository, IRepository, IRepositoryFactory, Mocks, UnitOfWork | | 10 | `DecoratorPipelineOrderTests` | MMCA.Common.Testing.Tests | 11 | DecoratorPipelineOrderTests, DecoratorPipelineOrderTestsBase, ICacheService, ICorrelationContext, ICurrentUserService, IPermissionRegistry, IUnitOfWork, PingCommand, PingCommandHandler, PingQuery, Result | -| 10 | `HandlerTestBaseTests` | MMCA.Common.Testing.Tests | 5 | FakeHandler, HandlerTestBase, TestAggregate, TestChildEntity, UnitOfWork | | 10 | `GalleryHostFixture` | MMCA.Common.UI.E2E.Tests | 2 | E2ETestConfiguration, GalleryHost | +| 11 | `ActivitiesControllerTests` | MMCA.ADC.Conference.API.Tests | 20 | ActivitiesController, Activity, ActivityCreateRequest, ActivityDTO, ActivityUpdateRequest, ConferencePermissions, DeleteEntityCommand, Error, GetPublicActivityFilterQuery, HasPermissionAttribute, ICommandHandler, ICurrentUserService, IEntityQueryService, InlineSpecification, IQueryHandler, PagedCollectionResult, Result, RoleNames, Specification, UpdateActivityCommand | | 11 | `ConditionalWriteConventionTests` | MMCA.ADC.Conference.API.Tests | 4 | EventsController, EventTransitionRequest, IConcurrencyAware, SupportsIfMatchAttribute | -| 11 | `EntityExportAuthorizationTests` | MMCA.ADC.Conference.API.Tests | 12 | ConferencePermissions, EventQuestionAnswersController, EventsController, EventSpeakersController, HasPermissionAttribute, SessionCategoryItemsController, SessionQuestionAnswersController, SessionsController, SessionSpeakersController, SpeakerCategoryItemsController, SpeakersController, SponsorsController | +| 11 | `EntityExportAuthorizationTests` | MMCA.ADC.Conference.API.Tests | 13 | ActivitiesController, ConferencePermissions, EventQuestionAnswersController, EventsController, EventSpeakersController, HasPermissionAttribute, SessionCategoryItemsController, SessionQuestionAnswersController, SessionsController, SessionSpeakersController, SpeakerCategoryItemsController, SpeakersController, SponsorsController | | 11 | `EventsControllerTests` | MMCA.ADC.Conference.API.Tests | 30 | BaseLookup, CollectionResult, DeleteEntityCommand, Error, ErrorType, Event, EventCreateRequest, EventDTO, EventsController, EventUpdateRequest, ExportEventCalendarQuery, GetNowNextQuery, ICommandHandler, ICurrentUserService, IEntityQueryService, IQueryHandler, ISpecification, NowNextDTO, PagedCollectionResult, PaginationMetadata …(+10) | | 11 | `SessionCategoryItemsControllerTests` | MMCA.ADC.Conference.API.Tests | 19 | AddSessionCategoryItemCommand, AddSessionCategoryItemRequest, BaseLookup, Error, GetPublicSessionCategoryItemFilterQuery, ICommandHandler, ICurrentUserService, IEntityQueryService, InlineSpecification, IQueryHandler, ISpecification, PagedCollectionResult, RemoveSessionCategoryItemCommand, Result, RoleNames, SessionCategoryItem, SessionCategoryItemDTO, SessionCategoryItemsController, Specification | | 11 | `SessionQuestionAnswersControllerTests` | MMCA.ADC.Conference.API.Tests | 18 | AddSessionQuestionAnswerCommand, AddSessionQuestionAnswerRequest, CollectionResult, Error, ICommandHandler, ICurrentUserService, IEntityQueryService, PagedCollectionResult, PaginationMetadata, RemoveSessionQuestionAnswerCommand, Result, RoleNames, SessionQuestionAnswer, SessionQuestionAnswerDTO, SessionQuestionAnswersController, Specification, UpdateSessionQuestionAnswerCommand, UpdateSessionQuestionAnswerRequest | @@ -3316,171 +3246,440 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 11 | `SessionSpeakersControllerTests` | MMCA.ADC.Conference.API.Tests | 19 | AddSessionSpeakerCommand, AddSessionSpeakerRequest, BaseLookup, Error, GetPublicSessionSpeakerFilterQuery, ICommandHandler, ICurrentUserService, IEntityQueryService, InlineSpecification, IQueryHandler, ISpecification, PagedCollectionResult, RemoveSessionSpeakerCommand, Result, RoleNames, SessionSpeaker, SessionSpeakerDTO, SessionSpeakersController, Specification | | 11 | `SponsorsControllerTests` | MMCA.ADC.Conference.API.Tests | 21 | ConferencePermissions, DeleteEntityCommand, Error, GetPublicSponsorFilterQuery, HasPermissionAttribute, ICommandHandler, ICurrentUserService, IEntityQueryService, InlineSpecification, IQueryHandler, PagedCollectionResult, Result, RoleNames, Specification, Sponsor, SponsorCreateRequest, SponsorDTO, SponsorsController, SponsorTier, SponsorUpdateRequest …(+1) | | 11 | `CreateSessionHandler` | MMCA.ADC.Conference.Application | 12 | Error, Event, ICommandHandler, IEntityRequestMapper, IUnitOfWork, Result, Session, SessionCreateRequest, SessionDTO, SessionDTOMapper, SessionInvariants, SessionRoomScheduling | -| 11 | `DependencyInjection` | MMCA.ADC.Conference.Application | 54 | ApplicationSettings, Category, CategoryItem, CategoryItemDTO, ClassReference, ClassReference, ConferenceCategoryDTO, ConferenceCategoryNavigationPopulator, DeleteEntityCommand, DeleteEntityHandler, DeleteEventHandler, DeleteSessionHandler, EntityQueryService, Event, EventCascadeDeletionDomainService, EventDTO, EventLiveValidationService, EventNavigationPopulator, EventQuestionAnswer, EventQuestionAnswerDTO …(+34) | +| 11 | `DependencyInjection` | MMCA.ADC.Conference.Application | 68 | Activity, ActivityDTO, ActivityNavigationPopulator, ApplicationSettings, Category, CategoryItem, CategoryItemDTO, CategoryItemNavigationPopulator, ClassReference, ClassReference, ConferenceCategoryDTO, ConferenceCategoryNavigationPopulator, DeleteEntityCommand, DeleteEntityHandler, DeleteEventHandler, DeleteSessionHandler, EntityQueryService, Event, EventCascadeDeletionDomainService, EventDTO …(+48) | +| 11 | `GetPublicActivityFilterHandler` | MMCA.ADC.Conference.Application | 8 | Activity, GetPublicActivityFilterQuery, InlineSpecification, IQueryHandler, IUnitOfWork, PublicConferenceVisibility, Result, Specification | | 11 | `GetPublicEventSpeakerFilterHandler` | MMCA.ADC.Conference.Application | 8 | EventSpeaker, GetPublicEventSpeakerFilterQuery, InlineSpecification, IQueryHandler, IUnitOfWork, PublicConferenceVisibility, Result, Specification | +| 11 | `GetPublicRoomFilterHandler` | MMCA.ADC.Conference.Application | 8 | GetPublicRoomFilterQuery, InlineSpecification, IQueryHandler, IUnitOfWork, PublicConferenceVisibility, Result, Room, Specification | | 11 | `GetPublicSessionCategoryItemFilterHandler` | MMCA.ADC.Conference.Application | 8 | GetPublicSessionCategoryItemFilterQuery, InlineSpecification, IQueryHandler, IUnitOfWork, PublicConferenceVisibility, Result, SessionCategoryItem, Specification | | 11 | `GetPublicSessionSpeakerFilterHandler` | MMCA.ADC.Conference.Application | 8 | GetPublicSessionSpeakerFilterQuery, InlineSpecification, IQueryHandler, IUnitOfWork, PublicConferenceVisibility, Result, SessionSpeaker, Specification | | 11 | `GetPublicSpeakerCategoryItemFilterHandler` | MMCA.ADC.Conference.Application | 8 | GetPublicSpeakerCategoryItemFilterQuery, InlineSpecification, IQueryHandler, IUnitOfWork, PublicConferenceVisibility, Result, SpeakerCategoryItem, Specification | | 11 | `GetPublicSpeakerFilterHandler` | MMCA.ADC.Conference.Application | 8 | GetPublicSpeakerFilterQuery, InlineSpecification, IQueryHandler, IUnitOfWork, PublicConferenceVisibility, Result, Speaker, Specification | | 11 | `GetPublicSponsorFilterHandler` | MMCA.ADC.Conference.Application | 8 | GetPublicSponsorFilterQuery, InlineSpecification, IQueryHandler, IUnitOfWork, PublicConferenceVisibility, Result, Specification, Sponsor | -| 11 | `RefreshFromSessionizeHandler` | MMCA.ADC.Conference.Application | 19 | CategorySyncStrategy, Error, Event, ICommandHandler, ICurrentUserService, ISessionizeService, ISessionizeSyncStrategy, IUnitOfWork, QuestionSyncStrategy, RefreshFromSessionizeCommand, RefreshFromSessionizeResultDTO, Result, RoomSyncStrategy, SessionizeResponse, SessionizeSyncContext, SessionizeSyncResult, SessionSyncStrategy, SpeakerSyncStrategy, UnitOfWork | | 11 | `UpdateSessionHandler` | MMCA.ADC.Conference.Application | 10 | Error, Event, ICommandHandler, IUnitOfWork, Result, Session, SessionDTOMapper, SessionRoomScheduling, UpdateSessionCommand, UpdateSessionResult | +| 11 | `ActivityCreateRequestValidatorTests` | MMCA.ADC.Conference.Application.Tests | 3 | ActivityCreateRequest, ActivityCreateRequestValidator, ActivityInvariants | | 11 | `AddSessionCategoryItemCommandValidatorTests` | MMCA.ADC.Conference.Application.Tests | 2 | AddSessionCategoryItemCommand, AddSessionCategoryItemCommandValidator | -| 11 | `AddSessionCategoryItemHandlerTests` | MMCA.ADC.Conference.Application.Tests | 8 | AddSessionCategoryItemCommand, AddSessionCategoryItemHandler, ErrorType, HandlerTestBase, IRepository, Session, SessionCategoryItemDTOMapper, UnitOfWork | | 11 | `AddSessionQuestionAnswerCommandValidatorTests` | MMCA.ADC.Conference.Application.Tests | 2 | AddSessionQuestionAnswerCommand, AddSessionQuestionAnswerCommandValidator | -| 11 | `AddSessionQuestionAnswerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 11 | AddSessionQuestionAnswerCommand, AddSessionQuestionAnswerHandler, ErrorType, Event, HandlerTestBase, ICurrentUserService, IRepository, Question, Session, SessionQuestionAnswerDTOMapper, UnitOfWork | | 11 | `AddSessionSpeakerCommandValidatorTests` | MMCA.ADC.Conference.Application.Tests | 2 | AddSessionSpeakerCommand, AddSessionSpeakerCommandValidator | -| 11 | `AddSessionSpeakerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 8 | AddSessionSpeakerCommand, AddSessionSpeakerHandler, ErrorType, HandlerTestBase, IRepository, Session, SessionSpeakerDTOMapper, UnitOfWork | -| 11 | `CategorySyncStrategyTests` | MMCA.ADC.Conference.Application.Tests | 10 | Category, CategorySyncStrategy, Event, IRepository, IUnitOfWork, SessionizeCategory, SessionizeCategoryItem, SessionizeResponse, SessionizeSyncContext, UnitOfWork | -| 11 | `ConferenceCategoryNavigationPopulatorTests` | MMCA.ADC.Conference.Application.Tests | 6 | Category, ConferenceCategoryNavigationPopulator, HandlerTestBase, INavigationPopulator, NavigationMetadata, UnitOfWork | -| 11 | `CreateEventHandlerTests` | MMCA.ADC.Conference.Application.Tests | 13 | CreateEventHandler, Error, Event, EventCreateRequest, EventDTOMapper, EventQuestionAnswerDTOMapper, EventSpeakerDTOMapper, HandlerTestBase, IEntityRequestMapper, IRepository, Result, RoomDTOMapper, UnitOfWork | -| 11 | `CreateSpeakerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 14 | CreateSpeakerHandler, Email, Error, HandlerTestBase, ICurrentUserService, IEntityRequestMapper, IRepository, Result, Speaker, SpeakerCategoryItemDTOMapper, SpeakerCreateRequest, SpeakerDTOMapper, SpeakerQuestionAnswerDTOMapper, UnitOfWork | -| 11 | `CreateSponsorHandlerTests` | MMCA.ADC.Conference.Application.Tests | 11 | CreateSponsorHandler, Error, HandlerTestBase, IEntityRequestMapper, IRepository, Result, Sponsor, SponsorCreateRequest, SponsorDTOMapper, SponsorTier, UnitOfWork | -| 11 | `DeleteEventHandlerTests` | MMCA.ADC.Conference.Application.Tests | 11 | DeleteEntityCommand, DeleteEventHandler, ErrorType, Event, EventCascadeDeletionDomainService, HandlerTestBase, IRepository, Session, Sponsor, SponsorTier, UnitOfWork | -| 11 | `EventLiveValidationServiceTests` | MMCA.ADC.Conference.Application.Tests | 11 | ErrorType, Event, EventLiveValidationService, FixedTimeProvider, HandlerTestBase, IRepository, QuestionModerationDefault, Session, Sponsor, SponsorTier, UnitOfWork | -| 11 | `EventNavigationPopulatorTests` | MMCA.ADC.Conference.Application.Tests | 6 | Event, EventNavigationPopulator, HandlerTestBase, INavigationPopulator, NavigationMetadata, UnitOfWork | | 11 | `ExportEventCalendarHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | ErrorType, Event, ExportEventCalendarHandler, ExportEventCalendarQuery, IRepository, IUnitOfWork, Session | | 11 | `ExportSessionCalendarHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | ErrorType, Event, ExportSessionCalendarHandler, ExportSessionCalendarQuery, IRepository, IUnitOfWork, Session | | 11 | `GetNowNextHandlerTests` | MMCA.ADC.Conference.Application.Tests | 9 | ErrorType, Event, FixedTimeProvider, GetNowNextHandler, GetNowNextQuery, IRepository, IUnitOfWork, Session, SessionStatuses | -| 11 | `GetPublicSessionFilterHandlerTests` | MMCA.ADC.Conference.Application.Tests | 8 | Event, GetPublicSessionFilterHandler, GetPublicSessionFilterQuery, HandlerTestBase, IReadRepository, Session, SessionStatuses, UnitOfWork | -| 11 | `QuestionSyncStrategyTests` | MMCA.ADC.Conference.Application.Tests | 11 | Event, IRepository, IUnitOfWork, Question, QuestionSyncStrategy, SessionizeQuestion, SessionizeQuestionAnswer, SessionizeResponse, SessionizeSpeaker, SessionizeSyncContext, UnitOfWork | -| 11 | `RemoveSessionCategoryItemHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | ErrorType, HandlerTestBase, IRepository, RemoveSessionCategoryItemCommand, RemoveSessionCategoryItemHandler, Session, UnitOfWork | -| 11 | `RemoveSessionQuestionAnswerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 9 | ErrorType, HandlerTestBase, ICurrentUserService, IRepository, RemoveSessionQuestionAnswerCommand, RemoveSessionQuestionAnswerHandler, RoleNames, Session, UnitOfWork | -| 11 | `RemoveSessionSpeakerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | ErrorType, HandlerTestBase, IRepository, RemoveSessionSpeakerCommand, RemoveSessionSpeakerHandler, Session, UnitOfWork | -| 11 | `RoomSyncStrategyTests` | MMCA.ADC.Conference.Application.Tests | 9 | Event, IReadRepository, IUnitOfWork, Room, RoomSyncStrategy, SessionizeResponse, SessionizeRoom, SessionizeSyncContext, UnitOfWork | | 11 | `SessionCreateRequestValidatorTests` | MMCA.ADC.Conference.Application.Tests | 3 | SessionCreateRequest, SessionCreateRequestValidator, SessionInvariants | | 11 | `SessionDTOMapperTests` | MMCA.ADC.Conference.Application.Tests | 6 | Event, Session, SessionCategoryItemDTOMapper, SessionDTOMapper, SessionQuestionAnswerDTOMapper, SessionSpeakerDTOMapper | -| 11 | `SessionNavigationPopulatorTests` | MMCA.ADC.Conference.Application.Tests | 6 | HandlerTestBase, INavigationPopulator, NavigationMetadata, Session, SessionNavigationPopulator, UnitOfWork | -| 11 | `SessionSyncStrategyTests` | MMCA.ADC.Conference.Application.Tests | 9 | Event, IRepository, IUnitOfWork, Session, SessionizeResponse, SessionizeSession, SessionizeSyncContext, SessionSyncStrategy, UnitOfWork | -| 11 | `SpeakerEntityQueryServiceTests` | MMCA.ADC.Conference.Application.Tests | 16 | EntityQueryParameters, ErrorType, HandlerTestBase, ICurrentUserService, IEntityQueryPipeline, INavigationMetadataProvider, INavigationPopulator, InlineSpecification, IReadRepository, NavigationMetadata, Speaker, SpeakerCategoryItemDTOMapper, SpeakerDTOMapper, SpeakerEntityQueryService, SpeakerQuestionAnswerDTOMapper, UnitOfWork | -| 11 | `SpeakerNavigationPopulatorTests` | MMCA.ADC.Conference.Application.Tests | 6 | HandlerTestBase, INavigationPopulator, NavigationMetadata, Speaker, SpeakerNavigationPopulator, UnitOfWork | -| 11 | `SpeakerSyncStrategyTests` | MMCA.ADC.Conference.Application.Tests | 11 | Event, EventSpeaker, IReadRepository, IRepository, IUnitOfWork, SessionizeResponse, SessionizeSpeaker, SessionizeSyncContext, Speaker, SpeakerSyncStrategy, UnitOfWork | | 11 | `SponsorCreateRequestValidatorTests` | MMCA.ADC.Conference.Application.Tests | 4 | SponsorCreateRequest, SponsorCreateRequestValidator, SponsorInvariants, SponsorTier | -| 11 | `UpdateEventHandlerTests` | MMCA.ADC.Conference.Application.Tests | 13 | ErrorType, Event, EventDTOMapper, EventQuestionAnswerDTOMapper, EventSpeakerDTOMapper, EventUpdateRequest, HandlerTestBase, IRepository, RoomDTOMapper, Session, UnitOfWork, UpdateEventCommand, UpdateEventHandler | -| 11 | `UpdateSessionQuestionAnswerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 10 | ErrorType, Event, HandlerTestBase, ICurrentUserService, IRepository, RoleNames, Session, UnitOfWork, UpdateSessionQuestionAnswerCommand, UpdateSessionQuestionAnswerHandler | -| 11 | `UpdateSpeakerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 13 | Email, ErrorType, HandlerTestBase, ICurrentUserService, IRepository, Speaker, SpeakerCategoryItemDTOMapper, SpeakerDTOMapper, SpeakerQuestionAnswerDTOMapper, SpeakerUpdateRequest, UnitOfWork, UpdateSpeakerCommand, UpdateSpeakerHandler | -| 11 | `UpdateSponsorHandlerTests` | MMCA.ADC.Conference.Application.Tests | 10 | ErrorType, HandlerTestBase, IRepository, Sponsor, SponsorDTOMapper, SponsorTier, SponsorUpdateRequest, UnitOfWork, UpdateSponsorCommand, UpdateSponsorHandler | | 11 | `EventLiveValidationServiceGrpcAdapter` | MMCA.ADC.Conference.Contracts | 10 | Error, EventLiveInfo, EventLiveValidationService, GrpcErrorTrailerParser, IEventLiveValidationService, QuestionModerationDefault, Result, RoomSessionInfo, SessionLiveInfo, SponsorLiveInfo | -| 11 | `EventCascadeDeletionDomainServiceTests` | MMCA.ADC.Conference.Domain.Tests | 4 | Event, EventCascadeDeletionDomainService, Session, SponsorBuilder | +| 11 | `EventCascadeDeletionDomainServiceTests` | MMCA.ADC.Conference.Domain.Tests | 5 | ActivityBuilder, Event, EventCascadeDeletionDomainService, Session, SponsorBuilder | | 11 | `ConferenceEntityConfigurationTests` | MMCA.ADC.Conference.Infrastructure.Tests | 20 | Category, CategoryInvariants, CategoryItem, ConferenceTestDbContext, Event, EventInvariants, EventQuestionAnswer, EventSpeaker, Question, QuestionInvariants, Room, Session, SessionCategoryItem, SessionInvariants, SessionQuestionAnswer, SessionSpeaker, Speaker, SpeakerCategoryItem, SpeakerInvariants, SpeakerQuestionAnswer | | 11 | `EventLiveValidationGrpcService` | MMCA.ADC.Conference.Service | 3 | EventLiveValidationService, IEventLiveValidationService, QuestionModerationDefault | | 11 | `PublicSessionListEventFilterTests` | MMCA.ADC.Conference.UI.Tests | 12 | BunitTestBase, Event, EventDTO, IEventUIService, ISessionUIService, ISpeakerLookupService, ListPageQueryStateService, ListPageStateService, PublicSessionList, RoleNames, SpeakerInfo, TestPrincipal | +| 11 | `PublicSessionListRoomFilterTests` | MMCA.ADC.Conference.UI.Tests | 11 | BunitTestBase, EventDTO, IEventUIService, ISessionUIService, ISpeakerLookupService, ListPageQueryStateService, ListPageStateService, PublicSessionList, Room, RoomDTO, SpeakerInfo | | 11 | `SessionListEventFilterTests` | MMCA.ADC.Conference.UI.Tests | 11 | BunitTestBase, Event, EventDTO, IEventUIService, ISessionUIService, ISpeakerLookupService, ListPageQueryStateService, ListPageStateService, SessionList, SpeakerInfo, TestPrincipal | | 11 | `BookmarksController` | MMCA.ADC.Engagement.API | 19 | ApiControllerBase, AuthorizationPolicies, CreateBookmarkRequest, DeleteEntityCommand, EngagementFeatures, Error, GetBookmarkedSessionIdsQuery, GetUserBookmarksQuery, ICommandHandler, ICurrentUserService, IEntityQueryService, IQueryHandler, OwnerOrAdminFilter, PagedCollectionResult, Result, RoleNames, Route, UserSessionBookmark, UserSessionBookmarkDTO | | 11 | `CheckInDTOMapper` | MMCA.ADC.Engagement.Application | 3 | CheckIn, CheckInDTO, IEntityDTOMapper | -| 11 | `CheckInProcessor` | MMCA.ADC.Engagement.Application | 8 | CheckIn, CheckInResultDTO, CheckInScope, Error, IEventLiveValidationService, IRepository, IUnitOfWork, Result | +| 11 | `CheckInProcessor` | MMCA.ADC.Engagement.Application | 8 | CheckIn, CheckInResultDTO, CheckInScope, Error, IEntityQuerier, IEventLiveValidationService, IUnitOfWork, Result | | 11 | `GetAttendanceStatsHandler` | MMCA.ADC.Engagement.Application | 8 | AttendanceStatsDTO, CheckIn, CheckInScope, GetAttendanceStatsQuery, IQueryHandler, IUnitOfWork, Result, SessionAttendanceDTO | | 11 | `RecordRoomCheckInHandler` | MMCA.ADC.Engagement.Application | 12 | CheckIn, CheckInScope, CheckInSettings, Error, ErrorType, ICommandHandler, ICurrentUserService, IEventLiveValidationService, IUnitOfWork, Result, RoomCheckInRequest, RoomCheckInResultDTO | | 11 | `RecordSponsorVisitHandler` | MMCA.ADC.Engagement.Application | 10 | CheckIn, CheckInScope, Error, ICommandHandler, ICurrentUserService, IEventLiveValidationService, IUnitOfWork, Result, SponsorVisitRequest, SponsorVisitResultDTO | | 11 | `UserEngagementExportService` | MMCA.ADC.Engagement.Application | 12 | CheckIn, IUnitOfWork, IUserEngagementExportService, LeaderboardOptIn, PointsEntry, SessionQuestion, UserEngagementBookmarkExportDTO, UserEngagementCheckInExportDTO, UserEngagementExportDTO, UserEngagementPointsEntryExportDTO, UserEngagementSubmittedQuestionExportDTO, UserSessionBookmark | -| 11 | `AttendeeCheckedInPointsHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 11 | AttendeeCheckedIn, AttendeeCheckedInPointsHandler, CheckInScopeNames, Error, HandlerTestBase, IPointsAwarder, PointsActivityType, RecordingPointsAwarder, Result, SponsorVisit, TestSupport | -| 11 | `CastVoteHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 12 | CastVoteCommand, CastVoteHandler, ErrorType, FixedTimeProvider, HandlerMocks, HandlerTestBase, InMemoryQueryableExecutor, IReadRepository, LivePoll, LivePollResultsBuilder, LivePollVote, UnitOfWork | -| 11 | `CreateLivePollHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 17 | CreateLivePollCommand, CreateLivePollHandler, CreateLivePollRequest, Error, ErrorType, EventLiveInfo, HandlerMocks, HandlerTestBase, IEventLiveValidationService, LivePoll, LivePollDTOMapper, LivePollStatus, Question, QuestionModerationDefault, Result, SessionLiveInfo, UnitOfWork | -| 11 | `GetEventPollsHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 7 | GetEventPollsHandler, GetEventPollsQuery, HandlerTestBase, LivePoll, LivePollDTOMapper, LivePollStatus, UnitOfWork | -| 11 | `GetLeaderboardHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 8 | GetLeaderboardHandler, GetLeaderboardQuery, HandlerTestBase, LeaderboardOptIn, PointsActivityType, PointsEntry, PointsSettings, UnitOfWork | -| 11 | `GetOpenPollsHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 9 | ErrorType, GetOpenPollsHandler, GetOpenPollsQuery, HandlerTestBase, InMemoryQueryableExecutor, LivePoll, LivePollResultsBuilder, LivePollVote, UnitOfWork | | 11 | `HandlerMocks` | MMCA.ADC.Engagement.Application.Tests | 4 | AttendeeBadge, CheckIn, IEventLiveValidationService, IRepository | | 11 | `LivePollVoteChangedHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 12 | DomainEntityState, InMemoryQueryableExecutor, IReadRepository, IUnitOfWork, LivePoll, LivePollChannel, LivePollResultsBuilder, LivePollResultsDTO, LivePollVote, LivePollVoteChanged, LivePollVoteChangedHandler, RecordingQueue | -| 11 | `PointsAwarderTests` | MMCA.ADC.Engagement.Application.Tests | 11 | AwarderMocks, EventFeedback, HandlerTestBase, MutableOptions, PointsActivityType, PointsAwarder, PointsEntry, PointsSettings, PointsSubjectKeys, SessionFeedback, UnitOfWork | | 11 | `CheckInTests` | MMCA.ADC.Engagement.Domain.Tests | 4 | AttendeeCheckedIn, CheckIn, CheckInScope, CheckInScopeNames | | 11 | `CheckInConfiguration` | MMCA.ADC.Engagement.Infrastructure | 2 | CheckIn, EntityTypeConfigurationSQLServer | -| 11 | `ModuleApplicationDbContext` | MMCA.ADC.Engagement.Infrastructure | 13 | ApplicationDbContext, AttendeeBadge, CheckIn, IEntityConfigurationAssemblyProvider, LeaderboardOptIn, LivePoll, LivePollOption, LivePollVote, PhysicalDataSource, PointsEntry, SessionQuestion, SessionQuestionUpvote, UserSessionBookmark | | 11 | `EngagementEntityConfigurationTests` | MMCA.ADC.Engagement.Infrastructure.Tests | 9 | EngagementTestDbContext, LivePoll, LivePollInvariants, LivePollOption, LivePollVote, SessionQuestion, SessionQuestionInvariants, SessionQuestionUpvote, UserSessionBookmark | | 11 | `EngagementUIModule` | MMCA.ADC.Engagement.UI | 6 | EngagementRoutePaths, IUIModule, LiveEventListener, NavItem, NavSection, RoleNames | | 11 | `CheckInScanTests` | MMCA.ADC.Engagement.UI.Tests | 14 | AttendeeSearchPanel, AttendeeSummary, BunitComponentTestBase, CheckInScan, IAttendeeLookupService, IBarcodeScannerService, ICheckInUIService, ILiveEventUIService, ISessionLookupService, ListPageQueryStateService, ListPageStateService, LiveEventContext, SessionInfo, TestPrincipal | | 11 | `LiveEventListenerResilienceTests` | MMCA.ADC.Engagement.UI.Tests | 12 | ApiSettings, BunitComponentTestBase, IAccessibilityAnnouncer, IBatteryStatusService, ILiveEventUIService, ITokenStorageService, LiveEventContext, LiveEventListener, NotificationHubService, NullAccessibilityAnnouncer, Severity, TestPrincipal | | 11 | `OrganizerAttendanceTests` | MMCA.ADC.Engagement.UI.Tests | 10 | AttendanceStatsDTO, BunitComponentTestBase, ICheckInUIService, ILiveEventUIService, ISessionLookupService, LiveEventContext, OrganizerAttendance, SessionAttendanceDTO, SessionInfo, TestPrincipal | | 11 | `RoomCheckInTests` | MMCA.ADC.Engagement.UI.Tests | 8 | BunitComponentTestBase, CheckIn, CheckInErrorCodes, ICheckInUIService, RoomCheckIn, RoomCheckInResultDTO, SelfCheckInOutcome, TestPrincipal | -| 11 | `IdentityModuleSeeder` | MMCA.ADC.Identity.API | 4 | IdentityModuleDbSeeder, IModuleSeeder, IPasswordHasher, IUnitOfWork | -| 11 | `IdentityModuleDbSeederTests` | MMCA.ADC.Identity.Infrastructure.Tests | 6 | IdentityModuleDbSeeder, IPasswordHasher, IRepository, IUnitOfWork, SeederMocks, User | | 11 | `MauiProgram` | MMCA.ADC.UI | 15 | ADCHomePageContent, App, AppActionsInitializer, ConfigurationOAuthUISettings, DeviceUIModule, DirectApiTokenRefresher, IDeepLinkDispatcher, IHomePageContent, IOAuthUISettings, IPublicLinkBuilder, ITokenRefresher, IUIModule, JwtAuthenticationStateProvider, MauiPublicLinkBuilder, UIModuleConfiguration | | 11 | `DependencyInjection` | MMCA.Common.API | 23 | CookieSessionRefresher, CookieTokenReader, CurrencyJsonConverter, CurrentUserTargetingContextAccessor, DbUpdateExceptionHandler, DisabledFeatureHandler, DomainExceptionHandler, EnumerationJsonConverterFactory, ErrorLocalizer, ErrorResources, ErrorResourceSource, GlobalExceptionHandler, ICookieSessionRefresher, IdempotencyFilter, IdempotencySettings, IErrorLocalizer, ModuleControllerFeatureProvider, ModuleLoader, ModulesSettings, OperationCanceledExceptionHandler …(+3) | -| 11 | `UserAccountAuthControllerBase` | MMCA.Common.API | 15 | AuthControllerBase, ChangePasswordHandler, ChangePasswordRequest, ChangePreferencesHandler, ChangePreferencesRequest, CurrentUserService, GetUserPreferencesHandler, GetUserPreferencesQuery, IAuthenticationService, ICommandHandler, ICurrentUserService, IQueryHandler, IUserScopedCommand, Result, UserPreferencesResponse | | 11 | `DependencyInjectionTests` | MMCA.Common.API.Tests | 11 | DbUpdateExceptionHandler, DisabledFeatureHandler, DomainExceptionHandler, GlobalExceptionHandler, IdempotencyFilter, IdempotencySettings, IModule, ModuleLoader, OperationCanceledExceptionHandler, OwnerOrAdminFilter, ValidationExceptionHandler | -| 11 | `OverridingAuthController` | MMCA.Common.API.Tests | 5 | AuthControllerBase, AuthenticationResponse, IAuthenticationService, ICurrentUserService, RegisterRequest | +| 11 | `MiddlewarePipelineBuilderTests` | MMCA.Common.API.Tests | 3 | MiddlewarePipelineBuilder, MiddlewarePipelineStep, MiddlewarePipelineStepNames | | 11 | `OwnerOrAdminFilterTests` | MMCA.Common.API.Tests | 4 | AllowMissingOwnerAttribute, ICurrentUserService, OwnerOrAdminFilter, OwnerOrAdminFilterOptions | -| 11 | `TestAuthController` | MMCA.Common.API.Tests | 3 | AuthControllerBase, IAuthenticationService, ICurrentUserService | | 11 | `TestDataExportController` | MMCA.Common.API.Tests | 6 | DataExportControllerBase, ICurrentUserService, IQueryHandler, Result, TestExportQuery, UserDataExportDTO | -| 11 | `DependencyInjection` | MMCA.Common.Infrastructure | 123 | ApplicationDbContext, ApplicationDbContextEFFactory, AuditSaveChangesInterceptor, AuditTrailCleanupJob, AuditTrailReader, AuditTrailSaveChangesInterceptor, AuditTrailSettings, AzureBlobFileStorageService, AzureNotificationHubDeviceRegistrar, AzureNotificationHubNativePushSender, BrokerEventBus, BrokerMessageBus, CacheKeyNamespace, CacheKeyPrefixOptions, CacheOptions, ClaimBasedUserIdProvider, ClassReference, ConnectionStringSettings, CorrelationContext, CosmosDbContext …(+103) | -| 11 | `CosmosConfigurationPortabilityTests` | MMCA.Common.Infrastructure.Tests | 17 | AuditSaveChangesInterceptor, ConnectionStringSettings, DataSource, DataSourceEntrySettings, DataSourceResolver, DataSourcesSettings, DomainEventSaveChangesInterceptor, EntityDataSourceRegistry, FixedAssemblyProvider, IDataSourceResolver, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, OutboxSignal, PhysicalDbContextFactory, PortablePrincipal, PortableThing | -| 11 | `FixedAssemblyProvider` | MMCA.Common.Infrastructure.Tests | 3 | CosmosConfigurationPortabilityTests, IEntityConfigurationAssemblyProvider, MultiSourceSqliteIntegrationTests | -| 11 | `IdentityModuleDbSeederBaseTests` | MMCA.Common.Infrastructure.Tests | 7 | IPasswordHasher, IRepository, IUnitOfWork, SeedAccount, SeederMocks, TestIdentityModuleDbSeeder, TestSeedUser | -| 11 | `MultiSourceSqliteIntegrationTests` | MMCA.Common.Infrastructure.Tests | 26 | AuditSaveChangesInterceptor, ConnectionStringSettings, DataSource, DataSourceEntrySettings, DataSourceResolver, DataSourceService, DataSourcesSettings, DbContextFactory, DomainEventSaveChangesInterceptor, EntityDataSourceRegistry, FixedAssemblyProvider, IApplicationSettings, ICurrentUserService, IDataSourceResolver, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, MultiSourceCustomer, MultiSourceOrder, MultiSourceTestEvent …(+6) | -| 11 | `RepositoryFactoryTests` | MMCA.Common.Infrastructure.Tests | 11 | EFReadRepository, EFReadRepositoryDecorator, EFRepository, EFRepositoryDecorator, FakeAggregate, FakeEntity, IApplicationSettings, IReadRepository, IRepository, RepositoryFactory, TestDbContext | +| 11 | `InfrastructureHealthChecksTests` | MMCA.Common.Aspire.Tests | 2 | Extensions, HealthCheckTags | +| 11 | `MetricsInstrumentationToggleTests` | MMCA.Common.Aspire.Tests | 1 | Extensions | +| 11 | `TracesSampleRatioTests` | MMCA.Common.Aspire.Tests | 1 | Extensions | +| 11 | `ApplicationDbContext` | MMCA.Common.Infrastructure | 26 | AuditableBaseEntity, AuditSaveChangesInterceptor, AuditTrailEntry, AuditTrailSaveChangesInterceptor, AuditTrailSettings, CrossDataSourceDegradeConvention, DataSource, DataSourceKey, DataSourceModelCacheKeyFactory, DetectChangesScope, DomainEventSaveChangesInterceptor, IAuditableEntity, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, IEntityTypeConfigurationCosmos, IEntityTypeConfigurationSqlite, IEntityTypeConfigurationSQLServer, InboxMessage, ITenantEntity, OutboxMessage …(+6) | +| 11 | `AuditSaveChangesInterceptor` | MMCA.Common.Infrastructure | 2 | ApplicationDbContext, IAuditableEntity | +| 11 | `AuditTrailSaveChangesInterceptor` | MMCA.Common.Infrastructure | 11 | Activity, ApplicationDbContext, AuditTrailEntry, CaptureContext, IAuditedEntity, InboxMessage, OutboxMessage, PendingEntityKey, PiiAttribute, PiiRedactor, ScheduledJobEntry | +| 11 | `DataSourceModelCacheKeyFactory` | MMCA.Common.Infrastructure | 1 | ApplicationDbContext | +| 11 | `DeferredDispatch` | MMCA.Common.Infrastructure | 2 | CapturedState, DomainEventSaveChangesInterceptor | +| 11 | `DomainEventSaveChangesInterceptor` | MMCA.Common.Infrastructure | 11 | AggregateCapture, ApplicationDbContext, CapturedState, DeferredDispatch, IAggregateRoot, IDomainEvent, IDomainEventDispatcher, IIntegrationEvent, IOutboxSignal, OutboxFinalizer, OutboxMessage | +| 11 | `OutboxFinalizer` | MMCA.Common.Infrastructure | 2 | ApplicationDbContext, OutboxMessage | +| 11 | `TenantSaveChangesInterceptor` | MMCA.Common.Infrastructure | 3 | ApplicationDbContext, CrossTenantWriteException, ITenantEntity | +| 11 | `MiddlewarePipelineOrderTestsBase` | MMCA.Common.Testing | 2 | MiddlewarePipelineBuilder, MiddlewarePipelineStepNames | | 11 | `GalleryE2ECollection` | MMCA.Common.UI.E2E.Tests | 2 | GalleryHostFixture, PlaywrightFixture | -| 12 | `CreateSessionHandlerTests` | MMCA.ADC.Conference.Application.Tests | 17 | CreateSessionHandler, Error, ErrorType, Event, HandlerTestBase, IEntityRequestMapper, IRepository, IUnitOfWork, Result, Session, SessionCategoryItemDTOMapper, SessionCreateRequest, SessionDTOMapper, SessionInvariants, SessionQuestionAnswerDTOMapper, SessionSpeakerDTOMapper, UnitOfWork | -| 12 | `GetPublicEventSpeakerFilterHandlerTests` | MMCA.ADC.Conference.Application.Tests | 9 | Event, EventSpeaker, GetPublicEventSpeakerFilterHandler, GetPublicEventSpeakerFilterQuery, HandlerTestBase, IReadRepository, Session, SessionSpeaker, UnitOfWork | -| 12 | `GetPublicSessionCategoryItemFilterHandlerTests` | MMCA.ADC.Conference.Application.Tests | 8 | Event, GetPublicSessionCategoryItemFilterHandler, GetPublicSessionCategoryItemFilterQuery, HandlerTestBase, IReadRepository, Session, SessionCategoryItem, UnitOfWork | -| 12 | `GetPublicSessionSpeakerFilterHandlerTests` | MMCA.ADC.Conference.Application.Tests | 8 | Event, GetPublicSessionSpeakerFilterHandler, GetPublicSessionSpeakerFilterQuery, HandlerTestBase, IReadRepository, Session, SessionSpeaker, UnitOfWork | -| 12 | `GetPublicSpeakerCategoryItemFilterHandlerTests` | MMCA.ADC.Conference.Application.Tests | 9 | Event, GetPublicSpeakerCategoryItemFilterHandler, GetPublicSpeakerCategoryItemFilterQuery, HandlerTestBase, IReadRepository, Session, SessionSpeaker, SpeakerCategoryItem, UnitOfWork | -| 12 | `GetPublicSpeakerFilterHandlerTests` | MMCA.ADC.Conference.Application.Tests | 11 | Event, EventSpeaker, GetPublicSpeakerFilterHandler, GetPublicSpeakerFilterQuery, HandlerTestBase, IReadRepository, Session, SessionSpeaker, SessionStatuses, Speaker, UnitOfWork | -| 12 | `GetPublicSponsorFilterHandlerTests` | MMCA.ADC.Conference.Application.Tests | 8 | Event, GetPublicSponsorFilterHandler, GetPublicSponsorFilterQuery, HandlerTestBase, IReadRepository, Sponsor, SponsorTier, UnitOfWork | -| 12 | `RefreshFromSessionizeHandlerTests` | MMCA.ADC.Conference.Application.Tests | 9 | ErrorType, Event, ICurrentUserService, IRepository, ISessionizeService, IUnitOfWork, RefreshFromSessionizeCommand, RefreshFromSessionizeHandler, SessionizeResponse | -| 12 | `UpdateSessionHandlerTests` | MMCA.ADC.Conference.Application.Tests | 13 | ErrorType, Event, HandlerTestBase, IRepository, Session, SessionCategoryItemDTOMapper, SessionDTOMapper, SessionQuestionAnswerDTOMapper, SessionSpeakerDTOMapper, SessionUpdateRequest, UnitOfWork, UpdateSessionCommand, UpdateSessionHandler | +| 12 | `AdcArchitectureMap` | MMCA.ADC.Architecture.Tests | 17 | ApiControllerBase, ApplicationDbContext, ArchitectureMapBase, BaseEntity, ConferenceModule, EngagementModule, EntityQueryService, Event, EventDTO, IdentityModule, Layer, LayerRef, Result, User, UserDTO, UserSessionBookmark, UserSessionBookmarkDTO | +| 12 | `MiddlewarePipelineOrderTests` | MMCA.ADC.Architecture.Tests | 1 | MiddlewarePipelineOrderTestsBase | | 12 | `DependencyInjection` | MMCA.ADC.Conference.Contracts | 6 | EventLiveValidationService, EventLiveValidationServiceGrpcAdapter, IEventLiveValidationService, ISessionBookmarkValidationService, SessionBookmarkValidationService, SessionBookmarkValidationServiceGrpcAdapter | +| 12 | `ModuleApplicationDbContext` | MMCA.ADC.Conference.Infrastructure | 18 | Activity, ApplicationDbContext, Category, CategoryItem, Event, EventQuestionAnswer, EventSpeaker, IEntityConfigurationAssemblyProvider, PhysicalDataSource, Question, Room, Session, SessionCategoryItem, SessionQuestionAnswer, SessionSpeaker, Speaker, SpeakerCategoryItem, Sponsor | | 12 | `BookmarksControllerTests` | MMCA.ADC.Engagement.API.Tests | 17 | BookmarksController, ControllerMocks, CreateBookmarkRequest, DeleteEntityCommand, Error, GetBookmarkedSessionIdsQuery, GetUserBookmarksQuery, ICommandHandler, ICurrentUserService, IEntityQueryService, IQueryHandler, OwnerOrAdminFilter, PagedCollectionResult, PaginationMetadata, Result, UserSessionBookmark, UserSessionBookmarkDTO | | 12 | `CheckInAttendeeHandler` | MMCA.ADC.Engagement.Application | 11 | AttendeeBadge, BadgePayload, CheckInAttendeeRequest, CheckInProcessor, CheckInResultDTO, Error, ICommandHandler, ICurrentUserService, IEventLiveValidationService, IUnitOfWork, Result | -| 12 | `DependencyInjection` | MMCA.ADC.Engagement.Application | 31 | ApplicationSettings, BookmarkCountService, BookmarkManagementDomainService, ClassReference, ClassReference, DeleteEntityCommand, DeleteEntityHandler, EntityQueryService, IBookmarkCountService, IBookmarkManagementDomainService, ICommandHandler, IEntityQueryService, ILiveChannelPublishQueue, INavigationPopulator, IPointsAwarder, IUserEngagementExportService, LiveChannelPublishQueue, LivePoll, LivePollDTO, LivePollNavigationPopulator …(+11) | +| 12 | `DependencyInjection` | MMCA.ADC.Engagement.Application | 33 | ApplicationSettings, BookmarkCountService, BookmarkManagementDomainService, ClassReference, ClassReference, DeleteEntityCommand, DeleteEntityHandler, EntityQueryService, IBookmarkCountService, IBookmarkManagementDomainService, ICommandHandler, IEntityQueryService, ILiveChannelPublishQueue, INavigationPopulator, IPointsAwarder, IUserEngagementExportService, LiveChannelPublishQueue, LivePoll, LivePollDTO, LivePollNavigationPopulator …(+13) | | 12 | `ManualCheckInHandler` | MMCA.ADC.Engagement.Application | 9 | CheckInProcessor, CheckInResultDTO, Error, ICommandHandler, ICurrentUserService, IEventLiveValidationService, IUnitOfWork, ManualCheckInRequest, Result | -| 12 | `GetAttendanceStatsHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 6 | CheckIn, CheckInScope, GetAttendanceStatsHandler, GetAttendanceStatsQuery, HandlerTestBase, UnitOfWork | -| 12 | `GetOrCreateMyBadgeHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 8 | AttendeeBadge, ErrorType, GetOrCreateMyBadgeCommand, GetOrCreateMyBadgeHandler, HandlerMocks, HandlerTestBase, ICurrentUserService, UnitOfWork | -| 12 | `RecordRoomCheckInHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 17 | AttendeeCheckedIn, CheckIn, CheckInScope, CheckInScopeNames, CheckInSettings, Error, ErrorType, FixedTimeProvider, HandlerMocks, HandlerTestBase, ICurrentUserService, IEventLiveValidationService, RecordRoomCheckInHandler, Result, RoomCheckInRequest, RoomSessionInfo, UnitOfWork | -| 12 | `RecordSponsorVisitHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 16 | AttendeeCheckedIn, CheckIn, CheckInScope, CheckInScopeNames, Error, ErrorType, FixedTimeProvider, HandlerMocks, HandlerTestBase, ICurrentUserService, IEventLiveValidationService, RecordSponsorVisitHandler, Result, SponsorLiveInfo, SponsorVisitRequest, UnitOfWork | | 12 | `UserEngagementExportServiceGrpcAdapter` | MMCA.ADC.Engagement.Contracts | 9 | CheckInScope, IUserEngagementExportService, PointsActivityType, UserEngagementBookmarkExportDTO, UserEngagementCheckInExportDTO, UserEngagementExportDTO, UserEngagementExportService, UserEngagementPointsEntryExportDTO, UserEngagementSubmittedQuestionExportDTO | +| 12 | `ModuleApplicationDbContext` | MMCA.ADC.Engagement.Infrastructure | 13 | ApplicationDbContext, AttendeeBadge, CheckIn, IEntityConfigurationAssemblyProvider, LeaderboardOptIn, LivePoll, LivePollOption, LivePollVote, PhysicalDataSource, PointsEntry, SessionQuestion, SessionQuestionUpvote, UserSessionBookmark | | 12 | `UserEngagementExportGrpcService` | MMCA.ADC.Engagement.Service | 3 | IUserEngagementExportService, LeaderboardOptIn, UserEngagementExportService | | 12 | `DependencyInjection` | MMCA.ADC.Engagement.UI | 33 | AttendeeLookupService, BookmarkService, CheckInService, CurrentEventNotificationScopeProvider, EngagementUIModule, EventFeedbackService, IAttendeeLookupService, IBookmarkUIService, ICheckInUIService, IEventFeedbackUIService, ILiveEventUIService, ILivePollUIService, INotificationScopeProvider, INowNextService, IPointsUIService, IQuestionLookupService, ISessionBookmarkUIService, ISessionFeedbackUIService, ISessionLiveUIService, ISessionLookupService …(+13) | -| 12 | `AuthController` | MMCA.ADC.Identity.API | 18 | AuthenticationResponse, AuthenticationService, ChangePasswordCommand, ChangePasswordRequest, ChangePreferencesCommand, ChangePreferencesRequest, GetUserPreferencesQuery, IAuthenticationService, ICommandHandler, ICurrentUserService, IQueryHandler, LoginRequest, RegisterRequest, Result, Route, UserAccountAuthControllerBase, UserPreferencesResponse, WebApplicationBuilderExtensions | +| 12 | `ModuleApplicationDbContext` | MMCA.ADC.Identity.Infrastructure | 4 | ApplicationDbContext, IEntityConfigurationAssemblyProvider, PhysicalDataSource, User | | 12 | `App` | MMCA.ADC.UI | 1 | MauiProgram | | 12 | `AppDelegate` | MMCA.ADC.UI | 2 | IDeepLinkDispatcher, MauiProgram | | 12 | `MainApplication` | MMCA.ADC.UI | 1 | MauiProgram | -| 12 | `AuthControllerBaseRateLimitTests` | MMCA.Common.API.Tests | 3 | AuthControllerBase, OverridingAuthController, WebApplicationBuilderExtensions | -| 12 | `AuthControllerBaseTests` | MMCA.Common.API.Tests | 9 | AuthenticationResponse, Error, IAuthenticationService, ICurrentUserService, LoginRequest, RefreshTokenRequest, RegisterRequest, Result, TestAuthController | | 12 | `DataExportControllerBaseTests` | MMCA.Common.API.Tests | 14 | AuthorizationPolicies, DataExportControllerBase, Error, ICurrentUserService, IQueryHandler, PrivacyFeatures, Result, StubFeatureManager, Subject, SubjectSnapshot, TestDataExportController, TestExportQuery, UserDataExportDTO, UserDataExportSectionDTO | -| 12 | `TestUserAccountAuthController` | MMCA.Common.API.Tests | 12 | ChangePasswordRequest, ChangePreferencesRequest, GetUserPreferencesQuery, IAuthenticationService, ICommandHandler, ICurrentUserService, IQueryHandler, Result, TestChangePasswordCommand, TestChangePreferencesCommand, UserAccountAuthControllerBase, UserPreferencesResponse | -| 12 | `EntityDataSourceRegistryTests` | MMCA.Common.Infrastructure.Tests | 15 | ConnectionStringSettings, DataSource, DataSourceEntrySettings, DataSourceKey, DataSourceResolver, DataSourcesSettings, EntityDataSourceRegistry, FixedAssemblyProvider, NamespaceConventions, PushNotification, RegistryDuplicate, RegistryInvoice, RegistryOrder, RegistrySqlServerEntity, RegistryUnattributed | -| 12 | `OutboxProcessorExecuteAsyncTests` | MMCA.Common.Infrastructure.Tests | 9 | DataSource, DataSourceKey, DependencyInjection, FakeTimeProvider, IDataSourceResolver, IEntityDataSourceRegistry, IOutboxSignal, OutboxProcessor, OutboxSettings | +| 12 | `CommonArchitectureMap` | MMCA.Common.Architecture.Tests | 10 | ApiControllerBase, ApplicationDbContext, ArchitectureMapBase, BaseEntity, DomainEventDispatcher, Layer, LayerRef, Result, ResultGrpcExtensions, UISharedAssemblyReference | +| 12 | `FrameworkSanityTests` | MMCA.Common.Architecture.Tests | 7 | ApplicationDbContext, ArchitectureAssert, DomainEventDispatcher, IJwksProvider, ILiveChannelPublisher, IMessageBus, ResultGrpcExtensions | +| 12 | `CosmosDbContext` | MMCA.Common.Infrastructure | 5 | ApplicationDbContext, DataSource, IEntityConfigurationAssemblyProvider, OutboxMessage, PhysicalDataSource | +| 12 | `EFRepository` | MMCA.Common.Infrastructure | 9 | ApplicationDbContext, AuditableBaseEntity, EFReadRepository, IAuditableEntity, ICurrentUserService, IRepository, IRowVersioned, IUpdatePropertySetter, UpdatePropertySetterBuilder | +| 12 | `IDbContextFactory` | MMCA.Common.Infrastructure | 3 | ApplicationDbContext, DataSource, DataSourceKey | +| 12 | `IPhysicalDbContextFactory` | MMCA.Common.Infrastructure | 3 | ApplicationDbContext, DataSourceKey, PhysicalDataSource | +| 12 | `SqliteDbContext` | MMCA.Common.Infrastructure | 4 | ApplicationDbContext, DataSource, IEntityConfigurationAssemblyProvider, PhysicalDataSource | +| 12 | `SQLServerDbContext` | MMCA.Common.Infrastructure | 5 | ApplicationDbContext, DataSource, IEntityConfigurationAssemblyProvider, PersistenceSettings, PhysicalDataSource | +| 12 | `AddMultiTenancyTests` | MMCA.Common.Infrastructure.Tests | 11 | ConnectionStringSettings, DataSourceResolver, DataSourcesSettings, ITenantContext, TenancySettings, TenancySettingsValidator, TenantContext, TenantDataSourceOverrideSettings, TenantEntrySettings, TenantResolutionStrategy, TenantSaveChangesInterceptor | +| 12 | `CleanupTestContext` | MMCA.Common.Infrastructure.Tests | 11 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, InboxMessage, IOutboxSignal, NullAssemblyProvider, OutboxMessage, TestPhysicalDataSources | +| 12 | `CommitFailingDbContext` | MMCA.Common.Infrastructure.Tests | 12 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, FailingDatabaseFacade, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, OutboxMessage, TestAggregate, TestPhysicalDataSources | +| 12 | `DegradeTestContext` | MMCA.Common.Infrastructure.Tests | 8 | ApplicationDbContext, DataSourceKey, DegradeCustomer, DegradeOrder, EmptyAssemblyProvider, EmptyAssemblyProvider, IEntityDataSourceRegistry, PhysicalDataSource | +| 12 | `DetectionTestDbContext` | MMCA.Common.Infrastructure.Tests | 10 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, TestPhysicalDataSources, Widget | +| 12 | `ExclusionTestDbContext` | MMCA.Common.Infrastructure.Tests | 8 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, ExclusionAggregate, IEntityDataSourceRegistry, NullAssemblyProvider, TestPhysicalDataSources | +| 12 | `FailingDatabaseFacade` | MMCA.Common.Infrastructure.Tests | 2 | AlwaysRetryExecutionStrategy, CommitFailingDbContext | +| 12 | `FailingSaveInterceptor` | MMCA.Common.Infrastructure.Tests | 1 | OutboxRoutingTestDbContext | +| 12 | `GateTestContext` | MMCA.Common.Infrastructure.Tests | 13 | ApplicationDbContext, AuditSaveChangesInterceptor, DataSource, DataSourceKey, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, GateTestContext, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, PhysicalDataSource, SchedulerSettings | +| 12 | `GateTestContext` | MMCA.Common.Infrastructure.Tests | 14 | ApplicationDbContext, AuditSaveChangesInterceptor, AuditTrailSettings, DataSource, DataSourceKey, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, GateTestContext, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, NullAssemblyProvider, PhysicalDataSource | +| 12 | `InboxTestDbContext` | MMCA.Common.Infrastructure.Tests | 4 | ApplicationDbContext, IEntityConfigurationAssemblyProvider, InboxMessage, TestPhysicalDataSources | +| 12 | `IntegrityTestDbContext` | MMCA.Common.Infrastructure.Tests | 11 | ApplicationDbContext, AuditSaveChangesInterceptor, DataSourceKey, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IntegrityAggregate, IOutboxSignal, NullAssemblyProvider, PhysicalDataSource | +| 12 | `MidSaveContextCreatingDbContext` | MMCA.Common.Infrastructure.Tests | 10 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, IOutboxSignal, ReentrantSaveInterceptor, TestPhysicalDataSources | +| 12 | `NamedSoftDeleteTestDbContext` | MMCA.Common.Infrastructure.Tests | 2 | ApplicationDbContext, ProjectedTestEntity | +| 12 | `OutboxRoutingTestDbContext` | MMCA.Common.Infrastructure.Tests | 10 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, FailingSaveInterceptor, IEntityDataSourceRegistry, NullAssemblyProvider, OutboxMessage, TestAggregate, TestPhysicalDataSources | +| 12 | `OutboxTestDbContext` | MMCA.Common.Infrastructure.Tests | 4 | ApplicationDbContext, IEntityConfigurationAssemblyProvider, OutboxMessage, TestPhysicalDataSources | +| 12 | `QueryShapeTestDbContext` | MMCA.Common.Infrastructure.Tests | 10 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, Product, TestPhysicalDataSources | +| 12 | `ReentrantSaveInterceptor` | MMCA.Common.Infrastructure.Tests | 1 | MidSaveContextCreatingDbContext | +| 12 | `SchedulerTestContext` | MMCA.Common.Infrastructure.Tests | 10 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, ScheduledJobEntry, TestPhysicalDataSources | +| 12 | `SoftDeleteTestDbContext` | MMCA.Common.Infrastructure.Tests | 11 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, SoftDeletableEntity, SoftDeletableTestEntity, TestPhysicalDataSources | +| 12 | `SpecificationTestDbContext` | MMCA.Common.Infrastructure.Tests | 3 | ApplicationDbContext, SpecTestChild, SpecTestEntity | +| 12 | `StampTestDbContext` | MMCA.Common.Infrastructure.Tests | 10 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, StampedEntity, TestPhysicalDataSources | +| 12 | `TenantTestContext` | MMCA.Common.Infrastructure.Tests | 16 | ApplicationDbContext, AuditSaveChangesInterceptor, AuditTrailEntry, AuditTrailSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, NullAssemblyProvider, PlainThing, TenantSaveChangesInterceptor, TenantThing, TestPhysicalDataSources, TrailedTenantThing | +| 12 | `TestApplicationDbContext` | MMCA.Common.Infrastructure.Tests | 10 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, IOutboxSignal, TestEntity, TestPhysicalDataSources | +| 12 | `TestAuditDbContext` | MMCA.Common.Infrastructure.Tests | 10 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, TestAuditEntity, TestPhysicalDataSources | +| 12 | `TestDomainEventDbContext` | MMCA.Common.Infrastructure.Tests | 8 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IEntityDataSourceRegistry, NullAssemblyProvider, TestAggregate, TestPhysicalDataSources | +| 12 | `TestNonOutboxContext` | MMCA.Common.Infrastructure.Tests | 9 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, TestPhysicalDataSources | +| 12 | `TestOutboxContext` | MMCA.Common.Infrastructure.Tests | 10 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, OutboxMessage, TestPhysicalDataSources | +| 12 | `TransactionTestDbContext` | MMCA.Common.Infrastructure.Tests | 11 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, OutboxMessage, TestAggregate, TestPhysicalDataSources | +| 12 | `UniqueIndexTestDbContext` | MMCA.Common.Infrastructure.Tests | 12 | ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, FilteredIndexEntity, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, NullAssemblyProvider, TestPhysicalDataSources, UniqueNamedEntity | +| 12 | `MiddlewarePipelineOrderTests` | MMCA.Common.Testing.Tests | 1 | MiddlewarePipelineOrderTestsBase | | 12 | `GalleryAxeTestBase` | MMCA.Common.UI.E2E.Tests | 4 | E2ETestConfiguration, GalleryE2ECollection, GalleryHostFixture, PlaywrightFixture | -| 13 | `CheckInAttendeeHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 20 | AttendeeBadge, AttendeeCheckedIn, BadgePayload, CheckIn, CheckInAttendeeHandler, CheckInAttendeeRequest, CheckInScope, CheckInScopeNames, Error, ErrorType, EventLiveInfo, FixedTimeProvider, HandlerMocks, HandlerTestBase, ICurrentUserService, IEventLiveValidationService, QuestionModerationDefault, Result, SessionLiveInfo, UnitOfWork | -| 13 | `ManualCheckInHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 17 | AttendeeCheckedIn, CheckIn, CheckInScope, CheckInScopeNames, ErrorType, EventLiveInfo, FixedTimeProvider, HandlerMocks, HandlerTestBase, ICurrentUserService, IEventLiveValidationService, ManualCheckInHandler, ManualCheckInRequest, QuestionModerationDefault, Result, SessionLiveInfo, UnitOfWork | +| 13 | `ConcurrencyConventionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, ConcurrencyConventionTestsBase, IArchitectureMap | +| 13 | `ConstructorDependencyCountTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, ConstructorDependencyCountTestsBase, IArchitectureMap | +| 13 | `ControllerConventionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, ControllerConventionTestsBase, IArchitectureMap | +| 13 | `DataResidencyTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, DataResidencyTestsBase, IArchitectureMap | +| 13 | `DomainPurityTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, DomainPurityTestsBase, IArchitectureMap | +| 13 | `EntityConventionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, EntityConventionTestsBase, IArchitectureMap | +| 13 | `EventConventionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, EventConventionTestsBase, IArchitectureMap | +| 13 | `FormsConventionTests` | MMCA.ADC.Architecture.Tests | 4 | AdcArchitectureMap, ArchitectureMapBase, FormsConventionTestsBase, IArchitectureMap | +| 13 | `FrameworkVersionConsistencyTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, FrameworkVersionConsistencyTestsBase, IArchitectureMap | +| 13 | `HandlerConventionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, HandlerConventionTestsBase, IArchitectureMap | +| 13 | `HandlerResultConventionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, HandlerResultConventionTestsBase, IArchitectureMap | +| 13 | `IdempotencyConventionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, IdempotencyConventionTestsBase | +| 13 | `ImmutabilityTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, ImmutabilityTestsBase | +| 13 | `IntegrationEventContractTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, IntegrationEventContractTestsBase | +| 13 | `LayerDependencyTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, LayerDependencyTestsBase | +| 13 | `LocalizedTextConventionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, LocalizedTextConventionTestsBase | +| 13 | `MicroserviceExtractionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, MicroserviceExtractionTestsBase | +| 13 | `ModuleIsolationTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, ModuleIsolationTestsBase | +| 13 | `NamingConventionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, NamingConventionTestsBase | +| 13 | `PiiConventionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, PiiConventionTestsBase | +| 13 | `RawQueryableConventionTests` | MMCA.ADC.Architecture.Tests | 4 | AdcArchitectureMap, ArchitectureMapBase, IArchitectureMap, RawQueryableConventionTestsBase | +| 13 | `ServiceContractPurityTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, ServiceContractPurityTestsBase | +| 13 | `SharedLayerTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, SharedLayerTestsBase | +| 13 | `SliceCohesionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, SliceCohesionTestsBase | +| 13 | `SpecificationConventionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, SpecificationConventionTestsBase | +| 13 | `StateManagementConventionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, StateManagementConventionTestsBase | +| 13 | `UIArchitectureConventionTests` | MMCA.ADC.Architecture.Tests | 3 | AdcArchitectureMap, IArchitectureMap, UIArchitectureConventionTestsBase | | 13 | `DependencyInjection` | MMCA.ADC.Engagement.Contracts | 6 | BookmarkCountService, BookmarkCountServiceGrpcAdapter, IBookmarkCountService, IUserEngagementExportService, UserEngagementExportService, UserEngagementExportServiceGrpcAdapter | -| 13 | `AuthControllerTests` | MMCA.ADC.Identity.API.Tests | 17 | AuthController, AuthenticationResponse, ChangePasswordCommand, ChangePasswordRequest, ChangePreferencesCommand, Error, ErrorType, GetUserPreferencesQuery, IAuthenticationService, ICommandHandler, ICurrentUserService, IQueryHandler, LoginRequest, RefreshTokenRequest, RegisterRequest, Result, UserPreferencesResponse | | 13 | `UserEngagementExportGrpcServiceTests` | MMCA.ADC.Services.Tests | 8 | CheckInScope, FakeServerCallContext, IUserEngagementExportService, UserEngagementBookmarkExportDTO, UserEngagementCheckInExportDTO, UserEngagementExportDTO, UserEngagementExportGrpcService, UserEngagementSubmittedQuestionExportDTO | | 13 | `UserEngagementExportServiceGrpcAdapterTests` | MMCA.ADC.Services.Tests | 4 | CheckInScope, UserEngagementExportDTO, UserEngagementExportService, UserEngagementExportServiceGrpcAdapter | | 13 | `Program` | MMCA.ADC.UI | 1 | AppDelegate | -| 13 | `UserAccountAuthControllerBaseTests` | MMCA.Common.API.Tests | 15 | AuthenticationResponse, ChangePasswordRequest, ChangePreferencesRequest, Error, GetUserPreferencesQuery, IAuthenticationService, ICommandHandler, ICurrentUserService, IQueryHandler, LoginRequest, Result, TestChangePasswordCommand, TestChangePreferencesCommand, TestUserAccountAuthController, UserPreferencesResponse | +| 13 | `DatabaseInitializationExtensions` | MMCA.Common.API | 11 | ApplicationSettings, DataSource, DataSourceKey, IDataSourceResolver, IDbContextFactory, IEntityDataSourceRegistry, ITenantContext, ModuleLoader, TenancySettings, TenantDataSourceTarget, TenantDataSourceTargets | +| 13 | `AggregateConventionTests` | MMCA.Common.Architecture.Tests | 3 | AggregateConventionTestsBase, CommonArchitectureMap, IArchitectureMap | +| 13 | `CancellationTokenConventionTests` | MMCA.Common.Architecture.Tests | 3 | CancellationTokenConventionTestsBase, CommonArchitectureMap, IArchitectureMap | +| 13 | `DomainPurityTests` | MMCA.Common.Architecture.Tests | 3 | CommonArchitectureMap, DomainPurityTestsBase, IArchitectureMap | +| 13 | `EventScopeFitnessTests` | MMCA.Common.Architecture.Tests | 3 | ArchitectureRules, CommonArchitectureMap, FakeConsumerMap | +| 13 | `EventUpcasterFitnessTests` | MMCA.Common.Architecture.Tests | 9 | ArchitectureRules, CommonArchitectureMap, FixtureBackwardsVersionUpcaster, FixtureCompliantV1ToV2Upcaster, FixtureCompliantV2ToV3Upcaster, FixtureContestedClaimUpcaster, FixtureContestedV1, FixtureRivalClaimUpcaster, UpcasterTestMap | +| 13 | `EventVersioningConventionTests` | MMCA.Common.Architecture.Tests | 3 | CommonArchitectureMap, EventConventionTestsBase, IArchitectureMap | +| 13 | `FakeConsumerMap` | MMCA.Common.Architecture.Tests | 5 | ArchitectureMapBase, BaseIntegrationEvent, EventScopeFitnessTests, Layer, LayerRef | +| 13 | `HandlerResultConventionTests` | MMCA.Common.Architecture.Tests | 3 | CommonArchitectureMap, HandlerResultConventionTestsBase, IArchitectureMap | +| 13 | `IdempotencyConventionTests` | MMCA.Common.Architecture.Tests | 3 | CommonArchitectureMap, IArchitectureMap, IdempotencyConventionTestsBase | +| 13 | `LayerDependencyTests` | MMCA.Common.Architecture.Tests | 3 | CommonArchitectureMap, IArchitectureMap, LayerDependencyTestsBase | +| 13 | `LocalizedTextConventionTests` | MMCA.Common.Architecture.Tests | 3 | CommonArchitectureMap, IArchitectureMap, LocalizedTextConventionTestsBase | +| 13 | `MicroserviceExtractionTests` | MMCA.Common.Architecture.Tests | 3 | CommonArchitectureMap, IArchitectureMap, MicroserviceExtractionTestsBase | +| 13 | `NamespaceCycleTests` | MMCA.Common.Architecture.Tests | 3 | CommonArchitectureMap, IArchitectureMap, NamespaceCycleTestsBase | +| 13 | `PiiConventionTests` | MMCA.Common.Architecture.Tests | 3 | CommonArchitectureMap, IArchitectureMap, PiiConventionTestsBase | +| 13 | `RawQueryableConventionTests` | MMCA.Common.Architecture.Tests | 4 | ArchitectureMapBase, CommonArchitectureMap, IArchitectureMap, RawQueryableConventionTestsBase | +| 13 | `ServiceContractPurityTests` | MMCA.Common.Architecture.Tests | 3 | CommonArchitectureMap, IArchitectureMap, ServiceContractPurityTestsBase | +| 13 | `SliceCohesionTests` | MMCA.Common.Architecture.Tests | 3 | CommonArchitectureMap, IArchitectureMap, SliceCohesionTestsBase | +| 13 | `StateManagementConventionTests` | MMCA.Common.Architecture.Tests | 3 | CommonArchitectureMap, IArchitectureMap, StateManagementConventionTestsBase | +| 13 | `UIArchitectureConventionTests` | MMCA.Common.Architecture.Tests | 3 | CommonArchitectureMap, IArchitectureMap, UIArchitectureConventionTestsBase | +| 13 | `UpcasterTestMap` | MMCA.Common.Architecture.Tests | 4 | ArchitectureMapBase, EventUpcasterFitnessTests, Layer, LayerRef | +| 13 | `ApplicationDbContextEFFactory` | MMCA.Common.Infrastructure | 6 | ApplicationDbContext, CosmosDbContext, DataSource, IDbContextFactory, SqliteDbContext, SQLServerDbContext | +| 13 | `AuditTrailCleanupJob` | MMCA.Common.Infrastructure | 10 | AuditTrailEntry, AuditTrailSettings, DataSource, IDbContextFactory, IEntityDataSourceRegistry, IScheduledJob, ITenantContext, TenancySettings, TenantDataSourceTarget, TenantDataSourceTargets | +| 13 | `AuditTrailReader` | MMCA.Common.Infrastructure | 7 | AuditTrailEntry, AuditTrailEntryDTO, AuditTrailSettings, DataSourceKey, IAuditTrailReader, IDataSourceResolver, IDbContextFactory | +| 13 | `BrokerEventBus` | MMCA.Common.Infrastructure | 7 | IDataSourceResolver, IDbContextFactory, IEventBus, IIntegrationEvent, IOutboxSignal, OutboxMessage, OutboxSettings | +| 13 | `DbContextFactory` | MMCA.Common.Infrastructure | 18 | ApplicationDbContext, CosmosDbContext, DataSource, DataSourceKey, DomainEventSaveChangesInterceptor, ICurrentUserService, IDataSourceResolver, IDbContextFactory, IdentityInsertGroup, IEntityDataSourceRegistry, IPhysicalDbContextFactory, ITenantContext, PhysicalDataSource, Result, SQLServerDbContext, TenancySettings, TenancySettingsValidator, TransactionCommitAmbiguousException | +| 13 | `DefaultCosmosDbContextFactory` | MMCA.Common.Infrastructure | 5 | CosmosDbContext, DataSource, DataSourceKey, IDbContextFactory, IPhysicalDbContextFactory | +| 13 | `DefaultSqliteDbContextFactory` | MMCA.Common.Infrastructure | 5 | DataSource, DataSourceKey, IDbContextFactory, IPhysicalDbContextFactory, SqliteDbContext | +| 13 | `DefaultSqlServerDbContextFactory` | MMCA.Common.Infrastructure | 5 | DataSource, DataSourceKey, IDbContextFactory, IPhysicalDbContextFactory, SQLServerDbContext | +| 13 | `DesignTimeDbContextHelper` | MMCA.Common.Infrastructure | 22 | AuditSaveChangesInterceptor, AuditTrailSaveChangesInterceptor, AuditTrailSettings, DataSource, DataSourceKey, DataSourceResolver, DataSourcesSettings, DesignTimeDbContextOptions, DomainEventSaveChangesInterceptor, EntityDataSourceRegistry, ExplicitAssemblyProvider, IDataSourceResolver, IDomainEventDispatcher, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, IOutboxSignal, NullDomainEventDispatcher, OutboxSignal, SchedulerSettings, SQLServerDbContext …(+2) | +| 13 | `EfInboxStore` | MMCA.Common.Infrastructure | 6 | ApplicationDbContext, IDataSourceResolver, IDbContextFactory, IInboxStore, InboxMessage, OutboxSettings | +| 13 | `InProcessEventBus` | MMCA.Common.Infrastructure | 8 | IDataSourceResolver, IDbContextFactory, IDomainEventDispatcher, IEventBus, IIntegrationEvent, OutboxFinalizer, OutboxMessage, OutboxSettings | +| 13 | `OutboxCleanupService` | MMCA.Common.Infrastructure | 13 | DataSource, DataSourceKey, IDataSourceResolver, IDbContextFactory, IEntityDataSourceRegistry, InboxMessage, ITenantContext, MessageBusSettings, OutboxMessage, OutboxSettings, TenancySettings, TenantDataSourceTarget, TenantDataSourceTargets | +| 13 | `OutboxProcessor` | MMCA.Common.Infrastructure | 22 | Activity, ApplicationDbContext, BrokerMetrics, BrokerResilienceDefaults, DataSource, DataSourceKey, Event, IDataSourceResolver, IDbContextFactory, IDomainEventDispatcher, IEntityDataSourceRegistry, IIntegrationEvent, IMessageBus, IOutboxSignal, ITenantContext, OutboxCycleResult, OutboxMessage, OutboxMetrics, OutboxSettings, TenancySettings …(+2) | +| 13 | `PhysicalDbContextFactory` | MMCA.Common.Infrastructure | 10 | ApplicationDbContext, CosmosDbContext, DataSource, DataSourceKey, IDataSourceResolver, IEntityConfigurationAssemblyProvider, IPhysicalDbContextFactory, PhysicalDataSource, SqliteDbContext, SQLServerDbContext | +| 13 | `RepositoryFactory` | MMCA.Common.Infrastructure | 10 | AuditableAggregateRootEntity, AuditableBaseEntity, EFReadRepository, EFReadRepositoryDecorator, EFRepository, EFRepositoryDecorator, IApplicationSettings, IReadRepository, IRepository, IRepositoryFactory | +| 13 | `ScheduledJobRunner` | MMCA.Common.Infrastructure | 9 | ApplicationDbContext, DataSourceKey, IDataSourceResolver, IDbContextFactory, IScheduledJob, JobClaim, ScheduledJobEntry, SchedulerMetrics, SchedulerSettings | +| 13 | `UnitOfWork` | MMCA.Common.Infrastructure | 8 | AuditableAggregateRootEntity, AuditableBaseEntity, IDataSourceService, IDbContextFactory, IReadRepository, IRepository, IRepositoryFactory, IUnitOfWork | +| 13 | `ApplicationDbContextTenantFilterTests` | MMCA.Common.Infrastructure.Tests | 6 | ApplicationDbContext, EFReadRepository, PlainThing, TenantDetail, TenantTestContext, TenantThing | +| 13 | `ApplicationDbContextTests` | MMCA.Common.Infrastructure.Tests | 4 | ApplicationDbContext, DataSource, TestApplicationDbContext, TestEntity | +| 13 | `AuditSaveChangesInterceptorTests` | MMCA.Common.Infrastructure.Tests | 4 | AuditSaveChangesInterceptor, FakeTimeProvider, TestAuditDbContext, TestAuditEntity | +| 13 | `AuditTrailModelGateTests` | MMCA.Common.Infrastructure.Tests | 3 | AuditTrailEntry, DataSourceKey, GateTestContext | +| 13 | `AuditTrailTestContext` | MMCA.Common.Infrastructure.Tests | 17 | ApplicationDbContext, AuditedThing, AuditSaveChangesInterceptor, AuditTrailEntry, AuditTrailSaveChangesInterceptor, CompositeKeyThing, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, FailingSaveInterceptor, FailingSaveInterceptor, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, NullAssemblyProvider, PlainThing, TestPhysicalDataSources | +| 13 | `CrossDataSourceDegradeConventionTests` | MMCA.Common.Infrastructure.Tests | 14 | AuditSaveChangesInterceptor, DataSource, DataSourceKey, DataSourceModelCacheKeyFactory, DegradeCustomer, DegradeOrder, DegradeTestContext, DomainEventSaveChangesInterceptor, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, MapRegistry, OutboxSignal, PhysicalDataSource | +| 13 | `DependencyInjectionAdditionalTests` | MMCA.Common.Infrastructure.Tests | 6 | EntityConfigurationOptions, IDataSourceService, IDbContextFactory, IQueryableExecutor, IRepositoryFactory, IUnitOfWork | +| 13 | `DomainEventCaptureExclusionTests` | MMCA.Common.Infrastructure.Tests | 7 | DomainEventSaveChangesInterceptor, ExclusionAggregate, ExclusionEvent, ExclusionTestDbContext, IDomainEvent, IDomainEventDispatcher, IOutboxSignal | +| 13 | `DomainEventSaveChangesInterceptorOutboxRoutingTests` | MMCA.Common.Infrastructure.Tests | 9 | DomainEventSaveChangesInterceptor, IDomainEvent, IDomainEventDispatcher, IOutboxSignal, OutboxMessage, OutboxRoutingTestDbContext, TestAggregate, TestIntegrationEvent, TestLocalEvent | +| 13 | `DomainEventSaveChangesInterceptorTests` | MMCA.Common.Infrastructure.Tests | 7 | DomainEventSaveChangesInterceptor, IDomainEvent, IDomainEventDispatcher, IOutboxSignal, TestAggregate, TestDomainEvent, TestDomainEventDbContext | +| 13 | `EFReadRepositoryGetByIdFilterTests` | MMCA.Common.Infrastructure.Tests | 3 | EFReadRepository, SoftDeletableTestEntity, SoftDeleteTestDbContext | +| 13 | `EFReadRepositoryKeysetPagingTests` | MMCA.Common.Infrastructure.Tests | 8 | BbbSpecification, Category, EFReadRepository, ErrorType, KeysetCursor, KeysetPageRequest, SpecificationTestDbContext, SpecTestEntity | +| 13 | `EFReadRepositoryProjectedFilterTests` | MMCA.Common.Infrastructure.Tests | 3 | EFReadRepository, NamedSoftDeleteTestDbContext, ProjectedTestEntity | +| 13 | `EFReadRepositorySpecificationTests` | MMCA.Common.Infrastructure.Tests | 15 | AllSpecification, BetaSpecification, Category, DeletedByNameSpecification, EFReadRepository, HighRankSpecification, IncludingSoftDeletedSpecification, IncludingSpecification, ISpecification, NoMatchSpecification, SpecificationTestDbContext, SpecTestChild, SpecTestEntity, TopTwoByRankSpecification, TrackedSpecification | +| 13 | `EFRepositoryAdditionalTests` | MMCA.Common.Infrastructure.Tests | 3 | EFRepository, TestDbContext, TestEntity | +| 13 | `EFRepositoryAuditStampTests` | MMCA.Common.Infrastructure.Tests | 5 | EFRepository, ICurrentUserService, PlainDbContext, StampedEntity, StampTestDbContext | +| 13 | `EFRepositoryIntegrationTests` | MMCA.Common.Infrastructure.Tests | 7 | EFReadRepository, EFRepository, FakeTimeProvider, ICurrentUserService, TestChildEntity, TestDbContext, TestEntity | +| 13 | `FailingSaveInterceptor` | MMCA.Common.Infrastructure.Tests | 1 | AuditTrailTestContext | +| 13 | `MarkAllNotificationsReadHandlerTrackingTests` | MMCA.Common.Infrastructure.Tests | 10 | EFQueryableExecutor, EFRepository, IUnitOfWork, MarkAllNotificationsReadCommand, MarkAllNotificationsReadHandler, NotificationTestDbContext, PushNotification, Result, SeededIds, UserNotification | +| 13 | `Mocks` | MMCA.Common.Infrastructure.Tests | 4 | IDataSourceResolver, IDbContextFactory, IDomainEventDispatcher, IOutboxSignal | +| 13 | `QueryParameterizationTests` | MMCA.Common.Infrastructure.Tests | 3 | QueryFieldService, QueryFilterService, QueryShapeTestDbContext | +| 13 | `SaveChangeDetectionTests` | MMCA.Common.Infrastructure.Tests | 2 | DetectionTestDbContext, Widget | +| 13 | `SchedulerModelGateTests` | MMCA.Common.Infrastructure.Tests | 3 | DataSourceKey, GateTestContext, ScheduledJobEntry | +| 13 | `SoftDeleteQueryFilterTests` | MMCA.Common.Infrastructure.Tests | 2 | SoftDeletableEntity, SoftDeleteTestDbContext | +| 13 | `SoftDeleteUniqueIndexConventionTests` | MMCA.Common.Infrastructure.Tests | 3 | FilteredIndexEntity, UniqueIndexTestDbContext, UniqueNamedEntity | +| 13 | `SpecificationEvaluatorTests` | MMCA.Common.Infrastructure.Tests | 11 | BetaSpecification, Category, IncludingSpecification, OrderedSpecification, PagedSpecification, RankDescendingSpecification, SpecificationEvaluator, SpecificationTestDbContext, SpecTestChild, SpecTestEntity, UnorderedQuerySpecification | +| 13 | `SQLServerDbContextTests` | MMCA.Common.Infrastructure.Tests | 11 | AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyAssemblyProvider, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, OutboxSignal, PersistenceSettings, SQLServerDbContext, TestPhysicalDataSources | +| 13 | `TenantSaveChangesInterceptorTests` | MMCA.Common.Infrastructure.Tests | 5 | CrossTenantWriteException, PlainThing, TenantTestContext, TenantThing, TrailedTenantThing | | 13 | `ComponentsPageE2ETests` | MMCA.Common.UI.E2E.Tests | 4 | AxeOptions, GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixture | | 13 | `DarkModeE2ETests` | MMCA.Common.UI.E2E.Tests | 4 | AxeOptions, GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixture | +| 13 | `ForgotPasswordPageE2ETests` | MMCA.Common.UI.E2E.Tests | 5 | AxeOptions, ForgotPasswordPage, GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixture | | 13 | `LoginPageE2ETests` | MMCA.Common.UI.E2E.Tests | 5 | AxeOptions, GalleryAxeTestBase, GalleryHostFixture, LoginPage, PlaywrightFixture | | 13 | `MobileTopRowE2ETests` | MMCA.Common.UI.E2E.Tests | 3 | GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixture | | 13 | `NotificationPagesE2ETests` | MMCA.Common.UI.E2E.Tests | 4 | AxeOptions, GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixture | | 13 | `PseudoLocalizationE2ETests` | MMCA.Common.UI.E2E.Tests | 4 | GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixture, SupportedCultures | | 13 | `RegisterPageE2ETests` | MMCA.Common.UI.E2E.Tests | 5 | AxeOptions, GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixture, RegisterPage | +| 13 | `ResetPasswordPageE2ETests` | MMCA.Common.UI.E2E.Tests | 5 | AxeOptions, GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixture, ResetPasswordPage | | 13 | `StickySidebarE2ETests` | MMCA.Common.UI.E2E.Tests | 3 | GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixture | | 13 | `WebVitalsE2ETests` | MMCA.Common.UI.E2E.Tests | 4 | GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixture, WebVitalsCollector | -| 14 | `ConferenceTestWebApplicationFactory` | MMCA.ADC.Conference.IntegrationTests | 8 | FakeAiScoringService, FakeBookmarkCountService, FakeSessionizeService, IAiScoringService, IBookmarkCountService, ISessionizeService, JwtTokenGenerator, Program | -| 14 | `ConferenceCrossServiceFactory` | MMCA.ADC.CrossService.IntegrationTests | 3 | JwtTokenGenerator, Program, RateLimiterNeutralizer | -| 14 | `EngagementCrossServiceFactory` | MMCA.ADC.CrossService.IntegrationTests | 3 | JwtTokenGenerator, Program, RateLimiterNeutralizer | +| 14 | `RefreshFromSessionizeHandler` | MMCA.ADC.Conference.Application | 19 | CategorySyncStrategy, Error, Event, ICommandHandler, ICurrentUserService, ISessionizeService, ISessionizeSyncStrategy, IUnitOfWork, QuestionSyncStrategy, RefreshFromSessionizeCommand, RefreshFromSessionizeResultDTO, Result, RoomSyncStrategy, SessionizeResponse, SessionizeSyncContext, SessionizeSyncResult, SessionSyncStrategy, SpeakerSyncStrategy, UnitOfWork | +| 14 | `CategorySyncStrategyTests` | MMCA.ADC.Conference.Application.Tests | 10 | Category, CategorySyncStrategy, Event, IRepository, IUnitOfWork, SessionizeCategory, SessionizeCategoryItem, SessionizeResponse, SessionizeSyncContext, UnitOfWork | +| 14 | `QuestionSyncStrategyTests` | MMCA.ADC.Conference.Application.Tests | 11 | Event, IRepository, IUnitOfWork, Question, QuestionSyncStrategy, SessionizeQuestion, SessionizeQuestionAnswer, SessionizeResponse, SessionizeSpeaker, SessionizeSyncContext, UnitOfWork | +| 14 | `RoomSyncStrategyTests` | MMCA.ADC.Conference.Application.Tests | 10 | Event, EventInvariants, IReadRepository, IUnitOfWork, Room, RoomSyncStrategy, SessionizeResponse, SessionizeRoom, SessionizeSyncContext, UnitOfWork | +| 14 | `SessionSyncStrategyTests` | MMCA.ADC.Conference.Application.Tests | 9 | Event, IRepository, IUnitOfWork, Session, SessionizeResponse, SessionizeSession, SessionizeSyncContext, SessionSyncStrategy, UnitOfWork | +| 14 | `SpeakerSyncStrategyTests` | MMCA.ADC.Conference.Application.Tests | 11 | Event, EventSpeaker, IReadRepository, IRepository, IUnitOfWork, SessionizeResponse, SessionizeSpeaker, SessionizeSyncContext, Speaker, SpeakerSyncStrategy, UnitOfWork | +| 14 | `ConferenceTestWebApplicationFactory` | MMCA.ADC.Conference.IntegrationTests | 9 | FakeAiScoringService, FakeBookmarkCountService, FakeSessionizeService, IAiScoringService, IBookmarkCountService, ISessionizeService, JwtTokenGenerator, Program, WebApplicationBuilderExtensions | +| 14 | `ConferenceCrossServiceFactory` | MMCA.ADC.CrossService.IntegrationTests | 4 | JwtTokenGenerator, Program, RateLimiterNeutralizer, WebApplicationBuilderExtensions | +| 14 | `EngagementCrossServiceFactory` | MMCA.ADC.CrossService.IntegrationTests | 4 | JwtTokenGenerator, Program, RateLimiterNeutralizer, WebApplicationBuilderExtensions | | 14 | `IdentityCrossServiceFactory` | MMCA.ADC.CrossService.IntegrationTests | 2 | Program, RateLimiterNeutralizer | -| 14 | `EngagementTestWebApplicationFactory` | MMCA.ADC.Engagement.IntegrationTests | 8 | FakeEventLiveValidationService, FakeSessionBookmarkValidationService, IEventLiveValidationService, ILiveChannelPublisher, ISessionBookmarkValidationService, JwtTokenGenerator, NullLiveChannelPublisher, Program | +| 14 | `EngagementTestWebApplicationFactory` | MMCA.ADC.Engagement.IntegrationTests | 9 | FakeEventLiveValidationService, FakeSessionBookmarkValidationService, IEventLiveValidationService, ILiveChannelPublisher, ISessionBookmarkValidationService, JwtTokenGenerator, NullLiveChannelPublisher, Program, WebApplicationBuilderExtensions | | 14 | `GatewayApplicationFactory` | MMCA.ADC.Gateway.Tests | 2 | Program, RecordingHttpForwarder | | 14 | `GracefulShutdownTests` | MMCA.ADC.Gateway.Tests | 2 | GracefulShutdownTestsBase, Program | | 14 | `RouteMapApplicationFactory` | MMCA.ADC.Gateway.Tests | 2 | Program, RecordingHttpForwarder | | 14 | `SecurityHeadersTests` | MMCA.ADC.Gateway.Tests | 3 | ProductionHostApplicationFactory, Program, SecurityHeadersTestsBase | +| 14 | `AuthenticationService` | MMCA.ADC.Identity.Application | 18 | AuthenticationResponse, AuthenticationServiceBase, AuthenticationValidators, Email, Error, IAuthenticationService, IExternalLoginEmailVerifier, ILoginProtectionService, IPasswordHasher, ITokenService, IUnitOfWork, RegisterRequest, Result, TokenService, UnitOfWork, User, UserRegistered, UserRole | +| 14 | `ForgotPasswordHandler` | MMCA.ADC.Identity.Application | 9 | Email, ForgotPasswordCommand, ForgotPasswordHandlerBase, IEmailSender, IPasswordResetTokenService, IUnitOfWork, PasswordResetSettings, UnitOfWork, User | | 14 | `IdentityTestWebApplicationFactory` | MMCA.ADC.Identity.IntegrationTests | 6 | FakeUserEngagementExportService, FakeUserNotificationExportService, IUserEngagementExportService, IUserNotificationExportService, PiiCaptureLoggerProvider, Program | -| 14 | `NotificationTestWebApplicationFactory` | MMCA.ADC.Notification.IntegrationTests | 4 | FakeAttendeeQueryService, IAttendeeQueryService, JwtTokenGenerator, Program | +| 14 | `NotificationTestWebApplicationFactory` | MMCA.ADC.Notification.IntegrationTests | 5 | FakeAttendeeQueryService, IAttendeeQueryService, JwtTokenGenerator, Program, WebApplicationBuilderExtensions | +| 14 | `DatabaseInitializationExtensionsTests` | MMCA.Common.API.Tests | 23 | ApplicationSettings, AuditSaveChangesInterceptor, ConnectionStringSettings, DataSource, DataSourceEntrySettings, DataSourceResolver, DataSourcesSettings, DbContextFactory, DomainEventSaveChangesInterceptor, EntityDataSourceRegistry, FixedAssemblyProvider, ICurrentUserService, IDataSourceResolver, IDbContextFactory, IDomainEventDispatcher, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, InitTestWidget, IOutboxSignal, IPhysicalDbContextFactory …(+3) | +| 14 | `FixedAssemblyProvider` | MMCA.Common.API.Tests | 2 | DatabaseInitializationExtensionsTests, IEntityConfigurationAssemblyProvider | +| 14 | `DependencyInjection` | MMCA.Common.Infrastructure | 127 | ApplicationDbContext, ApplicationDbContextEFFactory, AuditSaveChangesInterceptor, AuditTrailCleanupJob, AuditTrailReader, AuditTrailSaveChangesInterceptor, AuditTrailSettings, AzureBlobFileStorageService, AzureNotificationHubDeviceRegistrar, AzureNotificationHubNativePushSender, BrokerEventBus, BrokerMessageBus, CacheKeyNamespace, CacheKeyPrefixOptions, CacheOptions, ClaimBasedUserIdProvider, ClassReference, ConnectionStringSettings, CorrelationContext, CosmosDbContext …(+107) | +| 14 | `IdentityModuleDbSeederBase` | MMCA.Common.Infrastructure | 9 | AuditableAggregateRootEntity, DbSeeder, Email, IPasswordHasher, IUnitOfWork, PasswordHasher, Result, SeedAccount, UnitOfWork | +| 14 | `AddAuditTrailTests` | MMCA.Common.Infrastructure.Tests | 6 | AuditTrailCleanupJob, AuditTrailReader, AuditTrailSaveChangesInterceptor, AuditTrailSettings, IAuditTrailReader, IScheduledJob | +| 14 | `AddScheduledJobsTests` | MMCA.Common.Infrastructure.Tests | 5 | FirstJob, IScheduledJob, ScheduledJobRunner, SchedulerSettings, SecondJob | +| 14 | `ApplicationDbContextEFFactoryTests` | MMCA.Common.Infrastructure.Tests | 5 | ApplicationDbContextEFFactory, CosmosDbContext, IDbContextFactory, SqliteDbContext, SQLServerDbContext | +| 14 | `AuditTrailSaveChangesInterceptorTests` | MMCA.Common.Infrastructure.Tests | 13 | AuditedThing, AuditTrailEntry, AuditTrailSaveChangesInterceptor, AuditTrailTestContext, AuditTrailTestHarness, CompositeKeyThing, Email, FakeTimeProvider, InboxMessage, OutboxMessage, PiiRedactor, PlainThing, ScheduledJobEntry | +| 14 | `BrokerEventBusTests` | MMCA.Common.Infrastructure.Tests | 19 | ApplicationDbContext, AuditSaveChangesInterceptor, BrokerEventBus, DataSource, DataSourceKey, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDataSourceResolver, IDbContextFactory, IDomainEventDispatcher, IEntityDataSourceRegistry, IIntegrationEvent, IOutboxSignal, Mocks, OutboxMessage, OutboxSettings, TestIntegrationEvent, TestNonOutboxContext, TestOutboxContext | +| 14 | `BrokerMessageBusTests` | MMCA.Common.Infrastructure.Tests | 5 | BrokerMessageBus, IIntegrationEvent, Mocks, OtherIntegrationEvent, TestIntegrationEvent | +| 14 | `CosmosConfigurationPortabilityTests` | MMCA.Common.Infrastructure.Tests | 17 | AuditSaveChangesInterceptor, ConnectionStringSettings, DataSource, DataSourceEntrySettings, DataSourceResolver, DataSourcesSettings, DomainEventSaveChangesInterceptor, EntityDataSourceRegistry, FixedAssemblyProvider, IDataSourceResolver, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, OutboxSignal, PhysicalDbContextFactory, PortablePrincipal, PortableThing | +| 14 | `CronosNextOccurrenceTests` | MMCA.Common.Infrastructure.Tests | 1 | ScheduledJobRunner | +| 14 | `DbContextFactoryAdditionalTests` | MMCA.Common.Infrastructure.Tests | 8 | DataSource, DataSourceKey, DbContextFactory, ICurrentUserService, IDataSourceResolver, IEntityDataSourceRegistry, IPhysicalDbContextFactory, MidSaveContextCreatingDbContext | +| 14 | `DbContextFactoryCommitAmbiguityTests` | MMCA.Common.Infrastructure.Tests | 14 | CommitFailingDbContext, DataSource, DataSourceKey, DbContextFactory, ICurrentUserService, IDataSourceResolver, IDomainEvent, IDomainEventDispatcher, IEntityDataSourceRegistry, IPhysicalDbContextFactory, Result, TestAggregate, TestLocalEvent, TransactionCommitAmbiguousException | +| 14 | `DbContextFactorySaveIntegrityTests` | MMCA.Common.Infrastructure.Tests | 12 | DataSource, DataSourceKey, DbContextFactory, ICurrentUserService, IDataSourceResolver, IDomainEvent, IDomainEventDispatcher, IEntityDataSourceRegistry, IntegrityAggregate, IntegrityEvent, IntegrityTestDbContext, IPhysicalDbContextFactory | +| 14 | `DbContextFactoryTenantTests` | MMCA.Common.Infrastructure.Tests | 15 | ApplicationDbContext, DataSource, DataSourceKey, DbContextFactory, ICurrentUserService, IDataSourceResolver, IEntityDataSourceRegistry, IPhysicalDbContextFactory, ITenantContext, MutableTenantContext, PhysicalDataSource, TenancySettings, TenantDataSourceOverrideSettings, TenantEntrySettings, TenantTestContext | +| 14 | `DbContextFactoryTests` | MMCA.Common.Infrastructure.Tests | 8 | ApplicationDbContext, DataSource, DataSourceKey, DbContextFactory, ICurrentUserService, IDataSourceResolver, IEntityDataSourceRegistry, IPhysicalDbContextFactory | +| 14 | `DbContextFactoryTransactionTests` | MMCA.Common.Infrastructure.Tests | 15 | DataSource, DataSourceKey, DbContextFactory, Error, ICurrentUserService, IDataSourceResolver, IDomainEvent, IDomainEventDispatcher, IEntityDataSourceRegistry, IPhysicalDbContextFactory, OutboxMessage, Result, TestAggregate, TestLocalEvent, TransactionTestDbContext | +| 14 | `DependencyInjectionBrokerMessagingTests` | MMCA.Common.Infrastructure.Tests | 4 | EfInboxStore, IInboxStore, InboxDisabledWarningService, NoOpInboxStore | +| 14 | `DependencyInjectionInfrastructureTests` | MMCA.Common.Infrastructure.Tests | 16 | AuditSaveChangesInterceptor, ConnectionStringSettings, DomainEventSaveChangesInterceptor, EntityConfigurationOptions, IConnectionStringSettings, IDataSourceService, IEntityConfigurationAssemblyProvider, IJwtSettings, IQueryableExecutor, IRepository, IRepositoryFactory, ISmtpSettings, IUnitOfWork, OutboxProcessor, OutboxSettings, SmtpSettings | +| 14 | `DependencyInjectionTests` | MMCA.Common.Infrastructure.Tests | 23 | CorrelationContext, CurrentUserService, DistributedCacheService, EntityConfigurationOptions, ICacheService, ICorrelationContext, ICurrentUserService, IDistributedLock, IEmailSender, IEventBus, ILiveChannelPublisher, InProcessDistributedLock, InProcessEventBus, IPasswordHasher, IPushNotificationSender, ITokenService, MemoryCacheService, NullLiveChannelPublisher, NullPushNotificationSender, PasswordHasher …(+3) | +| 14 | `DesignTimeDbContextHelperTests` | MMCA.Common.Infrastructure.Tests | 8 | ConnectionStringSettings, DataSource, DataSourceEntrySettings, DataSourceKey, DesignAlphaEntity, DesignBetaEntity, DesignTimeDbContextHelper, DesignTimeDbContextOptions | +| 14 | `EfInboxStoreTests` | MMCA.Common.Infrastructure.Tests | 16 | ApplicationDbContext, AuditSaveChangesInterceptor, DataSource, DataSourceKey, DomainEventSaveChangesInterceptor, EfInboxStore, EmptyEntityDataSourceRegistry, IDataSourceResolver, IDbContextFactory, IDomainEventDispatcher, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, InboxMessage, InboxTestDbContext, IOutboxSignal, OutboxSettings | +| 14 | `FixedAssemblyProvider` | MMCA.Common.Infrastructure.Tests | 3 | CosmosConfigurationPortabilityTests, IEntityConfigurationAssemblyProvider, MultiSourceSqliteIntegrationTests | +| 14 | `InProcessEventBusOutboxTests` | MMCA.Common.Infrastructure.Tests | 11 | DataSource, DataSourceKey, IDataSourceResolver, IDbContextFactory, IDomainEvent, IDomainEventDispatcher, InProcessEventBus, OutboxMessage, OutboxSettings, TestIntegrationEvent, TestOutboxContext | +| 14 | `InProcessEventBusTests` | MMCA.Common.Infrastructure.Tests | 10 | DataSource, DataSourceKey, IDataSourceResolver, IDbContextFactory, IDomainEvent, IDomainEventDispatcher, IIntegrationEvent, InProcessEventBus, OutboxSettings, TestNonOutboxContext | +| 14 | `InProcessMessageBusTests` | MMCA.Common.Infrastructure.Tests | 16 | DomainEventDispatcher, IDomainEvent, IDomainEventDispatcher, IDomainEventHandler, IIntegrationEvent, IIntegrationEventHandler, InProcessMessageBus, Mocks, RecordingDomainHandler, RecordingIntegrationHandler, RecordingOriginalHandler, RecordingSuccessorHandler, RetiredTestIntegrationEvent, RetiredToV2Upcaster, TestIntegrationEvent, TestIntegrationEventV2 | +| 14 | `Mocks` | MMCA.Common.Infrastructure.Tests | 6 | IDataSourceResolver, IDataSourceService, IDbContextFactory, IEntityDataSourceRegistry, IRepositoryFactory, OutboxCleanupService | +| 14 | `MultiSourceSqliteIntegrationTests` | MMCA.Common.Infrastructure.Tests | 26 | AuditSaveChangesInterceptor, ConnectionStringSettings, DataSource, DataSourceEntrySettings, DataSourceResolver, DataSourceService, DataSourcesSettings, DbContextFactory, DomainEventSaveChangesInterceptor, EntityDataSourceRegistry, FixedAssemblyProvider, IApplicationSettings, ICurrentUserService, IDataSourceResolver, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, MultiSourceCustomer, MultiSourceOrder, MultiSourceTestEvent …(+6) | +| 14 | `OutboxProcessorTests` | MMCA.Common.Infrastructure.Tests | 23 | AuditSaveChangesInterceptor, BrokerResilienceDefaults, DataSource, DataSourceKey, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, FakeTimeProvider, IDataSourceResolver, IDbContextFactory, IDomainEvent, IDomainEventDispatcher, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, IIntegrationEvent, IMessageBus, IOutboxSignal, OutboxCycleResult, OutboxMessage, OutboxProcessor, OutboxSettings …(+3) | +| 14 | `OutboxProcessorWaitTests` | MMCA.Common.Infrastructure.Tests | 1 | OutboxProcessor | +| 14 | `RepositoryFactoryTests` | MMCA.Common.Infrastructure.Tests | 11 | EFReadRepository, EFReadRepositoryDecorator, EFRepository, EFRepositoryDecorator, FakeAggregate, FakeEntity, IApplicationSettings, IReadRepository, IRepository, RepositoryFactory, TestDbContext | +| 14 | `ScheduledJobRunnerTests` | MMCA.Common.Infrastructure.Tests | 10 | ApplicationDbContext, DataSource, DelegateScheduledJob, FakeTimeProvider, IDataSourceResolver, ScheduledJobEntry, ScheduledJobOverrideSettings, ScheduledJobRunner, SchedulerSettings, SchedulerTestContext | +| 14 | `SchedulerTestHarness` | MMCA.Common.Infrastructure.Tests | 9 | ApplicationDbContext, DataSource, DataSourceKey, FakeTimeProvider, IDataSourceResolver, IDbContextFactory, IScheduledJob, ScheduledJobRunner, SchedulerSettings | +| 14 | `TenantDataSourceTargetTests` | MMCA.Common.Infrastructure.Tests | 14 | DataSource, DataSourceKey, IDataSourceResolver, IEntityDataSourceRegistry, IOutboxSignal, MessageBusSettings, OutboxCleanupService, OutboxProcessor, OutboxSettings, TenancySettings, TenantDataSourceOverrideSettings, TenantDataSourceTarget, TenantDataSourceTargets, TenantEntrySettings | +| 14 | `HandlerTestBase` | MMCA.Common.Testing | 6 | AuditableAggregateRootEntity, AuditableBaseEntity, IReadRepository, IRepository, IUnitOfWork, UnitOfWork | +| 15 | `ActivityNavigationPopulatorTests` | MMCA.ADC.Conference.Application.Tests | 6 | Activity, ActivityNavigationPopulator, HandlerTestBase, INavigationPopulator, NavigationMetadata, UnitOfWork | +| 15 | `AddCategoryItemHandlerTests` | MMCA.ADC.Conference.Application.Tests | 8 | AddCategoryItemCommand, AddCategoryItemHandler, Category, CategoryItemDTOMapper, ErrorType, HandlerTestBase, IRepository, UnitOfWork | +| 15 | `AddEventQuestionAnswerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 10 | AddEventQuestionAnswerCommand, AddEventQuestionAnswerHandler, ErrorType, Event, EventQuestionAnswerDTOMapper, HandlerTestBase, ICurrentUserService, IRepository, Question, UnitOfWork | +| 15 | `AddEventSpeakerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 8 | AddEventSpeakerCommand, AddEventSpeakerHandler, ErrorType, Event, EventSpeakerDTOMapper, HandlerTestBase, IRepository, UnitOfWork | +| 15 | `AddRoomHandlerTests` | MMCA.ADC.Conference.Application.Tests | 12 | AddRoomCommand, AddRoomHandler, ErrorType, Event, EventInvariants, HandlerTestBase, IReadRepository, IRepository, IUnitOfWork, Room, RoomDTOMapper, UnitOfWork | +| 15 | `AddSessionCategoryItemHandlerTests` | MMCA.ADC.Conference.Application.Tests | 8 | AddSessionCategoryItemCommand, AddSessionCategoryItemHandler, ErrorType, HandlerTestBase, IRepository, Session, SessionCategoryItemDTOMapper, UnitOfWork | +| 15 | `AddSessionQuestionAnswerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 11 | AddSessionQuestionAnswerCommand, AddSessionQuestionAnswerHandler, ErrorType, Event, HandlerTestBase, ICurrentUserService, IRepository, Question, Session, SessionQuestionAnswerDTOMapper, UnitOfWork | +| 15 | `AddSessionSpeakerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 8 | AddSessionSpeakerCommand, AddSessionSpeakerHandler, ErrorType, HandlerTestBase, IRepository, Session, SessionSpeakerDTOMapper, UnitOfWork | +| 15 | `AddSpeakerCategoryItemHandlerTests` | MMCA.ADC.Conference.Application.Tests | 8 | AddSpeakerCategoryItemCommand, AddSpeakerCategoryItemHandler, ErrorType, HandlerTestBase, IRepository, Speaker, SpeakerCategoryItemDTOMapper, UnitOfWork | +| 15 | `CategoryItemNavigationPopulatorTests` | MMCA.ADC.Conference.Application.Tests | 6 | CategoryItem, CategoryItemNavigationPopulator, HandlerTestBase, INavigationPopulator, NavigationMetadata, UnitOfWork | +| 15 | `ConferenceCategoryNavigationPopulatorTests` | MMCA.ADC.Conference.Application.Tests | 6 | Category, ConferenceCategoryNavigationPopulator, HandlerTestBase, INavigationPopulator, NavigationMetadata, UnitOfWork | +| 15 | `CreateActivityHandlerTests` | MMCA.ADC.Conference.Application.Tests | 10 | Activity, ActivityCreateRequest, ActivityDTOMapper, CreateActivityHandler, Error, HandlerTestBase, IEntityRequestMapper, IRepository, Result, UnitOfWork | +| 15 | `CreateConferenceCategoryHandlerTests` | MMCA.ADC.Conference.Application.Tests | 11 | Category, CategoryItemDTOMapper, ConferenceCategoryCreateRequest, ConferenceCategoryDTOMapper, CreateConferenceCategoryHandler, Error, HandlerTestBase, IEntityRequestMapper, IRepository, Result, UnitOfWork | +| 15 | `CreateEventHandlerTests` | MMCA.ADC.Conference.Application.Tests | 13 | CreateEventHandler, Error, Event, EventCreateRequest, EventDTOMapper, EventQuestionAnswerDTOMapper, EventSpeakerDTOMapper, HandlerTestBase, IEntityRequestMapper, IRepository, Result, RoomDTOMapper, UnitOfWork | +| 15 | `CreateQuestionHandlerTests` | MMCA.ADC.Conference.Application.Tests | 11 | CreateQuestionHandler, HandlerTestBase, IEntityRequestMapper, IRepository, IUnitOfWork, Question, QuestionCreateRequest, QuestionDTOMapper, QuestionInvariants, Result, UnitOfWork | +| 15 | `CreateSessionHandlerTests` | MMCA.ADC.Conference.Application.Tests | 17 | CreateSessionHandler, Error, ErrorType, Event, HandlerTestBase, IEntityRequestMapper, IRepository, IUnitOfWork, Result, Session, SessionCategoryItemDTOMapper, SessionCreateRequest, SessionDTOMapper, SessionInvariants, SessionQuestionAnswerDTOMapper, SessionSpeakerDTOMapper, UnitOfWork | +| 15 | `CreateSpeakerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 14 | CreateSpeakerHandler, Email, Error, HandlerTestBase, ICurrentUserService, IEntityRequestMapper, IRepository, Result, Speaker, SpeakerCategoryItemDTOMapper, SpeakerCreateRequest, SpeakerDTOMapper, SpeakerQuestionAnswerDTOMapper, UnitOfWork | +| 15 | `CreateSponsorHandlerTests` | MMCA.ADC.Conference.Application.Tests | 11 | CreateSponsorHandler, Error, HandlerTestBase, IEntityRequestMapper, IRepository, Result, Sponsor, SponsorCreateRequest, SponsorDTOMapper, SponsorTier, UnitOfWork | +| 15 | `DeleteEventHandlerTests` | MMCA.ADC.Conference.Application.Tests | 12 | Activity, DeleteEntityCommand, DeleteEventHandler, ErrorType, Event, EventCascadeDeletionDomainService, HandlerTestBase, IRepository, Session, Sponsor, SponsorTier, UnitOfWork | +| 15 | `DeleteSessionHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | DeleteEntityCommand, DeleteSessionHandler, ErrorType, HandlerTestBase, IRepository, Session, UnitOfWork | +| 15 | `EventLiveValidationServiceTests` | MMCA.ADC.Conference.Application.Tests | 11 | ErrorType, Event, EventLiveValidationService, FixedTimeProvider, HandlerTestBase, IRepository, QuestionModerationDefault, Session, Sponsor, SponsorTier, UnitOfWork | +| 15 | `EventNavigationPopulatorTests` | MMCA.ADC.Conference.Application.Tests | 6 | Event, EventNavigationPopulator, HandlerTestBase, INavigationPopulator, NavigationMetadata, UnitOfWork | +| 15 | `EventQuestionAnswerNavigationPopulatorTests` | MMCA.ADC.Conference.Application.Tests | 6 | EventQuestionAnswer, EventQuestionAnswerNavigationPopulator, HandlerTestBase, INavigationPopulator, NavigationMetadata, UnitOfWork | +| 15 | `EventSpeakerNavigationPopulatorTests` | MMCA.ADC.Conference.Application.Tests | 6 | EventSpeaker, EventSpeakerNavigationPopulator, HandlerTestBase, INavigationPopulator, NavigationMetadata, UnitOfWork | +| 15 | `GetCategoryDistributionHandlerTests` | MMCA.ADC.Conference.Application.Tests | 8 | Category, GetCategoryDistributionHandler, GetCategoryDistributionQuery, HandlerTestBase, IRepository, Session, SessionStatuses, UnitOfWork | +| 15 | `GetContentSimilarityHandlerTests` | MMCA.ADC.Conference.Application.Tests | 8 | Category, GetContentSimilarityHandler, GetContentSimilarityQuery, HandlerTestBase, IRepository, Session, SessionStatuses, UnitOfWork | +| 15 | `GetPublicActivityFilterHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | Activity, Event, GetPublicActivityFilterHandler, GetPublicActivityFilterQuery, HandlerTestBase, IReadRepository, UnitOfWork | +| 15 | `GetPublicEventSpeakerFilterHandlerTests` | MMCA.ADC.Conference.Application.Tests | 10 | Event, EventSpeaker, GetPublicEventSpeakerFilterHandler, GetPublicEventSpeakerFilterQuery, HandlerTestBase, IReadRepository, ISpecification, Session, SessionSpeaker, UnitOfWork | +| 15 | `GetPublicSessionCategoryItemFilterHandlerTests` | MMCA.ADC.Conference.Application.Tests | 9 | Event, GetPublicSessionCategoryItemFilterHandler, GetPublicSessionCategoryItemFilterQuery, HandlerTestBase, IReadRepository, ISpecification, Session, SessionCategoryItem, UnitOfWork | +| 15 | `GetPublicSessionFilterHandlerTests` | MMCA.ADC.Conference.Application.Tests | 8 | Event, GetPublicSessionFilterHandler, GetPublicSessionFilterQuery, HandlerTestBase, IReadRepository, Session, SessionStatuses, UnitOfWork | +| 15 | `GetPublicSessionSpeakerFilterHandlerTests` | MMCA.ADC.Conference.Application.Tests | 9 | Event, GetPublicSessionSpeakerFilterHandler, GetPublicSessionSpeakerFilterQuery, HandlerTestBase, IReadRepository, ISpecification, Session, SessionSpeaker, UnitOfWork | +| 15 | `GetPublicSpeakerCategoryItemFilterHandlerTests` | MMCA.ADC.Conference.Application.Tests | 10 | Event, GetPublicSpeakerCategoryItemFilterHandler, GetPublicSpeakerCategoryItemFilterQuery, HandlerTestBase, IReadRepository, ISpecification, Session, SessionSpeaker, SpeakerCategoryItem, UnitOfWork | +| 15 | `GetPublicSpeakerFilterHandlerTests` | MMCA.ADC.Conference.Application.Tests | 12 | Event, EventSpeaker, GetPublicSpeakerFilterHandler, GetPublicSpeakerFilterQuery, HandlerTestBase, IReadRepository, ISpecification, Session, SessionSpeaker, SessionStatuses, Speaker, UnitOfWork | +| 15 | `GetPublicSponsorFilterHandlerTests` | MMCA.ADC.Conference.Application.Tests | 8 | Event, GetPublicSponsorFilterHandler, GetPublicSponsorFilterQuery, HandlerTestBase, IReadRepository, Sponsor, SponsorTier, UnitOfWork | +| 15 | `GetSessionsBySpeakerFilterHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | GetSessionsBySpeakerFilterHandler, GetSessionsBySpeakerFilterQuery, HandlerTestBase, IReadRepository, Session, SessionSpeaker, UnitOfWork | +| 15 | `GetSessionSelectionDashboardHandlerTests` | MMCA.ADC.Conference.Application.Tests | 12 | Category, ErrorType, Event, GetSessionSelectionDashboardHandler, GetSessionSelectionDashboardQuery, HandlerTestBase, IRepository, Session, SessionAiScore, SessionStatuses, Speaker, UnitOfWork | +| 15 | `GetSpeakersByEventFilterHandlerTests` | MMCA.ADC.Conference.Application.Tests | 9 | EventSpeaker, GetSpeakersByEventFilterHandler, GetSpeakersByEventFilterQuery, HandlerTestBase, IReadRepository, Session, SessionSpeaker, Speaker, UnitOfWork | +| 15 | `GetSpeakerSessionOverlapHandlerTests` | MMCA.ADC.Conference.Application.Tests | 9 | Category, GetSpeakerSessionOverlapHandler, GetSpeakerSessionOverlapQuery, HandlerTestBase, IRepository, Session, SessionStatuses, Speaker, UnitOfWork | +| 15 | `LinkUserToSpeakerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 8 | ErrorType, HandlerTestBase, IRepository, LinkUserToSpeakerCommand, LinkUserToSpeakerHandler, Speaker, SpeakerLinkedToUser, UnitOfWork | +| 15 | `PublishEventHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | ErrorType, Event, HandlerTestBase, IRepository, PublishEventCommand, PublishEventHandler, UnitOfWork | +| 15 | `RefreshFromSessionizeHandlerTests` | MMCA.ADC.Conference.Application.Tests | 9 | ErrorType, Event, ICurrentUserService, IRepository, ISessionizeService, IUnitOfWork, RefreshFromSessionizeCommand, RefreshFromSessionizeHandler, SessionizeResponse | +| 15 | `RemoveCategoryItemHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | Category, ErrorType, HandlerTestBase, IRepository, RemoveCategoryItemCommand, RemoveCategoryItemHandler, UnitOfWork | +| 15 | `RemoveEventQuestionAnswerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 9 | ErrorType, Event, HandlerTestBase, ICurrentUserService, IRepository, RemoveEventQuestionAnswerCommand, RemoveEventQuestionAnswerHandler, RoleNames, UnitOfWork | +| 15 | `RemoveEventSpeakerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | ErrorType, Event, HandlerTestBase, IRepository, RemoveEventSpeakerCommand, RemoveEventSpeakerHandler, UnitOfWork | +| 15 | `RemoveRoomHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | ErrorType, Event, HandlerTestBase, IRepository, RemoveRoomCommand, RemoveRoomHandler, UnitOfWork | +| 15 | `RemoveSessionCategoryItemHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | ErrorType, HandlerTestBase, IRepository, RemoveSessionCategoryItemCommand, RemoveSessionCategoryItemHandler, Session, UnitOfWork | +| 15 | `RemoveSessionQuestionAnswerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 9 | ErrorType, HandlerTestBase, ICurrentUserService, IRepository, RemoveSessionQuestionAnswerCommand, RemoveSessionQuestionAnswerHandler, RoleNames, Session, UnitOfWork | +| 15 | `RemoveSessionSpeakerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | ErrorType, HandlerTestBase, IRepository, RemoveSessionSpeakerCommand, RemoveSessionSpeakerHandler, Session, UnitOfWork | +| 15 | `RemoveSpeakerCategoryItemHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | ErrorType, HandlerTestBase, IRepository, RemoveSpeakerCategoryItemCommand, RemoveSpeakerCategoryItemHandler, Speaker, UnitOfWork | +| 15 | `RoomNavigationPopulatorTests` | MMCA.ADC.Conference.Application.Tests | 6 | HandlerTestBase, INavigationPopulator, NavigationMetadata, Room, RoomNavigationPopulator, UnitOfWork | +| 15 | `ScoreEventSessionsHandlerTests` | MMCA.ADC.Conference.Application.Tests | 12 | HandlerTestBase, IAiScoringService, IRepository, ScoreEventSessionsCommand, ScoreEventSessionsHandler, Session, SessionAiScore, SessionScoringInput, SessionScoringResult, SessionStatuses, Speaker, UnitOfWork | +| 15 | `SessionBookmarkValidationServiceTests` | MMCA.ADC.Conference.Application.Tests | 6 | ErrorType, HandlerTestBase, IRepository, Session, SessionBookmarkValidationService, UnitOfWork | +| 15 | `SessionCategoryItemNavigationPopulatorTests` | MMCA.ADC.Conference.Application.Tests | 6 | HandlerTestBase, INavigationPopulator, NavigationMetadata, SessionCategoryItem, SessionCategoryItemNavigationPopulator, UnitOfWork | +| 15 | `SessionNavigationPopulatorTests` | MMCA.ADC.Conference.Application.Tests | 6 | HandlerTestBase, INavigationPopulator, NavigationMetadata, Session, SessionNavigationPopulator, UnitOfWork | +| 15 | `SessionQuestionAnswerNavigationPopulatorTests` | MMCA.ADC.Conference.Application.Tests | 6 | HandlerTestBase, INavigationPopulator, NavigationMetadata, SessionQuestionAnswer, SessionQuestionAnswerNavigationPopulator, UnitOfWork | +| 15 | `SessionSpeakerNavigationPopulatorTests` | MMCA.ADC.Conference.Application.Tests | 6 | HandlerTestBase, INavigationPopulator, NavigationMetadata, SessionSpeaker, SessionSpeakerNavigationPopulator, UnitOfWork | +| 15 | `SpeakerCategoryItemNavigationPopulatorTests` | MMCA.ADC.Conference.Application.Tests | 6 | HandlerTestBase, INavigationPopulator, NavigationMetadata, SpeakerCategoryItem, SpeakerCategoryItemNavigationPopulator, UnitOfWork | +| 15 | `SpeakerEntityQueryServiceTests` | MMCA.ADC.Conference.Application.Tests | 16 | EntityQueryParameters, ErrorType, HandlerTestBase, ICurrentUserService, IEntityQueryPipeline, INavigationMetadataProvider, INavigationPopulator, InlineSpecification, IReadRepository, NavigationMetadata, Speaker, SpeakerCategoryItemDTOMapper, SpeakerDTOMapper, SpeakerEntityQueryService, SpeakerQuestionAnswerDTOMapper, UnitOfWork | +| 15 | `SpeakerNavigationPopulatorTests` | MMCA.ADC.Conference.Application.Tests | 6 | HandlerTestBase, INavigationPopulator, NavigationMetadata, Speaker, SpeakerNavigationPopulator, UnitOfWork | +| 15 | `SpeakerQuestionAnswerNavigationPopulatorTests` | MMCA.ADC.Conference.Application.Tests | 6 | HandlerTestBase, INavigationPopulator, NavigationMetadata, SpeakerQuestionAnswer, SpeakerQuestionAnswerNavigationPopulator, UnitOfWork | +| 15 | `SponsorNavigationPopulatorTests` | MMCA.ADC.Conference.Application.Tests | 6 | HandlerTestBase, INavigationPopulator, NavigationMetadata, Sponsor, SponsorNavigationPopulator, UnitOfWork | +| 15 | `UnlinkUserFromSpeakerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 8 | ErrorType, HandlerTestBase, IRepository, Speaker, SpeakerUnlinkedFromUser, UnitOfWork, UnlinkUserFromSpeakerCommand, UnlinkUserFromSpeakerHandler | +| 15 | `UnpublishEventHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | ErrorType, Event, HandlerTestBase, IRepository, UnitOfWork, UnpublishEventCommand, UnpublishEventHandler | +| 15 | `UpdateActivityHandlerTests` | MMCA.ADC.Conference.Application.Tests | 9 | Activity, ActivityDTOMapper, ActivityUpdateRequest, ErrorType, HandlerTestBase, IRepository, UnitOfWork, UpdateActivityCommand, UpdateActivityHandler | +| 15 | `UpdateCategoryItemHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | Category, ErrorType, HandlerTestBase, IRepository, UnitOfWork, UpdateCategoryItemCommand, UpdateCategoryItemHandler | +| 15 | `UpdateConferenceCategoryHandlerTests` | MMCA.ADC.Conference.Application.Tests | 10 | Category, CategoryItemDTOMapper, ConferenceCategoryDTOMapper, ConferenceCategoryUpdateRequest, ErrorType, HandlerTestBase, IRepository, UnitOfWork, UpdateConferenceCategoryCommand, UpdateConferenceCategoryHandler | +| 15 | `UpdateEventHandlerTests` | MMCA.ADC.Conference.Application.Tests | 13 | ErrorType, Event, EventDTOMapper, EventQuestionAnswerDTOMapper, EventSpeakerDTOMapper, EventUpdateRequest, HandlerTestBase, IRepository, RoomDTOMapper, Session, UnitOfWork, UpdateEventCommand, UpdateEventHandler | +| 15 | `UpdateEventQuestionAnswerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 9 | ErrorType, Event, HandlerTestBase, ICurrentUserService, IRepository, RoleNames, UnitOfWork, UpdateEventQuestionAnswerCommand, UpdateEventQuestionAnswerHandler | +| 15 | `UpdateQuestionHandlerTests` | MMCA.ADC.Conference.Application.Tests | 13 | ErrorType, EventQuestionAnswer, HandlerTestBase, IReadRepository, IRepository, Question, QuestionDTOMapper, QuestionUpdateRequest, SessionQuestionAnswer, SpeakerQuestionAnswer, UnitOfWork, UpdateQuestionCommand, UpdateQuestionHandler | +| 15 | `UpdateRoomHandlerTests` | MMCA.ADC.Conference.Application.Tests | 7 | ErrorType, Event, HandlerTestBase, IRepository, UnitOfWork, UpdateRoomCommand, UpdateRoomHandler | +| 15 | `UpdateSessionHandlerTests` | MMCA.ADC.Conference.Application.Tests | 13 | ErrorType, Event, HandlerTestBase, IRepository, Session, SessionCategoryItemDTOMapper, SessionDTOMapper, SessionQuestionAnswerDTOMapper, SessionSpeakerDTOMapper, SessionUpdateRequest, UnitOfWork, UpdateSessionCommand, UpdateSessionHandler | +| 15 | `UpdateSessionQuestionAnswerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 10 | ErrorType, Event, HandlerTestBase, ICurrentUserService, IRepository, RoleNames, Session, UnitOfWork, UpdateSessionQuestionAnswerCommand, UpdateSessionQuestionAnswerHandler | +| 15 | `UpdateSpeakerHandlerTests` | MMCA.ADC.Conference.Application.Tests | 13 | Email, ErrorType, HandlerTestBase, ICurrentUserService, IRepository, Speaker, SpeakerCategoryItemDTOMapper, SpeakerDTOMapper, SpeakerQuestionAnswerDTOMapper, SpeakerUpdateRequest, UnitOfWork, UpdateSpeakerCommand, UpdateSpeakerHandler | +| 15 | `UpdateSponsorHandlerTests` | MMCA.ADC.Conference.Application.Tests | 10 | ErrorType, HandlerTestBase, IRepository, Sponsor, SponsorDTOMapper, SponsorTier, SponsorUpdateRequest, UnitOfWork, UpdateSponsorCommand, UpdateSponsorHandler | | 15 | `ConferenceIntegrationTestFixture` | MMCA.ADC.Conference.IntegrationTests | 4 | ConferenceTestWebApplicationFactory, JwtTokenGenerator, Program, SqlServerIntegrationTestFixtureBase | | 15 | `CrossServiceFixture` | MMCA.ADC.CrossService.IntegrationTests | 6 | ConferenceCrossServiceFactory, CrossServiceDataSource, CrossServiceFixtureBase, EngagementCrossServiceFactory, IdentityCrossServiceFactory, JwtTokenGenerator | +| 15 | `AttendeeCheckedInPointsHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 11 | AttendeeCheckedIn, AttendeeCheckedInPointsHandler, CheckInScopeNames, Error, HandlerTestBase, IPointsAwarder, PointsActivityType, RecordingPointsAwarder, Result, SponsorVisit, TestSupport | +| 15 | `BookmarkCountServiceTests` | MMCA.ADC.Engagement.Application.Tests | 5 | BookmarkCountService, HandlerTestBase, InMemoryQueryableExecutor, UnitOfWork, UserSessionBookmark | +| 15 | `CastVoteHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 12 | CastVoteCommand, CastVoteHandler, ErrorType, FixedTimeProvider, HandlerMocks, HandlerTestBase, InMemoryQueryableExecutor, IReadRepository, LivePoll, LivePollResultsBuilder, LivePollVote, UnitOfWork | +| 15 | `CheckInAttendeeHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 20 | AttendeeBadge, AttendeeCheckedIn, BadgePayload, CheckIn, CheckInAttendeeHandler, CheckInAttendeeRequest, CheckInScope, CheckInScopeNames, Error, ErrorType, EventLiveInfo, FixedTimeProvider, HandlerMocks, HandlerTestBase, ICurrentUserService, IEventLiveValidationService, QuestionModerationDefault, Result, SessionLiveInfo, UnitOfWork | +| 15 | `CloseLivePollHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 16 | CloseLivePollCommand, CloseLivePollHandler, Error, ErrorType, HandlerMocks, HandlerTestBase, IEventLiveValidationService, ILiveChannelPublishQueue, LiveChannelPublishWorkItem, LivePoll, LivePollChannel, LivePollStatus, QuestionModerationDefault, Result, SessionLiveInfo, UnitOfWork | +| 15 | `CreateBookmarkHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 12 | BookmarkManagementDomainService, CreateBookmarkHandler, CreateBookmarkRequest, Error, ErrorType, HandlerMocks, HandlerTestBase, ISessionBookmarkValidationService, Result, UnitOfWork, UserSessionBookmark, UserSessionBookmarkDTOMapper | +| 15 | `CreateLivePollHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 17 | CreateLivePollCommand, CreateLivePollHandler, CreateLivePollRequest, Error, ErrorType, EventLiveInfo, HandlerMocks, HandlerTestBase, IEventLiveValidationService, LivePoll, LivePollDTOMapper, LivePollStatus, Question, QuestionModerationDefault, Result, SessionLiveInfo, UnitOfWork | +| 15 | `EventFeedbackSubmittedPointsHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 9 | Error, EventFeedbackSubmitted, EventFeedbackSubmittedPointsHandler, HandlerTestBase, IPointsAwarder, PointsActivityType, RecordingPointsAwarder, Result, TestSupport | +| 15 | `GetAttendanceStatsHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 6 | CheckIn, CheckInScope, GetAttendanceStatsHandler, GetAttendanceStatsQuery, HandlerTestBase, UnitOfWork | +| 15 | `GetBookmarkedSessionIdsHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 5 | GetBookmarkedSessionIdsHandler, GetBookmarkedSessionIdsQuery, HandlerTestBase, UnitOfWork, UserSessionBookmark | +| 15 | `GetEventPollsHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 7 | GetEventPollsHandler, GetEventPollsQuery, HandlerTestBase, LivePoll, LivePollDTOMapper, LivePollStatus, UnitOfWork | +| 15 | `GetLeaderboardHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 8 | GetLeaderboardHandler, GetLeaderboardQuery, HandlerTestBase, LeaderboardOptIn, PointsActivityType, PointsEntry, PointsSettings, UnitOfWork | +| 15 | `GetModerationQueueHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 16 | Error, ErrorType, GetModerationQueueHandler, GetModerationQueueQuery, HandlerMocks, HandlerTestBase, IEventLiveValidationService, InMemoryQueryableExecutor, QuestionModerationDefault, QuestionStatus, Result, SessionLiveInfo, SessionQuestion, SessionQuestionUpvote, SessionQuestionViewBuilder, UnitOfWork | +| 15 | `GetMyPointsHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 9 | ErrorType, GetMyPointsHandler, GetMyPointsQuery, HandlerTestBase, ICurrentUserService, LeaderboardOptIn, PointsActivityType, PointsEntry, UnitOfWork | +| 15 | `GetOpenPollsHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 9 | ErrorType, GetOpenPollsHandler, GetOpenPollsQuery, HandlerTestBase, InMemoryQueryableExecutor, LivePoll, LivePollResultsBuilder, LivePollVote, UnitOfWork | +| 15 | `GetOrCreateMyBadgeHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 8 | AttendeeBadge, ErrorType, GetOrCreateMyBadgeCommand, GetOrCreateMyBadgeHandler, HandlerMocks, HandlerTestBase, ICurrentUserService, UnitOfWork | +| 15 | `GetPointsOverviewHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 7 | GetPointsOverviewHandler, GetPointsOverviewQuery, HandlerTestBase, PointsActivityType, PointsEntry, PointsEntryDTO, UnitOfWork | +| 15 | `GetSessionQuestionsHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 9 | GetSessionQuestionsHandler, GetSessionQuestionsQuery, HandlerTestBase, InMemoryQueryableExecutor, QuestionStatus, SessionQuestion, SessionQuestionUpvote, SessionQuestionViewBuilder, UnitOfWork | +| 15 | `GetUserBookmarksHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 11 | Error, GetUserBookmarksHandler, GetUserBookmarksQuery, HandlerMocks, HandlerTestBase, IQueryableExecutor, ISessionBookmarkValidationService, Result, UnitOfWork, UserSessionBookmark, UserSessionBookmarkDTOMapper | +| 15 | `LivePollOptionNavigationPopulatorTests` | MMCA.ADC.Engagement.Application.Tests | 6 | HandlerTestBase, INavigationPopulator, LivePollOption, LivePollOptionNavigationPopulator, NavigationMetadata, UnitOfWork | +| 15 | `ManualCheckInHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 17 | AttendeeCheckedIn, CheckIn, CheckInScope, CheckInScopeNames, ErrorType, EventLiveInfo, FixedTimeProvider, HandlerMocks, HandlerTestBase, ICurrentUserService, IEventLiveValidationService, ManualCheckInHandler, ManualCheckInRequest, QuestionModerationDefault, Result, SessionLiveInfo, UnitOfWork | +| 15 | `ModerateQuestionHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 17 | Error, ErrorType, HandlerMocks, HandlerTestBase, IEventLiveValidationService, ILiveChannelPublishQueue, LiveChannelPublishWorkItem, ModerateQuestionCommand, ModerateQuestionHandler, ModerationAction, QuestionModerationDefault, QuestionStatus, Result, SessionLiveInfo, SessionQuestion, SessionQuestionChannel, UnitOfWork | +| 15 | `OpenLivePollHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 18 | Error, ErrorType, EventLiveInfo, FixedTimeProvider, HandlerMocks, HandlerTestBase, IEventLiveValidationService, ILiveChannelPublishQueue, LiveChannelPublishWorkItem, LivePoll, LivePollChannel, LivePollStatus, OpenLivePollCommand, OpenLivePollHandler, QuestionModerationDefault, Result, SessionLiveInfo, UnitOfWork | +| 15 | `PointsAwarderTests` | MMCA.ADC.Engagement.Application.Tests | 11 | AwarderMocks, EventFeedback, HandlerTestBase, MutableOptions, PointsActivityType, PointsAwarder, PointsEntry, PointsSettings, PointsSubjectKeys, SessionFeedback, UnitOfWork | +| 15 | `RecordRoomCheckInHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 17 | AttendeeCheckedIn, CheckIn, CheckInScope, CheckInScopeNames, CheckInSettings, Error, ErrorType, FixedTimeProvider, HandlerMocks, HandlerTestBase, ICurrentUserService, IEventLiveValidationService, RecordRoomCheckInHandler, Result, RoomCheckInRequest, RoomSessionInfo, UnitOfWork | +| 15 | `RecordSponsorVisitHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 16 | AttendeeCheckedIn, CheckIn, CheckInScope, CheckInScopeNames, Error, ErrorType, FixedTimeProvider, HandlerMocks, HandlerTestBase, ICurrentUserService, IEventLiveValidationService, RecordSponsorVisitHandler, Result, SponsorLiveInfo, SponsorVisitRequest, UnitOfWork | +| 15 | `SessionFeedbackSubmittedPointsHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 9 | Error, HandlerTestBase, IPointsAwarder, PointsActivityType, RecordingPointsAwarder, Result, SessionFeedbackSubmitted, SessionFeedbackSubmittedPointsHandler, TestSupport | +| 15 | `SessionQuestionSubmittedPointsHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 13 | DomainEntityState, Error, HandlerTestBase, IPointsAwarder, PointsActivityType, QuestionStatus, RecordingPointsAwarder, Result, SessionQuestion, SessionQuestionChanged, SessionQuestionSubmittedPointsHandler, TestSupport, ThrowingPointsAwarder | +| 15 | `SetLeaderboardParticipationHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 8 | ErrorType, HandlerMocks, HandlerTestBase, ICurrentUserService, LeaderboardOptIn, SetLeaderboardParticipationHandler, SetLeaderboardParticipationRequest, UnitOfWork | +| 15 | `SubmitQuestionHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 23 | Error, FixedTimeProvider, HandlerMocks, HandlerTestBase, IEventLiveValidationService, ILiveChannelPublishQueue, InMemoryQueryableExecutor, IReadRepository, LiveChannelPublishWorkItem, QuestionModerationDefault, QuestionStatus, Result, SessionLiveInfo, SessionQuestion, SessionQuestionApprovedPayload, SessionQuestionChannel, SessionQuestionInvariants, SessionQuestionPendingCountChangedPayload, SessionQuestionUpvote, SessionQuestionViewBuilder …(+3) | +| 15 | `ToggleUpvoteHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 11 | ErrorType, FixedTimeProvider, HandlerMocks, HandlerTestBase, IRepository, QuestionStatus, SessionQuestion, SessionQuestionUpvote, ToggleUpvoteCommand, ToggleUpvoteHandler, UnitOfWork | +| 15 | `UserDeletedPointsHandlerTests` | MMCA.ADC.Engagement.Application.Tests | 7 | HandlerTestBase, IRepository, LeaderboardOptIn, TestSupport, UnitOfWork, UserDeleted, UserDeletedPointsHandler | | 15 | `EngagementIntegrationTestFixture` | MMCA.ADC.Engagement.IntegrationTests | 4 | EngagementTestWebApplicationFactory, JwtTokenGenerator, Program, SqlServerIntegrationTestFixtureBase | | 15 | `GatewayHardeningTests` | MMCA.ADC.Gateway.Tests | 1 | GatewayApplicationFactory | | 15 | `RouteMapTests` | MMCA.ADC.Gateway.Tests | 3 | ClusterProfile, RecordingHttpForwarder, RouteMapApplicationFactory | +| 15 | `DependencyInjection` | MMCA.ADC.Identity.Application | 13 | ApplicationSettings, AttendeeQueryService, AuthenticationService, AuthenticationValidators, ClassReference, ClassReference, EngagementUserDataExportSection, IAttendeeQueryService, IAuthenticationService, ISoftDeletedUserValidator, NotificationUserDataExportSection, SoftDeletedUserValidator, User | +| 15 | `AttendeeQueryServiceTests` | MMCA.ADC.Identity.Application.Tests | 5 | AttendeeQueryService, HandlerTestBase, IRepository, UnitOfWork, User | +| 15 | `AuthenticationServiceTests` | MMCA.ADC.Identity.Application.Tests | 19 | AuthenticationResponse, AuthenticationService, AuthenticationValidators, Error, ErrorType, IExternalLoginEmailVerifier, ILoginProtectionService, IPasswordHasher, IRepository, ITokenService, IUnitOfWork, LoginRequest, RefreshTokenRequest, RegisterRequest, Result, ServiceMocks, User, UserRegistered, UserRole | +| 15 | `ChangePasswordHandlerTests` | MMCA.ADC.Identity.Application.Tests | 10 | ChangePasswordCommand, ChangePasswordHandler, ChangePasswordRequest, ErrorType, HandlerTestBase, IPasswordHasher, IRepository, UnitOfWork, User, UserRole | +| 15 | `ChangePreferencesHandlerTests` | MMCA.ADC.Identity.Application.Tests | 9 | ChangePreferencesCommand, ChangePreferencesHandler, ChangePreferencesRequest, ErrorType, HandlerTestBase, IRepository, UnitOfWork, User, UserRole | +| 15 | `DeleteUserHandlerTests` | MMCA.ADC.Identity.Application.Tests | 13 | DeleteUserCommand, DeleteUserHandler, ErrorType, FixedTimeProvider, HandlerTestBase, ICacheService, IFileStorageService, IRepository, Result, SoftDeletedUserCache, UnitOfWork, User, UserRole | +| 15 | `ExportUserDataHandlerTests` | MMCA.ADC.Identity.Application.Tests | 26 | EngagementUserDataExportSection, ErrorType, ExportUserDataHandler, ExportUserDataHandlerBase, ExportUserDataQuery, HandlerTestBase, IRepository, IUserDataExportSection, IUserEngagementExportService, IUserNotificationExportService, NotificationUserDataExportSection, Subject, ThrowingExportSection, UnitOfWork, User, UserDataExportDTO, UserDataExportEngagementSectionDTO, UserDataExportNotificationSectionDTO, UserDataExportSectionDefaults, UserDataExportSectionDTO …(+6) | +| 15 | `ForgotPasswordHandlerTests` | MMCA.ADC.Identity.Application.Tests | 12 | ForgotPasswordCommand, ForgotPasswordHandler, ForgotPasswordRequest, HandlerTestBase, IEmailSender, IPasswordResetTokenService, IRepository, PasswordResetSettings, Result, UnitOfWork, User, UserRole | +| 15 | `GetUserPreferencesHandlerTests` | MMCA.ADC.Identity.Application.Tests | 12 | ChangePreferencesCommand, ChangePreferencesHandler, ChangePreferencesRequest, ErrorType, GetUserPreferencesHandler, GetUserPreferencesQuery, HandlerTestBase, IRepository, UnitOfWork, User, UserPreferencesResponse, UserRole | +| 15 | `GetUsersHandlerTests` | MMCA.ADC.Identity.Application.Tests | 10 | Email, GetUsersHandler, GetUsersQuery, HandlerTestBase, IQueryableExecutor, IRepository, UnitOfWork, User, UserListDTO, UserRole | +| 15 | `ResetPasswordHandlerTests` | MMCA.ADC.Identity.Application.Tests | 14 | Email, Error, HandlerTestBase, ILoginProtectionService, IPasswordHasher, IPasswordResetTokenService, IRepository, ResetPasswordCommand, ResetPasswordHandler, ResetPasswordRequest, Result, UnitOfWork, User, UserRole | +| 15 | `IdentityModuleDbSeeder` | MMCA.ADC.Identity.Infrastructure | 9 | Email, IdentityModuleDbSeederBase, IPasswordHasher, IUnitOfWork, Result, SeedAccount, UnitOfWork, User, UserRole | | 15 | `IdentityIntegrationTestFixture` | MMCA.ADC.Identity.IntegrationTests | 4 | IdentityTestWebApplicationFactory, JwtTokenGenerator, Program, SqlServerIntegrationTestFixtureBase | +| 15 | `UserNotificationExportServiceTests` | MMCA.ADC.Notification.Application.Tests | 7 | HandlerTestBase, InMemoryQueryableExecutor, IRepository, PushNotification, UnitOfWork, UserNotification, UserNotificationExportService | | 15 | `NotificationIntegrationTestFixture` | MMCA.ADC.Notification.IntegrationTests | 4 | JwtTokenGenerator, NotificationTestWebApplicationFactory, Program, SqlServerIntegrationTestFixtureBase | +| 15 | `AuthControllerBase` | MMCA.Common.API | 10 | ApiControllerBase, AuthenticationResponse, AuthenticationService, CurrentUserService, IAuthenticationService, ICurrentUserService, LoginRequest, RefreshTokenRequest, RegisterRequest, WebApplicationBuilderExtensions | +| 15 | `PasswordResetAuthControllerBase` | MMCA.Common.API | 9 | ApiControllerBase, ForgotPasswordHandler, ForgotPasswordRequest, ICommandHandler, ICommandWithRequest, ResetPasswordHandler, ResetPasswordRequest, Result, WebApplicationBuilderExtensions | +| 15 | `AuditTrailCleanupJobTests` | MMCA.Common.Infrastructure.Tests | 13 | ApplicationDbContext, AuditedThing, AuditTrailCleanupJob, AuditTrailEntry, AuditTrailSettings, AuditTrailTestContext, AuditTrailTestHarness, DataSource, DataSourceKey, FakeTimeProvider, IDbContextFactory, IEntityDataSourceRegistry, SchedulerTestHarness | +| 15 | `AuditTrailReaderTests` | MMCA.Common.Infrastructure.Tests | 12 | ApplicationDbContext, AuditTrailEntry, AuditTrailReader, AuditTrailSettings, AuditTrailTestContext, AuditTrailTestHarness, DataSource, DataSourceKey, FakeTimeProvider, IDataSourceResolver, IDbContextFactory, SchedulerTestHarness | +| 15 | `EntityDataSourceRegistryTests` | MMCA.Common.Infrastructure.Tests | 15 | ConnectionStringSettings, DataSource, DataSourceEntrySettings, DataSourceKey, DataSourceResolver, DataSourcesSettings, EntityDataSourceRegistry, FixedAssemblyProvider, NamespaceConventions, PushNotification, RegistryDuplicate, RegistryInvoice, RegistryOrder, RegistrySqlServerEntity, RegistryUnattributed | +| 15 | `OutboxCleanupServiceTests` | MMCA.Common.Infrastructure.Tests | 14 | ApplicationDbContext, CleanupTestContext, DataSource, DataSourceKey, FakeTimeProvider, IDataSourceResolver, IDbContextFactory, IEntityDataSourceRegistry, InboxMessage, MessageBusSettings, Mocks, OutboxCleanupService, OutboxMessage, OutboxSettings | +| 15 | `OutboxProcessorExecuteAsyncTests` | MMCA.Common.Infrastructure.Tests | 9 | DataSource, DataSourceKey, DependencyInjection, FakeTimeProvider, IDataSourceResolver, IEntityDataSourceRegistry, IOutboxSignal, OutboxProcessor, OutboxSettings | +| 15 | `TestIdentityModuleDbSeeder` | MMCA.Common.Infrastructure.Tests | 8 | Email, Error, IdentityModuleDbSeederBase, IPasswordHasher, IUnitOfWork, Result, SeedAccount, TestSeedUser | +| 15 | `UnitOfWorkAdditionalTests` | MMCA.Common.Infrastructure.Tests | 12 | ApplicationDbContext, DataSource, DataSourceKey, FakeAggregate, FakeEntity, IDataSourceService, IDbContextFactory, IReadRepository, IRepository, IRepositoryFactory, Mocks, UnitOfWork | +| 15 | `UnitOfWorkTests` | MMCA.Common.Infrastructure.Tests | 12 | ApplicationDbContext, DataSource, DataSourceKey, FakeAggregate, FakeEntity, IDataSourceService, IDbContextFactory, IReadRepository, IRepository, IRepositoryFactory, Mocks, UnitOfWork | +| 15 | `HandlerTestBaseTests` | MMCA.Common.Testing.Tests | 5 | FakeHandler, HandlerTestBase, TestAggregate, TestChildEntity, UnitOfWork | | 16 | `ConferenceIntegrationTestCollection` | MMCA.ADC.Conference.IntegrationTests | 1 | ConferenceIntegrationTestFixture | | 16 | `CrossServiceCollection` | MMCA.ADC.CrossService.IntegrationTests | 1 | CrossServiceFixture | | 16 | `EngagementIntegrationTestCollection` | MMCA.ADC.Engagement.IntegrationTests | 1 | EngagementIntegrationTestFixture | +| 16 | `IdentityModuleSeeder` | MMCA.ADC.Identity.API | 4 | IdentityModuleDbSeeder, IModuleSeeder, IPasswordHasher, IUnitOfWork | +| 16 | `PasswordResetController` | MMCA.ADC.Identity.API | 8 | ForgotPasswordCommand, ForgotPasswordRequest, ICommandHandler, PasswordResetAuthControllerBase, ResetPasswordCommand, ResetPasswordRequest, Result, Route | +| 16 | `IdentityModuleDbSeederTests` | MMCA.ADC.Identity.Infrastructure.Tests | 6 | IdentityModuleDbSeeder, IPasswordHasher, IRepository, IUnitOfWork, SeederMocks, User | | 16 | `IdentityIntegrationTestCollection` | MMCA.ADC.Identity.IntegrationTests | 1 | IdentityIntegrationTestFixture | | 16 | `JwksEnabledIdentityFixture` | MMCA.ADC.Identity.IntegrationTests | 2 | IdentityIntegrationTestFixture, JwtTokenGenerator | | 16 | `NotificationIntegrationTestCollection` | MMCA.ADC.Notification.IntegrationTests | 1 | NotificationIntegrationTestFixture | +| 16 | `UserAccountAuthControllerBase` | MMCA.Common.API | 15 | AuthControllerBase, ChangePasswordHandler, ChangePasswordRequest, ChangePreferencesHandler, ChangePreferencesRequest, CurrentUserService, GetUserPreferencesHandler, GetUserPreferencesQuery, IAuthenticationService, ICommandHandler, ICurrentUserService, IQueryHandler, IUserScopedCommand, Result, UserPreferencesResponse | +| 16 | `OverridingAuthController` | MMCA.Common.API.Tests | 5 | AuthControllerBase, AuthenticationResponse, IAuthenticationService, ICurrentUserService, RegisterRequest | +| 16 | `TestAuthController` | MMCA.Common.API.Tests | 3 | AuthControllerBase, IAuthenticationService, ICurrentUserService | +| 16 | `TestPasswordResetController` | MMCA.Common.API.Tests | 7 | ForgotPasswordRequest, ICommandHandler, PasswordResetAuthControllerBase, ResetPasswordRequest, Result, TestForgotPasswordCommand, TestResetPasswordCommand | +| 16 | `IdentityModuleDbSeederBaseTests` | MMCA.Common.Infrastructure.Tests | 7 | IPasswordHasher, IRepository, IUnitOfWork, SeedAccount, SeederMocks, TestIdentityModuleDbSeeder, TestSeedUser | | 17 | `ApiVersioningTests` | MMCA.ADC.Conference.IntegrationTests | 3 | ConferenceIntegrationTestCollection, ConferenceIntegrationTestFixture, ServiceInfoVersioningContractTestsBase | | 17 | `ConferenceIntegrationTestBase` | MMCA.ADC.Conference.IntegrationTests | 5 | ConferenceIntegrationTestCollection, ConferenceIntegrationTestFixture, Email, IntegrationTestBase, JwtTokenGenerator | | 17 | `OpenApiContractTests` | MMCA.ADC.Conference.IntegrationTests | 3 | ConferenceIntegrationTestCollection, ConferenceIntegrationTestFixture, OpenApiContractTestsBase | @@ -3489,6 +3688,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 17 | `EngagementIntegrationTestBase` | MMCA.ADC.Engagement.IntegrationTests | 4 | EngagementIntegrationTestCollection, EngagementIntegrationTestFixture, IntegrationTestBase, JwtTokenGenerator | | 17 | `OpenApiContractTests` | MMCA.ADC.Engagement.IntegrationTests | 3 | EngagementIntegrationTestCollection, EngagementIntegrationTestFixture, OpenApiContractTestsBase | | 17 | `ProblemDetailsContractTests` | MMCA.ADC.Engagement.IntegrationTests | 4 | EngagementIntegrationTestCollection, EngagementIntegrationTestFixture, JwtTokenGenerator, ProblemDetailsContractTestsBase | +| 17 | `AuthController` | MMCA.ADC.Identity.API | 18 | AuthenticationResponse, AuthenticationService, ChangePasswordCommand, ChangePasswordRequest, ChangePreferencesCommand, ChangePreferencesRequest, GetUserPreferencesQuery, IAuthenticationService, ICommandHandler, ICurrentUserService, IQueryHandler, LoginRequest, RegisterRequest, Result, Route, UserAccountAuthControllerBase, UserPreferencesResponse, WebApplicationBuilderExtensions | | 17 | `IdentityIntegrationTestBase` | MMCA.ADC.Identity.IntegrationTests | 4 | IdentityIntegrationTestCollection, IdentityIntegrationTestFixture, IntegrationTestBase, JwtTokenGenerator | | 17 | `JwksIntegrationTestCollection` | MMCA.ADC.Identity.IntegrationTests | 1 | JwksEnabledIdentityFixture | | 17 | `OpenApiContractTests` | MMCA.ADC.Identity.IntegrationTests | 3 | IdentityIntegrationTestCollection, IdentityIntegrationTestFixture, OpenApiContractTestsBase | @@ -3496,6 +3696,10 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 17 | `NotificationIntegrationTestBase` | MMCA.ADC.Notification.IntegrationTests | 4 | IntegrationTestBase, JwtTokenGenerator, NotificationIntegrationTestCollection, NotificationIntegrationTestFixture | | 17 | `OpenApiContractTests` | MMCA.ADC.Notification.IntegrationTests | 3 | NotificationIntegrationTestCollection, NotificationIntegrationTestFixture, OpenApiContractTestsBase | | 17 | `ProblemDetailsContractTests` | MMCA.ADC.Notification.IntegrationTests | 4 | JwtTokenGenerator, NotificationIntegrationTestCollection, NotificationIntegrationTestFixture, ProblemDetailsContractTestsBase | +| 17 | `AuthControllerBaseRateLimitTests` | MMCA.Common.API.Tests | 3 | AuthControllerBase, OverridingAuthController, WebApplicationBuilderExtensions | +| 17 | `AuthControllerBaseTests` | MMCA.Common.API.Tests | 9 | AuthenticationResponse, Error, IAuthenticationService, ICurrentUserService, LoginRequest, RefreshTokenRequest, RegisterRequest, Result, TestAuthController | +| 17 | `PasswordResetAuthControllerBaseTests` | MMCA.Common.API.Tests | 11 | Error, ForgotPasswordRequest, ICommandHandler, IdempotentAttribute, PasswordResetAuthControllerBase, ResetPasswordRequest, Result, TestForgotPasswordCommand, TestPasswordResetController, TestResetPasswordCommand, WebApplicationBuilderExtensions | +| 17 | `TestUserAccountAuthController` | MMCA.Common.API.Tests | 12 | ChangePasswordRequest, ChangePreferencesRequest, GetUserPreferencesQuery, IAuthenticationService, ICommandHandler, ICurrentUserService, IQueryHandler, Result, TestChangePasswordCommand, TestChangePreferencesCommand, UserAccountAuthControllerBase, UserPreferencesResponse | | 18 | `AnonymousAccessDeniedTests` | MMCA.ADC.Conference.IntegrationTests | 2 | ConferenceIntegrationTestBase, ConferenceIntegrationTestFixture | | 18 | `AnonymousConferenceReadTests` | MMCA.ADC.Conference.IntegrationTests | 2 | ConferenceIntegrationTestBase, ConferenceIntegrationTestFixture | | 18 | `AttendeeAccessDeniedTests` | MMCA.ADC.Conference.IntegrationTests | 2 | ConferenceIntegrationTestBase, ConferenceIntegrationTestFixture | @@ -3538,6 +3742,7 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 18 | `RoomCheckInRoundTripTests` | MMCA.ADC.Engagement.IntegrationTests | 6 | BadgePayload, CheckInRow, CheckInScope, EngagementIntegrationTestBase, EngagementIntegrationTestFixture, FakeEventLiveValidationService | | 18 | `SessionQuestionLifecycleTests` | MMCA.ADC.Engagement.IntegrationTests | 3 | EngagementIntegrationTestBase, EngagementIntegrationTestFixture, FakeEventLiveValidationService | | 18 | `SponsorVisitRoundTripTests` | MMCA.ADC.Engagement.IntegrationTests | 8 | AttendeeCheckedIn, EngagementIntegrationTestBase, EngagementIntegrationTestFixture, FakeEventLiveValidationService, IIntegrationEventHandler, LedgerRow, PointsActivityType, PointsSubjectKeys | +| 18 | `AuthControllerTests` | MMCA.ADC.Identity.API.Tests | 17 | AuthController, AuthenticationResponse, ChangePasswordCommand, ChangePasswordRequest, ChangePreferencesCommand, Error, ErrorType, GetUserPreferencesQuery, IAuthenticationService, ICommandHandler, ICurrentUserService, IQueryHandler, LoginRequest, RefreshTokenRequest, RegisterRequest, Result, UserPreferencesResponse | | 18 | `AnonymousAccessDeniedTests` | MMCA.ADC.Identity.IntegrationTests | 2 | IdentityIntegrationTestBase, IdentityIntegrationTestFixture | | 18 | `AnonymousAuthEdgeCaseTests` | MMCA.ADC.Identity.IntegrationTests | 3 | Email, IdentityIntegrationTestBase, IdentityIntegrationTestFixture | | 18 | `AnonymousAuthTests` | MMCA.ADC.Identity.IntegrationTests | 3 | Email, IdentityIntegrationTestBase, IdentityIntegrationTestFixture | @@ -3553,7 +3758,9 @@ alias `using`s name a target whose bare name already matches (so they resolve re | 18 | `OAuthExchangeTests` | MMCA.ADC.Identity.IntegrationTests | 5 | AuthenticationResponse, ExchangeResponse, ICacheService, IdentityIntegrationTestBase, IdentityIntegrationTestFixture | | 18 | `OrganizerUserTests` | MMCA.ADC.Identity.IntegrationTests | 3 | Email, IdentityIntegrationTestBase, IdentityIntegrationTestFixture | | 18 | `OutboxFidelityTests` | MMCA.ADC.Identity.IntegrationTests | 3 | Email, IdentityIntegrationTestBase, IdentityIntegrationTestFixture | +| 18 | `PasswordResetFlowTests` | MMCA.ADC.Identity.IntegrationTests | 4 | Email, IdentityIntegrationTestBase, IdentityIntegrationTestFixture, IPasswordResetTokenService | | 18 | `UserExportTests` | MMCA.ADC.Identity.IntegrationTests | 3 | Email, IdentityIntegrationTestBase, IdentityIntegrationTestFixture | | 18 | `NotificationControllerTests` | MMCA.ADC.Notification.IntegrationTests | 3 | FakeAttendeeQueryService, NotificationIntegrationTestBase, NotificationIntegrationTestFixture | | 18 | `NotificationHubTests` | MMCA.ADC.Notification.IntegrationTests | 4 | FakeAttendeeQueryService, NotificationHub, NotificationIntegrationTestBase, NotificationIntegrationTestFixture | +| 18 | `UserAccountAuthControllerBaseTests` | MMCA.Common.API.Tests | 15 | AuthenticationResponse, ChangePasswordRequest, ChangePreferencesRequest, Error, GetUserPreferencesQuery, IAuthenticationService, ICommandHandler, ICurrentUserService, IQueryHandler, LoginRequest, Result, TestChangePasswordCommand, TestChangePreferencesCommand, TestUserAccountAuthController, UserPreferencesResponse | | 19 | `JwksDiscoveryTests` | MMCA.ADC.Identity.IntegrationTests | 3 | JwksEnabledIdentityFixture, JwksIntegrationTestBase, JwtTokenGenerator | diff --git a/docs-src/onboarding/00-group-taxonomy.md b/docs-src/onboarding/00-group-taxonomy.md index 5f6cbff..f5fe4af 100644 --- a/docs-src/onboarding/00-group-taxonomy.md +++ b/docs-src/onboarding/00-group-taxonomy.md @@ -1,6 +1,6 @@ # Phase 1b - Functional Group Taxonomy -This is the **primary axis** of the guide. Every one of the **3,465** distinct first-party type +This is the **primary axis** of the guide. Every one of the **3,668** distinct first-party type nodes from [`00-inventory.md`](00-inventory.md) is assigned to **exactly one** functional group - its primary *home*: the capability or cross-cutting concern it most exists to serve. A type used across many groups (e.g. `Result`, the entity base) lives in the one foundational group that @@ -55,33 +55,33 @@ disclosure) and is cross-linked in the chapter. |---|-----------------|-------|--------|---------| | G01 | **Result & Error Handling**
group-01-result-error-handling.md | 14 | L0-L2 | The Result/Error railway that every operation returns instead of throwing; pagination result shapes. | | G02 | **Domain Building Blocks (Entities, Value Objects, Aggregates)**
group-02-domain-building-blocks.md | 33 | L0-L5 | The DDD primitives: entity/aggregate base classes, audit fields, value objects + invariants, domain markers, attributes, identifier aliases. | -| G03 | **Querying: Specifications, Filtering & the Entity Query Service**
group-03-querying-specifications.md | 37 | L0-L8 | Composable read-side: the Specification pattern, dynamic filtering/sorting/paging, and the generic entity query pipeline. | -| G04 | **Domain & Integration Events + Outbox Dual-Dispatch**
group-04-events-outbox.md | 32 | L0-L8 | Event contracts, the domain-event dispatcher, the transactional outbox/inbox, and the in-process + broker message buses. | -| G05 | **CQRS: Commands, Queries & the Decorator Pipeline**
group-05-cqrs-pipeline.md | 36 | L0-L9 | The command/query handler abstraction and the cross-cutting decorator pipeline (logging, transaction, caching, feature-gate, idempotency) wrapping it. | +| G03 | **Querying: Specifications, Filtering & the Entity Query Service**
group-03-querying-specifications.md | 38 | L0-L8 | Composable read-side: the Specification pattern, dynamic filtering/sorting/paging, and the generic entity query pipeline. | +| G04 | **Domain & Integration Events + Outbox Dual-Dispatch**
group-04-events-outbox.md | 32 | L0-L13 | Event contracts, the domain-event dispatcher, the transactional outbox/inbox, and the in-process + broker message buses. | +| G05 | **CQRS: Commands, Queries & the Decorator Pipeline**
group-05-cqrs-pipeline.md | 38 | L0-L9 | The command/query handler abstraction and the cross-cutting decorator pipeline (logging, transaction, caching, feature-gate, idempotency) wrapping it. | | G06 | **Validation**
group-06-validation.md | 17 | L0-L5 | The FluentValidation-based validation contracts and failure mapping that gate commands before they execute. | -| G07 | **Persistence & EF Core**
group-07-persistence-ef-core.md | 116 | L0-L10 | The single SQLServerDbContext over the abstract ApplicationDbContext, interceptors, repositories, specifications evaluation, data-source routing (database-per-service), conventions, value generators, encryption, factories and design-time. | -| G08 | **Authentication & Authorization**
group-08-auth.md | 69 | L0-L10 | JWT/JWKS dual-fetch token validation, current-user/claims, password hashing, cookie sessions, and policy/authorization plumbing. | +| G07 | **Persistence & EF Core**
group-07-persistence-ef-core.md | 118 | L0-L14 | The single SQLServerDbContext over the abstract ApplicationDbContext, interceptors, repositories, specifications evaluation, data-source routing (database-per-service), conventions, value generators, encryption, factories and design-time. | +| G08 | **Authentication & Authorization**
group-08-auth.md | 77 | L0-L10 | JWT/JWKS dual-fetch token validation, current-user/claims, password hashing, cookie sessions, and policy/authorization plumbing. | | G09 | **Caching**
group-09-caching.md | 8 | L0-L4 | The cache abstraction and its decorator-driven, invalidation-aware integration into the query pipeline. | | G10 | **Notifications (Push + In-App Inbox + Email)**
group-10-notifications.md | 55 | L0-L10 | The notification subsystem: push (SignalR), the in-app inbox, email sending, recipient providers, and the thin ADC Notification module host. | | G11 | **Navigation Metadata & Populators (EF-decoupled eager loading)**
group-11-navigation-populators.md | 12 | L0-L9 | INavigationMetadata/INavigationPopulator and the loader that hydrate cross-container/cross-source relationships without EF Include coupling ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html)). | -| G12 | **API Hosting, Middleware, Idempotency & DTO/Contract Mapping**
group-12-api-hosting-mapping.md | 74 | L0-L11 | The ASP.NET Core edge: controller bases, middleware, startup, model binders, JSON converters, feature management, idempotency, correlation, and manual DTO/request mapping. | +| G12 | **API Hosting, Middleware, Idempotency & DTO/Contract Mapping**
group-12-api-hosting-mapping.md | 79 | L0-L16 | The ASP.NET Core edge: controller bases, middleware, startup, model binders, JSON converters, feature management, idempotency, correlation, and manual DTO/request mapping. | | G13 | **gRPC & Inter-Service Contracts**
group-13-grpc-contracts.md | 6 | L0-L4 | Typed gRPC clients/servers, interceptors, Result-over-the-wire, and the ServiceContract marker for synchronous inter-service calls ([ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html)). | -| G14 | **Module System, Composition & Configuration**
group-14-module-system-composition.md | 68 | L0-L11 | IModule discovery + Kahn-ordered ModuleLoader, the DI composition roots, assembly markers, data-source/database attributes, and options/settings binding. | -| G15 | **Common UI Framework (MudBlazor components, theme, base pages)**
group-15-common-ui-framework.md | 89 | L0-L7 | Reusable Blazor building blocks: the data-grid list page base, theme, common pages/services, and UI extensions shared by every consumer app. | -| G16 | **Aspire Orchestration & Service Defaults**
group-16-aspire-orchestration.md | 31 | L0-L3 | The Aspire AppHost wiring, ServiceDefaults, warmup, telemetry and security helpers that compose and run the distributed app locally and in Azure. | -| G17 | **ADC Conference - Domain Model & Module Contracts**
group-17-conference-domain.md | 96 | L0-L10 | The Conference bounded context: Event/Session/Speaker/Category/Question aggregates, their domain events and invariants, plus the Shared identifiers/DTOs/integration-event contracts. | -| G18 | **ADC Conference - Application & Use Cases**
group-18-conference-application.md | 252 | L0-L11 | Conference CQRS handlers, validators, DTOs, specifications, the Sessionize import, and the session-selection decision-support analytics. | -| G19 | **ADC Conference - Infrastructure & Persistence**
group-19-conference-infrastructure.md | 32 | L0-L10 | The Conference module DbContext registration, EF entity configurations, database seeding, and infrastructure services. | -| G20 | **ADC Conference - API, gRPC Contracts & Service Host**
group-20-conference-api-grpc.md | 42 | L0-L12 | Conference REST controllers, the .Contracts gRPC surface, the extractable service host, and the gRPC adapter. | -| G21 | **ADC Conference - UI**
group-21-conference-ui.md | 97 | L0-L10 | The Conference Blazor pages (events, sessions, speakers, categories, questions, rooms, feedback, public, session-selection) and their UI services. | +| G14 | **Module System, Composition & Configuration**
group-14-module-system-composition.md | 70 | L0-L14 | IModule discovery + Kahn-ordered ModuleLoader, the DI composition roots, assembly markers, data-source/database attributes, and options/settings binding. | +| G15 | **Common UI Framework (MudBlazor components, theme, base pages)**
group-15-common-ui-framework.md | 91 | L0-L7 | Reusable Blazor building blocks: the data-grid list page base, theme, common pages/services, and UI extensions shared by every consumer app. | +| G16 | **Aspire Orchestration & Service Defaults**
group-16-aspire-orchestration.md | 31 | L0-L10 | The Aspire AppHost wiring, ServiceDefaults, warmup, telemetry and security helpers that compose and run the distributed app locally and in Azure. | +| G17 | **ADC Conference - Domain Model & Module Contracts**
group-17-conference-domain.md | 100 | L0-L10 | The Conference bounded context: Event/Session/Speaker/Category/Question aggregates, their domain events and invariants, plus the Shared identifiers/DTOs/integration-event contracts. | +| G18 | **ADC Conference - Application & Use Cases**
group-18-conference-application.md | 285 | L0-L14 | Conference CQRS handlers, validators, DTOs, specifications, the Sessionize import, and the session-selection decision-support analytics. | +| G19 | **ADC Conference - Infrastructure & Persistence**
group-19-conference-infrastructure.md | 33 | L0-L12 | The Conference module DbContext registration, EF entity configurations, database seeding, and infrastructure services. | +| G20 | **ADC Conference - API, gRPC Contracts & Service Host**
group-20-conference-api-grpc.md | 43 | L0-L12 | Conference REST controllers, the .Contracts gRPC surface, the extractable service host, and the gRPC adapter. | +| G21 | **ADC Conference - UI**
group-21-conference-ui.md | 106 | L0-L10 | The Conference Blazor pages (events, sessions, speakers, categories, questions, rooms, feedback, public, session-selection) and their UI services. | | G22 | **ADC Engagement Module (Session Bookmarks)**
group-22-engagement-module.md | 179 | L0-L13 | The Engagement bounded context end-to-end: bookmark aggregate, use cases, persistence, API/contracts/service, and feedback UI. | -| G26 | **ADC Engagement Live Layer (Real-Time Polls & Session Q&A)**
group-23-engagement-live-layer.md | 94 | L0-L10 | Real-time audience interaction in the Engagement bounded context: event-wide live polls with voting and moderated per-session Q&A with upvoting, over the SignalR hub-channel transport ([ADR-039](https://ivanball.github.io/docs/adr/039-live-channel-push.html)) and the cross-service gRPC live-channel adapter. | -| G23 | **ADC Identity Module (Users, Profiles, GDPR Export/Erasure)**
group-24-identity-module.md | 83 | L0-L12 | The Identity bounded context end-to-end: the User aggregate, change-password/delete/export use cases, persistence, API/contracts/service, and profile/user UI. | +| G26 | **ADC Engagement Live Layer (Real-Time Polls & Session Q&A)**
group-23-engagement-live-layer.md | 95 | L0-L10 | Real-time audience interaction in the Engagement bounded context: event-wide live polls with voting and moderated per-session Q&A with upvoting, over the SignalR hub-channel transport ([ADR-039](https://ivanball.github.io/docs/adr/039-live-channel-push.html)) and the cross-service gRPC live-channel adapter. | +| G23 | **ADC Identity Module (Users, Profiles, GDPR Export/Erasure)**
group-24-identity-module.md | 88 | L0-L17 | The Identity bounded context end-to-end: the User aggregate, change-password/delete/export use cases, persistence, API/contracts/service, and profile/user UI. | | G24 | **ADC Application Host, UI Shell & Cross-Module Composition**
group-25-adc-host-composition.md | 17 | L0-L13 | The ADC host: the Blazor Web/WASM/WinUI shells, host pages/services, security, and the cross-module application composition. | | G27 | **Device Capability Abstraction Layer (Native Contracts, MAUI, Browser & Fallback Adapters)**
group-26-device-capability-layer.md | 96 | L0-L4 | Per-capability interface contracts (biometric, geocoding/geolocation, speech, push registration, media/clipboard/screenshot, haptics, share, external auth/links, local cache/notifications, connectivity/battery/accessibility, deep links) plus their MAUI-native, browser-JS-interop, and inert fallback implementations, selected per host at DI composition time ([ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html)/043/044/045). | -| G25 | **Testing & Quality Infrastructure**
group-27-testing-infrastructure.md | 1780 | L0-L19 | All test projects + the reusable Testing/Testing.E2E/Testing.UI bases, architecture-fitness tests, and the component Gallery harness; individual [Fact]s are rolled up by project (logged exception). | +| G25 | **Testing & Quality Infrastructure**
group-27-testing-infrastructure.md | 1907 | L0-L19 | All test projects + the reusable Testing/Testing.E2E/Testing.UI bases, architecture-fitness tests, and the component Gallery harness; individual [Fact]s are rolled up by project (logged exception). | -**Reconciliation:** 1685 production types across 26 groups + 1780 test/testing types in G25 = **3465** (matches the inventory's distinct-node count). No type appears twice; none dropped. +**Reconciliation:** 1761 production types across 26 groups + 1907 test/testing types in G25 = **3668** (matches the inventory's distinct-node count). No type appears twice; none dropped. --- @@ -150,7 +150,7 @@ disclosure) and is cross-linked in the chapter. ### G03 - Querying: Specifications, Filtering & the Entity Query Service -> `group-03-querying-specifications.md` | 37 types | Composable read-side: the Specification pattern, dynamic filtering/sorting/paging, and the generic entity query pipeline. +> `group-03-querying-specifications.md` | 38 types | Composable read-side: the Specification pattern, dynamic filtering/sorting/paging, and the generic entity query pipeline. | Level | Type | Kind | Namespace | |-------|------|------|-----------| @@ -176,6 +176,7 @@ disclosure) and is cross-linked in the chapter. | 2 | `Specification` | class | MMCA.Common.Domain.Specifications | | 2 | `SpecificationComposer` | class | MMCA.Common.Domain.Specifications | | 3 | `AndSpecification` | class | MMCA.Common.Domain.Specifications | +| 3 | `EventUpcasterRegistry` | class | MMCA.Common.Application.Services | | 3 | `InlineSpecification` | class | MMCA.Common.Domain.Specifications | | 3 | `NotSpecification` | class | MMCA.Common.Domain.Specifications | | 3 | `OrSpecification` | class | MMCA.Common.Domain.Specifications | @@ -210,7 +211,6 @@ disclosure) and is cross-linked in the chapter. | 1 | `IDomainEventHandler` | interface | MMCA.Common.Application.Interfaces | | 1 | `IIntegrationEvent` | interface | MMCA.Common.Domain.Interfaces | | 1 | `NoOpInboxStore` | class | MMCA.Common.Infrastructure.Persistence.Inbox | -| 1 | `OutboxMessage` | class | MMCA.Common.Infrastructure.Persistence.Outbox | | 1 | `OutboxSignal` | class | MMCA.Common.Infrastructure.Persistence.Outbox | | 2 | `BaseIntegrationEvent` | record | MMCA.Common.Domain.DomainEvents | | 2 | `EntityChangedEvent` | record | MMCA.Common.Domain.DomainEvents | @@ -224,16 +224,17 @@ disclosure) and is cross-linked in the chapter. | 3 | `IntegrationEventConsumer` | class | MMCA.Common.Infrastructure.Services | | 3 | `OutputCacheEvictionRequested` | record | MMCA.Common.Domain.IntegrationEvents | | 4 | `IntegrationEventConsumerExtensions` | class | MMCA.Common.Infrastructure.Services | -| 6 | `OutboxFinalizer` | class | MMCA.Common.Infrastructure.Persistence.Outbox | -| 8 | `BrokerEventBus` | class | MMCA.Common.Infrastructure.Services | -| 8 | `EfInboxStore` | class | MMCA.Common.Infrastructure.Persistence.Inbox | -| 8 | `InProcessEventBus` | class | MMCA.Common.Infrastructure.Services | -| 8 | `OutboxCleanupService` | class | MMCA.Common.Infrastructure.Persistence.Outbox | -| 8 | `OutboxProcessor` | class | MMCA.Common.Infrastructure.Persistence.Outbox | +| 9 | `OutboxMessage` | class | MMCA.Common.Infrastructure.Persistence.Outbox | +| 11 | `OutboxFinalizer` | class | MMCA.Common.Infrastructure.Persistence.Outbox | +| 13 | `BrokerEventBus` | class | MMCA.Common.Infrastructure.Services | +| 13 | `EfInboxStore` | class | MMCA.Common.Infrastructure.Persistence.Inbox | +| 13 | `InProcessEventBus` | class | MMCA.Common.Infrastructure.Services | +| 13 | `OutboxCleanupService` | class | MMCA.Common.Infrastructure.Persistence.Outbox | +| 13 | `OutboxProcessor` | class | MMCA.Common.Infrastructure.Persistence.Outbox | ### G05 - CQRS: Commands, Queries & the Decorator Pipeline -> `group-05-cqrs-pipeline.md` | 36 types | The command/query handler abstraction and the cross-cutting decorator pipeline (logging, transaction, caching, feature-gate, idempotency) wrapping it. +> `group-05-cqrs-pipeline.md` | 38 types | The command/query handler abstraction and the cross-cutting decorator pipeline (logging, transaction, caching, feature-gate, idempotency) wrapping it. | Level | Type | Kind | Namespace | |-------|------|------|-----------| @@ -257,6 +258,8 @@ disclosure) and is cross-linked in the chapter. | 1 | `ProfilingQueryDecorator` | class | MMCA.Common.Application.UseCases.Decorators | | 1 | `TenantCacheKey` | class | MMCA.Common.Application.UseCases.Decorators | | 2 | `CacheKeyLocks` | class | MMCA.Common.Application.Interfaces | +| 2 | `IEventUpcaster` | interface | MMCA.Common.Application.Interfaces | +| 2 | `IEventUpcasterRegistry` | interface | MMCA.Common.Application.Interfaces | | 2 | `QueryCacheKeyLocks` | class | MMCA.Common.Application.UseCases.Decorators | | 3 | `LoggingCommandDecorator` | class | MMCA.Common.Application.UseCases.Decorators | | 3 | `LoggingQueryDecorator` | class | MMCA.Common.Application.UseCases.Decorators | @@ -300,7 +303,7 @@ disclosure) and is cross-linked in the chapter. ### G07 - Persistence & EF Core -> `group-07-persistence-ef-core.md` | 116 types | The single SQLServerDbContext over the abstract ApplicationDbContext, interceptors, repositories, specifications evaluation, data-source routing (database-per-service), conventions, value generators, encryption, factories and design-time. +> `group-07-persistence-ef-core.md` | 118 types | The single SQLServerDbContext over the abstract ApplicationDbContext, interceptors, repositories, specifications evaluation, data-source routing (database-per-service), conventions, value generators, encryption, factories and design-time. | Level | Type | Kind | Namespace | |-------|------|------|-----------| @@ -349,13 +352,14 @@ disclosure) and is cross-linked in the chapter. | 2 | `Snapshot` | record | MMCA.Common.Infrastructure.Persistence.DataSources | | 2 | `SoftDeleteUniqueIndexConvention` | class | MMCA.Common.Infrastructure.Persistence.Conventions | | 2 | `TenantDataSourceTarget` | record struct | MMCA.Common.Infrastructure.Persistence.DataSources | -| 3 | `CapturedState` | record | MMCA.Common.Infrastructure.Persistence.Interceptors | | 3 | `CrossDataSourceDegradeConvention` | class | MMCA.Common.Infrastructure.Persistence.Conventions | | 3 | `DataSourceService` | class | MMCA.Common.Infrastructure.Services | +| 3 | `EventUpcasterStartupValidator` | class | MMCA.Common.Infrastructure.Services | | 3 | `IDataSourceResolver` | interface | MMCA.Common.Infrastructure.Persistence.DataSources | | 3 | `IFileStorageService` | interface | MMCA.Common.Application.Interfaces.Infrastructure | | 3 | `IImageProcessor` | interface | MMCA.Common.Application.Interfaces.Infrastructure | | 3 | `IPushDeviceRegistrar` | interface | MMCA.Common.Application.Interfaces.Infrastructure | +| 3 | `UpcastingIntegrationEventConsumer` | class | MMCA.Common.Infrastructure.Services | | 4 | `AzureBlobFileStorageService` | class | MMCA.Common.Infrastructure.Services | | 4 | `AzureNotificationHubDeviceRegistrar` | class | MMCA.Common.Infrastructure.Services | | 4 | `DataSourceResolver` | class | MMCA.Common.Infrastructure.Persistence.DataSources | @@ -382,48 +386,49 @@ disclosure) and is cross-linked in the chapter. | 5 | `NullablePhoneNumberValueConverter` | class | MMCA.Common.Infrastructure.Persistence.Conversions | | 5 | `PhoneNumberValueConverter` | class | MMCA.Common.Infrastructure.Persistence.Conversions | | 5 | `TenantDataSourceTargets` | class | MMCA.Common.Infrastructure.Persistence.DataSources | -| 6 | `ApplicationDbContext` | class | MMCA.Common.Infrastructure.Persistence.DbContexts | -| 6 | `AuditSaveChangesInterceptor` | class | MMCA.Common.Infrastructure.Persistence.Interceptors | -| 6 | `AuditTrailSaveChangesInterceptor` | class | MMCA.Common.Infrastructure.Persistence.AuditTrail | -| 6 | `DataSourceModelCacheKeyFactory` | class | MMCA.Common.Infrastructure.Persistence.DbContexts | -| 6 | `DeferredDispatch` | record | MMCA.Common.Infrastructure.Persistence.Interceptors | -| 6 | `DomainEventSaveChangesInterceptor` | class | MMCA.Common.Infrastructure.Persistence.Interceptors | | 6 | `EFReadRepository` | class | MMCA.Common.Infrastructure.Persistence.Repositories | | 6 | `EFReadRepositoryDecorator` | class | MMCA.Common.Infrastructure.Persistence.Repositories | | 6 | `EntityTypeConfiguration` | class | MMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration | | 6 | `IRepository` | interface | MMCA.Common.Application.Interfaces.Infrastructure | | 6 | `ReadRepositoryExtensions` | class | MMCA.Common.Application.Extensions | -| 6 | `TenantSaveChangesInterceptor` | class | MMCA.Common.Infrastructure.Persistence.Interceptors | -| 7 | `CosmosDbContext` | class | MMCA.Common.Infrastructure.Persistence.DbContexts | | 7 | `EFRepositoryDecorator` | class | MMCA.Common.Infrastructure.Persistence.Repositories | | 7 | `EntityTypeConfigurationCosmos` | class | MMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration | | 7 | `EntityTypeConfigurationSqlite` | class | MMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration | | 7 | `EntityTypeConfigurationSQLServer` | class | MMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration | -| 7 | `IDbContextFactory` | interface | MMCA.Common.Infrastructure.Persistence.DbContexts.Factory | -| 7 | `IPhysicalDbContextFactory` | interface | MMCA.Common.Infrastructure.Persistence.DbContexts.Factory | | 7 | `IRepositoryFactory` | interface | MMCA.Common.Infrastructure.Persistence.Repositories.Factory | | 7 | `IUnitOfWork` | interface | MMCA.Common.Application.Interfaces.Infrastructure | -| 7 | `SqliteDbContext` | class | MMCA.Common.Infrastructure.Persistence.DbContexts | -| 7 | `SQLServerDbContext` | class | MMCA.Common.Infrastructure.Persistence.DbContexts | -| 8 | `ApplicationDbContextEFFactory` | class | MMCA.Common.Infrastructure.Persistence.DbContexts.Factory | -| 8 | `AuditTrailCleanupJob` | class | MMCA.Common.Infrastructure.Persistence.AuditTrail | -| 8 | `AuditTrailReader` | class | MMCA.Common.Infrastructure.Persistence.AuditTrail | -| 8 | `DefaultCosmosDbContextFactory` | class | MMCA.Common.Infrastructure.Persistence.DbContexts.Factory | -| 8 | `DefaultSqliteDbContextFactory` | class | MMCA.Common.Infrastructure.Persistence.DbContexts.Factory | -| 8 | `DefaultSqlServerDbContextFactory` | class | MMCA.Common.Infrastructure.Persistence.DbContexts.Factory | -| 8 | `DesignTimeDbContextHelper` | class | MMCA.Common.Infrastructure.Persistence.DbContexts.Design | -| 8 | `PhysicalDbContextFactory` | class | MMCA.Common.Infrastructure.Persistence.DbContexts.Factory | | 8 | `PushNotificationConfiguration` | class | MMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration.Notifications | -| 8 | `UnitOfWork` | class | MMCA.Common.Infrastructure.Persistence | | 8 | `UserNotificationConfiguration` | class | MMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration.Notifications | -| 9 | `DbContextFactory` | class | MMCA.Common.Infrastructure.Persistence.DbContexts.Factory | -| 9 | `EFRepository` | class | MMCA.Common.Infrastructure.Persistence.Repositories | -| 9 | `IdentityModuleDbSeederBase` | class | MMCA.Common.Infrastructure.Persistence.DbContexts.Seeding | -| 10 | `RepositoryFactory` | class | MMCA.Common.Infrastructure.Persistence.Repositories.Factory | +| 10 | `CapturedState` | record | MMCA.Common.Infrastructure.Persistence.Interceptors | +| 11 | `ApplicationDbContext` | class | MMCA.Common.Infrastructure.Persistence.DbContexts | +| 11 | `AuditSaveChangesInterceptor` | class | MMCA.Common.Infrastructure.Persistence.Interceptors | +| 11 | `AuditTrailSaveChangesInterceptor` | class | MMCA.Common.Infrastructure.Persistence.AuditTrail | +| 11 | `DataSourceModelCacheKeyFactory` | class | MMCA.Common.Infrastructure.Persistence.DbContexts | +| 11 | `DeferredDispatch` | record | MMCA.Common.Infrastructure.Persistence.Interceptors | +| 11 | `DomainEventSaveChangesInterceptor` | class | MMCA.Common.Infrastructure.Persistence.Interceptors | +| 11 | `TenantSaveChangesInterceptor` | class | MMCA.Common.Infrastructure.Persistence.Interceptors | +| 12 | `CosmosDbContext` | class | MMCA.Common.Infrastructure.Persistence.DbContexts | +| 12 | `EFRepository` | class | MMCA.Common.Infrastructure.Persistence.Repositories | +| 12 | `IDbContextFactory` | interface | MMCA.Common.Infrastructure.Persistence.DbContexts.Factory | +| 12 | `IPhysicalDbContextFactory` | interface | MMCA.Common.Infrastructure.Persistence.DbContexts.Factory | +| 12 | `SqliteDbContext` | class | MMCA.Common.Infrastructure.Persistence.DbContexts | +| 12 | `SQLServerDbContext` | class | MMCA.Common.Infrastructure.Persistence.DbContexts | +| 13 | `ApplicationDbContextEFFactory` | class | MMCA.Common.Infrastructure.Persistence.DbContexts.Factory | +| 13 | `AuditTrailCleanupJob` | class | MMCA.Common.Infrastructure.Persistence.AuditTrail | +| 13 | `AuditTrailReader` | class | MMCA.Common.Infrastructure.Persistence.AuditTrail | +| 13 | `DbContextFactory` | class | MMCA.Common.Infrastructure.Persistence.DbContexts.Factory | +| 13 | `DefaultCosmosDbContextFactory` | class | MMCA.Common.Infrastructure.Persistence.DbContexts.Factory | +| 13 | `DefaultSqliteDbContextFactory` | class | MMCA.Common.Infrastructure.Persistence.DbContexts.Factory | +| 13 | `DefaultSqlServerDbContextFactory` | class | MMCA.Common.Infrastructure.Persistence.DbContexts.Factory | +| 13 | `DesignTimeDbContextHelper` | class | MMCA.Common.Infrastructure.Persistence.DbContexts.Design | +| 13 | `PhysicalDbContextFactory` | class | MMCA.Common.Infrastructure.Persistence.DbContexts.Factory | +| 13 | `RepositoryFactory` | class | MMCA.Common.Infrastructure.Persistence.Repositories.Factory | +| 13 | `UnitOfWork` | class | MMCA.Common.Infrastructure.Persistence | +| 14 | `IdentityModuleDbSeederBase` | class | MMCA.Common.Infrastructure.Persistence.DbContexts.Seeding | ### G08 - Authentication & Authorization -> `group-08-auth.md` | 69 types | JWT/JWKS dual-fetch token validation, current-user/claims, password hashing, cookie sessions, and policy/authorization plumbing. +> `group-08-auth.md` | 77 types | JWT/JWKS dual-fetch token validation, current-user/claims, password hashing, cookie sessions, and policy/authorization plumbing. | Level | Type | Kind | Namespace | |-------|------|------|-----------| @@ -435,6 +440,7 @@ disclosure) and is cross-linked in the chapter. | 0 | `ChangePasswordRequest` | record struct | MMCA.Common.Shared.Auth | | 0 | `ChangePreferencesRequest` | record | MMCA.Common.Shared.Auth | | 0 | `ClaimBasedUserIdProvider` | class | MMCA.Common.Infrastructure.Services | +| 0 | `ForgotPasswordRequest` | record struct | MMCA.Common.Shared.Auth | | 0 | `IAuthUser` | interface | MMCA.Common.Domain.Auth | | 0 | `IcsEvent` | record | MMCA.Common.Shared.Calendars | | 0 | `IdempotencyHeaders` | class | MMCA.Common.Shared.Http | @@ -447,17 +453,21 @@ disclosure) and is cross-linked in the chapter. | 0 | `LoginRequest` | record struct | MMCA.Common.Shared.Auth | | 0 | `OAuthCodeExchangeRequest` | record struct | MMCA.Common.Shared.Auth | | 0 | `OwnerOrAdminFilterOptions` | class | MMCA.Common.API.Authorization | +| 0 | `PasswordResetEntry` | record | MMCA.Common.Infrastructure.Auth | +| 0 | `PasswordResetSettings` | class | MMCA.Common.Application.Auth | | 0 | `PermissionPolicy` | class | MMCA.Common.API.Authorization | | 0 | `PermissionRequirement` | class | MMCA.Common.API.Authorization | | 0 | `PrivacyFeatures` | class | MMCA.Common.Shared.Privacy | | 0 | `RefreshTokenRequest` | record struct | MMCA.Common.Shared.Auth | | 0 | `Releaser` | record struct | MMCA.Common.Shared.Concurrency | +| 0 | `ResetPasswordRequest` | record struct | MMCA.Common.Shared.Auth | | 0 | `RoleNames` | class | MMCA.Common.Shared.Auth | | 0 | `SessionCookieRequest` | record | MMCA.Common.API.SessionCookies | | 0 | `SessionTokenResponse` | record | MMCA.Common.API.SessionCookies | | 0 | `SessionTokenResult` | record struct | MMCA.Common.API.SessionCookies | | 0 | `UserDataExportSectionDTO` | record | MMCA.Common.Shared.Privacy | | 0 | `UserPreferencesResponse` | record | MMCA.Common.Shared.Auth | +| 1 | `ForgotPasswordRequestValidator` | class | MMCA.Common.Application.Auth.Validation | | 1 | `HasPermissionAttribute` | class | MMCA.Common.API.Authorization | | 1 | `ICookieSessionRefresher` | interface | MMCA.Common.API.SessionCookies | | 1 | `IcsCalendarBuilder` | class | MMCA.Common.Shared.Calendars | @@ -468,6 +478,7 @@ disclosure) and is cross-linked in the chapter. | 1 | `PermissionPolicyProvider` | class | MMCA.Common.API.Authorization | | 1 | `PermissionRegistry` | class | MMCA.Common.Shared.Auth | | 1 | `RefreshTokenRequestValidator` | class | MMCA.Common.Application.Auth.Validation | +| 1 | `ResetPasswordRequestValidator` | class | MMCA.Common.Application.Auth.Validation | | 1 | `RsaJwksProvider` | class | MMCA.Common.Infrastructure.Auth | | 1 | `UserDataExportDTO` | record | MMCA.Common.Shared.Privacy | | 2 | `CookieSessionRefreshMiddleware` | class | MMCA.Common.API.SessionCookies | @@ -480,6 +491,7 @@ disclosure) and is cross-linked in the chapter. | 3 | `CookieTokenReader` | class | MMCA.Common.API.SessionCookies | | 3 | `ILoginProtectionService` | interface | MMCA.Common.Application.Auth | | 3 | `IPasswordChangeableUser` | interface | MMCA.Common.Domain.Auth | +| 3 | `IPasswordResetTokenService` | interface | MMCA.Common.Application.Auth | | 3 | `IUserPreferences` | interface | MMCA.Common.Domain.Auth | | 3 | `RoleValue` | class | MMCA.Common.Shared.Auth | | 4 | `CookieSessionRefresher` | class | MMCA.Common.API.SessionCookies | @@ -490,6 +502,7 @@ disclosure) and is cross-linked in the chapter. | 5 | `AuthenticationValidators` | class | MMCA.Common.Application.Auth | | 5 | `IAuthenticationService` | interface | MMCA.Common.Application.Auth | | 5 | `LoginProtectionService` | class | MMCA.Common.Infrastructure.Auth | +| 5 | `PasswordResetTokenService` | class | MMCA.Common.Infrastructure.Auth | | 5 | `SessionCookieAuthenticationExtensions` | class | MMCA.Common.API.SessionCookies | | 8 | `AuthenticationServiceBase` | class | MMCA.Common.Application.Auth | | 8 | `ICurrentUserService` | interface | MMCA.Common.Application.Interfaces.Infrastructure | @@ -595,7 +608,7 @@ disclosure) and is cross-linked in the chapter. ### G12 - API Hosting, Middleware, Idempotency & DTO/Contract Mapping -> `group-12-api-hosting-mapping.md` | 74 types | The ASP.NET Core edge: controller bases, middleware, startup, model binders, JSON converters, feature management, idempotency, correlation, and manual DTO/request mapping. +> `group-12-api-hosting-mapping.md` | 79 types | The ASP.NET Core edge: controller bases, middleware, startup, model binders, JSON converters, feature management, idempotency, correlation, and manual DTO/request mapping. | Level | Type | Kind | Namespace | |-------|------|------|-----------| @@ -619,6 +632,8 @@ disclosure) and is cross-linked in the chapter. | 0 | `IdempotencySettings` | class | MMCA.Common.API.Idempotency | | 0 | `IErrorLocalizer` | interface | MMCA.Common.API.Localization | | 0 | `JwtForwardingDelegatingHandler` | class | MMCA.Common.Infrastructure.Http | +| 0 | `MiddlewarePipelineStep` | record | MMCA.Common.API.Startup | +| 0 | `MiddlewarePipelineStepNames` | class | MMCA.Common.API.Startup | | 0 | `NonIdempotentAttribute` | class | MMCA.Common.API.Idempotency | | 0 | `OpenApiEndpointExtensions` | class | MMCA.Common.API.Startup | | 0 | `OperationCanceledExceptionHandler` | class | MMCA.Common.API.Middleware | @@ -635,7 +650,6 @@ disclosure) and is cross-linked in the chapter. | 1 | `BaseLookup` | record | MMCA.Common.Shared.DTOs | | 1 | `ConcurrencyTokenRequest` | record | MMCA.Common.Shared.DTOs | | 1 | `CorrelationContext` | class | MMCA.Common.Infrastructure.Services | -| 1 | `CorrelationIdMiddleware` | class | MMCA.Common.API.Middleware | | 1 | `DomainExceptionHandler` | class | MMCA.Common.API.Middleware | | 1 | `ErrorLocalizer` | class | MMCA.Common.API.Localization | | 1 | `JwksEndpointExtensions` | class | MMCA.Common.API.Startup | @@ -651,6 +665,7 @@ disclosure) and is cross-linked in the chapter. | 2 | `OidcDiscoveryEndpointExtensions` | class | MMCA.Common.API.Startup | | 3 | `ApiControllerBase` | class | MMCA.Common.API.Controllers | | 3 | `IAggregateRootEntityControllerBase` | interface | MMCA.Common.API.Controllers | +| 3 | `InsecureJwtMetadataWarningStartupFilter` | class | MMCA.Common.API.Startup | | 3 | `SignalRExtensions` | class | MMCA.Common.API.Startup | | 3 | `TenantResolutionMiddleware` | class | MMCA.Common.API.Middleware | | 3 | `UnhandledResultFailureFilter` | class | MMCA.Common.API.Middleware | @@ -666,13 +681,16 @@ disclosure) and is cross-linked in the chapter. | 6 | `OAuthControllerBase` | class | MMCA.Common.API.Controllers | | 7 | `AggregateRootEntityControllerBase` | class | MMCA.Common.API.Controllers | | 8 | `CurrentUserTargetingContextAccessor` | class | MMCA.Common.API.FeatureManagement | -| 8 | `DatabaseInitializationExtensions` | class | MMCA.Common.API.Startup | +| 9 | `CorrelationIdMiddleware` | class | MMCA.Common.API.Middleware | | 9 | `SoftDeletedUserMiddleware` | class | MMCA.Common.API.Middleware | -| 10 | `AuthControllerBase` | class | MMCA.Common.API.Controllers | | 10 | `DataExportControllerBase` | class | MMCA.Common.API.Controllers.Privacy | +| 10 | `MiddlewarePipelineBuilder` | class | MMCA.Common.API.Startup | | 10 | `WebApplicationExtensions` | class | MMCA.Common.API.Startup | | 11 | `DependencyInjection` | class | MMCA.Common.API | -| 11 | `UserAccountAuthControllerBase` | class | MMCA.Common.API.Controllers | +| 13 | `DatabaseInitializationExtensions` | class | MMCA.Common.API.Startup | +| 15 | `AuthControllerBase` | class | MMCA.Common.API.Controllers | +| 15 | `PasswordResetAuthControllerBase` | class | MMCA.Common.API.Controllers | +| 16 | `UserAccountAuthControllerBase` | class | MMCA.Common.API.Controllers | ### G13 - gRPC & Inter-Service Contracts @@ -689,7 +707,7 @@ disclosure) and is cross-linked in the chapter. ### G14 - Module System, Composition & Configuration -> `group-14-module-system-composition.md` | 68 types | IModule discovery + Kahn-ordered ModuleLoader, the DI composition roots, assembly markers, data-source/database attributes, and options/settings binding. +> `group-14-module-system-composition.md` | 70 types | IModule discovery + Kahn-ordered ModuleLoader, the DI composition roots, assembly markers, data-source/database attributes, and options/settings binding. | Level | Type | Kind | Namespace | |-------|------|------|-----------| @@ -756,15 +774,17 @@ disclosure) and is cross-linked in the chapter. | 8 | `ChangePreferencesHandlerBase` | class | MMCA.Common.Application.Users.UseCases.ChangePreferences | | 8 | `DeleteUserHandlerBase` | class | MMCA.Common.Application.Users.UseCases.DeleteUser | | 8 | `ExportUserDataHandlerBase` | class | MMCA.Common.Application.Users.UseCases.ExportUserData | +| 8 | `ForgotPasswordHandlerBase` | class | MMCA.Common.Application.Users.UseCases.ForgotPassword | | 8 | `GetUserPreferencesHandlerBase` | class | MMCA.Common.Application.Users.UseCases.GetPreferences | -| 8 | `ScheduledJobRunner` | class | MMCA.Common.Infrastructure.Scheduling | +| 8 | `ResetPasswordHandlerBase` | class | MMCA.Common.Application.Users.UseCases.ResetPassword | | 8 | `SoftDeletedUserValidator` | class | MMCA.Common.Application.Users | | 10 | `DependencyInjection` | class | MMCA.Common.Application | -| 11 | `DependencyInjection` | class | MMCA.Common.Infrastructure | +| 13 | `ScheduledJobRunner` | class | MMCA.Common.Infrastructure.Scheduling | +| 14 | `DependencyInjection` | class | MMCA.Common.Infrastructure | ### G15 - Common UI Framework (MudBlazor components, theme, base pages) -> `group-15-common-ui-framework.md` | 89 types | Reusable Blazor building blocks: the data-grid list page base, theme, common pages/services, and UI extensions shared by every consumer app. +> `group-15-common-ui-framework.md` | 91 types | Reusable Blazor building blocks: the data-grid list page base, theme, common pages/services, and UI extensions shared by every consumer app. | Level | Type | Kind | Namespace | |-------|------|------|-----------| @@ -773,6 +793,7 @@ disclosure) and is cross-linked in the chapter. | 0 | `BreakpointConstants` | class | MMCA.Common.UI.Common | | 0 | `ChannelReferenceCounter` | class | MMCA.Common.UI.Services.Notifications | | 0 | `CultureDelegatingHandler` | class | MMCA.Common.UI.Services | +| 0 | `ForgotPasswordModel` | class | MMCA.Common.UI.Pages.Auth | | 0 | `IApiSettings` | interface | MMCA.Common.UI.Common.Settings | | 0 | `ICultureApplier` | interface | MMCA.Common.UI.Services | | 0 | `IHomePageContent` | interface | MMCA.Common.UI.Common.Interfaces | @@ -796,6 +817,7 @@ disclosure) and is cross-linked in the chapter. | 0 | `PseudoLocalizer` | class | MMCA.Common.UI.Globalization | | 0 | `QrErrorCorrectionLevel` | enum | MMCA.Common.UI.Components | | 0 | `RegisterModel` | class | MMCA.Common.UI.Pages.Auth | +| 0 | `ResetPasswordModel` | class | MMCA.Common.UI.Pages.Auth | | 0 | `ReturnUrlProtector` | class | MMCA.Common.UI.Services.Navigation | | 0 | `RoutePaths` | class | MMCA.Common.UI.Common | | 0 | `SharedResource` | class | MMCA.Common.UI.Resources | @@ -869,7 +891,6 @@ disclosure) and is cross-linked in the chapter. | 0 | `DataProtectionExtensions` | class | MMCA.Common.Aspire | | 0 | `DownstreamServiceHealthCheck` | class | MMCA.Common.Aspire.Gateway | | 0 | `Extensions` | class | MMCA.Common.Aspire.Hosting | -| 0 | `GatewayCorrelationMiddleware` | class | MMCA.Common.Aspire.Gateway | | 0 | `GatewayCorsExtensions` | class | MMCA.Common.Aspire | | 0 | `GatewayDownstreamRegistry` | class | MMCA.Common.Aspire.Gateway | | 0 | `GatewayRateLimitingSettings` | class | MMCA.Common.Aspire.Gateway | @@ -879,10 +900,8 @@ disclosure) and is cross-linked in the chapter. | 0 | `IWarmupTask` | interface | MMCA.Common.Aspire.Warmup | | 0 | `KestrelListenerSpec` | record | MMCA.Common.Aspire.Kestrel | | 0 | `KeyVaultConfigurationExtensions` | class | MMCA.Common.Aspire | -| 0 | `OutboxPollFilterProcessor` | class | MMCA.Common.Aspire.Telemetry | | 0 | `SecurityHeadersSettings` | class | MMCA.Common.Aspire.Security | | 0 | `WarmupReadinessGate` | class | MMCA.Common.Aspire.Warmup | -| 1 | `GatewayCorrelationExtensions` | class | MMCA.Common.Aspire.Gateway | | 1 | `GatewayHealthCheckExtensions` | class | MMCA.Common.Aspire.Gateway | | 1 | `GatewayRateLimitingExtensions` | class | MMCA.Common.Aspire.Gateway | | 1 | `ICspPolicyProvider` | interface | MMCA.Common.Aspire.Security | @@ -891,14 +910,17 @@ disclosure) and is cross-linked in the chapter. | 1 | `SelfHttpWarmupTaskBase` | class | MMCA.Common.Aspire.Warmup | | 1 | `WarmupHostedService` | class | MMCA.Common.Aspire.Warmup | | 1 | `WarmupReadinessHealthCheck` | class | MMCA.Common.Aspire.Warmup | -| 2 | `Extensions` | class | MMCA.Common.Aspire | | 2 | `SecurityHeadersMiddleware` | class | MMCA.Common.Aspire.Security | | 2 | `StaticCspPolicyProvider` | class | MMCA.Common.Aspire.Security | | 3 | `SecurityHeadersExtensions` | class | MMCA.Common.Aspire.Security | +| 9 | `GatewayCorrelationMiddleware` | class | MMCA.Common.Aspire.Gateway | +| 9 | `OutboxPollFilterProcessor` | class | MMCA.Common.Aspire.Telemetry | +| 10 | `Extensions` | class | MMCA.Common.Aspire | +| 10 | `GatewayCorrelationExtensions` | class | MMCA.Common.Aspire.Gateway | ### G17 - ADC Conference - Domain Model & Module Contracts -> `group-17-conference-domain.md` | 96 types | The Conference bounded context: Event/Session/Speaker/Category/Question aggregates, their domain events and invariants, plus the Shared identifiers/DTOs/integration-event contracts. +> `group-17-conference-domain.md` | 100 types | The Conference bounded context: Event/Session/Speaker/Category/Question aggregates, their domain events and invariants, plus the Shared identifiers/DTOs/integration-event contracts. | Level | Type | Kind | Namespace | |-------|------|------|-----------| @@ -923,6 +945,7 @@ disclosure) and is cross-linked in the chapter. | 0 | `SponsorLiveInfo` | record | MMCA.ADC.Conference.Shared.Events | | 0 | `SponsorTier` | enum | MMCA.ADC.Conference.Shared.Sponsors | | 0 | `TextQuestionResponses` | record | MMCA.ADC.Conference.Shared.Speakers | +| 1 | `ActivityDTO` | record | MMCA.ADC.Conference.Shared.Activities | | 1 | `CategoryGroupDistribution` | record | MMCA.ADC.Conference.Shared.Sessions.DecisionSupport | | 1 | `CategoryItemDTO` | record | MMCA.ADC.Conference.Shared.Categories | | 1 | `ConferenceReadAudience` | class | MMCA.ADC.Conference.Shared.Authorization | @@ -957,6 +980,7 @@ disclosure) and is cross-linked in the chapter. | 2 | `SpeakerDTO` | record | MMCA.ADC.Conference.Shared.Speakers | | 2 | `SpeakerQuestionAnswerChanged` | record | MMCA.ADC.Conference.Domain.Speakers.DomainEvents | | 2 | `SpeakerSessionOverlapDTO` | record | MMCA.ADC.Conference.Shared.Sessions.DecisionSupport | +| 3 | `ActivityChanged` | record | MMCA.ADC.Conference.Domain.Activities.DomainEvents | | 3 | `CategoryChanged` | record | MMCA.ADC.Conference.Domain.Categories.DomainEvents | | 3 | `EventChanged` | record | MMCA.ADC.Conference.Domain.Events.DomainEvents | | 3 | `EventFeedbackSubmitted` | record | MMCA.ADC.Conference.Shared.Events.IntegrationEvents | @@ -973,6 +997,7 @@ disclosure) and is cross-linked in the chapter. | 4 | `DisabledEventLiveValidationService` | class | MMCA.ADC.Conference.Shared.Events | | 4 | `DisabledSessionBookmarkValidationService` | class | MMCA.ADC.Conference.Shared.Sessions | | 5 | `SessionAiScore` | class | MMCA.ADC.Conference.Domain.Sessions | +| 6 | `ActivityInvariants` | class | MMCA.ADC.Conference.Domain.Activities | | 6 | `Category` | class | MMCA.ADC.Conference.Domain.Categories | | 6 | `CategoryInvariants` | class | MMCA.ADC.Conference.Domain.Categories | | 6 | `CategoryItem` | class | MMCA.ADC.Conference.Domain.Categories | @@ -989,6 +1014,7 @@ disclosure) and is cross-linked in the chapter. | 7 | `Speaker` | class | MMCA.ADC.Conference.Domain.Speakers | | 7 | `SpeakerCategoryItem` | class | MMCA.ADC.Conference.Domain.Speakers | | 7 | `SpeakerQuestionAnswer` | class | MMCA.ADC.Conference.Domain.Speakers | +| 8 | `Activity` | class | MMCA.ADC.Conference.Domain.Activities | | 8 | `CurrentEventSelector` | class | MMCA.ADC.Conference.Shared.Events | | 8 | `Session` | class | MMCA.ADC.Conference.Domain.Sessions | | 8 | `SessionCategoryItem` | class | MMCA.ADC.Conference.Domain.Sessions | @@ -1001,10 +1027,13 @@ disclosure) and is cross-linked in the chapter. ### G18 - ADC Conference - Application & Use Cases -> `group-18-conference-application.md` | 252 types | Conference CQRS handlers, validators, DTOs, specifications, the Sessionize import, and the session-selection decision-support analytics. +> `group-18-conference-application.md` | 285 types | Conference CQRS handlers, validators, DTOs, specifications, the Sessionize import, and the session-selection decision-support analytics. | Level | Type | Kind | Namespace | |-------|------|------|-----------| +| 0 | `ActivityEventIdRules` | class | MMCA.ADC.Conference.Application.Activities.Validation | +| 0 | `ActivitySortOrderRules` | class | MMCA.ADC.Conference.Application.Activities.Validation | +| 0 | `ActivityTimeRangeRules` | class | MMCA.ADC.Conference.Application.Activities.Validation | | 0 | `AssemblyReference` | class | MMCA.ADC.Conference.Application | | 0 | `CategoryItemSortRules` | class | MMCA.ADC.Conference.Application.Categories.Validation | | 0 | `ClassReference` | class | MMCA.ADC.Conference.Application | @@ -1013,7 +1042,9 @@ disclosure) and is cross-linked in the chapter. | 0 | `ExportSessionCalendarQuery` | record | MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar | | 0 | `GetCategoryDistributionQuery` | record | MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetCategoryDistribution | | 0 | `GetContentSimilarityQuery` | record | MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetContentSimilarity | +| 0 | `GetPublicActivityFilterQuery` | record | MMCA.ADC.Conference.Application.Activities.UseCases.GetPublicActivityFilter | | 0 | `GetPublicEventSpeakerFilterQuery` | record | MMCA.ADC.Conference.Application.Events.UseCases.GetPublicEventSpeakerFilter | +| 0 | `GetPublicRoomFilterQuery` | record | MMCA.ADC.Conference.Application.Events.UseCases.GetPublicRoomFilter | | 0 | `GetPublicSessionCategoryItemFilterQuery` | record | MMCA.ADC.Conference.Application.Sessions.UseCases.GetPublicSessionCategoryItemFilter | | 0 | `GetPublicSessionFilterQuery` | record | MMCA.ADC.Conference.Application.Sessions.UseCases.GetPublicSessionFilter | | 0 | `GetPublicSessionSpeakerFilterQuery` | record | MMCA.ADC.Conference.Application.Sessions.UseCases.GetPublicSessionSpeakerFilter | @@ -1045,8 +1076,9 @@ disclosure) and is cross-linked in the chapter. | 0 | `SpeakerInfo` | record | MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions | | 0 | `SponsorEventIdRules` | class | MMCA.ADC.Conference.Application.Sponsors.Validation | | 0 | `SponsorSortRules` | class | MMCA.ADC.Conference.Application.Sponsors.Validation | -| 0 | `StatusBucket` | enum | MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetCategoryDistribution | | 0 | `StatusBucket` | enum | MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetSessionSelectionDashboard | +| 0 | `StatusBucket` | enum | MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetCategoryDistribution | +| 1 | `ActivityUpdateRequest` | record | MMCA.ADC.Conference.Application.Activities.UseCases.Update | | 1 | `ConferenceCategoryUpdateRequest` | record | MMCA.ADC.Conference.Application.Categories.UseCases.Update | | 1 | `EventUpdateRequest` | record | MMCA.ADC.Conference.Application.Events.UseCases.Update | | 1 | `ISessionScoringQueue` | interface | MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions | @@ -1068,6 +1100,11 @@ disclosure) and is cross-linked in the chapter. | 3 | `UpdateSessionResult` | record | MMCA.ADC.Conference.Application.Sessions.UseCases.Update | | 4 | `SessionCreatedHandler` | class | MMCA.ADC.Conference.Application.Sessions.DomainEventHandlers | | 4 | `SpeakerDeletedHandler` | class | MMCA.ADC.Conference.Application.Speakers.DomainEventHandlers | +| 7 | `ActivityDescriptionRules` | class | MMCA.ADC.Conference.Application.Activities.Validation | +| 7 | `ActivityNameRules` | class | MMCA.ADC.Conference.Application.Activities.Validation | +| 7 | `ActivityVenueAddressRules` | class | MMCA.ADC.Conference.Application.Activities.Validation | +| 7 | `ActivityVenueNameRules` | class | MMCA.ADC.Conference.Application.Activities.Validation | +| 7 | `ActivityVenueUrlRules` | class | MMCA.ADC.Conference.Application.Activities.Validation | | 7 | `AddCategoryItemCommand` | record | MMCA.ADC.Conference.Application.Categories.UseCases.AddCategoryItem | | 7 | `CategoryItemDTOMapper` | class | MMCA.ADC.Conference.Application.Categories.DTOs | | 7 | `CategoryItemNameRules` | class | MMCA.ADC.Conference.Application.Categories.Validation | @@ -1076,6 +1113,7 @@ disclosure) and is cross-linked in the chapter. | 7 | `EventNameRules` | class | MMCA.ADC.Conference.Application.Events.Validation | | 7 | `EventOrganizerContactEmailRules` | class | MMCA.ADC.Conference.Application.Events.Validation | | 7 | `EventSponsorshipPacketUrlRules` | class | MMCA.ADC.Conference.Application.Events.Validation | +| 7 | `EventTicketingUrlRules` | class | MMCA.ADC.Conference.Application.Events.Validation | | 7 | `EventTimeZoneRules` | class | MMCA.ADC.Conference.Application.Events.Validation | | 7 | `QuestionTextRules` | class | MMCA.ADC.Conference.Application.Questions.Validation | | 7 | `RemoveCategoryItemCommand` | record | MMCA.ADC.Conference.Application.Categories.UseCases.RemoveCategoryItem | @@ -1101,6 +1139,7 @@ disclosure) and is cross-linked in the chapter. | 7 | `SponsorWebsiteUrlRules` | class | MMCA.ADC.Conference.Application.Sponsors.Validation | | 7 | `UpdateCategoryItemCommand` | record | MMCA.ADC.Conference.Application.Categories.UseCases.UpdateCategoryItem | | 7 | `UpdateConferenceCategoryCommand` | record | MMCA.ADC.Conference.Application.Categories.UseCases.Update | +| 8 | `ActivityUpdateRequestValidator` | class | MMCA.ADC.Conference.Application.Activities.UseCases.Update | | 8 | `AddCategoryItemCommandValidator` | class | MMCA.ADC.Conference.Application.Categories.UseCases.AddCategoryItem | | 8 | `AddCategoryItemHandler` | class | MMCA.ADC.Conference.Application.Categories.UseCases.AddCategoryItem | | 8 | `AddEventQuestionAnswerCommand` | record | MMCA.ADC.Conference.Application.Events.UseCases.AddEventQuestionAnswer | @@ -1146,6 +1185,8 @@ disclosure) and is cross-linked in the chapter. | 8 | `UpdateRoomCommand` | record | MMCA.ADC.Conference.Application.Events.UseCases.UpdateRoom | | 8 | `UpdateSpeakerCommand` | record | MMCA.ADC.Conference.Application.Speakers.UseCases.Update | | 8 | `UserRegisteredHandler` | class | MMCA.ADC.Conference.Application.Users.IntegrationEventHandlers | +| 9 | `ActivityCreateRequest` | record | MMCA.ADC.Conference.Application.Activities.UseCases.Create | +| 9 | `ActivityDTOMapper` | class | MMCA.ADC.Conference.Application.Activities.DTOs | | 9 | `AddEventQuestionAnswerCommandValidator` | class | MMCA.ADC.Conference.Application.Events.UseCases.AddEventQuestionAnswer | | 9 | `AddEventQuestionAnswerHandler` | class | MMCA.ADC.Conference.Application.Events.UseCases.AddEventQuestionAnswer | | 9 | `AddEventSpeakerCommandValidator` | class | MMCA.ADC.Conference.Application.Events.UseCases.AddEventSpeaker | @@ -1201,6 +1242,7 @@ disclosure) and is cross-linked in the chapter. | 9 | `SponsorDTOMapper` | class | MMCA.ADC.Conference.Application.Sponsors.DTOs | | 9 | `UnlinkUserFromSpeakerHandler` | class | MMCA.ADC.Conference.Application.Speakers.UseCases.UnlinkUser | | 9 | `UnpublishEventHandler` | class | MMCA.ADC.Conference.Application.Events.UseCases.Unpublish | +| 9 | `UpdateActivityCommand` | record | MMCA.ADC.Conference.Application.Activities.UseCases.Update | | 9 | `UpdateConferenceCategoryHandler` | class | MMCA.ADC.Conference.Application.Categories.UseCases.Update | | 9 | `UpdateEventQuestionAnswerHandler` | class | MMCA.ADC.Conference.Application.Events.UseCases.UpdateEventQuestionAnswer | | 9 | `UpdateQuestionHandler` | class | MMCA.ADC.Conference.Application.Questions.UseCases.Update | @@ -1209,20 +1251,27 @@ disclosure) and is cross-linked in the chapter. | 9 | `UpdateSessionCommand` | record | MMCA.ADC.Conference.Application.Sessions.UseCases.Update | | 9 | `UpdateSessionQuestionAnswerCommand` | record | MMCA.ADC.Conference.Application.Sessions.UseCases.UpdateSessionQuestionAnswer | | 9 | `UpdateSponsorCommand` | record | MMCA.ADC.Conference.Application.Sponsors.UseCases.Update | +| 10 | `ActivityCreateRequestMapper` | class | MMCA.ADC.Conference.Application.Activities.UseCases.Create | +| 10 | `ActivityCreateRequestValidator` | class | MMCA.ADC.Conference.Application.Activities.UseCases.Create | +| 10 | `ActivityNavigationPopulator` | class | MMCA.ADC.Conference.Application.Activities | | 10 | `AddSessionCategoryItemCommandValidator` | class | MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionCategoryItem | | 10 | `AddSessionCategoryItemHandler` | class | MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionCategoryItem | | 10 | `AddSessionQuestionAnswerCommandValidator` | class | MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionQuestionAnswer | | 10 | `AddSessionQuestionAnswerHandler` | class | MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionQuestionAnswer | | 10 | `AddSessionSpeakerCommandValidator` | class | MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionSpeaker | | 10 | `AddSessionSpeakerHandler` | class | MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionSpeaker | +| 10 | `CategoryItemNavigationPopulator` | class | MMCA.ADC.Conference.Application.Categories | | 10 | `CategorySyncStrategy` | class | MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize | | 10 | `ConferenceCategoryNavigationPopulator` | class | MMCA.ADC.Conference.Application.Categories | +| 10 | `CreateActivityHandler` | class | MMCA.ADC.Conference.Application.Activities.UseCases.Create | | 10 | `CreateEventHandler` | class | MMCA.ADC.Conference.Application.Events.UseCases.Create | | 10 | `CreateSpeakerHandler` | class | MMCA.ADC.Conference.Application.Speakers.UseCases.Create | | 10 | `CreateSponsorHandler` | class | MMCA.ADC.Conference.Application.Sponsors.UseCases.Create | | 10 | `DeleteEventHandler` | class | MMCA.ADC.Conference.Application.Events.UseCases.Delete | | 10 | `EventLiveValidationService` | class | MMCA.ADC.Conference.Application.Events | | 10 | `EventNavigationPopulator` | class | MMCA.ADC.Conference.Application.Events | +| 10 | `EventQuestionAnswerNavigationPopulator` | class | MMCA.ADC.Conference.Application.Events | +| 10 | `EventSpeakerNavigationPopulator` | class | MMCA.ADC.Conference.Application.Events | | 10 | `ExportEventCalendarHandler` | class | MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar | | 10 | `ExportSessionCalendarHandler` | class | MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar | | 10 | `GetNowNextHandler` | class | MMCA.ADC.Conference.Application.Sessions.UseCases.NowNext | @@ -1232,35 +1281,45 @@ disclosure) and is cross-linked in the chapter. | 10 | `RemoveSessionCategoryItemHandler` | class | MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionCategoryItem | | 10 | `RemoveSessionQuestionAnswerHandler` | class | MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionQuestionAnswer | | 10 | `RemoveSessionSpeakerHandler` | class | MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionSpeaker | +| 10 | `RoomNavigationPopulator` | class | MMCA.ADC.Conference.Application.Events | | 10 | `RoomSyncStrategy` | class | MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize | +| 10 | `SessionCategoryItemNavigationPopulator` | class | MMCA.ADC.Conference.Application.Sessions | | 10 | `SessionCreateRequestMapper` | class | MMCA.ADC.Conference.Application.Sessions.UseCases.Create | | 10 | `SessionCreateRequestValidator` | class | MMCA.ADC.Conference.Application.Sessions.UseCases.Create | | 10 | `SessionDTOMapper` | class | MMCA.ADC.Conference.Application.Sessions.DTOs | | 10 | `SessionNavigationPopulator` | class | MMCA.ADC.Conference.Application.Sessions | +| 10 | `SessionQuestionAnswerNavigationPopulator` | class | MMCA.ADC.Conference.Application.Sessions | +| 10 | `SessionSpeakerNavigationPopulator` | class | MMCA.ADC.Conference.Application.Sessions | | 10 | `SessionSyncStrategy` | class | MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize | +| 10 | `SpeakerCategoryItemNavigationPopulator` | class | MMCA.ADC.Conference.Application.Speakers | | 10 | `SpeakerEntityQueryService` | class | MMCA.ADC.Conference.Application.Speakers | | 10 | `SpeakerNavigationPopulator` | class | MMCA.ADC.Conference.Application.Speakers | +| 10 | `SpeakerQuestionAnswerNavigationPopulator` | class | MMCA.ADC.Conference.Application.Speakers | | 10 | `SpeakerSyncStrategy` | class | MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize | | 10 | `SponsorCreateRequestMapper` | class | MMCA.ADC.Conference.Application.Sponsors.UseCases.Create | | 10 | `SponsorCreateRequestValidator` | class | MMCA.ADC.Conference.Application.Sponsors.UseCases.Create | +| 10 | `SponsorNavigationPopulator` | class | MMCA.ADC.Conference.Application.Sponsors | +| 10 | `UpdateActivityHandler` | class | MMCA.ADC.Conference.Application.Activities.UseCases.Update | | 10 | `UpdateEventHandler` | class | MMCA.ADC.Conference.Application.Events.UseCases.Update | | 10 | `UpdateSessionQuestionAnswerHandler` | class | MMCA.ADC.Conference.Application.Sessions.UseCases.UpdateSessionQuestionAnswer | | 10 | `UpdateSpeakerHandler` | class | MMCA.ADC.Conference.Application.Speakers.UseCases.Update | | 10 | `UpdateSponsorHandler` | class | MMCA.ADC.Conference.Application.Sponsors.UseCases.Update | | 11 | `CreateSessionHandler` | class | MMCA.ADC.Conference.Application.Sessions.UseCases.Create | | 11 | `DependencyInjection` | class | MMCA.ADC.Conference.Application | +| 11 | `GetPublicActivityFilterHandler` | class | MMCA.ADC.Conference.Application.Activities.UseCases.GetPublicActivityFilter | | 11 | `GetPublicEventSpeakerFilterHandler` | class | MMCA.ADC.Conference.Application.Events.UseCases.GetPublicEventSpeakerFilter | +| 11 | `GetPublicRoomFilterHandler` | class | MMCA.ADC.Conference.Application.Events.UseCases.GetPublicRoomFilter | | 11 | `GetPublicSessionCategoryItemFilterHandler` | class | MMCA.ADC.Conference.Application.Sessions.UseCases.GetPublicSessionCategoryItemFilter | | 11 | `GetPublicSessionSpeakerFilterHandler` | class | MMCA.ADC.Conference.Application.Sessions.UseCases.GetPublicSessionSpeakerFilter | | 11 | `GetPublicSpeakerCategoryItemFilterHandler` | class | MMCA.ADC.Conference.Application.Speakers.UseCases.GetPublicSpeakerCategoryItemFilter | | 11 | `GetPublicSpeakerFilterHandler` | class | MMCA.ADC.Conference.Application.Speakers.UseCases.GetPublicSpeakerFilter | | 11 | `GetPublicSponsorFilterHandler` | class | MMCA.ADC.Conference.Application.Sponsors.UseCases.GetPublicSponsorFilter | -| 11 | `RefreshFromSessionizeHandler` | class | MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize | | 11 | `UpdateSessionHandler` | class | MMCA.ADC.Conference.Application.Sessions.UseCases.Update | +| 14 | `RefreshFromSessionizeHandler` | class | MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize | ### G19 - ADC Conference - Infrastructure & Persistence -> `group-19-conference-infrastructure.md` | 32 types | The Conference module DbContext registration, EF entity configurations, database seeding, and infrastructure services. +> `group-19-conference-infrastructure.md` | 33 types | The Conference module DbContext registration, EF entity configurations, database seeding, and infrastructure services. | Level | Type | Kind | Namespace | |-------|------|------|-----------| @@ -1287,8 +1346,8 @@ disclosure) and is cross-linked in the chapter. | 8 | `SpeakerCategoryItemConfiguration` | class | MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration | | 8 | `SpeakerConfiguration` | class | MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration | | 8 | `SpeakerQuestionAnswerConfiguration` | class | MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration | +| 9 | `ActivityConfiguration` | class | MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration | | 9 | `ConferenceModuleDbSeeder` | class | MMCA.ADC.Conference.Infrastructure.Persistence.DbContexts.Seeding | -| 9 | `ModuleApplicationDbContext` | class | MMCA.ADC.Conference.Infrastructure.Persistence.DbContexts | | 9 | `SessionCategoryItemConfiguration` | class | MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration | | 9 | `SessionConfiguration` | class | MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration | | 9 | `SessionQuestionAnswerConfiguration` | class | MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration | @@ -1296,10 +1355,11 @@ disclosure) and is cross-linked in the chapter. | 9 | `SessionSpeakerConfiguration` | class | MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration | | 9 | `SponsorConfiguration` | class | MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration | | 10 | `DependencyInjection` | class | MMCA.ADC.Conference.Infrastructure | +| 12 | `ModuleApplicationDbContext` | class | MMCA.ADC.Conference.Infrastructure.Persistence.DbContexts | ### G20 - ADC Conference - API, gRPC Contracts & Service Host -> `group-20-conference-api-grpc.md` | 42 types | Conference REST controllers, the .Contracts gRPC surface, the extractable service host, and the gRPC adapter. +> `group-20-conference-api-grpc.md` | 43 types | Conference REST controllers, the .Contracts gRPC surface, the extractable service host, and the gRPC adapter. | Level | Type | Kind | Namespace | |-------|------|------|-----------| @@ -1333,6 +1393,7 @@ disclosure) and is cross-linked in the chapter. | 9 | `RoomsController` | class | MMCA.ADC.Conference.API.Controllers | | 9 | `SpeakerCategoryItemsController` | class | MMCA.ADC.Conference.API.Controllers | | 9 | `SpeakersController` | class | MMCA.ADC.Conference.API.Controllers | +| 10 | `ActivitiesController` | class | MMCA.ADC.Conference.API.Controllers | | 10 | `ConferenceModuleSeeder` | class | MMCA.ADC.Conference.API | | 10 | `EventsController` | class | MMCA.ADC.Conference.API.Controllers | | 10 | `SessionBookmarksGrpcService` | class | MMCA.ADC.Conference.Service.Grpc | @@ -1348,7 +1409,7 @@ disclosure) and is cross-linked in the chapter. ### G21 - ADC Conference - UI -> `group-21-conference-ui.md` | 97 types | The Conference Blazor pages (events, sessions, speakers, categories, questions, rooms, feedback, public, session-selection) and their UI services. +> `group-21-conference-ui.md` | 106 types | The Conference Blazor pages (events, sessions, speakers, categories, questions, rooms, feedback, public, session-selection) and their UI services. | Level | Type | Kind | Namespace | |-------|------|------|-----------| @@ -1358,8 +1419,10 @@ disclosure) and is cross-linked in the chapter. | 0 | `ConferenceTrackInfo` | record | MMCA.ADC.Conference.UI.Pages.Home | | 0 | `EventInfo` | record | MMCA.ADC.Conference.UI.Services | | 0 | `EventPhase` | enum | MMCA.ADC.Conference.UI.Pages.Home | +| 0 | `InfiniteScrollSentinel` | class | MMCA.ADC.Conference.UI.Components | | 0 | `IPublicLinkBuilder` | interface | MMCA.ADC.Conference.UI.Services | | 0 | `KeynoteSpeakerInfo` | record | MMCA.ADC.Conference.UI.Pages.Home | +| 0 | `PreConferenceWorkshopInfo` | record | MMCA.ADC.Conference.UI.Pages.Home | | 0 | `ScorePollSignal` | enum | MMCA.ADC.Conference.UI.Pages.SessionSelection | | 0 | `SessionSelectionDisplay` | class | MMCA.ADC.Conference.UI.Pages.SessionSelection | | 0 | `SpeakerInfo` | record | MMCA.ADC.Conference.UI.Services | @@ -1383,6 +1446,7 @@ disclosure) and is cross-linked in the chapter. | 3 | `CategoryItemLookupService` | class | MMCA.ADC.Conference.UI.Services | | 3 | `ConferenceUIModule` | class | MMCA.ADC.Conference.UI | | 3 | `EventLookupService` | class | MMCA.ADC.Conference.UI.Services | +| 3 | `IActivityUIService` | interface | MMCA.ADC.Conference.UI.Services | | 3 | `ICategoryItemUIService` | interface | MMCA.ADC.Conference.UI.Services | | 3 | `IConferenceCategoryUIService` | interface | MMCA.ADC.Conference.UI.Services | | 3 | `IEventUIService` | interface | MMCA.ADC.Conference.UI.Services | @@ -1394,7 +1458,9 @@ disclosure) and is cross-linked in the chapter. | 3 | `ISponsorUIService` | interface | MMCA.ADC.Conference.UI.Services | | 3 | `OrganizerEventFeedbackService` | class | MMCA.ADC.Conference.UI.Services | | 3 | `OrganizerSessionFeedbackService` | class | MMCA.ADC.Conference.UI.Services | +| 3 | `PublicScheduleRoomOptions` | class | MMCA.ADC.Conference.UI.Pages.Public | | 3 | `SpeakerLookupService` | class | MMCA.ADC.Conference.UI.Services | +| 4 | `ActivityService` | class | MMCA.ADC.Conference.UI.Services | | 4 | `CategoryItemService` | class | MMCA.ADC.Conference.UI.Services | | 4 | `ConferenceCategoryService` | class | MMCA.ADC.Conference.UI.Services | | 4 | `EventService` | class | MMCA.ADC.Conference.UI.Services | @@ -1425,7 +1491,6 @@ disclosure) and is cross-linked in the chapter. | 7 | `ConferenceCategoryDetail` | class | MMCA.ADC.Conference.UI.Pages.ConferenceCategory | | 7 | `ConferenceCategoryList` | class | MMCA.ADC.Conference.UI.Pages.ConferenceCategory | | 7 | `EventList` | class | MMCA.ADC.Conference.UI.Pages.Event | -| 7 | `PublicEventList` | class | MMCA.ADC.Conference.UI.Pages.Public | | 7 | `PublicSessionListView` | class | MMCA.ADC.Conference.UI.Pages.Public | | 7 | `QuestionList` | class | MMCA.ADC.Conference.UI.Pages.Question | | 8 | `EventDetail` | class | MMCA.ADC.Conference.UI.Pages.Event | @@ -1435,7 +1500,12 @@ disclosure) and is cross-linked in the chapter. | 8 | `RoomDetail` | class | MMCA.ADC.Conference.UI.Pages.Room | | 8 | `SpeakerCategoryItemsPanel` | class | MMCA.ADC.Conference.UI.Pages.Speaker | | 8 | `SpeakerDetail` | class | MMCA.ADC.Conference.UI.Pages.Speaker | +| 9 | `ActivityCreate` | class | MMCA.ADC.Conference.UI.Pages.Activity | +| 9 | `ActivityDetail` | class | MMCA.ADC.Conference.UI.Pages.Activity | +| 9 | `ActivityList` | class | MMCA.ADC.Conference.UI.Pages.Activity | | 9 | `ADCHome` | class | MMCA.ADC.Conference.UI.Pages.Home | +| 9 | `PublicActivityList` | class | MMCA.ADC.Conference.UI.Pages.Public | +| 9 | `PublicEventList` | class | MMCA.ADC.Conference.UI.Pages.Public | | 9 | `PublicSessionDetail` | class | MMCA.ADC.Conference.UI.Pages.Public | | 9 | `PublicSpeakerList` | class | MMCA.ADC.Conference.UI.Pages.Public | | 9 | `PublicSponsorList` | class | MMCA.ADC.Conference.UI.Pages.Public | @@ -1567,6 +1637,7 @@ disclosure) and is cross-linked in the chapter. | 4 | `PointsController` | class | MMCA.ADC.Engagement.API.Controllers | | 4 | `PointsService` | class | MMCA.ADC.Engagement.UI.Services | | 4 | `SessionFeedbackSubmittedPointsHandler` | class | MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers | +| 4 | `SessionQuestionSubmittedPointsHandler` | class | MMCA.ADC.Engagement.Application.Points.DomainEventHandlers | | 4 | `UserSessionBookmarkCacheEvictionHandler` | class | MMCA.ADC.Engagement.Application.UserSessionBookmarks.DomainEventHandlers | | 5 | `AttendeeSummary` | record | MMCA.ADC.Engagement.UI.Services | | 5 | `EngagementModule` | class | MMCA.ADC.Engagement.API | @@ -1596,7 +1667,6 @@ disclosure) and is cross-linked in the chapter. | 8 | `PointsEntryConfiguration` | class | MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration | | 8 | `SessionFeedback` | class | MMCA.ADC.Engagement.UI.Pages.Feedback | | 8 | `SessionQuestionConfiguration` | class | MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration | -| 8 | `SessionQuestionSubmittedPointsHandler` | class | MMCA.ADC.Engagement.Application.Points.DomainEventHandlers | | 8 | `SessionQuestionUpvoteConfiguration` | class | MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration | | 8 | `UserDeletedPointsHandler` | class | MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers | | 8 | `UserSessionBookmarkConfiguration` | class | MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration | @@ -1624,7 +1694,6 @@ disclosure) and is cross-linked in the chapter. | 11 | `CheckInProcessor` | class | MMCA.ADC.Engagement.Application.CheckIns.Services | | 11 | `EngagementUIModule` | class | MMCA.ADC.Engagement.UI | | 11 | `GetAttendanceStatsHandler` | class | MMCA.ADC.Engagement.Application.CheckIns.UseCases.GetAttendanceStats | -| 11 | `ModuleApplicationDbContext` | class | MMCA.ADC.Engagement.Infrastructure.Persistence.DbContexts | | 11 | `RecordRoomCheckInHandler` | class | MMCA.ADC.Engagement.Application.CheckIns.UseCases.RecordRoomCheckIn | | 11 | `RecordSponsorVisitHandler` | class | MMCA.ADC.Engagement.Application.CheckIns.UseCases.RecordSponsorVisit | | 11 | `UserEngagementExportService` | class | MMCA.ADC.Engagement.Application.Exports | @@ -1632,13 +1701,14 @@ disclosure) and is cross-linked in the chapter. | 12 | `DependencyInjection` | class | MMCA.ADC.Engagement.UI | | 12 | `DependencyInjection` | class | MMCA.ADC.Engagement.Application | | 12 | `ManualCheckInHandler` | class | MMCA.ADC.Engagement.Application.CheckIns.UseCases.ManualCheckIn | +| 12 | `ModuleApplicationDbContext` | class | MMCA.ADC.Engagement.Infrastructure.Persistence.DbContexts | | 12 | `UserEngagementExportGrpcService` | class | MMCA.ADC.Engagement.Service.Grpc | | 12 | `UserEngagementExportServiceGrpcAdapter` | class | MMCA.ADC.Engagement.Contracts | | 13 | `DependencyInjection` | class | MMCA.ADC.Engagement.Contracts | ### G26 - ADC Engagement Live Layer (Real-Time Polls & Session Q&A) -> `group-23-engagement-live-layer.md` | 94 types | Real-time audience interaction in the Engagement bounded context: event-wide live polls with voting and moderated per-session Q&A with upvoting, over the SignalR hub-channel transport ([ADR-039](https://ivanball.github.io/docs/adr/039-live-channel-push.html)) and the cross-service gRPC live-channel adapter. +> `group-23-engagement-live-layer.md` | 95 types | Real-time audience interaction in the Engagement bounded context: event-wide live polls with voting and moderated per-session Q&A with upvoting, over the SignalR hub-channel transport ([ADR-039](https://ivanball.github.io/docs/adr/039-live-channel-push.html)) and the cross-service gRPC live-channel adapter. | Level | Type | Kind | Namespace | |-------|------|------|-----------| @@ -1661,8 +1731,8 @@ disclosure) and is cross-linked in the chapter. | 0 | `LivePollStatus` | enum | MMCA.ADC.Engagement.Shared.LivePolls | | 0 | `ModerationAction` | enum | MMCA.ADC.Engagement.Shared.SessionQuestions | | 0 | `OpenLivePollCommand` | record | MMCA.ADC.Engagement.Application.LivePolls.UseCases.Open | -| 0 | `OptionState` | class | MMCA.ADC.Engagement.UI.Pages.HappeningNow | | 0 | `OptionState` | class | MMCA.ADC.Engagement.UI.Pages.SessionLive | +| 0 | `OptionState` | class | MMCA.ADC.Engagement.UI.Pages.HappeningNow | | 0 | `QuestionStatus` | enum | MMCA.ADC.Engagement.Shared.SessionQuestions | | 0 | `SessionInfo` | record | MMCA.ADC.Engagement.UI.Services | | 0 | `SessionQuestionAnsweredPayload` | record | MMCA.ADC.Engagement.Shared.SessionQuestions | @@ -1735,22 +1805,23 @@ disclosure) and is cross-linked in the chapter. | 10 | `GetPollResultsHandler` | class | MMCA.ADC.Engagement.Application.LivePolls.UseCases.GetPollResults | | 10 | `HappeningNow` | class | MMCA.ADC.Engagement.UI.Pages.HappeningNow | | 10 | `LivePollNavigationPopulator` | class | MMCA.ADC.Engagement.Application.LivePolls.Services | +| 10 | `LivePollOptionNavigationPopulator` | class | MMCA.ADC.Engagement.Application.LivePolls.Services | | 10 | `LivePollVoteChangedHandler` | class | MMCA.ADC.Engagement.Application.LivePolls.DomainEventHandlers | ### G23 - ADC Identity Module (Users, Profiles, GDPR Export/Erasure) -> `group-24-identity-module.md` | 83 types | The Identity bounded context end-to-end: the User aggregate, change-password/delete/export use cases, persistence, API/contracts/service, and profile/user UI. +> `group-24-identity-module.md` | 88 types | The Identity bounded context end-to-end: the User aggregate, change-password/delete/export use cases, persistence, API/contracts/service, and profile/user UI. | Level | Type | Kind | Namespace | |-------|------|------|-----------| +| 0 | `AssemblyReference` | class | MMCA.ADC.Identity.Domain | | 0 | `AssemblyReference` | class | MMCA.ADC.Identity.API | | 0 | `AssemblyReference` | class | MMCA.ADC.Identity.Infrastructure | | 0 | `AssemblyReference` | class | MMCA.ADC.Identity.Application | -| 0 | `AssemblyReference` | class | MMCA.ADC.Identity.Domain | -| 0 | `ClassReference` | class | MMCA.ADC.Identity.API | | 0 | `ClassReference` | class | MMCA.ADC.Identity.Infrastructure | -| 0 | `ClassReference` | class | MMCA.ADC.Identity.Application | +| 0 | `ClassReference` | class | MMCA.ADC.Identity.API | | 0 | `ClassReference` | class | MMCA.ADC.Identity.Domain | +| 0 | `ClassReference` | class | MMCA.ADC.Identity.Application | | 0 | `DependencyInjection` | class | MMCA.ADC.Identity.Infrastructure | | 0 | `GetUserAvatarQuery` | record | MMCA.ADC.Identity.Application.Users.UseCases.GetUserAvatar | | 0 | `GetUsersQuery` | record | MMCA.ADC.Identity.Application.Users.UseCases.GetUsers | @@ -1772,6 +1843,7 @@ disclosure) and is cross-linked in the chapter. | 0 | `UserListDTO` | record | MMCA.ADC.Identity.Shared.Users | | 1 | `ChangePasswordRequestValidator` | class | MMCA.ADC.Identity.Application.Users.Validation | | 1 | `DisabledAttendeeQueryService` | class | MMCA.ADC.Identity.Shared.Users | +| 1 | `ForgotPasswordCommand` | record | MMCA.ADC.Identity.Application.Users.UseCases.ForgotPassword | | 1 | `HttpContextExternalLoginEmailVerifier` | class | MMCA.ADC.Identity.API.Authentication | | 1 | `IUserUIService` | interface | MMCA.ADC.Identity.UI.Services | | 1 | `UserDataExportEngagementSectionDTO` | record | MMCA.ADC.Identity.Shared.Users | @@ -1805,7 +1877,7 @@ disclosure) and is cross-linked in the chapter. | 8 | `DeleteUserCommand` | record | MMCA.ADC.Identity.Application.Users.UseCases.DeleteUser | | 8 | `GetUserAvatarHandler` | class | MMCA.ADC.Identity.Application.Users.UseCases.GetUserAvatar | | 8 | `GetUsersHandler` | class | MMCA.ADC.Identity.Application.Users.UseCases.GetUsers | -| 8 | `ModuleApplicationDbContext` | class | MMCA.ADC.Identity.Infrastructure.Persistence.DbContexts | +| 8 | `ResetPasswordCommand` | record | MMCA.ADC.Identity.Application.Users.UseCases.ResetPassword | | 8 | `SetUserAvatarHandler` | class | MMCA.ADC.Identity.Application.Users.UseCases.SetUserAvatar | | 8 | `SpeakerLinkedToUserHandler` | class | MMCA.ADC.Identity.Application.Speakers.IntegrationEventHandlers | | 8 | `SpeakerUnlinkedFromUserHandler` | class | MMCA.ADC.Identity.Application.Speakers.IntegrationEventHandlers | @@ -1813,19 +1885,23 @@ disclosure) and is cross-linked in the chapter. | 8 | `UserDTOMapper` | class | MMCA.ADC.Identity.Application.Users.DTOs | | 9 | `AttendeeQueryServiceGrpcAdapter` | class | MMCA.ADC.Identity.Contracts | | 9 | `AttendeesGrpcService` | class | MMCA.ADC.Identity.Service.Grpc | -| 9 | `AuthenticationService` | class | MMCA.ADC.Identity.Application.Users | | 9 | `ChangePasswordHandler` | class | MMCA.ADC.Identity.Application.Users.UseCases.ChangePassword | | 9 | `ChangePreferencesHandler` | class | MMCA.ADC.Identity.Application.Users.UseCases.ChangePreferences | | 9 | `DeleteUserHandler` | class | MMCA.ADC.Identity.Application.Users.UseCases.DeleteUser | | 9 | `ExportUserDataHandler` | class | MMCA.ADC.Identity.Application.Users.UseCases.ExportUserData | | 9 | `GetUserPreferencesHandler` | class | MMCA.ADC.Identity.Application.Users.UseCases.GetPreferences | | 9 | `RemoveUserAvatarHandler` | class | MMCA.ADC.Identity.Application.Users.UseCases.RemoveUserAvatar | +| 9 | `ResetPasswordHandler` | class | MMCA.ADC.Identity.Application.Users.UseCases.ResetPassword | | 9 | `UsersController` | class | MMCA.ADC.Identity.API.Controllers | -| 10 | `DependencyInjection` | class | MMCA.ADC.Identity.Application | | 10 | `DependencyInjection` | class | MMCA.ADC.Identity.Contracts | -| 10 | `IdentityModuleDbSeeder` | class | MMCA.ADC.Identity.Infrastructure.Persistence.DbContexts.Seeding | -| 11 | `IdentityModuleSeeder` | class | MMCA.ADC.Identity.API | -| 12 | `AuthController` | class | MMCA.ADC.Identity.API.Controllers | +| 12 | `ModuleApplicationDbContext` | class | MMCA.ADC.Identity.Infrastructure.Persistence.DbContexts | +| 14 | `AuthenticationService` | class | MMCA.ADC.Identity.Application.Users | +| 14 | `ForgotPasswordHandler` | class | MMCA.ADC.Identity.Application.Users.UseCases.ForgotPassword | +| 15 | `DependencyInjection` | class | MMCA.ADC.Identity.Application | +| 15 | `IdentityModuleDbSeeder` | class | MMCA.ADC.Identity.Infrastructure.Persistence.DbContexts.Seeding | +| 16 | `IdentityModuleSeeder` | class | MMCA.ADC.Identity.API | +| 16 | `PasswordResetController` | class | MMCA.ADC.Identity.API.Controllers | +| 17 | `AuthController` | class | MMCA.ADC.Identity.API.Controllers | ### G24 - ADC Application Host, UI Shell & Cross-Module Composition @@ -1834,17 +1910,17 @@ disclosure) and is cross-linked in the chapter. | Level | Type | Kind | Namespace | |-------|------|------|-----------| | 0 | `NowNextSession` | record | MMCA.ADC.UI | -| 0 | `WebAuthenticatorCallbackActivity` | class | MMCA.ADC.UI | | 1 | `ADCHomePageContent` | class | MMCA.ADC.UI.Web.Client.Pages | | 1 | `AppActionsInitializer` | class | MMCA.ADC.UI.Services | | 1 | `MauiPublicLinkBuilder` | class | MMCA.ADC.UI.Services | | 1 | `NowNextSnapshot` | record | MMCA.ADC.UI | | 3 | `DeviceUIModule` | class | MMCA.ADC.UI | -| 3 | `MainActivity` | class | MMCA.ADC.UI | | 3 | `MainPage` | class | MMCA.ADC.UI | | 4 | `App` | class | MMCA.ADC.UI | -| 4 | `NowNextWidgetProvider` | class | MMCA.ADC.UI | +| 9 | `MainActivity` | class | MMCA.ADC.UI | +| 9 | `WebAuthenticatorCallbackActivity` | class | MMCA.ADC.UI | | 10 | `ADCHomePageContent` | class | MMCA.ADC.UI.Pages | +| 10 | `NowNextWidgetProvider` | class | MMCA.ADC.UI | | 11 | `MauiProgram` | class | MMCA.ADC.UI | | 12 | `App` | class | MMCA.ADC.UI.WinUI | | 12 | `AppDelegate` | class | MMCA.ADC.UI | @@ -1956,60 +2032,60 @@ disclosure) and is cross-linked in the chapter. ### G25 - Testing & Quality Infrastructure -> `group-27-testing-infrastructure.md` | 1780 types | All test projects + the reusable Testing/Testing.E2E/Testing.UI bases, architecture-fitness tests, and the component Gallery harness; individual [Fact]s are rolled up by project (logged exception). +> `group-27-testing-infrastructure.md` | 1907 types | All test projects + the reusable Testing/Testing.E2E/Testing.UI bases, architecture-fitness tests, and the component Gallery harness; individual [Fact]s are rolled up by project (logged exception). Rolled up by project (individual `[Fact]`s not sectioned - logged exception). Reusable test infrastructure assemblies (sectioned in full in the chapter) are marked **(infra)**. | Test project (assembly) | Types | Levels | Kind | |--------------------------|-------|--------|------| -| `MMCA.ADC.Architecture.Tests` **(infra)** | 32 | L1-L10 | | -| `MMCA.ADC.Conference.API.Tests` | 19 | L1-L11 | | -| `MMCA.ADC.Conference.Application.Tests` | 148 | L0-L12 | | -| `MMCA.ADC.Conference.Domain.Tests` | 25 | L6-L11 | | +| `MMCA.ADC.Architecture.Tests` **(infra)** | 35 | L1-L13 | | +| `MMCA.ADC.Conference.API.Tests` | 20 | L1-L11 | | +| `MMCA.ADC.Conference.Application.Tests` | 166 | L0-L15 | | +| `MMCA.ADC.Conference.Domain.Tests` | 28 | L6-L11 | | | `MMCA.ADC.Conference.Infrastructure.Tests` | 15 | L0-L11 | | | `MMCA.ADC.Conference.IntegrationTests` | 37 | L1-L18 | | | `MMCA.ADC.Conference.Shared.Tests` | 17 | L0-L10 | | -| `MMCA.ADC.Conference.UI.Tests` | 37 | L1-L11 | | +| `MMCA.ADC.Conference.UI.Tests` | 45 | L1-L11 | | | `MMCA.ADC.CrossService.IntegrationTests` | 11 | L0-L18 | | -| `MMCA.ADC.E2E.Tests` | 82 | L0-L8 | | +| `MMCA.ADC.E2E.Tests` | 83 | L0-L8 | | | `MMCA.ADC.Engagement.API.Tests` | 9 | L1-L12 | | -| `MMCA.ADC.Engagement.Application.Tests` | 57 | L0-L13 | | +| `MMCA.ADC.Engagement.Application.Tests` | 59 | L0-L15 | | | `MMCA.ADC.Engagement.Domain.Tests` | 11 | L7-L11 | | | `MMCA.ADC.Engagement.Infrastructure.Tests` | 4 | L1-L11 | | | `MMCA.ADC.Engagement.IntegrationTests` | 22 | L0-L18 | | | `MMCA.ADC.Engagement.Shared.Tests` | 7 | L1-L10 | | | `MMCA.ADC.Engagement.UI.Tests` | 34 | L0-L11 | | | `MMCA.ADC.Gateway.Tests` | 8 | L0-L15 | | -| `MMCA.ADC.Identity.API.Tests` | 7 | L1-L13 | | -| `MMCA.ADC.Identity.Application.Tests` | 26 | L0-L10 | | +| `MMCA.ADC.Identity.API.Tests` | 7 | L1-L18 | | +| `MMCA.ADC.Identity.Application.Tests` | 28 | L0-L15 | | | `MMCA.ADC.Identity.Domain.Tests` | 4 | L8-L8 | | -| `MMCA.ADC.Identity.Infrastructure.Tests` | 4 | L8-L11 | | -| `MMCA.ADC.Identity.IntegrationTests` | 33 | L0-L19 | | +| `MMCA.ADC.Identity.Infrastructure.Tests` | 4 | L8-L16 | | +| `MMCA.ADC.Identity.IntegrationTests` | 34 | L0-L19 | | | `MMCA.ADC.Identity.Shared.Tests` | 3 | L2-L5 | | | `MMCA.ADC.Identity.UI.Tests` | 6 | L3-L8 | | | `MMCA.ADC.Notification.API.Tests` | 1 | L4-L4 | | -| `MMCA.ADC.Notification.Application.Tests` | 5 | L1-L10 | | +| `MMCA.ADC.Notification.Application.Tests` | 5 | L1-L15 | | | `MMCA.ADC.Notification.IntegrationTests` | 9 | L1-L18 | | | `MMCA.ADC.ServiceBusEmulator.IntegrationTests` | 3 | L4-L6 | | | `MMCA.ADC.Services.Tests` | 5 | L0-L13 | | -| `MMCA.Common.API.Tests` | 111 | L0-L13 | | -| `MMCA.Common.Application.Tests` | 241 | L0-L10 | | -| `MMCA.Common.Architecture.Tests` **(infra)** | 63 | L0-L8 | | -| `MMCA.Common.Aspire.Tests` | 34 | L0-L3 | | +| `MMCA.Common.API.Tests` | 121 | L0-L18 | | +| `MMCA.Common.Application.Tests` | 265 | L0-L10 | | +| `MMCA.Common.Architecture.Tests` **(infra)** | 89 | L0-L13 | | +| `MMCA.Common.Aspire.Tests` | 36 | L0-L11 | | | `MMCA.Common.Benchmarks` | 6 | L0-L4 | | | `MMCA.Common.Domain.Tests` | 57 | L0-L8 | | | `MMCA.Common.Grpc.Tests` | 15 | L0-L4 | | | `MMCA.Common.Infrastructure.Redis.Tests` | 2 | L5-L5 | | -| `MMCA.Common.Infrastructure.Tests` | 307 | L0-L12 | | +| `MMCA.Common.Infrastructure.Tests` | 323 | L0-L16 | | | `MMCA.Common.Shared.Tests` | 33 | L0-L6 | | -| `MMCA.Common.Testing` **(infra)** | 18 | L0-L9 | | -| `MMCA.Common.Testing.Architecture` **(infra)** | 46 | L0-L5 | | -| `MMCA.Common.Testing.E2E` **(infra)** | 22 | L0-L4 | | -| `MMCA.Common.Testing.Tests` | 16 | L0-L10 | | +| `MMCA.Common.Testing` **(infra)** | 19 | L0-L14 | | +| `MMCA.Common.Testing.Architecture` **(infra)** | 48 | L0-L5 | | +| `MMCA.Common.Testing.E2E` **(infra)** | 25 | L0-L4 | | +| `MMCA.Common.Testing.Tests` | 17 | L0-L15 | | | `MMCA.Common.Testing.UI` **(infra)** | 15 | L0-L3 | | -| `MMCA.Common.UI.E2E.Tests` | 13 | L2-L13 | | +| `MMCA.Common.UI.E2E.Tests` | 15 | L2-L13 | | | `MMCA.Common.UI.Gallery` **(infra)** | 9 | L0-L9 | | -| `MMCA.Common.UI.Tests` | 87 | L0-L7 | | +| `MMCA.Common.UI.Tests` | 88 | L0-L7 | | | `MMCA.Common.UI.Web.Tests` | 4 | L1-L5 | | diff --git a/docs-src/onboarding/00-index.md b/docs-src/onboarding/00-index.md index 32abc27..eaa7dd9 100644 --- a/docs-src/onboarding/00-index.md +++ b/docs-src/onboarding/00-index.md @@ -66,9 +66,9 @@ globally-unique-name fallback described below cannot tell apart, so each resolve The type inventory and dependency graph are produced **mechanically** by a small Roslyn syntactic parser (`Tools/invtool/`), so the type list, namespaces, and `file:line` are exact and reproducible. Edges are resolved by **namespace-aware name matching**; ~96% bind by namespace visibility, the rest by a -globally-unique-name fallback (493 edges), and 29 references are dropped as ambiguous. The functional grouping is +globally-unique-name fallback (540 edges), and 29 references are dropped as ambiguous. The functional grouping is then applied mechanically (`Tools/invtool/classify.ps1` → `00-group-taxonomy.md`), so every one of the -**3,465** distinct type nodes maps to exactly one group with no silent drops. See the +**3,668** distinct type nodes maps to exactly one group with no silent drops. See the [manifest's accuracy note](00-dependency-manifest.md#edge-resolution--accuracy) for residual caveats. --- @@ -79,7 +79,7 @@ then applied mechanically (`Tools/invtool/classify.ps1` → `00-group-taxonomy.m | File | Contents | |------|----------| | [`00-primer.md`](00-primer.md) | Cross-cutting concepts, the BCL/NuGet stack, build/language conventions, and the 34-category rubric, taught **once** | -| [`00-inventory.md`](00-inventory.md) | Phase 0, every in-scope type (3,465 distinct), mechanically extracted, with `file:line` | +| [`00-inventory.md`](00-inventory.md) | Phase 0, every in-scope type (3,668 distinct), mechanically extracted, with `file:line` | | [`00-dependency-manifest.md`](00-dependency-manifest.md) | Phase 1a, per-type first-party deps + computed Level; the 30 cycles | | [`00-group-taxonomy.md`](00-group-taxonomy.md) | Phase 1b, the ordered groups + every type's group assignment (the primary axis) | @@ -88,31 +88,31 @@ then applied mechanically (`Tools/invtool/classify.ps1` → `00-group-taxonomy.m |---|---------|-------|---------| | 1 | [Result & Error Handling](group-01-result-error-handling.md) | 14 | The `Result`/`Error` railway returned instead of throwing, incl. its own JSON round-trip converter | | 2 | [Domain Building Blocks](group-02-domain-building-blocks.md) | 33 | Entity/aggregate bases, value objects + invariants, domain markers, attributes, identifier aliases, `[Pii]` redaction, row-version concurrency marker | -| 3 | [Querying: Specifications, Filtering & the Entity Query Service](group-03-querying-specifications.md) | 37 | Specification pattern (incl. cross-source), dynamic filter/sort/page (incl. IN-list value parsing), the generic query pipeline | +| 3 | [Querying: Specifications, Filtering & the Entity Query Service](group-03-querying-specifications.md) | 38 | Specification pattern (incl. cross-source), dynamic filter/sort/page (incl. IN-list value parsing), the generic query pipeline | | 4 | [Domain & Integration Events + Outbox Dual-Dispatch](group-04-events-outbox.md) | 32 | Event contracts, dispatcher, transactional outbox/inbox (incl. the async `OutboxFinalizer`), message buses | -| 5 | [CQRS: Commands, Queries & the Decorator Pipeline](group-05-cqrs-pipeline.md) | 36 | Handler abstraction + the logging/transaction/caching/feature-gate/idempotency decorators (incl. the cache-stampede lock) | +| 5 | [CQRS: Commands, Queries & the Decorator Pipeline](group-05-cqrs-pipeline.md) | 38 | Handler abstraction + the logging/transaction/caching/feature-gate/idempotency decorators (incl. the cache-stampede lock) | | 6 | [Validation](group-06-validation.md) | 17 | FluentValidation contracts + failure mapping that gate commands | -| 7 | [Persistence & EF Core](group-07-persistence-ef-core.md) | 116 | `SQLServerDbContext` over abstract `ApplicationDbContext`, interceptors (incl. the deferred-dispatch record), repositories, engine-aware entity config, data-source routing, conventions (incl. the soft-delete unique-index convention), factories, managed file storage + image processing ([ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-storage-and-avatars.html)), native-push device registrar ([ADR-044](https://ivanball.github.io/docs/adr/044-native-push-delivery.html)) | -| 8 | [Authentication & Authorization](group-08-auth.md) | 69 | JWT/JWKS dual-fetch, the shared `AuthenticationServiceBase` login/refresh workflow (`IAuthUser`), current-user/claims, password hashing, cookie sessions, role policies + the permission-based authorization mechanism (registry, `[HasPermission]`), the `RoleValue` base, the external-auth-broker contract ([ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html)/043) | +| 7 | [Persistence & EF Core](group-07-persistence-ef-core.md) | 118 | `SQLServerDbContext` over abstract `ApplicationDbContext`, interceptors (incl. the deferred-dispatch record), repositories, engine-aware entity config, data-source routing, conventions (incl. the soft-delete unique-index convention), factories, managed file storage + image processing ([ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-storage-and-avatars.html)), native-push device registrar ([ADR-044](https://ivanball.github.io/docs/adr/044-native-push-delivery.html)) | +| 8 | [Authentication & Authorization](group-08-auth.md) | 77 | JWT/JWKS dual-fetch, the shared `AuthenticationServiceBase` login/refresh workflow (`IAuthUser`), current-user/claims, password hashing, cookie sessions, role policies + the permission-based authorization mechanism (registry, `[HasPermission]`), the `RoleValue` base, the external-auth-broker contract ([ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html)/043) | | 9 | [Caching](group-09-caching.md) | 8 | The cache abstraction + its invalidation-aware decorator integration | | 10 | [Notifications (Push + In-App Inbox + Email)](group-10-notifications.md) | 55 | Push (SignalR), in-app inbox, email, recipient providers, the [ADR-039](https://ivanball.github.io/docs/adr/039-live-channel-push.html) hub-channel live publisher, [ADR-044](https://ivanball.github.io/docs/adr/044-native-push-delivery.html) native OS-level push (Azure Notification Hubs, shipped inert), ADC Notification module + its cross-service gRPC live-channel plumbing + extractable-host Kestrel config | | 11 | [Navigation Metadata & Populators](group-11-navigation-populators.md) | 12 | EF-decoupled cross-container/cross-source eager loading ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html)) | -| 12 | [API Hosting, Middleware, Idempotency & DTO/Contract Mapping](group-12-api-hosting-mapping.md) | 74 | Controller bases (incl. OAuth + service-info + API versioning, [ADR-046](https://ivanball.github.io/docs/adr/046-http-api-versioning.html)), middleware (incl. soft-deleted-user revocation, [ADR-047](https://ivanball.github.io/docs/adr/047-soft-deleted-user-session-revocation.html)), startup, model binders, JSON converters, feature mgmt, idempotency, mapping, edge error localization (i18n), authenticated output caching ([ADR-040](https://ivanball.github.io/docs/adr/040-authenticated-output-caching-for-public-reads.html)), app-association/deep-link endpoints ([ADR-043](https://ivanball.github.io/docs/adr/043-mobile-deep-links-and-native-oauth-callback.html)) | +| 12 | [API Hosting, Middleware, Idempotency & DTO/Contract Mapping](group-12-api-hosting-mapping.md) | 79 | Controller bases (incl. OAuth + service-info + API versioning, [ADR-046](https://ivanball.github.io/docs/adr/046-http-api-versioning.html)), middleware (incl. soft-deleted-user revocation, [ADR-047](https://ivanball.github.io/docs/adr/047-soft-deleted-user-session-revocation.html)), startup, model binders, JSON converters, feature mgmt, idempotency, mapping, edge error localization (i18n), authenticated output caching ([ADR-040](https://ivanball.github.io/docs/adr/040-authenticated-output-caching-for-public-reads.html)), app-association/deep-link endpoints ([ADR-043](https://ivanball.github.io/docs/adr/043-mobile-deep-links-and-native-oauth-callback.html)) | | 13 | [gRPC & Inter-Service Contracts](group-13-grpc-contracts.md) | 6 | Typed gRPC clients/servers, interceptors, Result-over-the-wire ([ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html)) | -| 14 | [Module System, Composition & Configuration](group-14-module-system-composition.md) | 68 | `IModule` + Kahn-ordered loader, DI composition roots, data-source attributes, options binding | -| 15 | [Common UI Framework](group-15-common-ui-framework.md) | 89 | Reusable MudBlazor building blocks: data-grid list page base, theme, common pages/services, i18n culture bootstrap + day/dark `ThemeService`, user-preference readers/writers, pseudo-localization gate, OAuth UI settings + token storage (Web/WASM), hub-channel subscriptions ([ADR-039](https://ivanball.github.io/docs/adr/039-live-channel-push.html)) | +| 14 | [Module System, Composition & Configuration](group-14-module-system-composition.md) | 70 | `IModule` + Kahn-ordered loader, DI composition roots, data-source attributes, options binding | +| 15 | [Common UI Framework](group-15-common-ui-framework.md) | 91 | Reusable MudBlazor building blocks: data-grid list page base, theme, common pages/services, i18n culture bootstrap + day/dark `ThemeService`, user-preference readers/writers, pseudo-localization gate, OAuth UI settings + token storage (Web/WASM), hub-channel subscriptions ([ADR-039](https://ivanball.github.io/docs/adr/039-live-channel-push.html)) | | 16 | [Aspire Orchestration & Service Defaults](group-16-aspire-orchestration.md) | 31 | AppHost wiring, ServiceDefaults, warmup, telemetry, security helpers, the shared `HttpResilienceDefaults` Polly source of truth, the shared `HealthCheckTags` liveness/readiness vocabulary | -| 17 | [ADC Conference, Domain Model & Module Contracts](group-17-conference-domain.md) | 96 | Event/Session/Speaker/Category/Question aggregates + domain events + invariants + Shared contracts (incl. `ConferencePermissions`, the current/next-event selector + live-validation contracts) | -| 18 | [ADC Conference, Application & Use Cases](group-18-conference-application.md) | 252 | Conference CQRS handlers, validators (incl. the per-field session validation-rule family), DTOs, specs, Sessionize import, decision-support analytics, batch bookmark-count query, event-filtering-by-role handlers, calendar (.ics) export slice ([ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html)) | -| 19 | [ADC Conference, Infrastructure & Persistence](group-19-conference-infrastructure.md) | 32 | Conference DbContext registration, EF configs, seeding, infra services | -| 20 | [ADC Conference, API, gRPC Contracts & Service Host](group-20-conference-api-grpc.md) | 42 | REST controllers, `.Contracts` gRPC, the extractable service host (incl. its Kestrel config), the gRPC adapters (incl. cross-service live-validation), localized error resources | -| 21 | [ADC Conference, UI](group-21-conference-ui.md) | 97 | Conference Blazor pages + UI services, the single canonical ADC Home page + its view models, the AI-scoring poll recovery tracker, calendar/QR export UI, OfflineBanner, PresenterLayout on Common theme providers | +| 17 | [ADC Conference, Domain Model & Module Contracts](group-17-conference-domain.md) | 100 | Event/Session/Speaker/Category/Question aggregates + domain events + invariants + Shared contracts (incl. `ConferencePermissions`, the current/next-event selector + live-validation contracts) | +| 18 | [ADC Conference, Application & Use Cases](group-18-conference-application.md) | 285 | Conference CQRS handlers, validators (incl. the per-field session validation-rule family), DTOs, specs, Sessionize import, decision-support analytics, batch bookmark-count query, event-filtering-by-role handlers, calendar (.ics) export slice ([ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html)) | +| 19 | [ADC Conference, Infrastructure & Persistence](group-19-conference-infrastructure.md) | 33 | Conference DbContext registration, EF configs, seeding, infra services | +| 20 | [ADC Conference, API, gRPC Contracts & Service Host](group-20-conference-api-grpc.md) | 43 | REST controllers, `.Contracts` gRPC, the extractable service host (incl. its Kestrel config), the gRPC adapters (incl. cross-service live-validation), localized error resources | +| 21 | [ADC Conference, UI](group-21-conference-ui.md) | 106 | Conference Blazor pages + UI services, the single canonical ADC Home page + its view models, the AI-scoring poll recovery tracker, calendar/QR export UI, OfflineBanner, PresenterLayout on Common theme providers | | 22 | [ADC Engagement Module (Session Bookmarks)](group-22-engagement-module.md) | 179 | The attendee-activity slice of the Engagement bounded context, four capability families: the session-bookmark aggregate, the QR badge check-in surface (organizer scanning, manual fallback, attendee self-service, attendance rollup), the points economy (append-only ledger + opt-in public leaderboard), and the feedback UI; plus use cases, persistence, API/contracts/service, the durable live-channel publish queue ([ADR-039](https://ivanball.github.io/docs/adr/039-live-channel-push.html)), the cross-service user-engagement export slice (gRPC) | -| 23 | [ADC Engagement Live Layer (Real-Time Polls & Session Q&A)](group-23-engagement-live-layer.md) | 94 | Event-wide live polls with voting + moderated per-session Q&A with upvoting, over the [ADR-039](https://ivanball.github.io/docs/adr/039-live-channel-push.html) hub-channel transport and the cross-service gRPC live-channel adapter (HappeningNow / SessionLive / PresenterView) | -| 24 | [ADC Identity Module (Users, Profiles, GDPR Export/Erasure)](group-24-identity-module.md) | 83 | The Identity bounded context end-to-end (incl. `IdentityPermissions`, user culture/theme preferences, the [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-storage-and-avatars.html) user-avatar photo slice end to end, the external-login email verifier + extractable-host Kestrel config; `AuthenticationService` now extends Common's shared base) | +| 23 | [ADC Engagement Live Layer (Real-Time Polls & Session Q&A)](group-23-engagement-live-layer.md) | 95 | Event-wide live polls with voting + moderated per-session Q&A with upvoting, over the [ADR-039](https://ivanball.github.io/docs/adr/039-live-channel-push.html) hub-channel transport and the cross-service gRPC live-channel adapter (HappeningNow / SessionLive / PresenterView) | +| 24 | [ADC Identity Module (Users, Profiles, GDPR Export/Erasure)](group-24-identity-module.md) | 88 | The Identity bounded context end-to-end (incl. `IdentityPermissions`, user culture/theme preferences, the [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-storage-and-avatars.html) user-avatar photo slice end to end, the external-login email verifier + extractable-host Kestrel config; `AuthenticationService` now extends Common's shared base) | | 25 | [ADC Application Host, UI Shell & Cross-Module Composition](group-25-adc-host-composition.md) | 17 | Blazor Web/WASM/WinUI shells, host pages/services, security, app composition, device-capability DI wiring, one-time preference migrator (the shared ADC Home page + its view models now live in the Conference UI chapter) | | 26 | [Device Capability Abstraction Layer (Native Contracts, MAUI, Browser & Fallback Adapters)](group-26-device-capability-layer.md) | 96 | Per-capability interface contracts (biometric, geolocation, speech, push registration, clipboard/share/haptics, external auth/links, connectivity/battery, deep links) + their MAUI-native, browser-JS-interop, and inert-fallback implementations, selected per host at DI composition time ([ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html)/043/044/045) | -| 27 | [Testing & Quality Infrastructure](group-27-testing-infrastructure.md) | 1780 | All test projects + reusable Testing/Testing.E2E/Testing.UI/**Testing.Architecture** bases (incl. the handler + decorator-pipeline test bases, the shared production-host/graceful-shutdown bases, the observability-convention fitness base, and the Gallery auth stubs) + architecture-fitness tests + Gallery + the BenchmarkDotNet perf-smoke suite | +| 27 | [Testing & Quality Infrastructure](group-27-testing-infrastructure.md) | 1,907 | All test projects + reusable Testing/Testing.E2E/Testing.UI/**Testing.Architecture** bases (incl. the handler + decorator-pipeline test bases, the shared production-host/graceful-shutdown bases, the observability-convention fitness base, and the Gallery auth stubs) + architecture-fitness tests + Gallery + the BenchmarkDotNet perf-smoke suite | ### DevOps & operations chapters | File | Contents | diff --git a/docs-src/onboarding/00-inventory.md b/docs-src/onboarding/00-inventory.md index 52dbc33..570de5e 100644 --- a/docs-src/onboarding/00-inventory.md +++ b/docs-src/onboarding/00-inventory.md @@ -3,20 +3,20 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file under `MMCA.Common/Source`, `MMCA.Common/Tests`, `MMCA.ADC/Source`, `MMCA.ADC/Tests`. -- Files scanned: **2810** (in-scope **2692**, generated/excluded **118**) -- Type declaration rows (including partial-class fragments): **3586** -- Distinct type nodes (partials collapsed): **3465** +- Files scanned: **2950** (in-scope **2828**, generated/excluded **122**) +- Type declaration rows (including partial-class fragments): **3797** +- Distinct type nodes (partials collapsed): **3668** - `extension(T)` blocks: **78** ## Counts by kind | Kind | Count (declarations) | |------|------| -| class | 2812 | -| record | 531 | -| interface | 196 | +| class | 2978 | +| record | 569 | +| interface | 201 | | enum | 29 | -| record struct | 16 | +| record struct | 18 | | delegate | 1 | | struct | 1 | @@ -24,28 +24,28 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | Assembly | Distinct types | |----------|------| -| MMCA.ADC.Architecture.Tests | 32 | -| MMCA.ADC.Conference.API | 35 | -| MMCA.ADC.Conference.API.Tests | 19 | -| MMCA.ADC.Conference.Application | 252 | -| MMCA.ADC.Conference.Application.Tests | 148 | +| MMCA.ADC.Architecture.Tests | 35 | +| MMCA.ADC.Conference.API | 36 | +| MMCA.ADC.Conference.API.Tests | 20 | +| MMCA.ADC.Conference.Application | 285 | +| MMCA.ADC.Conference.Application.Tests | 166 | | MMCA.ADC.Conference.Contracts | 4 | -| MMCA.ADC.Conference.Domain | 42 | -| MMCA.ADC.Conference.Domain.Tests | 25 | -| MMCA.ADC.Conference.Infrastructure | 32 | +| MMCA.ADC.Conference.Domain | 45 | +| MMCA.ADC.Conference.Domain.Tests | 28 | +| MMCA.ADC.Conference.Infrastructure | 33 | | MMCA.ADC.Conference.Infrastructure.Tests | 15 | | MMCA.ADC.Conference.IntegrationTests | 37 | | MMCA.ADC.Conference.Service | 3 | -| MMCA.ADC.Conference.Shared | 54 | +| MMCA.ADC.Conference.Shared | 55 | | MMCA.ADC.Conference.Shared.Tests | 17 | -| MMCA.ADC.Conference.UI | 97 | -| MMCA.ADC.Conference.UI.Tests | 37 | +| MMCA.ADC.Conference.UI | 106 | +| MMCA.ADC.Conference.UI.Tests | 45 | | MMCA.ADC.CrossService.IntegrationTests | 11 | -| MMCA.ADC.E2E.Tests | 82 | +| MMCA.ADC.E2E.Tests | 83 | | MMCA.ADC.Engagement.API | 10 | | MMCA.ADC.Engagement.API.Tests | 9 | -| MMCA.ADC.Engagement.Application | 84 | -| MMCA.ADC.Engagement.Application.Tests | 57 | +| MMCA.ADC.Engagement.Application | 85 | +| MMCA.ADC.Engagement.Application.Tests | 59 | | MMCA.ADC.Engagement.Contracts | 3 | | MMCA.ADC.Engagement.Domain | 30 | | MMCA.ADC.Engagement.Domain.Tests | 11 | @@ -59,16 +59,16 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | MMCA.ADC.Engagement.UI.Tests | 34 | | MMCA.ADC.Gateway | 1 | | MMCA.ADC.Gateway.Tests | 8 | -| MMCA.ADC.Identity.API | 11 | +| MMCA.ADC.Identity.API | 12 | | MMCA.ADC.Identity.API.Tests | 7 | -| MMCA.ADC.Identity.Application | 30 | -| MMCA.ADC.Identity.Application.Tests | 26 | +| MMCA.ADC.Identity.Application | 34 | +| MMCA.ADC.Identity.Application.Tests | 28 | | MMCA.ADC.Identity.Contracts | 2 | | MMCA.ADC.Identity.Domain | 7 | | MMCA.ADC.Identity.Domain.Tests | 4 | | MMCA.ADC.Identity.Infrastructure | 6 | | MMCA.ADC.Identity.Infrastructure.Tests | 4 | -| MMCA.ADC.Identity.IntegrationTests | 33 | +| MMCA.ADC.Identity.IntegrationTests | 34 | | MMCA.ADC.Identity.Service | 2 | | MMCA.ADC.Identity.Shared | 17 | | MMCA.ADC.Identity.Shared.Tests | 3 | @@ -86,34 +86,34 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | MMCA.ADC.Services.Tests | 5 | | MMCA.ADC.UI | 16 | | MMCA.ADC.UI.Web.Client | 1 | -| MMCA.Common.API | 91 | -| MMCA.Common.API.Tests | 111 | -| MMCA.Common.Application | 177 | -| MMCA.Common.Application.Tests | 241 | -| MMCA.Common.Architecture.Tests | 63 | +| MMCA.Common.API | 96 | +| MMCA.Common.API.Tests | 121 | +| MMCA.Common.Application | 186 | +| MMCA.Common.Application.Tests | 265 | +| MMCA.Common.Architecture.Tests | 89 | | MMCA.Common.Aspire | 27 | | MMCA.Common.Aspire.Hosting | 1 | -| MMCA.Common.Aspire.Tests | 34 | +| MMCA.Common.Aspire.Tests | 36 | | MMCA.Common.Benchmarks | 6 | | MMCA.Common.Domain | 47 | | MMCA.Common.Domain.Tests | 57 | | MMCA.Common.Grpc | 5 | | MMCA.Common.Grpc.Tests | 15 | -| MMCA.Common.Infrastructure | 180 | +| MMCA.Common.Infrastructure | 184 | | MMCA.Common.Infrastructure.Redis.Tests | 2 | -| MMCA.Common.Infrastructure.Tests | 307 | -| MMCA.Common.Shared | 66 | +| MMCA.Common.Infrastructure.Tests | 323 | +| MMCA.Common.Shared | 68 | | MMCA.Common.Shared.Tests | 33 | -| MMCA.Common.Testing | 18 | -| MMCA.Common.Testing.Architecture | 46 | -| MMCA.Common.Testing.E2E | 22 | -| MMCA.Common.Testing.Tests | 16 | +| MMCA.Common.Testing | 19 | +| MMCA.Common.Testing.Architecture | 48 | +| MMCA.Common.Testing.E2E | 25 | +| MMCA.Common.Testing.Tests | 17 | | MMCA.Common.Testing.UI | 15 | -| MMCA.Common.UI | 150 | -| MMCA.Common.UI.E2E.Tests | 13 | +| MMCA.Common.UI | 152 | +| MMCA.Common.UI.E2E.Tests | 15 | | MMCA.Common.UI.Gallery | 9 | | MMCA.Common.UI.Maui | 31 | -| MMCA.Common.UI.Tests | 87 | +| MMCA.Common.UI.Tests | 88 | | MMCA.Common.UI.Web | 4 | | MMCA.Common.UI.Web.Tests | 4 | @@ -122,6 +122,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | Type | Kind | Assembly | Namespace | File:Line | |------|------|----------|-----------|-----------| | `AdcArchitectureMap` | class | MMCA.ADC.Architecture.Tests | `MMCA.ADC.Architecture.Tests` | `MMCA.ADC.Architecture.Tests/AdcArchitectureMap.cs:8` | +| `AnonymousEndpointTests` | class | MMCA.ADC.Architecture.Tests | `MMCA.ADC.Architecture.Tests` | `MMCA.ADC.Architecture.Tests/AnonymousEndpointTests.cs:21` | | `BrandColorTokenTests` | class | MMCA.ADC.Architecture.Tests | `MMCA.ADC.Architecture.Tests` | `MMCA.ADC.Architecture.Tests/BrandColorTokenTests.cs:12` | | `ConcurrencyConventionTests` | class | MMCA.ADC.Architecture.Tests | `MMCA.ADC.Architecture.Tests` | `MMCA.ADC.Architecture.Tests/ConcurrencyConventionTests.cs:3` | | `ConstructorDependencyCountTests` | class | MMCA.ADC.Architecture.Tests | `MMCA.ADC.Architecture.Tests` | `MMCA.ADC.Architecture.Tests/ConstructorDependencyCountTests.cs:17` | @@ -141,12 +142,14 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `LayerDependencyTests` | class | MMCA.ADC.Architecture.Tests | `MMCA.ADC.Architecture.Tests` | `MMCA.ADC.Architecture.Tests/LayerDependencyTests.cs:3` | | `LocalizedTextConventionTests` | class | MMCA.ADC.Architecture.Tests | `MMCA.ADC.Architecture.Tests` | `MMCA.ADC.Architecture.Tests/LocalizedTextConventionTests.cs:14` | | `MicroserviceExtractionTests` | class | MMCA.ADC.Architecture.Tests | `MMCA.ADC.Architecture.Tests` | `MMCA.ADC.Architecture.Tests/MicroserviceExtractionTests.cs:3` | +| `MiddlewarePipelineOrderTests` | class | MMCA.ADC.Architecture.Tests | `MMCA.ADC.Architecture.Tests` | `MMCA.ADC.Architecture.Tests/MiddlewarePipelineOrderTests.cs:15` | | `ModuleIsolationTests` | class | MMCA.ADC.Architecture.Tests | `MMCA.ADC.Architecture.Tests` | `MMCA.ADC.Architecture.Tests/ModuleIsolationTests.cs:3` | | `NamingConventionTests` | class | MMCA.ADC.Architecture.Tests | `MMCA.ADC.Architecture.Tests` | `MMCA.ADC.Architecture.Tests/NamingConventionTests.cs:3` | | `ObservabilityConventionTests` | class | MMCA.ADC.Architecture.Tests | `MMCA.ADC.Architecture.Tests` | `MMCA.ADC.Architecture.Tests/ObservabilityConventionTests.cs:7` | | `PiiConventionTests` | class | MMCA.ADC.Architecture.Tests | `MMCA.ADC.Architecture.Tests` | `MMCA.ADC.Architecture.Tests/PiiConventionTests.cs:3` | | `ProtoContractTests` | class | MMCA.ADC.Architecture.Tests | `MMCA.ADC.Architecture.Tests` | `MMCA.ADC.Architecture.Tests/ProtoContractTests.cs:3` | | `RawQueryableConventionTests` | class | MMCA.ADC.Architecture.Tests | `MMCA.ADC.Architecture.Tests` | `MMCA.ADC.Architecture.Tests/RawQueryableConventionTests.cs:11` | +| `ServiceContractPurityTests` | class | MMCA.ADC.Architecture.Tests | `MMCA.ADC.Architecture.Tests` | `MMCA.ADC.Architecture.Tests/ServiceContractPurityTests.cs:9` | | `SharedLayerTests` | class | MMCA.ADC.Architecture.Tests | `MMCA.ADC.Architecture.Tests` | `MMCA.ADC.Architecture.Tests/SharedLayerTests.cs:3` | | `SliceCohesionTests` | class | MMCA.ADC.Architecture.Tests | `MMCA.ADC.Architecture.Tests` | `MMCA.ADC.Architecture.Tests/SliceCohesionTests.cs:8` | | `SpecificationConventionTests` | class | MMCA.ADC.Architecture.Tests | `MMCA.ADC.Architecture.Tests` | `MMCA.ADC.Architecture.Tests/SpecificationConventionTests.cs:8` | @@ -159,10 +162,11 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `ConferenceModuleSeeder` | class | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API` | `MMCA.ADC.Conference.API/ConferenceModuleSeeder.cs:13` | | `DependencyInjection` | class | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API` | `MMCA.ADC.Conference.API/DependencyInjection.cs:14` | | `CurrentUserServiceExtensions` | class | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API.Authorization` | `MMCA.ADC.Conference.API/Authorization/CurrentUserServiceExtensions.cs:10` | +| `ActivitiesController` | class | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API.Controllers` | `MMCA.ADC.Conference.API/Controllers/ActivitiesController.cs:37` | | `AddCategoryItemRequest` | record | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API.Controllers` | `MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:25` | | `AddEventQuestionAnswerRequest` | record | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API.Controllers` | `MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:27` | | `AddEventSpeakerRequest` | record | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API.Controllers` | `MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:29` | -| `AddRoomRequest` | record | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API.Controllers` | `MMCA.ADC.Conference.API/Controllers/RoomsController.cs:25` | +| `AddRoomRequest` | record | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API.Controllers` | `MMCA.ADC.Conference.API/Controllers/RoomsController.cs:30` | | `AddSessionCategoryItemRequest` | record | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API.Controllers` | `MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:29` | | `AddSessionQuestionAnswerRequest` | record | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API.Controllers` | `MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:27` | | `AddSessionSpeakerRequest` | record | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API.Controllers` | `MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:29` | @@ -173,7 +177,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `EventsController` | class | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API.Controllers` | `MMCA.ADC.Conference.API/Controllers/EventsController.cs:45` | | `EventSpeakersController` | class | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API.Controllers` | `MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:47` | | `QuestionsController` | class | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API.Controllers` | `MMCA.ADC.Conference.API/Controllers/QuestionsController.cs:31` | -| `RoomsController` | class | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API.Controllers` | `MMCA.ADC.Conference.API/Controllers/RoomsController.cs:86` | +| `RoomsController` | class | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API.Controllers` | `MMCA.ADC.Conference.API/Controllers/RoomsController.cs:92` | | `ServiceInfoController` | class | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API.Controllers` | `MMCA.ADC.Conference.API/Controllers/ServiceInfoController.cs:20` | | `SessionCategoryItemsController` | class | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API.Controllers` | `MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:48` | | `SessionQuestionAnswersController` | class | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API.Controllers` | `MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:57` | @@ -185,10 +189,11 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `SponsorsController` | class | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API.Controllers` | `MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:37` | | `UpdateCategoryItemRequest` | record | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API.Controllers` | `MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:41` | | `UpdateEventQuestionAnswerRequest` | record | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API.Controllers` | `MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:40` | -| `UpdateRoomRequest` | record | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API.Controllers` | `MMCA.ADC.Conference.API/Controllers/RoomsController.cs:53` | +| `UpdateRoomRequest` | record | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API.Controllers` | `MMCA.ADC.Conference.API/Controllers/RoomsController.cs:58` | | `UpdateSessionQuestionAnswerRequest` | record | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API.Controllers` | `MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:40` | | `ConferenceErrorResources` | class | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API.Resources` | `MMCA.ADC.Conference.API/Resources/ConferenceErrorResources.cs:11` | | `ConferencePermissionGrantsTests` | class | MMCA.ADC.Conference.API.Tests | `MMCA.ADC.Conference.API.Tests.Authorization` | `MMCA.ADC.Conference.API.Tests/Authorization/ConferencePermissionGrantsTests.cs:14` | +| `ActivitiesControllerTests` | class | MMCA.ADC.Conference.API.Tests | `MMCA.ADC.Conference.API.Tests.Controllers` | `MMCA.ADC.Conference.API.Tests/Controllers/ActivitiesControllerTests.cs:26` | | `CategoryItemsControllerTests` | class | MMCA.ADC.Conference.API.Tests | `MMCA.ADC.Conference.API.Tests.Controllers` | `MMCA.ADC.Conference.API.Tests/Controllers/CategoryItemsControllerTests.cs:19` | | `ConditionalWriteConventionTests` | class | MMCA.ADC.Conference.API.Tests | `MMCA.ADC.Conference.API.Tests.Controllers` | `MMCA.ADC.Conference.API.Tests/Controllers/ConditionalWriteConventionTests.cs:16` | | `ConferenceCategoriesControllerTests` | class | MMCA.ADC.Conference.API.Tests | `MMCA.ADC.Conference.API.Tests.Controllers` | `MMCA.ADC.Conference.API.Tests/Controllers/ConferenceCategoriesControllerTests.cs:18` | @@ -197,7 +202,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `EventsControllerTests` | class | MMCA.ADC.Conference.API.Tests | `MMCA.ADC.Conference.API.Tests.Controllers` | `MMCA.ADC.Conference.API.Tests/Controllers/EventsControllerTests.cs:28` | | `EventSpeakersControllerTests` | class | MMCA.ADC.Conference.API.Tests | `MMCA.ADC.Conference.API.Tests.Controllers` | `MMCA.ADC.Conference.API.Tests/Controllers/EventSpeakersControllerTests.cs:25` | | `QuestionsControllerTests` | class | MMCA.ADC.Conference.API.Tests | `MMCA.ADC.Conference.API.Tests.Controllers` | `MMCA.ADC.Conference.API.Tests/Controllers/QuestionsControllerTests.cs:18` | -| `RoomsControllerTests` | class | MMCA.ADC.Conference.API.Tests | `MMCA.ADC.Conference.API.Tests.Controllers` | `MMCA.ADC.Conference.API.Tests/Controllers/RoomsControllerTests.cs:19` | +| `RoomsControllerTests` | class | MMCA.ADC.Conference.API.Tests | `MMCA.ADC.Conference.API.Tests.Controllers` | `MMCA.ADC.Conference.API.Tests/Controllers/RoomsControllerTests.cs:26` | | `SessionCategoryItemsControllerTests` | class | MMCA.ADC.Conference.API.Tests | `MMCA.ADC.Conference.API.Tests.Controllers` | `MMCA.ADC.Conference.API.Tests/Controllers/SessionCategoryItemsControllerTests.cs:25` | | `SessionQuestionAnswersControllerTests` | class | MMCA.ADC.Conference.API.Tests | `MMCA.ADC.Conference.API.Tests.Controllers` | `MMCA.ADC.Conference.API.Tests/Controllers/SessionQuestionAnswersControllerTests.cs:20` | | `SessionsControllerTests` | class | MMCA.ADC.Conference.API.Tests | `MMCA.ADC.Conference.API.Tests.Controllers` | `MMCA.ADC.Conference.API.Tests/Controllers/SessionsControllerTests.cs:29` | @@ -209,7 +214,28 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `ConferenceErrorResourcesTests` | class | MMCA.ADC.Conference.API.Tests | `MMCA.ADC.Conference.API.Tests.Localization` | `MMCA.ADC.Conference.API.Tests/Localization/ConferenceErrorResourcesTests.cs:15` | | `AssemblyReference` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application` | `MMCA.ADC.Conference.Application/AssemblyReference.cs:5` | | `ClassReference` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application` | `MMCA.ADC.Conference.Application/AssemblyReference.cs:11` | -| `DependencyInjection` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application` | `MMCA.ADC.Conference.Application/DependencyInjection.cs:35` | +| `DependencyInjection` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application` | `MMCA.ADC.Conference.Application/DependencyInjection.cs:39` | +| `ActivityNavigationPopulator` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Activities` | `MMCA.ADC.Conference.Application/Activities/ActivityNavigationPopulator.cs:12` | +| `ActivityDTOMapper` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Activities.DTOs` | `MMCA.ADC.Conference.Application/Activities/DTOs/ActivityDTOMapper.cs:13` | +| `ActivityCreateRequest` | record | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Activities.UseCases.Create` | `MMCA.ADC.Conference.Application/Activities/UseCases/Create/ActivityCreateRequest.cs:10` | +| `ActivityCreateRequestMapper` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Activities.UseCases.Create` | `MMCA.ADC.Conference.Application/Activities/UseCases/Create/ActivityCreateRequestMapper.cs:11` | +| `ActivityCreateRequestValidator` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Activities.UseCases.Create` | `MMCA.ADC.Conference.Application/Activities/UseCases/Create/ActivityCreateRequestValidator.cs:7` | +| `CreateActivityHandler` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Activities.UseCases.Create` | `MMCA.ADC.Conference.Application/Activities/UseCases/Create/CreateActivityHandler.cs:16` | +| `GetPublicActivityFilterHandler` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Activities.UseCases.GetPublicActivityFilter` | `MMCA.ADC.Conference.Application/Activities/UseCases/GetPublicActivityFilter/GetPublicActivityFilterHandler.cs:16` | +| `GetPublicActivityFilterQuery` | record | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Activities.UseCases.GetPublicActivityFilter` | `MMCA.ADC.Conference.Application/Activities/UseCases/GetPublicActivityFilter/GetPublicActivityFilterQuery.cs:13` | +| `ActivityUpdateRequest` | record | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Activities.UseCases.Update` | `MMCA.ADC.Conference.Application/Activities/UseCases/Update/ActivityUpdateRequest.cs:10` | +| `ActivityUpdateRequestValidator` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Activities.UseCases.Update` | `MMCA.ADC.Conference.Application/Activities/UseCases/Update/ActivityUpdateRequestValidator.cs:7` | +| `UpdateActivityCommand` | record | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Activities.UseCases.Update` | `MMCA.ADC.Conference.Application/Activities/UseCases/Update/UpdateActivityCommand.cs:9` | +| `UpdateActivityHandler` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Activities.UseCases.Update` | `MMCA.ADC.Conference.Application/Activities/UseCases/Update/UpdateActivityHandler.cs:15` | +| `ActivityDescriptionRules` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Activities.Validation` | `MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:25` | +| `ActivityEventIdRules` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Activities.Validation` | `MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:74` | +| `ActivityNameRules` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Activities.Validation` | `MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:13` | +| `ActivitySortOrderRules` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Activities.Validation` | `MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:111` | +| `ActivityTimeRangeRules` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Activities.Validation` | `MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:87` | +| `ActivityVenueAddressRules` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Activities.Validation` | `MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:49` | +| `ActivityVenueNameRules` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Activities.Validation` | `MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:37` | +| `ActivityVenueUrlRules` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Activities.Validation` | `MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:62` | +| `CategoryItemNavigationPopulator` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Categories` | `MMCA.ADC.Conference.Application/Categories/CategoryItemNavigationPopulator.cs:11` | | `ConferenceCategoryNavigationPopulator` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Categories` | `MMCA.ADC.Conference.Application/Categories/ConferenceCategoryNavigationPopulator.cs:11` | | `CategoryItemDTOMapper` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Categories.DTOs` | `MMCA.ADC.Conference.Application/Categories/DTOs/CategoryItemDTOMapper.cs:12` | | `ConferenceCategoryDTOMapper` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Categories.DTOs` | `MMCA.ADC.Conference.Application/Categories/DTOs/ConferenceCategoryDTOMapper.cs:13` | @@ -235,6 +261,9 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `PublicConferenceVisibility` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Common` | `MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:28` | | `EventLiveValidationService` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events` | `MMCA.ADC.Conference.Application/Events/EventLiveValidationService.cs:22` | | `EventNavigationPopulator` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events` | `MMCA.ADC.Conference.Application/Events/EventNavigationPopulator.cs:11` | +| `EventQuestionAnswerNavigationPopulator` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events` | `MMCA.ADC.Conference.Application/Events/EventQuestionAnswerNavigationPopulator.cs:11` | +| `EventSpeakerNavigationPopulator` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events` | `MMCA.ADC.Conference.Application/Events/EventSpeakerNavigationPopulator.cs:11` | +| `RoomNavigationPopulator` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events` | `MMCA.ADC.Conference.Application/Events/RoomNavigationPopulator.cs:11` | | `RoomChangedHandler` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.DomainEventHandlers` | `MMCA.ADC.Conference.Application/Events/DomainEventHandlers/RoomChangedHandler.cs:11` | | `EventDTOMapper` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.DTOs` | `MMCA.ADC.Conference.Application/Events/DTOs/EventDTOMapper.cs:14` | | `EventQuestionAnswerDTOMapper` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.DTOs` | `MMCA.ADC.Conference.Application/Events/DTOs/EventQuestionAnswerDTOMapper.cs:12` | @@ -264,9 +293,11 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `EventCreateRequest` | record | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.UseCases.Create` | `MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequest.cs:10` | | `EventCreateRequestMapper` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.UseCases.Create` | `MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequestMapper.cs:11` | | `EventCreateRequestValidator` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.UseCases.Create` | `MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequestValidator.cs:7` | -| `DeleteEventHandler` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.UseCases.Delete` | `MMCA.ADC.Conference.Application/Events/UseCases/Delete/DeleteEventHandler.cs:17` | +| `DeleteEventHandler` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.UseCases.Delete` | `MMCA.ADC.Conference.Application/Events/UseCases/Delete/DeleteEventHandler.cs:18` | | `GetPublicEventSpeakerFilterHandler` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.UseCases.GetPublicEventSpeakerFilter` | `MMCA.ADC.Conference.Application/Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterHandler.cs:22` | | `GetPublicEventSpeakerFilterQuery` | record | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.UseCases.GetPublicEventSpeakerFilter` | `MMCA.ADC.Conference.Application/Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterQuery.cs:10` | +| `GetPublicRoomFilterHandler` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.UseCases.GetPublicRoomFilter` | `MMCA.ADC.Conference.Application/Events/UseCases/GetPublicRoomFilter/GetPublicRoomFilterHandler.cs:16` | +| `GetPublicRoomFilterQuery` | record | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.UseCases.GetPublicRoomFilter` | `MMCA.ADC.Conference.Application/Events/UseCases/GetPublicRoomFilter/GetPublicRoomFilterQuery.cs:14` | | `PublishEventCommand` | record | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.UseCases.Publish` | `MMCA.ADC.Conference.Application/Events/UseCases/Publish/PublishEventCommand.cs:12` | | `PublishEventHandler` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.UseCases.Publish` | `MMCA.ADC.Conference.Application/Events/UseCases/Publish/PublishEventHandler.cs:13` | | `CategorySyncStrategy` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize` | `MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/CategorySyncStrategy.cs:12` | @@ -274,7 +305,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `QuestionSyncStrategy` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize` | `MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/QuestionSyncStrategy.cs:12` | | `RefreshFromSessionizeCommand` | record | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize` | `MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeCommand.cs:13` | | `RefreshFromSessionizeHandler` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize` | `MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeHandler.cs:19` | -| `RoomSyncStrategy` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize` | `MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RoomSyncStrategy.cs:10` | +| `RoomSyncStrategy` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize` | `MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RoomSyncStrategy.cs:20` | | `SessionizeSyncContext` | record | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize` | `MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionizeSyncContext.cs:11` | | `SessionizeSyncResult` | record | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize` | `MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/ISessionizeSyncStrategy.cs:21` | | `SessionizeSyncWarnings` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize` | `MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionizeSyncWarnings.cs:9` | @@ -298,10 +329,11 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `UpdateRoomCommand` | record | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.UseCases.UpdateRoom` | `MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomCommand.cs:15` | | `UpdateRoomCommandValidator` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.UseCases.UpdateRoom` | `MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomCommandValidator.cs:7` | | `UpdateRoomHandler` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.UseCases.UpdateRoom` | `MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomHandler.cs:13` | -| `EventDateRangeRules` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.Validation` | `MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:91` | +| `EventDateRangeRules` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.Validation` | `MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:109` | | `EventNameRules` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.Validation` | `MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:13` | | `EventOrganizerContactEmailRules` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.Validation` | `MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:57` | | `EventSponsorshipPacketUrlRules` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.Validation` | `MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:75` | +| `EventTicketingUrlRules` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.Validation` | `MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:93` | | `EventTimeZoneRules` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.Validation` | `MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:25` | | `RoomAccessibilityInfoRules` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.Validation` | `MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:77` | | `RoomCapacityRules` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Events.Validation` | `MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:37` | @@ -320,7 +352,10 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `UpdateQuestionHandler` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Questions.UseCases.Update` | `MMCA.ADC.Conference.Application/Questions/UseCases/Update/UpdateQuestionHandler.cs:19` | | `QuestionTextRules` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Questions.Validation` | `MMCA.ADC.Conference.Application/Questions/Validation/QuestionValidationRules.cs:12` | | `SessionBookmarkValidationService` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Sessions` | `MMCA.ADC.Conference.Application/Sessions/SessionBookmarkValidationService.cs:12` | -| `SessionNavigationPopulator` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Sessions` | `MMCA.ADC.Conference.Application/Sessions/SessionNavigationPopulator.cs:12` | +| `SessionCategoryItemNavigationPopulator` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Sessions` | `MMCA.ADC.Conference.Application/Sessions/SessionCategoryItemNavigationPopulator.cs:11` | +| `SessionNavigationPopulator` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Sessions` | `MMCA.ADC.Conference.Application/Sessions/SessionNavigationPopulator.cs:13` | +| `SessionQuestionAnswerNavigationPopulator` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Sessions` | `MMCA.ADC.Conference.Application/Sessions/SessionQuestionAnswerNavigationPopulator.cs:11` | +| `SessionSpeakerNavigationPopulator` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Sessions` | `MMCA.ADC.Conference.Application/Sessions/SessionSpeakerNavigationPopulator.cs:11` | | `SessionCreatedHandler` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Sessions.DomainEventHandlers` | `MMCA.ADC.Conference.Application/Sessions/DomainEventHandlers/SessionCreatedHandler.cs:11` | | `SessionCategoryItemDTOMapper` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Sessions.DTOs` | `MMCA.ADC.Conference.Application/Sessions/DTOs/SessionCategoryItemDTOMapper.cs:12` | | `SessionDTOMapper` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Sessions.DTOs` | `MMCA.ADC.Conference.Application/Sessions/DTOs/SessionDTOMapper.cs:14` | @@ -401,8 +436,10 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `SessionRoomScheduling` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Sessions.Validation` | `MMCA.ADC.Conference.Application/Sessions/Validation/SessionRoomScheduling.cs:27` | | `SessionStatusRules` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Sessions.Validation` | `MMCA.ADC.Conference.Application/Sessions/Validation/SessionValidationRules.cs:49` | | `SessionTitleRules` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Sessions.Validation` | `MMCA.ADC.Conference.Application/Sessions/Validation/SessionValidationRules.cs:13` | +| `SpeakerCategoryItemNavigationPopulator` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Speakers` | `MMCA.ADC.Conference.Application/Speakers/SpeakerCategoryItemNavigationPopulator.cs:11` | | `SpeakerEntityQueryService` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Speakers` | `MMCA.ADC.Conference.Application/Speakers/SpeakerEntityQueryService.cs:15` | | `SpeakerNavigationPopulator` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Speakers` | `MMCA.ADC.Conference.Application/Speakers/SpeakerNavigationPopulator.cs:11` | +| `SpeakerQuestionAnswerNavigationPopulator` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Speakers` | `MMCA.ADC.Conference.Application/Speakers/SpeakerQuestionAnswerNavigationPopulator.cs:11` | | `SpeakerDeletedHandler` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Speakers.DomainEventHandlers` | `MMCA.ADC.Conference.Application/Speakers/DomainEventHandlers/SpeakerDeletedHandler.cs:20` | | `SpeakerCategoryItemDTOMapper` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Speakers.DTOs` | `MMCA.ADC.Conference.Application/Speakers/DTOs/SpeakerCategoryItemDTOMapper.cs:12` | | `SpeakerDTOMapper` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Speakers.DTOs` | `MMCA.ADC.Conference.Application/Speakers/DTOs/SpeakerDTOMapper.cs:17` | @@ -438,6 +475,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `UpdateSpeakerHandler` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Speakers.UseCases.Update` | `MMCA.ADC.Conference.Application/Speakers/UseCases/Update/UpdateSpeakerHandler.cs:15` | | `SpeakerFirstNameRules` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Speakers.Validation` | `MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:11` | | `SpeakerLastNameRules` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Speakers.Validation` | `MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:22` | +| `SponsorNavigationPopulator` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Sponsors` | `MMCA.ADC.Conference.Application/Sponsors/SponsorNavigationPopulator.cs:12` | | `SponsorDTOMapper` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Sponsors.DTOs` | `MMCA.ADC.Conference.Application/Sponsors/DTOs/SponsorDTOMapper.cs:13` | | `CreateSponsorHandler` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Sponsors.UseCases.Create` | `MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/CreateSponsorHandler.cs:16` | | `SponsorCreateRequest` | record | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Sponsors.UseCases.Create` | `MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequest.cs:11` | @@ -459,6 +497,14 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `SponsorTwitterHandleRules` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Sponsors.Validation` | `MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:74` | | `SponsorWebsiteUrlRules` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Sponsors.Validation` | `MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:50` | | `UserRegisteredHandler` | class | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application.Users.IntegrationEventHandlers` | `MMCA.ADC.Conference.Application/Users/IntegrationEventHandlers/UserRegisteredHandler.cs:40` | +| `ActivityNavigationPopulatorTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Activities` | `MMCA.ADC.Conference.Application.Tests/Activities/ActivityNavigationPopulatorTests.cs:9` | +| `ActivityDTOMapperTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Activities.DTOs` | `MMCA.ADC.Conference.Application.Tests/Activities/DTOs/ActivityDTOMapperTests.cs:7` | +| `CreateActivityHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Activities.UseCases` | `MMCA.ADC.Conference.Application.Tests/Activities/UseCases/CreateActivityHandlerTests.cs:13` | +| `UpdateActivityHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Activities.UseCases` | `MMCA.ADC.Conference.Application.Tests/Activities/UseCases/UpdateActivityHandlerTests.cs:12` | +| `GetPublicActivityFilterHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Activities.UseCases.GetPublicActivityFilter` | `MMCA.ADC.Conference.Application.Tests/Activities/UseCases/GetPublicActivityFilter/GetPublicActivityFilterHandlerTests.cs:18` | +| `ActivityCreateRequestValidatorTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Activities.Validation` | `MMCA.ADC.Conference.Application.Tests/Activities/Validation/ActivityCreateRequestValidatorTests.cs:7` | +| `ActivityUpdateRequestValidatorTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Activities.Validation` | `MMCA.ADC.Conference.Application.Tests/Activities/Validation/ActivityUpdateRequestValidatorTests.cs:7` | +| `CategoryItemNavigationPopulatorTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Categories` | `MMCA.ADC.Conference.Application.Tests/Categories/CategoryItemNavigationPopulatorTests.cs:9` | | `ConferenceCategoryNavigationPopulatorTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Categories` | `MMCA.ADC.Conference.Application.Tests/Categories/ConferenceCategoryNavigationPopulatorTests.cs:9` | | `CategoryItemDTOMapperTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Categories.DTOs` | `MMCA.ADC.Conference.Application.Tests/Categories/DTOs/CategoryItemDTOMapperTests.cs:7` | | `ConferenceCategoryDTOMapperTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Categories.DTOs` | `MMCA.ADC.Conference.Application.Tests/Categories/DTOs/ConferenceCategoryDTOMapperTests.cs:7` | @@ -480,7 +526,10 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `SessionCreatedHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.DomainEvents` | `MMCA.ADC.Conference.Application.Tests/DomainEvents/SessionCreatedHandlerTests.cs:10` | | `EventLiveValidationServiceTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Events` | `MMCA.ADC.Conference.Application.Tests/Events/EventLiveValidationServiceTests.cs:16` | | `EventNavigationPopulatorTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Events` | `MMCA.ADC.Conference.Application.Tests/Events/EventNavigationPopulatorTests.cs:9` | +| `EventQuestionAnswerNavigationPopulatorTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Events` | `MMCA.ADC.Conference.Application.Tests/Events/EventQuestionAnswerNavigationPopulatorTests.cs:9` | +| `EventSpeakerNavigationPopulatorTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Events` | `MMCA.ADC.Conference.Application.Tests/Events/EventSpeakerNavigationPopulatorTests.cs:9` | | `FixedTimeProvider` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Events` | `MMCA.ADC.Conference.Application.Tests/Events/EventLiveValidationServiceTests.cs:388` | +| `RoomNavigationPopulatorTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Events` | `MMCA.ADC.Conference.Application.Tests/Events/RoomNavigationPopulatorTests.cs:9` | | `RoomChangedHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Events.DomainEventHandlers` | `MMCA.ADC.Conference.Application.Tests/Events/DomainEventHandlers/RoomChangedHandlerTests.cs:10` | | `EventDTOMapperTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Events.DTOs` | `MMCA.ADC.Conference.Application.Tests/Events/DTOs/EventDTOMapperTests.cs:7` | | `EventQuestionAnswerDTOMapperTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Events.DTOs` | `MMCA.ADC.Conference.Application.Tests/Events/DTOs/EventQuestionAnswerDTOMapperTests.cs:7` | @@ -491,7 +540,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `AddEventSpeakerHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Events.UseCases` | `MMCA.ADC.Conference.Application.Tests/Events/UseCases/AddEventSpeakerHandlerTests.cs:12` | | `AddRoomHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Events.UseCases` | `MMCA.ADC.Conference.Application.Tests/Events/UseCases/AddRoomHandlerTests.cs:14` | | `CreateEventHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Events.UseCases` | `MMCA.ADC.Conference.Application.Tests/Events/UseCases/CreateEventHandlerTests.cs:13` | -| `DeleteEventHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Events.UseCases` | `MMCA.ADC.Conference.Application.Tests/Events/UseCases/DeleteEventHandlerTests.cs:17` | +| `DeleteEventHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Events.UseCases` | `MMCA.ADC.Conference.Application.Tests/Events/UseCases/DeleteEventHandlerTests.cs:18` | | `PublishEventHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Events.UseCases` | `MMCA.ADC.Conference.Application.Tests/Events/UseCases/PublishEventHandlerTests.cs:11` | | `RefreshFromSessionizeHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Events.UseCases` | `MMCA.ADC.Conference.Application.Tests/Events/UseCases/RefreshFromSessionizeHandlerTests.cs:15` | | `RemoveEventQuestionAnswerHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Events.UseCases` | `MMCA.ADC.Conference.Application.Tests/Events/UseCases/RemoveEventQuestionAnswerHandlerTests.cs:12` | @@ -501,7 +550,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `UpdateEventHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Events.UseCases` | `MMCA.ADC.Conference.Application.Tests/Events/UseCases/UpdateEventHandlerTests.cs:14` | | `UpdateEventQuestionAnswerHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Events.UseCases` | `MMCA.ADC.Conference.Application.Tests/Events/UseCases/UpdateEventQuestionAnswerHandlerTests.cs:12` | | `UpdateRoomHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Events.UseCases` | `MMCA.ADC.Conference.Application.Tests/Events/UseCases/UpdateRoomHandlerTests.cs:11` | -| `GetPublicEventSpeakerFilterHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Events.UseCases.GetPublicEventSpeakerFilter` | `MMCA.ADC.Conference.Application.Tests/Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterHandlerTests.cs:18` | +| `GetPublicEventSpeakerFilterHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Events.UseCases.GetPublicEventSpeakerFilter` | `MMCA.ADC.Conference.Application.Tests/Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterHandlerTests.cs:19` | | `AddEventQuestionAnswerCommandValidatorTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Events.Validation` | `MMCA.ADC.Conference.Application.Tests/Events/Validation/CommandValidatorTests.cs:10` | | `AddEventSpeakerCommandValidatorTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Events.Validation` | `MMCA.ADC.Conference.Application.Tests/Events/Validation/CommandValidatorTests.cs:40` | | `AddRoomCommandValidatorTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Events.Validation` | `MMCA.ADC.Conference.Application.Tests/Events/Validation/CommandValidatorTests.cs:62` | @@ -523,7 +572,11 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `TestQuestionModel` | record | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Questions.Validation` | `MMCA.ADC.Conference.Application.Tests/Questions/Validation/QuestionValidationRulesTests.cs:10` | | `TestQuestionTextValidator` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Questions.Validation` | `MMCA.ADC.Conference.Application.Tests/Questions/Validation/QuestionValidationRulesTests.cs:12` | | `SessionBookmarkValidationServiceTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sessions` | `MMCA.ADC.Conference.Application.Tests/Sessions/SessionBookmarkValidationServiceTests.cs:12` | +| `SessionCategoryItemNavigationPopulatorTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sessions` | `MMCA.ADC.Conference.Application.Tests/Sessions/SessionCategoryItemNavigationPopulatorTests.cs:9` | | `SessionNavigationPopulatorTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sessions` | `MMCA.ADC.Conference.Application.Tests/Sessions/SessionNavigationPopulatorTests.cs:9` | +| `SessionQuestionAnswerNavigationPopulatorTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sessions` | `MMCA.ADC.Conference.Application.Tests/Sessions/SessionQuestionAnswerNavigationPopulatorTests.cs:9` | +| `SessionRoomFilterTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sessions` | `MMCA.ADC.Conference.Application.Tests/Sessions/SessionRoomFilterTests.cs:15` | +| `SessionSpeakerNavigationPopulatorTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sessions` | `MMCA.ADC.Conference.Application.Tests/Sessions/SessionSpeakerNavigationPopulatorTests.cs:9` | | `SessionScoringQueueTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sessions.DecisionSupport` | `MMCA.ADC.Conference.Application.Tests/Sessions/DecisionSupport/SessionScoringQueueTests.cs:11` | | `SessionCreatedHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sessions.DomainEventHandlers` | `MMCA.ADC.Conference.Application.Tests/Sessions/DomainEventHandlers/SessionCreatedHandlerTests.cs:10` | | `SessionCategoryItemDTOMapperTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sessions.DTOs` | `MMCA.ADC.Conference.Application.Tests/Sessions/DTOs/SessionCategoryItemDTOMapperTests.cs:7` | @@ -550,9 +603,9 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `CalendarExportMapperTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sessions.UseCases.ExportCalendar` | `MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/ExportCalendar/CalendarExportMapperTests.cs:14` | | `ExportEventCalendarHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sessions.UseCases.ExportCalendar` | `MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/ExportCalendar/ExportEventCalendarHandlerTests.cs:17` | | `ExportSessionCalendarHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sessions.UseCases.ExportCalendar` | `MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/ExportCalendar/ExportSessionCalendarHandlerTests.cs:16` | -| `GetPublicSessionCategoryItemFilterHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sessions.UseCases.GetPublicSessionCategoryItemFilter` | `MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/GetPublicSessionCategoryItemFilter/GetPublicSessionCategoryItemFilterHandlerTests.cs:17` | +| `GetPublicSessionCategoryItemFilterHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sessions.UseCases.GetPublicSessionCategoryItemFilter` | `MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/GetPublicSessionCategoryItemFilter/GetPublicSessionCategoryItemFilterHandlerTests.cs:18` | | `GetPublicSessionFilterHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sessions.UseCases.GetPublicSessionFilter` | `MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/GetPublicSessionFilter/GetPublicSessionFilterHandlerTests.cs:12` | -| `GetPublicSessionSpeakerFilterHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sessions.UseCases.GetPublicSessionSpeakerFilter` | `MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/GetPublicSessionSpeakerFilter/GetPublicSessionSpeakerFilterHandlerTests.cs:17` | +| `GetPublicSessionSpeakerFilterHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sessions.UseCases.GetPublicSessionSpeakerFilter` | `MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/GetPublicSessionSpeakerFilter/GetPublicSessionSpeakerFilterHandlerTests.cs:18` | | `GetSessionsBySpeakerFilterHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sessions.UseCases.GetSessionsBySpeakerFilter` | `MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/GetSessionsBySpeakerFilter/GetSessionsBySpeakerFilterHandlerTests.cs:16` | | `FixedTimeProvider` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sessions.UseCases.NowNext` | `MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/NowNext/GetNowNextHandlerTests.cs:32` | | `GetNowNextHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sessions.UseCases.NowNext` | `MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/NowNext/GetNowNextHandlerTests.cs:17` | @@ -566,8 +619,10 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `SessionValidationRulesTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sessions.Validation` | `MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionValidationRulesTests.cs:8` | | `TestSessionModel` | record | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sessions.Validation` | `MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionValidationRulesTests.cs:10` | | `TestSessionValidator` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sessions.Validation` | `MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionValidationRulesTests.cs:12` | +| `SpeakerCategoryItemNavigationPopulatorTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Speakers` | `MMCA.ADC.Conference.Application.Tests/Speakers/SpeakerCategoryItemNavigationPopulatorTests.cs:9` | | `SpeakerEntityQueryServiceTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Speakers` | `MMCA.ADC.Conference.Application.Tests/Speakers/SpeakerEntityQueryServiceTests.cs:15` | | `SpeakerNavigationPopulatorTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Speakers` | `MMCA.ADC.Conference.Application.Tests/Speakers/SpeakerNavigationPopulatorTests.cs:9` | +| `SpeakerQuestionAnswerNavigationPopulatorTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Speakers` | `MMCA.ADC.Conference.Application.Tests/Speakers/SpeakerQuestionAnswerNavigationPopulatorTests.cs:9` | | `Mocks` | record | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Speakers.DomainEventHandlers` | `MMCA.ADC.Conference.Application.Tests/Speakers/DomainEventHandlers/SpeakerDeletedHandlerTests.cs:17` | | `SpeakerDeletedHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Speakers.DomainEventHandlers` | `MMCA.ADC.Conference.Application.Tests/Speakers/DomainEventHandlers/SpeakerDeletedHandlerTests.cs:14` | | `SpeakerCategoryItemDTOMapperTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Speakers.DTOs` | `MMCA.ADC.Conference.Application.Tests/Speakers/DTOs/SpeakerCategoryItemDTOMapperTests.cs:7` | @@ -582,8 +637,8 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `RemoveSpeakerCategoryItemHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Speakers.UseCases` | `MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/RemoveSpeakerCategoryItemHandlerTests.cs:11` | | `UnlinkUserFromSpeakerHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Speakers.UseCases` | `MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/UnlinkUserFromSpeakerHandlerTests.cs:12` | | `UpdateSpeakerHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Speakers.UseCases` | `MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/UpdateSpeakerHandlerTests.cs:12` | -| `GetPublicSpeakerCategoryItemFilterHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Speakers.UseCases.GetPublicSpeakerCategoryItemFilter` | `MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/GetPublicSpeakerCategoryItemFilter/GetPublicSpeakerCategoryItemFilterHandlerTests.cs:18` | -| `GetPublicSpeakerFilterHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Speakers.UseCases.GetPublicSpeakerFilter` | `MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/GetPublicSpeakerFilter/GetPublicSpeakerFilterHandlerTests.cs:21` | +| `GetPublicSpeakerCategoryItemFilterHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Speakers.UseCases.GetPublicSpeakerCategoryItemFilter` | `MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/GetPublicSpeakerCategoryItemFilter/GetPublicSpeakerCategoryItemFilterHandlerTests.cs:19` | +| `GetPublicSpeakerFilterHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Speakers.UseCases.GetPublicSpeakerFilter` | `MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/GetPublicSpeakerFilter/GetPublicSpeakerFilterHandlerTests.cs:22` | | `GetSpeakersByEventFilterHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Speakers.UseCases.GetSpeakersByEventFilter` | `MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/GetSpeakersByEventFilter/GetSpeakersByEventFilterHandlerTests.cs:18` | | `AddSpeakerCategoryItemCommandValidatorTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Speakers.Validation` | `MMCA.ADC.Conference.Application.Tests/Speakers/Validation/SpeakerCommandValidatorTests.cs:6` | | `SpeakerCreateRequestValidatorTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Speakers.Validation` | `MMCA.ADC.Conference.Application.Tests/Speakers/Validation/SpeakerCreateRequestValidatorTests.cs:6` | @@ -591,6 +646,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `SpeakerValidationRulesTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Speakers.Validation` | `MMCA.ADC.Conference.Application.Tests/Speakers/Validation/SpeakerValidationRulesTests.cs:8` | | `TestSpeakerModel` | record | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Speakers.Validation` | `MMCA.ADC.Conference.Application.Tests/Speakers/Validation/SpeakerValidationRulesTests.cs:10` | | `TestSpeakerValidator` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Speakers.Validation` | `MMCA.ADC.Conference.Application.Tests/Speakers/Validation/SpeakerValidationRulesTests.cs:12` | +| `SponsorNavigationPopulatorTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sponsors` | `MMCA.ADC.Conference.Application.Tests/Sponsors/SponsorNavigationPopulatorTests.cs:9` | | `SponsorDTOMapperTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sponsors.DTOs` | `MMCA.ADC.Conference.Application.Tests/Sponsors/DTOs/SponsorDTOMapperTests.cs:8` | | `CreateSponsorHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sponsors.UseCases` | `MMCA.ADC.Conference.Application.Tests/Sponsors/UseCases/CreateSponsorHandlerTests.cs:14` | | `UpdateSponsorHandlerTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sponsors.UseCases` | `MMCA.ADC.Conference.Application.Tests/Sponsors/UseCases/UpdateSponsorHandlerTests.cs:13` | @@ -602,7 +658,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `RecordingUnitOfWork` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Support` | `MMCA.ADC.Conference.Application.Tests/Support/TestSupport.cs:246` | | `CategorySyncStrategyTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sync` | `MMCA.ADC.Conference.Application.Tests/Sync/CategorySyncStrategyTests.cs:15` | | `QuestionSyncStrategyTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sync` | `MMCA.ADC.Conference.Application.Tests/Sync/QuestionSyncStrategyTests.cs:16` | -| `RoomSyncStrategyTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sync` | `MMCA.ADC.Conference.Application.Tests/Sync/RoomSyncStrategyTests.cs:10` | +| `RoomSyncStrategyTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sync` | `MMCA.ADC.Conference.Application.Tests/Sync/RoomSyncStrategyTests.cs:11` | | `SessionSyncStrategyTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sync` | `MMCA.ADC.Conference.Application.Tests/Sync/SessionSyncStrategyTests.cs:15` | | `SpeakerSyncStrategyTests` | class | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Sync` | `MMCA.ADC.Conference.Application.Tests/Sync/SpeakerSyncStrategyTests.cs:12` | | `Fakes` | record | MMCA.ADC.Conference.Application.Tests | `MMCA.ADC.Conference.Application.Tests.Users.IntegrationEventHandlers` | `MMCA.ADC.Conference.Application.Tests/Users/IntegrationEventHandlers/UserRegisteredHandlerTests.cs:18` | @@ -613,6 +669,9 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `SessionBookmarkValidationServiceGrpcAdapter` | class | MMCA.ADC.Conference.Contracts | `MMCA.ADC.Conference.Contracts` | `MMCA.ADC.Conference.Contracts/SessionBookmarkValidationServiceGrpcAdapter.cs:24` | | `AssemblyReference` | class | MMCA.ADC.Conference.Domain | `MMCA.ADC.Conference.Domain` | `MMCA.ADC.Conference.Domain/AssemblyReference.cs:5` | | `ClassReference` | class | MMCA.ADC.Conference.Domain | `MMCA.ADC.Conference.Domain` | `MMCA.ADC.Conference.Domain/AssemblyReference.cs:11` | +| `Activity` | class | MMCA.ADC.Conference.Domain | `MMCA.ADC.Conference.Domain.Activities` | `MMCA.ADC.Conference.Domain/Activities/Activity.cs:20` | +| `ActivityInvariants` | class | MMCA.ADC.Conference.Domain | `MMCA.ADC.Conference.Domain.Activities` | `MMCA.ADC.Conference.Domain/Activities/ActivityInvariants.cs:10` | +| `ActivityChanged` | record | MMCA.ADC.Conference.Domain | `MMCA.ADC.Conference.Domain.Activities.DomainEvents` | `MMCA.ADC.Conference.Domain/Activities/DomainEvents/ActivityChanged.cs:12` | | `Category` | class | MMCA.ADC.Conference.Domain | `MMCA.ADC.Conference.Domain.Categories` | `MMCA.ADC.Conference.Domain/Categories/Category.cs:16` | | `CategoryInvariants` | class | MMCA.ADC.Conference.Domain | `MMCA.ADC.Conference.Domain.Categories` | `MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:11` | | `CategoryItem` | class | MMCA.ADC.Conference.Domain | `MMCA.ADC.Conference.Domain.Categories` | `MMCA.ADC.Conference.Domain/Categories/CategoryItem.cs:14` | @@ -630,8 +689,8 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `Question` | class | MMCA.ADC.Conference.Domain | `MMCA.ADC.Conference.Domain.Questions` | `MMCA.ADC.Conference.Domain/Questions/Question.cs:14` | | `QuestionInvariants` | class | MMCA.ADC.Conference.Domain | `MMCA.ADC.Conference.Domain.Questions` | `MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:10` | | `QuestionChanged` | record | MMCA.ADC.Conference.Domain | `MMCA.ADC.Conference.Domain.Questions.DomainEvents` | `MMCA.ADC.Conference.Domain/Questions/DomainEvents/QuestionChanged.cs:12` | -| `EventCascadeDeletionDomainService` | class | MMCA.ADC.Conference.Domain | `MMCA.ADC.Conference.Domain.Services` | `MMCA.ADC.Conference.Domain/Services/EventCascadeDeletionDomainService.cs:14` | -| `IEventCascadeDeletionDomainService` | interface | MMCA.ADC.Conference.Domain | `MMCA.ADC.Conference.Domain.Services` | `MMCA.ADC.Conference.Domain/Services/IEventCascadeDeletionDomainService.cs:13` | +| `EventCascadeDeletionDomainService` | class | MMCA.ADC.Conference.Domain | `MMCA.ADC.Conference.Domain.Services` | `MMCA.ADC.Conference.Domain/Services/EventCascadeDeletionDomainService.cs:16` | +| `IEventCascadeDeletionDomainService` | interface | MMCA.ADC.Conference.Domain | `MMCA.ADC.Conference.Domain.Services` | `MMCA.ADC.Conference.Domain/Services/IEventCascadeDeletionDomainService.cs:15` | | `Session` | class | MMCA.ADC.Conference.Domain | `MMCA.ADC.Conference.Domain.Sessions` | `MMCA.ADC.Conference.Domain/Sessions/Session.cs:22` | | `SessionAiScore` | class | MMCA.ADC.Conference.Domain | `MMCA.ADC.Conference.Domain.Sessions` | `MMCA.ADC.Conference.Domain/Sessions/SessionAiScore.cs:13` | | `SessionCategoryItem` | class | MMCA.ADC.Conference.Domain | `MMCA.ADC.Conference.Domain.Sessions` | `MMCA.ADC.Conference.Domain/Sessions/SessionCategoryItem.cs:13` | @@ -653,6 +712,8 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `Sponsor` | class | MMCA.ADC.Conference.Domain | `MMCA.ADC.Conference.Domain.Sponsors` | `MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:18` | | `SponsorInvariants` | class | MMCA.ADC.Conference.Domain | `MMCA.ADC.Conference.Domain.Sponsors` | `MMCA.ADC.Conference.Domain/Sponsors/SponsorInvariants.cs:10` | | `SponsorChanged` | record | MMCA.ADC.Conference.Domain | `MMCA.ADC.Conference.Domain.Sponsors.DomainEvents` | `MMCA.ADC.Conference.Domain/Sponsors/DomainEvents/SponsorChanged.cs:12` | +| `ActivityTests` | class | MMCA.ADC.Conference.Domain.Tests | `MMCA.ADC.Conference.Domain.Tests.Activities` | `MMCA.ADC.Conference.Domain.Tests/Activities/ActivityTests.cs:10` | +| `ActivityBuilder` | class | MMCA.ADC.Conference.Domain.Tests | `MMCA.ADC.Conference.Domain.Tests.Builders` | `MMCA.ADC.Conference.Domain.Tests/Builders/ActivityBuilder.cs:10` | | `EventBuilder` | class | MMCA.ADC.Conference.Domain.Tests | `MMCA.ADC.Conference.Domain.Tests.Builders` | `MMCA.ADC.Conference.Domain.Tests/Builders/EventBuilder.cs:10` | | `SessionBuilder` | class | MMCA.ADC.Conference.Domain.Tests | `MMCA.ADC.Conference.Domain.Tests.Builders` | `MMCA.ADC.Conference.Domain.Tests/Builders/SessionBuilder.cs:10` | | `SpeakerBuilder` | class | MMCA.ADC.Conference.Domain.Tests | `MMCA.ADC.Conference.Domain.Tests.Builders` | `MMCA.ADC.Conference.Domain.Tests/Builders/SpeakerBuilder.cs:10` | @@ -660,7 +721,8 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `CategoryTests` | class | MMCA.ADC.Conference.Domain.Tests | `MMCA.ADC.Conference.Domain.Tests.Categories` | `MMCA.ADC.Conference.Domain.Tests/Categories/CategoryTests.cs:8` | | `EventQuestionAnswerTests` | class | MMCA.ADC.Conference.Domain.Tests | `MMCA.ADC.Conference.Domain.Tests.Events` | `MMCA.ADC.Conference.Domain.Tests/Events/EventQuestionAnswerTests.cs:14` | | `EventSpeakerTests` | class | MMCA.ADC.Conference.Domain.Tests | `MMCA.ADC.Conference.Domain.Tests.Events` | `MMCA.ADC.Conference.Domain.Tests/Events/EventSpeakerTests.cs:13` | -| `EventTests` | class | MMCA.ADC.Conference.Domain.Tests | `MMCA.ADC.Conference.Domain.Tests.Events` | `MMCA.ADC.Conference.Domain.Tests/Events/EventTests.cs:9` | +| `EventTests` | class | MMCA.ADC.Conference.Domain.Tests | `MMCA.ADC.Conference.Domain.Tests.Events` | `MMCA.ADC.Conference.Domain.Tests/Events/EventTests.cs:10` | +| `ActivityInvariantsTests` | class | MMCA.ADC.Conference.Domain.Tests | `MMCA.ADC.Conference.Domain.Tests.Invariants` | `MMCA.ADC.Conference.Domain.Tests/Invariants/ActivityInvariantsTests.cs:6` | | `CategoryInvariantsTests` | class | MMCA.ADC.Conference.Domain.Tests | `MMCA.ADC.Conference.Domain.Tests.Invariants` | `MMCA.ADC.Conference.Domain.Tests/Invariants/CategoryInvariantsTests.cs:6` | | `EventInvariantsTests` | class | MMCA.ADC.Conference.Domain.Tests | `MMCA.ADC.Conference.Domain.Tests.Invariants` | `MMCA.ADC.Conference.Domain.Tests/Invariants/EventInvariantsTests.cs:6` | | `QuestionInvariantsTests` | class | MMCA.ADC.Conference.Domain.Tests | `MMCA.ADC.Conference.Domain.Tests.Invariants` | `MMCA.ADC.Conference.Domain.Tests/Invariants/QuestionInvariantsTests.cs:6` | @@ -681,8 +743,9 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `AssemblyReference` | class | MMCA.ADC.Conference.Infrastructure | `MMCA.ADC.Conference.Infrastructure` | `MMCA.ADC.Conference.Infrastructure/AssemblyReference.cs:5` | | `ClassReference` | class | MMCA.ADC.Conference.Infrastructure | `MMCA.ADC.Conference.Infrastructure` | `MMCA.ADC.Conference.Infrastructure/AssemblyReference.cs:11` | | `DependencyInjection` | class | MMCA.ADC.Conference.Infrastructure | `MMCA.ADC.Conference.Infrastructure` | `MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:12` | -| `ModuleApplicationDbContext` | class | MMCA.ADC.Conference.Infrastructure | `MMCA.ADC.Conference.Infrastructure.Persistence.DbContexts` | `MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:19` | -| `ConferenceModuleDbSeeder` | class | MMCA.ADC.Conference.Infrastructure | `MMCA.ADC.Conference.Infrastructure.Persistence.DbContexts.Seeding` | `MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/Seeding/ConferenceModuleDbSeeder.cs:24` | +| `ModuleApplicationDbContext` | class | MMCA.ADC.Conference.Infrastructure | `MMCA.ADC.Conference.Infrastructure.Persistence.DbContexts` | `MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:20` | +| `ConferenceModuleDbSeeder` | class | MMCA.ADC.Conference.Infrastructure | `MMCA.ADC.Conference.Infrastructure.Persistence.DbContexts.Seeding` | `MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/Seeding/ConferenceModuleDbSeeder.cs:25` | +| `ActivityConfiguration` | class | MMCA.ADC.Conference.Infrastructure | `MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration` | `MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/ActivityConfiguration.cs:11` | | `CategoryItemConfiguration` | class | MMCA.ADC.Conference.Infrastructure | `MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration` | `MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/CategoryItemConfiguration.cs:10` | | `ConferenceCategoryConfiguration` | class | MMCA.ADC.Conference.Infrastructure | `MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration` | `MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/ConferenceCategoryConfiguration.cs:13` | | `EventConfiguration` | class | MMCA.ADC.Conference.Infrastructure | `MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration` | `MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/EventConfiguration.cs:11` | @@ -738,7 +801,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `ConferenceIntegrationTestBase` | class | MMCA.ADC.Conference.IntegrationTests | `MMCA.ADC.Conference.IntegrationTests.Infrastructure` | `MMCA.ADC.Conference.IntegrationTests/Infrastructure/ConferenceIntegrationTestBase.cs:15` | | `ConferenceIntegrationTestCollection` | class | MMCA.ADC.Conference.IntegrationTests | `MMCA.ADC.Conference.IntegrationTests.Infrastructure` | `MMCA.ADC.Conference.IntegrationTests/Infrastructure/ConferenceIntegrationTestCollection.cs:8` | | `ConferenceIntegrationTestFixture` | class | MMCA.ADC.Conference.IntegrationTests | `MMCA.ADC.Conference.IntegrationTests.Infrastructure` | `MMCA.ADC.Conference.IntegrationTests/Infrastructure/ConferenceIntegrationTestFixture.cs:17` | -| `ConferenceTestWebApplicationFactory` | class | MMCA.ADC.Conference.IntegrationTests | `MMCA.ADC.Conference.IntegrationTests.Infrastructure` | `MMCA.ADC.Conference.IntegrationTests/Infrastructure/ConferenceTestWebApplicationFactory.cs:32` | +| `ConferenceTestWebApplicationFactory` | class | MMCA.ADC.Conference.IntegrationTests | `MMCA.ADC.Conference.IntegrationTests.Infrastructure` | `MMCA.ADC.Conference.IntegrationTests/Infrastructure/ConferenceTestWebApplicationFactory.cs:33` | | `FakeAiScoringService` | class | MMCA.ADC.Conference.IntegrationTests | `MMCA.ADC.Conference.IntegrationTests.Infrastructure` | `MMCA.ADC.Conference.IntegrationTests/Infrastructure/FakeAiScoringService.cs:11` | | `FakeBookmarkCountService` | class | MMCA.ADC.Conference.IntegrationTests | `MMCA.ADC.Conference.IntegrationTests.Infrastructure` | `MMCA.ADC.Conference.IntegrationTests/Infrastructure/FakeBookmarkCountService.cs:9` | | `FakeSessionizeService` | class | MMCA.ADC.Conference.IntegrationTests | `MMCA.ADC.Conference.IntegrationTests.Infrastructure` | `MMCA.ADC.Conference.IntegrationTests/Infrastructure/FakeSessionizeService.cs:12` | @@ -766,6 +829,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `EventLiveValidationGrpcService` | class | MMCA.ADC.Conference.Service | `MMCA.ADC.Conference.Service.Grpc` | `MMCA.ADC.Conference.Service/Grpc/EventLiveValidationGrpcService.cs:22` | | `SessionBookmarksGrpcService` | class | MMCA.ADC.Conference.Service | `MMCA.ADC.Conference.Service.Grpc` | `MMCA.ADC.Conference.Service/Grpc/SessionBookmarksGrpcService.cs:23` | | `ConferenceFeatures` | class | MMCA.ADC.Conference.Shared | `MMCA.ADC.Conference.Shared` | `MMCA.ADC.Conference.Shared/ConferenceFeatures.cs:8` | +| `ActivityDTO` | record | MMCA.ADC.Conference.Shared | `MMCA.ADC.Conference.Shared.Activities` | `MMCA.ADC.Conference.Shared/Activities/ActivityDTO.cs:10` | | `ConferencePermissions` | class | MMCA.ADC.Conference.Shared | `MMCA.ADC.Conference.Shared.Authorization` | `MMCA.ADC.Conference.Shared/Authorization/ConferencePermissions.cs:9` | | `ConferenceReadAudience` | class | MMCA.ADC.Conference.Shared | `MMCA.ADC.Conference.Shared.Authorization` | `MMCA.ADC.Conference.Shared/Authorization/ConferenceReadAudience.cs:23` | | `CategoryItemDTO` | record | MMCA.ADC.Conference.Shared | `MMCA.ADC.Conference.Shared.Categories` | `MMCA.ADC.Conference.Shared/Categories/CategoryItemDTO.cs:8` | @@ -839,6 +903,10 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `ConferenceRoutePaths` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI` | `MMCA.ADC.Conference.UI/ConferenceRoutePaths.cs:8` | | `ConferenceUIModule` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI` | `MMCA.ADC.Conference.UI/ConferenceUIModule.cs:14` | | `DependencyInjection` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI` | `MMCA.ADC.Conference.UI/DependencyInjection.cs:11` | +| `InfiniteScrollSentinel` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Components` | `MMCA.ADC.Conference.UI/Components/InfiniteScrollSentinel.razor.cs:21` | +| `ActivityCreate` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Activity` | `MMCA.ADC.Conference.UI/Pages/Activity/ActivityCreate.razor.cs:16` | +| `ActivityDetail` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Activity` | `MMCA.ADC.Conference.UI/Pages/Activity/ActivityDetail.razor.cs:16` | +| `ActivityList` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Activity` | `MMCA.ADC.Conference.UI/Pages/Activity/ActivityList.razor.cs:19` | | `ConferenceCategoryCreate` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.ConferenceCategory` | `MMCA.ADC.Conference.UI/Pages/ConferenceCategory/ConferenceCategoryCreate.razor.cs:9` | | `ConferenceCategoryDetail` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.ConferenceCategory` | `MMCA.ADC.Conference.UI/Pages/ConferenceCategory/ConferenceCategoryDetail.razor.cs:11` | | `ConferenceCategoryList` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.ConferenceCategory` | `MMCA.ADC.Conference.UI/Pages/ConferenceCategory/ConferenceCategoryList.razor.cs:11` | @@ -847,23 +915,26 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `EventList` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Event` | `MMCA.ADC.Conference.UI/Pages/Event/EventList.razor.cs:16` | | `OrganizerEventFeedback` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Feedback` | `MMCA.ADC.Conference.UI/Pages/Feedback/OrganizerEventFeedback.razor.cs:14` | | `OrganizerSessionFeedback` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Feedback` | `MMCA.ADC.Conference.UI/Pages/Feedback/OrganizerSessionFeedback.razor.cs:14` | -| `ADCCollectionResult` | record | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Home` | `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:282` | -| `ADCEventInfo` | record | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Home` | `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:284` | -| `ADCHome` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Home` | `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:17` | -| `ADCSponsorCollectionResult` | record | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Home` | `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:295` | -| `ADCSponsorInfo` | record | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Home` | `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:297` | -| `ConferenceTrackInfo` | record | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Home` | `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:341` | -| `EventPhase` | enum | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Home` | `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:57` | -| `KeynoteSpeakerInfo` | record | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Home` | `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:340` | -| `CachedSessionPage` | record | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Public` | `MMCA.ADC.Conference.UI/Pages/Public/PublicSessionList.razor.cs:342` | -| `PublicEventDetail` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Public` | `MMCA.ADC.Conference.UI/Pages/Public/PublicEventDetail.razor.cs:14` | -| `PublicEventList` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Public` | `MMCA.ADC.Conference.UI/Pages/Public/PublicEventList.razor.cs:17` | +| `ADCCollectionResult` | record | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Home` | `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:297` | +| `ADCEventInfo` | record | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Home` | `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:299` | +| `ADCHome` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Home` | `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:18` | +| `ADCSponsorCollectionResult` | record | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Home` | `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:311` | +| `ADCSponsorInfo` | record | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Home` | `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:313` | +| `ConferenceTrackInfo` | record | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Home` | `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:372` | +| `EventPhase` | enum | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Home` | `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:72` | +| `KeynoteSpeakerInfo` | record | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Home` | `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:371` | +| `PreConferenceWorkshopInfo` | record | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Home` | `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:379` | +| `CachedSessionPage` | record | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Public` | `MMCA.ADC.Conference.UI/Pages/Public/PublicSessionList.razor.cs:366` | +| `PublicActivityList` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Public` | `MMCA.ADC.Conference.UI/Pages/Public/PublicActivityList.razor.cs:19` | +| `PublicEventDetail` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Public` | `MMCA.ADC.Conference.UI/Pages/Public/PublicEventDetail.razor.cs:16` | +| `PublicEventList` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Public` | `MMCA.ADC.Conference.UI/Pages/Public/PublicEventList.razor.cs:30` | +| `PublicScheduleRoomOptions` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Public` | `MMCA.ADC.Conference.UI/Pages/Public/PublicScheduleRoomOptions.cs:11` | | `PublicSessionDetail` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Public` | `MMCA.ADC.Conference.UI/Pages/Public/PublicSessionDetail.razor.cs:20` | | `PublicSessionList` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Public` | `MMCA.ADC.Conference.UI/Pages/Public/PublicSessionList.razor.cs:25` | | `PublicSessionListFilterBar` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Public` | `MMCA.ADC.Conference.UI/Pages/Public/PublicSessionListFilterBar.razor.cs:15` | -| `PublicSessionListView` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Public` | `MMCA.ADC.Conference.UI/Pages/Public/PublicSessionListView.razor.cs:21` | +| `PublicSessionListView` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Public` | `MMCA.ADC.Conference.UI/Pages/Public/PublicSessionListView.razor.cs:23` | | `PublicSpeakerDetail` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Public` | `MMCA.ADC.Conference.UI/Pages/Public/PublicSpeakerDetail.razor.cs:14` | -| `PublicSpeakerList` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Public` | `MMCA.ADC.Conference.UI/Pages/Public/PublicSpeakerList.razor.cs:27` | +| `PublicSpeakerList` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Public` | `MMCA.ADC.Conference.UI/Pages/Public/PublicSpeakerList.razor.cs:35` | | `PublicSponsorList` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Public` | `MMCA.ADC.Conference.UI/Pages/Public/PublicSponsorList.razor.cs:18` | | `QuestionCreate` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Question` | `MMCA.ADC.Conference.UI/Pages/Question/QuestionCreate.razor.cs:9` | | `QuestionDetail` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Question` | `MMCA.ADC.Conference.UI/Pages/Question/QuestionDetail.razor.cs:11` | @@ -890,6 +961,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `SponsorCreate` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Sponsor` | `MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorCreate.razor.cs:15` | | `SponsorDetail` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Sponsor` | `MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorDetail.razor.cs:15` | | `SponsorList` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Pages.Sponsor` | `MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorList.razor.cs:19` | +| `ActivityService` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Services` | `MMCA.ADC.Conference.UI/Services/ActivityService.cs:10` | | `CategoryItemInfo` | record | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Services` | `MMCA.ADC.Conference.UI/Services/ICategoryItemLookupService.cs:7` | | `CategoryItemLookupService` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Services` | `MMCA.ADC.Conference.UI/Services/CategoryItemLookupService.cs:11` | | `CategoryItemService` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Services` | `MMCA.ADC.Conference.UI/Services/CategoryItemService.cs:10` | @@ -898,6 +970,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `EventLookupService` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Services` | `MMCA.ADC.Conference.UI/Services/EventLookupService.cs:11` | | `EventService` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Services` | `MMCA.ADC.Conference.UI/Services/EventService.cs:13` | | `EventSpeakerService` | class | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Services` | `MMCA.ADC.Conference.UI/Services/ChildEntityServices.cs:14` | +| `IActivityUIService` | interface | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Services` | `MMCA.ADC.Conference.UI/Services/IActivityUIService.cs:9` | | `ICategoryItemLookupService` | interface | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Services` | `MMCA.ADC.Conference.UI/Services/ICategoryItemLookupService.cs:16` | | `ICategoryItemUIService` | interface | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Services` | `MMCA.ADC.Conference.UI/Services/ICategoryItemUIService.cs:9` | | `IConferenceCategoryUIService` | interface | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI.Services` | `MMCA.ADC.Conference.UI/Services/IConferenceCategoryUIService.cs:9` | @@ -938,17 +1011,25 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `AddToCalendarButtonTests` | class | MMCA.ADC.Conference.UI.Tests | `MMCA.ADC.Conference.UI.Tests.Components` | `MMCA.ADC.Conference.UI.Tests/Components/AddToCalendarButtonTests.cs:22` | | `QrCodeButtonTests` | class | MMCA.ADC.Conference.UI.Tests | `MMCA.ADC.Conference.UI.Tests.Components` | `MMCA.ADC.Conference.UI.Tests/Components/QrCodeButtonTests.cs:14` | | `SharePageButtonTests` | class | MMCA.ADC.Conference.UI.Tests | `MMCA.ADC.Conference.UI.Tests.Components` | `MMCA.ADC.Conference.UI.Tests/Components/SharePageButtonTests.cs:17` | +| `ActivityCreateTests` | class | MMCA.ADC.Conference.UI.Tests | `MMCA.ADC.Conference.UI.Tests.Pages.Activity` | `MMCA.ADC.Conference.UI.Tests/Pages/Activity/ActivityCreateTests.cs:19` | +| `ActivityDetailTests` | class | MMCA.ADC.Conference.UI.Tests | `MMCA.ADC.Conference.UI.Tests.Pages.Activity` | `MMCA.ADC.Conference.UI.Tests/Pages/Activity/ActivityDetailTests.cs:18` | | `EventCreateTests` | class | MMCA.ADC.Conference.UI.Tests | `MMCA.ADC.Conference.UI.Tests.Pages.Event` | `MMCA.ADC.Conference.UI.Tests/Pages/Event/EventCreateTests.cs:17` | | `EventDetailTests` | class | MMCA.ADC.Conference.UI.Tests | `MMCA.ADC.Conference.UI.Tests.Pages.Event` | `MMCA.ADC.Conference.UI.Tests/Pages/Event/EventDetailTests.cs:17` | | `OrganizerEventFeedbackTests` | class | MMCA.ADC.Conference.UI.Tests | `MMCA.ADC.Conference.UI.Tests.Pages.Feedback` | `MMCA.ADC.Conference.UI.Tests/Pages/Feedback/OrganizerEventFeedbackTests.cs:18` | | `OrganizerSessionFeedbackTests` | class | MMCA.ADC.Conference.UI.Tests | `MMCA.ADC.Conference.UI.Tests.Pages.Feedback` | `MMCA.ADC.Conference.UI.Tests/Pages/Feedback/OrganizerSessionFeedbackTests.cs:19` | +| `ADCHomeTests` | class | MMCA.ADC.Conference.UI.Tests | `MMCA.ADC.Conference.UI.Tests.Pages.Home` | `MMCA.ADC.Conference.UI.Tests/Pages/Home/ADCHomeTests.cs:20` | +| `ADCHomeTicketingTests` | class | MMCA.ADC.Conference.UI.Tests | `MMCA.ADC.Conference.UI.Tests.Pages.Home` | `MMCA.ADC.Conference.UI.Tests/Pages/Home/ADCHomeTests.cs:124` | +| `PublicActivityListTests` | class | MMCA.ADC.Conference.UI.Tests | `MMCA.ADC.Conference.UI.Tests.Pages.Public` | `MMCA.ADC.Conference.UI.Tests/Pages/Public/PublicActivityListTests.cs:16` | | `PublicEventDetailTests` | class | MMCA.ADC.Conference.UI.Tests | `MMCA.ADC.Conference.UI.Tests.Pages.Public` | `MMCA.ADC.Conference.UI.Tests/Pages/Public/PublicEventDetailTests.cs:15` | +| `PublicEventListRedirectTests` | class | MMCA.ADC.Conference.UI.Tests | `MMCA.ADC.Conference.UI.Tests.Pages.Public` | `MMCA.ADC.Conference.UI.Tests/Pages/Public/PublicEventListRedirectTests.cs:34` | | `PublicSessionDetailBookmarkTests` | class | MMCA.ADC.Conference.UI.Tests | `MMCA.ADC.Conference.UI.Tests.Pages.Public` | `MMCA.ADC.Conference.UI.Tests/Pages/Public/PublicSessionDetailBookmarkTests.cs:23` | | `PublicSessionDetailLiveButtonTests` | class | MMCA.ADC.Conference.UI.Tests | `MMCA.ADC.Conference.UI.Tests.Pages.Public` | `MMCA.ADC.Conference.UI.Tests/Pages/Public/PublicSessionDetailLiveButtonTests.cs:23` | | `PublicSessionDetailTests` | class | MMCA.ADC.Conference.UI.Tests | `MMCA.ADC.Conference.UI.Tests.Pages.Public` | `MMCA.ADC.Conference.UI.Tests/Pages/Public/PublicSessionDetailTests.cs:22` | | `PublicSessionListEventFilterTests` | class | MMCA.ADC.Conference.UI.Tests | `MMCA.ADC.Conference.UI.Tests.Pages.Public` | `MMCA.ADC.Conference.UI.Tests/Pages/Public/PublicSessionListEventFilterTests.cs:22` | +| `PublicSessionListRoomFilterTests` | class | MMCA.ADC.Conference.UI.Tests | `MMCA.ADC.Conference.UI.Tests.Pages.Public` | `MMCA.ADC.Conference.UI.Tests/Pages/Public/PublicSessionListRoomFilterTests.cs:20` | | `PublicSessionListViewBookmarkTests` | class | MMCA.ADC.Conference.UI.Tests | `MMCA.ADC.Conference.UI.Tests.Pages.Public` | `MMCA.ADC.Conference.UI.Tests/Pages/Public/PublicSessionListViewBookmarkTests.cs:19` | | `PublicSpeakerDetailTests` | class | MMCA.ADC.Conference.UI.Tests | `MMCA.ADC.Conference.UI.Tests.Pages.Public` | `MMCA.ADC.Conference.UI.Tests/Pages/Public/PublicSpeakerDetailTests.cs:13` | +| `PublicSpeakerListCardGridTests` | class | MMCA.ADC.Conference.UI.Tests | `MMCA.ADC.Conference.UI.Tests.Pages.Public` | `MMCA.ADC.Conference.UI.Tests/Pages/Public/PublicSpeakerListCardGridTests.cs:24` | | `PublicSpeakerListEventFilterTests` | class | MMCA.ADC.Conference.UI.Tests | `MMCA.ADC.Conference.UI.Tests.Pages.Public` | `MMCA.ADC.Conference.UI.Tests/Pages/Public/PublicSpeakerListEventFilterTests.cs:21` | | `PublicSponsorListTests` | class | MMCA.ADC.Conference.UI.Tests | `MMCA.ADC.Conference.UI.Tests.Pages.Public` | `MMCA.ADC.Conference.UI.Tests/Pages/Public/PublicSponsorListTests.cs:16` | | `QuestionCreateTests` | class | MMCA.ADC.Conference.UI.Tests | `MMCA.ADC.Conference.UI.Tests.Pages.Question` | `MMCA.ADC.Conference.UI.Tests/Pages/Question/QuestionCreateTests.cs:18` | @@ -974,11 +1055,11 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `CrossServiceSmokeTests` | class | MMCA.ADC.CrossService.IntegrationTests | `MMCA.ADC.CrossService.IntegrationTests.CrossService` | `MMCA.ADC.CrossService.IntegrationTests/CrossService/CrossServiceSmokeTests.cs:15` | | `SpeakerLinkBrokerFlowTests` | class | MMCA.ADC.CrossService.IntegrationTests | `MMCA.ADC.CrossService.IntegrationTests.CrossService` | `MMCA.ADC.CrossService.IntegrationTests/CrossService/SpeakerLinkBrokerFlowTests.cs:17` | | `UserRegisteredBrokerFlowTests` | class | MMCA.ADC.CrossService.IntegrationTests | `MMCA.ADC.CrossService.IntegrationTests.CrossService` | `MMCA.ADC.CrossService.IntegrationTests/CrossService/UserRegisteredBrokerFlowTests.cs:20` | -| `ConferenceCrossServiceFactory` | class | MMCA.ADC.CrossService.IntegrationTests | `MMCA.ADC.CrossService.IntegrationTests.Infrastructure` | `MMCA.ADC.CrossService.IntegrationTests/Infrastructure/ConferenceCrossServiceFactory.cs:28` | +| `ConferenceCrossServiceFactory` | class | MMCA.ADC.CrossService.IntegrationTests | `MMCA.ADC.CrossService.IntegrationTests.Infrastructure` | `MMCA.ADC.CrossService.IntegrationTests/Infrastructure/ConferenceCrossServiceFactory.cs:29` | | `CrossServiceCollection` | class | MMCA.ADC.CrossService.IntegrationTests | `MMCA.ADC.CrossService.IntegrationTests.Infrastructure` | `MMCA.ADC.CrossService.IntegrationTests/Infrastructure/CrossServiceCollection.cs:10` | | `CrossServiceFixture` | class | MMCA.ADC.CrossService.IntegrationTests | `MMCA.ADC.CrossService.IntegrationTests.Infrastructure` | `MMCA.ADC.CrossService.IntegrationTests/Infrastructure/CrossServiceFixture.cs:23` | | `CrossServiceTestBase` | class | MMCA.ADC.CrossService.IntegrationTests | `MMCA.ADC.CrossService.IntegrationTests.Infrastructure` | `MMCA.ADC.CrossService.IntegrationTests/Infrastructure/CrossServiceTestBase.cs:20` | -| `EngagementCrossServiceFactory` | class | MMCA.ADC.CrossService.IntegrationTests | `MMCA.ADC.CrossService.IntegrationTests.Infrastructure` | `MMCA.ADC.CrossService.IntegrationTests/Infrastructure/EngagementCrossServiceFactory.cs:32` | +| `EngagementCrossServiceFactory` | class | MMCA.ADC.CrossService.IntegrationTests | `MMCA.ADC.CrossService.IntegrationTests.Infrastructure` | `MMCA.ADC.CrossService.IntegrationTests/Infrastructure/EngagementCrossServiceFactory.cs:33` | | `IdentityCrossServiceFactory` | class | MMCA.ADC.CrossService.IntegrationTests | `MMCA.ADC.CrossService.IntegrationTests.Infrastructure` | `MMCA.ADC.CrossService.IntegrationTests/Infrastructure/IdentityCrossServiceFactory.cs:28` | | `RateLimiterNeutralizer` | class | MMCA.ADC.CrossService.IntegrationTests | `MMCA.ADC.CrossService.IntegrationTests.Infrastructure` | `MMCA.ADC.CrossService.IntegrationTests/Infrastructure/IdentityCrossServiceFactory.cs:43` | | `E2ETestCollection` | class | MMCA.ADC.E2E.Tests | `MMCA.ADC.E2E.Tests.Infrastructure` | `MMCA.ADC.E2E.Tests/Infrastructure/E2ETestCollection.cs:8` | @@ -1005,7 +1086,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `PublicSessionDetailPage` | class | MMCA.ADC.E2E.Tests | `MMCA.ADC.E2E.Tests.PageObjects` | `MMCA.ADC.E2E.Tests/PageObjects/PublicSessionDetailPage.cs:3` | | `PublicSessionListPage` | class | MMCA.ADC.E2E.Tests | `MMCA.ADC.E2E.Tests.PageObjects` | `MMCA.ADC.E2E.Tests/PageObjects/PublicSessionListPage.cs:3` | | `PublicSpeakerDetailPage` | class | MMCA.ADC.E2E.Tests | `MMCA.ADC.E2E.Tests.PageObjects` | `MMCA.ADC.E2E.Tests/PageObjects/PublicSpeakerDetailPage.cs:3` | -| `PublicSpeakerListPage` | class | MMCA.ADC.E2E.Tests | `MMCA.ADC.E2E.Tests.PageObjects` | `MMCA.ADC.E2E.Tests/PageObjects/PublicSpeakerListPage.cs:3` | +| `PublicSpeakerListPage` | class | MMCA.ADC.E2E.Tests | `MMCA.ADC.E2E.Tests.PageObjects` | `MMCA.ADC.E2E.Tests/PageObjects/PublicSpeakerListPage.cs:10` | | `PublicSponsorListPage` | class | MMCA.ADC.E2E.Tests | `MMCA.ADC.E2E.Tests.PageObjects` | `MMCA.ADC.E2E.Tests/PageObjects/PublicSponsorListPage.cs:8` | | `QuestionCreatePage` | class | MMCA.ADC.E2E.Tests | `MMCA.ADC.E2E.Tests.PageObjects` | `MMCA.ADC.E2E.Tests/PageObjects/QuestionCreatePage.cs:3` | | `QuestionDetailPage` | class | MMCA.ADC.E2E.Tests | `MMCA.ADC.E2E.Tests.PageObjects` | `MMCA.ADC.E2E.Tests/PageObjects/QuestionDetailPage.cs:3` | @@ -1032,7 +1113,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `PseudoLocalizationTests` | class | MMCA.ADC.E2E.Tests | `MMCA.ADC.E2E.Tests.Workflows` | `MMCA.ADC.E2E.Tests/Workflows/PseudoLocalizationTests.cs:34` | | `WebVitalsTests` | class | MMCA.ADC.E2E.Tests | `MMCA.ADC.E2E.Tests.Workflows` | `MMCA.ADC.E2E.Tests/Workflows/WebVitalsTests.cs:23` | | `DataIntegrityTests` | class | MMCA.ADC.E2E.Tests | `MMCA.ADC.E2E.Tests.Workflows.Conference` | `MMCA.ADC.E2E.Tests/Workflows/Conference/DataIntegrityTests.cs:11` | -| `FeaturedEvent` | record | MMCA.ADC.E2E.Tests | `MMCA.ADC.E2E.Tests.Workflows.Conference` | `MMCA.ADC.E2E.Tests/Workflows/Conference/PublicBrowseTests.cs:450` | +| `FeaturedEvent` | record | MMCA.ADC.E2E.Tests | `MMCA.ADC.E2E.Tests.Workflows.Conference` | `MMCA.ADC.E2E.Tests/Workflows/Conference/PublicBrowseTests.cs:462` | | `OrganizerCategoryManagementTests` | class | MMCA.ADC.E2E.Tests | `MMCA.ADC.E2E.Tests.Workflows.Conference` | `MMCA.ADC.E2E.Tests/Workflows/Conference/OrganizerCategoryManagementTests.cs:9` | | `OrganizerEventManagementTests` | class | MMCA.ADC.E2E.Tests | `MMCA.ADC.E2E.Tests.Workflows.Conference` | `MMCA.ADC.E2E.Tests/Workflows/Conference/OrganizerEventManagementTests.cs:9` | | `OrganizerFeedbackAnalyticsTests` | class | MMCA.ADC.E2E.Tests | `MMCA.ADC.E2E.Tests.Workflows.Conference` | `MMCA.ADC.E2E.Tests/Workflows/Conference/OrganizerFeedbackAnalyticsTests.cs:9` | @@ -1057,6 +1138,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `AccountDeletionTests` | class | MMCA.ADC.E2E.Tests | `MMCA.ADC.E2E.Tests.Workflows.Identity` | `MMCA.ADC.E2E.Tests/Workflows/Identity/AccountDeletionTests.cs:9` | | `AuthorizationTests` | class | MMCA.ADC.E2E.Tests | `MMCA.ADC.E2E.Tests.Workflows.Identity` | `MMCA.ADC.E2E.Tests/Workflows/Identity/AuthorizationTests.cs:11` | | `LogoutTests` | class | MMCA.ADC.E2E.Tests | `MMCA.ADC.E2E.Tests.Workflows.Identity` | `MMCA.ADC.E2E.Tests/Workflows/Identity/LogoutTests.cs:5` | +| `PasswordResetTests` | class | MMCA.ADC.E2E.Tests | `MMCA.ADC.E2E.Tests.Workflows.Identity` | `MMCA.ADC.E2E.Tests/Workflows/Identity/PasswordResetTests.cs:5` | | `ProfileManagementTests` | class | MMCA.ADC.E2E.Tests | `MMCA.ADC.E2E.Tests.Workflows.Identity` | `MMCA.ADC.E2E.Tests/Workflows/Identity/ProfileManagementTests.cs:8` | | `UserLoginTests` | class | MMCA.ADC.E2E.Tests | `MMCA.ADC.E2E.Tests.Workflows.Identity` | `MMCA.ADC.E2E.Tests/Workflows/Identity/UserLoginTests.cs:5` | | `UserManagementTests` | class | MMCA.ADC.E2E.Tests | `MMCA.ADC.E2E.Tests.Workflows.Identity` | `MMCA.ADC.E2E.Tests/Workflows/Identity/UserManagementTests.cs:9` | @@ -1112,6 +1194,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `LivePollDTOMapper` | class | MMCA.ADC.Engagement.Application | `MMCA.ADC.Engagement.Application.LivePolls.DTOs` | `MMCA.ADC.Engagement.Application/LivePolls/DTOs/LivePollDTOMapper.cs:13` | | `LivePollAuthorization` | class | MMCA.ADC.Engagement.Application | `MMCA.ADC.Engagement.Application.LivePolls.Services` | `MMCA.ADC.Engagement.Application/LivePolls/Services/LivePollAuthorization.cs:12` | | `LivePollNavigationPopulator` | class | MMCA.ADC.Engagement.Application | `MMCA.ADC.Engagement.Application.LivePolls.Services` | `MMCA.ADC.Engagement.Application/LivePolls/Services/LivePollNavigationPopulator.cs:11` | +| `LivePollOptionNavigationPopulator` | class | MMCA.ADC.Engagement.Application | `MMCA.ADC.Engagement.Application.LivePolls.Services` | `MMCA.ADC.Engagement.Application/LivePolls/Services/LivePollOptionNavigationPopulator.cs:11` | | `LivePollResultsBuilder` | class | MMCA.ADC.Engagement.Application | `MMCA.ADC.Engagement.Application.LivePolls.Services` | `MMCA.ADC.Engagement.Application/LivePolls/Services/LivePollResultsBuilder.cs:12` | | `CastVoteCommand` | record | MMCA.ADC.Engagement.Application | `MMCA.ADC.Engagement.Application.LivePolls.UseCases.CastVote` | `MMCA.ADC.Engagement.Application/LivePolls/UseCases/CastVote/CastVoteCommand.cs:11` | | `CastVoteCommandValidator` | class | MMCA.ADC.Engagement.Application | `MMCA.ADC.Engagement.Application.LivePolls.UseCases.CastVote` | `MMCA.ADC.Engagement.Application/LivePolls/UseCases/CastVote/CastVoteCommandValidator.cs:8` | @@ -1130,7 +1213,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `GetPollResultsQuery` | record | MMCA.ADC.Engagement.Application | `MMCA.ADC.Engagement.Application.LivePolls.UseCases.GetPollResults` | `MMCA.ADC.Engagement.Application/LivePolls/UseCases/GetPollResults/GetPollResultsQuery.cs:9` | | `OpenLivePollCommand` | record | MMCA.ADC.Engagement.Application | `MMCA.ADC.Engagement.Application.LivePolls.UseCases.Open` | `MMCA.ADC.Engagement.Application/LivePolls/UseCases/Open/OpenLivePollCommand.cs:12` | | `OpenLivePollHandler` | class | MMCA.ADC.Engagement.Application | `MMCA.ADC.Engagement.Application.LivePolls.UseCases.Open` | `MMCA.ADC.Engagement.Application/LivePolls/UseCases/Open/OpenLivePollHandler.cs:20` | -| `SessionQuestionSubmittedPointsHandler` | class | MMCA.ADC.Engagement.Application | `MMCA.ADC.Engagement.Application.Points.DomainEventHandlers` | `MMCA.ADC.Engagement.Application/Points/DomainEventHandlers/SessionQuestionSubmittedPointsHandler.cs:39` | +| `SessionQuestionSubmittedPointsHandler` | class | MMCA.ADC.Engagement.Application | `MMCA.ADC.Engagement.Application.Points.DomainEventHandlers` | `MMCA.ADC.Engagement.Application/Points/DomainEventHandlers/SessionQuestionSubmittedPointsHandler.cs:51` | | `AttendeeCheckedInPointsHandler` | class | MMCA.ADC.Engagement.Application | `MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers` | `MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/AttendeeCheckedInPointsHandler.cs:30` | | `EventFeedbackSubmittedPointsHandler` | class | MMCA.ADC.Engagement.Application | `MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers` | `MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/EventFeedbackSubmittedPointsHandler.cs:26` | | `SessionFeedbackSubmittedPointsHandler` | class | MMCA.ADC.Engagement.Application | `MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers` | `MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/SessionFeedbackSubmittedPointsHandler.cs:28` | @@ -1189,6 +1272,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `LivePollVoteChangedHandlerTests` | class | MMCA.ADC.Engagement.Application.Tests | `MMCA.ADC.Engagement.Application.Tests.LivePolls.DomainEventHandlers` | `MMCA.ADC.Engagement.Application.Tests/LivePolls/DomainEventHandlers/LivePollVoteChangedHandlerTests.cs:23` | | `RecordingQueue` | class | MMCA.ADC.Engagement.Application.Tests | `MMCA.ADC.Engagement.Application.Tests.LivePolls.DomainEventHandlers` | `MMCA.ADC.Engagement.Application.Tests/LivePolls/DomainEventHandlers/LivePollVoteChangedHandlerTests.cs:157` | | `LivePollDTOMapperTests` | class | MMCA.ADC.Engagement.Application.Tests | `MMCA.ADC.Engagement.Application.Tests.LivePolls.DTOs` | `MMCA.ADC.Engagement.Application.Tests/LivePolls/DTOs/LivePollDTOMapperTests.cs:9` | +| `LivePollOptionNavigationPopulatorTests` | class | MMCA.ADC.Engagement.Application.Tests | `MMCA.ADC.Engagement.Application.Tests.LivePolls.Services` | `MMCA.ADC.Engagement.Application.Tests/LivePolls/Services/LivePollOptionNavigationPopulatorTests.cs:9` | | `LivePollResultsBuilderTests` | class | MMCA.ADC.Engagement.Application.Tests | `MMCA.ADC.Engagement.Application.Tests.LivePolls.Services` | `MMCA.ADC.Engagement.Application.Tests/LivePolls/Services/LivePollResultsBuilderTests.cs:17` | | `CastVoteHandlerTests` | class | MMCA.ADC.Engagement.Application.Tests | `MMCA.ADC.Engagement.Application.Tests.LivePolls.UseCases` | `MMCA.ADC.Engagement.Application.Tests/LivePolls/UseCases/CastVoteHandlerTests.cs:13` | | `CloseLivePollHandlerTests` | class | MMCA.ADC.Engagement.Application.Tests | `MMCA.ADC.Engagement.Application.Tests.LivePolls.UseCases` | `MMCA.ADC.Engagement.Application.Tests/LivePolls/UseCases/CloseLivePollHandlerTests.cs:15` | @@ -1202,7 +1286,8 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `OpenLivePollHandlerTests` | class | MMCA.ADC.Engagement.Application.Tests | `MMCA.ADC.Engagement.Application.Tests.LivePolls.UseCases` | `MMCA.ADC.Engagement.Application.Tests/LivePolls/UseCases/OpenLivePollHandlerTests.cs:15` | | `CastVoteCommandValidatorTests` | class | MMCA.ADC.Engagement.Application.Tests | `MMCA.ADC.Engagement.Application.Tests.LivePolls.Validation` | `MMCA.ADC.Engagement.Application.Tests/LivePolls/Validation/CastVoteCommandValidatorTests.cs:6` | | `CreateLivePollCommandValidatorTests` | class | MMCA.ADC.Engagement.Application.Tests | `MMCA.ADC.Engagement.Application.Tests.LivePolls.Validation` | `MMCA.ADC.Engagement.Application.Tests/LivePolls/Validation/CreateLivePollCommandValidatorTests.cs:9` | -| `SessionQuestionSubmittedPointsHandlerTests` | class | MMCA.ADC.Engagement.Application.Tests | `MMCA.ADC.Engagement.Application.Tests.Points.DomainEventHandlers` | `MMCA.ADC.Engagement.Application.Tests/Points/DomainEventHandlers/SessionQuestionSubmittedPointsHandlerTests.cs:18` | +| `SessionQuestionSubmittedPointsHandlerTests` | class | MMCA.ADC.Engagement.Application.Tests | `MMCA.ADC.Engagement.Application.Tests.Points.DomainEventHandlers` | `MMCA.ADC.Engagement.Application.Tests/Points/DomainEventHandlers/SessionQuestionSubmittedPointsHandlerTests.cs:29` | +| `ThrowingPointsAwarder` | class | MMCA.ADC.Engagement.Application.Tests | `MMCA.ADC.Engagement.Application.Tests.Points.DomainEventHandlers` | `MMCA.ADC.Engagement.Application.Tests/Points/DomainEventHandlers/SessionQuestionSubmittedPointsHandlerTests.cs:236` | | `AttendeeCheckedInPointsHandlerTests` | class | MMCA.ADC.Engagement.Application.Tests | `MMCA.ADC.Engagement.Application.Tests.Points.IntegrationEventHandlers` | `MMCA.ADC.Engagement.Application.Tests/Points/IntegrationEventHandlers/AttendeeCheckedInPointsHandlerTests.cs:15` | | `EventFeedbackSubmittedPointsHandlerTests` | class | MMCA.ADC.Engagement.Application.Tests | `MMCA.ADC.Engagement.Application.Tests.Points.IntegrationEventHandlers` | `MMCA.ADC.Engagement.Application.Tests/Points/IntegrationEventHandlers/EventFeedbackSubmittedPointsHandlerTests.cs:14` | | `SessionFeedbackSubmittedPointsHandlerTests` | class | MMCA.ADC.Engagement.Application.Tests | `MMCA.ADC.Engagement.Application.Tests.Points.IntegrationEventHandlers` | `MMCA.ADC.Engagement.Application.Tests/Points/IntegrationEventHandlers/SessionFeedbackSubmittedPointsHandlerTests.cs:14` | @@ -1253,7 +1338,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `LivePollVote` | class | MMCA.ADC.Engagement.Domain | `MMCA.ADC.Engagement.Domain.LivePolls` | `MMCA.ADC.Engagement.Domain/LivePolls/LivePollVote.cs:19` | | `LivePollVoteInvariants` | class | MMCA.ADC.Engagement.Domain | `MMCA.ADC.Engagement.Domain.LivePolls` | `MMCA.ADC.Engagement.Domain/LivePolls/LivePollVoteInvariants.cs:9` | | `LivePollChanged` | record | MMCA.ADC.Engagement.Domain | `MMCA.ADC.Engagement.Domain.LivePolls.DomainEvents` | `MMCA.ADC.Engagement.Domain/LivePolls/DomainEvents/LivePollChanged.cs:17` | -| `LivePollVoteChanged` | record | MMCA.ADC.Engagement.Domain | `MMCA.ADC.Engagement.Domain.LivePolls.DomainEvents` | `MMCA.ADC.Engagement.Domain/LivePolls/DomainEvents/LivePollVoteChanged.cs:15` | +| `LivePollVoteChanged` | record | MMCA.ADC.Engagement.Domain | `MMCA.ADC.Engagement.Domain.LivePolls.DomainEvents` | `MMCA.ADC.Engagement.Domain/LivePolls/DomainEvents/LivePollVoteChanged.cs:21` | | `LeaderboardOptIn` | class | MMCA.ADC.Engagement.Domain | `MMCA.ADC.Engagement.Domain.Points` | `MMCA.ADC.Engagement.Domain/Points/LeaderboardOptIn.cs:19` | | `LeaderboardOptInInvariants` | class | MMCA.ADC.Engagement.Domain | `MMCA.ADC.Engagement.Domain.Points` | `MMCA.ADC.Engagement.Domain/Points/LeaderboardOptInInvariants.cs:9` | | `PointsEntry` | class | MMCA.ADC.Engagement.Domain | `MMCA.ADC.Engagement.Domain.Points` | `MMCA.ADC.Engagement.Domain/Points/PointsEntry.cs:31` | @@ -1266,11 +1351,11 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `SessionQuestionInvariants` | class | MMCA.ADC.Engagement.Domain | `MMCA.ADC.Engagement.Domain.SessionQuestions` | `MMCA.ADC.Engagement.Domain/SessionQuestions/SessionQuestionInvariants.cs:9` | | `SessionQuestionUpvote` | class | MMCA.ADC.Engagement.Domain | `MMCA.ADC.Engagement.Domain.SessionQuestions` | `MMCA.ADC.Engagement.Domain/SessionQuestions/SessionQuestionUpvote.cs:19` | | `SessionQuestionUpvoteInvariants` | class | MMCA.ADC.Engagement.Domain | `MMCA.ADC.Engagement.Domain.SessionQuestions` | `MMCA.ADC.Engagement.Domain/SessionQuestions/SessionQuestionUpvoteInvariants.cs:9` | -| `SessionQuestionChanged` | record | MMCA.ADC.Engagement.Domain | `MMCA.ADC.Engagement.Domain.SessionQuestions.DomainEvents` | `MMCA.ADC.Engagement.Domain/SessionQuestions/DomainEvents/SessionQuestionChanged.cs:17` | -| `SessionQuestionUpvoteChanged` | record | MMCA.ADC.Engagement.Domain | `MMCA.ADC.Engagement.Domain.SessionQuestions.DomainEvents` | `MMCA.ADC.Engagement.Domain/SessionQuestions/DomainEvents/SessionQuestionUpvoteChanged.cs:14` | +| `SessionQuestionChanged` | record | MMCA.ADC.Engagement.Domain | `MMCA.ADC.Engagement.Domain.SessionQuestions.DomainEvents` | `MMCA.ADC.Engagement.Domain/SessionQuestions/DomainEvents/SessionQuestionChanged.cs:30` | +| `SessionQuestionUpvoteChanged` | record | MMCA.ADC.Engagement.Domain | `MMCA.ADC.Engagement.Domain.SessionQuestions.DomainEvents` | `MMCA.ADC.Engagement.Domain/SessionQuestions/DomainEvents/SessionQuestionUpvoteChanged.cs:20` | | `UserSessionBookmark` | class | MMCA.ADC.Engagement.Domain | `MMCA.ADC.Engagement.Domain.UserSessionBookmarks` | `MMCA.ADC.Engagement.Domain/UserSessionBookmarks/UserSessionBookmark.cs:16` | | `UserSessionBookmarkInvariants` | class | MMCA.ADC.Engagement.Domain | `MMCA.ADC.Engagement.Domain.UserSessionBookmarks` | `MMCA.ADC.Engagement.Domain/UserSessionBookmarks/UserSessionBookmarkInvariants.cs:9` | -| `UserSessionBookmarkChanged` | record | MMCA.ADC.Engagement.Domain | `MMCA.ADC.Engagement.Domain.UserSessionBookmarks.DomainEvents` | `MMCA.ADC.Engagement.Domain/UserSessionBookmarks/DomainEvents/UserSessionBookmarkChanged.cs:15` | +| `UserSessionBookmarkChanged` | record | MMCA.ADC.Engagement.Domain | `MMCA.ADC.Engagement.Domain.UserSessionBookmarks.DomainEvents` | `MMCA.ADC.Engagement.Domain/UserSessionBookmarks/DomainEvents/UserSessionBookmarkChanged.cs:21` | | `AttendeeBadgeTests` | class | MMCA.ADC.Engagement.Domain.Tests | `MMCA.ADC.Engagement.Domain.Tests.Badges` | `MMCA.ADC.Engagement.Domain.Tests/Badges/AttendeeBadgeTests.cs:6` | | `CheckInTests` | class | MMCA.ADC.Engagement.Domain.Tests | `MMCA.ADC.Engagement.Domain.Tests.CheckIns` | `MMCA.ADC.Engagement.Domain.Tests/CheckIns/CheckInTests.cs:8` | | `LivePollTests` | class | MMCA.ADC.Engagement.Domain.Tests | `MMCA.ADC.Engagement.Domain.Tests.LivePolls` | `MMCA.ADC.Engagement.Domain.Tests/LivePolls/LivePollTests.cs:10` | @@ -1314,13 +1399,13 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `EngagementIntegrationTestBase` | class | MMCA.ADC.Engagement.IntegrationTests | `MMCA.ADC.Engagement.IntegrationTests.Infrastructure` | `MMCA.ADC.Engagement.IntegrationTests/Infrastructure/EngagementIntegrationTestBase.cs:12` | | `EngagementIntegrationTestCollection` | class | MMCA.ADC.Engagement.IntegrationTests | `MMCA.ADC.Engagement.IntegrationTests.Infrastructure` | `MMCA.ADC.Engagement.IntegrationTests/Infrastructure/EngagementIntegrationTestCollection.cs:8` | | `EngagementIntegrationTestFixture` | class | MMCA.ADC.Engagement.IntegrationTests | `MMCA.ADC.Engagement.IntegrationTests.Infrastructure` | `MMCA.ADC.Engagement.IntegrationTests/Infrastructure/EngagementIntegrationTestFixture.cs:17` | -| `EngagementTestWebApplicationFactory` | class | MMCA.ADC.Engagement.IntegrationTests | `MMCA.ADC.Engagement.IntegrationTests.Infrastructure` | `MMCA.ADC.Engagement.IntegrationTests/Infrastructure/EngagementTestWebApplicationFactory.cs:39` | +| `EngagementTestWebApplicationFactory` | class | MMCA.ADC.Engagement.IntegrationTests | `MMCA.ADC.Engagement.IntegrationTests.Infrastructure` | `MMCA.ADC.Engagement.IntegrationTests/Infrastructure/EngagementTestWebApplicationFactory.cs:40` | | `FakeEventLiveValidationService` | class | MMCA.ADC.Engagement.IntegrationTests | `MMCA.ADC.Engagement.IntegrationTests.Infrastructure` | `MMCA.ADC.Engagement.IntegrationTests/Infrastructure/FakeEventLiveValidationService.cs:22` | | `FakeSessionBookmarkValidationService` | class | MMCA.ADC.Engagement.IntegrationTests | `MMCA.ADC.Engagement.IntegrationTests.Infrastructure` | `MMCA.ADC.Engagement.IntegrationTests/Infrastructure/FakeSessionBookmarkValidationService.cs:12` | | `LivePollAuthorizationTests` | class | MMCA.ADC.Engagement.IntegrationTests | `MMCA.ADC.Engagement.IntegrationTests.LivePolls` | `MMCA.ADC.Engagement.IntegrationTests/LivePolls/LivePollAuthorizationTests.cs:13` | | `OrganizerLivePollLifecycleTests` | class | MMCA.ADC.Engagement.IntegrationTests | `MMCA.ADC.Engagement.IntegrationTests.LivePolls` | `MMCA.ADC.Engagement.IntegrationTests/LivePolls/OrganizerLivePollLifecycleTests.cs:18` | -| `LedgerRow` | record | MMCA.ADC.Engagement.IntegrationTests | `MMCA.ADC.Engagement.IntegrationTests.Points` | `MMCA.ADC.Engagement.IntegrationTests/Points/PointsAwardRoundTripTests.cs:278` | -| `PointsAwardRoundTripTests` | class | MMCA.ADC.Engagement.IntegrationTests | `MMCA.ADC.Engagement.IntegrationTests.Points` | `MMCA.ADC.Engagement.IntegrationTests/Points/PointsAwardRoundTripTests.cs:32` | +| `LedgerRow` | record | MMCA.ADC.Engagement.IntegrationTests | `MMCA.ADC.Engagement.IntegrationTests.Points` | `MMCA.ADC.Engagement.IntegrationTests/Points/PointsAwardRoundTripTests.cs:332` | +| `PointsAwardRoundTripTests` | class | MMCA.ADC.Engagement.IntegrationTests | `MMCA.ADC.Engagement.IntegrationTests.Points` | `MMCA.ADC.Engagement.IntegrationTests/Points/PointsAwardRoundTripTests.cs:41` | | `PointsEndpointTests` | class | MMCA.ADC.Engagement.IntegrationTests | `MMCA.ADC.Engagement.IntegrationTests.Points` | `MMCA.ADC.Engagement.IntegrationTests/Points/PointsEndpointTests.cs:33` | | `SessionQuestionLifecycleTests` | class | MMCA.ADC.Engagement.IntegrationTests | `MMCA.ADC.Engagement.IntegrationTests.SessionQuestions` | `MMCA.ADC.Engagement.IntegrationTests/SessionQuestions/SessionQuestionLifecycleTests.cs:18` | | `SelfHttpWarmupTask` | class | MMCA.ADC.Engagement.Service | `MMCA.ADC.Engagement.Service` | `MMCA.ADC.Engagement.Service/SelfHttpWarmupTask.cs:23` | @@ -1497,12 +1582,12 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `SessionReminderCoordinatorTests` | class | MMCA.ADC.Engagement.UI.Tests | `MMCA.ADC.Engagement.UI.Tests.Services` | `MMCA.ADC.Engagement.UI.Tests/Services/SessionReminderCoordinatorTests.cs:16` | | `SessionReminderPlannerTests` | class | MMCA.ADC.Engagement.UI.Tests | `MMCA.ADC.Engagement.UI.Tests.Services` | `MMCA.ADC.Engagement.UI.Tests/Services/SessionReminderPlannerTests.cs:11` | | `Http2ForwardingConfigFilter` | class | MMCA.ADC.Gateway | `MMCA.ADC.Gateway` | `MMCA.ADC.Gateway/Http2ForwardingConfigFilter.cs:23` | -| `ClusterProfile` | record | MMCA.ADC.Gateway.Tests | `MMCA.ADC.Gateway.Tests` | `MMCA.ADC.Gateway.Tests/RouteMapTests.cs:257` | +| `ClusterProfile` | record | MMCA.ADC.Gateway.Tests | `MMCA.ADC.Gateway.Tests` | `MMCA.ADC.Gateway.Tests/RouteMapTests.cs:258` | | `GatewayApplicationFactory` | class | MMCA.ADC.Gateway.Tests | `MMCA.ADC.Gateway.Tests` | `MMCA.ADC.Gateway.Tests/GatewayHardeningTests.cs:252` | | `GatewayHardeningTests` | class | MMCA.ADC.Gateway.Tests | `MMCA.ADC.Gateway.Tests` | `MMCA.ADC.Gateway.Tests/GatewayHardeningTests.cs:27` | | `GracefulShutdownTests` | class | MMCA.ADC.Gateway.Tests | `MMCA.ADC.Gateway.Tests` | `MMCA.ADC.Gateway.Tests/GracefulShutdownTests.cs:9` | | `RecordingHttpForwarder` | class | MMCA.ADC.Gateway.Tests | `MMCA.ADC.Gateway.Tests` | `MMCA.ADC.Gateway.Tests/RecordingHttpForwarder.cs:21` | -| `RouteMapApplicationFactory` | class | MMCA.ADC.Gateway.Tests | `MMCA.ADC.Gateway.Tests` | `MMCA.ADC.Gateway.Tests/RouteMapTests.cs:268` | +| `RouteMapApplicationFactory` | class | MMCA.ADC.Gateway.Tests | `MMCA.ADC.Gateway.Tests` | `MMCA.ADC.Gateway.Tests/RouteMapTests.cs:269` | | `RouteMapTests` | class | MMCA.ADC.Gateway.Tests | `MMCA.ADC.Gateway.Tests` | `MMCA.ADC.Gateway.Tests/RouteMapTests.cs:35` | | `SecurityHeadersTests` | class | MMCA.ADC.Gateway.Tests | `MMCA.ADC.Gateway.Tests` | `MMCA.ADC.Gateway.Tests/SecurityHeadersTests.cs:11` | | `AssemblyReference` | class | MMCA.ADC.Identity.API | `MMCA.ADC.Identity.API` | `MMCA.ADC.Identity.API/AssemblyReference.cs:5` | @@ -1513,6 +1598,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `HttpContextExternalLoginEmailVerifier` | class | MMCA.ADC.Identity.API | `MMCA.ADC.Identity.API.Authentication` | `MMCA.ADC.Identity.API/Authentication/HttpContextExternalLoginEmailVerifier.cs:17` | | `AuthController` | class | MMCA.ADC.Identity.API | `MMCA.ADC.Identity.API.Controllers` | `MMCA.ADC.Identity.API/Controllers/AuthController.cs:29` | | `OAuthController` | class | MMCA.ADC.Identity.API | `MMCA.ADC.Identity.API.Controllers` | `MMCA.ADC.Identity.API/Controllers/OAuthController.cs:20` | +| `PasswordResetController` | class | MMCA.ADC.Identity.API | `MMCA.ADC.Identity.API.Controllers` | `MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:28` | | `UserClaimsController` | class | MMCA.ADC.Identity.API | `MMCA.ADC.Identity.API.Controllers` | `MMCA.ADC.Identity.API/Controllers/UserClaimsController.cs:16` | | `UsersController` | class | MMCA.ADC.Identity.API | `MMCA.ADC.Identity.API.Controllers` | `MMCA.ADC.Identity.API/Controllers/UsersController.cs:32` | | `IdentityErrorResources` | class | MMCA.ADC.Identity.API | `MMCA.ADC.Identity.API.Resources` | `MMCA.ADC.Identity.API/Resources/IdentityErrorResources.cs:11` | @@ -1542,6 +1628,8 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `ExportUserDataHandler` | class | MMCA.ADC.Identity.Application | `MMCA.ADC.Identity.Application.Users.UseCases.ExportUserData` | `MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/ExportUserDataHandler.cs:30` | | `ExportUserDataQuery` | record | MMCA.ADC.Identity.Application | `MMCA.ADC.Identity.Application.Users.UseCases.ExportUserData` | `MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/ExportUserDataQuery.cs:12` | | `NotificationUserDataExportSection` | class | MMCA.ADC.Identity.Application | `MMCA.ADC.Identity.Application.Users.UseCases.ExportUserData` | `MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/NotificationUserDataExportSection.cs:18` | +| `ForgotPasswordCommand` | record | MMCA.ADC.Identity.Application | `MMCA.ADC.Identity.Application.Users.UseCases.ForgotPassword` | `MMCA.ADC.Identity.Application/Users/UseCases/ForgotPassword/ForgotPasswordCommand.cs:12` | +| `ForgotPasswordHandler` | class | MMCA.ADC.Identity.Application | `MMCA.ADC.Identity.Application.Users.UseCases.ForgotPassword` | `MMCA.ADC.Identity.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandler.cs:20` | | `GetUserPreferencesHandler` | class | MMCA.ADC.Identity.Application | `MMCA.ADC.Identity.Application.Users.UseCases.GetPreferences` | `MMCA.ADC.Identity.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandler.cs:13` | | `GetUserAvatarHandler` | class | MMCA.ADC.Identity.Application | `MMCA.ADC.Identity.Application.Users.UseCases.GetUserAvatar` | `MMCA.ADC.Identity.Application/Users/UseCases/GetUserAvatar/GetUserAvatarHandler.cs:10` | | `GetUserAvatarQuery` | record | MMCA.ADC.Identity.Application | `MMCA.ADC.Identity.Application.Users.UseCases.GetUserAvatar` | `MMCA.ADC.Identity.Application/Users/UseCases/GetUserAvatar/GetUserAvatarQuery.cs:5` | @@ -1549,6 +1637,8 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `GetUsersQuery` | record | MMCA.ADC.Identity.Application | `MMCA.ADC.Identity.Application.Users.UseCases.GetUsers` | `MMCA.ADC.Identity.Application/Users/UseCases/GetUsers/GetUsersQuery.cs:12` | | `RemoveUserAvatarCommand` | record | MMCA.ADC.Identity.Application | `MMCA.ADC.Identity.Application.Users.UseCases.RemoveUserAvatar` | `MMCA.ADC.Identity.Application/Users/UseCases/RemoveUserAvatar/RemoveUserAvatarCommand.cs:8` | | `RemoveUserAvatarHandler` | class | MMCA.ADC.Identity.Application | `MMCA.ADC.Identity.Application.Users.UseCases.RemoveUserAvatar` | `MMCA.ADC.Identity.Application/Users/UseCases/RemoveUserAvatar/RemoveUserAvatarHandler.cs:14` | +| `ResetPasswordCommand` | record | MMCA.ADC.Identity.Application | `MMCA.ADC.Identity.Application.Users.UseCases.ResetPassword` | `MMCA.ADC.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordCommand.cs:14` | +| `ResetPasswordHandler` | class | MMCA.ADC.Identity.Application | `MMCA.ADC.Identity.Application.Users.UseCases.ResetPassword` | `MMCA.ADC.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordHandler.cs:18` | | `SetUserAvatarCommand` | record | MMCA.ADC.Identity.Application | `MMCA.ADC.Identity.Application.Users.UseCases.SetUserAvatar` | `MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarCommand.cs:10` | | `SetUserAvatarHandler` | class | MMCA.ADC.Identity.Application | `MMCA.ADC.Identity.Application.Users.UseCases.SetUserAvatar` | `MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarHandler.cs:16` | | `ChangePasswordRequestValidator` | class | MMCA.ADC.Identity.Application | `MMCA.ADC.Identity.Application.Users.Validation` | `MMCA.ADC.Identity.Application/Users/Validation/ChangePasswordRequestValidator.cs:11` | @@ -1571,9 +1661,11 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `ExportUserDataHandlerTests` | class | MMCA.ADC.Identity.Application.Tests | `MMCA.ADC.Identity.Application.Tests.Users.UseCases` | `MMCA.ADC.Identity.Application.Tests/Users/UseCases/ExportUserDataHandlerTests.cs:16` | | `ExportUserDataRegistrationTests` | class | MMCA.ADC.Identity.Application.Tests | `MMCA.ADC.Identity.Application.Tests.Users.UseCases` | `MMCA.ADC.Identity.Application.Tests/Users/UseCases/ExportUserDataRegistrationTests.cs:16` | | `FixedTimeProvider` | class | MMCA.ADC.Identity.Application.Tests | `MMCA.ADC.Identity.Application.Tests.Users.UseCases` | `MMCA.ADC.Identity.Application.Tests/Users/UseCases/DeleteUserHandlerTests.cs:328` | +| `ForgotPasswordHandlerTests` | class | MMCA.ADC.Identity.Application.Tests | `MMCA.ADC.Identity.Application.Tests.Users.UseCases` | `MMCA.ADC.Identity.Application.Tests/Users/UseCases/ForgotPasswordHandlerTests.cs:22` | | `GetUserPreferencesHandlerTests` | class | MMCA.ADC.Identity.Application.Tests | `MMCA.ADC.Identity.Application.Tests.Users.UseCases` | `MMCA.ADC.Identity.Application.Tests/Users/UseCases/GetUserPreferencesHandlerTests.cs:15` | | `GetUsersHandlerTests` | class | MMCA.ADC.Identity.Application.Tests | `MMCA.ADC.Identity.Application.Tests.Users.UseCases` | `MMCA.ADC.Identity.Application.Tests/Users/UseCases/GetUsersHandlerTests.cs:12` | | `NotificationUserDataExportSectionTests` | class | MMCA.ADC.Identity.Application.Tests | `MMCA.ADC.Identity.Application.Tests.Users.UseCases` | `MMCA.ADC.Identity.Application.Tests/Users/UseCases/NotificationUserDataExportSectionTests.cs:9` | +| `ResetPasswordHandlerTests` | class | MMCA.ADC.Identity.Application.Tests | `MMCA.ADC.Identity.Application.Tests.Users.UseCases` | `MMCA.ADC.Identity.Application.Tests/Users/UseCases/ResetPasswordHandlerTests.cs:19` | | `SetUserAvatarHandlerTests` | class | MMCA.ADC.Identity.Application.Tests | `MMCA.ADC.Identity.Application.Tests.Users.UseCases` | `MMCA.ADC.Identity.Application.Tests/Users/UseCases/SetUserAvatarHandlerTests.cs:16` | | `ThrowingExportSection` | class | MMCA.ADC.Identity.Application.Tests | `MMCA.ADC.Identity.Application.Tests.Users.UseCases` | `MMCA.ADC.Identity.Application.Tests/Users/UseCases/ExportUserDataHandlerTests.cs:353` | | `ChangePasswordRequestValidatorTests` | class | MMCA.ADC.Identity.Application.Tests | `MMCA.ADC.Identity.Application.Tests.Validation` | `MMCA.ADC.Identity.Application.Tests/Validation/ChangePasswordRequestValidatorTests.cs:7` | @@ -1618,6 +1710,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `AnonymousAuthTests` | class | MMCA.ADC.Identity.IntegrationTests | `MMCA.ADC.Identity.IntegrationTests.Auth` | `MMCA.ADC.Identity.IntegrationTests/Auth/AnonymousAuthTests.cs:11` | | `ExchangeResponse` | record | MMCA.ADC.Identity.IntegrationTests | `MMCA.ADC.Identity.IntegrationTests.Auth` | `MMCA.ADC.Identity.IntegrationTests/Auth/OAuthExchangeTests.cs:68` | | `OAuthExchangeTests` | class | MMCA.ADC.Identity.IntegrationTests | `MMCA.ADC.Identity.IntegrationTests.Auth` | `MMCA.ADC.Identity.IntegrationTests/Auth/OAuthExchangeTests.cs:18` | +| `PasswordResetFlowTests` | class | MMCA.ADC.Identity.IntegrationTests | `MMCA.ADC.Identity.IntegrationTests.Auth` | `MMCA.ADC.Identity.IntegrationTests/Auth/PasswordResetFlowTests.cs:18` | | `ErasureAndPiiLoggingTests` | class | MMCA.ADC.Identity.IntegrationTests | `MMCA.ADC.Identity.IntegrationTests.Compliance` | `MMCA.ADC.Identity.IntegrationTests/Compliance/ErasureAndPiiLoggingTests.cs:19` | | `OpenApiContractTests` | class | MMCA.ADC.Identity.IntegrationTests | `MMCA.ADC.Identity.IntegrationTests.Contract` | `MMCA.ADC.Identity.IntegrationTests/Contract/OpenApiContractTests.cs:15` | | `ProblemDetailsContractTests` | class | MMCA.ADC.Identity.IntegrationTests | `MMCA.ADC.Identity.IntegrationTests.Contract` | `MMCA.ADC.Identity.IntegrationTests/Contract/ProblemDetailsContractTests.cs:16` | @@ -1692,7 +1785,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `NotificationIntegrationTestBase` | class | MMCA.ADC.Notification.IntegrationTests | `MMCA.ADC.Notification.IntegrationTests.Infrastructure` | `MMCA.ADC.Notification.IntegrationTests/Infrastructure/NotificationIntegrationTestBase.cs:17` | | `NotificationIntegrationTestCollection` | class | MMCA.ADC.Notification.IntegrationTests | `MMCA.ADC.Notification.IntegrationTests.Infrastructure` | `MMCA.ADC.Notification.IntegrationTests/Infrastructure/NotificationIntegrationTestCollection.cs:8` | | `NotificationIntegrationTestFixture` | class | MMCA.ADC.Notification.IntegrationTests | `MMCA.ADC.Notification.IntegrationTests.Infrastructure` | `MMCA.ADC.Notification.IntegrationTests/Infrastructure/NotificationIntegrationTestFixture.cs:17` | -| `NotificationTestWebApplicationFactory` | class | MMCA.ADC.Notification.IntegrationTests | `MMCA.ADC.Notification.IntegrationTests.Infrastructure` | `MMCA.ADC.Notification.IntegrationTests/Infrastructure/NotificationTestWebApplicationFactory.cs:33` | +| `NotificationTestWebApplicationFactory` | class | MMCA.ADC.Notification.IntegrationTests | `MMCA.ADC.Notification.IntegrationTests.Infrastructure` | `MMCA.ADC.Notification.IntegrationTests/Infrastructure/NotificationTestWebApplicationFactory.cs:34` | | `NotificationControllerTests` | class | MMCA.ADC.Notification.IntegrationTests | `MMCA.ADC.Notification.IntegrationTests.Notifications` | `MMCA.ADC.Notification.IntegrationTests/Notifications/NotificationControllerTests.cs:16` | | `NotificationHubTests` | class | MMCA.ADC.Notification.IntegrationTests | `MMCA.ADC.Notification.IntegrationTests.Notifications` | `MMCA.ADC.Notification.IntegrationTests/Notifications/NotificationHubTests.cs:15` | | `LiveChannelGrpcService` | class | MMCA.ADC.Notification.Service | `MMCA.ADC.Notification.Service.Grpc` | `MMCA.ADC.Notification.Service/Grpc/LiveChannelGrpcService.cs:22` | @@ -1757,6 +1850,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `IAggregateRootEntityControllerBase` | interface | MMCA.Common.API | `MMCA.Common.API.Controllers` | `MMCA.Common.API/Controllers/IAggregateRootEntityControllerBase.cs:15` | | `IEntityControllerBase` | interface | MMCA.Common.API | `MMCA.Common.API.Controllers` | `MMCA.Common.API/Controllers/IEntityControllerBase.cs:14` | | `OAuthControllerBase` | class | MMCA.Common.API | `MMCA.Common.API.Controllers` | `MMCA.Common.API/Controllers/OAuthControllerBase.cs:33` | +| `PasswordResetAuthControllerBase` | class | MMCA.Common.API | `MMCA.Common.API.Controllers` | `MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:43` | | `ServiceInfoControllerBase` | class | MMCA.Common.API | `MMCA.Common.API.Controllers` | `MMCA.Common.API/Controllers/ServiceInfoControllerBase.cs:30` | | `ServiceInfoResponse` | record | MMCA.Common.API | `MMCA.Common.API.Controllers` | `MMCA.Common.API/Controllers/ServiceInfoControllerBase.cs:51` | | `ServiceInfoV2Response` | record | MMCA.Common.API | `MMCA.Common.API.Controllers` | `MMCA.Common.API/Controllers/ServiceInfoControllerBase.cs:54` | @@ -1811,13 +1905,17 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `AppAssociationEndpointExtensions` | class | MMCA.Common.API | `MMCA.Common.API.Startup` | `MMCA.Common.API/Startup/AppAssociationEndpointExtensions.cs:15` | | `AppAssociationOptions` | class | MMCA.Common.API | `MMCA.Common.API.Startup` | `MMCA.Common.API/Startup/AppAssociationOptions.cs:9` | | `DatabaseInitializationExtensions` | class | MMCA.Common.API | `MMCA.Common.API.Startup` | `MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:21` | +| `InsecureJwtMetadataWarningStartupFilter` | class | MMCA.Common.API | `MMCA.Common.API.Startup` | `MMCA.Common.API/Startup/InsecureJwtMetadataWarningStartupFilter.cs:15` | | `JwksEndpointExtensions` | class | MMCA.Common.API | `MMCA.Common.API.Startup` | `MMCA.Common.API/Startup/JwksEndpointExtensions.cs:15` | +| `MiddlewarePipelineBuilder` | class | MMCA.Common.API | `MMCA.Common.API.Startup` | `MMCA.Common.API/Startup/MiddlewarePipelineBuilder.cs:15` | +| `MiddlewarePipelineStep` | record | MMCA.Common.API | `MMCA.Common.API.Startup` | `MMCA.Common.API/Startup/MiddlewarePipelineStep.cs:21` | +| `MiddlewarePipelineStepNames` | class | MMCA.Common.API | `MMCA.Common.API.Startup` | `MMCA.Common.API/Startup/MiddlewarePipelineStepNames.cs:14` | | `MiniProfilerExtensions` | class | MMCA.Common.API | `MMCA.Common.API.Startup` | `MMCA.Common.API/Startup/MiniProfilerExtensions.cs:9` | | `OidcDiscoveryEndpointExtensions` | class | MMCA.Common.API | `MMCA.Common.API.Startup` | `MMCA.Common.API/Startup/OidcDiscoveryEndpointExtensions.cs:22` | -| `OpenApiEndpointExtensions` | class | MMCA.Common.API | `MMCA.Common.API.Startup` | `MMCA.Common.API/Startup/OpenApiEndpointExtensions.cs:18` | +| `OpenApiEndpointExtensions` | class | MMCA.Common.API | `MMCA.Common.API.Startup` | `MMCA.Common.API/Startup/OpenApiEndpointExtensions.cs:22` | | `SignalRExtensions` | class | MMCA.Common.API | `MMCA.Common.API.Startup` | `MMCA.Common.API/Startup/SignalRExtensions.cs:12` | -| `WebApplicationBuilderExtensions` | class | MMCA.Common.API | `MMCA.Common.API.Startup` | `MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:29` | -| `WebApplicationExtensions` | class | MMCA.Common.API | `MMCA.Common.API.Startup` | `MMCA.Common.API/Startup/WebApplicationExtensions.cs:16` | +| `WebApplicationBuilderExtensions` | class | MMCA.Common.API | `MMCA.Common.API.Startup` | `MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:31` | +| `WebApplicationExtensions` | class | MMCA.Common.API | `MMCA.Common.API.Startup` | `MMCA.Common.API/Startup/WebApplicationExtensions.cs:14` | | `FakeCategoriesController` | class | MMCA.Common.API.Tests | `Fakes.MMCA.Store.Catalog.API.Controllers` | `MMCA.Common.API.Tests/Fakes/FakeCategoriesController.cs:7` | | `DependencyInjectionTests` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests` | `MMCA.Common.API.Tests/DependencyInjectionTests.cs:18` | | `ModuleControllerFeatureProviderTests` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests` | `MMCA.Common.API.Tests/ModuleControllerFeatureProviderTests.cs:8` | @@ -1851,6 +1949,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `Mocks` | record | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Controllers` | `MMCA.Common.API.Tests/Controllers/OAuthControllerBaseTests.cs:32` | | `OAuthControllerBaseTests` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Controllers` | `MMCA.Common.API.Tests/Controllers/OAuthControllerBaseTests.cs:26` | | `OverridingAuthController` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Controllers` | `MMCA.Common.API.Tests/Controllers/AuthControllerBaseRateLimitTests.cs:87` | +| `PasswordResetAuthControllerBaseTests` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Controllers` | `MMCA.Common.API.Tests/Controllers/PasswordResetAuthControllerBaseTests.cs:23` | | `PlainDTO` | record | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Controllers` | `MMCA.Common.API.Tests/Controllers/EntityControllerBaseETagTests.cs:168` | | `PlainEntity` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Controllers` | `MMCA.Common.API.Tests/Controllers/EntityControllerBaseETagTests.cs:165` | | `PlainEntityController` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Controllers` | `MMCA.Common.API.Tests/Controllers/EntityControllerBaseETagTests.cs:181` | @@ -1868,7 +1967,10 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `TestDTO` | record | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Controllers` | `MMCA.Common.API.Tests/Controllers/EntityControllerBaseTests.cs:345` | | `TestEntity` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Controllers` | `MMCA.Common.API.Tests/Controllers/EntityControllerBaseTests.cs:343` | | `TestEntityController` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Controllers` | `MMCA.Common.API.Tests/Controllers/EntityControllerBaseTests.cs:332` | +| `TestForgotPasswordCommand` | record | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Controllers` | `MMCA.Common.API.Tests/Controllers/PasswordResetAuthControllerBaseTests.cs:155` | | `TestOAuthController` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Controllers` | `MMCA.Common.API.Tests/Controllers/OAuthControllerBaseTests.cs:638` | +| `TestPasswordResetController` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Controllers` | `MMCA.Common.API.Tests/Controllers/PasswordResetAuthControllerBaseTests.cs:165` | +| `TestResetPasswordCommand` | record | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Controllers` | `MMCA.Common.API.Tests/Controllers/PasswordResetAuthControllerBaseTests.cs:162` | | `TestUserAccountAuthController` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Controllers` | `MMCA.Common.API.Tests/Controllers/UserAccountAuthControllerBaseTests.cs:252` | | `UserAccountAuthControllerBaseTests` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Controllers` | `MMCA.Common.API.Tests/Controllers/UserAccountAuthControllerBaseTests.cs:16` | | `VersionedDTO` | record | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Controllers` | `MMCA.Common.API.Tests/Controllers/EntityControllerBaseETagTests.cs:155` | @@ -1902,10 +2004,13 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `TestDomainException` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Middleware` | `MMCA.Common.API.Tests/Middleware/ExceptionHandlerTests.cs:313` | | `UnhandledResultFailureFilterTests` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Middleware` | `MMCA.Common.API.Tests/Middleware/UnhandledResultFailureFilterTests.cs:13` | | `QueryFilterModelBinderTests` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.ModelBinders` | `MMCA.Common.API.Tests/ModelBinders/QueryFilterModelBinderTests.cs:9` | -| `ApiParameterDescriptorBackfillProviderTests` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.OpenApi` | `MMCA.Common.API.Tests/OpenApi/ApiParameterDescriptorBackfillProviderTests.cs:36` | -| `ProbeControllerFeatureProvider` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.OpenApi` | `MMCA.Common.API.Tests/OpenApi/ApiParameterDescriptorBackfillProviderTests.cs:182` | -| `SegmentVersionedProbeController` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.OpenApi` | `MMCA.Common.API.Tests/OpenApi/ApiParameterDescriptorBackfillProviderTests.cs:200` | -| `UnboundRouteTokenProbeController` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.OpenApi` | `MMCA.Common.API.Tests/OpenApi/ApiParameterDescriptorBackfillProviderTests.cs:215` | +| `ApiParameterDescriptorBackfillProviderTests` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.OpenApi` | `MMCA.Common.API.Tests/OpenApi/ApiParameterDescriptorBackfillProviderTests.cs:31` | +| `OpenApiBaselineTests` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.OpenApi` | `MMCA.Common.API.Tests/OpenApi/OpenApiBaselineTests.cs:35` | +| `OpenApiProbeHost` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.OpenApi` | `MMCA.Common.API.Tests/OpenApi/OpenApiProbeHost.cs:20` | +| `ProbeControllerFeatureProvider` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.OpenApi` | `MMCA.Common.API.Tests/OpenApi/OpenApiProbeHost.cs:65` | +| `ProblemDetailsProbeController` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.OpenApi` | `MMCA.Common.API.Tests/OpenApi/OpenApiBaselineTests.cs:172` | +| `SegmentVersionedProbeController` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.OpenApi` | `MMCA.Common.API.Tests/OpenApi/ApiParameterDescriptorBackfillProviderTests.cs:157` | +| `UnboundRouteTokenProbeController` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.OpenApi` | `MMCA.Common.API.Tests/OpenApi/ApiParameterDescriptorBackfillProviderTests.cs:172` | | `RateLimitingSettingsTests` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.RateLimiting` | `MMCA.Common.API.Tests/RateLimiting/RateLimitingSettingsTests.cs:13` | | `RedisFixedWindowRateLimiterTests` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.RateLimiting` | `MMCA.Common.API.Tests/RateLimiting/RedisFixedWindowRateLimiterTests.cs:16` | | `CookieSessionRefresherTests` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.SessionCookies` | `MMCA.Common.API.Tests/SessionCookies/CookieSessionRefresherTests.cs:24` | @@ -1922,24 +2027,31 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `AppAssociationEndpointTests` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Startup` | `MMCA.Common.API.Tests/Startup/AppAssociationEndpointTests.cs:24` | | `DatabaseInitializationExtensionsTests` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Startup` | `MMCA.Common.API.Tests/Startup/DatabaseInitializationExtensionsTests.cs:29` | | `FixedAssemblyProvider` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Startup` | `MMCA.Common.API.Tests/Startup/DatabaseInitializationExtensionsTests.cs:94` | +| `ForwardedJwtBearerSecurityTests` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Startup` | `MMCA.Common.API.Tests/Startup/ForwardedJwtBearerSecurityTests.cs:22` | | `InitTestWidget` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Startup` | `MMCA.Common.API.Tests/Startup/DatabaseInitializationExtensionsTests.cs:100` | | `InitTestWidgetConfiguration` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Startup` | `MMCA.Common.API.Tests/Startup/DatabaseInitializationExtensionsTests.cs:106` | | `JwksEndpointTests` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Startup` | `MMCA.Common.API.Tests/Startup/JwksEndpointTests.cs:26` | +| `MiddlewarePipelineBuilderTests` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Startup` | `MMCA.Common.API.Tests/Startup/MiddlewarePipelineBuilderTests.cs:12` | | `OidcDiscoveryEndpointTests` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Startup` | `MMCA.Common.API.Tests/Startup/OidcDiscoveryEndpointTests.cs:20` | | `RateLimitAlgorithmSelectionTests` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Startup` | `MMCA.Common.API.Tests/Startup/RateLimitAlgorithmSelectionTests.cs:21` | | `RateLimitPartitionTests` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Startup` | `MMCA.Common.API.Tests/Startup/RateLimitPartitionTests.cs:16` | +| `StubHostEnvironment` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Startup` | `MMCA.Common.API.Tests/Startup/ForwardedJwtBearerSecurityTests.cs:146` | | `WebApplicationBuilderExtensionsTests` | class | MMCA.Common.API.Tests | `MMCA.Common.API.Tests.Startup` | `MMCA.Common.API.Tests/Startup/WebApplicationBuilderExtensionsTests.cs:14` | | `AssemblyReference` | class | MMCA.Common.Application | `MMCA.Common.Application` | `MMCA.Common.Application/AssemblyReference.cs:5` | | `ClassReference` | class | MMCA.Common.Application | `MMCA.Common.Application` | `MMCA.Common.Application/AssemblyReference.cs:11` | -| `DependencyInjection` | class | MMCA.Common.Application | `MMCA.Common.Application` | `MMCA.Common.Application/DependencyInjection.cs:21` | +| `DependencyInjection` | class | MMCA.Common.Application | `MMCA.Common.Application` | `MMCA.Common.Application/DependencyInjection.cs:22` | | `AuditTrailEntryDTO` | record | MMCA.Common.Application | `MMCA.Common.Application.Auditing` | `MMCA.Common.Application/Auditing/AuditTrailEntryDTO.cs:12` | | `AuthenticationServiceBase` | class | MMCA.Common.Application | `MMCA.Common.Application.Auth` | `MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:34` | | `AuthenticationValidators` | class | MMCA.Common.Application | `MMCA.Common.Application.Auth` | `MMCA.Common.Application/Auth/AuthenticationValidators.cs:16` | | `IAuthenticationService` | interface | MMCA.Common.Application | `MMCA.Common.Application.Auth` | `MMCA.Common.Application/Auth/IAuthenticationService.cs:11` | | `ILoginProtectionService` | interface | MMCA.Common.Application | `MMCA.Common.Application.Auth` | `MMCA.Common.Application/Auth/ILoginProtectionService.cs:10` | +| `IPasswordResetTokenService` | interface | MMCA.Common.Application | `MMCA.Common.Application.Auth` | `MMCA.Common.Application/Auth/IPasswordResetTokenService.cs:10` | +| `PasswordResetSettings` | class | MMCA.Common.Application | `MMCA.Common.Application.Auth` | `MMCA.Common.Application/Auth/PasswordResetSettings.cs:10` | | `SoftDeletedUserCache` | class | MMCA.Common.Application | `MMCA.Common.Application.Auth` | `MMCA.Common.Application/Auth/SoftDeletedUserCache.cs:17` | +| `ForgotPasswordRequestValidator` | class | MMCA.Common.Application | `MMCA.Common.Application.Auth.Validation` | `MMCA.Common.Application/Auth/Validation/ForgotPasswordRequestValidator.cs:11` | | `LoginRequestValidator` | class | MMCA.Common.Application | `MMCA.Common.Application.Auth.Validation` | `MMCA.Common.Application/Auth/Validation/LoginRequestValidator.cs:11` | | `RefreshTokenRequestValidator` | class | MMCA.Common.Application | `MMCA.Common.Application.Auth.Validation` | `MMCA.Common.Application/Auth/Validation/RefreshTokenRequestValidator.cs:10` | +| `ResetPasswordRequestValidator` | class | MMCA.Common.Application | `MMCA.Common.Application.Auth.Validation` | `MMCA.Common.Application/Auth/Validation/ResetPasswordRequestValidator.cs:12` | | `SafeDomainEventHandler` | class | MMCA.Common.Application | `MMCA.Common.Application.DomainEvents` | `MMCA.Common.Application/DomainEvents/SafeDomainEventHandler.cs:32` | | `ReadRepositoryExtensions` | class | MMCA.Common.Application | `MMCA.Common.Application.Extensions` | `MMCA.Common.Application/Extensions/ReadRepositoryExtensions.cs:10` | | `ValidationFailureExtensions` | class | MMCA.Common.Application | `MMCA.Common.Application.Extensions` | `MMCA.Common.Application/Extensions/ValidationFailureExtensions.cs:9` | @@ -1956,6 +2068,9 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `IEntityQueryService` | interface | MMCA.Common.Application | `MMCA.Common.Application.Interfaces` | `MMCA.Common.Application/Interfaces/IEntityQueryService.cs:19` | | `IEntityRequestMapper` | interface | MMCA.Common.Application | `MMCA.Common.Application.Interfaces` | `MMCA.Common.Application/Interfaces/IEntityDTOMapper.cs:42` | | `IEventBus` | interface | MMCA.Common.Application | `MMCA.Common.Application.Interfaces` | `MMCA.Common.Application/Interfaces/IEventBus.cs:11` | +| `IEventUpcaster` | interface | MMCA.Common.Application | `MMCA.Common.Application.Interfaces` | `MMCA.Common.Application/Interfaces/IEventUpcaster.cs:28` | +| `IEventUpcaster` | interface | MMCA.Common.Application | `MMCA.Common.Application.Interfaces` | `MMCA.Common.Application/Interfaces/IEventUpcaster.cs:67` | +| `IEventUpcasterRegistry` | interface | MMCA.Common.Application | `MMCA.Common.Application.Interfaces` | `MMCA.Common.Application/Interfaces/IEventUpcasterRegistry.cs:24` | | `IIntegrationEventHandler` | interface | MMCA.Common.Application | `MMCA.Common.Application.Interfaces` | `MMCA.Common.Application/Interfaces/IIntegrationEventHandler.cs:15` | | `INavigationMetadata` | interface | MMCA.Common.Application | `MMCA.Common.Application.Interfaces` | `MMCA.Common.Application/Interfaces/INavigationMetadata.cs:34` | | `INavigationPopulator` | interface | MMCA.Common.Application | `MMCA.Common.Application.Interfaces` | `MMCA.Common.Application/Interfaces/INavigationPopulator.cs:9` | @@ -2014,8 +2129,9 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `BestEffort` | class | MMCA.Common.Application | `MMCA.Common.Application.Services` | `MMCA.Common.Application/Services/BestEffort.cs:25` | | `BestEffortLog` | class | MMCA.Common.Application | `MMCA.Common.Application.Services` | `MMCA.Common.Application/Services/BestEffort.cs:79` | | `BestEffortMetrics` | class | MMCA.Common.Application | `MMCA.Common.Application.Services` | `MMCA.Common.Application/Services/BestEffort.cs:99` | -| `DomainEventDispatcher` | class | MMCA.Common.Application | `MMCA.Common.Application.Services` | `MMCA.Common.Application/Services/DomainEventDispatcher.cs:16` | +| `DomainEventDispatcher` | class | MMCA.Common.Application | `MMCA.Common.Application.Services` | `MMCA.Common.Application/Services/DomainEventDispatcher.cs:23` | | `EntityQueryService` | class | MMCA.Common.Application | `MMCA.Common.Application.Services` | `MMCA.Common.Application/Services/EntityQueryService.cs:31` | +| `EventUpcasterRegistry` | class | MMCA.Common.Application | `MMCA.Common.Application.Services` | `MMCA.Common.Application/Services/EventUpcasterRegistry.cs:30` | | `NavigationLoader` | class | MMCA.Common.Application | `MMCA.Common.Application.Services` | `MMCA.Common.Application/Services/NavigationLoader.cs:21` | | `NullNavigationPopulator` | class | MMCA.Common.Application | `MMCA.Common.Application.Services` | `MMCA.Common.Application/Services/NullNavigationPopulator.cs:11` | | `PropertyAccessor` | record struct | MMCA.Common.Application | `MMCA.Common.Application.Services` | `MMCA.Common.Application/Services/QueryFieldService.cs:46` | @@ -2088,8 +2204,10 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `IUserDataExportSection` | interface | MMCA.Common.Application | `MMCA.Common.Application.Users.UseCases.ExportUserData` | `MMCA.Common.Application/Users/UseCases/ExportUserData/IUserDataExportSection.cs:20` | | `UserDataExportSectionDefaults` | class | MMCA.Common.Application | `MMCA.Common.Application.Users.UseCases.ExportUserData` | `MMCA.Common.Application/Users/UseCases/ExportUserData/IUserDataExportSection.cs:105` | | `UserDataExportSectionResult` | record | MMCA.Common.Application | `MMCA.Common.Application.Users.UseCases.ExportUserData` | `MMCA.Common.Application/Users/UseCases/ExportUserData/IUserDataExportSection.cs:47` | +| `ForgotPasswordHandlerBase` | class | MMCA.Common.Application | `MMCA.Common.Application.Users.UseCases.ForgotPassword` | `MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:35` | | `GetUserPreferencesHandlerBase` | class | MMCA.Common.Application | `MMCA.Common.Application.Users.UseCases.GetPreferences` | `MMCA.Common.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandlerBase.cs:21` | | `GetUserPreferencesQuery` | record | MMCA.Common.Application | `MMCA.Common.Application.Users.UseCases.GetPreferences` | `MMCA.Common.Application/Users/UseCases/GetPreferences/GetUserPreferencesQuery.cs:5` | +| `ResetPasswordHandlerBase` | class | MMCA.Common.Application | `MMCA.Common.Application.Users.UseCases.ResetPassword` | `MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:30` | | `AddressLine1Rules` | class | MMCA.Common.Application | `MMCA.Common.Application.Validation` | `MMCA.Common.Application/Validation/AddressValidationRules.cs:31` | | `AddressLine2Rules` | class | MMCA.Common.Application | `MMCA.Common.Application.Validation` | `MMCA.Common.Application/Validation/AddressValidationRules.cs:42` | | `AddressValidator` | class | MMCA.Common.Application | `MMCA.Common.Application.Validation` | `MMCA.Common.Application/Validation/AddressValidationRules.cs:13` | @@ -2107,21 +2225,26 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `StrongPasswordRules` | class | MMCA.Common.Application | `MMCA.Common.Application.Validation` | `MMCA.Common.Application/Validation/CommonValidationRules.cs:97` | | `ZipCodeRules` | class | MMCA.Common.Application | `MMCA.Common.Application.Validation` | `MMCA.Common.Application/Validation/AddressValidationRules.cs:72` | | `DependencyInjectionTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/DependencyInjectionTests.cs:10` | -| `DomainEventDispatcherAdditionalTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:10` | +| `DomainEventDispatcherAdditionalTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:11` | | `DomainEventDispatcherTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/DomainEventDispatcherTests.cs:11` | | `ImageContentSnifferTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/ImageContentSnifferTests.cs:12` | -| `MultiHandlerEvent` | record | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:75` | -| `MultiHandlerEventHandler1` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:77` | -| `MultiHandlerEventHandler2` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:88` | +| `MultiHandlerEvent` | record | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:76` | +| `MultiHandlerEventHandler1` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:78` | +| `MultiHandlerEventHandler2` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:89` | | `NavigationMetadataTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/NavigationMetadataTests.cs:13` | | `NullNotificationRecipientProviderTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/NullNotificationRecipientProviderTests.cs:9` | -| `TestDomainEventHandlerForIntegration` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:26` | +| `RecordingDomainHandlerForRetired` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:145` | +| `RecordingIntegrationHandler` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:133` | +| `RetiredEvent` | record | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:121` | +| `RetiredToSuccessorUpcaster` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:128` | +| `SuccessorEvent` | record | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:123` | +| `TestDomainEventHandlerForIntegration` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:27` | | `TestEvent` | record | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/DomainEventDispatcherTests.cs:13` | | `TestEventHandler` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/DomainEventDispatcherTests.cs:17` | -| `TestIntegrationEvent` | record | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:13` | +| `TestIntegrationEvent` | record | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:14` | | `TestIntegrationEvent` | record | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/DomainEventDispatcherTests.cs:15` | | `TestIntegrationEventDomainHandler` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/DomainEventDispatcherTests.cs:28` | -| `TestIntegrationEventHandler` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:15` | +| `TestIntegrationEventHandler` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:16` | | `TestIntegrationEventHandler` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests` | `MMCA.Common.Application.Tests/DomainEventDispatcherTests.cs:39` | | `AuditTrailEntryDTOTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Auditing` | `MMCA.Common.Application.Tests/Auditing/AuditTrailEntryDTOTests.cs:13` | | `AuthenticationServiceBaseTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Auth` | `MMCA.Common.Application.Tests/Auth/AuthenticationServiceBaseTests.cs:22` | @@ -2131,8 +2254,10 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `SoftDeletedUserCacheTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Auth` | `MMCA.Common.Application.Tests/Auth/SoftDeletedUserCacheTests.cs:13` | | `TestAuthenticationService` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Auth` | `MMCA.Common.Application.Tests/Auth/AuthenticationServiceBaseTests.cs:631` | | `TestAuthUser` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Auth` | `MMCA.Common.Application.Tests/Auth/AuthenticationServiceBaseTests.cs:598` | +| `ForgotPasswordRequestValidatorTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Auth.Validation` | `MMCA.Common.Application.Tests/Auth/Validation/ForgotPasswordRequestValidatorTests.cs:7` | | `LoginRequestValidatorTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Auth.Validation` | `MMCA.Common.Application.Tests/Auth/Validation/LoginRequestValidatorTests.cs:7` | | `RefreshTokenRequestValidatorTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Auth.Validation` | `MMCA.Common.Application.Tests/Auth/Validation/RefreshTokenRequestValidatorTests.cs:7` | +| `ResetPasswordRequestValidatorTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Auth.Validation` | `MMCA.Common.Application.Tests/Auth/Validation/ResetPasswordRequestValidatorTests.cs:7` | | `AuthorizationCommandDecoratorTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Decorators` | `MMCA.Common.Application.Tests/Decorators/AuthorizationCommandDecoratorTests.cs:11` | | `AuthorizationQueryDecoratorTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Decorators` | `MMCA.Common.Application.Tests/Decorators/AuthorizationQueryDecoratorTests.cs:11` | | `BudgetedCommand` | record | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Decorators` | `MMCA.Common.Application.Tests/Decorators/TimeoutCommandDecoratorTests.cs:152` | @@ -2247,6 +2372,9 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `ChildC` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/NavigationMetadataProviderTests.cs:68` | | `ChildD` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/NavigationMetadataProviderTests.cs:70` | | `ChildNavigationDescriptorTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/ChildNavigationDescriptorTests.cs:9` | +| `CustomerRenamedV1` | record | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/EventUpcasterRegistryTests.cs:23` | +| `CustomerRenamedV2` | record | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/EventUpcasterRegistryTests.cs:25` | +| `CustomerRenamedV3` | record | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/EventUpcasterRegistryTests.cs:30` | | `DeclarativeNavigationPopulatorTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/DeclarativeNavigationPopulatorTests.cs:9` | | `EntityQueryParametersTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/EntityQueryParametersTests.cs:12` | | `EntityQueryPipelineOrderingTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/EntityQueryPipelineOrderingTests.cs:16` | @@ -2254,6 +2382,8 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `EntityQueryServiceProjectionTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/EntityQueryServiceProjectionTests.cs:19` | | `EntityQueryServiceResolutionTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/EntityQueryServiceResolutionTests.cs:19` | | `EntityQueryServiceTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/EntityQueryServiceTests.cs:13` | +| `EnvelopeCopyingV1ToV2Upcaster` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/EventUpcasterRegistryTests.cs:54` | +| `EventUpcasterRegistryTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/EventUpcasterRegistryTests.cs:19` | | `FakeEntity` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/EntityQueryServiceTests.cs:15` | | `FakeEntityDTO` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/EntityQueryServiceTests.cs:20` | | `FakeEntityDTOMapper` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/EntityQueryServiceTests.cs:236` | @@ -2288,6 +2418,8 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `ResolvedEntityDTO` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/EntityQueryServiceResolutionTests.cs:26` | | `ResolvedProjector` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/EntityQueryServiceResolutionTests.cs:31` | | `ResolvedProjectorMarker` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/EntityQueryServiceResolutionTests.cs:94` | +| `RivalV1ToV3Upcaster` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/EventUpcasterRegistryTests.cs:65` | +| `SelfMappingUpcaster` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/EventUpcasterRegistryTests.cs:79` | | `SortTestEntity` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/QueryFieldServiceTieBreakTests.cs:13` | | `SpyMapper` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/EntityQueryServiceProjectionTests.cs:36` | | `StubChild` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/NavigationLoaderTests.cs:204` | @@ -2299,8 +2431,12 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `TestEntity` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/EntityQueryParametersTests.cs:14` | | `TestEntity` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/EntityQueryPipelineTests.cs:20` | | `TestProjector` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/EntityQueryServiceProjectionTests.cs:56` | +| `UnrelatedEvent` | record | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/EventUpcasterRegistryTests.cs:35` | | `UnsupportedChild` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/NavigationMetadataProviderTests.cs:32` | | `UnsupportedFK` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/NavigationMetadataProviderTests.cs:20` | +| `V1ToV2Upcaster` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/EventUpcasterRegistryTests.cs:38` | +| `V2ToV1Upcaster` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/EventUpcasterRegistryTests.cs:72` | +| `V2ToV3Upcaster` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services` | `MMCA.Common.Application.Tests/Services/EventUpcasterRegistryTests.cs:44` | | `BoolFilterStrategyTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services.Filtering` | `MMCA.Common.Application.Tests/Services/Filtering/BoolFilterStrategyTests.cs:6` | | `DateTimeFilterStrategyTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services.Filtering` | `MMCA.Common.Application.Tests/Services/Filtering/DateTimeFilterStrategyTests.cs:6` | | `DecimalFilterStrategyTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Services.Filtering` | `MMCA.Common.Application.Tests/Services/Filtering/DecimalFilterStrategyTests.cs:6` | @@ -2335,13 +2471,17 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `ChangePreferencesHandlerBaseTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/ChangePreferencesHandlerBaseTests.cs:16` | | `DeleteUserHandlerBaseTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/DeleteUserHandlerBaseTests.cs:14` | | `ExportUserDataHandlerBaseTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/ExportUserDataHandlerBaseTests.cs:17` | +| `ForgotPasswordHandlerBaseTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/ForgotPasswordHandlerBaseTests.cs:20` | | `GetUserPreferencesHandlerBaseTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/GetUserPreferencesHandlerBaseTests.cs:14` | | `HandlerMocks` | record | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/ChangePasswordHandlerBaseTests.cs:97` | | `HandlerMocks` | record | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/ChangePreferencesHandlerBaseTests.cs:90` | | `HandlerMocks` | record | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/DeleteUserHandlerBaseTests.cs:177` | | `HandlerMocks` | record | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/ExportUserDataHandlerBaseTests.cs:234` | +| `HandlerMocks` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/ForgotPasswordHandlerBaseTests.cs:136` | | `HandlerMocks` | record | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/GetUserPreferencesHandlerBaseTests.cs:70` | +| `HandlerMocks` | record | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/ResetPasswordHandlerBaseTests.cs:137` | | `RecordingSection` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/ExportUserDataHandlerBaseTests.cs:289` | +| `ResetPasswordHandlerBaseTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/ResetPasswordHandlerBaseTests.cs:18` | | `SoftDeletedUserValidatorTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/SoftDeletedUserValidatorTests.cs:13` | | `TestChangePasswordCommand` | record | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/UserUseCaseTestDoubles.cs:109` | | `TestChangePasswordHandler` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/ChangePasswordHandlerBaseTests.cs:122` | @@ -2351,9 +2491,13 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `TestDeleteUserHandler` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/DeleteUserHandlerBaseTests.cs:195` | | `TestExportUserDataHandler` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/ExportUserDataHandlerBaseTests.cs:252` | | `TestExportUserDataQuery` | record | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/UserUseCaseTestDoubles.cs:123` | +| `TestForgotPasswordCommand` | record | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/ForgotPasswordHandlerBaseTests.cs:186` | +| `TestForgotPasswordHandler` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/ForgotPasswordHandlerBaseTests.cs:190` | | `TestGetUserPreferencesHandler` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/GetUserPreferencesHandlerBaseTests.cs:87` | | `TestHidingDeleteUser` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/UserUseCaseTestDoubles.cs:96` | | `TestIdentityUser` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/UserUseCaseTestDoubles.cs:13` | +| `TestResetPasswordCommand` | record | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/ResetPasswordHandlerBaseTests.cs:172` | +| `TestResetPasswordHandler` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/ResetPasswordHandlerBaseTests.cs:176` | | `ThrowingSection` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/ExportUserDataHandlerBaseTests.cs:309` | | `UserOwnershipRuleTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Users` | `MMCA.Common.Application.Tests/Users/UserOwnershipRuleTests.cs:11` | | `AddressValidationRulesTests` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Validation` | `MMCA.Common.Application.Tests/Validation/AddressValidationRulesTests.cs:8` | @@ -2368,19 +2512,27 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `TestRequest` | record | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Validation` | `MMCA.Common.Application.Tests/Validation/CommandRequestValidatorTests.cs:70` | | `TestRequestValidator` | class | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Validation` | `MMCA.Common.Application.Tests/Validation/CommandRequestValidatorTests.cs:74` | | `TestStringModel` | record | MMCA.Common.Application.Tests | `MMCA.Common.Application.Tests.Validation` | `MMCA.Common.Application.Tests/Validation/CommonValidationRulesTests.cs:320` | +| `AbstractAnonymousFixtureControllerBase` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:84` | | `AbstractFitnessControllerBase` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/IdempotencyFitnessTests.cs:71` | | `AggregateConventionTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/AggregateConventionTests.cs:9` | +| `AnonymousEndpointTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/AnonymousEndpointTests.cs:14` | +| `AnonymousEndpointTestsBaseTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:13` | +| `AnonymousFixtureController` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:74` | | `CancellationTestMap` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/CancellationTokenFitnessTests.cs:63` | | `CancellationTokenConventionTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/CancellationTokenConventionTests.cs:10` | | `CancellationTokenFitnessTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/CancellationTokenFitnessTests.cs:12` | | `CommonArchitectureMap` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/CommonArchitectureMap.cs:15` | +| `ConformantTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:126` | | `CycleTestMap` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/NamespaceCycleFitnessTests.cs:52` | | `DataSubjectSample` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/PiiErasureContractFitnessTests.cs:79` | | `DependencyVersionTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/DependencyVersionTests.cs:9` | | `DisabledFakeExportService` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/ModuleConformanceTestsBaseTests.cs:28` | | `DomainPurityTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/DomainPurityTests.cs:9` | +| `DriftedTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:100` | | `DriftedTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/ModuleConformanceTestsBaseTests.cs:131` | +| `EmptyScanTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:117` | | `EventScopeFitnessTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/EventScopeFitnessTests.cs:13` | +| `EventUpcasterFitnessTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/EventUpcasterFitnessTests.cs:12` | | `EventVersioningConventionTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/EventVersioningConventionTests.cs:12` | | `FakeConsumerMap` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/EventScopeFitnessTests.cs:50` | | `FakeDependentModule` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/ModuleConformanceTestsBaseTests.cs:31` | @@ -2397,6 +2549,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `IdempotentFitnessController` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/IdempotencyFitnessTests.cs:61` | | `IFakeExportService` | interface | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/ModuleConformanceTestsBaseTests.cs:25` | | `InheritingFitnessController` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/IdempotencyFitnessTests.cs:81` | +| `InheritingFixtureController` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:94` | | `LayerDependencyTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/LayerDependencyTests.cs:9` | | `LocalizationResourceTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/LocalizationResourceTests.cs:12` | | `LocalizedTextConventionTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/LocalizedTextConventionTests.cs:11` | @@ -2409,18 +2562,23 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `NavigationContractTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/NavigationContractTests.cs:17` | | `NonIdempotentFitnessController` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/IdempotencyFitnessTests.cs:84` | | `ObservabilityConventionTestsBaseTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/ObservabilityConventionTestsBaseTests.cs:14` | +| `PasswordHashingFitnessTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/PasswordHashingFitnessTests.cs:15` | | `PiiConventionTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/PiiConventionTests.cs:13` | | `PiiErasureContractFitnessTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/PiiErasureContractFitnessTests.cs:19` | | `ProtoContractFitnessTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/ProtoContractFitnessTests.cs:14` | | `RawQueryableConventionTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/RawQueryableConventionTests.cs:13` | | `ScalarOnlyQuerySpec` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/SpecificationFitnessTests.cs:89` | | `ScalarOnlySpec` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/SpecificationFitnessTests.cs:69` | +| `ServiceContractPurityTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/ServiceContractPurityTests.cs:11` | | `SliceCohesionTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/SliceCohesionTests.cs:10` | | `SpecificationFitnessTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/SpecificationFitnessTests.cs:13` | | `SpecTestMap` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/SpecificationFitnessTests.cs:40` | +| `StaleAllowListTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:108` | | `StateManagementConventionTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/StateManagementConventionTests.cs:11` | +| `TypeLevelAnonymousFixtureController` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:98` | | `UIArchitectureConventionTests` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/UIArchitectureConventionTests.cs:11` | | `UndeclaredFitnessController` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/IdempotencyFitnessTests.cs:94` | +| `UpcasterTestMap` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests` | `MMCA.Common.Architecture.Tests/EventUpcasterFitnessTests.cs:74` | | `CompliantFixtureService` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests.CancellationFixtures` | `MMCA.Common.Architecture.Tests/CancellationFixtures/CancellationTokenFixtures.cs:6` | | `ExemptableFixtureService` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests.CancellationFixtures` | `MMCA.Common.Architecture.Tests/CancellationFixtures/CancellationTokenFixtures.cs:57` | | `ExternalContractFixtureService` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests.CancellationFixtures` | `MMCA.Common.Architecture.Tests/CancellationFixtures/CancellationTokenFixtures.cs:67` | @@ -2431,6 +2589,19 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `LeftModelBase` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests.CycleFixtures.Left` | `MMCA.Common.Architecture.Tests/CycleFixtures/Left/LeftFixtures.cs:13` | | `LeftService` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests.CycleFixtures.Left` | `MMCA.Common.Architecture.Tests/CycleFixtures/Left/LeftFixtures.cs:6` | | `RightModel` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests.CycleFixtures.Right` | `MMCA.Common.Architecture.Tests/CycleFixtures/Right/RightFixtures.cs:6` | +| `FixtureBackwardsV1` | record | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEvents` | `MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:45` | +| `FixtureBackwardsV2` | record | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEvents` | `MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:48` | +| `FixtureBackwardsVersionUpcaster` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEvents` | `MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:78` | +| `FixtureCompliantV1` | record | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEvents` | `MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:15` | +| `FixtureCompliantV1ToV2Upcaster` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEvents` | `MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:54` | +| `FixtureCompliantV2` | record | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEvents` | `MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:18` | +| `FixtureCompliantV2ToV3Upcaster` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEvents` | `MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:60` | +| `FixtureCompliantV3` | record | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEvents` | `MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:24` | +| `FixtureContestedClaimUpcaster` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEvents` | `MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:66` | +| `FixtureContestedV1` | record | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEvents` | `MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:30` | +| `FixtureContestedV2` | record | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEvents` | `MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:33` | +| `FixtureContestedV3` | record | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEvents` | `MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:39` | +| `FixtureRivalClaimUpcaster` | class | MMCA.Common.Architecture.Tests | `MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEvents` | `MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:72` | | `DataProtectionExtensions` | class | MMCA.Common.Aspire | `MMCA.Common.Aspire` | `MMCA.Common.Aspire/DataProtection/DataProtectionExtensions.cs:19` | | `Extensions` | class | MMCA.Common.Aspire | `MMCA.Common.Aspire` | `MMCA.Common.Aspire/Extensions.cs:28` | | `GatewayCorsExtensions` | class | MMCA.Common.Aspire | `MMCA.Common.Aspire` | `MMCA.Common.Aspire/GatewayCorsExtensions.cs:16` | @@ -2467,10 +2638,12 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `StubMetricsBuilder` | class | MMCA.Common.Aspire.Tests | `MMCA.Common.Aspire.Tests.Configuration` | `MMCA.Common.Aspire.Tests/Configuration/KeyVaultConfigurationExtensionsTests.cs:208` | | `DataProtectionExtensionsTests` | class | MMCA.Common.Aspire.Tests | `MMCA.Common.Aspire.Tests.DataProtection` | `MMCA.Common.Aspire.Tests/DataProtection/DataProtectionExtensionsTests.cs:19` | | `GatewayCorrelationMiddlewareTests` | class | MMCA.Common.Aspire.Tests | `MMCA.Common.Aspire.Tests.Gateway` | `MMCA.Common.Aspire.Tests/Gateway/GatewayCorrelationMiddlewareTests.cs:15` | +| `GatewayCorsExtensionsTests` | class | MMCA.Common.Aspire.Tests | `MMCA.Common.Aspire.Tests.Gateway` | `MMCA.Common.Aspire.Tests/Gateway/GatewayCorsExtensionsTests.cs:19` | | `GatewayDownstreamHealthChecksTests` | class | MMCA.Common.Aspire.Tests | `MMCA.Common.Aspire.Tests.Gateway` | `MMCA.Common.Aspire.Tests/Gateway/GatewayDownstreamHealthChecksTests.cs:16` | | `GatewayRateLimitingTests` | class | MMCA.Common.Aspire.Tests | `MMCA.Common.Aspire.Tests.Gateway` | `MMCA.Common.Aspire.Tests/Gateway/GatewayRateLimitingTests.cs:19` | | `RecordingHttpResponseFeature` | class | MMCA.Common.Aspire.Tests | `MMCA.Common.Aspire.Tests.Gateway` | `MMCA.Common.Aspire.Tests/Gateway/GatewayCorrelationMiddlewareTests.cs:106` | | `StubHandler` | class | MMCA.Common.Aspire.Tests | `MMCA.Common.Aspire.Tests.Gateway` | `MMCA.Common.Aspire.Tests/Gateway/GatewayDownstreamHealthChecksTests.cs:175` | +| `StubHostEnvironment` | class | MMCA.Common.Aspire.Tests | `MMCA.Common.Aspire.Tests.Gateway` | `MMCA.Common.Aspire.Tests/Gateway/GatewayCorsExtensionsTests.cs:78` | | `StubHttpClientFactory` | class | MMCA.Common.Aspire.Tests | `MMCA.Common.Aspire.Tests.Gateway` | `MMCA.Common.Aspire.Tests/Gateway/GatewayDownstreamHealthChecksTests.cs:170` | | `InfrastructureHealthChecksTests` | class | MMCA.Common.Aspire.Tests | `MMCA.Common.Aspire.Tests.Health` | `MMCA.Common.Aspire.Tests/Health/InfrastructureHealthChecksTests.cs:16` | | `KestrelEndpointExtensionsTests` | class | MMCA.Common.Aspire.Tests | `MMCA.Common.Aspire.Tests.Kestrel` | `MMCA.Common.Aspire.Tests/Kestrel/KestrelEndpointExtensionsTests.cs:14` | @@ -2516,7 +2689,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `BaseEntity` | class | MMCA.Common.Domain | `MMCA.Common.Domain.Entities` | `MMCA.Common.Domain/Entities/BaseEntity.cs:14` | | `DomainEntityState` | enum | MMCA.Common.Domain | `MMCA.Common.Domain.Enums` | `MMCA.Common.Domain/Enums/DomainEntityState.cs:7` | | `EntityTypeExtensions` | class | MMCA.Common.Domain | `MMCA.Common.Domain.Extensions` | `MMCA.Common.Domain/Extensions/EntityTypeExtensions.cs:9` | -| `OutputCacheEvictionRequested` | record | MMCA.Common.Domain | `MMCA.Common.Domain.IntegrationEvents` | `MMCA.Common.Domain/IntegrationEvents/OutputCacheEvictionRequested.cs:23` | +| `OutputCacheEvictionRequested` | record | MMCA.Common.Domain | `MMCA.Common.Domain.IntegrationEvents` | `MMCA.Common.Domain/IntegrationEvents/OutputCacheEvictionRequested.cs:27` | | `IAggregateRoot` | interface | MMCA.Common.Domain | `MMCA.Common.Domain.Interfaces` | `MMCA.Common.Domain/Interfaces/IAggregateRoot.cs:9` | | `IAnonymizable` | interface | MMCA.Common.Domain | `MMCA.Common.Domain.Interfaces` | `MMCA.Common.Domain/Interfaces/IAnonymizable.cs:22` | | `IAuditableEntity` | interface | MMCA.Common.Domain | `MMCA.Common.Domain.Interfaces` | `MMCA.Common.Domain/Interfaces/IAuditableEntity.cs:8` | @@ -2634,6 +2807,8 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `IJwksProvider` | interface | MMCA.Common.Infrastructure | `MMCA.Common.Infrastructure.Auth` | `MMCA.Common.Infrastructure/Auth/IJwksProvider.cs:11` | | `LoginProtectionService` | class | MMCA.Common.Infrastructure | `MMCA.Common.Infrastructure.Auth` | `MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:19` | | `LoginProtectionSettings` | class | MMCA.Common.Infrastructure | `MMCA.Common.Infrastructure.Auth` | `MMCA.Common.Infrastructure/Auth/LoginProtectionSettings.cs:9` | +| `PasswordResetEntry` | record | MMCA.Common.Infrastructure | `MMCA.Common.Infrastructure.Auth` | `MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:171` | +| `PasswordResetTokenService` | class | MMCA.Common.Infrastructure | `MMCA.Common.Infrastructure.Auth` | `MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:26` | | `RsaJwksProvider` | class | MMCA.Common.Infrastructure | `MMCA.Common.Infrastructure.Auth` | `MMCA.Common.Infrastructure/Auth/RsaJwksProvider.cs:15` | | `CacheKeyNamespace` | class | MMCA.Common.Infrastructure | `MMCA.Common.Infrastructure.Caching` | `MMCA.Common.Infrastructure/Caching/CacheKeyPrefix.cs:41` | | `CacheKeyPrefixOptions` | class | MMCA.Common.Infrastructure | `MMCA.Common.Infrastructure.Caching` | `MMCA.Common.Infrastructure/Caching/CacheKeyPrefix.cs:28` | @@ -2761,6 +2936,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `CorrelationContext` | class | MMCA.Common.Infrastructure | `MMCA.Common.Infrastructure.Services` | `MMCA.Common.Infrastructure/Services/CorrelationContext.cs:9` | | `CurrentUserService` | class | MMCA.Common.Infrastructure | `MMCA.Common.Infrastructure.Services` | `MMCA.Common.Infrastructure/Services/CurrentUserService.cs:13` | | `DataSourceService` | class | MMCA.Common.Infrastructure | `MMCA.Common.Infrastructure.Services` | `MMCA.Common.Infrastructure/Services/DataSourceService.cs:12` | +| `EventUpcasterStartupValidator` | class | MMCA.Common.Infrastructure | `MMCA.Common.Infrastructure.Services` | `MMCA.Common.Infrastructure/Services/EventUpcasterStartupValidator.cs:20` | | `FaultIntegrationEventConsumer` | class | MMCA.Common.Infrastructure | `MMCA.Common.Infrastructure.Services` | `MMCA.Common.Infrastructure/Services/FaultIntegrationEventConsumer.cs:27` | | `ImageSharpImageProcessor` | class | MMCA.Common.Infrastructure | `MMCA.Common.Infrastructure.Services` | `MMCA.Common.Infrastructure/Services/ImageSharpImageProcessor.cs:14` | | `InProcessEventBus` | class | MMCA.Common.Infrastructure | `MMCA.Common.Infrastructure.Services` | `MMCA.Common.Infrastructure/Services/InProcessEventBus.cs:23` | @@ -2780,6 +2956,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `SmtpEmailSender` | class | MMCA.Common.Infrastructure | `MMCA.Common.Infrastructure.Services` | `MMCA.Common.Infrastructure/Services/SmtpEmailSender.cs:12` | | `TenantContext` | class | MMCA.Common.Infrastructure | `MMCA.Common.Infrastructure.Services` | `MMCA.Common.Infrastructure/Services/TenantContext.cs:11` | | `TokenService` | class | MMCA.Common.Infrastructure | `MMCA.Common.Infrastructure.Services` | `MMCA.Common.Infrastructure/Services/TokenService.cs:23` | +| `UpcastingIntegrationEventConsumer` | class | MMCA.Common.Infrastructure | `MMCA.Common.Infrastructure.Services` | `MMCA.Common.Infrastructure/Services/UpcastingIntegrationEventConsumer.cs:31` | | `AuditTrailSettings` | class | MMCA.Common.Infrastructure | `MMCA.Common.Infrastructure.Settings` | `MMCA.Common.Infrastructure/Settings/AuditTrailSettings.cs:16` | | `ConnectionStringSettings` | class | MMCA.Common.Infrastructure | `MMCA.Common.Infrastructure.Settings` | `MMCA.Common.Infrastructure/Settings/ConnectionStringSettings.cs:9` | | `DataSourceEntrySettings` | class | MMCA.Common.Infrastructure | `MMCA.Common.Infrastructure.Settings` | `MMCA.Common.Infrastructure/Settings/DataSourceEntrySettings.cs:19` | @@ -2815,7 +2992,9 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `DependencyInjectionTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests` | `MMCA.Common.Infrastructure.Tests/DependencyInjectionTests.cs:18` | | `UseDataSourceAttributeTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests` | `MMCA.Common.Infrastructure.Tests/UseDataSourceAttributeTests.cs:6` | | `FakeCacheService` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Auth` | `MMCA.Common.Infrastructure.Tests/Auth/LoginProtectionServiceTests.cs:291` | +| `FakeCacheService` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Auth` | `MMCA.Common.Infrastructure.Tests/Auth/PasswordResetTokenServiceTests.cs:222` | | `LoginProtectionServiceTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Auth` | `MMCA.Common.Infrastructure.Tests/Auth/LoginProtectionServiceTests.cs:14` | +| `PasswordResetTokenServiceTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Auth` | `MMCA.Common.Infrastructure.Tests/Auth/PasswordResetTokenServiceTests.cs:18` | | `RsaJwksProviderTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Auth` | `MMCA.Common.Infrastructure.Tests/Auth/RsaJwksProviderTests.cs:14` | | `AddCommonHybridCacheTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Caching` | `MMCA.Common.Infrastructure.Tests/Caching/AddCommonHybridCacheTests.cs:18` | | `CacheOptionsTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Caching` | `MMCA.Common.Infrastructure.Tests/Caching/CacheOptionsTests.cs:6` | @@ -3113,17 +3292,18 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `CurrentUserServiceTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/CurrentUserServiceTests.cs:11` | | `DataSourceServiceAdditionalTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/DataSourceServiceAdditionalTests.cs:14` | | `DataSourceServiceTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/DataSourceServiceTests.cs:13` | +| `EventUpcasterStartupValidatorTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/EventUpcasterStartupValidatorTests.cs:20` | | `FakeEntity` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/DataSourceServiceAdditionalTests.cs:84` | | `FakeEntity` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/DataSourceServiceTests.cs:165` | | `FaultIntegrationEventConsumerTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/FaultIntegrationEventConsumerTests.cs:15` | | `ImageSharpImageProcessorTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/ImageSharpImageProcessorTests.cs:15` | | `InProcessEventBusOutboxTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/InProcessEventBusOutboxTests.cs:25` | | `InProcessEventBusTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/InProcessEventBusTests.cs:20` | -| `InProcessMessageBusTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:19` | +| `InProcessMessageBusTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:20` | | `IntegrationEventConsumerTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/IntegrationEventConsumerTests.cs:11` | | `Mocks` | record | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/BrokerEventBusTests.cs:30` | | `Mocks` | record | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/BrokerMessageBusTests.cs:26` | -| `Mocks` | record | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:24` | +| `Mocks` | record | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:25` | | `NativePushPayloadsTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/NativePushPayloadsTests.cs:12` | | `NullAssemblyProvider` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/BrokerEventBusTests.cs:332` | | `NullAssemblyProvider` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/InProcessEventBusOutboxTests.cs:150` | @@ -3131,12 +3311,22 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `NullLiveChannelPublisherTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/NullLiveChannelPublisherTests.cs:6` | | `NullPushNotificationSenderTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/NullPushNotificationSenderTests.cs:6` | | `NullUserService` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/CurrentUserServiceTests.cs:277` | +| `OrderPlacedV2` | record | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/UpcastingIntegrationEventConsumerTests.cs:31` | | `OtherIntegrationEvent` | record | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/BrokerMessageBusTests.cs:23` | +| `PasswordHasherSecurityTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/PasswordHasherSecurityTests.cs:18` | | `PasswordHasherTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/PasswordHasherTests.cs:8` | | `PeriodicBackgroundServiceTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/PeriodicBackgroundServiceTests.cs:15` | -| `RecordingDomainHandler` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:152` | -| `RecordingIntegrationHandler` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:161` | +| `RecordingDomainHandler` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:231` | +| `RecordingIntegrationHandler` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:240` | +| `RecordingOriginalHandler` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:219` | +| `RecordingSuccessorHandler` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:208` | +| `RetiredOrderPlaced` | record | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/UpcastingIntegrationEventConsumerTests.cs:29` | +| `RetiredTestIntegrationEvent` | record | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:195` | +| `RetiredToV2Upcaster` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:202` | +| `RetiredToV2Upcaster` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/UpcastingIntegrationEventConsumerTests.cs:36` | +| `RivalV1ToV3Upcaster` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/EventUpcasterStartupValidatorTests.cs:40` | | `RoleOnlyService` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/CurrentUserServiceTests.cs:290` | +| `SampleV1ToV2Upcaster` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/EventUpcasterStartupValidatorTests.cs:35` | | `SignalRLiveChannelPublisherTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/SignalRLiveChannelPublisherTests.cs:8` | | `SignalRPushNotificationSenderAdditionalTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/SignalRPushNotificationSenderAdditionalTests.cs:12` | | `SignalRPushNotificationSenderTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/SignalRPushNotificationSenderTests.cs:8` | @@ -3148,14 +3338,19 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `TestIntegrationEvent` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/BrokerEventBusTests.cs:226` | | `TestIntegrationEvent` | record | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/BrokerMessageBusTests.cs:21` | | `TestIntegrationEvent` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/InProcessEventBusOutboxTests.cs:94` | -| `TestIntegrationEvent` | record | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:21` | +| `TestIntegrationEvent` | record | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:22` | | `TestIntegrationEvent` | record | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/IntegrationEventConsumerTests.cs:13` | +| `TestIntegrationEventV2` | record | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:197` | | `TestNonOutboxContext` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/BrokerEventBusTests.cs:292` | | `TestNonOutboxContext` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/InProcessEventBusTests.cs:118` | | `TestOutboxContext` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/BrokerEventBusTests.cs:237` | | `TestOutboxContext` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/InProcessEventBusOutboxTests.cs:105` | | `TokenServiceTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/TokenServiceTests.cs:11` | | `UnregisteredEntity` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/DataSourceServiceAdditionalTests.cs:86` | +| `UpcastingIntegrationEventConsumerTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/UpcastingIntegrationEventConsumerTests.cs:26` | +| `ValidatorSampleV1` | record | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/EventUpcasterStartupValidatorTests.cs:23` | +| `ValidatorSampleV2` | record | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/EventUpcasterStartupValidatorTests.cs:25` | +| `ValidatorSampleV3` | record | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Services` | `MMCA.Common.Infrastructure.Tests/Services/EventUpcasterStartupValidatorTests.cs:30` | | `ConnectionStringSettingsTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Settings` | `MMCA.Common.Infrastructure.Tests/Settings/SettingsTests.cs:132` | | `JwtSettingsTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Settings` | `MMCA.Common.Infrastructure.Tests/Settings/SettingsTests.cs:8` | | `MessageBusSettingsTests` | class | MMCA.Common.Infrastructure.Tests | `MMCA.Common.Infrastructure.Tests.Settings` | `MMCA.Common.Infrastructure.Tests/Settings/SettingsTests.cs:227` | @@ -3176,11 +3371,12 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `PaginationMetadata` | record | MMCA.Common.Shared | `MMCA.Common.Shared.Abstractions` | `MMCA.Common.Shared/Abstractions/PaginationMetadata.cs:12` | | `Result` | class | MMCA.Common.Shared | `MMCA.Common.Shared.Abstractions` | `MMCA.Common.Shared/Abstractions/Result.cs:18` | | `Result` | class | MMCA.Common.Shared | `MMCA.Common.Shared.Abstractions` | `MMCA.Common.Shared/Abstractions/Result.cs:137` | -| `ServiceContractAttribute` | class | MMCA.Common.Shared | `MMCA.Common.Shared.Abstractions` | `MMCA.Common.Shared/Abstractions/ServiceContractAttribute.cs:19` | +| `ServiceContractAttribute` | class | MMCA.Common.Shared | `MMCA.Common.Shared.Abstractions` | `MMCA.Common.Shared/Abstractions/ServiceContractAttribute.cs:21` | | `AuthClaimTypes` | class | MMCA.Common.Shared | `MMCA.Common.Shared.Auth` | `MMCA.Common.Shared/Auth/AuthClaimTypes.cs:7` | | `AuthenticationResponse` | record struct | MMCA.Common.Shared | `MMCA.Common.Shared.Auth` | `MMCA.Common.Shared/Auth/AuthenticationResponse.cs:10` | | `ChangePasswordRequest` | record struct | MMCA.Common.Shared | `MMCA.Common.Shared.Auth` | `MMCA.Common.Shared/Auth/ChangePasswordRequest.cs:8` | | `ChangePreferencesRequest` | record | MMCA.Common.Shared | `MMCA.Common.Shared.Auth` | `MMCA.Common.Shared/Auth/ChangePreferencesRequest.cs:10` | +| `ForgotPasswordRequest` | record struct | MMCA.Common.Shared | `MMCA.Common.Shared.Auth` | `MMCA.Common.Shared/Auth/ForgotPasswordRequest.cs:8` | | `IPermissionRegistry` | interface | MMCA.Common.Shared | `MMCA.Common.Shared.Auth` | `MMCA.Common.Shared/Auth/IPermissionRegistry.cs:13` | | `LoginRequest` | record struct | MMCA.Common.Shared | `MMCA.Common.Shared.Auth` | `MMCA.Common.Shared/Auth/LoginRequest.cs:8` | | `OAuthCodeExchangeRequest` | record struct | MMCA.Common.Shared | `MMCA.Common.Shared.Auth` | `MMCA.Common.Shared/Auth/OAuthCodeExchangeRequest.cs:11` | @@ -3188,6 +3384,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `PermissionRegistryBuilder` | class | MMCA.Common.Shared | `MMCA.Common.Shared.Auth` | `MMCA.Common.Shared/Auth/PermissionRegistryBuilder.cs:8` | | `RefreshTokenRequest` | record struct | MMCA.Common.Shared | `MMCA.Common.Shared.Auth` | `MMCA.Common.Shared/Auth/RefreshTokenRequest.cs:9` | | `RegisterRequest` | record struct | MMCA.Common.Shared | `MMCA.Common.Shared.Auth` | `MMCA.Common.Shared/Auth/RegisterRequest.cs:13` | +| `ResetPasswordRequest` | record struct | MMCA.Common.Shared | `MMCA.Common.Shared.Auth` | `MMCA.Common.Shared/Auth/ResetPasswordRequest.cs:9` | | `RoleNames` | class | MMCA.Common.Shared | `MMCA.Common.Shared.Auth` | `MMCA.Common.Shared/Auth/RoleNames.cs:12` | | `RoleValue` | class | MMCA.Common.Shared | `MMCA.Common.Shared.Auth` | `MMCA.Common.Shared/Auth/RoleValue.cs:25` | | `UserPreferencesResponse` | record | MMCA.Common.Shared | `MMCA.Common.Shared.Auth` | `MMCA.Common.Shared/Auth/UserPreferencesResponse.cs:9` | @@ -3277,6 +3474,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `IIntegrationTestFixture` | interface | MMCA.Common.Testing | `MMCA.Common.Testing` | `MMCA.Common.Testing/IIntegrationTestFixture.cs:8` | | `IntegrationTestBase` | class | MMCA.Common.Testing | `MMCA.Common.Testing` | `MMCA.Common.Testing/IntegrationTestBase.cs:13` | | `JwtTokenGenerator` | class | MMCA.Common.Testing | `MMCA.Common.Testing` | `MMCA.Common.Testing/JwtTokenGenerator.cs:30` | +| `MiddlewarePipelineOrderTestsBase` | class | MMCA.Common.Testing | `MMCA.Common.Testing` | `MMCA.Common.Testing/MiddlewarePipelineOrderTestsBase.cs:29` | | `OpenApiContractTestsBase` | class | MMCA.Common.Testing | `MMCA.Common.Testing` | `MMCA.Common.Testing/OpenApiContractTestsBase.cs:21` | | `ProblemDetailsContractTestsBase` | class | MMCA.Common.Testing | `MMCA.Common.Testing` | `MMCA.Common.Testing/ProblemDetailsContractTestsBase.cs:21` | | `ProductionHostApplicationFactory` | class | MMCA.Common.Testing | `MMCA.Common.Testing` | `MMCA.Common.Testing/ProductionHostApplicationFactory.cs:22` | @@ -3286,9 +3484,11 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `TestPolling` | class | MMCA.Common.Testing | `MMCA.Common.Testing` | `MMCA.Common.Testing/TestPolling.cs:9` | | `EntityBuilderBase` | class | MMCA.Common.Testing | `MMCA.Common.Testing.Builders` | `MMCA.Common.Testing/Builders/EntityBuilderBase.cs:9` | | `AggregateConventionTestsBase` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/Bases/AggregateConventionTestsBase.cs:10` | +| `AnonymousEndpointTestsBase` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/Bases/AnonymousEndpointTestsBase.cs:30` | | `ArchitectureAssert` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/ArchitectureAssert.cs:8` | | `ArchitectureMapBase` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/ArchitectureMapBase.cs:11` | | `ArchitectureRules` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/ArchitectureRules.CancellationTokens.cs:5` | +| `ArchitectureRules` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/ArchitectureRules.Contracts.cs:3` | | `ArchitectureRules` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/ArchitectureRules.Controllers.cs:3` | | `ArchitectureRules` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/ArchitectureRules.Cycles.cs:5` | | `ArchitectureRules` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/ArchitectureRules.Entities.cs:3` | @@ -3308,6 +3508,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `ArchitectureRules` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/ArchitectureRules.Slices.cs:3` | | `ArchitectureRules` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/ArchitectureRules.Specifications.cs:5` | | `ArchitectureRules` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/ArchitectureRules.Transport.cs:3` | +| `ArchitectureRules` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/ArchitectureRules.Upcasters.cs:5` | | `BrandColorTokenTestsBase` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/Bases/BrandColorTokenTestsBase.cs:13` | | `CancellationTokenConventionTestsBase` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/Bases/CancellationTokenConventionTestsBase.cs:16` | | `ConcurrencyConventionTestsBase` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/Bases/ConcurrencyConventionTestsBase.cs:8` | @@ -3318,7 +3519,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `DependencyVersionTestsBase` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/Bases/DependencyVersionTestsBase.cs:15` | | `DomainPurityTestsBase` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/Bases/DomainPurityTestsBase.cs:8` | | `EntityConventionTestsBase` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/Bases/EntityConventionTestsBase.cs:9` | -| `EventConventionTestsBase` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/Bases/EventConventionTestsBase.cs:8` | +| `EventConventionTestsBase` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/Bases/EventConventionTestsBase.cs:9` | | `FormsConventionTestsBase` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/Bases/FormsConventionTestsBase.cs:15` | | `FrameworkVersionConsistencyTestsBase` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/Bases/FrameworkVersionConsistencyTestsBase.cs:13` | | `HandlerConventionTestsBase` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/Bases/HandlerConventionTestsBase.cs:8` | @@ -3345,6 +3546,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `RawQueryableConventionTestsBase` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/Bases/RawQueryableConventionTestsBase.cs:30` | | `RouteAuthorizationTestsBase` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/Bases/RouteAuthorizationTestsBase.cs:22` | | `RuleHelpers` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/RuleHelpers.cs:14` | +| `ServiceContractPurityTestsBase` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/Bases/ServiceContractPurityTestsBase.cs:20` | | `SharedLayerTestsBase` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/Bases/SharedLayerTestsBase.cs:7` | | `SliceCohesionTestsBase` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/Bases/SliceCohesionTestsBase.cs:10` | | `SpecificationConventionTestsBase` | class | MMCA.Common.Testing.Architecture | `MMCA.Common.Testing.Architecture` | `MMCA.Common.Testing.Architecture/Bases/SpecificationConventionTestsBase.cs:10` | @@ -3363,11 +3565,14 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `WebVitalsBudget` | record | MMCA.Common.Testing.E2E | `MMCA.Common.Testing.E2E.Infrastructure` | `MMCA.Common.Testing.E2E/Infrastructure/WebVitalsCollector.cs:103` | | `WebVitalsCollector` | class | MMCA.Common.Testing.E2E | `MMCA.Common.Testing.E2E.Infrastructure` | `MMCA.Common.Testing.E2E/Infrastructure/WebVitalsCollector.cs:20` | | `WebVitalsSample` | record | MMCA.Common.Testing.E2E | `MMCA.Common.Testing.E2E.Infrastructure` | `MMCA.Common.Testing.E2E/Infrastructure/WebVitalsCollector.cs:76` | +| `ForgotPasswordPage` | class | MMCA.Common.Testing.E2E | `MMCA.Common.Testing.E2E.PageObjects` | `MMCA.Common.Testing.E2E/PageObjects/ForgotPasswordPage.cs:6` | | `LoginPage` | class | MMCA.Common.Testing.E2E | `MMCA.Common.Testing.E2E.PageObjects` | `MMCA.Common.Testing.E2E/PageObjects/LoginPage.cs:6` | | `ProfilePage` | class | MMCA.Common.Testing.E2E | `MMCA.Common.Testing.E2E.PageObjects` | `MMCA.Common.Testing.E2E/PageObjects/ProfilePage.cs:6` | | `RegisterPage` | class | MMCA.Common.Testing.E2E | `MMCA.Common.Testing.E2E.PageObjects` | `MMCA.Common.Testing.E2E/PageObjects/RegisterPage.cs:6` | +| `ResetPasswordPage` | class | MMCA.Common.Testing.E2E | `MMCA.Common.Testing.E2E.PageObjects` | `MMCA.Common.Testing.E2E/PageObjects/ResetPasswordPage.cs:6` | | `AuthorizationTestsBase` | class | MMCA.Common.Testing.E2E | `MMCA.Common.Testing.E2E.Workflows.Identity` | `MMCA.Common.Testing.E2E/Workflows/Identity/AuthorizationTestsBase.cs:18` | | `LogoutTestsBase` | class | MMCA.Common.Testing.E2E | `MMCA.Common.Testing.E2E.Workflows.Identity` | `MMCA.Common.Testing.E2E/Workflows/Identity/LogoutTestsBase.cs:9` | +| `PasswordResetTestsBase` | class | MMCA.Common.Testing.E2E | `MMCA.Common.Testing.E2E.Workflows.Identity` | `MMCA.Common.Testing.E2E/Workflows/Identity/PasswordResetTestsBase.cs:17` | | `ProfileManagementTestsBase` | class | MMCA.Common.Testing.E2E | `MMCA.Common.Testing.E2E.Workflows.Identity` | `MMCA.Common.Testing.E2E/Workflows/Identity/ProfileManagementTestsBase.cs:11` | | `UserLoginTestsBase` | class | MMCA.Common.Testing.E2E | `MMCA.Common.Testing.E2E.Workflows.Identity` | `MMCA.Common.Testing.E2E/Workflows/Identity/UserLoginTestsBase.cs:10` | | `UserRegistrationTestsBase` | class | MMCA.Common.Testing.E2E | `MMCA.Common.Testing.E2E.Workflows.Identity` | `MMCA.Common.Testing.E2E/Workflows/Identity/UserRegistrationTestsBase.cs:10` | @@ -3380,6 +3585,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `HandlerTestBaseTests` | class | MMCA.Common.Testing.Tests | `MMCA.Common.Testing.Tests` | `MMCA.Common.Testing.Tests/HandlerTestBaseTests.cs:12` | | `ISampleService` | interface | MMCA.Common.Testing.Tests | `MMCA.Common.Testing.Tests` | `MMCA.Common.Testing.Tests/DependencyInjectionAssertTests.cs:44` | | `JwtTokenGeneratorTests` | class | MMCA.Common.Testing.Tests | `MMCA.Common.Testing.Tests` | `MMCA.Common.Testing.Tests/JwtTokenGeneratorTests.cs:18` | +| `MiddlewarePipelineOrderTests` | class | MMCA.Common.Testing.Tests | `MMCA.Common.Testing.Tests` | `MMCA.Common.Testing.Tests/MiddlewarePipelineOrderTests.cs:10` | | `PingCommand` | record | MMCA.Common.Testing.Tests | `MMCA.Common.Testing.Tests` | `MMCA.Common.Testing.Tests/DecoratorPipelineOrderTests.cs:41` | | `PingCommandHandler` | class | MMCA.Common.Testing.Tests | `MMCA.Common.Testing.Tests` | `MMCA.Common.Testing.Tests/DecoratorPipelineOrderTests.cs:45` | | `PingQuery` | record | MMCA.Common.Testing.Tests | `MMCA.Common.Testing.Tests` | `MMCA.Common.Testing.Tests/DecoratorPipelineOrderTests.cs:43` | @@ -3415,11 +3621,11 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `IUIModule` | interface | MMCA.Common.UI | `MMCA.Common.UI.Common.Interfaces` | `MMCA.Common.UI/Common/Interfaces/IUIModule.cs:10` | | `ApiSettings` | class | MMCA.Common.UI | `MMCA.Common.UI.Common.Settings` | `MMCA.Common.UI/Common/Settings/ApiSettings.cs:9` | | `IApiSettings` | interface | MMCA.Common.UI | `MMCA.Common.UI.Common.Settings` | `MMCA.Common.UI/Common/Settings/IApiSettings.cs:6` | -| `LayoutSettings` | class | MMCA.Common.UI | `MMCA.Common.UI.Common.Settings` | `MMCA.Common.UI/Common/Settings/LayoutSettings.cs:7` | +| `LayoutSettings` | class | MMCA.Common.UI | `MMCA.Common.UI.Common.Settings` | `MMCA.Common.UI/Common/Settings/LayoutSettings.cs:9` | | `UIModuleConfiguration` | class | MMCA.Common.UI | `MMCA.Common.UI.Common.Settings` | `MMCA.Common.UI/Common/Settings/UIModuleConfiguration.cs:10` | | `MobileInfiniteScrollList` | class | MMCA.Common.UI | `MMCA.Common.UI.Components` | `MMCA.Common.UI/Components/MobileInfiniteScrollList.razor.cs:17` | | `QrErrorCorrectionLevel` | enum | MMCA.Common.UI | `MMCA.Common.UI.Components` | `MMCA.Common.UI/Components/QrErrorCorrectionLevel.cs:9` | -| `NotificationBell` | class | MMCA.Common.UI | `MMCA.Common.UI.Components.Notifications` | `MMCA.Common.UI/Components/Notifications/NotificationBell.razor.cs:14` | +| `NotificationBell` | class | MMCA.Common.UI | `MMCA.Common.UI.Components.Notifications` | `MMCA.Common.UI/Components/Notifications/NotificationBell.razor.cs:22` | | `MoneyExtensions` | class | MMCA.Common.UI | `MMCA.Common.UI.Extensions` | `MMCA.Common.UI/Extensions/MoneyExtensions.cs:14` | | `WebApplicationExtensions` | class | MMCA.Common.UI | `MMCA.Common.UI.Extensions` | `MMCA.Common.UI/Extensions/WebApplicationExtensions.cs:8` | | `PseudoLocalizer` | class | MMCA.Common.UI | `MMCA.Common.UI.Globalization` | `MMCA.Common.UI/Globalization/PseudoLocalizer.cs:20` | @@ -3428,9 +3634,11 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `ResxMudLocalizer` | class | MMCA.Common.UI | `MMCA.Common.UI.Globalization` | `MMCA.Common.UI/Globalization/ResxMudLocalizer.cs:17` | | `DependencyInjection` | class | MMCA.Common.UI | `MMCA.Common.UI.Notifications` | `MMCA.Common.UI/Notifications/DependencyInjection.cs:12` | | `NotificationUIModule` | class | MMCA.Common.UI | `MMCA.Common.UI.Notifications` | `MMCA.Common.UI/Notifications/NotificationUIModule.cs:14` | +| `ForgotPasswordModel` | class | MMCA.Common.UI | `MMCA.Common.UI.Pages.Auth` | `MMCA.Common.UI/Pages/Auth/ForgotPasswordModel.cs:9` | | `LoginModel` | class | MMCA.Common.UI | `MMCA.Common.UI.Pages.Auth` | `MMCA.Common.UI/Pages/Auth/LoginModel.cs:9` | | `PasswordComplexityAttribute` | class | MMCA.Common.UI | `MMCA.Common.UI.Pages.Auth` | `MMCA.Common.UI/Pages/Auth/PasswordComplexityAttribute.cs:12` | | `RegisterModel` | class | MMCA.Common.UI | `MMCA.Common.UI.Pages.Auth` | `MMCA.Common.UI/Pages/Auth/RegisterModel.cs:9` | +| `ResetPasswordModel` | class | MMCA.Common.UI | `MMCA.Common.UI.Pages.Auth` | `MMCA.Common.UI/Pages/Auth/ResetPasswordModel.cs:10` | | `DataGridListPageBase` | class | MMCA.Common.UI | `MMCA.Common.UI.Pages.Common` | `MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:20` | | `ErrorMessages` | class | MMCA.Common.UI | `MMCA.Common.UI.Pages.Common` | `MMCA.Common.UI/Pages/Common/ErrorMessages.cs:17` | | `PersistedGridState` | record | MMCA.Common.UI | `MMCA.Common.UI.Pages.Common` | `MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:805` | @@ -3547,7 +3755,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `INotificationScopeProvider` | interface | MMCA.Common.UI | `MMCA.Common.UI.Services.Notifications` | `MMCA.Common.UI/Services/Notifications/INotificationScopeProvider.cs:14` | | `IPushNotificationUIService` | interface | MMCA.Common.UI | `MMCA.Common.UI.Services.Notifications` | `MMCA.Common.UI/Services/Notifications/IPushNotificationUIService.cs:9` | | `NotificationHubService` | class | MMCA.Common.UI | `MMCA.Common.UI.Services.Notifications` | `MMCA.Common.UI/Services/Notifications/NotificationHubService.cs:26` | -| `NotificationInboxService` | class | MMCA.Common.UI | `MMCA.Common.UI.Services.Notifications` | `MMCA.Common.UI/Services/Notifications/NotificationInboxService.cs:15` | +| `NotificationInboxService` | class | MMCA.Common.UI | `MMCA.Common.UI.Services.Notifications` | `MMCA.Common.UI/Services/Notifications/NotificationInboxService.cs:28` | | `NotificationState` | class | MMCA.Common.UI | `MMCA.Common.UI.Services.Notifications` | `MMCA.Common.UI/Services/Notifications/NotificationState.cs:8` | | `NullNotificationScopeProvider` | class | MMCA.Common.UI | `MMCA.Common.UI.Services.Notifications` | `MMCA.Common.UI/Services/Notifications/NullNotificationScopeProvider.cs:8` | | `PushNotificationService` | class | MMCA.Common.UI | `MMCA.Common.UI.Services.Notifications` | `MMCA.Common.UI/Services/Notifications/PushNotificationService.cs:15` | @@ -3555,11 +3763,13 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `MMCATheme` | class | MMCA.Common.UI | `MMCA.Common.UI.Theme` | `MMCA.Common.UI/Theme/MMCATheme.cs:9` | | `ComponentsPageE2ETests` | class | MMCA.Common.UI.E2E.Tests | `MMCA.Common.UI.E2E.Tests` | `MMCA.Common.UI.E2E.Tests/ComponentsPageE2ETests.cs:10` | | `DarkModeE2ETests` | class | MMCA.Common.UI.E2E.Tests | `MMCA.Common.UI.E2E.Tests` | `MMCA.Common.UI.E2E.Tests/DarkModeE2ETests.cs:16` | +| `ForgotPasswordPageE2ETests` | class | MMCA.Common.UI.E2E.Tests | `MMCA.Common.UI.E2E.Tests` | `MMCA.Common.UI.E2E.Tests/ForgotPasswordPageE2ETests.cs:9` | | `LoginPageE2ETests` | class | MMCA.Common.UI.E2E.Tests | `MMCA.Common.UI.E2E.Tests` | `MMCA.Common.UI.E2E.Tests/LoginPageE2ETests.cs:9` | | `MobileTopRowE2ETests` | class | MMCA.Common.UI.E2E.Tests | `MMCA.Common.UI.E2E.Tests` | `MMCA.Common.UI.E2E.Tests/MobileTopRowE2ETests.cs:18` | | `NotificationPagesE2ETests` | class | MMCA.Common.UI.E2E.Tests | `MMCA.Common.UI.E2E.Tests` | `MMCA.Common.UI.E2E.Tests/NotificationPagesE2ETests.cs:13` | | `PseudoLocalizationE2ETests` | class | MMCA.Common.UI.E2E.Tests | `MMCA.Common.UI.E2E.Tests` | `MMCA.Common.UI.E2E.Tests/PseudoLocalizationE2ETests.cs:24` | | `RegisterPageE2ETests` | class | MMCA.Common.UI.E2E.Tests | `MMCA.Common.UI.E2E.Tests` | `MMCA.Common.UI.E2E.Tests/RegisterPageE2ETests.cs:9` | +| `ResetPasswordPageE2ETests` | class | MMCA.Common.UI.E2E.Tests | `MMCA.Common.UI.E2E.Tests` | `MMCA.Common.UI.E2E.Tests/ResetPasswordPageE2ETests.cs:9` | | `StickySidebarE2ETests` | class | MMCA.Common.UI.E2E.Tests | `MMCA.Common.UI.E2E.Tests` | `MMCA.Common.UI.E2E.Tests/StickySidebarE2ETests.cs:23` | | `WebVitalsBudgetTests` | class | MMCA.Common.UI.E2E.Tests | `MMCA.Common.UI.E2E.Tests` | `MMCA.Common.UI.E2E.Tests/WebVitalsBudgetTests.cs:12` | | `WebVitalsE2ETests` | class | MMCA.Common.UI.E2E.Tests | `MMCA.Common.UI.E2E.Tests` | `MMCA.Common.UI.E2E.Tests/WebVitalsE2ETests.cs:16` | @@ -3614,7 +3824,8 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `MmcaThemeProvidersTests` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Components` | `MMCA.Common.UI.Tests/Components/MmcaThemeProvidersTests.cs:19` | | `MobileCardListTests` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Components` | `MMCA.Common.UI.Tests/Components/MobileCardListTests.cs:8` | | `MobileInfiniteScrollListTests` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Components` | `MMCA.Common.UI.Tests/Components/MobileInfiniteScrollListTests.cs:11` | -| `NotificationBellTests` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Components` | `MMCA.Common.UI.Tests/Components/NotificationBellTests.cs:15` | +| `NotificationBellHost` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Components` | `MMCA.Common.UI.Tests/Components/NotificationBellTests.cs:17` | +| `NotificationBellTests` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Components` | `MMCA.Common.UI.Tests/Components/NotificationBellTests.cs:48` | | `NotificationListenerTests` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Components` | `MMCA.Common.UI.Tests/Components/NotificationListenerTests.cs:23` | | `PageStateScopeTests` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Components` | `MMCA.Common.UI.Tests/Components/PageStateScopeTests.cs:12` | | `PrimitivesSnapshotTests` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Components` | `MMCA.Common.UI.Tests/Components/PrimitivesSnapshotTests.cs:14` | @@ -3646,7 +3857,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `StubTokenStorageServiceTests` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Infrastructure` | `MMCA.Common.UI.Tests/Infrastructure/StubTokenStorageServiceTests.cs:13` | | `UiHttpServiceHarnessTests` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Infrastructure` | `MMCA.Common.UI.Tests/Infrastructure/UiHttpServiceHarnessTests.cs:13` | | `NavMenuTests` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Layout` | `MMCA.Common.UI.Tests/Layout/NavMenuTests.cs:21` | -| `StubUiModule` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Layout` | `MMCA.Common.UI.Tests/Layout/NavMenuTests.cs:108` | +| `StubUiModule` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Layout` | `MMCA.Common.UI.Tests/Layout/NavMenuTests.cs:136` | | `ForbiddenTests` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Pages` | `MMCA.Common.UI.Tests/Pages/ForbiddenTests.cs:10` | | `AuthModelValidationTests` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Pages.Auth` | `MMCA.Common.UI.Tests/Pages/Auth/AuthModelValidationTests.cs:11` | | `RegisterFormTests` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Pages.Auth` | `MMCA.Common.UI.Tests/Pages/Auth/RegisterFormTests.cs:16` | @@ -3690,13 +3901,13 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `CapturingLogger` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Services.Notifications` | `MMCA.Common.UI.Tests/Services/Notifications/NotificationHubServiceTests.cs:286` | | `ChannelReferenceCounterTests` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Services.Notifications` | `MMCA.Common.UI.Tests/Services/Notifications/NotificationHubServiceTests.cs:320` | | `ConcurrencyTrackingTokenStorage` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Services.Notifications` | `MMCA.Common.UI.Tests/Services/Notifications/NotificationHubServiceTests.cs:241` | -| `Mocks` | record | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Services.Notifications` | `MMCA.Common.UI.Tests/Services/Notifications/NotificationInboxServiceTests.cs:23` | +| `Mocks` | record | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Services.Notifications` | `MMCA.Common.UI.Tests/Services/Notifications/NotificationInboxServiceTests.cs:24` | | `Mocks` | record | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Services.Notifications` | `MMCA.Common.UI.Tests/Services/Notifications/PushNotificationServiceTests.cs:21` | | `NotificationHubServiceTests` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Services.Notifications` | `MMCA.Common.UI.Tests/Services/Notifications/NotificationHubServiceTests.cs:29` | -| `NotificationInboxServiceTests` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Services.Notifications` | `MMCA.Common.UI.Tests/Services/Notifications/NotificationInboxServiceTests.cs:21` | +| `NotificationInboxServiceTests` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Services.Notifications` | `MMCA.Common.UI.Tests/Services/Notifications/NotificationInboxServiceTests.cs:22` | | `NotificationStateTests` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Services.Notifications` | `MMCA.Common.UI.Tests/Services/Notifications/NotificationStateTests.cs:11` | | `PushNotificationServiceTests` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Services.Notifications` | `MMCA.Common.UI.Tests/Services/Notifications/PushNotificationServiceTests.cs:19` | -| `StubScopeProvider` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Services.Notifications` | `MMCA.Common.UI.Tests/Services/Notifications/NotificationInboxServiceTests.cs:26` | +| `StubScopeProvider` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Services.Notifications` | `MMCA.Common.UI.Tests/Services/Notifications/NotificationInboxServiceTests.cs:27` | | `StubScopeProvider` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Services.Notifications` | `MMCA.Common.UI.Tests/Services/Notifications/PushNotificationServiceTests.cs:24` | | `BrandColorTokenTests` | class | MMCA.Common.UI.Tests | `MMCA.Common.UI.Tests.Theme` | `MMCA.Common.UI.Tests/Theme/BrandColorTokenTests.cs:14` | | `DependencyInjection` | class | MMCA.Common.UI.Web | `MMCA.Common.UI.Web` | `MMCA.Common.UI.Web/DependencyInjection.cs:14` | @@ -3714,7 +3925,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file |----------|----------|-----------| | `ICurrentUserService currentUserService` | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API/Authorization/CurrentUserServiceExtensions.cs:12` | | `IServiceCollection services` | MMCA.ADC.Conference.API | `MMCA.ADC.Conference.API/DependencyInjection.cs:16` | -| `IServiceCollection services` | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application/DependencyInjection.cs:37` | +| `IServiceCollection services` | MMCA.ADC.Conference.Application | `MMCA.ADC.Conference.Application/DependencyInjection.cs:41` | | `IServiceCollection services` | MMCA.ADC.Conference.Contracts | `MMCA.ADC.Conference.Contracts/DependencyInjection.cs:17` | | `IServiceCollection services` | MMCA.ADC.Conference.Infrastructure | `MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:14` | | `IServiceCollection services` | MMCA.ADC.Conference.UI | `MMCA.ADC.Conference.UI/DependencyInjection.cs:13` | @@ -3745,11 +3956,11 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `IEndpointRouteBuilder endpoints` | MMCA.Common.API | `MMCA.Common.API/Startup/JwksEndpointExtensions.cs:22` | | `IServiceCollection services` | MMCA.Common.API | `MMCA.Common.API/Startup/MiniProfilerExtensions.cs:11` | | `IEndpointRouteBuilder endpoints` | MMCA.Common.API | `MMCA.Common.API/Startup/OidcDiscoveryEndpointExtensions.cs:49` | -| `WebApplication app` | MMCA.Common.API | `MMCA.Common.API/Startup/OpenApiEndpointExtensions.cs:20` | +| `WebApplication app` | MMCA.Common.API | `MMCA.Common.API/Startup/OpenApiEndpointExtensions.cs:24` | | `WebApplication app` | MMCA.Common.API | `MMCA.Common.API/Startup/SignalRExtensions.cs:14` | -| `IServiceCollection services` | MMCA.Common.API | `MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:226` | -| `WebApplication app` | MMCA.Common.API | `MMCA.Common.API/Startup/WebApplicationExtensions.cs:37` | -| `IServiceCollection services` | MMCA.Common.Application | `MMCA.Common.Application/DependencyInjection.cs:23` | +| `IServiceCollection services` | MMCA.Common.API | `MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:236` | +| `WebApplication app` | MMCA.Common.API | `MMCA.Common.API/Startup/WebApplicationExtensions.cs:35` | +| `IServiceCollection services` | MMCA.Common.Application | `MMCA.Common.Application/DependencyInjection.cs:24` | | `ValidationResult result` | MMCA.Common.Application | `MMCA.Common.Application/Extensions/ValidationFailureExtensions.cs:11` | | `IServiceCollection services` | MMCA.Common.Application | `MMCA.Common.Application/Notifications/DependencyInjection.cs:28` | | `IServiceCollection services` | MMCA.Common.Aspire | `MMCA.Common.Aspire/Extensions.cs:306` | @@ -3793,7 +4004,7 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file ## Generated / excluded artifacts (no type sections written) -118 files excluded as generated (EF migrations, snapshots, *.g.cs, AssemblyInfo). +122 files excluded as generated (EF migrations, snapshots, *.g.cs, AssemblyInfo). | File | |------| @@ -3822,6 +4033,10 @@ Generated mechanically by a Roslyn syntactic parse of every in-scope `.cs` file | `MMCA.ADC.Migrations.SqlServer.Conference/Migrations/20260814210425_AddEventOrganizerContactEmail.Designer.cs` | | `MMCA.ADC.Migrations.SqlServer.Conference/Migrations/20260814214554_AddEventSponsorshipPacketUrl.cs` | | `MMCA.ADC.Migrations.SqlServer.Conference/Migrations/20260814214554_AddEventSponsorshipPacketUrl.Designer.cs` | +| `MMCA.ADC.Migrations.SqlServer.Conference/Migrations/20260819153953_AddEventTicketingUrl.cs` | +| `MMCA.ADC.Migrations.SqlServer.Conference/Migrations/20260819153953_AddEventTicketingUrl.Designer.cs` | +| `MMCA.ADC.Migrations.SqlServer.Conference/Migrations/20260819160828_AddActivity.cs` | +| `MMCA.ADC.Migrations.SqlServer.Conference/Migrations/20260819160828_AddActivity.Designer.cs` | | `MMCA.ADC.Migrations.SqlServer.Conference/Migrations/SQLServerDbContextModelSnapshot.cs` | | `MMCA.ADC.Migrations.SqlServer.Engagement/DesignTimeSQLServerDbContextFactory.cs` | | `MMCA.ADC.Migrations.SqlServer.Engagement/Migrations/20260606053150_InitialCreate.cs` | diff --git a/docs-src/onboarding/00-primer.md b/docs-src/onboarding/00-primer.md index 7dbc43f..e36ddfe 100644 --- a/docs-src/onboarding/00-primer.md +++ b/docs-src/onboarding/00-primer.md @@ -314,6 +314,13 @@ the full set, for orientation: | 087 | Broker poison-message handling (amends 009): transport-aware second-level redelivery (opt-in on RabbitMQ, native on Azure Service Bus), an auto-registered `FaultIntegrationEventConsumer` that makes an exhausted message visible but never replays it (meter `MMCA.Common.Broker`), and a circuit breaker on the outbox broker publish only; a per-query DB breaker is rejected (does not compose with EF's execution strategy) | [g04](group-04-events-outbox.md)/[g07](group-07-persistence-ef-core.md) | | 088 | Gateway edge responsibilities (extends 019): the edge owns three cross-cutting behaviors via `MMCA.Common.Aspire`: `GatewayCorrelationMiddleware` ensures + forwards `X-Correlation-ID`, per-client-IP rate limiting that deliberately includes anonymous callers (inverting ADR-019's exemption), and downstream health probes on the `Ready` tag only; edge JWT pre-validation is declined with a trigger | [g16](group-16-aspire-orchestration.md) | | 089 | Gateway topology owned by configuration (amends 008): the route table moves out of `MapForwarder` code into YARP `ReverseProxy` configuration as the single route source, with `RouteMapTests` as a drift gate in both consumers and the per-destination HTTP version policy (ADR-012 profiles) in cluster config; the AppHost/bicep keep address books, not route tables | [g16](group-16-aspire-orchestration.md) | +| 090 | Event upcaster registration extension point (completes 010): `IEventUpcaster` in the Application layer plus an `EventUpcasterRegistry` that chains V1 to V2 to V3 to the terminal contract and re-stamps `MessageId`/`DateOccurred` after every hop, so inbox dedup survives upcasting; both delivery paths consult it, and a duplicate/self-map/cycle throws at host start | [g04](group-04-events-outbox.md)/[g05](group-05-cqrs-pipeline.md) | +| 091 | Cache-backed password reset (extends 029/032): the reset credential is one `ICacheService` record (256-bit token, only its SHA-256 stored, 30-minute TTL, single live token per address, 5 validation attempts, 3 requests per 60 minutes) rather than three columns on the user row, and `ForgotPasswordHandlerBase` returns success on every path so the endpoint is not an account-enumeration oracle | [g08](group-08-auth.md)/[g14](group-14-module-system-composition.md) | +| 092 | Core Web Vitals budget as a shipped test contract and deploy gate: `WebVitalsCollector` installs `PerformanceObserver` hooks as a Playwright init script, `WebVitalsBudget` defaults to the good band (LCP 2500, FCP 1800, TTFB 800 ms, CLS 0.1, INP 500), a breach throws naming the page, and both apps' assertions ride the chromium `e2e-gate` | [g27](group-27-testing-infrastructure.md)/[devops-cicd](devops-cicd.md) | +| 093 | Container image build posture: eleven four-stage Dockerfiles where the GitHub Packages token is a BuildKit secret (never an `ARG`/`ENV`), there is deliberately no separate `dotnet build` stage (publish re-restores; the RID split made every image compile twice, about 75 s), and `PublishReadyToRun=true` on the nine service/gateway images only; floating base tag and running as root are recorded as undecided | [devops-aspire](devops-aspire.md)/[devops-cicd](devops-cicd.md) | +| 094 | Client-side entity data-access contract (the calling half of 034): hand-written typed bases in `MMCA.Common.UI` (`AuthenticatedServiceBase` + `EntityServiceBase`), no generated client; the user-facing Polly retry lives in the client base rather than in ADR-009's resilience handler, and ADR-017's `Idempotency-Key` is minted client-side for creates only and held constant across the retry burst | [g15](group-15-common-ui-framework.md)/[g12](group-12-api-hosting-mapping.md) | +| 095 | Uniqueness under soft delete: `SoftDeleteUniqueIndexConvention`, registered once in `ApplicationDbContext.ConfigureConventions`, filters every unique index on a non-owned `IAuditableEntity` to live rows, so a deleted record stops occupying its unique slot forever; a hand-authored filter wins, `HasSoftDeleteFilter` is the manual extension point, and Cosmos is a no-op | [g07](group-07-persistence-ef-core.md) | +| 096 | Best-effort side-effect contract: one `BestEffort.ExecuteAsync(operation, logger, action, ct)` helper awaits the side effect and turns any failure into exactly one Warning plus one `besteffort.dispatch.failed` increment on its own `MMCA.Common.BestEffort` meter; caller cancellation is rethrown rather than swallowed, and the operation name stays a low-cardinality constant | [g03](group-03-querying-specifications.md)/[g22](group-22-engagement-module.md) | The canonical index for the full set can be found at . diff --git a/docs-src/onboarding/99-coverage-audit.md b/docs-src/onboarding/99-coverage-audit.md index 9d8efdb..bf703fa 100644 --- a/docs-src/onboarding/99-coverage-audit.md +++ b/docs-src/onboarding/99-coverage-audit.md @@ -11,20 +11,20 @@ explained, and lists what could not be determined from source. All counts are re | Quantity | Count | Source | |----------|------:|--------| -| `.cs` files scanned | 2,810 | `00-inventory.md` | -|, in-scope | 2,692 | | -|, generated/excluded | 118 | logged exception §2.1 | -| Type declaration rows (incl. partial-class fragments) | 3,586 | `00-inventory.md` | -| **Distinct type nodes (partials collapsed)** | **3,465** | the master checklist | -| → mapped to a functional group | 3,465 | `classify.ps1` (0 unmapped) | -| → individually sectioned (named in a chapter) | 1,890 | `verify.ps1` | -| → rolled up by project (G25 test classes) | 1,575 | logged exception §2.2 | -| Distinct `###` sections written across 27 chapters | 1,834 | covering the 1,890 (sibling families share a section, §2.3) | +| `.cs` files scanned | 2,950 | `00-inventory.md` | +|, in-scope | 2,828 | | +|, generated/excluded | 122 | logged exception §2.1 | +| Type declaration rows (incl. partial-class fragments) | 3,797 | `00-inventory.md` | +| **Distinct type nodes (partials collapsed)** | **3,668** | the master checklist | +| → mapped to a functional group | 3,668 | `classify.ps1` (0 unmapped) | +| → individually sectioned (named in a chapter) | 2,001 | `verify.ps1` | +| → rolled up by project (G25 test classes) | 1,667 | logged exception §2.2 | +| Distinct `###` sections written across 27 chapters | 1,910 | covering the 2,001 (sibling families share a section, §2.3) | | Chapter overviews written | 27 | one per group | -**Cross-check result:** `verify.ps1` confirms **0** of the 1,890 individually-sectioned types are +**Cross-check result:** `verify.ps1` confirms **0** of the 2,001 individually-sectioned types are missing from their group chapter, every one appears as a `###` heading or in a sibling-family -`File:Line` table. 3,465 = 1,890 individually-sectioned + 1,575 rolled-up. Nothing dropped, nothing +`File:Line` table. 3,668 = 2,001 individually-sectioned + 1,667 rolled-up. Nothing dropped, nothing double-counted (each type maps to exactly one group). > **Caveat on what `verify.ps1` proves.** Its check is name presence: a type counts as covered when @@ -905,11 +905,53 @@ double-counted (each type maps to exactly one group). > three numeric properties. The v1.135.0 backlog of sections with corrected citations but unverified > bodies remains open outside the parts re-authored here. +> **Regeneration note (re-verified against current source, 2026-08-23 full drift sweep).** Regenerated +> at MMCA.Common `0110aee` + MMCA.ADC `96f0919a` (both clean; prior pass `0b19b56` / `018ccc50`). +> Net change: **+203** distinct nodes (3,465 to **3,668**), 0 removed, 0 regrouped; `classify.ps1` +> reports 0 unmapped and the per-group counts sum to 3,668. Individually-sectioned types 1,890 to +> **2,001**, roll-ups 1,575 to **1,667**, `###` sections 1,834 to **1,910**, cycles 30 to **34**. +> - **Password-reset vertical (G08 +8, G12 +5, G14 +2, [ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html)):** the cache-backed +> forgot/reset-password flow: `ForgotPasswordHandlerBase` +> (`MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:35`) and its +> Reset sibling, `PasswordResetTokenService` (`MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:26`), +> `PasswordResetSettings` (`MMCA.Common.Application/Auth/PasswordResetSettings.cs:10`), and +> `PasswordResetAuthControllerBase` +> (`MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:43`). +> - **Event upcasting (G03 +1, G05 +2, [ADR-090](https://ivanball.github.io/docs/adr/090-event-upcaster-registration.html)):** `IEventUpcaster` +> and `EventUpcasterRegistry` (`MMCA.Common.Application/Services/EventUpcasterRegistry.cs:30`). +> - **ADC Conference application (G18 +33):** the session-selection decision-support vertical +> (`GetContentSimilarity`, `GetSessionSelectionDashboard`, `GetSpeakerSessionOverlap`, +> `GetCategoryDistribution` under `MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/`, +> incl. `IAiScoringService` at `.../ScoreEventSessions/IAiScoringService.cs:40`), calendar export +> (`ExportEventCalendarQuery`/`ExportSessionCalendarQuery`), and new sponsor/category/question +> update use-cases; G17 +4, G19 +1, G20 +1, G21 +9, G23/Identity +5, G26/Live +1, G15 +2, G07 +2 +> ride the same waves. +> - **Testing growth (G25 +127):** per-`[Fact]` classes rolled up per the standing exception; the +> individually-sectioned reusable base set in group-27 now counts 240 types. +> - **Level-repack fallout (the reason 17 extra parts were re-authored):** `plan.ps1`'s repack moved +> 174 existing sections across unit boundaries beyond the delta-touched units (G08, G15, G18 p03/p06-p16, +> G19, G21, G22). A deterministic heading-vs-membership scan over `parts/` confirms 0 stale sections +> remain; the residual duplicate headings in group-23/group-24 are distinct same-name types (two +> `OptionState` component states; `UserDeleted` domain event vs integration event), not leftovers. +> - **Cycles 30 to 34:** four new SCCs, each wholly inside one group: two in G12 +> (`InsecureJwtMetadataWarningStartupFilter` / `WebApplicationBuilderExtensions` and +> `MiddlewarePipelineBuilder` / `WebApplicationExtensions`) and two test-only in G25 +> (`AnonymousEndpointTestsBaseTests` / `DriftedTests` / `StaleAllowListTests` / `ConformantTests`, +> and `EventUpcasterFitnessTests` / `UpcasterTestMap`). +> - **Outside the type pipeline:** `devops-iac` was re-authored against the 2026-08-22 FinOps +> second-stage Bicep changes (`MMCA.ADC/infra/main.bicep`, `foundation.bicep`; ADC PRs #135/#136); +> `CONCEPT-MAPS.md` needed no change (27 groups, 15 packages unchanged); the `00-primer.md` ADR +> table gained rows 090-096 from the canonical index. +> - **Authoring pass and verification:** 73 parts re-authored across 16 group chapters (52 approved +> units + the 17 repack-fallout units + 3 G18 units + `devops-iac`), authored against real source +> with `path:line` citations. `verify.ps1`: **0 missing**, rubric **34/34**. All adversarial +> spot-checks (overviews of G03/G05/G07/G08/G12, G08-p02, G15-p04) returned CONFIRMED. + --- ## 2. Exceptions log (every deliberate omission, with reason) -### 2.1 Generated / scaffolded code, not sectioned (118 files) +### 2.1 Generated / scaffolded code, not sectioned (122 files) EF Core migrations (`/Migrations/`, `.Migrations.SqlServer`), `ModelSnapshot`, `*.Designer.cs`, `*.g.cs`, `GlobalUsings.g.cs`, and `AssemblyInfo.cs` are excluded by rule (`Tools/invtool` `IsGenerated`). The **mechanisms** that produce them are taught instead: the `DbContext`, the migration workflow, and @@ -917,10 +959,10 @@ the `.proto`/gRPC contracts (see [group-07](group-07-persistence-ef-core.md), [group-13](group-13-grpc-contracts.md), and [devops-testing](devops-testing.md)). The full file list is in [`00-inventory.md`](00-inventory.md#generated--excluded-artifacts-no-type-sections-written). -### 2.2 Per-`[Fact]` test classes, rolled up by project (1,460 types) +### 2.2 Per-`[Fact]` test classes, rolled up by project (1,667 types) Per the guide's TESTS note, individual test classes are **not** given per-type sections. The [Testing chapter (group-27)](group-27-testing-infrastructure.md) instead: -- sections the **reusable** test infrastructure in full (the **168** types in `MMCA.Common.Testing`, +- sections the **reusable** test infrastructure in full (the **240** types in `MMCA.Common.Testing`, `.Testing.E2E`, `.Testing.UI`, the shared **`.Testing.Architecture`** rule library + bases, now including the six convention/fitness bases added since v1.93.0, the web-vitals collector, the localization resx-parity base, the slice-cohesion base, the markup-snapshot helper, the new @@ -932,7 +974,7 @@ Per the guide's TESTS note, individual test classes are **not** given per-type s `DependencyInjectionAssert`, `TestPolling`, `ModuleConformanceTestsBase` and the `WebVitalsBudget` added at the v1.142.0 pass, and the per-repo architecture-fitness test classes plus the `Gallery` harness), and -- rolls the remaining **1,460** per-suite test classes (including the `MMCA.Common.Benchmarks` +- rolls the remaining **1,667** per-suite test classes (including the `MMCA.Common.Benchmarks` perf-smoke project) into a **per-project table** (purpose + style: unit / integration / fitness / E2E / component / performance-smoke). Every one of the 1,460 remains individually listed with `file:line` in @@ -943,16 +985,16 @@ prose section. Near-identical families (per-entity `Add*/Remove*/Update*` commands, `*DTOMapper`, `*CreateRequest`, `*Validator`, per-type filter strategies, etc.) are taught in one `### A, B, C` section that explains the shared shape once. **Every** grouped type is still named and cited individually via the section's -`File:Line` table, so citation coverage is complete (this is what `verify.ps1` checks). The 1,804 -individually-sectioned types are covered by 1,740 `###` sections; the 64-type difference is family grouping. +`File:Line` table, so citation coverage is complete (this is what `verify.ps1` checks). The 2,001 +individually-sectioned types are covered by 1,910 `###` sections; the 91-type difference is family grouping. --- ## 3. Grouping & ordering verification -- **Every type in exactly one group.** `classify.ps1` assigns all 3,465 nodes via name-level overrides +- **Every type in exactly one group.** `classify.ps1` assigns all 3,668 nodes via name-level overrides (for the grab-bag `MMCA.Common.*Interfaces*/Services` namespaces) + ordered namespace-prefix rules; - it reports **0 unmapped** and the per-group counts sum to 3,465. See + it reports **0 unmapped** and the per-group counts sum to 3,668. See [`00-group-taxonomy.md`](00-group-taxonomy.md). - **Within-group ascending Level.** Each chapter's sections were authored from a pre-sorted, Level- ascending unit table, so no section precedes a same-group type it depends on (ties broken by name). diff --git a/docs-src/onboarding/devops-iac.md b/docs-src/onboarding/devops-iac.md index 60cc7ec..746ca09 100644 --- a/docs-src/onboarding/devops-iac.md +++ b/docs-src/onboarding/devops-iac.md @@ -145,14 +145,14 @@ workspaceCapping: { dailyQuotaGb: 1 } PerGB2018 is the pay-as-you-go tier. The 30-day minimum is Azure's floor for this SKU, shorter retention is rejected (and the memory note `reference_log_analytics_sku_limits.md` records this hard constraint). All six container apps ship their logs here via the Container Apps environment's -`appLogsConfiguration` (`main.bicep:905-911`), and `main.bicep`'s Application Insights component +`appLogsConfiguration` (`main.bicep:916-922`), and `main.bicep`'s Application Insights component uses it as its workspace backing store, meaning traces and metrics land in the same workspace. `workspaceCapping.dailyQuotaGb: 1` (`foundation.bicep:41-43`) is a FinOps circuit breaker, not a sizing decision. Normal ingestion is around 0.4 GB/day, so the ceiling never bites in steady state; it exists to bound a runaway telemetry storm (a metrics or log loop) instead of leaving a -pay-per-GB workspace uncapped. The comment records the escape hatch: raise it, or set -`dailyQuotaGb: -1`, if a legitimate busy period approaches the cap. +pay-per-GB workspace uncapped. The comment records the escape hatch (`foundation.bicep:37-40`): +raise it, or set `dailyQuotaGb: -1`, if a legitimate busy period approaches the cap. [Rubric §13, Observability & Operability] assesses whether the system exposes structured logs, distributed traces, and metrics in a queryable store. The single workspace is the convergence @@ -170,19 +170,19 @@ The `adminUserEnabled: false` setting (`foundation.bicep:60`) is the central cre decision for image pull. Without it, every container app would need a stored registry admin password. With it disabled, images are pulled exclusively via the shared UAMI's `AcrPull` role assignment (bootstrapped out-of-band, see the UAMI section below). The deploy push likewise uses -the GitHub deploy identity's `AcrPush` role, not the admin credential. +the GitHub deploy identity's `AcrPush` role, not the admin credential (`foundation.bicep:58-59`). [Rubric §11, Security] assesses elimination of long-lived credentials. Disabling the admin user removes the one static credential that would otherwise be needed for every pull, a concrete, verifiable hardening choice recorded directly in the Bicep. -### ACR scheduled purge task (`foundation.bicep:64-108`) +### ACR scheduled purge task (`foundation.bicep:64-110`) The registry has no garbage collection of its own at Basic tier: the retention policy feature is Premium-only (`foundation.bicep:67`). Every deploy pushes a `sha` tag plus `:latest` for six images -along with buildx cache layers, and nothing ever deleted any of them, so the ACR Data Stored meter -only ratcheted upward. The comment records the measured shape of that ratchet: $0.49/day climbing to -$0.69/day within nine days in 2026-08 (`foundation.bicep:69-70`). +along with buildx cache layers, and nothing deletes any of them, so the ACR Data Stored meter only +ratchets upward. The comment records the measured shape of that ratchet: $0.49/day climbing to +$0.69/day within nine days in 2026-08 (`foundation.bicep:68-70`). The answer is an ACR task rather than a workflow step: @@ -190,32 +190,37 @@ The answer is an ACR task rather than a workflow step: var acrPurgeTaskYaml = ''' version: v1.1.0 steps: - - cmd: acr purge --filter '.*:.*' --ago 30d --keep 10 --untagged + - cmd: acr purge --filter '.*:.*' --ago 3d --keep 3 --untagged disableWorkingDirectoryOverride: true timeout: 3600 ''' ``` -`acrPurgeTask` (`foundation.bicep:83-108`) is a `Microsoft.ContainerRegistry/registries/tasks` +`acrPurgeTask` (`foundation.bicep:85-110`) is a `Microsoft.ContainerRegistry/registries/tasks` child of the registry, `status: 'Enabled'`, running the YAML above as a base64 `EncodedTask` -(`foundation.bicep:95-98`) on a Linux/amd64 agent with a 3600-second timeout. Its single +(`foundation.bicep:97-100`) on a Linux/amd64 agent with a 3600-second timeout. Its single `timerTriggers` entry, `daily-0500-utc`, carries the cron expression `0 5 * * *` -(`foundation.bicep:99-105`), so it fires once a day at 05:00 UTC. +(`foundation.bicep:101-108`), so it fires once a day at 05:00 UTC. -Three details in that one command line carry the whole retention policy -(`foundation.bicep:78`): +Three flags on that one command line carry the whole retention policy (`foundation.bicep:80`): | Flag | Effect | Why | |---|---|---| | `--untagged` | deletes manifests with no tag at all | buildx cache layers and superseded `:latest` targets, pure waste the moment they are orphaned | -| `--ago 30d` | deletes tags not updated in 30 days | one month of deployed history is the retention window | -| `--keep 10` | keeps the 10 most recent tags per repository regardless of age | the rollback window survives a quiet month; a repo that has not been deployed to in 30 days still keeps ten images to roll back to | +| `--ago 3d` | deletes tags not updated in 3 days | three days of deployed history is the retention window | +| `--keep 3` | keeps the 3 most recent tags per repository regardless of age | rollback only ever reaches the previous revision, so three kept tags cover it even for a repository nobody has deployed to in a week | + +The window is that tight for a reason the template states as a measurement +(`foundation.bicep:71-74`): a wider 30-day / keep-10 window let the registry grow to about 300 GiB +against the 10 GiB the Basic tier includes (measured 2026-08-22), and every GiB above the included +allowance is billed as storage overage. Six images times two tags per deploy, plus a `mode=max` +buildx cache export per image, is a lot of manifest per merge. Two things make this credential-free, which is why it is a task and not another OIDC job in `deploy.yml`. `acr` in the step command is the registry's built-in task alias for `mcr.microsoft.com/acr/acr-cli`, and a scheduled task authenticates to its own home registry automatically, so no credential is configured anywhere in the resource -(`foundation.bicep:71-74`). +(`foundation.bicep:74-76`). [Rubric §31, Cost Efficiency / FinOps] assesses whether infrastructure cost is actively monitored, bounded, and governed. This is the storage end of that: the purge task bounds a monotonically @@ -223,11 +228,11 @@ growing meter that no alert would have caught (registry storage never fails, it every day), and it does so declaratively, in the same template that created the registry, with the retention window expressed as reviewable flags rather than as a habit somebody has to remember. -### Outputs (`foundation.bicep:113-115`) +### Outputs (`foundation.bicep:115-117`) `acrName`, `acrLoginServer`, and `logAnalyticsName` are the three values threaded from Phase 1 into Phase 2 (docker push target) and then into Phase 3 (`main.bicep` parameters). Because Phases 1 -to 3 are now separate jobs, they cross the job boundary as job outputs (`deploy.yml:759-762`) and +to 3 are separate jobs, they cross the job boundary as job outputs (`deploy.yml:759-762`) and are read as `needs.foundation.outputs.*`: see `deploy.yml:829` (`az acr login --name ${{ needs.foundation.outputs.acrName }}`), `deploy.yml:845-846` (the two image tags), and `deploy.yml:955-956` (the `acrName`/`logAnalyticsName` parameter assembly). @@ -236,16 +241,17 @@ image tags), and `deploy.yml:955-956` (the `acrName`/`logAnalyticsName` paramete ## Deployment parameters, assembled at deploy time, not committed -There is **no `infra/main.parameters.json` file** in the repository, the `infra/` directory holds only -`foundation.bicep`, `main.bicep`, `DISASTER-RECOVERY.md`, `OPERATIONS.md`, `SQL-MANAGED-IDENTITY.md`, -`POST-CUTOVER-atldevcon-downgrade.md`, and a `workbooks/` folder. The parameters fed to `main.bicep` -are built **from scratch at deploy time** by `deploy.yml`'s "Build deployment parameters file" step -(`deploy.yml:911-1068`), which writes `/tmp/deploy-params.json` with `jq`. +There is **no `infra/main.parameters.json` file** in the repository, the tracked `infra/` directory +holds only `foundation.bicep`, `main.bicep`, `DISASTER-RECOVERY.md`, `OPERATIONS.md`, +`SQL-MANAGED-IDENTITY.md`, `POST-CUTOVER-atldevcon-downgrade.md`, and a `workbooks/` folder. The +parameters fed to `main.bicep` are built **from scratch at deploy time** by `deploy.yml`'s "Build +deployment parameters file" step (`deploy.yml:911-1068`), which writes `/tmp/deploy-params.json` +with `jq`. How it works: - The step fails fast when the `ALERT_EMAIL` repository variable is empty (`deploy.yml:937-940`), - because `alertEmailAddress` is now a **required** `main.bicep` parameter with no default + because `alertEmailAddress` is a **required** `main.bicep` parameter with no default (`main.bicep:102-104`). An alert rule wired to no notification channel is a silent failure, so the deploy refuses to proceed with an actionable error rather than letting Bicep validation report it. - A base `jq -n` invocation (`deploy.yml:952-980`) emits the always-present parameters, `environmentName`, @@ -277,7 +283,7 @@ that exists only for the duration of the workflow run. `main.bicep` declares every application-layer Azure resource: Application Insights, the SLO scheduled query rules and their action group, two operational log alerts, a Gateway availability -web test and its alert, a saved SLO workbook (`main.bicep:530-542`), the monthly cost budget, SQL +web test and its alert, a saved SLO workbook (`main.bicep:541-553`), the monthly cost budget, SQL Server with five databases (the `AtlDevCon` archive plus the four per-service databases), Service Bus, an inert-by-default Notification Hub, the blob storage account with its two containers (public avatars and the private DataProtection key ring), an Azure Managed Redis @@ -339,7 +345,7 @@ Five boolean flags gate optional blocks throughout the template: post-login redirect target, so it must be injected whenever _any_ external provider is on rather than behind one of them. -Per-service SQL connection strings (`main.bicep:152-159`) are composed from a shared base: the SQL +Per-service SQL connection strings (`main.bicep:156-159`) are composed from a shared base: the SQL server FQDN plus one of two auth segments selected by `useManagedIdentitySql` (`main.bicep:152-154`). Each is a distinct string pointing at its own database (`ADC_Identity`, `ADC_Conference`, `ADC_Engagement`, `ADC_Notification`), making the database-per-service boundary explicit in the value @@ -348,7 +354,7 @@ that goes into Key Vault. The Service Bus connection string (`main.bicep:164`) is resolved via `listKeys()` against the `app-clients` SAS authorization rule (not `RootManageSharedAccessKey`) so a future migration to managed identity can revoke only the app rule without touching the namespace root. The Redis -connection string (`main.bicep:895`) is assembled the same way, from the instance hostname plus a +connection string (`main.bicep:906`) is assembled the same way, from the instance hostname plus a `listKeys()` primary key. ### Application Insights (`main.bicep:185-195`) @@ -378,7 +384,7 @@ var is what Azure Monitor maps to the Cloud Role Name, without it, all services framework automatically routes OpenTelemetry spans, logs, and metrics to Azure Monitor in production with no service-level code change. -Four more shared env entries ride along with the connection string on every app, and all of them +Five more shared env entries ride along with the connection string on every app, and all of them are cost controls on a pay-per-GB workspace: - `Telemetry__TracesSampleRatio: '0.25'` (`main.bicep:209-212`), head-based trace sampling that keeps @@ -388,8 +394,8 @@ are cost controls on a pay-per-GB workspace: OpenTelemetry logging provider ships to Azure Monitor. Serilog still writes Information to stdout (container logs), but only Warning and above bills against the workspace. The value is set explicitly because `OpenTelemetry` is the `ProviderAlias` of `OpenTelemetryLoggerProvider`, so the - key gates that provider only, and because the service hosts now register Serilog as one provider - alongside OpenTelemetry instead of calling `UseSerilog()`, which used to replace the + key gates that provider only, and because the service hosts register Serilog as one provider + alongside OpenTelemetry instead of calling `UseSerilog()`, which would replace the `ILoggerFactory` and drop every application log line before it could reach App Insights. - `Telemetry__DisableHttpClientMetrics: 'true'` (`main.bicep:231-234`) and `Telemetry__DisableRuntimeMetrics: 'true'` (`main.bicep:235-238`), which drop the two @@ -400,21 +406,34 @@ are cost controls on a pay-per-GB workspace: `http.server.request.duration` and the MMCA.Common meters carry the operational signal. Both keys are read by `MMCA.Common.Aspire`'s `ConfigureOpenTelemetry`, and the outbound-dependency latency the client metrics would have shown is still captured as (sampled) `AppDependencies` - traces, so this trims volume rather than visibility. Every one of the six apps gets the pair: - Identity (`main.bicep:1070-1071`), Conference (`:1263-1264`), Engagement (`:1383-1384`), - Notification (`:1522-1523`), Gateway (`:1662-1663`), UI (`:1768-1769`). + traces, so this trims volume rather than visibility. +- `OTEL_METRIC_EXPORT_INTERVAL: '300000'` (`main.bicep:246-249`) is the second stage of the same + cost control, and it works on cadence rather than on instrument selection. AppMetrics remained + about 63% of workspace ingestion after the two instrument groups above were dropped (measured + 2026-08-01 to 2026-08-22, `main.bicep:240-245`). The exporter ships **cumulative** aggregates, so + stretching the export interval from the SDK default of 60s to 300s drops roughly 80% of the + remaining datapoints without losing the signal: every alert rule in this template evaluates over a + 5-minute or 15-minute window, so a 5-minute export cadence still lands a datapoint per window. + This is the standard OpenTelemetry SDK env var, read by the periodic exporting metric reader + rather than by any MMCA.Common code. + +Every one of the six apps gets all five: Identity (`main.bicep:1079-1083`), Conference +(`:1278-1282`), Engagement (`:1403-1407`), Notification (`:1547-1551`), Gateway (`:1692-1696`), +UI (`:1799-1803`). They are declared once as Bicep variables and spliced into each `env` array by +name, which is what keeps a cost decision from being applied to five apps and forgotten on the +sixth. [Rubric §13, Observability & Operability] assesses whether the system ships distributed traces, structured logs, and metrics to a queryable backend. The workspace-based App Insights with per-service Cloud Role Names gives full Application Map visibility, end-to-end distributed traces across all six services, and Kusto-queryable logs, covering this category end-to-end. -### SLO alerts as code (`main.bicep:240-348`), [ADR-062](https://ivanball.github.io/docs/adr/062-slo-alerting-as-code.html) +### SLO alerts as code (`main.bicep:251-359`), [ADR-062](https://ivanball.github.io/docs/adr/062-slo-alerting-as-code.html) The three SLOs are declared as **data**: an array of records named `sloAlertSpecs` -(`main.bicep:276-304`) carrying `key`, `description`, `query`, `timeAggregation`, +(`main.bicep:287-315`) carrying `key`, `description`, `query`, `timeAggregation`, `metricMeasureColumn`, `threshold` and `severity`. A Bicep `for` loop materializes one Log Analytics -`Microsoft.Insights/scheduledQueryRules` per spec (`main.bicep:306-348`): +`Microsoft.Insights/scheduledQueryRules` per spec (`main.bicep:317-359`): | Alert key | KQL source | Threshold | Window | Severity | |---|---|---|---|---| @@ -425,60 +444,62 @@ The three SLOs are declared as **data**: an array of records named `sloAlertSpec **The KQL predicate is the whole point of the migration.** These rules replaced metric alerts on `requests/failed`, `requests/duration` and `dependencies/failed`, which paged on routine traffic because a metric alert cannot express a status-code or URL predicate. The template records the two -real incidents (`main.bicep:262-275`): one window held 8x401 plus 2x499 plus a single readiness 503 +real incidents (`main.bicep:273-286`): one window held 8x401 plus 2x499 plus a single readiness 503 and zero other failures, all from one browser session retrying with an expired token, and five long-lived SignalR hub connections averaging 11.3s dragged the fleet-wide average to 5539ms against a 3000ms threshold while every real request was fast. A hub connection reports its **connection lifetime** as request duration. The thresholds and severities are unchanged, so this is a precision fix, not a sensitivity cut: a genuine 400 or 500 burst still pages at the same numbers. -The `union(...)` in the criteria (`main.bicep:328-340`) supplies `metricMeasureColumn` only for the +The `union(...)` in the criteria (`main.bicep:339-351`) supplies `metricMeasureColumn` only for the aggregate rule. Omitting it (the empty-string case) makes a rule count returned **rows**, which is what the two failure-count SLOs want. `evaluationFrequency: 'PT5M'` over `windowSize: 'PT15M'` with -`autoMitigate: true` (`main.bicep:320-322`) means each rule re-evaluates every five minutes against +`autoMitigate: true` (`main.bicep:331-333`) means each rule re-evaluates every five minutes against a 15-minute rolling window and auto-resolves when the signal returns below threshold. -**The superseded metric alerts are still declared, and disabled in place** (`main.bicep:350-392`). -`legacySloMetricAlertSpecs` (`main.bicep:357-361`) still materializes the three `metricAlerts` under -their **original, unsuffixed** names (`main.bicep:365`) with `enabled: false` and an empty `actions` -array (`main.bicep:370`, `:389`). This is the incremental-ARM consequence made explicit: a resource +**The superseded metric alerts are still declared, and disabled in place** (`main.bicep:361-403`). +`legacySloMetricAlertSpecs` (`main.bicep:368-372`) still materializes the three `metricAlerts` under +their **original, unsuffixed** names (`main.bicep:376`) with `enabled: false` and an empty `actions` +array (`main.bicep:381`, `:400`). This is the incremental-ARM consequence made explicit: a resource that simply leaves the template is never deleted from the resource group, so dropping them would have left three live rules firing alongside the new ones. Disabling them declaratively needs no portal step and rolls back in one line. It is also why the replacements carry a `-v2` suffix -(`main.bicep:308-311`): reusing the name would have renamed the live originals instead of disabling +(`main.bicep:319-322`): reusing the name would have renamed the live originals instead of disabling them. -The action group (`main.bicep:246-260`) has an **unconditional** email receiver, which is the direct +The action group (`main.bicep:257-271`) has an **unconditional** email receiver, which is the direct consequence of `alertEmailAddress` being a required parameter. Every scheduled query rule routes to -it (`main.bicep:344`) and so does the cost budget (`main.bicep:567`, `:575`). One group, one +it (`main.bicep:355`) and so does the cost budget (`main.bicep:577`, `:585`). One group, one receiver, no severity routing: severity is triage metadata, not a delivery decision. Each SLO alert is paired with a same-severity triage section in `MMCA.ADC/infra/OPERATIONS.md` (`OPERATIONS.md:15`, `:29`, `:42`), and that pairing is enforced by a framework fitness test rather than by discipline: `ObservabilityConventionTestsBase` parses this template between the literal -anchors `var sloAlertSpecs` and `resource sloAlerts` and fails the build in both directions. That -gate is covered in [group 27](group-27-testing-infrastructure.md#observabilityconventiontestsbase); -it is not duplicated here. Note the coverage boundary: only alerts inside that parse window are -gated, so the two operational rules and the availability alert below are provisioned but ungated. +anchors `var sloAlertSpecs` and `resource sloAlerts` +(`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/ObservabilityConventionTestsBase.cs:109-110`) +and fails the build in both directions. That gate is covered in +[group 27](group-27-testing-infrastructure.md#observabilityconventiontestsbase); it is not +duplicated here. Note the coverage boundary: only alerts inside that parse window are gated, so the +two operational rules and the availability alert below are provisioned but ungated. -### Operational and availability alerts (`main.bicep:394-520`) +### Operational and availability alerts (`main.bicep:405-531`) Beyond the three SLOs, `main.bicep` provisions two more scheduled query rules from -`scheduledQueryAlertSpecs` (`main.bicep:407-420`, materialized at `:422-454`), both severity 2 on a +`scheduledQueryAlertSpecs` (`main.bicep:418-431`, materialized at `:433-465`), both severity 2 on a 15-minute evaluation over a 15-minute window: -- `outbox-dead-letter` (`main.bicep:408-413`) fires on **any** hit (`threshold: 0`) of an `AppTraces` +- `outbox-dead-letter` (`main.bicep:419-424`) fires on **any** hit (`threshold: 0`) of an `AppTraces` row at Error or above whose message contains `dead-lettered`. An outbox message that exhausted its retries means an integration event was permanently lost. The row-age signal is DB-side and not queryable from Log Analytics, so this Error line _is_ the backlog alarm. -- `sql-dependency-failures` (`main.bicep:414-419`) fires above 10 failed SQL dependency calls. Every +- `sql-dependency-failures` (`main.bicep:425-430`) fires above 10 failed SQL dependency calls. Every service owns exactly one database, so a burst here means a service cannot reach its own DB, which also stalls its outbox drain. An outside-in availability signal sits alongside them: a standard URL-ping web test -(`main.bicep:463-494`) probes the public Gateway `/health` every 300 seconds from three Azure +(`main.bicep:474-505`) probes the public Gateway `/health` every 300 seconds from three Azure locations (East US, North Central US, South Central US), bound to the App Insights component via a -`hidden-link` tag. Its severity **1** alert (`main.bicep:496-520`) fires on a `failedLocationCount` +`hidden-link` tag. Its severity **1** alert (`main.bicep:507-531`) fires on a `failedLocationCount` of 2, so a single-location blip does not page. [Rubric §29, Resilience, Reliability & Business Continuity] assesses whether the system can detect @@ -487,15 +508,15 @@ the sev-1 availability alert all route to the same action group as the cost budg on-call operator an automated signal for error rate, latency, dependency failures, permanent event loss, database reachability, and total entry-point outage. -### SLO workbook (`main.bicep:522-542`) +### SLO workbook (`main.bicep:533-553`) A saved Azure Monitor workbook renders the same three SLO signals plus exceptions, grouped per service by `AppRoleName` (which is the `OTEL_SERVICE_NAME` value). It is bound to the Log Analytics workspace and embeds `workbooks/adc-slo-workbook.json` at **compile time** via `loadTextContent` -(`main.bicep:539`), so the visualization cannot diverge from the alerts by being maintained +(`main.bicep:550`), so the visualization cannot diverge from the alerts by being maintained somewhere else, and the JSON stays independently validatable as a file. -### Cost budget (`main.bicep:544-579`) +### Cost budget (`main.bicep:555-590`) ```bicep resource costBudget 'Microsoft.Consumption/budgets@2023-11-01' = if (enableBudget) { @@ -529,14 +550,14 @@ constraint directly so future operators don't hit the ARM error. [Rubric §31, Cost Efficiency / FinOps] assesses whether infrastructure cost is actively monitored, bounded, and governed. The budget resource, the `enableBudget` escape hatch, the workspace daily ingestion cap, the 25% trace sampling, the Warning log floor, the two -metric-group disables, the daily ACR purge task, and the `commonTags` -applied to every billable resource (`main.bicep:138-144`) together satisfy this category: tags -enable cost attribution; the caps bound runaway spend at the telemetry, storage and compute ends; -and the budget threshold notifications make the cap actionable. +metric-group disables, the 300-second metric export interval, the daily ACR purge task, and the +`commonTags` applied to every billable resource (`main.bicep:138-144`) together satisfy this +category: tags enable cost attribution; the caps bound runaway spend at the telemetry, storage and +compute ends; and the budget threshold notifications make the cap actionable. -### SQL Server and databases (`main.bicep:581-694`) +### SQL Server and databases (`main.bicep:592-705`) -**SQL Server** (`main.bicep:584-595`): +**SQL Server** (`main.bicep:595-606`): ``` name: '${prefix}-sql-${resourceToken}' version: '12.0' @@ -544,31 +565,31 @@ minimalTlsVersion: '1.2' publicNetworkAccess: 'Enabled' ``` -`publicNetworkAccess: 'Enabled'` (`main.bicep:593`) combined with the firewall rule -`AllowAzureServices` (`main.bicep:597-604`, startIpAddress/endIpAddress both `0.0.0.0`) is the +`publicNetworkAccess: 'Enabled'` (`main.bicep:604`) combined with the firewall rule +`AllowAzureServices` (`main.bicep:608-615`, startIpAddress/endIpAddress both `0.0.0.0`) is the Azure-standard pattern for allowing Container Apps to reach SQL without a VNet/private endpoint. The `0.0.0.0-0.0.0.0` rule does not allow traffic from arbitrary internet IPs; it enables the -special "allow Azure services" flag. `minimalTlsVersion: '1.2'` (`main.bicep:592`) ensures all +special "allow Azure services" flag. `minimalTlsVersion: '1.2'` (`main.bicep:603`) ensures all connections are encrypted at TLS 1.2 minimum. -**Entra (Azure AD) admin** (`main.bicep:612-621`), provisioned only when `sqlAadAdminObjectId` is +**Entra (Azure AD) admin** (`main.bicep:623-632`), provisioned only when `sqlAadAdminObjectId` is supplied. It is deliberately **additive**: it enables Entra auth alongside the SQL admin login and does **not** set `azureADOnlyAuthentication`, so password auth keeps working throughout the -transition. Its purpose is to let an operator run the per-database +transition (`main.bicep:617-622`). Its purpose is to let an operator run the per-database `CREATE USER [adc-prod-apps-identity] FROM EXTERNAL PROVIDER` grants that managed-identity app auth depends on. Full sequencing lives in `infra/SQL-MANAGED-IDENTITY.md`; the staged model is described in the Key Vault section below. -**Legacy `AtlDevCon` database** (`main.bicep:629-643`): +**Legacy `AtlDevCon` database** (`main.bicep:640-654`): Retained at Basic tier (5 DTU, 2 GB cap) as a read-only archive and rollback source after the database-per-service cutover, downgraded from S0 to minimise cost on an idle archive. Its Bicep resource declaration prevents out-of-band drift, even though Incremental mode would not delete it anyway, having it declared makes the "never touch this" intent explicit and prevents ARM complaining -about an undeclared resource. The comment at `main.bicep:623-628` is the canonical explanation: the +about an undeclared resource. The comment at `main.bicep:634-639` is the canonical explanation: the data (~34 MB) was fully copied into the per-service databases; this is the archive, not the live store. -**Per-service databases** (`main.bicep:654-677`), `[Rubric §8, Data Architecture]`: +**Per-service databases** (`main.bicep:665-688`), `[Rubric §8, Data Architecture]`: ```bicep var serviceDatabaseNames = [ @@ -599,7 +620,7 @@ cheapest expression of full data autonomy: each service has an independent schem migrations, independent outbox, and can be moved to its own server later without application changes. -**Long-term backup retention (LTR)** (`main.bicep:683-694`): +**Long-term backup retention (LTR)** (`main.bicep:694-705`): ```bicep resource serviceDatabaseLtr '…/backupLongTermRetentionPolicies@…' = [ @@ -615,10 +636,10 @@ resource serviceDatabaseLtr '…/backupLongTermRetentionPolicies@…' = [ ``` Basic tier already provides 7-day PITR (point-in-time recovery) with geo-redundant backups; LTR -adds weekly (4-week), monthly (12-month), and yearly (1-year) archival on top. The practical -value: a corrupted migration or a data-loss bug discovered three weeks after the fact is still -recoverable. The `AtlDevCon` archive is intentionally excluded from LTR, it is a static archive, -not a live store. +adds weekly (4-week), monthly (12-month), and yearly (1-year) archival on top (`main.bicep:690-693`). +The practical value: a corrupted migration or a data-loss bug discovered three weeks after the fact +is still recoverable. The `AtlDevCon` archive is intentionally excluded from LTR, it is a static +archive, not a live store. [Rubric §29, Resilience, Reliability & Business Continuity] extends to data recovery. LTR on the live per-service databases means every production restore scenario, bad migration, silent @@ -626,28 +647,28 @@ corruption, regulatory request for historical data, has a recovery path beyond t window. The disaster-recovery runbook at `MMCA.ADC/infra/DISASTER-RECOVERY.md` documents the drilled restore procedure ([ADR-009](https://ivanball.github.io/docs/adr/009-resilience-and-recovery-objectives.html)). -### Azure Service Bus (`main.bicep:696-738`) +### Azure Service Bus (`main.bicep:707-749`) ``` sku: Standard // Basic rejected: MassTransit requires topics, Basic supports queues only minimumTlsVersion: '1.2' ``` -The Standard tier comment at `main.bicep:704-708` is the explanation of a constraint that has -bitten the project before (it was absent in early production and is now documented in the memory -note `project_adc_no_broker_in_azure.md`): MassTransit's `UsingAzureServiceBus` auto-provisions +The Standard tier comment at `main.bicep:715-719` is the explanation of a constraint that has +bitten the project before: MassTransit's `UsingAzureServiceBus` auto-provisions one topic per message type and one subscription per consumer, Basic tier has no topics, only queues, so it silently fails at MassTransit startup. Standard tier costs a flat ~$10/month base for the namespace plus per-million-operations, and the link/unlink flows are far below 1k messages a month even at conference scale. -The `app-clients` authorization rule (`main.bicep:728-738`) grants `Send + Listen + Manage` rights. +The `app-clients` authorization rule (`main.bicep:739-749`) grants `Send + Listen + Manage` rights. The `Manage` right is required so MassTransit can `ConfigureEndpoints`, auto-provision topics -and subscriptions at startup. The alternative (declaring every topic in Bicep) would be brittle +and subscriptions at startup, and without it the first publish fails with an Unauthorized topology +error (`main.bicep:733-738`). The alternative (declaring every topic in Bicep) would be brittle as new integration events are added, because it would require a Bicep change for every new event type. -Current integration event flows wired over Service Bus (documented at `main.bicep:699-702`): +Current integration event flows wired over Service Bus (documented at `main.bicep:710-713`): - Identity publishes `UserRegistered` → Conference `UserRegisteredHandler` auto-links a speaker by email match (BR-207). - Conference publishes `SpeakerLinkedToUser` / `SpeakerUnlinkedFromUser` → Identity updates @@ -657,10 +678,10 @@ These events cross service boundaries asynchronously via the outbox + MassTransi namespace is the transport that carries them in production (RabbitMQ fills the same role locally). All four services receive `MessageBus__Provider` and `MessageBus__ConnectionString`, but only Identity and Conference call `AddBrokerMessaging` today: the Engagement and Notification entries are -pre-provisioned forward-compatible wiring, and the template says so (`main.bicep:1414-1418`, -`:1551-1554`), so adding a consumer later is a `Program.cs` change with no infra redeploy. +pre-provisioned forward-compatible wiring, and the template says so (`main.bicep:1441-1445`, +`:1583-1586`), so adding a consumer later is a `Program.cs` change with no infra redeploy. -### Azure Notification Hub (`main.bicep:740-778`), inert by default +### Azure Notification Hub (`main.bicep:751-789`), inert by default The [ADR-044](https://ivanball.github.io/docs/adr/044-native-push-delivery.html) native-push fan-out (FCM v1 and APNs) is declared but **not deployed**: the namespace, @@ -672,39 +693,39 @@ blocking every application deploy for an inert-by-design resource. Even with the namespace deployed, delivery stays off: `nativePushEnabled` (`main.bicep:107`) is a separate default-`false` parameter, and the Notification app's `NativePush__Enabled` env var is only -injected at all when the hub exists (`main.bicep:1571-1575`). Turning it on is a two-step runbook +injected at all when the hub exists (`main.bicep:1600-1607`). Turning it on is a two-step runbook operation, upload the platform credentials in the portal, then redeploy with the flags flipped. The hub's Free tier covers 500 devices and 1M pushes per month, far above conference volumes. -### Blob storage: avatars and the DataProtection key ring (`main.bicep:780-848`), [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-storage-and-avatars.html) +### Blob storage: avatars and the DataProtection key ring (`main.bicep:791-859`), [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-storage-and-avatars.html) -One `Standard_LRS` StorageV2 account (`main.bicep:788-801`) carries two containers on the same +One `Standard_LRS` StorageV2 account (`main.bicep:799-812`) carries two containers on the same `default` blob service. The first is the public-read `avatars` container -(`main.bicep:808-814`). Public read is deliberate: avatar URLs render in `` tags on +(`main.bicep:819-825`). Public read is deliberate: avatar URLs render in `` tags on anonymous-visible surfaces with no SAS plumbing, and blob names carry a random suffix so they are not enumerable. The account sets `minimumTlsVersion: 'TLS1_2'` and `supportsHttpsTrafficOnly: true`. -The second is `dataProtectionKeysContainer` (`main.bicep:821-827`), named `dataprotection-keys` and +The second is `dataProtectionKeysContainer` (`main.bicep:832-838`), named `dataprotection-keys` and explicitly `publicAccess: 'None'`. It holds the shared ASP.NET Core DataProtection key ring for the two apps that mint cookies (Identity and UI), and its privacy is the whole point of declaring it separately rather than reusing `avatars`: a key ring readable anonymously would hand out the keys that protect every auth cookie and antiforgery token in the system. The comment above it -(`main.bicep:816-820`) states the failure it prevents: both apps run at `maxReplicas: 2`, and the +(`main.bicep:827-831`) states the failure it prevents: both apps run at `maxReplicas: 2`, and the default in-memory key ring is per replica, so a token minted by one replica is undecryptable by the other. The per-app wiring is in the Identity and UI subsections below. The Identity service authenticates to it with `DefaultAzureCredential` resolving the shared apps identity, so there is no connection-string secret. Control-plane ownership of the account does not grant blob writes, though: the `Storage Blob Data Contributor` data-plane assignment -(`main.bicep:840-848`) is what does, and it is guarded by `grantAvatarStorageRole`, default `false`, +(`main.bicep:851-859`) is what does, and it is guarded by `grantAvatarStorageRole`, default `false`, for exactly the same reason as the Key Vault grants. Until an operator applies it once by hand, avatar uploads fail cleanly with `FileStorage.UploadFailed` and everything else deploys. That one assignment is scoped to the storage **account**, not to a container, so it also covers the key-ring container: the shared key ring needs no second role assignment, and the template says so -(`main.bicep:834-835`). +(`main.bicep:845-846`). [Rubric §11, Security] assesses credential and key handling. One follow-up is recorded in the -template as **not implemented** (`main.bicep:836-839`): encrypting the key ring at rest with a Key +template as **not implemented** (`main.bicep:847-850`): encrypting the key ring at rest with a Key Vault key (`DataProtection__KeyVaultKeyUri`) would need a separate Key Vault Crypto User grant on the apps identity, and neither the env var nor the grant exists today. The comment states the reason blob persistence deliberately works without it: a missing or delayed crypto grant would @@ -714,16 +735,16 @@ step behind its own gate on `DataProtection:KeyVaultKeyUri` (`MMCA.Common/Source/Hosting/MMCA.Common.Aspire/DataProtection/DataProtectionExtensions.cs:74-85`), so the infrastructure gap and the code path agree. -### Azure Managed Redis (`main.bicep:850-895`) +### Azure Managed Redis (`main.bicep:861-906`) One shared `Microsoft.Cache/redisEnterprise` instance at the `Balanced_B0` SKU (1 GB, HA disabled, around $13/month) with a single `default` database on port 10000, encrypted client protocol, `OSSCluster` clustering, `VolatileLRU` eviction and both persistence modes off -(`main.bicep:864-892`). Volatile-only eviction is deliberate: cache entries and idempotency records -carry TTLs, and a key without a TTL must never be silently evicted. +(`main.bicep:875-903`). Volatile-only eviction is deliberate: cache entries and idempotency records +carry TTLs, and a key without a TTL must never be silently evicted (`main.bicep:895-896`). Every service gets `ConnectionStrings__redis` from the vault, and three consumers activate on that -key alone with no application change: +key alone with no application change (`main.bicep:864-872`): 1. `ICacheService` upgrades from a per-replica `MemoryCache` to `DistributedCacheService`, which makes the `IdempotencyFilter`'s 24h replay records cross-replica (with `maxReplicas: 2` a @@ -733,7 +754,7 @@ key alone with no application change: 3. The Notification SignalR backplane auto-wires when the key appears, via `MMCA.Common.Infrastructure`'s `AddPushNotifications`. -### Container Apps environment (`main.bicep:897-913`) +### Container Apps environment (`main.bicep:908-924`) ```bicep resource containerAppEnv '…/managedEnvironments@2024-03-01' = { @@ -755,7 +776,7 @@ internal DNS resolution. An app can reach another by its Container App name (e.g `http://adc-prod-identity`) because the ACA environment's internal DNS resolves Container App names as hostnames within the environment. -### UAMI and ACR credential model (`main.bicep:915-928`) +### UAMI and ACR credential model (`main.bicep:926-939`) [Rubric §11, Security] assesses credential handling as one of its primary axes. @@ -772,10 +793,10 @@ var acrRegistry = { `appsIdentity` is a User-Assigned Managed Identity (UAMI) bootstrapped out-of-band (one-time admin operation) with `AcrPull` on the registry and `Key Vault Secrets User` on the vault. The Bicep -template only *references* it (`existing` keyword), not creates it, because the deploy identity -(also a UAMI, used by GitHub Actions via OIDC) has `Contributor` but not `Microsoft.Authorization/ -roleAssignments/write`, creating role assignments requires elevated permissions deliberately -withheld from the CI identity. +template only *references* it (`existing` keyword, `main.bicep:931-933`), not creates it, because +the deploy identity (also a UAMI, used by GitHub Actions via OIDC) has `Contributor` but not +`Microsoft.Authorization/roleAssignments/write` (`main.bicep:926-930`), creating role assignments +requires elevated permissions deliberately withheld from the CI identity. Every container app resource declares the same identity: @@ -812,9 +833,10 @@ One non-obvious constraint: `environment: production` on a job is **required for itself**, not just for approval gates. The federated identity credential's subject is `repo:ivanball/ADC:environment:production`, so a job without it presents `repo:ivanball/ADC:ref:refs/heads/main` instead and `azure/login` fails with AADSTS700213 -(`deploy.yml:752-757`). Every job that runs `azure/login` therefore declares it. +(`deploy.yml:752-757`). Every job that runs `azure/login` therefore declares it, including +`cost-guard.yml`'s read-only surge check (`cost-guard.yml:31-32`). -### Key Vault and runtime secrets (`main.bicep:930-1013`), [ADR-061](https://ivanball.github.io/docs/adr/061-runtime-secret-management.html) +### Key Vault and runtime secrets (`main.bicep:941-1024`), [ADR-061](https://ivanball.github.io/docs/adr/061-runtime-secret-management.html) ```bicep resource keyVault '…/vaults@…' existing = { @@ -824,8 +846,8 @@ resource keyVault '…/vaults@…' existing = { **Every production secret lives in Key Vault and reaches a Container App as a reference, never as a value.** Key Vault is bootstrapped out-of-band like the identity: the template declares it `existing` -(`main.bicep:940-942`) and then writes fourteen secret child resources into it -(`main.bicep:944-1013`). Each Container App references them by Key Vault URI through the shared UAMI: +(`main.bicep:951-953`) and then writes fourteen secret child resources into it +(`main.bicep:955-1024`). Each Container App references them by Key Vault URI through the shared UAMI: ```bicep secrets: [ @@ -841,17 +863,17 @@ secrets: [ This is the `keyVaultUrl` + `identity` pattern in ACA (Container Apps Secrets backed by Key Vault): the secret value never appears in the Container App definition, the ARM deployment history, or deployment logs. Not one `secrets` entry in this template carries an inline `value`. Containers then -consume them only through `secretRef` (for example `main.bicep:1077`, `:1099`, `:1125`, `:1268`, -`:1574`). At runtime ACA fetches the current secret version via the UAMI's Key Vault Secrets User +consume them only through `secretRef` (for example `main.bicep:1089`, `:1111`, `:1137`, `:1286`, +`:1573`). At runtime ACA fetches the current secret version via the UAMI's Key Vault Secrets User role, meaning a secret rotation only requires updating the Key Vault secret, no Bicep re-deployment, no app restart. -Secrets stored in Key Vault (`main.bicep:944-1013`): +Secrets stored in Key Vault (`main.bicep:955-1024`): - Per-service SQL connection strings (4): `identity-sql-connection-string`, `conference-sql-connection-string`, `engagement-sql-connection-string`, `notification-sql-connection-string` - `service-bus-connection-string`, `redis-connection-string` -- `notification-hub-connection-string` (only when `deployNotificationHub` is true, `main.bicep:969`) +- `notification-hub-connection-string` (only when `deployNotificationHub` is true, `main.bicep:980`) - `rsa-private-key-pem`, `rsa-public-key-pem` (or `'unused'` placeholder when not supplied) - `jwt-secret-key` (HS256 fallback, or `'unused'`) - `smtp-password`, `github-oauth-client-secret`, `google-oauth-client-secret`, `anthropic-api-key` @@ -868,14 +890,14 @@ code. The cost is that the vault is a poor inventory: an `unused` secret is indi configured one, and only an app's `secrets` list says which credentials are actually live. **The two apps that need no credential say so explicitly.** Gateway and UI declare `secrets: []` -(`main.bicep:1649`, `:1754`) rather than omitting the property: a pure YARP proxy and a Blazor host +(`main.bicep:1681`, `:1787`) rather than omitting the property: a pure YARP proxy and a Blazor host that talks only to the Gateway hold nothing worth stealing, and stating it makes that a reviewable fact rather than an omission. **Both role assignments are bootstrapped out of band, deliberately.** The deploy identity holds Key Vault Secrets Officer to write the values; the apps hold Key Vault Secrets User to read them; the vault and both grants are created outside the template because the deploy principal has Contributor -without `Microsoft.Authorization/roleAssignments/write` (`main.bicep:933-936`). A template that +without `Microsoft.Authorization/roleAssignments/write` (`main.bicep:944-947`). A template that created its own role assignments would need exactly the permission the deployment deliberately does not have. The trade-off is stated in the ADR: one shared identity means any app carrying it can read **every** secret in the vault, not only the ones its own `secrets` list names, and the template @@ -883,20 +905,20 @@ cannot report that a grant is missing. **The same grant also backs a second, different consumption path.** Alongside the platform-resolved `keyVaultUrl` secret references above, five of the six apps receive `KeyVault__Uri` -(`main.bicep:1144` Identity, `:1299` Conference, `:1425` Engagement, `:1566` Notification, `:1785` +(`main.bicep:1156` Identity, `:1321` Conference, `:1452` Engagement, `:1598` Notification, `:1819` UI), which turns the vault into an ASP.NET Core **configuration source**: `MMCA.Common`'s `AddCommonKeyVaultConfiguration` is a no-op without the key, and with it the host reads the vault synchronously at startup through `DefaultAzureCredential`. The Gateway is deliberately not in that -list (`main.bicep:937-939`): it holds no secret at all, so there is nothing for it to read. The two +list (`main.bicep:948-950`): it holds no secret at all, so there is nothing for it to read. The two paths differ in who resolves the value: the platform does it for `secretRef` entries, the host process does it for the configuration source, and both authenticate as the same `appsIdentity` that already holds Key Vault Secrets User. Secret names use a double dash for the configuration separator, so the existing single-dash secrets arrive as flat keys and shadow nothing the container -already sets. +already sets (`main.bicep:1149-1155`). -That startup read is why `AZURE_CLIENT_ID` is now on Conference, Engagement, Notification and the -UI (`main.bicep:1298`, `:1424`, `:1565`, `:1782`) and no longer only on Identity, where it was -introduced for avatar blob access (`:1136`). Each app carries only the user-assigned identity, and +That startup read is why `AZURE_CLIENT_ID` is on Conference, Engagement, Notification and the +UI (`main.bicep:1320`, `:1451`, `:1597`, `:1816`) and not only on Identity, where it was +introduced for avatar blob access (`:1148`). Each app carries only the user-assigned identity, and the ACA identity endpoint needs that identity **named**, so without the pin `DefaultAzureCredential` fails the startup vault read rather than falling back. @@ -911,9 +933,8 @@ repository variable: `deploy.yml:932` reads `vars.USE_MANAGED_IDENTITY_SQL`, and `deploy.yml:1065-1068` rewrites the parameter to `true` when it is set. In `ivanball/ADC` that variable is `true` (set 2026-06-28, alongside `SQL_AAD_ADMIN_LOGIN` and `SQL_AAD_ADMIN_OID`), so the running apps authenticate passwordlessly and the shared SQL password is no longer on the app path. -The ADC scorecard records the same activation on that date as the change that lifted §17 DevOps -Implementation from 8 to 9. The -migration ran in three stages, all driven by repository variables that are absent by default: +The runbook states the same as an operational fact (`OPERATIONS.md:46`). The +migration runs in three stages, all driven by repository variables that are absent by default: supply the Entra admin (`deploy.yml:1054-1061`), run the per-database external-provider grants by hand, then set `USE_MANAGED_IDENTITY_SQL=true` (`deploy.yml:1065-1068`). Because the Entra admin is additive and the flag defaults off, stage 1 changes nothing observable and a bad flip rolls back by @@ -921,13 +942,14 @@ the same one parameter. Whether a given deployment has already set that variable from source. **Where the other repos stand.** MMCA.Store implements the identical Key Vault model with its own -identity (`mmca-prod-apps-identity`) and eleven vault secrets. MMCA.Common ships the shape as a -compile-only reference sample under `samples/deployment/`, not a deployment: it creates an -RBAC-authorized vault and attaches the identity for both ACR pull and secret reads, but declares no -`secrets` entry for the `secretRef` it uses and writes no secret into the vault it creates, and CI -only type-checks it. MMCA.Helpdesk has no `infra/` directory and no deploy workflow at all (its -`.github/workflows/` holds `ci.yml` plus the two Claude workflows), so there is nothing there to -adopt. +identity (`mmca-prod-apps-identity`, `MMCA.Store/infra/main.bicep:25`) and eleven vault secrets. +MMCA.Common ships the shape as a compile-only reference sample under `samples/deployment/`, not a +deployment: it creates an RBAC-authorized vault (`MMCA.Common/samples/deployment/main.bicep:67`) and +attaches the identity for both ACR pull and secret reads, but declares no `secrets` entry for the +`secretRef` it uses (`:143`) and writes no secret into the vault it creates, and CI only +type-checks it. MMCA.Helpdesk has no `infra/` directory and no deploy workflow at all (its +`.github/workflows/` holds `ci.yml`, `release-templates.yml`, and the two Claude workflows), so +there is nothing there to adopt. ### Container Apps, the six deployables @@ -936,47 +958,50 @@ patterns but differ in ingress transport, probe style, and environment variables #### Common structural patterns -All six apps (`main.bicep:1015-1839`) share: +All six apps (`main.bicep:1029-1874`) share: - `identity: { type: 'UserAssigned', userAssignedIdentities: { '${appsIdentity.id}': {} } }`, the - same shared UAMI on every app (`main.bicep:1022-1027`, `:1223`, `:1347`, `:1471`, `:1632`, `:1734`). -- `activeRevisionsMode: 'Single'`, one active revision at a time; new deploys create a new - revision and traffic flips atomically rather than gradually. This matches `deploy.yml`'s post- - deploy smoke-test gate, which checks the new revision before marking the deploy green. + same shared UAMI on every app (`main.bicep:1033-1038`, `:1240`, `:1369`, `:1498`, `:1664`, `:1767`). +- `activeRevisionsMode: 'Single'` (`main.bicep:1042`, `:1249`, `:1378`, `:1507`, `:1673`, `:1776`), + one active revision at a time; new deploys create a new revision and traffic flips atomically + rather than gradually. This matches `deploy.yml`'s post-deploy smoke-test gate, which checks the + new revision before marking the deploy green. - `scale: { minReplicas: 1, maxReplicas: 2, rules: [{ name: 'http-scale', http: { metadata: { concurrentRequests: '50' } } }] }`, `minReplicas: 1` prevents scale-to-zero (which would destroy Blazor Server circuits and outbox in-flight messages); HTTP scale-out at 50 concurrent requests gives the headroom needed for a conference-day load (historically ~67 peak concurrent). **Notification is the exception**: its - `maxReplicas` is **1** (`main.bicep:1616`), a deliberate right-sizing at that measured peak. The - Redis backplane that would make a second replica safe for hub fan-out _is_ now wired, so the cap - is a cost choice rather than a correctness one (`main.bicep:1611-1615`); raising it wants a + `maxReplicas` is **1** (`main.bicep:1648`), a deliberate right-sizing at that measured peak. The + Redis backplane that would make a second replica safe for hub fan-out _is_ wired, so the cap + is a cost choice rather than a correctness one (`main.bicep:1643-1647`); raising it wants a verified two-replica fan-out test first. - `ASPNETCORE_ENVIRONMENT: 'Production'`, switches ASP.NET Core to the production configuration, which among other things disables the OpenAPI endpoint (it is only mapped outside Production per the ADC CLAUDE.md). -- `ApplicationSettings__DatabaseInitStrategy: 'Migrate'`, each service auto-applies its own - database's pending migrations at startup as the **sole migrator**. `deploy.yml` deliberately has *no* +- `ApplicationSettings__DatabaseInitStrategy: 'Migrate'` on the four database-owning services + (`main.bicep:1118`, `:1307`, `:1431`, `:1579`), each service auto-applies its own database's + pending migrations at startup as the **sole migrator**. `deploy.yml` deliberately has *no* separate `sqlcmd` migration step (a backstop would race the container's startup `Migrate()`); with `minReplicas: 1` exactly one replica migrates before the revision serves (`deploy.yml:1078-1088`). - The build-time EF model-drift gate (`deploy.yml:262-276`) still guarantees a migration exists for + The build-time EF model-drift gate (`deploy.yml:262-277`) still guarantees a migration exists for every model change, across all four migrations projects. -- `Outbox__PollingIntervalSeconds: '300'`, the outbox signal + smart wait in MMCA.Common ≥ 1.50.0 - delivers real messages in ~5 seconds regardless of the poll interval; the 300-second poll only - governs idle polling. This cuts App Insights SQL dependency telemetry that would otherwise flood - the workspace around the clock (the `OutboxPollFilterProcessor` suppresses the poll spans from - App Insights per the memory note `project_outbox_cost_optimization.md`). -- `Outbox__DeadLetterRetentionDays: '30'` on the four database-owning services (`main.bicep:1086`, - `:1274`, `:1394`, `:1539`; Gateway and UI own no database and therefore no outbox). A +- `Outbox__PollingIntervalSeconds: '300'` (`main.bicep:1102`, `:1294`, `:1419`, `:1569`), the outbox + signal + smart wait in MMCA.Common ≥ 1.50.0 delivers real messages in ~5 seconds regardless of the + poll interval; the 300-second poll only governs idle polling. This cuts App Insights SQL dependency + telemetry that would otherwise flood the workspace around the clock (the + `OutboxPollFilterProcessor` suppresses the poll spans from App Insights per the memory note + `project_outbox_cost_optimization.md`). +- `Outbox__DeadLetterRetentionDays: '30'` on the four database-owning services (`main.bicep:1098`, + `:1292`, `:1417`, `:1567`; Gateway and UI own no database and therefore no outbox). A dead-lettered row (retries exhausted, never delivered) keeps `ProcessedOn` null forever, so the processed-row sweep never reaches it and it stays in the pending index that every poll re-scans. `OutboxCleanupService` purges those rows on their own window, falling back to `RetentionDays` (default 7) when the key is `0` - (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/OutboxCleanupService.cs:116-136`, + (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/OutboxCleanupService.cs:116-127`, `Settings/OutboxSettings.cs:101-108`). Setting 30 in production deliberately keeps a failed payload longer than a delivered one: four weeks to diagnose or replay it by hand before the row is abandoned. - `Scheduler__PollingIntervalSeconds: '300'` on Identity, Conference and Engagement only - (`main.bicep:1095`, `:1278`, `:1398`), the same reasoning as the outbox interval applied to the + (`main.bicep:1107`, `:1296`, `:1421`), the same reasoning as the outbox interval applied to the scheduled-job runner: it smart-waits until the earliest due job, so the interval only bounds an idle sleep, and the 30-second default woke every runner twice a minute per database for nothing. Notification does not get the key because it runs no scheduler: `Scheduler:Enabled` is `true` in @@ -985,16 +1010,27 @@ All six apps (`main.bicep:1015-1839`) share: `MMCA.ADC.Conference.Service/appsettings.json:33-34`, `MMCA.ADC.Engagement.Service/appsettings.json:54-55`), and the Notification service declares no `Scheduler` section at all. The template's own note says the same - (`main.bicep:1091-1094`): the audit-trail cleanup job runs daily, which is what the interval + (`main.bicep:1103-1106`): the audit-trail cleanup job runs daily, which is what the interval paces. -- `ConnectionStrings__redis` from Key Vault on all four services, which is the single key that turns - on the distributed cache, cross-replica idempotency, and the SignalR backplane. +- `ConnectionStrings__redis` from Key Vault on all four services (`main.bicep:1111`, `:1300`, + `:1425`, `:1573`), which is the single key that turns on the distributed cache, cross-replica + idempotency, and the SignalR backplane. - `MessageBus__Provider: 'AzureServiceBus'` + `MessageBus__ConnectionString` from Key Vault, selects MassTransit's Azure Service Bus transport at startup (locally the AppHost injects `WithBroker(rabbit)` for RabbitMQ instead). - `HealthProbe__Port`, a dedicated HTTP/1.1 listener that `Program.cs` adds when the key is set, and the target of all three probes (see below). +Three of the four services also carry +`Authentication__JwtBearer__RequireHttpsMetadata: 'false'` (`main.bicep:1306` Conference, `:1430` +Engagement, `:1578` Notification), and the template explains why in the comment directly above each +one (`main.bicep:1303-1305`, `:1427-1429`, `:1575-1577`). Their JWKS `Authority` is the ACA +**internal-ingress h2c URL** for Identity, `http://adc-prod-identity`: TLS terminates at the platform +edge, so traffic inside the environment is cleartext, and the framework's secure-by-default HTTPS +metadata requirement would otherwise reject that discovery fetch outright. Identity itself does not +carry the key because it issues the tokens rather than validating them against a remote authority, +and the Gateway does no JWT validation at all. + [Rubric §17, DevOps & Deployment] specifically calls out environment parity. The same six services that run under Aspire locally also run as Container Apps in production, with the transport switch (`RabbitMQ → AzureServiceBus`), the SQL location switch (`localhost SQL container @@ -1006,7 +1042,7 @@ configuration differences, not code differences. Application code is identical i Two distinct transport configurations appear across the six apps: **HTTP/2 cleartext (`transport: 'http2'`, `allowInsecure: true`)**: used by Identity, Conference, -and Engagement (`main.bicep:1032-1039`, `:1233-1240`, `:1357-1364`). These three +and Engagement (`main.bicep:1043-1050`, `:1250-1257`, `:1379-1386`). These three services run Kestrel in `Http2`-only on cleartext (h2c prior knowledge), which is required for cross-service gRPC: Kestrel cannot negotiate HTTP/2 via ALPN without TLS, and internal ACA service-to-service traffic does not pass through the TLS terminator. `allowInsecure: true` is @@ -1015,12 +1051,12 @@ architectural sense (traffic stays within the ACA virtual network) but the field **HTTP/1.1 (`transport: 'http'`)**: used by Notification, Gateway, and UI. Notification runs Kestrel in `Http1AndHttp2` because SignalR's WebSocket transport begins with an HTTP/1.1 Upgrade -handshake (`main.bicep:1484` comment). Gateway and UI use HTTP/1.1 because they are the external -entry points (Blazor Server also uses WebSocket upgrade from HTTP/1.1, `main.bicep:1794-1795` -comment). +handshake (`main.bicep:1511` comment). Gateway and UI use HTTP/1.1 because they are the external +entry points (`main.bicep:1674-1679`, `:1777-1785`; Blazor Server also uses WebSocket upgrade from +HTTP/1.1, `main.bicep:1828-1829` comment). Notification carries a third shape on top: `additionalPortMappings` exposes an internal-only TCP -port 8081 (`main.bicep:1491-1497`) for the cleartext h2c gRPC ingress (`LiveChannelPush`). TCP +port 8081 (`main.bicep:1518-1524`) for the cleartext h2c gRPC ingress (`LiveChannelPush`). TCP passthrough is what sidesteps the envoy HTTP/1.1-versus-HTTP/2 conflict, because the main ingress must stay `http` for WebSockets while gRPC needs end-to-end HTTP/2 (the [ADR-012](https://ivanball.github.io/docs/adr/012-grpc-host-transport.html) mixed-transport @@ -1030,12 +1066,13 @@ profile). Kestrel in HTTP/2 prior-knowledge mode rejects the platform's HTTP/1.1 `httpGet` probe with `GOAWAY HTTP_1_1_REQUIRED`, which would fail the liveness check and cause a reboot loop. Rather than -degrading the three h2c services to port-only `tcpSocket` probes, each service now opens a +degrading the three h2c services to port-only `tcpSocket` probes, each service opens a **dedicated HTTP/1.1 probe listener** that is not exposed via ingress: `HealthProbe__Port: '8081'` -on Identity, Conference and Engagement (`main.bicep:1076`, `:1267`, `:1387`) and `'8082'` on -Notification (`main.bicep:1530`, because 8080 and 8081 are already the ADR-012 pair). ACA probes may +on Identity, Conference and Engagement (`main.bicep:1088`, `:1285`, `:1410`) and `'8082'` on +Notification (`main.bicep:1558`, because 8080 and 8081 are already the ADR-012 pair). ACA probes may target a port that ingress does not publish, so all six apps use `httpGet` probes and all six carry -the same three: +the same three (`main.bicep:1200-1225` Identity, `:1329-1354` Conference, `:1458-1483` Engagement, +`:1615-1640` Notification, `:1711-1736` Gateway, `:1830-1855` UI): | Probe | Path | Semantics | |---|---|---| @@ -1043,31 +1080,33 @@ the same three: | `liveness` | `/alive` | self-only, so a SQL outage never restarts the container | | `readiness` | `/health/ready` | warmup gate plus the DB-aware `AddSqlServer` check | -The liveness/readiness split is the load-bearing part (`main.bicep:1176-1182`): `/alive` checks the +The liveness/readiness split is the load-bearing part (`main.bicep:1193-1199`): `/alive` checks the process only, so a database outage does not trigger a restart loop, while `/health/ready` fails when a replica cannot reach its database, pulling it out of rotation instead of letting it serve 500s. Readiness is also gated on `WarmupHostedService` completing (OIDC discovery fetched), so ACA holds -back user traffic until the replica is warm. Gateway and UI probe their own 8080 (`main.bicep:1678-1703`, -`:1796-1821`) because their Kestrel accepts HTTP/1.1 directly. +back user traffic until the replica is warm. Gateway and UI probe their own 8080 (`main.bicep:1709-1710`, +`:1828-1829`) because their Kestrel accepts HTTP/1.1 directly. #### Service Discovery (`services____http__0`) Aspire's service discovery convention uses env vars of the form `services____http__0` to resolve service endpoints. In production these point at internal ACA hostnames: -- Gateway → all four services: `conference` (`main.bicep:1671`), `identity` (`:1672`), - `engagement` (`:1673`), `notification` (`:1674`), each as `http://${.name}` -- Conference → `services__engagement__http__0 = http://${prefix}-engagement` (`main.bicep:1289`) +- Gateway → all four services: `conference` (`main.bicep:1704`), `identity` (`:1705`), + `engagement` (`:1706`), `notification` (`:1707`), each as `http://${.name}` +- Conference → `services__engagement__http__0 = http://${prefix}-engagement` (`main.bicep:1311`) (using the literal `${prefix}-engagement` rather than `${engagementApp.name}` to avoid a Bicep symbolic cycle, Conference and Engagement both reference each other) -- Engagement → `services__conference__http__0 = http://${prefix}-conference` (`main.bicep:1408`) -- Notification → `services__identity__http__0 = http://${identityApp.name}` (`main.bicep:1550`) -- Identity → `services__engagement__http__0` (`main.bicep:1113`), for the PRIVACY.md data-subject +- Engagement → `services__conference__http__0 = http://${prefix}-conference` (`main.bicep:1435`) +- Notification → `services__identity__http__0 = http://${identityApp.name}` (`main.bicep:1582`), + for the `IAttendeeQueryService` email-recipient lookup +- Identity → `services__engagement__http__0` (`main.bicep:1125`), for the PRIVACY.md data-subject export's Engagement section Two edges use a **named** endpoint rather than the default `http` one, because they target Notification's dedicated h2c gRPC port: `services__notification__grpc__0 = http://${prefix}-notification:8081` -from Identity (`main.bicep:1120`) and from Engagement (`main.bicep:1413`). Both use the literal +from Identity (`main.bicep:1132`, the Notifications section of the same data-subject export) and +from Engagement (`main.bicep:1440`, the best-effort live-channel push). Both use the literal `${prefix}-notification` name so deployment ordering stays unconstrained, since Notification itself references `identityApp` for its JWKS authority. @@ -1076,9 +1115,9 @@ The same service names work locally because the AppHost's `WithReference` inject `AddHttpForwarderWithServiceDiscovery()` or `AddTypedGrpcClient(serviceName)` in both environments and resolves the endpoint from that env var key. -#### Identity Service specifics (`main.bicep:1015-1214`) +#### Identity Service specifics (`main.bicep:1026-1231`) -Identity is the JWT issuer and JWKS endpoint. Its JWT configuration (`main.bicep:1101-1105`): +Identity is the JWT issuer and JWKS endpoint. Its JWT configuration (`main.bicep:1113-1117`): ```bicep { name: 'Jwt__SigningAlgorithm', value: useRs256 ? 'RS256' : 'HS256' } @@ -1089,43 +1128,57 @@ Identity is the JWT issuer and JWKS endpoint. Its JWT configuration (`main.bicep ``` When `useRs256 = true`, the RSA private key (from Key Vault) signs tokens and the public key is -published at `/.well-known/jwks.json` (`main.bicep:1152-1157`). Otherwise the HS256 branch injects -`Jwt__SecretForKey` and sets `Jwks__Enabled: 'false'` (`main.bicep:1158-1162`). Other services fetch +published at `/.well-known/jwks.json` (`main.bicep:1169-1174`). Otherwise the HS256 branch injects +`Jwt__SecretForKey` and sets `Jwks__Enabled: 'false'` (`main.bicep:1175-1179`). Other services fetch the JWKS document through the Gateway (`Authentication__JwtBearer__Authority = 'http://${identityApp.name}'`) to validate tokens without a shared secret ([ADR-004](https://ivanball.github.io/docs/adr/004-authentication-dual-fetch.html) "authentication dual-fetch"). The 15-minute access token lifetime limits the blast radius of a leaked token. -Identity is also the app that carries the avatar-storage wiring (`main.bicep:1129-1130`): +Identity is also the app that carries the avatar-storage wiring (`main.bicep:1141-1142`): `FileStorage__ServiceUri` and `FileStorage__ContainerName`, pointed at the storage account's blob -endpoint and the `avatars` container. `AZURE_CLIENT_ID` (`main.bicep:1136`) sits beside them and +endpoint and the `avatars` container. `AZURE_CLIENT_ID` (`main.bicep:1148`) sits beside them and pins the apps identity's client id so `DefaultAzureCredential` resolves the intended identity explicitly rather than relying on discovery order. That pin started here for blob access, but it is -no longer avatar-specific: four other apps now carry it for the Key Vault configuration source (see +no longer avatar-specific: four other apps carry it for the Key Vault configuration source (see the Key Vault section). Identity is one of the two apps that persist the **DataProtection key ring** -(`main.bicep:1134-1135`): `DataProtection__BlobStorageUri` points at +(`main.bicep:1146-1147`): `DataProtection__BlobStorageUri` points at `dataprotection-keys/keys.xml` in the private container described above, and `DataProtection__ApplicationName: 'MMCA.ADC'` is the isolation name the ring is scoped by (the same value on the UI, which is what makes the two apps share one ring rather than two). The comment -above them (`main.bicep:1131-1133`) states the failure mode: Identity does OAuth cookie +above them (`main.bicep:1143-1145`) states the failure mode: Identity does OAuth cookie cryptography at `maxReplicas: 2` with no session affinity, so with the default per-replica in-memory ring a login started on one replica fails on the other. `MMCA.Common`'s `AddCommonDataProtection` reads both keys, and `DataProtection:BlobStorageUri` is the gate: absent, the method does nothing and the host keeps the in-memory default, which is what local development and the tests want -(`MMCA.Common/Source/Hosting/MMCA.Common.Aspire/DataProtection/DataProtectionExtensions.cs:54-72`). - -Identity is sized at 0.25 CPU / 0.5 Gi (`main.bicep:1063`), the smallest Container Apps allocation. +(`MMCA.Common/Source/Hosting/MMCA.Common.Aspire/DataProtection/DataProtectionExtensions.cs:54-62`). + +Identity is also the app that sends the account emails, so it receives the SMTP block +(`main.bicep:1157-1161`: `Smtp__Host`, `Smtp__Port`, `Smtp__Username`, `Smtp__EnableSsl: 'true'`, +`Smtp__From`) with the password arriving separately as a `secretRef` only when one is configured +(`main.bicep:1180`, gated on `hasSmtpPassword`). Sitting with them is +`PasswordReset__ResetUrl` (`main.bicep:1166`), the absolute URL of the UI reset page the +forgot-password email links to +([ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html)). It points at +the same UI origin `OAuth__UIBaseUrl` uses but is injected **unconditionally**, and the comment +above it says why (`main.bicep:1162-1165`): password recovery is a local-credential feature and has +to work whether or not an external OAuth provider is configured, so gating it behind `hasAnyOAuth` +would silently degrade the reset mail to a token-only message on any deployment without social +login. + +Identity is sized at 0.25 CPU / 0.5 Gi (`main.bicep:1074`), the smallest Container Apps allocation. JWT operations are CPU-cheap once the key is loaded; the bottleneck is typically network I/O to SQL. -#### Conference Service specifics (`main.bicep:1216-1338`) +#### Conference Service specifics (`main.bicep:1233-1360`) -Conference is one of the two largest apps (0.5 CPU / 1 Gi, `main.bicep:1256`), reflecting its 14 REST -controllers, its AI scoring path (Anthropic API), and its role as the read-heavy entry point for -the event/session catalog. The Anthropic API key is injected only when `hasAnthropic = true` -(`main.bicep:1242-1249`, `:1301`): +Conference is one of the two largest apps (0.5 CPU / 1 Gi, `main.bicep:1273`), reflecting its +seventeen API controllers +(`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/`), its AI scoring path +(Anthropic API), and its role as the read-heavy entry point for the event/session catalog. The +Anthropic API key is injected only when `hasAnthropic = true` (`main.bicep:1259-1266`, `:1323`): ```bicep secrets: union( @@ -1138,55 +1191,68 @@ This is the `union()` + conditional array pattern used throughout `main.bicep` t secrets and env vars out of the resource definition when not configured, rather than passing empty strings to the container. -#### Notification Service specifics (`main.bicep:1464-1619`) +#### Notification Service specifics (`main.bicep:1491-1651`) -Notification differs from the other three back-end services in four ways: +Notification differs from the other three back-end services in five ways: 1. `transport: 'http'` instead of `'http2'`, SignalR WebSocket requires an HTTP/1.1 Upgrade - handshake (`main.bicep:1484`), plus the extra internal-only h2c port 8081 for gRPC - (`main.bicep:1491-1497`). -2. Its probe listener is on **8082** (`main.bicep:1530`), because the ADR-012 mixed profile already - owns 8080 and 8081 and those two endpoints are load-bearing. -3. `maxReplicas: 1` (`main.bicep:1616`) rather than 2. + handshake (`main.bicep:1511`), plus the extra internal-only h2c port 8081 for gRPC + (`main.bicep:1518-1524`). +2. Its probe listener is on **8082** (`main.bicep:1558`), because the ADR-012 mixed profile already + owns 8080 and 8081 and those two endpoints are load-bearing (`main.bicep:1553-1557`). +3. `maxReplicas: 1` (`main.bicep:1648`) rather than 2. 4. It is the only app that can receive the native-push env block, and only when the hub exists - (`main.bicep:1571-1575`). + (`main.bicep:1600-1607`). +5. It is the second app with an SMTP block (`main.bicep:1589-1593` plus the conditional + `Smtp__Password` `secretRef` at `:1608` and its vault-backed secret at `:1534`), because the + notification service is the one that fans a notification out to email as well as to the hub. -Its readiness probe (`main.bicep:1599-1607`) is what holds ACA ingress until the -`WarmupHostedService` has fetched the JWKS document from Identity. Without it, SignalR connections -made during warmup would fail because the JWT validator is not yet initialized. +It runs no scheduler, so unlike the other three it gets no `Scheduler__PollingIntervalSeconds`. +Its readiness probe (`main.bicep:1631-1639`) is what holds ACA ingress until the +`WarmupHostedService` has fetched the JWKS document from Identity (`main.bicep:1610-1614`). Without +it, SignalR connections made during warmup would fail because the JWT validator is not yet +initialized. -#### Gateway specifics (`main.bicep:1621-1722`) +#### Gateway specifics (`main.bicep:1653-1755`) Gateway is the sole externally-reachable back-end entry point (`external: true`, -`allowInsecure: false`, `main.bicep:1642-1647`). It is a pure YARP reverse proxy: no DbContext, no -JWT issuing, no module, and `secrets: []` (`main.bicep:1649`). Its env configuration is entirely +`allowInsecure: false`, `main.bicep:1674-1679`). It is a pure YARP reverse proxy: no DbContext, no +JWT issuing, no module, and `secrets: []` (`main.bicep:1681`). Its env configuration is entirely service-discovery entries and CORS: ```bicep { name: 'Cors__AllowedOrigins__0', value: 'https://${prefix}-ui.${...defaultDomain}' } ``` -CORS is scoped to exactly the UI's FQDN (`main.bicep:1665`), not a wildcard. Gateway is sized at -0.5 CPU / 1 Gi (`main.bicep:1656`) and uses the readiness gate at `main.bicep:1694-1702` because its +CORS is scoped to exactly the UI's FQDN (`main.bicep:1698`), not a wildcard. Gateway is sized at +0.5 CPU / 1 Gi (`main.bicep:1688`) and uses the readiness gate at `main.bicep:1728-1735` because its warmup involves establishing connections to all back-end services. It is also the target of the -availability web test described above. It is also the one app with no `KeyVault__Uri`: holding no +availability web test described above, and the only app with no `KeyVault__Uri`: holding no secret, it has no vault to read. -#### UI specifics (`main.bicep:1724-1839`) +The template also records the transport contract the Gateway holds up (`main.bicep:1699-1702`): +`ForwardHttp2` defaults to true in the gateway code and YARP uses `VersionPolicy=RequestVersionExact`, +so it sends the HTTP/2 preface to the three h2c-prior-knowledge backends whose ACA ingress is +`transport: http2`. That pairing is why the ingress choice on those three services and the forwarder +policy here cannot be changed independently. + +#### UI specifics (`main.bicep:1757-1874`) -UI is the other externally-reachable app (`external: true`, `main.bicep:1745`), also with -`secrets: []` (`main.bicep:1754`) and sized at 0.25 CPU / 0.5 Gi (`main.bicep:1761`). Three +UI is the other externally-reachable app (`external: true`, `main.bicep:1778`), also with +`secrets: []` (`main.bicep:1787`) and sized at 0.25 CPU / 0.5 Gi (`main.bicep:1794`). Three non-obvious configuration points: -**Sticky sessions** (`main.bicep:1749-1751`): +**Sticky sessions** (`main.bicep:1782-1784`): ```bicep stickySessions: { affinity: 'sticky' } ``` Blazor Server runs the component model as a stateful SignalR circuit on the server. If a request from a browser is load-balanced to a different replica than the one holding the circuit, the -circuit drops. Sticky session affinity pins each browser session to one replica. +circuit drops. Sticky session affinity pins each browser session to one replica. The header comment +on the resource (`main.bicep:1760-1762`) states both Blazor Server requirements together: sticky +sessions and `minReplicas >= 1`. -**Dual API endpoints** (`main.bicep:1771-1774`): +**Dual API endpoints** (`main.bicep:1806`, `:1808`): ```bicep { name: 'Api__ApiEndpoint', value: 'http://${gatewayApp.name}' } { name: 'Api__WasmApiEndpoint', value: 'https://${gatewayApp.properties.configuration.ingress.fqdn}' } @@ -1196,19 +1262,19 @@ and the Envoy round-trip). WebAssembly code running in the browser must use the it has no access to the internal ACA DNS. The UI serves the WASM endpoint URL via a `/client-config` endpoint so the WASM app can discover the gateway without the URL being baked into the WASM build. -**Shared DataProtection key ring** (`main.bicep:1780-1781`): the UI carries the same +**Shared DataProtection key ring** (`main.bicep:1814-1815`): the UI carries the same `DataProtection__BlobStorageUri` and `DataProtection__ApplicationName: 'MMCA.ADC'` pair as Identity, pointed at the same `dataprotection-keys/keys.xml` blob. The reason is the one above with the consequence reversed: sticky sessions pin a **circuit** to a replica, but the UI also mints the SSR session cookie and antiforgery tokens, and those travel with the browser rather than with the circuit, so at `maxReplicas: 2` a per-replica in-memory ring makes them undecryptable on the other -replica (`main.bicep:1775-1777`). `AZURE_CLIENT_ID` (`main.bicep:1782`) pins the identity that +replica (`main.bicep:1809-1813`). `AZURE_CLIENT_ID` (`main.bicep:1816`) pins the identity that `DefaultAzureCredential` uses for both the blob write and the vault read. -The UI receives only the OAuth **client ids** when a provider is configured (`main.bicep:1787-1792`); +The UI receives only the OAuth **client ids** when a provider is configured (`main.bicep:1821-1826`); the client secrets stay on Identity, which is the app that completes the exchange. -### Outputs (`main.bicep:1842-1850`) +### Outputs (`main.bicep:1876-1884`) ```bicep output acrLoginServer string = acr.properties.loginServer @@ -1223,12 +1289,14 @@ output appInsightsName string = appInsights.name the deployed revision. That step probes every service through the Gateway, and for the two auth-gated endpoints the asserted status is exactly **401**, not 2xx: an anonymous request must be rejected _by the service_, which only happens when the service is up and serving -(`deploy.yml:1130-1133`). On failure it rolls every app back to its previous revision and still -fails the job, and it reports separately when a rollback itself failed, so a partially rolled-back -fleet never looks like a clean auto-revert (`deploy.yml:1151-1178`). +(`deploy.yml:1130-1133`). A security-headers check rides along but is explicitly informational +(`deploy.yml:1137-1144`): a missing hardening header is not a "revision not serving" failure and +must not trip the fleet-wide rollback. On a real failure it rolls every app back to its previous +revision and still fails the job, and it reports separately when a rollback itself failed, so a +partially rolled-back fleet never looks like a clean auto-revert (`deploy.yml:1151-1178`). `sqlServerFqdn` is an output of `main.bicep` (each service connects to its own -database via the per-service connection strings written into Key Vault; `deploy.yml` itself no longer runs +database via the per-service connection strings written into Key Vault; `deploy.yml` itself does not run `sqlcmd` against the server, migrations are applied by the services at startup). The `cutover-per-service-dbs.yml` workflow discovers the SQL FQDN independently for the one-time data migration. @@ -1270,18 +1338,18 @@ secret and redeploying. |---|---| | §7 Microservices Readiness | Per-service databases ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)); service-discovery env vars (including the two named `grpc` endpoints); gRPC transport selection | | §8 Data Architecture | Four per-service databases; LTR policies; AtlDevCon archive retention; EF model-drift gate in deploy.yml (migrations applied by services at startup) | -| §11 Security | UAMI/OIDC model; Key Vault-backed secrets ([ADR-061](https://ivanball.github.io/docs/adr/061-runtime-secret-management.html)) plus the `KeyVault__Uri` configuration source on five of six apps; `secrets: []` on Gateway and UI; `adminUserEnabled: false`; `@secure()` parameters; staged `useManagedIdentitySql`; private `dataprotection-keys` container for the shared key ring (at-rest key-vault encryption of that ring is an explicit not-yet-implemented follow-up); no static credentials | +| §11 Security | UAMI/OIDC model; Key Vault-backed secrets ([ADR-061](https://ivanball.github.io/docs/adr/061-runtime-secret-management.html)) plus the `KeyVault__Uri` configuration source on five of six apps; `secrets: []` on Gateway and UI; `adminUserEnabled: false`; `@secure()` parameters; staged `useManagedIdentitySql`; private `dataprotection-keys` container for the shared key ring (at-rest key-vault encryption of that ring is an explicit not-yet-implemented follow-up); the scoped `RequireHttpsMetadata: false` on the three internal JWKS consumers; no static credentials | | §13 Observability | Workspace-based App Insights; per-service `OTEL_SERVICE_NAME`; Application Map coverage; SLO scheduled query rules + workbook ([ADR-062](https://ivanball.github.io/docs/adr/062-slo-alerting-as-code.html)); outbox dead-letter and SQL dependency alerts | | §17 DevOps & Deployment | Two-phase Bicep split; Incremental mode; image sha-tagging + registry build cache; service-startup migration (sole migrator, minReplicas:1); smoke-test gate | | §29 Resilience & Business Continuity | LTR on per-service databases; SLO alerts; sev-1 Gateway availability web test; smoke-test rollback; `minReplicas: 1`; readiness probes with a self-only liveness split | -| §31 Cost Efficiency / FinOps | `commonTags` on every resource; monthly budget with 80%/100% thresholds; `cost-guard.yml` surge-drift gate; workspace `dailyQuotaGb: 1`; 25% trace sampling; Warning OTel log floor; Basic-tier DB sizing; 300s outbox and scheduler polls; the two disabled metric groups; the daily ACR image-purge task | +| §31 Cost Efficiency / FinOps | `commonTags` on every resource; monthly budget with 80%/100% thresholds; `cost-guard.yml` surge-drift gate; workspace `dailyQuotaGb: 1`; 25% trace sampling; Warning OTel log floor; Basic-tier DB sizing; 300s outbox and scheduler polls; the two disabled metric groups plus the 300s metric export interval; the daily 3-day/keep-3 ACR image-purge task | --- ## Not determinable from source - The exact `AcrPull` and `Key Vault Secrets User` role-assignment commands used in the out-of- - band bootstrap are referenced in comments (`main.bicep:915-919`, `main.bicep:933-936`) but the + band bootstrap are referenced in comments (`main.bicep:926-930`, `main.bicep:944-947`) but the commands themselves live in `infra/DISASTER-RECOVERY.md`, which is private to the ADC repo and out of scope for this chapter. A distilled version is published in the framework's reference runbook, `MMCA.Common/samples/deployment/DEPLOYMENT.md`. @@ -1292,7 +1360,9 @@ secret and redeploying. even though the template default says otherwise. Treat the template as the shape and the repository variables as the state; neither alone tells you what production is doing. The same split applies to `AZURE_RESOURCE_GROUP` and `AZURE_SQL_LOCATION`, whose fallbacks (`acc-rg`, - `westus2`) appear only in workflow comments and defaults. + `westus2`) appear only in workflow comments and defaults, and to the whole `SMTP_*` set + (`deploy.yml:923-927`), which decides whether the SMTP env block on Identity and Notification + carries a real relay or empty strings. - The `azure/arm-deploy@v2` action's `deploymentMode` is not set explicitly in `deploy.yml` (`deploy.yml:773-779` for foundation, `deploy.yml:1070-1076` for main), the action defaults to Incremental, but this is not stated in the workflow file; it is inferred from the Incremental intent diff --git a/docs-src/onboarding/group-03-querying-specifications.md b/docs-src/onboarding/group-03-querying-specifications.md index 7eca618..41ddf7b 100644 --- a/docs-src/onboarding/group-03-querying-specifications.md +++ b/docs-src/onboarding/group-03-querying-specifications.md @@ -14,11 +14,11 @@ The split matters: specifications are trusted and live with the domain, dynamic [`ISpecification`](#ispecificationtentity-tidentifiertype) (`MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/ISpecification.cs:12`) exposes two faces of one rule: a `Criteria` expression tree that EF Core translates to SQL, so the filter runs in the database rather than in memory after a full-table load (`ISpecification.cs:17`), and `IsSatisfiedBy(entity)` for in-memory evaluation (`ISpecification.cs:22`). The abstract base [`Specification`](#specificationtentity-tidentifiertype) (`MMCA.Common/Source/Core/MMCA.Common.Domain/Specifications/Specification.cs:15`) leaves `Criteria` abstract (`Specification.cs:23`), compiles it lazily on first use, and caches the delegate in a private field (`Specification.cs:27`, `Specification.cs:32`), so repeated in-memory checks do not recompile the tree. -The three combinators, [`AndSpecification`](#andspecificationtentity-tidentifiertype) (`Specification.cs:81`), [`OrSpecification`](#orspecificationtentity-tidentifiertype) (`Specification.cs:105`), and [`NotSpecification`](#notspecificationtentity-tidentifiertype) (`Specification.cs:128`), each delegate to the internal [`SpecificationComposer`](#specificationcomposer) (`Specification.cs:146`) and cache the composed expression in a per-instance field rather than rebuilding it on every `Criteria` read (`Specification.cs:88`, `Specification.cs:112`, `Specification.cs:134`), because the pipeline reads `Criteria` at least once per request. `Combine` (`Specification.cs:155`) takes the left lambda's own parameter (`Specification.cs:167`), rebinds the right-hand body onto it, and joins the two with `Expression.AndAlso` or `Expression.OrElse` before closing the lambda (`Specification.cs:169-173`); `Negate` (`Specification.cs:181`) wraps the body in `Expression.Not` while keeping the inner lambda's parameter (`Specification.cs:189-191`). The rebinding is done by [`ParameterReplacer`](#parameterreplacer) (`MMCA.Common/Source/Core/MMCA.Common.Domain/Specifications/ParameterReplacer.cs:24`), an `ExpressionVisitor` whose static `Replace` short-circuits when the two parameters are already the same instance (`ParameterReplacer.cs:34`, `ParameterReplacer.cs:40`) and whose `VisitParameter` swaps the rest (`ParameterReplacer.cs:44`). Composing by substitution rather than `Expression.Invoke` is a deliberate portability decision: an `InvocationExpression` survives into the query tree and several providers (Cosmos among them) refuse to translate one, so an ANDed specification used to fail on exactly the engines the framework is meant to be portable across (`Specification.cs:66-69`, `ParameterReplacer.cs:12-15`). The visitor is `internal` and reaches the Application layer through `InternalsVisibleTo` so the cross-source builder shares one copy rather than carrying its own (`ParameterReplacer.cs:18-23`). [`SpecificationExtensions`](#specificationextensions) (`MMCA.Common/Source/Core/MMCA.Common.Domain/Specifications/SpecificationExtensions.cs:30`) puts a fluent face on those three, as `extension` members (`SpecificationExtensions.cs:32`) exposing `And` (`SpecificationExtensions.cs:48`), `Or` (`SpecificationExtensions.cs:68`), and `Not` (`SpecificationExtensions.cs:85`), so a composed predicate reads left to right instead of inside out. +The three combinators, [`AndSpecification`](#andspecificationtentity-tidentifiertype) (`Specification.cs:81`), [`OrSpecification`](#orspecificationtentity-tidentifiertype) (`Specification.cs:105`), and [`NotSpecification`](#notspecificationtentity-tidentifiertype) (`Specification.cs:128`), each delegate to the internal [`SpecificationComposer`](#specificationcomposer) (`Specification.cs:146`) and cache the composed expression in a per-instance `_criteria` field rather than rebuilding it on every `Criteria` read (`Specification.cs:88-93`, `Specification.cs:112-117`, `Specification.cs:134-135`), because the pipeline reads `Criteria` at least once per request. `Combine` (`Specification.cs:155`) takes the left lambda's own parameter (`Specification.cs:167`), rebinds the right-hand body onto it, and joins the two with `Expression.AndAlso` or `Expression.OrElse` before closing the lambda (`Specification.cs:169-173`); `Negate` (`Specification.cs:181`) wraps the body in `Expression.Not` while keeping the inner lambda's parameter (`Specification.cs:189-191`). The rebinding is done by [`ParameterReplacer`](#parameterreplacer) (`MMCA.Common/Source/Core/MMCA.Common.Domain/Specifications/ParameterReplacer.cs:24`), an `ExpressionVisitor` whose static `Replace` short-circuits when the two parameters are already the same instance (`ParameterReplacer.cs:34`, `ParameterReplacer.cs:40`) and whose `VisitParameter` swaps the rest (`ParameterReplacer.cs:44`). Composing by substitution rather than `Expression.Invoke` is a deliberate portability decision: an `InvocationExpression` survives into the query tree and several providers (Cosmos among them) refuse to translate one, so an ANDed specification failed on exactly the engines the framework is meant to be portable across (`Specification.cs:66-69`, `ParameterReplacer.cs:12-16`). The visitor is `internal` and reaches the Application layer through `InternalsVisibleTo` so the cross-source builder shares one copy rather than carrying its own (`ParameterReplacer.cs:19-23`). [`SpecificationExtensions`](#specificationextensions) (`MMCA.Common/Source/Core/MMCA.Common.Domain/Specifications/SpecificationExtensions.cs:30`) puts a fluent face on those three, as `extension` members (`SpecificationExtensions.cs:32`) exposing `And` (`SpecificationExtensions.cs:48`), `Or` (`SpecificationExtensions.cs:68`), and `Not` (`SpecificationExtensions.cs:85`), so a composed predicate reads left to right instead of inside out. Concrete specifications are how a controller scopes a query to allowed data without trusting the request to do it. ADC has two, both one-liners: [`PublishedEventSpecification`](group-18-conference-application.md#publishedeventspecification) is `e => e.IsPublished` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Specifications/PublishedEventSpecification.cs:11`, criteria at `PublishedEventSpecification.cs:14`), and [`PublicSessionStatusSpecification`](group-18-conference-application.md#publicsessionstatusspecification) allows the public session-status list (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/Specifications/PublicSessionStatusSpecification.cs:20`), exposing its predicate as a `public static readonly` expression (`PublicSessionStatusSpecification.cs:23`) so the cross-source filter and the visible-session id resolver share one definition rather than each re-deriving BR-49 (`Criteria` simply returns it at `PublicSessionStatusSpecification.cs:27`). The framework also ships one ready-made scope: [`OwnedByUserSpecification`](#ownedbyuserspecificationtentity-tidentifiertype) (`MMCA.Common/Source/Core/MMCA.Common.Domain/Specifications/OwnedByUserSpecification.cs:20`) filters on the audit field `CreatedBy` as the ownership marker (`OwnedByUserSpecification.cs:29-30`), and its constraint is deliberately the concrete [`AuditableBaseEntity`](group-02-domain-building-blocks.md#auditablebaseentitytidentifiertype) rather than an `IAuditableEntity` interface, because a member access declared on an interface is not guaranteed to map to the entity's audit column and the criteria must stay EF-translatable (`OwnedByUserSpecification.cs:12-16`). ADC's question-answer controllers are its callers, and they show the intended shape: an organizer gets `null` (no scoping at all), everyone else gets the specification bound to their own user id (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:67-68`, and the same pair at `SessionQuestionAnswersController.cs:67-68`). This is [Rubric §4, Domain-Driven Design] (the rule is a first-class, reusable domain object) and [Rubric §2, Design Patterns] (a textbook Specification), with a [Rubric §11, Security] overtone: an authorization predicate is server-supplied criteria the client cannot tamper with. -Two members round the family out for polyglot persistence ([ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html)). [`InlineSpecification`](#inlinespecificationtentity-tidentifiertype) (`Specification.cs:45`) wraps an already-composed `Criteria` expression as a first-class specification (`Specification.cs:51-52`), for predicates built at runtime where no hand-written class exists. The static [`CrossSourceSpecification`](#crosssourcespecification) (`MMCA.Common/Source/Core/MMCA.Common.Application/Specifications/CrossSourceSpecification.cs:22`) builds the cross-source filter: when a dependent entity references a principal that lives in a different physical data source (database-per-service, [ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)), a navigating predicate like `s => s.Event.IsPublished` cannot be translated, so `BuildAsync` (`CrossSourceSpecification.cs:39`) first projects the matching principal keys from the principal's own source through the read repository's `GetProjectedAsync` (`CrossSourceSpecification.cs:55-56`), materializes them once (`CrossSourceSpecification.cs:60`), and returns an `InlineSpecification` (`CrossSourceSpecification.cs:62`) whose body is an `Enumerable.Contains(keys, dependent.ForeignKey)` call that translates to `IN` or `ARRAY_CONTAINS` (`CrossSourceSpecification.cs:74-79`). An optional local predicate on the dependent's own columns is rebound onto the foreign-key selector's parameter by the shared `ParameterReplacer` (`CrossSourceSpecification.cs:86`) and ANDed in (`CrossSourceSpecification.cs:87`), again without `Expression.Invoke` so the combined predicate stays translatable on every provider (`CrossSourceSpecification.cs:83-85`). The doc comment is explicit about the limit: the keys are materialized and embedded in the predicate, so the shape fits bounded principal sets (`CrossSourceSpecification.cs:17-20`). ADC uses it in production on both of its Session reads, each passing `PublicSessionStatusSpecification.StatusCriteria` as the local predicate: [`GetPublicSessionFilterHandler`](group-18-conference-application.md#getpublicsessionfilterhandler) returns the specification as a query result (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/GetPublicSessionFilter/GetPublicSessionFilterHandler.cs:29-36`), and [`PublicConferenceVisibility`](group-18-conference-application.md#publicconferencevisibility) uses the same criteria to resolve the visible session ids so a session hidden from the session list cannot stay reachable through a speaker or junction read (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:62-74`). The convention this exists to serve is guarded by an opt-in fitness rule, `ArchitectureRules.SpecificationsDoNotNavigateToOtherEntities` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Specifications.cs:24`), which analyzes only the parameterless specifications it can instantiate (`ArchitectureRules.Specifications.cs:38-41`) and is exposed to repos as the single-fact base [`SpecificationConventionTestsBase`](group-27-testing-infrastructure.md#specificationconventiontestsbase) (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/SpecificationConventionTestsBase.cs:10`, the fact at `SpecificationConventionTestsBase.cs:16`), which is [Rubric §14, Testability] applied to an architectural rule. +Two members round the family out for polyglot persistence ([ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html)). [`InlineSpecification`](#inlinespecificationtentity-tidentifiertype) (`Specification.cs:45`) wraps an already-composed `Criteria` expression as a first-class specification (`Specification.cs:51-52`), for predicates built at runtime where no hand-written class exists. The static [`CrossSourceSpecification`](#crosssourcespecification) (`MMCA.Common/Source/Core/MMCA.Common.Application/Specifications/CrossSourceSpecification.cs:22`) builds the cross-source filter: when a dependent entity references a principal that lives in a different physical data source (database-per-service, [ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)), a navigating predicate like `s => s.Event.IsPublished` cannot be translated, so `BuildAsync` (`CrossSourceSpecification.cs:39`) first projects the matching principal keys from the principal's own source through the read repository's `GetProjectedAsync` (`CrossSourceSpecification.cs:55-56`), materializes them once (`CrossSourceSpecification.cs:60`), and returns an `InlineSpecification` (`CrossSourceSpecification.cs:62`) whose body is an `Enumerable.Contains(keys, dependent.ForeignKey)` call that translates to `IN` or `ARRAY_CONTAINS` (`CrossSourceSpecification.cs:74-79`). An optional local predicate on the dependent's own columns is rebound onto the foreign-key selector's parameter by the shared `ParameterReplacer` (`CrossSourceSpecification.cs:86`) and ANDed in (`CrossSourceSpecification.cs:87`), again without `Expression.Invoke` so the combined predicate stays translatable on every provider (`CrossSourceSpecification.cs:83-85`). The doc comment is explicit about the limit: the keys are materialized and embedded in the predicate, so the shape fits bounded principal sets (`CrossSourceSpecification.cs:17-20`). ADC uses it in production on both of its Session reads, each passing `PublicSessionStatusSpecification.StatusCriteria` as the local predicate: [`GetPublicSessionFilterHandler`](group-18-conference-application.md#getpublicsessionfilterhandler) returns the specification as a query result (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/GetPublicSessionFilter/GetPublicSessionFilterHandler.cs:29-36`), and [`PublicConferenceVisibility`](group-18-conference-application.md#publicconferencevisibility) uses the same criteria to resolve the visible session ids so a session hidden from the session list cannot stay reachable through a speaker or junction read (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:63-69`). The convention this exists to serve is guarded by an opt-in fitness rule, `ArchitectureRules.SpecificationsDoNotNavigateToOtherEntities` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Specifications.cs:24`), which analyzes only the parameterless specifications it can instantiate (`ArchitectureRules.Specifications.cs:38-41`) and is exposed to repos as the single-fact base [`SpecificationConventionTestsBase`](group-27-testing-infrastructure.md#specificationconventiontestsbase) (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/SpecificationConventionTestsBase.cs:10`, the fact at `SpecificationConventionTestsBase.cs:15-17`), which is [Rubric §14, Testability] applied to an architectural rule. ## QuerySpecification, a whole read in one object @@ -28,15 +28,15 @@ That object is consumed on the persistence side, not by the pipeline in this cha ## Dynamic filtering, one Strategy per CLR type -User filters arrive as a `Dictionary`, property name to operator key plus raw string value, parsed from the query string by [`QueryFilterModelBinder`](group-12-api-hosting-mapping.md#queryfiltermodelbinder) at the API edge, which caps a single request at `MaxFilters = 50` distinct properties (`MMCA.Common/Source/Presentation/MMCA.Common.API/ModelBinders/QueryFilterModelBinder.cs:34`, enforced at `QueryFilterModelBinder.cs:61`, where surplus entries are dropped rather than rejected). Turning `("Name", "CONTAINS", "blazor")` into a `.Where()` clause depends entirely on the property's CLR type, so instead of one large `switch` each type gets an [`IFilterStrategy`](#ifilterstrategy) (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Filtering/IFilterStrategy.cs:6`) declaring an `Apply` method (`IFilterStrategy.cs:17`), the operator set it supports (`IFilterStrategy.cs:24`, where the default `SupportedOperators` is `null`, meaning operator validation is skipped for custom strategies), and a `CanParseValue` predicate that defaults to `true` (`IFilterStrategy.cs:44`). That last member exists because `Apply` fails open: a strategy that cannot parse a value silently returns the query unfiltered, so `?filter=id:equals:abc` used to return the whole result set instead of no matches (`IFilterStrategy.cs:32-38`). Validating the value up front turns that into a 400, and the default of `true` keeps a custom strategy behaving exactly as before until it opts in. +User filters arrive as a `Dictionary`, property name to operator key plus raw string value, parsed from the query string by [`QueryFilterModelBinder`](group-12-api-hosting-mapping.md#queryfiltermodelbinder) at the API edge, which caps a single request at `MaxFilters = 50` distinct properties (`MMCA.Common/Source/Presentation/MMCA.Common.API/ModelBinders/QueryFilterModelBinder.cs:34`, enforced at `QueryFilterModelBinder.cs:61`, where surplus entries are dropped rather than rejected). Turning `("Name", "CONTAINS", "blazor")` into a `.Where()` clause depends entirely on the property's CLR type, so instead of one large `switch` each type gets an [`IFilterStrategy`](#ifilterstrategy) (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Filtering/IFilterStrategy.cs:6`) declaring an `Apply` method (`IFilterStrategy.cs:17`), the operator set it supports (`IFilterStrategy.cs:24`, where the default `SupportedOperators` is `null`, meaning operator validation is skipped for custom strategies), and a `CanParseValue` predicate that defaults to `true` (`IFilterStrategy.cs:44`). That last member exists because `Apply` fails open: a strategy that cannot parse a value silently returns the query unfiltered, so `?filter=id:equals:abc` returned the whole result set instead of no matches (`IFilterStrategy.cs:32-38`). Validating the value up front turns that into a 400, and the default of `true` keeps a custom strategy behaving exactly as before until it opts in. -The seven built-ins each override `SupportedOperators` with a `FrozenSet`: [`StringFilterStrategy`](#stringfilterstrategy) (`StringFilterStrategy.cs:12`, operators at `StringFilterStrategy.cs:14-18`: `CONTAINS`, `NOT CONTAINS`, `EQUALS`, `NOT EQUALS`, `STARTS WITH`, `ENDS WITH`, `IS EMPTY`, `IS NOT EMPTY`, `IN`), [`IntFilterStrategy`](#intfilterstrategy) (`IntFilterStrategy.cs:15`), [`LongFilterStrategy`](#longfilterstrategy) (`LongFilterStrategy.cs:14`), and [`DecimalFilterStrategy`](#decimalfilterstrategy) (`DecimalFilterStrategy.cs:14`), which share one numeric set (equality, the four comparisons, `IN`, an inclusive `BETWEEN` range, and the two presence checks, at `IntFilterStrategy.cs:17-22` and its two siblings, all parsing invariant-culture), [`DateTimeFilterStrategy`](#datetimefilterstrategy) (`DateTimeFilterStrategy.cs:13`: `IS`, `IS NOT`, `IS AFTER`, `IS ON OR AFTER`, `IS BEFORE`, `IS ON OR BEFORE`, the two presence checks, `IN`, and `BETWEEN` at `DateTimeFilterStrategy.cs:17-22`, all parsed with `CultureInfo.InvariantCulture` at `DateTimeFilterStrategy.cs:15`), [`BoolFilterStrategy`](#boolfilterstrategy) (`BoolFilterStrategy.cs:12`: `IS` plus the two presence checks, `BoolFilterStrategy.cs:14-17`), and [`GuidFilterStrategy`](#guidfilterstrategy) (`GuidFilterStrategy.cs:13`: `EQUALS`, `NOT EQUALS`, `IN`, and the two presence checks at `GuidFilterStrategy.cs:15-18`; GUIDs have no ordering, so no comparisons). Every value-typed strategy implements `CanParseValue` by delegating to one shared rule (`IntFilterStrategy.cs:25`, `LongFilterStrategy.cs:24`, `DecimalFilterStrategy.cs:24`, `DateTimeFilterStrategy.cs:25`, `BoolFilterStrategy.cs:20`, `GuidFilterStrategy.cs:21`); `StringFilterStrategy` declares none, because any string parses. That shared rule lives in the internal [`FilterValueParser`](#filtervalueparser) (`FilterValueParser.cs:8`): `CanParse` (`FilterValueParser.cs:53`) says presence checks ignore the value, `IN` needs at least one parseable item, `BETWEEN` needs exactly two bounds, and every other operator needs the single scalar to parse (`FilterValueParser.cs:58-64`). `BETWEEN` gets its own stricter check (`FilterValueParser.cs:76`) that keeps empty and unparseable segments in play, because dropping them let `"5,abc,10"` and `"5,,10"` validate as a two-bound range and the strategies then applied a pair the caller never asked for (`FilterValueParser.cs:70-75`). The same class decodes the lists at apply time: `ParseList` skips unparseable entries rather than failing the request (`FilterValueParser.cs:17`, the `if (parse(part) is { } parsed)` guard at `FilterValueParser.cs:26`), and `ParseStringList` splits on comma, trimming and dropping empty entries (`FilterValueParser.cs:34`). +The seven built-ins each override `SupportedOperators` with a `FrozenSet`: [`StringFilterStrategy`](#stringfilterstrategy) (`StringFilterStrategy.cs:12`, operators at `StringFilterStrategy.cs:14-18`: `CONTAINS`, `NOT CONTAINS`, `EQUALS`, `NOT EQUALS`, `STARTS WITH`, `ENDS WITH`, `IS EMPTY`, `IS NOT EMPTY`, `IN`), the numeric trio [`IntFilterStrategy`](#intfilterstrategy) (`IntFilterStrategy.cs:15`), [`LongFilterStrategy`](#longfilterstrategy) (`LongFilterStrategy.cs:14`), and [`DecimalFilterStrategy`](#decimalfilterstrategy) (`DecimalFilterStrategy.cs:14`), which share one operator set (equality, the four comparisons, `IN`, an inclusive `BETWEEN` range, and the two presence checks, at `IntFilterStrategy.cs:17-22` and its two siblings, all parsing invariant-culture), [`DateTimeFilterStrategy`](#datetimefilterstrategy) (`DateTimeFilterStrategy.cs:13`: `IS`, `IS NOT`, `IS AFTER`, `IS ON OR AFTER`, `IS BEFORE`, `IS ON OR BEFORE`, the two presence checks, `IN`, and `BETWEEN` at `DateTimeFilterStrategy.cs:17-22`, parsed with `CultureInfo.InvariantCulture` at `DateTimeFilterStrategy.cs:15`), [`BoolFilterStrategy`](#boolfilterstrategy) (`BoolFilterStrategy.cs:12`: `IS` plus the two presence checks, `BoolFilterStrategy.cs:14-17`), and [`GuidFilterStrategy`](#guidfilterstrategy) (`GuidFilterStrategy.cs:13`: `EQUALS`, `NOT EQUALS`, `IN`, and the two presence checks at `GuidFilterStrategy.cs:15-18`; GUIDs have no ordering, so no comparisons). Every value-typed strategy implements `CanParseValue` by delegating to one shared rule (`IntFilterStrategy.cs:25`, `LongFilterStrategy.cs:24`, `DecimalFilterStrategy.cs:24`, `DateTimeFilterStrategy.cs:25`, `BoolFilterStrategy.cs:20`, `GuidFilterStrategy.cs:21`); `StringFilterStrategy` declares none, because any string parses. That shared rule lives in the internal [`FilterValueParser`](#filtervalueparser) (`FilterValueParser.cs:8`): `CanParse` (`FilterValueParser.cs:53`) says presence checks ignore the value, `IN` needs at least one parseable item, `BETWEEN` needs exactly two bounds, and every other operator needs the single scalar to parse (`FilterValueParser.cs:58-64`). `BETWEEN` gets its own stricter check (`FilterValueParser.cs:76`) that keeps empty and unparseable segments in play, because dropping them let `"5,abc,10"` and `"5,,10"` validate as a two-bound range and the strategies then applied a pair the caller never asked for (`FilterValueParser.cs:70-75`). The same class decodes the lists at apply time: `ParseList` skips unparseable entries rather than failing the request (`FilterValueParser.cs:17`, the `if (parse(part) is { } parsed)` guard at `FilterValueParser.cs:26`), and `ParseStringList` splits on comma, trimming and dropping empty entries (`FilterValueParser.cs:34`). -Every clause is built through **System.Linq.Dynamic.Core** string predicates with parameter placeholders (`@0`), never string-concatenated values, and every call site passes the one shared [`DynamicQueryConfig.Parameterized`](#dynamicqueryconfig) parsing config (`DynamicQueryConfig.cs:18`, the instance at `DynamicQueryConfig.cs:21-24`). That flag is not cosmetic: Dynamic LINQ defaults `UseParameterizedNamesInDynamicQuery` to `false`, which turns each `@0` into a `ConstantExpression` that EF inlines, so one filter value produced one distinct SQL string, one SQL Server plan-cache entry per value, and an EF compiled-query cache miss on every request (`DynamicQueryConfig.cs:8-15`). With the flag on the value is reached through a member access and EF parameterizes it, and `QueryParameterizationTests` (`MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/QueryParameterizationTests.cs:26`) is the regression guard, the only test in the suite that inspects the emitted SQL. This is [Rubric §12, Performance & Scalability] hiding inside a one-property config object. +Every clause is built through **System.Linq.Dynamic.Core** string predicates with parameter placeholders (`@0`), never string-concatenated values, and every call site passes the one shared [`DynamicQueryConfig.Parameterized`](#dynamicqueryconfig) parsing config (`DynamicQueryConfig.cs:18`, the instance at `DynamicQueryConfig.cs:21-24`). That flag is not cosmetic: Dynamic LINQ defaults `UseParameterizedNamesInDynamicQuery` to `false`, which turns each `@0` into a `ConstantExpression` that EF inlines, so one filter value produces one distinct SQL string, one SQL Server plan-cache entry per value, and an EF compiled-query cache miss on every request (`DynamicQueryConfig.cs:8-16`). With the flag on the value is reached through a member access and EF parameterizes it, and `QueryParameterizationTests` (`MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/QueryParameterizationTests.cs:26`) is the regression guard, the only test in the suite that inspects the emitted SQL. This is [Rubric §12, Performance & Scalability] hiding inside a one-property config object. -The static [`QueryFilterService`](#queryfilterservice) (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Filtering/QueryFilterService.cs:19`) is the registry and dispatcher. It seeds a `ConcurrentDictionary` with the built-ins, registering both the value type and its `Nullable<>` form (`QueryFilterService.cs:29-45`), keeps a dedicated string instance for string properties and for dotted paths whose leaf type cannot be resolved (`QueryFilterService.cs:52`, fallback at `QueryFilterService.cs:280-283`), and exposes `RegisterStrategy` so a module can add a custom type without touching framework code (`QueryFilterService.cs:60`, the open/closed principle made literal, [Rubric §1, SOLID]). Reflection is memoized per (entity type, property name) but **hits only** (`QueryFilterService.cs:27`, `LookupProperty` at `QueryFilterService.cs:235-246`): the probed names come from the client's query string, so caching misses would let any caller grow a never-evicted static dictionary simply by filtering on names that do not exist, while the request still gets a clean 400 (`QueryFilterService.cs:214-221`). One shared resolver, `ResolvePropertyInfo` (`QueryFilterService.cs:223`), backs both phases so they cannot disagree about what resolves; they used to, and a plain rename entry passed validation and was then silently dropped, returning an unfiltered 200 (`QueryFilterService.cs:208-213`). A dotted path like `"Category.Name"` is walked segment by segment to its leaf type by `ResolveFilterValueType` (`QueryFilterService.cs:259`), so the leaf's own strategy validates the operator instead of every nested path defaulting to the string strategy (`QueryFilterService.cs:153-157`). +The static [`QueryFilterService`](#queryfilterservice) (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Filtering/QueryFilterService.cs:19`) is the registry and dispatcher. It seeds a `ConcurrentDictionary` with the built-ins, registering both the value type and its `Nullable<>` form (`QueryFilterService.cs:29-45`), keeps a dedicated string instance for string properties and for dotted paths whose leaf type cannot be resolved (`QueryFilterService.cs:52`, fallback at `QueryFilterService.cs:280-283`), and exposes `RegisterStrategy` so a module can add a custom type without touching framework code (`QueryFilterService.cs:60`, the open/closed principle made literal, [Rubric §1, SOLID]). Reflection is memoized per (entity type, property name) but **hits only** (`QueryFilterService.cs:27`, `LookupProperty` at `QueryFilterService.cs:235`): the probed names come from the client's query string, so caching misses would let any caller grow a never-evicted static dictionary simply by filtering on names that do not exist, while the request still gets a clean 400 (`QueryFilterService.cs:214-221`). One shared resolver, `ResolvePropertyInfo` (`QueryFilterService.cs:223`), backs both phases so they cannot disagree about what resolves; when they did, a plain rename entry passed validation and was then silently dropped, returning an unfiltered 200 (`QueryFilterService.cs:208-213`). A dotted path like `"Category.Name"` is walked segment by segment to its leaf type by `ResolveFilterValueType` (`QueryFilterService.cs:259`), so the leaf's own strategy validates the operator instead of every nested path defaulting to the string strategy (`QueryFilterService.cs:153-157`). -The two phases and their ordering are the security story. `ValidateFilters` (`QueryFilterService.cs:111`) runs before the query and returns a [`Result`](group-01-result-error-handling.md#result) carrying every [`Error`](group-01-result-error-handling.md#error) it found: `Filter.Property.NotFound` (`QueryFilterService.cs:143-144`), `Filter.Type.NotSupported` (`QueryFilterService.cs:163-164`), `Filter.Operator.NotSupported` (`QueryFilterService.cs:295-296`), and `Filter.Value.Invalid` (`QueryFilterService.cs:196-197`), the last suppressed when the operator itself was already rejected so one mistake does not produce two errors (`QueryFilterService.cs:191`). A bad filter is therefore a validation failure, not a SQL exception and not a silently widened result set. `ApplyFilters` (`QueryFilterService.cs:76`) then builds the actual `.Where()` chain, resolving the DTO name through the property map first (`QueryFilterService.cs:84-86`) and skipping any property it cannot resolve (`QueryFilterService.cs:90-91`). Strategy dispatch plus allow-listing untrusted input against real entity metadata is [Rubric §2, Design Patterns] and [Rubric §11, Security] together. +The two phases and their ordering are the security story. `ValidateFilters` (`QueryFilterService.cs:111`) runs before the query and returns a [`Result`](group-01-result-error-handling.md#result) carrying every [`Error`](group-01-result-error-handling.md#error) it found: `Filter.Property.NotFound` (`QueryFilterService.cs:143-147`), `Filter.Type.NotSupported` (`QueryFilterService.cs:163-167`), `Filter.Operator.NotSupported` (`QueryFilterService.cs:295-299`), and `Filter.Value.Invalid` (`QueryFilterService.cs:196-200`), the last suppressed when the operator itself was already rejected so one mistake does not produce two errors (`QueryFilterService.cs:191-192`). A bad filter is therefore a validation failure, not a SQL exception and not a silently widened result set. `ApplyFilters` (`QueryFilterService.cs:76`) then builds the actual `.Where()` chain, resolving the DTO name through the property map first (`QueryFilterService.cs:84-86`) and skipping any property it cannot resolve (`QueryFilterService.cs:90-91`). Strategy dispatch plus allow-listing untrusted input against real entity metadata is [Rubric §2, Design Patterns] and [Rubric §11, Security] together. ## Sorting, sparse fieldsets, and paging arithmetic @@ -44,7 +44,7 @@ The two phases and their ordering are the security story. `ValidateFilters` (`Qu `ApplyFieldSelection` (`QueryFieldService.cs:229`) builds a `MemberInit` `Select` expression so a `fields=name,bio` request pulls only those columns from the database ([Rubric §12, Performance & Scalability]), restricted to writable properties because the projection needs setters (`QueryFieldService.cs:282-291`, the `CanWrite` filter at `QueryFieldService.cs:287`). The compiled lambda is cached per (entity type, normalized field set) (`QueryFieldService.cs:237`, cache at `QueryFieldService.cs:280`), and a `null` is cached on purpose to record "this field set projects nothing writable" so the miss is not recomputed per request (`QueryFieldService.cs:269-271`). `ShapeData` and `ShapeCollectionData` (`QueryFieldService.cs:75`, `QueryFieldService.cs:96`) produce the wire shape: an `ExpandoObject` (or a list of them) holding only the requested fields under camelCase keys. To make that cheap on large result sets the service caches a per-type array of [`PropertyAccessor`](#propertyaccessor) (`QueryFieldService.cs:42`), a private `readonly record struct` bundling each property's name, its precomputed camelCase key, and a compiled `Func` getter built with `Expression.Lambda(...).Compile()` rather than `PropertyInfo.GetValue` (`QueryFieldService.cs:46`, built at `QueryFieldService.cs:48-65`); the field-filtered subset is cached again per field set (`QueryFieldService.cs:465`, `QueryFieldService.cs:471`), under an order- and case-insensitive key so `name,id` and `Id, Name` share one entry (`QueryFieldService.cs:502-503`). -Both field-set caches are bounded, and the reason is the same one that shapes the filter cache. Their key is half client-supplied, so an entity with N properties admits up to 2^N distinct subsets and a caller could grow either dictionary by permuting the list (`QueryFieldService.cs:18-38`). The cap is a `const int MaxCacheEntries = 512` per cache (`QueryFieldService.cs:39`), with deliberately no LRU: past the cap `ApplyFieldSelection` skips server-side projection rather than admitting another key (`QueryFieldService.cs:248-249`, and the response is unchanged because shaping still trims the payload), and `GetShapedAccessors` filters per request instead (`QueryFieldService.cs:489-490`), which is a scan over an already-compiled accessor array rather than an expression rebuild. Validation mirrors the filter side: `Validate` rejects unknown field names and (when shaping) read-only properties (`QueryFieldService.cs:317` and the map-aware overload at `QueryFieldService.cs:352`, shared body at `QueryFieldService.cs:362`), and `ValidateSortDirection` accepts only `asc` or `desc` (`QueryFieldService.cs:415`). Paging arithmetic is small enough to look trivial and is not, which is why it has its own type: [`PagingMath.Clamp`](#pagingmath) (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/PagingMath.cs:32`) clamps the page size into `[1, maxPageSize]` and the page number to at least 1 (`PagingMath.cs:37-38`), computes the offset in 64-bit (`PagingMath.cs:40`), and returns `(0, 0)` for a page beyond the reachable offset range, materializing the empty page that page genuinely holds (`PagingMath.cs:42`). A 32-bit `(pageNumber - 1) * pageSize` overflows and wraps negative near `int.MaxValue`, and SQL Server rejects a negative `OFFSET` outright, so the request surfaced as a 500 instead of an empty page (`PagingMath.cs:7-12`). Every paginating caller routes through here rather than open-coding the multiply, because the arithmetic previously lived only inside the pipeline and the handlers that paginate their own queryable each re-derived it in 32-bit (`PagingMath.cs:14-17`). +Both field-set caches are bounded, and the reason is the same one that shapes the filter cache. Their key is half client-supplied, so an entity with N properties admits up to 2^N distinct subsets and a caller could grow either dictionary by permuting the list (`QueryFieldService.cs:18-38`). The cap is a `const int MaxCacheEntries = 512` per cache (`QueryFieldService.cs:39`), with deliberately no LRU: past the cap `ApplyFieldSelection` skips server-side projection rather than admitting another key (`QueryFieldService.cs:248-249`, and the response is unchanged because shaping still trims the payload), and `GetShapedAccessors` filters per request instead (`QueryFieldService.cs:489-490`), which is a scan over an already-compiled accessor array rather than an expression rebuild. Validation mirrors the filter side: `Validate` rejects unknown field names and (when shaping) read-only properties (`QueryFieldService.cs:317` and the map-aware overload at `QueryFieldService.cs:352`, shared body at `QueryFieldService.cs:362`), and `ValidateSortDirection` accepts only `asc` or `desc` (`QueryFieldService.cs:415`). Paging arithmetic is small enough to look trivial and is not, which is why it has its own type: [`PagingMath.Clamp`](#pagingmath) (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/PagingMath.cs:32`) clamps the page size into `[1, maxPageSize]` and the page number to at least 1 (`PagingMath.cs:37-38`), computes the offset in 64-bit (`PagingMath.cs:40`), and returns `(0, 0)` for a page beyond the reachable offset range, materializing the empty page that page genuinely holds (`PagingMath.cs:42`). A 32-bit `(pageNumber - 1) * pageSize` overflows and wraps negative near `int.MaxValue`, and SQL Server rejects a negative `OFFSET` outright, so the request surfaced as a 500 instead of an empty page (`PagingMath.cs:8-12`). Every paginating caller routes through here rather than open-coding the multiply, because the arithmetic previously lived only inside the pipeline and the handlers that paginate their own queryable each re-derived it in 32-bit (`PagingMath.cs:14-17`). ## The pipeline: two entity paths plus projection pushdown @@ -52,7 +52,7 @@ Both field-set caches are bounded, and the reason is the same one that shapes th `ExecuteAsync` (`EntityQueryPipeline.cs:39`) runs a shared front half, `ApplyIncludesCriteriaAndFilters` (`EntityQueryPipeline.cs:119`): add every supported navigation as an `.Include()` (`EntityQueryPipeline.cs:133-134`), force `AsSplitQuery()` when a child collection is among them (`EntityQueryPipeline.cs:140-141`), then apply the specification criteria and the dynamic filters **before** materializing anything (`EntityQueryPipeline.cs:147-151`) so the data source does as much of the work as possible. The comment above the split-query switch records the hard-won reason, annotated `R24/§8`: paginating a single-query collection include truncates child rows because EF applies `Skip`/`Take` to the JOIN-expanded set, so list reads returned empty child collections while by-id reads worked (`EntityQueryPipeline.cs:136-139`). It then branches on whether any requested navigation is unsupported (`EntityQueryPipeline.cs:53`). **Path 1, server-side includes** (`EntityQueryPipeline.cs:216`): sort (`EntityQueryPipeline.cs:227`), count before paging (`EntityQueryPipeline.cs:240`), `Skip`/`Take` (`EntityQueryPipeline.cs:241`), field-selection `Select` (`EntityQueryPipeline.cs:251`), materialize (`EntityQueryPipeline.cs:252`). **Path 2, manual navigation** (`EntityQueryPipeline.cs:161`), taken when a requested navigation crosses a physical data source and cannot be joined: sort and page at the database first (`EntityQueryPipeline.cs:175`, `EntityQueryPipeline.cs:187`), materialize the page (`EntityQueryPipeline.cs:196`), then invoke the `navigationPopulator` callback to batch-load those navigations in a second query (`EntityQueryPipeline.cs:199`), the [`INavigationPopulator`](group-11-navigation-populators.md#inavigationpopulatorin-tentity) extension point of [ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html), and apply field selection in memory afterwards (`EntityQueryPipeline.cs:208`). Both paths pass the entity key as the sort tie-break, and **only** when the read is paginated (`EntityQueryPipeline.cs:36`, passed at `EntityQueryPipeline.cs:180` and `EntityQueryPipeline.cs:232`): an unpaginated read materializes one capped set in one statement, so it cannot suffer the split-across-pages incoherence, and adding an `ORDER BY` there would charge every unsorted list read for a sort nobody asked for (`EntityQueryPipeline.cs:30-35`). -`ExecuteProjectedAsync` (`EntityQueryPipeline.cs:60`) is the third path and the newest: for a read whose result type has a registered [`IEntityDTOProjector`](group-05-cqrs-pipeline.md#ientitydtoprojectortentity-tentitydto-tidentifiertype), criteria, filters, sorting, and paging all run over entity rows (`EntityQueryPipeline.cs:73-95`) and the projection is applied **last** (`EntityQueryPipeline.cs:105`), so the provider pages exactly the rows it means to and selects only that page's columns. Nothing is materialized as an entity and no mapper runs. It handles server-side navigations only: there is no populator hook, because a projection cannot be post-processed row by row, so a query with cross-source includes must use `ExecuteAsync` instead, and navigation includes are not applied here at all because the projection itself decides what the provider joins and selects (`IEntityQueryPipeline.cs:37-49`, restated at `EntityQueryPipeline.cs:101-104`). +`ExecuteProjectedAsync` (`EntityQueryPipeline.cs:60`) is the third path: for a read whose result type has a registered [`IEntityDTOProjector`](group-05-cqrs-pipeline.md#ientitydtoprojectortentity-tentitydto-tidentifiertype), criteria, filters, sorting, and paging all run over entity rows (`EntityQueryPipeline.cs:73-94`) and the projection is applied **last** (`EntityQueryPipeline.cs:105`), so the provider pages exactly the rows it means to and selects only that page's columns. Nothing is materialized as an entity and no mapper runs. It handles server-side navigations only: there is no populator hook, because a projection cannot be post-processed row by row, so a query with cross-source includes must use `ExecuteAsync` instead, and navigation includes are not applied here at all because the projection itself decides what the provider joins and selects (`IEntityQueryPipeline.cs:43-48`, restated at `EntityQueryPipeline.cs:101-104`). All three paths share one [Rubric §12, Performance & Scalability] safety ceiling: an unpaginated query is capped at `MaxUnboundedResultLimit`, a public `const int` of 1000 (`EntityQueryPipeline.cs:23`, applied at `EntityQueryPipeline.cs:98`, `EntityQueryPipeline.cs:193`, and `EntityQueryPipeline.cs:247`), and a paginated call has its page size clamped to that same ceiling inside `ApplyPaging`, which delegates the offset arithmetic to `PagingMath.Clamp` (`EntityQueryPipeline.cs:271-280`). A direct service caller who forgets or oversizes paging therefore can never trigger an unbounded full-table load. The reported total for an unpaginated read is not simply the materialized count: `CountUnpaginatedAsync` (`EntityQueryPipeline.cs:288`) returns the materialized count only while it stays under the ceiling and issues a real `COUNT` otherwise (`EntityQueryPipeline.cs:292-294`), because at the cap the materialized number is the cap itself and reporting it told callers the set was exactly 1000 rows (`EntityQueryPipeline.cs:283-287`). Which navigations are eligible, and which path each takes, is decided by [`NavigationMetadataProvider`](#navigationmetadataprovider) (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/NavigationMetadataProvider.cs:20`) behind [`INavigationMetadataProvider`](#inavigationmetadataprovider) (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/INavigationMetadataProvider.cs:9`). `BuildIncludes` asks separately for FK references and child collections (`NavigationMetadataProvider.cs:31`), and the classifier reflects over the entity's public properties looking for [`NavigationAttribute`](group-11-navigation-populators.md#navigationattribute) (`NavigationMetadataProvider.cs:74`), unwraps `ICollection` / `IReadOnlyCollection` to find the target entity (`NavigationMetadataProvider.cs:106`), and asks [`IDataSourceService`](group-07-persistence-ef-core.md#idatasourceservice) whether the two ends share a JOIN-capable source, sorting each [`NavigationPropertyInfo`](group-11-navigation-populators.md#navigationpropertyinfo) into the supported or unsupported bucket of [`NavigationMetadata`](group-11-navigation-populators.md#navigationmetadata) (`NavigationMetadataProvider.cs:96-99`). Results are cached per (entity type, [`NavigationType`](group-11-navigation-populators.md#navigationtype)) in an **instance-level** dictionary, not a static one, precisely so that a process hosting more than one data-source configuration (integration tests, for example) cannot share classifications across hosts (`NavigationMetadataProvider.cs:28`, rationale at `NavigationMetadataProvider.cs:22-27`). @@ -60,17 +60,19 @@ All three paths share one [Rubric §12, Performance & Scalability] safety ceilin [`IEntityQueryService`](#ientityqueryservicetentity-tentitydto-tidentifiertype) (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEntityQueryService.cs:19`) and its concrete [`EntityQueryService`](#entityqueryservicetentity-tentitydto-tidentifiertype) (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:31`) are what controllers and handlers inject. The service is constructed from [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), the metadata provider, the pipeline, an [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) ([ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html)), and an `INavigationPopulator` (`EntityQueryService.cs:31-36`), and it resolves its [`IReadRepository`](group-07-persistence-ef-core.md#ireadrepositorytentity-tidentifiertype) from the unit of work through a `virtual` property (`EntityQueryService.cs:87`). A second, six-argument constructor adds the optional `IEntityDTOProjector` (`EntityQueryService.cs:69-77`): it is a second constructor rather than an optional parameter because the Microsoft DI container has no notion of an optional dependency, so with two overloads it picks the longer one when a projector is registered and the shorter one when it is not (`EntityQueryService.cs:51-61`). -`GetAllAsync` (`EntityQueryService.cs:248`, with a six-parameter convenience overload at `EntityQueryService.cs:227`) is the four-step orchestration. **(1) Validate** every parameter up front with `Result.Combine` over the fields, sort-column, sort-direction, and filter validators, so a bad `fields` fails before any database hit (`EntityQueryService.cs:262-267`), re-stamping each error with the operation and entity name (`EntityQueryService.cs:270-276`). **(2) Build the query**: ask the metadata provider which includes are supported (`EntityQueryService.cs:282`), pack everything into `EntityQueryParameters` (`EntityQueryService.cs:284-296`), and pick `Repository.Table` or `TableNoTracking` from the `asTracking` flag (`EntityQueryService.cs:298`). **(3) Execute** on one of two branches, chosen by `CanProject` (`EntityQueryService.cs:489-492`): a registered projector, no tracking request, and no cross-source includes routes to `ExecuteProjectedAsync` and the mapper is never involved (`EntityQueryService.cs:303-312`); otherwise `ExecuteAsync` runs and `DTOMapper.MapToDTOs` converts the materialized entities (`EntityQueryService.cs:316-324`). Field shaping deliberately does not disqualify projection, because shaping runs after materialization over whatever object the pipeline produced (`EntityQueryService.cs:484-487`). **(4) Shape and wrap**: shape **only when a field subset was requested**, otherwise return the typed DTOs as-is to avoid a per-row `ExpandoObject` allocation and boxing (`EntityQueryService.cs:334-336`); both forms serialize to the same camelCase JSON, which is why the return type is `PagedCollectionResult` rather than a typed collection ([`PagedCollectionResult`](group-01-result-error-handling.md#pagedcollectionresultt), and the contract note at `IEntityQueryService.cs:12-14`). The [`PaginationMetadata`](group-01-result-error-handling.md#paginationmetadata) comes from `BuildPaginationMetadata` (`EntityQueryService.cs:332`, method at `EntityQueryService.cs:555`), whose job is to describe what the pipeline actually did rather than what the caller asked for: an unpaginated call reports the true total with the page size floored to the rows actually returnable, `Math.Min(total, MaxUnboundedResultLimit)`, on page 1 (`EntityQueryService.cs:569-572`), and a paginated call reports `Math.Clamp(pageSize, 1, MaxUnboundedResultLimit)` on `Math.Max(pageNumber, 1)` (`EntityQueryService.cs:579-582`), mirroring exactly the floor and ceiling `PagingMath` applied. The clamp is recomputed here rather than read back from `PagingMath`, because that helper's `(0, 0)` sentinel for an unreachable page would otherwise advertise `PageSize = 0` for a perfectly valid page size (`EntityQueryService.cs:548-553`). +`GetAllAsync` (`EntityQueryService.cs:248`, with a six-parameter convenience overload at `EntityQueryService.cs:227`) is the four-step orchestration. **(1) Validate** every parameter up front with `Result.Combine` over the fields, sort-column, sort-direction, and filter validators, so a bad `fields` fails before any database hit (`EntityQueryService.cs:262-267`), re-stamping each error with the operation and entity name (`EntityQueryService.cs:270-276`). **(2) Build the query**: ask the metadata provider which includes are supported (`EntityQueryService.cs:282`), pack everything into `EntityQueryParameters` (`EntityQueryService.cs:284-296`), and pick `Repository.Table` or `TableNoTracking` from the `asTracking` flag (`EntityQueryService.cs:298`). **(3) Execute** on one of two branches, chosen by `CanProject` (`EntityQueryService.cs:489-492`): a registered projector, no tracking request, and no cross-source includes routes to `ExecuteProjectedAsync` and the mapper is never involved (`EntityQueryService.cs:303-313`); otherwise `ExecuteAsync` runs and `DTOMapper.MapToDTOs` converts the materialized entities (`EntityQueryService.cs:316-324`). Field shaping deliberately does not disqualify projection, because shaping runs after materialization over whatever object the pipeline produced (`EntityQueryService.cs:484-487`). **(4) Shape and wrap**: shape **only when a field subset was requested**, otherwise return the typed DTOs as-is to avoid a per-row `ExpandoObject` allocation and boxing (`EntityQueryService.cs:334-336`); both forms serialize to the same camelCase JSON, which is why the return type is `PagedCollectionResult` rather than a typed collection ([`PagedCollectionResult`](group-01-result-error-handling.md#pagedcollectionresultt), and the contract note at `IEntityQueryService.cs:12-14`). The [`PaginationMetadata`](group-01-result-error-handling.md#paginationmetadata) comes from `BuildPaginationMetadata` (`EntityQueryService.cs:332`, method at `EntityQueryService.cs:555`), whose job is to describe what the pipeline actually did rather than what the caller asked for: an unpaginated call reports the true total with the page size floored to the rows actually returnable, `Math.Min(total, MaxUnboundedResultLimit)`, on page 1 (`EntityQueryService.cs:569-572`), and a paginated call reports `Math.Clamp(pageSize, 1, MaxUnboundedResultLimit)` on `Math.Max(pageNumber, 1)` (`EntityQueryService.cs:579-582`), mirroring exactly the floor and ceiling `PagingMath` applied. The clamp is recomputed here rather than read back from `PagingMath`, because that helper's `(0, 0)` sentinel for an unreachable page would otherwise advertise `PageSize = 0` for a perfectly valid page size (`EntityQueryService.cs:548-553`). -The by-id path has a fast lane worth knowing. `GetEntityByIdAsync` (`EntityQueryService.cs:376`) validates the fields (`EntityQueryService.cs:386`), then tries `TryGetByIdFastPathAsync` (`EntityQueryService.cs:119`). `TryGetFastPathIncludes` (`EntityQueryService.cs:161`) decides eligibility: a field projection, a specification, or a non-default `idField` disqualifies the request (`EntityQueryService.cs:171-176`), and so do unsupported (cross-source) navigations, since only the pipeline's populator can batch-load those (`EntityQueryService.cs:184-187`, rationale at `EntityQueryService.cs:155-159`). Requested includes do **not** disqualify it: the repository's include overload applies the same `Include` calls and auto-applies `AsSplitQuery` for a child collection (`EFReadRepository.cs:185-198`, delegating the split decision to `SpecificationEvaluator.cs:93`), and disqualifying on includes left the fast path unreachable for every entity that declares a navigation, because the REST by-id action defaults `includeFKs` to true (`EntityQueryService.cs:146-153`). The string id is converted with a `TypeConverter` cached per identifier type (`EntityQueryService.cs:198`, cache at `EntityQueryService.cs:107`), and the read runs on the filtered `TableNoTracking` (`EFReadRepository.cs:194`), so soft-delete query filters still apply, unlike `FindAsync` (`EntityQueryService.cs:113-117`, and the same trap documented at `EFReadRepository.cs:176-180`). Anything else falls through to the pipeline with a synthetic `Id EQUALS ` filter (`EntityQueryService.cs:408-411`) and returns `Error.NotFound` when the page is empty (`EntityQueryService.cs:427-430`). `GetByIdAsync` (`EntityQueryService.cs:437`) layers DTO mapping and the same shape-only-if-fields rule on top (`EntityQueryService.cs:461-463`); `GetAllForLookupAsync` (`EntityQueryService.cs:348`) returns lightweight [`BaseLookup`](group-12-api-hosting-mapping.md#baselookuptidentifiertype) id/name pairs for dropdowns; `ExistsAsync` (`EntityQueryService.cs:467`) delegates straight to the repository. The class is built for extension over modification ([Rubric §1, SOLID]): `Repository` (`EntityQueryService.cs:87`), `DTOToEntityPropertyMap` (`EntityQueryService.cs:100`), and every query method are `virtual`, so a module subclass such as [`SpeakerEntityQueryService`](group-18-conference-application.md#speakerentityqueryservice) (`SpeakerEntityQueryService.cs:15`) overrides one behavior (`SpeakerEntityQueryService.cs:34`) without reimplementing the engine. +The by-id path has a fast lane worth knowing. `GetEntityByIdAsync` (`EntityQueryService.cs:376`) validates the fields, then tries `TryGetByIdFastPathAsync` (`EntityQueryService.cs:119`), which issues a single keyed `TOP 1 WHERE Id = @id` through the repository's include overload (`EntityQueryService.cs:135`). `TryGetFastPathIncludes` (`EntityQueryService.cs:161`) decides eligibility: a field projection, a specification, or a non-default `idField` disqualifies the request (`EntityQueryService.cs:171-176`), and so do unsupported (cross-source) navigations, since only the pipeline's populator can batch-load those (`EntityQueryService.cs:184-187`, rationale at `EntityQueryService.cs:155-159`). Requested includes do **not** disqualify it: the repository's include overload applies the same `Include` calls and auto-applies `AsSplitQuery` for a child collection (`EFReadRepository.cs:185-198`, delegating the split decision to `SpecificationEvaluator.cs:93`), and disqualifying on includes left the fast path unreachable for every entity that declares a navigation, because the REST by-id action defaults `includeFKs` to true (`EntityQueryService.cs:147-153`). The string id is converted with a `TypeConverter` cached per identifier type (`EntityQueryService.cs:198`, cache at `EntityQueryService.cs:107`), and the read runs on the filtered `TableNoTracking` (`EFReadRepository.cs:194`), so soft-delete query filters still apply, unlike `FindAsync` (`EntityQueryService.cs:113-117`, and the same trap documented at `EFReadRepository.cs:176-180`). Anything else falls through to the pipeline with a synthetic `Id EQUALS ` filter and returns `Error.NotFound` when the page comes back empty. `GetByIdAsync` (`EntityQueryService.cs:437`) layers DTO mapping and the same shape-only-if-fields rule on top; `GetAllForLookupAsync` (`EntityQueryService.cs:348`) returns lightweight [`BaseLookup`](group-12-api-hosting-mapping.md#baselookuptidentifiertype) id/name pairs for dropdowns; `ExistsAsync` (`EntityQueryService.cs:467`) delegates straight to the repository. The class is built for extension over modification ([Rubric §1, SOLID]): `Repository` (`EntityQueryService.cs:87`), `DTOToEntityPropertyMap` (`EntityQueryService.cs:100`), and every query method are `virtual`, so a module subclass such as [`SpeakerEntityQueryService`](group-18-conference-application.md#speakerentityqueryservice) (`SpeakerEntityQueryService.cs:15`) overrides one behavior (`SpeakerEntityQueryService.cs:34`) without reimplementing the engine. ## End to end, one list request -The request reaches a read controller, [`EntityControllerBase`](group-12-api-hosting-mapping.md#entitycontrollerbasetentity-tentitydto-tidentifiertype) (Group 12), which resolves `MaxPageSize` per request from [`IApplicationSettings`](group-14-module-system-composition.md#iapplicationsettings), falling back to 500 when unset (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:54-62`), clamps the requested page size to it (`EntityControllerBase.cs:155`), binds `?filter=` through `QueryFilterModelBinder` (`EntityControllerBase.cs:152`), and may supply a server-authored specification for authorization scope. It calls `IEntityQueryService.GetAllAsync`. The service validates fields, sort, and filters (an early failure short-circuits to an error result), classifies the requested includes, and packages an `EntityQueryParameters`. `EntityQueryPipeline` then takes the projection path when a projector is registered and the read qualifies, or one of the two entity paths otherwise, applying the specification criteria plus the dynamic filters as translated, parameterized `WHERE` clauses, sorting with the key tie-break when paginating, counting, paging through `PagingMath`, projecting the requested columns, materializing, and batch-loading any cross-source navigations. The service maps to DTOs (or skips mapping entirely on the projected path), shapes only if a field subset was asked for, and returns a `Result>` that the controller unwraps into the HTTP body plus an `X-Pagination` header carrying the serialized metadata (`EntityControllerBase.cs:172`). One pipeline, every entity, validated input, server-side execution, and a clean extension point for navigations that cross a service boundary ([Rubric §6, CQRS & Event-Driven] on the read side, [Rubric §9, API & Contract Design] for the uniform query contract). +The request reaches a read controller, [`EntityControllerBase`](group-12-api-hosting-mapping.md#entitycontrollerbasetentity-tentitydto-tidentifiertype) (Group 12), which resolves `MaxPageSize` per request from [`IApplicationSettings`](group-14-module-system-composition.md#iapplicationsettings), falling back to 500 when unset (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:57-62`), clamps the requested page size to it (`EntityControllerBase.cs:155`), binds `?filter=` through `QueryFilterModelBinder` (`EntityControllerBase.cs:152`), and may supply a server-authored specification for authorization scope. It calls `IEntityQueryService.GetAllAsync`. The service validates fields, sort, and filters (an early failure short-circuits to an error result), classifies the requested includes, and packages an `EntityQueryParameters`. `EntityQueryPipeline` then takes the projection path when a projector is registered and the read qualifies, or one of the two entity paths otherwise, applying the specification criteria plus the dynamic filters as translated, parameterized `WHERE` clauses, sorting with the key tie-break when paginating, counting, paging through `PagingMath`, projecting the requested columns, materializing, and batch-loading any cross-source navigations. The service maps to DTOs (or skips mapping entirely on the projected path), shapes only if a field subset was asked for, and returns a `Result>` that the controller unwraps into the HTTP body plus an `X-Pagination` header carrying the serialized metadata (`EntityControllerBase.cs:172`). One pipeline, every entity, validated input, server-side execution, and a clean extension point for navigations that cross a service boundary ([Rubric §6, CQRS & Event-Driven] on the read side, [Rubric §9, API & Contract Design] for the uniform query contract). -## Also filed here: the best-effort side-effect helper +## Also filed here: best-effort dispatch and the upcaster registry -Three types in this group are not part of the read path at all; they are co-located in `MMCA.Common.Application/Services` and are grouped by that folder. [`BestEffort`](#besteffort) (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/BestEffort.cs:25`) runs a side effect that must never fail its caller (cache eviction after a committed command, a fire-and-forget notification, an eviction broadcast onto the bus): `ExecuteAsync` awaits the action and turns any non-cancellation failure into exactly one Warning plus one metric increment instead of an exception that would roll back or 500 an operation whose real work already succeeded (`BestEffort.cs:45`, the swallow at `BestEffort.cs:65-71`). Cancellation is explicitly **not** swallowed: when the caller's own token is the reason the action stopped, the `OperationCanceledException` is rethrown so a host shutdown unwinds promptly (`BestEffort.cs:59-64`, rationale at `BestEffort.cs:11-17`). The two companions carry the telemetry: [`BestEffortLog`](#besteffortlog) (`BestEffort.cs:79`) is the source-generated Warning message, kept separate so the public helper need not be `partial` (`BestEffort.cs:81-84`), and [`BestEffortMetrics`](#besteffortmetrics) (`BestEffort.cs:99`) owns the `MMCA.Common.BestEffort` meter and its `besteffort.dispatch.failed` counter, tagged by a low-cardinality `operation` name (`BestEffort.cs:102-115`). It is its own meter rather than a counter folded into the CQRS metrics so an operator can drop or keep it independently of the RED metrics (`BestEffort.cs:92-97`). Callers span both apps: Store's output-cache eviction (`MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/OutputCacheEvictionExtensions.cs:36`) and ADC's live broadcasts and cache-eviction handlers (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/DomainEventHandlers/UserSessionBookmarkCacheEvictionHandler.cs:68`, `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Live/LiveChannelPublishProcessor.cs:45`). This is [Rubric §13, Observability & Operability] (a quietly broken side effect becomes a metric, not a line in a log nobody reads) and [Rubric §29, Resilience & Business Continuity] (a non-essential failure degrades instead of propagating). +Four types in this group are not part of the read path at all; they are co-located in `MMCA.Common.Application/Services` and are grouped by that folder. [`BestEffort`](#besteffort) (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/BestEffort.cs:25`) runs a side effect that must never fail its caller (cache eviction after a committed command, a fire-and-forget notification, an eviction broadcast onto the bus): `ExecuteAsync` awaits the action and turns any non-cancellation failure into exactly one Warning plus one metric increment instead of an exception that would roll back or 500 an operation whose real work already succeeded (`BestEffort.cs:45`, the swallow at `BestEffort.cs:65-71`). Cancellation is explicitly **not** swallowed: when the caller's own token is the reason the action stopped, the `OperationCanceledException` is rethrown so a host shutdown unwinds promptly (`BestEffort.cs:59-64`, rationale at `BestEffort.cs:11-17`). The two companions carry the telemetry: [`BestEffortLog`](#besteffortlog) (`BestEffort.cs:79`) is the source-generated Warning message, kept separate so the public helper need not be `partial` (`BestEffort.cs:81-84`), and [`BestEffortMetrics`](#besteffortmetrics) (`BestEffort.cs:99`) owns the `MMCA.Common.BestEffort` meter and its `besteffort.dispatch.failed` counter, tagged by a low-cardinality `operation` name (`BestEffort.cs:102-115`). It is its own meter rather than a counter folded into the CQRS metrics so an operator can drop or keep it independently of the RED metrics (`BestEffort.cs:92-97`). Callers span both apps: Store's output-cache eviction (`MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/OutputCacheEvictionExtensions.cs:36`) and ADC's live broadcasts and cache-eviction handlers (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/DomainEventHandlers/UserSessionBookmarkCacheEvictionHandler.cs:68`, `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Live/LiveChannelPublishProcessor.cs:45`). This is [Rubric §13, Observability & Operability] (a quietly broken side effect becomes a metric, not a line in a log nobody reads) and [Rubric §29, Resilience & Business Continuity] (a non-essential failure degrades instead of propagating). + +The fourth co-located type is `EventUpcasterRegistry` (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/EventUpcasterRegistry.cs:30`), the default `IEventUpcasterRegistry`, which belongs to the integration-event story rather than to querying. It indexes every registered `IEventUpcaster` by its source contract and rejects a duplicate source, a self-mapping upcaster, or a chain cycle at construction time, throwing an `InvalidOperationException` that names the offenders (`EventUpcasterRegistry.cs:50-79`); it precomputes each chain's terminal type once, because the graph is static after DI is built (`EventUpcasterRegistry.cs:133`); and it preserves the event envelope across every hop by stamping `MessageId` and `DateOccurred` from the pre-hop instance onto the upcasted one through cached `PropertyInfo` handles (`EventUpcasterRegistry.cs:36`, `EventUpcasterRegistry.cs:169`), so consumer-side inbox deduplication stays keyed on the id the producer published. ### BestEffortLog > MMCA.Common.Application · `MMCA.Common.Application.Services` · `MMCA.Common/Source/Core/MMCA.Common.Application/Services/BestEffort.cs:79` · Level 0 · class (internal, static, partial) @@ -85,7 +87,7 @@ Three types in this group are not part of the read path at all; they are co-loca message template on every call; the `[LoggerMessage]` attribute instead makes the compiler emit a strongly-typed, allocation-free `DispatchFailed` method with the template pre-parsed and the event wired up once. The type has to be `partial` for the generator to add the body, which is exactly why - it is a separate companion class: the doc comment (`BestEffort.cs:76-78`) records that the reason is + it is a separate companion class: the doc comment (`BestEffort.cs:76-77`) records that the reason is to keep the public [`BestEffort`](#besteffort) helper from having to be `partial` itself. - **Walkthrough**: one member. `[LoggerMessage(Level = LogLevel.Warning, Message = "Best-effort operation '{Operation}' failed and @@ -228,7 +230,7 @@ Three types in this group are not part of the read path at all; they are co-loca passed `CancellationToken.None`, not the request token. Store's output-cache eviction does exactly that, with the reason in a comment: the write has committed, so a client that disconnected mid-response must not abandon the cache cleanup - (`MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/OutputCacheEvictionExtensions.cs:33-41`). + (`MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/OutputCacheEvictionExtensions.cs:34-40`). Keeping `operation` a low-cardinality constant is the other obligation, because it becomes a metric tag (`BestEffort.cs:20-22`). - **Where it's used**: ADC Engagement's real-time and cache-eviction paths, all of which broadcast or @@ -245,6 +247,118 @@ Three types in this group are not part of the read path at all; they are co-loca --- +### EventUpcasterRegistry +> MMCA.Common.Application · `MMCA.Common.Application.Services` · `MMCA.Common/Source/Core/MMCA.Common.Application/Services/EventUpcasterRegistry.cs:30` · Level 3 · class (public, sealed) + +- **What it is**: the default + [`IEventUpcasterRegistry`](group-05-cqrs-pipeline.md#ieventupcasterregistry). It indexes every + registered [`IEventUpcaster`](group-05-cqrs-pipeline.md#ieventupcaster) by the contract it consumes, + precomputes where each upcast chain ends, and walks an incoming integration event forward to that + terminal contract while keeping the envelope the producer stamped. +- **Depends on**: [`IEventUpcasterRegistry`](group-05-cqrs-pipeline.md#ieventupcasterregistry) (the + interface it implements), [`IEventUpcaster`](group-05-cqrs-pipeline.md#ieventupcaster), + [`IIntegrationEvent`](group-04-events-outbox.md#iintegrationevent), + [`IDomainEvent`](group-04-events-outbox.md#idomainevent) (only for the `nameof` of the two envelope + properties); `System.Collections.Concurrent` (`ConcurrentDictionary`), `System.Reflection` + (`PropertyInfo`) from the BCL. +- **Concept introduced, upcasting a retired event contract.** `[Rubric §6, CQRS & Event-Driven]` + assesses how well the event model handles change over time, and `[Rubric §7, Microservices + Readiness]` assesses whether producers and consumers can deploy independently. Both meet here. Once + an integration event has been published, its shape is a contract: a producer that has moved to + `FooV2` cannot assume every consumer redeployed at the same moment, and a queue can still hold `Foo` + messages written before the change. Upcasting resolves that without a flag day. An author registers + a small mapper that converts `Foo` into `FooV2` + (`services.AddEventUpcaster()`, + `MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:283-290`), and this registry + converts on the way in so that exactly one handler shape exists in the codebase, the newest one. + Registrations compose: V1 to V2 plus V2 to V3 delivers a V1 message to the V3 handler + (`DependencyInjection.cs:271`). + `[Rubric §15, Best Practices & Code Quality]` covers the failure model: a bad registration graph is + a programming error, so it throws at construction naming the offenders instead of returning a + [`Result`](group-01-result-error-handling.md#result); the class remarks (`:14-20`) call that the + permission-registry precedent, and note that because the graph is static once DI is built, terminal + types are resolved once here rather than per message. +- **Walkthrough** + - **State.** `EnvelopeProperties` + (`static ConcurrentDictionary`, + `:36`) caches the two writable envelope handles per upcast target type, so a chain pays the + reflection lookup once per contract for the life of the process. `_bySourceType` + (`Dictionary`, `:38`) is the index the walk follows, and `_terminalTypes` + (`Dictionary`, `:39`) is the precomputed answer to "where does this chain end". + - **Constructor** (`:50-82`). After `ArgumentNullException.ThrowIfNull(upcasters)` (`:52`) it makes + one pass over the registrations, collecting *all* offenders instead of failing on the first: an + upcaster whose `SourceType == TargetType` is rejected as mapping a type onto itself (`:59-63`), a + second upcaster claiming an already-claimed source is rejected as a duplicate naming both + contenders (`:65-69`), and anything else is indexed (`:71`). If the offender list is non-empty it + throws one `InvalidOperationException` joining every message and restating the rule, "exactly one + upcaster may claim a source contract, and it must produce a different one" (`:74-79`). Only then + is `BuildTerminalTypes` run (`:81`), so cycle detection sees an already-validated graph. + - `BuildTerminalTypes` (`:133-161`) walks each source forward, carrying a `visited` set (`:139`) and + a `chain` list for the error message (`:140`). Each hop advances by the upcaster's `TargetType` + (`:145`); a type that fails to enter `visited` means the ladder came back on itself, and the throw + renders the whole chain as `A -> B -> C -> A` (`:148-154`). The doc (`:125-129`) explains why a + repeat is unambiguously a cycle rather than a diamond: the constructor already rejected duplicate + sources, so the graph is functional (one outgoing edge per node). The final type reached is stored + as that source's terminal (`:157`). + - `HasUpcasterFor(Type)` (`:85-90`) is a plain containment check on `_bySourceType`, and + `ResolveTerminalType(Type)` (`:93-98`) is a dictionary read that **falls back to the type itself** + (`:97`), so an unregistered contract is its own terminal. That identity behavior is what lets the + rest of the framework depend on the registry unconditionally. + - `UpcastToTerminal(IIntegrationEvent)` (`:101-123`) is the hot path. It walks while a source has an + upcaster (`:110`), rejects a `null` return with an `InvalidOperationException` naming the offending + upcaster (`:112-114`), stamps the envelope (`:116`), and then advances `currentType` by the + upcaster's **declared** `TargetType`, not the runtime type of what was returned (`:119`). The + comment (`:108-109`) says why that matters: the constructor's acyclicity check was computed over + declared types, so following declared types is what bounds this loop. With no registration at all, + the loop never runs and the input instance is returned unchanged (`:122`). + - `PreserveEnvelope` (`:169-179`) is the correctness detail. It pulls the cached `MessageId` and + `DateOccurred` handles for the produced type (`:171-175`, keyed on `target.GetType()`), then copies + both values from the pre-hop instance onto the new one (`:177-178`). `Writable` (`:181-182`) is the + filter that caches a handle only when `CanWrite` is true, so a target that does not expose the + property simply gets nothing written rather than throwing. Both properties are `init`-only on + `BaseDomainEvent` + (`MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/BaseDomainEvent.cs:28` and `:35`), which + reflection can still set. + - `Describe` (`:184`, `:186`) renders a type or an upcaster instance as its `FullName`, which is what + makes every one of the exception messages above name real types. +- **Why it's built this way**: [ADR-090](https://ivanball.github.io/docs/adr/090-event-upcaster-registration.html) + records the registration model. Envelope preservation is deliberately the registry's job rather than + the upcaster author's (remarks, `:21-28`): an upcaster maps payload fields only, and consumer-side + inbox deduplication is keyed on the `MessageId` the producer published + ([ADR-021](https://ivanball.github.io/docs/adr/021-consumer-inbox-idempotency.html)), so leaving the + copy to each author would make dedup depend on every author remembering. Making it automatic also + makes it idempotent: an author who does copy the envelope just gets the same values written twice, + which the test at + `MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Services/EventUpcasterRegistryTests.cs:170` + pins. +- **Where it's used**: registered unconditionally as a singleton by `AddApplication` + (`services.TryAddSingleton()`, + `DependencyInjection.cs:40`), and populated by each `AddEventUpcaster()` + call, which appends the upcaster through `TryAddEnumerable` + (`DependencyInjection.cs:283-290`, the descriptor at `:288`). Both delivery paths consume it: the + in-process branch of [`DomainEventDispatcher`](group-04-events-outbox.md#domaineventdispatcher) + (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/DomainEventDispatcher.cs:62`, resolved + through a `Lazy` at `:32-33` so a host without one still works) and the + broker-side + [`UpcastingIntegrationEventConsumer`](group-07-persistence-ef-core.md#upcastingintegrationeventconsumertevent) + (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/UpcastingIntegrationEventConsumer.cs:65` + and `:72`). + [`EventUpcasterStartupValidator`](group-07-persistence-ef-core.md#eventupcasterstartupvalidator) + exists purely to force construction at host start + (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/EventUpcasterStartupValidator.cs:27` + calls `ResolveTerminalType` for that side effect), so a broken graph fails the host rather than the + first message. Behavior is pinned by + [`EventUpcasterRegistryTests`](group-27-testing-infrastructure.md#eventupcasterregistrytests) + (identity at `:88` and `:187`, chain walking at `:116` and `:130`, envelope preservation at `:153`, + and one test per constructor rejection at `:199`, `:211`, `:223`). +- **Caveats / not-in-source**: the walk trusts declared types. An upcaster that returns an instance + whose runtime type is not its declared `TargetType` still advances the walk by the declared type, and + the envelope stamp is looked up by the runtime type, so the two can disagree; nothing in this class + verifies the returned instance's type. Envelope stamping is also silently a no-op for a target that + exposes no writable `MessageId`/`DateOccurred` (`Writable`, `:181-182`). + +--- + ### QueryFieldService > MMCA.Common.Application · `MMCA.Common.Application.Services` · `MMCA.Common/Source/Core/MMCA.Common.Application/Services/QueryFieldService.cs:16` · Level 3 · class (sealed, all members static) diff --git a/docs-src/onboarding/group-05-cqrs-pipeline.md b/docs-src/onboarding/group-05-cqrs-pipeline.md index 8d385df..5810180 100644 --- a/docs-src/onboarding/group-05-cqrs-pipeline.md +++ b/docs-src/onboarding/group-05-cqrs-pipeline.md @@ -37,9 +37,10 @@ and the cross-cutting pipeline wrapped around it. There are five families: consumed by use cases: [`ITenantContext`](#itenantcontext) (which tenant this scope runs as), [`IDistributedLock`](#idistributedlock) (mutual exclusion across replicas), [`IScheduledJob`](#ischeduledjob) (recurring work on a cron schedule), - [`IAuditTrailReader`](#iaudittrailreader) (the recorded change history of one entity), and + [`IAuditTrailReader`](#iaudittrailreader) (the recorded change history of one entity), [`IEntityDTOProjector`](#ientitydtoprojectortentity-tentitydto-tidentifiertype) - (opt-in projection pushdown on list reads). + (opt-in projection pushdown on list reads), and the event-versioning pair + [`IEventUpcaster`](#ieventupcaster) / [`IEventUpcasterRegistry`](#ieventupcasterregistry). 5. **One reusable use case shipped by the framework itself**, [`DeleteEntityCommand`](#deleteentitycommandtentity-tidentifiertype) and [`DeleteEntityHandler`](#deleteentityhandlertentity-tidentifiertype), @@ -49,9 +50,11 @@ This is the central column of `[Rubric §6, CQRS & Event-Driven]` (reads separat intent-revealing use cases) and `[Rubric §10, Cross-Cutting Concerns]` (the place those concerns are implemented once, uniformly, instead of scattered through handlers). The governing decision is [ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html), revised 2026-07-19 -for the transactional semantics and again 2026-08-18 for the pipeline order, which now inserts an +for the transactional semantics and again 2026-08-18 for the pipeline order, which inserts an Authorization decorator between FeatureGate and Logging and a Timeout decorator between Validating -and Transactional. +and Transactional on both chains. The ADR's own Status block warns that the order printed in its +Decision section is the pre-2026-08-18 one and points at the later revision, so read the revision, not +the decision, when you need the current chain. ## The shape: thin handlers, fat pipeline @@ -83,28 +86,31 @@ call arrives over REST, gRPC, or an integration-event consumer. ## How the pipeline is assembled (Scrutor, registration versus execution order) The wiring lives in `DependencyInjection.cs` -(`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:21`), exposed as +(`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:22`), exposed as `extension(IServiceCollection services)` members (the C# `extension(T)` syntax, [primer §4](00-primer.md#c-extensiont-types-read-this-once)). The sequence a host must follow is strict and ordered: -1. `AddApplication()` registers the core singletons (settings facade, event dispatcher, navigation - metadata, the [`EntityQueryPipeline`](group-03-querying-specifications.md#entityquerypipeline)) and - Common's own validators (`DependencyInjection.cs:29-42`). -2. `ScanModuleApplicationServices()` runs **once per module** and uses **Scrutor** - assembly scanning to register domain and integration event handlers (singleton), DTO mappers, DTO - projectors and request mappers (scoped), and every concrete `ICommandHandler<,>`/`IQueryHandler<,>` - (scoped), plus FluentValidation validators (`DependencyInjection.cs:132-204`). -3. `AddApplicationDecorators()` is called **last** (`DependencyInjection.cs:102-122`). It uses - Scrutor's `TryDecorate` to wrap the already-registered handlers. **This ordering is load-bearing**: +1. `AddApplication()` (`DependencyInjection.cs:30`) registers the core singletons: the settings + facade, the domain event dispatcher, the upcaster registry, the navigation metadata provider and + the [`EntityQueryPipeline`](group-03-querying-specifications.md#entityquerypipeline) + (`DependencyInjection.cs:32-43`), then Common's own validators (`DependencyInjection.cs:48`). +2. `ScanModuleApplicationServices()` (`DependencyInjection.cs:140`) runs **once per + module** and uses **Scrutor** assembly scanning to register domain and integration event handlers + (singleton, `DependencyInjection.cs:144-155`), DTO mappers, DTO projectors and request mappers + (scoped, `DependencyInjection.cs:157-176`), and every concrete + `ICommandHandler<,>`/`IQueryHandler<,>` (scoped, `DependencyInjection.cs:178-188`), plus + FluentValidation validators (`DependencyInjection.cs:190`). +3. `AddApplicationDecorators()` (`DependencyInjection.cs:110`) is called **last**. It uses Scrutor's + `TryDecorate` to wrap the already-registered handlers. **This ordering is load-bearing**: `TryDecorate` can only wrap registrations that already exist, which is why decorators must come - after every module's handler scan. + after every module's handler scan (`DependencyInjection.cs:54-55`). The subtle rule is **registration order versus execution order**. `TryDecorate` applies decorators in *reverse* registration order, so the **last** one registered becomes the **outermost** wrapper -(`DependencyInjection.cs:49-51`). The command registrations (`DependencyInjection.cs:107-113`), read +(`DependencyInjection.cs:57-58`). The command registrations (`DependencyInjection.cs:115-121`), read top to bottom, therefore list innermost-first, and the XML doc above them draws the resulting nesting -(`DependencyInjection.cs:53-63`): +(`DependencyInjection.cs:63-70`): ``` FeatureGateCommandDecorator outermost (registered last) @@ -117,7 +123,7 @@ FeatureGateCommandDecorator outermost (registered last) -> ConcreteHandler the actual business logic ``` -The query side (`DependencyInjection.cs:116-120`, drawn at `DependencyInjection.cs:66-74`) is lighter, +The query side (`DependencyInjection.cs:124-128`, drawn at `DependencyInjection.cs:76-81`) is lighter, since there is nothing to validate or commit on a read: ``` @@ -133,84 +139,89 @@ Since the 2026-08-18 revision the order is **pinned by a test, not only by the c `DecoratorPipelineOrderTestsBase` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/DecoratorPipelineOrderTestsBase.cs:38`) resolves both handler types from a real `ServiceCollection` and unwraps the constructed object graph by reflection, -asserting the two sequences outermost-first. Both expected lists are `protected virtual` -(`DecoratorPipelineOrderTestsBase.cs:49`, `:61`), so a consumer whose chain differs can override them; -MMCA.Common subclasses the base against its own registration sequence without overriding either list -(`MMCA.Common/Tests/Hosting/MMCA.Common.Testing.Tests/DecoratorPipelineOrderTests.cs:21`). That is -`[Rubric §14, Testability]` doing governance work: the diagram above cannot silently drift from the -registrations below it. - -A separate, optional call layers MiniProfiler on top: -`AddApplicationProfiling()` (`DependencyInjection.cs:245-250`) registers +asserting the two sequences outermost-first (`DecoratorPipelineOrderTestsBase.cs:72`, `:76`). Both +expected lists are `protected virtual` (`DecoratorPipelineOrderTestsBase.cs:49`, `:61`), so a consumer +whose chain differs can override them; MMCA.Common subclasses the base against its own registration +sequence without overriding either list +(`MMCA.Common/Tests/Hosting/MMCA.Common.Testing.Tests/DecoratorPipelineOrderTests.cs:21`, the real +registration sequence at `DecoratorPipelineOrderTests.cs:35`). That is `[Rubric §14, Testability]` +doing governance work: the diagram above cannot silently drift from the registrations below it. + +A separate, optional call layers MiniProfiler on top: `AddApplicationProfiling()` +(`DependencyInjection.cs:297-301`) registers [`ProfilingCommandDecorator`](#profilingcommanddecoratortcommand-tresult) (`MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/ProfilingCommandDecorator.cs:11`, one `MiniProfiler.Current?.Step(...)` around the inner call at `ProfilingCommandDecorator.cs:17`) and its read twin [`ProfilingQueryDecorator`](#profilingquerydecoratortquery-tresult) (`.../Decorators/ProfilingQueryDecorator.cs:11`, `:17`). No host in this workspace calls it today: the only call sites are the framework's own `DependencyInjectionTests` -(`MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/DependencyInjectionTests.cs:148`, -`:158`), which matches -[ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)'s note that the -profiling pair is opt-in and unwired. +(`MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/DependencyInjectionTests.cs:148`, `:158`), +which matches [ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)'s note +that the profiling pair is opt-in and unwired. ## Why this exact order, and what each layer guards The nesting order is a deliberate cost-and-correctness argument, spelled out in the registration -XML-doc (`DependencyInjection.cs:76-98`): +XML-doc (`DependencyInjection.cs:87-105`): - **Feature-gating is outermost** so a disabled feature is rejected with *zero* downstream work: no - permission check, no log scope, no cache touch, no validation, no budget, no transaction. + permission check, no log scope, no cache touch, no validation, no budget, no transaction + (`DependencyInjection.cs:87-90`). [`FeatureGateCommandDecorator`](#featuregatecommanddecoratortcommand-tresult) (`MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/FeatureGateCommandDecorator.cs:18`) and its read twin [`FeatureGateQueryDecorator`](#featuregatequerydecoratortquery-tresult) (`.../Decorators/FeatureGateQueryDecorator.cs:18`) call `IFeatureManager.IsEnabledAsync` only when the use case opts in via [`IFeatureGated`](#ifeaturegated) (`FeatureGateCommandDecorator.cs:48-51`, - `FeatureGateQueryDecorator.cs:51`) and short-circuit with a `NotFound` failure carrying the code - `Feature.Disabled` (`FeatureGateCommandDecorator.cs:55-56`, `FeatureGateQueryDecorator.cs:56`). A + `FeatureGateQueryDecorator.cs:48-51`) and short-circuit with a `NotFound` failure carrying the code + `Feature.Disabled` (`FeatureGateCommandDecorator.cs:55-56`, `FeatureGateQueryDecorator.cs:55-56`). A disabled feature reads as "this does not exist" rather than "you may not", which is the deliberate posture of [ADR-031](https://ivanball.github.io/docs/adr/031-feature-flag-management.html), and it is also why the gate stays *outside* authorization: an off feature must answer identically for every - caller instead of leaking which permission guards it (`DependencyInjection.cs:79-82`). + caller instead of leaking which permission guards it (`DependencyInjection.cs:88-90`). - **Authorization sits directly inside the gate and outside caching**, so a denied request neither - reads nor populates the cache (`DependencyInjection.cs:83-85`). + reads nor populates the cache (`DependencyInjection.cs:91-93`). [`AuthorizationCommandDecorator`](#authorizationcommanddecoratortcommand-tresult) (`.../Decorators/AuthorizationCommandDecorator.cs:26`) and [`AuthorizationQueryDecorator`](#authorizationquerydecoratortquery-tresult) (`.../Decorators/AuthorizationQueryDecorator.cs:21`) take [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) and - [`IPermissionRegistry`](group-08-auth.md#ipermissionregistry), pass straight through when the use - case does not implement [`IRequiresPermission`](#irequirespermission) - (`AuthorizationCommandDecorator.cs:58-59`, `AuthorizationQueryDecorator.cs:53-54`), and otherwise - ask the registry whether any of the caller's roles grants the named permission - (`AuthorizationCommandDecorator.cs:61`, `AuthorizationQueryDecorator.cs:56`). When none does, the - decorator returns a `Forbidden` [`Error`](group-01-result-error-handling.md#error) with the code - `Authorization.PermissionDenied` **without invoking the handler** - (`AuthorizationCommandDecorator.cs:68-71`, `AuthorizationQueryDecorator.cs:63-66`) and counts the - denial on [`CqrsMetrics`](#cqrsmetrics) (`AuthorizationCommandDecorator.cs:65`). This is defense in + [`IPermissionRegistry`](group-08-auth.md#ipermissionregistry) + (`AuthorizationCommandDecorator.cs:27-29`), pass straight through when the use case does not + implement [`IRequiresPermission`](#irequirespermission) (`AuthorizationCommandDecorator.cs:58-59`, + `AuthorizationQueryDecorator.cs:53-54`), and otherwise ask the registry whether any of the caller's + roles grants the named permission (`AuthorizationCommandDecorator.cs:61`, + `AuthorizationQueryDecorator.cs:56`). When none does, the decorator returns a `Forbidden` + [`Error`](group-01-result-error-handling.md#error) with the code `Authorization.PermissionDenied` + **without invoking the handler** (`AuthorizationCommandDecorator.cs:68-71`, + `AuthorizationQueryDecorator.cs:63-66`) and counts the denial on [`CqrsMetrics`](#cqrsmetrics) + (`AuthorizationCommandDecorator.cs:65`, `AuthorizationQueryDecorator.cs:60`). This is defense in depth beside the endpoint's `[Authorize]` policy rather than a replacement for it - (`AuthorizationCommandDecorator.cs:19-22`): the capability check now travels with the use case, so a + (`AuthorizationCommandDecorator.cs:19-22`): the capability check travels with the use case, so a command reached over gRPC, from a scheduled job, or from another module is checked the same way it is over HTTP. That is `[Rubric §11, Security]` moving inward, and it is the pipeline-side surface of [ADR-020](https://ivanball.github.io/docs/adr/020-permission-based-authorization.html). -- **Logging sits just inside authorization** so it measures only enabled, permitted executions. +- **Logging sits just inside authorization** so it measures only enabled, permitted executions + (`DependencyInjection.cs:94`). [`LoggingCommandDecorator`](#loggingcommanddecoratortcommand-tresult) (`.../Decorators/LoggingCommandDecorator.cs:14`) opens a source-generated structured-logging scope carrying the command name and the `CorrelationId` from [`ICorrelationContext`](group-12-api-hosting-mapping.md#icorrelationcontext) (`LoggingCommandDecorator.cs:23`, `:25`, `:66-67`), times the whole inner pipeline with `Stopwatch.GetTimestamp()`/`Stopwatch.GetElapsedTime` rather than a `Stopwatch` instance (one fewer - allocation per command, `LoggingCommandDecorator.cs:29-36`), and separates three outcomes: - `completed`, `failed` (a `Result` in a failure state, logged at Warning with an error summary) and - `exception` (logged at Error, then rethrown), at `LoggingCommandDecorator.cs:38-58`. Each outcome is - also recorded to the [`CqrsMetrics`](#cqrsmetrics) duration histogram tagged `command` and `outcome` + allocation per command, `LoggingCommandDecorator.cs:32-36`), and separates three outcomes: + `completed` (Information), `failed` (a `Result` in a failure state, Warning with an error summary), + and `exception` (Error, then rethrown), at `LoggingCommandDecorator.cs:38-58` with the levels + declared at `LoggingCommandDecorator.cs:77-87`. Each outcome is also recorded to the + [`CqrsMetrics`](#cqrsmetrics) duration histogram tagged `command` and `outcome` (`LoggingCommandDecorator.cs:69-73`). This is the RED (Rate, Errors, Duration) anchor of `[Rubric §13, Observability & Operability]` ([ADR-041](https://ivanball.github.io/docs/adr/041-observability-and-telemetry.html)). The read side [`LoggingQueryDecorator`](#loggingquerydecoratortquery-tresult) - (`.../Decorators/LoggingQueryDecorator.cs:13`) is the same shape against - `CqrsMetrics.QueryDuration` (`LoggingQueryDecorator.cs:68`). + (`.../Decorators/LoggingQueryDecorator.cs:13`) is the same shape against `CqrsMetrics.QueryDuration` + (`LoggingQueryDecorator.cs:67-71`), with one calibration difference: a completed query logs at Debug + rather than Information (`LoggingQueryDecorator.cs:73`), because reads are the high-volume half. - **Cache invalidation sits outside validation and outside the transaction**, so the cache is only - cleared after a valid, committed mutation (`DependencyInjection.cs:89-90`). + cleared after a valid, committed mutation (`DependencyInjection.cs:97-98`). [`CachingCommandDecorator`](#cachingcommanddecoratortcommand-tresult) (`.../Decorators/CachingCommandDecorator.cs:32`) calls `ICacheService.RemoveByPrefixAsync` only when the command opts in via [`ICacheInvalidating`](#icacheinvalidating), its prefix is non-blank, and @@ -219,21 +230,23 @@ XML-doc (`DependencyInjection.cs:76-98`): `RemoveByPrefixAsync("")` would evict the entire cache (`CachingCommandDecorator.cs:73-75`); the eviction runs with `CancellationToken.None` and swallows every fault into a warning, because the command has already committed and a cache outage must not turn a committed write into a failure - (`CachingCommandDecorator.cs:84-104`); and a second, delayed eviction fires after - `ReInvalidationDelay` (5 seconds by default, `CachingCommandDecorator.cs:60`) to remove an entry - that an in-flight read repopulated with pre-write state (`CachingCommandDecorator.cs:91-96`, - `CachingCommandDecorator.cs:113-126`). On the read side, + (`CachingCommandDecorator.cs:86-89`, `CachingCommandDecorator.cs:98-103`); and a second, delayed + eviction fires after `ReInvalidationDelay` (5 seconds by default, `CachingCommandDecorator.cs:60`) + to remove an entry that an in-flight read repopulated with pre-write state + (`CachingCommandDecorator.cs:91-96`, `CachingCommandDecorator.cs:113-126`). That follow-up task is + held on an internal property rather than dropped, so it is observed and a test can await it + deterministically (`CachingCommandDecorator.cs:62-66`). On the read side, [`CachingQueryDecorator`](#cachingquerydecoratortquery-tresult) (`.../Decorators/CachingQueryDecorator.cs:34`) serves hits without touching the handler (`CachingQueryDecorator.cs:79-84`), stores only non-failure results (`CachingQueryDecorator.cs:109-114`), and is **fail-open** throughout: a failed read is logged and treated as a miss, a failed populate returns the answer uncached, and only - `OperationCanceledException` escapes the guard (`CachingQueryDecorator.cs:116`, - `CachingQueryDecorator.cs:162-165`). Both halves are the pipeline's + `OperationCanceledException` escapes either guard (`CachingQueryDecorator.cs:116-122`, + `CachingQueryDecorator.cs:165-169`). Both halves are the pipeline's `[Rubric §12, Performance & Scalability]` story ([ADR-026](https://ivanball.github.io/docs/adr/026-caching-strategy.html)). - **Validation sits outside the budget and the transaction** so a malformed command never spends its - timeout allowance or opens a database transaction (`DependencyInjection.cs:87-88`). + timeout allowance or opens a database transaction (`DependencyInjection.cs:95-96`). [`ValidatingCommandDecorator`](#validatingcommanddecoratortcommand-tresult) (`.../Decorators/ValidatingCommandDecorator.cs:24`) takes `IEnumerable>` and keeps the first (`ValidatingCommandDecorator.cs:29`), passes straight through when there is none @@ -243,12 +256,12 @@ XML-doc (`DependencyInjection.cs:76-98`): [`ICommandWithRequest`](#icommandwithrequestout-trequest) get a validator wired automatically: the module scan reflects over the assembly and `TryAdd`s a [`CommandRequestValidator`](group-06-validation.md#commandrequestvalidatortcommand-trequest) - for each (`DependencyInjection.cs:186-201`), with `TryAdd` semantics so an explicit - `IValidator` always wins. That whole story belongs to + for each (`DependencyInjection.cs:192-205`), with `TryAdd` semantics so an explicit + `IValidator` always wins (`DependencyInjection.cs:192-193`). That whole story belongs to [G06, Validation](group-06-validation.md) (`[Rubric §24, Forms, Validation & UX Safety]`). - **The timeout budget sits inside validation and outside the transaction**, so it covers the database work that actually hangs, does not charge the caller for validation, and cancels the transaction - rather than leaving it open (`DependencyInjection.cs:91-94`). + rather than leaving it open (`DependencyInjection.cs:99-102`). [`TimeoutCommandDecorator`](#timeoutcommanddecoratortcommand-tresult) (`.../Decorators/TimeoutCommandDecorator.cs:33`) passes through unless the command implements [`IHasTimeout`](#ihastimeout) with a positive budget (`TimeoutCommandDecorator.cs:63-64`), otherwise @@ -260,13 +273,13 @@ XML-doc (`DependencyInjection.cs:76-98`): `Error.Failure("Request.TimedOut", ...)` (`TimeoutCommandDecorator.cs:79-84`) because the framework's `ErrorType` taxonomy maps to HTTP status codes and has no member for 408 or 504, so the machine-readable code, not the type, is what callers branch on - (`TimeoutCommandDecorator.cs:12-17`); the expiry is also counted on - [`CqrsMetrics`](#cqrsmetrics) (`TimeoutCommandDecorator.cs:76`). The read twin + (`TimeoutCommandDecorator.cs:12-17`); the expiry is also counted on [`CqrsMetrics`](#cqrsmetrics) + (`TimeoutCommandDecorator.cs:76`). The read twin [`TimeoutQueryDecorator`](#timeoutquerydecoratortquery-tresult) (`.../Decorators/TimeoutQueryDecorator.cs:33`) is line-for-line identical - (`TimeoutQueryDecorator.cs:63-84`) but sits **innermost** on the query side, so a cache hit is served - without starting a budget at all. This is `[Rubric §29, Resilience & Business Continuity]` expressed - per use case rather than per host. + (`TimeoutQueryDecorator.cs:63-67`, `:73`, `:76`, `:80`) but sits **innermost** on the query side, so + a cache hit is served without starting a budget at all. This is + `[Rubric §29, Resilience & Business Continuity]` expressed per use case rather than per host. - **Transaction is innermost** (closest to the handler) so the unit-of-work boundary is as tight as possible. [`TransactionalCommandDecorator`](#transactionalcommanddecoratortcommand-tresult) (`.../Decorators/TransactionalCommandDecorator.cs:18`) is sixteen lines (18-33): pass through unless @@ -277,13 +290,13 @@ XML-doc (`DependencyInjection.cs:76-98`): call, in [`DbContextFactory`](group-07-persistence-ef-core.md#dbcontextfactory) (`[Rubric §8, Data Architecture]`), and it is worth reading: **a returned failed `Result` rolls the transaction back, exactly like an exception** - (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:565-570`); + (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:565-571`); the call is re-entrant, so a nested transaction joins the ambient one and only the outermost call - begins, commits, or rolls back (`DbContextFactory.cs:515-516`); in-process domain event dispatch is + begins, commits, or rolls back (`DbContextFactory.cs:508-516`); in-process domain event dispatch is deferred until after a successful commit and dropped on rollback (`DbContextFactory.cs:472-475`, - `DbContextFactory.cs:581-585`); and a failure of the *commit itself* is never retried, surfacing as - `TransactionCommitAmbiguousException` instead (`DbContextFactory.cs:485-490`, - `DbContextFactory.cs:542-543`). + `DbContextFactory.cs:578-580`); and a failure of the *commit itself* is never retried, surfacing as + `TransactionCommitAmbiguousException` instead (`DbContextFactory.cs:488-496`, + `DbContextFactory.cs:574-576`). ## Opt-in by marker interface, pay only for what you use @@ -321,19 +334,20 @@ Adoption is honest about that, and it is uneven. `IQueryCacheable` is wired and exactly one production query implements it today, ADC's `GetNowNextQuery` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/NowNext/GetNowNextQuery.cs:23`, a 30-second TTL at `:38`), plus the reference apps (Helpdesk's `GetTicketByIdQuery`, -`MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Application/Tickets/UseCases/GetById/GetTicketByIdQuery.cs:23`, -and the ECommerce sample's `GetProductByIdQuery`, +`MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Application/Tickets/UseCases/GetById/GetTicketByIdQuery.cs:23` +with a 5-minute TTL at `:29`, and the ECommerce sample's `GetProductByIdQuery`, `MMCA.ECommerce/Source/Modules/Products/MMCA.ECommerce.Products.Application/Products/UseCases/GetById/GetProductByIdQuery.cs:23`, and `GetOrderByIdQuery`, `MMCA.ECommerce/Source/Modules/Orders/MMCA.ECommerce.Orders.Application/Orders/UseCases/GetById/GetOrderByIdQuery.cs:23`). -MMCA.Store has no -`IQueryCacheable` query at all; its public reads cache at the HTTP `OutputCache` layer instead +MMCA.Store has no `IQueryCacheable` query at all; its public reads cache at the HTTP `OutputCache` +layer instead ([ADR-040](https://ivanball.github.io/docs/adr/040-authenticated-output-caching-for-public-reads.html)). The two newest markers are further back still: no use case in MMCA.ADC, MMCA.Store or MMCA.Helpdesk implements `IRequiresPermission` or `IHasTimeout` yet, so both decorators are exercised only by the -framework's own tests (`MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Decorators/AuthorizationCommandDecoratorTests.cs`, -`.../Decorators/TimeoutCommandDecoratorTests.cs`). The capability shipped; the adoption has not -started. +framework's own tests +(`MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Decorators/AuthorizationCommandDecoratorTests.cs`, +`.../Decorators/AuthorizationQueryDecoratorTests.cs`, `.../Decorators/TimeoutCommandDecoratorTests.cs`, +`.../Decorators/TimeoutQueryDecoratorTests.cs`). The capability shipped; the adoption has not started. ## Tenant scoping and the two lock tables @@ -344,23 +358,28 @@ is applied where the key is *computed* instead: [`TenantCacheKey`](#tenantcachek (`MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/TenantCacheKey.cs:25`) turns a key or prefix into `t:{tenantId}:{key}` when [`ITenantContext`](#itenantcontext) (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ITenantContext.cs:22`) reports a resolved -tenant, and returns it untouched when it does not (`TenantCacheKey.cs:37-40`). The scoped form is a -**prefix, not a suffix**, precisely so prefix eviction keeps working: a command's invalidation can -only reach its own tenant's entries (`TenantCacheKey.cs:16-18`). Because the query decorator uses the -same helper for its reads (`CachingQueryDecorator.cs:64`) and the command decorator for its evictions +tenant, and returns it untouched when it does not (`TenantCacheKey.cs:37-40`, marker constant at +`TenantCacheKey.cs:28`). The scoped form is a **prefix, not a suffix**, precisely so prefix eviction +keeps working: a command's invalidation can only reach its own tenant's entries +(`TenantCacheKey.cs:15-19`). Because the query decorator uses the same helper for its reads +(`CachingQueryDecorator.cs:63-64`) and the command decorator for its evictions (`CachingCommandDecorator.cs:82`), reads and invalidations stay symmetric by construction. `ITenantContext` is injected as an optional constructor parameter defaulting to `null` (`CachingQueryDecorator.cs:38`, `CachingCommandDecorator.cs:36`), so a single-tenant host keeps byte-identical cache keys to the pre-tenancy framework ([ADR-073](https://ivanball.github.io/docs/adr/073-multi-tenancy-model.html)); the interface itself -exposes `TenantId` and `IsResolved` (`ITenantContext.cs:28`, `:31`) and refuses to change tenant -mid-scope, accepting the value it already holds and throwing on a different one +exposes `TenantId` and `IsResolved` (`ITenantContext.cs:28`, `:31`), treats an unresolved tenant as a +meaningful state rather than inventing a fallback value (`ITenantContext.cs:10-15`), and refuses to +change tenant mid-scope, accepting the value it already holds and throwing on a different one (`ITenantContext.cs:33-41`). The read path also guards against **cache stampede**. On a miss, [`CachingQueryDecorator`](#cachingquerydecoratortquery-tresult) takes a per-key lock and re-checks the cache inside it, so on expiry of a hot key exactly one caller runs the handler and -the rest are served the fresh entry (`CachingQueryDecorator.cs:86-96`). The lock table is +the rest are served the fresh entry (`CachingQueryDecorator.cs:89-96`). The miss counter is +incremented once, at the point where execution actually falls through to the handler rather than at +either cache read, so a request that misses the fast path and the double-check is not counted twice +(`CachingQueryDecorator.cs:98-104`). The lock table is [`QueryCacheKeyLocks`](#querycachekeylocks) (`.../Decorators/CachingQueryDecorator.cs:194`), a non-generic holder around a [`KeyedSemaphoreStripe`](group-08-auth.md#keyedsemaphorestripe) so that every closed generic decorator shares one table rather than one per closed type @@ -368,7 +387,7 @@ every closed generic decorator shares one table rather than one per closed type (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICacheService.cs:142`) does the same job for the default `ICacheService.GetOrCreateAsync` implementation, and is deliberately a *separate* table: different call sites over different keys, where sharing stripes would only widen the -unrelated-key collisions striping already tolerates (`ICacheService.cs:135-141`). Both are striped +unrelated-key collisions striping already tolerates (`ICacheService.cs:134-141`). Both are striped rather than one semaphore per key, and both are honest about the limit: the lock is per process, so across replicas stampede protection is at most one handler execution per instance, not one cluster-wide (`CachingQueryDecorator.cs:186-192`). @@ -379,15 +398,15 @@ Two small helpers make the short-circuit decorators possible. [`ResultFailureFactory`](#resultfailurefactory) (`MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/ResultFailureFactory.cs:11`) builds a delegate that manufactures a `TResult` failure from an error list, taking a direct cast for -non-generic `Result` (`ResultFailureFactory.cs:22-25`) and compiling an expression tree once per -closed `Result` (`ResultFailureFactory.cs:27-41`), and throwing `InvalidOperationException` for -anything else (`ResultFailureFactory.cs:43-45`). All four short-circuiting decorator families (feature -gate, authorization, validation, timeout) cache that delegate in a static field but build it -**lazily, on the first short-circuit**, not in a static constructor: since Scrutor's `TryDecorate` is +non-generic `Result` (`ResultFailureFactory.cs:22-25`), compiling an expression tree once per closed +`Result` (`ResultFailureFactory.cs:27-41`), and throwing `InvalidOperationException` for anything +else (`ResultFailureFactory.cs:43-45`). All four short-circuiting decorator families (feature gate, +authorization, validation, timeout) cache that delegate in a static field but build it **lazily, on +the first short-circuit**, not in a static constructor: since Scrutor's `TryDecorate` is unconditional, an eager initializer turned an unsupported `TResult` into a `TypeInitializationException` at *resolve* time for a handler that never short-circuits -(`FeatureGateCommandDecorator.cs:36-43`, `AuthorizationCommandDecorator.cs:36-53`, -`ValidatingCommandDecorator.cs:45-52`, `TimeoutCommandDecorator.cs:41-58`). That repeated remark is a +(`FeatureGateCommandDecorator.cs:36-43`, `AuthorizationCommandDecorator.cs:46-52`, +`ValidatingCommandDecorator.cs:45-52`, `TimeoutCommandDecorator.cs:51-58`). That repeated remark is a good example of the guide's general rule: read the remarks, they usually record a bug that was paid for once. @@ -412,12 +431,12 @@ command/handler pair that deletes *any* [`AuditableAggregateRootEntity`](group-02-domain-building-blocks.md#auditableaggregaterootentitytidentifiertype) (`DeleteEntityHandler.cs:17`) rather than forcing every module to author `DeleteSessionCommand`, `DeleteSpeakerCommand`, and so on. The handler resolves the repository from -[`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), returns a `NotFound` -[`Error`](group-01-result-error-handling.md#error) stamped with its source and the entity type name -when the row is missing (`DeleteEntityHandler.cs:25-28`), calls the aggregate's own `Delete()` (which -enforces invariants and may raise domain events), and saves **only** when that succeeded -(`DeleteEntityHandler.cs:30-32`). The command itself is a one-property record that implements -[`ICacheInvalidating`](#icacheinvalidating) with a defaulted `CachePrefix` of +[`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`DeleteEntityHandler.cs:25`), returns a +`NotFound` [`Error`](group-01-result-error-handling.md#error) stamped with its source and the entity +type name when the row is missing (`DeleteEntityHandler.cs:27-28`), calls the aggregate's own +`Delete()` (which enforces invariants and may raise domain events), and saves **only** when that +succeeded (`DeleteEntityHandler.cs:30-32`). The command itself is a one-property record that +implements [`ICacheInvalidating`](#icacheinvalidating) with a defaulted `CachePrefix` of `typeof(TEntity).FullName + ":"` (`DeleteEntityCommand.cs:20`), because the generic controller constructs the command itself and cannot supply one; setting it to an empty string is the documented opt-out (`DeleteEntityCommand.cs:14-18`), and matches the blank-prefix guard in the caching decorator. @@ -427,7 +446,7 @@ own handler, *and* it supplies that default cache prefix (`DeleteEntityCommand.c ## The other Application-layer contracts in this group -Five contracts sit beside the pipeline rather than inside it. They are declared here, in the +Seven contracts sit beside the pipeline rather than inside it. They are declared here, in the Application layer, and implemented in Infrastructure or the composition root, which is what keeps a use case that depends on one extractable into its own service. @@ -437,36 +456,40 @@ mutual exclusion on a logical key across every replica of a service. Its single `TryAcquireAsync(key, ttl, wait, cancellationToken)` returns an `IAsyncDisposable` handle or `null` when the key was still held after `wait` elapsed (`IDistributedLock.cs:59-63`). The XML doc is explicit about the three things that make it safe to use: it is not reentrant -(`IDistributedLock.cs:20-22`), the TTL is a crash guard rather than a lease you may rely on, so a +(`IDistributedLock.cs:19-22`), the TTL is a crash guard rather than a lease you may rely on, so a paused holder can lose the lock without knowing (`IDistributedLock.cs:37-42`), and release is owner-scoped and idempotent (`IDistributedLock.cs:54-57`). No decorator takes it; its in-framework caller is the API idempotency filter, which needs its execute-then-store window to be exclusive across replicas (`MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:150`, +acquisition at `IdempotencyFilter.cs:246-252`, [ADR-017](https://ivanball.github.io/docs/adr/017-request-idempotency.html)), and the implementation -([`RedisDistributedLock`](group-14-module-system-composition.md#redisdistributedlock) or the warn-once -[`InProcessDistributedLock`](group-14-module-system-composition.md#inprocessdistributedlock)) is -chosen at the composition root ([G14](group-14-module-system-composition.md)). +([`RedisDistributedLock`](group-14-module-system-composition.md#redisdistributedlock) or the +[`InProcessDistributedLock`](group-14-module-system-composition.md#inprocessdistributedlock) fallback) +is chosen at the composition root ([G14](group-14-module-system-composition.md)). [`IScheduledJob`](#ischeduledjob) (`.../Interfaces/IScheduledJob.cs:36`) is recurring work driven by a five-field cron expression parsed by Cronos, with three members: a stable `Name` that doubles as the primary key of the persisted job row (`IScheduledJob.cs:44`), a default `CronExpression` a host may -override per job through `Scheduler:Jobs:{Name}:Cron` (`IScheduledJob.cs:65`, `:68`), and -`ExecuteAsync` (`IScheduledJob.cs:78`). Four behaviors documented on the interface shape how you +override per job through `Scheduler:Jobs:{Name}:Cron` (`IScheduledJob.cs:68`, `:63-66`), and +`ExecuteAsync` (`IScheduledJob.cs:78`). Occurrences are computed against the **UTC** clock, never a +local or configured time zone, so a schedule never shifts, doubles, or vanishes across a daylight +saving transition (`IScheduledJob.cs:57-62`). Four behaviors documented on the interface shape how you write one: jobs resolve **scoped**, in a fresh DI scope per execution, so they may take a unit of work and must hold no state between runs (`IScheduledJob.cs:9-14`); a claim lease in the job store makes an -occurrence run exactly once across replicas (`IScheduledJob.cs:16-21`); missed occurrences do **not** +occurrence run exactly once across replicas (`IScheduledJob.cs:15-21`); missed occurrences do **not** pile up, so work that must not be skipped has to be idempotent and range-driven rather than -one-run-per-tick (`IScheduledJob.cs:23-29`); and a thrown exception is caught, logged and stamped as a -failed outcome without retry inside the occurrence (`IScheduledJob.cs:31-34`). The runner lives in +one-run-per-tick (`IScheduledJob.cs:22-29`); and a thrown exception is caught, logged, and stamped as +a failed outcome without retry inside the occurrence (`IScheduledJob.cs:30-34`). The runner lives in [`ScheduledJobRunner`](group-14-module-system-composition.md#scheduledjobrunner) ([ADR-074](https://ivanball.github.io/docs/adr/074-recurring-job-scheduler.html)). [`IAuditTrailReader`](#iaudittrailreader) (`.../Interfaces/IAuditTrailReader.cs:20`) reads the recorded change history of one entity, keyed by the entity's full CLR type name and the invariant string form of its primary key (composite keys joined with `|` in model key order), paged and newest -first (`IAuditTrailReader.cs:22-42`). It is registered only by `AddAuditTrail`, so a host that never -opted in has nothing to resolve (`IAuditTrailReader.cs:6-7`), and the framework deliberately ships the -read without an endpoint or page, because who may see an entity's history is an application decision +first (`IAuditTrailReader.cs:37-42`, ordering note at `IAuditTrailReader.cs:16-18`). It is registered +only by `AddAuditTrail`, so a host that never opted in has nothing to resolve +(`IAuditTrailReader.cs:5-8`), and the framework deliberately ships the read without an endpoint or +page, because who may see an entity's history is an application decision (`IAuditTrailReader.cs:10-15`). The implementation is [`AuditTrailReader`](group-07-persistence-ef-core.md#audittrailreader) over the rows written by [`AuditTrailSaveChangesInterceptor`](group-07-persistence-ef-core.md#audittrailsavechangesinterceptor) @@ -478,31 +501,59 @@ read without an endpoint or page, because who may see an entity's history is an [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype): a mapper maps rows *after* they materialize, so the query must select whole entities, while a projector rewrites the queryable so the provider selects the DTO's columns directly -(`IEntityDTOProjector.cs:6-16`). Its one method is +(`IEntityDTOProjector.cs:9-16`). Its one method is `IQueryable ProjectTo(IQueryable source)` (`IEntityDTOProjector.cs:62`), and the -implementation must stay translatable: no materializing inside it -(`IEntityDTOProjector.cs:56-59`), and no instance sub-mappers, custom mapping methods, or after-map -hooks, because a projection is an expression tree the database provider has to translate -(`IEntityDTOProjector.cs:36-41`). Registering one is the whole opt-in: the module scan picks it up -scoped beside the mappers (`DependencyInjection.cs:155-162`), and +implementation must stay translatable: no materializing inside it (`IEntityDTOProjector.cs:56-58`), +and no instance sub-mappers, custom mapping methods, or after-map hooks, because a projection is an +expression tree the database provider has to translate (`IEntityDTOProjector.cs:36-41`). Registering +one is the whole opt-in: the module scan picks it up scoped beside the mappers +(`DependencyInjection.cs:166-170`), and [`EntityQueryService`](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype) declares a second, longer constructor purely so the container selects the projected path when one is registered and the plain path when it is not -(`MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:69-77`, -`EntityQueryService.cs:84`, gate at `EntityQueryService.cs:489-491`). Because the two paths are chosen -by registration, a projector that disagrees with its mapper would make a response depend on which one -happened to be wired, which is why the contract says to pin the equivalence with a test -(`IEntityDTOProjector.cs:42-46`). That is `[Rubric §12, Performance & Scalability]` again, this time -on the read path ([ADR-034](https://ivanball.github.io/docs/adr/034-generic-entity-query-layer.html)). +(`MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:69-75`, +`EntityQueryService.cs:84`, gate at `EntityQueryService.cs:489-492`, which also disqualifies tracked +reads and unsupported includes). Because the two paths are chosen by registration, a projector that +disagrees with its mapper would make a response depend on which one happened to be wired, which is +why the contract says to pin the equivalence with a test (`IEntityDTOProjector.cs:42-46`). That is +`[Rubric §12, Performance & Scalability]` again, this time on the read path +([ADR-034](https://ivanball.github.io/docs/adr/034-generic-entity-query-layer.html)). + +[`IEventUpcaster`](#ieventupcaster) (`.../Interfaces/IEventUpcaster.cs:28`) and +[`IEventUpcasterRegistry`](#ieventupcasterregistry) (`.../Interfaces/IEventUpcasterRegistry.cs:24`) +are the versioning contracts for integration events, declared in this layer because both delivery +paths consume them. A breaking event-shape change is a **new event type plus a consumer-side +upcaster**, never a silent reshape of the existing type +([ADR-010](https://ivanball.github.io/docs/adr/010-integration-event-schema-versioning.html)), and the +typed `IEventUpcaster` (`IEventUpcaster.cs:67`) is the one an application +writes: it supplies `SourceType`, `TargetType`, and the non-generic `Upcast` as default interface +implementations (`IEventUpcaster.cs:72-86`), so the class body is the single typed conversion method +(`IEventUpcaster.cs:82`). Registration is one call, +`services.AddEventUpcaster()` (`DependencyInjection.cs:283-290`), which +appends a singleton to an enumerable so several upcasters compose into a chain. The registry +(`IEventUpcasterRegistry.cs:24`) is the composed view: it probes whether a type has an upcaster +(`IEventUpcasterRegistry.cs:31`), resolves the terminal (newest) contract by walking the whole chain +(`IEventUpcasterRegistry.cs:39`), and upcasts an instance hop by hop +(`IEventUpcasterRegistry.cs:48`). Two properties make it safe to depend on unconditionally: it is +**always registered**, and with no upcasters its operations are identity +(`DependencyInjection.cs:36-40`, `IEventUpcasterRegistry.cs:11-15`); and every hop preserves the +envelope, restamping `MessageId` and `DateOccurred` from the pre-hop instance, so consumer-side inbox +deduplication keeps working on the id the producer published (`IEventUpcaster.cs:21-26`). A bad +registration graph (duplicate source, a source mapped onto itself, or a cycle) throws from the +implementation's constructor and is resolved at host start, so a misconfiguration fails the host +rather than the first message (`IEventUpcasterRegistry.cs:17-22`). The in-process consumer is the +[`DomainEventDispatcher`](group-04-events-outbox.md#domaineventdispatcher), the broker-side one is +[`UpcastingIntegrationEventConsumer`](group-07-persistence-ef-core.md#upcastingintegrationeventconsumertevent) +([ADR-090](https://ivanball.github.io/docs/adr/090-event-upcaster-registration.html)). [`ICreateRequest`](#icreaterequest) (`.../Interfaces/ICreateRequest.cs:8`) is the smallest type in the group: an empty marker used purely as a generic constraint by [`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype) -so request-to-entity mapping is type-safe (`ICreateRequest.cs:3-6`). It pairs with +so request-to-entity mapping is type-safe (`ICreateRequest.cs:3-7`). It pairs with [`ICommandWithRequest`](#icommandwithrequestout-trequest) (`.../UseCases/ICommandWithRequest.cs:14`), whose single covariant `Request` property (`ICommandWithRequest.cs:17`) is what the module scan looks for when it auto-registers the delegating -validator described above. +validator described above (`ICommandWithRequest.cs:4-11`). ## Where this fits, and the failure-mode contract @@ -531,11 +582,11 @@ whole pipeline survives a module being extracted into its own service unchanged The contract to memorize, because the rest of the system relies on it, has four clauses. On a **business failure** (a `Result` with `IsFailure`, no exception thrown) the transaction is **rolled back**, atomicity over partial persistence, and cache invalidation is skipped -(`DependencyInjection.cs:95-96`, enforced at `DbContextFactory.cs:565-570` and +(`DependencyInjection.cs:103-104`, enforced at `DbContextFactory.cs:565-571` and `CachingCommandDecorator.cs:76-78`). On an **exception** the transaction also rolls back and the exception propagates outward through every decorator, which logs it and tags the metric `exception` -(`DependencyInjection.cs:97`, `LoggingCommandDecorator.cs:52-58`). On a **short circuit** (feature off, -permission denied, validation failed, budget expired) the handler is never called at all and the +(`DependencyInjection.cs:105`, `LoggingCommandDecorator.cs:52-58`). On a **short circuit** (feature +off, permission denied, validation failed, budget expired) the handler is never called at all and the caller gets a typed failure whose `ErrorType` is the decorator's own: `NotFound`, `Forbidden`, `Validation`, `Failure` respectively. And on the read side only non-failure results are ever cached (`CachingQueryDecorator.cs:109`). Note the revision history here: rollback-on-business-failure is the @@ -549,12 +600,13 @@ rules of the pipeline. ### ICreateRequest > MMCA.Common.Application · `MMCA.Common.Application.Interfaces` · `MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICreateRequest.cs:8` · Level 0 · interface (marker, empty) -- **What it is**: an empty marker interface for "create" request DTOs, used as a generic type constraint by [IEntityRequestMapper](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype) to distinguish create-mapping from update-mapping at the type-system level. +- **What it is**: an empty marker interface for "create" request DTOs, used as a generic type constraint by [IEntityRequestMapper](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype) to distinguish create-mapping from every other mapping path at the type-system level. - **Depends on**: nothing. Same presence-as-signal marker pattern as [ITransactional](#itransactional). -- **Concept introduced, type-system constraints as documentation and enforcement.** `[Rubric §9, API & Contract Design]` assesses how request contracts are modelled and kept unambiguous; tagging a DTO as "this is a create" lets generic mapper infrastructure (G12) refuse anything that is not a create request on the create-mapping path, catching a wiring mistake at compile time rather than at runtime. -- **Walkthrough**: the body is empty (`{ }`, lines 9-10); the XML doc on lines 3-7 names the single consumer. All of the type's value is in the hierarchy. -- **Why it's built this way**: a mapper constrained to `where TCreateRequest : ICreateRequest` makes it impossible to pass an update-request DTO into the create-mapping path, with no runtime check needed. -- **Where it's used**: implemented by create-request DTOs in every module. Source search finds 6 in `MMCA.ADC/Source` (`EventCreateRequest`, `SessionCreateRequest`, `SpeakerCreateRequest`, `SponsorCreateRequest`, `QuestionCreateRequest`, `ConferenceCategoryCreateRequest`) and 8 in `MMCA.Store/Source` (`ProductCreateRequest`, `ProductVariantCreateRequest`, `CategoryCreateRequest`, `OrderCreateRequest`, `CustomerCreateRequest`, `ShoppingCartCreateRequest`, `ShoppingCartItemCreateRequest`, `InventoryItemCreateRequest`). Consumed as a generic constraint by [IEntityRequestMapper](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype) (G12). +- **Concept introduced, type-system constraints as documentation and enforcement.** `[Rubric §9, API & Contract Design]` assesses how request contracts are modelled and kept unambiguous; tagging a DTO as "this is a create" lets generic mapper and controller infrastructure (G12) refuse anything that is not a create request on the create path, catching a wiring mistake at compile time rather than at runtime. +- **Walkthrough**: the body is empty (`{ }`, `ICreateRequest.cs:9-10`); the XML doc (`ICreateRequest.cs:3-7`) names the constraint site. All of the type's value is in the hierarchy: there is no member to implement, so opting in costs a base-list entry and nothing else. +- **Why it's built this way**: a mapper constrained to `where TCreateRequest : ICreateRequest` makes it impossible to pass a non-create DTO into the create-mapping path, with no runtime check needed and no reflection. +- **Where it's used**: as a generic constraint in three framework places, all of them declaring `where TCreateRequest : ICreateRequest`: [IEntityRequestMapper](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype) (declared at `MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEntityDTOMapper.cs:42-44`, which is the file that hosts both mapper contracts), and the API controller pair `IAggregateRootEntityControllerBase` (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/IAggregateRootEntityControllerBase.cs:22`) and `AggregateRootEntityControllerBase` (`.../Controllers/AggregateRootEntityControllerBase.cs:43`). Implemented by create-request DTOs in every module: 7 in `MMCA.ADC/Source` (`EventCreateRequest`, `SessionCreateRequest`, `SpeakerCreateRequest`, `SponsorCreateRequest`, `QuestionCreateRequest`, `ActivityCreateRequest`, `ConferenceCategoryCreateRequest`) and 8 in `MMCA.Store/Source` (`ProductCreateRequest`, `ProductVariantCreateRequest`, `CategoryCreateRequest`, `OrderCreateRequest`, `CustomerCreateRequest`, `ShoppingCartCreateRequest`, `ShoppingCartItemCreateRequest`, `InventoryItemCreateRequest`). +- **Caveats / not-in-source**: the marker says "create", not "valid". Nothing in the type system checks that a `ICreateRequest` omits an identifier or carries the fields the aggregate factory needs; that is FluentValidation's and the factory's job. --- @@ -564,10 +616,12 @@ rules of the pipeline. - **What it is**: a one-method contract for mutual exclusion on a logical string key across *every replica* of a service. `TryAcquireAsync` hands back an `IAsyncDisposable` handle whose disposal releases the lock, or `null` when the key was still held elsewhere after the caller's wait elapsed. - **Depends on**: BCL only (`Task`, `TimeSpan`, `IAsyncDisposable`, `CancellationToken`), no first-party types. Its two implementations live in Infrastructure: [InProcessDistributedLock](group-14-module-system-composition.md#inprocessdistributedlock) and [RedisDistributedLock](group-14-module-system-composition.md#redisdistributedlock). Contrast the per-process [KeyedSemaphoreStripe](group-08-auth.md#keyedsemaphorestripe), which is exactly what this interface exists to outgrow. - **Concept introduced, cross-replica mutual exclusion as an Application-layer abstraction.** `[Rubric §12, Performance & Scalability]` assesses whether a design still holds once the service scales out horizontally, and the XML doc opens with precisely that failure (`IDistributedLock.cs:6-13`): a `SemaphoreSlim` (or a striped one) serializes callers *inside one process*, so a service running more than one replica executes an "only one of these at a time" section once per replica. `[Rubric §29, Resilience, Reliability & Business Continuity]` assesses behavior under partial failure; this contract is documented as **best-effort, not a consensus protocol** (`IDistributedLock.cs:23-28`): a holder paused past its time-to-live loses the lock without being told, so the guarded section must stay correct (merely slower, or duplicated) when exclusion is lost. The doc states the usage rule bluntly: take the lock to *collapse duplicate work*, never as the only guard on a correctness invariant that persistence can enforce. `[Rubric §3, Clean Architecture]` assesses whether the core depends on abstractions while technology choices sit at the edge; the contract carries no transport type at all, so the StackExchange.Redis dependency stays in Infrastructure and callers here never see it. -- **Walkthrough**: line 30 declares the interface; lines 59-63 declare its single member, `Task TryAcquireAsync(string key, TimeSpan ttl, TimeSpan wait, CancellationToken cancellationToken = default)`. Every parameter carries a contract the implementations must honour. `key` is the logical name that callers sharing one backing store have to agree on (line 36). `ttl` is the **crash guard**: how long the lock survives with no explicit release, so a holder that dies mid-section cannot wedge the key; it must sit comfortably above the guarded section's expected duration, because work that outlives the TTL is no longer protected (lines 37-42). `wait` is how long to block for a current holder, and `TimeSpan.Zero` makes the call a single non-blocking attempt (lines 43-46). The token cancels *the wait*, not the work that follows it (line 47). The return contract matters as much as the parameters: `null` means "still held elsewhere after `wait` elapsed", and the handle is meant to be disposed inside an `await using` so release happens even when the guarded work throws (lines 48-53). Release is **owner-scoped and idempotent** (lines 54-58): disposing a handle whose TTL already lapsed is a no-op, not a release of whatever holder now owns the key. Two remarks bound usage further: implementations are singletons and must be safe to call concurrently (line 17), and the lock is **not reentrant**, so a caller that already holds `key` and asks for it again waits for itself and then fails to acquire (lines 20-21). -- **Why it's built this way**: [ADR-017](https://ivanball.github.io/docs/adr/017-request-idempotency.html) records the change that introduced it. The idempotency filter's execute-then-store window used to be guarded only by a process-local striped semaphore, which stops serializing anything the moment a service runs more than one replica, and both deployed apps do. Putting the contract in `MMCA.Common.Application.Interfaces` rather than Infrastructure is what lets the API filter depend on "a lock" while the Redis-versus-process-local decision stays a composition-root concern. -- **Where it's used**: the Infrastructure composition root registers exactly one implementation inside `AddCaching` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:191-204`), choosing [RedisDistributedLock](group-14-module-system-composition.md#redisdistributedlock) when an `IConnectionMultiplexer` is resolvable and the warn-once [InProcessDistributedLock](group-14-module-system-composition.md#inprocessdistributedlock) otherwise. The one in-framework consumer is [IdempotencyFilter](group-12-api-hosting-mapping.md#idempotencyfilter), which resolves it from request services (`MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:150`) and spans the double-check plus action plus cache-store window with a 30-second `ttl` and a 5-second `wait`, answering a 409 in-flight-duplicate result instead of executing a second time when that wait expires with nothing cached. -- **Caveats / not-in-source**: verified by source search across the workspace, no ADC or Store type takes an `IDistributedLock` today; the framework's own idempotency filter is the only production caller. The filter also resolves it with `GetService()` and falls back to the striped-semaphore path when the result is `null`, even though `AddCaching` registers an implementation unconditionally, so that fallback is reachable only in a host that never calls `AddCaching` (or a test building its own provider). +- **Walkthrough**: line 30 declares the interface; lines 59-63 declare its single member, `Task TryAcquireAsync(string key, TimeSpan ttl, TimeSpan wait, CancellationToken cancellationToken = default)`. Every parameter carries a contract the implementations must honour. `key` is the logical name that callers sharing one backing store have to agree on (`IDistributedLock.cs:36`). `ttl` is the **crash guard**: how long the lock survives with no explicit release, so a holder that dies mid-section cannot wedge the key; it must sit comfortably above the guarded section's expected duration, because work that outlives the TTL is no longer protected (`:37-42`). `wait` is how long to block for a current holder, and `TimeSpan.Zero` makes the call a single non-blocking attempt (`:43-46`). The token cancels *the wait*, not the work that follows it (`:47`). The return contract matters as much as the parameters: `null` means "still held elsewhere after `wait` elapsed", and the handle is meant to be disposed inside an `await using` so release happens even when the guarded work throws (`:48-53`). Release is **owner-scoped and idempotent** (`:54-58`): disposing a handle whose TTL already lapsed is a no-op, not a release of whatever holder now owns the key. Two remarks bound usage further: implementations are singletons and must be safe to call concurrently (`:17`), and the lock is **not reentrant**, so a caller that already holds `key` and asks for it again waits for itself and then fails to acquire (`:20-21`). +- **Why it's built this way**: [ADR-017](https://ivanball.github.io/docs/adr/017-request-idempotency.html) records the change that introduced it. The idempotency filter's execute-then-store window was previously guarded only by a process-local striped semaphore, which stops serializing anything the moment a service runs more than one replica, and both deployed apps do. Putting the contract in `MMCA.Common.Application.Interfaces` rather than Infrastructure is what lets the API filter depend on "a lock" while the Redis-versus-process-local decision stays a composition-root concern. +- **Where it's used**: the Infrastructure composition root registers exactly one implementation inside `AddCaching` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:208-222`), choosing [RedisDistributedLock](group-14-module-system-composition.md#redisdistributedlock) when an `IConnectionMultiplexer` is resolvable (`:210-217`) and the warn-once [InProcessDistributedLock](group-14-module-system-composition.md#inprocessdistributedlock) otherwise (`:219-221`); the comment above the registration explains the pairing with the cache (`:204-207`). Two production consumers exist today. + - The framework's [IdempotencyFilter](group-12-api-hosting-mapping.md#idempotencyfilter) resolves it from request services (`MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:150`) and spans the double-check plus action plus cache-store window with a 30-second `ttl` (`LockTimeToLive`, `:99`) and a 5-second `wait` (`LockWait`, `:106`), calling `TryAcquireAsync` at `:252`. When that wait expires with nothing cached it answers a 409 in-flight-duplicate result instead of executing a second time (`:263-273`); when the lock call itself *throws*, it records a degraded metric and executes anyway rather than failing the request (`:255-261`). + - MMCA.ADC's `SessionScoringProcessor` takes the lock per event around an AI-scoring pass (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Services/SessionScoringProcessor.cs:175-179`) with a 15-minute `ClaimTimeToLive` (`:85`) and a `ClaimWait` of `TimeSpan.Zero` (`:92`), so a second replica that cannot claim the event simply skips its pass (`:182-189`). The inline rationale (`:162-174`) is a good worked example of both halves of the contract: the handle's disposal releases on every exit path, and the TTL releases for a replica that never reaches an exit path at all. +- **Caveats / not-in-source**: `MMCA.Store/Source` has no `IDistributedLock` consumer today. The idempotency filter also resolves it with `GetService()` and falls back to the striped-semaphore path when the result is `null` (`IdempotencyFilter.cs:150-155`), even though `AddCaching` registers an implementation unconditionally, so that fallback is reachable only in a host that never calls `AddCaching` (or a test building its own provider). The ADC comment states the other honest limit: a host with no Redis gets the in-process implementation, where "cross-replica" exclusion degrades back to per-replica (`SessionScoringProcessor.cs:172-174`). --- @@ -577,14 +631,16 @@ rules of the pipeline. - **What it is**: the contract for a unit of recurring work driven by a cron schedule: a stable `Name`, a default `CronExpression`, and an `ExecuteAsync` that runs one occurrence. - **Depends on**: BCL only (`Task`, `CancellationToken`). Executed by [ScheduledJobRunner](group-14-module-system-composition.md#scheduledjobrunner), persisted as [ScheduledJobEntry](group-14-module-system-composition.md#scheduledjobentry) rows, configured through [SchedulerSettings](group-14-module-system-composition.md#schedulersettings) and [ScheduledJobOverrideSettings](group-14-module-system-composition.md#scheduledjoboverridesettings), and cron-parsed by Cronos (NuGet). - **Concept introduced, recurring work as a first-class Application abstraction.** `[Rubric §13, Observability & Operability]` assesses whether operators can see and steer background work; a job here is a named row with a schedule, an outcome and a last error, not an anonymous `Timer`. `[Rubric §7, Microservices Readiness]` and `[Rubric §29, Resilience]` assess behavior under scale-out and partial failure, and the interface's own doc is where the hard rules are written down (`IScheduledJob.cs:8-35`), so read them as contract, not commentary: - - **Lifetime**: jobs are resolved **scoped**, in a fresh DI scope per execution, exactly like a request. A job may take scoped dependencies (a unit of work, a repository, a command handler) and must hold no state between runs, because the previous instance is already disposed (lines 9-14). - - **Single runner across replicas**: every replica runs a scheduler, but an occurrence executes once, because the persistent job store hands out a claim lease per row and only the claim winner runs (lines 16-21). A replica that dies mid-execution releases its claim implicitly when the lease expires. - - **Missed occurrences do not pile up**: after an outage the job runs **once** and its next run is computed from the current instant, not from the backlog (lines 23-29). Work that must not be skipped therefore has to be idempotent and range-driven, processing everything since the last successful run rather than relying on one run per tick. - - **Failures are recorded, not fatal**: an exception from `ExecuteAsync` is caught, logged and stamped on the row as a failed outcome while the schedule advances and the loop survives; there is no retry inside an occurrence (lines 31-33). -- **Walkthrough**: line 44 declares `string Name { get; }`, the stable identity that is also the primary key of the persisted row, so renaming it strands the old row and starts a new schedule, and two registered jobs must never share it (lines 38-43). Line 68 declares `string CronExpression { get; }`, a **five-field** expression (`minute hour day-of-month month day-of-week`) parsed by Cronos, with worked examples on lines 51-56. Two properties of that field are load-bearing: **all times are UTC**, never a local or configured zone, so a schedule never shifts, doubles or vanishes across a daylight-saving transition (lines 57-62), and the value is only the **default**, overridden per job by `Scheduler:Jobs:{Name}:Cron` in configuration whenever that key is present (lines 63-66). Line 78 declares `Task ExecuteAsync(CancellationToken cancellationToken)`, whose token is cancelled on host shutdown; work that ignores it delays shutdown and can outlive its claim lease (lines 73-76). -- **Why it's built this way**: [ADR-074](https://ivanball.github.io/docs/adr/074-recurring-job-scheduler.html) records the design. Keeping the interface in Application (with no EF, no `IHostedService`, no cron library type in its signature) is what lets a module declare recurring work without taking an Infrastructure dependency, and lets the runner, the persistence of job state and the claim protocol all stay replaceable. Registration is deliberately split in two: `AddScheduledJobs(configuration)` enables the runner once per host, while `AddScheduledJob()` adds one job. The two are order-free, both use `TryAddEnumerable` so a double call cannot produce two runners racing the same rows, and registering the scheduler is not the same as turning it on: everything stays inert until `Scheduler:Enabled` is true. -- **Where it's used**: the framework ships exactly one implementation, `AuditTrailCleanupJob` (see [AuditTrailCleanupJob](group-07-persistence-ef-core.md#audittrailcleanupjob)), named `"audit-trail-cleanup"` and scheduled `"0 3 * * *"` (daily at 03:00 UTC). It is registered by `AddAuditTrail` rather than by `AddScheduledJobs`, which keeps the trail and the scheduler independent features. -- **Caveats / not-in-source**: verified by source search, neither `MMCA.ADC/Source` nor `MMCA.Store/Source` implements `IScheduledJob` today; the retention job is the only job in the workspace. Retention therefore only happens in a host that enables both features: registering the trail without the scheduler records every change and purges nothing, leaving `AuditTrail:RetentionDays` inert. + - **Lifetime**: jobs are resolved **scoped**, in a fresh DI scope per execution, exactly like a request. A job may take scoped dependencies (a unit of work, a repository, a command handler) and must hold no state between runs, because the previous instance is already disposed (`:9-14`). + - **Single runner across replicas**: every replica runs a scheduler, but an occurrence executes once, because the persistent job store hands out a claim lease per row (the outbox processor's claim idiom) and only the claim winner runs (`:16-21`). A replica that dies mid-execution releases its claim implicitly when the lease expires. + - **Missed occurrences do not pile up**: after an outage the job runs **once** and its next run is computed from the current instant, not from the backlog (`:23-29`). Work that must not be skipped therefore has to be idempotent and range-driven, processing everything since the last successful run rather than relying on one run per tick. + - **Failures are recorded, not fatal**: an exception from `ExecuteAsync` is caught, logged and stamped on the row as a failed outcome while the schedule advances and the loop survives; there is no retry inside an occurrence (`:31-33`). +- **Walkthrough**: line 44 declares `string Name { get; }`, the stable identity that is also the primary key of the persisted row, so renaming it strands the old row and starts a new schedule, and two registered jobs must never share it (`:38-43`). Line 68 declares `string CronExpression { get; }`, a **five-field** expression (`minute hour day-of-month month day-of-week`) parsed by Cronos, with worked examples on `:51-56`. Two properties of that field are load-bearing: **all times are UTC**, never a local or configured zone, so a schedule never shifts, doubles or vanishes across a daylight-saving transition (`:57-62`), and the value is only the **default**, overridden per job by `Scheduler:Jobs:{Name}:Cron` in configuration whenever that key is present (`:63-66`). Line 78 declares `Task ExecuteAsync(CancellationToken cancellationToken)`, whose token is cancelled on host shutdown; work that ignores it delays shutdown and can outlive its claim lease (`:73-76`). +- **Why it's built this way**: [ADR-074](https://ivanball.github.io/docs/adr/074-recurring-job-scheduler.html) records the design. Keeping the interface in Application (with no EF, no `IHostedService`, no cron library type in its signature) is what lets a module declare recurring work without taking an Infrastructure dependency, and lets the runner, the persistence of job state and the claim protocol all stay replaceable. Registration is deliberately split in two: `AddScheduledJobs(configuration)` enables the runner once per host (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:317`, registering [ScheduledJobRunner](group-14-module-system-composition.md#scheduledjobrunner) through `TryAddEnumerable` at `:328`), while `AddScheduledJob()` adds one job (`:352`). Registering the scheduler is not the same as turning it on: everything stays inert until `Scheduler:Enabled` is true (`:313`). +- **Where it's used**: two implementations exist in the workspace. + - The framework ships `AuditTrailCleanupJob` (see [AuditTrailCleanupJob](group-07-persistence-ef-core.md#audittrailcleanupjob)), named `"audit-trail-cleanup"` and scheduled `"0 3 * * *"`, daily at 03:00 UTC (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailCleanupJob.cs:63,67`). It is registered by `AddAuditTrail` rather than by `AddScheduledJobs` (`.../Infrastructure/DependencyInjection.cs:404`), which keeps the trail and the scheduler independent features (`:377-379`). + - MMCA.ADC's `SessionScoringSweepJob` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Services/SessionScoringSweepJob.cs:54`) is named `"conference-session-scoring-sweep"` and runs `"*/5 * * * *"`, every five minutes (`:69,77`), recovering AI-scoring passes interrupted inside a `RecoveryWindow` of 24 hours (`:66`). It is registered by the Conference module (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:54`) and the scheduler itself is turned on in each ADC service host (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:313`, and likewise in the Engagement and Identity service hosts). +- **Caveats / not-in-source**: `MMCA.Store/Source` implements no `IScheduledJob` today. Retention only happens in a host that enables both features: registering the trail without the scheduler records every change and purges nothing, leaving `AuditTrail:RetentionDays` inert, which is exactly what the `AddAuditTrail` doc warns about (`.../Infrastructure/DependencyInjection.cs:377-380`). --- @@ -594,10 +650,10 @@ rules of the pipeline. - **What it is**: the scoped ambient contract for "which tenant is this scope running as": a nullable `TenantId`, an `IsResolved` flag, and a `SetTenant` that may be called once per scope. - **Depends on**: BCL only. Implemented by [TenantContext](group-07-persistence-ef-core.md#tenantcontext) in Infrastructure, populated at the edge by [TenantResolutionMiddleware](group-12-api-hosting-mapping.md#tenantresolutionmiddleware), and consumed by the caching decorators in this group plus the persistence layer (G07). Configured through [TenancySettings](group-14-module-system-composition.md#tenancysettings). Deliberately mirrors [ICorrelationContext](group-12-api-hosting-mapping.md#icorrelationcontext). - **Concept introduced, ambient scope state with an honest "unset" value.** `[Rubric §11, Security]` assesses whether data isolation is enforced structurally rather than remembered per query; every tenant-aware read filter, save interceptor and cache key reads this one object, so a handler cannot forget to scope itself. `[Rubric §10, Cross-Cutting Concerns]`: like the correlation id, the value is captured once at the edge and flows implicitly for the rest of the scope. The interesting design decision is the one the doc calls out (`ITenantContext.cs:10-15`): unlike the correlation id there is **no generated fallback**. An unresolved tenant is a meaningful state (a background service, a seeder, an admin flow) and reads as "see everything", so inventing a value would silently scope a system operation to a tenant that does not exist. -- **Walkthrough**: line 28 declares `string? TenantId { get; }`, null until resolved. Line 31 declares `bool IsResolved { get; }`. Line 41 declares `void SetTenant(string tenantId)`, and its contract is the strict part: it throws `ArgumentException` on a null, empty or whitespace id (line 37) and `InvalidOperationException` when a *different* tenant was already resolved for this scope (lines 38-40), while accepting the value it already holds. The rationale is on lines 16-20: **one scope, one tenant**, because a scope whose tenant changed mid-flight has already read rows under the previous tenant and there is no honest way to reconcile that afterwards. The implementation matches exactly (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/TenantContext.cs:20-44`), and its `InvalidOperationException` message tells the caller to start a new scope. -- **Why it's built this way**: [ADR-073](https://ivanball.github.io/docs/adr/073-multi-tenancy-model.html) records the multi-tenancy model. Putting the contract in Application, not Infrastructure, is what lets the Application-layer caching decorators scope their keys without referencing EF Core: [CachingCommandDecorator](#cachingcommanddecoratortcommand-tresult) and [CachingQueryDecorator](#cachingquerydecoratortquery-tresult) both take it as an **optional** constructor parameter defaulting to `null` (`.../Decorators/CachingCommandDecorator.cs:36`, `.../Decorators/CachingQueryDecorator.cs:38`), so a single-tenant host that never calls `AddMultiTenancy` resolves them unchanged and pays nothing. -- **Where it's used**: registered scoped in `AddMultiTenancy` in the Infrastructure composition root. Written at the edge by [TenantResolutionMiddleware](group-12-api-hosting-mapping.md#tenantresolutionmiddleware) from a claim or a header, and re-asserted onto a fresh scope by every background path that fans out per tenant (the outbox processor and its cleanup service, [AuditTrailCleanupJob](group-07-persistence-ef-core.md#audittrailcleanupjob), and startup database initialization). Read by [DbContextFactory](group-07-persistence-ef-core.md#dbcontextfactory) for per-tenant routing (also optional) and by both caching decorators through `TenantCacheKey.Scope` (`.../Decorators/TenantCacheKey.cs:37-38`). -- **Caveats / not-in-source**: verified by source search, neither ADC nor Store resolves or sets `ITenantContext` today. Multi-tenancy is a shipped, tested framework capability that no deployed app has opted into, so every scope in production runs unresolved and the tenant-scoped cache keys and query filters are no-ops there. +- **Walkthrough**: line 28 declares `string? TenantId { get; }`, null until resolved. Line 31 declares `bool IsResolved { get; }`. Line 41 declares `void SetTenant(string tenantId)`, and its contract is the strict part: it throws `ArgumentException` on a null, empty or whitespace id (`:37`) and `InvalidOperationException` when a *different* tenant was already resolved for this scope (`:38-40`), while accepting the value it already holds (`:34`). The rationale is on `:16-20`: **one scope, one tenant**, because a scope whose tenant changed mid-flight has already read rows under the previous tenant and there is no honest way to reconcile that afterwards. The implementation matches exactly (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/TenantContext.cs:11-44`), where `IsResolved` is simply `TenantId is not null` (`:17`), the first `SetTenant` assigns (`:24-26`), a repeat of the same value returns quietly (`:32`), and anything else throws. +- **Why it's built this way**: [ADR-073](https://ivanball.github.io/docs/adr/073-multi-tenancy-model.html) records the multi-tenancy model. Putting the contract in Application, not Infrastructure, is what lets the Application-layer caching decorators scope their keys without referencing EF Core: [CachingCommandDecorator](#cachingcommanddecoratortcommand-tresult) and [CachingQueryDecorator](#cachingquerydecoratortquery-tresult) both take it as an **optional** primary-constructor parameter defaulting to `null` (`MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingCommandDecorator.cs:36` and `.../CachingQueryDecorator.cs:38`), so a single-tenant host that never calls `AddMultiTenancy` resolves them unchanged and pays nothing. +- **Where it's used**: registered scoped in `AddMultiTenancy` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:465`). Written at the edge by [TenantResolutionMiddleware](group-12-api-hosting-mapping.md#tenantresolutionmiddleware) from a claim or a header, and re-asserted onto a fresh scope by every background path that fans out per tenant (the outbox processor and its cleanup service, [AuditTrailCleanupJob](group-07-persistence-ef-core.md#audittrailcleanupjob), and startup database initialization). Read by [DbContextFactory](group-07-persistence-ef-core.md#dbcontextfactory) for per-tenant routing (also optional) and by both caching decorators through `TenantCacheKey.Scope`, which prefixes the key only when a tenant is actually resolved (`.../Decorators/TenantCacheKey.cs:37-40`). +- **Caveats / not-in-source**: verified by source search, neither `MMCA.ADC/Source` nor `MMCA.Store/Source` resolves or sets `ITenantContext` today. Multi-tenancy is a shipped, tested framework capability that no deployed app has opted into, so every scope in production runs unresolved and the tenant-scoped cache keys and query filters are no-ops there. --- @@ -605,12 +661,12 @@ rules of the pipeline. > MMCA.Common.Application · `MMCA.Common.Application.Interfaces` · `MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IAuditTrailReader.cs:20` · Level 1 · interface - **What it is**: the one-method read surface over the recorded change history of a single entity: one page of changes, newest first. -- **Depends on**: [AuditTrailEntryDTO](group-14-module-system-composition.md#audittrailentrydto) (its return payload, `using` at line 1). Implemented by [AuditTrailReader](group-07-persistence-ef-core.md#audittrailreader) over the [AuditTrailEntry](group-07-persistence-ef-core.md#audittrailentry) rows written by the audit-trail save interceptor. +- **Depends on**: [AuditTrailEntryDTO](group-14-module-system-composition.md#audittrailentrydto) (its return payload, `using` at `IAuditTrailReader.cs:1`). Implemented by [AuditTrailReader](group-07-persistence-ef-core.md#audittrailreader) over the [AuditTrailEntry](group-07-persistence-ef-core.md#audittrailentry) rows written by the audit-trail save interceptor. - **Concept introduced, shipping the read without shipping the exposure.** `[Rubric §30, Compliance, Privacy & Data Governance]` assesses whether a system can answer "who changed this, and when"; the trail is that answer, and this is how an application asks. `[Rubric §11, Security]` assesses authorization placement, and the doc is explicit about the boundary it draws (`IAuditTrailReader.cs:10-15`): there is deliberately **no shipped endpoint or page** in v1, because who may see an entity's history is an application decision (an admin screen, a support tool, a data-subject request) rather than a framework one. Consumers wrap this in whatever query and authorization their domain calls for. `[Rubric §3, Clean Architecture]`: the contract speaks in strings and DTOs with no EF type in its signature, so the Application layer can offer history without knowing where rows live. -- **Walkthrough**: lines 37-42 declare the single member, `Task> GetForEntityAsync(string entityType, string entityKey, int page = 1, int pageSize = 50, CancellationToken cancellationToken = default)`. The two identity parameters are string-typed on purpose, because they must match what the interceptor recorded: `entityType` is the full CLR type name (lines 25-28, for example `typeof(Order).FullName`) and `entityKey` is the invariant string form of the primary key, with composite parts joined by `|` in the model's key order (lines 29-32). Paging is forgiving rather than validating: values below 1 are treated as 1 for both `page` and `pageSize` (lines 33-34). Ordering is part of the contract, not an implementation detail (lines 17-18, 23): newest change first, so the first page is the most recent activity, and the implementation makes that stable by ordering `ChangedOn` descending with the row id descending as the tie-break. -- **Why it's built this way**: [ADR-075](https://ivanball.github.io/docs/adr/075-audit-trail.html) records the trail. The interface exists at all so the read is testable and swappable, and it returns a DTO rather than the entity so the Application layer never handles a tracked row. The implementation is honest about a v1 limitation worth knowing before you build on it: trail rows are written to whichever database holds the entity that changed, which is what makes the write atomic, but this reader queries exactly one of them, the `Default` database of the engine named by `AuditTrail:DataSource`. For a monolith that is the whole trail; for a database-per-module host it is only the modules living in the default database. -- **Where it's used**: registered scoped by `AddAuditTrail`, which is opt-in per host: a host that never calls it has no implementation to resolve (`IAuditTrailReader.cs:6-7`). The reader returns an empty list rather than throwing when the trail table is absent from the model, so registering the feature before flipping `AuditTrail:Enabled` is safe. -- **Caveats / not-in-source**: verified by source search, no ADC or Store type consumes `IAuditTrailReader` today. Framework capability, no application consumer yet. +- **Walkthrough**: `IAuditTrailReader.cs:37-42` declare the single member, `Task> GetForEntityAsync(string entityType, string entityKey, int page = 1, int pageSize = 50, CancellationToken cancellationToken = default)`. The two identity parameters are string-typed on purpose, because they must match what the interceptor recorded: `entityType` is the full CLR type name (`:25-28`, for example `typeof(Order).FullName`) and `entityKey` is the invariant string form of the primary key, with composite parts joined by `|` in the model's key order (`:29-32`). Paging is forgiving rather than validating: values below 1 are treated as 1 for both `page` and `pageSize` (`:33-34`). Ordering is part of the contract, not an implementation detail (`:17-18,23`): newest change first, so the first page is the most recent activity, and the implementation makes that stable by ordering `ChangedOn` descending (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailReader.cs:63`) with the row id descending as the tie-break. +- **Why it's built this way**: [ADR-075](https://ivanball.github.io/docs/adr/075-audit-trail.html) records the trail. The interface exists at all so the read is testable and swappable, and it returns a DTO rather than the entity so the Application layer never handles a tracked row. The implementation is honest about a v1 limitation worth knowing before you build on it (`AuditTrailReader.cs:17-22`): trail rows are written to whichever database holds the entity that changed, which is what makes the write atomic, but this reader queries exactly one of them, the `Default` database of the engine named by `AuditTrail:DataSource` (resolved at `AuditTrailReader.cs:54`). For a monolith, where every source collapses onto `Default`, that is the whole trail; for a database-per-module host it is only the modules living in the default database. +- **Where it's used**: registered scoped by `AddAuditTrail` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:399`), which is opt-in per host: a host that never calls it has no implementation to resolve (`IAuditTrailReader.cs:6-7`). The reader returns an empty list rather than throwing when the trail table is absent from the model (`AuditTrailReader.cs:27-28`), so registering the feature before flipping `AuditTrail:Enabled` is safe. +- **Caveats / not-in-source**: verified by source search, no MMCA.ADC or MMCA.Store type consumes `IAuditTrailReader` today. It is a framework capability with no application consumer yet, which also means no shipped authorization decision to inherit: the first consumer owns that entirely. --- @@ -619,24 +675,62 @@ rules of the pipeline. - **What it is**: a two-line internal holder for the process-wide stripe table that the default `ICacheService.GetOrCreateAsync` implementation uses to keep concurrent misses on one key from all running the factory. - **Depends on**: [KeyedSemaphoreStripe](group-08-auth.md#keyedsemaphorestripe) (`MMCA.Common.Shared.Concurrency`, `using` at `ICacheService.cs:1`). Used only by [ICacheService](group-09-caching.md#icacheservice)'s default interface method. -- **Concept introduced, cache stampede protection and why the lock table is striped.** `[Rubric §12, Performance & Scalability]` assesses behavior under load, and this is the classic thundering-herd guard: on a cold key, N concurrent readers would otherwise all miss, all call the expensive factory, and all write the same value. The interesting part is the *shape* of the guard. A per-key semaphore table forces a bad choice, spelled out on `ICacheService.cs:135-140`: drop the entry on release and two callers can run concurrently, or never drop it and a parameterized cache key grows the table without bound. Striping sidesteps both by hashing keys onto a fixed number of semaphores ([KeyedSemaphoreStripe](group-08-auth.md#keyedsemaphorestripe) defaults to 256 stripes) and accepting that two unrelated keys occasionally share one. That is a fixed, bounded cost, and the stripes are never disposed because the table outlives every caller. -- **Walkthrough**: line 142 declares `internal static class CacheKeyLocks`; line 145 declares its only member, `internal static readonly KeyedSemaphoreStripe Locks = new()`. The consuming sequence is the double-checked idiom at `ICacheService.cs:104-124`: a lock-free `GetAsync` fast path returns immediately on a hit (lines 107-110), then the stripe is taken (line 112), then the key is re-read inside the stripe (lines 116-118) so the waiters see what the winner just wrote, and only a still-missing key runs the factory and stores it (lines 120-122). The class doc (lines 127-133) explains the non-generic holder: statics on a generic method's declaring type would already be shared, but a holder keeps the table addressable and matches the sibling [QueryCacheKeyLocks](#querycachekeylocks) in the caching decorator (`MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingQueryDecorator.cs:194-197`). -- **Why it's built this way**: the two tables are separate **on purpose** (`ICacheService.cs:137-140`): these are different call sites over different keys, so sharing stripes would only widen the unrelated-key collisions striping already tolerates. Two limits of the mechanism are documented on the member it guards (`ICacheService.cs:80-97`) and matter more than the class itself. First, **caching there is unconditional**: whatever the factory returns is stored, including a failed [Result](group-01-result-error-handling.md#result), which is exactly why the caching decorators do NOT route through `GetOrCreateAsync` and keep their own read/execute/write sequence. Second, **stampede protection is per process**: the stripe table is process-wide, so with several replicas over one shared cache the factory can still run once per replica; a cluster-wide guarantee would need an [IDistributedLock](#idistributedlock) and is deliberately not attempted here. -- **Where it's used**: only by the default implementation of `ICacheService.GetOrCreateAsync` (`ICacheService.cs:112`). Backing stores with a native two-level primitive (see [HybridCacheService](group-09-caching.md#hybridcacheservice)) override the method and never touch this table (`ICacheService.cs:92-97`). +- **Concept introduced, cache stampede protection and why the lock table is striped.** `[Rubric §12, Performance & Scalability]` assesses behavior under load, and this is the classic thundering-herd guard: on a cold key, N concurrent readers would otherwise all miss, all call the expensive factory, and all write the same value. The interesting part is the *shape* of the guard. A per-key semaphore table forces a bad choice, spelled out on `ICacheService.cs:135-140`: drop the entry on release and two callers can run concurrently, or never drop it and a parameterized cache key grows the table without bound. Striping sidesteps both by hashing keys onto a fixed number of semaphores ([KeyedSemaphoreStripe](group-08-auth.md#keyedsemaphorestripe)) and accepting that two unrelated keys occasionally share one. That is a fixed, bounded cost, and the stripes are never disposed because the table outlives every caller. +- **Walkthrough**: line 142 declares `internal static class CacheKeyLocks`; line 145 declares its only member, `internal static readonly KeyedSemaphoreStripe Locks = new()`. The consuming sequence is the double-checked idiom at `ICacheService.cs:99-124`: a null-check on the factory (`:105`), a lock-free `GetAsync` fast path that returns immediately on a hit (`:107-110`), then the stripe is taken (`:112`), then the key is re-read inside the stripe (`:116-118`) so the waiters see what the winner just wrote, and only a still-missing key runs the factory and stores it (`:120-122`). The class doc (`:127-133`) explains the non-generic holder: statics on a generic method's declaring type would already be shared, but a holder keeps the table addressable and matches the sibling [QueryCacheKeyLocks](#querycachekeylocks) in the caching decorator (`MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingQueryDecorator.cs:194`, used at `:89`). +- **Why it's built this way**: the two tables are separate **on purpose** (`ICacheService.cs:137-140`): these are different call sites over different keys, so sharing stripes would only widen the unrelated-key collisions striping already tolerates. Two limits of the mechanism are documented on the member it guards (`ICacheService.cs:80-97`) and matter more than the class itself. First, **caching there is unconditional**: whatever the factory returns is stored, including a failed [Result](group-01-result-error-handling.md#result) or a null-equivalent value, which is exactly why the caching decorators do NOT route through `GetOrCreateAsync` and keep their own read/execute/write sequence (`:82-86`). Second, **stampede protection is per process**: the stripe table is process-wide, so with several replicas over one shared cache the factory can still run once per replica; a cluster-wide guarantee would need an [IDistributedLock](#idistributedlock) and is deliberately not attempted here (`:88-91`). +- **Where it's used**: only by the default implementation of `ICacheService.GetOrCreateAsync` (`ICacheService.cs:112`). Backing stores with a native two-level primitive (see [HybridCacheService](group-09-caching.md#hybridcacheservice)) override the method and never touch this table (`:92-97`). - **Caveats / not-in-source**: the type is `internal`, so it is not part of the public package surface and cannot be referenced or replaced from a consumer app; it is documented here because the behavior it produces is visible to anyone calling `GetOrCreateAsync`. --- +### IEventUpcaster +> MMCA.Common.Application · `MMCA.Common.Application.Interfaces` · `MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEventUpcaster.cs:28` · Level 2 · interface (plus its typed generic sibling in the same file) + +- **What it is**: the contract for converting one **retired** integration-event contract into its successor, so handlers are written once against the newest shape while older messages (queued at the broker, or sitting unprocessed in an outbox written before the upgrade) keep being delivered. The file declares two interfaces: the non-generic `IEventUpcaster` the framework resolves and indexes by (`:28`), and the typed `IEventUpcaster` application code actually implements (`:67`). +- **Depends on**: [IIntegrationEvent](group-04-events-outbox.md#iintegrationevent) (`using` at `:2`) as the type of both ends of the conversion, and `System.Diagnostics.CodeAnalysis.SuppressMessage` (BCL). Composed by [IEventUpcasterRegistry](#ieventupcasterregistry) and registered through `AddEventUpcaster()`. +- **Concept introduced, event-schema evolution by additive versioning instead of in-place reshaping.** `[Rubric §6, CQRS & Event-Driven]` assesses whether published event contracts can change without breaking subscribers, and `[Rubric §9, API & Contract Design]` assesses versioning of the contracts a system publishes. The policy behind this type is that a breaking event-shape change (a renamed, removed or retyped field) is a **new event type plus a consumer-side upcaster**, never a silent edit of an existing type ([ADR-010](https://ivanball.github.io/docs/adr/010-integration-event-schema-versioning.html)); this interface plus its registration extension point is how that policy is actually expressed in code ([ADR-090](https://ivanball.github.io/docs/adr/090-event-upcaster-registration.html), cited at `:15-19`). The teaching point for a reader new to the pattern: *upcasting is a read-side concern*. Producers are never asked to publish two shapes, and handlers are never asked to accept two shapes. The conversion happens once, at the boundary between "what arrived" and "what the handlers are written for". `[Rubric §7, Microservices Readiness]` also applies, because a broker in front of independently-deployed services is exactly the environment where producer and consumer versions diverge for a while. `[Rubric §16, Maintainability]`: a chain of small pure functions is deletable in the order it was added, which is why the registration docs describe the retirement path (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:275-281`). +- **Walkthrough**, taking the two interfaces in the order the framework sees them. + - The **non-generic** `IEventUpcaster` (`:28`) declares three members: `Type SourceType { get; }` (`:31`), the retired contract it reads; `Type TargetType { get; }` (`:34`), the successor it produces; and `IIntegrationEvent Upcast(IIntegrationEvent integrationEvent)` (`:41`). This is the shape the registry indexes by and walks chains with, so nothing in the framework needs to know the closed generic types. + - The **typed** `IEventUpcaster : IEventUpcaster` (`:67`) is what an application implements. Both parameters are constrained `class, IIntegrationEvent` (`:68-69`), and the variance annotations (`in`/`out`) are the natural ones for a converter. Its whole trick is **default interface implementations**: `SourceType => typeof(TSource)` (`:72`), `TargetType => typeof(TTarget)` (`:75`), and an explicit `IIntegrationEvent IEventUpcaster.Upcast(...)` that downcasts and forwards to the typed overload (`:85-86`). An implementer therefore writes exactly one method, `TTarget Upcast(TSource integrationEvent)` (`:82`), and gets the non-generic surface for free. + - The `[SuppressMessage]` on CA1033 (`:63-66`) documents why: a default interface implementation of an *inherited* member can only be written as an explicit implementation, so there is no non-explicit form to offer child types. + - **Map payload fields only** (`:21-26` and `:58-61`). The framework preserves the envelope: after every hop the registry stamps `MessageId` and `DateOccurred` from the pre-hop instance onto the upcasted one, so consumer-side inbox deduplication keeps working on the id the *producer* published. An upcaster that copies them itself is harmless (the stamp is idempotent) and one that forgets is still correct. +- **Why it's built this way**: splitting the non-generic index surface from the typed authoring surface is what lets one registry hold heterogeneous upcasters in a single `Dictionary` while implementers still write strongly typed code with no casts. Registration names both contracts explicitly, `services.AddEventUpcaster()` (`.../DependencyInjection.cs:283-289`), so the compiler checks the shape at the registration site rather than leaving a mismatch to fail on the first message (`:265-270`); implementations are registered **singleton** through `TryAddEnumerable` because they are pure functions (`:288`). Chains compose: registering V1 to V2 and V2 to V3 delivers a V1 message to the V3 handler (`IEventUpcaster.cs:56`). +- **Where it's used**: composed by [IEventUpcasterRegistry](#ieventupcasterregistry) and thereby reached from both delivery paths, the in-process [DomainEventDispatcher](group-04-events-outbox.md#domaineventdispatcher) and the broker-side [UpcastingIntegrationEventConsumer](group-07-persistence-ef-core.md#upcastingintegrationeventconsumertevent). Two architecture fitness rules police the shape across every repo, living once in the shared rules package: `EventUpcastersHaveUniqueSourceTypes` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Upcasters.cs:12`), because with two upcasters reading one type the contract a handler receives would depend on DI registration order, and `EventUpcastersIncreaseSchemaVersion` (`:28`), which reads the `SchemaVersion` off both contracts and fails when the target is not strictly higher (`:44-48`). The rules match the interface **by name and arity** (an ordinal comparison against the runtime interface name for `IEventUpcaster` with generic arity 2, `:81-83`) so the rule library keeps its no-compile-dependency idiom. `SchemaVersion` itself is the `virtual int` on [BaseIntegrationEvent](group-04-events-outbox.md#baseintegrationevent) that defaults to 1 (`MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/BaseIntegrationEvent.cs:32`). +- **Caveats / not-in-source**: verified by source search, **no `Source/` tree in the workspace contains an `IEventUpcaster` implementation today**, in the framework or in MMCA.ADC, MMCA.Store or MMCA.Helpdesk. Every implementation is a test fixture (for example `MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Services/EventUpcasterRegistryTests.cs:38,44` and the deliberately non-compliant fixtures the fitness rules assert against, `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:54-78`). The mechanism is fully built and tested and has not yet had to be used on a real contract; the doc comment on the framework's own [OutputCacheEvictionRequested](group-04-events-outbox.md#outputcacheevictionrequested) records this as the shape a future V2 would take (`MMCA.Common/Source/Core/MMCA.Common.Domain/IntegrationEvents/OutputCacheEvictionRequested.cs:21-23`). + +--- + +### IEventUpcasterRegistry +> MMCA.Common.Application · `MMCA.Common.Application.Interfaces` · `MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEventUpcasterRegistry.cs:24` · Level 2 · interface + +- **What it is**: the composed view of every registered [IEventUpcaster](#ieventupcaster). It answers three questions: does anything upcast this type, what is the newest contract this type ends up as, and give me this instance converted to that contract, walking the whole chain. +- **Depends on**: [IIntegrationEvent](group-04-events-outbox.md#iintegrationevent) (`using` at `:1`) and [IEventUpcaster](#ieventupcaster). Implemented by `EventUpcasterRegistry` (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/EventUpcasterRegistry.cs:30`). +- **Concept introduced, the empty-registry identity default.** `[Rubric §10, Cross-Cutting Concerns]` assesses how an optional capability is threaded through a pipeline without every call site having to test for its presence. The registry is registered **unconditionally** by `AddApplication()` (`.../Application/DependencyInjection.cs:40`, comment at `:36-39`): with no upcasters registered it is an empty registry whose operations are the identity function, so both delivery paths can depend on it without a null check or a feature flag ([ADR-090](https://ivanball.github.io/docs/adr/090-event-upcaster-registration.html)). `[Rubric §15, Best Practices & Code Quality]` and `[Rubric §13, Observability & Operability]` both apply to the failure model: a misregistration is a programming error, so it throws at composition time naming the offenders rather than returning a `Result` (`EventUpcasterRegistry.cs:14-20`), and a dedicated hosted service makes that happen at host start rather than on the first message. +- **Walkthrough**: three members on the interface, and the implementation behind each is worth reading. + - `bool HasUpcasterFor(Type eventType)` (`:31`) is a dictionary probe (`EventUpcasterRegistry.cs:85-90`). + - `Type ResolveTerminalType(Type eventType)` (`:39`) returns the newest contract the type upcasts to, or the type itself when nothing claims it (`EventUpcasterRegistry.cs:93-98`). It is a **precomputed** lookup, not a walk: `BuildTerminalTypes` resolves every chain once in the constructor (`:133-161`), because the chain graph is static once DI is built. + - `IIntegrationEvent UpcastToTerminal(IIntegrationEvent integrationEvent)` (`:48`) applies every hop and preserves the envelope at each one (`EventUpcasterRegistry.cs:101-123`). Two details in the loop repay attention: it advances by the upcaster's **declared** `TargetType` rather than the runtime type of what was returned (`:119`, with the comment at `:108-109` noting that the constructor's acyclicity check is what bounds the loop), and a `null` return from an upcaster throws with the offender named (`:112-114`). + - **Validation is constructor-time** (`EventUpcasterRegistry.cs:50-82`). A self-mapping upcaster (`:59-63`) and two upcasters claiming one source (`:65-69`) are collected into an `offenders` list, so a misconfigured host sees *all* the problems at once rather than one per restart, then a single `InvalidOperationException` is thrown (`:76-79`). Cycles are caught separately in `BuildTerminalTypes` by walking each chain with a `visited` set and reporting the chain in order (`:143-155`); the comment explains why a repeated type is definitely a cycle and not a diamond (`:126-128`): the duplicate-source check already made the graph functional. + - **Envelope preservation** (`EventUpcasterRegistry.cs:169-179`) reads `MessageId` and `DateOccurred` off the pre-hop instance and writes them onto the upcasted one through cached `PropertyInfo` handles, held in a static `ConcurrentDictionary` keyed by the produced type (`:36`) so a chain pays the reflection lookup once per contract. Both properties are `init`-only on `BaseDomainEvent`, which reflection can still set (`:24-25`), and a non-writable property is simply skipped (`Writable`, `:181-182`). +- **Why it's built this way**: [ADR-090](https://ivanball.github.io/docs/adr/090-event-upcaster-registration.html). Making envelope preservation *the registry's* job rather than the author's is the load-bearing choice: it means consumer-side inbox deduplication stays keyed on the id the producer published **by construction**, so no upcaster author can break deduplication by forgetting to copy a field they were never asked to think about (`EventUpcasterRegistry.cs:22-27`). Precomputing terminal types and caching envelope reflection keeps the per-message cost to dictionary lookups and delegate calls. +- **Where it's used**: registered singleton by `AddApplication()` (`.../Application/DependencyInjection.cs:40`) and consumed by both delivery paths. + - In-process: [DomainEventDispatcher](group-04-events-outbox.md#domaineventdispatcher) holds it as a `Lazy` resolved with `GetService` (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/DomainEventDispatcher.cs:32-33`) and runs the integration branch through it before resolving handlers, so the handlers invoked are the ones written against the newest type (`:62-64`). + - Broker-side: [UpcastingIntegrationEventConsumer](group-07-persistence-ef-core.md#upcastingintegrationeventconsumertevent) takes it as a constructor dependency (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/UpcastingIntegrationEventConsumer.cs:32`) and dedups on the **original** message id before any upcasting (`:46-48`), registered per retired type with `RegisterUpcastedIntegrationEventConsumer()` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/IntegrationEventConsumerExtensions.cs:78-90`). That doc is explicit that you must not also register the plain [IntegrationEventConsumer](group-04-events-outbox.md#integrationeventconsumertevent) for the same type: two consumers on one event compete for the same queue and run the handlers twice (`:61-64`). + - Startup: [EventUpcasterStartupValidator](group-07-persistence-ef-core.md#eventupcasterstartupvalidator) is an `IHostedService` whose entire job is to resolve the registry and touch one member, turning the constructor validation into a host-start failure (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/EventUpcasterStartupValidator.cs:20,23-30`). It is registered by `AddInfrastructure` through `TryAddEnumerable` (`.../Infrastructure/DependencyInjection.cs:161`) so several modules calling it do not run the validation several times. +- **Caveats / not-in-source**: because no host registers an upcaster today (see [IEventUpcaster](#ieventupcaster)), every production resolution of this type is the empty-registry identity path; `UpcastToTerminal` returns its argument and `ResolveTerminalType` returns its argument. The chain-walking, cycle detection and envelope preservation described above are exercised only by tests at present. + +--- + ### IEntityDTOProjector > MMCA.Common.Application · `MMCA.Common.Application.Interfaces` · `MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEntityDTOProjector.cs:51` · Level 4 · interface - **What it is**: an opt-in, one-method contract that rewrites an entity `IQueryable` into a DTO `IQueryable`, so the database returns only the columns the DTO actually has instead of whole entity rows that are mapped afterwards. - **Depends on**: [AuditableBaseEntity](group-02-domain-building-blocks.md#auditablebaseentitytidentifiertype) and [IBaseDTO](group-12-api-hosting-mapping.md#ibasedtotidentifiertype) as generic constraints (`IEntityDTOProjector.cs:52-54`, `using`s at `:1-2`). It is the pushdown counterpart of [IEntityDTOMapper](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype), consumed by [EntityQueryService](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype) and executed through [IEntityQueryPipeline](group-03-querying-specifications.md#ientityquerypipeline). Implementations are typically Mapperly-generated (Riok.Mapperly, NuGet). -- **Concept introduced, projection pushdown as an optional, additive read path.** `[Rubric §12, Performance & Scalability]` assesses whether reads pay for data they do not return; the doc states the two costs the entity path incurs (`IEntityDTOProjector.cs:9-16`): the query must select whole entities (every column, plus a join per include the DTO happens to flatten), and every materialized row is mapped in .NET afterwards. A projector removes both by making the provider select the DTO's columns directly. `[Rubric §8, Data Architecture]` assesses how much shaping is pushed to the database. The design point worth internalising is that this is **additive, never required**: registering one for an entity is what switches that entity's list reads onto the projected path, and nothing breaks when none is registered because the query service falls back to materialize-then-map. The remarks then bound what a projection can express (`:36-41`): it is an expression tree the provider must translate, so no instance sub-mappers, no custom mapping methods (`Use = nameof(...)`), no after-map hooks, nothing that would have to run in .NET on a materialized object. A DTO whose shape needs any of those simply does not get a projector. -- **Walkthrough**: line 51 declares `public interface IEntityDTOProjector`, constrained to an auditable entity, an `IBaseDTO`, and a `notnull` key (lines 52-54). Line 62 declares the single member, `IQueryable ProjectTo(IQueryable source)`, whose contract is stated in two halves: the input is an entity queryable **already filtered, sorted, and paged** (line 60), and the output must still be a translatable queryable, so an implementation must not materialize inside `ProjectTo` (lines 56-58). The doc carries a worked example (lines 23-35) of the idiomatic shape: a Mapperly `[Mapper]` static partial class exposing `ProjectToDTO`, wrapped by a small `sealed class` implementing this interface. The last remark is the correctness obligation (`:42-46`): a projector MUST produce the same values as the entity's mapper for the same row, because the two paths are chosen by registration, so a divergence would make a response depend on whether a projector happened to be registered. The doc says to pin the equivalence with a test, and the framework's own projector does exactly that. +- **Concept introduced, projection pushdown as an optional, additive read path.** `[Rubric §12, Performance & Scalability]` assesses whether reads pay for data they do not return; the doc states the two costs the entity path incurs (`IEntityDTOProjector.cs:9-16`): the query must select whole entities (every column, plus a JOIN per include the DTO happens to flatten), and every materialized row is mapped in .NET afterwards. A projector removes both by making the provider select the DTO's columns directly. `[Rubric §8, Data Architecture]` assesses how much shaping is pushed to the database. The design point worth internalising is that this is **additive, never required**: registering one for an entity is what switches that entity's list reads onto the projected path, and nothing breaks when none is registered because the query service falls back to materialize-then-map. The remarks then bound what a projection can express (`:36-41`): it is an expression tree the provider must translate, so no instance sub-mappers, no custom mapping methods (`Use = nameof(...)`), no after-map hooks, nothing that would have to run in .NET on a materialized object. A DTO whose shape needs any of those simply does not get a projector, and its reads keep using the mapper. +- **Walkthrough**: line 51 declares `public interface IEntityDTOProjector`, constrained to an auditable entity, an `IBaseDTO`, and a `notnull` key (`:52-54`). Line 62 declares the single member, `IQueryable ProjectTo(IQueryable source)`, whose contract is stated in two halves: the input is an entity queryable **already filtered, sorted, and paged** (`:60`), and the output must still be a translatable queryable, so an implementation must not materialize inside `ProjectTo` (`:56-58`). The doc carries a worked example (`:23-35`) of the idiomatic shape: a Mapperly `[Mapper]` static partial class exposing `ProjectToDTO`, wrapped by a small `sealed class` implementing this interface. The last remark is the correctness obligation (`:42-46`): a projector MUST produce the same values as the entity's mapper for the same row, because the two paths are chosen by registration, so a divergence would make a response depend on whether a projector happened to be registered. The doc says to pin the equivalence with a test, and the framework's own projector does exactly that. - **Why it's built this way**: [ADR-055](https://ivanball.github.io/docs/adr/055-repository-and-specification-contract.html) records the optional projector on the read contract. The interesting mechanical detail is how "optional" is expressed in DI, because `Microsoft.Extensions.DependencyInjection` has no notion of an optional dependency: a single constructor naming an unregistered service fails to resolve, default value or not. [EntityQueryService](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype) therefore declares a **second, longer constructor** that takes the projector (`EntityQueryService.cs:69-77`, rationale at `:51-61`); the container picks the longer one when a projector is registered and the shorter one when it is not, with no ambiguity because one parameter set is a strict superset of the other, and existing subclasses keep compiling untouched. -- **Where it's used**: discovered by convention. `ScanModuleApplicationServices()` scans a module assembly for `IEntityDTOProjector<,,>` and registers each as itself plus its interfaces, scoped, beside the DTO mappers (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:158-162`), so a module only has to write the projector class. At read time [EntityQueryService](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype) holds it as a nullable `DTOProjector` property (`EntityQueryService.cs:84`) and takes the projected branch only when `CanProject` is true (`:303-313`, predicate at `:489-492`). That predicate is three conditions: a projector is registered, the caller did **not** ask for tracking, and the query has no unsupported (cross-source) includes, because those are loaded row by row after materialization by the navigation populator and a projection has no rows to hand it (`:476-483`). Field shaping deliberately does not disqualify (`:484-487`). The framework ships one worked implementation, `PushNotificationDTOProjector` (`MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/DTOs/PushNotificationDTOProjector.cs:35-45`, see [PushNotificationDTOProjector](group-10-notifications.md#pushnotificationdtoprojector)), registered explicitly by the notification module (`.../Notifications/DependencyInjection.cs:50-52`). -- **Caveats / not-in-source**: verified by source search, **neither MMCA.ADC nor MMCA.Store registers a projector today**; outside the framework's own notification projector the only implementation in the workspace is MMCA.Helpdesk's `TicketDTOProjector` (`MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Application/Tickets/DTOs/TicketDTOProjector.cs:38-47`), which exists as the reference-app demonstration. Every list read in both production apps therefore still runs the materialize-then-map path. The equivalence obligation is also a convention, not a compiler rule: the framework's projector documents an enum-to-string divergence it had to inline by hand (`PushNotificationDTOProjector.cs:13-20`) and pins it with a test, but nothing stops a new projector from quietly disagreeing with its mapper. +- **Where it's used**: discovered by convention. `ScanModuleApplicationServices()` scans a module assembly for `IEntityDTOProjector<,,>` and registers each as itself plus its interfaces, scoped, beside the DTO mappers (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:166-170`, with the opt-in rationale in the comment at `:163-165`), so a module only has to write the projector class. At read time [EntityQueryService](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype) holds it as a nullable `DTOProjector` property (`EntityQueryService.cs:84`) and takes the projected branch only when `CanProject` is true (`:303-313`, predicate at `:489-492`). That predicate is three conditions: a projector is registered, the caller did **not** ask for tracking, and the query has no unsupported (cross-source) includes, because those are loaded row by row after materialization by the navigation populator and a projection has no rows to hand it (`:476-482`). Field shaping deliberately does not disqualify, because shaping runs after materialization over whatever object the pipeline produced (`:484-487`). The framework ships one worked implementation, [PushNotificationDTOProjector](group-10-notifications.md#pushnotificationdtoprojector) (`MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/DTOs/PushNotificationDTOProjector.cs:35-45`), registered explicitly by the notification module (`.../Notifications/DependencyInjection.cs:50-52`). +- **Caveats / not-in-source**: verified by source search, **neither MMCA.ADC nor MMCA.Store registers a projector today**; outside the framework's own notification projector the only implementation in the workspace is MMCA.Helpdesk's `TicketDTOProjector` (`MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Application/Tickets/DTOs/TicketDTOProjector.cs:38-47`), which exists as the reference-app demonstration. Every list read in both production apps therefore still runs the materialize-then-map path. The equivalence obligation is also a convention, not a compiler rule: the framework's projector documents an enum-to-string divergence it had to inline by hand (the entity's `Status` is an enum, the DTO's is a string, and the instance mapper's `Use = nameof(MapStatusToString)` is not expressible in an expression tree, so the projection inlines a conditional that the provider renders as a SQL `CASE`, `PushNotificationDTOProjector.cs:13-19`) and pins it with a test, but nothing stops a new projector from quietly disagreeing with its mapper. --- diff --git a/docs-src/onboarding/group-07-persistence-ef-core.md b/docs-src/onboarding/group-07-persistence-ef-core.md index 83c47e7..6a74561 100644 --- a/docs-src/onboarding/group-07-persistence-ef-core.md +++ b/docs-src/onboarding/group-07-persistence-ef-core.md @@ -10,15 +10,17 @@ field-level change trail; a small repository family behind an interface-segregat ([`IReadRepository`](#ireadrepositorytentity-tidentifiertype), [`IWriteRepository`](#iwriterepositorytentity-tidentifiertype), [`IRepository`](#irepositorytentity-tidentifiertype)) coordinated by a -[`UnitOfWork`](#unitofwork); a data-source routing layer that lets every entity resolve to its own -physical database ("database per service") and every tenant optionally to its own copy of it; two -model-finalizing conventions that keep that routing honest; an engine-portable entity-configuration -hierarchy; and a supporting cast of value converters, value generators, an encryption converter, -seeders, and design-time factories. The group also hosts the framework's non-EF storage-adjacent -services: blob storage, image normalization, native push registration and delivery, and the shared -periodic-sweep base class. The whole thing is the [Rubric §8, Data Architecture] chapter of the -codebase, and it leans hard on [Rubric §7, Microservices Readiness] and -[Rubric §3, Clean Architecture]. +[`UnitOfWork`](#unitofwork), with the query-shaping helpers +([`SpecificationEvaluator`](#specificationevaluator), [`KeysetQueryBuilder`](#keysetquerybuilder)) +that turn a specification or a cursor into SQL; a data-source routing layer that lets every entity +resolve to its own physical database ("database per service") and every tenant optionally to its own +copy of it; two model-finalizing conventions that keep that routing honest; an engine-portable +entity-configuration hierarchy; and a supporting cast of value converters, value generators, an +encryption converter, seeders, and design-time factories. The group also hosts the framework's +non-EF storage-adjacent services: blob storage, image normalization, native push registration and +delivery, and the shared periodic-sweep base class. The whole thing is the +[Rubric §8, Data Architecture] chapter of the codebase, and it leans hard on +[Rubric §7, Microservices Readiness] and [Rubric §3, Clean Architecture]. ## One base context, one class per engine, one instance per database @@ -38,9 +40,10 @@ the framework's own bookkeeping tables so every relational database carries its [`ScheduledJobEntry`](group-14-module-system-composition.md#scheduledjobentry) at `:538-563`, [`AuditTrailEntry`](#audittrailentry) at `:572-601`), each with the filtered indexes its poll path and its retention sweep need (`IX_OutboxMessages_Pending` at `:496-499`, `IX_OutboxMessages_Processed` at -`:504-506`, `IX_InboxMessages_MessageId` at `:520-522`, `IX_ScheduledJobs_NextRunOn` at `:559-561`, -`IX_AuditTrailEntries_Entity` at `:592-593`). Two of those four tables are **gated**: the job table is -mapped only when `Scheduler:Enabled` is set AND this context targets the `Default` source (jobs are +`:504-506`, `IX_InboxMessages_MessageId` at `:520-522`, `IX_InboxMessages_ProcessedOn` at `:526-527`, +`IX_ScheduledJobs_NextRunOn` at `:559-561`, `IX_AuditTrailEntries_Entity` at `:592-593`, +`IX_AuditTrailEntries_ChangedOn` at `:598-599`). Two of those four tables are **gated**: the job table +is mapped only when `Scheduler:Enabled` is set AND this context targets the `Default` source (jobs are host-scoped, `:266-268`), the trail table only when `AuditTrail:Enabled` is set, on every relational source (a trail row must commit with the change it describes, and a transaction does not span databases, `:271`). A host that opted into neither keeps the model it had before those features @@ -59,7 +62,7 @@ otherwise trigger a full `DetectChanges`, so a save paid three snapshot comparis suffices, and the previous auto-detect setting is restored on the way out (`:225`). The design decision that shapes this whole group is stated in the base's own doc comment: **one -context class per engine, one instance per physical data source** (`ApplicationDbContext.cs:28-33`). +context class per engine, one instance per physical data source** (`ApplicationDbContext.cs:29-33`). The same [`SQLServerDbContext`](#sqlserverdbcontext) class (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/SQLServerDbContext.cs:16`) is instantiated once per SQL Server database, each instance carrying a different @@ -67,9 +70,9 @@ is instantiated once per SQL Server database, each instance carrying a different name). To keep EF from silently reusing the first-built model for every database, [`DataSourceModelCacheKeyFactory`](#datasourcemodelcachekeyfactory) (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/DataSourceModelCacheKeyFactory.cs:16`) -keys EF's model cache by context type plus physical source name plus the design-time flag, and is -installed by the base in `OnConfiguring` (`ApplicationDbContext.cs:276`). This is deliberately not a -per-module context split: one sealed context per engine over the abstract base is +keys EF's model cache by context type plus physical source name plus the design-time flag (`:19-22`), +and is installed by the base in `OnConfiguring` (`ApplicationDbContext.cs:276`). This is deliberately +not a per-module context split: one sealed context per engine over the abstract base is [ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)'s ruling. `SQLServerDbContext` adds the provider-specific touches: a per-environment command timeout read from [`PersistenceSettings`](group-14-module-system-composition.md#persistencesettings) rather than @@ -133,27 +136,29 @@ justifies it. The exclusion is by instance rather than by entity state on purpos would also drop events raised on an already-saved aggregate, which is how the identity module publishes its registration events (`:155-159`). -After the save, `SavedChangesAsync` does one of two things (`DomainEventSaveChangesInterceptor.cs:278-295`). -With no ambient transaction it flushes immediately: dispatch local events through +After the save, the post-save path `DispatchAndFinalizeAsync` +(`DomainEventSaveChangesInterceptor.cs:278-295`, reached from `SavedChangesAsync` at `:89-98`) does one +of two things. With no ambient transaction it flushes immediately: dispatch local events through [`IDomainEventDispatcher`](group-04-events-outbox.md#idomaineventdispatcher), remove exactly the -captured events from their aggregates, mark the local outbox rows processed, and signal the outbox for -integration events (`:301-329`). With an active transaction it removes the captured events (so a second -save inside the same transaction cannot re-capture them) and parks a -[`DeferredDispatch`](#deferreddispatch) (`:365`) in a second weak table (`:55`); -[`DbContextFactory`](#dbcontextfactory) then calls the static `FlushDeferredAsync` only after a -successful commit (`:128-137`) and `DropDeferred` on rollback (`:145`). That is what keeps handler side -effects from acting on state that could still roll back, and what keeps a retrying execution strategy -from dispatching the same events once per attempt. Note the precision of the clearing: the interceptor -calls `RemoveDomainEvents(capture.Events)` rather than clearing the aggregate wholesale (`:337-341`), so -an event a handler raises on the same aggregate during in-process dispatch survives to a later capture -instead of being wiped. If in-process dispatch throws, the interceptor logs a warning and signals the -outbox to retry from the persisted rows rather than losing the event (`:315-323`). The synchronous -`SavedChanges` path cannot await a dispatcher at all, so it removes the captured events, signals the -outbox, and leaves delivery entirely to it (`:108-121`). Cosmos DB has no relational outbox table, so -the base exposes a `SupportsOutbox` flag (`ApplicationDbContext.cs:116`) that -[`CosmosDbContext`](#cosmosdbcontext) overrides to `false` (`CosmosDbContext.cs:69`) and the interceptor -honors by dispatching everything in-process instead (`:239-244`). This split, atomic persistence plus -best-effort immediate dispatch with a durable fallback, is the at-least-once contract of +captured events from their aggregates, mark the local outbox rows processed through +[`OutboxFinalizer`](group-04-events-outbox.md#outboxfinalizer), and signal the outbox for integration +events (`:301-329`). With an active transaction it removes the captured events (so a second save inside +the same transaction cannot re-capture them) and parks a [`DeferredDispatch`](#deferreddispatch) +(`:365`) in a second weak table (`:55`); [`DbContextFactory`](#dbcontextfactory) then calls the static +`FlushDeferredAsync` only after a successful commit (`:128-137`) and `DropDeferred` on rollback +(`:145`). That is what keeps handler side effects from acting on state that could still roll back, and +what keeps a retrying execution strategy from dispatching the same events once per attempt. Note the +precision of the clearing: the interceptor calls `RemoveDomainEvents(capture.Events)` rather than +clearing the aggregate wholesale (`:337-341`), so an event a handler raises on the same aggregate +during in-process dispatch survives to a later capture instead of being wiped. If in-process dispatch +throws, the interceptor logs a warning and signals the outbox to retry from the persisted rows rather +than losing the event (`:315-323`). The synchronous `SavedChanges` path cannot await a dispatcher at +all, so it removes the captured events, signals the outbox, and leaves delivery entirely to it +(`:108-121`). Cosmos DB has no relational outbox table, so the base exposes a `SupportsOutbox` flag +(`ApplicationDbContext.cs:116`) that [`CosmosDbContext`](#cosmosdbcontext) overrides to `false` +(`CosmosDbContext.cs:69`) and the interceptor honors by dispatching everything in-process instead +(`:239-244`). This split, atomic persistence plus best-effort immediate dispatch with a durable +fallback, is the at-least-once contract of [ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html); the consumer end lives in [Group 04](group-04-events-outbox.md). @@ -169,26 +174,29 @@ lifts `CurrentTenantId` into a SQL parameter, letting **one compiled model serve so a scope with no tenant (the outbox processor, the seeders, the retention jobs) sees every tenant's rows (`:435-441`); and the column itself is declared required, 64 characters, non-Unicode, and **indexed** on relational engines, because every tenant-scoped read carries it as the leading predicate -(`:411-422`, width constant at `:366`). Because the two filters are named, EF composes them with AND, -and a caller asking for soft-deleted rows drops exactly the `SoftDelete` filter while the tenant filter -stays in force: the repository contract says so in as many words -(`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:16-20`, -`:41-45`). +(`:411-422`, width constant at `:366`). The filter reads the value through `EF.Property` rather than a +CLR member access, so an explicitly implemented interface member or a shadow property translates +identically (`:428-433`). Because the two filters are named, EF composes them with AND, and a caller +asking for soft-deleted rows drops exactly the `SoftDelete` filter while the tenant filter stays in +force: the repository contract says so in as many words +(`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:16-19`, +`:75-79`). The **write** half is [`TenantSaveChangesInterceptor`](#tenantsavechangesinterceptor) (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/TenantSaveChangesInterceptor.cs:36`). It stamps the scope's tenant onto an insert that declares none (`:116-120`), refuses an insert that names a different one (`:122-123`), and on update or delete checks **both** the original and the current value, so touching another tenant's row and reassigning a row to another tenant are both rejected -(`:131-153`). An untenanted insert from an untenanted scope is refused too, because silently writing a -row no tenant can ever read is worse than failing the save (`:107-110`). Owned types are skipped on both -sides: an owned value has no independent existence and its owner's tenant is already the row's tenant -(`:70-75`). Failures surface as [`CrossTenantWriteException`](#crosstenantwriteexception) +(`:131-153`, with the original reported in preference to the current one at `:155-167`). An untenanted +insert from an untenanted scope is refused too, because silently writing a row no tenant can ever read +is worse than failing the save (`:107-110`). Owned types are skipped on both sides: an owned value has +no independent existence and its owner's tenant is already the row's tenant (`:70-75`). Failures +surface as [`CrossTenantWriteException`](#crosstenantwriteexception) (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/CrossTenantWriteException.cs:24`), which derives from `InvalidOperationException` so existing catch sites treat it like any other save-time invariant failure (`:19-22`). The deliberate asymmetry is documented in the interceptor's own remarks: a caller who bypasses the read filter with EF's parameterless `IgnoreQueryFilters()` can read across -tenants, but still cannot write across them (`:30-34`). That is [Rubric §11, Security] and +tenants, but still cannot write across them (`:29-34`). That is [Rubric §11, Security] and [Rubric §30, Compliance and Data Governance] in one type. The scope's tenant itself lives in [`TenantContext`](#tenantcontext) (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/TenantContext.cs:11`), which is @@ -198,7 +206,7 @@ sweeps expand their work list through [`TenantDataSourceTargets`](#tenantdatasou (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/TenantDataSourceTargets.cs:49-79`), which emits the shared target for every source plus one extra [`TenantDataSourceTarget`](#tenantdatasourcetarget) (`:13`) per tenant that overrides a source, because -a tenant with its own database is invisible to the shared sweep (`:23-39`). +a tenant with its own database is invisible to the shared sweep (`:33-38`). ## Recording what changed, the audit trail @@ -211,11 +219,11 @@ It records a field-level history for entities marked outbox precedent that a trail committable without its data is worse than no trail (`:18-23`). A `Modified` save produces one row per property whose value actually changed; `Added` and `Deleted` produce a single summary row with a null `PropertyName` -(`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailEntry.cs:15-21`). -Four things are worth knowing about it. It is opt-in twice over, once through `AddAuditTrail` (the -interceptor is resolved with `GetService`) and once through `AuditTrail:Enabled` (which maps the table), -and both are checked cheaply per save by asking the model whether the entity type exists at all -(`:182-185`). Personal data never reaches the table: a property carrying +(`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailEntry.cs:15-21`, +class at `:23`). Four things are worth knowing about it. It is opt-in twice over, once through +`AddAuditTrail` (the interceptor is resolved with `GetService`) and once through `AuditTrail:Enabled` +(which maps the table), and both are checked cheaply per save by asking the model whether the entity +type exists at all (`:182-185`). Personal data never reaches the table: a property carrying [`PiiAttribute`](group-02-domain-building-blocks.md#piiattribute) records [`PiiRedactor`](group-02-domain-building-blocks.md#piiredactor)`.RedactedToken` on both sides, and the redaction happens **at capture, not at read**, so the trail cannot become a second copy of a data @@ -226,16 +234,22 @@ from recording its own rows in an unbounded feedback loop (`:107-119`). And the records is the ambient `Activity` trace id rather than a scoped correlation service, because a singleton interceptor holding a context built by the singleton physical factory cannot reach a scoped service without a lifetime bug; the doc comment says exactly that and names the accessor pattern -tenancy introduced as the way to change it later (`:44-54`). Two more types close the feature: -[`AuditTrailReader`](#audittrailreader) (`.../AuditTrail/AuditTrailReader.cs:35`) serves paged history -for one entity and states its own v1 limitation, that it reads only the `Default` source's trail table -(`:16-25`), and [`AuditTrailCleanupJob`](#audittrailcleanupjob) (`.../AuditTrail/AuditTrailCleanupJob.cs:48`) -is the framework's own recurring job, purging rows past `RetentionDays` from every relational source -nightly at 03:00 UTC in 1000-row `ExecuteDelete` batches (`:58`, `:67`, `:70-80`). It only runs if the -host also runs the scheduler, and a host that records the trail without one is fully supported: pruning -is then the operator's job (`:23-28`). - -## Repositories and the unit of work +tenancy introduced as the way to change it later (`:44-54`). The values every row of one save shares +(user, instant, trace id, tenant) are gathered once into a [`CaptureContext`](#capturecontext) record +struct (`:189-193`, declared at `:541`), and a row describing an insert whose key the database has not +assigned yet is parked as a [`PendingEntityKey`](#pendingentitykey) (`:550`) until the store-generated +key exists. Two more types close the feature: [`AuditTrailReader`](#audittrailreader) +(`.../AuditTrail/AuditTrailReader.cs:35`) serves paged history for one entity and states its own v1 +limitation, that it reads only the `Default` source's trail table (`:17-24`), and +[`AuditTrailCleanupJob`](#audittrailcleanupjob) (`.../AuditTrail/AuditTrailCleanupJob.cs:48`) is the +framework's own recurring [`IScheduledJob`](group-05-cqrs-pipeline.md#ischeduledjob), purging rows past +`RetentionDays` from every relational source nightly at 03:00 UTC in 1000-row `ExecuteDelete` batches +(`:58`, `:63`, `:67`, `:77-85`), expanding that source list through +[`TenantDataSourceTargets`](#tenantdatasourcetargets) so a tenant with its own database gets swept too +(`:83`). It only runs if the host also runs the scheduler, and a host that records the trail without one +is fully supported: pruning is then the operator's job (`:23-28`). + +## Repositories, specifications, and the unit of work Handlers do not touch a `DbContext` directly. They ask a [`UnitOfWork`](#unitofwork) (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/UnitOfWork.cs:13`) for a repository. @@ -246,7 +260,7 @@ handler that only needs a lookup can depend on the narrow [`IEntityReader`](#ientityreadertentity-tidentifiertype) (`IRepository.cs:21`) or [`IEntityQuerier`](#ientityqueriertentity-tidentifiertype) (`:80`); [`IReadRepository`](#ireadrepositorytentity-tidentifiertype) (`:221`) combines -both plus four `IQueryable` surfaces (tracking, no-tracking, single-query, split-query, `:226-236`), +both plus four `IQueryable` surfaces (tracking, no-tracking, single-query, split-query, `:227-236`), [`IWriteRepository`](#iwriterepositorytentity-tidentifiertype) (`:244`) adds mutation, and [`IRepository`](#irepositorytentity-tidentifiertype) (`:349`) is the union. That layering is the group's clearest [Rubric §1, SOLID] (interface-segregation) statement, @@ -274,6 +288,28 @@ builder by [`UpdatePropertySetterBuilder`](#updatepropertysetterbuilder Application layer, and because `ExecuteUpdate` bypasses the interceptor pipeline the repository stamps `LastModifiedOn/By` itself unless the caller assigned them (`EFRepository.cs:121-132`). +The read repository does not compose queries by hand. [`SpecificationEvaluator`](#specificationevaluator) +(`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/SpecificationEvaluator.cs:20`) +turns an [`ISpecification`](group-03-querying-specifications.md#ispecificationtentity-tidentifiertype) +into an `IQueryable`: criteria always, then the includes, the +[`OrderExpression`](group-03-querying-specifications.md#orderexpression) chain, and the paging a +[`QuerySpecification`](group-03-querying-specifications.md#queryspecificationtentity-tidentifiertype) +carries (`:36-61`), with the shape deliberately skipped for aggregate reads because joining includes to +count rows costs a join per navigation (`:29-34`). Tracking and soft-delete scope are **not** its +business: those choose the base queryable, which only the repository can do (`:14-18`). It also owns the +one split-query heuristic in the framework, opting into `AsSplitQuery` as soon as any include targets a +collection navigation (`:85-93`), and `EFReadRepository.ApplyIncludes` delegates to it so the +string-include path and the specification path cannot drift (`:69-72`, +`EFReadRepository.cs:302`). Cursor paging is the sibling helper: +[`KeysetQueryBuilder`](#keysetquerybuilder) (`.../Repositories/KeysetQueryBuilder.cs:22`) resolves the +requested sort property or fails validation (`:35-47`), orders by `(sortKey, Id)` with the identifier +tie-break that makes the order total (`:59-75`), and builds the composite seek predicate against the +last row of the previous page (`:102`), so `GetPageByCursorAsync` +(`IRepository.cs:207`, implemented at `EFReadRepository.cs:369`) seeks straight to the boundary instead +of counting past every skipped row. Exactly one sort key is supported, by design +(`KeysetQueryBuilder.cs:17-20`). That is [Rubric §12, Performance and Scalability] expressed as a +contract rather than as advice. + Two factories keep the wiring honest. [`RepositoryFactory`](#repositoryfactory) (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/Factory/RepositoryFactory.cs:14`) builds a repository over a given context and conditionally wraps it in a MiniProfiler decorator @@ -281,35 +317,37 @@ builds a repository over a given context and conditionally wraps it in a MiniPro [`EFReadRepositoryDecorator`](#efreadrepositorydecoratortentity-tidentifiertype)) when `UseMiniProfiler` is on (`:33-38`, `:57-62`), adding timing without the base repository knowing, and it activates both through a cached compiled `ObjectFactory` rather than reflecting on every call -(`:69-84`). [`DbContextFactory`](#dbcontextfactory) +(`:67-84`). [`DbContextFactory`](#dbcontextfactory) (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:39`) is the scoped coordinator: it caches one [`ApplicationDbContext`](#applicationdbcontext) per -[`DataSourceKey`](#datasourcekey) so every repository in a scope shares one change tracker, gives each -new context a live tenant accessor rather than a copied value (`:134-140`), and enlists a late-created -context into an already-open transaction (`:108-109`). It is also the database-per-tenant routing point: -when the scope's tenant overrides a source, the context is created against that tenant's connection -string while keeping the **original** `DataSourceKey`, which is what lets one compiled model serve every -tenant's database (`:148-173`), and a cached routed context is refused to a second tenant rather than -silently serving the first tenant's rows (`:181-198`). Its save loop runs up to `MaxSavePasses` (3, -`:53`) passes over the cached contexts, because dispatching events in-process can materialize a context -for a source nobody had touched yet (`:242-256`), and it closes with a hard assertion: any context still -reporting `ChangeTracker.HasChanges()` when the unit of work returns throws rather than silently -discarding those changes (`:264-275`). Because there can be more than one physical source in play, -`ExecuteInTransactionAsync` runs the operation under the first transactional context's execution -strategy, opens a transaction per source, and commits them sequentially with no two-phase commit -(`:501-543`); cross-source consistency is the outbox's job, and the doc comment is explicit that a -commit failure on the second source leaves the first one committed (`:492-499`). The method is -re-entrant: a nested call joins the ambient transaction instead of opening a second one, so only the -outermost call may begin, commit, roll back, or flush (`:505-513`). A returned failed -[`Result`](group-01-result-error-handling.md#result) rolls back exactly like an exception (`:562-569`), +[`DataSourceKey`](#datasourcekey) so every repository in a scope shares one change tracker (`:88-118`), +gives each new context a live tenant accessor rather than a copied value (`:134-140`), and enlists a +late-created context into an already-open transaction (`:108-109`). It is also the database-per-tenant +routing point: when the scope's tenant overrides a source, the context is created against that tenant's +connection string while keeping the **original** `DataSourceKey`, which is what lets one compiled model +serve every tenant's database (`:148-173`), and a cached routed context is refused to a second tenant +rather than silently serving the first tenant's rows (`:181-198`). Its save loop runs up to +`MaxSavePasses` (3, `:53`) passes over the cached contexts, because dispatching events in-process can +materialize a context for a source nobody had touched yet (`:242-256`), and it closes with a hard +assertion: any context still reporting `ChangeTracker.HasChanges()` when the unit of work returns throws +rather than silently discarding those changes (`:264-275`). + +Because there can be more than one physical source in play, `ExecuteInTransactionAsync` (`:504-546`) +runs the operation under the first transactional context's execution strategy, opens a transaction per +source, and commits them sequentially with no two-phase commit (`TryCommit` at `:623-655`); +cross-source consistency is the outbox's job, and the doc comment is explicit that a commit failure on +the second source leaves the first one committed (`:492-502`). The method is re-entrant: a nested call +joins the ambient transaction instead of opening a second one, so only the outermost call may begin, +commit, roll back, or flush (`:508-516`). A returned failed +[`Result`](group-01-result-error-handling.md#result) rolls back exactly like an exception (`:565-572`), which is what makes [ADR-013](https://ivanball.github.io/docs/adr/013-result-pattern.html)'s Result-over-exceptions rule safe for partial persistence; rollback also drops the deferred event dispatch (`:448-452`), and a retry resets the change tracker first so the aborted attempt's `Added` -entities are not inserted twice (`ResetForRetry` at `:682-689`). `DbContextFactory` further carries the +entities are not inserted twice (`ResetForRetry` at `:711-718`). `DbContextFactory` further carries the `SET IDENTITY_INSERT` machinery ([`IdentityInsertGroup`](#identityinsertgroup) at `:410`, the per-table save split at `:289-361`) for importing entities with explicit database-generated ids one table at a time, and the `MigrateAsync` / `HasPendingMigrationsAsync` sweeps over every SQL Server source in use -(`:659-675`). +(`:688-704`). [`UnitOfWork`](#unitofwork) sits on top, resolving an entity's physical source through [`IDataSourceService`](#idatasourceservice), handing the matching context to the factory, and caching the @@ -332,11 +370,12 @@ keep the application layer talking to abstractions. Every member of that factory section below, including the two types that exist only because of what the coordinator does: [`IdentityInsertGroup`](#identityinsertgroup), the per-table batch the `SET IDENTITY_INSERT` loop saves one at a time, and [`TransactionCommitAmbiguousException`](#transactioncommitambiguousexception) -(`.../Factory/TransactionCommitAmbiguousException.cs:22`), which `ExecuteInTransactionAsync` throws when -the commit itself fails with an outcome nobody can vouch for. That last one is raised **outside** the -execution strategy on purpose (`DbContextFactory.cs:535-540`), because the strategy walks an -exception's whole inner chain to decide retriability and would otherwise re-run the operation on top of -a possibly-durable commit. +(`.../Factory/TransactionCommitAmbiguousException.cs:22`), which the commit path raises when the commit +itself fails with an outcome nobody can vouch for, naming each physical source's outcome (committed, +ambiguous, or rolled back) so the partial state is observable rather than inferred (`:57-69`, +`DbContextFactory.cs:644-649`). That exception is thrown **outside** the execution strategy on purpose +(`DbContextFactory.cs:538-543`), because the strategy walks an exception's whole inner chain to decide +retriability and would otherwise re-run the operation on top of a possibly-durable commit. ## Routing an entity to its database @@ -372,7 +411,7 @@ entity claimed by two different sources (`:141-152`), and precomputes the distin use so the outbox processor's per-poll call allocates nothing (`:75-82`, `:157-160`). Because the registry reads the same attributes the model configuration reads, routing and model contents agree by construction, and configurations that implement a provider interface directly without the attributed -base classes are deliberately skipped as legacy (`:168-178`). [`DataSourceService`](#datasourceservice) +base classes are deliberately skipped as legacy (`:163-178`). [`DataSourceService`](#datasourceservice) (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/DataSourceService.cs:12`) is the thin application-facing facade over [`IEntityDataSourceRegistry`](#ientitydatasourceregistry), and it answers the one question navigation loading needs: two entities support EF `.Include()` only when their physical @@ -408,7 +447,7 @@ the same codebase run as a monolith today and as split services later without a a smaller but sharper hole. Soft-delete hides a row from queries, but a plain unique index still enforces uniqueness against it, so "deleting" a speaker would permanently block re-creating one with the same email. The convention appends an `IsDeleted = 0` filter to every unique index on a soft-deletable entity, -leaves hand-authored filters untouched, and no-ops for Cosmos (`SoftDeleteUniqueIndexConvention.cs:33-56`). +leaves hand-authored filters untouched, and no-ops for Cosmos (`SoftDeleteUniqueIndexConvention.cs:27-55`). The predicate text itself is not built inline: both this convention and the opt-in `HasSoftDeleteFilter` extension go through [`SoftDeleteFilterSql`](#softdeletefiltersql)`.Build` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/SoftDeleteFilterSql.cs:27-38`), which @@ -458,7 +497,7 @@ too: [`EntityTypeBuilderExtensions`](#entitytypebuilderextensions) (`.../Configuration/EntityTypeBuilderExtensions.cs:12`) flattens a [`Money`](group-02-domain-building-blocks.md#money) into an amount plus an ISO 4217 code column with a read-leg fallback to the zero-Money sentinel [`Currency`](group-02-domain-building-blocks.md#currency) -(`:19`, `:24-50`); the four converters in `Persistence/Conversions` map +(`:19`, mapping at `:62-76`, fallback at `:71`); the four converters in `Persistence/Conversions` map [`Email`](group-02-domain-building-blocks.md#email) and [`PhoneNumber`](group-02-domain-building-blocks.md#phonenumber) to plain strings in required ([`EmailValueConverter`](#emailvalueconverter) at `.../Conversions/EmailValueConverter.cs:33`, @@ -470,7 +509,7 @@ and optional ([`NullableEmailValueConverter`](#nullableemailvalueconverter) at ` (`.../Conversions/EnumerationValueConverter.cs:33`) plus its nullable sibling [`NullableEnumerationValueConverter`](#nullableenumerationvalueconvertertenumeration) (`:62`) store a smart enumeration as its plain `int` value, so replacing a CLR enum property with an enumeration -is not a schema change (`:6-11`). +is not a schema change. Discovery runs through [`ModelBuilderExtensions`](#modelbuilderextensions)`.ApplyAllConfigurations` (`.../DbContexts/ModelBuilderExtensions.cs:10`, an `extension(ModelBuilder)` block at `:12`), which the @@ -489,8 +528,9 @@ Two configurations ship inside the framework itself, the `Notification` schema because namespace derivation would otherwise resolve them to `Common` (`PushNotificationConfiguration.cs:8-15`, `:25`). This engine-portability design is [ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html) (polyglot persistence); note -the current-reality caveat: the SQLite and Cosmos plumbing is shipped and tested, but SQL Server is the -only engine backing production entities today. +the current-reality caveat: the SQLite and Cosmos plumbing is shipped and tested, but every concrete +subclass of the SQLite and Cosmos configuration bases lives in Common's own test projects, so SQL Server +is the only engine backing production entities today. ## Encryption, seeding, design time, and the shared helpers @@ -529,15 +569,18 @@ fixed interval (`:12-16`); the one production subclass in the workspace today is and [ADR-052](https://ivanball.github.io/docs/adr/052-background-job-execution.html) covers in-process background work generally. -Seeding and design time close the loop. [`IDbSeeder`](#idbseeder) and the [`DbSeeder`](#dbseeder) base +Seeding and design time close the loop. [`IDbSeeder`](#idbseeder) +(`.../Seeding/IDbSeeder.cs:7`) and the [`DbSeeder`](#dbseeder) base (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Seeding/DbSeeder.cs:7`) give module seeders a `GetId` helper that maps integer seed ids to either `int` or a deterministic `Guid` so seed data reproduces across key strategies (`:20-39`), and [`IdentityModuleDbSeederBase`](#identitymoduledbseederbasetuser) -(`.../Seeding/IdentityModuleDbSeederBase.cs:38`) hoists the five-times-repeated account-seeding idiom out -of the two app identity modules, leaving only two app-specific hooks and a `ShouldSeed` opt-in gate that -defaults to true (`:50`, `:57`, `:60-71`), each account described by a [`SeedAccount`](#seedaccount) record -(`.../Seeding/SeedAccount.cs:17`). For migrations, [`DesignTimeDbContextHelper`](#designtimedbcontexthelper) +(`.../Seeding/IdentityModuleDbSeederBase.cs:38`) hoists the repeated account-seeding idiom out of the two +app identity modules, leaving only app-specific hooks and a `ShouldSeed` opt-in gate that defaults to true +(`:50`, `:57`, `:60-71`), each account described by a [`SeedAccount`](#seedaccount) record +(`.../Seeding/SeedAccount.cs:17`) whose own remarks warn that seed credentials are plaintext by +construction and therefore development-only data (`:6-11`). For migrations, +[`DesignTimeDbContextHelper`](#designtimedbcontexthelper) (`.../DbContexts/Design/DesignTimeDbContextHelper.cs:36`) builds a [`SQLServerDbContext`](#sqlserverdbcontext) for `dotnet ef` without the app's DI container: a downstream migrations project writes a few-line `IDesignTimeDbContextFactory` (`:18-35`), and @@ -545,8 +588,9 @@ migrations project writes a few-line `IDesignTimeDbContextFactory` (`:18-35`), a (`:106-124`), so each database gets its own migrations project. It composes minimal stand-ins ([`ExplicitAssemblyProvider`](#explicitassemblyprovider) at `:126`, [`NullDomainEventDispatcher`](#nulldomaineventdispatcher) at `:131`) and a -[`DesignTimeDbContextOptions`](#designtimedbcontextoptions) carrying the connection settings, then wires -the same [`DataSourceResolver`](#datasourceresolver) and +[`DesignTimeDbContextOptions`](#designtimedbcontextoptions) +(`.../Design/DesignTimeDbContextOptions.cs:11`) carrying the connection settings, then wires the same +[`DataSourceResolver`](#datasourceresolver) and [`EntityDataSourceRegistry`](#entitydatasourceregistry) the runtime uses so the design-time model matches the runtime one (`:57-101`). It registers the tenant interceptor, the scheduler options and the audit-trail options unconditionally, defaulted to disabled, precisely so `dotnet ef` scaffolds the same migration for @@ -564,10 +608,11 @@ exposes an `IsConfigured` flag handlers can gate features on (`:14`); is the Azure implementation over a single pre-provisioned container, and [`NullFileStorageService`](#nullfilestorageservice) (`.../Services/NullFileStorageService.cs:11`) fails uploads with a named error while letting deletes succeed (`:17-25`). [`IImageProcessor`](#iimageprocessor) -and [`ImageSharpImageProcessor`](#imagesharpimageprocessor) (`.../Services/ImageSharpImageProcessor.cs:14`) +(`.../Interfaces/Infrastructure/IImageProcessor.cs:11`) and +[`ImageSharpImageProcessor`](#imagesharpimageprocessor) (`.../Services/ImageSharpImageProcessor.cs:14`) normalize untrusted uploads by decoding, baking in the EXIF orientation, center-cropping to a square, stripping the EXIF, XMP, and IPTC profiles, and re-encoding as JPEG at quality 85, so only pixels survive -(`:21-42`); the dependency-free [`ImageContentSniffer`](#imagecontentsniffer) +(`:17-42`); the dependency-free [`ImageContentSniffer`](#imagecontentsniffer) (`.../Interfaces/Infrastructure/ImageContentSniffer.cs:10`) is its upload-side companion, deciding the accepted formats (JPEG, PNG, WebP) from magic bytes rather than the client-declared content type (`:15-36`). Both are [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-storage-and-avatars.html), and both @@ -587,6 +632,27 @@ as the unconfigured defaults. [`NativePushPayloads`](#nativepushpayloads) those rules unit-testable without a hub. This channel sits beside the persisted notification record and the SignalR path in [Group 10](group-10-notifications.md). +Three broker-side types share the same `Infrastructure/Services` folder without being storage at all, and +they belong to the events story in [Group 04](group-04-events-outbox.md) and +[Group 05](group-05-cqrs-pipeline.md) rather than to persistence. +[`FaultIntegrationEventConsumer`](#faultintegrationeventconsumertevent) +(`.../Services/FaultIntegrationEventConsumer.cs:27`) consumes MassTransit's `Fault` when a consumer +exhausts its retry policy, turning a silent row in the broker's error queue into one structured Error log +plus a `broker.fault.count` metric tagged by event type (`:32-56`); it never throws, because a fault +consumer that faults would publish `Fault>` and could re-enter itself (`:18-23`). +[`UpcastingIntegrationEventConsumer`](#upcastingintegrationeventconsumertevent) +(`.../Services/UpcastingIntegrationEventConsumer.cs:31`) is the draining consumer for a **retired** contract: +it binds the old queue, upcasts each message to its terminal contract through +[`IEventUpcasterRegistry`](group-05-cqrs-pipeline.md#ieventupcasterregistry), and dispatches the handlers +registered for that newer type, deduplicating on the ORIGINAL message id through +[`IInboxStore`](group-04-events-outbox.md#iinboxstore) so a redelivery is recognized whatever contract the +handlers ultimately see (`:49-63`). [`EventUpcasterStartupValidator`](#eventupcasterstartupvalidator) +(`.../Services/EventUpcasterStartupValidator.cs:20`) is the hosted service that forces that registry to be +constructed at host start, so a duplicate source, a self-mapping, or a cycle fails the host rather than +dead-lettering events hours later (`:23-30`). All three are +[ADR-090](https://ivanball.github.io/docs/adr/090-event-upcaster-registration.html) and +[Rubric §13, Observability and Operability] material. + ## Where this group sits Persistence is the concrete floor the abstract domain stands on. The entity bases and audit contracts from @@ -595,13 +661,13 @@ the domain events aggregates raise are what [`DomainEventSaveChangesInterceptor`](#domaineventsavechangesinterceptor) drains into the outbox that [Group 04](group-04-events-outbox.md) delivers; the transactional decorator in [Group 05](group-05-cqrs-pipeline.md) is what opens the transaction whose commit releases the deferred -dispatch; the specifications and query service in [Group 03](group-03-querying-specifications.md) run -through this group's repositories and `IQueryable` surfaces; the navigation populators in -[Group 11](group-11-navigation-populators.md) fill the cross-source gaps the degrade convention opens; the -scheduler and settings types this group's gated tables answer to live in -[Group 14](group-14-module-system-composition.md); and the entity-source registry answers the `.Include()` -questions the populators ask. The design axes here are now three orthogonal ones collapsed behind a single -[`DataSourceKey`](#datasourcekey) plus a scoped tenant: +dispatch; the specifications and query service in [Group 03](group-03-querying-specifications.md) are +evaluated by this group's [`SpecificationEvaluator`](#specificationevaluator) against its repositories and +`IQueryable` surfaces; the navigation populators in [Group 11](group-11-navigation-populators.md) fill the +cross-source gaps the degrade convention opens; the scheduler and settings types this group's gated tables +answer to live in [Group 14](group-14-module-system-composition.md); and the entity-source registry answers +the `.Include()` questions the populators ask. The design axes here are three orthogonal ones collapsed +behind a single [`DataSourceKey`](#datasourcekey) plus a scoped tenant: [ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)'s `Name` axis (which database), [ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html)'s `Engine` axis (which storage technology), and [ADR-073](https://ivanball.github.io/docs/adr/073-multi-tenancy-model.html)'s tenant axis @@ -3267,19 +3333,19 @@ survives a module being pulled out into its own service. > MMCA.Common.Infrastructure · `MMCA.Common.Infrastructure.Services` · `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/NativePushPayloads.cs:10` · Level 0 · class (internal static) -- **What it is**: a pure helper that builds the platform-native JSON bodies (FCM v1 for Android, APNs for Apple) and the `user:{id}` OR-tag expressions that an Azure Notification Hubs send needs. It holds no state and touches no hub, so the payload shapes and the tag-chunking rule are unit-testable in isolation (`NativePushPayloads.cs:5-10`). +- **What it is**: a pure helper that builds the platform-native JSON bodies (FCM v1 for Android, APNs for Apple) and the `user:{id}` OR-tag expressions an Azure Notification Hubs send needs. It holds no state and touches no hub, so the payload shapes and the tag-chunking rule are unit-testable in isolation (`NativePushPayloads.cs:5-10`). - **Depends on**: the BCL only: `System.Text.Json.JsonSerializer` for the payload strings, `Enumerable.Chunk` for the OR-expression batching, and the `UserIdentifierType` alias (see [primer §2](00-primer.md#2-architectural-styles-this-codebase-commits-to)) for the user-tag input. - **Concept introduced, native push payload construction and the 20-tag chunk rule.** `[Rubric §7, Microservices Readiness]` assesses whether cross-cutting delivery mechanics live behind a reusable, transport-specific boundary rather than smeared through handlers; here the exact wire shapes of two third-party push protocols are pinned in one place. Azure Notification Hubs caps a single tag expression at 20 tags (`MaxTagsPerExpression`, `NativePushPayloads.cs:13`), so a user-targeted broadcast to a large audience is split into `Chunk(20)` groups, each rendered as a `user:a || user:b || ...` OR-expression (`NativePushPayloads.cs:59-63`). That cap is a real hub limit, not an arbitrary batch size, which is why it is a named constant the sender and the registrar both reuse rather than a literal. - **Walkthrough**: `BuildFcmV1Payload` (`NativePushPayloads.cs:16-28`) nests a `notification` block of `title`/`body` under a `message` envelope, adding a `data` map only when metadata is non-empty (the `{ Count: > 0 }` pattern, `NativePushPayloads.cs:22`). `BuildApnsPayload` (`NativePushPayloads.cs:31-53`) builds the APNs `aps.alert` block, then copies each metadata pair up to the top level as a custom key while explicitly refusing to overwrite the reserved `aps` key (`NativePushPayloads.cs:44-49`). `BuildUserTagExpressions` (`NativePushPayloads.cs:59-63`) maps each id through `UserTag`, chunks, and joins. `UserTag` (`NativePushPayloads.cs:66-67`) formats `user:{userId}` under `InvariantCulture` via `string.Create`, so a numeric id never picks up a locale-specific separator. -- **Why it's built this way**: keeping the payload shapes and the hub's tag cap in a stateless helper ([ADR-044](https://ivanball.github.io/docs/adr/044-native-push-delivery.html)) means the [`AzureNotificationHubNativePushSender`](#azurenotificationhubnativepushsender) stays a thin adapter and the fiddly JSON/tag rules can be proven correct without a live hub or credentials. -- **Where it's used**: consumed by [`AzureNotificationHubNativePushSender`](#azurenotificationhubnativepushsender) (payloads and tag expressions, `AzureNotificationHubNativePushSender.cs:21-24`) and [`AzureNotificationHubDeviceRegistrar`](#azurenotificationhubdeviceregistrar) twice: the `UserTag` stamped on each installation (`AzureNotificationHubDeviceRegistrar.cs:41`) and the same tag re-read to verify ownership before a delete (`AzureNotificationHubDeviceRegistrar.cs:112`). +- **Why it's built this way**: keeping the payload shapes and the hub's tag cap in a stateless helper ([ADR-044](https://ivanball.github.io/docs/adr/044-native-push-delivery.html)) means [`AzureNotificationHubNativePushSender`](#azurenotificationhubnativepushsender) stays a thin adapter and the fiddly JSON/tag rules can be proven correct without a live hub or credentials (`MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/NativePushPayloadsTests.cs`). +- **Where it's used**: consumed by [`AzureNotificationHubNativePushSender`](#azurenotificationhubnativepushsender) (payloads and tag expressions, `AzureNotificationHubNativePushSender.cs:21-24`) and by [`AzureNotificationHubDeviceRegistrar`](#azurenotificationhubdeviceregistrar) twice: the `UserTag` stamped on each installation (`AzureNotificationHubDeviceRegistrar.cs:41`) and the same tag re-read to verify ownership before a delete (`AzureNotificationHubDeviceRegistrar.cs:112`). - **Caveats / not-in-source**: `internal`, so it is reachable only inside `MMCA.Common.Infrastructure` and its `InternalsVisibleTo` test project. ### PeriodicBackgroundService > MMCA.Common.Infrastructure · `MMCA.Common.Infrastructure.Services` · `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/PeriodicBackgroundService.cs:20` · Level 0 · class (public abstract partial) -- **What it is**: the framework's base class for fixed-interval background sweeps. A subclass supplies an interval and one cycle body; the base supplies the enablement gate, the startup delay, the loop, the never-die error handling, and a clock that tests can drive (`PeriodicBackgroundService.cs:6-22`). +- **What it is**: the framework's base class for fixed-interval background sweeps. A subclass supplies an interval and one cycle body; the base supplies the enablement gate, the startup delay, the loop, the never-die error handling, and a clock tests can drive (`PeriodicBackgroundService.cs:6-22`). - **Depends on**: no first-party types at all. It extends `Microsoft.Extensions.Hosting.BackgroundService` and takes `TimeProvider` plus a non-generic `ILogger` through its primary constructor (`PeriodicBackgroundService.cs:20-22`). - **Concept introduced, the clock-injected periodic hosted service.** `[Rubric §14, Testability]` assesses whether time-dependent behavior can be exercised without waiting for real time: every wait here goes through the injected `TimeProvider` (`PeriodicBackgroundService.cs:55` and `PeriodicBackgroundService.cs:80`), so a `FakeTimeProvider` can advance an hour-scale loop instantly, which is exactly what the unit tests do (`MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/PeriodicBackgroundServiceTests.cs:90-100`). `[Rubric §29, Resilience & Business Continuity]` applies to the failure contract: a throwing cycle is logged and the loop continues to the next interval (`PeriodicBackgroundService.cs:73-76`), so one bad sweep cannot silently take a reconciliation job offline for the life of the process. `[Rubric §13, Observability & Operability]` shows in the two source-generated `[LoggerMessage]` methods (`PeriodicBackgroundService.cs:89-93`), which is why the class is `partial`: a disabled service says so at Information level, a failed cycle logs at Error with the exception. - **Walkthrough** @@ -3288,30 +3354,19 @@ survives a module being pulled out into its own service. - **The startup delay** is awaited in its own `try` whose `catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)` returns cleanly (`PeriodicBackgroundService.cs:53-60`): a host stopped during the first 15 seconds exits without a first-chance exception escaping to the host. - **The loop body** (`PeriodicBackgroundService.cs:62-86`) runs the cycle, then waits the interval, each guarded separately. Cancellation mid-cycle breaks out as normal shutdown (`PeriodicBackgroundService.cs:68-72`); any other exception is logged with the concrete `GetType().Name` and the loop falls through to the interval wait (`PeriodicBackgroundService.cs:73-76`). Because the wait comes after the cycle, a slow cycle pushes the next one out rather than overlapping it: the interval is a gap between runs, not a fixed schedule. - **Why it's built this way**: the class doc states the boundary explicitly (`PeriodicBackgroundService.cs:12-16`): this shape fits periodic reconciliation and cleanup work, and is deliberately **not** used by the outbox processor, whose signal-driven smart wait does not fit a fixed interval. [ADR-054](https://ivanball.github.io/docs/adr/054-saga-compensation-and-reconciliation.html) records the same loop shape (gate, startup delay, per-cycle try/catch, `TimeProvider` waits) as the framework's answer for reconciliation sweeps. -- **Where it's used**: its one production subclass is MMCA.Store's `PaymentReconciliationService`, the saga-timeout backstop for the Stripe payment flow (`MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Infrastructure/Services/PaymentReconciliationService.cs:39`). That subclass is a good read for how little a derived sweep has to write: it overrides `Interval` from configuration (`PaymentReconciliationService.cs:46`), `IsEnabled` to log the specific reason it is off rather than the base's generic line (`PaymentReconciliationService.cs:54-72`), and `ExecuteCycleAsync` (`PaymentReconciliationService.cs:75`). The other subclass in the workspace is the `CountingSweep` test double (`MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/PeriodicBackgroundServiceTests.cs:103-104`). MMCA.Common's own hosted services predate the base class and hand-roll their loops directly on `BackgroundService`: [`OutboxCleanupService`](group-04-events-outbox.md#outboxcleanupservice) is one (declared at `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/OutboxCleanupService.cs:40`, deriving from `BackgroundService` at `:48`). +- **Where it's used**: its one production subclass is MMCA.Store's `PaymentReconciliationService`, the saga-timeout backstop for the Stripe payment flow (`MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Infrastructure/Services/PaymentReconciliationService.cs:33`, deriving at `:39`). That subclass is a good read for how little a derived sweep has to write: it overrides `Interval` from configuration (`PaymentReconciliationService.cs:46`), `IsEnabled` to log the specific reason it is off rather than the base's generic line (`PaymentReconciliationService.cs:54`), and `ExecuteCycleAsync` (`PaymentReconciliationService.cs:75`). The other subclass in the workspace is the `CountingSweep` test double (`MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/PeriodicBackgroundServiceTests.cs:103-104`). MMCA.Common's own hosted services predate the base class and hand-roll their loops directly on `BackgroundService`: [`OutboxCleanupService`](group-04-events-outbox.md#outboxcleanupservice) is one. - **Caveats / not-in-source**: [ADR-054](https://ivanball.github.io/docs/adr/054-saga-compensation-and-reconciliation.html) records that `PaymentReconciliationService` is the base class's only subclass in any of the applications, so adoption is real but narrow; treat the class as an available base rather than as a description of how every sweep in the workspace is built. ### AzureNotificationHubNativePushSender > MMCA.Common.Infrastructure · `MMCA.Common.Infrastructure.Services` · `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/AzureNotificationHubNativePushSender.cs:14` · Level 1 · class (sealed partial) -- **What it is**: the Azure Notification Hubs implementation of [`INativePushSender`](#inativepushsender): the real, mobile-facing native notification channel that pushes FCM v1 and APNs payloads through a hub client (`AzureNotificationHubNativePushSender.cs:7-16`). +- **What it is**: the Azure Notification Hubs implementation of [`INativePushSender`](#inativepushsender), the mobile-facing native notification channel that pushes FCM v1 and APNs payloads through a hub client (`AzureNotificationHubNativePushSender.cs:7-16`). - **Depends on**: [`INativePushSender`](#inativepushsender) (the contract it fulfills), [`NativePushPayloads`](#nativepushpayloads) (payload and tag construction), and two externals: `Microsoft.Azure.NotificationHubs.INotificationHubClient` (the hub SDK) and `ILogger`. -- **Concept introduced, the native (mobile) push channel and its best-effort contract.** `[Rubric §13, Observability & Operability]` covers whether side-effecting integrations log their outcomes and fail without taking the request down; this sender emits a structured log per send (`LogNativePushSent`, `AzureNotificationHubNativePushSender.cs:42-43`) and its class comment records that callers treat the channel as best-effort (`AzureNotificationHubNativePushSender.cs:11-12`). That is literally true at the call site: [`SendPushNotificationHandler`](group-10-notifications.md#sendpushnotificationhandler) wraps the native send in a `catch (Exception)` annotated "native delivery is best-effort" (`MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/Send/SendPushNotificationHandler.cs:147-148`). This is the device-facing counterpart to the in-app SignalR channel: [`NullPushNotificationSender`](group-10-notifications.md#nullpushnotificationsender) and its SignalR sibling deliver to connected web clients, whereas this one reaches devices via APNs and FCM. +- **Concept introduced, the native (mobile) push channel and its best-effort contract.** `[Rubric §13, Observability & Operability]` covers whether side-effecting integrations log their outcomes and fail without taking the request down; this sender emits a structured log per send (`LogNativePushSent`, `AzureNotificationHubNativePushSender.cs:42-43`) and its class comment records that callers treat the channel as best-effort (`AzureNotificationHubNativePushSender.cs:11-12`). That is literally true at the call site: [`SendPushNotificationHandler`](group-10-notifications.md#sendpushnotificationhandler) wraps the native send in a catch annotated "native delivery is best-effort" (`MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/Send/SendPushNotificationHandler.cs:141-148`). This is the device-facing counterpart to the in-app SignalR channel: [`NullPushNotificationSender`](group-10-notifications.md#nullpushnotificationsender) and its SignalR sibling deliver to connected web clients, whereas this one reaches devices via APNs and FCM. - **Walkthrough**: the primary constructor takes the hub client and logger (`AzureNotificationHubNativePushSender.cs:14-16`). `SendToUsersAsync` (`AzureNotificationHubNativePushSender.cs:19-31`) builds both payloads once (`AzureNotificationHubNativePushSender.cs:21-22`), then for each 20-tag OR-expression sends an `FcmV1Notification` and an `AppleNotification` targeted at that expression (`AzureNotificationHubNativePushSender.cs:24-28`), so one call fans out to both platforms per audience chunk. `BroadcastAsync` (`AzureNotificationHubNativePushSender.cs:34-40`) sends the same two payloads with no tag filter, reaching every registered installation. Both `ConfigureAwait(false)` on every await (library code, no sync context needed, [ADR-049](https://ivanball.github.io/docs/adr/049-library-configureawait-policy.html)) and log the title on completion. - **Why it's built this way**: the `partial` class exists so the `[LoggerMessage]` source generator can emit `LogNativePushSent` (`AzureNotificationHubNativePushSender.cs:42-43`), the high-performance logging pattern used across the framework. Splitting payload construction into [`NativePushPayloads`](#nativepushpayloads) ([ADR-044](https://ivanball.github.io/docs/adr/044-native-push-delivery.html)) keeps this type a pure transport adapter. -- **Where it's used**: registered as a transient `INativePushSender` by `AddNativePushNotifications(configuration)` in place of [`NullNativePushSender`](#nullnativepushsender) (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:579`); resolved by [`SendPushNotificationHandler`](group-10-notifications.md#sendpushnotificationhandler) (`SendPushNotificationHandler.cs:21`). - -### ExplicitAssemblyProvider - -> MMCA.Common.Infrastructure · `MMCA.Common.Infrastructure.Persistence.DbContexts.Design` · `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Design/DesignTimeDbContextHelper.cs:126` · Level 1 · class (sealed, private nested) - -- **What it is**: a tiny private nested provider inside [`DesignTimeDbContextHelper`](#designtimedbcontexthelper) that returns a fixed, caller-supplied list of entity-configuration assemblies (`DesignTimeDbContextHelper.cs:126-129`). -- **Depends on**: [`IEntityConfigurationAssemblyProvider`](#ientityconfigurationassemblyprovider) (the contract) and `System.Reflection.Assembly`. -- **Concept reinforced, explicit assembly enumeration in place of runtime scanning.** `[Rubric §8, Data Architecture]` looks at whether the model's entity set is deterministic per database; at runtime the framework discovers configuration assemblies by scanning the AppDomain through [`DefaultEntityConfigurationAssemblyProvider`](#defaultentityconfigurationassemblyprovider), but `dotnet ef` design-time commands see none of that. `GetConfigurationAssemblies` (`DesignTimeDbContextHelper.cs:128`) simply hands back the assemblies the migrations project listed via [`DesignTimeDbContextOptions.AddConfigurationAssembly`](#designtimedbcontextoptions), so the design-time model contains exactly the intended entities and nothing else. -- **Why it's built this way**: it is the design-time substitute for the AppDomain-scanning provider; keeping it private and trivial means the migrations authoring surface stays [`DesignTimeDbContextOptions`](#designtimedbcontextoptions), not this class. -- **Where it's used**: instantiated once inside `DesignTimeDbContextHelper.CreateSqlServer` (`DesignTimeDbContextHelper.cs:57`), passed straight to the [`EntityDataSourceRegistry`](#entitydatasourceregistry) it builds (`DesignTimeDbContextHelper.cs:62`), registered as the `IEntityConfigurationAssemblyProvider` for the design-time container (`DesignTimeDbContextHelper.cs:90`), and handed to the context constructor (`DesignTimeDbContextHelper.cs:99`). -- **Caveats / not-in-source**: private nested type; it surfaces in the inventory only because the tool includes private nested classes. Not reachable from outside the helper. +- **Where it's used**: registered as a transient `INativePushSender` by `AddNativePushNotifications(configuration)` in place of [`NullNativePushSender`](#nullnativepushsender) (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:592`), and only after that method has confirmed the `NativePush` section is enabled and carries both a connection string and a hub name (`DependencyInjection.cs:581-591`). Resolved by [`SendPushNotificationHandler`](group-10-notifications.md#sendpushnotificationhandler) (`SendPushNotificationHandler.cs:21`). ### NullNativePushSender @@ -3322,7 +3377,7 @@ survives a module being pulled out into its own service. - **Concept reinforced, the Null Object pattern as the safe default channel.** `[Rubric §2, Design Patterns]` values a harmless default that satisfies a contract without a live dependency; registering this type by default means DI resolution and the Devices/send endpoints work everywhere, even in a host with no notification hub. The real [`AzureNotificationHubNativePushSender`](#azurenotificationhubnativepushsender) is swapped in only when `AddNativePushNotifications(configuration)` runs against an enabled, fully-configured hub (`NullNativePushSender.cs:6-9`). - **Walkthrough**: `SendToUsersAsync` and `BroadcastAsync` (`NullNativePushSender.cs:13-18`) each match the interface signature and return a completed task; there is no logging and no failure, by design. - **Why it's built this way**: [ADR-044](https://ivanball.github.io/docs/adr/044-native-push-delivery.html) gives the framework three notification channels; a no-op default keeps the native channel optional, so a host that never configures a hub still composes and runs. -- **Where it's used**: registered with `TryAddTransient` as the default `INativePushSender` in `AddInfrastructure` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:478`), paired with [`NullPushDeviceRegistrar`](#nullpushdeviceregistrar) on the next line for the same disabled-hub scenario (`DependencyInjection.cs:479`). +- **Where it's used**: registered with `TryAddTransient` as the default `INativePushSender` in `AddServices` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:491`), paired with [`NullPushDeviceRegistrar`](#nullpushdeviceregistrar) on the next line for the same disabled-hub scenario (`DependencyInjection.cs:492`, under the comment at `:489-490`). ### TenantContext @@ -3330,27 +3385,12 @@ survives a module being pulled out into its own service. - **What it is**: the scoped holder of "which tenant is this scope running as". One instance per DI scope, unresolved until something calls `SetTenant`, which is the state every background service, seeder, and design-time tool stays in (`TenantContext.cs:6-11`). - **Depends on**: [`ITenantContext`](group-05-cqrs-pipeline.md#itenantcontext) (the Application-layer contract it implements) and `System.Globalization.CultureInfo` for the exception message. -- **Concept introduced, the ambient tenant as a scoped value with a one-way latch.** `[Rubric §11, Security]` assesses whether isolation boundaries are enforced rather than trusted, and `[Rubric §8, Data Architecture]` covers how a shared database keeps tenants apart. Multi-tenancy here ([ADR-073](https://ivanball.github.io/docs/adr/073-multi-tenancy-model.html)) is row-level by default: the model gives every non-owned `ITenantEntity` a global query filter whose predicate lifts `ApplicationDbContext.CurrentTenantId` into a SQL parameter (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:99`, applied by `ApplyTenantFilters` at `:394-441`), and [`TenantSaveChangesInterceptor`](#tenantsavechangesinterceptor) stamps or verifies `TenantId` on every write (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/TenantSaveChangesInterceptor.cs:64-90`). This class is the single value both of those read. Two design decisions are worth internalizing before you write anything tenant-aware: - - **An unresolved tenant means "see everything", not "see nothing".** There is deliberately no generated fallback the way [`ICorrelationContext`](group-12-api-hosting-mapping.md#icorrelationcontext) has one, because a background worker or a seeder legitimately runs outside any tenant, and inventing an id would silently scope a system operation to a tenant that does not exist (`ITenantContext.cs:9-15`). The filter's `CurrentTenantId == null` disjunct is what implements that (`ApplicationDbContext.cs:388`). +- **Concept introduced, the ambient tenant as a scoped value with a one-way latch.** `[Rubric §11, Security]` assesses whether isolation boundaries are enforced rather than trusted, and `[Rubric §8, Data Architecture]` covers how a shared database keeps tenants apart. Multi-tenancy here ([ADR-073](https://ivanball.github.io/docs/adr/073-multi-tenancy-model.html)) is row-level by default: the model gives every non-owned `ITenantEntity` a global query filter whose predicate lifts `ApplicationDbContext.CurrentTenantId` into a SQL parameter (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:99`, applied by `ApplyTenantFilters` at `:394` and attached as the named `Tenant` filter at `:441`, the name constant at `:360`), and [`TenantSaveChangesInterceptor`](#tenantsavechangesinterceptor) stamps or verifies `TenantId` on every write (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/TenantSaveChangesInterceptor.cs:64-93`). This class is the single value both of those read. Two design decisions are worth internalizing before you write anything tenant-aware: + - **An unresolved tenant means "see everything", not "see nothing".** There is deliberately no generated fallback the way [`ICorrelationContext`](group-12-api-hosting-mapping.md#icorrelationcontext) has one, because a background worker or a seeder legitimately runs outside any tenant, and inventing an id would silently scope a system operation to a tenant that does not exist (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ITenantContext.cs:9-15`). The filter's `CurrentTenantId == null` disjunct is what implements that (`ApplicationDbContext.cs:388`). - **One scope, one tenant.** Changing the tenant mid-scope is refused, because rows already read or tracked in the scope were read under the first tenant and there is no honest way to reconcile that afterwards (`ITenantContext.cs:16-20`). - **Walkthrough**: `TenantId` is a `private set` auto-property (`TenantContext.cs:14`) and `IsResolved` is simply `TenantId is not null` (`TenantContext.cs:17`). `SetTenant` (`TenantContext.cs:20-44`) does three things in order: it rejects null/empty/whitespace up front with `ArgumentException.ThrowIfNullOrWhiteSpace` (`TenantContext.cs:22`); it latches the value when none is held yet (`TenantContext.cs:24-28`); and when a value is already held it compares ordinally, returning quietly for the same tenant (idempotent, so the resolution middleware and a worker re-asserting the tenant on the same scope do not fight, `TenantContext.cs:30-35`) and throwing an `InvalidOperationException` for a different one. That exception message names both tenants and tells the caller what to do instead: start a new scope (`TenantContext.cs:37-43`). -- **Why it's built this way**: the registration is unconditional. `AddServices` registers it with `TryAddScoped` whether or not the host called `AddMultiTenancy`, and the comment says why: everything that reads it treats an unresolved tenant as "no tenancy", so always-on registration costs one object per scope and removes a whole class of "works until someone forgets the opt-in" bug (`DependencyInjection.cs:448-452`). `AddMultiTenancy` binds and validates the `Tenancy` settings and switches on *resolution* at the edge; it does not install the isolation, which is always present and always inert (`DependencyInjection.cs:402-409`). -- **Where it's used**: written at the API edge by [`TenantResolutionMiddleware`](group-12-api-hosting-mapping.md#tenantresolutionmiddleware) from the configured claim or header (`MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/TenantResolutionMiddleware.cs:70`), and re-asserted on a fresh scope by every background path that must run as a tenant: [`OutboxProcessor`](group-04-events-outbox.md#outboxprocessor) (`OutboxProcessor.cs:264`), [`OutboxCleanupService`](group-04-events-outbox.md#outboxcleanupservice) (`OutboxCleanupService.cs:100`), [`AuditTrailCleanupJob`](#audittrailcleanupjob) (`AuditTrailCleanupJob.cs:106`), and the per-tenant database initializer (`MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:128`). It is read by [`DbContextFactory`](#dbcontextfactory) for per-tenant database routing (`DbContextFactory.cs:44`, used at `:102`, `:139` and `:150`) and by the caching decorators, which scope cache keys through `TenantCacheKey.Scope` (`MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/TenantCacheKey.cs:37`, called from `CachingQueryDecorator.cs:64` and `CachingCommandDecorator.cs:82` with the context injected at `CachingQueryDecorator.cs:38` and `CachingCommandDecorator.cs:36`). - -### DesignTimeDbContextOptions - -> MMCA.Common.Infrastructure · `MMCA.Common.Infrastructure.Persistence.DbContexts.Design` · `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Design/DesignTimeDbContextOptions.cs:11` · Level 2 · class (sealed) - -- **What it is**: the configuration carrier a migrations project fills in to tell [`DesignTimeDbContextHelper`](#designtimedbcontexthelper) how to build a context for `dotnet ef ... -- --datasource `. It holds the connection settings, the named data-source entries, two model-shape flags, and the explicit list of entity-configuration assemblies (`DesignTimeDbContextOptions.cs:11-61`). -- **Depends on**: [`ConnectionStringSettings`](group-14-module-system-composition.md#connectionstringsettings), [`DataSourceEntrySettings`](group-14-module-system-composition.md#datasourceentrysettings), and `System.Reflection.Assembly`. -- **Concept introduced, design-time context construction for database-per-service.** `[Rubric §8, Data Architecture]` assesses whether each database's migrations are built in isolation; in the database-per-service model ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)) each module's migrations project must scaffold a context for only its own database. At design time there is no DI container and no AppDomain scan, so this options object captures everything `dotnet ef` cannot discover on its own: the top-level connection strings including `SQLServerMigrationsAssembly` (`DesignTimeDbContextOptions.cs:20-24`), the named `DataSources` entries (`DesignTimeDbContextOptions.cs:26-27`), and the explicit configuration assemblies (`DesignTimeDbContextOptions.cs:57-61`, whose comment notes the runtime scan sees nothing here). -- **Walkthrough** - - `DataSourceName` (`DesignTimeDbContextOptions.cs:18`) is optional; when null the helper parses `--datasource` and falls back to `Default`. - - **`EnableScheduler`** (`DesignTimeDbContextOptions.cs:41`) mirrors `Scheduler:Enabled` and decides whether the `ScheduledJobs` table is part of the design-time model. It defaults to `false` so `dotnet ef` keeps producing exactly the migrations it produced before the scheduler shipped ([ADR-074](https://ivanball.github.io/docs/adr/074-recurring-job-scheduler.html)). The remarks are the operational rule: set it in the migrations project of the `Default` data source of a host that calls `AddScheduledJobs`, and **only** there, because the table is host-scoped and a second migrations project that also enabled it would create a second copy (`DesignTimeDbContextOptions.cs:35-40`). - - **`EnableAuditTrail`** (`DesignTimeDbContextOptions.cs:55`) mirrors `AuditTrail:Enabled` for the `AuditTrailEntries` change-history table ([ADR-075](https://ivanball.github.io/docs/adr/075-audit-trail.html)), and the rule is the inverse of the scheduler's: set it in **every** data source whose entities are audited, because a trail row is written to the database holding the entity that changed (`DesignTimeDbContextOptions.cs:49-54`). Both flags carry the same warning: the flag must match the host's configuration or the scaffolded migrations and the running model disagree. - - `AddConfigurationAssembly` (`DesignTimeDbContextOptions.cs:66-75`) is a chainable builder method that null-guards and skips duplicates before adding. -- **Why it's built this way**: a single options object plus a builder method keeps each per-module migrations factory to a handful of lines while still pinning the model to one database ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)). The two boolean flags exist because the model is configuration-shaped: opt-in tables would otherwise be invisible to `dotnet ef`, which has no configuration to read. -- **Where it's used**: passed to `DesignTimeDbContextHelper.CreateSqlServer(args, options => ...)` from each per-database migrations factory; the helper's class doc shows the exact shape (`DesignTimeDbContextHelper.cs:20-32`), and a real one is `MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Conference/DesignTimeSQLServerDbContextFactory.cs:15-51`, which sets `DataSourceName = "Conference"` (`:32`) and both flags to true (`:37-38`). +- **Why it's built this way**: the registration is unconditional. `AddServices` registers it with `TryAddScoped` whether or not the host called `AddMultiTenancy`, and the comment says why: everything that reads it treats an unresolved tenant as "no tenancy", so always-on registration costs one object per scope and removes a whole class of "works until someone forgets the opt-in" bug (`DependencyInjection.cs:461-465`). `AddMultiTenancy` binds and validates the `Tenancy` settings and switches on *resolution* at the edge; it does not install the isolation, which is always present and always inert (`DependencyInjection.cs:415-449`). +- **Where it's used**: written at the API edge by [`TenantResolutionMiddleware`](group-12-api-hosting-mapping.md#tenantresolutionmiddleware) from the configured claim or header (`MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/TenantResolutionMiddleware.cs:70`), and re-asserted on a fresh scope by every background path that must run as a tenant: [`OutboxProcessor`](group-04-events-outbox.md#outboxprocessor) (`OutboxProcessor.cs:264`), [`OutboxCleanupService`](group-04-events-outbox.md#outboxcleanupservice) (`OutboxCleanupService.cs:100`), [`AuditTrailCleanupJob`](#audittrailcleanupjob) (`AuditTrailCleanupJob.cs:106`), and the per-tenant database initializer (`MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:128`). It is read by [`DbContextFactory`](#dbcontextfactory) for per-tenant database routing (injected optionally at `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:44`, used at `:102`, `:139`, `:150` and `:186`) and by the caching decorators, which scope cache keys through `TenantCacheKey.Scope` (`MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/TenantCacheKey.cs:37`, called from `CachingQueryDecorator.cs:64` and `CachingCommandDecorator.cs:82` with the context injected at `CachingQueryDecorator.cs:38` and `CachingCommandDecorator.cs:36`). Covered directly by `MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/TenantContextTests.cs`. ### FaultIntegrationEventConsumer @@ -3364,18 +3404,7 @@ survives a module being pulled out into its own service. - **The reason string** joins every exception message in the chain, innermost cause included, with `" | "`, falling back to `""` when the fault carries no exceptions (`:45-47`). The comment records the trade-off: the stack traces stay in the error queue's message headers, and the message text is what identifies the failure at a glance. - **The two outputs** are `LogFault` at Error level, a source-generated `[LoggerMessage]` naming the event type, message id, and reasons (`:49`, declared `:58-59`), and a single increment of `BrokerMetrics.FaultCounter` tagged `event_type` (`:51-53`). The counter is `broker.fault.count`, described as the number of integration events that exhausted retries and faulted (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/BrokerMetrics.cs:30-33`). - **Why it's built this way**: [ADR-087](https://ivanball.github.io/docs/adr/087-broker-poison-message-handling.html) is the poison-message decision this implements. The consumer deliberately observes and stops there: it does not replay, because the original message is already in the error queue and re-publishing from an observability path would double-deliver (`FaultIntegrationEventConsumer.cs:21-22`). The class is `partial` for the `[LoggerMessage]` generator, and generic so one implementation covers every event type while the `event_type` tag keeps the metric decomposable. -- **Where it's used**: registered automatically alongside every consumer wired through `RegisterIntegrationEventConsumer` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/IntegrationEventConsumerExtensions.cs:38-50`): that method adds the [`IntegrationEventConsumer`](group-04-events-outbox.md#integrationeventconsumertevent) at `:42` and, gated on the `registerFaultConsumer` parameter defaulting to `true`, this consumer at `:46`. A host passes `false` only for an event whose faults it routes itself, so two consumers do not compete for the same fault topic (`:31-37`). Covered directly by `MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/FaultIntegrationEventConsumerTests.cs:15`. - -### NullDomainEventDispatcher - -> MMCA.Common.Infrastructure · `MMCA.Common.Infrastructure.Persistence.DbContexts.Design` · `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Design/DesignTimeDbContextHelper.cs:131` · Level 2 · class (sealed, private nested) - -- **What it is**: a no-op [`IDomainEventDispatcher`](group-04-events-outbox.md#idomaineventdispatcher) used only inside the design-time context helper, never in production. `DispatchAsync` returns `Task.CompletedTask` (`DesignTimeDbContextHelper.cs:131-135`). -- **Depends on**: [`IDomainEvent`](group-04-events-outbox.md#idomainevent) and [`IDomainEventDispatcher`](group-04-events-outbox.md#idomaineventdispatcher). -- **Concept reinforced, the Null Object pattern for a design-time DI gap.** `[Rubric §2, Design Patterns]` values satisfying an interface with a harmless no-op when the real implementation would need the full application container. During `dotnet ef migrations add` the design-time factory builds a context but never saves through it, so a real dispatcher (which would try to hand events to handlers that are not registered here) would be both unnecessary and wrong. Registering this null dispatcher (`DesignTimeDbContextHelper.cs:68`) closes that dependency without pulling in application services, because [`DomainEventSaveChangesInterceptor`](#domaineventsavechangesinterceptor) is itself registered in that minimal container (`DesignTimeDbContextHelper.cs:71`) and demands one. -- **Why it's built this way**: the design-time service graph is deliberately minimal (null loggers, null dispatcher, a hand-built `ServiceCollection`) so scaffolding a migration never spins up the app; this type is one leaf of that minimal graph. -- **Where it's used**: registered as the `IDomainEventDispatcher` inside `DesignTimeDbContextHelper.CreateSqlServer` (`DesignTimeDbContextHelper.cs:68`). -- **Caveats / not-in-source**: private nested type inside [`DesignTimeDbContextHelper`](#designtimedbcontexthelper); not accessible from outside. +- **Where it's used**: registered automatically alongside every consumer wired through [`IntegrationEventConsumerExtensions`](group-04-events-outbox.md#integrationeventconsumerextensions) (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/IntegrationEventConsumerExtensions.cs:38-50`): `RegisterIntegrationEventConsumer` adds the [`IntegrationEventConsumer`](group-04-events-outbox.md#integrationeventconsumertevent) at `:42` and, gated on the `registerFaultConsumer` parameter defaulting to `true`, this consumer at `:46`. The retired-contract sibling `RegisterUpcastedIntegrationEventConsumer` registers it the same way at `:86`, so a draining queue gets the same fault visibility. A host passes `false` only for an event whose faults it routes itself, so two consumers do not compete for the same fault topic (`:31-37`). Covered directly by `MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/FaultIntegrationEventConsumerTests.cs:15`. ### DataSourceService @@ -3386,7 +3415,42 @@ survives a module being pulled out into its own service. - **Concept reinforced, entity-to-database routing as a query surface.** `[Rubric §8, Data Architecture]` assesses whether database-per-service routing is a first-class, queryable concept; the registry aggregates every `[UseDataSource]` and `[UseDatabase]` declaration at startup, and this facade is the thin runtime interface over it. Because the registry is built eagerly from configuration assemblies (`DataSourceService.cs:8-11`), resolution no longer waits for an EF model to be built, which matters for the navigation classification that runs before any query. - **Walkthrough**: the four `GetDataSource*` overloads (`DataSourceService.cs:15-24`) forward straight to the registry, returning either the full [`DataSourceKey`](#datasourcekey) or just its `Engine` ([`DataSource`](#datasource)). `HaveIncludeSupport(DataSourceKey, DataSourceKey)` (`DataSourceService.cs:31-32`) encodes the eager-loading rule: an EF `Include` is valid only when both entities resolve to the *same* key **and** that engine is not Cosmos (`first == second && first.Engine != DataSource.CosmosDB`), because Cosmos has no cross-document joins (`DataSourceService.cs:27-30`). The string overload (`DataSourceService.cs:35-38`) resolves both names through `TryGetDataSourceKey` and defers to the key overload, returning false if either name is unknown. - **Why it's built this way**: keeping the include-support rule in one predicate lets the navigation metadata and cross-source degrade logic ask a single authority whether a relationship can be loaded in-database versus batch-loaded across sources ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)). Facading the registry keeps callers off its lower-level API. -- **Where it's used**: registered with `TryAddSingleton` as the `IDataSourceService` in `AddInfrastructure` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:53`); injected into [`NavigationMetadataProvider`](group-03-querying-specifications.md#navigationmetadataprovider) (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/NavigationMetadataProvider.cs:20`), which classifies navigations per process, and into [`UnitOfWork`](#unitofwork) (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/UnitOfWork.cs:13`), which uses it to pick the context for an entity (`UnitOfWork.cs:29`). +- **Where it's used**: registered with `TryAddSingleton` as the `IDataSourceService` in `AddInfrastructure` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:53`); injected into [`NavigationMetadataProvider`](group-03-querying-specifications.md#navigationmetadataprovider) (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/NavigationMetadataProvider.cs:20`), which classifies navigations per process, and into [`UnitOfWork`](#unitofwork) (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/UnitOfWork.cs:13`), which calls `GetDataSourceKey(typeof(TEntity))` to pick the context an entity's repository binds to (`UnitOfWork.cs:40` and `:60`). Covered by `DataSourceServiceTests.cs` and `DataSourceServiceAdditionalTests.cs` in `MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/`. + +### EventUpcasterStartupValidator + +> MMCA.Common.Infrastructure · `MMCA.Common.Infrastructure.Services` · `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/EventUpcasterStartupValidator.cs:20` · Level 3 · class (internal sealed) + +- **What it is**: a two-line `IHostedService` whose only job is to *resolve* [`IEventUpcasterRegistry`](group-05-cqrs-pipeline.md#ieventupcasterregistry) at host start, so a broken event-upcaster registration graph fails the host immediately instead of surfacing as a dead-lettered message hours later (`EventUpcasterStartupValidator.cs:7-20`). +- **Depends on**: [`IEventUpcasterRegistry`](group-05-cqrs-pipeline.md#ieventupcasterregistry) (injected, and the whole point), [`IIntegrationEvent`](group-04-events-outbox.md#iintegrationevent) (the harmless type it probes with), and `Microsoft.Extensions.Hosting.IHostedService`. +- **Concept introduced, fail-fast startup validation by construction.** `[Rubric §13, Observability & Operability]` and `[Rubric §33, Developer Experience]` both ask whether a misconfiguration is discovered at the earliest honest moment with an actionable message. The validation itself is not here: [`EventUpcasterRegistry`](group-03-querying-specifications.md#eventupcasterregistry) does its checking in its **constructor**, rejecting an upcaster that maps a type onto itself (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/EventUpcasterRegistry.cs:59-63`), two upcasters claiming the same source contract (`:65-69`), and, in `BuildTerminalTypes`, a chain that forms a cycle (`:81`, `:133`, with the throw at `:151`). The exception message names the offenders (`EventUpcasterRegistry.cs:74-79`). Because the registry is a **singleton** registered with `TryAddSingleton` in `AddApplication` (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:40`), nothing constructs it until something asks for it, and on the broker path the first asker would be the first arriving message. This hosted service is the "something" that asks at startup. That is the whole pattern: when validation lives in a constructor, a hosted service that merely resolves the type converts lazy validation into eager validation without duplicating a single rule. +- **Walkthrough** + - **`StartAsync`** (`EventUpcasterStartupValidator.cs:23-30`) calls `upcasters.ResolveTerminalType(typeof(IIntegrationEvent))` and discards the result with `_ =`. The comment explains why the call exists at all (`:25-26`): resolving the constructor parameter is what runs the validation, and reading one member is what makes the dependency impossible for a later refactor to elide. `IIntegrationEvent` is a safe probe argument because no upcaster claims the interface itself, so `ResolveTerminalType` returns it unchanged (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEventUpcasterRegistry.cs:33-39`) and the probe has no side effect. + - **`StopAsync`** (`EventUpcasterStartupValidator.cs:33`) returns `Task.CompletedTask`. There is nothing to unwind. +- **Why it's built this way**: [ADR-090](https://ivanball.github.io/docs/adr/090-event-upcaster-registration.html) ships the upcaster extension point, and this is its startup half. The registration detail is load-bearing and spelled out in the class doc (`EventUpcasterStartupValidator.cs:13-17`): it is registered through `TryAddEnumerable(ServiceDescriptor.Singleton())` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:160-161`, under the comment at `:156-159`) rather than `AddHostedService`, because `AddHostedService` appends unconditionally and several modules each calling `AddInfrastructure` would then run the same validation several times. The cost when a host registers no upcasters at all is one resolve of an empty registry and one no-op call, which is why the registration is unconditional. +- **Where it's used**: registered by `AddInfrastructure` (`DependencyInjection.cs:160-161`) and run by the generic host at start. It is `internal`, so no application code references it. Covered directly by `MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/EventUpcasterStartupValidatorTests.cs:20`. +- **Caveats / not-in-source**: the validator proves the *graph* is well-formed (no duplicate source, no self-map, no cycle). It does not execute any upcaster, so a mapping that compiles and registers cleanly but produces a wrong payload is not caught here; that is what an upcaster's own unit test is for. + +### UpcastingIntegrationEventConsumer + +> MMCA.Common.Infrastructure · `MMCA.Common.Infrastructure.Services` · `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/UpcastingIntegrationEventConsumer.cs:31` · Level 3 · class (sealed partial, generic) + +- **What it is**: the draining consumer for a **retired** integration-event contract. It binds a broker queue to the old type `TEvent`, upcasts each arriving message to its terminal (newest) contract, and then invokes the handlers registered for *that* contract, so handlers only ever have to exist for the newest shape (`UpcastingIntegrationEventConsumer.cs:12-31`). +- **Depends on**: [`IEventUpcasterRegistry`](group-05-cqrs-pipeline.md#ieventupcasterregistry) (the chain walker), `IServiceProvider` (non-generic handler resolution), [`IInboxStore`](group-04-events-outbox.md#iinboxstore) (idempotent delivery), [`IIntegrationEvent`](group-04-events-outbox.md#iintegrationevent) (the generic constraint) and [`IIntegrationEventHandler`](group-04-events-outbox.md#iintegrationeventhandlerin-tintegrationevent) (the handler contract it closes at runtime), plus MassTransit's `IConsumer` / `ConsumeContext`, `System.Linq.Expressions`, `ConcurrentDictionary`, and `ILogger`. +- **Concept introduced, event upcasting on the broker path.** `[Rubric §6, CQRS & Event-Driven]` assesses how event contracts evolve without a lockstep deploy, and `[Rubric §7, Microservices Readiness]` assesses whether producers and consumers can be released independently. MassTransit binds consumers by .NET message type, so a retired contract keeps arriving as its old type until every producer has moved *and* every queue has drained. Without an upcasting path a consumer must either keep two sets of handlers or break. [ADR-090](https://ivanball.github.io/docs/adr/090-event-upcaster-registration.html) resolves that with a registry of [`IEventUpcaster`](group-05-cqrs-pipeline.md#ieventupcaster) mappings and two consumers: the ordinary [`IntegrationEventConsumer`](group-04-events-outbox.md#integrationeventconsumertevent) for the current contract, and this one for each retired contract still in flight. Three properties of the design are worth reading closely. + - **Deduplication stays keyed on the ORIGINAL message id.** The envelope (`MessageId`, `DateOccurred`) is preserved across every upcast hop by the registry (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEventUpcasterRegistry.cs:41-48`), and this consumer reads `integrationEvent.MessageId` *before* any upcasting (`UpcastingIntegrationEventConsumer.cs:55-57`). A redelivery of the same broker message is therefore recognised whatever contract the handlers ultimately see, exactly as a plain consumer would have recorded it. + - **It degrades to plain dispatch.** With no upcaster registered for `TEvent`, `HasUpcasterFor` is false, the class logs an Information line saying so (`:65-70`), and `UpcastToTerminal` returns the instance untouched, so the handlers for the original type run as usual. That is what makes the registration safe to add before the upcaster exists and safe to leave in place for one release after it is deleted (`IntegrationEventConsumerExtensions.cs:65-70`). + - **Handler resolution is non-generic, so it is cached.** The terminal type is only known at runtime, so the closed handler interface must be built with `MakeGenericType` and the handler invoked without a compile-time generic argument. `[Rubric §12, Performance & Scalability]` is the reason for the static `DispatchCache` (`:44-46`): the closed interface type and a **compiled expression-tree invoker** are computed once per terminal type and reused for every subsequent message, keeping reflection off the per-message path exactly as the in-process [`DomainEventDispatcher`](group-04-events-outbox.md#domaineventdispatcher) does (`:38-43`). +- **Walkthrough** + - **`Consume`** (`UpcastingIntegrationEventConsumer.cs:49-121`) null-guards the context, reads the message and its `MessageId`, and short-circuits on a duplicate: `inbox.AlreadyProcessedAsync` returning true logs at Debug and returns without touching a handler (`:59-63`). + - **The upcast** is one call, `upcasters.UpcastToTerminal(integrationEvent)`, followed by reading the runtime type of the result (`:72-73`). When the terminal type differs from `TEvent` the hop is logged at Debug with both type names and the message id (`:75-78`), which is what makes an in-flight migration visible in the logs. + - **Dispatch** pulls `(closedHandlerType, invoker)` from `DispatchCache.GetOrAdd` (`:80-84`), then enumerates `serviceProvider.GetServices(closedHandlerType)` and awaits the compiled invoker per handler, counting them (`:88-109`). A handler exception that is not an `OperationCanceledException` is logged at Error naming the failing handler type and then **rethrown** (`:101-108`), deliberately, so MassTransit applies the `UseMessageRetry` policy configured in `ConfigureBrokerTransport` before the message is dead-lettered. + - **Zero handlers is a normal outcome, not an error** (`:111-116`): the process simply does not handle this contract, so an Information line is logged and the method returns normally, which lets MassTransit ack the message rather than retry it forever. + - **The inbox record is written last** (`:120`), after every handler succeeded, keyed on the original message id and tagged with `typeof(TEvent).Name`. The comment states the invariant (`:118-119`): a handler failure rethrows above, leaves the message un-recorded, and keeps it eligible for redelivery. + - **`BuildInvoker`** (`:131-151`) constructs the delegate. It resolves `HandleAsync` on the closed handler interface (throwing an `InvalidOperationException` if it is somehow missing, `:134-135`), builds three `object`/`CancellationToken` parameters, emits `Expression.Convert` casts to the concrete handler and event types, and compiles a `Func` (`:139-150`). After the first message per terminal type, dispatch is a delegate call. +- **Why it's built this way**: [ADR-090](https://ivanball.github.io/docs/adr/090-event-upcaster-registration.html). The class doc records the one rule that will bite you if you miss it (`:19-21`): do **not** register both this consumer and the plain `IntegrationEventConsumer` for the same type, because two consumers compete for one queue and the handlers would run twice. The intended migration shape is a `RegisterUpcastedIntegrationEventConsumer()` for the retired contract, a plain `RegisterIntegrationEventConsumer()` for the current one, and an `AddEventUpcaster()` supplying the conversion (`IntegrationEventConsumerExtensions.cs:58-64`); once the queues have drained you remove all three in turn (`:65-70`). +- **Where it's used**: registered per retired type through `RegisterUpcastedIntegrationEventConsumer` inside the `configureConsumers` callback a host passes to `AddBrokerMessaging` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/IntegrationEventConsumerExtensions.cs:78-90`), which also adds a [`FaultIntegrationEventConsumer`](#faultintegrationeventconsumertevent) by default (`:86`). Covered directly by `MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/UpcastingIntegrationEventConsumerTests.cs:26`. +- **Caveats / not-in-source**: `DispatchCache` is `static`, so it is shared by every closed generic instantiation of the class within a process and is never evicted. That is unremarkable for a fixed set of event contracts; nothing in source bounds it, so a host that generated event types dynamically would grow it without limit. ### AzureBlobFileStorageService @@ -3395,9 +3459,9 @@ survives a module being pulled out into its own service. - **What it is**: the Azure Blob Storage implementation of [`IFileStorageService`](#ifilestorageservice): uploads and deletes blobs in the single configured container, returning [`Result`](group-01-result-error-handling.md#result) instead of throwing (`AzureBlobFileStorageService.cs:10-17`). - **Depends on**: [`IFileStorageService`](#ifilestorageservice), the [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error) types, and Azure externals `BlobContainerClient` / `BlobUploadOptions` / `RequestFailedException` plus `ILogger`. - **Concept introduced, the file-storage boundary and Result-wrapped I/O.** `[Rubric §10, Cross-Cutting Concerns]` covers pushing infrastructure integrations behind an application-owned contract; here blob I/O is hidden behind [`IFileStorageService`](#ifilestorageservice) and every SDK failure is caught and mapped to a domain [`Error`](group-01-result-error-handling.md#error) rather than bubbling as an exception. `IsConfigured => true` (`AzureBlobFileStorageService.cs:20`) is the flag that distinguishes this live implementation from the [`NullFileStorageService`](#nullfilestorageservice) fallback. -- **Walkthrough**: the constructor takes an already-resolved `BlobContainerClient` and a logger (`AzureBlobFileStorageService.cs:15-17`); the class comment notes the container and its public-access level are provisioned by infrastructure, not created here (`AzureBlobFileStorageService.cs:12-13`). `UploadAsync` (`AzureBlobFileStorageService.cs:23-43`) gets a blob client, uploads with an explicit `ContentType` header (`AzureBlobFileStorageService.cs:30`), and returns `Result.Success(blobClient.Uri)`; a `RequestFailedException` is logged and mapped to `Error.Failure("FileStorage.UploadFailed", ...)` (`AzureBlobFileStorageService.cs:35-42`). `DeleteAsync` (`AzureBlobFileStorageService.cs:46-62`) calls `DeleteBlobIfExistsAsync` (idempotent) and maps failures to `FileStorage.DeleteFailed`. +- **Walkthrough**: the primary constructor takes an already-resolved `BlobContainerClient` and a logger (`AzureBlobFileStorageService.cs:15-17`); the class comment notes the container and its public-access level are provisioned by infrastructure, not created here (`AzureBlobFileStorageService.cs:12-13`). `UploadAsync` (`AzureBlobFileStorageService.cs:23-43`) gets a blob client, uploads with an explicit `ContentType` header (`AzureBlobFileStorageService.cs:30`), and returns `Result.Success(blobClient.Uri)`; a `RequestFailedException` is logged and mapped to `Error.Failure("FileStorage.UploadFailed", ...)` (`AzureBlobFileStorageService.cs:35-42`). `DeleteAsync` (`AzureBlobFileStorageService.cs:46-62`) calls `DeleteBlobIfExistsAsync` (idempotent) and maps failures to `FileStorage.DeleteFailed` (`:54-61`). - **Why it's built this way**: [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-storage-and-avatars.html) introduces the file-storage and image pipeline; returning `Result` keeps storage failures on the same error-handling rail as the rest of the stack, and catching only `RequestFailedException` means genuinely unexpected errors still surface. -- **Where it's used**: registered as a transient `IFileStorageService` by `AddAzureBlobFileStorage(configuration)` in place of [`NullFileStorageService`](#nullfilestorageservice) (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:620`). The `BlobContainerClient` it receives is built one registration earlier (`DependencyInjection.cs:613-619`) and picks its auth mode from configuration: an absolute `FileStorage:ServiceUri` means `DefaultAzureCredential` (managed identity, the production path), otherwise a connection string (local Azurite); an incomplete section makes the whole call a no-op so hosts can register it unconditionally (`DependencyInjection.cs:600-611`). Consumed by the ADC Identity avatar handlers, for example [`SetUserAvatarHandler`](group-24-identity-module.md#setuseravatarhandler) (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarHandler.cs:19`), [`RemoveUserAvatarHandler`](group-24-identity-module.md#removeuseravatarhandler) (`RemoveUserAvatarHandler.cs:16`), and [`DeleteUserHandler`](group-24-identity-module.md#deleteuserhandler) (`DeleteUserHandler.cs:30`), typically after [`ImageSharpImageProcessor`](#imagesharpimageprocessor) has normalized the bytes. +- **Where it's used**: registered as a transient `IFileStorageService` by `AddAzureBlobFileStorage(configuration)` in place of [`NullFileStorageService`](#nullfilestorageservice) (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:633`). The `BlobContainerClient` it receives is built one registration earlier (`DependencyInjection.cs:626-632`) and picks its auth mode from configuration: an absolute `FileStorage:ServiceUri` means `DefaultAzureCredential` (managed identity, the production path), otherwise a connection string (local Azurite). Two guards make the whole call a no-op on an incomplete section, one for a missing container name (`:613-617`) and one for neither an absolute service URI nor a connection string (`:619-624`, with the comment noting that an empty-string `ServiceUri` binds to a *relative* Uri and so does not count), so hosts can register it unconditionally. Consumed by the ADC Identity avatar handlers, for example [`SetUserAvatarHandler`](group-24-identity-module.md#setuseravatarhandler) (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarHandler.cs:19`), [`RemoveUserAvatarHandler`](group-24-identity-module.md#removeuseravatarhandler) (`RemoveUserAvatarHandler.cs:16`), and [`DeleteUserHandler`](group-24-identity-module.md#deleteuserhandler) (`DeleteUserHandler.cs:30`), typically after [`ImageSharpImageProcessor`](#imagesharpimageprocessor) has normalized the bytes. ### AzureNotificationHubDeviceRegistrar @@ -3411,7 +3475,7 @@ survives a module being pulled out into its own service. - **`DeleteAsync(string installationId, ...)`** (`:60-77`) is the unscoped overload: it deletes and treats `MessagingEntityNotFoundException` as success (`:67-71`), because an unknown installation is already in the desired state. The interface remarks are emphatic that this overload performs no ownership check and must not be reached from a caller-supplied id; it stays for server-initiated cleanup where the owner is already established (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IPushDeviceRegistrar.cs:24-29`). - **`DeleteAsync(UserIdentifierType userId, string installationId, ...)`** (`:80-109`) is the one an authenticated endpoint calls. It reads the installation, checks the owner tag through the private `OwnedBy` helper (`:111-112`), and deletes only on a match. A mismatch returns `Result.Success()` **without** deleting (`:89-94`): answering differently for "no such installation" and "not yours" would turn the endpoint into an existence oracle for other users' installation ids, and the caller has nothing to do with either answer (`IPushDeviceRegistrar.cs:40-48`). Both delete overloads funnel their `MessagingException` mapping through the private `DeleteFailed()` factory (`:114-118`). - **Why it's built this way**: [ADR-044](https://ivanball.github.io/docs/adr/044-native-push-delivery.html)'s native channel needs a way to associate devices with users; the tag-per-installation approach lets sends target `user:{id}` OR-expressions without the app keeping its own device table, and it doubles as the ownership record the scoped delete verifies. Idempotent delete keeps client retries safe. The default interface implementation of the scoped overload delegates to the unscoped one (`IPushDeviceRegistrar.cs:54-55`) so out-of-framework implementations keep compiling; this class overrides it because it can actually verify ownership. -- **Where it's used**: registered as a transient `IPushDeviceRegistrar` by `AddNativePushNotifications(configuration)` in place of [`NullPushDeviceRegistrar`](#nullpushdeviceregistrar) (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:580`); called by [`DevicesController`](group-10-notifications.md#devicescontroller), which passes the authenticated user id into both operations (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/DevicesController.cs:43` and `:67`), and paired with [`AzureNotificationHubNativePushSender`](#azurenotificationhubnativepushsender) for the send side. +- **Where it's used**: registered as a transient `IPushDeviceRegistrar` by `AddNativePushNotifications(configuration)` in place of [`NullPushDeviceRegistrar`](#nullpushdeviceregistrar) (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:593`); called by [`DevicesController`](group-10-notifications.md#devicescontroller), which passes the authenticated user id into both operations (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/DevicesController.cs:43` and `:67`), and paired with [`AzureNotificationHubNativePushSender`](#azurenotificationhubnativepushsender) for the send side. Covered directly by `MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/AzureNotificationHubDeviceRegistrarTests.cs`. - **Caveats / not-in-source**: the ownership check is a read followed by a delete, not an atomic operation. The source says why that is acceptable: a concurrent re-registration of the same id between the two calls is the owner's own doing, so no lock is warranted (`AzureNotificationHubDeviceRegistrar.cs:86-87`). An installation registered before ownership tagging existed has no tag, so it is treated as someone else's and is not deleted (`:91-92`). ### ImageSharpImageProcessor @@ -3423,7 +3487,7 @@ survives a module being pulled out into its own service. - **Concept introduced, full re-encode as a security control.** `[Rubric §11, Security]` and `[Rubric §30, Compliance/Privacy/Data Governance]` both apply: decoding to pixels and re-encoding is deliberate so that EXIF metadata (including GPS coordinates, which are PII) and any polyglot payload smuggled into the original file are discarded, since only pixels survive the round trip (`ImageSharpImageProcessor.cs:9-13`). This is a defense against both privacy leaks and image-parser exploits, not merely a resize. Its upload-side companion is [`ImageContentSniffer`](#imagecontentsniffer), which decides the accepted formats (jpeg, png, webp) from the magic bytes rather than the client-declared content type or extension before the stream reaches this processor (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ImageContentSniffer.cs:3-9`, with the predicate itself at `:15-16`). - **Walkthrough**: `NormalizeToSquareJpegAsync` (`ImageSharpImageProcessor.cs:17-51`) loads the stream, then `Mutate`s with `AutoOrient()` *before* stripping metadata so a portrait phone photo is not left rotated (`ImageSharpImageProcessor.cs:23-31`), and resizes to `size x size` with `ResizeMode.Crop`. It then nulls out the EXIF, XMP, and IPTC profiles (`ImageSharpImageProcessor.cs:33-35`) and saves to a `MemoryStream` with `JpegEncoder { Quality = 85 }` (`ImageSharpImageProcessor.cs:40`), returning `Result.Success(output.ToArray())`. An `UnknownImageFormatException` or `InvalidImageContentException` is caught by an exception filter and mapped to `Error.Validation("Image.Undecodable", ...)` (`ImageSharpImageProcessor.cs:44-50`), so a garbage upload becomes a clean validation failure rather than a 500. - **Why it's built this way**: [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-storage-and-avatars.html) pairs storage with sanitization; ordering `AutoOrient` before metadata removal is the subtle correctness detail, and quality 85 is the standard size/quality trade-off. Catching only the two ImageSharp decode exceptions keeps unexpected faults visible. -- **Where it's used**: registered with `TryAddSingleton` as the `IImageProcessor` in `AddInfrastructure` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:484`, whose comment notes it is dependency-free and therefore always the real implementation, `DependencyInjection.cs:481-482`); invoked by [`SetUserAvatarHandler`](group-24-identity-module.md#setuseravatarhandler) (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarHandler.cs:18`) before the bytes are handed to [`AzureBlobFileStorageService`](#azureblobfilestorageservice). There is no Null variant because processing needs no external resource. +- **Where it's used**: registered with `TryAddSingleton` as the `IImageProcessor` in `AddServices` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:497`, whose comment notes it is dependency-free and therefore always the real implementation, `DependencyInjection.cs:494-495`); invoked by [`SetUserAvatarHandler`](group-24-identity-module.md#setuseravatarhandler) (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarHandler.cs:18`) before the bytes are handed to [`AzureBlobFileStorageService`](#azureblobfilestorageservice). There is no Null variant because processing needs no external resource. Covered directly by `MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/ImageSharpImageProcessorTests.cs`. ### NullFileStorageService @@ -3433,7 +3497,7 @@ survives a module being pulled out into its own service. - **Depends on**: [`IFileStorageService`](#ifilestorageservice) and [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error). - **Concept reinforced, an asymmetric Null Object (fail-closed write, no-op delete).** `[Rubric §2, Design Patterns]` and `[Rubric §10, Cross-Cutting Concerns]`: unlike a pure no-op, this fallback distinguishes its two operations by intent. `IsConfigured => false` (`NullFileStorageService.cs:14`) lets callers detect the disabled channel; `UploadAsync` returns `Error.Failure("FileStorage.NotConfigured", ...)` (`NullFileStorageService.cs:17-21`) so a write fails loudly and predictably, while `DeleteAsync` returns `Result.Success()` (`NullFileStorageService.cs:24-25`) because there is nothing to delete and a delete of a non-existent file is already the desired state. - **Why it's built this way**: [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-storage-and-avatars.html) makes storage optional; failing uploads with a typed error (rather than a null-reference crash) keeps a host with no storage configured running and honest about what it cannot do. -- **Where it's used**: registered with `TryAddTransient` as the default `IFileStorageService` in `AddInfrastructure` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:483`), swapped for [`AzureBlobFileStorageService`](#azureblobfilestorageservice) by `AddAzureBlobFileStorage(configuration)` (`DependencyInjection.cs:595-623`). +- **Where it's used**: registered with `TryAddTransient` as the default `IFileStorageService` in `AddServices` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:496`), swapped for [`AzureBlobFileStorageService`](#azureblobfilestorageservice) by `AddAzureBlobFileStorage(configuration)` (`DependencyInjection.cs:608-636`). ### NullPushDeviceRegistrar @@ -3443,24 +3507,7 @@ survives a module being pulled out into its own service. - **Depends on**: [`IPushDeviceRegistrar`](#ipushdeviceregistrar), [`DeviceInstallationRequest`](group-10-notifications.md#deviceinstallationrequest), and [`Result`](group-01-result-error-handling.md#result). - **Concept reinforced, the Null Object pattern for the disabled native channel.** `[Rubric §2, Design Patterns]`: `UpsertAsync` and both `DeleteAsync` overloads return `Result.Success()` (`NullPushDeviceRegistrar.cs:15-24`), so the Devices API is always callable and simply does nothing when no notification hub is wired up. Note that it implements the owner-scoped delete explicitly rather than inheriting the interface's default (which would delegate to the unscoped overload): the outcome is identical, and being explicit keeps the no-op honest about supporting the full contract. It is the device-registration twin of [`NullNativePushSender`](#nullnativepushsender), which no-ops the send side of the same disabled channel ([ADR-044](https://ivanball.github.io/docs/adr/044-native-push-delivery.html)). - **Why it's built this way**: keeping registration a success (rather than an error) means a client that always registers on launch is not blocked by a host that has not enabled native push; the channel becomes real only when [`AzureNotificationHubDeviceRegistrar`](#azurenotificationhubdeviceregistrar) is registered. -- **Where it's used**: registered with `TryAddTransient` as the default `IPushDeviceRegistrar` in `AddInfrastructure` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:479`), replaced by [`AzureNotificationHubDeviceRegistrar`](#azurenotificationhubdeviceregistrar) when `AddNativePushNotifications(configuration)` finds an enabled hub (`DependencyInjection.cs:580`). - -### DesignTimeDbContextHelper - -> MMCA.Common.Infrastructure · `MMCA.Common.Infrastructure.Persistence.DbContexts.Design` · `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Design/DesignTimeDbContextHelper.cs:36` · Level 8 · class (static) - -- **What it is**: a static helper that builds a [`SQLServerDbContext`](#sqlserverdbcontext) for `dotnet ef` design-time commands **without** the application's DI container, so each per-database migrations project reduces to a few lines (`DesignTimeDbContextHelper.cs:18-36`). -- **Depends on**: EF Core (`DbContextOptionsBuilder`, the caller-implemented `IDesignTimeDbContextFactory`), the data-source resolution stack ([`DataSourceResolver`](#datasourceresolver), [`EntityDataSourceRegistry`](#entitydatasourceregistry), [`DataSourcesSettings`](group-14-module-system-composition.md#datasourcessettings)), the four save interceptors ([`AuditSaveChangesInterceptor`](#auditsavechangesinterceptor), [`DomainEventSaveChangesInterceptor`](#domaineventsavechangesinterceptor), [`TenantSaveChangesInterceptor`](#tenantsavechangesinterceptor), [`AuditTrailSaveChangesInterceptor`](#audittrailsavechangesinterceptor)), the options types [`TenancySettings`](group-14-module-system-composition.md#tenancysettings) / [`SchedulerSettings`](group-14-module-system-composition.md#schedulersettings) / [`AuditTrailSettings`](group-14-module-system-composition.md#audittrailsettings), [`IOutboxSignal`](group-04-events-outbox.md#ioutboxsignal) / [`OutboxSignal`](group-04-events-outbox.md#outboxsignal), and its own two private nested leaves [`ExplicitAssemblyProvider`](#explicitassemblyprovider) and [`NullDomainEventDispatcher`](#nulldomaineventdispatcher). -- **Concept introduced, design-time context construction for migrations-per-database.** `[Rubric §17, DevOps]` and `[Rubric §33, Developer Experience]`: database-per-service ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)) needs one migrations project per database, and scaffolding a migration must not require standing up the whole app. `CreateSqlServer(args, configure)` (`DesignTimeDbContextHelper.cs:45-101`) lets a migrations project implement EF's `IDesignTimeDbContextFactory` in a callback that supplies connection settings, model-shape flags, and configuration assemblies (the pattern is shown verbatim in the class doc, `DesignTimeDbContextHelper.cs:20-32`). -- **Walkthrough** - - **Argument handling and source selection** (`DesignTimeDbContextHelper.cs:45-55`): both parameters are null-guarded, the caller's `configure` runs over a fresh [`DesignTimeDbContextOptions`](#designtimedbcontextoptions), and the logical source name is resolved in priority order: explicit `DataSourceName`, else `--datasource` from args, else `DataSourceKey.DefaultName`. - - **The routing stack** (`:57-62`): an [`ExplicitAssemblyProvider`](#explicitassemblyprovider) over the listed assemblies, a [`DataSourceResolver`](#datasourceresolver) built from the supplied connection settings and `DataSources` entries with a `NullLogger`, and an [`EntityDataSourceRegistry`](#entitydatasourceregistry) over the two. - - **The minimal container** (`:64-92`) is hand-built as a plain `ServiceCollection`: `TimeProvider.System`, null logger factory and null generic loggers, the [`NullDomainEventDispatcher`](#nulldomaineventdispatcher), an [`OutboxSignal`](group-04-events-outbox.md#outboxsignal), and the interceptors. The tenant interceptor and a default [`TenancySettings`](group-14-module-system-composition.md#tenancysettings) are registered **unconditionally**, and the comment explains the reasoning: design time never resolves a tenant, so the interceptor is inert and the `Tenant` query filter short-circuits, which means the scaffolded migration is identical with or without tenancy apart from the `TenantId` column and index the model declares (`:72-78`). [`SchedulerSettings`](group-14-module-system-composition.md#schedulersettings) and [`AuditTrailSettings`](group-14-module-system-composition.md#audittrailsettings) are created from the two `DesignTimeDbContextOptions` flags (`:82-88`), which is how an opt-in table becomes part of the design-time model; the audit-trail interceptor is registered even though the context resolves it with `GetService`, purely to keep the design-time pipeline identical to the runtime one (`:84-89`). - - **Construction** (`:94-100`): the logical name is collapsed to a physical one through `resolver.GetPhysical(resolver.ResolveLogical(DataSource.SQLServer, logicalName))`, then the [`SQLServerDbContext`](#sqlserverdbcontext) is built with an empty options builder, the built service provider, the assembly provider, and that physical key, so the model contains only the selected source's entities. - - **`ParseDataSourceName`** (`:106-124`) reads `--datasource ` or `--datasource=Name`, throwing an actionable `InvalidOperationException` if the flag is present with no value (`:112-114`). -- **Why it's built this way**: [ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html) requires per-database migrations; a shared design-time helper keeps each migrations project trivial and avoids booting the full application DI graph just to scaffold a migration. The pattern in the registrations above is "register everything the runtime registers, defaulted to inert", because the failure mode this guards against is a scaffolded migration that quietly differs from the running model. -- **Where it's used**: called from each per-database migrations factory, for example `MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Conference/DesignTimeSQLServerDbContextFactory.cs:15` and its Identity, Engagement, and Notification siblings, MMCA.Store's Catalog/Sales/Identity factories, and MMCA.Helpdesk's single Tickets factory (`MMCA.Helpdesk/Source/Hosting/MMCA.Helpdesk.Migrations.SqlServer.Tickets/DesignTimeSQLServerDbContextFactory.cs:25`). It is invoked as `dotnet ef migrations add X --project ... -- --datasource ` (`DesignTimeDbContextHelper.cs:33-34`), and covered directly by `MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DataSources/DesignTimeDbContextHelperTests.cs:37`. -- **Caveats / not-in-source**: the ADC Conference factory is worth reading beside this helper for the one non-obvious trap. It deliberately gives the top-level connection string and the named `Conference` entry the **same** value so the design-time source collapses onto `Default` exactly as the running host's does; without the collapse the physical key would be the named `Conference` key and the host-scoped `ScheduledJobs` table would be missing from the scaffolded model while present in the running one (`MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Conference/DesignTimeSQLServerDbContextFactory.cs:17-27`). +- **Where it's used**: registered with `TryAddTransient` as the default `IPushDeviceRegistrar` in `AddServices` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:492`), replaced by [`AzureNotificationHubDeviceRegistrar`](#azurenotificationhubdeviceregistrar) when `AddNativePushNotifications(configuration)` finds an enabled hub (`DependencyInjection.cs:593`). ### UpdatePropertySetterBuilder diff --git a/docs-src/onboarding/group-08-auth.md b/docs-src/onboarding/group-08-auth.md index 5226472..9136d2d 100644 --- a/docs-src/onboarding/group-08-auth.md +++ b/docs-src/onboarding/group-08-auth.md @@ -3,7 +3,7 @@ **What this group covers.** This is the security spine of the framework: how a caller proves who they are (authentication), how the system decides what they may do (authorization), and how both survive the jump from a single-process monolith to a fleet of extracted services. Almost every type here -serves one of eight moving parts: **minting and validating JWTs** +serves one of nine moving parts: **minting and validating JWTs** ([`TokenService`](#tokenservice) / [`ITokenService`](#itokenservice), [`RsaJwksProvider`](#rsajwksprovider) / [`IJwksProvider`](#ijwksprovider)); **the shared login / register / refresh workflow** ([`AuthenticationServiceBase`](#authenticationservicebasetuser), @@ -15,8 +15,12 @@ exposes to those shared workflows** ([`IAuthUser`](#iauthuser), ([`PasswordHasher`](#passwordhasher) / [`IPasswordHasher`](#ipasswordhasher)); **brute-force and rate-limit protection** ([`LoginProtectionService`](#loginprotectionservice) / [`ILoginProtectionService`](#iloginprotectionservice), -[`LoginProtectionSettings`](#loginprotectionsettings)); **reading the current caller's identity from -claims** ([`CurrentUserService`](#currentuserservice) / [`ICurrentUserService`](#icurrentuserservice), +[`LoginProtectionSettings`](#loginprotectionsettings)); **the forgot-password token lifecycle** +([`PasswordResetTokenService`](#passwordresettokenservice) / +[`IPasswordResetTokenService`](#ipasswordresettokenservice), +[`PasswordResetEntry`](#passwordresetentry), [`PasswordResetSettings`](#passwordresetsettings)); +**reading the current caller's identity from claims** +([`CurrentUserService`](#currentuserservice) / [`ICurrentUserService`](#icurrentuserservice), [`ClaimBasedUserIdProvider`](#claimbaseduseridprovider), [`AuthClaimTypes`](#authclaimtypes)); **the authorization model** (roles, permissions, and resource ownership under [`AuthorizationExtensions`](#authorizationextensions), @@ -34,6 +38,8 @@ refresh token with reuse detection), [ADR-029](https://ivanball.github.io/docs/adr/029-authentication-brute-force-protection.html) (brute-force protection), [ADR-032](https://ivanball.github.io/docs/adr/032-password-hashing.html) (password hashing), +[ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html) (the cache-backed +forgot-password token), [ADR-020](https://ivanball.github.io/docs/adr/020-permission-based-authorization.html) (permission-based authorization), [ADR-033](https://ivanball.github.io/docs/adr/033-resource-ownership-authorization.html) @@ -43,7 +49,7 @@ session-cookie scheme), and [ADR-051](https://ivanball.github.io/docs/adr/051-client-auth-token-lifecycle.html) (how each render head holds and reacquires a token). The rubric lenses are dominated by [Rubric §11, Security], with supporting [Rubric §7, Microservices Readiness] and [Rubric §10, Cross-Cutting]. Auth surfaces all of -its expected failures (bad password, lockout, expired session) as +its expected failures (bad password, lockout, expired session, rejected reset token) as [`Result`](group-01-result-error-handling.md#result) failures, never exceptions, so reading the [Result pattern](group-01-result-error-handling.md#result) first pays off here. @@ -87,7 +93,7 @@ The public half is served by [`RsaJwksProvider`](#rsajwksprovider) (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/RsaJwksProvider.cs:15`), which lazily builds a `JsonWebKeySet` from a PEM key (inline or read from a path) configured through [`JwksSettings`](group-14-module-system-composition.md#jwkssettings) (`RsaJwksProvider.cs:15`, -`RsaJwksProvider.cs:58-74`), behind the [`IJwksProvider`](#ijwksprovider) port +`RsaJwksProvider.cs:58-73`), behind the [`IJwksProvider`](#ijwksprovider) port (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/IJwksProvider.cs:11`). Publishing is off by default, and when disabled or unconfigured the provider returns an *empty* key set (`RsaJwksProvider.cs:30-33`, `RsaJwksProvider.cs:36-39`) so the endpoint stays queryable but a @@ -169,13 +175,15 @@ The request and response DTOs for these flows ([`LoginRequest`](#loginrequest), [`AuthenticationResponse`](#authenticationresponse), [`ChangePasswordRequest`](#changepasswordrequest), [`OAuthCodeExchangeRequest`](#oauthcodeexchangerequest), and the device-aware [`AuthenticationRequest`](#authenticationrequest) used by MAUI clients) are compact `readonly record -struct`s in `MMCA.Common.Shared`. Two of them mark boundaries worth noting: password change is +struct`s in `MMCA.Common.Shared`. Several of them mark boundaries worth noting: password change is dispatched straight through its command handler at the controller layer rather than brokered by [`IAuthenticationService`](#iauthenticationservice) -(`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IAuthenticationService.cs:8-9`), and -`ExternalLoginAsync` has a default interface implementation that *rejects* the call -(`IAuthenticationService.cs:66-74`) because OAuth account linking stays coupled to the app's own -`User` factory. `OAuthCodeExchangeRequest` carries only an opaque single-use code +(`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IAuthenticationService.cs:11`), the same is +true of the forgot/reset pair below ([`ForgotPasswordRequest`](#forgotpasswordrequest), +[`ResetPasswordRequest`](#resetpasswordrequest)), and `ExternalLoginAsync` has a default interface +implementation that *rejects* the call (`IAuthenticationService.cs:66-74`) because OAuth account +linking stays coupled to the app's own `User` factory. `OAuthCodeExchangeRequest` carries only an +opaque single-use code (`MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/OAuthCodeExchangeRequest.cs:11`) precisely so the token pair never appears in the address bar, browser history, a `Referer` header, or an access log. The FluentValidation rules that guard the requests are bundled into one parameter object, @@ -201,12 +209,16 @@ password hash and salt, the current refresh token and its expiry, and the two mu aggregates stay app-specific and are reached only through the per-app hooks. [`IPasswordChangeableUser`](#ipasswordchangeableuser) (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IPasswordChangeableUser.cs:11`) extends it with -`ChangePassword`, because the rotation workflow must verify the current credential before writing the -new one. [`IUserPreferences`](#iuserpreferences) +`ChangePassword` (`IPasswordChangeableUser.cs:19`), because both the rotation workflow and the reset +workflow have to write a new credential through the aggregate rather than around it. +[`IUserPreferences`](#iuserpreferences) (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IUserPreferences.cs:10`) carries the stored culture -and theme plus a single `UpdatePreferences` mutator that always writes both fields, so persisting one -preference never clears the other (`IUserPreferences.cs:18-25`), matching the null-means-unchanged -semantics of [`ChangePreferencesRequest`](#changepreferencesrequest) and +and theme plus a single `UpdatePreferences` mutator that always replaces *both* fields +(`IUserPreferences.cs:13-25`); the shared workflow is what preserves the other preference, passing the +stored value for any field the request left null +(`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:53-55`), +which is the null-means-unchanged contract stated on +[`ChangePreferencesRequest`](#changepreferencesrequest) and mirrored by [`UserPreferencesResponse`](#userpreferencesresponse) (`MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ChangePreferencesRequest.cs:10`, `MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/UserPreferencesResponse.cs:9`). @@ -228,6 +240,8 @@ contracts are consumed by the shared handler bases in group 14: (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:23`), [`GetUserPreferencesHandlerBase`](group-14-module-system-composition.md#getuserpreferenceshandlerbasetuser) (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandlerBase.cs:21`), +[`ResetPasswordHandlerBase`](group-14-module-system-composition.md#resetpasswordhandlerbasetuser-tcommand) +(`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:30`), and [`DeleteUserHandlerBase`](group-14-module-system-composition.md#deleteuserhandlerbasetuser-tcommand) (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:38`), @@ -274,6 +288,87 @@ read-modify-write rather than an atomic counter, because the native Redis `INCR` `IDistributedCache` could not read back (`LoginProtectionService.cs:66-74`). Sequential guessing, which is what a credential-stuffing run looks like, still trips the lockout. +## Forgot password: a cache-backed single-use token + +A user who has lost the password cannot present one, so this flow is anonymous by necessity, which +makes every one of its responses a potential account-enumeration oracle. It is also built without a +schema change: the token lives in the cache, hashed, and expires by TTL rather than being reaped by a +sweeper ([ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html)). The +port is [`IPasswordResetTokenService`](#ipasswordresettokenservice) +(`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IPasswordResetTokenService.cs:10`), two methods +wide: `IssueAsync` mints a token for an address (`IPasswordResetTokenService.cs:23`) and +`ValidateAndConsumeAsync` redeems it exactly once (`IPasswordResetTokenService.cs:36`). The +implementation, [`PasswordResetTokenService`](#passwordresettokenservice) +(`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:26`), rides on +[`ICacheService`](group-09-caching.md#icacheservice) and buys four properties in a few lines each: + +- **One active token per email.** Issuing writes the same per-address key + (`PasswordResetTokenService.cs:51`, `PasswordResetTokenService.cs:88`), so requesting a new link + retires the previous one. +- **Hashed at rest.** Only the Base64 of the token's SHA-256 is stored + (`PasswordResetTokenService.cs:55-56`, `PasswordResetTokenService.cs:82-88`), so a cache dump hands + out no working reset links, and the comparison on redemption is constant time through + `CryptographicOperations.FixedTimeEquals` (`PasswordResetTokenService.cs:118`). +- **An attempt cap.** A wrong token increments a counter on the record, and the record is discarded at + `MaxValidationAttempts` (`PasswordResetTokenService.cs:132-153`, default 5, + `MMCA.Common/Source/Core/MMCA.Common.Application/Auth/PasswordResetSettings.cs:36`). The rewrite + after a wrong guess uses the record's *remaining* lifetime rather than a fresh one + (`PasswordResetTokenService.cs:148-152`), so guessing cannot extend the redeemable window. +- **A per-email request throttle.** A counter carrying the window's TTL caps how often one address can + trigger an email (`PasswordResetTokenService.cs:66-77`, default 3 per 60 minutes, + `PasswordResetSettings.cs:40`, `PasswordResetSettings.cs:44`), and a successful redemption deletes + the token *and* that counter (`PasswordResetTokenService.cs:126-127`) so a legitimate reset does not + leave the user throttled out of a later one. + +Keys are built from an `Email`-normalized identity for the same reason +[`LoginProtectionService`](#loginprotectionservice) does it +(`PasswordResetTokenService.cs:40-53`). The cached record, [`PasswordResetEntry`](#passwordresetentry) +(`PasswordResetTokenService.cs:171`), is deliberately all JSON primitives: cache values round-trip +through `System.Text.Json`, so a value object or a `byte[]` member would not survive a distributed +backing store. Token material is 32 random bytes, Base64Url-encoded +(`PasswordResetTokenService.cs:30`, `PasswordResetTokenService.cs:79`), redeemable for +`TokenLifetimeMinutes` (default 30, `PasswordResetSettings.cs:29`), and every rejection (unknown, +expired, mismatched, attempt-capped) collapses into one generic failure +(`PasswordResetTokenService.cs:155-159`). The settings bind from the `PasswordReset` configuration +section and the service is registered scoped in Infrastructure DI (`PasswordResetSettings.cs:13`, +`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:139-143`). + +The workflow around the port lives in the group-14 handler bases, and it is where the +anti-enumeration rule is enforced. +[`ForgotPasswordHandlerBase`](group-14-module-system-composition.md#forgotpasswordhandlerbasetuser-tcommand) +(`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:35`) +resolves the account through its one abstract lookup, issues a token, and mails it through +[`IEmailSender`](group-10-notifications.md#iemailsender) (`ForgotPasswordHandlerBase.cs:83-88`), but a +malformed address, an address with no account, a throttled request, and a failed send all log and +return success alike (`ForgotPasswordHandlerBase.cs:57-62`, `ForgotPasswordHandlerBase.cs:65-69`, +`ForgotPasswordHandlerBase.cs:72-76`, `ForgotPasswordHandlerBase.cs:90-95`). The only 400 comes from +[`ForgotPasswordRequestValidator`](#forgotpasswordrequestvalidator) +(`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ForgotPasswordRequestValidator.cs:11`), +which inspects the shape of the address and nothing else. The email carries both a prefilled link +(composed from `PasswordResetSettings.ResetUrl`, deliberately not required so a host that has not +configured a UI base still boots, `PasswordResetSettings.cs:25`) and the raw token, because a client +without deep linking (the MAUI head) needs it typed into the reset page by hand +(`ForgotPasswordHandlerBase.cs:123-133`). +[`ResetPasswordHandlerBase`](group-14-module-system-composition.md#resetpasswordhandlerbasetuser-tcommand) +(`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:30`) +consumes the token *before* the save on a stated trade-off (leaving it live until the write succeeds +opens a replay window; a token burned by a later invariant failure costs the user one more reset +request, `ResetPasswordHandlerBase.cs:61-67`), hashes through [`IPasswordHasher`](#ipasswordhasher) +and writes the credential through the aggregate's `ChangePassword` +(`ResetPasswordHandlerBase.cs:79-80`), then clears the login-protection counters so a user who reset +*because* of a lockout is not left locked out (`ResetPasswordHandlerBase.cs:89`). +[`ResetPasswordRequestValidator`](#resetpasswordrequestvalidator) +(`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ResetPasswordRequestValidator.cs:12`) +includes the same [`StrongPasswordRules`](group-06-validation.md#strongpasswordrulest) that +registration and change-password use (`ResetPasswordRequestValidator.cs:40`), so a reset is not a way +around the complexity policy. The endpoints are +[`PasswordResetAuthControllerBase`](group-12-api-hosting-mapping.md#passwordresetauthcontrollerbasetforgotpasswordcommand-tresetpasswordcommand) +(`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:43`): +both actions are `[AllowAnonymous]` and rate-limited per IP exactly as login and register are, +`forgot-password` answers 202 for any well-formed request +(`PasswordResetAuthControllerBase.cs:75-92`), and `reset-password` collapses every rejection into a +single 401 (`PasswordResetAuthControllerBase.cs:99-117`). + ## Reading identity from claims Once a request is authenticated, downstream code needs the caller's identity without re-parsing the @@ -295,7 +390,8 @@ case-insensitive membership check over that set (`ICurrentUserService.cs:88-89`) [`ClaimBasedUserIdProvider`](#claimbaseduseridprovider) (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/ClaimBasedUserIdProvider.cs:9`), plugs the same `user_id` claim into SignalR's `IUserIdProvider` so `Clients.User(userId)` routes hub messages to -the right connections. [`AuthClaimTypes`](#authclaimtypes) +the right connections (`ClaimBasedUserIdProvider.cs:11`, `ClaimBasedUserIdProvider.cs:14`). +[`AuthClaimTypes`](#authclaimtypes) (`MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/AuthClaimTypes.cs:7`) names the one framework-custom claim beyond the BCL set, `"permission"` (`AuthClaimTypes.cs:15`), used by the authorization model below. @@ -328,8 +424,8 @@ controller or action with a permission such as `"sessions:manage"`; under the ho `AuthorizeAttribute` whose policy name is `perm:sessions:manage` ([`PermissionPolicy`](#permissionpolicy)`.NameFor`, `MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionPolicy.cs:12`, -`PermissionPolicy.cs:17`). Rather than pre-registering a named policy per permission, -[`PermissionPolicyProvider`](#permissionpolicyprovider) +`PermissionPolicy.cs:17`, applied at `HasPermissionAttribute.cs:18`). Rather than pre-registering a +named policy per permission, [`PermissionPolicyProvider`](#permissionpolicyprovider) (`MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionPolicyProvider.cs:13`) materializes those policies on demand for any `perm:` name and falls through to the default provider for everything else (`PermissionPolicyProvider.cs:31-47`). The requirement it attaches, @@ -368,8 +464,9 @@ action that legitimately has no owner parameter opts out explicitly with [`AllowMissingOwnerAttribute`](#allowmissingownerattribute) (`MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AllowMissingOwnerAttribute.cs:21`), honored from either the action or its declaring controller via endpoint metadata -(`OwnerOrAdminFilter.cs:83-84`). The filter's vocabulary (claim type, bypass role, route parameter) is -configurable through [`OwnerOrAdminFilterOptions`](#owneroradminfilteroptions) +(`OwnerOrAdminFilter.cs:83-84`, `AllowMissingOwnerAttribute.cs:20`). The filter's vocabulary (claim +type, bypass role, route parameter) is configurable through +[`OwnerOrAdminFilterOptions`](#owneroradminfilteroptions) (`MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilterOptions.cs:11`) whose defaults preserve the original `customer_id` / `Admin` / `id` behavior (`OwnerOrAdminFilterOptions.cs:14-24`, @@ -421,7 +518,7 @@ allowance (`CookieSessionRefresher.cs:59`, `CookieSessionRefresher.cs:154-183`); exchanges the refresh cookie at the API's `auth/refresh` endpoint server-to-server (`CookieSessionRefresher.cs:127-130`), so the refresh token never reaches browser JS. It then writes the rotated pair back as cookies and stashes the fresh access token on `HttpContext.Items` -(`CookieSessionRefresher.cs:87-91`) so the *current* request's authentication reads the new token: +(`CookieSessionRefresher.cs:86-91`) so the *current* request's authentication reads the new token: [`CookieTokenReader`](#cookietokenreader) checks that item before falling back to the request cookie (`CookieTokenReader.cs:17`, `CookieTokenReader.cs:27-33`). Concurrent refreshes are collapsed into a single flight by a [`KeyedSemaphoreStripe`](#keyedsemaphorestripe) keyed on the refresh token plus a @@ -433,11 +530,11 @@ call, so a single semaphore serialized every unrelated user's cold navigation be was in flight (`CookieSessionRefresher.cs:44-49`); two unrelated tokens sharing a stripe is harmless because the grace cache is re-checked per token after acquiring (`CookieSessionRefresher.cs:104-108`). A transport failure is not cached and renders the request -anonymously rather than throwing a 500 out of SSR (`CookieSessionRefresher.cs:117-122`, +anonymously rather than throwing a 500 out of SSR (`CookieSessionRefresher.cs:113-122`, `CookieSessionRefresher.cs:147-151`). The same refresher backs the same-origin `POST /auth/session/token` endpoint the browser polls to hydrate its in-memory token (`SessionCookieEndpoints.cs:45-60`), guarded by `SameSite=Lax` plus a `Sec-Fetch-Site` cross-site -rejection (`SessionCookieEndpoints.cs:48`, `SessionCookieEndpoints.cs:68-70`) and returning +rejection (`SessionCookieEndpoints.cs:44`, `SessionCookieEndpoints.cs:66-70`) and returning [`SessionTokenResponse`](#sessiontokenresponse) (`CookieSessionRefresher.cs:20`), the browser-safe projection of the internal [`SessionTokenResult`](#sessiontokenresult) (`CookieSessionRefresher.cs:14`) that deliberately omits the refresh token. This whole cluster is @@ -480,11 +577,13 @@ dependency grouping fell, though one of them is now load-bearing for auth. [`KeyedSemaphoreStripe`](#keyedsemaphorestripe) (`MMCA.Common/Source/Core/MMCA.Common.Shared/Concurrency/KeyedSemaphoreStripe.cs:22`) and its [`Releaser`](#releaser) handle (`KeyedSemaphoreStripe.cs:78`) serialize work per logical key across a -fixed set of 256 semaphores (`KeyedSemaphoreStripe.cs:25`, `KeyedSemaphoreStripe.cs:60-75`). That is -the bounded alternative to a semaphore-per-key dictionary, which forces a choice between two defects: -removing the entry on release opens a window where one caller waits on a semaphore no longer in the -table while another creates a fresh one, and never removing it lets caller-supplied keys grow the table -without bound (`KeyedSemaphoreStripe.cs:7-16`). Its consumers today are +fixed set of semaphores (256 by default, `KeyedSemaphoreStripe.cs:25`, with an explicit-width +constructor at `KeyedSemaphoreStripe.cs:37`; acquisition maps the key onto one stripe at +`KeyedSemaphoreStripe.cs:60-75`). That is the bounded alternative to a semaphore-per-key dictionary, +which forces a choice between two defects: removing the entry on release opens a window where one +caller waits on a semaphore no longer in the table while another creates a fresh one, and never +removing it lets caller-supplied keys grow the table without bound +(`KeyedSemaphoreStripe.cs:7-16`). Its consumers today are [`CookieSessionRefresher`](#cookiesessionrefresher) (above, `CookieSessionRefresher.cs:62`), the [`IdempotencyFilter`](group-12-api-hosting-mapping.md#idempotencyfilter) (`MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:92`), @@ -520,14 +619,15 @@ an otherwise-valid token whose backing account has since been soft-deleted (BR-1 each Identity module so Common never takes a cross-module domain reference. Its fast path is [`SoftDeletedUserCache`](#softdeletedusercache) (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/SoftDeletedUserCache.cs:17`), which owns both the -key shape and the 30-second marker lifetime (`SoftDeletedUserCache.cs:29`, `SoftDeletedUserCache.cs:42`) +key shape and the 30-second marker lifetime (`SoftDeletedUserCache.cs:29`, `SoftDeletedUserCache.cs:43`) so the module that deletes an account writes exactly the key the middleware reads; the marker only has to outlive the window between the delete committing and the next validator query, and the 15-minute access-token lifetime bounds the rest of the exposure. The key is formatted invariantly on purpose, because a culture-sensitive identifier would be written under one request's culture and missed under -another (`SoftDeletedUserCache.cs:42-43`). The controller surface that drives everything above +another (`SoftDeletedUserCache.cs:37-43`). The controller surface that drives everything above ([`AuthControllerBase`](group-12-api-hosting-mapping.md#authcontrollerbase), [`OAuthControllerBase`](group-12-api-hosting-mapping.md#oauthcontrollerbase), +[`PasswordResetAuthControllerBase`](group-12-api-hosting-mapping.md#passwordresetauthcontrollerbasetforgotpasswordcommand-tresetpasswordcommand), [`ExternalAuthExtensions`](group-12-api-hosting-mapping.md#externalauthextensions)) and the gRPC token forwarding ([`JwtForwardingClientInterceptor`](group-13-grpc-contracts.md#jwtforwardingclientinterceptor)) live in later groups; this chapter is the engine those endpoints call into. @@ -1027,123 +1127,7 @@ live in later groups; this chapter is the engine those endpoints call into. holds for a cart or a customer profile but not for a resource with its own id and a foreign-key owner ([ADR-033](https://ivanball.github.io/docs/adr/033-resource-ownership-authorization.html) lists orders as that case, handled with a specification or an explicit per-id check instead). -### IPasswordHasher - -> MMCA.Common.Application · `MMCA.Common.Application.Interfaces.Infrastructure` · `MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IPasswordHasher.cs:6` · Level 0 · interface - -- **What it is**: the password-security port. Two methods: hash a plaintext password into a separated - `(byte[] Hash, byte[] Salt)` pair, and verify a plaintext against a stored hash plus salt. -- **Depends on**: nothing first-party, BCL only (`byte[]`). Its Infrastructure adapter is - [`PasswordHasher`](#passwordhasher). -- **Concept introduced, hash and salt kept apart.** [Rubric §11, Security] assesses credential - handling. Returning the hash and the salt as two distinct `byte[]` members - (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IPasswordHasher.cs:11`) - rather than one concatenated blob keeps the storage contract explicit: the caller persists two - columns, and `VerifyPassword` (`:18`) is unambiguous about what it re-derives and compares. Because - the algorithm and its parameters live entirely behind this interface, they can be strengthened - without touching a single Application handler - ([ADR-032](https://ivanball.github.io/docs/adr/032-password-hashing.html) sets the current hashing - policy, applied inside [`PasswordHasher`](#passwordhasher)). -- **Walkthrough**: `(byte[] Hash, byte[] Salt) HashPassword(string password)` (`:11`) returns a named - value tuple the caller stores as two fields. `bool VerifyPassword(string password, byte[] hash, - byte[] salt)` (`:18`) re-derives from the supplied salt and compares. The interface declares no - iteration count, algorithm identifier, or format version: every one of those is the concrete's - business. -- **Why it's built this way**: a two-method port is the [Rubric §1, SOLID] dependency-inversion story - in miniature. Swapping the KDF or raising the iteration count is an Infrastructure change, invisible - to the Register/Login/ChangePassword use cases that only ever see this contract. -- **Where it's used**: constructor-injected into the shared `AuthenticationServiceBase` - (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:37`), which calls - `VerifyPassword` on the login path (`:112`) and `HashPassword` on registration (`:159`), and into the - per-app Identity services that derive from it, for example ADC's `AuthenticationService` - (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:38`) - and its `ChangePasswordHandler` - (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordHandler.cs:19`). - ---- - -### ISoftDeletedUserValidator - -> MMCA.Common.Application · `MMCA.Common.Application.Interfaces.Infrastructure` · `MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ISoftDeletedUserValidator.cs:7` · Level 0 · interface - -- **What it is**: a single-method port that answers "has this account been soft-deleted?", called after - JWT authentication to reject a soft-deleted user who still holds a valid, unexpired token (BR-133, - named in the type comment at - `MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ISoftDeletedUserValidator.cs:4`). -- **Depends on**: BCL plus the solution-wide `UserIdentifierType` alias (`:15`). See - [primer §2](00-primer.md#2-architectural-styles-this-codebase-commits-to) for the alias convention - and [ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html) for soft-delete - versus erasure. The generic implementation is - [`SoftDeletedUserValidator`](group-14-module-system-composition.md#softdeleteduservalidatortuser). -- **Concept introduced, closing the stateless-token window.** [Rubric §11, Security] assesses whether - revocation is timely. A JWT is stateless: once signed it stays valid until `exp`, even if the account - behind it was deleted a minute later. This port lets middleware re-ask the question on every - authenticated request and fail the request when the answer is yes, with no per-handler code. The - comment at `:5` states the second motive: the interface is declared in Application and implemented - against the app's own `User` aggregate precisely so the middleware never takes a cross-module domain - reference. That is the same dependency inversion as the other ports in this group, applied to a - cross-module read. -- **Walkthrough**: `Task IsUserSoftDeletedAsync(UserIdentifierType userId, CancellationToken - cancellationToken = default)` (`:15`). One question, one answer, cancellable. -- **Where it's used**: - [`SoftDeletedUserMiddleware`](group-12-api-hosting-mapping.md#softdeletedusermiddleware) resolves it - lazily from the request scope - (`MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/SoftDeletedUserMiddleware.cs:75` uses - `context.RequestServices.GetService()`, so a host that registers no - implementation simply skips the check; the reason is stated at `:43`). Both apps register the shared - generic against their own user type: - `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:35` and - `MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/DependencyInjection.cs:41`, both - as `TryAddScoped>()`. - ---- - -### ITokenService - -> MMCA.Common.Application · `MMCA.Common.Application.Interfaces.Infrastructure` · `MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ITokenService.cs:8` · Level 0 · interface - -- **What it is**: the token-minting port called by the login and refresh use cases. It builds a signed - JWT access token from explicit identity facts, generates an opaque refresh token, publishes the two - token lifetimes, and recovers the `ClaimsPrincipal` from an expired-but-validly-signed access token. -- **Depends on**: `System.Security.Claims` (BCL) and the `UserIdentifierType` alias. Its Infrastructure - adapter is [`TokenService`](#tokenservice), which signs with the RSA key surfaced by - [`IJwksProvider`](#ijwksprovider). -- **Concept introduced, token creation as an Infrastructure detail.** [Rubric §3, Clean Architecture] - assesses whether library-specific types stay out of the inner layers: the handlers call this contract - and never see `System.IdentityModel.Tokens.Jwt`. `GetPrincipalFromExpiredToken` - (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ITokenService.cs:48`) is - the linchpin of the refresh flow: it validates the signature while deliberately ignoring lifetime, so - an expired access token can still identify the user whose tokens are being rotated, returning `null` - when the token is invalid (`:47`). -- **Walkthrough**: `GenerateAccessToken(UserIdentifierType userId, string email, string role, string - fullName, IEnumerable? additionalClaims = null)` (`:17-22`) takes the minimum claim set as - typed parameters rather than a ready-made principal, with an escape hatch for module-specific claims. - `GenerateRefreshToken()` (`:26`) returns a cryptographically random base64 string. Two **default - interface members** publish the lifetimes: `AccessTokenLifetime` (`:33`, defaulting to 15 minutes) - and `RefreshTokenLifetime` (`:40`, defaulting to 7 days), both documented as the BR-205 baseline. The - comments at `:28-32` and `:35-39` explain the split: the real implementation derives both from the - bound JWT settings, so the expiry reported to a client matches the token's actual `exp`, while the - defaults keep hand-written test doubles on the baseline instead of forcing every double to implement - two more members. `GetPrincipalFromExpiredToken(string token)` (`:48`) closes the set. -- **Why it's built this way**: the explicit-parameter overload is a [Rubric §11, Security] guardrail. - The token's contents are a deliberate list, not whatever claims happened to ride in on an inbound - principal. Surfacing the lifetimes through the same port removes the older duplication where the - caller hard-coded an expiry that could silently drift from the signed `exp`. Note the consumer still - guards: `AuthenticationServiceBase` falls back to the same 15-minute and 7-day baselines when an - implementation reports a non-positive lifetime - (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:61-70`). -- **Where it's used**: the shared `AuthenticationServiceBase` login/refresh/register paths - (`AuthenticationServiceBase.cs:168` and `:214` stamp the refresh-token and access-token expiries from - those lifetimes, and `:298`/`:305` do the same on the refresh path) and, through it, each app's - Identity authentication service, for example - `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:100` - (access token with speaker claims) and `:225` (refresh token). The rotated pair produced here is what - [`CookieSessionRefresher`](#cookiesessionrefresher) later exchanges on the browser's behalf. - ---- - ### SessionCookieRequest - > MMCA.Common.API · `MMCA.Common.API.SessionCookies` · `MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieEndpoints.cs:72` · Level 0 · record - **What it is**: the inbound body for `POST /auth/session-cookie`: the access and refresh token @@ -1164,10 +1148,7 @@ live in later groups; this chapter is the engine those endpoints call into. - **Where it's used**: bound by the `POST` handler at `SessionCookieEndpoints.cs:29`, which passes both strings straight to [`SessionCookieJar`](#sessioncookiejar) (`:31`). ---- - ### SessionTokenResponse - > MMCA.Common.API · `MMCA.Common.API.SessionCookies` · `MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:20` · Level 0 · record - **What it is**: the JSON body returned by `POST /auth/session/token`: the access token and its UTC @@ -1187,10 +1168,7 @@ live in later groups; this chapter is the engine those endpoints call into. - **Where it's used**: constructed and returned by the `/auth/session/token` handler (`MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieEndpoints.cs:56`). ---- - ### SessionTokenResult - > MMCA.Common.API · `MMCA.Common.API.SessionCookies` · `MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:14` · Level 0 · record struct - **What it is**: the internal carrier for a validated access token plus its UTC expiry, returned by @@ -1212,10 +1190,7 @@ live in later groups; this chapter is the engine those endpoints call into. rotation); unwrapped by [`SessionCookieEndpoints`](#sessioncookieendpoints) at `SessionCookieEndpoints.cs:56`. ---- - ### ICookieSessionRefresher - > MMCA.Common.API · `MMCA.Common.API.SessionCookies` · `MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:29` · Level 1 · interface - **What it is**: the "validate-or-refresh over the HttpOnly session cookies" port. One method returns @@ -1244,12 +1219,9 @@ live in later groups; this chapter is the engine those endpoints call into. authentication on navigations) and resolved by the `/auth/session/token` handler (`MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieEndpoints.cs:46`). Registered as a singleton at - `MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:163`. - ---- + `MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:172`. ### CookieSessionRefreshMiddleware - > MMCA.Common.API · `MMCA.Common.API.SessionCookies` · `MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefreshMiddleware.cs:13` · Level 2 · class - **What it is**: an ASP.NET Core middleware that runs before `UseAuthentication` on full-page @@ -1279,15 +1251,17 @@ live in later groups; this chapter is the engine those endpoints call into. ([ADR-022](https://ivanball.github.io/docs/adr/022-browser-session-cookie-auth.html)). - **Where it's used**: registered on both Blazor Server hosts immediately before `UseAuthentication()`, `MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:138` (with `UseAuthentication()` on the very next - statement at `:140`) and `MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:178` (`:180`). + statement at `:140`) and `MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:178` (`:180`). Its + gating rules are pinned one test per branch in + `MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/CookieSessionRefreshMiddlewareTests.cs`: + an HTML navigation refreshes (`:19`), a browser-style multi-value `Accept` list still matches (`:31`), + a non-HTML `Accept` (`:45`), a missing `Accept` (`:60`) and a `POST` (`:74`) all skip, and a `null` + refresh result still calls `next` (`:90`). - **Caveats / not-in-source**: the ordering rule (before `UseAuthentication`) is enforced by the host that calls the extension, not by this class. Getting it wrong silently disables the SSR refresh rather than failing loudly. ---- - ### SessionCookieEndpoints - > MMCA.Common.API · `MMCA.Common.API.SessionCookies` · `MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieEndpoints.cs:15` · Level 2 · class - **What it is**: the minimal-API mapper for the three session-cookie routes: `POST` and `DELETE @@ -1336,10 +1310,7 @@ live in later groups; this chapter is the engine those endpoints call into. cross-site `403` (`:60`), the no-session `401` (`:91`), and the assertion that a valid session returns the access token but never the refresh token (`:104`). ---- - ### SessionCookieJar - > MMCA.Common.API · `MMCA.Common.API.SessionCookies` · `MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieJar.cs:11` · Level 2 · class - **What it is**: the one internal static helper that writes and clears the two HttpOnly auth cookies, @@ -1368,12 +1339,11 @@ live in later groups; this chapter is the engine those endpoints call into. ([ADR-022](https://ivanball.github.io/docs/adr/022-browser-session-cookie-auth.html)). - **Where it's used**: [`SessionCookieEndpoints`](#sessioncookieendpoints) (seed at `SessionCookieEndpoints.cs:31`, clear at `:37`) and - [`CookieSessionRefresher`](#cookiesessionrefresher) (rewrite after rotation, `CookieSessionRefresher.cs:87`). - ---- + [`CookieSessionRefresher`](#cookiesessionrefresher) (rewrite after rotation, + `CookieSessionRefresher.cs:87`). The attributes it emits are asserted directly by + `MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/SessionCookieJarTests.cs`. ### CookieSessionRefreshMiddlewareExtensions - > MMCA.Common.API · `MMCA.Common.API.SessionCookies` · `MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefreshMiddleware.cs:35` · Level 3 · class - **What it is**: a one-method registration helper (`UseCookieSessionRefresh`) that adds @@ -1396,10 +1366,7 @@ live in later groups; this chapter is the engine those endpoints call into. `MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/CookieSessionRefreshMiddlewareTests.cs:115` and `:123`. ---- - ### CookieTokenReader - > MMCA.Common.API · `MMCA.Common.API.SessionCookies` · `MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieTokenReader.cs:10` · Level 3 · class - **What it is**: the read side of the cookie feature. It pulls the access JWT and the refresh token @@ -1432,12 +1399,10 @@ live in later groups; this chapter is the engine those endpoints call into. (`SessionCookieAuthenticationHandler.cs:28`) and into the UI host's server-side token store (`MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Services/ServerTokenStorageService.cs:19`). Registered scoped by `AddServerAuthSessionCookie` - (`MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:157`). - ---- + (`MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:166`), and covered by + `MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/CookieTokenReaderTests.cs`. ### CookieSessionRefresher - > MMCA.Common.API · `MMCA.Common.API.SessionCookies` · `MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:51` · Level 4 · class - **What it is**: the singleton implementation of @@ -1457,15 +1422,16 @@ live in later groups; this chapter is the engine those endpoints call into. - **Concept introduced, single-flight refresh under a thundering herd.** [Rubric §12, Performance & Scalability] assesses behavior under concurrent load. When an access token expires, many queued navigations can arrive at once; rotating for each would burn the refresh token repeatedly and log the - user out. The type comment (`:39-50`) states the design and, notably, why it changed: the lock is a - **striped** [`KeyedSemaphoreStripe`](#keyedsemaphorestripe) keyed by refresh token rather than one - process-wide semaphore, because the lock is held across an outbound HTTP call and a single semaphore - serialized every unrelated user's cold navigation behind whichever refresh happened to be in flight. - Two unrelated tokens can still land on one stripe, which the comment calls out as harmless precisely - because the rotation-grace cache is re-checked per token after acquiring. Alongside the lock, a - 10-second `RotationGrace` (`:60`) caches the rotated pair keyed by the OLD refresh token (`:144`), so - a slightly-late sibling carrying the same expired pair gets the same result instead of rotating - again. + user out. The type comment (`:39-50`) states the design: the lock is a **striped** + [`KeyedSemaphoreStripe`](#keyedsemaphorestripe) keyed by refresh token rather than one process-wide + semaphore, because the lock is held across an outbound HTTP call and a single semaphore would + serialize every unrelated user's cold navigation behind whichever refresh happened to be in flight. + Two unrelated tokens can still land on the same one of the stripe's 256 lanes + (`MMCA.Common/Source/Core/MMCA.Common.Shared/Concurrency/KeyedSemaphoreStripe.cs:25`), which the + comment calls out as harmless precisely because the rotation-grace cache is re-checked per token + after acquiring. Alongside the lock, a 10-second `RotationGrace` (`:60`) caches the rotated pair + keyed by the OLD refresh token (`:144`), so a slightly-late sibling carrying the same expired pair + gets the same result instead of rotating again. - **Walkthrough**: `GetOrRefreshAsync` (`:64-93`) reads the access cookie (`:68`) and, if `TryReadValidExpiry` passes, returns it untouched (`:69-72`). Otherwise it reads the refresh cookie and returns `null` when there is none (`:74-78`). It calls `RefreshAsync` (`:80`), treats a missing or @@ -1489,16 +1455,17 @@ live in later groups; this chapter is the engine those endpoints call into. comment at `:185-188` explains it is `internal` rather than `private` so a concurrency test can pick two refresh tokens that do not collide on a stripe, which is a nice example of a testability affordance that costs nothing at runtime. [Rubric §14, Testability]. -- **Concept, an SSR-safe failure mode.** [Rubric §29, Resilience] and [Rubric §13, Observability] - apply to the outbound call. `CallRefreshAsync` wraps the POST in a `try` whose filter narrows to - `HttpRequestException`, `OperationCanceledException`, `JsonException` and `NotSupportedException` - (`:147`), logs one warning through the source-generated `LogRefreshCallFailed` (`:149`, declared with - `[LoggerMessage]` at `:191-192`, which is why the class is `partial` at `:51`), and returns `null`. - The comment at `:117-122` gives the reasoning: this code runs during SSR, so an escaping exception - would turn a signed-in user's navigation into a `500` instead of an anonymous render. The failure is - deliberately not cached (only a successful rotation reaches `cache.Set` at `:144`), so the next - navigation retries, and a missing `BaseAddress` raises `InvalidOperationException` and is left to - propagate because that is a host misconfiguration rather than a runtime condition. +- **Concept, an SSR-safe failure mode.** [Rubric §29, Resilience & Business Continuity] and [Rubric + §13, Observability & Operability] apply to the outbound call. `CallRefreshAsync` wraps the POST in a + `try` whose filter narrows to `HttpRequestException`, `OperationCanceledException`, `JsonException` + and `NotSupportedException` (`:147`), logs one warning through the source-generated + `LogRefreshCallFailed` (`:149`, declared with `[LoggerMessage]` at `:191-192`, which is why the class + is `partial` at `:51`), and returns `null`. The comment at `:117-122` gives the reasoning: this code + runs during SSR, so an escaping exception would turn a signed-in user's navigation into a `500` + instead of an anonymous render. The failure is deliberately not cached (only a successful rotation + reaches `cache.Set` at `:144`), so the next navigation retries, and a missing `BaseAddress` raises + `InvalidOperationException` and is left to propagate because that is a host misconfiguration rather + than a runtime condition. - **Why it's built this way**: keying the grace cache by the OLD token is what lets a slightly-late sibling find the already-rotated pair, and striping the lock keeps one user's slow refresh from blocking everyone else's cold navigation. The server-to-server call is what keeps the refresh token @@ -1508,14 +1475,14 @@ live in later groups; this chapter is the engine those endpoints call into. [`CookieSessionRefreshMiddleware`](#cookiesessionrefreshmiddleware) and by the `/auth/session/token` endpoint. Its named `HttpClient`, `RefreshClientName = "SessionCookieRefreshClient"` (`:57`), is configured with the API base address in - `AddServerAuthSessionCookie` (`MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:159-160`), - which also registers the refresher as a singleton (`:162-163`) with an inline note that a shared - instance across requests is what makes single-flight work at all. - ---- + `AddServerAuthSessionCookie` + (`MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:168-169`), which also + registers the refresher as a singleton (`:171-172`) with an inline note that a shared instance across + requests is what makes single-flight work at all. The validate, rotate, grace-cache and failure paths + are covered by + `MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/CookieSessionRefresherTests.cs`. ### SessionCookieAuthenticationHandler - > MMCA.Common.API · `MMCA.Common.API.SessionCookies` · `MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieAuthenticationHandler.cs:24` · Level 4 · class - **What it is**: an ASP.NET Core `AuthenticationHandler` that reads the JWT out of the session cookie, @@ -1561,12 +1528,10 @@ live in later groups; this chapter is the engine those endpoints call into. `MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:111-112`. Covered directly by `MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/SessionCookieAuthenticationHandlerTests.cs`, including the fresh-token-from-Items path (`:95`, which stashes the token under - `CookieTokenReader.FreshAccessTokenItemKey` at `:102` and asserts it wins over an expired cookie). - ---- + `CookieTokenReader.FreshAccessTokenItemKey` at `:102` and asserts it wins over an expired cookie) and + the proof that expiry is judged by the handler's `TimeProvider` rather than the system clock (`:112`). ### SessionCookieAuthenticationExtensions - > MMCA.Common.API · `MMCA.Common.API.SessionCookies` · `MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieAuthenticationHandler.cs:90` · Level 5 · class - **What it is**: the registration helper for @@ -1590,60 +1555,189 @@ live in later groups; this chapter is the engine those endpoints call into. `MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:112`, chained onto the host's `AddAuthentication(SessionCookieAuthenticationHandler.SchemeName)` call on the preceding line. ---- +### IAuthUser +> MMCA.Common.Domain · `MMCA.Common.Domain.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:10` · Level 0 · interface -### ICurrentUserService +- **What it is**: the deliberately minimal credential and refresh-token surface an Identity module's `User` aggregate exposes to the shared [AuthenticationServiceBase](#authenticationservicebasetuser) workflow. It is the contract that lets the framework's authentication plumbing read password material and rotate refresh tokens without knowing anything app-specific about the user (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:3-9`). +- **Depends on**: nothing first-party; the BCL only (`byte[]`, `DateTime`). Implemented by each app's `User` aggregate (see [User](group-24-identity-module.md#user)). +- **Concept introduced: the inverted user contract.** Rather than the shared auth workflow depending on a concrete `User` class, `User` implements a small interface the framework owns. Profile fields, roles, linked aggregates, and claim sources stay app-specific: the shared workflow reaches those only through per-app hooks (`CreateAccessToken`, `CreateUser`), never through this contract (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:5-8`). `[Rubric §1, SOLID]` assesses interface segregation and dependency inversion, and this is a textbook case: the interface is exactly the credential surface and nothing more. `[Rubric §11, Security]` assesses credential and session handling, and here the password hash, its salt, and the refresh-token lifecycle are the entire contract, which makes the security-relevant surface of a `User` aggregate readable in one screen. +- **Walkthrough**: read the six members in two groups. + - Password material: `byte[] PasswordHash` (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:14`) and `byte[] PasswordSalt` (`:17`), where the salt length is what selects the verify algorithm (see [PasswordHasher](#passwordhasher)). The scoped `#pragma warning disable CA1819` (`:12`, restored on `:18`) knowingly returns arrays, to mirror [IPasswordHasher](#ipasswordhasher)'s `byte[]` shape and the EF-mapped `varbinary` columns rather than force a defensive copy on every read. + - Refresh-token state: nullable `string? RefreshToken` (`:21`) and `DateTime? RefreshTokenExpiry` (`:24`), both null when the token was never issued or has been revoked. Two mutators carry the rotation and revocation rules: `UpdateRefreshToken(string refreshToken, DateTime expiry)` (`:27`, BR-205) and `RevokeRefreshToken()` (`:30`, BR-206/216). Note that the state is read-only through properties and changed only through the two methods: the aggregate keeps control of the transition. +- **Why it's built this way**: keeping the contract in Domain and keeping it small is what makes the shared auth workflow reusable across Store and ADC (both `User` aggregates implement it) while each aggregate stays free to model everything else its own way. See [ADR-004](https://ivanball.github.io/docs/adr/004-authentication-dual-fetch.html) for the dual-fetch auth model this contract feeds and [ADR-032](https://ivanball.github.io/docs/adr/032-password-hashing.html) for the password-material policy. +- **Where it's used**: it is half the generic constraint on the shared login and refresh workflow, `where TUser : AuditableAggregateRootEntity, IAuthUser` (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:41`), which calls `UpdateRefreshToken` on issue and rotation (`:168`, `:298`) and `RevokeRefreshToken` on reuse detection and revocation (`:261`, `:282`). It is also the base of [IPasswordChangeableUser](#ipasswordchangeableuser). + +### IPasswordHasher +> MMCA.Common.Application · `MMCA.Common.Application.Interfaces.Infrastructure` · `MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IPasswordHasher.cs:6` · Level 0 · interface + +- **What it is**: the password-security port. Two methods: hash a plaintext password into a separated `(byte[] Hash, byte[] Salt)` pair, and verify a plaintext against a stored hash plus salt. +- **Depends on**: nothing first-party, BCL only (`byte[]`). Its Infrastructure adapter is [PasswordHasher](#passwordhasher). +- **Concept introduced: hash and salt kept apart.** `[Rubric §11, Security]` assesses credential handling. Returning the hash and the salt as two distinct `byte[]` members (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IPasswordHasher.cs:11`) rather than one concatenated blob keeps the storage contract explicit: the caller persists two columns, and `VerifyPassword` (`:18`) is unambiguous about what it re-derives and compares. Because the algorithm and its parameters live entirely behind this interface, they can be strengthened without touching a single Application handler ([ADR-032](https://ivanball.github.io/docs/adr/032-password-hashing.html) sets the current hashing policy, applied inside [PasswordHasher](#passwordhasher)). +- **Walkthrough**: `(byte[] Hash, byte[] Salt) HashPassword(string password)` (`:11`) returns a named value tuple the caller stores as two fields. `bool VerifyPassword(string password, byte[] hash, byte[] salt)` (`:18`) re-derives from the supplied salt and compares. The interface declares no iteration count, algorithm identifier, or format version: every one of those is the concrete's business. +- **Why it's built this way**: a two-method port is the `[Rubric §1, SOLID]` dependency-inversion story in miniature. Swapping the key-derivation function or raising the iteration count is an Infrastructure change, invisible to the register, login, and change-password use cases that only ever see this contract. +- **Where it's used**: constructor-injected into [AuthenticationServiceBase](#authenticationservicebasetuser) (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:37`), which calls `VerifyPassword` on the login path (`:112`) and `HashPassword` on registration (`:159`); into the shared [ChangePasswordHandlerBase](group-14-module-system-composition.md#changepasswordhandlerbasetuser-tcommand), which verifies the current password (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:55`) before hashing the new one (`:61`); and into the per-app Identity services and handlers that derive from those, for example ADC's [AuthenticationService](group-24-identity-module.md#authenticationservice) (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:38`) and its `ChangePasswordHandler` (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordHandler.cs:19`). + +### ISoftDeletedUserValidator +> MMCA.Common.Application · `MMCA.Common.Application.Interfaces.Infrastructure` · `MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ISoftDeletedUserValidator.cs:7` · Level 0 · interface + +- **What it is**: a single-method port that answers "has this account been soft-deleted?", called after JWT authentication to reject a soft-deleted user who still holds a valid, unexpired token (BR-133, named in the type comment at `MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ISoftDeletedUserValidator.cs:4`). +- **Depends on**: BCL plus the solution-wide `UserIdentifierType` alias (`:15`). See [primer §2](00-primer.md#2-architectural-styles-this-codebase-commits-to) for the alias convention and [ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html) for soft-delete versus erasure. The generic implementation is [SoftDeletedUserValidator](group-14-module-system-composition.md#softdeleteduservalidatortuser). +- **Concept introduced: closing the stateless-token window.** `[Rubric §11, Security]` assesses whether revocation is timely. A JWT is stateless: once signed it stays valid until `exp`, even if the account behind it was deleted a minute later. This port lets middleware re-ask the question on every authenticated request and fail the request when the answer is yes, with no per-handler code. The comment at `:5` states the second motive: the interface is declared in Application and implemented against the app's own `User` aggregate precisely so the middleware never takes a cross-module domain reference. That is the same dependency inversion as the other ports in this group, applied to a cross-module read. +- **Walkthrough**: one member, `Task IsUserSoftDeletedAsync(UserIdentifierType userId, CancellationToken cancellationToken = default)` (`:15`). One question, one answer, cancellable. +- **Where it's used**: [SoftDeletedUserMiddleware](group-12-api-hosting-mapping.md#softdeletedusermiddleware) resolves it lazily from the request scope (`MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/SoftDeletedUserMiddleware.cs:75` calls `context.RequestServices.GetService()`, so a host that registers no implementation simply skips the check; the reason is stated at `:43-44`). Both apps register the shared generic against their own user type: `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:35` and `MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/DependencyInjection.cs:41`, both as `TryAddScoped>()`. + +### ITokenService +> MMCA.Common.Application · `MMCA.Common.Application.Interfaces.Infrastructure` · `MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ITokenService.cs:8` · Level 0 · interface + +- **What it is**: the token-minting port called by the login and refresh use cases. It builds a signed JWT access token from explicit identity facts, generates an opaque refresh token, publishes the two token lifetimes, and recovers the `ClaimsPrincipal` from an expired-but-validly-signed access token. +- **Depends on**: `System.Security.Claims` (BCL, `:1`) and the `UserIdentifierType` alias. Its Infrastructure adapter is [TokenService](#tokenservice), which signs with the RSA key surfaced by [IJwksProvider](#ijwksprovider). +- **Concept introduced: token creation as an Infrastructure detail.** `[Rubric §3, Clean Architecture]` assesses whether library-specific types stay out of the inner layers: the handlers call this contract and never see `System.IdentityModel.Tokens.Jwt`. `GetPrincipalFromExpiredToken` (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ITokenService.cs:48`) is the linchpin of the refresh flow: it validates the signature while deliberately ignoring lifetime, so an expired access token can still identify the user whose tokens are being rotated, returning `null` when the token is invalid (`:47`). +- **Walkthrough**: `GenerateAccessToken(UserIdentifierType userId, string email, string role, string fullName, IEnumerable? additionalClaims = null)` (`:17-22`) takes the minimum claim set as typed parameters rather than a ready-made principal, with an escape hatch for module-specific claims. `GenerateRefreshToken()` (`:26`) returns a cryptographically random base64 string. Two **default interface members** publish the lifetimes: `AccessTokenLifetime` (`:33`, defaulting to 15 minutes) and `RefreshTokenLifetime` (`:40`, defaulting to 7 days), both documented as the BR-205 baseline. The comments at `:28-32` and `:35-39` explain the split: the real implementation derives both from the bound JWT settings, so the expiry reported to a client matches the token's actual `exp`, while the defaults keep hand-written test doubles on the baseline instead of forcing every double to implement two more members. `GetPrincipalFromExpiredToken(string token)` (`:48`) closes the set. +- **Why it's built this way**: the explicit-parameter overload is a `[Rubric §11, Security]` guardrail. The token's contents are a deliberate list, not whatever claims happened to ride in on an inbound principal. Surfacing the lifetimes through the same port removes the duplication where a caller would hard-code an expiry that could drift from the signed `exp`. Note the consumer still guards: [AuthenticationServiceBase](#authenticationservicebasetuser) falls back to the same 15-minute and 7-day baselines when an implementation reports a non-positive lifetime (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:61-70`). +- **Where it's used**: the shared login, register, and refresh paths (`AuthenticationServiceBase.cs:168` and `:214` stamp the refresh-token and access-token expiries from those lifetimes, `:230` reads the expired principal, and `:297`/`:305` do the same on the rotation path) and, through them, each app's Identity authentication service, for example `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:100` (access token with the `speaker_id` claim) and `:225` (refresh token). The rotated pair produced here is what [CookieSessionRefresher](#cookiesessionrefresher) later exchanges on the browser's behalf. + +### PasswordResetSettings +> MMCA.Common.Application · `MMCA.Common.Application.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Application/Auth/PasswordResetSettings.cs:10` · Level 0 · class (sealed) + +- **What it is**: the bound options object for the forgot-password workflow: where the reset page lives, how long a token stays redeemable, how many wrong guesses a token tolerates, and how often one address may ask for a reset (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/PasswordResetSettings.cs:6-9`). +- **Depends on**: `System.ComponentModel.DataAnnotations` for the range attributes and `System.Diagnostics.CodeAnalysis` for one scoped suppression (BCL, `:1-2`). Nothing first-party. Read by the implementation behind [IPasswordResetTokenService](#ipasswordresettokenservice) and by the shared [ForgotPasswordHandlerBase](group-14-module-system-composition.md#forgotpasswordhandlerbasetuser-tcommand). +- **Concept: validated options whose defaults keep an unconfigured host bootable.** `[Rubric §10, Cross-Cutting Concerns]` assesses whether policy knobs are configuration rather than constants buried in a handler, and `[Rubric §11, Security]` assesses whether the security-relevant knobs (token lifetime, attempt cap, request throttle) are bounded rather than free-form. Every numeric member carries a `[Range]` attribute, and the host binds the section with `ValidateDataAnnotations().ValidateOnStart()` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:139-142`), so a typo such as `TokenLifetimeMinutes: 0` fails the host at startup instead of silently issuing tokens that are already expired. +- **Walkthrough**: `const string SectionName = "PasswordReset"` (`:13`) names the configuration section the host binds. `ResetUrl` (`:25`) defaults to `string.Empty` and is **deliberately not** `[Required]`: the doc comment (`:15-20`) records that a host which has not configured a UI base must still boot, and an empty value degrades to a token-only email the user pastes into the reset page by hand. That degradation is visible in the caller, which emits the bare token when the URL is blank and otherwise appends `?email=...&token=...` (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:145-147`). The property carries a scoped `CA1056` suppression (`:21-24`) explaining why it is a `string` and not a `System.Uri`: it is bound from `PasswordReset__ResetUrl`, concatenated with a query string, and the empty default is not a valid `Uri`. The four numeric knobs follow: `TokenLifetimeMinutes` (`:29`, `[Range(1, 1440)]`, default 30), `MaxValidationAttempts` (`:36`, `[Range(1, 100)]`, default 5), `MaxRequestsPerEmail` (`:40`, `[Range(1, 100)]`, default 3), and `RequestWindowMinutes` (`:44`, `[Range(1, 1440)]`, default 60). All five members are `init`-only, so the bound instance is immutable afterwards. +- **Why it's built this way**: the defaults are a working policy on their own, so adopting the feature costs a registration call and no configuration at all, while the `[Range]` bounds plus `ValidateOnStart` make the one genuinely dangerous class of misconfiguration (a zero or negative lifetime, an unbounded attempt cap) unreachable. The decision to keep the whole reset credential in configuration and cache rather than in schema is [ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html). +- **Where it's used**: bound in the framework's Infrastructure registration (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:139-142`); consumed by [PasswordResetTokenService](#passwordresettokenservice) as a snapshot field (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:32`) for the request window, the throttle ceiling, the token lifetime, and the attempt cap; and exposed to the shared forgot-password handler as a protected `Settings` property (`ForgotPasswordHandlerBase.cs:48`) that states the expiry in the email body (`:125`) and renders the link (`:145-147`). + +### ILoginProtectionService +> MMCA.Common.Application · `MMCA.Common.Application.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Application/Auth/ILoginProtectionService.cs:10` · Level 3 · interface + +- **What it is**: the application-layer contract for **brute-force and rate-limit protection** on authentication endpoints: lockout checks, failed-attempt increments, successful-login resets, and registration rate-limiting per IP address. +- **Depends on**: [Result](group-01-result-error-handling.md#result) from `MMCA.Common.Shared.Abstractions` (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/ILoginProtectionService.cs:1`). +- **Concept introduced: rate limiting as a first-class application concern.** `[Rubric §11, Security]` assesses brute-force protection on auth flows, and `[Rubric §10, Cross-Cutting Concerns]` assesses whether such a policy is extracted to a port so the application layer can reason about it without coupling to a specific store (the doc comment at `:7-8` names both a distributed and an in-memory cache as valid backers). Returning [Result](group-01-result-error-handling.md#result) from `CheckLockoutAsync` (`:18`) and `CheckRegistrationRateLimitAsync` (`:42`) makes "account is locked out" a normal control-flow branch rather than a thrown exception. +- **Walkthrough**: five async methods in two scopes. + - **Email-scoped (failed-login lockout):** `CheckLockoutAsync` (`:18`) returns a failure result when the email is currently locked; `IncrementFailedAttemptsAsync` (`:26`) records a failure and, per the doc comment (`:20-22`), applies **exponential-backoff lockout** once the maximum is exceeded; `ResetFailedAttemptsAsync` (`:33`) clears the counter after a successful login. + - **IP-scoped (registration flood):** `CheckRegistrationRateLimitAsync` (`:42`) and `IncrementRegistrationCountAsync` (`:49`) throttle account creation per client IP. Both accept a nullable `ipAddress` and **skip** the check when it is null, so a host that cannot resolve the caller IP degrades to no limit rather than blocking everyone; `CheckRegistrationRateLimitAsync` returns `Result.Success()` in that case (doc comment, `:36-37`). + + All five take a `CancellationToken` with a `default` argument, per convention. +- **Why it's built this way**: keeping the protection policy behind an interface lets the shared authentication workflow compose it in while the concrete cache mechanics stay in the implementation; the null-IP skip keeps the limiter from becoming an availability hazard ([ADR-029](https://ivanball.github.io/docs/adr/029-authentication-brute-force-protection.html)). +- **Where it's used**: injected into [AuthenticationServiceBase](#authenticationservicebasetuser) (constructor parameter at `MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:38`), which calls all five across its login and registration flows (`:84`, `:99`, `:114`, `:128`, `:146`, `:207`). The concrete, cache-backed [LoginProtectionService](#loginprotectionservice) (tuned by [LoginProtectionSettings](#loginprotectionsettings)) implements it, and the framework registers that pairing at `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:137`. + +### IPasswordChangeableUser +> MMCA.Common.Domain · `MMCA.Common.Domain.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IPasswordChangeableUser.cs:11` · Level 3 · interface + +- **What it is**: the password-rotation surface an Identity module's `User` aggregate exposes to the shared [ChangePasswordHandlerBase](group-14-module-system-composition.md#changepasswordhandlerbasetuser-tcommand) workflow. It is one method on top of [IAuthUser](#iauthuser) (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IPasswordChangeableUser.cs:5-10`). +- **Depends on**: [IAuthUser](#iauthuser) (its base interface, `:11`) and [Result](group-01-result-error-handling.md#result) from `MMCA.Common.Shared.Abstractions` (`:1`). +- **Concept: capability interfaces layered by workflow.** `[Rubric §1, SOLID]` assesses interface segregation, and this is the pattern applied twice over: a `User` that only ever authenticates implements [IAuthUser](#iauthuser); a `User` whose app offers self-service password change implements this one and gets `PasswordHash` and `PasswordSalt` along with it, because the workflow must verify the current credential before writing the new one (the XML comment states exactly this reason, `:8-9`). Inheritance here encodes a real dependency between capabilities rather than a taxonomy. `[Rubric §4, DDD]` also applies: the method returns [Result](group-01-result-error-handling.md#result), so the aggregate can refuse the change (an invariant failure) instead of the handler assuming success. +- **Walkthrough**: one member, `Result ChangePassword(byte[] newPasswordHash, byte[] newPasswordSalt)` (`:19`). The aggregate receives already-hashed material, never a plaintext password: hashing is the handler's job via [IPasswordHasher](#ipasswordhasher), so no plaintext ever reaches the Domain layer or an EF change tracker. +- **Why it's built this way**: keeping the hash-and-salt pair as the parameter shape mirrors [IAuthUser](#iauthuser)'s two properties and [IPasswordHasher](#ipasswordhasher)'s tuple return, so the whole chain from handler to aggregate speaks one vocabulary. See [ADR-032](https://ivanball.github.io/docs/adr/032-password-hashing.html). +- **Where it's used**: as the generic constraint `where TUser : AuditableAggregateRootEntity, IPasswordChangeableUser` on the shared change-password workflow (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:28`), which verifies the current password (`:55`), hashes the new one (`:61`), and calls `ChangePassword` with the result (`:62`). The forgot-password sibling workflow, [ResetPasswordHandlerBase](group-14-module-system-composition.md#resetpasswordhandlerbasetuser-tcommand), writes the new material on the redeem path after [IPasswordResetTokenService](#ipasswordresettokenservice) has identified the account. + +### IPasswordResetTokenService +> MMCA.Common.Application · `MMCA.Common.Application.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IPasswordResetTokenService.cs:10` · Level 3 · interface + +- **What it is**: the two-method port behind the forgot-password workflow: issue a single-use reset token for an email address, and validate-then-consume a token presented back by the user. Implementations keep the token material outside the database, hashed at rest, and enforce both the per-email request throttle and the per-token validation-attempt cap (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IPasswordResetTokenService.cs:5-9`). +- **Depends on**: [Result](group-01-result-error-handling.md#result) and its generic form from `MMCA.Common.Shared.Abstractions` (`:1`), plus the `UserIdentifierType` alias. Its Infrastructure adapter is [PasswordResetTokenService](#passwordresettokenservice), backed by [ICacheService](group-09-caching.md#icacheservice) and tuned by [PasswordResetSettings](#passwordresetsettings). +- **Concept introduced: a single-use credential without a schema change.** `[Rubric §11, Security]` assesses how a secondary credential is minted, stored, and retired; `[Rubric §8, Data Architecture]` assesses whether short-lived state earns a place in the durable store. A reset token is not durable data: it is valid for minutes and must stop working the instant it is redeemed. Putting it in columns on the user row costs a migration in every consumer and needs a sweeper to reap expired rows, because expiry is not something a table enforces; a self-contained signed payload needs no store but then cannot be single-use, since a signed token that has not expired stays valid however many times it is presented. This port takes the third path and hides the choice: the handlers see two `Result`-returning methods, and the cache substrate is entirely the implementation's business ([ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html)). + + The second teaching point is in the return shapes. `ValidateAndConsumeAsync` is documented to collapse **unknown, expired, mismatched, and attempt-capped** into one generic failure (`:32-35`), so the redeem endpoint cannot be used to distinguish a wrong token from an expired one from an address that was never issued a token. The issue path is throttled rather than refused loudly, for the same anti-enumeration reason the forgot-password handler answers success to every input. +- **Walkthrough**: two members. + - `Task> IssueAsync(string email, UserIdentifierType userId, CancellationToken cancellationToken = default)` (`:23`) returns the **raw** token to email, or a failure when the per-email request throttle has been exceeded. The doc comment (`:12-15`) states the replace semantics: issuing overwrites any token already outstanding for that address, so requesting a new link immediately stops the older one from working. The `userId` parameter is what the token resolves back to at redeem time, which is why the redeem call never has to trust an identifier supplied by the caller. + - `Task> ValidateAndConsumeAsync(string email, string token, CancellationToken cancellationToken = default)` (`:36`) validates the presented token against the outstanding record and **consumes it on success**, so a token never redeems twice (`:25-28`), returning the account the token belongs to. +- **Why it's built this way**: taking `email` on both methods, rather than treating the token as self-describing, is what lets the implementation key its records by address and enforce the per-address throttle and the one-active-token rule at the same key. Returning `Result` rather than a boolean means the redeem handler gets the account identity from the token store itself. See [PasswordResetTokenService](#passwordresettokenservice) for the mechanics the port hides: a 32-byte random token, only its SHA-256 stored, a fixed-time comparison, an attempt counter rewritten with the **remaining** lifetime so a wrong guess cannot extend the window, and removal of both the token record and the request counter on success. +- **Where it's used**: injected into the shared [ForgotPasswordHandlerBase](group-14-module-system-composition.md#forgotpasswordhandlerbasetuser-tcommand) (constructor parameter at `MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:37`, called at `:72`, where a throttled issue is logged and still answered as success) and into [ResetPasswordHandlerBase](group-14-module-system-composition.md#resetpasswordhandlerbasetuser-tcommand) (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:33`, redeemed at `:62`). Both apps' sealed subclasses take the same dependency, for example ADC's [ForgotPasswordHandler](group-24-identity-module.md#forgotpasswordhandler) (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandler.cs:22`) and [ResetPasswordHandler](group-24-identity-module.md#resetpasswordhandler) (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordHandler.cs:21`). The framework registers the concrete as scoped at `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:143`. + +### IUserPreferences +> MMCA.Common.Domain · `MMCA.Common.Domain.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IUserPreferences.cs:10` · Level 3 · interface + +- **What it is**: the stored UI-preference surface an Identity module's `User` aggregate exposes to the shared preference read and write workflows: preferred culture, preferred theme, and a single method that replaces both (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IUserPreferences.cs:5-9`). +- **Depends on**: [Result](group-01-result-error-handling.md#result) from `MMCA.Common.Shared.Abstractions` (`:1`). Nothing else; it is deliberately not tied to [IAuthUser](#iauthuser), because preferences are orthogonal to credentials. +- **Concept: null as "not chosen".** `[Rubric §27, i18n]` assesses whether locale is a first-class, persisted user choice rather than a per-session guess, and `[Rubric §19, State Management]` assesses where such UI state lives. Both properties are nullable, and the contract states that `null` means the user has not chosen that preference (`:7-8`), which is what lets the UI fall back to a browser or host default without needing a separate "is set" flag. See [ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html) for the culture model and [ADR-028](https://ivanball.github.io/docs/adr/028-dark-theme-mode.html) for the theme model. +- **Walkthrough**: `string? PreferredCulture` (for example `"es"`, `:13`) and `string? PreferredTheme` (`"light"` or `"dark"`, `:16`) are read-only. `Result UpdatePreferences(string? preferredCulture, string? preferredTheme)` (`:25`) replaces **both** at once. The subtlety is documented at `:18-21`: because the method is a whole-object replace, the shared workflow always passes the currently stored value for any field the request left `null`, so writing one preference never silently clears the other. That read-then-merge is visible in the caller (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:53`). +- **Why it's built this way**: one replace method keeps the aggregate's invariant check in a single place, and pushing the merge into the workflow keeps the null-means-unchanged policy out of every app's `User`. Returning [Result](group-01-result-error-handling.md#result) lets the aggregate reject an unsupported culture or theme value. +- **Where it's used**: the read workflow constrains `where TUser : AuditableBaseEntity, IUserPreferences` and projects both properties into a response (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandlerBase.cs:23`, `:44`); the write workflow constrains `where TUser : AuditableAggregateRootEntity, IUserPreferences` (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:26`). Both are cross-linked as [GetUserPreferencesHandlerBase](group-14-module-system-composition.md#getuserpreferenceshandlerbasetuser) and [ChangePreferencesHandlerBase](group-14-module-system-composition.md#changepreferenceshandlerbasetuser-tcommand). + +### IErasableUser +> MMCA.Common.Domain · `MMCA.Common.Domain.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IErasableUser.cs:30` · Level 4 · interface + +- **What it is**: the erasure surface an Identity module's `User` aggregate exposes to the shared [DeleteUserHandlerBase](group-14-module-system-composition.md#deleteuserhandlerbasetuser-tcommand) workflow: soft-delete the row, then irreversibly anonymize the personal data it still holds (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IErasableUser.cs:6-10`). +- **Depends on**: [IAnonymizable](group-02-domain-building-blocks.md#ianonymizable) (its base, contributing `Result Anonymize()`, `:1` and `:30`) and [Result](group-01-result-error-handling.md#result) (`:2`). +- **Concept introduced: why a `Delete()` that already exists on the base entity is redeclared here.** This is the most instructive comment in the file and it is worth reading in full (`:11-29`). [AuditableBaseEntity](group-02-domain-building-blocks.md#auditablebaseentitytidentifiertype) already has a `Delete()`. But an app's `User` may **hide** it (`public new Result Delete()`) to couple account-specific behavior to deletion, typically revoking the refresh token so outstanding sessions die immediately. A hidden method is not an override. C# member lookup on a generic type parameter prefers the members of its **class** constraint, so a shared workflow writing `user.Delete()` would bind to the base implementation and silently skip the app's version. Redeclaring `Delete()` on this interface and invoking it **through the interface** forces interface dispatch, which resolves to the most derived member the app type maps onto `IErasableUser`. `[Rubric §1, SOLID]` (Liskov: the hidden method is exactly the substitutability hazard this closes) and `[Rubric §15, Best Practices & Code Quality]` both apply, and this is a case where the language rule, not a style preference, dictates the design. The second paragraph (`:25-28`) adds the compile-time guarantee: the base entity deliberately does **not** implement this interface, so a consumer that forgets to declare it fails the generic constraint at compile time rather than losing behavior at run time. +- **Walkthrough**: one declared member, `Result Delete()` (`:37`), documented as soft-delete plus whatever the app couples to deletion (`:32-35`), returning a failure when the account is already deleted (`:36`). Inherited from [IAnonymizable](group-02-domain-building-blocks.md#ianonymizable) is `Result Anonymize()`, which must be idempotent. The two-step order is visible in the caller: cast once to the interface (`IErasableUser erasable = user;`, `MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:88`, with the reason spelled out at `:83-87`), `erasable.Delete()` first (`:89`), the app's own tail hook next (`OnAfterSoftDeleteAsync`, `:95`), then `erasable.Anonymize()` (`:103`), each short-circuiting on failure. +- **Why it's built this way**: soft-delete alone hides a row but retains its personal data, so it does not satisfy an erasure request; anonymize-in-place overwrites the personal fields while keeping the row so foreign keys and the audit trail survive ([ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html)). Splitting the two into separate members lets the workflow run app-specific work between them. `[Rubric §30, Compliance, Privacy & Data Governance]` assesses exactly this: an erasure path that does not destroy referential integrity. +- **Where it's used**: the generic constraint `where TUser : AuditableAggregateRootEntity, IErasableUser` on the shared delete-user workflow (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:41`), implemented by each app's [User](group-24-identity-module.md#user) aggregate. + +### SoftDeletedUserCache +> MMCA.Common.Application · `MMCA.Common.Application.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Application/Auth/SoftDeletedUserCache.cs:17` · Level 4 · class (static) + +- **What it is**: the shared cache contract for the **soft-deleted user marker** (BR-133): the key shape, the marker lifetime, and a one-call helper that writes it. The API middleware reads the marker on every authenticated request; the module that soft-deletes a user writes it (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/SoftDeletedUserCache.cs:6-10`). +- **Depends on**: [ICacheService](group-09-caching.md#icacheservice) (`:2`), the `UserIdentifierType` alias, and `System.Globalization.CultureInfo` (BCL, `:1`). +- **Concept introduced: revoking a stateless credential without a per-request lookup.** `[Rubric §11, Security]` assesses whether a revoked principal actually loses access, and `[Rubric §10, Cross-Cutting Concerns]` assesses whether such a concern is factored so both ends share one definition. A JWT is a bearer credential: signature validation never asks "is this account still active?", so soft-deleting a user leaves an already-issued access token passing validation until it expires ([ADR-047](https://ivanball.github.io/docs/adr/047-soft-deleted-user-session-revocation.html)). The textbook fixes (a deny-list, or an account-status query on every request) reintroduce exactly the per-request state that stateless JWT was chosen to avoid. This type is the middle path: a short-lived cache marker written at deletion time and read cheaply on the hot path. + + The `remarks` (`:11-16`) explain why the constants live in the **Application** layer rather than next to the middleware that reads them: a downstream application deleting an account has to write the exact same key the middleware reads, and a private constant in the presentation layer is unreachable from an application-layer command handler. Same reasoning as [IdempotencyHeaders](#idempotencyheaders), applied one layer up. +- **Walkthrough**: three static members, no state. + - `MarkerDuration => TimeSpan.FromSeconds(30)` (`:29`). The remarks (`:22-28`) justify the number rather than leaving it magic: the marker only has to cover the window between the delete committing and the next token validation, because once it expires the validator query is the source of truth again and gives the same answer. Short-lived access tokens (15 minutes, the BR-205 default on [ITokenService](#itokenservice)) bound the rest of the exposure, so a longer marker would buy nothing and would keep stale entries alive for users who were never deleted. + - `KeyFor(UserIdentifierType userId)` (`:42-43`) builds `user:deleted:{userId}` through `string.Create(CultureInfo.InvariantCulture, ...)`. The remarks (`:36-41`) name the bug this prevents: an identifier renders differently under some cultures (digit shapes, group separators), so a culture-sensitive key would be written under one request's culture and missed under another, silently letting a deleted user keep making requests. This is a case where the analyzer rule about culture-invariant formatting is guarding a security property, not just a formatting nicety. + - `MarkDeletedAsync(ICacheService cache, UserIdentifierType userId, CancellationToken cancellationToken = default)` (`:53-61`) null-guards the cache (`:58`) and writes `true` under `KeyFor(userId)` for `MarkerDuration` (`:60`). It returns the task without awaiting, so there is no extra async state machine for a one-call passthrough. +- **Why it's built this way**: publishing the key shape and the TTL as framework API is what keeps the writer and the reader honest, and it is a precondition for the module boundary in [ADR-047](https://ivanball.github.io/docs/adr/047-soft-deleted-user-session-revocation.html): Identity owns the delete, every service hosts the middleware, and the only thing they share is a cache entry rather than a database. `[Rubric §7, Microservices Readiness]` applies directly: an extracted service can enforce the revocation without a reference to the Identity database. +- **Where it's used**: read by [SoftDeletedUserMiddleware](group-12-api-hosting-mapping.md#softdeletedusermiddleware), which builds the key (`MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/SoftDeletedUserMiddleware.cs:85`), short-circuits with 401 when the marker is `true` (`:104`), and on a miss falls back to the validator query and caches **that** answer, deleted or not, for the same `MarkerDuration` (`:132`). Written by the Identity delete path: ADC's [DeleteUserHandler](group-24-identity-module.md#deleteuserhandler) queues it as an after-commit action (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs:68-73`, inside the `OnAfterSoftDeleteAsync` override at `:46`) and swallows a cache fault so a failed marker cannot turn a successful erasure into an error the caller would retry. +- **Caveats / not-in-source**: the marker is best effort on both ends by design. The middleware fails **open** on a cache outage, falling through to the validator query and proceeding if that is also unavailable, and the writer logs and continues on a cache fault. The exposure that leaves is bounded by the access-token lifetime, which is the trade-off ADR-047 accepts explicitly. ADC's handler is the only writer in the source tree today; MMCA.Store soft-deletes users without writing the marker, so there the middleware's own validator-query fallback is what enforces BR-133. + +### AuthenticationValidators +> MMCA.Common.Application · `MMCA.Common.Application.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationValidators.cs:16` · Level 5 · class (sealed) + +- **What it is**: a tiny **parameter object** that bundles the three FluentValidation validators the authentication workflow needs (login, registration, refresh) into one injectable dependency. +- **Depends on**: FluentValidation's `IValidator` (NuGet, `:1`) over the request DTOs [LoginRequest](#loginrequest), [RegisterRequest](#registerrequest), and [RefreshTokenRequest](#refreshtokenrequest) (all in `MMCA.Common.Shared.Auth`, `:2`). +- **Concept introduced: the parameter object as a constructor-arity guardrail.** `[Rubric §1, SOLID]` assesses whether a class stays a single, cohesive responsibility rather than sprawling into a god class, and `[Rubric §16, Maintainability & Evolvability]` assesses whether cross-cutting dependencies are grouped so a class can grow without exploding its constructor. The doc comment (`:6-12`) states the exact motive: collapsing three closely-related dependencies into one keeps the app's `AuthenticationService` **below the application-service constructor-arity ceiling** (a god-class analyzer guardrail) without giving up per-request validation. Because the request DTOs already live in `MMCA.Common.Shared.Auth`, the bundle is app-agnostic, which is why it could be hoisted out of the apps into the framework. +- **Walkthrough**: a primary constructor takes the three `IValidator` instances (`:16-19`), and three get-only properties surface them by name: `Login` (`:22`), `Register` (`:25`), and `Refresh` (`:28`), each assigned from its matching constructor parameter. There is no logic here; the type exists purely to shrink the dependency footprint of its consumer. +- **Why it's built this way**: a `sealed` grouping type with get-only properties is the cheapest way to fold three cohesive dependencies into one constructor slot, so the workflow base can validate each request shape without pushing its constructor over the arity limit; DI resolves the three underlying validators and composes them into this one object. Two of the three ([LoginRequestValidator](#loginrequestvalidator), [RefreshTokenRequestValidator](#refreshtokenrequestvalidator)) come from the framework assembly, while `IValidator` is satisfied by the app's own `RegisterRequestValidator`, so the bundle is the point where framework and app validation meet. +- **Where it's used**: injected into [AuthenticationServiceBase](#authenticationservicebasetuser) (constructor parameter at `MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:40`), whose `LoginAsync`, `RegisterAsync`, and `RefreshTokenAsync` call `validators.Login` (`:77`), `validators.Register` (`:139`), and `validators.Refresh` (`:222`) respectively before doing any work. + +### IAuthenticationService +> MMCA.Common.Application · `MMCA.Common.Application.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IAuthenticationService.cs:11` · Level 5 · interface + +- **What it is**: the application-layer contract for the Identity module's authentication workflows: login, registration, token refresh, token revocation, and external (OAuth) login. +- **Depends on**: [LoginRequest](#loginrequest), [RefreshTokenRequest](#refreshtokenrequest), [RegisterRequest](#registerrequest), [AuthenticationResponse](#authenticationresponse), [Result](group-01-result-error-handling.md#result), [Error](group-01-result-error-handling.md#error), and the `UserIdentifierType` alias (`:1-2`). +- **Concept introduced: default interface methods for optional capabilities.** `[Rubric §1, SOLID]` (interface segregation and dependency inversion): `ExternalLoginAsync` (`:66-74`) ships a **default implementation** in the interface itself that returns a not-supported [Error](group-01-result-error-handling.md#error) (`"Auth.ExternalLoginNotSupported"`, `:74`). An implementation that does not offer OAuth (a stub host, or a deployment with social login disabled) inherits that failure for free and need not override anything, so the interface stays one piece while the capability is opt-in ([ADR-036](https://ivanball.github.io/docs/adr/036-external-oauth-login.html)). `[Rubric §11, Security]`: login, registration, and refresh all return `Result`, so auth outcomes flow as values and no exception leaks credential detail to the caller. +- **Walkthrough**: five methods, all async, all taking a `CancellationToken`. + - `LoginAsync(LoginRequest)` returns `Result` (`:19`). + - `RegisterAsync(RegisterRequest, string? ipAddress = null)` (`:30`); the optional `ipAddress` feeds [ILoginProtectionService](#iloginprotectionservice)'s registration rate limit. + - `RefreshTokenAsync(RefreshTokenRequest)` (`:41`) rotates the token pair. + - `RevokeTokenAsync(UserIdentifierType userId)` returns `Result` (`:51`) and revokes a user's refresh token, returning a not-found error when there is none. + - `ExternalLoginAsync(loginProvider, providerKey, email, firstName, lastName)` (`:66`), the default-implemented OAuth path; finds an account by provider and key or creates one from claims. + The doc comment (`:6-9`) also records a scope decision: **password change is not on this interface**. It is dispatched directly through its own command handler at the controller layer. +- **Why it's built this way**: concentrating the token-issuing workflows behind one port keeps the Identity controllers thin and lets the protection and rate-limit policy ([ILoginProtectionService](#iloginprotectionservice)) compose in; the default OAuth method keeps the contract stable across hosts that do and do not enable social login. +- **Where it's used**: implemented by [AuthenticationServiceBase](#authenticationservicebasetuser) (which realises every member except the default `ExternalLoginAsync`) and, through it, by each app's sealed [AuthenticationService](group-24-identity-module.md#authenticationservice); consumed by the Identity API controllers. + +### AuthenticationServiceBase +> MMCA.Common.Application · `MMCA.Common.Application.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:34` · Level 8 · class (abstract) + +- **What it is**: the **shared authentication workflow** (login, registration, token refresh and rotation, revocation) hoisted once into the framework, generic over the app's `User` aggregate. It realises [IAuthenticationService](#iauthenticationservice) and leaves the genuinely app-specific decisions to a small set of `abstract` and `virtual` hooks a sealed subclass overrides. +- **Depends on**: [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) and [IRepository](group-07-persistence-ef-core.md#irepositorytentity-tidentifiertype) (persistence, G07), [ITokenService](#itokenservice), [IPasswordHasher](#ipasswordhasher), [ILoginProtectionService](#iloginprotectionservice), [AuthenticationValidators](#authenticationvalidators) (this group), the [IAuthUser](#iauthuser) credential contract plus [AuditableAggregateRootEntity](group-02-domain-building-blocks.md#auditableaggregaterootentitytidentifiertype) as the `TUser` constraint (`:41`), [Email](group-02-domain-building-blocks.md#email) (normalizing the login and register address), [Result](group-01-result-error-handling.md#result) and [Error](group-01-result-error-handling.md#error), the request and response DTOs ([LoginRequest](#loginrequest), [RegisterRequest](#registerrequest), [RefreshTokenRequest](#refreshtokenrequest), [AuthenticationResponse](#authenticationresponse)), and the BCL `TimeProvider` (injected at `:39`, never `DateTime.UtcNow`, so the clock is testable). +- **Concept introduced: the Template Method that de-duplicates a whole vertical slice.** `[Rubric §2, Design Patterns]` assesses idiomatic pattern use: this is a textbook **Template Method**, the invariant sequence of an operation living in the base while the variable steps are deferred to subclass hooks. `[Rubric §16, Maintainability & Evolvability]` (DRY across services) and `[Rubric §1, SOLID]` also apply: the doc comment (`:11-32`) records that the app Identity modules previously duplicated this workflow at roughly 70 to 95 percent line-identity, so folding it here means a fix to the lockout order or the rotation logic is written once. `[Rubric §11, Security]`: the base encodes the security posture directly, validate first, an [ILoginProtectionService](#iloginprotectionservice) lockout and rate-limit gate ([ADR-029](https://ivanball.github.io/docs/adr/029-authentication-brute-force-protection.html)), an untracked-then-tracked dual fetch ([ADR-004](https://ivanball.github.io/docs/adr/004-authentication-dual-fetch.html)), and refresh-token rotation with **reuse detection** ([ADR-050](https://ivanball.github.io/docs/adr/050-jwt-refresh-token-rotation.html), BR-205/206). `[Rubric §7, Microservices Readiness]`: the workflow depends only on ports, so it runs unchanged whether the Identity module is in-monolith or its own service. +- **Walkthrough** (members in teaching order): + - **Constructor and protected accessors** (`:34-54`): a primary constructor takes the six collaborators; protected read-only properties re-expose `UnitOfWork` (`:44`), `TokenService` (`:47`), `TimeProvider` (`:50`) and a `Repository` (`:53-54`) resolved lazily as `unitOfWork.GetRepository()`, so subclass hooks and app-level flows (external login) reuse them without re-injecting. + - **Token lifetimes** (`:61-70`): `virtual` `AccessTokenLifetime` and `RefreshTokenLifetime` read through to [ITokenService](#itokenservice) (which derives them from `Jwt:AccessTokenExpirationMinutes` and `Jwt:RefreshTokenExpirationDays`), so the expiry reported to the client matches the JWT's actual `exp`. A non-positive value, meaning a hand-written test double or a misconfigured host, falls back to the BR-205 defaults of 15 minutes and 7 days (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ITokenService.cs:33` and `:40` carry the same defaults on the port). + - **`LoginAsync`** (`:73-131`): validate the request (`:77-81`), check lockout (`:84`, ADR-029 and BR-212), normalize the raw email into an [Email](group-02-domain-building-blocks.md#email) value object (`:92`) so the EF predicate compares same-typed converted values (an invalid address yields a null value object that simply matches no user, which is the invalid-credentials answer anyway). **Step 1** is an *untracked* fetch via the `FindUntrackedByEmailAsync` hook (`:96`) to verify credentials without change-tracker overhead; a null result increments failed attempts and returns a generic 401 (`:97-102`). An app gate runs before password verification (`:106`, with no failed-attempt increment so the pre-hoist behavior is preserved), then `passwordHasher.VerifyPassword` (`:112`). **Step 2** is a *tracked* re-fetch by id (`:120`) so the new refresh token can be persisted, followed by `ResetFailedAttemptsAsync` (`:128`) and `IssueTokensAsync` (`:130`). + - **`RegisterAsync`** (`:134-215`): validate (`:139-143`), IP rate-limit (`:146`, ADR-029 and BR-213), reject a duplicate email through the `EmailExistsAsync` hook (`:153-157`), hash the password (`:159`), build the user through the `CreateUser` hook (`:160`), mint and store a refresh token (`:167-168`), `AddAsync` (`:170`) and `SaveChangesAsync` (`:174`), then run the `OnUserRegisteredAsync` post-commit hook (`:204`) to pick up the instance the first access token is minted from, increment the IP registration count (`:207`), and return the token pair (`:211-214`). + + The save is wrapped in a deliberately **broad** `catch (Exception)` (`:172-200`, with a scoped `CA1031` suppression at `:176-178`) whose comment is the teaching material. The email lookup above is a check-then-act: two concurrent registrations for the same address both pass it, and the loser only fails on the insert, against the unique index every consumer puts on `Email` (ADC unfiltered, Store filtered on `IsDeleted`). Without the catch, that race surfaces as a generic 500 instead of the 409 a serialized pair would have produced. The catch cannot name `DbUpdateException`, because Application has no EF Core dependency by layer rule, so the **re-check is what narrows it** (`:194`): if the address exists now, the concurrent registration is the cause and the caller gets the same conflict the serial path returns through the shared `EmailAlreadyExistsFailure()` helper; anything else rethrows untouched (`:199`) and still reaches the exception middleware. The re-check passes `CancellationToken.None` on purpose (`:192-194`): it has to run even when the caller's token is what aborted the save, or a cancelled save could never be classified. + - **`RefreshTokenAsync`** (`:218-269`): validate (`:222-226`), pull claims from the *expired* JWT via `tokenService.GetPrincipalFromExpiredToken` (`:230`, signature still checked, only lifetime skipped), read the `user_id` claim (`:237-242`), load the tracked user (`:244`), run the refresh app gate (`:251`), then the security-critical check (`:259`): if the stored `RefreshToken` does not match or has expired, this is treated as **token reuse (potential theft)**, so `user.RevokeRefreshToken()` is called and saved (`:261-262`, BR-206) before returning a 401. A clean match issues a rotated pair through `IssueTokensAsync` (`:268`). + - **`RevokeTokenAsync`** (`:272-286`): load by id, `RevokeRefreshToken()`, save; a missing user yields `Error.NotFound` targeted at `typeof(TUser).Name` (`:279`). + - **`IssueTokensAsync`** (`:292-306`): the shared rotation used by login and refresh, and reusable by an app-level external-login flow. It mints an access token via the `CreateAccessToken` hook (`:296`), generates a new refresh token (`:297`), stamps its expiry off `TimeProvider` (`:298`), saves (`:300`), and returns the response (`:302-305`). + - **The hooks**: four are `abstract`, so a subclass must supply them. `FindUntrackedByEmailAsync` (`:313`) and `EmailExistsAsync` (`:319`) are deliberately written against the app's concrete `User` so EF translates the predicate byte-for-byte as before, and the second explicitly leaves the app to decide whether soft-deleted accounts count (`ignoreQueryFilters: true` blocks re-registration of an erased address, `:315-318`); `CreateUser` (`:322`) runs the app's domain factory; `CreateAccessToken` (`:325`) mints the app's claim set (for example `speaker_id` versus `customer_id`). Four `virtual` hooks default to a no-op: `ValidateLoginCandidateAsync` (`:328`) and `ValidateRefreshCandidateAsync` (`:332`) add extra gates such as a deactivated-account check; `OnUserRegisteredAsync` (`:339`) runs the post-commit side-effect (publish an integration event, or re-fetch a linked id); and `CreateRefreshUserMissingError` (`:347`) defaults the vanished-user case to 401 (a token for a missing user is indistinguishable from an invalid one) while letting an app return 404 where its public contract already promises it. One `private static` helper, `EmailAlreadyExistsFailure()` (`:355-357`), returns the `Auth.EmailAlreadyExists` conflict so the up-front check and the race recovery are indistinguishable to the caller. +- **Why it's built this way**: the untracked-then-tracked dual fetch keeps the common credential-verification path off the change tracker (cheaper, and soft-deleted accounts fall out via EF query filters returning the generic 401) while still giving a tracked instance to persist the new token ([ADR-004](https://ivanball.github.io/docs/adr/004-authentication-dual-fetch.html)). Refresh-token reuse detection (revoke on mismatch) is the BR-206 defence against a stolen token being replayed ([ADR-050](https://ivanball.github.io/docs/adr/050-jwt-refresh-token-rotation.html)). Password material flows through [IAuthUser](#iauthuser)'s `PasswordHash` and `PasswordSalt` ([ADR-032](https://ivanball.github.io/docs/adr/032-password-hashing.html)), and the whole workflow depends only on abstractions, so it is identical whether the module runs in-process or as an extracted service. +- **Where it's used**: subclassed by each app's sealed [AuthenticationService](group-24-identity-module.md#authenticationservice), for example `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:35`, which binds `TUser = User`, adds the Attendee default role (BR-45) and the `speaker_id` claim (BR-209, built at `:249-252`), and re-lists `IAuthenticationService` so it can re-implement `RegisterAsync` and `ExternalLoginAsync` outright: ADC raises its registration side-effects inside one transactional unit rather than through the `OnUserRegisteredAsync` hook, because the identity column means the id does not exist until the first save (`AuthenticationService.cs:16-32`). MMCA.Store supplies its own subclass with a `customer_id` claim. Consumed by the Identity API controllers via the [IAuthenticationService](#iauthenticationservice) port. +- **Caveats / not-in-source**: the `user_id` claim is parsed with `int.TryParse` (`:238`), so the refresh flow assumes `UserIdentifierType` is `int` (the framework alias today, per [ADR-048](https://ivanball.github.io/docs/adr/048-primitive-identifier-type-aliases.html)); an app that redefined the alias would need to override the refresh handling. `ExternalLoginAsync` is intentionally **not** overridden here: the base inherits the interface's default not-supported failure, and OAuth account linking stays in the app subclass because it is coupled to the app's `User` factory surface (doc comment, `:30-31`). + +### ICurrentUserService > MMCA.Common.Application · `MMCA.Common.Application.Interfaces.Infrastructure` · `MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ICurrentUserService.cs:9` · Level 8 · interface -- **What it is**: the Application layer's read-only window onto the authenticated caller: the raw - `ClaimsPrincipal`, a strongly-typed `UserId`, the caller's first role, the full role set, a generic - typed-claim reader, and a role-membership helper. It answers "who is calling?" without any handler - ever touching `HttpContext`. -- **Depends on**: `System.Security.Claims` and `IParsable` (BCL) plus the solution-wide - `UserIdentifierType` alias - (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ICurrentUserService.cs:15`); - see [primer §2](00-primer.md#2-architectural-styles-this-codebase-commits-to). It references - [`RoleNames`](#rolenames) in documentation only (`:80`). Its adapter is - [`CurrentUserService`](#currentuserservice) in Infrastructure. -- **Concept introduced, the caller-identity port with behavior on the interface.** [Rubric §3, Clean - Architecture] assesses whether inner layers stay free of transport types, and [Rubric §1, SOLID] - (interface segregation) whether a contract exposes only what its clients need. A handler must know - the caller to run ownership checks and to stamp audit fields, but it must not depend on - `IHttpContextAccessor`, which would drag ASP.NET Core into the Application project. This interface is - that inversion. What makes it worth studying is the use of **default interface members**: `Roles` - (`:45-64`) and `IsInRole` (`:88-89`) ship real implementations on the contract, so every implementer - and every hand-written test double inherits correct multi-role behavior instead of re-deriving it. -- **Walkthrough**: `ClaimsPrincipal User` (`:12`) exposes the full principal for advanced inspection. - `UserIdentifierType? UserId` (`:15`) is the typed identifier, nullable because an unauthenticated - request has no user. `string? Role` (`:22`) is documented as the **first** role claim only, with the - remarks at `:18-21` steering callers to `Roles` or `IsInRole` for membership checks. `Roles` - (`:45-64`) is the interesting member: it reads every role claim, accepting each claim type the JWT - middleware may produce (`ClaimTypes.Role` when inbound claim mapping is on, or the raw `role` / - `roles` claim when it is off, `:50-53`), falls back to a single-element list built from `Role` when - the principal yields nothing (`:62`), and null-guards `User` even though the property is declared - non-nullable (`:49`). The long remarks at `:27-44` justify both accommodations from the nature of a - default interface member: it runs against *every* implementation, including a hand-written double or - a mock that stubs only `Role`, where reading claims alone would have reported no roles and silently - turned an authorization check into a denial, and dereferencing a null principal would have turned it - into a `NullReferenceException`. Claims win when present, so a genuine multi-role principal is still - read in full. `T? GetClaimValue(string claimType) where T : struct, IParsable` (`:73-74`) - parses a named claim into any parsable value type and returns `null` when the claim is missing or - unparseable, which is how a module reads its own claim (the doc names `speaker_id`, `:68`) without - Common ever knowing that claim exists. `IsInRole(string roleName)` (`:88-89`) is - `Roles.Any(role => string.Equals(role, roleName, StringComparison.OrdinalIgnoreCase))`. -- **Why it's built this way**: the remarks at `:82-87` record the reasoning behind `IsInRole` checking - every claim rather than comparing against `Role`. Comparing against the first role alone matched only - whichever role happened to be listed first, which is latent today because tokens carry a single role, - and would have surfaced silently as an authorization denial the moment a second role was added. - Typing `UserId` as the per-app alias instead of a generic parameter keeps the interface concrete and - easy to mock while staying correct for each app. [Rubric §11, Security] and [Rubric §15, Best - Practices & Code Quality]. -- **Where it's used**: injected into command handlers for ownership checks, into - [`ApplicationDbContext`](group-07-persistence-ef-core.md#applicationdbcontext) for `CreatedBy` and - `LastModifiedBy` stamping, and into this group's authorization filters and permission handlers. -- **Caveats / not-in-source**: `Role` deliberately reports only the first role claim; treat it as a - display value and use `Roles` or `IsInRole` for any decision. +- **What it is**: the Application layer's read-only window onto the authenticated caller: the raw `ClaimsPrincipal`, a strongly-typed `UserId`, the caller's first role, the full role set, a generic typed-claim reader, and a role-membership helper. It answers "who is calling?" without any handler ever touching `HttpContext`. +- **Depends on**: `System.Security.Claims` and `IParsable` (BCL, `:1`) plus the solution-wide `UserIdentifierType` alias (`:15`); see [primer §2](00-primer.md#2-architectural-styles-this-codebase-commits-to). It references [RoleNames](#rolenames) in documentation only (`:80`). Its adapter is [CurrentUserService](#currentuserservice) in Infrastructure. +- **Concept introduced: the caller-identity port with behavior on the interface.** `[Rubric §3, Clean Architecture]` assesses whether inner layers stay free of transport types, and `[Rubric §1, SOLID]` (interface segregation) whether a contract exposes only what its clients need. A handler must know the caller to run ownership checks and to stamp audit fields, but it must not depend on `IHttpContextAccessor`, which would drag ASP.NET Core into the Application project. This interface is that inversion. What makes it worth studying is the use of **default interface members**: `Roles` (`:45-64`) and `IsInRole` (`:88-89`) ship real implementations on the contract, so every implementer and every hand-written test double inherits correct multi-role behavior instead of re-deriving it. +- **Walkthrough**: `ClaimsPrincipal User` (`:12`) exposes the full principal for advanced inspection. `UserIdentifierType? UserId` (`:15`) is the typed identifier, nullable because an unauthenticated request has no user. `string? Role` (`:22`) is documented as the **first** role claim only, with the remarks at `:18-21` steering callers to `Roles` or `IsInRole` for membership checks. `Roles` (`:45-64`) is the interesting member: it reads every role claim, accepting each claim type the JWT middleware may produce (`ClaimTypes.Role` when inbound claim mapping is on, or the raw `role` / `roles` claim when it is off, `:50-53`), falls back to a single-element list built from `Role` when the principal yields nothing (`:62`), and null-guards `User` even though the property is declared non-nullable (`:49`). The long remarks at `:27-44` justify both accommodations from the nature of a default interface member: it runs against *every* implementation, including a hand-written double or a mock that stubs only `Role`, where reading claims alone would have reported no roles and silently turned an authorization check into a denial, and dereferencing a null principal would have turned it into a `NullReferenceException`. Claims win when present, so a genuine multi-role principal is still read in full. `T? GetClaimValue(string claimType) where T : struct, IParsable` (`:73-74`) parses a named claim into any parsable value type and returns `null` when the claim is missing or unparseable, which is how a module reads its own claim (the doc names `speaker_id`, `:68`) without Common ever knowing that claim exists. `IsInRole(string roleName)` (`:88-89`) is `Roles.Any(role => string.Equals(role, roleName, StringComparison.OrdinalIgnoreCase))`. +- **Why it's built this way**: the remarks at `:82-87` record the reasoning behind `IsInRole` checking every claim rather than comparing against `Role`. Comparing against the first role alone matched only whichever role happened to be listed first, which is latent today because tokens carry a single role, and would have surfaced silently as an authorization denial the moment a second role was added. Typing `UserId` as the per-app alias instead of a generic parameter keeps the interface concrete and easy to mock while staying correct for each app. `[Rubric §11, Security]` and `[Rubric §15, Best Practices & Code Quality]` both apply. +- **Where it's used**: injected into command handlers for ownership checks, into [ApplicationDbContext](group-07-persistence-ef-core.md#applicationdbcontext) for `CreatedBy` and `LastModifiedBy` stamping, and into this group's authorization filters and permission handlers. +- **Caveats / not-in-source**: `Role` deliberately reports only the first role claim; treat it as a display value and use `Roles` or `IsInRole` for any decision. ### AuthenticationRequest > MMCA.Common.Shared · `MMCA.Common.Shared` · `MMCA.Common/Source/Core/MMCA.Common.Shared/AuthenticationRequest.cs:15` · Level 0 · record struct @@ -1665,21 +1759,7 @@ live in later groups; this chapter is the engine those endpoints call into. - **Concept**: `[Rubric §11, Security]` assesses that identity is derived from the token and not from client-supplied input, and `[Rubric §10, Cross-Cutting]` assesses whether such plumbing is centralized once. SignalR's default `IUserIdProvider` keys connections by the `NameIdentifier` claim. This codebase instead stamps a custom `user_id` claim on every JWT (see [TokenService](#tokenservice), `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/TokenService.cs:81`), so without a matching provider `Clients.User(userId)` would resolve zero connections and every targeted push would silently vanish. - **Walkthrough**: `const string UserIdClaimType = "user_id"` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/ClaimBasedUserIdProvider.cs:11`) keeps the claim name identical to the issuer's. `GetUserId(HubConnectionContext connection)` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/ClaimBasedUserIdProvider.cs:14-15`) returns `connection.User?.FindFirst(UserIdClaimType)?.Value`. The null-conditional chain means an unauthenticated connection (no principal, or no claim) yields `null`, and SignalR then treats the connection as having no user rather than throwing during connection setup. - **Why it's built this way**: `sealed`, one claim in and one nullable string out. Naming the claim in a `const` on the reader side, matching the literal on the writer side, is what keeps the issuer and the connection router from drifting apart. -- **Where it's used**: registered as `services.TryAddSingleton()` in Infrastructure DI (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:310`); called by SignalR's connection manager on every server-initiated `Clients.User(...)`. - ---- - -### IAuthUser -> MMCA.Common.Domain · `MMCA.Common.Domain.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:10` · Level 0 · interface - -- **What it is**: the deliberately minimal credential and refresh-token surface an Identity module's `User` aggregate exposes to the shared [`AuthenticationServiceBase`](#authenticationservicebasetuser) workflow. It is the contract that lets the framework's authentication plumbing read password material and rotate refresh tokens without knowing anything app-specific about the user (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:3-9`). -- **Depends on**: nothing first-party; the BCL only (`byte[]`, `DateTime`). Implemented by each app's `User` aggregate (see [User](group-24-identity-module.md#user)). -- **Concept introduced: the inverted user contract.** Rather than the shared auth workflow depending on a concrete `User` class, `User` implements a small interface the framework owns. Profile fields, roles, linked aggregates, and claim sources stay app-specific: the shared workflow reaches those only through per-app hooks (`CreateAccessToken`, `CreateUser`), never through this contract (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:5-8`). `[Rubric §1, SOLID]` assesses interface segregation and dependency inversion, and this is a textbook case: the interface is exactly the credential surface and nothing more. `[Rubric §11, Security]` assesses credential and session handling, and here the password hash, its salt, and the refresh-token lifecycle are the entire contract, which makes the security-relevant surface of a `User` aggregate readable in one screen. -- **Walkthrough**: read the six members in two groups. - - Password material: `byte[] PasswordHash` (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:14`) and `byte[] PasswordSalt` (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:17`), where the salt length is what selects the verify algorithm (see [PasswordHasher](#passwordhasher)). The scoped `#pragma warning disable CA1819` (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:12`, restored on `:18`) knowingly returns arrays, to mirror [IPasswordHasher](#ipasswordhasher)'s `byte[]` shape and the EF-mapped `varbinary` columns rather than force a defensive copy on every read. - - Refresh-token state: nullable `string? RefreshToken` (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:21`) and `DateTime? RefreshTokenExpiry` (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:24`), both null when the token was never issued or has been revoked. Two mutators carry the rotation and revocation rules: `UpdateRefreshToken(string refreshToken, DateTime expiry)` (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:27`, BR-205) and `RevokeRefreshToken()` (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:30`, BR-206/216). Note that the state is read-only through properties and changed only through the two methods: the aggregate keeps control of the transition. -- **Why it's built this way**: keeping the contract in Domain and keeping it small is what makes the shared auth workflow reusable across Store and ADC (both `User` aggregates implement it) while each aggregate stays free to model everything else its own way. See [ADR-004](https://ivanball.github.io/docs/adr/004-authentication-dual-fetch.html) for the dual-fetch/JWKS auth model this contract feeds and [ADR-032](https://ivanball.github.io/docs/adr/032-password-hashing.html) for the password-material policy. -- **Where it's used**: it is the generic constraint on the shared login/refresh workflow, `where TUser : AuditableAggregateRootEntity, IAuthUser` (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:41`), which calls `UpdateRefreshToken` on issue and rotation (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:168`, `:298`) and `RevokeRefreshToken` on logout and revocation (`:261`, `:282`). It is also the base of [IPasswordChangeableUser](#ipasswordchangeableuser). +- **Where it's used**: registered as `services.TryAddSingleton()` in Infrastructure DI (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:561`); called by SignalR's connection manager on every server-initiated `Clients.User(...)`. --- @@ -1691,7 +1771,7 @@ live in later groups; this chapter is the engine those endpoints call into. - **Concept introduced: publishing a public key instead of sharing a secret.** `[Rubric §11, Security]` assesses key management and blast radius, and `[Rubric §7, Microservices Readiness]` assesses whether a module can be lifted out without a rewrite. In an extracted-service topology, symmetric HS256 would require every service to hold the same secret, so any one compromised service can mint tokens for all of them. The asymmetric alternative ([ADR-004](https://ivanball.github.io/docs/adr/004-authentication-dual-fetch.html)) keeps the RSA private key inside the Identity service and publishes only the public key at a well-known URL; peers fetch it and validate signatures without ever being able to sign. `IJwksProvider` is how the Identity API obtains that public key set to serve. - **Walkthrough**: a single synchronous member, `JsonWebKeySet GetJsonWebKeySet()` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/IJwksProvider.cs:19`). Synchronous is the deliberate shape because key material is resolved once and cached in-process by the implementation. The doc comment sets a contract that the implementation must honor: return an **empty** key set rather than throwing when no signing key is configured (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/IJwksProvider.cs:13-18`), so `/.well-known/jwks.json` stays a valid, pollable URL even in a host where JWKS publishing is off. - **Why it's built this way**: an interface here lets tests inject a pre-built key set with no file I/O, and the empty-set contract makes the endpoint safe to map unconditionally instead of behind a feature check. -- **Where it's used**: registered as `services.TryAddSingleton()` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:141`) next to the `JwksSettings` options binding (`:137-140`); the JWKS minimal-API endpoint calls it, and consuming services fetch the resulting document through `AddForwardedJwtBearer` at startup. +- **Where it's used**: registered as `services.TryAddSingleton()` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:154`) immediately after the `JwksSettings` options binding (`:150-153`); the JWKS minimal-API endpoint calls it, and consuming services fetch the resulting document through `AddForwardedJwtBearer` at startup. --- @@ -1704,8 +1784,24 @@ live in later groups; this chapter is the engine those endpoints call into. - **Walkthrough**: `const string SectionName = "LoginProtection"` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionSettings.cs:12`) names the bound section. Two concerns follow. - Account lockout: `MaxFailedAttempts` (default 5, `[Range(1, 100)]`, `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionSettings.cs:17-18`), `MaxLockoutSeconds` (default 300, `[Range(1, 3600)]`, `:23-24`), `FailedAttemptWindowMinutes` (default 30, `[Range(1, 1440)]`, `:30-31`). The window comment (`:27-28`) is load-bearing for understanding the service: the attempt counter resets by cache expiration, not by a sweep job. - Registration rate limiting: `MaxRegistrationsPerIpPerHour` (default 10, `[Range(1, 10000)]`, `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionSettings.cs:36-37`) and `RegistrationRateLimitWindowMinutes` (default 60, `[Range(1, 1440)]`, `:42-43`). -- **Why it's built this way**: `sealed` with `init`-only properties gives an immutable options object. Every property carries a `[Range]`, and the registration wires `.ValidateDataAnnotations().ValidateOnStart()` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:126-129`), so an obviously unsafe value such as `MaxFailedAttempts = 0` fails the host at startup instead of quietly disabling lockout until someone notices in production. The `MaxLockoutSeconds` upper bound of 3600 is also what lets [LoginProtectionService](#loginprotectionservice) reason about its shift-clamp safely. -- **Where it's used**: bound and validated in `AddInfrastructure` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:126-129`) immediately before [LoginProtectionService](#loginprotectionservice) is registered (`:130`). +- **Why it's built this way**: `sealed` with `init`-only properties gives an immutable options object. Every property carries a `[Range]`, and the registration wires `.ValidateDataAnnotations().ValidateOnStart()` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:133-136`), so an obviously unsafe value such as `MaxFailedAttempts = 0` fails the host at startup instead of quietly disabling lockout until someone notices in production. The `MaxLockoutSeconds` upper bound of 3600 is also what lets [LoginProtectionService](#loginprotectionservice) reason about its shift-clamp safely. +- **Where it's used**: bound and validated in `AddInfrastructure` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:133-136`) immediately before [LoginProtectionService](#loginprotectionservice) is registered (`:137`). Its sibling [PasswordResetSettings](#passwordresetsettings) is bound in exactly the same shape three lines later (`:139-142`). + +--- + +### PasswordResetEntry +> MMCA.Common.Infrastructure · `MMCA.Common.Infrastructure.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:171` · Level 0 · record + +- **What it is**: the cached reset record behind the forgot-password flow: what [PasswordResetTokenService](#passwordresettokenservice) writes into the cache when a reset token is issued, and reads back when one is redeemed. It is `internal sealed`, declared as a second type at the bottom of its service's file (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:171-175`). +- **Depends on**: nothing first-party except the `UserIdentifierType` alias (the per-module `global using` identifier alias taught in the primer, [ADR-048](https://ivanball.github.io/docs/adr/048-primitive-identifier-type-aliases.html)). Four positional parameters, all BCL primitives. +- **Concept introduced: a cache DTO is constrained by its serializer, not by your domain.** `[Rubric §8, Data Architecture]` assesses whether each store is given a shape it can actually round-trip, and this four-line record is a compact lesson in that. The XML comment states the rule directly (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:162-166`): the cache round-trips values through `System.Text.Json`, so **every member is a JSON primitive**. A value object such as [Email](group-02-domain-building-blocks.md#email) or a raw `byte[]` here would not survive a distributed backing store, which is why the token digest is carried as Base64 text (`:172`) and the expiry as Unix seconds (`:175`) rather than as `byte[]` and `DateTimeOffset`. `[Rubric §11, Security]` also applies through one member name: `TokenHashBase64`, not `Token`. The record is structurally incapable of holding the secret it guards. +- **Walkthrough**: four members, in the order they matter. + - `string TokenHashBase64` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:172`): Base64 of the SHA-256 of the issued token, never the token itself (`:167`). Validation re-hashes the presented token and compares digests, so the cache never holds redeemable material. + - `UserIdentifierType UserId` (`:173`): the account the token redeems to (`:168`). Storing the id in the record is what lets redemption resolve the user without a second lookup by email. + - `int FailedAttempts` (`:174`): wrong tokens presented against this record so far (`:169`), the counter the attempt cap is enforced against. + - `long ExpiresAtUnixSeconds` (`:175`): when the record expires (`:170`). This one exists for a specific reason explained at the rewrite site: when a failed attempt bumps the counter, the record is re-cached with the **remaining** lifetime computed from this field, so a wrong guess cannot extend how long the token stays redeemable (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:138`, `:146-152`). +- **Why it's built this way**: being a `record` gives the non-destructive `with` expression that the attempt counter update relies on (`entry with { FailedAttempts = attempts }`, `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:150`), so the rewrite is a copy rather than a mutation. Being `internal` keeps a cache-layout detail out of the package's public API: nothing outside the Infrastructure assembly should be able to construct or read one. See [ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html) for why the reset lifecycle lives in the cache at all. +- **Where it's used**: written by `IssueAsync` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:82-88`), read by `ValidateAndConsumeAsync` (`:100`), and rewritten by `RecordFailedAttemptAsync` (`:148-152`). It appears nowhere else. --- @@ -1721,7 +1817,7 @@ live in later groups; this chapter is the engine those endpoints call into. - `VerifyPassword(string password, byte[] hash, byte[] salt)` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/PasswordHasher.cs:46`): null-guards all three arguments (`:48-50`), selects the algorithm by `salt.Length == LegacyHmacSaltSize` (`:52-54`), and compares with `CryptographicOperations.FixedTimeEquals` (`:58`) so the comparison always walks the full length regardless of where the first difference occurs. Note the PBKDF2 branch derives `hash.Length` bytes rather than `HashSize` (`:54`), so a stored hash of a different length still verifies. - `ComputePbkdf2Hash` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/PasswordHasher.cs:62`) and `ComputeLegacyHash` (`:71`, `using var hmac = new HMACSHA512(salt)`) are the two private algorithm bodies. - **Why it's built this way**: verification stays backward-compatible with pre-existing HMAC hashes so a deployment can migrate lazily, while every write is PBKDF2, so the stored population converges on the strong format as users log in and change passwords, with no data migration and no downtime. `FixedTimeEquals` and the 600k iteration count are the concrete OWASP-aligned defenses; [ADR-032](https://ivanball.github.io/docs/adr/032-password-hashing.html) records the policy. -- **Where it's used**: registered `services.TryAddSingleton()` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:224`); called by the shared change-password workflow (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:55` verify, `:61` re-hash) and by each Identity module's register and login handlers, against the `PasswordHash`/`PasswordSalt` exposed by [IAuthUser](#iauthuser). +- **Where it's used**: registered `services.TryAddSingleton()` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:475`); called by the shared change-password workflow (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:55` verify, `:61` re-hash), by the forgot-password reset workflow (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:79`, which hashes but never verifies: possession of the reset token replaces knowledge of the old password), and by each Identity module's register and login handlers, against the `PasswordHash`/`PasswordSalt` exposed by [IAuthUser](#iauthuser). --- @@ -1736,7 +1832,7 @@ live in later groups; this chapter is the engine those endpoints call into. - `BuildKeySet(JwksSettings settings)` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/RsaJwksProvider.cs:28`) short-circuits to an empty `JsonWebKeySet` when `!settings.Enabled` (`:30-33`) or when the resolved PEM is blank (`:36-39`). Those are the two paths that satisfy the [IJwksProvider](#ijwksprovider) never-throw contract. - With a key present it imports the PEM into a disposable `RSA` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/RsaJwksProvider.cs:41-42`), exports **only** the public parameters (`ExportParameters(includePrivateParameters: false)`, `:44`) into an `RsaSecurityKey` tagged with the configured `KeyId` (`:46`), converts it with `JsonWebKeyConverter.ConvertFromRSASecurityKey` (`:49`), marks it `Use = "sig"` and `Alg = SecurityAlgorithms.RsaSha256` (`:50-51`) so consumers know the key's purpose and algorithm, and adds it to a fresh key set (`:53-55`). - `ResolvePem(JwksSettings settings)` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/RsaJwksProvider.cs:58`) prefers the inline `RsaPublicKeyPem` (`:60-63`) and otherwise reads `RsaPublicKeyPath` from disk with a synchronous `File.ReadAllText` (`:70`), justified in the comment because the read happens on the first request and its success is cached, while a failure is deliberately not cached (`:67-69`). -- **Why it's built this way**: exporting only the public parameters guarantees the private key can never reach the JWKS document even by accident. The inline-PEM-or-path pair supports both secrets-manager injection (env var or config) and a volume-mounted key file, which are the two deployment shapes the framework's samples use. `sealed`, and registered singleton (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:141`) so the cache is process-wide. +- **Why it's built this way**: exporting only the public parameters guarantees the private key can never reach the JWKS document even by accident. The inline-PEM-or-path pair supports both secrets-manager injection (env var or config) and a volume-mounted key file, which are the two deployment shapes the framework's samples use. `sealed`, and registered singleton (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:154`) so the cache is process-wide. - **Where it's used**: the JWKS minimal-API endpoint calls `GetJsonWebKeySet()` per request; see [JwksEndpointExtensions](group-12-api-hosting-mapping.md#jwksendpointextensions). --- @@ -1756,73 +1852,60 @@ live in later groups; this chapter is the engine those endpoints call into. - `GetPrincipalFromExpiredToken` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/TokenService.cs:123`): validates issuer, audience, signing key and algorithm but not lifetime (`:125-137`), then applies the post-validation `Alg` re-check (`:145-149`). Any exception is swallowed and returns `null` (`:153-156`), so a malformed or forged token produces a plain "no principal" answer rather than leaking a parser exception to the caller. - `Dispose` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/TokenService.cs:160`): releases both owned `RSA` handles. - `BuildHmacCredentials` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/TokenService.cs:166`) throws `InvalidOperationException` when `SecretForKey` is missing (`:168-172`) and Base64-decodes it into a `SymmetricSecurityKey` (`:175`). `BuildRsaCredentials` (`:180`) throws when `RsaPrivateKeyPem` is missing (`:183-187`), imports the private key, and then resolves a validation key: the configured `RsaPublicKeyPem` when present, otherwise the public parameters derived from the private key (`:196-210`), so an issuer configured with only a private key can still self-validate its own tokens during refresh. Both nested `try`/`catch` blocks dispose the partially-created `RSA` before rethrowing (`:215-225`), so a bad PEM does not leak a native key handle. Missing key material therefore fails at construction, meaning at host startup, not on the first login. -- **Why it's built this way**: see [ADR-004](https://ivanball.github.io/docs/adr/004-authentication-dual-fetch.html) for the RS256 rationale. The DI lifetime is worth reading in full at the registration site (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:217-223`): the service is `TryAddSingleton` because a scoped lifetime disposed the underlying `RSA` at end-of-request while `Microsoft.IdentityModel.Tokens`' static `CryptoProviderCache` still held the cached `AsymmetricSignatureProvider` wrapping it, throwing `ObjectDisposedException` on the next RS256 sign. Singleton is safe because the constructor depends only on the singleton `IJwtSettings` and the service is stateless afterwards. +- **Why it's built this way**: see [ADR-004](https://ivanball.github.io/docs/adr/004-authentication-dual-fetch.html) for the RS256 rationale. The DI lifetime is worth reading in full at the registration site (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:468-474`): the service is `TryAddSingleton` because a scoped lifetime disposed the underlying `RSA` at end-of-request while `Microsoft.IdentityModel.Tokens`' static `CryptoProviderCache` still held the cached `AsymmetricSignatureProvider` wrapping it, throwing `ObjectDisposedException` on the next RS256 sign. Singleton is safe because the constructor depends only on the singleton `IJwtSettings` and the service is stateless afterwards. - **Where it's used**: the shared [`AuthenticationServiceBase`](#authenticationservicebasetuser) login/refresh flow and each Identity module's auth handlers. The `user_id` claim it emits is exactly what [CurrentUserService](#currentuserservice) and [ClaimBasedUserIdProvider](#claimbaseduseridprovider) read back. --- -### IPasswordChangeableUser -> MMCA.Common.Domain · `MMCA.Common.Domain.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IPasswordChangeableUser.cs:11` · Level 3 · interface - -- **What it is**: the password-rotation surface an Identity module's `User` aggregate exposes to the shared [`ChangePasswordHandlerBase`](group-14-module-system-composition.md#changepasswordhandlerbasetuser-tcommand) workflow. It is one method on top of [IAuthUser](#iauthuser) (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IPasswordChangeableUser.cs:5-10`). -- **Depends on**: [IAuthUser](#iauthuser) (its base interface) and [Result](group-01-result-error-handling.md#result) from `MMCA.Common.Shared.Abstractions` (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IPasswordChangeableUser.cs:1`). -- **Concept**: capability interfaces layered by workflow. `[Rubric §1, SOLID]` assesses interface segregation, and this is the pattern applied twice over: a `User` that only ever authenticates implements [IAuthUser](#iauthuser); a `User` whose app offers self-service password change implements this one and gets `PasswordHash`/`PasswordSalt` along with it, because the workflow must verify the current credential before writing the new one (the XML comment states exactly this reason, `MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IPasswordChangeableUser.cs:8-9`). Inheritance here encodes a real dependency between capabilities rather than a taxonomy. `[Rubric §4, DDD]` also applies: the method returns [Result](group-01-result-error-handling.md#result), so the aggregate can refuse the change (an invariant failure) instead of the handler assuming success. -- **Walkthrough**: one member, `Result ChangePassword(byte[] newPasswordHash, byte[] newPasswordSalt)` (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IPasswordChangeableUser.cs:19`). The aggregate receives already-hashed material, never a plaintext password: hashing is the handler's job via [IPasswordHasher](#ipasswordhasher), so no plaintext ever reaches the Domain layer or an EF change tracker. -- **Why it's built this way**: keeping the hash-and-salt pair as the parameter shape mirrors [IAuthUser](#iauthuser)'s two properties and [IPasswordHasher](#ipasswordhasher)'s tuple return, so the whole chain from handler to aggregate speaks one vocabulary. See [ADR-032](https://ivanball.github.io/docs/adr/032-password-hashing.html). -- **Where it's used**: as the generic constraint `where TUser : AuditableAggregateRootEntity, IPasswordChangeableUser` on the shared change-password workflow (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:28`), which verifies the current password (`:55`), hashes the new one (`:61`), and calls `ChangePassword` with the result (`:62`). - ---- - -### IUserPreferences -> MMCA.Common.Domain · `MMCA.Common.Domain.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IUserPreferences.cs:10` · Level 3 · interface - -- **What it is**: the stored UI-preference surface an Identity module's `User` aggregate exposes to the shared preference read and write workflows: preferred culture, preferred theme, and a single method that replaces both (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IUserPreferences.cs:5-9`). -- **Depends on**: [Result](group-01-result-error-handling.md#result) from `MMCA.Common.Shared.Abstractions` (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IUserPreferences.cs:1`). Nothing else; it is deliberately not tied to [IAuthUser](#iauthuser), because preferences are orthogonal to credentials. -- **Concept**: null as "not chosen". `[Rubric §27, i18n]` assesses whether locale is a first-class, persisted user choice rather than a per-session guess, and `[Rubric §19, State Management]` assesses where such UI state lives. Both properties are nullable, and the contract states that `null` means the user has not chosen that preference (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IUserPreferences.cs:7-8`), which is what lets the UI fall back to a browser or host default without needing a separate "is set" flag. See [ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html) for the culture model and [ADR-028](https://ivanball.github.io/docs/adr/028-dark-theme-mode.html) for the theme model. -- **Walkthrough**: `string? PreferredCulture` (for example `"es"`, `MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IUserPreferences.cs:13`) and `string? PreferredTheme` (`"light"`/`"dark"`, `:16`) are read-only. `Result UpdatePreferences(string? preferredCulture, string? preferredTheme)` (`:25`) replaces **both** at once. The subtlety is documented on `:18-21`: because the method is a whole-object replace, the shared workflow always passes the currently stored value for any field the request left `null`, so writing one preference never silently clears the other. You can see that read-then-merge in the caller: `user.UpdatePreferences(command.Request.Culture ?? user.PreferredCulture, command.Request.Theme ?? user.PreferredTheme)` (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:53-55`). -- **Why it's built this way**: one replace method keeps the aggregate's invariant check in a single place, and pushing the merge into the workflow keeps the null-means-unchanged policy out of every app's `User`. Returning [Result](group-01-result-error-handling.md#result) lets the aggregate reject an unsupported culture or theme value. -- **Where it's used**: the read workflow constrains `where TUser : AuditableBaseEntity, IUserPreferences` and projects both properties into a response (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandlerBase.cs:23`, `:44`); the write workflow constrains `where TUser : AuditableAggregateRootEntity, IUserPreferences` (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:26`). Both are cross-linked as [`GetUserPreferencesHandlerBase`](group-14-module-system-composition.md#getuserpreferenceshandlerbasetuser) and [`ChangePreferencesHandlerBase`](group-14-module-system-composition.md#changepreferenceshandlerbasetuser-tcommand). - ---- - -### IErasableUser -> MMCA.Common.Domain · `MMCA.Common.Domain.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IErasableUser.cs:30` · Level 4 · interface - -- **What it is**: the erasure surface an Identity module's `User` aggregate exposes to the shared [`DeleteUserHandlerBase`](group-14-module-system-composition.md#deleteuserhandlerbasetuser-tcommand) workflow: soft-delete the row, then irreversibly anonymize the personal data it still holds (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IErasableUser.cs:6-10`). -- **Depends on**: [IAnonymizable](group-02-domain-building-blocks.md#ianonymizable) (its base, contributing `Result Anonymize()`) and [Result](group-01-result-error-handling.md#result) (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IErasableUser.cs:1-2`). -- **Concept introduced: why a `Delete()` that already exists on the base entity is redeclared here.** This is the most instructive comment in the file and it is worth reading in full (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IErasableUser.cs:11-29`). [`AuditableBaseEntity`](group-02-domain-building-blocks.md#auditablebaseentitytidentifiertype) already has a `Delete()`. But an app's `User` may **hide** it (`public new Result Delete()`) to couple account-specific behavior to deletion, typically revoking the refresh token so outstanding sessions die immediately. A hidden method is not an override. C# member lookup on a generic type parameter prefers the members of its **class** constraint, so a shared workflow writing `user.Delete()` would bind to the base implementation and silently skip the app's version. Redeclaring `Delete()` on this interface and invoking it **through the interface** forces interface dispatch, which resolves to the most derived member the app type maps onto `IErasableUser`. `[Rubric §1, SOLID]` (Liskov: the hidden method is exactly the substitutability hazard this closes) and `[Rubric §15, Best Practices]` both apply, and this is a case where the language rule, not a style preference, dictates the design. The second paragraph (`:25-28`) adds the compile-time guarantee: the base entity deliberately does **not** implement this interface, so a consumer that forgets to declare it fails the generic constraint at compile time rather than losing behavior at run time. -- **Walkthrough**: one declared member, `Result Delete()` (`MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IErasableUser.cs:37`), documented as soft-delete plus whatever the app couples to deletion (`:32-35`), returning a failure when the account is already deleted (`:36`). Inherited from [IAnonymizable](group-02-domain-building-blocks.md#ianonymizable) is `Result Anonymize()` (`MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IAnonymizable.cs:30`), which must be idempotent (`:26-27`). The two-step order is visible in the caller: cast once to the interface (`IErasableUser erasable = user;`, `MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:88`), `erasable.Delete()` first (`:89`), the app's own tail hook next (`:96`), then `erasable.Anonymize()` (`:103`), each short-circuiting on failure. -- **Why it's built this way**: soft-delete alone hides a row but retains its personal data, so it does not satisfy an erasure request; anonymize-in-place overwrites the personal fields while keeping the row so foreign keys and the audit trail survive ([ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html), and `MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IAnonymizable.cs:10-20`). Splitting the two into separate members lets the workflow run app-specific work between them. `[Rubric §30, Compliance and Data Governance]` assesses exactly this: a GDPR/CCPA erasure path that does not destroy referential integrity. -- **Where it's used**: the generic constraint `where TUser : AuditableAggregateRootEntity, IErasableUser` on the shared delete-user workflow (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:41`), implemented by each app's [User](group-24-identity-module.md#user) aggregate. - ---- - ### LoginProtectionService > MMCA.Common.Infrastructure · `MMCA.Common.Infrastructure.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:19` · Level 5 · class - **What it is**: the cache-backed brute-force and rate-limiting service: exponential-backoff account lockout after repeated login failures, plus a per-IP registration rate limit (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:9-18`). - **Depends on**: [ILoginProtectionService](#iloginprotectionservice) (the Application port); [LoginProtectionSettings](#loginprotectionsettings) via `IOptions<>`; [ICacheService](group-09-caching.md#icacheservice); [Result](group-01-result-error-handling.md#result) and [Error](group-01-result-error-handling.md#error); and the [Email](group-02-domain-building-blocks.md#email) value object, used purely as a normalizer. - **Concept introduced: counter keys must be normalized the same way the lookup is.** `[Rubric §11, Security]` assesses brute-force protection and rate limiting; `[Rubric §10, Cross-Cutting]` assesses whether it is one shared service rather than logic copied per endpoint. Two mechanisms in this file deserve close reading. - - **Key normalization** (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:25-43`): `NormalizeIdentity` runs the supplied address through [Email](group-02-domain-building-blocks.md#email)`.Create` and uses the normalized value (`:39-41`). Without it, the counter keys are built from raw request input while the user lookup runs against the normalized value object, so `User@x.com`, `user@x.com` and `" user@x.com "` resolve to one account but get **independent** attempt counters, and an attacker defeats the [ADR-029](https://ivanball.github.io/docs/adr/029-authentication-brute-force-protection.html) backoff just by varying capitalization. A malformed address (which never matches a user but still increments a counter) falls back to the same trim-and-lowercase shape (`:41`) so its attempts collapse onto one key too. The `#pragma warning disable CA1308` (`:40`) is scoped and justified: lowercase is the RFC 5321 normalization `Email` itself performs. + - **Key normalization** (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:25-43`): `NormalizeIdentity` runs the supplied address through [Email](group-02-domain-building-blocks.md#email)`.Create` and uses the normalized value (`:39-41`). Without it, the counter keys are built from raw request input while the user lookup runs against the normalized value object, so `User@x.com`, `user@x.com` and `" user@x.com "` resolve to one account but get **independent** attempt counters, and an attacker defeats the [ADR-029](https://ivanball.github.io/docs/adr/029-authentication-brute-force-protection.html) backoff just by varying capitalization. A malformed address (which never matches a user but still increments a counter) falls back to the same trim-and-lowercase shape (`:41`) so its attempts collapse onto one key too. The `#pragma warning disable CA1308` (`:40`) is scoped and justified: lowercase is the RFC 5321 normalization `Email` itself performs. [PasswordResetTokenService](#passwordresettokenservice) copies this helper verbatim and cites this type as the reason (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:34-38`). - **The lockout curve** (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:80-89`): `excessAttempts = newCount - MaxFailedAttempts` (`:82`) drives `lockoutSeconds = Math.Min(1 << Math.Min(excessAttempts, 30), MaxLockoutSeconds)` (`:88`), doubling the lockout per excess failure (1s, 2s, 4s, and so on) up to the configured cap. The inner `Math.Min(excessAttempts, 30)` clamps the shift exponent, and the comment explains why (`:84-87`): C# masks an `int` shift count to five bits, so `1 << 31` is negative and `1 << 32` wraps back to `1`, which would silently shrink the lockout for a sufficiently persistent attacker. Since `1 << 30` already exceeds the `[Range(1, 3600)]` cap on [LoginProtectionSettings](#loginprotectionsettings)`.MaxLockoutSeconds`, deep excess always lands on the cap. - **Walkthrough** - - Key builders: `LockoutKey` -> `login:lockout:{normalized}` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:45`), `AttemptsKey` -> `login:attempts:{normalized}` (`:47`), `RegistrationKey` -> `registration:ip:{ipAddress}` (`:136`). + - Key builders: `LockoutKey` produces `login:lockout:{normalized}` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:45`), `AttemptsKey` produces `login:attempts:{normalized}` (`:47`), `RegistrationKey` produces `registration:ip:{ipAddress}` (`:136`). - `CheckLockoutAsync(string email, CancellationToken)` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:50`): reads the boolean lockout key (`:53`) and returns `Error.Unauthorized("Auth.TooManyAttempts", ...)` when set, otherwise `Result.Success()` (`:55-60`). A cache miss is treated as not locked out (`?? false`), so a cache outage fails open on lockout rather than locking everyone out. - `IncrementFailedAttemptsAsync` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:64`): increments the attempts key with the `FailedAttemptWindowMinutes` TTL (`:75-78`), and once the count reaches `MaxFailedAttempts` writes the lockout key with the exponential TTL (`:80-90`). - `ResetFailedAttemptsAsync` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:94`): removes both keys on a successful login (`:96-97`). - `CheckRegistrationRateLimitAsync(string? ipAddress, ...)` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:101`): a null or empty IP is unrestricted (`:103-106`); otherwise it compares the per-IP count against `MaxRegistrationsPerIpPerHour` and fails with `Auth.RegistrationRateLimitExceeded` (`:111-116`). - `IncrementRegistrationCountAsync` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:120`): no-ops on a missing IP (`:122-125`) and otherwise increments the per-IP counter with the `RegistrationRateLimitWindowMinutes` TTL (`:130-133`). The comment (`:127-129`) notes the TTL is refreshed on every write, so the window slides rather than staying anchored to the first registration, which only ever tightens the limit. -- **Why it's built this way**: reusing [ICacheService](group-09-caching.md#icacheservice) (Redis in production, in-memory fallback) instead of a bespoke store keeps the service thin and lets counters expire naturally by TTL rather than needing a sweep job; `IOptions<>` keeps every threshold configurable per environment. Registered scoped (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:130`). +- **Why it's built this way**: reusing [ICacheService](group-09-caching.md#icacheservice) (Redis in production, in-memory fallback) instead of a bespoke store keeps the service thin and lets counters expire naturally by TTL rather than needing a sweep job; `IOptions<>` keeps every threshold configurable per environment. Registered scoped (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:137`). - **Caveats / not-in-source**: the increment is documented in source as **not atomic** on the distributed cache today (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:66-74`). [DistributedCacheService](group-09-caching.md#distributedcacheservice)`.IncrementAsync` is a read-modify-write, because the Redis `INCR` it used to issue wrote a plain string key while `IDistributedCache` reads entries back as hashes, and the mismatch made the counter unreadable (`WRONGTYPE`). The accepted cost: genuinely parallel attempts can overwrite each other's increments, so a concurrent burst can stay under `MaxFailedAttempts`. Sequential guessing, which is what a credential-stuffing run against one account looks like, still trips the lockout. The comment names the two ways to close the gap (a Lua script that increments within the hash layout, or moving counters off `IDistributedCache`); neither is implemented today. --- +### PasswordResetTokenService +> MMCA.Common.Infrastructure · `MMCA.Common.Infrastructure.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:26` · Level 5 · class + +- **What it is**: the [IPasswordResetTokenService](#ipasswordresettokenservice) implementation, and the whole forgot-password token lifecycle in one file: issue a single-use token for an address, throttle how often one address can ask, hash the token at rest, cap wrong guesses, and consume the token on a successful redeem (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:12-25`). +- **Depends on**: [IPasswordResetTokenService](#ipasswordresettokenservice) (the Application port); [PasswordResetSettings](#passwordresetsettings) via `IOptions<>`; [ICacheService](group-09-caching.md#icacheservice); [PasswordResetEntry](#passwordresetentry) (its cached record); [Result](group-01-result-error-handling.md#result) and [Error](group-01-result-error-handling.md#error); the [Email](group-02-domain-building-blocks.md#email) value object as a normalizer; and from the BCL `SHA256`, `RandomNumberGenerator`, `CryptographicOperations`, and `System.Buffers.Text.Base64Url`. +- **Concept introduced: a reset token is a bearer credential, so treat it like a password.** `[Rubric §11, Security]` assesses credential issuance and redemption; `[Rubric §8, Data Architecture]` assesses picking the right store for the right lifetime. Four properties are designed in, and the class doc lists all four up front (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:15-24`). + - **Hashed at rest.** Only `SHA256.HashData(...)` of the token is stored (`:55-56`, `:83`), so a cache dump does not hand out working reset links. Unlike a password, a reset token is high-entropy (32 random bytes, `:30`, `:79`) and short-lived, which is why a plain digest is sufficient here where [PasswordHasher](#passwordhasher) needs 600,000 PBKDF2 iterations: there is no dictionary to run against a 256-bit random value. + - **One active token per email.** The key is derived purely from the address (`:51`), so `SetAsync` overwrites (`:88`) and an older link stops working the moment a newer one is requested. + - **Attempt cap.** Wrong tokens are counted on the record and the record is discarded at `MaxValidationAttempts` (`:140-144`), which turns the token into a credential you cannot grind at. + - **No schema change, no sweeper.** The whole lifecycle rides [ICacheService](group-09-caching.md#icacheservice), so expiry is the cache TTL rather than a background job over a table ([ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html)). Compare [LoginProtectionService](#loginprotectionservice), which reaches the same conclusion for lockout counters. +- **Walkthrough** + - Primary constructor takes `ICacheService cacheService` and `IOptions settings` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:26-28`), snapshotting `settings.Value` into `_settings` (`:32`). `TokenByteLength = 32` (`:30`) is the only other constant. + - `NormalizeIdentity` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:40-49`) is the same [Email](group-02-domain-building-blocks.md#email)`.Create`-then-fallback shape as [LoginProtectionService](#loginprotectionservice), and its doc comment cites that type as the reason (`:34-38`): keys built from raw request input would give `User@x.com` and `user@x.com` independent tokens **and** independent request counters while resolving to one account. Two key builders follow: `TokenKey` produces `pwdreset:token:{normalized}` (`:51`) and `RequestKey` produces `pwdreset:req:{normalized}` (`:53`). `HashToken` is the shared SHA-256 helper (`:55-56`). + - `IssueAsync(string email, UserIdentifierType userId, CancellationToken)` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:59`): throttle first. It increments the per-email request counter with the `RequestWindowMinutes` TTL (`:66-69`) and fails with `Error.Unauthorized("Auth.ResetThrottled", ...)` once the count exceeds `MaxRequestsPerEmail` (`:71-77`). Only then does it mint the token: 32 CSPRNG bytes rendered with `Base64Url.EncodeToString` (`:79`, URL-safe because the token travels in a query string), builds a [PasswordResetEntry](#passwordresetentry) holding the Base64 digest, the user id, a zero attempt count and the absolute expiry as Unix seconds (`:82-86`), caches it under the token key with the configured lifetime (`:88`), and returns the **raw** token to the caller to email (`:90`). The raw token exists only in that return value: it is never written anywhere. + - `ValidateAndConsumeAsync(string email, string token, CancellationToken)` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:94`): loads the entry (`:100`) and returns `InvalidToken()` when there is none (`:101-104`). A `FormatException` decoding the stored Base64 removes the unreadable record rather than leaving it to expire (`:107-116`). The comparison is `CryptographicOperations.FixedTimeEquals` over the two digests (`:118`), the same timing-side-channel defense [PasswordHasher](#passwordhasher) uses, with `token ?? string.Empty` so a null token hashes rather than throwing. A mismatch records a failed attempt and returns the same generic failure (`:120-121`). On a match it removes **both** the token key and the address's request counter (`:126-127`), so a successful reset does not leave the user throttled out of a later legitimate request (`:124-125`), and returns the entry's `UserId` (`:129`). + - `RecordFailedAttemptAsync` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:132`): computes `attempts = entry.FailedAttempts + 1` and the remaining lifetime from `ExpiresAtUnixSeconds` (`:137-138`). At `MaxValidationAttempts`, or once the remaining lifetime is non-positive, it deletes the record (`:140-144`). Otherwise it rewrites the entry with `entry with { FailedAttempts = attempts }` and a TTL of the **remaining** seconds, not a fresh lifetime (`:146-152`), because a wrong guess must not be able to extend how long the token stays redeemable. + - `InvalidToken()` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:155-159`) is the single failure factory: unknown, expired, mismatched and attempt-capped all collapse to one `Auth.InvalidResetToken` error with one message. That uniformity is deliberate: distinct errors would make the endpoint an oracle for which addresses have an outstanding reset. +- **Why it's built this way**: [ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html) records the decision. It extends [ADR-029](https://ivanball.github.io/docs/adr/029-authentication-brute-force-protection.html) (the cache-backed protection idiom reused here) and sits beside [ADR-032](https://ivanball.github.io/docs/adr/032-password-hashing.html), which decided how a password is stored but not how a user who has lost one gets a new one. Keeping the token out of the database is what makes the feature additive: no migration, no new table, and nothing to reap. +- **Where it's used**: registered `services.TryAddScoped()` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:143`), directly after the `PasswordResetSettings` binding (`:139-142`). [`ForgotPasswordHandlerBase`](group-14-module-system-composition.md#forgotpasswordhandlerbasetuser-tcommand) calls `IssueAsync` and emails the resulting link (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:72`); [`ResetPasswordHandlerBase`](group-14-module-system-composition.md#resetpasswordhandlerbasetuser-tcommand) calls `ValidateAndConsumeAsync` (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:61-63`) **before** the save, because leaving the token live until the write succeeds would open a replay window (`:58-60`). It is unit-tested by [PasswordResetTokenServiceTests](group-27-testing-infrastructure.md#passwordresettokenservicetests). +- **Caveats / not-in-source**: the per-email request throttle inherits [LoginProtectionService](#loginprotectionservice)'s non-atomic increment, and the source says so where it matters (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:64-65`): concurrent requests can undercount, which loosens the throttle but never tightens it. The failed-attempt rewrite is a read-modify-write too, so a burst of simultaneous wrong guesses can lose increments against the attempt cap; sequential guessing still trips it. + +--- + ### CurrentUserService > MMCA.Common.Infrastructure · `MMCA.Common.Infrastructure.Services` · `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/CurrentUserService.cs:13` · Level 9 · class - **What it is**: the scoped, per-request implementation of [ICurrentUserService](#icurrentuserservice). It extracts the current user's id, role, principal, and arbitrary typed claims from the JWT in the HTTP context (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/CurrentUserService.cs:8-12`). - **Depends on**: [ICurrentUserService](#icurrentuserservice); `Microsoft.AspNetCore.Http.IHttpContextAccessor`, `System.Security.Claims`, and `System.Globalization` (BCL). The `user_id` claim it reads is emitted by [TokenService](#tokenservice). -- **Concept introduced: scoped claim extraction with lazy per-request caching, parsed invariantly.** `[Rubric §11, Security]` assesses correct claim extraction, `[Rubric §12, Performance]` the cost of doing it repeatedly, and `[Rubric §27, i18n]` the culture trap. The service is registered scoped (one instance per request, `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:216`) and wraps `_userId` and `_role` in `Lazy` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/CurrentUserService.cs:18`, `:26`), so `HttpContext.User` is walked at most once per request no matter how many handlers, filters, and `SaveChangesAsync` calls ask. The i18n point is the one most codebases get wrong: claims are machine-written by [TokenService](#tokenservice) under `CultureInfo.InvariantCulture`, so they must be **read** invariantly too. Both `int.TryParse` for the user id (`:23`) and `T.TryParse` for generic claims (`:45`) pass `CultureInfo.InvariantCulture` explicitly, with comments explaining that parsing under the ambient request culture misreads separators for decimal, double and `DateTime` claim types (`:21-22`, `:43-44`). Reading the custom `user_id` claim type (`:16`) rather than the standard `sub` keeps the claim contract with [TokenService](#tokenservice) explicit. +- **Concept introduced: scoped claim extraction with lazy per-request caching, parsed invariantly.** `[Rubric §11, Security]` assesses correct claim extraction, `[Rubric §12, Performance]` the cost of doing it repeatedly, and `[Rubric §27, i18n]` the culture trap. The service is registered scoped (one instance per request, `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:467`) and wraps `_userId` and `_role` in `Lazy` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/CurrentUserService.cs:18`, `:26`), so `HttpContext.User` is walked at most once per request no matter how many handlers, filters, and `SaveChangesAsync` calls ask. The i18n point is the one most codebases get wrong: claims are machine-written by [TokenService](#tokenservice) under `CultureInfo.InvariantCulture`, so they must be **read** invariantly too. Both `int.TryParse` for the user id (`:23`) and `T.TryParse` for generic claims (`:45`) pass `CultureInfo.InvariantCulture` explicitly, with comments explaining that parsing under the ambient request culture misreads separators for decimal, double and `DateTime` claim types (`:21-22`, `:43-44`). Reading the custom `user_id` claim type (`:16`) rather than the standard `sub` keeps the claim contract with [TokenService](#tokenservice) explicit. - **Walkthrough** - Primary constructor takes `IHttpContextAccessor httpContextAccessor` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/CurrentUserService.cs:13`), captured directly by the lazy initializers. - `User` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/CurrentUserService.cs:30`): returns the `ClaimsPrincipal`, or a fresh empty one when there is no HTTP context, so background jobs and hosted services resolving the same interface get an anonymous principal instead of a `NullReferenceException`. @@ -1868,11 +1951,12 @@ live in later groups; this chapter is the engine those endpoints call into. (`MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionAuthorizationHandler.cs:29-30`), and described (without being named) in the [`HasPermissionAttribute`](#haspermissionattribute) doc comment as the "explicit permission claim" alternative to the role-derived path - (`MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/HasPermissionAttribute.cs:6-10`). + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/HasPermissionAttribute.cs:5-11`). - **Caveats / not-in-source**: no shipped token issuer in this repo writes a permission claim. Across - both applications and the framework there are exactly four references to the constant - (its declaration, the handler's doc comment, the handler's check, and one test), and the only - writer in the tree is a test that hands the claim to a principal directly + both applications and the framework there are exactly four references to the constant (its + declaration, the handler's doc comment at `PermissionAuthorizationHandler.cs:9`, the handler's + check at `:29`, and one test), and the only writer in the tree is a test that hands the claim to a + principal directly (`MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Authorization/PermissionAuthorizationHandlerTests.cs:30`). The claim path is real and covered, but every deployed grant today flows through roles. @@ -1899,15 +1983,14 @@ live in later groups; this chapter is the engine those endpoints call into. [`OAuthControllerBase`](group-12-api-hosting-mapping.md#oauthcontrollerbase) therefore detects a missing exchange entry by testing `string.IsNullOrEmpty(response.AccessToken)`, with the reason written down at the call site - (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:149-155`). + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:151-154`). - **Where it's used**: produced by [`AuthenticationServiceBase`](#authenticationservicebasetuser) at the end of registration - (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:211-214`) and - from the shared `IssueTokensAsync` helper that login, refresh, and app-level external-login flows - all funnel through (`AuthenticationServiceBase.cs:292,302-305`); declared as the 200/201 response - type on the three [`AuthControllerBase`](group-12-api-hosting-mapping.md#authcontrollerbase) - endpoints - (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:56,77,97`); + (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:211`) and from + the shared `IssueTokensAsync` helper that login, refresh, and app-level external-login flows all + funnel through (`AuthenticationServiceBase.cs:292,302`); declared as the 200/201 response type on + the three [`AuthControllerBase`](group-12-api-hosting-mapping.md#authcontrollerbase) endpoints + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:58,80,101`); consumed by [`AuthUIService`](group-15-common-ui-framework.md#authuiservice), [`DirectApiTokenRefresher`](group-15-common-ui-framework.md#directapitokenrefresher), and [`CookieSessionRefresher`](#cookiesessionrefresher). @@ -1926,15 +2009,15 @@ live in later groups; this chapter is the engine those endpoints call into. [`ChangePasswordRequestValidator`](group-24-identity-module.md#changepasswordrequestvalidator)), which is what lets Store and ADC differ on policy while sharing the contract. - **Walkthrough**: two positional parameters (`ChangePasswordRequest.cs:8-10`); no body. -- **Where it's used**: bound as the body of the shared `PUT change-password` endpoint on +- **Where it's used**: bound as the body of the shared `PUT password` endpoint on [`UserAccountAuthControllerBase`](group-12-api-hosting-mapping.md#useraccountauthcontrollerbasetchangepasswordcommand-tchangepreferencescommand) - (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:100`), + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:86,92`), carried by each app's [`ChangePasswordCommand`](group-24-identity-module.md#changepasswordcommand) - (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordCommand.cs` + (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordCommand.cs:14` and its Store twin), and validated by ADC's `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/Validation/ChangePasswordRequestValidator.cs:11`, - which requires a non-empty `CurrentPassword` (`:15-16`) and applies the shared - `StrongPasswordRules` to `NewPassword` (`:18`). + which requires a non-empty `CurrentPassword` (`:15-16`) and includes the shared + [`StrongPasswordRules`](group-06-validation.md#strongpasswordrulest) for `NewPassword` (`:18`). - **Caveats / not-in-source**: nothing in this type prevents the password strings from reaching a log. That is an operational convention (PII masking plus the "never log the body" habit), not a compile-time or runtime guarantee. @@ -1953,11 +2036,11 @@ live in later groups; this chapter is the engine those endpoints call into. has a real bug hiding in it: the app-bar language switcher knows only the culture and the theme toggle knows only the theme, so whichever fires last would send `null` for the other field and silently erase the user's other choice. The doc comment states the rule that removes the bug - (`ChangePreferencesRequest.cs:3-6`): a `null` field leaves that preference unchanged, so each + (`ChangePreferencesRequest.cs:3-7`): a `null` field leaves that preference unchanged, so each control can persist its own field in isolation. The rule is honored in exactly one place, the shared handler's `command.Request.Culture ?? user.PreferredCulture` / `command.Request.Theme ?? user.PreferredTheme` coalesce - (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:53-55`), + (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:54-55`), which is why the contract can afford to be this terse. The two preferences themselves come from [ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html) (culture) and [ADR-028](https://ivanball.github.io/docs/adr/028-dark-theme-mode.html) (theme). @@ -1969,13 +2052,13 @@ live in later groups; this chapter is the engine those endpoints call into. - **Why it's built this way**: the payload record was byte-identical in both applications' Identity modules and was hoisted here, while the *command* record stayed app-side because ADC marks it `ICacheInvalidating` and Store does not. That split is spelled out in the handler base's remarks - (`ChangePreferencesHandlerBase.cs:16-20`), and it is a good illustration of the framework's hoisting + (`ChangePreferencesHandlerBase.cs:16-22`), and it is a good illustration of the framework's hoisting rule: share the shape, leave the per-app policy behind. -- **Where it's used**: the body of the shared `PUT auth/preferences` endpoint - (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:112-119`), +- **Where it's used**: the body of the shared `PUT preferences` endpoint + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:112-118`), which hands it to the app's command through the abstract `CreateChangePreferencesCommand` factory - (`UserAccountAuthControllerBase.cs:77-79,125-127`); the generic constraint that ties the two together - is `where TChangePreferencesCommand : IUserScopedCommand` + (`UserAccountAuthControllerBase.cs:77,126`); the generic constraint that ties the two together is + `where TChangePreferencesCommand : IUserScopedCommand` (`UserAccountAuthControllerBase.cs:48`). It is consumed by [`ChangePreferencesHandlerBase`](group-14-module-system-composition.md#changepreferenceshandlerbasetuser-tcommand) and carried by each app's @@ -1988,36 +2071,62 @@ live in later groups; this chapter is the engine those endpoints call into. [`Result`](group-01-result-error-handling.md#result) the handler propagates. Note also that the Blazor UI does **not** send this exact type: `ApiUserPreferenceWriter` declares its own private `UserPreferencesRequest(string? Culture, string? Theme)` wire record - (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ApiUserPreferenceWriter.cs:29,64-65`), so + (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ApiUserPreferenceWriter.cs:29,65`), so the two shapes agree by convention rather than by a shared reference. -### IcsEvent -> MMCA.Common.Shared · `MMCA.Common.Shared.Calendars` · `MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsEvent.cs:15` · Level 0 · record (sealed) - -- **What it is**: one calendar entry consumed by [`IcsCalendarBuilder`](#icscalendarbuilder): a - positional `sealed record` carrying a stable UID, a title, start and end instants, and optional - description and location (`MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsEvent.cs:15-21`). -- **Depends on**: nothing first-party; `System.DateTimeOffset` (BCL). -- **Concept introduced, UTC by contract.** `[Rubric §9, API & Contract Design]` assesses whether a - contract is unambiguous about what the caller must supply. Unlike the auth siblings this is a - `record` (reference type), not a `record struct`, because it carries optional members and travels as - a collection. The load-bearing rule is in the doc comment (`IcsEvent.cs:3-8`): `StartsAtUtc` and - `EndsAtUtc` are UTC by contract, so converting a wall-clock time in the event's IANA time zone to - UTC is the *caller's* job. That single rule lets the builder emit `Z`-suffixed timestamps and skip - RFC 5545's error-prone VTIMEZONE machinery entirely, and it pushes the one genuinely hard problem - (daylight-saving transitions) to the one layer that knows the event's zone. -- **Walkthrough**: six positional parameters (`IcsEvent.cs:15-21`): `Uid` (globally unique and stable, - which is how calendar apps de-duplicate a reimport instead of creating a second entry, documented at - `IcsEvent.cs:9`), `Summary`, `StartsAtUtc`, `EndsAtUtc`, and the two nullable optionals - `Description = null` and `Location = null` (`IcsEvent.cs:20-21`). -- **Where it's used**: passed as an `IReadOnlyCollection` to - [`IcsCalendarBuilder`](#icscalendarbuilder)'s `Build`. In MMCA.ADC, - [`CalendarExportMapper`](group-18-conference-application.md#calendarexportmapper) converts a session - plus its event into one entry - (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/CalendarExportMapper.cs:31,37-43`), - performing exactly the wall-clock-to-UTC conversion the contract demands in its own `ToUtc` helper, - with the DST discipline written out (invalid spring-forward times shift ahead one hour, ambiguous - fall-back times take the standard offset, `CalendarExportMapper.cs:47-56`). +### ForgotPasswordRequest +> MMCA.Common.Shared · `MMCA.Common.Shared.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ForgotPasswordRequest.cs:8` · Level 0 · record struct (readonly) + +- **What it is**: a single-field request `(string Email)` that starts a password reset + (`MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ForgotPasswordRequest.cs:3-9`). +- **Depends on**: nothing first-party. It pairs with + [`ResetPasswordRequest`](#resetpasswordrequest), which completes the flow this one starts. +- **Concept introduced, the anti-enumeration contract.** `[Rubric §11, Security]` assesses whether an + endpoint leaks facts an attacker can harvest, and `[Rubric §9, API & Contract Design]` assesses + whether a contract's shape matches the answer it is allowed to give. A password-reset entry point is + the classic account-enumeration oracle: if "no such user" answers differently from "email sent", an + attacker can test an address list against your user base for free. The doc comment on this one-field + record records the countermeasure as part of the contract (`ForgotPasswordRequest.cs:3-6`): the + response is *always* accepted, so the payload carries no signal about whether the address belongs to + an account. The rule is not aspirational, it is implemented in three coordinated places: + - the request validator checks only the **shape** of the address, and its doc comment says exactly + why it stops there, because a 400 on an unknown address would be the oracle the always-accepted + response exists to close + (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ForgotPasswordRequestValidator.cs:6-16`); + - the handler returns `Result.Success()` for a malformed address, an address with no account, a + throttled request, and a failed send alike, logging the real reason instead of returning it + (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:57-99`); + - the endpoint answers `202 Accepted` on every well-formed request + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:79,92`). +- **Walkthrough**: one positional `string Email` (`ForgotPasswordRequest.cs:8-9`); no body, no + validation attributes, no normalization. Normalizing the address is the handler's job, through + `Email.Create(command.Request.Email)` (`ForgotPasswordHandlerBase.cs:57`), which is what lets the + DTO stay a raw wire shape while the value object owns the parsing rules. +- **Why it's built this way**: + [ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html) records the + cache-backed reset design this request opens. Keeping the payload to a single field means there is + nothing else for an attacker to probe, and keeping the "always accepted" promise in the *type's* doc + comment puts it where a reader meets it before the handler. +- **Where it's used**: bound as the body of the anonymous, rate-limited `POST forgot-password` action + on + [`PasswordResetAuthControllerBase`](group-12-api-hosting-mapping.md#passwordresetauthcontrollerbasetforgotpasswordcommand-tresetpasswordcommand) + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:75-84`), + which turns it into the app's command through an abstract factory (`:61`) constrained to + [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) + (`:46`); shape-validated by + [`ForgotPasswordRequestValidator`](#forgotpasswordrequestvalidator); handled by + [`ForgotPasswordHandlerBase`](group-14-module-system-composition.md#forgotpasswordhandlerbasetuser-tcommand); + posted by [`AuthUIService`](group-15-common-ui-framework.md#authuiservice)'s + `RequestPasswordResetAsync`, deliberately over a bearer-free client so a signed-in caller does not + bind the reset to the current session + (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthUIService.cs:289-293`). +- **Caveats / not-in-source**: only MMCA.ADC wires this vertical today. ADC has a + [`ForgotPasswordCommand`](group-24-identity-module.md#forgotpasswordcommand) + (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ForgotPassword/ForgotPasswordCommand.cs:12-13`) + and a derived controller + (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:36`); + MMCA.Store has no forgot-password command or controller in the tree, so the framework half ships + unused there. ### IPermissionRegistry > MMCA.Common.Shared · `MMCA.Common.Shared.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/IPermissionRegistry.cs:13` · Level 0 · interface @@ -2072,7 +2181,7 @@ live in later groups; this chapter is the engine those endpoints call into. (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:73`), which is reached through [`AuthControllerBase.LoginAsync`](group-12-api-hosting-mapping.md#authcontrollerbase) - (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:59-60`). + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:61-65`). ### OAuthCodeExchangeRequest > MMCA.Common.Shared · `MMCA.Common.Shared.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/OAuthCodeExchangeRequest.cs:11` · Level 0 · record struct (readonly) @@ -2096,14 +2205,16 @@ live in later groups; this chapter is the engine those endpoints call into. putting it in a URL acceptable. [`OAuthControllerBase.ExchangeAsync`](group-12-api-hosting-mapping.md#oauthcontrollerbase) rejects a blank code - (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:142-145`), + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:144-147`), looks the code up in [`ICacheService`](group-09-caching.md#icacheservice) - (`OAuthControllerBase.cs:151`), and then removes it so a replayed code cannot mint a second token - pair (`OAuthControllerBase.cs:157-158`); an unknown, burned, or expired code all return the same - HTTP 400 with a deliberately non-specific message (`OAuthControllerBase.cs:163-169`). + (`OAuthControllerBase.cs:149-153`), and then removes it so a replayed code cannot mint a second + token pair (`OAuthControllerBase.cs:159-160`); an unknown, burned, or expired code all return the + same HTTP 400 with a deliberately non-specific message (`OAuthControllerBase.cs:165-170`). The + action is also marked `[NonIdempotent]` with the reason inline: replaying a stored response would + defeat the burn and let a leaked code mint the same tokens again (`OAuthControllerBase.cs:137`). - **Where it's used**: the body of the OAuth `exchange` endpoint - (`OAuthControllerBase.cs:135-140`), called by the UI's `/auth/oauth-complete` page after the - provider redirect lands (`OAuthControllerBase.cs:125,128-131`). + (`OAuthControllerBase.cs:136-142`), called by the UI's `/auth/oauth-complete` page after the + provider redirect lands (`OAuthControllerBase.cs:126,130-131`). ### RefreshTokenRequest > MMCA.Common.Shared · `MMCA.Common.Shared.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/RefreshTokenRequest.cs:9` · Level 0 · record struct (readonly) @@ -2124,9 +2235,59 @@ live in later groups; this chapter is the engine those endpoints call into. [`AuthenticationServiceBase.RefreshTokenAsync`](#authenticationservicebasetuser) (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:218`), which rejects an unreadable token or missing claims with an `Auth.InvalidToken` failure before it ever - looks at the refresh token (`AuthenticationServiceBase.cs:233-241`); exposed by + looks at the refresh token (`AuthenticationServiceBase.cs:234,241`); exposed by [`AuthControllerBase.RefreshAsync`](group-12-api-hosting-mapping.md#authcontrollerbase) - (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:99-100`). + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:103-104`). + +### ResetPasswordRequest +> MMCA.Common.Shared · `MMCA.Common.Shared.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ResetPasswordRequest.cs:9` · Level 0 · record struct (readonly) + +- **What it is**: `(string Email, string Token, string NewPassword)`, the payload that completes a + password reset by redeeming the single-use token that + [`ForgotPasswordRequest`](#forgotpasswordrequest) caused to be mailed + (`MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ResetPasswordRequest.cs:3-12`). +- **Depends on**: nothing first-party; the `readonly record struct` shape from + [`AuthenticationResponse`](#authenticationresponse). +- **Concept, the three-field redemption payload and the single collapsed failure.** `[Rubric §11, + Security]`: the address is carried alongside the token so the server can verify that the token was + issued *for that address* rather than trusting the token in isolation, which is what the handler's + `ValidateAndConsumeAsync(request.Email, request.Token, ...)` call checks + (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:61-63`). + The anti-enumeration discipline that governs the forgot half continues here in a different form: + an unknown, expired, mismatched or attempt-capped token and a vanished account all collapse to one + `Auth.InvalidResetToken` 401 with the same message, so the response distinguishes none of them + (`ResetPasswordHandlerBase.cs:17-22,95-99`). One ordering decision is worth internalizing: the token + is consumed *before* the save, and the comment says why (`ResetPasswordHandlerBase.cs:58-60`), + because leaving it live until the write succeeds would open a replay window in which the same token + is redeemed twice; a token burned by a later invariant failure costs the user one more reset + request, which is the cheaper failure. +- **Walkthrough**: three positional parameters (`ResetPasswordRequest.cs:9-12`); no body. The doc + comment repeats the never-logged rule for `NewPassword` (`ResetPasswordRequest.cs:8`), the same + convention [`LoginRequest`](#loginrequest) states. +- **Why it's built this way**: the new password goes through the *same* + [`StrongPasswordRules`](group-06-validation.md#strongpasswordrulest) that registration and + change-password use, so a reset cannot become a way around the complexity policy + (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ResetPasswordRequestValidator.cs:7-23`). + Reusing one rule set rather than restating it per endpoint is the reason the policy cannot drift. + [ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html) covers the + token side. +- **Where it's used**: bound as the body of the anonymous, rate-limited `POST reset-password` action + on + [`PasswordResetAuthControllerBase`](group-12-api-hosting-mapping.md#passwordresetauthcontrollerbasetforgotpasswordcommand-tresetpasswordcommand), + which answers 204 on success (`PasswordResetAuthControllerBase.cs:99-117`); shape-validated by + [`ResetPasswordRequestValidator`](#resetpasswordrequestvalidator); handled by + [`ResetPasswordHandlerBase`](group-14-module-system-composition.md#resetpasswordhandlerbasetuser-tcommand), + which hashes the new password, lets the aggregate apply its own invariants, saves, and then clears + the account's lockout so a user who reset *because* of a lockout is not still locked out + (`ResetPasswordHandlerBase.cs:79-89`); posted by + [`AuthUIService`](group-15-common-ui-framework.md#authuiservice)'s `ResetPasswordAsync` + (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthUIService.cs:316-319`). ADC + carries it in a [`ResetPasswordCommand`](group-24-identity-module.md#resetpasswordcommand) marked + `ICacheInvalidating` + (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordCommand.cs:14-15`). +- **Caveats / not-in-source**: as with the forgot half, MMCA.Store has no reset-password command or + controller in the tree; ADC is the only app that wires this vertical + (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:39`). ### RoleNames > MMCA.Common.Shared · `MMCA.Common.Shared.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/RoleNames.cs:12` · Level 0 · class (static) @@ -2193,76 +2354,25 @@ live in later groups; this chapter is the engine those endpoints call into. field-by-field (`MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/GetUserPreferencesHandlerTests.cs:46,60`). - **Why it's built this way**: like its request twin, the response record was byte-identical in both - applications' Identity modules and was hoisted into Shared, which is what let the read handler become - a shared base generic only in the `User` aggregate rather than in the query and the response too - (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandlerBase.cs:10-14`). + applications' Identity modules and was hoisted into Shared, which is what let the read side become a + shared base generic only in the `User` aggregate rather than in the query and the response too + (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandlerBase.cs:21`). - **Where it's used**: produced by [`GetUserPreferencesHandlerBase`](group-14-module-system-composition.md#getuserpreferenceshandlerbasetuser) - from the aggregate's `PreferredCulture`/`PreferredTheme` - (`GetUserPreferencesHandlerBase.cs:44`), against a + from the aggregate's `PreferredCulture`/`PreferredTheme` (`GetUserPreferencesHandlerBase.cs:44`), + against a [`GetUserPreferencesQuery`](group-14-module-system-composition.md#getuserpreferencesquery); declared - as the 200 response of the shared `GET auth/preferences` endpoint on + as the 200 response of the shared `GET preferences` endpoint on [`UserAccountAuthControllerBase`](group-12-api-hosting-mapping.md#useraccountauthcontrollerbasetchangepasswordcommand-tchangepreferencescommand) - (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:140-142`). + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:138-140`). - **Caveats / not-in-source**: the handler reads through `GetReadRepository`, not the write repository, and the remarks note this was a deliberate correction of a disagreement between the two app copies (ADC read, Store write), so Store gained a no-tracking read on adoption - (`GetUserPreferencesHandlerBase.cs:15-19,39`). As with the request twin, the Blazor client does not + (`GetUserPreferencesHandlerBase.cs:16-20,39`). As with the request twin, the Blazor client does not deserialize into this type: `ApiUserPreferenceReader` reads `auth/preferences` into its own UI-side `UserPreferences` record and falls back to an empty one for anonymous users or any error (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ApiUserPreferenceReader.cs:18,39-42`). -### IcsCalendarBuilder -> MMCA.Common.Shared · `MMCA.Common.Shared.Calendars` · `MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsCalendarBuilder.cs:12` · Level 1 · class (static) - -- **What it is**: a dependency-free RFC 5545 iCalendar writer for "add to calendar" exports, turning a - product id and a collection of [`IcsEvent`](#icsevent)s into a complete `VCALENDAR` string - (`MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsCalendarBuilder.cs:6-12`). -- **Depends on**: [`IcsEvent`](#icsevent); `System.Text.StringBuilder` and - `System.Globalization.CultureInfo` (BCL). -- **Concept introduced, the deliberately minimal, deterministic protocol writer.** `[Rubric §15, Best - Practices & Code Quality]` assesses focused, standards-correct implementations, and `[Rubric §32, - Dependency & Supply-Chain]` assesses whether a dependency is worth its cost. Rather than pull in a - full iCalendar package, the builder implements exactly the RFC 5545 subset every calendar app - imports reliably: UTC-only timestamps (no VTIMEZONE), TEXT escaping, CRLF line endings, and 75-octet - line folding (`IcsCalendarBuilder.cs:7-10`). It is also **deterministic**: the caller supplies - `dtStamp` (`IcsCalendarBuilder.cs:21-22`), so identical inputs produce byte-identical output, which - is what makes the export cacheable and lets a test assert on the exact document. -- **Walkthrough**: one public entry point and four private helpers, plus the `MaxLineOctets = 75` - constant (`IcsCalendarBuilder.cs:14`). - `Build(productId, events, dtStamp)` (`IcsCalendarBuilder.cs:22`) guards its inputs - (`IcsCalendarBuilder.cs:24-25`), writes the calendar header (`BEGIN:VCALENDAR`, `VERSION:2.0`, - `PRODID`, `CALSCALE:GREGORIAN`, `METHOD:PUBLISH`, `IcsCalendarBuilder.cs:28-32`), appends each entry - in the collection's own order, and closes the document (`IcsCalendarBuilder.cs:34-40`). - `AppendEvent` (`IcsCalendarBuilder.cs:43`) writes a `VEVENT` block with `UID`, `DTSTAMP`, `DTSTART`, - `DTEND`, and `SUMMARY` (`:45-50`), then `DESCRIPTION` and `LOCATION` only when non-blank - (`IcsCalendarBuilder.cs:52-60`), so an absent optional produces no property line at all rather than - an empty one. `FormatUtc` (`IcsCalendarBuilder.cs:65-66`) renders an instant through - `instant.UtcDateTime` with the invariant culture, which is what turns the [`IcsEvent`](#icsevent) - UTC-by-contract rule into a literal `Z`-suffixed timestamp. `EscapeText` - (`IcsCalendarBuilder.cs:69-76`) applies RFC 5545 section 3.3.11 TEXT escaping, and the order - matters: backslash is escaped first (`IcsCalendarBuilder.cs:71`) so the escapes it later introduces - are not double-escaped, and all three newline forms collapse to a literal `\n` (`:74-76`). The - subtlest helper is `AppendLine` (`IcsCalendarBuilder.cs:83`): it folds content lines at 75 octets of - UTF-8, counting octets per character and treating a surrogate pair as one unit - (`IcsCalendarBuilder.cs:89-90`) so a fold can never split a multi-byte character, and it charges the - leading fold space against the continuation line's budget (`IcsCalendarBuilder.cs:94-95`). -- **Why it's built this way**: a static, allocation-light writer with no external dependency keeps - `MMCA.Common.Shared` pure and therefore usable from Blazor WebAssembly, and pushing `dtStamp` to the - caller is the single choice that makes the output deterministic and testable. -- **Where it's used**: MMCA.ADC's calendar exports: - [`ExportSessionCalendarHandler`](group-18-conference-application.md#exportsessioncalendarhandler) - for one session - (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportSessionCalendarHandler.cs:59-62`) - and [`ExportEventCalendarHandler`](group-18-conference-application.md#exporteventcalendarhandler) - for a whole event - (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportEventCalendarHandler.cs:54,61`). - Both pass ADC's single `PRODID` constant - (`.../ExportCalendar/CalendarExportMapper.cs:17`). -- **Caveats / not-in-source**: both ADC call sites pass `DateTimeOffset.UtcNow` as `dtStamp` - (`ExportSessionCalendarHandler.cs:62`, `ExportEventCalendarHandler.cs:61`), so the determinism the - builder offers is exercised by the tests rather than by production output. - ### PermissionRegistry > MMCA.Common.Shared · `MMCA.Common.Shared.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/PermissionRegistry.cs:10` · Level 1 · class (sealed) @@ -2335,7 +2445,7 @@ live in later groups; this chapter is the engine those endpoints call into. first resolve, after every module has contributed (`MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:65-81`); modules reach it through `AddPermissions(...)`, which is deliberately safe to call once per module - (`AuthorizationExtensions.cs:47-62`), as MMCA.ADC's Conference, Engagement, and Identity modules + (`AuthorizationExtensions.cs:48-62`), as MMCA.ADC's Conference, Engagement, and Identity modules each do (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/DependencyInjection.cs:41-51`, `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/DependencyInjection.cs:51-54`, @@ -2363,25 +2473,30 @@ live in later groups; this chapter is the engine those endpoints call into. deliberately does **not** implement `IEquatable` (`RoleValue.cs:17-23`): the remarks cite Sonar S4035, an unsealed `IEquatable` breaks the equality contract for subclasses. Instead equality is the `object.Equals` override, type-guarded so two roles are equal only when they are the *same - concrete type* with the same case-insensitive value (`RoleValue.cs:78-81`), and a sealed derived type + concrete type* with the same case-insensitive value (`RoleValue.cs:90-93`), and a sealed derived type may safely add a strongly-typed `IEquatable` plus `==`/`!=` on top, which ADC's `UserRole` - does (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/UserRole.cs:17,78-84`). It - lives in `MMCA.Common.Shared` so it stays dependency-free and usable from Blazor WebAssembly as well - as Domain, with each app deriving a concrete role type that fixes its own role set + does (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/UserRole.cs:17,78`). It lives + in `MMCA.Common.Shared` so it stays dependency-free and usable from Blazor WebAssembly as well as + Domain, with each app deriving a concrete role type that fixes its own role set (`RoleValue.cs:11-16`). - **Walkthrough**: a get-only `Value` (`RoleValue.cs:28`) set by the protected constructor (`RoleValue.cs:32`). The static `Validate(role, knownRoles, source)` (`RoleValue.cs:42`) returns `Result.Success()` when the role is in the app's known set, otherwise a [`Result`](group-01-result-error-handling.md#result) failure carrying an [`Error`](group-01-result-error-handling.md#error) of kind `Invariant` coded `User.Role.Invalid` - (`RoleValue.cs:46-52`); note the `role ?? string.Empty` coalesce (`RoleValue.cs:46`), which turns a - null role into a clean failure rather than a `NullReferenceException`. The protected generic - `BuildLookup(params roles)` (`RoleValue.cs:63`) freezes the supplied singletons into a - case-insensitive `FrozenDictionary` keyed by `Value` (`RoleValue.cs:68-71`), so a derived type can - back its `FromString`/`IsValid` members with interned instances instead of re-allocating. `ToString` - returns the value (`RoleValue.cs:75`), and `GetHashCode` uses the ordinal-ignore-case hash - (`RoleValue.cs:84`) so it stays consistent with `Equals`, which is the contract a dictionary key - depends on. + (`RoleValue.cs:46-52`). The membership test itself is the private `IsKnown` + (`RoleValue.cs:63-65`), and it is more careful than it first looks: the fast path is the supplied + set's own `Contains` (correct and O(1) for the intended `OrdinalIgnoreCase` sets, with a + `role ?? string.Empty` coalesce so a null role becomes a clean failure rather than a + `NullReferenceException`), and a miss falls back to an explicit case-insensitive scan so that a set + built with the *default ordinal* comparer still validates case-insensitively as the contract + promises (`RoleValue.cs:55-62`). Role sets hold a handful of entries, so the fallback is negligible + and only runs on a miss. The protected generic `BuildLookup(params roles)` + (`RoleValue.cs:75`) freezes the supplied singletons into a case-insensitive `FrozenDictionary` keyed + by `Value` (`RoleValue.cs:80-83`), so a derived type can back its `FromString`/`IsValid` members + with interned instances instead of re-allocating. `ToString` returns the value (`RoleValue.cs:87`), + and `GetHashCode` uses the ordinal-ignore-case hash (`RoleValue.cs:96`) so it stays consistent with + `Equals`, which is the contract a dictionary key depends on. - **Why it's built this way**: the abstract-class-plus-type-guard shape is how you share equality behavior across an open hierarchy of value objects without violating the equality contract, and the S4035 rationale is documented inline so a future reader does not "helpfully" add `IEquatable` to @@ -2393,8 +2508,8 @@ live in later groups; this chapter is the engine those endpoints call into. (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/UserRole.cs:20-33`), and exposes `FromString`/`IsValid` over that frozen lookup (`UserRole.cs:51-65`) plus a case-insensitive `IsOrganizer` for raw claim strings (`UserRole.cs:76`). Store's `UserRole` is a **static class** - rather than a subclass: it fixes Admin and Customer as string constants and calls the shared - `RoleValue.Validate` helper for its `IsValid` + rather than a subclass: it fixes Admin and Customer as string constants over an + `OrdinalIgnoreCase` set and calls the shared `RoleValue.Validate` helper for its `IsValid` (`MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Domain/Users/UserRole.cs:14,26-30,37`), so it inherits the rule set (case-insensitive membership, the `User.Role.Invalid` code) without inheriting the type. Both key their known-role sets off the [`RoleNames`](#rolenames) constants. @@ -2427,776 +2542,612 @@ live in later groups; this chapter is the engine those endpoints call into. (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:134`) and the `register` endpoint on [`AuthControllerBase`](group-12-api-hosting-mapping.md#authcontrollerbase) - (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:81-82`), plus + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:84-85`), plus each app's register form. +### IcsEvent + +> MMCA.Common.Shared · `MMCA.Common.Shared.Calendars` · `MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsEvent.cs:15` · Level 0 · record + +- **What it is**: one calendar entry handed to + [`IcsCalendarBuilder`](#icscalendarbuilder): a stable `Uid`, a `Summary`, a UTC start and end, and + two optional strings for description and location + (`MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsEvent.cs:15-21`). +- **Depends on**: nothing first-party; `System.DateTimeOffset` (BCL). +- **Concept introduced, the UTC-only calendar contract.** `[Rubric §9, API & Contract Design]` + assesses whether a contract states its own invariants rather than leaving them to convention. The + invariant here is written into the type's own doc comment: "Times are UTC by contract" + (`IcsEvent.cs:4`). RFC 5545 lets a calendar carry local times paired with a `VTIMEZONE` block that + restates the zone's DST rules inside the document; getting that block right (and keeping it right + as tzdata moves) is a well-known source of bugs. By declaring the two timestamps + `DateTimeOffset` and requiring them to already be UTC instants, this record pushes the wall-clock + to UTC conversion onto the caller, which is where the zone knowledge actually lives, and lets the + builder emit plain `Z`-suffixed timestamps with no `VTIMEZONE` machinery at all (`IcsEvent.cs:5-7`). + The `Uid` carries a second contract: calendar clients de-duplicate re-imports by it, so it must be + globally unique and *stable* across exports of the same thing (`IcsEvent.cs:9`). +- **Walkthrough**: a positional `sealed record` with six parameters and no body. `Uid`, `Summary`, + `StartsAtUtc`, `EndsAtUtc` are required by position; `Description` and `Location` default to `null` + (`IcsEvent.cs:16-21`), which is how the builder decides to omit the corresponding lines entirely + rather than emit an empty one. A `record` (reference type) rather than the `readonly record struct` + that the auth DTOs in this group use: entries are built into a collection and enumerated once, so + there is no per-call allocation to avoid. +- **Why it's built this way**: the framework ships no calendar NuGet dependency, so the shape of an + entry is the framework's to define. Keeping it to the six fields every calendar client honors is + the same minimal-subset judgement the builder documents at + `MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsCalendarBuilder.cs:7-10`. No ADR governs + calendar export; the decision lives in these two files' doc comments. +- **Where it's used**: ADC's Conference module builds entries from sessions in + [`CalendarExportMapper`](group-18-conference-application.md#calendarexportmapper), which does the + event-zone to UTC conversion the contract demands and composes the `Uid` as + `session-{id}@atldevcon` + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/CalendarExportMapper.cs:31-44`). + The mapped entries reach + [`ExportSessionCalendarHandler`](group-18-conference-application.md#exportsessioncalendarhandler) + (`ExportSessionCalendarHandler.cs:59-61`) and + [`ExportEventCalendarHandler`](group-18-conference-application.md#exporteventcalendarhandler) + (`ExportEventCalendarHandler.cs:54,61`). +- **Caveats / not-in-source**: nothing in the type enforces that `StartsAtUtc` and `EndsAtUtc` really + carry a zero offset, that the end follows the start, or that the `Uid` is unique. All three are + contract-by-documentation; the only enforcement is the mapper that produces them. + ### IdempotencyHeaders > MMCA.Common.Shared · `MMCA.Common.Shared.Http` · `MMCA.Common/Source/Core/MMCA.Common.Shared/Http/IdempotencyHeaders.cs:13` · Level 0 · class (static) -- **What it is**: two `const string` header names for the idempotency protocol, the request header a - client sends to make a write repeatable and the response header a server sets when it replayed a - stored answer instead of executing again - (`MMCA.Common/Source/Core/MMCA.Common.Shared/Http/IdempotencyHeaders.cs:13-26`). -- **Depends on**: nothing. No usings, no first-party types, no externals. -- **Concept introduced, the shared-literal constant as a contract between two packages that cannot - see each other.** `[Rubric §9, API & Contract Design]` assesses whether the wire contract is - expressed once and consistently; `[Rubric §16, Maintainability & Evolvability]` assesses whether a - change lands in one place. The remarks - (`MMCA.Common/Source/Core/MMCA.Common.Shared/Http/IdempotencyHeaders.cs:7-12`) state the exact - reason this type sits in `Shared` rather than next to the filter that consumes it: the server-side - reader lives in `MMCA.Common.API` and the client-side writer lives in `MMCA.Common.UI`, and by the - layering rules those two packages have no reference to one another (`UI` depends on `Shared` only, - for Blazor WebAssembly compatibility). `Shared` is the one assembly both can see, so it is the only - place a single literal can serve both ends. Hard-coding `"Idempotency-Key"` twice would compile - perfectly and break silently the day one side is edited: a typo on the client means the server never - sees a key and every retry executes again. -- **Walkthrough**: two members and no behavior. - - `IdempotencyKey = "Idempotency-Key"` (line 19), the client-provided key. The doc comment (lines - 15-18) records the protocol contract: a server that has already seen the key replays the original - response rather than executing the action a second time. - - `IdempotentReplay = "X-Idempotent-Replay"` (line 25), appended by the server when the body it - returned came from the idempotency cache rather than a fresh execution, so a client can tell a - deduplicated answer from an original one. - - Both are `const`, not `static readonly`, so they are usable in attribute arguments and in `switch` - patterns that require compile-time constants, the same choice - [`AuthClaimTypes`](#authclaimtypes) and [`RoleNames`](#rolenames) make. -- **Why it's built this way**: [ADR-017](https://ivanball.github.io/docs/adr/017-request-idempotency.html) - defines idempotency as a client-supplied-key protocol at the inbound HTTP edge, which only works if - both ends agree on the header spelling. Putting the literal in `Shared` makes that agreement a - compile-time fact instead of a convention. -- **Where it's used**: on the server, - [`IdempotencyFilter`](group-12-api-hosting-mapping.md#idempotencyfilter) re-exposes it as a public - `IdempotencyKeyHeader` property - (`MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:72`), reads the - incoming header (`IdempotencyFilter.cs:167`), and appends `X-Idempotent-Replay: true` on a replay - (`IdempotencyFilter.cs:387`). On the client, - [`EntityServiceBase`](group-15-common-ui-framework.md#entityservicebasetentitydto-tidentifiertype) - sets it as a default request header on the `HttpClient` that serves every retry attempt of one - logical operation - (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/EntityServiceBase.cs:193-199`), which is - what makes the retries deduplicate instead of creating extra records. ADC's live-layer UI services - do the same per call - (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/LivePollUIService.cs:96` and - `:143`, - `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionQuestionUIService.cs:75`). +- **What it is**: the two HTTP header names of the idempotency protocol, as `const string`s: + `Idempotency-Key` (the request header a client sends) and `X-Idempotent-Replay` (the response + header a server appends when it served a cached body) + (`MMCA.Common/Source/Core/MMCA.Common.Shared/Http/IdempotencyHeaders.cs:19,25`). +- **Depends on**: nothing. +- **Concept introduced, the shared wire-literal.** `[Rubric §16, Maintainability]` assesses whether a + fact that two components must agree on has exactly one home. `[Rubric §9, API & Contract Design]` + assesses whether the protocol between client and server is expressed explicitly. Both ends of this + protocol are first-party but live in packages that do not reference each other: the filter that + reads the key ships in `MMCA.Common.API`, the service bases that write it ship in `MMCA.Common.UI`. + The doc comment states the consequence plainly: "Hard-coding the string in both places is exactly + the drift this constant exists to prevent" (`IdempotencyHeaders.cs:8-12`). Putting the literal in + `MMCA.Common.Shared`, the one assembly both sides already depend on, is the standard placement rule + for cross-layer constants in this framework and is the same reasoning that puts the auth request + DTOs there. +- **Walkthrough**: a `static class` with two `const string` fields and nothing else + (`IdempotencyHeaders.cs:13-26`). `const` rather than `static readonly` so the values can appear in + attribute arguments and constant patterns, matching + [`AuthClaimTypes`](#authclaimtypes) and [`RoleNames`](#rolenames) in this group. +- **Why it's built this way**: + [ADR-017](https://ivanball.github.io/docs/adr/017-request-idempotency.html) defines the protocol: + the client supplies the key, and a server that replays a cached response adds + `X-Idempotent-Replay: true` so the caller can tell a replay from a fresh execution + (`Website/docs-src/adr/017-request-idempotency.md:31,46`). +- **Where it's used**: server side, [`IdempotencyFilter`](group-12-api-hosting-mapping.md#idempotencyfilter) + re-exports the request header name as a public property + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:72`), reads it + in the one helper both filter stages share (`IdempotencyFilter.cs:167`), and appends the replay + header when it serves a cached response (`IdempotencyFilter.cs:387`); + [`NotificationsController`](group-10-notifications.md#notificationscontroller) reads the same + request header directly + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/NotificationsController.cs:62`). + Client side, [`EntityServiceBase`](group-15-common-ui-framework.md#entityservicebasetentitydto-tidentifiertype) + attaches a generated key on retried writes + (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/EntityServiceBase.cs:199`), as do ADC's + [`SessionQuestionUIService`](group-23-engagement-live-layer.md#sessionquestionuiservice) + (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionQuestionUIService.cs:75`) + and [`LivePollUIService`](group-23-engagement-live-layer.md#livepolluiservice) + (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/LivePollUIService.cs:96,143`). ### PrivacyFeatures > MMCA.Common.Shared · `MMCA.Common.Shared.Privacy` · `MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/PrivacyFeatures.cs:6` · Level 0 · class (static) -- **What it is**: the feature-flag name space for the privacy (data-subject rights) surface. One - member today: `DataExport = "Privacy.DataExport"`, the flag that turns the data-subject export - endpoint on - (`MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/PrivacyFeatures.cs:6-10`). -- **Depends on**: nothing. No usings, no first-party types, no externals. Same reason as - [`IdempotencyHeaders`](#idempotencyheaders): a flag name has to be nameable from the layer that - gates on it and from the configuration a host writes, so it lives at the bottom of the stack. -- **Concept introduced, the flag name as a compile-time symbol rather than a magic string.** - `[Rubric §10, Cross-Cutting Concerns]` assesses whether a concern like feature gating is expressed - once instead of restated per call site; - `[Rubric §30, Compliance / Privacy / Data Governance]` assesses whether privacy-affecting surfaces - are deliberately controlled rather than always-on. `Microsoft.FeatureManagement` matches flags by - **string**, both in the `FeatureManagement` configuration section and in the `[FeatureGate("...")]` - attribute, so nothing in the compiler stops a host from enabling `"Privacy.DataExport"` while the - controller gates on `"PrivacyDataExport"`: the endpoint would simply stay 404 with no error - anywhere. Publishing the literal as a `const` makes the attribute side of that pair a symbol the - compiler checks, and it is `const` (not `static readonly`) precisely so it can be used as an - attribute argument, which `static readonly` cannot - (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Privacy/DataExportControllerBase.cs:59`). - The configuration side stays a string a host types, and no source in this tree types it. -- **Walkthrough**: one member. - - `DataExport = "Privacy.DataExport"` (line 9), documented as the flag controlling the data-subject - export (DSAR) endpoint (line 8). The value is dotted, matching the flag-name convention the - feature-management configuration section uses. -- **Why it's built this way**: [ADR-031](https://ivanball.github.io/docs/adr/031-feature-flag-management.html) - settles on `Microsoft.FeatureManagement` and enforces one flag name on two surfaces (a - `[FeatureGate]` on controllers, `IFeatureGated` on CQRS handlers), with a disabled feature - answering **404, not 403**, so a turned-off capability is indistinguishable from one that was never - deployed. [ADR-076](https://ivanball.github.io/docs/adr/076-data-subject-export.html) then chooses - to ship the whole export endpoint behind that gate, so adopting the framework does not silently - publish a route that returns a complete dossier on a person. -- **Where it's used**: as the argument to the class-level - `[FeatureGate(PrivacyFeatures.DataExport)]` on +- **What it is**: one `const string` naming the feature flag that gates the data-subject export + surface, `Privacy.DataExport` + (`MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/PrivacyFeatures.cs:9`). +- **Depends on**: nothing first-party. +- **Concept introduced, the feature flag as a shared constant.** `[Rubric §30, Compliance / Privacy / + Data Governance]` assesses how the codebase handles data-subject rights and how deliberately those + surfaces are turned on. `[Rubric §10, Cross-Cutting Concerns]` assesses whether concerns like + feature gating are applied uniformly rather than ad hoc. A data-subject access endpoint returns a + complete dossier of one person's personal data, so it is the last endpoint that should default to + reachable. Naming the flag once, in the assembly every layer can see, lets the attribute that + gates the controller and any host configuration that enables it refer to the same string. The + flag's own evaluation is the `Microsoft.FeatureManagement` `[FeatureGate]` attribute, whose + behavior is not this type's concern; see + [ADR-031](https://ivanball.github.io/docs/adr/031-feature-flag-management.html). +- **Walkthrough**: a `static class` containing a single `public const string DataExport = + "Privacy.DataExport";` (`PrivacyFeatures.cs:6-10`). The dotted name is a namespace convention for + the flag key, not C# syntax: it is one opaque string as far as the feature manager is concerned. +- **Why it's built this way**: + [ADR-076](https://ivanball.github.io/docs/adr/076-data-subject-export.html) makes the whole export + capability opt-in, and records the gate explicitly: a host that has not turned the feature on gets + a 404 from the endpoint rather than an unauthorized-looking 403 + (`Website/docs-src/adr/076-data-subject-export.md:116`). +- **Where it's used**: [`DataExportControllerBase`](group-12-api-hosting-mapping.md#dataexportcontrollerbasetquery) - (`DataExportControllerBase.cs:59`, with the rationale in its remarks at `:50-55`), and asserted by - `MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/Privacy/DataExportControllerBaseTests.cs:149`, - which reflects over the attribute so the gate cannot be dropped in a refactor. -- **Caveats / not-in-source**: no `appsettings*.json` in this workspace declares a - `Privacy.DataExport` entry, and no production controller derives from - `DataExportControllerBase` (ADC and Store keep their own earlier export endpoints, see - [`UserDataExportDTO`](#userdataexportdto)). So the flag is defined and gated on, but not currently - enabled by any host in this tree. + carries `[FeatureGate(PrivacyFeatures.DataExport)]` on the class + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Privacy/DataExportControllerBase.cs:59`), + with the rationale in the same file's remarks (`DataExportControllerBase.cs:50-55`). +- **Caveats / not-in-source**: no `appsettings*.json` anywhere in the workspace declares a + `Privacy.DataExport` flag, and neither ADC's nor Store's `UsersController` derives from + `DataExportControllerBase` (both declare their own `ExportAsync` action: + `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:158-161`, + `MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/UsersController.cs:36-39`). + The gate is therefore framework behavior that no deployed endpoint currently exhibits, which + ADR-076 itself records (`Website/docs-src/adr/076-data-subject-export.md:185`). ### Releaser > MMCA.Common.Shared · `MMCA.Common.Shared.Concurrency` · `MMCA.Common/Source/Core/MMCA.Common.Shared/Concurrency/KeyedSemaphoreStripe.cs:78` · Level 0 · record struct (readonly, nested) -- **What it is**: the disposable handle [`KeyedSemaphoreStripe`](#keyedsemaphorestripe) hands back - from `AcquireAsync`; disposing it releases the stripe that was taken +- **What it is**: the handle [`KeyedSemaphoreStripe.AcquireAsync`](#keyedsemaphorestripe) returns. + Disposing it releases the stripe that was taken (`MMCA.Common/Source/Core/MMCA.Common.Shared/Concurrency/KeyedSemaphoreStripe.cs:78-86`). -- **Depends on**: `System.IDisposable` and `SemaphoreSlim` (both BCL). It is nested inside - [`KeyedSemaphoreStripe`](#keyedsemaphorestripe) and only that type can construct it. -- **Concept introduced, the scope-bound lock handle.** `[Rubric §15, Best Practices & Code Quality]` - assesses whether resource lifetimes are expressed so the compiler enforces them. The alternative - shape, `WaitAsync(...)` followed by a `try` / `finally` `Release()` at every call site, puts the - release on the caller and fails open the first time someone forgets or returns early. Returning a - handle instead makes `using` the natural spelling, so the release rides on the scope and survives an - exception in the guarded work (the `AcquireAsync` doc comment says exactly this, - `KeyedSemaphoreStripe.cs:52-56`). `readonly record struct` keeps the handle allocation-free on a - path that runs per cache write and per idempotent POST, which matters because the whole point of the - striped design is to be cheap. `[Rubric §12, Performance & Scalability]` covers that allocation - choice. -- **Walkthrough**: three members. - - `private readonly SemaphoreSlim? _stripe` (line 80): the semaphore to release, deliberately - nullable. - - `internal Releaser(SemaphoreSlim stripe)` (line 82): `internal`, so only the enclosing stripe set - can mint one; there is no public way to fabricate a handle for a semaphore you never took. - - `Dispose()` (line 85): `_stripe?.Release()`. The null-conditional is what makes a - `default(Releaser)` (which a struct always permits, since a struct has no null) a safe no-op - rather than a `NullReferenceException`; the doc comment on line 84 calls that out. -- **Why it's built this way**: a struct handle with an `internal` constructor gives the ergonomics of - `using` with none of the per-acquisition garbage, and the null-tolerant `Dispose` closes the one - hole a value type opens (nobody can construct a broken handle, but the language always allows a - zeroed one). -- **Where it's used**: returned by - [`KeyedSemaphoreStripe.AcquireAsync`](#keyedsemaphorestripe) - (`KeyedSemaphoreStripe.cs:64`) and consumed as a `using` at every call site: - [`IdempotencyFilter`](group-12-api-hosting-mapping.md#idempotencyfilter) - (`MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:208`), - [`CookieSessionRefresher`](#cookiesessionrefresher) - (`MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:102`), - [`MemoryCacheService`](group-09-caching.md#memorycacheservice) - (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/MemoryCacheService.cs:101`, `:112`, - `:132`), the default `GetOrCreateAsync` implementation on - [`ICacheService`](group-09-caching.md#icacheservice) - (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICacheService.cs:112`), and +- **Depends on**: nested inside [`KeyedSemaphoreStripe`](#keyedsemaphorestripe); implements + `System.IDisposable`; wraps a `System.Threading.SemaphoreSlim` (BCL). +- **Concept introduced, the disposable-scope handle over a manual acquire/release pair.** + `[Rubric §15, Best Practices & Code Quality]` assesses whether resource lifetimes are expressed so + the compiler enforces them. A raw `SemaphoreSlim` requires `WaitAsync` and `Release` to be paired + by hand, and the pairing has to survive an exception in between; forgetting the `finally` deadlocks + every later caller on that semaphore permanently. Returning a handle turns the pairing into a + `using` statement, which the compiler expands to a `try/finally` for you. The caller's whole + contract becomes one line, and the doc comment says so: "Await the call inside a `using` statement + so the release happens even when the guarded work throws" + (`KeyedSemaphoreStripe.cs:53-55`). `[Rubric §12, Performance & Scalability]`: making it a + `readonly record struct` means the handle costs one machine word on the stack rather than a heap + allocation on the hot path of every cache read. +- **Walkthrough**: one private field, `SemaphoreSlim? _stripe` (`KeyedSemaphoreStripe.cs:80`), set by + an `internal` constructor so only the enclosing stripe set can hand out a live handle + (`KeyedSemaphoreStripe.cs:82`). `Dispose` is `_stripe?.Release()` (`KeyedSemaphoreStripe.cs:85`). + The null-conditional is load-bearing rather than defensive noise: a struct always has a + parameterless `default` form that no constructor ever ran for, so `default(Releaser).Dispose()` is + reachable C# and must be a no-op instead of a `NullReferenceException`. The doc comment states that + guarantee (`KeyedSemaphoreStripe.cs:84`). +- **Why it's built this way**: synchronous `IDisposable` rather than `IAsyncDisposable` because + `SemaphoreSlim.Release` does not block. Contrast the distributed path, where + [`InProcessDistributedLock`](group-14-module-system-composition.md#inprocessdistributedlock) + returns an `IAsyncDisposable?` + (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Concurrency/InProcessDistributedLock.cs:42`), + because releasing a lock held in a remote store is I/O. +- **Where it's used**: every caller of `AcquireAsync`, always inside a `using`: + [`MemoryCacheService`](group-09-caching.md#memorycacheservice) at + `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/MemoryCacheService.cs:101,112,132`, + [`CookieSessionRefresher`](#cookiesessionrefresher) at + `MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:102`, + [`IdempotencyFilter`](group-12-api-hosting-mapping.md#idempotencyfilter) at + `MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:208`, [`CachingQueryDecorator`](group-05-cqrs-pipeline.md#cachingquerydecoratortquery-tresult) - (`MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingQueryDecorator.cs:89`). + at `MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingQueryDecorator.cs:89`, + and the [`ICacheService`](group-09-caching.md#icacheservice) `GetOrCreateAsync` default + implementation at `MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICacheService.cs:112`. ### UserDataExportSectionDTO -> MMCA.Common.Shared · `MMCA.Common.Shared.Privacy` · `MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:61` · Level 0 · record (sealed) - -- **What it is**: one section of a data-subject export package: the data a single contributor holds - about the subject, plus whether that contributor could be reached at all - (`MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:51-89`). -- **Depends on**: `System.Runtime.Serialization`'s `DataContract` / `DataMember` (BCL, line 1). No - first-party types. It is the element type of [`UserDataExportDTO`](#userdataexportdto)`.Sections`. -- **Concept introduced, the degradation envelope: reporting "not retrieved" as data rather than as an - error.** `[Rubric §29, Resilience & Business Continuity]` assesses whether a partial failure - degrades a response instead of failing it, and `[Rubric §30, Compliance / Privacy / Data - Governance]` assesses whether a legal obligation is actually met under fault. A data-subject access - request is a deadline with a statutory obligation attached, and this document is assembled by - fanning out over contributors that in an extracted topology are **other services**. If any one of - them being unreachable failed the whole export, one peer outage would deny the subject their entire - package. So the shape carries the outcome instead: an unreachable contributor still produces an - envelope, with `Available = false` and a caller-safe reason. The doc comment states the invariant - the reader depends on (lines 67-70): `Available = false` means "incomplete, retry later", **not** - "the subject has no data here". Without that flag those two cases are the same empty payload, and a - subject could be told their record is empty when a service was simply down. - - The second half of the concept is the **caller-safe** reason string (lines 82-86): it explicitly - never carries exception messages, stack traces, connection strings or peer addresses, because this - string is handed to the data subject. `[Rubric §11, Security]` applies: an error surface that leaks - infrastructure detail to an unauthenticated-adjacent audience is a disclosure bug, and the diagnostic - detail belongs in the log instead. The producer honours that split, logging the exception and - substituting a fixed generic reason - (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/ExportUserDataHandlerBase.cs:187-197`). -- **Walkthrough**: four `init`-only properties, no behavior, each with an explicit `[DataMember(Order - = n)]` so the wire order is declared rather than inherited from declaration order. - - `SectionName` (`required`, Order 1, line 65): the stable identifier for the section, for example - "Engagement" or "Sales". - - `Available` (`required`, Order 2, line 72): whether the section was produced successfully. Both - of these are `required`, so a section envelope cannot be constructed without answering the two - questions the reader must have. - - `Data` (Order 3, line 80): the contributor's own payload, or `null` when the section is - unavailable. Typed `object` for the same reason [`UserDataExportDTO`](#userdataexportdto)`.Subject` - is (taught there): the payload shape is owned by the contributor, not by the framework. - - `UnavailableReason` (Order 4, line 88): the short caller-safe explanation, `null` when the section - is available. -- **Why it's built this way**: [ADR-076](https://ivanball.github.io/docs/adr/076-data-subject-export.html) - makes per-section degradation the rule rather than an implementation detail, and the two `required` - members are what stop a producer from emitting an ambiguous envelope. `sealed record` gives value - equality and `init`-only immutability for free, so an assembled package cannot be mutated on its way - out. +> MMCA.Common.Shared · `MMCA.Common.Shared.Privacy` · `MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:61` · Level 0 · record + +- **What it is**: one section of a data-subject export package: a `SectionName`, an `Available` flag, + an opaque `Data` payload, and an `UnavailableReason` + (`MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:61-89`). It is the + envelope around whatever one contributor holds about the subject. +- **Depends on**: nothing first-party; + `System.Runtime.Serialization.DataContractAttribute`/`DataMemberAttribute` (BCL). It is the element + type of [`UserDataExportDTO.Sections`](#userdataexportdto). +- **Concept introduced, "no data" is not the same fact as "not retrieved".** `[Rubric §29, Resilience + & Business Continuity]` assesses how a composite operation behaves when one contributor is down. + `[Rubric §30, Compliance / Privacy / Data Governance]` assesses whether a data-subject right can be + honored under partial failure. A naive export either fails whole when any peer is unreachable + (denying the subject the data that *is* available) or silently omits the failed section (telling + the subject, falsely, that nothing is held there). This envelope refuses both: a section that could + not be produced is still present in the document, reporting `Available = false`, and the doc + comment records the distinction the reader must draw: false "means the section is incomplete and + the export can be retried later; it does not mean the subject has no data here" + (`UserDataExportDTO.cs:68-69`). This is the shape + [ADR-096](https://ivanball.github.io/docs/adr/096-best-effort-side-effects.html) calls best-effort, + applied to a read. +- **Walkthrough**: four `init`-only properties, ordered explicitly with `[DataMember(Order = n)]` + (`UserDataExportDTO.cs:64,71,79,87`) so the serialized field order is a stated part of the + contract rather than a reflection accident. `SectionName` and `Available` are `required` + (`UserDataExportDTO.cs:65,72`), so a section envelope cannot be constructed without answering both + questions. `Data` is typed `object?` for the same reason `UserDataExportDTO.Subject` is: the + framework owns the envelope, the contributor owns the payload shape, and `System.Text.Json` + serializes an `object`-typed property by its runtime type (`UserDataExportDTO.cs:74-78`). The + fourth property carries the section's most security-sensitive rule: + `UnavailableReason` is "a short, caller-safe explanation" that "never carries exception messages, + stack traces, connection strings, or peer addresses: this string is handed to the data subject" + (`UserDataExportDTO.cs:82-86`). +- **Why it's built this way**: + [ADR-076](https://ivanball.github.io/docs/adr/076-data-subject-export.html) settled the three + questions neither app had answered, one of which was exactly "what an export does when one + contributing source is unavailable" + (`Website/docs-src/adr/076-data-subject-export.md:44-46`). Degrading one section preserves the + legal deadline on the rest of the document. - **Where it's used**: produced by - [`ExportUserDataHandlerBase`](group-14-module-system-composition.md#exportuserdatahandlerbasetuser-tquery)`.RunSectionAsync` - on both the success path (`ExportUserDataHandlerBase.cs:177-183`, copying the fields off the - contributor's [`UserDataExportSectionResult`](group-14-module-system-composition.md#userdataexportsectionresult)) - and the degraded path (`:192-197`, stamping - [`UserDataExportSectionDefaults`](group-14-module-system-composition.md#userdataexportsectiondefaults)`.UnavailableReason`); - collected into `Sections` at `:104-107` and `:116`. + [`ExportUserDataHandlerBase`](group-14-module-system-composition.md#exportuserdatahandlerbasetuser-tquery) + on both paths: from a successful + [`UserDataExportSectionResult`](group-14-module-system-composition.md#userdataexportsectionresult) + (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/ExportUserDataHandlerBase.cs:177-183`) + and from the `catch` that degrades a throwing contributor, where the reason is the fixed string on + [`UserDataExportSectionDefaults`](group-14-module-system-composition.md#userdataexportsectiondefaults) + rather than anything derived from the exception (`ExportUserDataHandlerBase.cs:185-197`). Collected + into [`UserDataExportDTO.Sections`](#userdataexportdto) at `ExportUserDataHandlerBase.cs:104-116`. +- **Caveats / not-in-source**: nothing prevents an envelope from setting `Available = true` and a + non-null `UnavailableReason` at the same time, or `Available = false` with a payload. The + consistency is a convention the producing handler upholds, not a type invariant. + +### ForgotPasswordRequestValidator + +> MMCA.Common.Application · `MMCA.Common.Application.Auth.Validation` · `MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ForgotPasswordRequestValidator.cs:11` · Level 1 · class + +- **What it is**: the FluentValidation validator for + [`ForgotPasswordRequest`](#forgotpasswordrequest). It checks one field, `Email`, for non-empty and + address shape + (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ForgotPasswordRequestValidator.cs:13-16`). +- **Depends on**: [`ForgotPasswordRequest`](#forgotpasswordrequest); `FluentValidation`'s + `AbstractValidator` (NuGet). +- **Concept introduced, validation that deliberately stops short.** `[Rubric §11, Security]` assesses + whether the system leaks facts an attacker can use, and account enumeration is the classic leak: + if "forgot password" answers differently for a registered and an unregistered address, the endpoint + becomes a membership oracle. The forgot-password endpoint answers `202 Accepted` unconditionally + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:79,92`), + and this validator is the place that could quietly undo it: a rule that checked whether the address + belongs to an account would turn a miss into a `400`, which is the same oracle by a different + status code. The class doc comment names that trap and refuses it: a 400 there "would be the + enumeration oracle the always-accepted response exists to close" + (`ForgotPasswordRequestValidator.cs:7-9`). `[Rubric §24, Forms / Validation / UX Safety]`: shape + validation still runs, so a genuinely malformed address gets a useful client-side message without + costing an email send. +- **Walkthrough**: an expression-bodied constructor with a single chained rule, + `RuleFor(x => x.Email).NotEmpty().EmailAddress()`, each stage carrying an explicit + `WithMessage` (`ForgotPasswordRequestValidator.cs:13-16`). The messages are literal English strings + rather than resource lookups, which is how every validator in this assembly is written. +- **Why it's built this way**: the reset flow itself is + [ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html); the + uniform-response posture it depends on is only as strong as its weakest responder, and a validator + runs before the handler does. +- **Where it's used**: registered by assembly scan. + `services.AddValidatorsFromAssemblyContaining()` + (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:48`) picks up every + validator in `MMCA.Common.Application`, with the comment explaining why it must happen here rather + than in the per-module scan (`DependencyInjection.cs:45-47`). The resolved `IValidator` + is then consumed indirectly: an app's forgot-password command implements `ICommandWithRequest` + (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:42`), + and [`CommandRequestValidator`](group-06-validation.md#commandrequestvalidatortcommand-trequest) + bridges the command's `Request` property to this validator + (`MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommandRequestValidator.cs:22-26`), + auto-registered for every such command at `DependencyInjection.cs:196-210`. + +### IcsCalendarBuilder + +> MMCA.Common.Shared · `MMCA.Common.Shared.Calendars` · `MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsCalendarBuilder.cs:12` · Level 1 · class (static) + +- **What it is**: a dependency-free RFC 5545 writer. Given a product id, a collection of + [`IcsEvent`](#icsevent), and a timestamp, it returns a complete `VCALENDAR` document as a string + (`MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsCalendarBuilder.cs:22-41`). +- **Depends on**: [`IcsEvent`](#icsevent); `System.Text.StringBuilder`, `System.Text.Encoding`, and + `System.Globalization.CultureInfo` (BCL). No NuGet package. +- **Concept introduced, the deterministic pure builder.** `[Rubric §14, Testability]` assesses + whether behavior can be asserted without a harness. This type takes `dtStamp` as a parameter rather + than reading a clock, and the doc comment states the consequence: "Deterministic by design: the + caller supplies `dtStamp`, so identical inputs produce identical output" + (`IcsCalendarBuilder.cs:10-11`). That makes the whole document byte-assertable, which is exactly + what the suite in + `MMCA.Common/Tests/Core/MMCA.Common.Shared.Tests/Calendars/IcsCalendarBuilderTests.cs` does, + including a determinism test that builds twice and compares (`IcsCalendarBuilderTests.cs:140-141`). + `[Rubric §32, Dependency & Supply-Chain]` assesses what the framework takes on as a dependency. + Emitting an ICS file is a few hundred lines of string handling; taking a calendar library for it + would add a transitive surface to `MMCA.Common.Shared`, the assembly every other package depends + on. The type instead states its scope as "the subset every calendar app imports reliably" + (`IcsCalendarBuilder.cs:7-10`). +- **Walkthrough**: + - `MaxLineOctets = 75` (`IcsCalendarBuilder.cs:14`) is RFC 5545's content-line limit, counted in + octets rather than characters. + - `Build` (`IcsCalendarBuilder.cs:22`) guards both inputs (`ThrowIfNullOrWhiteSpace` on the product + id, `ThrowIfNull` on the events, `:24-25`), then writes the fixed calendar preamble + `VERSION:2.0`, an escaped `PRODID`, `CALSCALE:GREGORIAN`, and `METHOD:PUBLISH` + (`:28-32`), loops the entries in the order given (`:34-37`), and closes the document (`:39`). + Note that an empty collection is legal: it produces a valid, entry-less calendar, which + `IcsCalendarBuilderTests.cs:149` pins. + - `AppendEvent` (`:43`) writes the five mandatory `VEVENT` lines: `UID`, `DTSTAMP`, `DTSTART`, + `DTEND`, `SUMMARY` (`:46-50`). `DESCRIPTION` and `LOCATION` are emitted only when the optional + field is not null *or whitespace* (`:52-60`), so an all-blank location does not leave a stray + empty property in the document. + - `FormatUtc` (`:65`) is where the UTC-only contract shows up on the wire: it converts through + `UtcDateTime` and formats `yyyyMMdd'T'HHmmss'Z'` under `InvariantCulture`. The invariant culture + is not optional decoration; a non-Gregorian or non-ASCII-digit current culture would otherwise + corrupt the timestamp. + - `EscapeText` (`:69`) implements RFC 5545 section 3.3.11 TEXT escaping. Order matters and is + correct here: backslash is escaped *first* (`:71`), so the backslashes introduced by the later + replacements are not double-escaped. Semicolon and comma follow (`:72-73`), then all three + newline forms collapse to the literal `\n` sequence (`:74-76`), CRLF before its parts so a + Windows line break does not become two escapes. + - `AppendLine` (`:83`) is the subtlest method: RFC 5545 folding. It walks the string counting UTF-8 + *octets* per character, treating a surrogate pair as one unit (`:89-90`), and when the next + character would push the line past 75 octets it emits `CRLF` plus a single space and resets the + counter to `1` (`:92-96`). Two details are easy to get wrong and are handled: a fold never splits + a multi-byte character (because the decision is made per character, before appending), and the + continuation line's leading space counts against its own budget, which the inline comment states + (`:95`). Every line, folded or not, ends in `CRLF` (`:103`). +- **Why it's built this way**: no ADR covers calendar export; the rationale is entirely in the doc + comments cited above. The minimal-subset choice is the same instinct as the + [`IcsEvent`](#icsevent) UTC contract: avoid the parts of the specification whose correctness would + need continuous maintenance. +- **Where it's used**: ADC's Conference module only, from + [`ExportSessionCalendarHandler`](group-18-conference-application.md#exportsessioncalendarhandler) + for a single session + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportSessionCalendarHandler.cs:59-61`) + and [`ExportEventCalendarHandler`](group-18-conference-application.md#exporteventcalendarhandler) + for a whole event + (`.../ExportEventCalendarHandler.cs:61`), both passing + [`CalendarExportMapper`](group-18-conference-application.md#calendarexportmapper)'s + `ProductId` constant `-//MMCA//AtlDevCon//EN` (`CalendarExportMapper.cs:17`). +- **Caveats / not-in-source**: both ADC handlers pass `DateTimeOffset.UtcNow` for `dtStamp` + (`ExportEventCalendarHandler.cs:61`) rather than an injected `TimeProvider`, so the determinism the + builder guarantees is available to its own tests but not exercised through the handlers. ### KeyedSemaphoreStripe -> MMCA.Common.Shared · `MMCA.Common.Shared.Concurrency` · `MMCA.Common/Source/Core/MMCA.Common.Shared/Concurrency/KeyedSemaphoreStripe.cs:22` · Level 1 · class (sealed) - -- **What it is**: an in-process mutual-exclusion primitive that serializes work **per logical key** - across a fixed array of `SemaphoreSlim` instances. A key hashes to one stripe, so the table size is - bounded by `Width` no matter how many distinct keys the process ever sees - (`MMCA.Common/Source/Core/MMCA.Common.Shared/Concurrency/KeyedSemaphoreStripe.cs:3-6`). -- **Depends on**: `SemaphoreSlim`, `ArgumentOutOfRangeException`, `ArgumentNullException` and - `string.GetHashCode(ReadOnlySpan, StringComparison)` (all BCL); it returns the nested - [`Releaser`](#releaser). No first-party dependencies at all, which is why it can live in `Shared` - and be used from Application, Infrastructure and API alike. -- **Concept introduced, lock striping (and the two defects it exists to avoid).** `[Rubric §12, - Performance & Scalability]` assesses whether concurrency control is bounded and does not become a - memory or contention hazard; `[Rubric §11, Security]` applies because the keys here are frequently - **caller-supplied** (an idempotency key, a parameterized cache key), which makes an unbounded - per-key table a remote memory-exhaustion vector. The class doc (lines 7-16) is worth reading in - full, because it argues against the shape most codebases reach for first, one `SemaphoreSlim` per - key in a `ConcurrentDictionary`. That shape forces a choice between two real defects: - 1. **Remove the entry when the last holder releases**, and you open a window where one caller is - waiting on a semaphore that is no longer in the table while a second caller creates a fresh one; - both then run concurrently, which is precisely what the lock existed to prevent. - 2. **Never remove it**, and a caller-supplied key grows the table without bound. - - Striping has neither problem: the array is allocated once at construction and never mutated. The - price is **false sharing of a stripe**: two unrelated keys can hash to the same slot and briefly - serialize against each other. The doc explains why that is acceptable here (lines 13-15): every - caller is doing double-check locking and re-checks its own key's state after acquiring, so a - spurious wait costs latency, never correctness. -- **Walkthrough** (fields, constructors, then the one public method): - - `DefaultWidth = 256` (line 25): the default stripe count, documented as "ample concurrency without - a meaningful memory cost" (line 24). It is `public const`, which is what lets a test compute a - deliberate collision (see Caveats below). - - `private readonly SemaphoreSlim[] _stripes` (line 27): the fixed table. - - Parameterless constructor (lines 30-33): chains to the width overload with `DefaultWidth`. - - `KeyedSemaphoreStripe(int width)` (lines 37-47): guards with - `ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(width, 0)` (line 39), stores `Width` (line 41), - then eagerly allocates every stripe as `new SemaphoreSlim(1, 1)` (lines 42-46). Initial count 1 and - maximum count 1 is a mutex: exactly one holder at a time. Allocating all of them up front is what - removes every race from the acquire path, there is no lazy creation to synchronize. - - `Width { get; }` (line 50): the table size, get-only. - - `AcquireAsync(string key, CancellationToken)` (lines 60-65): map the key to its stripe, `await - stripe.WaitAsync(cancellationToken)` with `ConfigureAwait(false)` (line 63, the library - ConfigureAwait policy of - [ADR-049](https://ivanball.github.io/docs/adr/049-library-configureawait-policy.html)), and return - a [`Releaser`](#releaser) wrapping it (line 64). The doc comment (line 58) is precise about the - token's scope: it cancels the **wait**, not the work that follows it. - - `private SemaphoreSlim GetStripe(string key)` (lines 67-75): null-checks the key (line 69) and then - folds an **ordinal** hash into a non-negative index: - `(uint)string.GetHashCode(key, StringComparison.Ordinal) % (uint)Width` (line 73). Two details - matter. Ordinal (not the default culture-sensitive comparison) keeps the mapping stable regardless - of the ambient culture. The `uint` cast rather than `Math.Abs` is deliberate and commented (lines - 71-72): `int.MinValue` has no positive counterpart, so `Math.Abs` on it throws, while masking the - sign bit by reinterpreting as unsigned cannot. -- **Why it's built this way**: the remarks (lines 18-21) fix the intended lifetime: instances are - thread-safe and meant to be held in a **static field for the process lifetime**, and the stripes are - never disposed because the instance outlives every caller. That is why you will find it as a - `static readonly` or an instance field on a singleton, never as a scoped dependency. Note the scope - limit that follows from being in-process: it serializes callers **inside one process only**, so - under more than one replica it is not sufficient on its own. That is exactly why - [ADR-017](https://ivanball.github.io/docs/adr/017-request-idempotency.html) was revised to make the - idempotency guard an [`IDistributedLock`](group-05-cqrs-pipeline.md#idistributedlock) resolved from - DI, keeping the stripe only as the fallback for a host that registers none - (`MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:36` and - `:197-199`). `[Rubric §7, Microservices Readiness]` is the lens here: a primitive that is correct on - one node and insufficient on several is exactly the kind of assumption an extraction has to - re-examine. -- **Where it's used**: five call sites, all double-check-locking a cache. - [`IdempotencyFilter`](group-12-api-hosting-mapping.md#idempotencyfilter) holds a static instance - (`IdempotencyFilter.cs:92`) and runs the guarded section under it when no distributed lock is - registered (`IdempotencyFilter.cs:208-215`). - [`CookieSessionRefresher`](#cookiesessionrefresher) holds a per-instance one - (`MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:62`) so - concurrent SSR requests carrying the same expired cookie do not each burn the refresh token - (`:102-105`). [`MemoryCacheService`](group-09-caching.md#memorycacheservice) uses one to make its - read-modify-write paths atomic per key - (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/MemoryCacheService.cs:38`, `:101`, - `:112`, `:132`). The default `GetOrCreateAsync` on - [`ICacheService`](group-09-caching.md#icacheservice) collapses a factory stampede through an - `internal` non-generic holder, `CacheKeyLocks.Locks` - (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICacheService.cs:112`, holder at - `:142-146`), and +> MMCA.Common.Shared · `MMCA.Common.Shared.Concurrency` · `MMCA.Common/Source/Core/MMCA.Common.Shared/Concurrency/KeyedSemaphoreStripe.cs:22` · Level 1 · class + +- **What it is**: an in-process, per-key mutual-exclusion primitive. Callers ask to serialize on a + string key; the key is hashed onto one of a fixed number of `SemaphoreSlim` stripes, and the caller + gets back a [`Releaser`](#releaser) to dispose + (`MMCA.Common/Source/Core/MMCA.Common.Shared/Concurrency/KeyedSemaphoreStripe.cs:22-86`). +- **Depends on**: its own nested [`Releaser`](#releaser); `System.Threading.SemaphoreSlim` (BCL). +- **Concept introduced, lock striping.** `[Rubric §12, Performance & Scalability]` assesses how + shared state is guarded under concurrency and what that guard costs. The naive way to lock per key + is a `ConcurrentDictionary`, and the class doc comment lays out why that + shape is a trap, in the code rather than in tribal memory (`KeyedSemaphoreStripe.cs:8-15`): + - If you **remove** the entry when the last holder releases, you open a race. Caller A looks the + semaphore up, then B releases and removes it, then A waits on an object no longer in the table + while C creates a fresh one and takes that. A and C now both run the guarded section, which is + precisely what the lock existed to prevent. + - If you **never remove** it, the table grows without bound, and the keys here are + caller-supplied (an idempotency key, a parameterized cache key), so that is an + attacker-influenced memory leak. + + Striping sidesteps both by never creating or destroying anything: the table is allocated once at + the declared width and every key maps into it forever. The price is stated honestly in the same + comment: two unrelated keys can collide on a stripe and briefly serialize against each other. That + is harmless for the double-check-locking callers this exists for, because each one re-checks its + own key's state after acquiring (`KeyedSemaphoreStripe.cs:13-15`). +- **Walkthrough**: + - `DefaultWidth = 256` (`:25`), described as "ample concurrency without a meaningful memory cost"; + 256 `SemaphoreSlim` instances is a fixed, small, one-time allocation. + - The parameterless constructor chains to the width-taking one (`:30-33`). The real constructor + validates with `ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(width, 0)` (`:39`), then + eagerly fills the array with binary semaphores, `new SemaphoreSlim(1, 1)` (`:42-46`). Eager fill + is what removes every later allocation and every later race: after the constructor there is no + mutation of the table at all, which is why the type is safe to share without any lock of its own. + - `Width` is a get-only property (`:50`), exposed so tests can reason about collisions; one test + computes the exact stripe index a key lands on + (`MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/CookieSessionRefresherTests.cs:288-291`). + - `AcquireAsync` (`:60`) resolves the stripe, awaits `WaitAsync(cancellationToken)` with + `ConfigureAwait(false)` per + [ADR-049](https://ivanball.github.io/docs/adr/049-library-configureawait-policy.html) (`:63`), + and wraps the semaphore in a `Releaser` (`:64`). The parameter doc draws a line worth + remembering: the token "Cancels the wait, not the work that follows it" (`:58`). + - `GetStripe` (`:67`) does the hashing: `(uint)string.GetHashCode(key, StringComparison.Ordinal) % + (uint)Width` (`:73`). Two deliberate choices, both commented (`:71-72`). `StringComparison.Ordinal` + is passed explicitly rather than relying on the default, which keeps the mapping culture-independent. + And the sign is folded by casting to `uint` rather than calling `Math.Abs`, because + `int.MinValue` has no positive counterpart and `Math.Abs` would throw on it. +- **Why it's built this way**: the class is a hoisted shared primitive rather than a private helper + because five separate call sites needed the same guard. + [ADR-017](https://ivanball.github.io/docs/adr/017-request-idempotency.html) records its role in the + idempotency filter explicitly: the striped semaphore is the fallback for a host that registers no + `IDistributedLock`, and the ADR reproduces the same two-defects argument + (`Website/docs-src/adr/017-request-idempotency.md:59-65`). The scaling limit is stated there too: + a process-local lock only serializes duplicates that land on the same replica + (`017-request-idempotency.md:93`), which is why + [`IDistributedLock`](group-05-cqrs-pipeline.md#idistributedlock) is preferred when present + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:33-37`). +- **Where it's used**: five holders, all `static` or instance fields that live for the lifetime of + their owner, matching the remark that instances are "intended to be held in a static field for the + process lifetime" and that stripes are never disposed (`KeyedSemaphoreStripe.cs:18-21`): + [`IdempotencyFilter`](group-12-api-hosting-mapping.md#idempotencyfilter) (`IdempotencyFilter.cs:92`), + [`CookieSessionRefresher`](#cookiesessionrefresher) + (`MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:62`), + [`MemoryCacheService`](group-09-caching.md#memorycacheservice) + (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/MemoryCacheService.cs:38`), the + `CacheKeyLocks` holder behind [`ICacheService`](group-09-caching.md#icacheservice)'s + `GetOrCreateAsync` default implementation + (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICacheService.cs:145`), and the + `QueryCacheKeyLocks` holder behind [`CachingQueryDecorator`](group-05-cqrs-pipeline.md#cachingquerydecoratortquery-tresult) - does the same for a query key through its own holder `QueryCacheKeyLocks` - (`CachingQueryDecorator.cs:89`, holder field at `:197`). The two holders are deliberately separate - tables, not one shared set: the remarks on `CacheKeyLocks` (`ICacheService.cs:134-141`) note that - sharing stripes across unrelated call sites would only widen the unrelated-key collisions striping - already tolerates. -- **Caveats / not-in-source**: `[Rubric §14, Testability]` shows up in an unusual way here. Because - `DefaultWidth` is public, `CookieSessionRefresherTests` computes a key that provably lands on a - *different* stripe rather than hoping, so the test cannot flake on the one-in-256 collision - (`MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/CookieSessionRefresherTests.cs:274` - and `:289-291`). The primitive's own behavior is covered by - `MMCA.Common/Tests/Core/MMCA.Common.Shared.Tests/Concurrency/KeyedSemaphoreStripeTests.cs`, which - drives it at `width: 1`, `2` and `4` (`:40`, `:78`, `:98`) to force collisions deterministically. - Note also that `string.GetHashCode` is randomized per process by default in .NET, so which key lands - on which stripe is stable within a run and not across runs; nothing in the design depends on it being - stable across runs. + (`MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingQueryDecorator.cs:197`). + [`InProcessDistributedLock`](group-14-module-system-composition.md#inprocessdistributedlock) cites + the same reasoning in its own doc comment (`InProcessDistributedLock.cs:20`). +- **Caveats / not-in-source**: .NET randomizes string hash codes per process, so the stripe a given + key lands on differs between runs. That is invisible to correctness (any key consistently maps to + one stripe *within* a process) but it means collision behavior cannot be reproduced across + processes, which the caching tests call out + (`MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Interfaces/CacheServiceGetOrCreateTests.cs:178-179`). ### LoginRequestValidator > MMCA.Common.Application · `MMCA.Common.Application.Auth.Validation` · `MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/LoginRequestValidator.cs:11` · Level 1 · class -- **What it is**: the FluentValidation rule set for [`LoginRequest`](#loginrequest): the email must be - present and well-formed, the password must be present. Nothing else - (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/LoginRequestValidator.cs:11-22`). -- **Depends on**: FluentValidation's `AbstractValidator` (NuGet, line 1) and - [`LoginRequest`](#loginrequest) from `MMCA.Common.Shared.Auth` (line 2). -- **Concept introduced, validation that deliberately stops short.** `[Rubric §11, Security]` assesses - whether authentication avoids leaking information to an unauthenticated caller. The doc comment - (lines 6-10) is explicit that the minimalism is the design: detailed credential verification happens - in the authentication service to avoid leaking information about which field was wrong. A - validator that answered "no account with that email" would turn the login endpoint into an account - enumeration oracle. Instead the shape check happens here, and every credential outcome collapses - into the single `Auth.InvalidCredentials` / "Invalid email or password." failure that - [`AuthenticationServiceBase`](#authenticationservicebasetuser) returns for both a missing - user and a wrong password - (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:100-101` and - `:115-116`). `[Rubric §9, API & Contract Design]` also applies: shape validation belongs at the edge - of the request, semantic validation belongs in the workflow. -- **Walkthrough**: one constructor (line 13) with two rules. - - `RuleFor(x => x.Email).NotEmpty().EmailAddress()` (lines 15-17), with the messages "Email is - required." and "A valid email address is required." - - `RuleFor(x => x.Password).NotEmpty()` (lines 19-20), message "Password is required." There is no - length, complexity or character rule here; a password policy on *login* would only reject - credentials that a legacy account might legitimately still hold. -- **Why it's built this way**: FluentValidation keeps the rules declarative and out of the workflow, - and the framework owns this particular validator because the request DTO it validates is itself - framework-owned. Contrast `RegisterRequestValidator`, which stays in each app - (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/Validation/RegisterRequestValidator.cs:12`, - `MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/Validation/RegisterRequestValidator.cs:13`) - because password policy and required profile fields are an application decision. -- **Where it's used**: registered by `AddApplication()` via - `services.AddValidatorsFromAssemblyContaining()` - (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:40`); the comment there - (`:37-39`) records why it cannot ride on the module scan, `ScanModuleApplicationServices` only scans - a module's own assembly. DI then injects it into - [`AuthenticationValidators`](#authenticationvalidators) as the `IValidator`, and - [`AuthenticationServiceBase.LoginAsync`](#authenticationservicebasetuser) runs it first - (`AuthenticationServiceBase.cs:77-81`). +- **What it is**: the validator for [`LoginRequest`](#loginrequest): `Email` must be non-empty and a + valid address, `Password` must be non-empty + (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/LoginRequestValidator.cs:15-20`). +- **Depends on**: [`LoginRequest`](#loginrequest); `FluentValidation`'s `AbstractValidator`. +- **Concept**: the same "validation that deliberately stops short" posture introduced by + [`ForgotPasswordRequestValidator`](#forgotpasswordrequestvalidator). `[Rubric §11, Security]`: the + doc comment is explicit that the minimalism is a security property, not laziness. Credential + verification "happens in the authentication service to avoid leaking information about which field + was wrong" (`LoginRequestValidator.cs:7-9`). Notice what is *absent*: no `PasswordRules` or + `StrongPasswordRules` include. Applying the complexity policy at login would tell an attacker + that a candidate password could not possibly be the stored one, and would lock out any account + whose password predates the current policy. Complexity belongs on the *writing* paths only, which + is why [`ResetPasswordRequestValidator`](#resetpasswordrequestvalidator) includes it and this one + does not. +- **Walkthrough**: a block-bodied constructor with two independent `RuleFor` chains + (`LoginRequestValidator.cs:15-20`), each stage given an explicit `WithMessage`. FluentValidation + runs both rule sets and reports every failure, so a request missing both fields returns two errors + rather than one. +- **Why it's built this way**: uniform failure responses for authentication are the same discipline + as the forgot-password 202, applied to a different endpoint. The complementary defence against + guessing at scale is the per-IP rate-limit policy the login action carries + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:54-57`), which + is [ADR-029](https://ivanball.github.io/docs/adr/029-authentication-brute-force-protection.html). +- **Where it's used**: registered by the assembly scan at + `MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:48` (which names this class + in its comment, `DependencyInjection.cs:45`), then injected as `IValidator` into + [`AuthenticationValidators`](#authenticationvalidators), the parameter object that bundles the three + auth validators + (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationValidators.cs:17,22`), which is + in turn what [`AuthenticationServiceBase`](#authenticationservicebasetuser) consumes. +- **Caveats / not-in-source**: `AuthenticationValidators` also requires an + `IValidator` (`AuthenticationValidators.cs:18`), but `MMCA.Common.Application` + ships no `RegisterRequestValidator`: the only one in the tree is app-level + ([`RegisterRequestValidator`](group-24-identity-module.md#registerrequestvalidator)). The bundle + therefore only resolves in a host whose own Application assembly has been scanned as well. ### RefreshTokenRequestValidator > MMCA.Common.Application · `MMCA.Common.Application.Auth.Validation` · `MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/RefreshTokenRequestValidator.cs:10` · Level 1 · class -- **What it is**: the sibling rule set for [`RefreshTokenRequest`](#refreshtokenrequest): both tokens - must be non-empty - (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/RefreshTokenRequestValidator.cs:10-20`). -- **Depends on**: FluentValidation's `AbstractValidator` (line 1) and - [`RefreshTokenRequest`](#refreshtokenrequest) (line 2). Structurally identical to - [`LoginRequestValidator`](#loginrequestvalidator); the shared shape and the "stop short on purpose" - rationale are taught there. -- **Walkthrough**: one constructor (line 12), two `NotEmpty` rules: `AccessToken` (lines 14-15, - "Access token is required.") and `RefreshToken` (lines 17-18, "Refresh token is required."). The doc - comment (lines 6-9) explains why *both* are mandatory even though only one is the credential: the - expired access token is what the workflow parses claims out of, and the refresh token is what it - compares for rotation. Neither is optional because the refresh flow needs both halves - (`AuthenticationServiceBase.cs:230` reads the principal out of the access token, - `:259` compares the refresh token). -- **Why it's built this way**: same reasoning as its login sibling. It also validates no token - *format*, which is correct: an unparsable or tampered access token is rejected by signature - validation inside `GetPrincipalFromExpiredToken` - ([`ITokenService`](#itokenservice)), not by a string rule that would only tell an attacker which of - their guesses were shaped right. -- **Where it's used**: registered by the same - `AddValidatorsFromAssemblyContaining()` call - (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:40`), bundled into - [`AuthenticationValidators`](#authenticationvalidators) as the `IValidator`, and - run first by - [`AuthenticationServiceBase.RefreshTokenAsync`](#authenticationservicebasetuser) - (`AuthenticationServiceBase.cs:222-226`). +- **What it is**: the validator for [`RefreshTokenRequest`](#refreshtokenrequest). Both fields are + required and nothing more is checked + (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/RefreshTokenRequestValidator.cs:14-18`). +- **Depends on**: [`RefreshTokenRequest`](#refreshtokenrequest); `FluentValidation`'s + `AbstractValidator`. +- **Concept**: the shape is the one + [`ForgotPasswordRequestValidator`](#forgotpasswordrequestvalidator) introduced. What this validator + teaches is *why both* fields are mandatory, which the doc comment states: the expired access token + is needed "for claim extraction" and the refresh token "for rotation verification" + (`RefreshTokenRequestValidator.cs:7-8`). `[Rubric §11, Security]`: refresh-token rotation + ([ADR-050](https://ivanball.github.io/docs/adr/050-jwt-refresh-token-rotation.html)) verifies the + presented refresh token against the one stored for the *identity carried by the access token*, so + a request missing either half cannot be evaluated at all. Deliberately absent: any JWT + well-formedness or signature check. Parsing a token is the token service's job, and doing it here + would duplicate the trust boundary in a layer that has no key material. +- **Walkthrough**: two single-stage `RuleFor(...).NotEmpty()` chains with explicit messages + (`RefreshTokenRequestValidator.cs:14-18`). +- **Why it's built this way**: keeping the validator to presence checks leaves exactly one place + where a token's authenticity is decided, which is what makes the refresh endpoint's failure + responses uniform. +- **Where it's used**: picked up by the same assembly scan + (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:48`, named in the comment + at `:46`) and injected as `IValidator` into + [`AuthenticationValidators`](#authenticationvalidators) + (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationValidators.cs:19,28`). + +### ResetPasswordRequestValidator + +> MMCA.Common.Application · `MMCA.Common.Application.Auth.Validation` · `MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ResetPasswordRequestValidator.cs:12` · Level 1 · class + +- **What it is**: the validator for [`ResetPasswordRequest`](#resetpasswordrequest): address shape on + `Email`, presence on `Token`, and the shared strong-password policy on `NewPassword` + (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ResetPasswordRequestValidator.cs:16-23`). +- **Depends on**: [`ResetPasswordRequest`](#resetpasswordrequest); + [`StrongPasswordRules`](group-06-validation.md#strongpasswordrulest); `FluentValidation`'s + `AbstractValidator` and its `Include` composition. +- **Concept introduced, composing a rule set with `Include`.** `[Rubric §11, Security]` assesses + whether a policy holds on every path that can change the guarded value, and `[Rubric §1, SOLID]` + the single-responsibility split that makes that possible. A password-complexity policy is only a + policy if *every* write path enforces it; if registration demands an uppercase letter and reset + does not, reset is a documented downgrade route. FluentValidation's `Include` merges another + validator's rules for the same model type into this one, so the policy can live in exactly one + class and be pulled into each writer. The doc comment states the intent: the new password goes + through "the same `StrongPasswordRules` the registration and change-password requests use, so a + reset cannot be a way around the complexity policy" (`ResetPasswordRequestValidator.cs:8-10`). + `StrongPasswordRules` is generic over the containing model and takes a selector expression, which + is what lets one rule set attach to a differently-shaped request each time + (`MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:97-108`): it + enforces non-empty, 8 to 128 characters, and one each of uppercase, lowercase, digit, and + non-alphanumeric. +- **Walkthrough**: three statements in a block-bodied constructor + (`ResetPasswordRequestValidator.cs:13-24`). `Email` gets `NotEmpty().EmailAddress()` (`:16-18`), + matching the forgot-password half so the two steps agree on what an address is. `Token` gets + `NotEmpty()` with a reset-specific message (`:20-21`); no format check, because the token's + validity is a lookup, not a shape. Then `Include(new StrongPasswordRules(x + => x.NewPassword))` (`:23`) grafts the seven policy rules onto the `NewPassword` field. Note the + contrast with the weaker sibling + [`PasswordRules`](group-06-validation.md#passwordrulest) + (`CommonValidationRules.cs:83-90`), which enforces length only; reset deliberately takes the strong + one. +- **Why it's built this way**: the reset flow is + [ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html), and the + hashing the accepted password ends up under is + [ADR-032](https://ivanball.github.io/docs/adr/032-password-hashing.html). Neither is this + validator's concern, which is the point: it only decides whether the candidate is policy-compliant. +- **Where it's used**: registered by the assembly scan + (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:48`) and reached through + [`CommandRequestValidator`](group-06-validation.md#commandrequestvalidatortcommand-trequest) + for any command implementing `ICommandWithRequest`, the constraint + [`ResetPasswordHandlerBase`](group-14-module-system-composition.md#resetpasswordhandlerbasetuser-tcommand) + declares + (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:37`). + The request arrives at + [`PasswordResetAuthControllerBase`](group-12-api-hosting-mapping.md#passwordresetauthcontrollerbasetforgotpasswordcommand-tresetpasswordcommand) + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:108`), + which is why a policy failure surfaces as the documented `400` + (`PasswordResetAuthControllerBase.cs:104`) while a bad token collapses to `401` + (`PasswordResetAuthControllerBase.cs:96-97,105`). ### UserDataExportDTO -> MMCA.Common.Shared · `MMCA.Common.Shared.Privacy` · `MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:15` · Level 1 · record (sealed) - -- **What it is**: the portable data-subject export package (GDPR/CCPA access and portability): a - snapshot of the account itself plus one [`UserDataExportSectionDTO`](#userdataexportsectiondto) - envelope per registered section of the user's data - (`MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:5-49`). -- **Depends on**: `System.Runtime.Serialization`'s `DataContract` / `DataMember` (BCL, line 1), the - `UserIdentifierType` alias, and [`UserDataExportSectionDTO`](#userdataexportsectiondto) (line 48). - Nothing else: it is a pure contract type, which is why it sits in `Shared` where the Application - handler, the API controller and any client can all see it. -- **Concept introduced, the versioned envelope with app-owned payloads.** `[Rubric §9, API & - Contract Design]` assesses whether a contract can evolve without breaking readers, and `[Rubric §30, - Compliance / Privacy / Data Governance]` assesses whether personal data is handled with an explicit, - documented shape. Two design choices carry the whole idea: - - 1. **`FormatVersion` is read before parsing** (lines 17-22). The framework owns the *envelope*, so - when the envelope changes a consumer can detect it rather than guess. Crucially the version - covers the envelope only: an app changing its own subject or section payloads does not move it - (`ExportUserDataHandlerBase.cs:57-61`, where the constant `CurrentFormatVersion = "1.0"` lives). - 2. **`Subject` and each section's `Data` are typed `object`** (lines 32-40). This looks like a lost - type, and the doc comment explains why it is the point: the framework owns the envelope, each app - owns which of *its* fields are portable personal data, and a property typed `object` serializes - **by its runtime type** under System.Text.Json. So ADC can put its - [`UserDataExportSubjectDTO`](group-24-identity-module.md#userdataexportsubjectdto) in that slot and - Store can put a different one, with no generic parameter threaded through the controller, the - handler and the response type. The cost is that a *reader* deserializing back into this record - gets a `JsonElement` rather than the app's type, which is exactly what the round-trip test - asserts - (`MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/Privacy/DataExportControllerBaseTests.cs:99`). - - The third thing to internalise is the header comment (lines 8-12): this document is **PII by - design**. It exists to hand a data subject everything an app holds about them, so it must only ever - be produced for the account owner (or a privileged role) and must never be logged, cached, or - persisted by the pipeline that serves it. The producer honours that literally: the export query - implements no `IQueryCacheable`, so the caching decorator does not apply to it - (`ExportUserDataHandlerBase.cs:42-45`). `[Rubric §11, Security]` and `[Rubric §13, Observability & - Operability]` pull in opposite directions here, and privacy wins: this is one payload you do not log. -- **Walkthrough**: five `init`-only properties, explicit `[DataMember(Order = n)]` on each so the wire - order is declared rather than incidental. - - `FormatVersion` (`required`, Order 1, line 22): the envelope version, described above. - - `GeneratedOn` (`required`, Order 2, line 26): the UTC instant the export was produced, sourced - from the injected `TimeProvider` (`ExportUserDataHandlerBase.cs:113`), never `DateTime.UtcNow`. - - `UserId` (`required`, Order 3, line 30): the subject the export describes, in the - `UserIdentifierType` alias. - - `Subject` (Order 4, line 40): the app's account snapshot, `object?`, null when the app publishes - no subject fields. - - `Sections` (Order 5, line 48): the section envelopes, defaulting to `[]` so an export with no - registered contributors is an empty list rather than a null a reader has to guard. The doc comment - (lines 42-46) pins the two guarantees a consumer relies on: the order is the registration order, - and a section that could not be produced is *still present* reporting `Available = false`, so - "no data" is distinguishable from "not retrieved". - - The three `required` members mean the compiler refuses a package missing its version, timestamp or - subject id: the three facts that make the document self-describing. -- **Why it's built this way**: [ADR-076](https://ivanball.github.io/docs/adr/076-data-subject-export.html) - hoists the export idiom ADC and Store each wrote by hand into one framework contract, mirroring the - delete-handler shape, and makes per-section degradation the rule. `sealed record` with `init`-only - members gives an immutable, structurally-equal document, which is what lets a test compare an - assembled package by value. Erasure (the other half of the data-subject story) is a separate - decision, [ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html). -- **Where it's used**: assembled by - [`ExportUserDataHandlerBase`](group-14-module-system-composition.md#exportuserdatahandlerbasetuser-tquery), - whose whole contract is `IQueryHandler>` - (`ExportUserDataHandlerBase.cs:53`, assembly at `:110-117`). Served by - [`DataExportControllerBase`](group-12-api-hosting-mapping.md#dataexportcontrollerbasetquery), - which deliberately serializes it to UTF-8 bytes and returns a `File(...)` download rather than - `Ok(export)`, because the document exists to be saved by the person it describes - (`DataExportControllerBase.cs:104-110`), naming the file from the package's own `GeneratedOn` so the - name and the document always agree (`:134-135`). -- **Caveats / not-in-source**: the shipped controller base has no production subclass in this - workspace today. Both apps keep their earlier standalone export endpoints, which return the same - `UserDataExportDTO` inline via `Ok(result.Value)` with no feature gate and no file download - (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:153-168`, - `MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/UsersController.cs:39-54`). - ADR-076 records that non-adoption explicitly; the type itself is shared by both paths. - -### ILoginProtectionService - -> MMCA.Common.Application · `MMCA.Common.Application.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Application/Auth/ILoginProtectionService.cs:10` · Level 3 · interface - -- **What it is**: the application-layer contract for **brute-force and rate-limit protection** on - authentication endpoints: lockout checks, failed-attempt increments, successful-login resets, and - registration rate-limiting per IP address. -- **Depends on**: [`Result`](group-01-result-error-handling.md#result) (`MMCA.Common.Shared.Abstractions`, - line 1). -- **Concept introduced, rate-limiting as a first-class application concern.** `[Rubric §11, - Security]` (assesses brute-force protection on auth flows) and `[Rubric §10, Cross-Cutting - Concerns]` (rate-limiting extracted to a port so the application layer reasons about it without - coupling to a specific store; the doc comment, lines 7-8, names both a distributed and an in-memory - cache as valid backers). Returning [`Result`](group-01-result-error-handling.md#result) from - `CheckLockoutAsync` (line 18) and `CheckRegistrationRateLimitAsync` (line 42) makes "account is - locked out" a normal control-flow branch rather than a thrown exception. -- **Walkthrough**: five async methods, split into two scopes. - - **Email-scoped (failed-login lockout):** `CheckLockoutAsync` (line 18) returns a failure result - when the email is currently locked; `IncrementFailedAttemptsAsync` (line 26) records a failure and, - per the doc comment (lines 20-22), applies **exponential-backoff lockout** once the max is - exceeded; `ResetFailedAttemptsAsync` (line 33) clears the counter after a successful login. - - **IP-scoped (registration flood):** `CheckRegistrationRateLimitAsync` (line 42) and - `IncrementRegistrationCountAsync` (line 49) throttle account creation per client IP. Both accept a - nullable `ipAddress` and **skip** the check when it is null (so a host that cannot resolve the - caller IP degrades to no limit rather than blocking everyone); `CheckRegistrationRateLimitAsync` - returns `Result.Success()` in that case (doc comment, lines 36-37). - - All five methods take a `CancellationToken` with a `default` argument, per convention. -- **Why it's built this way**: keeping the protection policy behind an interface lets the shared - authentication workflow compose it in while the concrete cache mechanics stay in the implementation; - the null-IP "skip" keeps the limiter from becoming an availability hazard - ([ADR-029](https://ivanball.github.io/docs/adr/029-authentication-brute-force-protection.html)). -- **Where it's used**: injected into [`AuthenticationServiceBase`](#authenticationservicebasetuser) - (constructor, `AuthenticationServiceBase.cs:38`), which calls all five methods across its login and - registration flows (`:84`, `:99`, `:114`, `:128`, `:146`, `:207`); the concrete, cache-backed - [`LoginProtectionService`](#loginprotectionservice) (tuned by - [`LoginProtectionSettings`](#loginprotectionsettings)) implements it. - -### SoftDeletedUserCache - -> MMCA.Common.Application · `MMCA.Common.Application.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Application/Auth/SoftDeletedUserCache.cs:17` · Level 4 · class (static) - -- **What it is**: the shared cache contract for the **soft-deleted user marker** (BR-133): the key - shape, the marker lifetime, and a one-call helper that writes it. The API middleware reads the - marker on every authenticated request; the module that soft-deletes a user writes it - (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/SoftDeletedUserCache.cs:6-10`). -- **Depends on**: [`ICacheService`](group-09-caching.md#icacheservice) (line 2), the - `UserIdentifierType` alias, and `System.Globalization.CultureInfo` (BCL, line 1). -- **Concept introduced, revoking a stateless credential without a per-request lookup.** `[Rubric §11, - Security]` assesses whether a revoked principal actually loses access, and `[Rubric §10, - Cross-Cutting Concerns]` assesses whether such a concern is factored so both ends share one - definition. A JWT is a bearer credential: signature validation never asks "is this account still - active?", so soft-deleting a user leaves their already-issued access token passing validation until - it expires - ([ADR-047](https://ivanball.github.io/docs/adr/047-soft-deleted-user-session-revocation.html)). The - textbook fixes (a deny-list, or an account-status query on every request) reintroduce exactly the - per-request state that stateless JWT was chosen to avoid. This type is the middle path: a short-lived - cache marker written at deletion time and read cheaply on the hot path. - - The `remarks` (lines 11-16) explain why the constants live in the **Application** layer rather than - next to the middleware that reads them: a downstream application deleting an account has to write - the exact same key the middleware reads, and a private constant in the presentation layer is - unreachable from an application-layer command handler. Same reasoning as - [`IdempotencyHeaders`](#idempotencyheaders), applied one layer up. -- **Walkthrough**: three static members, no state. - - `MarkerDuration => TimeSpan.FromSeconds(30)` (line 29). The remarks (lines 22-28) justify the - number rather than leaving it magic: the marker only has to cover the window between the delete - committing and the next token validation, because once it expires the validator query is the - source of truth again and gives the same answer. Short-lived access tokens (15 minutes, the BR-205 - default on [`ITokenService`](#itokenservice)) bound the rest of the exposure, so a longer marker - would buy nothing and would keep stale entries alive for users who were never deleted. - - `KeyFor(UserIdentifierType userId)` (lines 42-43): builds `user:deleted:{userId}` through - `string.Create(CultureInfo.InvariantCulture, ...)`. The remarks (lines 36-41) name the bug this - prevents: an identifier renders differently under some cultures (digit shapes, group separators), - so a culture-sensitive key would be written under one request's culture and missed under another, - silently letting a deleted user keep making requests. This is a case where the analyzer rule about - culture-invariant formatting is guarding a security property, not just a formatting nicety. - - `MarkDeletedAsync(ICacheService cache, UserIdentifierType userId, CancellationToken)` (lines - 53-61): null-guards the cache (line 58) and writes `true` under `KeyFor(userId)` for - `MarkerDuration` (line 60). It returns the task without awaiting, so there is no extra async state - machine for a one-call passthrough. -- **Why it's built this way**: publishing the key shape and the TTL as framework API is what keeps the - writer and the reader honest, and it is a precondition for the module boundary in - [ADR-047](https://ivanball.github.io/docs/adr/047-soft-deleted-user-session-revocation.html): - Identity owns the delete, every service hosts the middleware, and the only thing they share is a - cache entry rather than a database. `[Rubric §7, Microservices Readiness]` applies directly, an - extracted service can enforce the revocation without a reference to the Identity database. -- **Where it's used**: read by - [`SoftDeletedUserMiddleware`](group-12-api-hosting-mapping.md#softdeletedusermiddleware), which - builds the key (`MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/SoftDeletedUserMiddleware.cs:85`), - short-circuits with 401 when the marker is `true` (`:102-106`), and on a miss falls back to the - validator query and caches **that** answer, deleted or not, for the same `MarkerDuration` - (`:131-133`). Written by the Identity delete path: ADC's - [`DeleteUserHandler`](group-24-identity-module.md#deleteuserhandler) queues it as an after-commit - action and swallows a cache fault so a failed marker cannot turn a successful erasure into an error - the caller would retry - (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs:68-80`, - inside the `OnAfterSoftDeleteAsync` override at `:46`). -- **Caveats / not-in-source**: the marker is best effort on both ends by design. The middleware fails - **open** on a cache outage (falling through to the validator query, and proceeding if that is also - unavailable, `SoftDeletedUserMiddleware.cs:93-100` and `:118-125`), and the writer logs and - continues on a cache fault. The exposure that leaves is bounded by the access-token lifetime, which - is the trade-off ADR-047 accepts explicitly. ADC's handler is the only writer in the source tree - today; MMCA.Store soft-deletes users without writing the marker. - -### AuthenticationValidators - -> MMCA.Common.Application · `MMCA.Common.Application.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationValidators.cs:16` · Level 5 · class (sealed) - -- **What it is**: a tiny **parameter object** that bundles the three FluentValidation validators the - authentication workflow needs (login, registration, refresh) into one injectable dependency. -- **Depends on**: FluentValidation's `IValidator` (NuGet, line 1) over the request DTOs - [`LoginRequest`](#loginrequest), [`RegisterRequest`](#registerrequest), and - [`RefreshTokenRequest`](#refreshtokenrequest) (all in `MMCA.Common.Shared.Auth`, line 2). -- **Concept introduced, the parameter object as a constructor-arity guardrail.** `[Rubric §1, SOLID]` - (assesses whether a class stays a single, cohesive responsibility rather than sprawling into a - god-class) and `[Rubric §16, Maintainability & Evolvability]` (assesses whether cross-cutting - dependencies are grouped so a class can grow without exploding its constructor). The doc comment - (lines 6-12) states the exact motive: collapsing three closely-related dependencies into one keeps - the app's `AuthenticationService` **below the application-service constructor-arity ceiling** (a - god-class analyzer guardrail) without giving up per-request validation. Because the request DTOs - already live in `MMCA.Common.Shared.Auth`, the bundle is app-agnostic, which is why it could be - hoisted out of the apps into the framework. -- **Walkthrough**: a primary constructor takes the three `IValidator` instances (lines 16-19), and - three get-only properties surface them by name: `Login` (line 22), `Register` (line 25), and - `Refresh` (line 28), each assigned from its matching constructor parameter. There is no logic here; - the type exists purely to shrink the dependency footprint of its consumer. -- **Why it's built this way**: a `sealed` grouping type with get-only properties is the cheapest way to - fold three cohesive dependencies into one constructor slot, so the workflow base can validate each - request shape without pushing its constructor over the arity limit; DI resolves the three underlying - validators and composes them into this one object. Two of the three - ([`LoginRequestValidator`](#loginrequestvalidator), - [`RefreshTokenRequestValidator`](#refreshtokenrequestvalidator)) come from the framework assembly, - while `IValidator` is satisfied by the app's own `RegisterRequestValidator`, so the - bundle is the point where framework and app validation meet. -- **Where it's used**: injected into [`AuthenticationServiceBase`](#authenticationservicebasetuser) - (constructor, `AuthenticationServiceBase.cs:40`), whose `LoginAsync`/`RegisterAsync`/`RefreshTokenAsync` - call `validators.Login` (`:77`), `validators.Register` (`:139`), and `validators.Refresh` (`:222`) - respectively before doing any work. - -### IAuthenticationService - -> MMCA.Common.Application · `MMCA.Common.Application.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IAuthenticationService.cs:11` · Level 5 · interface - -- **What it is**: the application-layer contract for the Identity module's authentication workflows: - login, registration, token refresh, token revocation, and external (OAuth) login. -- **Depends on**: [`LoginRequest`](#loginrequest), [`RefreshTokenRequest`](#refreshtokenrequest), - [`RegisterRequest`](#registerrequest), [`AuthenticationResponse`](#authenticationresponse), - [`Result`](group-01-result-error-handling.md#result), - [`Error`](group-01-result-error-handling.md#error), and the `UserIdentifierType` alias. -- **Concept introduced, default interface methods for optional capabilities.** `[Rubric §1, SOLID]` - (Interface Segregation and Dependency Inversion): `ExternalLoginAsync` (lines 66-74) ships a - **default implementation** in the interface itself that returns a "not supported" - [`Error.Failure`](group-01-result-error-handling.md#error) (`"Auth.ExternalLoginNotSupported"`). An - implementation that does not offer OAuth (a stub host, or a deployment with social login disabled) - inherits that failure for free and need not override anything, so the interface stays one piece while - the capability is opt-in ([ADR-036](https://ivanball.github.io/docs/adr/036-external-oauth-login.html)). - `[Rubric §11, Security]`: login, registration, and refresh all return - `Result`, so auth outcomes flow as values and no exception leaks credential - detail to the caller. -- **Walkthrough**: five methods, all async, all taking a `CancellationToken`. - - `LoginAsync(LoginRequest)` returns `Result` (line 19). - - `RegisterAsync(RegisterRequest, string? ipAddress = null)` (line 30); the optional `ipAddress` - feeds [`ILoginProtectionService`](#iloginprotectionservice)'s registration rate limit. - - `RefreshTokenAsync(RefreshTokenRequest)` (line 41) rotates the token pair. - - `RevokeTokenAsync(UserIdentifierType userId)` returns `Result` (line 51) and revokes a user's - refresh token, returning a not-found error when there is none. - - `ExternalLoginAsync(loginProvider, providerKey, email, firstName, lastName)` (line 66), the - default-implemented OAuth path; finds an account by provider and key or creates one from claims. - - The doc comment (lines 6-9) also records a scope decision: **password change is not on this - interface**. It is dispatched directly through its own command handler at the controller layer. -- **Why it's built this way**: concentrating the token-issuing workflows behind one port keeps the - Identity controllers thin and lets the protection/rate-limit policy - ([`ILoginProtectionService`](#iloginprotectionservice)) compose in; the default OAuth method keeps - the contract stable across hosts that do and do not enable social login. -- **Where it's used**: implemented by [`AuthenticationServiceBase`](#authenticationservicebasetuser) - (which realises every member except the default `ExternalLoginAsync`) and, through it, by each app's - sealed [`AuthenticationService`](group-24-identity-module.md#authenticationservice); consumed by the - Identity API controllers. - -### AuthenticationServiceBase - -> MMCA.Common.Application · `MMCA.Common.Application.Auth` · `MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:34` · Level 8 · class (abstract) - -- **What it is**: the **shared authentication workflow** (login, registration, token refresh and - rotation, revocation) hoisted once into the framework, generic over the app's `User` aggregate. It - realises [`IAuthenticationService`](#iauthenticationservice) and leaves the genuinely app-specific - decisions to a small set of `abstract`/`virtual` hooks a sealed subclass overrides. -- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) and - [`IRepository`](group-07-persistence-ef-core.md#irepositorytentity-tidentifiertype) - (persistence, G07), [`ITokenService`](#itokenservice), [`IPasswordHasher`](#ipasswordhasher), - [`ILoginProtectionService`](#iloginprotectionservice), [`AuthenticationValidators`](#authenticationvalidators) - (this group), the [`IAuthUser`](#iauthuser) credential contract plus - [`AuditableAggregateRootEntity`](group-02-domain-building-blocks.md#auditableaggregaterootentitytidentifiertype) - as the `TUser` constraint (line 41), [`Email`](group-02-domain-building-blocks.md#email) (normalising the - login/register email), [`Result`](group-01-result-error-handling.md#result) / - [`Error`](group-01-result-error-handling.md#error), the request/response DTOs - ([`LoginRequest`](#loginrequest), [`RegisterRequest`](#registerrequest), - [`RefreshTokenRequest`](#refreshtokenrequest), [`AuthenticationResponse`](#authenticationresponse)), - and the BCL `TimeProvider` (injected, never `DateTime.UtcNow`, so the clock is testable). -- **Concept introduced, the Template Method that de-duplicates a whole vertical slice.** `[Rubric §2, - Design Patterns]` (assesses idiomatic pattern use): this is a textbook **Template Method**, the - invariant sequence of an operation lives in the base while the variable steps are deferred to - subclass hooks. `[Rubric §16, Maintainability & Evolvability]` (DRY across services) and `[Rubric §1, - SOLID]`: the doc comment (lines 11-32) records that the app Identity modules previously duplicated - this workflow at roughly 70-95% line-identity; folding it here means a fix to the lockout order or - the refresh-rotation logic is written once. `[Rubric §11, Security]`: the base encodes the security - posture directly, validate-first, an [`ILoginProtectionService`](#iloginprotectionservice) - lockout/rate-limit gate - ([ADR-029](https://ivanball.github.io/docs/adr/029-authentication-brute-force-protection.html)), an - untracked-then-tracked dual fetch - ([ADR-004](https://ivanball.github.io/docs/adr/004-authentication-dual-fetch.html)), and - refresh-token rotation with **reuse detection** - ([ADR-050](https://ivanball.github.io/docs/adr/050-jwt-refresh-token-rotation.html), BR-205/206). - `[Rubric §7, Microservices Readiness]`: the workflow depends only on ports (`IUnitOfWork`, - `ITokenService`, ...) so it runs unchanged whether the Identity module is in-monolith or its own - service. -- **Walkthrough** (members in teaching order): - - **Constructor + protected accessors** (lines 34-54): a primary constructor takes the six - collaborators; protected read-only properties re-expose `UnitOfWork` (line 44), `TokenService` - (line 47), `TimeProvider` (line 50) and a `Repository` (lines 53-54) resolved lazily as - `unitOfWork.GetRepository()`, so subclass hooks and app-level flows - (external login) reuse them without re-injecting. - - **Token lifetimes** (lines 61-70): `virtual` `AccessTokenLifetime` and `RefreshTokenLifetime` read - through to [`ITokenService`](#itokenservice) (which derives them from `Jwt:AccessTokenExpirationMinutes` - and `Jwt:RefreshTokenExpirationDays`), so the expiry reported to the client matches the JWT's - actual `exp`. A non-positive value, meaning a hand-written test double or a misconfigured host, - falls back to the BR-205 defaults of 15 minutes and 7 days - (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ITokenService.cs:33` - and `:40` carry the same defaults on the port). - - **`LoginAsync`** (lines 73-131): validate the request (lines 77-81), check lockout (line 84, - [ADR-029](https://ivanball.github.io/docs/adr/029-authentication-brute-force-protection.html) / - BR-212), normalise the raw email into an [`Email`](group-02-domain-building-blocks.md#email) value - object (line 92) so the EF predicate compares same-typed converted values (an invalid email yields - a null value object that simply matches no user, which is the invalid-credentials answer anyway). - **Step 1** is an *untracked* fetch via the `FindUntrackedByEmailAsync` hook (line 96) to verify - credentials without change-tracker overhead; a null result increments failed attempts and returns - a generic 401 (lines 97-102). An app gate runs before password verification (line 106, no - failed-attempt increment so the pre-hoist behaviour is preserved), then - `passwordHasher.VerifyPassword` (line 112). **Step 2** is a *tracked* re-fetch by id (line 120) so - the new refresh token can be persisted, followed by `ResetFailedAttemptsAsync` (line 128) and - `IssueTokensAsync` (line 130). - - **`RegisterAsync`** (lines 134-215): validate (lines 139-143), IP rate-limit (line 146, - [ADR-029](https://ivanball.github.io/docs/adr/029-authentication-brute-force-protection.html) / - BR-213), reject a duplicate email through the `EmailExistsAsync` hook (lines 153-157), hash the - password (line 159), build the user through the `CreateUser` hook (line 160), mint and store a - refresh token (lines 167-168), `AddAsync` (line 170) and `SaveChangesAsync` (line 174), then run - the `OnUserRegisteredAsync` post-commit hook (line 204) to pick up the instance the first access - token is minted from, increment the IP registration count (line 207), and return the token pair - (lines 209-214). - - The save is wrapped in a deliberately **broad** `catch (Exception)` (lines 172-200, with a scoped - `CA1031` suppression) whose comment is the teaching material. The email lookup above is a - check-then-act: two concurrent registrations for the same address both pass it, and the loser only - fails on the insert, against the unique index every consumer puts on `Email` (ADC unfiltered, Store - filtered on `IsDeleted`). Without the catch, that race surfaces as a generic 500 instead of the 409 - a serialized pair would have produced. The catch cannot name `DbUpdateException`, because - Application has no EF Core dependency by layer rule, so the **re-check is what narrows it** (line - 194): if the address exists now, the concurrent registration is the cause and the caller gets the - same conflict the serial path returns through the shared `EmailAlreadyExistsFailure()` helper; - anything else rethrows untouched (line 199) and still reaches the exception middleware. The - re-check passes `CancellationToken.None` on purpose (lines 192-194): it has to run even when the - caller's token is what aborted the save, or a cancelled save could never be classified. - - **`RefreshTokenAsync`** (lines 218-269): validate (lines 222-226), pull claims from the *expired* - JWT via `tokenService.GetPrincipalFromExpiredToken` (line 230, signature still checked, only - lifetime skipped), read the `user_id` claim (lines 237-242), load the tracked user (line 244), run - the refresh app gate (line 251), then the security-critical check (line 259): if the stored - `RefreshToken` does not match or has expired, this is treated as **token reuse (potential theft)**, - so `user.RevokeRefreshToken()` is called and saved (lines 261-262, BR-206) before returning a 401. - A clean match issues a rotated pair through `IssueTokensAsync` (line 268). - - **`RevokeTokenAsync`** (lines 272-286): load by id, `RevokeRefreshToken()`, save; a missing user - yields `Error.NotFound` targeted at `typeof(TUser).Name` (line 279). - - **`IssueTokensAsync`** (lines 292-306): the shared rotation used by login and refresh (and reusable - by app-level external login), mints an access token via the `CreateAccessToken` hook, generates a - new refresh token, stamps its expiry off `TimeProvider`, saves, and returns the response. - - **The hooks**: four `abstract` (a subclass must supply them). `FindUntrackedByEmailAsync` (line - 313) and `EmailExistsAsync` (line 319) are deliberately written against the app's concrete `User` - so EF translates the predicate byte-for-byte as before, and the second explicitly leaves the app to - decide whether soft-deleted accounts count (`ignoreQueryFilters: true` blocks re-registration of an - erased email, lines 315-318); `CreateUser` (line 322) runs the app's domain factory; - `CreateAccessToken` (line 325) mints the app's claim set (for example `speaker_id` vs - `customer_id`). Four `virtual` hooks default to a no-op: `ValidateLoginCandidateAsync` (line 328) - and `ValidateRefreshCandidateAsync` (line 332) add extra gates such as a deactivated-account check; - `OnUserRegisteredAsync` (line 339) runs the post-commit side-effect (publish an integration event - or re-fetch a linked id); and `CreateRefreshUserMissingError` (line 347) defaults the vanished-user - case to 401 (a token for a missing user is indistinguishable from an invalid one) while letting an - app return 404 where its public contract already promises it. One `private static` helper, - `EmailAlreadyExistsFailure()` (lines 355-357), returns the `Auth.EmailAlreadyExists` conflict so - the up-front check and the race recovery are indistinguishable to the caller. -- **Why it's built this way**: the untracked-then-tracked dual fetch keeps the common - credential-verification path off the change tracker (cheaper, and soft-deleted accounts fall out via - EF query filters returning the generic 401) while still giving a tracked instance to persist the new - token ([ADR-004](https://ivanball.github.io/docs/adr/004-authentication-dual-fetch.html)). - Refresh-token reuse detection (revoke-on-mismatch) is the BR-206 defence against a stolen token being - replayed ([ADR-050](https://ivanball.github.io/docs/adr/050-jwt-refresh-token-rotation.html)). - Password material flows through [`IAuthUser`](#iauthuser)'s `PasswordHash`/`PasswordSalt` - ([ADR-032](https://ivanball.github.io/docs/adr/032-password-hashing.html)), and the whole workflow - depends only on abstractions, so it is identical whether the module runs in-process or as an - extracted service. -- **Where it's used**: subclassed by each app's sealed - [`AuthenticationService`](group-24-identity-module.md#authenticationservice) (for example - `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:35`, - which binds `TUser = User`, adds the Attendee default role (BR-45) and the `speaker_id` claim - (BR-209, built at `:249-252`), and re-lists `IAuthenticationService` so it can re-implement - `RegisterAsync` (`:57-62`) and `ExternalLoginAsync` (`:130-137`) outright: ADC raises its - registration side-effects inside one `ExecuteInTransactionAsync` unit rather than through the - `OnUserRegisteredAsync` hook, because the identity column means the id does not exist until the first - save (`AuthenticationService.cs:16-32`, `:44`). MMCA.Store supplies its own subclass with a - `customer_id` claim. Consumed by the Identity API controllers via the - [`IAuthenticationService`](#iauthenticationservice) port. -- **Caveats / not-in-source**: the `user_id` claim is parsed with `int.TryParse` (line 238), so the - refresh flow assumes `UserIdentifierType` is `int` (the framework alias today, per - [ADR-048](https://ivanball.github.io/docs/adr/048-primitive-identifier-type-aliases.html)); an app - that redefined the alias would need to override the refresh handling. `ExternalLoginAsync` is - intentionally **not** overridden here: the base inherits the interface's default "not supported" - failure, and OAuth account linking stays in the app subclass because it is coupled to the app's - `User` factory surface (doc comment, lines 30-31). +> MMCA.Common.Shared · `MMCA.Common.Shared.Privacy` · `MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:15` · Level 1 · record + +- **What it is**: the whole data-subject export package: a format version, a generation timestamp, + the subject's id, an app-owned snapshot of the account itself, and a list of + [`UserDataExportSectionDTO`](#userdataexportsectiondto) envelopes + (`MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:15-49`). +- **Depends on**: [`UserDataExportSectionDTO`](#userdataexportsectiondto); the + `UserIdentifierType` alias ([ADR-085](https://ivanball.github.io/docs/adr/085-identifier-type-aliases-revisited.html)); + `System.Runtime.Serialization` attributes (BCL). +- **Concept introduced, the versioned, PII-by-design document.** `[Rubric §30, Compliance / Privacy / + Data Governance]` assesses how personal data is classified and handled. Most DTOs in this codebase + carry incidental personal data; this one *is* personal data end to end, and the type says so in + bold in its own summary: "This document is **PII by design**. It exists to hand a data subject + everything an app holds about them, so it must only ever be produced for the account owner (or a + privileged role) and must never be logged, cached, or persisted by the pipeline that serves it" + (`UserDataExportDTO.cs:9-12`). That single comment is what makes three otherwise-invisible + decisions legible: the query is not `IQueryCacheable`, so the caching decorator never sees it + (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/ExportUserDataHandlerBase.cs:42-45`); + the degradation path logs the exception but hands the subject a generic reason + (`ExportUserDataHandlerBase.cs:187-190`); and the controller serializes to bytes and returns a + file rather than an `ObjectResult` + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Privacy/DataExportControllerBase.cs:104-110`). + `[Rubric §9, API & Contract Design]`: `FormatVersion` versions "the export document shape itself + (not the app's data)" (`UserDataExportDTO.cs:18-19`), so a consumer parsing an old file can detect + an envelope change rather than guess at it. +- **Walkthrough**: five `init`-only properties under `[DataContract]`, each with an explicit + `[DataMember(Order = n)]` (`UserDataExportDTO.cs:21,25,29,39,47`) pinning field order into the + contract. + - `FormatVersion`, `GeneratedOn`, and `UserId` are `required` (`:22,26,30`), so the envelope cannot + be constructed without them. + - `Subject` is `object?` (`:40`), and the doc comment gives the full reasoning: the framework owns + the envelope, each app owns which of its own fields are portable personal data, and an + `object`-typed property serializes by its *runtime* type under `System.Text.Json` (`:32-38`). + That last clause is the mechanism that makes the erasure of the static type harmless. `null` is + legal and means the app publishes no subject fields. + - `Sections` defaults to an empty collection expression, `= []` (`:48`), so an export with no + registered contributors is a well-formed document rather than a null-bearing one. Order is the + section registration order, which the comment makes part of the contract (`:42-45`). +- **Why it's built this way**: + [ADR-076](https://ivanball.github.io/docs/adr/076-data-subject-export.html) hoisted this shape out + of two near-identical app implementations. It is the export half of the data-subject obligation + whose erasure half was settled by + [ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html), which explicitly + scoped export out and left it to consumers + (`Website/docs-src/adr/076-data-subject-export.md:16-19`). +- **Where it's used**: it is the result type of the export query all the way through the stack. + [`ExportUserDataHandlerBase`](group-14-module-system-composition.md#exportuserdatahandlerbasetuser-tquery) + implements `IQueryHandler>` + (`ExportUserDataHandlerBase.cs:53`), stamps `CurrentFormatVersion = "1.0"` into it + (`ExportUserDataHandlerBase.cs:61,112`), and takes `GeneratedOn` from an injected `TimeProvider` + rather than a static clock (`ExportUserDataHandlerBase.cs:113`). + [`DataExportControllerBase`](group-12-api-hosting-mapping.md#dataexportcontrollerbasetquery) + declares it as the 200 response type (`DataExportControllerBase.cs:79`) and derives the download + file name from the package's own `GeneratedOn` so the file name and the document can never disagree + (`DataExportControllerBase.cs:126-135`). Both apps subclass the handler + (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/ExportUserDataHandler.cs:35`, + `MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ExportUserData/ExportUserDataHandler.cs:39`) + and expose it from their own `UsersController.ExportAsync` + (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:158-161`, + `MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/UsersController.cs:36-39`). +- **Caveats / not-in-source**: the "never logged, cached, or persisted" rule is a documented + discipline, not something the type or an analyzer enforces. Nothing stops a future handler from + marking its export query cacheable; the only guard today is that no shipped query does. --- diff --git a/docs-src/onboarding/group-12-api-hosting-mapping.md b/docs-src/onboarding/group-12-api-hosting-mapping.md index b10bf5d..9eda319 100644 --- a/docs-src/onboarding/group-12-api-hosting-mapping.md +++ b/docs-src/onboarding/group-12-api-hosting-mapping.md @@ -11,17 +11,18 @@ with a handful of transport-agnostic collaborators in `MMCA.Common.Application` [`JwtForwardingDelegatingHandler`](#jwtforwardingdelegatinghandler)), and `MMCA.Common.Shared` (the DTO vocabulary and [`SupportedCultures`](#supportedcultures)). The group has seven interlocking concerns: the **composition root** that registers the whole edge; the **middleware pipeline** every -request flows through in a fixed order; the **error translation** that keeps every failure shaped like -RFC 9457 Problem Details; the **controller hierarchy** that hands a module ready-made CRUD, export, -auth, and service-discovery endpoints; the **write-safety controls** (idempotency keys and conditional -writes) that make a retried or racing write predictable; the **contract surface** (DTO/request mapping, -JSON conversion, model binding, correlation, tenancy, feature gating, output caching); and the -**well-known endpoints** that make an extracted service self-describing. Read the group as the -reusable ASP.NET host a downstream service (Store, ADC, Helpdesk, or an extracted microservice) drops -into place so its own code is nothing but modules. Its central rubric column is [Rubric §9, API & -Contract Design] (consistent, versioned, standardized contracts and error shapes), with heavy -supporting roles for [Rubric §10, Cross-Cutting Concerns], [Rubric §11, Security], [Rubric §13, -Observability & Operability], [Rubric §7, Microservices Readiness], and (since +request flows through, itself expressed as ordered data rather than a hard-coded call sequence; the +**error translation** that keeps every failure shaped like RFC 9457 Problem Details; the **controller +hierarchy** that hands a module ready-made CRUD, export, auth, recovery, and service-discovery +endpoints; the **write-safety controls** (idempotency keys and conditional writes) that make a retried +or racing write predictable; the **contract surface** (DTO/request mapping, JSON conversion, model +binding, correlation, tenancy, feature gating, output caching); and the **well-known endpoints** that +make an extracted service self-describing. Read the group as the reusable ASP.NET host a downstream +service (Store, ADC, Helpdesk, or an extracted microservice) drops into place so its own code is +nothing but modules. Its central rubric column is [Rubric §9, API & Contract Design] (consistent, +versioned, standardized contracts and error shapes), with heavy supporting roles for [Rubric §10, +Cross-Cutting Concerns], [Rubric §11, Security], [Rubric §13, Observability & Operability], [Rubric §7, +Microservices Readiness], and (since [ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html)) [Rubric §27, Internationalization]. @@ -58,19 +59,19 @@ turns [`ModuleLoader`](group-14-module-system-composition.md#moduleloader) disco `module-{Name}` health checks, tagged `module` so `/health?tag=module` filters them (Healthy for enabled modules at `:192-198`, Degraded for disabled ones at `:200-207`). [`WebApplicationBuilderExtensions`](#webapplicationbuilderextensions) -(`MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:29`) +(`MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:31`) carries the identical builder-side setup every service shares: header-based API versioning through the -`api-version` header (`AddCommonApiVersioning`, line 233, reader at line 242, v1.0 assumed when the -header is absent at line 240, +`api-version` header (`AddCommonApiVersioning`, line 243, reader at line 252, v1.0 assumed when the +header is absent at line 250, [ADR-046](https://ivanball.github.io/docs/adr/046-http-api-versioning.html)), rate limiting -(`AddCommonRateLimiting`, three overloads at lines 285, 303, and 321), Brotli and Gzip compression at -`CompressionLevel.Fastest` (`AddCommonResponseCompression`, line 363, both providers pinned to -`Fastest` at lines 371-372 and 376-377 because these are dynamic per-request payloads on fractional -vCPUs), OpenAPI (line 392), CORS (line 543, +(`AddCommonRateLimiting`, three overloads at lines 295, 313, and 331), Brotli and Gzip compression at +`CompressionLevel.Fastest` (`AddCommonResponseCompression`, line 373, both providers pinned to +`Fastest` at lines 381-382 and 386-387 because these are dynamic per-request payloads on fractional +vCPUs), OpenAPI (line 402), CORS (line 579, [ADR-082](https://ivanball.github.io/docs/adr/082-two-tier-cors-posture.html), with the two policy -names as constants at lines 32 and 35 and the allow-any-origin policy reachable only in Development, -lines 563-568), and the two JWT bearer registrations: in-process `AddCommonAuthentication` (line 500) -for the Identity host and `AddForwardedJwtBearer` (line 430) for extracted services that validate +names as constants at lines 34 and 37 and the allow-any-origin policy reachable only in Development, +lines 592-596), and the two JWT bearer registrations: in-process `AddCommonAuthentication` (line 536) +for the Identity host and `AddForwardedJwtBearer` (line 444) for extracted services that validate against a remote JWKS. Only one DI ordering rule is load-bearing in the whole host, and it belongs to the CQRS pipeline group, not here: `AddApplicationDecorators` (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:89`) must run last so Scrutor @@ -79,65 +80,94 @@ order-independent. This is the [Rubric §9, API & Contract Design] and [Rubric story: versioning, compression, rate limiting, and CORS are configured once and inherited by every service instead of copy-pasted per host. -**The request pipeline, in a fixed order.** +**The request pipeline is data, not prose.** Middleware order is behavior in ASP.NET Core, so the +framework does not leave it to each host's `Program.cs`, and it no longer even leaves it as a fixed +sequence of `Use...` calls. [`WebApplicationExtensions`](#webapplicationextensions)'s `UseCommonMiddlewarePipeline` -(`MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:45`) is the -single place the middleware order is decided -([ADR-079](https://ivanball.github.io/docs/adr/079-shared-http-middleware-pipeline.html)), and the -order is deliberate: exception handling (line 47), then -[`CorrelationIdMiddleware`](#correlationidmiddleware) (48), request localization (53), forwarded -headers (79), conditional HTTPS redirect (87-89), response compression (91), routing (92), CORS -(93-95), authentication (96), [`TenantResolutionMiddleware`](#tenantresolutionmiddleware) (102), the -rate limiter (108), [`SoftDeletedUserMiddleware`](#softdeletedusermiddleware) (109), authorization -(110), output cache (111), the JWKS and OIDC discovery endpoints (118-119), and finally -`MapControllers` (121). Three of those positions are worth internalizing. The rate limiter runs -**after** authentication on purpose -([ADR-019](https://ivanball.github.io/docs/adr/019-rate-limiting.html), comment at -`WebApplicationExtensions.cs:104-107`): `GlobalRateLimitPartition` -(`WebApplicationBuilderExtensions.cs:68`) partitions by the authenticated principal and routes -anonymous traffic down a no-limiter branch (lines 75-78), so `HttpContext.User` must already be -populated or every request would look anonymous and the per-user cap would never engage; health, -liveness, `/.well-known/*`, and `application/grpc` traffic bypass the limiter outright -(`IsRateLimitBypassed`, lines 50-54). Tenant resolution sits immediately after authentication for the -mirror-image reason: its claim strategy reads `HttpContext.User` -(`TenantResolutionMiddleware.cs:111`), so running it any earlier would silently demote every request -to the header strategy. And the HTTPS redirect is skipped for any request whose content type starts -with `application/grpc` (`WebApplicationExtensions.cs:87-89`) because extracted gRPC services speak -HTTP/2 cleartext (h2c) and a 307 redirect would break the call. Forwarded-headers handling clears the -known-proxy allowlists so cloud reverse proxies are trusted regardless of their internal IPs (lines -63-64) and stashes the pre-forward scheme and host in `HttpContext.Items` under `PreForwardedSchemeKey` -and `PreForwardedHostKey` (lines 24, 35, 72-77). `UseCommonRequestLocalization` (line 133) builds the -culture options from [`SupportedCultures`](#supportedcultures) +(`MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:46`) and its +`Action` overload (`WebApplicationExtensions.cs:58`) both route through one +private `ApplyPipeline` that seeds the defaults, lets the host adjust them, validates the result, and +only then applies each step (`WebApplicationExtensions.cs:138-148`). The steps themselves are +[`MiddlewarePipelineStep`](#middlewarepipelinestep) records +(`MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/MiddlewarePipelineStep.cs:21`), each a stable +`Name` plus an `Action Configure` delegate, both null-validated on construction +(`MiddlewarePipelineStep.cs:27-30`); because a step is inert data until someone runs it, the whole +order is assertable without building a host. [`MiddlewarePipelineBuilder`](#middlewarepipelinebuilder) +(`MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/MiddlewarePipelineBuilder.cs:15`) owns the +list: `CreateDefault` (`MiddlewarePipelineBuilder.cs:31`) seeds the eighteen framework steps in order +(`:34-156`) and `InsertBefore`, `InsertAfter`, `Replace`, and `Remove` (`:166`, `:183`, `:203`, `:224`) +let a host address any of them **by name**, which is why +[`MiddlewarePipelineStepNames`](#middlewarepipelinestepnames) +(`MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/MiddlewarePipelineStepNames.cs:14`) is public +contract rather than an implementation detail: renaming a constant there is a breaking change, and the +declaration order of those constants (`:17-74`) is the runtime order. Read it top to bottom and you +have the edge: exception handler, correlation id, request localization, pre-forwarded capture, +forwarded headers, HTTPS redirection, response compression, routing, CORS, authentication, tenant +resolution, rate limiting, the soft-deleted-user filter, authorization, output cache, the JWKS and OIDC +discovery endpoints, and finally the controllers. Four of those adjacencies are load-bearing, and +`Build` (`MiddlewarePipelineBuilder.cs:257`) re-checks them before a single step is applied +(`:259-277`): the pre-forwarded capture must run immediately before `UseForwardedHeaders` or the +captured scheme and host are no longer the ones the connection saw; authentication must run immediately +before tenant resolution because the claim strategy reads `HttpContext.User`; authentication must +precede the rate limiter ([ADR-019](https://ivanball.github.io/docs/adr/019-rate-limiting.html)) +because `GlobalRateLimitPartition` keys on the authenticated principal and an unauthenticated pipeline +would see every request as anonymous; and forwarded headers must precede the HTTPS redirect so the +redirect decision reads the proxy-reported scheme. A violation throws `InvalidOperationException` at +startup with the offending order printed (`:325-326` and `:340-341`), and an invariant binds only when +**both** of its steps are still present (`:320` and `:335`), so dropping a whole capability stays +legal while reordering a pair does not. Two more decisions are worth internalizing from the default +step list: the HTTPS redirect is wrapped in a `UseWhen` that skips any request whose content type +starts with `application/grpc` (`MiddlewarePipelineBuilder.cs:90-92`), because extracted gRPC services +speak HTTP/2 cleartext (h2c) and a 307 would break the call; and the forwarded-headers step clears the +known-proxy allowlists so cloud reverse proxies are trusted regardless of their internal IPs +(`:76-77`), which is safe only because the pre-forward scheme and host were already stashed in +`HttpContext.Items` under the two keys declared at `WebApplicationExtensions.cs:22` and `:33`. Hosts +freeze their own resulting order with the opt-in fitness function `MiddlewarePipelineOrderTestsBase` +(`MMCA.Common/Source/Hosting/MMCA.Common.Testing/MiddlewarePipelineOrderTestsBase.cs:29`), whose +default expectation is the framework list verbatim (`:38-58`), so a reorder fails a fast unit test +instead of surfacing as an unreachable `jwks_uri` or a rate cap that never engages +([ADR-079](https://ivanball.github.io/docs/adr/079-shared-http-middleware-pipeline.html)). Alongside +the pipeline, `UseCommonRequestLocalization` (`WebApplicationExtensions.cs:71`) builds the culture +options from [`SupportedCultures`](#supportedcultures) (`MMCA.Common/Source/Core/MMCA.Common.Shared/Globalization/SupportedCultures.cs:9`: `en-US` as the default at line 12 and the full `en-US` plus `es` list at line 18, with the `qps-Ploc` pseudo locale at -line 28 added in Development only, `WebApplicationExtensions.cs:140-143`) so edge error localization -runs under the caller's culture, and the companion `MapCultureEndpoint` (line 162) serves the -`GET /culture/set` switch that Blazor UI hosts map -([ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html)). +line 28 added in Development only, `WebApplicationExtensions.cs:78-81`) so edge error localization runs +under the caller's culture, and the companion `MapCultureEndpoint` (`WebApplicationExtensions.cs:100`) +serves the `GET /culture/set` switch that Blazor UI hosts map +([ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html)). Turning order into +inspectable, validated data is [Rubric §10, Cross-Cutting], [Rubric §14, Testability] and +[Rubric §34, Architecture Governance] in one move. **Rate limiting: one always-on partition, one named policy, and an optional shared counter.** The global limiter is active on every request and rejects with 429 above [`RateLimitingSettings`](#ratelimitingsettings)`.GlobalPermitLimit` (default 300 requests per minute, `MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RateLimitingSettings.cs:40`) **per -authenticated user**. Because it deliberately no-ops for anonymous callers and account lockout is -per-email, a password spray (one password, many email addresses) from a single source would otherwise -be unthrottled. The framework closes that gap with the named `auth-ip` policy (`RateLimitPolicyAuthIp`, -`WebApplicationBuilderExtensions.cs:44`), whose partition selector `AuthIpRateLimitPartition` (lines -210-224) is a per-client-IP window defaulting to 30 requests per minute (`RateLimitingSettings.cs:47`) -and fails **open** on an unattributable IP (lines 214-215) rather than collapsing every such request -into one shared bucket, which would throttle the in-process test server to a standstill. Unlike the -other named limiters, this one is not left for each app to attach: +authenticated user**: `GlobalRateLimitPartition` (`WebApplicationBuilderExtensions.cs:78`) routes +anonymous traffic down a no-limiter branch (lines 85-88), and health, liveness, `/.well-known/*`, and +`application/grpc` traffic bypass the limiter outright (`IsRateLimitBypassed`, lines 60-64). Because it +deliberately no-ops for anonymous callers and account lockout is per-email, a password spray (one +password, many email addresses) from a single source would otherwise be unthrottled. The framework +closes that gap with the named `auth-ip` policy (`RateLimitPolicyAuthIp`, +`WebApplicationBuilderExtensions.cs:46`), whose partition selector `AuthIpRateLimitPartition` (line +220) is a per-client-IP window defaulting to 30 requests per minute (`RateLimitingSettings.cs:47`) and +fails **open** on an unattributable IP (lines 224-225) rather than collapsing every such request into +one shared bucket, which would throttle the in-process test server to a standstill. Unlike the other +named limiters, this one is not left for each app to attach: [`AuthControllerBase`](#authcontrollerbase) carries `[EnableRateLimiting(WebApplicationBuilderExtensions.RateLimitPolicyAuthIp)]` on both `LoginAsync` (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:57`) and -`RegisterAsync` (`AuthControllerBase.cs:79`), so every consumer inherits spray protection by -construction, while `RefreshAsync` (`AuthControllerBase.cs:98-103`) is deliberately left unthrottled -because refresh is periodic and automatic and Blazor Server circuits issue it server-side from one -shared host IP. A consumer that inherits the base without calling `AddCommonRateLimiting` fails at -startup on an unregistered policy, which is the loud failure rather than the silent one. Two knobs sit -on top of that baseline, both reachable only through the `IConfiguration` overload -(`WebApplicationBuilderExtensions.cs:303`) that binds the `RateLimiting` section. `Algorithm` -(`RateLimitingSettings.cs:53`) selects the [`RateLimitAlgorithm`](#ratelimitalgorithm) enum +`RegisterAsync` (`AuthControllerBase.cs:79`), and +[`PasswordResetAuthControllerBase`](#passwordresetauthcontrollerbasetforgotpasswordcommand-tresetpasswordcommand) +carries it on both recovery endpoints +(`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:78` +and `:102`), so every consumer inherits spray protection by construction, while `RefreshAsync` +(`AuthControllerBase.cs:98-103`) is deliberately left unthrottled because refresh is periodic and +automatic and Blazor Server circuits issue it server-side from one shared host IP. A consumer that +inherits the base without calling `AddCommonRateLimiting` fails at startup on an unregistered policy, +which is the loud failure rather than the silent one. Two knobs sit on top of that baseline, both +reachable only through the `IConfiguration` overload (`WebApplicationBuilderExtensions.cs:313`) that +binds the `RateLimiting` section. `Algorithm` (`RateLimitingSettings.cs:53`) selects the +[`RateLimitAlgorithm`](#ratelimitalgorithm) enum (`MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RateLimitAlgorithm.cs:8`): `FixedWindow` is the default and cheapest but lets a caller spend an allowance twice across a boundary, while `SlidingWindow` divides the same one-minute window into `SegmentsPerWindow` segments (default 4, @@ -146,9 +176,9 @@ is the default and cheapest but lets a caller spend an allowance twice across a [`RedisFixedWindowRateLimiter`](#redisfixedwindowratelimiter) (`MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RedisFixedWindowRateLimiter.cs:37`), so a limit means the same thing behind a load balancer as it does on one node. All three choices funnel -through one private factory, `CreateLimitedPartition` (`WebApplicationBuilderExtensions.cs:137`), which -takes the Redis path only when a `IConnectionMultiplexer` is actually registered and otherwise falls -through to the in-memory limiters rather than failing startup (lines 146-167). The Redis limiter is +through one private factory, `CreateLimitedPartition` (`WebApplicationBuilderExtensions.cs:147`), which +takes the Redis path only when an `IConnectionMultiplexer` is actually registered and otherwise falls +through to the in-memory limiters rather than failing startup (lines 156-177). The Redis limiter is worth reading for its three deliberate compromises: it stores one `INCR` counter per partition per window under `rl:{partitionKey}:{unixMinute}` (line 130) and gives it a 65 second TTL on the increment that creates it (line 142), so keys expire themselves and clock skew between instances cannot hand a @@ -160,7 +190,7 @@ healthy request is rejected. [`RedisRateLimitLease`](#redisratelimitlease) (`RedisFixedWindowRateLimiter.cs:169`) is the two-instance lease type it hands out, `Acquired` and `Rejected` as shared statics (lines 172 and 175) so a permitted request allocates nothing. The `auth-ip` policy stays per-instance whatever `Distributed` says (`allowDistributed: false`, -`WebApplicationBuilderExtensions.cs:223`): per-account lockout already backs it, and a login throttle +`WebApplicationBuilderExtensions.cs:233`): per-account lockout already backs it, and a login throttle that fails open on a Redis outage is a worse trade than one that stays local ([ADR-019](https://ivanball.github.io/docs/adr/019-rate-limiting.html) for the layering, and [ADR-029](https://ivanball.github.io/docs/adr/029-authentication-brute-force-protection.html) for the @@ -196,9 +226,10 @@ soft-deleted-user middleware below. It is wired unconditionally but inert by def `Enabled` false in a host that never called `AddMultiTenancy` (line 62). And it fails **closed**: with `Tenancy:RequireTenant` on, a request that resolves no tenant is rejected at line 83 and answered 400 with an RFC 9457 body naming the claim and header it looked at (lines 133-146), because an unscoped -request would read across every tenant, which is the exact outcome tenancy exists to prevent; health, -liveness, and discovery paths are excluded so probes still answer before any tenant exists (lines -89-94). [`SoftDeletedUserMiddleware`](#softdeletedusermiddleware) +request would read across every tenant, which is the exact outcome tenancy exists to prevent; an +explicit `RequireTenant` opt-out lets the request run as a system caller instead (lines 75-81), and +health, liveness, and discovery paths are excluded so probes still answer before any tenant exists +(lines 89-94). [`SoftDeletedUserMiddleware`](#softdeletedusermiddleware) (`MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/SoftDeletedUserMiddleware.cs:31`) enforces business rule BR-133: an authenticated caller whose account was soft-deleted is rejected with a bare 401 (lines 104 and 145), checked first against a marker cached for 30 seconds @@ -210,7 +241,7 @@ resolves [`ISoftDeletedUserValidator`](group-08-auth.md#isoftdeleteduservalidato `RequestServices` (line 75) instead of as an `InvokeAsync` parameter, so a service that does not host Identity passes the request through rather than 500-ing on every call: an explicit nod to the [Rubric §7, Microservices Readiness] extraction path. And unlike tenancy it fails **open**: a cache -read that throws falls back to the validator query (lines 93-100), and a validator query that throws +read that throws falls back to the validator query (lines 93-99), and a validator query that throws lets the request continue (lines 118-126), because failing closed would turn any cache or database blip into a total outage for every authenticated request, while the exposure it buys back is bounded by the access-token lifetime. All three middlewares are [Rubric §13, Observability & Operability] @@ -235,7 +266,7 @@ list is empty (lines 29-35), and the safety net [`UnhandledResultFailureFilter`](#unhandledresultfailurefilter) (`MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/UnhandledResultFailureFilter.cs:21`, an `IAlwaysRunResultFilter`) catches any action that accidentally returned a failed `Result` as a 200 -body, logs a warning, and rewrites it as the correct error (lines 27-49). All of those paths converge +body, logs a warning, and rewrites it as the correct error (lines 25-49). All of those paths converge on [`ErrorHttpMapping`](#errorhttpmapping) (`MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/ErrorHttpMapping.cs:14`), whose `FrozenDictionary` (lines 20-30) is the single source of truth mapping each @@ -290,17 +321,26 @@ for reads and from for writes, `TEntityDTO` implements [`IBaseDTO`](#ibasedtotidentifiertype), and `TCreateRequest` implements [`ICreateRequest`](group-05-cqrs-pipeline.md#icreaterequest) (`IEntityControllerBase.cs:17-18`, `IAggregateRootEntityControllerBase.cs:20-22`). Alongside the CRUD -tower sit five special-purpose bases: [`AuthControllerBase`](#authcontrollerbase) +tower sit six special-purpose bases: [`AuthControllerBase`](#authcontrollerbase) (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:41`, anonymous login, register, and refresh over [`IAuthenticationService`](group-08-auth.md#iauthenticationservice), lines 54-103, plus an `[Authorize]` revoke at lines 117-122); +[`PasswordResetAuthControllerBase`](#passwordresetauthcontrollerbasetforgotpasswordcommand-tresetpasswordcommand) +(`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:43`), +a **sibling** of that base rather than an addition to it because each app's own `AuthController` +already occupies the single-inheritance chain (`PasswordResetAuthControllerBase.cs:13-18`), serving +`POST forgot-password` (line 82) and `POST reset-password` (line 107); both are anonymous by necessity +since the caller has lost the credential, forgot-password always answers 202 whether or not the address +exists (line 92) so the response never reveals which addresses hold accounts, and each app supplies its +own command record through a one-line factory +([ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html)); [`UserAccountAuthControllerBase`](#useraccountauthcontrollerbasetchangepasswordcommand-tchangepreferencescommand) (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:40`), -which subclasses it purely additively (line 46) and adds the self-service account endpoints -`PUT password` (line 86), `PUT preferences` (line 112), and `GET preferences` (line 138), taking the -two mutation commands as type parameters because each app owns its own command record while the -preferences query is shared (`UserAccountAuthControllerBase.cs:47-48`); +which subclasses `AuthControllerBase` purely additively (line 46) and adds the self-service account +endpoints `PUT password` (line 86), `PUT preferences` (line 112), and `GET preferences` (line 138), +taking the two mutation commands as type parameters because each app owns its own command record while +the preferences query is shared (`UserAccountAuthControllerBase.cs:47-48`); [`OAuthControllerBase`](#oauthcontrollerbase) (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:33`, the Google and GitHub external-provider flow whose single-use exchange code, cached for two minutes at @@ -322,10 +362,10 @@ record (`CreateQuery`, line 120); and (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/ServiceInfoControllerBase.cs:30`), whose dual-version `/ServiceInfo` returns [`ServiceInfoResponse`](#serviceinforesponse) for the deprecated v1.0 (line 51) and [`ServiceInfoV2Response`](#serviceinfov2response) for v2.0 (line 54, a superset -adding the supported and deprecated version lists at lines 32-33), proving the versioning machinery -works across versions (`[MapToApiVersion]` at lines 40 and 46). All of these carry the same note: -class-level routing and versioning attributes are not reliably inherited, so the per-service sealed -subclass supplies them. This is the clearest [Rubric §5, Vertical Slice] and [Rubric §16, +adding the supported and deprecated version lists held at lines 32-33), proving the versioning +machinery works across versions (`[MapToApiVersion]` at lines 40 and 46). All of these carry the same +note: class-level routing and versioning attributes are not reliably inherited, so the per-service +sealed subclass supplies them. This is the clearest [Rubric §5, Vertical Slice] and [Rubric §16, Maintainability] payoff in the presentation layer: a module writes a DTO, a mapper, and a short sealed subclass, and inherits a fully paged, filterable, exportable, error-mapped REST resource, with [Rubric §30, Compliance & Data Governance] covered by the DSAR base. @@ -347,14 +387,22 @@ records with CRLF regardless of host OS (line 48), formats cells invariantly so the same bytes on every machine (`FormatCell`, lines 128-143: lowercase booleans to match the sibling JSON, ISO 8601 round-trip "O" for timestamps), and writes a UTF-8 BOM explicitly (lines 45 and 69, paired with the preamble-free encoding at line 55) because Excel reads a BOM-less UTF-8 CSV in the -machine's ANSI code page. The controller opens a `StreamWriter` over `Response.Body` without committing -the response (line 261), which is what keeps the "a failure on page one still returns Problem Details" -path honest, and the row ceiling is announced up front through the `X-Export-Row-Limit` header -(constant at line 493, default 100,000 at line 484, overridable per host through `MaxExportRows` at -lines 77-85) with the truncation notice written as a final body line, because headers are frozen the -moment the first byte flushes. Row scoping is a hook, not a default: `GetExportSpecification` returns -null (line 542), so a controller whose list endpoints row-scope reads must override it. That is a -[Rubric §12, Performance & Scalability] and [Rubric §11, Security] pairing worth reading closely. +machine's ANSI code page. Two guards run before any byte is written. Columns a CSV cannot represent +faithfully, binary concurrency tokens and every non-string collection property, are computed once per +closed controller type and dropped (`UnexportablePropertyNames` at `EntityControllerBase.cs:555`, +the type test at `:583`); value objects and other class-typed properties are deliberately kept, since +their invariant `ToString` is exactly the cell a reader expects. And a caller who names one of those +dropped properties in `fields=` gets an `Error.InvalidEntityField` validation failure rather than a +quietly missing column (`ValidateExportFields`, `:601`, called at `:243`). Then the controller opens a +`StreamWriter` over `Response.Body` without committing the response (line 261), which is what keeps the +"a failure on page one still returns Problem Details" path honest, and the row ceiling is announced up +front through the `X-Export-Row-Limit` header (constant at line 493, default 100,000 at line 484, +overridable per host through `MaxExportRows` at lines 77-85, written alongside the +`Content-Disposition` attachment name at lines 629-630) with the truncation notice written as a final +body line (line 333), because headers are frozen the moment the first byte flushes. Row scoping is a +hook, not a default: `GetExportSpecification` returns null (line 542), so a controller whose list +endpoints row-scope reads must override it. That is a [Rubric §12, Performance & Scalability] and +[Rubric §11, Security] pairing worth reading closely. **Idempotency for safe retries.** Write endpoints are made replay-safe by [`IdempotentAttribute`](#idempotentattribute) @@ -384,8 +432,8 @@ distributed lock the filter waits `LockWait` (5 seconds, line 106) for a lease l memorizing. A hit replays the stored [`IdempotencyRecord`](#idempotencyrecord) (`IdempotencyRecord.cs:17`, a status code plus JSON body plus the request-body hash) with an `X-Idempotent-Replay: true` header (line 387), as a bare `StatusCodeResult` when the stored body is -empty so a replayed 204 does not acquire a content type (lines 386-393). A key reused with a -**different** payload is answered **422 Unprocessable Entity** rather than replayed (lines 373-380 and +empty so a replayed 204 does not acquire a content type (lines 388-395). A key reused with a +**different** payload is answered **422 Unprocessable Entity** rather than replayed (lines 375-382 and `BodyMismatchResult` at 324-333), because replaying would tell the client a genuinely new write succeeded when nothing ran. A duplicate that cannot take the lock within the wait and finds nothing cached gets **409 Conflict** (lines 263-275 and `InFlightDuplicateResult` at 303-312), which is @@ -402,13 +450,14 @@ window would defeat the retry the header exists to enable. All three behaviors a [`IdempotencyMetrics`](#idempotencymetrics) (`MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyMetrics.cs:16`) publishes `idempotency.replayed` (line 37), `idempotency.conflict` tagged `kind=body_mismatch` or `in_flight` -(lines 24-32 and 42), and `idempotency.degraded` (line 47) on the `MMCA.Common.Idempotency` meter (line -19), so a sustained degraded rate says out loud that deduplication is effectively off. The one thing -the filter cannot do is notice an endpoint that forgot to opt in, which is what +(lines 24, 29, and 42), and `idempotency.degraded` (line 47) on the `MMCA.Common.Idempotency` meter +(line 19), so a sustained degraded rate says out loud that deduplication is effectively off. The one +thing the filter cannot do is notice an endpoint that forgot to opt in, which is what [`NonIdempotentAttribute`](#nonidempotentattribute) (`MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/NonIdempotentAttribute.cs:23`) exists for: it attaches no pipeline stage and changes no behavior, it only records a required `Justification` -string (line 28), and its sole consumer is the `PostActionsDeclareIdempotencyIntent` fitness function, +string (line 28), and its sole consumer is the `PostActionsDeclareIdempotencyIntent` fitness function +(`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Idempotency.cs:44`), which fails the build unless every POST action carries either `[Idempotent]` or this attribute. That is why [`AuthControllerBase`](#authcontrollerbase) reads the way it does: `RegisterAsync` is `[Idempotent]` (`AuthControllerBase.cs:77`) while login, refresh, and revoke each carry a written reason for staying @@ -440,14 +489,14 @@ unlike [`IdempotentAttribute`](#idempotentattribute), needs no scoped service an `IAsyncActionFilter` directly and needs no DI registration (lines 41-44). Before the action it decodes the header and writes the token into every bound argument that implements [`IConcurrencyAware`](#iconcurrencyaware) and does not already carry one (lines 120-135, through a -cached `RowVersion` setter so `init`-only record properties stay `init`-only, lines 61 and 165-172); +cached `RowVersion` setter so `init`-only record properties stay `init`-only, lines 61 and 159-172); after the action it rewrites a conflict outcome to **412 Precondition Failed** (lines 178-207), covering both the 409 result and the raw `DbUpdateConcurrencyException`. Three decisions make the two mechanisms coexist. **Body precedence**: an argument that already carries a `RowVersion` is left alone and keeps its existing 409 semantics untouched, so an older client posting the token in the body sees no change at all (lines 151-154). **Only the header path is rewritten**, which is what keeps 412 meaning "the precondition you stated failed" and 409 meaning "a conflict you did not condition on". -And a **malformed** `If-Match` short-circuits with a 400 rather than being ignored (lines 114-118 and +And a **malformed** `If-Match` short-circuits with a 400 rather than being ignored (lines 70-74 and 210-219), because silently dropping a precondition the client believed it had set is the one outcome worse than rejecting it. Alongside it, [`ConcurrencyTokenRequest`](#concurrencytokenrequest) (`MMCA.Common/Source/Core/MMCA.Common.Shared/DTOs/ConcurrencyTokenRequest.cs:12`) remains the @@ -461,9 +510,9 @@ to every request, which the built-in ASP.NET policy refuses to do. (`MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/PublicEndpointOutputCachePolicy.cs:35`), registered by name through [`OutputCacheOptionsExtensions`](#outputcacheoptionsextensions) (`MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/OutputCacheOptionsExtensions.cs:6`, both -overloads at lines 20-21 and 34-35), drops that identity bail-out (line 69), varies by every -query-string key (line 81) so search, paging, filtering and field projection each get their own entry, -refuses to store responses that set cookies or are not plain 200s (lines 100-103), and offers a +overloads at lines 20-21 and 34-35), drops that identity bail-out (lines 68-70), varies by every +query-string key (lines 79-81) so search, paging, filtering and field projection each get their own +entry, refuses to store responses that set cookies or are not plain 200s (lines 100-103), and offers a `bypassRoles` escape hatch so a privileged caller who receives an elevated payload always reads fresh, skipping both lookup and storage (line 113, [ADR-040](https://ivanball.github.io/docs/adr/040-authenticated-output-caching-for-public-reads.html)). @@ -472,7 +521,7 @@ other instance serving stale bytes. [`OutputCacheEvictionHandler`](#outputcachee (`MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/OutputCacheEvictionHandler.cs:32`) closes that gap by consuming the [`OutputCacheEvictionRequested`](group-04-events-outbox.md#outputcacheevictionrequested) integration -event and calling `EvictByTagAsync` for each tag against **this** host's store (lines 44-63); no +event and calling `EvictByTagAsync` for each tag against **this** host's store (lines 38-63); no MassTransit type appears in it, so the same handler is reachable from the in-process dispatcher and from the broker (ADR-026). Its two behaviors are both deliberate: eviction is **per-tag best effort**, so a store that throws on one tag is logged and counted rather than rethrown (lines 56-62), because @@ -573,7 +622,19 @@ serves the minimal discovery document the JWT middleware fetches when `AddForwar authority: it returns 404 when `Jwt:Issuer` is not configured (lines 63-66), derives `jwks_uri` from that configured issuer rather than from the inbound request (line 76) so issuer and JWKS URI stay origin-aligned, and disables the camelCase naming policy (line 45) because RFC 8414 field names are -snake_case and `jwksUri` would not be recognized. +snake_case and `jwksUri` would not be recognized. `AddForwardedJwtBearer` itself resolves +`RequireHttpsMetadata` in three steps, explicit argument, then the +`Authentication:JwtBearer:RequireHttpsMetadata` key (constant at +`WebApplicationBuilderExtensions.cs:54`), then "true outside Development" +(`WebApplicationBuilderExtensions.cs:456-458`); a resolved `false` outside Development is honored, +because an internal-ingress h2c authority genuinely has no HTTPS metadata, but it is never silent: +[`InsecureJwtMetadataWarningStartupFilter`](#insecurejwtmetadatawarningstartupfilter) +(`MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/InsecureJwtMetadataWarningStartupFilter.cs:15`) +is registered exactly then (`WebApplicationBuilderExtensions.cs:460-464`) and logs one startup warning +naming the key (`InsecureJwtMetadataWarningStartupFilter.cs:26-29`). It is an `IStartupFilter` rather +than a log line at registration time for a reason worth remembering: while the service collection is +being built the logging providers are not configured yet, so a warning written there is dropped +(`InsecureJwtMetadataWarningStartupFilter.cs:7-13`). [`JwtForwardingDelegatingHandler`](#jwtforwardingdelegatinghandler) (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Http/JwtForwardingDelegatingHandler.cs:17`) copies the caller's inbound `Authorization` header onto outgoing HTTP calls, unless one was already set (lines @@ -592,19 +653,20 @@ migrations (line 83 into `ThrowIfPendingMigrationsAsync` at line 191; [ADR-030](https://ivanball.github.io/docs/adr/030-startup-sole-migrator.html), [ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)), repeats the same strategy per tenant that keeps its own copy of a source, each in a fresh scope with its tenant set -(line 91 into `InitializeTenantDatabasesAsync` at line 112, scope and tenant at lines 127-128, +(line 91 into `InitializeTenantDatabasesAsync` at line 112, [ADR-073](https://ivanball.github.io/docs/adr/073-multi-tenancy-model.html)), and finishes by running the enabled modules' seeders on the default scope only (line 98). Five smaller startup helpers round out the host: [`OpenApiEndpointExtensions`](#openapiendpointextensions) -(`MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/OpenApiEndpointExtensions.cs:18`) maps the -per-version OpenAPI document (lines 32-34) and the optional Scalar reference UI (lines 48-50) outside -Production only, [`ApiParameterDescriptorBackfillProvider`](#apiparameterdescriptorbackfillprovider) +(`MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/OpenApiEndpointExtensions.cs:22`) maps the +per-version OpenAPI document (`MapCommonOpenApi`, lines 34-38) and the optional Scalar reference UI +(`MapCommonScalarUi`, lines 52-56), both outside Production only, +[`ApiParameterDescriptorBackfillProvider`](#apiparameterdescriptorbackfillprovider) (`MMCA.Common/Source/Presentation/MMCA.Common.API/OpenApi/ApiParameterDescriptorBackfillProvider.cs:43`) fills in the placeholder descriptor MVC leaves null on an unbound route token (lines 65-71) so a URL-segment-versioned or `{tenant}`-templated route cannot turn document generation into a 500, running last by ordering itself at `int.MinValue` (line 46) and registered exactly once through `TryAddEnumerable` however many helpers a host calls -(`WebApplicationBuilderExtensions.cs:250`, `:395`, and the helper itself at `:407-409`), +(`WebApplicationBuilderExtensions.cs:260`, `:405`, and the helper itself at `:417-419`), [`SignalRExtensions`](#signalrextensions) (`MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/SignalRExtensions.cs:12`) maps [`NotificationHub`](group-10-notifications.md#notificationhub) at its configured path when push @@ -837,14 +899,14 @@ code stays modules and domain logic, never plumbing. - **What it is**: an anonymous, read-only discovery controller that proves the API-versioning machinery works across more than one version. The same `/ServiceInfo` route is served by v1.0 (deprecated) and v2.0, selected via the `api-version` header. - **Depends on**: `Asp.Versioning` (`MapToApiVersion`) and ASP.NET Core MVC (`ControllerBase`); returns [ServiceInfoResponse](#serviceinforesponse) and [ServiceInfoV2Response](#serviceinfov2response), both nested in this file. -- **Concept introduced: header-based API versioning as a first-class contract.** `[Rubric §9, API & Contract Design]` assesses whether an API can carry multiple versions concurrently and signal deprecation; this controller demonstrates the whole loop: two versions on one route, one marked deprecated, and `ReportApiVersions = true` (set in `AddCommonApiVersioning` on [WebApplicationBuilderExtensions](#webapplicationbuilderextensions), `WebApplicationBuilderExtensions.cs:241`) so responses carry `api-supported-versions` / `api-deprecated-versions` headers (class doc, `ServiceInfoControllerBase.cs:6-14`). [ADR-046](https://ivanball.github.io/docs/adr/046-http-api-versioning.html) makes the point that a versioning claim which only ever ships `v1.0` is untestable; this endpoint is what makes it testable. +- **Concept introduced: header-based API versioning as a first-class contract.** `[Rubric §9, API & Contract Design]` assesses whether an API can carry multiple versions concurrently and signal deprecation; this controller demonstrates the whole loop: two versions on one route, one marked deprecated, and `ReportApiVersions = true` (set in `AddCommonApiVersioning` on [WebApplicationBuilderExtensions](#webapplicationbuilderextensions), `WebApplicationBuilderExtensions.cs:251`, inside the extension member declared at `WebApplicationBuilderExtensions.cs:243`) so responses carry `api-supported-versions` / `api-deprecated-versions` headers (class doc, `ServiceInfoControllerBase.cs:6-14`). [ADR-046](https://ivanball.github.io/docs/adr/046-http-api-versioning.html) makes the point that a versioning claim which only ever ships `v1.0` is untestable; this endpoint is what makes it testable. - **Walkthrough** - `Supported = ["1.0", "2.0"]` and `Deprecated = ["1.0"]` (`ServiceInfoControllerBase.cs:32-33`) are the static version lists the v2 payload echoes. - `ServiceName` (`ServiceInfoControllerBase.cs:36`) is an abstract property the sealed per-service subclass supplies, because class-level routing/versioning attributes are not reliably inherited (remarks, `ServiceInfoControllerBase.cs:15-29`): the subclass carries `[ApiController]`, `[Route("[controller]")]`, `[AllowAnonymous]`, and the two `[ApiVersion]` attributes. - `GetV1()` (`ServiceInfoControllerBase.cs:41`) is `[HttpGet]` plus `[MapToApiVersion("1.0")]` (`ServiceInfoControllerBase.cs:39-40`) and returns the minimal [ServiceInfoResponse](#serviceinforesponse). - `GetV2()` (`ServiceInfoControllerBase.cs:47`) is `[MapToApiVersion("2.0")]` (`ServiceInfoControllerBase.cs:46`) and returns the superset [ServiceInfoV2Response](#serviceinfov2response) with the supported/deprecated lists. - **Why it's built this way**: the type is abstract with an abstract `ServiceName` so each extracted service reuses the identical versioning surface while stamping its own identity, keeping the "build the monolith now, extract a service later" path uniform (`[Rubric §7, Microservices Readiness]`). The endpoint is anonymous and reached on the service host directly; gateways do not route it (class doc, `ServiceInfoControllerBase.cs:12-13`). -- **Where it's used**: subclassed by each service's sealed `ServiceInfoController`, for example ADC's Conference service (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/ServiceInfoController.cs:20`) and Store's Catalog service (`MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/Controllers/ServiceInfoController.cs:20`). Because the controller ships in the framework, the fitness contract that exercises it is shared too: `ServiceInfoVersioningContractTestsBase` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/ServiceInfoVersioningContractTestsBase.cs:19`), and a repo subclasses it supplying only its fixture. +- **Where it's used**: subclassed by each service's sealed `ServiceInfoController`, for example ADC's Conference service (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/ServiceInfoController.cs:20`) and Store's Catalog service (`MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/Controllers/ServiceInfoController.cs:20`). Because the controller ships in the framework, the fitness contract that exercises it is shared too: [ServiceInfoVersioningContractTestsBase](group-27-testing-infrastructure.md#serviceinfoversioningcontracttestsbasetfixture) (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/ServiceInfoVersioningContractTestsBase.cs:19`), and a repo subclasses it supplying only its fixture. ### IEntityControllerBase > MMCA.Common.API · `MMCA.Common.API.Controllers` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/IEntityControllerBase.cs:14` · Level 2 · interface @@ -870,8 +932,8 @@ code stays modules and domain logic, never plumbing. - Null/empty guard (`ApiControllerBase.cs:27-35`): with no errors it returns a 500 "Unknown error", treating an empty failure as a programming mistake rather than a domain outcome. - First-error-drives-status (`ApiControllerBase.cs:38`): `ErrorHttpMapping.GetStatusCode(errorList[0].Type)` picks the status from the first error's [ErrorType](group-01-result-error-handling.md#errortype); the convention, spelled out in the inline comment just above it (`ApiControllerBase.cs:37`), is that callers order the most significant error first. - Builds a `ProblemDetails` with that status and a fixed title/detail (`ApiControllerBase.cs:40-45`), attaches `Extensions["errors"]` via `ErrorHttpMapping.BuildErrorsExtension` (`ApiControllerBase.cs:48`), optionally localized through an [IErrorLocalizer](#ierrorlocalizer) resolved with `GetService` (`ApiControllerBase.cs:47`, so a host without localization simply passes `null`), then returns `StatusCode(statusCode, problemDetails)` (`ApiControllerBase.cs:50`). -- **Why it's built this way**: one `virtual` method instead of a `switch` in every action removes duplication and makes the response shape uniform ([ADR-013](https://ivanball.github.io/docs/adr/013-result-pattern.html) for why failures are values rather than exceptions in the first place); keeping it `virtual` lets a subclass ([EntityControllerBase](#entitycontrollerbasetentity-tentitydto-tidentifiertype)) wrap it with logging without reimplementing the mapping. The two `ErrorHttpMapping` members are `internal static` (`MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/ErrorHttpMapping.cs:36` and `ErrorHttpMapping.cs:47`), which is what lets [UnhandledResultFailureFilter](#unhandledresultfailurefilter) reuse the same status-code mapping and the same `errors` extension array for a failed `Result` that an action returned without calling `HandleFailure` (`UnhandledResultFailureFilter.cs:36` and `:47`). The two bodies are deliberately *not* identical: the filter labels its `ProblemDetails` `Title`/`Detail` "Unhandled result failure" / "The action returned a Result.Failure that was not mapped to an HTTP error response." (`UnhandledResultFailureFilter.cs:42-43`) against the base's "Operation failed" / "One or more errors occurred." (`ApiControllerBase.cs:43-44`), so a response that fell through the filter is distinguishable from one the controller mapped on purpose. Localization is the [ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html) extension point, keyed by `Error.Code` and leaving `Code`/`Type`/`Source`/`Target` verbatim so clients can still branch on them (`ErrorHttpMapping.cs:47-55`). -- **Where it's used**: the root of the controller hierarchy. [EntityControllerBase](#entitycontrollerbasetentity-tentitydto-tidentifiertype), [AuthControllerBase](#authcontrollerbase), [DataExportControllerBase](#dataexportcontrollerbasetquery), and every module controller derive from it directly or transitively. +- **Why it's built this way**: one `virtual` method instead of a `switch` in every action removes duplication and makes the response shape uniform ([ADR-013](https://ivanball.github.io/docs/adr/013-result-pattern.html) for why failures are values rather than exceptions in the first place); keeping it `virtual` lets a subclass ([EntityControllerBase](#entitycontrollerbasetentity-tentitydto-tidentifiertype)) wrap it with logging without reimplementing the mapping. The two `ErrorHttpMapping` members are `internal static` (`MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/ErrorHttpMapping.cs:36` and `ErrorHttpMapping.cs:47`), which is what lets [UnhandledResultFailureFilter](#unhandledresultfailurefilter) reuse the same status-code mapping and the same `errors` extension array for a failed `Result` that an action returned without calling `HandleFailure` (`UnhandledResultFailureFilter.cs:36` and `:47`). The two bodies are deliberately *not* identical: the filter labels its `ProblemDetails` `Title`/`Detail` "Unhandled result failure" / "The action returned a Result.Failure that was not mapped to an HTTP error response." (`UnhandledResultFailureFilter.cs:42-43`) against the base's "Operation failed" / "One or more errors occurred." (`ApiControllerBase.cs:43-44`), so a response that fell through the filter is distinguishable from one the controller mapped on purpose. Localization is the [ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html) extension point, keyed by `Error.Code` and leaving `Code`/`Type`/`Source`/`Target` verbatim so clients can still branch on them (`ErrorHttpMapping.cs:42-45` for the contract, `ErrorHttpMapping.cs:48-55` for the projection that honours it). +- **Where it's used**: the root of the controller hierarchy. [EntityControllerBase](#entitycontrollerbasetentity-tentitydto-tidentifiertype), [AuthControllerBase](#authcontrollerbase), [PasswordResetAuthControllerBase](#passwordresetauthcontrollerbasetforgotpasswordcommand-tresetpasswordcommand), [DataExportControllerBase](#dataexportcontrollerbasetquery), and every module controller derive from it directly or transitively. ### IAggregateRootEntityControllerBase > MMCA.Common.API · `MMCA.Common.API.Controllers` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/IAggregateRootEntityControllerBase.cs:15` · Level 3 · interface @@ -912,7 +974,7 @@ code stays modules and domain logic, never plumbing. - **Column resolution is derived, not invented**: `ResolveExportColumns` (`:690`) takes the shaped keys of the first row, so the CSV columns for a given `fields=` request are the same camelCase JSON names the JSON endpoints emit; an empty result still gets a header row built from the DTO's own properties in declaration order (`:696-708`). `UnexportablePropertyNames` (`:555-561`) drops the properties that cannot render a faithful scalar cell, `byte[]`/`ReadOnlyMemory` concurrency tokens and every collection type except `string` (`IsExportableType`, `:583-586`), computed once per closed controller type since a DTO's shape cannot change at runtime. Value objects and other class-typed properties are deliberately NOT dropped, because a record or value object has a meaningful invariant `ToString` (`:550-554`). - **Why it's built this way**: the controller stays thin. All filtering/sorting/paging lives in [IEntityQueryService](group-03-querying-specifications.md#ientityqueryservicetentity-tentitydto-tidentifiertype), and manual DTO mapping ([ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html)) keeps entities off the wire. The controller only translates HTTP concerns: query strings, headers, status codes. The export reuses the paged read wholesale precisely so it cannot disagree with the grid it was launched from, and the row ceiling (`DefaultMaxExportRows = 100_000`, `:484`, matching `ApplicationSettings.MaxExportRows`'s own default) is a number an operator can reason about rather than an unbounded connection hold. - **Caveats / not-in-source**: [ADR-078](https://ivanball.github.io/docs/adr/078-csv-export-endpoint.html) describes truncation as signalled by an `X-Export-Truncated: true` response header; the shipped code sends no such header. It always sends `X-Export-Row-Limit` instead and carries the truncation fact in the trailing body record, with the remarks stating why a header cannot work (`:197-205`). Trust the code. The export is also **not** output-cached and inherits only whatever `[Authorize]`/`[FeatureGate]` the derived controller declares (`:190-196`). -- **Where it's used**: the base for every read-only module controller, for example ADC's child-collection controllers `SessionSpeakersController` and `CategoryItemsController`. Extended by [AggregateRootEntityControllerBase](#aggregaterootentitycontrollerbasetentity-tentitydto-tidentifiertype-tcreaterequest) for entities that also create and delete. The `GetExportSpecification` hook is overridden today by Store's owner-scoped `OrdersController` and `ShoppingCartsController`. Framework coverage lives in `EntityControllerBaseTests`, `EntityControllerBaseExportTests`, and `EntityControllerBaseETagTests` (`MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/`). +- **Where it's used**: the base for every read-only module controller, for example ADC's child-collection controllers `SessionSpeakersController` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:56`) and `CategoryItemsController` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:69`). Extended by [AggregateRootEntityControllerBase](#aggregaterootentitycontrollerbasetentity-tentitydto-tidentifiertype-tcreaterequest) for entities that also create and delete. The `GetExportSpecification` hook is overridden today by Store's owner-scoped `OrdersController` (`MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.API/Controllers/OrdersController.cs:270`) and `ShoppingCartsController` (`MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.API/Controllers/ShoppingCartsController.cs:224`); Store's `CustomersController` deliberately does not, and records why (its list endpoints are Admin-only rather than row-scoped, so there is no ownership specification to reproduce, `MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/CustomersController.cs:84-98`). Framework coverage lives in `EntityControllerBaseTests`, `EntityControllerBaseExportTests`, and `EntityControllerBaseETagTests` (`MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/`). ### OAuthControllerBase > MMCA.Common.API · `MMCA.Common.API.Controllers` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:33` · Level 6 · class (abstract) @@ -926,24 +988,24 @@ code stays modules and domain logic, never plumbing. - `CompleteAsync` (`:76`): after the middleware handles the provider callback, this reads the external cookie (`:79`), redirects to `/login?error=oauth_failed` when the ticket did not survive (`:81-86`), reads the stashed `returnUrl` with a `GetString` fallback to `"/"` rather than the throwing `Items` indexer (`:88-91`), extracts provider claims (`ExtractClaims`, `:173`), calls `ExternalLoginAsync` to find or create the local user and mint tokens (`:101-102`), signs out the temporary external cookie (`:112`), then mints a 32-byte hex `exchangeCode` (`:117`), stashes the token pair in the cache under it (`:118-119`), and redirects with only the code (`:121`, URL built at `:124-127`). - Name handling is defensive: `ExtractName` (`:183`) prefers `GivenName`/`Surname` claims and otherwise splits the `Name` claim, falling back to `("User", "")` when there is no usable space-separated name (`:197-211`), so a provider that returns only a display name still yields a creatable local account. - Native heads ([ADR-043](https://ivanball.github.io/docs/adr/043-mobile-deep-links-and-native-oauth-callback.html)): `GetAllowedMobileReturnUrl` (`:235`) returns the stashed `returnUrl` as the redirect target only when it is an absolute URI whose custom scheme is listed in `OAuth:AllowedReturnUrlSchemes`; http/https never match (`:237-242`), so the allowlist cannot become an open redirect, and a missing or empty section (or a test double returning `null` from `GetSection`) means "no allowlist", the exact pre-ADR-043 behavior (`:244-248`). - - `ExchangeAsync` (`:140`): `[HttpPost("exchange")]` with `[NonIdempotent(...)]` and `[AllowAnonymous]` (`:136-139`); the UI swaps the code for the real [AuthenticationResponse](group-08-auth.md#authenticationresponse) out-of-band. Because that response is a `readonly record struct`, a cache miss yields a default value rather than `null`, so the miss is detected via an empty `AccessToken` (`:151-157`). The code is then removed (`:160`), making it single-use so a leaked or replayed code cannot mint a second token pair. Both failure paths return the same opaque 400 "Invalid sign-in code" (`:165-171`). + - `ExchangeAsync` (`:140`): `[HttpPost("exchange")]` with `[NonIdempotent(...)]` and `[AllowAnonymous]` (`:136-139`); the UI swaps the code for the real [AuthenticationResponse](group-08-auth.md#authenticationresponse) out-of-band. Because that response is a struct, a cache miss yields a default value rather than `null`, so the miss is detected via an empty `AccessToken` (`:151-157`). The code is then removed (`:160`), making it single-use so a leaked or replayed code cannot mint a second token pair. Both failure paths return the same opaque 400 "Invalid sign-in code" (`:165-171`). - **Why it's built this way**: carrying tokens in a redirect is the classic OAuth token-leak vector; the single-use code plus a short-lived server-side stash closes it while keeping the client flow a plain redirect and one POST. The `[NonIdempotent]` justification on `ExchangeAsync` (`:137`) records why this one endpoint must stay outside the replay contract: replaying the stored response would defeat the burn, letting a leaked code mint the same tokens again for the whole retention window. The `AppendQuery` helper (`:251`) deliberately uses `OriginalString` rather than `ToString()`, because `Uri` normalization appends a trailing slash to authority-only URIs (`atldevcon://oauth-complete`) and native authenticator callback matching can be exact (`:253-255`). - **Caveats / not-in-source**: the provider scheme registration and the concrete `ExternalLoginAsync` implementation live outside this base ([ExternalAuthExtensions](#externalauthextensions) and the app's [IAuthenticationService](group-08-auth.md#iauthenticationservice)); this file assumes both are wired. -- **Where it's used**: subclassed by each app's sealed OAuth controller, for example ADC's `OAuthController` (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/OAuthController.cs:20`), which adds only the class-level routing and versioning attributes. +- **Where it's used**: subclassed by each app's sealed OAuth controller, for example ADC's `OAuthController` (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/OAuthController.cs:20`), which adds only the class-level routing and versioning attributes. `ExchangeAsync` is one of the endpoints the framework's anonymous-endpoint architecture gate lists by name (`MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTests.cs:31`), so its `[AllowAnonymous]` is an approved exception rather than an oversight. ### AggregateRootEntityControllerBase > MMCA.Common.API · `MMCA.Common.API.Controllers` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AggregateRootEntityControllerBase.cs:27` · Level 7 · class (abstract) - **What it is**: the read-write tier of the controller hierarchy. It extends [EntityControllerBase](#entitycontrollerbasetentity-tentitydto-tidentifiertype) (the read endpoints) by adding a `CreateAsync` (POST) and a `DeleteAsync` (DELETE) for aggregate-root entities. - **Depends on**: [EntityControllerBase](#entitycontrollerbasetentity-tentitydto-tidentifiertype) (base), [IAggregateRootEntityControllerBase](#iaggregaterootentitycontrollerbasetentitydto-tidentifiertype-tcreaterequest) (implements), [ICommandHandler](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) (create and delete handlers), [DeleteEntityCommand](group-05-cqrs-pipeline.md#deleteentitycommandtentity-tidentifiertype), [AuditableAggregateRootEntity](group-02-domain-building-blocks.md#auditableaggregaterootentitytidentifiertype) (constraint), [ICreateRequest](group-05-cqrs-pipeline.md#icreaterequest) (constraint), [IdempotentAttribute](#idempotentattribute); ASP.NET Core MVC and `Asp.Versioning`. -- **Concept introduced: idempotent creation guarded at the endpoint.** `[Rubric §9, API & Contract Design]` assesses safe mutation; `CreateAsync` carries `[Idempotent]` (`AggregateRootEntityControllerBase.cs:59`), which wires [IdempotencyFilter](#idempotencyfilter) so a retried POST carrying the same `Idempotency-Key` replays the original 201 (flagged `X-Idempotent-Replay: true`) instead of creating a duplicate aggregate, exactly what mobile and flaky-network clients need. A duplicate that arrives while the first request is still running and cannot take the lock within the 5-second `LockWait` (`IdempotencyFilter.cs:106`) is answered with 409 Conflict rather than a replay (`IdempotencyFilter.cs:263-275`). Because the fitness rule reads attributes with `inherit: true`, every concrete controller that inherits this action satisfies `PostActionsDeclareIdempotencyIntent` through this base (see [NonIdempotentAttribute](#nonidempotentattribute)). `[Rubric §1, SOLID]`: the four constraints (`AggregateRootEntityControllerBase.cs:40-43`, notably `TEntity : AuditableAggregateRootEntity`) enforce at compile time that only aggregate roots reach this create/delete surface. +- **Concept introduced: idempotent creation guarded at the endpoint.** `[Rubric §9, API & Contract Design]` assesses safe mutation; `CreateAsync` carries `[Idempotent]` (`AggregateRootEntityControllerBase.cs:59`), which wires [IdempotencyFilter](#idempotencyfilter) so a retried POST carrying the same `Idempotency-Key` replays the original 201 (flagged `X-Idempotent-Replay: true`, `IdempotencyFilter.cs:39`) instead of creating a duplicate aggregate, exactly what mobile and flaky-network clients need. A duplicate that arrives while the first request is still running and cannot take the lock within the 5-second `LockWait` (`IdempotencyFilter.cs:106`, awaited at `IdempotencyFilter.cs:252`) is answered with 409 Conflict rather than a replay (`IdempotencyFilter.cs:306-311`). Because the fitness rule reads attributes with `inherit: true`, every concrete controller that inherits this action satisfies `PostActionsDeclareIdempotencyIntent` through this base (see [NonIdempotentAttribute](#nonidempotentattribute)). `[Rubric §1, SOLID]`: the four constraints (`AggregateRootEntityControllerBase.cs:40-43`, notably `TEntity : AuditableAggregateRootEntity`) enforce at compile time that only aggregate roots reach this create/delete surface. - **Walkthrough** - Primary constructor (`AggregateRootEntityControllerBase.cs:27-38`): four parameters, where `queryService` and `logger` are forwarded to the [EntityControllerBase](#entitycontrollerbasetentity-tentitydto-tidentifiertype) base (`:38`), plus `createHandler` and `deleteHandler`. The `logger` is typed `ILogger>`, not of this class, because `ILogger` is not covariant and the base ctor requires that exact type; the `#pragma warning disable S6672` (`:35-37`) is a justified, narrowly-scoped suppression documenting exactly that (`[Rubric §15, Best Practices]`). - `CreateHandler` property (`:48`): `protected`, so a derived controller that overrides `CreateAsync` to build a more specific command can still reach the handler. `deleteHandler` stays a captured constructor parameter, used directly at `:93`, because nothing overrides delete today. - `CreateAsync` (`:63-76`): `[HttpPost]` plus `[Idempotent]` (`:58-59`), body bound `[FromBody, Required]` (`:64`); it dispatches the create command and on success returns `CreatedAtRoute($"Get{typeof(TEntity).Name}ById", new { id = result.Value!.Id }, result.Value)` (`:72-75`), following the `"Get{Entity}ById"` route-name convention derived controllers establish (`:69`). On failure it maps errors via `HandleFailure`. - `DeleteAsync` (`:89-98`): `[HttpDelete("{id}")]` (`:84`); builds a [DeleteEntityCommand](group-05-cqrs-pipeline.md#deleteentitycommandtentity-tidentifiertype), dispatches it, and returns `NoContent()` on success. Delete here means soft-delete: the handler loads the aggregate and calls its `Delete()` method, so the domain, not the controller, decides whether the removal is allowed. - **Why it's built this way**: splitting the read-only base from the aggregate-root base means a child-collection controller (add/remove associations, not create whole aggregates) can extend the read base without inheriting create/delete it should not expose (`[Rubric §1, SOLID]`, Interface Segregation), while the actual work stays in injected Application-layer handlers (`[Rubric §3, Clean Architecture]`) that the CQRS decorator pipeline already wraps with validation, transactions, and cache invalidation ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). -- **Where it's used**: concrete aggregate controllers in the modules extend this, for example ADC's `EventsController`, `SessionsController`, and `SpeakersController`; child-only controllers deliberately extend the read-only base instead. +- **Where it's used**: concrete aggregate controllers in the modules extend this, for example ADC's `EventsController`, `SessionsController`, and `SpeakersController`; child-only controllers deliberately extend the read-only base instead. Framework coverage lives in `AggregateRootEntityControllerBaseTests` (`MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/AggregateRootEntityControllerBaseTests.cs`). ### CurrentUserTargetingContextAccessor > MMCA.Common.API · `MMCA.Common.API.FeatureManagement` · `MMCA.Common/Source/Presentation/MMCA.Common.API/FeatureManagement/CurrentUserTargetingContextAccessor.cs:51` · Level 8 · class (sealed) @@ -951,13 +1013,13 @@ code stays modules and domain logic, never plumbing. - **What it is**: the `ITargetingContextAccessor` that supplies the audience (a user id plus that user's groups) which the feature-management `Targeting` filter evaluates, read from the current HTTP request's principal. - **Depends on**: `Microsoft.FeatureManagement.FeatureFilters.ITargetingContextAccessor` and `TargetingContext`, `Microsoft.AspNetCore.Http.IHttpContextAccessor`, and `System.Security.Claims` (`CurrentUserTargetingContextAccessor.cs:1-3`). Registered by [DependencyInjection](#dependencyinjection)`.AddAPI`; a sibling of [DisabledFeatureHandler](#disabledfeaturehandler) in the same folder. - **Concept introduced (a percentage rollout that is sticky per user rather than random per request).** A `Percentage` feature filter with no targeting rolls a die on every evaluation, so the same user sees the feature on one request and off the next: unusable for a UI. The `Targeting` filter fixes that by **hashing the audience's user id**, which makes the answer deterministic for a given user across requests and across instances, and this accessor is what supplies that id (`:7-13`). `[Rubric §10, Cross-Cutting]` assesses whether a cross-cutting toggle is applied uniformly; `[Rubric §11, Security]` and `[Rubric §17, DevOps]` both bear on the rollout being an operational lever rather than a deploy. See [ADR-031](https://ivanball.github.io/docs/adr/031-feature-flag-management.html). - Two source decisions are worth carrying. First, the user id is the `user_id` claim `TokenService` emits, the same claim [IdempotencyFilter](#idempotencyfilter) keys its cache on, with the principal's name as a fallback for a token that predates it. Second, the accessor is a **singleton** (that is the lifetime `WithTargeting` gives it), so it cannot take the scoped `ICurrentUserService`; it reads `IHttpContextAccessor` instead and re-derives the roles itself, which the doc calls out explicitly (`:14-21`). + Two source decisions are worth carrying. First, the user id is the `user_id` claim `TokenService` emits (`UserIdClaimType`, `:55`), the same claim [IdempotencyFilter](#idempotencyfilter) keys its cache on, with the principal's name as a fallback for a token that predates it. Second, the accessor is a **singleton** (that is the lifetime `WithTargeting` gives it), so it cannot take the scoped `ICurrentUserService`; it reads `IHttpContextAccessor` instead and re-derives the roles itself, which the doc calls out explicitly (`:14-21`). - **Walkthrough**: `GetContextAsync()` (`:63`) reads `httpContextAccessor.HttpContext?.User` (`:65`). An unauthenticated or absent principal yields an **empty** context, `UserId = null` and `Groups = []` (`:67-74`), so a targeted feature is simply off for anonymous callers unless the audience opts everyone in (`:23-26`); the method never returns null, because a feature filter must not be able to fail a request (`:57-61`). For an authenticated caller it collects the role claims, accepting each claim type the JWT middleware may produce: the standard `ClaimTypes.Role` URI when inbound claim mapping is on, or the raw `role` / `roles` claim when it is off (`:76-82`). Finally it builds the `TargetingContext` with `user.FindFirst("user_id")?.Value ?? user.Identity.Name` (`:86`) and those groups (`:87`). - **Why it's built this way**: accepting three role claim types is not defensive padding, it is the concrete consequence of ASP.NET Core's inbound claim mapping being configurable; matching only `ClaimTypes.Role` would silently drop every group when a host turns mapping off, and a group-targeted rollout would then behave as an ungrouped one. The class doc carries a worked `FeatureManagement` configuration example (`:27-48`) showing a rollout that always includes the Organizer role, includes 25 percent of everyone else, and pins two named users, which is the fastest way to see what the context is actually feeding. -- **Where it's used**: registered inside `AddAPI` as `services.AddFeatureManagement().WithTargeting()` (`MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:91-92`), immediately after `AddHttpContextAccessor()` (`:90`, which is `TryAdd`-based and therefore safe to call here as well as in `AddServerAuthSessionCookie`). From there it is consumed only by the `Targeting` feature filter, whose verdicts reach the HTTP edge through `[FeatureGate]` and [DisabledFeatureHandler](#disabledfeaturehandler), and the CQRS layer through [FeatureGateCommandDecorator](group-05-cqrs-pipeline.md#featuregatecommanddecoratortcommand-tresult). +- **Where it's used**: registered inside `AddAPI` as `services.AddFeatureManagement().WithTargeting()` (`MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:91-92`), immediately after `AddHttpContextAccessor()` (`:90`, which is `TryAdd`-based and therefore safe to call here as well as in `AddServerAuthSessionCookie`, `:86-89`). From there it is consumed only by the `Targeting` feature filter, whose verdicts reach the HTTP edge through `[FeatureGate]` and [DisabledFeatureHandler](#disabledfeaturehandler), and the CQRS layer through [FeatureGateCommandDecorator](group-05-cqrs-pipeline.md#featuregatecommanddecoratortcommand-tresult). ### AuthControllerBase -> MMCA.Common.API · `MMCA.Common.API.Controllers` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:41` · Level 10 · class (abstract) +> MMCA.Common.API · `MMCA.Common.API.Controllers` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:41` · Level 15 · class (abstract) - **What it is**: the abstract base for password-based authentication endpoints: login, register, refresh, and revoke. A downstream module (Identity) inherits it and adds the route prefix, version attribute, and any module-specific endpoints. - **Depends on**: [ApiControllerBase](#apicontrollerbase) (base), [IAuthenticationService](group-08-auth.md#iauthenticationservice) and [ICurrentUserService](group-08-auth.md#icurrentuserservice) (injected), [LoginRequest](group-08-auth.md#loginrequest), [RegisterRequest](group-08-auth.md#registerrequest), [RefreshTokenRequest](group-08-auth.md#refreshtokenrequest), [AuthenticationResponse](group-08-auth.md#authenticationresponse), [IdempotentAttribute](#idempotentattribute) and [NonIdempotentAttribute](#nonidempotentattribute), and the `RateLimitPolicyAuthIp` constant on [WebApplicationBuilderExtensions](#webapplicationbuilderextensions); `Microsoft.AspNetCore.RateLimiting` for `[EnableRateLimiting]`. @@ -969,26 +1031,44 @@ code stays modules and domain logic, never plumbing. - `RegisterAsync` (`:84`): `[HttpPost("register")]`, `[Idempotent]`, anonymous and throttled (`:76-79`); returns `StatusCode(StatusCodes.Status201Created, ...)` (`:92`), correctly 201 Created for a new account rather than 200. It is `virtual` so a module can override it to inject extra context (the doc comment names client IP, `:74`). - `RefreshAsync` (`:103`): `[AllowAnonymous]` (`:100`), since exchanging an expired token pair is pre-authentication, and deliberately **not** throttled (`:28-34`): refresh is automatic and periodic rather than user-initiated, Blazor Server circuits issue it server-side so every Server-circuit user shares the UI host's IP, and refresh tokens are high-entropy, so brute force is not the threat password spraying is. - `RevokeAsync` (`:122`): `[Authorize]` (`:119`); reads `CurrentUserService.UserId`, returns `Unauthorized()` if null (`:124-126`) as a defensive guard even though `[Authorize]` should already prevent a null id, then revokes and returns `NoContent()` (`:128-132`). -- **Why it's built this way**: `[Rubric §16, Maintainability]`: adding a new token flow means changing one base, not N module controllers; keeping the four methods `virtual` (rather than the class open-ended) keeps the override surface intentional. The rate-limit default is deliberately a *loud* dependency: a consumer that inherits this base without calling `AddCommonRateLimiting()` (`MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:285`, which registers the `"auth-ip"` policy at `:354-356`, constant defined at `:44`, with an `authIpPermitLimit` default of 30 requests per minute per IP at `:285` over the one-minute window at `:183`) fails at startup on an unregistered policy rather than silently serving unthrottled logins (`AuthControllerBase.cs:35-39`). -- **Caveats / not-in-source**: the per-IP policy partitions on `Connection.RemoteIpAddress` and deliberately does **not** limit when that address is null (in-process `TestServer`, integration tests): `AuthIpRateLimitPartition` returns `RateLimitPartition.GetNoLimiter("__unknown-ip")` in that case (`WebApplicationBuilderExtensions.cs:214-215`), a fail-open posture matching the global limiter and documented at `WebApplicationBuilderExtensions.cs:194-200`. -- **Where it's used**: the base of every app's Identity `AuthController`, reached today through [UserAccountAuthControllerBase](#useraccountauthcontrollerbasetchangepasswordcommand-tchangepreferencescommand), which both ADC (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AuthController.cs:29`) and Store (`MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/AuthController.cs:27`) extend. The framework's own coverage drives the base through a minimal test double, `TestAuthController` (`MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/AuthControllerBaseTests.cs`). +- **Why it's built this way**: `[Rubric §16, Maintainability]`: adding a new token flow means changing one base, not N module controllers; keeping the four methods `virtual` (rather than the class open-ended) keeps the override surface intentional. The rate-limit default is deliberately a *loud* dependency: a consumer that inherits this base without calling `AddCommonRateLimiting()` (`MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:295`, which registers the `"auth-ip"` policy at `:364-366`, constant defined at `:46`, with an `authIpPermitLimit` default of 30 requests per minute per IP at `:295` over the one-minute window at `:183` and `:193`) fails at startup on an unregistered policy rather than silently serving unthrottled logins (`AuthControllerBase.cs:35-39`). +- **Caveats / not-in-source**: the per-IP policy partitions on `Connection.RemoteIpAddress` (`WebApplicationBuilderExtensions.cs:222`) and deliberately does **not** limit when that address is null (in-process `TestServer`, integration tests): `AuthIpRateLimitPartition` returns `RateLimitPartition.GetNoLimiter("__unknown-ip")` in that case (`WebApplicationBuilderExtensions.cs:224-225`), a fail-open posture matching the global limiter and documented at `WebApplicationBuilderExtensions.cs:204-209`. +- **Where it's used**: the base of every app's Identity `AuthController`, reached today through [UserAccountAuthControllerBase](#useraccountauthcontrollerbasetchangepasswordcommand-tchangepreferencescommand), which both ADC (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AuthController.cs:29`) and Store (`MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/AuthController.cs:27`) extend. Password *recovery* is deliberately NOT on this chain: it ships as the sibling [PasswordResetAuthControllerBase](#passwordresetauthcontrollerbasetforgotpasswordcommand-tresetpasswordcommand). The framework's own coverage drives the base through a minimal test double, `TestAuthController` (`MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/AuthControllerBaseTests.cs:18`), with the throttling asserted separately in `AuthControllerBaseRateLimitTests`. + +### PasswordResetAuthControllerBase +> MMCA.Common.API · `MMCA.Common.API.Controllers` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:43` · Level 15 · class (abstract) + +- **What it is**: the two anonymous password-recovery endpoints, `POST forgot-password` and `POST reset-password`, for a user who cannot sign in at all. It is a *sibling* of [AuthControllerBase](#authcontrollerbase), not an addition to it. +- **Depends on**: [ApiControllerBase](#apicontrollerbase) (base), two [ICommandHandler](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) instances returning [Result](group-01-result-error-handling.md#result) (`PasswordResetAuthControllerBase.cs:44-45`), [ICommandWithRequest](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) as the constraint on both type parameters (`:46-47`), [ForgotPasswordRequest](group-08-auth.md#forgotpasswordrequest) and [ResetPasswordRequest](group-08-auth.md#resetpasswordrequest), [IdempotentAttribute](#idempotentattribute), and the `RateLimitPolicyAuthIp` constant on [WebApplicationBuilderExtensions](#webapplicationbuilderextensions); ASP.NET Core MVC, `Microsoft.AspNetCore.Authorization`, and `Microsoft.AspNetCore.RateLimiting`. +- **Concept introduced: a recovery surface that leaks nothing, on a chain single inheritance already owns.** Two separate ideas meet in this one type. + - **Why a sibling controller and not three more actions on the auth base.** C# gives a class one base, and each app's `AuthController` already spends it on [UserAccountAuthControllerBase](#useraccountauthcontrollerbasetchangepasswordcommand-tchangepreferencescommand). Recovery therefore ships as its own base that the app routes to the *same* `Auth` prefix (`[Route("Auth")]` on the concrete controller), so `POST /Auth/forgot-password` rides the gateway's existing `/Auth` route with no gateway change (class doc, `PasswordResetAuthControllerBase.cs:13-18`; the ADC subclass records the same reasoning at `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:19-24`). `[Rubric §16, Maintainability]` assesses whether a capability can be added without disturbing what already works: nothing on the authentication chain changed to make room for this. + - **Response shapes chosen so the endpoint is not an account oracle.** `[Rubric §11, Security]` assesses what an unauthenticated caller can learn by probing. Forgot-password always answers 202 on a well-formed request: an unknown address, a throttled request and a failed send are all treated as success by the handler, so the response never reveals which addresses hold accounts, and only a malformed payload reaches 400 through the request validator (remarks, `:27-31`). Reset-password collapses every rejection to one 401 for the same reason (`:95-98`). Both actions must be anonymous by necessity, because the caller has lost the credential that authentication would demand, so requiring one would be circular (`:20-26`); the framework's anonymous-endpoint architecture gate therefore lists both by name rather than letting them pass silently (`MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTests.cs:37-38`). See [ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html), which chose a cache-backed single-use token over user-row columns or a self-contained signed payload. +- **Walkthrough** + - Type parameters and constraints (`:43-47`): `TForgotPasswordCommand : ICommandWithRequest` and `TResetPasswordCommand : ICommandWithRequest`. That is the entire contract the base needs, a command that carries a request payload. Note the difference from the account base next door, whose commands are `IUserScopedCommand`: recovery has no authenticated user to scope to. + - The two handlers become `protected` properties (`:50` and `:53`), the same convention the other auth bases follow, so a derived controller can dispatch them itself for an extra endpoint. + - `CreateForgotPasswordCommand(request)` (`:61`) and `CreateResetPasswordCommand(request)` (`:69`) are the two abstract factories. The doc comments state the expected implementation verbatim, `=> new(request);`, and both consumers do exactly that (`MMCA.ADC/.../PasswordResetController.cs:36` and `:39`). + - `ForgotPasswordAsync` (`:82`): `[HttpPost("forgot-password")]`, `[Idempotent]`, `[AllowAnonymous]`, `[EnableRateLimiting(RateLimitPolicyAuthIp)]` (`:75-78`), with the 202/400/429 contract declared for OpenAPI (`:79-81`). The body dispatches the app command and returns `Accepted()` on success or `HandleFailure` (`:86-92`). + - `ResetPasswordAsync` (`:107`) mirrors it at `[HttpPost("reset-password")]` (`:99-102`), declaring 204/400/401/429 (`:103-106`) and returning `NoContent()` on success (`:111-117`). +- **Why it's built this way**: the commands stay app-side for the same reason they do on the account base (remarks, `:32-39`): ADC marks its `ResetPasswordCommand` [ICacheInvalidating](group-05-cqrs-pipeline.md#icacheinvalidating) (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordCommand.cs:15`) while Store's implements only `ICommandWithRequest` (`MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordCommand.cs:12-13`), so one shared record could not preserve both behaviors. `[Rubric §2, Design Patterns]`: this is Template Method again, the base owning the HTTP shape and deferring construction to two primitive operations. Both actions carry `[Idempotent]` rather than a `[NonIdempotent]` justification, which fits their contract: a retried forgot-password should replay the same 202 instead of mailing a second token, and a retried reset should replay the same 204 rather than fail against a token the first call already burned. `[Rubric §3, Clean Architecture]`: no token generation, hashing, or mail send appears here at all; the controller dispatches and maps, and everything else lives behind the CQRS decorator pipeline ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). +- **Where it's used**: subclassed by each app's sealed `PasswordResetController`: ADC's (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:28-33`) and Store's (`MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/PasswordResetController.cs:25`), each supplying only the two one-line factory overrides plus `[ApiController]`, `[Route("Auth")]`, and `[ApiVersion("1.0")]`. Framework coverage is `PasswordResetAuthControllerBaseTests` (`MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/PasswordResetAuthControllerBaseTests.cs:23`), which drives a `TestPasswordResetController` double (`:168`) and additionally asserts, action by action, that the anonymous, rate-limited and idempotent attributes are still attached (`:116-148`), so the security posture cannot be removed silently. +- **Caveats / not-in-source**: the "always 202" and "every rejection collapses to one 401" guarantees are properties of the app's command handlers, stated in this base's remarks (`:27-31`, `:95-98`) but enforced one layer down; nothing in this file forces them. ### UserAccountAuthControllerBase -> MMCA.Common.API · `MMCA.Common.API.Controllers` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:40` · Level 11 · class (abstract) +> MMCA.Common.API · `MMCA.Common.API.Controllers` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:40` · Level 16 · class (abstract) - **What it is**: [AuthControllerBase](#authcontrollerbase) plus the three self-service account endpoints every app needs once a user is signed in: `PUT password`, `PUT preferences`, and `GET preferences`. The app Identity modules previously carried line-identical copies of all three actions, and the only real difference between them was the command record each one constructed (class doc, `UserAccountAuthControllerBase.cs:14-19`). - **Depends on**: [AuthControllerBase](#authcontrollerbase) (base, constructed with the same [IAuthenticationService](group-08-auth.md#iauthenticationservice) and [ICurrentUserService](group-08-auth.md#icurrentuserservice) it forwards, `UserAccountAuthControllerBase.cs:46`), two [ICommandHandler](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) instances and one [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) (`:43-45`), [IUserScopedCommand](group-14-module-system-composition.md#iuserscopedcommandout-trequest) as the constraint on both command type parameters (`:47-48`), [ChangePasswordRequest](group-08-auth.md#changepasswordrequest), [ChangePreferencesRequest](group-08-auth.md#changepreferencesrequest), [GetUserPreferencesQuery](group-14-module-system-composition.md#getuserpreferencesquery), [UserPreferencesResponse](group-08-auth.md#userpreferencesresponse), and [Result](group-01-result-error-handling.md#result); ASP.NET Core MVC and `Microsoft.AspNetCore.Authorization`. -- **Concept introduced: generic-over-the-command deduplication.** Two apps wanted the same HTTP surface but not the same command record: ADC's `ChangePasswordCommand` also implements [ICacheInvalidating](group-05-cqrs-pipeline.md#icacheinvalidating) with a cache prefix built from its own `User` type, while Store's does not, so one shared record could not preserve both behaviors (remarks, `UserAccountAuthControllerBase.cs:21-30`). The resolution is the classic Template Method: the base owns the HTTP shape and the dispatch, and defers *construction* of the app command to two abstract factory methods. `[Rubric §16, Maintainability]` assesses whether a change lands in one place; `[Rubric §1, SOLID]` covers both the Open/Closed extension point and the Dependency Inversion angle, since the base depends only on the `IUserScopedCommand` abstraction and never on either app's concrete record. `[Rubric §2, Design Patterns]`: the two `Create*Command` overrides are the pattern's primitive operations, and their implementations really are one line each. +- **Concept introduced: generic-over-the-command deduplication.** Two apps wanted the same HTTP surface but not the same command record: ADC's `ChangePasswordCommand` also implements [ICacheInvalidating](group-05-cqrs-pipeline.md#icacheinvalidating) with a cache prefix built from its own `User` type (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordCommand.cs:15`), while Store's does not, so one shared record could not preserve both behaviors (remarks, `UserAccountAuthControllerBase.cs:21-30`). The resolution is the classic Template Method: the base owns the HTTP shape and the dispatch, and defers *construction* of the app command to two abstract factory methods. `[Rubric §16, Maintainability]` assesses whether a change lands in one place; `[Rubric §1, SOLID]` covers both the Open/Closed extension point and the Dependency Inversion angle, since the base depends only on the `IUserScopedCommand` abstraction and never on either app's concrete record. `[Rubric §2, Design Patterns]`: the two `Create*Command` overrides are the pattern's primitive operations, and their implementations really are one line each. - **Walkthrough** - Type parameters and constraints (`:40-48`): `TChangePasswordCommand : IUserScopedCommand` and `TChangePreferencesCommand : IUserScopedCommand`. That constraint is the whole contract the base needs: a command that carries a user id and a request payload. - The three handlers become `protected` properties (`:51`, `:54`, `:57`), matching the base's convention so a derived controller can dispatch them itself for an extra endpoint. - `CreateChangePasswordCommand(userId, request)` (`:66-68`) and `CreateChangePreferencesCommand(userId, request)` (`:77-79`) are the two abstract factories. Both take a `UserIdentifierType`, the solution-wide identifier alias, so the base never has to know whether an app's user key is an `int` or a `Guid`. - - `ChangePasswordAsync` (`:91`): `[HttpPut("password")]` plus `[Authorize]` (`:86-87`). It reads `CurrentUserService.UserId`, returns `Unauthorized()` when null (`:95-97`), then dispatches `CreateChangePasswordCommand(userId.Value, request)` through the handler (`:99-101`) and returns `NoContent()` or `HandleFailure`. Note what is absent: no password verification, no hashing, no user lookup. Those live in the app's command handler, behind the CQRS decorator pipeline, so validation and the transaction wrap them ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). The doc comment is explicit that this dispatches the handler directly rather than brokering through the authentication service (`:82-85`). + - `ChangePasswordAsync` (`:91`): `[HttpPut("password")]` plus `[Authorize]` (`:86-87`). It reads `CurrentUserService.UserId`, returns `Unauthorized()` when null (`:95-97`), then dispatches `CreateChangePasswordCommand(userId.Value, request)` through the handler (`:99-101`) and returns `NoContent()` or `HandleFailure`. Note what is absent: no password verification, no hashing, no user lookup. Those live in the app's command handler, behind the CQRS decorator pipeline, so validation and the transaction wrap them ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). The doc comment is explicit that this dispatches the handler directly rather than brokering through the authentication service (`:81-85`). - `ChangePreferencesAsync` (`:117`) mirrors it at `[HttpPut("preferences")]` (`:112`): the stored UI culture and theme ([ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html), [ADR-028](https://ivanball.github.io/docs/adr/028-dark-theme-mode.html)) follow the user across devices, and a null field leaves that preference unchanged (`:108-111`). - `GetPreferencesAsync` (`:142`): `[HttpGet("preferences")]` (`:138`). This one constructs its query inline, `new GetUserPreferencesQuery(userId.Value)` (`:150`), because the read side has no per-app detail to preserve; the remarks call that asymmetry out deliberately (`:28-29`). - All three actions repeat the same "UserId is null yields Unauthorized()" guard rather than trusting `[Authorize]` alone, the same defensive posture [AuthControllerBase](#authcontrollerbase)`.RevokeAsync` takes. - **Why it's built this way**: inheriting this base instead of [AuthControllerBase](#authcontrollerbase) is purely additive (remarks, `:31-36`): every inherited login/register/refresh/revoke action, including the default per-IP throttling, the idempotency attributes, and the ability to override `RegisterAsync` or attach another `[EnableRateLimiting]` policy app-side, behaves exactly as before. That is what made the consolidation safe to do at all. The alternative (pushing the command records into the framework) would have forced ADC's cache-invalidation behavior onto Store or dropped it from ADC. `[Rubric §14, Testability]`: because the extension point is two abstract methods rather than a service lookup, the framework can exercise the whole base with a test double supplying trivial commands. -- **Where it's used**: extended by each app's Identity `AuthController`: ADC's (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AuthController.cs:29`) and Store's (`MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/AuthController.cs:27`), each supplying the two one-line factory overrides. Covered in the framework by `UserAccountAuthControllerBaseTests` (`MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/UserAccountAuthControllerBaseTests.cs`), which drives the base through a `TestUserAccountAuthController` double. +- **Where it's used**: extended by each app's Identity `AuthController`: ADC's (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AuthController.cs:29`) and Store's (`MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/AuthController.cs:27`), each supplying the two one-line factory overrides. Covered in the framework by `UserAccountAuthControllerBaseTests` (`MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/UserAccountAuthControllerBaseTests.cs:16`), which drives the base through a `TestUserAccountAuthController` double (`:258`). ### ErrorResourceSource > MMCA.Common.API · `MMCA.Common.API.Localization` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Localization/ErrorResourceSource.cs:12` · Level 0 · class (sealed) @@ -1335,7 +1415,7 @@ code stays modules and domain logic, never plumbing. - `AppleAppId` (`AppAssociationOptions.cs:21`, `required`): the `TeamID.BundleID` value used by both the `webcredentials` and the `applinks` sections. - `AppleAppLinkComponents` (`AppAssociationOptions.cs:28`, defaults to `[]`): the URL patterns (for example `"/conference/*"`) that each become a `{ "/": pattern }` component; the comment (`:23-27`) notes these should mirror the app's shared Blazor routes, because identical URLs on web and device is the Blazor Hybrid payoff (no route-translation table). - **Why it's built this way**: `required init` gives compile-checked construction plus immutability once bound (see the primer on [required/init immutability](00-primer.md#2-architectural-styles-this-codebase-commits-to)), which matches the lifetime: a host builds one instance at startup and the endpoint reads it for the process lifetime. Defaulting the two collections to `[]` means a host that ships only one platform still constructs a valid document for the other. See [ADR-043](https://ivanball.github.io/docs/adr/043-mobile-deep-links-and-native-oauth-callback.html) for the deep-link decision this serves. -- **Where it's used**: constructed inline by the ADC Blazor web host and passed straight to the mapper, with the Android/Apple identifiers read from the `AppAssociation` configuration section and the applinks patterns hard-coded to the app's routes (`MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:168-179`). +- **Where it's used**: constructed inline by the ADC Blazor web host and passed straight to the mapper, with the Android and Apple identifiers read from the `AppAssociation` configuration section (with in-code fallbacks) and the applinks patterns hard-coded to the app's routes (`MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:168-179`). The comment there (`:171-174`) records a trap worth reading: the Release Android head overrides `ApplicationId` to `ivanball.AtlDevCon`, so that is the package Digital Asset Links must name, not the Debug-only id. - **Caveats / not-in-source**: the type performs no validation. Whether a fingerprint or bundle id is the *correct* one for the shipped app is only observable at install time on the device. ### ErrorResources @@ -1346,58 +1426,66 @@ code stays modules and domain logic, never plumbing. - **Concept: the resource anchor type.** .NET's `IStringLocalizerFactory.Create(Type)` locates a satellite resource set by the type's assembly and namespace-relative name, so a resx file needs a co-located type to point at even when that type has no behavior. Keeping the anchor a real, public, empty class makes the resx discoverable by convention and gives modules a pattern to copy. `[Rubric §27, i18n]` assesses whether user-facing text is externalized rather than hard-coded: here the resx entries are keyed by the stable domain error `Code` (for example `"PhoneNumber.Empty"`, `ErrorResources.cs:5-6`), so a translation never depends on the English message string. - **Walkthrough**: there is nothing to walk. The type is a body-less `sealed class` declared with the semicolon form (`ErrorResources.cs:9`); all of its meaning is in the doc comment and its resx siblings. - **Why it's built this way**: see [ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html). Localizing at the HTTP edge (rather than inside the domain) keeps `Error.Code` culture-free all the way through the Application layer, and one anchor per resource set lets modules add their own translations additively instead of editing a framework file. -- **Where it's used**: `AddErrorLocalization()` registers it as the framework's own source via `services.AddErrorResources()` (`MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:111`); each module calls the same generic `AddErrorResources()` (`DependencyInjection.cs:122-127`) with its own anchor type. - -### ICorrelationContext -> MMCA.Common.Application · `MMCA.Common.Application.Interfaces` · `MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICorrelationContext.cs:8` · Level 0 · interface - -- **What it is**: the scoped abstraction that holds the correlation ID for the current request. Middleware sets it from the inbound `X-Correlation-ID` header (or a generated value) and everything downstream reads it through structured-logging scopes. -- **Depends on**: nothing first-party, nothing external. Its holder implementation is [CorrelationContext](#correlationcontext) (Infrastructure) and its writer is [CorrelationIdMiddleware](#correlationidmiddleware) (API). -- **Concept introduced (distributed trace correlation).** `[Rubric §13, Observability & Operability]` assesses whether one logical operation can be reconstructed end to end from disjoint logs; a **correlation ID** is the single value stamped on every log line for one request that makes that possible, and it survives a service extraction because the ID travels on the wire rather than in process memory. `[Rubric §10, Cross-Cutting]` also applies: handlers and decorators read the ID through this interface and never touch `HttpContext`, so the concern is factored out of business code entirely. -- **Walkthrough**: two members. `CorrelationId { get; }` (`ICorrelationContext.cs:11`) is what every downstream reader uses, and `SetCorrelationId(string)` (`ICorrelationContext.cs:15`) is what the middleware calls once at the start of a request. Keeping the setter on the same interface rather than splitting a second write-only abstraction is a deliberate simplification: exactly one type in the stack calls it. -- **Why it's built this way**: the interface lives in **Application**, not Infrastructure, so the CQRS logging decorators can enrich their log scope without taking an ASP.NET dependency, which keeps the dependency arrow pointing inward `[Rubric §3, Clean Architecture]`. The same shape is deliberately mirrored by the tenancy abstraction: [ITenantContext](group-05-cqrs-pipeline.md#itenantcontext) names `ICorrelationContext` as its model (one scoped instance per request, populated once at the edge). -- **Where it's used**: registered as `services.TryAddScoped()` in the Infrastructure DI extensions, so one instance lives per request and a host may substitute its own implementation by registering first. It is written by [CorrelationIdMiddleware](#correlationidmiddleware), which resolves it as a method parameter of `InvokeAsync` (`MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/CorrelationIdMiddleware.cs:27`) and sets it from the header, the current `Activity` trace ID, or `HttpContext.TraceIdentifier` in that order (`CorrelationIdMiddleware.cs:32-36`). It is read by [LoggingCommandDecorator](group-05-cqrs-pipeline.md#loggingcommanddecoratortcommand-tresult) and [LoggingQueryDecorator](group-05-cqrs-pipeline.md#loggingquerydecoratortquery-tresult). -- **Caveats / not-in-source**: nothing in the framework source publishes the correlation ID onto outbox messages or broker headers, so cross-process correlation today rests on the HTTP header echo plus OpenTelemetry's own trace context, not on this interface. - -### JwtForwardingDelegatingHandler -> MMCA.Common.Infrastructure · `MMCA.Common.Infrastructure.Http` · `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Http/JwtForwardingDelegatingHandler.cs:17` · Level 0 · class (sealed) - -- **What it is**: an HTTP `DelegatingHandler` that copies the inbound `Authorization` header from the current `HttpContext` onto every outgoing request, so a typed service client forwards the caller's bearer token to a downstream service without any handler threading the token by hand. -- **Depends on**: `Microsoft.AspNetCore.Http.IHttpContextAccessor` (primary-constructor parameter, `JwtForwardingDelegatingHandler.cs:17`) and BCL `DelegatingHandler`/`AuthenticationHeaderValue`. It is the HTTP twin of the gRPC [JwtForwardingClientInterceptor](group-13-grpc-contracts.md#jwtforwardingclientinterceptor), a relationship the doc comment states outright (`:11-15`). -- **Concept introduced (token propagation for distributed authorization).** `[Rubric §7, Microservices Readiness]` assesses whether a module can be lifted into its own process without rewriting application code, and `[Rubric §11, Security]` assesses how identity is carried across a trust boundary. When an extracted service calls another service on behalf of a user, the downstream needs that user's JWT to authorize the call. Doing that per call site would be both repetitive and easy to forget; putting it in the `HttpClient` message pipeline makes it a property of the client registration, so no application code participates `[Rubric §10, Cross-Cutting]`. -- **Walkthrough**: one override, `SendAsync` (`JwtForwardingDelegatingHandler.cs:22`). - - Null-guards the request (`:24`). - - If `request.Headers.Authorization` is already set it forwards untouched (`:27-30`), so an explicit token or a prior handler in the chain is never overwritten. - - Reads the inbound header through `IHttpContextAccessor` (`:32`); when there is no context or no header (background processors, outbox dispatch, tests) it is a plain **no-op** and calls `base.SendAsync` (`:33-36`). - - Normalizes the scheme: a value starting with `Bearer ` (case-insensitive) has the prefix stripped, otherwise the whole string is treated as the token, and it is re-attached as a fresh `AuthenticationHeaderValue("Bearer", token)` (`:40-45`). The `BearerScheme` constant (`:19`) is the one place the scheme name is spelled. -- **Why it's built this way**: the no-op-without-context branch is what lets the handler be registered unconditionally on a typed client. Background services run with their own credentials rather than an ambient user's token, and without that branch every non-HTTP invocation path would need conditional wiring at the call site. -- **Where it's used**: `AddTypedServiceClient(serviceName)` in the Infrastructure DI extensions registers it transiently and attaches it to the client pipeline alongside `AddHttpContextAccessor()` and the standard resilience handler. That helper is the HTTP counterpart to `AddTypedGrpcClient`; its doc comment says to prefer gRPC for service-to-service contracts and to use this for webhook receivers, public REST endpoints, and third-party API wrappers. +- **Where it's used**: `AddErrorLocalization()` registers it as the framework's own source via `services.AddErrorResources()` (`MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:111`); each module calls the same generic `AddErrorResources()` (`DependencyInjection.cs:122`) with its own anchor type. + +### MiddlewarePipelineStep +> MMCA.Common.API · `MMCA.Common.API.Startup` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/MiddlewarePipelineStep.cs:21` · Level 0 · record (sealed, positional) + +- **What it is**: one named step of the shared HTTP edge pipeline: a stable string identifier plus the delegate that registers that step's middleware on a `WebApplication`. It is the atom that makes the edge order *data* rather than a hand-written sequence of `app.UseX()` calls. +- **Depends on**: ASP.NET Core's `WebApplication` (through the `Action` payload) and nothing else first-party. Its names normally come from [MiddlewarePipelineStepNames](#middlewarepipelinestepnames); its container is [MiddlewarePipelineBuilder](#middlewarepipelinebuilder). +- **Concept introduced (the pipeline as an inspectable list, not an imperative script).** A conventional ASP.NET composition root *is* its own ordering: the order exists only as the sequence of statements in `Program.cs`, so nothing can read it, assert on it, or edit it. Modelling each step as a value with a name means the whole order can be enumerated (`StepNames` on the builder), rewritten by name, validated, and frozen by a unit test with no host running at all. The doc comment states the payoff directly: steps are pure data until `UseCommonMiddlewarePipeline` runs them in order, which is what makes the pipeline order testable without a running host (`MiddlewarePipelineStep.cs:5-10`). `[Rubric §2, Design Patterns]` assesses whether a recognizable pattern is applied where it earns its keep; this is the classic "reify the plan, then execute it" split, and it is what unlocks the fitness function. `[Rubric §14, Testability]` assesses whether behavior can be asserted cheaply: because a step never touches a host until `Configure` is invoked, the entire order runs in the fast unit tier. +- **Walkthrough**: a positional record with two components, each re-declared as a validated property. + - `Name` (`MiddlewarePipelineStep.cs:27`) shadows the positional parameter with `Validated(Name)`, which calls `ArgumentException.ThrowIfNullOrWhiteSpace` (`:32-36`). Null, empty, and whitespace names are rejected at construction, so an anchor lookup can never match a meaningless key. + - `Configure` (`:30`) is validated the same way through the second `Validated` overload, which null-guards the delegate (`:38-42`). + - The `init` accessors keep both immutable after construction, so a step handed to the builder cannot be mutated behind the builder's back. + - The parameter doc records the runtime contract: `Configure` is invoked exactly once, in pipeline order, at the point `UseCommonMiddlewarePipeline` is called, so anything the delegate reads from the host (configuration, environment) is evaluated at configure time and not per request (`:16-20`). +- **Why it's built this way**: the validation-in-the-property-initializer idiom is how a positional record enforces invariants without giving up the concise declaration or the value semantics. Value equality also matters here: two steps with the same name and delegate compare equal, which keeps assertions in [MiddlewarePipelineBuilder](#middlewarepipelinebuilder) tests simple. See [ADR-079](https://ivanball.github.io/docs/adr/079-shared-http-middleware-pipeline.html), which records the move from an inline sequence to named steps. +- **Where it's used**: [MiddlewarePipelineBuilder](#middlewarepipelinebuilder)`.CreateDefault()` constructs eighteen of them (`MiddlewarePipelineBuilder.cs:31-156`), and a host customizing the pipeline constructs its own to pass to `InsertBefore` / `InsertAfter` / `Replace`. +- **Caveats / not-in-source**: name uniqueness is *not* enforced here; it is enforced by the builder's `RequireUniqueName` at insertion time (`MiddlewarePipelineBuilder.cs:304-312`). Constructing two steps with the same name in isolation is legal. + +### MiddlewarePipelineStepNames +> MMCA.Common.API · `MMCA.Common.API.Startup` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/MiddlewarePipelineStepNames.cs:14` · Level 0 · class (static, constants) + +- **What it is**: the eighteen well-known step names of the default edge pipeline, as `const string` fields declared in runtime order. A host customizing the pipeline addresses steps by these constants. +- **Depends on**: nothing. It is referenced by [MiddlewarePipelineBuilder](#middlewarepipelinebuilder) (which seeds the defaults with these names and re-checks adjacencies by them) and by `MiddlewarePipelineOrderTestsBase`. +- **Concept (names as a published contract).** Because a host inserts, replaces, and removes steps *by name*, the names are part of the framework's public API surface: the doc comment says so outright and adds that renaming one is a breaking change (`MiddlewarePipelineStepNames.cs:3-7`). `[Rubric §9, API & Contract Design]` assesses whether the surface a consumer binds to is explicit and stable; hoisting each name into a `const` is what turns a magic string into that surface, and what lets the compiler find every caller when the list changes. `[Rubric §34, Architecture Governance & Documentation]` also applies: the declaration order below the summary *is* the documented runtime order, so the code and the documentation cannot drift apart. +- **Walkthrough**: the constants in declaration order, which is application order (outermost first). + - `ExceptionHandler` (`:17`), `CorrelationId` (`:20`), `RequestLocalization` (`:23`). + - `PreForwardedCapture` (`:29`) and `ForwardedHeaders` (`:32`). The comment on the first (`:25-28`) states the adjacency: it must run immediately before `ForwardedHeaders`, because it captures the transport scheme and host as the connection saw them, before the forwarded headers rewrite them. + - `HttpsRedirection` (`:35`), `ResponseCompression` (`:38`), `Routing` (`:41`), `Cors` (`:44`), `Authentication` (`:47`). + - `TenantResolution` (`:53`), whose comment (`:49-52`) records that it must run immediately after `Authentication` because the claim strategy reads `HttpContext.User`. + - `RateLimiting` (`:56`), documented as needing to run after `Authentication` per [ADR-019](https://ivanball.github.io/docs/adr/019-rate-limiting.html). + - `SoftDeletedUserFilter` (`:59`), `Authorization` (`:62`), `OutputCache` (`:65`). + - `JwksEndpoint` (`:68`), `OidcDiscoveryEndpoint` (`:71`), and `Controllers` (`:74`), the innermost step. + - The class summary (`:8-12`) flags that several of these adjacencies are load-bearing and are re-checked by [MiddlewarePipelineBuilder](#middlewarepipelinebuilder)`.Build`. +- **Why it's built this way**: `const` rather than `static readonly` so the values are usable in attribute arguments and `switch` patterns, and one file rather than a nested enum so the XML doc on each field can carry the ordering rationale next to the name it explains. See [ADR-079](https://ivanball.github.io/docs/adr/079-shared-http-middleware-pipeline.html). +- **Where it's used**: [MiddlewarePipelineBuilder](#middlewarepipelinebuilder)`.CreateDefault()` names every seeded step with these constants; `Build()` names them again in its four invariant checks; and `MiddlewarePipelineOrderTestsBase.ExpectedStepNames` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/MiddlewarePipelineOrderTestsBase.cs:38-58`) lists all eighteen as the frozen expected order, which each app's `MiddlewarePipelineOrderTests` subclasses. ### OpenApiEndpointExtensions -> MMCA.Common.API · `MMCA.Common.API.Startup` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/OpenApiEndpointExtensions.cs:18` · Level 0 · class (static, extension block) +> MMCA.Common.API · `MMCA.Common.API.Startup` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/OpenApiEndpointExtensions.cs:22` · Level 0 · class (static, extension block) - **What it is**: two `extension(WebApplication app)` mapping helpers that expose the generated OpenAPI document and an optional interactive reference UI, both **outside Production only**. - **Depends on**: `Scalar.AspNetCore` (NuGet) for the reference UI and `Asp.Versioning`'s `WithDocumentPerVersion()` convention. It pairs with `AddCommonOpenApi()` on [WebApplicationBuilderExtensions](#webapplicationbuilderextensions), which registers the generator. -- **Concept introduced (the OpenAPI document as a dev/CI artifact, not a public surface).** `[Rubric §9, API & Contract Design]` assesses whether an API has a machine-readable contract and whether that contract is guarded against silent drift. The doc comment (`OpenApiEndpointExtensions.cs:7-17`) is explicit on both halves: the document is the source of truth for the API surface and is meant to be guarded by a contract-snapshot test in the consumer integration tiers, which the framework deliberately does not duplicate because the surface lives in the consumer hosts. Mapping outside Production is the security posture `[Rubric §11, Security]`: these are internal services reached through the Gateway, which does not route the endpoint. +- **Concept introduced (the OpenAPI document as a dev/CI artifact, not a public surface).** `[Rubric §9, API & Contract Design]` assesses whether an API has a machine-readable contract and whether that contract is guarded against silent drift. The doc comment (`OpenApiEndpointExtensions.cs:7-21`) is explicit that the guarding happens at **two levels**: the framework-owned part of the generated document (the versioned naming convention, the unbound-route-token backfill, the generated `ProblemDetails` error schema) is diffed against a committed baseline in-repo by `OpenApiBaselineTests` (`MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/OpenApi/OpenApiBaselineTests.cs`), which fails on any change until the baseline is regenerated deliberately in the same pull request, while each consumer's concrete API surface stays the concern of the contract-snapshot tests in that host's integration tier. Mapping outside Production is the security posture `[Rubric §11, Security]`: these are internal services reached through the Gateway, which does not route the endpoint. - **Walkthrough** - - `MapCommonOpenApi()` (`OpenApiEndpointExtensions.cs:30`) calls the built-in `MapOpenApi()` only when `!app.Environment.IsProduction()` (`:32-35`) and chains `.WithDocumentPerVersion()`, which applies the API-versioning convention so the route resolves one document per discovered API version (`/openapi/v1.json` for v1.0, doc comment `:22-29`). It is a no-op in Production and returns `app` for chaining (`:37`). - - `MapCommonScalarUi()` (`OpenApiEndpointExtensions.cs:48`) is the opt-in developer convenience: it calls `MapScalarApiReference()` outside Production (`:50-53`), rendering `/scalar/{documentName}`. Assets ship inside the `Scalar.AspNetCore` package rather than a CDN (`:45-46`), so it works offline and in CI. + - `MapCommonOpenApi()` (`OpenApiEndpointExtensions.cs:34`) calls the built-in `MapOpenApi()` only when `!app.Environment.IsProduction()` (`:36-39`) and chains `.WithDocumentPerVersion()`, which applies the API-versioning convention so the route resolves one document per discovered API version (`/openapi/v1.json` for v1.0, doc comment `:26-33`). It is a no-op in Production and returns `app` for chaining (`:41`). + - `MapCommonScalarUi()` (`OpenApiEndpointExtensions.cs:52`) is the opt-in developer convenience: it calls `MapScalarApiReference()` outside Production (`:54-57`), rendering `/scalar/{documentName}`. Assets ship inside the `Scalar.AspNetCore` package rather than a CDN (`:49-50`), so it works offline and in CI. - **Why it's built this way**: one shared pair of helpers keeps every service's OpenAPI story identical and enforces the "internal spec, not public surface" convention in one place instead of per host. The version-aware document mapping is what keeps the route stable as versions accumulate ([ADR-046](https://ivanball.github.io/docs/adr/046-http-api-versioning.html)). -- **Where it's used**: inside this workspace the only caller is the framework's own test host for [ApiParameterDescriptorBackfillProvider](#apiparameterdescriptorbackfillprovider). The ADC service hosts today call the stock ASP.NET pair directly instead (`services.AddOpenApi()` and a `MapOpenApi()` guarded by their own environment check). This is the framework offering a convention ahead of the consumers adopting it. +- **Where it's used**: inside this workspace the only caller is the framework's own probe host, `OpenApiProbeHost` (`MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/OpenApi/OpenApiProbeHost.cs:39` and `:59`), which is what `OpenApiBaselineTests` and the [ApiParameterDescriptorBackfillProvider](#apiparameterdescriptorbackfillprovider) tests boot. The ADC and Store service hosts today call the stock ASP.NET pair directly instead. This is the framework offering a convention ahead of the consumers adopting it; `OpenApiContractTestsBase` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/OpenApiContractTestsBase.cs:11`) is the base a consumer subclasses once it does. ### AppAssociationEndpointExtensions > MMCA.Common.API · `MMCA.Common.API.Startup` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/AppAssociationEndpointExtensions.cs:15` · Level 1 · class (static, extension block) - **What it is**: a mapping helper that serves the two well-known app-association documents from an [AppAssociationOptions](#appassociationoptions): Android Digital Asset Links at `/.well-known/assetlinks.json` and the Apple App Site Association at `/.well-known/apple-app-site-association`. - **Depends on**: [AppAssociationOptions](#appassociationoptions) (Level 0) for every value; ASP.NET `IEndpointRouteBuilder` and `Results.Json`. -- **Concept (anonymous, machine-verified association documents).** Both endpoints are anonymous by design because the OS and Apple's CDN fetch them without credentials, which the doc comment states (`AppAssociationEndpointExtensions.cs:11-13`). `[Rubric §9, API & Contract Design]`: the exact JSON shape is a contract a third party parses, so the code builds it structurally out of dictionaries rather than formatting strings by hand. +- **Concept (anonymous, machine-verified association documents).** Both endpoints are anonymous by design because the OS and Apple's CDN fetch them without credentials, which the doc comment states (`AppAssociationEndpointExtensions.cs:12-13`). `[Rubric §9, API & Contract Design]`: the exact JSON shape is a contract a third party parses, so the code builds it structurally out of dictionaries rather than formatting strings by hand. - **Walkthrough** - Two path constants: `AssetLinksPath` (`AppAssociationEndpointExtensions.cs:18`) and `AppleAppSiteAssociationPath` (`:24`). The comment on the Apple constant (`:20-23`) records that the path deliberately has no file extension because Apple requires that exact path, while the content type must still be JSON. - `MapAppAssociationEndpoints(AppAssociationOptions options)` (`:35`) null-guards the options (`:37`), builds both documents once at map time because they are static for the process lifetime (`:39-40`), then maps two `GET`s that each return `Results.Json(...)`, are `.AllowAnonymous()` and are `.ExcludeFromDescription()` so they never leak into the OpenAPI document (`:42-48`). - - `BuildAssetLinks` (`:54`) emits the `delegate_permission/common.handle_all_urls` relation with the Android package name and the fingerprint list (`:58-64`). + - `BuildAssetLinks` (`:54`) emits the `delegate_permission/common.handle_all_urls` relation with the Android package name and the fingerprint list (`:56-65`). - `BuildAppleAppSiteAssociation` (`:68`) emits the `applinks` details block, projecting each configured URL pattern into a `{ "/": pattern }` component (`:78-80`), plus the `webcredentials` apps list naming the same app id (`:84-87`). - **Why it's built this way**: building the payload once at map time avoids a per-request allocation for a document that never changes `[Rubric §12, Performance & Scalability]`, and holding the RFC 8615 well-known paths as public constants keeps them from drifting between hosts or between the endpoint and any gateway forwarding rule. -- **Where it's used**: the ADC Blazor web host maps them once at startup (`MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:169`), which is the host that ships a companion MAUI Hybrid app. +- **Where it's used**: the ADC Blazor web host maps them once at startup (`MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:169`), which is the host that ships a companion MAUI Hybrid app. Both documents' exact shapes are asserted by `AppAssociationEndpointTests` (`MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Startup/AppAssociationEndpointTests.cs`). ### JwksEndpointExtensions > MMCA.Common.API · `MMCA.Common.API.Startup` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/JwksEndpointExtensions.cs:15` · Level 1 · class (static, extension block) @@ -1405,9 +1493,9 @@ code stays modules and domain logic, never plumbing. - **What it is**: maps `/.well-known/jwks.json`, serializing the active `JsonWebKeySet` of the Identity service so other services can validate its RS256 tokens. - **Depends on**: [IJwksProvider](group-08-auth.md#ijwksprovider), resolved from DI per request, whose implementation is [RsaJwksProvider](group-08-auth.md#rsajwksprovider); plus `Microsoft.IdentityModel.Tokens.JsonWebKeySet` and `System.Text.Json`. - **Concept (the public-key distribution endpoint of cross-service auth).** `[Rubric §11, Security]` and `[Rubric §7, Microservices Readiness]`: with RS256 only the Identity service holds the private key, and every other service fetches the public keys from here, so no shared secret ever crosses a service boundary ([ADR-004](https://ivanball.github.io/docs/adr/004-authentication-dual-fetch.html)). The endpoint is `.AllowAnonymous()` (`JwksEndpointExtensions.cs:39`) because clients fetch it *before* they have a token, which is what JWKS means (RFC 7517; the doc comment says so at `:27-28`). -- **Walkthrough**: the `DefaultJwksPath` constant (`JwksEndpointExtensions.cs:20`) pins the RFC 8615 path. `MapJwksEndpoint()` (`:31`) maps a single `GET` whose handler takes `HttpContext` and `IJwksProvider` as parameters (`:33`), calls `GetJsonWebKeySet()` (`:35`), serializes with `JsonSerializer` (`:36`), sets `application/json; charset=utf-8` explicitly (`:37`), and writes the body (`:38`). The whole endpoint is nine lines because the key material and its rotation live behind the provider. -- **Why it's built this way**: non-Identity hosts still map it (see [WebApplicationExtensions](#webapplicationextensions), which calls it unconditionally); their provider returns an empty key set rather than erroring, so the wiring is uniform across every host and a single gateway forwarder rule for `/.well-known/*` covers JWKS discovery for the whole platform. That same prefix is one of the paths the global rate limiter bypasses (`WebApplicationBuilderExtensions.cs:53`). -- **Where it's used**: mapped inside `UseCommonMiddlewarePipeline()` (`MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:118`), so every host that adopts the shared pipeline serves it; the path it owns is the `jwks_uri` value that [OidcDiscoveryEndpointExtensions](#oidcdiscoveryendpointextensions) advertises, which is in turn what `AddForwardedJwtBearer` (on [WebApplicationBuilderExtensions](#webapplicationbuilderextensions)) reaches through OIDC discovery. +- **Walkthrough**: the `DefaultJwksPath` constant (`JwksEndpointExtensions.cs:20`) pins the RFC 8615 path. `MapJwksEndpoint()` (`:31`) maps a single `GET` whose handler takes `HttpContext` and `IJwksProvider` as parameters (`:33`), calls `GetJsonWebKeySet()` (`:35`), serializes with `JsonSerializer` (`:36`), sets `application/json; charset=utf-8` explicitly (`:37`), and writes the body (`:38`). The whole endpoint is under ten lines because the key material and its rotation live behind the provider. +- **Why it's built this way**: non-Identity hosts still map it (the `JwksEndpoint` step is unconditional in the default pipeline, `MiddlewarePipelineBuilder.cs:140-147`); their provider returns an empty key set rather than erroring, so the wiring is uniform across every host and a single gateway forwarder rule for `/.well-known/*` covers JWKS discovery for the whole platform. That same prefix is one of the paths the global rate limiter bypasses (`WebApplicationBuilderExtensions.cs:63`). +- **Where it's used**: applied as the `JwksEndpoint` step of the default pipeline seeded by [MiddlewarePipelineBuilder](#middlewarepipelinebuilder) (`MiddlewarePipelineBuilder.cs:147`), so every host that adopts the shared pipeline serves it; the path it owns is the `jwks_uri` value that [OidcDiscoveryEndpointExtensions](#oidcdiscoveryendpointextensions) advertises, which is in turn what `AddForwardedJwtBearer` (on [WebApplicationBuilderExtensions](#webapplicationbuilderextensions)) reaches through OIDC discovery. ### MiniProfilerExtensions > MMCA.Common.API · `MMCA.Common.API.Startup` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/MiniProfilerExtensions.cs:9` · Level 2 · class (static, extension block) @@ -1415,9 +1503,10 @@ code stays modules and domain logic, never plumbing. - **What it is**: a conditional MiniProfiler registration helper. When [ApplicationSettings](group-14-module-system-composition.md#applicationsettings)`.UseMiniProfiler` is true it registers MiniProfiler plus its Entity Framework integration; otherwise it does nothing. - **Depends on**: [ApplicationSettings](group-14-module-system-composition.md#applicationsettings) and `StackExchange.Profiling` (NuGet). - **Concept (opt-in, settings-gated profiling).** `[Rubric §13, Observability & Operability]` assesses whether diagnostics exist and whether they cost anything when switched off. One configuration flag turns a cross-cutting profiler on or off with no application code involved, and when off the MiniProfiler services are never registered at all, so there is no middleware and no per-request work. -- **Walkthrough**: one member, `AddMiniProfilerIfEnabled(ApplicationSettings)` (`MiniProfilerExtensions.cs:16`). It tests `applicationSettings.UseMiniProfiler` (`:18`) and only then calls `AddMiniProfiler(...)` with a `/profiler` route base, `PopupShowTimeWithChildren`, the dark color scheme (`:20-25`), and `.AddEntityFramework()` so EF/SQL timings appear inline. It returns `services` either way (`:28`) so the call chains. -- **Why it's built this way**: gating on a settings flag rather than `#if DEBUG` lets one specific environment (a staging slot, say) enable profiling without a rebuild, while production leaves it off and pays nothing. The default is off: `ApplicationSettings.UseMiniProfiler` is a plain `bool` with no initializer. -- **Where it's used**: **no host in this workspace calls it today.** `AddAPI(...)` (`MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:44`) does not invoke it, and no ADC or Store `Program.cs` does either; the helper is available for a host that opts in. +- **Walkthrough**: one member, `AddMiniProfilerIfEnabled(ApplicationSettings)` (`MiniProfilerExtensions.cs:16`). It tests `applicationSettings.UseMiniProfiler` (`:18`) and only then calls `AddMiniProfiler(...)` with a `/profiler` route base, `PopupShowTimeWithChildren`, the dark color scheme (`:20-25`), and `.AddEntityFramework()` so EF and SQL timings appear inline. It returns `services` either way (`:28`) so the call chains. +- **Why it's built this way**: gating on a settings flag rather than `#if DEBUG` lets one specific environment (a staging slot, say) enable profiling without a rebuild, while production leaves it off and pays nothing. +- **Where it's used**: **no host in this workspace calls it today.** `AddAPI(...)` in `MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs` does not invoke it, and no ADC, Store, or Helpdesk `Program.cs` does either; the helper is available for a host that opts in. +- **Caveats / not-in-source**: the helper registers services only. Nothing in this file maps the profiler's own middleware, so a host opting in must also call `UseMiniProfiler()` itself. ### OidcDiscoveryEndpointExtensions > MMCA.Common.API · `MMCA.Common.API.Startup` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/OidcDiscoveryEndpointExtensions.cs:22` · Level 2 · class (static, extension block) @@ -1430,7 +1519,21 @@ code stays modules and domain logic, never plumbing. - Three static arrays (`token`, `public`, `RS256`, `:32-34`) and an `OidcJsonOptions` with `PropertyNamingPolicy = null` (`:43-46`). Disabling the naming policy is load-bearing: the field names are already OIDC snake_case per RFC 8414, and camelCasing `jwks_uri` to `jwksUri` would leave `OpenIdConnectConfigurationRetriever` unable to recognise the document (`:36-42`). The fields sit under a scoped `#pragma warning disable IDE0052` (`:31`, restored `:47`) because the analyzer does not look into C# extension blocks for field usage, and the comment records that (`:29-30`). - `MapOidcDiscoveryEndpoint()` (`:58`) maps a `GET` that is `.AllowAnonymous()` (`:86`) and reads `Jwt:Issuer` (`:62`); a blank issuer returns `Results.NotFound()` (`:63-66`), safe because no downstream points its authority at a non-Identity host. Otherwise it derives `jwks_uri` from the configured issuer rather than the inbound request (`:76`) and returns the issuer, that URI, and the three supported-value arrays (`:78-85`). - **Why it's built this way**: the comment at `:68-75` documents the subtle reason `jwks_uri` is built from the configured issuer and not from the request. Aspire/DCP fronts the Identity service on per-launchSettings ports and rewrites `Host` via `X-Forwarded-Host` to canonical ports that internal callers cannot always reach, so reusing the issuer keeps issuer and `jwks_uri` origin-aligned (a common OIDC client requirement) and routes both through the same gateway that fronts `/Auth`, which means one forwarder rule for `/.well-known/*` covers everything. -- **Where it's used**: mapped unconditionally by [WebApplicationExtensions](#webapplicationextensions) in the shared pipeline (`WebApplicationExtensions.cs:119`); consumed by the bearer middleware that `AddForwardedJwtBearer` configures on [WebApplicationBuilderExtensions](#webapplicationbuilderextensions). +- **Where it's used**: applied unconditionally as the `OidcDiscoveryEndpoint` step of the default pipeline (`MiddlewarePipelineBuilder.cs:149-151`); consumed by the bearer middleware that `AddForwardedJwtBearer` configures on [WebApplicationBuilderExtensions](#webapplicationbuilderextensions), which deliberately leaves `ValidIssuer` unset so the issuer comes from this document (`WebApplicationBuilderExtensions.cs:487-492`). +- **Caveats / not-in-source**: the pre-forwarded scheme and host captured into `HttpContext.Items` by the `PreForwardedCapture` step exist for exactly this endpoint's benefit (`WebApplicationExtensions.cs:16-33`), but the current handler composes `jwks_uri` from the configured issuer only, so those items are not read on this path today. + +### InsecureJwtMetadataWarningStartupFilter +> MMCA.Common.API · `MMCA.Common.API.Startup` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/InsecureJwtMetadataWarningStartupFilter.cs:15` · Level 3 · class (internal, sealed, partial) + +- **What it is**: a one-purpose `IStartupFilter` that writes a single warning at host startup when `AddForwardedJwtBearer` resolved `RequireHttpsMetadata` to `false` outside Development. It changes no behavior; it only makes a deliberate weakening visible in the logs. +- **Depends on**: `Microsoft.AspNetCore.Hosting.IStartupFilter`, `ILogger`, the source-generated `[LoggerMessage]` attribute, and [WebApplicationBuilderExtensions](#webapplicationbuilderextensions)`.RequireHttpsMetadataConfigKey` for the key name it echoes. +- **Concept introduced (`IStartupFilter` as a "log after the logging providers exist" hook).** Registration-time code runs while the `IServiceCollection` is still being built, so the logging providers are not configured yet and anything written there is dropped. `IStartupFilter.Configure` runs later, once the provider is built and the application pipeline is being assembled, which is the first moment a warning is guaranteed to reach a sink. The doc comment states exactly that reasoning (`InsecureJwtMetadataWarningStartupFilter.cs:7-13`). `[Rubric §11, Security]` assesses whether a security-relevant deviation is deliberate, narrow, and visible; the code permits the deviation (an internal-ingress cleartext authority is a real deployment) but refuses to let it be silent. `[Rubric §13, Observability & Operability]` assesses whether an operator can see the posture a deployment is actually running: this warning is the only place the resolved value surfaces at runtime. +- **Walkthrough**: two members. + - `Configure(Action next)` (`:19`) logs once (`:21`) and returns `next` unchanged (`:23`). It inserts nothing into the request pipeline, so the filter costs nothing per request; the whole type is a startup-time side effect wearing a pipeline interface. + - `LogInsecureJwtMetadata` (`:29`) is a `[LoggerMessage]` source-generated partial at `LogLevel.Warning` (`:26-28`). The message names the config key that produced the value and tells the operator what makes it safe (an internal-ingress cleartext h2c authority) and what to do (record that justification beside the setting in the deployment template). +- **Why it's built this way**: the filter is registered through `TryAddEnumerable(ServiceDescriptor.Singleton)` (`WebApplicationBuilderExtensions.cs:462-463`), which de-duplicates on implementation type, so a host that calls `AddForwardedJwtBearer` more than once still gets exactly one warning. Registration is itself conditional (`:460`): the filter is only added when the resolved value is `false` **and** the environment is not Development, so a developer's normal loop stays quiet. Source-generated logging avoids boxing and string formatting on a path that runs once, which is the framework's convention rather than a hot-path optimization here. +- **Where it's used**: registered only from `AddForwardedJwtBearer` (`WebApplicationBuilderExtensions.cs:460-464`). Its registration conditions are asserted by `ForwardedJwtBearerSecurityTests` (`MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Startup/ForwardedJwtBearerSecurityTests.cs`). In the deployed apps the ADC service hosts document the override explicitly: Azure sets `Authentication:JwtBearer:RequireHttpsMetadata` to `false` because the authority is the internal-ingress h2c URL (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:290-293`), which is precisely the case this filter exists to annotate. +- **Caveats / not-in-source**: nothing in the framework fails a build or a deployment on this warning. Whether an operator acts on it is a process concern, and there is no ADR for this decision in `Website/docs-src/adr/` at the time of writing. ### SignalRExtensions > MMCA.Common.API · `MMCA.Common.API.Startup` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/SignalRExtensions.cs:12` · Level 3 · class (static, extension block) @@ -1439,59 +1542,74 @@ code stays modules and domain logic, never plumbing. - **Depends on**: [NotificationHub](group-10-notifications.md#notificationhub) (Infrastructure), [PushNotificationSettings](group-14-module-system-composition.md#pushnotificationsettings), and `IOptions`. - **Concept (conditional real-time endpoint mapping).** `[Rubric §6, CQRS & Event-Driven]`: the SignalR hub is the real-time delivery arm of the notification pipeline, so mapping it behind a settings gate means a host that does not push notifications simply never opens the endpoint, and the same `Program.cs` line is safe in every host. - **Walkthrough**: `MapNotificationHub()` (`SignalRExtensions.cs:22`) resolves `IOptions` through `GetService()`, which returns null when nothing registered it, and takes `?.Value` (`:24`). Only when `settings is { Enabled: true }` does it call `MapHub(settings.HubPath)` (`:25-28`). The doc comment (`:16-21`) notes it must run after `UseCommonMiddlewarePipeline()` so authentication and routing are already in place. -- **Why it's built this way**: `GetService` rather than `GetRequiredService`, plus the property-pattern guard, is what makes the call unconditionally safe; it matches the same "always call, no-op if not applicable" convention as the JWKS and OIDC mappers. The hub path is also why the JWT bearer options carry an `access_token` query-string fallback for `/hubs` (`WebApplicationBuilderExtensions.cs:470-481` and `:520-531`): a WebSocket cannot send an `Authorization` header. -- **Where it's used**: the ADC Notification service maps it after the shared pipeline (`MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:275`, with the pipeline itself at `:258`); that host reads the hub path from configuration rather than hard-coding it. +- **Why it's built this way**: `GetService` rather than `GetRequiredService`, plus the property-pattern guard, is what makes the call unconditionally safe; it matches the same "always call, no-op if not applicable" convention as the JWKS and OIDC mappers. The hub path is also why the JWT bearer options carry an `access_token` query-string fallback for `/hubs` on both authentication paths (`WebApplicationBuilderExtensions.cs:504-517` and `:554-567`): a WebSocket cannot send an `Authorization` header. +- **Where it's used**: the ADC Notification service maps it after the shared pipeline (`MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:281`, with the pipeline itself at `:264`); that host reads the hub path from configuration rather than hard-coding it (`:277`). ### WebApplicationBuilderExtensions -> MMCA.Common.API · `MMCA.Common.API.Startup` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:29` · Level 3 · class (static, extension block) +> MMCA.Common.API · `MMCA.Common.API.Startup` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:31` · Level 3 · class (static, extension block) -- **What it is**: the consolidated **builder-side** registration surface shared by every MMCA host: API versioning, rate limiting, response compression, OpenAPI, CORS, and the two JWT authentication modes (in-process validation and JWKS-forwarded validation). It is the sibling of [WebApplicationExtensions](#webapplicationextensions), which owns middleware order; this one owns what goes into the DI container. -- **Depends on**: [JwtSettings](group-14-module-system-composition.md#jwtsettings) and its `JwtSigningAlgorithm`; `AddAuthorizationPolicies` from `MMCA.Common.API.Authorization`; [ApiParameterDescriptorBackfillProvider](#apiparameterdescriptorbackfillprovider); [RateLimitingSettings](#ratelimitingsettings), [RateLimitAlgorithm](#ratelimitalgorithm) and [RedisFixedWindowRateLimiter](#redisfixedwindowratelimiter); ASP.NET rate-limiting, compression, CORS and `Asp.Versioning` primitives; `Microsoft.IdentityModel.Tokens` and `StackExchange.Redis`. -- **Concept introduced (per-user global rate limiting, a pluggable counter location, and algorithm-pinned JWT validation).** `[Rubric §12, Performance & Scalability]` (a global limiter protects finite capacity, and a distributed counter makes the configured number mean the same thing behind a load balancer), `[Rubric §11, Security]` (algorithm pinning, HTTPS metadata, per-IP anonymous auth throttling) and `[Rubric §9, API & Contract Design]` (versioning, OpenAPI and compression handled identically across hosts rather than per host). +- **What it is**: the consolidated **builder-side** registration surface shared by every MMCA host: API versioning, rate limiting, response compression, OpenAPI, CORS, and the two JWT authentication modes (in-process validation and JWKS-forwarded validation). It is the sibling of [WebApplicationExtensions](#webapplicationextensions), which owns the runtime pipeline; this one owns what goes into the DI container. +- **Depends on**: [JwtSettings](group-14-module-system-composition.md#jwtsettings) and its [JwtSigningAlgorithm](group-14-module-system-composition.md#jwtsigningalgorithm); `AddAuthorizationPolicies` from `MMCA.Common.API.Authorization`; [ApiParameterDescriptorBackfillProvider](#apiparameterdescriptorbackfillprovider); [RateLimitingSettings](#ratelimitingsettings), [RateLimitAlgorithm](#ratelimitalgorithm) and [RedisFixedWindowRateLimiter](#redisfixedwindowratelimiter); [InsecureJwtMetadataWarningStartupFilter](#insecurejwtmetadatawarningstartupfilter); ASP.NET rate-limiting, compression, CORS and `Asp.Versioning` primitives; `Microsoft.IdentityModel.Tokens` and `StackExchange.Redis`. +- **Concept introduced (per-user global rate limiting, a pluggable counter location, and algorithm-pinned JWT validation).** `[Rubric §12, Performance & Scalability]` (a global limiter protects finite capacity, and a distributed counter makes the configured number mean the same thing behind a load balancer), `[Rubric §11, Security]` (algorithm pinning, HTTPS metadata resolution, per-IP anonymous auth throttling) and `[Rubric §9, API & Contract Design]` (versioning, OpenAPI and compression handled identically across hosts rather than per host). - **Walkthrough**: the load-bearing members, in file order. - - `CorsPolicyAllowSpecificOrigins` / `CorsPolicyAllowAll` (`WebApplicationBuilderExtensions.cs:32`, `:35`): the two policy names the pipeline chooses between by environment. - - `RateLimitPolicyAuthIp` (`:44`): the named `"auth-ip"` policy for anonymous authentication attempts. Its comment (`:37-43`) states why it exists: the global limiter deliberately no-ops for anonymous traffic and per-account lockout is per-email, which would leave a password spray (one password, many emails) from a single source unthrottled. - - `IsRateLimitBypassed(HttpContext)` (`:50`) exempts `/health`, `/alive`, `/.well-known/*` and `application/grpc` content types (`:51-54`), all legitimately high-frequency. It is `internal` rather than private specifically so the exemption logic is unit-testable through `InternalsVisibleTo` instead of only under a request flood (`:48-49`). - - `GlobalRateLimitPartition` has two overloads. The permit-count one (`:60-61`) simply wraps its argument in a [RateLimitingSettings](#ratelimitingsettings) and delegates; the settings one (`:68`) holds the logic: a `NoLimiter` for bypassed infrastructure (`:70-73`) and for unauthenticated callers (`:75-78`), otherwise a partition keyed by `Identity.Name`, then the `user_id` claim, then the remote IP, then the literal `"authenticated"` (`:80-83`), built through `CreateLimitedPartition` with `redisScope: "global"`, `queueLimit: 0` and `allowDistributed: true` (`:85-92`). - - `UserPolicyRateLimitPartition` (`:103`) is the same shape for the opt-in `"UserPolicy"` limiter: one bucket per authenticated user, falling back to the client IP and then a shared anonymous bucket (`:105-107`), with `redisScope: "user"` and the configured queue limit (`:109-116`). It was extracted from the inline lambda it used to be so the key selection is unit-testable (`:99-102`). - - `CreateLimitedPartition` (`:137`) is the single place a partition is built, and reading it is the fastest way to understand the whole limiter. When `allowDistributed && settings.Distributed` it resolves `IConnectionMultiplexer` through a **nullable** local, because `HttpContext.RequestServices` is declared non-nullable but is genuinely null outside a request pipeline such as a bare `DefaultHttpContext` in a unit test (`:146-152`). With a connection it returns a partition whose factory builds a [RedisFixedWindowRateLimiter](#redisfixedwindowratelimiter) over `$"{redisScope}:{key}"` (`:159-161`), falling back to a null logger when none is registered (`:156-157`). Without a connection it deliberately **falls through** to the in-memory limiters rather than failing startup, so a host that turns the flag on before wiring Redis degrades to per-instance limits instead of losing rate limiting altogether (`:164-166`). The in-memory branch honours [RateLimitAlgorithm](#ratelimitalgorithm): a sliding window with `SegmentsPerWindow` segments (`:169-179`) or the default fixed window (`:181-187`), both over `TimeSpan.FromMinutes(1)` with `QueueProcessingOrder.OldestFirst`. - - `AuthIpRateLimitPartition` (`:201` permit-count overload, `:210` settings overload) partitions the `"auth-ip"` policy on the client IP and returns **no limiter at all** when the IP is unattributable (`:212-215`). The remark (`:194-200`) explains the choice: failing open on a null IP beats collapsing every such request into one shared bucket, which would throttle the in-process `TestServer` and the integration tier to a standstill. It passes `allowDistributed: false` (`:223`), so login throttling stays per-instance whatever `Distributed` says, because per-account login protection already backs it and a login throttle that fails open on a Redis outage is a worse trade than one that stays local (`:132-135`). - - `AddCommonApiVersioning()` (`:233`): header-based versioning through the `api-version` reader with `AssumeDefaultVersionWhenUnspecified` and `ReportApiVersions` (`:238-243`), the API explorer group format `'v'VVV` and `SubstituteApiVersionInUrl` (`:244-248`), then the backfill guard (`:250`). The comment (`:235-237`) records that `DefaultApiVersion` is deliberately not set because 1.0 is already the framework default and restating it trips AV0011/AV0024. See [ADR-046](https://ivanball.github.io/docs/adr/046-http-api-versioning.html). - - `AddCommonRateLimiting` now has **three** overloads. The permit-count one (`:285`) keeps the original defaults `permitLimit: 100, queueLimit: 2, perUserPermitLimit: 30, globalPermitLimit: 300, authIpPermitLimit: 30` and simply builds a [RateLimitingSettings](#ratelimitingsettings) from them (`:285-293`). The `IConfiguration` one (`:303`) binds the `RateLimiting` section, falling back to a default instance when the section is absent (`:307-308`). The settings one (`:321`) does the work: rejection status 429 (`:327`), the always-on `GlobalLimiter` (`:329-330`), the opt-in `"FixedPolicy"` (`:335-342`, `allowDistributed: false`), `"UserPolicy"` (`:344`), and `auth-ip` (`:354-356`). Two comments carry the reasoning: `"FixedPolicy"` keeps its name whichever algorithm is configured, because it is referenced by name from `[EnableRateLimiting]` attributes in three repos and renaming it on a settings change would silently unlimit every endpoint using it (`:332-334`); and the `auth-ip` policy takes the client IP from `Connection.RemoteIpAddress`, which the shared pipeline has already resolved from `X-Forwarded-For` because `UseForwardedHeaders` runs before `UseRateLimiter` (`:346-353`). The long doc comment on the permit-count overload (`:255-284`) explains why anonymous traffic is deliberately unlimited and why `authIpPermitLimit` is 30 rather than a tighter 10: Blazor Server circuits issue the login call server-side, so every Server-circuit user shares the UI host's IP. See [ADR-019](https://ivanball.github.io/docs/adr/019-rate-limiting.html). - - `AddCommonResponseCompression()` (`:363`): Brotli plus Gzip, enabled for HTTPS, both at `CompressionLevel.Fastest` (`:365-377`); the comment (`:373-375`) justifies Fastest for gzip too on fractional-vCPU hosts serving dynamic payloads. - - `AddCommonOpenApi()` (`:392`): `services.AddApiVersioning().AddOpenApi()` (`:394`) plus the backfill guard (`:395`). The comment (`:382-391`) notes the parameterless `AddApiVersioning()` only returns the builder, so options configured by `AddCommonApiVersioning` accumulate independently of call order. Pair it with `MapCommonOpenApi()` on [OpenApiEndpointExtensions](#openapiendpointextensions). - - `AddApiParameterDescriptorBackfill()` (`:407`, private) registers [ApiParameterDescriptorBackfillProvider](#apiparameterdescriptorbackfillprovider) via `TryAddEnumerable` (`:408-409`), which de-duplicates on implementation type so calling both `AddCommonApiVersioning` and `AddCommonOpenApi` installs the guard exactly once. - - `AddForwardedJwtBearer(authority, audience, requireHttpsMetadata = false)` (`:430`) is the **extracted-service** mode: it validates its two string arguments (`:435-436`), sets `Authority`, `Audience` and `RequireHttpsMetadata` (`:441-443`), deliberately leaves `ValidIssuer` unset so the middleware takes the issuer from the discovery document (`:451-456`), and pins `ValidAlgorithms = [RsaSha256]` as defense against an algorithm-confusion swap (`:458-464`). It also installs the SignalR `access_token` query-string fallback for `/hubs` (`:467-481`) and then calls `AddAuthorizationPolicies()` (`:484`). - - `AddCommonAuthentication(IConfiguration)` (`:500`) is the **in-process** mode: it binds [JwtSettings](group-14-module-system-composition.md#jwtsettings) with data-annotation validation on start (`:502-505`), builds validation parameters through `BuildValidationParameters` (`:513`), wires the same `/hubs` access-token fallback (`:515-531`), and adds the authorization policies (`:534`). - - `AddCommonCors(IConfiguration)` (`:543`): the restrictive production policy takes its origins from `Cors:AllowedOrigins` and allowlists the SignalR headers and five methods with `AllowCredentials` (`:547-554`); the development any-origin policy sits under a justified `#pragma warning disable S5122` explaining it is only ever selected when the environment is Development (`:555-560`). - - `GetValidatedSigningKey(string)` (`:571`) decodes the Base64 HMAC key and throws when it is under 256 bits (`:574-578`), so a too-short secret fails at startup rather than weakening every token. - - `BuildValidationParameters(JwtSettings)` (`:590`) branches on `JwtSettings.SigningAlgorithm`: RS256 requires `RsaPublicKeyPem` and throws a message that points at `AddForwardedJwtBearer` when it is missing (`:592-598`), imports the PEM into an `RSA` held for the app lifetime (`:600-603`, with a justified CA2000 suppression) and pins RS256 (`:613`); the default HS256 path builds a `SymmetricSecurityKey` from the validated secret and pins HmacSha256 (`:617-630`). -- **Why it's built this way**: two authentication entry points are the framework's monolith-to-microservice hinge ([ADR-004](https://ivanball.github.io/docs/adr/004-authentication-dual-fetch.html)): the monolith or the issuing Identity service validates in process against a local key, while an extracted service validates against the issuer's published JWKS with no shared secret. The explicit `ValidAlgorithms` pin on **both** paths is deliberate defense in depth rather than trust in the token header. On the limiter side, factoring every partition through `CreateLimitedPartition` is what let the Redis counter and the sliding window arrive without touching a single partition-key rule: the policy names, the bypass list and every key are identical whatever the settings say, and only the permit counts, the algorithm and the counter's location change (`:313-317`). -- **Where it's used**: every ADC service host calls the builder-side quartet in one block (`AddCommonCors`, `AddCommonApiVersioning`, `AddCommonRateLimiting`, `AddCommonResponseCompression`). Identity hosts take the in-process mode while the other services take the forwarded mode. The `"auth-ip"` policy is applied by attribute on the login and register actions of [AuthControllerBase](#authcontrollerbase) (`AuthControllerBase.cs:57` and `:79`), so every consumer inheriting that base gets it without opting in. The internal partition helpers are exercised directly by `WebApplicationBuilderExtensionsTests`. + - `CorsPolicyAllowSpecificOrigins` / `CorsPolicyAllowAll` (`WebApplicationBuilderExtensions.cs:34`, `:37`): the two policy names the pipeline chooses between by environment. + - `RateLimitPolicyAuthIp` (`:46`): the named `"auth-ip"` policy for anonymous authentication attempts. Its comment (`:39-45`) states why it exists: the global limiter deliberately no-ops for anonymous traffic and per-account lockout is per-email, which would leave a password spray (one password, many emails) from a single source unthrottled. + - `RequireHttpsMetadataConfigKey` (`:54`, value `"Authentication:JwtBearer:RequireHttpsMetadata"`): the one configuration key that can override the secure-by-default metadata posture. Its comment (`:48-53`) narrows the legitimate use to an authority that is genuinely plain HTTP (an internal-ingress h2c service URL) and asks for the justification to be recorded beside the setting. + - `IsRateLimitBypassed(HttpContext)` (`:60`) exempts `/health`, `/alive`, `/.well-known/*` and `application/grpc` content types (`:61-64`), all legitimately high-frequency. It is `internal` rather than private specifically so the exemption logic is unit-testable through `InternalsVisibleTo` instead of only under a request flood (`:58-59`). + - `GlobalRateLimitPartition` has two overloads. The permit-count one (`:70-71`) simply wraps its argument in a [RateLimitingSettings](#ratelimitingsettings) and delegates; the settings one (`:78`) holds the logic: a `NoLimiter` for bypassed infrastructure (`:80-83`) and for unauthenticated callers (`:85-88`), otherwise a partition keyed by `Identity.Name`, then the `user_id` claim, then the remote IP, then the literal `"authenticated"` (`:90-93`), built through `CreateLimitedPartition` with `redisScope: "global"`, `queueLimit: 0` and `allowDistributed: true` (`:95-102`). + - `UserPolicyRateLimitPartition` (`:113`) is the same shape for the opt-in `"UserPolicy"` limiter: one bucket per authenticated user, falling back to the client IP and then a shared anonymous bucket (`:115-117`), with `redisScope: "user"` and the configured queue limit (`:119-126`). It was extracted from the inline lambda it used to be so the key selection is unit-testable (`:109-112`). + - `CreateLimitedPartition` (`:147`) is the single place a partition is built, and reading it is the fastest way to understand the whole limiter. When `allowDistributed && settings.Distributed` it resolves `IConnectionMultiplexer` through a **nullable** local, because `HttpContext.RequestServices` is declared non-nullable but is genuinely null outside a request pipeline such as a bare `DefaultHttpContext` in a unit test (`:156-162`). With a connection it returns a partition whose factory builds a [RedisFixedWindowRateLimiter](#redisfixedwindowratelimiter) over `$"{redisScope}:{key}"` (`:169-171`), falling back to a null logger when none is registered (`:166-167`). Without a connection it deliberately **falls through** to the in-memory limiters rather than failing startup, so a host that turns the flag on before wiring Redis degrades to per-instance limits instead of losing rate limiting altogether (`:174-176`). The in-memory branch honours [RateLimitAlgorithm](#ratelimitalgorithm): a sliding window with `SegmentsPerWindow` segments (`:179-189`) or the default fixed window (`:191-197`), both over `TimeSpan.FromMinutes(1)` with `QueueProcessingOrder.OldestFirst`. + - `AuthIpRateLimitPartition` (`:211` permit-count overload, `:220` settings overload) partitions the `"auth-ip"` policy on the client IP and returns **no limiter at all** when the IP is unattributable (`:222-225`). The remark (`:204-210`) explains the choice: failing open on a null IP beats collapsing every such request into one shared bucket, which would throttle the in-process `TestServer` and the integration tier to a standstill. It passes `allowDistributed: false` (`:233`), so login throttling stays per-instance whatever `Distributed` says, because per-account login protection already backs it and a login throttle that fails open on a Redis outage is a worse trade than one that stays local (`:142-146`). + - `AddCommonApiVersioning()` (`:243`): header-based versioning through the `api-version` reader with `AssumeDefaultVersionWhenUnspecified` and `ReportApiVersions` (`:248-253`), the API explorer group format `'v'VVV` and `SubstituteApiVersionInUrl` (`:254-258`), then the backfill guard (`:260`). The comment (`:245-247`) records that `DefaultApiVersion` is deliberately not set because 1.0 is already the framework default and restating it trips AV0011/AV0024. See [ADR-046](https://ivanball.github.io/docs/adr/046-http-api-versioning.html). + - `AddCommonRateLimiting` has **three** overloads. The permit-count one (`:295`) keeps the defaults `permitLimit: 100, queueLimit: 2, perUserPermitLimit: 30, globalPermitLimit: 300, authIpPermitLimit: 30` and simply builds a [RateLimitingSettings](#ratelimitingsettings) from them (`:295-303`). The `IConfiguration` one (`:313`) binds the `RateLimiting` section, falling back to a default instance when the section is absent (`:317-318`). The settings one (`:331`) does the work: rejection status 429 (`:337`), the always-on `GlobalLimiter` (`:339-340`), the opt-in `"FixedPolicy"` (`:345-352`, `allowDistributed: false`), `"UserPolicy"` (`:354`), and `auth-ip` (`:364-366`). Two comments carry the reasoning: `"FixedPolicy"` keeps its name whichever algorithm is configured, because it is referenced by name from `[EnableRateLimiting]` attributes in three repos and renaming it on a settings change would silently unlimit every endpoint using it (`:342-344`); and the `auth-ip` policy takes the client IP from `Connection.RemoteIpAddress`, which the shared pipeline has already resolved from `X-Forwarded-For` because `UseForwardedHeaders` runs before `UseRateLimiter` (`:356-363`). The long doc comment on the permit-count overload (`:265-294`) explains why anonymous traffic is deliberately unlimited and why `authIpPermitLimit` is 30 rather than a tighter 10: Blazor Server circuits issue the login call server-side, so every Server-circuit user shares the UI host's IP. See [ADR-019](https://ivanball.github.io/docs/adr/019-rate-limiting.html). + - `AddCommonResponseCompression()` (`:373`): Brotli plus Gzip, enabled for HTTPS, both at `CompressionLevel.Fastest` (`:375-387`); the comment (`:383-385`) justifies Fastest for gzip too on fractional-vCPU hosts serving dynamic payloads. + - `AddCommonOpenApi()` (`:402`): `services.AddApiVersioning().AddOpenApi()` (`:404`) plus the backfill guard (`:405`). The comment (`:392-401`) notes the parameterless `AddApiVersioning()` only returns the builder, so options configured by `AddCommonApiVersioning` accumulate independently of call order. Pair it with `MapCommonOpenApi()` on [OpenApiEndpointExtensions](#openapiendpointextensions). + - `AddApiParameterDescriptorBackfill()` (`:417`, private) registers [ApiParameterDescriptorBackfillProvider](#apiparameterdescriptorbackfillprovider) via `TryAddEnumerable` (`:418-419`), which de-duplicates on implementation type, so calling both `AddCommonApiVersioning` and `AddCommonOpenApi` installs the guard exactly once. + - `AddForwardedJwtBearer(authority, audience, configuration, environment, requireHttpsMetadata = null)` (`:444`) is the **extracted-service** mode. It validates all four required arguments (`:451-454`), then resolves the metadata posture in three steps: the explicit argument when it is not null, then `RequireHttpsMetadataConfigKey`, then `true` everywhere except Development (`:456-458`). When the resolved value is `false` outside Development it registers [InsecureJwtMetadataWarningStartupFilter](#insecurejwtmetadatawarningstartupfilter) so the deviation is logged once at startup (`:460-464`), and finally delegates to the private `AddForwardedJwtBearerCore` (`:466`). + - `AddForwardedJwtBearerCore` (`:469`, private) does the JWT wiring: `Authority`, `Audience` and `RequireHttpsMetadata` (`:477-479`), validation parameters that deliberately leave `ValidIssuer` unset so the middleware takes the issuer from the discovery document (`:487-492`), and `ValidAlgorithms = [RsaSha256]` as defense against an algorithm-confusion swap (`:494-500`). It also installs the SignalR `access_token` query-string fallback for `/hubs` (`:503-517`) and then calls `AddAuthorizationPolicies()` (`:520`). + - `AddCommonAuthentication(IConfiguration)` (`:536`) is the **in-process** mode: it binds [JwtSettings](group-14-module-system-composition.md#jwtsettings) with data-annotation validation on start (`:538-541`), builds validation parameters through `BuildValidationParameters` (`:549`), wires the same `/hubs` access-token fallback (`:551-567`), and adds the authorization policies (`:570`). + - `AddCommonCors(IConfiguration)` (`:579`): the restrictive production policy takes its origins from `Cors:AllowedOrigins` and allowlists four headers and five methods with `AllowCredentials` (`:583-590`); the development any-origin policy sits under a justified `#pragma warning disable S5122` explaining it is only ever selected when the environment is Development (`:591-596`). See [ADR-082](https://ivanball.github.io/docs/adr/082-two-tier-cors-posture.html). + - `GetValidatedSigningKey(string)` (`:607`, `internal static`, outside the extension block) decodes the Base64 HMAC key and throws when it is under 256 bits (`:610-614`), so a too-short secret fails at startup rather than weakening every token. + - `BuildValidationParameters(JwtSettings)` (`:626`, also `internal static`) branches on [JwtSigningAlgorithm](group-14-module-system-composition.md#jwtsigningalgorithm): RS256 requires `RsaPublicKeyPem` and throws a message that points at `AddForwardedJwtBearer` when it is missing (`:630-634`), imports the PEM into an `RSA` held for the app lifetime (`:636-639`, with a justified CA2000 suppression) and pins RS256 (`:649`); the default HS256 path builds a `SymmetricSecurityKey` from the validated secret and pins HmacSha256 (`:653-666`). +- **Why it's built this way**: two authentication entry points are the framework's monolith-to-microservice hinge ([ADR-004](https://ivanball.github.io/docs/adr/004-authentication-dual-fetch.html)): the monolith or the issuing Identity service validates in process against a local key, while an extracted service validates against the issuer's published JWKS with no shared secret. The explicit `ValidAlgorithms` pin on **both** paths is deliberate defense in depth rather than trust in the token header. Taking `IConfiguration` and `IHostEnvironment` as required arguments on `AddForwardedJwtBearer` is what makes "HTTPS metadata unless you say otherwise" the default a host cannot forget: the insecure value stays reachable for the deployments that need it, but only through a named key and never silently. On the limiter side, factoring every partition through `CreateLimitedPartition` is what let the Redis counter and the sliding window arrive without touching a single partition-key rule: the policy names, the bypass list and every key are identical whatever the settings say, and only the permit counts, the algorithm and the counter's location change (`:323-327`). +- **Where it's used**: every ADC and Store service host calls the builder-side quartet in one block (`AddCommonCors`, `AddCommonApiVersioning`, `AddCommonRateLimiting`, `AddCommonResponseCompression`). Identity hosts take the in-process mode while the other services take the forwarded mode, passing `builder.Configuration` and `builder.Environment` so the framework resolves the metadata posture (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:298-302`). The `"auth-ip"` policy is applied by attribute on the login and register actions of [AuthControllerBase](#authcontrollerbase) (`AuthControllerBase.cs:57` and `:79`), so every consumer inheriting that base gets it without opting in. The internal partition helpers are exercised directly by `WebApplicationBuilderExtensionsTests`, `RateLimitPartitionTests` and `RateLimitAlgorithmSelectionTests`; the metadata resolution by `ForwardedJwtBearerSecurityTests`. - **Caveats / not-in-source**: the five permit-limit defaults are framework defaults only. What a given deployment actually enforces is whatever the host passes or configures under `RateLimiting`, and that configuration value is not determinable from this file. Likewise `Distributed` only takes effect when the host also registers an `IConnectionMultiplexer`; whether it does is host composition, not this file. -### IEntityDTOMapper -> MMCA.Common.Application · `MMCA.Common.Application.Interfaces` · `MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEntityDTOMapper.cs:14` · Level 4 · interface +### MiddlewarePipelineBuilder +> MMCA.Common.API · `MMCA.Common.API.Startup` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/MiddlewarePipelineBuilder.cs:15` · Level 10 · class (sealed) -- **What it is**: the contract for mapping a domain entity to its DTO. It declares `MapToDTO(entity)` and ships a default `MapToDTOs(collection)` that fans `MapToDTO` across a collection. -- **Depends on**: [AuditableBaseEntity](group-02-domain-building-blocks.md#auditablebaseentitytidentifiertype) and [IBaseDTO](#ibasedtotidentifiertype), both as generic constraints. -- **Concept introduced (manual DTO mapping).** `[Rubric §16, Maintainability]`: the framework maps by hand in classes implementing this interface rather than through a reflective mapper, so a missing or mistyped mapping is a compile error and not a runtime surprise ([ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html)). `[Rubric §1, SOLID]`: the interface has exactly one required member (interface segregation), and the default `MapToDTOs` (`IEntityDTOMapper.cs:27-32`) is a C# **default interface method**, so every concrete mapper inherits batch mapping for free and overrides it only when a bulk-lookup optimization is worth writing `[Rubric §2, Design Patterns]`. -- **Walkthrough**: the three constraints (`IEntityDTOMapper.cs:15-17`) force the entity and the DTO to agree on the identifier type and require it to be `notnull`, so a structurally unsound mapper does not compile. `MapToDTO(TEntity)` (`:22`) is the single required member. `MapToDTOs(...)` (`:27`) null-guards its argument (`:29`) and projects with `Select` into a read-only collection through a collection expression (`:31`). -- **Why it's built this way**: [ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html) chose compile-time discoverability over reflective convenience. Implementations are auto-registered by the Scrutor scan, which picks up everything assignable to the open generic, so adding a mapper needs no DI edit. -- **Where it's used**: it is a constructor dependency and a public property of [EntityQueryService](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype) and is surfaced on the [IEntityQueryService](group-03-querying-specifications.md#ientityqueryservicetentity-tentitydto-tidentifiertype) contract. The framework ships one implementation itself, [PushNotificationDTOMapper](group-10-notifications.md#pushnotificationdtomapper), and every module in the apps supplies one per entity. +- **What it is**: the mutable, ordered list of [MiddlewarePipelineStep](#middlewarepipelinestep) values behind `UseCommonMiddlewarePipeline`. `CreateDefault()` seeds the framework's eighteen-step edge pipeline; a host may then insert, replace, or remove steps by name; and `Build()` re-checks four load-bearing adjacencies before anything is applied. +- **Depends on**: [MiddlewarePipelineStep](#middlewarepipelinestep) and [MiddlewarePipelineStepNames](#middlewarepipelinestepnames); [CorrelationIdMiddleware](#correlationidmiddleware), [TenantResolutionMiddleware](#tenantresolutionmiddleware) and [SoftDeletedUserMiddleware](#softdeletedusermiddleware) (the three custom middlewares it wires); [WebApplicationBuilderExtensions](#webapplicationbuilderextensions) for the two CORS policy names; [JwksEndpointExtensions](#jwksendpointextensions) and [OidcDiscoveryEndpointExtensions](#oidcdiscoveryendpointextensions) for the two always-mapped well-known endpoints; [WebApplicationExtensions](#webapplicationextensions) for `UseCommonRequestLocalization` and the two pre-forwarded `HttpContext.Items` keys; ASP.NET forwarded-headers primitives. +- **Concept introduced (a customization point that validates itself, not a free-for-all).** The tension a shared pipeline has to resolve: one fixed order is safe but blocks any host with a legitimate extra step, while an open `Action` hook gives the order back to every host and re-opens exactly the bugs the shared pipeline closed. The resolution here is a **scoped escape hatch with startup-enforced invariants**: the host may edit the list, but four adjacencies are re-asserted afterwards and a violation throws while the host is starting, naming the invariant it broke and printing the current order. `[Rubric §16, Maintainability]` assesses whether a change can be made locally without breaking distant behavior; encoding the rationale as an executable check rather than a comment is what makes that true here. `[Rubric §13, Observability & Operability]` also applies: the failure modes these invariants prevent (an unreachable `jwks_uri`, a tenant that never resolves, a per-user rate cap that never engages) all look like configuration bugs at runtime, so converting them into a startup exception with a named cause is a large operability win. See [ADR-079](https://ivanball.github.io/docs/adr/079-shared-http-middleware-pipeline.html). +- **Walkthrough** + - One field, `_steps` (`MiddlewarePipelineBuilder.cs:17`), and a private constructor (`:19`), so the only way in is `CreateDefault()`. `StepNames` (`:24`) projects the current names in application order, which is what the fitness function asserts on and what error messages print. + - `CreateDefault()` (`:31-156`) seeds the eighteen steps. Reading it top to bottom is the fastest way to learn the edge: exception handler (`:34-36`), correlation id (`:38-40`), request localization (`:42-47`, with the ADR-027 note that this runs early so edge error localization uses the caller's culture), the pre-forwarded scheme and host capture (`:49-62`), forwarded headers with `KnownProxies` and `KnownIPNetworks` cleared for cloud reverse proxies (`:64-80`), an HTTPS redirect wrapped in `UseWhen` that **skips `application/grpc`** so h2c gRPC calls are not 307-redirected (`:82-92`), response compression (`:94-96`), routing (`:98-100`), CORS choosing the development or production policy by environment (`:102-106`), authentication (`:108-110`), tenant resolution (`:112-118`), the rate limiter (`:120-126`), the soft-deleted-user filter (`:128-130`), authorization (`:132-134`), output cache (`:136-138`), the always-mapped JWKS (`:140-147`) and OIDC discovery (`:149-151`) endpoints, and finally `MapControllers()` (`:153-155`). Each `Configure` delegate is `static`, so no closure is allocated per step. + - Four mutators, all returning `this` for chaining and all validating first: `InsertBefore` (`:166`), `InsertAfter` (`:183`), `Replace` (`:203`) and `Remove` (`:224`). `Replace` keeps the replaced step's position and permits a different name, but rejects a name another step already carries (`:208-214`). + - `Build()` (`:257`) runs the four checks and returns a defensive copy (`:279`). Two adjacency checks: `PreForwardedCapture` immediately before `ForwardedHeaders` (`:259-262`) and `Authentication` immediately before `TenantResolution` (`:264-267`). Two precedence checks: `Authentication` before `RateLimiting` (`:269-272`, ADR-019) and `ForwardedHeaders` before `HttpsRedirection` (`:274-277`). Every check carries its rationale string, which is what the exception message prints. + - The private helpers hold the guard semantics. `RequireIndexOf` (`:285`) rejects a blank name and, for an unknown one, throws listing every known step (`:296-299`), which turns a typo into a self-answering error. `RequireUniqueName` (`:304`) enforces name uniqueness across the list. `RequireImmediatelyBefore` (`:314`) and `RequirePrecedes` (`:329`) share one subtle rule: an invariant **binds only when both of its steps are still present** (`:320`, `:335`), so a host that removes a whole capability (both members of a pair) stays legal, while a host that removes only one half is not constrained by a rule that no longer has anything to say. +- **Why it's built this way**: the "both present or the rule is silent" clause is the design decision worth internalizing. Without it, `Remove(MiddlewarePipelineStepNames.TenantResolution)` on a single-tenant host would fail the authentication adjacency check for no reason, and the escape hatch would be unusable. With it, the invariants constrain *reordering* rather than *composition*, which is what they were written to protect. Constructing the defaults as data rather than as calls also means the whole order can be asserted with no `WebApplication` built at all, which is what puts the fitness function in the fast unit tier. +- **Where it's used**: only through [WebApplicationExtensions](#webapplicationextensions)`.ApplyPipeline` (`WebApplicationExtensions.cs:138-149`), which both `UseCommonMiddlewarePipeline` overloads route through, so the zero-argument path is exactly the validated default pipeline. `MiddlewarePipelineOrderTestsBase` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/MiddlewarePipelineOrderTestsBase.cs:29`) subclasses into each app's architecture tier (ADC, Store, Helpdesk, and Common's own testing tier) and freezes the eighteen-name order; `MiddlewarePipelineBuilderTests` covers the mutators and the invariants directly. +- **Caveats / not-in-source**: the builder validates order, not semantics. A `Replace` that keeps a step's name but swaps in an unrelated middleware passes every check, because nothing inspects the `Configure` delegate. -### IEntityRequestMapper -> MMCA.Common.Application · `MMCA.Common.Application.Interfaces` · `MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEntityDTOMapper.cs:42` · Level 4 · interface +### WebApplicationExtensions +> MMCA.Common.API · `MMCA.Common.API.Startup` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:14` · Level 10 · class (static, extension block) -- **What it is**: the create-side counterpart to [IEntityDTOMapper](#ientitydtomappertentity-tentitydto-tidentifiertype). It maps an incoming create request to a domain entity through that entity's factory method, returning `Task>` so asynchronous validation can run before the entity exists. It is declared in the **same file** as the read mapper, so one file owns both mapping directions. -- **Depends on**: [AuditableBaseEntity](group-02-domain-building-blocks.md#auditablebaseentitytidentifiertype) and [ICreateRequest](group-05-cqrs-pipeline.md#icreaterequest) as constraints (`IEntityDTOMapper.cs:43-45`), and [Result](group-01-result-error-handling.md#result) as the return payload. -- **Concept (request-to-entity mapping with async validation).** `[Rubric §1, SOLID]`: separating create-mapping from read-mapping keeps each interface to one reason to change. `[Rubric §9, API & Contract Design]`: the `ICreateRequest` constraint tags a DTO as a create payload, so a read DTO cannot be passed down this path by accident. The `Task>` signature is the load-bearing detail: creation frequently needs a database round trip (a uniqueness check) before the factory runs, and any failure surfaces as a `Result` error rather than an exception, exactly as the doc comment describes (`IEntityDTOMapper.cs:35-38`, `:47-50`). -- **Walkthrough**: one member, `CreateEntityAsync(TCreateRequest request, CancellationToken cancellationToken = default)` (`IEntityDTOMapper.cs:54`). Implementations call the entity's `Create(...)` factory and return its `Result` unchanged, so validation errors thread through without translation. -- **Why it's built this way**: the same [ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html) rationale (explicit, compile-checked mapping), and co-locating it with the read mapper documents the expectation that a module supplies both directions per entity. -- **Where it's used**: implemented by the per-entity `*CreateRequestMapper` classes in each module and injected into the matching create handlers (`MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Application/Tickets/UseCases/Create/CreateTicketHandler.cs:21` is the smallest worked example in the workspace). +- **What it is**: the `extension(WebApplication app)` type every downstream host calls to wire its HTTP edge: the two `UseCommonMiddlewarePipeline` overloads, the request-localization member, and the culture-switch endpoint. It is the runtime-side sibling of [WebApplicationBuilderExtensions](#webapplicationbuilderextensions). +- **Depends on**: [MiddlewarePipelineBuilder](#middlewarepipelinebuilder) (which now owns the step list) and [SupportedCultures](#supportedcultures); ASP.NET localization and cookie primitives. +- **Concept (one canonical, ordered pipeline, applied through one private helper).** `[Rubric §10, Cross-Cutting]` and `[Rubric §13, Observability & Operability]`: middleware order is behavior, not taste. Correlation must be established before anything downstream logs, and authentication must run before the rate limiter so the per-user partition sees a principal at all. Centralizing the order means a host cannot get it wrong ([ADR-079](https://ivanball.github.io/docs/adr/079-shared-http-middleware-pipeline.html)). `[Rubric §27, i18n]` applies through the localization wiring ([ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html)). +- **Walkthrough** + - Two internal constants, `PreForwardedSchemeKey` (`WebApplicationExtensions.cs:22`) and `PreForwardedHostKey` (`:33`), name the `HttpContext.Items` slots that the pipeline's `PreForwardedCapture` step writes **before** `UseForwardedHeaders` rewrites scheme and host. The comment on the host key (`:24-32`) records why: Aspire/DCP injects an `X-Forwarded-Host` pointing at the canonical launchSettings URL, which internal callers cannot reach. + - `UseCommonMiddlewarePipeline()` (`:46`) is now a one-liner: `ApplyPipeline(app, configure: null)`. Its doc comment (`:37-45`) states the contract in one sentence worth keeping: the order is data, not prose, named by [MiddlewarePipelineStepNames](#middlewarepipelinestepnames) and frozen by the `MiddlewarePipelineOrderTestsBase` fitness function. + - `UseCommonMiddlewarePipeline(Action configure)` (`:58`) is the scoped escape hatch: it null-guards the delegate (`:60`) and routes through the same helper (`:61`). The XML doc declares both failure modes, `ArgumentNullException` and the `InvalidOperationException` an invariant violation raises (`:56-57`). + - `UseCommonRequestLocalization()` (`:71`) builds the supported list from [SupportedCultures](#supportedcultures)`.All` (`:73`), appends the pseudo-locale in **Development only** (`:78-81`), and sets the default plus both supported and supported-UI culture lists (`:84-87`). It is itself the `RequestLocalization` step of the default pipeline, and Blazor UI hosts call it explicitly before `MapRazorComponents` so SSR prerender runs under the right culture (`:64-70`). + - `MapCultureEndpoint()` (`:100`) maps the anonymous `GET /culture/set?culture=&redirectUri=` that the culture switcher calls. It honors only allowlisted cultures, and the pseudo-locale only in Development (`:104`, `:107`), writes the standard ASP.NET culture cookie as **non-HttpOnly** so the WASM client can read it (`:110-121`, with `Secure` conditional on the environment and both deviations justified inline at `:109`), then local-redirects (`:125-126`) to force a full reload. + - `ApplyPipeline` (`:138`, private) is the whole application step: seed the defaults (`:140`), let the host's delegate adjust them if there is one (`:141`), then `foreach` over `builder.Build()` invoking each step's `Configure` in order (`:143-146`). Because both public overloads route through here, the zero-argument path is exactly the validated default pipeline (`:133-137`). +- **Why it's built this way**: pushing the step list out into [MiddlewarePipelineBuilder](#middlewarepipelinebuilder) and keeping only `ApplyPipeline` here is what lets the order be inspected and asserted while the entry point stays a single line in a host's `Program.cs`. The `configure`-then-`Build` sequence is deliberate: the host mutates first and the invariants are checked last, so a customization is judged on its result rather than on the order the host happened to make its edits. +- **Where it's used**: called once per service host after `app.Build()`, for example `MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:399`, `MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:277`, and `MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:117`. The Blazor UI hosts instead call the localization and culture-endpoint members directly (`MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:160`). +- **Caveats / not-in-source**: a host that maps additional endpoints (SignalR hubs, minimal-API endpoints, app-association documents) does so after this call; the framework cannot enforce that ordering, it only documents it on the members that require it (for example [SignalRExtensions](#signalrextensions)`.MapNotificationHub`, `MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/SignalRExtensions.cs:16-21`). Note also that `MMCA.Common.UI` ships a *different* `WebApplicationExtensions` (see [WebApplicationExtensions](group-15-common-ui-framework.md#webapplicationextensions) in the UI framework chapter); the two share a name and nothing else. ### DatabaseInitializationExtensions -> MMCA.Common.API · `MMCA.Common.API.Startup` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:21` · Level 8 · class (static, extension block) +> MMCA.Common.API · `MMCA.Common.API.Startup` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:21` · Level 13 · class (static, extension block) - **What it is**: the shared startup routine that, per **physical data source** and then per **tenant database**, creates or migrates the schema and finally runs each enabled module's seeder. - **Depends on**: [IEntityDataSourceRegistry](group-07-persistence-ef-core.md#ientitydatasourceregistry), [IDataSourceResolver](group-07-persistence-ef-core.md#idatasourceresolver), [IDbContextFactory](group-07-persistence-ef-core.md#idbcontextfactory), [DataSourceKey](group-07-persistence-ef-core.md#datasourcekey) and its [DataSource](group-07-persistence-ef-core.md#datasource) engine enum, [TenantDataSourceTargets](group-07-persistence-ef-core.md#tenantdatasourcetargets) / [TenantDataSourceTarget](group-07-persistence-ef-core.md#tenantdatasourcetarget), [ITenantContext](group-05-cqrs-pipeline.md#itenantcontext), [TenancySettings](group-14-module-system-composition.md#tenancysettings), [ApplicationSettings](group-14-module-system-composition.md#applicationsettings) and [ModuleLoader](group-14-module-system-composition.md#moduleloader). @@ -1505,24 +1623,9 @@ code stays modules and domain logic, never plumbing. - `InitializeTenantDatabasesAsync` (`:112`) returns immediately when no [TenancySettings](group-14-module-system-composition.md#tenancysettings) is registered or no tenants are configured (`:118-122`), then, for each expanded tenant target (`:124-125`), opens a **fresh scope** and sets the tenant on [ITenantContext](group-05-cqrs-pipeline.md#itenantcontext) *before* asking for a context factory (`:127-131`). The remark (`:107-111`) is the reason: the scoped factory binds one physical database per source for the life of a scope, so reusing the outer scope would keep handing back the shared database. It then applies the same three-way strategy per tenant, migrating only SQL Server and falling back to `EnsureCreated` for other engines (`:133-157`). - `ThrowIfTenantPendingMigrationsAsync` (`:165`) and `ThrowIfPendingMigrationsAsync` (`:191`) are the production rails. The shared one short-circuits on `IDbContextFactory.HasPendingMigrationsAsync` (`:196-199`) and otherwise builds a per-source breakdown of exactly which migrations are behind before throwing (`:201-214`); the tenant one skips non-SQL-Server engines (`:170-173`) and throws naming the tenant target (`:181-184`). - **Why it's built this way**: one shared init path keeps every downstream service consistent, and the `"None"` strategy is the deploy-time guarantee that an app never serves traffic against an un-migrated database when migrations are applied by the pipeline rather than the app. The tenant pass exists because nothing else ever opens a per-tenant database ([ADR-073](https://ivanball.github.io/docs/adr/073-multi-tenancy-model.html)), so without it such a database is never created and never migrated (`:102-105`). -- **Where it's used**: called from each service host's `Program.cs` after `app.Build()` and before the middleware pipeline is wired. Its branches are covered by `DatabaseInitializationExtensionsTests`. +- **Where it's used**: called from each service host's `Program.cs` after `app.Build()` and before the middleware pipeline is wired. Its branches are covered by `DatabaseInitializationExtensionsTests` (`MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Startup/DatabaseInitializationExtensionsTests.cs`). - **Caveats / not-in-source**: which strategy a deployment runs is configuration, not code. ADC sets `Migrate` in production so each service migrates its own database at startup, which is a deployment decision recorded in `MMCA.ADC/CLAUDE.md`, not something this file can show. -### WebApplicationExtensions -> MMCA.Common.API · `MMCA.Common.API.Startup` · `MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:16` · Level 10 · class (static, extension block) - -- **What it is**: the `extension(WebApplication app)` type that defines the canonical middleware pipeline (`UseCommonMiddlewarePipeline`) plus the request-localization and culture-switch endpoints, so every downstream host wires middleware in exactly one order. It is the runtime-side sibling of [WebApplicationBuilderExtensions](#webapplicationbuilderextensions). -- **Depends on**: [CorrelationIdMiddleware](#correlationidmiddleware), [TenantResolutionMiddleware](#tenantresolutionmiddleware), [SoftDeletedUserMiddleware](#softdeletedusermiddleware), [WebApplicationBuilderExtensions](#webapplicationbuilderextensions) for the CORS policy names, [JwksEndpointExtensions](#jwksendpointextensions), [OidcDiscoveryEndpointExtensions](#oidcdiscoveryendpointextensions) and [SupportedCultures](#supportedcultures); ASP.NET forwarded-headers and localization primitives. -- **Concept (one canonical, ordered pipeline).** `[Rubric §10, Cross-Cutting]` and `[Rubric §13, Observability & Operability]`: middleware order is behavior, not taste. Correlation must be established before anything downstream logs, and authentication must run before the rate limiter so the per-user partition sees a principal at all. Centralizing the order means a host cannot get it wrong ([ADR-079](https://ivanball.github.io/docs/adr/079-shared-http-middleware-pipeline.html)). `[Rubric §27, i18n]` applies through the localization wiring ([ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html)). -- **Walkthrough** - - Two internal constants, `PreForwardedSchemeKey` (`WebApplicationExtensions.cs:24`) and `PreForwardedHostKey` (`:35`), name the `HttpContext.Items` slots that capture the transport scheme and host **before** `UseForwardedHeaders` rewrites them. The comment on the host key (`:26-34`) records why: Aspire/DCP injects an `X-Forwarded-Host` pointing at the canonical launchSettings URL, which internal callers cannot reach. - - `UseCommonMiddlewarePipeline()` (`:45`) wires, in order: exception handler (`:47`), correlation-id middleware (`:48`), request localization (`:53`, so edge error localization runs under the caller's culture), forwarded-headers options with `KnownProxies`/`KnownIPNetworks` cleared for cloud reverse proxies (`:55-64`), the capture step storing the pre-forwarded scheme and host (`:72-77`), `UseForwardedHeaders` (`:79`), an HTTPS redirect wrapped in `UseWhen` that **skips `application/grpc`** so h2c gRPC calls are not 307-redirected (`:87-89`), response compression (`:91`), routing (`:92`), CORS choosing the development or production policy by environment (`:93-95`), authentication (`:96`), tenant resolution (`:102`), the rate limiter (`:108`), the soft-deleted-user middleware (`:109`), authorization (`:110`), output cache (`:111`), the always-mapped `MapJwksEndpoint()` / `MapOidcDiscoveryEndpoint()` pair (`:118-119`), and finally `MapControllers()` (`:121`). Two comments carry the ordering rationale: tenant resolution sits immediately after authentication because its claim strategy reads `HttpContext.User` (`:98-101`), and the rate limiter sits after authentication per [ADR-019](https://ivanball.github.io/docs/adr/019-rate-limiting.html) because otherwise every request looks anonymous and the per-user cap never engages (`:104-107`). - - `UseCommonRequestLocalization()` (`:133`) builds the supported list from [SupportedCultures](#supportedcultures)`.All` (`:135`), appends the pseudo-locale in **Development only** (`:140-143`), and sets the default plus both supported and supported-UI culture lists (`:146-151`). Blazor UI hosts call it explicitly before `MapRazorComponents` so SSR prerender runs under the right culture (`:126-132`). - - `MapCultureEndpoint()` (`:162`) maps the anonymous `GET /culture/set?culture=&redirectUri=` that the culture switcher calls. It honors only allowlisted cultures, and the pseudo-locale only in Development (`:166`, `:169`), writes the standard ASP.NET culture cookie as **non-HttpOnly** so the WASM client can read it (`:172-183`, with `Secure` conditional on the environment and both deviations justified inline at `:171`), then local-redirects (`:187-188`) to force a full reload. -- **Why it's built this way**: centralizing the order means a host cannot accidentally place rate limiting before authentication or forget forwarded-headers handling behind a cloud proxy. That last point is load-bearing for the limiter: `UseForwardedHeaders` running before `UseRateLimiter` is what makes `Connection.RemoteIpAddress` the real client IP for the `auth-ip` partition (`WebApplicationBuilderExtensions.cs:346-349`). The JWKS and OIDC endpoints are mapped unconditionally so a non-Identity host degrades to an empty key set or a `404` rather than diverging in wiring (`:113-117`). -- **Where it's used**: called once per service host after `app.Build()`. The Blazor web host instead calls the two localization members directly. -- **Caveats / not-in-source**: a host that maps additional endpoints (SignalR hubs, minimal-API endpoints, app-association documents) does so after this call; the framework cannot enforce that ordering, it only documents it on the members that require it (for example [SignalRExtensions](#signalrextensions)`.MapNotificationHub`, `MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/SignalRExtensions.cs:16-21`). - ### IBaseDTO > MMCA.Common.Shared · `MMCA.Common.Shared.DTOs` · `MMCA.Common/Source/Core/MMCA.Common.Shared/DTOs/IBaseDTO.cs:9` · Level 0 · interface diff --git a/docs-src/onboarding/group-14-module-system-composition.md b/docs-src/onboarding/group-14-module-system-composition.md index f225380..3d37647 100644 --- a/docs-src/onboarding/group-14-module-system-composition.md +++ b/docs-src/onboarding/group-14-module-system-composition.md @@ -17,7 +17,7 @@ the two data-source attributes ([`UseDataSourceAttribute`](#usedatasourceattribu [`MessageBusSettings`](#messagebussettings), [`OutboxSettings`](#outboxsettings), [`PersistenceSettings`](#persistencesettings), the JWT/JWKS group, [`SmtpSettings`](#smtpsettings), [`PushNotificationSettings`](#pushnotificationsettings), [`NativePushSettings`](#nativepushsettings), -[`FileStorageSettings`](#filestoragesettings)) and the newer opt-in feature sections +[`FileStorageSettings`](#filestoragesettings)) and the opt-in feature sections ([`SchedulerSettings`](#schedulersettings), [`AuditTrailSettings`](#audittrailsettings), [`TenancySettings`](#tenancysettings)); the cross-replica locking pair ([`RedisDistributedLock`](#redisdistributedlock), [`InProcessDistributedLock`](#inprocessdistributedlock)); @@ -61,7 +61,7 @@ in its own service the Engagement module is *disabled* in that host's config, ye `GetSessionBookmarkCountHandler` still needs Engagement's `IBookmarkCountService`, so the disabled Engagement module contributes a stub and the host then *replaces* that stub with a typed gRPC client pointed at the real Engagement process -(`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:321-329`). Application code never +(`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:342-350`). Application code never learns which path it got; the transport choice lives entirely at the composition edge ([ADR-008](https://ivanball.github.io/docs/adr/008-service-extraction-topology.html)). `[Rubric §2, Design Patterns]` applies here: this is a clean strategy / null-object pairing (real service, disabled @@ -124,7 +124,7 @@ this method and the log messages above. A subtlety worth stating against the source: the loader is **not** called from inside `AddApplication()`. Each host's `Program.cs` constructs a [`ModuleLoader`](#moduleloader), hands it a logger, calls `DiscoverAndRegister` directly, then registers the loader instance itself as a singleton -(`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:313-319`). After discovery the +(`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:335-340`). After discovery the loader also drives startup data through `SeedAllAsync` (`ModuleLoader.cs:270-276`), which invokes each collected [`IModuleSeeder.SeedAsync`](#imoduleseeder) in registration order. [`IModuleSeeder`](#imoduleseeder) @@ -140,99 +140,118 @@ Service registration itself lives in two static [`DependencyInjection`](#depende each using a C# `extension(IServiceCollection services)` block (see [primer §4](00-primer.md#4-c-build-and-code-style-conventions) for the `extension(T)` syntax). The **Application** root -(`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:21`) exposes `AddApplication()` -(`DependencyInjection.cs:29`), which fronts [`ApplicationSettings`](#applicationsettings) with its -[`IApplicationSettings`](#iapplicationsettings) abstraction (`DependencyInjection.cs:31`), registers -the three core singletons ([`IDomainEventDispatcher`](group-04-events-outbox.md#idomaineventdispatcher), -[`INavigationMetadataProvider`](group-03-querying-specifications.md#inavigationmetadataprovider), -[`IEntityQueryPipeline`](group-03-querying-specifications.md#ientityquerypipeline), -`DependencyInjection.cs:33-35`), and pulls in the framework's own FluentValidation validators by -assembly (`DependencyInjection.cs:40`). It also owns `ScanModuleApplicationServices()` -(`DependencyInjection.cs:115-179`), the Scrutor convention scan every module's `AddXModule` calls: -domain-event and integration-event handlers as singletons (`DependencyInjection.cs:119-130`), DTO and -request mappers scoped (`DependencyInjection.cs:132-142`), command and query handlers scoped -(`DependencyInjection.cs:144-154`), validators from the module assembly (`DependencyInjection.cs:156`), -and finally a reflection pass that `TryAdd`s a `CommandRequestValidator<,>` for every command -implementing `ICommandWithRequest` (`DependencyInjection.cs:160-176`) so an explicit validator still -wins. The **Infrastructure** root +(`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:22`) exposes `AddApplication()` +(`DependencyInjection.cs:30`), which fronts [`ApplicationSettings`](#applicationsettings) with its +[`IApplicationSettings`](#iapplicationsettings) abstraction (`DependencyInjection.cs:32`), registers +the core singletons ([`IDomainEventDispatcher`](group-04-events-outbox.md#idomaineventdispatcher) at +`:34`, [`IEventUpcasterRegistry`](group-05-cqrs-pipeline.md#ieventupcasterregistry) at `:40`, +[`INavigationMetadataProvider`](group-03-querying-specifications.md#inavigationmetadataprovider) at +`:42`, [`IEntityQueryPipeline`](group-03-querying-specifications.md#ientityquerypipeline) at `:43`), +and pulls in the framework's own FluentValidation validators by assembly (`DependencyInjection.cs:48`). +The upcaster registry is registered unconditionally on purpose: with no upcasters it is an empty +registry whose operations are the identity, so both delivery paths can depend on it without a null +check (`DependencyInjection.cs:36-40`, +[ADR-090](https://ivanball.github.io/docs/adr/090-event-upcaster-registration.html)), and individual +upcasters accumulate through `AddEventUpcaster()` +(`DependencyInjection.cs:283-290`). + +The Application root also owns `ScanModuleApplicationServices()` +(`DependencyInjection.cs:140-213`), the Scrutor convention scan every module's `AddXModule` calls: +domain-event and integration-event handlers as singletons (`DependencyInjection.cs:144-155`), DTO +mappers, the opt-in +[`IEntityDTOProjector`](group-05-cqrs-pipeline.md#ientitydtoprojectortentity-tentitydto-tidentifiertype) +projectors and request mappers scoped (`DependencyInjection.cs:157-176`), command and query handlers +scoped (`DependencyInjection.cs:178-188`), validators from the module assembly +(`DependencyInjection.cs:190`), and finally a reflection pass that `TryAdd`s a +`CommandRequestValidator<,>` for every command implementing `ICommandWithRequest` +(`DependencyInjection.cs:194-210`) so an explicit validator still wins. + +The **Infrastructure** root (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:40`) exposes `AddInfrastructure(configuration)` (`DependencyInjection.cs:50`), which binds most of the settings types in this chapter, registers the three save interceptors as singletons (`DependencyInjection.cs:57-63`), the persistence stack (data-source service and resolver, entity registry, the scoped and singleton context factories, repositories, unit of work, `DependencyInjection.cs:52-109`), Scrutor-scans the framework's own EF entity configurations -(`DependencyInjection.cs:113-117`), adds caching (`DependencyInjection.cs:119`), and enrolls the two -outbox hosted services (`DependencyInjection.cs:151-152`). Optional add-ons sit alongside it: -`AddPushNotifications` (`DependencyInjection.cs:528`), `AddNativePushNotifications` -(`DependencyInjection.cs:563`), `AddAzureBlobFileStorage` (`DependencyInjection.cs:595`), -`AddBrokerMessaging` (`DependencyInjection.cs:647`), and the typed-client helper -`AddTypedServiceClient(serviceName)` (`DependencyInjection.cs:716`) that -swaps an in-process abstraction for an HTTP transport with JWT forwarding and the standard Polly -pipeline. - -`AddCaching` (`MMCA.Common.Infrastructure/DependencyInjection.cs:164`) also registers this chapter's +(`DependencyInjection.cs:113-117`), adds caching (`DependencyInjection.cs:119`), enrolls a startup +validator that fails the host on a bad upcaster graph (`DependencyInjection.cs:160-161`), and adds the +two outbox hosted services (`DependencyInjection.cs:164-165`). Optional add-ons sit alongside it: +`AddPushNotifications` (`DependencyInjection.cs:541`), `AddNativePushNotifications` +(`DependencyInjection.cs:576`), `AddAzureBlobFileStorage` (`DependencyInjection.cs:608`), +`AddBrokerMessaging` (`DependencyInjection.cs:660`), and the typed-client helper +`AddTypedServiceClient(serviceName)` (`DependencyInjection.cs:734`) that +swaps an in-process abstraction for an HTTP transport. + +`AddCaching` (`MMCA.Common.Infrastructure/DependencyInjection.cs:177`) also registers this chapter's one cross-replica primitive: an [`IDistributedLock`](group-05-cqrs-pipeline.md#idistributedlock) that resolves to [`RedisDistributedLock`](#redisdistributedlock) when the host has an `IConnectionMultiplexer` registered, and to the warn-once [`InProcessDistributedLock`](#inprocessdistributedlock) otherwise -(`MMCA.Common.Infrastructure/DependencyInjection.cs:195-209`). The Redis implementation is the -standard `SET key token NX PX ttl` lock with a compare-and-delete release script -(`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Concurrency/RedisDistributedLock.cs:36-37`, -`RedisDistributedLock.cs:66-72`), handing back a [`RedisLockHandle`](#redislockhandle) that releases -exactly its own acquisition, once (`RedisDistributedLock.cs:88`); the fallback is exclusive only inside -one process, which is exactly what its warning says out loud +(`MMCA.Common.Infrastructure/DependencyInjection.cs:208-222`). The Redis implementation is the +standard `SET key token NX PX ttl` acquire +(`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Concurrency/RedisDistributedLock.cs:66-68`) with a +compare-and-delete release script (`RedisDistributedLock.cs:36-37`), handing back a +[`RedisLockHandle`](#redislockhandle) that releases exactly its own acquisition, once +(`RedisDistributedLock.cs:88-98`); the fallback is exclusive only inside one process, which is exactly +what its warning says out loud (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Concurrency/InProcessDistributedLock.cs:75`), and its [`InProcessLockHandle`](#inprocesslockhandle) simply removes the key from a -`ConcurrentDictionary` (`InProcessDistributedLock.cs:79-88`). A multi-replica host that registers no +`ConcurrentDictionary` (`InProcessDistributedLock.cs:79-91`). A multi-replica host that registers no Redis client therefore gets one execution of the guarded section per replica. `[Rubric §29, Resilience]` and `[Rubric §12, Performance & Scalability]` both touch this pair: the degradation is deliberate, announced, and never silent. The **order** of these calls is a hard contract in exactly one respect, and it is the reason -`AddApplicationDecorators()` (`MMCA.Common.Application/DependencyInjection.cs:89`) must come *last*. +`AddApplicationDecorators()` (`MMCA.Common.Application/DependencyInjection.cs:110`) must come *last*. Decorators are registered with **Scrutor's `TryDecorate`**, which wraps *existing* registrations, so every module's concrete handlers must already be in the container or there is nothing to wrap. Beyond that, the relative position of `AddInfrastructure` and `AddAPI` is not load-bearing. `[Rubric §6, CQRS & Event-Driven]` and `[Rubric §1, SOLID]` (open/closed) live here: cross-cutting behavior is added by wrapping, not by editing handlers. `AddApplicationDecorators` also encodes the **execution order** via `TryDecorate`'s reverse-registration rule (registered innermost first, -`MMCA.Common.Application/DependencyInjection.cs:94-103`), so the command pipeline ends up -`FeatureGate -> Logging -> Caching -> Validating -> Transactional -> handler` and the query pipeline -`FeatureGate -> Logging -> Caching -> handler` -([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). The decorator types -themselves (for example -[`FeatureGateCommandDecorator`](group-05-cqrs-pipeline.md#featuregatecommanddecoratortcommand-tresult) -and [`LoggingCommandDecorator`](group-05-cqrs-pipeline.md#loggingcommanddecoratortcommand-tresult)) +`MMCA.Common.Application/DependencyInjection.cs:115-128`), so the command pipeline ends up +`FeatureGate -> Authorization -> Logging -> Caching -> Validating -> Timeout -> Transactional -> handler` +and the query pipeline `FeatureGate -> Authorization -> Logging -> Caching -> Timeout -> handler` +([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). The rationale for +each position is written out in the method's own doc comment +(`MMCA.Common.Application/DependencyInjection.cs:84-107`): authorization sits outside caching so a +denied request neither reads nor populates the cache, validation sits outside the transaction so an +invalid command never opens one, and the timeout budget sits inside validation and outside the +transaction so it covers the database work and cancels it rather than leaving it open. The decorator +types themselves (for example +[`FeatureGateCommandDecorator`](group-05-cqrs-pipeline.md#featuregatecommanddecoratortcommand-tresult), +[`AuthorizationCommandDecorator`](group-05-cqrs-pipeline.md#authorizationcommanddecoratortcommand-tresult) +and [`TimeoutCommandDecorator`](group-05-cqrs-pipeline.md#timeoutcommanddecoratortcommand-tresult)) are documented in the CQRS-pipeline chapter; this chapter owns only the *wiring* of them. An optional MiniProfiler pair is registered separately by an opt-in `AddApplicationProfiling()` -(`MMCA.Common.Application/DependencyInjection.cs:219-225`), never by `AddApplicationDecorators()`. +(`MMCA.Common.Application/DependencyInjection.cs:297-303`), never by `AddApplicationDecorators()`. ## Opt-in platform features are composed the same way -Four newer capabilities are registered beside the roots rather than inside them, and they share one +Four capabilities are registered beside the roots rather than inside them, and they share one discipline: **registering a feature is not the same as turning it on.** `AddScheduledJobs(configuration)` -(`MMCA.Common.Infrastructure/DependencyInjection.cs:304`) binds +(`MMCA.Common.Infrastructure/DependencyInjection.cs:317`) binds [`SchedulerSettings`](#schedulersettings) and enrolls [`ScheduledJobRunner`](#scheduledjobrunner) through `TryAddEnumerable` rather than `AddHostedService`, precisely so two modules calling it cannot -start two runners racing for the same rows (`DependencyInjection.cs:311-315`); individual jobs arrive -through `AddScheduledJob()` (`DependencyInjection.cs:339-345`), each registered scoped so the +start two runners racing for the same rows (`DependencyInjection.cs:324-328`); individual jobs arrive +through `AddScheduledJob()` (`DependencyInjection.cs:352-357`), each registered scoped so the runner can resolve it in a fresh scope per execution. `AddAuditTrail(configuration)` -(`DependencyInjection.cs:375`) binds [`AuditTrailSettings`](#audittrailsettings), adds the +(`DependencyInjection.cs:388`) binds [`AuditTrailSettings`](#audittrailsettings), adds the [`AuditTrailSaveChangesInterceptor`](group-07-persistence-ef-core.md#audittrailsavechangesinterceptor) and the [`AuditTrailReader`](group-07-persistence-ef-core.md#audittrailreader) that projects [`AuditTrailEntryDTO`](#audittrailentrydto) rows, and contributes its own retention job -(`DependencyInjection.cs:377-391`), which only actually runs when the host also enabled the scheduler. -`AddMultiTenancy(configuration)` (`DependencyInjection.cs:424`) binds +(`DependencyInjection.cs:390-404`), which only actually runs when the host also enabled the scheduler. +`AddMultiTenancy(configuration)` (`DependencyInjection.cs:437`) binds [`TenancySettings`](#tenancysettings) and registers [`TenancySettingsValidator`](#tenancysettingsvalidator) as an `IValidateOptions` -(`DependencyInjection.cs:426-433`); note what it does *not* do, because that is the design: +(`DependencyInjection.cs:439-446`); note what it does *not* do, because that is the design: [`TenantSaveChangesInterceptor`](group-07-persistence-ef-core.md#tenantsavechangesinterceptor) and [`ITenantContext`](group-05-cqrs-pipeline.md#itenantcontext) are registered unconditionally by -`AddInfrastructure` and `AddServices` (`DependencyInjection.cs:63`, `:452`) and stay inert until a +`AddInfrastructure` and `AddServices` (`DependencyInjection.cs:63`, `:465`) and stay inert until a tenant is resolved, so the framework can never sit in the half-wired state where entities carry [`ITenantEntity`](group-02-domain-building-blocks.md#itenantentity) but the write-side guard is off ([ADR-073](https://ivanball.github.io/docs/adr/073-multi-tenancy-model.html)). Finally -`AddUserDataExportSection()` (`MMCA.Common.Application/DependencyInjection.cs:206-212`) +`AddUserDataExportSection()` (`MMCA.Common.Application/DependencyInjection.cs:240-246`) accumulates [`IUserDataExportSection`](#iuserdataexportsection) contributors into the one `IEnumerable` the export handler fans out over; ADC's Identity module registers two of them (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:42-43`). The @@ -249,16 +268,21 @@ of these with real runtime machinery, and it reuses the outbox's idioms wholesal (`ScheduledJobRunner.cs:69`, `:87`), then loops: reconcile the `ScheduledJobs` rows against the registered jobs, claim due rows with a lease, execute, stamp the outcome, and smart-wait until the earliest upcoming occurrence capped at `Scheduler:PollingIntervalSeconds` -(`ScheduledJobRunner.cs:94-126`). A claim attempt returns a [`JobClaim`](#jobclaim) carrying either -this replica's lock token or `null` when another replica won the row -(`ScheduledJobRunner.cs:439-447`), which is what makes an occurrence run exactly once across a scaled -host. The persisted row is [`ScheduledJobEntry`](#scheduledjobentry) +(`ScheduledJobRunner.cs:94-126`). A claim attempt is a single filtered `ExecuteUpdateAsync` against the +still-unleased predicate, so two racing replicas both issue it and exactly one matches +(`ScheduledJobRunner.cs:429-439`); it returns a [`JobClaim`](#jobclaim) carrying either this replica's +lock token or `null` when another replica won the row (`ScheduledJobRunner.cs:447`), which is what +makes an occurrence run exactly once across a scaled host. The persisted row is +[`ScheduledJobEntry`](#scheduledjobentry) (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Scheduling/ScheduledJobEntry.cs:20`), deliberately not an auditable entity: it is framework bookkeeping with an explicit claim lease instead of a concurrency token (`ScheduledJobEntry.cs:10-13`). [`SchedulerMetrics`](#schedulermetrics) (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Scheduling/SchedulerMetrics.cs:16`) publishes the `MMCA.Common.Scheduler` meter with a run counter tagged by job and outcome -(`SchedulerMetrics.cs:28-31`) and a duration histogram (`SchedulerMetrics.cs:39-40`), so +(`SchedulerMetrics.cs:28-31`) and a duration histogram (`SchedulerMetrics.cs:39-42`), the same shape +[`BrokerMetrics`](#brokermetrics) +(`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/BrokerMetrics.cs:18`) uses for the +`MMCA.Common.Broker` meter's fault and circuit-open counters (`BrokerMetrics.cs:30`, `:42`). So `[Rubric §13, Observability & Operability]` is covered by instruments rather than by log scraping. ## Assembly anchors @@ -271,7 +295,7 @@ each a trivial `static class AssemblyReference` holding `Assembly` / `AssemblyNa (`MMCA.Common/Source/Core/MMCA.Common.Domain/AssemblyReference.cs:8-12`) beside a non-static `class ClassReference` (`AssemblyReference.cs:18`) for the places a generic constraint forbids a static type. `AddApplication` uses the Application pair for the common validators -(`MMCA.Common.Application/DependencyInjection.cs:40`) and `AddInfrastructure` uses the Infrastructure +(`MMCA.Common.Application/DependencyInjection.cs:48`) and `AddInfrastructure` uses the Infrastructure pair to scan entity configurations (`MMCA.Common.Infrastructure/DependencyInjection.cs:113-117`). They are deliberately behavior-free; their whole job is to *name an assembly* for the scanning and governance tooling. @@ -290,7 +314,7 @@ concrete class is then usually fronted by an interface singleton so consumers de abstraction ([`IConnectionStringSettings`](#iconnectionstringsettings) at `DependencyInjection.cs:71`, [`ISmtpSettings`](#ismtpsettings) at `DependencyInjection.cs:85`, [`IJwtSettings`](#ijwtsettings) at `DependencyInjection.cs:65`, -[`IPushNotificationSettings`](#ipushnotificationsettings) at `DependencyInjection.cs:534`). +[`IPushNotificationSettings`](#ipushnotificationsettings) at `DependencyInjection.cs:547`). `[Rubric §13, Observability & Operability]` and `[Rubric §15, Best Practices]` apply: `ValidateOnStart` plus DataAnnotations ranges (for example [`OutboxSettings`](#outboxsettings) `BatchSize` is `[Range(1, 1000)]` with a default of 50, @@ -325,12 +349,12 @@ does not bind that way, with a constructor that rejects a reserved `"Default"` k (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Settings/DataSourcesSettings.cs:34-39`); [`MessageBusSettings`](#messagebussettings) and its [`MessageBusProvider`](#messagebusprovider) enum (`InProcess` / `RabbitMq` / `AzureServiceBus`, -`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Settings/MessageBusSettings.cs:68-84`) that +`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Settings/MessageBusSettings.cs:116-132`) that `AddBrokerMessaging` switches on, short-circuiting entirely for `InProcess` -(`MMCA.Common.Infrastructure/DependencyInjection.cs:656-659`) and otherwise `Replace`-ing both +(`MMCA.Common.Infrastructure/DependencyInjection.cs:669-672`) and otherwise `Replace`-ing both [`IMessageBus`](group-04-events-outbox.md#imessagebus) and [`IEventBus`](group-04-events-outbox.md#ieventbus) with their broker-backed counterparts -(`DependencyInjection.cs:676-682`); [`OutboxSettings`](#outboxsettings) (batch size, retries, polling +(`DependencyInjection.cs:689-695`); [`OutboxSettings`](#outboxsettings) (batch size, retries, polling and processing intervals, lease, retention) consumed by the [`OutboxProcessor`](group-04-events-outbox.md#outboxprocessor); [`PersistenceSettings`](#persistencesettings), whose single `CommandTimeoutSeconds` defaults to the 30 @@ -351,11 +375,11 @@ channels, [`SmtpSettings`](#smtpsettings), [`PushNotificationSettings`](#pushnot [`FileStorageSettings`](#filestoragesettings) ([ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-storage-and-avatars.html)). The last two follow a different discipline on purpose: their `Add*` methods bind the section and then **no-op** -when it is disabled or incomplete (`MMCA.Common.Infrastructure/DependencyInjection.cs:568-574` and -`:600-611`), so a host registers them unconditionally and a deployment switches the channel on by +when it is disabled or incomplete (`MMCA.Common.Infrastructure/DependencyInjection.cs:581-587` and +`:613-624`), so a host registers them unconditionally and a deployment switches the channel on by configuration alone. One binding is deliberately elsewhere: `JwtSettings` is bound by the API layer's `AddCommonAuthentication` -(`MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:344-349`), +(`MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:536-541`), while Infrastructure only registers the `IJwtSettings` facade over the resulting options (`MMCA.Common.Infrastructure/DependencyInjection.cs:65`), so a host that skips authentication never pays for a JWT section it does not have. @@ -371,7 +395,7 @@ that never opts in keeps exactly the migrations it had. Their tunables are range (`PollingIntervalSeconds` default 30, `LeaseSeconds` default 300, `SchedulerSettings.cs:33-43`; `RetentionDays` default 90, `AuditTrailSettings.cs:37-38`), and per-job retiming lives in [`ScheduledJobOverrideSettings`](#scheduledjoboverridesettings) bound from `Scheduler:Jobs:{Name}` -(`SchedulerSettings.cs:60-75`). [`TenancySettings`](#tenancysettings) +(`SchedulerSettings.cs:60`, `:66-75`). [`TenancySettings`](#tenancysettings) (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Settings/TenancySettings.cs:50`) adds the collection-binding subtlety: `ResolutionOrder` and `ExcludedPathPrefixes` bind as *empty* lists and the framework reads `EffectiveResolutionOrder` / `EffectiveExcludedPathPrefixes` instead @@ -417,8 +441,9 @@ to map each entity to a physical source; those types are documented in the persi ## Shared user use-case bases: composition in the other direction The chapter's last family is composition at the *handler* level rather than the container level. ADC -and Store each own an Identity module, and five of their account use cases had drifted into -line-identical copies, so the workflow was hoisted into abstract bases that each app subclasses: +and Store each own an Identity module, and seven of their account use cases had drifted into +line-identical copies (or would have), so the workflow was hoisted into abstract bases that each app +subclasses: [`ChangePasswordHandlerBase`](#changepasswordhandlerbasetuser-tcommand) (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:24`, [ADR-032](https://ivanball.github.io/docs/adr/032-password-hashing.html)), @@ -429,20 +454,34 @@ line-identical copies, so the workflow was hoisted into abstract bases that each [`DeleteUserHandlerBase`](#deleteuserhandlerbasetuser-tcommand) (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:38`), the erasure workflow behind -[ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html), and +[ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html), [`ExportUserDataHandlerBase`](#exportuserdatahandlerbasetuser-tquery) (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/ExportUserDataHandlerBase.cs:49`), -the data-subject access workflow. Each base is generic in the app's `User` aggregate and in the app's -own command or query record, and reads that record only through the small contracts in this group: +the data-subject access workflow, and the password-recovery pair +[`ForgotPasswordHandlerBase`](#forgotpasswordhandlerbasetuser-tcommand) +(`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:35`) +and [`ResetPasswordHandlerBase`](#resetpasswordhandlerbasetuser-tcommand) +(`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:30`), +which run the token issue-and-redeem flow described in +[ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html) over +`IPasswordResetTokenService` and answer identically whether or not the address holds an account +(`ResetPasswordHandlerBase.cs:20-21`). + +Each base is generic in the app's `User` aggregate and in the app's own command or query record, and +reads that record only through the small contracts in this group: [`IUserScopedRequest`](#iuserscopedrequest) (`UserId`, `MMCA.Common/Source/Core/MMCA.Common.Application/Users/IUserScopedRequest.cs:8`), [`IUserScopedCommand`](#iuserscopedcommandout-trequest) (adds the embedded payload, `IUserScopedCommand.cs:13`), and [`IUserOwnedRequest`](#iuserownedrequest) (adds `CurrentUserId` and `CurrentUserRole`, `IUserOwnedRequest.cs:8`). The commands stay app-side precisely because the two apps disagree on their pipeline attributes: ADC marks the password-change command `ICacheInvalidating` and -Store does not (`ChangePasswordHandlerBase.cs:16-21`). +Store does not (`ChangePasswordHandlerBase.cs:17-20`). Note that +[`IUserScopedCommand`](#iuserscopedcommandout-trequest) is deliberately *not* +`ICommandWithRequest`: implementing the latter also opts a command into automatic +`CommandRequestValidator` registration, which is a per-app decision, so implementing this one alone +changes no pipeline behavior (`IUserScopedCommand.cs:6-11`). -The export base is the most instructive of the five, because it is where the container-level and +The export base is the most instructive of the seven, because it is where the container-level and handler-level composition meet. It authorizes through [`UserOwnershipRule.CheckOwnership`](#userownershiprule) (`ExportUserDataHandlerBase.cs:81-90`), reads the account through `GetReadRepository` @@ -458,7 +497,7 @@ factories with the caller-safe default text in [`UserDataExportSectionDefaults`](#userdataexportsectiondefaults) (`IUserDataExportSection.cs:105-113`). The result is a [`UserDataExportDTO`](group-08-auth.md#userdataexportdto) that is PII by design and is therefore never -logged or cached (`ExportUserDataHandlerBase.cs:42-45`). +logged or cached (`ExportUserDataHandlerBase.cs:43-45`). Around those bases sit the small shared pieces: [`UserOwnershipRule`](#userownershiprule) (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UserOwnershipRule.cs:21`), the @@ -468,11 +507,11 @@ privileged-role test passed in already evaluated because each app owns its own r [`UserUseCaseLog`](#userusecaselog) (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UserUseCaseLog.cs:11`), a non-generic `[LoggerMessage]` holder so every subclass emits identical text while the log category still comes from -the subclass's own `ILogger` (`UserUseCaseLog.cs:13-23`); +the subclass's own `ILogger` (`UserUseCaseLog.cs:13-29`); [`SoftDeletedUserValidator`](#softdeleteduservalidatortuser) (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/SoftDeletedUserValidator.cs:19`), which answers [`ISoftDeletedUserValidator`](group-08-auth.md#isoftdeleteduservalidator) with one -query-filter-bypassing existence check; and +query-filter-bypassing existence check (`SoftDeletedUserValidator.cs:30-33`); and [`GetUserPreferencesQuery`](#getuserpreferencesquery) (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/GetPreferences/GetUserPreferencesQuery.cs:5`), the one request record that *was* byte-identical in both apps and so became shared. `[Rubric §16, @@ -485,21 +524,21 @@ workflow. Reading `MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs` top to bottom shows the whole chapter cooperating. The host binds and validates [`ApplicationSettings`](#applicationsettings) and also reads the section eagerly for the value it must pass around (`Program.cs:170-176`), calls -`AddApplication()` then `AddInfrastructure(builder.Configuration)` (`Program.cs:287-288`), opts into the -scheduler and the audit trail (`Program.cs:292`, `:296`), binds -[`ModulesSettings`](#modulessettings) and calls `AddAPI(modulesSettings)` (`Program.cs:299-308`), then +`AddApplication()` then `AddInfrastructure(builder.Configuration)` (`Program.cs:308-309`), opts into the +scheduler and the audit trail (`Program.cs:313`, `:317`), binds +[`ModulesSettings`](#modulessettings) and calls `AddAPI(modulesSettings)` (`Program.cs:320-329`), then constructs a [`ModuleLoader`](#moduleloader) with a Serilog-backed logger and calls `DiscoverAndRegister(services, configuration, applicationSettings, modulesSettings, environmentName)` -before registering the loader as a singleton (`Program.cs:313-319`). Because this is the *Conference* +before registering the loader as a singleton (`Program.cs:335-340`). Because this is the *Conference* service, only the Conference module is `Enabled` in its configuration; every other discovered module takes the `RegisterDisabledStubs` path. The host then patches the cross-process edges: it replaces the disabled Engagement stub with a real gRPC client (`AddEngagementBookmarkCountClient()`, -`Program.cs:329`) and calls `AddBrokerMessaging(builder.Configuration, ...)` (`Program.cs:346-347`) so +`Program.cs:350`) and calls `AddBrokerMessaging(builder.Configuration, ...)` (`Program.cs:371`) so [`MessageBusSettings`](#messagebussettings) `Provider` decides whether [`IMessageBus`](group-04-events-outbox.md#imessagebus) stays in-process or becomes the -MassTransit-backed broker. Only then comes `AddApplicationDecorators()` (`Program.cs:349`), last, so +MassTransit-backed broker. Only then comes `AddApplicationDecorators()` (`Program.cs:375`), last, so the decorators wrap the now-registered Conference handlers. Finally -`app.Services.InitializeDatabaseAsync(applicationSettings, moduleLoader)` (`Program.cs:370`) applies +`app.Services.InitializeDatabaseAsync(applicationSettings, moduleLoader)` (`Program.cs:396`) applies migrations and runs the module seeders the loader collected. The exact same module assemblies, dropped into a monolith host with every module `Enabled`, would Kahn-sort into one in-process graph with no gRPC clients, which is precisely the reversibility @@ -2293,22 +2332,25 @@ gRPC clients, which is precisely the reversibility [primer §4](00-primer.md#4-c-build-and-code-style-conventions) for the alias convention). No externals. -- **Concept introduced: the one request record in this family that could be shared.** Everything else - in the shared Users use cases keeps its command record app-side, because ADC and Store disagree on - the pipeline markers those records carry: both `DeleteUserCommand` records, for instance, implement - `ICacheInvalidating` with a `CachePrefix` built from *their own* `User` type - (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserCommand.cs:14-18`, - `MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/DeleteUser/DeleteUserCommand.cs:14-21`), - which is a value no shared record could produce. This query carries **no** markers at all: it is not - [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating), not `IQueryCacheable`, not - `ICommandWithRequest`. That absence is precisely what made it hoistable, and it is the rule worth - taking away: a type moves into the framework when it has no app-specific policy attached to it. +- **Concept introduced: the one request record in this family that could be shared.** Almost + everything else in the shared Users use cases keeps its command record app-side, because ADC and + Store disagree on the pipeline markers those records carry: both `DeleteUserCommand` records, for + instance, implement `ICacheInvalidating` with a `CachePrefix` built from *their own* `User` type + (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserCommand.cs:14`, + `:17`; + `MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/DeleteUser/DeleteUserCommand.cs:17`, + `:20`), which is a value no shared record could produce. This query carries **no** markers at all: + it is not [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating), not + `IQueryCacheable`, not + [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest). + That absence is precisely what made it hoistable, and it is the rule worth taking away: a type + moves into the framework when it has no app-specific policy attached to it. `[Rubric §9: API & Contract Design]` assesses whether the contract between layers is explicit and minimal. The query is the entire input contract for the read: one identifier, supplied by the controller from the authenticated principal rather than by the caller, so there is no way to ask for another account's preferences through this shape - (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:145-151`). + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:145-150`). `[Rubric §6: CQRS & Event-Driven]` assesses the separation of reads from writes. This is the read half of the culture/theme pair; its write counterpart is the app-side `ChangePreferencesCommand` @@ -2326,8 +2368,8 @@ gRPC clients, which is precisely the reversibility - **Why it's built this way**: the query and its [`UserPreferencesResponse`](group-08-auth.md#userpreferencesresponse) reply were byte-identical in both app Identity modules, so the handler base could be made generic in the `User` aggregate alone - rather than also in the query type - (`GetUserPreferencesHandlerBase.cs:10-14`). Preferences themselves are the persistence side of + rather than also in the query type (`GetUserPreferencesHandlerBase.cs:10-14`). Preferences + themselves are the persistence side of [ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html) (culture) and [ADR-028](https://ivanball.github.io/docs/adr/028-dark-theme-mode.html) (theme). @@ -2340,7 +2382,7 @@ gRPC clients, which is precisely the reversibility (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AuthController.cs:34`, `MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/AuthController.cs:32`), and both architecture suites use it as the *query* specimen when asserting decorator ordering - (`MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/DecoratorPipelineOrderTests.cs:27`, + (`MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/DecoratorPipelineOrderTests.cs:28`, `MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/DecoratorPipelineOrderTests.cs:27`). --- @@ -2349,9 +2391,10 @@ gRPC clients, which is precisely the reversibility > MMCA.Common.Application · `MMCA.Common.Application.Users.UseCases.ChangePassword` · `MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:24` · Level 8 · class (abstract) -- **What it is**: the shared password-rotation workflow. Load the account, verify the current password - against the stored hash, hash the new one, let the aggregate apply its own invariants, and persist - only if the aggregate accepted the change (`ChangePasswordHandlerBase.cs:24`, `:42-70`). +- **What it is**: the shared password-rotation workflow for an authenticated user. Load the account, + verify the current password against the stored hash, hash the new one, let the aggregate apply its + own invariants, and persist only if the aggregate accepted the change + (`ChangePasswordHandlerBase.cs:24`, `:42-70`). - **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`IPasswordHasher`](group-08-auth.md#ipasswordhasher) and an `ILogger` as primary-constructor @@ -2368,20 +2411,22 @@ gRPC clients, which is precisely the reversibility [`UserUseCaseLog`](#userusecaselog). Externals: `Microsoft.Extensions.Logging` (`:1`). - **Concept introduced: the generic template-method handler, and the two axes it is generic over.** - The prior state was two line-identical handlers, one per app, differing only in log text - (`ChangePasswordHandlerBase.cs:11-15`). Hoisting them needed two variation points, and each is a - separate generic parameter for a separate reason. `TUser` varies because each app owns its own + The two app Identity modules carried line-identical copies of this handler, differing only in log + text (`ChangePasswordHandlerBase.cs:11-15`). Hoisting them needed two variation points, and each is + a separate generic parameter for a separate reason. `TUser` varies because each app owns its own `User` aggregate and the framework must never reference either; the *capability* it needs is named - by an interface constraint instead, so the base can call `ChangePassword` without knowing the type. - `TCommand` varies because the command record carries app-specific **pipeline** policy: ADC's - `ChangePasswordCommand` is + by an interface constraint instead, so the base can call `ChangePassword` without knowing the type + (`IPasswordChangeableUser.cs:19`). `TCommand` varies because the command record carries + app-specific **pipeline** policy: ADC's `ChangePasswordCommand` is [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) - (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordCommand.cs:14-15`) - and Store's is not - (`MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordCommand.cs:12-13`), + (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordCommand.cs:15`, + `:18`) and Store's is not + (`MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordCommand.cs:13`), so a single shared record would have had to pick one behavior. The base reads the command only through [`IUserScopedCommand`](#iuserscopedcommandout-trequest), which is deliberately - *not* `ICommandWithRequest`: that marker also opts the command into automatic + *not* + [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest): + that marker also opts the command into automatic [`CommandRequestValidator`](group-06-validation.md#commandrequestvalidatortcommand-trequest) registration, which is a per-app decision (`IUserScopedCommand.cs:6-11`). @@ -2394,10 +2439,9 @@ gRPC clients, which is precisely the reversibility The current password is verified **before** anything is written (`:55`), the failure is an `Unauthorized` error with a stable code rather than a message that distinguishes "no such user" from "wrong password" at this layer (`:57-58`), and nothing in the handler ever logs the plaintext, the - hash or the salt: the success log carries only the user id - (`UserUseCaseLog.cs:13-14`). Hashing itself is delegated to - [`IPasswordHasher`](group-08-auth.md#ipasswordhasher), whose contract returns a hash and a fresh - salt as a tuple (`IPasswordHasher.cs:11`), the shape + hash or the salt: the success log carries only the user id (`UserUseCaseLog.cs:13-14`). Hashing + itself is delegated to [`IPasswordHasher`](group-08-auth.md#ipasswordhasher), whose contract returns + a hash and a fresh salt as a tuple (`IPasswordHasher.cs:11`), the shape [ADR-032](https://ivanball.github.io/docs/adr/032-password-hashing.html) fixes. `[Rubric §4: DDD]` assesses whether business rules live in the domain. The handler never mutates @@ -2409,7 +2453,7 @@ gRPC clients, which is precisely the reversibility - **Walkthrough**: two protected members and one method. - Primary constructor (`:24-27`): `unitOfWork`, `passwordHasher`, `logger`. The logger is typed as the non-generic `ILogger` so a subclass can pass its own `ILogger` and keep the log - *category* app-specific while the message text stays shared. + *category* app-specific while the message text stays shared (`UserUseCaseLog.cs:5-10`). - `UnitOfWork` (`:32`): a `protected` pass-through over the captured parameter, exposed so an app subclass can enlist further aggregates in the same unit of work. - `HandlerName` (`:39`): `protected virtual`, defaulting to `GetType().Name`. This is the detail @@ -2433,20 +2477,21 @@ gRPC clients, which is precisely the reversibility (`ChangePasswordHandlerBase.cs:16-21`). - **Where it's used**: subclassed once per app, each subclass empty apart from the constructor - forwarding (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordHandler.cs:17-23`, - `MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordHandler.cs:20`). - Both subclasses are picked up as scoped command handlers by - `ScanModuleApplicationServices()` (see - [`DependencyInjection`](#dependencyinjection)) and are then wrapped by the decorator pipeline. The - workflow is pinned directly by + forwarding + (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordHandler.cs:17`, + `:21`; + `MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordHandler.cs:16`, + `:20`). Both subclasses are picked up as scoped command handlers by + `ScanModuleApplicationServices()` (see [`DependencyInjection`](#dependencyinjection)) + and are then wrapped by the decorator pipeline. The workflow is pinned directly by `MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/ChangePasswordHandlerBaseTests.cs:15`, - which drives it through a test double subclass (`:123`). + which drives it through a test double subclass (`:122-123`). - **Caveats**: new-password strength is **not** checked here. Both apps' commands additionally - implement `ICommandWithRequest` (`ChangePasswordCommand.cs:15` in ADC, - `:13` in Store), which routes the payload through the Validating decorator before the handler runs, - so the base can assume a syntactically valid request. Neither app's command implements - `ITransactional`, so the single `SaveChangesAsync` at `:65` is the whole atomic unit. + implement `ICommandWithRequest` (`ChangePasswordCommand.cs:15` in ADC, `:13` + in Store), which routes the payload through the Validating decorator before the handler runs, so + the base can assume a syntactically valid request. Neither app's command implements `ITransactional`, + so the single `SaveChangesAsync` at `:65` is the whole atomic unit. --- @@ -2477,8 +2522,7 @@ gRPC clients, which is precisely the reversibility merge therefore happens in exactly one place, at the call into the aggregate: `command.Request.Culture ?? user.PreferredCulture` and the matching line for the theme (`:53-55`). The domain interface documents the same contract from its side, so an aggregate author knows that - `UpdatePreferences` always receives both values fully resolved - (`IUserPreferences.cs:18-25`). + `UpdatePreferences` always receives both values fully resolved (`IUserPreferences.cs:18-25`). `[Rubric §16: Maintainability]` assesses whether a rule has one home. Before the hoist this merge existed twice; a change to it (say, adding a third preference) had to be made in two repositories in @@ -2490,7 +2534,7 @@ gRPC clients, which is precisely the reversibility otherwise produce ([ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html) culture, [ADR-028](https://ivanball.github.io/docs/adr/028-dark-theme-mode.html) theme). -- **Walkthrough**: same shape as the password base, one method shorter. +- **Walkthrough**: same shape as the password base, one collaborator shorter. - Primary constructor (`:23-25`): `unitOfWork` and `logger`; no hasher, since nothing here is credential material. - `UnitOfWork` (`:30`) and `HandlerName` (`:37`): the same two protected members, with the same @@ -2507,24 +2551,24 @@ gRPC clients, which is precisely the reversibility [`ChangePasswordHandlerBase`](#changepasswordhandlerbasetuser-tcommand), and with the same asymmetry on the command record: ADC's `ChangePreferencesCommand` is [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) - (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesCommand.cs:14-15`) - while Store's is not + (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesCommand.cs:15`, + `:18`) while Store's is not (`MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesCommand.cs:11-12`), so the record stays app-side and only the payload record ([`ChangePreferencesRequest`](group-08-auth.md#changepreferencesrequest)) is shared (`ChangePreferencesHandlerBase.cs:16-20`). - **Where it's used**: subclassed by - `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandler.cs:17-22` - and - `MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandler.cs:19`, - both empty subclasses that exist only to fix the generic arguments and preserve the class name. - Invoked from + `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandler.cs:17`, + `:20` and + `MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandler.cs:16`, + `:19`, both empty subclasses that exist only to fix the generic arguments and preserve the class + name. Invoked from [`UserAccountAuthControllerBase`](group-12-api-hosting-mapping.md#useraccountauthcontrollerbasetchangepasswordcommand-tchangepreferencescommand), which builds the app's command through a factory hook and returns `204 No Content` on success (`UserAccountAuthControllerBase.cs:125-131`). Covered by `MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/ChangePreferencesHandlerBaseTests.cs:16` - through a test subclass (`:109`). + through a test subclass (`:108-109`). --- @@ -2535,7 +2579,7 @@ gRPC clients, which is precisely the reversibility - **What it is**: the shared account-erasure workflow: authorize owner-or-privileged-role, soft-delete the account, run the app's tail hook, irreversibly anonymize the personal data in place, save, then drain a post-commit queue (`DeleteUserHandlerBase.cs:38`, `:55-119`). It is the most extensible of - the five bases: one abstract member and one virtual hook. + the seven Users bases: one abstract member and one virtual hook. - **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) and an `ILogger` (`:38-40`); implements @@ -2583,14 +2627,12 @@ gRPC clients, which is precisely the reversibility `[Rubric §30: Compliance, Privacy & Data Governance]` assesses whether a data-subject erasure request is actually satisfiable. The sequence here is the mechanism behind both apps' published - erasure promise: soft-delete, then irreversible anonymization, in one transaction - (`:10-15`). + erasure promise: soft-delete, then irreversible anonymization, in one transaction (`:10-15`). `[Rubric §11: Security]` assesses authorization placement. The very first thing the method does, before it touches the repository, is the ownership check (`:62-71`), so an unauthorized caller cannot even confirm that an account id exists. The privileged-role test is passed in already - evaluated because each app owns its own role vocabulary - (`UserOwnershipRule.cs:15-19`). + evaluated because each app owns its own role vocabulary (`UserOwnershipRule.cs:15-19`). `[Rubric §1: SOLID]` assesses the template-method shape. The invariant order (authorize, load, delete, tail, anonymize, save, post-commit) is fixed by the base; only the two hooks vary. @@ -2602,8 +2644,8 @@ gRPC clients, which is precisely the reversibility `source`. - `HandleAsync(TCommand, CancellationToken)` (`:55-119`): - Authorization first (`:62-67`) through - [`UserOwnershipRule.CheckOwnership`](#userownershiprule), with the code - `"User.DeleteForbidden"` and a message the caller sees; a non-null return is the failure + [`UserOwnershipRule.CheckOwnership`](#userownershiprule) (`UserOwnershipRule.cs:38`), with the + code `"User.DeleteForbidden"` and a message the caller sees; a non-null return is the failure (`:68-71`). - Load through the write repository (`:73-74`); `Error.NotFound` when absent (`:77`). - `IErasableUser erasable = user; erasable.Delete()` (`:88-89`) with the dispatch rationale above; @@ -2624,41 +2666,156 @@ gRPC clients, which is precisely the reversibility cascaded aggregate's error mask the account's own `AlreadyDeleted` error. - **Why it's built this way**: the hook contract was derived from what the two apps actually needed, - and both uses are visible in their overrides. ADC captures the avatar blob name *before* - anonymization clears the URL, raises the cross-service `UserDeleted` domain event on the aggregate - so its outbox row is written by the very save that commits the erasure, and queues the - soft-deleted-user cache marker and the blob deletion as post-commit actions - (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs:46-88`). + and both uses are visible in their overrides. ADC's override + (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs:46-88`) + captures the avatar blob name *before* anonymization clears the URL (`DeleteUserHandler.cs:54`), + raises the cross-service `UserDeleted` domain event on the aggregate so its outbox row is written by + the very save that commits the erasure (`:62`), and queues the soft-deleted-user cache marker and + the blob deletion as post-commit actions (`:68-84`). Store instead cascades in the same unit of work, erasing the linked `Customer` that holds its name/email/address PII and returning the Customer's own failure untouched so nothing is persisted (`MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs:35-60`). One hook covers both because it can do work inline *and* schedule work for after the commit. - **Where it's used**: subclassed once per app - (`MMCA.ADC/.../DeleteUser/DeleteUserHandler.cs:28-34` with `HasDeletePrivilege` returning - `UserRole.IsOrganizer(...)` at `:42-43`; - `MMCA.Store/.../DeleteUser/DeleteUserHandler.cs:20-23` with `UserRole.IsAdmin(...)` at `:26-27`). - Covered directly by - `MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/DeleteUserHandlerBaseTests.cs:14`, - whose fixture user type is named `TestHidingDeleteUser` (`:196`) precisely so the hidden-`Delete()` - dispatch rule above is a regression test rather than a comment. + (`MMCA.ADC/.../DeleteUser/DeleteUserHandler.cs:28`, `:34`, with `HasDeletePrivilege` returning + `UserRole.IsOrganizer(...)` at `:42-43`; `MMCA.Store/.../DeleteUser/DeleteUserHandler.cs:20`, `:23`, + with `UserRole.IsAdmin(...)` at `:26-27`). Covered directly by + `MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/DeleteUserHandlerBaseTests.cs:14`, whose + fixture user type is `TestHidingDeleteUser` + (`MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/UserUseCaseTestDoubles.cs:96`, closed + over at `DeleteUserHandlerBaseTests.cs:195-196`) precisely so the hidden-`Delete()` dispatch rule + above is a regression test rather than a comment. - **Caveats**: post-commit actions run after the erasure has already succeeded, so each one owns its own failure handling; the base does not wrap them (`:111-114`, and see the documented expectation at `:135-139`). ADC's override wraps its cache-marker action in a try/catch for exactly that reason - (`MMCA.ADC/.../DeleteUser/DeleteUserHandler.cs:68-80`). Both apps' `DeleteUserCommand` records are + (`MMCA.ADC/.../DeleteUser/DeleteUserHandler.cs:70-79`). Both apps' `DeleteUserCommand` records are [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating), so the cache prefix they carry is invalidated by the decorator after the handler returns success, outside this class. --- +### ForgotPasswordHandlerBase + +> MMCA.Common.Application · `MMCA.Common.Application.Users.UseCases.ForgotPassword` · `MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:35` · Level 8 · class (abstract) + +- **What it is**: the shared start-a-password-reset workflow: parse the submitted address, resolve the + account behind it, mint a single-use token, and email it. Every outcome returns + `Result.Success()` (`ForgotPasswordHandlerBase.cs:35`, `:51-100`). + +- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), + [`IPasswordResetTokenService`](group-08-auth.md#ipasswordresettokenservice), + [`IEmailSender`](group-10-notifications.md#iemailsender), + `IOptions<`[`PasswordResetSettings`](group-08-auth.md#passwordresetsettings)`>` and an `ILogger` + (`:35-40`); implements + [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) + over [`Result`](group-01-result-error-handling.md#result) (`:40`). Constraints: `TUser` is only an + [`AuditableAggregateRootEntity`](group-02-domain-building-blocks.md#auditableaggregaterootentitytidentifiertype) + keyed by `UserIdentifierType` (`:41`) with no capability interface at all, because the workflow + reads nothing off the aggregate except `Id` (`:72`), and `TCommand` is an + [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) + carrying a [`ForgotPasswordRequest`](group-08-auth.md#forgotpasswordrequest) (`:42`). It also uses + the [`Email`](group-02-domain-building-blocks.md#email) value object (`:57`) and + [`UserUseCaseLog`](#userusecaselog). Externals: `Microsoft.Extensions.Options`, + `Microsoft.Extensions.Logging`, `System.Globalization` and `System.Net.WebUtility` (`:1-4`). + +- **Concept introduced: the success-always handler, and anti-enumeration as a return-type decision.** + Every other command base in this family reports its failures. This one cannot. A response that + differs between "we sent you a reset link" and "no such account" is an account-enumeration oracle: + anyone can walk an address list and learn which addresses are registered. So the four ways this + workflow can fail to send anything all return `Result.Success()` and differ only in a log line: a + malformed address (`:58-62`), an address with no account (`:66-70`), a request the token service + throttled (`:73-77`), and an email send that threw (`:90-96`). The class remarks state the rule + outright and name the one exception: only the request validator can produce a 400, and it inspects + the shape of the address alone (`:20-25`, + `MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ForgotPasswordRequestValidator.cs:6-16`). + + `[Rubric §11: Security]` assesses whether a public endpoint leaks facts about who holds an account. + The leak surface is wider than the HTTP response, and the code closes it in three places. The result + is uniform (`:62`, `:70`, `:77`, `:95`). The controller turns every one of them into the same + `202 Accepted` + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:82-93`). + And the rejection log deliberately carries a reason string but no address and no account id, so the + log does not become the oracle the response is not + (`UserUseCaseLog.cs:34-37`). Only the paths that already proved an account exists log a user id + (`UserUseCaseLog.cs:25-32`). + + `[Rubric §29: Resilience & Business Continuity]` assesses what happens when a dependency fails + mid-workflow. A send failure is caught, logged with the exception, and swallowed (`:90-96`); the + token has already been issued and is still valid, so the user can retry or use the link from a later + request. The catch filter excludes `OperationCanceledException` (`:90`) so a cancelled request is + not misreported as a delivered reset. + + `[Rubric §3: Clean Architecture]` assesses dependency direction. The workflow lives in the + Application layer and reaches the SMTP relay, the token cache and the database only through + interfaces; the one thing it genuinely cannot express in the framework, an address-to-account lookup + over an app-owned `User` aggregate, is the single abstract member (`:109`). + +- **Walkthrough**: two protected properties, the handler method, and four hooks. + - Primary constructor (`:35-40`): `unitOfWork`, `tokenService`, `emailSender`, `settings`, `logger`. + - `UnitOfWork` (`:45`): exposed so the lookup override can reach a read repository. Both apps use it + for exactly that. + - `Settings` (`:48`): `settings.Value`, unwrapped once so the body reads + `Settings.TokenLifetimeMinutes` rather than `settings.Value...`. + - `HandleAsync(TCommand, CancellationToken)` (`:51-100`): null-guard (`:55`); `Email.Create` on the + raw string, so a malformed address never reaches the lookup (`:57-62`); `FindUntrackedByEmailAsync` + (`:65`); `tokenService.IssueAsync(email.Value, user.Id, ...)` (`:72`), whose failure means the + per-email throttle fired; then the send, composed from the three `Compose*` hooks and sent as HTML + (`:83-88`); and finally the `PasswordResetRequested` log and success (`:98-99`). + - `FindUntrackedByEmailAsync(Email, CancellationToken)` (`:109`): `protected abstract`. The only + app-specific step, because each app's `User` stores the address differently. + - `ComposeSubject()` (`:113`): `protected virtual`, `"Reset your password"`. Override to localize or + rebrand. + - `ComposeBody(string? resetLink, string token)` (`:123-134`): `protected virtual`. It carries the + link **and** the raw token, because clients without deep linking (the MAUI head) need the token + typed into the reset page by hand (`:115-119`). Both the link and the token go through + `WebUtility.HtmlEncode` before interpolation into the HTML (`:128`, `:132`), and the expiry is + rendered with `CultureInfo.InvariantCulture` (`:125`). + - `ComposeResetLink(string email, string token)` (`:144-147`): `protected virtual`. Returns `null` + when `PasswordResetSettings.ResetUrl` is blank, so an unconfigured host degrades to a token-only + email rather than emailing a broken link; otherwise it appends `?email=...&token=...` with both + values `Uri.EscapeDataString`-encoded. + +- **Why it's built this way**: the reset token is deliberately not a database row. It lives in the + distributed cache, hashed at rest, with the per-email request throttle and the per-token attempt cap + enforced by the token service rather than by this handler + ([ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html); + `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:56`, `:71`, + `:79-80`). That split is why the handler's only reaction to a throttled request is a log line: it + never learns which limit fired. The command record stays app-side for the same reason it does in the + ChangePassword hoist, and the base reads it only through `ICommandWithRequest` + (`:27-31`). + +- **Where it's used**: subclassed once per app, each override implementing the address lookup as an + untracked `GetAllAsync` filtered on the `Email` value object + (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandler.cs:20`, + `:26`, `:29-37`; + `MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandler.cs:21`, + `:27`, `:34-46`). Reached over HTTP through + [`PasswordResetAuthControllerBase`](group-12-api-hosting-mapping.md#passwordresetauthcontrollerbasetforgotpasswordcommand-tresetpasswordcommand), + whose `POST forgot-password` action is `[AllowAnonymous]`, rate-limited by the auth-IP policy and + `[Idempotent]` (`PasswordResetAuthControllerBase.cs:75-93`). Pinned by six tests in + `MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/ForgotPasswordHandlerBaseTests.cs:20` + through a test subclass (`:190-195`), one per rejection path plus the unconfigured-`ResetUrl` + degradation (`:27`, `:42`, `:57`, `:79`, `:92`, `:108`). + +- **Caveats**: the anonymous command carries no user identifier, which is why it implements + [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) + rather than [`IUserScopedCommand`](#iuserscopedcommandout-trequest) + (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ForgotPassword/ForgotPasswordCommand.cs:12-13`). + Nothing in this workflow writes to the database, so it never calls `SaveChangesAsync`; the unit of + work is present only to hand the subclass a read repository. + +--- + ### GetUserPreferencesHandlerBase > MMCA.Common.Application · `MMCA.Common.Application.Users.UseCases.GetPreferences` · `MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandlerBase.cs:21` · Level 8 · class (abstract) -- **What it is**: the shared preference-read workflow, and the only query handler among the five - Users bases. Load the account through the read repository and project its two preference fields into - a [`UserPreferencesResponse`](group-08-auth.md#userpreferencesresponse) +- **What it is**: the shared preference-read workflow, and the only query handler among the Users + bases. Load the account through the read repository and project its two preference fields into a + [`UserPreferencesResponse`](group-08-auth.md#userpreferencesresponse) (`GetUserPreferencesHandlerBase.cs:21`, `:33-45`). - **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) as its single @@ -2671,7 +2828,7 @@ gRPC clients, which is precisely the reversibility [`Error`](group-01-result-error-handling.md#error). No logger, and no externals beyond the BCL. - **Concept introduced: one generic parameter is enough when nothing app-specific rides on the - request.** This is the contrast case for the three command bases above. Because + request.** This is the contrast case for the command bases above. Because [`GetUserPreferencesQuery`](#getuserpreferencesquery) carries no pipeline markers, it could be shared outright, so the base is generic in the `User` aggregate **only** (`:10-14`). Note also the weaker entity constraint: `AuditableBaseEntity` rather than @@ -2694,8 +2851,8 @@ gRPC clients, which is precisely the reversibility `[Rubric §15: Best Practices & Code Quality]` assesses consistency of error shape. The not-found path produces the identical `Error.NotFound.WithSource(HandlerName).WithTarget(typeof(TUser).Name)` - construction the three command bases use (`:42-43`), so every account use case in both apps reports - a missing user the same way. + construction the command bases use (`:42-43`), so every account use case in both apps reports a + missing user the same way. - **Walkthrough**: one protected member and one method. - Primary constructor (`:21`): `unitOfWork` only. @@ -2708,27 +2865,137 @@ gRPC clients, which is precisely the reversibility (`:44`). A ternary, not a branch chain: the whole method is a load and a projection. - **Why it's built this way**: the query, the response and the workflow were all identical across the - two apps, so this is the cleanest of the five hoists; the only decision it had to make was which - repository is correct for a read, and it resolved that in favor of the no-tracking one - (`:15-19`). Preferences are read at login to reapply a returning user's culture and theme across - devices ([ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html), + two apps, so this is the cleanest of the Users hoists; the only decision it had to make was which + repository is correct for a read, and it resolved that in favor of the no-tracking one (`:15-19`). + Preferences are read at login to reapply a returning user's culture and theme across devices + ([ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html), [ADR-028](https://ivanball.github.io/docs/adr/028-dark-theme-mode.html)), which is why the read path is worth keeping cheap. - **Where it's used**: subclassed as an empty, name-preserving class in both apps - (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandler.cs:13-16`, - `MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandler.cs:13`). + (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandler.cs:13-14`, + `MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandler.cs:12-13`). Consumed through the closed `IQueryHandler>` interface by [`UserAccountAuthControllerBase`](group-12-api-hosting-mapping.md#useraccountauthcontrollerbasetchangepasswordcommand-tchangepreferencescommand) - (`UserAccountAuthControllerBase.cs:45`, `:57`, `:149-151`). Pinned by + (`UserAccountAuthControllerBase.cs:45`, `:57`, `:149-150`). Pinned by `MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/GetUserPreferencesHandlerBaseTests.cs:14` - through a test subclass (`:88`), and by each app's own handler tests. + through a test subclass (`:87-88`), and by each app's own handler tests. - **Caveats**: the soft-delete global query filter applies to this read like any other, so a soft-deleted account resolves to `null` and returns `NotFound` rather than its stored preferences. That behavior comes from the persistence layer, not from anything in this class. +--- + +### ResetPasswordHandlerBase + +> MMCA.Common.Application · `MMCA.Common.Application.Users.UseCases.ResetPassword` · `MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:30` · Level 8 · class (abstract) + +- **What it is**: the shared complete-a-password-reset workflow: redeem the single-use token, hash the + new password, let the aggregate apply its invariants, persist, then clear the account's lockout so + the user can sign in immediately with the new credential (`ResetPasswordHandlerBase.cs:30`, + `:50-93`). + +- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), + [`IPasswordHasher`](group-08-auth.md#ipasswordhasher), + [`IPasswordResetTokenService`](group-08-auth.md#ipasswordresettokenservice), + [`ILoginProtectionService`](group-08-auth.md#iloginprotectionservice) and an `ILogger` (`:30-35`); + implements + [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) + over [`Result`](group-01-result-error-handling.md#result) (`:35`). Constraints: `TUser` is an + [`AuditableAggregateRootEntity`](group-02-domain-building-blocks.md#auditableaggregaterootentitytidentifiertype) + implementing [`IPasswordChangeableUser`](group-08-auth.md#ipasswordchangeableuser) (`:36`), the same + capability [`ChangePasswordHandlerBase`](#changepasswordhandlerbasetuser-tcommand) + requires, and `TCommand` is an + [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) + carrying a [`ResetPasswordRequest`](group-08-auth.md#resetpasswordrequest) (`:37`). Also uses + [`Error`](group-01-result-error-handling.md#error) and [`UserUseCaseLog`](#userusecaselog). + Externals: `Microsoft.Extensions.Logging` (`:1`). + +- **Concept introduced: burn the token before the write, not after.** The token is consumed at the top + of the method, before anything is saved (`:61-63`), and the comment explains the trade: leaving it + live until the write succeeds opens a replay window in which the same token redeems twice, while + burning it early costs a user whose aggregate then rejects the change one extra reset request + (`:58-60`). Choosing the second cost is the security-over-convenience call, and it is pinned by its + own test, `HandleAsync_ConsumesTheTokenBeforeSaving` + (`MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/ResetPasswordHandlerBaseTests.cs:111`). + + **Concept introduced: one error for every rejection.** Unlike the authenticated change-password + path, which can afford a specific `Auth.InvalidCurrentPassword`, this anonymous endpoint collapses + an unknown token, an expired token, a mismatched token, an attempt-capped token and a vanished + account into a single `Auth.InvalidResetToken` (`:95-99`, produced at `:67` and `:77`). The private + `InvalidToken()` factory exists so there is exactly one construction site and no way for a future + edit to make two branches distinguishable by accident. + + `[Rubric §11: Security]` assesses whether an anonymous endpoint leaks account state. Two mechanisms + do the work here: the uniform error above, and a rejection log that names only a reason string, + never an address or an account id (`:66`, `:75`, and `UserUseCaseLog.cs:34-37`). The + matching controller action turns every failure into the same `401` + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:99-118`). + + `[Rubric §29: Resilience & Business Continuity]` assesses whether a user can recover unaided. The + final `ResetFailedAttemptsAsync` call (`:89`) is the part that makes a reset actually usable: a user + who reset the password *because* the brute-force lockout locked them out would otherwise still be + locked out with a brand-new credential + ([ADR-029](https://ivanball.github.io/docs/adr/029-authentication-brute-force-protection.html); + `ILoginProtectionService.cs:33`). + + `[Rubric §4: DDD]` assesses whether the rules live in the domain. As with the change-password base, + the handler hashes and then calls `user.ChangePassword(newHash, newSalt)` (`:80`), returning the + aggregate's own result on failure without saving or clearing the lockout (`:81-84`). + +- **Walkthrough**: two protected members, the handler method, and one private helper. + - Primary constructor (`:30-35`): `unitOfWork`, `passwordHasher`, `tokenService`, `loginProtection`, + `logger`. Five collaborators, the widest of the Users bases, because a reset touches the token + store, the hasher, the database and the lockout store in one pass. + - `UnitOfWork` (`:40`) and `HandlerName` (`:47`): the same two protected members as the other bases, + with the same rationale (an app subclass named `ResetPasswordHandler` reports that name as the + error `source`, `:42-46`). + - `HandleAsync(TCommand, CancellationToken)` (`:50-93`): null-guard (`:54`); redeem the token via + `ValidateAndConsumeAsync(request.Email, request.Token, ...)` (`:61-63`) and fail generically on + rejection (`:64-68`); take the account id the token resolved to (`:70`) and load it through the + **write** repository (`:71-72`), failing with the same generic error if it is gone (`:73-77`); + hash the new password (`:79`) and call the aggregate (`:80`); `SaveChangesAsync` (`:86`); clear the + lockout (`:89`); log completion and return the aggregate's success result (`:91-92`). + - `InvalidToken()` (`:95-99`): the single `Error.Unauthorized("Auth.InvalidResetToken", ...)` + construction, stamped with `HandlerName`. + +- **Why it's built this way**: the reset half of the recovery vertical had to share the + change-password hoist's shape (generic in the aggregate, generic in the command, one virtual + `HandlerName`) so that both credential-write paths report errors identically and neither app has to + restate the workflow + ([ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html)). The one + ordering decision it owns, consuming before saving, is documented in the code rather than left to be + rediscovered (`:58-60`). New-password strength is not re-checked here because + [`ResetPasswordRequestValidator`](group-08-auth.md#resetpasswordrequestvalidator) includes the same + `StrongPasswordRules` set the registration and change-password requests use, so a reset cannot be + a way around the complexity policy + (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ResetPasswordRequestValidator.cs:7-23`). + +- **Where it's used**: subclassed once per app as an empty, name-preserving class + (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordHandler.cs:18`, + `:24-29`; + `MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordHandler.cs:20`, + `:26-31`). Reached over HTTP through the `POST reset-password` action on + [`PasswordResetAuthControllerBase`](group-12-api-hosting-mapping.md#passwordresetauthcontrollerbasetforgotpasswordcommand-tresetpasswordcommand) + (`PasswordResetAuthControllerBase.cs:99-118`), which answers `204 No Content` on success. Pinned by + five tests in + `MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/ResetPasswordHandlerBaseTests.cs:18` + through a test subclass (`:176-181`), covering both generic-error paths, the happy path, the + aggregate rejection and the consume-before-save ordering (`:28`, `:48`, `:64`, `:85`, `:111`). + +- **Caveats**: the two apps differ on cache policy exactly as they do for change-password: ADC's + `ResetPasswordCommand` is + [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) with a prefix built from its + own `User` type + (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordCommand.cs:15`, + `:18`) and Store's is not, which is the reason the command record stays app-side. The lockout clear + at `:89` runs after the save and is not part of the transaction: if it throws, the password has + already changed. Not determinable from source: whether any deployment configures a + `ResetFailedAttemptsAsync` implementation that can fail in a way the caller would notice, since the + contract returns a bare `Task` with no result (`ILoginProtectionService.cs:33`). + --- [⬅ gRPC & Inter-Service Contracts](group-13-grpc-contracts.md) • [Index](00-index.md) • [Common UI Framework (MudBlazor components, theme, base pages) ➡](group-15-common-ui-framework.md) diff --git a/docs-src/onboarding/group-15-common-ui-framework.md b/docs-src/onboarding/group-15-common-ui-framework.md index 6d42097..847810f 100644 --- a/docs-src/onboarding/group-15-common-ui-framework.md +++ b/docs-src/onboarding/group-15-common-ui-framework.md @@ -1,49 +1,68 @@ # 15. Common UI Framework (MudBlazor components, theme, base pages) **What this chapter covers.** `MMCA.Common.UI` is the Blazor presentation package, and it is one of the -two layers (with `Grpc`) allowed to reference **`Shared` only** (see -[primer §1](00-primer.md#1-the-big-picture)). It touches no Application, Domain, or Infrastructure type, -which is exactly what lets it compile into a Blazor WebAssembly bundle and into a .NET MAUI hybrid head. -What it ships is the set of reusable parts every consumer UI assembles pages from: a **server-paged -data-grid list-page base class**, the brand **MudBlazor theme**, a **typed HTTP service base** for -talking to the WebAPI, the **client-side authentication and token-refresh boundary**, **list-page state -preservation** across navigation, a **pluggable UI-module** contract, an end-to-end **localization** -pipeline, and a turnkey **notification inbox / push / live-channel** feature. A second, thinner package -`MMCA.Common.UI.Web` sits above it and holds the pieces that need an ASP.NET pipeline (server-side token -storage, the Blazor Content-Security-Policy provider). The per-app and per-module Razor pages in the -consumer apps (group 21) derive from and consume these primitives, and the same components render across -Blazor Server, WebAssembly, and MAUI with no per-platform reimplementation. +two layers (with `Grpc`) allowed to reference **`Shared` only**: its single `ProjectReference` is +`MMCA.Common.Shared` (`MMCA.Common/Source/Presentation/MMCA.Common.UI/MMCA.Common.UI.csproj:42`), and +every other dependency is a NuGet package (MudBlazor, Polly, SignalR client, Scrutor, QRCoder, +`System.IdentityModel.Tokens.Jwt`, `MMCA.Common.UI.csproj:19-37`). It touches no Application, Domain, or +Infrastructure type, which is exactly what lets it compile into a Blazor WebAssembly bundle and into a +.NET MAUI hybrid head (see [primer §1](00-primer.md#1-the-big-picture)). What it ships is the set of +reusable parts every consumer UI assembles pages from: a **server-paged data-grid list-page base class**, +the brand **MudBlazor theme**, a **typed HTTP service base** for talking to the WebAPI, the **client-side +authentication and token-refresh boundary**, **list-page state preservation** across navigation, a +**pluggable UI-module** contract, an end-to-end **localization** pipeline, and a turnkey **notification +inbox / push / live-channel** feature. A second, thinner package `MMCA.Common.UI.Web` sits above it and +holds the pieces that need an ASP.NET pipeline (server-side token storage, the Blazor +Content-Security-Policy provider). The per-app and per-module Razor pages in the consumer apps +([chapter 21](group-21-conference-ui.md)) derive from and consume these primitives, and the same +components render across Blazor Server, WebAssembly, and MAUI with no per-platform reimplementation. `[Rubric §18, UI Architecture & Component Design]` assesses component reuse, separation of presentation from data access, and whether there is a coherent composition model; nearly every type in this group exists so a consumer page is *composed* rather than hand-rolled. **The data-access boundary: `IEntityService` over one named HttpClient.** A page never touches `HttpClient`. It depends on -[IEntityService](#ientityservicetentitydto-tidentifiertype), the CRUD +[IEntityService](#ientityservicetentitydto-tidentifiertype) +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IEntityService.cs:12`), the CRUD contract, and gets its behavior from the abstract [EntityServiceBase](#entityservicebasetentitydto-tidentifiertype) (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/EntityServiceBase.cs:25`), which derives in turn from [AuthenticatedServiceBase](#authenticatedservicebase) (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/AuthenticatedServiceBase.cs:15`). That base -owns the two cross-cutting concerns of an outbound API call. First, a **Polly** retry policy: 3 retries -with exponential backoff (2s, 4s, 8s) **plus up to one second of random jitter** so a fleet of clients -does not re-converge on the same instant (`AuthenticatedServiceBase.cs:26-32`), and the retryable set is +owns the cross-cutting concerns of an outbound API call. First, a **Polly** retry policy: 3 retries with +exponential backoff (2s, 4s, 8s) **plus up to one second of random jitter** so a fleet of clients does +not re-converge on the same instant (`AuthenticatedServiceBase.cs:26-32`), and the retryable set is deliberate rather than "any 5xx", 501 and 505 are permanent verdicts and are excluded while 408 and 429 -are explicit invitations to come back (`AuthenticatedServiceBase.cs:89-98`). Second, a helper that +are explicit invitations to come back (`AuthenticatedServiceBase.cs:108-117`). Second, a helper that creates a `"APIClient"` `HttpClient` from `IHttpClientFactory` and stamps the JWT Bearer token onto it from [ITokenStorageService](#itokenstorageservice), swallowing the `InvalidOperationException` that JS -interop throws during SSR prerender (`AuthenticatedServiceBase.cs:57-76`). Retry and idempotency are -coupled on purpose: `NewIdempotencyKey()` (`AuthenticatedServiceBase.cs:49`) is generated **once per +interop throws during SSR prerender (`AuthenticatedServiceBase.cs:59-78`); a sibling +`CreateClientWithToken` builds a client around an explicitly supplied token so a request the API answered +`401` can be replayed with one acquired straight from [ITokenRefresher](#itokenrefresher) rather than +resending the token the server just rejected (`AuthenticatedServiceBase.cs:88-95`). Retry and idempotency +are coupled on purpose: `NewIdempotencyKey()` (`AuthenticatedServiceBase.cs:51`) is generated **once per logical write** and set as a default header on the single client that serves every attempt -(`EntityServiceBase.cs:193-200`), so a retried create dedupes on the server instead of producing a -duplicate row (the server half is [IdempotencyHeaders](group-08-auth.md#idempotencyheaders) and +(`EntityServiceBase.cs:135`, `EntityServiceBase.cs:193-200`), so a retried create dedupes on the server +instead of producing a duplicate row (the server half is +[IdempotencyHeaders](group-08-auth.md#idempotencyheaders) and [IdempotentAttribute](group-12-api-hosting-mapping.md#idempotentattribute), -[ADR-017](https://ivanball.github.io/docs/adr/017-request-idempotency.html)). Responses come back in the -same [PagedCollectionResult](group-01-result-error-handling.md#pagedcollectionresultt) / +[ADR-017](https://ivanball.github.io/docs/adr/017-request-idempotency.html)). Creates are the only verb +that carries a key: updates are full PUTs and deletes are naturally idempotent +(`EntityServiceBase.cs:128-130`). Responses come back in the same +[PagedCollectionResult](group-01-result-error-handling.md#pagedcollectionresultt) / [CollectionResult](group-01-result-error-handling.md#collectionresultt) envelopes the API returns, and `SendRequestAsync` runs [ServiceExceptionHelper](#serviceexceptionhelper) over a failed response *before* -`EnsureSuccessStatusCode` can throw a contextless exception (`EntityServiceBase.cs:209-213`), so a -backend `Result.Failure` reaches the page as a typed, displayable error. +`EnsureSuccessStatusCode` can throw a contextless exception (`EntityServiceBase.cs:210-211`): the helper +matches the ProblemDetails `title` the API emits ("Domain Exception", "Validation Exception", "Operation +failed") and rethrows it as a +[DomainInvariantViolationException](group-01-result-error-handling.md#domaininvariantviolationexception) +carrying the original message +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ServiceExceptionHelper.cs:49-56`), so a backend +`Result.Failure` reaches the page as a typed, displayable error. Many-to-many join endpoints, which have +POST and DELETE but no standalone reads, get their own thinner base, +[ChildEntityServiceBase](#childentityservicebase) +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ChildEntityServiceBase.cs:17`), whose +`DeleteByIdAsync` maps a 404 to `false` instead of an exception (`ChildEntityServiceBase.cs:45-48`). `[Rubric §3, Clean Architecture]` and `[Rubric §9, API & Contract Design]`: the UI binds to a DTO contract and an interface, never to server internals, and the wire envelope is uniform across every entity. `[Rubric §29, Resilience]` is the retry/jitter/idempotency triad. @@ -56,114 +75,167 @@ from [DataGridListPageBase](#datagridlistpagebasetdto) against `MudDataGrid`, `CancellationTokenSource` lifecycle, loading state, filter and sort extraction from MudBlazor's `GridState`, error surfacing through `ISnackbar`, a `LoadFailed` flag so a failed fetch renders an inline retry instead of a misleading "no records" empty state -(`DataGridListPageBase.cs:40`), **viewport-driven mobile versus desktop rendering** (it implements -`IBrowserViewportObserver` and flips `IsMobile` through +(`DataGridListPageBase.cs:40`, set at `:507` and `:565`), **viewport-driven mobile versus desktop +rendering** (it implements `IBrowserViewportObserver` and flips `IsMobile` through [BreakpointConstants](#breakpointconstants) at the 960 px sidebar-collapse boundary, -`DataGridListPageBase.cs:263-276`), a persisted dense-density toggle (`DataGridListPageBase.cs:76`), and -a careful `IAsyncDisposable`/`IDisposable` teardown. It also solves a Blazor render-mode problem: grid -data captured during SSR prerender is persisted through `PersistentComponentState` as a -[PersistedGridState](#persistedgridstate) record (`DataGridListPageBase.cs:805`), restored on -`OnInitialized` (`DataGridListPageBase.cs:136-140`) and re-registered for persisting with an **explicit** -`RenderMode.InteractiveAuto`, because a page that inherits its render mode from `` gives the -framework nothing to associate the callback with (`DataGridListPageBase.cs:149-159`). A -`PrerenderFetchTimeoutMs` of 5000 caps how long prerender may block on a cold backend before falling back -to an empty grid the first interactive fetch refills (`DataGridListPageBase.cs:82`). +`DataGridListPageBase.cs:44,267` and +`MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/BreakpointConstants.cs:16-17`), a persisted +dense-density toggle (`DataGridListPageBase.cs:76`), and a careful `IAsyncDisposable`/`IDisposable` +teardown. It also solves a Blazor render-mode problem: grid data captured during SSR prerender is +persisted through `PersistentComponentState` as a [PersistedGridState](#persistedgridstate) record +(`DataGridListPageBase.cs:805`), restored on `OnInitialized` (`DataGridListPageBase.cs:130-140`) and +re-registered for persisting with an **explicit** `RenderMode.InteractiveAuto`, because a page that +inherits its render mode from `` gives the framework nothing to associate the callback with +(`DataGridListPageBase.cs:146-159`). A `PrerenderFetchTimeoutMs` of 5000 caps how long prerender may +block on a cold backend before falling back to an empty grid the first interactive fetch refills +(`DataGridListPageBase.cs:82`, applied at `:528`). `[Rubric §23, Front-End Performance & Rendering]` assesses render efficiency and avoided round-trips; this persist-and-restore dance is that concern made concrete. The inline comments also record the MudDataGrid v9 pager quirks the class works around, notably that `RowsPerPage` cannot be restored by -parameter without resetting `CurrentPage` (`DataGridListPageBase.cs:59-67`, `:278-283`). +parameter without resetting `CurrentPage` (`DataGridListPageBase.cs:59-67`, `:359-411`). **State preservation across navigation.** Paging, sort, filters, and density live in the URL query -string as the source of truth, so deep links and browser back/forward replay correctly; the noisier -scroll offset lives in [ListPageStateService](#listpagestateservice) +string as the source of truth, encoded and decoded by +[ListPageQueryStateService](#listpagequerystateservice) under deliberately short reserved keys (`p`, +`ps`, `mp`, `s`, `sd`, `d`, `q`, `f:`) with defaults omitted so a pristine list page has a clean +URL (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ListPageQueryStateService.cs:15-28`), so +deep links and browser back/forward replay correctly. The noisier scroll offset lives in +[ListPageStateService](#listpagestateservice) (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ListPageStateService.cs:58`), a **per-circuit -scoped** service whose synchronous dictionary is the fast path and whose `HydrateFromSessionAsync` / -`PersistToSessionAsync` mirror entries through `sessionStorage` via a `nav-interop.js` module -(`ListPageStateService.cs:98-162`) so state survives circuit teardown, `forceLoad` navigation, and the -SSR to WASM transition. Every JS path there is defensively caught (prerender, disconnected circuit, -Safari private mode) so storage can never break the page. The immutable -[ListPageState](#listpagestate) record (`ListPageStateService.cs:9`) carries page, page size, mobile -page, scroll, sort, density, and a page-specific filter dictionary, and is updated with `with` -expressions. [ListPageQueryStateService](#listpagequerystateservice) owns the URL half and -[NavigationHistoryService](#navigationhistoryservice) tracks an in-app history stack for back -affordances. `[Rubric §19, State Management & Data Flow]` assesses a deliberate, scoped state model -rather than ambient globals: these are registered `Scoped`, so each circuit gets its own instance +scoped** service whose synchronous dictionary is the fast path and whose `HydrateFromSessionAsync` +(`ListPageStateService.cs:98`) / `PersistToSessionAsync` (`ListPageStateService.cs:133`) mirror entries +through `sessionStorage` via a `nav-interop.js` module (`ListPageStateService.cs:60`) so state survives +circuit teardown, `forceLoad` navigation, and the SSR to WASM transition. Every JS path there is +defensively caught (prerender, disconnected circuit, Safari private mode) so storage can never break the +page. The immutable [ListPageState](#listpagestate) record (`ListPageStateService.cs:9`) carries page, +page size, mobile page, scroll, sort, density, and a page-specific filter dictionary, and is updated with +`with` expressions. [NavigationHistoryService](#navigationhistoryservice) +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/NavigationHistoryService.cs:12`) +bridges Blazor's `NavigationManager` to the browser history API so a detail page can perform a real +`history.back()` when a previous entry exists and fall back to a fixed path otherwise. +`[Rubric §19, State Management & Data Flow]` assesses a deliberate, scoped state model rather than +ambient globals: these are registered `Scoped`, so each circuit gets its own instance (`MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:86-88`). `[Rubric §25, Navigation & Information Architecture]` covers the route catalogue -([RoutePaths](#routepaths), [NavItem](#navitem), [NavSection](#navsection)) and the open-redirect guard +([RoutePaths](#routepaths), [NavItem](#navitem) with its role, claim, section and group facets, and the +[NavSection](#navsection) enum whose declaration order is the sidebar order, +`MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/NavSection.cs:7-17`) and the open-redirect guard [ReturnUrlProtector](#returnurlprotector), which accepts only same-origin relative paths beginning with -a single forward slash and replaces anything else with a fallback -(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/ReturnUrlProtector.cs:18-30`). +a single forward slash and rejects protocol-relative forms, backslashes, control characters, and +anything that does not parse as a relative URI, replacing each with a fallback +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/ReturnUrlProtector.cs:18-59`). **Authentication and the host-polymorphic token refresh.** Client-side auth is contracted by -[IAuthUIService](#iauthuiservice) and implemented by [AuthUIService](#authuiservice) +[IAuthUIService](#iauthuiservice) +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/IAuthUIService.cs:9`) and implemented by +[AuthUIService](#authuiservice) (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthUIService.cs:15`), which calls the WebAPI `auth/*` endpoints, persists tokens through [ITokenStorageService](#itokenstorageservice), pushes auth-state changes through [JwtAuthenticationStateProvider](#jwtauthenticationstateprovider) so `AuthorizeView` reacts immediately, and coordinates push-registration through the device-capability -contract `IPushRegistrationService` (`AuthUIService.cs:15-20`). The interesting part is the refresh: -one [ITokenRefresher](#itokenrefresher) abstraction +contract [IPushRegistrationService](group-26-device-capability-layer.md#ipushregistrationservice) +(`AuthUIService.cs:16-20`). Alongside login, register, OAuth code exchange, logout, refresh and change +password, it carries the self-service reset pair: `RequestPasswordResetAsync` POSTs to the anonymous +`auth/forgot-password` endpoint, which answers 202 for every well-formed address, so a `true` result +means "accepted" and never "an account exists" (`IAuthUIService.cs:36-41`, `AuthUIService.cs:285-305`), +and `ResetPasswordAsync` completes the reset against `auth/reset-password`, returning `false` with the +server's generic message in `LastError` for an invalid, expired, or already-consumed token +(`IAuthUIService.cs:43-48`, `AuthUIService.cs:307-328`, +[ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html)). The refresh is the +interesting part: one [ITokenRefresher](#itokenrefresher) abstraction (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/ITokenRefresher.cs:13`) has two implementations picked per host, [SameOriginProxyTokenRefresher](#sameoriginproxytokenrefresher) for the browser (the refresh token lives in an HttpOnly cookie and rotation happens server-side behind a same-origin `/auth/session/token` proxy, so JS never sees it) and [DirectApiTokenRefresher](#directapitokenrefresher) for MAUI (the refresh token sits in OS SecureStorage -and is exchanged directly against `auth/refresh`), documented at `ITokenRefresher.cs:3-11`. The +and is exchanged directly against `auth/refresh`), documented at `ITokenRefresher.cs:3-11`. Storage is +host-polymorphic in the same way: [WasmTokenStorageService](#wasmtokenstorageservice) holds the access +token in memory only and single-flights its re-acquisition behind a lock +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/WasmTokenStorageService.cs:11-30`), while +[ServerTokenStorageService](#servertokenstorageservice) reads the HttpOnly cookie whenever a live +`HttpContext` exists (SSR prerender) and switches to the in-memory token on the interactive circuit +(`MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Services/ServerTokenStorageService.cs:17,30-40`, +[ADR-022](https://ivanball.github.io/docs/adr/022-browser-session-cookie-auth.html)). The [ISessionCookieSync](#isessioncookiesync) / [JsFetchSessionCookieSync](#jsfetchsessioncookiesync) pair -mirrors the in-memory access token into the HttpOnly cookie the SSR prerender reads, and on a Blazor -Server head [ServerTokenStorageService](#servertokenstorageservice) reads that cookie during prerender -and an in-memory token on the interactive circuit -(`MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs:18-30`, -[ADR-022](https://ivanball.github.io/docs/adr/022-browser-session-cookie-auth.html)). -`[Rubric §26, Front-End Security]` assesses token handling, XSS exposure, and secret storage, and this -group answers it in three places: keeping the refresh token out of JS-reachable storage, -[BlazorCspPolicyProvider](#blazorcsppolicyprovider), which pins `connect-src` to `'self'` plus the -configured API/Gateway origin and degrades to a permissive `Report-Only` policy rather than hard-breaking -on a misconfiguration -(`MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Security/BlazorCspPolicyProvider.cs:21-40`), and +mirrors the in-memory access token into that cookie by firing the fetch **from the browser**, so the +`Set-Cookie` lands in the user's own jar under both render modes and falls silent when interop is +unavailable +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/JsFetchSessionCookieSync.cs:11-26`). Both +storage implementations and both preference services agree on one 30-second expiry skew read through +[JwtTokenInfo](#jwttokeninfo)`.IsFresh` +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/JwtTokenInfo.cs:17-36`), which parses the +token client-side without validating its signature because the API validates every request. Every +outbound call also passes [AuthDelegatingHandler](#authdelegatinghandler), which attaches the stored +bearer token to requests that do not go through `CreateAuthenticatedClientAsync` +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthDelegatingHandler.cs:9-24`). The +cross-service JWKS validation these tokens flow into is +[ADR-004](https://ivanball.github.io/docs/adr/004-authentication-dual-fetch.html). + +**Front-end security beyond tokens.** `[Rubric §26, Front-End Security]` assesses token handling, XSS +exposure, and secret storage, and this group answers it in four places: keeping the refresh token out of +JS-reachable storage (above); [BlazorCspPolicyProvider](#blazorcsppolicyprovider), which pins +`connect-src` to `'self'` plus the configured API/Gateway origin (plus its `wss` form for the SignalR +hub) and degrades to a permissive `Report-Only` policy rather than hard-breaking on a misconfiguration +(`MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Security/BlazorCspPolicyProvider.cs:21,38-56`), +feeding the shared +[SecurityHeadersMiddleware](group-16-aspire-orchestration.md#securityheadersmiddleware) through +[ICspPolicyProvider](group-16-aspire-orchestration.md#icsppolicyprovider); [WebApplicationExtensions](#webapplicationextensions)`.UseAuthenticatedNoStore`, which emits `Cache-Control: no-store` on authenticated HTML so a logged-out user pressing Back never sees the previous user's page out of the bfcache while anonymous pages stay bfcache-eligible -(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Extensions/WebApplicationExtensions.cs:24-44`). -The cross-service JWKS validation these tokens flow into is -[ADR-004](https://ivanball.github.io/docs/adr/004-authentication-dual-fetch.html). +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Extensions/WebApplicationExtensions.cs:24-44`); and the +`returnUrl` sanitizer already covered. The shared auth forms sit on the same fence: +[LoginModel](#loginmodel), [RegisterModel](#registermodel), [ForgotPasswordModel](#forgotpasswordmodel) +and [ResetPasswordModel](#resetpasswordmodel) are plain data-annotation `EditForm` models, with +[PasswordComplexityAttribute](#passwordcomplexityattribute) mirroring the server's rule (at least 8 +characters with upper, lower, digit, and a non-alphanumeric character) so the form gives the verdict the +API would, and deferring empty input to `[Required]` so a blank field shows one message rather than two +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/PasswordComplexityAttribute.cs:12,20-30`). +That client-side parity is the point of `[Rubric §24, Forms, Validation & UX Safety]`: the client +predicts, the server decides. **Design system and theming.** Visual consistency is centralized in one static [MMCATheme](#mmcatheme) `MudTheme` instance (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Theme/MMCATheme.cs:11`) holding a light palette -(`:13-47`), a full dark palette (`:48-84`), an Inter-first typography scale (`:85-137`), and a 6 px -default border radius (`:138-141`). It is applied through the shared `MmcaThemeProviders` component, -which renders the four Mud providers every root layout needs exactly once -(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/MmcaThemeProviders.razor:11-14`). The +(`:13-47`), a full dark palette (`:48-84`), an Inter-first typography scale (`:85-163`), and a 6 px +default border radius (`:164-167`). It is applied through the shared `MmcaThemeProviders` component, +which renders the four Mud providers every root layout needs exactly once and takes the theme as a +parameter defaulting to `MMCATheme.Instance`, so an app with its own brand passes a derived `MudTheme` +instead of duplicating the provider block +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/MmcaThemeProviders.razor:11-14,22`). The palette itself comes from a single C# source of truth, [BrandColors](#brandcolors) (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Theme/BrandColors.cs:10`), whose doc comment states the duplication contract plainly: the CSS custom properties in `wwwroot/app.css` must mirror these constants because C# cannot read CSS at build time, and `BrandColorTokenTests` asserts the two stay in sync -(`BrandColors.cs:3-9`). Color choices carry explicit WCAG reasoning: Secondary was moved to Teal 700 -`#00796B` for about 5.3:1 on light surfaces because the Teal 600 it replaced sat at about 4.0:1, under -the AA 4.5:1 floor (`BrandColors.cs:21-26`), and `WarningContrastText` is overridden to `#212121` -because MudBlazor's default white on `#F57F17` measures about 2.65:1 and failed an axe scan on a -"Pending Payment" chip (`MMCATheme.cs:29-33`). -`[Rubric §20, Design System, Theming & Consistency]` is the home category (one token source, dark mode, -consistent typography) and `[Rubric §21, Accessibility]` is woven into the palette itself. +(`BrandColors.cs:3-9`). Color choices carry explicit WCAG reasoning: Secondary is Teal 700 `#00796B` for +about 5.3:1 on light surfaces because the Teal 600 it replaced sat at about 4.0:1, under the AA 4.5:1 +floor (`BrandColors.cs:21-26`), and `WarningContrastText` is overridden to `#212121` because MudBlazor's +default white on `#F57F17` measures about 2.65:1 and failed an axe scan on a "Pending Payment" chip +(`MMCATheme.cs:29-33`). `[Rubric §20, Design System, Theming & Consistency]` is the home category (one +token source, dark mode, consistent typography) and `[Rubric §21, Accessibility]` is woven into the +palette itself and into the chrome, down to the skip-to-content link the shared layout renders first +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Layout/MainLayout.razor:17`). `[Rubric §22, Responsive & Cross-Browser]` is named by [BreakpointConstants](#breakpointconstants) and exercised by [MobileInfiniteScrollList](#mobileinfinitescrolllisttitem), the mobile card list -whose IntersectionObserver sentinel, rendered-item cap, and generation-guarded supersession of in-flight -fetches keep a long list bounded -(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/MobileInfiniteScrollList.razor.cs:17`). +whose IntersectionObserver sentinel, 500-item rendered cap, and generation-guarded supersession of +in-flight fetches keep a long list bounded +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/MobileInfiniteScrollList.razor.cs:17,38-43`). **Dark mode is a service, not a flag.** [ThemeService](#themeservice) (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ThemeService.cs:16`, registered `Scoped` at `DependencyInjection.cs:91`) owns the preference: `InitializeAsync` reads the stored value through a `theme.js` module and falls back to the OS `prefers-color-scheme` only when nothing is stored (`ThemeService.cs:34-49`), `SetDarkModeAsync` persists through the same module and raises `OnChange` -(`ThemeService.cs:53-59`), and the JS module handle is held by [LazyJsModule](#lazyjsmodule) so the -import happens once and disposes cleanly. `MmcaThemeProviders` subscribes to `OnChange` and re-renders -defensively, guarding the race where the event fires between disposal and render dispatch -(`MmcaThemeProviders.razor:33-61`). **Honest caveat:** unlike locale, the no-flash SSR bootstrap is not -wired for theme. `InitializeAsync` is called from `OnAfterRenderAsync(firstRender)` because JS interop is -unavailable during prerender (`MmcaThemeProviders.razor:22-31`), so the bound mode is corrected just -after hydration and a brief wrong-theme first paint is possible +(`ThemeService.cs:53-59`), and the JS module handle is held by [LazyJsModule](#lazyjsmodule) +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/LazyJsModule.cs:20`), a single-flight importer +that caches the in-flight import under a lock so two concurrent callers cannot leak a second module +reference, and that drops a failed task so an import attempted during prerender does not poison the +module for the rest of the circuit (`LazyJsModule.cs:5-19`). `MmcaThemeProviders` subscribes to +`OnChange` and re-renders defensively, guarding the race where the event fires between disposal and +render dispatch (`MmcaThemeProviders.razor:40-68`). **Honest caveat:** unlike locale, the no-flash SSR +bootstrap is not wired for theme. `InitializeAsync` is called from `OnAfterRenderAsync(firstRender)` +because JS interop is unavailable during prerender (`MmcaThemeProviders.razor:29-38`), so the bound mode +is corrected just after hydration and a brief wrong-theme first paint is possible ([ADR-028](https://ivanball.github.io/docs/adr/028-dark-theme-mode.html)). **Internationalization: one culture decision, carried everywhere.** The framework serves `en-US` and @@ -188,36 +260,47 @@ localized. View strings are externalized to co-located `.resx` resolved by `IStr [SharedResource](#sharedresource) for cross-cutting chrome (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Resources/SharedResource.cs:9`, injected by the shared layout at `MMCA.Common/Source/Presentation/MMCA.Common.UI/Layout/MainLayout.razor:12`) and -[MudTranslations](#mudtranslations) for MudBlazor's own component text (pager, filter menus, pickers), -served through [ResxMudLocalizer](#resxmudlocalizer), which `AddUIShared` `TryAdd`s because -`AddMudServices` registers no `MudLocalizer` of its own (`DependencyInjection.cs:51-55`). Applying a +[MudTranslations](#mudtranslations) for MudBlazor's own component text (pager, filter menus, pickers, +`MMCA.Common/Source/Presentation/MMCA.Common.UI/Resources/MudTranslations.cs:10`), served through +[ResxMudLocalizer](#resxmudlocalizer), which `AddUIShared` `TryAdd`s because `AddMudServices` registers +no `MudLocalizer` of its own (`DependencyInjection.cs:51-55`) and whose values degrade to MudBlazor's +built-in English when a key is missing +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Globalization/ResxMudLocalizer.cs:7-17`). Applying a switch is host-specific and sits behind [ICultureApplier](#icultureapplier): the web default [EndpointCultureApplier](#endpointcultureapplier) force-loads the server `/culture/set` endpoint so the server re-renders SSR under the new cookie and the WASM runtime re-reads it on startup (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/EndpointCultureApplier.cs:18-32`), while a MAUI hybrid head, having no ASP.NET pipeline, replaces it after `AddUIShared` with an in-process applier -([MauiCultureApplier](group-26-device-capability-layer.md#mauicultureapplier), group 26). The +([MauiCultureApplier](group-26-device-capability-layer.md#mauicultureapplier), chapter 26). The development-only pseudo locale is the group's own i18n test harness: [PseudoStringLocalizerFactory](#pseudostringlocalizerfactory) decorates `IStringLocalizerFactory` -unconditionally (`DependencyInjection.cs:49`) and [PseudoLocalizer](#pseudolocalizer) accents every -letter, pads for the roughly 40% expansion real translations need, and wraps the result in a bracket -sentinel while leaving `{0}` placeholders byte-identical +unconditionally (`DependencyInjection.cs:49`, +`MMCA.Common/Source/Presentation/MMCA.Common.UI/Globalization/PseudoStringLocalizerFactory.cs:11-19`) so +every `IStringLocalizer` in the host is wrapped in a [PseudoStringLocalizer](#pseudostringlocalizer) at +once, and [PseudoLocalizer](#pseudolocalizer) accents every letter, pads for the roughly 40% expansion +real translations need, and wraps the result in a bracket sentinel while leaving `{0}` placeholders +byte-identical (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Globalization/PseudoLocalizer.cs:20-30`), which makes hard-coded strings, fixed-width layouts, and concatenated fragments all visible in one pass -(`PseudoLocalizer.cs:12-19`). `[Rubric §27, Internationalization]` is the home category here, and adding -a locale is a `.es.resx` sibling plus one allowlist entry, not new infrastructure. +(`PseudoLocalizer.cs:12-19`). Even the snackbar text is localized: [ErrorMessages](#errormessages) keeps +its static call sites but resolves each message from `SharedResource` once the root layout hands it a +localizer, falling back to the English format string until then +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/ErrorMessages.cs:17,26`). +`[Rubric §27, Internationalization]` is the home category here, and adding a locale is a `.es.resx` +sibling plus one allowlist entry, not new infrastructure. **Per-user preference persistence.** A signed-in user's culture and theme follow them across devices via the Identity profile. [IUserPreferenceWriter](#iuserpreferencewriter) / [ApiUserPreferenceWriter](#apiuserpreferencewriter) PUT to `auth/preferences` over the shared `"APIClient"` -(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ApiUserPreferenceWriter.cs:63-66`) using the +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ApiUserPreferenceWriter.cs:62-66`) using the private [UserPreferencesRequest](#userpreferencesrequest) record (`ApiUserPreferenceWriter.cs:29`), and [IUserPreferenceReader](#iuserpreferencereader) / [ApiUserPreferenceReader](#apiuserpreferencereader) GET the same endpoint at login and return the immutable [UserPreferences](#userpreferences) record, whose null fields mean "leave unchanged" -(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ApiUserPreferenceReader.cs:24-52`). The write -is strictly best-effort: the cookie is the device-local runtime channel and a failed persist never breaks +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/UserPreferences.cs:9`, +`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ApiUserPreferenceReader.cs:24-52`). The write is +strictly best-effort: the cookie is the device-local runtime channel and a failed persist never breaks the in-page switch. Best-effort has a cost, though, and both sides guard it, first by refusing to send when the token is missing, unreadable, or within 30 seconds of expiry via [JwtTokenInfo](#jwttokeninfo)`.IsFresh` (`ApiUserPreferenceWriter.cs:27,47`, @@ -228,7 +311,7 @@ detail as much as a `[Rubric §19, State Management]` one: at low traffic, one 4 enough on its own to trip a failed-request alert rule. **Pluggable UI modules.** The module system that organizes the back end -([IModule](group-14-module-system-composition.md#imodule), group 14) has a front-end counterpart in +([IModule](group-14-module-system-composition.md#imodule), chapter 14) has a front-end counterpart in [IUIModule](#iuimodule) (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IUIModule.cs:10`). A module descriptor exposes its navigation entries as [NavItem](#navitem) values, the `Assembly` holding its Razor pages so @@ -237,9 +320,14 @@ component types to render in the app bar and at the root layout (`IUIModule.cs:1 prologue is shared too: `AddUIModule()` runs one Scrutor scan that picks up every `IEntityService<,>` implementation in the module's assembly as scoped, then registers the descriptor as a singleton (`DependencyInjection.cs:152-162`), so a module's own `Add{Module}UI()` no longer carries its -own copy of that scan and can still register services that must win afterwards. Adding a feature module -therefore wires its pages, its services, and its menu entries into the shell with no edit to the shell. -`[Rubric §18, UI Architecture]` and `[Rubric §1, SOLID]` (open/closed). +own copy of that scan and can still register services that must win afterwards. +[UIModuleConfiguration](#uimoduleconfiguration) lets a host switch a module off through +`Modules:{name}:Enabled`, defaulting to enabled when the section is absent +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/UIModuleConfiguration.cs:19-22`), and +[IHomePageContent](#ihomepagecontent) is the per-app landing-page hook behind the shared `/` route +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IHomePageContent.cs:8`). Adding a +feature module therefore wires its pages, its services, and its menu entries into the shell with no edit +to the shell. `[Rubric §18, UI Architecture]` and `[Rubric §1, SOLID]` (open/closed). **A complete vertical slice shipped inside the framework: notifications.** Unlike the rest of the package, which is base classes consumers extend, the `Notifications` area is a finished feature an app @@ -247,32 +335,37 @@ switches on with one call. [NotificationUIModule](#notificationuimodule) (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/NotificationUIModule.cs:14`) contributes a user-facing inbox nav entry plus an Organizer-gated push-notification entry (`NotificationUIModule.cs:16-20`), the app-bar [NotificationBell](#notificationbell) -(`NotificationUIModule.cs:22`), and a root-layout listener component (`NotificationUIModule.cs:24`); [NotificationInbox](#notificationinbox), [NotificationList](#notificationlist), and -[NotificationSend](#notificationsend) render it; -[NotificationInboxService](#notificationinboxservice) and -[PushNotificationService](#pushnotificationservice) (behind +(`NotificationUIModule.cs:22`), and a root-layout listener component (`NotificationUIModule.cs:24`); +[NotificationInbox](#notificationinbox), [NotificationList](#notificationlist), and +[NotificationSend](#notificationsend) render it; [NotificationInboxService](#notificationinboxservice) +and [PushNotificationService](#pushnotificationservice) (behind [INotificationInboxUIService](#inotificationinboxuiservice) and [IPushNotificationUIService](#ipushnotificationuiservice)) call the API; and [NotificationHubService](#notificationhubservice) (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationHubService.cs:26`) -holds the **SignalR** connection to the API's [NotificationHub](group-10-notifications.md#notificationhub), -retrying an initial connect up to 3 times with doubling backoff and discarding a connection that never -started so a later join is not blocked forever (`NotificationHubService.cs:145-179`). The same connection -carries ephemeral **live channel** events: components join through `JoinChannelAsync` -(`NotificationHubService.cs:192`), membership is reference-counted per key by -[ChannelReferenceCounter](#channelreferencecounter) so one subscriber leaving does not cut the channel -off for the others, handlers are multicast through disposable [ChannelSubscription](#channelsubscription) handles -(`NotificationHubService.cs:412-419`), -and every held channel is re-joined on `Reconnected` because SignalR group membership does not survive a -new connection (`NotificationHubService.cs:16-24`, `:141-143`). Which notifications a user sees can be -narrowed by [INotificationScopeProvider](#inotificationscopeprovider), an app-supplied scope key such as -`"event:2"` that both HTTP services consume so a send and the reads that follow agree, defaulting to the -unscoped [NullNotificationScopeProvider](#nullnotificationscopeprovider) and contractually forbidden from +holds the **SignalR** connection to the API's +[NotificationHub](group-10-notifications.md#notificationhub), retrying an initial connect up to 3 times +with doubling backoff and discarding a connection that never started so a later join is not blocked +forever (`NotificationHubService.cs:28,145-180`). The same connection carries ephemeral **live channel** +events: components join through `JoinChannelAsync` (`NotificationHubService.cs:192`), membership is +reference-counted per key by [ChannelReferenceCounter](#channelreferencecounter) so one subscriber +leaving does not cut the channel off for the others +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/ChannelReferenceCounter.cs:16`), +handlers are multicast through disposable [ChannelSubscription](#channelsubscription) handles +(`NotificationHubService.cs:412`), and every held channel is re-joined on `Reconnected` because SignalR +group membership does not survive a new connection (`NotificationHubService.cs:16-24`, `:143`). Which +notifications a user sees can be narrowed by [INotificationScopeProvider](#inotificationscopeprovider), +an app-supplied scope key such as `"event:2"` that both HTTP services consume so a send and the reads +that follow agree, defaulting to the unscoped +[NullNotificationScopeProvider](#nullnotificationscopeprovider) and contractually forbidden from throwing, since a scope is a view filter and not a security boundary (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/INotificationScopeProvider.cs:9-21`). -Shared unread state lives in [NotificationState](#notificationstate), and the whole feature is wired by -its own [DependencyInjection](#dependencyinjection)`.AddNotificationUI()` in the `Notifications` -namespace +Shared unread state lives in [NotificationState](#notificationstate), which also arbitrates a single +active-poller slot by owner reference rather than a counter, so a teardown that never unregisters cannot +strand the slot for the life of the circuit +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationState.cs:8,12-19`), +and the whole feature is wired by its own +[DependencyInjection](#dependencyinjection)`.AddNotificationUI()` in the `Notifications` namespace (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/DependencyInjection.cs:20-42`), kept separate so an app that does not want real-time notifications never pays for the SignalR plumbing. @@ -281,44 +374,57 @@ separate so an app that does not want real-time notifications never pays for the on [DependencyInjection](#dependencyinjection) (`MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:29-112`). In order it binds and **validates on start** [ApiSettings](#apisettings), so a missing endpoint fails the host rather than the -first request (`DependencyInjection.cs:32-35`); binds [LayoutSettings](#layoutsettings) *without* -validation, deliberately optional so a host with no `Layout` section still renders -(`DependencyInjection.cs:38-39`); sets up localization and the pseudo/Mud localizer decorators -(`:42-55`); registers the auth and culture delegating handlers and the named `"APIClient"` whose base -address comes from `ApiSettings` and whose timeout is pinned to +first request (`DependencyInjection.cs:32-35`; the read-only face of those options is +[IApiSettings](#iapisettings), whose `WasmApiEndpoint` lets the server call an internal URL while the +browser is handed an external one, +`MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/IApiSettings.cs:11-17`); binds +[LayoutSettings](#layoutsettings) *without* validation, deliberately optional so a host with no `Layout` +section still renders (`DependencyInjection.cs:38-39`); sets up localization and the pseudo/Mud localizer +decorators (`:42-55`); registers the auth and culture delegating handlers and the named `"APIClient"` +whose base address comes from `ApiSettings` and whose timeout is pinned to [HttpResilienceDefaults](group-16-aspire-orchestration.md#httpresiliencedefaults)`.TotalRequestTimeout` rather than the BCL's arbitrary 100s, so the transport never pre-empts the resilience budget (`:59-82`); then `TryAdd`s [AuthUIService](#authuiservice), the list-page state services, [NavigationHistoryService](#navigationhistoryservice), [ThemeService](#themeservice), [EndpointCultureApplier](#endpointcultureapplier), the preference reader/writer, and a default [IOAuthUISettings](#ioauthuisettings) ([DefaultOAuthUISettings](#defaultoauthuisettings)) that downstream -apps override with [ConfigurationOAuthUISettings](#configurationoauthuisettings) (`:85-105`); and finally -calls `AddDeviceCapabilityDefaults()` so every capability contract resolves on every head -([ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html), group 26). The -`TryAdd*` discipline is what lets a consumer pre-register its own implementation and win. Browser hosts -add `AddClientAuthSessionCookieSync()` (`:119-123`) and `AddWasmFormFactor()` (`:131-132`); a Blazor -Server head adds `AddCommonServerTokenStorage()`, `AddCommonBlazorCsp()`, and `AddCommonWebFormFactor()` -from `MMCA.Common.UI.Web` (`MMCA.Common.UI.Web/DependencyInjection.cs:26-48`) plus the +apps override with [ConfigurationOAuthUISettings](#configurationoauthuisettings), which reads provider +availability from the `OAuth` section for a server host and from pre-computed `Enabled` flags for a WASM +client (`:85-105`, +`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/ConfigurationOAuthUISettings.cs:13-28`); +and finally calls `AddDeviceCapabilityDefaults()` so every capability contract resolves on every head +(`:109`, [ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html), +chapter 26). The `TryAdd*` discipline is what lets a consumer pre-register its own implementation and +win. Browser hosts add `AddClientAuthSessionCookieSync()` (`:119-123`) and `AddWasmFormFactor()` +(`:131-132`); a Blazor Server head adds `AddCommonServerTokenStorage()`, `AddCommonBlazorCsp()` (before +`AddCommonSecurityHeaders`, so it beats the `TryAdd`ed static provider), and `AddCommonWebFormFactor()` +from `MMCA.Common.UI.Web` +(`MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs:26-48`) plus the `UseAuthenticatedNoStore()` middleware. [UISharedAssemblyReference](#uisharedassemblyreference) (`DependencyInjection.cs:167`) is the marker other assemblies scan against. -The small Level-0 supporting cast fills in the rest: [ErrorMessages](#errormessages), -[NotificationRoutePaths](#notificationroutepaths), [UIModuleConfiguration](#uimoduleconfiguration), -[IHomePageContent](#ihomepagecontent) (the per-app landing-page hook behind the shared `/` route), -[LoginModel](#loginmodel) / [RegisterModel](#registermodel) / -[PasswordComplexityAttribute](#passwordcomplexityattribute) for the shared auth forms -(`[Rubric §24, Forms, Validation & UX Safety]`), [QrErrorCorrectionLevel](#qrerrorcorrectionlevel), and +The small Level-0 supporting cast fills in the rest: [NotificationRoutePaths](#notificationroutepaths) +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/NotificationRoutePaths.cs:6`), +[QrErrorCorrectionLevel](#qrerrorcorrectionlevel), the framework's own enum for `QrCodeImage` so the +component's public API does not pin consumers to QRCoder's `ECCLevel` +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/QrErrorCorrectionLevel.cs:9`), and [MauiBackNavigationBridge](#mauibacknavigationbridge) with its -[BackNavigationResult](#backnavigationresult) for MAUI hardware-back handling. Form-factor detection has -since graduated into its own device-capability layer -([IFormFactor](group-26-device-capability-layer.md#iformfactor) and friends, group 26). The presentational -helper [MoneyExtensions](#moneyextensions) formats [Money](group-02-domain-building-blocks.md#money) for -display, keeping a display concern out of the domain value object, exactly where Clean Architecture wants -it. +[BackNavigationResult](#backnavigationresult) for MAUI hardware-back handling, which reports both whether +`history.back()` fired and whether the WebView is at the root of its stack so a host can decide to exit +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/MauiBackNavigationBridge.cs:19,28`). +Form-factor detection has graduated into its own device-capability layer +([IFormFactor](group-26-device-capability-layer.md#iformfactor) and friends, chapter 26). The +presentational helper [MoneyExtensions](#moneyextensions) formats +[Money](group-02-domain-building-blocks.md#money) for display, grouping a mixed collection by currency so +unrelated amounts never collapse under whichever symbol came first +(`MMCA.Common/Source/Presentation/MMCA.Common.UI/Extensions/MoneyExtensions.cs:14,23-30`), keeping a +display concern out of the domain value object, exactly where Clean Architecture wants it. Read the per-type sections that follow for the mechanics. The consumer-side module UIs live in the ADC -module-UI chapters (group 21), and the bUnit component tests plus the Playwright/axe-core E2E suite that -exercise this package are covered in the testing chapter (group 25). +module-UI chapter ([chapter 21](group-21-conference-ui.md)), and the bUnit component tests plus the +Playwright/axe-core E2E suite that exercise this package are covered in the testing chapter +([chapter 27](group-27-testing-infrastructure.md)), which is where `[Rubric §28, Front-End Testing]` +lives. ### BreakpointConstants @@ -548,6 +654,18 @@ exercise this package are covered in the testing chapter (group 25). - **Why it's built this way**: `TryAdd` throughout makes these methods safe to call from several composing hosts, and it is also the override mechanism, since a host that registers its own implementation before `AddUIShared` wins. Two ordering choices are called out in comments and are load-bearing in the opposite direction: [ICultureApplier](#icultureapplier)'s default round-trips a server `/culture/set` endpoint that a MAUI hybrid head does not have, so hybrids override it **after** `AddUIShared` (`:93-96`), and device-capability defaults register first precisely so MAUI and browser heads can override them afterwards under last-registration-wins (`:107-108`). - **Where it's used**: Called once at startup by every consuming UI host (`MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs`, `.../MMCA.ADC.UI.Web.Client/Program.cs`, `.../MMCA.ADC.UI/MauiProgram.cs`, and the three Store equivalents), immediately followed by the per-module `Add{Module}UI()` calls that `UIModuleConfiguration.IsModuleEnabled` guards. The `"APIClient"` it configures is the client every [EntityServiceBase](#entityservicebasetentitydto-tidentifiertype)-derived service resolves. +### ForgotPasswordModel + +> MMCA.Common.UI · `MMCA.Common.UI.Pages.Auth` · `MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/ForgotPasswordModel.cs:9` · Level 0 · class (sealed) + +- **What it is**: the `EditForm` backing model for the Forgot Password page, a single `Email` string carrying DataAnnotations for shape validation. Nothing else is collected, because nothing else is needed to start a reset. +- **Depends on**: `System.ComponentModel.DataAnnotations` (BCL): `[Required]`, `[EmailAddress]`. Nothing first-party. +- **Concept introduced, validation deliberately capped at "shape" because of an anti-enumeration contract.** `[Rubric §24, Forms, Validation & UX Safety]` (assesses whether a form gives a clear per-field verdict before submit) and `[Rubric §26, Front-End Security]` (assesses whether the front end avoids leaking information the back end withholds). Every other form in this group validates as much as it can client-side. This one deliberately stops at "is this a syntactically valid address", because the interesting question, does an account exist for it, is one the server refuses to answer: [ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html) Decision 3 has `ForgotPasswordHandlerBase` return success on *every* path (malformed address, no account, throttled, failed send), so a distinguishable client-side outcome would reintroduce exactly the account-enumeration oracle the endpoint is built to avoid. The doc comment (`ForgotPasswordModel.cs:5-8`) states that trade-off directly. +- **Walkthrough**: one `get; set;` property. `Email` (line 13) carries `[Required(ErrorMessage = "Email is required")]` and `[EmailAddress(ErrorMessage = "Enter a valid email address")]` (lines 11-12) and defaults to `string.Empty`. +- **Why it's built this way**: `sealed` and mutable (`set`, not `init`) because `EditForm` two-way-binds the input to the model; keeping the model to one field is what makes the page's anti-enumeration behavior easy to reason about, there is no second field whose validation could betray a lookup. +- **Where it's used**: instantiated as `_model` by `ForgotPassword.razor` (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/ForgotPassword.razor:66`) and bound by its `` + `` (lines 34-35), with the field wired `For="@(() => _model.Email)"` at line 37 so the message attaches to that input. On valid submit `HandleRequestAsync` (lines 73-90) calls [`IAuthUIService`](#iauthuiservice)`.RequestPasswordResetAsync(_model.Email)` (line 79, contract at `MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/IAuthUIService.cs:41`) inside a `try` whose `catch` is empty on purpose (lines 81-84) and whose `finally` sets `_isSubmitted = true` unconditionally (line 88), so the success alert (line 23) renders for every submitted address whether the call succeeded, failed, or threw. The page is reached from the "forgot password" link on the Login page (`Login.razor:64`). +- **Caveats / not-in-source**: `RequestPasswordResetAsync` returns `bool`, and the call site ignores it (line 79); that is the anti-enumeration rule, not an oversight, and the gallery E2E test pins it by asserting the confirmation appears against a stub service that always answers "not accepted" (`MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/ForgotPasswordPageE2ETests.cs:28`). + ### LoginModel > MMCA.Common.UI · `MMCA.Common.UI.Pages.Auth` · `MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/LoginModel.cs:9` · Level 0 · class (sealed) @@ -559,7 +677,7 @@ exercise this package are covered in the testing chapter (group 25). - `Email` (line 13), `[Required(ErrorMessage = "Email is required")]` + `[EmailAddress(ErrorMessage = "Enter a valid email address")]` (lines 11-12), defaulting to `string.Empty`. - `Password` (line 16), `[Required(ErrorMessage = "Password is required")]` (line 15); deliberately no complexity rule here, login validates an *existing* credential, not a new one. - **Why it's built this way**: `sealed` and mutable (`set`, not `init`) because `EditForm` two-way-binds each input to the model; the messages are authored inline so each field shows one clear verdict. -- **Where it's used**: instantiated as `_model` and bound by `Login.razor` (`` + ``, `MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Login.razor:32-33`, field at line 130, inputs bound with `For="@(() => _model.Email)"` at lines 39 and 45 so each `MudTextField` shows its own message); on valid submit the page hands the credentials to the injected [`IAuthUIService`](#iauthuiservice) as a [`LoginRequest`](group-08-auth.md#loginrequest) (`Login.razor:173`). Sibling of [`RegisterModel`](#registermodel). +- **Where it's used**: instantiated as `_model` and bound by `Login.razor` (`` + ``, `MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Login.razor:32-33`, field at line 134, inputs bound with `For="@(() => _model.Email)"` at lines 39 and 45 so each `MudTextField` shows its own message); on valid submit the page hands the credentials to the injected [`IAuthUIService`](#iauthuiservice) as a [`LoginRequest`](group-08-auth.md#loginrequest) (`Login.razor:177`). The same page carries the escape hatch for a user who cannot supply a password at all, a link to `/forgot-password` (`Login.razor:64`, backed by [`ForgotPasswordModel`](#forgotpasswordmodel)). Sibling of [`RegisterModel`](#registermodel). ### MudTranslations @@ -577,16 +695,16 @@ exercise this package are covered in the testing chapter (group 25). > MMCA.Common.UI · `MMCA.Common.UI.Pages.Auth` · `MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/PasswordComplexityAttribute.cs:12` · Level 0 · class (sealed attribute) -- **What it is**: a custom `ValidationAttribute` that enforces the Register form's password-strength rule, at least 8 characters including an uppercase, a lowercase, a digit, and a special (non-alphanumeric) character. +- **What it is**: a custom `ValidationAttribute` that enforces the framework's password-strength rule on any form that sets a new password, at least 8 characters including an uppercase, a lowercase, a digit, and a special (non-alphanumeric) character. - **Depends on**: `System.ComponentModel.DataAnnotations` (`ValidationAttribute`, `ValidationResult`, `ValidationContext`) and `char.IsUpper`/`IsLower`/`IsDigit`/`IsLetterOrDigit` (BCL). Nothing first-party. - **Concept introduced, extending DataAnnotations with a domain rule.** `[Rubric §24, Forms, Validation & UX Safety]` (assesses client-side validation parity with the server). Beyond the built-in `[Required]`/`[EmailAddress]`, a bespoke rule subclasses `ValidationAttribute` and overrides `IsValid`. The doc comment (`PasswordComplexityAttribute.cs:5-10`) states the intent: mirror the server's rule so the `EditForm` gives the same verdict the API would. The downstream server-side story, how an accepted password is then *hashed*, is [ADR-032](https://ivanball.github.io/docs/adr/032-password-hashing.html) (PBKDF2-HMAC-SHA512 with legacy-hash backward compatibility); this attribute is only the client-side gate, never the security boundary. - **Walkthrough**: - `[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]` (line 11), applied as `[PasswordComplexity]` on a property. - Constructor (lines 14-17) seeds the base `ErrorMessage` with the full human-readable rule. - `IsValid(object?, ValidationContext)` (lines 19-39): returns `ValidationResult.Success` for a non-string or null/empty input (lines 21-24), deliberately deferring the "missing" message to `RequiredAttribute` so the field shows one message, not two; otherwise evaluates five predicates (`Length >= 8`, `Any(char.IsUpper)`, `Any(char.IsLower)`, `Any(char.IsDigit)`, `Any(c => !char.IsLetterOrDigit(c))`, lines 26-30) and, on failure, returns a `ValidationResult` scoped to the member name (lines 37-38) so the message attaches to the right field. -- **Why it's built this way**: a `ValidationAttribute` plugs straight into the same `DataAnnotationsValidator` that drives the rest of the form, so the complexity rule participates in the standard EditForm lifecycle with no extra wiring; emptiness is delegated to `[Required]` to avoid duplicate messages on one field. -- **Where it's used**: applied to `RegisterModel.Password` ([`RegisterModel`](#registermodel), `RegisterModel.cs:22`); evaluated by the `` in `Register.razor` (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Register.razor:27`). -- **Caveats / not-in-source**: the doc comment claims parity with the server's rule; this file only encodes the client check, so whether the server rule is byte-identical is not verifiable from this source. +- **Why it's built this way**: a `ValidationAttribute` plugs straight into the same `DataAnnotationsValidator` that drives the rest of the form, so the complexity rule participates in the standard EditForm lifecycle with no extra wiring; emptiness is delegated to `[Required]` to avoid duplicate messages on one field. Because the rule is an attribute rather than a method, a second form that sets a password gets identical behavior by adding one line, which is exactly how the reset vertical picked it up. +- **Where it's used**: applied to `RegisterModel.Password` ([`RegisterModel`](#registermodel), `RegisterModel.cs:22`) and to `ResetPasswordModel.NewPassword` ([`ResetPasswordModel`](#resetpasswordmodel), `ResetPasswordModel.cs:20`); evaluated by the `` in `Register.razor` (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Register.razor:27`) and `ResetPassword.razor` (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/ResetPassword.razor:35`). +- **Caveats / not-in-source**: the doc comment (line 6) still describes the attribute as the rule "for the Register form" although the reset form now carries it too; the code is the wider truth. The comment also claims parity with the server's rule, but this file only encodes the client check, so whether the server rule is byte-identical is not verifiable from this source. ### PersistedGridState @@ -627,7 +745,23 @@ exercise this package are covered in the testing chapter (group 25). - `ConfirmPassword` (line 27), `[Required]` + `[Compare(nameof(Password), ErrorMessage = "Passwords do not match")]` (lines 25-26), the cross-field check. - `AddressLine1` plus nullable `AddressLine2`/`City`/`State`/`ZipCode`/`Country` (lines 30-35), no validation attributes; the inline comment (line 29) states an empty Line 1 means "no address supplied". - **Why it's built this way**: the address fields stay attribute-free so a user can register without supplying one; the model is a flat view-model that the page projects onto the wire DTO at submit time rather than reusing the domain type directly. -- **Where it's used**: instantiated as `_model` and bound by `Register.razor` (`` + ``, `MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Register.razor:26-27`, field at line 122); on valid submit the page projects it into a [`RegisterRequest`](group-08-auth.md#registerrequest) (`Register.razor:161`), with the address fields folded into an [`Address`](group-02-domain-building-blocks.md#address) by `BuildAddressResult()` (`Register.razor:129`), which returns `null` when all six address fields are blank (lines 131-137) and otherwise `Address.Create(...)` (line 139). The accepted password is hashed server-side per [ADR-032](https://ivanball.github.io/docs/adr/032-password-hashing.html). +- **Where it's used**: instantiated as `_model` and bound by `Register.razor` (`` + ``, `MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Register.razor:26-27`, field at line 122); on valid submit the page projects it into a [`RegisterRequest`](group-08-auth.md#registerrequest) (`Register.razor:161`), with the address fields folded into an [`Address`](group-02-domain-building-blocks.md#address) by `BuildAddressResult()` (`Register.razor:129`), which returns `null` when all six address fields are blank (lines 131-137) and otherwise `Address.Create(...)` (line 139). The accepted password is hashed server-side per [ADR-032](https://ivanball.github.io/docs/adr/032-password-hashing.html). Its password block is mirrored by [`ResetPasswordModel`](#resetpasswordmodel). + +### ResetPasswordModel + +> MMCA.Common.UI · `MMCA.Common.UI.Pages.Auth` · `MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/ResetPasswordModel.cs:10` · Level 0 · class (sealed) + +- **What it is**: the `EditForm` backing model for the Reset Password page: the address and the emailed reset token that identify the request, plus the new password and its confirmation. +- **Depends on**: `System.ComponentModel.DataAnnotations` (`[Required]`, `[EmailAddress]`, `[Compare]`) and the sibling first-party [`PasswordComplexityAttribute`](#passwordcomplexityattribute). +- **Concept reinforced, the same password block as registration, on a credential-carrying form.** `[Rubric §24, Forms, Validation & UX Safety]` and `[Rubric §26, Front-End Security]`. The password half is byte-for-byte the shape [`RegisterModel`](#registermodel) introduced (`[Required]` + `[PasswordComplexity]` on the new value, `[Required]` + `[Compare]` on the confirmation), which is the payoff of expressing the complexity rule as an attribute rather than page code. What is new is the top half: `Email` and `Token` are not things the user chooses, they are the credential minted by the server and mailed as a link. The client validates only that both are present and that the address is well-formed; every substantive rejection (unknown, expired, mismatched, or attempt-capped token) collapses into one server-side `Auth.InvalidResetToken` error by design, per [ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html) Decision 3, so the form must not try to pre-judge a token it cannot verify. +- **Walkthrough**: four `get; set;` properties, each defaulting to `string.Empty`: + - `Email` (line 14), `[Required(ErrorMessage = "Email is required")]` + `[EmailAddress(ErrorMessage = "Enter a valid email address")]` (lines 12-13). + - `Token` (line 17), `[Required(ErrorMessage = "Reset token is required")]` (line 16), and nothing more: length, encoding, and freshness are all server-side properties of the cache record. + - `NewPassword` (line 21), `[Required]` + `[PasswordComplexity]` (lines 19-20). + - `ConfirmPassword` (line 25), `[Required]` + `[Compare(nameof(NewPassword), ErrorMessage = "Passwords do not match")]` (lines 23-24), the cross-field check retargeted at `NewPassword`. +- **Why it's built this way**: the doc comment (lines 5-9) records the load-bearing choice, that `Email` and `Token` arrive prefilled from the reset link but stay **editable**, so a user who only has the raw token text from the email (no working deep link, which is the situation on the native heads) can paste it in by hand. Making those two ordinary bound fields rather than read-only parameters is what buys that fallback for free. +- **Where it's used**: instantiated as `_model` by `ResetPassword.razor` (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/ResetPassword.razor:94`) and bound by its `` + `` (lines 34-35), with the four inputs at lines 41, 50, 56, and 61. The page declares `[SupplyParameterFromQuery]` `Email` and `Token` properties (lines 88-92) and copies them into the model in `OnParametersSet` (lines 101-112), which fills a field **only when it is still blank** (lines 103, 108) so a value the user corrected by hand is not overwritten when parameters are set again. `HandleResetAsync` (lines 114-138) calls [`IAuthUIService`](#iauthuiservice)`.ResetPasswordAsync(_model.Email, _model.Token, _model.NewPassword)` (line 121, contract at `MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/IAuthUIService.cs:48`), flips `_isCompleted` on true, and on false shows `AuthService.LastError` or the generic `Auth.Reset.GenericError` string (line 127). The prefill path is pinned by a gallery E2E test (`MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/ResetPasswordPageE2ETests.cs:31`), with a WCAG 2.1 AA scan alongside it (`:43`). +- **Caveats / not-in-source**: the model has no rule tying `Token` to the address; that pairing is enforced by the server's cache record (`pwdreset:token:{email}`, [ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html) Decision 1), not by anything visible here. ### SharedResource @@ -638,7 +772,7 @@ exercise this package are covered in the testing chapter (group 25). - **Concept introduced, the resource-anchor type.** `[Rubric §27, Internationalization]` (assesses whether user-facing copy is externalized to per-culture resources keyed stably, not hard-coded). ASP.NET Core's `IStringLocalizer` convention resolves keys against the resource file whose base name matches the type `T`. So a dedicated empty class becomes the *name* that ties many components to one shared string table: injecting `IStringLocalizer` anywhere reads the same dotted, stable keys (e.g. `Common.Error.Load`, `Grid.Snackbar.LoadCancelled`). The doc comment (`SharedResource.cs:3-8`) enumerates the chrome it covers: buttons, layout labels, snackbar/error templates, and the culture- and theme-switcher text. Its counterpart for library chrome is [`MudTranslations`](#mudtranslations). - **Walkthrough**: there are no members. The whole contract is "be a public sealed type named `SharedResource` in this namespace, with sibling `.resx` files." The work lives in the `.resx` key/value pairs and the localization middleware that resolves them by culture. - **Why it's built this way**: a marker type is the idiomatic ASP.NET Core way to scope a shared resource table without inventing a real class; one anchor keeps the chrome strings in a single table every component shares ([ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html) supersedes the prior single-locale stance of [ADR-011](https://ivanball.github.io/docs/adr/011-single-locale-i18n.html)). -- **Where it's used**: injected as `IStringLocalizer` by [`DataGridListPageBase`](#datagridlistpagebasetdto) (`DataGridListPageBase.cs:23`) for its cancellation snackbar, and handed to [`ErrorMessages.Configure`](#errormessages) from the root layout (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Layout/MainLayout.razor:103`) so the static helper resolves the same table; broadly consumed by the layout, the culture switcher, and the theme toggle components. +- **Where it's used**: injected as `IStringLocalizer` by [`DataGridListPageBase`](#datagridlistpagebasetdto) (`DataGridListPageBase.cs:23`) for its cancellation snackbar, by the auth pages for their field labels and messages (`ForgotPassword.razor:5`, `ResetPassword.razor:5`), and handed to [`ErrorMessages.Configure`](#errormessages) from the root layout (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Layout/MainLayout.razor:103`) so the static helper resolves the same table; broadly consumed by the layout, the culture switcher, and the theme toggle components. - **Caveats / not-in-source**: the `.resx` files (`SharedResource.resx`, `SharedResource.es.resx`) are resources, not `.cs`; their per-key contents are not enumerated here. ### WebApplicationExtensions @@ -689,7 +823,7 @@ exercise this package are covered in the testing chapter (group 25). - **What it is**: a centralized factory of user-facing snackbar message strings (load/save/delete/not-found/validation/action), so every page code-behind reports an outcome with identical phrasing, resolved through a shared localizer when one is configured ([ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html)). - **Depends on**: `IStringLocalizer`/`LocalizedString` (Microsoft.Extensions.Localization, NuGet), `string.Format` with `CultureInfo.CurrentCulture` (BCL), and the first-party [`DomainInvariantViolationException`](group-01-result-error-handling.md#domaininvariantviolationexception) (the one exception whose message is shown). The localizer it is handed is an `IStringLocalizer` (per the doc comment, `ErrorMessages.cs:25`), so it shares the [`SharedResource`](#sharedresource) `.resx` keys. -- **Concept introduced, the static-helper-with-injected-localizer bridge plus a safe-exception carve-out.** `[Rubric §27, Internationalization]` (assesses whether user-facing copy resolves per UI culture from resources rather than hard-coded English), `[Rubric §16, Maintainability]` (assesses whether a wording change is localized to one place), and `[Rubric §24, Forms, Validation & UX Safety]` (assesses that raw error text is not leaked to the user). This type is the boundary where a *static* helper (callable from any page without DI) is back-filled with a culture-aware localizer: each method calls a private `Localize(key, fallbackFormat, args)` that returns the localized value when the localizer is set and the key resolves, else the inline English fallback, so the static call sites never change yet the output follows the current culture. The load-bearing subtlety is the exception carve-out: a [`DomainInvariantViolationException`](group-01-result-error-handling.md#domaininvariantviolationexception) has its `Message` shown **verbatim** (because `ServiceExceptionHelper` rethrows the API's Problem Details errors as that type and their text is curated, server-localized domain wording, [ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html) Decisions 3 and 5), while every *other* exception's `Message` is deliberately **not** surfaced (raw exception text is neither localizable nor safe to show, [ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html) Decision 9). The rationale is spelled out in the `LoadError` doc comment (lines 42-51). +- **Concept introduced, the static-helper-with-injected-localizer bridge plus a safe-exception carve-out.** `[Rubric §27, Internationalization]` (assesses whether user-facing copy resolves per UI culture from resources rather than hard-coded English), `[Rubric §16, Maintainability]` (assesses whether a wording change is localized to one place), and `[Rubric §24, Forms, Validation & UX Safety]` (assesses that raw error text is not leaked to the user). This type is the boundary where a *static* helper (callable from any page without DI) is back-filled with a culture-aware localizer: each method calls a private `Localize(key, fallbackFormat, args)` that returns the localized value when the localizer is set and the key resolves, else the inline English fallback, so the static call sites never change yet the output follows the current culture. The load-bearing subtlety is the exception carve-out: a [`DomainInvariantViolationException`](group-01-result-error-handling.md#domaininvariantviolationexception) has its `Message` shown **verbatim** (because [`ServiceExceptionHelper`](#serviceexceptionhelper) rethrows the API's Problem Details errors as that type and their text is curated, server-localized domain wording, [ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html) Decisions 3 and 5), while every *other* exception's `Message` is deliberately **not** surfaced (raw exception text is neither localizable nor safe to show, [ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html) Decision 9). The rationale is spelled out in the `LoadError` doc comment (lines 42-51). - **Walkthrough**: a static class holding one mutable localizer field plus pure builders: - `_localizer` (line 19), a nullable `IStringLocalizer?`, null until configured. - `Configure(IStringLocalizer localizer)` (line 26), the one-time wiring point: assigns `_localizer`; idempotent; called from the root layout (see *Where it's used*). @@ -723,7 +857,7 @@ exercise this package are covered in the testing chapter (group 25). - **What it is**: the abstract Blazor base for every server-paged `MudDataGrid` list page. It folds the otherwise-copy-pasted concerns, cancellation lifecycle, loading and failure flags, mobile/desktop viewport detection, filter/sort extraction, error reporting, scroll restore, density toggle, URL + session + prerender state plumbing, and disposal, into one reusable component (`class DataGridListPageBase : ComponentBase, IBrowserViewportObserver, IAsyncDisposable, IDisposable`, line 20). - **Depends on**: [`ErrorMessages`](#errormessages) (Level 2), [`SharedResource`](#sharedresource) (Level 0, injected as `IStringLocalizer`), [`ListPageState`](#listpagestate) (Level 0), [`PersistedGridState`](#persistedgridstate) (Level 0, nested), [`ListPageQueryStateService`](#listpagequerystateservice) (Level 1), [`ListPageStateService`](#listpagestateservice) (Level 1), [`BreakpointConstants`](#breakpointconstants) (Level 0); MudBlazor's `MudDataGrid`, `GridState`, `GridData`, `IBrowserViewportObserver`/`IBrowserViewportService` (NuGet); Blazor's `PersistentComponentState`, `NavigationManager`, `IJSRuntime` (framework). -- **Concept introduced, a behavior-rich Blazor base component.** `[Rubric §18, UI Architecture & Component Design]` (assesses reuse; every list page inherits this behavior with zero copy-paste) and `[Rubric §23, Front-End Performance & Rendering]` (assesses server-side paging, only the requested page is fetched, never the whole table, plus the prerender cache that skips a redundant fetch). It also embodies several hard-won quality notes, each documented inline: the `MudDataGrid v9 RowsPerPage` bug (the v9 parameter setter always uses `resetPage: true` and clobbers `CurrentPage`, comment at lines 407-410), the disposed-CTS race (a debounced reload firing after disposal threw `ObjectDisposedException` and stuck the `blazor-error-ui` banner, lines 578-582), and the stale-write race (a late grid-state save landing after navigation stamped grid params onto the *next* page's URL and disposed it, lines 161-165), all worked around here, touching `[Rubric §22, Responsive & Cross-Browser]` and `[Rubric §28, Front-End Testing]` (these were E2E-discovered regressions). Its cancellation snackbar reads a localized string from [`SharedResource`](#sharedresource), the `[Rubric §27, Internationalization]` angle, and the `LoadFailed` flag is a `[Rubric §24, Forms, Validation & UX Safety]` detail: a failed fetch renders zero rows, which looks exactly like an empty list once the error snackbar expires, so derived pages branch on the flag to show an inline error-with-retry instead of the "no records" empty state (lines 33-40). +- **Concept introduced, a behavior-rich Blazor base component.** `[Rubric §18, UI Architecture & Component Design]` (assesses reuse; every list page inherits this behavior with zero copy-paste) and `[Rubric §23, Front-End Performance & Rendering]` (assesses server-side paging, only the requested page is fetched, never the whole table, plus the prerender cache that skips a redundant fetch). It also embodies several hard-won quality notes, each documented inline: the `MudDataGrid v9 RowsPerPage` bug (the v9 parameter setter always uses `resetPage: true` and clobbers `CurrentPage`, comment at lines 407-410), the disposed-CTS race (a debounced reload firing after disposal threw `ObjectDisposedException` and stuck the `blazor-error-ui` banner, lines 578-582), and the stale-write race (a late grid-state save landing after navigation stamped grid params onto the *next* page's URL and disposed it, lines 161-165), all worked around here, touching `[Rubric §22, Responsive & Cross-Browser]` and `[Rubric §28, Front-End Testing]` (these were E2E-discovered regressions). Its cancellation snackbar reads a localized string from [`SharedResource`](#sharedresource), the `[Rubric §27, Internationalization]` angle, and the `LoadFailed` flag is a `[Rubric §24, Forms, Validation & UX Safety]` detail: a failed fetch renders zero rows, which looks exactly like an empty list once the error snackbar expires, so derived pages branch on the flag to show an inline error-with-retry instead of the "no records" empty state (documented at lines 32-40). - **Walkthrough**: in teaching order: - **Injected services and abstract surface** (lines 22-29): `ISnackbar` (line 22), `IStringLocalizer` (line 23, the localized cancel message), `IBrowserViewportService` (line 24), the two state services (lines 25-26), `NavigationManager` (line 27), `IJSRuntime` (line 28), `PersistentComponentState` (line 29). Derived pages supply the abstract `Title` (line 41) and may override `GridRef` (line 121), `SaveFilters`/`RestoreFilters` (lines 108, 111), and `OnMobileDataRequestedAsync` (line 720). - **Public/protected state** (lines 31-76): `IsLoading` (line 31), `LoadFailed` (line 40), `IsMobile` (line 44), the mobile card-view block `MobileItems`/`MobileTotalItems`/`MobileCurrentPage`/`MobilePageSize` (lines 47-50), the bindable `CurrentPageState` (line 57, 0-indexed), `RowsPerPageState` (line 67, defaulting to 10 to match MudDataGrid v9's own default), and `DenseGrid` (line 76). `PrerenderFetchTimeoutMs = 5000` (line 82) bounds the SSR fetch. @@ -761,7 +895,7 @@ exercise this package are covered in the testing chapter (group 25). - `Symbol(string code)` (lines 54-59), a private switch mapping `"USD"` to `$` and `"EUR"` to the escaped euro sign (line 57, escaped to keep the source file ASCII-only). Every other code, **including the empty code of the `Currency.None` sentinel behind `Money.Zero()`** (`MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Currency.cs:23`, `Money.cs:142`), renders with no symbol rather than falsely claiming dollars. - `FormatGroup(decimal min, decimal max, string code)` (lines 65-73), the single formatting path: `"N2"` with `CultureInfo.InvariantCulture` (lines 69-70) so two decimals and a thousands separator render identically regardless of server locale, a single price when `min == max` and a hyphen-separated range otherwise (line 68), and the trailing code appended only when it is non-empty (line 72). - **Why it's built this way**: presentational formatting belongs above the domain, so `Money` stays display-agnostic and the same value can be rendered differently by a different head. `InvariantCulture` is a deliberate choice over `CurrentCulture`: prices are shown with an explicit ISO code (`USD`), so a locale-dependent decimal separator would produce `$12,50 USD` and read as an error. The empty-symbol fallback and the per-currency grouping are both "render the truth" decisions: never imply a currency the data does not carry. -- **Where it's used**: Store's Sales and Catalog UIs. `ToDisplayString()` renders order totals and line amounts (`MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.UI/Pages/Order/OrderLinesPanel.razor:34`, `:39`, `:51`; `Pages/Order/OrderSummaryPanel.razor:54`; `Pages/Order/OrderList.razor:36`, `:102`) and the cart's order-created snackbar (`Pages/ShoppingCart/ShoppingCartDetail.razor.cs:265`); `ToDisplayRange()` renders the price span across a product's variants in catalog browse (`MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.UI/Pages/Catalog/CatalogBrowse.razor.cs:303`). +- **Where it's used**: Store's Sales and Catalog UIs. `ToDisplayString()` renders order totals and line amounts (`MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.UI/Pages/Order/OrderLinesPanel.razor:34`, `:39`, `:51`; `Pages/Order/OrderSummaryPanel.razor:54`; `Pages/Order/OrderList.razor:36`, `:102`) and the cart's order-created snackbar (`Pages/ShoppingCart/ShoppingCartDetail.razor.cs:265`); `ToDisplayRange()` renders the price span across a product's variants in catalog browse (`MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.UI/Pages/Catalog/CatalogBrowse.razor.cs:303`, with the single-price helper alongside it at `:306`). - **Caveats / not-in-source**: only `USD` and `EUR` have symbols; adding a currency means editing `Symbol`, there is no configuration-driven table. The `"N2"` format assumes a two-minor-unit currency, so a zero-decimal currency (JPY) would render two spurious decimals; no code guards that today. ### CultureDelegatingHandler @@ -974,448 +1108,265 @@ exercise this package are covered in the testing chapter (group 25). - **Where it's used**: Registered `TryAddScoped` (`DependencyInjection.cs:101`); injected into the login page (`Login.razor:13`) and read once per login in `ApplyStoredPreferencesAndNavigateAsync` (`Login.razor:198`). - **Caveats / not-in-source**: Unlike the writer it keeps no rejected-token memory, which is a reasonable asymmetry given it runs once per login rather than once per toggle, but it is a difference between the two classes rather than a shared pattern. -### IOAuthUISettings -> MMCA.Common.UI · `MMCA.Common.UI.Services.Auth` · `MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/IOAuthUISettings.cs:9` · Level 0 · interface - -- **What it is**: the UI-layer contract that declares which external OAuth providers are available so - the shared login page can conditionally render social-login buttons. -- **Depends on**: nothing first-party. -- **Concept introduced, safe-by-default via default interface members.** `[Rubric §18, UI - Architecture]` (assesses how presentation configuration is surfaced without leaking backend - concerns) and `[Rubric §26, Front-End Security]` (assesses that optional auth surfaces are opt-in). - Both members are **default interface members**: `bool GoogleEnabled => false` - (`IOAuthUISettings.cs:12`) and `bool GitHubEnabled => false` (`IOAuthUISettings.cs:15`). An app that - registers no implementation, or the no-op [`DefaultOAuthUISettings`](#defaultoauthuisettings), gets - "no social login": the buttons stay hidden. Turning a provider on is additive, an implementation - returns `true` for the property it enables, with no change to the shared login component. -- **Walkthrough**: two boolean getter members, both defaulting to `false`. The login Razor component - reads `IOAuthUISettings` from DI to decide whether to render each provider's button. -- **Why it's built this way**: default interface members remove the need for a separate no-op class - while still shipping a usable, secure default (social login off until deliberately enabled). -- **Where it's used**: implemented by the no-op [`DefaultOAuthUISettings`](#defaultoauthuisettings) - and the config-driven [`ConfigurationOAuthUISettings`](#configurationoauthuisettings); consumed by - the login page. - -### ISessionCookieSync -> MMCA.Common.UI · `MMCA.Common.UI.Services.Auth` · `MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/ISessionCookieSync.cs:8` · Level 0 · interface - -- **What it is**: the contract for keeping the browser's HttpOnly auth cookie in step with the - client's in-memory tokens, so a server-side prerender can recognize an already-authenticated user. -- **Depends on**: nothing first-party. -- **Concept introduced, the prerender/interactive cookie boundary.** `[Rubric §18, UI Architecture]` - (assesses how the SSR prerender pass and the interactive circuit share auth state) and `[Rubric §26, - Front-End Security]` (assesses that the refresh secret stays in an HttpOnly cookie, not JS). The doc - comment (`ISessionCookieSync.cs:3-7`) states the exact failure this prevents: the interactive - circuit's in-memory access token is unreachable from the server, so without a synced cookie a - right-click "Open in new tab" on an `[Authorize]` page (which prerenders on the server) redirects to - `/login`. This is the client half of the dual-fetch auth model ([ADR-004](https://ivanball.github.io/docs/adr/004-authentication-dual-fetch.html)). -- **Walkthrough**: two methods, `SyncAsync(string accessToken, string refreshToken)` - (`ISessionCookieSync.cs:10`), called after login and each refresh to write the cookie, and - `ClearAsync()` (`ISessionCookieSync.cs:12`), called on logout to delete it. -- **Why it's built this way**: keeping this an interface lets each host supply the right mechanism, a - browser fetch on the web heads ([`JsFetchSessionCookieSync`](#jsfetchsessioncookiesync)) and a no-op - on MAUI (no SSR, no cookie). -- **Where it's used**: implemented by [`JsFetchSessionCookieSync`](#jsfetchsessioncookiesync); driven - by [`WasmTokenStorageService`](#wasmtokenstorageservice) at login and logout. - -### ITokenRefresher -> MMCA.Common.UI · `MMCA.Common.UI.Services.Auth` · `MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/ITokenRefresher.cs:13` · Level 0 · interface - -- **What it is**: the contract that acquires a fresh JWT access token, abstracting over where the - refresh credential lives per host. -- **Depends on**: nothing first-party. -- **Concept introduced, host-agnostic token refresh.** `[Rubric §11, Security]` and `[Rubric §26, - Front-End Security]` (both assess that the refresh token, the high-value secret, is handled by the - safest mechanism per platform). The doc comment (`ITokenRefresher.cs:3-12`) names the two concrete - paths this single method hides: on the browser hosts (Server + WASM), - [`SameOriginProxyTokenRefresher`](#sameoriginproxytokenrefresher) calls the same-origin - `/auth/session/token` endpoint where the refresh token sits in an HttpOnly cookie and rotates - server-side (never exposed to JS); on MAUI, [`DirectApiTokenRefresher`](#directapitokenrefresher) - exchanges the refresh token held in OS SecureStorage directly against `auth/refresh`. -- **Walkthrough**: one method, `Task AcquireAccessTokenAsync(CancellationToken = default)` - (`ITokenRefresher.cs:20`). It returns a fresh access token, or `null` when no valid session exists - (missing, expired, or revoked credential), a clean null convention so callers redirect to login - rather than catch exceptions. -- **Why it's built this way**: a one-method contract with a null-means-reauthenticate convention lets - the storage layer stay identical across hosts while the refresh-token persistence differs at the - edges ([ADR-004](https://ivanball.github.io/docs/adr/004-authentication-dual-fetch.html)). -- **Where it's used**: implemented by [`SameOriginProxyTokenRefresher`](#sameoriginproxytokenrefresher) - and [`DirectApiTokenRefresher`](#directapitokenrefresher); consumed by - [`WasmTokenStorageService`](#wasmtokenstorageservice) and [`AuthUIService`](#authuiservice). - -### ITokenStorageService -> MMCA.Common.UI · `MMCA.Common.UI.Services.Auth` · `MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/ITokenStorageService.cs:8` · Level 0 · interface - -- **What it is**: the platform-agnostic contract for persisting the JWT access/refresh pair, letting - each host use the safe storage mechanism for its platform. -- **Depends on**: nothing first-party. -- **Concept introduced, platform-abstracted token persistence.** `[Rubric §26, Front-End Security]` - and `[Rubric §11, Security]` (assess that tokens are held in the safest store per platform). The doc - comment (`ITokenStorageService.cs:3-7`) fixes the policy the implementations honor: browser hosts - keep the access token **in memory** and mirror the refresh token to an HttpOnly cookie, never - `localStorage`; MAUI uses OS SecureStorage. Managing both tokens through one abstraction means no - page component ever touches a raw storage API. -- **Walkthrough**: four methods, `GetAccessTokenAsync()` (`ITokenStorageService.cs:11`) and - `GetRefreshTokenAsync()` (`ITokenStorageService.cs:14`) each returning `Task` (async - because SecureStorage is async on MAUI); `SetTokensAsync(accessToken, refreshToken)` - (`ITokenStorageService.cs:17`), an atomic write of both after login or refresh; and - `ClearTokensAsync()` (`ITokenStorageService.cs:20`) on logout. -- **Why it's built this way**: an interface (not a base class) keeps the platform-specific - implementation in its own host with no shared code dependency; writing both tokens together avoids - partial-update bugs (a fresh access token paired with a stale refresh token). -- **Where it's used**: implemented by [`WasmTokenStorageService`](#wasmtokenstorageservice) (and a - Blazor Server sibling `ServerTokenStorageService` noted in the WASM doc comment); read by - [`AuthDelegatingHandler`](#authdelegatinghandler), - [`JwtAuthenticationStateProvider`](#jwtauthenticationstateprovider), and - [`AuthUIService`](#authuiservice). - -### JwtTokenInfo -> MMCA.Common.UI · `MMCA.Common.UI.Services.Auth` · `MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/JwtTokenInfo.cs:9` · Level 0 · class (static) - -- **What it is**: a static helper that inspects a JWT client-side (expiry only, no signature check) so - token storage can decide when to re-acquire an access token. -- **Depends on**: BCL only (`System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler`). -- **Concept introduced, deliberate signature-free client inspection.** `[Rubric §26, Front-End - Security]` (assesses that trust decisions stay server-side) and `[Rubric §12, Performance & - Scalability]` (assesses avoiding a doomed round-trip). The doc comment (`JwtTokenInfo.cs:5-7`) is - explicit: there is **no signature validation** here, the API validates every request. The only job - is to read expiry locally and refresh proactively, avoiding an API call that would come back 401. -- **Walkthrough**: `IsFresh(string? token, TimeSpan skew)` (`JwtTokenInfo.cs:16`): returns `false` - immediately for null/blank (`JwtTokenInfo.cs:18-21`); returns `false` if `CanReadToken` says the - string is not a readable JWT (`JwtTokenInfo.cs:24-27`); otherwise returns whether - `ReadJwtToken(token).ValidTo > DateTime.UtcNow + skew` (`JwtTokenInfo.cs:31`), so a token within - `skew` of expiry is already treated as stale. A narrow catch of `ArgumentException`/`FormatException` - (`JwtTokenInfo.cs:33-36`) yields `false` on a malformed token rather than throwing. -- **Why it's built this way**: a pure static method with no dependencies is trivially unit-testable by - passing token strings and needs no DI. The `skew` argument makes proactive refresh a caller policy, - not a hard-coded constant. -- **Where it's used**: [`WasmTokenStorageService.GetAccessTokenAsync`](#wasmtokenstorageservice) gates - its in-memory access token on `JwtTokenInfo.IsFresh(_accessToken, ExpirySkew)` before returning it - (`WasmTokenStorageService.cs:22`). - -### AuthDelegatingHandler -> MMCA.Common.UI · `MMCA.Common.UI.Services.Auth` · `MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthDelegatingHandler.cs:9` · Level 1 · class (sealed) - -- **What it is**: an `HttpClient` message handler that attaches the stored JWT Bearer token to every - outgoing API request. -- **Depends on**: [`ITokenStorageService`](#itokenstorageservice) (Level 0); BCL - (`System.Net.Http.Headers`). -- **Concept introduced, the delegating-handler auth interceptor.** `[Rubric §11, Security]` and - `[Rubric §18, UI Architecture]` (assess where the outbound auth header is centralized). A - `DelegatingHandler` is the `HttpClient` analogue of ASP.NET middleware: it wraps a request before it - goes on the wire. This one reads the access token from - [`ITokenStorageService`](#itokenstorageservice) and sets `Authorization: Bearer {token}`, so no call - site has to remember to authenticate. -- **Walkthrough**: `SendAsync` (`AuthDelegatingHandler.cs:13`): awaits `GetAccessTokenAsync` - (`AuthDelegatingHandler.cs:17`); if the token is non-blank, sets - `request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token)` - (`AuthDelegatingHandler.cs:18-21`); then delegates to `base.SendAsync` - (`AuthDelegatingHandler.cs:23`). With no token the request goes unauthenticated (the API answers 401 - where auth is required). -- **Why it's built this way**: centralizing the header on the handler keeps every service call - uniformly authenticated without per-call code. The class is `sealed` and constructor-injects its - one dependency. -- **Where it's used**: registered in the `"APIClient"` named-client pipeline via - `AddHttpMessageHandler` (per its doc comment, `AuthDelegatingHandler.cs:5-7`). Note that - [`AuthUIService`](#authuiservice) sets the header manually on some calls because of a Blazor Server - DI scope issue (`AuthUIService.cs:263`). - -### ConfigurationOAuthUISettings -> MMCA.Common.UI · `MMCA.Common.UI.Services.Auth` · `MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/ConfigurationOAuthUISettings.cs:13` · Level 1 · class (sealed) - -- **What it is**: an [`IOAuthUISettings`](#ioauthuisettings) implementation that reads provider - availability from the `OAuth` configuration section, covering both host shapes (server and WASM) - with one class. -- **Depends on**: [`IOAuthUISettings`](#ioauthuisettings) (Level 0); NuGet - (`Microsoft.Extensions.Configuration.IConfiguration`). -- **Concept introduced, config-driven provider gating that never ships the client id to the browser.** - `[Rubric §18, UI Architecture]` (assesses configuration-driven UI without backend leakage) and - `[Rubric §26, Front-End Security]` (assesses that a secret-bearing key stays server-side). The doc - comment (`ConfigurationOAuthUISettings.cs:5-12`) explains the dual shape: a server host declares a - provider enabled when its `OAuth:{Provider}:ClientId` is configured; a WASM client instead receives - a pre-computed `OAuth:{Provider}Enabled` flag through its runtime config (`/client-config`), which - never carries the client id itself. -- **Walkthrough**: the constructor (`ConfigurationOAuthUISettings.cs:21`) null-guards `configuration`, - reads the `OAuth` section, and computes `GoogleEnabled`/`GitHubEnabled` once - (`ConfigurationOAuthUISettings.cs:25-27`) into get-only properties - (`ConfigurationOAuthUISettings.cs:16,19`). `IsProviderEnabled` - (`ConfigurationOAuthUISettings.cs:30`) returns `true` when either the `{Provider}Enabled` flag parses - to `true` **or** a non-empty `{Provider}:ClientId` is present - (`ConfigurationOAuthUISettings.cs:32-33`), so the flag path (WASM) and the client-id path (server) - both light up the button. -- **Why it's built this way**: folding both host shapes into one predicate avoids two near-identical - settings classes and keeps the "browser never sees the client id" rule in one place; computing the - flags in the constructor makes the instance immutable and cheap to read. -- **Where it's used**: registered as a singleton (per its doc comment, - `ConfigurationOAuthUISettings.cs:7`) to replace the no-op - [`DefaultOAuthUISettings`](#defaultoauthuisettings); consumed by the login page. - -### DefaultOAuthUISettings -> MMCA.Common.UI · `MMCA.Common.UI.Services.Auth` · `MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/DefaultOAuthUISettings.cs:7` · Level 1 · class (internal sealed) - -- **What it is**: the no-op [`IOAuthUISettings`](#ioauthuisettings) implementation that disables all - OAuth providers, a single-line type: `internal sealed class DefaultOAuthUISettings : - IOAuthUISettings;` (`DefaultOAuthUISettings.cs:7`). -- **Depends on**: [`IOAuthUISettings`](#ioauthuisettings) (Level 0). -- **Concept, the Null Object / default-registration pattern.** `[Rubric §2, Design Patterns]` - (assesses using a benign default rather than a nullable dependency). Because - [`IOAuthUISettings`](#ioauthuisettings) supplies default members returning `false`, this class needs - no body: it inherits "all providers off". Registering it guarantees the interface is always - resolvable, so the login page can inject it unconditionally; a downstream app overrides the - registration with [`ConfigurationOAuthUISettings`](#configurationoauthuisettings) to enable - providers. -- **Walkthrough**: no members. All behavior comes from the interface's default members. -- **Where it's used**: the framework's fallback registration; superseded by - [`ConfigurationOAuthUISettings`](#configurationoauthuisettings) when an app configures OAuth. - -### DirectApiTokenRefresher -> MMCA.Common.UI · `MMCA.Common.UI.Services.Auth` · `MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/DirectApiTokenRefresher.cs:11` · Level 1 · class (sealed) - -- **What it is**: the MAUI [`ITokenRefresher`](#itokenrefresher): it exchanges the refresh token held - in OS SecureStorage directly against the API's `auth/refresh` endpoint and persists the rotated pair - back to storage. -- **Depends on**: [`ITokenRefresher`](#itokenrefresher) (Level 0), - [`ITokenStorageService`](#itokenstorageservice) (Level 0), - [`RefreshTokenRequest`](group-08-auth.md#refreshtokenrequest), - [`AuthenticationResponse`](group-08-auth.md#authenticationresponse); BCL/NuGet - (`IHttpClientFactory`, `System.Net.Http.Json`). -- **Concept, per-host refresh strategy.** `[Rubric §11, Security]` (assesses matching the refresh - mechanism to the platform's threat surface). The doc comment (`DirectApiTokenRefresher.cs:6-9`) - justifies handling the refresh token directly: MAUI has no browser DOM and therefore no XSS surface, - so exchanging a SecureStorage-held token straight against the cross-origin API is acceptable. The - browser hosts use [`SameOriginProxyTokenRefresher`](#sameoriginproxytokenrefresher) instead. -- **Walkthrough**: `AcquireAccessTokenAsync` (`DirectApiTokenRefresher.cs:17`): reads both tokens - (`DirectApiTokenRefresher.cs:19-20`); returns `null` if either is missing - (`DirectApiTokenRefresher.cs:22-25`); POSTs a - [`RefreshTokenRequest`](group-08-auth.md#refreshtokenrequest) to the relative `auth/refresh` - (`DirectApiTokenRefresher.cs:27-29`); on a non-success status returns `null` - (`DirectApiTokenRefresher.cs:31-34`); otherwise reads - [`AuthenticationResponse`](group-08-auth.md#authenticationresponse), returns `null` on a blank access - token, then persists the rotated pair via `SetTokensAsync` and returns the new access token - (`DirectApiTokenRefresher.cs:36-43`). -- **Why it's built this way**: constructor-injecting the storage service and HTTP factory keeps the - refresher stateless; the null-on-failure convention matches [`ITokenRefresher`](#itokenrefresher) so - a caller treats null as "re-login". -- **Where it's used**: registered as the [`ITokenRefresher`](#itokenrefresher) on the MAUI host; - reached through [`AuthUIService.TryRefreshTokenAsync`](#authuiservice). - -### JsFetchSessionCookieSync -> MMCA.Common.UI · `MMCA.Common.UI.Services.Auth` · `MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/JsFetchSessionCookieSync.cs:11` · Level 1 · class (sealed) - -- **What it is**: the [`ISessionCookieSync`](#isessioncookiesync) implementation that syncs the - HttpOnly auth cookie by firing a browser `fetch` through JS interop. -- **Depends on**: [`ISessionCookieSync`](#isessioncookiesync) (Level 0); NuGet - (`Microsoft.JSInterop.IJSRuntime`). -- **Concept, browser-issued cookie writes.** `[Rubric §26, Front-End Security]` and `[Rubric §18, UI - Architecture]` (assess crossing the Server/WASM prerender boundary safely). The doc comment - (`JsFetchSessionCookieSync.cs:5-9`) explains why the fetch is issued from the browser and not the - server: only then does the resulting `Set-Cookie` land in the user's cookie jar, and it works in - both Blazor Server interactive mode and WebAssembly. When JS interop is unavailable (SSR prerender, - a render-mode transition), the calls fall silent rather than throw. -- **Walkthrough**: `SyncAsync` (`JsFetchSessionCookieSync.cs:16`) invokes `mmcaAuthCookie.set` with - both tokens; `ClearAsync` (`JsFetchSessionCookieSync.cs:28`) invokes `mmcaAuthCookie.clear`. Both - wrap the interop call and swallow the interop-unavailable exception family via the shared - `IsInteropUnavailable` predicate (`JsFetchSessionCookieSync.cs:13-14`), which matches - `InvalidOperationException`, `JSDisconnectedException`, `JSException`, and - `OperationCanceledException`. The catch comments note the cookie will be re-synced on the next write. -- **Why it's built this way**: keeping the JS mechanics behind the interface lets MAUI drop in a - no-op; swallowing interop failures during prerender keeps a login flow from crashing when the circuit - is not yet interactive. -- **Where it's used**: registered on the web heads as [`ISessionCookieSync`](#isessioncookiesync); - driven by [`WasmTokenStorageService`](#wasmtokenstorageservice) at login and logout. - -### JwtAuthenticationStateProvider -> MMCA.Common.UI · `MMCA.Common.UI.Services.Auth` · `MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/JwtAuthenticationStateProvider.cs:12` · Level 1 · class (sealed) - -- **What it is**: a custom Blazor `AuthenticationStateProvider` that derives auth state from the JWT - held by [`ITokenStorageService`](#itokenstorageservice), reading claims client-side for - responsiveness while the API validates fully on every request. -- **Depends on**: [`ITokenStorageService`](#itokenstorageservice) (Level 0); NuGet/BCL - (`Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider`, `System.Security.Claims`, - `JwtSecurityTokenHandler`). -- **Concept introduced, client-side auth-state projection.** `[Rubric §18, UI Architecture]` (assesses - how Blazor's `AuthorizeView`/`CascadingAuthenticationState` learns who is signed in), `[Rubric §11, - Security]` (assesses that client-side claims drive only rendering, not trust), and `[Rubric §19, - State Management]` (assesses pushing state changes without a page reload). The doc comment - (`JwtAuthenticationStateProvider.cs:7-11`) states the split: claims are extracted client-side without - server validation to keep the UI responsive; the WebAPI does the real validation. +### ServiceExceptionHelper +> MMCA.Common.UI · `MMCA.Common.UI.Services` · `MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ServiceExceptionHelper.cs:11` · Level 2 · class (static) + +- **What it is**: the client-side half of the API error contract. It inspects a non-success HTTP + response body for the Problem Details payloads the WebAPI emits and re-throws them as a + [`DomainInvariantViolationException`](group-01-result-error-handling.md#domaininvariantviolationexception) + carrying the server's own message, so a page can show "Session already has that speaker" instead of + "Response status code does not indicate success". +- **Depends on**: + [`DomainInvariantViolationException`](group-01-result-error-handling.md#domaininvariantviolationexception) + (`ServiceExceptionHelper.cs:2`); `System.Text.Json` (BCL) for the parse. Nothing else: it is a static + class with no state and no DI surface, which is why every UI service base can call it for free. +- **Concept introduced, reading the Problem Details contract from the client side.** + `[Rubric §9, API & Contract Design]` assesses whether errors travel as a structured, versionable + payload rather than a status code plus prose. The server side of that contract has three producers + and this helper branches on the `title` each one writes: `"Domain Exception"` from + [`DomainExceptionHandler`](group-12-api-hosting-mapping.md#domainexceptionhandler) + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/DomainExceptionHandler.cs:40`), + `"Validation Exception"` from + [`ValidationExceptionHandler`](group-12-api-hosting-mapping.md#validationexceptionhandler) + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/ValidationExceptionHandler.cs:41`), and + `"Operation failed"` from + [`ApiControllerBase`](group-12-api-hosting-mapping.md#apicontrollerbase)`.HandleFailure` when an + [`Error`](group-01-result-error-handling.md#error) list comes back from a handler + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/ApiControllerBase.cs:43`). The title + string is the discriminator, matched with `StringComparison.Ordinal` + (`ServiceExceptionHelper.cs:49,52,55`), so the three producers and this consumer are coupled by an + exact literal on both ends. `[Rubric §10, Cross-Cutting Concerns]` also applies: error translation + lives in one place instead of in each page's `catch`. - **Walkthrough** - - A shared `AnonymousState` (`JwtAuthenticationStateProvider.cs:14-15`) is an empty - `ClaimsPrincipal`, the fallback for every unauthenticated path. - - `GetAuthenticationStateAsync` (`JwtAuthenticationStateProvider.cs:22`): reads the token; returns - anonymous on blank (`:27-30`), on an unreadable token (`CanReadToken`, `:33-36`), or on an expired - token (`ValidTo < DateTime.UtcNow`, `:39-42`). Otherwise it builds a `ClaimsIdentity` with the - `"jwt"` authentication type (`:45`), which is what makes `IsAuthenticated == true`, and returns the - principal. A bare `catch` (`:49-52`) falls back to anonymous on any failure (corrupt data, interop - unavailable). - - `NotifyUserAuthentication(string token)` (`:59`) builds a principal from the token and calls - `NotifyAuthenticationStateChanged` so `CascadingAuthenticationState` consumers update immediately - after login/refresh, with no page reload; `NotifyUserLogout()` (`:71`) pushes `AnonymousState`. -- **Why it's built this way**: deriving state from the stored token (rather than a server round-trip) - keeps the UI instant, and the explicit notify methods let [`AuthUIService`](#authuiservice) drive - state transitions on login, refresh, and logout. The `"jwt"` auth-type string is load-bearing: an - identity built with no auth type reports `IsAuthenticated == false`. -- **Where it's used**: registered as the Blazor `AuthenticationStateProvider`; - [`AuthUIService`](#authuiservice) pattern-matches it to call - `NotifyUserAuthentication`/`NotifyUserLogout`. - -### SameOriginProxyTokenRefresher -> MMCA.Common.UI · `MMCA.Common.UI.Services.Auth` · `MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/SameOriginProxyTokenRefresher.cs:11` · Level 1 · class (sealed) - -- **What it is**: the browser (Blazor Server + WebAssembly) [`ITokenRefresher`](#itokenrefresher): it - calls the same-origin `POST /auth/session/token` endpoint via JS `fetch` so the browser sends its - HttpOnly cookies and the UI host refreshes server-side, returning only the access token. -- **Depends on**: [`ITokenRefresher`](#itokenrefresher) (Level 0); NuGet - (`Microsoft.JSInterop.IJSRuntime`). -- **Concept, refresh-token isolation from JS.** `[Rubric §11, Security]` and `[Rubric §26, Front-End - Security]` (assess that the refresh token never enters JS-reachable memory). The doc comment - (`SameOriginProxyTokenRefresher.cs:5-10`) explains the mechanism: the JS fetch uses - `credentials:'same-origin'`, which sends the HttpOnly auth cookie to the same-origin UI host; the - host validates-or-refreshes server-side and hands back only the access token. This is the browser - half of the dual-fetch model ([ADR-004](https://ivanball.github.io/docs/adr/004-authentication-dual-fetch.html)). -- **Walkthrough**: `AcquireAccessTokenAsync` (`SameOriginProxyTokenRefresher.cs:13`) invokes - `mmcaAuthSession.getToken` (`:17`), returning `null` for a blank result (`:18`). It catches the JS - interop exception family (`InvalidOperationException`, `JSDisconnectedException`, `JSException`, - `OperationCanceledException`, `:20-25`) and returns `null`, the comment noting the server-side cookie - path covers SSR-prerender and disconnected-circuit phases. -- **Why it's built this way**: routing the refresh through a same-origin JS fetch keeps the - high-value refresh token in the HttpOnly cookie and out of JS memory, exactly the isolation §26 - rewards. -- **Where it's used**: registered as the [`ITokenRefresher`](#itokenrefresher) on the web server and - WASM hosts; consumed by [`WasmTokenStorageService`](#wasmtokenstorageservice) and - [`AuthUIService`](#authuiservice). - -### WasmTokenStorageService -> MMCA.Common.UI · `MMCA.Common.UI.Services.Auth` · `MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/WasmTokenStorageService.cs:11` · Level 1 · class (sealed) - -- **What it is**: the WebAssembly [`ITokenStorageService`](#itokenstorageservice): it holds the access - token **in memory only** (never `localStorage`) and hydrates or refreshes it on demand from the - HttpOnly cookies through an [`ITokenRefresher`](#itokenrefresher). -- **Depends on**: [`ITokenStorageService`](#itokenstorageservice) (Level 0), - [`ISessionCookieSync`](#isessioncookiesync) (Level 0), [`ITokenRefresher`](#itokenrefresher) - (Level 0), [`JwtTokenInfo`](#jwttokeninfo) (Level 0). -- **Concept introduced, in-memory-plus-cookie token custody with single-flight refresh.** `[Rubric - §26, Front-End Security]` (assesses keeping the access token out of persistent, JS-readable storage), - `[Rubric §11, Security]` (assesses that the refresh token is never client-readable), `[Rubric §12, - Performance & Scalability]`, and `[Rubric §19, State Management]` (assess deduplicating concurrent - token acquisition). The doc comment (`WasmTokenStorageService.cs:3-10`) states the model: cookie-only, - the access token lives in memory and is rehydrated from the HttpOnly cookies via the same-origin - `/auth/session/token` endpoint; the refresh token is never readable by JS; and the class is hoisted - from the app WASM clients because it carries no app-specific state (its Blazor Server sibling is - `ServerTokenStorageService`). + - `ThrowIfDomainExceptionAsync(HttpResponseMessage, CancellationToken)` (`ServiceExceptionHelper.cs:17`) + is the only public member. It null-guards the response (line 19), returns immediately when there is + no content or the body is blank (lines 21-26), and reads the body as a string (line 24). + - The parse is defensive: `JsonDocument.Parse` is wrapped in a `try` that swallows `JsonException` + and returns (lines 29-38). The comment names the cases that reach it, a bare 401 challenge or an + HTML error page, and states the contract with the caller: a non-JSON failure falls through to the + caller's own `EnsureSuccessStatusCode()`. Nothing is thrown here that the caller was not already + going to throw. + - `using (document)` (line 40) disposes the parsed document on every exit path, including the throw + paths below, because the exception is constructed from strings already extracted. + - No `title` property means "not one of ours": return and let the caller decide (lines 44-45). + - `"Domain Exception"` takes the simple path, `ExtractDetailMessage(root, "A domain error occurred.")` + (line 50), which reads `detail` or falls back (lines 60-63). + - `"Validation Exception"` goes through `ExtractValidationMessage` (lines 65-83). The server writes + `errors` as an object keyed by property name whose values are arrays of messages, so the helper + walks `EnumerateObject()` then `EnumerateArray()` (lines 72-76) and joins every message with a + single space (line 79). The joined string replaces the `detail` fallback only when at least one + message was found (line 78). + - `"Operation failed"` goes through `ExtractOperationFailedMessage` (lines 85-98), which expects a + different shape: `errors` is a JSON **array** of error objects, not an object of arrays (line 89). + `CollectErrorMessages` (lines 100-114) pulls the `message` property off each element, skipping + blanks. That shape is what `ErrorHttpMapping.BuildErrorsExtension` projects from an `Error` list + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/ErrorHttpMapping.cs:47-55`), which is + why the two `errors` branches cannot share code. +- **Why it's built this way**: the alternative is a shared error DTO deserialized with a strongly typed + model, but the three payloads differ in the shape of `errors` and the helper must stay tolerant of + bodies that are not Problem Details at all (proxies, gateways, auth challenges). Reading the document + loosely and returning quietly on anything unrecognized means the helper can be called + unconditionally before `EnsureSuccessStatusCode()` without ever changing behavior for responses it + does not understand. Collapsing all three onto one exception type is deliberate too: pages catch one + thing and display `ex.Message`. +- **Where it's used**: called on every non-success response by both service bases in this group, + [`EntityServiceBase`](#entityservicebasetentitydto-tidentifiertype) + (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/EntityServiceBase.cs:211`) and + [`ChildEntityServiceBase`](#childentityservicebase) + (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ChildEntityServiceBase.cs:31,52`), plus the + hand-written services that use neither base: + [`NotificationInboxService`](#notificationinboxservice) in this package, and the module UI services in + ADC (Engagement check-in, points, feedback, bookmarks, live polls) and Store (cart state). Its + behavior is pinned by + [`ServiceExceptionHelperTests`](group-27-testing-infrastructure.md#serviceexceptionhelpertests). +- **Caveats**: the whole body is buffered into a string before parsing (line 24), so a very large error + payload is fully materialized; error bodies are small in practice, but there is no size guard in + source. The `title` match is exact and case-sensitive, so a producer that renames a title silently + degrades every client to the generic `HttpRequestException` path. + +### ChildEntityServiceBase +> MMCA.Common.UI · `MMCA.Common.UI.Services` · `MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ChildEntityServiceBase.cs:17` · Level 3 · class (abstract) + +- **What it is**: the two-verb service base for join entities, the many-to-many rows a UI can create and + delete but never lists or edits on their own. It offers exactly `PostAsync` and `DeleteByIdAsync` over + the named `"APIClient"`, and nothing else. +- **Depends on**: [`AuthenticatedServiceBase`](#authenticatedservicebase) (base class, supplying the + authenticated client factory and the retry policy), [`ITokenStorageService`](#itokenstorageservice) + (constructor parameter, passed straight through), [`ServiceExceptionHelper`](#serviceexceptionhelper); + `IHttpClientFactory` and `System.Net.Http.Json` (BCL). +- **Concept introduced, a base class shaped by the resource rather than by convention.** + `[Rubric §18, UI Architecture & Component Design]` assesses whether the presentation layer talks to the + backend through typed services rather than raw `HttpClient` calls in components. The interesting design + choice here is what is **absent**: a join row like `SessionSpeaker` has no list page, no edit form and + no lookup, so this base deliberately does not implement + [`IEntityService`](#ientityservicetentitydto-tidentifiertype). Giving join + services the full CRUD surface would hand pages six operations of which four have no endpoint behind + them. `[Rubric §1, SOLID]` reads this as interface segregation applied at the service-base level: the + smaller base cannot promise what the API does not serve. - **Walkthrough** - - Fields: a static `ExpirySkew` of 30 seconds (`WasmTokenStorageService.cs:15`), the in-memory - `_accessToken` (`:17`), and an `_hydrateInFlight` task handle (`:18`) that backs the single-flight - guard. - - `GetAccessTokenAsync` (`:20`): returns the cached token immediately if - `JwtTokenInfo.IsFresh(_accessToken, ExpirySkew)` (`:22-25`); otherwise it starts (or joins) one - `HydrateAsync` via `_hydrateInFlight ??= HydrateAsync()` so concurrent callers (the delegating - handler, auth-state provider, SignalR) share a single acquisition (`:27-36`), clearing the handle - in a `finally`. - - `GetRefreshTokenAsync` (`:40`): always returns `null`, the refresh token lives only in the HttpOnly - cookie. - - `SetTokensAsync` (`:42`): stores the access token in memory and seeds the HttpOnly cookies via - [`ISessionCookieSync.SyncAsync`](#isessioncookiesync); the comment notes the refresh token transits - JS only for that one same-origin POST and is never persisted (`:44-47`). - - `ClearTokensAsync` (`:50`): nulls the in-memory token and clears the cookies. - - `HydrateAsync` (`:56`): calls [`ITokenRefresher.AcquireAccessTokenAsync`](#itokenrefresher), caches - the result in `_accessToken`, and returns it. -- **Why it's built this way**: holding the access token in process memory (not `localStorage`) shrinks - the XSS blast radius, and the single-flight `_hydrateInFlight` guard prevents a thundering herd of - parallel refreshes when several components ask for a token at once ([ADR-004](https://ivanball.github.io/docs/adr/004-authentication-dual-fetch.html)). -- **Where it's used**: registered as the [`ITokenStorageService`](#itokenstorageservice) on the WASM - host; read by [`AuthDelegatingHandler`](#authdelegatinghandler), - [`JwtAuthenticationStateProvider`](#jwtauthenticationstateprovider), and - [`AuthUIService`](#authuiservice). -- **Caveats / not-in-source**: the `finally` clears `_hydrateInFlight` after the first awaiter - completes, so single-flight coalesces callers that overlap the acquisition window, not every call - across the token's lifetime; a caller arriving after the window starts a fresh hydration. - -### IAuthUIService -> MMCA.Common.UI · `MMCA.Common.UI.Services.Auth` · `MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/IAuthUIService.cs:9` · Level 5 · interface - -- **What it is**: the client-side authentication contract that ties together token storage, HTTP calls - to the `auth/*` WebAPI endpoints, and Blazor auth-state notifications. -- **Depends on**: [`AuthenticationResponse`](group-08-auth.md#authenticationresponse), - [`LoginRequest`](group-08-auth.md#loginrequest), - [`RegisterRequest`](group-08-auth.md#registerrequest) (via `MMCA.Common.Shared.Auth`). -- **Concept, the UI-layer auth boundary.** `[Rubric §3, Clean Architecture]` (assesses that the UI - auth surface depends only on Shared DTOs, never Application/Domain) and `[Rubric §11, Security]` - (assesses token handling behind a service abstraction, not in page components). This interface lives - in `MMCA.Common.UI` and references only `MMCA.Common.Shared.Auth` request/response records, so page - components talk to it without pulling in any backend layer. -- **Walkthrough**: a `LastError` string property (`IAuthUIService.cs:12`, the last failure message, or - null), plus `LoginAsync` (`:15`), `RegisterAsync` (`:18`), `ExchangeOAuthCodeAsync` (`:25`, which - swaps a single-use OAuth completion code for the token pair via `auth/oauth/exchange`, keeping tokens - out of the address bar), `LogoutAsync` (`:28`), `TryRefreshTokenAsync` (`:31`), and - `ChangePasswordAsync` (`:34`). The `LoginAsync`/`RegisterAsync`/`ExchangeOAuthCodeAsync` methods - return a nullable [`AuthenticationResponse`](group-08-auth.md#authenticationresponse) (null on - failure). -- **Why it's built this way**: exposing auth as a UI-layer contract keeps components free of HTTP and - token mechanics and preserves the layered dependency rule (UI depends on Shared only). -- **Where it's used**: implemented by [`AuthUIService`](#authuiservice); injected into the login, - register, profile, and session-refresh Blazor components. - -### AuthUIService -> MMCA.Common.UI · `MMCA.Common.UI.Services.Auth` · `MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthUIService.cs:15` · Level 6 · class (sealed) - -- **What it is**: the concrete [`IAuthUIService`](#iauthuiservice): it drives the full client auth - lifecycle (login, register, OAuth exchange, logout, refresh, password change) by calling the - `auth/*` endpoints, persisting tokens via [`ITokenStorageService`](#itokenstorageservice), and - pushing state through [`JwtAuthenticationStateProvider`](#jwtauthenticationstateprovider). -- **Depends on**: [`IAuthUIService`](#iauthuiservice) (Level 5), - [`ITokenStorageService`](#itokenstorageservice) (Level 0), - [`ITokenRefresher`](#itokenrefresher) (Level 0), - [`JwtAuthenticationStateProvider`](#jwtauthenticationstateprovider) (Level 1), - [`IPushRegistrationService`](group-26-device-capability-layer.md#ipushregistrationservice), - [`AuthenticationResponse`](group-08-auth.md#authenticationresponse), - [`LoginRequest`](group-08-auth.md#loginrequest), - [`RegisterRequest`](group-08-auth.md#registerrequest), - [`OAuthCodeExchangeRequest`](group-08-auth.md#oauthcodeexchangerequest), - [`ChangePasswordRequest`](group-08-auth.md#changepasswordrequest); NuGet/BCL - (`IHttpClientFactory`, `System.Net.Http.Json`, `ProblemDetails`, Blazor - `AuthenticationStateProvider`). -- **Concept, centralized UI auth orchestration.** `[Rubric §11, Security]` and `[Rubric §26, Front-End - Security]` (assess that token storage/refresh flow through service abstractions, never raw storage in - page code), `[Rubric §19, State Management]` (assesses coordinating auth-state notifications), and - `[Rubric §29, Resilience & Business Continuity]` (assesses best-effort side effects that never block - the primary flow). The doc comment (`AuthUIService.cs:9-13`) notes it also guards - `InvalidOperationException` around JS interop during SSR prerender. + - The primary constructor takes `IHttpClientFactory`, `ITokenStorageService` and a `string endpoint`, + forwarding the first two to `AuthenticatedServiceBase` (`ChildEntityServiceBase.cs:17-20`). The + endpoint is captured as a primary-constructor parameter rather than exposed as a property, so + subclasses cannot rewrite it after construction; contrast + [`EntityServiceBase`](#entityservicebasetentitydto-tidentifiertype), + which surfaces `protected string Endpoint { get; }` because its own methods build sub-paths from it. + - `PostAsync(TRequest request, CancellationToken)` (line 24) creates an authenticated client + with `using var` (line 26), POSTs the payload as JSON to the relative endpoint URI (line 27), calls + [`ServiceExceptionHelper.ThrowIfDomainExceptionAsync`](#serviceexceptionhelper) on a non-success + status (lines 29-32), then `EnsureSuccessStatusCode()` (line 34) and returns the raw + `HttpResponseMessage`. Returning the response rather than a DTO is what lets each subclass decide how + to read the body. `TRequest` is generic precisely because join payloads are usually anonymous objects + (the doc comment says so at line 23). + - `DeleteByIdAsync(string id, CancellationToken)` (line 39) builds `"{endpoint}/{id}"` (line 42) and + treats `404 NotFound` as `false` rather than an exception (lines 45-48), so "already gone" is a + result, not a failure. Other non-success statuses go through the same domain-error extraction and + `EnsureSuccessStatusCode()` (lines 50-55) before returning `true`. + - The id parameter is a `string`, not a generic identifier type: subclasses format their own typed id + before calling, for example `id.ToString(CultureInfo.InvariantCulture)` in + `EventSpeakerService.DeleteAsync` + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/ChildEntityServices.cs:24`). +- **Why it's built this way**: join endpoints sit behind `[Authorize]` exactly like their parent CRUD + endpoints, so they need the same Bearer-token plumbing and the same domain-error translation, but none + of the paging, filtering or lookup machinery. Deriving from + [`AuthenticatedServiceBase`](#authenticatedservicebase) rather than from + [`EntityServiceBase`](#entityservicebasetentitydto-tidentifiertype) + reuses the auth path while keeping the surface honest. Note the deliberate asymmetry with its sibling: + `PostAsync` sends **no** `Idempotency-Key`, so a duplicate join is stopped by the domain invariant and + the unique index behind it rather than by request deduplication (the opt-in server-side model is + ADR-017, `Website/docs-src/adr/017-request-idempotency.md`). +- **Where it's used**: four ADC Conference join services derive from it, + [`EventSpeakerService`](group-21-conference-ui.md#eventspeakerservice) on `eventspeakers`, + [`SessionSpeakerService`](group-21-conference-ui.md#sessionspeakerservice) on `sessionspeakers`, + [`SessionCategoryItemService`](group-21-conference-ui.md#sessioncategoryitemservice) on + `sessioncategoryitems`, and + [`SpeakerCategoryItemService`](group-21-conference-ui.md#speakercategoryitemservice) on + `speakercategoryitems`, all four declared in + `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/ChildEntityServices.cs:15,31,47,63`. + Each adds a typed `AddAsync`/`DeleteAsync` pair over the two protected methods and implements its own + module interface. The base is pinned by + [`ChildEntityServiceBaseTests`](group-27-testing-infrastructure.md#childentityservicebasetests) through + a minimal `MembershipService` subclass. +- **Caveats**: `PostAsync` returns the `HttpResponseMessage` after the client it was created from has + been disposed by the enclosing `using var` (lines 26, 35). Reading the body afterwards works in every + current subclass because the content is already buffered by the time the call returns, but the + disposal ordering is a sharp edge a new subclass could cut itself on. Neither method routes through + `RetryPolicy`: the policy is inherited from [`AuthenticatedServiceBase`](#authenticatedservicebase) but + never invoked here, so joins are single-attempt. + +### EntityServiceBase +> MMCA.Common.UI · `MMCA.Common.UI.Services` · `MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/EntityServiceBase.cs:25` · Level 3 · class (abstract) + +- **What it is**: the CRUD workhorse of the UI layer. It implements + [`IEntityService`](#ientityservicetentitydto-tidentifiertype) against a + REST endpoint by turning each operation into a URL plus a one-line HTTP lambda, and funnels every one + of them through a single dispatch method that owns retry, idempotency, error translation and + deserialization. +- **Depends on**: [`AuthenticatedServiceBase`](#authenticatedservicebase) (base class), + [`IEntityService`](#ientityservicetentitydto-tidentifiertype) + (implemented interface), + [`IBaseDTO`](group-12-api-hosting-mapping.md#ibasedtotidentifiertype) (the + `TEntityDTO` constraint, `EntityServiceBase.cs:29`), + [`BaseLookup`](group-12-api-hosting-mapping.md#baselookuptidentifiertype), + [`CollectionResult`](group-01-result-error-handling.md#collectionresultt), + [`PagedCollectionResult`](group-01-result-error-handling.md#pagedcollectionresultt) and its + [`PaginationMetadata`](group-01-result-error-handling.md#paginationmetadata), + [`IdempotencyHeaders`](group-08-auth.md#idempotencyheaders), + [`ITokenStorageService`](#itokenstorageservice), + [`ServiceExceptionHelper`](#serviceexceptionhelper); Polly (through the inherited `RetryPolicy`) and + `System.Net.Http.Json` (BCL). +- **Concept introduced, one dispatch point for every cross-cutting HTTP concern.** + `[Rubric §10, Cross-Cutting Concerns]` assesses whether retry, auth and error handling are applied in + one place instead of repeated per call: here the six public methods contain only URL construction, and + `SendRequestAsync` (line 183) contains all of the policy. `[Rubric §19, State Management & Data + Flow]` applies because components never touch `HttpClient`: they inject the typed interface and receive + DTOs. `[Rubric §29, Resilience & Business Continuity]` applies through the inherited three-retry + exponential-backoff-with-jitter policy + (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/AuthenticatedServiceBase.cs:26-32`), whose + predicate retries 5xx plus 408 and 429 but not 501 or 505 (`AuthenticatedServiceBase.cs:108-117`). +- **Concept introduced, retry safety for a non-idempotent verb.** `[Rubric §9, API & Contract Design]` + assesses whether the client and server share an explicit protocol for duplicate writes. A retry policy + that re-issues a POST is a correctness hazard: if the first attempt reached the server and only the + response was lost, the retry creates a second record. `AddAsync` is the one method that passes an + idempotency key (`EntityServiceBase.cs:135`), generated once per logical operation by + `AuthenticatedServiceBase.NewIdempotencyKey()` as a compact GUID (`AuthenticatedServiceBase.cs:51`). + The key is set as a **default request header** on the client (line 199) rather than on an individual + request, and that one client instance serves every retry attempt, so all attempts carry the identical + value (the comment at lines 195-198 spells out that this is the point). The server side of the protocol + is the opt-in [`IdempotencyFilter`](group-12-api-hosting-mapping.md#idempotencyfilter), and both ends + read the header name from the shared + [`IdempotencyHeaders`](group-08-auth.md#idempotencyheaders) constant rather than hard-coding the literal + twice (`MMCA.Common/Source/Core/MMCA.Common.Shared/Http/IdempotencyHeaders.cs:19`). Reads, full-PUT + updates and deletes send no key because they are naturally idempotent (comment at + `EntityServiceBase.cs:128-130`). - **Walkthrough** - - `LoginAsync` (`AuthUIService.cs:26`) and `RegisterAsync` (`:72`) follow one shape: POST the request - to `auth/login`/`auth/register`; on a non-success status, read a `ProblemDetails` body into - `LastError` (falling back to a generic message) and return `null` (`:32-46`, `:78-92`); on success, - read [`AuthenticationResponse`](group-08-auth.md#authenticationresponse), bail on a blank access - token, persist the pair via `SetTokensAsync` inside a `try/catch (InvalidOperationException)` for - prerender, then call `NotifyUserAuthentication` when the provider is a - [`JwtAuthenticationStateProvider`](#jwtauthenticationstateprovider) (`:48-69`, `:94-114`). - - `ExchangeOAuthCodeAsync` (`:117`): rejects a blank code up front (`:121-125`), then POSTs an - [`OAuthCodeExchangeRequest`](group-08-auth.md#oauthcodeexchangerequest) to `auth/oauth/exchange` and - follows the same success/failure handling, keeping tokens out of the URL. - - `LogoutAsync` (`:172`): first best-effort `pushRegistration.UnregisterAsync()` while the token is - still valid (native-push cleanup, [ADR-044](https://ivanball.github.io/docs/adr/044-native-push-delivery.html)), wrapped in a `CA1031`-suppressed catch so a failure - never blocks sign-out (`:177-186`); then a best-effort authenticated `auth/revoke` POST - (`:188-205`); then `ClearTokensAsync` and `NotifyUserLogout` (`:207-219`). - - `TryRefreshTokenAsync` (`:222`): delegates to - [`ITokenRefresher.AcquireAccessTokenAsync`](#itokenrefresher); a null result means the session - cannot be refreshed, so it clears tokens, notifies logout, and returns `false`; a token notifies - authentication and returns `true` (`:227-253`). - - `ChangePasswordAsync` (`:256`): manually attaches the Bearer token from circuit-scoped storage - (the comment at `:263` notes `AuthDelegatingHandler` has Blazor Server scope issues), then PUTs a - [`ChangePasswordRequest`](group-08-auth.md#changepasswordrequest) to `auth/password` and returns - the success flag. -- **Why it's built this way**: routing every auth operation through one service keeps components free - of HTTP and token mechanics; the pervasive `InvalidOperationException` guards keep an operation from - crashing when JS interop is unavailable during prerender; and the best-effort push/revoke steps - ensure sign-out always completes locally even when a remote call fails ([ADR-044](https://ivanball.github.io/docs/adr/044-native-push-delivery.html)). -- **Where it's used**: registered as the [`IAuthUIService`](#iauthuiservice) implementation on the web - and MAUI heads; injected into login, register, profile, and session-refresh components. The - `NoOpAuthUIService` in the component gallery is the backend-less stand-in for gallery rendering. -- **Caveats / not-in-source**: several catch blocks around `ProblemDetails` parsing and `auth/revoke` - swallow all exceptions deliberately (a failed error-detail read or revoke must not derail the flow); - the concrete `AuthenticationStateProvider` is injected by its base type and pattern-matched to - [`JwtAuthenticationStateProvider`](#jwtauthenticationstateprovider) at each notification site, so a - different provider registration would silently skip the notify calls. + - The primary constructor takes `endpoint`, `IHttpClientFactory` and `ITokenStorageService` + (lines 25-28); note the parameter order differs from + [`ChildEntityServiceBase`](#childentityservicebase). The endpoint is republished as + `protected string Endpoint { get; }` (line 32) because the read methods append sub-paths to it. Both + type parameters are constrained: `TEntityDTO : IBaseDTO` and + `TIdentifierType : notnull` (lines 29-30). + - `GetAllAsync(includeFKs, includeChildren, ct)` (line 34) builds a two-parameter query string and + deserializes into `PagedCollectionResult`, returning `Items` or an empty list + (lines 46-50). The "all" endpoint returns the paged envelope, not a bare array. + - `GetPagedAsync(filters, pageNumber, pageSize, sortColumn, sortDirection, includeChildren, ct)` + (line 53) is the one with real work. Page numbers are formatted with + `string.Create(CultureInfo.InvariantCulture, ...)` (lines 64-65) so a comma-decimal locale cannot + corrupt the query, and every filter property, operator and value is passed through + `Uri.EscapeDataString` (lines 77-79). Filters serialize as `filters[Property].operator=` plus an + optional `filters[Property].value=`, and a filter whose operator is blank is skipped entirely + (line 75), which is how a grid clears a column filter. It targets `{Endpoint}/paged` (line 84) and + returns a tuple of items plus `PaginationMetadata.TotalItemCount` (line 89), the two things a + server-side data grid needs. + - `GetAllForLookupAsync(nameProperty, ct)` (line 92) hits `{Endpoint}/lookup` and deserializes + `CollectionResult>` (line 97), the lightweight id-plus-name shape that + feeds dropdowns and autocompletes. + - `GetByIdAsync(id, includeChildren, ct)` (line 104) is the only read that passes + `treatNotFoundAsDefault: true` (line 118), so a 404 becomes `null` instead of an exception. + - `AddAsync(entity, ct)` (line 122) POSTs with `throwIfNull: true` and the idempotency key + (lines 134-135), and throws again at the call site if the dispatch still returned null (line 136). + - `UpdateAsync(entity, ct)` (line 139) PUTs to `{Endpoint}/{GetEntityId(entity)}` with + `expectContent: false` (line 147) and always returns `true`; `DeleteAsync(id, ct)` (line 152) does the + same for DELETE (lines 157-162). Both rely on the dispatch to throw on failure, so `true` means "no + exception", not "the server reported a change". + - `GetEntityId(entity)` (line 165) is `protected virtual` and simply returns `entity.Id`, the hook a + subclass overrides when the route key is not the DTO's own id. + - `SendRequestAsync(httpAction, ct, treatNotFoundAsDefault, throwIfNull, expectContent, idempotencyKey)` + (line 183) is the center of the class. It creates the authenticated client (line 191), attaches the + idempotency header when one was supplied (lines 193-200), executes the caller's lambda through + `RetryPolicy` with the cancellation token threaded in so a cancelled operation does not sleep out its + backoff (lines 202-204), short-circuits 404 to `default` when asked (lines 206-207), calls + [`ServiceExceptionHelper.ThrowIfDomainExceptionAsync`](#serviceexceptionhelper) **before** + `EnsureSuccessStatusCode()` so a domain failure surfaces as a readable message (lines 209-213), + returns `default` when no body is expected (lines 215-216), and finally deserializes and optionally + null-checks the payload (lines 218-221). +- **Why it's built this way**: passing the HTTP call as a `Func>` + lets each verb stay a two-line method while every policy decision lives once. The ordering inside the + dispatch is the load-bearing part: domain-error extraction has to run before `EnsureSuccessStatusCode()` + or the readable message is lost inside a generic `HttpRequestException`, and the idempotency header has + to be set on the client rather than per request or each retry would carry a different key. All six + public methods are `virtual`, so a module service overrides only the one that needs domain-specific + behavior and inherits the rest. +- **Where it's used**: it is the base of essentially every module CRUD service. In this package, + [`PushNotificationService`](#pushnotificationservice) + (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/PushNotificationService.cs:19`). + In ADC Conference, [`EventService`](group-21-conference-ui.md#eventservice), + [`SessionService`](group-21-conference-ui.md#sessionservice), + [`SpeakerService`](group-21-conference-ui.md#speakerservice) and + [`SponsorService`](group-21-conference-ui.md#sponsorservice) among others; in ADC Identity, + [`UserService`](group-24-identity-module.md#userservice). In Store, `ProductService`, `CategoryService`, + `OrderService`, `ShoppingCartService`, `InventoryItemService` and `CustomerService`. Behavior is pinned + by [`EntityServiceBaseTests`](group-27-testing-infrastructure.md#entityservicebasetests) and, for the + write-safety half, by + [`EntityServiceBaseIdempotencyRetryTests`](group-27-testing-infrastructure.md#entityservicebaseidempotencyretrytests), + which asserts the key is emitted on creates only and stays identical across attempts. +- **Caveats**: `UpdateAsync` and `DeleteAsync` return a hard-coded `true` with no path that returns + `false`, so a caller cannot distinguish "updated" from "server accepted a no-op". `GetAllAsync` has no + page-size bound in source: it asks the "all" endpoint for everything and materializes the result, which + is why grids use `GetPagedAsync` instead. The bearer token is applied by + [`AuthenticatedServiceBase`](#authenticatedservicebase)`.CreateAuthenticatedClientAsync` rather than by + the [`AuthDelegatingHandler`](#authdelegatinghandler), because handlers created by `IHttpClientFactory` + live in a different DI scope than the Blazor circuit that holds the token + (`AuthenticatedServiceBase.cs:53-58`). ### BackNavigationResult > MMCA.Common.UI · `MMCA.Common.UI.Services.Navigation` · `MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/MauiBackNavigationBridge.cs:19` · Level 0 · record (sealed) diff --git a/docs-src/onboarding/group-17-conference-domain.md b/docs-src/onboarding/group-17-conference-domain.md index 1d16d15..b6320e6 100644 --- a/docs-src/onboarding/group-17-conference-domain.md +++ b/docs-src/onboarding/group-17-conference-domain.md @@ -4,9 +4,10 @@ **Conference bounded context**, the largest and richest domain in MMCA.ADC. It models everything an organizer curates and an attendee browses: the **Event** (the conference itself, with its rooms, speaker roster, and venue details), the **Session** (a talk on the schedule), the **Speaker**, the -**Sponsor** (the sold sponsorship and expo-booth record), the **Category**/**CategoryItem** taxonomy +**Sponsor** (the sold sponsorship and expo-booth record), the **Activity** (the party, coffee connect, +or closing ceremony that is deliberately not a session), the **Category**/**CategoryItem** taxonomy (tracks, levels, session formats), and the **Question**/answer machinery that captures structured -metadata about events, sessions, and speakers. Seven aggregate roots (one of them an AI scorecard), a +metadata about events, sessions, and speakers. Eight aggregate roots (one of them an AI scorecard), a dozen child entities, the static **invariant** classes that guard their business rules, the **domain events** every mutation raises, a pure **domain service** that coordinates the cross-aggregate cascade delete, and, across the package boundary in `MMCA.ADC.Conference.Shared`, the **DTO contracts**, the @@ -54,7 +55,7 @@ anchor pair in `Domain` trivial type that assembly scanning and the architecture-fitness tests pin to when they need to *name* the Conference domain assembly. -## Seven aggregates and their ownership boundaries +## Eight aggregates and their ownership boundaries An **aggregate** is a root entity plus the children it exclusively owns; invariants are enforced *inside* the boundary, and references *across* aggregates are by ID, never by object graph. Every root @@ -67,10 +68,10 @@ so it inherits soft-delete, audit stamping, and the buffered `DomainEvents` coll [`Room`](#room) (the physical rooms), [`EventSpeaker`](#eventspeaker) (the speaker roster, a join to `Speaker` *by ID*), and [`EventQuestionAnswer`](#eventquestionanswer) (event-level structured answers). Its `Id` is database-generated (marked `[IdValueGenerated]`, `Event.cs:22`), and it also - carries the per-event live-layer moderation default (`Event.cs:71`), the published flag - (`Event.cs:65`), the organizer contact email and sponsorship packet URL that drive the public pages - (`Event.cs:56,62`), and the Sessionize refresh stamp written by `RecordSessionizeRefresh` - (`Event.cs:74,77,302`). + carries the per-event live-layer moderation default (`Event.cs:77`), the published flag + (`Event.cs:71`), the organizer contact email, sponsorship packet URL, and ticketing URL that drive + the public pages (`Event.cs:56,62,68`, each of which the pages hide entirely when absent), and the + Sessionize refresh stamp written by `RecordSessionizeRefresh` (`Event.cs:80,83,316`). - [`Session`](#session) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:22`) owns [`SessionSpeaker`](#sessionspeaker), [`SessionCategoryItem`](#sessioncategoryitem), and @@ -87,15 +88,29 @@ so it inherits soft-delete, audit stamping, and the buffered `DomainEvents` coll holds an optional `Email` [value object](group-02-domain-building-blocks.md#email) (`Speaker.cs:31`), and carries the cross-module `LinkedUserId` FK to an Identity `User` (`Speaker.cs:58`). Speaker `Id`s are Sessionize-assigned GUIDs, with a fallback to `Guid.NewGuid()` for organizer-created and seeded - speakers (see the in-code note at `Speaker.cs:148-153`). + speakers (see the in-code note at `Speaker.cs:156-161`). - [`Sponsor`](#sponsor) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:18`) is a flat root belonging to exactly one event by scalar `EventId` (`Sponsor.cs:45`, with a read-only - `[Navigation]` `Event` for public visibility filtering at `Sponsor.cs:49`). It carries a + `[Navigation]` `Event` for public visibility filtering at `Sponsor.cs:48-49`). It carries a [`SponsorTier`](#sponsortier) that drives public placement, branding links, and the optional expo booth (`IsExhibitor`/`BoothNumber`, `Sponsor.cs:52,58`); its `Id` is database-generated (`Sponsor.cs:17`) because sponsors are sold, not imported from Sessionize. Moving a sponsor between - events is deliberately not an update: `Update` omits the event entirely (`Sponsor.cs:153`). + events is deliberately not an update: `Update` omits the event entirely (`Sponsor.cs:153-163`). +- [`Activity`](#activity) + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:20`) is the + social and networking programme: the pre-conference party, the morning coffee connect, the + after-party, the closing ceremony. It is deliberately *not* a session, and the type's own doc comment + says why (`Activity.cs:11-18`): an activity has no room and no speakers, and it frequently happens at + an external venue, so the venue travels on the activity itself (`VenueName`, `VenueAddress`, + `VenueUrl` at `Activity.cs:42,45,48`) instead of being inherited from the event. Its `Id` is + database-generated (`Activity.cs:19`) because activities are planned, not imported; it belongs to one + event by scalar `EventId` with a read-only `[Navigation]` for visibility filtering + (`Activity.cs:54,57-58`); `StartTime`/`EndTime` are plain wall-clock `DateTime`s in the owning event's + IANA zone, exactly like `Session.StartsAt`, with the zone kept on the event and never repeated per row + (`Activity.cs:29-36`); and `SortOrder` breaks ties between activities starting at the same minute + (`Activity.cs:51`). Like `Sponsor`, moving it between events is a create plus a delete rather than an + update (`Activity.cs:134,145`). - [`Category`](#category) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/Category.cs:16`) owns [`CategoryItem`](#categoryitem): the taxonomy roots ("Level", "Track", "Session format") and their @@ -126,7 +141,8 @@ Three of the roots opt into the framework's change-history trail by also impleme `Session` (`Session.cs:22`), and `Speaker` (`Speaker.cs:22`). The in-code rationale is worth reading (`Event.cs:16-20`, `Session.cs:16-20`, `Speaker.cs:15-20`): these three are written by organizers *and* overwritten by the Sessionize sync, and "which edit moved this, and was it a person or the importer" is -a question that only a history answers. Sponsors, categories, and questions do not carry that cost. +a question that only a history answers. Sponsors, activities, categories, and questions do not carry +that cost. ## The aggregate shape, taught once @@ -138,45 +154,54 @@ exemplar: through the aggregate's own methods, never by an outside caller assigning a property. This is encapsulation as a compile-time guarantee (`[Rubric §4, Domain-Driven Design]`, `[Rubric §1, SOLID]`). -2. **Backing-field collections exposed as `IReadOnlyCollection`** (`_rooms` at `Event.cs:79` becomes - `Rooms => _rooms.AsReadOnly()` at `Event.cs:83`). Children can only be added, updated, or removed +2. **Backing-field collections exposed as `IReadOnlyCollection`** (`_rooms` at `Event.cs:85` becomes + `Rooms => _rooms.AsReadOnly()` at `Event.cs:89`). Children can only be added, updated, or removed through `AddRoom`/`UpdateRoom`/`RemoveRoom`-style methods that enforce invariants (for example the - duplicate-name rejection at `Event.cs:687-704`). Most collections are decorated + duplicate-name rejection at `Event.cs:716-733`). Most collections are decorated `[Navigation(IsCollection = true)]` so the navigation-populator machinery (G11) eager-loads them, - but two deliberately are **not**: `Event.EventQuestionAnswers` (`Event.cs:93-103`) and - `Session.SessionQuestionAnswers` (`Session.cs:92-104`) opt out because those collections grow with + but two deliberately are **not**: `Event.EventQuestionAnswers` (`Event.cs:97-109`) and + `Session.SessionQuestionAnswers` (`Session.cs:90-104`) opt out because those collections grow with attendance rather than with the schedule and were riding along on hot anonymous public reads that - never render them. Handlers that genuinely need them pass an explicit `includes:` list. That is a - `[Rubric §12, Performance & Scalability]` decision expressed as a deliberately absent attribute. -3. **A private EF Core constructor** (`Event.cs:106`, for materialization) plus a **private state - constructor** (`Event.cs:112`) used only by the factory. + never render them; the session answers are also the one child collection here that is not public + data (`Session.cs:99-102`). Handlers that genuinely need them pass an explicit `includes:` list. + That is a `[Rubric §12, Performance & Scalability]` decision expressed as a deliberately absent + attribute. +3. **A private EF Core constructor** (`Event.cs:112`, for materialization) plus a **private state + constructor** (`Event.cs:118`) used only by the factory. 4. **A static `Create(...)` factory returning [`Result`](group-01-result-error-handling.md#result)** - (`Event.cs:155`): it validates invariants via `Result.Combine(...)` *before* constructing anything - (`Event.cs:170-173`), so an invalid aggregate is unrepresentable, then raises an `Added` domain - event (`Event.cs:196`). The `isIdValueGenerated ? default : id!.Value` dance (`Event.cs:177,192`) + (`Event.cs:164`): it validates invariants via `Result.Combine(...)` *before* constructing anything + (`Event.cs:180-183`), so an invalid aggregate is unrepresentable, then raises an `Added` domain + event (`Event.cs:207`). The `isIdValueGenerated ? default : id!.Value` dance (`Event.cs:187,203`) reconciles database-generated IDs with explicitly supplied ones. Each root spells that reconciliation slightly differently: [`Speaker`](#speaker) generates a GUID when no id is supplied - (`Speaker.cs:153`), and [`Category`](#category) throws for a missing id when identity is not - database-generated (`Category.cs:69`). -5. **Mutator methods** (`Update` at `Event.cs:217`, `Publish`/`Unpublish` at `Event.cs:258,278`, - `LinkUser`/`UnlinkUser` on Speaker at `Speaker.cs:260,278`) that re-validate, mutate, and raise an + (`Speaker.cs:161`), [`Category`](#category) throws for a missing id when identity is not + database-generated (`Category.cs:69`), and [`Activity`](#activity) uses the plain `Event` form + (`Activity.cs:120-124`). +5. **Mutator methods** (`Update` at `Event.cs:229`, `Publish`/`Unpublish` at `Event.cs:272,292`, + `LinkUser`/`UnlinkUser` on Speaker at `Speaker.cs:272,290`) that re-validate, mutate, and raise an `Updated` event. Lifecycle guards return failures rather than throwing: publishing an already - published event yields the `"Event.AlreadyPublished"` invariant error (`Event.cs:262-266`). -6. **An overridden `Delete()`** (`Event.cs:314`) that calls `base.Delete()` (the soft-delete from G02), - then **cascade-soft-deletes each owned child** (rooms, event speakers, and event answers at - `Event.cs:320-339`) and raises a `Deleted` event (`Event.cs:341`). [`Session`](#session) does the - same for its three child collections (`Session.cs:283-304`), [`Category`](#category) for its items - (`Category.cs:109-116`), [`Sponsor`](#sponsor) has nothing to cascade to and simply raises its - `Deleted` event (`Sponsor.cs:190-197`), and [`Speaker`](#speaker) uses its override for a different - job: clearing the cross-context link (`Speaker.cs:239-253`). Soft-delete is the default everywhere - (`[Rubric §8, Data Architecture]`: the `IsDeleted` flag plus EF Core global query filters, never a - hard `DELETE`; [ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html)). -7. **Restore methods for the Sessionize round-trip** (`RestoreRoom` at `Event.cs:439`, - `RestoreEventSpeaker` at `Event.cs:548`, and the equivalents on Session and Speaker): a re-imported + published event yields the `"Event.AlreadyPublished"` invariant error (`Event.cs:274-281`). +6. **An overridden `Delete()`** (`Event.cs:328`) that calls `base.Delete()` (the soft-delete from G02, + `Event.cs:330`), then **cascade-soft-deletes each owned child** (rooms, event speakers, and event + answers at `Event.cs:334-353`) and raises a `Deleted` event (`Event.cs:355`). + [`Session`](#session) does the same for its three child collections (`Session.cs:283-304`), + [`Category`](#category) for its items (`Category.cs:109-116`), [`Sponsor`](#sponsor) and + [`Activity`](#activity) have nothing to cascade to and simply raise their `Deleted` events + (`Sponsor.cs:190-197`, `Activity.cs:180-188`), and [`Speaker`](#speaker) uses its override for a + different job: clearing the cross-context link while deliberately leaving its junction children + alive so the Sessionize import can reactivate them in place (`Speaker.cs:244-267`). Soft-delete is + the default everywhere (`[Rubric §8, Data Architecture]`: the `IsDeleted` flag plus EF Core global + query filters, never a hard `DELETE`; + [ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html)). +7. **Restore methods for the Sessionize round-trip** (`RestoreRoom` at `Event.cs:454`, + `RestoreEventSpeaker` at `Event.cs:577`, and the equivalents on Session and Speaker): a re-imported child that was previously soft-deleted is reactivated in place rather than re-inserted. A restore has to clear the same uniqueness bar as an add, which is why `RestoreRoom` re-runs the duplicate-name - check before reactivating (`Event.cs:455`). -8. **`internal SetX(...)` methods** (`Event.cs:500,596,673`) delegating to the framework's `SetItems` + check before reactivating (`Event.cs:484`), and it first refuses any room owned by a different event + (`Event.cs:463-470`): room ids come from a global Sessionize sequence, `Room.EventId` has no setter, + and adding a foreign room to this collection would let EF relationship fixup silently move the row + (`Event.cs:458-462`). +8. **`internal SetX(...)` methods** (`Event.cs:529,625,702`) delegating to the framework's `SetItems` helper: the hooks the navigation populators call to hydrate the read-only collections after a batch load. @@ -189,37 +214,40 @@ joins, the three `*QuestionAnswer` types) and their `*Changed` domain events are Each aggregate has a co-located static **invariant class**, [`EventInvariants`](#eventinvariants) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:10`), [`SessionInvariants`](#sessioninvariants), [`SpeakerInvariants`](#speakerinvariants), -[`SponsorInvariants`](#sponsorinvariants), [`CategoryInvariants`](#categoryinvariants), and -[`QuestionInvariants`](#questioninvariants), whose methods each return a -[`Result`](group-01-result-error-handling.md#result) and are combined with `Result.Combine(...)` in the -factory and mutators. They build on +[`SponsorInvariants`](#sponsorinvariants), [`ActivityInvariants`](#activityinvariants), +[`CategoryInvariants`](#categoryinvariants), and [`QuestionInvariants`](#questioninvariants), whose +methods each return a [`Result`](group-01-result-error-handling.md#result) and are combined with +`Result.Combine(...)` in the factory and mutators. They build on [`CommonInvariants`](group-02-domain-building-blocks.md#commoninvariants) (G02) for the generic string-not-empty and max-length checks and add domain-specific rules. They also carry the **length constants shared with the EF Core configuration**, so the domain rule and the column constraint can -never drift (`EventInvariants.cs:13-49`, +never drift (`EventInvariants.cs:13-55`, `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:13-34`, `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/SpeakerInvariants.cs:13-40`, -`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/SponsorInvariants.cs:13-31`). +`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/SponsorInvariants.cs:13-31`, +`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/ActivityInvariants.cs:13-25`). The domain-specific rules are where the ubiquitous language shows up. `SessionInvariants` holds the BR-91 service-session guard (`SessionInvariants.cs:91`), the BR-49 status-eligibility check (`SessionInvariants.cs:107`), the BR-122 zero-duration guard whose failure code is `"Session.Duration.Invalid"` (`SessionInvariants.cs:124-136`), and the reserved manual id range `999_999_000` through `999_999_999` for sessions that did not come from Sessionize -(`SessionInvariants.cs:41-44`, mirrored for questions at +(`SessionInvariants.cs:41-44`, mirrored for rooms at `EventInvariants.cs:62-65` and for questions at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:37-40`). `QuestionInvariants` validates the free-text enum-like fields against allow-lists (`QuestionInvariants.cs:28,31,34`, checked at `:68,83,98`) and, for answers, checks each value against -its question type: Rating must parse as an integer 1 through 5 (`QuestionInvariants.cs:130`), Text is -capped at 2000 characters (`QuestionInvariants.cs:25`), and Email must parse as a -`System.Net.Mail.MailAddress` (`QuestionInvariants.cs:160`). `CategoryInvariants` enforces -case-insensitive uniqueness of an item name within its category (BR-138, +its question type: Rating must parse as an invariant-culture integer 1 through 5 +(`QuestionInvariants.cs:130`), Text is capped at 2000 characters (`QuestionInvariants.cs:25`), and +Email must parse as a `System.Net.Mail.MailAddress` (`QuestionInvariants.cs:160`). +`ActivityInvariants` is the compact newcomer: name, venue name, venue address, and venue URL length +checks plus a start-before-end time-range rule (`ActivityInvariants.cs:33,45,57,69,82`). +`CategoryInvariants` enforces case-insensitive uniqueness of an item name within its category (BR-138, `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:37`), and its in-code note explains why the exclusion parameter is nullable rather than defaulted: a database-generated `CategoryItem` id is 0 until the save, so a `default` exclusion would silently exempt every unsaved sibling (`CategoryInvariants.cs:43-45`). Centralizing each rule as a named, side-effect-free method is what makes the domain exhaustively unit-testable (`[Rubric §14, -Testability]`), and the error codes (`"Event.AlreadyPublished"` at `Event.cs:263`, +Testability]`), and the error codes (`"Event.AlreadyPublished"` at `Event.cs:277`, `"Session.StatusIneligible"` at `SessionInvariants.cs:110`) *are* the business vocabulary. The recurring `// BR-NN` comments are traceability links back to the business-requirements catalogue. @@ -244,8 +272,8 @@ externally sourced data). Every state-changing method raises a domain event through the inherited `AddDomainEvent(...)`. The events come in two shapes. The **aggregate-level** ones, [`EventChanged`](#eventchanged), [`SessionChanged`](#sessionchanged), [`SpeakerChanged`](#speakerchanged), -[`CategoryChanged`](#categorychanged), [`QuestionChanged`](#questionchanged), and -[`SponsorChanged`](#sponsorchanged), derive from +[`CategoryChanged`](#categorychanged), [`QuestionChanged`](#questionchanged), +[`SponsorChanged`](#sponsorchanged), and [`ActivityChanged`](#activitychanged), derive from [`EntityChangedEvent`](group-04-events-outbox.md#entitychangedeventtidentifiertype) and carry the [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate) (Added/Updated/Deleted) plus a friendly label, and sometimes one extra correlating field: @@ -256,7 +284,7 @@ The **child-level** ones, [`RoomChanged`](#roomchanged), [`EventSpeakerChanged`] [`SessionCategoryItemChanged`](#sessioncategoryitemchanged), [`SpeakerCategoryItemChanged`](#speakercategoryitemchanged), [`CategoryItemChanged`](#categoryitemchanged), and the `*QuestionAnswerChanged` set, carry both the parent and child IDs (for example -`RoomChanged(state, Id, room.Id, room.Name)` at `Event.cs:381`) so a consumer can target the precise +`RoomChanged(state, Id, room.Id, room.Name)` at `Event.cs:433`) so a consumer can target the precise change and the module can invalidate the right output-cache tag. These are **intra-module domain events**: they ride the outbox ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)) but are consumed inside Conference. They do not cross the wire to other services; that is the job of @@ -269,7 +297,7 @@ then delivers it at least once. Nothing in this chapter's code does any dispatch only *declare* what happened, which is the Clean Architecture division of labor (`[Rubric §6, CQRS & Event-Driven]`). One domain detail matters for the cross-context link: `Speaker.Delete()` captures the previous `LinkedUserId` *before* clearing it and passes it into the `Deleted` -[`SpeakerChanged`](#speakerchanged) event (`Speaker.cs:242,249,251`), whose optional +[`SpeakerChanged`](#speakerchanged) event (`Speaker.cs:254,261,263`), whose optional `PreviousLinkedUserId` payload field (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/DomainEvents/SpeakerChanged.cs:20`) exists precisely so the cross-context cleanup handler has what it needs even though the field is @@ -278,25 +306,28 @@ already nulled within Conference (BR-70). ## The cross-aggregate cascade: a pure domain service One business rule cannot live inside a single aggregate: deleting an `Event` must also delete every -`Session` belonging to it (BR-127) and every `Sponsor` sold against it, but sessions and sponsors are -*separate* aggregates (referenced by `EventId`, not owned). Putting a `List` inside `Event` -would violate the aggregate boundary. The answer is a **domain service**, -[`IEventCascadeDeletionDomainService`](#ieventcascadedeletiondomainservice) -(`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Services/IEventCascadeDeletionDomainService.cs:13`) +`Session` belonging to it (BR-127), every `Sponsor` sold against it, and every `Activity` planned for +it, but sessions, sponsors, and activities are *separate* aggregates (referenced by `EventId`, not +owned). Putting a `List` inside `Event` would violate the aggregate boundary. The answer is a +**domain service**, [`IEventCascadeDeletionDomainService`](#ieventcascadedeletiondomainservice) +(`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Services/IEventCascadeDeletionDomainService.cs:15`) and its implementation [`EventCascadeDeletionDomainService`](#eventcascadedeletiondomainservice) -(`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Services/EventCascadeDeletionDomainService.cs:14`), +(`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Services/EventCascadeDeletionDomainService.cs:16`), a pure, infrastructure-free coordinator that takes the pre-fetched `Event` plus its already-loaded -`Session` and `Sponsor` collections and orchestrates the deletes: soft-delete each session first (BR-55 -cascades to *its* children), then each sponsor, then the event itself (BR-72 cascades to rooms, event -speakers, and event answers) (`EventCascadeDeletionDomainService.cs:17-39`). The ordering is what makes -the failure path safe: the first child that refuses to delete short-circuits the cascade and returns its -own failure unchanged, so the event is never deleted and the caller (which saves only on success) -discards the aborted in-memory mutations rather than persisting a half-deleted graph -(`EventCascadeDeletionDomainService.cs:19-36`). This is `[Rubric §4, Domain-Driven Design]`'s textbook -"domain service for behavior that spans aggregates and belongs to no single one," and `[Rubric §3, Clean -Architecture]`'s purity discipline: the service does no I/O; the *application* layer fetches the -aggregates and saves them. It is the highest-level type in the chapter precisely because it depends on -three aggregates at once. +`Session`, `Sponsor`, and `Activity` collections (`IEventCascadeDeletionDomainService.cs:28-32`) and +orchestrates the deletes: soft-delete each session first (BR-55 cascades to *its* children), then each +sponsor, then each activity, then the event itself (BR-72 cascades to rooms, event speakers, and event +answers) (`EventCascadeDeletionDomainService.cs:28-55`). The ordering is what makes the failure path +safe: the first child that refuses to delete short-circuits the cascade and returns its own failure +unchanged, so the event is never deleted and the caller (which saves only on success) discards the +aborted in-memory mutations rather than persisting a half-deleted graph +(`EventCascadeDeletionDomainService.cs:25-52`). Activities were folded into the same cascade for the +reason recorded beside the loop: leaving them behind would orphan rows the public activities page still +reads (`EventCascadeDeletionDomainService.cs:44-46`). This is `[Rubric §4, Domain-Driven Design]`'s +textbook "domain service for behavior that spans aggregates and belongs to no single one," and `[Rubric +§3, Clean Architecture]`'s purity discipline: the service does no I/O; the *application* layer fetches +the aggregates and saves them. It is the highest-level type in the chapter precisely because it depends +on four aggregates at once. ## Read models and the AI decision-support feature @@ -305,9 +336,10 @@ API from the domain entities (`[Rubric §9, API & Contract Design]`; [ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html) chose manual/Mapperly mapping over reflection-based AutoMapper). Most are straightforward projections: [`EventDTO`](#eventdto), [`SessionDTO`](#sessiondto), [`SpeakerDTO`](#speakerdto), -[`SponsorDTO`](#sponsordto), [`ConferenceCategoryDTO`](#conferencecategorydto), -[`CategoryItemDTO`](#categoryitemdto), [`QuestionDTO`](#questiondto), [`RoomDTO`](#roomdto), and the -per-child join DTOs ([`EventSpeakerDTO`](#eventspeakerdto), [`SessionSpeakerDTO`](#sessionspeakerdto), +[`SponsorDTO`](#sponsordto), [`ActivityDTO`](#activitydto), +[`ConferenceCategoryDTO`](#conferencecategorydto), [`CategoryItemDTO`](#categoryitemdto), +[`QuestionDTO`](#questiondto), [`RoomDTO`](#roomdto), and the per-child join DTOs +([`EventSpeakerDTO`](#eventspeakerdto), [`SessionSpeakerDTO`](#sessionspeakerdto), [`SessionCategoryItemDTO`](#sessioncategoryitemdto), [`SpeakerCategoryItemDTO`](#speakercategoryitemdto), and the three `*QuestionAnswerDTO` records: [`EventQuestionAnswerDTO`](#eventquestionanswerdto), @@ -318,22 +350,26 @@ and [`TextQuestionResponses`](#textquestionresponses) members (BR-210, `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/SessionFeedbackDTO.cs:6,22,38`). They carry the entity's `Id` via the framework's [`IBaseDTO`](group-12-api-hosting-mapping.md#ibasedtotidentifiertype) contract and -`required init`-only properties: read contracts, immutable after construction. Some also implement +`required init`-only properties: read contracts, immutable after construction. Many also implement [`IConcurrencyAware`](group-12-api-hosting-mapping.md#iconcurrencyaware) and round-trip the `RowVersion` token (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/EventDTO.cs:9,15`, and the -same pair on `SponsorDTO.cs:9,15`), which is what -[`EventTransitionRequest`](#eventtransitionrequest) echoes back on publish and unpublish so a -transition decided against a stale view surfaces as 409 Conflict instead of applying silently +same pair on `Sessions/SessionDTO.cs:9,15`, `Sponsors/SponsorDTO.cs:9,15`, and +`Activities/ActivityDTO.cs:10,16`), which is what [`EventTransitionRequest`](#eventtransitionrequest) +echoes back on publish and unpublish so a transition decided against a stale view surfaces as 409 +Conflict instead of applying silently ([ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html), `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/EventTransitionRequest.cs:14,17`; its own doc comment records that MMCA.Common's `ConcurrencyTokenRequest` supersedes it at the next framework sweep, `EventTransitionRequest.cs:12`). Alongside those sit the small task-shaped contracts: -[`LinkUserRequest`](#linkuserrequest) (the manual speaker-to-user link body, BR-209), +[`LinkUserRequest`](#linkuserrequest) (the manual speaker-to-user link body, BR-209, +`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/LinkUserRequest.cs:6`), [`RefreshFromSessionizeResultDTO`](#refreshfromsessionizeresultdto) (per-entity synced counts, the -BR-136 skipped-soft-deleted count, and non-fatal warnings), and the glanceable -[`NowNextDTO`](#nownextdto)/[`NowNextSessionDTO`](#nownextsessiondto) snapshot behind the public -now-next endpoint (the Android home-screen widget payload, carrying both event-local wall clock and UTC -instants, `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/NowNextDTO.cs:14,29`). +BR-136 skipped-soft-deleted count, and non-fatal warnings, +`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/RefreshFromSessionizeResultDTO.cs:7,28,31`), +and the glanceable [`NowNextDTO`](#nownextdto)/[`NowNextSessionDTO`](#nownextsessiondto) snapshot behind +the public now-next endpoint (the Android home-screen widget payload, carrying both event-local wall +clock and UTC instants, +`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/NowNextDTO.cs:14,29`). A distinct and more interesting subgroup is the **`DecisionSupport`** namespace: read models built purely to help an organizer *curate* a conference. @@ -357,7 +393,7 @@ rather than from a field on `Speaker`: the dashboard handler resolves each speak rows (near-duplicate talks, scored 0.0 to 1.0 with the shared category items and keywords that drove the score, `ContentSimilarityDTO.cs:34-41`) are *not* members of the composite record: they are served by their own endpoint on the same controller -(`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:81`). +(`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:82`). The AI scores are produced by an Anthropic-backed scoring service in `Conference.Infrastructure` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Services/AnthropicScoringService.cs:16`, outside this chapter) and persisted as the [`SessionAiScore`](#sessionaiscore) aggregate; @@ -368,7 +404,7 @@ counts The whole organizer workflow is guarded by the `conference:session-selection:manage` capability permission catalogued in [`ConferencePermissions`](#conferencepermissions) (`ConferencePermissions.cs:30`), applied once at the controller level -(`SessionSelectionController.cs:28`), not by a feature flag. The one flag the module does carry, +(`SessionSelectionController.cs:29`), not by a feature flag. The one flag the module does carry, [`ConferenceFeatures`](#conferencefeatures)`.SessionizeIntegration` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/ConferenceFeatures.cs:15`), gates only the Sessionize external sync that seeds the raw session data this dashboard then analyzes: the @@ -384,14 +420,14 @@ scoring and dashboard handlers are not flag-gated. Two more `Shared` helpers deserve a mention because they encode policy the whole module relies on. [`ConferencePermissions`](#conferencepermissions) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Authorization/ConferencePermissions.cs:9`) -is the catalogue of the module's eight **capability permissions** (`conference:events:manage`, -`conference:sessions:manage`, `conference:sponsors:manage`, and so on, -`ConferencePermissions.cs:12-33`), the stable string identifiers endpoints require via +is the catalogue of the module's nine **capability permissions** (`conference:events:manage`, +`conference:sessions:manage`, `conference:sponsors:manage`, `conference:activities:manage`, and so on, +`ConferencePermissions.cs:12-36`), the stable string identifiers endpoints require via `[HasPermission(...)]` rather than by role name. The `All` and `ContentManagement` subsets -(`ConferencePermissions.cs:36,53`) let a role grant an entire capability set or the narrower -catalog-curation slice (sessions, speakers, sponsors, and the category taxonomy) at once, a distinction -capability checks express centrally and role checks cannot. This is the permission-based authorization -story (`[Rubric §11, Security]`, +(`ConferencePermissions.cs:39,57`) let a role grant an entire capability set or the narrower +catalog-curation slice (sessions, speakers, sponsors, activities, and the category taxonomy) at once, a +distinction capability checks express centrally and role checks cannot. This is the permission-based +authorization story (`[Rubric §11, Security]`, [ADR-020](https://ivanball.github.io/docs/adr/020-permission-based-authorization.html)), decided by the role-to-permission grants declared in the module's registration rather than scattered across controllers. Beside it sits [`ConferenceReadAudience`](#conferencereadaudience) @@ -460,8 +496,8 @@ Readiness]`, `[Rubric §3, Clean Architecture]`): moderation default is the [`QuestionModerationDefault`](#questionmoderationdefault) enum (`Pending = 0`/`Approved = 1`, BR-233, `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/QuestionModerationDefault.cs:7-13`) - carried on the `Event` (`Event.cs:71`, defaulted to `Pending` in both `Create` and `Update`, - `Event.cs:166,227`). The disabled stub, + carried on the `Event` (`Event.cs:77`, defaulted to `Pending` in both `Create` and `Update`, + `Event.cs:175,239`). The disabled stub, [`DisabledEventLiveValidationService`](#disabledeventlivevalidationservice) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/DisabledEventLiveValidationService.cs:22`), deliberately **fails open** on all four: an always-open window and a published flag for events and @@ -489,7 +525,7 @@ Readiness]`, `[Rubric §3, Clean Architecture]`): answer (`SessionFeedbackSubmitted.cs:8-13`). They are also added to the aggregate pre-save with `AddDomainEvent`, so the outbox captures them atomically with the answer in the same `SaveChangesAsync` - (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerHandler.cs:131-136`). + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerHandler.cs:133-136`). All four are the eventually consistent replacement for what would otherwise be direct cross-module service calls: the links and the points ledger survive the service split because they travel as events over the broker ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)/[ADR-008](https://ivanball.github.io/docs/adr/008-service-extraction-topology.html)). @@ -498,19 +534,19 @@ Readiness]`, `[Rubric §3, Clean Architecture]`): To see the chapter cooperate, follow an organizer renaming a room on an event. The application handler loads the [`Event`](#event) aggregate (with its `Rooms` hydrated by the navigation populator), calls -`event.UpdateRoom(...)` (`Event.cs:397`), which routes through the private `GetRoomOrNotFound` helper -(`Event.cs:706-709`, delegating to the framework's `GetChildOrNotFound`, so a missing or soft-deleted +`event.UpdateRoom(...)` (`Event.cs:411`), which routes through the private `GetRoomOrNotFound` helper +(`Event.cs:735-738`, delegating to the framework's `GetChildOrNotFound`, so a missing or soft-deleted room comes back as a `NotFound` [`Result`](group-01-result-error-handling.md#result) rather than an exception), re-checks the case-insensitive room-name uniqueness rule that mirrors the database index -(`Event.cs:411`, implemented at `Event.cs:687-704`), delegates to the child's own `Room.Update(...)` -(which validates *its* invariants), and on success raises a [`RoomChanged`](#roomchanged) `Updated` -event (`Event.cs:419`). The handler calls `SaveChangesAsync`; the interceptor writes the `RoomChanged` -to the outbox in the same transaction; in-process dispatch busts the relevant output-cache tags so the -next read is fresh. No exception was thrown on the expected not-found path, no child was mutated from -outside its aggregate, no event was hand-dispatched, and the same code path would behave identically -whether Conference runs in the monolith or as its own service, which is exactly the property the -framework groups (G01 through G14) exist to provide, here made concrete in a domain you can reason -about. For the *why* behind each design choice, +(`Event.cs:425`, implemented at `Event.cs:716-733`), delegates to the child's own `Room.Update(...)` +(which validates *its* invariants, `Event.cs:429`), and on success raises a +[`RoomChanged`](#roomchanged) `Updated` event (`Event.cs:433`). The handler calls `SaveChangesAsync`; +the interceptor writes the `RoomChanged` to the outbox in the same transaction; in-process dispatch +busts the relevant output-cache tags so the next read is fresh. No exception was thrown on the expected +not-found path, no child was mutated from outside its aggregate, no event was hand-dispatched, and the +same code path would behave identically whether Conference runs in the monolith or as its own service, +which is exactly the property the framework groups (G01 through G14) exist to provide, here made +concrete in a domain you can reason about. For the *why* behind each design choice, [ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html) (manual mapping), [ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html) (navigation populators), [ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html) (outbox), @@ -1297,7 +1333,7 @@ are the primary references; the business rules themselves are catalogued in ADC' contract without a Domain reference. - **Where it's used**: bound by `SpeakersController.LinkUserAsync`, a `PUT /Speakers/{id}/link` gated by `[HasPermission(ConferencePermissions.SpeakersManage)]` - (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:363-372`), + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:363-380`), which forwards `request.UserId` into the [`LinkUserToSpeakerCommand`](group-18-conference-application.md#linkusertospeakercommand). That command's handler raises [`SpeakerLinkedToUser`](#speakerlinkedtouser) on the aggregate *before* the @@ -1322,13 +1358,17 @@ are the primary references; the business rules themselves are catalogued in ADC' - **Walkthrough**: four `required init` members (`SessionFeedbackDTO.cs:25-34`), `QuestionId`, `QuestionText` (so the client renders a label without a second lookup), `AverageRating` (a `double`, the computed mean), and `ResponseCount` (the sample size behind that mean). The mean is computed in - memory over the answers that parse as integers + memory over the answers that parse as integers under `CultureInfo.InvariantCulture` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/UseCases/GetSessionFeedback/GetSessionFeedbackHandler.cs:75-88`), so `ResponseCount` counts *parseable* ratings, not raw answer rows. - **Why it's built this way**: carrying `QuestionText` and `ResponseCount` alongside the average makes the record self-describing, so a UI can show "4.6 (from 32 responses)" straight from the payload. - **Where it's used**: nested in [`SessionFeedbackDTO.Ratings`](#sessionfeedbackdto); built by [`GetSessionFeedbackHandler`](group-18-conference-application.md#getsessionfeedbackhandler). +- **Caveats / not-in-source**: a rating question whose answers are all unparseable produces **no** + summary row at all rather than a zero-count one, because the handler only adds the record when at + least one value parsed (`GetSessionFeedbackHandler.cs:81-90`). A consumer therefore cannot tell + "nobody rated it" from "the question was never asked" out of this payload alone. --- @@ -1355,8 +1395,9 @@ are the primary references; the business rules themselves are catalogued in ADC' The type's remarks call this out rather than leaving it as an accident. Contrast this with the loose status strings elsewhere in the Conference contract (for example - `SessionDTO.Status`, which carries Sessionize's vocabulary as free text): tiers are sold by ADC itself, - so the set is closed and can be an enum. + [`SessionDTO`](#sessiondto)`.Status`, a nullable `string` carrying Sessionize's vocabulary as free text, + `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/SessionDTO.cs:30`): tiers are + sold by ADC itself, so the set is closed and can be an enum. - **Walkthrough**: four members with explicit values (`SponsorTier.cs:15-24`). There is no `None`, `Unknown`, or `[Flags]` member: a sponsor always has exactly one package. - **Why it's built this way**: encoding package rank in the ordinal keeps ordering logic out of the UI @@ -1397,6 +1438,64 @@ are the primary references; the business rules themselves are catalogued in ADC' --- +### ActivityDTO +> MMCA.ADC.Conference.Shared · `MMCA.ADC.Conference.Shared.Activities` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Activities/ActivityDTO.cs:10` · Level 1 · record (class) + +- **What it is**: the read-model shape of an [`Activity`](#activity), a conference social or networking + slot (a party, a coffee connect, an after-party, the closing ceremony). It carries the name and blurb, + the event-local start and end times, an optional off-site venue, a display tie-breaker, and the FK to + its owning event. +- **Depends on**: [`IBaseDTO`](group-12-api-hosting-mapping.md#ibasedtotidentifiertype), + [`IConcurrencyAware`](group-12-api-hosting-mapping.md#iconcurrencyaware) (both from + `MMCA.Common.Shared.DTOs`, `ActivityDTO.cs:1,10`); the aliases `ActivityIdentifierType` and + `EventIdentifierType` (both `int`, + `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:5,8`). +- **Concept, the event-local wall-clock DTO.** `[Rubric §8, Data Architecture]` (assesses how time and + ownership are modelled at the storage boundary) and `[Rubric §9, API & Contract Design]`. Structurally + this is the concurrency-aware entity DTO already introduced by [`QuestionDTO`](#questiondto), but its + time fields are worth stopping on. `StartTime` and `EndTime` are plain `DateTime`, not + `DateTimeOffset`, because the entity stores them as wall-clock values in the owning event's IANA time + zone and the zone lives once on the event rather than repeated per row + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:28-36`). The DTO + faithfully carries that decision instead of quietly converting: a consumer that needs an absolute + instant has to combine the value with the event's zone, and the public page simply formats it as-is + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicActivityList.razor.cs:39-42`). +- **Walkthrough**: eleven members (`ActivityDTO.cs:13-43`). `Id` + `RowVersion` are the two framework + contracts. `Name` is the only `required` content field (line 19); `Description` (line 22) is optional. + `StartTime` and `EndTime` (lines 25-28) are the event-local programme window. The three venue fields + `VenueName`, `VenueAddress`, and `VenueUrl` (lines 31-37) are all optional and model the *off-site* + case only: an empty `VenueName` means the activity happens at the main conference venue, so the public + page falls back to the event venue rather than rendering a gap (`Activity.cs:38-42`), and + `VenueAddress` is what the "directions" affordance hands to a maps URL + (`PublicActivityList.razor.cs:101-111`). `SortOrder` (line 40) breaks ties between activities that + start at the same minute. `EventId` (line 43) scopes the activity to exactly one event. Note what is + absent: the entity's `[Navigation] Event?` reference (`Activity.cs:56-58`) is *not* projected, so an + activity response never drags an event graph along with it; and there is no room and no speaker + collection, because an activity is deliberately neither a session nor a talk. +- **Why it's built this way**: activities are ADC's own content rather than a Sessionize import, so the + contract is small and mostly optional: an organizer can publish "After party" the moment it is + scheduled and fill in the venue later. Naming the tie-breaker `SortOrder` (where + [`SponsorDTO`](#sponsordto) uses `Sort`) mirrors the underlying entity property in each case rather + than imposing a synthetic house name on the wire. +- **Where it's used**: produced by + [`ActivityDTOMapper`](group-18-conference-application.md#activitydtomapper), a `[Mapper] partial class` + whose doc comment records that nothing is redacted because activity data is published to attendees by + design + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/DTOs/ActivityDTOMapper.cs:10-17`); + projected by the + [`IEntityQueryService`](group-03-querying-specifications.md#ientityqueryservicetentity-tentitydto-tidentifiertype) + injected into [`ActivitiesController`](group-20-conference-api-grpc.md#activitiescontroller) + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/ActivitiesController.cs:37-47`), + whose anonymous `GET` narrows non-privileged callers to activities of published events via a + specification (`ActivitiesController.cs:49-70`); rendered by + [`PublicActivityList`](group-21-conference-ui.md#publicactivitylist), which orders by `StartTime` then + `SortOrder` (`PublicActivityList.razor.cs:76-83`), and by the organizer-facing + [`ActivityList`](group-21-conference-ui.md#activitylist); written through + [`ActivityCreateRequest`](group-18-conference-application.md#activitycreaterequest) and + [`ActivityUpdateRequest`](group-18-conference-application.md#activityupdaterequest). + +--- + ### CategoryItemDTO > MMCA.ADC.Conference.Shared · `MMCA.ADC.Conference.Shared.Categories` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Categories/CategoryItemDTO.cs:8` · Level 1 · record (class) @@ -1418,14 +1517,19 @@ are the primary references; the business rules themselves are catalogued in ADC' [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) implementation (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/DTOs/CategoryItemDTOMapper.cs:11-13`) - is a `[Mapper] partial class`, so the source generator writes the field-by-field copy at compile - time ([ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html)): no reflection - cost, and a shape mismatch is a build error rather than a runtime surprise. + is a `[Mapper] partial class` declaring `public partial CategoryItemDTO MapToDTO(CategoryItem entity);` + with no body (`CategoryItemDTOMapper.cs:16`), so the source generator writes the field-by-field copy + at compile time ([ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html)): no + reflection cost, and a shape mismatch is a build error rather than a runtime surprise. The + collection overload is the one hand-written member, a null-guarded `Select` over the single map + (`CategoryItemDTOMapper.cs:19-23`). - **Walkthrough**: four members (`CategoryItemDTO.cs:11-20`), `Id` (the `IBaseDTO` contract), the `required` `Name`, a plain `Sort` (`int`, display order), and the `required` `CategoryId` FK back to the parent category. `Sort` is not `required`, so it defaults to 0 and an item without an explicit - order sorts first. Note what is absent: no `RowVersion`, because a category item is edited through its - parent [`Category`](#category) aggregate, which is where the concurrency token lives. + order sorts first. Note what is absent: no `RowVersion`, because a category item is a child entity + (`AuditableBaseEntity`, + `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/CategoryItem.cs:14`) edited + through its parent [`Category`](#category) aggregate root, which is where the concurrency token lives. - **Why it's built this way**: keeping the DTO a flat record with `init`-only members makes it an immutable snapshot the query pipeline can project, serialize, and cache without defensive copying; Mapperly keeps the entity to DTO copy allocation-light and drift-proof. @@ -1453,9 +1557,11 @@ are the primary references; the business rules themselves are catalogued in ADC' client so a later update can be rejected if the row changed underneath it. The interface itself documents the failure mode it prevents: without the round-trip an update reloads the row and saves it, so two concurrent editors silently overwrite each other and the mapped `409 Conflict` never fires - (`MMCA.Common/Source/Core/MMCA.Common.Shared/DTOs/IConcurrencyAware.cs:9-12`). The CA1819 suppression - that lets a property return `byte[]` is declared once on the interface member - (`IConcurrencyAware.cs:19`), not repeated on each DTO, so implementing types stay clean. + (`MMCA.Common/Source/Core/MMCA.Common.Shared/DTOs/IConcurrencyAware.cs:9-12`). A null or empty token is + not an error either: the conflict check is simply skipped, which is what lets a create call and a + legacy client through (`IConcurrencyAware.cs:15-17`). The CA1819 suppression that lets a property + return `byte[]` is declared once on the interface member (`IConcurrencyAware.cs:19`), not repeated on + each DTO, so implementing types stay clean. - **Walkthrough**: eight members (`QuestionDTO.cs:12-33`), `Id` + `RowVersion` (the two contracts), the `required` `QuestionText`, then the optional descriptors `QuestionEntity` ("session" or "speaker"), `QuestionType` ("text" or "select"), `Sort`, `IsRequired`, and `QuestionSource` ("Sessionize" or @@ -1467,8 +1573,9 @@ are the primary references; the business rules themselves are catalogued in ADC' - **Where it's used**: mapped by [`QuestionDTOMapper`](group-18-conference-application.md#questiondtomapper) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Questions/DTOs/QuestionDTOMapper.cs:11-13`); - returned by [`QuestionsController`](group-20-conference-api-grpc.md#questionscontroller) and consumed - by the answer-collection UI. `QuestionType` is also the switch + returned by [`QuestionsController`](group-20-conference-api-grpc.md#questionscontroller) + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/QuestionsController.cs:31-32`) + and consumed by the answer-collection UI. `QuestionType` is also the switch [`GetSessionFeedbackHandler`](group-18-conference-application.md#getsessionfeedbackhandler) reads when it splits feedback into ratings and text (`GetSessionFeedbackHandler.cs:73`). @@ -1480,8 +1587,8 @@ are the primary references; the business rules themselves are catalogued in ADC' - **What it is**: the aggregated feedback report for a single session (BR-210, `SessionFeedbackDTO.cs:4`): the session identity plus two grouped result sets, numeric ratings and free-text responses. - **Depends on**: [`RatingQuestionSummary`](#ratingquestionsummary), - [`TextQuestionResponses`](#textquestionresponses); the aliases `SessionIdentifierType` (a `Guid`) and - `QuestionIdentifierType`. + [`TextQuestionResponses`](#textquestionresponses); the aliases `SessionIdentifierType` and + `QuestionIdentifierType` (both `int`). - **Concept, the composed query-projection report.** `[Rubric §6, CQRS & Event-Driven]` (a read model purpose-built for one query rather than a mapped entity) and `[Rubric §12, Performance & Scalability]`. This is the parent that composes the two Level-0 records above. It does **not** implement @@ -1492,21 +1599,30 @@ are the primary references; the business rules themselves are catalogued in ADC' rating question) and `TextResponses` (an `IReadOnlyList`, one entry per non-rating question). Splitting ratings from text mirrors the two answer kinds a session collects. The producing handler shows how the shape is filled and where it refuses: it loads the session with its - `SessionSpeakers` and `SessionQuestionAnswers`, returns `Forbidden` if the requested speaker is not - assigned to that session, returns an empty-but-valid report when there are no answers, then groups the - answers by question and routes each group to `Ratings` or `TextResponses` - (`GetSessionFeedbackHandler.cs:24-53,68-101`). + `SessionSpeakers` and `SessionQuestionAnswers` untracked, returns `NotFound` when the session is gone, + returns a `Forbidden` error coded `Speaker.NotAssigned` if the requested speaker is not assigned to + that session, returns an empty-but-valid report when there are no answers, then loads only the + questions that actually have answers and routes each answer group to `Ratings` or `TextResponses` + (`GetSessionFeedbackHandler.cs:23-53,56-101`). - **Why it's built this way**: pre-aggregating on the server (averages and groupings) keeps the speaker UI a thin renderer and avoids shipping every raw answer row to the client; returning an empty report - rather than a 404 when nobody answered keeps the dashboard's happy path free of special cases. -- **Where it's used**: returned by - `GET /Speakers/{speakerId}/sessions/{sessionId}/feedback`, which is `[AllowAnonymous]` under the - `ConferencePublicCache` output-cache policy (`SpeakersController.cs:402-412`), via - [`GetSessionFeedbackHandler`](group-18-conference-application.md#getsessionfeedbackhandler); rendered - by [`SpeakerDashboardService`](group-21-conference-ui.md#speakerdashboardservice) and the speaker - dashboard page. + rather than a 404 when nobody answered keeps the dashboard's happy path free of special cases; and + loading only the questions referenced by an answer (`GetSessionFeedbackHandler.cs:56-63`) keeps the + second query proportional to the feedback actually received. +- **Where it's used**: returned by `GET /Speakers/{speakerId}/sessions/{sessionId}/feedback` + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:406-425`) + via [`GetSessionFeedbackHandler`](group-18-conference-application.md#getsessionfeedbackhandler); fetched + by [`SpeakerDashboardService`](group-21-conference-ui.md#speakerdashboardservice) + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/SpeakerDashboardService.cs:69-76`) + and rendered on the speaker dashboard. `[Rubric §11, Security]` is worth reading off that endpoint + directly: it is `[Authorize]` and applies a self-or-organizer gate in the action body, requiring either + the `Organizer` role or a `speaker_id` claim matching the route speaker before it calls the handler + (`SpeakersController.cs:407,413-416`). Its own doc comment records why it carries no output cache: free + text comments are the speaker's own read, and every response is authorization-dependent, so a shared + public cache entry would be a leak (`SpeakersController.cs:400-405`). - **Caveats / not-in-source**: the answers this report aggregates are the Conference module's - `SessionQuestionAnswers`, not the Engagement module's feedback aggregates; nothing in this DTO or its + `SessionQuestionAnswers`, not the Engagement module's + [`SessionFeedback`](group-22-engagement-module.md#sessionfeedback) aggregate; nothing in this DTO or its handler reads across that module boundary. --- @@ -1518,7 +1634,7 @@ are the primary references; the business rules themselves are catalogued in ADC' many-to-many link that attaches a [`CategoryItem`](#categoryitem) (a topic or a locality tier) to a [`Speaker`](#speaker). - **Depends on**: [`IBaseDTO`](group-12-api-hosting-mapping.md#ibasedtotidentifiertype); - the aliases `SpeakerCategoryItemIdentifierType` (`int`), `SpeakerIdentifierType` (`Guid`), and + the aliases `SpeakerCategoryItemIdentifierType` (`int`), `SpeakerIdentifierType` (`System.Guid`), and `CategoryItemIdentifierType` (`int`). - **Concept**: the entity read DTO (see [`CategoryItemDTO`](#categoryitemdto)), here for a *join* entity: a flat record of foreign keys with no editable content of its own. @@ -1586,7 +1702,7 @@ are the primary references; the business rules themselves are catalogued in ADC' entered. `Sort` (line 39) is the tie-breaker *within* a tier. `EventId` (line 42) scopes the sponsor to exactly one event. `IsExhibitor` + `BoothNumber` (lines 45-48) model the expo floor; the domain keeps a stored booth number even when the flag is false, because the flag drives display and does not reject - stored data (`Sponsor.cs:54-57`). + stored data (`Sponsor.cs:54-58`). - **Why it's built this way**: sponsors are sold rather than imported, so unlike the Sessionize-sourced entities this contract is fully ADC's own: a closed enum for tier, a required name, everything else optional so an organizer can create a sponsor the moment a deal closes and fill in the logo later. @@ -1625,11 +1741,14 @@ are the primary references; the business rules themselves are catalogued in ADC' change needs *two* identifiers (the parent aggregate and the child) plus a descriptor, so it does not fit that one-id shape. The aggregate-root lifecycle events, [`CategoryChanged`](#categorychanged) and its siblings, do use `EntityChangedEvent`. - 2. **It is a `sealed record class` with no behavior.** Structural equality plus the inherited - `DateOccurred` and event id come from `BaseDomainEvent` - (`MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/BaseDomainEvent.cs:26-32`); the type + 2. **It is a `sealed record class` with no behavior.** The inherited `DateOccurred` and `MessageId` + come from `BaseDomainEvent`, each defaulted at construction + (`MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/BaseDomainEvent.cs:26-35`); the type exists purely so `IDomainEventHandler` can be registered and dispatched - independently of every other event type. + independently of every other event type. Being a `record` gives it structural equality, but the + base's own remarks warn that this is *not* a deduplication mechanism: two logically identical + events raised separately are never equal because both defaults are fresh per instance, and + consumer-side dedup is the inbox's job keyed on `MessageId` (`BaseDomainEvent.cs:10-16`, ADR-021). - **Walkthrough**: four positional members (`CategoryItemChanged.cs:13-17`), `State` (the `Added`/`Updated`/`Deleted` transition), `CategoryId` (the parent), `CategoryItemId` (the child), and `Name` (the item's display name, so a handler or log line has a human-readable label without @@ -1663,7 +1782,10 @@ are the primary references; the business rules themselves are catalogued in ADC' DTO (`RowVersion`, as in [`QuestionDTO`](#questiondto)) *and* it nests a child collection of [`CategoryItemDTO`](#categoryitemdto), so the whole aggregate (category plus its options) serializes in one response. The concurrency token sits here and not on the child, which is the aggregate boundary - showing through the read model: you version the root, not each option. + showing through the read model: you version the root, not each option. The entity declarations line up + with that split, `Category` is an `AuditableAggregateRootEntity` + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/Category.cs:16`) while + `CategoryItem` is a plain `AuditableBaseEntity` (`CategoryItem.cs:14`). - **Walkthrough**: six members (`ConferenceCategoryDTO.cs:12-27`), `Id` + `RowVersion` (the contracts), the `required` `Title`, an optional `Sort` and `Type` ("session" or "speaker"), and the `CategoryItems` collection, an `IReadOnlyCollection` initialized to `[]` @@ -1674,9 +1796,10 @@ are the primary references; the business rules themselves are catalogued in ADC' - **Where it's used**: mapped by [`ConferenceCategoryDTOMapper`](group-18-conference-application.md#conferencecategorydtomapper), which takes [`CategoryItemDTOMapper`](group-18-conference-application.md#categoryitemdtomapper) as a - constructor dependency to project the children - (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/DTOs/ConferenceCategoryDTOMapper.cs:12-15`); + constructor dependency and marks it `[UseMapper]` so the generator uses it for the children + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/DTOs/ConferenceCategoryDTOMapper.cs:12-18`); returned by [`ConferenceCategoriesController`](group-20-conference-api-grpc.md#conferencecategoriescontroller) + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/ConferenceCategoriesController.cs:32-33`) and consumed by the category-management UI. --- @@ -1690,9 +1813,10 @@ are the primary references; the business rules themselves are catalogued in ADC' - **Depends on**: [`IBaseDTO`](group-12-api-hosting-mapping.md#ibasedtotidentifiertype), [`IConcurrencyAware`](group-12-api-hosting-mapping.md#iconcurrencyaware), [`SpeakerCategoryItemDTO`](#speakercategoryitemdto), - [`SpeakerQuestionAnswerDTO`](#speakerquestionanswerdto); the aliases `SpeakerIdentifierType` (a `Guid`, - because speakers are imported with Sessionize-side identity) and `UserIdentifierType` (an `int`, owned - by Identity). + [`SpeakerQuestionAnswerDTO`](#speakerquestionanswerdto); the aliases `SpeakerIdentifierType` (a + `System.Guid`, because speakers are imported with Sessionize-assigned identity per BR-61, + `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:3,19`) + and `UserIdentifierType` (an `int`, owned by Identity). - **Concept, the cross-context read DTO and the redacting mapper.** `[Rubric §7, Microservices Readiness]`, `[Rubric §8, Data Architecture]`, `[Rubric §11, Security]`. Two things make this DTO worth studying beyond its size: @@ -1702,12 +1826,14 @@ are the primary references; the business rules themselves are catalogued in ADC' reconciled by events ([`SpeakerLinkedToUser`](#speakerlinkedtouser) and [`SpeakerUnlinkedFromUser`](#speakerunlinkedfromuser)), never by a cross-database join. 2. **`Email` is nullable on the DTO although the entity holds an `Email` value object**, because - [`SpeakerDTOMapper`](group-18-conference-application.md#speakerdtomapper) redacts it: after the - generated copy runs it returns `dto with { Email = null }` unless the caller is in the `Organizer` - role (BR-66, - `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/DTOs/SpeakerDTOMapper.cs:13,35-36`). - The redaction is in the mapper rather than the controller, so every read path inherits it. This is - the DTO layer doing real work, not just shape translation. + [`SpeakerDTOMapper`](group-18-conference-application.md#speakerdtomapper) redacts it: the public + `MapToDTO` calls the generated `MapToDTOGenerated` and then returns `dto with { Email = null }` + unless the caller is in the `Organizer` role (BR-66, + `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/DTOs/SpeakerDTOMapper.cs:13,30-37,46`). + A small private converter, `NullableEmailToString` (`SpeakerDTOMapper.cs:49`), is what lets the + generator flatten the value object to a string in the first place. The redaction is in the mapper + rather than the controller, so every read path inherits it. This is the DTO layer doing real work, + not just shape translation. - **Walkthrough**: seventeen members (`SpeakerDTO.cs:12-60`). `Id` + `RowVersion` are the contracts. The `required` name fields are `FirstName`, `LastName`, and `FullName` (lines 18-24); `FullName` is a computed expression on the entity (`Speaker.cs:61`) flattened into a stored string here, so a client @@ -1726,7 +1852,7 @@ are the primary references; the business rules themselves are catalogued in ADC' - **Where it's used**: projected by the [`IEntityQueryService`](group-03-querying-specifications.md#ientityqueryservicetentity-tentitydto-tidentifiertype) injected into [`SpeakersController`](group-20-conference-api-grpc.md#speakerscontroller) - (`SpeakersController.cs:44-47`), and returned by its create and update commands; rendered by the public + (`SpeakersController.cs:44-45`), and returned by its create and update commands; rendered by the public speaker pages and by [`SpeakerDashboardService`](group-21-conference-ui.md#speakerdashboardservice). --- @@ -1745,15 +1871,18 @@ are the primary references; the business rules themselves are catalogued in ADC' `Created`/`Updated`/`Deleted` trio). Where the Level-2 events above derive from [`BaseDomainEvent`](group-04-events-outbox.md#basedomainevent) directly, the root-level events derive from `EntityChangedEvent`, which consolidates the CRUD-lifecycle pattern: it holds - `State` plus a single generic `EntityId`, and each concrete record passes its own id up to that base + `State` plus a single generic `EntityId` (constrained `notnull`, + `MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/EntityChangedEvent.cs:24-27`), and each + concrete record passes its own id up to that base (`CategoryChanged.cs:16`: `: EntityChangedEvent(State, CategoryId)`). A subtle but real consequence: the derived record re-exposes the id under a domain-meaningful name (`CategoryId`) while the same value is also reachable as the inherited generic `EntityId`, one identity under two property names, so handlers written against `EntityChangedEvent` and handlers written against the concrete type both work. The base's own doc comment draws the dividing line, generic CRUD lifecycle belongs here while a business transition such as `OrderPaid` keeps inheriting - `BaseDomainEvent` directly - (`MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/EntityChangedEvent.cs:16-18`). + `BaseDomainEvent` directly (`EntityChangedEvent.cs:15-19`), and it also fixes the raise convention: + `Added` from factory methods, `Updated` from mutators, `Deleted` from `Delete()` + (`EntityChangedEvent.cs:9-14`). - **Walkthrough**: three positional members (`CategoryChanged.cs:13-15`), `State`, `CategoryId`, and `Title`; `State` and `CategoryId` are forwarded to the base constructor (line 16), and `Title` is the record's own added property, the human-readable descriptor a handler or log line can use without @@ -1776,8 +1905,9 @@ are the primary references; the business rules themselves are catalogued in ADC' - **Depends on**: [`BaseDomainEvent`](group-04-events-outbox.md#basedomainevent) (the base record) and the [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate) enum, both from `MMCA.Common.Domain`; the module identifier aliases `EventIdentifierType`, - `EventQuestionAnswerIdentifierType`, and `QuestionIdentifierType` (BCL scalars behind a `global using` - alias, see the [primer](00-primer.md)). No NuGet dependency. + `EventQuestionAnswerIdentifierType`, and `QuestionIdentifierType`, all `int` behind a `global using` + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8-11`, + see the [primer](00-primer.md)). No NuGet dependency. - **Concept introduced, the child-change domain event.** `[Rubric §6, CQRS & Event-Driven]` (assesses whether state transitions are published as typed, first-class events that typed handlers can subscribe to, instead of leaking out as ad-hoc side effects) and `[Rubric §4, DDD]` (assesses whether the @@ -1811,9 +1941,11 @@ are the primary references; the business rules themselves are catalogued in ADC' exactly what moved. - **Where it's used**: raised by [`Event`](#event)'s `AddEventQuestionAnswer` / `UpdateEventQuestionAnswer` / `RemoveEventQuestionAnswer` - (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:621`, `:645`, `:666`); - collected on the aggregate and dispatched in-process by - [`DomainEventDispatcher`](group-04-events-outbox.md#domaineventdispatcher) after `SaveChangesAsync`. + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:650`, `:674`, `:695`, + declared at `:637`, `:661`, `:684`); collected on the aggregate, written to an outbox row by the save-changes + interceptor and dispatched in-process by + [`DomainEventDispatcher`](group-04-events-outbox.md#domaineventdispatcher) + (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:215-238`). - **Caveats / not-in-source**: no dedicated `IDomainEventHandler` subscribes to it today (the Conference Application layer has handlers only for [`RoomChanged`](#roomchanged), [`SessionChanged`](#sessionchanged), and [`SpeakerChanged`](#speakerchanged)); the event is raised and recorded regardless. @@ -1827,7 +1959,9 @@ are the primary references; the business rules themselves are catalogued in ADC' entity is added or removed, that is, when a [`Speaker`](#speaker) is attached to or detached from the event. - **Depends on**: [`BaseDomainEvent`](group-04-events-outbox.md#basedomainevent), [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate); aliases `EventIdentifierType`, - `EventSpeakerIdentifierType`, `SpeakerIdentifierType`. + `EventSpeakerIdentifierType`, `SpeakerIdentifierType` (the last is `System.Guid`, not `int`, because + speakers carry Sessionize-assigned GUIDs, + `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:19`). - **Concept**: the child-change domain event introduced by [`EventQuestionAnswerChanged`](#eventquestionanswerchanged), here for a *join* entity. `[Rubric §6, CQRS & Event-Driven]`. The XML doc says "added or removed" with no update case @@ -1836,9 +1970,12 @@ are the primary references; the business rules themselves are catalogued in ADC' sites use only `Added` and `Deleted`. - **Walkthrough**: `State`, `EventId` (parent), `EventSpeakerId` (the join row), `SpeakerId` (the linked speaker), lines 14-17. -- **Where it's used**: raised by [`Event`](#event)'s `AddEventSpeaker` / `RemoveEventSpeaker` - (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:532`, `:568`, `:589`); - dispatched in-process. No dedicated handler subscribes today. +- **Where it's used**: raised by [`Event`](#event)'s `AddEventSpeaker` (`:540`), `RestoreEventSpeaker` + (`:577`), and `RemoveEventSpeaker` (`:607`) + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:561`, `:597`, `:618`). + Note the restore path: un-deleting a soft-deleted join row raises `Added` again (`:597`), so a subscriber + sees the same transition it saw the first time and needs no separate "restored" case. No dedicated handler + subscribes today. --- @@ -1855,11 +1992,16 @@ are the primary references; the business rules themselves are catalogued in ADC' subscriber, so it is the concrete sighting of the `IDomainEventHandler` extension point: [`RoomChangedHandler`](group-18-conference-application.md#roomchangedhandler) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/DomainEventHandlers/RoomChangedHandler.cs:11-12`) - implements `IDomainEventHandler` and branches on the `State` transition. Note the descriptor - choice: `RoomName` is a display label rather than an FK, so a log line or projection reads without a reload. + implements `IDomainEventHandler` and logs the transition with a source-generated + `[LoggerMessage]` that takes `State`, `EventId`, `RoomId`, and `RoomName` as structured fields + (`RoomChangedHandler.cs:17-22`). That is why the descriptor choice matters: `RoomName` is a display label + rather than an FK, so the log line (or a projection) reads without a reload. `[Rubric §13, Observability & + Operability]` applies here too: the event payload is shaped so the handler can emit structured telemetry + without touching the database. - **Walkthrough**: `State`, `EventId` (parent), `RoomId` (child), `RoomName` (display label), lines 14-17. -- **Where it's used**: raised by [`Event`](#event)'s `AddRoom` / `UpdateRoom` / `RemoveRoom` - (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:381`, `:419`, `:472`, `:493`); +- **Where it's used**: raised by [`Event`](#event)'s `AddRoom` (`:374`), `UpdateRoom` (`:411`), `RestoreRoom` + (`:454`), and `RemoveRoom` (`:511`) + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:395`, `:433`, `:501`, `:522`); consumed by [`RoomChangedHandler`](group-18-conference-application.md#roomchangedhandler). --- @@ -1880,7 +2022,8 @@ are the primary references; the business rules themselves are catalogued in ADC' - **Walkthrough**: `sealed record class` with `State`, `SessionId`, `SessionCategoryItemId`, `CategoryItemId` (`SessionCategoryItemChanged.cs:13-17`). Being a record, immutability and structural equality come for free; the primary-constructor parameters are the only state. -- **Where it's used**: raised by [`Session`](#session)'s category-item add/remove methods +- **Where it's used**: raised by [`Session`](#session)'s `AddSessionCategoryItem` (`:414`), + `RestoreSessionCategoryItem` (`:452`), and `RemoveSessionCategoryItem` (`:482`) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:435`, `:472`, `:493`); captured by the outbox in `SaveChangesAsync` and dispatched in-process. No dedicated handler today. @@ -1897,10 +2040,12 @@ are the primary references; the business rules themselves are catalogued in ADC' `SessionQuestionAnswerIdentifierType`, `QuestionIdentifierType`. - **Concept**: the child-change domain event ([`EventQuestionAnswerChanged`](#eventquestionanswerchanged)). `[Rubric §6, CQRS & Event-Driven]`. The behavioral difference against a join event is the `Updated` state: - an answer's text can change in place (a join row cannot), and the raise sites use all three transitions. + an answer's value can change in place (a join row cannot), so `UpdateSessionQuestionAnswer` exists + (`Session.cs:536`) and the raise sites use all three transitions. - **Walkthrough**: `sealed record class` with `State`, `SessionId`, `SessionQuestionAnswerId`, `QuestionId` (`SessionQuestionAnswerChanged.cs:13-17`). -- **Where it's used**: raised by [`Session`](#session)'s question-answer methods +- **Where it's used**: raised by [`Session`](#session)'s `AddSessionQuestionAnswer` (`:512`), + `UpdateSessionQuestionAnswer` (`:536`), and `RemoveSessionQuestionAnswer` (`:559`) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:525`, `:549`, `:570`); captured by the outbox. Do not confuse it with [`SessionFeedbackSubmitted`](#sessionfeedbacksubmitted), the cross-module event the *application* layer @@ -1922,7 +2067,8 @@ are the primary references; the business rules themselves are catalogued in ADC' and `Deleted`. - **Walkthrough**: `sealed record class` with `State`, `SessionId`, `SessionSpeakerId`, `SpeakerId` (`SessionSpeakerChanged.cs:13-17`). -- **Where it's used**: raised by [`Session`](#session)'s speaker-association methods +- **Where it's used**: raised by [`Session`](#session)'s `AddSessionSpeaker` (`:318`), `RestoreSessionSpeaker` + (`:355`), and `RemoveSessionSpeaker` (`:385`) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:339`, `:375`, `:396`); captured by the outbox. @@ -1944,8 +2090,9 @@ are the primary references; the business rules themselves are catalogued in ADC' relationship so handlers stay narrow. - **Walkthrough**: `sealed record class` with `State`, `SpeakerId`, `SpeakerCategoryItemId`, `CategoryItemId` (`SpeakerCategoryItemChanged.cs:13-17`). -- **Where it's used**: raised by [`Speaker`](#speaker)'s category-item methods - (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:323`, `:360`, `:381`); +- **Where it's used**: raised by [`Speaker`](#speaker)'s `AddSpeakerCategoryItem` (`:314`), + `RestoreSpeakerCategoryItem` (`:352`), and `RemoveSpeakerCategoryItem` (`:382`) + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:335`, `:372`, `:393`); captured by the outbox. --- @@ -1960,16 +2107,46 @@ are the primary references; the business rules themselves are catalogued in ADC' [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate); aliases `SpeakerIdentifierType`, `SpeakerQuestionAnswerIdentifierType`, `QuestionIdentifierType`. - **Concept**: the child-change domain event ([`EventQuestionAnswerChanged`](#eventquestionanswerchanged)). - `[Rubric §6, CQRS & Event-Driven]`. As with the session answer, the answer text is mutable, so the raise + `[Rubric §6, CQRS & Event-Driven]`. As with the session answer, the answer value is mutable, so the raise sites span `Added`, `Updated`, and `Deleted`. - **Walkthrough**: `sealed record class` with `State`, `SpeakerId`, `SpeakerQuestionAnswerId`, `QuestionId` (`SpeakerQuestionAnswerChanged.cs:13-17`). -- **Where it's used**: raised by [`Speaker`](#speaker)'s question-answer methods - (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:413`, `:437`, `:458`); +- **Where it's used**: raised by [`Speaker`](#speaker)'s `AddSpeakerQuestionAnswer` (`:412`), + `UpdateSpeakerQuestionAnswer` (`:436`), and `RemoveSpeakerQuestionAnswer` (`:459`) + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:425`, `:449`, `:470`); captured by the outbox. --- +### ActivityChanged +> MMCA.ADC.Conference.Domain · `MMCA.ADC.Conference.Domain.Activities.DomainEvents` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/DomainEvents/ActivityChanged.cs:12` · Level 3 · record (sealed) + +- **What it is**: the aggregate-root lifecycle event for an [`Activity`](#activity), the non-session agenda item + (a keynote reception, a lunch break, a hallway track slot): raised when one is created, updated, or + soft-deleted. It carries the activity id and its display name. +- **Depends on**: + [`EntityChangedEvent`](group-04-events-outbox.md#entitychangedeventtidentifiertype) (the + base record), [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate); alias + `ActivityIdentifierType`. +- **Concept**: the aggregate-root lifecycle event, taught in detail under [`EventChanged`](#eventchanged) + below. `[Rubric §6, CQRS & Event-Driven]` and `[Rubric §16, Maintainability]` (assesses whether a recurring + shape is factored once instead of copied). The instructive detail is a *contrast*: [`Activity`](#activity) + owns an `EventId` property + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:54`), yet + `ActivityChanged` does not carry it, where the structurally similar [`SessionChanged`](#sessionchanged) + does. A subscriber that needs the parent event for an activity therefore has to reload it, which is a real + (if small) asymmetry in the event contracts of this bounded context rather than a rule you can infer. +- **Walkthrough**: three positional members (`ActivityChanged.cs:12-16`): `State`, `ActivityId`, and `Name`, + with `(State, ActivityId)` forwarded to `EntityChangedEvent` on line 16. +- **Where it's used**: raised from [`Activity`](#activity)'s `Create` factory + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:127`), its `Update` + method (`:173`), and its `Delete` override, which calls the base soft-delete first and raises the event only + when that base call returned success (`:180-188`); dispatched in-process. +- **Caveats / not-in-source**: no `IDomainEventHandler` is implemented today; the event is + raised and persisted to the outbox regardless. + +--- + ### EventChanged > MMCA.ADC.Conference.Domain · `MMCA.ADC.Conference.Domain.Events.DomainEvents` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/DomainEvents/EventChanged.cs:12` · Level 3 · record (sealed) @@ -2004,10 +2181,14 @@ are the primary references; the business rules themselves are catalogued in ADC' serialized through the outbox, keeping the base shared also keeps their contract shape stable ([ADR-010](https://ivanball.github.io/docs/adr/010-integration-event-schema-versioning.html) governs the versioning rules for anything that crosses a boundary). -- **Where it's used**: raised from [`Event`](#event)'s `Create` / `Update` / `Publish` / `Unpublish` / `Delete` - (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:196`, `:251`, `:271`, `:291`, - `:341`); dispatched in-process by - [`DomainEventDispatcher`](group-04-events-outbox.md#domaineventdispatcher) after `SaveChangesAsync`. +- **Where it's used**: raised from [`Event`](#event)'s `Create` (`:164`), `Update` (`:229`), `Publish` + (`:272`), `Unpublish` (`:292`), and `Delete` (`:328`) + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:207`, `:265`, `:285`, + `:305`, `:355`); dispatched in-process by + [`DomainEventDispatcher`](group-04-events-outbox.md#domaineventdispatcher) after `SaveChangesAsync`. Note + that publish and unpublish reuse the `Updated` transition rather than introducing dedicated event types, + which is the CRUD-lifecycle base doing its job: a subscriber that cares specifically about publication has + to compare the [`Event`](#event)'s own state, not the event type. --- @@ -2025,11 +2206,15 @@ are the primary references; the business rules themselves are catalogued in ADC' things separate it from every event above. First, it derives from [`BaseIntegrationEvent`](group-04-events-outbox.md#baseintegrationevent), which adds a virtual `SchemaVersion` defaulting to `1` - (`MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/BaseIntegrationEvent.cs:22`) and implements - `IIntegrationEvent`, the marker that makes `SaveChangesAsync` leave its outbox row *unprocessed* so the - [`OutboxProcessor`](group-04-events-outbox.md#outboxprocessor) publishes it over the message bus instead of - dispatching it in process. Second, it lives in the `.Shared` project, not `.Domain`, precisely so a - subscribing module can reference the contract without pulling in Conference's domain model. + (`MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/BaseIntegrationEvent.cs:32`) and implements + `IIntegrationEvent`, the marker the save-changes interceptor branches on: an integration event still gets an + outbox row, but it is deliberately *not* dispatched in process, so its row stays unprocessed and the + [`OutboxProcessor`](group-04-events-outbox.md#outboxprocessor) publishes it over `IMessageBus` instead + (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:215-235` + and `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/OutboxProcessor.cs:510-521`). + The registered transport then decides delivery: in-process for the monolith, MassTransit broker for the + extracted services. Second, it lives in the `.Shared` project, not `.Domain`, precisely so a subscribing + module can reference the contract without pulling in Conference's domain model. [ADR-010](https://ivanball.github.io/docs/adr/010-integration-event-schema-versioning.html) is the rule for evolving it: additive changes keep the version, a breaking change means a new type plus a consumer-side upcaster. @@ -2041,11 +2226,10 @@ are the primary references; the business rules themselves are catalogued in ADC' - **Why it's built this way**: the delivery semantics are the interesting part, and the XML doc states them (`EventFeedbackSubmitted.cs:8-13`). Event feedback is an upsert writing one row per form question (BR-107), so one submitted form raises this event once per *newly created* answer, and only on the create path: the - update branch of the same handler raises nothing - (`AddEventQuestionAnswerHandler.cs:87-93` versus `:102-116`). Because at-least-once outbox delivery and a - multi-question form both mean the consumer can see the message more than once, the consumer is idempotent on - its own side: it collapses everything onto one subject key and lets the awarder's uniqueness rule reject the - duplicates + update branch of the same handler raises nothing (`AddEventQuestionAnswerHandler.cs:81-94` for the update + path versus `:96-117` for the create path). Because at-least-once outbox delivery and a multi-question form + both mean the consumer can see the message more than once, the consumer is idempotent on its own side: it + collapses everything onto one subject key and lets the awarder's uniqueness rule reject the duplicates (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/EventFeedbackSubmittedPointsHandler.cs:38-45`). That is the standard posture for [ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html): the producer guarantees the fact was recorded atomically with the data, the consumer guarantees the effect @@ -2055,7 +2239,7 @@ are the primary references; the business rules themselves are catalogued in ADC' (`:112`), so the outbox captures it in the same `SaveChangesAsync`; consumed by [`EventFeedbackSubmittedPointsHandler`](group-22-engagement-module.md#eventfeedbacksubmittedpointshandler), registered as a broker consumer in the Engagement service host - (`MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:301`). + (`MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:307`). --- @@ -2072,7 +2256,8 @@ are the primary references; the business rules themselves are catalogued in ADC' - **Walkthrough**: `sealed record class QuestionChanged(DomainEntityState State, QuestionIdentifierType QuestionId, string QuestionText)` forwarding `(State, QuestionId)` to `EntityChangedEvent` (`QuestionChanged.cs:12-16`). -- **Where it's used**: raised from [`Question`](#question)'s create, update, and delete paths +- **Where it's used**: raised from [`Question`](#question)'s `Create` (`:70`), `Update` (`:108`), and `Delete` + (`:135`) paths (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/Question.cs:94`, `:128`, `:140`); dispatched in-process. No dedicated handler subscribes today. @@ -2089,14 +2274,16 @@ are the primary references; the business rules themselves are catalogued in ADC' `EventIdentifierType`. - **Concept**: the aggregate-root lifecycle event ([`EventChanged`](#eventchanged)). `[Rubric §6, CQRS & Event-Driven]`. It is the one root event that carries a *second* identifier, the parent - `EventId`, in addition to `Title`, so a subscriber knows which event's schedule moved (useful for + `EventId` (line 17), in addition to `Title`, so a subscriber knows which event's schedule moved (useful for invalidating that event's session list rather than the whole cache). This is also where the `State` filter earns its keep: [`SessionCreatedHandler`](group-18-conference-application.md#sessioncreatedhandler) subscribes to the single type and returns early unless `State` is `Added` - (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/DomainEventHandlers/SessionCreatedHandler.cs:17-20`). + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/DomainEventHandlers/SessionCreatedHandler.cs:17-20`), + then logs `SessionId`, `Title`, and `EventId` as structured fields (`:24-25`). - **Walkthrough**: `sealed record class SessionChanged(DomainEntityState State, SessionIdentifierType SessionId, string Title, EventIdentifierType EventId)` chaining `(State, SessionId)` to the base (`SessionChanged.cs:13-18`). -- **Where it's used**: raised by [`Session`](#session)'s lifecycle methods +- **Where it's used**: raised by [`Session`](#session)'s `Create` (`:163`), `Update` (`:229`), and `Delete` + (`:277`) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:206`, `:267`, `:304`); consumed by [`SessionCreatedHandler`](group-18-conference-application.md#sessioncreatedhandler). @@ -2125,8 +2312,10 @@ are the primary references; the business rules themselves are catalogued in ADC' (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerHandler.cs:134`), with the timestamp taken from the injected `TimeProvider`; consumed by [`SessionFeedbackSubmittedPointsHandler`](group-22-engagement-module.md#sessionfeedbacksubmittedpointshandler), - registered as a broker consumer in the Engagement service host - (`MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:300`). + which resolves it onto a session subject key + (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/SessionFeedbackSubmittedPointsHandler.cs:37-40`) + and is registered as a broker consumer in the Engagement service host + (`MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:306`). --- @@ -2148,10 +2337,12 @@ are the primary references; the business rules themselves are catalogued in ADC' can perform the BR-70 cross-context cleanup after the entity's own link field has been nulled. - **Walkthrough**: `sealed record class SpeakerChanged(DomainEntityState State, SpeakerIdentifierType SpeakerId, string FullName, UserIdentifierType? PreviousLinkedUserId = null)` chaining `(State, SpeakerId)` to the base (`SpeakerChanged.cs:16-21`). The default `null` on the fourth - parameter is what keeps the non-delete raise sites a three-argument call - (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:156`, `:223`, `:272`, - `:290`), while the delete path passes the captured value - (`Speaker.cs:251`). + parameter is what keeps the non-delete raise sites a three-argument call: `Create` (`:168`), `Update` + (`:235`), `LinkUser` (`:284`), and `UnlinkUser` (`:302`) + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs`), while the delete path + is the only four-argument call and passes the captured value (`Speaker.cs:263`). Note that `LinkUser` and + `UnlinkUser` both emit `Updated`, not a bespoke link event, so a subscriber cannot tell a link change from a + name edit by event type alone. - **Why it's built this way**: an event is an immutable record of what already happened, so snapshotting the prior link onto the event avoids a lost-update race in which the cleanup handler would read an already-cleared field. It also decouples the delete transaction from the downstream unlink, which crosses a @@ -2159,7 +2350,8 @@ are the primary references; the business rules themselves are catalogued in ADC' - **Where it's used**: consumed by [`SpeakerDeletedHandler`](group-18-conference-application.md#speakerdeletedhandler), which ignores every transition except `Deleted`, then publishes - [`SpeakerUnlinkedFromUser`](#speakerunlinkedfromuser) through `IEventBus` when `PreviousLinkedUserId` has a + [`SpeakerUnlinkedFromUser`](#speakerunlinkedfromuser) through + [`IEventBus`](group-04-events-outbox.md#ieventbus) when `PreviousLinkedUserId` has a value, from a fresh DI scope because the handler is a singleton (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/DomainEventHandlers/SpeakerDeletedHandler.cs:29-45`). Identity then clears `User.LinkedSpeakerId`. @@ -2175,13 +2367,14 @@ are the primary references; the business rules themselves are catalogued in ADC' [`EntityChangedEvent`](group-04-events-outbox.md#entitychangedeventtidentifiertype), [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate); alias `SponsorIdentifierType`. - **Concept**: the aggregate-root lifecycle event ([`EventChanged`](#eventchanged)). - `[Rubric §6, CQRS & Event-Driven]` and `[Rubric §16, Maintainability]`. Sponsors are the newest aggregate in - this bounded context, and the fact that its event is a three-line record derived from the same base is the - payoff of the shared shape: a new aggregate gets the full lifecycle-event story without inventing anything. + `[Rubric §6, CQRS & Event-Driven]` and `[Rubric §16, Maintainability]`. Sponsors are among the newest + aggregates in this bounded context, and the fact that its event is a three-line record derived from the same + base is the payoff of the shared shape: a new aggregate gets the full lifecycle-event story without + inventing anything. - **Walkthrough**: three positional members (`SponsorChanged.cs:12-16`): `State`, `SponsorId`, and `Name`, with `(State, SponsorId)` forwarded to `EntityChangedEvent` on line 16. - **Where it's used**: raised from [`Sponsor`](#sponsor)'s `Create` factory - (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:133`), its update path + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:133`), its `Update` path (`:183`), and its `Delete` override, which calls the base soft-delete first and raises the event only when that base call returned success (`:190-198`); dispatched in-process. No dedicated handler subscribes today. @@ -2217,9 +2410,11 @@ are the primary references; the business rules themselves are catalogued in ADC' [ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html); it lets Identity and Conference run as separate services with no shared database and no cross-database FK ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)). -- **Where it's used**: published by the Conference link-command handler (Application tier, Group 18) and - by [`UserRegisteredHandler`](group-18-conference-application.md#userregisteredhandler)'s auto-link path; - consumed on the Identity side. +- **Where it's used**: raised by + [`LinkUserToSpeakerHandler`](group-18-conference-application.md#linkusertospeakerhandler) + (`LinkUserToSpeakerHandler.cs:54`) and, on the auto-link path, by + [`UserRegisteredHandler`](group-18-conference-application.md#userregisteredhandler) + (`UserRegisteredHandler.cs:77` and `:96`); consumed on the Identity side. ### SpeakerUnlinkedFromUser > MMCA.ADC.Conference.Shared · `MMCA.ADC.Conference.Shared.Speakers.IntegrationEvents` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/IntegrationEvents/SpeakerUnlinkedFromUser.cs:17` · Level 3 · record (sealed) @@ -2240,11 +2435,60 @@ are the primary references; the business rules themselves are catalogued in ADC' (`SpeakerUnlinkedFromUser.cs:9-13`). - **Why it's built this way**: it closes the loop on the eventually-consistent link, and it is the downstream half of a [`SpeakerChanged`](#speakerchanged) delete. That is exactly why - [`Speaker.Delete`](#speaker) snapshots the previous link id onto the domain event before clearing the - field (`Speaker.cs:242`): the handler that publishes this integration event would otherwise have nothing - left to read. -- **Where it's used**: published by the Conference unlink-command handler and by the speaker-delete - cleanup path; consumed on the Identity side. + [`Speaker.Delete`](#speaker) snapshots the previous link id into a local **before** `base.Delete()` + runs (`Speaker.cs:253-254`): the handler that publishes this integration event would otherwise have + nothing left to read. +- **Where it's used**: raised by + [`UnlinkUserFromSpeakerHandler`](group-18-conference-application.md#unlinkuserfromspeakerhandler) + (`UnlinkUserFromSpeakerHandler.cs:42`) and by the speaker-delete cleanup path in + [`SpeakerDeletedHandler`](group-18-conference-application.md#speakerdeletedhandler) + (`SpeakerDeletedHandler.cs:43`); consumed on the Identity side. + +### ActivityInvariants +> MMCA.ADC.Conference.Domain · `MMCA.ADC.Conference.Domain.Activities` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/ActivityInvariants.cs:10` · Level 6 · class (static) + +- **What it is**: the domain rules for the [`Activity`](#activity) aggregate: a required name, three + optional venue fields that are length-checked only, and a start-before-end time range check. The + length constants declared here are read by both domain validation and the EF configuration, so a + column width and a domain rule cannot silently diverge (`ActivityInvariants.cs:6-9`). +- **Depends on**: [`CommonInvariants`](group-02-domain-building-blocks.md#commoninvariants) + (`ActivityInvariants.cs:1`), [`Result`](group-01-result-error-handling.md#result) and + [`Error`](group-01-result-error-handling.md#error) (`:2`); BCL `DateTime`. +- **Concept**: the static-invariants-class pattern introduced for the framework at + [`CommonInvariants`](group-02-domain-building-blocks.md#commoninvariants) and shown for a Conference + aggregate at [`SessionInvariants`](#sessioninvariants). `[Rubric §4, Domain-Driven Design]`: the rules + live in the domain rather than in a handler or a validator. What this particular class teaches, which + its siblings do not, is the **optional field as a first-class domain concept**: three of its five rule + methods short-circuit to `Result.Success()` when the value is null or empty rather than failing. The + doc on `EnsureVenueNameIsValid` states the reason plainly (`:38-41`): an empty venue name means the + activity happens at the main conference venue, so absence is a meaningful value, not missing data. +- **Walkthrough** + - **Length constants** (`ActivityInvariants.cs:13-25`), all `public const int`: `NameMaxLength` (200), + `DescriptionMaxLength` (2000), `VenueNameMaxLength` (200), `VenueAddressMaxLength` (500, chosen to + match the event venue address per the doc at `:21`), and `VenueUrlMaxLength` (2000). + - **`EnsureNameIsValid`** (`:33-36`): the standard `Result.Combine` of + `CommonInvariants.EnsureStringIsNotEmpty` plus `CommonInvariants.EnsureStringMaxLength`, tagged with + the stable codes `Activity.Name.Empty` and `Activity.Name.TooLong`. + - **`EnsureVenueNameIsValid`** (`:45-48`), **`EnsureVenueAddressIsValid`** (`:57-60`), and + **`EnsureVenueUrlIsValid`** (`:69-72`): each is a single expression, `string.IsNullOrEmpty(x) ? + Result.Success() : CommonInvariants.EnsureStringMaxLength(...)`. Note what is deliberately absent for + the URL: no scheme parse, no reachability check. The doc (`:62-65`) records that the value is stored + as an opaque string with no fetch or upload pipeline behind it, matching the sponsor website-URL + precedent, so only the storage constraint is enforced. + - **`EnsureTimeRangeIsValid`** (`:82-89`): fails with `Error.Invariant("Activity.TimeRange.Invalid")` + when `endTime < startTime`. The doc (`:74-77`) explains why the comparison is a plain one: both + values are event-local wall times, and the IANA zone lives on the owning [`Event`](#event), never + repeated per row. A zero-length activity is allowed; only an inverted range is rejected. +- **Why it's built this way**: pushing the "absent is legal" decision into the invariant, rather than + into every caller, means a handler cannot accidentally require a venue name and the EF column cannot + accidentally be narrower than the rule. Comparing naive wall times instead of instants keeps the domain + free of time-zone conversion, which belongs where the zone is known. +- **Where it's used**: [`Activity.Create`](#activity) and [`Activity.Update`](#activity) + (`Activity.cs:111-116` and `:155-160`); the length constants feed + [`ActivityConfiguration`](group-19-conference-infrastructure.md#activityconfiguration) + (`ActivityConfiguration.cs:20`, `:24`, `:36`, `:40`, `:44`) and the application-layer rule types + [`ActivityNameRules`](group-18-conference-application.md#activitynamerulest) and its siblings + (`ActivityValidationRules.cs:17-66`). ### Category > MMCA.ADC.Conference.Domain · `MMCA.ADC.Conference.Domain.Categories` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/Category.cs:16` · Level 6 · class (sealed, aggregate root) @@ -2316,7 +2560,9 @@ are the primary references; the business rules themselves are catalogued in ADC' aggregate stays agnostic to the load path. - **Where it's used**: loaded through [`IReadRepository`](group-07-persistence-ef-core.md#ireadrepositorytentity-tidentifiertype), - mutated by the Conference category command handlers (Group 18), and projected for the category UI. + mutated by the Conference category command handlers (Group 18), persisted through + [`ConferenceCategoryConfiguration`](group-19-conference-infrastructure.md#conferencecategoryconfiguration), + and projected for the category UI. ### CategoryInvariants > MMCA.ADC.Conference.Domain · `MMCA.ADC.Conference.Domain.Categories` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:11` · Level 6 · class (static) @@ -2328,32 +2574,37 @@ are the primary references; the business rules themselves are catalogued in ADC' lower layer it delegates to), [`Result`](group-01-result-error-handling.md#result), [`Error`](group-01-result-error-handling.md#error), [`CategoryItem`](#categoryitem) (it takes the child collection as a parameter); BCL `CultureInfo`. -- **Concept**: the module invariants class taught at [`EventInvariants`](#eventinvariants). The - distinctive method here, which the simpler invariant classes lack, is the **collection-aware uniqueness - guard**. `[Rubric §4, Domain-Driven Design]`: the ubiquitous-language rule "an item name is unique - within its category" is expressed directly in the domain rather than deferred to a database index or a - UI check. +- **Concept**: the module invariants class (see [`SessionInvariants`](#sessioninvariants) and + [`EventInvariants`](#eventinvariants)). The distinctive method here, which the simpler invariant classes + lack, is the **collection-aware uniqueness guard**. `[Rubric §4, Domain-Driven Design]`: the + ubiquitous-language rule "an item name is unique within its category" is expressed directly in the + domain rather than deferred to a database index or a UI check. - **Walkthrough** - `TitleMaxLength` (255) and `CategoryItemNameMaxLength` (500) at `CategoryInvariants.cs:14` and `:17`. - Note these are `public static readonly int` here rather than the `const int` used by the other + Note these are `public static readonly int` here rather than the `public const int` used by the other invariant classes in this chapter; both still feed the EF column widths. - - `EnsureTitleIsValid` (`:19`) and `EnsureCategoryItemNameIsValid` (`:24`): each a `Result.Combine` of - `CommonInvariants.EnsureStringIsNotEmpty` plus `CommonInvariants.EnsureStringMaxLength`, with the + - `EnsureTitleIsValid` (`:19-22`) and `EnsureCategoryItemNameIsValid` (`:24-27`): each a `Result.Combine` + of `CommonInvariants.EnsureStringIsNotEmpty` plus `CommonInvariants.EnsureStringMaxLength`, with the message built through `string.Create(CultureInfo.InvariantCulture, ...)` so the text does not vary by - ambient culture. + ambient culture. That call is needed here and not in the sibling classes precisely because the length + is a `static readonly int` rather than a compile-time constant, so the interpolation is evaluated at + run time. - `EnsureCategoryItemNameIsUnique` (`:37-58`): takes the existing item collection plus an optional `excludeItemId` (so renaming an item to its own name during an update does not self-conflict). It - skips `IsDeleted` items and compares with `StringComparison.OrdinalIgnoreCase` (`:47-49`), returning - `Error.Conflict` on a duplicate (`:52`). The inline comment at `:43-45` records why the exclusion is - modeled as a nullable rather than defaulted: defaulting to `default(id)` would silently exclude every - unsaved sibling, since a database-generated `CategoryItem` id is 0 until the save. + skips `IsDeleted` items and compares with `StringComparison.OrdinalIgnoreCase` (`:46-49`), returning + `Error.Conflict("CategoryItem.Name.Duplicate")` on a duplicate (`:52-56`). The inline comment at + `:43-45` records why the exclusion is modeled as a nullable rather than defaulted: defaulting to + `default(id)` would silently exclude every unsaved sibling, since a database-generated `CategoryItem` + id is 0 until the save. - **Why it's built this way**: co-locating the rules per aggregate keeps the entity itself readable, and the `Result`-returning style composes with `Result.Combine`. The uniqueness method takes the collection as a parameter so it stays a **pure** function with no repository and no EF dependency, which is what lets the aggregate call it in memory. - **Where it's used**: called from [`Category`](#category)'s `Create`, `Update`, `AddCategoryItem`, and `UpdateCategoryItem`, and from [`CategoryItem`](#categoryitem)'s `Create` and `Update`; the length - constants are read by the Categories EF configuration. + constants are read by the Categories EF configurations + ([`ConferenceCategoryConfiguration`](group-19-conference-infrastructure.md#conferencecategoryconfiguration), + [`CategoryItemConfiguration`](group-19-conference-infrastructure.md#categoryitemconfiguration)). ### CategoryItem > MMCA.ADC.Conference.Domain · `MMCA.ADC.Conference.Domain.Categories` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/CategoryItem.cs:14` · Level 6 · class (sealed, child entity) @@ -2391,7 +2642,8 @@ are the primary references; the business rules themselves are catalogued in ADC' caller cannot bypass the parent's uniqueness and cascade rules by reaching in and calling `categoryItem.Update(...)` directly. The parent method is the only path that also runs BR-138. - **Where it's used**: loaded through [`Category`](#category) (EF `Include` or the navigation populator); - referenced by [`SpeakerCategoryItem`](#speakercategoryitem) as the target of that many-to-many bridge. + referenced by [`SpeakerCategoryItem`](#speakercategoryitem) and + [`SessionCategoryItem`](#sessioncategoryitem) as the target of those many-to-many bridges. ### EventInvariants > MMCA.ADC.Conference.Domain · `MMCA.ADC.Conference.Domain.Events` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:10` · Level 6 · class (static) @@ -2404,47 +2656,57 @@ are the primary references; the business rules themselves are catalogued in ADC' [`Result`](group-01-result-error-handling.md#result), [`Error`](group-01-result-error-handling.md#error); BCL `TimeZoneInfo` and `DateOnly`. Alias `RoomIdentifierType`. -- **Concept introduced, the module invariants class.** `[Rubric §4, Domain-Driven Design]` (invariants - live in the domain, expressed as reusable named rules rather than inline `if` blocks) and `[Rubric §8, - Data Architecture]` (the `MaxLength` constants are the single source of truth shared by the EF column - configuration and by validation, keeping schema and rule in sync). This is the same static-invariants - idiom taught for value objects in - [Group 02](group-02-domain-building-blocks.md#commoninvariants), applied to an aggregate: each +- **Concept**: the module invariants class, the same idiom taught for the framework at + [`CommonInvariants`](group-02-domain-building-blocks.md#commoninvariants) and for a Conference aggregate + at [`SessionInvariants`](#sessioninvariants), here in its widest form: fifteen length constants, a + reserved id range, and six rule methods covering a root plus two children. `[Rubric §4, Domain-Driven + Design]` (invariants live in the domain, expressed as reusable named rules rather than inline `if` + blocks) and `[Rubric §8, Data Architecture]` (the `MaxLength` constants are the single source of truth + shared by the EF column configuration and by validation, keeping schema and rule in sync). Each `Ensure...` returns a [`Result`](group-01-result-error-handling.md#result) rather than throwing, and callers combine several through `Result.Combine`. - **Walkthrough**, in teaching order: - - **Length constants** (`EventInvariants.cs:13-52`), all `public const int`: `NameMaxLength` (500), + - **Length constants** (`EventInvariants.cs:13-55`), all `public const int`: `NameMaxLength` (500), `DescriptionMaxLength` (4000), `TimeZoneMaxLength` (100), `SessionizeCodeMaxLength` (100), `VenueAddressMaxLength` (500), `VenueMapUrlMaxLength` (2000), `WiFiInfoMaxLength` (500), - `OrganizerContactEmailMaxLength` (255, `:34`), `SponsorshipPacketUrlMaxLength` (2000, `:37`), the four - room limits (`RoomNameMaxLength` 255, `RoomFloorMaxLength` 100, `RoomLocationMaxLength` 255, - `RoomAccessibilityInfoMaxLength` 500), and `AnswerValueMaxLength` (4000). - - **Reserved id range** (`EventInvariants.cs:54-62`): `RoomManualIdRangeStart` (999_999_000) and - `RoomManualIdRangeEnd` (999_999_999). Room ids are app-assigned, the int PK **is** the Sessionize id, - so organizer-created rooms draw from this reserved high range and never collide with a real Sessionize - id. The comment notes it mirrors [`SessionInvariants`](#sessioninvariants)`.ManualIdRangeStart`. - - **`EnsureNameIsValid`** (`:64`): a `Result.Combine` of a not-empty and a max-length check delegated to - [`CommonInvariants`](group-02-domain-building-blocks.md#commoninvariants). - - **`EnsureTimeZoneIsValid`** (`:75-104`): not-empty, then max-length, then + `OrganizerContactEmailMaxLength` (255, `:34`), `SponsorshipPacketUrlMaxLength` (2000, `:37`), + `TicketingUrlMaxLength` (2000, `:40`), the four room limits (`RoomNameMaxLength` 255, + `RoomFloorMaxLength` 100, `RoomLocationMaxLength` 255, `RoomAccessibilityInfoMaxLength` 500), and + `AnswerValueMaxLength` (4000, `:55`). + - **Reserved id range** (`EventInvariants.cs:57-65`): `RoomManualIdRangeStart` (999_999_000) and + `RoomManualIdRangeEnd` (999_999_999), both `static readonly RoomIdentifierType`. Room ids are + app-assigned, the int PK **is** the Sessionize id, so organizer-created rooms draw from this reserved + high range and never collide with a real Sessionize id. The comment notes it mirrors + [`SessionInvariants`](#sessioninvariants)`.ManualIdRangeStart`. + - **`EnsureNameIsValid`** (`:67-70`): a `Result.Combine` of a not-empty and a max-length check delegated + to [`CommonInvariants`](group-02-domain-building-blocks.md#commoninvariants). + - **`EnsureTimeZoneIsValid`** (`:78-107`): an explicit `IsNullOrWhiteSpace` guard, then max-length, then `TimeZoneInfo.FindSystemTimeZoneById` inside a `try`/`catch` that maps `TimeZoneNotFoundException` to an `Event.TimeZone.Invalid` invariant error (BR-87). The BCL is the authority on what counts as a - valid IANA identifier. - - **`EnsureDateRangeIsValid`** (`:113`): fails with `Event.DateRange.Invalid` when `endDate < startDate`. - - **`EnsureRoomCapacityIsValid`** (`:128`): rejects a non-positive capacity when one is supplied, written - as the pattern `capacity is <= 0` so a `null` capacity passes (BR-93). - - **`EnsureRoomNameIsValid`** (`:137`) and **`EnsureAnswerValueIsValid`** (`:142`): not-empty plus - max-length pairs for the two children. - - **`EnsureEventIsPublished`** (`:153`): guards actions that require a published event (BR-108). + valid IANA identifier; the domain carries no zone table of its own. + - **`EnsureDateRangeIsValid`** (`:116-123`): fails with `Event.DateRange.Invalid` when + `endDate < startDate`, so a single-day event (equal dates) is legal. + - **`EnsureRoomCapacityIsValid`** (`:131-138`): rejects a non-positive capacity when one is supplied, + written as the pattern `capacity is <= 0` so a `null` capacity passes (BR-93). + - **`EnsureRoomNameIsValid`** (`:140-143`) and **`EnsureAnswerValueIsValid`** (`:145-148`): not-empty + plus max-length pairs for the two children. + - **`EnsureEventIsPublished`** (`:156-163`): guards actions that require a published event (BR-108), + failing with `Event.NotPublished`. - **Why it's built this way**: keeping the length limits as constants on the invariants class, and having the EF configuration read the same constants, prevents the classic drift where a validator accepts a value the column then truncates. Returning [`Result`](group-01-result-error-handling.md#result) instead of throwing keeps validation composable at the factory, where several checks are combined into one error list. - **Where it's used**: the [`Event`](#event), [`Room`](#room), and - [`EventQuestionAnswer`](#eventquestionanswer) factories and updaters call these; the Events EF - configuration reads the length constants. -- **Caveats / not-in-source**: the reserved room-id range is defined here but nothing in this file assigns - from it. Which caller draws the next manual room id is not determinable from this source file. + [`EventQuestionAnswer`](#eventquestionanswer) factories and updaters call these; the length constants are + read by [`EventConfiguration`](group-19-conference-infrastructure.md#eventconfiguration) and + [`RoomConfiguration`](group-19-conference-infrastructure.md#roomconfiguration) and by the + application-layer event and room validation rules. The reserved room-id range is consumed in two places: + [`AddRoomHandler`](group-18-conference-application.md#addroomhandler) allocates the next free id from it + and refuses once it is exhausted (`AddRoomHandler.cs:96-104`), and + [`RoomSyncStrategy`](group-18-conference-application.md#roomsyncstrategy) skips any Sessionize room whose + id falls inside it, recording a warning rather than importing a colliding row + (`RoomSyncStrategy.cs:95-97`). ### QuestionInvariants > MMCA.ADC.Conference.Domain · `MMCA.ADC.Conference.Domain.Questions` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:10` · Level 6 · class (static) @@ -2460,64 +2722,38 @@ are the primary references; the business rules themselves are catalogued in ADC' richer. `[Rubric §4, Domain-Driven Design]`: the closed value sets and the answer rules are expressed as domain logic, not as API or UI validation. The permitted values are held as **data** rather than as long `switch` statements: `ValidQuestionEntities`, `ValidQuestionTypes`, and `ValidQuestionSources` are - `private static readonly string[]` (`QuestionInvariants.cs:28-34`) checked with + `private static readonly string[]` (`QuestionInvariants.cs:28`, `:31`, `:34`) checked with `StringComparer.OrdinalIgnoreCase`. - **Walkthrough** - Length constants (`QuestionInvariants.cs:13-25`): `QuestionTextMaxLength` (1000), the three 20-char - discriminator limits, and `TextAnswerMaxLength` (2000). - - The user-created id range `ManualIdRangeStart` / `ManualIdRangeEnd` (`:37`, `:40`, both 999_999_000 to - 999_999_999), distinguishing Sessionize ids from user-created ones. - - `EnsureQuestionTextIsValid` (`:48`): an explicit `IsNullOrWhiteSpace` guard first, then max-length via - `CommonInvariants.EnsureStringMaxLength`. - - `EnsureQuestionEntityIsValid` (`:68`), `EnsureQuestionTypeIsValid` (`:83`), and - `EnsureQuestionSourceIsValid` (`:98`): membership tests against the closed arrays, each returning a + discriminator limits (`QuestionEntityMaxLength`, `QuestionTypeMaxLength`, `QuestionSourceMaxLength`), + and `TextAnswerMaxLength` (2000). + - The user-created id range `ManualIdRangeStart` / `ManualIdRangeEnd` (`:37`, `:40`, 999_999_000 to + 999_999_999), distinguishing Sessionize ids from user-created ones, the same device + [`SessionInvariants`](#sessioninvariants) and [`EventInvariants`](#eventinvariants) use. + - `EnsureQuestionTextIsValid` (`:48-60`): an explicit `IsNullOrWhiteSpace` guard first, then max-length + via `CommonInvariants.EnsureStringMaxLength`. + - `EnsureQuestionEntityIsValid` (`:68-75`), `EnsureQuestionTypeIsValid` (`:83-90`), and + `EnsureQuestionSourceIsValid` (`:98-105`): membership tests against the closed arrays, each returning a specific `Error.Invariant` code. - `EnsureAnswerValueMatchesQuestionType` (`:115-126`): a `switch` expression on `questionType` dispatching to three private validators, because what counts as a valid answer depends on the question's type: - - `ValidateRatingAnswer` (`:128`): `int.TryParse` with `NumberStyles.Integer` and + - `ValidateRatingAnswer` (`:128-140`): `int.TryParse` with `NumberStyles.Integer` and `CultureInfo.InvariantCulture`, requiring 1 to 5, otherwise `Error.Validation`. The invariant culture is deliberate: a rating must parse identically wherever the request originates. - - `ValidateTextAnswer` (`:142`): length must not exceed `TextAnswerMaxLength` (2000). - - `ValidateEmailAnswer` (`:156`): constructs a `System.Net.Mail.MailAddress` and treats a + - `ValidateTextAnswer` (`:142-154`): length must not exceed `TextAnswerMaxLength` (2000). + - `ValidateEmailAnswer` (`:156-171`): constructs a `System.Net.Mail.MailAddress` and treats a `FormatException` as invalid, again letting the BCL be the format authority. - - An unrecognized type falls through to `Error.Invariant("Question.QuestionType.Unknown")` (`:121`). - Note the dispatch is an ordinal `switch` on the literal strings, so it is case-sensitive here even - though `EnsureQuestionTypeIsValid` accepts any casing. + - An unrecognized type falls through to `Error.Invariant("Question.QuestionType.Unknown")` + (`:121-125`). Note the dispatch is an ordinal `switch` on the literal strings, so it is + case-sensitive here even though `EnsureQuestionTypeIsValid` accepts any casing. - **Why it's built this way**: encoding answer-shape rules in the domain means the model rejects a malformed rating or email before it can reach a handler or the database, and expressing the allowed sets as arrays keeps adding a new question type a one-line data change rather than a code restructure. - **Where it's used**: called from [`Question`](#question)'s `Create` and `Update`; the answer-matching - rule is used by the answer-recording handlers in the Application tier; the length constants feed the - Questions EF configuration. - -### SpeakerInvariants -> MMCA.ADC.Conference.Domain · `MMCA.ADC.Conference.Domain.Speakers` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/SpeakerInvariants.cs:10` · Level 6 · class (static) - -- **What it is**: the domain rules for the [`Speaker`](#speaker) aggregate: first-name, last-name, and - answer-value non-empty and length constraints, plus the length constants for every profile field. -- **Depends on**: [`CommonInvariants`](group-02-domain-building-blocks.md#commoninvariants), - [`Result`](group-01-result-error-handling.md#result). -- **Concept**: cross-reference [`EventInvariants`](#eventinvariants) for the pattern. This is the simplest - sibling in the family: no cross-field checks, no type dispatch. The bulk of the class is length - constants for the rich speaker profile (`SpeakerInvariants.cs:13-40`): `FirstNameMaxLength` and - `LastNameMaxLength` (200), `EmailMaxLength` (255), `TagLineMaxLength` (500), - `TwitterHandleMaxLength` (100), the four URL fields at 2000 (`ProfilePictureMaxLength`, - `LinkedInUrlMaxLength`, `GitHubUrlMaxLength`, `WebsiteUrlMaxLength`), and `AnswerValueMaxLength` (4000). - All are `public const int` and are read by the Speakers EF configuration so column widths stay in sync. -- **Walkthrough**: three `Ensure...` methods (`SpeakerInvariants.cs:42-55`), each a `Result.Combine` of - `CommonInvariants.EnsureStringIsNotEmpty` plus `CommonInvariants.EnsureStringMaxLength`: - `EnsureFirstNameIsValid` (`:42`), `EnsureLastNameIsValid` (`:47`), and `EnsureAnswerValueIsValid` - (`:52`, using `AnswerValueMaxLength`). Note what is **absent**: email is not validated here even though - `EmailMaxLength` is declared. The [`Speaker`](#speaker) factory parses it through the - [`Email`](group-02-domain-building-blocks.md#email) value object instead (`Speaker.cs:125`), so format - correctness is the value object's responsibility and the constant exists only to size the column. -- **Why it's built this way**: the split is a deliberate division of labor. Rules that are genuinely - speaker-specific live here; anything that is a reusable concept in its own right (a well-formed email) - becomes a value object that any module can hold. -- **Where it's used**: [`Speaker`](#speaker)'s `Create` and `Update`, and - [`SpeakerQuestionAnswer`](#speakerquestionanswer)'s `Create` and `UpdateAnswer`; the length constants - feed the Speakers EF configuration. + rule is used by the answer-recording handlers in the Application tier; the length constants feed + [`QuestionConfiguration`](group-19-conference-infrastructure.md#questionconfiguration). ### Event > MMCA.ADC.Conference.Domain · `MMCA.ADC.Conference.Domain.Events` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:23` · Level 7 · class (sealed, aggregate root) @@ -2550,68 +2786,83 @@ are the primary references; the business rules themselves are catalogued in ADC' 1. **Selective auditing.** `Event` implements [`IAuditedEntity`](group-02-domain-building-blocks.md#iauditedentity) (`Event.cs:23`). The class doc (`Event.cs:16-20`) states the reason as a cost-benefit judgment rather than a blanket policy: the - event record is the schedule everything else hangs off, several organizers edit it, and a wrong date - or venue is felt by every attendee, so one trail row per change is worth it. + event record is the schedule everything else hangs off, several organizers edit it, and a wrong date, + venue or live window is felt by every attendee, so one trail row per change is worth it. 2. **Selective navigation.** `Rooms` and `EventSpeakers` are marked `[Navigation(IsCollection = true)]` - (`Event.cs:82`, `:88`) but `EventQuestionAnswers` deliberately is **not** (`Event.cs:93-103`). + (`Event.cs:88`, `:94`) but `EventQuestionAnswers` deliberately is **not** (`Event.cs:99-109`). `[Rubric §12, Performance & Scalability]`: the remarks record that the collection grows with - attendance rather than with the schedule, and that it was riding along on public reads that never - render it. Handlers that genuinely need it pass an explicit `includes:` list instead. + attendance rather than with the schedule, that it rode along on public reads that never render it, + and that it is per-attendee feedback behind an anonymous endpoint. Handlers that genuinely need it + pass an explicit `includes:` list instead. - **Walkthrough**, in teaching order: - - **`[IdValueGenerated]`** on the class (`Event.cs:22`): the factory reads this at runtime through - `typeof(Event).IsIdValueGenerated` (`Event.cs:177`). - - **Scalar state** (`Event.cs:25-77`): `Name`, `Description?`, `StartDate`/`EndDate` (`DateOnly`), + - **`[IdValueGenerated]`** on the class (`Event.cs:22`): the factory reads this at run time through + `typeof(Event).IsIdValueGenerated` (`Event.cs:187`). + - **Scalar state** (`Event.cs:25-83`): `Name`, `Description?`, `StartDate`/`EndDate` (`DateOnly`), `TimeZone`, `SessionizeCode?`, `VenueAddress?`, `VenueMapUrl?`, `WiFiInfo?`, `OrganizerContactEmail?` (`:56`, falling back to the host-configured support address when absent), `SponsorshipPacketUrl?` (`:62`, whose absence hides the sponsorship call to action entirely), - `IsPublished`, `QuestionModerationDefault` (`:71`, the BR-233 initial status a newly submitted - live-layer question receives), and the nullable `LastSessionizeRefreshOn`/`LastSessionizeRefreshBy` - refresh-audit pair. All have private setters. - - **Child collections** (`Event.cs:79-103`): three private `List` backing fields exposed as + `TicketingUrl?` (`:68`, whose absence likewise hides the ticketing call to action on the landing and + public event pages), `IsPublished`, `QuestionModerationDefault` (`:77`, the BR-233 initial status a + newly submitted live-layer question receives), and the nullable + `LastSessionizeRefreshOn`/`LastSessionizeRefreshBy` refresh-audit pair. All have private setters. + - **Child collections** (`Event.cs:85-109`): three private `List` backing fields exposed as `IReadOnlyCollection` projections. - - **Constructors** (`Event.cs:106-136`): a private parameterless EF constructor that seeds the - non-nullable strings, plus a private field constructor used by the factory. - - **`Create`** (`Event.cs:155-199`): combines `EnsureNameIsValid`, `EnsureTimeZoneIsValid`, and - `EnsureDateRangeIsValid` (`:170-173`); on success builds the instance with - `Id = isIdValueGenerated ? default : id!.Value` (`:192`), sets `QuestionModerationDefault`, and raises - `EventChanged(Added)` (`:196`). - - **`Update`** (`Event.cs:217`): re-validates the same three invariants, writes the scalars including the - moderation default, raises `EventChanged(Updated)`. - - **`Publish`** and **`Unpublish`** (`Event.cs:258`, `:278`): flip `IsPublished`, refusing a no-op + - **Constructors** (`Event.cs:112-144`): a private parameterless EF constructor that seeds the + non-nullable strings, plus a private twelve-parameter field constructor used by the factory. + - **`Create`** (`Event.cs:164-210`): combines `EnsureNameIsValid`, `EnsureTimeZoneIsValid`, and + `EnsureDateRangeIsValid` (`:180-183`); on success builds the instance with + `Id = isIdValueGenerated ? default : id!.Value` (`:203`) and sets `QuestionModerationDefault` (`:204`, + defaulted to `QuestionModerationDefault.Pending` at the parameter, `:175`), then raises + `EventChanged(Added)` (`:207`). + - **`Update`** (`Event.cs:229-268`): re-validates the same three invariants (`:244-247`), writes the + scalars including the moderation default and the optional email and two URLs, raises + `EventChanged(Updated)` (`:265`). + - **`Publish`** and **`Unpublish`** (`Event.cs:272`, `:292`): flip `IsPublished`, refusing a no-op transition with `Event.AlreadyPublished` / `Event.AlreadyUnpublished`, and raise `EventChanged(Updated)`. - - **`RecordSessionizeRefresh`** (`Event.cs:302`): stamps `LastSessionizeRefreshOn`/`By` from a - caller-supplied UTC instant. The parameter doc (`:298-301`) is explicit that the value comes from an + - **`RecordSessionizeRefresh`** (`Event.cs:316-320`): stamps `LastSessionizeRefreshOn`/`By` from a + caller-supplied UTC instant. The parameter doc (`:312-315`) is explicit that the value comes from an injected `TimeProvider` so the domain never reads an ambient clock. `[Rubric §14, Testability]`. Note this method returns `void` and raises no event. - - **`Delete`** (`Event.cs:314-345`): overrides the base soft-delete, then cascade soft-deletes every - non-deleted room, event-speaker, and answer (BR-72), and raises `EventChanged(Deleted)`. Session - cascade is deliberately **not** here: it is handled a layer up (BR-127) because sessions are separate - aggregates, which is what + - **`Delete`** (`Event.cs:328-359`): overrides the base soft-delete, then cascade soft-deletes every + non-deleted room, event-speaker, and answer (BR-72, `:334-353`), and raises `EventChanged(Deleted)`. + Session cascade is deliberately **not** here: it is handled a layer up (BR-127) because sessions are + separate aggregates, which is what [`IEventCascadeDeletionDomainService`](#ieventcascadedeletiondomainservice) exists for. - - **Room management** (`Event.cs:360-501`): `AddRoom` (`:360`) checks name uniqueness first, delegates to - `Room.Create`, adds, and raises `RoomChanged(Added)`; `UpdateRoom` (`:397`) resolves the child, - re-checks uniqueness excluding itself, and delegates; `RestoreRoom` (`:439`) is the BR-135 reactivation - path, taking the room **instance** rather than an id because a soft-deleted row is excluded by the - global query filter and so is not reachable through the loaded collection (`:430-434`). It re-runs the - uniqueness bar, calls `room.Update` before `room.Reactivate()` so a rejected name leaves the room - untouched and still deleted rather than half-restored (`:459-467`), and raises `RoomChanged(Added)` - because the room re-enters the visible set. `RemoveRoom` (`:482`) soft-deletes and raises + - **Room management** (`Event.cs:374-529`): `AddRoom` (`:374`) checks name uniqueness first, delegates to + `Room.Create`, adds, and raises `RoomChanged(Added)`; `UpdateRoom` (`:411`) resolves the child, + re-checks uniqueness excluding itself, and delegates. `RestoreRoom` (`:454`) is the BR-135 + reactivation path and the most defensive method on the type. It takes the room **instance** rather + than an id because a soft-deleted row is excluded by the global query filter and so is not reachable + through the loaded collection (`:442-447`), and it then runs its guards in order: the room must belong + to **this** event (`:463`, `Event.Room.WrongEvent`), whose comment at `:456-460` explains the stakes + precisely (`Room.EventId` has no setter and is populated purely by EF relationship fixup off this + `Rooms` navigation, so adding a foreign room here would silently rewrite its `EventId` on save and + move the row out of its real event); the room must actually be soft-deleted (`:472`, + `Event.Room.NotDeleted`); the incoming name must clear the same uniqueness bar as an add (`:484`, + with the comment at `:481-483` noting that otherwise a Sessionize refresh restoring a room whose name + an organizer has since reused would fail on the database index and abort the whole refresh); and only + then `room.Update` runs **before** `room.Reactivate()` (`:490`, `:494`) so a rejected name leaves the + room untouched and still deleted rather than half-restored. It raises `RoomChanged(Added)` (`:501`) + because the room re-enters the visible set. `RemoveRoom` (`:511`) soft-deletes and raises `RoomChanged(Deleted)`. - - **Event-speaker management** (`Event.cs:511-597`): `AddEventSpeaker` (`:511`) guards duplicates in - memory (`:515`) with `Event.Speaker.Duplicate`; `RestoreEventSpeaker` (`:548`) is the join-entity - counterpart to `RestoreRoom` and needs no field re-apply because the join carries no organizer-entered - data (`:541-545`); `RemoveEventSpeaker` (`:578`) soft-deletes. - - **Answer management** (`Event.cs:608-674`): `AddEventQuestionAnswer`, `UpdateEventQuestionAnswer`, - `RemoveEventQuestionAnswer`. Unlike the two collections above, the add has **no** duplicate guard: an - event answering the same question twice is not blocked in the domain. - - **Populator hooks** (`Event.cs:500`, `:596`, `:673`): `SetRooms`, `SetEventSpeakers`, and + - **Event-speaker management** (`Event.cs:540-626`): `AddEventSpeaker` (`:540`) guards duplicates in + memory (`:544`) with `Event.Speaker.Duplicate`; `RestoreEventSpeaker` (`:577`) is the join-entity + counterpart to `RestoreRoom`, keeping the not-deleted guard (`:581`, `Event.Speaker.NotDeleted`) but + needing no field re-apply and no uniqueness re-check because the join carries no organizer-entered + data (`:570-574`); `RemoveEventSpeaker` (`:607`) soft-deletes. + - **Answer management** (`Event.cs:637-698`): `AddEventQuestionAnswer` (`:637`), + `UpdateEventQuestionAnswer` (`:661`), `RemoveEventQuestionAnswer` (`:684`). Unlike the two collections + above, the add has **no** duplicate guard: an event answering the same question twice is not blocked + in the domain. + - **Populator hooks** (`Event.cs:529`, `:625`, `:702`): `SetRooms`, `SetEventSpeakers`, and `SetEventQuestionAnswers` are `internal` and call the base `SetItems`, raising no events ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html)). - - **Private helpers** (`Event.cs:687-719`): `EnsureRoomNameIsUnique` (`:687`), whose doc comment notes + - **Private helpers** (`Event.cs:716-748`): `EnsureRoomNameIsUnique` (`:716`), whose doc comment notes the ordinal-ignore-case comparison is chosen to match the database uniqueness index under the server's - default case-insensitive collation; and the three `Get...OrNotFound` wrappers over the base - `GetChildOrNotFound` so a missing child returns an + default case-insensitive collation, and which uses the same nullable-exclusion shape as + [`CategoryInvariants`](#categoryinvariants) (`:724`); and the three `Get...OrNotFound` wrappers + (`:735`, `:740`, `:745`) over the base `GetChildOrNotFound` so a missing child returns an [`Error`](group-01-result-error-handling.md#error) rather than a null. - **Why it's built this way**: routing every child change through the root is what makes the invariants (no duplicate room name, cascade on delete) enforceable at all, and what gives the outbox an ordered @@ -2620,7 +2871,9 @@ are the primary references; the business rules themselves are catalogued in ADC' reinstate a room or a speaker, and reactivating a soft-deleted row preserves its id and history where re-creating it would not (BR-135). - **Where it's used**: loaded and mutated by the Conference application-layer command handlers (Group 18); - persisted through the Events EF configuration; projected to DTOs for the read endpoints. + persisted through [`EventConfiguration`](group-19-conference-infrastructure.md#eventconfiguration); + projected to DTOs for the read endpoints; and referenced by FK from [`Activity`](#activity), + [`Room`](#room), [`EventSpeaker`](#eventspeaker), and [`EventQuestionAnswer`](#eventquestionanswer). ### EventQuestionAnswer > MMCA.ADC.Conference.Domain · `MMCA.ADC.Conference.Domain.Events` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventQuestionAnswer.cs:13` · Level 7 · class (sealed, child entity) @@ -2641,7 +2894,7 @@ are the primary references; the business rules themselves are catalogued in ADC' - **Walkthrough**: `[IdValueGenerated]` (`:12`); `QuestionId` (the FK to the answered question) and `AnswerValue`, both with private setters (`:15-19`); the `[Navigation] Event?` back-navigation and the get-only `EventId` FK (`:21-26`); a private EF constructor that seeds `AnswerValue = string.Empty` and a - private field constructor (`:29-37`); `Create` (`:46-64`), which validates through + private field constructor (`:28-37`); `Create` (`:46-64`), which validates through `EventInvariants.EnsureAnswerValueIsValid` and assigns `Id = isIdValueGenerated ? default : id!.Value` (`:60`); `UpdateAnswer` (`:71-80`), which re-validates and then writes `AnswerValue`. @@ -2649,8 +2902,14 @@ are the primary references; the business rules themselves are catalogued in ADC' means it shares the event's transaction and cascade delete, and its lifecycle notifications flow through the root's ordered event stream. - **Where it's used**: created and mutated only through [`Event`](#event)'s `AddEventQuestionAnswer`, - `UpdateEventQuestionAnswer`, and `RemoveEventQuestionAnswer`. Because the collection is not marked - `[Navigation]`, handlers that need it request it explicitly rather than getting it from the populator. + `UpdateEventQuestionAnswer`, and `RemoveEventQuestionAnswer`; mapped by + [`EventQuestionAnswerConfiguration`](group-19-conference-infrastructure.md#eventquestionanswerconfiguration). + Because the collection is not marked `[Navigation]`, handlers that need it request it explicitly rather + than getting it from the populator. +- **Caveats / not-in-source**: nothing in this file checks that `AnswerValue` matches the referenced + question's type. `QuestionInvariants.EnsureAnswerValueMatchesQuestionType` + (`QuestionInvariants.cs:115`) exists for that but is not called from here, so the BR-124 check has to be + applied by a caller in the Application tier. ### EventSpeaker > MMCA.ADC.Conference.Domain · `MMCA.ADC.Conference.Domain.Events` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventSpeaker.cs:13` · Level 7 · class (sealed, join entity) @@ -2671,8 +2930,8 @@ are the primary references; the business rules themselves are catalogued in ADC' There is no `Update`, because a join either exists or it does not. - **Walkthrough**: `[IdValueGenerated]` (`:12`); `SpeakerId` (`:16`); `[Navigation] Event?` and the get-only `EventId` (`:18-23`); an empty private EF constructor and a one-line private field constructor - (`:26-28`); `Create` (`:36`); and `Reactivate()` (`:56`), a one-line delegation to the base `Undelete()`. - The `Reactivate` doc (`:50-54`) explains its reason for existing: the join row carries the + (`:26`, `:28`); `Create` (`:36-48`); and `Reactivate()` (`:56`), a one-line delegation to the base + `Undelete()`. The `Reactivate` doc (`:50-55`) explains its reason for existing: the join row carries the Sessionize-assigned speaker id, so an association that reappears in the feed is reactivated rather than duplicated by a second row (BR-135). - **Why it's built this way**: an explicit join entity is what lets [`Event`](#event) raise @@ -2680,7 +2939,8 @@ are the primary references; the business rules themselves are catalogued in ADC' it is what makes the soft-delete-then-reactivate cycle possible under a repeatedly re-run import. - **Where it's used**: created, restored, and removed only through [`Event`](#event)'s `AddEventSpeaker`, `RestoreEventSpeaker`, and `RemoveEventSpeaker`; the duplicate-speaker guard lives in the root - (`Event.cs:515`), not here. + (`Event.cs:544`), not here. Mapped by + [`EventSpeakerConfiguration`](group-19-conference-infrastructure.md#eventspeakerconfiguration). ### Question > MMCA.ADC.Conference.Domain · `MMCA.ADC.Conference.Domain.Questions` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/Question.cs:14` · Level 7 · class (sealed, aggregate root) @@ -2690,7 +2950,8 @@ are the primary references; the business rules themselves are catalogued in ADC' (`QuestionEntity`), has an input type (`QuestionType`), a sort order, an `IsRequired` flag, and a `QuestionSource`. Unlike the other roots in this part it owns **no** children: answers live on the answering entity ([`EventQuestionAnswer`](#eventquestionanswer), - [`SpeakerQuestionAnswer`](#speakerquestionanswer)). + [`SpeakerQuestionAnswer`](#speakerquestionanswer), + [`SessionQuestionAnswer`](#sessionquestionanswer)). - **Depends on**: [`AuditableAggregateRootEntity`](group-02-domain-building-blocks.md#auditableaggregaterootentitytidentifiertype) (`Question.cs:14`), [`QuestionInvariants`](#questioninvariants), @@ -2702,7 +2963,7 @@ are the primary references; the business rules themselves are catalogued in ADC' (`Question.cs:14`), so question ids are explicitly assigned, typically by Sessionize. `Create` still runs the same `typeof(Question).IsIdValueGenerated` check (`:87`), which here evaluates to `false`, so the `id!.Value` branch is always taken (`:91`). `[Rubric §8, Data Architecture]`: the id-origin decision - is expressed once, as an attribute on the type, and every factory reads it uniformly. + is expressed once, as an attribute on the type (or its absence), and every factory reads it uniformly. - **Walkthrough** - **Scalars** (`Question.cs:16-32`): `QuestionText`, `QuestionEntity`, `QuestionType`, `Sort`, `IsRequired`, `QuestionSource`, all with private setters. The three discriminators are plain strings @@ -2722,11 +2983,13 @@ are the primary references; the business rules themselves are catalogued in ADC' user-created one, which is what the reserved manual id range in [`QuestionInvariants`](#questioninvariants) also protects. - **Where it's used**: referenced by scalar FK (`QuestionId`) from - [`EventQuestionAnswer`](#eventquestionanswer) and [`SpeakerQuestionAnswer`](#speakerquestionanswer); - fed into the feedback and custom-form features in the Application and UI tiers. + [`EventQuestionAnswer`](#eventquestionanswer), [`SpeakerQuestionAnswer`](#speakerquestionanswer), and + [`SessionQuestionAnswer`](#sessionquestionanswer); mapped by + [`QuestionConfiguration`](group-19-conference-infrastructure.md#questionconfiguration); fed into the + feedback and custom-form features in the Application and UI tiers. - **Caveats / not-in-source**: `QuestionEntity` accepts "Speaker" (`QuestionInvariants.cs:28`) while the - property's own XML doc still says "Session" or "Event" (`Question.cs:19`). The array is the operative - rule; the doc comment is stale. + property's own XML doc still says "Session" or "Event" (`Question.cs:19`), as do the `Create` parameter + docs (`:64`). The array is the operative rule; the doc comments are stale. ### Room > MMCA.ADC.Conference.Domain · `MMCA.ADC.Conference.Domain.Events` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Room.cs:12` · Level 7 · class (sealed, child entity) @@ -2749,173 +3012,87 @@ are the primary references; the business rules themselves are catalogued in ADC' - **Walkthrough**: scalars `Name`, `Sort`, `Capacity?`, `Floor?`, `Location?`, `AccessibilityInfo?` (`Room.cs:14-30`); `[Navigation] Event?` and the get-only `EventId` (`:32-37`); the EF constructor and the private field constructor (`:40-56`); `Create` (`:69-98`) validating `EnsureRoomNameIsValid` plus - `EnsureRoomCapacityIsValid`; `Update` (`:110-132`) re-validating the same pair and writing all six - scalars; `Reactivate()` (`:140`), a one-line delegation to the base `Undelete()` whose doc (`:134-139`) - explains that a room reappearing in the Sessionize feed has to be reactivated rather than re-created - precisely because its id is externally owned (BR-135). As a child it raises no events itself. + `EnsureRoomCapacityIsValid` (`:78-80`); `Update` (`:110-132`) re-validating the same pair and writing all + six scalars; `Reactivate()` (`:140`), a one-line delegation to the base `Undelete()` whose doc + (`:134-139`) explains that a room reappearing in the Sessionize feed has to be reactivated rather than + re-created precisely because its id is externally owned (BR-135). As a child it raises no events itself. - **Why it's built this way**: preserving the Sessionize id as the PK keeps imported rooms stable across refreshes, so a re-import updates in place instead of creating duplicates, and the reserved manual range lets organizers add rooms without an id clash. Note the room-name uniqueness rule is **not** here: it - lives in [`Event`](#event) (`Event.cs:687`), because uniqueness is a statement about the collection, - which only the root can see. + lives in [`Event`](#event) (`Event.cs:716`), because uniqueness is a statement about the collection, + which only the root can see. The same reasoning puts the "does this room belong to this event" check in + the root as well (`Event.cs:463`): `EventId` is get-only here (`Room.cs:37`), so only EF relationship + fixup ever sets it. - **Where it's used**: created, updated, restored, and removed through [`Event`](#event)'s `AddRoom`, `UpdateRoom`, `RestoreRoom`, and `RemoveRoom`, each of which raises [`RoomChanged`](#roomchanged); - referenced by [`Session`](#session) scheduling. + mapped by [`RoomConfiguration`](group-19-conference-infrastructure.md#roomconfiguration); referenced by + [`Session`](#session) scheduling. -### Speaker -> MMCA.ADC.Conference.Domain · `MMCA.ADC.Conference.Domain.Speakers` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:22` · Level 7 · class (sealed, aggregate root) +### Activity +> MMCA.ADC.Conference.Domain · `MMCA.ADC.Conference.Domain.Activities` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:20` · Level 8 · class (sealed, aggregate root) -- **What it is**: the aggregate root for a conference speaker. It carries rich profile data (names, an - optional [`Email`](group-02-domain-building-blocks.md#email) value object, bio, tag line, social and URL - links, `IsTopSpeaker`), owns [`SpeakerCategoryItem`](#speakercategoryitem) join entities and - [`SpeakerQuestionAnswer`](#speakerquestionanswer) children, and holds the cross-module link - `LinkedUserId`. Speaker ids are Sessionize-assigned GUIDs (`Speaker.cs:12-14`). +- **What it is**: the aggregate root for a social or networking activity attached to a conference event: a + pre-conference party, a morning coffee connect, an after-party, a closing ceremony (`Activity.cs:11-18`). + It carries a name, an optional description, a start and end time, three optional venue fields, a sort + order, and the FK to its owning [`Event`](#event). Activity ids are database-generated. - **Depends on**: [`AuditableAggregateRootEntity`](group-02-domain-building-blocks.md#auditableaggregaterootentitytidentifiertype) - and [`IAuditedEntity`](group-02-domain-building-blocks.md#iauditedentity) (`Speaker.cs:22`), - [`SpeakerInvariants`](#speakerinvariants), [`Email`](group-02-domain-building-blocks.md#email), - [`SpeakerCategoryItem`](#speakercategoryitem), [`SpeakerQuestionAnswer`](#speakerquestionanswer), - [`Result`](group-01-result-error-handling.md#result) and - [`Error`](group-01-result-error-handling.md#error), + (the base, `Activity.cs:20`), [`ActivityInvariants`](#activityinvariants), [`Event`](#event) (the + navigation target, `:58`), [`Result`](group-01-result-error-handling.md#result), [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate), + [`IdValueGeneratedAttribute`](group-02-domain-building-blocks.md#idvaluegeneratedattribute), [`NavigationAttribute`](group-11-navigation-populators.md#navigationattribute), and the - [`SpeakerChanged`](#speakerchanged) / [`SpeakerCategoryItemChanged`](#speakercategoryitemchanged) / - [`SpeakerQuestionAnswerChanged`](#speakerquestionanswerchanged) domain events. Aliases - `SpeakerIdentifierType` (a `Guid`), `UserIdentifierType`, `CategoryItemIdentifierType`, - `SpeakerCategoryItemIdentifierType`, `SpeakerQuestionAnswerIdentifierType`, `QuestionIdentifierType`. -- **Concept**: the aggregate root pattern (see [`Category`](#category)) plus a **cross-module link field** - and **value-object composition**. `[Rubric §7, Microservices Readiness]` and `[Rubric §8, Data - Architecture]`: `LinkedUserId` (`Speaker.cs:58`) is a nullable **scalar** FK to `User` in the Identity - database. It cannot be an EF navigation, because the two entities live in different databases - ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)); the bidirectional link - is instead maintained through the integration events - [`SpeakerLinkedToUser`](#speakerlinkedtouser) and - [`SpeakerUnlinkedFromUser`](#speakerunlinkedfromuser). `[Rubric §11, Security]` and `[Rubric §30, - Compliance and Privacy]`: the class implements - [`IAuditedEntity`](group-02-domain-building-blocks.md#iauditedentity), and the doc (`Speaker.cs:15-20`) - gives the reason: the record carries personal data and the link that grants a person speaker rights over - their sessions, so "who linked this account to this speaker" has to be answerable. + [`ActivityChanged`](#activitychanged) domain event. Aliases `ActivityIdentifierType`, + `EventIdentifierType`. +- **Concept**: the aggregate root taught at [`Category`](#category) and [`Event`](#event), in its + **childless** form (compare [`Question`](#question)). What Activity teaches that the others do not is a + modeling decision stated outright in the class doc (`Activity.cs:11-18`): an activity is deliberately + **not** a [`Session`](#session). It has no room and no speakers, and it frequently happens at an + external venue, so the venue is carried on the activity itself instead of being inherited from the + event. `[Rubric §4, Domain-Driven Design]`: rather than overload `Session` with nullable + room/speaker/venue fields and a "kind" discriminator, the ubiquitous language gets a second, smaller + aggregate whose invariants are genuinely different. `[Rubric §16, Maintainability]`: the cost of that + choice is a parallel command, query, and UI slice, and the benefit is that neither type carries the + other's optionality. - **Walkthrough** - - **Profile scalars** (`Speaker.cs:24-55`) and the computed `FullName => $"{FirstName} {LastName}"` - (`:61`), which is a projection, not a stored column. - - **Child collections** (`Speaker.cs:63-73`): both `SpeakerCategoryItems` and `SpeakerQuestionAnswers` - are private lists exposed read-only and marked `[Navigation(IsCollection = true)]`. (Contrast - [`Event`](#event), where the answers collection is deliberately unmarked.) - - **`Create`** (`Speaker.cs:112-159`): parses `email` into an - [`Email`](group-02-domain-building-blocks.md#email) value object **first** (`:122-129`), so a supplied - but malformed email fails before the name checks run and the caller is not handed a partial error - list; then `Result.Combine`s the two name invariants. The id assignment (`:153`) is the one that - differs from every sibling in this chapter: `Id = id ?? (isIdValueGenerated ? default : - Guid.NewGuid())`. The inline comment (`:148-152`) records why: `SpeakerIdentifierType` is a - client-assigned `Guid`, and organizer-created speakers and the sample-data seeder both pass `null`, so - the factory generates one rather than dereferencing a null `Nullable`. The old `id!.Value` threw - "Nullable object must have a value" and killed both Conference's startup seeding and every organizer - "create speaker" call. - - **`Update`** (`Speaker.cs:183-226`): the same email-first shape, then the name invariants, then eleven - scalar writes and `SpeakerChanged(Updated)`. Read the remarks (`:164-170`): the method deliberately - does **not** touch `LinkedUserId`, because `LinkUser`/`UnlinkUser` are the only paths that carry the - BR-208 uniqueness check and raise the link and unlink events that keep Identity's `User.LinkedSpeakerId` - in sync. Writing the link here would silently desynchronize the two sides. - - **`Delete`** (`Speaker.cs:239-255`): the BR-70 cross-context cleanup. It captures `LinkedUserId` into a - local **before** `base.Delete()` (`:242`), clears the field within the Conference context (`:249`), - then raises `SpeakerChanged(Deleted, ..., previousLinkedUserId)` (`:251`) so the downstream handler can - clear `User.LinkedSpeakerId` in Identity without a synchronous call back - ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)). The doc (`:228-237`) - also records a deliberate **non**-cascade: the child associations survive the soft-delete and are not - cascaded (BR-70, BR-71), because the Sessionize import reactivates them in place when the speaker - returns (BR-135) and no cascade-restore counterpart exists; junction reads follow the parent's - visibility (BR-132), so the surviving children are not observable meanwhile. This is the opposite - choice from [`Category`](#category) and [`Event`](#event), and it is worth understanding why: cascade - is right when children have no independent upstream lifecycle, and wrong when they do. - - **`LinkUser`** / **`UnlinkUser`** (`Speaker.cs:260`, `:278`): guard against already-linked and - not-linked with `Speaker.AlreadyLinked` / `Speaker.NotLinked` (BR-209), set or clear `LinkedUserId`, - and raise `SpeakerChanged(Updated)`. - - **Category-item management** (`Speaker.cs:302-389`): `AddSpeakerCategoryItem` (`:302`) runs an - in-memory duplicate guard (`:306`) returning `Speaker.CategoryItem.Duplicate` before delegating to the - child factory, the same shape as `Event.AddEventSpeaker`; `RestoreSpeakerCategoryItem` (`:340`) is the - BR-135 reactivation counterpart; `RemoveSpeakerCategoryItem` (`:370`) soft-deletes. - - **Answer management** (`Speaker.cs:400-466`): `AddSpeakerQuestionAnswer` (`:400`), - `UpdateSpeakerQuestionAnswer` (`:424`), `RemoveSpeakerQuestionAnswer` (`:447`). As with - [`Event`](#event)'s answers, the add carries **no** duplicate guard: a speaker answering the same - question twice is not blocked in the domain. - - **Populator hooks and helpers** (`Speaker.cs:388`, `:465`, `:469-477`): `SetSpeakerCategoryItems` and - `SetSpeakerQuestionAnswers` are `internal` and event-free; the two `Get...OrNotFound` wrappers turn a - missing child into an [`Error`](group-01-result-error-handling.md#error). -- **Why it's built this way**: `LinkedUserId` as a nullable scalar rather than a navigation is the direct - consequence of database-per-service - ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)), and the link is kept - consistent through integration events - ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)) rather than a - cross-database FK. Note also what is **not** a field here: speaker locality is modeled as a - [`CategoryItem`](#categoryitem) attached through - [`SpeakerCategoryItem`](#speakercategoryitem), not as a `Speaker.Location` property, which is why that - collection exists rather than a scalar. -- **Where it's used**: read and projected by the Conference query handlers, mutated by the speaker command - handlers (Group 18), and referenced by FK from [`EventSpeaker`](#eventspeaker), the Engagement bookmark - entities, and Identity's `User`. - -### SpeakerCategoryItem -> MMCA.ADC.Conference.Domain · `MMCA.ADC.Conference.Domain.Speakers` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/SpeakerCategoryItem.cs:13` · Level 7 · class (sealed, join entity) - -- **What it is**: the join entity linking a [`Speaker`](#speaker) to a [`CategoryItem`](#categoryitem) - (`SpeakerCategoryItem.cs:8-10`). It holds `CategoryItemId`, the back-navigation `Speaker?`, and the FK - `SpeakerId`. Database-generated id. -- **Depends on**: - [`AuditableBaseEntity`](group-02-domain-building-blocks.md#auditablebaseentitytidentifiertype) - (`SpeakerCategoryItem.cs:13`), [`Speaker`](#speaker), - [`Result`](group-01-result-error-handling.md#result), - [`IdValueGeneratedAttribute`](group-02-domain-building-blocks.md#idvaluegeneratedattribute), - [`NavigationAttribute`](group-11-navigation-populators.md#navigationattribute). -- **Concept**: the explicit join entity taught at [`EventSpeaker`](#eventspeaker), structurally identical - apart from which two entities it bridges. `[Rubric §4, Domain-Driven Design]`. This is also the physical - representation of how a speaker's topics **and locality** are tracked: rather than a `Speaker.Location` - field, locality is a [`CategoryItem`](#categoryitem) attached through this join. -- **Walkthrough**: `[IdValueGenerated]` (`:12`); `CategoryItemId` (`:16`); `[Navigation] Speaker?` and the - get-only `SpeakerId` (`:18-23`); an empty private EF constructor and a one-line field constructor - (`:26-28`); `Create` (`:36-48`), a pure FK assignment with no content validation and no domain event - ([`Speaker`](#speaker) raises [`SpeakerCategoryItemChanged`](#speakercategoryitemchanged)); and - `Reactivate()` (`:56`), the same `Undelete()` delegation as [`EventSpeaker`](#eventspeaker), for the same - BR-135 reason (`:50-55`). -- **Why it's built this way**: modeling locality and topic as category items rather than as scalar speaker - columns means the vocabulary is organizer-editable data ([`Category`](#category) rows) instead of a code - change, and the explicit join gives each association its own soft-delete and reactivation path. -- **Where it's used**: loaded through `Speaker.SpeakerCategoryItems`; created, restored, and removed only - through [`Speaker`](#speaker)'s `AddSpeakerCategoryItem`, `RestoreSpeakerCategoryItem`, and - `RemoveSpeakerCategoryItem`; consumed by the speaker-detail and locality features. - -### SpeakerQuestionAnswer -> MMCA.ADC.Conference.Domain · `MMCA.ADC.Conference.Domain.Speakers` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/SpeakerQuestionAnswer.cs:13` · Level 7 · class (sealed, child entity) - -- **What it is**: the child entity of [`Speaker`](#speaker) holding that speaker's answer to one - [`Question`](#question), for example a T-shirt size (`SpeakerQuestionAnswer.cs:8-10`). It holds - `QuestionId`, `AnswerValue`, the back-navigation `Speaker?`, and the FK `SpeakerId`. Database-generated - id. -- **Depends on**: - [`AuditableBaseEntity`](group-02-domain-building-blocks.md#auditablebaseentitytidentifiertype) - (`SpeakerQuestionAnswer.cs:13`), [`Speaker`](#speaker), [`SpeakerInvariants`](#speakerinvariants), - [`Result`](group-01-result-error-handling.md#result), - [`IdValueGeneratedAttribute`](group-02-domain-building-blocks.md#idvaluegeneratedattribute), - [`NavigationAttribute`](group-11-navigation-populators.md#navigationattribute). -- **Concept**: the child-entity discipline of [`EventQuestionAnswer`](#eventquestionanswer), differing only - in its parent and in which invariants class it calls. `[Rubric §4, Domain-Driven Design]`. The two types - are worth reading side by side: same `[IdValueGenerated]` marker, same `QuestionId` plus `AnswerValue` - pair, same back-navigation shape, same event-free factory and updater. -- **Walkthrough**: `[IdValueGenerated]` (`:12`); `QuestionId` and `AnswerValue` (`:15-19`); - `[Navigation] Speaker?` and the get-only `SpeakerId` (`:21-26`); the EF constructor seeding - `AnswerValue = string.Empty` and the private field constructor (`:29-37`); `Create` (`:46-64`) and - `UpdateAnswer` (`:71-80`), both validating through `SpeakerInvariants.EnsureAnswerValueIsValid` and - neither raising a domain event ([`Speaker`](#speaker) raises - [`SpeakerQuestionAnswerChanged`](#speakerquestionanswerchanged)). -- **Why it's built this way**: the same reasoning as its `Event` twin. Keeping the answer a child of the - answering entity puts it inside that aggregate's transaction and gives the root a single place to - announce the change. -- **Caveats / not-in-source**: neither this type nor [`Speaker`](#speaker) checks that `AnswerValue` - matches the referenced question's type. `QuestionInvariants.EnsureAnswerValueMatchesQuestionType` - (`QuestionInvariants.cs:115`) exists for that, but it is not called from either file, so the BR-124 check - must be applied by a caller in the Application tier. -- **Where it's used**: loaded through `Speaker.SpeakerQuestionAnswers`; created, updated, and removed only - through [`Speaker`](#speaker)'s `AddSpeakerQuestionAnswer`, `UpdateSpeakerQuestionAnswer`, and - `RemoveSpeakerQuestionAnswer`. + - **`[IdValueGenerated]`** on the class (`Activity.cs:19`): activities are planned, not imported from + Sessionize, so the database owns the id. `Create` reads it through + `typeof(Activity).IsIdValueGenerated` (`:120`). + - **Scalars** (`Activity.cs:22-54`): `Name`, `Description?`, `StartTime`/`EndTime`, `VenueName?`, + `VenueAddress?`, `VenueUrl?`, `SortOrder`, and `EventId`, all with private setters. Read the two time + docs carefully (`:28-32`, `:35`): both are plain wall-clock `DateTime` values in the owning event's + IANA time zone, exactly as `Session.StartsAt` does, and the zone lives on the event, never repeated + per row. `SortOrder` (`:50`) exists only to break ties between activities starting at the same time. + - **`[Navigation] public Event? Event`** (`Activity.cs:57-58`): a single-reference navigation (not a + collection), described in its doc as read-only and used for public visibility filtering, so a public + read can honor the parent event's published state + ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html)). + - **Constructors** (`Activity.cs:61-83`): the private parameterless EF constructor seeds + `Name = string.Empty`; the private nine-parameter field constructor is what the factory calls. + - **`Create`** (`Activity.cs:99-130`): a five-way `Result.Combine` over + [`ActivityInvariants`](#activityinvariants) (`:111-116`) so a caller sees every problem at once, then + `Id = isIdValueGenerated ? default : id!.Value` (`:124`), then + `AddDomainEvent(new ActivityChanged(DomainEntityState.Added, activity.Id, activity.Name))` (`:127`). + - **`Update`** (`Activity.cs:145-176`): the same five checks (`:155-160`), then eight scalar writes, then + `ActivityChanged(Updated)` (`:173`). Note the parameter list has no `eventId`: the doc (`:132-135`) + records that the owning event is not updatable, and that moving an activity between events is a create + plus a delete. + - **`Delete`** (`Activity.cs:180-188`): overrides the base soft-delete and, on success, raises + `ActivityChanged(Deleted)`. There is no cascade loop, because the aggregate owns no children. +- **Why it's built this way**: storing event-local wall times rather than instants means an organizer + edits the time they see printed on the schedule, and the single authoritative zone on [`Event`](#event) + is applied once at render. Keeping venue on the activity is what lets an off-site after-party carry its + own address and map link while an on-site coffee connect simply leaves the fields null and the reader + falls back to the event venue. +- **Where it's used**: mutated by the Conference activity command handlers and mapped to + [`ActivityDTO`](#activitydto) by + [`ActivityDTOMapper`](group-18-conference-application.md#activitydtomapper); hydrated by + [`ActivityNavigationPopulator`](group-18-conference-application.md#activitynavigationpopulator); + persisted through + [`ActivityConfiguration`](group-19-conference-infrastructure.md#activityconfiguration); rendered by the + [`ActivityList`](group-21-conference-ui.md#activitylist), + [`ActivityDetail`](group-21-conference-ui.md#activitydetail), and + [`ActivityCreate`](group-21-conference-ui.md#activitycreate) pages. ### SponsorInvariants > MMCA.ADC.Conference.Domain · `MMCA.ADC.Conference.Domain.Sponsors` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/SponsorInvariants.cs:10` · Level 6 · class (static) diff --git a/docs-src/onboarding/group-18-conference-application.md b/docs-src/onboarding/group-18-conference-application.md index 7b57858..2c3cb50 100644 --- a/docs-src/onboarding/group-18-conference-application.md +++ b/docs-src/onboarding/group-18-conference-application.md @@ -1,34 +1,36 @@ # 18. ADC Conference - Application & Use Cases **What this chapter covers.** This is the *application layer* of the Conference module, the largest -single application assembly in the codebase (this group covers 251 distinct types). It sits between -the REST/gRPC edge ([G20, Conference API & gRPC](group-20-conference-api-grpc.md)) and the domain -aggregates ([G17, Conference Domain](group-17-conference-domain.md)), and it is where the conference's -*use cases* actually live: create an event, publish it, add a room or a speaker, import the whole -agenda from Sessionize.com, export the schedule as an `.ics` calendar, answer "what is happening right -now", and run the AI-assisted analytics that help organizers decide which session proposals to accept. -One slice cuts across several of those reads: the **public-visibility projection** that keeps -non-published events, non-accepted sessions, their speakers, and their sponsors out of an unprivileged -reader's results (BR-108 / BR-49 / BR-239). It is resolved in one place by +single application assembly in the codebase (this group covers 285 entries in the type map, 284 +distinct names, since two private `StatusBucket` enums share a name in different decision-support +slices). It sits between the REST/gRPC edge ([G20, Conference API & gRPC](group-20-conference-api-grpc.md)) +and the domain aggregates ([G17, Conference Domain](group-17-conference-domain.md)), and it is where +the conference's *use cases* actually live: create an event, publish it, add a room, a speaker, a +sponsor or a social activity, import the whole agenda from Sessionize.com, export the schedule as an +`.ics` calendar, answer "what is happening right now", and run the AI-assisted analytics that help +organizers decide which session proposals to accept. One slice cuts across several of those reads: the +**public-visibility projection** that keeps non-published events, non-accepted sessions, their +speakers, their rooms, their sponsors and their activities out of an unprivileged reader's results +(BR-108 / BR-49 / BR-239). It is resolved in one place by [PublicConferenceVisibility](#publicconferencevisibility) over the shared -[PublicSessionStatusSpecification](#publicsessionstatusspecification) allow-list, and seven -`GetPublic*Filter` handlers (events' speakers, sessions, session category items, session speakers, -speakers, speaker category items, and sponsors) turn its id lists into specifications the Conference -controllers apply. Everything here is **engine-agnostic and framework-light**: it depends on the -abstractions introduced by `MMCA.Common.Application` (handlers, mappers, validators, query services, -navigation populators) and on the Conference domain, but never on EF Core, ASP.NET, or a broker SDK -directly. Read the primer's tour of +[PublicSessionStatusSpecification](#publicsessionstatusspecification) allow-list, and nine +`GetPublic*Filter` handlers (activities, rooms, event speakers, sessions, session category items, +session speakers, speakers, speaker category items, and sponsors) turn its id lists into +specifications the Conference controllers apply. Everything here is **engine-agnostic and +framework-light**: it depends on the abstractions introduced by `MMCA.Common.Application` (handlers, +mappers, validators, query services, navigation populators) and on the Conference domain, but never on +EF Core, ASP.NET, or a broker SDK directly. Read the primer's tour of [CQRS and Vertical Slice](00-primer.md#2-architectural-styles-this-codebase-commits-to) first; this chapter shows those styles at full scale in one module. ## The vertical-slice anatomy of a use case Open any feature folder under `Sessions/UseCases/`, `Events/UseCases/`, `Speakers/UseCases/`, -`Sponsors/UseCases/`, `Categories/UseCases/`, or `Questions/UseCases/` and you will find the same -**cohesive slice**: a command or query record, its handler, its FluentValidation validator, and (for -creates) a request record plus a request mapper, all co-located. Adding a feature means adding a -folder, not threading an edit through horizontal `Services/`, `Validators/`, and `Repositories/` -directories. This is the +`Sponsors/UseCases/`, `Activities/UseCases/`, `Categories/UseCases/`, or `Questions/UseCases/` and you +will find the same **cohesive slice**: a command or query record, its handler, its FluentValidation +validator, and (for creates) a request record plus a request mapper, all co-located. Adding a feature +means adding a folder, not threading an edit through horizontal `Services/`, `Validators/`, and +`Repositories/` directories. This is the [Vertical Slice](00-primer.md#2-architectural-styles-this-codebase-commits-to) discipline made physical. [Rubric §5, Vertical Slice] assesses whether a feature is one navigable unit rather than scattered horizontally, and the folder layout is the evidence. @@ -39,20 +41,27 @@ side-effect-free and returns a `Result`. Both implement the Common contrac and [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult), so every handler in this assembly flows through the same **decorator pipeline** (Logging, Caching, Transactional, then the handler) without knowing it exists. Commands that change cached read data -implement [ICacheInvalidating](group-05-cqrs-pipeline.md#icacheinvalidating); commands that must be -atomic implement [ITransactional](group-05-cqrs-pipeline.md#itransactional); the one read that opts -into caching, [GetNowNextQuery](#getnownextquery), implements +implement [ICacheInvalidating](group-05-cqrs-pipeline.md#icacheinvalidating) and publish the aggregate +prefix the pipeline evicts (`MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionCommand.cs:13`, +`MMCA.ADC.Conference.Application/Events/UseCases/Update/UpdateEventCommand.cs:13`); the three commands +that must be atomic across several aggregates also implement +[ITransactional](group-05-cqrs-pipeline.md#itransactional) (the Sessionize refresh at +`MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeCommand.cs:13`, +which additionally carries [IFeatureGated](group-05-cqrs-pipeline.md#ifeaturegated) with the +`SessionizeIntegration` flag at `:19`, plus the two speaker link commands). Exactly one read opts into +caching: [GetNowNextQuery](#getnownextquery) implements [IQueryCacheable](group-05-cqrs-pipeline.md#iquerycacheable) with a 30-second TTL (`MMCA.ADC.Conference.Application/Sessions/UseCases/NowNext/GetNowNextQuery.cs:38`) and a cache key -built under the `Session` aggregate prefix, so session writes evict it -(`GetNowNextQuery.cs:26-35`). The controller injects the handler interface and calls `HandleAsync`; -the concrete type is invisible to it. [Rubric §6, CQRS & Event-Driven] assesses a clean command/query -split through well-defined handler boundaries: this module is the canonical demonstration, dozens of -single-responsibility handlers, each one slice wide, all dispatched uniformly. +built under the `Session` aggregate prefix, so session writes evict it (`GetNowNextQuery.cs:26-35`). +The controller injects the handler interface and calls `HandleAsync`; the concrete type is invisible to +it. [Rubric §6, CQRS & Event-Driven] assesses a clean command/query split through well-defined handler +boundaries: this module is the canonical demonstration, dozens of single-responsibility handlers, each +one slice wide, all dispatched uniformly. The CRUD-shaped handlers ([CreateEventHandler](#createeventhandler), [CreateSessionHandler](#createsessionhandler), [CreateSpeakerHandler](#createspeakerhandler), -[CreateSponsorHandler](#createsponsorhandler), [CreateQuestionHandler](#createquestionhandler), +[CreateSponsorHandler](#createsponsorhandler), [CreateActivityHandler](#createactivityhandler), +[CreateQuestionHandler](#createquestionhandler), [CreateConferenceCategoryHandler](#createconferencecategoryhandler), and the matching `Update*`/`Delete*`/`Add*`/`Remove*` families) share one shape: they delegate object construction to an [IEntityRequestMapper](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype) @@ -60,28 +69,42 @@ The CRUD-shaped handlers ([CreateEventHandler](#createeventhandler), persist through [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) and map the saved entity back to a DTO with an [IEntityDTOMapper](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype). -The handler owns *orchestration only* (load, validate, persist, map, log) while the business rule ("an +[CreateActivityHandler](#createactivityhandler) is the smallest complete example of the shape, four +statements between the mapper and the DTO +(`MMCA.ADC.Conference.Application/Activities/UseCases/Create/CreateActivityHandler.cs:27-40`). The +handler owns *orchestration only* (load, validate, persist, map, log) while the business rule ("an event's end date cannot precede its start date") lives in the domain factory and the invariant classes. +[PublishEventHandler](#publisheventhandler) shows the same economy on a state transition: load, stamp +the client's rowversion so a decision taken against a stale view fails with 409 rather than silently +winning (`MMCA.ADC.Conference.Application/Events/UseCases/Publish/PublishEventHandler.cs:27-29`, +[ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html)), then delegate to +`Event.Publish()` (`:31`). + [DeleteEventHandler](#deleteeventhandler) is the one delete that is not the generic framework handler: it eagerly loads the event's owned children (`Rooms`, `EventSpeakers`, `EventQuestionAnswers`, -`MMCA.ADC.Conference.Application/Events/UseCases/Delete/DeleteEventHandler.cs:28-32`), then the event's -separate `Session` aggregates (BR-127, `DeleteEventHandler.cs:37-42`) and its `Sponsor` aggregates -(`DeleteEventHandler.cs:46-51`, otherwise the public sponsor strip keeps reading orphaned rows), and -hands all three to the domain's +`MMCA.ADC.Conference.Application/Events/UseCases/Delete/DeleteEventHandler.cs:28-33`), then the event's +separate `Session` aggregates (BR-127, `DeleteEventHandler.cs:37-43`), its `Sponsor` aggregates +(`DeleteEventHandler.cs:45-52`, otherwise the public sponsor strip keeps reading orphaned rows) and its +`Activity` aggregates (`DeleteEventHandler.cs:54-61`, same reasoning for the public activities page), +and hands all four collections to the domain's [EventCascadeDeletionDomainService](group-17-conference-domain.md#eventcascadedeletiondomainservice) -(`DeleteEventHandler.cs:54`, BR-72/BR-55), because a cross-aggregate cascade is an application-layer -decision. +(`DeleteEventHandler.cs:64`, BR-72/BR-55), because a cross-aggregate cascade is an application-layer +decision. Every other aggregate delete is the framework's +[DeleteEntityHandler](group-05-cqrs-pipeline.md#deleteentityhandlertentity-tidentifiertype), +bound closed in the composition root +(`MMCA.ADC.Conference.Application/DependencyInjection.cs:68`, `:72`, `:77`, `:82`, `:86`). The richer handlers add what genuinely needs orchestration context. [UpdateSessionHandler](#updatesessionhandler) and [UpdateEventHandler](#updateeventhandler) stamp the client's concurrency token before mutating, so a concurrent edit surfaces as a 409 instead of silent last-write-wins (`MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionHandler.cs:34`, -`MMCA.ADC.Conference.Application/Events/UseCases/Update/UpdateEventHandler.cs:33`, [ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html)), and each -returns a two-part result record ([UpdateSessionResult](#updatesessionresult), +`MMCA.ADC.Conference.Application/Events/UseCases/Update/UpdateEventHandler.cs:33`, +[ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html)), and each returns a +two-part result record ([UpdateSessionResult](#updatesessionresult), [UpdateEventResult](#updateeventresult)) carrying the DTO plus a non-blocking warning flag: sessions -scheduled outside the event's date range (BR-86, `UpdateSessionHandler.cs:89-91`) and a time-zone -change on an event that already has sessions (BR-131, `UpdateEventHandler.cs:35-46`). The session -variant also rejects immutable-field edits with +scheduled outside the event's date range (BR-86, `UpdateSessionHandler.cs:88-91`) and a time-zone change +on an event that already has sessions (BR-131, `UpdateEventHandler.cs:35-46`). The session variant also +rejects immutable-field edits with [Error](group-01-result-error-handling.md#error)`.UnprocessableEntity` (`UpdateSessionHandler.cs:36-44`, BR-140). @@ -95,16 +118,16 @@ double-book the room, probed with `BuildOverlapPredicate` (`SessionRoomSchedulin SQL-translatable half-open interval comparison (`s.StartsAt < endsAt && s.EndsAt > startsAt`, `SessionRoomScheduling.cs:103-106`) so back-to-back sessions in one room do not collide, with `int.MinValue` as an "exclude nothing" sentinel that keeps the predicate a single shape -(`SessionRoomScheduling.cs:101`). The class documents the overlap half as a deliberate **soft guard** -(`SessionRoomScheduling.cs:16-25`): the existence probe and the write that follows are separate +(`SessionRoomScheduling.cs:99-101`). The class documents the overlap half as a deliberate **soft +guard** (`SessionRoomScheduling.cs:16-25`): the existence probe and the write that follows are separate statements, so two concurrent organizer writes can both observe a free window; SQL Server has no range-exclusion constraint to express the rule as an index, and the trade-off is accepted because the endpoints are organizer-only and the outcome is repairable. Writing that reasoning down beside the code is [Rubric §34, Architecture Governance & Documentation] in practice. [CreateSessionHandler](#createsessionhandler) carries one more piece of orchestration: session ids are -app-assigned (the integer primary key *is* the Sessionize id), so an organizer create computes the -next id in the reserved manual range bounded by `SessionInvariants.ManualIdRangeStart/End` +app-assigned (the integer primary key *is* the Sessionize id), so an organizer create computes the next +id in the reserved manual range bounded by `SessionInvariants.ManualIdRangeStart/End` (`CreateSessionHandler.cs:79-95`), and because two concurrent creates can compute the same id, the handler retries a bounded three times (`CreateSessionHandler.cs:30`) on a unique-key violation, each retry in a **fresh DI scope** because the ambient `DbContext` still tracks the failed insert @@ -120,42 +143,47 @@ concurrent case is handled in code rather than left to the caller. Three sibling families recur across every aggregate. **DTO mappers** ([SessionDTOMapper](#sessiondtomapper), [EventDTOMapper](#eventdtomapper), [SpeakerDTOMapper](#speakerdtomapper), [SponsorDTOMapper](#sponsordtomapper), -[RoomDTOMapper](#roomdtomapper), [CategoryItemDTOMapper](#categoryitemdtomapper), and the -question-answer / category-item link mappers) implement the Common mapper contract and assign each -field *by hand*, the deliberate choice of [ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html) -(manual/Mapperly mapping over reflection-based AutoMapper) so a renamed property is a compile error, -not a silent null. [Rubric §9, API & Contract Design] assesses explicit, traceable contracts: the -mapping is code you can read and test, not convention magic. +[ActivityDTOMapper](#activitydtomapper), [RoomDTOMapper](#roomdtomapper), +[CategoryItemDTOMapper](#categoryitemdtomapper), and the question-answer / category-item link mappers) +implement the Common mapper contract and assign each field *by hand*, the deliberate choice of +[ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html) (manual/Mapperly mapping over +reflection-based AutoMapper) so a renamed property is a compile error, not a silent null. +[Rubric §9, API & Contract Design] assesses explicit, traceable contracts: the mapping is code you can +read and test, not convention magic. **Validation** is composed, not inherited. Small generic rule fragments ([EventDateRangeRules](#eventdaterangerulest), [EventNameRules](#eventnamerulest), [RoomCapacityRules](#roomcapacityrulest), [SessionTitleRules](#sessiontitlerulest), [SpeakerFirstNameRules](#speakerfirstnamerulest), [SponsorNameRules](#sponsornamerulest), -[CategoryItemNameRules](#categoryitemnamerulest), and two dozen siblings) each encapsulate one +[ActivityTimeRangeRules](#activitytimerangerulest), +[CategoryItemNameRules](#categoryitemnamerulest), and three dozen siblings) each encapsulate one validated concern behind a property selector: the plain string ones subclass the framework's [RequiredStringRules](group-06-validation.md#requiredstringrulest) and pass the domain's max-length invariant through (`MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:13-18`), while the ones with real logic derive from `AbstractValidator` directly, such as [EventTimeZoneRules](#eventtimezonerulest), which additionally proves the value is a resolvable IANA identifier via `TimeZoneInfo.FindSystemTimeZoneById` (`EventValidationRules.cs:25-48`, BR-87). The -optional fields wrap a shared fragment in a `When(...)` guard so an empty value is simply absent rather -than invalid ([EventOrganizerContactEmailRules](#eventorganizercontactemailrulest) at -`EventValidationRules.cs:60-66`, -[EventSponsorshipPacketUrlRules](#eventsponsorshippacketurlrulest) at -`EventValidationRules.cs:78-84`). The per-use-case validators -([EventUpdateRequestValidator](#eventupdaterequestvalidator), +optional fields compile the selector and wrap a shared fragment in a `When(...)` guard so an empty value +is simply absent rather than invalid ([EventOrganizerContactEmailRules](#eventorganizercontactemailrulest) +wrapping [EmailRules](group-06-validation.md#emailrulest) at `EventValidationRules.cs:60-66`, +[EventSponsorshipPacketUrlRules](#eventsponsorshippacketurlrulest) and +[EventTicketingUrlRules](#eventticketingurlrulest) wrapping +[OptionalStringRules](group-06-validation.md#optionalstringrulest) at `EventValidationRules.cs:78-84` +and `:96-102`). The per-use-case validators ([EventUpdateRequestValidator](#eventupdaterequestvalidator), [SessionCreateRequestValidator](#sessioncreaterequestvalidator), and the rest) pull the fragments together with FluentValidation's `Include(...)` and add only what is local to the request -(`MMCA.ADC.Conference.Application/Events/UseCases/Update/EventUpdateRequestValidator.cs:11-15`, with -the request-local enum check at `:17-20`). +(`MMCA.ADC.Conference.Application/Events/UseCases/Update/EventUpdateRequestValidator.cs:11-16`, with +the request-local enum check at `:18-21`). [EventDateRangeRules](#eventdaterangerulest) is the richest, compiling the `StartDate` selector into -a delegate (`EventValidationRules.cs:104`) and reading it inside a cross-property `Must` on `EndDate` -(`EventValidationRules.cs:105-107`). Every rule carries a stable error code alongside its message -(`EventValidationRules.cs:30-32`), so clients and tests key off the failure without string-matching -prose. The pattern mirrors the framework's rule-fragment families in -[G06, Validation](group-06-validation.md#addressline1rulest). [Rubric §24, Forms, Validation & UX -Safety] and [Rubric §1, SOLID] both apply: fragments compose without an inheritance chain, and a new -constraint is a new fragment that touches no existing validator. +a delegate (`EventValidationRules.cs:122`) and reading it inside a cross-property `Must` on `EndDate` +(`EventValidationRules.cs:123-125`); [ActivityTimeRangeRules](#activitytimerangerulest) is the same +shape one aggregate over +(`MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:100-103`). Every rule +carries a stable error code alongside its message (`EventValidationRules.cs:30-32`), so clients and +tests key off the failure without string-matching prose. The pattern mirrors the framework's +rule-fragment families in [G06, Validation](group-06-validation.md#addressline1rulest). +[Rubric §24, Forms, Validation & UX Safety] and [Rubric §1, SOLID] both apply: fragments compose without +an inheritance chain, and a new constraint is a new fragment that touches no existing validator. **Authorization and scoping specifications** are the read-side half of the same story, and the module keeps exactly two of them. [PublishedEventSpecification](#publishedeventspecification) filters events @@ -164,7 +192,7 @@ to `e => e.IsPublished` (BR-108, [PublicSessionStatusSpecification](#publicsessionstatusspecification) holds the BR-49 status allow-list as a `static readonly Expression` so the predicate can be *composed into* other expressions rather than only applied as a specification -(`MMCA.ADC.Conference.Application/Sessions/Specifications/PublicSessionStatusSpecification.cs:23-24`). +(`MMCA.ADC.Conference.Application/Sessions/Specifications/PublicSessionStatusSpecification.cs:22-24`). The allow-list is deliberately positive ("`Status` is null, or `Status` is `Accepted`") rather than a list of exclusions, and it compares against the constant instead of calling [SessionStatuses](group-17-conference-domain.md#sessionstatuses)`.IsEligible`, because a compiled @@ -176,34 +204,43 @@ authorization predicate a reusable, testable expression rather than an `if` buri The composition point above them is [PublicConferenceVisibility](#publicconferencevisibility) (`MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:28`), a static resolver with -three public methods and one rule each: published event ids (BR-108, `:36-47`), visible session ids -(the BR-49 allow-list ANDed with the published-event scoping, `:56-77`), and visible speaker ids -(BR-239: the speakers of at least one eligible session inside the scoped event set, `:99-127`, over a -private helper that re-applies the allow-list inside an already narrowed scope, `:134-151`). -Everything is expressed as **scalar id projections rather than navigation joins**, so the criteria stay -translatable on any engine ([ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html)) and each aggregate keeps its by-id boundary to the others -(`PublicConferenceVisibility.cs:22-26`). The speaker rule's remarks record a real leak that shaped it: -the `EventSpeaker` join is deliberately *not* a visibility grant, because the Sessionize import writes -a row for every speaker in the response, so reading it as one published the whole imported roster and -made the filter vacuous (`PublicConferenceVisibility.cs:92-98`). +three public methods and one rule each: published event ids (BR-108, `:36-48`), visible session ids +(the BR-49 allow-list ANDed with the published-event scoping, built through the framework's +[CrossSourceSpecification](group-03-querying-specifications.md#crosssourcespecification) helper, +`:57-82`), and visible speaker ids (BR-239: the speakers of at least one eligible session inside the +scoped event set, `:104-134`, over a private helper that re-applies the allow-list inside an already +narrowed scope, `:141-158`). Everything is expressed as **scalar id projections rather than navigation +joins**, so the criteria stay translatable on any engine +([ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html)) and each aggregate keeps +its by-id boundary to the others (`PublicConferenceVisibility.cs:22-26`). The speaker rule's remarks +record a real leak that shaped it: the `EventSpeaker` join is deliberately *not* a visibility grant, +because the Sessionize import writes a row for every speaker in the response, so reading it as one +published the whole imported roster and made the filter vacuous +(`PublicConferenceVisibility.cs:97-103`). Several query handlers *build* a specification instead of returning data, and they exist because a navigating predicate (`s => s.Event.IsPublished`) is not translatable once the two entities can live in -different data sources ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html), [ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html)). [GetPublicSessionFilterHandler](#getpublicsessionfilterhandler) -uses the framework's [CrossSourceSpecification](group-03-querying-specifications.md#crosssourcespecification) -helper to resolve the published `Event` ids and return a translatable `Session.EventId IN (...)` filter, -ANDed with the shared status allow-list -(`MMCA.ADC.Conference.Application/Sessions/UseCases/GetPublicSessionFilter/GetPublicSessionFilterHandler.cs:29-36`); -[GetPublicSponsorFilterHandler](#getpublicsponsorfilterhandler) does the simplest version of the same -move, wrapping the published-event id list in an +different data sources ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html), +[ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html)). +[GetPublicSessionFilterHandler](#getpublicsessionfilterhandler) uses the same +[CrossSourceSpecification](group-03-querying-specifications.md#crosssourcespecification) helper to +resolve the published `Event` ids and return a translatable `Session.EventId IN (...)` filter, ANDed +with the shared status allow-list +(`MMCA.ADC.Conference.Application/Sessions/UseCases/GetPublicSessionFilter/GetPublicSessionFilterHandler.cs:29-36`). +[GetPublicSponsorFilterHandler](#getpublicsponsorfilterhandler), +[GetPublicActivityFilterHandler](#getpublicactivityfilterhandler) and +[GetPublicRoomFilterHandler](#getpublicroomfilterhandler) do the simplest version of the same move, +wrapping the published-event id list in an [InlineSpecification](group-03-querying-specifications.md#inlinespecificationtentity-tidentifiertype) -(`MMCA.ADC.Conference.Application/Sponsors/UseCases/GetPublicSponsorFilter/GetPublicSponsorFilterHandler.cs:25-30`). +(`MMCA.ADC.Conference.Application/Sponsors/UseCases/GetPublicSponsorFilter/GetPublicSponsorFilterHandler.cs:25-30`, +`MMCA.ADC.Conference.Application/Activities/UseCases/GetPublicActivityFilter/GetPublicActivityFilterHandler.cs:25-31`, +`MMCA.ADC.Conference.Application/Events/UseCases/GetPublicRoomFilter/GetPublicRoomFilterHandler.cs:25-31`). [GetSessionsBySpeakerFilterHandler](#getsessionsbyspeakerfilterhandler) and [GetSpeakersByEventFilterHandler](#getspeakersbyeventfilterhandler) hand-roll the same id-list shape against the link tables, projecting ids through `GetReadRepository(...).GetProjectedAsync` and materializing them once so the predicate embeds a stable collection EF can translate to `IN` (`MMCA.ADC.Conference.Application/Sessions/UseCases/GetSessionsBySpeakerFilter/GetSessionsBySpeakerFilterHandler.cs:30-43`, -`MMCA.ADC.Conference.Application/Speakers/UseCases/GetSpeakersByEventFilter/GetSpeakersByEventFilterHandler.cs:28-50`, +`MMCA.ADC.Conference.Application/Speakers/UseCases/GetSpeakersByEventFilter/GetSpeakersByEventFilterHandler.cs:28-53`, which unions the direct `EventSpeaker` links with the transitive `SessionSpeaker` ones). An empty id list correctly matches nothing, which is why the caller must still apply the specification rather than skip it (`GetSessionsBySpeakerFilterHandler.cs:15-19`). @@ -212,30 +249,35 @@ skip it (`GetSessionsBySpeakerFilterHandler.cs:15-19`). Read paths do not get bespoke handlers for the common cases; they go through the framework's generic [IEntityQueryService](group-03-querying-specifications.md#ientityqueryservicetentity-tentitydto-tidentifiertype), -which supplies filtering, sorting, paging, and field projection ([ADR-034](https://ivanball.github.io/docs/adr/034-generic-entity-query-layer.html)). The one local specialization -is [SpeakerEntityQueryService](#speakerentityqueryservice), a thin subclass that overrides only the -DTO-to-entity property map so API consumers can sort and filter on the computed `FullName` while the -pipeline translates it to `(FirstName + " " + LastName)` +which supplies filtering, sorting, paging, and field projection +([ADR-034](https://ivanball.github.io/docs/adr/034-generic-entity-query-layer.html)). The one local +specialization is [SpeakerEntityQueryService](#speakerentityqueryservice), a thin subclass of +[EntityQueryService](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype) +that overrides only the DTO-to-entity property map so API consumers can sort and filter on the computed +`FullName` while the pipeline translates it to `(FirstName + " " + LastName)` (`MMCA.ADC.Conference.Application/Speakers/SpeakerEntityQueryService.cs:28-34`). Eager-loading of child graphs is delegated to per-aggregate [INavigationPopulator](group-11-navigation-populators.md#inavigationpopulatorin-tentity) implementations ([EventNavigationPopulator](#eventnavigationpopulator), [SessionNavigationPopulator](#sessionnavigationpopulator), [SpeakerNavigationPopulator](#speakernavigationpopulator), +[ActivityNavigationPopulator](#activitynavigationpopulator), +[SponsorNavigationPopulator](#sponsornavigationpopulator), [ConferenceCategoryNavigationPopulator](#conferencecategorynavigationpopulator)), which encapsulate -which navigations to include and how to batch-load cross-source relationships ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html)); child entities -and the childless `Question` and `Sponsor` aggregates use the framework's +which navigations to include and how to batch-load cross-source relationships +([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html)); the childless +`Question` aggregate uses the framework's [NullNavigationPopulator](group-11-navigation-populators.md#nullnavigationpopulatortentity) -because they are never the root of a full graph load -(`MMCA.ADC.Conference.Application/DependencyInjection.cs:71-102`). +because it is never the root of a full graph load +(`MMCA.ADC.Conference.Application/DependencyInjection.cs:75`). All of this is wired by [DependencyInjection](#dependencyinjection), the module's **composition root** -(`MMCA.ADC.Conference.Application/DependencyInjection.cs:35`, with the registration surface exposed as a -C# `extension(IServiceCollection)` member at `DependencyInjection.cs:37-39`). It explicitly binds the -closed generics Scrutor cannot infer (the cascade-deletion domain service at `:44`, the AI scoring -queue at `:50-51`, then each aggregate's navigation populator, query service, and delete handler at -`:54-102`, plus the two cross-module validation services at `:105` and `:108`) and then calls -`ScanModuleApplicationServices()` (`DependencyInjection.cs:112`) to discover the "many +(`MMCA.ADC.Conference.Application/DependencyInjection.cs:39`, with the registration surface exposed as a +C# `extension(IServiceCollection)` member at `DependencyInjection.cs:41-43`). It explicitly binds the +closed generics Scrutor cannot infer (the cascade-deletion domain service at `:48`, the AI scoring +queue at `:54-55`, then each aggregate's navigation populator, query service, and delete handler at +`:58-115`, plus the two cross-module validation services at `:118` and `:121`) and then calls +`ScanModuleApplicationServices()` (`DependencyInjection.cs:125`) to discover the "many small things" (every handler, mapper, validator, and event handler) by convention. [AssemblyReference](#assemblyreference) and [ClassReference](#classreference) are the marker types that anchor that scan (`MMCA.ADC.Conference.Application/AssemblyReference.cs:5` and `:11`). @@ -248,26 +290,35 @@ single place a new aggregate gets registered. The application layer is also where the module *reacts* to events. **Domain event handlers** implement [IDomainEventHandler](group-04-events-outbox.md#idomaineventhandlerin-tdomainevent) and -run in-process after the aggregate's `SaveChangesAsync`. Two of the three are deliberately -observability-only: [SessionCreatedHandler](#sessioncreatedhandler) filters `SessionChanged` down to the -`Added` state and writes one structured log line +run in-process after the aggregate's `SaveChangesAsync`. Each subscribes to one entity's single +lifecycle event and switches on its state discriminator, the taxonomy +[ADR-083](https://ivanball.github.io/docs/adr/083-crud-lifecycle-event-taxonomy.html) settles. Two of the +three are deliberately observability-only: [SessionCreatedHandler](#sessioncreatedhandler) filters +`SessionChanged` down to the `Added` state and writes one structured log line (`MMCA.ADC.Conference.Application/Sessions/DomainEventHandlers/SessionCreatedHandler.cs:17-21`), and [RoomChangedHandler](#roomchangedhandler) logs every room add/update/delete with the state on the -message (`MMCA.ADC.Conference.Application/Events/DomainEventHandlers/RoomChangedHandler.cs:17-22`), +message (`MMCA.ADC.Conference.Application/Events/DomainEventHandlers/RoomChangedHandler.cs:17-18`), which is the [Rubric §13, Observability & Operability] story: the event stream is where lifecycle telemetry is emitted, not the entity. [SpeakerDeletedHandler](#speakerdeletedhandler) is the one with a real side effect: on a `Deleted` state with a previously linked user it opens its own DI scope (the handler is a singleton), resolves [IEventBus](group-04-events-outbox.md#ieventbus), and publishes `SpeakerUnlinkedFromUser` so Identity can clear `User.LinkedSpeakerId` (`MMCA.ADC.Conference.Application/Speakers/DomainEventHandlers/SpeakerDeletedHandler.cs:38-45`, BR-70). +The write side of the same link works in the other direction and inside the transaction: +[LinkUserToSpeakerHandler](#linkusertospeakerhandler) enforces the BR-208 one-speaker-per-user guard +(`MMCA.ADC.Conference.Application/Speakers/UseCases/LinkUser/LinkUserToSpeakerHandler.cs:34-46`) and +raises `SpeakerLinkedToUser` on the aggregate *before* the save, so the outbox row is captured in the +same `SaveChangesAsync` as the link (`:51-56`, +[ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)). The integration event handler [UserRegisteredHandler](#userregisteredhandler) implements [IIntegrationEventHandler](group-04-events-outbox.md#iintegrationeventhandlerin-tintegrationevent) and is the cross-module boundary in the other direction: when Identity publishes `UserRegistered` over the broker, Conference auto-links a speaker to that user (BR-207). **The only auto-link signal is an -email match.** The handler resolves the address through the `Email` value object (normalized to -lowercase, so the comparison is effectively case-insensitive) and orders the candidates so an unlinked -speaker wins and the choice stays deterministic when an address is shared +email match.** The handler resolves the address through the +[Email](group-02-domain-building-blocks.md#email) value object (normalized to lowercase, so the +comparison is effectively case-insensitive) and orders the candidates so an unlinked speaker wins and +the choice stays deterministic when an address is shared (`MMCA.ADC.Conference.Application/Users/IntegrationEventHandlers/UserRegisteredHandler.cs:130-158`). On a miss it runs a **read-only** name-match probe that counts unlinked speakers with the same first and last name and logs the count (`UserRegisteredHandler.cs:167-193`): the matched rows are never returned, @@ -286,9 +337,10 @@ because the delivery was already acked, so letting the exception through hands t the delivery mechanism, which is built for it (the outbox retries then dead-letters, MassTransit redelivers then moves the message to the error queue). Retrying is safe, because the "already linked to a different user" guard (`UserRegisteredHandler.cs:66-70`) makes the second attempt a no-op. Publishing -flows through the `IEventBus` abstraction and the outbox ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)), so the application code never -references MassTransit. [Rubric §6, CQRS & Event-Driven] and [Rubric §7, Microservices Readiness]: the -module collaborates through events and interfaces, never direct cross-module type references. +flows through the `IEventBus` abstraction and the outbox +([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)), so the application code +never references MassTransit. [Rubric §6, CQRS & Event-Driven] and [Rubric §7, Microservices Readiness]: +the module collaborates through events and interfaces, never direct cross-module type references. Two in-process services close the loop with the Engagement module. [SessionBookmarkValidationService](#sessionbookmarkvalidationservice) @@ -298,7 +350,8 @@ Two in-process services close the loop with the Engagement module. Engagement-facing contracts [ISessionBookmarkValidationService](group-17-conference-domain.md#isessionbookmarkvalidationservice) and [IEventLiveValidationService](group-17-conference-domain.md#ieventlivevalidationservice), which -Engagement calls via gRPC when the modules run as separate services ([ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html)). The former gates session +Engagement calls via gRPC when the modules run as separate services +([ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html)). The former gates session bookmarking through the domain's [SessionInvariants](group-17-conference-domain.md#sessioninvariants) (`EnsureNotServiceSession` for BR-91, `EnsureStatusIsEligible` for BR-49, `SessionBookmarkValidationService.cs:33-38`), so the eligibility rule is the domain's, not a copy, and @@ -308,7 +361,7 @@ event's window at `EventLiveValidationService.cs:25`, a session's at `:48`, a sp `:104`, and which session a room is hosting right now at `:143`, so a check-in never has to trust a client-supplied session id) and deliberately does *not* compute the window itself: it delegates to the domain's [CurrentEventSelector](group-17-conference-domain.md#currenteventselector)`.GetLiveWindowUtc` -(`EventLiveValidationService.cs:242-243`) so the midnight-to-midnight rule, the unknown-time-zone +(`EventLiveValidationService.cs:242-246`) so the midnight-to-midnight rule, the unknown-time-zone degradation, and the spring-forward-gap guard stay identical to the ones the home surfaces and the now/next snapshot use. Its session variant adds the assigned speaker ids (BR-236), the plenum flag, and the event's question-moderation default (BR-233, `EventLiveValidationService.cs:90-100`) after @@ -331,8 +384,11 @@ service, because their output is not a DTO list. with `Error.NotFound`, and turns every exportable session into one VEVENT with the room as its location (`MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportEventCalendarHandler.cs:24-59`), with [CalendarExportMapper](#calendarexportmapper) doing the entity-to-entry shaping and owning the -`IsExportable` eligibility rule, and the framework's -[IcsCalendarBuilder](group-08-auth.md#icscalendarbuilder) assembling the document +`IsExportable` eligibility rule, which itself defers the status half to +[SessionStatuses](group-17-conference-domain.md#sessionstatuses)`.IsEligible` so no second copy of the +allow-list can drift +(`MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/CalendarExportMapper.cs:26-28`), and +the framework's [IcsCalendarBuilder](group-08-auth.md#icscalendarbuilder) assembling the document (`ExportEventCalendarHandler.cs:61`). An unresolvable IANA zone degrades to UTC rather than failing the export, defensively, for legacy rows (`ExportEventCalendarHandler.cs:39-49`). [GetNowNextHandler](#getnownexthandler) builds the conference-day "happening now plus next up" snapshot @@ -346,7 +402,7 @@ than one arbitrary winner (`GetNowNextHandler.cs:64-72`). `GetNowNextHandler` in rather than reading the clock directly (`GetNowNextHandler.cs:20-22`), which is what makes its "now" unit-testable at a fixed instant; the two export handlers instead stamp the `.ics` `DTSTAMP` from `DateTimeOffset.UtcNow` directly (`ExportEventCalendarHandler.cs:61`, -`MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportSessionCalendarHandler.cs:59`), +`MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportSessionCalendarHandler.cs:59-62`), so their timestamp is not injectable. [Rubric §14, Testability]. ## The Sessionize import: Strategy-pattern orchestration @@ -385,7 +441,7 @@ dependency order ([CategorySyncStrategy](#categorysyncstrategy), [RoomSyncStrate carrying its work via a shared [SessionizeSyncContext](#sessionizesynccontext) and returning a [SessionizeSyncResult](#sessionizesyncresult) with a primary and an optional secondary count (`ISessionizeSyncStrategy.cs:21-28`). An empty response is treated as success, not an error, and still -stamps the refresh (`:96-111`). Each strategy bulk-loads its entity family in one call (no N+1, +stamps the refresh (`:95-111`). Each strategy bulk-loads its entity family in one call (no N+1, `MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionSyncStrategy.cs:21-29`), upserts via the domain's `Create`/`Update` methods (`SessionSyncStrategy.cs:79-114`), skips soft-deleted rows (BR-136, `SessionSyncStrategy.cs:79-85`, warned once at `RefreshFromSessionizeHandler.cs:128-131`), @@ -395,8 +451,8 @@ warnings at `:53-71`). A final `RequestIdentityInsert()` (`RefreshFromSessionize SQL Server accept Sessionize's own integer IDs before one batched `SaveChangesAsync` (`:139`). [Rubric §2, Design Patterns] (Strategy solving a real Open/Closed problem: a new entity family is a new strategy, not an edit to the orchestrator), [Rubric §12, Performance & Scalability] (bulk loads plus a -single save round-trip), and [Rubric §17, DevOps & Deployment] (the throttle and graceful per-entity -degradation make a re-import safe to run repeatedly). +single save round-trip), and [Rubric §17, DevOps & Deployment] (the throttle, the feature gate, and +graceful per-entity degradation make a re-import safe to run repeatedly). ## Decision support: AI scoring and content analytics @@ -438,7 +494,9 @@ and turns the returned [SessionScoringEnqueueResult](#sessionscoringenqueueresul a conflict (`MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/ISessionScoringQueue.cs:4-14`, consumed by [SessionSelectionController](group-20-conference-api-grpc.md#sessionselectioncontroller)). -[SessionScoringQueue](#sessionscoringqueue) is a bounded `Channel` of capacity 16 +That bounded-queue-plus-hosted-drain shape is the pattern +[ADR-052](https://ivanball.github.io/docs/adr/052-background-job-execution.html) settles for the whole +codebase. [SessionScoringQueue](#sessionscoringqueue) is a bounded `Channel` of capacity 16 (`MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/SessionScoringQueue.cs:38`) with `SingleReader` set and `FullMode = Wait` paired with a non-blocking `TryWrite` (`:43-49`), so a full queue refuses the new request outright instead of dropping an earlier one, which matters because each run @@ -455,7 +513,7 @@ nothing deduplicated, and nothing could cancel at shutdown (`ISessionScoringQueu drain worker lives in infrastructure ([SessionScoringProcessor](group-19-conference-infrastructure.md#sessionscoringprocessor)), which is why [DependencyInjection](#dependencyinjection) registers the concrete queue *and* the interface as the same -singleton instance (`DependencyInjection.cs:50-51`): two instances would mean producers writing to a +singleton instance (`DependencyInjection.cs:54-55`): two instances would mean producers writing to a queue nobody drains. [Rubric §12, Performance & Scalability] and [Rubric §29, Resilience & Business Continuity]. @@ -480,7 +538,7 @@ the command fails as a whole only when every session failed (`:127-133`). The co `Success` flag and seven `1.0`-`10.0` sub-scores (overall, topic relevance, description quality, novelty, actionable takeaways, depth/insight quality, credibility/experience) in *all* cases (`MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/IAiScoringService.cs:9`, -`IAiScoringService.cs:40-71`), so one bad session never aborts the scoring loop. The input shapes are +`IAiScoringService.cs:40-70`), so one bad session never aborts the scoring loop. The input shapes are [SessionScoringInput](#sessionscoringinput) and [SpeakerInfo](#speakerinfo) (`IAiScoringService.cs:23-37`); the result is mapped onto the [SessionAiScore](group-17-conference-domain.md#sessionaiscore) aggregate for persistence @@ -495,23 +553,57 @@ populate), and bespoke handlers reserved for the genuinely complex 20% (the Sess attendee-facing read models, and the decision-support analytics), each isolated behind a port or a strategy so it can evolve, be tested, and ultimately be extracted without disturbing the rest. +### ActivityEventIdRules +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Activities.Validation` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:74` · Level 0 · class (sealed) + +- **What it is**: a reusable FluentValidation rule fragment that enforces "an activity must name the event it belongs to", applied to whichever `EventIdentifierType` property a caller points it at. +- **Depends on**: FluentValidation's `AbstractValidator` (NuGet, primer [§3](00-primer.md#3-the-external-stack-bcl--nuget-external-level-0)) and `System.Linq.Expressions.Expression>` (BCL). The `EventIdentifierType` in the selector signature (`ActivityValidationRules.cs:77`) is the module's identifier alias, declared once as `global using EventIdentifierType = int;` in the Shared project (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8`) and linked solution-wide, which is why no import for it appears in this file. +- **Concept, the module-local rule fragment.** The fragment idiom itself (a tiny generic `AbstractValidator` that a real validator folds in with FluentValidation's `Include(...)`) is taught in [group-06](group-06-validation.md) on [RequiredStringRules](group-06-validation.md#requiredstringrulest) and its siblings. What the Conference module adds on top are two conventions: (1) the numeric or length bound is read from the domain's invariant class instead of a literal, so the constraint cannot drift between the domain factory, the EF configuration, and the request validator; and (2) every rule chain written by hand in this module attaches a **stable dotted error code** with `WithErrorCode(...)` next to its human-readable message, so an API client or a test can key off `Activity.EventId.Required` without string-matching English prose (the fragments that instead subclass a framework rule, such as [ActivityNameRules](#activitynamerulest), inherit the base message and get no code). `[Rubric §24, Forms, Validation & UX Safety]` assesses whether validation is reused rather than copy-pasted across create and update paths and whether failures are machine-addressable: the fragment plus the error code is this module's answer to both. `[Rubric §1, SOLID]`: each fragment carries exactly one field contract, so changing that contract is a one-line edit in one place. +- **Walkthrough**: one expression-bodied constructor, `ActivityEventIdRules(Expression> selector)` (`ActivityValidationRules.cs:77`), whose entire body is `RuleFor(selector).NotEmpty()` with the message "You must specify an Event for the Activity" and the error code `Activity.EventId.Required` (`ActivityValidationRules.cs:78-79`). Because `EventIdentifierType` aliases `int`, `NotEmpty()` here rejects `0` (the default of an unset id) rather than a null. +- **Why it's built this way**: the XML doc above the class (`ActivityValidationRules.cs:69-73`) states the rule's reason in domain terms: activities are scheduled per event, so an unscoped activity has nowhere to appear. Encoding that as a request-level rule means the caller gets a field-addressed validation failure before any handler or aggregate is touched. +- **Where it's used**: `Include`d by [ActivityCreateRequestValidator](#activitycreaterequestvalidator) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/UseCases/Create/ActivityCreateRequestValidator.cs:12`) and by that validator **only**. [ActivityUpdateRequestValidator](#activityupdaterequestvalidator) does not include it, because [ActivityUpdateRequest](#activityupdaterequest) carries no `EventId` at all: its doc comment (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/UseCases/Update/ActivityUpdateRequest.cs:6-9`) records the choice, moving an activity between events is a create plus a delete, so a mistyped id cannot silently relocate a published social event. + +### ActivitySortOrderRules +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Activities.Validation` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:111` · Level 0 · class (sealed) + +- **What it is**: a reusable rule fragment enforcing that an activity's display sort order is non-negative. +- **Depends on**: FluentValidation (`AbstractValidator`), `System.Linq.Expressions` (BCL). No first-party dependency: unlike the string fragments in the same file it reads no domain constant. +- **Concept**: the module-local rule fragment taught on [ActivityEventIdRules](#activityeventidrulest). `[Rubric §16, Maintainability]` assesses whether a constraint lives in one place; a single fragment shared by the create and update paths is that. +- **Walkthrough**: one expression-bodied constructor, `ActivitySortOrderRules(Expression> selector)` (`ActivityValidationRules.cs:114`), body `RuleFor(selector).GreaterThanOrEqualTo(0)` with the message "Sort Order must be greater than or equal to 0" and the error code `Activity.SortOrder.Negative` (`ActivityValidationRules.cs:115-116`). +- **Why it's built this way**: `GreaterThanOrEqualTo(0)` rather than `GreaterThan(0)` because zero is a legitimate "first in the list" position; the framework's shared `PositiveIntRules` would have been the wrong fragment to reuse here, which is why this one exists locally. +- **Where it's used**: `Include`d by [ActivityCreateRequestValidator](#activitycreaterequestvalidator) (`.../Activities/UseCases/Create/ActivityCreateRequestValidator.cs:14`) and [ActivityUpdateRequestValidator](#activityupdaterequestvalidator) (`.../Activities/UseCases/Update/ActivityUpdateRequestValidator.cs:13`), each bound to its own `SortOrder` property. Its structural twin on the Categories side is [CategoryItemSortRules](#categoryitemsortrulest). + +### ActivityTimeRangeRules +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Activities.Validation` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:87` · Level 0 · class (sealed) + +- **What it is**: the one fragment in the activity family that validates a **pair** of properties rather than a single field: both ends of the activity's time range must be present, and the end must be on or after the start. +- **Depends on**: FluentValidation (`AbstractValidator`), `System.Linq.Expressions` (BCL). No domain constant. +- **Concept, the cross-field rule fragment.** Single-field fragments can stay expression-bodied, but a rule that compares two properties needs the *instance*, not just the selected value. `[Rubric §24, Forms, Validation & UX Safety]` covers exactly this class of check (the one users hit most often and the one most likely to be duplicated inconsistently). The mechanism is worth learning once because every cross-field rule in the codebase uses it: FluentValidation's `Must` has an overload taking `(instance, value)`, so the fragment compiles the *other* selector into a delegate at construction time and calls it against the instance inside the predicate. +- **Walkthrough**: a two-selector constructor with a statement body, `ActivityTimeRangeRules(Expression> startTimeSelector, Expression> endTimeSelector)` (`ActivityValidationRules.cs:90-92`), building three rules: + - `RuleFor(startTimeSelector).NotEmpty()`, message "You must enter a Start Time", code `Activity.StartTime.Required` (`:94-95`). + - `RuleFor(endTimeSelector).NotEmpty()`, message "You must enter an End Time", code `Activity.EndTime.Required` (`:97-98`). + - `var startTimeFunc = startTimeSelector.Compile();` (`:100`) turns the start-time expression into an executable `Func` **once**, at fragment construction, not per validation call. The third rule then hangs off the end-time selector and uses the two-argument `Must((instance, endTime) => endTime >= startTimeFunc(instance))` (`:101-102`), reporting "End Time must be on or after the Start Time" with code `Activity.EndTime.BeforeStart` (`:103`). Attaching the comparison to the *end* selector is what makes the error surface on the end-time field in the UI. +- **Why it's built this way**: the class doc (`ActivityValidationRules.cs:82-86`) says the shape mirrors `EventDateRangeRules`, so the two schedule-bearing aggregates fail the same way for the same reason. Note that `>=` is deliberate, a zero-length activity passes; the rule bans only an end before its start. +- **Caveats / not in source**: `NotEmpty()` on a non-nullable `DateTime` rejects `default(DateTime)`, so a genuinely unset value is caught, but a caller that posts a real-but-implausible date (for example far outside the event window) is not: no range-versus-event check exists in this fragment. +- **Where it's used**: `Include`d by [ActivityCreateRequestValidator](#activitycreaterequestvalidator) (`.../Create/ActivityCreateRequestValidator.cs:13`) and [ActivityUpdateRequestValidator](#activityupdaterequestvalidator) (`.../Update/ActivityUpdateRequestValidator.cs:12`), each passing its own `StartTime` and `EndTime` selectors. + ### AssemblyReference > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/AssemblyReference.cs:5` · Level 0 · class (static) -- **What it is**: a tiny static class exposing the Conference Application assembly and its short name as two `static readonly` fields, so scanners and registrars have a strongly-typed handle on this assembly. +- **What it is**: a tiny static class exposing the Conference Application assembly and its short name as two `static readonly` fields, so any reflection-driven tooling has a strongly-typed handle on this assembly. - **Depends on**: `System.Reflection` (BCL) only. -- **Concept, the assembly-anchor type.** [Rubric §5, Vertical Slice] assesses whether a module is a self-contained, discoverable unit; a per-assembly anchor type is how the framework's reflection-based wiring finds "everything in the Conference Application layer" without hard-coding a namespace string. The two fields (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/AssemblyReference.cs:7-8`) are `Assembly = typeof(AssemblyReference).Assembly` and `AssemblyName = Assembly.GetName().Name ?? string.Empty`, both computed once at type load. There is a sibling anchor of the same name in every layer (Conference Domain, Infrastructure, API), so a caller can name the exact assembly it means. -- **Walkthrough**: two fields, no methods. `Assembly` (line 7) resolves the containing assembly via `typeof(AssemblyReference).Assembly`; `AssemblyName` (line 8) reads `Assembly.GetName().Name`, falling back to `string.Empty` when the runtime reports a null simple name. -- **Why it's built this way**: taking `typeof(AssemblyReference).Assembly` is refactor-safe (renaming the assembly or moving the file changes nothing), which is why the framework prefers an anchor type over a hard-coded `Assembly.Load("...")`. -- **Where it's used**: assembly-scoped tooling that needs the Conference Application assembly by reference. The convention scan performed inside [DependencyInjection](#dependencyinjection) instead anchors on [ClassReference](#classreference), because that API is generic over a type argument rather than over an `Assembly` value. +- **Concept, the assembly-anchor type.** `[Rubric §5, Vertical Slice]` assesses whether a module is a self-contained, discoverable unit; a per-assembly anchor is how reflection-based wiring names "everything in the Conference Application layer" without hard-coding a namespace or assembly string. There is a sibling anchor of the same name in every layer of the module (Conference Domain, Infrastructure, API) and in every other module, so a caller can always name the exact assembly it means. +- **Walkthrough**: two fields, no methods. `Assembly = typeof(AssemblyReference).Assembly` (`AssemblyReference.cs:7`) resolves the containing assembly from the type itself; `AssemblyName = Assembly.GetName().Name ?? string.Empty` (`AssemblyReference.cs:8`) reads the simple name and falls back to an empty string when the runtime reports null. Both are computed once at type load. +- **Why it's built this way**: `typeof(X).Assembly` is refactor-safe (renaming the assembly, moving the file, or restructuring the namespace changes nothing), which is why the codebase prefers an anchor type over `Assembly.Load("...")` with a literal. +- **Where it's used**: nothing in MMCA.ADC references `MMCA.ADC.Conference.Application.AssemblyReference` today (a repo-wide search over `Source/` and `Tests/` returns no consumer). The convention scan performed inside [DependencyInjection](#dependencyinjection) anchors on [ClassReference](#classreference) instead, because that framework API is generic over a *type* argument rather than over an `Assembly` value. The class is kept for symmetry with the other layers' anchors and for tooling that wants the `Assembly` object directly. ### CategoryItemSortRules > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.Validation` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:40` · Level 0 · class (sealed) -- **What it is**: a reusable FluentValidation rule fragment that enforces a non-negative sort order (`>= 0`) on whichever `int` property a caller points it at. -- **Depends on**: FluentValidation (`AbstractValidator`, NuGet), `System.Linq.Expressions` (BCL). No first-party dependency: unlike its two siblings in the same file it reads no domain constant. -- **Concept introduced, the generic reusable validator rule.** [Rubric §1, SOLID] (single responsibility, do not repeat) and [Rubric §16, Maintainability] (one place to change a constraint) both apply. Rather than re-declare "sort must be non-negative" inside every create/update validator, the rule is written once as a generic `AbstractValidator` whose constructor takes an `Expression>` selector (`ConferenceCategoryValidationRules.cs:43`) naming the property that carries the sort value. A concrete command validator then folds the fragment into itself with FluentValidation's `Include(...)`, which merges the fragment's rules into the including validator's rule set instead of nesting a child validator. This is the composition idiom every reusable rule class in the Conference module follows. -- **Walkthrough**: one constructor, `CategoryItemSortRules(Expression> selector)` (`ConferenceCategoryValidationRules.cs:43`), written as an expression-bodied member whose whole body is a single chained call: `RuleFor(selector).GreaterThanOrEqualTo(0)` with the message "Sort order must be greater than or equal to 0" and the error code `CategoryItem.Sort.Negative` (`ConferenceCategoryValidationRules.cs:44-45`). Attaching a stable error code (not just prose) lets clients and tests key off the failure without string-matching the message. +- **What it is**: a reusable rule fragment enforcing a non-negative sort order (`>= 0`) on whichever `int` property a caller points it at, for category items. +- **Depends on**: FluentValidation (`AbstractValidator`), `System.Linq.Expressions` (BCL). No first-party dependency: unlike its two siblings in the same file it reads no domain constant. +- **Concept**: the module-local rule fragment taught on [ActivityEventIdRules](#activityeventidrulest). `[Rubric §1, SOLID]` (one responsibility per fragment) and `[Rubric §16, Maintainability]` (one place to change the constraint) both apply. +- **Walkthrough**: one expression-bodied constructor, `CategoryItemSortRules(Expression> selector)` (`ConferenceCategoryValidationRules.cs:43`), whose whole body is `RuleFor(selector).GreaterThanOrEqualTo(0)` with the message "Sort order must be greater than or equal to 0" and the error code `CategoryItem.Sort.Negative` (`ConferenceCategoryValidationRules.cs:44-45`). - **Why it's built this way**: generic over `T` so the same fragment serves both the add-item and update-item command shapes; `sealed` because it is a leaf composition unit with no intended subclassing. - **Where it's used**: `Include`d by [AddCategoryItemCommandValidator](#addcategoryitemcommandvalidator) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/UseCases/AddCategoryItem/AddCategoryItemCommandValidator.cs:12`) and [UpdateCategoryItemCommandValidator](#updatecategoryitemcommandvalidator) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/UseCases/UpdateCategoryItem/UpdateCategoryItemCommandValidator.cs:12`), each binding the selector to its own `Sort` property. Sibling fragments in the same file are [CategoryItemNameRules](#categoryitemnamerulest) and [ConferenceCategoryTitleRules](#conferencecategorytitlerulest). @@ -520,172 +612,327 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **What it is**: an empty marker class used purely as a `typeof` anchor for assembly scanning; it declares no members. - **Depends on**: nothing. -- **Concept, the scan-anchor marker.** [Rubric §2, Design Patterns] assesses idiomatic registration wiring. The framework's scanning API is generic over an anchor type, so `ScanModuleApplicationServices()` reads as "scan the assembly that contains `ClassReference`", that is, this Application layer. The class body is empty (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/AssemblyReference.cs:11`); its only job is to be a compile-time-checked stand-in for the assembly. -- **Walkthrough**: `public class ClassReference { }` on one line. No fields, no methods, not `sealed` or `static` (it has to be usable as a generic type argument). -- **Where it's used**: passed as the type argument to `services.ScanModuleApplicationServices()` in [DependencyInjection](#dependencyinjection) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112`). The generic parameter it satisfies is declared as `TAssemblyMarker` on `ScanModuleApplicationServices` in MMCA.Common (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:115`). +- **Concept, the scan-anchor marker.** `[Rubric §2, Design Patterns]` assesses idiomatic registration wiring. The framework's scanning API is generic over an anchor type, so `ScanModuleApplicationServices()` reads as "scan the assembly that contains `ClassReference`", that is, this Application layer. The class body is empty (`AssemblyReference.cs:11`); its only job is to be a compile-time-checked stand-in for the assembly. +- **Walkthrough**: `public class ClassReference { }` on one line. No fields, no methods, and deliberately neither `sealed` nor `static`, because a `static` class cannot be used as a generic type argument. - **Why it's built this way**: a dedicated marker keeps the scan call site refactor-safe and avoids anchoring the scan on a real domain or handler type that might later move to another assembly. +- **Where it's used**: passed as the type argument to `services.ScanModuleApplicationServices()` in [DependencyInjection](#dependencyinjection) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`). The generic parameter it satisfies is `TAssemblyMarker` on `ScanModuleApplicationServices`, declared in MMCA.Common (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:140-141`) with a `where TAssemblyMarker : class` constraint. + +### GetPublicActivityFilterQuery +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Activities.UseCases.GetPublicActivityFilter` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/UseCases/GetPublicActivityFilter/GetPublicActivityFilterQuery.cs:13` · Level 0 · record (sealed) + +- **What it is**: a parameterless query record asking one question: "which activities may an anonymous or non-privileged caller see?". It carries no data at all; the answer it triggers is a specification, not a page of rows. +- **Depends on**: nothing. `public sealed record GetPublicActivityFilterQuery();` is the entire type, a positional record with an empty parameter list. +- **Concept, the specification-returning query.** Most CQRS queries return data. This family returns a **filter**: a [Specification](group-03-querying-specifications.md#specificationtentity-tidentifiertype) the caller then hands to the generic read pipeline, which ANDs it with whatever paging, sorting and field-selection the request already asked for. `[Rubric §11, Security]` assesses whether authorization is enforced at the data boundary rather than trusted to the UI: expressing "public visibility" as a server-built specification means a non-privileged caller's query is narrowed before it reaches the database, no matter which endpoint or filter string they sent. `[Rubric §6, CQRS & Event-Driven]`: the visibility rule is a first-class query use case with its own handler, so it is unit-testable and reusable instead of being an `if` buried in a controller. The empty record is the CQRS convention taken to its logical end, the query has no inputs because the answer depends only on server state (which events are published) and never on the caller's arguments. +- **Walkthrough**: no members. The teaching is in the two doc comments. The summary (`GetPublicActivityFilterQuery.cs:3-7`) states the business rule: an activity is publicly visible when the event it belongs to is published (BR-108), so an event still being assembled does not leak its social programme before announcement. The remarks (`:8-12`) state the *shape* choice: `Activity` carries a real `EventId` column, so the rule resolves to a published-event id list and comes back as an `Activity.EventId IN (...)` criteria; no navigation join is involved, so the criteria stays engine-portable and `Activity` keeps its by-id boundary to `Event`. +- **Why it's built this way**: keeping the criteria to scalar id comparisons rather than a navigation join is the polyglot-persistence safeguard of [ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html), a filter written this way translates on any supported engine, not just SQL Server. It also preserves the DDD rule that one aggregate references another by id ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html) draws the same boundary at the storage level). +- **Where it's used**: constructed by [ActivitiesController](group-20-conference-api-grpc.md#activitiescontroller) in `BuildPublicActivitySpecificationAsync` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/ActivitiesController.cs:66`) and handled by [GetPublicActivityFilterHandler](#getpublicactivityfilterhandler). It is one of the nine public-filter queries in this module, alongside [GetPublicSessionFilterQuery](#getpublicsessionfilterquery), [GetPublicSpeakerFilterQuery](#getpublicspeakerfilterquery), [GetPublicSponsorFilterQuery](#getpublicsponsorfilterquery) and the rest. + +### ActivityDescriptionRules +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Activities.Validation` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:25` · Level 7 · class (sealed) + +- **What it is**: a length-only rule fragment for the optional activity description. +- **Depends on**: [OptionalStringRules](group-06-validation.md#optionalstringrulest) (its base class, `MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:25`), [ActivityInvariants](group-17-conference-domain.md#activityinvariants) (its `DescriptionMaxLength` constant), `System.Linq.Expressions` (BCL). +- **Concept**: the module-local rule fragment ([ActivityEventIdRules](#activityeventidrulest)), here in its *inheriting* form. The four optional string rules in this file and [ActivityNameRules](#activitynamerulest) do not write a `RuleFor` chain at all: they subclass a framework fragment and pass it a field label plus the domain's max-length constant. That is the module's whole contribution, binding a generic rule to a domain invariant. `[Rubric §16, Maintainability]`. +- **Walkthrough**: one constructor, `ActivityDescriptionRules(Expression> selector)` (`ActivityValidationRules.cs:28`), whose body is only a base call: `: base(selector, "Activity Description", ActivityInvariants.DescriptionMaxLength)` (`:29`). The base contributes `MaximumLength(maxLength)` and nothing else, so null and empty are both accepted. `DescriptionMaxLength` is `2000` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/ActivityInvariants.cs:16`). +- **Why it's built this way**: reading the ceiling from `ActivityInvariants` means the number is declared once and shared by the domain factory, the EF column configuration, and this validator, so a schema change cannot leave a stale validator behind. +- **Caveats**: the inherited fragments emit no `WithErrorCode`, so their failures carry a message but no stable code, unlike the hand-written fragments in the same file. And unlike the name and venue fields, which have domain-side guards emitting coded errors (`ActivityInvariants.cs:36`, `:48`, `:60`, `:72`), the description has only the constant at `ActivityInvariants.cs:16` and no `Ensure...` guard beside it, so this fragment is the enforcement point on the request path. +- **Where it's used**: `Include`d by [ActivityCreateRequestValidator](#activitycreaterequestvalidator) (`.../Create/ActivityCreateRequestValidator.cs:15`) and [ActivityUpdateRequestValidator](#activityupdaterequestvalidator) (`.../Update/ActivityUpdateRequestValidator.cs:14`). + +### ActivityNameRules +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Activities.Validation` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:13` · Level 7 · class (sealed) + +- **What it is**: the required-string rule fragment for an activity's display name: non-empty and within the domain's maximum length. +- **Depends on**: [RequiredStringRules](group-06-validation.md#requiredstringrulest) (its base class, `MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:13`), [ActivityInvariants](group-17-conference-domain.md#activityinvariants) (`NameMaxLength`), `System.Linq.Expressions` (BCL). +- **Concept**: the inheriting form of the module-local rule fragment, as on [ActivityDescriptionRules](#activitydescriptionrulest). This one subclasses the *required* base rather than the optional one, which is the only structural difference between the two. `[Rubric §1, SOLID]`, `[Rubric §24, Forms, Validation & UX Safety]`. +- **Walkthrough**: one constructor, `ActivityNameRules(Expression> selector)` (`ActivityValidationRules.cs:16`), body `: base(selector, "Activity Name", ActivityInvariants.NameMaxLength)` (`:17`). Note the non-nullable `string` selector, versus the `string?` of the optional siblings: the compiler enforces at the call site that only a required property can be passed here. The base contributes `NotEmpty()` plus `MaximumLength(200)` (`ActivityInvariants.NameMaxLength` is `200`, `.../Domain/Activities/ActivityInvariants.cs:13`), with messages built from the "Activity Name" label. +- **Where it's used**: `Include`d by [ActivityCreateRequestValidator](#activitycreaterequestvalidator) (`.../Create/ActivityCreateRequestValidator.cs:11`) and [ActivityUpdateRequestValidator](#activityupdaterequestvalidator) (`.../Update/ActivityUpdateRequestValidator.cs:11`), pointed at each request's `Name`. + +### ActivityVenueAddressRules +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Activities.Validation` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:49` · Level 7 · class (sealed) + +- **What it is**: a length-only rule fragment for the optional street address of an activity's venue. +- **Depends on**: [OptionalStringRules](group-06-validation.md#optionalstringrulest), [ActivityInvariants](group-17-conference-domain.md#activityinvariants) (`VenueAddressMaxLength`), `System.Linq.Expressions` (BCL). +- **Concept**: the inheriting rule fragment taught on [ActivityDescriptionRules](#activitydescriptionrulest). `[Rubric §16, Maintainability]`. +- **Walkthrough**: one constructor (`ActivityValidationRules.cs:52`) delegating to `: base(selector, "Venue Address", ActivityInvariants.VenueAddressMaxLength)` (`:53`). The ceiling is `500` (`.../Domain/Activities/ActivityInvariants.cs:22`), the tightest of the four optional activity strings. +- **Where it's used**: `Include`d by [ActivityCreateRequestValidator](#activitycreaterequestvalidator) (`.../Create/ActivityCreateRequestValidator.cs:17`) and [ActivityUpdateRequestValidator](#activityupdaterequestvalidator) (`.../Update/ActivityUpdateRequestValidator.cs:16`). + +### ActivityVenueNameRules +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Activities.Validation` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:37` · Level 7 · class (sealed) + +- **What it is**: a length-only rule fragment for the optional name of the venue an activity happens at. +- **Depends on**: [OptionalStringRules](group-06-validation.md#optionalstringrulest), [ActivityInvariants](group-17-conference-domain.md#activityinvariants) (`VenueNameMaxLength`), `System.Linq.Expressions` (BCL). +- **Concept**: the inheriting rule fragment taught on [ActivityDescriptionRules](#activitydescriptionrulest). +- **Walkthrough**: one constructor (`ActivityValidationRules.cs:40`) delegating to `: base(selector, "Venue Name", ActivityInvariants.VenueNameMaxLength)` (`:41`), ceiling `200` (`.../Domain/Activities/ActivityInvariants.cs:19`). The class doc (`ActivityValidationRules.cs:32-35`) records the semantics the emptiness carries: an empty venue name means the main conference venue, which is precisely why this rule is the *optional* base and not the required one. Absence is meaningful data here, not a missing field. +- **Where it's used**: `Include`d by [ActivityCreateRequestValidator](#activitycreaterequestvalidator) (`.../Create/ActivityCreateRequestValidator.cs:16`) and [ActivityUpdateRequestValidator](#activityupdaterequestvalidator) (`.../Update/ActivityUpdateRequestValidator.cs:15`). + +### ActivityVenueUrlRules +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Activities.Validation` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:62` · Level 7 · class (sealed) + +- **What it is**: a length-only rule fragment for the optional external website URL of an activity's venue. +- **Depends on**: [OptionalStringRules](group-06-validation.md#optionalstringrulest), [ActivityInvariants](group-17-conference-domain.md#activityinvariants) (`VenueUrlMaxLength`), `System.Linq.Expressions` (BCL). +- **Concept**: the inheriting rule fragment taught on [ActivityDescriptionRules](#activitydescriptionrulest). `[Rubric §9, API & Contract Design]` is worth a note here: the module chooses *not* to constrain the URL's format at the contract boundary. +- **Walkthrough**: one constructor (`ActivityValidationRules.cs:65`) delegating to `: base(selector, "Venue URL", ActivityInvariants.VenueUrlMaxLength)` (`:66`), ceiling `2000` (`.../Domain/Activities/ActivityInvariants.cs:25`). +- **Why it's built this way**: the class doc (`ActivityValidationRules.cs:56-61`) is explicit that the check is length-only, the value is stored as an opaque string, matching the sponsor website-URL precedent. Keeping it a plain `string` with no `Uri` parse and no regex means an organizer pasting a slightly non-canonical link is not blocked at the boundary, and the 2000-character ceiling is the practical URL limit rather than a domain rule. +- **Caveats**: no scheme check exists in this fragment, so `javascript:` or a relative value passes validation. Anything rendering this value is responsible for its own output handling; `[Rubric §26, Front-End Security]` lands on the consumer, not here. +- **Where it's used**: `Include`d by [ActivityCreateRequestValidator](#activitycreaterequestvalidator) (`.../Create/ActivityCreateRequestValidator.cs:18`) and [ActivityUpdateRequestValidator](#activityupdaterequestvalidator) (`.../Update/ActivityUpdateRequestValidator.cs:17`). + +### CategoryItemNameRules +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.Validation` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:27` · Level 7 · class (sealed) + +- **What it is**: a rule fragment enforcing that a category-item name is non-empty and no longer than the maximum the domain defines. +- **Depends on**: FluentValidation (`AbstractValidator`), [CategoryInvariants](group-17-conference-domain.md#categoryinvariants) (its `CategoryItemNameMaxLength` field), `System.Linq.Expressions` and `System.Globalization` (BCL). +- **Concept**: the module-local rule fragment taught on [ActivityEventIdRules](#activityeventidrulest). Unlike the activity string rules, the two Categories string fragments **write their own chain** instead of subclassing the framework's, which is what lets them attach error codes. `[Rubric §1, SOLID]`, `[Rubric §16, Maintainability]`. +- **Walkthrough**: one expression-bodied constructor, `CategoryItemNameRules(Expression> selector)` (`ConferenceCategoryValidationRules.cs:30`), body a single chained `RuleFor(selector)`: `.NotEmpty()` with message "You must enter a Category Item Name" and code `CategoryItem.Name.Required` (`:32`), then `.MaximumLength(CategoryInvariants.CategoryItemNameMaxLength)` with code `CategoryItem.Name.MaxLength` (`:33`). The bound is `500` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:17`), declared `static readonly int` rather than `const`, so the value is read at runtime and consumers are not compile-time-baked to it. The max-length message interpolates that same value through `string.Create(CultureInfo.InvariantCulture, $"...")` rather than plain interpolation, which is what keeps the analyzers-as-errors build satisfied about culture-sensitive formatting. +- **Why it's built this way**: pulling the bound from the domain invariants instead of a literal is the "one place to change a constraint" discipline; validating at the Application boundary *as well as* in the domain factory means the caller gets a field-level failure with a code instead of a generic domain error. The two layers deliberately do not share a code: this validator emits `CategoryItem.Name.MaxLength` while the domain path emits `CategoryItem.Name.TooLong` (`CategoryInvariants.cs:27`), so a failure tells you which layer rejected the value. +- **Where it's used**: `Include`d by [AddCategoryItemCommandValidator](#addcategoryitemcommandvalidator) (`.../Categories/UseCases/AddCategoryItem/AddCategoryItemCommandValidator.cs:11`) and [UpdateCategoryItemCommandValidator](#updatecategoryitemcommandvalidator) (`.../Categories/UseCases/UpdateCategoryItem/UpdateCategoryItemCommandValidator.cs:11`), each pointed at its own `Name`. Siblings: [CategoryItemSortRules](#categoryitemsortrulest), [ConferenceCategoryTitleRules](#conferencecategorytitlerulest). + +### ConferenceCategoryTitleRules +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.Validation` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:13` · Level 7 · class (sealed) + +- **What it is**: a rule fragment enforcing that a conference-category title is non-empty and within the domain-defined maximum length. +- **Depends on**: FluentValidation (`AbstractValidator`), [CategoryInvariants](group-17-conference-domain.md#categoryinvariants) (its `TitleMaxLength` field), `System.Linq.Expressions` and `System.Globalization` (BCL). +- **Concept**: the module-local rule fragment taught on [ActivityEventIdRules](#activityeventidrulest), in the same hand-written-chain form as [CategoryItemNameRules](#categoryitemnamerulest). +- **Walkthrough**: one expression-bodied constructor, `ConferenceCategoryTitleRules(Expression> selector)` (`ConferenceCategoryValidationRules.cs:16`), with a single chained `RuleFor(selector)`: `.NotEmpty()`, message "You must enter a Category Title", code `Category.Title.Required` (`:18`), then `.MaximumLength(CategoryInvariants.TitleMaxLength)` with code `Category.Title.MaxLength` (`:19`). `TitleMaxLength` is `255` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:14`). Structurally identical to [CategoryItemNameRules](#categoryitemnamerulest), differing only in the target property, the constant, and the error-code prefix (`Category.` versus `CategoryItem.`, which is how a client tells a parent failure from a child one). +- **Where it's used**: `Include`d by [ConferenceCategoryCreateRequestValidator](#conferencecategorycreaterequestvalidator) (`.../Categories/UseCases/Create/ConferenceCategoryCreateRequestValidator.cs:10`) and [ConferenceCategoryUpdateRequestValidator](#conferencecategoryupdaterequestvalidator) (`.../Categories/UseCases/Update/ConferenceCategoryUpdateRequestValidator.cs:10`). Note that these two validate inbound *request* records while the two item validators validate *commands*: the fragment is generic over `T`, so it does not care which. + +### DependencyInjection +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:39` · Level 11 · class (static, extension block) + +- **What it is**: the Conference module's application-layer composition root: a static class exposing `AddModuleConferenceApplication(ApplicationSettings)`, which registers every application service this module needs into the DI container. +- **Depends on**: [ApplicationSettings](group-14-module-system-composition.md#applicationsettings); the Conference domain aggregates and children ([Event](group-17-conference-domain.md#event), [Session](group-17-conference-domain.md#session), [Speaker](group-17-conference-domain.md#speaker), [Category](group-17-conference-domain.md#category), [CategoryItem](group-17-conference-domain.md#categoryitem), [Question](group-17-conference-domain.md#question), [Activity](group-17-conference-domain.md#activity), [Sponsor](group-17-conference-domain.md#sponsor), [Room](group-17-conference-domain.md#room), [EventSpeaker](group-17-conference-domain.md#eventspeaker), [EventQuestionAnswer](group-17-conference-domain.md#eventquestionanswer), [SessionSpeaker](group-17-conference-domain.md#sessionspeaker), [SessionCategoryItem](group-17-conference-domain.md#sessioncategoryitem), [SessionQuestionAnswer](group-17-conference-domain.md#sessionquestionanswer), [SpeakerCategoryItem](group-17-conference-domain.md#speakercategoryitem), [SpeakerQuestionAnswer](group-17-conference-domain.md#speakerquestionanswer)); the framework generics [EntityQueryService](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype), [IEntityQueryService](group-03-querying-specifications.md#ientityqueryservicetentity-tentitydto-tidentifiertype), [INavigationPopulator](group-11-navigation-populators.md#inavigationpopulatorin-tentity), [NullNavigationPopulator](group-11-navigation-populators.md#nullnavigationpopulatortentity), [DeleteEntityCommand](group-05-cqrs-pipeline.md#deleteentitycommandtentity-tidentifiertype) and [DeleteEntityHandler](group-05-cqrs-pipeline.md#deleteentityhandlertentity-tidentifiertype); the cross-module ports [ISessionBookmarkValidationService](group-17-conference-domain.md#isessionbookmarkvalidationservice) and [IEventLiveValidationService](group-17-conference-domain.md#ieventlivevalidationservice); [ClassReference](#classreference); plus `Microsoft.Extensions.DependencyInjection` and its `Extensions` namespace (the `TryAdd*` helpers). +- **Concept, the module composition root written as an `extension(IServiceCollection)` block.** `[Rubric §5, Vertical Slice]` assesses whether each module wires its own slice rather than a central registry knowing about every type; `[Rubric §2, Design Patterns]` assesses idiomatic registration. The registration method lives inside a C# `extension(IServiceCollection services)` block (`DependencyInjection.cs:41`), so callers write `services.AddModuleConferenceApplication(settings)`: the same `extension(T)` member style used for DI across the codebase, explained once in the [primer](00-primer.md#c-extensiont-types-read-this-once). The class comment (`DependencyInjection.cs:34-38`) names the deliberate split this file embodies: **explicit registrations for the generic per-entity services** (which cannot be discovered by convention, because the closed generic has to be spelled out) and **Scrutor assembly scanning for everything hand-written** (handlers, mappers, validators), so adding a use case needs no edit here. +- **Walkthrough** (in body order): + - `_ = applicationSettings` (`DependencyInjection.cs:45`): the settings object is part of the module registration contract but this module does not branch on it today; the discard plus the inline comment "Reserved for future use (e.g., profiler decorators)" is what keeps the unused-parameter analyzer quiet without dropping the parameter from the signature. + - **Domain service** (`:48`): `IEventCascadeDeletionDomainService` to [EventCascadeDeletionDomainService](group-17-conference-domain.md#eventcascadedeletiondomainservice) as a singleton. It is stateless, which is why singleton is safe. + - **Session scoring queue** (`:50-55`): [SessionScoringQueue](#sessionscoringqueue) is registered concretely (`:54`) *and* behind [ISessionScoringQueue](#isessionscoringqueue), with the interface registration written as a factory that resolves the concrete singleton (`sp => sp.GetRequiredService()`, `:55`) rather than as a second `TryAddSingleton()`. The comment above it (`:50-53`) states why: long-running AI scoring runs off the request path, the hosted drain in Infrastructure needs the reader side and the completion callback, and both registrations must resolve to the ONE instance, or producers would enqueue work into a queue nobody drains. This is the classic two-registrations-one-instance trap, and the factory form is the fix. + - **Aggregate roots with custom navigation populators** (`:57-72`): `Event` (`:58-60`), `Session` (`:62-64`), `Speaker` (`:66-68`) and `Category` (`:70-72`) each get three scoped registrations, an `INavigationPopulator` (their bespoke populators [EventNavigationPopulator](#eventnavigationpopulator), [SessionNavigationPopulator](#sessionnavigationpopulator), [SpeakerNavigationPopulator](#speakernavigationpopulator), [ConferenceCategoryNavigationPopulator](#conferencecategorynavigationpopulator)), an `IEntityQueryService`, and a delete-command handler. Three of them deviate from the generic default, and the deviations are the interesting part: `Event` binds its delete to the bespoke [DeleteEventHandler](#deleteeventhandler) (`:60`) because deleting an event has to cascade, `Session` binds its delete to the bespoke [DeleteSessionHandler](#deletesessionhandler) (`:64`), and `Speaker` binds its query service to the bespoke [SpeakerEntityQueryService](#speakerentityqueryservice) (`:67`). Everything else uses the framework generics unchanged. + - **Aggregate roots with no navigation properties at all** (`:74-77`): `Question` is the only member of this bucket, and it is the only entity in the whole file registered with `NullNavigationPopulator` (`:75`), the do-nothing populator that satisfies the contract when there is nothing to eager-load, plus the generic `EntityQueryService` and `DeleteEntityHandler`. + - **Aggregate roots whose only navigation is the parent Event FK reference** (`:79-86`): `Activity` (`:80-82`) and `Sponsor` (`:84-86`) each get a bespoke populator ([ActivityNavigationPopulator](#activitynavigationpopulator), [SponsorNavigationPopulator](#sponsornavigationpopulator)) that resolves just that back-reference, the generic query service, and the generic `DeleteEntityHandler`. + - **Child entities** (`:88-115`): `Room`, `CategoryItem`, `EventSpeaker`, `EventQuestionAnswer`, `SessionSpeaker`, `SessionCategoryItem`, `SessionQuestionAnswer` and `SpeakerCategoryItem` each get their own FK populator ([RoomNavigationPopulator](#roomnavigationpopulator), [CategoryItemNavigationPopulator](#categoryitemnavigationpopulator), [EventSpeakerNavigationPopulator](#eventspeakernavigationpopulator), [EventQuestionAnswerNavigationPopulator](#eventquestionanswernavigationpopulator), [SessionSpeakerNavigationPopulator](#sessionspeakernavigationpopulator), [SessionCategoryItemNavigationPopulator](#sessioncategoryitemnavigationpopulator), [SessionQuestionAnswerNavigationPopulator](#sessionquestionanswernavigationpopulator), [SpeakerCategoryItemNavigationPopulator](#speakercategoryitemnavigationpopulator)) plus the base `EntityQueryService`, and deliberately **no** delete handler: children are removed through their aggregate root, never addressed directly by a delete command. `SpeakerQuestionAnswer` is the one asymmetry, it gets [SpeakerQuestionAnswerNavigationPopulator](#speakerquestionanswernavigationpopulator) (`:115`) but no query service, and the comment above it (`:113-114`) says so outright: it has no query service today, and registering the populator future-proofs the one that would be added alongside it. + - **Cross-module ports** (`:117-121`): `ISessionBookmarkValidationService` to [SessionBookmarkValidationService](#sessionbookmarkvalidationservice) (`:118`) and `IEventLiveValidationService` to [EventLiveValidationService](#eventlivevalidationservice) (`:121`), the in-process interfaces the Engagement module consumes (the file's comments name Engagement and its live layer as the consumers). + - **Convention scan** (`:125`): `services.ScanModuleApplicationServices()` sweeps this assembly for, per the comment on `:123-124`, domain event handlers, DTO/request mappers, command/query handlers, and validators. The framework method behind it (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:140`) runs a series of Scrutor `services.Scan(...)` passes, singleton lifetimes for domain and integration event handlers, scoped for mappers and projectors. The method then returns `services` (`:127`) for fluent chaining. +- **Why it's built this way**: every registration uses `TryAdd*` rather than `Add*`, so a host (or a test) can register its own implementation first and this method will not clobber it or produce a duplicate registration. Splitting explicit generics from convention scanning keeps a file that wires roughly twenty entities under 130 lines while still registering the module's dozens of hand-written handlers. Registering the cross-module validation services here as ordinary in-process interfaces is exactly what lets the same module code run co-located or split behind gRPC without a rewrite ([ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html), [ADR-008](https://ivanball.github.io/docs/adr/008-service-extraction-topology.html)); the per-entity `INavigationPopulator` registrations are the populator pattern of [ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html) being bound one entity at a time. +- **Where it's used**: called by the Conference module's API-layer registration (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/DependencyInjection.cs:25`), which is itself invoked through the module's [IModule](group-14-module-system-composition.md#imodule) implementation during host startup; modules are discovered and registered in topological order by the [ModuleLoader](group-14-module-system-composition.md#moduleloader). +- **Caveats / not in source**: `applicationSettings` is accepted and immediately discarded; the "profiler decorators" the comment reserves it for do not exist in this layer today. `ISessionizeService` is absent from this file on purpose: the Application layer owns that port, but its typed-client registration lives in Conference Infrastructure. + +### GetPublicActivityFilterHandler +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Activities.UseCases.GetPublicActivityFilter` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/UseCases/GetPublicActivityFilter/GetPublicActivityFilterHandler.cs:16` · Level 11 · class (sealed) + +- **What it is**: the query handler that answers [GetPublicActivityFilterQuery](#getpublicactivityfilterquery). It resolves the ids of the published events and returns an `Activity.EventId IN (...)` specification the read pipeline can AND into any activity query. +- **Depends on**: [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) (constructor-injected, `GetPublicActivityFilterHandler.cs:17`), [PublicConferenceVisibility](#publicconferencevisibility), [Specification](group-03-querying-specifications.md#specificationtentity-tidentifiertype) and [InlineSpecification](group-03-querying-specifications.md#inlinespecificationtentity-tidentifiertype), [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult), [Result](group-01-result-error-handling.md#result), and the [Activity](group-17-conference-domain.md#activity) aggregate it filters. +- **Concept, the handler that returns a specification instead of rows.** `[Rubric §6, CQRS & Event-Driven]` assesses whether read intent is modelled as first-class, individually testable use cases; `[Rubric §11, Security]` assesses whether the visibility rule is applied server-side at the data boundary. Combining the two produces the shape here: the handler's `TResult` is not a DTO or a page but `Result>` (`:18`). The caller receives a composable predicate and hands it to the generic entity-query layer, which applies it *before* paging and sorting, so the rule cannot be defeated by a crafted query string. `[Rubric §12, Performance & Scalability]` is served by the same choice: the filter arrives as one translated `IN` clause on a column, not as an in-memory post-filter over a full result set. +- **Walkthrough**: a primary-constructor class taking `IUnitOfWork unitOfWork` (`:16-17`), with one method. + - `HandleAsync(GetPublicActivityFilterQuery query, CancellationToken cancellationToken = default)` (`:21-23`). The `query` parameter is unused by design, the query record has no fields. + - `await PublicConferenceVisibility.GetPublishedEventIdsAsync(unitOfWork, cancellationToken)` (`:25-27`) does the work. That shared helper resolves the read repository for `Event` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:40`), projects `e => e.Id` under the predicate `e => e.IsPublished` with `asTracking: false` (`PublicConferenceVisibility.cs:42-44`), and materializes the sequence once so the caller embeds a stable collection EF can translate into `IN` (`PublicConferenceVisibility.cs:46-47`). Every await in this path is `.ConfigureAwait(false)`, the codebase convention for library code. + - The return (`:29-30`) wraps a `new InlineSpecification(a => publishedEventIds.Contains(a.EventId))` in `Result.Success<...>`. `InlineSpecification` is the lambda-carrying specification, so no bespoke specification class is needed for a one-line criteria. The handler has no failure path: an empty published-event list is a valid answer that yields a specification matching nothing. +- **Why it's built this way**: the class doc (`:10-15`) states the alignment: the id-list shape mirrors the sponsor, speaker and session public filters, so no navigation join is required and the criteria stays translatable on any engine ([ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html)). Centralizing the id resolution in `PublicConferenceVisibility` rather than repeating the `IsPublished` projection in nine handlers means the definition of "published" changes in one place. +- **Where it's used**: injected into [ActivitiesController](group-20-conference-api-grpc.md#activitiescontroller) as `IQueryHandler>>` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/ActivitiesController.cs:42`) and called from `BuildPublicActivitySpecificationAsync` (`ActivitiesController.cs:60-69`), which short-circuits to `null` for privileged readers (`ActivitiesController.cs:63-64`, guarded by `IsPrivileged` at `:50`) so Organizers and ContentEditors keep seeing activities of events still being assembled. It is registered by convention, not explicitly: the scan in [DependencyInjection](#dependencyinjection) (`DependencyInjection.cs:125`) picks up every `IQueryHandler<,>` in the assembly. +- **Caveats / not in source**: the two-query shape (published event ids, then activities) is two round trips by construction. Whether the id list is cached anywhere is not determinable from this file; nothing in the handler or in `PublicConferenceVisibility` memoizes it, so each call re-reads the published-event ids. + +### GetPublicEventSpeakerFilterQuery + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.GetPublicEventSpeakerFilter` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterQuery.cs:10` · Level 0 · record + +- **What it is**: a parameterless **marker query** asking for the filter that limits [`EventSpeaker`](group-17-conference-domain.md#eventspeaker) junction rows to the ones a non-privileged caller may read. The whole type is one line: `public sealed record GetPublicEventSpeakerFilterQuery;` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterQuery.cs:10`). +- **Depends on**: nothing first-party, nothing external. It is an empty record with no positional parameters. +- **Concept**: none new. The marker-query shape is taught under [`GetPublicSessionFilterQuery`](#getpublicsessionfilterquery), and the visibility rules themselves are defined once in [`PublicConferenceVisibility`](#publicconferencevisibility). What this query adds is a junction with **two** parents. The doc comment (`GetPublicEventSpeakerFilterQuery.cs:3-9`) states both legs and the leak each one closes: a row is readable only when its parent event is published (BR-108), because otherwise the join endpoints would list the speakers of an unannounced event and reveal that it exists, AND when its parent speaker is publicly visible (BR-239), because otherwise the association endpoint would hand back the whole Sessionize-imported roster that the speaker list itself hides. `[Rubric §11, Security]` assesses whether an anonymous surface can be used to infer the existence of content the caller may not read; a join row with two parents can leak through either of them. +- **Walkthrough**: no members. Every line of behavior lives in [`GetPublicEventSpeakerFilterHandler`](#getpubliceventspeakerfilterhandler). +- **Why it's built this way**: the rules belong to the two parents, not to the join row, so the query carries no arguments and the handler derives its answer from the shared resolver instead of restating either rule. +- **Where it's used**: handled by [`GetPublicEventSpeakerFilterHandler`](#getpubliceventspeakerfilterhandler); injected into `EventSpeakersController` as an [`IQueryHandler`](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:51`) and constructed in its private `BuildPublicSpecificationAsync` helper (`EventSpeakersController.cs:71`), which returns `null` for privileged readers (`:68-69`) and the specification for everyone else. That helper feeds all four `[AllowAnonymous]` reads: the unpaged list (`:90`), the paged list (`:120`), the lookup (`:148`), and the by-id read (`:178`). + +--- + +### GetPublicRoomFilterQuery + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.GetPublicRoomFilter` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/GetPublicRoomFilter/GetPublicRoomFilterQuery.cs:14` · Level 0 · record + +- **What it is**: a parameterless marker query asking for the filter that limits [`Room`](group-17-conference-domain.md#room) rows to the ones a non-privileged caller may read: a room is publicly visible when the event it belongs to is published (BR-108). The whole type is one line, `public sealed record GetPublicRoomFilterQuery();` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/GetPublicRoomFilter/GetPublicRoomFilterQuery.cs:14`). +- **Depends on**: nothing first-party, nothing external. +- **Concept**: none new. The marker-query shape is taught under [`GetPublicSessionFilterQuery`](#getpublicsessionfilterquery); the rule it names is resolved once in [`PublicConferenceVisibility`](#publicconferencevisibility). `[Rubric §11, Security]` assesses whether an anonymous surface leaks the existence or the detail of content the caller may not read. The doc comment names the concrete exposure this query closes (`GetPublicRoomFilterQuery.cs:3-8`): rooms of an unpublished event stay hidden, so an event still being assembled does not publish its floor plan, room names, or capacities before the agenda is announced. +- **Walkthrough**: no members. Note the declaration-style difference from its sibling in this same unit: this one is written with an empty parameter list, `GetPublicRoomFilterQuery()`, which declares a primary constructor, while [`GetPublicEventSpeakerFilterQuery`](#getpubliceventspeakerfilterquery) is written without one, `GetPublicEventSpeakerFilterQuery;`. Both are constructed identically at the call site as `new X()`, so the difference is cosmetic; it is worth knowing only so you do not read meaning into it. +- **Why it's built this way**: the remarks (`GetPublicRoomFilterQuery.cs:9-13`) record the design choice behind the shape of the answer. `Room` carries a real `EventId` column (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Room.cs:37`), so the rule resolves to a published-event id list and comes back as a `Room.EventId IN (...)` criteria. No navigation join is involved, which keeps the criteria engine-portable ([ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html)) and keeps `Room`'s reference to `Event` a by-id boundary rather than a traversal. +- **Where it's used**: handled by [`GetPublicRoomFilterHandler`](#getpublicroomfilterhandler); injected into `RoomsController` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/RoomsController.cs:97`) and constructed in its private `BuildPublicRoomSpecificationAsync` helper (`RoomsController.cs:120`). + +--- ### SessionizeCategoryItem + > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.Sessionize` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:60` · Level 0 · record (sealed) - **What it is**: the leaf DTO for one category value from the Sessionize "View All" API (for example "Beginner" under the "Level" category, or ".NET" under "Track"): an `Id`, a `Name`, and a `Sort` order. - **Depends on**: `System.Text.Json.Serialization` (`[JsonPropertyName]`, BCL) only. -- **Concept introduced, the external-API contract DTO.** [Rubric §9, API & Contract Design] assesses whether contracts crossing a boundary are explicit; [Rubric §32, Dependency & Supply-Chain] assesses controlling the shape of data arriving from a third party. The whole `Sessionize*` family in this one file models the JSON wire format of the external system the conference agenda is imported from. Every member of the family follows the same three rules: it is a `sealed record`, every property is `init`-only, and every property carries a `[JsonPropertyName("...")]` mapping the C# name onto the exact Sessionize field (`SessionizeModels.cs:62-69`). Reference-typed properties get a non-null default (`Name { get; init; } = string.Empty` at line 66, collections `= []`), so a payload missing a field deserializes to an empty value rather than a null the import code would have to guard. Modeling the external contract as its own dedicated immutable type, instead of binding straight onto domain entities, is the anti-corruption discipline: the outside shape is captured here and translated into the domain by the sync strategies. The remaining `Sessionize*` sections cross-reference back to this one rather than repeating the shape. +- **Concept introduced, the external-API contract DTO.** `[Rubric §9, API & Contract Design]` assesses whether contracts crossing a boundary are explicit; `[Rubric §32, Dependency & Supply-Chain]` assesses controlling the shape of data arriving from a third party. The whole `Sessionize*` family in this one file models the JSON wire format of the external system the conference agenda is imported from. Every member of the family follows the same three rules: it is a `sealed record`, every property is `init`-only, and every property carries a `[JsonPropertyName("...")]` mapping the C# name onto the exact Sessionize field (`SessionizeModels.cs:62-69`). Reference-typed properties get a non-null default (`Name { get; init; } = string.Empty` at line 66, collections `= []`), so a payload missing a field deserializes to an empty value rather than a null the import code would have to guard. Modeling the external contract as its own dedicated immutable type, instead of binding straight onto domain entities, is the anti-corruption discipline: the outside shape is captured here and translated into the domain by the sync strategies. The remaining `Sessionize*` sections cross-reference back to this one rather than repeating the shape. - **Walkthrough**: three `init` properties, `Id` (`int`, line 63), `Name` (`string`, empty default, line 66), `Sort` (`int`, line 69), each JSON-mapped by the attribute on the line above it. No behavior at all; it is a pure data-transfer record. -- **Why it's built this way**: `record` gives structural equality and a compact declaration (the same reasoning as the [ValueObject](group-02-domain-building-blocks.md#valueobject) discussion), and `init` plus non-null defaults means System.Text.Json can populate it while callers can never mutate it afterward. -- **Where it's used**: nested inside [SessionizeCategory](#sessionizecategory)'s `Items`; consumed by the category import path, [CategorySyncStrategy](#categorysyncstrategy). +- **Why it's built this way**: `record` gives structural equality and a compact declaration (the same reasoning as the [`ValueObject`](group-02-domain-building-blocks.md#valueobject) discussion), and `init` plus non-null defaults means System.Text.Json can populate it while callers can never mutate it afterward. +- **Where it's used**: nested inside [`SessionizeCategory`](#sessionizecategory)'s `Items`; consumed by the category import path, [`CategorySyncStrategy`](#categorysyncstrategy). + +--- ### SessionizeLink + > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.Sessionize` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:126` · Level 0 · record (sealed) - **What it is**: the DTO for one speaker social link from Sessionize: a `Title`, a `Url`, and a `LinkType` (for example "Twitter" or "LinkedIn"). - **Depends on**: `System.Text.Json.Serialization` (BCL) only. -- **Concept**: an external-API contract DTO, the pattern taught on [SessionizeCategoryItem](#sessionizecategoryitem). [Rubric §9, API & Contract Design]. +- **Concept**: an external-API contract DTO, the pattern taught on [`SessionizeCategoryItem`](#sessionizecategoryitem). `[Rubric §9, API & Contract Design]`. - **Walkthrough**: three `init` `string` properties, all empty-defaulted and JSON-mapped: `Title` (line 129), `Url` (line 132), `LinkType` (line 135). - **Why it's built this way**: `Url` is a plain `string`, not a `Uri`. Keeping it a string means a non-canonical value from Sessionize cannot fail deserialization at the wire boundary; any parsing or validation happens later, in the import path, where a bad value can be reported as a warning instead of an exception. -- **Where it's used**: nested inside [SessionizeSpeaker](#sessionizespeaker)'s `Links` collection, read by [SpeakerSyncStrategy](#speakersyncstrategy). +- **Where it's used**: nested inside [`SessionizeSpeaker`](#sessionizespeaker)'s `Links` collection, read by [`SpeakerSyncStrategy`](#speakersyncstrategy). + +--- ### SessionizeQuestion + > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.Sessionize` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:25` · Level 0 · record (sealed) - **What it is**: the DTO for one custom-question *definition* from Sessionize (the question itself, not an answer to it): `Id`, `Question` text, `QuestionType`, and a `Sort` order. - **Depends on**: `System.Text.Json.Serialization` (BCL) only. -- **Concept**: an external-API contract DTO ([SessionizeCategoryItem](#sessionizecategoryitem)). [Rubric §9, API & Contract Design]. +- **Concept**: an external-API contract DTO ([`SessionizeCategoryItem`](#sessionizecategoryitem)). `[Rubric §9, API & Contract Design]`. - **Walkthrough**: four `init` properties, `Id` (`int`, line 28), `Question` (`string`, empty default, line 31), `QuestionType` (`string`, empty default, line 34), `Sort` (`int`, line 37). `QuestionType` arrives as a free-form string, so the import decides how to interpret it rather than the wire model constraining it to an enum. -- **Where it's used**: nested inside [SessionizeResponse](#sessionizeresponse)'s `Questions`; imported by [QuestionSyncStrategy](#questionsyncstrategy). Its answers are carried separately, by [SessionizeQuestionAnswer](#sessionizequestionanswer). +- **Where it's used**: nested inside [`SessionizeResponse`](#sessionizeresponse)'s `Questions` (`SessionizeModels.cs:21`); imported by [`QuestionSyncStrategy`](#questionsyncstrategy). Its answers are carried separately, by [`SessionizeQuestionAnswer`](#sessionizequestionanswer). + +--- ### SessionizeQuestionAnswer + > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.Sessionize` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:192` · Level 0 · record (sealed) - **What it is**: the DTO for one answer to a Sessionize custom question: the `QuestionId` it answers and its `AnswerValue`. - **Depends on**: `System.Text.Json.Serialization` (BCL) only. -- **Concept**: an external-API contract DTO ([SessionizeCategoryItem](#sessionizecategoryitem)). [Rubric §9, API & Contract Design]. +- **Concept**: an external-API contract DTO ([`SessionizeCategoryItem`](#sessionizecategoryitem)). `[Rubric §9, API & Contract Design]`. - **Walkthrough**: two `init` properties, `QuestionId` (`int`, line 195) and `AnswerValue` (`string`, empty default, line 198). The answer points back at its question by id rather than nesting the question definition, which is why the same answer record can hang off two different parents. -- **Where it's used**: nested inside both [SessionizeSpeaker](#sessionizespeaker)'s and [SessionizeSession](#sessionizesession)'s `QuestionAnswers` collections. +- **Where it's used**: nested inside both [`SessionizeSpeaker`](#sessionizespeaker)'s (`SessionizeModels.cs:122`) and [`SessionizeSession`](#sessionizesession)'s (`SessionizeModels.cs:170`) `QuestionAnswers` collections. + +--- ### SessionizeRoom + > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.Sessionize` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:73` · Level 0 · record (sealed) - **What it is**: the DTO for one room from Sessionize: `Id`, `Name`, `Sort`. - **Depends on**: `System.Text.Json.Serialization` (BCL) only. -- **Concept**: an external-API contract DTO ([SessionizeCategoryItem](#sessionizecategoryitem)). [Rubric §9, API & Contract Design]. -- **Walkthrough**: three `init` properties, `Id` (`int`, line 76), `Name` (`string`, empty default, line 79), `Sort` (`int`, line 82). Structurally identical to [SessionizeCategoryItem](#sessionizecategoryitem); the two are kept as distinct types (rather than one shared "named thing" record) so a change on either side of the Sessionize contract cannot silently propagate to the other import path. -- **Where it's used**: nested inside [SessionizeResponse](#sessionizeresponse)'s `Rooms`; imported by [RoomSyncStrategy](#roomsyncstrategy). Sessions reference a room by `RoomId`, not by nesting this record. +- **Concept**: an external-API contract DTO ([`SessionizeCategoryItem`](#sessionizecategoryitem)). `[Rubric §9, API & Contract Design]`. +- **Walkthrough**: three `init` properties, `Id` (`int`, line 76), `Name` (`string`, empty default, line 79), `Sort` (`int`, line 82). Structurally identical to [`SessionizeCategoryItem`](#sessionizecategoryitem); the two are kept as distinct types (rather than one shared "named thing" record) so a change on either side of the Sessionize contract cannot silently propagate to the other import path. +- **Where it's used**: nested inside [`SessionizeResponse`](#sessionizeresponse)'s `Rooms` (`SessionizeModels.cs:12`); imported by [`RoomSyncStrategy`](#roomsyncstrategy). Sessions reference a room by `RoomId` (`SessionizeModels.cs:173`), not by nesting this record. + +--- ### SessionizeCategory + > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.Sessionize` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:41` · Level 1 · record (sealed) -- **What it is**: the DTO for one Sessionize category (for example "Level" or "Track"), owning the nested list of its [SessionizeCategoryItem](#sessionizecategoryitem) values. -- **Depends on**: [SessionizeCategoryItem](#sessionizecategoryitem) (its `Items` collection); `System.Text.Json.Serialization` (BCL). -- **Concept**: an external-API contract DTO ([SessionizeCategoryItem](#sessionizecategoryitem)). This is the first `Sessionize*` record that nests another, which is exactly why it sits one dependency level up. [Rubric §9, API & Contract Design]. +- **What it is**: the DTO for one Sessionize category (for example "Level" or "Track"), owning the nested list of its [`SessionizeCategoryItem`](#sessionizecategoryitem) values. +- **Depends on**: [`SessionizeCategoryItem`](#sessionizecategoryitem) (its `Items` collection); `System.Text.Json.Serialization` (BCL). +- **Concept**: an external-API contract DTO ([`SessionizeCategoryItem`](#sessionizecategoryitem)). This is the first `Sessionize*` record that nests another, which is exactly why it sits one dependency level up. `[Rubric §9, API & Contract Design]`. - **Walkthrough**: five `init` properties. `Id` (`int`, line 44), `Title` (`string`, empty default, line 47), and `Sort` (`int`, line 50) are the flat fields; `Type` is `string?` (line 53), so an absent JSON field stays null rather than being flattened to an empty string; `Items` is `IReadOnlyList` defaulted to the collection expression `[]` (line 56), so a category with no values deserializes to an empty list. Exposing the collection as `IReadOnlyList` (not `List`) keeps the record immutable in practice as well as by `init`. -- **Where it's used**: nested inside [SessionizeResponse](#sessionizeresponse)'s `Categories`; both the category and its items are reconciled against the domain by [CategorySyncStrategy](#categorysyncstrategy). +- **Where it's used**: nested inside [`SessionizeResponse`](#sessionizeresponse)'s `Categories` (`SessionizeModels.cs:9`); both the category and its items are reconciled against the domain by [`CategorySyncStrategy`](#categorysyncstrategy). + +--- ### SessionizeSession + > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.Sessionize` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:139` · Level 1 · record (sealed) - **What it is**: the richest Sessionize DTO: one conference session with its schedule, room, speaker references, category assignments, question answers, and live/recording metadata. -- **Depends on**: [SessionizeQuestionAnswer](#sessionizequestionanswer) (its `QuestionAnswers` list, line 170); `System.Text.Json.Serialization` (BCL). -- **Concept**: an external-API contract DTO ([SessionizeCategoryItem](#sessionizecategoryitem)), here at full width. [Rubric §9, API & Contract Design]. +- **Depends on**: [`SessionizeQuestionAnswer`](#sessionizequestionanswer) (its `QuestionAnswers` list, line 170); `System.Text.Json.Serialization` (BCL). +- **Concept**: an external-API contract DTO ([`SessionizeCategoryItem`](#sessionizecategoryitem)), here at full width. `[Rubric §9, API & Contract Design]`. - **Walkthrough**: sixteen `init` properties (`SessionizeModels.cs:141-188`). Five groups are worth knowing: - `Id` (`int`, line 143) is the only property in the whole file annotated `[JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]` (line 142). Sessionize sometimes serializes a session id as a JSON *string* rather than a number, and that one attribute is what stops the whole import from failing on it. - - `StartsAt` and `EndsAt` are `DateTime?` (lines 152 and 155), so an unscheduled session round-trips with nulls instead of a deserialization error. [SessionSyncStrategy](#sessionsyncstrategy) validates the pair rather than the wire model doing it: `ValidateSessionTimes` warns when a start date falls before the event's start date and when the duration is zero or negative, storing the value as-is per BR-122 (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionSyncStrategy.cs:53-69`). + - `StartsAt` and `EndsAt` are `DateTime?` (lines 152 and 155), so an unscheduled session round-trips with nulls instead of a deserialization error. [`SessionSyncStrategy`](#sessionsyncstrategy) validates the pair rather than the wire model doing it: `ValidateSessionTimes` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionSyncStrategy.cs:53`) warns when a start falls before the event's start date and when an end falls after its end date (BR-86, `:55-64`), and warns on a zero or negative duration while storing the value as-is per BR-122 (`:66-70`). - `Speakers` is `IReadOnlyList` (line 164), `CategoryItems` is `IReadOnlyList` (line 167), and `RoomId` is `int?` (line 173): the session references other entities by their Sessionize ids instead of nesting the full objects, so those cross-references are resolved during import against the already-imported speakers, category items, and rooms. - - `Description`, `LiveUrl`, `RecordingUrl`, and `Status` are nullable strings (lines 149, 176, 179, 182); `LiveUrl`/`RecordingUrl` stay `string` for the same reason as [SessionizeLink](#sessionizelink)'s `Url`. + - `Description`, `LiveUrl`, `RecordingUrl`, and `Status` are nullable strings (lines 149, 176, 179, 182); `LiveUrl` and `RecordingUrl` stay `string` for the same reason as [`SessionizeLink`](#sessionizelink)'s `Url`. - Four booleans classify the session: `IsServiceSession` (line 158) and `IsPlenumSession` (line 161) mark non-talk and plenary slots, `IsInformed` (line 185) and `IsConfirmed` (line 188) carry the speaker-communication state Sessionize tracks. - **Why it's built this way**: the id-reference lists mirror how Sessionize normalizes its own payload; keeping the DTO faithful to that shape (rather than pre-joining it) means the wire model stays a mechanical translation and every judgement call lives in the sync strategies, where it can emit a warning. -- **Where it's used**: nested inside [SessionizeResponse](#sessionizeresponse)'s `Sessions`; imported by [SessionSyncStrategy](#sessionsyncstrategy) under [RefreshFromSessionizeHandler](#refreshfromsessionizehandler). +- **Where it's used**: nested inside [`SessionizeResponse`](#sessionizeresponse)'s `Sessions` (`SessionizeModels.cs:18`); imported by [`SessionSyncStrategy`](#sessionsyncstrategy) under [`RefreshFromSessionizeHandler`](#refreshfromsessionizehandler). + +--- ### SessionizeSpeaker + > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.Sessionize` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:86` · Level 1 · record (sealed) -- **What it is**: the DTO for one speaker from Sessionize, with profile fields, social [SessionizeLink](#sessionizelink)s, question answers, and id references to the speaker's sessions and category items. -- **Depends on**: [SessionizeLink](#sessionizelink) (`Links`), [SessionizeQuestionAnswer](#sessionizequestionanswer) (`QuestionAnswers`); `System.Text.Json.Serialization` (BCL). -- **Concept**: an external-API contract DTO ([SessionizeCategoryItem](#sessionizecategoryitem)). [Rubric §9, API & Contract Design]. +- **What it is**: the DTO for one speaker from Sessionize, with profile fields, social [`SessionizeLink`](#sessionizelink)s, question answers, and id references to the speaker's sessions and category items. +- **Depends on**: [`SessionizeLink`](#sessionizelink) (`Links`), [`SessionizeQuestionAnswer`](#sessionizequestionanswer) (`QuestionAnswers`); `System.Text.Json.Serialization` (BCL). +- **Concept**: an external-API contract DTO ([`SessionizeCategoryItem`](#sessionizecategoryitem)). `[Rubric §9, API & Contract Design]`. - **Walkthrough**: twelve `init` properties (`SessionizeModels.cs:88-122`). `Id` is a `Guid` (line 89), unlike the `int` ids of every other Sessionize entity in this file. The optional profile fields `Bio` (line 98), `TagLine` (line 101), `ProfilePicture` (line 104), and `FullName` (line 116) are nullable strings, while `FirstName` (line 92) and `LastName` (line 95) are empty-defaulted non-nullable ones. `IsTopSpeaker` is a `bool` (line 107). Four collections, `Links` (line 110), `Sessions` (`IReadOnlyList`, line 113), `CategoryItems` (`IReadOnlyList`, line 119), and `QuestionAnswers` (line 122), are all `IReadOnlyList` defaulted to `[]`. -- **Why it's built this way**: the `Guid` speaker id lines up with the Conference module's `SpeakerIdentifierType = Guid` alias, so the import can carry a Sessionize speaker id straight into a domain [Speaker](group-17-conference-domain.md#speaker) key without a conversion or a lookup table. That both `FullName` and the `FirstName`/`LastName` pair exist is Sessionize's redundancy, not the module's: the wire model keeps both and lets [SpeakerSyncStrategy](#speakersyncstrategy) choose. -- **Where it's used**: nested inside [SessionizeResponse](#sessionizeresponse)'s `Speakers`; imported by [SpeakerSyncStrategy](#speakersyncstrategy), which takes the record directly as a parameter (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SpeakerSyncStrategy.cs:102`). +- **Why it's built this way**: the `Guid` speaker id lines up with the Conference module's `SpeakerIdentifierType = Guid` alias, so the import can carry a Sessionize speaker id straight into a domain [`Speaker`](group-17-conference-domain.md#speaker) key without a conversion or a lookup table. That both `FullName` and the `FirstName`/`LastName` pair exist is Sessionize's redundancy, not the module's: the wire model keeps both and lets [`SpeakerSyncStrategy`](#speakersyncstrategy) choose. +- **Where it's used**: nested inside [`SessionizeResponse`](#sessionizeresponse)'s `Speakers` (`SessionizeModels.cs:15`); imported by [`SpeakerSyncStrategy`](#speakersyncstrategy), which takes the record directly as a parameter (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SpeakerSyncStrategy.cs:102`). One consequence of that import is load-bearing elsewhere in this chapter: every synced speaker without an active link gets an `EventSpeaker` row (`SpeakerSyncStrategy.cs:59`), which is why [`GetPublicEventSpeakerFilterHandler`](#getpubliceventspeakerfilterhandler) cannot treat that junction as an acceptance signal. + +--- ### SessionizeResponse + > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.Sessionize` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:6` · Level 2 · record (sealed) - **What it is**: the top-level envelope for the Sessionize "View All" API response, holding the five parallel collections (`Categories`, `Rooms`, `Speakers`, `Sessions`, `Questions`) that make up an entire conference import payload. -- **Depends on**: [SessionizeCategory](#sessionizecategory), [SessionizeRoom](#sessionizeroom), [SessionizeSpeaker](#sessionizespeaker), [SessionizeSession](#sessionizesession), [SessionizeQuestion](#sessionizequestion); `System.Text.Json.Serialization` (BCL). -- **Concept**: the root of the external-API contract DTO tree that [SessionizeCategoryItem](#sessionizecategoryitem) taught. [Rubric §9, API & Contract Design]. This is the object [ISessionizeService](#isessionizeservice) returns and the single input every Sessionize sync strategy reads from. +- **Depends on**: [`SessionizeCategory`](#sessionizecategory), [`SessionizeRoom`](#sessionizeroom), [`SessionizeSpeaker`](#sessionizespeaker), [`SessionizeSession`](#sessionizesession), [`SessionizeQuestion`](#sessionizequestion); `System.Text.Json.Serialization` (BCL). +- **Concept**: the root of the external-API contract DTO tree that [`SessionizeCategoryItem`](#sessionizecategoryitem) taught. `[Rubric §9, API & Contract Design]`. This is the object [`ISessionizeService`](#isessionizeservice) returns and the single input every Sessionize sync strategy reads from. - **Walkthrough**: five `init` `IReadOnlyList<...>` properties, each defaulted to `[]` and JSON-mapped to the lower-cased Sessionize field name: `Categories` (line 9), `Rooms` (line 12), `Speakers` (line 15), `Sessions` (line 18), `Questions` (line 21). "View All" is Sessionize's denormalized endpoint: it returns every entity kind in one document, which is why this envelope has one collection per kind rather than a paged, per-type shape. - **Why it's built this way**: one immutable envelope makes the import easy to reason about, the strategies receive the whole snapshot at once and reconcile the domain against it; and the empty-list defaults mean a payload missing a section is still a valid, non-null response the strategies can iterate over without null checks. -- **Where it's used**: returned (nullable) by [ISessionizeService](#isessionizeservice); handed to the sync strategies through [SessionizeSyncContext](#sessionizesynccontext) by [RefreshFromSessionizeHandler](#refreshfromsessionizehandler), under the [RefreshFromSessionizeCommand](#refreshfromsessionizecommand) use case. +- **Where it's used**: returned (nullable) by [`ISessionizeService`](#isessionizeservice); carried to the sync strategies as the `required` `Response` property of [`SessionizeSyncContext`](#sessionizesynccontext) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionizeSyncContext.cs:13`), which [`RefreshFromSessionizeHandler`](#refreshfromsessionizehandler) builds under the [`RefreshFromSessionizeCommand`](#refreshfromsessionizecommand) use case. + +--- ### ISessionizeService + > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.Sessionize` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/ISessionizeService.cs:6` · Level 3 · interface -- **What it is**: the one-method contract for fetching a whole conference from the Sessionize "View All" API, returning a [SessionizeResponse](#sessionizeresponse) or `null` when the response is empty. -- **Depends on**: [SessionizeResponse](#sessionizeresponse) (its return type). Nothing else, no `HttpClient`, no options type. -- **Concept introduced, the outbound-port interface (dependency inversion at an external boundary).** [Rubric §3, Clean Architecture] assesses whether the Application layer depends only on abstractions it owns, with concrete adapters living further out; [Rubric §7, Microservices Readiness] assesses isolating third-party calls behind a swappable boundary. Here the Application layer declares *what* it needs from Sessionize (this interface), while the HTTP client that actually calls the API lives in Conference Infrastructure and implements it: [SessionizeService](group-19-conference-infrastructure.md#sessionizeservice), registered as a typed client with the base address `https://sessionize.com/api/v2/` via `services.AddHttpClient(...)` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:21-23`). That inversion is what keeps the import use case unit-testable: the test tier substitutes [FakeSessionizeService](group-27-testing-infrastructure.md#fakesessionizeservice) and feeds a canned response with no network at all. +- **What it is**: the one-method contract for fetching a whole conference from the Sessionize "View All" API, returning a [`SessionizeResponse`](#sessionizeresponse) or `null` when the response is empty. +- **Depends on**: [`SessionizeResponse`](#sessionizeresponse) (its return type). Nothing else, no `HttpClient`, no options type. +- **Concept introduced, the outbound-port interface (dependency inversion at an external boundary).** `[Rubric §3, Clean Architecture]` assesses whether the Application layer depends only on abstractions it owns, with concrete adapters living further out; `[Rubric §7, Microservices Readiness]` assesses isolating third-party calls behind a swappable boundary. Here the Application layer declares *what* it needs from Sessionize (this interface), while the HTTP client that actually calls the API lives in Conference Infrastructure and implements it: [`SessionizeService`](group-19-conference-infrastructure.md#sessionizeservice), registered as a typed client with the base address `https://sessionize.com/api/v2/` via `services.AddHttpClient(...)` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:22-24`). That inversion is what keeps the import use case exercisable without a network: the integration tier substitutes [`FakeSessionizeService`](group-27-testing-infrastructure.md#fakesessionizeservice) (`MMCA.ADC/Tests/Integration/MMCA.ADC.Conference.IntegrationTests/Infrastructure/FakeSessionizeService.cs:12`) and feeds a canned response. - **Walkthrough**: a single method, `Task GetAllAsync(string sessionizeCode, CancellationToken cancellationToken = default)` (`ISessionizeService.cs:12`). `sessionizeCode` is the per-event Sessionize code (the XML doc gives the example `"kqf8l42a"`, line 9); the nullable return signals an empty or absent response instead of throwing, so the caller decides whether an empty import is an error; and the defaulted trailing `CancellationToken` follows the codebase convention that every async boundary is cancelable. -- **Why it's built this way**: a narrow, single-purpose port is the smallest surface the import needs, which makes both the real HTTP adapter and its test double trivial to write and keeps retry/timeout policy an Infrastructure concern. -- **Where it's used**: constructor-injected into [RefreshFromSessionizeHandler](#refreshfromsessionizehandler) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeHandler.cs:21`) and called there as `GetAllAsync(@event.SessionizeCode, cancellationToken)` (`RefreshFromSessionizeHandler.cs:81`). Note that it is *not* registered by [DependencyInjection](#dependencyinjection) in this layer: the Application layer owns the interface, Infrastructure owns and registers the implementation. - -### CategoryItemNameRules -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.Validation` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:27` · Level 7 · class (sealed) +- **Why it's built this way**: a narrow, single-purpose port is the smallest surface the import needs, which makes both the real HTTP adapter and its test double trivial to write and keeps retry and timeout policy an Infrastructure concern. +- **Where it's used**: constructor-injected into [`RefreshFromSessionizeHandler`](#refreshfromsessionizehandler) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeHandler.cs:21`) and called there as `GetAllAsync(@event.SessionizeCode, cancellationToken)` (`RefreshFromSessionizeHandler.cs:81`). Note that it is *not* registered by this layer's [`DependencyInjection`](#dependencyinjection): the Application layer owns the interface, Infrastructure owns and registers the implementation. -- **What it is**: a reusable FluentValidation rule fragment enforcing that a category-item name is non-empty and no longer than the maximum the domain defines. -- **Depends on**: FluentValidation (`AbstractValidator`), [CategoryInvariants](group-17-conference-domain.md#categoryinvariants) (its `CategoryItemNameMaxLength` constant), `System.Linq.Expressions` and `System.Globalization` (BCL). -- **Concept**: the generic reusable validator rule taught on [CategoryItemSortRules](#categoryitemsortrulest). [Rubric §1, SOLID] and [Rubric §16, Maintainability]. -- **Walkthrough**: one expression-bodied constructor, `CategoryItemNameRules(Expression> selector)` (`ConferenceCategoryValidationRules.cs:30`), whose body is a single chained `RuleFor(selector)`: `.NotEmpty()` with message "You must enter a Category Item Name" and error code `CategoryItem.Name.Required` (line 32), then `.MaximumLength(CategoryInvariants.CategoryItemNameMaxLength)` with error code `CategoryItem.Name.MaxLength` (line 33). The bound is read from [CategoryInvariants](group-17-conference-domain.md#categoryinvariants) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:17`, currently `500`), which the domain factory, the EF configuration, and this validator all share, so the length rule cannot drift between layers. The message interpolates that same constant through `string.Create(CultureInfo.InvariantCulture, ...)` rather than plain interpolation, which is what keeps the analyzer-as-error build happy about culture-sensitive formatting. -- **Why it's built this way**: pulling the bound from the domain invariants instead of a literal here is the "one place to change a constraint" discipline; validating it at the Application boundary as well as in the domain factory means the caller gets a field-level validation failure (with an error code) instead of a generic domain error. Note the two do not duplicate the error code: the validator emits `CategoryItem.Name.MaxLength` while the domain path emits `CategoryItem.Name.TooLong` (`CategoryInvariants.cs:27`), so a failure tells you which layer rejected the value. -- **Where it's used**: `Include`d by [AddCategoryItemCommandValidator](#addcategoryitemcommandvalidator) (`.../AddCategoryItem/AddCategoryItemCommandValidator.cs:11`) and [UpdateCategoryItemCommandValidator](#updatecategoryitemcommandvalidator) (`.../UpdateCategoryItem/UpdateCategoryItemCommandValidator.cs:11`), each pointed at its own `Name` property. Siblings: [CategoryItemSortRules](#categoryitemsortrulest), [ConferenceCategoryTitleRules](#conferencecategorytitlerulest). +--- -### ConferenceCategoryTitleRules -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.Validation` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:13` · Level 7 · class (sealed) +### GetPublicEventSpeakerFilterHandler -- **What it is**: a reusable FluentValidation rule fragment enforcing that a conference-category title is non-empty and within the domain-defined maximum length. -- **Depends on**: FluentValidation (`AbstractValidator`), [CategoryInvariants](group-17-conference-domain.md#categoryinvariants) (its `TitleMaxLength` constant), `System.Linq.Expressions` and `System.Globalization` (BCL). -- **Concept**: the generic reusable validator rule taught on [CategoryItemSortRules](#categoryitemsortrulest). [Rubric §1, SOLID] and [Rubric §16, Maintainability]. -- **Walkthrough**: one expression-bodied constructor, `ConferenceCategoryTitleRules(Expression> selector)` (`ConferenceCategoryValidationRules.cs:16`), with a single chained `RuleFor(selector)`: `.NotEmpty()` with message "You must enter a Category Title" and error code `Category.Title.Required` (line 18), then `.MaximumLength(CategoryInvariants.TitleMaxLength)` with error code `Category.Title.MaxLength` (line 19). `TitleMaxLength` is `255` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:14`). Structurally identical to [CategoryItemNameRules](#categoryitemnamerulest), differing only in the target property, the constant, and the error-code prefix. -- **Where it's used**: `Include`d by [ConferenceCategoryCreateRequestValidator](#conferencecategorycreaterequestvalidator) (`.../Categories/UseCases/Create/ConferenceCategoryCreateRequestValidator.cs:10`) and [ConferenceCategoryUpdateRequestValidator](#conferencecategoryupdaterequestvalidator) (`.../Categories/UseCases/Update/ConferenceCategoryUpdateRequestValidator.cs:10`). Note that these two validate the inbound *request* records while the two item validators validate *commands*: the fragment is generic over `T`, so it does not care which. +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.GetPublicEventSpeakerFilter` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterHandler.cs:22` · Level 11 · class (sealed) -### DependencyInjection -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:35` · Level 11 · class (static, extension block) +- **What it is**: the handler for [`GetPublicEventSpeakerFilterQuery`](#getpubliceventspeakerfilterquery). It asks [`PublicConferenceVisibility`](#publicconferencevisibility) twice, once for the published event ids and once for the visible speaker ids, and returns an `EventSpeaker.EventId IN (...) AND EventSpeaker.SpeakerId IN (...)` specification. +- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:23`, injected only to hand on to the resolver), [`PublicConferenceVisibility`](#publicconferencevisibility), [`InlineSpecification`](group-03-querying-specifications.md#inlinespecificationtentity-tidentifiertype) and its base [`Specification`](group-03-querying-specifications.md#specificationtentity-tidentifiertype), [`EventSpeaker`](group-17-conference-domain.md#eventspeaker), and [`Result`](group-01-result-error-handling.md#result). It implements [`IQueryHandler`](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) to `Result>` (`:24`). +- **Concept introduced: the two-parent junction filter.** `[Rubric §11, Security]`. The other public filters in this family each derive from a single parent: [`GetPublicRoomFilterHandler`](#getpublicroomfilterhandler) follows the event, [`GetPublicSpeakerFilterHandler`](#getpublicspeakerfilterhandler) follows the speaker's eligible sessions. This one ANDs two independent legs, and the remarks explain why the second is not redundant (`:16-21`): the Sessionize import writes an `EventSpeaker` row for every speaker in the response, which you can read in [`SpeakerSyncStrategy`](#speakersyncstrategy) itself, where every synced speaker without an active link gets `context.Event.AddEventSpeaker(null, ss.Id)` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SpeakerSyncStrategy.cs:59`). An event-only filter would therefore republish the entire imported roster through the association endpoint, which is exactly what the public speaker list hides. +- **Concept: the duplicate scalar read, taken deliberately.** `[Rubric §12, Performance & Scalability]`. The inline comment (`:35-37`) records a cost decision rather than an oversight. The junction read carries no event context, so the speaker rule spans every published event; both resolver calls read the `Event` table, described there as bounded at single-digit rows; and the duplicate scalar read was judged cheaper than threading the already-resolved ids through the shared resolver's signature. The trade-off is in the open: one extra projection query per request in exchange for keeping [`PublicConferenceVisibility`](#publicconferencevisibility)'s API narrow. +- **Walkthrough**: + 1. Resolve the published event ids: `PublicConferenceVisibility.GetPublishedEventIdsAsync(unitOfWork, cancellationToken)` (`:31-33`). Inside the resolver that is one scalar projection of `Event.Id` filtered by `IsPublished`, read untracked (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:42-44`), then materialized once so the caller embeds a stable collection EF can translate to `IN` (`PublicConferenceVisibility.cs:46-47`). + 2. Resolve the visible speaker ids: `GetVisibleSpeakerIdsAsync(unitOfWork, cancellationToken: cancellationToken)` (`:38-40`), with the optional event scope left at its default and spelled out with a named argument so it reads as a decision rather than an omission. Inside, that is the BR-239 chain: the published events (`PublicConferenceVisibility.cs:109`), the optional narrowing to one scoped event (`:113-117`), an empty answer when the scope is empty (`:119-120`), the eligible sessions inside that scope (`:122-124`), then the [`SessionSpeaker`](group-17-conference-domain.md#sessionspeaker) join projected down to distinct speaker ids (`:126-133`). + 3. Wrap `es => eventIds.Contains(es.EventId) && speakerIds.Contains(es.SpeakerId)` in an [`InlineSpecification`](group-03-querying-specifications.md#inlinespecificationtentity-tidentifiertype) and return `Result.Success` (`:42-44`). There is no failure path: the handler cannot fail on its own terms. +- **Why it's built this way**: the summary states the shape (`:10-15`) and the remarks give the reason for the second leg (`:16-21`). Both legs are id lists turned into `Contains`, never navigation joins, so the criteria stays translatable on any provider ([ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html)), and deriving both from the shared resolver means the junction cannot drift away from the entities whose visibility it follows. +- **Where it's used**: `EventSpeakersController`'s `BuildPublicSpecificationAsync` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:66-75`) is the only consumer, and from there it reaches the unpaged list (`:90`), paged list (`:120`), lookup (`:148`), and by-id (`:178`) reads. Note that the class-level `[HasPermission(ConferencePermissions.EventsManage)]` (`:46`) is overridden per action by `[AllowAnonymous]` (`:78`, `:101`, `:142`, `:164`), which is exactly why the handler has to carry the visibility rules itself. +- **Testing**: `GetPublicEventSpeakerFilterHandlerTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterHandlerTests.cs:19`), six tests on the shared `HandlerTestBase`: the success shape (`:74`), a row whose event is published and whose speaker is visible (`:83`), a row on an unpublished event (`:94`), a row of a hidden speaker on a published event (`:105`), a world where no speaker is visible (`:118`), and one that captures the predicate the handler hands to the `Event` projection, compiles it, and asserts it accepts a published event and rejects an unpublished one (`:128-147`). One fixture detail is a property of the entity rather than of the test: `EventSpeaker.EventId` is get-only and written by EF (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventSpeaker.cs:23`), so a row built through the factory in memory carries the default id, and the fixture uses `default` as its row event id (`:21-22`). `[Rubric §14, Testability]`: the speaker leg is the rule most likely to be dropped as redundant, and `:105` is the test that would catch it. +- **Caveats / not-in-source**: the controller maps a failed `Result` to `null`, meaning **no** filter: `return result.IsSuccess ? result.Value : null;` (`EventSpeakersController.cs:74`), which would widen the read rather than narrow it. Nothing in this handler can produce that failure today, so the exposure is latent rather than live, but it is the opposite of the fail-closed default the rest of the visibility code takes, and the same shape appears on the room controller. Both id lists are also materialized into the predicate, so the two `IN` lists grow with the number of published events and of publicly visible speakers; nothing in this file bounds either. -- **What it is**: the Conference module's application-layer composition root: a static class exposing `AddModuleConferenceApplication(ApplicationSettings)`, which registers every application service this module needs into the DI container. -- **Depends on**: [ApplicationSettings](group-14-module-system-composition.md#applicationsettings); the Conference domain aggregates and children ([Event](group-17-conference-domain.md#event), [Session](group-17-conference-domain.md#session), [Speaker](group-17-conference-domain.md#speaker), [Category](group-17-conference-domain.md#category), [CategoryItem](group-17-conference-domain.md#categoryitem), [Question](group-17-conference-domain.md#question), [Sponsor](group-17-conference-domain.md#sponsor), [Room](group-17-conference-domain.md#room), [SessionSpeaker](group-17-conference-domain.md#sessionspeaker), [SpeakerCategoryItem](group-17-conference-domain.md#speakercategoryitem) and the rest); the framework generics [EntityQueryService](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype), [IEntityQueryService](group-03-querying-specifications.md#ientityqueryservicetentity-tentitydto-tidentifiertype), [INavigationPopulator](group-11-navigation-populators.md#inavigationpopulatorin-tentity), [NullNavigationPopulator](group-11-navigation-populators.md#nullnavigationpopulatortentity), [DeleteEntityCommand](group-05-cqrs-pipeline.md#deleteentitycommandtentity-tidentifiertype) and [DeleteEntityHandler](group-05-cqrs-pipeline.md#deleteentityhandlertentity-tidentifiertype); the cross-module ports [ISessionBookmarkValidationService](group-17-conference-domain.md#isessionbookmarkvalidationservice) and [IEventLiveValidationService](group-17-conference-domain.md#ieventlivevalidationservice); [ClassReference](#classreference); plus `Microsoft.Extensions.DependencyInjection` and its `Extensions` namespace (the `TryAdd*` helpers). -- **Concept introduced, the module composition root written as an `extension(IServiceCollection)` block.** [Rubric §5, Vertical Slice] assesses whether each module wires its own slice rather than a central registry knowing about every type; [Rubric §2, Design Patterns] assesses idiomatic registration. The registration method lives inside a C# `extension(IServiceCollection services)` block (`DependencyInjection.cs:37`) so callers write `services.AddModuleConferenceApplication(settings)`: the same `extension(T)` member style used for DI across the codebase, explained once in the [primer](00-primer.md#c-extensiont-types-read-this-once). The class comment (`DependencyInjection.cs:30-34`) names the deliberate split this file embodies: **explicit registrations for the generic per-entity services** (which cannot be discovered by convention, because the closed generic has to be spelled out) and **Scrutor assembly scanning for everything hand-written** (handlers, mappers, validators), so adding a use case needs no edit here. -- **Walkthrough** (in body order): - - `_ = applicationSettings` (`DependencyInjection.cs:41`): the settings object is part of the module registration contract but this module does not branch on it today; the discard plus the inline comment "Reserved for future use (e.g., profiler decorators)" is what keeps the unused-parameter analyzer quiet without dropping the parameter. - - **Domain service** (line 44): `IEventCascadeDeletionDomainService` -> [EventCascadeDeletionDomainService](group-17-conference-domain.md#eventcascadedeletiondomainservice) as a singleton. It is stateless, which is why singleton is safe. - - **Session scoring queue** (lines 46-51): [SessionScoringQueue](#sessionscoringqueue) is registered concretely (line 50) *and* behind [ISessionScoringQueue](#isessionscoringqueue), with the interface registration written as a factory that resolves the concrete singleton (`sp => sp.GetRequiredService()`, line 51) rather than as a second `TryAddSingleton()`. The comment above it (lines 46-49) states why: long-running AI scoring runs off the request path, the hosted drain in Infrastructure needs the reader side and the completion callback, and both registrations must resolve to the ONE instance, or producers would enqueue work into a queue nobody drains. This is the classic two-registrations-one-instance trap, and the factory form is the fix. - - **Aggregate roots with custom navigation populators** (lines 53-68): `Event`, `Session`, `Speaker`, and `Category` each get three scoped registrations, an `INavigationPopulator` (their bespoke populators, [EventNavigationPopulator](#eventnavigationpopulator), [SessionNavigationPopulator](#sessionnavigationpopulator), [SpeakerNavigationPopulator](#speakernavigationpopulator), [ConferenceCategoryNavigationPopulator](#conferencecategorynavigationpopulator)), an `IEntityQueryService`, and a delete-command handler. Three of those deviate from the generic default and the deviations are the interesting part: `Event` binds its delete to the bespoke [DeleteEventHandler](#deleteeventhandler) (line 56) because deleting an event has to cascade, `Session` binds its delete to a bespoke `DeleteSessionHandler` (line 60), and `Speaker` binds its query service to the bespoke [SpeakerEntityQueryService](#speakerentityqueryservice) (line 63); everything else uses the framework generics unchanged. - - **Aggregate roots with no child navigations** (lines 70-77): `Question` and `Sponsor` each get the same trio but with `NullNavigationPopulator`, the do-nothing populator that satisfies the contract when there is nothing to eager-load, plus the generic `DeleteEntityHandler`. - - **Child entities** (lines 79-102): `Room`, `CategoryItem`, `EventSpeaker`, `EventQuestionAnswer`, `SessionSpeaker`, `SessionCategoryItem`, `SessionQuestionAnswer`, and `SpeakerCategoryItem` each get a `NullNavigationPopulator` plus the base `EntityQueryService`, and deliberately no delete handler: children are removed through their aggregate root, never addressed directly by a delete command. - - **Cross-module ports** (lines 104-108): `ISessionBookmarkValidationService` -> [SessionBookmarkValidationService](#sessionbookmarkvalidationservice) and `IEventLiveValidationService` -> [EventLiveValidationService](#eventlivevalidationservice), the in-process interfaces the Engagement service consumes (over gRPC once the modules run as separate processes; the file's comments name Engagement and its live layer as the consumers). - - **Convention scan** (line 112): `services.ScanModuleApplicationServices()` sweeps this assembly for, per the comment on lines 110-111, domain event handlers, DTO/request mappers, command/query handlers, and validators. Then the method returns `services` (line 114) for fluent chaining. -- **Why it's built this way**: every registration uses `TryAdd*` rather than `Add*`, so a host (or a test) can register its own implementation first and this method will not clobber it or produce a duplicate-registration conflict. Splitting explicit generics from convention scanning keeps a file that wires roughly a dozen entities under 120 lines while still registering the module's dozens of hand-written handlers. Registering the cross-module validation services here as ordinary in-process interfaces is exactly what lets the same module code run co-located or split behind gRPC without a rewrite ([ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html), [ADR-008](https://ivanball.github.io/docs/adr/008-service-extraction-topology.html)); the per-entity `INavigationPopulator` registrations are the ADR-002 populator pattern ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html)) being bound one entity at a time. -- **Where it's used**: called by the Conference module's `IModule` registration during host startup; modules are discovered and registered in topological order by the `ModuleLoader` ([G14, Module System & Composition](group-14-module-system-composition.md)). -- **Caveats / not-in-source**: `applicationSettings` is accepted and immediately discarded; the "profiler decorators" the comment reserves it for do not exist in this layer today. `ISessionizeService` is absent from this file on purpose: its typed-client registration lives in Conference Infrastructure. +--- -### GetPublicEventSpeakerFilterQuery +### GetPublicRoomFilterHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.GetPublicEventSpeakerFilter` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterQuery.cs:10` · Level 0 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.GetPublicRoomFilter` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/GetPublicRoomFilter/GetPublicRoomFilterHandler.cs:16` · Level 11 · class (sealed) -- **What it is**: a parameterless **marker query** asking for the filter that limits [`EventSpeaker`](group-17-conference-domain.md#eventspeaker) junction rows to the ones a non-privileged caller may read. The whole type is one line: `public sealed record GetPublicEventSpeakerFilterQuery;` (`GetPublicEventSpeakerFilterQuery.cs:10`). -- **Depends on**: nothing first-party, nothing external. It is an empty record with no positional parameters. -- **Concept**: none new. The marker-query shape is taught under [`GetPublicSessionFilterQuery`](#getpublicsessionfilterquery), the junction dimension under [`GetPublicSessionSpeakerFilterQuery`](#getpublicsessionspeakerfilterquery), and the visibility rules themselves are defined once in [`PublicConferenceVisibility`](#publicconferencevisibility). What this query adds is a junction with **two** parents. The doc comment (`GetPublicEventSpeakerFilterQuery.cs:3-9`) states both legs and the leak each one closes: a row is readable only when its parent event is published (BR-108), because otherwise the join endpoints would list the speakers of an unannounced event and reveal that it exists, AND when its parent speaker is publicly visible (BR-239), because otherwise the association endpoint would hand back the whole Sessionize-imported roster that the speaker list itself hides. `[Rubric §11, Security]` assesses whether an anonymous surface can be used to infer the existence of content the caller may not read; a join row with two parents can leak through either of them. -- **Walkthrough**: no members. Every line of behavior lives in [`GetPublicEventSpeakerFilterHandler`](#getpubliceventspeakerfilterhandler). -- **Why it's built this way**: the rules belong to the two parents, not to the join row, so the query carries no arguments and the handler derives its answer from the shared resolver instead of restating either rule. -- **Where it's used**: handled by [`GetPublicEventSpeakerFilterHandler`](#getpubliceventspeakerfilterhandler); injected into [`EventSpeakersController`](group-20-conference-api-grpc.md#eventspeakerscontroller) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:50`) and constructed in its private `BuildPublicSpecificationAsync` helper (`EventSpeakersController.cs:70`), which returns `null` for privileged readers (`:67-68`) and the specification for everyone else. That helper feeds all four `[AllowAnonymous]` reads: the unpaged list (`:89`), the paged list (`:119`), the lookup (`:147`), and the by-id read (`:177`). +- **What it is**: the handler for [`GetPublicRoomFilterQuery`](#getpublicroomfilterquery). It asks [`PublicConferenceVisibility`](#publicconferencevisibility) for the published event ids and returns a `Room.EventId IN (...)` specification. It is the simplest member of the public-filter family: one resolver call, one predicate, no failure path. +- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:17`, injected only to hand on to the resolver), [`PublicConferenceVisibility`](#publicconferencevisibility), [`InlineSpecification`](group-03-querying-specifications.md#inlinespecificationtentity-tidentifiertype) and its base [`Specification`](group-03-querying-specifications.md#specificationtentity-tidentifiertype), [`Room`](group-17-conference-domain.md#room), and [`Result`](group-01-result-error-handling.md#result). It implements [`IQueryHandler`](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) to `Result>` (`:18`). +- **Concept**: the query-that-returns-a-specification shape is taught under [`GetPublicSessionFilterHandler`](#getpublicsessionfilterhandler): a handler whose *result* is a reusable predicate rather than data, so the visibility rule is resolved once in the Application layer and applied by whichever read the controller is serving. `[Rubric §6, CQRS & Event-Driven]` assesses whether reads are expressed as explicit, single-purpose query objects; this is a query whose payload is the filter itself. `[Rubric §11, Security]` assesses the anonymous read surface: the rule here is one line of predicate, and it is the only thing standing between an unpublished event's venue layout and an anonymous caller. +- **Walkthrough**: + 1. `HandleAsync` (`:21-23`) takes the marker query and a `CancellationToken`. + 2. Resolve the published event ids: `PublicConferenceVisibility.GetPublishedEventIdsAsync(unitOfWork, cancellationToken)` (`:25-27`). Inside the shared resolver that is a scalar, untracked projection of `Event.Id` where `IsPublished`, materialized once so the list embedded in the predicate is stable and EF-translatable to `IN` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:36-47`). + 3. Wrap `r => publishedEventIds.Contains(r.EventId)` in an [`InlineSpecification`](group-03-querying-specifications.md#inlinespecificationtentity-tidentifiertype) and return `Result.Success` (`:29-30`). Nothing here can fail, so `Result` is used for pipeline uniformity rather than to carry an error. +- **Why it's built this way**: the summary (`:10-15`) says the id-list shape mirrors the sponsor, speaker, and session public filters, so no navigation join is required and the criteria stays translatable on any engine ([ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html)). `Room` is the easy case for that rule because it carries a real `EventId` column (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Room.cs:37`), so the parent's visibility is expressible directly on the child's own column, with no traversal and no join table. Sourcing the id list from the shared [`PublicConferenceVisibility`](#publicconferencevisibility) rather than restating `IsPublished` here is what keeps one definition of "published" behind every public read. +- **Where it's used**: `RoomsController` injects it as an `IQueryHandler` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/RoomsController.cs:97`) and calls it from the private `BuildPublicRoomSpecificationAsync` helper (`:114-124`), which short-circuits to `null` when `IsPrivileged` (`:117`, backed by `currentUserService.IsPrivilegedConferenceReader()` at `:104`), so Organizer and ContentEditor readers see every room. The helper feeds the four `[AllowAnonymous]` reads (`:131`, `:159`, `:201`, `:228`): the unpaged list (`:142`), the paged list (`:177`), the lookup (`:207`), and the by-id read (`:241`). All four also sit behind `[OutputCache(PolicyName = "RoomsCache")]` (`:132`, `:160`, `:202`, `:229`), and the write actions evict that cache through `EvictRoomsCacheAsync` (`:328`). +- **Testing**: there is no per-handler unit-test class for this filter; it is covered from the controller side by `RoomsControllerTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.API.Tests/Controllers/RoomsControllerTests.cs:26`), which mocks the handler (`:32`) and asserts the paired behavior on all four reads: the specification is applied for an anonymous or Attendee caller and never resolved for an Organizer or ContentEditor one (`:199` and `:214` unpaged, `:229` and `:245` paged, `:261` and `:277` lookup, `:292` and `:312` by-id). The mock stands in a filter of its own, `r => r.EventId == 1` (`:329-333`), and `VerifyFilterNeverResolved` (`:335-338`) is what proves the privileged path never even calls the handler. `[Rubric §14, Testability]`: the branch worth protecting is the privileged short-circuit, because a regression there is silent (privileged callers would simply see less), and these are the tests that would catch it. +- **Caveats / not-in-source**: the controller maps a failed `Result` to `null`, that is, to no filter at all (`RoomsController.cs:123`), the same fail-open shape noted on [`GetPublicEventSpeakerFilterHandler`](#getpubliceventspeakerfilterhandler). No path in this handler produces a failure today. The published-event id list is also materialized into the predicate, so the `IN` list grows with the number of published events; [`PublicConferenceVisibility`](#publicconferencevisibility) describes that table as bounded at single-digit rows, but nothing in code enforces that bound. --- ### SessionizeSyncResult -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/ISessionizeSyncStrategy.cs:21` · Level 0 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/ISessionizeSyncStrategy.cs:21` · Level 0 · record (sealed) -- **What it is**: the small immutable value returned by every Sessionize sync strategy: a pair of counters reporting how many entities that strategy touched. It is co-located in the same file as [`ISessionizeSyncStrategy`](#isessionizesyncstrategy) because it is that interface's return type. -- **Depends on**: nothing first-party; it is a plain `sealed record` with two `int` `init` properties. -- **Walkthrough**: two members. `PrimarySynced` (`ISessionizeSyncStrategy.cs:24`) is the count of the strategy's main entity (categories, rooms, questions, speakers, or sessions). `SecondarySynced` (`ISessionizeSyncStrategy.cs:27`) is an optional count of a nested child entity synced in the same pass; today only [`CategorySyncStrategy`](#categorysyncstrategy) sets it, to report category items synced alongside categories (`CategorySyncStrategy.cs:59`). Both default to `0`, so a strategy with no secondary entity simply leaves it unset (see the four one-counter returns at `RoomSyncStrategy.cs:59`, `QuestionSyncStrategy.cs:93`, `SpeakerSyncStrategy.cs:72`, and `SessionSyncStrategy.cs:50`). -- **Why it's built this way**: returning a record rather than a bare `int` leaves room to grow the result (more counters, per-entity metadata) without breaking the five implementors. The two-field shape is deliberately generic so one type serves all five strategies. -- **Where it's used**: produced by each `SyncAsync` implementation and accumulated into a `List` by [`RefreshFromSessionizeHandler`](#refreshfromsessionizehandler) (`RefreshFromSessionizeHandler.cs:122-126`), which then reads the counters positionally to build its result DTO (`:143-153`). +- **What it is**: the two-number return value of one Sessionize sync step. It carries how many rows of the step's primary entity were accepted and, where a step also touches a child collection, how many of those were accepted. +- **Depends on**: nothing first-party, nothing external. Two `int` properties, both `init`-only. +- **Concept**: none new. It is a deliberately anaemic result record co-located with the contract that returns it, [ISessionizeSyncStrategy](#isessionizesyncstrategy) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/ISessionizeSyncStrategy.cs:7`), rather than living in its own file. `[Rubric §9, API and Contract Design]` assesses whether a contract can grow without breaking its implementors: returning a record instead of a bare `int` means a future step can report a third number by adding one `init` property, and the five existing strategies keep compiling untouched. +- **Walkthrough**: `PrimarySynced` (`ISessionizeSyncStrategy.cs:24`) is the headline count, for example speakers synced. `SecondarySynced` (`ISessionizeSyncStrategy.cs:27`) is the optional child count, for example the category items synced alongside their categories. Neither is `required`, so a strategy that has nothing secondary to report constructs `new SessionizeSyncResult { PrimarySynced = n }` and leaves the other at its default of zero, which is what four of the five strategies do (for example `RoomSyncStrategy.cs:79`). +- **Why it's built this way**: the counts are what the organizer sees after an import, so they must mean "the domain accepted this row", not "the feed listed this row". Every strategy increments only after the aggregate call succeeded, which is why the record is filled at the end of the loop rather than from the feed's own collection sizes. +- **Where it's used**: returned by all five strategies; collected into a `List` by [RefreshFromSessionizeHandler](#refreshfromsessionizehandler) (`RefreshFromSessionizeHandler.cs:122-126`) and projected into [RefreshFromSessionizeResultDTO](group-17-conference-domain.md#refreshfromsessionizeresultdto) (`RefreshFromSessionizeHandler.cs:143-153`). --- @@ -693,43 +940,40 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionizeSyncWarnings.cs:9` · Level 3 · class (internal static) -- **What it is**: a one-method helper the sync strategies share when they have to report an entity that the Sessionize feed listed but the domain refused to create. It turns a failed [`Result`](group-01-result-error-handling.md#result) into a short human-readable reason suitable for the warnings list on [`SessionizeSyncContext`](#sessionizesynccontext) (`SessionizeSyncWarnings.cs:5-8`). -- **Depends on**: [`Result`](group-01-result-error-handling.md#result) from `MMCA.Common.Shared.Abstractions` (`SessionizeSyncWarnings.cs:1`). Nothing external. -- **Concept introduced: the silently dropped import row, made visible.** Each strategy calls a domain factory that returns [`Result`](group-01-result-error-handling.md#result), and a failed create is skipped so the rest of the import can continue. Without a message, the organizer would see a synced count that quietly excludes the row and no way to tell why: the comment at `CategorySyncStrategy.cs:101-103` states exactly that reasoning. `[Rubric §13, Observability & Operability]` assesses whether an operator can tell what a run actually did; the warning string is the operator-facing half of a partial-success import. `[Rubric §16, Maintainability]` applies too: the "first error, else fallback" phrasing lives in one place instead of being re-typed at each of the three call sites. -- **Walkthrough**: a single `internal static string FirstErrorMessage(Result result)` (`SessionizeSyncWarnings.cs:17`). Its body is one expression using a C# **list pattern**: `result.Errors is [var first, ..] ? first.Message : "Unknown error"` (`:18`). The pattern matches when `Errors` has at least one element, binding the first and discarding the rest, so the method never indexes an empty collection and never needs a `Count` check. A failed result carrying no errors degrades to the literal `"Unknown error"` rather than throwing. -- **Why it's built this way**: `internal static` keeps it out of the module's public surface while still being reachable from every strategy in the namespace, and the expression body means the helper compiles to little more than the pattern test. The doc comment (`:12-14`) notes it mirrors the first-error idiom already used elsewhere in the module, so the warning text stays consistent across use cases. -- **Where it's used**: three strategies, each on the create-failure path: [`CategorySyncStrategy`](#categorysyncstrategy) (`CategorySyncStrategy.cs:104`), [`SpeakerSyncStrategy`](#speakersyncstrategy) (`SpeakerSyncStrategy.cs:131`), and [`SessionSyncStrategy`](#sessionsyncstrategy) (`SessionSyncStrategy.cs:107`). [`QuestionSyncStrategy`](#questionsyncstrategy) deliberately does not use it: it joins **all** error messages instead of just the first (`QuestionSyncStrategy.cs:80`). +- **What it is**: a one-method helper the sync strategies share when they have to explain, in one short sentence, why the domain refused a row that the Sessionize feed listed. +- **Depends on**: [Result](group-01-result-error-handling.md#result) (`MMCA.Common.Shared.Abstractions`, imported at `SessionizeSyncWarnings.cs:1`). Nothing external. +- **Concept introduced, the first-error idiom.** A failed [Result](group-01-result-error-handling.md#result) carries a collection of [Error](group-01-result-error-handling.md#error) values, but a warning line has room for one reason. `FirstErrorMessage` (`SessionizeSyncWarnings.cs:65-66`) uses a C# list pattern, `result.Errors is [var first, ..]`, to bind the head of the collection when one exists and fall back to the literal `"Unknown error"` when the failure carries none. `[Rubric §15, Best Practices and Code Quality]` assesses whether recurring micro-logic is expressed once: five strategies needed the same sentence, so the idiom lives in one `internal static` method instead of five near-copies. +- **Walkthrough**: one member. `internal static string FirstErrorMessage(Result result)` (`SessionizeSyncWarnings.cs:65`), expression-bodied, reading the head of the collection through a pattern rather than materializing a LINQ query. +- **Why it's built this way**: `internal` and `static` because this is an implementation detail of a single use case, not a service. There is nothing to inject and nothing to mock, so it is a static call rather than a dependency. +- **Where it's used**: [CategorySyncStrategy](#categorysyncstrategy) (`CategorySyncStrategy.cs:104`), [RoomSyncStrategy](#roomsyncstrategy) (`RoomSyncStrategy.cs:72`), [SessionSyncStrategy](#sessionsyncstrategy) (`SessionSyncStrategy.cs:107`) and [SpeakerSyncStrategy](#speakersyncstrategy) (`SpeakerSyncStrategy.cs:131`). +- **Caveats**: [QuestionSyncStrategy](#questionsyncstrategy) does not use it. Its create-failure warning joins every error message with `"; "` instead (`QuestionSyncStrategy.cs:80`), so the question path reports all reasons where the other four report the first one. --- ### RefreshFromSessionizeCommand -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeCommand.cs:13` · Level 8 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeCommand.cs:13` · Level 8 · record (sealed) -- **What it is**: the CQRS command that requests a full refresh of one event's data (categories, rooms, questions, speakers, sessions) from the external Sessionize API, use case UC-6. It carries a single positional field: the `EventId` to refresh (`RefreshFromSessionizeCommand.cs:13`). -- **Depends on**: [`Event`](group-17-conference-domain.md#event) (only for the `typeof(Event).FullName` cache-prefix expression), [`ConferenceFeatures`](group-17-conference-domain.md#conferencefeatures) (the feature-flag constant), the `EventIdentifierType` alias from `MMCA.ADC.Conference.Shared`, and three marker interfaces from `MMCA.Common.Application.UseCases`: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating), [`ITransactional`](group-05-cqrs-pipeline.md#itransactional), and [`IFeatureGated`](group-05-cqrs-pipeline.md#ifeaturegated). -- **Concept introduced: marker interfaces that opt a command into pipeline behavior.** The command itself has no logic; it is a request record whose *interfaces* tell the decorator pipeline how to treat it (the pipeline is taught in [Group 05](group-05-cqrs-pipeline.md)). Implementing three markers stacks three cross-cutting behaviors declaratively: - - [`IFeatureGated`](group-05-cqrs-pipeline.md#ifeaturegated) exposes `FeatureName => ConferenceFeatures.SessionizeIntegration` (`RefreshFromSessionizeCommand.cs:19`), which resolves to the string `"Conference.SessionizeIntegration"` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/ConferenceFeatures.cs:15`). The FeatureGate decorator short-circuits the command when that flag is off, a runtime kill switch for the whole Sessionize integration. `[Rubric §10, Cross-Cutting Concerns]` assesses whether concerns like feature flags are handled centrally rather than scattered; here the flag check is inherited from the pipeline, not coded in the handler. - - [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) exposes `CachePrefix => $"{typeof(Event).FullName}:"` (`RefreshFromSessionizeCommand.cs:16`); on success the Caching decorator evicts every cache entry under the `Event` prefix, so freshly imported data is not masked by a stale read cache. - - [`ITransactional`](group-05-cqrs-pipeline.md#itransactional) makes the Transactional decorator wrap the handler in one database transaction, so the five per-entity syncs commit atomically or roll back together. `[Rubric §6, CQRS & Event-Driven]` assesses whether mutations flow through well-defined command boundaries; this command is the boundary, and its markers are how it configures the pipeline around itself. -- **Walkthrough**: a one-parameter positional record declaration with its three base interfaces on one line (`RefreshFromSessionizeCommand.cs:13`), plus two expression-bodied `get` properties satisfying the marker contracts (`CachePrefix` at `:16`, `FeatureName` at `:19`). No constructor body, no validation: an event id is the only input the use case needs. -- **Why it's built this way**: keeping the behavior in interfaces, not in the command body, is what lets one small record participate in feature-gating, cache invalidation, and transactions without repeating that plumbing per use case (see [ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html) for the decorator ordering). -- **Where it's used**: handled by [`RefreshFromSessionizeHandler`](#refreshfromsessionizehandler); dispatched from `EventsController.RefreshAsync`, the `POST {id}/refresh` endpoint (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventsController.cs:337-344`), whose handler dependency is declared at `:51`. +- **What it is**: the command that asks for one event's data to be re-pulled from Sessionize (UC-6). It is a single-parameter record carrying the event id. +- **Depends on**: `EventIdentifierType` (the module's identifier alias, see the primer), [Event](group-17-conference-domain.md#event) (used only to build the cache prefix from its full type name), [ConferenceFeatures](group-17-conference-domain.md#conferencefeatures), and three pipeline markers from MMCA.Common: [ICacheInvalidating](group-05-cqrs-pipeline.md#icacheinvalidating), [ITransactional](group-05-cqrs-pipeline.md#itransactional) and [IFeatureGated](group-05-cqrs-pipeline.md#ifeaturegated). +- **Concept**: the marker-driven decorator pipeline is taught in [group 5](group-05-cqrs-pipeline.md). What is worth studying here is that one small record opts into three cross-cutting behaviors at once by implementing three interfaces (`RefreshFromSessionizeCommand.cs:13`). `[Rubric §10, Cross-Cutting Concerns]` assesses whether transactions, caching and feature gating are applied declaratively rather than hand-coded per handler: the handler below contains no transaction call, no cache eviction and no feature-flag check, because all three are decided by the markers on this type. `[Rubric §29, Resilience and Business Continuity]` assesses whether a risky dependency can be switched off without a deploy: `IFeatureGated` makes the whole Sessionize integration a runtime toggle. +- **Walkthrough**: the positional parameter `EventId` (`RefreshFromSessionizeCommand.cs:13`). `CachePrefix` returns `$"{typeof(Event).FullName}:"` (`RefreshFromSessionizeCommand.cs:17`), so a successful refresh evicts the whole Event cache region rather than a single key: the import touches events, rooms, sessions, speakers, categories and questions, so a narrower eviction would leave stale reads behind. `FeatureName` returns `ConferenceFeatures.SessionizeIntegration` (`RefreshFromSessionizeCommand.cs:19`), whose value is the string `"Conference.SessionizeIntegration"` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/ConferenceFeatures.cs:15`). `ITransactional` is what makes the five entity families commit or roll back together. +- **Why it's built this way**: all five sync steps write through one [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) and one save, so a half-applied import (rooms in, sessions out) is not reachable. Sessions reference rooms and speakers, so a partial commit would leave dangling references; the transactional marker is a correctness requirement here, not a convenience. +- **Where it's used**: constructed by `EventsController.RefreshAsync` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventsController.cs:372`) and passed to the injected [ICommandHandler](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) (`EventsController.cs:52`). --- ### SessionizeSyncContext -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionizeSyncContext.cs:11` · Level 8 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionizeSyncContext.cs:11` · Level 8 · record (sealed) -- **What it is**: the mutable "parameter object" passed to every sync strategy for one import run. It bundles the four things a strategy needs (the parsed API payload, the target event, the unit of work, and a shared warnings list) plus one running counter the strategies write back into. -- **Depends on**: [`SessionizeResponse`](#sessionizeresponse) (the parsed API payload, same group), [`Event`](group-17-conference-domain.md#event) (the aggregate being refreshed), and [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (repository access for strategies that load their own entities). -- **Concept introduced: a shared context object for a multi-step pipeline.** Rather than pass four or five arguments into every strategy method, the orchestrator builds one context and threads it through. Two of its members are deliberately *mutable* so the strategies can report side information back to the orchestrator without changing the `SyncAsync` return contract: - - `Warnings` (`SessionizeSyncContext.cs:16`) is a `required List` any strategy can append non-fatal problems to (a session outside the event date range, a question in the reserved id range, an entity the domain refused to create). The handler folds these into the result DTO (`RefreshFromSessionizeHandler.cs:152`). - - `SkippedSoftDeleted` (`SessionizeSyncContext.cs:19`) is a plain `int { get; set; }` that every strategy increments when it meets a soft-deleted local row matching an incoming Sessionize id, implementing BR-136: a soft-deleted entity is never resurrected by an import. -- **Walkthrough**: four `required init` members set once at construction: `Response` (`:13`), `Event` (`:14`), `UnitOfWork` (`:15`), `Warnings` (`:16`); then the single mutable counter `SkippedSoftDeleted` (`:19`). The `required` keyword forces the handler to supply all four when it constructs the context (`RefreshFromSessionizeHandler.cs:114-120`), so a strategy can never see a half-built context. Note the asymmetry that makes the type work: the four `init` members cannot be reassigned, but `Warnings` is a mutable `List` whose *contents* strategies append to, and `SkippedSoftDeleted` is the one genuinely settable property. -- **Why it's built this way**: a single context keeps the strategy signature stable ([`ISessionizeSyncStrategy.SyncAsync`](#isessionizesyncstrategy) takes exactly `(context, cancellationToken)`), and the two mutable channels give strategies a back-channel for warnings and skip counts without a richer return type. Because a context instance is created per command and the strategies run strictly in sequence (`RefreshFromSessionizeHandler.cs:123-126`), the mutability costs nothing in concurrency terms. -- **Where it's used**: created once per command in [`RefreshFromSessionizeHandler`](#refreshfromsessionizehandler) and passed to each of the five strategies in turn. +- **What it is**: the single parameter object every sync step receives: the parsed feed, the target event, the unit of work the step opens repositories from, and the two accumulators (warnings, skipped count) the steps write into as they go. +- **Depends on**: [SessionizeResponse](#sessionizeresponse), [Event](group-17-conference-domain.md#event), [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork), and `List` (BCL). +- **Concept introduced, the mutable run context behind an immutable-looking record.** Four members are `required ... { get; init; }` (`SessionizeSyncContext.cs:13-16`), so the identity of the run (which feed, which event, which unit of work, which warnings list) cannot be swapped by a step. The accumulators are still mutable, in two different ways: `Warnings` is `init`-only yet holds a `List` whose contents every step appends to, and `SkippedSoftDeleted` is a plain `{ get; set; }` counter (`SessionizeSyncContext.cs:19`) each step increments when it meets a soft-deleted row the feed still lists (BR-136). `[Rubric §1, SOLID]` assesses interface and parameter-shape discipline: bundling the run state into one type is what lets [ISessionizeSyncStrategy](#isessionizesyncstrategy) keep a two-parameter signature that never changes when one step needs an extra input. +- **Walkthrough**: `Response` (`:13`) is the deserialized feed. `Event` (`:14`) is the tracked aggregate the handler loaded with its `Rooms` and `EventSpeakers` navigations, which is why [RoomSyncStrategy](#roomsyncstrategy) and [SpeakerSyncStrategy](#speakersyncstrategy) can consult those collections without a query. `UnitOfWork` (`:15`) is the shared unit of work: every strategy resolves its repositories from it, which is what keeps all five steps inside one transaction and one change tracker. `Warnings` (`:16`) is the running list that ends up on the response DTO. `SkippedSoftDeleted` (`:19`) is the running count of soft-deleted rows skipped. +- **Why it's built this way**: shared mutable state is normally a smell. It is safe here for one reason visible in the orchestrator: the strategies run strictly sequentially in a `foreach` (`RefreshFromSessionizeHandler.cs:123-126`), never concurrently. That sequencing is also a hard dependency requirement (categories before speakers and sessions, rooms before sessions), so the context's design and the execution order reinforce each other. +- **Where it's used**: created once per command (`RefreshFromSessionizeHandler.cs:114-120`) and passed to each strategy's `SyncAsync`. +- **Caveats**: nothing in the type enforces the sequential assumption. `List` and the `int` counter are not thread-safe, so a change that ran the independent steps in parallel would need a concurrent collection and an interlocked counter. --- @@ -737,13 +981,13 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/ISessionizeSyncStrategy.cs:7` · Level 9 · interface -- **What it is**: the Strategy interface for synchronizing one entity family from a parsed Sessionize response into the domain. Each implementation owns exactly one entity type: categories, rooms, questions, speakers, or sessions (`ISessionizeSyncStrategy.cs:3-6`). -- **Depends on**: [`SessionizeSyncContext`](#sessionizesynccontext) (the input for one run) and [`SessionizeSyncResult`](#sessionizesyncresult) (the return, co-located in the same file at `:21`). -- **Concept introduced: the Strategy pattern for pluggable, ordered sync steps.** `[Rubric §2, Design Patterns]` assesses whether a pattern solves a real structural problem rather than decorating a simple one. Sessionize returns one payload covering five interdependent entity types; splitting the sync into one strategy per type keeps each `SyncAsync` small and single-purpose, and lets a new entity type arrive as a new strategy without touching the ones that exist (`[Rubric §1, SOLID]`, open for extension). `[Rubric §16, Maintainability]` applies too: a change to how rooms sync cannot break speaker sync because the code paths are physically separate files. -- **Walkthrough**: one method, `Task SyncAsync(SessionizeSyncContext context, CancellationToken cancellationToken)` (`ISessionizeSyncStrategy.cs:15`). The strategy reads what it needs from the context, upserts its entity family through domain aggregates, and returns a [`SessionizeSyncResult`](#sessionizesyncresult) with the counts it produced. Nothing on the interface saves changes: persistence is the orchestrator's job, once, at the end. -- **Why it's built this way**: a record result (not a bare `int`) leaves room to add metadata without breaking implementors, and a single context parameter keeps every strategy's signature identical so the orchestrator can loop over them uniformly. -- **Where it's used**: implemented by the five Level-10 strategy classes below; the implementations are held in a static array inside [`RefreshFromSessionizeHandler`](#refreshfromsessionizehandler) (`RefreshFromSessionizeHandler.cs:28-35`) and executed in dependency order. -- **Caveats / not-in-source**: the strategies are **not** injected via DI. The handler instantiates them directly in a `static readonly ISessionizeSyncStrategy[]` field (`RefreshFromSessionizeHandler.cs:28`), which is possible because they are stateless: all per-run state lives in [`SessionizeSyncContext`](#sessionizesynccontext). The trade-off is that a test cannot substitute a strategy; the extension point for testing is the [`ISessionizeService`](#isessionizeservice) the handler calls, not the strategy array. +- **What it is**: the one-method contract for "synchronize one entity family from the Sessionize feed into the domain". Five implementations exist, one per family: categories, rooms, questions, speakers, sessions. +- **Depends on**: [SessionizeSyncContext](#sessionizesynccontext) and [SessionizeSyncResult](#sessionizesyncresult) (the latter declared in the same file at `ISessionizeSyncStrategy.cs:21`). +- **Concept introduced, the Strategy pattern applied to an import pipeline.** `[Rubric §2, Design Patterns]` assesses whether a pattern solves a real structural problem instead of adding ceremony. One Sessionize payload covers five entity families with different upsert rules, different reserved-id guards and different child collections. Written as one method that would run to several hundred lines at a cyclomatic complexity the analyzers reject at error severity. Split behind this interface, each family's rules sit in their own file and the orchestrator holds only the order. `[Rubric §16, Maintainability]` assesses whether independent concerns are isolated so that a change to room handling cannot break speaker handling: the five files share nothing but this signature and the context type. `[Rubric §14, Testability]` assesses whether a unit can be exercised without its collaborators: each strategy is a stateless object with a single method taking a context, so a test instantiates one directly and asserts on the returned counts and the context's warnings (for example `MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sync/RoomSyncStrategyTests.cs:11`). +- **Walkthrough**: one member, `Task SyncAsync(SessionizeSyncContext context, CancellationToken cancellationToken)` (`ISessionizeSyncStrategy.cs:15`). There is no `Order` property and no entity-type discriminator: order is the orchestrator's business, declared once in its static array. +- **Why it's built this way**: passing a context rather than four parameters means a step that later needs another input costs one added property on the context, not a signature change rippling through five implementations. Returning a record rather than an `int` gives the same freedom on the way out. +- **Where it's used**: implemented by [CategorySyncStrategy](#categorysyncstrategy), [RoomSyncStrategy](#roomsyncstrategy), [QuestionSyncStrategy](#questionsyncstrategy), [SpeakerSyncStrategy](#speakersyncstrategy) and [SessionSyncStrategy](#sessionsyncstrategy); consumed only by [RefreshFromSessionizeHandler](#refreshfromsessionizehandler). +- **Caveats**: the implementations are not registered in DI. The handler holds five instances in a `static readonly ISessionizeSyncStrategy[]` it constructs itself (`RefreshFromSessionizeHandler.cs:28-35`), so "add a strategy" means editing that array, not adding a registration. --- @@ -751,19 +995,18 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/CategorySyncStrategy.cs:12` · Level 10 · class (internal sealed) -- **What it is**: the sync strategy for categories and their nested category items. It is the clearest of the five, so it also serves as the reference for the shared upsert shape the others reuse. -- **Depends on**: [`ISessionizeSyncStrategy`](#isessionizesyncstrategy) (the interface it implements), [`SessionizeSyncContext`](#sessionizesynccontext), [`SessionizeSyncResult`](#sessionizesyncresult), [`SessionizeCategory`](#sessionizecategory) and [`SessionizeCategoryItem`](#sessionizecategoryitem) (the payload shapes), [`Category`](group-17-conference-domain.md#category) (the aggregate it upserts through), [`SessionizeSyncWarnings`](#sessionizesyncwarnings), and [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) reached via the context. -- **Concept introduced: the shared four-phase upsert every strategy follows.** - 1. **Bulk pre-load.** Rather than N `GetByIdAsync` calls, the strategy opens its repository via `context.UnitOfWork.GetRepository()` (`CategorySyncStrategy.cs:16`) and calls `GetByIdsAsync` once with the full set of incoming Sessionize ids (`:21-27`). It passes `includes: [nameof(Category.CategoryItems)]` to eager-load children (`:24`), `asTracking: true` so updates are tracked (`:25`), and `ignoreQueryFilters: true` so soft-deleted rows are visible for the BR-136 check (`:26`). The results become a dictionary keyed by id (`:28`). This is a deliberate `[Rubric §12, Performance & Scalability]` choice: a full re-import of hundreds of entities would suffer badly from N+1 queries. - 2. **Iterate and discriminate.** For each incoming category (`:32-52`): if it matches a local row that `IsDeleted`, increment `context.SkippedSoftDeleted` and `continue` (`:36-40`, BR-136); if it matches an active row, call the aggregate's `Update(sc.Title, sc.Sort, sc.Type)` (`:42`); if there is no match at all, hand off to `CreateNewCategory` (`:50`). - 3. **Sync children.** `SyncCategoryItems` (`:62`) walks each incoming category's items, skipping soft-deleted ones (`:71-75`) and routing the rest to `existing.UpdateCategoryItem` (`:79`) or `existing.AddCategoryItem` (`:83`), returning the count it applied (`:89`). - 4. **Batch-add new entities.** New aggregates are collected in a local `List` (`:30`) and flushed with a single `categoryRepo.AddRangeAsync(newCategories, ...)` (`:56`), then a [`SessionizeSyncResult`](#sessionizesyncresult) carrying both counters is returned (`:59`). +- **What it is**: the first step of the import. It upserts [Category](group-17-conference-domain.md#category) rows and their [CategoryItem](group-17-conference-domain.md#categoryitem) children from the feed, and it runs first because speakers and sessions reference category items. +- **Depends on**: [SessionizeSyncContext](#sessionizesynccontext), [SessionizeCategory](#sessionizecategory), [SessionizeCategoryItem](#sessionizecategoryitem), [Category](group-17-conference-domain.md#category), [IRepository](group-07-persistence-ef-core.md#irepositorytentity-tidentifiertype) resolved from the context's unit of work, and [SessionizeSyncWarnings](#sessionizesyncwarnings). Externals: `System.Globalization` for invariant-culture id formatting. +- **Concept introduced, the four-phase upsert shape the other four strategies reuse.** Read it once here and the remaining strategies become variations on it. + 1. **Bulk pre-load.** The strategy resolves the repository once (`CategorySyncStrategy.cs:16`), projects the feed's ids (`:21`) and issues a single `GetByIdsAsync` with `includes: [nameof(Category.CategoryItems)]`, `asTracking: true` and `ignoreQueryFilters: true` (`:22-27`), then indexes the result by id (`:28`). One query replaces N `GetByIdAsync` calls. `[Rubric §12, Performance and Scalability]` assesses whether repeated data access is batched: a full ADC re-import walks hundreds of feed rows, so an id-at-a-time lookup would be an N+1 against the same table. + 2. **Discriminate.** For each feed row (`:32`): if a stored row exists and is soft-deleted, bump `context.SkippedSoftDeleted` and skip (`:36-40`, BR-136). If it exists and is live, call the aggregate's `Update` (`:42`). If it does not exist, go to the `Create` factory (`:99`). + 3. **Sync children.** `SyncCategoryItems` (`:62-90`) compares feed items against the loaded `CategoryItems` collection by id, skips soft-deleted ones (`:71-75`), and routes the rest to `UpdateCategoryItem` or `AddCategoryItem` on the parent aggregate (`:79-83`), never to a child repository. + 4. **Batch add and report.** New categories accumulate in a local list and are flushed with one `AddRangeAsync` (`:54-57`), then the counts are returned (`:59`). - `[Rubric §4, Domain-Driven Design]` applies throughout: every mutation goes through a `Category` factory or aggregate method, so the domain enforces its own invariants and the strategy never sets fields directly. -- **Walkthrough**: `SyncAsync` (`:14`) runs the four phases above. The private helper `CreateNewCategory` (`:92`) calls the `Category.Create(sc.Id, sc.Title, sc.Sort, sc.Type)` factory (`:99`); on failure it does **not** abort the import, it appends a warning naming the category, its Sessionize id, and the first domain error via [`SessionizeSyncWarnings.FirstErrorMessage`](#sessionizesyncwarnings) (`:104`), then returns zero items synced. On success it seeds the new category's items in the same pass (`:109-113`) before adding it to the pending list (`:115`). Note that the Sessionize id becomes the domain primary key: `Create` takes `sc.Id` directly, which is what makes the identity-insert step in the orchestrator necessary. -- **Why it's built this way**: partial success beats all-or-nothing for a feed the application does not control, and a warning is what turns "silently fewer rows" into something an organizer can act on. `[Rubric §15, Best Practices & Code Quality]` shows in the small things too: the warning interpolates the id through `ToString(CultureInfo.InvariantCulture)` (`:104`) rather than relying on ambient culture. -- **Where it's used**: first entry in the handler's `SyncStrategies` array (`RefreshFromSessionizeHandler.cs:30`); it runs first because speakers and sessions reference category items. -- **Caveats / not-in-source**: `CategorySyncStrategy` is the only strategy that reports a secondary count, and the counter semantics differ by branch: for an existing category the item count is what `SyncCategoryItems` applied (`:46`), while for a new category it is every item seeded (`:111-112`). Nothing in this file reconciles the two, so `CategoryItemsSynced` in the result DTO is a touched-count, not a changed-count. + `[Rubric §4, DDD]` assesses whether invariants stay inside aggregates: every mutation in this file is a call on `Category`, and category items are only ever reached through their parent, so the aggregate boundary holds even under a bulk import. +- **Walkthrough of the specifics**: `CreateNewCategory` (`:92-118`) is where the failure policy shows. `Category.Create` returns a [Result](group-01-result-error-handling.md#result); on failure the strategy appends a warning naming the title and id and quoting `SessionizeSyncWarnings.FirstErrorMessage` (`:104`), then returns without counting the row. On success it adds every feed item to the fresh aggregate (`:109-113`), queues the category (`:115`) and increments the count through a `ref int` parameter (`:116`). Note the asymmetry between the two paths: for an existing category items are reconciled against what is stored, while for a new one they are simply added, because there is nothing to reconcile against. +- **Why it's built this way**: a single bad row must not abort an import of hundreds, so the strategy degrades: warn, skip, keep going, and let the organizer read the warnings on the response. `ignoreQueryFilters: true` is required rather than optional here, because a feed id is the row's literal primary key: a soft-deleted category is invisible under the global filter, so without the flag the strategy would treat it as new and the insert would violate the primary key and roll the whole refresh back. +- **Where it's used**: instance zero of the handler's strategy array (`RefreshFromSessionizeHandler.cs:30`); its two counts become `CategoriesSynced` and `CategoryItemsSynced` on the response (`RefreshFromSessionizeHandler.cs:145-146`). Covered by `MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sync/CategorySyncStrategyTests.cs:15`. --- @@ -771,34 +1014,37 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/QuestionSyncStrategy.cs:12` · Level 10 · class (internal sealed) -- **What it is**: the sync strategy for questions. It follows the same shape as [`CategorySyncStrategy`](#categorysyncstrategy) (bulk pre-load at `QuestionSyncStrategy.cs:45-49`, discriminate at `:54`, batch-add at `:90`) and adds three question-specific concerns. -- **Depends on**: [`ISessionizeSyncStrategy`](#isessionizesyncstrategy), [`SessionizeSyncContext`](#sessionizesynccontext), [`SessionizeSyncResult`](#sessionizesyncresult), [`Question`](group-17-conference-domain.md#question) (upserted via `Create` / `Update`), and [`QuestionInvariants`](group-17-conference-domain.md#questioninvariants) (the reserved-id constants). Externally, only `System.Globalization` for invariant-culture warning text (`:1`). -- **Concept introduced: cross-source invariants enforced at the application layer.** Three of this strategy's rules cannot live inside the `Question` entity because they compare *external* Sessionize data against *internal* rules that the entity alone cannot see: - - **Reserved-id guard** (`:30-41`): an incoming id inside `[QuestionInvariants.ManualIdRangeStart, QuestionInvariants.ManualIdRangeEnd]` is reserved for manually created questions (`:33`), so a Sessionize question landing there would shadow a user-created one. It is dropped with a warning naming the range (`:35`) and the import continues. `[Rubric §11, Security]` and `[Rubric §16, Maintainability]` both touch this: a bad external row cannot overwrite user-owned data, and the rule lives in exactly one place. The filter runs **before** the bulk pre-load (the surviving ids are what `:44` feeds to the repository), so a reserved id never reaches a query. - - **Entity-type detection** (`:19-27` plus `DeriveQuestionEntity`, `:101-118`): the strategy pre-computes two hash sets of question ids, one from the answers attached to speakers (`:20-23`) and one from the answers attached to sessions (`:24-27`), then classifies each question from that evidence: a session answer wins first (`:107-109`), a speaker answer second (`:112-114`). The fallback is the part worth reading twice (`:117`): a feed carrying **no** answers for the question offers no classification signal at all, so the value already stored on the existing `Question` wins, and the literal `"Session"` default applies only to a genuinely new question. The doc comment (`:96-100`) states that rule directly. Without it, one answer-less feed would silently retag every stored Speaker question as a Session question. - - **Type mapping** (`MapSessionizeQuestionType`, `:123-129`): Sessionize's open type strings collapse to the three domain-valid values. `"Rating"` (`:126`) and `"Email"` (`:127`) pass through, and everything else (`Short_Text`, `Long_Text`, `Url`, `YesNo`, and so on) maps to `"Text"` (`:128`). The helper is `internal static` (`:123`), so it is unit-testable without constructing the strategy: `[Rubric §14, Testability]`. -- **Walkthrough**: `SyncAsync` (`:14`) opens the question repository (`:16`), builds the two answer-derived id sets (`:19-27`), filters the reserved range into `validQuestions` (`:30-41`), bulk-loads the survivors with `asTracking: true` and `ignoreQueryFilters: true` (`:44-49`), keys them by id (`:50`), and loops (`:54`). Per question it looks up any local row (`:56`), resolves the entity tag (`:58`) and the mapped type (`:59`), then branches: a soft-deleted local row is skipped and counted (`:63-67`, BR-136); an active one is updated via `Question.Update(...)` preserving the existing `IsRequired` (`:69`); no local row means `Question.Create(...)` tagged `questionSource: "Sessionize"` with `isRequired: false` (`:73`), and the new aggregate joins the pending list (`:76`). A failed create appends a warning joining **all** error messages and skips (`:80-81`); the counter only advances for a row that actually landed (`:85`). New questions are flushed in one `AddRangeAsync` (`:88-91`) and a single-counter result is returned (`:93`). -- **Why it's built this way**: preserving `existingQuestion.IsRequired` on update (`:69`) is the same instinct as the room strategy's preserved organizer fields, and the stored-entity fallback in `DeriveQuestionEntity` (`:117`) extends it to a field the feed only implies: the feed is authoritative for what it owns and silent about the rest, so a re-sync must not flatten local state on either. Tagging created rows with `questionSource: "Sessionize"` (`:73`) keeps the provenance queryable after the fact. -- **Where it's used**: third entry in the handler's `SyncStrategies` array (`RefreshFromSessionizeHandler.cs:32`); it runs after categories and before speakers and sessions, whose answers reference these questions. -- **Caveats / not-in-source**: this is the one strategy that does not route its create-failure text through [`SessionizeSyncWarnings`](#sessionizesyncwarnings); it joins every error with `"; "` instead of taking the first (`:80`). The difference is cosmetic in the warnings list, but it means the four strategies do not produce identically shaped messages. Note also that a question the feed drops entirely is left untouched: like the other strategies, this one only adds and updates. +- **What it is**: the step that upserts [Question](group-17-conference-domain.md#question) rows. It adds two concerns the category step does not have: deciding which entity a question belongs to, and refusing feed ids that would collide with organizer-created questions. +- **Depends on**: [SessionizeSyncContext](#sessionizesynccontext), [SessionizeQuestion](#sessionizequestion), [Question](group-17-conference-domain.md#question), [QuestionInvariants](group-17-conference-domain.md#questioninvariants), and the repository from the context's unit of work. +- **Concept**: the four-phase shape is taught under [CategorySyncStrategy](#categorysyncstrategy). What is new here is the **reserved identifier band**. `QuestionInvariants.ManualIdRangeStart` and `ManualIdRangeEnd` are `999_999_000` and `999_999_999` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:37` and `:40`). Organizer-created questions take ids from that band; Sessionize allocates from far below it. A feed id landing inside the band would silently overwrite a question a human wrote, so the strategy filters those rows out with a warning before anything reaches the database (`QuestionSyncStrategy.cs:30-41`). `[Rubric §8, Data Architecture]` assesses how identity is allocated when rows arrive from two sources into one table: the split-range convention is what lets imported and hand-created questions share a key space without a mapping table. +- **Walkthrough**: + - **Answer-derived classification** (`:20-27`): before touching the database, the strategy builds two hash sets, the question ids answered by speakers and the question ids answered by sessions, by flattening `QuestionAnswers` across the feed's speakers and sessions. + - **Reserved-id filter** (`:30-41`): the guard above, producing `validQuestions`. + - **Bulk pre-load** (`:44-50`): one `GetByIdsAsync` over the surviving ids with tracking on and query filters off, indexed by id. + - **Classification and mapping**: `DeriveQuestionEntity` (`:101-118`) returns `"Session"` when a session answered the question, `"Speaker"` when a speaker did, and otherwise falls back to the **stored** value before defaulting to `"Session"` (`:117`). That ordering matters: a feed that happens to carry no answers this run offers no classification signal, so an existing question keeps its recorded entity instead of being reclassified. `MapSessionizeQuestionType` (`:123-129`) collapses the feed's open type vocabulary onto the three domain-valid values, passing `"Rating"` and `"Email"` through and mapping everything else (`Short_Text`, `Long_Text`, `Url`, `YesNo`) to `"Text"`. + - **Update or create** (`:61-83`): a soft-deleted match is skipped and counted (`:63-67`, BR-136); a live match is updated with `existingQuestion.IsRequired` passed back in (`:69`), so the organizer's required flag survives a re-sync. A new question is created with `isRequired: false` and `questionSource: "Sessionize"` (`:73`), which is how imported questions stay distinguishable from hand-created ones; a failed create warns and skips (`:80-81`). + - **Batch add and report** (`:88-93`). +- **Why it's built this way**: classification cannot come from the feed's question record itself, only from which side of the payload answered it, which is why the two hash sets are computed up front rather than per row. Preserving `IsRequired` and the stored entity value on update is the same principle the room step applies to organizer-entered fields: the import owns the fields Sessionize sends and nothing else. +- **Where it's used**: instance two of the handler's array (`RefreshFromSessionizeHandler.cs:32`), reported as `QuestionsSynced` (`RefreshFromSessionizeHandler.cs:148`). `MapSessionizeQuestionType` is `internal static` so it can be exercised directly: the assembly grants `InternalsVisibleTo` to the application test project (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/MMCA.ADC.Conference.Application.csproj:3`), and the tests live at `MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sync/QuestionSyncStrategyTests.cs:16`. --- ### RoomSyncStrategy -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RoomSyncStrategy.cs:10` · Level 10 · class (internal sealed) +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RoomSyncStrategy.cs:20` · Level 10 · class (internal sealed) -- **What it is**: the smallest strategy. It syncs rooms, which are children of the [`Event`](group-17-conference-domain.md#event) aggregate rather than aggregate roots of their own, and it is the strategy that shows the restore-rather-than-skip half of the import policy. -- **Depends on**: [`ISessionizeSyncStrategy`](#isessionizesyncstrategy), [`SessionizeSyncContext`](#sessionizesynccontext), [`SessionizeSyncResult`](#sessionizesyncresult), [`Room`](group-17-conference-domain.md#room), and the [`Event`](group-17-conference-domain.md#event) aggregate reached through `context.Event`. -- **Concept introduced: reading a child entity through the read repository, mutating it through its aggregate.** The comment at `:14-16` states the rule directly: `Room` is a child of `Event`, not an aggregate root, so it is reachable only through `GetReadRepository()` (`:17`), the same accessor `AddRoomHandler` uses. The rows that accessor returns are still tracked, and every mutation still goes through an `Event` method. `[Rubric §4, Domain-Driven Design]` assesses whether aggregate boundaries are respected on the write path; this is the pattern that lets a query reach inside an aggregate without a write leaking around it. See also the [`IReadRepository`](group-07-persistence-ef-core.md#ireadrepositorytentity-tidentifiertype) accessor rules in Group 07. -- **Concept: soft-deleted rows must be resolved with the filters off, or the import breaks on a primary key.** The comment at `:20-22` explains why this is not optional here: a room carries its Sessionize id as its literal primary key, so a room the organizer removed and Sessionize still lists cannot simply be re-added; that would be a duplicate key. The pre-load therefore passes `ignoreQueryFilters: true` (`:27`) so the removed row is visible, and the strategy restores it in place. +- **What it is**: the step that upserts [Room](group-17-conference-domain.md#room) rows. Rooms are children of the [Event](group-17-conference-domain.md#event) aggregate, so every mutation goes through the event, and this step carries the most defensive id handling of the five. +- **Depends on**: [SessionizeSyncContext](#sessionizesynccontext), [SessionizeRoom](#sessionizeroom), [Event](group-17-conference-domain.md#event), [Room](group-17-conference-domain.md#room), [EventInvariants](group-17-conference-domain.md#eventinvariants), [Result](group-01-result-error-handling.md#result), [SessionizeSyncWarnings](#sessionizesyncwarnings), and [IReadRepository](group-07-persistence-ef-core.md#ireadrepositorytentity-tidentifiertype). +- **Concept introduced, reading a child entity without granting it a write repository.** `Room` is not an aggregate root, so the strategy resolves `GetReadRepository` (`RoomSyncStrategy.cs:27`) rather than a full repository: it may load and track the rows, but it cannot add or remove them directly. All writes route through `context.Event` (`:109-122`). `[Rubric §4, DDD]` assesses whether the aggregate root remains the only write entry point for its children, and that one line is the mechanical expression of the rule. `[Rubric §11, Security]` and `[Rubric §8, Data Architecture]` both bear on the id guards below: the strategy treats an external id as untrusted input that could point at another event's row or at an organizer-owned one. - **Walkthrough**: - 1. Open the read repository (`:17`) and bulk-load every incoming room id with tracking on and query filters off (`:23-28`), keyed into a dictionary (`:29`). - 2. For each incoming room (`:31`), resolve the local row from the already-loaded `context.Event.Rooms` collection first, falling back to the filters-off dictionary (`:33-34`). That two-step lookup matters: the handler loaded the event with its `Rooms` navigation under the normal query filters, so an active room is already present, while a removed one is only in the dictionary. - 3. Dispatch on what was found: no local row means `context.Event.AddRoom(sr.Id, sr.Name, sr.Sort)` (`:38`); a soft-deleted row means `context.Event.RestoreRoom(existingRoom, sr.Name, sr.Sort)` (`:42`); an active row means `context.Event.UpdateRoom(...)` (`:46-53`). - 4. Count every incoming room as synced (`:56`) and return a single-counter result (`:59`). -- **Why it's built this way**: the `UpdateRoom` call passes back the room's **existing** `Capacity`, `Floor`, `Location`, and `AccessibilityInfo` (`:50-53`) rather than anything from the feed, which is how the doc comment's promise that "organizer-entered fields survive a re-sync" (`:8`) is actually kept. Sessionize knows a room's name and sort order and nothing else, so the update deliberately re-supplies the locally owned fields unchanged. Rooms are the one entity family where a reappearing row is restored instead of skipped: unlike categories, questions, speakers, and sessions, a room has no independent lifecycle to protect, and its id collision would otherwise fail the whole transaction. -- **Where it's used**: second entry in the handler's `SyncStrategies` array (`RefreshFromSessionizeHandler.cs:31`); it runs after categories and before sessions, which reference rooms by `RoomId`. + - **Reserved-band filter** (`:87-102`): `ExcludeReservedIds` drops any feed room whose id falls between `EventInvariants.RoomManualIdRangeStart` and `RoomManualIdRangeEnd`, which are `999_999_000` and `999_999_999` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:62` and `:65`), warning for each. This mirrors the question-side guard exactly. + - **Unscoped id lookup** (`:43-49`): one `GetByIdsAsync` with `asTracking: true` and `ignoreQueryFilters: true`, and deliberately **not** filtered by `EventId`. The reasoning is written into the file (`:32-42`): Sessionize allocates room ids from a global sequence, and a room id carries straight through as the row's primary key, so an id can already belong to another event's room. Filtering by event would hide that row, the strategy would treat the id as new, and the insert would hit the primary key and roll back the entire refresh across all five families. Loading it unscoped and skipping it keeps the damage at the single offending room. + - **Resolution order** (`:53-54`): the aggregate's own `Rooms` collection is consulted first, and only if that misses does the strategy fall back to the unscoped dictionary. + - **Ownership guard** (`:59-63`): if the row came only from the unscoped lookup and its `EventId` is not this event's, warn and skip. The comment records why the check is conditional: anything already on the aggregate belongs to this event by construction, and a room added earlier in the same run still carries `EventId` 0 until EF assigns it on save. + - **Apply** (`:109-122`): a three-way switch expression. No stored room means `AddRoom`; a soft-deleted one means `RestoreRoom`; otherwise `UpdateRoom`, which reads `Capacity`, `Floor`, `Location` and `AccessibilityInfo` back off the stored room (`:118-121`) because Sessionize never sends those. That is how organizer-entered room detail survives a re-sync. + - **Count acceptances only** (`:70-76`): the aggregate can legitimately refuse a room (a name the feed repeats, a blank name). A refusal produces a warning and no increment, so the reported count and the warnings list always add up. +- **Why it's built this way**: the whole refresh is one transaction (`ITransactional` on [RefreshFromSessionizeCommand](#refreshfromsessionizecommand)), which makes any uncaught constraint violation an all-or-nothing loss. Each guard here converts a would-be transaction abort into a single skipped row plus a warning line. `[Rubric §29, Resilience and Business Continuity]` assesses whether a partial upstream defect degrades gracefully rather than taking the operation down. +- **Where it's used**: instance one of the handler's array (`RefreshFromSessionizeHandler.cs:31`), reported as `RoomsSynced` (`RefreshFromSessionizeHandler.cs:147`). Covered by `MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sync/RoomSyncStrategyTests.cs:11`. --- @@ -806,19 +1052,17 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionSyncStrategy.cs:14` · Level 10 · class (internal sealed) -- **What it is**: the richest child-syncing strategy. It upserts sessions and, for each one, reconciles three child collections: session speakers, session category items, and session question answers. -- **Depends on**: [`ISessionizeSyncStrategy`](#isessionizesyncstrategy), [`SessionizeSyncContext`](#sessionizesynccontext), [`SessionizeSyncResult`](#sessionizesyncresult), [`SessionizeSession`](#sessionizesession) and [`SessionizeQuestionAnswer`](#sessionizequestionanswer) (payload shapes), [`Session`](group-17-conference-domain.md#session) (upserted via `Create` / `Update` and its `Add*` / `Restore*` child methods), and [`SessionizeSyncWarnings`](#sessionizesyncwarnings). -- **Concept introduced: validate-then-upsert with non-fatal warnings.** Before touching a session, `ValidateSessionTimes` (`SessionSyncStrategy.cs:53`) records warnings, never errors, and the row is stored either way: - - BR-86, a session starting before the event's `StartDate` (`:56-59`) or ending after its `EndDate` (`:61-64`). Both comparisons project the event's `DateOnly` bounds through `ToDateTime(TimeOnly.MinValue)` and `ToDateTime(TimeOnly.MaxValue)` so a same-day session is never flagged. - - BR-122, zero or negative duration where `EndsAt <= StartsAt` (`:67-70`); the warning text itself says the row is "stored as-is per BR-122". -- **Concept introduced: reactivate the association, do not duplicate it (BR-135).** The comment at `:118-120` states the failure mode precisely: the session is loaded with query filters off, so its child collections carry the removed associations too, and blindly re-adding one would leave the removed row behind and double the association. Each of the three child reconciliations therefore looks for a soft-deleted match first: - - Session speakers (`:123-137`): for every incoming speaker with no active link, restore a soft-deleted link if one exists (`RestoreSessionSpeaker`, `:131`) or add a fresh one (`AddSessionSpeaker`, `:135`). - - Session category items (`:140-154`): the identical shape via `RestoreSessionCategoryItem` (`:148`) and `AddSessionCategoryItem` (`:152`). - - Session question answers (`SyncSessionQuestionAnswers`, `:160`): here the update path is the interesting one; an existing non-deleted answer has its value overwritten (`UpdateSessionQuestionAnswer`, `:168`) rather than being replaced, and only a genuinely new question gets `AddSessionQuestionAnswer` (`:172`). -- **Walkthrough**: `SyncAsync` (`:16`) opens the session repository (`:18`) and bulk-loads existing sessions with all three child navigations included and filters off (`:23-28`). Per incoming session it validates times (`:35`), resolves through `ResolveOrCreateSession` (`:37`), counts the row (`:41`), and reconciles children (`:42`). `ResolveOrCreateSession` (`:73`) applies the now-familiar three-way branch: soft-deleted local row means skip and count (`:81-85`, BR-136); an active row means `Session.Update(...)` (`:87-93`); no row means `Session.Create(...)` (`:97-102`), and a failed create appends a warning via [`SessionizeSyncWarnings`](#sessionizesyncwarnings) and skips (`:107-108`). New sessions are flushed in one `AddRangeAsync` (`:47`). -- **Why it's built this way**: treating out-of-range times as warnings rather than rejections keeps the import resilient to imperfect upstream data while still telling the organizer what looked wrong. Note the same preserve-local-edits instinct as the rooms strategy: `Update` passes back the session's existing `AccessibilityInfo` and `ResourceLinks` (`:93`), and `Create` seeds both as `null` (`:101`), because Sessionize does not own those fields. `[Rubric §4, Domain-Driven Design]`: all mutation, including every child add and restore, flows through `Session` aggregate methods. -- **Where it's used**: final entry in the handler's `SyncStrategies` array (`RefreshFromSessionizeHandler.cs:34`); it runs last because sessions reference rooms, speakers, categories, and questions synced by the earlier strategies. -- **Caveats / not-in-source**: the reconciliation is additive only. An association Sessionize **stopped** listing is left in place; nothing in this file removes a session speaker or category item that disappeared from the feed. Warnings are also emitted per validation, not per session, so one badly shaped session can contribute up to three entries to the shared list. +- **What it is**: the last step of the import. It upserts [Session](group-17-conference-domain.md#session) rows and their three join collections (speakers, category items, question answers), and it runs last because a session references rooms, speakers and category items the earlier steps created. +- **Depends on**: [SessionizeSyncContext](#sessionizesynccontext), [SessionizeSession](#sessionizesession), [SessionizeQuestionAnswer](#sessionizequestionanswer), [Session](group-17-conference-domain.md#session), [SessionizeSyncWarnings](#sessionizesyncwarnings), and the repository from the context's unit of work. +- **Concept introduced, reactivate rather than re-add (BR-135).** The session and its collections are loaded with `ignoreQueryFilters: true` (`SessionSyncStrategy.cs:23-28`), so the loaded `SessionSpeakers`, `SessionCategoryItems` and `SessionQuestionAnswers` collections carry the removed associations too. `SyncSessionChildren` (`:116-158`) uses that: for a feed association with no live match it first looks for a soft-deleted row with the same key and calls `RestoreSessionSpeaker` (`:131`) or `RestoreSessionCategoryItem` (`:148`), and only adds a new row when none exists. Adding instead would leave the removed row in place and double the association. `[Rubric §8, Data Architecture]` assesses whether soft-delete is handled consistently on the write path as well as the read path: here the filters are turned off precisely so the write path can see and revive what the read path hides. +- **Walkthrough**: + - **Bulk pre-load** (`:23-29`) with all three child collections included. + - **Advisory time validation** (`:53-71`): `ValidateSessionTimes` warns when a session starts before the event's `StartDate` or ends after its `EndDate` (BR-86, `:56-64`) and when `EndsAt` is at or before `StartsAt` (BR-122, `:67-70`). None of these reject the session: the row is imported as-is and the organizer decides. `[Rubric §24, Forms, Validation and UX Safety]` assesses whether a system distinguishes a hard invariant from an advisory: schedule anomalies in a live conference feed are usually real and in flight, so blocking the import would be worse than reporting it. + - **Resolve or create** (`:73-114`): a soft-deleted match is skipped and counted (`:81-85`, BR-136). A live match is updated with the feed's fields, while `AccessibilityInfo` and `ResourceLinks` are read back off the stored session (`:93`) because Sessionize does not send them. A miss goes to `Session.Create` (`:97-102`), which receives `null` for those same two fields and the event id from the context; a failed create warns and returns null (`:103-109`). + - **Children** (`:116-175`): speakers and category items follow the restore-or-add shape above; `SyncSessionQuestionAnswers` (`:160-175`) updates the live answer for a question id if one exists and adds one otherwise, keyed on `QuestionId` with an `IsDeleted` guard (`:164-165`). + - **Batch add and report** (`:45-50`). +- **Why it's built this way**: an import that runs repeatedly against a moving feed must be idempotent in the practical sense, that re-running it does not multiply rows. Keying every child comparison on the domain id plus an `IsDeleted` check, and preferring restore over insert, is what delivers that. +- **Where it's used**: instance four of the handler's array (`RefreshFromSessionizeHandler.cs:34`), reported as `SessionsSynced` (`RefreshFromSessionizeHandler.cs:150`). Covered by `MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sync/SessionSyncStrategyTests.cs:15`. --- @@ -826,546 +1070,519 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SpeakerSyncStrategy.cs:14` · Level 10 · class (internal sealed) -- **What it is**: the sync strategy for speakers. It follows the shape [`CategorySyncStrategy`](#categorysyncstrategy) establishes and adds three things of its own: social-link parsing, the event-to-speaker link, and an extra query to reach soft-deleted links the aggregate cannot see. -- **Depends on**: [`ISessionizeSyncStrategy`](#isessionizesyncstrategy), [`SessionizeSyncContext`](#sessionizesynccontext), [`SessionizeSyncResult`](#sessionizesyncresult), [`SessionizeSpeaker`](#sessionizespeaker) / [`SessionizeLink`](#sessionizelink) / [`SessionizeQuestionAnswer`](#sessionizequestionanswer) (payload shapes), [`Speaker`](group-17-conference-domain.md#speaker) and its [`SpeakerCategoryItem`](group-17-conference-domain.md#speakercategoryitem) children, [`Event`](group-17-conference-domain.md#event) and [`EventSpeaker`](group-17-conference-domain.md#eventspeaker), and [`SessionizeSyncWarnings`](#sessionizesyncwarnings). -- **Concept introduced: the association a loaded aggregate cannot show you.** `LoadDeletedEventSpeakersAsync` (`:81`) exists because of a specific interaction: `EventSpeaker` is a child of `Event`, and the orchestrator loaded the `Event` with query filters **on** (`RefreshFromSessionizeHandler.cs:43-48`), so a removed link is simply absent from `context.Event.EventSpeakers`. The strategy therefore reads those links back explicitly through `GetReadRepository()` with `ignoreQueryFilters: true` and a `where` narrowing to this event's deleted rows (`:85-92`). The doc comment at `:75-79` spells out that reasoning. A speaker that was added and removed repeatedly can carry more than one removed link, so the result is grouped and only the first per speaker is kept (`:96-98`, comment at `:94-95`); the rest stay deleted. -- **Concept introduced: parsing untyped external links into typed fields.** `ExtractSocialLinks` (`:183`) iterates the speaker's Sessionize `Links` and dispatches on `LinkType`, all comparisons `OrdinalIgnoreCase`: `"Twitter"` routes through `ExtractTwitterHandle` (`:197-199`), `"LinkedIn"` fills the LinkedIn url (`:201`), `"Blog"` or `"Company_Website"` fill the website url (`:205-206`), and anything whose url merely *contains* `"github"` is treated as the GitHub link (`:210`). `ExtractTwitterHandle` (`:217`) strips the six known `twitter.com` / `x.com` prefixes, trims a leading `@` and trailing slashes (`:228-236`), and returns `null` for an empty result (`:239`). The `#pragma warning disable S5332` around the prefix list (`:227-237`) carries its own justification (`:224-226`): those `http://` literals are input patterns being **removed**, not addresses this code connects to, and dropping them would leave legacy Sessionize handles unparsed. `[Rubric §15, Best Practices & Code Quality]` assesses whether an analyzer suppression is narrow and explained; this one is both. -- **Walkthrough**: `SyncAsync` (`:16`) opens the speaker repository (`:18`), bulk-loads existing speakers with their category items and question answers included and filters off (`:23-28`), and pre-resolves the deleted event links (`:31`). Per incoming speaker it extracts the social links (`:37`), then `CreateOrUpdateSpeaker` (`:101`) applies the three-way branch: soft-deleted local row means skip and count (`:113-117`, BR-136); an active row means `Speaker.Update(...)` preserving the existing `Email` (`:119-123`); no row means `Speaker.Create(...)` (`:126`) followed immediately by an `Update` to set the social links, because `Create` does not accept them (`:136-140`). A failed create warns via [`SessionizeSyncWarnings`](#sessionizesyncwarnings) and returns `null` so the caller skips the speaker (`:131-132`). After upsert the strategy ensures the event link (BR-135): if no active link exists (`:48`), it restores a soft-deleted one (`context.Event.RestoreEventSpeaker`, `:55`) or adds a new one (`context.Event.AddEventSpeaker(null, ss.Id)`, `:59`). Finally it reconciles category items (`:145`, restore at `:157` / add at `:161`) and question answers (`:166`, update at `:174` / add at `:178`), and flushes new speakers with one `AddRangeAsync` (`:69`). -- **Why it's built this way**: preserving `existingSpeaker.Email?.Value` on update (`:120`) and passing `null` for email on create (`:126`) keeps the feed away from the field that BR-207 speaker auto-linking depends on; Sessionize does not publish speaker emails, so the import must never blank a locally held one. Two independent restore paths (the event link and the category items) exist because the two collections are reached differently: the category items come back on the filters-off speaker read and are visible in memory (comment at `:147-148`), while the event links needed the extra query above. -- **Where it's used**: fourth entry in the handler's `SyncStrategies` array (`RefreshFromSessionizeHandler.cs:33`); it runs after categories and questions (which speakers reference) and before sessions (which reference speakers). Its `AddEventSpeaker` behavior is also the reason [`GetPublicEventSpeakerFilterHandler`](#getpubliceventspeakerfilterhandler) needs a second visibility leg. -- **Caveats / not-in-source**: `ExtractSocialLinks` has no `else` for an unrecognized `LinkType`, so a link type Sessionize adds later is silently dropped rather than warned about, and the `"github"` substring test (`:210`) will claim any url mentioning github that was not already matched by an earlier branch. Question answers are matched on `QuestionId` only (`:171`), so a speaker with two answers to the same question would have the first repeatedly overwritten. +- **What it is**: the step that upserts [Speaker](group-17-conference-domain.md#speaker) rows, links each speaker to the event through [EventSpeaker](group-17-conference-domain.md#eventspeaker), and syncs each speaker's category items and question answers. It also turns the feed's loose link list into typed social fields. +- **Depends on**: [SessionizeSyncContext](#sessionizesynccontext), [SessionizeSpeaker](#sessionizespeaker), [SessionizeLink](#sessionizelink), [SessionizeQuestionAnswer](#sessionizequestionanswer), [Speaker](group-17-conference-domain.md#speaker), [Event](group-17-conference-domain.md#event), [EventSpeaker](group-17-conference-domain.md#eventspeaker), [SessionizeSyncWarnings](#sessionizesyncwarnings), plus both [IRepository](group-07-persistence-ef-core.md#irepositorytentity-tidentifiertype) and [IReadRepository](group-07-persistence-ef-core.md#ireadrepositorytentity-tidentifiertype). +- **Concept introduced, reviving an association the aggregate cannot see.** The event was loaded by the handler with the global query filters **on**, so a removed `EventSpeaker` link is simply absent from `context.Event.EventSpeakers`. Re-adding it would create a second row alongside the removed one. `LoadDeletedEventSpeakersAsync` (`:81-99`) closes that hole: it opens a read repository for `EventSpeaker`, queries this event's deleted links with `ignoreQueryFilters: true` and `asTracking: true` (`:87-92`), and groups them by speaker id, taking the first of each group because a speaker added and removed repeatedly can carry more than one removed link (`:96-98`). The link decision then reads: skip if a live link exists, restore a deleted one if either the aggregate or that dictionary has it, otherwise add (`:48-61`, BR-135). `[Rubric §8, Data Architecture]` assesses whether the soft-delete convention is applied coherently across an aggregate boundary, which is exactly the trap this method sidesteps. +- **Walkthrough**: + - **Bulk pre-load** (`:23-29`) with `SpeakerCategoryItems` and `SpeakerQuestionAnswers` included, filters off, tracking on. + - **Social-link extraction** (`:183-215`): `ExtractSocialLinks` walks the feed's links and matches `LinkType` case-insensitively, sending `"Twitter"` through `ExtractTwitterHandle`, `"LinkedIn"` to the LinkedIn field, `"Blog"` and `"Company_Website"` to the website field, and falling back to a URL-content check for `"github"` (`:210`). `ExtractTwitterHandle` (`:217-240`) strips six known twitter.com and x.com prefixes, trims a leading `@` and surrounding slashes, and returns null for an empty result. The `#pragma warning disable S5332` around the replacement chain (`:227-237`) is deliberate and documented in place: the plain-http literals are input patterns being removed, not addresses this code connects to, and dropping them would leave legacy Sessionize profile links unparsed. + - **Create or update** (`:101-143`): a soft-deleted speaker is skipped and counted (`:113-117`). A live one is updated with `existingSpeaker.Email?.Value` passed back in (`:120`) so the stored email survives, since the feed does not carry it. A new speaker goes through `Speaker.Create` with a null email (`:126`) and is then immediately updated (`:137-140`) because the factory does not accept the social fields; a failed create warns and returns null (`:127-133`). + - **Children** (`:145-181`): `SyncCategoryItems` uses the restore-or-add shape; `SyncQuestionAnswers` updates a live answer by its id or adds a new one. + - **Batch add and report** (`:67-72`). +- **Why it's built this way**: the module treats Sessionize as the owner of the fields Sessionize sends and the organizer as the owner of everything else, so every update call in this file threads the locally held values (email here, room detail in the room step, `IsRequired` in the question step) back through the aggregate rather than blanking them. `[Rubric §30, Compliance, Privacy and Data Governance]` assesses whether personal data is written only from the source entitled to set it: the speaker email is never overwritten by an import. +- **Where it's used**: instance three of the handler's array (`RefreshFromSessionizeHandler.cs:33`), reported as `SpeakersSynced` (`RefreshFromSessionizeHandler.cs:149`). Covered by `MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sync/SpeakerSyncStrategyTests.cs:12`. --- -### GetPublicEventSpeakerFilterHandler +### RefreshFromSessionizeHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.GetPublicEventSpeakerFilter` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterHandler.cs:22` · Level 11 · class (sealed) +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeHandler.cs:19` · Level 14 · class (sealed partial) -- **What it is**: the handler for [`GetPublicEventSpeakerFilterQuery`](#getpubliceventspeakerfilterquery). It asks [`PublicConferenceVisibility`](#publicconferencevisibility) twice, once for the published event ids and once for the visible speaker ids, and returns an `EventSpeaker.EventId IN (...) AND EventSpeaker.SpeakerId IN (...)` specification. -- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:23`, injected only to hand on to the resolver), [`PublicConferenceVisibility`](#publicconferencevisibility), [`InlineSpecification`](group-03-querying-specifications.md#inlinespecificationtentity-tidentifiertype) and its base [`Specification`](group-03-querying-specifications.md#specificationtentity-tidentifiertype), [`EventSpeaker`](group-17-conference-domain.md#eventspeaker), and [`Result`](group-01-result-error-handling.md#result). It implements [`IQueryHandler`](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) to `Result>` (`:24`). -- **Concept introduced: the two-parent junction filter.** `[Rubric §11, Security]`. The other junction filters in this family each derive from a single parent: [`GetPublicSessionSpeakerFilterHandler`](#getpublicsessionspeakerfilterhandler) follows the session, [`GetPublicSpeakerCategoryItemFilterHandler`](#getpublicspeakercategoryitemfilterhandler) follows the speaker. This one ANDs two independent legs, and the remarks explain why the second is not redundant (`:16-21`): the Sessionize import writes an `EventSpeaker` row for every speaker in the response, which you can read in [`SpeakerSyncStrategy`](#speakersyncstrategy) itself, where every synced speaker without an active link gets `context.Event.AddEventSpeaker(null, ss.Id)` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SpeakerSyncStrategy.cs:59`). An event-only filter would therefore republish the entire imported roster through the association endpoint, which is exactly what the public speaker list hides. -- **Concept: the duplicate scalar read, taken deliberately.** `[Rubric §12, Performance & Scalability]`. The inline comment (`:35-37`) records a cost decision rather than an oversight. The junction read carries no event context, so the speaker rule spans every published event; both resolver calls read the `Event` table, described there as bounded at single-digit rows; and the duplicate scalar read was judged cheaper than threading the already-resolved ids through the shared resolver's signature. The trade-off is in the open: one extra projection query per request in exchange for keeping [`PublicConferenceVisibility`](#publicconferencevisibility)'s API narrow. +- **What it is**: the command handler behind UC-6. It checks the preconditions, calls the Sessionize API, runs the five strategies in dependency order against one shared context, stamps the refresh on the event, saves everything in one transaction, and returns the per-entity counts and warnings. +- **Depends on**: [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork), [ISessionizeService](#isessionizeservice), [ICurrentUserService](group-08-auth.md#icurrentuserservice), `TimeProvider` (BCL), `ILogger` (Microsoft.Extensions.Logging), [Event](group-17-conference-domain.md#event), [Result](group-01-result-error-handling.md#result) and [Error](group-01-result-error-handling.md#error), [RefreshFromSessionizeCommand](#refreshfromsessionizecommand), [RefreshFromSessionizeResultDTO](group-17-conference-domain.md#refreshfromsessionizeresultdto), [SessionizeResponse](#sessionizeresponse), [SessionizeSyncContext](#sessionizesynccontext), [SessionizeSyncResult](#sessionizesyncresult), [ISessionizeSyncStrategy](#isessionizesyncstrategy) and its five implementations. Externals: `Polly.CircuitBreaker` and `Polly.Timeout` (for the two rejection types it catches) and `System.Text.Json`. +- **Concept introduced, classifying an upstream failure instead of letting it become a 500.** `IsSessionizeUnavailable` (`RefreshFromSessionizeHandler.cs:166-171`) treats five exception types as "no usable Sessionize data right now": `HttpRequestException`, Polly's `TimeoutRejectedException` and `BrokenCircuitException`, and `JsonException` or `NotSupportedException`. The last two matter because an upstream that serves an HTML error page with a success status makes the JSON read fail on content rather than on transport. The Polly types appear because the client runs behind the standard resilience pipeline from `AddServiceDefaults`, so an unreachable API often reaches the handler as a pipeline rejection rather than a socket error. Critically, the catch block re-checks cancellation first (`:86`): a broadened catch must not convert a caller's cancellation into an "upstream is down" answer. `[Rubric §13, Observability and Operability]` assesses whether operators can tell a dependency outage from a defect: this classification is what lets the controller answer 502 instead of 500. `[Rubric §29, Resilience and Business Continuity]` assesses graceful degradation against a third-party dependency. - **Walkthrough**: - 1. Resolve the published event ids: `PublicConferenceVisibility.GetPublishedEventIdsAsync(unitOfWork, cancellationToken)` (`:31-33`). Inside the resolver that is one scalar projection of `Event.Id` filtered by `IsPublished` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:40-43`), materialized once so the caller embeds a stable collection EF can translate to `IN` (`:45-46`). - 2. Resolve the visible speaker ids: `GetVisibleSpeakerIdsAsync(unitOfWork, cancellationToken: cancellationToken)` (`:38-40`), with the optional event scope left at its default and spelled out with a named argument so it reads as a decision rather than an omission. Inside, that is the BR-239 chain: the published events (`PublicConferenceVisibility.cs:104`), an empty answer when the scope is empty (`:114-115`), the eligible sessions inside that scope (`:117-119`), then the [`SessionSpeaker`](group-17-conference-domain.md#sessionspeaker) join projected down to distinct speaker ids (`:121-126`). - 3. Wrap `es => eventIds.Contains(es.EventId) && speakerIds.Contains(es.SpeakerId)` in an [`InlineSpecification`](group-03-querying-specifications.md#inlinespecificationtentity-tidentifiertype) and return `Result.Success` (`:42-44`). There is no failure path: the handler cannot fail on its own terms. -- **Why it's built this way**: the summary states the shape (`:10-15`) and the remarks give the reason for the second leg (`:16-21`). Both legs are id lists turned into `Contains`, never navigation joins, so the criteria stays translatable on any provider ([ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html)), and deriving both from the shared resolver means the junction cannot drift away from the entities whose visibility it follows. -- **Where it's used**: [`EventSpeakersController`](group-20-conference-api-grpc.md#eventspeakerscontroller)'s `BuildPublicSpecificationAsync` (`EventSpeakersController.cs:65-74`) is the only consumer, and from there it reaches the unpaged list (`:89`), paged list (`:119`), lookup (`:147`), and by-id (`:177`) reads. Note that the class-level `[HasPermission(ConferencePermissions.EventsManage)]` (`:45`) is overridden per action by `[AllowAnonymous]` (`:77`, `:100`, `:141`, `:163`), which is exactly why the handler has to carry the visibility rules itself. -- **Testing**: `GetPublicEventSpeakerFilterHandlerTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterHandlerTests.cs:18`), six tests on the shared `HandlerTestBase`: the success shape (`:75`), a row whose event is published and whose speaker is visible (`:84`), a row on an unpublished event (`:95`), a row of a hidden speaker on a published event (`:106`), a world where no speaker is visible (`:119`), and one that captures the predicate the handler hands to the `Event` projection, compiles it, and asserts it accepts a published event and rejects an unpublished one (`:129-147`). One fixture detail is a property of the entity rather than of the test: `EventSpeaker.EventId` is get-only and written by EF (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventSpeaker.cs:23`), so a row built through the factory in memory carries the default id, and the fixture uses `default` as its row event id (`:20-21`). `[Rubric §14, Testability]`: the speaker leg is the rule most likely to be dropped as redundant, and `:106` is the test that would catch it. -- **Caveats / not-in-source**: the controller maps a failed `Result` to `null`, meaning **no** filter: `return result.IsSuccess ? result.Value : null;` (`EventSpeakersController.cs:73`), which would widen the read rather than narrow it. Nothing in this handler can produce that failure today, so the exposure is latent rather than live, but it is the opposite of the fail-closed default the rest of the visibility code takes, and the same shape appears on the other junction controllers. Both id lists are also materialized into the predicate, so the two `IN` lists grow with the number of published events and of publicly visible speakers; nothing in this file bounds either. + - **Static strategy array** (`:28-35`): five stateless instances in dependency order (categories, rooms, questions, speakers, sessions), with the ordering rationale in the comment above them (`:26-27`). `static readonly` because the strategies hold no state; all state lives in the per-command context. + - **Primary constructor** (`:19-24`): five dependencies. `sealed partial` is what allows the `[LoggerMessage]` source-generated log method at the bottom of the file (`:173-174`). + - **Load the aggregate** (`:43-54`): `GetByIdAsync` with `Rooms` and `EventSpeakers` included and `asTracking: true`, returning `Error.NotFound` when the event is missing. + - **Precondition, a configured code** (`:57-64`, BR-6): a blank `SessionizeCode` fails with `Event.Sessionize.NoCode`. + - **Precondition, the throttle** (`:67-75`, BR-63): if `LastSessionizeRefreshOn` is less than five minutes before `timeProvider.GetUtcNow()`, the handler fails with `Event.Sessionize.Throttled` and never calls the API. Time comes from an injected `TimeProvider`, which is what makes the window testable without waiting. + - **Call the API** (`:78-93`), with the classification above. + - **An empty response is success** (`:96-111`): a null response is not an error, since an event may have no data yet. The handler stamps the refresh, saves, and returns a DTO of zeros with no warnings. + - **Run the strategies** (`:114-126`): one context is built and the five `SyncAsync` calls run sequentially, collecting a [SessionizeSyncResult](#sessionizesyncresult) each. + - **Summarize skips** (`:128-131`): a non-zero `SkippedSoftDeleted` becomes one final warning line naming the count and BR-136. + - **Stamp and save** (`:134-139`): `RecordSessionizeRefresh` writes the current user id and timestamp onto the aggregate, then `unitOfWork.RequestIdentityInsert()` (`:138`) is called before the single `SaveChangesAsync`. This is the load-bearing detail of the whole use case: Sessionize rows keep their external ids as primary keys in tables whose key columns are IDENTITY, so the unit of work has to wrap the save in `SET IDENTITY_INSERT ON/OFF` per table. The request flag is declared on [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IUnitOfWork.cs:49`), forwarded to the context factory (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/UnitOfWork.cs:76`), and consumed on the next save, which splits the work into rounds per table (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:224-233`). + - **Log and project** (`:141-153`): the source-generated information-level log records the event id, then the five results are read **positionally**, `results[0]` through `results[4]`, into the response DTO. + + `[Rubric §6, CQRS and Event-Driven]` assesses whether writes flow through one explicit handler boundary: this type implements [ICommandHandler](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) (`:24`), is wrapped by the decorator pipeline described in [group 5](group-05-cqrs-pipeline.md), and the controller knows only the interface. `[Rubric §3, Clean Architecture]` assesses dependency direction: the handler names [ISessionizeService](#isessionizeservice), never an HTTP client, so the outbound call is an abstraction the infrastructure layer satisfies with [SessionizeService](group-19-conference-infrastructure.md#sessionizeservice). +- **Why it's built this way**: one transaction across five entity families is not an optimization, it is what keeps sessions from referencing rooms or speakers that did not commit. That constraint drives the rest of the design: strategies must not throw on a bad row (a throw would abort everything), they must not provoke primary key collisions (hence the unscoped lookups and the reserved-band guards), and the handler must not save between steps. +- **Where it's used**: injected into `EventsController` as `ICommandHandler>` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventsController.cs:52`) and invoked from `RefreshAsync` (`EventsController.cs:367-372`), which is `[HttpPost("{id}/refresh")]` and `[Idempotent]` (`EventsController.cs:365-366`). The controller maps the two well-known error codes onto transport: `Event.Sessionize.Throttled` becomes a 429 with a `Retry-After` of 300 seconds (`EventsController.cs:378-381`) and `Event.Sessionize.Unavailable` becomes a 502 (`EventsController.cs:385-386`). Unit tests live at `MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/UseCases/RefreshFromSessionizeHandlerTests.cs:15`, with an integration tier at `MMCA.ADC/Tests/Integration/MMCA.ADC.Conference.IntegrationTests/Organizer/SessionizeRefreshTests.cs`. +- **Caveats**: the DTO projection is positional (`:145-150`), so the response mapping is coupled to the order of the static array; inserting a sixth strategy anywhere but the end would silently shift every count that follows it. Both `RecordSessionizeRefresh` calls dereference `currentUserService.UserId!.Value` (`:98` and `:134`) with the null-forgiving operator, so the handler assumes an authenticated caller and would throw for an anonymous one; the endpoint's authorization is what upholds that assumption. --- -### RefreshFromSessionizeHandler - -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeHandler.cs:19` · Level 11 · class (sealed partial, command handler) - -- **What it is**: the command handler for [`RefreshFromSessionizeCommand`](#refreshfromsessionizecommand). It loads the event, checks two preconditions, calls the Sessionize API, runs the five per-entity strategies in dependency order, and returns a DTO of per-entity sync counts. It is the orchestration seat of the whole import. -- **Depends on**: first-party: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`ICurrentUserService`](group-08-auth.md#icurrentuserservice), [`ISessionizeService`](#isessionizeservice) (the HTTP client abstraction), [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error), [`SessionizeResponse`](#sessionizeresponse), [`SessionizeSyncContext`](#sessionizesynccontext), [`SessionizeSyncResult`](#sessionizesyncresult), [`ISessionizeSyncStrategy`](#isessionizesyncstrategy) and its five implementations ([`CategorySyncStrategy`](#categorysyncstrategy), [`RoomSyncStrategy`](#roomsyncstrategy), [`QuestionSyncStrategy`](#questionsyncstrategy), [`SpeakerSyncStrategy`](#speakersyncstrategy), [`SessionSyncStrategy`](#sessionsyncstrategy)), [`Event`](group-17-conference-domain.md#event), [`RefreshFromSessionizeResultDTO`](group-17-conference-domain.md#refreshfromsessionizeresultdto), and the [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) contract it satisfies. Notable externals: `TimeProvider` (BCL, injected for a testable clock), `Microsoft.Extensions.Logging` (`ILogger` plus the `[LoggerMessage]` source generator), `System.Text.Json` for `JsonException`, and `Polly.CircuitBreaker` / `Polly.Timeout` for the two resilience-pipeline exception types. -- **Concept introduced: the orchestrator that owns sequencing but not sync logic.** `[Rubric §2, Design Patterns]` and `[Rubric §6, CQRS & Event-Driven]` both apply. The handler holds a `static readonly ISessionizeSyncStrategy[] SyncStrategies` (`RefreshFromSessionizeHandler.cs:28-35`) with the five strategies in explicit dependency order, and the comment above it states that order's reason (`:26-27`): categories first because speakers and sessions reference them, then rooms because sessions reference them, then questions, speakers, and finally sessions. Making the array `static` avoids re-allocating it per request; the strategies are safe to share because they are stateless. The handler knows *the order* entities must be synced but nothing about *how* any entity is synced. -- **Concept introduced: classifying an upstream exception as an outage, not a defect.** `IsSessionizeUnavailable` (`:166-171`) is the interesting piece of error handling here. Its doc comment (`:156-164`) explains that the Sessionize client runs behind the standard resilience pipeline from `AddServiceDefaults`, so an unreachable API arrives as a Polly `TimeoutRejectedException` or `BrokenCircuitException` at least as often as an `HttpRequestException`, and an HTML error page served with a success status surfaces from `ReadFromJsonAsync` as `JsonException` or `NotSupportedException` (unparseable content type). All five share the friendly failure instead of escaping as a 500. The guard immediately after the catch is what keeps that breadth honest: `cancellationToken.ThrowIfCancellationRequested()` (`:86`) so a caller cancellation is never reported as an upstream outage. `[Rubric §29, Resilience & Business Continuity]` assesses whether a dependency's failure degrades the caller gracefully; see [ADR-009](https://ivanball.github.io/docs/adr/009-resilience-and-recovery-objectives.html). -- **Walkthrough**: the primary constructor (`:19-24`) takes five dependencies: `IUnitOfWork`, `ISessionizeService`, `ICurrentUserService`, `TimeProvider`, and `ILogger`; the `sealed partial` modifier pairs with the `[LoggerMessage]` generator at `:173-174`. `HandleAsync` (`:38`) runs six phases: - 1. **Event load** (`:43-48`): fetches the [`Event`](group-17-conference-domain.md#event) with its `Rooms` and `EventSpeakers` navigations, `asTracking: true`; a missing event returns `Error.NotFound` (`:50-54`). The query filters stay **on** here, which is why [`SpeakerSyncStrategy`](#speakersyncstrategy) has to re-read deleted links itself. - 2. **BR-6 precondition** (`:57-64`): a missing or blank `SessionizeCode` returns `Error.Invariant` with code `"Event.Sessionize.NoCode"`. - 3. **BR-63 throttle** (`:67-75`): if `LastSessionizeRefreshOn` is within five minutes of `timeProvider.GetUtcNow().UtcDateTime` (`:68`), it returns `Error.Invariant` code `"Event.Sessionize.Throttled"` **without** calling the API. `[Rubric §12, Performance & Scalability]`: the throttle protects both the upstream rate limit and the local transaction cost. - 4. **External API call** (`:81`): `sessionizeService.GetAllAsync` inside a `try` whose `catch ... when (IsSessionizeUnavailable(ex))` filter (`:83`) converts the five recognized shapes into `Error.Failure` code `"Event.Sessionize.Unavailable"` (`:88-92`). Any other exception propagates: the filter deliberately does not swallow defects. - 5. **Empty-response short-circuit** (`:96-111`): a `null` response is valid (the event may have no data yet). The refresh timestamp is stamped via `@event.RecordSessionizeRefresh(...)` (`:98`), changes are saved (`:99`), and a zero-count DTO is returned without running any strategy. - 6. **Strategy execution** (`:114-153`): a fresh [`SessionizeSyncContext`](#sessionizesynccontext) is built with an empty warnings list (`:114-120`), then each strategy is `await`ed in sequence (`:123-126`), never in parallel, because later entities reference earlier ones and all five share one change tracker. A non-zero `SkippedSoftDeleted` is folded into `Warnings` as a single BR-136 summary line (`:128-131`), the refresh is stamped with the current user and time (`:134`), `unitOfWork.RequestIdentityInsert()` is called (`:138`), `SaveChangesAsync` commits the whole batch (`:139`), and the success log is emitted (`:141`). The DTO is assembled by reading each result's counters positionally (`:143-153`). -- **Why it's built this way**: keeping only load, check, call, fan-out, and commit here (no per-entity merge logic) keeps the method readable despite spanning five entity types, and returning [`Result`](group-01-result-error-handling.md#result) on every failure path lets the pipeline and controller handle errors uniformly rather than through exceptions. `RequestIdentityInsert()` (`:138`) is a deliberate infrastructure signal, explained in the comment above it (`:136-137`): Sessionize preserves its own integer ids and the strategies write them as primary keys, but SQL Server `IDENTITY` columns reject explicit values, so the unit of work must wrap the save in `SET IDENTITY_INSERT ON/OFF` per table. The `[LoggerMessage]` source generator (`:173-174`) emits an allocation-free structured log carrying `EventId`: `[Rubric §13, Observability & Operability]`. -- **Where it's used**: discovered by assembly scanning and wrapped by the decorator pipeline (FeatureGate, Logging, Caching, Transactional, given the command's markers, see [Group 05](group-05-cqrs-pipeline.md)); invoked by `EventsController.RefreshAsync` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventsController.cs:342-344`). That controller translates two of this handler's error codes into specific HTTP statuses: `"Event.Sessionize.Throttled"` becomes a `429` with a `Retry-After: 300` header (`:349-352`) and `"Event.Sessionize.Unavailable"` becomes a `502` (`:356-357`); everything else falls through to the shared `HandleFailure` (`:359`). On success it evicts the events cache plus five entity-family output-cache tags (`:363-368`), which is the read-cache counterpart to the command's own `ICacheInvalidating` prefix. -- **Caveats / not-in-source**: the result-DTO assembly reads `results[0]` through `results[4]` positionally (`:145-150`), so it silently depends on `SyncStrategies` staying in the declared order; reordering the array without updating the indices would swap the reported counts. `currentUserService.UserId!.Value` is dereferenced with `!` on both stamp paths (`:98`, `:134`), so an unauthenticated invocation would throw rather than return a `Result` failure; in practice the endpoint sits behind the controller's permission attribute, but this file does not enforce it. - ### EventDateRangeRules -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.Validation` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:91` · Level 0 · class (sealed, generic) - -- **What it is** - a reusable FluentValidation rule fragment that enforces event date-range integrity: a start date is required, an end date is required, and the end date must fall on or after the start date. -- **Depends on** - `FluentValidation.AbstractValidator` (NuGet) and `System.Linq.Expressions.Expression<>` (BCL). No first-party types: the file's `MMCA.ADC.Conference.Domain.Events` and `MMCA.Common.Application.Validation` usings (`EventValidationRules.cs:3-4`) are consumed by its sibling fragments, not by this one. -- **Concept introduced - the reusable field-rule fragment.** This is the first place in this chapter's per-type sections where the Conference module's validation-composition pattern appears, so it is worth teaching from first principles. Each `*Rules` class is a tiny `AbstractValidator` (or a [RequiredStringRules](group-06-validation.md#requiredstringrulest) subclass) that validates exactly one concern: one field, or one cross-field relationship. The generic parameter `T` is the owning command or request type, and the constructor takes a property-selector `Expression>`. Because the rule is parameterized on both `T` and the selector, the same fragment composes into a create-request validator and an update-request validator through FluentValidation's `Include(...)`, with zero copy-paste. `[Rubric §1 - SOLID]` assesses single-responsibility and open/closed adherence: each fragment owns one rule, and a new constraint is added by composing another fragment rather than by editing an existing one. `[Rubric §24 - Forms/Validation/UX Safety]` assesses whether validation is centralized and message-consistent: the fragment carries both the user-facing message and a stable `WithErrorCode` string that a client can key off. -- **Walkthrough** - the constructor (`EventValidationRules.cs:94-96`) takes two selectors, `startDateSelector` and `endDateSelector`, both over `DateOnly`. It registers a `NotEmpty` rule on each (`:98-99`, `:101-102`) with distinct error codes (`Event.StartDate.Required`, `Event.EndDate.Required`). The cross-field check is the notable mechanism: the start-date selector is compiled into a delegate once, at construction (`var startDateFunc = startDateSelector.Compile();`, `:104`), and a second rule on the end date calls `Must((instance, endDate) => endDate >= startDateFunc(instance))` with error code `Event.EndDate.BeforeStart` (`:105-107`). The two-argument `Must` overload hands the predicate both the whole instance under validation and the end-date value, so the compiled getter reads the sibling property off that same object. -- **Why it's built this way** - compiling the selector once at construction, rather than invoking the expression tree on every validation, keeps the cross-property comparison allocation-light on a path that runs per request. Splitting each concern into its own fragment means an update validator can pull in exactly the rules it needs instead of inheriting a monolithic validator. -- **Where it's used** - included by [EventCreateRequestValidator](#eventcreaterequestvalidator) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequestValidator.cs:13`) and [EventUpdateRequestValidator](#eventupdaterequestvalidator) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/Update/EventUpdateRequestValidator.cs:13`), each passing `p => p.StartDate, p => p.EndDate`. -- **Caveats / not-in-source** - `NotEmpty` on a `DateOnly` rejects `default(DateOnly)` (January 1, year 1), so a caller that never sets a date fails the required rule. That is FluentValidation's default-value semantics, not something this fragment states. - -### GetCategoryDistributionQuery -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetCategoryDistribution` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetCategoryDistribution/GetCategoryDistributionQuery.cs:5` · Level 0 · record (sealed) +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.Validation` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:109` · Level 0 · class (sealed, generic) -- **What it is** - the CQRS query contract that asks for the distribution of an event's sessions across its category items. A one-line record carrying nothing but the event to analyze. -- **Depends on** - the `EventIdentifierType` alias, an `int` in this module (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7`). No other first-party types, nothing external. -- **Concept introduced** - this is a plain read-side CQRS request; the query/handler split is taught by [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult), so it is cross-referenced rather than re-taught here. Note that the record implements no marker interface: the pairing to a handler is purely the generic argument on `IQueryHandler>` (`GetCategoryDistributionHandler.cs:15`). `[Rubric §6 - CQRS & Event-Driven]` assesses whether reads and writes travel separate paths with explicit contracts: this record is a read intent with no side effects, resolved by [GetCategoryDistributionHandler](#getcategorydistributionhandler). -- **Walkthrough** - one positional parameter, `EventId` of type `EventIdentifierType` (`:5`). No body, no defaults. -- **Why it's built this way** - keeping the query as a standalone record means it can be dispatched on its own (an organizer opening the category-distribution view) or read alongside the other decision-support dimensions without one endpoint over-fetching for another. -- **Where it's used** - constructed by [SessionSelectionController](group-20-conference-api-grpc.md#sessionselectioncontroller) on `GET SessionSelection/categories/{eventId}` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:53-61`), which resolves the handler through the injected `IQueryHandler>` (`:31`). +- **What it is**: a reusable FluentValidation rule fragment that enforces event date-range integrity: a start date is required, an end date is required, and the end date must fall on or after the start date. It is the only fragment in the Events validation folder that reasons about two properties at once. +- **Depends on**: FluentValidation's `AbstractValidator` (NuGet, primer [§3](00-primer.md#3-the-external-stack-bcl--nuget-external-level-0)) and `System.Linq.Expressions.Expression<>` (BCL). No first-party types: the file's `MMCA.ADC.Conference.Domain.Events` and `MMCA.Common.Application.Validation` imports (`EventValidationRules.cs:3-4`) serve its sibling fragments in the same file, not this one. +- **Concept, the cross-field rule fragment.** The module-local fragment idiom itself is taught on [ActivityEventIdRules](#activityeventidrulest), and the framework fragments it composes over live in [group-06](group-06-validation.md). What this class adds is the two-property case. A single-field fragment closes over one `Expression>`; a cross-field fragment takes two selectors and must read one property while validating the other. FluentValidation's two-argument `Must` overload is the mechanism: the predicate receives both the instance under validation and the value of the property the rule is attached to, so the sibling property is reachable through a compiled getter. `[Rubric §1, SOLID]` assesses single responsibility and open/closed adherence: "end after start" is its own fragment rather than a clause bolted onto a name or time-zone rule, so adding a constraint means composing another `Include(...)` line, never editing an existing fragment. `[Rubric §24, Forms, Validation & UX Safety]` assesses whether validation is centralized and machine-addressable: one fragment serves both the create and the update path, and each of its three rules carries a stable dotted error code beside its human message. +- **Walkthrough**: the constructor (`EventValidationRules.cs:112-114`) takes two `Expression>` selectors, `startDateSelector` and `endDateSelector`. It registers `NotEmpty` on each, with the distinct codes `Event.StartDate.Required` (`:116-117`) and `Event.EndDate.Required` (`:119-120`). The cross-field check is the part worth reading closely: the start-date selector is compiled to a delegate **once, at construction** (`var startDateFunc = startDateSelector.Compile();`, `:122`), and a second rule on the end date calls `Must((instance, endDate) => endDate >= startDateFunc(instance))` with the message "End Date must be on or after the Start Date" and the code `Event.EndDate.BeforeStart` (`:123-125`). Because the delegate is captured in the constructor, the expression tree is compiled per validator instance, not per validated request. +- **Why it's built this way**: the same rule exists on the domain side as `EventInvariants.EnsureDateRangeIsValid` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:116-125`, error code `Event.DateRange.Invalid`), so the fragment is not the only guard: it is the fast, field-attributed one that fires before a handler or an aggregate is touched, while the invariant is the backstop for any caller that bypasses the validator. Splitting the concern into its own fragment is what lets the update validator pull in exactly this rule instead of inheriting a monolithic event validator. +- **Where it's used**: `Include`d by [EventCreateRequestValidator](#eventcreaterequestvalidator) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequestValidator.cs:13`) and [EventUpdateRequestValidator](#eventupdaterequestvalidator) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/Update/EventUpdateRequestValidator.cs:13`), each passing `p => p.StartDate, p => p.EndDate`. +- **Caveats / not-in-source**: `NotEmpty` on a `DateOnly` rejects `default(DateOnly)` (January 1, year 1), so a caller that never sets a date fails the required rule. That is FluentValidation's default-value semantics, not something this fragment states. ### LocalityLookupEntry > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/SpeakerLocalityHelper.cs:13` · Level 0 · record class (internal sealed) -- **What it is** - one entry of the merged speaker-locality lookup: a tier name plus the id of the locality category the item came from. It is declared in the same file as [SpeakerLocalityHelper](#speakerlocalityhelper) (`SpeakerLocalityHelper.cs:13-15`) because it is that helper's dictionary value type and nothing outside the helper constructs one. -- **Depends on** - the `ConferenceCategoryIdentifierType` alias, an `int` in this module (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:6`). No other first-party types, nothing external beyond the BCL. -- **Concept introduced - locality modelled as a category assignment that carries its import generation.** A speaker's origin is not a column on [Speaker](group-17-conference-domain.md#speaker): it is a [SpeakerCategoryItem](group-17-conference-domain.md#speakercategoryitem) row pointing at a [CategoryItem](group-17-conference-domain.md#categoryitem) inside a "Where are you traveling from" [Category](group-17-conference-domain.md#category) (`SpeakerLocalityHelper.cs:17-19`, with the title match at `:98-99`). `[Rubric §4 - DDD]` assesses whether concepts are expressed through the aggregates the domain actually has rather than through bolted-on fields; here the answer is read out of the existing category machinery, which is also what the Sessionize import populates. `[Rubric §8 - Data Architecture]` explains the second field: [Category](group-17-conference-domain.md#category) is a global aggregate with no event scoping, so every yearly Sessionize refresh adds another locality category carrying fresh item ids (`:82-84`), and a returning speaker keeps the item from every year they answered the question (`:51-52`). A bare `id -> name` lookup cannot say which year an item belongs to; pairing the name with its owning category id can, and that is the whole reason this record exists. -- **Walkthrough** - two positional members on a `record class` (`:13-15`): `Name`, the locality tier name, documented with the example "Atlanta and Suburbs" (`:11`), and `CategoryId`, the identifier of the locality category owning the item (`:12`). The type is `internal sealed`, so it never leaves the Application assembly. Values are produced in exactly one place, `BuildLocalityLookup`, which walks the locality categories in ascending id order and writes `lookup[item.Id] = new LocalityLookupEntry(item.Name, category.Id)` for every non-deleted item (`:128-137`); they are consumed in exactly one place, `GetLocalityTier`, which walks the speaker's non-deleted assignments, looks each one up, and keeps the entry whose `CategoryId` is the highest seen so far (`:43-58`, the comparison at `:53`). Because the winner is chosen by comparison rather than by position, the order of the speaker's own assignments does not affect the answer. -- **Why it's built this way** - the "most recent import wins" rule needs a tiebreaker that survives merging several years of categories into one dictionary, and the owning category id is the only ordering signal available without a schema change (`:7-9`, `:117-119`). Declaring it a `record` gives value equality and immutability for free, which is what lets the helper treat entries as plain values while scanning. -- **Where it's used** - only inside the `DecisionSupport` folder, as the value type of the `IReadOnlyDictionary` that [SpeakerLocalityHelper](#speakerlocalityhelper) builds and reads (`:38`, `:123`). That dictionary is threaded through two decision-support handlers: [GetSessionSelectionDashboardHandler](#getsessionselectiondashboardhandler) builds it once and passes it into its overlap, locality, and AI-score passes (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboard/GetSessionSelectionDashboardHandler.cs:76`, `:92`, `:95`, `:106`, and the parameter declarations at `:190`, `:258`, `:343`, `:367`, `:397`), and [GetSpeakerSessionOverlapHandler](#getspeakersessionoverlaphandler) does the same for its narrower view (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSpeakerSessionOverlap/GetSpeakerSessionOverlapHandler.cs:52-53`, `:56`, `:103`, `:119`). -- **Caveats / not-in-source** - the "highest category id is the most recent import" rule is an assumption about how Sessionize allocates category ids. The source states it twice as a comment (`:51-52`, `:117-119`) but nothing enforces or validates it, so an out-of-order id would silently resolve a returning speaker to an older tier. The behavior is pinned by `SpeakerLocalityHelperTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/SpeakerLocalityHelperTests.cs:110-123`, `:126-141`, `:144-158`), which construct entries directly and assert the newest-import tier wins regardless of assignment order, but the id ordering itself is an upstream property. +- **What it is**: one entry of the merged speaker-locality lookup: a tier name plus the id of the locality category the item came from. It is declared in the same file as [SpeakerLocalityHelper](#speakerlocalityhelper) (`SpeakerLocalityHelper.cs:13-15`) because it is that helper's dictionary value type and nothing outside the helper constructs one. +- **Depends on**: the `ConferenceCategoryIdentifierType` alias, an `int` in this module (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7`). No other first-party types, nothing external beyond the BCL. +- **Concept, locality modelled as a category assignment that carries its import generation.** A speaker's origin is not a column on [Speaker](group-17-conference-domain.md#speaker): it is a [SpeakerCategoryItem](group-17-conference-domain.md#speakercategoryitem) row pointing at a [CategoryItem](group-17-conference-domain.md#categoryitem) inside a "Where are you traveling from" [Category](group-17-conference-domain.md#category) (`SpeakerLocalityHelper.cs:17-19`, with the title match at `:98-99`). `[Rubric §4, DDD]` assesses whether concepts are expressed through the aggregates the domain actually has rather than through bolted-on fields; here the answer is read out of the existing category machinery, which is also what the Sessionize import populates. `[Rubric §8, Data Architecture]` explains the second field: [Category](group-17-conference-domain.md#category) is a global aggregate with no event scoping, so every yearly Sessionize refresh adds another locality category carrying fresh item ids (`:82-84`), and a returning speaker keeps the item from every year they answered the question (`:51-52`). A bare id-to-name lookup cannot say which year an item belongs to; pairing the name with its owning category id can, and that is the whole reason this record exists. +- **Walkthrough**: two positional members on a `record class` (`:13-15`). `Name` is the locality tier name, documented with the example "Atlanta and Suburbs" (`:11`); `CategoryId` is the identifier of the locality category owning the item (`:12`). The type is `internal sealed`, so it never leaves the Application assembly. Values are produced in exactly one place, `BuildLocalityLookup`, which walks the locality categories in ascending id order and writes `lookup[item.Id] = new LocalityLookupEntry(item.Name, category.Id)` for every non-deleted item (`:128-137`); they are consumed in exactly one place, `GetLocalityTier`, which walks the speaker's non-deleted assignments, looks each one up, and keeps the entry whose `CategoryId` is the highest seen so far (`:43-58`, the comparison at `:53`). Because the winner is chosen by comparison rather than by position, the order of the speaker's own assignments does not affect the answer. +- **Why it's built this way**: the "most recent import wins" rule needs a tiebreaker that survives merging several years of categories into one dictionary, and the owning category id is the only ordering signal available without a schema change (`:7-9`, `:117-119`). Declaring it a `record` gives value equality and immutability for free, which is what lets the helper treat entries as plain values while scanning. +- **Where it's used**: only inside the `DecisionSupport` folder, as the value type of the `IReadOnlyDictionary` that [SpeakerLocalityHelper](#speakerlocalityhelper) builds and reads (`:38`, `:123`). That dictionary is threaded through two decision-support handlers: [GetSessionSelectionDashboardHandler](#getsessionselectiondashboardhandler) builds it once and passes it into its overlap, locality, and AI-score passes (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboard/GetSessionSelectionDashboardHandler.cs:76`, `:92`, `:95`, `:106`, with the parameter declarations at `:190`, `:258`, `:343`, `:367`, `:397`), and [GetSpeakerSessionOverlapHandler](#getspeakersessionoverlaphandler) does the same for its narrower view (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSpeakerSessionOverlap/GetSpeakerSessionOverlapHandler.cs:52-53`, `:56`, `:103`, `:119`). +- **Caveats / not-in-source**: the "highest category id is the most recent import" rule is an assumption about how Sessionize allocates category ids. The source states it twice as a comment (`:51-52`, `:117-119`) but nothing enforces or validates it, so an out-of-order id would silently resolve a returning speaker to an older tier. The behavior is pinned by `SpeakerLocalityHelperTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/SpeakerLocalityHelperTests.cs:110-123`, `:126-141`, `:144-158`), which build the entries directly and assert that the newest-import tier wins regardless of assignment order, but the id ordering itself is an upstream property. ### RoomCapacityRules > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.Validation` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:37` · Level 0 · class (sealed, generic) -- **What it is** - a reusable rule fragment enforcing that a room's capacity, when supplied, is strictly positive. Capacity is optional (`int?`), so the rule only fires when a value is present. -- **Depends on** - `FluentValidation.AbstractValidator` and `System.Linq.Expressions.Expression<>`. No first-party types. -- **Concept introduced** - the same reusable field-rule pattern taught in [EventDateRangeRules](#eventdaterangerulest). The wrinkle worth noticing is the conditional: `.When(x => selector.Compile()(x) is not null)` (`RoomValidationRules.cs:43`) guards the `GreaterThan(0)` rule (`:42`) so a null capacity is silently accepted rather than reported as invalid. `[Rubric §24 - Forms/Validation/UX Safety]` assesses whether validation matches the field's real optionality: an absent optional numeric field should not raise an error. -- **Walkthrough** - the constructor takes an `Expression>` selector (`:40`), chains `GreaterThan(0)` with message "Capacity must be greater than 0" and error code `Room.Capacity.NotPositive` (`:42`), then applies the null-guard `When` clause (`:43`). -- **Why it's built this way** - separating the presence check (`When`) from the value check keeps the "optional but bounded" semantics in one place: a room without a known capacity is valid, a room claiming a non-positive capacity is not. -- **Where it's used** - included by [AddRoomCommandValidator](#addroomcommandvalidator) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/AddRoom/AddRoomCommandValidator.cs:13`) and [UpdateRoomCommandValidator](#updateroomcommandvalidator) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomCommandValidator.cs:13`), both on `p => p.Capacity`. -- **Caveats / not-in-source** - unlike [EventDateRangeRules](#eventdaterangerulest), which compiles its selector once at construction, the `When` predicate here calls `selector.Compile()` inside the lambda (`:43`), so the expression is compiled on each evaluation rather than cached. +- **What it is**: a reusable rule fragment enforcing that a room's capacity, when supplied, is strictly positive. Capacity is optional (`int?`), so the rule only fires when a value is present. +- **Depends on**: FluentValidation's `AbstractValidator` and `System.Linq.Expressions.Expression<>`. No first-party types: unlike the string fragments in the same file it reads no domain constant. +- **Concept**: the module-local rule fragment taught on [ActivityEventIdRules](#activityeventidrulest). The wrinkle worth noticing here is the conditional: `.When(x => selector.Compile()(x) is not null)` (`RoomValidationRules.cs:43`) guards the `GreaterThan(0)` rule (`:42`) so a null capacity is silently accepted rather than reported as invalid. `[Rubric §24, Forms, Validation & UX Safety]` assesses whether validation matches a field's real optionality: an absent optional numeric field should not raise an error, while a present but nonsensical one should. +- **Walkthrough**: the constructor takes an `Expression>` selector (`:40`), chains `GreaterThan(0)` with the message "Capacity must be greater than 0" and the error code `Room.Capacity.NotPositive` (`:41-42`), then applies the null-guard `When` clause (`:43`). +- **Why it's built this way**: separating the presence check (`When`) from the value check keeps the "optional but bounded" semantics in one place: a room without a known capacity is valid, a room claiming a non-positive capacity is not. The domain states the same rule as `EventInvariants.EnsureRoomCapacityIsValid`, whose pattern is `capacity is <= 0` and whose code is `Room.Capacity.Invalid` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:131-139`), tagged BR-93 in its doc comment (`:127`), so the fragment and the invariant agree on treating null as acceptable. +- **Where it's used**: `Include`d by [AddRoomCommandValidator](#addroomcommandvalidator) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/AddRoom/AddRoomCommandValidator.cs:13`) and [UpdateRoomCommandValidator](#updateroomcommandvalidator) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomCommandValidator.cs:13`), both on `p => p.Capacity`. +- **Caveats / not-in-source**: unlike [EventDateRangeRules](#eventdaterangerulest), which compiles its selector once at construction, the `When` predicate here calls `selector.Compile()` **inside** the lambda (`:43`), so the expression is recompiled on each evaluation rather than cached. ### RoomSortRules > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.Validation` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:25` · Level 0 · class (sealed, generic) -- **What it is** - a reusable rule fragment enforcing that a room's sort-order value is non-negative. -- **Depends on** - `FluentValidation.AbstractValidator` and `System.Linq.Expressions.Expression<>`. No first-party types. -- **Concept introduced** - the same pattern as [EventDateRangeRules](#eventdaterangerulest), in its simplest possible form: one rule, no conditionals, no cross-field logic. -- **Walkthrough** - the constructor takes an `Expression>` selector (`:28`) and registers `GreaterThanOrEqualTo(0)` with message "Sort must be greater than or equal to 0" and error code `Room.Sort.Negative` (`:29-30`). -- **Why it's built this way** - sort order drives deterministic ordering of rooms in the UI; a negative value has no meaning, so the fragment rejects it at the application boundary before it reaches the domain factory. -- **Where it's used** - included by [AddRoomCommandValidator](#addroomcommandvalidator) (`AddRoomCommandValidator.cs:12`) and [UpdateRoomCommandValidator](#updateroomcommandvalidator) (`UpdateRoomCommandValidator.cs:12`), both on `p => p.Sort`, alongside [RoomCapacityRules](#roomcapacityrulest). - -### StatusBucket -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetCategoryDistribution` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetCategoryDistribution/GetCategoryDistributionHandler.cs:94` · Level 0 · enum (private, nested) - -- **What it is** - a private enum nested inside [GetCategoryDistributionHandler](#getcategorydistributionhandler) that collapses a session's raw status string onto one of three counting buckets used to tally the category distribution. -- **Depends on** - nothing structurally; it is produced by the handler's `ClassifyStatus` from the [SessionStatuses](group-17-conference-domain.md#sessionstatuses) string constants (`GetCategoryDistributionHandler.cs:101-112`). -- **Concept introduced** - a handler-local aggregation vocabulary. The enum is an implementation detail of one handler and never reaches a caller, who receives DTO counts instead. `[Rubric §16 - Maintainability]` assesses local reasoning: keeping the bucket type private to its handler means the bucketing can evolve without coupling the sibling decision-support handlers. `[Rubric §15 - Best Practices & Code Quality]` assesses expressiveness: three named members read better at the tally site (`:58-60`) than three ad-hoc string comparisons would. -- **Walkthrough** - three members: `Accepted`, `AcceptQueue`, `Pending` (`:94-99`). There is deliberately no `Declined` member: declined sessions are removed upstream by `IsDeclined` (`:45`, `:114-115`) before any bucketing happens, so the enum only spans the statuses that count toward a category's totals. -- **Why it's built this way** - declined proposals do not contribute to the distribution an organizer is weighing, so filtering them out before the enum stage keeps the three live buckets clean. -- **Where it's used** - inside [GetCategoryDistributionHandler](#getcategorydistributionhandler) only, by `CountSessionsPerCategoryItem` (`:58-60`) and `ClassifyStatus` (`:101-112`). -- **Caveats / not-in-source** - [GetSessionSelectionDashboardHandler](#getsessionselectiondashboardhandler) declares its own private enum of the same name and the same three members (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboard/GetSessionSelectionDashboardHandler.cs:314-319`), with a matching `ClassifyStatus` (`:321-332`). They are two independent types that happen to agree today; nothing in source keeps them in step. +- **What it is**: a reusable rule fragment enforcing that a room's sort-order value is non-negative. +- **Depends on**: FluentValidation's `AbstractValidator` and `System.Linq.Expressions.Expression<>`. No first-party types. +- **Concept**: the module-local rule fragment taught on [ActivityEventIdRules](#activityeventidrulest), in its simplest possible form: one rule, no conditional, no cross-field logic. Its structural twin on the Activities side is [ActivitySortOrderRules](#activitysortorderrulest), and on the Categories side [CategoryItemSortRules](#categoryitemsortrulest). +- **Walkthrough**: the constructor takes an `Expression>` selector (`:28`) and registers `GreaterThanOrEqualTo(0)` with the message "Sort must be greater than or equal to 0" and the error code `Room.Sort.Negative` (`:29-30`). +- **Why it's built this way**: `GreaterThanOrEqualTo(0)` rather than `GreaterThan(0)` because zero is a legitimate "first in the list" position, which is also why the framework's shared [PositiveIntRules](group-06-validation.md#positiveintrulest) (`MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:49-54`) is the wrong fragment to reuse here. Sort order drives deterministic room ordering in the UI, so a negative value is rejected at the application boundary before it reaches the domain factory. +- **Where it's used**: `Include`d by [AddRoomCommandValidator](#addroomcommandvalidator) (`AddRoomCommandValidator.cs:12`) and [UpdateRoomCommandValidator](#updateroomcommandvalidator) (`UpdateRoomCommandValidator.cs:12`), both on `p => p.Sort`, alongside [RoomCapacityRules](#roomcapacityrulest). +- **Caveats / not-in-source**: nothing in `EventInvariants` mirrors this rule, so unlike room name and room capacity the sort order has no domain-side backstop: this fragment is the only guard on the path. ### EventNameRules > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.Validation` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:13` · Level 7 · class (sealed, generic) -- **What it is** - a reusable rule fragment for the event name: non-empty and bounded by `EventInvariants.NameMaxLength`, which is 500 characters (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:13`). -- **Depends on** - [RequiredStringRules](group-06-validation.md#requiredstringrulest) (its base class, `MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:13`) and [EventInvariants](group-17-conference-domain.md#eventinvariants). -- **Concept introduced** - the same reusable field-rule pattern as [EventDateRangeRules](#eventdaterangerulest), but this fragment inherits the framework's shared `RequiredStringRules` instead of `AbstractValidator` directly, delegating the `NotEmpty` plus `MaximumLength` wiring to the base (`CommonValidationRules.cs:15-18`). `[Rubric §4 - DDD]` assesses ubiquitous language in code: the class is named `EventNameRules` after the domain field rather than a generic "NameValidator". `[Rubric §16 - Maintainability]` assesses reuse across repos: the two-line derived class is all the module writes, because the shared base owns the message shape. -- **Walkthrough** - one constructor taking an `Expression>` selector, whose whole body is the base call `base(selector, "Event Name", EventInvariants.NameMaxLength)` (`:16-17`). The base produces the messages "You must enter a Event Name" and "Event Name cannot be longer than 500 characters" (`CommonValidationRules.cs:17-18`). -- **Why it's built this way** - the length constant lives once in [EventInvariants](group-17-conference-domain.md#eventinvariants) and is the same value the domain-side invariant enforces (`EventInvariants.EnsureNameIsValid`, `EventInvariants.cs:64-67`, error code `Event.Name.TooLong`), so the validation message and the domain invariant cannot drift apart. -- **Where it's used** - included by [EventCreateRequestValidator](#eventcreaterequestvalidator) (`EventCreateRequestValidator.cs:11`) and [EventUpdateRequestValidator](#eventupdaterequestvalidator) (`EventUpdateRequestValidator.cs:11`), both on `p => p.Name`. -- **Caveats / not-in-source** - unlike the room and time-zone fragments, this one emits no `WithErrorCode`, because the shared base sets none (`CommonValidationRules.cs:16-18`). A client keying off error codes gets them for the event time zone and date range but not for the event name. +- **What it is**: a reusable rule fragment for the event name: non-empty and bounded by `EventInvariants.NameMaxLength`, which is 500 characters (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:13`). +- **Depends on**: [RequiredStringRules](group-06-validation.md#requiredstringrulest), its base class from the framework (`MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:13`), and [EventInvariants](group-17-conference-domain.md#eventinvariants) for the bound. +- **Concept**: the same fragment idiom taught on [ActivityEventIdRules](#activityeventidrulest), but taken to its terse extreme: instead of writing a rule chain, this fragment **subclasses** the framework's shared `RequiredStringRules` and passes it a field label and a bound, delegating the `NotEmpty` plus `MaximumLength` wiring to the base (`CommonValidationRules.cs:15-18`). `[Rubric §4, DDD]` assesses ubiquitous language in code: the class is named `EventNameRules` after the domain field, not a generic "NameValidator". `[Rubric §16, Maintainability]` assesses reuse across repos: the module writes two lines, because the shared base owns the message shape, which is exactly the trade this idiom makes (see the caveat). +- **Walkthrough**: one constructor taking an `Expression>` selector (`:16`), whose entire body is the base call `base(selector, "Event Name", EventInvariants.NameMaxLength)` (`:17`). The base produces the two messages "You must enter a Event Name" and "Event Name cannot be longer than 500 characters" (`CommonValidationRules.cs:17-18`). +- **Why it's built this way**: the length constant lives once in [EventInvariants](group-17-conference-domain.md#eventinvariants) and is the same value the domain-side invariant enforces (`EventInvariants.EnsureNameIsValid`, `EventInvariants.cs:67-70`, error code `Event.Name.TooLong`), so the validation message, the aggregate guard, and the schema cannot drift apart. +- **Where it's used**: `Include`d by [EventCreateRequestValidator](#eventcreaterequestvalidator) (`EventCreateRequestValidator.cs:11`) and [EventUpdateRequestValidator](#eventupdaterequestvalidator) (`EventUpdateRequestValidator.cs:11`), both on `p => p.Name`. +- **Caveats / not-in-source**: unlike the room and time-zone fragments, this one emits **no** `WithErrorCode`, because the shared base sets none (`CommonValidationRules.cs:16-18`). A client keying off error codes gets them for the event time zone and the date range but not for the event name. The base message also reads "You must enter a Event Name", an article-agreement artifact of building the message from the field label. ### EventOrganizerContactEmailRules > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.Validation` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:57` · Level 7 · class (sealed, generic) -- **What it is** - a rule fragment for the event's optional organizer contact email. When the caller supplies a value it must be a well-formed email address no longer than `EventInvariants.OrganizerContactEmailMaxLength`, 255 characters (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:34`); when the caller leaves it blank, no rule runs at all. -- **Depends on** - [EmailRules](group-06-validation.md#emailrulest), the shared framework fragment it wraps (`MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:36-43`), and [EventInvariants](group-17-conference-domain.md#eventinvariants). Inherits `AbstractValidator` directly and uses `System.Linq.Expressions.Expression<>`. -- **Concept introduced - conditional inclusion of a required-field fragment.** The three preceding fragments either always apply ([EventNameRules](#eventnamerulest)) or are unconditionally length-only ([RoomFloorRules](#roomfloorrulest)). This one is different: the shared `EmailRules` it wants to reuse starts with `NotEmpty` (`CommonValidationRules.cs:40`), which is exactly wrong for an optional field. Rather than fork a near-copy of the shared fragment, the constructor compiles the selector once (`var accessor = selector.Compile();`, `EventValidationRules.cs:62`) and wraps the whole `Include` in FluentValidation's `When(...)` so the required-email rules are only registered against instances that actually carry a value (`:64-65`). `[Rubric §1 - SOLID]` assesses open/closed adherence: optionality is added around the shared rule, not by modifying it. `[Rubric §24 - Forms/Validation/UX Safety]` assesses whether validation matches the field's real optionality: an organizer who never fills the field sees no error, while a typo in a filled field is still rejected as a bad address. -- **Walkthrough** - the constructor takes an `Expression>` selector (`:60`), compiles it to a delegate (`:62`), then calls `When(x => !string.IsNullOrWhiteSpace(accessor(x)), () => Include(new EmailRules(selector, "Organizer Contact Email", EventInvariants.OrganizerContactEmailMaxLength)))` (`:64-65`). Inside the guard the shared base contributes three chained rules: `NotEmpty`, `EmailAddress`, and `MaximumLength`, with messages built from the "Organizer Contact Email" field name (`CommonValidationRules.cs:39-42`). -- **Why it's built this way** - the field's doc comment states the product reason (`:51-55`): the value is optional, and an empty value means the public event page falls back to the configured support address rather than showing nothing. That fallback is real, `PublicEventDetail` picks `Configuration["Support:Email"]` when the event carries no organizer address (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicEventDetail.razor.cs:81-83`), so rejecting a blank value at the boundary would break the intended default. -- **Where it's used** - included by [EventCreateRequestValidator](#eventcreaterequestvalidator) (`EventCreateRequestValidator.cs:14`) and [EventUpdateRequestValidator](#eventupdaterequestvalidator) (`EventUpdateRequestValidator.cs:14`), both as `p => p.OrganizerContactEmail!`. -- **Caveats / not-in-source** - the selector type is non-nullable `string` while the underlying request property is `string?` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequest.cs:46`), which is why both call sites pass the null-forgiving `p => p.OrganizerContactEmail!`. The `!` only silences the compiler; the runtime null is handled by the `When` guard, which is what makes the combination safe. There is no matching domain-side invariant for this field: [EventInvariants](group-17-conference-domain.md#eventinvariants) defines the length constant (`:34`) and the EF configuration applies it to the column (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/EventConfiguration.cs:56-57`), but no `Ensure...` method validates the address, so this fragment is the only format check on the path. +- **What it is**: a rule fragment for the event's optional organizer contact email. When the caller supplies a value it must be a well-formed email address no longer than `EventInvariants.OrganizerContactEmailMaxLength`, 255 characters (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:34`); when the caller leaves it blank, no rule runs at all. +- **Depends on**: [EmailRules](group-06-validation.md#emailrulest), the shared framework fragment it wraps (`MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:36-43`), and [EventInvariants](group-17-conference-domain.md#eventinvariants). It inherits `AbstractValidator` directly and uses `System.Linq.Expressions.Expression<>`. +- **Concept, conditional inclusion of a required-field fragment.** The fragments seen so far either always apply ([EventNameRules](#eventnamerulest)) or are unconditionally length-only ([RoomFloorRules](#roomfloorrulest)). This one is different: the shared `EmailRules` it wants to reuse **starts** with `NotEmpty` (`CommonValidationRules.cs:40`), which is exactly wrong for an optional field. Rather than fork a near-copy of the shared fragment, the constructor compiles the selector once (`var accessor = selector.Compile();`, `EventValidationRules.cs:62`) and wraps the whole `Include` in FluentValidation's `When(...)`, so the required-email rules are only registered against instances that actually carry a value (`:64-65`). `[Rubric §1, SOLID]` assesses open/closed adherence: optionality is composed **around** the shared rule, never by modifying it. `[Rubric §24, Forms, Validation & UX Safety]` assesses whether validation matches the field's real optionality: an organizer who never fills the field sees no error, while a typo in a filled field is still rejected as a bad address. +- **Walkthrough**: the constructor takes an `Expression>` selector (`:60`), compiles it to a delegate (`:62`), then calls `When(x => !string.IsNullOrWhiteSpace(accessor(x)), () => Include(new EmailRules(selector, "Organizer Contact Email", EventInvariants.OrganizerContactEmailMaxLength)))` (`:64-65`). Inside the guard the shared base contributes three chained rules: `NotEmpty`, `EmailAddress`, and `MaximumLength`, all with messages built from the "Organizer Contact Email" field label (`CommonValidationRules.cs:39-42`). +- **Why it's built this way**: the field's doc comment states the product reason (`:51-55`): the value is optional, and an empty value means the public page falls back to the configured support address rather than showing nothing. That fallback is real. `PublicEventDetail` seeds `_supportEmail` from `Configuration["Support:Email"]` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicEventDetail.razor.cs:54`) and, once the event loads, keeps the configured address when `Event.OrganizerContactEmail` is blank and uses the event's own address otherwise (`:115-117`). Rejecting a blank value at the boundary would break that intended default. +- **Where it's used**: `Include`d by [EventCreateRequestValidator](#eventcreaterequestvalidator) (`EventCreateRequestValidator.cs:14`) and [EventUpdateRequestValidator](#eventupdaterequestvalidator) (`EventUpdateRequestValidator.cs:14`), both as `p => p.OrganizerContactEmail!`. +- **Caveats / not-in-source**: the selector type is non-nullable `string` while the underlying request property is `string?` on both requests (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequest.cs:46`, `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/Update/EventUpdateRequest.cs:43`), which is why both call sites pass the null-forgiving `p => p.OrganizerContactEmail!`. The `!` only silences the compiler; the runtime null is handled by the `When` guard, and that combination is what makes it safe. There is also no domain-side invariant for this field: [EventInvariants](group-17-conference-domain.md#eventinvariants) defines the length constant (`:34`) and the EF configuration applies it to the column (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/EventConfiguration.cs:56-57`), but no `Ensure...` method validates the address, so this fragment is the only format check on the path. ### EventSponsorshipPacketUrlRules > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.Validation` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:75` · Level 7 · class (sealed, generic) -- **What it is** - a rule fragment bounding the event's optional sponsorship-packet URL to `EventInvariants.SponsorshipPacketUrlMaxLength`, 2000 characters (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:37`), and only when a value is supplied. -- **Depends on** - [OptionalStringRules](group-06-validation.md#optionalstringrulest), the shared framework fragment it wraps (`MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:25-30`), and [EventInvariants](group-17-conference-domain.md#eventinvariants). Inherits `AbstractValidator` directly. -- **Concept introduced** - structurally the same conditional-inclusion shape as [EventOrganizerContactEmailRules](#eventorganizercontactemailrulest): compile the selector once, then `Include` the shared fragment inside a `When` guard. Worth noticing is that here the guard is not strictly needed for correctness, because the wrapped `OptionalStringRules` is length-only and already passes a null (`CommonValidationRules.cs:28-29`); the two fragments are written to the same shape so the file reads uniformly. `[Rubric §16 - Maintainability]` assesses consistency: two adjacent optional-field fragments that look identical are cheaper to read and to extend than two that solve the same problem differently. -- **Walkthrough** - the constructor takes an `Expression>` selector (`:78`), note the nullable `string?` here as against the non-nullable selector of its email sibling, compiles it (`:80`), and registers `When(x => !string.IsNullOrWhiteSpace(accessor(x)), () => Include(new OptionalStringRules(selector, "Sponsorship Packet URL", EventInvariants.SponsorshipPacketUrlMaxLength)))` (`:82-83`). The shared base contributes a single `MaximumLength` rule with the message "Sponsorship Packet URL cannot be longer than 2000 characters" (`CommonValidationRules.cs:28-29`). -- **Why it's built this way** - the doc comment gives the product reason (`:69-73`): the field is optional, and an empty value means the landing page and the public sponsor page hide the sponsorship call to action rather than rendering a dead link. That behavior is visible in the UI, `PublicSponsorList` renders the packet button only when the URL is non-blank (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSponsorList.razor:30`, with the value loaded at `PublicSponsorList.razor.cs:61`). -- **Where it's used** - included by [EventCreateRequestValidator](#eventcreaterequestvalidator) (`EventCreateRequestValidator.cs:15`) and [EventUpdateRequestValidator](#eventupdaterequestvalidator) (`EventUpdateRequestValidator.cs:15`), both on `p => p.SponsorshipPacketUrl`. -- **Caveats / not-in-source** - despite the name, nothing here validates that the value is a URL: the only constraint is length. A caller can store arbitrary text, and the public page will render it as a link target. The shared base also sets no `WithErrorCode`, so this field produces a message without a machine-readable code. +- **What it is**: a rule fragment bounding the event's optional sponsorship-packet URL to `EventInvariants.SponsorshipPacketUrlMaxLength`, 2000 characters (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:37`), and only when a value is supplied. +- **Depends on**: [OptionalStringRules](group-06-validation.md#optionalstringrulest), the shared framework fragment it wraps (`MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:25-30`), and [EventInvariants](group-17-conference-domain.md#eventinvariants). It inherits `AbstractValidator` directly. +- **Concept**: structurally the same conditional-inclusion shape taught on [EventOrganizerContactEmailRules](#eventorganizercontactemailrulest): compile the selector once, then `Include` the shared fragment inside a `When` guard. Worth noticing is that here the guard is not strictly needed for correctness, because the wrapped `OptionalStringRules` is length-only and already passes a null (`CommonValidationRules.cs:28-29`); the two fragments are written to the same shape so the file reads uniformly. `[Rubric §16, Maintainability]` assesses consistency: two adjacent optional-field fragments that look identical are cheaper to read and to extend than two that reach the same outcome by different routes. +- **Walkthrough**: the constructor takes an `Expression>` selector (`:78`), note the nullable `string?` here as against the non-nullable selector of its email sibling, compiles it (`:80`), and registers `When(x => !string.IsNullOrWhiteSpace(accessor(x)), () => Include(new OptionalStringRules(selector, "Sponsorship Packet URL", EventInvariants.SponsorshipPacketUrlMaxLength)))` (`:82-83`). The shared base contributes a single `MaximumLength` rule with the message "Sponsorship Packet URL cannot be longer than 2000 characters" (`CommonValidationRules.cs:28-29`). +- **Why it's built this way**: the doc comment gives the product reason (`:69-73`): the field is optional, and an empty value means the landing page and the public sponsor page hide the sponsorship call to action rather than rendering a dead link. Both behaviors are visible in the UI. `PublicSponsorList` renders its "Download Sponsorship Packet" button only when the URL is non-blank (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSponsorList.razor:29-37`, with the value loaded at `PublicSponsorList.razor.cs:61`), and the landing page guards its own sponsorship call to action the same way (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor:310`, `:322`). +- **Where it's used**: `Include`d by [EventCreateRequestValidator](#eventcreaterequestvalidator) (`EventCreateRequestValidator.cs:15`) and [EventUpdateRequestValidator](#eventupdaterequestvalidator) (`EventUpdateRequestValidator.cs:15`), both on `p => p.SponsorshipPacketUrl`. +- **Caveats / not-in-source**: despite the name, nothing here validates that the value **is** a URL: the only constraint is length. A caller can store arbitrary text and the public page will render it as a link target. The shared base also sets no `WithErrorCode`, so this field produces a message without a machine-readable code. + +### EventTicketingUrlRules +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.Validation` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:93` · Level 7 · class (sealed, generic) + +- **What it is**: a rule fragment bounding the event's optional ticketing URL to `EventInvariants.TicketingUrlMaxLength`, 2000 characters (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:40`), and only when a value is supplied. It is the exact twin of its sponsorship sibling, one field over. +- **Depends on**: [OptionalStringRules](group-06-validation.md#optionalstringrulest) (`MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:25-30`) and [EventInvariants](group-17-conference-domain.md#eventinvariants). It inherits `AbstractValidator` directly. +- **Concept**: the conditional-inclusion shape taught on [EventOrganizerContactEmailRules](#eventorganizercontactemailrulest) and repeated verbatim by [EventSponsorshipPacketUrlRules](#eventsponsorshippacketurlrulest). Reading the three optional event fragments in a row (`:57`, `:75`, `:93`) is the clearest illustration of the module's convention: an optional field gets a compiled accessor, a `When` presence guard, and an `Include` of a shared framework fragment, and the only things that vary between them are which framework fragment is wrapped and which invariant constant bounds it. `[Rubric §2, Design Patterns]` assesses whether a recurring shape is expressed as a reusable composition rather than duplicated logic: the wrapping is identical, only the parameters change. +- **Walkthrough**: the constructor takes an `Expression>` selector (`:96`), compiles it to a delegate (`:98`), and registers `When(x => !string.IsNullOrWhiteSpace(accessor(x)), () => Include(new OptionalStringRules(selector, "Ticketing URL", EventInvariants.TicketingUrlMaxLength)))` (`:100-101`). The shared base contributes one `MaximumLength` rule whose message is "Ticketing URL cannot be longer than 2000 characters" (`CommonValidationRules.cs:28-29`). +- **Why it's built this way**: the doc comment states the product reason (`:87-91`): the field is optional, and an empty value means the landing page and the public event page hide the ticketing call to action. Both sites guard on the value being non-blank before rendering a button, the landing page at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor:71-75` and the public event page at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicEventDetail.razor:114-118`, so a blank value is a supported product state rather than a validation failure. The 2000-character bound is the same constant the EF configuration applies to the column (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/EventConfiguration.cs:64-65`). +- **Where it's used**: `Include`d by [EventCreateRequestValidator](#eventcreaterequestvalidator) (`EventCreateRequestValidator.cs:16`) and [EventUpdateRequestValidator](#eventupdaterequestvalidator) (`EventUpdateRequestValidator.cs:16`), both on `p => p.TicketingUrl`. +- **Caveats / not-in-source**: as with the sponsorship URL, nothing validates URL syntax and no `WithErrorCode` is attached, because the wrapped base sets none. Note also that the landing page's pre-conference ticketing button is a separate, hard-coded constant (`ADCHome.razor.cs:30`) and does not flow through this field or this rule. ### EventTimeZoneRules > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.Validation` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:25` · Level 7 · class (sealed, generic) -- **What it is** - a rule fragment for an event's time zone: non-empty, bounded by `EventInvariants.TimeZoneMaxLength` (100 characters, `EventInvariants.cs:19`), and semantically checked to be a time-zone identifier the runtime actually recognizes. The doc comment ties the third rule to business requirement BR-87 (`EventValidationRules.cs:21-22`). -- **Depends on** - [EventInvariants](group-17-conference-domain.md#eventinvariants) and `System.TimeZoneInfo` (BCL). Inherits `AbstractValidator` directly. -- **Concept introduced** - the same fragment shape as [EventNameRules](#eventnamerulest), but it needs a predicate beyond string length, so it extends `AbstractValidator` and adds a `Must(...)` rule. `[Rubric §24 - Forms/Validation/UX Safety]` assesses whether the boundary rejects values the downstream code cannot use: proving the string resolves to a real time zone stops an unusable identifier from reaching scheduling logic. `[Rubric §15 - Best Practices & Code Quality]` assesses defensive detail: the predicate catches only `TimeZoneNotFoundException` (`:44`), so an unexpected failure is not swallowed as "invalid input". -- **Walkthrough** - the constructor chains three rules on one selector: `NotEmpty` with code `Event.TimeZone.Required` (`:30`), `MaximumLength(EventInvariants.TimeZoneMaxLength)` with code `Event.TimeZone.MaxLength` (`:31`), and `Must(BeAValidIanaTimeZone)` with code `Event.TimeZone.InvalidIana` and the message naming `'America/New_York'` as the example form (`:32`). `BeAValidIanaTimeZone` (`:34-48`) returns `true` immediately for null or whitespace (`:36-37`) with the comment that `NotEmpty` already covers that branch, then calls `TimeZoneInfo.FindSystemTimeZoneById(timeZone)` inside a `try` (`:41`) and returns `false` only on `TimeZoneNotFoundException` (`:44-46`). -- **Why it's built this way** - returning `true` for the empty case avoids emitting two messages for one missing field. Delegating the identifier check to `TimeZoneInfo` reuses the platform's canonical time-zone database instead of hand-maintaining a list of identifiers. The domain repeats the same three checks in `EventInvariants.EnsureTimeZoneIsValid` (`EventInvariants.cs:75-93`), so a caller bypassing the validator still cannot persist an unknown zone. -- **Where it's used** - included by [EventCreateRequestValidator](#eventcreaterequestvalidator) (`EventCreateRequestValidator.cs:12`) and [EventUpdateRequestValidator](#eventupdaterequestvalidator) (`EventUpdateRequestValidator.cs:12`), both on `p => p.TimeZone`. -- **Caveats / not-in-source** - `FindSystemTimeZoneById` resolves against the host operating system's time-zone database, so which identifiers are accepted can differ between a Windows developer machine and the Linux containers the services run in. Nothing in the rule pins that behavior, and the message says "IANA" while the lookup is whatever the host supports. +- **What it is**: a rule fragment for an event's time zone: non-empty, bounded by `EventInvariants.TimeZoneMaxLength` (100 characters, `EventInvariants.cs:19`), and semantically checked to be a time-zone identifier the runtime actually recognizes. The doc comment ties the third rule to business requirement BR-87 (`EventValidationRules.cs:21-22`). +- **Depends on**: [EventInvariants](group-17-conference-domain.md#eventinvariants) and `System.TimeZoneInfo` (BCL). It inherits `AbstractValidator` directly. +- **Concept**: the same fragment shape as [EventNameRules](#eventnamerulest), but it needs a predicate beyond string length, so it extends `AbstractValidator` and adds a `Must(...)` rule backed by a private static predicate method. `[Rubric §24, Forms, Validation & UX Safety]` assesses whether the boundary rejects values the downstream code cannot use: proving the string resolves to a real time zone stops an unusable identifier from reaching scheduling logic. `[Rubric §15, Best Practices & Code Quality]` assesses defensive detail: the predicate catches only `TimeZoneNotFoundException` (`:44`), so an unexpected failure surfaces as an exception rather than being silently reported as "invalid input". +- **Walkthrough**: the constructor chains three rules on one selector (`:28-32`): `NotEmpty` with code `Event.TimeZone.Required` (`:30`), `MaximumLength(EventInvariants.TimeZoneMaxLength)` with code `Event.TimeZone.MaxLength` (`:31`), and `Must(BeAValidIanaTimeZone)` with code `Event.TimeZone.InvalidIana` and a message naming `'America/New_York'` as the example form (`:32`). `BeAValidIanaTimeZone` (`:34-48`) returns `true` immediately for null or whitespace (`:36-37`), with an inline comment noting that `NotEmpty` already covers that branch, then calls `TimeZoneInfo.FindSystemTimeZoneById(timeZone)` inside a `try` (`:39-43`) and returns `false` only on `TimeZoneNotFoundException` (`:44-47`). +- **Why it's built this way**: returning `true` for the empty case avoids emitting two messages for one missing field. Delegating the identifier check to `TimeZoneInfo` reuses the platform's canonical time-zone database instead of hand-maintaining a list of identifiers. The domain repeats all three checks in `EventInvariants.EnsureTimeZoneIsValid` (`EventInvariants.cs:78-105`, with the codes `Event.TimeZone.Empty`, `Event.TimeZone.TooLong`, and `Event.TimeZone.Invalid`), so a caller that bypasses the validator still cannot persist an unknown zone. Note that the application-layer codes and the domain-layer codes are deliberately different strings for the same three conditions. +- **Where it's used**: `Include`d by [EventCreateRequestValidator](#eventcreaterequestvalidator) (`EventCreateRequestValidator.cs:12`) and [EventUpdateRequestValidator](#eventupdaterequestvalidator) (`EventUpdateRequestValidator.cs:12`), both on `p => p.TimeZone`. +- **Caveats / not-in-source**: `FindSystemTimeZoneById` resolves against the host operating system's time-zone database, so which identifiers are accepted can differ between a Windows developer machine and the Linux containers the services run in. Nothing in the rule pins that behavior, and the message says "IANA" while the lookup is whatever the host supports. ### RoomAccessibilityInfoRules > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.Validation` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:77` · Level 7 · class (sealed, generic) -- **What it is** - a rule fragment bounding a room's optional accessibility-info text to `EventInvariants.RoomAccessibilityInfoMaxLength`, 500 characters (`EventInvariants.cs:49`). -- **Depends on** - [EventInvariants](group-17-conference-domain.md#eventinvariants). Inherits `AbstractValidator` directly. -- **Concept introduced** - the same fragment pattern as [EventNameRules](#eventnamerulest). The distinguishing detail is optionality: the selector is `Expression>` (`:80`) and the fragment applies `MaximumLength` only, with no `NotEmpty`, so a null value passes. Note the contrast with [EventSponsorshipPacketUrlRules](#eventsponsorshippacketurlrulest), which reaches the same outcome through a shared fragment behind a `When` guard: this one simply writes the single rule locally. -- **Walkthrough** - one constructor, one rule: `MaximumLength(EventInvariants.RoomAccessibilityInfoMaxLength)` with the message "Accessibility Info cannot be longer than 500 characters" and error code `Room.AccessibilityInfo.MaxLength` (`:80-82`). -- **Why it's built this way** - accessibility notes are free text an organizer may not have yet, so absence is valid; only the length is constrained, using the same constant the domain and the persistence configuration share. -- **Where it's used** - included by [AddRoomCommandValidator](#addroomcommandvalidator) (`AddRoomCommandValidator.cs:16`) and [UpdateRoomCommandValidator](#updateroomcommandvalidator) (`UpdateRoomCommandValidator.cs:16`), both on `p => p.AccessibilityInfo`. +- **What it is**: a rule fragment bounding a room's optional accessibility-info text to `EventInvariants.RoomAccessibilityInfoMaxLength`, 500 characters (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:52`). +- **Depends on**: [EventInvariants](group-17-conference-domain.md#eventinvariants). It inherits `AbstractValidator` directly. +- **Concept**: the module-local fragment idiom taught on [ActivityEventIdRules](#activityeventidrulest). The distinguishing detail is optionality expressed **in the selector type**: `Expression>` (`:80`), with `MaximumLength` only and no `NotEmpty`, so a null value passes without a guard. Contrast [EventSponsorshipPacketUrlRules](#eventsponsorshippacketurlrulest), which reaches the same outcome by wrapping a shared framework fragment in a `When` guard: this one simply writes the single rule locally, which is also what buys it an error code. +- **Walkthrough**: one constructor (`:80`), one rule: `MaximumLength(EventInvariants.RoomAccessibilityInfoMaxLength)` with the message "Accessibility Info cannot be longer than 500 characters" and the error code `Room.AccessibilityInfo.MaxLength` (`:81-82`). +- **Why it's built this way**: accessibility notes are free text an organizer may not have yet, so absence is valid; only the length is constrained, using the same constant the domain and the persistence configuration share. +- **Where it's used**: `Include`d by [AddRoomCommandValidator](#addroomcommandvalidator) (`AddRoomCommandValidator.cs:16`) and [UpdateRoomCommandValidator](#updateroomcommandvalidator) (`UpdateRoomCommandValidator.cs:16`), both on `p => p.AccessibilityInfo`. ### RoomFloorRules > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.Validation` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:51` · Level 7 · class (sealed, generic) -- **What it is** - a rule fragment bounding a room's optional floor label to `EventInvariants.RoomFloorMaxLength`, 100 characters (`EventInvariants.cs:43`). -- **Depends on** - [EventInvariants](group-17-conference-domain.md#eventinvariants). Inherits `AbstractValidator` directly. -- **Concept introduced** - structurally identical to [RoomAccessibilityInfoRules](#roomaccessibilityinforulest): a nullable `string?` selector, `MaximumLength` only, no `NotEmpty`. -- **Walkthrough** - one `MaximumLength(EventInvariants.RoomFloorMaxLength)` rule with error code `Room.Floor.MaxLength` (`:54-56`). -- **Why it's built this way** - a floor is a label ("2", "Mezzanine"), not a required attribute of a room, so the fragment constrains only its length. -- **Where it's used** - included by [AddRoomCommandValidator](#addroomcommandvalidator) (`AddRoomCommandValidator.cs:14`) and [UpdateRoomCommandValidator](#updateroomcommandvalidator) (`UpdateRoomCommandValidator.cs:14`), both on `p => p.Floor`. +- **What it is**: a rule fragment bounding a room's optional floor label to `EventInvariants.RoomFloorMaxLength`, 100 characters (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:46`). +- **Depends on**: [EventInvariants](group-17-conference-domain.md#eventinvariants). It inherits `AbstractValidator` directly. +- **Concept**: structurally identical to [RoomAccessibilityInfoRules](#roomaccessibilityinforulest): a nullable `string?` selector, `MaximumLength` only, no `NotEmpty`. +- **Walkthrough**: one constructor (`:54`) and one `MaximumLength(EventInvariants.RoomFloorMaxLength)` rule with the error code `Room.Floor.MaxLength` (`:55-56`). +- **Why it's built this way**: a floor is a label ("2", "Mezzanine"), not a required attribute of a room, so the fragment constrains only its length. +- **Where it's used**: `Include`d by [AddRoomCommandValidator](#addroomcommandvalidator) (`AddRoomCommandValidator.cs:14`) and [UpdateRoomCommandValidator](#updateroomcommandvalidator) (`UpdateRoomCommandValidator.cs:14`), both on `p => p.Floor`. ### RoomLocationRules > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.Validation` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:64` · Level 7 · class (sealed, generic) -- **What it is** - a rule fragment bounding a room's optional location text to `EventInvariants.RoomLocationMaxLength`, 255 characters (`EventInvariants.cs:46`). -- **Depends on** - [EventInvariants](group-17-conference-domain.md#eventinvariants). Inherits `AbstractValidator` directly. -- **Concept introduced** - structurally identical to [RoomFloorRules](#roomfloorrulest): a nullable selector and a single `MaximumLength` rule. Reading the four optional room fragments together shows why the family exists at all: each is three lines, and the only things that vary are the constant, the message noun, and the error code. -- **Walkthrough** - one `MaximumLength(EventInvariants.RoomLocationMaxLength)` rule with error code `Room.Location.MaxLength` (`:67-69`). -- **Where it's used** - included by [AddRoomCommandValidator](#addroomcommandvalidator) (`AddRoomCommandValidator.cs:15`) and [UpdateRoomCommandValidator](#updateroomcommandvalidator) (`UpdateRoomCommandValidator.cs:15`), both on `p => p.Location`. +- **What it is**: a rule fragment bounding a room's optional location text to `EventInvariants.RoomLocationMaxLength`, 255 characters (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:49`). +- **Depends on**: [EventInvariants](group-17-conference-domain.md#eventinvariants). It inherits `AbstractValidator` directly. +- **Concept**: structurally identical to [RoomFloorRules](#roomfloorrulest): a nullable selector and a single `MaximumLength` rule. Reading the three optional room fragments together (`:51`, `:64`, `:77`) shows why the family exists at all: each is three lines, and the only things that vary are the invariant constant, the message noun, and the error code. `[Rubric §16, Maintainability]` assesses whether a constraint lives in exactly one place; splitting per field is what lets a room validator compose the required-plus-optional mix it actually needs. +- **Walkthrough**: one constructor (`:67`) and one `MaximumLength(EventInvariants.RoomLocationMaxLength)` rule with the error code `Room.Location.MaxLength` (`:68-69`). +- **Where it's used**: `Include`d by [AddRoomCommandValidator](#addroomcommandvalidator) (`AddRoomCommandValidator.cs:15`) and [UpdateRoomCommandValidator](#updateroomcommandvalidator) (`UpdateRoomCommandValidator.cs:15`), both on `p => p.Location`. ### RoomNameRules > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.Validation` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:12` · Level 7 · class (sealed, generic) -- **What it is** - a rule fragment for a room's name: non-empty and bounded by `EventInvariants.RoomNameMaxLength`, 255 characters (`EventInvariants.cs:40`). It is the one required field among the room fragments. -- **Depends on** - [EventInvariants](group-17-conference-domain.md#eventinvariants). Inherits `AbstractValidator` directly. -- **Concept introduced** - the same fragment pattern as [EventNameRules](#eventnamerulest), but written out on `AbstractValidator` rather than derived from [RequiredStringRules](group-06-validation.md#requiredstringrulest), even though the shape matches. Writing it locally is what buys the two `WithErrorCode` values the shared base does not set. `[Rubric §9 - API & Contract Design]` assesses the stability of the error contract clients consume: `Room.Name.Required` and `Room.Name.MaxLength` are stable machine-readable codes alongside the human message. -- **Walkthrough** - the constructor chains `NotEmpty` with message "You must enter a Room Name" and code `Room.Name.Required` (`:17`), then `MaximumLength(EventInvariants.RoomNameMaxLength)` with code `Room.Name.MaxLength` (`:18`), on the single `Expression>` selector (`:15`). -- **Why it's built this way** - the required name distinguishes this fragment from the three optional room fields; keeping each as its own fragment lets a command validator compose exactly the required-plus-optional mix it needs, which is what the two room validators do line by line. -- **Where it's used** - included by [AddRoomCommandValidator](#addroomcommandvalidator) (`AddRoomCommandValidator.cs:11`) and [UpdateRoomCommandValidator](#updateroomcommandvalidator) (`UpdateRoomCommandValidator.cs:11`), both on `p => p.Name`. -- **Caveats / not-in-source** - the length rule duplicates a domain-side check: `EventInvariants.EnsureRoomNameIsValid` enforces the same constant with error code `Room.Name.TooLong` (`EventInvariants.cs:137-140`). The application fragment gives a fast, field-attributed failure; the domain invariant is the backstop that also fires for callers that bypass the validator. +- **What it is**: a rule fragment for a room's name: non-empty and bounded by `EventInvariants.RoomNameMaxLength`, 255 characters (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:43`). It is the one required field among the room fragments. +- **Depends on**: [EventInvariants](group-17-conference-domain.md#eventinvariants). It inherits `AbstractValidator` directly. +- **Concept**: the same shape as [EventNameRules](#eventnamerulest), but written out on `AbstractValidator` rather than derived from [RequiredStringRules](group-06-validation.md#requiredstringrulest), even though the base would fit. Writing the chain locally is precisely what buys the two `WithErrorCode` values the shared base does not set, so the two sibling "name" fragments in this module are a clean illustration of the trade-off between inheriting a framework fragment and writing three lines by hand. `[Rubric §9, API & Contract Design]` assesses the stability of the error contract clients consume: `Room.Name.Required` and `Room.Name.MaxLength` are stable machine-readable codes alongside the human message, whereas the event name yields a message only. +- **Walkthrough**: the constructor takes a single `Expression>` selector (`:15`) and chains `NotEmpty` with the message "You must enter a Room Name" and the code `Room.Name.Required` (`:16-17`), then `MaximumLength(EventInvariants.RoomNameMaxLength)` with the code `Room.Name.MaxLength` (`:18`). +- **Why it's built this way**: the required name is what distinguishes this fragment from the three optional room fields; keeping each field as its own fragment lets a command validator compose exactly the mix it needs, which is what the two room validators do line by line. +- **Where it's used**: `Include`d by [AddRoomCommandValidator](#addroomcommandvalidator) (`AddRoomCommandValidator.cs:11`) and [UpdateRoomCommandValidator](#updateroomcommandvalidator) (`UpdateRoomCommandValidator.cs:11`), both on `p => p.Name`. +- **Caveats / not-in-source**: the length rule duplicates a domain-side check: `EventInvariants.EnsureRoomNameIsValid` enforces the same constant with the codes `Room.Name.Empty` and `Room.Name.TooLong` (`EventInvariants.cs:140-143`). The application fragment gives a fast, field-attributed failure; the domain invariant is the backstop that also fires for callers that bypass the validator. ### SpeakerLocalityHelper > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/SpeakerLocalityHelper.cs:21` · Level 8 · class (internal static) -- **What it is** - the small pure helper that answers "where is this speaker traveling from?" by reading the speaker's category assignments. It finds the locality categories among all loaded categories, flattens their items into one lookup, and resolves a speaker to a single tier name such as "Atlanta and Suburbs" or "Not North America" (`SpeakerLocalityHelper.cs:17-19`). -- **Depends on** - [Category](group-17-conference-domain.md#category) and [CategoryItem](group-17-conference-domain.md#categoryitem) (via `Category.CategoryItems`), [Speaker](group-17-conference-domain.md#speaker) and its [SpeakerCategoryItem](group-17-conference-domain.md#speakercategoryitem) collection, [LocalityLookupEntry](#localitylookupentry) as its dictionary value type, and the `CategoryItemIdentifierType` / `ConferenceCategoryIdentifierType` aliases. Nothing external beyond the BCL; no repository, no `IUnitOfWork`, no logging. -- **Concept introduced - the pure in-memory helper beside a handler.** Both decision-support handlers already load [Category](group-17-conference-domain.md#category) and [Speaker](group-17-conference-domain.md#speaker) graphs for other reasons, so the locality question is answered from data already in memory rather than by another query. Making the helper `static` with no injected dependencies means it is exercised directly in unit tests with hand-built aggregates and no test double at all. `[Rubric §14 - Testability]` assesses whether logic can be tested without infrastructure: `SpeakerLocalityHelperTests` constructs speakers and categories in-process and asserts against the four public methods (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/SpeakerLocalityHelperTests.cs:60`, `:154-157`, `:174`, `:186`). `[Rubric §12 - Performance & Scalability]` assesses repeated work: the expensive step (scanning every category's items) happens once per request in `BuildLocalityLookup`, and the per-speaker step is a dictionary probe. `[Rubric §8 - Data Architecture]` assesses how the model's shape drives the code: because [Category](group-17-conference-domain.md#category) has no event scoping, correctness here depends on merging categories across imports rather than picking one. -- **Walkthrough** - members in teaching order: - - `KnownLocalityCategoryId` (`:27`), a `private const` holding `121854`, the Sessionize identifier of the original "Where are you traveling from" category. It is the fallback used only when no category title matches the heuristic (`:23-26`). - - `FindLocalityCategories(IEnumerable categories)` (`:88`) walks every category, skipping soft-deleted ones (`:95-96`), and collects those whose `Title` contains "traveling" or "Where are you based", case-insensitively (`:98-101`). A category that matches neither but carries the known id is remembered as `fallback` (`:103-104`). If nothing matched by title, it returns the fallback as a single-element list or an empty list (`:109-110`); otherwise it sorts the matches by ascending id and returns them (`:112-113`). +- **What it is**: the small pure helper that answers "where is this speaker traveling from?" by reading the speaker's category assignments. It finds the locality categories among all loaded categories, flattens their items into one lookup, and resolves a speaker to a single tier name such as "Atlanta and Suburbs" or "Not North America" (`SpeakerLocalityHelper.cs:17-19`). +- **Depends on**: [Category](group-17-conference-domain.md#category) and [CategoryItem](group-17-conference-domain.md#categoryitem) (through `Category.CategoryItems`), [Speaker](group-17-conference-domain.md#speaker) and its [SpeakerCategoryItem](group-17-conference-domain.md#speakercategoryitem) collection, [LocalityLookupEntry](#localitylookupentry) as its dictionary value type, and the `CategoryItemIdentifierType` / `ConferenceCategoryIdentifierType` aliases (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:6-7`). Nothing external beyond the BCL: no repository, no `IUnitOfWork`, no logger. +- **Concept, the pure in-memory helper beside a handler.** Both decision-support handlers already load [Category](group-17-conference-domain.md#category) and [Speaker](group-17-conference-domain.md#speaker) graphs for other reasons, so the locality question is answered from data already in memory rather than by another query. Making the helper `static` with no injected dependencies means it is exercised directly in unit tests with hand-built aggregates and no test double at all. `[Rubric §14, Testability]` assesses whether logic can be tested without infrastructure: `SpeakerLocalityHelperTests` constructs speakers and categories in-process and asserts against all four public methods (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/SpeakerLocalityHelperTests.cs:60`, `:154-157`, `:174`, `:186`). `[Rubric §12, Performance & Scalability]` assesses repeated work: the expensive step (scanning every category's items) happens once per request in `BuildLocalityLookup`, and the per-speaker step is a dictionary probe. `[Rubric §8, Data Architecture]` assesses how the model's shape drives the code: because [Category](group-17-conference-domain.md#category) has no event scoping, correctness here depends on merging categories across imports rather than picking one. +- **Walkthrough**, members in teaching order: + - `KnownLocalityCategoryId` (`:27`), a `private const` holding `121854`, the Sessionize identifier of the original "Where are you traveling from" category. It is the fallback used **only** when no category title matches the heuristic (`:23-26`). + - `FindLocalityCategories(IEnumerable categories)` (`:88`) walks every category, skipping soft-deleted ones (`:95-96`), and collects those whose `Title` contains "traveling" or "Where are you based", case-insensitively (`:98-102`). A category that matches neither but carries the known id is remembered as `fallback` (`:103-106`). If nothing matched by title it returns the fallback as a single-element list, or an empty list (`:109-110`); otherwise it sorts the matches by ascending id and returns them (`:112-113`). - `BuildLocalityLookup(IEnumerable localityCategories)` (`:123`) iterates those categories in ascending id order (`:128`) and writes `lookup[item.Id] = new LocalityLookupEntry(item.Name, category.Id)` for every non-deleted item (`:130-135`). Ascending order is what makes the last write win, so a colliding item id resolves to the newest import (`:116-119`). - - `GetLocalityTier(Speaker speaker, IReadOnlyDictionary<...> localityCategoryItems)` (`:36`) scans the speaker's `SpeakerCategoryItems`, skipping soft-deleted assignments (`:45-46`) and assignments whose item is not in the lookup (`:48-49`), and keeps the entry with the highest `CategoryId` seen (`:53-57`). It returns `null` when the speaker has no locality assignment at all (`:40`, `:60`). + - `GetLocalityTier(Speaker speaker, IReadOnlyDictionary localityCategoryItems)` (`:36-38`) scans the speaker's `SpeakerCategoryItems`, skipping soft-deleted assignments (`:45-46`) and assignments whose item is not in the lookup (`:48-49`), and keeps the entry with the highest `CategoryId` seen (`:53-57`). It returns `null` when the speaker has no locality assignment at all (`:40`, `:60`). - `IsLocalSpeaker(string? localityTier)` (`:69`) returns `false` for null (`:71-72`) and otherwise reports whether the tier name contains "Atlanta", "Georgia", or "Surrounding", case-insensitively (`:74-76`). -- **Why it's built this way** - the regression this shape exists to fix is spelled out in the doc comment at `:82-84` and in the test names: returning only the first (oldest) matching category left every speaker new to the current event resolving to no tier, because each yearly Sessionize refresh creates a fresh locality category with fresh item ids (`SpeakerLocalityHelperTests.cs:126-141`). Merging every locality category into one lookup, and breaking ties by highest owning category id, makes both a newcomer and a returning speaker resolve to their current-year answer (`:144-158`). The title heuristic with an id fallback keeps the code working across two different question wordings without a configuration entry. -- **Where it's used** - [GetSessionSelectionDashboardHandler](#getsessionselectiondashboardhandler) calls `FindLocalityCategories` and `BuildLocalityLookup` once (`GetSessionSelectionDashboardHandler.cs:75-76`) and `GetLocalityTier` in three projection passes (`:224`, `:286`, `:405`, the last two defaulting a null tier to "Unknown"); [GetSpeakerSessionOverlapHandler](#getspeakersessionoverlaphandler) does the same for its narrower view (`GetSpeakerSessionOverlapHandler.cs:52-53`, `:119`). -- **Caveats / not-in-source** - two things are worth flagging. First, `IsLocalSpeaker` has no caller under `MMCA.ADC/Source`: the only references are its declaration (`:69`) and the theory in `SpeakerLocalityHelperTests.cs:161-177`, so the "local speaker" notion is defined and tested but not yet consumed by a handler. Second, the tier match is substring-based, so any future tier name containing "Georgia" or "Surrounding" would be classified local without a code change; nothing in source constrains the set of tier names that Sessionize can produce. +- **Why it's built this way**: the regression this shape exists to fix is spelled out in the doc comment at `:82-84` and in the test names: returning only the first (oldest) matching category left every speaker new to the current event resolving to no tier, because each yearly Sessionize refresh creates a fresh locality category with fresh item ids (`SpeakerLocalityHelperTests.cs:126-141`). Merging every locality category into one lookup, and breaking ties by highest owning category id, makes both a newcomer and a returning speaker resolve to their current-year answer (`:144-158`). The title heuristic with an id fallback keeps the code working across two different question wordings without a configuration entry. +- **Where it's used**: [GetSessionSelectionDashboardHandler](#getsessionselectiondashboardhandler) calls `FindLocalityCategories` and `BuildLocalityLookup` once (`GetSessionSelectionDashboardHandler.cs:75-76`) and `GetLocalityTier` in three projection passes (`:224`, `:286`, `:405`, the last two defaulting a null tier to "Unknown"); [GetSpeakerSessionOverlapHandler](#getspeakersessionoverlaphandler) does the same for its narrower view (`GetSpeakerSessionOverlapHandler.cs:52-53`, `:119`). +- **Caveats / not-in-source**: two things are worth flagging. First, `IsLocalSpeaker` has no caller anywhere under `MMCA.ADC/Source`: the only references are its declaration (`:69`) and the theory in `SpeakerLocalityHelperTests.cs:161-177`, so the "local speaker" notion is defined and tested but not yet consumed by a handler. Second, the tier match is substring-based, so any future tier name containing "Georgia" or "Surrounding" would be classified local without a code change; nothing in source constrains the set of tier names Sessionize can produce. -### GetCategoryDistributionHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetCategoryDistribution` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetCategoryDistribution/GetCategoryDistributionHandler.cs:14` · Level 9 · class (sealed) +### GetCategoryDistributionQuery +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetCategoryDistribution` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetCategoryDistribution/GetCategoryDistributionQuery.cs:5` · Level 0 · record (sealed) -- **What it is** - the query handler that computes, per category item, how many of an event's sessions were submitted, accepted, put in the accept queue, or left pending. It backs the organizer's category-distribution view during session selection. -- **Depends on** - [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) (implemented) and [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) (the only constructor dependency, `:14-15`); [Session](group-17-conference-domain.md#session) and [Category](group-17-conference-domain.md#category) as repository entities plus [CategoryItem](group-17-conference-domain.md#categoryitem) through their collections; [SessionStatuses](group-17-conference-domain.md#sessionstatuses) for the status constants; the nested [StatusBucket](#statusbucket) enum; and the output contracts [CategoryDistributionDTO](group-17-conference-domain.md#categorydistributiondto), [CategoryGroupDistribution](group-17-conference-domain.md#categorygroupdistribution), and [CategoryItemDistribution](group-17-conference-domain.md#categoryitemdistribution) from `MMCA.ADC.Conference.Shared.Sessions.DecisionSupport` (`:3`). -- **Concept introduced - the in-memory analytics read handler.** It loads two aggregate sets untracked and then does all filtering, bucketing, and grouping in C#, rather than pushing aggregation down into SQL. `[Rubric §6 - CQRS & Event-Driven]` assesses the read path: this is a pure query returning `Result` and mutating nothing, so it is wrapped only by the query-side decorators the framework registers (Caching, Logging, FeatureGate, plus Profiling when enabled), never by the command-side Validating or Transactional ones (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:94-103`, `:222`). `[Rubric §12 - Performance & Scalability]` assesses read efficiency: both loads pass `asTracking: false` (`:27`, `:32`) so EF skips change tracking, and the tally is a single pass into a dictionary rather than a nested scan (`:50-61`). `[Rubric §5 - Vertical Slice]` assesses feature cohesion: query, handler, and private bucketing live in one `GetCategoryDistribution` folder, so the whole feature is readable in one place. -- **Walkthrough** - `HandleAsync` (`:17-39`) resolves a [Session](group-17-conference-domain.md#session) repository and a [Category](group-17-conference-domain.md#category) repository from the unit of work (`:21-22`). It loads the event's sessions with `SessionCategoryItems` included, filtered to `s.EventId == query.EventId && !s.IsServiceSession` (`:24-28`), then loads every category with its `CategoryItems` (`:30-33`), with no event filter because categories are global. `CountSessionsPerCategoryItem` (`:41-64`) drops declined sessions via `IsDeclined` (`:45`, `:114-115`), flattens each remaining session into `(CategoryItemId, StatusBucket)` pairs while skipping soft-deleted links (`:46-48`), and folds those pairs into a `(Total, Accepted, AcceptQueue, Pending)` tuple per category item (`:50-61`). `ClassifyStatus` (`:101-112`) treats a null status or `SessionStatuses.Accepted` as `Accepted`, `SessionStatuses.AcceptQueue` as `AcceptQueue`, and anything else as `Pending`, all with `OrdinalIgnoreCase` comparison. `BuildCategoryGroups` (`:66-92`) keeps only non-deleted categories that have at least one counted item (`:70`), orders categories by `Sort` (`:71`) and items by `Sort` (`:78`), and projects each item into a `CategoryItemDistribution` with its four counts, using `TryGetValue` so an uncounted item yields zeros (`:81-90`). The handler returns `Result.Success(new CategoryDistributionDTO { Categories = categoryGroups })` (`:38`): it has no failure path. -- **Why it's built this way** - aggregating in memory keeps the handler engine-agnostic (the same code runs against whatever store backs `IUnitOfWork`, per the database-per-service model of ADR-006) and states the domain rules (service sessions excluded, declined excluded, soft-deletes excluded at both the session-link and category-item levels) as readable filters instead of burying them in SQL. The trade-off is that a full event's sessions and the full category set are materialized; that is bounded by one conference's proposal volume. -- **Where it's used** - injected into [SessionSelectionController](group-20-conference-api-grpc.md#sessionselectioncontroller) as `IQueryHandler>` (`SessionSelectionController.cs:31`) and invoked from `GET SessionSelection/categories/{eventId}` (`:53-64`), an organizer-only endpoint (`:28`) whose response is output-cached under the `ConferenceCache` policy (`:54`). -- **Caveats / not-in-source** - a null `Session.Status` is counted as `Accepted` (`:103-107`), so a session whose status was never set inflates the accepted column rather than the pending one. The branch is explicit in code, but the reason for choosing `Accepted` over `Pending` as the null default is not stated there. +- **What it is** - the CQRS query contract that asks for the distribution of an event's sessions across its category items. A one-line record carrying nothing but the event to analyze. +- **Depends on** - the `EventIdentifierType` alias, an `int` in this module (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8`). No other first-party types, nothing external. +- **Concept introduced** - this is a plain read-side CQRS request; the query/handler split is taught by [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult), so it is cross-referenced rather than re-taught here. Note that the record implements no marker interface: the pairing to a handler is purely the generic argument on `IQueryHandler>` (`GetCategoryDistributionHandler.cs:15`). `[Rubric §6 - CQRS & Event-Driven]` assesses whether reads and writes travel separate paths with explicit contracts: this record is a read intent with no side effects, resolved by [GetCategoryDistributionHandler](#getcategorydistributionhandler). +- **Walkthrough** - one positional parameter, `EventId` of type `EventIdentifierType` (`:5`), documented by the two-line summary above it (`:3-4`). No body, no defaults. +- **Why it's built this way** - keeping the query as a standalone record means it can be dispatched on its own (an organizer opening the category-distribution view) or computed alongside the other decision-support dimensions by [GetSessionSelectionDashboardHandler](#getsessionselectiondashboardhandler) without one endpoint over-fetching for another. +- **Where it's used** - constructed by [SessionSelectionController](group-20-conference-api-grpc.md#sessionselectioncontroller) on `GET SessionSelection/categories/{eventId}` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:53-65`), which resolves the handler through the injected `IQueryHandler>` (`:32`). ### GetContentSimilarityQuery -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetContentSimilarity` · `MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetContentSimilarity/GetContentSimilarityQuery.cs:6` · Level 0 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetContentSimilarity` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetContentSimilarity/GetContentSimilarityQuery.cs:6` · Level 0 · record (sealed) -- **What it is**: the read request that asks, for one event, which pairs of submitted sessions cover similar content, so an organizer can spot proposals that would compete for the same audience. A two-parameter `sealed record` (`GetContentSimilarityQuery.cs:6`). -- **Depends on**: the `EventIdentifierType` alias (see [identifier aliases](00-primer.md#2-architectural-styles-this-codebase-commits-to)) and a BCL `double`. No first-party types. -- **Concept introduced: the request record as a CQRS message.** Every decision-support use case in this folder is a positional `sealed record` naming exactly the inputs its handler needs and nothing else, matched to its handler by generic argument and dispatched through the [CQRS decorator pipeline](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult). The folder layout reinforces it: query, handler, and (here) the calculator that does the math all live in one `GetContentSimilarity/` directory, so a use case is a folder, not a scattering of files across a service, a DTO namespace, and a helper class. [Rubric §5, Vertical Slice] assesses exactly that co-location, and [Rubric §6, CQRS and Event-Driven] assesses whether read intent is modeled as a named message rather than a method-parameter bag. -- **Walkthrough**: two positional parameters (`:6`). `EventId` is the event to analyze (`:4`), and `MinimumSimilarity` is a `double` **defaulting to `0.3`** (`:5`), the floor a pair's score must clear to appear in the result. It is the only parameter default among the decision-support request records. -- **Why it's built this way**: exposing the threshold as a defaulted parameter lets a caller loosen or tighten the floor per request while the common case (an organizer opening the view) needs no argument at all. Keep in mind that the `0.3` floor lives here, but the weights that produce the number it is compared against live in [SessionSimilarityCalculator](#sessionsimilaritycalculator); the two have to be read together to reason about what actually surfaces. -- **Where it's used**: injected as `IQueryHandler>` into [SessionSelectionController](group-20-conference-api-grpc.md#sessionselectioncontroller) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:33`) and constructed by its `GET SessionSelection/content-similarity/{eventId}` action (`:81-93`, construction at `:89`); handled by [GetContentSimilarityHandler](#getcontentsimilarityhandler), returning a [ContentSimilarityDTO](group-17-conference-domain.md#contentsimilaritydto). -- **Caveats / not-in-source**: the `0.3` default is written twice, once on this record (`:6`) and once as the action's own optional parameter default (`SessionSelectionController.cs:85`). Nothing links them, so changing one alone would silently leave the other in force for callers that omit the argument. +- **What it is** - the read request behind "which pairs of submitted sessions look like the same talk". It carries the event to analyze plus the score floor below which a pair is not worth showing. +- **Depends on** - the `EventIdentifierType` alias (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8`) and a BCL `double`. No first-party types. +- **Concept introduced - the query that carries a tuning knob.** The three sibling decision-support queries are pure identity ("analyze this event"); this one also carries a policy value, `MinimumSimilarity`, with an in-contract default of `0.3` (`:6`). `[Rubric §9 - API & Contract Design]` assesses whether a contract's optional inputs are explicit and defaulted in one place: here the default is stated twice, once on the record (`:6`) and once on the controller action parameter (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:86`), so an HTTP caller that omits `minimumSimilarity` never exercises the record's default at all. `[Rubric §6 - CQRS & Event-Driven]` applies as for the sibling queries: a named read message resolved by exactly one [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult). +- **Walkthrough** - two positional members (`:6`): `EventId`, and `MinimumSimilarity` defaulted to `0.3`. The doc comment documents the intended range as 0.0 to 1.0 (`:5`). The value is used exactly once, as an inclusive lower bound in [GetContentSimilarityHandler](#getcontentsimilarityhandler) (`GetContentSimilarityHandler.cs:74`, `score >= query.MinimumSimilarity`). +- **Why it's built this way** - similarity is a judgment call, not a fact: an organizer sweeping for near-duplicate submissions wants a different floor than one looking only at blatant overlaps. Putting the knob on the query rather than in configuration lets the caller choose per request without a redeploy. +- **Where it's used** - constructed by [SessionSelectionController](group-20-conference-api-grpc.md#sessionselectioncontroller) on `GET SessionSelection/content-similarity/{eventId}` (`SessionSelectionController.cs:81-94`), binding `minimumSimilarity` from the query string (`:86`). +- **Caveats / not-in-source** - the 0.0 to 1.0 range is documentation only. There is no validator for this query (the `GetContentSimilarity` folder holds only the query, the handler, and [SessionSimilarityCalculator](#sessionsimilaritycalculator)), and the query-side decorator chain registers no validating decorator: validation is command-side only (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:117` for commands versus `:124-128` for queries). A caller passing `5.0` therefore gets an empty pair list, and a negative floor returns every pair up to the handler's cap, both silently. ### GetSessionSelectionDashboardQuery -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetSessionSelectionDashboard` · `MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboard/GetSessionSelectionDashboardQuery.cs:5` · Level 0 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetSessionSelectionDashboard` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboard/GetSessionSelectionDashboardQuery.cs:5` · Level 0 · record (sealed) -- **What it is**: the read request for the composite session-selection dashboard: given one `EventId`, produce summary counts, category distribution, speaker overlap, speaker locality, and the AI-score table in a single result. A one-field `sealed record` (`GetSessionSelectionDashboardQuery.cs:5`). -- **Depends on**: the `EventIdentifierType` alias. No externals beyond the BCL. -- **Concept introduced**: none new; the same request-record shape taught under [GetContentSimilarityQuery](#getcontentsimilarityquery). [Rubric §6, CQRS and Event-Driven]. -- **Walkthrough**: a single `EventId` positional parameter (`:5`), documented as "the event to analyze" (`:4`); compiler-generated value equality and `init` immutability come free from `record`. -- **Why it's built this way**: a composite query (one message, one round trip) is the decision-support answer to running four separate analytics queries against the same session set: see [GetSessionSelectionDashboardHandler](#getsessionselectiondashboardhandler). -- **Where it's used**: injected into [SessionSelectionController](group-20-conference-api-grpc.md#sessionselectioncontroller) (`SessionSelectionController.cs:30`) and constructed by its `GET SessionSelection/dashboard/{eventId}` action (`:39-50`, construction at `:46`); handled by [GetSessionSelectionDashboardHandler](#getsessionselectiondashboardhandler), returning a [SessionSelectionDashboardDTO](group-17-conference-domain.md#sessionselectiondashboarddto). +- **What it is** - the read request for the whole session-selection screen in one call: summary counts, category distribution, speaker overlap, speaker locality, and AI scores for one event. +- **Depends on** - the `EventIdentifierType` alias (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8`). Nothing else. +- **Concept introduced - the composite (screen-shaped) query.** The other three decision-support queries each answer one question; this one is deliberately shaped like the UI page rather than like a single analytical question, and its handler computes all four dimensions from one set of loads (`GetSessionSelectionDashboardHandler.cs:12-14`). `[Rubric §9 - API & Contract Design]` assesses whether the contract fits its consumer: one round trip for a screen that would otherwise need four, at the cost of a response the narrower endpoints do not need. `[Rubric §12 - Performance & Scalability]` assesses request economy: the sessions, categories, and speakers are read once and reused across every computed block instead of four times. +- **Walkthrough** - one positional parameter, `EventId` (`:5`), with the summary and parameter doc above it (`:3-4`). Identical in shape to [GetCategoryDistributionQuery](#getcategorydistributionquery) and [GetSpeakerSessionOverlapQuery](#getspeakersessionoverlapquery): the difference is entirely in the handler's breadth. +- **Why it's built this way** - the Blazor page is the only consumer that needs all four dimensions, and it needs them consistent with each other. Answering them from one snapshot of loaded data means the counts on the page cannot disagree between panels. +- **Where it's used** - constructed by [SessionSelectionController](group-20-conference-api-grpc.md#sessionselectioncontroller) on `GET SessionSelection/dashboard/{eventId}` (`SessionSelectionController.cs:39-51`). That endpoint is the one the UI actually calls: [SessionSelectionService](group-21-conference-ui.md#sessionselectionservice) requests `sessionselection/dashboard/{eventId}` and deserializes [SessionSelectionDashboardDTO](group-17-conference-domain.md#sessionselectiondashboarddto) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/SessionSelectionService.cs:16-30`), and calls none of the three narrow endpoints. ### GetSpeakerSessionOverlapQuery -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetSpeakerSessionOverlap` · `MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSpeakerSessionOverlap/GetSpeakerSessionOverlapQuery.cs:5` · Level 0 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetSpeakerSessionOverlap` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSpeakerSessionOverlap/GetSpeakerSessionOverlapQuery.cs:5` · Level 0 · record (sealed) -- **What it is**: the read request that asks, for one event, which speakers submitted sessions and how many. A one-field `sealed record` carrying the `EventId` (`GetSpeakerSessionOverlapQuery.cs:5`). -- **Depends on**: the `EventIdentifierType` alias. No externals. -- **Concept introduced**: none new; the same request-record shape as [GetContentSimilarityQuery](#getcontentsimilarityquery). [Rubric §6, CQRS and Event-Driven]. -- **Walkthrough**: a single `EventId` positional parameter (`:5`). -- **Why it's built this way**: speaker overlap is one focused slice of the dashboard, exposed on its own query so the UI can request just that view without paying for the composite load. -- **Where it's used**: injected into [SessionSelectionController](group-20-conference-api-grpc.md#sessionselectioncontroller) (`SessionSelectionController.cs:32`) and constructed by its `GET SessionSelection/speaker-overlap/{eventId}` action (`:67-78`, construction at `:74`); handled by [GetSpeakerSessionOverlapHandler](#getspeakersessionoverlaphandler), returning a [SpeakerSessionOverlapDTO](group-17-conference-domain.md#speakersessionoverlapdto). -- **Caveats / not-in-source**: the doc comment frames the query as "speakers with multiple submitted sessions" (`:3`) and the controller action repeats that (`SessionSelectionController.cs:66`), but the handler in fact returns **every** submitting speaker, sorted so multi-session ones surface first ([GetSpeakerSessionOverlapHandler](#getspeakersessionoverlaphandler), doc comment `:11-17`). The comments are stale relative to the code. +- **What it is** - the read request for the speaker-centric view of an event's submissions: who submitted what, with the multi-session speakers surfaced first. +- **Depends on** - the `EventIdentifierType` alias (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8`). Nothing else. +- **Concept introduced** - none new; it is the same one-field read message as [GetCategoryDistributionQuery](#getcategorydistributionquery), and the request-record concept is taught there. `[Rubric §5 - Vertical Slice]` assesses feature cohesion: this record sits in the same `GetSpeakerSessionOverlap` folder as its handler, so the whole capability is one directory. +- **Walkthrough** - one positional parameter, `EventId` (`:5`); the summary above it states the intent as "find speakers with multiple submitted sessions" (`:3`). Note that the handler's own doc comment corrects that scope: it returns every speaker with at least one submitted session (`GetSpeakerSessionOverlapHandler.cs:12-16`). +- **Why it's built this way** - speaker overlap is a distinct selection concern from topic balance (one speaker holding three accepted slots is a program problem even when the topic mix is fine), so it gets its own message and its own endpoint rather than being a filter over the category view. +- **Where it's used** - constructed by [SessionSelectionController](group-20-conference-api-grpc.md#sessionselectioncontroller) on `GET SessionSelection/speaker-overlap/{eventId}` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:67-79`). ### SessionSimilarityCalculator -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetContentSimilarity` · `MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetContentSimilarity/SessionSimilarityCalculator.cs:9` · Level 0 · class (internal static) +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetContentSimilarity` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetContentSimilarity/SessionSimilarityCalculator.cs:9` · Level 0 · class (static, internal) -- **What it is**: the pure static engine behind content similarity. It scores how alike two sessions are by blending category-item overlap (weight `0.6`) with keyword overlap drawn from titles and descriptions (weight `0.4`), and it also supplies the intersection helper the handler uses to explain each match (`SessionSimilarityCalculator.cs:9`). -- **Depends on**: `System.Collections.Frozen.FrozenSet` (`:1`, `:14`) and `HashSet` from the BCL, and the `CategoryItemIdentifierType` alias as a set element type on `CalculateSimilarity` (`:98-99`). No first-party types at all: this class never touches a repository, an entity, or a DTO. -- **Concept introduced: weighted Jaccard similarity with span-based tokenization.** The Jaccard index is the size of a set intersection divided by the size of its union, a value in `[0.0, 1.0]`. Here two independent Jaccard scores are blended: one over the sessions' category-item ids, one over their keyword sets. Two sessions with identical category tags but no shared keywords score `0.6`; identical keywords but no shared tags score `0.4`. Two important properties fall out of the implementation rather than the formula. [Rubric §12, Performance and Scalability] assesses allocation and lookup discipline on compute paths, and this type is where the quadratic pair loop's per-comparison cost is decided: the stop-word list is a `FrozenSet` (`:14-34`), which pays its build cost once at class initialization to buy the fastest possible read-only membership test, and `TokenizeText` walks a `ReadOnlySpan` (`:48-68`) so it does not allocate a substring per candidate word. [Rubric §14, Testability] assesses whether logic can be exercised without infrastructure: because the class is static, side-effect free, and dependency free, its whole behavior is reachable from a plain unit test with no fixture (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/SessionSimilarityCalculatorTests.cs:6`). -- **Walkthrough**, members in teaching order: - 1. `CategoryWeight = 0.6` and `KeywordWeight = 0.4` (`:11-12`), private consts. They sum to `1.0`, which is what keeps the composite score inside `[0.0, 1.0]`. - 2. `StopWords` (`:14-34`), a `FrozenSet` built with `StringComparer.Ordinal`. It holds two distinct groups, and the source separates them with comments: ordinary English function words (`:16-29`) and **conference-generic** words that would otherwise make every pair look alike, including `"SESSION"`, `"TALK"`, `"PRESENTATION"`, `"WORKSHOP"`, `"DEEP"`, `"DIVE"`, `"OVERVIEW"` (`:30-33`). That second group is the domain knowledge in this file: it is why "Deep Dive into X" and "Deep Dive into Y" do not register as similar. - 3. `TokenizeText(string? text)` (`:41`): returns an empty set for null or whitespace input (`:43-44`), then scans the text as a span, tracking the start index of each run of letters or digits (`:50-68`). A run is emitted only when it is at least three characters long (`:62`), and `AddTokenIfNotStopWord` (`:123-130`) uppercases it with `ToUpperInvariant` and drops it if it is a stop word. Uppercase rather than lowercase is deliberate and the doc comment says why (`:37`): analyzer rule CA1308 prefers `ToUpperInvariant` for normalization, because lowercasing is not round-trip safe in every culture. - 4. `CalculateJaccardIndex(HashSet, HashSet)` (`:80`): returns `0.0` when both sets are empty (`:82-83`, so two untagged sessions are not treated as identical), then iterates the **smaller** set against the larger (`:85-88`) so the intersection scan is bounded by the smaller count, and divides by `setA.Count + setB.Count - intersectionCount` (`:90-91`), the inclusion-exclusion form of the union size. - 5. `CalculateSimilarity(...)` (`:97`): the composite, `CategoryWeight * categoryScore + KeywordWeight * keywordScore` (`:103-105`). - 6. `GetIntersection(...)` (`:115`): the same smaller-against-larger trick (`:117-120`), returning the shared elements as a `List` so the handler can name the shared tags and keywords on each result pair. -- **Why it's built this way**: a Jaccard index on category items alone would flag unrelated sessions that merely share a broad track, so the keyword signal raises the bar; weighting categories higher reflects that curated tags are a stronger signal than free-text overlap. Keeping the math in a separate dependency-free type (rather than as private methods on the handler) is what makes the scoring rules directly testable and lets the handler read as pure orchestration. -- **Where it's used**: [GetContentSimilarityHandler](#getcontentsimilarityhandler) only. It calls `TokenizeText` once per session while pre-computing (`GetContentSimilarityHandler.cs:58`), `CalculateSimilarity` once per pair inside the double loop (`:68-72`), and `GetIntersection` twice per surviving pair when building the result (`:94-95`). -- **Caveats / not-in-source**: the stop-word list, the two weights, and the three-character minimum are all compile-time constants with no configuration hook, so tuning the similarity behavior for a different kind of event means editing and redeploying this file. +- **What it is** - the pure-function core of the content-similarity feature: it turns two sessions into a single number between 0.0 and 1.0 by blending category-item overlap (weight 0.6) with keyword overlap from title and description (weight 0.4). +- **Depends on** - `System.Collections.Frozen.FrozenSet` and `System.Linq` from the BCL, plus the `CategoryItemIdentifierType` alias in one signature (`:98-99`). No first-party types at all: it never touches an entity, a repository, or a DTO. +- **Concept introduced - the Jaccard index, and why the scoring logic is a static class.** The Jaccard index of two sets is the size of their intersection divided by the size of their union, so identical sets score 1.0 and disjoint sets score 0.0. This file applies it twice, once to the two sessions' category-item id sets and once to their keyword sets, then blends the two with fixed weights (`:103-105`). Two sessions tagged identically but sharing no vocabulary score 0.6; two sessions sharing vocabulary but no tags score 0.4. `[Rubric §14 - Testability]` assesses whether logic can be exercised without infrastructure: because every method is static and takes plain sets, `SessionSimilarityCalculatorTests` drives all four of them with literal inputs and no test double at all (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/SessionSimilarityCalculatorTests.cs:6`). `[Rubric §12 - Performance & Scalability]` assesses hot-path cost: this runs once per session pair, so the code avoids per-character allocation and uses an O(1) frozen set for stop-word lookups. `[Rubric §1 - SOLID]` assesses separation of responsibility: the scoring rule lives apart from the handler that orchestrates loading and DTO building, so a weight change touches one file. +- **Walkthrough** + - `CategoryWeight = 0.6` and `KeywordWeight = 0.4` (`:11-12`), the only two tuning constants, `private const` and therefore not configurable at runtime. + - `StopWords`, a `FrozenSet` built once at type initialization with `StringComparer.Ordinal` (`:14-34`). It holds ordinary English function words and, deliberately, conference-generic vocabulary such as `"SESSION"`, `"TALK"`, `"PRESENTATION"`, `"DEEP"`, `"DIVE"`, and `"WORKSHOP"` (`:31-33`), which would otherwise make every abstract look like every other abstract. + - `TokenizeText(string? text)` (`:41-71`) returns an empty set for null or whitespace (`:43-44`), then scans the text as a `ReadOnlySpan` with a manual index loop rather than `string.Split` (`:48-68`). A run of letters or digits ends at any other character or at end of input (`:52`); runs shorter than three characters are dropped (`:62`); surviving runs go to `AddTokenIfNotStopWord` (`:123-130`), which uppercases with `ToUpperInvariant` (the summary at `:37` notes this is upper rather than lower to satisfy analyzer rule CA1308) and adds the token only if it is not a stop word. + - `CalculateJaccardIndex(HashSet, HashSet)` (`:80-92`) returns `0.0` when both sets are empty (`:82-83`), which is the guard against dividing by a zero union. It iterates the smaller set against the larger one's `Contains` (`:85-88`), then divides the intersection count by `setA.Count + setB.Count - intersectionCount` (`:90-91`). + - `CalculateSimilarity(...)` (`:97-106`) is the blend: `CategoryWeight * categoryJaccard + KeywordWeight * keywordJaccard`. + - `GetIntersection(...)` (`:115-121`) returns the shared elements as a `List`, again scanning the smaller set, and exists so the handler can show a reader why a pair scored what it scored. +- **Why it's built this way** - a single signal is too blunt for program selection. Category overlap alone flags every pair inside a broad track; keyword overlap alone flags any two talks that both say "Kubernetes". Weighting categories higher than keywords encodes that a shared explicit tag is stronger evidence than shared prose. Keeping all of it `internal static` with no dependencies means the rule is auditable and unit-testable in isolation, which is what the test class does. +- **Where it's used** - only by [GetContentSimilarityHandler](#getcontentsimilarityhandler): `TokenizeText` while pre-computing per-session keyword sets (`GetContentSimilarityHandler.cs:58`), `CalculateSimilarity` inside the pairwise loop (`:68-72`), and `GetIntersection` twice when building each result row (`:94-95`). +- **Caveats / not-in-source** - two sessions with no category items at all score 0.0 on the category component, not 1.0, because the both-empty case returns zero by design (`:82-83`): "neither is tagged" is treated as no evidence rather than as agreement. Stop words are English only, and the token filter keeps digits, so a version number such as "2026" counts as a keyword. The weights and the three-character minimum are constants with no configuration path. ### StatusBucket -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetSessionSelectionDashboard` · `MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboard/GetSessionSelectionDashboardHandler.cs:314` · Level 0 · enum (private) +> MMCA.ADC.Conference.Application · `...DecisionSupport.GetCategoryDistribution` and `...DecisionSupport.GetSessionSelectionDashboard` · see table · Level 0 · enum (private, nested, two declarations) + +- **What it is** - two independent private enums, one nested in each of the two handlers that tally sessions by status, that collapse a session's free-text status string onto the three columns those tallies report. + +| Type | File:Line | Notes (what differs) | +|------|-----------|----------------------| +| `StatusBucket` (GetCategoryDistribution) | `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetCategoryDistribution/GetCategoryDistributionHandler.cs:94` | Members `Accepted`, `AcceptQueue`, `Pending` (`:94-99`), classified by that handler's own `ClassifyStatus` (`:101-112`). | +| `StatusBucket` (GetSessionSelectionDashboard) | `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboard/GetSessionSelectionDashboardHandler.cs:314` | The same three members (`:314-319`) and a matching `ClassifyStatus` (`:321-332`). Nothing in source keeps the two copies in step. | -- **What it is**: a private three-member enum (`Accepted`, `AcceptQueue`, `Pending`) that collapses a session's free-text status string into the buckets the dashboard's category-distribution tally counts (`GetSessionSelectionDashboardHandler.cs:314-319`). -- **Depends on**: nothing at the type level; conceptually on [SessionStatuses](group-17-conference-domain.md#sessionstatuses), the string constants the classifier compares against (`:324`, `:329`). -- **Concept introduced: a handler-private classification vocabulary.** [Session](group-17-conference-domain.md#session) stores its status as a nullable string (it arrives that way from the Sessionize import), and three different places in this handler need to answer "which pile does this session go in". Naming the three piles as an enum turns repeated `string.Equals(..., StringComparison.OrdinalIgnoreCase)` comparisons into one classifier plus a switch on a closed set. The enum is `private`, so it is an implementation detail: callers only ever see the aggregated counts on the DTO. Note there is deliberately **no** `Declined` member, because declined sessions are filtered out by `IsDeclined` (`:334-335`) before bucketing ever runs (`:137`), so the enum spans only the statuses that count toward a category's totals. [Rubric §16, Maintainability] assesses local reasoning: because the type cannot escape the file, this handler's bucketing can change without any coordination with a sibling use case. -- **Walkthrough**: three members (`:316-318`). `ClassifyStatus(Session)` (`:321-332`) is the only producer: a null status or `SessionStatuses.Accepted` maps to `Accepted` (`:323-326`), `SessionStatuses.AcceptQueue` maps to `AcceptQueue`, and everything else falls through to `Pending` (`:329-331`). The null-means-accepted rule is worth internalizing; it repeats throughout this handler (`:81`, `:217-218`, `:293-294`). `CountCategoryItems` (`:133-156`) is the only consumer: for each non-declined session it pairs every live `SessionCategoryItem` id with the session's bucket (`:136-140`) and folds the pairs into a `(Total, Accepted, AcceptQueue, Pending)` tuple per category item (`:143-153`). -- **Why it's built this way**: keeping the bucket private to this handler means its bucketing can diverge from another dashboard's without coupling the two use cases, which is exactly what happened: see the caveat. -- **Where it's used**: inside [GetSessionSelectionDashboardHandler](#getsessionselectiondashboardhandler) only. -- **Caveats / not-in-source**: a same-named private sibling enum lives in [GetCategoryDistributionHandler](#getcategorydistributionhandler) (`MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetCategoryDistribution/GetCategoryDistributionHandler.cs:94`, with its own `ClassifyStatus` at `:101` and `IsDeclined` at `:114`). The duplication is deliberate: the two handlers share the name and the semantics but no type, so neither can break the other. The cost is that a change to the bucketing rule has to be made twice, and nothing in the source flags the pair. +- **Depends on** - nothing structurally. Both are produced from the [SessionStatuses](group-17-conference-domain.md#sessionstatuses) string constants by their handler's `ClassifyStatus`. +- **Concept introduced - a handler-local aggregation vocabulary.** [Session](group-17-conference-domain.md#session)`.Status` is free text imported from Sessionize, and [SessionStatuses](group-17-conference-domain.md#sessionstatuses) names six recognized values: `Accepted`, `Waitlisted`, `AcceptQueue`, `Nominated`, `DeclineQueue`, `Declined` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionStatuses.cs:17-32`). The distribution views do not want six columns, so each handler declares a private three-member enum and folds everything that is neither accepted nor accept-queue into `Pending`. `[Rubric §16 - Maintainability]` assesses local reasoning: the bucket type is an implementation detail no caller can see, so either handler can change its bucketing without touching the other. `[Rubric §15 - Best Practices & Code Quality]` assesses expressiveness: three named members read better at the tally site than three ad-hoc string comparisons. +- **Walkthrough** - three members in each declaration: `Accepted`, `AcceptQueue`, `Pending`. There is deliberately no `Declined` member, because declined sessions are removed before any bucketing happens: `IsDeclined` filters them out in `GetCategoryDistributionHandler` (`:45`, `:114-115`) and in the dashboard handler's `CountCategoryItems` (`GetSessionSelectionDashboardHandler.cs:137`, `:334-335`). `ClassifyStatus` in both handlers maps a null status or `SessionStatuses.Accepted` to `Accepted`, `SessionStatuses.AcceptQueue` to `AcceptQueue`, and everything else to `Pending`, comparing with `StringComparison.OrdinalIgnoreCase`. +- **Why it's built this way** - declined proposals do not compete for a slot, so they are dropped before the enum stage and the three live buckets stay meaningful. Keeping the enum private to each handler avoids a shared type that would couple two otherwise independent use cases. +- **Where it's used** - inside its own handler only: [GetCategoryDistributionHandler](#getcategorydistributionhandler) (`:58-60`, `:101-112`) and [GetSessionSelectionDashboardHandler](#getsessionselectiondashboardhandler) (`:150-152`, `:321-332`). Callers receive DTO counts, never a bucket value. +- **Caveats / not-in-source** - a null `Session.Status` counts as `Accepted` in both copies (`GetCategoryDistributionHandler.cs:103-107`, `GetSessionSelectionDashboardHandler.cs:323-327`). That is consistent with the domain's public-visibility allow-list, where an unset status is eligible because organizer-created sessions never carry one (`SessionStatuses.cs:47-51`), but the code does not restate the reason at the bucketing site. `Waitlisted`, `Nominated`, and `DeclineQueue` all land in `Pending` with no way to tell them apart in the output. + +### GetCategoryDistributionHandler +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetCategoryDistribution` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetCategoryDistribution/GetCategoryDistributionHandler.cs:14` · Level 9 · class (sealed) + +- **What it is** - the query handler that computes, per category item, how many of an event's sessions were submitted, accepted, put in the accept queue, or left pending. It backs the organizer's category-distribution view during session selection. +- **Depends on** - [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) (implemented) and [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) (the only constructor dependency, `:14-15`); [Session](group-17-conference-domain.md#session) and [Category](group-17-conference-domain.md#category) as repository entities plus [CategoryItem](group-17-conference-domain.md#categoryitem) through their collections; [SessionStatuses](group-17-conference-domain.md#sessionstatuses) for the status constants; the nested [StatusBucket](#statusbucket) enum; and the output contracts [CategoryDistributionDTO](group-17-conference-domain.md#categorydistributiondto), [CategoryGroupDistribution](group-17-conference-domain.md#categorygroupdistribution), and [CategoryItemDistribution](group-17-conference-domain.md#categoryitemdistribution) from `MMCA.ADC.Conference.Shared.Sessions.DecisionSupport` (`:3`). +- **Concept introduced - the in-memory analytics read handler.** It loads two aggregate sets untracked and then does all filtering, bucketing, and grouping in C#, rather than pushing aggregation down into SQL. `[Rubric §6 - CQRS & Event-Driven]` assesses the read path: this is a pure query returning `Result` and mutating nothing, so it is wrapped only by the query-side decorators the framework registers (Timeout, Caching, Logging, Authorization, FeatureGate, plus Profiling when enabled), never by the command-side Validating or Transactional ones (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:124-128`, `:300`). `[Rubric §12 - Performance & Scalability]` assesses read efficiency: both loads pass `asTracking: false` (`:27`, `:32`) so EF skips change tracking, and the tally is a single pass into a dictionary rather than a nested scan (`:50-61`). `[Rubric §5 - Vertical Slice]` assesses feature cohesion: query, handler, and private bucketing live in one `GetCategoryDistribution` folder, so the whole feature is readable in one place. +- **Walkthrough** - `HandleAsync` (`:17-39`) resolves a [Session](group-17-conference-domain.md#session) repository and a [Category](group-17-conference-domain.md#category) repository from the unit of work (`:21-22`). It loads the event's sessions with `SessionCategoryItems` included, filtered to `s.EventId == query.EventId && !s.IsServiceSession` (`:24-28`), then loads every category with its `CategoryItems` (`:30-33`), with no event filter because categories are global. `CountSessionsPerCategoryItem` (`:41-64`) drops declined sessions via `IsDeclined` (`:45`, `:114-115`), flattens each remaining session into `(CategoryItemId, StatusBucket)` pairs while skipping soft-deleted links (`:46-48`), and folds those pairs into a `(Total, Accepted, AcceptQueue, Pending)` tuple per category item (`:50-61`). `ClassifyStatus` (`:101-112`) treats a null status or `SessionStatuses.Accepted` as `Accepted`, `SessionStatuses.AcceptQueue` as `AcceptQueue`, and anything else as `Pending`, all with `OrdinalIgnoreCase` comparison. `BuildCategoryGroups` (`:66-92`) keeps only non-deleted categories that have at least one counted item (`:70`), orders categories by `Sort` (`:71`) and items by `Sort` (`:78`), and projects each item into a `CategoryItemDistribution` with its four counts, using `TryGetValue` so an uncounted item yields zeros (`:81-90`). The handler returns `Result.Success(new CategoryDistributionDTO { Categories = categoryGroups })` (`:38`): it has no failure path. +- **Why it's built this way** - aggregating in memory keeps the handler engine-agnostic (the same code runs against whatever store backs [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork), per the database-per-service model of ADR-006) and states the domain rules (service sessions excluded, declined excluded, soft-deletes excluded at both the session-link and category-item levels) as readable filters instead of burying them in SQL. The trade-off is that a full event's sessions and the full category set are materialized; that is bounded by one conference's proposal volume. +- **Where it's used** - injected into [SessionSelectionController](group-20-conference-api-grpc.md#sessionselectioncontroller) as `IQueryHandler>` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:32`) and invoked from `GET SessionSelection/categories/{eventId}` (`:53-65`), an organizer-only endpoint (`:29`) whose response is output-cached under the `ConferenceCache` policy (`:55`). Behavior is pinned by `GetCategoryDistributionHandlerTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/GetCategoryDistributionHandlerTests.cs:13`). +- **Caveats / not-in-source** - a null `Session.Status` is counted as `Accepted` (`:103-107`), so a session whose status was never set inflates the accepted column rather than the pending one. The branch is explicit in code, but the reason for choosing `Accepted` over `Pending` as the null default is not stated there. The handler also resolves the read-write `GetRepository` (`:21-22`) although it only reads: [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) exposes a narrower `GetReadRepository` alongside it (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IUnitOfWork.cs:19` versus `:29`). ### GetContentSimilarityHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetContentSimilarity` · `MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetContentSimilarity/GetContentSimilarityHandler.cs:14` · Level 9 · class (sealed) - -- **What it is**: the query handler that finds pairs of similar-content sessions in an event: it loads the candidate sessions once, scores every pair, and returns the strongest matches above the caller's threshold, capped at 50 (`GetContentSimilarityHandler.cs:14`). -- **Depends on**: [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) (constructor-injected, `:15`) and, through it, the [Session](group-17-conference-domain.md#session) and [Category](group-17-conference-domain.md#category) repositories (`:23-24`); [SessionStatuses](group-17-conference-domain.md#sessionstatuses) (`:31`); [SessionSimilarityCalculator](#sessionsimilaritycalculator); [Result](group-01-result-error-handling.md#result); and the DTOs [ContentSimilarityDTO](group-17-conference-domain.md#contentsimilaritydto) and [SimilarSessionPair](group-17-conference-domain.md#similarsessionpair). It implements [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) of [GetContentSimilarityQuery](#getcontentsimilarityquery) to `Result` (`:15`). -- **Concept introduced: a quadratic analytics handler with a hard result cap.** Comparing every session against every other is O(n^2) in the session count, and nothing about the request bounds n: an event with 300 proposals produces about 45,000 comparisons. Three separate devices keep that affordable, and it is worth seeing them as a set. First, **narrow the input**: the `where` clause drops service sessions and declined ones at the database, not in memory (`:29-31`). Second, **hoist the per-item work out of the loop**: each session's category-item set and keyword set are computed once, before the loop begins (`:51-59`), so the inner comparison is pure set arithmetic rather than repeated tokenization. Third, **bound the output**: `MaxPairs` (`:17`) truncates the sorted list to 50, so the response size does not grow with n^2 even when the threshold is set to zero. Both reads are `asTracking: false` (`:32`, `:38`), so EF materializes without change-tracking overhead on a path that never writes. [Rubric §12, Performance and Scalability] is the category this section is really about; [Rubric §8, Data Architecture] applies to the read shape, two `GetAllAsync` calls with explicit `includes` rather than lazy navigation. -- **Concept introduced: a total order for a truncated result.** Sorting by score alone is not enough when the list is then cut to a fixed size: ties would be ordered by whatever `List.Sort` (an unstable introsort) happened to produce, so the same event could return different top-50 pairs on two identical requests. `CompareByScoreThenIndex` (`:120-132`) makes the comparison total by falling through from score to `IndexA` and then `IndexB` (`:130-131`), and its doc comment states that purpose explicitly (`:116-119`). The regression test is `HandleAsync_WithTiedScores_TruncatesToADeterministicTopFifty` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/GetContentSimilarityHandlerTests.cs:319`). [Rubric §9, API and Contract Design] assesses response predictability: a truncated collection endpoint owes the caller a deterministic ordering, otherwise the cut point is arbitrary. -- **Walkthrough** of `HandleAsync` (`:19`): - 1. Resolve the session and category repositories from the unit of work (`:23-24`). - 2. Load the candidate sessions: `SessionCategoryItems` included, filtered to the event, excluding `IsServiceSession` rows and anything whose `Status` equals `SessionStatuses.Declined`, untracked (`:27-33`). Note the `Status != SessionStatuses.Declined` comparison runs in SQL here, unlike the case-insensitive in-memory comparisons the dashboard handler uses. - 3. Load all categories with their `CategoryItems`, untracked (`:36-39`), and flatten them into a `CategoryItemIdentifierType -> name` dictionary (`:41-48`). This is only needed to turn shared ids into readable names at the end. - 4. Pre-compute, per session, an anonymous record of the session plus a `HashSet` of its non-deleted category-item ids and a keyword set from `TokenizeText(s.Title + " " + s.Description)` (`:51-59`). - 5. Double-loop with `j = i + 1` so each unordered pair is visited exactly once (`:64-67`), call `SessionSimilarityCalculator.CalculateSimilarity` (`:68-72`), and keep the pair only when the score is at or above `query.MinimumSimilarity` (`:74-77`); the comparison is `>=`, so the threshold is an inclusive lower bound. - 6. Sort with the `CompareByScoreThenIndex` comparator (`:82`), which is score descending with an index tie-break, then truncate with `GetRange(0, MaxPairs)` when there are more than 50 (`:83-86`). - 7. Project each survivor into a [SimilarSessionPair](group-17-conference-domain.md#similarsessionpair) (`:89-111`): both session ids, titles, and statuses; the score rounded to three digits with explicit `MidpointRounding.ToEven` (`:105`); the shared category items resolved through the name lookup, silently dropping ids the lookup does not know (`:94`, `:106-108`); and at most ten shared keywords (`:95`, `:109`). - 8. Return `Result.Success(new ContentSimilarityDTO { Pairs = result })` (`:113`). -- **Why it's built this way**: the handler has no failure path at all, because a similarity report over zero sessions is legitimately an empty list rather than an error, so it never constructs a `Result.Failure`. Delegating all scoring to the static calculator keeps this file readable as orchestration, and keeps the tuning constants in one place. The cap plus the threshold together bound compute and payload so a large proposal set cannot produce an unbounded response. -- **Where it's used**: resolved through [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) by [SessionSelectionController](group-20-conference-api-grpc.md#sessionselectioncontroller)'s content-similarity action (`SessionSelectionController.cs:33`, `:81-93`), which is behind the class-level `[HasPermission(ConferencePermissions.SessionSelectionManage)]` (`:28`) and served through the `ConferenceCache` output-cache policy (`:82`). -- **Testing**: `GetContentSimilarityHandlerTests` on the shared `HandlerTestBase` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/GetContentSimilarityHandlerTests.cs:12`), 16 tests covering the threshold boundary (`:173`), the self-comparison exclusion (`:224`), the fifty-pair cap (`:302`), the deterministic tie-break (`:319`), and the three-decimal rounding (`:363`). -- **Caveats / not-in-source**: the pairwise loop is unconditional, so the `MaxPairs` cap bounds the **response** but not the **work**; nothing in this file limits how many sessions get loaded and compared. There is also no cancellation check inside the loops, so `cancellationToken` only takes effect at the two awaited repository calls. +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetContentSimilarity` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetContentSimilarity/GetContentSimilarityHandler.cs:14` · Level 9 · class (sealed) + +- **What it is** - the query handler that scores every pair of an event's live sessions for content overlap and returns the strongest pairs, each annotated with the tags and words the two proposals share. +- **Depends on** - [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) (implemented) and [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) (the only constructor dependency, `:14-15`); [SessionSimilarityCalculator](#sessionsimilaritycalculator) for the scoring; [Session](group-17-conference-domain.md#session), [Category](group-17-conference-domain.md#category), and [CategoryItem](group-17-conference-domain.md#categoryitem) as loaded aggregates; [SessionStatuses](group-17-conference-domain.md#sessionstatuses) for the declined filter; and the output contracts [ContentSimilarityDTO](group-17-conference-domain.md#contentsimilaritydto) and [SimilarSessionPair](group-17-conference-domain.md#similarsessionpair) (`:3`). +- **Concept introduced - the bounded quadratic analytical query.** Unlike its sibling handlers, which fold each session once, this one compares every session against every other one: a double loop with `j = i + 1` (`:64-79`), so a 200-proposal event performs 19,900 comparisons. Three separate mechanisms keep that honest. First, the expensive per-session work (tokenizing title plus description, materializing the category-item id set) is hoisted out of the loop and done once per session (`:51-59`), so the loop body is only set arithmetic. Second, only sessions that can still be scheduled are loaded at all: the `where` predicate excludes service sessions and declined ones in the database (`:29-31`). Third, the result is capped at `MaxPairs = 50` (`:17`, `:83-86`). `[Rubric §12 - Performance & Scalability]` assesses exactly this shape: quadratic work is acceptable here because n is one conference's proposal count and the constant factor is a hash-set intersection, but the cost is real and the cap bounds the response, not the computation. `[Rubric §9 - API & Contract Design]` assesses response determinism, which is why the sort is not a plain score sort (see the walkthrough). `[Rubric §6 - CQRS & Event-Driven]` applies as for the sibling handlers: a pure read wrapped only by the query-side decorators. +- **Walkthrough** - `HandleAsync` (`:19-114`) resolves [Session](group-17-conference-domain.md#session) and [Category](group-17-conference-domain.md#category) repositories (`:23-24`), loads the event's non-service, non-declined sessions with `SessionCategoryItems` included and `asTracking: false` (`:27-33`), and loads all categories with their items (`:36-39`) purely to build an id-to-name lookup for the shared-tag labels (`:41-48`). It then projects each session into an anonymous value of `(Session, CategoryItems, Keywords)` (`:51-59`), where `CategoryItems` skips soft-deleted links (`:56`) and `Keywords` comes from `SessionSimilarityCalculator.TokenizeText(s.Title + " " + s.Description)` (`:58`). The pairwise loop scores each combination and keeps the pair when `score >= query.MinimumSimilarity` (`:64-79`): the bound is inclusive, which is what `HandleAsync_TreatsMinimumSimilarityAsInclusiveLowerBound` pins (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/GetContentSimilarityHandlerTests.cs:173`). Sorting uses the explicit comparer `CompareByScoreThenIndex` (`:82`, `:120-132`): score descending, then index A, then index B. The tie-break is the load-bearing part, and the summary above the comparer says why (`:116-119`): a plain score comparison leaves equal-scoring pairs in unspecified relative order, so truncating to 50 could return a different 50 for the same input. Truncation is `GetRange(0, MaxPairs)` (`:83-86`). Each surviving pair becomes a [SimilarSessionPair](group-17-conference-domain.md#similarsessionpair) (`:89-111`) carrying both sessions' id, title, and status, the score rounded to three decimals with `MidpointRounding.ToEven` (`:105`), the shared category items resolved to names (`:106-108`), and at most ten shared keywords (`:109`). The method ends with `Result.Success(new ContentSimilarityDTO { Pairs = result })` (`:113`) and has no failure path. +- **Why it's built this way** - the point of the feature is a conversation between organizers, so the output has to be explainable: showing the shared tags and words next to the number is what makes a pair actionable rather than merely flagged. The deterministic comparer exists because the response is output-cached and read by humans comparing runs, so a stable list is worth the extra comparisons. Doing the whole computation in memory keeps the scoring rule in C# where it is unit-testable, rather than in SQL where it would not be. +- **Where it's used** - injected into [SessionSelectionController](group-20-conference-api-grpc.md#sessionselectioncontroller) as `IQueryHandler>` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:34`) and invoked from `GET SessionSelection/content-similarity/{eventId}` (`:81-94`). The dashboard path does not use it: [GetSessionSelectionDashboardHandler](#getsessionselectiondashboardhandler) computes distribution, overlap, locality, and AI scores but no similarity block, so this handler is reachable only through its own endpoint. +- **Caveats / not-in-source** - the 50-pair cap truncates silently: the response carries no flag saying more pairs cleared the threshold. Sessions with neither tags nor keywords score 0.0 against each other and are therefore returned when the caller passes a floor of 0.0 (`GetContentSimilarityHandlerTests.cs:207`). The declined filter is expressed as `s.Status != SessionStatuses.Declined` (`:31`), a comparison translated to SQL, whereas the sibling handlers compare statuses with `OrdinalIgnoreCase` in memory; whether the two agree on casing depends on the database collation, which this file does not state. ### GetSessionSelectionDashboardHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetSessionSelectionDashboard` · `MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboard/GetSessionSelectionDashboardHandler.cs:16` · Level 9 · class (sealed) - -- **What it is**: the composite decision-support handler. It loads an event's sessions, categories, speakers, and AI scores once, then derives summary counts, category distribution, speaker overlap, speaker locality, and the AI-score table in memory, returning them all in a single [SessionSelectionDashboardDTO](group-17-conference-domain.md#sessionselectiondashboarddto) (`GetSessionSelectionDashboardHandler.cs:16`). -- **Depends on**: [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) (`:17`) and, through it, the [Event](group-17-conference-domain.md#event), [Session](group-17-conference-domain.md#session), [Speaker](group-17-conference-domain.md#speaker), [Category](group-17-conference-domain.md#category), and [SessionAiScore](group-17-conference-domain.md#sessionaiscore) repositories (`:23-26`, `:98`); [SpeakerLocalityHelper](#speakerlocalityhelper) and [LocalityLookupEntry](#localitylookupentry); [SessionStatuses](group-17-conference-domain.md#sessionstatuses); the private [StatusBucket](#statusbucket) enum; [Result](group-01-result-error-handling.md#result) and [Error](group-01-result-error-handling.md#error); and the dashboard DTO family, [CategoryDistributionDTO](group-17-conference-domain.md#categorydistributiondto), [CategoryGroupDistribution](group-17-conference-domain.md#categorygroupdistribution), [CategoryItemDistribution](group-17-conference-domain.md#categoryitemdistribution), [SpeakerSessionOverlapDTO](group-17-conference-domain.md#speakersessionoverlapdto), [MultiSessionSpeaker](group-17-conference-domain.md#multisessionspeaker), [SpeakerSessionSummary](group-17-conference-domain.md#speakersessionsummary), [SpeakerLocalitySummary](group-17-conference-domain.md#speakerlocalitysummary), and [SessionAiScoreDTO](group-17-conference-domain.md#sessionaiscoredto). Implements [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) of [GetSessionSelectionDashboardQuery](#getsessionselectiondashboardquery) to `Result` (`:17`). -- **Concept introduced: load once, compute many.** This handler backs a single screen that shows five analytics at once. The naive shape (one query per panel) would read the same sessions five times. Instead it issues a small fixed set of untracked reads (`asTracking: false` at `:37`, `:42`, `:60`, `:103`) and derives every panel from those materialized collections with private static methods. The comment at `:33` records a constraint that shapes the code: the loads are **sequential**, not parallel, because a `DbContext` is not concurrency-safe, so `Task.WhenAll` over the same unit of work would fault. [Rubric §12, Performance and Scalability] assesses read-path efficiency, and this is a worked example of trading in-memory CPU for round trips. [Rubric §8, Data Architecture] applies to a subtler decision described next. -- **Concept introduced: reading past the soft-delete filter, on purpose.** The speaker load passes `ignoreQueryFilters: true` (`:57-62`), which switches off the EF global query filter that normally hides soft-deleted rows (see [soft-delete](00-primer.md#2-architectural-styles-this-codebase-commits-to)). The comment above it (`:52-56`) is the rationale and is worth reading in the source: a live `SessionSpeaker` link can point at a soft-deleted [Speaker](group-17-conference-domain.md#speaker), because speaker deletion deliberately does not cascade (BR-70/BR-71), and dropping that speaker here would render the dashboard row as "Unknown" instead of the truth. The comment also states why this is safe rather than a resurrection bug: every downstream consumer re-filters the child collections in memory (`.Where(ss => !ss.IsDeleted)` at `:48` and `:403`, the equivalent `if (ss.IsDeleted) continue;` guards at `:197-198` and `:266-267`, and `.Where(sci => !sci.IsDeleted)` at `:139`, `:234`, `:419`), so ignoring the filter widens only the speaker lookup, never the join rows. [Rubric §8, Data Architecture] assesses whether the persistence rules (here soft-delete and non-cascading deletes) are understood at the point of query rather than assumed away. -- **Walkthrough** of `HandleAsync` (`:19`): - 1. Resolve the event, session, speaker, and category repositories (`:23-26`), then validate the event exists; a miss returns `Error.NotFound` sourced to this handler and targeted at `Event` (`:29-31`). This is the handler's only failure path. - 2. Load non-service sessions for the event with `SessionSpeakers` and `SessionCategoryItems` included (`:34-38`), then all categories with `CategoryItems` (`:40-43`). - 3. Collect the distinct speaker ids off the live session-speaker links (`:46-50`) and load exactly those speakers with `SpeakerCategoryItems` via `GetByIdsAsync`, filters off (`:57-62`), into an id-keyed dictionary (`:63`). - 4. Flatten the categories into a category-item name lookup (`:66-73`), then build the locality lookup: `SpeakerLocalityHelper.FindLocalityCategories(categories)` followed by `BuildLocalityLookup` (`:75-76`). - 5. Compute the summary counts (`:79-86`): total, accepted, accept-queue, and declined by [SessionStatuses](group-17-conference-domain.md#sessionstatuses) comparison, with `pending` derived as the remainder rather than counted (`:86`). A null `Status` counts as accepted (`:81`). - 6. `ComputeCategoryDistribution` (`:89`, defined `:124-131`) tallies category items via `CountCategoryItems` (`:133-156`, using [StatusBucket](#statusbucket)) and shapes them with `BuildCategoryGroups` (`:158-184`), which keeps only non-deleted categories that actually have a counted item, ordered by `Sort` (`:161-163`), with the items inside each group also ordered by `Sort` (`:170`). - 7. `ComputeSpeakerOverlap` (`:92`, defined `:186-253`) groups sessions by live speaker link, projects each into a [MultiSessionSpeaker](group-17-conference-domain.md#multisessionspeaker) with its locality tier and an accepted-session flag, and sorts by session count descending, then accepted presence, then name (`:240-250`). - 8. `ComputeSpeakerLocality` (`:95`, defined `:255-312`) re-groups the same sessions by speaker and folds them into per-tier totals, falling back to the literal `"Unknown"` both when the speaker is missing from the lookup and when the helper returns no tier (`:283-287`), ordered by speaker count descending (`:302-303`). - 9. Load the [SessionAiScore](group-17-conference-domain.md#sessionaiscore) rows for this event's sessions (`:98-104`) and map them through `BuildAiScoreDtos` (`:337-360`), ordered by descending `OverallScore` (`:354`). That helper first locates the "Level" category by title match (`:347-351`) so that `ResolveCategoryInfo` (`:410-432`) can split a session's tags into ordinary categories and its single level, while `ResolveSpeakerLocalities` (`:394-408`) lists each session's distinct speaker tiers. A score whose session is not in the loaded set still produces a row, with an empty title and a null status (`:357`, `:376`, `:387`). - 10. Assemble and return the composite DTO (`:108-121`). -- **Why it's built this way**: one screen, one request. Computing every panel from one shared load avoids re-reading the same sessions once per panel, and keeping the compute in private static methods (rather than in a shared service) means each panel's rules stay local to the use case that renders them. -- **Where it's used**: [SessionSelectionController](group-20-conference-api-grpc.md#sessionselectioncontroller)'s dashboard action (`SessionSelectionController.cs:30`, `:39-50`), organizer-only and output-cached under the `ConferenceCache` policy (`:28`, `:40`). Two of its panels are also available standalone: speaker overlap through [GetSpeakerSessionOverlapHandler](#getspeakersessionoverlaphandler) and category distribution through [GetCategoryDistributionHandler](#getcategorydistributionhandler). The AI-score panel is populated by [ScoreEventSessionsHandler](#scoreeventsessionshandler). -- **Testing**: `GetSessionSelectionDashboardHandlerTests` on `HandlerTestBase` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboardHandlerTests.cs:15`). -- **Caveats / not-in-source**: the class doc comment (`:12-15`) still lists "content similarity" among the analytics computed here; it is not, and never appears in the returned DTO. That analysis lives in the separate [GetContentSimilarityHandler](#getcontentsimilarityhandler). The comment also omits the AI-score panel, which the handler does compute. Separately, `ComputeSpeakerOverlap` and `ComputeSpeakerLocality` each rebuild the same speaker-to-sessions grouping independently (`:192-208` and `:261-277`); the duplication is in the source as written. +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetSessionSelectionDashboard` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboard/GetSessionSelectionDashboardHandler.cs:16` · Level 9 · class (sealed) + +- **What it is** - the composite handler behind the session-selection screen. It validates the event, loads sessions, categories, speakers, and AI scores once, and computes five blocks from that one snapshot: summary counts, category distribution, speaker overlap, speaker locality, and per-session AI scores. +- **Depends on** - [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) (implemented) and [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) (the only constructor dependency, `:16-17`); the aggregates [Event](group-17-conference-domain.md#event), [Session](group-17-conference-domain.md#session), [Speaker](group-17-conference-domain.md#speaker), [Category](group-17-conference-domain.md#category), and [SessionAiScore](group-17-conference-domain.md#sessionaiscore); [SpeakerLocalityHelper](#speakerlocalityhelper) and its [LocalityLookupEntry](#localitylookupentry); [SessionStatuses](group-17-conference-domain.md#sessionstatuses); [Result](group-01-result-error-handling.md#result) and [Error](group-01-result-error-handling.md#error); and the output contracts [SessionSelectionDashboardDTO](group-17-conference-domain.md#sessionselectiondashboarddto), [CategoryDistributionDTO](group-17-conference-domain.md#categorydistributiondto), [SpeakerSessionOverlapDTO](group-17-conference-domain.md#speakersessionoverlapdto), [MultiSessionSpeaker](group-17-conference-domain.md#multisessionspeaker), [SpeakerSessionSummary](group-17-conference-domain.md#speakersessionsummary), [SpeakerLocalitySummary](group-17-conference-domain.md#speakerlocalitysummary), and [SessionAiScoreDTO](group-17-conference-domain.md#sessionaiscoredto) (`:5`). +- **Concept introduced - one snapshot, many projections, and the deliberate query-filter escape.** Two mechanisms are worth learning here. The first is the load-once discipline: the comment at `:33` records that the loads stay sequential for EF single-context safety (a `DbContext` is not thread-safe, so "parallel-friendly" here means ordered and independent, not concurrent), and every later block is a pure function over the already-materialized collections. The second is the one place the handler steps outside the framework's defaults: the speaker load passes `ignoreQueryFilters: true` (`:61`), turning off the global soft-delete filter for that read only. The comment above it explains the rule (`:52-56`): speaker deletion deliberately does not cascade to [SessionSpeaker](group-17-conference-domain.md#sessionspeaker) links (BR-70/BR-71), so a live link can point at a soft-deleted speaker, and honoring the filter would render that row as "Unknown" instead of the truth. `[Rubric §8 - Data Architecture]` assesses whether soft-delete semantics are applied deliberately rather than by reflex: this is an explicit, commented, single-read opt-out, and the comment notes that every downstream consumer re-filters the child collections in memory, so the escape cannot resurrect deleted category items. `[Rubric §12 - Performance & Scalability]` assesses request economy: four loads serve five projections. `[Rubric §16 - Maintainability]` assesses duplication, and that is the honest weak point (see the caveats). +- **Walkthrough** - `HandleAsync` (`:19-122`) resolves four repositories (`:23-26`), then fetches the [Event](group-17-conference-domain.md#event) and returns `Error.NotFound` decorated with source and target when it is missing (`:29-31`): this is the only decision-support handler with a failure path, pinned by `HandleAsync_WhenEventNotFound_ReturnsNotFound` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboardHandlerTests.cs:197`). It loads the event's non-service sessions with `SessionSpeakers` and `SessionCategoryItems` included, untracked (`:34-38`), all categories with their items (`:40-43`), the distinct speaker ids referenced by live session-speaker links (`:46-50`), and those speakers with `SpeakerCategoryItems` included and query filters off (`:57-62`), indexed into a dictionary (`:63`). Two lookups follow: category-item id to name (`:66-73`) and the locality lookup built by [SpeakerLocalityHelper](#speakerlocalityhelper) from the locality categories it finds (`:75-76`). The summary counts are four `Count` passes plus one subtraction: accepted counts null-or-`Accepted` statuses, accept-queue and declined count their constants, and pending is the remainder (`:79-86`). `ComputeCategoryDistribution` (`:89`, `:124-131`) reuses the same tally-then-group shape as [GetCategoryDistributionHandler](#getcategorydistributionhandler) (`:133-156`, `:158-184`). `ComputeSpeakerOverlap` (`:92`, `:186-253`) groups sessions by live speaker link, skips ids missing from the lookup (`:213-214`), stamps each speaker's locality tier (`:224`), orders each speaker's sessions by title (`:227`), and sorts speakers by session count descending, then accepted-session presence, then name (`:240-250`). `ComputeSpeakerLocality` (`:95`, `:255-312`) re-groups the same sessions by speaker, resolves each speaker to a tier defaulting to `"Unknown"` (`:283-287`), accumulates speaker, session, accepted, and accept-queue counts per tier in a case-insensitive dictionary (`:279`, `:289-299`), and emits [SpeakerLocalitySummary](group-17-conference-domain.md#speakerlocalitysummary) rows ordered by speaker count descending (`:302-311`). Finally the AI-score block loads every [SessionAiScore](group-17-conference-domain.md#sessionaiscore) whose `SessionId` is in the loaded set (`:98-104`), and `BuildAiScoreDtos` (`:337-360`) orders them by `OverallScore` descending and projects each one through `BuildSingleAiScoreDto` (`:362-392`). That projection is where the "Level" category is special-cased: the handler finds the first non-deleted category whose title contains "Level" (`:347-348`), collects its item ids (`:349-351`), and `ResolveCategoryInfo` (`:410-432`) then splits a session's tags into ordinary categories and the single level value. `ResolveSpeakerLocalities` (`:394-408`) produces the distinct tier names for a scored session's speakers, again defaulting to `"Unknown"`. `ScoredOn` prefers `LastModifiedOn` and falls back to `CreatedOn` (`:386`), the audit fields the framework stamps on save. +- **Why it's built this way** - the screen needs internally consistent numbers, and computing every block from one materialized snapshot is what guarantees the speaker panel and the category panel describe the same set of sessions. The AI scores are read here rather than computed here because scoring is background work: [ScoreEventSessionsHandler](#scoreeventsessionshandler) queues it and the dashboard surfaces whatever rows exist, which is why the queue endpoint tells the caller to refresh after a few minutes (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:96-99`). +- **Where it's used** - injected into [SessionSelectionController](group-20-conference-api-grpc.md#sessionselectioncontroller) as `IQueryHandler>` (`SessionSelectionController.cs:31`) and invoked from `GET SessionSelection/dashboard/{eventId}` (`:39-51`), the one decision-support endpoint the Blazor UI calls, through [SessionSelectionService](group-21-conference-ui.md#sessionselectionservice) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/SessionSelectionService.cs:16-30`) and onto the [SessionSelectionDashboard](group-21-conference-ui.md#sessionselectiondashboard) page. +- **Caveats / not-in-source** - the category-distribution and speaker-overlap logic is duplicated rather than shared: `CountCategoryItems` and `BuildCategoryGroups` here (`:133-156`, `:158-184`) mirror [GetCategoryDistributionHandler](#getcategorydistributionhandler)'s `CountSessionsPerCategoryItem` and `BuildCategoryGroups` (`GetCategoryDistributionHandler.cs:41-64`, `:66-92`), and `ComputeSpeakerOverlap` mirrors [GetSpeakerSessionOverlapHandler](#getspeakersessionoverlaphandler). Nothing in source keeps the copies aligned, and they already differ in one visible way: this handler loads speakers with `ignoreQueryFilters: true` while the standalone overlap handler does not, so a soft-deleted speaker appears on the dashboard and is absent from the narrow endpoint. The "Level" category is matched by a substring of the category title (`:347-348`), so renaming that category upstream silently empties `SessionLevel`. A session carrying more than one level tag resolves to whichever `ResolveCategoryInfo` encounters first (`:426-429`), decided by the enumeration order of the session's links. ### GetSpeakerSessionOverlapHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetSpeakerSessionOverlap` · `MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSpeakerSessionOverlap/GetSpeakerSessionOverlapHandler.cs:18` · Level 9 · class (sealed) - -- **What it is**: the standalone speaker-overlap handler. It returns every speaker who submitted at least one session for an event, with their sessions, sorted so multi-session speakers surface first: session count descending, then accepted-session presence, then name (`GetSpeakerSessionOverlapHandler.cs:18`, doc comment `:11-17`). -- **Depends on**: [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) (`:19`) and the [Session](group-17-conference-domain.md#session), [Speaker](group-17-conference-domain.md#speaker), and [Category](group-17-conference-domain.md#category) repositories (`:25-27`); [SpeakerLocalityHelper](#speakerlocalityhelper) and [LocalityLookupEntry](#localitylookupentry); [SessionStatuses](group-17-conference-domain.md#sessionstatuses); [Result](group-01-result-error-handling.md#result); and [SpeakerSessionOverlapDTO](group-17-conference-domain.md#speakersessionoverlapdto) / [MultiSessionSpeaker](group-17-conference-domain.md#multisessionspeaker) / [SpeakerSessionSummary](group-17-conference-domain.md#speakersessionsummary). Implements [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) of [GetSpeakerSessionOverlapQuery](#getspeakersessionoverlapquery) to `Result` (`:19`). -- **Concept introduced**: none new. It repeats the load-once-compute shape of [GetSessionSelectionDashboardHandler](#getsessionselectiondashboardhandler) and emits the identical [MultiSessionSpeaker](group-17-conference-domain.md#multisessionspeaker) projection with the same three-key sort, but for one panel instead of five. What it adds is an **early exit**: if the event has no submitting speakers it returns an empty result before issuing the speaker and category queries at all (`:38-39`), so the empty case costs one round trip rather than three. [Rubric §6, CQRS and Event-Driven] (one message per read intent, even when two intents overlap) and [Rubric §12, Performance and Scalability]. -- **Walkthrough** of `HandleAsync` (`:21`): - 1. Resolve the session, speaker, and category repositories (`:25-27`). - 2. Load non-service sessions for the event with `SessionSpeakers` and `SessionCategoryItems` included, untracked (`:29-33`). - 3. Group them by speaker id with `GroupSessionsBySpeaker` (`:35`, defined `:61-82`), which skips soft-deleted `SessionSpeaker` rows (`:66`); short-circuit to an empty `SpeakerSessionOverlapDTO` when no speaker ids came back (`:38-39`). - 4. Load exactly those speakers with `SpeakerCategoryItems` via `GetByIdsAsync`, untracked (`:41-45`), and all categories with `CategoryItems` (`:47-50`). - 5. Build the locality lookup by piping `FindLocalityCategories` into `BuildLocalityLookup` (`:52-53`) and the category-item name lookup with `BuildCategoryItemNameLookup` (`:54`, defined `:84-97`). - 6. `BuildMultiSessionSpeakers` (`:56`, defined `:99-140`) walks the loaded speakers, skips any with no grouped sessions (`:108-109`), stamps the locality tier via `SpeakerLocalityHelper.GetLocalityTier` (`:119`) and an accepted flag where a null `Status` counts as accepted (`:111-113`), projects each session through `BuildSessionSummary` (`:123`, defined `:142-153`) with its non-deleted category-item names, ordered by title case-insensitively (`:122`), and sorts the speakers by session count, then accepted presence, then name (`:127-137`). - 7. Return `Result.Success` with the list (`:58`). Like the similarity handler, this one has no failure path: note it does **not** validate that the event exists, so an unknown event id yields an empty list rather than a 404. -- **Why it's built this way**: organizers often want just the overlap view, so it is its own use case reusing the shared [SpeakerLocalityHelper](#speakerlocalityhelper) and the same DTOs the dashboard embeds. Because the response shape is identical, the UI can render one component against either endpoint. -- **Where it's used**: [SessionSelectionController](group-20-conference-api-grpc.md#sessionselectioncontroller)'s speaker-overlap action (`SessionSelectionController.cs:32`, `:67-78`), organizer-only and output-cached (`:28`, `:68`). -- **Testing**: `GetSpeakerSessionOverlapHandlerTests` on `HandlerTestBase` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/GetSpeakerSessionOverlapHandlerTests.cs:13`). -- **Caveats / not-in-source**: this handler's speaker load does **not** pass `ignoreQueryFilters: true` (`:41-45`), while the dashboard's equivalent load does (`GetSessionSelectionDashboardHandler.cs:57-62`). A speaker who was soft-deleted but still linked to a live session therefore appears on the dashboard's overlap panel and is silently missing from this standalone endpoint, even though both return the same DTO type. Nothing in either file cross-references the other. +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetSpeakerSessionOverlap` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSpeakerSessionOverlap/GetSpeakerSessionOverlapHandler.cs:18` · Level 9 · class (sealed) + +- **What it is** - the query handler that returns every speaker who submitted at least one session for an event, each with their sessions and locality tier, sorted so speakers holding several proposals appear first. +- **Depends on** - [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) (implemented) and [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) (the only constructor dependency, `:18-19`); the aggregates [Session](group-17-conference-domain.md#session), [Speaker](group-17-conference-domain.md#speaker), and [Category](group-17-conference-domain.md#category) plus the [SessionSpeaker](group-17-conference-domain.md#sessionspeaker) and [SpeakerCategoryItem](group-17-conference-domain.md#speakercategoryitem) links; [SpeakerLocalityHelper](#speakerlocalityhelper) and [LocalityLookupEntry](#localitylookupentry); [SessionStatuses](group-17-conference-domain.md#sessionstatuses); and the output contracts [SpeakerSessionOverlapDTO](group-17-conference-domain.md#speakersessionoverlapdto), [MultiSessionSpeaker](group-17-conference-domain.md#multisessionspeaker), and [SpeakerSessionSummary](group-17-conference-domain.md#speakersessionsummary) (`:4`). +- **Concept introduced - inverting an aggregate's direction in memory.** The database is queried session-first (sessions for an event, with their speaker links included, `:29-33`), but the answer is speaker-first. `GroupSessionsBySpeaker` (`:61-82`) performs that inversion: it flattens sessions into `(SpeakerId, Session)` pairs while skipping soft-deleted links (`:64-67`) and folds them into a dictionary of speaker to session list. Only then does the handler know which speakers to fetch, which is why the [Speaker](group-17-conference-domain.md#speaker) load is a `GetByIdsAsync` over the collected keys (`:41-45`, the interface at `MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:48`) rather than a second broad query. `[Rubric §12 - Performance & Scalability]` assesses query shape: three loads, no per-speaker round trip, and an early return that skips the speaker and category loads entirely when the event has no sessions (`:38-39`), a path pinned by `HandleAsync_WithNoSessions_ReturnsEmptyAndSkipsSpeakerLookup` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/GetSpeakerSessionOverlapHandlerTests.cs:131`). `[Rubric §4 - DDD]` assesses whether a concept is read from the aggregates the domain actually has: a speaker's origin is not a column but a category assignment, resolved by [SpeakerLocalityHelper](#speakerlocalityhelper) (`:52-53`, `:119`). `[Rubric §6 - CQRS & Event-Driven]` applies as for the siblings: a pure read with no failure path. +- **Walkthrough** - `HandleAsync` (`:21-59`) resolves session, speaker, and category repositories (`:25-27`), loads the event's non-service sessions with `SessionSpeakers` and `SessionCategoryItems` included and `asTracking: false` (`:29-33`), inverts them into the speaker-to-sessions dictionary (`:35`), and returns an empty [SpeakerSessionOverlapDTO](group-17-conference-domain.md#speakersessionoverlapdto) when no speaker was referenced (`:38-39`). Otherwise it loads exactly those speakers with `SpeakerCategoryItems` included (`:41-45`) and all categories with their items (`:47-50`), then builds two lookups: the locality lookup, via `SpeakerLocalityHelper.BuildLocalityLookup(SpeakerLocalityHelper.FindLocalityCategories(categories))` (`:52-53`), and category-item id to name (`:54`, `:84-97`). `BuildMultiSessionSpeakers` (`:99-140`) walks the loaded speakers, skips any with no sessions in the dictionary (`:108-109`), computes `HasAcceptedSession` by treating a null status or `SessionStatuses.Accepted` as accepted with `OrdinalIgnoreCase` (`:111-113`), stamps the locality tier (`:119`), and orders each speaker's sessions by title case-insensitively (`:122`). Each session becomes a [SpeakerSessionSummary](group-17-conference-domain.md#speakersessionsummary) through `BuildSessionSummary` (`:142-153`), which carries id, title, raw status, and the names of the session's non-deleted category items. The final sort (`:127-137`) is three-level: session count descending, then accepted-session presence, then speaker name with `OrdinalIgnoreCase`, which is what makes the list deterministic rather than dictionary-enumeration order. +- **Why it's built this way** - the class summary states the scope decision explicitly (`:12-16`): the endpoint returns every speaker, not only multi-session ones, because the UI renders a session-count column and lets the organizer see the whole roster while the sort surfaces the overlap cases first. Filtering server-side to speakers with two or more sessions would have made that same screen impossible without a second call. +- **Where it's used** - injected into [SessionSelectionController](group-20-conference-api-grpc.md#sessionselectioncontroller) as `IQueryHandler>` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:33`) and invoked from `GET SessionSelection/speaker-overlap/{eventId}` (`:67-79`). The Blazor UI does not call this endpoint: it reads the equivalent block off the dashboard response instead. +- **Caveats / not-in-source** - the type and method names are residue from the narrower original scope: the DTO element is still [MultiSessionSpeaker](group-17-conference-domain.md#multisessionspeaker) and the builder is still `BuildMultiSessionSpeakers` (`:99`) even though single-session speakers are included (`HandleAsync_IncludesSingleSessionSpeakers`, `GetSpeakerSessionOverlapHandlerTests.cs:174`). Unlike [GetSessionSelectionDashboardHandler](#getsessionselectiondashboardhandler), this handler's `GetByIdsAsync` call does not pass `ignoreQueryFilters` (`:41-45`, versus `GetSessionSelectionDashboardHandler.cs:57-62`), so the global soft-delete filter applies and a soft-deleted speaker is skipped along with every session only they submitted (`HandleAsync_SkipsSpeakersMissingFromRepository`, `GetSpeakerSessionOverlapHandlerTests.cs:217`). Whether that difference is intended is not stated in either file. `LocalityCategory` stays null when a speaker has no locality assignment; this handler does not substitute `"Unknown"` the way the dashboard's locality block does. ### ExportEventCalendarQuery -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportEventCalendarQuery.cs:5` · Level 0 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportEventCalendarQuery.cs:5` · Level 0 · record (sealed) -- **What it is**: the read request that asks for one published event's whole schedule rendered as an RFC 5545 (`.ics`) calendar document. A one-field `sealed record` carrying the `EventId` to export (`ExportEventCalendarQuery.cs:5`). -- **Depends on**: the `EventIdentifierType` alias (see [identifier aliases](00-primer.md#2-architectural-styles-this-codebase-commits-to)). Nothing else first-party, nothing external beyond the BCL. -- **Concept introduced, the request record as a CQRS message.** [Rubric §6, CQRS and Event-Driven] assesses whether every read is an explicitly named message routed to its own handler. The whole type is one line: `public sealed record ExportEventCalendarQuery(EventIdentifierType EventId);`. It names exactly the input its handler needs, carries no behavior, and is dispatched through the shared decorator pipeline to its [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) implementation, [ExportEventCalendarHandler](#exporteventcalendarhandler). [Rubric §5, Vertical Slice]: query, handler, and the mapper they share sit together under one `UseCases/ExportCalendar` folder rather than in layer-wide "Queries" and "Handlers" buckets. -- **Walkthrough**: a positional `record` with the single member `EventId` (`ExportEventCalendarQuery.cs:5`); the doc comment attributes the feature to [ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html) Wave 5 and documents the parameter (`ExportEventCalendarQuery.cs:3-4`). `record` supplies value equality and immutability, so the query is safe as a cache or log key in the pipeline. -- **Why it's built this way**: keeping the query minimal (an id and nothing else) leaves every publish and exportability rule in one place, the handler, instead of splitting it between the request and the code that serves it. -- **Where it's used**: handled by [ExportEventCalendarHandler](#exporteventcalendarhandler); the handler is injected into [EventsController](group-20-conference-api-grpc.md#eventscontroller) as `IQueryHandler>` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventsController.cs:52`) and the query is constructed in `ExportCalendarAsync` on `GET {id}/ics` (`EventsController.cs:213`), an `[AllowAnonymous]` action output-cached under the `EventsCache` policy that returns the string UTF-8 encoded as a `text/calendar` file named `event-{id}.ics` (`EventsController.cs:202-217`). +- **What it is** - the read request behind "add the whole conference to my calendar": one event id, answered with an RFC 5545 `.ics` document covering every exportable session on that event's schedule. +- **Depends on** - the `EventIdentifierType` alias, an `int` in this module (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8`). No other first-party types, nothing external. +- **Concept introduced - the query whose result is a document, not a DTO.** Every other read in this group resolves to a shaped DTO; this one resolves to `Result` where the string is a complete calendar file (`ExportEventCalendarHandler.cs:17`). The query/handler split itself is taught by [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) and is cross-referenced rather than re-taught. `[Rubric §9 - API & Contract Design]` assesses whether a contract matches the representation its consumer needs: a calendar client wants `text/calendar` bytes, so the use case produces the serialized document and the controller only wraps it in a `File(...)` response (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventsController.cs:214-217`). `[Rubric §6 - CQRS & Event-Driven]` applies as for the sibling reads: a named message with no side effects, bound to exactly one handler by the generic argument. +- **Walkthrough** - one positional parameter, `EventId` (`:5`), documented by the summary and `` above it (`:3-4`). No body, no defaults, no marker interface. +- **Why it's built this way** - the event-wide and single-session exports are genuinely different reads (one loads the whole schedule plus the room map, the other loads one session and then checks its parent), so they get separate messages instead of one query with a nullable session id. See [ExportSessionCalendarQuery](#exportsessioncalendarquery) for the narrow twin. +- **Where it's used** - constructed by [EventsController](group-20-conference-api-grpc.md#eventscontroller) on `GET Events/{id}/ics`, an `[AllowAnonymous]` action under the `EventsCache` output-cache policy (`EventsController.cs:207-218`), resolved through the injected `IQueryHandler>` (`:53`). The browser-side caller is the add-to-calendar button on [PublicEventDetail](group-21-conference-ui.md#publiceventdetail) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicEventDetail.razor:27-28`). +- **Caveats / not-in-source** - the doc comment tags the feature "ADR-042 Wave 5" (`:3`). ADR-042 is the MAUI device-capability abstraction (`Website/docs-src/adr/042-device-capability-abstraction.md:1`) and says nothing about iCalendar, so read that tag as a delivery-wave label rather than as a pointer to a specification of this export. ### ExportSessionCalendarQuery -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportSessionCalendarQuery.cs:5` · Level 0 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportSessionCalendarQuery.cs:5` · Level 0 · record (sealed) -- **What it is**: the single-session variant of the calendar export: it asks for one public session rendered as an `.ics` document, backing the add-to-calendar affordance. A one-field `sealed record` carrying the `SessionId` (`ExportSessionCalendarQuery.cs:5`). -- **Depends on**: the `SessionIdentifierType` alias. Nothing else. -- **Concept introduced**: none new; it is the sibling of [ExportEventCalendarQuery](#exporteventcalendarquery) and differs only in identifying one session rather than a whole event. [Rubric §6, CQRS and Event-Driven]. -- **Walkthrough**: a positional `record` with the single member `SessionId` (`ExportSessionCalendarQuery.cs:5`); same [ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html) Wave 5 attribution in the doc comment (`ExportSessionCalendarQuery.cs:3-4`). -- **Why it's built this way**: a separate query keeps the one-session public rules distinct from the whole-event export, so neither path has to branch on "did the caller want one or all". -- **Where it's used**: handled by [ExportSessionCalendarHandler](#exportsessioncalendarhandler); the handler is injected into [SessionsController](group-20-conference-api-grpc.md#sessionscontroller) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionsController.cs:50`) and the query is constructed on `GET {id}/ics` (`SessionsController.cs:279`), `[AllowAnonymous]` and output-cached under `SessionsCache`, returned as `session-{id}.ics` (`SessionsController.cs:268-283`). +- **What it is** - the read request for a single-session `.ics` document, the one behind the "add to calendar" button on a session page. +- **Depends on** - the `SessionIdentifierType` alias, an `int` in this module (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:15`). Nothing else. +- **Concept** - none new; it is the same document-producing read message as [ExportEventCalendarQuery](#exporteventcalendarquery), which teaches the shape. `[Rubric §5 - Vertical Slice]` assesses feature cohesion: both queries, both handlers, and the mapper they share sit in one `ExportCalendar` folder, so the whole capability is one directory. +- **Walkthrough** - one positional parameter, `SessionId` (`:5`), with the summary and `` above it (`:3-4`). +- **Why it's built this way** - the single-session export is what a public attendee actually uses while browsing the agenda, and it enforces a stricter rule than the event export does (the session itself must be exportable, not merely present in a published event). Keeping it a separate message keeps that rule in one handler rather than as a branch inside a combined one. +- **Where it's used** - constructed by [SessionsController](group-20-conference-api-grpc.md#sessionscontroller) on `GET Sessions/{id}/ics`, `[AllowAnonymous]` under the `SessionsCache` policy (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionsController.cs:272-283`), through the injected `IQueryHandler>` (`:50`). The UI caller is the add-to-calendar button on [PublicSessionDetail](group-21-conference-ui.md#publicsessiondetail) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSessionDetail.razor:38-39`). ### ScoreEventSessionsCommand -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/ScoreEventSessionsCommand.cs:5` · Level 0 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/ScoreEventSessionsCommand.cs:5` · Level 0 · record (sealed) -- **What it is**: the write request that triggers AI scoring for every session in an event. A one-field `sealed record` carrying the `EventId` whose sessions to score (`ScoreEventSessionsCommand.cs:3-5`). -- **Depends on**: the `EventIdentifierType` alias. No externals. -- **Concept introduced**: this is the one **command** among the sibling request records in this unit. It is structurally identical to the query records, but it dispatches through the command side of the pipeline ([ICommandHandler](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult)), which carries the Validating and Transactional decorators the query side does not. Note the record implements no marker interface (`:5`), so the Transactional decorator opens no transaction for it: durability comes from the handler's own per-session `SaveChangesAsync` instead. [Rubric §6, CQRS and Event-Driven] is precisely the split modeled here: a read (the dashboard) and a write (the scoring run) that happen to share a shape are still separate message types on separate pipelines. -- **Walkthrough**: a single `EventId` positional parameter (`:5`), documented at `:4`. -- **Why it's built this way**: scoring mutates persistence (it deletes and rewrites [SessionAiScore](group-17-conference-domain.md#sessionaiscore) rows, `ScoreEventSessionsHandler.cs:105-107`), so it is a command, not a query, and keeping it a distinct message makes that read/write asymmetry explicit. -- **Where it's used**: constructed by the hosted drain [SessionScoringProcessor](group-19-conference-infrastructure.md#sessionscoringprocessor) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Services/SessionScoringProcessor.cs:193`), never by the controller: the endpoint only enqueues. Handled by [ScoreEventSessionsHandler](#scoreeventsessionshandler), which resolves through DI as `ICommandHandler>` (`SessionScoringProcessor.cs:190-191`) and returns a [ScoreEventSessionsResultDTO](group-17-conference-domain.md#scoreeventsessionsresultdto). +- **What it is** - the write message that says "score every non-service session on this event with the AI model". One positional `EventId` and nothing else. +- **Depends on** - the `EventIdentifierType` alias (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8`). Nothing external. +- **Concept introduced - the command nobody sends from a request thread.** Unlike the other commands in this group, no controller ever constructs this record. The HTTP surface enqueues an event id instead (see [ISessionScoringQueue](#isessionscoringqueue)), and the only construction site is the hosted drain worker resolving the handler inside its own DI scope (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Services/SessionScoringProcessor.cs:190-194`). `[Rubric §6 - CQRS & Event-Driven]` assesses whether writes travel as explicit messages: keeping the run a real `ICommandHandler` command rather than a plain service method means the drain worker goes through the same handler pipeline a controller dispatch would. `[Rubric §12 - Performance & Scalability]` assesses request economy: the expensive work is named here and executed elsewhere, so the request thread returns in milliseconds. +- **Walkthrough** - one positional parameter, `EventId` (`:5`), with the one-line summary above it (`:3-4`). +- **Why it's built this way** - a scoring run takes minutes and issues one paid Anthropic call per session (`ISessionScoringQueue.cs:19-21`). Modelling it as a command lets the queue carry only an id while the handler stays a normal, testable use case. +- **Where it's used** - resolved and dispatched by [SessionScoringProcessor](group-19-conference-infrastructure.md#sessionscoringprocessor) as `ICommandHandler>` (`SessionScoringProcessor.cs:190-194`); handled by [ScoreEventSessionsHandler](#scoreeventsessionshandler). ### SessionScoringEnqueueResult > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/ISessionScoringQueue.cs:4` · Level 0 · enum -- **What it is**: the three-valued outcome of asking to score an event's sessions: `Queued`, `AlreadyPending`, `QueueFull` (`ISessionScoringQueue.cs:4-14`). It is the return type of [ISessionScoringQueue](#isessionscoringqueue)'s `TryEnqueue`. -- **Depends on**: nothing; a plain public enum co-located with the interface that returns it. -- **Concept introduced, an explicit refusal vocabulary instead of a bool.** A `bool TryEnqueue` could only say yes or no; the caller could not tell "your run is already going" from "we are saturated, come back later", and those need different HTTP answers. Naming all three outcomes lets the edge translate each one without inspecting queue internals. [Rubric §9, API and Contract Design] assesses whether a contract carries enough information for its caller to act: here the enum is the reason the endpoint can distinguish a 202 from two different 409s. -- **Walkthrough**: `Queued` (`:7`), accepted and awaiting the drain worker; `AlreadyPending` (`:10`), an existing run for the same event is queued or executing and continues untouched; `QueueFull` (`:13`), the bounded channel is at capacity and the caller should retry later. -- **Why it's built this way**: [ADR-052](https://ivanball.github.io/docs/adr/052-background-job-execution.html) (background job execution) requires expensive work to **refuse** rather than silently coalesce or drop (`052-background-job-execution.md:46-54`), and a refusal is only useful if the caller learns which refusal it was. -- **Where it's used**: returned by [SessionScoringQueue](#sessionscoringqueue)'s `TryEnqueue` (`SessionScoringQueue.cs:64-77`) and switched on by [SessionSelectionController](group-20-conference-api-grpc.md#sessionselectioncontroller)'s `ScoreSessions` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:110-128`), which maps `Queued` to 202 Accepted (`:114`) and the other two to distinct 409 Conflict errors, `SessionScoring.AlreadyRunning` (`:117-120`) and `SessionScoring.QueueFull` (`:124-127`). +- **What it is** - the three-valued answer to "did my scoring request get in": `Queued`, `AlreadyPending`, or `QueueFull` (`:7`, `:10`, `:13`). +- **Depends on** - nothing. It is a bare enum with default integer backing and no attributes. +- **Concept introduced - the tri-state accept, and why it is not a bool.** A boolean enqueue result would collapse two refusals that need different words at the API edge: "your run is already in flight, do nothing" versus "the queue is saturated, come back". The controller maps them to distinct problem codes on the same HTTP status, `SessionScoring.AlreadyRunning` and `SessionScoring.QueueFull`, both 409 (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:112-130`). `[Rubric §9 - API & Contract Design]` assesses whether a refusal is expressed with enough fidelity for a caller to act on it: an operator seeing "already running" waits, an operator seeing "queue full" retries. `[Rubric §29 - Resilience & Business Continuity]` assesses back-pressure: refusing outright is the deliberate alternative to blocking a request thread on a bounded channel. +- **Walkthrough** - three members in acceptance order, each with a one-line doc comment stating the caller's next move (`:6-13`). There is no `None` or `Unknown` member: every path through `TryEnqueue` returns one of the three explicitly (`SessionScoringQueue.cs:69`, `:72`, `:76`). +- **Why it's built this way** - the dedup decision and the capacity decision are made at different points inside `TryEnqueue` (a lost `TryAdd` versus a failed `TryWrite`), so the return type carries both outcomes rather than forcing the caller to re-inspect the queue. +- **Where it's used** - returned by `ISessionScoringQueue.TryEnqueue` (`ISessionScoringQueue.cs:36`), produced by [SessionScoringQueue](#sessionscoringqueue), and switched on by `SessionSelectionController.ScoreSessions` (`SessionSelectionController.cs:112`). ### SessionScoringResult -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/IAiScoringService.cs:40` · Level 0 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/IAiScoringService.cs:40` · Level 0 · record (sealed) -- **What it is**: the Application-layer result of scoring one session via [IAiScoringService](#iaiscoringservice). It carries seven numeric sub-scores (each documented `1.0-10.0` decimal), a free-text `Reasoning`, the `SessionId`, and a `Success` flag. A failed AI call returns this record with `Success = false` rather than throwing (`IAiScoringService.cs:40-71`). -- **Depends on**: the `SessionIdentifierType` alias (on `SessionId`, `:43`). No first-party types; every property is `required init`. -- **Concept introduced, never-throw service results.** Rather than propagate exceptions from the AI call, `ScoreSessionAsync` returns this record in every case (the contract is stated at `IAiScoringService.cs:9`) and the handler branches on `Success` (`ScoreEventSessionsHandler.cs:72`). This is the Result philosophy (see [Result](group-01-result-error-handling.md#result)) applied to an unreliable network dependency: the scoring loop cannot be aborted by one bad session. [Rubric §29, Resilience and Business Continuity] assesses how a dependency failure is contained; here it is demoted to a per-item flag rather than an exception that unwinds the whole batch. -- **Walkthrough**: ten `required init` members (`:43-70`): `SessionId`, `OverallScore`, `TopicRelevanceScore`, `DescriptionQualityScore`, `NoveltyScore`, `ActionableTakeawaysScore`, `DepthOrInsightQualityScore`, `CredibilityExperienceScore`, `Reasoning`, `Success`. `required` on all of them means a scorer implementation cannot forget to populate one, and `init` means a result cannot be edited after the scorer hands it back. -- **Why it's built this way**: separating this Application-layer result from the external [SessionAiScoreDTO](group-17-conference-domain.md#sessionaiscoredto) lets the scoring contract evolve (add or drop a sub-score) without immediately breaking the API surface. -- **Where it's used**: returned by `IAiScoringService.ScoreSessionAsync` and consumed by [ScoreEventSessionsHandler](#scoreeventsessionshandler), which feeds a successful one plus the scorer's `ModelId` into `SessionAiScore.Create` (`ScoreEventSessionsHandler.cs:79-83`) to make a [SessionAiScore](group-17-conference-domain.md#sessionaiscore) domain row. -- **Caveats / not-in-source**: the `1.0-10.0` range lives only in the doc comments here (`:45-64`); this record enforces no bound. The range check that does exist is in the domain factory `SessionAiScore.Create`, whose `EnsureScoreInRange` rejects anything outside `>= 1.0m and <= 10.0m` for all seven sub-scores (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionAiScore.cs:68-74`, `:134-135`); the handler treats such a rejection as just another counted failure (`ScoreEventSessionsHandler.cs:85-90`). +- **What it is** - what the AI scorer hands back for one session: seven numeric sub-scores, the model's free-text `Reasoning`, and a `Success` flag that says whether any of it means anything. +- **Depends on** - the `SessionIdentifierType` alias (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:15`) and BCL `decimal`, `string`, `bool`. No first-party types. +- **Concept introduced - the never-throw service result.** [IAiScoringService](#iaiscoringservice) contracts that scoring never throws and reports failure in the result instead (`IAiScoringService.cs:9`), and this record is the vehicle. The adapter's failure path builds it with every score at `0m`, `Reasoning = "Scoring failed"`, and `Success = false` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Services/AnthropicScoringService.cs:181-192`), so one bad session never aborts a loop over hundreds. `[Rubric §29 - Resilience & Business Continuity]` assesses whether a flaky external dependency degrades one item or the whole run: here it degrades one. `[Rubric §13 - Observability & Operability]` assesses whether an outcome is legible after the fact: `Reasoning` is persisted onto [SessionAiScore](group-17-conference-domain.md#sessionaiscore) alongside the model id, so an organizer can see why a session scored what it scored and which model said so. +- **Walkthrough** - ten `required init` members (`:43-70`): `SessionId`; the seven `decimal` scores `OverallScore`, `TopicRelevanceScore`, `DescriptionQualityScore`, `NoveltyScore`, `ActionableTakeawaysScore`, `DepthOrInsightQualityScore`, `CredibilityExperienceScore`, each documented as 1.0 to 10.0; `Reasoning`; and `Success`. `required` on all ten means no partially-populated instance can be constructed, which is what lets the handler read `result.SessionId` rather than the loop variable when building the entity (`ScoreEventSessionsHandler.cs:80`). +- **Why it's built this way** - the 1.0 to 10.0 range in the doc comments is documentation on this record only. The invariant is enforced one layer in, by `SessionAiScore.Create`, which rejects anything outside `>= 1.0m and <= 10.0m` with a `SessionAiScore.OutOfRange` error (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionAiScore.cs:134-141`). Keeping the transport record permissive and the entity strict means a model that returns nonsense produces a counted failure instead of a corrupt row. +- **Where it's used** - returned by `IAiScoringService.ScoreSessionAsync` (`IAiScoringService.cs:11-13`), produced by [AnthropicScoringService](group-19-conference-infrastructure.md#anthropicscoringservice) and by [FakeAiScoringService](group-27-testing-infrastructure.md#fakeaiscoringservice) in tests, consumed by [ScoreEventSessionsHandler](#scoreeventsessionshandler) (`:70-83`). It is application-internal: the shape the API returns is [SessionAiScoreDTO](group-17-conference-domain.md#sessionaiscoredto). +- **Caveats / not-in-source** - a `Success = false` result carries all-zero scores, which `SessionAiScore.Create` would reject outright. Nothing in the type enforces that pairing; the handler simply never reaches `Create` on a failed result because it checks `Success` first (`ScoreEventSessionsHandler.cs:72-77`). ### SessionScoringWorkItem -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/SessionScoringQueue.cs:21` · Level 0 · record struct +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/SessionScoringQueue.cs:21` · Level 0 · readonly record struct -- **What it is**: one queued AI scoring run, as it travels through the channel: which event to score, and which attempt this is. A `readonly record struct` of `(EventIdentifierType EventId, int Attempt)` (`SessionScoringQueue.cs:21`). -- **Depends on**: the `EventIdentifierType` alias, and the BCL `StructLayoutAttribute` / `LayoutKind.Auto` from `System.Runtime.InteropServices` (`:20`). Nothing else first-party. -- **Concept introduced, retry state that rides on the message.** The attempt count travels **with** the item rather than living in a side table, so the drain worker can decide whether a failed run is worth retrying without keeping any per-event state of its own (`:7-15`). The doc comment is explicit about the cost of that choice: a crash between the failure and the requeue loses the retry, which is the intended floor, because the queue is in-process and best-effort and an organizer can always trigger the run again. [Rubric §29, Resilience and Business Continuity] assesses whether the failure model is stated and bounded rather than assumed; here the guarantee is deliberately weak and written down. [Rubric §12, Performance and Scalability]: a `readonly record struct` means each queued item is a stack-sized value with no allocation per enqueue, and `[StructLayout(LayoutKind.Auto)]` (`:20`) lets the runtime pack the two fields rather than forcing sequential layout. -- **Walkthrough**: two positional members, `EventId` (documented at `:16`) and `Attempt` (`:17-19`), where `1` is the original request and each bounded retry the drain schedules increments it by one. `readonly` makes every member non-mutating, so an item cannot be edited in flight; `record struct` supplies value equality for free, which is what makes the queue's unit tests able to assert on a dequeued item directly (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/DecisionSupport/SessionScoringQueueTests.cs:79`). -- **Why it's built this way**: [ADR-052](https://ivanball.github.io/docs/adr/052-background-job-execution.html) puts the retry policy in the drain worker, not in the queue; carrying the attempt on the item is what lets the worker stay stateless while still enforcing a cap. -- **Where it's used**: it is the channel's element type inside [SessionScoringQueue](#sessionscoringqueue) (`:43-44`), constructed on the enqueue path with `FirstAttempt` (`:71`) and on the requeue path with the caller's attempt number (`:97`); consumed by [SessionScoringProcessor](group-19-conference-infrastructure.md#sessionscoringprocessor), which reads items off `Reader.ReadAllAsync` (`SessionScoringProcessor.cs:107`) and compares `item.Attempt` against its own `MaxAttempts` of `3` before re-queuing (`SessionScoringProcessor.cs:74`, `:143`). +- **What it is** - one queued scoring run: which event to score, and which attempt this is. Two values in a `readonly record struct` marked `[StructLayout(LayoutKind.Auto)]` (`:20-21`). +- **Depends on** - the `EventIdentifierType` alias (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8`), plus `System.Runtime.InteropServices.StructLayoutAttribute` (`:2`). No first-party dependencies. +- **Concept introduced - retry state that travels with the message.** The obvious alternative is a side table of "how many times have I tried event 7", owned by the drain worker. Carrying `Attempt` on the item instead means the worker keeps no per-event state at all: it reads an item, and if the run throws it re-queues the same item with `Attempt + 1` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Services/SessionScoringProcessor.cs:143`). The doc comment names the price honestly: a crash between the failure and the requeue loses the retry, which is the intended floor for an in-process, best-effort queue that an organizer can always trigger again (`:11-14`). `[Rubric §29 - Resilience & Business Continuity]` assesses whether the durability level of a mechanism is chosen and stated rather than assumed. `[Rubric §12 - Performance & Scalability]` applies in a small way: a struct element in a `Channel` avoids a heap allocation per enqueue, and `LayoutKind.Auto` lets the runtime pack the fields. +- **Walkthrough** - two positional members (`:21`): `EventId` and `Attempt`. `Attempt` is documented as 1 for the original request, incremented by one on each bounded retry the drain worker schedules (`:17-19`); the constant supplying that initial 1 lives on the queue as `FirstAttempt` (`SessionScoringQueue.cs:41`). +- **Why it's built this way** - a `readonly record struct` gives value equality and immutability with no allocation, which suits a message that is written, read once, and discarded. +- **Where it's used** - the element type of the queue's bounded `Channel` (`SessionScoringQueue.cs:43-49`), written by `TryEnqueue` (`:71`) and `TryRequeue` (`:97`), read by [SessionScoringProcessor](group-19-conference-infrastructure.md#sessionscoringprocessor) through `queue.Reader.ReadAllAsync` (`SessionScoringProcessor.cs:107`). +- **Caveats / not-in-source** - the retry ceiling is not on this type. `MaxAttempts = 3` is a private constant on the drain worker (`SessionScoringProcessor.cs:74`), so nothing in the item itself stops a different consumer from re-queuing forever. ### SpeakerInfo -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/IAiScoringService.cs:23` · Level 0 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/IAiScoringService.cs:23` · Level 0 · record (sealed) -- **What it is**: the minimal speaker payload the AI scorer needs: `FullName`, optional `TagLine`, optional `Bio`. A positional `sealed record` (`IAiScoringService.cs:23-26`). -- **Depends on**: nothing first-party; three `string` / `string?` members. -- **Concept introduced, least-privilege data passing.** The record deliberately carries only what the model reads (name, tagline, bio) and no ids, contact fields, or other PII. [Rubric §11, Security] and [Rubric §30, Compliance, Privacy and Data Governance] both assess how much data crosses a boundary to a third party: shipping a purpose-built projection to the AI vendor rather than a whole [Speaker](group-17-conference-domain.md#speaker) limits what leaves the trust boundary. -- **Walkthrough**: three positional parameters (`:23-26`); the doc comments (`:19-22`) note the source-side maximum lengths (`TagLine` 500 characters, `Bio` 4000) that the domain enforces upstream. -- **Why it's built this way**: a narrow record keeps the scoring prompt small and keeps speaker identity beyond the name out of the external model call. -- **Where it's used**: nested inside [SessionScoringInput](#sessionscoringinput); populated by [ScoreEventSessionsHandler](#scoreeventsessionshandler) from each [Speaker](group-17-conference-domain.md#speaker)'s `FullName` / `TagLine` / `Bio` (`ScoreEventSessionsHandler.cs:64`), with speakers that miss the lookup filtered out before the list is built (`:63-67`). -- **Caveats / not-in-source**: a differently-shaped `SpeakerInfo` also exists in the Conference UI layer ([SpeakerInfo](group-21-conference-ui.md#speakerinfo)); the two are unrelated types that share a name. The speaker's `FullName` and `Bio` do still leave the trust boundary on every scoring call, so "least privilege" here means a narrowed projection, not an anonymized one. +- **What it is** - the slice of a speaker that the AI model is allowed to see: full name, optional tagline, optional biography (`:23-26`). +- **Depends on** - nothing but BCL strings. Notably it does not carry the `SpeakerIdentifierType` (a `Guid` in this module, `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:19`), nor an email, a photo url, or any other [Speaker](group-17-conference-domain.md#speaker) field. +- **Concept introduced - the minimized projection at an external boundary.** Everything in this record leaves the system: [AnthropicScoringService](group-19-conference-infrastructure.md#anthropicscoringservice) concatenates the name, tagline, and bio straight into the prompt body it posts to Anthropic (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Services/AnthropicScoringService.cs:167-171`). Building a purpose-shaped record instead of passing the entity means the set of fields that can reach a third party is a three-line declaration a reviewer can read at a glance. `[Rubric §30 - Compliance/Privacy/Data Governance]` assesses data minimization at a processor boundary: the identifier is deliberately absent, so what leaves is speaker-authored public bio text with no key to join it back. `[Rubric §11 - Security]` assesses whether such a boundary is explicit rather than incidental; here it is a type, not a convention. +- **Walkthrough** - three positional members (`:23-26`), the last two nullable. The handler builds one per non-deleted [SessionSpeaker](group-17-conference-domain.md#sessionspeaker) it can resolve, from `speaker.FullName`, `speaker.TagLine`, `speaker.Bio` (`ScoreEventSessionsHandler.cs:61-67`). +- **Why it's built this way** - a scoring prompt needs the speaker's credibility signals and nothing else. Widening the model would silently widen what is sent to a paid third-party API, which is the kind of change a dedicated record forces into review. +- **Where it's used** - as the `Speakers` list on [SessionScoringInput](#sessionscoringinput) (`IAiScoringService.cs:37`); constructed only in [ScoreEventSessionsHandler](#scoreeventsessionshandler) (`:64`). +- **Caveats / not-in-source** - the doc comments state "max 500 chars" for `TagLine` and "max 4000 chars" for `Bio` (`:21-22`), but nothing in this record, the handler, or the Anthropic adapter truncates or validates either value: the adapter appends whatever it is given (`AnthropicScoringService.cs:167-171`). Treat those numbers as descriptive of the source fields, not as an enforced bound on the prompt. A different, unrelated `SpeakerInfo` exists in the Conference UI assembly ([SpeakerInfo](group-21-conference-ui.md#speakerinfo)); the two only share a name. ### ISessionScoringQueue > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/ISessionScoringQueue.cs:31` · Level 1 · interface -- **What it is**: the producer-side port for requesting an AI scoring run. The API edge calls `TryEnqueue` and gets an immediate answer; the run itself happens later on a hosted worker (`ISessionScoringQueue.cs:31-41`). -- **Depends on**: [SessionScoringEnqueueResult](#sessionscoringenqueueresult) (Level 0, same file `:4`) as its return type, and the `EventIdentifierType` alias as its key. No externals. -- **Concept introduced, queue-plus-drain instead of fire-and-forget.** The interface doc (`:16-30`) records the history explicitly: scoring an event takes minutes and issues one paid Anthropic call per session, so it cannot run on the request thread, and it used to run as an untracked task started from the controller. That shape had three defects, all named in the comment: nothing tracked it, so a deploy or scale-in killed it mid-run with no record; nothing deduplicated it, so two clicks meant two concurrent passes over the same event, doubling the spend and racing each other's writes; and it ignored the host lifetime, so shutdown could neither wait for it nor cancel it. Declaring the port in the Application layer keeps the API edge unaware of channels and hosted services entirely. [Rubric §3, Clean Architecture] assesses dependency inversion at layer boundaries, and [Rubric §29, Resilience and Business Continuity] assesses whether long-running work survives (or fails cleanly across) a restart. -- **Walkthrough**: `TryEnqueue(EventIdentifierType)` (`:36`) requests a run and returns which of the three outcomes happened; `IsPending(EventIdentifierType)` (`:40`) reports whether a run for that event is queued or currently executing. The doc at `:27-29` states the dedup posture: a second request while one is in flight is **refused, not coalesced**, so the caller learns the run is already going. Note what is deliberately absent from the port: the reader side and the completion callback live on the concrete class, not here, so a producer cannot accidentally drain the queue. -- **Why it's built this way**: [ADR-052](https://ivanball.github.io/docs/adr/052-background-job-execution.html) makes the bounded queue plus single-reader hosted drain the standard shape for in-process background work (`052-background-job-execution.md:34-54`), and puts capacity, full-mode, and dedup policy inside the queue type so a caller cannot get them wrong. -- **Where it's used**: injected into [SessionSelectionController](group-20-conference-api-grpc.md#sessionselectioncontroller) (`SessionSelectionController.cs:34`), whose `ScoreSessions` action is its only production caller (`:110`); implemented by [SessionScoringQueue](#sessionscoringqueue), registered as a singleton in the Conference application DI (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:50-51`). -- **Caveats / not-in-source**: `IsPending` has no production caller today. It is exercised only from tests: the queue's own unit tests (`SessionScoringQueueTests.cs:19`, `:49`, `:62`, `:102`, `:121`) and the drain's tests, which use it to observe that the claim was released (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Infrastructure.Tests/Services/SessionScoringProcessorTests.cs:43`, `:69`, `:94`, `:146`). +- **What it is** - the producer-side port for AI scoring runs: ask for an event to be scored, or ask whether one is already in flight. Two methods, neither async. +- **Depends on** - [SessionScoringEnqueueResult](#sessionscoringenqueueresult) (same file, `:4`) and the `EventIdentifierType` alias (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8`). Nothing external. +- **Concept introduced - the request-shedding work queue, and why fire-and-forget was not enough.** The interface's own doc comment (`:16-30`) is the design record for the whole feature. Scoring an event takes minutes and issues one paid Anthropic call per session, so it cannot run on the request thread; it previously ran as a fire-and-forget task started from the controller, which had three named problems: nothing tracked it, so a deploy or scale-in killed it mid-run with no record; nothing deduplicated it, so two clicks meant two concurrent passes over the same event, doubling the spend and racing each other's writes; and it ignored the host lifetime, so shutdown could not wait for or cancel it (`:20-24`). The port answers all three by handing the work to a hosted drain. Note the deliberate dedup choice: a second request is refused rather than silently coalesced, so the caller learns the run is already in flight (`:26-29`). `[Rubric §29 - Resilience & Business Continuity]` assesses whether long work survives the request that started it. `[Rubric §31 - Cost/FinOps]` assesses spend control on a metered dependency: dedup here is a money guard as much as a correctness one. `[Rubric §3 - Clean Architecture]` assesses the direction of dependency: the port is declared in Application, while the channel implementation and the hosted worker that drains it are wired nearer the host, so the controller sees neither. +- **Walkthrough** - `TryEnqueue(EventIdentifierType)` (`:36`) returns [SessionScoringEnqueueResult](#sessionscoringenqueueresult) rather than a bool, so the caller can distinguish "already pending" from "queue full". `IsPending(EventIdentifierType)` (`:40`) reports whether a run is queued **or currently executing**, not merely waiting: the implementation holds the claim until the run finishes (`SessionScoringQueue.cs:51-55`). Both methods are synchronous, which is what makes the enqueue safe to call from an MVC action with no awaits at all. +- **Why it's built this way** - separating the producer port from the concrete [SessionScoringQueue](#sessionscoringqueue) keeps the consumer side (`Reader`, `TryRequeue`, `MarkCompleted`) off the interface the API layer can reach. A controller can only ask; only the drain worker, which resolves the concrete class, can consume or complete. +- **Where it's used** - injected into `SessionSelectionController` for `POST SessionSelection/score/{eventId}` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:35`, `:106-131`) and into [SessionScoringSweepJob](group-19-conference-infrastructure.md#sessionscoringsweepjob), the five-minute crash-recovery backstop that re-enqueues events whose pass started but never finished (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Services/SessionScoringSweepJob.cs:56`, rationale at `:13-20`). Registered as a singleton that forwards to the one concrete instance (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:50-55`). ### SessionScoringInput -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/IAiScoringService.cs:33` · Level 1 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/IAiScoringService.cs:33` · Level 1 · record (sealed) -- **What it is**: the full input for scoring one session: `SessionId`, `Title`, optional `Description`, and the list of [SpeakerInfo](#speakerinfo) records for the session's speakers. A positional `sealed record` (`IAiScoringService.cs:33-37`). -- **Depends on**: [SpeakerInfo](#speakerinfo) (Level 0, same file `:23`) via `IReadOnlyList`; the `SessionIdentifierType` alias. Referencing a Level-0 first-party type is what puts this record at Level 1. -- **Concept introduced**: none new; it is the request DTO for [IAiScoringService](#iaiscoringservice), assembling exactly what the model prompt needs (title, description, speaker bios) and nothing more, extending the least-privilege discipline [SpeakerInfo](#speakerinfo) sets. [Rubric §6, CQRS and Event-Driven] and [Rubric §30, Compliance, Privacy and Data Governance]. -- **Walkthrough**: four positional parameters (`:33-37`), documented at `:28-32`; `Speakers` may be empty (stated at `:32`), so a speaker-less session still scores. -- **Why it's built this way**: a purpose-built input record keeps the port contract stable and lets the use case be tested against a fake scorer without constructing domain aggregates. -- **Where it's used**: constructed per session by [ScoreEventSessionsHandler](#scoreeventsessionshandler) (`ScoreEventSessionsHandler.cs:69`) and passed straight to `IAiScoringService.ScoreSessionAsync` (`:70`). +- **What it is** - everything the AI scorer is given about one session: its id, title, optional description, and the [SpeakerInfo](#speakerinfo) projections for its speakers (`:33-37`). +- **Depends on** - [SpeakerInfo](#speakerinfo) (same file, `:23`) and the `SessionIdentifierType` alias (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:15`). External: `IReadOnlyList` only. +- **Concept** - none new; it is the request half of the never-throw port taught at [IAiScoringService](#iaiscoringservice), and the minimization rationale is taught at [SpeakerInfo](#speakerinfo). `[Rubric §3 - Clean Architecture]` assesses whether an external capability is described in the application's own language: this record names a session and its speakers, not a prompt, a token budget, or a JSON body, all of which stay inside the Infrastructure adapter. +- **Walkthrough** - four positional members (`:33-37`). `Speakers` is documented as possibly empty (`:32`), and the handler does produce an empty list for a session whose speaker links resolve to nothing (`ScoreEventSessionsHandler.cs:61-67`). `SessionId` is carried through the call and echoed back on [SessionScoringResult](#sessionscoringresult), which is what lets the handler pair a result with its session without holding a map. +- **Why it's built this way** - passing a purpose-built input record rather than the [Session](group-17-conference-domain.md#session) entity keeps the domain aggregate out of the adapter and keeps the prompt's ingredients auditable in four lines. +- **Where it's used** - the sole payload parameter of `IAiScoringService.ScoreSessionAsync` (`:11-13`); built once per session by [ScoreEventSessionsHandler](#scoreeventsessionshandler) (`:69`), consumed by [AnthropicScoringService](group-19-conference-infrastructure.md#anthropicscoringservice). ### IAiScoringService > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/IAiScoringService.cs:6` · Level 2 · interface -- **What it is**: the application-layer **port** for scoring a single conference session with an AI model. Its contract guarantees it never throws: failure is reported through the returned [SessionScoringResult](#sessionscoringresult)'s `Success` flag (`IAiScoringService.cs:6-17`). -- **Depends on**: [SessionScoringInput](#sessionscoringinput) (Level 1, `:33`) as the argument and [SessionScoringResult](#sessionscoringresult) (Level 0, `:40`) as the return; BCL `Task` / `CancellationToken` otherwise. -- **Concept introduced, a port-and-adapter boundary for an external AI capability.** The Application layer declares the interface; the Anthropic HTTP and JSON details live in an Infrastructure adapter, so the vendor protocol never reaches Application. [Rubric §3, Clean Architecture] assesses whether outward dependencies are inverted behind an abstraction, which this does exactly, and [Rubric §1, SOLID] (the Dependency Inversion Principle) is the same story: the handler depends on this port, not a concrete API client. The never-throws clause (documented at `:9`) also ties to [Rubric §29, Resilience and Business Continuity], and [Rubric §14, Testability] follows from both: [ScoreEventSessionsHandler](#scoreeventsessionshandler) is unit-tested against a fake scorer (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/ScoreEventSessionsHandlerTests.cs:13`). -- **Walkthrough**: `ScoreSessionAsync(SessionScoringInput, CancellationToken)` (`:11-13`) returns the per-session result; `ModelId` (`:15-16`) exposes which model produced the score, so persisted rows can record the model used for auditability. Note that `ModelId` is a property on the port rather than a field of the result, which is what lets the handler stamp every row it writes without asking the scorer twice (`ScoreEventSessionsHandler.cs:83`). -- **Why it's built this way**: defining the port in Application lets the scoring use case be unit-tested with a fake scorer and lets the AI vendor be swapped without touching [ScoreEventSessionsHandler](#scoreeventsessionshandler). -- **Where it's used**: injected into [ScoreEventSessionsHandler](#scoreeventsessionshandler) (`ScoreEventSessionsHandler.cs:20`); implemented by the Infrastructure adapter [AnthropicScoringService](group-19-conference-infrastructure.md#anthropicscoringservice), whose `ModelId` is the literal `claude-haiku-4-5-20251001` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Services/AnthropicScoringService.cs:22`) and which sends that same id as the request model (`AnthropicScoringService.cs:42`). +- **What it is** - the application-layer port for scoring one conference session with an AI model, plus a property naming the model that did the scoring. +- **Depends on** - [SessionScoringInput](#sessionscoringinput) and [SessionScoringResult](#sessionscoringresult), both declared in this same file (`:33`, `:40`), which in turn use [SpeakerInfo](#speakerinfo) (`:23`). External: BCL only. There is no Anthropic client type anywhere in the Application assembly. +- **Concept introduced - port and adapter for an unreliable, paid, external capability.** `[Rubric §3 - Clean Architecture]` assesses which layer owns the abstraction: the application declares the port, while the HTTP client, the prompt text, the JSON contract records, and the API key all live in Infrastructure's [AnthropicScoringService](group-19-conference-infrastructure.md#anthropicscoringservice), bound by `AddHttpClient` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:29`). `[Rubric §1 - SOLID]` assesses the dependency-inversion direction: [ScoreEventSessionsHandler](#scoreeventsessionshandler) depends on this interface, so swapping vendors touches one registration. `[Rubric §29 - Resilience & Business Continuity]` assesses failure containment: the "never throws, failure is indicated in the result" clause (`:9`) is the Result philosophy applied to a network call, and it is what lets the handler's per-session loop keep going. `[Rubric §14 - Testability]` assesses whether the use case can run without the dependency: [FakeAiScoringService](group-27-testing-infrastructure.md#fakeaiscoringservice) implements this interface, so the handler is unit-tested with no HTTP and no API key. +- **Walkthrough** + - `ScoreSessionAsync(SessionScoringInput, CancellationToken = default)` (`:11-13`) returns `Task`. There is no `Result` here and no exception path: the success or failure signal is the `Success` flag on the returned record. + - `ModelId { get; }` (`:16`) exposes which model produced a score. The handler stamps it onto the persisted entity as the `modelUsed` argument (`ScoreEventSessionsHandler.cs:83`), so a score row records both the number and its provenance. + - Scope: one session per call. Nothing on this interface batches, so the fan-out policy (sequential, one at a time) is the handler's decision rather than the port's. +- **Why it's built this way** - defining the port in Application lets the scoring use case be exercised with a fake scorer, and lets the AI vendor change without touching the handler. Exposing `ModelId` on the port rather than hard-coding a string in the handler means the recorded provenance cannot drift from the client that actually made the call. +- **Where it's used** - constructor-injected into [ScoreEventSessionsHandler](#scoreeventsessionshandler) (`:20`); implemented by [AnthropicScoringService](group-19-conference-infrastructure.md#anthropicscoringservice) in production and [FakeAiScoringService](group-27-testing-infrastructure.md#fakeaiscoringservice) in tests. ### SessionScoringQueue -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/SessionScoringQueue.cs:34` · Level 2 · class - -- **What it is**: the bounded in-process implementation of [ISessionScoringQueue](#isessionscoringqueue), built on a `System.Threading.Channels` channel of [SessionScoringWorkItem](#sessionscoringworkitem) values plus a concurrent set of in-flight events. Registered as a singleton and drained by exactly one hosted worker (`SessionScoringQueue.cs:23-34`). -- **Depends on**: [ISessionScoringQueue](#isessionscoringqueue), [SessionScoringEnqueueResult](#sessionscoringenqueueresult), and [SessionScoringWorkItem](#sessionscoringworkitem); BCL `Channel` / `BoundedChannelOptions` (`System.Threading.Channels`) and `ConcurrentDictionary<,>` (`System.Collections.Concurrent`). -- **Concept introduced, backpressure mode as a statement of what the work is worth.** The channel is created with `FullMode = BoundedChannelFullMode.Wait`, `SingleReader = true`, `SingleWriter = false` (`:43-49`) and is written with a **non-blocking** `TryWrite` (`:71`). That combination means a full queue **refuses** the request instead of blocking the request thread or evicting an earlier item: the class comment contrasts it with an ephemeral live broadcast, where dropping is fine, and notes that a scoring run is expensive enough that the caller needs to know it was not accepted (`:25-29`). `SingleReader` matches the one drain worker, so runs execute one at a time and cannot contend for the same event's rows (`:30-32`). [Rubric §12, Performance and Scalability] assesses how load is shed at the edge; [Rubric §31, Cost and FinOps] applies too, because each queued run is metered spend against the AI vendor. -- **Concept introduced, claim-before-write deduplication.** `TryEnqueue` (`:64-77`) first does `_pending.TryAdd(eventId, 0)` and returns `AlreadyPending` when it loses (`:68-69`), so a concurrent duplicate is refused and exactly one run per event is ever in flight. Only then does it try the channel write; if that fails it releases the claim before returning `QueueFull` (`:74-76`) so a later attempt is not blocked by a request that never queued. The claim is cleared by `MarkCompleted`, called by the drain **after** the run finishes, so the dedup window covers execution and not just the wait in the queue (`:51-55`, `:105-110`). -- **Walkthrough**: - - `Capacity` (`:36-38`): the const `16`. The comment is explicit that the bound exists to refuse a runaway caller, not to absorb load: organizers score a handful of events. - - `FirstAttempt` (`:40-41`): the const `1`, the attempt number stamped on an item queued from a caller's original request. - - `_channel` (`:43-49`): the bounded channel of [SessionScoringWorkItem](#sessionscoringworkitem) described above. - - `_pending` (`:51-55`): a `ConcurrentDictionary` used as a concurrent set of queued-or-running events. - - `Reader` (`:57-58`): the `ChannelReader` the hosted drain consumes. It is on the concrete class, not on the interface, which is why DI registers both the concrete type and the interface pointing at the same instance. - - `IsPending` (`:61`): a dictionary containment check. - - `TryEnqueue` (`:64-77`): the claim-then-write sequence above, writing attempt `1`. - - `TryRequeue(EventIdentifierType, int)` (`:79-103`): the drain worker's retry path. Unlike `TryEnqueue` it does **not** refuse an already-claimed event (`:83-89`, `:95`): the caller is the drain itself, which has just finished the run that held the claim, so re-taking it is the point. A full channel is handled identically, by giving the claim back (`:100-102`), so an event is never left marked pending by a retry that never queued. - - `MarkCompleted` (`:105-110`): removes the claim; called from the drain after every run, successful or not. -- **Why it's built this way**: [ADR-052](https://ivanball.github.io/docs/adr/052-background-job-execution.html) (background job execution) is the governing decision. It requires a bounded `Channel` per job kind registered so that the concrete type and its interface resolve to the **one** instance (`052-background-job-execution.md:37-41`; the registration is `DependencyInjection.cs:50-51`, whose comment at `:46-49` spells out that registering them separately would give producers a queue nobody drains), `Wait` plus non-blocking `TryWrite` for expensive work, and dedup by natural key with the claim released only at run end (`052-background-job-execution.md:46-54`). Its stated trade-offs apply here: the queue is in-process, so it does not survive a restart and dedup is per replica. -- **Where it's used**: produced into by [SessionSelectionController](group-20-conference-api-grpc.md#sessionselectioncontroller) through the interface; drained by [SessionScoringProcessor](group-19-conference-infrastructure.md#sessionscoringprocessor), which iterates `queue.Reader.ReadAllAsync(stoppingToken)` (`SessionScoringProcessor.cs:107`), calls `MarkCompleted` in a `finally` (`:130-136`), and only then decides whether to `TryRequeue` with `item.Attempt + 1` (`:143`). The ordering there is load-bearing and commented as such (`:132-134`): completing after a requeue would clear the very claim the requeue just re-took. -- **Caveats / not-in-source**: per-process dedup is not per-deployment dedup. With more than one replica, two hosts each keep their own `_pending`, so the cross-replica guarantee comes from an `IDistributedLock` taken in the drain instead (`SessionScoringProcessor.cs:162-188`), not from this class. Covered directly by `SessionScoringQueueTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/DecisionSupport/SessionScoringQueueTests.cs:11`), including the attempt plumbing (`:90`) and the claim release on a full queue (`:109`). +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/SessionScoringQueue.cs:34` · Level 2 · class (sealed) + +- **What it is** - the bounded in-process implementation of [ISessionScoringQueue](#isessionscoringqueue): a 16-slot channel of [SessionScoringWorkItem](#sessionscoringworkitem) values plus a concurrent set of the events currently claimed, registered as a singleton and drained by one hosted worker. +- **Depends on** - [ISessionScoringQueue](#isessionscoringqueue), [SessionScoringWorkItem](#sessionscoringworkitem) (same file, `:21`), and [SessionScoringEnqueueResult](#sessionscoringenqueueresult). External: `System.Threading.Channels.Channel` and `System.Collections.Concurrent.ConcurrentDictionary` (`:1-3`). +- **Concept introduced - claim-then-write, and refuse rather than drop.** Two mechanisms interlock here and both repay a close read. + - *Refuse rather than drop.* The channel is created with `BoundedChannelFullMode.Wait` (`:46`) but is only ever written through the non-blocking `TryWrite` (`:71`, `:97`). That combination means a full queue makes `TryWrite` return false immediately instead of blocking the caller or evicting an older item. The doc comment states why the alternative is wrong for this workload: unlike an ephemeral live broadcast, a scoring run is expensive and the caller needs to know it was not accepted (`:26-29`). `[Rubric §29 - Resilience & Business Continuity]` assesses back-pressure policy; `[Rubric §31 - Cost/FinOps]` assesses spend control, since every accepted run is real money. + - *Claim first, then write.* `TryEnqueue` adds to `_pending` **before** touching the channel (`:68`), so of two concurrent duplicate requests exactly one wins the `TryAdd` and the other is refused (`:66-69`). If the subsequent `TryWrite` fails, the claim is released again (`:75`) so a request that never queued cannot lock the event out. Taking the claim after a successful write would leave a window in which a second caller sees no claim and enqueues a duplicate. + - `SingleReader = true` (`:47`) encodes that exactly one drain worker consumes the channel, so runs execute one at a time and cannot contend for the same event's rows (`:30-31`). `SingleWriter = false` (`:48`) admits many concurrent producers. +- **Walkthrough** + - `Capacity = 16` (`:38`), with the comment stating the intent: organizers score a handful of events, so the bound exists to refuse a runaway caller, not to absorb load (`:36-37`). `FirstAttempt = 1` (`:41`) is the attempt number stamped on an item queued from an original request. + - `_channel` (`:43-49`), the bounded channel described above; `_pending` (`:55`), a `ConcurrentDictionary` used as a set. Its doc comment names the important subtlety: the drain removes an entry only after the run finishes, so the dedup window covers execution too, not just the wait in the queue (`:51-54`). + - `Reader` (`:58`) exposes the `ChannelReader` for the hosted drain. It is on the class, not on the interface, so only a consumer holding the concrete type can read. + - `IsPending(eventId)` (`:61`) is a dictionary lookup. + - `TryEnqueue(eventId)` (`:64-77`) returns `AlreadyPending` on a lost claim (`:69`), `Queued` on a successful write (`:72`), or `QueueFull` after releasing the claim (`:75-76`). + - `TryRequeue(eventId, attempt)` (`:93-103`) is the retry path and deliberately does **not** refuse an already-claimed event: its caller is the drain worker itself, which has just finished the run that held the claim, so re-adding is the point rather than a duplicate (`:84-88`). A full channel is handled exactly as on the enqueue path, by giving the claim back (`:100-101`). + - `MarkCompleted(eventId)` (`:110`) clears the claim once a run has finished, successfully or not. +- **Why it's built this way** - the dedup guarantee is only as good as the ordering of the claim and the write, and the two release paths exist so that a refusal never leaves a permanent phantom claim behind. Note what the class does not try to be: durable. It is in-process memory, so a replica restart loses queued items, which is why [SessionScoringSweepJob](group-19-conference-infrastructure.md#sessionscoringsweepjob) exists as a slower crash-recovery backstop (`SessionScoringSweepJob.cs:13-20`). +- **Where it's used** - registered twice on purpose: `TryAddSingleton()` and then `TryAddSingleton(sp => sp.GetRequiredService())`, so both registrations resolve to the one instance (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:50-55`; the comment there notes that producers would otherwise write to a queue nobody drains). Producers hold the interface; [SessionScoringProcessor](group-19-conference-infrastructure.md#sessionscoringprocessor) holds the concrete class and uses `Reader`, `MarkCompleted`, and `TryRequeue` (`SessionScoringProcessor.cs:50`, `:107`, `:135`, `:143`). Exercised directly by [SessionScoringQueueTests](group-27-testing-infrastructure.md#sessionscoringqueuetests). +- **Caveats / not-in-source** - dedup is per process. Conference runs with more than one replica, so two triggers landing on different replicas both pass this class's `_pending` check; the cross-replica guard is an [IDistributedLock](group-05-cqrs-pipeline.md#idistributedlock) taken by the drain worker before it invokes the handler, and a host with no Redis configured falls back to per-replica exclusion again (`SessionScoringProcessor.cs:162-181`). Nothing here bounds how long a claim may live: `MarkCompleted` is the only release, so a consumer that neither completes nor crashes would hold an event's claim indefinitely. ### CalendarExportMapper -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/CalendarExportMapper.cs:14` · Level 9 · class (internal static) +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/CalendarExportMapper.cs:14` · Level 9 · class (static, internal) -- **What it is**: the shared helper that decides whether a session may appear in a public calendar and maps an exportable one to an [IcsEvent](group-08-auth.md#icsevent), converting the event-zone wall-clock times to UTC with explicit DST discipline (`CalendarExportMapper.cs:8-14`). -- **Depends on**: [Session](group-17-conference-domain.md#session) and [Event](group-17-conference-domain.md#event) (Conference domain), [SessionStatuses](group-17-conference-domain.md#sessionstatuses), [IcsEvent](group-08-auth.md#icsevent) from `MMCA.Common.Shared.Calendars`, and the BCL `TimeZoneInfo` / `DateTimeOffset` / `CultureInfo` types. -- **Concept introduced, wall-clock to UTC conversion at the layer boundary.** [Rubric §8, Data Architecture] and [Rubric §16, Maintainability]. [IcsCalendarBuilder](group-08-auth.md#icscalendarbuilder) is UTC-only by contract: its entry type takes `StartsAtUtc` / `EndsAtUtc` as `DateTimeOffset` instants and says so in its own doc comment (`MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsEvent.cs:4-19`). Sessions, however, store `StartsAt` / `EndsAt` as nullable `DateTime` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:31`, `:34`) that this mapper's own comment identifies as wall-clock local to the event's IANA time zone (`CalendarExportMapper.cs:9-10`). Bridging those two representations is this helper's whole reason to exist, and it does so with a named, testable rule rather than an inline `ToUniversalTime()` at each call site. -- **Concept introduced, DST edge cases made deterministic.** `ToUtc` (`:47-56`) re-kinds the value as `Unspecified` (`:49`), asks `TimeZoneInfo.IsInvalidTime` whether it falls in a spring-forward gap and shifts it ahead one hour if so (`:50-53`), then builds the `DateTimeOffset` from the zone's offset for that instant (`:55`). The class comment (`:10-12`) states the second half of the policy: ambiguous fall-back times resolve to the standard offset, which is what `GetUtcOffset` returns for an ambiguous local time. Both branches are decisions, not accidents, and the comment ties them to the same rules the Engagement reminder planner applies (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionReminderPlanner.cs:96-102`, including the identical spring-forward shift and the same explicit note about ambiguous times) so the two features cannot disagree. -- **Concept introduced, one allow-list, no second copy.** [Rubric §11, Security] and [Rubric §16, Maintainability]. `IsExportable` (`:26-28`) delegates the status question entirely to [SessionStatuses](group-17-conference-domain.md#sessionstatuses)'s `IsEligible`, which permits only `Accepted` (case-insensitively) or an unset status and rejects every other value, known or unknown (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionStatuses.cs:47-56`). The doc comment records why (`:21-22`): this file used to carry a second, drifting copy of the allow-list. Because `Status` is free text imported from Sessionize, an allow-list is the safe default and a deny-list is not, which the domain type says in its own remarks (`SessionStatuses.cs:8-13`). -- **Walkthrough**: - - `ProductId` (`:16-17`): the RFC 5545 `PRODID` constant `-//MMCA//AtlDevCon//EN` stamped on every ADC-produced calendar. - - `IsExportable(Session)` (`:19-28`): the single source of truth for public exportability. True only when the session has both `StartsAt` and `EndsAt`, is not a service session, and passes `SessionStatuses.IsEligible` (BR-49). The doc notes it is role-independent by design (`:23-24`): the ICS document is a public-schedule artifact, so privileged callers get the same filtered export. - - `ToIcsEvent(Session, Event, TimeZoneInfo, string?)` (`:30-44`): builds a stable UID `session-{Id}@atldevcon` with `string.Create(CultureInfo.InvariantCulture, ...)` (`:38`), joins the room name and the event's `VenueAddress` into a comma-separated location skipping blank parts (`:33-35`), converts both endpoints through `ToUtc` (`:40-41`), and passes `null` rather than an empty string when there is no location (`:43`). - - `ToUtc(DateTime, TimeZoneInfo)` (`:46-56`): the DST-aware conversion described above. -- **Why it's built this way**: centralizing exportability and the time conversion in one `internal static` helper keeps both calendar handlers thin and guarantees they agree on what "public" and "UTC" mean. `internal` is deliberate: the rule is an Application-layer implementation detail, not part of the module's public surface. -- **Where it's used**: by [ExportEventCalendarHandler](#exporteventcalendarhandler) (`ExportEventCalendarHandler.cs:52-61`) and [ExportSessionCalendarHandler](#exportsessioncalendarhandler) (`ExportSessionCalendarHandler.cs:27`, `:60-61`), and by [GetNowNextHandler](#getnownexthandler), which filters the happening-now surface with the same `IsExportable` predicate (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/NowNext/GetNowNextHandler.cs:54`) and reuses `ToUtc` for its row conversions (`GetNowNextHandler.cs:87-88`). The resulting `IcsEvent` list is handed to [IcsCalendarBuilder](group-08-auth.md#icscalendarbuilder)'s `Build(productId, events, dtStamp)` (`MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsCalendarBuilder.cs:22`). -- **Testing**: covered directly by `CalendarExportMapperTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/ExportCalendar/CalendarExportMapperTests.cs:14`), whose cases are organized around the BR-49 allow-list (`:37-68`, the accepted and unset cases at `:37-41`, the eight rejected statuses at `:54-63`) and the wall-clock conversion (`:71-72`, with the spring-forward gap at `:81-82`); `internal` members are reachable from the test project through the usual `InternalsVisibleTo` arrangement. [Rubric §14, Testability]: pulling the two rules out of the handlers is what makes them unit-testable without a repository. +- **What it is** - the shared rules of the calendar export: which sessions may appear in one, how a session becomes an [IcsEvent](group-08-auth.md#icsevent), and how an event-local wall-clock time becomes a UTC instant. +- **Depends on** - [Session](group-17-conference-domain.md#session), [Event](group-17-conference-domain.md#event), and [SessionStatuses](group-17-conference-domain.md#sessionstatuses) from the Conference domain, and [IcsEvent](group-08-auth.md#icsevent) from `MMCA.Common.Shared.Calendars` (`:1-4`). External: `System.Globalization` and BCL `TimeZoneInfo`. +- **Concept introduced - the time-zone conversion contract, and one source of truth for a visibility allow-list.** + - [IcsCalendarBuilder](group-08-auth.md#icscalendarbuilder) is UTC-only by contract so it can emit `Z`-suffixed timestamps and skip RFC 5545's VTIMEZONE machinery entirely (`MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsEvent.cs:3-8`). Session times, however, are wall-clock local to the event's IANA zone. Somebody has to convert, and this mapper is where that happens, with the DST discipline stated in its own summary: invalid spring-forward times shift ahead one hour, ambiguous fall-back times resolve to the standard offset (`:9-12`). `[Rubric §15 - Best Practices & Code Quality]` assesses whether a known-hard problem is handled explicitly rather than by accident: the two DST edge cases are named in the doc and the first is coded. + - `IsExportable` delegates the status question wholesale to `SessionStatuses.IsEligible`, and the comment records why: this file used to carry a second, drifting copy of the allow-list (`:21-22`). `[Rubric §11 - Security]` assesses whether a public-visibility rule has exactly one definition; a duplicated allow-list is how a status ends up publicly visible on one surface and not another. +- **Walkthrough** + - `ProductId = "-//MMCA//AtlDevCon//EN"` (`:17`), the RFC 5545 PRODID stamped on every ADC-produced calendar document. + - `IsExportable(Session)` (`:26-28`): a property pattern requiring `StartsAt` and `EndsAt` to be non-null and `IsServiceSession` to be false, combined with `SessionStatuses.IsEligible(session.Status)` (BR-49: only `Accepted` or an unset status, `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionStatuses.cs:54-56`). The summary states the deliberate design point: this is role-independent, because the ICS document is a public-schedule artifact, so privileged callers get the same filtered export (`:23-24`). + - `ToIcsEvent(Session, Event, TimeZoneInfo, string? roomName)` (`:31-44`): joins the room name and the event's `VenueAddress` with ", ", dropping blanks (`:33-35`), then builds the [IcsEvent](group-08-auth.md#icsevent) with a stable uid of the form `session-{id}@atldevcon` (`:38`), the title, both converted instants, the description, and the joined location or null when empty (`:43`). The stable uid matters: calendar apps use it to de-duplicate re-imports (`MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsEvent.cs:9`), so re-downloading the file updates the entry instead of creating a second one. + - `ToUtc(DateTime localWallClock, TimeZoneInfo)` (`:47-56`): re-kinds the input as `Unspecified` (`:49`), and if the zone reports it as an invalid time (the hour that does not exist on a spring-forward day) adds one hour (`:50-53`), then constructs the `DateTimeOffset` with that zone's offset for the adjusted instant (`:55`). +- **Why it's built this way** - both export handlers need identical filtering and identical time conversion. Putting them in an `internal static` class with no infrastructure dependencies means the rules are stated once, are unit-testable in isolation, and cannot diverge between the whole-schedule and single-session paths. +- **Where it's used** - by [ExportEventCalendarHandler](#exporteventcalendarhandler) (`:52`, `:54`, `:61`) and [ExportSessionCalendarHandler](#exportsessioncalendarhandler) (`:27`, `:60-61`); covered directly by [CalendarExportMapperTests](group-27-testing-infrastructure.md#calendarexportmappertests). +- **Caveats / not-in-source** - the summary says ambiguous fall-back times resolve to the standard offset, but no code branches on `IsAmbiguousTime`: that outcome comes from `TimeZoneInfo.GetUtcOffset`'s own behavior for an ambiguous local time (`:55`), not from a decision in this file. `IsExportable` ignores the owning event's published state entirely; that check belongs to the callers, and both perform it. ### ScoreEventSessionsHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/ScoreEventSessionsHandler.cs:18` · Level 9 · class - -- **What it is**: the command handler that scores every session in an event via [IAiScoringService](#iaiscoringservice), persisting each score immediately so the dashboard can show real-time progress (`ScoreEventSessionsHandler.cs:12-21`). A `sealed partial class`, partial because its log methods are source-generated. -- **Depends on**: [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) and, through it, the [Session](group-17-conference-domain.md#session) / [SessionAiScore](group-17-conference-domain.md#sessionaiscore) / [Speaker](group-17-conference-domain.md#speaker) repositories (`:27-28`, `:40`); [IAiScoringService](#iaiscoringservice); `ILogger`; [SpeakerInfo](#speakerinfo) / [SessionScoringInput](#sessionscoringinput) / [SessionScoringResult](#sessionscoringresult); [ScoreEventSessionsResultDTO](group-17-conference-domain.md#scoreeventsessionsresultdto); [Result](group-01-result-error-handling.md#result) / [Error](group-01-result-error-handling.md#error). Implements [ICommandHandler](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) of [ScoreEventSessionsCommand](#scoreeventsessionscommand) to `Result` (`:21`). -- **Concept introduced, per-item save with contained failure.** The loop scores one session at a time and saves it individually (`:92-117`); a scorer failure, a domain-factory failure, or a save exception increments `failed` and `continue`s rather than aborting the batch (`:72-77`, `:85-90`, `:113-117`), and only an all-failed run returns `Error.Failure` (`:127-133`). This is the never-throw contract of [SessionScoringResult](#sessionscoringresult) carried up into batch orchestration. Note the `catch` filter excludes `OperationCanceledException` (`:113`), so host shutdown propagates instead of being counted as a failure. [Rubric §29, Resilience and Business Continuity]. -- **Concept introduced, replace-in-place instead of wipe-then-rebuild.** The long comment at `:94-103` documents a reversal worth reading in full. The handler used to delete every existing score for the event up front, which made the dashboard reset to zero and count up. It paid for that with every existing score: N sequential paid Anthropic calls follow, and the first one to fail on an expired key or a rate limit left the sessions it never reached with no score at all. Now each session's stale row is deleted inside the same step that writes its replacement (`:105-107`), so a run that dies partway through has moved only the sessions it actually reached, and a session whose call failed keeps the score it already had. The delete-then-add pair is safe because the unique filtered index on `SessionId` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SessionAiScoreConfiguration.cs:58-61`, `IsUnique().HasSoftDeleteFilter()`) allows at most one live row per session either way. [Rubric §8, Data Architecture] and [Rubric §29, Resilience and Business Continuity]. -- **Concept introduced, source-generated logging.** Every log call is a `[LoggerMessage]` `static partial` method (`:138-154`), the compiler-generated high-performance logging pattern that avoids boxing and template re-parsing, and each per-session line carries a `{Progress}/{Total}` pair so an operator can follow a run in the log (`:144-151`). [Rubric §13, Observability and Operability]. -- **Walkthrough**: - 1. Resolve the session and score repositories (`:27-28`), then load the event's non-service sessions with `SessionSpeakers` included and no tracking (`:30-34`); short-circuit to a zero-count success when there are none (`:36-37`). - 2. Batch-load the distinct non-deleted speakers for those sessions via `GetByIdsAsync` and build an id lookup (`:40-51`), then log the run start (`:53`). - 3. For each session: project its non-deleted speakers into [SpeakerInfo](#speakerinfo), dropping any the lookup misses (`:61-67`), build a [SessionScoringInput](#sessionscoringinput) and call `ScoreSessionAsync` (`:69-70`). - 4. On `!result.Success`, count a failure and continue (`:72-77`). Otherwise build the domain row with `SessionAiScore.Create`, passing the seven sub-scores, the reasoning, and `aiScoringService.ModelId` (`:79-83`); a failed `Create` is also just a counted failure (`:85-90`). - 5. Inside a `try`, `ExecuteDeleteAsync` this one session's existing scores (accumulating into `replaced`), `AddAsync` the new row, and `SaveChangesAsync` (`:104-108`); count the success and log progress (`:110-111`). - 6. After the loop, log the replacement total when non-zero (`:120-123`) and the completion counts (`:125`), then return `Error.Failure` with code `AiScoring.AllFailed` when nothing scored and something failed (`:127-133`), else the count DTO (`:135`). -- **Why it's built this way**: saving per session gives the organizer live progress and means a mid-run failure keeps the scores already computed; the all-failed guard surfaces a misconfiguration (a missing or expired Anthropic API key, named in the error message at `:131`) as one actionable error rather than a silent empty result. -- **Where it's used**: resolved per run from a fresh DI scope by the hosted drain [SessionScoringProcessor](group-19-conference-infrastructure.md#sessionscoringprocessor) (`SessionScoringProcessor.cs:160`, `:190-194`), never called from the controller: the endpoint only enqueues. The resulting scores feed [GetSessionSelectionDashboardHandler](#getsessionselectiondashboardhandler)'s AI-score panel, and the drain evicts the sessions output-cache tag on both sides of the run (`SessionScoringProcessor.cs:158`, `:208`). -- **Caveats / not-in-source**: the actual AI call, prompt, and model are supplied by the Infrastructure adapter [AnthropicScoringService](group-19-conference-infrastructure.md#anthropicscoringservice); this handler only orchestrates the port. A `Result` failure returned from here is deliberately **not** retried by the drain (`SessionScoringProcessor.cs:196-205`): only a thrown exception reaches the retry path, because a `Result` failure is a business outcome and replaying it would pay for the same refusal twice more. Covered by `ScoreEventSessionsHandlerTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/ScoreEventSessionsHandlerTests.cs:13`), built on the shared `HandlerTestBase`. +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/ScoreEventSessionsHandler.cs:18` · Level 9 · class (sealed, partial) + +- **What it is** - the use case that walks an event's sessions one at a time, asks the AI scorer about each, and persists each score the moment it arrives, returning how many were scored and how many failed. +- **Depends on** - [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork), [IAiScoringService](#iaiscoringservice), and `ILogger` by primary constructor (`:18-21`); [Session](group-17-conference-domain.md#session), [Speaker](group-17-conference-domain.md#speaker), and [SessionAiScore](group-17-conference-domain.md#sessionaiscore) from the domain; [ScoreEventSessionsResultDTO](group-17-conference-domain.md#scoreeventsessionsresultdto) as the payload; [Result](group-01-result-error-handling.md#result) and [Error](group-01-result-error-handling.md#error). It implements [ICommandHandler](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) (`:21`). +- **Concept introduced - incremental commit, and per-item replacement instead of an up-front wipe.** This handler is the clearest example in the group of a long-running use case designed around the question "what does a run that dies halfway leave behind?". + - *Commit per session, not per run.* `SaveChangesAsync` is called inside the loop (`:108`), so the UI can show real-time progress and a run killed at session 40 of 200 leaves 40 durable scores. That is the opposite of the usual one-transaction-per-command shape, and the summary says so explicitly (`:12-16`). + - *Replace in the same step that writes.* The comment at `:94-103` records the failure this design fixed: an up-front bulk delete of the event's scores made the dashboard reset to zero and count up, but it paid for that with every existing score on the event, so the first Anthropic call to fail on an expired key or a rate limit left the sessions it never reached with no score at all. Per-session granularity means a run that dies partway through has replaced only what it re-scored, and a session whose call failed keeps the score it already had. The delete-then-add pair is safe because the unique filtered index on `SessionId` in [SessionAiScoreConfiguration](group-19-conference-infrastructure.md#sessionaiscoreconfiguration) permits at most one live row per session either way. + - `[Rubric §8 - Data Architecture]` assesses write granularity and the invariants the schema itself enforces; `[Rubric §29 - Resilience & Business Continuity]` assesses partial-failure behavior; `[Rubric §31 - Cost/FinOps]` assesses paid-call economy, since every failure that forces a full re-run costs money again; `[Rubric §13 - Observability & Operability]` assesses run legibility, which here is six source-generated `[LoggerMessage]` methods carrying progress counters (`:138-154`). +- **Walkthrough** + - Repositories for [Session](group-17-conference-domain.md#session) and [SessionAiScore](group-17-conference-domain.md#sessionaiscore) off the unit of work (`:27-28`). + - Load the event's sessions with `SessionSpeakers` included, excluding service sessions, `asTracking: false` (`:30-34`). An event with no sessions short-circuits to a success carrying zeroes (`:36-37`), not a failure. + - Batch-load speakers: flatten `SessionSpeakers`, drop soft-deleted links, distinct the speaker ids, one `GetByIdsAsync` (`:40-50`), then a dictionary by id (`:51`). This is the N+1 avoidance step: one speaker query for the whole event rather than one per session. + - The loop (`:59-118`). Per session: project the non-deleted speaker links into [SpeakerInfo](#speakerinfo) values, skipping ids the lookup does not resolve (`:61-67`); build a [SessionScoringInput](#sessionscoringinput) (`:69`); call `ScoreSessionAsync` (`:70`). + - Two failure gates before any write. `!result.Success` counts a failure and continues (`:72-77`). `SessionAiScore.Create` returning a failure (an out-of-range score, `SessionAiScore.cs:134-141`) also counts a failure and continues (`:85-90`), so a model that answers with a 12 is rejected at the domain boundary rather than persisted. + - The write step (`:92-117`): `ExecuteDeleteAsync` for this session's existing score rows, accumulating the count into `replaced` (`:105`); `AddAsync` for the new entity (`:107`); `SaveChangesAsync` (`:108`). It is wrapped in `catch (Exception ex) when (ex is not OperationCanceledException)` (`:113`), so a save failure counts as one failed session and the loop continues, while a cancellation still propagates and unwinds the run. + - Outcome (`:120-135`): log the replaced count if any, log the totals, then one policy decision. If nothing scored and something failed, return a `Result` failure with code `AiScoring.AllFailed` naming the likely cause (`:127-133`). Any partial success returns `Result.Success` with the counts, which is what keeps the drain worker from retrying a business outcome. +- **Why it's built this way** - the run is long, paid, and externally fallible, so the design optimizes for "every session that was successfully scored stays scored" over transactional all-or-nothing. The `AllFailed` failure exists so that a total washout (an expired key, a wrong endpoint) is loud rather than a silent success reporting zero. +- **Where it's used** - resolved by [SessionScoringProcessor](group-19-conference-infrastructure.md#sessionscoringprocessor) inside a per-run DI scope and invoked with a [ScoreEventSessionsCommand](#scoreeventsessionscommand) (`SessionScoringProcessor.cs:190-194`). No controller calls it directly; the HTTP surface only enqueues. +- **Caveats / not-in-source** - `ExecuteDeleteAsync` is a set-based database delete that bypasses change tracking, domain events, audit stamps, and soft-delete entirely (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:298-308`), so a replaced AI score is physically gone rather than flagged `IsDeleted`. Scoring is also strictly sequential, one Anthropic call at a time with no concurrency knob, so wall-clock time grows linearly with session count. The `replaced` counter is logged but is not part of [ScoreEventSessionsResultDTO](group-17-conference-domain.md#scoreeventsessionsresultdto), which carries only `SessionsScored` and `SessionsFailed`. ### ExportEventCalendarHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportEventCalendarHandler.cs:15` · Level 10 · class +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportEventCalendarHandler.cs:15` · Level 10 · class (sealed) -- **What it is**: the query handler that produces a whole-schedule `.ics` string for a published event: every exportable session becomes one VEVENT with its room in the location. Unknown or unpublished events come back as NotFound (`ExportEventCalendarHandler.cs:10-15`). -- **Depends on**: [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) (for the [Event](group-17-conference-domain.md#event) and [Session](group-17-conference-domain.md#session) repositories), [CalendarExportMapper](#calendarexportmapper), [IcsCalendarBuilder](group-08-auth.md#icscalendarbuilder), and [Result](group-01-result-error-handling.md#result) / [Error](group-01-result-error-handling.md#error). Implements [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) of [ExportEventCalendarQuery](#exporteventcalendarquery) to `Result` (`:17`). -- **Concept introduced, public-read handlers leak nothing.** [Rubric §11, Security] assesses whether an anonymous endpoint can be used to probe for the existence of content the caller may not see. This handler collapses "missing" and "unpublished" into one answer: either condition returns `Error.NotFound` tagged with the handler name and `Event` (`:28-32`), so a caller cannot distinguish an unpublished event from one that does not exist. The endpoint is `[AllowAnonymous]` (`EventsController.cs:207`), which is exactly why the distinction has to disappear here rather than at the controller. -- **Concept introduced, degrade rather than fail on bad reference data.** The IANA zone lookup is wrapped in a `try` that catches `TimeZoneNotFoundException` and falls back to `TimeZoneInfo.Utc` (`:39-49`). The comment states the reasoning (`:46-47`): `EventInvariants.EnsureTimeZoneIsValid` guards writes, so this can only trip on legacy rows, and an export that silently shifts to UTC is a better answer for a public endpoint than a 500. [Rubric §29, Resilience and Business Continuity] and [Rubric §15, Best Practices and Code Quality]: the fallback is the same rule [GetNowNextHandler](#getnownexthandler) applies, and the comment says so, which is what stops the two from drifting. -- **Walkthrough**: - 1. Load the event with its `Rooms` child collection eagerly included (`:25-27`). The inline comment (`:24`) explains why the include is necessary: rooms are children of the `Event` aggregate and have no repository of their own. - 2. Guard: null or `!IsPublished` returns NotFound (`:28-32`). - 3. Load the event's sessions with `GetAllAsync([], s => s.EventId == query.EventId, ...)` (`:34-36`), then build a room-id to room-name dictionary from the already-loaded aggregate (`:37`), which is what lets step 5 resolve room names without a second query. - 4. Resolve the event's IANA zone, with the UTC fallback above (`:39-49`). - 5. Filter with `CalendarExportMapper.IsExportable`, order by `StartsAt`, and map each survivor to an `IcsEvent`, looking each session's `RoomId` up in the dictionary and passing `null` when it misses (`:51-59`). - 6. Hand the entries to `IcsCalendarBuilder.Build` with the shared `ProductId` and `DateTimeOffset.UtcNow` as the DTSTAMP, and return the document as `Result.Success` (`:61-62`). -- **Why it's built this way**: rendering the schedule server-side ([ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html) Wave 5, cited at `ExportEventCalendarQuery.cs:3` and at `EventsController.cs:203`) keeps the RFC 5545 formatting in one shared builder in `MMCA.Common.Shared` instead of in a client, and lets the read be output-cached like every other anonymous Conference read. [Rubric §12, Performance and Scalability]: the handler issues two reads, one for the event with its rooms and one for the sessions, and does all filtering, ordering, and room resolution in memory over those materialized collections. -- **Where it's used**: invoked by [EventsController](group-20-conference-api-grpc.md#eventscontroller)'s `ExportCalendarAsync` (`EventsController.cs:209-217`), which UTF-8 encodes the string and returns it as a `text/calendar` file (`:216`). -- **Testing**: `ExportEventCalendarHandlerTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/ExportCalendar/ExportEventCalendarHandlerTests.cs:17`). -- **Caveats / not-in-source**: the session read is unfiltered at the database (`:34-36` passes only the event-id predicate), so every session on the event is materialized and then narrowed in memory by `IsExportable`. That is cheap at conference scale but is not a database-side filter. +- **What it is** - the read use case that turns a published event into one `.ics` document: every exportable session becomes one VEVENT with its room in the location field. +- **Depends on** - [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) by primary constructor (`:15-16`), [CalendarExportMapper](#calendarexportmapper), [IcsCalendarBuilder](group-08-auth.md#icscalendarbuilder), the [Event](group-17-conference-domain.md#event) and [Session](group-17-conference-domain.md#session) aggregates, and [Result](group-01-result-error-handling.md#result) / [Error](group-01-result-error-handling.md#error). It implements `IQueryHandler>` (`:17`). +- **Concept introduced - the defensive read against a legacy row.** Time zones are validated on write by `EventInvariants.EnsureTimeZoneIsValid` (named at `:46`), yet this handler still wraps `TimeZoneInfo.FindSystemTimeZoneById` in a `try` / `catch (TimeZoneNotFoundException)` and degrades to UTC (`:39-49`). The comment gives the reasoning: stay defensive for legacy rows, and degrade rather than fail the export, the same rule [GetNowNextHandler](#getnownexthandler) applies. `[Rubric §29 - Resilience & Business Continuity]` assesses graceful degradation on a public read path: an unrecognized zone yields a schedule shifted to UTC rather than a 500 during the conference. `[Rubric §11 - Security]` assesses information disclosure on an anonymous endpoint: an unpublished or unknown event returns `Error.NotFound` (`:28-32`), so the response cannot distinguish "does not exist" from "not published yet". +- **Walkthrough** + - Load the [Event](group-17-conference-domain.md#event) by id with `nameof(Event.Rooms)` included (`:25-27`). The comment explains the include rather than a separate repository call: rooms are children of the Event aggregate and have no repository of their own (`:24`), which is aggregate-boundary discipline in practice. + - Guard: null or not `IsPublished` returns a `NotFound` error tagged with source and target (`:28-32`). + - Load every session for the event with no includes (`:34-36`), untracked by the repository default (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:85-92`), then build a room-id to room-name dictionary from the loaded rooms (`:37`). + - Resolve the time zone with the UTC fallback described above (`:39-49`). + - Project: filter by `CalendarExportMapper.IsExportable`, order by `StartsAt`, map each session through `ToIcsEvent`, passing the room name only when the session has a `RoomId` the dictionary resolves (`:51-59`). + - Build and return: `IcsCalendarBuilder.Build(CalendarExportMapper.ProductId, entries, DateTimeOffset.UtcNow)` wrapped in `Result.Success` (`:61-62`). +- **Why it's built this way** - the filtering and conversion rules live in [CalendarExportMapper](#calendarexportmapper), so this handler is only orchestration: load, guard, project, serialize. Ordering by `StartsAt` before serializing means the document reads chronologically for any client that renders it as a list, since [IcsCalendarBuilder](group-08-auth.md#icscalendarbuilder) emits the entries in the order the caller supplies them (`MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsCalendarBuilder.cs:20`). +- **Where it's used** - injected into [EventsController](group-20-conference-api-grpc.md#eventscontroller) as `IQueryHandler>` (`EventsController.cs:53`), invoked from `GET Events/{id}/ics`, which returns the string as a `text/calendar` file named `event-{id}.ics` (`:214-217`). Covered by [ExportEventCalendarHandlerTests](group-27-testing-infrastructure.md#exporteventcalendarhandlertests). +- **Caveats / not-in-source** - the handler loads every session on the event with no paging or cap, so document size scales with the schedule. `DateTimeOffset.UtcNow` is read inline rather than through an injected clock (`:61`), so the emitted DTSTAMP differs per call even though [IcsCalendarBuilder](group-08-auth.md#icscalendarbuilder) is otherwise deterministic for identical inputs. A session whose `RoomId` is not among the event's loaded rooms exports silently with no room in its location. ### ExportSessionCalendarHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportSessionCalendarHandler.cs:16` · Level 10 · class +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportSessionCalendarHandler.cs:16` · Level 10 · class (sealed) -- **What it is**: the single-session sibling of [ExportEventCalendarHandler](#exporteventcalendarhandler). It produces a one-VEVENT `.ics` document for the public add-to-calendar affordance under the same public-read rules (`ExportSessionCalendarHandler.cs:10-16`). -- **Depends on**: [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork), [CalendarExportMapper](#calendarexportmapper), [IcsCalendarBuilder](group-08-auth.md#icscalendarbuilder), [Result](group-01-result-error-handling.md#result) / [Error](group-01-result-error-handling.md#error). Implements [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) of [ExportSessionCalendarQuery](#exportsessioncalendarquery) to `Result` (`:18`). -- **Concept introduced**: none new; it applies the existence-hiding discipline [ExportEventCalendarHandler](#exporteventcalendarhandler) introduces, with two guards instead of one, and the same UTC fallback for an unrecognized zone id (`:47-57`). [Rubric §11, Security]. Worth noticing the ordering: the session guard and the event guard return NotFound targeting different entities (`Session` at `:30`, `Event` at `:40`), which keeps server-side diagnostics precise while the HTTP response stays a plain 404 either way. -- **Walkthrough**: - 1. Load the session by id (no includes) and reject it if null or if `CalendarExportMapper.IsExportable` says no, returning NotFound targeting `Session` (`:25-31`). Ineligible-status, unscheduled, and service sessions are therefore invisible here. - 2. Load the owning event with `Rooms` included and reject null or unpublished with NotFound targeting `Event` (`:34-41`): a perfectly exportable session on an unpublished event stays hidden. The same "rooms are children of the aggregate" comment appears at `:33`. - 3. Resolve the session's room name from the loaded event's `Rooms` with `FirstOrDefault`, or `null` when the session has no room (`:43-45`), then resolve the IANA zone with the UTC fallback (`:47-57`). - 4. Build a one-entry calendar via `IcsCalendarBuilder.Build` with the shared `ProductId` and `DateTimeOffset.UtcNow`, and return it (`:59-64`). -- **Why it's built this way**: a dedicated one-session path, rather than filtering the whole-event export down to one row, keeps the add-to-calendar button cheap (two by-id reads) and makes the two leak-prevention guards explicit and individually testable ([ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html) Wave 5, cited at `ExportSessionCalendarQuery.cs:3` and at `SessionsController.cs:269-270`). -- **Where it's used**: invoked by [SessionsController](group-20-conference-api-grpc.md#sessionscontroller)'s `ExportCalendarAsync` (`SessionsController.cs:275-283`). The action is `[AllowAnonymous]` and output-cached under `SessionsCache` (`SessionsController.cs:272-274`), which is why the handler carries the visibility rules itself rather than leaning on the controller's class-level permission attribute. -- **Testing**: `ExportSessionCalendarHandlerTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/ExportCalendar/ExportSessionCalendarHandlerTests.cs:16`). +- **What it is** - the read use case behind the single-session add-to-calendar button: one session, one VEVENT, one `.ics` document. +- **Depends on** - the same set as its event-wide twin: [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) (`:16-17`), [CalendarExportMapper](#calendarexportmapper), [IcsCalendarBuilder](group-08-auth.md#icscalendarbuilder), [Session](group-17-conference-domain.md#session), [Event](group-17-conference-domain.md#event), [Result](group-01-result-error-handling.md#result) / [Error](group-01-result-error-handling.md#error). It implements `IQueryHandler>` (`:18`). +- **Concept introduced - the two-hop public-read guard.** The interesting difference from [ExportEventCalendarHandler](#exporteventcalendarhandler) is that a session id alone does not establish public visibility: the session must itself be exportable **and** its owning event must be published. This handler checks both, in that order, and answers `NotFound` for either failure (`:27-31`, `:37-41`). The summary states the intent plainly: everything else is NotFound so the endpoint leaks nothing about unpublished content (`:13-14`). `[Rubric §11 - Security]` assesses whether an anonymous endpoint can be used to probe for hidden content: a declined session and a session inside an unpublished event are indistinguishable from one that does not exist. `[Rubric §1 - SOLID]` assesses reuse over duplication: the filtering rule itself is not restated here, it is the same `IsExportable` predicate the event-wide export applies. +- **Walkthrough** + - Load the session by id, no includes, untracked (`:25-26`); guard on null or `!CalendarExportMapper.IsExportable(session)` (`:27-31`). + - Load the owning [Event](group-17-conference-domain.md#event) with `Rooms` included via `session.EventId` (`:33-36`), with the same aggregate-child note as the twin (`:33`); guard on null or unpublished (`:37-41`). + - Resolve the room name by scanning the event's loaded rooms for `session.RoomId`, null when the session has no room (`:43-45`). + - Resolve the time zone with the same `TimeZoneNotFoundException` to UTC degradation (`:47-57`). + - Build a one-element calendar with a collection expression and return it as a success (`:59-64`). +- **Why it's built this way** - it is a deliberate near-twin of the event-wide handler rather than a shared code path with a nullable session id, because the guard order differs (session first, then event) and the room lookup is a scan rather than a dictionary. Everything genuinely shared, the export predicate, the PRODID, the `IcsEvent` mapping, and the time conversion, already lives once in [CalendarExportMapper](#calendarexportmapper). +- **Where it's used** - injected into [SessionsController](group-20-conference-api-grpc.md#sessionscontroller) as `IQueryHandler>` (`SessionsController.cs:50`), invoked from `GET Sessions/{id}/ics`, which returns `text/calendar` named `session-{id}.ics` (`:279-282`). Covered by [ExportSessionCalendarHandlerTests](group-27-testing-infrastructure.md#exportsessioncalendarhandlertests). +- **Caveats / not-in-source** - as with the twin, `DateTimeOffset.UtcNow` is read inline (`:62`) rather than injected. The two time-zone `catch` blocks in this folder are identical copies with nothing shared between them, so a change to the degradation policy has to be made in both handlers. ### GetPublicSessionCategoryItemFilterQuery > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.GetPublicSessionCategoryItemFilter` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/GetPublicSessionCategoryItemFilter/GetPublicSessionCategoryItemFilterQuery.cs:8` · Level 0 · record @@ -1375,7 +1592,7 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **Concept**: none new. The marker-query shape is taught under [GetPublicSessionFilterQuery](#getpublicsessionfilterquery), the junction dimension under [GetPublicSessionSpeakerFilterQuery](#getpublicsessionspeakerfilterquery), and the visibility rule itself lives once in [PublicConferenceVisibility](#publicconferencevisibility). The doc comment (`:3-7`) names the leak the query closes: a junction row is readable only when its parent session is publicly visible (the BR-49 status allow-list, inside a BR-108 published event), because otherwise the join endpoints would list the categories of a hidden session and so reveal that the session exists. `[Rubric §11, Security]` assesses whether an anonymous surface can be used to infer the existence of content the caller may not read; a join table is exactly the surface that gets forgotten once the parent entity is locked down. - **Walkthrough**: no members. Note what is deliberately absent: unlike [GetSessionsBySpeakerFilterQuery](#getsessionsbyspeakerfilterquery), which carries the speaker it filters by, this query takes no argument at all, because the junction reads carry no scope to narrow to. Every line of behavior lives in [GetPublicSessionCategoryItemFilterHandler](#getpublicsessioncategoryitemfilterhandler). - **Why it's built this way**: the rule belongs to the parent session, not to the join row, so the query carries no arguments and the handler derives its answer from the shared resolver instead of restating BR-49 a second time. -- **Where it's used**: handled by [GetPublicSessionCategoryItemFilterHandler](#getpublicsessioncategoryitemfilterhandler); injected into [SessionCategoryItemsController](group-20-conference-api-grpc.md#sessioncategoryitemscontroller) as an `IQueryHandler<...>` constructor parameter (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:51`) and constructed inside that controller's private `BuildPublicSpecificationAsync` helper (`SessionCategoryItemsController.cs:66-75`), which returns `null` for privileged readers (`:68-69`) and the specification for everyone else (`:71-74`). From there it reaches all four anonymous reads: the unpaged list (`:90`), the paged list (`:120`), the lookup (`:148`), and the by-id read (`:178`), where a hidden parent session turns the row into a 404 rather than a redacted record (`:173-183`). +- **Where it's used**: handled by [GetPublicSessionCategoryItemFilterHandler](#getpublicsessioncategoryitemfilterhandler); injected into [SessionCategoryItemsController](group-20-conference-api-grpc.md#sessioncategoryitemscontroller) as an `IQueryHandler<...>` constructor parameter (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:52`) and constructed inside that controller's private `BuildPublicSpecificationAsync` helper (`SessionCategoryItemsController.cs:67-76`), which returns `null` for privileged readers (`:69-70`) and the specification for everyone else (`:72-75`). From there it reaches all four anonymous reads: the unpaged list (`:91`), the paged list (`:121`), the lookup (`:149`), and the by-id read (`:179`), where a hidden parent session turns the row into a 404 rather than a redacted record. --- @@ -1388,9 +1605,9 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur `[Rubric §11, Security]` assesses whether an authorization rule is enforced once, server side, on every path that can reach the data. The doc comment (`:3-10`) spells out the rule and why it is an allow-list rather than a deny-list: Accepted-or-unset sessions whose parent event is published, so a session in any other state (waitlisted, nominated, queued, declined, or an unrecognized Sessionize value) is invisible by default. A deny-list would silently expose the next status Sessionize invents. `[Rubric §2, Design Patterns]` assesses whether a recognized pattern is used where it earns its keep. Specification-as-return-value keeps the predicate composable: [SessionsController](group-20-conference-api-grpc.md#sessionscontroller) ANDs it with the speaker filter rather than choosing between them (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionsController.cs:116-118`). - **Walkthrough**: no members, no methods, no validation. The doc comment (`:6-9`) also records the physical constraint that shapes the handler: the design treats [Session](group-17-conference-domain.md#session) and [Event](group-17-conference-domain.md#event) as potentially living in different data sources, so the published-event check cannot be a navigation join and the handler delegates to the framework's cross-source specification helper. -- **Why it's built this way**: an empty record still buys a distinct type, and a distinct type is what the CQRS pipeline dispatches on. `new GetPublicSessionFilterQuery()` selects [GetPublicSessionFilterHandler](#getpublicsessionfilterhandler) through the DI registration of [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult), so the visibility rule is reached the same way every other read is, with the same decorators around it. +- **Why it's built this way**: an empty record still buys a distinct type, and a distinct type is what the CQRS pipeline dispatches on. `new GetPublicSessionFilterQuery()` selects [GetPublicSessionFilterHandler](#getpublicsessionfilterhandler) through the Scrutor assembly scan the module's registration runs (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`, documented at `:37`), which closes [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) over this query type. The visibility rule is therefore reached the same way every other read is, with the same decorators around it. - **Where it's used**: handled by [GetPublicSessionFilterHandler](#getpublicsessionfilterhandler); injected into [SessionsController](group-20-conference-api-grpc.md#sessionscontroller) (`SessionsController.cs:48`) and constructed in its private `BuildPublicSessionSpecificationAsync` helper (`SessionsController.cs:67-76`), which short-circuits to `null` for privileged readers (`:69-70`). That helper feeds the unpaged list (`:138`), the paged list through `BuildPagedSessionSpecificationAsync` (`:96`, applied at `:177`), the lookup (`:207`), and the by-id read (`:237`), each of which is `[AllowAnonymous]` (`:126`, `:152`, `:201`, `:223`) under the class-level `[HasPermission(ConferencePermissions.SessionsManage)]` (`:41`). -- **Caveats / not-in-source**: the doc comment states that Session lives in Cosmos DB and Event in SQL Server (`:7-8`). In ADC as configured today both are SQL Server entities: `SessionConfiguration` derives from `EntityTypeConfigurationSQLServer` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SessionConfiguration.cs:12-13`) and `EventConfiguration` from the same SQL Server base (`.../EntityConfiguration/EventConfiguration.cs:12`). The cross-source treatment is therefore prophylactic against the polyglot option ([ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html)) rather than a description of the deployed engine split. +- **Caveats / not-in-source**: the doc comment states that Session lives in Cosmos DB and Event in SQL Server (`:7-8`), and the controller repeats it (`SessionsController.cs:63`). In ADC as configured today both are SQL Server entities: `SessionConfiguration` derives from `EntityTypeConfigurationSQLServer` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SessionConfiguration.cs:12-13`) and `EventConfiguration` from the same SQL Server base (`.../EntityConfiguration/EventConfiguration.cs:11-12`). The cross-source treatment is therefore prophylactic against the polyglot option ([ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html)) rather than a description of the deployed engine split. --- @@ -1402,7 +1619,7 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **Concept introduced: visibility propagates down a junction.** The marker shape itself comes from [GetPublicSessionFilterQuery](#getpublicsessionfilterquery); what this query adds is the observation that hiding an entity is not finished until every table pointing at it is hidden too. The doc comment (`:3-7`) states the leak in one sentence: without this filter the join endpoints would list the speakers of a hidden session and thereby leak its existence. `[Rubric §11, Security]`: the junction is an independent read surface with its own controller and its own anonymous actions, so it needs its own enforcement rather than inheriting one. - **Walkthrough**: no members. The filter is derived, not parameterized, so [GetPublicSessionSpeakerFilterHandler](#getpublicsessionspeakerfilterhandler) can compute the answer from the same visible-session id list the category-item filter uses. - **Why it's built this way**: giving the join its own query type (rather than reusing [GetPublicSessionFilterQuery](#getpublicsessionfilterquery) and translating the result) keeps each handler's return type bound to the entity being filtered: this one yields `Specification`, which the join controller hands straight to the generic query service with no adaptation. -- **Where it's used**: handled by [GetPublicSessionSpeakerFilterHandler](#getpublicsessionspeakerfilterhandler); injected into [SessionSpeakersController](group-20-conference-api-grpc.md#sessionspeakerscontroller) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:51`) and constructed in that controller's `BuildPublicSpecificationAsync` helper (`SessionSpeakersController.cs:66-75`, privileged short circuit at `:68-69`). The helper feeds the unpaged list (`:90`), the paged list (`:120`), the lookup (`:148`), and the by-id read (`:178`), all `[AllowAnonymous]` (`:78`, `:101`, `:142`, `:164`) beneath the class-level `[HasPermission(ConferencePermissions.SessionsManage)]` (`:46`). +- **Where it's used**: handled by [GetPublicSessionSpeakerFilterHandler](#getpublicsessionspeakerfilterhandler); injected into [SessionSpeakersController](group-20-conference-api-grpc.md#sessionspeakerscontroller) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:52`) and constructed in that controller's `BuildPublicSpecificationAsync` helper (`SessionSpeakersController.cs:67-76`, privileged short circuit at `:69-70`). The helper feeds the unpaged list (`:91`), the paged list (`:121`), the lookup (`:149`), and the by-id read (`:179`), all `[AllowAnonymous]` (`:79`, `:102`, `:143`, `:165`) beneath the class-level `[HasPermission(ConferencePermissions.SessionsManage)]` (`:47`). --- @@ -1410,8 +1627,8 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.GetSessionsBySpeakerFilter` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/GetSessionsBySpeakerFilter/GetSessionsBySpeakerFilterQuery.cs:11` · Level 0 · record - **What it is**: the one member of this filter family that carries an argument: `public sealed record GetSessionsBySpeakerFilterQuery(SpeakerIdentifierType SpeakerId);` (`GetSessionsBySpeakerFilterQuery.cs:11`). Its answer is the specification selecting the sessions a given speaker presents. -- **Depends on**: the `SpeakerIdentifierType` alias (`System.Guid` in Conference, `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:18`). Nothing else. -- **Concept introduced: the virtual filter key.** A client filtering the paged session list by speaker sends `SpeakerId` as an ordinary filter key, but [Session](group-17-conference-domain.md#session) has no `SpeakerId` column: the link lives in the [SessionSpeaker](group-17-conference-domain.md#sessionspeaker) join. The doc comment (`:4-9`) records the resolution: the link is resolved as an ID-list projection so the resulting criteria stays engine-portable and the `Session` aggregate keeps a by-id boundary to [Speaker](group-17-conference-domain.md#speaker), following the `GetSpeakersByEventFilterQuery` precedent (BR-132). +- **Depends on**: the `SpeakerIdentifierType` alias (`System.Guid` in Conference, `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:19`). Nothing else. +- **Concept introduced: the virtual filter key.** A client filtering the paged session list by speaker sends `SpeakerId` as an ordinary filter key, but [Session](group-17-conference-domain.md#session) has no `SpeakerId` column: the link lives in the [SessionSpeaker](group-17-conference-domain.md#sessionspeaker) join. The doc comment (`:4-9`) records the resolution: the link is resolved as an ID-list projection so the resulting criteria stays engine-portable and the `Session` aggregate keeps a by-id boundary to [Speaker](group-17-conference-domain.md#speaker), following the [GetSpeakersByEventFilterQuery](#getspeakersbyeventfilterquery) precedent (BR-132). `[Rubric §4, DDD]` assesses whether aggregates reference each other by identifier instead of by object graph; this query exists precisely so a cross-aggregate question can be answered without giving `Session` a navigation to `Speaker`. `[Rubric §9, API & Contract Design]`: the key is intercepted in the controller and never forwarded to the generic filter pipeline, which rejects unknown properties, and an unparseable value ignores the key rather than failing the request (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionsController.cs:82-89`, `:102-107`). - **Walkthrough**: a single positional member, `SpeakerId` (`:11`), documented as the speaker whose sessions should match (`:10`). No behavior; all of it is in [GetSessionsBySpeakerFilterHandler](#getsessionsbyspeakerfilterhandler). @@ -1425,11 +1642,11 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **What it is**: the handler for [GetSessionsBySpeakerFilterQuery](#getsessionsbyspeakerfilterquery). It projects the session ids linked to the speaker through the [SessionSpeaker](group-17-conference-domain.md#sessionspeaker) join and returns a `Session.Id IN (...)` filter (`GetSessionsBySpeakerFilterHandler.cs:21-23`). - **Depends on**: [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) (primary-constructor parameter, `:22`); [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) closed over `Result>` (`:23`); [InlineSpecification](group-03-querying-specifications.md#inlinespecificationtentity-tidentifiertype) and [Specification](group-03-querying-specifications.md#specificationtentity-tidentifiertype); [Session](group-17-conference-domain.md#session) and [SessionSpeaker](group-17-conference-domain.md#sessionspeaker); [Result](group-01-result-error-handling.md#result). -- **Concept introduced: ID-list projection instead of a navigation join.** Rather than expressing the rule as one LINQ expression that walks `Session -> SessionSpeaker -> Speaker`, the handler runs a scalar projection query first and embeds its result in the predicate. `GetProjectedAsync(select, where, asTracking, ignoreQueryFilters, cancellationToken)` returns only the selected column instead of whole entities; it is declared on the `IEntityQuerier` facet (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:103`, facet declared at `:78`) that [IReadRepository](group-07-persistence-ef-core.md#ireadrepositorytentity-tidentifiertype) composes (`IRepository.cs:134`). - `[Rubric §8, Data Architecture]` assesses whether query shapes survive the storage topology the architecture allows. A navigation join is only translatable when both ends sit in the same physical source; an `IN` over materialized ids translates on every provider, which is what [ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html) needs. The framework enforces the same constraint mechanically for declared specification classes through the `SpecificationsDoNotNavigateToOtherEntities` fitness rule, which instantiates parameterless specifications and inspects their `Criteria` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Specifications.cs:24`, evaluation at `:53`). +- **Concept introduced: ID-list projection instead of a navigation join.** Rather than expressing the rule as one LINQ expression that walks `Session -> SessionSpeaker -> Speaker`, the handler runs a scalar projection query first and embeds its result in the predicate. `GetProjectedAsync(select, where, asTracking, ignoreQueryFilters, cancellationToken)` returns only the selected column instead of whole entities; it is declared on the [IEntityQuerier](group-07-persistence-ef-core.md#ientityqueriertentity-tidentifiertype) facet (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:105-110`, facet declared at `:80`) that [IReadRepository](group-07-persistence-ef-core.md#ireadrepositorytentity-tidentifiertype) composes (`IRepository.cs:221-222`). + `[Rubric §8, Data Architecture]` assesses whether query shapes survive the storage topology the architecture allows. A navigation join is only translatable when both ends sit in the same physical source; an `IN` over materialized ids translates on every provider, which is what [ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html) needs. The framework enforces the same constraint mechanically for declared specification classes through the `SpecificationsDoNotNavigateToOtherEntities` fitness rule, which instantiates parameterless specifications and inspects their `Criteria` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Specifications.cs:24`, evaluation at `:53`, failure message at `:74`). `[Rubric §4, DDD]`: the class comment (`:9-14`) states the second property being bought, that the `Session` aggregate keeps its by-id boundary to the Speaker aggregate even while answering a cross-aggregate question. - **Walkthrough** - 1. **Project the linked session ids** (`:30-37`): resolve the read repository for `SessionSpeaker` and call `GetProjectedAsync(ss => ss.SessionId, ss => ss.SpeakerId == query.SpeakerId, asTracking: false, cancellationToken: cancellationToken)`. `asTracking: false` matters because nothing here will be mutated; a tracked read would pollute the change tracker for whatever else the request does. `ignoreQueryFilters` is left at its `false` default (`IRepository.cs:107`), so soft-deleted join rows are excluded by the EF global query filter rather than by a predicate term written here. + 1. **Project the linked session ids** (`:30-37`): resolve the read repository for `SessionSpeaker` and call `GetProjectedAsync(ss => ss.SessionId, ss => ss.SpeakerId == query.SpeakerId, asTracking: false, cancellationToken: cancellationToken)`. Nothing here will be mutated, so the untracked read is what is wanted; `asTracking` is passed explicitly even though `false` is already its default (`IRepository.cs:108`), and a tracked read would pollute the change tracker for whatever else the request does. `ignoreQueryFilters` is left at its `false` default (`IRepository.cs:109`), so soft-deleted join rows are excluded by the EF global query filter rather than by a predicate term written here. 2. **Materialize and de-duplicate** (`:39-40`): `IReadOnlyList ids = [.. sessionIds.Distinct()];`. The inline comment (`:39`) gives the reason for materializing: the predicate must embed a stable collection EF can translate to `IN`. A lazily-enumerated source would be captured unevaluated and re-enumerated every time the criteria is applied. 3. **Wrap and return** (`:42-43`): `ids.Contains(s.Id)` becomes an [InlineSpecification](group-03-querying-specifications.md#inlinespecificationtentity-tidentifiertype) returned inside `Result.Success`. There is no failure path: the handler cannot fail on its own terms. - **Why it's built this way**: the `` in the class comment (`:15-19`) records the one behavior that is easy to get wrong downstream. A speaker with no sessions yields an empty id list, and an empty `IN` matches nothing, which is the correct answer; that is exactly why the caller must apply the specification rather than skip it when the list is empty. Skipping it would turn "this speaker presents nothing" into "show every session". @@ -1444,15 +1661,15 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **What it is**: the handler for [GetPublicSessionFilterQuery](#getpublicsessionfilterquery) and the definitive statement of BR-132 / BR-49 in code. It resolves the published [Event](group-17-conference-domain.md#event) ids and returns a `Session.EventId IN (...)` filter ANDed with the status allow-list (`GetPublicSessionFilterHandler.cs:20-22`). - **Depends on**: [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) (`:21`); [CrossSourceSpecification](group-03-querying-specifications.md#crosssourcespecification); [PublicSessionStatusSpecification](#publicsessionstatusspecification) (for its static `StatusCriteria`); [Session](group-17-conference-domain.md#session) and [Event](group-17-conference-domain.md#event); [Specification](group-03-querying-specifications.md#specificationtentity-tidentifiertype); [Result](group-01-result-error-handling.md#result); [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) (`:22`). -- **Concept introduced: composing a filter across two data sources.** [CrossSourceSpecification](group-03-querying-specifications.md#crosssourcespecification) exists because a predicate like `s => s.Event.IsPublished` is not translatable when principal and dependent may live in different physical sources (`MMCA.Common/Source/Core/MMCA.Common.Application/Specifications/CrossSourceSpecification.cs:9-21`). `BuildAsync` runs a scalar projection against the principal's own source (`:54-57`), materializes the keys once (`:59-60`), then builds `Enumerable.Contains(keys, dependent.ForeignKey)` as an expression tree and ANDs the optional local predicate onto it after rebinding its parameter, deliberately avoiding `Expression.Invoke` so the combined predicate stays translatable on every provider (`:66-91`, the rebinding at `:85-87`). +- **Concept introduced: composing a filter across two data sources.** [CrossSourceSpecification](group-03-querying-specifications.md#crosssourcespecification) exists because a predicate like `s => s.Event.IsPublished` is not translatable when principal and dependent may live in different physical sources (`MMCA.Common/Source/Core/MMCA.Common.Application/Specifications/CrossSourceSpecification.cs:9-21`). `BuildAsync` runs a scalar projection against the principal's own source (`:54-57`), materializes the keys once (`:59-60`), then builds `Enumerable.Contains(keys, dependent.ForeignKey)` as an expression tree (`:74-79`) and ANDs the optional local predicate onto it after rebinding its parameter, deliberately avoiding `Expression.Invoke` so the combined predicate stays translatable on every provider (`:66-91`, the rebinding at `:86-87`). `[Rubric §3, Clean Architecture]` assesses whether infrastructure concerns stay out of the application layer. The handler expresses a business rule and hands the storage problem to a framework helper; it names no provider, no table, and no SQL. - `[Rubric §16, Maintainability]`: the status leg is not written here. It is [PublicSessionStatusSpecification](#publicsessionstatusspecification)'s `StatusCriteria` (`:34`), the same static expression the visible-session id resolver passes (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:67`), so the session list and every derived read cannot drift apart. That expression is `s => s.Status == null || s.Status == SessionStatuses.Accepted` and compares against `SessionStatuses.Accepted` rather than calling [SessionStatuses](group-17-conference-domain.md#sessionstatuses)'s `IsEligible`, because compiled code does not translate to SQL (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/Specifications/PublicSessionStatusSpecification.cs:12-19`, `:23-24`). + `[Rubric §16, Maintainability]`: the status leg is not written here. It is [PublicSessionStatusSpecification](#publicsessionstatusspecification)'s `StatusCriteria` (`:34`), the same static expression the visible-session id resolver passes (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:68`), so the session list and every derived read cannot drift apart. That expression is `s => s.Status == null || s.Status == SessionStatuses.Accepted` and compares against `SessionStatuses.Accepted` rather than calling [SessionStatuses](group-17-conference-domain.md#sessionstatuses)'s `IsEligible`, because compiled code does not translate to SQL (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/Specifications/PublicSessionStatusSpecification.cs:12-19`, `:23-24`). - **Walkthrough** 1. **Build the cross-source specification** (`:29-36`): one call to `CrossSourceSpecification.BuildAsync` with `principalPredicate: e => e.IsPublished` (BR-108), `dependentForeignKey: s => s.EventId`, and `localPredicate: PublicSessionStatusSpecification.StatusCriteria` (BR-49). The type arguments pin the direction of the relationship: `Session` is the dependent being filtered, `Event` the principal being resolved. 2. **Return** (`:38`): `Result.Success(specification)`. As with its siblings there is no failure branch. - **Why it's built this way**: the whole handler is two statements because the reusable mechanics were pushed into the framework. What stays local is the pair of business predicates, which is the part that can change. The rule is enforced at the application layer rather than in the controller so that every caller of the query gets it, including the ones added later. - **Where it's used**: [SessionsController](group-20-conference-api-grpc.md#sessionscontroller) via `BuildPublicSessionSpecificationAsync` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionsController.cs:67-76`), which reaches the unpaged list, the paged list, the lookup, and the by-id read. The lookup action is worth reading: it forwards `specification.Criteria` as the lookup filter (`SessionsController.cs:211-215`) precisely because a lookup endpoint would otherwise be a side channel listing the sessions the list and detail endpoints already hide (`:194-199`). -- **Testing**: [GetPublicSessionFilterHandlerTests](group-27-testing-infrastructure.md#getpublicsessionfilterhandlertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/GetPublicSessionFilter/GetPublicSessionFilterHandlerTests.cs:12`), seven test methods asserted against the produced criteria rather than against the handler's internals: the success shape (`:72`), the public statuses matching for a published event (`:84`, a theory over `Accepted` and `null`), the non-public statuses excluded (`:101`, a theory over Waitlisted, AcceptQueue, Nominated, DeclineQueue, Declined, and two unrecognized values), a session of an unpublished event excluded (`:111`), no published events matching nothing (`:121`), the principal predicate selecting only published events (`:139`), and the cancellation token reaching the event query (`:162`). +- **Testing**: [GetPublicSessionFilterHandlerTests](group-27-testing-infrastructure.md#getpublicsessionfilterhandlertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/GetPublicSessionFilter/GetPublicSessionFilterHandlerTests.cs:12`), seven test methods asserted against the produced criteria rather than against the handler's internals: the success shape (`:72`), the public statuses matching for a published event (`:84`, a theory over `Accepted` and `null`), the non-public statuses excluded (`:101`, a theory over the non-eligible Sessionize values), a session of an unpublished event excluded (`:111`), no published events matching nothing (`:121`), the principal predicate selecting only published events (`:139`), and the cancellation token reaching the event query (`:162`). - **Caveats / not-in-source**: `CrossSourceSpecification` materializes the matching principal keys into the predicate, and its own note (`CrossSourceSpecification.cs:17-20`) scopes the technique to small or bounded principal sets, the "published events" shape. Nothing in this handler bounds that set; it is bounded in practice by how many events a conference publishes. Note also that the controller maps a failed `Result` to `null`, meaning no filter (`SessionsController.cs:75`), which would widen the read rather than narrow it; nothing in this handler can produce that failure today, so the exposure is latent rather than live. --- @@ -1464,12 +1681,12 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **Depends on**: [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) (`:17`, injected only to hand on to the resolver); [PublicConferenceVisibility](#publicconferencevisibility); [InlineSpecification](group-03-querying-specifications.md#inlinespecificationtentity-tidentifiertype); [SessionCategoryItem](group-17-conference-domain.md#sessioncategoryitem); [Result](group-01-result-error-handling.md#result). Implements [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) to `Result>` (`:18`). - **Concept**: none new; the derived junction filter is taught under [GetPublicSessionSpeakerFilterHandler](#getpublicsessionspeakerfilterhandler), and this is the category-assignment instance of the same shape. It calls the identical resolver method its speaker-side twin does and restates nothing of the rule. `[Rubric §16, Maintainability]`: the junction cannot drift away from the entity whose visibility it follows, because it holds no copy of that entity's rule. `[Rubric §8, Data Architecture]`: the answer arrives as an id list turned into `Contains`, not a navigation join, so the criteria stays translatable on any provider ([ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html)). - **Walkthrough** - 1. **Resolve the visible session ids** (`:25-27`): `PublicConferenceVisibility.GetVisibleSessionIdsAsync(unitOfWork, cancellationToken)`. Inside the resolver that is the same `CrossSourceSpecification.BuildAsync` call [GetPublicSessionFilterHandler](#getpublicsessionfilterhandler) makes, over [Session](group-17-conference-domain.md#session) and [Event](group-17-conference-domain.md#event) with `principalPredicate: e => e.IsPublished`, `dependentForeignKey: s => s.EventId`, and `localPredicate: PublicSessionStatusSpecification.StatusCriteria` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:62-69`), followed by a scalar projection of the session ids that combined criteria matches (`PublicConferenceVisibility.cs:71-74`). The comment there (`:60-61`) states the property being bought: the same helper and the same criteria the public-session read filter uses, so a session hidden from the session list can never stay reachable through a junction read. + 1. **Resolve the visible session ids** (`:25-27`): `PublicConferenceVisibility.GetVisibleSessionIdsAsync(unitOfWork, cancellationToken)`. Inside the resolver that is the same `CrossSourceSpecification.BuildAsync` call [GetPublicSessionFilterHandler](#getpublicsessionfilterhandler) makes, over [Session](group-17-conference-domain.md#session) and [Event](group-17-conference-domain.md#event) with `principalPredicate: e => e.IsPublished`, `dependentForeignKey: s => s.EventId`, and `localPredicate: PublicSessionStatusSpecification.StatusCriteria` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:63-70`). The resolver then hands that specification straight to the repository's spec-taking projection overload, `ListAsync(specification, s => s.Id, cancellationToken)` (`PublicConferenceVisibility.cs:77-79`; the overload is declared at `MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:170-173` and projects server side, after the specification's ordering and paging, `:155-164`). The comment above the call explains why passing a specification is enough (`PublicConferenceVisibility.cs:72-74`): a plain specification contributes its `Criteria` and nothing else, so this is the untracked, soft-delete-filtered read the explicit `GetProjectedAsync` arguments would otherwise have to spell out. The comment at `:61-62` states the property being bought: the same helper and the same criteria the public-session read filter uses, so a session hidden from the session list can never stay reachable through a junction read. 2. **Wrap and return** (`:29-31`): `sci => sessionIds.Contains(sci.SessionId)` inside an [InlineSpecification](group-03-querying-specifications.md#inlinespecificationtentity-tidentifiertype), returned as `Result.Success`. No failure path. - **Why it's built this way**: the doc comment states the intent directly (`:10-15`): a junction row follows the visibility of its parent session (BR-49). Deriving that answer instead of copying the session rule keeps one definition of "publicly visible session" behind the session list, the session-speaker join, and this category-assignment join. -- **Where it's used**: [SessionCategoryItemsController](group-20-conference-api-grpc.md#sessioncategoryitemscontroller)'s `BuildPublicSpecificationAsync` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:66-75`) is the only consumer, and from there it reaches the unpaged list (`:90`), paged list (`:120`), lookup (`:148`), and by-id (`:178`) reads. Note that the class-level `[HasPermission(ConferencePermissions.SessionsManage)]` (`:46`) is overridden per action by `[AllowAnonymous]` (`:78`, `:101`, `:142`, `:164`), which is exactly why the handler has to carry the visibility rule itself. -- **Testing**: [GetPublicSessionCategoryItemFilterHandlerTests](group-27-testing-infrastructure.md#getpublicsessioncategoryitemfilterhandlertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/GetPublicSessionCategoryItemFilter/GetPublicSessionCategoryItemFilterHandlerTests.cs:17`), four tests on the shared `HandlerTestBase`: the success shape (`:55`), a row whose parent session is visible (`:64`), a row whose parent session is hidden (`:74`), and a world with no visible sessions at all (`:84`). One fixture detail is a property of the entity rather than of the test: `SessionCategoryItem.SessionId` is get-only and written by EF (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionCategoryItem.cs:23`), so a row built in memory carries the default id, and the fixture uses `default` as its row session id (`:19-21`). -- **Caveats / not-in-source**: as on the other junction reads, the controller maps a failed `Result` to `null`, meaning no filter (`SessionCategoryItemsController.cs:74`), which would widen the read rather than narrow it; nothing in this handler can produce that failure today, so the exposure is latent rather than live. The resolved id list is also materialized into the predicate, so the `IN` list grows with the number of publicly visible sessions across every published event, and nothing in this file bounds it. +- **Where it's used**: [SessionCategoryItemsController](group-20-conference-api-grpc.md#sessioncategoryitemscontroller)'s `BuildPublicSpecificationAsync` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:67-76`) is the only consumer, and from there it reaches the unpaged list (`:91`), paged list (`:121`), lookup (`:149`), and by-id (`:179`) reads. Note that the class-level `[HasPermission(ConferencePermissions.SessionsManage)]` (`:47`) is overridden per action by `[AllowAnonymous]` (`:79`, `:102`, `:143`, `:165`), which is exactly why the handler has to carry the visibility rule itself. +- **Testing**: [GetPublicSessionCategoryItemFilterHandlerTests](group-27-testing-infrastructure.md#getpublicsessioncategoryitemfilterhandlertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/GetPublicSessionCategoryItemFilter/GetPublicSessionCategoryItemFilterHandlerTests.cs:18`), four tests on the shared `HandlerTestBase`: the success shape (`:56`), a row whose parent session is visible (`:65`), a row whose parent session is hidden (`:75`), and a world with no visible sessions at all (`:85`). The fixture mocks the two reads the resolver actually performs: `GetProjectedAsync` on the `Event` repository (`:29-36`) and the spec-taking `ListAsync` on the `Session` repository (`:46-51`), whose comment notes that the resolver hands the session read a specification rather than an unwrapped predicate. One further detail is a property of the entity rather than of the test: `SessionCategoryItem.SessionId` is get-only and written by EF (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionCategoryItem.cs:23`), so a row built in memory carries the default id, and the fixture uses `default` as its row session id (`:20-21`). +- **Caveats / not-in-source**: as on the other junction reads, the controller maps a failed `Result` to `null`, meaning no filter (`SessionCategoryItemsController.cs:75`), which would widen the read rather than narrow it; nothing in this handler can produce that failure today, so the exposure is latent rather than live. The resolved id list is also materialized into the predicate, so the `IN` list grows with the number of publicly visible sessions across every published event, and nothing in this file bounds it. --- @@ -1478,16 +1695,16 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **What it is**: the handler for [GetPublicSessionSpeakerFilterQuery](#getpublicsessionspeakerfilterquery). It resolves the visible session ids and returns a `SessionSpeaker.SessionId IN (...)` specification, so a join row is readable exactly when its parent session is (`GetPublicSessionSpeakerFilterHandler.cs:15-17`). - **Depends on**: [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) (`:16`); [PublicConferenceVisibility](#publicconferencevisibility); [InlineSpecification](group-03-querying-specifications.md#inlinespecificationtentity-tidentifiertype); [SessionSpeaker](group-17-conference-domain.md#sessionspeaker); [Result](group-01-result-error-handling.md#result); [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) (`:17`). -- **Concept introduced: the derived junction filter.** The rule this handler enforces is not its own. It is one call to a shared resolver, `PublicConferenceVisibility.GetVisibleSessionIdsAsync` (`:24-26`), followed by a `Contains` over the returned ids. Nothing about "Accepted or unset status, inside a published event" appears in this file, which is the point: the definition lives once (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:56-77`) and every read that must respect it derives from that one definition. - `[Rubric §11, Security]` assesses whether a visibility rule holds on every surface that can reach the protected data. The remarks on the resolver (`PublicConferenceVisibility.cs:10-27`) state the invariant: one definition of "publicly visible" backs the session, speaker, and junction read filters, so closing a leak in one place closes it everywhere. +- **Concept introduced: the derived junction filter.** The rule this handler enforces is not its own. It is one call to a shared resolver, `PublicConferenceVisibility.GetVisibleSessionIdsAsync` (`:24-26`), followed by a `Contains` over the returned ids. Nothing about "Accepted or unset status, inside a published event" appears in this file, which is the point: the definition lives once (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:57-82`) and every read that must respect it derives from that one definition. + `[Rubric §11, Security]` assesses whether a visibility rule holds on every surface that can reach the protected data. The summary and remarks on the resolver (`PublicConferenceVisibility.cs:10-27`) state the invariant: one definition of "publicly visible" backs the session, speaker, and junction read filters, so closing a leak in one place closes it everywhere, and everything is expressed as scalar id projections rather than navigation joins so the criteria stay translatable on any engine. `[Rubric §1, SOLID]`: this is the single-responsibility split in miniature. The resolver decides who is visible; the handler decides how that answer is shaped for one entity. - **Walkthrough** - 1. **Resolve** (`:24-26`): `GetVisibleSessionIdsAsync(unitOfWork, cancellationToken)`, which internally builds the cross-source session specification and projects the ids matching it (`PublicConferenceVisibility.cs:62-76`). + 1. **Resolve** (`:24-26`): `GetVisibleSessionIdsAsync(unitOfWork, cancellationToken)`, which internally builds the cross-source session specification (`PublicConferenceVisibility.cs:63-70`) and projects the ids matching it through the spec-taking `ListAsync` overload (`:75-79`). 2. **Wrap and return** (`:28-30`): `ss => sessionIds.Contains(ss.SessionId)` inside an [InlineSpecification](group-03-querying-specifications.md#inlinespecificationtentity-tidentifiertype), returned as `Result.Success`. The handler has no failure branch and no conditional logic at all. - **Why it's built this way**: the alternative, restating the status and published-event rules against navigation properties, would both duplicate the rule and produce criteria a non-relational provider could not translate. Two round trips (ids, then the filtered read) buy one rule and portable criteria. -- **Where it's used**: [SessionSpeakersController](group-20-conference-api-grpc.md#sessionspeakerscontroller)'s `BuildPublicSpecificationAsync` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:66-75`) is the only consumer, feeding the unpaged list (`:90`), paged list (`:120`), lookup (`:148`), and by-id (`:178`) reads, each `[AllowAnonymous]` (`:78`, `:101`, `:142`, `:164`) under the class-level permission requirement (`:46`). -- **Testing**: [GetPublicSessionSpeakerFilterHandlerTests](group-27-testing-infrastructure.md#getpublicsessionspeakerfilterhandlertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/GetPublicSessionSpeakerFilter/GetPublicSessionSpeakerFilterHandlerTests.cs:17`), four tests mirroring the category-item twin one for one: the success shape (`:55`), a row of a visible session matching (`:64`), a row of a hidden session excluded (`:74`), and no visible sessions matching nothing (`:84`). -- **Caveats / not-in-source**: identical to the category-item handler. A failed `Result` becomes `null` in the controller, meaning no filter (`SessionSpeakersController.cs:74`), and the materialized id list is unbounded in this file. +- **Where it's used**: [SessionSpeakersController](group-20-conference-api-grpc.md#sessionspeakerscontroller)'s `BuildPublicSpecificationAsync` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:67-76`) is the only consumer, feeding the unpaged list (`:91`), paged list (`:121`), lookup (`:149`), and by-id (`:179`) reads, each `[AllowAnonymous]` (`:79`, `:102`, `:143`, `:165`) under the class-level permission requirement (`:47`). +- **Testing**: [GetPublicSessionSpeakerFilterHandlerTests](group-27-testing-infrastructure.md#getpublicsessionspeakerfilterhandlertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/GetPublicSessionSpeakerFilter/GetPublicSessionSpeakerFilterHandlerTests.cs:18`), four tests mirroring the category-item twin one for one: the success shape (`:56`), a row of a visible session matching (`:65`), a row of a hidden session excluded (`:75`), and no visible sessions matching nothing (`:85`). +- **Caveats / not-in-source**: identical to the category-item handler. A failed `Result` becomes `null` in the controller, meaning no filter (`SessionSpeakersController.cs:75`), and the materialized id list is unbounded in this file. ### GetPublicSpeakerCategoryItemFilterQuery @@ -1496,18 +1713,18 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **What it is**: the parameterless query that asks for the read filter applied to the speaker-to-category-item junction. It carries no data at all; it exists purely as the typed key that resolves the matching handler out of DI. - **Depends on**: nothing first-party. It is a bare `public sealed record` with no positional parameters and no members (`GetPublicSpeakerCategoryItemFilterQuery.cs:8`). - **Concept introduced**: **the filter query as a DI lookup token.** Most CQRS messages in this module carry a payload. This one carries none, because the answer depends only on ambient data (which events are published, which sessions are on the BR-49 allow-list) and not on anything the caller can supply. Declaring it as a type anyway is what lets a controller inject `IQueryHandler` and get the visibility rule through the same pipeline as every other read, rather than calling a static helper directly from the API layer. `[Rubric §6, CQRS & Event-Driven]` assesses whether reads are expressed as explicit, individually resolvable messages: even a zero-argument rule gets its own query type here. `[Rubric §11, Security]` assesses where authorization data is decided: the rule is derived server-side from published state, so there is no request field an anonymous caller could tamper with. -- **Walkthrough**: one line of code. The XML doc above it (`:3-7`) is the load-bearing part: it records why the junction needs its own filter at all, namely that without it the join endpoints would list the categories of a hidden speaker (including the BR-66 locality assignments) and leak that speaker's existence even though the speaker row itself is filtered out. +- **Walkthrough**: one line of code (`:8`). The XML doc above it (`:3-7`) is the load-bearing part: it records why the junction needs its own filter at all, namely that without it the join endpoints would list the categories of a hidden speaker (including the BR-66 locality assignments) and leak that speaker's existence even though the speaker row itself is filtered out. - **Why it's built this way**: a record with no parameters still gets value equality and a compiler-generated `ToString`, and costs nothing to allocate per request. Keeping it distinct from [GetPublicSpeakerFilterQuery](#getpublicspeakerfilterquery) means the two filters can diverge later (the junction read has no event context to scope by) without either handler growing a mode flag. -- **Where it's used**: constructed by [SpeakerCategoryItemsController](group-20-conference-api-grpc.md#speakercategoryitemscontroller) in its private `BuildPublicSpecificationAsync` helper (`MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:71`) and answered by [GetPublicSpeakerCategoryItemFilterHandler](#getpublicspeakercategoryitemfilterhandler). +- **Where it's used**: constructed by [SpeakerCategoryItemsController](group-20-conference-api-grpc.md#speakercategoryitemscontroller) in its private `BuildPublicSpecificationAsync` helper (`MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:72`) and answered by [GetPublicSpeakerCategoryItemFilterHandler](#getpublicspeakercategoryitemfilterhandler). ### GetPublicSpeakerFilterQuery > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.GetPublicSpeakerFilter` · `MMCA.ADC.Conference.Application/Speakers/UseCases/GetPublicSpeakerFilter/GetPublicSpeakerFilterQuery.cs:20` · Level 0 · record - **What it is**: the query that asks for the public-speaker read filter (BR-239), optionally narrowed to one event. It is a single-parameter record whose only field is a nullable event id that defaults to `null`. -- **Depends on**: the module alias `EventIdentifierType` (`int` for Conference, declared in `MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7`). No other first-party types. -- **Concept introduced**: **the optional scope parameter.** The same rule has to answer two different questions: "which speakers are public anywhere" and "which speakers are public *on this event*". Rather than two query types, one nullable parameter distinguishes them, and the default `= null` (`GetPublicSpeakerFilterQuery.cs:20`) means the un-scoped call site reads as `new GetPublicSpeakerFilterQuery()`. `[Rubric §9, API & Contract Design]` assesses contract expressiveness: the nullable is documented per-parameter (`:15-19`) as "the paged list has one, everything else passes none", so the two modes are part of the published contract rather than folklore. `[Rubric §11, Security]` as with the junction query: the id only *narrows* the rule, it can never widen it, because an unpublished or unknown scoped event resolves to an empty visible set (`MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:109-115`). -- **Walkthrough**: `public sealed record GetPublicSpeakerFilterQuery(EventIdentifierType? EventId = null)` (`:20`). The `` block (`:9-14`) explains the shape the handler will return: `Speaker` carries no status or event column of its own, so the rule cannot be expressed as a property comparison and is instead resolved into an id list and returned as a `Speaker.Id IN (...)` criteria, following the BR-132 precedent. That keeps the criteria free of navigation joins, so it stays translatable on any engine and `Speaker` keeps its by-id boundary to the `Session` and `Event` aggregates. +- **Depends on**: the module alias `EventIdentifierType` (`int` for Conference, declared in `MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8`). No other first-party types. +- **Concept introduced**: **the optional scope parameter.** The same rule has to answer two different questions: "which speakers are public anywhere" and "which speakers are public *on this event*". Rather than two query types, one nullable parameter distinguishes them, and the default `= null` (`GetPublicSpeakerFilterQuery.cs:20`) means the un-scoped call site reads as `new GetPublicSpeakerFilterQuery()`. `[Rubric §9, API & Contract Design]` assesses contract expressiveness: the nullable is documented per-parameter (`:15-19`) as "the paged list has one, everything else passes none", so the two modes are part of the published contract rather than folklore. `[Rubric §11, Security]` as with the junction query: the id only *narrows* the rule, it can never widen it, because an unpublished or unknown scoped event resolves to an empty visible set (`MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:114-120`). +- **Walkthrough**: `public sealed record GetPublicSpeakerFilterQuery(EventIdentifierType? EventId = null)` (`:20`). The `` block (`:9-14`) explains the shape the handler will return: [Speaker](group-17-conference-domain.md#speaker) carries no status or event column of its own, so the rule cannot be expressed as a property comparison and is instead resolved into an id list and returned as a `Speaker.Id IN (...)` criteria, following the BR-132 precedent. That keeps the criteria free of navigation joins, so it stays translatable on any engine and `Speaker` keeps its by-id boundary to the [Session](group-17-conference-domain.md#session) and [Event](group-17-conference-domain.md#event) aggregates. - **Why it's built this way**: making the scope optional rather than required is what lets one handler serve the paged list, the lookup, `GetById`, and the junction reads. The alternative (a required id plus a sentinel) would have pushed the "no context" case into every caller. - **Where it's used**: constructed by [SpeakersController](group-20-conference-api-grpc.md#speakerscontroller) in `BuildPublicSpeakerSpecificationAsync` (`MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:89`) and answered by [GetPublicSpeakerFilterHandler](#getpublicspeakerfilterhandler). @@ -1516,10 +1733,10 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.GetSessionBookmarkCount` · `MMCA.ADC.Conference.Application/Speakers/UseCases/GetSessionBookmarkCount/GetSessionBookmarkCountQuery.cs:6` · Level 0 · record - **What it is**: the query behind the speaker dashboard's "how many people bookmarked my talk" number (BR-210). It names both the session being counted and the speaker asking, so the handler can authorize the read. -- **Depends on**: the module aliases `SpeakerIdentifierType` (`System.Guid`) and `SessionIdentifierType` (`int`), declared in `MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:18` and `:14`. +- **Depends on**: the module aliases `SpeakerIdentifierType` (`System.Guid`) and `SessionIdentifierType` (`int`), declared in `MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:19` and `:15`. - **Concept introduced**: **carrying the subject alongside the object.** The count itself only needs a session id. The speaker id is in the message because the authorization rule is "you may see the count for a session you are assigned to", and that rule is enforced by the handler rather than by a route filter. Putting the speaker in the query keeps the authorization input explicit and testable instead of hidden in ambient request state. `[Rubric §11, Security]` assesses whether object-level authorization is enforced next to the data access; here the pairing in the query is what makes that possible. `[Rubric §6, CQRS & Event-Driven]`: a read that spans two bounded contexts still travels as one ordinary query. - **Walkthrough**: `public sealed record GetSessionBookmarkCountQuery(SpeakerIdentifierType SpeakerId, SessionIdentifierType SessionId)` (`:6`), with per-parameter docs naming `SpeakerId` as "the speaker requesting the count" (`:4`). -- **Where it's used**: constructed by [SpeakersController](group-20-conference-api-grpc.md#speakerscontroller) at `MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:430` for the `GET /Speakers/{speakerId}/sessions/{sessionId}/bookmarks/count` endpoint (`:421`), and handled by [GetSessionBookmarkCountHandler](#getsessionbookmarkcounthandler). Its batch sibling is [GetSessionBookmarkCountsQuery](#getsessionbookmarkcountsquery), which the dashboard uses to avoid a per-session fan-out (`:441-449`). +- **Where it's used**: constructed by [SpeakersController](group-20-conference-api-grpc.md#speakerscontroller) at `MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:438` for the `GET /Speakers/{speakerId}/sessions/{sessionId}/bookmarks/count` endpoint (`:429`), and handled by [GetSessionBookmarkCountHandler](#getsessionbookmarkcounthandler). Its batch sibling is [GetSessionBookmarkCountsQuery](#getsessionbookmarkcountsquery), which the dashboard uses to avoid a per-session fan-out (`:449-459`). ### SessionEventIdRules @@ -1550,7 +1767,7 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **What it is**: the rule fragment bounding a session's optional description to 4000 characters, the widest text bound in the session family. - **Depends on**: [OptionalStringRules](group-06-validation.md#optionalstringrulest) (`:38`) and [SessionInvariants](group-17-conference-domain.md#sessioninvariants) (`:41`). - **Concept reinforced**: identical shape to [SessionAccessibilityInfoRules](#sessionaccessibilityinforulest): nullable field, `MaximumLength` only, no `NotEmpty`. -- **Walkthrough**: the constructor (`:40`) forwards `base(selector, "Session Description", SessionInvariants.DescriptionMaxLength)` (`:41`); `DescriptionMaxLength` is `4000` (`SessionInvariants.cs:16`). +- **Walkthrough**: the constructor (`:40`) forwards `base(selector, "Session Description", SessionInvariants.DescriptionMaxLength)` (`:41`); `DescriptionMaxLength` is `4000` (`SessionInvariants.cs:16`), the same constant the aggregate's own update guard cites (`SessionInvariants.cs:73`). - **Where it's used**: `Include`d by [SessionCreateRequestValidator](#sessioncreaterequestvalidator) (`:13`) and [SessionUpdateRequestValidator](#sessionupdaterequestvalidator) (`:12`). ### SessionLiveUrlRules @@ -1602,7 +1819,7 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **Depends on**: [RequiredStringRules](group-06-validation.md#requiredstringrulest) (its base class, `SessionValidationRules.cs:14`) and [SessionInvariants](group-17-conference-domain.md#sessioninvariants) (`:17`). - **Concept reinforced**: the parameterized fragment from [SessionEventIdRules](#sessioneventidrulest), specialized against the *required* framework base rather than the optional one. [RequiredStringRules](group-06-validation.md#requiredstringrulest) chains `NotEmpty()` then `MaximumLength(maxLength)` with generated messages built from the field label (`MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:15-18`), so choosing the base is how a fragment declares required-versus-optional. `[Rubric §1, SOLID]` and `[Rubric §24, Forms/Validation/UX Safety]`. - **Walkthrough**: `sealed class SessionTitleRules : RequiredStringRules` (`:13-14`); the constructor (`:16`) forwards `base(selector, "Session Title", SessionInvariants.TitleMaxLength)` (`:17`). `TitleMaxLength` is `500` (`SessionInvariants.cs:13`), the same constant the aggregate's own title guard cites (`SessionInvariants.cs:49`). -- **Caveats / not-in-source**: unlike [SessionEventIdRules](#sessioneventidrulest), none of the seven string fragments in this file set `WithErrorCode`, because neither framework base does (`CommonValidationRules.cs:16-18,28-29`). Their failures therefore surface with generated messages and FluentValidation's default codes, not the stable `Session..` codes the domain invariants use. +- **Caveats / not-in-source**: unlike [SessionEventIdRules](#sessioneventidrulest), none of the seven string fragments in this file set `WithErrorCode`, because neither framework base does (`CommonValidationRules.cs:16-18,28-29`). Their failures therefore surface with generated messages and FluentValidation's default codes, not the stable `Session..` codes the domain invariants use (`SessionInvariants.cs:49,73-78`). - **Where it's used**: `Include`d by [SessionCreateRequestValidator](#sessioncreaterequestvalidator) (`:11`) and [SessionUpdateRequestValidator](#sessionupdaterequestvalidator) (`:11`). ### GetSessionBookmarkCountHandler @@ -1611,23 +1828,25 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **What it is**: the handler for [GetSessionBookmarkCountQuery](#getsessionbookmarkcountquery). It verifies the asking speaker is actually assigned to the session, then asks the Engagement module for the count. Conference never reads Engagement's bookmark table itself. - **Depends on**: [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult), [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork), [IBookmarkCountService](group-22-engagement-module.md#ibookmarkcountservice) (Engagement's shared contract), [Session](group-17-conference-domain.md#session) and its [SessionSpeaker](group-17-conference-domain.md#sessionspeaker) children, plus [Result](group-01-result-error-handling.md#result) and [Error](group-01-result-error-handling.md#error). -- **Concept introduced**: **the cross-context read through an owned interface.** Bookmarks belong to Engagement's bounded context and live in Engagement's own database (database-per-service, ADR-006), so there is no join available and no table Conference is allowed to touch. Instead Engagement publishes a one-method contract in its `Shared` project and Conference depends on that abstraction (`GetSessionBookmarkCountHandler.cs:16`). In the monolith the implementation is in-process; in the extracted topology the same interface is satisfied by a gRPC client the Conference service host registers with `services.AddEngagementBookmarkCountClient()` (`MMCA.ADC.Conference.Service/Program.cs:329`, which replaces any prior registration), and by `DisabledBookmarkCountService` when the Engagement module is switched off (`MMCA.ADC.Engagement.API/EngagementModule.cs:30-32`, its `RegisterDisabledStubs` hook). The handler is unchanged in all three cases. `[Rubric §7, Microservices Readiness]` assesses whether cross-module calls go through abstractions that can be re-pointed at a transport; this is the pattern in one file. `[Rubric §3, Clean Architecture]`: the Application layer names an interface and never a transport. `[Rubric §11, Security]`: the ownership check sits in the handler, immediately beside the data it guards. -- **Walkthrough**: the primary constructor (`:14-16`) injects [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) and [IBookmarkCountService](group-22-engagement-module.md#ibookmarkcountservice). `HandleAsync` (`:19`) resolves the session repository off the unit of work (`:23`, never by constructor-injecting `IRepository<,>` directly) and loads the session by id with its `SessionSpeakers` included and `asTracking: false` (`:24-28`), since this is a pure read. A missing session returns `Error.NotFound` stamped with the handler name and target (`:30`). The authorization step (`:33`) then requires at least one non-soft-deleted `SessionSpeaker` whose `SpeakerId` matches the caller, and otherwise returns `Error.Forbidden` coded `Speaker.NotAssigned` (`:35-40`). Only after both checks does it call `bookmarkCountService.GetBookmarkCountForSessionAsync(query.SessionId, cancellationToken)` (`:43`) and wrap the integer in `Result.Success` (`:45`). -- **Why it's built this way**: ordering matters. The not-found check precedes the assignment check, and the assignment check precedes the cross-context call, so an unauthorized caller never causes a gRPC hop and never learns anything beyond "forbidden". Distinguishing `NotFound` from `Forbidden` is a deliberate choice here (the session's existence is public information on this module's read surface), which is the opposite of the Bookmarks delete endpoint, where Engagement returns 404 rather than 403 to avoid leaking existence. -- **Where it's used**: injected into [SpeakersController](group-20-conference-api-grpc.md#speakerscontroller) (`MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:52`) and invoked by the `GET /Speakers/{speakerId}/sessions/{sessionId}/bookmarks/count` endpoint (`:421-436`), which is `[AllowAnonymous]` and served through the `BookmarkCountsCache` output-cache policy (`:422-423`). The batch equivalent used by the speaker dashboard is [GetSessionBookmarkCountsHandler](#getsessionbookmarkcountshandler) (`:441-449`). +- **Concept introduced**: **the cross-context read through an owned interface.** Bookmarks belong to Engagement's bounded context and live in Engagement's own database (database-per-service, [ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)), so there is no join available and no table Conference is allowed to touch. Instead Engagement publishes a one-method contract in its `Shared` project and Conference depends on that abstraction (`GetSessionBookmarkCountHandler.cs:16`). In the monolith the implementation is in-process; in the extracted topology the same interface is satisfied by a gRPC client the Conference service host registers with `services.AddEngagementBookmarkCountClient()` (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:350`, which replaces any prior registration, `:347-349`), and by [DisabledBookmarkCountService](group-22-engagement-module.md#disabledbookmarkcountservice) when the Engagement module is switched off (`MMCA.ADC.Engagement.API/EngagementModule.cs:30-32`, its `RegisterDisabledStubs` hook). The handler is unchanged in all three cases. `[Rubric §7, Microservices Readiness]` assesses whether cross-module calls go through abstractions that can be re-pointed at a transport; this is that pattern in one file, and the gRPC path is [ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html). `[Rubric §3, Clean Architecture]`: the Application layer names an interface and never a transport. `[Rubric §11, Security]`: the ownership check sits in the handler, immediately beside the data it guards. +- **Walkthrough**: the primary constructor (`:14-16`) injects [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) and [IBookmarkCountService](group-22-engagement-module.md#ibookmarkcountservice). `HandleAsync` (`:19-21`) resolves the session repository off the unit of work (`:23`, never by constructor-injecting `IRepository<,>` directly) and loads the session by id with its `SessionSpeakers` included and `asTracking: false` (`:24-28`), since this is a pure read. A missing session returns `Error.NotFound` stamped with the handler name and target (`:30`). The authorization step (`:33`) then requires at least one non-soft-deleted `SessionSpeaker` whose `SpeakerId` matches the caller, and otherwise returns `Error.Forbidden` coded `Speaker.NotAssigned` (`:35-40`). Only after both checks does it call `bookmarkCountService.GetBookmarkCountForSessionAsync(query.SessionId, cancellationToken)` (`:43`) and wrap the integer in `Result.Success` (`:45`). +- **Why it's built this way**: ordering matters. The not-found check precedes the assignment check, and the assignment check precedes the cross-context call, so an unauthorized caller never causes a gRPC hop and never learns anything beyond "forbidden". Distinguishing `NotFound` from `Forbidden` is a deliberate choice here: the session's existence is public information on this module's read surface, which is the opposite of the Bookmarks delete endpoint in Engagement, where a 404 is returned rather than a 403 to avoid leaking existence. +- **Testing**: [GetSessionBookmarkCountHandlerTests](group-27-testing-infrastructure.md#getsessionbookmarkcounthandlertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/GetSessionBookmarkCountHandlerTests.cs:11`) covers exactly the three outcomes above: a missing session (`:25`), an unassigned speaker (`:45`), and the assigned happy path returning the count (`:66`). +- **Where it's used**: injected into [SpeakersController](group-20-conference-api-grpc.md#speakerscontroller) (`MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:52`) and invoked by the `GET /Speakers/{speakerId}/sessions/{sessionId}/bookmarks/count` endpoint (`:429-444`), which is `[AllowAnonymous]` (`:430`) and served through the `BookmarkCountsCache` output-cache policy (`:431`). The batch equivalent used by the speaker dashboard is [GetSessionBookmarkCountsHandler](#getsessionbookmarkcountshandler) (`:449-459`). ### SessionRoomScheduling > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.Validation` · `MMCA.ADC.Conference.Application/Sessions/Validation/SessionRoomScheduling.cs:27` · Level 9 · class (static) - **What it is**: the shared room-assignment guard for the session create and update paths. One static class holding the cross-event room check (BR-130), the SQL-translatable overlap predicate that detects a double booking, and the conflict error both paths return. -- **Depends on**: [IRepository](group-07-persistence-ef-core.md#irepositorytentity-tidentifiertype) (for the existence probe), [Session](group-17-conference-domain.md#session), [Event](group-17-conference-domain.md#event) and its [Room](group-17-conference-domain.md#room) children, [Result](group-01-result-error-handling.md#result) and [Error](group-01-result-error-handling.md#error), plus `System.Linq.Expressions` (BCL) and the module aliases `RoomIdentifierType` / `SessionIdentifierType` (both `int`). -- **Concept introduced**: **the half-open interval and the honestly-documented soft guard.** Two sessions conflict when they share a room and their `[StartsAt, EndsAt)` windows overlap; back-to-back sessions, where one ends exactly when the next starts, do not conflict. That is the whole business rule, and it falls out of the two strict comparisons in the predicate rather than needing any special case. The second, more important lesson is in the class doc (`:16-25`): this check is deliberately **advisory**. The existence probe and the insert or update that follows are separate statements, not one atomic step, so two concurrent organizer writes can both observe a free window and both commit, genuinely double-booking the room. The doc also explains why persistence cannot close the gap cheaply: the predicate spans an interval rather than a single value, and SQL Server has no range-exclusion constraint, so no unique index can express the rule. The trade-off is accepted because the endpoints are organizer-only (a narrow, low-concurrency audience) and the outcome is repairable at any time by editing either session. `[Rubric §8, Data Architecture]` assesses how consistency rules are enforced against the store; this is a read-then-write guard with its own limits written down instead of assumed away. `[Rubric §15, Best Practices & Code Quality]` and `[Rubric §34, Architecture Governance & Documentation]`: an accepted weakness documented at the point of use is worth more than a silent one. `[Rubric §12, Performance & Scalability]`: the check is one server-side existence probe, never a client-side scan of the room's schedule. +- **Depends on**: [IEntityReader](group-07-persistence-ef-core.md#ientityreadertentity-tidentifiertype) for the existence probe (`SessionRoomScheduling.cs:45`), [Session](group-17-conference-domain.md#session), [Event](group-17-conference-domain.md#event) and its [Room](group-17-conference-domain.md#room) children, [Result](group-01-result-error-handling.md#result) and [Error](group-01-result-error-handling.md#error), plus `System.Linq.Expressions` (BCL) and the module aliases `RoomIdentifierType` and `SessionIdentifierType` (both `int`). +- **Concept introduced**: **the half-open interval and the honestly-documented soft guard.** Two sessions conflict when they share a room and their `[StartsAt, EndsAt)` windows overlap; back-to-back sessions, where one ends exactly when the next starts, do not conflict. That is the whole business rule, and it falls out of the two strict comparisons in the predicate rather than needing any special case. The second, more important lesson is in the class doc (`:16-25`): this check is deliberately **advisory**. The existence probe and the insert or update that follows are separate statements, not one atomic step, so two concurrent organizer writes can both observe a free window and both commit, genuinely double-booking the room. The doc also explains why persistence cannot close the gap cheaply: the predicate spans an interval rather than a single value, and SQL Server has no range-exclusion constraint, so no unique index can express the rule. The trade-off is accepted because the create and update endpoints are organizer-only (a narrow, low-concurrency audience) and the outcome is repairable at any time by editing either session's room or slot. `[Rubric §8, Data Architecture]` assesses how consistency rules are enforced against the store; this is a read-then-write guard with its own limits written down instead of assumed away. `[Rubric §15, Best Practices & Code Quality]` and `[Rubric §34, Architecture Governance & Documentation]`: an accepted weakness documented at the point of use is worth more than a silent one. `[Rubric §12, Performance & Scalability]`: the check is one server-side existence probe, never a client-side scan of the room's schedule. - **Walkthrough** (three public members, in call order): - `ValidateRoomAssignmentAsync` (`:44-81`) is the entry point both handlers call. It null-guards `parentEvent` (`:54`), then short-circuits to success when no room was requested (`:56-57`), because an unassigned session cannot conflict with anything. It looks the room up **inside the already-loaded parent event's `Rooms` collection**, requiring it to be non-soft-deleted (`:59`); a room that belongs to some other event is not found there and returns `Error.Validation` coded `Session.RoomId.CrossEvent` targeting `Session.RoomId` (`:62-67`). That is BR-130, and it costs no extra query. It then short-circuits again if either end of the window is missing (`:69-70`): a room can be assigned without a scheduled slot. Only with a room and both times does it run the probe, `repository.ExistsAsync(BuildOverlapPredicate(...))` (`:74-76`), returning the conflict error or success (`:78-80`). - - `BuildOverlapPredicate` (`:93-107`) builds the `Expression>` the probe translates to SQL. `excludeSessionId` defaults to `null` and collapses to `int.MinValue` (`:101`), which the comment justifies: session ids are always positive (Sessionize-assigned or the reserved manual range), so the sentinel excludes nothing and keeps the predicate a single shape rather than two conditionally-composed ones. The predicate itself (`:103-106`) requires same `RoomId`, `Id != exclusionId`, both timestamps non-null, and then the two strict comparisons `s.StartsAt < endsAt && s.EndsAt > startsAt` that define the half-open overlap. + - `BuildOverlapPredicate` (`:93-107`) builds the `Expression>` the probe translates to SQL. `excludeSessionId` defaults to `null` and collapses to `int.MinValue` (`:101`), which the comment justifies: session ids are always positive (Sessionize-assigned or the reserved manual range), so the sentinel excludes nothing and keeps the predicate a single shape rather than two conditionally-composed ones. The predicate itself (`:103-106`) requires the same `RoomId`, `Id != exclusionId`, both timestamps non-null, and then the two strict comparisons `s.StartsAt < endsAt && s.EndsAt > startsAt` that define the half-open overlap. - `DoubleBookedError` (`:116-121`) returns `Error.Conflict` coded `Session.Room.DoubleBooked`, the 409-style failure the API surfaces. Exposing it as a named member means the two handlers and their tests refer to one definition of the conflict. -- **Why it's built this way**: factoring the rule into a static class (rather than duplicating it in each handler, or pushing it into `Session`) is a direct consequence of where the data lives. The rule spans two aggregates: it needs the parent [Event](group-17-conference-domain.md#event)'s rooms and it needs every *other* session's schedule, so no single aggregate can enforce it and it belongs in the application layer beside the handlers that load both. Keeping the predicate a separate public member is what lets the update path pass `excludeSessionId` so a session can keep or shrink its own slot without colliding with itself. +- **Why it's built this way**: factoring the rule into a static class (rather than duplicating it in each handler, or pushing it into `Session`) is a direct consequence of where the data lives. The rule spans two aggregates: it needs the parent [Event](group-17-conference-domain.md#event)'s rooms and it needs every *other* session's schedule, so no single aggregate can enforce it, and it belongs in the application layer beside the handlers that load both. Keeping the predicate a separate public member is what lets the update path pass `excludeSessionId` so a session can keep or shrink its own slot without colliding with itself. +- **Testing**: [SessionRoomSchedulingTests](group-27-testing-infrastructure.md#sessionroomschedulingtests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionRoomSchedulingTests.cs:12`) exercises the predicate as a pure function, which is what makes the half-open rule cheap to pin down: overlapping (`:54`) and fully contained (`:62`) slots match, while back-to-back (`:70`), same-room-different-day (`:79`), different-room (`:87`), self-excluded (`:95`), and unscheduled (`:104`) cases do not. A final test asserts `DoubleBookedError` is conflict-typed (`:112`). - **Caveats / not-in-source**: the class doc notes that deliberate co-location (lightning talks sharing one slot) would need this check relaxed from a rejection to a warning (`:13-15`). No such relaxation exists in the current code. - **Where it's used**: called by [CreateSessionHandler](#createsessionhandler) with `excludeSessionId: null` (`MMCA.ADC.Conference.Application/Sessions/UseCases/Create/CreateSessionHandler.cs:111-119`, only when the command actually carries a room, `:100`) and by [UpdateSessionHandler](#updatesessionhandler) with `excludeSessionId: command.Id` (`MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionHandler.cs:57-65`), each passing its own handler name as the error `source`. Both load the parent event with `includes: [nameof(Event.Rooms)]` and `asTracking: false` first (`CreateSessionHandler.cs:103-107`, `UpdateSessionHandler.cs:48-52`), because that collection is what the cross-event check reads. @@ -1637,11 +1856,12 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **What it is**: the handler that turns [GetPublicSpeakerCategoryItemFilterQuery](#getpublicspeakercategoryitemfilterquery) into a specification restricting the speaker-to-category-item junction to rows whose parent speaker is publicly visible (BR-239). - **Depends on**: [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult), [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork), [PublicConferenceVisibility](#publicconferencevisibility), [Specification](group-03-querying-specifications.md#specificationtentity-tidentifiertype) and [InlineSpecification](group-03-querying-specifications.md#inlinespecificationtentity-tidentifiertype), [SpeakerCategoryItem](group-17-conference-domain.md#speakercategoryitem), and [Result](group-01-result-error-handling.md#result). -- **Concept introduced**: **a query handler whose result is a filter, not data.** Every other read handler in this module returns rows or a DTO. This one returns a [Specification](group-03-querying-specifications.md#specificationtentity-tidentifiertype), which the controller then hands to its generic query service so the framework's paging, sorting, and projection all run *inside* the restricted set. The visibility rule is therefore composed with the caller's own filters by the query pipeline rather than being applied afterwards in memory, which is what keeps page counts honest. `[Rubric §6, CQRS & Event-Driven]` assesses the read side's composability; `[Rubric §11, Security]` assesses that the restriction is applied at the data layer, so an attacker cannot page past it; `[Rubric §12, Performance & Scalability]` assesses that filtering happens server-side rather than after materialization. -- **Walkthrough**: the primary constructor (`:17-18`) injects [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) only. The declared result type (`:19`) is `Result>`. `HandleAsync` (`:22`) calls `PublicConferenceVisibility.GetVisibleSpeakerIdsAsync(unitOfWork, cancellationToken: cancellationToken)` (`:26-28`), passing **no** event scope, so the rule spans every published event. It wraps the resulting id list in an [InlineSpecification](group-03-querying-specifications.md#inlinespecificationtentity-tidentifiertype) whose criteria is `sci => speakerIds.Contains(sci.SpeakerId)` (`:31-32`), a `SpeakerId IN (...)` predicate, and returns it as a success (`:30`). -- **Why it's built this way**: the junction row follows the visibility of its parent, so the handler reuses the one visible-speaker computation instead of re-deriving a junction-specific rule. Filtering by an id list rather than a navigation join keeps the criteria engine-portable and preserves the by-id boundary between `SpeakerCategoryItem` and the `Session` and `Event` aggregates the rule actually reads. The `query` parameter is accepted and unused because the [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) contract requires it, which is also why the cancellation token is passed by name (`:27`). +- **Concept introduced**: **a query handler whose result is a filter, not data.** Every other read handler in this module returns rows or a DTO. This one returns a [Specification](group-03-querying-specifications.md#specificationtentity-tidentifiertype), which the controller then hands to its generic query service so the framework's paging, sorting, and projection all run *inside* the restricted set. The visibility rule is therefore composed with the caller's own filters by the query pipeline rather than being applied afterwards in memory, which is what keeps page counts honest. `[Rubric §6, CQRS & Event-Driven]` assesses the read side's composability; `[Rubric §11, Security]` assesses that the restriction is applied at the data layer, so a caller cannot page past it; `[Rubric §12, Performance & Scalability]` assesses that filtering happens server-side rather than after materialization. +- **Walkthrough**: the primary constructor (`:17-18`) injects [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) only. The declared result type (`:19`) is `Result>`. `HandleAsync` (`:22-24`) calls `PublicConferenceVisibility.GetVisibleSpeakerIdsAsync(unitOfWork, cancellationToken: cancellationToken)` (`:26-28`), passing **no** event scope, so the rule spans every published event. It wraps the resulting id list in an [InlineSpecification](group-03-querying-specifications.md#inlinespecificationtentity-tidentifiertype) whose criteria is `sci => speakerIds.Contains(sci.SpeakerId)` (`:31-32`), a `SpeakerId IN (...)` predicate, and returns it as a success (`:30`). +- **Why it's built this way**: the junction row follows the visibility of its parent, so the handler reuses the one visible-speaker computation instead of re-deriving a junction-specific rule. Filtering by an id list rather than a navigation join keeps the criteria engine-portable ([ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html)) and preserves the by-id boundary between `SpeakerCategoryItem` and the `Session` and `Event` aggregates the rule actually reads. The `query` parameter is accepted and unused because the [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) contract requires it, which is also why the cancellation token is passed by name (`:27`). +- **Testing**: [GetPublicSpeakerCategoryItemFilterHandlerTests](group-27-testing-infrastructure.md#getpublicspeakercategoryitemfilterhandlertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/GetPublicSpeakerCategoryItemFilter/GetPublicSpeakerCategoryItemFilterHandlerTests.cs:19`), four tests: the success shape (`:66`), a row of a visible speaker matching (`:75`), a row of a hidden speaker excluded (`:85`), and no visible speakers matching nothing (`:95`). - **Caveats / not-in-source**: the id list is materialized into the expression, so the generated SQL carries as many parameters as there are visible speakers. What that costs at conference scale is not determinable from this file. -- **Where it's used**: injected into [SpeakerCategoryItemsController](group-20-conference-api-grpc.md#speakercategoryitemscontroller) (`MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:51`) and invoked from its `BuildPublicSpecificationAsync` helper (`:66-75`), which returns `null` for privileged readers (Organizer/ContentEditor) so they see every row. That helper feeds all four junction read endpoints: `GetAll` (`:90`), the paged list (`:120`), the lookup (`:148`, where only the specification's `Criteria` is forwarded), and `GetById` (`:178`). Registration is convention-based: the module's `ScanModuleApplicationServices()` call picks up every handler in the assembly (`MMCA.ADC.Conference.Application/DependencyInjection.cs:112`). +- **Where it's used**: injected into [SpeakerCategoryItemsController](group-20-conference-api-grpc.md#speakercategoryitemscontroller) (`MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:52`) and invoked from its `BuildPublicSpecificationAsync` helper (`:67-76`), which returns `null` for privileged readers (Organizer/ContentEditor, `:59,69-70`) so they see every row. That helper feeds all four junction read endpoints: `GetAll` (`:91`), the paged list (`:121`), the lookup (`:149`, where only the specification's `Criteria` is forwarded, `:155`), and `GetById` (`:179`), each `[AllowAnonymous]` (`:79`, `:102`, `:143`, `:165`) under the class-level `[HasPermission(ConferencePermissions.SpeakersManage)]` requirement (`:47`). Registration is convention-based: the module's `ScanModuleApplicationServices()` call picks up every handler in the assembly (`MMCA.ADC.Conference.Application/DependencyInjection.cs:125`). ### GetPublicSpeakerFilterHandler @@ -1649,11 +1869,12 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **What it is**: the handler that turns [GetPublicSpeakerFilterQuery](#getpublicspeakerfilterquery) into a `Speaker.Id IN (...)` specification implementing BR-239: a speaker is publicly visible when they have at least one publicly visible session in the scoped published-event set. - **Depends on**: [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult), [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork), [PublicConferenceVisibility](#publicconferencevisibility), [Specification](group-03-querying-specifications.md#specificationtentity-tidentifiertype) and [InlineSpecification](group-03-querying-specifications.md#inlinespecificationtentity-tidentifiertype), [Speaker](group-17-conference-domain.md#speaker), and [Result](group-01-result-error-handling.md#result). -- **Concept reinforced**: the filter-returning query handler introduced by [GetPublicSpeakerCategoryItemFilterHandler](#getpublicspeakercategoryitemfilterhandler), here with the optional event scope threaded through. It is worth understanding *why* the rule has to be resolved into ids at all: `Speaker` carries no status column and no event column, so "is this speaker public" is not a property of the speaker row. It is a fact about the sessions the speaker is linked to, which live in another aggregate. Resolving it to an id list is the BR-132 precedent, and the shape is shared with [GetSpeakersByEventFilterHandler](#getspeakersbyeventfilterhandler). `[Rubric §4, DDD]` assesses aggregate boundaries: `Speaker` keeps a by-id relationship to `Session` and `Event` instead of growing a navigation that would merge three aggregates into one query. `[Rubric §8, Data Architecture]`: an id-list criteria has no join, so it stays translatable on every engine the framework supports. -- **Walkthrough**: the primary constructor (`:17-18`) injects [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork). `HandleAsync` (`:22-24`) passes `query.EventId` straight through to `PublicConferenceVisibility.GetVisibleSpeakerIdsAsync(unitOfWork, query.EventId, cancellationToken)` (`:26-28`), then returns `Result.Success` over an [InlineSpecification](group-03-querying-specifications.md#inlinespecificationtentity-tidentifiertype) with the criteria `s => speakerIds.Contains(s.Id)` (`:30-31`). All of the actual rule lives in [PublicConferenceVisibility](#publicconferencevisibility) (`MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:99-127`), which resolves the published-event set (`:104`), narrows it to the scoped event when one is supplied (an unpublished or unknown scoped event yields an empty list, `:108-115`), collects the BR-49-eligible session ids (`:117-119`), and projects the distinct speaker ids off the `SessionSpeaker` junction (`:121-126`). -- **Why it's built this way**: the handler is deliberately thin. Keeping the rule in [PublicConferenceVisibility](#publicconferencevisibility) is what lets the speaker filter, the junction filter, and the session filters share one definition of "published" and one definition of the BR-49 allow-list (expressed once as [PublicSessionStatusSpecification](#publicsessionstatusspecification), `PublicConferenceVisibility.cs:141-143`), so they cannot drift apart into three subtly different notions of public. The scope is passed through rather than resolved here because narrowing is a caller concern: only the paged list has an event context. -- **Caveats / not-in-source**: [PublicConferenceVisibility](#publicconferencevisibility)'s remarks (`:92-98`) record that the `EventSpeaker` join is deliberately **not** treated as a visibility grant, because the Sessionize import ([SpeakerSyncStrategy](#speakersyncstrategy)) writes a row there for every speaker in the response, which once made this filter vacuous by publishing the entire imported roster. The session link is the only acceptance signal consulted. -- **Where it's used**: injected into [SpeakersController](group-20-conference-api-grpc.md#speakerscontroller) (`MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:55`) and invoked from `BuildPublicSpeakerSpecificationAsync` (`:82-93`), which returns `null` for privileged readers so Organizers and ContentEditors see every speaker (`:62-63,86-87`). +- **Concept reinforced**: the filter-returning query handler introduced by [GetPublicSpeakerCategoryItemFilterHandler](#getpublicspeakercategoryitemfilterhandler), here with the optional event scope threaded through. It is worth understanding *why* the rule has to be resolved into ids at all: [Speaker](group-17-conference-domain.md#speaker) carries no status column and no event column, so "is this speaker public" is not a property of the speaker row. It is a fact about the sessions the speaker is linked to, which live in another aggregate. Resolving it to an id list is the BR-132 precedent, and the shape is shared with [GetSpeakersByEventFilterHandler](#getspeakersbyeventfilterhandler). `[Rubric §4, DDD]` assesses aggregate boundaries: `Speaker` keeps a by-id relationship to `Session` and `Event` instead of growing a navigation that would merge three aggregates into one query. `[Rubric §8, Data Architecture]`: an id-list criteria has no join, so it stays translatable on every engine the framework supports. +- **Walkthrough**: the primary constructor (`:17-19`) injects [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork). `HandleAsync` (`:22-24`) passes `query.EventId` straight through to `PublicConferenceVisibility.GetVisibleSpeakerIdsAsync(unitOfWork, query.EventId, cancellationToken)` (`:26-28`), then returns `Result.Success` over an [InlineSpecification](group-03-querying-specifications.md#inlinespecificationtentity-tidentifiertype) with the criteria `s => speakerIds.Contains(s.Id)` (`:30-31`). All of the actual rule lives in [PublicConferenceVisibility](#publicconferencevisibility) (`MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:104-134`), which resolves the published-event set (`:109`), narrows it to the scoped event when one is supplied (an unpublished or unknown scoped event yields an empty list, `:111-120`), collects the BR-49-eligible session ids (`:122-124`), and projects the distinct speaker ids off the [SessionSpeaker](group-17-conference-domain.md#sessionspeaker) junction (`:126-133`). +- **Why it's built this way**: the handler is deliberately thin. Keeping the rule in [PublicConferenceVisibility](#publicconferencevisibility) is what lets the speaker filter, the junction filter, and the session filters share one definition of "published" and one definition of the BR-49 allow-list (expressed once as [PublicSessionStatusSpecification](#publicsessionstatusspecification) and ANDed with the event-id scope, `PublicConferenceVisibility.cs:148-149`), so they cannot drift apart into three subtly different notions of public. The scope is passed through rather than resolved here because narrowing is a caller concern: only the paged list has an event context. +- **Testing**: [GetPublicSpeakerFilterHandlerTests](group-27-testing-infrastructure.md#getpublicspeakerfilterhandlertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/GetPublicSpeakerFilter/GetPublicSpeakerFilterHandlerTests.cs:22`) is the largest of the filter suites, because the rule has the most edges: an accepted session publishes its speaker (`:153`), a null-status session does too (`:164`), while non-accepted-only (`:193`), `EventSpeaker`-only (`:214`), and orphan (`:235`) speakers stay hidden. The scope modes get their own tests: no scope spans every published event (`:256`), a scope narrows to that event (`:267`), an unpublished scope matches nothing (`:279`), and a scope with no eligible session matches nothing (`:298`). +- **Caveats / not-in-source**: [PublicConferenceVisibility](#publicconferencevisibility)'s remarks (`:97-103`) record that the `EventSpeaker` join is deliberately **not** treated as a visibility grant, because the Sessionize import ([SpeakerSyncStrategy](#speakersyncstrategy)) writes a row there for every speaker in the response, which once made this filter vacuous by publishing the entire imported roster. The session link is the only acceptance signal consulted. +- **Where it's used**: injected into [SpeakersController](group-20-conference-api-grpc.md#speakerscontroller) (`MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:55`) and invoked from `BuildPublicSpeakerSpecificationAsync` (`:82-93`), which returns `null` for privileged readers so Organizers and ContentEditors see every speaker (`:62-63,86-87`). That helper feeds the unpaged list (`:109`), the paged list (the only caller that supplies an event scope, `:162`), the lookup (`:215`), and `GetById` (`:257`). ### GetPublicSponsorFilterQuery @@ -1664,18 +1885,18 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **Concept introduced**: none new; this is the filter-query-as-DI-lookup-token shape taught at [GetPublicSpeakerCategoryItemFilterQuery](#getpublicspeakercategoryitemfilterquery). What is worth reading here is the `` block (`:8-12`), which records the one structural difference from its speaker and session siblings: [Sponsor](group-17-conference-domain.md#sponsor) carries a real `EventId` column (`MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:45`), so the rule can be resolved as a published-event id list and returned as a `Sponsor.EventId IN (...)` criteria with no navigation join anywhere in the expression tree. `[Rubric §11, Security]` assesses where visibility is decided: the query has no field an anonymous caller could set, so the rule cannot be widened from the wire, and the doc comment states the leak being prevented, namely an event still being assembled exposing its sponsor roster before announcement (`:5-6`). `[Rubric §8, Data Architecture]` assesses query portability: keeping the criteria to a scalar `IN` is what makes it translatable on any engine ([ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html), named in the remarks at `:11`). - **Walkthrough**: one line of code (`:13`). The ten lines above it (`:3-12`) are the contract: the summary states the rule and the leak it closes, the remarks state the shape the handler must return and why. - **Why it's built this way**: giving a zero-argument rule its own record type is what lets [SponsorsController](group-20-conference-api-grpc.md#sponsorscontroller) inject `IQueryHandler` and reach the rule through the same pipeline as every other read, instead of calling a static helper from the API layer. -- **Where it's used**: constructed by [SponsorsController](group-20-conference-api-grpc.md#sponsorscontroller) in its private `BuildPublicSponsorSpecificationAsync` helper (`MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:66`) and answered by [GetPublicSponsorFilterHandler](#getpublicsponsorfilterhandler). +- **Where it's used**: constructed by [SponsorsController](group-20-conference-api-grpc.md#sponsorscontroller) inside its private `BuildPublicSponsorSpecificationAsync` helper (`MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:66`) and answered by [GetPublicSponsorFilterHandler](#getpublicsponsorfilterhandler). ### GetSessionBookmarkCountsQuery > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.GetSessionBookmarkCounts` · `MMCA.ADC.Conference.Application/Speakers/UseCases/GetSessionBookmarkCounts/GetSessionBookmarkCountsQuery.cs:6` · Level 0 · record - **What it is**: the read intent behind the Speaker Dashboard's bookmark widget. It asks, in one call, "how many people bookmarked each of these sessions?", carrying the requesting speaker plus the set of session ids to count (BR-210, `GetSessionBookmarkCountsQuery.cs:3`). -- **Depends on**: nothing first-party beyond the module identifier aliases `SpeakerIdentifierType` (a `Guid`) and `SessionIdentifierType` (an `int`), declared in `MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:18,14` and used at `:7-8`. The only external is BCL `IReadOnlyCollection`. +- **Depends on**: nothing first-party beyond the module identifier aliases `SpeakerIdentifierType` (a `System.Guid`) and `SessionIdentifierType` (an `int`), declared in `MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:19,15` and used at `:7-8`. The only external is BCL `IReadOnlyCollection`. - **Concept introduced**: **the batched query, and why the caller's id list is an input rather than an authorization.** The singular sibling [GetSessionBookmarkCountQuery](#getsessionbookmarkcountquery) answers for one session; this record takes a whole collection (`:8`) so a dashboard listing N sessions makes one round trip instead of N. The important design point is what the record does *not* mean: `SessionIds` is a request, not a grant. The speaker id travels alongside (`:7`) precisely so [GetSessionBookmarkCountsHandler](#getsessionbookmarkcountshandler) can re-derive server-side which of those sessions the speaker is actually entitled to see. `[Rubric §11, Security]` assesses whether authorization decisions are made from server-held state rather than from client-supplied claims: the shape of this query is what makes that possible, because it forces the pairing of "who is asking" with "what they asked about". `[Rubric §12, Performance & Scalability]`: collapsing a per-row fan-out into one batched intent is the query-shape half of an N+1 fix. - **Walkthrough**: two positional parameters on a `sealed record` (`:6-8`), `SpeakerId` (`:7`) and `SessionIds` (`:8`). The declared parameter type is `IReadOnlyCollection`, so the handler can cheaply test `Count` before doing any work without committing the caller to a particular collection implementation. There are no methods, no markers, and no cache-invalidation interface: this is a pure read intent. - **Why it's built this way**: queries in this codebase are plain records with no behavior so the [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) implementation stays the single place where the read is described (see [Group 05](group-05-cqrs-pipeline.md)). -- **Where it's used**: constructed by [SpeakersController](group-20-conference-api-grpc.md#speakerscontroller) on `GET {speakerId}/sessions/bookmarks/counts` (`MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:441-451`), which binds `sessionIds` from the query string and null-coalesces a missing array to an empty one (`:446,450`). +- **Where it's used**: constructed by [SpeakersController](group-20-conference-api-grpc.md#speakerscontroller) on `GET {speakerId}/sessions/bookmarks/counts` (`MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:449-459`), which binds `sessionIds` from the query string with `[FromQuery]` and null-coalesces a missing array to an empty one (`:454,458`). ### GetSessionFeedbackQuery @@ -1685,18 +1906,19 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **Depends on**: the module identifier aliases `SpeakerIdentifierType` and `SessionIdentifierType` (`:6`). Nothing else. - **Concept introduced**: none new; this is the same "who is asking plus what they asked about" pair taught at [GetSessionBookmarkCountsQuery](#getsessionbookmarkcountsquery), in its single-target form. The speaker id is not decorative: [GetSessionFeedbackHandler](#getsessionfeedbackhandler) rejects the read with a `Forbidden` error when the speaker is not assigned to the session, so the query type carries exactly the two facts the authorization check needs. `[Rubric §11, Security]`: the read is scoped by a server-verified relationship, not by trusting the route. - **Walkthrough**: a one-line `sealed record` with two positional parameters, `SpeakerId` and `SessionId` (`:6`). The XML docs (`:3-5`) name the business rule and each parameter's role. -- **Where it's used**: constructed by [SpeakersController](group-20-conference-api-grpc.md#speakerscontroller) on `GET {speakerId}/sessions/{sessionId}/feedback` (`MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:402-412`), an `[AllowAnonymous]` endpoint served through the `ConferencePublicCache` output-cache policy (`:403-404`). +- **Where it's used**: constructed by [SpeakersController](group-20-conference-api-grpc.md#speakerscontroller) on `GET {speakerId}/sessions/{sessionId}/feedback` (`MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:406-420`). The endpoint is `[Authorize]` (`:407`) and applies a self-or-organizer gate before the handler ever runs: the caller must hold the `Organizer` role or carry the route speaker's `speaker_id` claim, otherwise it returns `Forbid()` (`:413-416`). It carries no `[OutputCache]` attribute, and the endpoint summary records why (`:403-405`): free-text comments are the speaker's own read, so every response is authorization-dependent and must not be publicly cached. ### GetSpeakersByEventFilterQuery > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.GetSpeakersByEventFilter` · `MMCA.ADC.Conference.Application/Speakers/UseCases/GetSpeakersByEventFilter/GetSpeakersByEventFilterQuery.cs:12` · Level 0 · record - **What it is**: the intent "give me a filter that selects the speakers belonging to this event". It carries one field, the `EventId` (`:12`), and its handler returns a [Specification](group-03-querying-specifications.md#specificationtentity-tidentifiertype) rather than data. -- **Depends on**: the `EventIdentifierType` alias (an `int`, `MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7`), used at `:12`. Nothing else. +- **Depends on**: the `EventIdentifierType` alias (an `int`, `MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8`), used at `:12`. Nothing else. - **Concept introduced**: **the query that returns a specification, not rows.** Most queries in this module resolve to a DTO or a count. This one resolves to a *predicate object* that the caller then composes into a larger read. The reason is spelled out in the type's own doc comment (`:3-10`): a [Speaker](group-17-conference-domain.md#speaker) has no `EventId` column, and it can belong to an event by two independent link paths, the [EventSpeaker](group-17-conference-domain.md#eventspeaker) join written by the Sessionize sync and the [SessionSpeaker](group-17-conference-domain.md#sessionspeaker) join written by organizer session management. Resolving that union has to happen in a handler with repository access, but the *result* still has to be a filter so it can be ANDed with the caller's other criteria and passed to the generic paged read. Returning a specification is how a multi-step lookup is turned back into a single composable clause. `[Rubric §2, Design Patterns]` assesses whether recognized patterns are applied where they earn their keep: this is Specification used as a first-class return value, not just as a parameter. `[Rubric §8, Data Architecture]`: the doc comment records the deliberate choice to resolve the joins as ID-list projections so the criteria stay engine-portable rather than depending on a navigation join. -- **Walkthrough**: the whole type is one line (`:12`); the ten lines above it (`:3-11`) are the design rationale, which is unusually long for a record and is the load-bearing part to read. It names the two link paths, states that they are populated by different flows so the handler must union them, and notes that `Speaker` has no `EventId` column. +- **Walkthrough**: the whole type is one line (`:12`); the nine lines above it (`:3-11`) are the design rationale, which is unusually long for a record and is the load-bearing part to read. It names the two link paths, states that they are populated by different flows so the handler must union them, and notes that `Speaker` has no `EventId` column. - **Why it's built this way**: keeping [Speaker](group-17-conference-domain.md#speaker) free of an `EventId` column preserves the aggregate boundary (a speaker exists independently of any event, and relates to events by id, not by containment). The cost of that DDD choice is this two-path lookup, and the specification return type is what keeps the cost contained in one handler. See [ADR-055](https://ivanball.github.io/docs/adr/055-repository-and-specification-contract.html) for the repository-plus-specification data-access contract this leans on. -- **Where it's used**: [SpeakersController](group-20-conference-api-grpc.md#speakerscontroller) lifts an `EventId` out of the incoming filter dictionary (`MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:154-160`), and when present calls the handler and folds the returned specification into the public-visibility specification via [AndSpecification](group-03-querying-specifications.md#andspecificationtentity-tidentifiertype) before running the paged read (`:165-189`). +- **Where it's used**: [SpeakersController](group-20-conference-api-grpc.md#speakerscontroller) lifts an `EventId` out of the incoming filter dictionary and removes it unconditionally, because `Speaker` has no such column and the generic filter pipeline rejects unknown properties (`MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:150-160`). When an id parsed, it calls the handler and folds the returned specification into the public-visibility specification (`:165-176`) before running the paged read (`:178-189`). +- **Caveats / not-in-source**: this filter is distinct from the BR-239 public-visibility rule, which is resolved separately by `BuildPublicSpeakerSpecificationAsync` (`SpeakersController.cs:162`). The two are ANDed, so an event-scoped listing shows the intersection, not the union. ### GetSessionBookmarkCountsHandler @@ -1704,22 +1926,23 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **What it is**: the handler for [GetSessionBookmarkCountsQuery](#getsessionbookmarkcountsquery). It re-verifies which of the requested sessions actually belong to the asking speaker, then asks the Engagement module for the bookmark counts of just those sessions, in one batched call. - **Depends on**: [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) (`GetSessionBookmarkCountsHandler.cs:4,20`), [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) (`:3,18`), [IBookmarkCountService](group-22-engagement-module.md#ibookmarkcountservice) from `MMCA.ADC.Engagement.Shared.UserSessionBookmarks` (`:2,19`), [Session](group-17-conference-domain.md#session) with its `SessionSpeakers` navigation (`:1,35`), and [Result](group-01-result-error-handling.md#result) (`:5`). -- **Concept introduced**: **reading across a bounded-context boundary through an interface, and filtering-not-failing authorization.** Conference *displays* bookmark counts but does not *own* them: the data lives in Engagement. Rather than referencing Engagement's domain or querying its database, the handler injects [IBookmarkCountService](group-22-engagement-module.md#ibookmarkcountservice), an interface published in Engagement's `Shared` layer. In the monolith topology DI binds it to the in-process [BookmarkCountService](group-22-engagement-module.md#bookmarkcountservice); in ADC's extracted topology the same interface is satisfied by a generated gRPC client, so this handler compiles and behaves identically either way ([ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html), [ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)). `[Rubric §7, Microservices Readiness]` assesses whether a module could be extracted without rewriting its callers: this handler is the proof, the only Engagement-shaped thing it knows is one interface. The second idea is the authorization posture (`:40-46`): the handler keeps only sessions the speaker is assigned to and *silently drops* the rest rather than failing the whole batch, so one stale or foreign id in a dashboard's list never denies the speaker the counts they are entitled to. `[Rubric §11, Security]`: the client's id list is treated as a request, never as a grant, and the class doc comment states that intent explicitly (`:11-12`). -- **Walkthrough**: the primary constructor takes the unit of work and the count service (`:17-19`); the class implements `IQueryHandler>>` (`:20`). `HandleAsync` (`:23`) starts with an empty-input short circuit returning an empty dictionary without touching the database (`:27-31`). It then takes the *read* repository (`GetReadRepository`, `:33`) and loads the requested sessions with their `SessionSpeakers` eager-included, `asTracking: false` (`:34-38`), a read-only query with no change-tracker overhead. The authorization projection (`:43-46`) keeps sessions where any `SessionSpeaker` matches the query's `SpeakerId` and is not soft-deleted, and selects just the ids. A second short circuit returns an empty dictionary when nothing survived (`:48-52`). Finally it calls `bookmarkCountService.GetBookmarkCountsForSessionsAsync(authorizedSessionIds, ...)` (`:54-56`) and wraps the dictionary in `Result.Success` (`:58`). -- **Why it's built this way**: the batched contract exists so the Speaker Dashboard makes one call instead of one per session; the class doc comment (`:9-16`) names that as the reason. Note the explicit `!ss.IsDeleted` test at `:44`: the eager-loaded child collection is filtered in memory here, so the check is written out rather than relying solely on the EF global soft-delete filter ([ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html)). `[Rubric §12, Performance & Scalability]`: two round trips total (one local read, one cross-module call) regardless of session count. -- **Where it's used**: injected into [SpeakersController](group-20-conference-api-grpc.md#speakerscontroller) as `IQueryHandler` (`MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:53`) and invoked from the `bookmarks/counts` endpoint (`:449-451`), which serves through the `BookmarkCountsCache` output-cache policy (`:443`). +- **Concept introduced**: **reading across a bounded-context boundary through an interface, and filtering-not-failing authorization.** Conference *displays* bookmark counts but does not *own* them: the data lives in Engagement. Rather than referencing Engagement's domain or querying its database, the handler injects [IBookmarkCountService](group-22-engagement-module.md#ibookmarkcountservice), an interface published in Engagement's `Shared` layer (`MMCA.ADC.Engagement.Shared/UserSessionBookmarks/IBookmarkCountService.cs:8`). In the monolith topology DI binds it to the in-process [BookmarkCountService](group-22-engagement-module.md#bookmarkcountservice) via `TryAddScoped` (`MMCA.ADC.Engagement.Application/DependencyInjection.cs:45`); in ADC's extracted topology that registration is swapped for `BookmarkCountServiceGrpcAdapter`, a hand-written adapter over the generated gRPC client, using `services.Replace` (`MMCA.ADC.Engagement.Contracts/DependencyInjection.cs:49`, `MMCA.ADC.Engagement.Contracts/BookmarkCountServiceGrpcAdapter.cs:15`). This handler compiles and behaves identically either way ([ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html), [ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)). `[Rubric §7, Microservices Readiness]` assesses whether a module could be extracted without rewriting its callers: this handler is the proof, the only Engagement-shaped thing it knows is one interface. The second idea is the authorization posture (`:40-46`): the handler keeps only sessions the speaker is assigned to and *silently drops* the rest rather than failing the whole batch, so one stale or foreign id in a dashboard's list never denies the speaker the counts they are entitled to. `[Rubric §11, Security]`: the client's id list is treated as a request, never as a grant, and the class doc comment states that intent explicitly (`:11-12`). +- **Walkthrough**: the primary constructor takes the unit of work and the count service (`:17-19`); the class implements `IQueryHandler>>` (`:20`). `HandleAsync` (`:23`) starts with an empty-input short circuit returning an empty dictionary without touching the database (`:27-31`). It then takes the *read* repository (`GetReadRepository`, `:33`) and loads the requested sessions with their `SessionSpeakers` eager-included, `asTracking: false` (`:34-38`), a read-only query with no change-tracker overhead. The authorization projection (`:43-46`) keeps sessions where any `SessionSpeaker` matches the query's `SpeakerId` and is not soft-deleted, and selects just the ids. A second short circuit returns an empty dictionary when nothing survived (`:48-52`). Finally it calls `bookmarkCountService.GetBookmarkCountsForSessionsAsync(authorizedSessionIds, ...)` (`:54-56`) and wraps the returned dictionary in `Result.Success` (`:58`). +- **Why it's built this way**: the batched contract exists so the Speaker Dashboard makes one call instead of one per session; the class doc comment (`:9-16`) names that as the reason, and the interface contract guarantees every requested id is present in the result with zero-bookmark sessions mapping to `0` (`MMCA.ADC.Engagement.Shared/UserSessionBookmarks/IBookmarkCountService.cs:19-20`). Note the explicit `!ss.IsDeleted` test at `:44`: the eager-loaded child collection is filtered in memory here, so the check is written out rather than relying solely on the EF global soft-delete filter ([ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html)). `[Rubric §12, Performance & Scalability]`: two round trips total (one local read, one cross-module call) regardless of session count. +- **Where it's used**: injected into [SpeakersController](group-20-conference-api-grpc.md#speakerscontroller) as `IQueryHandler` (`MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:53`) and invoked from the `bookmarks/counts` endpoint (`:457-459`), which is `[AllowAnonymous]` and served through the `BookmarkCountsCache` output-cache policy (`:450-451`). +- **Caveats / not-in-source**: when the Engagement module is disabled in a host, the interface resolves to `DisabledBookmarkCountService` instead (`MMCA.ADC.Engagement.API/EngagementModule.cs:32`), so this handler's cross-module hop can be satisfied by a stub. The stub's return shape is not read here. ### GetSessionFeedbackHandler > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.GetSessionFeedback` · `MMCA.ADC.Conference.Application/Speakers/UseCases/GetSessionFeedback/GetSessionFeedbackHandler.cs:15` · Level 9 · class - **What it is**: the handler for [GetSessionFeedbackQuery](#getsessionfeedbackquery). It confirms the speaker is assigned to the session, then aggregates that session's answers into average ratings per rating question and raw text lists per open question. -- **Depends on**: [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) (`GetSessionFeedbackHandler.cs:6,16`), [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) (`:5,16`), [Session](group-17-conference-domain.md#session) and [Question](group-17-conference-domain.md#question) (`:2-3`), [SessionFeedbackDTO](group-17-conference-domain.md#sessionfeedbackdto) with [RatingQuestionSummary](group-17-conference-domain.md#ratingquestionsummary) and [TextQuestionResponses](group-17-conference-domain.md#textquestionresponses) (`:4`), [Result](group-01-result-error-handling.md#result) and [Error](group-01-result-error-handling.md#error) (`:7`), and BCL `System.Globalization` for culture-invariant parsing (`:1`). -- **Concept introduced**: **the in-context analytics handler, and defensive parsing of a stringly-typed answer store.** Unlike its bookmark sibling, this handler needs no cross-module call: session questions and answers are Conference-owned, so everything is local. Two mechanisms are worth learning here. The first is the ownership gate (`:33-40`): if no live `SessionSpeaker` matches the query's speaker, the handler returns `Error.Forbidden` with the stable code `Speaker.NotAssigned` plus a message, source, and target, so a speaker cannot read another speaker's feedback by editing the URL. Note the deliberate distinction from [GetSessionBookmarkCountsHandler](#getsessionbookmarkcountshandler): a single-target read *fails* on a mismatch, a batch read *filters*. The second is the answer model: `AnswerValue` is stored as a string regardless of question type, so a `Rating` answer must be parsed back to an integer. The handler uses `int.TryParse` with `NumberStyles.Integer` and `CultureInfo.InvariantCulture` (`:76`) and drops values that do not parse, rather than throwing. Culture-invariance is the load-bearing detail: parsing a stored value with the ambient culture makes the same database return different results on different servers. `[Rubric §11, Security]` (server-verified ownership), `[Rubric §15, Best Practices & Code Quality]` (invariant-culture parsing, no exceptions used for flow control), `[Rubric §12, Performance & Scalability]` (two queries maximum, with the second skipped entirely when there are no answers). +- **Depends on**: [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) (`GetSessionFeedbackHandler.cs:6,16`), [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) (`:5,16`), [Session](group-17-conference-domain.md#session) and [Question](group-17-conference-domain.md#question) (`:2-3`), [SessionFeedbackDTO](group-17-conference-domain.md#sessionfeedbackdto) with [RatingQuestionSummary](group-17-conference-domain.md#ratingquestionsummary) and [TextQuestionResponses](group-17-conference-domain.md#textquestionresponses) (`:4`; all three declared in `MMCA.ADC.Conference.Shared/Speakers/SessionFeedbackDTO.cs:6,22,38`), [Result](group-01-result-error-handling.md#result) and [Error](group-01-result-error-handling.md#error) (`:7`), and BCL `System.Globalization` for culture-invariant parsing (`:1`). +- **Concept introduced**: **the in-context analytics handler, and defensive parsing of a stringly-typed answer store.** Unlike its bookmark sibling, this handler needs no cross-module call: session questions and answers are Conference-owned, so everything is local. Two mechanisms are worth learning here. The first is the ownership gate (`:33-40`): if no live [SessionSpeaker](group-17-conference-domain.md#sessionspeaker) matches the query's speaker, the handler returns `Error.Forbidden` with the stable code `Speaker.NotAssigned` plus a message, source, and target, so a speaker cannot read another speaker's feedback by editing the URL. Note the deliberate distinction from [GetSessionBookmarkCountsHandler](#getsessionbookmarkcountshandler): a single-target read *fails* on a mismatch, a batch read *filters*. The second is the answer model: [SessionQuestionAnswer](group-17-conference-domain.md#sessionquestionanswer) stores `AnswerValue` as a string regardless of question type, so a `Rating` answer must be parsed back to an integer. The handler uses `int.TryParse` with `NumberStyles.Integer` and `CultureInfo.InvariantCulture` (`:76`) and drops values that do not parse, rather than throwing. Culture-invariance is the load-bearing detail: parsing a stored value with the ambient culture makes the same database return different results on different servers. `[Rubric §11, Security]` (server-verified ownership), `[Rubric §15, Best Practices & Code Quality]` (invariant-culture parsing, no exceptions used for flow control), `[Rubric §12, Performance & Scalability]` (two queries maximum, with the second skipped entirely when there are no answers). - **Walkthrough**: the primary constructor takes only the unit of work (`:15-16`). `HandleAsync` (`:19`) takes the [Session](group-17-conference-domain.md#session) repository (`:23`) and loads the session by id with `SessionSpeakers` and `SessionQuestionAnswers` eager-included and `asTracking: false` (`:24-28`), returning `Error.NotFound` sourced and targeted for diagnostics when it is missing (`:29-30`). The ownership gate follows (`:33-40`). With no answers it returns an empty [SessionFeedbackDTO](group-17-conference-domain.md#sessionfeedbackdto) carrying just the session id and title (`:44-53`), avoiding the question query altogether. Otherwise it collects the distinct answered question ids into a `HashSet` (`:56`) and loads only those questions (`:57-62`), building a dictionary lookup (`:63`). The aggregation loop groups answers by question id (`:68`), skips a group whose question was not found (`:70-71`), and branches on `question.QuestionType == "Rating"` (`:73`): the rating branch parses each answer, keeps the parsed values (`:75-79`), and, only if at least one parsed (`:81`), emits a [RatingQuestionSummary](group-17-conference-domain.md#ratingquestionsummary) with `AverageRating` and `ResponseCount` (`:83-89`); every other question type emits a [TextQuestionResponses](group-17-conference-domain.md#textquestionresponses) with all raw answer strings via the collection expression `[.. group.Select(a => a.AnswerValue)]` (`:94-99`). The final DTO is assembled and wrapped in `Result.Success` (`:103-109`). - **Why it's built this way**: the comment at `:42` records that soft-deleted answers are already excluded by the EF global query filter, so the aggregation does not re-filter them (contrast the explicit `!ss.IsDeleted` on the eager-loaded speaker links at `:33`). Loading questions by the answered-id set rather than by session avoids pulling the whole question bank. See [ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html) for the soft-delete model these filters implement. -- **Where it's used**: injected into [SpeakersController](group-20-conference-api-grpc.md#speakerscontroller) (`MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:51`) and invoked from the session-feedback endpoint (`:410-412`). -- **Caveats / not-in-source**: the rating branch is selected by the literal string `"Rating"` (`:73`). Whether `QuestionType` is constrained to a known set anywhere else is not determinable from this file. Both repository calls use `GetRepository` rather than `GetReadRepository` (`:23,57`), though both pass `asTracking: false`, so the reads are untracked either way. +- **Where it's used**: injected into [SpeakersController](group-20-conference-api-grpc.md#speakerscontroller) (`MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:51`) and invoked from the session-feedback endpoint after its self-or-organizer gate (`:418-420`). +- **Caveats / not-in-source**: the rating branch is selected by the literal string `"Rating"` (`:73`); the domain does constrain the column to `["Rating", "Text", "Email"]` (`MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:31`), but this handler shares no constant with it, so the two definitions are coupled only by convention. Both repository calls use `GetRepository` rather than `GetReadRepository` (`:23,57`), though both pass `asTracking: false`, so the reads are untracked either way. ### GetSpeakersByEventFilterHandler @@ -1730,8 +1953,8 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **Concept introduced**: **projection queries and the ID-list filter.** The handler never materializes an entity. It uses `GetProjectedAsync` on the read repository up to three times, each pulling a single scalar column with a `where` clause and `asTracking: false`: speaker ids from the event-speaker join (`:28-31`), session ids for the event (`:33-36`), and speaker ids from the session-speaker join for those sessions (`:44-47`). Projecting rather than loading is what keeps a potentially wide join cheap: the query returns ids, not aggregates. The result is then expressed as an [InlineSpecification](group-03-querying-specifications.md#inlinespecificationtentity-tidentifiertype) over `speakerIds.Contains(s.Id)` (`:52-53`), which EF translates to a SQL `IN`. That indirection is deliberate: because the predicate closes over an in-memory list rather than over a navigation property, the criteria stay translatable on any provider, which is the engine-portability point the class doc comment makes (`:15-17`; see [ADR-055](https://ivanball.github.io/docs/adr/055-repository-and-specification-contract.html) and the multi-engine motivation in [ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html)). `[Rubric §8, Data Architecture]` assesses whether queries stay portable and index-friendly; `[Rubric §4, Domain-Driven Design]`: [Speaker](group-17-conference-domain.md#speaker) relates to events by id across an aggregate boundary, never by owning an `EventId` column. - **Walkthrough**: the primary constructor takes only the unit of work (`:19-20`); the handler's `TResult` is `Result>` (`:21`). `HandleAsync` (`:24`) runs the direct-link projection first (`:28-31`), then the event's session ids (`:33-36`). `sessionSpeakerIds` is initialized to an empty collection (`:38`) and the third query runs only when there is at least one session (`:39`), so an event with no sessions costs two queries, not three. Inside the branch, the session ids are materialized once into an `IReadOnlyList` (`:42`) with an explanatory comment (`:41`): the predicate must close over a stable collection for EF to translate it into `IN` rather than re-enumerating a deferred sequence. The two id sets are then concatenated and de-duplicated into one list (`:50`), and the specification is constructed and returned as a success (`:52-53`). The handler never returns a failure. - **Why it's built this way**: the union is necessary because the two link paths are written by different flows (the Sessionize import writes `EventSpeaker`, organizer session management writes `SessionSpeaker`), a fact recorded in the query's own doc comment (`MMCA.ADC.Conference.Application/Speakers/UseCases/GetSpeakersByEventFilter/GetSpeakersByEventFilterQuery.cs:5-8`). Returning a specification instead of speaker rows lets the caller AND this filter with its own visibility rules and still use the shared paged read path. The class doc comment cites the BR-132 cross-source specification helper as the precedent for the shape (`:15`). -- **Where it's used**: injected into [SpeakersController](group-20-conference-api-grpc.md#speakerscontroller) (`MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:54`); the controller composes the returned specification with the public-visibility specification via [AndSpecification](group-03-querying-specifications.md#andspecificationtentity-tidentifiertype) and passes the combination to the paged query service (`:165-189`). -- **Caveats / not-in-source**: the returned specification embeds a materialized id list, so its size grows with the event's speaker count. No cap is applied in this handler. +- **Where it's used**: injected into [SpeakersController](group-20-conference-api-grpc.md#speakerscontroller) (`MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:54`); on a successful result the controller composes the returned specification with the public-visibility specification through the `And` extension, which builds an [AndSpecification](group-03-querying-specifications.md#andspecificationtentity-tidentifiertype) (`SpeakersController.cs:170-175`; `MMCA.Common/Source/Core/MMCA.Common.Domain/Specifications/SpecificationExtensions.cs:53`), and passes the combination to the paged query service (`:178-189`). +- **Caveats / not-in-source**: the returned specification embeds a materialized id list, so its size grows with the event's speaker count. No cap is applied in this handler. Note also that a failed result at the call site is swallowed: the controller only composes when `filterResult.IsSuccess` (`SpeakersController.cs:170`), so a failure would silently fall back to the public specification alone. This handler has no failure path today, so that branch is unreachable from that call site. ### GetPublicSponsorFilterHandler @@ -1739,22 +1962,22 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **What it is**: the handler for [GetPublicSponsorFilterQuery](#getpublicsponsorfilterquery). It asks the shared visibility helper for the published event ids and returns a `Sponsor.EventId IN (...)` specification built from them. - **Depends on**: [PublicConferenceVisibility](#publicconferencevisibility) from `MMCA.ADC.Conference.Application.Common` (`GetPublicSponsorFilterHandler.cs:1,25`), [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) (`:4,18`), [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork) (`:3,17`), [Specification](group-03-querying-specifications.md#specificationtentity-tidentifiertype) and [InlineSpecification](group-03-querying-specifications.md#inlinespecificationtentity-tidentifiertype) (`:5,30`), [Sponsor](group-17-conference-domain.md#sponsor) (`:2`), and [Result](group-01-result-error-handling.md#result) (`:6`). -- **Concept introduced**: **the shortest public-filter handler, and what a real foreign-key column buys you.** Its speaker and session siblings have to translate a visibility rule into an id list of the entity they are filtering, because those aggregates carry no column the rule can be expressed against. [Sponsor](group-17-conference-domain.md#sponsor) does carry `EventId` (`MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:45`), so the rule collapses to one hop: fetch the published event ids and compare the sponsor's own column against them. The whole handler is six statements' worth of code. Two design points carry over from the siblings anyway. First, the id list comes from [PublicConferenceVisibility](#publicconferencevisibility), not from a local query, so "published" is defined once for sessions, speakers, junctions, and sponsors alike, and closing a leak in that one helper closes it everywhere (`MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:10-14`). Second, the returned criteria contain no navigation join, so they stay translatable on any provider. `[Rubric §11, Security]` assesses whether visibility is centrally defined and server-derived: a caller supplies nothing, and BR-108 lives in exactly one method. `[Rubric §1, SOLID]`: the handler's only job is to shape the helper's output into a specification. `[Rubric §8, Data Architecture]`: the `IN` predicate is engine-portable per [ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html). -- **Walkthrough**: the primary constructor takes only the unit of work (`:16-17`); the class implements `IQueryHandler>>` (`:18`). `HandleAsync` (`:21-23`) awaits `PublicConferenceVisibility.GetPublishedEventIdsAsync(unitOfWork, cancellationToken)` (`:25-27`), which projects `Event.Id` where `IsPublished` with `asTracking: false` and materializes the result once so the predicate closes over a stable collection (`PublicConferenceVisibility.cs:40-46`). It then returns `Result.Success` wrapping an `InlineSpecification(s => publishedEventIds.Contains(s.EventId))` (`:29-30`). There is no failure path and no branching: an empty published set simply yields a specification that matches nothing. -- **Why it's built this way**: routing every public read filter through one helper rather than through per-entity queries is the deliberate anti-leak measure recorded in the helper's own summary (`PublicConferenceVisibility.cs:10-14`), and returning a specification (rather than sponsor rows) lets the controller hand the filter to the shared paged query service and let it AND the filter with the caller's own criteria. -- **Where it's used**: injected into [SponsorsController](group-20-conference-api-grpc.md#sponsorscontroller) (`MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:42`) and called from its private `BuildPublicSponsorSpecificationAsync` helper (`:60-70`), which short-circuits to `null` for privileged readers (Organizer or ContentEditor, `:50,63-64`) and otherwise passes the specification into the list (`:84`), paged (`:119`), and lookup (`:143`) reads. Because the specification is ANDed by the query service rather than substituted, scoping a request to an unpublished event yields an empty page for a non-privileged caller instead of leaking the roster (`:94-98`). -- **Caveats / not-in-source**: when the handler returns a failure the controller falls back to `null`, meaning no filter (`:69`). The handler has no failure path today, so that branch is unreachable from this call site; whether it is defensive by intent is not determinable from source. +- **Concept introduced**: **the shortest public-filter handler, and what a real foreign-key column buys you.** Its speaker and session siblings have to translate a visibility rule into an id list of the entity they are filtering, because those aggregates carry no column the rule can be expressed against. [Sponsor](group-17-conference-domain.md#sponsor) does carry `EventId` (`MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:45`), so the rule collapses to one hop: fetch the published event ids and compare the sponsor's own column against them. The whole handler is two statements' worth of code. Two design points carry over from the siblings anyway. First, the id list comes from [PublicConferenceVisibility](#publicconferencevisibility), not from a local query, so "published" (BR-108) is defined in exactly one method for every public conference read, and closing a leak there closes it everywhere (`MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:10-15,36-48`). Second, the returned criteria contain no navigation join, so they stay translatable on any provider. `[Rubric §11, Security]` assesses whether visibility is centrally defined and server-derived: a caller supplies nothing, and the published-event rule lives in one place. `[Rubric §1, SOLID]`: the handler's only job is to shape the helper's output into a specification. `[Rubric §8, Data Architecture]`: the `IN` predicate is engine-portable per [ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html). +- **Walkthrough**: the primary constructor takes only the unit of work (`:16-17`); the class implements `IQueryHandler>>` (`:18`). `HandleAsync` (`:21-23`) awaits `PublicConferenceVisibility.GetPublishedEventIdsAsync(unitOfWork, cancellationToken)` (`:25-27`), which projects `Event.Id` where `IsPublished` with `asTracking: false` (`PublicConferenceVisibility.cs:42-44`) and materializes the result once so the predicate closes over a stable collection (`:46-47`). It then returns `Result.Success` wrapping an `InlineSpecification(s => publishedEventIds.Contains(s.EventId))` (`:29-30`). There is no failure path and no branching: an empty published set simply yields a specification that matches nothing. +- **Why it's built this way**: routing every public read filter through one helper rather than through per-entity queries is the deliberate anti-leak measure recorded in the helper's own summary (`PublicConferenceVisibility.cs:10-15`), and returning a specification (rather than sponsor rows) lets the controller hand the filter to the shared paged query service and let it AND the filter with the caller's own criteria. +- **Where it's used**: injected into [SponsorsController](group-20-conference-api-grpc.md#sponsorscontroller) (`MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:42`) and called from its private `BuildPublicSponsorSpecificationAsync` helper (`:60-70`), which short-circuits to `null` for privileged readers (Organizer or ContentEditor, `:50,53-54,63-64`) and otherwise supplies the specification to all four public reads: the list (`:84`), the paged list (`:119`), the lookup (`:143`), and get-by-id (`:176`). Because the specification is ANDed by the query service rather than substituted, scoping a request to an unpublished event yields an empty page for a non-privileged caller instead of leaking the roster (`:94-98`), and a single sponsor of an unpublished event is a 404 rather than a redacted record, so a guessed id cannot confirm that a sponsorship was sold (`:158-160`). +- **Caveats / not-in-source**: when the handler returns a failure the controller falls back to `null`, meaning no filter (`:69`). The handler has no failure path today, so that branch is unreachable from this call site; whether it is defensive by intent is not determinable from source. The lookup path takes a different route from the other three: it passes `specification.Criteria` as a raw `where` predicate rather than the specification object (`:149`), so any non-criteria part of a future specification would be dropped there. ### SponsorEventIdRules > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sponsors.Validation` · `MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:98` · Level 0 · class - **What it is**: a one-rule reusable validator that asserts a sponsor request actually names an event. It is generic over the request type, so the same rule object binds to any record that has an event id. -- **Depends on**: `AbstractValidator` and `Expression>` from FluentValidation and the BCL (`SponsorValidationRules.cs:1-2,99,101`), plus the `EventIdentifierType` alias (`:101`, aliased to `int` in `MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7`, see [primer](00-primer.md#2-architectural-styles-this-codebase-commits-to)). No first-party types. +- **Depends on**: `AbstractValidator` and `Expression>` from FluentValidation and the BCL (`MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:1-2,99,101`), plus the `EventIdentifierType` alias (`:101`, aliased to `int` in `MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8`, see [primer](00-primer.md#2-architectural-styles-this-codebase-commits-to)). No first-party types. - **Concept introduced**: **the parameterized rule set.** This file is the first place in the sponsors slice where validation is packaged rather than written inline, so learn the shape here. A rule set is a `sealed class Foo : AbstractValidator` whose constructor takes an `Expression> selector` (`:101`) and does nothing but call `RuleFor(selector)` with the field's contract attached. It is generic because the *contract* belongs to the concept ("a sponsor's event id"), not to any one request record: [`SponsorCreateRequest`](#sponsorcreaterequest) is a different type from [`SponsorUpdateRequest`](#sponsorupdaterequest), yet both can reuse the identical object by supplying their own property selector. Consumers fold it in with FluentValidation's `Include(...)`, which merges the included validator's rules into the host validator as if they had been typed there. The payoff is that a constraint has exactly one definition and many bindings, and a change to it cannot land on the create path while missing the update path. `[Rubric §24, Forms/Validation/UX Safety]` assesses whether input constraints are single-sourced and applied consistently at every entry point: this whole file exists to make that true for sponsors. `[Rubric §1, SOLID]`: each rule set has one reason to change, the contract of one field. -- **Walkthrough**: the class is `sealed` and derives directly from `AbstractValidator` (`:98-99`), unlike the seven length rule sets below it, which derive from the shared MMCA.Common bases. The whole body is an expression-bodied constructor (`:101-103`): `RuleFor(selector).NotEmpty().WithMessage("You must specify an Event for the Sponsor").WithErrorCode("Sponsor.EventId.Required")`. Two details matter. First, because `EventIdentifierType` is `int`, FluentValidation's `NotEmpty()` rejects the type default, so an omitted or zeroed event id fails rather than binding silently to `0`. Second, `WithErrorCode` is contract, not decoration: the string `Sponsor.EventId.Required` is what an API client or a test keys on, while the message is the human-facing half. +- **Walkthrough**: the class is `sealed` and derives directly from `AbstractValidator` (`:98-99`), unlike the seven length rule sets above it, which derive from the shared MMCA.Common bases. The whole body is an expression-bodied constructor (`:101-103`): `RuleFor(selector).NotEmpty().WithMessage("You must specify an Event for the Sponsor").WithErrorCode("Sponsor.EventId.Required")`. Two details matter. First, because `EventIdentifierType` is `int`, FluentValidation's `NotEmpty()` rejects the type default, so an omitted or zeroed event id fails rather than binding silently to `0`. Second, `WithErrorCode` is contract, not decoration: the string `Sponsor.EventId.Required` is what an API client or a test keys on, while the message is the human-facing half. - **Why it's built this way**: the XML doc states the business reason directly (`:94-96`), that sponsors are sold per event, so an unscoped sponsor has nowhere to appear. This rule is also the only enforcement of that fact at the application boundary: the [`Sponsor`](group-17-conference-domain.md#sponsor) aggregate's `Create` composes name, logo URL, and booth number invariants but does not re-check the event id (`MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:120-122`), because a zero-valued foreign key would already fail at the database. -- **Where it's used**: included once, by [`SponsorCreateRequestValidator`](#sponsorcreaterequestvalidator) (`MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestValidator.cs:12`). [`SponsorUpdateRequestValidator`](#sponsorupdaterequestvalidator) deliberately does not include it, because [`SponsorUpdateRequest`](#sponsorupdaterequest) carries no event id at all: its `` records that moving a sponsor between events is a create plus a delete, so a mistyped id cannot silently relocate bought placement (`MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/SponsorUpdateRequest.cs:7-10`). +- **Where it's used**: included once, by [`SponsorCreateRequestValidator`](#sponsorcreaterequestvalidator) (`MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestValidator.cs:12`). [`SponsorUpdateRequestValidator`](#sponsorupdaterequestvalidator) deliberately does not include it (`MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/SponsorUpdateRequestValidator.cs:11-18` includes eight rule sets and not this one), because [`SponsorUpdateRequest`](#sponsorupdaterequest) carries no event id at all: its `` records that moving a sponsor between events is a create plus a delete, so a mistyped id cannot silently relocate bought placement (`MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/SponsorUpdateRequest.cs:7-10`). ### SponsorSortRules @@ -1766,16 +1989,17 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **Walkthrough**: `sealed class SponsorSortRules : AbstractValidator` (`:110-111`) with an expression-bodied constructor (`:113-115`) calling `RuleFor(selector).GreaterThanOrEqualTo(0)` with the message "Sort must be greater than or equal to 0" and the stable error code `Sponsor.Sort.Negative`. - **Where it's used**: included by both sponsor request validators, [`SponsorCreateRequestValidator`](#sponsorcreaterequestvalidator) (`SponsorCreateRequestValidator.cs:13`) and [`SponsorUpdateRequestValidator`](#sponsorupdaterequestvalidator) (`SponsorUpdateRequestValidator.cs:12`), which is the reuse this file is built for. -### ConferenceCategoryUpdateRequest +### ActivityUpdateRequest -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.UseCases.Update` · `MMCA.ADC.Conference.Application/Categories/UseCases/Update/ConferenceCategoryUpdateRequest.cs:6` · Level 1 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Activities.UseCases.Update` · `MMCA.ADC.Conference.Application/Activities/UseCases/Update/ActivityUpdateRequest.cs:10` · Level 1 · record -- **What it is**: the request DTO a client PUTs to update an existing conference [`Category`](group-17-conference-domain.md#category). It carries the three editable fields plus the concurrency token the client last saw. -- **Depends on**: [`IConcurrencyAware`](group-12-api-hosting-mapping.md#iconcurrencyaware) from `MMCA.Common.Shared.DTOs` (`ConferenceCategoryUpdateRequest.cs:1,6`). Nothing else: it is a pure payload record with no domain types in its members. -- **Concept introduced**: **the concurrency-aware update request.** Every update request in this module is a `record class` implementing [`IConcurrencyAware`](group-12-api-hosting-mapping.md#iconcurrencyaware), which contributes exactly one member, a nullable `byte[]? RowVersion` (`:9`). That token is the client's proof of what it read. The handler stamps it as the entity's *original* row version before saving, so EF Core compares it against the stored value on `UPDATE` and raises a concurrency exception (surfaced as HTTP 409) when someone else has written the row in the meantime. Without the round trip, a stale form silently overwrites a newer edit. Because the property is nullable, omitting it opts out of the check rather than failing closed, which is the framework's deliberate trade-off ([ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html)). `[Rubric §8, Data Architecture]` assesses how concurrent writes to one row are reconciled: this codebase chooses optimistic concurrency with an explicit client token over pessimistic locking or last-write-wins. `[Rubric §9, API & Contract Design]`: the token is part of the wire contract, so the round trip is visible to clients rather than hidden server state. -- **Walkthrough**: `RowVersion` (`:9`) is `init`-only and nullable. `Title` (`:12`) is `required string`, the one field the validator guards. `Sort` (`:15`) is the display order, defaulting to zero. `Type` (`:18`) is the optional discriminator string, documented as "session" or "speaker" (`:17`). All four members are `init`-only, so the request is immutable once bound. -- **Why it's built this way**: `required` on `Title` means the request cannot be constructed without a title, pushing the most basic contract violation to the model binder instead of the validator. Making the PUT a full replacement (rather than a patch) is what lets the handler pass every field straight through to the aggregate without distinguishing "not supplied" from "cleared". -- **Where it's used**: bound from the body by [`ConferenceCategoriesController`](group-20-conference-api-grpc.md#conferencecategoriescontroller) on `PUT {id}` (`MMCA.ADC.Conference.API/Controllers/ConferenceCategoriesController.cs:103-107`), wrapped in [`UpdateConferenceCategoryCommand`](#updateconferencecategorycommand) (`:110`), validated by [`ConferenceCategoryUpdateRequestValidator`](#conferencecategoryupdaterequestvalidator), and consumed field by field by [`UpdateConferenceCategoryHandler`](#updateconferencecategoryhandler). +- **What it is**: the body a client PUTs to update an existing conference [`Activity`](group-17-conference-domain.md#activity), the non-session items on the agenda (receptions, breaks, after-parties). It carries every editable field plus the concurrency token the client last read. +- **Depends on**: [`IConcurrencyAware`](group-12-api-hosting-mapping.md#iconcurrencyaware) from `MMCA.Common.Shared.DTOs` (`MMCA.ADC.Conference.Application/Activities/UseCases/Update/ActivityUpdateRequest.cs:1,10`). Nothing else: `DateTime`, `string`, `int`, and `byte[]` are BCL. There is not a single domain type in its member list, which is the point of a request DTO. +- **Concept introduced**: none new. The concurrency-aware update request is taught at [`ConferenceCategoryUpdateRequest`](#conferencecategoryupdaterequest): a `record class` implementing [`IConcurrencyAware`](group-12-api-hosting-mapping.md#iconcurrencyaware), contributing one nullable `byte[]? RowVersion` (`:13`) that the handler stamps as the entity's original row version so a competing write surfaces as a 409 rather than last-write-wins ([ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html)). What is distinctive here is **the field that is deliberately missing**. The record has no `EventId`, and the `` says why: moving an activity between events is a create plus a delete, so a mistyped `EventId` cannot silently relocate a published social event (`:6-9`). That is the same rule [`SponsorUpdateRequest`](#sponsorupdaterequest) applies to bought sponsor placement, so the module has one consistent answer to "can a PUT reparent an aggregate?". `[Rubric §9, API & Contract Design]` assesses whether a contract makes the safe thing the only expressible thing: an operation that is dangerous is not merely validated against, it is absent from the type. `[Rubric §4, DDD]`: the owning event is part of the activity's identity within the conference, not an ordinary attribute, so it is not editable through the attribute-editing endpoint. +- **Walkthrough**: `RowVersion` (`:13`) is nullable and `init`-only, carrying `` from the interface. `Name` (`:16`) is the one `required` member, so the model binder rejects a body without it before any validator runs. `Description` (`:19`) is optional. `StartTime` and `EndTime` (`:22`, `:25`) are plain `DateTime` values documented as event-local (`:21`, `:24`). `VenueName` (`:28`) is optional and documented such that empty means the main conference venue, with `VenueAddress` (`:31`) and `VenueUrl` (`:34`) alongside it for off-site items. `SortOrder` (`:37`) breaks ties between activities that start at the same time (`:36`). Every member is `init`-only, so the bound request is immutable for the rest of the pipeline. +- **Why it's built this way**: making the PUT a full replacement rather than a patch means the handler can pass every field straight through to the aggregate without distinguishing "not supplied" from "cleared" (`MMCA.ADC.Conference.Application/Activities/UseCases/Update/UpdateActivityHandler.cs:34-42`). Marking only `Name` as `required` pushes the single non-negotiable field to bind time and leaves the graded constraints (lengths, the time ordering) to [`ActivityUpdateRequestValidator`](#activityupdaterequestvalidator), which can produce a readable message per field. +- **Where it's used**: bound from the body by [`ActivitiesController`](group-20-conference-api-grpc.md#activitiescontroller) on `PUT {id}` (`MMCA.ADC.Conference.API/Controllers/ActivitiesController.cs:223-227`), wrapped into [`UpdateActivityCommand`](#updateactivitycommand) (`:231`), validated by [`ActivityUpdateRequestValidator`](#activityupdaterequestvalidator), and consumed field by field by [`UpdateActivityHandler`](#updateactivityhandler). +- **Caveats / not-in-source**: unlike the create path, which has an [`ActivityCreateRequestMapper`](#activitycreaterequestmapper), there is no `ActivityUpdateRequestMapper`; the update handler reads the eight members positionally instead. Nothing in the record pins a `DateTimeKind`, so whether the wire value arrives as UTC or unspecified local time is not determinable from this file: the doc comments only say "event-local" (`:21`, `:24`). ### SponsorBoothNumberRules @@ -1783,10 +2007,10 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **What it is**: the reusable length rule for a sponsor's optional expo booth number. - **Depends on**: [`OptionalStringRules`](group-06-validation.md#optionalstringrulest) from `MMCA.Common.Application.Validation` (`SponsorValidationRules.cs:4,87`) and [`SponsorInvariants`](group-17-conference-domain.md#sponsorinvariants) for the constant `BoothNumberMaxLength` (`:3,90`, value 50 at `MMCA.ADC.Conference.Domain/Sponsors/SponsorInvariants.cs:31`). -- **Concept introduced**: **the three-argument subclass, and why a length constant lives in the domain.** The seven length rule sets in this file (this one, description, LinkedIn URL, logo URL, name, Twitter handle, website URL) are each a two-line `sealed class` whose constructor forwards to a shared MMCA.Common base with three arguments: the property selector, a human-facing field label, and a max length. The base does the actual work, `RuleFor(selector).MaximumLength(maxLength)` with a generated message (`MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:25-29`). Two design points are worth internalizing. First, the subclass exists purely to *name* the pairing of a field with its constant, so callers write `new SponsorBoothNumberRules(p => p.BoothNumber)` and cannot accidentally bind the booth-number field to the description's length. Second, the constant is imported from the Domain layer, not declared here: `SponsorInvariants`'s own doc comment states that its length constants are referenced by both domain validation and EF configuration to keep constraints in sync (`SponsorInvariants.cs:6-9`), and the EF entity configuration does exactly that (`MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SponsorConfiguration.cs:56`). One number therefore governs the validator's message, the domain guard where one exists, and the column width, so a 51-character booth number is rejected with a readable error instead of truncating or throwing at the database. That dependency on a Domain constant is also why these seven sit at Level 7 while the two inline rule sets above sit at Level 0. `[Rubric §8, Data Architecture]` assesses whether storage constraints and application constraints agree; `[Rubric §16, Maintainability]`: widening a column is a one-constant change that propagates to every layer that cares. +- **Concept introduced**: **the three-argument subclass, and why a length constant lives in the domain.** The seven length rule sets in this file (this one, description, LinkedIn URL, logo URL, name, Twitter handle, website URL) are each a two-line `sealed class` whose constructor forwards to a shared MMCA.Common base with three arguments: the property selector, a human-facing field label, and a max length. The base does the actual work, `RuleFor(selector).MaximumLength(maxLength)` with a generated message (`MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:25-29`). Two design points are worth internalizing. First, the subclass exists purely to *name* the pairing of a field with its constant, so callers write `new SponsorBoothNumberRules(p => p.BoothNumber)` and cannot accidentally bind the booth-number field to the description's length. Second, the constant is imported from the Domain layer, not declared here: `SponsorInvariants`'s own doc comment states that its length constants are referenced by both domain validation and EF configuration to keep constraints in sync (`SponsorInvariants.cs:6-9`), and the EF entity configuration does exactly that (`MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SponsorConfiguration.cs:56`). One number therefore governs the validator's message, the domain guard where one exists, and the column width, so a 51-character booth number is rejected with a readable error instead of truncating or throwing at the database. That dependency on a Domain constant is also why these seven sit at Level 7 while the two inline rule sets below them sit at Level 0. `[Rubric §8, Data Architecture]` assesses whether storage constraints and application constraints agree; `[Rubric §16, Maintainability]`: widening a column is a one-constant change that propagates to every layer that cares. - **Walkthrough**: `sealed class SponsorBoothNumberRules : OptionalStringRules` (`:86-87`); the constructor takes `Expression> selector` (`:89`) and calls `: base(selector, "Booth Number", SponsorInvariants.BoothNumberMaxLength)` (`:90`). There is no body. The selector type is nullable, which is the whole difference between the optional base and the required one: a null booth number passes. - **Why it's built this way**: the field is optional in the domain too. [`SponsorInvariants`](group-17-conference-domain.md#sponsorinvariants)`.EnsureBoothNumberIsValid` short-circuits to success on a null or empty value and otherwise applies the same constant (`SponsorInvariants.cs:63-66`), and its doc comment records the deliberate rule that a booth number is accepted even when the sponsor is not flagged as an exhibitor, because the flag drives display and does not reject stored data (`:56-59`). -- **Where it's used**: included by [`SponsorCreateRequestValidator`](#sponsorcreaterequestvalidator) (`SponsorCreateRequestValidator.cs:19`) and [`SponsorUpdateRequestValidator`](#sponsorupdaterequestvalidator) (`SponsorUpdateRequestValidator.cs:18`). +- **Where it's used**: included by [`SponsorCreateRequestValidator`](#sponsorcreaterequestvalidator) (`SponsorCreateRequestValidator.cs:19`) and [`SponsorUpdateRequestValidator`](#sponsorupdaterequestvalidator) (`SponsorUpdateRequestValidator.cs:18`); re-checked in the aggregate through `SponsorInvariants.EnsureBoothNumberIsValid` on both `Create` and `Update` (`MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:122,168`). ### SponsorDescriptionRules @@ -1807,7 +2031,7 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **Depends on**: [`OptionalStringRules`](group-06-validation.md#optionalstringrulest) (`SponsorValidationRules.cs:4,63`) and [`SponsorInvariants`](group-17-conference-domain.md#sponsorinvariants)`.LinkedInUrlMaxLength` (`:66`, value 2000 at `SponsorInvariants.cs:25`). - **Concept introduced**: none new; see [`SponsorBoothNumberRules`](#sponsorboothnumberrulest). The label is "LinkedIn URL" (`:66`). - **Walkthrough**: `sealed class SponsorLinkedInUrlRules : OptionalStringRules` (`:62-63`), one forwarding constructor (`:65-66`). -- **Caveats / not-in-source**: length only. Nothing in this rule set checks that the value is a well-formed URL or that it points at linkedin.com, and there is no matching domain invariant. Whether a client-side control constrains the input is not determinable from this file. +- **Caveats / not-in-source**: length only. Nothing in this rule set checks that the value is a well-formed URL or that it points at linkedin.com, and there is no matching domain invariant (`SponsorInvariants.cs:39-66`). Whether a client-side control constrains the input is not determinable from this file. - **Where it's used**: included by [`SponsorCreateRequestValidator`](#sponsorcreaterequestvalidator) (`SponsorCreateRequestValidator.cs:17`) and [`SponsorUpdateRequestValidator`](#sponsorupdaterequestvalidator) (`SponsorUpdateRequestValidator.cs:16`). ### SponsorLogoUrlRules @@ -1826,7 +2050,7 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **What it is**: the reusable rule set for the sponsor's display name: the one sponsor string that is mandatory as well as bounded. - **Depends on**: [`RequiredStringRules`](group-06-validation.md#requiredstringrulest) from `MMCA.Common.Application.Validation` (`SponsorValidationRules.cs:4,14`) and [`SponsorInvariants`](group-17-conference-domain.md#sponsorinvariants)`.NameMaxLength` (`:17`, value 200 at `SponsorInvariants.cs:13`). -- **Concept introduced**: **required versus optional, chosen by base class.** This is the one rule set in the file that derives from [`RequiredStringRules`](group-06-validation.md#requiredstringrulest) rather than [`OptionalStringRules`](group-06-validation.md#optionalstringrulest), and that single choice is the whole difference in behavior. The required base chains `NotEmpty()` ahead of `MaximumLength(...)` and takes a non-nullable `Expression>` selector (`CommonValidationRules.cs:13-18`); the optional base takes a nullable selector and declares only the length rule (`:25-29`). So "is this field mandatory?" is answered once, by which base you extend, and the compiler helps: binding a `string?` property to this rule set will not compile. `[Rubric §1, SOLID]`: two small bases, each with one responsibility, compose into every field contract in the module. `[Rubric §24, Forms/Validation/UX Safety]`: mandatory-ness is declared in one place per field rather than restated per request record. +- **Concept introduced**: **required versus optional, chosen by base class.** This is the one rule set in the file that derives from [`RequiredStringRules`](group-06-validation.md#requiredstringrulest) rather than [`OptionalStringRules`](group-06-validation.md#optionalstringrulest), and that single choice is the whole difference in behavior. The required base chains `NotEmpty()` ahead of `MaximumLength(...)` and takes a non-nullable `Expression>` selector (`MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:13-18`); the optional base takes a nullable selector and declares only the length rule (`:25-29`). So "is this field mandatory?" is answered once, by which base you extend, and the compiler helps: binding a `string?` property to this rule set will not compile. `[Rubric §1, SOLID]`: two small bases, each with one responsibility, compose into every field contract in the module. `[Rubric §24, Forms/Validation/UX Safety]`: mandatory-ness is declared in one place per field rather than restated per request record. - **Walkthrough**: `sealed class SponsorNameRules : RequiredStringRules` (`:13-14`); the constructor takes `Expression> selector` (`:16`) and forwards `(selector, "Sponsor Name", SponsorInvariants.NameMaxLength)` (`:17`). The base produces two messages: "You must enter a Sponsor Name" and "Sponsor Name cannot be longer than 200 characters" (`CommonValidationRules.cs:17-18`). - **Why it's built this way**: validation here is the fast, message-friendly first pass, not the authority. The [`Sponsor`](group-17-conference-domain.md#sponsor) aggregate re-checks the same rule through `SponsorInvariants.EnsureNameIsValid` in both `Create` and `Update` (`MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:120,166`), which returns [`Result`](group-01-result-error-handling.md#result) errors carrying the stable codes `Sponsor.Name.Empty` and `Sponsor.Name.TooLong` (`SponsorInvariants.cs:41-42`). A caller that bypasses the request pipeline still cannot create a nameless sponsor. - **Where it's used**: included by [`SponsorCreateRequestValidator`](#sponsorcreaterequestvalidator) (`SponsorCreateRequestValidator.cs:11`) and [`SponsorUpdateRequestValidator`](#sponsorupdaterequestvalidator) (`SponsorUpdateRequestValidator.cs:11`). @@ -1853,383 +2077,401 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **Caveats / not-in-source**: length only, with no domain counterpart, exactly as for the LinkedIn field. The rendered sponsor link is therefore whatever an organizer typed, so the escaping burden sits with the UI layer. - **Where it's used**: included by [`SponsorCreateRequestValidator`](#sponsorcreaterequestvalidator) (`SponsorCreateRequestValidator.cs:16`) and [`SponsorUpdateRequestValidator`](#sponsorupdaterequestvalidator) (`SponsorUpdateRequestValidator.cs:15`). -### UpdateConferenceCategoryCommand +### ActivityUpdateRequestValidator -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.UseCases.Update` · `MMCA.ADC.Conference.Application/Categories/UseCases/Update/UpdateConferenceCategoryCommand.cs:9` · Level 7 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Activities.UseCases.Update` · `MMCA.ADC.Conference.Application/Activities/UseCases/Update/ActivityUpdateRequestValidator.cs:7` · Level 8 · class -- **What it is**: the write intent for updating a conference [`Category`](group-17-conference-domain.md#category). It pairs the target `Id` with the [`ConferenceCategoryUpdateRequest`](#conferencecategoryupdaterequest) payload and opts the operation into cache eviction. -- **Depends on**: [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) and [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) from `MMCA.Common.Application.UseCases` (`UpdateConferenceCategoryCommand.cs:2,9`), the [`ConferenceCategoryUpdateRequest`](#conferencecategoryupdaterequest) it wraps (`:9`), and the [`Category`](group-17-conference-domain.md#category) type used only for its `FullName` in the cache prefix (`:1,12`). `ConferenceCategoryIdentifierType` is the module identifier alias. -- **Concept introduced**: **the id-plus-request command, and validation by delegation.** A create path can let the request record double as the command, because the identity is inside the body. An update cannot: the target id arrives on the route and the payload arrives in the body, so the command exists to marry the two into one object the pipeline can dispatch. [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) is the framework contract that makes this shape legible to the decorators: it exposes the wrapped request, so the [`ValidatingCommandDecorator`](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult) can reach the validator registered against the *request* type rather than against the command. That is why [`ConferenceCategoryUpdateRequestValidator`](#conferencecategoryupdaterequestvalidator) validates `ConferenceCategoryUpdateRequest` and no `UpdateConferenceCategoryCommandValidator` file exists to keep in sync. The second marker, [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating), opts the command into the caching decorator so a successful update evicts the category read cache. `[Rubric §6, CQRS & Event-Driven]` assesses whether writes are explicit intents flowing through a uniform pipeline: both cross-cutting behaviors attach declaratively through marker interfaces, with no wiring inside the handler ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). -- **Walkthrough**: two positional parameters, `Id` and `Request`, with both interfaces implemented on the same declaration line (`:9`). `CachePrefix` (`:12`) is an expression-bodied property returning `$"{typeof(Category).FullName}:"`, the key namespace the [`CachingCommandDecorator`](group-05-cqrs-pipeline.md#cachingcommanddecoratortcommand-tresult) wipes after a successful handle. There is no other member; the positional `Request` parameter satisfies the interface property with no extra code. -- **Why it's built this way**: deriving the cache prefix from `typeof(Category).FullName` rather than a literal string keeps the writer (this command) and the reader (the category query cache) agreed on one key namespace that a rename cannot desynchronize. -- **Where it's used**: constructed by [`ConferenceCategoriesController`](group-20-conference-api-grpc.md#conferencecategoriescontroller) from the route id and body (`MMCA.ADC.Conference.API/Controllers/ConferenceCategoriesController.cs:109-111`) and handled by [`UpdateConferenceCategoryHandler`](#updateconferencecategoryhandler). +- **What it is**: the FluentValidation validator for [`ActivityUpdateRequest`](#activityupdaterequest). It owns no rules of its own; it is a seven-line list of `Include(...)` calls that assembles the activity field rule sets. +- **Depends on**: `AbstractValidator` from FluentValidation (`MMCA.ADC.Conference.Application/Activities/UseCases/Update/ActivityUpdateRequestValidator.cs:1,7`) and the reusable activity rule sets in `MMCA.ADC.Conference.Application.Activities.Validation` (`:2`): [`ActivityNameRules`](#activitynamerulest) (`:11`), [`ActivityTimeRangeRules`](#activitytimerangerulest) (`:12`), [`ActivitySortOrderRules`](#activitysortorderrulest) (`:13`), [`ActivityDescriptionRules`](#activitydescriptionrulest) (`:14`), [`ActivityVenueNameRules`](#activityvenuenamerulest) (`:15`), [`ActivityVenueAddressRules`](#activityvenueaddressrulest) (`:16`), and [`ActivityVenueUrlRules`](#activityvenueurlrulest) (`:17`). +- **Concept introduced**: **the composed validator, and the create/update rule delta.** The parameterized rule set taught at [`SponsorEventIdRules`](#sponsoreventidrulest) only pays off if request validators are assembled from those parts rather than hand-written, and this class is the assembly step: `Include(...)` folds an included validator's rules into this one, and because each rule set is generic the same object serves the create and update records with different property selectors. The instructive part is what differs between the two lists. [`ActivityCreateRequestValidator`](#activitycreaterequestvalidator) includes eight rule sets, one of which is `ActivityEventIdRules` (`MMCA.ADC.Conference.Application/Activities/UseCases/Create/ActivityCreateRequestValidator.cs:12`); this validator includes seven and omits that one, because there is no `EventId` on the update record to validate. The delta between the two validators is therefore exactly the delta between the two contracts, which is what you want when auditing "does the update path enforce everything the create path does?". `[Rubric §24, Forms/Validation/UX Safety]` assesses whether every write entry point applies the same field constraints: here the shared set is literally shared objects, and the single difference is structural rather than an oversight. `[Rubric §5, Vertical Slice]`: the validator lives in the `UseCases/Update` folder beside the request, command, and handler it serves, not in a module-wide validators bucket. +- **Walkthrough**: `sealed class ActivityUpdateRequestValidator : AbstractValidator` (`:7`) with a parameterless constructor (`:9-18`) containing seven `Include(new XRules(p => p.Field))` statements. Two are not plain length rules. `ActivityTimeRangeRules` (`:12`) takes **two** selectors, start and end, and registers three rules: `NotEmpty` on each with codes `Activity.StartTime.Required` and `Activity.EndTime.Required`, then a cross-field `Must` that compiles the start selector once and asserts `endTime >= startTimeFunc(instance)` with code `Activity.EndTime.BeforeStart` (`MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:87,90-104`). `ActivitySortOrderRules` (`:13`) is the integer floor rule, `GreaterThanOrEqualTo(0)` with code `Activity.SortOrder.Negative` (`ActivityValidationRules.cs:111,114-116`). The remaining five are the required/optional string bases already taught in the sponsor rule sets. +- **Why it's built this way**: cross-field ordering (end after start) cannot be expressed by a per-property rule set, so it is packaged as a two-selector rule set instead of being written inline here. That keeps this class purely declarative: nothing in it can drift from the create path except by adding or removing a line, which is visible in review. +- **Where it's used**: never constructed by hand. `ScanModuleApplicationServices()` calls FluentValidation's `AddValidatorsFromAssemblyContaining()` (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:140,190`, invoked at `MMCA.ADC.Conference.Application/DependencyInjection.cs:125`), so this validator is registered as `IValidator` and picked up automatically. Because [`UpdateActivityCommand`](#updateactivitycommand) implements `ICommandWithRequest`, the framework wires a [`CommandRequestValidator`](group-06-validation.md#commandrequestvalidatortcommand-trequest) that delegates to it (`MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/ICommandWithRequest.cs:5-11`), and [`ValidatingCommandDecorator`](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult) runs it before the handler. -### ConferenceCategoryUpdateRequestValidator +### UpdateActivityCommand -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.UseCases.Update` · `MMCA.ADC.Conference.Application/Categories/UseCases/Update/ConferenceCategoryUpdateRequestValidator.cs:7` · Level 8 · class +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Activities.UseCases.Update` · `MMCA.ADC.Conference.Application/Activities/UseCases/Update/UpdateActivityCommand.cs:9` · Level 9 · record -- **What it is**: the FluentValidation validator for [`ConferenceCategoryUpdateRequest`](#conferencecategoryupdaterequest), run by the pipeline before the update handler executes. -- **Depends on**: `AbstractValidator` (FluentValidation, `ConferenceCategoryUpdateRequestValidator.cs:1,7`), [`ConferenceCategoryUpdateRequest`](#conferencecategoryupdaterequest), and the shared [`ConferenceCategoryTitleRules`](#conferencecategorytitlerulest) rule set from `MMCA.ADC.Conference.Application.Categories.Validation` (`:2,10`). -- **Concept introduced**: none new; this is the `Include` composition taught for the sponsor rule sets above, in its smallest possible form. The entire class body is one expression-bodied constructor (`:9-10`) folding in a single parameterized rule set: `Include(new ConferenceCategoryTitleRules(p => p.Title))`. The same rule object is included by the create-side category validator against a different request type, which is the whole point: the title contract is declared once and every entry path inherits it, complete with the stable error code `Category.Title.Required` that the rule set attaches (`MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:18`). `[Rubric §24, Forms/Validation/UX Safety]` assesses whether input constraints are single-sourced and consistently applied; `[Rubric §1, SOLID]`: this validator's only job is composition, so a title-rule change never has to be found in two files. -- **Walkthrough**: `sealed class ConferenceCategoryUpdateRequestValidator : AbstractValidator` (`:7`); the constructor (`:9-10`) is the single `Include`. `RowVersion`, `Sort`, and `Type` carry no rules here: a null concurrency token is a legitimate "skip the check" signal rather than an error, and the other two have no field-level business constraint. -- **Where it's used**: discovered by assembly scanning and invoked by the [`ValidatingCommandDecorator`](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult) ahead of [`UpdateConferenceCategoryHandler`](#updateconferencecategoryhandler), reached through [`UpdateConferenceCategoryCommand`](#updateconferencecategorycommand)'s [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) implementation. +- **What it is**: the CQRS command that carries an activity id plus its update payload to the handler. It is a two-parameter positional record, and it also declares that a successful run should evict the activity query cache. +- **Depends on**: [`ActivityUpdateRequest`](#activityupdaterequest) (`MMCA.ADC.Conference.Application/Activities/UseCases/Update/UpdateActivityCommand.cs:9`), the `ActivityIdentifierType` alias (`:9`, aliased to `int` at `MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:5`), the [`Activity`](group-17-conference-domain.md#activity) entity type, used only as a `typeof` argument (`:1,12`), and two MMCA.Common marker interfaces, [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) and [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`:2,9`). +- **Concept introduced**: **markers as pipeline configuration.** This record has no logic, yet the two interfaces it implements change what the decorator pipeline does around it, which is the pattern to internalize. `ICommandWithRequest` says "my `Request` property is the thing to validate", and the framework's convention registration turns that into an `IValidator` that delegates to `IValidator` via FluentValidation's `SetValidator`, using `TryAdd` semantics so an explicit command validator would win (`MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/ICommandWithRequest.cs:5-11,14-17`). `ICacheInvalidating` contributes a single `CachePrefix` string (`MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/ICacheInvalidating.cs:8-14`), and [`CachingCommandDecorator`](group-05-cqrs-pipeline.md#cachingcommanddecoratortcommand-tresult) evicts by that prefix only after the inner handler returns a non-failure result, scoping the prefix to the current tenant first (`MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingCommandDecorator.cs:76-88`). Two details in that decorator are worth reading once: the empty-prefix guard is load-bearing because `RemoveByPrefixAsync("")` would evict the entire cache (`:74-78`), and a second delayed eviction fires afterwards to catch an in-flight read that repopulated a stale entry (`:96`). `[Rubric §6, CQRS & Event-Driven]` assesses whether write intent is modelled as an explicit message with cross-cutting behavior attached declaratively: it is, and the command is the only place that behavior is configured. `[Rubric §10, Cross-Cutting]`: caching, validation, logging, and transactions are decorators around the handler rather than calls inside it. +- **Walkthrough**: the whole type is three lines of substance. `sealed record UpdateActivityCommand(ActivityIdentifierType Id, ActivityUpdateRequest Request)` (`:9`) gives the record its two members; `Request` satisfies the `ICommandWithRequest` contract by name. `CachePrefix` (`:12`) is an expression-bodied property returning `$"{typeof(Activity).FullName}:"`, so the prefix is the entity's fully qualified type name with a trailing colon. Deriving it from `typeof` rather than a literal means a rename of the entity moves the prefix with it, and it matches the key shape the query side writes. +- **Why it's built this way**: an id-plus-request command keeps the route parameter and the body as separate, typed things all the way to the handler, so the handler never has to trust an id embedded in the payload. The cache prefix on the command rather than in the handler is what lets the eviction happen *after* the transaction decorator commits, which is the only ordering that cannot leave a freshly repopulated stale entry behind. +- **Where it's used**: constructed by [`ActivitiesController`](group-20-conference-api-grpc.md#activitiescontroller) in its `PUT {id}` action (`MMCA.ADC.Conference.API/Controllers/ActivitiesController.cs:231`) and handled by [`UpdateActivityHandler`](#updateactivityhandler). -### UpdateConferenceCategoryHandler +### UpdateActivityHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.UseCases.Update` · `MMCA.ADC.Conference.Application/Categories/UseCases/Update/UpdateConferenceCategoryHandler.cs:15` · Level 9 · class +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Activities.UseCases.Update` · `MMCA.ADC.Conference.Application/Activities/UseCases/Update/UpdateActivityHandler.cs:15` · Level 10 · class -- **What it is**: the handler for [`UpdateConferenceCategoryCommand`](#updateconferencecategorycommand): load the [`Category`](group-17-conference-domain.md#category), stamp the client's concurrency token, delegate the field changes to the aggregate's `Update`, save, log, and return the updated DTO. -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) (`UpdateConferenceCategoryHandler.cs:6,18`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:5,16`), [`ConferenceCategoryDTOMapper`](#conferencecategorydtomapper) (`:2,17`), [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error) (`:7`), the [`Category`](group-17-conference-domain.md#category) aggregate (`:3`), [`ConferenceCategoryDTO`](group-17-conference-domain.md#conferencecategorydto) (`:4`), and `ILogger` from `Microsoft.Extensions.Logging` (`:1,18`). -- **Concept introduced**: **the optimistic-concurrency round trip inside a handler.** This is the canonical update shape in the module, and its one non-obvious line is `repository.SetOriginalRowVersion(entity, command.Request.RowVersion)` (`:32`). EF Core would otherwise use the row version it loaded a moment ago as the `WHERE` predicate on `UPDATE`, comparing the row against itself and always succeeding. Overwriting the *original* value with the token the client last saw changes the question to "has anyone written this row since the client read it?". If someone has, `SaveChangesAsync` raises `DbUpdateConcurrencyException`, which the shared exception middleware turns into HTTP 409 instead of a silent last-write-wins; the in-code comment states exactly this (`:30-31`). A null token skips the check, the documented opt-out from [ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html). `[Rubric §8, Data Architecture]` assesses concurrent-write reconciliation. `[Rubric §4, Domain-Driven Design]`: the handler assigns no properties itself, it calls `entity.Update(...)` (`:34-37`) so the aggregate re-checks its own invariants and raises [`CategoryChanged`](group-17-conference-domain.md#categorychanged) (`MMCA.ADC.Conference.Domain/Categories/Category.cs:84,95`). `[Rubric §13, Observability & Operability]`: the `[LoggerMessage]` source-generated log (`:49-50`) is compile-time and allocation-free. -- **Walkthrough**: the class is `sealed partial` with a primary constructor for DI (`:15-18`), `partial` because `[LoggerMessage]` generates the log method's body into the other half. `HandleAsync` (`:21-23`) gets the typed repository (`:25`), loads by id (`:26`), and returns `Error.NotFound` tagged with source and target when the category is absent (`:27-28`). It stamps the row version (`:32`), calls `entity.Update(command.Request.Title, command.Request.Sort, command.Request.Type)` (`:34-37`), and short-circuits with the aggregate's own errors on failure (`:39-40`). On success it awaits `SaveChangesAsync` with `ConfigureAwait(false)` (`:42`), the single save that also persists the domain event through the outbox, emits `LogConferenceCategoryUpdated` with the category id (`:44`), and returns `Result.Success(dtoMapper.MapToDTO(entity))` (`:46`). The `[LoggerMessage]` declaration sits at `:49-50` with level `Information` and the template "Conference category {CategoryId} updated". -- **Why it's built this way**: the handler opens no transaction and evicts no cache. Those are the transactional and caching decorators' jobs, driven by [`UpdateConferenceCategoryCommand`](#updateconferencecategorycommand)'s [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) marker, which keeps every command's cross-cutting behavior uniform ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). Mapping the *tracked* entity after the save means the returned DTO reflects anything the domain normalized. -- **Where it's used**: injected into [`ConferenceCategoriesController`](group-20-conference-api-grpc.md#conferencecategoriescontroller) as `ICommandHandler>` (`MMCA.ADC.Conference.API/Controllers/ConferenceCategoriesController.cs:35`) and invoked on `PUT {id}` (`:109-111`), after which the controller separately evicts the tagged HTTP output cache (`:116`). +- **What it is**: the command handler that applies an [`ActivityUpdateRequest`](#activityupdaterequest) to a stored activity. It is the canonical shape of an update handler in this codebase: load, stamp the concurrency token, delegate to the aggregate, save, map, return. +- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`MMCA.ADC.Conference.Application/Activities/UseCases/Update/UpdateActivityHandler.cs:5,16`), [`ActivityDTOMapper`](#activitydtomapper) (`:2,17`), `ILogger` from `Microsoft.Extensions.Logging` (`:1,18`), the [`Activity`](group-17-conference-domain.md#activity) aggregate (`:3,25`), [`ActivityDTO`](group-17-conference-domain.md#activitydto) (`:4,18`), and [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error) from `MMCA.Common.Shared.Abstractions` (`:7,28`). It implements [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) closed over [`UpdateActivityCommand`](#updateactivitycommand) and `Result` (`:18`). +- **Concept introduced**: **the concurrency stamp, and why the handler never touches the DbContext.** Everything about persistence goes through [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork): the handler asks it for a repository (`:25`) rather than injecting `IRepository<,>` directly, which is the workspace rule, and it calls `unitOfWork.SaveChangesAsync` (`:47`) rather than a context. The step that is easy to miss is line 32, `repository.SetOriginalRowVersion(entity, command.Request.RowVersion)`. EF Core has just loaded the row and recorded its *current* `RowVersion` as the original value, so an `UPDATE` would concur with whatever is in the database right now, which is last-write-wins. Overwriting the tracked original value with the token the client sent makes the generated `WHERE` clause compare against what the client actually read, so a row someone else changed in between produces zero affected rows and a `DbUpdateConcurrencyException`, surfaced as HTTP 409. The framework implementation is deliberately forgiving: a null or zero-length token returns early and skips the check entirely (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFRepository.cs:75-84`), so a client that omits the token opts out rather than being rejected ([ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html)). `[Rubric §8, Data Architecture]` assesses how concurrent writes to one row are reconciled: optimistic concurrency with a client-carried token, not pessimistic locking. `[Rubric §3, Clean Architecture]`: the handler names only Application-layer abstractions and the Domain aggregate, so nothing EF-shaped leaks into the use case. `[Rubric §13, Observability & Operability]`: the success log is a source-generated `[LoggerMessage]`, not string interpolation. +- **Walkthrough**: the class is a `sealed partial class` with a primary constructor taking the three dependencies (`:15-18`); `partial` is required because the logging source generator emits the other half. `HandleAsync` (`:21-52`) runs six steps. (1) Get the typed repository from the unit of work, `GetRepository()` (`:25`). (2) `GetByIdAsync(command.Id, ...)` (`:26`); a null entity returns `Result.Failure(Error.NotFound.WithSource(nameof(UpdateActivityHandler)).WithTarget(nameof(Activity)))` (`:27-28`), so the 404 carries which handler produced it and which aggregate was missing rather than a bare message. (3) Stamp the client's token (`:32`), with the comment above it recording the intent (`:30-31`). (4) Delegate to the aggregate: `entity.Update(...)` with the eight request fields in order (`:34-42`). The domain method, not the handler, is what validates: it composes name, time-range, venue-name, venue-address, and venue-URL invariants through `Result.Combine` and returns early on failure before mutating anything (`MMCA.ADC.Conference.Domain/Activities/Activity.cs:145,155-162`), then assigns the fields and raises `ActivityChanged` with `DomainEntityState.Updated` (`:164-173`). A failed result is propagated by errors, not exceptions (`:44-45`). (5) `await unitOfWork.SaveChangesAsync(cancellationToken)` (`:47`), which is where audit stamping, the domain-event dispatch, and the concurrency comparison all happen. (6) Log and map: `LogActivityUpdated(logger, command.Id)` (`:49`) then `Result.Success(dtoMapper.MapToDTO(entity))` (`:51`). The log method itself is the generator-backed partial at `:54-55`, `[LoggerMessage(Level = LogLevel.Information, Message = "Activity {ActivityId} updated")]`. +- **Why it's built this way**: the handler is deliberately thin. Field-shape validation already ran in the decorator pipeline via [`ActivityUpdateRequestValidator`](#activityupdaterequestvalidator), business invariants live in [`ActivityInvariants`](group-17-conference-domain.md#activityinvariants) behind `Activity.Update`, transactions and cache eviction are decorators configured by [`UpdateActivityCommand`](#updateactivitycommand), and mapping is a Mapperly-generated method on [`ActivityDTOMapper`](#activitydtomapper) ([ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html)). What remains in the handler is only the orchestration that is genuinely specific to this use case, which is why it reads as a linear list of six steps. +- **Where it's used**: resolved as `ICommandHandler>` and injected into [`ActivitiesController`](group-20-conference-api-grpc.md#activitiescontroller)'s primary constructor (`MMCA.ADC.Conference.API/Controllers/ActivitiesController.cs:40`), invoked from the `PUT {id}` action guarded by `[HasPermission(ConferencePermissions.ActivitiesManage)]` (`:223-232`). Registration is convention-based: the `ICommandHandler<,>` assembly scan inside `ScanModuleApplicationServices()` picks it up with scoped lifetime (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:178-182`, called at `MMCA.ADC.Conference.Application/DependencyInjection.cs:125`), so the injected instance is the decorated pipeline, not the bare class. +- **Caveats / not-in-source**: the controller evicts output-cache tags after a successful update (`ActivitiesController.cs:238`, evicting `conference:activities` and `conference` at `:253-257`). That is a second, distinct cache from the one [`UpdateActivityCommand`](#updateactivitycommand)`.CachePrefix` addresses: the response cache at the HTTP boundary versus the query-result cache inside the decorator pipeline. Both are evicted on this path; nothing in these files coordinates them beyond both being triggered by the same request. + +### ConferenceCategoryUpdateRequest + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.UseCases.Update` · `MMCA.ADC.Conference.Application/Categories/UseCases/Update/ConferenceCategoryUpdateRequest.cs:6` · Level 1 · record + +- **What it is**: the request DTO a client PUTs to update an existing conference [`Category`](group-17-conference-domain.md#category). It carries the three editable fields plus the concurrency token the client last saw. +- **Depends on**: [`IConcurrencyAware`](group-12-api-hosting-mapping.md#iconcurrencyaware) from `MMCA.Common.Shared.DTOs` (`ConferenceCategoryUpdateRequest.cs:1,6`). Nothing else: it is a pure payload record with no domain types in its members. +- **Concept introduced**: **the concurrency-aware update request.** Every update request in this module is a `record class` implementing [`IConcurrencyAware`](group-12-api-hosting-mapping.md#iconcurrencyaware), which contributes exactly one member, a nullable `byte[]? RowVersion` (`:9`). That token is the client's proof of what it read. The handler stamps it as the entity's *original* row version before saving, so EF Core compares it against the stored value on `UPDATE` and raises a concurrency exception (surfaced as HTTP 409) when someone else has written the row in the meantime. Without the round trip, a stale form silently overwrites a newer edit. Because the property is nullable, omitting it opts out of the check rather than failing closed, which is the framework's deliberate trade-off ([ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html)) and is stated on the contract itself (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:276-284`). `[Rubric §8, Data Architecture]` assesses how concurrent writes to one row are reconciled: this codebase chooses optimistic concurrency with an explicit client token over pessimistic locking or last-write-wins. `[Rubric §9, API & Contract Design]`: the token is part of the wire contract, so the round trip is visible to clients rather than hidden server state. +- **Walkthrough**: `RowVersion` (`:9`) is `init`-only and nullable. `Title` (`:12`) is `required string`, the one field the validator guards. `Sort` (`:15`) is the display order, defaulting to zero. `Type` (`:18`) is the optional discriminator string, documented as "session" or "speaker" (`:17`). All four members are `init`-only, so the request is immutable once bound. +- **Why it's built this way**: `required` on `Title` means the request cannot be constructed without a title, pushing the most basic contract violation to the model binder instead of the validator. Making the PUT a full replacement (rather than a patch) is what lets the handler pass every field straight through to the aggregate without distinguishing "not supplied" from "cleared". +- **Where it's used**: bound from the body by [`ConferenceCategoriesController`](group-20-conference-api-grpc.md#conferencecategoriescontroller) on `PUT {id}` (`MMCA.ADC.Conference.API/Controllers/ConferenceCategoriesController.cs:103-107`), wrapped in [`UpdateConferenceCategoryCommand`](#updateconferencecategorycommand) (`:110`), validated by [`ConferenceCategoryUpdateRequestValidator`](#conferencecategoryupdaterequestvalidator), and consumed field by field by [`UpdateConferenceCategoryHandler`](#updateconferencecategoryhandler). ### EventUpdateRequest > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.Update` · `MMCA.ADC.Conference.Application/Events/UseCases/Update/EventUpdateRequest.cs:7` · Level 1 · record -- **What it is**: the body a client PUTs to update an existing [`Event`](group-17-conference-domain.md#event). It is a full replacement payload: every editable field of the event edition plus the concurrency token the client last read. -- **Depends on**: [`IConcurrencyAware`](group-12-api-hosting-mapping.md#iconcurrencyaware) from `MMCA.Common.Shared.DTOs` (`EventUpdateRequest.cs:2,7`) and the [`QuestionModerationDefault`](group-17-conference-domain.md#questionmoderationdefault) enum from `MMCA.ADC.Conference.Shared.Events` (`:1,40`). Nothing else: `DateOnly` and `byte[]` are BCL. -- **Concept introduced**: none new. The concurrency-aware update request is taught at [`ConferenceCategoryUpdateRequest`](#conferencecategoryupdaterequest); the same `record class` plus nullable `byte[]? RowVersion` shape repeats here. What this request adds is an **enum-valued field on the wire**. `QuestionModerationDefault` (`:40`) is the only non-string, non-date member with a domain meaning, and it is deliberately not `required`, so an omitted value binds to the enum's zero member `Pending` (`MMCA.ADC.Conference.Shared/Events/QuestionModerationDefault.cs:10`). `[Rubric §9, API & Contract Design]` assesses whether the contract is explicit about what a client must send: seven members here are `required` and the compiler enforces them at bind time, while the optional remainder is genuinely optional. `[Rubric §24, Forms/Validation/UX Safety]`: the enum is re-checked by [`EventUpdateRequestValidator`](#eventupdaterequestvalidator) with `IsInEnum()`, because model binding will happily deserialize an out-of-range integer into an enum-typed property. -- **Walkthrough**: `RowVersion` (`:10`) is the `init`-only concurrency token. `Name` (`:13`), `StartDate` (`:19`), `EndDate` (`:22`), and `TimeZone` (`:25`) are `required`, so they cannot be omitted. `Description` (`:16`), `SessionizeCode` (`:28`), `VenueAddress` (`:31`), `VenueMapUrl` (`:34`), and `WiFiInfo` (`:37`) are nullable strings that make up the venue-and-logistics half of the payload. `QuestionModerationDefault` (`:40`) carries the live-layer moderation policy for the edition (BR-233). `OrganizerContactEmail` (`:43`) and `SponsorshipPacketUrl` (`:46`) are the two attendee-facing optional links. Every member is `init`-only, so the bound instance is immutable for the whole pipeline. -- **Why it's built this way**: `TimeZone` is `required` rather than optional because it is the interpretation key for every session time under the event, and [`UpdateEventHandler`](#updateeventhandler) compares it against the stored value to decide whether to raise the BR-131 warning (`MMCA.ADC.Conference.Application/Events/UseCases/Update/UpdateEventHandler.cs:36`). A nullable time zone would make "not supplied" indistinguishable from "cleared" on the one field where that ambiguity is most expensive. -- **Where it's used**: bound from the body by [`EventsController`](group-20-conference-api-grpc.md#eventscontroller) on `PUT {id}` (`MMCA.ADC.Conference.API/Controllers/EventsController.cs:266-269`), wrapped into [`UpdateEventCommand`](#updateeventcommand) (`:272`), validated by [`EventUpdateRequestValidator`](#eventupdaterequestvalidator), and read field by field by [`UpdateEventHandler`](#updateeventhandler) (`UpdateEventHandler.cs:48-60`). -- **Caveats / not-in-source**: because the request is a full replacement and `QuestionModerationDefault` is not `required`, a client that omits the field sets the edition's moderation default to `Pending`, since both the request member (`:40`) and the domain method's parameter default (`MMCA.ADC.Conference.Domain/Events/Event.cs:227`) land on the zero member. Whether the admin UI always sends the current value is not determinable from this file. +- **What it is**: the full-replacement payload for editing a conference [`Event`](group-17-conference-domain.md#event): identity and schedule (name, description, dates, time zone), the Sessionize link code, the venue and logistics fields an attendee sees, the per-event question moderation default, and the outward-facing contact and URLs an edition publishes. +- **Depends on**: [`IConcurrencyAware`](group-12-api-hosting-mapping.md#iconcurrencyaware) from `MMCA.Common.Shared.DTOs` (`EventUpdateRequest.cs:2,7`) and the [`QuestionModerationDefault`](group-17-conference-domain.md#questionmoderationdefault) enum from `MMCA.ADC.Conference.Shared.Events` (`:1,40`). Everything else is a BCL primitive, including `DateOnly` for the two dates (`:19,22`). +- **Concept introduced**: **date-only scheduling plus a named time zone, rather than an offset.** The event's span is two `DateOnly` values (`:19,22`) and its `TimeZone` is a string documented as an IANA identifier (`:24-25`). Nothing here is a `DateTimeOffset`, so the record cannot silently bake in a UTC offset that is wrong half the year: the calendar day is the fact, and the zone id is how any consumer resolves a wall-clock session time to an instant. That choice is what makes the time zone load-bearing enough to earn its own business rule on the update path (BR-131, see [`UpdateEventHandler`](#updateeventhandler)). `[Rubric §8, Data Architecture]` assesses whether temporal data is modeled so that it survives daylight-saving transitions and re-hosting; `[Rubric §27, i18n]`: an IANA id is the portable, culture-neutral way to express when this conference happens, and it is validated for real against the host's time zone database by [`EventTimeZoneRules`](#eventtimezonerulest) (`MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:34-48`). +- **Walkthrough**: `RowVersion` (`:10`) is the [`IConcurrencyAware`](group-12-api-hosting-mapping.md#iconcurrencyaware) token. Four members are `required` and therefore cannot be omitted by a caller: `Name` (`:13`), `StartDate` (`:19`), `EndDate` (`:22`), and `TimeZone` (`:25`). The optional strings are `Description` (`:16`), `SessionizeCode` (`:28`, the code that ties this edition to a Sessionize event feed), `VenueAddress` (`:31`), `VenueMapUrl` (`:34`), `WiFiInfo` (`:37`), `OrganizerContactEmail` (`:43`), `SponsorshipPacketUrl` (`:46`), and `TicketingUrl` (`:49`). `QuestionModerationDefault` (`:40`) is the one enum member, documented as the BR-233 moderation default for live-layer session questions. Every member is `init`-only. +- **Why it's built this way**: the update request carries one field the *create* request does not. `EventCreateRequest` has no `QuestionModerationDefault` member and [`EventCreateRequestMapper`](#eventcreaterequestmapper) omits the argument entirely when it calls the factory (`MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequestMapper.cs:19-32`), so a new event takes the domain default `Pending` (`MMCA.ADC.Conference.Domain/Events/Event.cs:175`, enum values at `MMCA.ADC.Conference.Shared/Events/QuestionModerationDefault.cs:10,13`). Moderation posture is therefore something an organizer opts into after the event exists rather than a decision forced at creation time, and the cautious value is the one you get by default. `[Rubric §11, Security]` assesses whether defaults fail safe: unmoderated display of attendee-submitted text is the riskier state, and it is never the implicit one. +- **Where it's used**: bound from the body by [`EventsController`](group-20-conference-api-grpc.md#eventscontroller) on `PUT {id}` (`MMCA.ADC.Conference.API/Controllers/EventsController.cs:266-270`), validated by [`EventUpdateRequestValidator`](#eventupdaterequestvalidator), wrapped in [`UpdateEventCommand`](#updateeventcommand) (`:273`), and passed field by field into `Event.Update` by [`UpdateEventHandler`](#updateeventhandler). ### QuestionUpdateRequest > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Questions.UseCases.Update` · `MMCA.ADC.Conference.Application/Questions/UseCases/Update/QuestionUpdateRequest.cs:6` · Level 1 · record -- **What it is**: the PUT body for an existing feedback [`Question`](group-17-conference-domain.md#question): the prompt text, the two discriminators that say what the question is attached to and how it is answered, plus display order and a required flag. -- **Depends on**: [`IConcurrencyAware`](group-12-api-hosting-mapping.md#iconcurrencyaware) only (`QuestionUpdateRequest.cs:1,6`). It is a pure payload record. -- **Concept introduced**: none new; see [`ConferenceCategoryUpdateRequest`](#conferencecategoryupdaterequest) for the shape. The point worth carrying forward from this record is that `QuestionEntity` and `QuestionType` (`:15,18`) are `required` **and** re-sent on every update even though [`UpdateQuestionHandler`](#updatequestionhandler) will refuse to change them once answers exist (BR-137). A full-replacement contract means the client always echoes them; the handler compares the echo against stored state and decides. `[Rubric §9, API & Contract Design]` assesses whether the payload shape matches the operation: a PUT that replaces the resource carries the whole resource, and conditional immutability is enforced server-side rather than by splitting the endpoint. -- **Walkthrough**: `RowVersion` (`:9`) is the concurrency token. `QuestionText` (`:12`) is the `required` prompt, the one field this request's validator guards. `QuestionEntity` (`:15`) is the `required` target discriminator, documented as "Session" or "Event". `QuestionType` (`:18`) is the `required` input-kind discriminator, documented as "Rating", "Text", or "Email". `Sort` (`:21`) is the display order and `IsRequired` (`:24`) is whether an answer is mandatory; both are plain value types with implicit defaults. -- **Where it's used**: bound by [`QuestionsController`](group-20-conference-api-grpc.md#questionscontroller) on `PUT {id}` (`MMCA.ADC.Conference.API/Controllers/QuestionsController.cs:103-106`), wrapped into [`UpdateQuestionCommand`](#updatequestioncommand) (`:109`), validated by [`QuestionUpdateRequestValidator`](#questionupdaterequestvalidator), and consumed by [`UpdateQuestionHandler`](#updatequestionhandler). -- **Caveats / not-in-source**: the two discriminators are typed as `string`, not enums, and their allowed values live in the doc comments (`:14,17`) plus the domain invariants `EnsureQuestionEntityIsValid` and `EnsureQuestionTypeIsValid` invoked from `Question.Update` (`MMCA.ADC.Conference.Domain/Questions/Question.cs:117-118`). This record itself constrains neither. +- **What it is**: the payload for editing an existing survey [`Question`](group-17-conference-domain.md#question): its text, the entity it targets, its input type, its display order, and whether an answer is mandatory. +- **Depends on**: [`IConcurrencyAware`](group-12-api-hosting-mapping.md#iconcurrencyaware) (`QuestionUpdateRequest.cs:1,6`). No other type; all five payload members are BCL primitives. +- **Concept introduced**: **a field that is in the contract but only conditionally editable.** `QuestionEntity` (`:15`) and `QuestionType` (`:18`) are plain `required string` members here, so the wire contract accepts a new value for either. Whether that value is *allowed* is not a property of the record: [`UpdateQuestionHandler`](#updatequestionhandler) probes the three answer tables and rejects the change once any answer exists (BR-137, `UpdateQuestionHandler.cs:38-73`). Contrast this with a field removed from a request entirely, which is how the module expresses "never editable through this path". The distinction is worth internalizing because it tells you where to look for a rule: a *shape* constraint lives in the record, a *state-dependent* constraint cannot, because the record has no access to the database. `[Rubric §4, Domain-Driven Design]` assesses whether rules live where the knowledge to enforce them lives; `[Rubric §9, API & Contract Design]`: the contract stays uniform between create and update, and the difference surfaces as a validation error with a stable code rather than as a missing property. +- **Walkthrough**: `RowVersion` (`:9`) is the concurrency token. `QuestionText` (`:12`), `QuestionEntity` (`:15`, documented as "Session" or "Event"), and `QuestionType` (`:18`, documented as "Rating", "Text", or "Email") are `required`. `Sort` (`:21`) and `IsRequired` (`:24`) are plain value members that default to `0` and `false`. Note the near-miss in naming: `IsRequired` is the survey question's own "an attendee must answer this" flag, not the C# `required` modifier that governs three of its siblings. +- **Why it's built this way**: the two discriminator strings are free-form `string`, not enums, so adding a question type or a new target entity does not require a change to the contract type; the legal values are asserted in the domain instead ([`QuestionInvariants`](group-17-conference-domain.md#questioninvariants), called from `MMCA.ADC.Conference.Domain/Questions/Question.cs:115-118`). +- **Where it's used**: bound by [`QuestionsController`](group-20-conference-api-grpc.md#questionscontroller) on `PUT {id}` (`MMCA.ADC.Conference.API/Controllers/QuestionsController.cs:102-106`), validated by [`QuestionUpdateRequestValidator`](#questionupdaterequestvalidator), wrapped in [`UpdateQuestionCommand`](#updatequestioncommand) (`:109`), and consumed by [`UpdateQuestionHandler`](#updatequestionhandler). -### SessionUpdateRequest +### UpdateEventResult -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.Update` · `MMCA.ADC.Conference.Application/Sessions/UseCases/Update/SessionUpdateRequest.cs:6` · Level 1 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.Update` · `MMCA.ADC.Conference.Application/Events/UseCases/Update/UpdateEventCommand.cs:19` · Level 3 · record -- **What it is**: the PUT body for an existing [`Session`](group-17-conference-domain.md#session), and the largest update request in the module: fifteen members covering identity, schedule, workflow flags, links, and the room assignment. -- **Depends on**: [`IConcurrencyAware`](group-12-api-hosting-mapping.md#iconcurrencyaware) (`SessionUpdateRequest.cs:1,6`) plus the module identifier aliases `EventIdentifierType` and `RoomIdentifierType` (`:12,54`), both aliased to `int` in `MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7,11`. -- **Concept introduced**: **the echoed immutable field.** `EventId` (`:12`) is `required` on a request that is not allowed to change it: its own doc comment says "Must match the session's current EventId (BR-140: immutable after creation)". At first read that looks redundant, but it is the safety property of a full-replacement PUT. The client sends the whole resource as it believes it to be, and [`UpdateSessionHandler`](#updatesessionhandler) compares the echoed parent against the stored one, failing with `Session.EventId.Immutable` and HTTP 422 when they differ (`MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionHandler.cs:37-44`). Dropping the field would make a stale client's belief invisible; keeping it turns a wrong assumption into an explicit rejection instead of a silent accept. The same echoed value is then used as the lookup key for the parent event that the room and date-range checks need. `[Rubric §9, API & Contract Design]`: immutability is expressed as a rejected transition with a stable error code, not as an absent field. `[Rubric §4, Domain-Driven Design]`: a session belongs to exactly one event for life, which is an aggregate-composition fact rather than an editable attribute. -- **Walkthrough**: `RowVersion` (`:9`) is the concurrency token. `EventId` (`:12`) and `Title` (`:15`) are the two `required` members. `Description` (`:18`) is the optional abstract. `StartsAt` and `EndsAt` (`:21,24`) are nullable `DateTime`s, so an unscheduled session is legal. `Status` (`:27`) is an optional free-text status. The four booleans `IsInformed`, `IsConfirmed`, `IsServiceSession`, `IsPlenumSession` (`:30,33,36,39`) carry speaker-workflow and session-kind state. `LiveUrl`, `RecordingUrl`, `AccessibilityInfo`, and `ResourceLinks` (`:42,45,48,51`) are the optional link and note fields. `RoomId` (`:54`) is a nullable room assignment, which is what makes the BR-130 cross-event and double-booking checks conditional rather than mandatory. -- **Why it's built this way**: the nullable schedule fields are load-bearing for the import path as well as the admin UI. A session that arrives from Sessionize before the agenda is fixed has no times and no room, so making `StartsAt`, `EndsAt`, and `RoomId` optional keeps that state representable instead of forcing placeholder values that later read as real data. -- **Where it's used**: bound by [`SessionsController`](group-20-conference-api-grpc.md#sessionscontroller) on `PUT {id}` (`MMCA.ADC.Conference.API/Controllers/SessionsController.cs:324-327`), wrapped into [`UpdateSessionCommand`](#updatesessioncommand) (`:330`), validated by [`SessionUpdateRequestValidator`](#sessionupdaterequestvalidator), and consumed by [`UpdateSessionHandler`](#updatesessionhandler). +- **What it is**: the two-member envelope [`UpdateEventHandler`](#updateeventhandler) returns: the updated [`EventDTO`](group-17-conference-domain.md#eventdto) plus a boolean saying whether this particular update changed the time zone while sessions already existed. +- **Depends on**: [`EventDTO`](group-17-conference-domain.md#eventdto) (`UpdateEventCommand.cs:19`, imported at `:1`). Nothing else; the second member is a `bool`. +- **Concept introduced**: **the advisory result, distinct from success and from failure.** The [`Result`](group-01-result-error-handling.md#result) pattern gives a handler two outcomes, success with a value or failure with errors. BR-131 is neither: changing an event's time zone after sessions are scheduled does not violate an invariant (the write is legitimate and must be persisted), but it does change what every already-stored session time *means*. Rejecting it would be wrong, and silently accepting it would be worse. The codebase's answer is a third channel carried inside the success value, a flag the caller can act on. This is a small type with a large lesson, namely that "succeeded, with something you should know" deserves a first-class shape rather than a log line the operator never reads. `[Rubric §9, API & Contract Design]` assesses how non-fatal conditions are conveyed: [`EventsController`](group-20-conference-api-grpc.md#eventscontroller) translates the flag into an `X-Warning` response header and still returns 200 with the DTO (`MMCA.ADC.Conference.API/Controllers/EventsController.cs:279-288`), so the body stays exactly the `EventDTO` the API contract promises and the advisory rides beside it. `[Rubric §13, Observability & Operability]`: the condition is surfaced to the human who caused it, at the moment they caused it. +- **Walkthrough**: one line. `sealed record UpdateEventResult(EventDTO Event, bool HasTimeZoneWarning)` (`:19`), with both members documented on the declaration (`:16-18`). It has no methods and no behavior; it exists to name a pair. +- **Why it's built this way**: it lives in the same file as [`UpdateEventCommand`](#updateeventcommand) (`:10`) because the two are one use case's input and output and are never referenced apart. Keeping the envelope in the Application layer rather than widening [`EventDTO`](group-17-conference-domain.md#eventdto) with a `HasTimeZoneWarning` property matters: the flag is a fact about *this write*, not a property of the event, so it must not be persisted, cached, or returned by any read. +- **Where it's used**: constructed by [`UpdateEventHandler`](#updateeventhandler) (`UpdateEventHandler.cs:70`), declared as the handler's result type on both the handler interface (`:19`) and the controller's injected dependency (`MMCA.ADC.Conference.API/Controllers/EventsController.cs:48`), and unwrapped by the controller, which reads the flag (`:280`) and then returns only `result.Value.Event` (`:288`). No other layer sees the envelope. -### UpdateEventResult +### UpdateConferenceCategoryCommand -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.Update` · `MMCA.ADC.Conference.Application/Events/UseCases/Update/UpdateEventCommand.cs:19` · Level 3 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.UseCases.Update` · `MMCA.ADC.Conference.Application/Categories/UseCases/Update/UpdateConferenceCategoryCommand.cs:9` · Level 7 · record -- **What it is**: the two-member return value of [`UpdateEventHandler`](#updateeventhandler): the updated [`EventDTO`](group-17-conference-domain.md#eventdto) and a boolean saying whether the update tripped the BR-131 time-zone advisory. -- **Depends on**: [`EventDTO`](group-17-conference-domain.md#eventdto) (`UpdateEventCommand.cs:19`). It lives in the same file as [`UpdateEventCommand`](#updateeventcommand), which is why its `File:Line` points there. -- **Concept introduced**: **the advisory warning, separated from the failure channel.** The codebase already has one way to say "no": a failed [`Result`](group-01-result-error-handling.md#result) carrying [`Error`](group-01-result-error-handling.md#error) values, which the controller turns into a 4xx. Some outcomes are neither success nor failure though: changing an event's time zone while sessions already exist does not violate an invariant, but it silently re-interprets every stored session time, so the organizer should be told. Encoding that as an error would block a legitimate edit; encoding it as a log line would hide it from the person who caused it. The pattern used here is a third channel: the handler still returns `Result.Success(...)`, but the success payload is a wrapper carrying the DTO plus a flag. The transport decides how to surface it, and [`EventsController`](group-20-conference-api-grpc.md#eventscontroller) converts the flag into an `X-Warning` response header, then returns only `result.Value.Event` as the body (`MMCA.ADC.Conference.API/Controllers/EventsController.cs:279-287`). The wrapper therefore never reaches the wire: it is an application-to-API carrier, so the client's response schema stays exactly `EventDTO`. `[Rubric §9, API & Contract Design]` assesses how non-fatal conditions are communicated without polluting the success contract. `[Rubric §6, CQRS & Event-Driven]`: the handler stays the single owner of the business decision and the controller owns only its presentation. -- **Walkthrough**: `sealed record UpdateEventResult(EventDTO Event, bool HasTimeZoneWarning)` (`:19`), two positional parameters and no body. The XML doc names the rule it serves, BR-131 (`:16-18`). -- **Where it's used**: constructed once, at the end of [`UpdateEventHandler.HandleAsync`](#updateeventhandler) (`UpdateEventHandler.cs:69`), and unwrapped by [`EventsController`](group-20-conference-api-grpc.md#eventscontroller) (`EventsController.cs:279-287`). It is also the `TResult` in the handler's `ICommandHandler>` registration, which is how the controller injects it (`EventsController.cs:47`). +- **What it is**: the write intent for updating a conference [`Category`](group-17-conference-domain.md#category). It pairs the target `Id` with the [`ConferenceCategoryUpdateRequest`](#conferencecategoryupdaterequest) payload and opts the operation into cache eviction. +- **Depends on**: [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) and [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) from `MMCA.Common.Application.UseCases` (`UpdateConferenceCategoryCommand.cs:2,9`), the [`ConferenceCategoryUpdateRequest`](#conferencecategoryupdaterequest) it wraps (`:9`), and the [`Category`](group-17-conference-domain.md#category) type used only for its `FullName` in the cache prefix (`:1,12`). `ConferenceCategoryIdentifierType` is the module identifier alias. +- **Concept introduced**: **the id-plus-request command, and validation by delegation.** A create path can let the request record double as the command, because the identity is inside the body. An update cannot: the target id arrives on the route and the payload arrives in the body, so the command exists to marry the two into one object the pipeline can dispatch. [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) is the framework contract that makes this shape legible to the decorators. The bridge is concrete and worth tracing once: module registration reflects over the assembly, finds every type implementing `ICommandWithRequest<>`, and `TryAdd`s an `IValidator` implemented by [`CommandRequestValidator`](group-06-validation.md#commandrequestvalidatortcommand-trequest) (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:196-210`), whose whole body is `RuleFor(c => c.Request).SetValidator(validator)` against the registered request validator (`MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommandRequestValidator.cs:22-27`). That is why [`ConferenceCategoryUpdateRequestValidator`](#conferencecategoryupdaterequestvalidator) validates the *request* type and no `UpdateConferenceCategoryCommandValidator` file exists to keep in sync, and why `TryAdd` matters: a hand-written command validator still wins. The second marker, [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating), opts the command into the caching decorator so a successful update evicts the category read cache. `[Rubric §6, CQRS & Event-Driven]` assesses whether writes are explicit intents flowing through a uniform pipeline: both cross-cutting behaviors attach declaratively through marker interfaces, with no wiring inside the handler ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). +- **Walkthrough**: two positional parameters, `Id` and `Request`, with both interfaces implemented on the same declaration line (`:9`). `CachePrefix` (`:12`) is an expression-bodied property returning `$"{typeof(Category).FullName}:"`, the key namespace the [`CachingCommandDecorator`](group-05-cqrs-pipeline.md#cachingcommanddecoratortcommand-tresult) wipes after a successful handle. There is no other member; the positional `Request` parameter satisfies the interface property with no extra code. +- **Why it's built this way**: deriving the cache prefix from `typeof(Category).FullName` rather than a literal string keeps the writer (this command) and the reader (the category query cache) agreed on one key namespace that a rename cannot desynchronize. +- **Where it's used**: constructed by [`ConferenceCategoriesController`](group-20-conference-api-grpc.md#conferencecategoriescontroller) from the route id and body (`MMCA.ADC.Conference.API/Controllers/ConferenceCategoriesController.cs:109-111`) and handled by [`UpdateConferenceCategoryHandler`](#updateconferencecategoryhandler). -### UpdateSessionResult +### ConferenceCategoryUpdateRequestValidator -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.Update` · `MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionCommand.cs:19` · Level 3 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.UseCases.Update` · `MMCA.ADC.Conference.Application/Categories/UseCases/Update/ConferenceCategoryUpdateRequestValidator.cs:7` · Level 8 · class -- **What it is**: the return value of [`UpdateSessionHandler`](#updatesessionhandler): the updated [`SessionDTO`](group-17-conference-domain.md#sessiondto) plus a boolean saying whether the session's times fall outside the parent event's date range (BR-86). -- **Depends on**: [`SessionDTO`](group-17-conference-domain.md#sessiondto) (`UpdateSessionCommand.cs:2,19`). Declared in the same file as [`UpdateSessionCommand`](#updatesessioncommand). -- **Concept introduced**: none new; this is the advisory-warning wrapper taught at [`UpdateEventResult`](#updateeventresult), applied to a different rule. The difference worth noting is what the two warnings mean. The time-zone warning says an existing set of session times may now be misinterpreted; the date-range warning says the times just submitted sit outside the event's own days. Both are organizer errors that the system deliberately refuses to treat as invariants, because conferences do run pre-days and after-parties that legitimately fall outside a strictly recorded date range. -- **Walkthrough**: `sealed record UpdateSessionResult(SessionDTO Session, bool HasDateRangeWarning)` (`:19`), with the XML doc naming BR-86 (`:16-18`). -- **Where it's used**: constructed at the end of [`UpdateSessionHandler.HandleAsync`](#updatesessionhandler) (`UpdateSessionHandler.cs:97`) and unwrapped by [`SessionsController`](group-20-conference-api-grpc.md#sessionscontroller), which appends the `X-Warning` header and returns `result.Value.Session` (`MMCA.ADC.Conference.API/Controllers/SessionsController.cs:337-343`). The same controller emits the identical header on the create path by computing the comparison inline instead (`SessionsController.cs:310-315`), so the wrapper is the update path's way of moving that decision into the handler where the parent event is already loaded. +- **What it is**: the FluentValidation validator for [`ConferenceCategoryUpdateRequest`](#conferencecategoryupdaterequest), run by the pipeline before the update handler executes. +- **Depends on**: `AbstractValidator` (FluentValidation, `ConferenceCategoryUpdateRequestValidator.cs:1,7`), [`ConferenceCategoryUpdateRequest`](#conferencecategoryupdaterequest), and the shared [`ConferenceCategoryTitleRules`](#conferencecategorytitlerulest) rule set from `MMCA.ADC.Conference.Application.Categories.Validation` (`:2,10`). +- **Concept introduced**: none new; this is `Include` composition (taught in [group 06](group-06-validation.md)) in its smallest possible form. The entire class body is one expression-bodied constructor (`:9-10`) folding in a single parameterized rule set: `Include(new ConferenceCategoryTitleRules(p => p.Title))`. The same rule object is included by the create-side category validator against a different request type, which is the whole point: the title contract is declared once and every entry path inherits it, complete with the stable error code `Category.Title.Required` that the rule set attaches (`MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:18`) and the max-length bound it reads from the domain's `CategoryInvariants` (`:19`). `[Rubric §24, Forms/Validation/UX Safety]` assesses whether input constraints are single-sourced and consistently applied; `[Rubric §1, SOLID]`: this validator's only job is composition, so a title-rule change never has to be found in two files. +- **Walkthrough**: `sealed class ConferenceCategoryUpdateRequestValidator : AbstractValidator` (`:7`); the constructor (`:9-10`) is the single `Include`. `RowVersion`, `Sort`, and `Type` carry no rules here: a null concurrency token is a legitimate "skip the check" signal rather than an error, and the other two have no field-level business constraint. +- **Where it's used**: discovered by assembly scanning and invoked by the [`ValidatingCommandDecorator`](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult) ahead of [`UpdateConferenceCategoryHandler`](#updateconferencecategoryhandler), reached through [`UpdateConferenceCategoryCommand`](#updateconferencecategorycommand)'s [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) implementation. ### EventUpdateRequestValidator > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.Update` · `MMCA.ADC.Conference.Application/Events/UseCases/Update/EventUpdateRequestValidator.cs:7` · Level 8 · class -- **What it is**: the FluentValidation validator for [`EventUpdateRequest`](#eventupdaterequest). It composes five reusable event rule sets and adds one inline rule for the moderation enum. -- **Depends on**: `AbstractValidator` from FluentValidation (`EventUpdateRequestValidator.cs:1,7`) and the rule sets in `MMCA.ADC.Conference.Application.Events.Validation` (`:2`): [`EventNameRules`](#eventnamerulest), [`EventTimeZoneRules`](#eventtimezonerulest), [`EventDateRangeRules`](#eventdaterangerulest), [`EventOrganizerContactEmailRules`](#eventorganizercontactemailrulest), and [`EventSponsorshipPacketUrlRules`](#eventsponsorshippacketurlrulest). -- **Concept introduced**: **`Include` composition with a multi-field rule set, and the null-forgiving selector.** The `Include` mechanism itself is taught at [`ConferenceCategoryUpdateRequestValidator`](#conferencecategoryupdaterequestvalidator); two wrinkles show up here for the first time. First, [`EventDateRangeRules`](#eventdaterangerulest) takes **two** selectors, `p => p.StartDate` and `p => p.EndDate` (`:13`), because the constraint it owns is a relationship rather than a field: it requires both dates and then asserts `endDate >= startDateFunc(instance)` by compiling the start-date selector and using it inside a cross-property `Must` (`MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:104-107`). A parameterized rule set does not have to be single-field, it just has to own one concept. Second, line 14 passes `p => p.OrganizerContactEmail!` with the null-forgiving operator, because that rule set's constructor takes a non-nullable `Expression>` (`EventValidationRules.cs:60`) while the request property is `string?`. That is safe here only because the rule set guards itself: it compiles the accessor and wraps the real email rules in `When(x => !string.IsNullOrWhiteSpace(accessor(x)), ...)` (`EventValidationRules.cs:62-65`), so a null value never reaches the inner [`EmailRules`](group-06-validation.md#emailrulest). `[Rubric §24, Forms/Validation/UX Safety]` assesses whether constraints are single-sourced and applied at every entry point: the same five rule sets are included by the create-side validator, so a rule change cannot land on one path only. `[Rubric §15, Best Practices & Code Quality]`: the `!` here is a real, if load-bearing, sharp edge, only correct because of the `When` guard one file away. -- **Walkthrough**: `sealed class EventUpdateRequestValidator : AbstractValidator` (`:7`). The constructor (`:9-21`) folds in name (`:11`), time zone (`:12`), the date-range pair (`:13`), organizer contact email (`:14`), and sponsorship packet URL (`:15`), then declares the one rule that has no reusable home: `RuleFor(x => x.QuestionModerationDefault).IsInEnum()` with the message "Question moderation default is not a valid value." and the stable error code `Event.QuestionModerationDefault.Invalid` (`:17-20`). `IsInEnum()` matters because a JSON body carrying `"questionModerationDefault": 7` binds without complaint into the enum-typed property, and only this rule rejects it. -- **Why it's built this way**: the time-zone rule is the clearest reason to keep these rule sets shared rather than inline. [`EventTimeZoneRules`](#eventtimezonerulest) is not a length check: it chains `NotEmpty`, `MaximumLength`, and a `Must(BeAValidIanaTimeZone)` predicate carrying the error code `Event.TimeZone.InvalidIana` (`EventValidationRules.cs:29-32`), which is the BR-87 enforcement point. Duplicating that logic per request record would be how the create and update paths eventually disagree. -- **Where it's used**: discovered by assembly scanning and invoked by the [`ValidatingCommandDecorator`](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult) ahead of [`UpdateEventHandler`](#updateeventhandler), reached through [`UpdateEventCommand`](#updateeventcommand)'s [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) implementation. -- **Caveats / not-in-source**: nothing here validates `SessionizeCode`, `VenueAddress`, `VenueMapUrl`, or `WiFiInfo`. For those fields the enforcement is the EF column width plus whatever the domain checks; `Event.Update` combines only name, time zone, and date-range invariants (`MMCA.ADC.Conference.Domain/Events/Event.cs:231-234`). +- **What it is**: the validator for [`EventUpdateRequest`](#eventupdaterequest). It composes six reusable event rule sets and adds one rule written inline. +- **Depends on**: `AbstractValidator` (FluentValidation, `EventUpdateRequestValidator.cs:1,7`), [`EventUpdateRequest`](#eventupdaterequest), and the six rule sets from `MMCA.ADC.Conference.Application.Events.Validation` (`:2,11-16`): [`EventNameRules`](#eventnamerulest), [`EventTimeZoneRules`](#eventtimezonerulest), [`EventDateRangeRules`](#eventdaterangerulest), [`EventOrganizerContactEmailRules`](#eventorganizercontactemailrulest), [`EventSponsorshipPacketUrlRules`](#eventsponsorshippacketurlrulest), and [`EventTicketingUrlRules`](#eventticketingurlrulest). +- **Concept introduced**: **when a rule belongs inline rather than in a shared rule set.** Compare this constructor to [`EventCreateRequestValidator`](#eventcreaterequestvalidator) (`MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequestValidator.cs:11-16`): the same six `Include` calls appear in the same order, and only this file adds a seventh rule, `RuleFor(x => x.QuestionModerationDefault).IsInEnum()` (`:18-21`). The asymmetry is not an oversight. `QuestionModerationDefault` is a member of the update request only (see [`EventUpdateRequest`](#eventupdaterequest)), so there is exactly one binding site, and packaging a one-call rule as a generic rule set would buy nothing. The rule itself guards against the way enums arrive over JSON: an unmapped integer binds happily into an enum-typed property, so a value such as `(QuestionModerationDefault)7` would otherwise reach the domain and be stored. `IsInEnum` rejects it before the handler runs, with a message and the stable code `Event.QuestionModerationDefault.Invalid` (`:20-21`). `[Rubric §24, Forms/Validation/UX Safety]` assesses whether every inbound field has a contract; `[Rubric §11, Security]`: enum members are a closed set only if something enforces the closure at the boundary. +- **Walkthrough**: `sealed class EventUpdateRequestValidator : AbstractValidator` (`:7`). The constructor (`:9-22`) includes the name rule (`:11`), the IANA time zone rule (`:12`), the date-range rule that also cross-checks `EndDate >= StartDate` (`:13`, cross-property comparison at `EventValidationRules.cs:122-125`), and three optional-field rules. Two details repay a second look. First, the organizer email is passed with a null-forgiving `p => p.OrganizerContactEmail!` (`:14`) because that rule set's selector is typed `Expression>` while the property is `string?` (`EventValidationRules.cs:60`); the rule set is nonetheless safe, because it wraps its inner [`EmailRules`](group-06-validation.md#emailrulest) in a `When(...)` that fires only on a non-blank value (`:64-65`). Second, the two URL rule sets use the same `When` guard over [`OptionalStringRules`](group-06-validation.md#optionalstringrulest) (`:82-83,100-101`), so clearing a URL is always legal. Fields with no rule at all: `RowVersion`, `Description`, `SessionizeCode`, `VenueAddress`, `VenueMapUrl`, and `WiFiInfo`. +- **Why it's built this way**: length bounds are not literals here. Each rule set reads its constant from `EventInvariants` in the Domain layer, so the validator's message, the aggregate's own guard, and the EF column width agree on one number ([`EventInvariants`](group-17-conference-domain.md#eventinvariants); usages at `EventValidationRules.cs:17,31,65,83,101`). `[Rubric §16, Maintainability]`: widening a field is a one-constant change. +- **Caveats / not-in-source**: the time zone check calls `TimeZoneInfo.FindSystemTimeZoneById` and treats `TimeZoneNotFoundException` as invalid (`EventValidationRules.cs:39-47`), so the accepted set is whatever the host's time zone database contains. Which identifiers that is on a given container image is not determinable from source. +- **Where it's used**: registered by assembly scanning, reached through [`UpdateEventCommand`](#updateeventcommand)'s [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest), and executed by the [`ValidatingCommandDecorator`](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult) before [`UpdateEventHandler`](#updateeventhandler). ### QuestionUpdateRequestValidator > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Questions.UseCases.Update` · `MMCA.ADC.Conference.Application/Questions/UseCases/Update/QuestionUpdateRequestValidator.cs:7` · Level 8 · class -- **What it is**: the validator for [`QuestionUpdateRequest`](#questionupdaterequest), and the smallest one in this slice: a single expression-bodied constructor with one `Include`. -- **Depends on**: `AbstractValidator` (`QuestionUpdateRequestValidator.cs:1,7`) and [`QuestionTextRules`](#questiontextrulest) from `MMCA.ADC.Conference.Application.Questions.Validation` (`:2,10`). -- **Concept introduced**: none new; see [`ConferenceCategoryUpdateRequestValidator`](#conferencecategoryupdaterequestvalidator) for the composition pattern and [`EventUpdateRequestValidator`](#eventupdaterequestvalidator) for the multi-rule version. The teaching value of this class is the **deliberate gap**. Three of the six request members carry business meaning, yet only `QuestionText` gets a rule. The reason is that the other two constraints cannot be answered from the payload alone: whether `QuestionEntity` and `QuestionType` hold legal values is a domain invariant (`QuestionInvariants.EnsureQuestionEntityIsValid` and `EnsureQuestionTypeIsValid`, invoked from `Question.Update` at `MMCA.ADC.Conference.Domain/Questions/Question.cs:117-118`), and whether they may change at all depends on database state, which is BR-137 in [`UpdateQuestionHandler`](#updatequestionhandler). A validator that only sees the request should not pretend to decide either. `[Rubric §24, Forms/Validation/UX Safety]`: each constraint is enforced at the layer that actually has the information, rather than being half-implemented in the cheapest one. -- **Walkthrough**: `sealed class QuestionUpdateRequestValidator : AbstractValidator` (`:7`); the whole body is `=> Include(new QuestionTextRules(p => p.QuestionText))` (`:9-10`). The included rule set chains `NotEmpty` with error code `Question.QuestionText.Required` and a `MaximumLength(QuestionInvariants.QuestionTextMaxLength)` with code `Question.QuestionText.MaxLength` (`MMCA.ADC.Conference.Application/Questions/Validation/QuestionValidationRules.cs:16-18`). -- **Where it's used**: run by the [`ValidatingCommandDecorator`](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult) before [`UpdateQuestionHandler`](#updatequestionhandler), through [`UpdateQuestionCommand`](#updatequestioncommand). - -### SessionUpdateRequestValidator - -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.Update` · `MMCA.ADC.Conference.Application/Sessions/UseCases/Update/SessionUpdateRequestValidator.cs:7` · Level 8 · class - -- **What it is**: the validator for [`SessionUpdateRequest`](#sessionupdaterequest): seven `Include` calls, one per bounded text field. -- **Depends on**: `AbstractValidator` (`SessionUpdateRequestValidator.cs:1,7`) and seven rule sets from `MMCA.ADC.Conference.Application.Sessions.Validation` (`:2`): [`SessionTitleRules`](#sessiontitlerulest), [`SessionDescriptionRules`](#sessiondescriptionrulest), [`SessionStatusRules`](#sessionstatusrulest), [`SessionLiveUrlRules`](#sessionliveurlrulest), [`SessionRecordingUrlRules`](#sessionrecordingurlrulest), [`SessionAccessibilityInfoRules`](#sessionaccessibilityinforulest), and [`SessionResourceLinksRules`](#sessionresourcelinksrulest). -- **Concept introduced**: none new; it is the same composition taught at [`ConferenceCategoryUpdateRequestValidator`](#conferencecategoryupdaterequestvalidator), at its widest in this module. What is worth studying is the **shape of what is absent**. Of the fifteen request members, the seven text fields get rules and eight do not: `RowVersion` (a null token is a legitimate opt-out, not an error), the four booleans (every value is legal), `EventId`, `RoomId`, and the `StartsAt`/`EndsAt` pair. The last four are exactly the fields whose constraints need other rows to evaluate: BR-140 needs the stored session, BR-130 needs the parent event's room collection, and the double-booking check needs every other session in that room. All three therefore live in [`UpdateSessionHandler`](#updatesessionhandler) and [`SessionRoomScheduling`](#sessionroomscheduling), not here. `[Rubric §3, Clean Architecture]` assesses whether each concern sits at the layer that owns its data: a stateless request validator stays stateless, and stateful rules stay in the handler that already has a unit of work. -- **Walkthrough**: `sealed class SessionUpdateRequestValidator : AbstractValidator` (`:7`); the constructor (`:9-18`) includes title (`:11`), description (`:12`), status (`:13`), live URL (`:14`), recording URL (`:15`), accessibility info (`:16`), and resource links (`:17`). The six optional selectors bind nullable properties, the title selector binds the non-nullable `Title`, and that difference in the rule sets' base classes is what makes the title the only mandatory one. -- **Why it's built this way**: the same six optional text constraints are re-checked in the aggregate by `SessionInvariants.EnsureOptionalTextLengthsAreValid`, which `Session.Update` combines with the title and time-order invariants (`MMCA.ADC.Conference.Domain/Sessions/Session.cs:245-248`). The validator is the fast path with readable per-field messages; the domain is the authority a non-HTTP caller still cannot bypass. -- **Where it's used**: run by the [`ValidatingCommandDecorator`](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult) before [`UpdateSessionHandler`](#updatesessionhandler), through [`UpdateSessionCommand`](#updatesessioncommand). +- **What it is**: the validator for [`QuestionUpdateRequest`](#questionupdaterequest). One `Include`, covering the question text. +- **Depends on**: `AbstractValidator` (FluentValidation, `QuestionUpdateRequestValidator.cs:1,7`), [`QuestionUpdateRequest`](#questionupdaterequest), and [`QuestionTextRules`](#questiontextrulest) from `MMCA.ADC.Conference.Application.Questions.Validation` (`:2,10`). +- **Concept introduced**: none new; it is the same one-line composition as [`ConferenceCategoryUpdateRequestValidator`](#conferencecategoryupdaterequestvalidator), and it is the create-side validator with a different type argument (`MMCA.ADC.Conference.Application/Questions/UseCases/Create/QuestionCreateRequestValidator.cs:10`, compare `:9-10` here). What is worth noticing is everything it does *not* validate. `QuestionEntity` and `QuestionType` are `required` strings with no rule here, even though only certain values are legal. Their legality is asserted twice further in: by [`QuestionInvariants`](group-17-conference-domain.md#questioninvariants) inside `Question.Update` (`MMCA.ADC.Conference.Domain/Questions/Question.cs:115-118`), and, for the *change* rather than the value, by [`UpdateQuestionHandler`](#updatequestionhandler)'s BR-137 probe. `[Rubric §3, Clean Architecture]` assesses whether each layer holds the checks it is entitled to hold: the validator owns cheap shape checks at the boundary, the aggregate owns the value invariants, and the handler owns the checks that require a database read. +- **Walkthrough**: `sealed class QuestionUpdateRequestValidator : AbstractValidator` (`:7`) with an expression-bodied constructor (`:9-10`) that includes `new QuestionTextRules(p => p.QuestionText)`. That rule set is `NotEmpty` plus `MaximumLength(QuestionInvariants.QuestionTextMaxLength)`, carrying the codes `Question.QuestionText.Required` and `Question.QuestionText.MaxLength` (`MMCA.ADC.Conference.Application/Questions/Validation/QuestionValidationRules.cs:16-18`). +- **Where it's used**: executed by the [`ValidatingCommandDecorator`](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult) through [`UpdateQuestionCommand`](#updatequestioncommand), ahead of [`UpdateQuestionHandler`](#updatequestionhandler). ### UpdateEventCommand > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.Update` · `MMCA.ADC.Conference.Application/Events/UseCases/Update/UpdateEventCommand.cs:10` · Level 8 · record -- **What it is**: the write intent for updating an [`Event`](group-17-conference-domain.md#event). It marries the route id with the [`EventUpdateRequest`](#eventupdaterequest) body and opts the operation into cache eviction. -- **Depends on**: [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) and [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) from `MMCA.Common.Application.UseCases` (`UpdateEventCommand.cs:3,10`), the [`EventUpdateRequest`](#eventupdaterequest) it wraps (`:10`), and the [`Event`](group-17-conference-domain.md#event) domain type used only for its `FullName` in the cache prefix (`:1,13`). `EventIdentifierType` is the module alias for `int`. -- **Concept introduced**: none new; the id-plus-request command and validation by delegation are taught at [`UpdateConferenceCategoryCommand`](#updateconferencecategorycommand). This is that shape applied to events, and it is worth noting how little the command has to say: two positional parameters and one computed property, with both cross-cutting behaviors attached declaratively. `[Rubric §6, CQRS & Event-Driven]` assesses whether writes are explicit intents flowing through a uniform pipeline; the caching and validation behavior arrives from marker interfaces rather than from code inside [`UpdateEventHandler`](#updateeventhandler) ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). -- **Walkthrough**: `sealed record UpdateEventCommand(EventIdentifierType Id, EventUpdateRequest Request)` implementing both interfaces on the declaration line (`:10`). `CachePrefix` (`:13`) is expression-bodied, returning `$"{typeof(Event).FullName}:"`, the key namespace the [`CachingCommandDecorator`](group-05-cqrs-pipeline.md#cachingcommanddecoratortcommand-tresult) wipes after a successful handle. The positional `Request` parameter satisfies the [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) property with no extra member, which is why [`EventUpdateRequestValidator`](#eventupdaterequestvalidator) can be registered against the request type and still be found for this command. -- **Why it's built this way**: deriving the prefix from `typeof(Event).FullName` rather than a string literal keeps the writer and the read-side cache keys agreed on one namespace that a rename cannot desynchronize. Note this is the **application** cache; the HTTP output cache is a second, separate layer that [`EventsController`](group-20-conference-api-grpc.md#eventscontroller) evicts by tag right after the handler returns (`MMCA.ADC.Conference.API/Controllers/EventsController.cs:286`). -- **Where it's used**: constructed by [`EventsController`](group-20-conference-api-grpc.md#eventscontroller) from the route id and body (`EventsController.cs:271-273`) and handled by [`UpdateEventHandler`](#updateeventhandler). +- **What it is**: the write intent for updating an [`Event`](group-17-conference-domain.md#event): the route id plus the [`EventUpdateRequest`](#eventupdaterequest) body, marked as cache-invalidating. +- **Depends on**: [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) and [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`UpdateEventCommand.cs:3,10`), [`EventUpdateRequest`](#eventupdaterequest) (`:10`), and the [`Event`](group-17-conference-domain.md#event) aggregate type, used only for its `FullName` (`:1,13`). `EventIdentifierType` is the module identifier alias. +- **Concept introduced**: none new; it is the id-plus-request shape taught at [`UpdateConferenceCategoryCommand`](#updateconferencecategorycommand). The one thing to note is the asymmetry in its handler's result type: this command's handler returns `Result` rather than `Result` (`UpdateEventHandler.cs:19`), which is the only place across these three update slices where the returned envelope differs from the DTO. `[Rubric §6, CQRS & Event-Driven]`: the command type is the pipeline's dispatch key, so the result type can vary per use case without any decorator caring. +- **Walkthrough**: `sealed record UpdateEventCommand(EventIdentifierType Id, EventUpdateRequest Request)` implementing both marker interfaces on the declaration line (`:10`); `CachePrefix => $"{typeof(Event).FullName}:"` (`:13`). [`UpdateEventResult`](#updateeventresult) sits directly below it in the same file (`:19`). +- **Why it's built this way**: the command does not implement `ITransactional`. The handler writes one aggregate and saves once, so the ambient `SaveChangesAsync` boundary suffices, and the transactional decorator is reserved for commands that coordinate multiple aggregates ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html); see [`TransactionalCommandDecorator`](group-05-cqrs-pipeline.md#transactionalcommanddecoratortcommand-tresult)). +- **Where it's used**: constructed by [`EventsController`](group-20-conference-api-grpc.md#eventscontroller) (`MMCA.ADC.Conference.API/Controllers/EventsController.cs:273`) against the injected `ICommandHandler>` (`:48`), and handled by [`UpdateEventHandler`](#updateeventhandler). ### UpdateQuestionCommand > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Questions.UseCases.Update` · `MMCA.ADC.Conference.Application/Questions/UseCases/Update/UpdateQuestionCommand.cs:9` · Level 8 · record -- **What it is**: the write intent for updating a [`Question`](group-17-conference-domain.md#question), pairing the route id with the [`QuestionUpdateRequest`](#questionupdaterequest) body. -- **Depends on**: [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) and [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`UpdateQuestionCommand.cs:2,9`), the wrapped [`QuestionUpdateRequest`](#questionupdaterequest) (`:9`), and the [`Question`](group-17-conference-domain.md#question) type for the cache prefix (`:1,12`). -- **Concept introduced**: none new; identical in shape to [`UpdateEventCommand`](#updateeventcommand) and taught at [`UpdateConferenceCategoryCommand`](#updateconferencecategorycommand). The uniformity is the point: every update in this module is one two-parameter record with the same two markers, so a reader who has understood one has understood all of them, and a new use case cannot forget cache invalidation without that omission being visible on a single line. -- **Walkthrough**: `sealed record UpdateQuestionCommand(QuestionIdentifierType Id, QuestionUpdateRequest Request)` (`:9`), with `CachePrefix => $"{typeof(Question).FullName}:"` (`:12`). Unlike the event and session update files, this one declares no result wrapper: the question update has no advisory warning to carry, so [`UpdateQuestionHandler`](#updatequestionhandler) returns `Result` directly. -- **Where it's used**: constructed by [`QuestionsController`](group-20-conference-api-grpc.md#questionscontroller) on `PUT {id}` (`MMCA.ADC.Conference.API/Controllers/QuestionsController.cs:108-110`) and handled by [`UpdateQuestionHandler`](#updatequestionhandler). +- **What it is**: the write intent for updating a [`Question`](group-17-conference-domain.md#question): route id plus the [`QuestionUpdateRequest`](#questionupdaterequest) body, marked as cache-invalidating. +- **Depends on**: [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) and [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`UpdateQuestionCommand.cs:2,9`), [`QuestionUpdateRequest`](#questionupdaterequest) (`:9`), and the [`Question`](group-17-conference-domain.md#question) type for its `FullName` (`:1,12`). `QuestionIdentifierType` is the module identifier alias. +- **Concept introduced**: none new; identical in shape to [`UpdateConferenceCategoryCommand`](#updateconferencecategorycommand), including the derived cache prefix. Reading the three update commands side by side is the fastest way to internalize the module's uniformity: same two positional members, same two markers, same one computed property, three different aggregates. `[Rubric §5, Vertical Slice]` assesses whether a feature's types sit together and follow one recognizable shape: each `UseCases/Update` folder holds exactly the request, the validator, the command, and the handler for one entity, so a new slice is a copy of a known pattern rather than an act of invention. +- **Walkthrough**: `sealed record UpdateQuestionCommand(QuestionIdentifierType Id, QuestionUpdateRequest Request)` with both interfaces on the declaration (`:9`), and `CachePrefix => $"{typeof(Question).FullName}:"` (`:12`). +- **Where it's used**: constructed by [`QuestionsController`](group-20-conference-api-grpc.md#questionscontroller) (`MMCA.ADC.Conference.API/Controllers/QuestionsController.cs:109`) against the injected `ICommandHandler>` (`:34`), and handled by [`UpdateQuestionHandler`](#updatequestionhandler). -### UpdateQuestionHandler +### UpdateConferenceCategoryHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Questions.UseCases.Update` · `MMCA.ADC.Conference.Application/Questions/UseCases/Update/UpdateQuestionHandler.cs:19` · Level 9 · class +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.UseCases.Update` · `MMCA.ADC.Conference.Application/Categories/UseCases/Update/UpdateConferenceCategoryHandler.cs:15` · Level 9 · class -- **What it is**: the handler for [`UpdateQuestionCommand`](#updatequestioncommand). It loads the [`Question`](group-17-conference-domain.md#question), stamps the concurrency token, refuses a shape change once answers exist (BR-137), delegates the field changes to the aggregate, saves, logs, and returns the DTO. -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) (`UpdateQuestionHandler.cs:9,22`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:8,20`), [`QuestionDTOMapper`](#questiondtomapper) (`:2,21`), [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error) (`:10`), the [`Question`](group-17-conference-domain.md#question) aggregate (`:4`), the three answer entities [`EventQuestionAnswer`](group-17-conference-domain.md#eventquestionanswer) (`:3,42`), [`SessionQuestionAnswer`](group-17-conference-domain.md#sessionquestionanswer) (`:5,49`), and [`SpeakerQuestionAnswer`](group-17-conference-domain.md#speakerquestionanswer) (`:6,59`), and `ILogger` (`:1,22`). -- **Concept introduced**: **the conditional-immutability guard, and read repositories for existence probes.** The optimistic-concurrency round trip itself is taught at [`UpdateConferenceCategoryHandler`](#updateconferencecategoryhandler); what is new here is a rule that depends on data the request cannot see. BR-137 says a question's `QuestionType` and `QuestionEntity` become frozen the moment anyone has answered it, because changing a "Rating" question into a "Text" question would leave stored answers uninterpretable. Two properties of the implementation deserve attention. First, the whole probe is **skipped** unless one of the two fields actually differs (`:39-40`): renaming the prompt text of an answered question stays free, so the guard costs nothing on the common edit. Second, when the probe does run it hits three tables through [`IReadRepository`](group-07-persistence-ef-core.md#ireadrepositorytentity-tidentifiertype) rather than the tracking [`IRepository`](group-07-persistence-ef-core.md#irepositorytentity-tidentifiertype), obtained from `unitOfWork.GetReadRepository<...>()` (`:42,49,59`). That is the deliberate choice for a question the handler only asks and never mutates, and the repositories are resolved from the unit of work rather than constructor-injected, which is the module-wide convention. `[Rubric §8, Data Architecture]` assesses whether stored data stays interpretable across schema and metadata edits: this guard exists precisely so historical answers cannot be orphaned from their question's type. `[Rubric §12, Performance & Scalability]`: each probe is an `ExistsAsync` predicate, so the database answers with an existence check rather than materializing answer rows, and the second and third probes are short-circuited once the first says yes (`:47,55`). -- **Walkthrough**: the class is `sealed partial` with a primary constructor for DI (`:19-22`), `partial` because `[LoggerMessage]` generates the log method body into the other half. `HandleAsync` (`:25-27`) resolves the tracking repository (`:29`), loads by id (`:30`), and returns `Error.NotFound` tagged with source and target when the question is absent (`:31-32`). It stamps the client's token with `repository.SetOriginalRowVersion(entity, command.Request.RowVersion)` (`:36`), the line whose comment records that a concurrent edit must surface as a 409 rather than a silent last-write-wins (`:34-35`). The BR-137 block (`:38-73`) compares the two discriminators (`:39-40`) and, only on a difference, probes event answers (`:42-45`), then session answers if none were found (`:47-53`), then speaker answers (`:55-63`, whose comment states that a speaker-profile answer counts the same as the other two). If any exist it returns `Error.Validation` with the stable code `Question.ImmutableAfterAnswers` (`:65-72`). Otherwise it calls `entity.Update(...)` with the five payload fields (`:75-80`), which re-runs the text, entity, and type invariants and raises [`QuestionChanged`](group-17-conference-domain.md#questionchanged) (`MMCA.ADC.Conference.Domain/Questions/Question.cs:115-128`), short-circuits on the aggregate's own errors (`:82-83`), awaits `SaveChangesAsync` with `ConfigureAwait(false)` (`:85`), emits `LogQuestionUpdated` with `command.Id` (`:87`), and returns `Result.Success(dtoMapper.MapToDTO(entity))` (`:89`). The `[LoggerMessage]` declaration sits at `:92-93` at `Information` level with the template "Question {QuestionId} updated". -- **Why it's built this way**: the guard lives in the handler rather than in the aggregate because the aggregate cannot see the answers. A [`Question`](group-17-conference-domain.md#question) does not own its answers as children (they hang off events, sessions, and speakers), so "has anyone answered this?" is a cross-aggregate query, and the application layer is where cross-aggregate questions are allowed to be asked. The handler also opens no transaction and evicts no cache: those are the transactional and caching decorators' jobs, driven by [`UpdateQuestionCommand`](#updatequestioncommand)'s markers ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). -- **Where it's used**: injected into [`QuestionsController`](group-20-conference-api-grpc.md#questionscontroller) as `ICommandHandler>` (`MMCA.ADC.Conference.API/Controllers/QuestionsController.cs:34`) and invoked on `PUT {id}` (`:108-110`), after which the controller evicts the tagged HTTP output cache (`:115`). -- **Caveats / not-in-source**: the three probes run sequentially rather than as one query, so a shape change on an unanswered question costs up to three round trips. That is the price of the answers living in three separate tables, and it is paid only on the rare edit that actually changes a discriminator. +- **What it is**: the handler for [`UpdateConferenceCategoryCommand`](#updateconferencecategorycommand): load the [`Category`](group-17-conference-domain.md#category), stamp the client's concurrency token, delegate the field changes to the aggregate's `Update`, save, log, and return the updated DTO. +- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) (`UpdateConferenceCategoryHandler.cs:6,18`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:5,16`), [`ConferenceCategoryDTOMapper`](#conferencecategorydtomapper) (`:2,17`), [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error) (`:7`), the [`Category`](group-17-conference-domain.md#category) aggregate (`:3`), [`ConferenceCategoryDTO`](group-17-conference-domain.md#conferencecategorydto) (`:4`), and `ILogger` from `Microsoft.Extensions.Logging` (`:1,18`). +- **Concept introduced**: **the optimistic-concurrency round trip inside a handler.** This is the canonical update shape in the module, and its one non-obvious line is `repository.SetOriginalRowVersion(entity, command.Request.RowVersion)` (`:32`). EF Core would otherwise use the row version it loaded a moment ago as the `WHERE` predicate on `UPDATE`, comparing the row against itself and always succeeding. Overwriting the *original* value with the token the client last saw changes the question to "has anyone written this row since the client read it?". If someone has, `SaveChangesAsync` raises `DbUpdateConcurrencyException`, which the shared exception middleware turns into HTTP 409 instead of a silent last-write-wins; the in-code comment states exactly this (`:30-31`), and the contract's own documentation repeats it, including that a null or empty token is a no-op (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:276-284`). That opt-out is the documented position of [ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html). `[Rubric §8, Data Architecture]` assesses concurrent-write reconciliation. `[Rubric §4, Domain-Driven Design]`: the handler assigns no properties itself, it calls `entity.Update(...)` (`:34-37`) so the aggregate re-checks its own invariants and raises [`CategoryChanged`](group-17-conference-domain.md#categorychanged) (`MMCA.ADC.Conference.Domain/Categories/Category.cs:84-95`). `[Rubric §13, Observability & Operability]`: the `[LoggerMessage]` source-generated log (`:49-50`) is compile-time and allocation-free. +- **Walkthrough**: the class is `sealed partial` with a primary constructor for DI (`:15-18`), `partial` because `[LoggerMessage]` generates the log method's body into the other half. `HandleAsync` (`:21-23`) gets the typed repository (`:25`), loads by id (`:26`), and returns `Error.NotFound` tagged with source and target when the category is absent (`:27-28`). It stamps the row version (`:32`), calls `entity.Update(command.Request.Title, command.Request.Sort, command.Request.Type)` (`:34-37`), and short-circuits with the aggregate's own errors on failure (`:39-40`). On success it awaits `SaveChangesAsync` with `ConfigureAwait(false)` (`:42`), the single save that also persists the domain event through the outbox, emits `LogConferenceCategoryUpdated` with the category id (`:44`), and returns `Result.Success(dtoMapper.MapToDTO(entity))` (`:46`). The `[LoggerMessage]` declaration sits at `:49-50` with level `Information` and the template "Conference category {CategoryId} updated". +- **Why it's built this way**: the handler opens no transaction and evicts no cache. Those are the transactional and caching decorators' jobs, driven by [`UpdateConferenceCategoryCommand`](#updateconferencecategorycommand)'s [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) marker, which keeps every command's cross-cutting behavior uniform ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). Mapping the *tracked* entity after the save means the returned DTO reflects anything the domain normalized. +- **Where it's used**: injected into [`ConferenceCategoriesController`](group-20-conference-api-grpc.md#conferencecategoriescontroller) as `ICommandHandler>` (`MMCA.ADC.Conference.API/Controllers/ConferenceCategoriesController.cs:35`) and invoked on `PUT {id}` (`:109-111`), after which the controller separately evicts the tagged HTTP output cache (`:116`). -### UpdateSessionCommand +### UpdateQuestionHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.Update` · `MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionCommand.cs:10` · Level 9 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Questions.UseCases.Update` · `MMCA.ADC.Conference.Application/Questions/UseCases/Update/UpdateQuestionHandler.cs:19` · Level 9 · class -- **What it is**: the write intent for updating a [`Session`](group-17-conference-domain.md#session), pairing the route id with the [`SessionUpdateRequest`](#sessionupdaterequest) body. -- **Depends on**: [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) and [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`UpdateSessionCommand.cs:3,10`), the wrapped [`SessionUpdateRequest`](#sessionupdaterequest) (`:10`), and the [`Session`](group-17-conference-domain.md#session) domain type for the cache prefix (`:1,13`). It also pulls in `MMCA.ADC.Conference.Shared.Sessions` (`:2`) for the [`SessionDTO`](group-17-conference-domain.md#sessiondto) referenced by [`UpdateSessionResult`](#updatesessionresult) further down the same file. -- **Concept introduced**: none new; see [`UpdateConferenceCategoryCommand`](#updateconferencecategorycommand) for the shape and [`UpdateEventCommand`](#updateeventcommand) for the sibling. Its level is one higher than the other update commands only because [`SessionUpdateRequest`](#sessionupdaterequest) sits deeper in the dependency graph, not because the command does more. -- **Walkthrough**: `sealed record UpdateSessionCommand(SessionIdentifierType Id, SessionUpdateRequest Request)` implementing both markers (`:10`), with `CachePrefix => $"{typeof(Session).FullName}:"` (`:13`). The file then declares [`UpdateSessionResult`](#updatesessionresult) (`:19`), so the intent and its return shape are read together. -- **Where it's used**: constructed by [`SessionsController`](group-20-conference-api-grpc.md#sessionscontroller) on `PUT {id}` (`MMCA.ADC.Conference.API/Controllers/SessionsController.cs:329-331`) and handled by [`UpdateSessionHandler`](#updatesessionhandler). +- **What it is**: the handler for [`UpdateQuestionCommand`](#updatequestioncommand). It follows the canonical update shape and inserts one extra gate: a question's type and target entity may not change once anybody has answered it (BR-137). +- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) (`UpdateQuestionHandler.cs:9,22`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:8,20`), [`QuestionDTOMapper`](#questiondtomapper) (`:2,21`), [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error) (`:10`), the [`Question`](group-17-conference-domain.md#question) aggregate (`:4`), the three answer entities [`EventQuestionAnswer`](group-17-conference-domain.md#eventquestionanswer) (`:3`), [`SessionQuestionAnswer`](group-17-conference-domain.md#sessionquestionanswer) (`:5`), and [`SpeakerQuestionAnswer`](group-17-conference-domain.md#speakerquestionanswer) (`:6`), plus `ILogger` (`:1,22`). +- **Concept introduced**: **the state-dependent immutability check, and why it is the handler's job.** Some rules cannot live in the request record (it has no data) or in the aggregate (a `Question` does not hold its answers; those live in separate tables reached through their own repositories). BR-137 is one of them: changing `QuestionType` from "Rating" to "Text" after answers exist would leave stored answers that no longer make sense under the new type. So the handler asks the question the domain cannot: does any answer reference this question? It guards the whole check behind a cheap comparison first (`:39-40`), so the common edit, fixing a typo in the text or reordering, costs zero extra queries. Only when a discriminator actually changes does it run up to three `ExistsAsync` probes, short-circuiting as soon as one returns true (`:42-63`). Each probe uses `unitOfWork.GetReadRepository<...>()`, the read-only face of the repository (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IUnitOfWork.cs:29`; the predicate overload of `ExistsAsync` is declared at `MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:62-65`), which both states the intent and keeps those entities out of the change tracker. `[Rubric §4, Domain-Driven Design]` assesses where knowledge lives: this is a cross-aggregate rule, and the application layer is the only place that can see both sides of it. `[Rubric §12, Performance & Scalability]`: `ExistsAsync` compiles to an existence probe rather than a load, and the sequence is both conditional and short-circuiting. `[Rubric §1, SOLID]`: the aggregate stays ignorant of a collection it does not own. +- **Walkthrough**: `sealed partial class` with primary-constructor DI (`:19-22`). `HandleAsync` (`:25-27`) takes the write repository for questions (`:29`), loads the entity (`:30`), and returns `Error.NotFound` when it is missing (`:31-32`). It stamps the client's row version (`:36`). The BR-137 block (`:38-73`) fires only when `entity.QuestionType != command.Request.QuestionType || entity.QuestionEntity != command.Request.QuestionEntity` (`:39-40`); it probes event answers (`:42-45`), then session answers if still clean (`:49-53`), then speaker answers (`:59-62`, with a comment recording that speaker answers count the same as the other two), and on a hit returns `Error.Validation` with code `Question.ImmutableAfterAnswers` and an explanatory message tagged with source and target (`:67-72`). Past the gate it calls `entity.Update(questionText, questionEntity, questionType, sort, isRequired)` (`:75-80`), which re-runs the value invariants and raises [`QuestionChanged`](group-17-conference-domain.md#questionchanged) (`MMCA.ADC.Conference.Domain/Questions/Question.cs:115-128`), propagates any domain errors (`:82-83`), saves (`:85`), logs "Question {QuestionId} updated" (`:87`, declared at `:92-93`), and returns the mapped [`QuestionDTO`](group-17-conference-domain.md#questiondto) (`:89`). +- **Why it's built this way**: the refusal is an `Error.Validation` with a stable code rather than a thrown exception, so it travels the same [`Result`](group-01-result-error-handling.md#result) channel as a FluentValidation failure and the controller's shared `HandleFailure` turns it into a client-error response with no special case (`MMCA.ADC.Conference.API/Controllers/QuestionsController.cs:112-113`). +- **Caveats / not-in-source**: the check is a read followed by a write with no lock between them, so an answer submitted in the window between the probe and the save is not caught. The row version protects the *question* row, not the answer tables. Whether that race has ever occurred in practice is not determinable from source. +- **Where it's used**: injected into [`QuestionsController`](group-20-conference-api-grpc.md#questionscontroller) (`MMCA.ADC.Conference.API/Controllers/QuestionsController.cs:34`) and invoked on `PUT {id}` (`:108-110`), after which the controller evicts the questions output cache (`:115`). ### UpdateEventHandler > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.Update` · `MMCA.ADC.Conference.Application/Events/UseCases/Update/UpdateEventHandler.cs:16` · Level 10 · class -- **What it is**: the handler for [`UpdateEventCommand`](#updateeventcommand). It loads the [`Event`](group-17-conference-domain.md#event), stamps the concurrency token, detects a time-zone change that would re-interpret existing session times (BR-131), delegates the field changes to the aggregate, saves, logs, and returns the DTO wrapped with the warning flag. -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) (`UpdateEventHandler.cs:6,19`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:5,17`), [`EventDTOMapper`](#eventdtomapper) (`:2,18`), [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error) (`:7`), the [`Event`](group-17-conference-domain.md#event) (`:3`) and [`Session`](group-17-conference-domain.md#session) (`:4`) aggregates, [`UpdateEventResult`](#updateeventresult), and `ILogger` (`:1,19`). -- **Concept introduced**: **detecting a semantic ripple, and reporting it without failing.** The concurrency round trip is taught at [`UpdateConferenceCategoryHandler`](#updateconferencecategoryhandler) and the wrapper is taught at [`UpdateEventResult`](#updateeventresult); what this handler adds is the reason both exist together. Session times are stored as absolute values, and the event's `TimeZone` is how the UI interprets them. Change the time zone and no row changes, yet every session on the agenda now means a different wall-clock time. That is not an invariant violation, so the handler must not fail the request, but it is also not nothing, so it must not be silent. The implementation is the cheapest possible detection: compare the incoming time zone to the stored one with `StringComparison.Ordinal` (`:36`), and only if it differs ask the session repository whether any session belongs to this event at all (`:41-45`). If the event has no sessions yet, changing the time zone is harmless and no warning is raised. `[Rubric §8, Data Architecture]` assesses whether the meaning of stored data is protected across edits: this is a case where the data is untouched and only its interpretation moves, which is exactly the class of change that silently corrupts a schedule. `[Rubric §13, Observability & Operability]`: the `[LoggerMessage]` source-generated log (`:72-73`) is compile-time and allocation-free. -- **Walkthrough**: `sealed partial class` with a primary constructor for DI (`:16-19`). `HandleAsync` (`:22-24`) resolves the tracking repository for events (`:26`), loads by id (`:27`), and returns `Error.NotFound` tagged with source and target if the event is gone (`:28-29`). It stamps the client's token (`:33`) with the comment recording the 409 intent (`:31-32`). The BR-131 block computes `timeZoneChanging` (`:36`), defaults `hasTimeZoneWarning` to false (`:37`), and only inside the `if` resolves a session repository and runs `ExistsAsync(s => s.EventId == command.Id, ...)` (`:39-46`). It then calls `entity.Update(...)` with all twelve payload fields in order (`:48-60`), which re-runs the name, time-zone, and date-range invariants and raises [`EventChanged`](group-17-conference-domain.md#eventchanged) (`MMCA.ADC.Conference.Domain/Events/Event.cs:231-251`), short-circuits on failure (`:62-63`), awaits `SaveChangesAsync` with `ConfigureAwait(false)` (`:65`), logs with `entity.Id` (`:67`), and returns `Result.Success(new UpdateEventResult(dtoMapper.MapToDTO(entity), hasTimeZoneWarning))` (`:69`). -- **Why it's built this way**: the existence probe deliberately runs **before** the aggregate mutation, while `entity.TimeZone` still holds the stored value; running it after `Update` would compare the new value against itself and never warn. Mapping the tracked entity after the save means the returned DTO reflects anything the domain normalized. And as with every handler in this module, no transaction is opened and no cache is evicted here: those come from the decorators driven by [`UpdateEventCommand`](#updateeventcommand)'s markers ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)), while the concurrency opt-out on a null token is the documented behavior of [ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html). -- **Where it's used**: injected into [`EventsController`](group-20-conference-api-grpc.md#eventscontroller) as `ICommandHandler>` (`MMCA.ADC.Conference.API/Controllers/EventsController.cs:47`) and invoked on `PUT {id}` (`:271-273`); the controller turns `HasTimeZoneWarning` into an `X-Warning` header advising that existing session times may now be semantically incorrect and suggesting a Sessionize refresh (`:279-284`), then evicts the events output cache and returns the DTO (`:286-287`). -- **Caveats / not-in-source**: the warning is advisory only. Nothing in this handler rewrites session times, and whether an organizer acts on the header is outside the code. The probe also stops at "does any session exist", so it does not distinguish an event with one unscheduled session from one with a full agenda. +- **What it is**: the handler for [`UpdateEventCommand`](#updateeventcommand). It performs the canonical update and, when the time zone changes on an event that already has sessions, returns an advisory flag alongside the DTO (BR-131). +- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) (`UpdateEventHandler.cs:6,19`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:5,17`), [`EventDTOMapper`](#eventdtomapper) (`:2,18`), [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error) (`:7`), the [`Event`](group-17-conference-domain.md#event) aggregate (`:3`), the [`Session`](group-17-conference-domain.md#session) aggregate used only for the existence probe (`:4`), [`UpdateEventResult`](#updateeventresult), and `ILogger` (`:1,19`). +- **Concept introduced**: **detecting a change by comparing before you overwrite.** The BR-131 test has to run *before* `entity.Update(...)`, because afterwards the tracked entity already holds the new time zone and the old value is gone. Line 36 captures it while it is still available: `var timeZoneChanging = !string.Equals(entity.TimeZone, command.Request.TimeZone, StringComparison.Ordinal)`. Only if that is true does the handler pay for a query, asking whether any session belongs to this event (`:41-45`). The answer is not an error and does not block the write; it is carried out through [`UpdateEventResult`](#updateeventresult) (`:70`) so the caller can warn the human. This is the practical face of the rule: session times are anchored to the event's zone, so re-pointing the zone changes what every stored session time means, and the system refuses to guess whether the organizer meant to move the conference or to fix a typo. `[Rubric §9, API & Contract Design]`: a non-fatal condition gets its own channel instead of overloading the failure path. `[Rubric §12, Performance & Scalability]`: the probe is conditional and uses `ExistsAsync` rather than loading sessions. `[Rubric §13, Observability & Operability]`: the operator receives the warning at the point of change, in the response, not in a log they would have to go looking for. +- **Walkthrough**: `sealed partial class` with primary-constructor DI (`:16-19`). `HandleAsync` (`:22-24`) resolves the event repository (`:26`), loads by id (`:27`), and returns `Error.NotFound` when absent (`:28-29`). It stamps the client's concurrency token (`:33`, the mechanism taught at [`UpdateConferenceCategoryHandler`](#updateconferencecategoryhandler)). It computes `timeZoneChanging` (`:36`), initializes `hasTimeZoneWarning` to false (`:37`), and inside the `if` (`:39-46`) resolves a session repository and calls `ExistsAsync(s => s.EventId == command.Id, ...)` (`:42-44`). It then calls `entity.Update(...)` with all thirteen fields in declaration order (`:48-61`), which re-runs the name, time zone, and date-range invariants through [`EventInvariants`](group-17-conference-domain.md#eventinvariants) and raises [`EventChanged`](group-17-conference-domain.md#eventchanged) (`MMCA.ADC.Conference.Domain/Events/Event.cs:244-265`). Domain failures short-circuit (`:63-64`); otherwise it saves (`:66`), logs "Event {EventId} updated" (`:68`, declared at `:73-74`), and returns `Result.Success(new UpdateEventResult(dtoMapper.MapToDTO(entity), hasTimeZoneWarning))` (`:70`). +- **Why it's built this way**: the ordering of the two guards is deliberate. The row version is stamped first (`:33`), so a stale client loses at `SaveChangesAsync` regardless of how the BR-131 branch went, and the warning is computed against the state actually loaded. Passing every field into one `Update` call rather than assigning properties keeps the aggregate the only writer of its own state and yields exactly one [`EventChanged`](group-17-conference-domain.md#eventchanged) domain event per update, which the outbox picks up on the same save ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)). +- **Caveats / not-in-source**: two small inconsistencies are worth knowing. The session probe uses `unitOfWork.GetRepository()` (`:41`), the full read-write repository, where the read-only `GetReadRepository` used by [`UpdateQuestionHandler`](#updatequestionhandler) would have expressed the intent better; nothing is written through it, so the difference is stylistic. And the advisory is one-way: the handler reports that session times may now be misaligned but performs no re-timing and schedules no follow-up, so acting on the warning (for example by re-running a Sessionize import) is left to the operator. +- **Where it's used**: injected into [`EventsController`](group-20-conference-api-grpc.md#eventscontroller) as `ICommandHandler>` (`MMCA.ADC.Conference.API/Controllers/EventsController.cs:48`) and invoked on `PUT {id}` (`:272-274`); the controller appends an `X-Warning` response header when the flag is set (`:279-285`), evicts the events output cache (`:287`), and returns only the DTO (`:288`). -### UpdateSessionHandler +### SessionUpdateRequest -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.Update` · `MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionHandler.cs:17` · Level 11 · class +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.Update` · `MMCA.ADC.Conference.Application/Sessions/UseCases/Update/SessionUpdateRequest.cs:6` · Level 1 · record -- **What it is**: the most guarded update handler in the module. It loads the [`Session`](group-17-conference-domain.md#session), stamps the concurrency token, rejects a change of parent event (BR-140), loads the parent [`Event`](group-17-conference-domain.md#event) with its rooms, validates the room assignment and schedule slot (BR-130 plus the double-booking guard), delegates the field changes to the aggregate, computes the date-range advisory (BR-86), saves, logs, and returns the wrapped DTO. -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) (`UpdateSessionHandler.cs:7,20`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:6,18`), [`SessionDTOMapper`](#sessiondtomapper) (`:2,19`), [`SessionRoomScheduling`](#sessionroomscheduling) (`:3,57`), [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error) (`:8`), the [`Event`](group-17-conference-domain.md#event) (`:4`) and [`Session`](group-17-conference-domain.md#session) (`:5`) aggregates, [`UpdateSessionResult`](#updatesessionresult), and `ILogger` (`:1,20`). -- **Concept introduced**: **ordering the guards, and the difference between a rejection, a validation, and a warning.** Read top to bottom, this handler is a lesson in sequencing. The cheapest, most absolute check runs first: BR-140 compares the echoed `EventId` against the stored one and, on a mismatch, returns `Error.UnprocessableEntity` with the code `Session.EventId.Immutable` and the message "Session cannot be moved between events." (`:37-44`). Note the error kind. This is not a `NotFound` (the session exists) and not a plain `Validation` (the payload is well formed); it is a semantically understood but forbidden transition, which is exactly what 422 means. Only after that does the handler pay for a database read, and even then it is a targeted one: the parent event is fetched with `includes: [nameof(Event.Rooms)]` and `asTracking: false` (`:48-52`), because the rooms are needed for the BR-130 check but nothing about the event is going to be modified. That non-tracking read is deliberate and consequential: the mutation path stays on the tracked `repository` for the session itself, and a tracked-versus-untracked mix is how "saved" changes silently vanish. The third guard delegates outward to [`SessionRoomScheduling.ValidateRoomAssignmentAsync`](#sessionroomscheduling) (`:57-65`), passing the tracking session repository, the loaded parent, the requested room, the requested slot, and `excludeSessionId: command.Id` so the session cannot collide with its own current booking. Finally, after the aggregate has accepted the change, BR-86 computes a flag rather than an error (`:89-91`). Four checks, four different outcomes: 422, delegated failure, aggregate failure, advisory header. `[Rubric §1, SOLID]` assesses single responsibility: the overlap and cross-event logic lives in its own reusable class so the create path can run the identical check. `[Rubric §12, Performance & Scalability]`: the guards are ordered cheapest-first, so the common well-formed update pays for one extra read and nothing else. `[Rubric §8, Data Architecture]`: the tracking discipline (mutate through the tracked repository, read reference data untracked) is what keeps a composed query from silently dropping the save. -- **Walkthrough**: `sealed partial class` with a primary constructor (`:17-20`). `HandleAsync` (`:23-25`) resolves the tracking session repository (`:27`), loads by id (`:28`), and returns `Error.NotFound` when absent (`:29-30`). It stamps the client's concurrency token (`:34`). BR-140 runs next (`:37-44`). The parent event load follows (`:47-52`) with its own `NotFound` guard targeting `Event` (`:53-54`), which is the "orphaned session" case. `SessionRoomScheduling.ValidateRoomAssignmentAsync` is awaited with `ConfigureAwait(false)` and its errors are propagated unchanged (`:57-67`). `entity.Update(...)` then takes fourteen fields (`:69-83`), re-running the title, time-order, and optional-length invariants and raising [`SessionChanged`](group-17-conference-domain.md#sessionchanged) (`MMCA.ADC.Conference.Domain/Sessions/Session.cs:245-267`), with the usual short-circuit on failure (`:85-86`). BR-86 calls the private static `IsOutsideEventDateRange` (`:89-91`), which converts each supplied `DateTime` with `DateOnly.FromDateTime` and returns true if the start is before the event's `StartDate` or the end is after its `EndDate`, treating a missing time as "no complaint" (`:104-113`). Then `SaveChangesAsync` with `ConfigureAwait(false)` (`:93`), the `LogSessionUpdated` call with `entity.Id` (`:95`), and `Result.Success(new UpdateSessionResult(dtoMapper.MapToDTO(entity), hasDateRangeWarning))` (`:97`). The `[LoggerMessage]` declaration is at `:100-101`. -- **Why it's built this way**: the date-range check is computed against `parentEvent.StartDate` and `parentEvent.EndDate`, which the handler already has in memory from the BR-130 load, so the advisory costs nothing extra. Placing it after `entity.Update` also means it reflects the values the domain accepted rather than the raw request. As everywhere in this module, the transaction and cache eviction come from the decorators driven by [`UpdateSessionCommand`](#updatesessioncommand)'s markers ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)), and the null-token concurrency opt-out is [ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html). -- **Where it's used**: injected into [`SessionsController`](group-20-conference-api-grpc.md#sessionscontroller) as `ICommandHandler>` (`MMCA.ADC.Conference.API/Controllers/SessionsController.cs:45`) and invoked on `PUT {id}` (`:329-331`); the controller converts `HasDateRangeWarning` into the `X-Warning` header "Session time falls outside the event's date range." (`:337-340`), evicts the sessions output cache, and returns `result.Value.Session` (`:342-343`). -- **Caveats / not-in-source**: `IsOutsideEventDateRange` compares dates only, not times, and it uses the raw `DateTime` values without applying the event's time zone, so a session scheduled late on the final day is judged by its stored date alone. Whether the stored session times are UTC or local is not determinable from this file. +- **What it is**: the full-replacement payload a client PUTs to edit an existing [`Session`](group-17-conference-domain.md#session). It carries the concurrency token, the parent event id, the title and description, the scheduled window, four booleans that describe what kind of slot this is, four optional strings (live URL, recording URL, accessibility info, resource links), and the optional room assignment. +- **Depends on**: [`IConcurrencyAware`](group-12-api-hosting-mapping.md#iconcurrencyaware) from `MMCA.Common.Shared.DTOs` (`SessionUpdateRequest.cs:1,6`). Every other member is a BCL primitive or one of the module's identifier aliases (`EventIdentifierType` at `:12`, `RoomIdentifierType` at `:54`), so the record pulls in no domain type at all. +- **Concept introduced**: **carrying a field you are not allowed to change, so the server can detect that you tried.** `EventId` is `required` here (`:12`) even though a session can never move between events, and the doc comment on the property says exactly that ("Must match the session's current EventId (BR-140: immutable after creation)", `:11`). The rule is enforced downstream by [`UpdateSessionHandler`](#updatesessionhandler), which compares the request value against the loaded entity and returns a 422-shaped error when they differ (`MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionHandler.cs:37-44`). This is one of three strategies the module uses for a relationship field that must not move, and all three sit side by side in this unit: carry-and-verify here, omit-entirely in [`SponsorUpdateRequest`](#sponsorupdaterequest) (`SponsorUpdateRequest.cs:7-10`), and route-to-a-governed-endpoint in [`SpeakerUpdateRequest`](#speakerupdaterequest) (`SpeakerUpdateRequest.cs:6-7`). Carry-and-verify is the right choice when the field is genuinely part of the resource's identity in the client's mental model: a session editor already knows which event it is working under, so sending it costs nothing and turns a client bug (posting session 42's body to session 43's route) into a loud rejection rather than a silent cross-event write. `[Rubric §9, API and Contract Design]` assesses whether a contract makes illegal states detectable rather than merely undocumented: the field's presence is what makes the mismatch checkable at all. `[Rubric §11, Security]`: a request body is caller-controlled, so an immutability rule that exists only in a UI is not a rule; the check that counts is the server-side comparison. +- **Walkthrough**: `RowVersion` (`:9`) is the [`IConcurrencyAware`](group-12-api-hosting-mapping.md#iconcurrencyaware) token, nullable and therefore opt-out. Two members are `required`: `EventId` (`:12`) and `Title` (`:15`). `Description` (`:18`) is optional. The schedule is two nullable `DateTime` values, `StartsAt` (`:21`) and `EndsAt` (`:24`), so an unscheduled session is a legal state. `Status` (`:27`) is a free-form optional string. Four booleans describe the slot: `IsInformed` (`:30`) and `IsConfirmed` (`:33`) track the speaker-communication workflow, `IsServiceSession` (`:36`) marks lunch and break blocks, and `IsPlenumSession` (`:39`) marks whole-room slots. Four optional strings follow: `LiveUrl` (`:42`), `RecordingUrl` (`:45`), `AccessibilityInfo` (`:48`), and `ResourceLinks` (`:51`). `RoomId` (`:54`) is the nullable room assignment, and it is the one member that triggers cross-aggregate work in the handler. Every member is `init`-only. +- **Why it's built this way**: nullable `StartsAt`/`EndsAt` are load-bearing rather than lazy. Sessions exist before the schedule is drawn, so "no time yet" must round-trip through the edit form without inventing a placeholder date; [`SessionRoomScheduling`](#sessionroomscheduling) reads the same nullability and simply skips the double-booking probe when either bound is missing (`MMCA.ADC.Conference.Application/Sessions/Validation/SessionRoomScheduling.cs:69-70`). Because the PUT is a full replacement, the handler can pass all fourteen editable fields straight into `Session.Update` (`MMCA.ADC.Conference.Domain/Sessions/Session.cs:229-243`) without ever distinguishing "omitted" from "cleared". +- **Where it's used**: bound from the body by [`SessionsController`](group-20-conference-api-grpc.md#sessionscontroller) on `PUT {id}` (`MMCA.ADC.Conference.API/Controllers/SessionsController.cs:323-327`), validated by [`SessionUpdateRequestValidator`](#sessionupdaterequestvalidator), wrapped in [`UpdateSessionCommand`](#updatesessioncommand) (`:330`), and consumed field by field by [`UpdateSessionHandler`](#updatesessionhandler). ### SpeakerUpdateRequest > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.Update` · `MMCA.ADC.Conference.Application/Speakers/UseCases/Update/SpeakerUpdateRequest.cs:8` · Level 1 · record -- **What it is**: the request DTO for changing an existing [`Speaker`](group-17-conference-domain.md#speaker): the two name fields, the profile text and links, and the curation flag, plus the concurrency token. -- **Depends on**: [`IConcurrencyAware`](group-12-api-hosting-mapping.md#iconcurrencyaware) from `MMCA.Common.Shared.DTOs` (`SpeakerUpdateRequest.cs:1,8`). Nothing else: every member is a BCL primitive, so the payload type carries no domain or framework reference. -- **Concept introduced**: **the field a request deliberately does not carry.** The record's `` (`:6-7`) states that it carries no linked-user field, because "the governed /link and /unlink endpoints (BR-208) are the only paths that change `Speaker.LinkedUserId`". This is a security decision expressed as an absence: because `LinkedUserId` is not bindable here, no crafted body can attach a speaker profile to someone else's account, and the uniqueness check plus the integration events that keep Identity's `User.LinkedSpeakerId` in sync stay on the one governed path. The complementary case is `IsTopSpeaker` (`:32`), which *is* present but is honored only for organizers: [`UpdateSpeakerHandler`](#updatespeakerhandler) discards it on a self-edit. `[Rubric §11, Security]` assesses whether privilege boundaries are enforced server-side rather than assumed from the client: one field is removed from the contract entirely and the other is filtered in the handler, so neither protection depends on the UI behaving. `[Rubric §9, API & Contract Design]`: the shape of the request is itself the statement of what a caller may change. -- **Walkthrough**: `RowVersion` (`:11`) is the nullable optimistic-concurrency token contributed by [`IConcurrencyAware`](group-12-api-hosting-mapping.md#iconcurrencyaware), carried back so the handler can detect a lost update. `FirstName` (`:14`) and `LastName` (`:17`) are the two `required string` fields, so the compiler refuses a partially built request. `Email` (`:20`), `Bio` (`:23`), `TagLine` (`:26`), and `ProfilePicture` (`:29`) are the optional profile fields. `IsTopSpeaker` (`:32`) is the organizer-only featured flag. The four social links, `TwitterHandle` (`:35`), `LinkedInUrl` (`:38`), `GitHubUrl` (`:41`), and `WebsiteUrl` (`:44`), are all optional strings. Every member is `init`-only, so the request is immutable once bound. -- **Why it's built this way**: `required` plus `init` is the codebase-wide immutability convention. Making both names `required` rather than nullable means the PUT is a full replacement, not a patch: the client always sends the complete state, so the handler never has to distinguish "not supplied" from "cleared". -- **Where it's used**: validated by [`SpeakerUpdateRequestValidator`](#speakerupdaterequestvalidator), wrapped by [`UpdateSpeakerCommand`](#updatespeakercommand), consumed by [`UpdateSpeakerHandler`](#updatespeakerhandler), and bound from the body by [`SpeakersController`](group-20-conference-api-grpc.md#speakerscontroller) (`MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:331`). +- **What it is**: the payload for editing an existing [`Speaker`](group-17-conference-domain.md#speaker): the name, the contact email, the biography and tagline, the profile picture, the four social and web links, and the featured-speaker flag. +- **Depends on**: [`IConcurrencyAware`](group-12-api-hosting-mapping.md#iconcurrencyaware) (`SpeakerUpdateRequest.cs:1,8`). Nothing else; all eleven payload members are `string`, `string?`, or `bool`. +- **Concept introduced**: **the field a request deliberately does not have.** A `Speaker` row owns a `LinkedUserId`, the pointer to the Identity user allowed to edit that speaker's own profile, and this record has no member for it. The remark on the type says why: the governed `/link` and `/unlink` endpoints (BR-208) are the only paths that change it (`:6-7`). Removing the field from the wire contract is a stronger guarantee than validating it, because there is no value to validate and no code path to forget: a crafted body simply has nowhere to put the claim. That matters here because linking is not a property assignment, it is a two-context transaction. [`UnlinkUserFromSpeakerHandler`](#unlinkuserfromspeakerhandler) raises [`SpeakerUnlinkedFromUser`](group-17-conference-domain.md#speakerunlinkedfromuser) on the aggregate before the save so the outbox row commits with the unlink (`MMCA.ADC.Conference.Application/Speakers/UseCases/UnlinkUser/UnlinkUserFromSpeakerHandler.cs:13-17`), and the Identity module clears `User.LinkedSpeakerId` from that event. A generic PUT could not carry that coordination, so the shape of the contract encodes the constraint instead. `[Rubric §11, Security]` assesses whether privilege-bearing state can be reached from an unprivileged path: the answer is strongest when the path does not exist. `[Rubric §9, API and Contract Design]`: a narrower request is a clearer contract, and here the omission is documented on the type rather than left to be inferred. +- **Walkthrough**: `RowVersion` (`:11`) is the concurrency token. `FirstName` (`:14`) and `LastName` (`:17`) are the two `required` members and the only two the validator guards. `Email` (`:20`), `Bio` (`:23`), `TagLine` (`:26`), and `ProfilePicture` (`:29`) are optional strings. `IsTopSpeaker` (`:32`) is the organizer curation flag: present in the contract, but only conditionally honored (see [`UpdateSpeakerCommand`](#updatespeakercommand) and [`UpdateSpeakerHandler`](#updatespeakerhandler)). `TwitterHandle` (`:35`), `LinkedInUrl` (`:38`), `GitHubUrl` (`:41`), and `WebsiteUrl` (`:44`) close out the links. All members are `init`-only. +- **Why it's built this way**: `Email` is a plain `string?` on the wire and becomes a validated value object inside the aggregate, not here. `Speaker.Update` calls `Email.Create` and returns its failure before touching any field (`MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:208-215`), so the format rule lives once in [`Email`](group-02-domain-building-blocks.md#email) and applies to every path that sets a speaker address, including the Sessionize import, rather than only to callers who happen to arrive through this record ([ADR-068](https://ivanball.github.io/docs/adr/068-value-objects-as-validated-primitives.html)). +- **Where it's used**: bound by [`SpeakersController`](group-20-conference-api-grpc.md#speakerscontroller) on `PUT {id}` (`MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:327-332`), validated by [`SpeakerUpdateRequestValidator`](#speakerupdaterequestvalidator), wrapped in [`UpdateSpeakerCommand`](#updatespeakercommand) together with the caller's role (`:341`), and consumed by [`UpdateSpeakerHandler`](#updatespeakerhandler). ### SponsorUpdateRequest > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sponsors.UseCases.Update` · `MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/SponsorUpdateRequest.cs:11` · Level 1 · record -- **What it is**: the request DTO for changing an existing conference [`Sponsor`](group-17-conference-domain.md#sponsor) or exhibitor: the display name, the tier, the branding and link fields, the display order, and the two expo-booth fields, plus the concurrency token. -- **Depends on**: [`IConcurrencyAware`](group-12-api-hosting-mapping.md#iconcurrencyaware) (`SponsorUpdateRequest.cs:2,11`) and [`SponsorTier`](group-17-conference-domain.md#sponsortier) from `MMCA.ADC.Conference.Shared.Sponsors` (`:1,20`). It is the one update request in this unit whose payload includes a domain enum rather than only primitives. -- **Concept**: the same "field deliberately absent" idea [`SpeakerUpdateRequest`](#speakerupdaterequest) teaches, applied to a commercial rather than a privacy concern. The record's `` (`:7-10`) records that the owning event is missing on purpose: "moving a sponsor between events is a create plus a delete, so a mistyped EventId cannot silently relocate bought placement." The create-side request does carry an event id and validates it (`MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestValidator.cs:12`), and the domain's own `Update` doc comment repeats the rule (`MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:140`), so all three layers agree that ownership is set once. `[Rubric §9, API & Contract Design]` assesses whether the contract expresses exactly what the server will do: an unchangeable relationship is simply not in the payload, so there is no field to ignore and no silent no-op to explain. `[Rubric §4, Domain-Driven Design]`: the sponsor-to-event relationship is treated as identity-bearing, not as an editable attribute. -- **Walkthrough**: `RowVersion` (`:14`) is the concurrency token. `Name` (`:17`) is the single `required string`. `Tier` (`:20`) is the [`SponsorTier`](group-17-conference-domain.md#sponsortier) enum, whose numeric values double as the public display order (`MMCA.ADC.Conference.Shared/Sponsors/SponsorTier.cs:12-25`); because `Platinum = 0` is the zero member, an omitted tier binds to the top package. `LogoUrl` (`:23`), `Description` (`:26`), `WebsiteUrl` (`:29`), `LinkedInUrl` (`:32`), and `TwitterHandle` (`:35`) are the optional branding and link fields. `Sort` (`:38`) is the order within the tier, so ranking is two-level (tier first, then sort). `IsExhibitor` (`:41`) marks a sponsor who also staffs an expo booth and `BoothNumber` (`:44`) is that booth's optional label. Every member is `init`-only. -- **Why it's built this way**: one flat request covers both a pure sponsor and an exhibitor rather than splitting the two into separate contracts, because the domain models exhibiting as a flag on the same aggregate (`Sponsor.cs:52`) and keeps the booth number even when the flag is off (`Sponsor.cs:55`). Sending every editable field on each PUT keeps the update a full replacement, matching the other update requests in this module. -- **Where it's used**: validated by [`SponsorUpdateRequestValidator`](#sponsorupdaterequestvalidator), wrapped by [`UpdateSponsorCommand`](#updatesponsorcommand), consumed by [`UpdateSponsorHandler`](#updatesponsorhandler), and bound from the body by [`SponsorsController`](group-20-conference-api-grpc.md#sponsorscontroller) (`MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:227`). -- **Caveats / not-in-source**: `Tier` carries no validator clause (see [`SponsorUpdateRequestValidator`](#sponsorupdaterequestvalidator)), so what happens to an out-of-range numeric tier is decided by the JSON binder rather than by anything in these files. Not determinable from source: whether an undefined `SponsorTier` value is rejected before it reaches the aggregate. +- **What it is**: the payload for editing an existing [`Sponsor`](group-17-conference-domain.md#sponsor) or exhibitor: display name, tier, logo, blurb, three outward links, the in-tier display order, and the two expo-booth fields. +- **Depends on**: [`IConcurrencyAware`](group-12-api-hosting-mapping.md#iconcurrencyaware) from `MMCA.Common.Shared.DTOs` (`SponsorUpdateRequest.cs:2,11`) and the [`SponsorTier`](group-17-conference-domain.md#sponsortier) enum from `MMCA.ADC.Conference.Shared.Sponsors` (`:1,20`). It is the only one of the three update requests in this unit that carries a domain enum. +- **Concept introduced**: **omission as the immutability mechanism, and why the stakes decide which mechanism you pick.** The remark on the type is explicit: the owning event is deliberately absent, because moving a sponsor between events is a create plus a delete, "so a mistyped EventId cannot silently relocate bought placement" (`:7-10`). Compare [`SessionUpdateRequest`](#sessionupdaterequest), which keeps `EventId` and has the handler reject a mismatch. Both prevent the move; they differ in what happens to a wrong value. Carry-and-verify turns a bad id into an error the client sees. Omission makes a bad id unrepresentable, at the cost of forcing a legitimate move through two operations. The module picks omission exactly where the record represents something sold: a sponsor's placement is inventory an organizer was paid for, and a create-plus-delete leaves an audit trail that a silent field edit does not. Note the follow-on effect on validation: [`SponsorCreateRequestValidator`](#sponsorcreaterequestvalidator) includes `SponsorEventIdRules` (`MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestValidator.cs:12`, rule at `MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:98-104`) and [`SponsorUpdateRequestValidator`](#sponsorupdaterequestvalidator) cannot, because there is no property to select. `[Rubric §9, API and Contract Design]` assesses how a contract expresses what may change; `[Rubric §30, Compliance and Data Governance]`: for records with commercial consequences, an operation that leaves two auditable rows beats one that mutates a field in place. +- **Walkthrough**: `RowVersion` (`:14`) is the concurrency token, and `Name` (`:17`) is the single `required` member. `Tier` (`:20`) is the [`SponsorTier`](group-17-conference-domain.md#sponsortier) enum. `LogoUrl` (`:23`), `Description` (`:26`), `WebsiteUrl` (`:29`), `LinkedInUrl` (`:32`), and `TwitterHandle` (`:35`) are the optional presentation fields. `Sort` (`:38`) is the display order within the tier, guarded to be non-negative by the validator. `IsExhibitor` (`:41`) and `BoothNumber` (`:44`) are the expo-floor pair: the flag says the sponsor staffs a booth, the string names it. All members are `init`-only. +- **Caveats / not-in-source**: nothing in this record, or in [`SponsorUpdateRequestValidator`](#sponsorupdaterequestvalidator), requires `BoothNumber` to be present when `IsExhibitor` is true or absent when it is false. Whether that pairing is enforced anywhere is not determinable from this type; the domain's own guard on the field is a length check only (`MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:168`). +- **Where it's used**: bound by [`SponsorsController`](group-20-conference-api-grpc.md#sponsorscontroller) on `PUT {id}` (`MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:223-228`), validated by [`SponsorUpdateRequestValidator`](#sponsorupdaterequestvalidator), wrapped in [`UpdateSponsorCommand`](#updatesponsorcommand) (`:231`), and consumed by [`UpdateSponsorHandler`](#updatesponsorhandler). ### RoomChangedHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.DomainEventHandlers` · `MMCA.ADC.Conference.Application/Events/DomainEventHandlers/RoomChangedHandler.cs:11` · Level 3 · class +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.DomainEventHandlers` · `MMCA.ADC.Conference.Application/Events/DomainEventHandlers/RoomChangedHandler.cs:11` · Level 3 · class (sealed partial) + +- **What it is**: the in-process handler for [`RoomChanged`](group-17-conference-domain.md#roomchanged). It writes one structured log line per room lifecycle transition and does nothing else. +- **Depends on**: [`IDomainEventHandler`](group-04-events-outbox.md#idomaineventhandlerin-tdomainevent) from `MMCA.Common.Application.Interfaces` (`RoomChangedHandler.cs:3,12`), the [`RoomChanged`](group-17-conference-domain.md#roomchanged) event it closes over (`:2,12`), [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate) from `MMCA.Common.Domain.Enums` (`:4,22`), and `ILogger` plus the `[LoggerMessage]` source generator from `Microsoft.Extensions.Logging` (`:1,21`). +- **Concept introduced**: **the unfiltered lifecycle handler, and the source-generated log message.** This is the simplest possible subscriber under the one-event-per-entity taxonomy: rather than testing `domainEvent.State`, it passes the discriminator into the message template as a value (`:17,21`), so a single handler covers add, update, and remove and the log line names which one happened. [ADR-083](https://ivanball.github.io/docs/adr/083-crud-lifecycle-event-taxonomy.html) records that choice explicitly, noting that a handler wanting every transition writes no filter and logs the discriminator instead. The second mechanism is `[LoggerMessage]` (`:21`): the attribute makes the compiler generate the body of the `partial` method `LogRoomChanged` (`:22`), which is why the class is declared `sealed partial` (`:11`). The generated code avoids boxing the arguments and skips formatting entirely when the `Information` level is disabled, so the call site is close to free when the log is off. It is also why the parameters keep their real types (`DomainEntityState`, the two identifier aliases, `string`) instead of being flattened into an interpolated string: each becomes a named field in the structured log, so an operator can query on `RoomId` rather than grep on text. `[Rubric §13, Observability and Operability]` assesses whether the system emits queryable, structured signals at meaningful transitions; `[Rubric §12, Performance and Scalability]`: the generator exists precisely so observability does not cost allocations on a path that runs on every write. +- **Walkthrough**: the primary constructor takes only `ILogger` (`:12`). `HandleAsync` (`:15-19`) is synchronous in substance: it calls `LogRoomChanged` with the event's four members (`:17`) and returns `Task.CompletedTask` (`:18`) rather than being marked `async`, which avoids allocating a state machine for a method that never awaits. `LogRoomChanged` (`:21-22`) declares the template `"Room {State}: EventId={EventId}, RoomId={RoomId}, Name={RoomName}"`. +- **Why it's built this way**: [`RoomChanged`](group-17-conference-domain.md#roomchanged) inherits [`BaseDomainEvent`](group-04-events-outbox.md#basedomainevent) directly rather than [`EntityChangedEvent`](group-04-events-outbox.md#entitychangedeventtidentifiertype), because a [`Room`](group-17-conference-domain.md#room) is a child and the interesting identity is the pair `EventId` plus `RoomId` (`MMCA.ADC.Conference.Domain/Events/DomainEvents/RoomChanged.cs:13-18`; [ADR-083](https://ivanball.github.io/docs/adr/083-crud-lifecycle-event-taxonomy.html) names this exact case). The handler's template mirrors that pair, so a log line identifies the parent as well as the child. +- **Where it's used**: registered as a singleton by the module's assembly scan, which finds every `IDomainEventHandler<>` implementation (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:144-148`), and invoked after a successful save by the [`DomainEventSaveChangesInterceptor`](group-07-persistence-ef-core.md#domaineventsavechangesinterceptor). The [`Event`](group-17-conference-domain.md#event) aggregate raises the event from four sites: adding a room (`MMCA.ADC.Conference.Domain/Events/Event.cs:395`), updating one (`:433`), restoring a soft-deleted one (`:501`), and removing one (`:522`). +- **Caveats / not-in-source**: the restore path raises `Added` (`Event.cs:501`), so an `Added` line in the log does not prove a new row was inserted; it can equally mean a previously soft-deleted room was reactivated in place. + +### UpdateSessionResult + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.Update` · `MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionCommand.cs:19` · Level 3 · record -- **What it is**: the domain event handler for [`RoomChanged`](group-17-conference-domain.md#roomchanged). It does exactly one thing: write a structured log line describing what happened to the room. -- **Depends on**: [`IDomainEventHandler`](group-04-events-outbox.md#idomaineventhandlerin-tdomainevent) from `MMCA.Common.Application.Interfaces` (`RoomChangedHandler.cs:3,12`), [`RoomChanged`](group-17-conference-domain.md#roomchanged) (`:2,12`), [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate) (`:4,22`), and `ILogger` (`Microsoft.Extensions.Logging`, `:1,12`). -- **Concept introduced**: **the observation-only domain event handler.** Domain event handlers are discovered by assembly scanning and registered as **singletons** during `ScanModuleApplicationServices`, then invoked by the framework's dispatcher after `SaveChangesAsync` (see [Group 04](group-04-events-outbox.md#idomaineventhandlerin-tdomainevent)). The aggregate that raised the event knows nothing about who listens. This particular handler is the simplest possible shape: no injected repository, no DI scope, no side effect beyond a log record, and a synchronous body returning `Task.CompletedTask` (`:18`) rather than an `async` method. That is worth learning as a baseline, because the next two handlers in this unit ([`SessionCreatedHandler`](#sessioncreatedhandler) and [`SpeakerDeletedHandler`](#speakerdeletedhandler)) each add exactly one thing on top of it. `[Rubric §6, CQRS & Event-Driven]` assesses whether behavior reacts to events instead of being wired into the writer: the room mutation path has no idea this log line exists. `[Rubric §13, Observability & Operability]`: the `[LoggerMessage]` source generator produces a compile-time log method with no boxing and no runtime format parsing. -- **Walkthrough**: the class is `sealed partial` with a primary constructor taking only `ILogger` (`:11-12`). `HandleAsync` (`:15`) calls `LogRoomChanged` with four fields off the event, `State`, `EventId`, `RoomId`, and `RoomName` (`:17`), then returns `Task.CompletedTask` (`:18`). The `[LoggerMessage]` declaration (`:21-22`) pins `LogLevel.Information` and the template "Room {State}: EventId={EventId}, RoomId={RoomId}, Name={RoomName}"; note that `State` is a message field rather than a branch, so one handler covers add, update, and delete without a state guard. -- **Why it's built this way**: `partial` plus `[LoggerMessage]` is the codebase-wide high-performance logging convention. Keeping the state in the template (instead of three handlers or an `if`) is the right call when every state deserves the same treatment. -- **Where it's used**: auto-discovered as a singleton by `ScanModuleApplicationServices` and invoked by the framework's domain event dispatcher after a [`Room`](group-17-conference-domain.md#room) change is saved. +- **What it is**: the two-member envelope [`UpdateSessionHandler`](#updatesessionhandler) returns: the updated [`SessionDTO`](group-17-conference-domain.md#sessiondto) plus a boolean saying whether the new session times fall outside the parent event's date range. +- **Depends on**: [`SessionDTO`](group-17-conference-domain.md#sessiondto) from `MMCA.ADC.Conference.Shared.Sessions` (`UpdateSessionCommand.cs:2,19`). The second member is a `bool`. +- **Concept introduced**: none new. This is the advisory-result shape taught at [`UpdateEventResult`](#updateeventresult), applied to a second rule: BR-86 rather than BR-131. What is worth carrying forward is that the shape recurs, which is what makes it a pattern rather than a one-off. Both cases share the same class of problem: the write is legal and must be persisted, but it leaves the data in a state a human should look at. Scheduling a session outside the conference dates is not an invariant violation (the organizer may be mid-edit, or the event dates may be about to move), so failing the request would be wrong, and swallowing it would leave a session nobody can attend. `[Rubric §9, API and Contract Design]` assesses how non-fatal conditions travel: [`SessionsController`](group-20-conference-api-grpc.md#sessionscontroller) reads the flag, appends an `X-Warning` response header, and still returns 200 with only the DTO in the body (`MMCA.ADC.Conference.API/Controllers/SessionsController.cs:336-343`), so the envelope itself never reaches the wire. `[Rubric §13, Observability and Operability]`: the warning reaches the person who caused it, at the moment they caused it. +- **Walkthrough**: one line, `sealed record UpdateSessionResult(SessionDTO Session, bool HasDateRangeWarning)` (`:19`), documented on the declaration (`:16-18`). No methods, no behavior; it exists to name a pair. +- **Why it's built this way**: it shares a file with [`UpdateSessionCommand`](#updatesessioncommand) (`:10`) because the two are one use case's input and output and are never referenced apart. Keeping the flag out of [`SessionDTO`](group-17-conference-domain.md#sessiondto) is the load-bearing part: `HasDateRangeWarning` is a fact about this particular write, not a property of the session, so it must never be persisted, cached, or returned by a read endpoint. +- **Where it's used**: constructed by [`UpdateSessionHandler`](#updatesessionhandler) (`UpdateSessionHandler.cs:97`), named in the handler's own interface (`:20`) and in the controller's injected handler type (`MMCA.ADC.Conference.API/Controllers/SessionsController.cs:45`), and unwrapped by the controller, which reads `result.Value!.HasDateRangeWarning` (`:337`) and then returns `result.Value.Session` (`:343`). +- **Caveats / not-in-source**: the create path computes the same BR-86 warning in the controller instead, by re-reading the event through the query service (`SessionsController.cs:302-316`), so the two paths reach the same header by different routes. Only the update path routes it through a result envelope. ### SessionCreatedHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.DomainEventHandlers` · `MMCA.ADC.Conference.Application/Sessions/DomainEventHandlers/SessionCreatedHandler.cs:11` · Level 4 · class +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.DomainEventHandlers` · `MMCA.ADC.Conference.Application/Sessions/DomainEventHandlers/SessionCreatedHandler.cs:11` · Level 4 · class (sealed partial) -- **What it is**: the domain event handler that logs session creation. It subscribes to the single [`SessionChanged`](group-17-conference-domain.md#sessionchanged) event and acts only when the state is `Added`. -- **Depends on**: [`IDomainEventHandler`](group-04-events-outbox.md#idomaineventhandlerin-tdomainevent) (`SessionCreatedHandler.cs:3,12`), [`SessionChanged`](group-17-conference-domain.md#sessionchanged) (`:2,12`), [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate) (`:4,17`), and `ILogger` (`:1,12`). -- **Concept introduced**: **one event type, many handlers, routed by state.** The Conference domain does not raise `SessionCreated` / `SessionUpdated` / `SessionDeleted` as three event types. It raises one [`SessionChanged`](group-17-conference-domain.md#sessionchanged) carrying a [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate) discriminator, and each handler opens with a guard that returns early for the states it does not care about (`:17-18`). The trade-off is deliberate: a single event type keeps the aggregate's `AddDomainEvent` calls uniform and lets a handler that genuinely wants every state (like [`RoomChangedHandler`](#roomchangedhandler)) skip the branch entirely, at the cost of every specialized handler paying for a guard and being invoked on states it ignores. `[Rubric §6, CQRS & Event-Driven]`: the aggregate publishes what changed, and each consumer decides what is interesting. `[Rubric §13, Observability & Operability]`: the `[LoggerMessage]` source generator gives a zero-allocation log path. -- **Walkthrough**: `sealed partial class` with an `ILogger` primary constructor (`:11-12`). `HandleAsync` (`:15`) first checks `domainEvent.State != DomainEntityState.Added` and returns `Task.CompletedTask` when the state is anything else (`:17-18`). On an add it calls `LogSessionCreated` with `SessionId`, `Title`, and `EventId` (`:20`) and returns a completed task (`:21`). The `[LoggerMessage]` (`:24-25`) pins `Information` and the template "Session created: SessionId={SessionId}, Title={SessionTitle}, EventId={EventId}". Note the log-property name is `SessionTitle` even though it is fed from `domainEvent.Title`: the structured log key is chosen for searchability across the whole telemetry stream, not to mirror the source property. -- **Why it's built this way**: the guard costs one enum comparison, which is cheaper than a per-state event hierarchy would cost in aggregate code. Because the handler is synchronous and stateless, registering it as a singleton is safe. -- **Where it's used**: auto-discovered as a singleton by `ScanModuleApplicationServices`; fires on every [`SessionChanged`](group-17-conference-domain.md#sessionchanged) dispatch and does work only on creations. +- **What it is**: the handler that subscribes to [`SessionChanged`](group-17-conference-domain.md#sessionchanged) and logs a line only when the transition was a creation. Every other state is ignored. +- **Depends on**: [`IDomainEventHandler`](group-04-events-outbox.md#idomaineventhandlerin-tdomainevent) (`SessionCreatedHandler.cs:3,12`), [`SessionChanged`](group-17-conference-domain.md#sessionchanged) (`:2,12`), [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate) (`:4,17`), and `ILogger` with `[LoggerMessage]` (`:1,24`). +- **Concept introduced**: **the state filter, and why the type name and the subscription differ.** The class is called `SessionCreatedHandler` but it implements `IDomainEventHandler` (`:12`), because there is no `SessionCreated` event to subscribe to: under [ADR-083](https://ivanball.github.io/docs/adr/083-crud-lifecycle-event-taxonomy.html) a [`Session`](group-17-conference-domain.md#session) raises one event type from all three lifecycle sites (`MMCA.ADC.Conference.Domain/Sessions/Session.cs:206` for `Added`, `:267` for `Updated`, `:304` for `Deleted`), and a subscriber that cares about one transition narrows on the discriminator itself. The guard is the first statement of the method: `if (domainEvent.State != DomainEntityState.Added) return Task.CompletedTask;` (`:17-18`). The ADR cites these exact lines as the canonical example of the pattern. Two practical consequences are worth internalizing. First, the handler is invoked for every session write in the system, so the filter has to be cheap and has to come first, before any logging or I/O. Second, "handler ran" and "handler acted" are now different facts, which is why a test for this type has to assert the no-op case as well as the acted case. Compare [`RoomChangedHandler`](#roomchangedhandler), which takes the other branch of the same fork and logs every transition. `[Rubric §6, CQRS and Event-Driven]` assesses whether write-side effects are expressed as subscriptions to explicit events rather than as inline calls; `[Rubric §16, Maintainability]`: adding a fourth transition later means adding a raise site, not editing three handler contracts. +- **Walkthrough**: the primary constructor takes only `ILogger` (`:12`). `HandleAsync` (`:15-22`) filters on state (`:17-18`), then calls the generated `LogSessionCreated` with the session id, the title, and the parent event id (`:20`) and returns `Task.CompletedTask` (`:21`). `LogSessionCreated` (`:24-25`) carries the template `"Session created: SessionId={SessionId}, Title={SessionTitle}, EventId={EventId}"`. Note the near-miss in naming: the template placeholder is `SessionTitle` while the event member is `Title`, and the generated structured field follows the method parameter, so the log records `SessionTitle`. +- **Why it's built this way**: logging the parent `EventId` beside the session id is what makes the line useful in a deployment that hosts several conference editions, and it is available only because [`SessionChanged`](group-17-conference-domain.md#sessionchanged) carries `EventId` as a member (`MMCA.ADC.Conference.Domain/Sessions/DomainEvents/SessionChanged.cs:13-18`) rather than forcing the handler to read the session back out of the database. +- **Where it's used**: registered as a singleton by the module assembly scan (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:144-148`) and dispatched after a successful save by the [`DomainEventSaveChangesInterceptor`](group-07-persistence-ef-core.md#domaineventsavechangesinterceptor). In practice it fires for sessions created by [`CreateSessionHandler`](#createsessionhandler) and for every session the Sessionize import inserts. ### SpeakerDeletedHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.DomainEventHandlers` · `MMCA.ADC.Conference.Application/Speakers/DomainEventHandlers/SpeakerDeletedHandler.cs:20` · Level 4 · class +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.DomainEventHandlers` · `MMCA.ADC.Conference.Application/Speakers/DomainEventHandlers/SpeakerDeletedHandler.cs:20` · Level 4 · class (sealed partial) -- **What it is**: the handler for [`SpeakerChanged`](group-17-conference-domain.md#speakerchanged) in the `Deleted` state (BR-70). It logs the soft-delete and, when the speaker had been linked to a user, publishes a [`SpeakerUnlinkedFromUser`](group-17-conference-domain.md#speakerunlinkedfromuser) integration event so the Identity module can clear that user's `LinkedSpeakerId`. -- **Depends on**: [`IDomainEventHandler`](group-04-events-outbox.md#idomaineventhandlerin-tdomainevent) (`SpeakerDeletedHandler.cs:5,22`), [`SpeakerChanged`](group-17-conference-domain.md#speakerchanged) (`:3,22`), [`SpeakerUnlinkedFromUser`](group-17-conference-domain.md#speakerunlinkedfromuser) (`:4,43`), [`IEventBus`](group-04-events-outbox.md#ieventbus) resolved from a child scope (`:41`), [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate) (`:6,29`), `IServiceScopeFactory` (`Microsoft.Extensions.DependencyInjection`, `:1,21`), and `ILogger` (`:2,22`). -- **Concept introduced**: **two things at once, and both matter.** First, **the domain event that raises an integration event.** A domain event is in-process and Conference-local; an integration event crosses a module (and, in ADC's extracted topology, a process) boundary. This handler is the bridge: the Speaker aggregate raises a local `SpeakerChanged`, and the handler translates it into a durable cross-service fact. Its own doc comment (`:14-18`) records that this **replaces a previous direct call into `IUserSpeakerLinkService.ClearLinkedSpeakerAsync`**, so the cleanup is now eventually consistent: Identity processes the event asynchronously through the broker, or in-process via the outbox in monolith mode. Publishing through [`IEventBus`](group-04-events-outbox.md#ieventbus) means the event is persisted with the aggregate change and delivered by the outbox processor ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)), so a broker outage delays but does not lose the unlink. `[Rubric §7, Microservices Readiness]` assesses whether cross-module coupling is asynchronous and transport-agnostic: replacing the direct service call with an event is precisely the change that lets Identity and Conference run as separate processes ([ADR-008](https://ivanball.github.io/docs/adr/008-service-extraction-topology.html)). `[Rubric §29, Resilience & Business Continuity]`: the outbox makes the cross-service cleanup retryable. - Second, **the singleton-to-scoped bridge.** Domain event handlers are registered as singletons, but [`IEventBus`](group-04-events-outbox.md#ieventbus) is scoped (it writes through the request's unit of work). Injecting a scoped service into a singleton constructor is a captive-dependency bug. The handler avoids it by taking `IServiceScopeFactory` instead and opening a fresh `await using` scope at the moment of use (`:40`), with the source comment stating the reason outright: "Uses a separate DI scope because the handler is a singleton" (`:36-37`). Learn this shape: it is the correct answer whenever singleton-lifetime code needs scoped work. -- **Walkthrough**: the class is `sealed partial` with a two-argument primary constructor, `IServiceScopeFactory` and `ILogger` (`:20-22`). `HandleAsync` is genuinely `async` here (`:25`), unlike its two siblings above. It null-guards the event with `ArgumentNullException.ThrowIfNull` (`:27`), then returns early unless `State == DomainEntityState.Deleted` (`:29-30`). It logs `SpeakerId`, `FullName`, and `PreviousLinkedUserId` (`:32`). The publish is conditional on `domainEvent.PreviousLinkedUserId.HasValue` (`:38`): a speaker who was never linked to a user produces no integration event at all. When there is a link, it opens the scope (`:40`), resolves [`IEventBus`](group-04-events-outbox.md#ieventbus) (`:41`), and publishes `new SpeakerUnlinkedFromUser(PreviousLinkedUserId.Value, SpeakerId)` with `ConfigureAwait(false)` (`:42-44`). The `[LoggerMessage]` (`:48-49`) pins `Information` and the template "Speaker soft-deleted: SpeakerId={SpeakerId}, Name={SpeakerName}, PreviousLinkedUserId={PreviousLinkedUserId}". -- **Why it's built this way**: the event carries `PreviousLinkedUserId` because, as the source comment records (`:34-36`), `LinkedUserId` was already cleared on the Speaker entity during `Delete()` in the Conference context. By the time the handler runs, the current entity no longer knows who it was linked to, so the domain event must have captured it. That is a general lesson about state-carrying events: capture what the consumer needs at raise time, because the aggregate has already moved on. -- **Where it's used**: auto-discovered as a singleton; fires on every [`SpeakerChanged`](group-17-conference-domain.md#speakerchanged) dispatch and acts only on soft-deletes. The published event is consumed on the Identity side (see [Group 24](group-24-identity-module.md)). -- **Caveats / not-in-source**: the word "soft-deleted" in the log template reflects the codebase-wide soft-delete convention; this file does not itself set `IsDeleted`, so the fact that the delete is soft is established in the Speaker aggregate, not here. +- **What it is**: the handler for [`SpeakerChanged`](group-17-conference-domain.md#speakerchanged) in the `Deleted` state (BR-70). It logs the soft delete and, when the speaker had a linked user, publishes [`SpeakerUnlinkedFromUser`](group-17-conference-domain.md#speakerunlinkedfromuser) so the Identity module can clear that user's `LinkedSpeakerId`. +- **Depends on**: [`IDomainEventHandler`](group-04-events-outbox.md#idomaineventhandlerin-tdomainevent) (`SpeakerDeletedHandler.cs:5,22`), [`SpeakerChanged`](group-17-conference-domain.md#speakerchanged) (`:3,22`), [`SpeakerUnlinkedFromUser`](group-17-conference-domain.md#speakerunlinkedfromuser) from `MMCA.ADC.Conference.Shared.Speakers.IntegrationEvents` (`:4,43`), [`IEventBus`](group-04-events-outbox.md#ieventbus) resolved at runtime rather than injected (`:41`), [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate) (`:6,29`), and `IServiceScopeFactory` from `Microsoft.Extensions.DependencyInjection` (`:1,21`). +- **Concept introduced**: **crossing a module boundary with an event instead of a call, and the singleton-to-scoped lifetime bridge that makes it possible.** Two mechanisms are stacked here, and both repay slowing down for. + - *The boundary crossing.* Deleting a Conference speaker leaves a dangling pointer in the Identity module's `User.LinkedSpeakerId`. The direct fix would be a call into an Identity service, which is exactly what this handler's doc comment says it replaces (`:14-18`). Instead the handler publishes an integration event and lets Identity react on its own schedule: [`SpeakerUnlinkedFromUserHandler`](group-24-identity-module.md#speakerunlinkedfromuserhandler) subscribes and clears the field. The consequence is that the two sides are eventually consistent, not atomically consistent, and the doc states that plainly (`:16-18`). That trade is what lets Conference and Identity run as separate services without a code change: in the monolith the publish goes through [`InProcessEventBus`](group-04-events-outbox.md#inprocesseventbus) and the outbox, and when the modules are split the same event travels over the broker ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html), [ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html)). `[Rubric §7, Microservices Readiness]` assesses whether cross-module work is already expressed in a transport-agnostic way; `[Rubric §6, CQRS and Event-Driven]`: a domain event internal to one context is translated into an integration event at the boundary rather than leaking as-is. + - *The lifetime bridge.* Domain event handlers are registered as **singletons** (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:143-148`, whose comment says they create their own DI scopes internally), while [`IEventBus`](group-04-events-outbox.md#ieventbus) needs a scoped `DbContext` to write its outbox row. Injecting a scoped service into a singleton constructor is the classic captive-dependency bug: the first scope's context would be pinned for the lifetime of the process. The handler therefore takes `IServiceScopeFactory` (`:21`) and opens a scope per invocation with `await using var scope = scopeFactory.CreateAsyncScope()` (`:40`), resolving [`IEventBus`](group-04-events-outbox.md#ieventbus) from inside it (`:41`). `await using` matters because the scope owns async-disposable services. `[Rubric §15, Best Practices and Code Quality]` assesses lifetime correctness; `[Rubric §1, SOLID]`: the handler depends on the abstraction for creating a scope, not on a service locator it could misuse elsewhere. +- **Walkthrough**: the primary constructor takes `IServiceScopeFactory` and `ILogger` (`:20-22`). `HandleAsync` (`:25-46`) starts with `ArgumentNullException.ThrowIfNull(domainEvent)` (`:27`), then filters to `Deleted` and returns otherwise (`:29-30`). It logs first (`:32`), so the soft delete is recorded whether or not a link existed. The publish is guarded by `if (domainEvent.PreviousLinkedUserId.HasValue)` (`:38`): a speaker who was never linked produces no integration event and therefore no work for Identity. Inside the guard, the scope is created (`:40`), [`IEventBus`](group-04-events-outbox.md#ieventbus) is resolved (`:41`), and `PublishAsync` sends `new SpeakerUnlinkedFromUser(domainEvent.PreviousLinkedUserId.Value, domainEvent.SpeakerId)` (`:42-44`). `LogSpeakerDeleted` (`:48-49`) is the generated `[LoggerMessage]` method carrying all three fields. +- **Why it's built this way**: the handler can only work because the event carries the *previous* value. [`Speaker`](group-17-conference-domain.md#speaker)`.Delete` captures `LinkedUserId` before clearing it and passes it into the event (`MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:253-263`), and the event's own doc says this exists so the cross-context cleanup can run after the field has already been cleared (`MMCA.ADC.Conference.Domain/Speakers/DomainEvents/SpeakerChanged.cs:12-15`). Without the captured value the handler would have to read a row whose link is already gone. Note also that the entity is only soft-deleted ([ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html)), so "deleted" here still leaves a speaker row behind. +- **Where it's used**: registered as a singleton by the assembly scan and dispatched by the [`DomainEventSaveChangesInterceptor`](group-07-persistence-ef-core.md#domaineventsavechangesinterceptor) after the delete has been persisted. Its output is consumed by [`SpeakerUnlinkedFromUserHandler`](group-24-identity-module.md#speakerunlinkedfromuserhandler) in the Identity module, registered as a broker consumer when the two run as separate services (`MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:300`). +- **Caveats / not-in-source**: the publish here is a **second** write, not part of the delete's transaction. Domain events are dispatched after the save completes, and under an ambient transaction after commit, by the interceptor (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:12-33`), and [`InProcessEventBus`](group-04-events-outbox.md#inprocesseventbus) then adds its own outbox row and calls `SaveChangesAsync` on its own (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/InProcessEventBus.cs:66-77`). Contrast [`UnlinkUserFromSpeakerHandler`](#unlinkuserfromspeakerhandler), which raises the same integration event on the aggregate *before* the save so the outbox row commits with the unlink, and whose doc comment names the two-commit hazard it is avoiding (`MMCA.ADC.Conference.Application/Speakers/UseCases/UnlinkUser/UnlinkUserFromSpeakerHandler.cs:13-17`). The delete path's backstop is the outbox row for `SpeakerChanged` itself, which stays unprocessed and is retried by the [`OutboxProcessor`](group-04-events-outbox.md#outboxprocessor) when the dispatch fails (`DomainEventSaveChangesInterceptor.cs:16-18`). Whether that backstop closes the window in every failure mode is not determinable from these files alone. -### AddCategoryItemCommand +### SessionUpdateRequestValidator -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.UseCases.AddCategoryItem` · `MMCA.ADC.Conference.Application/Categories/UseCases/AddCategoryItem/AddCategoryItemCommand.cs:14` · Level 7 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.Update` · `MMCA.ADC.Conference.Application/Sessions/UseCases/Update/SessionUpdateRequestValidator.cs:7` · Level 8 · class (sealed) -- **What it is**: the write intent for adding one child item to an existing conference [`Category`](group-17-conference-domain.md#category) aggregate. It carries the owning category id, an optional item id, the display name, and the sort order. -- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) from `MMCA.Common.Application.UseCases` (`AddCategoryItemCommand.cs:2,18`) and the [`Category`](group-17-conference-domain.md#category) domain type, referenced only to build the cache key prefix (`:1,21`). `ConferenceCategoryIdentifierType` and `CategoryItemIdentifierType` are the module identifier aliases (see [primer](00-primer.md#2-architectural-styles-this-codebase-commits-to)). -- **Concept introduced**: **the child-add command shape.** A positional `record` is the whole request: it holds no behavior, and the aggregate decides what the add means. Two details carry weight. First, `CategoryItemId` is nullable (`CategoryItemIdentifierType?`, `:16`), and the XML doc says why (`:11`): the Sessionize import supplies the source-assigned id, while a manual add leaves it `null` for database-generated identity. Second, implementing [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) opts the command into the caching decorator of the CQRS pipeline, so a successful add evicts the category read cache without the handler touching a cache API. `[Rubric §6, CQRS & Event-Driven]` assesses whether writes are explicit intents flowing through one uniform pipeline: this record is the intent, and the marker interface is how a cross-cutting concern attaches to it declaratively. `[Rubric §10, Cross-Cutting]`: cache eviction is declared on the contract and applied centrally, never hand-rolled per handler. -- **Walkthrough**: the positional parameters are `CategoryId` (`:15`), the nullable `CategoryItemId` (`:16`), `Name` (`:17`), and `Sort` (`:18`); the record is `sealed` and implements [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`:14,18`). The single member, `CachePrefix` (`:21`), returns `$"{typeof(Category).FullName}:"`, the key namespace the caching decorator wipes on success. Keying off `typeof(Category)` rather than `typeof(CategoryItem)` is deliberate: items are read as part of their parent, so the parent's cached reads are the stale ones. -- **Why it's built this way**: a positional record gives value equality and immutability for free, and deriving the prefix from the entity's `FullName` keeps producer (this command) and consumer (the query cache) agreed on one string with no shared constant to drift. -- **Where it's used**: constructed by [`CategoryItemsController`](group-20-conference-api-grpc.md#categoryitemscontroller) on `POST` from the bound request (`MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:126-130`), validated by [`AddCategoryItemCommandValidator`](#addcategoryitemcommandvalidator), and handled by [`AddCategoryItemHandler`](#addcategoryitemhandler). +- **What it is**: the FluentValidation validator for [`SessionUpdateRequest`](#sessionupdaterequest). It composes seven reusable session rule sets and adds nothing of its own. +- **Depends on**: `AbstractValidator` from FluentValidation (`SessionUpdateRequestValidator.cs:1,7`), [`SessionUpdateRequest`](#sessionupdaterequest), and seven rule sets from `MMCA.ADC.Conference.Application.Sessions.Validation` (`:2,11-17`): [`SessionTitleRules`](#sessiontitlerulest), [`SessionDescriptionRules`](#sessiondescriptionrulest), [`SessionStatusRules`](#sessionstatusrulest), [`SessionLiveUrlRules`](#sessionliveurlrulest), [`SessionRecordingUrlRules`](#sessionrecordingurlrulest), [`SessionAccessibilityInfoRules`](#sessionaccessibilityinforulest), and [`SessionResourceLinksRules`](#sessionresourcelinksrulest). +- **Concept introduced**: none new; this is `Include` composition (taught in [group 06](group-06-validation.md)) applied at its widest in this unit. What is worth reading here is the **diff against the create side**. [`SessionCreateRequestValidator`](#sessioncreaterequestvalidator) makes eight `Include` calls to this validator's seven, and the extra one is `SessionEventIdRules` (`MMCA.ADC.Conference.Application/Sessions/UseCases/Create/SessionCreateRequestValidator.cs:12`). The asymmetry is exactly the point made at [`SessionUpdateRequest`](#sessionupdaterequest): on create, `EventId` is the field that decides where the session lands, so "you must specify an Event for the Session" is a real field-level rule (`MMCA.ADC.Conference.Application/Sessions/Validation/SessionValidationRules.cs:24-30`). On update it is a value to be *compared*, not chosen, so a non-empty check would add nothing and the real guard is the handler's equality test. Every other rule is shared verbatim between the two paths, which is the payoff of packaging each field's contract as its own generic type rather than writing rules inline. `[Rubric §24, Forms, Validation and UX Safety]` assesses whether input constraints are single-sourced and consistently applied across entry paths; `[Rubric §1, SOLID]`: this class's only job is composition, so a title-rule change never has to be found in two files. +- **Walkthrough**: `sealed class SessionUpdateRequestValidator : AbstractValidator` (`:7`); the constructor (`:9-18`) is seven `Include` calls and nothing else. `Title` gets [`SessionTitleRules`](#sessiontitlerulest) (`:11`), which derives from [`RequiredStringRules`](group-06-validation.md#requiredstringrulest) and reads its bound from the domain's `SessionInvariants.TitleMaxLength` (`SessionValidationRules.cs:13-18`). The other six all derive from [`OptionalStringRules`](group-06-validation.md#optionalstringrulest) and are pure length bounds pulled from the same invariants class: description (`:12`), status (`:13`), live URL (`:14`), recording URL (`:15`), accessibility info (`:16`), and resource links (`:17`). Fields with no rule at all: `RowVersion`, `EventId`, `StartsAt`, `EndsAt`, the four booleans, and `RoomId`. +- **Why it's built this way**: the two URL fields are length-checked but not format-checked, and the rule sets say why on the type: the value is stored as an opaque string for Sessionize compatibility (`SessionValidationRules.cs:56-60,69-73`). An imported feed is the authority on what a live-stream link looks like, so rejecting a shape the upstream system accepts would break the import rather than protect anyone. Length bounds are read from [`SessionInvariants`](group-17-conference-domain.md#sessioninvariants) in the Domain layer, so the validator's message, the aggregate's guard, and the EF column width agree on one number. `[Rubric §16, Maintainability]`: widening a field is a one-constant change. +- **Caveats / not-in-source**: the start/end ordering rule is not here. `StartsAt` and `EndsAt` carry no validator rule; the ordering invariant is enforced inside the aggregate by `SessionInvariants.EnsureEndsAtIsAfterStartsAt`, called from `Session.Update` (`MMCA.ADC.Conference.Domain/Sessions/Session.cs:247`). A reader looking for "why was my end-before-start rejected" must look at the domain, not at this file. +- **Where it's used**: discovered by assembly scanning, reached through [`UpdateSessionCommand`](#updatesessioncommand)'s [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) implementation, and executed by the [`ValidatingCommandDecorator`](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult) before [`UpdateSessionHandler`](#updatesessionhandler) runs. -### CategoryItemDTOMapper +### SpeakerUpdateRequestValidator -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.DTOs` · `MMCA.ADC.Conference.Application/Categories/DTOs/CategoryItemDTOMapper.cs:12` · Level 7 · class +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.Update` · `MMCA.ADC.Conference.Application/Speakers/UseCases/Update/SpeakerUpdateRequestValidator.cs:7` · Level 8 · class (sealed) -- **What it is**: the Mapperly-generated mapper that turns a [`CategoryItem`](group-17-conference-domain.md#categoryitem) domain entity into its wire-facing [`CategoryItemDTO`](group-17-conference-domain.md#categoryitemdto). -- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) from `MMCA.Common.Application.Interfaces` (`CategoryItemDTOMapper.cs:3,13`), [`CategoryItem`](group-17-conference-domain.md#categoryitem) and [`CategoryItemDTO`](group-17-conference-domain.md#categoryitemdto) (`:1-2`), and the Mapperly source generator (`Riok.Mapperly.Abstractions`, NuGet, `:4`). -- **Concept introduced**: **source-generated DTO mapping ([ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html)).** The class is `sealed partial` and carries `[Mapper]` (`:11-12`); Mapperly writes the body of the `partial CategoryItemDTO MapToDTO(...)` declaration (`:16`) at compile time by name-matching properties, so there is no runtime reflection, no expression tree, and no hand-written field copy to fall behind the entity. A shape mismatch fails the build rather than a request. This is the split ADR-001 describes: property-name-parallel entity/DTO pairs get generated mappers, and only genuine mismatches need hand-written code. `[Rubric §9, API & Contract Design]` assesses whether the domain model is shielded from the wire contract: the entity and the DTO stay two separate shapes, so a domain rename cannot silently change the API payload. `[Rubric §12, Performance & Scalability]`: compile-time mapping costs no reflection at runtime. -- **Walkthrough**: `MapToDTO` (`:16`) is the generated single-entity conversion. `MapToDTOs` (`:19`) is hand-written because the interface asks for a collection overload: it null-guards with `ArgumentNullException.ThrowIfNull(entityCollection)` (`:21`), then projects each element through `MapToDTO` into a materialized array with the collection expression `[.. entityCollection.Select(MapToDTO)]` (`:22`). Materializing rather than returning a lazy sequence matters, because the caller may dispose the DbContext before enumeration. -- **Why it's built this way**: implementing the framework's [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) contract is what lets the mapper be discovered by assembly scanning and injected, so no caller ever `new`s it or hardcodes a conversion. -- **Where it's used**: composed as the child mapper inside [`ConferenceCategoryDTOMapper`](#conferencecategorydtomapper) (`ConferenceCategoryDTOMapper.cs:13-14,17-18`) and injected into [`AddCategoryItemHandler`](#addcategoryitemhandler) (`AddCategoryItemHandler.cs:17`) to shape the newly added item for the response. +- **What it is**: the validator for [`SpeakerUpdateRequest`](#speakerupdaterequest). Two `Include` calls, covering the first and last name. +- **Depends on**: `AbstractValidator` (`SpeakerUpdateRequestValidator.cs:1,7`), [`SpeakerUpdateRequest`](#speakerupdaterequest), and two rule sets from `MMCA.ADC.Conference.Application.Speakers.Validation` (`:2,11-12`): [`SpeakerFirstNameRules`](#speakerfirstnamerulest) and [`SpeakerLastNameRules`](#speakerlastnamerulest). +- **Concept introduced**: none new, but this is the clearest example in the unit of **a validator that is deliberately thin because the rules live deeper**. Nine of the eleven payload fields carry no rule here: `Email`, `Bio`, `TagLine`, `ProfilePicture`, `IsTopSpeaker`, and the four link fields. That is not an oversight to be filed as a gap. `Email` is validated where it becomes a value object, inside `Speaker.Update` via `Email.Create` (`MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:208-215`), so adding a duplicate rule here would create a second place to keep a format in agreement. `IsTopSpeaker` is not a shape question at all: whether the caller may set it is an authorization decision, made by [`UpdateSpeakerHandler`](#updatespeakerhandler) from the command's `CallerIsOrganizer` flag (`UpdateSpeakerHandler.cs:40`), and a validator has no access to the caller. The lesson generalizes: when you find a field with no rule, ask which of the three layers owns it (shape at the validator, invariant at the aggregate, privilege at the handler) before concluding it is unguarded. `[Rubric §24, Forms, Validation and UX Safety]` assesses coverage of inbound fields; `[Rubric §4, Domain-Driven Design]`: rules belong where the knowledge to enforce them lives. +- **Walkthrough**: `sealed class SpeakerUpdateRequestValidator : AbstractValidator` (`:7`); the constructor (`:9-13`) includes [`SpeakerFirstNameRules`](#speakerfirstnamerulest) against `FirstName` (`:11`) and [`SpeakerLastNameRules`](#speakerlastnamerulest) against `LastName` (`:12`). Both derive from [`RequiredStringRules`](group-06-validation.md#requiredstringrulest) and take their bounds from [`SpeakerInvariants`](group-17-conference-domain.md#speakerinvariants) (`MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:11-16,22-27`). +- **Why it's built this way**: this constructor is byte-for-byte the same two rules as [`SpeakerCreateRequestValidator`](#speakercreaterequestvalidator) (`MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequestValidator.cs:11-12`), differing only in the generic argument. Unlike sessions and sponsors, a speaker has no owning-event field, so create and update have identical field contracts and there is no diff to explain. +- **Where it's used**: registered by assembly scanning and run by the [`ValidatingCommandDecorator`](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult) ahead of [`UpdateSpeakerHandler`](#updatespeakerhandler), reached through [`UpdateSpeakerCommand`](#updatespeakercommand). -### AddCategoryItemCommandValidator +### SponsorUpdateRequestValidator -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.UseCases.AddCategoryItem` · `MMCA.ADC.Conference.Application/Categories/UseCases/AddCategoryItem/AddCategoryItemCommandValidator.cs:7` · Level 8 · class +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sponsors.UseCases.Update` · `MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/SponsorUpdateRequestValidator.cs:7` · Level 8 · class (sealed) -- **What it is**: the FluentValidation validator for [`AddCategoryItemCommand`](#addcategoryitemcommand), run by the pipeline before the add handler executes. It checks the new item's `Name` and `Sort`. -- **Depends on**: `AbstractValidator` (FluentValidation, NuGet, `AddCategoryItemCommandValidator.cs:1,7`) and two shared rule sets from the module's `Categories.Validation` namespace (`:2`), [`CategoryItemNameRules`](#categoryitemnamerulest) and [`CategoryItemSortRules`](#categoryitemsortrulest). -- **Concept introduced**: **rule-set composition via `Include`.** Rather than restating the item-field rules inline, the constructor folds two reusable, selector-parameterized rule objects into this validator: `Include(new CategoryItemNameRules(p => p.Name))` (`:11`) and `Include(new CategoryItemSortRules(p => p.Sort))` (`:12`). Each rule set is generic in the request type and takes a property selector, so one definition serves the add command, the update command, and the import path, each pointing the selector at its own property. The name rule enforces non-empty plus a bound sourced from `CategoryInvariants.CategoryItemNameMaxLength` (`MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:30-33`), and the sort rule enforces `GreaterThanOrEqualTo(0)` (`ConferenceCategoryValidationRules.cs:43-45`). `[Rubric §24, Forms/Validation/UX Safety]` assesses whether input constraints are declared once and applied consistently across entry paths. `[Rubric §1, SOLID]`: the item's field rules live in exactly one place, so a length or range change updates every command that edits those fields at once. -- **Walkthrough**: the constructor body (`:9-13`) is two `Include` calls and nothing else. Neither id field is validated: the owning category's existence is a database question, answered by [`AddCategoryItemHandler`](#addcategoryitemhandler) with an `Error.NotFound`, and uniqueness of the name within the category is a domain question, answered by the aggregate (BR-138). -- **Why it's built this way**: splitting the checks by who can answer them is the whole idea. Format rules that need no data run here, before the transaction opens; rules that need the aggregate run inside it. -- **Where it's used**: discovered by assembly scanning and invoked by the pipeline's validation decorator ahead of [`AddCategoryItemHandler`](#addcategoryitemhandler). +- **What it is**: the validator for [`SponsorUpdateRequest`](#sponsorupdaterequest). Eight `Include` calls covering the name, the sort order, and the six optional strings. +- **Depends on**: `AbstractValidator` (`SponsorUpdateRequestValidator.cs:1,7`), [`SponsorUpdateRequest`](#sponsorupdaterequest), and eight rule sets from `MMCA.ADC.Conference.Application.Sponsors.Validation` (`:2,11-18`): [`SponsorNameRules`](#sponsornamerulest), [`SponsorSortRules`](#sponsorsortrulest), [`SponsorLogoUrlRules`](#sponsorlogourlrulest), [`SponsorDescriptionRules`](#sponsordescriptionrulest), [`SponsorWebsiteUrlRules`](#sponsorwebsiteurlrulest), [`SponsorLinkedInUrlRules`](#sponsorlinkedinurlrulest), [`SponsorTwitterHandleRules`](#sponsortwitterhandlerulest), and [`SponsorBoothNumberRules`](#sponsorboothnumberrulest). +- **Concept introduced**: none new. The one thing to read is the **non-string rule in the set**: [`SponsorSortRules`](#sponsorsortrulest) (`:12`) is not a length bound, it is `GreaterThanOrEqualTo(0)` with the stable error code `Sponsor.Sort.Negative` (`MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:110-116`). Packaging a one-call numeric rule as a generic rule set pays off precisely because sponsors have two entry paths, create and update, and both include it. Note also what the enum member does *not* get: `Tier` has no rule, unlike the event module's update validator, which guards its enum with `IsInEnum` (compare [`EventUpdateRequestValidator`](#eventupdaterequestvalidator)). `[Rubric §24, Forms, Validation and UX Safety]` assesses whether every inbound field has a contract; on this record, an out-of-range integer cast to [`SponsorTier`](group-17-conference-domain.md#sponsortier) would bind and reach the aggregate, whose `Update` guards only the name, logo URL, and booth number (`MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:165-168`). +- **Walkthrough**: `sealed class SponsorUpdateRequestValidator : AbstractValidator` (`:7`); the constructor (`:9-19`) is eight `Include` calls: name (`:11`), sort (`:12`), logo URL (`:13`), description (`:14`), website URL (`:15`), LinkedIn URL (`:16`), Twitter handle (`:17`), and booth number (`:18`). [`SponsorNameRules`](#sponsornamerulest) derives from [`RequiredStringRules`](group-06-validation.md#requiredstringrulest) (`SponsorValidationRules.cs:13-18`); the six optional-string rules all derive from [`OptionalStringRules`](group-06-validation.md#optionalstringrulest) and read their bounds from [`SponsorInvariants`](group-17-conference-domain.md#sponsorinvariants). Fields with no rule: `RowVersion`, `Tier`, and `IsExhibitor`. +- **Why it's built this way**: [`SponsorCreateRequestValidator`](#sponsorcreaterequestvalidator) makes nine `Include` calls in the same order (`MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestValidator.cs:11-19`), and the extra one is `SponsorEventIdRules` (`:12`). That is the mechanical consequence of the design decision recorded on [`SponsorUpdateRequest`](#sponsorupdaterequest): with no `EventId` property there is no selector to pass, so the rule cannot be included even by accident. +- **Where it's used**: registered by assembly scanning, reached through [`UpdateSponsorCommand`](#updatesponsorcommand), and executed by the [`ValidatingCommandDecorator`](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult) before [`UpdateSponsorHandler`](#updatesponsorhandler). -### AddCategoryItemHandler +### UpdateSpeakerCommand -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.UseCases.AddCategoryItem` · `MMCA.ADC.Conference.Application/Categories/UseCases/AddCategoryItem/AddCategoryItemHandler.cs:15` · Level 8 · class +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.Update` · `MMCA.ADC.Conference.Application/Speakers/UseCases/Update/UpdateSpeakerCommand.cs:13` · Level 8 · record (sealed) -- **What it is**: the handler for [`AddCategoryItemCommand`](#addcategoryitemcommand): load the owning [`Category`](group-17-conference-domain.md#category), delegate the add to the root, persist, log, and return the new item's DTO. -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) (`AddCategoryItemHandler.cs:6,18`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:5,16`), [`CategoryItemDTOMapper`](#categoryitemdtomapper) (`:2,17`), [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error) (`:7`), and `ILogger` (`:1,18`). -- **Concept introduced**: **routing a child mutation through the aggregate root.** The handler never inserts a `CategoryItem` row; it loads the `Category` and calls `category.AddCategoryItem(...)` (`:30`), so the aggregate enforces its own consistency (the DDD boundary rule from [Group 02](group-02-domain-building-blocks.md)). What lives behind that one call is substantial: the case-insensitive name-uniqueness rule BR-138, the [`CategoryItem`](group-17-conference-domain.md#categoryitem) factory, and a `CategoryItemChanged` domain event (`MMCA.ADC.Conference.Domain/Categories/Category.cs:131-153`). The handler is correspondingly thin, because validation, caching, and the transaction are applied by the decorator pipeline around it (see [primer](00-primer.md#2-architectural-styles-this-codebase-commits-to) and [Group 05](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult)). `[Rubric §4, Domain-Driven Design]`: mutations go through the root. `[Rubric §13, Observability & Operability]`: the `[LoggerMessage]` source-generated log (`:41-42`) is compile-time and allocation-free. -- **Walkthrough**: `HandleAsync` (`:21-23`) gets the typed repository from the unit of work (`:25`) and loads the category with the plain `GetByIdAsync` (`:26`), with no eager include, because the aggregate root can add a child without materializing the existing collection. A `null` category returns `Error.NotFound` tagged with source and target for diagnostics (`:27-28`). It then calls `category.AddCategoryItem(command.CategoryItemId, command.Name, command.Sort)` (`:30`) and short-circuits with the aggregate's own errors on failure (`:31-32`). On success it awaits `SaveChangesAsync` with `ConfigureAwait(false)` (`:34`), emits `LogCategoryItemAdded` with the item name and category id (`:36`, `:41-42`), and returns `Result.Success(dtoMapper.MapToDTO(result.Value!))` (`:38`), mapping the very item the aggregate just created rather than re-reading it. -- **Why it's built this way**: the handler opens no transaction and evicts no cache itself; the transactional and caching decorators do that, driven by the marker interface on [`AddCategoryItemCommand`](#addcategoryitemcommand). That keeps the handler focused and the cross-cutting behavior uniform across every command in the module. -- **Where it's used**: injected into [`CategoryItemsController`](group-20-conference-api-grpc.md#categoryitemscontroller) as `ICommandHandler>` (`MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:63`) and invoked on `POST` (`CategoryItemsController.cs:125-131`). -- **Caveats / not-in-source**: `AddCategoryItem` returns the created entity, so the DTO is mapped from the in-memory instance. Any value the database assigns during `SaveChangesAsync` is reflected only because EF writes the generated key back onto that tracked instance; nothing in this handler re-queries. +- **What it is**: the write intent for updating a [`Speaker`](group-17-conference-domain.md#speaker). Unlike the other two update commands in this unit it has **three** members: the target id, the request payload, and a boolean saying whether the caller holds the Organizer role. +- **Depends on**: [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) and [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) from `MMCA.Common.Application.UseCases` (`UpdateSpeakerCommand.cs:2,16`), the [`SpeakerUpdateRequest`](#speakerupdaterequest) it wraps (`:15`), and the [`Speaker`](group-17-conference-domain.md#speaker) type used only for its `FullName` in the cache prefix (`:1,19`). +- **Concept introduced**: **putting the caller's authority *inside* the command, bound at the edge.** The id-plus-request shape and the validation-by-delegation wiring were taught at [`UpdateConferenceCategoryCommand`](#updateconferencecategorycommand); what is new here is the third parameter. `CallerIsOrganizer` (`:16`) is a fact about *who is asking*, not about *what is being asked*, and the doc comment states the invariant that makes it safe: it is "bound at the API edge, never from the request body" (`:9-10`). [`SpeakersController`](group-20-conference-api-grpc.md#speakerscontroller) computes it from the authenticated principal via [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) and passes it as a named argument (`MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:335,341`). Two design questions are settled by this shape. First, why not read the principal inside the handler? Because the Application layer would then depend on the ambient HTTP context, and the same handler has to work when invoked from a background job or a test. Passing authority as data keeps the handler a pure function of its command. Second, why a boolean rather than the whole principal? Because the handler needs exactly one bit, and narrowing at the boundary means the handler cannot accidentally start making other authorization decisions. The doc also records the consequence: when the flag is false (a BR-214 speaker self-edit) the handler ignores the organizer-only `IsTopSpeaker` field and keeps the stored value (`:10-12`). `[Rubric §11, Security]` assesses whether privilege decisions are made from trusted inputs: the role comes from the validated token, never from JSON. `[Rubric §3, Clean Architecture]`: the dependency on "who is calling" points inward as a value, not outward as an infrastructure reference. +- **Walkthrough**: three positional parameters, `Id` (`:14`), `Request` (`:15`), and `CallerIsOrganizer` (`:16`), with both marker interfaces implemented on the same declaration line (`:16`). `CachePrefix` (`:19`) is an expression-bodied property returning `$"{typeof(Speaker).FullName}:"`, the key namespace the [`CachingCommandDecorator`](group-05-cqrs-pipeline.md#cachingcommanddecoratortcommand-tresult) wipes after a successful handle. The positional `Request` parameter satisfies [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) with no extra code, which is what makes the auto-registered [`CommandRequestValidator`](group-06-validation.md#commandrequestvalidatortcommand-trequest) able to reach [`SpeakerUpdateRequestValidator`](#speakerupdaterequestvalidator) (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:192-209`). +- **Why it's built this way**: because `CallerIsOrganizer` is a positional record member, it is also part of the command's equality and its `ToString`, and it is visible to every decorator in the pipeline. That is a deliberate trade: the flag is not a secret, and having it on the command is what lets an operator see, in a logged command, which authority level performed an edit. Note the field is *not* validated: [`SpeakerUpdateRequestValidator`](#speakerupdaterequestvalidator) validates the `Request` property only, so nothing in the validation pipeline can contradict the edge's decision. +- **Where it's used**: constructed by [`SpeakersController`](group-20-conference-api-grpc.md#speakerscontroller) after its own BR-214 gate (`MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:335-341`) and handled by [`UpdateSpeakerHandler`](#updatespeakerhandler). -### ConferenceCategoryDTOMapper +### UpdateSessionCommand -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.DTOs` · `MMCA.ADC.Conference.Application/Categories/DTOs/ConferenceCategoryDTOMapper.cs:13` · Level 8 · class +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.Update` · `MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionCommand.cs:10` · Level 9 · record (sealed) -- **What it is**: the Mapperly mapper from the [`Category`](group-17-conference-domain.md#category) aggregate to its [`ConferenceCategoryDTO`](group-17-conference-domain.md#conferencecategorydto), including the child `CategoryItems` collection. -- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) (`ConferenceCategoryDTOMapper.cs:3,15`), the Mapperly generator (`:4`), and one injected child mapper, [`CategoryItemDTOMapper`](#categoryitemdtomapper) (`:13-14`). -- **Concept introduced**: **mapper composition with `[UseMapper]`.** This is the parent half of the two-mapper pair. The class is `sealed partial` with a primary constructor that takes the [`CategoryItemDTOMapper`](#categoryitemdtomapper) and stores it in a `[UseMapper]`-tagged private field (`:17-18`); that attribute tells Mapperly to call the child mapper for the nested collection instead of generating a second, parallel copy of the item mapping. Composition rather than regeneration is what keeps one entity/DTO pair mapped in exactly one place, however many parents embed it. `[Rubric §9, API & Contract Design]`: the aggregate's wire shape is assembled from composable per-child mappers. `[Rubric §16, Maintainability]`: adding a field to `CategoryItemDTO` is a one-file change that both mappers pick up. -- **Walkthrough**: the primary constructor (`:13-14`) receives `categoryItemDTOMapper`; the `[UseMapper] private readonly` field (`:17-18`) exposes it to the generator. `MapToDTO` (`:21`) is the generated single-entity conversion, routing child items through the reused mapper. `MapToDTOs` (`:24`) is the same hand-written projection as its child mapper: null-guard (`:26`), then `[.. entityCollection.Select(MapToDTO)]` (`:27`). -- **Why it's built this way**: taking the child mapper through DI rather than `new`ing it keeps both mappers ordinary injectable services, which is also what makes them unit-testable in isolation (`[Rubric §14, Testability]`). -- **Where it's used**: the primary DTO mapper for Conference category reads, resolved with its child auto-injected and consumed by the category create and read paths. +- **What it is**: the write intent for updating a [`Session`](group-17-conference-domain.md#session): the target id plus the [`SessionUpdateRequest`](#sessionupdaterequest) payload, opted into cache eviction. +- **Depends on**: [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) and [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`UpdateSessionCommand.cs:3,10`), the [`SessionUpdateRequest`](#sessionupdaterequest) it wraps (`:10`), and the [`Session`](group-17-conference-domain.md#session) type used only for its `FullName` in the cache prefix (`:1,13`). The file also declares [`UpdateSessionResult`](#updatesessionresult) (`:19`), which is why it imports [`SessionDTO`](group-17-conference-domain.md#sessiondto) (`:2`). +- **Concept introduced**: none new; this is the id-plus-request command taught at [`UpdateConferenceCategoryCommand`](#updateconferencecategorycommand). The detail worth noting is what the two marker interfaces buy and what they do **not**. [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) opts the command into the caching decorator so a successful update evicts the session read cache, and [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) is what routes [`SessionUpdateRequestValidator`](#sessionupdaterequestvalidator) into the pipeline. Neither is `ITransactional`, so this command runs without an explicit ambient transaction; the handler's single `SaveChangesAsync` is its own unit of work, which is sufficient because the write touches one aggregate. Contrast the link and unlink commands in this module, which do declare `ITransactional` because they coordinate a write with an integration event. `[Rubric §6, CQRS and Event-Driven]` assesses whether writes are explicit intents flowing through a uniform pipeline: both cross-cutting behaviors attach declaratively through markers, with no wiring inside the handler ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). +- **Walkthrough**: two positional parameters, `Id` and `Request`, with both interfaces on the declaration line (`:10`). `CachePrefix` (`:13`) returns `$"{typeof(Session).FullName}:"`. The result type [`UpdateSessionResult`](#updatesessionresult) shares the file (`:19`). +- **Why it's built this way**: deriving the cache prefix from `typeof(Session).FullName` rather than a literal keeps the writer (this command) and the reader (the session query cache) agreed on one key namespace that a rename cannot desynchronize. +- **Caveats / not-in-source**: the decorator's eviction is not the only cache clearing on this path. [`SessionsController`](group-20-conference-api-grpc.md#sessionscontroller) also calls its own `EvictSessionsCacheAsync` after a successful update (`MMCA.ADC.Conference.API/Controllers/SessionsController.cs:342`). Why both layers evict is not determinable from these files. +- **Where it's used**: constructed by [`SessionsController`](group-20-conference-api-grpc.md#sessionscontroller) from the route id and body (`MMCA.ADC.Conference.API/Controllers/SessionsController.cs:329-331`) and handled by [`UpdateSessionHandler`](#updatesessionhandler). -### SpeakerUpdateRequestValidator +### UpdateSponsorCommand -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.Update` · `MMCA.ADC.Conference.Application/Speakers/UseCases/Update/SpeakerUpdateRequestValidator.cs:7` · Level 8 · class +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sponsors.UseCases.Update` · `MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/UpdateSponsorCommand.cs:9` · Level 9 · record (sealed) -- **What it is**: the FluentValidation validator for [`SpeakerUpdateRequest`](#speakerupdaterequest): the two name rule sets, and nothing else. -- **Depends on**: `AbstractValidator` (FluentValidation, `SpeakerUpdateRequestValidator.cs:1,7`), [`SpeakerUpdateRequest`](#speakerupdaterequest), and the `Speakers.Validation` rule sets [`SpeakerFirstNameRules`](#speakerfirstnamerulest) and [`SpeakerLastNameRules`](#speakerlastnamerulest) (`:2,11-12`). -- **Concept**: nothing new; the same `Include` composition [`AddCategoryItemCommandValidator`](#addcategoryitemcommandvalidator) teaches, at the smallest useful size. The constructor (`:9-13`) folds in the first-name rules (`:11`) and the last-name rules (`:12`), the same pair the create-side speaker validator uses, so the two write paths cannot drift on what a speaker name must look like. The nine optional fields (email, bio, tagline, profile picture, the four social links, and `IsTopSpeaker`) carry no declared constraints at this layer. `[Rubric §24, Forms/Validation/UX Safety]`: the two mandatory fields are single-sourced across every path that writes a speaker name, including the Sessionize import mapping. -- **Walkthrough**: `sealed class SpeakerUpdateRequestValidator : AbstractValidator` (`:7`) with a two-statement constructor (`:9-13`). `RowVersion` is not validated: a null token is a legitimate "skip the conflict check" signal, not an error. -- **Where it's used**: discovered by assembly scanning and reached through [`UpdateSpeakerCommand`](#updatespeakercommand)'s [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) auto-registration, ahead of [`UpdateSpeakerHandler`](#updatespeakerhandler). -- **Caveats / not-in-source**: the optional URL fields are not format-checked here, only the two names are constrained. Whether the [`Speaker`](group-17-conference-domain.md#speaker) aggregate's own `Update` bounds them is not determinable from this file. +- **What it is**: the write intent for updating a [`Sponsor`](group-17-conference-domain.md#sponsor): the target id plus the [`SponsorUpdateRequest`](#sponsorupdaterequest) payload, opted into cache eviction. +- **Depends on**: [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) and [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`UpdateSponsorCommand.cs:2,9`), the [`SponsorUpdateRequest`](#sponsorupdaterequest) it wraps (`:9`), and the [`Sponsor`](group-17-conference-domain.md#sponsor) type used only for its `FullName` in the cache prefix (`:1,12`). +- **Concept introduced**: none; this is the same three-line shape as [`UpdateSessionCommand`](#updatesessioncommand) and [`UpdateConferenceCategoryCommand`](#updateconferencecategorycommand), and it is the plainest instance of it in the module. Reading the three side by side is the point of the repetition: sponsor is id-plus-request, session is id-plus-request with a richer result, and speaker is id-plus-request plus caller authority. The uniformity is what makes the pipeline generic, and the deviations are where the interesting rules live. +- **Walkthrough**: the whole type is four lines. Two positional parameters, `Id` and `Request`, with both interfaces on the declaration line (`:9`), and `CachePrefix` (`:12`) returning `$"{typeof(Sponsor).FullName}:"`. No other member. +- **Where it's used**: constructed by [`SponsorsController`](group-20-conference-api-grpc.md#sponsorscontroller) on `PUT {id}` (`MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:230-232`), validated through [`SponsorUpdateRequestValidator`](#sponsorupdaterequestvalidator) by way of the auto-registered [`CommandRequestValidator`](group-06-validation.md#commandrequestvalidatortcommand-trequest), and handled by [`UpdateSponsorHandler`](#updatesponsorhandler). -### SponsorUpdateRequestValidator +### UpdateSpeakerHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sponsors.UseCases.Update` · `MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/SponsorUpdateRequestValidator.cs:7` · Level 8 · class +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.Update` · `MMCA.ADC.Conference.Application/Speakers/UseCases/Update/UpdateSpeakerHandler.cs:15` · Level 10 · class (sealed partial) -- **What it is**: the FluentValidation validator for [`SponsorUpdateRequest`](#sponsorupdaterequest), assembled from eight reusable per-field rule sets. It is the widest `Include` composition among the update validators in this unit. -- **Depends on**: `AbstractValidator` (FluentValidation, `SponsorUpdateRequestValidator.cs:1,7`), [`SponsorUpdateRequest`](#sponsorupdaterequest), and the `Sponsors.Validation` rule sets [`SponsorNameRules`](#sponsornamerulest), [`SponsorSortRules`](#sponsorsortrulest), [`SponsorLogoUrlRules`](#sponsorlogourlrulest), [`SponsorDescriptionRules`](#sponsordescriptionrulest), [`SponsorWebsiteUrlRules`](#sponsorwebsiteurlrulest), [`SponsorLinkedInUrlRules`](#sponsorlinkedinurlrulest), [`SponsorTwitterHandleRules`](#sponsortwitterhandlerulest), and [`SponsorBoothNumberRules`](#sponsorboothnumberrulest) (`:2,11-18`). -- **Concept**: nothing new beyond [`AddCategoryItemCommandValidator`](#addcategoryitemcommandvalidator), but this is the clearest demonstration in the sponsor slice of what composition buys. Two of the included rule sets derive from framework bases rather than hand-chaining clauses: [`SponsorNameRules`](#sponsornamerulest) extends [`RequiredStringRules`](group-06-validation.md#requiredstringrulest) (`MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:13-17`) and the six optional-string sets extend [`OptionalStringRules`](group-06-validation.md#optionalstringrulest) (`SponsorValidationRules.cs:26-91`), each passing a human-facing label and a bound read from `SponsorInvariants` rather than a literal. Compare the create-side validator, which includes the same eight plus [`SponsorEventIdRules`](#sponsoreventidrulest) for the owning event (`MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestValidator.cs:11-19`): the one-rule difference between the two validators is exactly the one field the update contract drops. `[Rubric §24, Forms/Validation/UX Safety]` assesses whether input constraints are declared once and applied consistently: each sponsor field has a single definition bound twice. `[Rubric §16, Maintainability]`: a bound change is a one-line edit in `SponsorInvariants` that reaches both write paths and the EF column definition (`MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SponsorConfiguration.cs:20,30,34,38,42,46,56`). -- **Walkthrough**: the constructor (`:9-19`) is eight `Include` calls in payload order: name (`:11`), sort (`:12`), logo URL (`:13`), description (`:14`), website URL (`:15`), LinkedIn URL (`:16`), Twitter handle (`:17`), and booth number (`:18`). Three request members are deliberately unvalidated: `RowVersion` (a null token means "skip the conflict check"), `Tier` (an enum, constrained by its type), and `IsExhibitor` (a bool with no field-level contract to state). -- **Why it's built this way**: sponsors are a paid placement, so their text fields are the ones most likely to be pasted in from a contract document; bounding every one of them at the edge means an over-long blurb fails as a `400` with a named field rather than as a database truncation error deep in `SaveChangesAsync`. -- **Where it's used**: discovered by assembly scanning and reached through [`UpdateSponsorCommand`](#updatesponsorcommand)'s [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) auto-registration, ahead of [`UpdateSponsorHandler`](#updatesponsorhandler). +- **What it is**: the command handler for speaker updates. It loads the aggregate, stamps the concurrency token, reconciles the one organizer-only field against the caller's authority, delegates to `Speaker.Update`, saves, and returns the mapped [`SpeakerDTO`](group-17-conference-domain.md#speakerdto). +- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) (`UpdateSpeakerHandler.cs:6,18`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:5,16`), [`SpeakerDTOMapper`](#speakerdtomapper) (`:2,17`), [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error) (`:7`), the [`Speaker`](group-17-conference-domain.md#speaker) aggregate (`:3,25`), and [`SpeakerDTO`](group-17-conference-domain.md#speakerdto) (`:4,18`). +- **Concept introduced**: **field-level authorization, and where it has to live.** Line 40 is the whole idea in one expression: `var isTopSpeaker = command.CallerIsOrganizer ? command.Request.IsTopSpeaker : entity.IsTopSpeaker;`. A self-editing speaker's request may contain any value for `IsTopSpeaker`, and it is simply discarded in favor of the stored one. Three properties of that line are worth naming. It is **fail-closed**: the privileged branch requires the flag to be true, so an unset or unrecognized authority keeps the stored value rather than accepting the request. It is **silent by design**: a self-edit that tries to set the flag does not fail, it just has no effect on that field, which keeps a shared edit form working for both audiences without branching the API. And it **cannot be moved outward**: a validator sees only the request, so it cannot compare against the stored value, and the controller does not have the entity loaded. The handler is the first place where caller authority (from [`UpdateSpeakerCommand`](#updatespeakercommand)) and current state (from the repository) are both in hand. The comment above the line spells out the threat it closes: a crafted request body must not be able to feature a speaker (`:34-39`). The same comment records the companion rule, that `LinkedUserId` is absent from the request entirely so the governed `/link` and `/unlink` endpoints stay the only paths that change it (`:36-39`). `[Rubric §11, Security]` assesses whether authorization is enforced at the level the data requires, not only at the endpoint: role-gating the whole PUT would have forced a separate self-service endpoint, and per-field reconciliation is the alternative this codebase chose ([ADR-033](https://ivanball.github.io/docs/adr/033-resource-ownership-authorization.html) records the resource-ownership axis that sits beside role and permission checks). `[Rubric §1, SOLID]`: the handler still has one reason to change, because the authority arrives as data rather than as a second dependency. +- **Walkthrough**: the primary constructor takes [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`SpeakerDTOMapper`](#speakerdtomapper), and `ILogger` (`:15-18`). `HandleAsync` (`:21-63`) resolves the repository (`:25`), loads the aggregate tracked (`:26`), and returns `Error.NotFound` tagged with the handler and target names when it is missing (`:27-28`). It then stamps the client's token with `repository.SetOriginalRowVersion(entity, command.Request.RowVersion)` (`:32`) so a concurrent edit surfaces as a `DbUpdateConcurrencyException` and a 409 rather than last-write-wins ([ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html)); note the ordering, the stamp happens before any mutation. The privilege reconciliation follows (`:40`), then `entity.Update(...)` receives ten request fields plus the reconciled `isTopSpeaker` (`:42-53`). A domain failure returns its errors unchanged (`:55-56`), preserving the aggregate's own error codes rather than re-wrapping them. `SaveChangesAsync` persists (`:58`), the generated `LogSpeakerUpdated` records the id (`:60`, template at `:65-66`), and the result is `Result.Success(dtoMapper.MapToDTO(entity))` (`:62`). +- **Why it's built this way**: `Speaker.Update` raises `SpeakerChanged` with `DomainEntityState.Updated` as its last act (`MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:235`), and the handler never touches an event bus. That division is the whole point of the [ADR-083](https://ivanball.github.io/docs/adr/083-crud-lifecycle-event-taxonomy.html) taxonomy: the aggregate announces the transition, the [`DomainEventSaveChangesInterceptor`](group-07-persistence-ef-core.md#domaineventsavechangesinterceptor) captures it into the outbox inside the same save, and subscribers such as [`SpeakerDeletedHandler`](#speakerdeletedhandler) filter for the state they care about. Mapping to the DTO after the save (`:62`) rather than before means the returned payload carries the audit fields and the fresh row version that `SaveChangesAsync` stamped. +- **Where it's used**: registered by the module's handler scan and injected into [`SpeakersController`](group-20-conference-api-grpc.md#speakerscontroller) as `ICommandHandler>` (`MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:47`), invoked from the `PUT {id}` action (`:340-342`). It runs behind the decorator pipeline, so validation and cache eviction happen around it rather than inside it. -### UpdateSpeakerCommand +### UpdateSponsorHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.Update` · `MMCA.ADC.Conference.Application/Speakers/UseCases/Update/UpdateSpeakerCommand.cs:13` · Level 8 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sponsors.UseCases.Update` · `MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/UpdateSponsorHandler.cs:15` · Level 10 · class (sealed partial) -- **What it is**: the write intent for updating a [`Speaker`](group-17-conference-domain.md#speaker). Unlike the other update commands in this unit it carries a third parameter, `CallerIsOrganizer`, alongside the id and the request. -- **Depends on**: [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) and [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (both `MMCA.Common.Application.UseCases`, `UpdateSpeakerCommand.cs:2,16`), [`SpeakerUpdateRequest`](#speakerupdaterequest) (`:15`), and the [`Speaker`](group-17-conference-domain.md#speaker) entity for the cache prefix (`:1,19`). -- **Concept introduced**: **carrying an authorization fact into the command, bound at the edge.** BR-214 lets a speaker edit their own profile, but `IsTopSpeaker` is organizer curation and must not be self-assignable. Rather than injecting an identity service into the handler (which would make the handler depend on HTTP identity and become awkward to unit test), the API resolves the role once and passes the answer in. The doc comment on the parameter (`:9-12`) states the rule precisely: it is "bound at the API edge, never from the request body", and when it is `false` the handler "ignores the organizer-only request field `IsTopSpeaker` and keeps the entity's current value". [`SpeakersController`](group-20-conference-api-grpc.md#speakerscontroller) computes it from the role claim and refuses the request outright unless the caller is the organizer or the speaker themselves (`MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:334-338`). `[Rubric §11, Security]` assesses whether authorization decisions are made from server-side identity rather than client input: the flag is a named command parameter whose only writer is the controller, so the trust boundary is visible in the type. `[Rubric §14, Testability]`: because the fact is data on the command, both the organizer and self-edit branches are unit-testable with no HTTP context. -- **Walkthrough**: the record spans four lines (`:13-16`): `Id` (`:14`), `Request` (`:15`), and `bool CallerIsOrganizer` (`:16`), with both marker interfaces on the closing line. `CachePrefix` (`:19`) returns `$"{typeof(Speaker).FullName}:"`. Automatic request validation comes from [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest): the framework registers a request-delegating command validator, so [`SpeakerUpdateRequestValidator`](#speakerupdaterequestvalidator) is picked up through the `Request` property with no `UpdateSpeakerCommandValidator` file to keep in sync. `CallerIsOrganizer` is not validated because it is server-set. -- **Why it's built this way**: the alternative, filtering `IsTopSpeaker` in the controller before handing the request to the handler, would put a domain rule in the presentation layer and leave it unenforced for any other caller of the command. Passing the fact and deciding in the handler keeps the rule with the behavior. -- **Where it's used**: constructed by [`SpeakersController`](group-20-conference-api-grpc.md#speakerscontroller) with the named argument `CallerIsOrganizer: isOrganizer` (`SpeakersController.cs:341`) and handled by [`UpdateSpeakerHandler`](#updatespeakerhandler). +- **What it is**: the command handler for sponsor updates, and the reference shape for "an update handler with nothing unusual in it": load, stamp, delegate, save, map. +- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) (`UpdateSponsorHandler.cs:6,18`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:5,16`), [`SponsorDTOMapper`](#sponsordtomapper) (`:2,17`), the [`Sponsor`](group-17-conference-domain.md#sponsor) aggregate (`:3,25`), [`SponsorDTO`](group-17-conference-domain.md#sponsordto) (`:4,18`), and [`Result`](group-01-result-error-handling.md#result) with [`Error`](group-01-result-error-handling.md#error) (`:7`). +- **Concept introduced**: none new. Read this one to fix the **canonical five-step update shape** in mind, because the other two handlers in this unit are this shape plus something: [`UpdateSpeakerHandler`](#updatespeakerhandler) adds a privilege reconciliation, [`UpdateSessionHandler`](#updatesessionhandler) adds two cross-aggregate checks and an advisory flag. The five steps are: resolve the repository from the unit of work (`:25`), load and null-check (`:26-28`), stamp the concurrency token (`:32`), delegate the whole state transition to one aggregate method (`:34-44`), and save then map (`:49-53`). Everything the pipeline can do generically (validate, evict cache, log the request, wrap in a transaction) is absent, because the decorators do it. `[Rubric §5, Vertical Slice]` assesses whether a feature's code sits together and stays thin: this file is the entire write side of "edit a sponsor", and it is 58 lines. `[Rubric §3, Clean Architecture]`: the handler names no persistence technology, only [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork). +- **Walkthrough**: the primary constructor takes [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`SponsorDTOMapper`](#sponsordtomapper), and `ILogger` (`:15-18`). `HandleAsync` (`:21-54`) resolves `GetRepository()` (`:25`), loads by id (`:26`), and returns `Error.NotFound` with source and target set when absent (`:27-28`). `SetOriginalRowVersion` stamps the client's token (`:32`, rationale in the comment at `:30-31`). `entity.Update(...)` passes all ten editable fields in one call (`:34-44`); the aggregate validates name, logo URL, and booth number through `SponsorInvariants` before assigning anything (`MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:165-170`) and raises `SponsorChanged` with `Updated` at the end (`:183`). A domain failure returns its errors unchanged (`:46-47`). Then `SaveChangesAsync` (`:49`), `LogSponsorUpdated` (`:51`, template at `:56-57`), and `Result.Success(dtoMapper.MapToDTO(entity))` (`:53`). +- **Why it's built this way**: passing every field on every update, rather than diffing the request against the entity, is what makes the aggregate's `Update` a single validated transition. `Sponsor.Update` runs `Result.Combine` over its three invariant checks and returns before assigning a single property if any fails (`Sponsor.cs:165-170`), so a rejected update leaves the entity exactly as loaded. A field-by-field patch could not offer that guarantee without a rollback. +- **Where it's used**: injected into [`SponsorsController`](group-20-conference-api-grpc.md#sponsorscontroller) as `ICommandHandler>` (`MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:40`) and invoked from the `PUT {id}` action, which is gated by the `SponsorsManage` permission (`:224,230-232`). -### UpdateSponsorCommand +### UpdateSessionHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sponsors.UseCases.Update` · `MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/UpdateSponsorCommand.cs:9` · Level 9 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.Update` · `MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionHandler.cs:17` · Level 11 · class (sealed partial) -- **What it is**: the write intent for updating a [`Sponsor`](group-17-conference-domain.md#sponsor): the target id plus the [`SponsorUpdateRequest`](#sponsorupdaterequest) payload, and nothing else. -- **Depends on**: [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) and [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`UpdateSponsorCommand.cs:2,9`), [`SponsorUpdateRequest`](#sponsorupdaterequest) (`:9`), and the [`Sponsor`](group-17-conference-domain.md#sponsor) entity, referenced only for the cache prefix (`:1,12`). -- **Concept**: nothing new; this is the plain id-plus-request shape, and it is worth reading directly against [`UpdateSpeakerCommand`](#updatespeakercommand) to see what the extra parameter there is buying. Sponsors have no self-service editor: the endpoint is permission-gated as a whole (`[HasPermission(ConferencePermissions.SponsorsManage)]`, `MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:224`), so there is no per-field privilege to carry into the handler and the command stays two parameters wide. `[Rubric §6, CQRS & Event-Driven]`: intent in, typed result out, with cache eviction attached declaratively by marker rather than called by the handler. `[Rubric §11, Security]`: the authorization decision is made once at the edge because the whole use case is organizer-only, which is why the command needs no authorization payload. -- **Walkthrough**: the whole type is one declaration line plus one member: `sealed record UpdateSponsorCommand(SponsorIdentifierType Id, SponsorUpdateRequest Request)` implementing both markers (`:9`), and `CachePrefix` returning `$"{typeof(Sponsor).FullName}:"` (`:12`). The doc comment (`:6`) summarizes it as "Command to update an existing sponsor. Invalidates the sponsor cache on success." -- **Why it's built this way**: the update path needs a wrapper because the id comes from the route while the body carries the payload, and [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) makes that wrapper cheap: the positional `Request` parameter satisfies the interface, and [`SponsorUpdateRequestValidator`](#sponsorupdaterequestvalidator) is reused with no command-level validator to write. -- **Where it's used**: constructed by [`SponsorsController`](group-20-conference-api-grpc.md#sponsorscontroller) on `PUT` (`SponsorsController.cs:231`) and handled by [`UpdateSponsorHandler`](#updatesponsorhandler). +- **What it is**: the most guarded write path in this unit. It validates an immutable field, loads a second aggregate to validate the room assignment, delegates the state transition, computes an advisory warning, saves, and returns an [`UpdateSessionResult`](#updatesessionresult) rather than a bare DTO. +- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) (`UpdateSessionHandler.cs:7,20`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:6,18`), [`SessionDTOMapper`](#sessiondtomapper) (`:2,19`), [`SessionRoomScheduling`](#sessionroomscheduling) (`:3,57`), the [`Session`](group-17-conference-domain.md#session) and [`Event`](group-17-conference-domain.md#event) aggregates (`:4,5,27,47`), and [`Result`](group-01-result-error-handling.md#result) with [`Error`](group-01-result-error-handling.md#error) (`:8`). +- **Concept introduced**: **the handler as the only place a cross-aggregate rule can live.** [`Session`](group-17-conference-domain.md#session) and [`Event`](group-17-conference-domain.md#event) are separate aggregate roots, so neither may reach into the other to answer "does this room belong to my event" or "is my new time inside the conference dates". A session cannot load an event, and an event cannot validate a session it does not own. The handler is the first layer that can hold both, so it does: it loads the parent event with its rooms (`:47-52`) and then runs two different kinds of check against it. + - **A hard rule (BR-130), delegated to shared code.** `SessionRoomScheduling.ValidateRoomAssignmentAsync` (`:57-65`) takes the loaded event, the requested room and window, and `excludeSessionId: command.Id` (`:63`). It rejects a room that does not belong to the event with the stable code `Session.RoomId.CrossEvent` (`MMCA.ADC.Conference.Application/Sessions/Validation/SessionRoomScheduling.cs:62-67`) and then probes for an overlapping booking. The `excludeSessionId` argument is what makes this reusable between create and update: without it, a session re-saved with its own room and slot would collide with itself. The predicate implements the exclusion with `int.MinValue` as a sentinel so the expression keeps one shape for both callers (`SessionRoomScheduling.cs:99-107`), and the interval comparison `s.StartsAt < endsAt && s.EndsAt > startsAt` (`:106`) is why back-to-back sessions do not conflict. + - **A soft rule (BR-86), computed inline.** `IsOutsideEventDateRange` (`:104-113`) compares `DateOnly.FromDateTime(startsAt)` against the event's `StartDate` and the end against `EndDate`, and its result is carried out in [`UpdateSessionResult`](#updatesessionresult) instead of failing the request. + Holding both in one method is what makes the distinction legible: the same handler knows which violation blocks a write and which merely annotates it. `[Rubric §4, Domain-Driven Design]` assesses whether aggregate boundaries are respected: the cross-aggregate rule sits in the layer above rather than being smuggled into an entity. `[Rubric §5, Vertical Slice]`: the room rule is factored into a shared static so create and update cannot drift apart, and the drift risk is real (they are two files that must reject the same things). +- **Walkthrough**: the primary constructor takes [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`SessionDTOMapper`](#sessiondtomapper), and `ILogger` (`:17-20`), and the handler's result type is `Result` (`:20`). `HandleAsync` (`:23-98`) runs in order: resolve the session repository (`:27`) and load tracked (`:28`), returning `Error.NotFound` if absent (`:29-30`); stamp the concurrency token (`:34`, comment at `:32-33`); enforce BR-140 by comparing `command.Request.EventId` with `entity.EventId` and returning `Error.UnprocessableEntity` with the code `Session.EventId.Immutable` on a mismatch (`:37-44`); load the parent event with `includes: [nameof(Event.Rooms)]` and `asTracking: false` (`:47-52`), returning `Error.NotFound` if it is gone (`:53-54`); run the room validation and propagate its errors verbatim (`:57-67`); call `entity.Update(...)` with the fourteen editable fields (`:69-83`) and propagate a domain failure unchanged (`:85-86`); compute `hasDateRangeWarning` (`:89-91`); `SaveChangesAsync` (`:93`); log (`:95`, template at `:100-101`); and return `Result.Success(new UpdateSessionResult(dtoMapper.MapToDTO(entity), hasDateRangeWarning))` (`:97`). +- **Why it's built this way**: the parent event is fetched with `asTracking: false` (`:51`) because it is read for validation only and must not be written; an untracked read also keeps the change tracker from carrying an aggregate the save has no business touching. The explicit `includes: [nameof(Event.Rooms)]` (`:50`) is what lets `ValidateRoomAssignmentAsync` scan `parentEvent.Rooms` in memory (`SessionRoomScheduling.cs:59`) instead of issuing another query, and using `nameof` rather than a string literal keeps the include from silently going stale on a rename. The BR-140 check lives in the handler rather than in `Session.Update` because the aggregate method is never passed an event id at all (`MMCA.ADC.Conference.Domain/Sessions/Session.cs:229-243`): the field is not among the things a session can change, so the guard belongs where the request and the stored entity are both visible. +- **Caveats / not-in-source**: the double-booking half of BR-130 is a **soft** guard and the code says so at length. The existence probe and the update that follows are separate statements, so two concurrent organizer writes can both observe a free window and both commit; the type-level doc explains that SQL Server has no range-exclusion constraint able to express an interval predicate, and accepts the gap because the endpoint is organizer-only and the outcome is repairable (`MMCA.ADC.Conference.Application/Sessions/Validation/SessionRoomScheduling.cs:16-25`). Treat a "no conflict" result as advisory under concurrency, not as a guarantee. +- **Where it's used**: injected into [`SessionsController`](group-20-conference-api-grpc.md#sessionscontroller) as `ICommandHandler>` (`MMCA.ADC.Conference.API/Controllers/SessionsController.cs:45`) and invoked from the `PUT {id}` action (`:329-331`), which is covered by the controller-level `SessionsManage` permission (`:41`) and which converts the warning flag into an `X-Warning` header before returning the DTO (`:336-343`). -### UpdateSpeakerHandler +### AddCategoryItemCommand -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.Update` · `MMCA.ADC.Conference.Application/Speakers/UseCases/Update/UpdateSpeakerHandler.cs:15` · Level 10 · class +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.UseCases.AddCategoryItem` · `MMCA.ADC.Conference.Application/Categories/UseCases/AddCategoryItem/AddCategoryItemCommand.cs:14` · Level 7 · record (sealed) -- **What it is**: the handler for [`UpdateSpeakerCommand`](#updatespeakercommand). It loads the [`Speaker`](group-17-conference-domain.md#speaker), stamps the concurrency token, filters the organizer-only field, delegates to the aggregate's `Update`, saves, and returns the DTO. -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) (`UpdateSpeakerHandler.cs:6,18`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:5,16`), [`SpeakerDTOMapper`](#speakerdtomapper) (`:2,17`), [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error) (`:7`), the [`Speaker`](group-17-conference-domain.md#speaker) aggregate (`:3`), the [`SpeakerDTO`](group-17-conference-domain.md#speakerdto) contract (`:4`), and `ILogger` (`:1,18`). -- **Concept introduced**: **field-level authorization inside the handler.** The single most instructive line is `var isTopSpeaker = command.CallerIsOrganizer ? command.Request.IsTopSpeaker : entity.IsTopSpeaker;` (`:40`). A non-organizer's submitted value is discarded and the stored value is passed through unchanged, so a BR-214 self-edit cannot feature its own speaker no matter what the body says. The comment block above it (`:34-39`) spells out both halves of the design: `IsTopSpeaker` is organizer curation, so "a crafted request body cannot feature a speaker", and `LinkedUserId` "is not part of this request at all" because the governed `/link` and `/unlink` endpoints carry the BR-208 uniqueness check and raise the events that keep Identity's `User.LinkedSpeakerId` in sync. Two different techniques for two different risks: remove the field from the contract when no caller should ever set it, filter it in the handler when some callers may. `[Rubric §11, Security]` assesses whether privileged fields are protected server-side: neither protection can be bypassed by a client. `[Rubric §8, Data Architecture]`: the row-version stamp at `:32` is how a lost update is detected instead of silently accepted. `[Rubric §1, SOLID]`: the handler makes the decision, the controller only supplies the fact. -- **Walkthrough**: `HandleAsync` (`:21-23`) gets the typed repository (`:25`), loads by id (`:26`), and returns `Error.NotFound` tagged with source and target when absent (`:27-28`). It stamps the client's last-seen token with `repository.SetOriginalRowVersion(entity, command.Request.RowVersion)` (`:32`), the comment above it recording that a concurrent edit then surfaces as a `DbUpdateConcurrencyException` mapped to `409` rather than last-write-wins (`:30-31`). It computes `isTopSpeaker` (`:40`), then calls `entity.Update(...)` with the ten request fields and the filtered flag as the eleventh argument (`:42-53`), propagating the aggregate's errors on failure (`:55-56`). On success it saves with `ConfigureAwait(false)` (`:58`), logs (`:60`), and returns `Result.Success(dtoMapper.MapToDTO(entity))` (`:62`). The `[LoggerMessage]` (`:65-66`) declares "Speaker {SpeakerId} updated". -- **Why it's built this way**: passing every field positionally to `entity.Update` keeps the aggregate the only place that can mutate speaker state, so invariants are checked in one place and the handler stays a pure orchestrator. The handler opens no transaction and evicts no cache: the decorators do, driven by [`UpdateSpeakerCommand`](#updatespeakercommand)'s markers. -- **Where it's used**: injected into [`SpeakersController`](group-20-conference-api-grpc.md#speakerscontroller) as `ICommandHandler>` (`MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:47`) and invoked on `PUT` after the BR-214 organizer-or-self check (`SpeakersController.cs:334-342`). +- **What it is**: the write intent for adding one [`CategoryItem`](group-17-conference-domain.md#categoryitem) (a selectable option such as "Beginner" inside the "Level" category) to an existing [`Category`](group-17-conference-domain.md#category). It carries the owning category, an optional explicit item id, and the two fields the child actually holds. +- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) from `MMCA.Common.Application.UseCases` (`AddCategoryItemCommand.cs:2,18`), the [`Category`](group-17-conference-domain.md#category) domain type used only to build the cache prefix (`:1,21`), and the module identifier aliases `ConferenceCategoryIdentifierType` and `CategoryItemIdentifierType` (both `= int`, `MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:6-7`; see the [primer](00-primer.md#2-architectural-styles-this-codebase-commits-to) on identifier aliases). +- **Concept introduced, the optional-identity add command.** Every other member of the category family takes its identifiers as plain non-nullable values. This one declares `CategoryItemIdentifierType? CategoryItemId` (`AddCategoryItemCommand.cs:16`), and the question mark is the whole point: it is the wire-level way to say "I do not have an id for this child, let the store assign one". The nullability lines up exactly with the domain factory it eventually reaches, `CategoryItem.Create(CategoryItemIdentifierType? id, ...)` (`MMCA.ADC.Conference.Domain/Categories/CategoryItem.cs:47-48`), which resolves the argument as `id ?? (isIdValueGenerated ? default : throw ...)` (`CategoryItem.cs:61`). Because `CategoryItem` carries `[IdValueGenerated]` (`CategoryItem.cs:13`), a null id becomes `0` and EF assigns the real key at save. The reason the parameter exists at all is the Sessionize import, which calls the same domain method with upstream-assigned ids (`MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/CategorySyncStrategy.cs:83,111`). Contrast this with [`ConferenceCategoryCreateRequest`](#conferencecategorycreaterequest), whose `Id` is *not* nullable (`ConferenceCategoryCreateRequest.cs:16`): the same design decision was made two different ways in the same folder tree. `[Rubric §9, API & Contract Design]` assesses whether an inbound contract is explicit about optionality: here the nullable id is the contract's own documentation that identity is caller-optional. `[Rubric §10, Cross-Cutting]`: cache eviction is declared rather than coded, because the caching decorator reads `CachePrefix` off the command instead of the handler evicting by hand. +- **Walkthrough**: a `sealed record` with four positional parameters, `CategoryId`, `CategoryItemId`, `Name`, `Sort` (`AddCategoryItemCommand.cs:14-18`), implementing [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`:18`). The single member in the body is `CachePrefix => $"{typeof(Category).FullName}:"` (`:21`), keyed on the aggregate root rather than the child because a cached category read carries its items inline ([`ConferenceCategoryDTO.CategoryItems`](group-17-conference-domain.md#conferencecategorydto), `MMCA.ADC.Conference.Shared/Categories/ConferenceCategoryDTO.cs:27`). `Name` and `Sort` are non-nullable (`:17-18`), so the command cannot express "leave the name alone", which is correct for an add. +- **Why it's built this way**: the command names exactly the four values the aggregate's `AddCategoryItem` method needs (`MMCA.ADC.Conference.Domain/Categories/Category.cs:131-134`) and nothing else, so the HTTP shape, the validator target, and the domain call signature stay in one-to-one correspondence. +- **Where it's used**: constructed by the category-items controller from an [`AddCategoryItemRequest`](group-20-conference-api-grpc.md#addcategoryitemrequest) body (`MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:134-138`, handler injected at `:64`), validated by [`AddCategoryItemCommandValidator`](#addcategoryitemcommandvalidator), and handled by [`AddCategoryItemHandler`](#addcategoryitemhandler). Its edit and delete counterparts are [`UpdateCategoryItemCommand`](#updatecategoryitemcommand) and [`RemoveCategoryItemCommand`](#removecategoryitemcommand). -### UpdateSponsorHandler +### CategoryItemDTOMapper -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sponsors.UseCases.Update` · `MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/UpdateSponsorHandler.cs:15` · Level 10 · class +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.DTOs` · `MMCA.ADC.Conference.Application/Categories/DTOs/CategoryItemDTOMapper.cs:12` · Level 7 · class (sealed partial) -- **What it is**: the handler for [`UpdateSponsorCommand`](#updatesponsorcommand): load the [`Sponsor`](group-17-conference-domain.md#sponsor), stamp the concurrency token, delegate all ten editable fields to the aggregate's `Update`, save, log, and return the DTO. -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) (`UpdateSponsorHandler.cs:6,18`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:5,16`), [`SponsorDTOMapper`](#sponsordtomapper) (`:2,17`), [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error) (`:7`), the [`Sponsor`](group-17-conference-domain.md#sponsor) aggregate (`:3`), the [`SponsorDTO`](group-17-conference-domain.md#sponsordto) contract (`:4`), and `ILogger` (`:1,18`). -- **Concept**: this is the canonical update handler with nothing added, so read it as the baseline that [`UpdateSpeakerHandler`](#updatespeakerhandler) decorates with one filtered field. Five moves and two failure exits: load, stamp, delegate, save, map. What is worth noticing is the *pair* of caches involved on this path. The command's [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) marker makes the pipeline decorator wipe the application query cache, and separately [`SponsorsController`](group-20-conference-api-grpc.md#sponsorscontroller) evicts the HTTP output cache by tag after a successful update (`MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:237`, `SponsorsController.cs:253-257`, evicting `conference:sponsors` and `conference`). Two caches sit in front of a sponsor read, and a write has to clear both. `[Rubric §10, Cross-Cutting]` assesses whether such concerns are applied uniformly: one of the two evictions is declarative and one is an explicit call, so the pairing is a thing to remember rather than something the type system enforces. `[Rubric §8, Data Architecture]`: the row-version stamp (`:32`) is the conflict-detection mechanism, identical to the speaker path. -- **Walkthrough**: `HandleAsync` (`:21-23`) resolves `unitOfWork.GetRepository()` (`:25`), loads by id (`:26`), and returns `Error.NotFound` sourced to the handler and targeted at `Sponsor` when absent (`:27-28`). It stamps the client's row version (`:32`), with the same `409`-not-last-write-wins comment as the speaker handler (`:30-31`). It then calls `entity.Update(...)` with all ten editable fields in payload order, `Name`, `Tier`, `LogoUrl`, `Description`, `WebsiteUrl`, `LinkedInUrl`, `TwitterHandle`, `Sort`, `IsExhibitor`, `BoothNumber` (`:34-44`), and propagates the aggregate's errors verbatim on failure (`:46-47`). On success it saves with `ConfigureAwait(false)` (`:49`), logs `LogSponsorUpdated` (`:51`, `:56-57`), and returns `Result.Success(dtoMapper.MapToDTO(entity))` (`:53`). -- **Why it's built this way**: the aggregate, not the handler, decides what a valid sponsor is: `Sponsor.Update` combines three invariant checks (name, logo URL, booth number) before assigning any field and raises `SponsorChanged` with `DomainEntityState.Updated` at the end (`MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:153-186`, checks at `:165-168`, event at `:183`). Note that the owning event is absent from the parameter list on purpose, matching the request contract and the doc comment at `Sponsor.cs:140`: a sponsor cannot be moved between events by an update. -- **Where it's used**: injected into [`SponsorsController`](group-20-conference-api-grpc.md#sponsorscontroller) as `ICommandHandler>` (`MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:40`) and invoked on `PUT` (`SponsorsController.cs:230-232`). -- **Caveats / not-in-source**: only three of the ten fields are re-checked by the aggregate. The length bounds on the description and the three link fields are enforced by [`SponsorUpdateRequestValidator`](#sponsorupdaterequestvalidator) at the edge and by the EF column definitions (`MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SponsorConfiguration.cs:34,38,42,46`), so a caller that reached `Sponsor.Update` without passing through the validator would meet the constraint at the database rather than as a domain failure. +- **What it is**: the read-side mapper that turns a [`CategoryItem`](group-17-conference-domain.md#categoryitem) domain entity into a [`CategoryItemDTO`](group-17-conference-domain.md#categoryitemdto). The single-entity method has no body in this file: Mapperly generates it at compile time. +- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) from `MMCA.Common.Application.Interfaces` (`CategoryItemDTOMapper.cs:3,13`), `Riok.Mapperly.Abstractions` (NuGet, `:4,11`), the [`CategoryItem`](group-17-conference-domain.md#categoryitem) entity (`:1`), the [`CategoryItemDTO`](group-17-conference-domain.md#categoryitemdto) contract from the Shared project (`:2`), and the `CategoryItemIdentifierType` alias (`= int`, `MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:6`). +- **Concept introduced, source-generated DTO mapping.** `[Mapper]` on a `partial` class (`CategoryItemDTOMapper.cs:11-12`) tells the Mapperly generator to fill in the body of every `partial` method it finds, here `MapToDTO` (`:16`). The generated body is straight-line property assignment: no reflection, no expression trees, no runtime configuration, and a compile error rather than a silent null when a target member has no matching source member. That is the policy [ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html) settled on: mapping is either hand-written or generated, never reflective. The four target members are `Id`, `Name`, `Sort`, and `CategoryId` (`MMCA.ADC.Conference.Shared/Categories/CategoryItemDTO.cs:39-48`); the last of these reads the entity's foreign key, which the domain exposes as a getter-only property with no setter at all (`MMCA.ADC.Conference.Domain/Categories/CategoryItem.cs:27`), so the wire contract can surface the parent link without the domain ever handing out a way to reassign it. `[Rubric §12, Performance & Scalability]` assesses whether hot paths avoid avoidable runtime work: every category read maps a full item list, so a generated assignment beats a reflective copy exactly where volume lands. `[Rubric §9, API & Contract Design]`: the DTO, not the entity, is what crosses the wire, so a domain refactor cannot silently reshape the JSON. `[Rubric §14, Testability]`: the mapper is a pure function of its input and is tested directly (`CategoryItemDTOMapperTests`, [Group 27](group-27-testing-infrastructure.md#categoryitemdtomappertests)). +- **Walkthrough**: two members. `public partial CategoryItemDTO MapToDTO(CategoryItem entity)` (`CategoryItemDTOMapper.cs:16`) is the declaration whose implementation the generator supplies. `MapToDTOs(IReadOnlyCollection)` (`:19-23`) is hand-written and deliberately so: it null-guards with `ArgumentNullException.ThrowIfNull` (`:21`) and then projects with a collection expression over a spread, `[.. entityCollection.Select(MapToDTO)]` (`:22`), which materializes a single array without an intermediate `List` growth cycle. The collection method delegating to the generated single-item method is the shape every mapper in this module repeats. +- **Why it's built this way**: generating the property copy keeps the mapping honest (add a DTO member with no source and the build breaks) while the hand-written collection method keeps the allocation profile under the author's control rather than the generator's. +- **Where it's used**: injected by concrete type into [`AddCategoryItemHandler`](#addcategoryitemhandler) (`MMCA.ADC.Conference.Application/Categories/UseCases/AddCategoryItem/AddCategoryItemHandler.cs:17`), consumed as a child mapper by [`ConferenceCategoryDTOMapper`](#conferencecategorydtomapper) through `[UseMapper]` (`ConferenceCategoryDTOMapper.cs:17-18`), and resolved as `IEntityDTOMapper` by the generic query service registered for the entity (`MMCA.ADC.Conference.Application/DependencyInjection.cs:93`, constructor parameter at `MMCA.Common.Application/Services/EntityQueryService.cs:35`). Registration is by the convention scan, not an explicit `AddScoped` line (`MMCA.ADC.Conference.Application/DependencyInjection.cs:125`). ### ConferenceCategoryCreateRequest > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.UseCases.Create` · `MMCA.ADC.Conference.Application/Categories/UseCases/Create/ConferenceCategoryCreateRequest.cs:10` · Level 7 · record - **What it is**: the inbound contract for creating a conference [`Category`](group-17-conference-domain.md#category), the aggregate behind vocabularies such as "Level", "Track", and "Session Format". As with the other create slices in this module it is both the HTTP request body and the command the CQRS pipeline dispatches: there is no separate `CreateConferenceCategoryCommand`. -- **Depends on**: [`ICreateRequest`](group-05-cqrs-pipeline.md#icreaterequest) and [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) from `MMCA.Common.Application.UseCases` / `.Interfaces` (`ConferenceCategoryCreateRequest.cs:2-3,10`), the [`Category`](group-17-conference-domain.md#category) domain type used only to build the cache prefix (`ConferenceCategoryCreateRequest.cs:1,13`), and the `ConferenceCategoryIdentifierType` alias (`= int`, `MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:6`). -- **Concept**: the create-request-as-command shape introduced by [`EventCreateRequest`](#eventcreaterequest), applied to the smallest aggregate in the module. Implementing the marker [`ICreateRequest`](group-05-cqrs-pipeline.md#icreaterequest) (`ConferenceCategoryCreateRequest.cs:10`) is what lets the generic create machinery accept this record straight off the wire, hand it to an [`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype), and dispatch it as `ICommandHandler>`. Note that it is declared `public record class` rather than `sealed record` (`ConferenceCategoryCreateRequest.cs:10`), the only shape difference from its command siblings in this unit. `[Rubric §9, API & Contract Design]` assesses whether an inbound contract is explicit about shape and optionality: exactly one member is `required` (`Title`, `:19`), and the other three are optional by declaration, which is the endpoint's optionality documentation. `[Rubric §10, Cross-Cutting]`: cache eviction is declared, not coded, because the caching decorator reads `CachePrefix` off the request. -- **Walkthrough**: `CachePrefix => $"{typeof(Category).FullName}:"` (`ConferenceCategoryCreateRequest.cs:13`) is the eviction key the caching decorator purges on success, keyed on the aggregate root so every cached category read is invalidated together. `Id` (`:16`) is a non-nullable `ConferenceCategoryIdentifierType`, which matters downstream: [`Category.Create`](group-17-conference-domain.md#category) declares its id parameter as `ConferenceCategoryIdentifierType?` (`MMCA.ADC.Conference.Domain/Categories/Category.cs:55`) precisely so a caller can say "no id", but this request can never express that, so an omitted id binds to `0` and the factory's null branch (`Category.cs:69`) is unreachable from the HTTP path. The reason the factory accepts an id at all is the Sessionize import, which carries category ids assigned upstream (`Category.cs:49`). `Title` is `required` (`:19`), `Sort` is a plain `int` display order (`:22`), and `Type` is the optional discriminator whose documented examples are "session" and "speaker" (`:25`). Every member is `init`-only, so the request cannot be mutated after model binding. +- **Depends on**: [`ICreateRequest`](group-05-cqrs-pipeline.md#icreaterequest) and [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) from `MMCA.Common.Application.Interfaces` / `.UseCases` (`ConferenceCategoryCreateRequest.cs:2-3,10`), the [`Category`](group-17-conference-domain.md#category) domain type used only to build the cache prefix (`:1,13`), and the `ConferenceCategoryIdentifierType` alias (`= int`, `MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7`). +- **Concept**: the create-request-as-command shape introduced by [`EventCreateRequest`](#eventcreaterequest), applied to the smallest aggregate in the module. Implementing the marker [`ICreateRequest`](group-05-cqrs-pipeline.md#icreaterequest) (`ConferenceCategoryCreateRequest.cs:10`) is what lets the generic create machinery accept this record straight off the wire, hand it to an [`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype), and dispatch it as `ICommandHandler>`. Note that it is declared `public record class` rather than `sealed record` (`:10`), the only shape difference from its command siblings in this unit. `[Rubric §9, API & Contract Design]` assesses whether an inbound contract is explicit about shape and optionality: exactly one member is `required` (`Title`, `:19`), and the other three are optional by declaration, which is the endpoint's optionality documentation. `[Rubric §10, Cross-Cutting]`: cache eviction is declared, not coded, because the caching decorator reads `CachePrefix` off the request. +- **Walkthrough**: `CachePrefix => $"{typeof(Category).FullName}:"` (`ConferenceCategoryCreateRequest.cs:13`) is the eviction key the caching decorator purges on success, keyed on the aggregate root so every cached category read is invalidated together. `Id` (`:16`) is a non-nullable `ConferenceCategoryIdentifierType`, which matters downstream: [`Category.Create`](group-17-conference-domain.md#category) declares its id parameter as `ConferenceCategoryIdentifierType?` (`MMCA.ADC.Conference.Domain/Categories/Category.cs:54-55`) precisely so a caller can say "no id", but this request can never express that, so an omitted id binds to `0` and the factory's throw branch (`Category.cs:69`) is unreachable from the HTTP path (unreachable for this aggregate in any case, since `Category` carries `[IdValueGenerated]`, `Category.cs:15`). The reason the factory accepts an id at all is the Sessionize import, which carries category ids assigned upstream (`MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/CategorySyncStrategy.cs:99`). `Title` is `required` (`:19`), `Sort` is a plain `int` display order (`:22`), and `Type` is the optional discriminator whose documented examples are "session" and "speaker" (`:25`). Every member is `init`-only, so the request cannot be mutated after model binding. - **Why it's built this way**: collapsing request and command removes a mapping step with no behavior of its own, and keeping the id nullable on the domain factory while non-nullable on the wire contract lets one aggregate serve both the API (store-generated keys) and the importer (Sessionize-assigned keys). -- **Where it's used**: bound by the categories controller (`MMCA.ADC.Conference.API/Controllers/ConferenceCategoriesController.cs:94`), which types its base class on it (`ConferenceCategoriesController.cs:39`) and injects the handler as `ICommandHandler>` (`:34`); validated by [`ConferenceCategoryCreateRequestValidator`](#conferencecategorycreaterequestvalidator), converted by [`ConferenceCategoryCreateRequestMapper`](#conferencecategorycreaterequestmapper), handled by [`CreateConferenceCategoryHandler`](#createconferencecategoryhandler). Its edit-side counterpart is [`ConferenceCategoryUpdateRequest`](#conferencecategoryupdaterequest). +- **Where it's used**: bound by the categories controller (`MMCA.ADC.Conference.API/Controllers/ConferenceCategoriesController.cs:94`), which types its base class on it (`ConferenceCategoriesController.cs:39-40`) and injects the handler as `ICommandHandler>` (`:34`); validated by [`ConferenceCategoryCreateRequestValidator`](#conferencecategorycreaterequestvalidator), converted by [`ConferenceCategoryCreateRequestMapper`](#conferencecategorycreaterequestmapper), handled by [`CreateConferenceCategoryHandler`](#createconferencecategoryhandler). Its edit-side counterpart is [`ConferenceCategoryUpdateRequest`](#conferencecategoryupdaterequest). ### QuestionTextRules @@ -2238,51 +2480,54 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **What it is**: the one reusable FluentValidation rule set for the text of a conference [`Question`](group-17-conference-domain.md#question). It says a question text must be present and no longer than the domain's limit, and it is written once for every request type that carries such a field. - **Depends on**: `FluentValidation.AbstractValidator` (NuGet, `QuestionValidationRules.cs:2,13`), `System.Linq.Expressions.Expression` (BCL, `:1,15`), and [`QuestionInvariants`](group-17-conference-domain.md#questioninvariants) for the length constant (`:3,18`). - **Concept introduced, the generic rule object with a property selector.** A FluentValidation validator is generic over the type it validates, so a rule written for one request type cannot normally be reused by another. This codebase solves that by making the rule itself generic in `T` and taking an `Expression>` in its constructor (`QuestionValidationRules.cs:12,15`). `new QuestionTextRules(p => p.QuestionText)` then means "apply the question-text rules to this type's `QuestionText` property", and a consuming validator pulls the rules in with `Include(...)`, which copies every rule from another `AbstractValidator` over the same `T`. The payoff is that create and update cannot drift apart on what a valid question text is: both include this same object. `[Rubric §16, Maintainability]` assesses whether a change has one edit point: raising the limit is a single edit to `QuestionInvariants.QuestionTextMaxLength`, and both the message and the constraint follow. `[Rubric §24, Forms, Validation & UX Safety]` assesses whether invalid input is rejected at the boundary with actionable messages: each rule carries both human text and a stable machine code. -- **Walkthrough**: the whole type is an expression-bodied constructor (`QuestionValidationRules.cs:15-18`). `RuleFor(selector)` opens the chain, `.NotEmpty()` attaches the message "You must enter a Question Text" with error code `Question.QuestionText.Required` (`:17`), and `.MaximumLength(QuestionInvariants.QuestionTextMaxLength)` attaches "Question Text cannot be longer than 1000 characters" with code `Question.QuestionText.MaxLength` (`:18`). The constant is `1000` (`MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:13`), the same value the EF configuration and the domain invariant read, so the API error and the column width cannot disagree. Two details are worth noticing. First, the error codes are the stable contract: the message wording can change without breaking a client that branches on `Question.QuestionText.MaxLength`. Second, this class writes its interpolated message with a plain `$"..."` (`:18`) where the category rules use `string.Create(CultureInfo.InvariantCulture, $"...")` (`MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:19`), so the number is formatted with the ambient culture here and invariantly there. -- **Why it's built this way**: pulling length limits from the aggregate's invariants class rather than restating them in the validator is what keeps three layers (validation, domain guard, column constraint) on one number. Each aggregate owns its own invariants class, which is why this rule reads `QuestionInvariants` and not a shared constants bag. -- **Where it's used**: included by [`QuestionCreateRequestValidator`](#questioncreaterequestvalidator) (`MMCA.ADC.Conference.Application/Questions/UseCases/Create/QuestionCreateRequestValidator.cs:10`) and [`QuestionUpdateRequestValidator`](#questionupdaterequestvalidator) (`.../Update/QuestionUpdateRequestValidator.cs:10`). +- **Walkthrough**: the whole type is an expression-bodied constructor (`QuestionValidationRules.cs:15-18`). `RuleFor(selector)` opens the chain, `.NotEmpty()` attaches the message "You must enter a Question Text" with error code `Question.QuestionText.Required` (`:17`), and `.MaximumLength(QuestionInvariants.QuestionTextMaxLength)` attaches "Question Text cannot be longer than 1000 characters" with code `Question.QuestionText.MaxLength` (`:18`). The limit is `1000`, declared as a `public const int` (`MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:13`), the same value the EF configuration and the domain invariant read (`QuestionInvariants.cs:59`), so the API error and the column width cannot disagree. Two details are worth noticing. First, the error codes are the stable contract: the message wording can change without breaking a client that branches on `Question.QuestionText.MaxLength`. Second, this class writes its interpolated message with a plain `$"..."` (`:18`) where the category rules use `string.Create(CultureInfo.InvariantCulture, $"...")` (`MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:19`), so the number is formatted with the ambient culture here and invariantly there. +- **Why it's built this way**: pulling length limits from the aggregate's invariants class rather than restating them in the validator is what keeps three layers (validation, domain guard, column constraint) on one number. Each aggregate owns its own invariants class, which is why this rule reads `QuestionInvariants` and not a shared constants bag. Note the small inconsistency between the two invariants classes: question limits are `const` (`QuestionInvariants.cs:13`) while category limits are `public static readonly int` (`MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:14,17`), so the former are baked into each calling assembly at compile time and the latter are read at runtime. +- **Where it's used**: included by [`QuestionCreateRequestValidator`](#questioncreaterequestvalidator) (`MMCA.ADC.Conference.Application/Questions/UseCases/Create/QuestionCreateRequestValidator.cs:10`) and [`QuestionUpdateRequestValidator`](#questionupdaterequestvalidator) (`MMCA.ADC.Conference.Application/Questions/UseCases/Update/QuestionUpdateRequestValidator.cs:10`). ### RemoveCategoryItemCommand > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.UseCases.RemoveCategoryItem` · `MMCA.ADC.Conference.Application/Categories/UseCases/RemoveCategoryItem/RemoveCategoryItemCommand.cs:12` · Level 7 · record (sealed) -- **What it is**: the write intent for removing one [`CategoryItem`](group-17-conference-domain.md#categoryitem) (a selectable option such as "Beginner" inside the "Level" category) from its owning [`Category`](group-17-conference-domain.md#category). It names the category and the item, and nothing else. -- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`RemoveCategoryItemCommand.cs:2,14`), the [`Category`](group-17-conference-domain.md#category) type for the cache prefix (`:1,17`), and the `ConferenceCategoryIdentifierType` / `CategoryItemIdentifierType` aliases (both `= int`, `MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:5-6`). -- **Concept**: the remove-child command shape, the mirror of [`AddCategoryItemCommand`](#addcategoryitemcommand) and deliberately narrower than it. An add carries the child's payload; a remove carries only the two identifiers, because the child already exists and the only decision left is which one. The pair `(CategoryId, CategoryItemId)` is what makes the operation aggregate-scoped: the handler loads the *category* and asks it to remove the item, so no caller can delete an item by id alone and bypass the aggregate's rules. The cache prefix is keyed on `Category`, not on the child, because a cached category read carries its items inline. `[Rubric §4, Domain-Driven Design]` assesses whether children are mutated through their root: the command's shape makes any other access path impossible to express. -- **Walkthrough**: a `sealed record` with two positional parameters, `CategoryId` and `CategoryItemId` (`RemoveCategoryItemCommand.cs:12-14`), plus the single computed `CachePrefix => $"{typeof(Category).FullName}:"` (`:17`). There is no validator class in the folder, because two required identifiers have nothing to check beyond model binding, and there is no `RowVersion`: unlike the event publish transition, a category-item removal carries no optimistic-concurrency token. Note what `CategoryId` is allowed to be: the DELETE endpoint takes it as an optional query argument (`MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:173`), so it legitimately arrives as `0`, and [`RemoveCategoryItemHandler`](#removecategoryitemhandler) is the piece that copes with that. -- **Why it's built this way**: keeping the payload to two identifiers means the command is fully described by the route plus one query argument, and it leaves the aggregate as the only place that knows what removing an item means (a soft delete plus a `CategoryItemChanged` domain event, `MMCA.ADC.Conference.Domain/Categories/Category.cs:199,203`). -- **Where it's used**: constructed by the category-items controller as `new RemoveCategoryItemCommand(categoryId, id)` (`MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:177`, handler injected at `:65`) and handled by [`RemoveCategoryItemHandler`](#removecategoryitemhandler). Its add and update counterparts are [`AddCategoryItemCommand`](#addcategoryitemcommand) and [`UpdateCategoryItemCommand`](#updatecategoryitemcommand). +- **What it is**: the write intent for removing one [`CategoryItem`](group-17-conference-domain.md#categoryitem) from its owning [`Category`](group-17-conference-domain.md#category). It names the category and the item, and nothing else. +- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`RemoveCategoryItemCommand.cs:2,14`), the [`Category`](group-17-conference-domain.md#category) type for the cache prefix (`:1,17`), and the `ConferenceCategoryIdentifierType` / `CategoryItemIdentifierType` aliases (both `= int`, `MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:6-7`). +- **Concept**: the remove-child command shape, the mirror of [`AddCategoryItemCommand`](#addcategoryitemcommand) and deliberately narrower than it. An add carries the child's payload; a remove carries only the two identifiers, because the child already exists and the only decision left is which one. Note that both identifiers here are non-nullable (`RemoveCategoryItemCommand.cs:13-14`), where the add's child id is optional: an add may invent identity, a remove may only reference it. The pair `(CategoryId, CategoryItemId)` is what makes the operation aggregate-scoped: the handler loads the *category* and asks it to remove the item, so no caller can delete an item by id alone and bypass the aggregate's rules. The cache prefix is keyed on `Category`, not on the child, because a cached category read carries its items inline. `[Rubric §4, Domain-Driven Design]` assesses whether children are mutated through their root: the command's shape makes any other access path impossible to express. +- **Walkthrough**: a `sealed record` with two positional parameters, `CategoryId` and `CategoryItemId` (`RemoveCategoryItemCommand.cs:12-14`), plus the single computed `CachePrefix => $"{typeof(Category).FullName}:"` (`:17`). There is no validator class in the folder, because two required identifiers have nothing to check beyond model binding, and there is no `RowVersion`: unlike the event publish transition, a category-item removal carries no optimistic-concurrency token. Note what `CategoryId` is allowed to be: the DELETE endpoint takes it as a query argument with no `[Required]` and no default (`MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:181`), so it legitimately arrives as `0`, and [`RemoveCategoryItemHandler`](#removecategoryitemhandler) is the piece that copes with that. +- **Why it's built this way**: keeping the payload to two identifiers means the command is fully described by the route plus one query argument, and it leaves the aggregate as the only place that knows what removing an item means (a soft delete on the child plus a `CategoryItemChanged` domain event, `MMCA.ADC.Conference.Domain/Categories/Category.cs:199,203`). +- **Where it's used**: constructed by the category-items controller as `new RemoveCategoryItemCommand(categoryId, id)` (`MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:185`, handler injected at `:66`) and handled by [`RemoveCategoryItemHandler`](#removecategoryitemhandler). Its add and update counterparts are [`AddCategoryItemCommand`](#addcategoryitemcommand) and [`UpdateCategoryItemCommand`](#updatecategoryitemcommand). -### SpeakerFirstNameRules +### UpdateCategoryItemCommand -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.Validation` · `MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:11` · Level 7 · class (sealed, generic) +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.UseCases.UpdateCategoryItem` · `MMCA.ADC.Conference.Application/Categories/UseCases/UpdateCategoryItem/UpdateCategoryItemCommand.cs:14` · Level 7 · record (sealed) -- **What it is**: the reusable rule set for a [`Speaker`](group-17-conference-domain.md#speaker)'s first name. It contributes no rule bodies of its own: it is a three-line subclass that binds the framework's generic "required string" rules to one field name and one length constant. -- **Depends on**: [`RequiredStringRules`](group-06-validation.md#requiredstringrulest) from `MMCA.Common.Application.Validation` (`SpeakerValidationRules.cs:3,12`), [`SpeakerInvariants`](group-17-conference-domain.md#speakerinvariants) (`:2,15`), and `Expression` (BCL, `:1,14`). -- **Concept introduced, rule reuse by inheritance rather than by composition.** [`QuestionTextRules`](#questiontextrulest) hand-writes its `NotEmpty` plus `MaximumLength` chain; this class instead inherits the identical chain from the framework and passes three arguments to it: the selector, the display name, and the limit (`SpeakerValidationRules.cs:14-15`). The base builds both messages from the display name, "You must enter a First Name" and "First Name cannot be longer than 200 characters" (`MMCA.Common.Application/Validation/CommonValidationRules.cs:16-18`). The trade is visible in the output: the framework base attaches no `WithErrorCode`, so a speaker name violation surfaces with FluentValidation's default codes while a question text violation surfaces with the explicit `Question.QuestionText.*` codes. Choose the base when the field is an ordinary required string, hand-write when the field needs a stable machine code. `[Rubric §1, SOLID]` assesses whether a type has one reason to change: this one changes only if the speaker's first name changes its name or its length. `[Rubric §15, Best Practices & Code Quality]`: the shared base is in MMCA.Common, so the same shape is available to every module in every app rather than copy-pasted per aggregate. -- **Walkthrough**: `sealed class SpeakerFirstNameRules : RequiredStringRules` (`SpeakerValidationRules.cs:11-12`) with a single constructor forwarding `base(selector, "First Name", SpeakerInvariants.FirstNameMaxLength)` (`:14-15`). The constant is `200` (`MMCA.ADC.Conference.Domain/Speakers/SpeakerInvariants.cs:13`). -- **Where it's used**: included by [`SpeakerCreateRequestValidator`](#speakercreaterequestvalidator) (`MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequestValidator.cs:11`) and [`SpeakerUpdateRequestValidator`](#speakerupdaterequestvalidator) (`.../Update/SpeakerUpdateRequestValidator.cs:11`). +- **What it is**: the write intent for editing one [`CategoryItem`](group-17-conference-domain.md#categoryitem) inside its owning [`Category`](group-17-conference-domain.md#category): the two identifiers that locate it plus the two fields that may change. +- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`UpdateCategoryItemCommand.cs:2,18`), the [`Category`](group-17-conference-domain.md#category) type for the cache prefix (`:1,21`), and the `ConferenceCategoryIdentifierType` / `CategoryItemIdentifierType` aliases. +- **Concept**: the update-child shape, which sits between the add and remove shapes. It carries the aggregate id and the child id like a remove, plus exactly the fields that are editable and no others: `Name` is a non-nullable `string` and `Sort` a non-nullable `int` (`UpdateCategoryItemCommand.cs:17-18`), so this command cannot be used to blank a name by omission. Because both editable fields are here rather than spread across a partial-update document, the record is the complete answer to "what can this endpoint change", and it is also the exact target the validator binds to. `[Rubric §9, API & Contract Design]` assesses whether an edit contract states precisely what is mutable: the four positional parameters are that statement. `[Rubric §6, CQRS & Event-Driven]`: one command type, one handler, one write path, with the resulting `CategoryItemChanged` domain event raised inside the aggregate (`MMCA.ADC.Conference.Domain/Categories/Category.cs:182`). +- **Walkthrough**: four positional parameters, `CategoryId`, `CategoryItemId`, `Name`, `Sort` (`UpdateCategoryItemCommand.cs:14-18`), with `CachePrefix => $"{typeof(Category).FullName}:"` (`:21`). Unlike [`RemoveCategoryItemCommand`](#removecategoryitemcommand) this command is always fully populated: the controller reads the item id from the route and the owning category id from a `required` member of the request body (`MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:44,161-165`), so the handler never has to hunt for the owner. +- **Why it's built this way**: a narrow, explicitly-typed update command keeps the write surface auditable and gives the caching decorator a well-defined eviction boundary; a general "patch the item entity" contract would have neither property, and it could not be validated by a single `AbstractValidator`. +- **Where it's used**: constructed by the category-items controller (`MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:161`, handler injected at `:65`), validated by [`UpdateCategoryItemCommandValidator`](#updatecategoryitemcommandvalidator), and handled by [`UpdateCategoryItemHandler`](#updatecategoryitemhandler). -### SpeakerLastNameRules +### AddCategoryItemCommandValidator -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.Validation` · `MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:22` · Level 7 · class (sealed, generic) +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.UseCases.AddCategoryItem` · `MMCA.ADC.Conference.Application/Categories/UseCases/AddCategoryItem/AddCategoryItemCommandValidator.cs:7` · Level 8 · class (sealed) -- **What it is**: the last-name twin of [`SpeakerFirstNameRules`](#speakerfirstnamerulest), declared in the same file. -- **Depends on**: the same three: [`RequiredStringRules`](group-06-validation.md#requiredstringrulest) (`SpeakerValidationRules.cs:3,23`), [`SpeakerInvariants`](group-17-conference-domain.md#speakerinvariants) (`:2,26`), and `Expression` (`:1,25`). -- **Concept**: nothing new; the inherit-the-shared-rules pattern taught by [`SpeakerFirstNameRules`](#speakerfirstnamerulest). The two declarations differ only in the display name passed to the base, "Last Name" instead of "First Name", and in the constant, `SpeakerInvariants.LastNameMaxLength` (`SpeakerValidationRules.cs:26`), which is also `200` (`MMCA.ADC.Conference.Domain/Speakers/SpeakerInvariants.cs:16`). They stay two types rather than one parameterized rule because each is included by name against a specific property, which is what makes the call site at the validator read as documentation. -- **Walkthrough**: `sealed class SpeakerLastNameRules : RequiredStringRules` (`SpeakerValidationRules.cs:22-23`) with the forwarding constructor at `:25-26`. -- **Where it's used**: included by [`SpeakerCreateRequestValidator`](#speakercreaterequestvalidator) (`MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequestValidator.cs:12`) and [`SpeakerUpdateRequestValidator`](#speakerupdaterequestvalidator) (`.../Update/SpeakerUpdateRequestValidator.cs:12`). +- **What it is**: the FluentValidation validator the pipeline runs against an [`AddCategoryItemCommand`](#addcategoryitemcommand) before [`AddCategoryItemHandler`](#addcategoryitemhandler) sees it. It holds no rule bodies of its own: it is two `Include` calls. +- **Depends on**: `FluentValidation.AbstractValidator` (NuGet, `AddCategoryItemCommandValidator.cs:1,7`) and the two shared rule objects [`CategoryItemNameRules`](#categoryitemnamerulest) and [`CategoryItemSortRules`](#categoryitemsortrulest) from `MMCA.ADC.Conference.Application.Categories.Validation` (`:2,11-12`). +- **Concept**: rule composition by `Include` with a property selector, the mechanism taught by [`QuestionTextRules`](#questiontextrulest), applied to a command rather than a wire request. It is byte-for-byte the same body as [`UpdateCategoryItemCommandValidator`](#updatecategoryitemcommandvalidator) with a different generic argument (`AddCategoryItemCommandValidator.cs:11-12`), which is the point: the add path and the edit path cannot disagree about what a valid item name or sort order is, because both include the same two rule objects. `[Rubric §16, Maintainability]` assesses whether a change has one edit point: changing the name limit is one edit in [`CategoryInvariants`](group-17-conference-domain.md#categoryinvariants), and both validators follow. `[Rubric §24, Forms, Validation & UX Safety]` assesses boundary rejection with actionable messages: both included rules carry stable machine codes alongside their human text. +- **Walkthrough**: a block-bodied constructor with two statements (`AddCategoryItemCommandValidator.cs:9-13`): `Include(new CategoryItemNameRules(p => p.Name))` (`:11`) and `Include(new CategoryItemSortRules(p => p.Sort))` (`:12`). The name rule enforces `NotEmpty` with code `CategoryItem.Name.Required` and `MaximumLength(CategoryInvariants.CategoryItemNameMaxLength)` with code `CategoryItem.Name.MaxLength`, the limit being `500` (`MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:30-33`; `MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:17`). The sort rule enforces `GreaterThanOrEqualTo(0)` with code `CategoryItem.Sort.Negative` (`ConferenceCategoryValidationRules.cs:43-45`). Two things are deliberately *not* checked here: the optional `CategoryItemId` (nothing to validate about an absent id) and name uniqueness within the category, which needs the sibling collection and therefore lives in the aggregate (BR-138, `MMCA.ADC.Conference.Domain/Categories/Category.cs:136-140`). +- **Why it's built this way**: field-shape rules that need only the incoming values run at the boundary where they can be reported as one complete, field-addressed list; rules that need loaded state run in the domain. That split is why this validator stays a dependency-free composition that can be constructed in a unit test with `new`. +- **Where it's used**: resolved by the validation decorator around [`AddCategoryItemHandler`](#addcategoryitemhandler), registered by the convention scan (`MMCA.ADC.Conference.Application/DependencyInjection.cs:125`); covered directly by `AddCategoryItemCommandValidatorTests` ([Group 27](group-27-testing-infrastructure.md#addcategoryitemcommandvalidatortests)), which lives alongside its update-side twin in `MMCA.ADC.Conference.Application.Tests/Categories/Validation/CategoryCommandValidatorTests.cs:8`. -### UpdateCategoryItemCommand +### AddCategoryItemHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.UseCases.UpdateCategoryItem` · `MMCA.ADC.Conference.Application/Categories/UseCases/UpdateCategoryItem/UpdateCategoryItemCommand.cs:14` · Level 7 · record (sealed) +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.UseCases.AddCategoryItem` · `MMCA.ADC.Conference.Application/Categories/UseCases/AddCategoryItem/AddCategoryItemHandler.cs:15` · Level 8 · class (sealed partial) -- **What it is**: the write intent for editing one [`CategoryItem`](group-17-conference-domain.md#categoryitem) inside its owning [`Category`](group-17-conference-domain.md#category): the two identifiers that locate it plus the two fields that may change. -- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`UpdateCategoryItemCommand.cs:2,18`), the [`Category`](group-17-conference-domain.md#category) type for the cache prefix (`:1,21`), and the `ConferenceCategoryIdentifierType` / `CategoryItemIdentifierType` aliases. -- **Concept**: the update-child shape, which sits between the add and remove shapes. It carries the aggregate id and the child id like a remove, plus exactly the fields that are editable and no others: `Name` is a non-nullable `string` and `Sort` a non-nullable `int` (`UpdateCategoryItemCommand.cs:17-18`), so this command cannot be used to blank a name by omission. Because both editable fields are here rather than spread across a partial-update document, the record is the complete answer to "what can this endpoint change", and it is also the exact target the validator binds to. `[Rubric §9, API & Contract Design]` assesses whether an edit contract states precisely what is mutable: the four positional parameters are that statement. `[Rubric §6, CQRS & Event-Driven]`: one command type, one handler, one write path, with the resulting `CategoryItemChanged` domain event raised inside the aggregate (`MMCA.ADC.Conference.Domain/Categories/Category.cs:182`). -- **Walkthrough**: four positional parameters, `CategoryId`, `CategoryItemId`, `Name`, `Sort` (`UpdateCategoryItemCommand.cs:14-18`), with `CachePrefix => $"{typeof(Category).FullName}:"` (`:21`). Unlike [`RemoveCategoryItemCommand`](#removecategoryitemcommand) this command is always fully populated: the controller reads the item id from the route and the owning category id from the request body (`MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:153-157`), so the handler never has to hunt for the owner. -- **Why it's built this way**: a narrow, explicitly-typed update command keeps the write surface auditable and gives the caching decorator a well-defined eviction boundary; a general "patch the item entity" contract would have neither property, and it could not be validated by a single `AbstractValidator`. -- **Where it's used**: constructed by the category-items controller (`MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:153`, handler injected at `:64`), validated by [`UpdateCategoryItemCommandValidator`](#updatecategoryitemcommandvalidator), and handled by [`UpdateCategoryItemHandler`](#updatecategoryitemhandler). +- **What it is**: the handler for [`AddCategoryItemCommand`](#addcategoryitemcommand). It loads the owning [`Category`](group-17-conference-domain.md#category), delegates the creation of the child to the aggregate, saves, and returns the new child as a [`CategoryItemDTO`](group-17-conference-domain.md#categoryitemdto). +- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`AddCategoryItemHandler.cs:5,16`), [`CategoryItemDTOMapper`](#categoryitemdtomapper) by concrete type (`:2,17`), `ILogger` with a source-generated `[LoggerMessage]` (`:1,18,41-42`), [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) (`:6,18`), the [`Category`](group-17-conference-domain.md#category) aggregate and its [`CategoryItem`](group-17-conference-domain.md#categoryitem) child (`:3`), [`CategoryItemDTO`](group-17-conference-domain.md#categoryitemdto) from the Shared project (`:4`), and [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error) (`:7`). +- **Concept introduced, the child-mutation handler that returns the new child.** The other two category-item handlers return a bare [`Result`](group-01-result-error-handling.md#result); this one returns `Result` (`AddCategoryItemHandler.cs:18,21`) because a create has something the caller does not yet have: the store-assigned id. That single difference drives everything else in the class. It is why a DTO mapper is injected at all (`:17`), why the aggregate's `AddCategoryItem` returns `Result` rather than `Result` (`MMCA.ADC.Conference.Domain/Categories/Category.cs:131`), and why the controller can answer `201 Created` with a route to the new row (`MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:147-150`). Note the ordering discipline: `MapToDTO` runs *after* `SaveChangesAsync` (`:34,38`), so the DTO carries the real key rather than the `0` that existed a moment earlier. `[Rubric §4, Domain-Driven Design]` assesses whether children are created through their root: the handler never calls `CategoryItem.Create` itself, it calls `category.AddCategoryItem(...)` (`:30`) and lets the aggregate run the BR-138 uniqueness rule first (`Category.cs:136-140`). `[Rubric §6, CQRS & Event-Driven]`: one command, one handler, one write path, with `CategoryItemChanged` queued inside the aggregate (`Category.cs:150`) and drained by the same save. `[Rubric §13, Observability & Operability]`: the source-generated log message records the item name and owning category as structured fields at zero allocation when the level is disabled (`:36,41-42`). +- **Walkthrough**: primary-constructor injection of the three collaborators (`AddCategoryItemHandler.cs:15-18`), declaring `ICommandHandler>`. `HandleAsync` (`:21-39`) resolves the typed repository through the unit of work (`:25`), then loads the aggregate with the *two-argument* overload `GetByIdAsync(command.CategoryId, cancellationToken)` (`:26`). That overload is worth pausing on: it takes no `includes` and no `asTracking` flag, and its implementation queries the tracked `Table` on purpose so that generic load-mutate-save handlers are not silent no-ops (`MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:176-181`). A missing category becomes `Result.Failure(Error.NotFound.WithSource(nameof(AddCategoryItemHandler)).WithTarget(nameof(Category)))` (`:27-28`). The domain call at `:30` returns `Result`, whose errors are re-wrapped into the handler's own generic shape on failure (`:31-32`), which is how a duplicate-name conflict from `CategoryInvariants.EnsureCategoryItemNameIsUnique` (`MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:37-57`) reaches the caller as a value rather than an exception. On success it awaits the single `unitOfWork.SaveChangesAsync(cancellationToken)` with `.ConfigureAwait(false)` (`:34`), logs (`:36`), and returns `Result.Success(dtoMapper.MapToDTO(result.Value!))` (`:38`). +- **Why it's built this way**: the save-only-after-the-aggregate-agrees shape is the module's canonical command body, so a rejected add writes nothing at all, and the one `SaveChangesAsync` stays the single boundary that stamps audit fields, captures domain events, and writes the outbox row (ADR-003). +- **Where it's used**: resolved by the category-items controller as `ICommandHandler>` (`MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:64`) and invoked from its hand-written, `[Idempotent]`-decorated create action (`:127-139`), after which the controller evicts the named output-cache policy explicitly (`:146`). Covered by `AddCategoryItemHandlerTests` ([Group 27](group-27-testing-infrastructure.md#addcategoryitemhandlertests)). +- **Caveats / not-in-source**: the include-less load at `:26` brings back the aggregate without materializing `Category.CategoryItems`, while the BR-138 uniqueness check inside the aggregate scans exactly that collection (`Category.cs:137-138`). Whether a duplicate name is caught therefore depends on which items EF has already tracked in the current scope, unlike [`UpdateCategoryItemHandler`](#updatecategoryitemhandler) and [`RemoveCategoryItemHandler`](#removecategoryitemhandler), which both pass `includes: [nameof(Category.CategoryItems)]` explicitly. Not determinable from source: whether the database also enforces the name uniqueness with an index, which would make the domain check a second line of defense rather than the only one. ### ConferenceCategoryCreateRequestMapper @@ -2290,10 +2535,10 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **What it is**: the one adapter that turns a [`ConferenceCategoryCreateRequest`](#conferencecategorycreaterequest) into a [`Category`](group-17-conference-domain.md#category) domain entity, by calling the aggregate's `Create` factory and returning whatever [`Result`](group-01-result-error-handling.md#result) it produces. - **Depends on**: [`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype) from `MMCA.Common.Application.Interfaces` (`ConferenceCategoryCreateRequestMapper.cs:2,11-12`), the [`Category`](group-17-conference-domain.md#category) aggregate and its `ConferenceCategoryIdentifierType` alias (`:1`), and [`Result`](group-01-result-error-handling.md#result) from `MMCA.Common.Shared.Abstractions` (`:3,15`). -- **Concept**: request-to-entity mapping as a separate injectable role, the same contract [`EventCreateRequestMapper`](#eventcreaterequestmapper) implements for events. The generic create pipeline never constructs entities itself: it resolves an `IEntityRequestMapper` and asks it for one, which is what lets one handler shape serve every aggregate while each aggregate keeps its own construction rules. Two properties matter. First, it is a plain `sealed class` with no `[Mapper]` attribute (`ConferenceCategoryCreateRequestMapper.cs:11`): unlike the read-side DTO mappers below, request-to-entity conversion is deliberately hand-written, because it must go through a factory that can *fail*, which a property-copy generator cannot express. Second, it returns `Task>` rather than a `Category`, so an invalid request produces a failure value that flows back as a 400-class response instead of an exception. `[Rubric §3, Clean Architecture]` assesses whether the domain stays independent of the delivery mechanism: the controller knows a request type, the domain knows a factory, and this class is the only thing that knows both. `[Rubric §4, Domain-Driven Design]`: the factory stays the single construction path, so no invariant can be bypassed by `new`. See [ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html) for the no-reflection mapping policy. -- **Walkthrough**: one method. `CreateEntityAsync(ConferenceCategoryCreateRequest request, CancellationToken)` (`ConferenceCategoryCreateRequestMapper.cs:15`) null-guards with `ArgumentNullException.ThrowIfNull(request)` (`:17`), then returns `Task.FromResult(Category.Create(request.Id, request.Title, request.Sort, request.Type))` (`:19-23`). The method is synchronous in substance: `Task.FromResult` satisfies the async contract without a state machine, because the factory does no I/O. Inside the factory (`MMCA.ADC.Conference.Domain/Categories/Category.cs:54-75`) the title is checked by `CategoryInvariants.EnsureTitleIsValid` through `Result.Combine` (`:60-61`), the id is resolved against the store-generated-key check (`:65,69`), and a `CategoryChanged` domain event with `DomainEntityState.Added` is queued on the new aggregate (`:72`) for the outbox to pick up at save time. Because the request's `Id` is non-nullable (`ConferenceCategoryCreateRequest.cs:16`), what actually reaches the factory is `0` when the client omits it. -- **Why it's built this way**: pushing construction into `Category.Create` means the invariant check and the domain event run for every caller, not just HTTP ones. The mapper adds no rules of its own, which is exactly what makes it safe to have several entry points into the same aggregate. -- **Where it's used**: injected into [`CreateConferenceCategoryHandler`](#createconferencecategoryhandler) as `IEntityRequestMapper` (`CreateConferenceCategoryHandler.cs:18`), registered by the module's convention scan rather than an explicit `AddScoped` line (`MMCA.ADC.Conference.Application/DependencyInjection.cs:112`). +- **Concept**: request-to-entity mapping as a separate injectable role. The generic create pipeline never constructs entities itself: it resolves an `IEntityRequestMapper` and asks it for one, which is what lets one handler shape serve every aggregate while each aggregate keeps its own construction rules. Two properties matter. First, it is a plain `sealed class` with no `[Mapper]` attribute (`ConferenceCategoryCreateRequestMapper.cs:11`): unlike the read-side DTO mappers in this unit, request-to-entity conversion is deliberately hand-written, because it must go through a factory that can *fail*, which a property-copy generator cannot express. Second, it returns `Task>` rather than a `Category`, so an invalid request produces a failure value that flows back as a 400-class response instead of an exception. `[Rubric §3, Clean Architecture]` assesses whether the domain stays independent of the delivery mechanism: the controller knows a request type, the domain knows a factory, and this class is the only thing that knows both. `[Rubric §4, Domain-Driven Design]`: the factory stays the single construction path, so no invariant can be bypassed by `new`. See [ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html) for the no-reflection mapping policy. +- **Walkthrough**: one method. `CreateEntityAsync(ConferenceCategoryCreateRequest request, CancellationToken)` (`ConferenceCategoryCreateRequestMapper.cs:15`) null-guards with `ArgumentNullException.ThrowIfNull(request)` (`:17`), then returns `Task.FromResult(Category.Create(request.Id, request.Title, request.Sort, request.Type))` (`:19-23`). The method is synchronous in substance: `Task.FromResult` satisfies the async contract without a state machine, because the factory does no I/O. Inside the factory (`MMCA.ADC.Conference.Domain/Categories/Category.cs:54-75`) the title is checked by `CategoryInvariants.EnsureTitleIsValid` through `Result.Combine` (`:60-61`), the id is resolved against the `[IdValueGenerated]` check (`:65,69`), and a `CategoryChanged` domain event with `DomainEntityState.Added` is queued on the new aggregate (`:72`) for the outbox to pick up at save time. Because the request's `Id` is non-nullable (`ConferenceCategoryCreateRequest.cs:16`), what actually reaches the factory is `0` when the client omits it. +- **Why it's built this way**: pushing construction into `Category.Create` means the invariant check and the domain event run for every caller, not just HTTP ones (the Sessionize importer calls the same factory at `MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/CategorySyncStrategy.cs:99`). The mapper adds no rules of its own, which is exactly what makes it safe to have several entry points into the same aggregate. +- **Where it's used**: injected into [`CreateConferenceCategoryHandler`](#createconferencecategoryhandler) as `IEntityRequestMapper` (`CreateConferenceCategoryHandler.cs:18`), registered by the module's convention scan rather than an explicit `AddScoped` line (`MMCA.ADC.Conference.Application/DependencyInjection.cs:125`). ### ConferenceCategoryCreateRequestValidator @@ -2301,30 +2546,21 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **What it is**: the FluentValidation validator the pipeline runs against a [`ConferenceCategoryCreateRequest`](#conferencecategorycreaterequest) before [`CreateConferenceCategoryHandler`](#createconferencecategoryhandler) sees it. It contains no rules of its own: it is a single `Include` call. - **Depends on**: `FluentValidation.AbstractValidator` (NuGet, `ConferenceCategoryCreateRequestValidator.cs:1,7`) and [`ConferenceCategoryTitleRules`](#conferencecategorytitlerulest) from `MMCA.ADC.Conference.Application.Categories.Validation` (`:2,10`). -- **Concept**: rule composition by `Include` with a property selector, the mechanism taught by [`QuestionTextRules`](#questiontextrulest). `Include(new ConferenceCategoryTitleRules(p => p.Title))` (`ConferenceCategoryCreateRequestValidator.cs:10`) copies the shared title rules onto this request's `Title` property, and [`ConferenceCategoryUpdateRequestValidator`](#conferencecategoryupdaterequestvalidator) includes the same rule object against the update request, so create and update cannot disagree about what a valid title is. Equally instructive is what is *not* validated: `Sort` and `Type` have no rules at all here, even though a sibling rule object for a sort value exists ([`CategoryItemSortRules`](#categoryitemsortrulest), applied only to category *items* by [`UpdateCategoryItemCommandValidator`](#updatecategoryitemcommandvalidator)). A negative `Sort` on a category is therefore accepted. `[Rubric §24, Forms, Validation & UX Safety]` assesses whether invalid input is rejected at the boundary: the title path is covered, the sort path is not. `[Rubric §16, Maintainability]`: one shared rule object is one edit point instead of one per slice. +- **Concept**: rule composition by `Include` with a property selector, the mechanism taught by [`QuestionTextRules`](#questiontextrulest). `Include(new ConferenceCategoryTitleRules(p => p.Title))` (`ConferenceCategoryCreateRequestValidator.cs:10`) copies the shared title rules onto this request's `Title` property, and the update-side validator includes the same rule object against [`ConferenceCategoryUpdateRequest`](#conferencecategoryupdaterequest), so create and update cannot disagree about what a valid title is. Equally instructive is what is *not* validated: `Sort` and `Type` have no rules at all here, even though a sibling rule object for a sort value exists ([`CategoryItemSortRules`](#categoryitemsortrulest), applied only to category *items* by [`AddCategoryItemCommandValidator`](#addcategoryitemcommandvalidator) and [`UpdateCategoryItemCommandValidator`](#updatecategoryitemcommandvalidator)). A negative `Sort` on a category is therefore accepted. `[Rubric §24, Forms, Validation & UX Safety]` assesses whether invalid input is rejected at the boundary: the title path is covered, the sort path is not. `[Rubric §16, Maintainability]`: one shared rule object is one edit point instead of one per slice. - **Walkthrough**: the whole type is an expression-bodied constructor (`ConferenceCategoryCreateRequestValidator.cs:9-10`). The rule bodies it pulls in live at `MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:16-19`: `NotEmpty` with code `Category.Title.Required`, then `MaximumLength(CategoryInvariants.TitleMaxLength)` with code `Category.Title.MaxLength`, where the limit is `255` (`MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:14`). Note the domain declares that limit as `public static readonly int` rather than `const`, so it is read at runtime rather than baked into each caller. -- **Why it's built this way**: validating at the pipeline boundary gives the caller a complete, field-addressed error list in one round trip, while [`Category.Create`](group-17-conference-domain.md#category) keeps its own `EnsureTitleIsValid` check (`MMCA.ADC.Conference.Domain/Categories/Category.cs:61`) as the backstop for non-HTTP callers such as the Sessionize import. The duplication is intentional and cheap because both sides read the same constant. -- **Where it's used**: resolved by the validation decorator around [`CreateConferenceCategoryHandler`](#createconferencecategoryhandler), registered by the convention scan (`MMCA.ADC.Conference.Application/DependencyInjection.cs:112`); covered directly by `ConferenceCategoryCreateRequestValidatorTests` ([Group 27](group-27-testing-infrastructure.md#conferencecategorycreaterequestvalidatortests)). +- **Why it's built this way**: validating at the pipeline boundary gives the caller a complete, field-addressed error list in one round trip, while [`Category.Create`](group-17-conference-domain.md#category) keeps its own `EnsureTitleIsValid` check (`MMCA.ADC.Conference.Domain/Categories/Category.cs:60-61`) as the backstop for non-HTTP callers such as the Sessionize import. The duplication is intentional and cheap because both sides read the same constant. +- **Where it's used**: resolved by the validation decorator around [`CreateConferenceCategoryHandler`](#createconferencecategoryhandler), registered by the convention scan (`MMCA.ADC.Conference.Application/DependencyInjection.cs:125`); covered directly by `ConferenceCategoryCreateRequestValidatorTests` ([Group 27](group-27-testing-infrastructure.md#conferencecategorycreaterequestvalidatortests)). -### EventQuestionAnswerDTOMapper - -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.DTOs` · `MMCA.ADC.Conference.Application/Events/DTOs/EventQuestionAnswerDTOMapper.cs:12` · Level 8 · class (sealed partial) - -- **What it is**: the read-side mapper that turns an [`EventQuestionAnswer`](group-17-conference-domain.md#eventquestionanswer) domain entity into an [`EventQuestionAnswerDTO`](group-17-conference-domain.md#eventquestionanswerdto). The single-entity method has no body in this file: Mapperly generates it at compile time. -- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) from `MMCA.Common.Application.Interfaces` (`EventQuestionAnswerDTOMapper.cs:3,13`), `Riok.Mapperly.Abstractions` (NuGet, `:4,11`), the [`EventQuestionAnswer`](group-17-conference-domain.md#eventquestionanswer) entity (`:1`), the [`EventQuestionAnswerDTO`](group-17-conference-domain.md#eventquestionanswerdto) contract from the Shared project (`:2`), and the `EventQuestionAnswerIdentifierType` alias (`= int`). -- **Concept introduced (for this unit), source-generated DTO mapping.** `[Mapper]` on a `partial` class (`EventQuestionAnswerDTOMapper.cs:11-12`) tells the Mapperly generator to fill in the body of every `partial` method it finds, here `MapToDTO` (`:16`). The generated body is straight-line property assignment: no reflection, no expression trees, no runtime configuration, and a compile error rather than a silent null if a target member has no source. That is the whole point of [ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html): mapping is either hand-written or generated, never reflective. `[Rubric §12, Performance & Scalability]` assesses whether hot paths avoid avoidable runtime work: every read endpoint maps its result set, so a generated assignment beats a reflective copy at the exact place volume lands. `[Rubric §9, API & Contract Design]`: the DTO, not the entity, is what crosses the wire, so a domain refactor cannot silently reshape the JSON. `[Rubric §14, Testability]`: the mapper is a pure function of its input and is tested directly (`EventQuestionAnswerDTOMapperTests`, [Group 27](group-27-testing-infrastructure.md#eventquestionanswerdtomappertests)). -- **Walkthrough**: two members. `public partial EventQuestionAnswerDTO MapToDTO(EventQuestionAnswer entity)` (`EventQuestionAnswerDTOMapper.cs:16`) is the declaration whose implementation the generator supplies. `MapToDTOs(IReadOnlyCollection)` (`:19-23`) is hand-written and deliberately so: it null-guards with `ArgumentNullException.ThrowIfNull` (`:21`) and then projects with a collection expression over a spread, `[.. entityCollection.Select(MapToDTO)]` (`:22`), which materializes a single array without an intermediate `List` growth cycle. The collection method delegating to the generated single-item method is the shape every mapper in this family repeats. -- **Where it's used**: injected concretely into [`AddEventQuestionAnswerHandler`](#addeventquestionanswerhandler) (`MMCA.ADC.Conference.Application/Events/UseCases/AddEventQuestionAnswer/AddEventQuestionAnswerHandler.cs:21`), consumed as a child mapper by [`EventDTOMapper`](#eventdtomapper) through `[UseMapper]` (`EventDTOMapper.cs:26-27`), and resolved as `IEntityDTOMapper` by the generic query service registered for the entity (`MMCA.ADC.Conference.Application/DependencyInjection.cs:90`, constructor parameter at `MMCA.Common.Application/Services/EntityQueryService.cs:35`). Registration is by the convention scan (`DependencyInjection.cs:112`). - -### EventSpeakerDTOMapper +### ConferenceCategoryDTOMapper -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.DTOs` · `MMCA.ADC.Conference.Application/Events/DTOs/EventSpeakerDTOMapper.cs:12` · Level 8 · class (sealed partial) +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.DTOs` · `MMCA.ADC.Conference.Application/Categories/DTOs/ConferenceCategoryDTOMapper.cs:13` · Level 8 · class (sealed partial) -- **What it is**: the same generated mapper for the [`EventSpeaker`](group-17-conference-domain.md#eventspeaker) association entity to [`EventSpeakerDTO`](group-17-conference-domain.md#eventspeakerdto). -- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) (`EventSpeakerDTOMapper.cs:3,13`), Mapperly (`:4,11`), the entity and DTO (`:1-2`), and the `EventSpeakerIdentifierType` alias. -- **Concept**: nothing new; the `[Mapper]`-plus-`partial` shape taught by [`EventQuestionAnswerDTOMapper`](#eventquestionanswerdtomapper). Reading the two side by side is the fastest way to see how little varies: the entity, the DTO, and the identifier alias in the interface arguments, and nothing else. -- **Walkthrough**: `public partial EventSpeakerDTO MapToDTO(EventSpeaker entity)` (`EventSpeakerDTOMapper.cs:16`) generated by Mapperly, and the hand-written `MapToDTOs` with its null guard and spread projection (`:19-23`). -- **Where it's used**: injected concretely into [`AddEventSpeakerHandler`](#addeventspeakerhandler) (`MMCA.ADC.Conference.Application/Events/UseCases/AddEventSpeaker/AddEventSpeakerHandler.cs:17`), used as a child mapper by [`EventDTOMapper`](#eventdtomapper) (`EventDTOMapper.cs:23-24`), and resolved by the query service registered at `MMCA.ADC.Conference.Application/DependencyInjection.cs:87`. Tested by `EventSpeakerDTOMapperTests` ([Group 27](group-27-testing-infrastructure.md#eventspeakerdtomappertests)). +- **What it is**: the read-side mapper for the [`Category`](group-17-conference-domain.md#category) aggregate. It maps the root and delegates its one child collection to [`CategoryItemDTOMapper`](#categoryitemdtomapper), so a category and its options are produced by one call. +- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) (`ConferenceCategoryDTOMapper.cs:3,15`), `Riok.Mapperly.Abstractions` (NuGet, `:4,12,17`), [`CategoryItemDTOMapper`](#categoryitemdtomapper) (`:14,18`), the [`Category`](group-17-conference-domain.md#category) aggregate (`:1`), [`ConferenceCategoryDTO`](group-17-conference-domain.md#conferencecategorydto) from the Shared project (`:2`), and the `ConferenceCategoryIdentifierType` alias. +- **Concept introduced, composing generated mappers with `[UseMapper]`.** The generator faced with `IReadOnlyCollection` on the source and `IReadOnlyCollection` on the target could generate a second, private copy of the item mapping. `[UseMapper]` on a field (`ConferenceCategoryDTOMapper.cs:17-18`) tells it not to: "when you need to map a `CategoryItem`, call this instance". That is how an aggregate DTO gets its child collection filled without duplicating child-mapping logic, and it is why a category item rendered inside a category is byte-identical to one rendered from the items endpoint. The dependency arrives through a primary constructor (`:13-14`) and is stored in a `readonly` field, so the DI container composes the two mappers and neither knows about the container. `[Rubric §2, Design Patterns]` assesses whether composition is preferred to duplication: this is the composite mapper with an injected leaf. `[Rubric §9, API & Contract Design]`: the target contract exposes `RowVersion` (`MMCA.ADC.Conference.Shared/Categories/ConferenceCategoryDTO.cs:15`, via `IConcurrencyAware` at `:9`), so the optimistic-concurrency token travels to the client and back on edit. `[Rubric §12, Performance & Scalability]`: root and children are both straight-line generated assignment, no reflection anywhere on the read path. +- **Walkthrough**: `[Mapper]` on the `sealed partial class` (`ConferenceCategoryDTOMapper.cs:12-13`) turns on generation. The primary constructor takes the child mapper (`:13-14`) and assigns it to the `[UseMapper]`-annotated field `_categoryItemDTOMapper` (`:17-18`). `public partial ConferenceCategoryDTO MapToDTO(Category entity)` (`:21`) is the declaration the generator implements: it copies `Id`, `RowVersion`, `Title`, `Sort`, `Type` and projects `CategoryItems` (`MMCA.ADC.Conference.Shared/Categories/ConferenceCategoryDTO.cs:12-27`) through the injected child mapper. `MapToDTOs` (`:24-28`) is the same hand-written null-guarded spread projection as its siblings (`:26-27`). Unlike [`EventDTOMapper`](#eventdtomapper), this class needs no `[MapperIgnoreTarget]` escape hatch, because every DTO member has a same-named, same-typed source member on the entity. +- **Why it's built this way**: keeping the child mapper injected rather than generated inline means one `CategoryItemDTO` shape exists in the system regardless of how it was reached, and it keeps both mappers independently unit-testable. +- **Where it's used**: injected by concrete type into [`CreateConferenceCategoryHandler`](#createconferencecategoryhandler) (`CreateConferenceCategoryHandler.cs:19`) and [`UpdateConferenceCategoryHandler`](#updateconferencecategoryhandler) (`MMCA.ADC.Conference.Application/Categories/UseCases/Update/UpdateConferenceCategoryHandler.cs:17`), and resolved as `IEntityDTOMapper` by the category query service (`MMCA.ADC.Conference.Application/DependencyInjection.cs:71`, constructor parameter at `MMCA.Common.Application/Services/EntityQueryService.cs:35`). Registration is by the convention scan (`DependencyInjection.cs:125`). Tested by `ConferenceCategoryDTOMapperTests` ([Group 27](group-27-testing-infrastructure.md#conferencecategorydtomappertests)). ### RemoveCategoryItemHandler @@ -2332,31 +2568,21 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **What it is**: the handler for [`RemoveCategoryItemCommand`](#removecategoryitemcommand). It loads the owning [`Category`](group-17-conference-domain.md#category) with its items, delegates the removal to the aggregate, and saves only if the aggregate agreed. It is the one handler in the category slice that can find its aggregate two different ways. - **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`RemoveCategoryItemHandler.cs:3,14`), `ILogger` with a source-generated `[LoggerMessage]` (`:1,15,60-61`), [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) (`:4,15`), the [`Category`](group-17-conference-domain.md#category) aggregate and its [`CategoryItem`](group-17-conference-domain.md#categoryitem) child (`:2`), and [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error) (`:5`). -- **Concept introduced, resolving the aggregate from the child when the caller does not name it.** The usual child-mutation handler loads by aggregate id and stops there. This one cannot assume it has an aggregate id, because the DELETE endpoint takes `categoryId` as an optional query argument (`MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:173`) and the UI's generic delete sends only the item id, so `CategoryId` arrives as `default` (`0`). The handler branches on that (`RemoveCategoryItemHandler.cs:28`): when the id is unset it runs `GetAllAsync` with a predicate that finds the owner *through* its children, `where: c => c.CategoryItems.Any(ci => ci.Id == command.CategoryItemId)` (`:30-34`), and takes the first match (`:35`); otherwise it loads by id directly (`:39-43`). Both branches pass `includes: [nameof(Category.CategoryItems)]` and `asTracking: true`, and both are load-bearing: without the include the child collection is empty and the domain method finds nothing to remove, and without tracking the change would be made on an untracked graph and silently lost at `SaveChangesAsync` (see the primer on the EF tracking rule). `[Rubric §4, Domain-Driven Design]` assesses whether children are reached through their root: even the id-less path resolves the root first and then asks *it* to remove the child. `[Rubric §12, Performance & Scalability]`: the fallback path is a scan-and-filter query rather than a keyed lookup, which is the cost of accepting a request that omits the owner. `[Rubric §13, Observability & Operability]`: the source-generated log message records both identifiers with structured fields at zero allocation when the level is disabled. -- **Walkthrough**: primary-constructor injection of `IUnitOfWork` and `ILogger` (`RemoveCategoryItemHandler.cs:13-15`), implementing `ICommandHandler`. `HandleAsync` (`:18-58`) resolves the typed repository (`:22`), runs the two-branch load described above (`:27-44`), and returns `Error.NotFound.WithSource(nameof(RemoveCategoryItemHandler)).WithTarget(nameof(Category))` when nothing was found (`:46-47`); note that the fallback branch reports a *missing category* even when what the caller actually got wrong was the item id. It then calls `entity.RemoveCategoryItem(command.CategoryItemId)` (`:49`), and only on success awaits `unitOfWork.SaveChangesAsync(cancellationToken)` with `.ConfigureAwait(false)` and emits `LogCategoryItemRemoved` (`:50-55`, declaration `:60-61`). The aggregate's own [`Result`](group-01-result-error-handling.md#result) is returned unchanged (`:57`), so a missing-child failure from `GetCategoryItemOrNotFound` (`MMCA.ADC.Conference.Domain/Categories/Category.cs:194,217`) reaches the caller with its domain error intact. Inside the aggregate the removal is a soft delete on the child (`Category.cs:199`) followed by a `CategoryItemChanged` event with `DomainEntityState.Deleted` (`:203`). +- **Concept introduced, resolving the aggregate from the child when the caller does not name it.** The usual child-mutation handler loads by aggregate id and stops there. This one cannot assume it has an aggregate id, because the DELETE endpoint takes `categoryId` as a plain query argument (`MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:181`) and the UI's generic delete sends only the item id, so `CategoryId` arrives as `default` (`0`). The handler branches on that (`RemoveCategoryItemHandler.cs:28`): when the id is unset it runs `GetAllAsync` with a predicate that finds the owner *through* its children, `where: c => c.CategoryItems.Any(ci => ci.Id == command.CategoryItemId)` (`:30-34`), and takes the first match (`:35`); otherwise it loads by id directly (`:39-43`). Both branches pass `includes: [nameof(Category.CategoryItems)]` and `asTracking: true`, and both are load-bearing: without the include the child collection is empty and the domain method finds nothing to remove, and without tracking the change would be made on an untracked graph and silently lost at `SaveChangesAsync` (the include-carrying repository overload defaults `asTracking` to `false`, `MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:31-35`). `[Rubric §4, Domain-Driven Design]` assesses whether children are reached through their root: even the id-less path resolves the root first and then asks *it* to remove the child. `[Rubric §12, Performance & Scalability]`: the fallback path is a filter-over-children query rather than a keyed lookup, which is the cost of accepting a request that omits the owner. `[Rubric §13, Observability & Operability]`: the source-generated log message records both identifiers with structured fields at zero allocation when the level is disabled. +- **Walkthrough**: primary-constructor injection of `IUnitOfWork` and `ILogger` (`RemoveCategoryItemHandler.cs:13-15`), implementing `ICommandHandler`. `HandleAsync` (`:18-58`) resolves the typed repository (`:22`), runs the two-branch load described above (`:27-44`), and returns `Error.NotFound.WithSource(nameof(RemoveCategoryItemHandler)).WithTarget(nameof(Category))` when nothing was found (`:46-47`); note that the fallback branch reports a *missing category* even when what the caller actually got wrong was the item id. It then calls `entity.RemoveCategoryItem(command.CategoryItemId)` (`:49`), and only on success awaits `unitOfWork.SaveChangesAsync(cancellationToken)` with `.ConfigureAwait(false)` and emits `LogCategoryItemRemoved` (`:50-55`, declaration `:60-61`). The aggregate's own [`Result`](group-01-result-error-handling.md#result) is returned unchanged (`:57`), so a missing-child failure from `GetCategoryItemOrNotFound` (`MMCA.ADC.Conference.Domain/Categories/Category.cs:194,214-217`) reaches the caller with its domain error intact. Inside the aggregate the removal is a soft delete on the child (`Category.cs:199`) followed by a `CategoryItemChanged` event with `DomainEntityState.Deleted` (`:203`). - **Why it's built this way**: the save-only-on-success shape is the module's canonical command body, so a rejected removal writes nothing at all and the single `SaveChangesAsync` stays the one boundary that stamps audit fields, captures domain events, and writes the outbox row (ADR-003). The owner-resolution fallback exists because the UI reuses one generic delete action for every child grid, and the alternative would have been a bespoke client call per child type. -- **Where it's used**: resolved by the category-items controller as `ICommandHandler` (`MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:65`) and invoked at `:177`, after which the controller also evicts the named output-cache policy explicitly (`:185`). Covered by `RemoveCategoryItemHandlerTests` ([Group 27](group-27-testing-infrastructure.md#removecategoryitemhandlertests)). - -### RoomDTOMapper - -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.DTOs` · `MMCA.ADC.Conference.Application/Events/DTOs/RoomDTOMapper.cs:12` · Level 8 · class (sealed partial) - -- **What it is**: the generated mapper from a [`Room`](group-17-conference-domain.md#room) child entity to a [`RoomDTO`](group-17-conference-domain.md#roomdto). -- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) (`RoomDTOMapper.cs:3,13`), Mapperly (`:4,11`), the entity and DTO (`:1-2`), and the `RoomIdentifierType` alias. -- **Concept**: nothing new; the `[Mapper]`-plus-`partial` shape taught by [`EventQuestionAnswerDTOMapper`](#eventquestionanswerdtomapper). -- **Walkthrough**: `public partial RoomDTO MapToDTO(Room entity)` (`RoomDTOMapper.cs:16`) generated by Mapperly, plus the hand-written `MapToDTOs` with null guard and spread projection (`:19-23`). -- **Where it's used**: injected concretely into [`AddRoomHandler`](#addroomhandler) (`MMCA.ADC.Conference.Application/Events/UseCases/AddRoom/AddRoomHandler.cs:21`), used as a child mapper by [`EventDTOMapper`](#eventdtomapper) (`EventDTOMapper.cs:20-21`), and resolved by the query service registered at `MMCA.ADC.Conference.Application/DependencyInjection.cs:81`. Tested by `RoomDTOMapperTests` ([Group 27](group-27-testing-infrastructure.md#roomdtomappertests)). +- **Where it's used**: resolved by the category-items controller as `ICommandHandler` (`MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:66`) and invoked at `:184-186`, after which the controller evicts the named output-cache policy explicitly and returns `204 No Content` (`:193-194`). Covered by `RemoveCategoryItemHandlerTests` ([Group 27](group-27-testing-infrastructure.md#removecategoryitemhandlertests)). ### UpdateCategoryItemCommandValidator > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories.UseCases.UpdateCategoryItem` · `MMCA.ADC.Conference.Application/Categories/UseCases/UpdateCategoryItem/UpdateCategoryItemCommandValidator.cs:7` · Level 8 · class (sealed) -- **What it is**: the validator the pipeline runs against an [`UpdateCategoryItemCommand`](#updatecategoryitemcommand). Like its create-side cousin it holds no rule bodies: it is two `Include` calls. +- **What it is**: the validator the pipeline runs against an [`UpdateCategoryItemCommand`](#updatecategoryitemcommand). Like its add-side twin it holds no rule bodies: it is two `Include` calls. - **Depends on**: `FluentValidation.AbstractValidator` (NuGet, `UpdateCategoryItemCommandValidator.cs:1,7`) and the two shared rule objects [`CategoryItemNameRules`](#categoryitemnamerulest) and [`CategoryItemSortRules`](#categoryitemsortrulest) from `MMCA.ADC.Conference.Application.Categories.Validation` (`:2,11-12`). -- **Concept**: validating a *command* rather than a request. [`ConferenceCategoryCreateRequestValidator`](#conferencecategorycreaterequestvalidator) targets a wire contract; this one targets the record the controller assembles from a route value plus a body (`MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:153-157`). The pipeline treats them identically because both are just `T` to FluentValidation, which is what makes it possible to validate at the same boundary whether or not a slice has a distinct request type. The two included rule objects also show the generic-rule pattern working over different selector types: `CategoryItemNameRules` takes an `Expression>`, `CategoryItemSortRules` takes an `Expression>` (`MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:30,43`). `[Rubric §24, Forms, Validation & UX Safety]` assesses boundary rejection with actionable messages: both rules carry stable codes, `CategoryItem.Name.Required` / `CategoryItem.Name.MaxLength` (`ConferenceCategoryValidationRules.cs:32-33`) and `CategoryItem.Sort.Negative` (`:45`). `[Rubric §1, SOLID]`: name rules and sort rules are separate objects with separate reasons to change, and the validator composes them instead of inheriting a fat base. -- **Walkthrough**: a block-bodied constructor with two statements (`UpdateCategoryItemCommandValidator.cs:9-13`): `Include(new CategoryItemNameRules(p => p.Name))` (`:11`) and `Include(new CategoryItemSortRules(p => p.Sort))` (`:12`). The name rule enforces `NotEmpty` plus `MaximumLength(CategoryInvariants.CategoryItemNameMaxLength)`, which is `500` (`MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:17`); the sort rule enforces `GreaterThanOrEqualTo(0)` (`ConferenceCategoryValidationRules.cs:45`). What this validator does not check is uniqueness of the name within the category: that rule (BR-138) needs the sibling collection and therefore lives in the aggregate (`MMCA.ADC.Conference.Domain/Categories/Category.cs:172-176`). -- **Why it's built this way**: field-shape rules that need only the incoming values run at the boundary where they can be reported as a complete list; rules that need loaded state run in the domain. Splitting them that way is why the validator can stay a pure, allocation-free composition with no repository dependency. -- **Where it's used**: resolved by the validation decorator around [`UpdateCategoryItemHandler`](#updatecategoryitemhandler), registered by the convention scan (`MMCA.ADC.Conference.Application/DependencyInjection.cs:112`); covered by `UpdateCategoryItemCommandValidatorTests` ([Group 27](group-27-testing-infrastructure.md#updatecategoryitemcommandvalidatortests)). Its add-side sibling is [`AddCategoryItemCommandValidator`](#addcategoryitemcommandvalidator). +- **Concept**: validating a *command* rather than a request. [`ConferenceCategoryCreateRequestValidator`](#conferencecategorycreaterequestvalidator) targets a wire contract; this one targets the record the controller assembles from a route value plus a body (`MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:161-165`). The pipeline treats them identically because both are just `T` to FluentValidation, which is what makes it possible to validate at the same boundary whether or not a slice has a distinct request type. The two included rule objects also show the generic-rule pattern working over different selector types: `CategoryItemNameRules` takes an `Expression>`, `CategoryItemSortRules` takes an `Expression>` (`MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:30,43`). `[Rubric §24, Forms, Validation & UX Safety]` assesses boundary rejection with actionable messages: both rules carry stable codes, `CategoryItem.Name.Required` / `CategoryItem.Name.MaxLength` (`ConferenceCategoryValidationRules.cs:32-33`) and `CategoryItem.Sort.Negative` (`:45`). `[Rubric §1, SOLID]`: name rules and sort rules are separate objects with separate reasons to change, and the validator composes them instead of inheriting a fat base. +- **Walkthrough**: a block-bodied constructor with two statements (`UpdateCategoryItemCommandValidator.cs:9-13`): `Include(new CategoryItemNameRules(p => p.Name))` (`:11`) and `Include(new CategoryItemSortRules(p => p.Sort))` (`:12`). The name rule enforces `NotEmpty` plus `MaximumLength(CategoryInvariants.CategoryItemNameMaxLength)`, which is `500` (`MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:17`); the sort rule enforces `GreaterThanOrEqualTo(0)` (`ConferenceCategoryValidationRules.cs:44-45`). What this validator does not check is uniqueness of the name within the category: that rule (BR-138) needs the sibling collection and therefore lives in the aggregate, where the update path passes the item being edited as the exclusion (`MMCA.ADC.Conference.Domain/Categories/Category.cs:172-176`). +- **Why it's built this way**: field-shape rules that need only the incoming values run at the boundary where they can be reported as a complete list; rules that need loaded state run in the domain. Splitting them that way is why the validator can stay a pure, dependency-free composition with no repository dependency. +- **Where it's used**: resolved by the validation decorator around [`UpdateCategoryItemHandler`](#updatecategoryitemhandler), registered by the convention scan (`MMCA.ADC.Conference.Application/DependencyInjection.cs:125`); covered by `UpdateCategoryItemCommandValidatorTests` ([Group 27](group-27-testing-infrastructure.md#updatecategoryitemcommandvalidatortests)), which shares a file with its add-side twin (`MMCA.ADC.Conference.Application.Tests/Categories/Validation/CategoryCommandValidatorTests.cs:54`). Its add-side sibling is [`AddCategoryItemCommandValidator`](#addcategoryitemcommandvalidator). ### UpdateCategoryItemHandler @@ -2364,10 +2590,10 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **What it is**: the handler for [`UpdateCategoryItemCommand`](#updatecategoryitemcommand). It loads the owning [`Category`](group-17-conference-domain.md#category) with its items, delegates the edit to the aggregate, and saves only if the aggregate agreed. - **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`UpdateCategoryItemHandler.cs:3,14`), `ILogger` with a source-generated `[LoggerMessage]` (`:1,15,42-43`), [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) (`:4,15`), the [`Category`](group-17-conference-domain.md#category) aggregate and its [`CategoryItem`](group-17-conference-domain.md#categoryitem) child (`:2`), and [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error) (`:5`). -- **Concept**: the canonical child-mutation handler body, and the cleanest example of it in this unit because it has no fallback lookup to distract from the shape. Compare it line by line with [`RemoveCategoryItemHandler`](#removecategoryitemhandler): same primary constructor, same `GetRepository` call, same `includes` plus `asTracking: true` load, same decorated `Error.NotFound`, same save-only-on-success tail. The only differences are the domain method called and the log message, because the update always arrives with its owner id in the body and therefore needs only one load path. `[Rubric §4, Domain-Driven Design]` assesses whether the aggregate owns its invariants: the handler never touches the child collection itself, and the BR-138 case-insensitive uniqueness check that makes this operation interesting lives inside `Category.UpdateCategoryItem` (`MMCA.ADC.Conference.Domain/Categories/Category.cs:173-176`). `[Rubric §14, Testability]`: with only a unit of work and a logger injected, the handler is exercised in the unit tier against a faked repository (`UpdateCategoryItemHandlerTests`, [Group 27](group-27-testing-infrastructure.md#updatecategoryitemhandlertests)). -- **Walkthrough**: primary-constructor injection (`UpdateCategoryItemHandler.cs:13-15`), implementing `ICommandHandler`. `HandleAsync` (`:18-40`) resolves the typed repository (`:22`), loads with `GetByIdAsync(command.CategoryId, includes: [nameof(Category.CategoryItems)], asTracking: true, ...)` (`:23-27`), returns `Error.NotFound.WithSource(nameof(UpdateCategoryItemHandler)).WithTarget(nameof(Category))` when the row is absent (`:28-29`), calls `entity.UpdateCategoryItem(command.CategoryItemId, command.Name, command.Sort)` (`:31`), and on success awaits `SaveChangesAsync` with `.ConfigureAwait(false)` and emits `LogCategoryItemUpdated` (`:32-37`, declaration `:42-43`). The domain result is returned unchanged (`:39`). Inside the aggregate the order of checks matters: the child is located first (`Category.cs:167`), then the uniqueness rule runs excluding the item being edited (`:173-174`), then the child's own `Update` applies the values (`:178`), and only then is `CategoryItemChanged` with `DomainEntityState.Updated` queued (`:182`). +- **Concept**: the canonical child-mutation handler body, and the cleanest example of it in this unit because it has no fallback lookup to distract from the shape. Compare it line by line with [`RemoveCategoryItemHandler`](#removecategoryitemhandler): same primary constructor, same `GetRepository` call, same `includes` plus `asTracking: true` load, same decorated `Error.NotFound`, same save-only-on-success tail. The only differences are the domain method called and the log message, because the update always arrives with its owner id in the body and therefore needs only one load path. `[Rubric §4, Domain-Driven Design]` assesses whether the aggregate owns its invariants: the handler never touches the child collection itself, and the BR-138 case-insensitive uniqueness check that makes this operation interesting lives inside `Category.UpdateCategoryItem` (`MMCA.ADC.Conference.Domain/Categories/Category.cs:172-176`). `[Rubric §14, Testability]`: with only a unit of work and a logger injected, the handler is exercised in the unit tier against a faked repository (`UpdateCategoryItemHandlerTests`, [Group 27](group-27-testing-infrastructure.md#updatecategoryitemhandlertests)). +- **Walkthrough**: primary-constructor injection (`UpdateCategoryItemHandler.cs:13-15`), implementing `ICommandHandler`. `HandleAsync` (`:18-40`) resolves the typed repository (`:22`), loads with `GetByIdAsync(command.CategoryId, includes: [nameof(Category.CategoryItems)], asTracking: true, ...)` (`:23-27`), returns `Error.NotFound.WithSource(nameof(UpdateCategoryItemHandler)).WithTarget(nameof(Category))` when the row is absent (`:28-29`), calls `entity.UpdateCategoryItem(command.CategoryItemId, command.Name, command.Sort)` (`:31`), and on success awaits `SaveChangesAsync` with `.ConfigureAwait(false)` and emits `LogCategoryItemUpdated` (`:32-37`, declaration `:42-43`). The domain result is returned unchanged (`:39`). Inside the aggregate the order of checks matters: the child is located first (`Category.cs:167`), then the uniqueness rule runs excluding the item being edited (`:173-174`), then the child's own `Update` applies the values after re-checking the name invariant (`:178`; `MMCA.ADC.Conference.Domain/Categories/CategoryItem.cs:73-85`), and only then is `CategoryItemChanged` with `DomainEntityState.Updated` queued (`:182`). - **Why it's built this way**: keeping the handler this thin means every rule that could reject the edit is discoverable in one place, the aggregate, and the handler's entire contribution is orchestration: load with the right graph, delegate, persist once, log. -- **Where it's used**: resolved by the category-items controller as `ICommandHandler` (`MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:64`) and invoked at `:152`, followed by an explicit output-cache eviction and a `204 No Content` (`:165-166`). +- **Where it's used**: resolved by the category-items controller as `ICommandHandler` (`MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:65`) and invoked at `:160-166`, followed by an explicit output-cache eviction and a `204 No Content` (`:173-174`). ### CreateConferenceCategoryHandler @@ -2378,95 +2604,115 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **Concept**: the generic create slice assembled end to end, the same composition [`CreateEventHandler`](#createeventhandler) uses for events. Read the four injected dependencies as a pipeline (`CreateConferenceCategoryHandler.cs:16-20`): the request mapper turns the wire contract into a validated entity, the unit of work supplies the typed repository and owns the transaction boundary, and the DTO mapper turns the persisted entity back into a wire contract. Notice the asymmetry in how the two mappers are injected: the request mapper arrives *by interface* (`:18`) because the generic create machinery is written against that abstraction, while the DTO mapper arrives *by concrete type* (`:19`) because this handler wants that specific mapper and its nested child mapper. Notice equally what is absent: no validator call (the validation decorator already ran [`ConferenceCategoryCreateRequestValidator`](#conferencecategorycreaterequestvalidator)), no cache eviction (the caching decorator reads `CachePrefix` off the request), and no `try`/`catch` (failures arrive as [`Result`](group-01-result-error-handling.md#result) values). That absence is the point of the decorator pipeline taught in [Group 05](group-05-cqrs-pipeline.md). `[Rubric §5, Vertical Slice]` assesses whether a use case is self-contained: the four Create types live in one folder and this handler is the slice's entry point. `[Rubric §3, Clean Architecture]`: the Application layer depends on abstractions and on the Domain, never on EF Core or ASP.NET. `[Rubric §6, CQRS & Event-Driven]`: one command type, one handler, one write path, and the `CategoryChanged` event raised inside the factory (`MMCA.ADC.Conference.Domain/Categories/Category.cs:72`) is captured by the same `SaveChangesAsync`. - **Walkthrough**: primary-constructor injection of the four collaborators (`CreateConferenceCategoryHandler.cs:16-20`), declaring `ICommandHandler>`. `HandleAsync` (`:23-40`) awaits `requestMapper.CreateEntityAsync(command, cancellationToken)` (`:27`) and short-circuits on failure by re-wrapping the errors into the correct generic shape, `Result.Failure(result.Errors)` (`:28-29`), which is how a factory-level invariant failure becomes an API error without an exception. It then unwraps `result.Value!` (`:31`), gets `unitOfWork.GetRepository()` (`:32`), and awaits `repository.AddAsync(entity, cancellationToken)` followed by the single `unitOfWork.SaveChangesAsync(cancellationToken)` (`:34-35`), both with `.ConfigureAwait(false)`. `LogConferenceCategoryCreated(logger, entity.Id, entity.Title)` (`:37`, declaration `:42-43`) is emitted *after* the save, so the logged id is the store-generated key rather than the `0` that arrived on the request. It returns `Result.Success(dtoMapper.MapToDTO(entity))` (`:39`). - **Why it's built this way**: the single `SaveChangesAsync` is the one place audit fields are stamped, domain events are captured, and outbox rows are written, so the handler deliberately owns exactly one call to it. Returning a [`ConferenceCategoryDTO`](group-17-conference-domain.md#conferencecategorydto) rather than the entity keeps the domain type from crossing the API boundary, per [ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html). -- **Where it's used**: resolved by the categories controller as `ICommandHandler>` (`MMCA.ADC.Conference.API/Controllers/ConferenceCategoriesController.cs:34`) and reached through the base controller's create action, which the ADC controller overrides only to add an explicit cache eviction after the call (`:93-100`). Its update counterpart is [`UpdateConferenceCategoryHandler`](#updateconferencecategoryhandler). Covered by `CreateConferenceCategoryHandlerTests` ([Group 27](group-27-testing-infrastructure.md#createconferencecategoryhandlertests)). +- **Where it's used**: resolved by the categories controller as `ICommandHandler>` (`MMCA.ADC.Conference.API/Controllers/ConferenceCategoriesController.cs:34`) and reached through [`AggregateRootEntityControllerBase`](group-12-api-hosting-mapping.md#aggregaterootentitycontrollerbasetentity-tentitydto-tidentifiertype-tcreaterequest) (`:39-40`), whose create action the ADC controller overrides only to add an explicit cache eviction after the call (`:93-100`). Its update counterpart is [`UpdateConferenceCategoryHandler`](#updateconferencecategoryhandler). Covered by `CreateConferenceCategoryHandlerTests` ([Group 27](group-27-testing-infrastructure.md#createconferencecategoryhandlertests)). -### EventDTOMapper +### SpeakerFirstNameRules -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.DTOs` · `MMCA.ADC.Conference.Application/Events/DTOs/EventDTOMapper.cs:14` · Level 9 · class (sealed partial) +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.Validation` · `MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:11` · Level 7 · class (sealed, generic) -- **What it is**: the read-side mapper for the [`Event`](group-17-conference-domain.md#event) aggregate. It is the composite of the family: it maps the root and delegates its three child collections to the child mappers, then applies one hand-written fix-up the generator cannot express. -- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) (`EventDTOMapper.cs:3,18`), Mapperly (`:4,13,20,23,26,50`), [`RoomDTOMapper`](#roomdtomapper), [`EventSpeakerDTOMapper`](#eventspeakerdtomapper), and [`EventQuestionAnswerDTOMapper`](#eventquestionanswerdtomapper) (`:15-17`), the [`Event`](group-17-conference-domain.md#event) aggregate (`:1`), [`EventDTO`](group-17-conference-domain.md#eventdto) from the Shared project (`:2`), and `System.Globalization.CultureInfo` (BCL, `:39`). -- **Concept introduced, composing generated mappers and escaping to hand-written code.** Two Mapperly features carry this class. `[UseMapper]` on a field (`EventDTOMapper.cs:20,23,26`) tells the generator "when you need to map a `Room`, a `EventSpeaker`, or an `EventQuestionAnswer`, call this instance instead of generating a second copy", which is how an aggregate DTO gets its child collections filled without duplicating child mapping logic. `[MapperIgnoreTarget]` (`:50`) is the escape hatch: it tells the generator to leave one target member alone, so the class can set it itself. The member in question is `LastSessionizeRefreshBy`, a `UserIdentifierType?` on the entity (`MMCA.ADC.Conference.Domain/Events/Event.cs:77`) but a `string?` on the DTO (`MMCA.ADC.Conference.Shared/Events/EventDTO.cs:60`); a nullable-value-type-to-string conversion is not something the generator will invent, and the file's own comment says so (`EventDTOMapper.cs:35`). The general lesson is that a source generator is not all-or-nothing: you keep generation for the ninety percent of straight property copies and hand-write only the member that needs a decision. `[Rubric §12, Performance & Scalability]` assesses avoidable runtime work on hot paths: the whole event read path, including children, is generated assignment plus one `with` expression. `[Rubric §9, API & Contract Design]`: the DTO's own types are chosen for the wire (an id rendered as a string), and this class is where the two type systems meet. `[Rubric §15, Best Practices & Code Quality]`: the conversion is done with `CultureInfo.InvariantCulture` (`:39`) rather than the ambient culture, so the value is stable regardless of server locale. -- **Walkthrough**: the primary constructor takes the three child mappers (`EventDTOMapper.cs:14-17`) and assigns each to a `[UseMapper]`-annotated readonly field (`:20-27`). The public `MapToDTO(Event entity)` (`:30-41`) is hand-written: it null-guards (`:32`), calls the private generated `MapToDTOGenerated(entity)` (`:33`), and then returns a `with` expression that sets the one ignored member, `LastSessionizeRefreshBy = entity.LastSessionizeRefreshBy?.ToString(CultureInfo.InvariantCulture)` (`:36-40`). Because `EventDTO` is a record, the `with` copy is a cheap shallow clone that leaves every generated assignment intact. `MapToDTOs` (`:44-48`) is the same null-guarded spread projection as its siblings. The generated method itself is declared last, `private partial EventDTO MapToDTOGenerated(Event entity)` carrying `[MapperIgnoreTarget(nameof(EventDTO.LastSessionizeRefreshBy))]` (`:50-51`). -- **Why it's built this way**: making the public method the wrapper and the generated method private means no caller can accidentally bypass the fix-up and receive a DTO with a null `LastSessionizeRefreshBy`. Keeping the child mappers injected rather than generated inline means the same `RoomDTO` shape is produced whether a room is read directly or as part of an event. -- **Where it's used**: injected into [`CreateEventHandler`](#createeventhandler) (`MMCA.ADC.Conference.Application/Events/UseCases/Create/CreateEventHandler.cs:19`) and [`UpdateEventHandler`](#updateeventhandler) (`.../Update/UpdateEventHandler.cs:18`), and resolved as `IEntityDTOMapper` by the event query service (`MMCA.ADC.Conference.Application/DependencyInjection.cs:55`, constructor parameter at `MMCA.Common.Application/Services/EntityQueryService.cs:35`). Registration is by the convention scan (`DependencyInjection.cs:112`). +- **What it is**: the reusable rule set for a [`Speaker`](group-17-conference-domain.md#speaker)'s first name. It contributes no rule bodies of its own: it is a three-line subclass that binds the framework's generic "required string" rules to one field name and one length constant. +- **Depends on**: [`RequiredStringRules`](group-06-validation.md#requiredstringrulest) from `MMCA.Common.Application.Validation` (`MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:3,12`), [`SpeakerInvariants`](group-17-conference-domain.md#speakerinvariants) (`SpeakerValidationRules.cs:2,15`), and `Expression` from `System.Linq.Expressions` (BCL, `SpeakerValidationRules.cs:1,14`). +- **Concept introduced, rule reuse by inheritance rather than by composition.** [`QuestionTextRules`](#questiontextrulest) hand-writes its own `NotEmpty` plus `MaximumLength` chain (`MMCA.ADC.Conference.Application/Questions/Validation/QuestionValidationRules.cs:16-18`); this class instead inherits the identical chain from the framework and passes three arguments to it: the selector, the display name, and the limit (`SpeakerValidationRules.cs:14-15`). The base builds both messages from the display name, "You must enter a First Name" and "First Name cannot be longer than 200 characters" (`MMCA.Common.Application/Validation/CommonValidationRules.cs:16-18`). The trade is visible in the output: the framework base attaches no `WithErrorCode`, so a speaker name violation surfaces with FluentValidation's default code, while a question text violation surfaces with the explicit `Question.QuestionText.Required` / `Question.QuestionText.MaxLength` codes (`QuestionValidationRules.cs:17-18`). Choose the base when the field is an ordinary required string; hand-write when the field needs a stable machine-readable code. `[Rubric §1, SOLID]` assesses whether a type has one reason to change: this one changes only if the speaker's first name changes its display name or its length ceiling. `[Rubric §15, Best Practices & Code Quality]` assesses duplication: the shared base lives in MMCA.Common, so the same shape is available to every module in every app rather than copy-pasted per aggregate. +- **Walkthrough**: `public sealed class SpeakerFirstNameRules : RequiredStringRules` (`SpeakerValidationRules.cs:11-12`) with a single constructor that forwards `base(selector, "First Name", SpeakerInvariants.FirstNameMaxLength)` (`SpeakerValidationRules.cs:14-15`). The constant is `200` (`MMCA.ADC.Conference.Domain/Speakers/SpeakerInvariants.cs:13`), the same value the domain guard applies when it produces the `Speaker.FirstName.TooLong` invariant error (`SpeakerInvariants.cs:45`), so the boundary rejection and the deep guard cannot drift apart. +- **Why it's built this way**: keeping the constant in the domain and the message in the framework base means an ADC-specific validator is reduced to naming the field. The generic `T` is what makes one rule object serve several request shapes, since FluentValidation's `Include` requires both validators to be generic over the same type. +- **Where it's used**: included by [`SpeakerCreateRequestValidator`](#speakercreaterequestvalidator) (`MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequestValidator.cs:11`) and by [`SpeakerUpdateRequestValidator`](#speakerupdaterequestvalidator) (`MMCA.ADC.Conference.Application/Speakers/UseCases/Update/SpeakerUpdateRequestValidator.cs:11`). + +### SpeakerLastNameRules + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.Validation` · `MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:22` · Level 7 · class (sealed, generic) + +- **What it is**: the last-name twin of [`SpeakerFirstNameRules`](#speakerfirstnamerulest), declared in the same file. +- **Depends on**: the same three: [`RequiredStringRules`](group-06-validation.md#requiredstringrulest) (`MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:3,23`), [`SpeakerInvariants`](group-17-conference-domain.md#speakerinvariants) (`SpeakerValidationRules.cs:2,26`), and `Expression` (`SpeakerValidationRules.cs:1,25`). +- **Concept**: nothing new; the inherit-the-shared-rules pattern taught by [`SpeakerFirstNameRules`](#speakerfirstnamerulest). The two declarations differ only in the display name passed to the base, "Last Name" instead of "First Name", and in the constant, `SpeakerInvariants.LastNameMaxLength` (`SpeakerValidationRules.cs:26`), which is also `200` (`MMCA.ADC.Conference.Domain/Speakers/SpeakerInvariants.cs:16`). They stay two types rather than one parameterized rule because each is included by name against a specific property, which is what makes the call site at the validator read as documentation. +- **Walkthrough**: `public sealed class SpeakerLastNameRules : RequiredStringRules` (`SpeakerValidationRules.cs:22-23`) with the forwarding constructor at `SpeakerValidationRules.cs:25-26`. +- **Where it's used**: included by [`SpeakerCreateRequestValidator`](#speakercreaterequestvalidator) (`MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequestValidator.cs:12`) and by [`SpeakerUpdateRequestValidator`](#speakerupdaterequestvalidator) (`MMCA.ADC.Conference.Application/Speakers/UseCases/Update/SpeakerUpdateRequestValidator.cs:12`). ### AddEventQuestionAnswerCommand > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.AddEventQuestionAnswer` · `MMCA.ADC.Conference.Application/Events/UseCases/AddEventQuestionAnswer/AddEventQuestionAnswerCommand.cs:11` · Level 8 · record (sealed) - **What it is**: the write intent for recording an answer to a conference question against an [`Event`](group-17-conference-domain.md#event): which event, which question, the answer text, and optionally an explicit id for the new answer row. -- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) from `MMCA.Common.Application.UseCases` (`AddEventQuestionAnswerCommand.cs:2,15`), the [`Event`](group-17-conference-domain.md#event) domain type used only to build the cache prefix (`AddEventQuestionAnswerCommand.cs:1,18`), and the module identifier aliases `EventIdentifierType`, `EventQuestionAnswerIdentifierType`, and `QuestionIdentifierType` (`EventIdentifierType = int`, `MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7`; see the [primer](00-primer.md#2-architectural-styles-this-codebase-commits-to)). -- **Concept introduced, the cache-invalidating child-add command.** A mutation opts into cache eviction by implementing [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) and exposing a `CachePrefix`; the caching decorator in the CQRS pipeline (Group 05) purges every cached read under that prefix once the command succeeds. Two details generalize to every add-child command in this unit. First, the prefix is keyed on the *aggregate root* (`typeof(Event).FullName`, `AddEventQuestionAnswerCommand.cs:18`), not on the child type, because a cached event read carries its answers with it. Second, the child id is nullable (`EventQuestionAnswerIdentifierType?`, `AddEventQuestionAnswerCommand.cs:13`): a Sessionize import can supply the source-assigned id, while an interactive add leaves it `null`. `[Rubric §6, CQRS & Event-Driven]` assesses whether writes are explicit intents flowing through one pipeline: the record is the intent and the marker interface is how the cross-cutting cache concern attaches declaratively, so no handler touches the cache. `[Rubric §10, Cross-Cutting]`: caching is a pipeline concern, not hand-rolled per use case. -- **Walkthrough**: a `sealed record` with four positional parameters, `EventId`, the nullable `EventQuestionAnswerId`, `QuestionId`, and the `string AnswerValue` (`AddEventQuestionAnswerCommand.cs:11-15`), plus the single computed `CachePrefix => $"{typeof(Event).FullName}:"` (`AddEventQuestionAnswerCommand.cs:18`). There is no behavior here; the upsert rule, the published-event check, the question-type check, and the cross-module points notification all live in [`AddEventQuestionAnswerHandler`](#addeventquestionanswerhandler). +- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) from `MMCA.Common.Application.UseCases` (`MMCA.ADC.Conference.Application/Events/UseCases/AddEventQuestionAnswer/AddEventQuestionAnswerCommand.cs:2,15`), the [`Event`](group-17-conference-domain.md#event) domain type used only to build the cache prefix (`AddEventQuestionAnswerCommand.cs:1,18`), and the module identifier aliases `EventIdentifierType`, `EventQuestionAnswerIdentifierType`, and `QuestionIdentifierType` (see the [primer](00-primer.md#2-architectural-styles-this-codebase-commits-to) on identifier-type aliases). +- **Concept introduced, the cache-invalidating child-add command.** A mutation opts into cache eviction by implementing [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) and exposing a `CachePrefix`; the caching decorator in the CQRS pipeline (Group 05) purges every cached read under that prefix once the command succeeds. Two details generalize to every add-child command in this unit. First, the prefix is keyed on the *aggregate root*, `$"{typeof(Event).FullName}:"` (`AddEventQuestionAnswerCommand.cs:18`), not on the child type, because a cached event read carries its answers with it. Second, the child id is nullable, `EventQuestionAnswerIdentifierType?` (`AddEventQuestionAnswerCommand.cs:13`): the aggregate's own contract reads "Explicit ID, or null for database-generated identity" (`MMCA.ADC.Conference.Domain/Events/Event.cs:633`), so a caller that already owns a stable id can supply it while an interactive add leaves it null. `[Rubric §6, CQRS & Event-Driven]` assesses whether writes are explicit intents flowing through one pipeline: the record is the intent, and the marker interface is how the cross-cutting cache concern attaches declaratively, so no handler touches the cache. `[Rubric §10, Cross-Cutting]` assesses whether such concerns live in one place: caching is a pipeline decorator, not hand-rolled per use case. +- **Walkthrough**: a `sealed record` with four positional parameters, `EventId`, the nullable `EventQuestionAnswerId`, `QuestionId`, and the `string AnswerValue` (`AddEventQuestionAnswerCommand.cs:11-15`), plus the single computed `CachePrefix` (`AddEventQuestionAnswerCommand.cs:18`). There is no behavior here; the upsert rule, the published-event check, the question-type check, and the cross-module points notification all live in [`AddEventQuestionAnswerHandler`](#addeventquestionanswerhandler). - **Why it's built this way**: a positional record gives immutability and value equality for free, and keeping the payload to plain identifiers plus the answer text means the caller cannot smuggle in an owner id: the handler derives the answering user from [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) instead. -- **Where it's used**: constructed by the answers controller with a `null` child id (`MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:182`, handler injected at `:58`), validated by [`AddEventQuestionAnswerCommandValidator`](#addeventquestionanswercommandvalidator), and handled by [`AddEventQuestionAnswerHandler`](#addeventquestionanswerhandler). +- **Where it's used**: constructed by the answers controller with a `null` child id (`MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:190`, handler injected at `EventQuestionAnswersController.cs:59`), validated by [`AddEventQuestionAnswerCommandValidator`](#addeventquestionanswercommandvalidator), and handled by [`AddEventQuestionAnswerHandler`](#addeventquestionanswerhandler). ### AddEventSpeakerCommand > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.AddEventSpeaker` · `MMCA.ADC.Conference.Application/Events/UseCases/AddEventSpeaker/AddEventSpeakerCommand.cs:10` · Level 8 · record (sealed) - **What it is**: the write intent for associating an existing speaker with an existing event. It creates the [`EventSpeaker`](group-17-conference-domain.md#eventspeaker) association row, not the speaker. -- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`AddEventSpeakerCommand.cs:2,13`), [`Event`](group-17-conference-domain.md#event) for the cache prefix (`AddEventSpeakerCommand.cs:1,16`), and the `EventIdentifierType`, `EventSpeakerIdentifierType`, and `SpeakerIdentifierType` aliases (`SpeakerIdentifierType = System.Guid`, `MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:18`). +- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`MMCA.ADC.Conference.Application/Events/UseCases/AddEventSpeaker/AddEventSpeakerCommand.cs:2,13`), the [`Event`](group-17-conference-domain.md#event) type for the cache prefix (`AddEventSpeakerCommand.cs:1,16`), and the `EventIdentifierType`, `EventSpeakerIdentifierType`, and `SpeakerIdentifierType` aliases. - **Concept**: the same cache-invalidating add-child shape introduced by [`AddEventQuestionAnswerCommand`](#addeventquestionanswercommand), reduced to its minimum: aggregate id, optional child id, and the one foreign identifier being linked. `[Rubric §4, Domain-Driven Design]` assesses whether relationships are mutated through an aggregate root: the command names the `Event` first because the association is a child of the event aggregate, and the speaker aggregate is untouched by this write. -- **Walkthrough**: `sealed record AddEventSpeakerCommand(EventIdentifierType EventId, EventSpeakerIdentifierType? EventSpeakerId, SpeakerIdentifierType SpeakerId) : ICacheInvalidating` (`AddEventSpeakerCommand.cs:10-13`), with `CachePrefix => $"{typeof(Event).FullName}:"` (`AddEventSpeakerCommand.cs:16`). The nullable `EventSpeakerId` serves the Sessionize import path exactly as in the sibling command (`AddEventSpeakerCommand.cs:12`). -- **Why it's built this way**: linking rather than nesting keeps the speaker an independent aggregate that survives being removed from an event, and it keeps the duplicate-association rule (enforced in `Event.AddEventSpeaker`, `MMCA.ADC.Conference.Domain/Events/Event.cs:515-522`) inside the event boundary. -- **Where it's used**: constructed by the event-speakers controller with a `null` child id (`MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:215`, handler injected at `:48`), validated by [`AddEventSpeakerCommandValidator`](#addeventspeakercommandvalidator), and handled by [`AddEventSpeakerHandler`](#addeventspeakerhandler). +- **Walkthrough**: `public sealed record AddEventSpeakerCommand(EventIdentifierType EventId, EventSpeakerIdentifierType? EventSpeakerId, SpeakerIdentifierType SpeakerId) : ICacheInvalidating` (`AddEventSpeakerCommand.cs:10-13`), with `CachePrefix => $"{typeof(Event).FullName}:"` (`AddEventSpeakerCommand.cs:16`). The nullable `EventSpeakerId` (`AddEventSpeakerCommand.cs:12`) carries straight through to the aggregate, where null means "let the database generate the identity" (`MMCA.ADC.Conference.Domain/Events/Event.cs:537`). +- **Why it's built this way**: linking rather than nesting keeps the speaker an independent aggregate that survives being removed from an event, and it keeps the duplicate-association rule inside the event boundary, where `Event.AddEventSpeaker` scans its own loaded children and fails with `Event.Speaker.Duplicate` (`MMCA.ADC.Conference.Domain/Events/Event.cs:544-551`). +- **Where it's used**: constructed by the event-speakers controller with a `null` child id (`MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:223`, handler injected at `EventSpeakersController.cs:49`), validated by [`AddEventSpeakerCommandValidator`](#addeventspeakercommandvalidator), and handled by [`AddEventSpeakerHandler`](#addeventspeakerhandler). +- **Caveats / not-in-source**: the Sessionize refresh does not go through this command. `SpeakerSyncStrategy` calls the aggregate method directly, also with a null association id (`MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SpeakerSyncStrategy.cs:59`), so nothing in source constructs this record with a non-null `EventSpeakerId` today. ### AddRoomCommand > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.AddRoom` · `MMCA.ADC.Conference.Application/Events/UseCases/AddRoom/AddRoomCommand.cs:15` · Level 8 · record (sealed) - **What it is**: the write intent for adding a room to an existing event. It is the widest add-child command in this unit: eight positional parameters carrying the target event, an optional explicit room id, and the room's descriptive fields. -- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`AddRoomCommand.cs:2,23`), the [`Event`](group-17-conference-domain.md#event) type for the cache prefix (`AddRoomCommand.cs:1,26`), and the `EventIdentifierType` and `RoomIdentifierType` aliases. -- **Concept**: the same cache-invalidating add-child shape as [`AddEventQuestionAnswerCommand`](#addeventquestionanswercommand), but here the nullable child id is genuinely load-bearing rather than an import convenience. Room ids are application-assigned (the integer primary key *is* the Sessionize id), so a `null` `RoomId` tells [`AddRoomHandler`](#addroomhandler) to allocate one from a reserved manual range, while a non-null id is respected as an explicit Sessionize id (`AddRoomCommand.cs:17`, and see [`AddRoomHandler`](#addroomhandler) for the allocation). `[Rubric §9, API & Contract Design]` assesses whether inbound contracts are explicit about optionality: the four nullable trailing fields model genuinely optional room metadata rather than overloading empty strings. +- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`MMCA.ADC.Conference.Application/Events/UseCases/AddRoom/AddRoomCommand.cs:2,23`), the [`Event`](group-17-conference-domain.md#event) type for the cache prefix (`AddRoomCommand.cs:1,26`), and the `EventIdentifierType` and `RoomIdentifierType` aliases. +- **Concept**: the same cache-invalidating add-child shape as [`AddEventQuestionAnswerCommand`](#addeventquestionanswercommand), but here the nullable child id is genuinely load-bearing rather than an identity-generation detail. Room ids are application-assigned: the integer primary key *is* the Sessionize id, and the aggregate documents the parameter as "Sessionize-assigned room ID, or null when not available" (`MMCA.ADC.Conference.Domain/Events/Event.cs:366`). A null `RoomId` therefore tells [`AddRoomHandler`](#addroomhandler) to allocate one from a reserved manual range, while a non-null id is respected verbatim (`AddRoomCommand.cs:17`). `[Rubric §9, API & Contract Design]` assesses whether inbound contracts are explicit about optionality: the four nullable trailing fields model genuinely optional room metadata rather than overloading empty strings. - **Walkthrough**: the `sealed record` declares `EventId` and the nullable `RoomId` (`AddRoomCommand.cs:16-17`), the two mandatory room fields `Name` and `Sort` (`AddRoomCommand.cs:18-19`), then the four optional fields `Capacity`, `Floor`, `Location`, and `AccessibilityInfo` (`AddRoomCommand.cs:20-23`). `CachePrefix => $"{typeof(Event).FullName}:"` (`AddRoomCommand.cs:26`) evicts the event read cache, since a room is read as part of its event. -- **Why it's built this way**: one command serves both organizer-created rooms (no id) and Sessionize-imported rooms (explicit id), so the import needs no parallel write path. Cache invalidation as a marker interface keeps the decorator pipeline in charge of the cross-cutting concern. -- **Where it's used**: constructed by the rooms controller (`MMCA.ADC.Conference.API/Controllers/RoomsController.cs:150`, handler injected at `:87`), validated by [`AddRoomCommandValidator`](#addroomcommandvalidator), and handled by [`AddRoomHandler`](#addroomhandler). Its update counterpart is [`UpdateRoomCommand`](#updateroomcommand). +- **Why it's built this way**: one command serves both organizer-created rooms (no id) and callers that already hold a Sessionize id, so the id-allocation decision is made once in the handler rather than at every call site. Cache invalidation as a marker interface keeps the decorator pipeline in charge of the cross-cutting concern. +- **Where it's used**: constructed by the rooms controller, which forwards the request's `RoomId` rather than forcing null (`MMCA.ADC.Conference.API/Controllers/RoomsController.cs:263-271`, handler injected at `RoomsController.cs:94`), validated by [`AddRoomCommandValidator`](#addroomcommandvalidator), and handled by [`AddRoomHandler`](#addroomhandler). Its update counterpart is [`UpdateRoomCommand`](#updateroomcommand). +- **Caveats / not-in-source**: the Sessionize room refresh bypasses this command too, calling `@event.AddRoom(sr.Id, sr.Name, sr.Sort)` on the aggregate directly with the source-assigned id (`MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RoomSyncStrategy.cs:112`). -### EventCreateRequest +### EventQuestionAnswerDTOMapper -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.Create` · `MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequest.cs:10` · Level 8 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.DTOs` · `MMCA.ADC.Conference.Application/Events/DTOs/EventQuestionAnswerDTOMapper.cs:12` · Level 8 · class (sealed partial) -- **What it is**: the inbound contract for creating a conference [`Event`](group-17-conference-domain.md#event). It is both the HTTP request body and the command that the CQRS pipeline dispatches: there is no separate `CreateEventCommand`. -- **Depends on**: [`ICreateRequest`](group-05-cqrs-pipeline.md#icreaterequest) from `MMCA.Common.Application.Interfaces` and [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) from `MMCA.Common.Application.UseCases` (`EventCreateRequest.cs:2-3,10`), the [`Event`](group-17-conference-domain.md#event) domain type used only to build the cache prefix (`EventCreateRequest.cs:1,13`), the `EventIdentifierType` alias (`= int`, `MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7`), and `DateOnly` (BCL). -- **Concept introduced, the create request as the command.** Every other write in this unit has a hand-written `XxxCommand` record. Create does not: implementing the marker [`ICreateRequest`](group-05-cqrs-pipeline.md#icreaterequest) (`EventCreateRequest.cs:10`) is what lets the generic create machinery in Group 12 accept this record straight off the wire, hand it to an [`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype), and dispatch it as `ICommandHandler>`. One record therefore carries three roles: the JSON contract, the validation target, and the command. `required` on `Name`, `StartDate`, `EndDate`, and `TimeZone` (`EventCreateRequest.cs:19,25,28,31`) makes the mandatory set a compile-time property of the type rather than a convention the deserializer has to be trusted with, and every member is `init`-only so the request cannot be mutated after model binding. `[Rubric §9, API & Contract Design]` assesses whether inbound contracts are explicit about shape and optionality: the split between four `required` members and eight optional ones is the API's optionality documentation. `[Rubric §5, Vertical Slice]` assesses whether a feature's request, validator, mapper, and handler live together: all four Create types sit in one folder. -- **Walkthrough**: `CachePrefix => $"{typeof(Event).FullName}:"` (`EventCreateRequest.cs:13`) is the eviction key the caching decorator purges on success, keyed on the aggregate root exactly as the child commands above do. `Id` (`EventCreateRequest.cs:16`) is a non-nullable `EventIdentifierType`, so an omitted id binds to `0`; the domain factory discards whatever arrives when `Event`'s key is store-generated (`MMCA.ADC.Conference.Domain/Events/Event.cs:177,192`). Then the four required fields, `Name`, `StartDate`, `EndDate`, `TimeZone` (`EventCreateRequest.cs:19,25,28,31`), where `TimeZone` is an IANA identifier rather than a UTC offset. The remaining members are all nullable and optional: `Description` (`:22`), `SessionizeCode` (`:34`), `VenueAddress` (`:37`), `VenueMapUrl` (`:40`), `WiFiInfo` (`:43`), `OrganizerContactEmail` (`:46`), and `SponsorshipPacketUrl` (`:49`). `SessionizeCode` is the hook that later lets [`RefreshFromSessionizeCommand`](#refreshfromsessionizecommand) pull an agenda for this event; `OrganizerContactEmail` and `SponsorshipPacketUrl` are the two attendee- and sponsor-facing fields the public pages read, and both are validated only when supplied (see [`EventCreateRequestValidator`](#eventcreaterequestvalidator)). -- **Why it's built this way**: collapsing request and command removes a mapping step that would have no behavior of its own, while the marker interfaces keep caching and pipeline participation declarative instead of hand-coded in the handler. Storing an IANA zone id rather than a fixed offset is what lets session times render correctly across a daylight-saving boundary. -- **Where it's used**: injected into the events controller as `ICommandHandler>` (`MMCA.ADC.Conference.API/Controllers/EventsController.cs:46`) and forwarded to the shared [`AggregateRootEntityControllerBase`](group-12-api-hosting-mapping.md#aggregaterootentitycontrollerbasetentity-tentitydto-tidentifiertype-tcreaterequest), which owns the create action (`EventsController.cs:57-58`); validated by [`EventCreateRequestValidator`](#eventcreaterequestvalidator), converted by [`EventCreateRequestMapper`](#eventcreaterequestmapper), handled by [`CreateEventHandler`](#createeventhandler). Its edit-side counterpart is [`EventUpdateRequest`](#eventupdaterequest). +- **What it is**: the read-side mapper that turns an [`EventQuestionAnswer`](group-17-conference-domain.md#eventquestionanswer) domain entity into an [`EventQuestionAnswerDTO`](group-17-conference-domain.md#eventquestionanswerdto). The single-entity method has no body in this file: Mapperly generates it at compile time. +- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) from `MMCA.Common.Application.Interfaces` (`MMCA.ADC.Conference.Application/Events/DTOs/EventQuestionAnswerDTOMapper.cs:3,13`), `Riok.Mapperly.Abstractions` (NuGet, `EventQuestionAnswerDTOMapper.cs:4,11`), the [`EventQuestionAnswer`](group-17-conference-domain.md#eventquestionanswer) entity (`EventQuestionAnswerDTOMapper.cs:1`), the [`EventQuestionAnswerDTO`](group-17-conference-domain.md#eventquestionanswerdto) contract from the Shared project (`EventQuestionAnswerDTOMapper.cs:2`), and the `EventQuestionAnswerIdentifierType` alias. +- **Concept introduced (for this unit), source-generated DTO mapping.** `[Mapper]` on a `partial` class (`EventQuestionAnswerDTOMapper.cs:11-12`) tells the Mapperly generator to fill in the body of every `partial` method it finds, here `MapToDTO` (`EventQuestionAnswerDTOMapper.cs:16`). The generated body is straight-line property assignment: no reflection, no expression trees, no runtime configuration, and a compile error rather than a silent null if a target member has no source. That is the whole point of [ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html): mapping is either hand-written or generated, never reflective. `[Rubric §12, Performance & Scalability]` assesses whether hot paths avoid avoidable runtime work: every read endpoint maps its result set, so a generated assignment beats a reflective copy at the exact place volume lands. `[Rubric §9, API & Contract Design]` assesses what crosses the wire: the DTO, not the entity, so a domain refactor cannot silently reshape the JSON. `[Rubric §14, Testability]` assesses whether logic can be exercised in isolation: the mapper is a pure function of its input and is tested directly ([`EventQuestionAnswerDTOMapperTests`](group-27-testing-infrastructure.md#eventquestionanswerdtomappertests)). +- **Walkthrough**: two members. `public partial EventQuestionAnswerDTO MapToDTO(EventQuestionAnswer entity)` (`EventQuestionAnswerDTOMapper.cs:16`) is the declaration whose implementation the generator supplies. `MapToDTOs(IReadOnlyCollection)` (`EventQuestionAnswerDTOMapper.cs:19-23`) is hand-written and deliberately so: it null-guards with `ArgumentNullException.ThrowIfNull` (`EventQuestionAnswerDTOMapper.cs:21`) and then projects with a collection expression over a spread, `[.. entityCollection.Select(MapToDTO)]` (`EventQuestionAnswerDTOMapper.cs:22`), which materializes a single array without an intermediate `List` growth cycle. The collection method delegating to the generated single-item method is the shape every mapper in this family repeats. +- **Where it's used**: injected concretely into [`AddEventQuestionAnswerHandler`](#addeventquestionanswerhandler) (`MMCA.ADC.Conference.Application/Events/UseCases/AddEventQuestionAnswer/AddEventQuestionAnswerHandler.cs:21`), consumed as a child mapper by [`EventDTOMapper`](#eventdtomapper) through `[UseMapper]` (`MMCA.ADC.Conference.Application/Events/DTOs/EventDTOMapper.cs:26-27`), and resolved as `IEntityDTOMapper` by the generic query service registered for the entity (`MMCA.ADC.Conference.Application/DependencyInjection.cs:99`, constructor parameter at `MMCA.Common.Application/Services/EntityQueryService.cs:35`). Registration of the mapper itself is by the module's convention scan, `services.ScanModuleApplicationServices()` (`MMCA.ADC.Conference.Application/DependencyInjection.cs:125`). + +### EventSpeakerDTOMapper + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.DTOs` · `MMCA.ADC.Conference.Application/Events/DTOs/EventSpeakerDTOMapper.cs:12` · Level 8 · class (sealed partial) + +- **What it is**: the same generated mapper for the [`EventSpeaker`](group-17-conference-domain.md#eventspeaker) association entity to [`EventSpeakerDTO`](group-17-conference-domain.md#eventspeakerdto). +- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) (`MMCA.ADC.Conference.Application/Events/DTOs/EventSpeakerDTOMapper.cs:3,13`), Mapperly (`EventSpeakerDTOMapper.cs:4,11`), the entity and DTO (`EventSpeakerDTOMapper.cs:1-2`), and the `EventSpeakerIdentifierType` alias. +- **Concept**: nothing new; the `[Mapper]`-plus-`partial` shape taught by [`EventQuestionAnswerDTOMapper`](#eventquestionanswerdtomapper). Reading the two side by side is the fastest way to see how little varies: the entity, the DTO, and the identifier alias in the interface arguments, and nothing else. +- **Walkthrough**: `public partial EventSpeakerDTO MapToDTO(EventSpeaker entity)` (`EventSpeakerDTOMapper.cs:16`) generated by Mapperly, and the hand-written `MapToDTOs` with its null guard and spread projection (`EventSpeakerDTOMapper.cs:19-23`). +- **Where it's used**: injected concretely into [`AddEventSpeakerHandler`](#addeventspeakerhandler) (`MMCA.ADC.Conference.Application/Events/UseCases/AddEventSpeaker/AddEventSpeakerHandler.cs:17`), used as a child mapper by [`EventDTOMapper`](#eventdtomapper) (`MMCA.ADC.Conference.Application/Events/DTOs/EventDTOMapper.cs:23-24`), and resolved by the query service registered at `MMCA.ADC.Conference.Application/DependencyInjection.cs:96`. Tested by [`EventSpeakerDTOMapperTests`](group-27-testing-infrastructure.md#eventspeakerdtomappertests). ### PublishedEventSpecification > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.Specifications` · `MMCA.ADC.Conference.Application/Events/Specifications/PublishedEventSpecification.cs:11` · Level 8 · class (sealed) -- **What it is**: a one-line query filter object that restricts an event query to published events only. It is how BR-108 ("non-privileged readers see only published events") is expressed as data rather than as an `if` inside every read endpoint. +- **What it is**: a one-line query filter object that restricts an event query to published events only. It is how BR-108 ("non-privileged readers see only published events") is expressed as data rather than as an `if` inside every read endpoint (`MMCA.ADC.Conference.Application/Events/Specifications/PublishedEventSpecification.cs:7-9`). - **Depends on**: [`Specification`](group-03-querying-specifications.md#specificationtentity-tidentifiertype) from `MMCA.Common.Domain.Specifications` (`PublishedEventSpecification.cs:3,11`), the [`Event`](group-17-conference-domain.md#event) aggregate and its `EventIdentifierType` alias (`PublishedEventSpecification.cs:2,11`), and `System.Linq.Expressions` (`PublishedEventSpecification.cs:1,14`). -- **Concept introduced, authorization expressed as a specification.** The specification pattern itself is taught in [Group 03](group-03-querying-specifications.md); what this type introduces is using it as a *security filter*. The base class exposes a `Criteria` expression that the repository composes into the EF query, so the restriction is applied in SQL rather than after materialization: an unpublished event is never loaded, never counted in a page total, and never reaches the serializer. Because the filter is a first-class object, the decision "does this caller get the filter" becomes a nullable value at the call site instead of branching query code. `[Rubric §11, Security]` assesses whether authorization is enforced at the data boundary rather than in the view: here a non-privileged reader's query cannot return an unpublished row at all. `[Rubric §12, Performance & Scalability]`: pushing the predicate into the expression tree keeps paging counts correct and avoids over-fetching. -- **Walkthrough**: the whole type is a single expression-bodied override, `public override Expression> Criteria => e => e.IsPublished` (`PublishedEventSpecification.cs:14`). There is no constructor and no state, so an instance is free to allocate per request. -- **Why it's built this way**: keeping BR-108 in one named type means the four event read endpoints share one definition of "visible event", and the class name makes the business rule greppable. Inheriting from the framework `Specification` base lets the same object flow through the generic query service and repository without an ADC-specific overload. -- **Where it's used**: [`EventsController`](group-20-conference-api-grpc.md#eventscontroller) builds it through the private helper `GetPublishedEventSpecification()`, which returns `null` (no filter) when `currentUserService.IsPrivilegedConferenceReader()` and a new instance otherwise (`MMCA.ADC.Conference.API/Controllers/EventsController.cs:66-67`). The helper is passed as the `specification:` argument on four read paths (`EventsController.cs:82`, `EventsController.cs:112`, `EventsController.cs:141`, `EventsController.cs:171`). -- **Caveats / not-in-source**: the gate is the read *audience*, not the Organizer role alone: `IsPrivilegedConferenceReader()` is the shared predicate, and the same call also guards a non-read action at `EventsController.cs:194`. The predicate here does not exclude soft-deleted rows; that is handled separately by the EF global query filter (see [Group 07](group-07-persistence-ef-core.md)). +- **Concept introduced, authorization expressed as a specification.** The specification pattern itself is taught in [Group 03](group-03-querying-specifications.md); what this type introduces is using it as a *security filter*. The base class exposes a `Criteria` expression that the repository composes into the EF query, so the restriction is applied in SQL rather than after materialization: an unpublished event is never loaded, never counted in a page total, and never reaches the serializer. Because the filter is a first-class object, the decision "does this caller get the filter" becomes a nullable value at the call site instead of branching query code. `[Rubric §11, Security]` assesses whether authorization is enforced at the data boundary rather than in the view: a non-privileged reader's query cannot return an unpublished row at all. `[Rubric §12, Performance & Scalability]` assesses over-fetching: pushing the predicate into the expression tree keeps paging counts correct and avoids materializing rows the caller may not see. +- **Walkthrough**: the whole type is a single expression-bodied override, `public override Expression> Criteria => e => e.IsPublished` (`PublishedEventSpecification.cs:14`). There is no constructor and no state, so an instance is cheap to allocate per request. +- **Why it's built this way**: keeping BR-108 in one named type means the event read endpoints share one definition of "visible event", and the class name makes the business rule greppable. Inheriting from the framework `Specification` base lets the same object flow through the generic query service and repository without an ADC-specific overload. +- **Where it's used**: [`EventsController`](group-20-conference-api-grpc.md#eventscontroller) builds it through the private helper `GetPublishedEventSpecification()`, which returns `null` (no filter) when `currentUserService.IsPrivilegedConferenceReader()` and a new instance otherwise (`MMCA.ADC.Conference.API/Controllers/EventsController.cs:67-68`). The helper feeds the `specification:` argument on four read paths (`EventsController.cs:83`, `EventsController.cs:113`, `EventsController.cs:142`, `EventsController.cs:172`). +- **Caveats / not-in-source**: the gate is the read *audience*, not the Organizer role alone: `IsPrivilegedConferenceReader()` is the shared predicate, and the same call also guards the export action outright with a `Forbid()` rather than with this filter (`EventsController.cs:195-198`). The criteria here do not exclude soft-deleted rows; that is handled separately by the EF global query filter (see [Group 07](group-07-persistence-ef-core.md)). -### PublishEventCommand +### RoomDTOMapper -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.Publish` · `MMCA.ADC.Conference.Application/Events/UseCases/Publish/PublishEventCommand.cs:12` · Level 8 · record (sealed) +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.DTOs` · `MMCA.ADC.Conference.Application/Events/DTOs/RoomDTOMapper.cs:12` · Level 8 · class (sealed partial) -- **What it is**: the write intent for flipping an [`Event`](group-17-conference-domain.md#event) to published, which is what makes it visible to attendees. It carries the event id and, optionally, the concurrency token the client last saw. -- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`PublishEventCommand.cs:2,12`), the [`Event`](group-17-conference-domain.md#event) type for the cache prefix (`PublishEventCommand.cs:1,15`), and the `EventIdentifierType` alias. -- **Concept introduced, the optional optimistic-concurrency token on a state-transition command.** `byte[]? RowVersion = null` (`PublishEventCommand.cs:12`) is a defaulted positional parameter, so the command compiles and dispatches with or without it. When present it is the SQL Server `rowversion` the client received on its last read; the handler stamps it back as the row's *original* value so the `UPDATE` matches zero rows if anyone else changed the event in the meantime, and the save surfaces a 409 Conflict. When it is `null` the check is skipped entirely (`PublishEventCommand.cs:8-11`). Making it opt-in rather than mandatory is the deliberate choice recorded in [ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html): a state transition is the classic lost-update target (two organizers looking at the same stale draft), but internal callers such as the Sessionize import do not need to carry a token. `[Rubric §8, Data Architecture]` assesses how concurrent writes are reconciled: the token turns a silent last-writer-wins into an explicit conflict the caller must resolve. `[Rubric §9, API & Contract Design]` assesses contract explicitness: the token is part of the command, not an ambient header the handler has to go looking for. -- **Walkthrough**: two positional parameters, `EventIdentifierType Id` and `byte[]? RowVersion` (`PublishEventCommand.cs:12`), plus the computed `CachePrefix => $"{typeof(Event).FullName}:"` (`PublishEventCommand.cs:15`). There is no validation and no behavior on the record; the "already published" rule lives on the aggregate (`MMCA.ADC.Conference.Domain/Events/Event.cs:260-267`). -- **Why it's built this way**: publishing is a domain transition, not a field edit, so it gets its own command rather than riding on the update request. That keeps the authorization surface, the audit log line, and the cache eviction distinct from a generic edit, and it lets the API expose a purpose-named endpoint instead of a PATCH of a boolean. -- **Where it's used**: constructed by the events controller from the route id plus the request body's token (`MMCA.ADC.Conference.API/Controllers/EventsController.cs:302`, handler injected at `:48`) and handled by [`PublishEventHandler`](#publisheventhandler). Its inverse is [`UnpublishEventCommand`](#unpublisheventcommand). +- **What it is**: the generated mapper from a [`Room`](group-17-conference-domain.md#room) child entity to a [`RoomDTO`](group-17-conference-domain.md#roomdto). +- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) (`MMCA.ADC.Conference.Application/Events/DTOs/RoomDTOMapper.cs:3,13`), Mapperly (`RoomDTOMapper.cs:4,11`), the entity and DTO (`RoomDTOMapper.cs:1-2`), and the `RoomIdentifierType` alias. +- **Concept**: nothing new; the `[Mapper]`-plus-`partial` shape taught by [`EventQuestionAnswerDTOMapper`](#eventquestionanswerdtomapper). +- **Walkthrough**: `public partial RoomDTO MapToDTO(Room entity)` (`RoomDTOMapper.cs:16`) generated by Mapperly, plus the hand-written `MapToDTOs` with null guard and spread projection (`RoomDTOMapper.cs:19-23`). +- **Where it's used**: injected concretely into [`AddRoomHandler`](#addroomhandler) (`MMCA.ADC.Conference.Application/Events/UseCases/AddRoom/AddRoomHandler.cs:21`), used as a child mapper by [`EventDTOMapper`](#eventdtomapper) (`MMCA.ADC.Conference.Application/Events/DTOs/EventDTOMapper.cs:20-21`), and resolved by the query service registered at `MMCA.ADC.Conference.Application/DependencyInjection.cs:90`. Tested by [`RoomDTOMapperTests`](group-27-testing-infrastructure.md#roomdtomappertests). ### AddEventQuestionAnswerCommandValidator > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.AddEventQuestionAnswer` · `MMCA.ADC.Conference.Application/Events/UseCases/AddEventQuestionAnswer/AddEventQuestionAnswerCommandValidator.cs:8` · Level 9 · class (sealed) - **What it is**: the FluentValidation validator for [`AddEventQuestionAnswerCommand`](#addeventquestionanswercommand). It enforces exactly one thing: the answer text is not empty. -- **Depends on**: `AbstractValidator` from FluentValidation (NuGet, `AddEventQuestionAnswerCommandValidator.cs:1,8`). It includes no shared rule set. -- **Concept introduced, the inline validator with a stable error code.** Unlike the `Include`-composed validators in this unit ([`AddRoomCommandValidator`](#addroomcommandvalidator), [`EventCreateRequestValidator`](#eventcreaterequestvalidator)), this one writes its single rule inline because no other command shares an "event answer value" field. The load-bearing detail is `WithErrorCode("EventQuestionAnswer.AnswerValue.Required")` (`AddEventQuestionAnswerCommandValidator.cs:14`): that string is what the API error-mapping layer keys on to build the problem-details response, so it is part of the public contract, not just prose. `[Rubric §24, Forms, Validation & UX Safety]` assesses whether bad input is rejected before it reaches business logic with a message the UI can act on; `[Rubric §9, API & Contract Design]`: the stable dotted error code lets clients branch on the failure without parsing English. +- **Depends on**: `AbstractValidator` from FluentValidation (NuGet, `MMCA.ADC.Conference.Application/Events/UseCases/AddEventQuestionAnswer/AddEventQuestionAnswerCommandValidator.cs:1,8`). It includes no shared rule set. +- **Concept introduced, the inline validator with a stable error code.** Unlike the `Include`-composed validators in this unit ([`AddRoomCommandValidator`](#addroomcommandvalidator), [`EventCreateRequestValidator`](#eventcreaterequestvalidator)), this one writes its single rule inline because no other command shares an "event answer value" field. The load-bearing detail is `WithErrorCode("EventQuestionAnswer.AnswerValue.Required")` (`AddEventQuestionAnswerCommandValidator.cs:14`): that string is what a client can branch on, so it is part of the contract, not just prose. `[Rubric §24, Forms, Validation & UX Safety]` assesses whether bad input is rejected before it reaches business logic with a message the UI can act on. `[Rubric §9, API & Contract Design]` assesses contract stability: the dotted error code lets clients branch on the failure without parsing English. - **Walkthrough**: an expression-bodied constructor (`AddEventQuestionAnswerCommandValidator.cs:10`) chaining `RuleFor(x => x.AnswerValue).NotEmpty()` with the message "Answer value is required." and the error code above (`AddEventQuestionAnswerCommandValidator.cs:11-14`). -- **Why it's built this way**: the validator deliberately checks shape only. The *semantic* rules stay deeper: the 4000-character bound lives on `EventInvariants.AnswerValueMaxLength` and its guard (`MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:52`, `EventInvariants.cs:145`) and is mirrored in the EF configuration (`MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/EventQuestionAnswerConfiguration.cs:25-26`), and the "does this text match the question's type" rule is a domain invariant the handler calls (see [`AddEventQuestionAnswerHandler`](#addeventquestionanswerhandler)). The validator cannot express that rule because it would need to load the question first. +- **Why it's built this way**: the validator deliberately checks shape only. The *semantic* rules stay deeper: the 4000-character bound lives on `EventInvariants.AnswerValueMaxLength` and its guard (`MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:55`, `EventInvariants.cs:148`) and is mirrored in the EF configuration (`MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/EventQuestionAnswerConfiguration.cs:25-26`), and the "does this text match the question's type" rule is a domain invariant the handler calls (see [`AddEventQuestionAnswerHandler`](#addeventquestionanswerhandler)). The validator cannot express that rule because it would need to load the question first. - **Where it's used**: resolved and run by the validation decorator in the CQRS pipeline (Group 05) before [`AddEventQuestionAnswerHandler`](#addeventquestionanswerhandler) executes. ### AddEventQuestionAnswerHandler @@ -2474,16 +2720,16 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.AddEventQuestionAnswer` · `MMCA.ADC.Conference.Application/Events/UseCases/AddEventQuestionAnswer/AddEventQuestionAnswerHandler.cs:18` · Level 9 · class (sealed partial) - **What it is**: the handler that records a user's answer to an event question. It is the most rule-dense handler in this unit: it gates on the event being published, cross-checks the question, performs an upsert rather than a blind insert, and raises a cross-module integration event on the insert path only. -- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`AddEventQuestionAnswerHandler.cs:19`), [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) (`:20`), [`EventQuestionAnswerDTOMapper`](#eventquestionanswerdtomapper) (`:21`), `TimeProvider` (BCL, `:22`), `ILogger` (`:23`), the [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) contract (`:23`), the [`Event`](group-17-conference-domain.md#event) aggregate with its [`EventQuestionAnswer`](group-17-conference-domain.md#eventquestionanswer) children, [`EventInvariants`](group-17-conference-domain.md#eventinvariants) (`:40`), [`Question`](group-17-conference-domain.md#question) plus [`QuestionInvariants`](group-17-conference-domain.md#questioninvariants) (`:65,77`), [`EventFeedbackSubmitted`](group-17-conference-domain.md#eventfeedbacksubmitted) (`:6,112`), and [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error). -- **Concept introduced, the add that is really an upsert (BR-107), and a domain event raised on one branch only.** A user answering the same question twice must not accumulate rows; the second submission replaces the first. The handler encodes that as an identity lookup on the triple (current user, question, event): the event is already the aggregate being loaded, so the search reduces to scanning the loaded children for `!a.IsDeleted && a.QuestionId == command.QuestionId && a.CreatedBy == userId` (`AddEventQuestionAnswerHandler.cs:51-52`). The owner is read from [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) (`:50`), never from the command, so a caller cannot answer on someone else's behalf. The second idea is the one to carry forward: the create path (and only the create path) calls `entity.AddDomainEvent(new EventFeedbackSubmitted(userId, entity.Id, timeProvider.GetUtcNow().UtcDateTime))` (`:112`) so the Engagement module can award feedback points once per user per event. Because the event is added to the aggregate *before* the save, the outbox captures it in the same `SaveChangesAsync` transaction as the answer row, which is the whole point of [ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html): there is no window where the answer is persisted but the notification is lost, and no window where points are awarded for an answer that rolled back. Editing an existing answer raises nothing, so points cannot be farmed by resubmitting. `[Rubric §11, Security]` assesses whether identity is derived server-side: the answering user is ambient, not a request field. `[Rubric §4, DDD]`: reconciliation happens against children already loaded in the aggregate, so no second round trip and no chance of mutating an answer outside its event. `[Rubric §6, CQRS & Event-Driven]`: one command, one handler, one transaction boundary at `SaveChangesAsync`, and cross-module effects travel as an event rather than as a direct call into Engagement. `[Rubric §7, Microservices Readiness]`: Conference does not reference Engagement at all here; it publishes a fact and lets the other service decide what it is worth. +- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`MMCA.ADC.Conference.Application/Events/UseCases/AddEventQuestionAnswer/AddEventQuestionAnswerHandler.cs:19`), [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) (`AddEventQuestionAnswerHandler.cs:20`), [`EventQuestionAnswerDTOMapper`](#eventquestionanswerdtomapper) (`AddEventQuestionAnswerHandler.cs:21`), `TimeProvider` (BCL, `AddEventQuestionAnswerHandler.cs:22`), `ILogger` (`AddEventQuestionAnswerHandler.cs:23`), the [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) contract (`AddEventQuestionAnswerHandler.cs:23`), the [`Event`](group-17-conference-domain.md#event) aggregate with its [`EventQuestionAnswer`](group-17-conference-domain.md#eventquestionanswer) children, [`EventInvariants`](group-17-conference-domain.md#eventinvariants) (`AddEventQuestionAnswerHandler.cs:40`), [`Question`](group-17-conference-domain.md#question) plus [`QuestionInvariants`](group-17-conference-domain.md#questioninvariants) (`AddEventQuestionAnswerHandler.cs:65,77`), [`EventFeedbackSubmitted`](group-17-conference-domain.md#eventfeedbacksubmitted) (`AddEventQuestionAnswerHandler.cs:6,112`), and [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error). +- **Concept introduced, the add that is really an upsert (BR-107), and a domain event raised on one branch only.** A user answering the same question twice must not accumulate rows; the second submission replaces the first. The handler encodes that as an identity lookup on the triple (current user, question, event): the event is already the aggregate being loaded, so the search reduces to scanning the loaded children for `!a.IsDeleted && a.QuestionId == command.QuestionId && a.CreatedBy == userId` (`AddEventQuestionAnswerHandler.cs:51-52`). The owner is read from [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) (`AddEventQuestionAnswerHandler.cs:50`), never from the command, so a caller cannot answer on someone else's behalf. The second idea is the one to carry forward: the create path (and only the create path) calls `entity.AddDomainEvent(new EventFeedbackSubmitted(userId, entity.Id, timeProvider.GetUtcNow().UtcDateTime))` (`AddEventQuestionAnswerHandler.cs:112`) so the Engagement module can award feedback points once per user per event. Because the event is added to the aggregate *before* the save, the outbox captures it in the same `SaveChangesAsync` transaction as the answer row, which is the whole point of [ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html): there is no window where the answer is persisted but the notification is lost, and none where points are awarded for an answer that rolled back. The file states that reasoning inline (`AddEventQuestionAnswerHandler.cs:109-111`). Editing an existing answer raises nothing, so points cannot be farmed by resubmitting. `[Rubric §11, Security]` assesses whether identity is derived server-side: the answering user is ambient, not a request field. `[Rubric §4, DDD]` assesses aggregate integrity: reconciliation happens against children already loaded in the aggregate, so there is no second round trip and no chance of mutating an answer outside its event. `[Rubric §6, CQRS & Event-Driven]` assesses the write path: one command, one handler, one transaction boundary at `SaveChangesAsync`, and cross-module effects travel as an event rather than as a direct call into Engagement. `[Rubric §7, Microservices Readiness]` assesses coupling: Conference does not reference Engagement at all here; it publishes a fact and lets the other service decide what it is worth. - **Walkthrough** - - `HandleAsync` (`:26-58`) resolves the event repository from `unitOfWork.GetRepository()` (`:30`) and loads with `includes: [nameof(Event.EventQuestionAnswers)]` and `asTracking: true` (`:31-35`), because the upsert scan walks that child collection and EF must be tracking it for the update branch to persist. A missing event returns `Error.NotFound` sourced to the handler and targeted at `Event` (`:36-37`). - - BR-108 is checked by delegating to `EventInvariants.EnsureEventIsPublished(entity.IsPublished, ...)` (`:40-42`), which fails with the invariant code `Event.NotPublished` (`MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:153-160`). - - The private `ValidateQuestionAsync` (`:60-79`) loads the [`Question`](group-17-conference-domain.md#question) by id (`:65-66`) and fails with the validation code `Question.NotFoundOrWrongEntity` when it is missing or its `QuestionEntity != "Event"` (BR-128, `:67-74`), then returns `QuestionInvariants.EnsureAnswerValueMatchesQuestionType(question.QuestionType, command.AnswerValue, ...)` (BR-124, `:77-78`), which switches on `"Rating"`, `"Text"`, or `"Email"` and rejects an unknown type outright (`MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:115-126`). - - Only then does the upsert branch run (`:54-57`). An existing answer routes to `UpdateExistingAnswerAsync` (`:81-94`), which calls `entity.UpdateEventQuestionAnswer(existingAnswer.Id, command.AnswerValue)` (`:87`), saves (`:91`), and returns the mapped existing row (`:93`). Otherwise `CreateNewAnswerAsync` (`:96-117`) calls `entity.AddEventQuestionAnswer(...)` (`:102-105`), adds the [`EventFeedbackSubmitted`](group-17-conference-domain.md#eventfeedbacksubmitted) notification (`:112`, with the reason spelled out in the comment at `:109-111`), saves (`:114`), and returns the mapped new child (`:116`). + - `HandleAsync` (`AddEventQuestionAnswerHandler.cs:26-58`) resolves the event repository from `unitOfWork.GetRepository()` (`:30`) and loads with `includes: [nameof(Event.EventQuestionAnswers)]` and `asTracking: true` (`:31-35`), because the upsert scan walks that child collection and EF must be tracking it for the update branch to persist. A missing event returns `Error.NotFound` sourced to the handler and targeted at `Event` (`:36-37`). + - BR-108 is checked by delegating to `EventInvariants.EnsureEventIsPublished(entity.IsPublished, ...)` (`:40-42`), which fails with the invariant code `Event.NotPublished` and the message "This action requires the event to be published." (`MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:156-163`). + - The private `ValidateQuestionAsync` (`:60-79`) loads the [`Question`](group-17-conference-domain.md#question) by id (`:65-66`) and fails with the validation code `Question.NotFoundOrWrongEntity` when it is missing or its `QuestionEntity != "Event"` (BR-128, `:67-74`), then returns `QuestionInvariants.EnsureAnswerValueMatchesQuestionType(question.QuestionType, command.AnswerValue, ...)` (BR-124, `:77-78`), which switches on `"Rating"`, `"Text"`, or `"Email"` and rejects an unknown type outright with `Question.QuestionType.Unknown` (`MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:115-126`). + - Only then does the upsert branch run (`:54-57`). An existing answer routes to `UpdateExistingAnswerAsync` (`:81-94`), which calls `entity.UpdateEventQuestionAnswer(existingAnswer.Id, command.AnswerValue)` (`:87`), saves (`:91`), and returns the mapped existing row (`:93`). Otherwise `CreateNewAnswerAsync` (`:96-117`) calls `entity.AddEventQuestionAnswer(...)` (`:102-105`), adds the [`EventFeedbackSubmitted`](group-17-conference-domain.md#eventfeedbacksubmitted) notification (`:112`), saves (`:114`), and returns the mapped new child (`:116`). - Both branches log through the source-generated `LogQuestionAnswerAddedToEvent` (`:92`, `:115`, declared `:119-120`). -- **Why it's built this way**: the three gates run in increasing cost order, cheapest first: the aggregate is already loaded, the published flag is in memory, and only the question check costs a second query. Splitting the two upsert outcomes into private methods keeps `HandleAsync` readable as a decision tree while both paths share one save. Taking the timestamp from an injected `TimeProvider` rather than `DateTime.UtcNow` keeps the points-awarding path deterministic under test. The `[LoggerMessage]` source generator gives allocation-free structured logging (`[Rubric §13, Observability & Operability]`), and `ConfigureAwait(false)` on the infrastructure awaits follows the library convention ([ADR-049](https://ivanball.github.io/docs/adr/049-library-configureawait-policy.html)). -- **Where it's used**: dispatched by the answers controller through the CQRS pipeline (`MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:58`, `EventQuestionAnswersController.cs:182`). Downstream, the published `EventFeedbackSubmitted` is consumed by Engagement's `EventFeedbackSubmittedPointsHandler` (`MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/EventFeedbackSubmittedPointsHandler.cs:26`), registered on the Engagement service host (`MMCA.ADC.Engagement.Service/Program.cs:301`). +- **Why it's built this way**: the three gates run in increasing cost order, cheapest first: the aggregate is already loaded, the published flag is in memory, and only the question check costs a second query. Splitting the two upsert outcomes into private methods keeps `HandleAsync` readable as a decision tree while both paths share one save. Taking the timestamp from an injected `TimeProvider` rather than `DateTime.UtcNow` keeps the points-awarding path deterministic under test. The `[LoggerMessage]` source generator gives allocation-free structured logging (`[Rubric §13, Observability & Operability]`), and `ConfigureAwait(false)` on the infrastructure awaits (`:91`, `:114`) follows the library convention ([ADR-049](https://ivanball.github.io/docs/adr/049-library-configureawait-policy.html)). +- **Where it's used**: dispatched by the answers controller through the CQRS pipeline (`MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:59`, `EventQuestionAnswersController.cs:190`). Downstream, the published `EventFeedbackSubmitted` is consumed by Engagement's `EventFeedbackSubmittedPointsHandler` (`MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/EventFeedbackSubmittedPointsHandler.cs:26`), wired on the Engagement service host by `x.RegisterIntegrationEventConsumer()` (`MMCA.ADC.Engagement.Service/Program.cs:307`, with the full consumer map documented at `Program.cs:284-287`). Covered by [`AddEventQuestionAnswerHandlerTests`](group-27-testing-infrastructure.md#addeventquestionanswerhandlertests). - **Caveats / not-in-source**: `currentUserService.UserId!.Value` (`AddEventQuestionAnswerHandler.cs:50`) is null-forgiven, so the handler assumes an authenticated caller; the enforcement of that assumption lives in the controller's authorization attributes, not here. The success log message is identical for the update and insert branches (`:119`), so the log alone does not distinguish an upsert from a new answer; only the presence of the outbox row does. ### AddEventSpeakerCommandValidator @@ -2491,10 +2737,10 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.AddEventSpeaker` · `MMCA.ADC.Conference.Application/Events/UseCases/AddEventSpeaker/AddEventSpeakerCommandValidator.cs:8` · Level 9 · class (sealed) - **What it is**: the FluentValidation validator for [`AddEventSpeakerCommand`](#addeventspeakercommand). It rejects a default (empty) speaker id. -- **Depends on**: `AbstractValidator` from FluentValidation (`AddEventSpeakerCommandValidator.cs:1,8`) and the `SpeakerIdentifierType` alias. -- **Concept**: the same inline-validator shape as [`AddEventQuestionAnswerCommandValidator`](#addeventquestionanswercommandvalidator), applied to an identifier instead of a string. `NotEqual(default(SpeakerIdentifierType))` (`AddEventSpeakerCommandValidator.cs:12`) is written against the alias rather than a concrete type, so the rule keeps working if the module's speaker key type changes: the alias is `System.Guid` today (`MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:18`), which makes this an "id is not `Guid.Empty`" check. `[Rubric §24, Forms, Validation & UX Safety]`. +- **Depends on**: `AbstractValidator` from FluentValidation (`MMCA.ADC.Conference.Application/Events/UseCases/AddEventSpeaker/AddEventSpeakerCommandValidator.cs:1,8`) and the `SpeakerIdentifierType` alias. +- **Concept**: the same inline-validator shape as [`AddEventQuestionAnswerCommandValidator`](#addeventquestionanswercommandvalidator), applied to an identifier instead of a string. `NotEqual(default(SpeakerIdentifierType))` (`AddEventSpeakerCommandValidator.cs:12`) is written against the alias rather than a concrete type, so the rule keeps working if the module's speaker key type changes. `[Rubric §24, Forms, Validation & UX Safety]` assesses boundary rejection: an unset identifier is caught before any database work. - **Walkthrough**: an expression-bodied constructor (`AddEventSpeakerCommandValidator.cs:10`) with one rule, `RuleFor(x => x.SpeakerId).NotEqual(default(SpeakerIdentifierType)).WithMessage("Speaker ID is required.")` (`AddEventSpeakerCommandValidator.cs:11-13`). -- **Why it's built this way**: an empty identifier is a client bug worth catching before a database round trip; whether the speaker actually exists, and whether it is already linked to this event, are questions only the aggregate can answer, so those stay in `Event.AddEventSpeaker` (`MMCA.ADC.Conference.Domain/Events/Event.cs:515-522`). +- **Why it's built this way**: an empty identifier is a client bug worth catching before a database round trip; whether the speaker actually exists, and whether it is already linked to this event, are questions only the aggregate can answer, so those stay in `Event.AddEventSpeaker` (`MMCA.ADC.Conference.Domain/Events/Event.cs:544-551`). - **Where it's used**: run by the pipeline's validation decorator ahead of [`AddEventSpeakerHandler`](#addeventspeakerhandler). - **Caveats / not-in-source**: unlike its sibling in this unit, this rule sets no `WithErrorCode`, so the failure surfaces with FluentValidation's default code rather than a stable dotted code (`AddEventSpeakerCommandValidator.cs:11-13`). Whether that is deliberate is not stated in source. @@ -2503,21 +2749,21 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.AddEventSpeaker` · `MMCA.ADC.Conference.Application/Events/UseCases/AddEventSpeaker/AddEventSpeakerHandler.cs:15` · Level 9 · class (sealed partial) - **What it is**: the handler that associates a speaker with an event. It is the reference "load aggregate, call a domain method, save, map" shape with nothing else layered on. -- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`AddEventSpeakerHandler.cs:16`), [`EventSpeakerDTOMapper`](#eventspeakerdtomapper) (`:17`), `ILogger` (`:18`), the [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) contract returning `Result` (`:18`), the [`Event`](group-17-conference-domain.md#event) aggregate, and [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error). -- **Concept**: read this one first if you want the skeleton every other handler in the unit decorates. Four moves and no branching beyond the two failure exits: load, delegate, save, map. Note what is *absent*: no ownership check (that is [`UpdateEventQuestionAnswerHandler`](#updateeventquestionanswerhandler)), no id allocation (that is [`AddRoomHandler`](#addroomhandler)), no upsert and no integration event (that is [`AddEventQuestionAnswerHandler`](#addeventquestionanswerhandler)). The one non-obvious move is the include list, and the file explains it in a comment (`:27-28`): the join collection has to be loaded or the aggregate's duplicate check runs against an empty in-memory list and a double submit surfaces as a raw unique-index 409 instead of a clean domain error. `[Rubric §5, Vertical Slice]` assesses whether a use case is self-contained: the command, validator, and handler live in one folder and share no base class. `[Rubric §4, DDD]`: the duplicate-association rule lives on the aggregate (`MMCA.ADC.Conference.Domain/Events/Event.cs:515-522`), so the handler never re-implements it, but the handler is responsible for loading enough state for that rule to be evaluable. +- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`MMCA.ADC.Conference.Application/Events/UseCases/AddEventSpeaker/AddEventSpeakerHandler.cs:16`), [`EventSpeakerDTOMapper`](#eventspeakerdtomapper) (`AddEventSpeakerHandler.cs:17`), `ILogger` (`AddEventSpeakerHandler.cs:18`), the [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) contract returning `Result` (`AddEventSpeakerHandler.cs:18`), the [`Event`](group-17-conference-domain.md#event) aggregate, and [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error). +- **Concept**: read this one first if you want the skeleton every other handler in the unit decorates. Four moves and no branching beyond the two failure exits: load, delegate, save, map. Note what is *absent*: no ownership check (that is [`UpdateEventQuestionAnswerHandler`](#updateeventquestionanswerhandler)), no id allocation (that is [`AddRoomHandler`](#addroomhandler)), no upsert and no integration event (that is [`AddEventQuestionAnswerHandler`](#addeventquestionanswerhandler)). The one non-obvious move is the include list, and the file explains it in a comment (`AddEventSpeakerHandler.cs:27-28`): the join collection has to be loaded, or the aggregate's duplicate check runs against an empty in-memory list and a double submit surfaces as a raw unique-index 409 instead of a clean domain error. `[Rubric §5, Vertical Slice]` assesses whether a use case is self-contained: the command, validator, and handler live in one folder and share no base class. `[Rubric §4, DDD]` assesses where rules live: the duplicate-association rule is on the aggregate (`MMCA.ADC.Conference.Domain/Events/Event.cs:544-551`), so the handler never re-implements it, but the handler is responsible for loading enough state for that rule to be evaluable. - **Walkthrough**: `HandleAsync` (`AddEventSpeakerHandler.cs:21-44`) resolves the event repository from the unit of work (`:25`) and loads by id with `[nameof(Event.EventSpeakers)]` and `asTracking: true` (`:29`), returning `Error.NotFound` sourced to the handler and targeted at `Event` when absent (`:30-31`). It calls `entity.AddEventSpeaker(command.EventSpeakerId, command.SpeakerId)` (`:33-35`) and propagates the aggregate's own errors verbatim on failure (`:36-37`). On success it persists with `SaveChangesAsync(...).ConfigureAwait(false)` (`:39`), logs the source-generated `LogSpeakerAddedToEvent` with both ids (`:41`, declared `:46-47`), and returns `Result.Success(eventSpeakerDTOMapper.MapToDTO(result.Value!))` (`:43`). -- **Why it's built this way**: returning the domain result's errors rather than a handler-authored message preserves the aggregate's error code (`Event.Speaker.Duplicate`, `Event.cs:518`) all the way to the API response, which is what turns a concurrent double submit into a predictable, client-parseable conflict rather than a database-shaped exception. -- **Where it's used**: dispatched by the event-speakers controller through the CQRS pipeline (`MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:48`, `EventSpeakersController.cs:215`). +- **Why it's built this way**: returning the domain result's errors rather than a handler-authored message preserves the aggregate's error code (`Event.Speaker.Duplicate`, `MMCA.ADC.Conference.Domain/Events/Event.cs:547`) all the way to the API response, which is what turns a concurrent double submit into a predictable, client-parseable conflict rather than a database-shaped exception. +- **Where it's used**: dispatched by the event-speakers controller through the CQRS pipeline (`MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:49`, `EventSpeakersController.cs:223`). Covered by [`AddEventSpeakerHandlerTests`](group-27-testing-infrastructure.md#addeventspeakerhandlertests). ### AddRoomCommandValidator > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.AddRoom` · `MMCA.ADC.Conference.Application/Events/UseCases/AddRoom/AddRoomCommandValidator.cs:7` · Level 9 · class (sealed) - **What it is**: the FluentValidation validator for [`AddRoomCommand`](#addroomcommand), assembled from six reusable per-field rule sets rather than written inline. -- **Depends on**: `AbstractValidator` (FluentValidation, `AddRoomCommandValidator.cs:1,7`) and the module's room rule sets [`RoomNameRules`](#roomnamerulest), [`RoomSortRules`](#roomsortrulest), [`RoomCapacityRules`](#roomcapacityrulest), [`RoomFloorRules`](#roomfloorrulest), [`RoomLocationRules`](#roomlocationrulest), and [`RoomAccessibilityInfoRules`](#roomaccessibilityinforulest) from `Events.Validation` (`AddRoomCommandValidator.cs:2,11-16`). -- **Concept introduced, rule composition via `Include`.** FluentValidation's `Include(...)` folds another validator's rules into this one, and requires both validators to be generic over the same `T`. That is why each rule set is generic in the request type and takes a property selector: the same `RoomNameRules` object validates a room name on this command, on [`UpdateRoomCommand`](#updateroomcommand), and on the Sessionize import path, each pointing at its own property. The alternative, restating the clauses per command, is what lets a constraint drift between the add and update endpoints. `[Rubric §15, Best Practices & Code Quality]` assesses duplication: there is exactly one definition of each room-field constraint. `[Rubric §16, Maintainability]`: a constraint change is a one-file edit that propagates to every command that composed the rule. -- **Walkthrough**: the constructor is six `Include` calls in field order, each constructing a rule set bound to the matching property: `RoomNameRules` on `p => p.Name` (`AddRoomCommandValidator.cs:11`), `RoomSortRules` on `Sort` (`:12`), `RoomCapacityRules` on `Capacity` (`:13`), `RoomFloorRules` on `Floor` (`:14`), `RoomLocationRules` on `Location` (`:15`), and `RoomAccessibilityInfoRules` on `AccessibilityInfo` (`:16`). No bespoke rule appears in this class; every constraint lives in the included types (`MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:12`, `:25`, `:37`, `:51`, `:64`, `:77`). Two of those bodies are worth reading: the name rule is required-plus-max-length against `EventInvariants.RoomNameMaxLength` with the stable codes `Room.Name.Required` and `Room.Name.MaxLength` (`RoomValidationRules.cs:16-18`), and the capacity rule is a positive-value check wrapped in a `When(...)` so it applies only when a capacity was supplied (`RoomValidationRules.cs:40-43`). -- **Why it's built this way**: rooms are written from two directions (organizer create and Sessionize import), and shared rule sets are the mechanism that keeps both honest without a base validator class. The length ceilings come from `EventInvariants` (`MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:40`), the same constants the EF entity configuration applies to the columns, so validation and schema cannot drift. Its update counterpart [`UpdateRoomCommandValidator`](#updateroomcommandvalidator) composes the same six sets, which is the clearest demonstration that the composition is the point. +- **Depends on**: `AbstractValidator` (FluentValidation, `MMCA.ADC.Conference.Application/Events/UseCases/AddRoom/AddRoomCommandValidator.cs:1,7`) and the module's room rule sets [`RoomNameRules`](#roomnamerulest), [`RoomSortRules`](#roomsortrulest), [`RoomCapacityRules`](#roomcapacityrulest), [`RoomFloorRules`](#roomfloorrulest), [`RoomLocationRules`](#roomlocationrulest), and [`RoomAccessibilityInfoRules`](#roomaccessibilityinforulest) from `Events.Validation` (`AddRoomCommandValidator.cs:2,11-16`). +- **Concept introduced, rule composition via `Include`.** FluentValidation's `Include(...)` folds another validator's rules into this one, and requires both validators to be generic over the same `T`. That is why each rule set is generic in the request type and takes a property selector: the same `RoomNameRules` object validates a room name on this command and on [`UpdateRoomCommand`](#updateroomcommand), each pointing at its own property. The alternative, restating the clauses per command, is what lets a constraint drift between the add and update endpoints. `[Rubric §15, Best Practices & Code Quality]` assesses duplication: there is exactly one definition of each room-field constraint. `[Rubric §16, Maintainability]` assesses change cost: a constraint change is a one-file edit that propagates to every command that composed the rule. +- **Walkthrough**: the constructor is six `Include` calls in field order, each constructing a rule set bound to the matching property: `RoomNameRules` on `p => p.Name` (`AddRoomCommandValidator.cs:11`), `RoomSortRules` on `Sort` (`:12`), `RoomCapacityRules` on `Capacity` (`:13`), `RoomFloorRules` on `Floor` (`:14`), `RoomLocationRules` on `Location` (`:15`), and `RoomAccessibilityInfoRules` on `AccessibilityInfo` (`:16`). No bespoke rule appears in this class; every constraint lives in the included types (`MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:12`, `:25`, `:37`, `:51`, `:64`, `:77`). Two of those bodies are worth reading: the name rule is required-plus-max-length against `EventInvariants.RoomNameMaxLength` with the stable codes `Room.Name.Required` and `Room.Name.MaxLength` (`RoomValidationRules.cs:17-18`), and the capacity rule is a `GreaterThan(0)` check wrapped in a `When(...)` so it applies only when a capacity was supplied (`RoomValidationRules.cs:42-43`). +- **Why it's built this way**: shared rule sets are the mechanism that keeps the add and update endpoints honest without a base validator class. The length ceilings come from `EventInvariants` (`MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:43`, where `RoomNameMaxLength` is `255`), the same constants the domain guard and the EF entity configuration apply, so validation, invariant, and schema cannot drift. Its update counterpart [`UpdateRoomCommandValidator`](#updateroomcommandvalidator) composes the same six sets, which is the clearest demonstration that the composition is the point. - **Where it's used**: resolved and invoked by the pipeline's validation decorator (Group 05) for [`AddRoomCommand`](#addroomcommand), before [`AddRoomHandler`](#addroomhandler) runs. ### AddRoomHandler @@ -2525,228 +2771,206 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.AddRoom` · `MMCA.ADC.Conference.Application/Events/UseCases/AddRoom/AddRoomHandler.cs:18` · Level 9 · class (sealed partial) - **What it is**: the handler that adds a room to an event. It carries two concerns no other handler in this unit has: it allocates the room's primary key itself from a reserved range, and it retries in a fresh DI scope when a concurrent add wins the same key. -- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`AddRoomHandler.cs:19`), `IServiceScopeFactory` from `Microsoft.Extensions.DependencyInjection` (`:1,20`), [`RoomDTOMapper`](#roomdtomapper) (`:21`), `ILogger` (`:22`), the [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) contract (`:22`), the [`Event`](group-17-conference-domain.md#event) and [`Room`](group-17-conference-domain.md#room) types, [`EventInvariants`](group-17-conference-domain.md#eventinvariants) for the reserved-range constants (`:96,102,104`), [`IReadRepository`](group-07-persistence-ef-core.md#ireadrepositorytentity-tidentifiertype) via `GetReadRepository` (`:93`), and [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error). -- **Concept introduced, reserved-range key allocation with a bounded, index-aware collision retry.** Room ids are application-assigned: the integer primary key *is* the Sessionize id, so a database identity column cannot own the value or an import would overwrite an organizer's room. The codebase reserves a high block for manually created rooms, `EventInvariants.RoomManualIdRangeStart = 999_999_000` through `RoomManualIdRangeEnd = 999_999_999` (`MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:59,62`), and has the handler pick the next free id inside it. That read-then-write is inherently racy across concurrent requests, so the handler wraps the whole attempt in a bounded retry keyed on a duplicate-key failure. The refinement worth studying is that not every duplicate-key failure is retryable: rooms also carry a unique index on `(EventId, Name)`, and recomputing an *id* would never clear a *name* conflict, so the handler names that index in a `const` and excludes it from the retry filter (`AddRoomHandler.cs:31`, `:140-141`). Without that carve-out a genuine duplicate-name request would burn all three attempts before the caller finally got the conflict. `[Rubric §8, Data Architecture]` assesses id strategy and ownership of key space: a reserved range keeps two writers (the app and Sessionize) in one integer column without a coordination service. `[Rubric §29, Resilience & Business Continuity]` assesses whether transient conflicts are absorbed rather than surfaced, and whether non-transient ones are correctly *not* retried: three attempts (`:25`), and only for the collision class that a retry can actually fix. `[Rubric §13, Observability & Operability]`: the collision path logs a warning naming the attempt number (`:153-154`), so the race is visible in telemetry instead of silent. +- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`MMCA.ADC.Conference.Application/Events/UseCases/AddRoom/AddRoomHandler.cs:19`), `IServiceScopeFactory` from `Microsoft.Extensions.DependencyInjection` (`AddRoomHandler.cs:1,20`), [`RoomDTOMapper`](#roomdtomapper) (`AddRoomHandler.cs:21`), `ILogger` (`AddRoomHandler.cs:22`), the [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) contract (`AddRoomHandler.cs:22`), the [`Event`](group-17-conference-domain.md#event) and [`Room`](group-17-conference-domain.md#room) types, [`EventInvariants`](group-17-conference-domain.md#eventinvariants) for the reserved-range constants (`AddRoomHandler.cs:96,102,104`), [`IReadRepository`](group-07-persistence-ef-core.md#ireadrepositorytentity-tidentifiertype) via `GetReadRepository` (`AddRoomHandler.cs:93`), and [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error). +- **Concept introduced, reserved-range key allocation with a bounded, index-aware collision retry.** Room ids are application-assigned: the integer primary key *is* the Sessionize id, so a database identity column cannot own the value or an import would overwrite an organizer's room (`AddRoomHandler.cs:87-89`). The codebase reserves a high block for manually created rooms, `EventInvariants.RoomManualIdRangeStart = 999_999_000` through `RoomManualIdRangeEnd = 999_999_999` (`MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:62,65`), and has the handler pick the next free id inside it. That read-then-write is inherently racy across concurrent requests, so the handler wraps the whole attempt in a bounded retry keyed on a duplicate-key failure. The refinement worth studying is that not every duplicate-key failure is retryable: rooms also carry a unique index on `(EventId, Name)`, and recomputing an *id* would never clear a *name* conflict, so the handler names that index in a `const` and excludes it from the retry filter (`AddRoomHandler.cs:31`, `AddRoomHandler.cs:140-141`). Without that carve-out a genuine duplicate-name request would burn all three attempts before the caller finally got the conflict. `[Rubric §8, Data Architecture]` assesses id strategy and ownership of key space: a reserved range keeps two writers (the app and Sessionize) in one integer column without a coordination service. `[Rubric §29, Resilience & Business Continuity]` assesses whether transient conflicts are absorbed rather than surfaced, and whether non-transient ones are correctly *not* retried: three attempts (`AddRoomHandler.cs:25`), and only for the collision class a retry can actually fix. `[Rubric §13, Observability & Operability]` assesses whether the race is visible: the collision path logs a warning naming the attempt number and the budget (`AddRoomHandler.cs:153-154`). - **Walkthrough**: three parts. - - `HandleAsync` (`:34-67`) is pure retry policy. An explicit `command.RoomId` short-circuits to a single attempt with no recomputation, because a collision on a caller-supplied id (a Sessionize import, say) is a genuine caller error (`:38-41`). Otherwise it loops: attempt 1 runs against the ambient unit of work (`:49-50`); every later attempt opens `scopeFactory.CreateAsyncScope()` and resolves a fresh [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) from it (`:57-59`), because the ambient `DbContext` still tracks the failed insert and the whole body (including the event load) must re-run against a clean context, as the comment at `:52-56` explains. The `catch` filter is narrow, `attempt < MaxManualIdAttempts && IsUniqueKeyViolation(ex)` (`:61`), so anything else propagates. - - `AddRoomCoreAsync` (`:74-126`) is one attempt: resolve the repository (`:79`), load the event with `[nameof(Event.Rooms)]` and `asTracking: true` (`:83`, and the comment at `:81-82` says why: without the include, the aggregate's duplicate-name check runs against an empty list), fail `NotFound` if absent (`:84-85`); when `command.RoomId is null`, take a read repository for `Room` and `GetAllAsync` every room in the reserved range with `ignoreQueryFilters: true` so a soft-deleted room still reserves its id (`:93-98`), compute `Max(r => r.Id) + 1` or the range start when empty (`:100-102`), and fail with `Error.Failure(..., "Manual room ID range exhausted.")` if that passes the range end (`:104-105`); then delegate to `entity.AddRoom(roomId, command.Name, command.Sort, command.Capacity, command.Floor, command.Location, command.AccessibilityInfo)` (`:110-117`), propagate domain errors (`:118-119`), `SaveChangesAsync` (`:121`), log `LogRoomAdded` (`:123`, declared `:150-151`), and return the mapped [`RoomDTO`](group-17-conference-domain.md#roomdto) (`:125`). + - `HandleAsync` (`AddRoomHandler.cs:34-67`) is pure retry policy. An explicit `command.RoomId` short-circuits to a single attempt with no recomputation, because a collision on a caller-supplied id is a genuine caller error (`:38-41`). Otherwise it loops: attempt 1 runs against the ambient unit of work (`:49-50`); every later attempt opens `scopeFactory.CreateAsyncScope()` and resolves a fresh [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) from it (`:57-59`), because the ambient `DbContext` still tracks the failed insert and the whole body (including the event load) must re-run against a clean context, as the comment at `:52-56` explains. The `catch` filter is narrow, `attempt < MaxManualIdAttempts && IsUniqueKeyViolation(ex)` (`:61`), so anything else propagates. + - `AddRoomCoreAsync` (`:74-126`) is one attempt: resolve the repository (`:79`), load the event with `[nameof(Event.Rooms)]` and `asTracking: true` (`:83`, with the comment at `:81-82` explaining that without the include the aggregate's duplicate-name check runs against an empty list), fail `NotFound` if absent (`:84-85`); when `command.RoomId is null`, take a read repository for `Room` and `GetAllAsync` every room in the reserved range with `ignoreQueryFilters: true` so a soft-deleted room still reserves its id (`:93-98`), compute `Max(r => r.Id) + 1` or the range start when the set is empty (`:100-102`), and fail with `Error.Failure(..., "Manual room ID range exhausted.")` if that passes the range end (`:104-105`); then delegate to `entity.AddRoom(roomId, command.Name, command.Sort, command.Capacity, command.Floor, command.Location, command.AccessibilityInfo)` (`:110-117`), propagate domain errors (`:118-119`), `SaveChangesAsync` on the attempt's unit of work (`:121`), log `LogRoomAdded` (`:123`, declared `:150-151`), and return the mapped [`RoomDTO`](group-17-conference-domain.md#roomdto) (`:125`). - `IsUniqueKeyViolation` (`:136-148`) walks the whole `InnerException` chain (`:138`) looking for the substring "duplicate key" with `OrdinalIgnoreCase` while excluding any message that also names `RoomNameIndexName` (`:140-141`). -- **Why it's built this way**: the aggregate owns room creation invariants, while the handler owns the one concern no single event can decide, namely an id range that is global across all events (`AddRoomHandler.cs:87-89`). Detection is message-based rather than typed on a SQL exception because the Application layer is not allowed to reference EF Core or provider types (`:128-135`), which is Clean Architecture's dependency rule paying a small cost in precision; the comment records that both SQL Server errors 2601 and 2627 report "duplicate key". Retrying in a *new* scope instead of reusing the failed one is the load-bearing detail: reusing the attempt's already-mutated `Event` would append a second room and re-raise its domain event, and attaching that instance to another context is not permitted (`:52-56`). -- **Where it's used**: dispatched by the rooms controller through the CQRS pipeline for [`AddRoomCommand`](#addroomcommand) (`MMCA.ADC.Conference.API/Controllers/RoomsController.cs:87`, `RoomsController.cs:150`). +- **Why it's built this way**: the aggregate owns room creation invariants, while the handler owns the one concern no single event can decide, namely an id range that is global across all events (`AddRoomHandler.cs:87-89`). Detection is message-based rather than typed on a SQL exception because the Application layer is not allowed to reference EF Core or provider types (`AddRoomHandler.cs:128-135`), which is Clean Architecture's dependency rule paying a small cost in precision; the comment records that both SQL Server errors 2601 and 2627 report "duplicate key". Retrying in a *new* scope instead of reusing the failed one is the load-bearing detail: reusing the attempt's already-mutated `Event` would append a second room and re-raise its `RoomChanged` event, and attaching that instance to another context is not permitted (`AddRoomHandler.cs:52-56`). +- **Where it's used**: dispatched by the rooms controller through the CQRS pipeline for [`AddRoomCommand`](#addroomcommand) (`MMCA.ADC.Conference.API/Controllers/RoomsController.cs:94`, `RoomsController.cs:262-272`). Covered by [`AddRoomHandlerTests`](group-27-testing-infrastructure.md#addroomhandlertests). - **Caveats / not-in-source**: the `RoomNameIndexName` constant's doc comment says the index is "declared in the Infrastructure entity configuration" (`AddRoomHandler.cs:27-31`), but the configuration declares it without an explicit name, `builder.HasIndex(p => new { p.EventId, p.Name }).IsUnique().HasSoftDeleteFilter()` (`MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/RoomConfiguration.cs:52-54`); the literal `IX_Room_EventId_Name` is EF's conventional name and appears verbatim only in the migration that created it (`MMCA.ADC.Migrations.SqlServer.Conference/Migrations/20260813223101_AddRoomNameUniqueIndex.cs:46`). Nothing in source pins the two together, so renaming the index would silently re-arm the retry loop for name conflicts. Separately, an attempt that fails for a non-duplicate reason is not retried at all (the filter excludes it) and surfaces as a thrown exception to the pipeline's exception middleware, and the message-based match would not recognize a provider that words its duplicate-key error differently. -### EventCreateRequestMapper +### EventDTOMapper -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.Create` · `MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequestMapper.cs:11` · Level 9 · class (sealed) +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.DTOs` · `MMCA.ADC.Conference.Application/Events/DTOs/EventDTOMapper.cs:14` · Level 9 · class (sealed partial) -- **What it is**: the one adapter that turns an [`EventCreateRequest`](#eventcreaterequest) into an [`Event`](group-17-conference-domain.md#event) domain entity, by calling the aggregate's `Create` factory and returning whatever [`Result`](group-01-result-error-handling.md#result) it produces. -- **Depends on**: [`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype) from `MMCA.Common.Application.Interfaces` (`EventCreateRequestMapper.cs:2,11-12`), the [`Event`](group-17-conference-domain.md#event) aggregate and its `EventIdentifierType` alias (`EventCreateRequestMapper.cs:1`), and [`Result`](group-01-result-error-handling.md#result) from `MMCA.Common.Shared.Abstractions` (`EventCreateRequestMapper.cs:3,15`). -- **Concept introduced, request-to-entity mapping as a separate injectable role.** The generic create pipeline never constructs entities itself; it resolves an `IEntityRequestMapper` and asks it for one. That indirection is what lets one handler shape serve every aggregate while each aggregate keeps its own construction rules. Two properties of this mapper matter. First, it is a plain `sealed class` with no `[Mapper]` attribute (`EventCreateRequestMapper.cs:11`): unlike the read-side DTO mappers, request-to-entity conversion is deliberately hand-written, because it must go through a factory that can *fail*, which a property-copy generator cannot express. Second, it returns `Task>` rather than an `Event`, so an invalid request produces a failure value that flows back through the handler as a 400-class response instead of an exception. `[Rubric §3, Clean Architecture]` assesses whether the domain stays independent of the delivery mechanism: the controller knows a request type, the domain knows a factory, and this class is the only thing that knows both. `[Rubric §4, Domain-Driven Design]`: the factory stays the single construction path, so no invariant can be bypassed by `new`. See [ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html) for the no-reflection mapping policy. -- **Walkthrough**: one method. `CreateEntityAsync(EventCreateRequest request, CancellationToken)` (`EventCreateRequestMapper.cs:15`) null-guards with `ArgumentNullException.ThrowIfNull(request)` (`:17`), then returns `Task.FromResult(Event.Create(...))` (`:19-31`). The first ten arguments are forwarded positionally, `Id`, `Name`, `Description`, `StartDate`, `EndDate`, `TimeZone`, `SessionizeCode`, `VenueAddress`, `VenueMapUrl`, `WiFiInfo` (`:20-29`), and the last two are passed by name, `organizerContactEmail:` and `sponsorshipPacketUrl:` (`:30-31`). The named form is not decoration: the factory's eleventh parameter sits between them and the positional block, `questionModerationDefault` (`MMCA.ADC.Conference.Domain/Events/Event.cs:166`), and it is deliberately *not* forwarded, so a newly created event takes the declared default `QuestionModerationDefault.Pending` and starts with moderated live-layer questions (BR-233). The method is synchronous in substance: `Task.FromResult` satisfies the async contract without allocating a state machine, because the factory does no I/O. The forwarded `Id` is discarded by the factory when the aggregate's key is store-generated (`Event.cs:177,192`). -- **Why it's built this way**: pushing construction into `Event.Create` means the invariant checks combined at `Event.cs:170-173` (`EnsureNameIsValid`, `EnsureTimeZoneIsValid`, `EnsureDateRangeIsValid`) run even for callers that never touch the HTTP layer, such as the Sessionize import, and the `EventChanged` domain event is raised inside the factory (`Event.cs:196`) rather than by any caller. The mapper adds no rules of its own, which is exactly what makes it safe to have several entry points. -- **Where it's used**: injected into [`CreateEventHandler`](#createeventhandler) as `IEntityRequestMapper` (`CreateEventHandler.cs:18`), registered by the module's assembly scanning rather than an explicit `AddScoped` line (`MMCA.ADC.Conference.Application/DependencyInjection.cs`). +- **What it is**: the read-side mapper for the [`Event`](group-17-conference-domain.md#event) aggregate. It is the composite of the family: it maps the root, delegates its three child collections to the child mappers, then applies one hand-written fix-up the generator cannot express. +- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) (`MMCA.ADC.Conference.Application/Events/DTOs/EventDTOMapper.cs:3,18`), Mapperly (`EventDTOMapper.cs:4,13,20,23,26,50`), [`RoomDTOMapper`](#roomdtomapper), [`EventSpeakerDTOMapper`](#eventspeakerdtomapper), and [`EventQuestionAnswerDTOMapper`](#eventquestionanswerdtomapper) (`EventDTOMapper.cs:15-17`), the [`Event`](group-17-conference-domain.md#event) aggregate (`EventDTOMapper.cs:1`), [`EventDTO`](group-17-conference-domain.md#eventdto) from the Shared project (`EventDTOMapper.cs:2`), and `System.Globalization.CultureInfo` (BCL, `EventDTOMapper.cs:39`). +- **Concept introduced, composing generated mappers and escaping to hand-written code.** Two Mapperly features carry this class. `[UseMapper]` on a field (`EventDTOMapper.cs:20,23,26`) tells the generator "when you need to map a `Room`, an `EventSpeaker`, or an `EventQuestionAnswer`, call this instance instead of generating a second copy", which is how an aggregate DTO gets its child collections filled without duplicating child mapping logic. `[MapperIgnoreTarget]` (`EventDTOMapper.cs:50`) is the escape hatch: it tells the generator to leave one target member alone so the class can set it itself. The member in question is `LastSessionizeRefreshBy`, a `UserIdentifierType?` on the entity (`MMCA.ADC.Conference.Domain/Events/Event.cs:83`) but a `string?` on the DTO (`MMCA.ADC.Conference.Shared/Events/EventDTO.cs:63`); a nullable-value-type-to-string conversion is not something the generator will invent, and the file's own comment says so (`EventDTOMapper.cs:35`). The general lesson is that a source generator is not all-or-nothing: keep generation for the ninety percent of straight property copies and hand-write only the member that needs a decision. `[Rubric §12, Performance & Scalability]` assesses avoidable runtime work on hot paths: the whole event read path, including children, is generated assignment plus one `with` expression. `[Rubric §9, API & Contract Design]` assesses wire shape: the DTO's own types are chosen for the wire (an id rendered as a string), and this class is where the two type systems meet. `[Rubric §15, Best Practices & Code Quality]` assesses correctness details: the conversion uses `CultureInfo.InvariantCulture` (`EventDTOMapper.cs:38-39`) rather than the ambient culture, so the value is stable regardless of server locale. +- **Walkthrough**: the primary constructor takes the three child mappers (`EventDTOMapper.cs:14-17`) and assigns each to a `[UseMapper]`-annotated readonly field (`EventDTOMapper.cs:20-27`). The public `MapToDTO(Event entity)` (`:30-41`) is hand-written: it null-guards (`:32`), calls the private generated `MapToDTOGenerated(entity)` (`:33`), and then returns a `with` expression that sets the one ignored member, `LastSessionizeRefreshBy = entity.LastSessionizeRefreshBy?.ToString(System.Globalization.CultureInfo.InvariantCulture)` (`:36-40`). Because `EventDTO` is a record, the `with` copy is a cheap shallow clone that leaves every generated assignment intact. `MapToDTOs` (`:44-48`) is the same null-guarded spread projection as its siblings. The generated method itself is declared last, `private partial EventDTO MapToDTOGenerated(Event entity)` carrying `[MapperIgnoreTarget(nameof(EventDTO.LastSessionizeRefreshBy))]` (`:50-51`). +- **Why it's built this way**: making the public method the wrapper and the generated method private means no caller can accidentally bypass the fix-up and receive a DTO with a null `LastSessionizeRefreshBy`. Keeping the child mappers injected rather than generated inline means the same `RoomDTO` shape is produced whether a room is read directly or as part of an event, per [ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html). +- **Where it's used**: injected into [`CreateEventHandler`](#createeventhandler) (`MMCA.ADC.Conference.Application/Events/UseCases/Create/CreateEventHandler.cs:19`) and [`UpdateEventHandler`](#updateeventhandler) (`MMCA.ADC.Conference.Application/Events/UseCases/Update/UpdateEventHandler.cs:18`), and resolved as `IEntityDTOMapper` by the event query service (`MMCA.ADC.Conference.Application/DependencyInjection.cs:59`, constructor parameter at `MMCA.Common.Application/Services/EntityQueryService.cs:35`). Registration is by the module's convention scan (`MMCA.ADC.Conference.Application/DependencyInjection.cs:125`). Tested by [`EventDTOMapperTests`](group-27-testing-infrastructure.md#eventdtomappertests). -### EventCreateRequestValidator +### EventCreateRequest -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.Create` · `MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequestValidator.cs:7` · Level 9 · class (sealed) +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.Create` · `MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequest.cs:10` · Level 8 · record -- **What it is**: the FluentValidation validator the pipeline runs against an [`EventCreateRequest`](#eventcreaterequest) before [`CreateEventHandler`](#createeventhandler) sees it. It contains no rules of its own: it is five `Include` calls. -- **Depends on**: `FluentValidation.AbstractValidator` (NuGet, `EventCreateRequestValidator.cs:1,7`) and the five shared rule objects [`EventNameRules`](#eventnamerulest), [`EventTimeZoneRules`](#eventtimezonerulest), [`EventDateRangeRules`](#eventdaterangerulest), [`EventOrganizerContactEmailRules`](#eventorganizercontactemailrulest), and [`EventSponsorshipPacketUrlRules`](#eventsponsorshippacketurlrulest) from `MMCA.ADC.Conference.Application.Events.Validation` (`EventCreateRequestValidator.cs:2,11-15`). -- **Concept introduced, rule composition by `Include` with a property selector.** FluentValidation's `Include(otherValidator)` copies every rule from another `AbstractValidator` into this one, and it requires both validators to be generic over the *same* `T`. That is why the rule classes are generic in the containing type and take an expression selector in their constructor: `new EventNameRules(p => p.Name)` (`EventCreateRequestValidator.cs:11`) says "apply the event-name rules to *this* type's `Name` property". The same rule classes are re-included by [`EventUpdateRequestValidator`](#eventupdaterequestvalidator) against a different request type, so a length or format constraint is written once and both slices inherit it: there is no way for create and update to drift apart on what a valid event name is. `[Rubric §24, Forms, Validation & UX Safety]` assesses whether invalid input is rejected at the boundary with actionable messages: the request never reaches the aggregate when a rule fails. `[Rubric §16, Maintainability]`: a shared rule object is one edit point instead of N. `[Rubric §1, SOLID]`: each rule class has one reason to change, and validators compose them rather than inherit a fat base. -- **Walkthrough**: the whole type is a constructor (`EventCreateRequestValidator.cs:9-16`) with five `Include` calls: `EventNameRules` on `p => p.Name` (`:11`), `EventTimeZoneRules` on `p => p.TimeZone` (`:12`), `EventDateRangeRules` on the *pair* `p => p.StartDate, p => p.EndDate` (`:13`), `EventOrganizerContactEmailRules` on `p => p.OrganizerContactEmail!` (`:14`), and `EventSponsorshipPacketUrlRules` on `p => p.SponsorshipPacketUrl` (`:15`). The date-range rule takes two selectors because it is a cross-field rule, which is the reason it cannot be expressed as a per-property attribute (`MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:91-102`). The two optional-field rule sets share a shape worth noticing: each compiles its selector once and wraps the shared rule set in `When(x => !string.IsNullOrWhiteSpace(accessor(x)), ...)` (`EventValidationRules.cs:60-66` and `:78-84`), so an omitted contact email or packet URL is not an error while a supplied one is fully checked, the email against the framework `EmailRules` and the URL against `OptionalStringRules` with `EventInvariants` length ceilings. The time-zone rule is the one with real logic: required, length-capped, and `Must(BeAValidIanaTimeZone)` resolving the string through `TimeZoneInfo.FindSystemTimeZoneById` with the error code `Event.TimeZone.InvalidIana` (`EventValidationRules.cs:28-42`, BR-87). -- **Why it's built this way**: validating at the pipeline boundary gives the caller a complete, field-addressed error list in one round trip, while the domain factory keeps its own invariant checks as the backstop for non-HTTP callers. The duplication is intentional and cheap because both sides read the same `EventInvariants` limits. -- **Where it's used**: resolved by the validation decorator around [`CreateEventHandler`](#createeventhandler); the update-side sibling is [`EventUpdateRequestValidator`](#eventupdaterequestvalidator). -- **Caveats / not-in-source**: the organizer-contact-email selector is null-forgiven (`EventCreateRequestValidator.cs:14`) so it can bind a `string?` property to a rule set typed on `string`. The null case is handled by the `When` guard inside the rule set (`EventValidationRules.cs:64-65`), not by anything visible in this file. +- **What it is**: the inbound payload for creating a conference [`Event`](group-17-conference-domain.md#event), and simultaneously the command that the CQRS pipeline dispatches. There is no separate `CreateEventCommand`: the request record *is* the command, and [`CreateEventHandler`](#createeventhandler) is registered as `ICommandHandler>` (`MMCA.ADC.Conference.Application/Events/UseCases/Create/CreateEventHandler.cs:20`). +- **Depends on**: [`ICreateRequest`](group-05-cqrs-pipeline.md#icreaterequest) from `MMCA.Common.Application.Interfaces` (`EventCreateRequest.cs:2,10`), [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) from `MMCA.Common.Application.UseCases` (`EventCreateRequest.cs:3,10`), the [`Event`](group-17-conference-domain.md#event) domain type referenced only to build the cache prefix (`EventCreateRequest.cs:1,13`), the `EventIdentifierType` module alias ([ADR-048](https://ivanball.github.io/docs/adr/048-primitive-identifier-type-aliases.html), `EventCreateRequest.cs:16`), and `DateOnly` from the BCL (`EventCreateRequest.cs:25,28`). +- **Concept introduced, the create request as a dual-purpose contract.** Two marker interfaces do all the wiring here, and neither adds a member the author has to implement by hand except one property. [`ICreateRequest`](group-05-cqrs-pipeline.md#icreaterequest) is what allows the type to be the `TCreateRequest` argument of [`AggregateRootEntityControllerBase`](group-12-api-hosting-mapping.md#aggregaterootentitycontrollerbasetentity-tentitydto-tidentifiertype-tcreaterequest) (`MMCA.ADC.Conference.API/Controllers/EventsController.cs:58`), so the base controller can accept it as a `[FromBody]` model and hand it straight to a command handler (`MMCA.Common.API/Controllers/AggregateRootEntityControllerBase.cs:63-67`). [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) requires one property, `CachePrefix` (`MMCA.Common.Application/UseCases/ICacheInvalidating.cs:14`), and the caching decorator evicts every cached entry under that prefix after the command succeeds (`MMCA.Common.Application/UseCases/Decorators/CachingCommandDecorator.cs:76-89`). The prefix here is keyed on the aggregate type, `$"{typeof(Event).FullName}:"` (`EventCreateRequest.cs:13`), which is the same convention the framework's own generic delete command uses (`MMCA.Common.Application/UseCases/DeleteEntityCommand.cs:20`). `[Rubric §9, API & Contract Design]` assesses whether the wire contract is explicit and stable: `required` on `Name`, `StartDate`, `EndDate` and `TimeZone` (`EventCreateRequest.cs:19,25,28,31`) makes the mandatory set a compile-time fact for every in-process caller and a model-binding fact for HTTP callers, and the remaining nine fields are explicitly nullable rather than empty-string-as-absent. `[Rubric §10, Cross-Cutting]` assesses whether concerns like caching live in one place: this record declares *what* to evict and never touches a cache API. +- **Walkthrough**: `public record class EventCreateRequest : ICreateRequest, ICacheInvalidating` (`EventCreateRequest.cs:10`), then the computed `CachePrefix` (`EventCreateRequest.cs:13`). The payload is thirteen `init`-only properties: `Id` (`:16`), the required `Name` (`:19`), optional `Description` (`:22`), the required `StartDate` and `EndDate` (`:25,28`), the required IANA `TimeZone` (`:31`), then six optional strings, `SessionizeCode` (`:34`), `VenueAddress` (`:37`), `VenueMapUrl` (`:40`), `WiFiInfo` (`:43`), `OrganizerContactEmail` (`:46`), `SponsorshipPacketUrl` (`:49`), and `TicketingUrl` (`:52`). Every setter is `init`, so once the model binder has produced the instance no handler in the pipeline can mutate it. +- **Why it's built this way**: collapsing "request DTO" and "command" into one type removes a translation step that would carry no information, and it is what lets a whole CRUD endpoint be inherited rather than written (see the base controller in Group 12). Declaring the cache prefix on the message instead of inside the handler means the framework decorator, not the module, owns eviction. +- **Where it's used**: bound by `EventsController.CreateAsync` (`MMCA.ADC.Conference.API/Controllers/EventsController.cs:257`), which overrides the inherited action only to add the explicit `[Idempotent]` declaration and a follow-up output-cache eviction (`EventsController.cs:254-262`); validated by [`EventCreateRequestValidator`](#eventcreaterequestvalidator); translated by [`EventCreateRequestMapper`](#eventcreaterequestmapper); handled by [`CreateEventHandler`](#createeventhandler). Its update-side counterpart is [`EventUpdateRequest`](#eventupdaterequest). +- **Caveats / not-in-source**: the `Id` property (`EventCreateRequest.cs:16`) is accepted but never applied. [`Event`](group-17-conference-domain.md#event) carries `[IdValueGenerated]` (`MMCA.ADC.Conference.Domain/Events/Event.cs:22`), so the factory assigns `Id = isIdValueGenerated ? default : id!.Value` (`Event.cs:187,203`) and the caller-supplied value is discarded for this aggregate. The record is also `record class`, not `sealed record`, unlike every command in this unit; nothing in source derives from it and no comment explains the difference. -### PublishEventHandler +### PublishEventCommand -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.Publish` · `MMCA.ADC.Conference.Application/Events/UseCases/Publish/PublishEventHandler.cs:13` · Level 9 · class (sealed partial) +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.Publish` · `MMCA.ADC.Conference.Application/Events/UseCases/Publish/PublishEventCommand.cs:12` · Level 8 · record (sealed) -- **What it is**: the handler for [`PublishEventCommand`](#publisheventcommand). It loads the event, stamps the client's concurrency token, asks the aggregate to publish itself, and saves only if the aggregate agreed. -- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`PublishEventHandler.cs:3,14`), `ILogger` with a source-generated `[LoggerMessage]` (`PublishEventHandler.cs:1,15,41-42`), [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) (`PublishEventHandler.cs:4,15`), the [`Event`](group-17-conference-domain.md#event) aggregate (`PublishEventHandler.cs:2`), and [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error) (`PublishEventHandler.cs:5`). -- **Concept introduced, applying an optimistic-concurrency token to a loaded entity.** The interesting line is `repository.SetOriginalRowVersion(entity, command.RowVersion)` (`PublishEventHandler.cs:29`). EF Core decides whether an `UPDATE` succeeded by comparing the row's *original* tracked `rowversion` against the database. Normally the original value is whatever the row had when it was just loaded, which is by definition current, so a concurrency check would always pass. This call overwrites the tracked original with the token the *client* last saw ([`IRepository`](group-07-persistence-ef-core.md#irepositorytentity-tidentifiertype) declares it at `MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:197`), so a decision made against a stale view fails the save and surfaces as 409 Conflict rather than silently overwriting a concurrent edit. Passing `null` is the documented way to skip the check (`PublishEventHandler.cs:27-29`). This is [ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html) in three lines. `[Rubric §8, Data Architecture]` assesses concurrency and consistency handling: the check is in the database, not in an application-side read-then-compare that could itself race. `[Rubric §13, Observability & Operability]`: the source-generated log message means the transition is recorded with structured fields at zero allocation when the level is disabled. -- **Walkthrough**: primary-constructor injection of `IUnitOfWork` and `ILogger` (`PublishEventHandler.cs:13-15`), and the class implements `ICommandHandler`. `HandleAsync` (`:18-39`) gets the typed repository from the unit of work (`:22`), loads by id with the plain include-free overload (`:23`), and returns `Error.NotFound.WithSource(nameof(PublishEventHandler)).WithTarget(nameof(Event))` when the row is absent (`:24-25`). Note there is no `includes` array and no `asTracking: true` here: a publish touches a scalar on the root only, unlike [`AddEventSpeakerHandler`](#addeventspeakerhandler) and [`AddRoomHandler`](#addroomhandler), which must load a child collection for the aggregate's duplicate checks. It then stamps the row version (`:29`), calls `entity.Publish()` (`:31`), and only on success awaits `unitOfWork.SaveChangesAsync(cancellationToken)` and logs `LogEventPublished` (`:32-37`). The aggregate's own [`Result`](group-01-result-error-handling.md#result) is returned unchanged (`:38`), so an "already published" invariant failure (`MMCA.ADC.Conference.Domain/Events/Event.cs:260-267`, code `Event.AlreadyPublished`) reaches the caller with its domain error code intact. `LogEventPublished` is declared `partial` with `[LoggerMessage(Level = LogLevel.Information, ...)]` (`:41-42`) and its body is generated at build time. -- **Why it's built this way**: the save-only-on-success shape is the module's canonical command body. It means a rejected transition writes nothing at all, so the single `SaveChangesAsync` stays the one boundary that stamps audit fields, captures domain events, and writes the outbox row for [`EventChanged`](group-17-conference-domain.md#eventchanged) raised inside `Event.Publish` (`Event.cs:271`). -- **Where it's used**: resolved by the events controller as `ICommandHandler` (`MMCA.ADC.Conference.API/Controllers/EventsController.cs:48`) and invoked at `:302`. Its inverse is [`UnpublishEventHandler`](#unpublisheventhandler). +- **What it is**: the intent to make an event visible to attendees. Two positional parameters: the event `Id` and an optional `RowVersion`, the concurrency token the client last saw (`PublishEventCommand.cs:12`). +- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`PublishEventCommand.cs:2,12`), the [`Event`](group-17-conference-domain.md#event) type for the cache prefix (`PublishEventCommand.cs:1,15`), the `EventIdentifierType` alias, and `byte[]` from the BCL for the token. +- **Concept introduced, the optional concurrency token on a state-transition command.** Publishing is a decision made against what the organizer had on screen. If someone else edited or unpublished the event in the meantime, the transition was decided against a stale view. Rather than re-read and compare, the command carries the client's last-seen `RowVersion` (`PublishEventCommand.cs:8-11,12`) and [`PublishEventHandler`](#publisheventhandler) stamps it back as the tracked entity's *original* value, so SQL Server's own `WHERE RowVersion = @original` clause decides ([ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html)). The parameter defaults to `null`, and the framework treats null or empty as "skip the check" (`MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:276-284`), so the safety is opt-in per call rather than a breaking contract change. `[Rubric §8, Data Architecture]` assesses how concurrent writes are reconciled: this is optimistic concurrency pushed to the database predicate, with no read-compare window in application code. `[Rubric §9, API & Contract Design]` assesses evolution: a defaulted trailing parameter lets older callers keep compiling and calling while new ones opt into conditional writes. +- **Walkthrough**: `public sealed record PublishEventCommand(EventIdentifierType Id, byte[]? RowVersion = null) : ICacheInvalidating` (`PublishEventCommand.cs:12`) plus the single computed `CachePrefix => $"{typeof(Event).FullName}:"` (`PublishEventCommand.cs:15`). No behavior lives here; the already-published rule is a domain invariant (`MMCA.ADC.Conference.Domain/Events/Event.cs:274-281`). +- **Why it's built this way**: modelling publish as its own command rather than as a field on an update request keeps the transition auditable as a distinct intent, gives it its own endpoint, validator surface and idempotency contract, and keeps the update path free of a boolean that would otherwise be settable by accident. +- **Where it's used**: constructed by `EventsController.PublishAsync` from the optional [`EventTransitionRequest`](group-17-conference-domain.md#eventtransitionrequest) body, `new PublishEventCommand(id, request?.RowVersion)` (`MMCA.ADC.Conference.API/Controllers/EventsController.cs:316`, handler injected at `EventsController.cs:49`). The endpoint is `POST {id}/publish` carrying [`IdempotentAttribute`](group-12-api-hosting-mapping.md#idempotentattribute) and [`SupportsIfMatchAttribute`](group-12-api-hosting-mapping.md#supportsifmatchattribute) and declaring both 409 and 412 (`EventsController.cs:305-309`). Handled by [`PublishEventHandler`](#publisheventhandler). -### CreateEventHandler +### RemoveEventQuestionAnswerCommand -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.Create` · `MMCA.ADC.Conference.Application/Events/UseCases/Create/CreateEventHandler.cs:16` · Level 10 · class (sealed partial) +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RemoveEventQuestionAnswer` · `MMCA.ADC.Conference.Application/Events/UseCases/RemoveEventQuestionAnswer/RemoveEventQuestionAnswerCommand.cs:9` · Level 8 · record (sealed) -- **What it is**: the handler that creates a conference [`Event`](group-17-conference-domain.md#event). It is the highest-level type in this unit because it composes four of the others: the request, the request mapper, the repository, and the read-side DTO mapper. -- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`CreateEventHandler.cs:6,17`), [`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype) satisfied by [`EventCreateRequestMapper`](#eventcreaterequestmapper) (`CreateEventHandler.cs:5,18`), [`EventDTOMapper`](#eventdtomapper) (`:2,19`), `ILogger` (`:1,20,42-43`), [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) (`:7,20`), the [`Event`](group-17-conference-domain.md#event) aggregate (`:3`), [`EventDTO`](group-17-conference-domain.md#eventdto) from the Shared project (`:4`), and [`Result`](group-01-result-error-handling.md#result) (`:8`). -- **Concept introduced, the generic create slice assembled end to end.** Read the four injected dependencies as a pipeline (`CreateEventHandler.cs:16-20`): the request mapper turns the wire contract into a validated entity, the unit of work supplies the typed repository and owns the transaction boundary, and the DTO mapper turns the persisted entity back into a wire contract. The handler itself contributes only sequencing and a log line, and there is no `new Event(...)` anywhere in it. Note also what is *absent*: no validator call (the pipeline's validation decorator already ran [`EventCreateRequestValidator`](#eventcreaterequestvalidator)), no cache eviction (the caching decorator reads `CachePrefix` off [`EventCreateRequest`](#eventcreaterequest)), and no `try`/`catch` (failures arrive as [`Result`](group-01-result-error-handling.md#result) values). That absence is the point of the decorator pipeline taught in [Group 05](group-05-cqrs-pipeline.md). `[Rubric §5, Vertical Slice]` assesses whether a use case is self-contained: the four Create types live in one folder and this handler is the slice's entry point. `[Rubric §3, Clean Architecture]`: the Application layer depends on abstractions (`IUnitOfWork`, `IEntityRequestMapper`) and on the Domain, never on EF Core or ASP.NET. `[Rubric §6, CQRS & Event-Driven]`: one command type, one handler, one write path, and the `EventChanged` domain event raised inside the factory (`MMCA.ADC.Conference.Domain/Events/Event.cs:196`) is captured by the same `SaveChangesAsync`. -- **Walkthrough**: primary-constructor injection of the four collaborators (`CreateEventHandler.cs:16-20`), declaring `ICommandHandler>`. `HandleAsync` (`:23-40`) awaits `requestMapper.CreateEntityAsync(command, cancellationToken)` (`:27`) and short-circuits on failure by re-wrapping the errors into the correct generic shape, `Result.Failure(result.Errors)` (`:28-29`), which is how a factory-level invariant failure becomes an API error without an exception. It then unwraps `result.Value!` (`:31`), gets `unitOfWork.GetRepository()` (`:32`), awaits `repository.AddAsync(entity, cancellationToken)` and then the single `unitOfWork.SaveChangesAsync(cancellationToken)` (`:34-35`), both with `.ConfigureAwait(false)`. After the save it emits the generated `LogEventCreated(logger, entity.Id, entity.Name)` (`:37`, declaration `:42-43`), which is placed after the save so the logged id is the store-generated key rather than a placeholder. It returns `Result.Success(dtoMapper.MapToDTO(entity))` (`:39`). -- **Why it's built this way**: the single `SaveChangesAsync` is the one place audit fields are stamped, domain events are captured, and outbox rows are written, so the handler deliberately owns exactly one call to it. Returning an [`EventDTO`](group-17-conference-domain.md#eventdto) rather than the entity keeps the domain type from crossing the API boundary, per [ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html). -- **Where it's used**: injected into the events controller as `ICommandHandler>` (`MMCA.ADC.Conference.API/Controllers/EventsController.cs:46`) and forwarded to [`AggregateRootEntityControllerBase`](group-12-api-hosting-mapping.md#aggregaterootentitycontrollerbasetentity-tentitydto-tidentifiertype-tcreaterequest), which owns the inherited create action (`EventsController.cs:57-58`). Its update and delete counterparts are [`UpdateEventHandler`](#updateeventhandler) and [`DeleteEventHandler`](#deleteeventhandler). +- **What it is**: the intent to remove one [`EventQuestionAnswer`](group-17-conference-domain.md#eventquestionanswer) from an [`Event`](group-17-conference-domain.md#event). Two positional parameters, the owning `EventId` and the `EventQuestionAnswerId` (`RemoveEventQuestionAnswerCommand.cs:9-11`). +- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`RemoveEventQuestionAnswerCommand.cs:2,11`), the [`Event`](group-17-conference-domain.md#event) type for the cache prefix (`RemoveEventQuestionAnswerCommand.cs:1,14`), and the `EventIdentifierType` / `EventQuestionAnswerIdentifierType` aliases. +- **Concept introduced, the remove-child command shape.** Every child removal in this module is the same two-identifier record: the aggregate root first, the child second, nothing else. Naming the root is not redundant. The write has to travel through the aggregate so the invariant checks and the `EventQuestionAnswerChanged` domain event fire, so the handler loads the [`Event`](group-17-conference-domain.md#event) and calls a method on it rather than deleting a row by id. The removal itself is a soft delete: the child's `Delete()` sets `IsDeleted = true` and fails with `Error.AlreadyDeleted` if it was already removed (`MMCA.Common.Domain/Entities/AuditableBaseEntity.cs:47-59`), which is [ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html) applied to a child entity. `[Rubric §4, Domain-Driven Design]` assesses whether children are mutated through their root: the command's shape makes that structurally unavoidable. `[Rubric §8, Data Architecture]` assesses deletion policy: rows are retired, not destroyed, and the EF global query filter hides them from every later read. +- **Walkthrough**: a `sealed record` with `EventId` and `EventQuestionAnswerId` (`RemoveEventQuestionAnswerCommand.cs:9-11`), plus `CachePrefix => $"{typeof(Event).FullName}:"` (`RemoveEventQuestionAnswerCommand.cs:14`), keyed on the root because a cached event read carries its answers with it. +- **Why it's built this way**: keeping the delete as an aggregate operation rather than a repository-level `ExecuteDelete` preserves domain events, audit stamping and soft-delete semantics, all three of which a bulk delete would bypass (`MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:298-300`). +- **Where it's used**: constructed by `EventQuestionAnswersController.DeleteAsync` as `new RemoveEventQuestionAnswerCommand(eventId, id)`, with the event id taken from the query string (`MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:218-225`, handler injected at `EventQuestionAnswersController.cs:61`). Handled by [`RemoveEventQuestionAnswerHandler`](#removeeventquestionanswerhandler), which adds the ownership rule the record does not express. +- **Caveats / not-in-source**: unlike [`PublishEventCommand`](#publisheventcommand), this command carries no `RowVersion`, so a child removal is not a conditional write; a concurrent edit of the same answer is last-write-wins. -### QuestionDTOMapper +### RemoveEventSpeakerCommand -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Questions.DTOs` · `MMCA.ADC.Conference.Application/Questions/DTOs/QuestionDTOMapper.cs:12` · Level 8 · class +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RemoveEventSpeaker` · `MMCA.ADC.Conference.Application/Events/UseCases/RemoveEventSpeaker/RemoveEventSpeakerCommand.cs:9` · Level 8 · record (sealed) -- **What it is**: the outbound mapper that turns a [`Question`](group-17-conference-domain.md#question) aggregate into the [`QuestionDTO`](group-17-conference-domain.md#questiondto) the API returns. Its single-entity method has no body: Mapperly generates it at compile time from the `[Mapper]` attribute (`:11`). -- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) closed over `Question`, `QuestionDTO`, and the `QuestionIdentifierType` alias (`:13`); the [`Question`](group-17-conference-domain.md#question) entity and the [`QuestionDTO`](group-17-conference-domain.md#questiondto) contract; `Riok.Mapperly.Abstractions` (NuGet, `:4`). -- **Concept reinforced, source-generated DTO mapping.** `[Rubric §2, Design Patterns]` assesses whether repetitive translation code is factored out rather than hand-written per property: the `partial` declaration at `:16` is the whole contribution, and the generator emits the property-by-property assignment into a companion `.g.cs`. `[Rubric §12, Performance & Scalability]` assesses the cost of that translation: because the body is generated, mapping is straight-line assignment with no reflection and no expression compilation on the hot read path. `[Rubric §3, Clean Architecture]` assesses direction: the domain type never leaves the Application layer, only the DTO does. The convention and its trade-offs are set out in [ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html); contrast this with the inbound `*RequestMapper` classes such as [`QuestionCreateRequestMapper`](#questioncreaterequestmapper), which are hand-written because they must call a factory and are allowed to fail. -- **Walkthrough** - - `[Mapper]` (`:11`) is the generator trigger; the class is `sealed partial` (`:12`) so the generated half can be merged in. - - `public partial QuestionDTO MapToDTO(Question entity)` (`:16`) is the generated member. Mapping is by name, and the pairs line up one for one: `QuestionText`, `QuestionEntity`, `QuestionType`, `Sort`, `IsRequired`, and `QuestionSource` on the entity (`MMCA.ADC.Conference.Domain/Questions/Question.cs:17-32`) against the same names on the DTO (`MMCA.ADC.Conference.Shared/Questions/QuestionDTO.cs:18-33`), plus the `Id` and `RowVersion` members the DTO inherits from `IBaseDTO` and `IConcurrencyAware` (`QuestionDTO.cs:12-15`). - - `MapToDTOs` (`:19-23`) is hand-written, not generated: it null-guards the input (`:21`) and projects with a collection expression over the generated single-item mapper, `[.. entityCollection.Select(MapToDTO)]` (`:22`). -- **Why it's built this way**: the three string members are non-nullable on the entity (`Question.cs:20`, `:23`, `:32`) and nullable on the DTO (`QuestionDTO.cs:21`, `:24`, `:33`), which is the usual direction for a read contract: the DTO tolerates more than the domain produces, so a contract change does not force a domain change. -- **Where it's used**: injected as a concrete type by [`CreateQuestionHandler`](#createquestionhandler) (`MMCA.ADC.Conference.Application/Questions/UseCases/Create/CreateQuestionHandler.cs:23`) and [`UpdateQuestionHandler`](#updatequestionhandler) (`MMCA.ADC.Conference.Application/Questions/UseCases/Update/UpdateQuestionHandler.cs:21`); resolved through its interface by [`EntityQueryService`](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype) (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:35`), which is registered for `Question` at `MMCA.ADC.Conference.Application/DependencyInjection.cs:72` and drives every read on [`QuestionsController`](group-20-conference-api-grpc.md#questionscontroller). The mapper itself is picked up by the module scan (`DependencyInjection.cs:112`). -- **Caveats / not-in-source**: the generated assignments are not readable in this file, only in build output, so a member added to the DTO without a matching entity member surfaces as a generator diagnostic at build time rather than as anything visible here. Note also that this mapper redacts nothing: unlike [`SpeakerDTOMapper`](#speakerdtomapper), which withholds an email from non-organizers, every question member is copied verbatim. +- **What it is**: the intent to unlink a speaker from an event. It targets the [`EventSpeaker`](group-17-conference-domain.md#eventspeaker) association row, not the speaker: the speaker aggregate is untouched. +- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`RemoveEventSpeakerCommand.cs:2,11`), the [`Event`](group-17-conference-domain.md#event) type for the cache prefix (`RemoveEventSpeakerCommand.cs:1,14`), and the `EventIdentifierType` / `EventSpeakerIdentifierType` aliases. +- **Concept**: nothing new; the remove-child shape taught by [`RemoveEventQuestionAnswerCommand`](#removeeventquestionanswercommand). What it demonstrates is why "link" entities are worth having: removing a speaker from an event is a soft delete of one join row, so the speaker keeps existing, keeps its own identity, and can be linked to another edition of the conference. `[Rubric §4, Domain-Driven Design]` assesses aggregate boundaries: the association belongs to the event, the speaker is its own aggregate, and this command can only reach the former. +- **Walkthrough**: `public sealed record RemoveEventSpeakerCommand(EventIdentifierType EventId, EventSpeakerIdentifierType EventSpeakerId) : ICacheInvalidating` (`RemoveEventSpeakerCommand.cs:9-11`) with `CachePrefix => $"{typeof(Event).FullName}:"` (`RemoveEventSpeakerCommand.cs:14`). +- **Where it's used**: constructed by `EventSpeakersController.DeleteAsync` as `new RemoveEventSpeakerCommand(eventId, id)` (`MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:239-246`, handler injected at `EventSpeakersController.cs:50`). Handled by [`RemoveEventSpeakerHandler`](#removeeventspeakerhandler). Its add-side counterpart is the command handled by [`AddEventSpeakerHandler`](#addeventspeakerhandler). -### RemoveEventQuestionAnswerCommand +### RemoveRoomCommand -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RemoveEventQuestionAnswer` · `MMCA.ADC.Conference.Application/Events/UseCases/RemoveEventQuestionAnswer/RemoveEventQuestionAnswerCommand.cs:9` · Level 8 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RemoveRoom` · `MMCA.ADC.Conference.Application/Events/UseCases/RemoveRoom/RemoveRoomCommand.cs:9` · Level 8 · record (sealed) -- **What it is**: the write message that detaches an answer from an event's question set. Two ids and nothing else: the owning `EventId` and the `EventQuestionAnswerId` to remove (`:9-11`). -- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`:11`); the [`Event`](group-17-conference-domain.md#event) aggregate, referenced only to build the cache prefix (`:14`); the `EventIdentifierType` and `EventQuestionAnswerIdentifierType` module aliases. -- **Concept reinforced, the child-mutation command that names its parent.** `[Rubric §6, CQRS & Event-Driven]` assesses whether a write is an explicit single-purpose message: it is, and it deliberately carries the aggregate root's id rather than the child's alone, because the handler must load the [`Event`](group-17-conference-domain.md#event) and let the root perform the removal. `[Rubric §10, Cross-Cutting]` assesses whether such concerns are declared rather than coded: `CachePrefix => $"{typeof(Event).FullName}:"` (`:14`) is what makes [`CachingCommandDecorator`](group-05-cqrs-pipeline.md#cachingcommanddecoratortcommand-tresult) drop every cached read keyed under the `Event` type after a successful removal (pipeline order in [ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). The join row has no cache namespace of its own, so the parent's whole cached surface is evicted, which is exactly what keeps a deleted answer out of an already-cached event read. The same prefix appears on every command in this family, including [`AddEventQuestionAnswerCommand`](#addeventquestionanswercommand). -- **Walkthrough**: a `sealed record` with a two-parameter positional constructor (`:9-11`) and one expression-bodied member, `CachePrefix` (`:13-14`). There is no [`ITransactional`](group-05-cqrs-pipeline.md#itransactional) marker: the write lands inside one aggregate and one `SaveChangesAsync`, with no cross-context event to keep atomic. -- **Why it's built this way**: records give value equality and immutability for free, and the marker interface moves eviction into the pipeline so the handler stays free of cache code. -- **Where it's used**: constructed by [`EventQuestionAnswersController`](group-20-conference-api-grpc.md#eventquestionanswerscontroller)'s delete action (`MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:217`); handled by [`RemoveEventQuestionAnswerHandler`](#removeeventquestionanswerhandler). +- **What it is**: the intent to remove a [`Room`](group-17-conference-domain.md#room) from an event: the owning `EventId` and the `RoomId` (`RemoveRoomCommand.cs:9-11`). +- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`RemoveRoomCommand.cs:2,11`), the [`Event`](group-17-conference-domain.md#event) type for the cache prefix (`RemoveRoomCommand.cs:1,14`), and the `EventIdentifierType` / `RoomIdentifierType` aliases. +- **Concept**: nothing new; the remove-child shape taught by [`RemoveEventQuestionAnswerCommand`](#removeeventquestionanswercommand). Rooms are the one child family whose identifiers can be externally assigned (Sessionize ids), which makes the soft delete matter more than usual: retiring the row rather than deleting it keeps a later refresh from colliding with a reused key. `[Rubric §16, Maintainability]` assesses uniformity: the third identical remove command in the same module is a sign the shape is a convention, so a reader who has understood one has understood all of them. +- **Walkthrough**: `public sealed record RemoveRoomCommand(EventIdentifierType EventId, RoomIdentifierType RoomId) : ICacheInvalidating` (`RemoveRoomCommand.cs:9-11`) with `CachePrefix => $"{typeof(Event).FullName}:"` (`RemoveRoomCommand.cs:14`). +- **Where it's used**: constructed by `RoomsController.DeleteAsync` as `new RemoveRoomCommand(eventId, id)` (`MMCA.ADC.Conference.API/Controllers/RoomsController.cs:311-318`, handler injected at `RoomsController.cs:96`). Handled by [`RemoveRoomHandler`](#removeroomhandler). Its add-side counterpart is handled by [`AddRoomHandler`](#addroomhandler). -### RemoveEventSpeakerCommand +### UnpublishEventCommand -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RemoveEventSpeaker` · `MMCA.ADC.Conference.Application/Events/UseCases/RemoveEventSpeaker/RemoveEventSpeakerCommand.cs:9` · Level 8 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.Unpublish` · `MMCA.ADC.Conference.Application/Events/UseCases/Unpublish/UnpublishEventCommand.cs:12` · Level 8 · record (sealed) -- **What it is**: the command that removes a speaker's association with an event. It names the association join row, not the speaker: `EventSpeakerId` (`:11`). The speaker profile itself is untouched. -- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating); [`Event`](group-17-conference-domain.md#event) (cache prefix only); the `EventIdentifierType` and `EventSpeakerIdentifierType` aliases. -- **Concept reinforced**: none new, see [`RemoveEventQuestionAnswerCommand`](#removeeventquestionanswercommand). Same two-id shape, same parent-scoped `CachePrefix` (`:14`). `[Rubric §6, CQRS & Event-Driven]`. -- **Walkthrough**: a `sealed record` with two positional parameters (`:9-11`) and the single `CachePrefix` member (`:13-14`). Removing the association by its own id rather than by `(EventId, SpeakerId)` is what lets the domain treat the join row as a first-class child with its own soft-delete state (see [`EventSpeaker`](group-17-conference-domain.md#eventspeaker)). -- **Where it's used**: constructed by [`EventSpeakersController`](group-20-conference-api-grpc.md#eventspeakerscontroller)'s delete action (`MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:238`); handled by [`RemoveEventSpeakerHandler`](#removeeventspeakerhandler). +- **What it is**: the mirror of [`PublishEventCommand`](#publisheventcommand): the intent to hide an event from attendees again, with the same optional concurrency token (`UnpublishEventCommand.cs:12`). +- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`UnpublishEventCommand.cs:2,12`), the [`Event`](group-17-conference-domain.md#event) type for the cache prefix (`UnpublishEventCommand.cs:1,15`), and the `EventIdentifierType` alias. +- **Concept**: nothing new; the optional-`RowVersion` transition command taught by [`PublishEventCommand`](#publisheventcommand). The two records are byte-for-byte equivalent apart from their names, which is deliberate: each transition gets its own type so the pipeline decorators, the idempotency store and the logs can tell them apart without inspecting a payload field. `[Rubric §6, CQRS & Event-Driven]` assesses whether writes are modelled as named intents: "unpublish" is a type, not a `IsPublished = false` mutation. +- **Walkthrough**: `public sealed record UnpublishEventCommand(EventIdentifierType Id, byte[]? RowVersion = null) : ICacheInvalidating` (`UnpublishEventCommand.cs:12`) with `CachePrefix => $"{typeof(Event).FullName}:"` (`UnpublishEventCommand.cs:15`). +- **Where it's used**: constructed by `EventsController.UnpublishAsync` as `new UnpublishEventCommand(id, request?.RowVersion)` (`MMCA.ADC.Conference.API/Controllers/EventsController.cs:347`, handler injected at `EventsController.cs:50`), on the `POST {id}/unpublish` endpoint carrying the same `[Idempotent]` and `[SupportsIfMatch]` pair and the same 409/412 declarations (`EventsController.cs:336-340`). Handled by [`UnpublishEventHandler`](#unpublisheventhandler). -### RemoveRoomCommand +### UpdateEventQuestionAnswerCommand -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RemoveRoom` · `MMCA.ADC.Conference.Application/Events/UseCases/RemoveRoom/RemoveRoomCommand.cs:9` · Level 8 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.UpdateEventQuestionAnswer` · `MMCA.ADC.Conference.Application/Events/UseCases/UpdateEventQuestionAnswer/UpdateEventQuestionAnswerCommand.cs:10` · Level 8 · record (sealed) -- **What it is**: the command that removes a room from an event. Rooms are children of [`Event`](group-17-conference-domain.md#event), not standalone aggregates, so the message carries both ids (`:9-11`). -- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating); [`Event`](group-17-conference-domain.md#event) (cache prefix only); the `EventIdentifierType` and `RoomIdentifierType` aliases. -- **Concept reinforced**: none new, see [`RemoveEventQuestionAnswerCommand`](#removeeventquestionanswercommand); the `CachePrefix` is identical (`:14`) and the record is the same two-id shape. Compare [`AddRoomCommand`](#addroomcommand), which carries the full room payload for the same aggregate. -- **Walkthrough**: a `sealed record` with two positional parameters (`:9-11`) and one member (`:13-14`). -- **Where it's used**: constructed by [`RoomsController`](group-20-conference-api-grpc.md#roomscontroller)'s delete action (`MMCA.ADC.Conference.API/Controllers/RoomsController.cs:205`); handled by [`RemoveRoomHandler`](#removeroomhandler). -- **Caveats / not-in-source**: a removal is a soft delete, not a row deletion (the domain calls the child's `Delete()`, `MMCA.ADC.Conference.Domain/Events/Event.cs:489`). Nothing on the command says so, and a caller reading only this record would not know the room can come back through `Event.RestoreRoom` (`Event.cs:439-475`). +- **What it is**: the intent to change the text of an existing answer: the owning `EventId`, the `EventQuestionAnswerId`, and the new `AnswerValue` (`UpdateEventQuestionAnswerCommand.cs:10-13`). +- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`UpdateEventQuestionAnswerCommand.cs:2,13`), the [`Event`](group-17-conference-domain.md#event) type for the cache prefix (`UpdateEventQuestionAnswerCommand.cs:1,16`), and the `EventIdentifierType` / `EventQuestionAnswerIdentifierType` aliases. +- **Concept**: the remove-child shape plus one payload field. The detail worth noticing is what the record does *not* carry: no author, no timestamp. Ownership is decided server-side by [`UpdateEventQuestionAnswerHandler`](#updateeventquestionanswerhandler) from [`ICurrentUserService`](group-08-auth.md#icurrentuserservice), so a caller cannot claim to be editing on someone else's behalf by shaping the payload. `[Rubric §11, Security]` assesses whether identity is ambient rather than client-supplied: the absence of an owner field is the enforcement. +- **Walkthrough**: `public sealed record UpdateEventQuestionAnswerCommand(EventIdentifierType EventId, EventQuestionAnswerIdentifierType EventQuestionAnswerId, string AnswerValue) : ICacheInvalidating` (`UpdateEventQuestionAnswerCommand.cs:10-13`) with `CachePrefix => $"{typeof(Event).FullName}:"` (`UpdateEventQuestionAnswerCommand.cs:16`). The length and emptiness rules for `AnswerValue` are enforced deeper, by `EventInvariants.EnsureAnswerValueIsValid` inside `EventQuestionAnswer.UpdateAnswer` (`MMCA.ADC.Conference.Domain/Events/EventQuestionAnswer.cs:71-80`). +- **Where it's used**: constructed by `EventQuestionAnswersController.UpdateAsync` as `new UpdateEventQuestionAnswerCommand(request.EventId, id, request.AnswerValue)`, taking the event id from the body and the answer id from the route (`MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:202-209`, handler injected at `EventQuestionAnswersController.cs:60`). Handled by [`UpdateEventQuestionAnswerHandler`](#updateeventquestionanswerhandler). -### UnpublishEventCommand +### EventCreateRequestMapper -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.Unpublish` · `MMCA.ADC.Conference.Application/Events/UseCases/Unpublish/UnpublishEventCommand.cs:12` · Level 8 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.Create` · `MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequestMapper.cs:11` · Level 9 · class (sealed) -- **What it is**: the state-transition command that hides a published event from attendees again. It is the inverse of [`PublishEventCommand`](#publisheventcommand) and carries the same optional concurrency token. -- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating); [`Event`](group-17-conference-domain.md#event) (cache prefix); the `EventIdentifierType` alias; `byte[]` for the rowversion. -- **Concept introduced, the client-supplied concurrency token on a transition command.** `[Rubric §8, Data Architecture]` assesses whether concurrent writers can silently overwrite each other. A publish/unpublish toggle is the classic lost-update shape: two organizers both load the event, both see "published", and both press unpublish. `RowVersion` (`:12`) is the client's last-seen SQL Server rowversion, echoed back so the save can fail with a conflict instead of blindly applying a decision made against a stale view ([ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html)). The parameter is **optional and defaults to `null`** (`:12`), and the XML comment states what null means (`:8-11`): skip the stale-view check. That makes the check opt-in per caller rather than mandatory, which is a deliberate trade-off worth noticing: a client that never sends the token gets last-write-wins. `[Rubric §9, API & Contract Design]` reaches the same point from the wire side, where [`EventsController`](group-20-conference-api-grpc.md#eventscontroller) binds the body with `EmptyBodyBehavior.Allow` so an omitted body is legal (`MMCA.ADC.Conference.API/Controllers/EventsController.cs:320`). -- **Walkthrough**: a one-line `sealed record` (`:12`) with two positional parameters, the second defaulted, and the single `CachePrefix` member (`:14-15`) that evicts the cached `Event` reads on success like every other command in this family. -- **Why it's built this way**: keeping the token on the command rather than inferring it server-side means the check is about what the **caller** saw. The handler stamps it as the original value before the transition (see [`UnpublishEventHandler`](#unpublisheventhandler)), so EF's own concurrency machinery does the enforcement and no bespoke compare-and-swap code is needed. -- **Where it's used**: constructed by [`EventsController`](group-20-conference-api-grpc.md#eventscontroller)'s `POST /Events/{id}/unpublish` action (`EventsController.cs:324`), which declares `409 Conflict` as a documented response (`:317`) and evicts the events output cache afterwards (`:330`); handled by [`UnpublishEventHandler`](#unpublisheventhandler). +- **What it is**: the one-method collaborator that turns an [`EventCreateRequest`](#eventcreaterequest) into an [`Event`](group-17-conference-domain.md#event) by calling the aggregate's factory method. It performs no validation and constructs nothing itself. +- **Depends on**: [`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype) from `MMCA.Common.Application.Interfaces` (`EventCreateRequestMapper.cs:2,12`), the [`Event`](group-17-conference-domain.md#event) aggregate and its `Create` factory (`EventCreateRequestMapper.cs:1,19`), [`Result`](group-01-result-error-handling.md#result) from `MMCA.Common.Shared.Abstractions` (`EventCreateRequestMapper.cs:3,15`), and the `EventIdentifierType` alias. +- **Concept introduced, request-to-entity translation as its own injectable step.** The framework's create pipeline is deliberately split in three: a request record, a mapper that produces the entity, and a handler that persists it. The mapper is the only place that knows the factory's parameter order, so if `Event.Create` grows a parameter exactly one application-layer file changes. Because the interface returns `Task>`, an implementation that needs a lookup (another repository, an external call) can be genuinely asynchronous; this one has nothing to await, so it wraps the synchronous factory in `Task.FromResult` rather than declaring `async` and forcing a state machine (`EventCreateRequestMapper.cs:19`). `[Rubric §1, SOLID]` assesses single responsibility and dependency inversion: [`CreateEventHandler`](#createeventhandler) depends on the interface, never on this class, so a module can swap translation without touching the handler. `[Rubric §4, Domain-Driven Design]` assesses whether entities can be created in an invalid state: the mapper cannot call a constructor, only the factory, and the factory returns [`Result`](group-01-result-error-handling.md#result) rather than throwing. `[Rubric §14, Testability]` assesses isolation: the class has no dependencies at all, so a test constructs it directly. +- **Walkthrough**: `public sealed class EventCreateRequestMapper : IEntityRequestMapper` (`EventCreateRequestMapper.cs:11-12`). The single method `CreateEntityAsync` (`EventCreateRequestMapper.cs:15`) null-guards with `ArgumentNullException.ThrowIfNull(request)` (`:17`), then returns `Task.FromResult(Event.Create(...))` passing thirteen values positionally through `WiFiInfo` and the last three by name, `organizerContactEmail:`, `sponsorshipPacketUrl:` and `ticketingUrl:` (`:19-32`). Naming the trailing three is not cosmetic: it steps over the factory's `questionModerationDefault` parameter, which sits between them in the signature (`MMCA.ADC.Conference.Domain/Events/Event.cs:175-178`). Inside the factory, three invariants run through `Result.Combine` before any object exists (`Event.cs:180-185`), and a `EventChanged(DomainEntityState.Added, ...)` domain event is attached to the new aggregate (`Event.cs:207`) so the outbox picks it up in the same transaction as the insert ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)). +- **Why it's built this way**: the failure mode this design removes is an application layer that news up entities. Because the only path from a request to an [`Event`](group-17-conference-domain.md#event) runs through `Event.Create`, the name, time zone and date-range invariants cannot be skipped even by a caller that bypassed the validator. +- **Where it's used**: injected into [`CreateEventHandler`](#createeventhandler) as `IEntityRequestMapper` (`MMCA.ADC.Conference.Application/Events/UseCases/Create/CreateEventHandler.cs:18`). Registration is by convention: the module calls `services.ScanModuleApplicationServices()` (`MMCA.ADC.Conference.Application/DependencyInjection.cs:125`), which registers every `IEntityRequestMapper<,,>` implementation as itself and as its interfaces with a scoped lifetime (`MMCA.Common.Application/DependencyInjection.cs:172-176`). +- **Caveats / not-in-source**: the mapper never supplies `questionModerationDefault`, so every event created through this path takes the factory default `QuestionModerationDefault.Pending` (`MMCA.ADC.Conference.Domain/Events/Event.cs:175`). [`EventCreateRequest`](#eventcreaterequest) has no field for it, so the moderation default cannot be chosen at creation time through the API. -### UpdateEventQuestionAnswerCommand +### EventCreateRequestValidator -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.UpdateEventQuestionAnswer` · `MMCA.ADC.Conference.Application/Events/UseCases/UpdateEventQuestionAnswer/UpdateEventQuestionAnswerCommand.cs:10` · Level 8 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.Create` · `MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequestValidator.cs:7` · Level 9 · class (sealed) -- **What it is**: the command that edits the text of an existing answer on an event. Two ids to locate the child plus the new `AnswerValue` (`:10-13`). -- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating); [`Event`](group-17-conference-domain.md#event) (cache prefix); the `EventIdentifierType` and `EventQuestionAnswerIdentifierType` aliases. -- **Concept reinforced**: the parent-scoped `CachePrefix` (`:16`) introduced at [`RemoveEventQuestionAnswerCommand`](#removeeventquestionanswercommand). `[Rubric §6, CQRS & Event-Driven]`: the update carries the whole new value rather than a patch document, so the message is idempotent by construction. Applying it twice leaves the same text. -- **Walkthrough**: a `sealed record` with three positional parameters (`:10-13`) and one member (`:15-16`). `AnswerValue` is a plain non-nullable `string` with no length attribute on it: the ceiling lives in the domain, enforced when [`Event`](group-17-conference-domain.md#event) forwards to the child's `UpdateAnswer` (`MMCA.ADC.Conference.Domain/Events/Event.cs:641`). -- **Caveats / not-in-source**: there is no `UpdateEventQuestionAnswerCommandValidator` in this use-case folder, so nothing rejects an empty answer before the transaction opens; the failure comes back from the domain instead (compare [`UpdateRoomCommandValidator`](#updateroomcommandvalidator), which does front-load its checks). -- **Where it's used**: constructed by [`EventQuestionAnswersController`](group-20-conference-api-grpc.md#eventquestionanswerscontroller)'s update action from an `UpdateEventQuestionAnswerRequest` body (`MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:201`); handled by [`UpdateEventQuestionAnswerHandler`](#updateeventquestionanswerhandler). +- **What it is**: the FluentValidation validator for [`EventCreateRequest`](#eventcreaterequest). It contains no rule of its own: six `Include` calls assemble it from reusable per-field rule sets. +- **Depends on**: `AbstractValidator` from FluentValidation (NuGet, `EventCreateRequestValidator.cs:1,7`) and the module's own rule objects from `Events.Validation` (`EventCreateRequestValidator.cs:2`): [`EventNameRules`](#eventnamerulest), [`EventTimeZoneRules`](#eventtimezonerulest), [`EventDateRangeRules`](#eventdaterangerulest), [`EventOrganizerContactEmailRules`](#eventorganizercontactemailrulest), [`EventSponsorshipPacketUrlRules`](#eventsponsorshippacketurlrulest) and [`EventTicketingUrlRules`](#eventticketingurlrulest). +- **Concept introduced, the validator as pure composition.** FluentValidation's `Include` merges another validator's rules into this one, and because each rule set is generic over the request type they can be reused verbatim by the update-side validator against a different record. That is why the rules live in `Events/Validation` rather than in the use-case folder: the create and update slices share one definition of "a valid event name". Two of the includes are worth reading closely. `Include(new EventDateRangeRules(p => p.StartDate, p => p.EndDate))` (`EventCreateRequestValidator.cs:13`) takes two selectors because the rule is cross-field: it compiles the start-date selector and compares (`MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:122-125`), failing with the code `Event.EndDate.BeforeStart`. And `Include(new EventOrganizerContactEmailRules(p => p.OrganizerContactEmail!))` (`EventCreateRequestValidator.cs:14`) carries a null-forgiving `!` because the property is `string?` while the rule takes `Expression>` (`EventValidationRules.cs:60`); the `!` is safe because the rule body only applies the shared [`EmailRules`](group-06-validation.md#emailrulest) inside a `When(x => !string.IsNullOrWhiteSpace(accessor(x)), ...)` guard (`EventValidationRules.cs:64-65`), so a null value is never routed into the inner rules. `[Rubric §24, Forms, Validation & UX Safety]` assesses whether bad input is rejected at the boundary with actionable messages: the included rules attach stable dotted error codes such as `Event.TimeZone.InvalidIana` (`EventValidationRules.cs:32`) that a client can branch on. `[Rubric §15, Best Practices & Code Quality]` assesses duplication: this validator is six lines because the rules are objects. +- **Walkthrough**: `public sealed class EventCreateRequestValidator : AbstractValidator` (`EventCreateRequestValidator.cs:7`) with a parameterless constructor (`:9`) whose whole body is the six includes: name (`:11`), time zone (`:12`), date range (`:13`), organizer contact email (`:14`), sponsorship packet URL (`:15`) and ticketing URL (`:16`). Only two fields are unconditionally required here, name through [`RequiredStringRules`](group-06-validation.md#requiredstringrulest) (`EventValidationRules.cs:13-17`) and time zone (`EventValidationRules.cs:28-32`); the last three includes each wrap their rules in a `When` guard so an omitted optional URL or email passes. +- **Why it's built this way**: the time-zone rule is the clearest argument for this layout. It does not just check length, it calls `TimeZoneInfo.FindSystemTimeZoneById` and treats a `TimeZoneNotFoundException` as invalid (`EventValidationRules.cs:34-48`), which is real logic that no one wants written twice. Composing it means the create and update paths cannot drift. +- **Where it's used**: resolved and executed by the validation step of the CQRS pipeline before [`CreateEventHandler`](#createeventhandler) runs. Registration is again by convention: `ScanModuleApplicationServices` ends with `services.AddValidatorsFromAssemblyContaining()` (`MMCA.Common.Application/DependencyInjection.cs:190`), called for this module at `MMCA.ADC.Conference.Application/DependencyInjection.cs:125`. Covered by [`EventCreateRequestValidatorTests`](group-27-testing-infrastructure.md#eventcreaterequestvalidatortests). +- **Caveats / not-in-source**: five request fields have no rule at all: `Description`, `SessionizeCode`, `VenueAddress`, `VenueMapUrl` and `WiFiInfo` (`EventCreateRequest.cs:22,34,37,40,43`). Their length limits are enforced only by the EF column configuration and the domain invariants, not at the boundary, and nothing in source states whether that is deliberate. -### UpdateRoomCommand +### PublishEventHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.UpdateRoom` · `MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomCommand.cs:15` · Level 8 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.Publish` · `MMCA.ADC.Conference.Application/Events/UseCases/Publish/PublishEventHandler.cs:13` · Level 9 · class (sealed partial) -- **What it is**: the full-replacement update for one room inside an event. It is the widest command in this unit: two ids plus the six room fields (`:15-23`). -- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating); [`Event`](group-17-conference-domain.md#event) (cache prefix); the `EventIdentifierType` and `RoomIdentifierType` aliases. -- **Concept reinforced, the whole-object update command.** `[Rubric §9, API & Contract Design]` assesses whether a mutation contract is unambiguous: every optional field is declared nullable (`Capacity`, `Floor`, `Location`, `AccessibilityInfo`, `:20-23`) and is passed through to the domain as sent, so omitting one clears it rather than leaving it alone. That is the defining property of a PUT-shaped command, and it is why [`RoomsController`](group-20-conference-api-grpc.md#roomscontroller) binds it from a complete `UpdateRoomRequest` body (`MMCA.ADC.Conference.API/Controllers/RoomsController.cs:175`). `[Rubric §21, Accessibility]` is touched, though only at the data level: `AccessibilityInfo` (`:23`) is the field that carries a room's accessibility notes to attendees, so the write path preserves that information as a first-class member rather than folding it into a free-text description. -- **Walkthrough**: a `sealed record` with eight positional parameters (`:15-23`), each documented individually (`:6-14`), and the single `CachePrefix` member (`:25-26`). Note what is absent: no `RowVersion`, so unlike [`UnpublishEventCommand`](#unpublisheventcommand) a room edit is last-write-wins. -- **Where it's used**: constructed by [`RoomsController`](group-20-conference-api-grpc.md#roomscontroller)'s `PUT /Rooms/{id}` action (`RoomsController.cs:179-187`), which evicts the rooms output cache on success (`:193`); validated by [`UpdateRoomCommandValidator`](#updateroomcommandvalidator); handled by [`UpdateRoomHandler`](#updateroomhandler). +- **What it is**: the handler for [`PublishEventCommand`](#publisheventcommand). It loads the event, applies the caller's concurrency token, asks the aggregate to publish itself, and saves. +- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`PublishEventHandler.cs:14`), `ILogger` (`PublishEventHandler.cs:15`), the [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) contract returning a non-generic [`Result`](group-01-result-error-handling.md#result) (`PublishEventHandler.cs:15`), the [`Event`](group-17-conference-domain.md#event) aggregate, and [`Error`](group-01-result-error-handling.md#error). +- **Concept introduced, stamping a client token as the tracked entity's original value.** This is the application-side half of [ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html) and it is three lines long. After loading the tracked aggregate, the handler calls `repository.SetOriginalRowVersion(entity, command.RowVersion)` (`PublishEventHandler.cs:29`) with the explanation inline (`:27-28`). The repository writes that value into EF's change tracker as the `RowVersion` property's original value (`MMCA.Common.Infrastructure/Persistence/Repositories/EFRepository.cs:75-83`), so the UPDATE that EF emits carries `WHERE RowVersion = @clientToken`. If someone else touched the row, zero rows match, EF raises `DbUpdateConcurrencyException`, and the global handler maps it to 409 Conflict (`MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:276-284`); on an endpoint decorated with [`SupportsIfMatchAttribute`](group-12-api-hosting-mapping.md#supportsifmatchattribute) the same outcome is rewritten to 412 Precondition Failed instead, because under an explicit `If-Match` header a conflict is a failed precondition (`MMCA.Common.API/Concurrency/SupportsIfMatchAttribute.cs:178-186`). The guard that makes the whole thing opt-in lives in the repository: a null or empty token returns immediately and the check is simply not applied (`EFRepository.cs:78-79`). `[Rubric §8, Data Architecture]` assesses concurrency control: the arbitration is a database predicate, not an application-level compare. `[Rubric §12, Performance & Scalability]` assesses contention: optimistic concurrency takes no locks, so simultaneous readers are never blocked and only the losing writer pays. `[Rubric §13, Observability & Operability]` assesses diagnostics: the success path logs through a `[LoggerMessage]` source-generated method (`PublishEventHandler.cs:41-42`), which is allocation-free and emits `EventId` as a structured field. +- **Walkthrough**: `HandleAsync` (`PublishEventHandler.cs:18-39`) resolves the repository with `unitOfWork.GetRepository()` (`:22`), then loads with the two-argument `GetByIdAsync(command.Id, cancellationToken)` (`:23`). That overload is deliberately tracked (`MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:170-181`), which matters twice here: the mutation must persist, and `SetOriginalRowVersion` can only reach an entity EF is tracking. A missing event returns `Error.NotFound.WithSource(nameof(PublishEventHandler)).WithTarget(nameof(Event))` (`:24-25`). Then the token is stamped (`:29`) and the decision is delegated: `entity.Publish()` (`:31`) fails with the invariant `Event.AlreadyPublished` when the flag is already set and otherwise sets `IsPublished = true` and raises `EventChanged(DomainEntityState.Updated, ...)` (`MMCA.ADC.Conference.Domain/Events/Event.cs:272-288`). Only on success does the handler save and log (`:33-36`), and the aggregate's own [`Result`](group-01-result-error-handling.md#result) is returned verbatim (`:38`). +- **Why it's built this way**: no include list is requested because the transition touches only the root's own flag, so loading the children would be wasted I/O. Saving inside the `IsSuccess` branch means a rejected transition never opens a write, and returning the domain result unchanged preserves the `Event.AlreadyPublished` code all the way to the HTTP response instead of flattening it into a generic 400. +- **Where it's used**: dispatched by `EventsController.PublishAsync` (`MMCA.ADC.Conference.API/Controllers/EventsController.cs:49,315-317`), which converts a failure through `HandleFailure` and otherwise evicts the events output cache and returns 204 (`EventsController.cs:319-323`). Covered by [`PublishEventHandlerTests`](group-27-testing-infrastructure.md#publisheventhandlertests). +- **Caveats / not-in-source**: `ConfigureAwait(false)` appears on the save (`:34`) but not on the load (`:23`). That is not a defect here: [ADR-049](https://ivanball.github.io/docs/adr/049-library-configureawait-policy.html) scopes the CA2007 gate to packaged framework code and explicitly leaves it off in the application repos, ADC included, so both forms are permitted in this file. ### RemoveEventQuestionAnswerHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RemoveEventQuestionAnswer` · `MMCA.ADC.Conference.Application/Events/UseCases/RemoveEventQuestionAnswer/RemoveEventQuestionAnswerHandler.cs:14` · Level 9 · class +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RemoveEventQuestionAnswer` · `MMCA.ADC.Conference.Application/Events/UseCases/RemoveEventQuestionAnswer/RemoveEventQuestionAnswerHandler.cs:14` · Level 9 · class (sealed partial) -- **What it is**: the handler for [`RemoveEventQuestionAnswerCommand`](#removeeventquestionanswercommand). It is the load-delegate-save template with one addition that the other removal handlers do not have: an ownership check (BR-52/BR-53, stated in the class comment at `:10-13`). -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork); [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) and [`RoleNames`](group-08-auth.md#rolenames); the [`Event`](group-17-conference-domain.md#event) aggregate and its [`EventQuestionAnswer`](group-17-conference-domain.md#eventquestionanswer) child; [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error); `Microsoft.Extensions.Logging`. -- **Concept introduced, per-row ownership enforced in the handler.** `[Rubric §11, Security]` assesses whether authorization is checked at the granularity the rule actually needs. A policy attribute on the controller can only answer "is this caller authenticated", not "does this caller own row 4711", so the row-level rule lives here: the answer is located in the loaded collection (`:34`), and the request is refused when the caller is **not** an Organizer and the answer's `CreatedBy` is not the caller's id (`:35`). `CreatedBy` is the audit field the framework stamps automatically on insert (`MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/AuditableBaseEntity.cs:27`), so ownership costs no extra column. The refusal is `Error.Forbidden` with the stable code `EventQuestionAnswer.NotOwner` (`:37-42`), which is what lets the API return 403 with a machine-readable reason rather than a bare status. -- **Walkthrough** - - Primary constructor (`:14-17`): [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`ICurrentUserService`](group-08-auth.md#icurrentuserservice), and a typed logger. The declared result is the non-generic [`Result`](group-01-result-error-handling.md#result), so no DTO mapper is involved. - - `HandleAsync` (`:20`) resolves the `Event` repository from the unit of work rather than injecting it (`:24`), then loads with the two load-bearing arguments: `includes: [nameof(Event.EventQuestionAnswers)]` puts the child in memory for the domain method to find, and `asTracking: true` is what makes the soft-delete flag survive `SaveChangesAsync` (`:25-29`). A missing event returns `Error.NotFound` decorated with source and target (`:30-31`). - - The ownership guard (`:34-42`) filters `!a.IsDeleted` when locating the answer (`:34`), so an already-removed row is not treated as someone's property. Note the guard is written as `answer is not null && ...`: when the id matches nothing, the guard falls through and the domain returns the not-found error instead, which keeps one shape of error for one condition. - - `entity.RemoveEventQuestionAnswer(command.EventQuestionAnswerId)` (`:44`) is the domain decision. The aggregate resolves the child or returns not-found, calls its `Delete()`, and raises `EventQuestionAnswerChanged` with `DomainEntityState.Deleted` (`MMCA.ADC.Conference.Domain/Events/Event.cs:655-669`). Reaching into the collection from the handler would skip that event. - - Only on success does it save and log (`:45-49`), through the generated `LogQuestionAnswerRemovedFromEvent` (`:54-55`); the domain `Result` is returned unchanged either way (`:51`). -- **Why it's built this way**: guarding `SaveChangesAsync` behind `IsSuccess` matters more than it looks. That single call is the boundary that stamps audit fields, dispatches domain events, and writes outbox rows, so a rejected removal publishes nothing. `[Rubric §13, Observability & Operability]`: the `[LoggerMessage]` source-generated log keeps the successful path structured and allocation-free. -- **Where it's used**: registered by the module scan (`MMCA.ADC.Conference.Application/DependencyInjection.cs:112`) and invoked through the decorator pipeline by [`EventQuestionAnswersController`](group-20-conference-api-grpc.md#eventquestionanswerscontroller) (`MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:216-218`). -- **Caveats / not-in-source**: `currentUserService.UserId!.Value` (`:35`) is dereferenced with `!`. The controller is `[Authorize(Policy = AuthorizationPolicies.RequireAuthenticated)]` (`EventQuestionAnswersController.cs:55`), so an anonymous caller cannot reach it through that route, but nothing in this file enforces the invariant. Also worth noting: that controller performs no output-cache eviction after a mutation (there is no `Evict` call in it), unlike [`RoomsController`](group-20-conference-api-grpc.md#roomscontroller) (`RoomsController.cs:211`); the command's `CachePrefix` covers the handler-level read cache only. +- **What it is**: the handler for [`RemoveEventQuestionAnswerCommand`](#removeeventquestionanswercommand). It is the remove-child skeleton plus one authorization rule: an attendee may delete only their own answer, an Organizer may delete any (BR-52/BR-53, `RemoveEventQuestionAnswerHandler.cs:11-12`). +- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`RemoveEventQuestionAnswerHandler.cs:15`), [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) (`:16`), `ILogger` (`:17`), the [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) contract (`:17`), [`RoleNames`](group-08-auth.md#rolenames) from `MMCA.Common.Shared.Auth` (`:6,35`), the [`Event`](group-17-conference-domain.md#event) aggregate with its [`EventQuestionAnswer`](group-17-conference-domain.md#eventquestionanswer) children, and [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error). +- **Concept introduced, row-level ownership enforced in the handler.** Role-based authorization at the endpoint answers "may this kind of user delete answers"; it cannot answer "may this user delete *this* answer". That second question needs the row, so it is asked here, after the aggregate is loaded and before the domain method is called. The predicate is a three-way test (`:35`): the answer exists, the caller is not in the `Organizer` role, and `answer.CreatedBy != currentUserService.UserId!.Value`. `CreatedBy` is not a field the client sends: it is stamped automatically by the audit pipeline when the row was written, and the current user is read from the ambient claims principal, so both sides of the comparison are server-owned. A failure returns `Error.Forbidden` with the code `EventQuestionAnswer.NotOwner` (`:37-42`), which is a distinct outcome from not-found and maps to 403 rather than 404. `[Rubric §11, Security]` assesses whether authorization is enforced at the resource, not only at the route: this is object-level authorization, the check that role attributes structurally cannot perform. `[Rubric §4, Domain-Driven Design]` assesses rule placement: ownership is an application-policy question about the caller, not an invariant of the aggregate, which is why it lives here while the "does this child exist" rule stays in the domain. +- **Walkthrough**: `HandleAsync` (`:20-52`) resolves the event repository (`:24`) and loads with `includes: [nameof(Event.EventQuestionAnswers)]` and `asTracking: true` (`:25-29`); both are load-bearing, since the ownership scan reads the child collection and the soft delete must be tracked to persist. A missing event returns `Error.NotFound` sourced to the handler (`:30-31`). The candidate answer is found in memory with `FirstOrDefault(a => a.Id == command.EventQuestionAnswerId && !a.IsDeleted)` (`:34`), the ownership gate runs (`:35-42`), and only then does the aggregate act: `entity.RemoveEventQuestionAnswer(command.EventQuestionAnswerId)` (`:44`) re-finds the child through the framework helper `GetChildOrNotFound`, which returns `Error.NotFound` targeted at the child type when it is absent or already soft-deleted (`MMCA.Common.Domain/Entities/AuditableAggregateRootEntity.cs:103-120`), calls `Delete()` on it (`MMCA.Common.Domain/Entities/AuditableBaseEntity.cs:47-59`), and raises `EventQuestionAnswerChanged(DomainEntityState.Deleted, ...)` (`MMCA.ADC.Conference.Domain/Events/Event.cs:684-700`). Success saves and logs both identifiers through the generated `LogQuestionAnswerRemovedFromEvent` (`:47-48`, declared `:54-55`). +- **Why it's built this way**: doing the ownership check against the already-loaded aggregate costs nothing extra, since the answers were included for the removal anyway. Returning `Error.Forbidden` rather than silently no-oping keeps the API honest about why the delete did not happen, and keeping the not-found decision inside the aggregate means the two failure modes cannot get out of sync with the soft-delete filter. +- **Where it's used**: dispatched by `EventQuestionAnswersController.DeleteAsync` (`MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:61,218-225`), on a controller gated by `[Authorize(Policy = AuthorizationPolicies.RequireAuthenticated)]` (`EventQuestionAnswersController.cs:56`). Covered by [`RemoveEventQuestionAnswerHandlerTests`](group-27-testing-infrastructure.md#removeeventquestionanswerhandlertests). +- **Caveats / not-in-source**: `currentUserService.UserId!.Value` (`:35`) is null-forgiven, so the handler assumes an authenticated caller and relies on the controller policy for that guarantee. Note also the branch order: when no matching answer is found the ownership gate is skipped entirely (`answer is not null` short-circuits at `:35`) and the request falls through to the aggregate, which answers `Error.NotFound`. A non-owner therefore receives 403 for an answer that exists and 404 for one that does not. ### RemoveEventSpeakerHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RemoveEventSpeaker` · `MMCA.ADC.Conference.Application/Events/UseCases/RemoveEventSpeaker/RemoveEventSpeakerHandler.cs:13` · Level 9 · class +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RemoveEventSpeaker` · `MMCA.ADC.Conference.Application/Events/UseCases/RemoveEventSpeaker/RemoveEventSpeakerHandler.cs:13` · Level 9 · class (sealed partial) -- **What it is**: the handler for [`RemoveEventSpeakerCommand`](#removeeventspeakercommand). It is the minimal form of the child-removal template: no ownership check, no concurrency token, five statements. -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork); the [`Event`](group-17-conference-domain.md#event) aggregate and its [`EventSpeaker`](group-17-conference-domain.md#eventspeaker) child; [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error); `Microsoft.Extensions.Logging`. -- **Concept reinforced, the include-plus-tracking pair a removal requires.** `[Rubric §4, DDD]` assesses whether children are mutated through the root: the handler never touches an [`EventSpeaker`](group-17-conference-domain.md#eventspeaker), it calls `entity.RemoveEventSpeaker(...)` (`:31`), and the aggregate resolves the child, deletes it, and raises `EventSpeakerChanged` (`MMCA.ADC.Conference.Domain/Events/Event.cs:578-592`). `[Rubric §8, Data Architecture]` covers the load arguments at `:23-27`, both required for the same reasons spelled out at [`RemoveEventQuestionAnswerHandler`](#removeeventquestionanswerhandler). -- **Walkthrough**: `sealed partial class` with a two-parameter primary constructor (`:13-15`). `HandleAsync` (`:18`) resolves the repository (`:22`), loads the event with `includes: [nameof(Event.EventSpeakers)]` and `asTracking: true` (`:23-27`), returns `Error.NotFound` when the event is missing (`:28-29`), delegates to the aggregate (`:31`), and on success saves with `ConfigureAwait(false)` and logs through the generated `LogSpeakerRemovedFromEvent` (`:32-36`, declared `:41-42`). The domain `Result` is returned unchanged (`:38`). -- **Why it's built this way**: the class comment (`:9-12`) states the whole design in one sentence, "loads the event aggregate with its speakers and delegates". Validation, cache eviction, and transaction scope are the pipeline's job ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)), declared by the markers on the command, which is why the handler can be this small. -- **Where it's used**: registered by the module scan (`MMCA.ADC.Conference.Application/DependencyInjection.cs:112`); invoked by [`EventSpeakersController`](group-20-conference-api-grpc.md#eventspeakerscontroller)'s delete action, which evicts both parents' output-cache entries afterwards (`MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:237-247`). +- **What it is**: the handler for [`RemoveEventSpeakerCommand`](#removeeventspeakercommand). It is the remove-child skeleton with nothing added: load the aggregate with its speakers, delegate, save, log. +- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`RemoveEventSpeakerHandler.cs:14`), `ILogger` (`:15`), the [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) contract (`:15`), the [`Event`](group-17-conference-domain.md#event) aggregate, and [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error). +- **Concept**: read this one as the minimal form of what [`RemoveEventQuestionAnswerHandler`](#removeeventquestionanswerhandler) decorates. There is no ownership rule because an event-speaker link has no per-user owner: the endpoint's role gate is the whole authorization story. What both share is the include-plus-tracking pair (`:23-27`): a child soft delete mutates an object inside the root's collection, so the collection has to be loaded and EF has to be tracking it, otherwise the aggregate scans an empty list and reports not-found for a child that exists. `[Rubric §5, Vertical Slice]` assesses self-containment: the command and its handler live in one folder and share no base class with any sibling slice. `[Rubric §1, SOLID]` assesses dependency direction: the handler depends on [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), an application abstraction, not on a DbContext. +- **Walkthrough**: `HandleAsync` (`:18-39`) resolves the repository (`:22`), loads by id with `includes: [nameof(Event.EventSpeakers)]` and `asTracking: true` (`:23-27`), returns `Error.NotFound` sourced to the handler and targeted at `Event` when absent (`:28-29`), then calls `entity.RemoveEventSpeaker(command.EventSpeakerId)` (`:31`). The aggregate resolves the child through `GetEventSpeakerOrNotFound` (`MMCA.ADC.Conference.Domain/Events/Event.cs:609`, helper at `Event.cs:740-743`), soft-deletes it and raises `EventSpeakerChanged(DomainEntityState.Deleted, ...)` (`Event.cs:607-623`). Success saves (`:34`) and logs the speaker and event ids through the generated `LogSpeakerRemovedFromEvent` (`:35`, declared `:41-42`); the domain result is returned as is (`:38`). +- **Why it's built this way**: the handler adds no error text of its own on the domain path, so the aggregate stays the single author of "why not". That is what makes the four remove handlers in this module readable as one pattern with local variations rather than four independent implementations. +- **Where it's used**: dispatched by `EventSpeakersController.DeleteAsync` (`MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:50,239-246`). Covered by [`RemoveEventSpeakerHandlerTests`](group-27-testing-infrastructure.md#removeeventspeakerhandlertests). ### RemoveRoomHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RemoveRoom` · `MMCA.ADC.Conference.Application/Events/UseCases/RemoveRoom/RemoveRoomHandler.cs:13` · Level 9 · class +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.RemoveRoom` · `MMCA.ADC.Conference.Application/Events/UseCases/RemoveRoom/RemoveRoomHandler.cs:13` · Level 9 · class (sealed partial) -- **What it is**: the handler for [`RemoveRoomCommand`](#removeroomcommand). Structurally identical to [`RemoveEventSpeakerHandler`](#removeeventspeakerhandler), line for line, with `Event.Rooms` swapped in for `Event.EventSpeakers`. -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork); the [`Event`](group-17-conference-domain.md#event) aggregate and its [`Room`](group-17-conference-domain.md#room) child; [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error); `Microsoft.Extensions.Logging`. -- **Concept reinforced**: see [`RemoveEventSpeakerHandler`](#removeeventspeakerhandler) for the include-plus-tracking pair and the delegate-to-the-root rule. `[Rubric §4, DDD]`, `[Rubric §8, Data Architecture]`. -- **Walkthrough**: primary constructor (`:13-15`); `HandleAsync` (`:18`) resolves the repository (`:22`), loads with `includes: [nameof(Event.Rooms)]` and `asTracking: true` (`:23-27`), fails with `Error.NotFound` when absent (`:28-29`), calls `entity.RemoveRoom(command.RoomId)` (`:31`), and saves plus logs only on success (`:32-36`, generated method at `:41-42`). The domain method soft-deletes the room and raises `RoomChanged` with `DomainEntityState.Deleted` (`MMCA.ADC.Conference.Domain/Events/Event.cs:482-496`). -- **Why it's built this way**: the repetition across the three removal handlers is deliberate rather than factored away. Each one is a vertical slice (`[Rubric §5, Vertical Slice]`), so a rule that later applies to only one of them (as the ownership rule already does on the question-answer path) can be added without touching the others. -- **Where it's used**: registered by the module scan (`MMCA.ADC.Conference.Application/DependencyInjection.cs:112`); invoked by [`RoomsController`](group-20-conference-api-grpc.md#roomscontroller)'s delete action (`MMCA.ADC.Conference.API/Controllers/RoomsController.cs:204-212`). +- **What it is**: the handler for [`RemoveRoomCommand`](#removeroomcommand). Structurally identical to [`RemoveEventSpeakerHandler`](#removeeventspeakerhandler), differing only in the include name, the aggregate method and the log message. +- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`RemoveRoomHandler.cs:14`), `ILogger` (`:15`), the [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) contract (`:15`), the [`Event`](group-17-conference-domain.md#event) aggregate with its [`Room`](group-17-conference-domain.md#room) children, and [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error). +- **Concept**: nothing new; the remove-child handler taught by [`RemoveEventSpeakerHandler`](#removeeventspeakerhandler). The one thing worth carrying away is what a soft delete means for a room specifically: sessions scheduled into it keep referencing a row that still exists, so history stays readable rather than turning into dangling identifiers ([ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html)). `[Rubric §8, Data Architecture]` assesses referential integrity under deletion: retiring the row keeps every prior reference resolvable. +- **Walkthrough**: `HandleAsync` (`:18-39`) resolves the repository (`:22`), loads with `includes: [nameof(Event.Rooms)]` and `asTracking: true` (`:23-27`), returns `Error.NotFound` for a missing event (`:28-29`), and calls `entity.RemoveRoom(command.RoomId)` (`:31`), which resolves the child through `GetRoomOrNotFound` (`MMCA.ADC.Conference.Domain/Events/Event.cs:513`, helper at `Event.cs:735-738`), calls `Delete()` and raises `RoomChanged(DomainEntityState.Deleted, ...)` (`Event.cs:511-525`). Success saves (`:34`) and logs both ids through `LogRoomRemoved` (`:35`, declared `:41-42`). +- **Where it's used**: dispatched by `RoomsController.DeleteAsync`, which takes the room id from the route and the event id from the query string (`MMCA.ADC.Conference.API/Controllers/RoomsController.cs:96,311-318`) and evicts the rooms output cache before returning 204 (`RoomsController.cs:325-326`). Covered by [`RemoveRoomHandlerTests`](group-27-testing-infrastructure.md#removeroomhandlertests). +- **Caveats / not-in-source**: the room's own name-uniqueness rule (`Event.Room.Duplicate`, `MMCA.ADC.Conference.Domain/Events/Event.cs:721-732`) excludes soft-deleted rooms from its scan, so a name freed by this handler becomes reusable immediately. ### UnpublishEventHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.Unpublish` · `MMCA.ADC.Conference.Application/Events/UseCases/Unpublish/UnpublishEventHandler.cs:13` · Level 9 · class +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.Unpublish` · `MMCA.ADC.Conference.Application/Events/UseCases/Unpublish/UnpublishEventHandler.cs:13` · Level 9 · class (sealed partial) -- **What it is**: the handler for [`UnpublishEventCommand`](#unpublisheventcommand). It loads the event, arms the optimistic-concurrency check, and delegates the state transition to the aggregate. -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) and the repository's `SetOriginalRowVersion` ([`IRepository`](group-07-persistence-ef-core.md#irepositorytentity-tidentifiertype), declared at `MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:197`); the [`Event`](group-17-conference-domain.md#event) aggregate; [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error); `Microsoft.Extensions.Logging`. -- **Concept introduced, arming optimistic concurrency by stamping the original value.** `[Rubric §8, Data Architecture]` assesses how concurrent writers are reconciled. EF Core decides a concurrency conflict by comparing the **original** value it holds for a rowversion property against the row in the database at save time. Freshly loading the entity makes the original value whatever is on disk right now, which detects nothing. `repository.SetOriginalRowVersion(entity, command.RowVersion)` (`:29`) overwrites that original with the token the client saw, so the `UPDATE` carries the client's version in its `WHERE` clause and affects zero rows if anyone changed the event in the meantime, which EF surfaces as a concurrency exception and the API turns into 409. The comment above the call states the rule and the null case (`:27-28`), and the implementation is a no-op for a null or empty token (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFRepository.cs:75-84`). Rationale in [ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html). -- **Walkthrough** - - Primary constructor (`:13-15`): unit of work and a typed logger. - - `HandleAsync` (`:18`) resolves the repository (`:22`) and loads with the **include-free single-argument overload**, `GetByIdAsync(command.Id, cancellationToken)` (`:23`). This is the one place in this unit where tracking is not requested explicitly, and it still works: that overload queries the tracked `Table` deliberately, documented at `MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:172-176`, because the generic update and delete handlers load through it, mutate, and save. A no-tracking load here would make both the rowversion stamp and the transition silent no-ops. - - `Error.NotFound` with source and target when the event is missing (`:24-25`), then the rowversion stamp (`:29`). - - `entity.Unpublish()` (`:31`) is the domain decision: the aggregate refuses an event that is already unpublished with the stable code `Event.AlreadyUnpublished`, flips `IsPublished` to false, and raises `EventChanged` with `DomainEntityState.Updated` (`MMCA.ADC.Conference.Domain/Events/Event.cs:278-294`). - - Save and log only on success (`:32-36`), through the generated `LogEventUnpublished` (`:41-42`); the domain `Result` is returned unchanged (`:38`). -- **Why it's built this way**: the handler owns the plumbing of the stale-view check while the aggregate owns the legality of the transition. Neither knows about the other's rule, which is what keeps "can this event be unpublished" testable without a database. -- **Where it's used**: registered by the module scan (`MMCA.ADC.Conference.Application/DependencyInjection.cs:112`); invoked by [`EventsController`](group-20-conference-api-grpc.md#eventscontroller)'s unpublish action (`MMCA.ADC.Conference.API/Controllers/EventsController.cs:323-325`). -- **Caveats / not-in-source**: the translation from EF's concurrency exception to a 409 response is not in this file. The handler returns a domain `Result`; the conflict surfaces from `SaveChangesAsync` and is mapped by the shared API middleware. +- **What it is**: the handler for [`UnpublishEventCommand`](#unpublisheventcommand), the exact mirror of [`PublishEventHandler`](#publisheventhandler) with `entity.Unpublish()` in place of `entity.Publish()`. +- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`UnpublishEventHandler.cs:14`), `ILogger` (`:15`), the [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) contract (`:15`), the [`Event`](group-17-conference-domain.md#event) aggregate, and [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error). +- **Concept**: nothing new; the stale-view guard taught by [`PublishEventHandler`](#publisheventhandler), including the same inline explanation of why the token is stamped before the transition (`:27-28`). Unpublishing is the direction where the guard earns its keep: hiding an event that a colleague has just re-published, based on a page rendered minutes ago, is precisely the mistake ADR-035 is meant to convert into a 409 or a 412 rather than a silent overwrite. `[Rubric §6, CQRS & Event-Driven]` assesses intent modelling: the reverse transition is its own command and its own handler, so both directions are separately auditable and separately loggable. +- **Walkthrough**: `HandleAsync` (`:18-39`) resolves the repository (`:22`), loads the tracked aggregate with the two-argument `GetByIdAsync` (`:23`), returns `Error.NotFound` when absent (`:24-25`), stamps the client token (`:29`), and calls `entity.Unpublish()` (`:31`), which fails with the invariant `Event.AlreadyUnpublished` when the flag is already clear and otherwise clears `IsPublished` and raises `EventChanged(DomainEntityState.Updated, ...)` (`MMCA.ADC.Conference.Domain/Events/Event.cs:292-308`). Success saves and logs through `LogEventUnpublished` (`:34-35`, declared `:41-42`). +- **Where it's used**: dispatched by `EventsController.UnpublishAsync` (`MMCA.ADC.Conference.API/Controllers/EventsController.cs:50,346-348`), which evicts the events output cache and returns 204 on success (`EventsController.cs:350-354`). Covered by [`UnpublishEventHandlerTests`](group-27-testing-infrastructure.md#unpublisheventhandlertests). +- **Caveats / not-in-source**: unpublishing changes what non-privileged readers can see, since the read paths filter on `IsPublished`, but nothing in this handler evicts a cached read directly. That is the caching decorator's job, driven by the `CachePrefix` on [`UnpublishEventCommand`](#unpublisheventcommand) (`MMCA.Common.Application/UseCases/Decorators/CachingCommandDecorator.cs:76-89`), with the separate output cache evicted by the controller. ### UpdateEventQuestionAnswerHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.UpdateEventQuestionAnswer` · `MMCA.ADC.Conference.Application/Events/UseCases/UpdateEventQuestionAnswer/UpdateEventQuestionAnswerHandler.cs:14` · Level 9 · class - -- **What it is**: the handler for [`UpdateEventQuestionAnswerCommand`](#updateeventquestionanswercommand). It is the edit twin of [`RemoveEventQuestionAnswerHandler`](#removeeventquestionanswerhandler) and carries the same BR-52/BR-53 ownership rule (`:10-13`). -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork); [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) and [`RoleNames`](group-08-auth.md#rolenames); the [`Event`](group-17-conference-domain.md#event) aggregate and its [`EventQuestionAnswer`](group-17-conference-domain.md#eventquestionanswer) child; [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error); `Microsoft.Extensions.Logging`. -- **Concept reinforced, per-row ownership.** The guard is the same expression as on the removal path, down to the error code: not an Organizer plus `answer.CreatedBy != currentUserService.UserId!.Value` yields `Error.Forbidden` with code `EventQuestionAnswer.NotOwner` and the message "You can only update your own answers." (`:34-42`). `[Rubric §11, Security]`, `[Rubric §16, Maintainability]`: the duplication between the two handlers is real and visible; it is the price of keeping each slice independent, and it is worth knowing about when the rule changes, because it has to change in two files. -- **Walkthrough**: primary constructor (`:14-17`); `HandleAsync` (`:20`) resolves the repository (`:24`), loads with `includes: [nameof(Event.EventQuestionAnswers)]` and `asTracking: true` (`:25-29`), fails not-found when the event is missing (`:30-31`), runs the ownership guard (`:34-42`), then calls `entity.UpdateEventQuestionAnswer(id, answerValue)` (`:44-46`). The aggregate resolves the child, forwards to its `UpdateAnswer`, and raises `EventQuestionAnswerChanged` with `DomainEntityState.Updated` (`MMCA.ADC.Conference.Domain/Events/Event.cs:632-648`), so an invalid answer value fails before anything is saved. Save and log run only on success (`:47-51`, generated method at `:56-57`), and the domain `Result` is returned unchanged (`:53`). -- **Where it's used**: registered by the module scan (`MMCA.ADC.Conference.Application/DependencyInjection.cs:112`); invoked by [`EventQuestionAnswersController`](group-20-conference-api-grpc.md#eventquestionanswerscontroller)'s update action (`MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:200-202`). - -### UpdateRoomCommandValidator - -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.UpdateRoom` · `MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomCommandValidator.cs:7` · Level 9 · class +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.UpdateEventQuestionAnswer` · `MMCA.ADC.Conference.Application/Events/UseCases/UpdateEventQuestionAnswer/UpdateEventQuestionAnswerHandler.cs:14` · Level 9 · class (sealed partial) -- **What it is**: the FluentValidation validator for [`UpdateRoomCommand`](#updateroomcommand). It declares no rule of its own; its entire body composes six shared rule sets (`:11-16`). -- **Depends on**: FluentValidation's `AbstractValidator` (NuGet); [`RoomNameRules`](#roomnamerulest), [`RoomSortRules`](#roomsortrulest), [`RoomCapacityRules`](#roomcapacityrulest), [`RoomFloorRules`](#roomfloorrulest), [`RoomLocationRules`](#roomlocationrulest), and [`RoomAccessibilityInfoRules`](#roomaccessibilityinforulest). -- **Concept reinforced, rule composition with `Include`.** `[Rubric §16, Maintainability]` assesses whether one constraint is written once: rather than restate the room constraints in the add validator and again here, both `Include` the same generic rule sets, parameterized by a property selector. `Include` merges the included validator's rules in as though they had been declared inline, so composition costs nothing at validation time. `[Rubric §24, Forms, Validation & UX Safety]` covers what those rules produce: each carries a human message and a stable error code, for example `Room.Name.Required` and `Room.Name.MaxLength` (`MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:17-18`), so a client can branch on the code instead of parsing English. The ceilings come from the domain, not from the validator: `EventInvariants.RoomNameMaxLength` is 255, `RoomFloorMaxLength` 100, `RoomLocationMaxLength` 255, and `RoomAccessibilityInfoMaxLength` 500 (`MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:40`, `:43`, `:46`, `:49`), which are the same constants the EF column configuration uses, so the form limit and the column width cannot drift apart. -- **Walkthrough**: a `sealed class` whose whole body is a six-line constructor (`:9-17`). `RoomNameRules` is required plus max length (`RoomValidationRules.cs:12-19`); `RoomSortRules` demands a non-negative sort (`:25-31`); `RoomCapacityRules` demands a positive capacity but only when one is supplied, via `.When(x => selector.Compile()(x) is not null)` (`:37-44`); the floor, location, and accessibility rule sets are max-length only, so a null value passes (`:51-57`, `:64-70`, `:77-83`). -- **Why it's built this way**: extracting field rules into shared generic classes is the module-wide convention; add a constraint once and every command that includes the rule set picks it up. Running them ahead of the transaction is the pipeline's job: [`ValidatingCommandDecorator`](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult) sits outside [`ITransactional`](group-05-cqrs-pipeline.md#itransactional) ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)), so a malformed room never opens a database transaction. -- **Where it's used**: discovered by the module's validator scan (`MMCA.ADC.Conference.Application/DependencyInjection.cs:112`) and run by [`ValidatingCommandDecorator`](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult) ahead of [`UpdateRoomHandler`](#updateroomhandler). Compare [`AddRoomCommandValidator`](#addroomcommandvalidator), which includes the same rule sets for the add path. -- **Caveats / not-in-source**: name uniqueness within an event is **not** validated here. It cannot be: the rule needs the event's other rooms, so it lives in the aggregate as `EnsureRoomNameIsUnique` and comes back as the invariant error `Event.Room.Duplicate` (`MMCA.ADC.Conference.Domain/Events/Event.cs:687-704`). +- **What it is**: the handler for [`UpdateEventQuestionAnswerCommand`](#updateeventquestionanswercommand). It applies the same BR-52/BR-53 ownership gate as its remove twin and then asks the aggregate to change the answer text (`UpdateEventQuestionAnswerHandler.cs:10-13`). +- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:15`), [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) (`:16`), `ILogger` (`:17`), the [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) contract (`:17`), [`RoleNames`](group-08-auth.md#rolenames) (`:6,35`), the [`Event`](group-17-conference-domain.md#event) aggregate with its [`EventQuestionAnswer`](group-17-conference-domain.md#eventquestionanswer) children, and [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error). +- **Concept**: nothing new; the row-level ownership check taught by [`RemoveEventQuestionAnswerHandler`](#removeeventquestionanswerhandler), reproduced line for line with `"You can only update your own answers."` in place of the delete message (`:37-42`). Reading the two side by side is the fastest way to see that the rule is a policy about the caller and the row, not about the verb. The one difference downstream is that the update path re-validates the payload inside the domain: `EventQuestionAnswer.UpdateAnswer` runs `EventInvariants.EnsureAnswerValueIsValid` before assigning, so an empty or over-long answer is rejected by the entity even if it reached the handler (`MMCA.ADC.Conference.Domain/Events/EventQuestionAnswer.cs:71-80`). `[Rubric §11, Security]` assesses object-level authorization: identity comes from the ambient principal and the owner comes from the persisted audit field, never from the command. `[Rubric §4, Domain-Driven Design]` assesses invariant placement: the text rule stays on the entity, so no future caller can bypass it by writing a new handler. +- **Walkthrough**: `HandleAsync` (`:20-54`) resolves the repository (`:24`), loads with `includes: [nameof(Event.EventQuestionAnswers)]` and `asTracking: true` (`:25-29`), returns `Error.NotFound` for a missing event (`:30-31`), finds the candidate answer in the loaded collection (`:34`), applies the ownership gate returning `Error.Forbidden` with the code `EventQuestionAnswer.NotOwner` (`:35-42`), then calls `entity.UpdateEventQuestionAnswer(command.EventQuestionAnswerId, command.AnswerValue)` (`:44-46`). The aggregate re-resolves the child through `GetEventQuestionAnswerOrNotFound` (`MMCA.ADC.Conference.Domain/Events/Event.cs:665`, helper at `Event.cs:745-748`), delegates to `answer.UpdateAnswer(...)` and raises `EventQuestionAnswerChanged(DomainEntityState.Updated, ...)` (`Event.cs:661-680`). Success saves (`:49`) and logs through `LogEventQuestionAnswerUpdated` (`:50`, declared `:56-57`). +- **Why it's built this way**: the ownership predicate is duplicated between this handler and the remove handler rather than hoisted into a shared helper. With one condition and one message each, the two slices stay independently readable and independently changeable, which is the trade the vertical-slice layout is making on purpose. +- **Where it's used**: dispatched by `EventQuestionAnswersController.UpdateAsync` on `PUT {id}` (`MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:60,202-209`), which returns 204 on success. Covered by [`UpdateEventQuestionAnswerHandlerTests`](group-27-testing-infrastructure.md#updateeventquestionanswerhandlertests). +- **Caveats / not-in-source**: as in the remove twin, `currentUserService.UserId!.Value` (`:35`) assumes an authenticated caller, and the log line records only the answer id, not the editor (`:56-57`), so the audit trail for "who changed this text" lives in the row's `LastModifiedBy` stamp rather than in the log. -### UpdateRoomHandler +### CreateEventHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.UpdateRoom` · `MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomHandler.cs:13` · Level 9 · class +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.Create` · `MMCA.ADC.Conference.Application/Events/UseCases/Create/CreateEventHandler.cs:16` · Level 10 · class (sealed partial) -- **What it is**: the handler for [`UpdateRoomCommand`](#updateroomcommand). Same load-delegate-save template as [`RemoveRoomHandler`](#removeroomhandler), with all six room fields forwarded to the aggregate. -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork); the [`Event`](group-17-conference-domain.md#event) aggregate and its [`Room`](group-17-conference-domain.md#room) child; [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error); `Microsoft.Extensions.Logging`. -- **Concept reinforced, the handler as a pass-through to the root.** `[Rubric §4, DDD]` assesses where the rules live, and this handler is the clearest example in the unit of them living elsewhere: it re-checks nothing that [`UpdateRoomCommandValidator`](#updateroomcommandvalidator) already checked and decides nothing the aggregate decides. `Event.UpdateRoom` (`MMCA.ADC.Conference.Domain/Events/Event.cs:397-422`) resolves the room or returns not-found (`:406-409`), enforces name uniqueness excluding the room being edited (`:411-413`), forwards to `room.Update(...)` for the field-level invariants (`:415-417`), and raises `RoomChanged` with `DomainEntityState.Updated` only after all three pass (`:419`). `[Rubric §3, Clean Architecture]`: the handler touches abstractions only, with no EF type in sight. -- **Walkthrough**: primary constructor (`:13-15`); `HandleAsync` (`:18`) resolves the repository (`:22`), loads with `includes: [nameof(Event.Rooms)]` and `asTracking: true` (`:23-27`) because both the uniqueness check and the mutation need the sibling rooms in memory and tracked, returns `Error.NotFound` when the event is missing (`:28-29`), forwards the seven command members positionally to `entity.UpdateRoom(...)` (`:31-38`), and saves plus logs only on success (`:39-43`, generated method at `:48-49`). The domain `Result` is returned unchanged (`:45`). -- **Why it's built this way**: the uniqueness rule is the reason the whole `Rooms` collection is loaded for what looks like a single-row edit. It is an aggregate-scoped invariant, so it can only be answered with the aggregate in hand; pushing it to a database index alone would surface as an opaque constraint violation instead of the typed `Event.Room.Duplicate` error. -- **Where it's used**: registered by the module scan (`MMCA.ADC.Conference.Application/DependencyInjection.cs:112`); invoked through the decorator pipeline by [`RoomsController`](group-20-conference-api-grpc.md#roomscontroller)'s `PUT /Rooms/{id}` action (`MMCA.ADC.Conference.API/Controllers/RoomsController.cs:178-188`), which returns `204 No Content` and evicts the rooms output cache on success (`:193-194`). +- **What it is**: the handler that creates an [`Event`](group-17-conference-domain.md#event). It owns no construction logic and no mapping logic: it orchestrates three collaborators (request mapper, repository, DTO mapper) and returns the created [`EventDTO`](group-17-conference-domain.md#eventdto). +- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`CreateEventHandler.cs:17`), [`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype) closed over `Event`, [`EventCreateRequest`](#eventcreaterequest) and `EventIdentifierType` (`:18`), [`EventDTOMapper`](#eventdtomapper) injected as a concrete class (`:19`), `ILogger` (`:20`), the [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) contract returning `Result` (`:20`), and [`Result`](group-01-result-error-handling.md#result). +- **Concept introduced, the create slice as pure composition.** Three details make this handler the reference for every create in the module. First, the entity arrives already built and already validated: `requestMapper.CreateEntityAsync(command, cancellationToken)` returns a [`Result`](group-01-result-error-handling.md#result), and a failure is propagated with its errors intact rather than reworded (`:27-29`). Second, the repository is obtained per use case from the unit of work, `unitOfWork.GetRepository()` (`:32`), never constructor-injected; injecting [`IRepository`](group-07-persistence-ef-core.md#irepositorytentity-tidentifiertype) directly would bypass the unit of work that owns the transaction and the change tracker. Third, `AddAsync` stages and `SaveChangesAsync` commits (`:34-35`), and that single save is the transaction boundary for three separate effects: the row, the audit stamps applied by the DbContext, and the outbox record for the `EventChanged` domain event the factory attached ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html), event raised at `MMCA.ADC.Conference.Domain/Events/Event.cs:207`). Nothing can be persisted without its notification, and nothing can be notified without being persisted. `[Rubric §3, Clean Architecture]` assesses dependency direction: every collaborator here is an application or domain abstraction, and the only concrete type is the module's own DTO mapper. `[Rubric §6, CQRS & Event-Driven]` assesses the write path: one command type in, one DTO out, one save. `[Rubric §14, Testability]` assesses substitutability: four constructor parameters, all interfaces or a pure mapper, so the handler is exercised without a database. `[Rubric §13, Observability & Operability]` assesses diagnostics: the success log is a `[LoggerMessage]` method carrying `EventId` and `Name` as structured fields (`:42-43`). +- **Walkthrough**: the primary constructor declares the four dependencies (`:16-20`). `HandleAsync` (`:23-40`) awaits the request mapper (`:27`), returns `Result.Failure(result.Errors)` on failure (`:28-29`), unwraps the entity (`:31`), resolves the repository (`:32`), stages the insert with `AddAsync(entity, cancellationToken).ConfigureAwait(false)` (`:34`), commits with `SaveChangesAsync` (`:35`), logs (`:37`), and returns `Result.Success(dtoMapper.MapToDTO(entity))` (`:39`). Note that the DTO is produced from the entity *after* the save, so a database-generated `Id` is present in the response. +- **Why it's built this way**: this handler is what makes the inherited CRUD endpoint work. [`AggregateRootEntityControllerBase`](group-12-api-hosting-mapping.md#aggregaterootentitycontrollerbasetentity-tentitydto-tidentifiertype-tcreaterequest) takes an `ICommandHandler>` in its constructor (`MMCA.Common.API/Controllers/AggregateRootEntityControllerBase.cs:33,48`) and its `CreateAsync` does nothing but call it and translate the result into 201 Created with a `CreatedAtRoute` location (`AggregateRootEntityControllerBase.cs:63-76`). Because this handler satisfies that closed generic, the whole create endpoint for events is inherited rather than written. The pipeline decorators (logging, caching, transactional) wrap it without it knowing, which is why there is no `try`/`catch` and no cache call in the file. +- **Where it's used**: injected into [`EventsController`](group-20-conference-api-grpc.md#eventscontroller) as `ICommandHandler>` (`MMCA.ADC.Conference.API/Controllers/EventsController.cs:47`) and passed to the base controller (`EventsController.cs:58`); reached through the overridden `CreateAsync`, which calls `base.CreateAsync(request, cancellationToken)` and then evicts the events output cache (`EventsController.cs:254-262`). Registered by the convention scan, which binds every `ICommandHandler<,>` implementation to its interfaces with a scoped lifetime (`MMCA.Common.Application/DependencyInjection.cs:178-182`, invoked at `MMCA.ADC.Conference.Application/DependencyInjection.cs:125`). Covered by [`CreateEventHandlerTests`](group-27-testing-infrastructure.md#createeventhandlertests). Its update-side counterpart is [`UpdateEventHandler`](#updateeventhandler) and its delete-side counterpart is [`DeleteEventHandler`](#deleteeventhandler). +- **Caveats / not-in-source**: the handler never inspects `command.Id`, and neither does the aggregate for this type (see the caveat on [`EventCreateRequest`](#eventcreaterequest)), so a client-supplied event id is silently ignored rather than rejected. Idempotency for retried POSTs is handled entirely outside this file, by [`IdempotentAttribute`](group-12-api-hosting-mapping.md#idempotentattribute) on the endpoint ([ADR-017](https://ivanball.github.io/docs/adr/017-request-idempotency.html)); the handler itself would insert a second row if invoked twice. ### AddSpeakerCategoryItemCommand @@ -2754,21 +2978,10 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **What it is**: the command that tags a speaker with a category item. Category items are how a speaker's topics and locality are modeled, so this is the "tag this speaker" message. Three positional parameters: the owning `SpeakerId`, an optional `SpeakerCategoryItemId` for the join entity, and the `CategoryItemId` being associated (`AddSpeakerCategoryItemCommand.cs:13-16`). - **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating), the pipeline marker it implements (`AddSpeakerCategoryItemCommand.cs:16`); the [`Speaker`](group-17-conference-domain.md#speaker) domain type, referenced only to build the cache prefix; and the `SpeakerIdentifierType` / `SpeakerCategoryItemIdentifierType` / `CategoryItemIdentifierType` module aliases ([ADR-048](https://ivanball.github.io/docs/adr/048-primitive-identifier-type-aliases.html)). -- **Concept introduced, the nullable child id on an Add command**: the second parameter is `SpeakerCategoryItemIdentifierType?`, documented as "Explicit ID for the new join entity, or `null` for database-generated identity" (`AddSpeakerCategoryItemCommand.cs:11`). The REST path always passes `null` and lets the database assign the key ([`SpeakerCategoryItemsController`](group-20-conference-api-grpc.md#speakercategoryitemscontroller) at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:216`); the parameter exists so a caller that already knows the id can supply it, which is exactly the shape the aggregate's factory call takes (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:302-304`). `[Rubric §9, API and Contract Design]` assesses whether a contract states precisely what a caller may decide: making the id nullable rather than defaulted keeps "let the database choose" distinct from "I chose zero". +- **Concept introduced, the nullable child id on an Add command**: the second parameter is `SpeakerCategoryItemIdentifierType?`, documented as "Explicit ID for the new join entity, or `null` for database-generated identity" (`AddSpeakerCategoryItemCommand.cs:11`). The REST path always passes `null` and lets the database assign the key ([`SpeakerCategoryItemsController`](group-20-conference-api-grpc.md#speakercategoryitemscontroller) at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:224`); the parameter exists so a caller that already knows the id can supply it, which is exactly the shape the aggregate's method takes (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:314-316`). `[Rubric §9, API and Contract Design]` assesses whether a contract states precisely what a caller may decide: making the id nullable rather than defaulted keeps "let the database choose" distinct from "I chose zero". - **Walkthrough**: the record body holds one member, `CachePrefix => $"{typeof(Speaker).FullName}:"` (`AddSpeakerCategoryItemCommand.cs:18-19`). That satisfies [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating), so [`CachingCommandDecorator`](group-05-cqrs-pipeline.md#cachingcommanddecoratortcommand-tresult) evicts every cache entry under the `Speaker` prefix after the command succeeds. The join row has no cache namespace of its own, which is the point: tagging a speaker flushes the whole speaker read surface in one stroke rather than requiring per-query bookkeeping, so a stale nested category item cannot survive inside an already-cached speaker. -- **Why it's built this way**: caching and invalidation are declared by the message and applied uniformly by the pipeline ([ADR-026](https://ivanball.github.io/docs/adr/026-caching-strategy.html), [ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)), never hand-wired inside a handler. `[Rubric §10, Cross-Cutting]`: the command says what it invalidates; it does not know how. Note what is absent: no [`ITransactional`](group-05-cqrs-pipeline.md#itransactional), because the whole write lands in one aggregate and one `SaveChangesAsync` with no cross-context event to keep atomic (contrast [`LinkUserToSpeakerCommand`](#linkusertospeakercommand)). -- **Where it's used**: validated by [`AddSpeakerCategoryItemCommandValidator`](#addspeakercategoryitemcommandvalidator), handled by [`AddSpeakerCategoryItemHandler`](#addspeakercategoryitemhandler), and constructed by the `POST /SpeakerCategoryItems` action from an [`AddSpeakerCategoryItemRequest`](group-20-conference-api-grpc.md#addspeakercategoryitemrequest) body (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:212-217`), on a controller gated by the `SpeakersManage` permission (`SpeakerCategoryItemsController.cs:46`, [ADR-020](https://ivanball.github.io/docs/adr/020-permission-based-authorization.html)). Its mirror image is [`RemoveSpeakerCategoryItemCommand`](#removespeakercategoryitemcommand). - -### LinkUserToSpeakerCommand - -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.LinkUser` · `MMCA.ADC.Conference.Application/Speakers/UseCases/LinkUser/LinkUserToSpeakerCommand.cs:13` · Level 8 · record - -- **What it is**: the write message an organizer sends to attach an application account to a speaker profile (BR-209). Two ids and nothing else: `SpeakerId` and `UserId` (`LinkUserToSpeakerCommand.cs:13`). -- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) and [`ITransactional`](group-05-cqrs-pipeline.md#itransactional), both markers implemented at `LinkUserToSpeakerCommand.cs:13`; [`Speaker`](group-17-conference-domain.md#speaker), referenced only to build the cache prefix; and the module identifier aliases `SpeakerIdentifierType` (a GUID here) and `UserIdentifierType` (an int owned by Identity). -- **Concept introduced, the command that declares its own transaction**: `[Rubric §6, CQRS and Event-Driven]` assesses whether a write is modeled as an explicit single-purpose message: this record carries intent only, and the two marker interfaces tell the pipeline how to run it. `[Rubric §10, Cross-Cutting]` assesses whether such concerns are declared rather than hand-coded. Implementing [`ITransactional`](group-05-cqrs-pipeline.md#itransactional) opts the message into [`TransactionalCommandDecorator`](group-05-cqrs-pipeline.md#transactionalcommanddecoratortcommand-tresult), and the XML comment states the reason plainly (`LinkUserToSpeakerCommand.cs:7-8`): the Speaker link and the outbox row that carries the cross-context User update must commit together or not at all. Implementing [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) with `CachePrefix => $"{typeof(Speaker).FullName}:"` (`LinkUserToSpeakerCommand.cs:16`) is what makes [`CachingCommandDecorator`](group-05-cqrs-pipeline.md#cachingcommanddecoratortcommand-tresult) drop every cached read keyed under the `Speaker` type after a successful link. -- **Walkthrough**: a `sealed record` with a two-parameter positional constructor (`LinkUserToSpeakerCommand.cs:13`) and one member, the expression-bodied `CachePrefix` (`LinkUserToSpeakerCommand.cs:15-16`). The cross-module identifier pairing is the notable part: `UserIdentifierType` is Identity's alias, carried here as a plain scalar because the two modules own separate databases and there is no foreign key to point at ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)). -- **Why it's built this way**: the two markers move durability and cache eviction out of the handler and into the pipeline, so [`LinkUserToSpeakerHandler`](#linkusertospeakerhandler) reads as pure domain orchestration. Records give value equality and immutability for free. -- **Where it's used**: constructed by the `PUT /Speakers/{id}/link` action from a [`LinkUserRequest`](group-17-conference-domain.md#linkuserrequest) body ([`SpeakersController`](group-20-conference-api-grpc.md#speakerscontroller) at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:372`, handler injected at `SpeakersController.cs:49`, permission-gated at `SpeakersController.cs:365`); handled by [`LinkUserToSpeakerHandler`](#linkusertospeakerhandler). Its inverse is [`UnlinkUserFromSpeakerCommand`](#unlinkuserfromspeakercommand). +- **Why it's built this way**: caching and invalidation are declared by the message and applied uniformly by the pipeline ([ADR-026](https://ivanball.github.io/docs/adr/026-caching-strategy.html), [ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)), never hand-wired inside a handler. `[Rubric §10, Cross-Cutting]`: the command says what it invalidates; it does not know how. Note what is absent: no [`ITransactional`](group-05-cqrs-pipeline.md#itransactional), because the whole write lands in one aggregate and one `SaveChangesAsync` with no cross-context event to keep atomic. +- **Where it's used**: validated by [`AddSpeakerCategoryItemCommandValidator`](#addspeakercategoryitemcommandvalidator), handled by [`AddSpeakerCategoryItemHandler`](#addspeakercategoryitemhandler), and constructed by the `POST /SpeakerCategoryItems` action from an [`AddSpeakerCategoryItemRequest`](group-20-conference-api-grpc.md#addspeakercategoryitemrequest) body (`SpeakerCategoryItemsController.cs:217-225`, handler injected at `SpeakerCategoryItemsController.cs:50`), on a controller gated by the `SpeakersManage` permission (`SpeakerCategoryItemsController.cs:47`, [ADR-020](https://ivanball.github.io/docs/adr/020-permission-based-authorization.html)). That action also carries `[Idempotent]` (`SpeakerCategoryItemsController.cs:218`), so a retried request with the same `Idempotency-Key` replays the first response instead of adding a second row. Its mirror image is [`RemoveSpeakerCategoryItemCommand`](#removespeakercategoryitemcommand). ### QuestionCreateRequest @@ -2779,8 +2992,23 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **Concept introduced, the request whose id field is accepted and then thrown away**: `[Rubric §9, API and Contract Design]` assesses whether a contract is honest about what the caller controls. `Id` is a settable `init` member (`QuestionCreateRequest.cs:16`), but its own doc comment says "Auto-generated by the handler; caller-provided values are ignored". That is not laziness: the question id space is shared with Sessionize, so [`CreateQuestionHandler`](#createquestionhandler) allocates from a reserved manual range and overwrites whatever arrived (`CreateQuestionHandler.cs:87`). Compare [`SpeakerCreateRequest`](#speakercreaterequest), where the caller-supplied id IS honored because a speaker id is a Sessionize-assigned GUID. Two create requests in the same module, opposite id policies, and the only place either is stated is a one-line comment on the property. - **Walkthrough**: a `record class` (not `sealed`) with `init`-only members. `CachePrefix => $"{typeof(Question).FullName}:"` (`QuestionCreateRequest.cs:13`) is the invalidation tag. Exactly one member is `required`, `QuestionText` (`QuestionCreateRequest.cs:19`), so the record cannot be constructed without it. The rest are optional: `QuestionEntity` (`QuestionCreateRequest.cs:22`), `QuestionType` (`QuestionCreateRequest.cs:25`), `Sort` (`QuestionCreateRequest.cs:28`), and `IsRequired` (`QuestionCreateRequest.cs:31`). - **Why it's built this way**: `required` plus `init` gives compile-time enforcement of the minimum payload while leaving the rest optional, and collapsing request and command into one type keeps a simple create slice to a single message. `[Rubric §5, Vertical Slice]`: the request, its mapper, its validator, and its handler all sit in `Questions/UseCases/Create`. -- **Where it's used**: bound from the body by [`QuestionsController`](group-20-conference-api-grpc.md#questionscontroller) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/QuestionsController.cs:93`), and it is also the fourth generic argument of that controller's base [`AggregateRootEntityControllerBase`](group-12-api-hosting-mapping.md#aggregaterootentitycontrollerbasetentity-tentitydto-tidentifiertype-tcreaterequest) (`QuestionsController.cs:38-39`); validated by [`QuestionCreateRequestValidator`](#questioncreaterequestvalidator); translated to a domain entity by [`QuestionCreateRequestMapper`](#questioncreaterequestmapper); handled by [`CreateQuestionHandler`](#createquestionhandler). -- **Caveats / not-in-source**: `QuestionEntity` and `QuestionType` are declared nullable here, but [`Question`](group-17-conference-domain.md#question)`.Create` takes them as non-nullable and validates both against closed value lists (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:28`, `QuestionInvariants.cs:31`). [`QuestionCreateRequestMapper`](#questioncreaterequestmapper) bridges the gap with `!` (`QuestionCreateRequestMapper.cs:22-23`), so omitting either field is not a binding error but an invariant failure at create time. Nothing on this record says so. +- **Where it's used**: bound from the body by [`QuestionsController`](group-20-conference-api-grpc.md#questionscontroller) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/QuestionsController.cs:91-93`), and it is also the fourth generic argument of that controller's base [`AggregateRootEntityControllerBase`](group-12-api-hosting-mapping.md#aggregaterootentitycontrollerbasetentity-tentitydto-tidentifiertype-tcreaterequest) (`QuestionsController.cs:38-39`); validated by [`QuestionCreateRequestValidator`](#questioncreaterequestvalidator); translated to a domain entity by [`QuestionCreateRequestMapper`](#questioncreaterequestmapper); handled by [`CreateQuestionHandler`](#createquestionhandler). +- **Caveats / not-in-source**: `QuestionEntity` and `QuestionType` are declared nullable here, but [`Question`](group-17-conference-domain.md#question)`.Create` takes them as non-nullable and validates both against closed value lists, `["Session", "Event", "Speaker"]` and `["Rating", "Text", "Email"]` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:28`, `QuestionInvariants.cs:31`). [`QuestionCreateRequestMapper`](#questioncreaterequestmapper) bridges the gap with `!` (`QuestionCreateRequestMapper.cs:22-23`), so omitting either field is not a binding error but an invariant failure at create time. Nothing on this record says so. + +### QuestionDTOMapper + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Questions.DTOs` · `MMCA.ADC.Conference.Application/Questions/DTOs/QuestionDTOMapper.cs:12` · Level 8 · class (sealed partial) + +- **What it is**: the outbound mapper that turns a [`Question`](group-17-conference-domain.md#question) aggregate into the [`QuestionDTO`](group-17-conference-domain.md#questiondto) the API returns. Its single-entity method has no body: Mapperly generates it at compile time from the `[Mapper]` attribute (`QuestionDTOMapper.cs:11`). +- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) closed over `Question`, `QuestionDTO`, and the `QuestionIdentifierType` alias (`QuestionDTOMapper.cs:13`); the [`Question`](group-17-conference-domain.md#question) entity and the [`QuestionDTO`](group-17-conference-domain.md#questiondto) contract; `Riok.Mapperly.Abstractions` (NuGet, `QuestionDTOMapper.cs:4`). +- **Concept reinforced, source-generated DTO mapping**: `[Rubric §2, Design Patterns]` assesses whether repetitive translation code is factored out rather than hand-written per property: the `partial` declaration at `QuestionDTOMapper.cs:16` is the whole contribution, and the generator emits the property-by-property assignment into a companion generated file. `[Rubric §12, Performance and Scalability]` assesses the cost of that translation: because the body is generated, mapping is straight-line assignment with no reflection and no expression compilation on the hot read path. `[Rubric §3, Clean Architecture]` assesses direction: the domain type never leaves the Application layer, only the DTO does. The convention and its trade-offs are set out in [ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html); contrast this with the inbound `*RequestMapper` classes such as [`QuestionCreateRequestMapper`](#questioncreaterequestmapper), which are hand-written because they must call a factory and are allowed to fail. +- **Walkthrough** + - `[Mapper]` (`QuestionDTOMapper.cs:11`) is the generator trigger; the class is `sealed partial` (`QuestionDTOMapper.cs:12`) so the generated half can be merged in. + - `public partial QuestionDTO MapToDTO(Question entity)` (`QuestionDTOMapper.cs:16`) is the generated member. Mapping is by name, and the pairs line up one for one: `QuestionText`, `QuestionEntity`, `QuestionType`, `Sort`, `IsRequired`, and `QuestionSource` on the entity (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/Question.cs:17-32`) against the same names on the DTO (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Questions/QuestionDTO.cs:18-33`), plus the `Id` and `RowVersion` members the DTO declares for `IBaseDTO` and `IConcurrencyAware` (`QuestionDTO.cs:12-15`). + - `MapToDTOs` (`QuestionDTOMapper.cs:19-23`) is hand-written, not generated: it null-guards the input (`QuestionDTOMapper.cs:21`) and projects with a collection expression over the generated single-item mapper, `[.. entityCollection.Select(MapToDTO)]` (`QuestionDTOMapper.cs:22`). +- **Why it's built this way**: the three string members are non-nullable on the entity (`Question.cs:17`, `Question.cs:20`, `Question.cs:32`) and nullable on the DTO (`QuestionDTO.cs:21`, `QuestionDTO.cs:24`, `QuestionDTO.cs:33`), which is the usual direction for a read contract: the DTO tolerates more than the domain produces, so a contract change does not force a domain change. +- **Where it's used**: injected as a concrete type by [`CreateQuestionHandler`](#createquestionhandler) (`CreateQuestionHandler.cs:23`) and [`UpdateQuestionHandler`](#updatequestionhandler) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Questions/UseCases/Update/UpdateQuestionHandler.cs:21`); resolved through its interface by [`EntityQueryService`](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype) (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:35`), which is registered for `Question` at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:76` and drives every read on [`QuestionsController`](group-20-conference-api-grpc.md#questionscontroller). The mapper itself is picked up by the module scan (`DependencyInjection.cs:125`). Covered by `QuestionDTOMapperTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Questions/DTOs/QuestionDTOMapperTests.cs`). +- **Caveats / not-in-source**: the generated assignments are not readable in this file, only in build output, so a member added to the DTO without a matching entity member surfaces as a generator diagnostic at build time rather than as anything visible here. Note also that this mapper redacts nothing: unlike [`SpeakerDTOMapper`](#speakerdtomapper), which withholds an email from non-organizers, every question member is copied verbatim. ### SpeakerCategoryItemDTOMapper @@ -2788,26 +3016,14 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **What it is**: the entity-to-DTO mapper for the [`SpeakerCategoryItem`](group-17-conference-domain.md#speakercategoryitem) join entity. It is a Mapperly source-generated mapper: the class declares the signature, the generator writes the body at compile time. - **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) closed over [`SpeakerCategoryItem`](group-17-conference-domain.md#speakercategoryitem) / [`SpeakerCategoryItemDTO`](group-17-conference-domain.md#speakercategoryitemdto) / `SpeakerCategoryItemIdentifierType` (`SpeakerCategoryItemDTOMapper.cs:13`), and the `Riok.Mapperly.Abstractions` package for the `[Mapper]` attribute (`SpeakerCategoryItemDTOMapper.cs:4`, `SpeakerCategoryItemDTOMapper.cs:11`). -- **Concept introduced, compile-time mapping instead of runtime reflection**: `[Rubric §12, Performance and Scalability]` assesses whether hot per-row work avoids reflection, and `[Rubric §15, Best Practices]` assesses whether generated code is preferred to hand-maintained boilerplate that can silently drift. `[Mapper]` on a `partial` class makes Mapperly emit the body of `public partial SpeakerCategoryItemDTO MapToDTO(SpeakerCategoryItem entity)` (`SpeakerCategoryItemDTOMapper.cs:16`) as plain property assignments in a generated file. There is no runtime configuration step and no reflection at map time; a property that cannot be matched is a build diagnostic, not a null at runtime. This is the "manual mapping over reflective auto-mapping" position of [ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html), taken one step further: the compiler writes the manual mapping. +- **Concept introduced, compile-time mapping instead of runtime reflection**: `[Rubric §12, Performance and Scalability]` assesses whether hot per-row work avoids reflection, and `[Rubric §15, Best Practices and Code Quality]` assesses whether generated code is preferred to hand-maintained boilerplate that can silently drift. `[Mapper]` on a `partial` class makes Mapperly emit the body of `public partial SpeakerCategoryItemDTO MapToDTO(SpeakerCategoryItem entity)` (`SpeakerCategoryItemDTOMapper.cs:16`) as plain property assignments in a generated file. There is no runtime configuration step and no reflection at map time; a property that cannot be matched is a build diagnostic, not a null at runtime. This is the "manual mapping over reflective auto-mapping" position of [ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html), taken one step further: the compiler writes the manual mapping. - **Walkthrough**: two members. - `MapToDTO` (`SpeakerCategoryItemDTOMapper.cs:16`) is `partial` with no body; the generator supplies it. - `MapToDTOs` (`SpeakerCategoryItemDTOMapper.cs:19-23`) is written by hand: a null guard, then a collection-expression spread over `Select(MapToDTO)`. Re-declaring it on the class is what makes it callable through the *concrete* type, which matters because the handlers in this module inject the concrete mapper (`AddSpeakerCategoryItemHandler.cs:17`) rather than the interface. - **Why it's built this way**: DTO shaping stays a compile-checked, allocation-lean step owned by the Application layer, so the API contract cannot drift from the entity without a build error ([ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html)). -- **Where it's used**: injected into [`AddSpeakerCategoryItemHandler`](#addspeakercategoryitemhandler) (`AddSpeakerCategoryItemHandler.cs:17`), composed into [`SpeakerDTOMapper`](#speakerdtomapper) as a `[UseMapper]` field for the parent's child collection (`SpeakerDTOMapper.cs:23-24`), and resolved by the generic [`EntityQueryService`](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype) registered for this entity (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:102`). It is registered by the convention scan at `DependencyInjection.cs:112`, not by an explicit line. Covered by `SpeakerCategoryItemDTOMapperTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/DTOs/SpeakerCategoryItemDTOMapperTests.cs`). +- **Where it's used**: injected into [`AddSpeakerCategoryItemHandler`](#addspeakercategoryitemhandler) (`AddSpeakerCategoryItemHandler.cs:17`), composed into [`SpeakerDTOMapper`](#speakerdtomapper) as a `[UseMapper]` field for the parent's child collection (`SpeakerDTOMapper.cs:23-24`), and resolved by the generic [`EntityQueryService`](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype) registered for this entity (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:111`). The mapper itself is registered by the convention scan at `DependencyInjection.cs:125`, not by an explicit line. Covered by `SpeakerCategoryItemDTOMapperTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/DTOs/SpeakerCategoryItemDTOMapperTests.cs`). - **Caveats / not-in-source**: what the generated `MapToDTO` actually copies is decided by the property names on the entity and the DTO; the generated file is not in the repository, so the field list is only observable through the two type definitions and a build. -### SpeakerCreateRequest - -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.Create` · `MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequest.cs:10` · Level 8 · record - -- **What it is**: the create-request DTO for a conference speaker. Like [`QuestionCreateRequest`](#questioncreaterequest) it doubles as the command: [`CreateSpeakerHandler`](#createspeakerhandler) implements `ICommandHandler>` directly against this type, so there is no separate `CreateSpeakerCommand`. -- **Depends on**: [`ICreateRequest`](group-05-cqrs-pipeline.md#icreaterequest); [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating); the [`Speaker`](group-17-conference-domain.md#speaker) type for the cache prefix; the `SpeakerIdentifierType` alias (`SpeakerCreateRequest.cs:10`, `SpeakerCreateRequest.cs:13`). -- **Concept reinforced, the request-as-command shape**: `[Rubric §9, API and Contract Design]` assesses whether the wire contract is an explicit, versionable type rather than the domain entity leaking outward: the controller binds this record straight from the request body (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:311`) and it is also the fourth generic argument of the base controller (`SpeakersController.cs:59`). `[Rubric §5, Vertical Slice]` applies to the folder: request, mapper, validator, and handler for Create all sit in `Speakers/UseCases/Create`. Marking it [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) means a successful create evicts the cached speaker reads exactly like the command records above. -- **Walkthrough**: a `record class` (not `sealed`) with `init`-only members. `CachePrefix` (`SpeakerCreateRequest.cs:13`) is the invalidation tag. `Id` (`SpeakerCreateRequest.cs:16`) is a `SpeakerIdentifierType`, and its comment records that it is Sessionize-assigned: the caller supplies the key rather than the database generating it, which is what lets an import be idempotent. Three members are `required`, so the record cannot be constructed without them: `FirstName` (`SpeakerCreateRequest.cs:19`), `LastName` (`SpeakerCreateRequest.cs:22`), and `FullName` (`SpeakerCreateRequest.cs:25`). The rest are optional `init` members: `Email` (`SpeakerCreateRequest.cs:28`), `Bio` (`SpeakerCreateRequest.cs:31`), `TagLine` (`SpeakerCreateRequest.cs:34`), `ProfilePicture` (`SpeakerCreateRequest.cs:37`), the `IsTopSpeaker` flag (`SpeakerCreateRequest.cs:40`), and the four profile links `TwitterHandle` (`SpeakerCreateRequest.cs:43`), `LinkedInUrl` (`SpeakerCreateRequest.cs:46`), `GitHubUrl` (`SpeakerCreateRequest.cs:49`), and `WebsiteUrl` (`SpeakerCreateRequest.cs:52`). -- **Why it's built this way**: `required` plus `init` gives compile-time enforcement of the minimum payload while leaving the rest optional, and collapsing request and command into one type keeps a simple create slice to a single message (contrast the child-mutation flows, where the controller builds a distinct command record such as [`AddSpeakerCategoryItemCommand`](#addspeakercategoryitemcommand)). -- **Where it's used**: bound by [`SpeakersController`](group-20-conference-api-grpc.md#speakerscontroller) on the `SpeakersManage`-gated `POST /Speakers` (`SpeakersController.cs:308-312`); validated by [`SpeakerCreateRequestValidator`](#speakercreaterequestvalidator); translated to a domain entity by [`SpeakerCreateRequestMapper`](#speakercreaterequestmapper); handled by [`CreateSpeakerHandler`](#createspeakerhandler). -- **Caveats / not-in-source**: `FullName` is `required` on the contract but never reaches the domain. [`Speaker`](group-17-conference-domain.md#speaker) computes `FullName => $"{FirstName} {LastName}"` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:61`) and [`SpeakerCreateRequestMapper`](#speakercreaterequestmapper) does not pass it to the factory, so a caller-supplied value is accepted and discarded. The same is true of the four profile-link members, which the mapper also drops (`Email`, by contrast, IS forwarded). Nothing on this type says so. - ### SpeakerQuestionAnswerDTOMapper > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.DTOs` · `MMCA.ADC.Conference.Application/Speakers/DTOs/SpeakerQuestionAnswerDTOMapper.cs:12` · Level 8 · class (sealed partial) @@ -2815,7 +3031,17 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **What it is**: the Mapperly mapper for [`SpeakerQuestionAnswer`](group-17-conference-domain.md#speakerquestionanswer) to [`SpeakerQuestionAnswerDTO`](group-17-conference-domain.md#speakerquestionanswerdto), the speaker's answers to the conference's profile questions. Structurally identical to [`SpeakerCategoryItemDTOMapper`](#speakercategoryitemdtomapper): the `[Mapper]` attribute (`SpeakerQuestionAnswerDTOMapper.cs:11`), the partial `MapToDTO` (`SpeakerQuestionAnswerDTOMapper.cs:16`), and the hand-written `MapToDTOs` (`SpeakerQuestionAnswerDTOMapper.cs:19-23`). - **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) closed over the answer entity, its DTO, and `SpeakerQuestionAnswerIdentifierType` (`SpeakerQuestionAnswerDTOMapper.cs:13`), plus Mapperly. - **Concept introduced**: none new; see [`SpeakerCategoryItemDTOMapper`](#speakercategoryitemdtomapper) for the generated-mapper mechanism and why `MapToDTOs` is re-declared on the class. -- **Where it's used**: this is the one mapper in the speaker family with no handler and no query service of its own. It reaches the wire only through composition: [`SpeakerDTOMapper`](#speakerdtomapper) holds it as a `[UseMapper]` field (`SpeakerDTOMapper.cs:26-27`) and the generator calls it while filling [`SpeakerDTO`](group-17-conference-domain.md#speakerdto)`.SpeakerQuestionAnswers` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:60`). Registration is by the convention scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112`), which is why no explicit line exists for it while [`SpeakerCategoryItem`](group-17-conference-domain.md#speakercategoryitem) gets one at `DependencyInjection.cs:101-102`. Covered by `SpeakerQuestionAnswerDTOMapperTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/DTOs/SpeakerQuestionAnswerDTOMapperTests.cs`). +- **Where it's used**: this is the one mapper in the speaker family with no handler and no query service of its own. It reaches the wire only through composition: [`SpeakerDTOMapper`](#speakerdtomapper) holds it as a `[UseMapper]` field (`SpeakerDTOMapper.cs:26-27`) and the generator calls it while filling [`SpeakerDTO`](group-17-conference-domain.md#speakerdto)`.SpeakerQuestionAnswers` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:60`). Registration is by the convention scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`), which is why no explicit line exists for it while [`SpeakerCategoryItem`](group-17-conference-domain.md#speakercategoryitem) gets one at `DependencyInjection.cs:110-111`. The entity's navigation populator is registered on its own at `DependencyInjection.cs:115`, and the comment above it records why that pair is uneven: `SpeakerQuestionAnswer` has no query service today, and registering the populator future-proofs the one that would be added alongside it (`DependencyInjection.cs:113-114`). Covered by `SpeakerQuestionAnswerDTOMapperTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/DTOs/SpeakerQuestionAnswerDTOMapperTests.cs`). + +### UpdateRoomCommand + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.UpdateRoom` · `MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomCommand.cs:15` · Level 8 · record + +- **What it is**: the full-replacement update for one room inside an event. It is the widest command in the event slice: two ids plus the six room fields (`UpdateRoomCommand.cs:15-23`). +- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`UpdateRoomCommand.cs:23`); [`Event`](group-17-conference-domain.md#event), referenced only for the cache prefix; the `EventIdentifierType` and `RoomIdentifierType` aliases. +- **Concept reinforced, the whole-object update command**: `[Rubric §9, API and Contract Design]` assesses whether a mutation contract is unambiguous. Every optional field is declared nullable (`Capacity`, `Floor`, `Location`, `AccessibilityInfo`, `UpdateRoomCommand.cs:20-23`) and is passed through to the domain as sent, so omitting one clears it rather than leaving it alone. That is the defining property of a PUT-shaped command, and it is why [`RoomsController`](group-20-conference-api-grpc.md#roomscontroller) binds it from a complete [`UpdateRoomRequest`](group-20-conference-api-grpc.md#updateroomrequest) body (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/RoomsController.cs:288`). `[Rubric §21, Accessibility]` is touched, though only at the data level: `AccessibilityInfo` (`UpdateRoomCommand.cs:23`) is the field that carries a room's accessibility notes to attendees, so the write path preserves that information as a first-class member rather than folding it into a free-text description. +- **Walkthrough**: a `sealed record` with eight positional parameters (`UpdateRoomCommand.cs:15-23`), each documented individually (`UpdateRoomCommand.cs:7-14`), and the single `CachePrefix` member keyed on the [`Event`](group-17-conference-domain.md#event) type name (`UpdateRoomCommand.cs:25-26`). The prefix is the parent's, not the room's, because a room is a child of the event aggregate and every cached read that could contain it is keyed under `Event`. Note what is absent: no `RowVersion`, so unlike [`UnpublishEventCommand`](#unpublisheventcommand) a room edit is last-write-wins. +- **Where it's used**: constructed by [`RoomsController`](group-20-conference-api-grpc.md#roomscontroller)'s `PUT /Rooms/{id}` action (`RoomsController.cs:285-299`, handler injected at `RoomsController.cs:95`), which evicts the `conference:rooms` output-cache tag and returns `204 No Content` on success (`RoomsController.cs:306-307`, eviction helper at `RoomsController.cs:328-329`); validated by [`UpdateRoomCommandValidator`](#updateroomcommandvalidator); handled by [`UpdateRoomHandler`](#updateroomhandler). ### AddSpeakerCategoryItemCommandValidator @@ -2823,9 +3049,9 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **What it is**: the FluentValidation validator for [`AddSpeakerCategoryItemCommand`](#addspeakercategoryitemcommand). It asserts one thing: the caller actually supplied a category item. - **Depends on**: FluentValidation's `AbstractValidator` (`AddSpeakerCategoryItemCommandValidator.cs:1`, `AddSpeakerCategoryItemCommandValidator.cs:8`) and the `CategoryItemIdentifierType` alias. -- **Concept introduced, the Validating decorator stage**: `[Rubric §24, Forms, Validation and UX Safety]` assesses whether bad input is rejected before it reaches business logic, and `[Rubric §10, Cross-Cutting]` assesses whether that happens uniformly. The handler never calls this class. [`ValidatingCommandDecorator`](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult) runs every registered validator for the command type before the transaction opens ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)), so a malformed command costs no database work. Registration is by the convention scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112`): dropping a validator file next to the command is the entire wiring step, which is the vertical-slice payoff, `[Rubric §5, Vertical Slice]`. +- **Concept introduced, the Validating decorator stage**: `[Rubric §24, Forms, Validation and UX Safety]` assesses whether bad input is rejected before it reaches business logic, and `[Rubric §10, Cross-Cutting]` assesses whether that happens uniformly. The handler never calls this class. [`ValidatingCommandDecorator`](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult) runs every registered validator for the command type before the transaction opens ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)), so a malformed command costs no database work. Registration is by the convention scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`): dropping a validator file next to the command is the entire wiring step, which is the vertical-slice payoff, `[Rubric §5, Vertical Slice]`. - **Walkthrough**: an expression-bodied constructor with a single rule (`AddSpeakerCategoryItemCommandValidator.cs:10-13`): `RuleFor(x => x.CategoryItemId).NotEqual(default(CategoryItemIdentifierType))` with the message "Category item ID is required." Because the identifier alias is a value type, `default` is the "not supplied" sentinel that model binding produces for a missing JSON field, so this rule is what turns a silently omitted field into a 400 rather than a lookup miss deeper in. -- **Why it's built this way**: the validator covers only what can be judged from the message itself. Whether the association is a duplicate is decided against loaded state in the aggregate ([`Speaker`](group-17-conference-domain.md#speaker)`.AddSpeakerCategoryItem` at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:306-313`) rather than restated here. `[Rubric §4, Domain-Driven Design]`: the invariant stays in the aggregate; the validator only guards the shape. +- **Why it's built this way**: the validator covers only what can be judged from the message itself. Whether the association is a duplicate is decided against loaded state in the aggregate ([`Speaker`](group-17-conference-domain.md#speaker)`.AddSpeakerCategoryItem` at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:318-325`) rather than restated here. `[Rubric §4, Domain-Driven Design]`: the invariant stays in the aggregate; the validator only guards the shape. - **Where it's used**: resolved by the Validating decorator for [`AddSpeakerCategoryItemCommand`](#addspeakercategoryitemcommand); covered directly by `AddSpeakerCategoryItemCommandValidatorTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/Validation/SpeakerCommandValidatorTests.cs:6`, instantiating the validator at `SpeakerCommandValidatorTests.cs:8`). ### AddSpeakerCategoryItemHandler @@ -2834,14 +3060,14 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **What it is**: the handler for [`AddSpeakerCategoryItemCommand`](#addspeakercategoryitemcommand), and the canonical "add a child through the aggregate root" shape in the speaker slice: load the speaker with its children, delegate to a domain method, save, log, return the mapped DTO. - **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) closed over the command and `Result` (`AddSpeakerCategoryItemHandler.cs:18`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`SpeakerCategoryItemDTOMapper`](#speakercategoryitemdtomapper), the [`Speaker`](group-17-conference-domain.md#speaker) aggregate and its [`SpeakerCategoryItem`](group-17-conference-domain.md#speakercategoryitem) child, [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error), and `Microsoft.Extensions.Logging`. -- **Concept introduced, loading the children the invariant needs**: `[Rubric §4, Domain-Driven Design]` assesses whether application code mutates child entities directly. It does not here: `speaker.AddSpeakerCategoryItem(command.SpeakerCategoryItemId, command.CategoryItemId)` (`AddSpeakerCategoryItemHandler.cs:33`) is the only write, and the aggregate is where the duplicate rule, the child factory call, and the `SpeakerCategoryItemChanged` domain event live (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:302-326`). The load arguments are chosen by what the aggregate must decide, and the code says so in a comment (`AddSpeakerCategoryItemHandler.cs:27-28`): the join collection has to be included, or the duplicate check at `Speaker.cs:306` runs against an empty in-memory list and a double submit surfaces as a raw unique-index 409 instead of a worded invariant failure. `asTracking: true` is the other half, because an untracked aggregate would make the subsequent save a silent no-op. `[Rubric §8, Data Architecture]`: the in-memory check is still backed at the database level by a filtered unique index on `(SpeakerId, CategoryItemId)` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SpeakerCategoryItemConfiguration.cs:30-32`), so a race that beats the check still cannot write a duplicate row. +- **Concept introduced, loading the children the invariant needs**: `[Rubric §4, Domain-Driven Design]` assesses whether application code mutates child entities directly. It does not here: `speaker.AddSpeakerCategoryItem(command.SpeakerCategoryItemId, command.CategoryItemId)` (`AddSpeakerCategoryItemHandler.cs:33`) is the only write, and the aggregate is where the duplicate rule, the child factory call, and the `SpeakerCategoryItemChanged` domain event live (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:314-338`). The load arguments are chosen by what the aggregate must decide, and the code says so in a comment (`AddSpeakerCategoryItemHandler.cs:27-28`): the join collection has to be included, or the duplicate check at `Speaker.cs:318` runs against an empty in-memory list and a double submit surfaces as a raw unique-index 409 instead of a worded invariant failure. `asTracking: true` is the other half, because an untracked aggregate would make the subsequent save a silent no-op. `[Rubric §8, Data Architecture]`: the in-memory check is still backed at the database level by a unique index on `(SpeakerId, CategoryItemId)` filtered to non-deleted rows (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SpeakerCategoryItemConfiguration.cs:30-32`), so a race that beats the check still cannot write a duplicate row. - **Walkthrough**: a primary constructor taking `unitOfWork`, the concrete `dtoMapper`, and a typed `ILogger` (`AddSpeakerCategoryItemHandler.cs:15-18`). - `HandleAsync` (`AddSpeakerCategoryItemHandler.cs:21-42`) resolves the [`Speaker`](group-17-conference-domain.md#speaker) repository from the unit of work rather than injecting it (`AddSpeakerCategoryItemHandler.cs:25`), then loads with `includes: [nameof(Speaker.SpeakerCategoryItems)]` and `asTracking: true` (`AddSpeakerCategoryItemHandler.cs:29`). - A missing speaker returns `Error.NotFound` stamped with source and target (`AddSpeakerCategoryItemHandler.cs:30-31`), the standard failure shape of the [`Result`](group-01-result-error-handling.md#result) pattern ([ADR-013](https://ivanball.github.io/docs/adr/013-result-pattern.html)). - - The domain call's failure is forwarded verbatim, errors and all (`AddSpeakerCategoryItemHandler.cs:34-35`), so a duplicate association surfaces to the client as the aggregate worded it (`Speaker.CategoryItem.Duplicate`, `Speaker.cs:308-312`). - - Only on success does it `SaveChangesAsync` with `ConfigureAwait(false)` ([ADR-049](https://ivanball.github.io/docs/adr/049-library-configureawait-policy.html)), log through the source-generated `LogCategoryItemAdded` (`AddSpeakerCategoryItemHandler.cs:37-39`, declared at `AddSpeakerCategoryItemHandler.cs:44-45`), and return `Result.Success(dtoMapper.MapToDTO(result.Value!))` (`AddSpeakerCategoryItemHandler.cs:41`). The new child is mapped from the instance the aggregate returned, not re-queried. + - The domain call's failure is forwarded verbatim, errors and all (`AddSpeakerCategoryItemHandler.cs:34-35`), so a duplicate association surfaces to the client as the aggregate worded it (`Speaker.CategoryItem.Duplicate`, `Speaker.cs:320-325`). + - Only on success does it `SaveChangesAsync` with `ConfigureAwait(false)` ([ADR-049](https://ivanball.github.io/docs/adr/049-library-configureawait-policy.html)), log through the source-generated `LogCategoryItemAdded` (`AddSpeakerCategoryItemHandler.cs:37-39`, declared at `AddSpeakerCategoryItemHandler.cs:44-45`), and return `Result.Success(dtoMapper.MapToDTO(result.Value!))` (`AddSpeakerCategoryItemHandler.cs:41`). The new child is mapped from the instance the aggregate returned (`Speaker.cs:337`), not re-queried. - **Why it's built this way**: the handler is pure orchestration because the surrounding decorators already own the rest: validation before it, cache eviction after it ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). `[Rubric §1, SOLID]`: one reason to change, and it is the use case, not the plumbing. `[Rubric §13, Observability and Operability]`: logging goes through the `[LoggerMessage]` partial method, which is why the class is `partial`, and the log is emitted only on the success path. -- **Where it's used**: registered by the convention scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112`) and injected into [`SpeakerCategoryItemsController`](group-20-conference-api-grpc.md#speakercategoryitemscontroller) as `ICommandHandler>` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:49`). Its counterpart is [`RemoveSpeakerCategoryItemHandler`](#removespeakercategoryitemhandler). Covered by `AddSpeakerCategoryItemHandlerTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/AddSpeakerCategoryItemHandlerTests.cs`). +- **Where it's used**: registered by the convention scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`) and injected into [`SpeakerCategoryItemsController`](group-20-conference-api-grpc.md#speakercategoryitemscontroller) as `ICommandHandler>` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:50`). Its counterpart is [`RemoveSpeakerCategoryItemHandler`](#removespeakercategoryitemhandler). Covered by `AddSpeakerCategoryItemHandlerTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/AddSpeakerCategoryItemHandlerTests.cs`). ### CreateQuestionHandler @@ -2849,31 +3075,15 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **What it is**: the handler that creates a question. It is the create template with server-controlled id allocation bolted on, and the only handler in this unit that retries itself. - **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) closed over [`QuestionCreateRequest`](#questioncreaterequest) and `Result` (`CreateQuestionHandler.cs:24`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork); `IServiceScopeFactory` (Microsoft.Extensions.DependencyInjection); [`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype), satisfied by [`QuestionCreateRequestMapper`](#questioncreaterequestmapper); [`QuestionDTOMapper`](#questiondtomapper); [`QuestionInvariants`](group-17-conference-domain.md#questioninvariants); [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error); logging (`CreateQuestionHandler.cs:19-24`). -- **Concept introduced, application-side key allocation in a reserved range, and the retry that makes it safe**: `[Rubric §8, Data Architecture]` assesses whether a key strategy is deliberate and collision-proof. The question id space is shared with an external system: Sessionize assigns ids to imported questions, so the database's identity column cannot be trusted to stay out of the way. The module reserves `999_999_000` to `999_999_999` for user-created questions (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:37`, `QuestionInvariants.cs:40`), and the handler allocates `max + 1` inside it. Two details make that allocation honest. First, the range query passes `ignoreQueryFilters: true` (`CreateQuestionHandler.cs:76`), so a soft-deleted question still reserves its id and a re-created question never reuses a deleted key ([ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html)). Second, `max + 1` computed outside a lock is a race by construction, so the handler expects to lose it occasionally and retries. `[Rubric §29, Resilience]`: the failure mode is anticipated in code rather than left to the caller. +- **Concept introduced, application-side key allocation in a reserved range, and the retry that makes it safe**: `[Rubric §8, Data Architecture]` assesses whether a key strategy is deliberate and collision-proof. The question id space is shared with an external system: Sessionize assigns ids to imported questions, so the database's identity column cannot be trusted to stay out of the way. The module reserves `999_999_000` to `999_999_999` for user-created questions (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:37`, `QuestionInvariants.cs:40`), and the handler allocates `max + 1` inside it. Two details make that allocation honest. First, the range query passes `ignoreQueryFilters: true` (`CreateQuestionHandler.cs:76`), so a soft-deleted question still reserves its id and a re-created question never reuses a deleted key ([ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html)). Second, `max + 1` computed outside a lock is a race by construction, so the handler expects to lose it occasionally and retries. `[Rubric §29, Resilience and Business Continuity]`: the failure mode is anticipated in code rather than left to the caller. - **Walkthrough**: the primary constructor adds `IServiceScopeFactory` and the concrete [`QuestionDTOMapper`](#questiondtomapper) to the usual dependencies (`CreateQuestionHandler.cs:19-24`), and `MaxManualIdAttempts` is a `const int` of 3 (`CreateQuestionHandler.cs:27`). - `HandleAsync` (`CreateQuestionHandler.cs:30-58`) is a bounded retry loop. The first attempt runs against the ambient unit of work (`CreateQuestionHandler.cs:43-44`). Every later attempt creates an `await using` DI scope and resolves a fresh [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) from it (`CreateQuestionHandler.cs:48-50`), and the comment explains why that is not optional: the ambient DbContext still tracks the failed insert, so a clean context is required for the recomputed id to persist. - The `catch` is an exception filter, not a blanket catch: it re-enters the loop only while `attempt < MaxManualIdAttempts` and only for a unique-key violation (`CreateQuestionHandler.cs:52-56`), logging a warning through the generated `LogManualIdCollision`. Anything else propagates. - `CreateCoreAsync` (`CreateQuestionHandler.cs:64-101`) is one attempt: resolve the repository off the passed-in unit of work (`CreateQuestionHandler.cs:69`), read every question in the manual range (`CreateQuestionHandler.cs:73-77`), take `max + 1` or the range start when the range is empty (`CreateQuestionHandler.cs:79-81`), fail with a plain `Error.Failure` when the range is exhausted (`CreateQuestionHandler.cs:83-84`), and then overwrite the caller's id with a `with` expression (`CreateQuestionHandler.cs:87`). Only after that does it call the request mapper (`CreateQuestionHandler.cs:89`), forward mapper errors verbatim (`CreateQuestionHandler.cs:90-91`), add, save (`CreateQuestionHandler.cs:95-96`), log, and return the mapped DTO (`CreateQuestionHandler.cs:98-100`). - `IsUniqueKeyViolation` (`CreateQuestionHandler.cs:108-117`) walks the whole `InnerException` chain looking for the text "duplicate key". The comment states the constraint that forces this (`CreateQuestionHandler.cs:103-107`): the Application layer cannot reference EF Core types, so detection is message-based, and both SQL Server errors 2601 and 2627 carry that phrase. `[Rubric §3, Clean Architecture]`: the layer rule is upheld, and the cost is paid openly, in a documented string match rather than a hidden provider reference. - **Why it's built this way**: the class comment contrasts this slice with the session create path (`CreateQuestionHandler.cs:34-36`): there is no explicit-id branch here, because this handler always overrides the caller id, which is precisely what makes every attempt retryable. A handler that sometimes honored a caller id could not blindly recompute on collision. -- **Where it's used**: registered by the convention scan; injected into [`QuestionsController`](group-20-conference-api-grpc.md#questionscontroller) as `ICommandHandler>` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/QuestionsController.cs:33`) and dispatched by its `POST /Questions` override, which also evicts the output cache (`QuestionsController.cs:91-99`). The whole controller is gated on the `QuestionsManage` permission except the explicitly anonymous read actions (`QuestionsController.cs:30`, `QuestionsController.cs:42`). Covered by `CreateQuestionHandlerTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Questions/UseCases/CreateQuestionHandlerTests.cs`). -- **Caveats / not-in-source**: the range read materializes every manual-range question on every create to compute one maximum. At the ADC's question volume that is negligible, but nothing in the file bounds it, and no comment records the trade-off. - -### LinkUserToSpeakerHandler - -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.LinkUser` · `MMCA.ADC.Conference.Application/Speakers/UseCases/LinkUser/LinkUserToSpeakerHandler.cs:20` · Level 9 · class (sealed partial) - -- **What it is**: the handler for [`LinkUserToSpeakerCommand`](#linkusertospeakercommand). It updates the Conference side of a bidirectional link that spans two databases, and raises the event that updates the other side. -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork); [`Speaker`](group-17-conference-domain.md#speaker); [`SpeakerLinkedToUser`](group-17-conference-domain.md#speakerlinkedtouser); [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error); `Microsoft.Extensions.Logging`. Note what is not injected: there is no publisher service, because the event is raised on the aggregate. -- **Concept introduced, cross-context coordination captured in the same transaction**: `[Rubric §6, CQRS and Event-Driven]` and `[Rubric §7, Microservices Readiness]`. Conference and Identity own separate databases ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)), so there is no foreign key between `Speaker` and `User` and consistency has to flow through events. The load-bearing detail is the ordering: `speaker.AddDomainEvent(new SpeakerLinkedToUser(...))` runs BEFORE the single `SaveChangesAsync` (`LinkUserToSpeakerHandler.cs:54`, then `LinkUserToSpeakerHandler.cs:56`), so the outbox row is written inside the same transaction as the link ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)). The class comment states the failure this removed (`LinkUserToSpeakerHandler.cs:13-18`): with a post-save publish, a crash could commit the Conference-side link and lose the event that sets `User.LinkedSpeakerId`. The [`OutboxProcessor`](group-04-events-outbox.md#outboxprocessor) later routes the row to the registered [`IMessageBus`](group-04-events-outbox.md#imessagebus) transport. This is also why the command carries [`ITransactional`](group-05-cqrs-pipeline.md#itransactional). -- **Walkthrough** - - Primary constructor (`LinkUserToSpeakerHandler.cs:20-22`): [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) and `ILogger`; the declared result is the non-generic [`Result`](group-01-result-error-handling.md#result), so no DTO mapper is needed. - - `HandleAsync` (`LinkUserToSpeakerHandler.cs:25`) resolves the repository (`LinkUserToSpeakerHandler.cs:29`) and loads the speaker with the include-free, tracked overload (`LinkUserToSpeakerHandler.cs:30`), returning `Error.NotFound` with source and target set when it is missing (`LinkUserToSpeakerHandler.cs:31-32`). - - The BR-208 uniqueness guard (`LinkUserToSpeakerHandler.cs:34-46`) is the interesting part: it queries every speaker whose `LinkedUserId` equals the target user (`LinkUserToSpeakerHandler.cs:35-38`) and fails with `Error.Invariant(code: "Speaker.UserAlreadyLinked", ...)` if any OTHER speaker already holds that link (`LinkUserToSpeakerHandler.cs:39-46`). The `s.Id != command.SpeakerId` test is what makes re-linking the same pair a no-op rather than an error. - - `speaker.LinkUser(command.UserId)` (`LinkUserToSpeakerHandler.cs:48`) is the domain decision. The aggregate refuses a speaker that is already linked, returning `Speaker.AlreadyLinked` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:260-274`), so the handler owns only the cross-row rule and the entity owns its own. - - On success it raises the integration event on the aggregate (`LinkUserToSpeakerHandler.cs:54`), saves (`LinkUserToSpeakerHandler.cs:56`), and logs through the generated `LogUserLinkedToSpeaker` (`LinkUserToSpeakerHandler.cs:58`, declared at `LinkUserToSpeakerHandler.cs:64-65`). The domain `Result` is returned either way (`LinkUserToSpeakerHandler.cs:61`), so a rejection reaches the API with its error codes intact. -- **Why it's built this way**: splitting the rules (uniqueness across speakers in the handler, "already linked?" inside the aggregate) keeps each check where the data for it lives, and the pre-save event raise is a deliberate durability fix rather than a style choice. -- **Where it's used**: registered by the Conference application scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112`); invoked through the decorator pipeline by the `PUT /Speakers/{id}/link` action ([`SpeakersController`](group-20-conference-api-grpc.md#speakerscontroller) at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:366-379`). The emitted event is consumed on the Identity side to set `User.LinkedSpeakerId`. This organizer-driven path is also the deliberate fallback for speakers the automatic email match in [`UserRegisteredHandler`](#userregisteredhandler) cannot claim. Covered by `LinkUserToSpeakerHandlerTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/LinkUserToSpeakerHandlerTests.cs`). +- **Where it's used**: registered by the convention scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`); injected into [`QuestionsController`](group-20-conference-api-grpc.md#questionscontroller) as `ICommandHandler>` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/QuestionsController.cs:33`) and dispatched by its `POST /Questions` override, which also evicts the `conference:questions` output-cache tag (`QuestionsController.cs:91-97`, helper at `QuestionsController.cs:130-131`). The whole controller is gated on the `QuestionsManage` permission except the explicitly anonymous read actions (`QuestionsController.cs:30`, `QuestionsController.cs:42`). Covered by `CreateQuestionHandlerTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Questions/UseCases/CreateQuestionHandlerTests.cs`). +- **Caveats / not-in-source**: the range read materializes every manual-range question on every create to compute one maximum. At the conference's question volume that is negligible, but nothing in the file bounds it, and no comment records the trade-off. ### QuestionCreateRequestMapper @@ -2881,10 +3091,10 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **What it is**: the adapter that turns a validated [`QuestionCreateRequest`](#questioncreaterequest) into a [`Question`](group-17-conference-domain.md#question) entity by calling the aggregate's static factory. - **Depends on**: [`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype) closed over `Question`, `QuestionCreateRequest`, and `QuestionIdentifierType` (`QuestionCreateRequestMapper.cs:11-12`); [`Question`](group-17-conference-domain.md#question); [`Result`](group-01-result-error-handling.md#result). -- **Concept introduced, the request mapper as the only door into a factory**: `[Rubric §4, Domain-Driven Design]` assesses whether an entity can be constructed in an invalid state. It cannot: `Question.Create` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/Question.cs:70-97`) combines four invariant checks before it allocates anything (`Question.cs:79-83`) and raises `QuestionChanged` on success (`Question.cs:94`), returning a `Result` throughout. A bad request therefore becomes an error list, never a half-built object. Unlike a DTO mapper, which copies fields outward, a request mapper translates inward and is allowed to fail. Note the class carries no `[Mapper]` attribute: this is hand-written mapping, not Mapperly generation (contrast [`SpeakerCategoryItemDTOMapper`](#speakercategoryitemdtomapper)). `[Rubric §3, Clean Architecture]`: keeping it in its own class is what lets [`CreateQuestionHandler`](#createquestionhandler) depend on the generic interface instead of the factory signature. -- **Walkthrough**: `CreateEntityAsync` (`QuestionCreateRequestMapper.cs:15`) null-guards the request (`QuestionCreateRequestMapper.cs:17`), then returns `Task.FromResult(Question.Create(...))` (`QuestionCreateRequestMapper.cs:19-26`): the work is synchronous and the `Task` exists only to satisfy the async interface. Two things in that call are worth reading twice. The nullable `QuestionEntity` and `QuestionType` are forced with `!` (`QuestionCreateRequestMapper.cs:22-23`), which does not make them non-null; it hands a possible null to invariants that reject it against a closed list (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:68-75`, `QuestionInvariants.cs:83-90`), so an omitted field becomes a worded invariant error rather than a null-reference exception. And `questionSource` is hard-coded to `"User"` (`QuestionCreateRequestMapper.cs:26`), never taken from the request: an API-created question can never claim to have come from Sessionize. +- **Concept introduced, the request mapper as the only door into a factory**: `[Rubric §4, Domain-Driven Design]` assesses whether an entity can be constructed in an invalid state. It cannot: `Question.Create` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/Question.cs:70`) combines four invariant checks before it allocates anything (`Question.cs:80-83`) and raises `QuestionChanged` on success (`Question.cs:94`), returning a `Result` throughout. A bad request therefore becomes an error list, never a half-built object. Unlike a DTO mapper, which copies fields outward, a request mapper translates inward and is allowed to fail. Note the class carries no `[Mapper]` attribute: this is hand-written mapping, not Mapperly generation (contrast [`SpeakerCategoryItemDTOMapper`](#speakercategoryitemdtomapper)). `[Rubric §3, Clean Architecture]`: keeping it in its own class is what lets [`CreateQuestionHandler`](#createquestionhandler) depend on the generic interface instead of the factory signature. +- **Walkthrough**: `CreateEntityAsync` (`QuestionCreateRequestMapper.cs:15`) null-guards the request (`QuestionCreateRequestMapper.cs:17`), then returns `Task.FromResult(Question.Create(...))` (`QuestionCreateRequestMapper.cs:19-26`): the work is synchronous and the `Task` exists only to satisfy the async interface. Two things in that call are worth reading twice. The nullable `QuestionEntity` and `QuestionType` are forced with `!` (`QuestionCreateRequestMapper.cs:22-23`), which does not make them non-null; it hands a possible null to invariants that reject it against a closed list (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:68-69`, `QuestionInvariants.cs:83-84`), so an omitted field becomes a worded invariant error rather than a null-reference exception. And `questionSource` is hard-coded to `"User"` (`QuestionCreateRequestMapper.cs:26`), never taken from the request: an API-created question can never claim to have come from Sessionize. - **Why it's built this way**: delegating every field check to the factory keeps validation in the domain instead of duplicated in the Application layer, and the generic create pipeline can drive any aggregate through the same contract ([ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html)). Pinning the source server-side is the same instinct as never taking an identity from a request body: provenance is not a caller's field to set. -- **Where it's used**: resolved as `IEntityRequestMapper` by [`CreateQuestionHandler`](#createquestionhandler) (`CreateQuestionHandler.cs:22`) and invoked at `CreateQuestionHandler.cs:89`; registered by the module's application scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112`). +- **Where it's used**: resolved as `IEntityRequestMapper` by [`CreateQuestionHandler`](#createquestionhandler) (`CreateQuestionHandler.cs:22`) and invoked at `CreateQuestionHandler.cs:89`; registered by the module's application scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`). ### QuestionCreateRequestValidator @@ -2895,66 +3105,60 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **Concept reinforced, rule composition with `Include`**: `[Rubric §16, Maintainability]` assesses whether the same constraint is written once. Rather than repeat a required-plus-max-length rule in the create validator and again in the update validator, both `Include` the same generic rule set, parameterized by a property selector (`QuestionCreateRequestValidator.cs:10`). `Include` merges the included validator's rules into this one as though they were declared inline, so composition costs nothing at validation time. `[Rubric §24, Forms, Validation and UX Safety]` covers what the rules produce: [`QuestionTextRules`](#questiontextrulest) attaches `NotEmpty` and `MaximumLength(QuestionInvariants.QuestionTextMaxLength)` with stable error codes `Question.QuestionText.Required` and `Question.QuestionText.MaxLength` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Questions/Validation/QuestionValidationRules.cs:17-18`). The ceiling is 1000 characters and it comes from the domain constant (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:13`), the same constant the invariant enforces (`QuestionInvariants.cs:59`), so the form limit and the domain limit cannot drift apart. - **Walkthrough**: a `sealed class` whose whole body is an expression-bodied constructor (`QuestionCreateRequestValidator.cs:9-10`) calling `Include(new QuestionTextRules(p => p.QuestionText))`. - **Why it's built this way**: extracting field rules into shared generic classes is the module-wide convention; add a constraint once and every request type that includes the rule set picks it up. -- **Where it's used**: discovered by the module's validator scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112`) and run by [`ValidatingCommandDecorator`](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult) ahead of [`CreateQuestionHandler`](#createquestionhandler). Covered by `QuestionCreateRequestValidatorTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Questions/Validation/QuestionCreateRequestValidatorTests.cs`). -- **Caveats / not-in-source**: only `QuestionText` is validated here. `QuestionEntity`, `QuestionType`, and `QuestionSource` are left entirely to the domain invariants inside `Question.Create`, so an invalid or missing value arrives as an invariant failure rather than a field-level validation error. - -### SpeakerCreateRequestMapper - -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.Create` · `MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequestMapper.cs:11` · Level 9 · class (sealed) - -- **What it is**: the adapter that turns a validated [`SpeakerCreateRequest`](#speakercreaterequest) into a [`Speaker`](group-17-conference-domain.md#speaker) entity by calling the aggregate's static factory. -- **Depends on**: [`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype) closed over `Speaker`, `SpeakerCreateRequest`, and `SpeakerIdentifierType` (`SpeakerCreateRequestMapper.cs:11-12`); [`Speaker`](group-17-conference-domain.md#speaker); [`Result`](group-01-result-error-handling.md#result). -- **Concept reinforced, the request mapper as the only door into a factory**: see [`QuestionCreateRequestMapper`](#questioncreaterequestmapper) for the pattern. `Speaker.Create` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:112-159`) parses the optional email into an [`Email`](group-02-domain-building-blocks.md#email) value object and bails on a malformed address (`Speaker.cs:122-129`), combines the first- and last-name invariants (`Speaker.cs:131-135`), assigns the client-supplied id or generates one (`Speaker.cs:153`), and raises `SpeakerChanged` (`Speaker.cs:156`). The id fallback carries its own scar: the comment records that the previous `id!.Value` threw "Nullable object must have a value" and killed both Conference's startup seeding and every organizer create (`Speaker.cs:148-152`). `[Rubric §4, Domain-Driven Design]`, `[Rubric §15, Best Practices]`. -- **Walkthrough**: `CreateEntityAsync` (`SpeakerCreateRequestMapper.cs:15`) null-guards (`SpeakerCreateRequestMapper.cs:17`), then returns `Task.FromResult(Speaker.Create(...))` (`SpeakerCreateRequestMapper.cs:19-27`). Eight of the request's members are forwarded (`Id`, `FirstName`, `LastName`, `Email`, `Bio`, `TagLine`, `ProfilePicture`, `IsTopSpeaker`); `FullName` and the four profile-link members are not, because the factory has no parameters for them. -- **Why it's built this way**: delegating every field check to the factory keeps validation in the domain instead of duplicated in the Application layer, and the generic create pipeline can drive any aggregate through the same contract ([ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html)). -- **Where it's used**: injected into [`CreateSpeakerHandler`](#createspeakerhandler) as `IEntityRequestMapper` (`CreateSpeakerHandler.cs:18`) and invoked at `CreateSpeakerHandler.cs:27`; registered by the module's application scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112`). -- **Caveats / not-in-source**: whether the four profile links (`TwitterHandle`, `LinkedInUrl`, `GitHubUrl`, `WebsiteUrl`) are meant to be persisted at create time is Not determinable from source. The mapper simply does not forward them and no later assignment is visible in this file, so a create-then-update through [`UpdateSpeakerCommand`](#updatespeakercommand) is the only path that sets them. - -### SpeakerCreateRequestValidator - -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.Create` · `MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequestValidator.cs:7` · Level 9 · class (sealed) - -- **What it is**: the FluentValidation validator for [`SpeakerCreateRequest`](#speakercreaterequest). It declares no rules of its own; it composes two shared rule sets. -- **Depends on**: FluentValidation's `AbstractValidator` (`SpeakerCreateRequestValidator.cs:1`, `SpeakerCreateRequestValidator.cs:7`); [`SpeakerFirstNameRules`](#speakerfirstnamerulest) and [`SpeakerLastNameRules`](#speakerlastnamerulest) (`SpeakerCreateRequestValidator.cs:2`). -- **Concept reinforced, rule composition with `Include`**: the same mechanism taught on [`QuestionCreateRequestValidator`](#questioncreaterequestvalidator), with one extra layer. Both speaker rule sets extend the framework's [`RequiredStringRules`](group-06-validation.md#requiredstringrulest) with a display label and a ceiling taken from the domain: "First Name" with `SpeakerInvariants.FirstNameMaxLength` and "Last Name" with `LastNameMaxLength`, both 200 (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:14-15` and `SpeakerValidationRules.cs:25-26`; `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/SpeakerInvariants.cs:13` and `SpeakerInvariants.cs:16`), which is the same constant the domain invariant enforces (`SpeakerInvariants.cs:45`, `SpeakerInvariants.cs:50`). `[Rubric §16, Maintainability]`, `[Rubric §24, Forms, Validation and UX Safety]`. -- **Walkthrough**: a `sealed class` whose whole body is a two-line constructor (`SpeakerCreateRequestValidator.cs:9-13`) calling `Include(new SpeakerFirstNameRules(p => p.FirstName))` and the last-name equivalent. -- **Why it's built this way**: extracting field rules into shared generic classes is the module-wide convention; add a constraint once and every request type that includes the rule set picks it up (here, both the create and the update validators). -- **Where it's used**: discovered by the module's validator scan and run by [`ValidatingCommandDecorator`](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult) ahead of [`CreateSpeakerHandler`](#createspeakerhandler). Covered by `SpeakerCreateRequestValidatorTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/Validation/SpeakerCreateRequestValidatorTests.cs`). -- **Caveats / not-in-source**: only the two names are validated here. `Email` is left entirely to the [`Email`](group-02-domain-building-blocks.md#email) value object inside `Speaker.Create` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:122-129`), so a malformed address arrives as an invariant failure rather than a field-level validation error. +- **Where it's used**: discovered by the module's validator scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`) and run by [`ValidatingCommandDecorator`](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult) ahead of [`CreateQuestionHandler`](#createquestionhandler). Covered by `QuestionCreateRequestValidatorTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Questions/Validation/QuestionCreateRequestValidatorTests.cs`). +- **Caveats / not-in-source**: only `QuestionText` is validated here. `QuestionEntity`, `QuestionType`, and `QuestionSource` are left entirely to the domain invariants inside `Question.Create` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/Question.cs:80-83`), so an invalid or missing value arrives as an invariant failure rather than a field-level validation error. ### SpeakerDTOMapper > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.DTOs` · `MMCA.ADC.Conference.Application/Speakers/DTOs/SpeakerDTOMapper.cs:17` · Level 9 · class (sealed partial) - **What it is**: the mapper for the [`Speaker`](group-17-conference-domain.md#speaker) aggregate itself. It composes the two child mappers, and it is the single place where BR-66 redacts speaker email from anyone who is not an organizer. -- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) closed over [`Speaker`](group-17-conference-domain.md#speaker) / [`SpeakerDTO`](group-17-conference-domain.md#speakerdto) / `SpeakerIdentifierType` (`SpeakerDTOMapper.cs:21`); the two sibling mappers [`SpeakerCategoryItemDTOMapper`](#speakercategoryitemdtomapper) and [`SpeakerQuestionAnswerDTOMapper`](#speakerquestionanswerdtomapper); [`ICurrentUserService`](group-08-auth.md#icurrentuserservice); the [`Email`](group-02-domain-building-blocks.md#email) value object; `RoleNames`; and Mapperly (`SpeakerDTOMapper.cs:1-20`). -- **Concept introduced, a redacting mapper, and why the redaction lives here**: `[Rubric §11, Security]` assesses whether a PII rule is enforced at one chokepoint rather than at each call site, and `[Rubric §30, Compliance and Data Governance]` assesses whether personal data has a stated handling rule. `MapToDTO` does not expose the generated mapping directly. It calls the private generated method and then decides: `currentUserService.IsInRole(RoleNames.Organizer) ? dto : dto with { Email = null }` (`SpeakerDTOMapper.cs:33-36`). Because every speaker read path in the module goes through this one mapper (`SpeakerEntityQueryService.cs:19`, `CreateSpeakerHandler.cs:19`, `UpdateSpeakerHandler.cs:17`), there is no endpoint that can accidentally return a speaker email to the public. That single-chokepoint property is also what makes the framework's inherited CSV export a hole worth patching separately: it streams past the DTO mapper entirely, which is why [`SpeakersController`](group-20-conference-api-grpc.md#speakerscontroller) overrides the export action and denies non-privileged callers outright (BR-239, `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:281-305`). Reading the mapper alone would leave you believing the rule was airtight; reading the pair shows where it needed help. +- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) closed over [`Speaker`](group-17-conference-domain.md#speaker) / [`SpeakerDTO`](group-17-conference-domain.md#speakerdto) / `SpeakerIdentifierType` (`SpeakerDTOMapper.cs:21`); the two sibling mappers [`SpeakerCategoryItemDTOMapper`](#speakercategoryitemdtomapper) and [`SpeakerQuestionAnswerDTOMapper`](#speakerquestionanswerdtomapper); [`ICurrentUserService`](group-08-auth.md#icurrentuserservice); the [`Email`](group-02-domain-building-blocks.md#email) value object; [`RoleNames`](group-08-auth.md#rolenames); and Mapperly (`SpeakerDTOMapper.cs:1-20`). +- **Concept introduced, a redacting mapper, and why the redaction lives here**: `[Rubric §11, Security]` assesses whether a PII rule is enforced at one chokepoint rather than at each call site, and `[Rubric §30, Compliance, Privacy and Data Governance]` assesses whether personal data has a stated handling rule. `MapToDTO` does not expose the generated mapping directly. It calls the private generated method and then decides: `currentUserService.IsInRole(RoleNames.Organizer) ? dto : dto with { Email = null }` (`SpeakerDTOMapper.cs:33-36`). Because every speaker read path in the module goes through this one mapper (`SpeakerEntityQueryService.cs:19`, `CreateSpeakerHandler.cs:19`, `UpdateSpeakerHandler.cs:17`), there is no endpoint that can accidentally return a speaker email to the public. That single-chokepoint property is also what makes the framework's inherited CSV export a hole worth patching separately: it streams past the DTO mapper entirely, which is why [`SpeakersController`](group-20-conference-api-grpc.md#speakerscontroller) overrides the export action, gates it on `SpeakersManage`, and denies non-privileged callers outright (BR-239, `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:281-302`). Reading the mapper alone would leave you believing the rule was airtight; reading the pair shows where it needed help. - **Concept introduced, mapper composition with `[UseMapper]`**: each injected child mapper is stored in a private field annotated `[UseMapper]` (`SpeakerDTOMapper.cs:23-27`). That attribute tells the Mapperly generator: when you need to map a `SpeakerCategoryItem` to a `SpeakerCategoryItemDTO` while filling this type, call that mapper instead of generating a second, private copy of the same mapping. The payoff is that [`SpeakerDTO`](group-17-conference-domain.md#speakerdto)'s two child collections (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:57`, `SpeakerDTO.cs:60`) are filled by exactly the same code the child endpoints use, so a speaker read and a `GET /SpeakerCategoryItems` read can never disagree about a child's shape. `[Rubric §2, Design Patterns]`: composition over duplication, expressed declaratively. `[Rubric §16, Maintainability]`: adding a field to a child DTO is one edit, not three. -- **Walkthrough**: five members plus the two fields. +- **Walkthrough**: four methods plus the two fields. - The primary constructor takes the two child mappers and [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) (`SpeakerDTOMapper.cs:17-20`), assigned to the `[UseMapper]` fields (`SpeakerDTOMapper.cs:23-27`). - `MapToDTO` (`SpeakerDTOMapper.cs:30-37`) is hand-written, not generated: null guard, call the generated mapping, apply the BR-66 redaction with a `with` expression on the record DTO. - `MapToDTOGenerated` (`SpeakerDTOMapper.cs:46`) is the `private partial` the generator fills. Making the generated method private and wrapping it is the mechanism that lets the redaction be unskippable; a caller cannot reach the unredacted projection. - `MapToDTOs` (`SpeakerDTOMapper.cs:40-44`) maps a collection through the same public `MapToDTO`, so the rule applies per row on list reads too. - - `NullableEmailToString` (`SpeakerDTOMapper.cs:49`) is a private conversion helper Mapperly picks up to turn the [`Email`](group-02-domain-building-blocks.md#email) value object into the DTO's `string?`. A value object on the entity and a plain string on the contract is exactly the kind of gap that would otherwise be a build error. + - `NullableEmailToString` (`SpeakerDTOMapper.cs:49`) is a private conversion helper Mapperly picks up to turn the [`Email`](group-02-domain-building-blocks.md#email) value object into the DTO's `string?` (`SpeakerDTO.cs:27`). A value object on the entity and a plain string on the contract is exactly the kind of gap that would otherwise be a build error. - **Why it's built this way**: children are mapped by their owners' mappers, so the DTO graph is assembled from single-purpose pieces the DI container already has ([ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html)). What the child collections actually contain at map time is decided earlier, by [`SpeakerNavigationPopulator`](#speakernavigationpopulator) ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html)): this mapper copies what was loaded and never triggers a query itself. -- **Where it's used**: injected as a concrete type into [`CreateSpeakerHandler`](#createspeakerhandler) (`CreateSpeakerHandler.cs:19`) and [`UpdateSpeakerHandler`](#updatespeakerhandler) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/UseCases/Update/UpdateSpeakerHandler.cs:17`) for the write-path response DTO, and into [`SpeakerEntityQueryService`](#speakerentityqueryservice) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/SpeakerEntityQueryService.cs:19`), the speaker-specific query service registered at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:63`, which every speaker read endpoint goes through ([ADR-034](https://ivanball.github.io/docs/adr/034-generic-entity-query-layer.html)). Covered by `SpeakerDTOMapperTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/DTOs/SpeakerDTOMapperTests.cs`). +- **Where it's used**: injected as a concrete type into [`CreateSpeakerHandler`](#createspeakerhandler) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/UseCases/Create/CreateSpeakerHandler.cs:19`) and [`UpdateSpeakerHandler`](#updatespeakerhandler) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/UseCases/Update/UpdateSpeakerHandler.cs:17`) for the write-path response DTO, and into [`SpeakerEntityQueryService`](#speakerentityqueryservice) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/SpeakerEntityQueryService.cs:19`), the speaker-specific query service registered at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:67`, which every speaker read endpoint goes through ([ADR-034](https://ivanball.github.io/docs/adr/034-generic-entity-query-layer.html)). Covered by `SpeakerDTOMapperTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/DTOs/SpeakerDTOMapperTests.cs`). - **Caveats / not-in-source**: the redaction depends on [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) resolving a real caller. In a background or system context with no principal, `IsInRole` returning false means the mapper redacts, which fails closed; that is the safe direction, but nothing in this file states it as an intended behavior. -### CreateSpeakerHandler +### UpdateRoomCommandValidator -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.Create` · `MMCA.ADC.Conference.Application/Speakers/UseCases/Create/CreateSpeakerHandler.cs:16` · Level 10 · class (sealed partial) +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.UpdateRoom` · `MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomCommandValidator.cs:7` · Level 9 · class (sealed) -- **What it is**: the handler that creates a speaker. It is deliberately the thinnest handler in this unit: map, add, save, project. Compare it with [`CreateQuestionHandler`](#createquestionhandler), which needs a reserved id range and a collision retry; a speaker id is a client-assigned GUID, so none of that machinery is required here. -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) closed over [`SpeakerCreateRequest`](#speakercreaterequest) and `Result` (`CreateSpeakerHandler.cs:20`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork); [`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype), satisfied by [`SpeakerCreateRequestMapper`](#speakercreaterequestmapper); [`SpeakerDTOMapper`](#speakerdtomapper); [`SpeakerDTO`](group-17-conference-domain.md#speakerdto); [`Result`](group-01-result-error-handling.md#result); `Microsoft.Extensions.Logging`. -- **Concept reinforced, the generic create slice end to end**: `[Rubric §5, Vertical Slice]`: the request IS the command, so the class implements `ICommandHandler>` (`CreateSpeakerHandler.cs:20`) and the four types of the slice sit in one folder. `[Rubric §3, Clean Architecture]`: the handler orchestrates a request mapper, a repository, and a DTO mapper without embedding any construction logic of its own. `[Rubric §11, Security]` reaches it indirectly through the response: [`SpeakerDTOMapper`](#speakerdtomapper) redacts the speaker's email for non-organizers (BR-66, `SpeakerDTOMapper.cs:36`), so even the create response obeys the same rule as every read. -- **Walkthrough** - - Primary constructor (`CreateSpeakerHandler.cs:16-20`): unit of work, the request mapper resolved by its generic interface, the DTO mapper as a concrete type, and `ILogger`. - - `HandleAsync` (`CreateSpeakerHandler.cs:23-40`) calls `requestMapper.CreateEntityAsync(command, cancellationToken)` first (`CreateSpeakerHandler.cs:27`) and returns the mapper's errors verbatim on failure (`CreateSpeakerHandler.cs:28-29`), so a domain invariant violation surfaces as a `Result` failure rather than an exception. - - It takes `result.Value!` (`CreateSpeakerHandler.cs:31`), resolves `IRepository` from the unit of work rather than injecting it (`CreateSpeakerHandler.cs:32`), then awaits `repository.AddAsync(...)` and `unitOfWork.SaveChangesAsync(...)` with `ConfigureAwait(false)` (`CreateSpeakerHandler.cs:34-35`, [ADR-049](https://ivanball.github.io/docs/adr/049-library-configureawait-policy.html)). Resolving the repository through [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) rather than constructor-injecting [`IRepository`](group-07-persistence-ef-core.md#irepositorytentity-tidentifiertype) is the framework rule that keeps one tracked context per scope. - - It logs through the generated `LogSpeakerCreated`, which records the id and the computed full name (`CreateSpeakerHandler.cs:37`, declared at `CreateSpeakerHandler.cs:42-43`), and returns `Result.Success(dtoMapper.MapToDTO(entity))` (`CreateSpeakerHandler.cs:39`). -- **Why it's built this way**: validation, cache invalidation, and transaction scope are all handled by the pipeline decorators wrapped around this handler (declared by the markers on [`SpeakerCreateRequest`](#speakercreaterequest)), which is exactly why the create logic can reduce to four statements ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). `[Rubric §1, SOLID]`: the handler's one reason to change is the use case. -- **Where it's used**: registered by the Conference application scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112`); injected into [`SpeakersController`](group-20-conference-api-grpc.md#speakerscontroller) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:46`) and dispatched by its `POST /Speakers` override, which delegates to the base controller and then evicts the speakers output cache (`SpeakersController.cs:308-317`). The action is gated on the `SpeakersManage` permission (`SpeakersController.cs:309`). Covered by `CreateSpeakerHandlerTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/CreateSpeakerHandlerTests.cs`). -- **Caveats / not-in-source**: nothing here guards against a caller re-submitting an id that already exists; the insert simply fails on the primary key and surfaces through the shared exception handling. The Sessionize import path relies on that, since it supplies the Sessionize GUID as `Id`, but no comment in this file records the expectation. +- **What it is**: the FluentValidation validator for [`UpdateRoomCommand`](#updateroomcommand). It declares no rule of its own; its entire body composes six shared rule sets (`UpdateRoomCommandValidator.cs:11-16`). +- **Depends on**: FluentValidation's `AbstractValidator` (NuGet); [`RoomNameRules`](#roomnamerulest), [`RoomSortRules`](#roomsortrulest), [`RoomCapacityRules`](#roomcapacityrulest), [`RoomFloorRules`](#roomfloorrulest), [`RoomLocationRules`](#roomlocationrulest), and [`RoomAccessibilityInfoRules`](#roomaccessibilityinforulest) (`UpdateRoomCommandValidator.cs:2`). +- **Concept reinforced, rule composition with `Include`**: `[Rubric §16, Maintainability]` assesses whether one constraint is written once: rather than restate the room constraints in the add validator and again here, both `Include` the same generic rule sets, parameterized by a property selector. `Include` merges the included validator's rules in as though they had been declared inline, so composition costs nothing at validation time. `[Rubric §24, Forms, Validation and UX Safety]` covers what those rules produce: each carries a human message and a stable error code, for example `Room.Name.Required` and `Room.Name.MaxLength` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:17-18`), so a client can branch on the code instead of parsing English. The ceilings come from the domain, not from the validator: `EventInvariants.RoomNameMaxLength` is 255, `RoomFloorMaxLength` 100, `RoomLocationMaxLength` 255, and `RoomAccessibilityInfoMaxLength` 500 (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:43`, `EventInvariants.cs:46`, `EventInvariants.cs:49`, `EventInvariants.cs:52`), which are the same constants the domain invariant enforces (`EventInvariants.cs:143`), so the form limit and the domain limit cannot drift apart. +- **Walkthrough**: a `sealed class` whose whole body is a six-line constructor (`UpdateRoomCommandValidator.cs:9-17`). `RoomNameRules` is required plus max length (`RoomValidationRules.cs:12-18`); `RoomSortRules` demands a sort greater than or equal to zero (`RoomValidationRules.cs:25-30`); `RoomCapacityRules` demands a positive capacity but only when one is supplied, via `.When(x => selector.Compile()(x) is not null)` (`RoomValidationRules.cs:37-43`); the floor, location, and accessibility rule sets are max-length only, so a null value passes (`RoomValidationRules.cs:51-56`, `RoomValidationRules.cs:64-69`, `RoomValidationRules.cs:77-82`). +- **Why it's built this way**: extracting field rules into shared generic classes is the module-wide convention; add a constraint once and every command that includes the rule set picks it up. Running them ahead of the transaction is the pipeline's job: [`ValidatingCommandDecorator`](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult) sits outside [`ITransactional`](group-05-cqrs-pipeline.md#itransactional) ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)), so a malformed room never opens a database transaction. +- **Where it's used**: discovered by the module's validator scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`) and run by [`ValidatingCommandDecorator`](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult) ahead of [`UpdateRoomHandler`](#updateroomhandler). Compare [`AddRoomCommandValidator`](#addroomcommandvalidator), which includes the same rule sets for the add path. Covered by `UpdateRoomCommandValidatorTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/Validation/CommandValidatorTests.cs:116-118`). +- **Caveats / not-in-source**: name uniqueness within an event is **not** validated here. It cannot be: the rule needs the event's other rooms, so it lives in the aggregate as `EnsureRoomNameIsUnique` and comes back as the invariant error `Event.Room.Duplicate` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:716-733`, code at `Event.cs:728`). + +### UpdateRoomHandler + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.UpdateRoom` · `MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomHandler.cs:13` · Level 9 · class (sealed partial) + +- **What it is**: the handler for [`UpdateRoomCommand`](#updateroomcommand). Same load-delegate-save template as [`RemoveRoomHandler`](#removeroomhandler), with all six room fields forwarded to the aggregate. +- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) closed over the command and the non-generic [`Result`](group-01-result-error-handling.md#result) (`UpdateRoomHandler.cs:15`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork); the [`Event`](group-17-conference-domain.md#event) aggregate and its [`Room`](group-17-conference-domain.md#room) child; [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error); `Microsoft.Extensions.Logging`. No DTO mapper is injected, because the update returns no body. +- **Concept reinforced, the handler as a pass-through to the root**: `[Rubric §4, Domain-Driven Design]` assesses where the rules live, and this handler is the clearest example in the unit of them living elsewhere: it re-checks nothing that [`UpdateRoomCommandValidator`](#updateroomcommandvalidator) already checked and decides nothing the aggregate decides. `Event.UpdateRoom` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:411-436`) resolves the room or returns not-found (`Event.cs:420-423`), enforces name uniqueness excluding the room being edited (`Event.cs:425-427`), forwards to `room.Update(...)` for the field-level invariants (`Event.cs:429-431`), and raises `RoomChanged` with `DomainEntityState.Updated` only after all three pass (`Event.cs:433`). `[Rubric §3, Clean Architecture]`: the handler touches abstractions only, with no EF type in sight. +- **Walkthrough**: primary constructor (`UpdateRoomHandler.cs:13-15`); `HandleAsync` (`UpdateRoomHandler.cs:18`) resolves the repository (`UpdateRoomHandler.cs:22`), loads with `includes: [nameof(Event.Rooms)]` and `asTracking: true` (`UpdateRoomHandler.cs:23-27`) because both the uniqueness check and the mutation need the sibling rooms in memory and tracked, returns `Error.NotFound` when the event is missing (`UpdateRoomHandler.cs:28-29`), forwards the seven remaining command members positionally to `entity.UpdateRoom(...)` (`UpdateRoomHandler.cs:31-38`), and saves plus logs only on success (`UpdateRoomHandler.cs:39-43`, generated log method at `UpdateRoomHandler.cs:48-49`). The domain `Result` is returned unchanged (`UpdateRoomHandler.cs:45`), so a rejection reaches the API with its error codes intact. +- **Why it's built this way**: the uniqueness rule is the reason the whole `Rooms` collection is loaded for what looks like a single-row edit. It is an aggregate-scoped invariant, so it can only be answered with the aggregate in hand; pushing it to a database index alone would surface as an opaque constraint violation instead of the typed `Event.Room.Duplicate` error (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:728`). +- **Where it's used**: registered by the module scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`); invoked through the decorator pipeline by [`RoomsController`](group-20-conference-api-grpc.md#roomscontroller)'s `PUT /Rooms/{id}` action (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/RoomsController.cs:285-299`), which returns `204 No Content` and evicts the rooms output cache on success (`RoomsController.cs:306-307`). Covered by `UpdateRoomHandlerTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/UseCases/UpdateRoomHandlerTests.cs`). + +### LinkUserToSpeakerCommand + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.LinkUser` · `MMCA.ADC.Conference.Application/Speakers/UseCases/LinkUser/LinkUserToSpeakerCommand.cs:13` · Level 8 · record + +- **What it is**: the write message an organizer sends to attach an application account to a speaker profile (BR-209). Two ids and nothing else: `SpeakerId` and `UserId` (`LinkUserToSpeakerCommand.cs:13`). +- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) and [`ITransactional`](group-05-cqrs-pipeline.md#itransactional), both markers implemented at `LinkUserToSpeakerCommand.cs:13`; [`Speaker`](group-17-conference-domain.md#speaker), referenced only to build the cache prefix; and the module identifier aliases `SpeakerIdentifierType` (a `System.Guid`, `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:19`) and `UserIdentifierType` (owned by Identity). +- **Concept introduced, the command that declares its own transaction**: `[Rubric §6, CQRS and Event-Driven]` assesses whether a write is modeled as an explicit single-purpose message: this record carries intent only, and the two marker interfaces tell the pipeline how to run it. `[Rubric §10, Cross-Cutting]` assesses whether such concerns are declared rather than hand-coded. Implementing [`ITransactional`](group-05-cqrs-pipeline.md#itransactional) opts the message into [`TransactionalCommandDecorator`](group-05-cqrs-pipeline.md#transactionalcommanddecoratortcommand-tresult), the innermost decorator in the registered chain (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:115-121`), and the XML comment states the reason plainly (`LinkUserToSpeakerCommand.cs:7-8`): the Speaker link and the outbox row that carries the cross-context User update must commit together or not at all. Implementing [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) with `CachePrefix => $"{typeof(Speaker).FullName}:"` (`LinkUserToSpeakerCommand.cs:16`) is what makes [`CachingCommandDecorator`](group-05-cqrs-pipeline.md#cachingcommanddecoratortcommand-tresult) drop every cached read keyed under the `Speaker` type after a successful link. +- **Walkthrough**: a `sealed record` with a two-parameter positional constructor (`LinkUserToSpeakerCommand.cs:13`) and one member, the expression-bodied `CachePrefix` (`LinkUserToSpeakerCommand.cs:15-16`). The cross-module identifier pairing is the notable part: `UserIdentifierType` is Identity's alias, carried here as a plain scalar because the two modules own separate databases and there is no foreign key to point at ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html), [ADR-048](https://ivanball.github.io/docs/adr/048-primitive-identifier-type-aliases.html)). +- **Why it's built this way**: the two markers move durability and cache eviction out of the handler and into the pipeline, so [`LinkUserToSpeakerHandler`](#linkusertospeakerhandler) reads as pure domain orchestration ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). Records give value equality and immutability for free. +- **Where it's used**: constructed by the `PUT /Speakers/{id}/link` action from a [`LinkUserRequest`](group-17-conference-domain.md#linkuserrequest) body ([`SpeakersController`](group-20-conference-api-grpc.md#speakerscontroller) at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:372`, handler injected at `SpeakersController.cs:49`, permission-gated at `SpeakersController.cs:365`); handled by [`LinkUserToSpeakerHandler`](#linkusertospeakerhandler). Its inverse is [`UnlinkUserFromSpeakerCommand`](#unlinkuserfromspeakercommand). ### RemoveSpeakerCategoryItemCommand @@ -2962,10 +3166,22 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur - **What it is**: the mirror image of [`AddSpeakerCategoryItemCommand`](#addspeakercategoryitemcommand), the message that detaches one category-item tag from a speaker. Two positional parameters: the owning `SpeakerId` and the `SpeakerCategoryItemId` of the join entity to remove (`RemoveSpeakerCategoryItemCommand.cs:12-14`). - **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating), the only interface it implements (`RemoveSpeakerCategoryItemCommand.cs:14`); the [`Speaker`](group-17-conference-domain.md#speaker) domain type, referenced solely to build the cache prefix; and the `SpeakerIdentifierType` (a GUID) and `SpeakerCategoryItemIdentifierType` (an int) module aliases ([ADR-048](https://ivanball.github.io/docs/adr/048-primitive-identifier-type-aliases.html)). -- **Concept reinforced, the remove command addresses the join row, not the tag**: the second parameter is the identity of the *association* (`SpeakerCategoryItem`), not of the `CategoryItem` being untagged. That asymmetry with the Add command (which takes the `CategoryItemId` it wants to attach) is deliberate: after the association exists, the REST resource the client holds is the junction row, so a delete names it directly (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:232-239`). `[Rubric §9, API and Contract Design]` assesses whether the contract addresses the thing the caller actually has a handle on. -- **Walkthrough**: a `sealed record` whose whole body is one expression-bodied member, `CachePrefix => $"{typeof(Speaker).FullName}:"` (`RemoveSpeakerCategoryItemCommand.cs:16-17`). That is the same prefix every other speaker write declares, so an untag flushes the whole cached speaker read surface rather than trying to surgically evict the one nested collection ([ADR-026](https://ivanball.github.io/docs/adr/026-caching-strategy.html)). Note the absence of [`ITransactional`](group-05-cqrs-pipeline.md#itransactional): the write touches a single aggregate in a single `SaveChangesAsync`, so there is nothing to keep atomic across contexts (contrast [`UnlinkUserFromSpeakerCommand`](#unlinkuserfromspeakercommand), immediately below). +- **Concept reinforced, the remove command addresses the join row, not the tag**: the second parameter is the identity of the *association* ([`SpeakerCategoryItem`](group-17-conference-domain.md#speakercategoryitem)), not of the category item being untagged. That asymmetry with the Add command (which takes the `CategoryItemId` it wants to attach) is deliberate: once the association exists, the REST resource the client holds is the junction row, so a delete names it directly and takes the speaker id from the query string (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:240-247`). `[Rubric §9, API and Contract Design]` assesses whether the contract addresses the thing the caller actually has a handle on. +- **Walkthrough**: a `sealed record` whose whole body is one expression-bodied member, `CachePrefix => $"{typeof(Speaker).FullName}:"` (`RemoveSpeakerCategoryItemCommand.cs:16-17`). That is the same prefix every other speaker write declares, so an untag flushes the whole cached speaker read surface rather than trying to surgically evict the one nested collection ([ADR-026](https://ivanball.github.io/docs/adr/026-caching-strategy.html)). Note the absence of [`ITransactional`](group-05-cqrs-pipeline.md#itransactional): the write touches a single aggregate in a single `SaveChangesAsync`, so there is nothing to keep atomic across contexts (contrast [`UnlinkUserFromSpeakerCommand`](#unlinkuserfromspeakercommand), below). - **Why it's built this way**: the message declares its cross-cutting effects and the pipeline applies them ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)); the handler stays free of cache code. `[Rubric §10, Cross-Cutting]`. -- **Where it's used**: constructed by the `DELETE /SpeakerCategoryItems/{id}` action of [`SpeakerCategoryItemsController`](group-20-conference-api-grpc.md#speakercategoryitemscontroller), which takes the join id from the route and the speaker id from the query string (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:232-240`, handler injected at `SpeakerCategoryItemsController.cs:50`); handled by [`RemoveSpeakerCategoryItemHandler`](#removespeakercategoryitemhandler). +- **Where it's used**: constructed by the `DELETE /SpeakerCategoryItems/{id}` action of [`SpeakerCategoryItemsController`](group-20-conference-api-grpc.md#speakercategoryitemscontroller) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:240-247`, handler injected at `SpeakerCategoryItemsController.cs:51`), a controller gated on the `SpeakersManage` permission at the class level rather than per action (`SpeakerCategoryItemsController.cs:47`, [ADR-020](https://ivanball.github.io/docs/adr/020-permission-based-authorization.html)); handled by [`RemoveSpeakerCategoryItemHandler`](#removespeakercategoryitemhandler). + +### SpeakerCreateRequest + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.Create` · `MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequest.cs:10` · Level 8 · record + +- **What it is**: the create-request DTO for a conference speaker. Like [`QuestionCreateRequest`](#questioncreaterequest) it doubles as the command: [`CreateSpeakerHandler`](#createspeakerhandler) implements `ICommandHandler>` directly against this type, so there is no separate `CreateSpeakerCommand`. +- **Depends on**: [`ICreateRequest`](group-05-cqrs-pipeline.md#icreaterequest), an empty marker used as the generic constraint on the request-mapper contract (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICreateRequest.cs:8-10`); [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating); the [`Speaker`](group-17-conference-domain.md#speaker) type for the cache prefix; the `SpeakerIdentifierType` alias (`SpeakerCreateRequest.cs:10`, `SpeakerCreateRequest.cs:13`). +- **Concept reinforced, the request-as-command shape**: `[Rubric §9, API and Contract Design]` assesses whether the wire contract is an explicit, versionable type rather than the domain entity leaking outward: the controller binds this record straight from the request body (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:311`) and it is also the fourth generic argument of the base controller (`SpeakersController.cs:59-60`). `[Rubric §5, Vertical Slice]` applies to the folder: request, mapper, validator, and handler for Create all sit in `Speakers/UseCases/Create`. Marking it [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) means a successful create evicts the cached speaker reads exactly like the command records above. +- **Walkthrough**: a `record class` (not `sealed`) with `init`-only members. `CachePrefix` (`SpeakerCreateRequest.cs:12-13`) is the invalidation tag. `Id` (`SpeakerCreateRequest.cs:15-16`) is a `SpeakerIdentifierType`, and its comment records that it is Sessionize-assigned: the caller supplies the key rather than the database generating it, which is what lets an import be idempotent. Three members are `required`, so the record cannot be constructed without them: `FirstName` (`SpeakerCreateRequest.cs:19`), `LastName` (`SpeakerCreateRequest.cs:22`), and `FullName` (`SpeakerCreateRequest.cs:25`). The rest are optional `init` members: `Email` (`SpeakerCreateRequest.cs:28`), `Bio` (`SpeakerCreateRequest.cs:31`), `TagLine` (`SpeakerCreateRequest.cs:34`), `ProfilePicture` (`SpeakerCreateRequest.cs:37`), the `IsTopSpeaker` flag (`SpeakerCreateRequest.cs:40`), and the four profile links `TwitterHandle` (`SpeakerCreateRequest.cs:43`), `LinkedInUrl` (`SpeakerCreateRequest.cs:46`), `GitHubUrl` (`SpeakerCreateRequest.cs:49`), and `WebsiteUrl` (`SpeakerCreateRequest.cs:52`). +- **Why it's built this way**: `required` plus `init` gives compile-time enforcement of the minimum payload while leaving the rest optional, and collapsing request and command into one type keeps a simple create slice to a single message (contrast the child-mutation flows, where the controller builds a distinct command record such as [`AddSpeakerCategoryItemCommand`](#addspeakercategoryitemcommand)). +- **Where it's used**: bound by [`SpeakersController`](group-20-conference-api-grpc.md#speakerscontroller) on the `SpeakersManage`-gated `POST /Speakers` (`SpeakersController.cs:308-317`); validated by [`SpeakerCreateRequestValidator`](#speakercreaterequestvalidator); translated to a domain entity by [`SpeakerCreateRequestMapper`](#speakercreaterequestmapper); handled by [`CreateSpeakerHandler`](#createspeakerhandler). +- **Caveats / not-in-source**: `FullName` is `required` on the contract but never reaches the domain. [`Speaker`](group-17-conference-domain.md#speaker) computes `FullName => $"{FirstName} {LastName}"` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:61`) and [`SpeakerCreateRequestMapper`](#speakercreaterequestmapper) does not pass it to the factory, so a caller-supplied value is accepted and discarded. It is the only member of this record the mapper drops; every other one, the four profile links included, is forwarded (`SpeakerCreateRequestMapper.cs:19-31`). Nothing on this type says so. ### UnlinkUserFromSpeakerCommand @@ -2983,574 +3199,878 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Users.IntegrationEventHandlers` · `MMCA.ADC.Conference.Application/Users/IntegrationEventHandlers/UserRegisteredHandler.cs:40` · Level 8 · class (sealed partial) - **What it is**: the Conference-side subscriber to Identity's [`UserRegistered`](group-24-identity-module.md#userregistered) integration event. When someone registers an account, this handler tries to find the speaker profile that belongs to them and links the two (BR-207), so a speaker who signs up sees their own sessions without an organizer lifting a finger. -- **Depends on**: [`IIntegrationEventHandler`](group-04-events-outbox.md#iintegrationeventhandlerin-tintegrationevent) closed over [`UserRegistered`](group-24-identity-module.md#userregistered) (`UserRegisteredHandler.cs:42`); `IServiceScopeFactory` (BCL DI); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) and [`IRepository`](group-07-persistence-ef-core.md#irepositorytentity-tidentifiertype), both resolved from the created scope; [`IEventBus`](group-04-events-outbox.md#ieventbus); the [`Speaker`](group-17-conference-domain.md#speaker) aggregate; [`SpeakerLinkedToUser`](group-17-conference-domain.md#speakerlinkedtouser); the [`Email`](group-02-domain-building-blocks.md#email) value object; `Microsoft.Extensions.Logging`. -- **Concept introduced, the integration-event consumer that opens its own scope**: an [`IIntegrationEventHandler`](group-04-events-outbox.md#iintegrationeventhandlerin-tintegrationevent) is registered as a **singleton** by the framework's convention scan (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:126-130`), which means it cannot hold a scoped `IUnitOfWork` as a constructor dependency: a singleton capturing a scoped EF context is the classic captive-dependency bug. The handler therefore takes an `IServiceScopeFactory` and opens one scope per event (`UserRegisteredHandler.cs:51-53`), resolving the unit of work and the event bus inside it, then disposes the scope with the event. The class comment states the lifetime rule outright (`UserRegisteredHandler.cs:35-38`). `[Rubric §6, CQRS and Event-Driven]` assesses whether consumers are reliable and idempotent; `[Rubric §7, Microservices Readiness]`: Conference reacts to an Identity fact without referencing Identity's domain, only its published event contract. -- **Concept introduced, letting the delivery mechanism own retry**: the whole body sits inside `try` with an exception *filter* rather than a catch block that swallows: `catch (Exception ex) when (LogAndRethrow(ex, ...))` (`UserRegisteredHandler.cs:101`). `LogAndRethrow` logs and returns `false` (`UserRegisteredHandler.cs:124-128`), so the filter never matches and the exception keeps propagating; the `throw` inside the block is unreachable by construction (`UserRegisteredHandler.cs:103-104`). The remarks explain the fix this replaced (`UserRegisteredHandler.cs:112-122`): the handler used to swallow everything, so a single transient database fault lost the auto-link permanently, because delivery had already been acknowledged. Propagating instead hands the decision to the transport, which is built for it: the outbox retries to its limit and then dead-letters, and MassTransit redelivers and then moves the message to the error queue ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)). Retry is safe because the operation is idempotent (see the walkthrough), which is the same reasoning [ADR-021](https://ivanball.github.io/docs/adr/021-consumer-inbox-idempotency.html) formalizes for consumers. `[Rubric §29, Resilience]` assesses whether failures are recoverable rather than silently absorbed; this is the difference between an alertable dead letter and a lost link nobody notices. +- **Depends on**: [`IIntegrationEventHandler`](group-04-events-outbox.md#iintegrationeventhandlerin-tintegrationevent) closed over [`UserRegistered`](group-24-identity-module.md#userregistered) (`UserRegisteredHandler.cs:42`); `IServiceScopeFactory` (BCL DI); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) and [`IEntityQuerier`](group-07-persistence-ef-core.md#ientityqueriertentity-tidentifiertype), the read-side surface both private helpers take (`UserRegisteredHandler.cs:131`, `UserRegisteredHandler.cs:168`); [`IEventBus`](group-04-events-outbox.md#ieventbus); the [`Speaker`](group-17-conference-domain.md#speaker) aggregate; [`SpeakerLinkedToUser`](group-17-conference-domain.md#speakerlinkedtouser); the [`Email`](group-02-domain-building-blocks.md#email) value object; `Microsoft.Extensions.Logging`. +- **Concept introduced, the integration-event consumer that opens its own scope**: an [`IIntegrationEventHandler`](group-04-events-outbox.md#iintegrationeventhandlerin-tintegrationevent) is registered as a **singleton** by the framework's convention scan (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:151-155`), which means it cannot hold a scoped `IUnitOfWork` as a constructor dependency: a singleton capturing a scoped EF context is the classic captive-dependency bug. The handler therefore takes an `IServiceScopeFactory` and opens one scope per event (`UserRegisteredHandler.cs:51-53`), resolving the unit of work and the event bus inside it, then disposes the scope with the event. The class comment states the lifetime rule outright (`UserRegisteredHandler.cs:35-38`). `[Rubric §6, CQRS and Event-Driven]` assesses whether consumers are reliable and idempotent; `[Rubric §7, Microservices Readiness]`: Conference reacts to an Identity fact without referencing Identity's domain, only its published event contract. +- **Concept introduced, letting the delivery mechanism own retry**: the whole body sits inside `try` with an exception *filter* rather than a catch block that swallows: `catch (Exception ex) when (LogAndRethrow(ex, ...))` (`UserRegisteredHandler.cs:101`). `LogAndRethrow` logs and returns `false` (`UserRegisteredHandler.cs:124-128`), so the filter never matches and the exception keeps propagating; the `throw` inside the block is unreachable by construction (`UserRegisteredHandler.cs:103-104`). The remarks explain the fix this replaced (`UserRegisteredHandler.cs:111-123`): the handler used to swallow everything, so a single transient database fault lost the auto-link permanently, because delivery had already been acknowledged. Propagating instead hands the decision to the transport, which is built for it: the outbox retries to its limit and then dead-letters, and MassTransit redelivers and then moves the message to the error queue ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)). Retry is safe because the operation is idempotent (see the walkthrough), which is the same reasoning [ADR-021](https://ivanball.github.io/docs/adr/021-consumer-inbox-idempotency.html) formalizes for consumers. `[Rubric §29, Resilience]` assesses whether failures are recoverable rather than silently absorbed; this is the difference between an alertable dead letter and a lost link nobody notices. - **Concept introduced, an identity match must use a fact the registrant cannot forge**: there is exactly ONE match strategy, and the reason the others were removed is the interesting part. `TryMatchByEmailAsync` (`UserRegisteredHandler.cs:130-158`) parses the registered address through the [`Email`](group-02-domain-building-blocks.md#email) value object, bails with a warning if it is malformed (`UserRegisteredHandler.cs:137-142`), and queries speakers whose recorded `Email` equals it (`UserRegisteredHandler.cs:145-149`). A unique-name fallback used to run when the email missed, covering Sessionize-imported speakers whose `Email` is always null because the public `view/All` endpoint omits PII. It was deleted as a security fix (bug hunt C5): first and last name arrive straight from the attacker-controlled registration form and prove nothing, so anyone who knew a speaker's name could register under it and take over that profile (`UserRegisteredHandler.cs:16-25`). `[Rubric §11, Security]` assesses whether an authorization-relevant decision rests on a verified signal: an email is verified by the registration flow, a typed-in name is not. - **Walkthrough**: - `HandleAsync` (`UserRegisteredHandler.cs:45`) null-guards the event (`UserRegisteredHandler.cs:47`), opens the scope, and resolves the [`Speaker`](group-17-conference-domain.md#speaker) repository (`UserRegisteredHandler.cs:51-55`). - - On an email miss it calls `LogNameMatchCandidatesAsync` and returns without linking (`UserRegisteredHandler.cs:58-63`). That helper is read-only by design (`UserRegisteredHandler.cs:160-193`): it *counts* unlinked speakers whose first and last name match (`UserRegisteredHandler.cs:183-187`) and logs the count, never returning the rows, so no code path can link on a name. The log line is the trail an organizer follows to link the speaker by hand through [`LinkUserToSpeakerHandler`](#linkusertospeakerhandler) (BR-209). + - On an email miss it calls `LogNameMatchCandidatesAsync` and returns without linking (`UserRegisteredHandler.cs:58-63`). That helper is read-only by design (`UserRegisteredHandler.cs:167-193`): it *counts* unlinked speakers whose first and last name match (`UserRegisteredHandler.cs:183-187`) and logs the count, never returning the rows, so no code path can link on a name. The log line is the trail an organizer follows to link the speaker by hand through [`LinkUserToSpeakerHandler`](#linkusertospeakerhandler) (BR-209). - Three idempotency guards follow, in order: a speaker already linked to a *different* user is left alone (`UserRegisteredHandler.cs:66-70`); a speaker already linked to *this* user re-publishes [`SpeakerLinkedToUser`](group-17-conference-domain.md#speakerlinkedtouser) so Identity can re-sync, then returns without re-linking (`UserRegisteredHandler.cs:74-80`); and a rejected `speaker.LinkUser(...)` logs and returns (`UserRegisteredHandler.cs:82-87`). Together they make a redelivery a no-op, which is what licenses the rethrow above. - The happy path saves, publishes [`SpeakerLinkedToUser`](group-17-conference-domain.md#speakerlinkedtouser) through the [`IEventBus`](group-04-events-outbox.md#ieventbus), and logs (`UserRegisteredHandler.cs:92-99`). Identity consumes that event to set `User.LinkedSpeakerId`. - One detail worth reading twice: the email query orders `.OrderBy(s => s.LinkedUserId.HasValue).ThenBy(s => s.Id)` before taking the first (`UserRegisteredHandler.cs:154-157`). When two speaker rows share an address, an arbitrary `FirstOrDefault` could pick an already-linked record, abandon the link, and then pick a different one on a retry; the explicit ordering makes the choice deterministic across attempts and prefers the unlinked candidate. -- **Why it's built this way**: the auto-link is deliberately eventually consistent (`UserRegisteredHandler.cs:28-34`). A brand-new user's first token does not carry the `speaker_id` claim; it appears on the next token refresh after this handler completes. The class comment also records why the handler does not evict the 5-minute `SpeakersCache` output cache: doing so would require an ASP.NET Core dependency the Application layer must not take, so the cache simply expires. A unique-index race on `Speaker.LinkedUserId` (two registrations matching one speaker at once) surfaces as a `DbUpdateException` and resolves on the retry, since the loser then hits the "already linked to a different user" guard (`UserRegisteredHandler.cs:89-91`). -- **Where it's used**: registered as a singleton by the convention scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112`, scanning rule at `MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:126-130`) and fed by the Conference service host, which wires the generic `IntegrationEventConsumer` adapter for this event with `services.AddBrokerMessaging(builder.Configuration, x => x.RegisterIntegrationEventConsumer())` (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:346-347`, explained at `Program.cs:336-345`). Its producer is Identity's [`AuthenticationService`](group-24-identity-module.md#authenticationservice), which raises [`UserRegistered`](group-24-identity-module.md#userregistered) on the user aggregate and persists its outbox row (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:236-237`). Covered by `UserRegisteredHandlerTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Users/IntegrationEventHandlers/UserRegisteredHandlerTests.cs`) and end to end over a real broker by `UserRegisteredBrokerFlowTests` (`MMCA.ADC/Tests/Integration/MMCA.ADC.CrossService.IntegrationTests/CrossService/UserRegisteredBrokerFlowTests.cs`). -- **Caveats / not-in-source**: the email query loads every matching speaker with `asTracking: true` (`UserRegisteredHandler.cs:145-149`), which is needed for the link but means the shared-address case materializes all of them. Nothing in the file bounds that set, and nothing states a policy for what a duplicate speaker email is supposed to mean. + - The name-count query leans on two ambient behaviors the comment spells out (`UserRegisteredHandler.cs:180-182`): SQL Server's case-insensitive default collation, and the global soft-delete query filter that keeps `IsDeleted` rows out of the count ([ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html)). +- **Why it's built this way**: the auto-link is deliberately eventually consistent (`UserRegisteredHandler.cs:28-34`). A brand-new user's first token does not carry the `speaker_id` claim; it appears on the next token refresh after this handler completes. The class comment also records why the handler does not evict the 5-minute `SpeakersCache` output cache: doing so would require an ASP.NET Core dependency the Application layer must not take, so the cache simply expires. +- **Where it's used**: registered as a singleton by the convention scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`, scanning rule at `MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:151-155`) and fed by the Conference service host, which wires the generic `IntegrationEventConsumer` adapter for this event with `services.AddBrokerMessaging(builder.Configuration, x => x.RegisterIntegrationEventConsumer()...)` (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:371-373`, explained at `Program.cs:357-366`). Its producer is Identity's [`AuthenticationService`](group-24-identity-module.md#authenticationservice), which raises [`UserRegistered`](group-24-identity-module.md#userregistered) on the user aggregate and persists its outbox row on the standard registration path (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:112-116`) and again for a brand-new external-login user (`AuthenticationService.cs:236-237`). Covered by `UserRegisteredHandlerTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Users/IntegrationEventHandlers/UserRegisteredHandlerTests.cs`) and end to end over a real broker by `UserRegisteredBrokerFlowTests` (`MMCA.ADC/Tests/Integration/MMCA.ADC.CrossService.IntegrationTests/CrossService/UserRegisteredBrokerFlowTests.cs`). +- **Caveats / not-in-source**: two things in this file disagree with each other. The inline comment above the save still says a unique-index race on `Speaker.LinkedUserId` "is swallowed by the outer best-effort catch below" (`UserRegisteredHandler.cs:89-91`), but the catch filter no longer swallows anything (`UserRegisteredHandler.cs:101-105`): such a `DbUpdateException` propagates and is resolved by the redelivery, exactly as the `LogAndRethrow` remarks describe (`UserRegisteredHandler.cs:118-122`). Trust the code, not that comment. Separately, the email query loads every matching speaker with `asTracking: true` (`UserRegisteredHandler.cs:145-149`); nothing in the file bounds that set, and nothing states a policy for what a duplicate speaker email is supposed to mean. -### AddSessionCategoryItemCommand +### ActivityCreateRequest -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionCategoryItem` · `MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemCommand.cs:10` · Level 9 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Activities.UseCases.Create` · `MMCA.ADC.Conference.Application/Activities/UseCases/Create/ActivityCreateRequest.cs:10` · Level 9 · record -- **What it is**: the command that tags a session with a category item (the mechanism behind session topics and tracks). Three positional parameters: the owning `SessionId`, an optional `SessionCategoryItemId` for the join entity, and the `CategoryItemId` being associated (`AddSessionCategoryItemCommand.cs:10-13`). -- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`AddSessionCategoryItemCommand.cs:13`); the [`Session`](group-17-conference-domain.md#session) type for the cache prefix; and the `SessionIdentifierType`, `SessionCategoryItemIdentifierType`, and `CategoryItemIdentifierType` aliases, all three `int` in this module (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:5`, `:13`, `:14`). -- **Concept reinforced, the nullable child id**: the second parameter is documented as "Explicit ID for the join entity, or `null` for database-generated identity" (`AddSessionCategoryItemCommand.cs:8`), the same shape taught on [`AddSpeakerCategoryItemCommand`](#addspeakercategoryitemcommand). The REST path always passes `null` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:216`) and lets the database assign the key; the parameter exists for callers that already know the id, and it is the exact signature the aggregate factory takes (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:414-416`). `[Rubric §9, API and Contract Design]`: nullable rather than defaulted keeps "let the database choose" distinct from "I chose zero". -- **Walkthrough**: the record body is one member, `CachePrefix => $"{typeof(Session).FullName}:"` (`AddSessionCategoryItemCommand.cs:15-16`), the same session-wide prefix that [`RemoveSessionCategoryItemCommand`](#removesessioncategoryitemcommand) and the session create request declare, so one eviction covers every cached session projection ([ADR-026](https://ivanball.github.io/docs/adr/026-caching-strategy.html)). -- **Where it's used**: constructed by the `POST /SessionCategoryItems` action of [`SessionCategoryItemsController`](group-20-conference-api-grpc.md#sessioncategoryitemscontroller) from an [`AddSessionCategoryItemRequest`](group-20-conference-api-grpc.md#addsessioncategoryitemrequest) body (`SessionCategoryItemsController.cs:210-217`), on a controller gated by the `SessionsManage` permission (`SessionCategoryItemsController.cs:46`); validated by [`AddSessionCategoryItemCommandValidator`](#addsessioncategoryitemcommandvalidator); handled by [`AddSessionCategoryItemHandler`](#addsessioncategoryitemhandler). +- **What it is**: the create-request DTO for a conference social or networking activity (a pre-conference party, a morning coffee connect, an after-party, a closing ceremony). Like [`SpeakerCreateRequest`](#speakercreaterequest) it doubles as the command handled by [`CreateActivityHandler`](#createactivityhandler). +- **Depends on**: [`ICreateRequest`](group-05-cqrs-pipeline.md#icreaterequest) and [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`ActivityCreateRequest.cs:10`); the [`Activity`](group-17-conference-domain.md#activity) type for the cache prefix; the `ActivityIdentifierType` and `EventIdentifierType` module aliases. +- **Concept reinforced, an id the caller cannot choose**: the contrast with [`SpeakerCreateRequest`](#speakercreaterequest) is the lesson. A speaker id is client-assigned because Sessionize owns it; an activity is planned inside the app, so [`Activity`](group-17-conference-domain.md#activity) carries `[IdValueGenerated]` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:19`) and its factory assigns `default` rather than the supplied value (`Activity.cs:120-125`). The request still exposes an `Id` member, and its comment is honest about the consequence: "Database-generated; caller-provided values are ignored" (`ActivityCreateRequest.cs:15`). `[Rubric §9, API and Contract Design]` assesses whether a contract tells the caller the truth about what it does with each field. +- **Walkthrough**: a `record class` with `init`-only members. `CachePrefix => $"{typeof(Activity).FullName}:"` (`ActivityCreateRequest.cs:12-13`) is the invalidation tag. `Id` (`ActivityCreateRequest.cs:16`) is ignored as described above. Only `Name` is `required` (`ActivityCreateRequest.cs:19`). `Description` is optional (`ActivityCreateRequest.cs:22`); `StartTime` and `EndTime` (`ActivityCreateRequest.cs:25`, `ActivityCreateRequest.cs:28`) are plain `DateTime` values in the owning event's wall-clock time, matching how [`Activity`](group-17-conference-domain.md#activity) stores them and where the IANA zone actually lives, on the event (`Activity.cs:28-36`). The venue trio `VenueName`, `VenueAddress`, and `VenueUrl` (`ActivityCreateRequest.cs:31`, `ActivityCreateRequest.cs:34`, `ActivityCreateRequest.cs:37`) is carried on the activity rather than inherited from the event, because an after-party is usually somewhere else; an empty `VenueName` means the main conference venue. `SortOrder` (`ActivityCreateRequest.cs:40`) breaks ties between activities that start at the same minute, and `EventId` (`ActivityCreateRequest.cs:43`) is the owning event. +- **Why it's built this way**: keeping the venue on the activity instead of the event is the modeling decision the domain comment spells out (`Activity.cs:11-18`): an activity is deliberately not a session, has no room and no speakers, and frequently happens off site. `[Rubric §4, Domain-Driven Design]`. +- **Where it's used**: bound by [`ActivitiesController`](group-20-conference-api-grpc.md#activitiescontroller) on the `ActivitiesManage`-gated `POST /Activities` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/ActivitiesController.cs:211-220`) and used as the fourth generic argument of its base controller (`ActivitiesController.cs:46-47`); validated by [`ActivityCreateRequestValidator`](#activitycreaterequestvalidator); mapped by [`ActivityCreateRequestMapper`](#activitycreaterequestmapper); handled by [`CreateActivityHandler`](#createactivityhandler). The update side has its own pair, [`ActivityUpdateRequest`](#activityupdaterequest) and [`UpdateActivityCommand`](#updateactivitycommand). -### PublicSessionStatusSpecification +### ActivityDTOMapper -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.Specifications` · `MMCA.ADC.Conference.Application/Sessions/Specifications/PublicSessionStatusSpecification.cs:20` · Level 9 · class (sealed) +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Activities.DTOs` · `MMCA.ADC.Conference.Application/Activities/DTOs/ActivityDTOMapper.cs:13` · Level 9 · class (sealed partial) -- **What it is**: the single definition of which session statuses an anonymous or non-privileged caller may see (BR-49): `Accepted`, or no status at all, since organizer-created sessions never carry one. It is a nine-line class that every public session read path in the module goes through. -- **Depends on**: [`Specification`](group-03-querying-specifications.md#specificationtentity-tidentifiertype) closed over [`Session`](group-17-conference-domain.md#session) and `SessionIdentifierType` (`PublicSessionStatusSpecification.cs:20`); [`SessionStatuses`](group-17-conference-domain.md#sessionstatuses) for the `Accepted` constant; and `System.Linq.Expressions`. -- **Concept introduced, one predicate exposed in two forms**: the allow-list is a `public static readonly Expression> StatusCriteria` (`PublicSessionStatusSpecification.cs:23-24`), and the instance `Criteria` override simply returns it (`PublicSessionStatusSpecification.cs:27`). That duality is the whole design. Call sites that need to *compose* the predicate into a larger expression tree take the static field, and call sites that want specification algebra (AND, OR, paging, sorting) instantiate the class, and both share one definition so the rule cannot drift between them (`PublicSessionStatusSpecification.cs:13-15`). `[Rubric §11, Security]` assesses whether a visibility rule is centralized: a status that becomes public here becomes public everywhere at once, which is exactly what you want and also exactly why this file deserves care. -- **Concept introduced, writing predicates that a database can actually run**: the remarks record a trap the code deliberately avoids (`PublicSessionStatusSpecification.cs:16-18`). The domain already has [`SessionStatuses`](group-17-conference-domain.md#sessionstatuses)`.IsEligible(status)`, but calling it here would put compiled C# inside an expression tree, and EF Core cannot translate a method body to SQL; the predicate would either throw or silently evaluate client-side after loading every row. So the expression compares against the `Accepted` constant directly. The comment also notes that SQL Server's case-insensitive default collation gives the same case behavior the in-memory predicate has. `[Rubric §12, Performance and Scalability]`: a translatable predicate filters in the database instead of in the process; `[Rubric §8, Data Architecture]`: the query stays engine-agnostic enough to survive the polyglot posture of [ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html). -- **Walkthrough**: two members and no constructor. `StatusCriteria` (`PublicSessionStatusSpecification.cs:23-24`) is `s => s.Status == null || s.Status == SessionStatuses.Accepted`; the null branch is not an oversight but the organizer-created case. `Criteria` (`PublicSessionStatusSpecification.cs:27`) is the `override` the specification pipeline consumes ([ADR-055](https://ivanball.github.io/docs/adr/055-repository-and-specification-contract.html)). -- **Why it's built this way**: BR-49 is a business rule that appears in at least three query shapes; stating it once as an expression is the only way those shapes cannot disagree. Keeping it in the Application layer rather than the Domain is a consequence of it being a *read filter*, not an entity invariant (the invariant form lives beside it as `SessionInvariants.EnsureStatusIsEligible`, `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:107`). -- **Where it's used**: [`PublicConferenceVisibility`](#publicconferencevisibility) uses both forms, the static expression as the `localPredicate` of a cross-source build (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:67`) and an instance in specification algebra (`PublicConferenceVisibility.cs:142`); [`GetPublicSessionFilterHandler`](#getpublicsessionfilterhandler) uses the static expression for the public session list (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/GetPublicSessionFilter/GetPublicSessionFilterHandler.cs:34`). -- **Caveats / not-in-source**: the collation argument in the remarks is a statement about the deployed SQL Server, not something the type can enforce. On a case-sensitive collation or a different engine, the expression and `SessionStatuses.IsEligible` could disagree, and nothing in the code would catch it. +- **What it is**: the Mapperly-generated projector from the [`Activity`](group-17-conference-domain.md#activity) entity to [`ActivityDTO`](group-17-conference-domain.md#activitydto). It is the simplest mapper in the Conference module: no nested collections, no redaction, no injected services. +- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) closed over `Activity`, `ActivityDTO`, and `ActivityIdentifierType` (`ActivityDTOMapper.cs:13-14`); Riok.Mapperly's `[Mapper]` source generator (`ActivityDTOMapper.cs:4`, `ActivityDTOMapper.cs:12`). +- **Concept reinforced, "nothing to redact" is a decision, not an omission**: compare [`SpeakerDTOMapper`](#speakerdtomapper), which injects [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) and blanks the speaker email for non-organizers (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/DTOs/SpeakerDTOMapper.cs:35-36`, BR-66). This mapper takes no services at all, and the class comment says why: activity data is published to attendees by design (`ActivityDTOMapper.cs:8-11`). `[Rubric §11, Security]` assesses whether PII exposure is a considered per-field decision; the signal worth noticing here is that the absence of a filter is documented rather than accidental. +- **Walkthrough**: `[Mapper]` on a `sealed partial` class (`ActivityDTOMapper.cs:12-14`) lets Mapperly emit the property-by-property body of the `partial ActivityDTO MapToDTO(Activity entity)` declaration (`ActivityDTOMapper.cs:17`) at compile time, so there is no reflection at runtime and a renamed or unmapped property is a build error rather than a silent null. The collection overload is hand-written: `MapToDTOs` null-guards and projects with a collection expression, `[.. entityCollection.Select(MapToDTO)]` (`ActivityDTOMapper.cs:20-24`). [`ActivityDTO`](group-17-conference-domain.md#activitydto) carries `RowVersion` through `IConcurrencyAware` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Activities/ActivityDTO.cs:10-16`), which is what makes an optimistic-concurrency update possible from a previously read DTO. +- **Why it's built this way**: generated mapping keeps the manual-mapping guarantee (no runtime reflection, compile-time verification) without the hand-written drudgery ([ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html)). `[Rubric §16, Maintainability]`. +- **Where it's used**: injected as a concrete type into [`CreateActivityHandler`](#createactivityhandler) (`CreateActivityHandler.cs:19`) and [`UpdateActivityHandler`](#updateactivityhandler); resolved as `IEntityDTOMapper<...>` by the `EntityQueryService` registered for the module (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:81`), which is what serves the read endpoints. Registered both as itself and by its interfaces by the framework scan (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:157-161`). Covered by `ActivityDTOMapperTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Activities/DTOs/ActivityDTOMapperTests.cs`). +- **Caveats / not-in-source**: Activity has no `IEntityDTOProjector` sibling (the `Activities/DTOs/` folder holds only this mapper), so list reads materialize entities and then map them rather than projecting server-side. Whether that is a deliberate choice for a table this small is Not determinable from source. + +### LinkUserToSpeakerHandler + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.LinkUser` · `MMCA.ADC.Conference.Application/Speakers/UseCases/LinkUser/LinkUserToSpeakerHandler.cs:20` · Level 9 · class (sealed partial) + +- **What it is**: the handler for [`LinkUserToSpeakerCommand`](#linkusertospeakercommand). It updates the Conference side of a bidirectional link that spans two databases, and raises the event that updates the other side. +- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork); [`Speaker`](group-17-conference-domain.md#speaker); [`SpeakerLinkedToUser`](group-17-conference-domain.md#speakerlinkedtouser); [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error); `Microsoft.Extensions.Logging`. Note what is not injected: there is no publisher service, because the event is raised on the aggregate. +- **Concept introduced, cross-context coordination captured in the same transaction**: `[Rubric §6, CQRS and Event-Driven]` and `[Rubric §7, Microservices Readiness]`. Conference and Identity own separate databases ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)), so there is no foreign key between `Speaker` and `User` and consistency has to flow through events. The load-bearing detail is the ordering: `speaker.AddDomainEvent(new SpeakerLinkedToUser(...))` runs BEFORE the single `SaveChangesAsync` (`LinkUserToSpeakerHandler.cs:54`, then `LinkUserToSpeakerHandler.cs:56`), so the outbox row is written inside the same transaction as the link ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)). The class comment states the failure this removed (`LinkUserToSpeakerHandler.cs:13-18`): with a post-save publish, a crash could commit the Conference-side link and lose the event that sets `User.LinkedSpeakerId`. The [`OutboxProcessor`](group-04-events-outbox.md#outboxprocessor) later routes the row to the registered [`IMessageBus`](group-04-events-outbox.md#imessagebus) transport. This is also why the command carries [`ITransactional`](group-05-cqrs-pipeline.md#itransactional). +- **Walkthrough**: + - Primary constructor (`LinkUserToSpeakerHandler.cs:20-22`): [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) and `ILogger`; the declared result is the non-generic [`Result`](group-01-result-error-handling.md#result), so no DTO mapper is needed. + - `HandleAsync` (`LinkUserToSpeakerHandler.cs:25`) resolves the repository (`LinkUserToSpeakerHandler.cs:29`) and loads the speaker with the include-free overload (`LinkUserToSpeakerHandler.cs:30`), returning `Error.NotFound` with source and target set when it is missing (`LinkUserToSpeakerHandler.cs:31-32`). + - The BR-208 uniqueness guard (`LinkUserToSpeakerHandler.cs:34-46`) is the interesting part: it queries every speaker whose `LinkedUserId` equals the target user (`LinkUserToSpeakerHandler.cs:35-38`) and fails with `Error.Invariant(code: "Speaker.UserAlreadyLinked", ...)` if any OTHER speaker already holds that link (`LinkUserToSpeakerHandler.cs:39-46`). The `s.Id != command.SpeakerId` test is what makes re-linking the same pair a no-op rather than an error. + - `speaker.LinkUser(command.UserId)` (`LinkUserToSpeakerHandler.cs:48`) is the domain decision. The aggregate refuses a speaker that is already linked, returning `Speaker.AlreadyLinked` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:272-281`), so the handler owns only the cross-row rule and the entity owns its own. + - On success it raises the integration event on the aggregate (`LinkUserToSpeakerHandler.cs:54`), saves (`LinkUserToSpeakerHandler.cs:56`), and logs through the generated `LogUserLinkedToSpeaker` (`LinkUserToSpeakerHandler.cs:58`, declared at `LinkUserToSpeakerHandler.cs:64-65`). The domain `Result` is returned either way (`LinkUserToSpeakerHandler.cs:61`), so a rejection reaches the API with its error codes intact. +- **Why it's built this way**: splitting the rules (uniqueness across speakers in the handler, "already linked?" inside the aggregate) keeps each check where the data for it lives, and the pre-save event raise is a deliberate durability fix rather than a style choice. +- **Where it's used**: registered by the Conference application scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`); invoked through the decorator pipeline by the `PUT /Speakers/{id}/link` action ([`SpeakersController`](group-20-conference-api-grpc.md#speakerscontroller) at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:364-380`). The emitted event is consumed on the Identity side to set `User.LinkedSpeakerId`. This organizer-driven path is also the deliberate fallback for speakers the automatic email match in [`UserRegisteredHandler`](#userregisteredhandler) cannot claim. Covered by `LinkUserToSpeakerHandlerTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/LinkUserToSpeakerHandlerTests.cs`). +- **Caveats / not-in-source**: the BR-208 guard is a read-then-write with no lock, so two concurrent links naming the same user could both pass it; nothing in this file says what settles that race. ### RemoveSpeakerCategoryItemHandler > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.RemoveSpeakerCategoryItem` · `MMCA.ADC.Conference.Application/Speakers/UseCases/RemoveSpeakerCategoryItem/RemoveSpeakerCategoryItemHandler.cs:13` · Level 9 · class (sealed partial) -- **What it is**: the handler for [`RemoveSpeakerCategoryItemCommand`](#removespeakercategoryitemcommand). It is the canonical "load the aggregate, call a domain method, save" shape, at its smallest: twenty-two lines of orchestration with no business logic of its own. +- **What it is**: the handler for [`RemoveSpeakerCategoryItemCommand`](#removespeakercategoryitemcommand). It is the canonical "load the aggregate, call a domain method, save" shape at its smallest: about twenty lines of orchestration with no business logic of its own. - **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) closed over the command and the non-generic [`Result`](group-01-result-error-handling.md#result) (`RemoveSpeakerCategoryItemHandler.cs:15`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork); the [`Speaker`](group-17-conference-domain.md#speaker) aggregate and its [`SpeakerCategoryItem`](group-17-conference-domain.md#speakercategoryitem) child; [`Error`](group-01-result-error-handling.md#error); `Microsoft.Extensions.Logging`. -- **Concept reinforced, mutate only through the aggregate root**: the handler never touches the join entity. It loads the speaker with its `SpeakerCategoryItems` collection and hands the id to `entity.RemoveSpeakerCategoryItem(...)` (`RemoveSpeakerCategoryItemHandler.cs:31`), which resolves the child, soft-deletes it, and raises `SpeakerCategoryItemChanged` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:370-375`). `[Rubric §4, Domain-Driven Design]` assesses whether the aggregate boundary is respected on writes: a handler that deleted the join row through its own repository would bypass the domain event and the aggregate's own not-found error. +- **Concept reinforced, mutate only through the aggregate root**: the handler never touches the join entity. It loads the speaker with its `SpeakerCategoryItems` collection and hands the id to `entity.RemoveSpeakerCategoryItem(...)` (`RemoveSpeakerCategoryItemHandler.cs:31`), which resolves the child, soft-deletes it, and raises `SpeakerCategoryItemChanged` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:382-395`). `[Rubric §4, Domain-Driven Design]` assesses whether the aggregate boundary is respected on writes: a handler that deleted the join row through its own repository would bypass the domain event and the aggregate's own not-found error. - **Walkthrough**: - Primary constructor (`RemoveSpeakerCategoryItemHandler.cs:13-15`): [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) and a typed `ILogger`. No DTO mapper, because a delete returns the non-generic [`Result`](group-01-result-error-handling.md#result). - - `HandleAsync` (`RemoveSpeakerCategoryItemHandler.cs:18`) resolves the repository (`:22`) and loads with two arguments that are both load-bearing: `includes: [nameof(Speaker.SpeakerCategoryItems)]` (`:25`), without which the aggregate would search an empty in-memory collection and report not-found, and `asTracking: true` (`:26`), without which the change tracker would observe nothing and the save would emit no SQL. + - `HandleAsync` (`RemoveSpeakerCategoryItemHandler.cs:18`) resolves the repository (`RemoveSpeakerCategoryItemHandler.cs:22`) and loads with two arguments that are both load-bearing: `includes: [nameof(Speaker.SpeakerCategoryItems)]` (`RemoveSpeakerCategoryItemHandler.cs:25`), without which the aggregate would search an empty in-memory collection and report not-found, and `asTracking: true` (`RemoveSpeakerCategoryItemHandler.cs:26`), without which the change tracker would observe nothing and the save would emit no SQL. - A missing speaker returns `Error.NotFound.WithSource(...).WithTarget(...)` (`RemoveSpeakerCategoryItemHandler.cs:28-29`), stamping the handler and the entity type into the error so the API response says which lookup failed. - - Only on success does it save and log (`RemoveSpeakerCategoryItemHandler.cs:32-37`); the domain `Result` is returned unchanged either way (`:39`), so a domain rejection reaches the caller with its own codes intact. + - Only on success does it save and log (`RemoveSpeakerCategoryItemHandler.cs:32-37`); the domain `Result` is returned unchanged either way (`RemoveSpeakerCategoryItemHandler.cs:39`), so a domain rejection reaches the caller with its own codes intact. - Logging goes through the source-generated `[LoggerMessage]` partial `LogCategoryItemRemoved` (`RemoveSpeakerCategoryItemHandler.cs:42-43`), the allocation-free, compile-checked logging idiom used by every handler in this module. `[Rubric §13, Observability and Operability]`. - **Why it's built this way**: leaving removal semantics in the aggregate and cache eviction on the command ([`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating)) leaves the handler as four steps in a fixed order, which is why every sibling remove handler in this chapter reads the same way. -- **Where it's used**: registered by the module's application scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112`); invoked through the decorator pipeline by [`SpeakerCategoryItemsController`](group-20-conference-api-grpc.md#speakercategoryitemscontroller)'s delete action (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:238-240`). Covered by `RemoveSpeakerCategoryItemHandlerTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/RemoveSpeakerCategoryItemHandlerTests.cs`). +- **Where it's used**: registered by the module's application scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`); invoked through the decorator pipeline by [`SpeakerCategoryItemsController`](group-20-conference-api-grpc.md#speakercategoryitemscontroller)'s delete action (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:246-247`), which then evicts both parents' output-cache entries (`SpeakerCategoryItemsController.cs:255`). Covered by `RemoveSpeakerCategoryItemHandlerTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/RemoveSpeakerCategoryItemHandlerTests.cs`). + +### SpeakerCreateRequestMapper + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.Create` · `MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequestMapper.cs:11` · Level 9 · class (sealed) + +- **What it is**: the adapter that turns a validated [`SpeakerCreateRequest`](#speakercreaterequest) into a [`Speaker`](group-17-conference-domain.md#speaker) entity by calling the aggregate's static factory. +- **Depends on**: [`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype) closed over `Speaker`, `SpeakerCreateRequest`, and `SpeakerIdentifierType` (`SpeakerCreateRequestMapper.cs:11-12`); [`Speaker`](group-17-conference-domain.md#speaker); [`Result`](group-01-result-error-handling.md#result). +- **Concept reinforced, the request mapper as the only door into a factory**: see [`QuestionCreateRequestMapper`](#questioncreaterequestmapper) for the pattern. `Speaker.Create` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:116-171`) parses the optional email into an [`Email`](group-02-domain-building-blocks.md#email) value object and bails on a malformed address (`Speaker.cs:130-137`), combines the first- and last-name invariants with `Result.Combine` so both failures surface at once (`Speaker.cs:139-142`), assigns the client-supplied id or generates one (`Speaker.cs:161`), and raises `SpeakerChanged` (`Speaker.cs:168`). The id fallback carries its own scar: the comment records that the previous `id!.Value` threw "Nullable object must have a value" and killed both Conference's startup seeding and every organizer create (`Speaker.cs:156-160`). `[Rubric §4, Domain-Driven Design]`, `[Rubric §15, Best Practices]`. +- **Walkthrough**: `CreateEntityAsync` (`SpeakerCreateRequestMapper.cs:15`) null-guards (`SpeakerCreateRequestMapper.cs:17`), then returns `Task.FromResult(Speaker.Create(...))` (`SpeakerCreateRequestMapper.cs:19-31`). Every one of the factory's twelve parameters is filled from the request (`Id`, `FirstName`, `LastName`, `Email`, `Bio`, `TagLine`, `ProfilePicture`, `IsTopSpeaker`, `TwitterHandle`, `LinkedInUrl`, `GitHubUrl`, `WebsiteUrl`); only `FullName` is dropped, because the entity computes it (`Speaker.cs:61`). The method is synchronous work behind an async signature: `Task.FromResult` satisfies the interface without an allocation-heavy state machine. +- **Why it's built this way**: delegating every field check to the factory keeps validation in the domain instead of duplicated in the Application layer, and the generic create pipeline can drive any aggregate through the same contract ([ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html)). +- **Where it's used**: injected into [`CreateSpeakerHandler`](#createspeakerhandler) as `IEntityRequestMapper` (`CreateSpeakerHandler.cs:18`) and invoked at `CreateSpeakerHandler.cs:27`; registered by the framework's request-mapper scan (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:172-176`), driven from the module at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`. +- **Caveats / not-in-source**: the factory's `id` parameter is `SpeakerIdentifierType?`, but the request's `Id` is the non-nullable alias, so this path always passes a value and the `Guid.NewGuid()` fallback at `Speaker.cs:161` is unreachable from it. A `POST /Speakers` body that omits `Id` therefore creates a speaker whose key is `Guid.Empty` rather than a fresh GUID. The null-id branch is reached only by callers that pass `id: null` explicitly, which is what the sample-data seeder does (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/Seeding/ConferenceModuleDbSeeder.cs:201-202`); the Sessionize import supplies the Sessionize GUID instead (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SpeakerSyncStrategy.cs:126`). Nothing in this file or the request records that expectation. + +### SpeakerCreateRequestValidator + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.Create` · `MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequestValidator.cs:7` · Level 9 · class (sealed) + +- **What it is**: the FluentValidation validator for [`SpeakerCreateRequest`](#speakercreaterequest). It declares no rules of its own; it composes two shared rule sets. +- **Depends on**: FluentValidation's `AbstractValidator` (`SpeakerCreateRequestValidator.cs:1`, `SpeakerCreateRequestValidator.cs:7`); `SpeakerFirstNameRules` and `SpeakerLastNameRules` (`SpeakerCreateRequestValidator.cs:2`). +- **Concept reinforced, rule composition with `Include`**: the same mechanism taught on [`QuestionCreateRequestValidator`](#questioncreaterequestvalidator), with one extra layer. Both speaker rule sets extend the framework's [`RequiredStringRules`](group-06-validation.md#requiredstringrulest) with a display label and a ceiling taken from the domain: "First Name" with `SpeakerInvariants.FirstNameMaxLength` and "Last Name" with `LastNameMaxLength`, both 200 (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:14-15` and `SpeakerValidationRules.cs:25-26`; [`SpeakerInvariants`](group-17-conference-domain.md#speakerinvariants) at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/SpeakerInvariants.cs:13` and `SpeakerInvariants.cs:16`), which is the same constant the domain invariant enforces (`SpeakerInvariants.cs:45`, `SpeakerInvariants.cs:50`). One constant, two enforcement points, no drift. `[Rubric §16, Maintainability]`, `[Rubric §24, Forms, Validation and UX Safety]`. +- **Walkthrough**: a `sealed class` whose whole body is a two-line constructor (`SpeakerCreateRequestValidator.cs:9-13`) calling `Include(new SpeakerFirstNameRules(p => p.FirstName))` and the last-name equivalent. +- **Why it's built this way**: extracting field rules into shared generic classes is the module-wide convention; add a constraint once and every request type that includes the rule set picks it up (here, both the create and the update validators). +- **Where it's used**: discovered by `AddValidatorsFromAssemblyContaining` inside the module scan (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:190`) and run by [`ValidatingCommandDecorator`](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult) ahead of [`CreateSpeakerHandler`](#createspeakerhandler). Covered by `SpeakerCreateRequestValidatorTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/Validation/SpeakerCreateRequestValidatorTests.cs`). +- **Caveats / not-in-source**: only the two names are validated here. `Email` is left entirely to the [`Email`](group-02-domain-building-blocks.md#email) value object inside `Speaker.Create` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:130-137`), so a malformed address arrives as an invariant failure rather than a field-level validation error, and the profile-link members are length-checked nowhere in this layer. + +### UnlinkUserFromSpeakerHandler + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.UnlinkUser` · `MMCA.ADC.Conference.Application/Speakers/UseCases/UnlinkUser/UnlinkUserFromSpeakerHandler.cs:19` · Level 9 · class (sealed partial) + +- **What it is**: the handler for [`UnlinkUserFromSpeakerCommand`](#unlinkuserfromspeakercommand). It clears the Conference side of the User-Speaker link and raises the event that clears the Identity side. +- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) (`UnlinkUserFromSpeakerHandler.cs:21`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork); the [`Speaker`](group-17-conference-domain.md#speaker) aggregate; [`SpeakerUnlinkedFromUser`](group-17-conference-domain.md#speakerunlinkedfromuser); [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error); logging. As with [`LinkUserToSpeakerHandler`](#linkusertospeakerhandler), no publisher service is injected, because the event is raised on the aggregate. +- **Concept reinforced, raise the integration event before the save, not after**: the class comment is unusually explicit about the bug this ordering removes (`UnlinkUserFromSpeakerHandler.cs:13-17`). Raising [`SpeakerUnlinkedFromUser`](group-17-conference-domain.md#speakerunlinkedfromuser) on the aggregate first (`UnlinkUserFromSpeakerHandler.cs:42`) and saving after (`UnlinkUserFromSpeakerHandler.cs:45`) puts the outbox row and the unlink in one transaction, so a crash can no longer commit the Conference-side unlink while losing the event that clears `User.LinkedSpeakerId` on the Identity side. A post-save publish, which is what this code used to do, had exactly that hole. The [`OutboxProcessor`](group-04-events-outbox.md#outboxprocessor) then routes the row to the registered [`IMessageBus`](group-04-events-outbox.md#imessagebus) transport ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)). `[Rubric §6, CQRS and Event-Driven]`, `[Rubric §29, Resilience]`. +- **Walkthrough**: + - Loads the speaker with the include-free overload (`UnlinkUserFromSpeakerHandler.cs:29`) and returns a stamped `Error.NotFound` when it is missing (`UnlinkUserFromSpeakerHandler.cs:30-31`). No `includes` are needed: the link is a scalar column on the aggregate root. + - Captures `previousUserId` BEFORE calling `speaker.UnlinkUser()` (`UnlinkUserFromSpeakerHandler.cs:33-34`). This is the load-bearing line: the domain method clears `LinkedUserId` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:301`), so reading it afterwards would yield null and the event could not name the user that was unlinked. The aggregate itself rejects an unlinked speaker with `Speaker.NotLinked` (`Speaker.cs:290-299`). + - On success, and only when a previous user actually existed, it raises the event (`UnlinkUserFromSpeakerHandler.cs:40-43`), saves (`UnlinkUserFromSpeakerHandler.cs:45`), and logs through the generated `LogUserUnlinkedFromSpeaker` (`UnlinkUserFromSpeakerHandler.cs:47`, declared at `UnlinkUserFromSpeakerHandler.cs:53-54`). The domain `Result` is returned unchanged (`UnlinkUserFromSpeakerHandler.cs:50`). +- **Why it's built this way**: Conference and Identity own separate databases ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)), so there is no foreign key to cascade and the back-link has to travel as an event; the [`ITransactional`](group-05-cqrs-pipeline.md#itransactional) marker on the command plus the pre-save raise are together what make that event as durable as the write it describes. +- **Where it's used**: registered by the module's application scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`); invoked by `DELETE /Speakers/{id}/link` on [`SpeakersController`](group-20-conference-api-grpc.md#speakerscontroller) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:383-391`). The emitted event is consumed on the Identity side to clear `User.LinkedSpeakerId`. Covered by `UnlinkUserFromSpeakerHandlerTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/UnlinkUserFromSpeakerHandlerTests.cs`) and over a real broker by `SpeakerLinkBrokerFlowTests` (`MMCA.ADC/Tests/Integration/MMCA.ADC.CrossService.IntegrationTests/CrossService/SpeakerLinkBrokerFlowTests.cs`). +- **Caveats / not-in-source**: the `previousUserId.HasValue` test (`UnlinkUserFromSpeakerHandler.cs:40`) can never be false on the success path, because `UnlinkUser()` fails when nothing is linked (`Speaker.cs:292-299`). It is defensive, not a live branch. + +### ActivityCreateRequestMapper + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Activities.UseCases.Create` · `MMCA.ADC.Conference.Application/Activities/UseCases/Create/ActivityCreateRequestMapper.cs:11` · Level 10 · class (sealed) + +- **What it is**: the adapter that turns a validated [`ActivityCreateRequest`](#activitycreaterequest) into an [`Activity`](group-17-conference-domain.md#activity) entity by calling the aggregate's static factory. Structurally identical to [`SpeakerCreateRequestMapper`](#speakercreaterequestmapper). +- **Depends on**: [`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype) closed over `Activity`, `ActivityCreateRequest`, and `ActivityIdentifierType` (`ActivityCreateRequestMapper.cs:11-12`); [`Activity`](group-17-conference-domain.md#activity); [`Result`](group-01-result-error-handling.md#result). +- **Concept reinforced, the factory decides, the mapper only forwards**: `CreateEntityAsync` never constructs an entity itself. `Activity.Create` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:99-130`) combines five invariants in one `Result.Combine` (name, time range, venue name, venue address, venue URL: `Activity.cs:111-116`), returns their aggregated errors on failure (`Activity.cs:117-118`), assigns the id (`Activity.cs:124`), and raises `ActivityChanged` with `DomainEntityState.Added` (`Activity.cs:127`). `[Rubric §4, Domain-Driven Design]`: an `Activity` that exists is an `Activity` that passed its invariants. +- **Walkthrough**: `CreateEntityAsync` (`ActivityCreateRequestMapper.cs:15`) null-guards the request (`ActivityCreateRequestMapper.cs:17`) and returns `Task.FromResult(Activity.Create(...))` with all ten arguments taken straight from the request (`ActivityCreateRequestMapper.cs:19-29`). Unlike the speaker mapper, nothing is dropped here: the request and the factory have the same shape. +- **Why it's built this way**: one generic contract ([`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype)) lets the same create handler shape serve every aggregate, while each aggregate keeps its own construction rules ([ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html)). `[Rubric §1, SOLID]`: the mapper's single responsibility is translation. +- **Where it's used**: injected into [`CreateActivityHandler`](#createactivityhandler) by its interface (`CreateActivityHandler.cs:18`) and invoked at `CreateActivityHandler.cs:27`; registered by the framework's request-mapper scan (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:172-176`). +- **Caveats / not-in-source**: the request's `Id` is forwarded (`ActivityCreateRequestMapper.cs:20`) but discarded downstream, because [`Activity`](group-17-conference-domain.md#activity) is `[IdValueGenerated]` and the factory assigns `default` in that case (`Activity.cs:120-125`). If that attribute were ever removed, the same line would evaluate `id!.Value`, which is the exact null-Nullable crash [`Speaker`](group-17-conference-domain.md#speaker) had to fix (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:156-161`). Nothing here guards against that. + +### ActivityCreateRequestValidator + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Activities.UseCases.Create` · `MMCA.ADC.Conference.Application/Activities/UseCases/Create/ActivityCreateRequestValidator.cs:7` · Level 10 · class (sealed) + +- **What it is**: the FluentValidation validator for [`ActivityCreateRequest`](#activitycreaterequest). Like [`SpeakerCreateRequestValidator`](#speakercreaterequestvalidator) it writes no rules of its own, but it composes eight rule sets instead of two, which makes it the clearest example in the module of how far the `Include` convention scales. +- **Depends on**: FluentValidation's `AbstractValidator` (`ActivityCreateRequestValidator.cs:1`, `ActivityCreateRequestValidator.cs:7`); the eight reusable rule classes in `MMCA.ADC.Conference.Application.Activities.Validation` (`ActivityCreateRequestValidator.cs:2`). +- **Concept reinforced, two kinds of reusable rule set**: five of the eight extend a framework base ([`RequiredStringRules`](group-06-validation.md#requiredstringrulest) for the name, [`OptionalStringRules`](group-06-validation.md#optionalstringrulest) for description, venue name, venue address, and venue URL), each supplying a display label and a max length that comes from [`ActivityInvariants`](group-17-conference-domain.md#activityinvariants) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:13-67`; constants at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/ActivityInvariants.cs:13-25`). The other three are plain `AbstractValidator` subclasses that encode rules the framework has no base for: `ActivityEventIdRules` requires a non-empty event (`ActivityValidationRules.cs:74-80`), `ActivitySortOrderRules` requires a non-negative order (`ActivityValidationRules.cs:111-117`), and `ActivityTimeRangeRules` is the multi-field one, compiling the start-time selector so the end-time rule can compare against it: `.Must((instance, endTime) => endTime >= startTimeFunc(instance))` (`ActivityValidationRules.cs:100-103`). Every rule carries an explicit `WithErrorCode`, so a client can branch on `Activity.EndTime.BeforeStart` instead of matching English prose. `[Rubric §24, Forms, Validation and UX Safety]`, `[Rubric §9, API and Contract Design]`. +- **Walkthrough**: the whole class is an eight-line constructor (`ActivityCreateRequestValidator.cs:9-19`), one `Include` per rule set, each handed a property selector: name (`ActivityCreateRequestValidator.cs:11`), event id (`ActivityCreateRequestValidator.cs:12`), the start/end pair (`ActivityCreateRequestValidator.cs:13`), sort order (`ActivityCreateRequestValidator.cs:14`), description (`ActivityCreateRequestValidator.cs:15`), venue name (`ActivityCreateRequestValidator.cs:16`), venue address (`ActivityCreateRequestValidator.cs:17`), venue URL (`ActivityCreateRequestValidator.cs:18`). +- **Why it's built this way**: composition beats inheritance here because the update request needs the same field rules with a different id shape; both validators include the same rule classes rather than sharing a base class. `[Rubric §16, Maintainability]`. +- **Where it's used**: discovered by `AddValidatorsFromAssemblyContaining` (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:190`) and executed by [`ValidatingCommandDecorator`](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult) before [`CreateActivityHandler`](#createactivityhandler) ever runs ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). Covered by `ActivityCreateRequestValidatorTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Activities/Validation/ActivityCreateRequestValidatorTests.cs`). +- **Caveats / not-in-source**: the validator and the factory do not check the same set. `ActivityDescriptionRules`, `ActivitySortOrderRules`, and `ActivityEventIdRules` have no counterpart in `Activity.Create`, which combines only name, time range, and the three venue fields (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:111-116`). Anything that builds an `Activity` without going through this validator (a seeder, a future importer) can therefore produce a negative `SortOrder`, an over-long description, or an activity with an empty `EventId`. Whether that gap is intentional is Not determinable from source. + +### CreateActivityHandler + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Activities.UseCases.Create` · `MMCA.ADC.Conference.Application/Activities/UseCases/Create/CreateActivityHandler.cs:16` · Level 10 · class (sealed partial) + +- **What it is**: the handler that creates an activity: map, add, save, project. It is the same four-statement shape as [`CreateSpeakerHandler`](#createspeakerhandler), which is the point: once the decorators own validation, caching, and transactions, a create slice has almost nothing left to write. +- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) closed over [`ActivityCreateRequest`](#activitycreaterequest) and `Result` (`CreateActivityHandler.cs:20`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork); [`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype), satisfied by [`ActivityCreateRequestMapper`](#activitycreaterequestmapper); [`ActivityDTOMapper`](#activitydtomapper); [`ActivityDTO`](group-17-conference-domain.md#activitydto); [`Result`](group-01-result-error-handling.md#result); `Microsoft.Extensions.Logging`. +- **Concept reinforced, the generic create slice end to end**: `[Rubric §5, Vertical Slice]`: request, mapper, validator, and handler live in one folder and the request IS the command. `[Rubric §3, Clean Architecture]`: the handler orchestrates a request mapper, a repository, and a DTO mapper without embedding construction logic of its own; the domain types it touches come from the Domain project and nothing from ASP.NET Core appears here. +- **Walkthrough**: + - Primary constructor (`CreateActivityHandler.cs:16-20`): unit of work, the request mapper resolved by its generic interface, the DTO mapper as a concrete type, and `ILogger`. + - `HandleAsync` (`CreateActivityHandler.cs:23-40`) calls `requestMapper.CreateEntityAsync(command, cancellationToken)` first (`CreateActivityHandler.cs:27`) and returns the mapper's errors verbatim on failure (`CreateActivityHandler.cs:28-29`), so a domain invariant violation surfaces as a `Result` failure rather than an exception. + - It takes `result.Value!` (`CreateActivityHandler.cs:31`), resolves `IRepository` from the unit of work rather than injecting it (`CreateActivityHandler.cs:32`), then awaits `repository.AddAsync(...)` and `unitOfWork.SaveChangesAsync(...)` with `ConfigureAwait(false)` (`CreateActivityHandler.cs:34-35`, [ADR-049](https://ivanball.github.io/docs/adr/049-library-configureawait-policy.html)). Resolving the repository through [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) rather than constructor-injecting [`IRepository`](group-07-persistence-ef-core.md#irepositorytentity-tidentifiertype) is the framework rule that keeps one tracked context per scope. + - It logs the new id and name through the generated `LogActivityCreated` (`CreateActivityHandler.cs:37`, declared at `CreateActivityHandler.cs:42-43`) and returns `Result.Success(dtoMapper.MapToDTO(entity))` (`CreateActivityHandler.cs:39`). Reading `entity.Id` after the save is what makes the database-generated key observable in the response. +- **Why it's built this way**: the pipeline supplies everything this handler does not: [`ValidatingCommandDecorator`](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult) runs [`ActivityCreateRequestValidator`](#activitycreaterequestvalidator) first, and [`CachingCommandDecorator`](group-05-cqrs-pipeline.md#cachingcommanddecoratortcommand-tresult) acts on the `CachePrefix` the request declares ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). `[Rubric §10, Cross-Cutting]`. +- **Where it's used**: registered by the command-handler scan (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:178-182`, driven from `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`); injected into [`ActivitiesController`](group-20-conference-api-grpc.md#activitiescontroller) as `ICommandHandler>` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/ActivitiesController.cs:39`) and dispatched by its `POST /Activities` override, which delegates to the base controller and then evicts the activities output cache (`ActivitiesController.cs:211-220`). The action is gated on the `ActivitiesManage` permission (`ActivitiesController.cs:212`), held by Organizer and ContentEditor, while the read endpoints stay anonymous per BR-43 (`ActivitiesController.cs:27-32`). Covered by `CreateActivityHandlerTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Activities/UseCases/CreateActivityHandlerTests.cs`). + +### CreateSpeakerHandler + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.Create` · `MMCA.ADC.Conference.Application/Speakers/UseCases/Create/CreateSpeakerHandler.cs:16` · Level 10 · class (sealed partial) + +- **What it is**: the handler that creates a speaker. It is deliberately one of the thinnest handlers in this chapter: map, add, save, project. Compare it with [`CreateQuestionHandler`](#createquestionhandler), which needs a reserved id range and a collision retry; a speaker id is a client-assigned GUID, so none of that machinery is required here. +- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) closed over [`SpeakerCreateRequest`](#speakercreaterequest) and `Result` (`CreateSpeakerHandler.cs:20`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork); [`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype), satisfied by [`SpeakerCreateRequestMapper`](#speakercreaterequestmapper); [`SpeakerDTOMapper`](#speakerdtomapper); [`SpeakerDTO`](group-17-conference-domain.md#speakerdto); [`Result`](group-01-result-error-handling.md#result); `Microsoft.Extensions.Logging`. +- **Concept reinforced, the create response obeys the same rules as a read**: `[Rubric §5, Vertical Slice]`: the request IS the command, so the class implements `ICommandHandler>` (`CreateSpeakerHandler.cs:20`) and the four types of the slice sit in one folder. `[Rubric §11, Security]` reaches it through the projection: [`SpeakerDTOMapper`](#speakerdtomapper) blanks the speaker's email for non-organizers (BR-66, `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/DTOs/SpeakerDTOMapper.cs:35-36`), so even the create response is redacted by the same rule as every read, not only the list endpoints. +- **Walkthrough**: + - Primary constructor (`CreateSpeakerHandler.cs:16-20`): unit of work, the request mapper resolved by its generic interface, the DTO mapper as a concrete type, and `ILogger`. + - `HandleAsync` (`CreateSpeakerHandler.cs:23-40`) calls `requestMapper.CreateEntityAsync(command, cancellationToken)` first (`CreateSpeakerHandler.cs:27`) and returns the mapper's errors verbatim on failure (`CreateSpeakerHandler.cs:28-29`), so a domain invariant violation surfaces as a `Result` failure rather than an exception. + - It takes `result.Value!` (`CreateSpeakerHandler.cs:31`), resolves `IRepository` from the unit of work rather than injecting it (`CreateSpeakerHandler.cs:32`), then awaits `repository.AddAsync(...)` and `unitOfWork.SaveChangesAsync(...)` with `ConfigureAwait(false)` (`CreateSpeakerHandler.cs:34-35`, [ADR-049](https://ivanball.github.io/docs/adr/049-library-configureawait-policy.html)). + - It logs through the generated `LogSpeakerCreated`, which records the id and the computed full name (`CreateSpeakerHandler.cs:37`, declared at `CreateSpeakerHandler.cs:42-43`), and returns `Result.Success(dtoMapper.MapToDTO(entity))` (`CreateSpeakerHandler.cs:39`). +- **Why it's built this way**: validation, cache invalidation, and transaction scope are all handled by the pipeline decorators wrapped around this handler (declared by the markers on [`SpeakerCreateRequest`](#speakercreaterequest)), which is exactly why the create logic can reduce to four statements ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). `[Rubric §1, SOLID]`: the handler's one reason to change is the use case. +- **Where it's used**: registered by the command-handler scan (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:178-182`, driven from `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`); injected into [`SpeakersController`](group-20-conference-api-grpc.md#speakerscontroller) as `ICommandHandler>` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:46`) and dispatched by its `POST /Speakers` override, which delegates to the base controller and then evicts the speakers output cache (`SpeakersController.cs:308-317`). The action is gated on the `SpeakersManage` permission (`SpeakersController.cs:309`). Covered by `CreateSpeakerHandlerTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/CreateSpeakerHandlerTests.cs`). +- **Caveats / not-in-source**: nothing here guards against a caller re-submitting an id that already exists; the insert simply fails on the primary key and surfaces through the shared exception handling. The Sessionize import path relies on that, since it supplies the Sessionize GUID as `Id` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SpeakerSyncStrategy.cs:126`), but no comment in this file records the expectation. + +### AddSessionCategoryItemCommand + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionCategoryItem` · `MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemCommand.cs:10` · Level 9 · record + +- **What it is**: the command that tags a session with a category item (the mechanism behind session topics, levels, and localities). Three positional parameters: the owning `SessionId`, an optional `SessionCategoryItemId` for the join entity, and the `CategoryItemId` being associated (`MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemCommand.cs:10-13`). +- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`AddSessionCategoryItemCommand.cs:13`); the [`Session`](group-17-conference-domain.md#session) type, used only for its `FullName` when building the cache prefix; and the `SessionIdentifierType`, `SessionCategoryItemIdentifierType`, and `CategoryItemIdentifierType` module aliases, all three `int` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:6`, `:14`, `:15`). +- **Concept introduced, the nullable child id on an Add command**: the second parameter is `SessionCategoryItemIdentifierType?`, documented in the file as "Explicit ID for the join entity, or `null` for database-generated identity" (`AddSessionCategoryItemCommand.cs:8`). The REST path always passes `null` and lets the database assign the key (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:224`); the parameter exists because that is the exact signature the aggregate factory takes (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:414-416`), and a caller that already knows the id (the Sessionize import is the obvious one) can supply it. `[Rubric §9, API and Contract Design]` assesses whether a contract states exactly what a caller may decide: a nullable id rather than a defaulted one keeps "let the database choose" distinct from "I chose zero". +- **Walkthrough**: the record body is one member, `CachePrefix => $"{typeof(Session).FullName}:"` (`AddSessionCategoryItemCommand.cs:15-16`). That is the same session-wide prefix [`AddSessionQuestionAnswerCommand`](#addsessionquestionanswercommand) and [`RemoveSessionCategoryItemCommand`](#removesessioncategoryitemcommand) declare, so one eviction after a successful command covers every cached session projection rather than requiring per-query bookkeeping ([ADR-026](https://ivanball.github.io/docs/adr/026-caching-strategy.html)). The command itself never touches a cache: it only declares what it invalidates, and the caching decorator does the work ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). `[Rubric §10, Cross-Cutting]`. +- **Where it's used**: constructed by the `POST /SessionCategoryItems` action of [`SessionCategoryItemsController`](group-20-conference-api-grpc.md#sessioncategoryitemscontroller) from an [`AddSessionCategoryItemRequest`](group-20-conference-api-grpc.md#addsessioncategoryitemrequest) body (`SessionCategoryItemsController.cs:219-224`), on a controller gated by the `SessionsManage` permission (`SessionCategoryItemsController.cs:47`, [ADR-020](https://ivanball.github.io/docs/adr/020-permission-based-authorization.html)); validated by [`AddSessionCategoryItemCommandValidator`](#addsessioncategoryitemcommandvalidator); handled by [`AddSessionCategoryItemHandler`](#addsessioncategoryitemhandler). + +### AddSessionQuestionAnswerCommand + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionQuestionAnswer` · `MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerCommand.cs:11` · Level 9 · record + +- **What it is**: the message an attendee's session feedback travels on. Four positional fields: the owning `SessionId`, an optional `SessionQuestionAnswerId` for the answer row, the `QuestionId` being answered, and the `AnswerValue` text (`MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerCommand.cs:11-15`). +- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`AddSessionQuestionAnswerCommand.cs:15`), the [`Session`](group-17-conference-domain.md#session) type for the prefix, and the `SessionIdentifierType` / `SessionQuestionAnswerIdentifierType` / `QuestionIdentifierType` aliases ([ADR-048](https://ivanball.github.io/docs/adr/048-primitive-identifier-type-aliases.html)). +- **Concept reinforced**: none new. The nullable child id works exactly as on [`AddSessionCategoryItemCommand`](#addsessioncategoryitemcommand); the file states the same contract at `AddSessionQuestionAnswerCommand.cs:8`, the REST path passes `null` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:190`), and the aggregate method takes the same shape (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:512`). +- **Walkthrough**: one member, `CachePrefix => $"{typeof(Session).FullName}:"` (`AddSessionQuestionAnswerCommand.cs:17-18`). The interesting part of this record is what it does **not** carry: no author id. Ownership is resolved server side from [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) inside [`AddSessionQuestionAnswerHandler`](#addsessionquestionanswerhandler) (`AddSessionQuestionAnswerHandler.cs:52`), so a client cannot submit feedback as someone else. `[Rubric §11, Security]`: identity is never a request field. +- **Where it's used**: validated by [`AddSessionQuestionAnswerCommandValidator`](#addsessionquestionanswercommandvalidator), handled by [`AddSessionQuestionAnswerHandler`](#addsessionquestionanswerhandler), and built from an [`AddSessionQuestionAnswerRequest`](group-20-conference-api-grpc.md#addsessionquestionanswerrequest) by the `POST /SessionQuestionAnswers` action of [`SessionQuestionAnswersController`](group-20-conference-api-grpc.md#sessionquestionanswerscontroller) (`SessionQuestionAnswersController.cs:185-190`), on a controller whose whole surface requires an authenticated caller (`SessionQuestionAnswersController.cs:56`). + +### PublicSessionStatusSpecification + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.Specifications` · `MMCA.ADC.Conference.Application/Sessions/Specifications/PublicSessionStatusSpecification.cs:20` · Level 9 · class (sealed) + +- **What it is**: the single definition of which session statuses an anonymous or non-privileged caller may see (BR-49): `Accepted`, or no status at all, since organizer-created sessions never carry one. It is an eight-line class that every public session read path in the module goes through, ANDed with the published-event scoping of BR-108 (`MMCA.ADC.Conference.Application/Sessions/Specifications/PublicSessionStatusSpecification.cs:7-11`). +- **Depends on**: [`Specification`](group-03-querying-specifications.md#specificationtentity-tidentifiertype) closed over [`Session`](group-17-conference-domain.md#session) and `SessionIdentifierType` (`PublicSessionStatusSpecification.cs:20`); [`SessionStatuses`](group-17-conference-domain.md#sessionstatuses) for the `Accepted` constant; and `System.Linq.Expressions` (`PublicSessionStatusSpecification.cs:1`). +- **Concept introduced, one predicate exposed in two forms**: the allow-list is a `public static readonly Expression> StatusCriteria` (`PublicSessionStatusSpecification.cs:23-24`), and the instance `Criteria` override simply returns it (`PublicSessionStatusSpecification.cs:27`). That duality is the whole design. Call sites that need to *compose* the predicate into a larger expression tree take the static field, and call sites that want specification algebra (AND, OR, paging, sorting) instantiate the class; both share one definition so the rule cannot drift between them (the file says so at `:13-15`). `[Rubric §11, Security]` assesses whether a visibility rule is centralized: a status that becomes public here becomes public everywhere at once, which is what you want and also exactly why this file deserves care. +- **Concept introduced, writing predicates a database can actually run**: the remarks record a trap the code deliberately avoids (`PublicSessionStatusSpecification.cs:15-18`). The domain already has [`SessionStatuses`](group-17-conference-domain.md#sessionstatuses)`.IsEligible(status)`, but calling it here would put compiled C# inside an expression tree, and EF Core cannot translate a method body to SQL; the predicate would either throw or silently evaluate client-side after loading every row. So the expression compares against the `Accepted` constant directly. The comment also notes that SQL Server's case-insensitive default collation gives the same case behavior the in-memory predicate has. `[Rubric §12, Performance and Scalability]`: a translatable predicate filters in the database instead of in the process. `[Rubric §8, Data Architecture]`: the query stays engine-agnostic enough to survive the polyglot posture of [ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html). +- **Walkthrough**: two members and no constructor. `StatusCriteria` (`PublicSessionStatusSpecification.cs:23-24`) is `s => s.Status == null || s.Status == SessionStatuses.Accepted`; the null branch is not an oversight but the organizer-created case. `Criteria` (`PublicSessionStatusSpecification.cs:27`) is the `override` the specification pipeline consumes ([ADR-055](https://ivanball.github.io/docs/adr/055-repository-and-specification-contract.html)). +- **Why it's built this way**: BR-49 appears in at least three query shapes; stating it once as an expression is the only way those shapes cannot disagree. Keeping it in the Application layer rather than the Domain follows from it being a *read filter*, not an entity invariant. The invariant form lives beside it in the domain as `SessionInvariants.EnsureStatusIsEligible` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:107`), which is the form [`SessionBookmarkValidationService`](#sessionbookmarkvalidationservice) and [`AddSessionQuestionAnswerHandler`](#addsessionquestionanswerhandler) call on an already-loaded row. +- **Where it's used**: [`PublicConferenceVisibility`](#publicconferencevisibility) uses both forms, the static expression as the `localPredicate` of a cross-source build (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:68`) and an instance in specification algebra (`PublicConferenceVisibility.cs:148`); [`GetPublicSessionFilterHandler`](#getpublicsessionfilterhandler) uses the static expression for the public session list (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/GetPublicSessionFilter/GetPublicSessionFilterHandler.cs:34`, with the sharing intent stated at `:17`). +- **Caveats / not-in-source**: the collation argument in the remarks is a statement about the deployed SQL Server, not something the type can enforce. On a case-sensitive collation or a different engine, the expression and `SessionStatuses.IsEligible` could disagree, and nothing in the code would catch it. ### SessionBookmarkValidationService > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions` · `MMCA.ADC.Conference.Application/Sessions/SessionBookmarkValidationService.cs:12` · Level 9 · class (sealed) -- **What it is**: Conference's implementation of a contract the **Engagement** module declares. Engagement owns bookmarks but not sessions, so before it stores a bookmark it asks Conference two questions through this type: may this session be bookmarked, and which sessions belong to this event. -- **Depends on**: [`ISessionBookmarkValidationService`](group-17-conference-domain.md#isessionbookmarkvalidationservice) (the cross-module interface it implements, `SessionBookmarkValidationService.cs:12`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork); the [`Session`](group-17-conference-domain.md#session) aggregate and `SessionInvariants`; [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error). -- **Concept introduced, the cross-module provider behind an interface the consumer owns**: the interface lives in `MMCA.ADC.Conference.Shared`, the implementation in Conference's Application layer, and Engagement's handlers depend only on the interface (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/UseCases/Create/CreateBookmarkHandler.cs:20`). That indirection is what makes the module extractable: in the split topology Conference is disabled inside the Engagement process, and the Contracts project swaps the registration for a gRPC adapter with one line, `services.Replace(ServiceDescriptor.Scoped())` (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/DependencyInjection.cs:49`). Not one line of Engagement's application code changes. `[Rubric §7, Microservices Readiness]` assesses exactly this: whether a cross-module call is a transport decision made at the edge ([ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html)); `[Rubric §3, Clean Architecture]`: the dependency points at an abstraction, never at another module's domain. +- **What it is**: Conference's implementation of a contract the **Engagement** module consumes. Engagement owns bookmarks but not sessions, so before it stores a bookmark it asks Conference two questions through this type: may this session be bookmarked (BR-49 and BR-91), and which sessions belong to this event (`MMCA.ADC.Conference.Application/Sessions/SessionBookmarkValidationService.cs:8-11`). +- **Depends on**: [`ISessionBookmarkValidationService`](group-17-conference-domain.md#isessionbookmarkvalidationservice), the cross-module interface it implements (`SessionBookmarkValidationService.cs:12`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), taken by primary constructor; the [`Session`](group-17-conference-domain.md#session) aggregate and its [`SessionInvariants`](group-17-conference-domain.md#sessioninvariants) helpers; [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error). +- **Concept introduced, the cross-module provider behind an interface the consumer depends on**: the interface lives in `MMCA.ADC.Conference.Shared`, the implementation here in Conference's Application layer, and Engagement's handlers depend only on the interface (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/UseCases/Create/CreateBookmarkHandler.cs:20`). That indirection is what makes the module extractable: in the split topology Conference is disabled inside the Engagement process, and the Contracts project swaps the registration for a gRPC adapter with one line, `services.Replace(ServiceDescriptor.Scoped())` (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/DependencyInjection.cs:49`). Not one line of Engagement's application code changes. `[Rubric §7, Microservices Readiness]` assesses exactly this: whether a cross-module call is a transport decision made at the edge ([ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html)). `[Rubric §3, Clean Architecture]`: the dependency points at an abstraction, never at another module's domain. - **Walkthrough**: two methods, and the first is where the business rules live. - - `ValidateSessionForBookmarkAsync` (`SessionBookmarkValidationService.cs:15-39`) loads the session untracked with no includes (`:20-24`), returns `Error.NotFound` stamped with this service as source when it is missing (`:26-30`), then runs two domain invariants in order: `SessionInvariants.EnsureNotServiceSession` for BR-91, since a break or a lunch slot is not something an attendee bookmarks (`:33`, invariant at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:91`), and `SessionInvariants.EnsureStatusIsEligible` for BR-49 (`:38`, invariant at `SessionInvariants.cs:107`). Both are static domain functions, so the *rule* stays in the Domain layer and this class only decides when to ask. Note the contrast with [`PublicSessionStatusSpecification`](#publicsessionstatusspecification): that one is an EF-translatable expression for filtering a query, this one is compiled code checking an already-loaded row, and the two are deliberately different expressions of the same BR-49. - - `GetSessionIdsByEventAsync` (`SessionBookmarkValidationService.cs:42-54`) returns the id list for one event, read untracked and projected into a `Result>` (`:47-53`). Engagement uses it to scope a user's bookmark list to a single event without holding any session data of its own. Returning **ids** rather than session rows is the point: it crosses the module boundary with the smallest possible payload and no schema coupling, which is also what keeps the gRPC contract trivial (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/Protos/session_bookmark_validation.proto:27`). `[Rubric §8, Data Architecture]`. + - `ValidateSessionForBookmarkAsync` (`SessionBookmarkValidationService.cs:15-39`) loads the session untracked with no includes (`:19-24`), returns `Error.NotFound` stamped with this service as source and `Session` as target when it is missing (`:26-30`), then runs two domain invariants in order: `SessionInvariants.EnsureNotServiceSession` for BR-91, since a break or a lunch slot is not something an attendee bookmarks (`:33`, invariant at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:91`), and `SessionInvariants.EnsureStatusIsEligible` for BR-49 (`:38`, invariant at `SessionInvariants.cs:107`). Both are static domain functions, so the *rule* stays in the Domain layer and this class only decides when to ask. Note the contrast with [`PublicSessionStatusSpecification`](#publicsessionstatusspecification): that one is an EF-translatable expression for filtering a query, this one is compiled code checking an already-loaded row, and the two are deliberately different expressions of the same BR-49. + - `GetSessionIdsByEventAsync` (`SessionBookmarkValidationService.cs:42-54`) returns the id list for one event, read untracked with a `where: s => s.EventId == eventId` predicate and projected into a `Result>` (`:46-53`). Engagement uses it to scope a user's bookmark list to a single event without holding any session data of its own. Returning **ids** rather than session rows is the point: it crosses the module boundary with the smallest possible payload and no schema coupling, which is also what keeps the gRPC contract trivial (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/Protos/session_bookmark_validation.proto:28`). `[Rubric §8, Data Architecture]`. - **Why it's built this way**: bookmarks and sessions live in different databases ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)), so Engagement cannot join to a session table to check eligibility; the only correct move is to ask the owner. Keeping the answer in a narrow interface means the question survives the process split unchanged. -- **Where it's used**: registered explicitly (not by the convention scan) as the in-process implementation at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:105`; consumed by Engagement's [`CreateBookmarkHandler`](group-22-engagement-module.md#createbookmarkhandler) (`CreateBookmarkHandler.cs:31`) and [`GetUserBookmarksHandler`](group-22-engagement-module.md#getuserbookmarkshandler) (`GetUserBookmarksHandler.cs:43`); exposed over the wire by [`SessionBookmarksGrpcService`](group-20-conference-api-grpc.md#sessionbookmarksgrpcservice) (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Grpc/SessionBookmarksGrpcService.cs:23`), which wraps this instance as its `inner`. Covered by `SessionBookmarkValidationServiceTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/SessionBookmarkValidationServiceTests.cs`). -- **Caveats / not-in-source**: `GetSessionIdsByEventAsync` loads whole session entities and projects the ids in memory (`SessionBookmarkValidationService.cs:47-53`) rather than projecting in the query, so the cost scales with session size, not id count. It also applies no visibility filter: the ids of every non-deleted session in the event are returned, eligible or not, which is safe only because the caller uses them to narrow a bookmark list the user already owns. +- **Where it's used**: registered explicitly, not by the convention scan, as the in-process implementation at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:118`; consumed by Engagement's [`CreateBookmarkHandler`](group-22-engagement-module.md#createbookmarkhandler) (`CreateBookmarkHandler.cs:31`) and [`GetUserBookmarksHandler`](group-22-engagement-module.md#getuserbookmarkshandler); exposed over the wire by [`SessionBookmarksGrpcService`](group-20-conference-api-grpc.md#sessionbookmarksgrpcservice), which wraps this instance as its `inner` (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Grpc/SessionBookmarksGrpcService.cs:23`). Covered by [`SessionBookmarkValidationServiceTests`](group-27-testing-infrastructure.md#sessionbookmarkvalidationservicetests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/SessionBookmarkValidationServiceTests.cs:12`). +- **Caveats / not-in-source**: `GetSessionIdsByEventAsync` loads whole session entities and projects the ids in memory (`SessionBookmarkValidationService.cs:47-53`) rather than projecting in the query, so the cost scales with session row size, not id count. It also applies no visibility filter: the ids of every non-deleted session in the event are returned, eligible or not, which is safe only because the caller uses them to narrow a bookmark list the user already owns. ### SessionCategoryItemDTOMapper > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.DTOs` · `MMCA.ADC.Conference.Application/Sessions/DTOs/SessionCategoryItemDTOMapper.cs:12` · Level 9 · class (sealed partial) - **What it is**: the entity-to-DTO mapper for the [`SessionCategoryItem`](group-17-conference-domain.md#sessioncategoryitem) join entity, source-generated by Mapperly. -- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) closed over [`SessionCategoryItem`](group-17-conference-domain.md#sessioncategoryitem) / [`SessionCategoryItemDTO`](group-17-conference-domain.md#sessioncategoryitemdto) / `SessionCategoryItemIdentifierType` (`SessionCategoryItemDTOMapper.cs:13`), and `Riok.Mapperly.Abstractions` for the `[Mapper]` attribute (`SessionCategoryItemDTOMapper.cs:11`). -- **Concept reinforced, compile-time mapping**: the mechanism is taught on [`SpeakerCategoryItemDTOMapper`](#speakercategoryitemdtomapper); this is the session-side twin, byte-for-byte the same shape over different types. `[Mapper]` on a `partial` class makes the generator emit the body of `MapToDTO` as plain property assignments, so an unmatched property is a build diagnostic rather than a silent null ([ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html)). `[Rubric §12, Performance and Scalability]` and `[Rubric §15, Best Practices and Code Quality]`. -- **Walkthrough**: two members. `MapToDTO` (`SessionCategoryItemDTOMapper.cs:16`) is `partial` with no body. `MapToDTOs` (`SessionCategoryItemDTOMapper.cs:19-23`) is hand-written: a null guard, then a collection-expression spread over `Select(MapToDTO)`. Declaring it on the class is what makes it reachable through the concrete type, which matters because the add handler injects the concrete mapper, not the interface. -- **Where it's used**: injected into [`AddSessionCategoryItemHandler`](#addsessioncategoryitemhandler) (`AddSessionCategoryItemHandler.cs:18`); composed into [`SessionDTOMapper`](#sessiondtomapper) as a `[UseMapper]` field (`SessionDTOMapper.cs:26-27`); resolved by the generic [`EntityQueryService`](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype) registered for this entity (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:96`). Registered by the convention scan, self and interfaces, scoped (`DependencyInjection.cs:112`, rule at `MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:132-136`). Covered by `SessionCategoryItemDTOMapperTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/DTOs/SessionCategoryItemDTOMapperTests.cs`). +- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) closed over [`SessionCategoryItem`](group-17-conference-domain.md#sessioncategoryitem) / [`SessionCategoryItemDTO`](group-17-conference-domain.md#sessioncategoryitemdto) / `SessionCategoryItemIdentifierType` (`MMCA.ADC.Conference.Application/Sessions/DTOs/SessionCategoryItemDTOMapper.cs:13`), and `Riok.Mapperly.Abstractions` for the `[Mapper]` attribute (`SessionCategoryItemDTOMapper.cs:4`, `:11`). +- **Concept reinforced, compile-time mapping**: the mechanism is taught on [`SpeakerCategoryItemDTOMapper`](#speakercategoryitemdtomapper); this is the session-side twin, the same shape over different types. `[Mapper]` on a `partial` class makes the generator emit the body of `MapToDTO` as plain property assignments, so an unmatched property is a build diagnostic rather than a silent null, and there is no runtime reflection or expression compilation on the read path ([ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html)). `[Rubric §12, Performance and Scalability]` and `[Rubric §15, Best Practices and Code Quality]`. +- **Walkthrough**: two members. `MapToDTO` (`SessionCategoryItemDTOMapper.cs:16`) is `partial` with no body, which is the generator's hook. `MapToDTOs` (`SessionCategoryItemDTOMapper.cs:19-23`) is hand-written: `ArgumentNullException.ThrowIfNull` then a collection-expression spread over `Select(MapToDTO)`. Declaring the plural on the class is what makes it reachable through the concrete type, which matters because [`AddSessionCategoryItemHandler`](#addsessioncategoryitemhandler) injects the concrete mapper, not the interface. +- **Where it's used**: injected into [`AddSessionCategoryItemHandler`](#addsessioncategoryitemhandler) (`AddSessionCategoryItemHandler.cs:18`); composed into [`SessionDTOMapper`](#sessiondtomapper) as a `[UseMapper]` field (`SessionDTOMapper.cs:26-27`); resolved by the generic [`EntityQueryService`](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype) registered for this entity (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:105`). Registered by the convention scan, self and interfaces, scoped (`DependencyInjection.cs:125`). Covered by [`SessionCategoryItemDTOMapperTests`](group-27-testing-infrastructure.md#sessioncategoryitemdtomappertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/DTOs/SessionCategoryItemDTOMapperTests.cs:7`). ### SessionQuestionAnswerDTOMapper > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.DTOs` · `MMCA.ADC.Conference.Application/Sessions/DTOs/SessionQuestionAnswerDTOMapper.cs:12` · Level 9 · class (sealed partial) -- **What it is**: the Mapperly mapper for [`SessionQuestionAnswer`](group-17-conference-domain.md#sessionquestionanswer), the entity that stores a speaker's answer to a session question. -- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) closed over [`SessionQuestionAnswer`](group-17-conference-domain.md#sessionquestionanswer) / [`SessionQuestionAnswerDTO`](group-17-conference-domain.md#sessionquestionanswerdto) / `SessionQuestionAnswerIdentifierType` (`SessionQuestionAnswerDTOMapper.cs:13`), and `Riok.Mapperly.Abstractions` (`SessionQuestionAnswerDTOMapper.cs:11`). +- **What it is**: the Mapperly mapper for [`SessionQuestionAnswer`](group-17-conference-domain.md#sessionquestionanswer), the entity that stores one attendee's answer to one session question. +- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) closed over [`SessionQuestionAnswer`](group-17-conference-domain.md#sessionquestionanswer) / [`SessionQuestionAnswerDTO`](group-17-conference-domain.md#sessionquestionanswerdto) / `SessionQuestionAnswerIdentifierType` (`MMCA.ADC.Conference.Application/Sessions/DTOs/SessionQuestionAnswerDTOMapper.cs:13`), and `Riok.Mapperly.Abstractions` (`SessionQuestionAnswerDTOMapper.cs:11`). - **Concept reinforced**: none new. Identical in structure to [`SessionCategoryItemDTOMapper`](#sessioncategoryitemdtomapper): a `partial` `MapToDTO` the generator fills in (`SessionQuestionAnswerDTOMapper.cs:16`) and a hand-written null-guarded `MapToDTOs` (`SessionQuestionAnswerDTOMapper.cs:19-23`). -- **Where it's used**: injected into [`AddSessionQuestionAnswerHandler`](#addsessionquestionanswerhandler) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerHandler.cs:23`); composed into [`SessionDTOMapper`](#sessiondtomapper) (`SessionDTOMapper.cs:23-24`); resolved by the query service registered for this entity (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:99`). Covered by `SessionQuestionAnswerDTOMapperTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/DTOs/SessionQuestionAnswerDTOMapperTests.cs`). +- **Where it's used**: injected into [`AddSessionQuestionAnswerHandler`](#addsessionquestionanswerhandler) (`MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerHandler.cs:23`), where both the create and the update branch map through it; composed into [`SessionDTOMapper`](#sessiondtomapper) (`SessionDTOMapper.cs:23-24`); resolved by the query service registered for this entity (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:108`). Covered by [`SessionQuestionAnswerDTOMapperTests`](group-27-testing-infrastructure.md#sessionquestionanswerdtomappertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/DTOs/SessionQuestionAnswerDTOMapperTests.cs:7`). +- **Caveats / not-in-source**: the DTO shape is decided entirely by the two type definitions and the generated file, which is not in the repository. Whether an answer's author is exposed to a caller is a property question on [`SessionQuestionAnswerDTO`](group-17-conference-domain.md#sessionquestionanswerdto), not something this file controls. ### SessionSpeakerDTOMapper > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.DTOs` · `MMCA.ADC.Conference.Application/Sessions/DTOs/SessionSpeakerDTOMapper.cs:12` · Level 9 · class (sealed partial) - **What it is**: the Mapperly mapper for [`SessionSpeaker`](group-17-conference-domain.md#sessionspeaker), the join entity that assigns a speaker to a session. -- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) closed over [`SessionSpeaker`](group-17-conference-domain.md#sessionspeaker) / [`SessionSpeakerDTO`](group-17-conference-domain.md#sessionspeakerdto) / `SessionSpeakerIdentifierType` (`SessionSpeakerDTOMapper.cs:13`), and `Riok.Mapperly.Abstractions` (`SessionSpeakerDTOMapper.cs:11`). +- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) closed over [`SessionSpeaker`](group-17-conference-domain.md#sessionspeaker) / [`SessionSpeakerDTO`](group-17-conference-domain.md#sessionspeakerdto) / `SessionSpeakerIdentifierType` (`MMCA.ADC.Conference.Application/Sessions/DTOs/SessionSpeakerDTOMapper.cs:13`), and `Riok.Mapperly.Abstractions` (`SessionSpeakerDTOMapper.cs:11`). - **Concept reinforced**: none new; see [`SessionCategoryItemDTOMapper`](#sessioncategoryitemdtomapper). Same two members, same split between the generated `MapToDTO` (`SessionSpeakerDTOMapper.cs:16`) and the hand-written `MapToDTOs` (`SessionSpeakerDTOMapper.cs:19-23`). -- **Where it's used**: injected into [`AddSessionSpeakerHandler`](#addsessionspeakerhandler) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionSpeaker/AddSessionSpeakerHandler.cs:18`); composed into [`SessionDTOMapper`](#sessiondtomapper) (`SessionDTOMapper.cs:20-21`); resolved by the query service registered for this entity (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:93`). Covered by `SessionSpeakerDTOMapperTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/DTOs/SessionSpeakerDTOMapperTests.cs`). +- **Where it's used**: injected into the add-speaker handler for the session slice; composed into [`SessionDTOMapper`](#sessiondtomapper) (`SessionDTOMapper.cs:20-21`); resolved by the query service registered for this entity (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:102`). Covered by [`SessionSpeakerDTOMapperTests`](group-27-testing-infrastructure.md#sessionspeakerdtomappertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/DTOs/SessionSpeakerDTOMapperTests.cs:7`). - **Caveats / not-in-source**: this mapper projects the join row only. Whether a parent [`SessionDTO`](group-17-conference-domain.md#sessiondto) arrives carrying its `SessionSpeakers` at all depends on whether the read path ran [`SessionNavigationPopulator`](#sessionnavigationpopulator) for that collection; nothing in this file influences it. -### UnlinkUserFromSpeakerHandler - -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers.UseCases.UnlinkUser` · `MMCA.ADC.Conference.Application/Speakers/UseCases/UnlinkUser/UnlinkUserFromSpeakerHandler.cs:19` · Level 9 · class (sealed partial) - -- **What it is**: the handler for [`UnlinkUserFromSpeakerCommand`](#unlinkuserfromspeakercommand). It clears the Conference side of the User-Speaker link and raises the event that clears the Identity side. -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) (`UnlinkUserFromSpeakerHandler.cs:21`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork); the [`Speaker`](group-17-conference-domain.md#speaker) aggregate; [`SpeakerUnlinkedFromUser`](group-17-conference-domain.md#speakerunlinkedfromuser); [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error); logging. As with [`LinkUserToSpeakerHandler`](#linkusertospeakerhandler), no publisher service is injected, because the event is raised on the aggregate. -- **Concept reinforced, raise the integration event before the save, not after**: the class comment is unusually explicit about the bug this ordering removes (`UnlinkUserFromSpeakerHandler.cs:13-17`). Raising [`SpeakerUnlinkedFromUser`](group-17-conference-domain.md#speakerunlinkedfromuser) on the aggregate first (`UnlinkUserFromSpeakerHandler.cs:42`) and saving after (`:45`) puts the outbox row and the unlink in one transaction, so a crash can no longer commit the Conference-side unlink while losing the event that clears `User.LinkedSpeakerId` on the Identity side. A post-save publish, which is what this code used to do, had exactly that hole. The [`OutboxProcessor`](group-04-events-outbox.md#outboxprocessor) then routes the row to the registered [`IMessageBus`](group-04-events-outbox.md#imessagebus) transport ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)). `[Rubric §6, CQRS and Event-Driven]`, `[Rubric §29, Resilience]`. -- **Walkthrough**: - - Loads the speaker with the include-free, tracked overload (`UnlinkUserFromSpeakerHandler.cs:29`) and returns a stamped `Error.NotFound` when it is missing (`:30-31`). No `includes` are needed: the link is a scalar column on the aggregate root. - - Captures `previousUserId` BEFORE calling `speaker.UnlinkUser()` (`UnlinkUserFromSpeakerHandler.cs:33-34`). This is the load-bearing line: the domain method clears `LinkedUserId`, so reading it afterwards would yield null and the event could not name the user that was unlinked. The aggregate itself rejects an unlinked speaker with `Speaker.NotLinked` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:278-284`). - - On success, and only when a previous user actually existed, it raises the event (`UnlinkUserFromSpeakerHandler.cs:40-43`), saves (`:45`), and logs through the generated `LogUserUnlinkedFromSpeaker` (`:47`, declared at `:53-54`). The domain `Result` is returned unchanged (`:50`). -- **Why it's built this way**: Conference and Identity own separate databases ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)), so there is no foreign key to cascade and the back-link has to travel as an event; the [`ITransactional`](group-05-cqrs-pipeline.md#itransactional) marker on the command plus the pre-save raise are together what make that event as durable as the write it describes. -- **Where it's used**: registered by the module's application scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112`); invoked by `DELETE /Speakers/{id}/link` on [`SpeakersController`](group-20-conference-api-grpc.md#speakerscontroller) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:383-391`). The emitted event is consumed on the Identity side to clear `User.LinkedSpeakerId`. Covered by `UnlinkUserFromSpeakerHandlerTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/UnlinkUserFromSpeakerHandlerTests.cs`) and over a real broker by `SpeakerLinkBrokerFlowTests` (`MMCA.ADC/Tests/Integration/MMCA.ADC.CrossService.IntegrationTests/CrossService/SpeakerLinkBrokerFlowTests.cs`). - ### AddSessionCategoryItemCommandValidator > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionCategoryItem` · `MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemCommandValidator.cs:8` · Level 10 · class (sealed) -- **What it is**: the FluentValidation validator for [`AddSessionCategoryItemCommand`](#addsessioncategoryitemcommand). It is a single rule: the `CategoryItemId` must not be the default (`AddSessionCategoryItemCommandValidator.cs:10-13`). -- **Depends on**: FluentValidation's `AbstractValidator` only (`AddSessionCategoryItemCommandValidator.cs:1`, `:8`). -- **Concept reinforced, shape checks at the pipeline's front door**: the validating decorator runs this before [`AddSessionCategoryItemHandler`](#addsessioncategoryitemhandler) sees the command and before the transaction opens ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)), so the handler can assume a well-formed message. The division of labor is worth naming: "is the request well formed?" lives here, "is the operation allowed?" lives in the handler and the aggregate. That is why the duplicate-tag rule is NOT in this file: detecting it needs the loaded session, so it lives in `Session.AddSessionCategoryItem` as the `Session.CategoryItem.Duplicate` invariant (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:418-425`). `[Rubric §24, Forms, Validation and UX Safety]` assesses whether validation runs before business logic; `[Rubric §15, Best Practices and Code Quality]`: cheap guards stay declarative. -- **Walkthrough**: an expression-bodied constructor holding one `RuleFor(x => x.CategoryItemId).NotEqual(default(CategoryItemIdentifierType)).WithMessage("Category item ID is required.")` (`AddSessionCategoryItemCommandValidator.cs:10-13`). Because `CategoryItemIdentifierType` is `int` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:5`), this rejects a zero id, the value an unset JSON field binds to. Writing it as `default(CategoryItemIdentifierType)` rather than `0` means the rule survives an alias change to a GUID without editing. -- **Why it's built this way**: the convention scan auto-registers every `AbstractValidator` in the assembly (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:156`), so adding a validator class is the entire wiring step; no registration line to forget. -- **Where it's used**: resolved as `IValidator` by the validating decorator on every dispatch of that command. +- **What it is**: the FluentValidation validator for [`AddSessionCategoryItemCommand`](#addsessioncategoryitemcommand). It is a single rule: the `CategoryItemId` must not be the default (`MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemCommandValidator.cs:10-13`). +- **Depends on**: FluentValidation's `AbstractValidator` and nothing else (`AddSessionCategoryItemCommandValidator.cs:1`, `:8`). +- **Concept introduced, shape checks at the pipeline's front door**: the validating decorator runs every registered validator for the command type before [`AddSessionCategoryItemHandler`](#addsessioncategoryitemhandler) sees the message and before the transaction opens ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)), so a malformed command costs no database work and the handler can assume a well-formed message. The division of labor is worth naming: "is the request well formed?" lives here, "is the operation allowed?" lives in the handler and the aggregate. That is why the duplicate-tag rule is NOT in this file: detecting it needs the loaded session, so it lives in `Session.AddSessionCategoryItem` as the `Session.CategoryItem.Duplicate` invariant (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:418-425`). `[Rubric §24, Forms, Validation and UX Safety]` assesses whether bad input is rejected before it reaches business logic; `[Rubric §15, Best Practices and Code Quality]`: cheap guards stay declarative. +- **Walkthrough**: an expression-bodied constructor holding one `RuleFor(x => x.CategoryItemId).NotEqual(default(CategoryItemIdentifierType)).WithMessage("Category item ID is required.")` (`AddSessionCategoryItemCommandValidator.cs:10-13`). Because `CategoryItemIdentifierType` is `int` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:6`), this rejects a zero id, the value an unset JSON field binds to. Writing it as `default(CategoryItemIdentifierType)` rather than `0` means the rule survives an alias change to a GUID without editing. +- **Why it's built this way**: the convention scan auto-registers every `AbstractValidator` in the assembly, `services.ScanModuleApplicationServices()` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`), so dropping a validator file next to its command is the entire wiring step. `[Rubric §5, Vertical Slice]`: no registration line to forget in a distant file. +- **Where it's used**: resolved as `IValidator` by the validating decorator on every dispatch of that command. Covered by [`AddSessionCategoryItemCommandValidatorTests`](group-27-testing-infrastructure.md#addsessioncategoryitemcommandvalidatortests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionCommandValidatorTests.cs:60`), one of the validator test classes sharing that file. - **Caveats / not-in-source**: `SessionId` is not validated here. A zero session id therefore reaches the handler and comes back as the aggregate's not-found error rather than a validation failure. Nothing in the file says whether that is deliberate. ### AddSessionCategoryItemHandler > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionCategoryItem` · `MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemHandler.cs:16` · Level 10 · class (sealed partial) -- **What it is**: the handler for [`AddSessionCategoryItemCommand`](#addsessioncategoryitemcommand). It loads the session, asks the aggregate to create the association, saves, and returns the new join row as a DTO. -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) closed over the command and `Result` (`AddSessionCategoryItemHandler.cs:19`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork); [`SessionCategoryItemDTOMapper`](#sessioncategoryitemdtomapper), injected as the **concrete** type (`AddSessionCategoryItemHandler.cs:18`); the [`Session`](group-17-conference-domain.md#session) aggregate and its [`SessionCategoryItem`](group-17-conference-domain.md#sessioncategoryitem) child; [`SessionCategoryItemDTO`](group-17-conference-domain.md#sessioncategoryitemdto); [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error); logging. -- **Concept introduced, why the include list is a correctness argument and not an optimization**: the code carries a comment that is worth quoting in spirit (`AddSessionCategoryItemHandler.cs:28-29`): the join collection HAS to be loaded, or the aggregate's duplicate check runs against an empty in-memory list and a double submit surfaces as a raw unique-index 409 instead of a worded business error. `Session.AddSessionCategoryItem` guards with `_sessionCategoryItems.Exists(sci => !sci.IsDeleted && sci.CategoryItemId == categoryItemId)` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:418`), a purely in-memory test: it can only be as correct as the collection the handler hydrated. `[Rubric §4, Domain-Driven Design]` assesses whether invariants are enforced by the aggregate; this is the flip side, the application layer's obligation to give the aggregate the state its invariants need. `[Rubric §9, API and Contract Design]`: the difference between the two outcomes is a 400-class business error with a code the client can act on versus an opaque database conflict. +- **What it is**: the handler for [`AddSessionCategoryItemCommand`](#addsessioncategoryitemcommand). It loads the session, asks the aggregate to create the association, saves, and returns the new join row as a DTO (`MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemHandler.cs:11-15`). +- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) closed over the command and `Result` (`AddSessionCategoryItemHandler.cs:19`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork); [`SessionCategoryItemDTOMapper`](#sessioncategoryitemdtomapper), injected as the **concrete** type (`AddSessionCategoryItemHandler.cs:18`); the [`Session`](group-17-conference-domain.md#session) aggregate and its [`SessionCategoryItem`](group-17-conference-domain.md#sessioncategoryitem) child; [`SessionCategoryItemDTO`](group-17-conference-domain.md#sessioncategoryitemdto); [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error); `Microsoft.Extensions.Logging`. +- **Concept introduced, why the include list is a correctness argument and not an optimization**: the code carries a comment that says it outright (`AddSessionCategoryItemHandler.cs:28-29`): the join collection has to be loaded, or the aggregate's duplicate check runs against an empty in-memory list and a double submit surfaces as a raw unique-index 409 instead of a worded business error. `Session.AddSessionCategoryItem` guards with `_sessionCategoryItems.Exists(sci => !sci.IsDeleted && sci.CategoryItemId == categoryItemId)` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:418`), a purely in-memory test: it can only be as correct as the collection the handler hydrated. `[Rubric §4, Domain-Driven Design]` assesses whether invariants are enforced by the aggregate; this is the flip side, the application layer's obligation to give the aggregate the state its invariants need. `[Rubric §9, API and Contract Design]`: the difference between the two outcomes is a business error with a code the client can act on versus an opaque database conflict. - **Walkthrough**: - - Resolves the repository and loads with `includes: [nameof(Session.SessionCategoryItems)]` and `asTracking: true` (`AddSessionCategoryItemHandler.cs:26-30`), returning a stamped `Error.NotFound` when the session is missing (`:31-32`). - - Delegates to `session.AddSessionCategoryItem(command.SessionCategoryItemId, command.CategoryItemId)` (`AddSessionCategoryItemHandler.cs:34`). The aggregate rejects a duplicate with `Session.CategoryItem.Duplicate`, creates the child through its own factory, and raises [`SessionCategoryItemChanged`](group-17-conference-domain.md#sessioncategoryitemchanged) (`Session.cs:418-437`). A failure short-circuits with the domain errors converted to the generic failure type (`AddSessionCategoryItemHandler.cs:35-36`). - - Saves, logs through the generated `LogCategoryItemAddedToSession` (`AddSessionCategoryItemHandler.cs:38-40`, declared at `:45-46`), then maps the newly created child, `Result.Success(dtoMapper.MapToDTO(result.Value!))` (`:42`). The `!` is safe here only because the failure branch already returned. - - Note the return shape: this add handler returns a DTO, unlike the remove handlers in this chapter which return the bare [`Result`](group-01-result-error-handling.md#result). The controller needs the database-assigned join id to answer `201 Created` with a location header (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:225-228`). -- **Why it's built this way**: mapping after the save rather than before is what lets the DTO carry the identity the database generated, which is the whole reason the command's join id is nullable. -- **Where it's used**: registered by the module's application scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112`); injected into [`SessionCategoryItemsController`](group-20-conference-api-grpc.md#sessioncategoryitemscontroller) as `ICommandHandler>` (`SessionCategoryItemsController.cs:49`) and dispatched by its `POST` action, which also evicts the junction output cache (`SessionCategoryItemsController.cs:215-224`). Covered by `AddSessionCategoryItemHandlerTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/AddSessionCategoryItemHandlerTests.cs`). + - A primary constructor taking `unitOfWork`, the concrete `dtoMapper`, and a typed `ILogger` (`AddSessionCategoryItemHandler.cs:16-19`). + - `HandleAsync` (`:22-43`) resolves the write repository through [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) rather than injecting one (`:26`) and loads with `[nameof(Session.SessionCategoryItems)]` and `asTracking: true` (`:30`), returning a stamped `Error.NotFound` when the session is missing (`:31-32`). Tracking is as load-bearing as the include: an untracked graph would make the later save a silent no-op. + - Delegates to `session.AddSessionCategoryItem(command.SessionCategoryItemId, command.CategoryItemId)` (`:34`). The aggregate rejects a duplicate with `Session.CategoryItem.Duplicate`, creates the child through its own factory, and raises [`SessionCategoryItemChanged`](group-17-conference-domain.md#sessioncategoryitemchanged) (`Session.cs:418-437`). A failure short-circuits with the domain errors carried through unchanged (`:35-36`). + - Saves, logs through the source-generated `LogCategoryItemAddedToSession` (`:38-40`, declared at `:45-46`), then maps the newly created child, `Result.Success(dtoMapper.MapToDTO(result.Value!))` (`:42`). The null-forgiving `!` is safe only because the failure branch already returned. `[Rubric §13, Observability and Operability]`: one structured line, emitted once, after the save. + - Note the return shape: this add handler returns a DTO, unlike the remove handlers in this chapter which return the bare [`Result`](group-01-result-error-handling.md#result). The controller needs the database-assigned join id to answer `201 Created` with a location header (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:233-236`). +- **Why it's built this way**: mapping after the save rather than before is what lets the DTO carry the identity the database generated, which is the whole reason the command's join id is nullable. Atomicity is the one `SaveChangesAsync` covering the join row and the domain event the aggregate raised, both in the same `ADC_Conference` database ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html), [ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)). +- **Where it's used**: registered by the module's application scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`); injected into [`SessionCategoryItemsController`](group-20-conference-api-grpc.md#sessioncategoryitemscontroller) as `ICommandHandler>` (`SessionCategoryItemsController.cs:50`) and dispatched by its `POST` action, which is marked `[Idempotent]` so a retried request with the same `Idempotency-Key` replays the first response instead of adding a second row (`SessionCategoryItemsController.cs:212-218`), and which evicts the junction output cache before returning (`:232`). Covered by [`AddSessionCategoryItemHandlerTests`](group-27-testing-infrastructure.md#addsessioncategoryitemhandlertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/AddSessionCategoryItemHandlerTests.cs:12`). + +### AddSessionQuestionAnswerCommandValidator + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionQuestionAnswer` · `MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerCommandValidator.cs:8` · Level 10 · class (sealed) + +- **What it is**: the FluentValidation validator for [`AddSessionQuestionAnswerCommand`](#addsessionquestionanswercommand). One rule: `RuleFor(x => x.AnswerValue).NotEmpty()` with the message "Answer value is required." (`MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerCommandValidator.cs:10-13`). +- **Depends on**: FluentValidation's `AbstractValidator` and nothing else (`AddSessionQuestionAnswerCommandValidator.cs:1`, `:8`). +- **Concept reinforced**: the validating decorator stage, taught on [`AddSessionCategoryItemCommandValidator`](#addsessioncategoryitemcommandvalidator). The handler never calls this class; registration is by the convention scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`). +- **Why it's built this way**: `NotEmpty` covers null, empty, and whitespace-only text, which is all that can be judged without knowing the question. The *semantic* check, that the answer matches the question's declared type, needs the [`Question`](group-17-conference-domain.md#question) row and therefore lives in the handler as BR-124 (`AddSessionQuestionAnswerHandler.cs:102-103`). Shape rules here, data-dependent rules where the data is. `[Rubric §24, Forms, Validation and UX Safety]`. +- **Where it's used**: resolved by the validating decorator for [`AddSessionQuestionAnswerCommand`](#addsessionquestionanswercommand); covered by [`AddSessionQuestionAnswerCommandValidatorTests`](group-27-testing-infrastructure.md#addsessionquestionanswercommandvalidatortests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionCommandValidatorTests.cs:8`). + +### AddSessionQuestionAnswerHandler + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionQuestionAnswer` · `MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerHandler.cs:20` · Level 10 · class (sealed partial) + +- **What it is**: the richest write path among the session child commands. Where the other Add handlers are three-step orchestrations, this one runs a chain of business rules before it decides between creating a new answer and updating the caller's existing one, and raises a cross-module integration event on the create branch only (`MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerHandler.cs:14-19` names the rules: BR-91, BR-49, BR-108, BR-128, BR-124, BR-107). +- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) closed over the command and `Result` (`AddSessionQuestionAnswerHandler.cs:25`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`ICurrentUserService`](group-08-auth.md#icurrentuserservice), [`SessionQuestionAnswerDTOMapper`](#sessionquestionanswerdtomapper), the BCL `TimeProvider`, the [`Session`](group-17-conference-domain.md#session), [`Event`](group-17-conference-domain.md#event), and [`Question`](group-17-conference-domain.md#question) aggregates with their invariant helpers ([`SessionInvariants`](group-17-conference-domain.md#sessioninvariants), [`EventInvariants`](group-17-conference-domain.md#eventinvariants), [`QuestionInvariants`](group-17-conference-domain.md#questioninvariants)), [`SessionFeedbackSubmitted`](group-17-conference-domain.md#sessionfeedbacksubmitted), [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error), and logging (`AddSessionQuestionAnswerHandler.cs:1-10`, `:20-25`). +- **Concept introduced, an application-level upsert over an aggregate, and where its race is caught**: `[Rubric §6, CQRS and Event-Driven]` assesses whether a command slice owns its full decision, and `[Rubric §8, Data Architecture]` assesses whether an integrity rule has a database-level guarantee and not only an in-memory one. BR-107 says one live answer per (session, question, author), so the handler looks for an existing non-deleted answer by the current user for this question in the **already loaded** child collection (`:53-54`) and branches: found means update, not found means create (`:56-59`). That check is in-memory by construction, so two concurrent submissions can both take the create branch. The database is the backstop: a unique index on `(SessionId, QuestionId, CreatedBy)` stops the second write (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SessionQuestionAnswerConfiguration.cs:43-44`). Reading the handler alone would leave you thinking the rule is best-effort; reading the pair shows the real guarantee. +- **Concept introduced, an integration event raised on the aggregate pre-save so the outbox captures it atomically**: `[Rubric §7, Microservices Readiness]` assesses whether modules collaborate without reaching into each other's data. On the create branch only, the handler calls `session.AddDomainEvent(new SessionFeedbackSubmitted(userId, session.Id, session.EventId, timeProvider.GetUtcNow().UtcDateTime))` (`:134`) *before* the save, so the event row and the answer row land in the same transaction ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)); the comment above the call states exactly that (`:131-133`). Engagement consumes it to award feedback points (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/SessionFeedbackSubmittedPointsHandler.cs:28`). This is also why the handler takes an injected `TimeProvider` (`:24`) rather than reading `DateTime.UtcNow`: the timestamp on the event is testable. +- **Walkthrough**: five members, and the ordering between them is the rule hierarchy. + - `HandleAsync` (`:28-60`) loads the session **with** its `SessionQuestionAnswers` and `asTracking: true` (`:33-37`), because both the upsert lookup and the subsequent mutation need the children tracked. A missing session is a stamped `Error.NotFound` (`:38-39`). + - `ValidateSessionEligibilityAsync` (`:62-83`) reuses domain invariants rather than restating them: `SessionInvariants.EnsureNotServiceSession` (BR-91, a break or a lunch slot takes no feedback, `:67`, defined at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:91`), `SessionInvariants.EnsureStatusIsEligible` (BR-49, the same allow-list [`PublicSessionStatusSpecification`](#publicsessionstatusspecification) expresses for reads, `:72`, defined at `SessionInvariants.cs:107`), then a load of the parent [`Event`](group-17-conference-domain.md#event) and `EventInvariants.EnsureEventIsPublished` (BR-108, `:77-82`, defined at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:156`). Each check short-circuits on failure. + - `ValidateQuestionAsync` (`:85-104`) loads the [`Question`](group-17-conference-domain.md#question) and rejects one that does not exist or whose `QuestionEntity` is not `"Session"` with a validation error coded `Question.NotFoundOrWrongEntity` (BR-128, `:90-99`), then hands the answer text to `QuestionInvariants.EnsureAnswerValueMatchesQuestionType` (BR-124, `:102-103`, defined at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:115`). + - `UpdateExistingAnswerAsync` (`:106-119`) calls `session.UpdateSessionQuestionAnswer(existingAnswer.Id, command.AnswerValue)`, saves, and maps the same tracked instance back out, so the response carries the new value. + - `CreateNewAnswerAsync` (`:121-139`) calls `session.AddSessionQuestionAnswer(...)` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:512`), raises the integration event, saves, and maps the child the aggregate returned. Both branches emit the same `LogQuestionAnswerAddedToSession` line (`:141-142`), so the log does not distinguish an insert from an update. +- **Why it's built this way**: eligibility rules are shared with other callers, so the handler composes helpers instead of copying conditions, and every check returns a [`Result`](group-01-result-error-handling.md#result) that folds into the same failure channel ([ADR-013](https://ivanball.github.io/docs/adr/013-result-pattern.html)). `[Rubric §3, Clean Architecture]`: the two cross-aggregate reads go through [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) repositories, never EF types. +- **Where it's used**: injected into [`SessionQuestionAnswersController`](group-20-conference-api-grpc.md#sessionquestionanswerscontroller) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:59`), whose whole surface requires an authenticated caller (`:56`), from the `[Idempotent]` `POST /SessionQuestionAnswers` action (`:183-190`). Covered by [`AddSessionQuestionAnswerHandlerTests`](group-27-testing-infrastructure.md#addsessionquestionanswerhandlertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/AddSessionQuestionAnswerHandlerTests.cs:14`). +- **Caveats / not-in-source**: `currentUserService.UserId!.Value` (`:52`) is null-forgiving. Nothing inside this handler enforces that a user id is present; the guarantee comes from the controller policy, so a caller reaching this code with no id would fault rather than fail gracefully. The three validation steps each issue their own round-trip (session, event, question), which is three reads before any write on the create path. + +### SessionCategoryItemNavigationPopulator + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions` · `MMCA.ADC.Conference.Application/Sessions/SessionCategoryItemNavigationPopulator.cs:11` · Level 10 · class (sealed) + +- **What it is**: the navigation populator for the [`SessionCategoryItem`](group-17-conference-domain.md#sessioncategoryitem) join entity when it is read *as its own entity* rather than as a child of a session. It hydrates one navigation: the parent `Session` back-reference (`MMCA.ADC.Conference.Application/Sessions/SessionCategoryItemNavigationPopulator.cs:7-9`). +- **Depends on**: [`DeclarativeNavigationPopulator`](group-11-navigation-populators.md#declarativenavigationpopulatortentity) closed over [`SessionCategoryItem`](group-17-conference-domain.md#sessioncategoryitem) (`SessionCategoryItemNavigationPopulator.cs:13`); [`FKNavigationDescriptor`](group-11-navigation-populators.md#fknavigationdescriptortentity-tchild-tchildid) (`:15`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), passed straight through to the base (`:12-13`); and the [`Session`](group-17-conference-domain.md#session) aggregate as the FK target. +- **Concept introduced, the FK direction of a declarative populator**: [Group 11](group-11-navigation-populators.md#declarativenavigationpopulatortentity) teaches the populator pattern itself; what this file introduces for the session slice is the *reference* direction, as opposed to the collection direction [`SessionNavigationPopulator`](#sessionnavigationpopulator) uses. A `FKNavigationDescriptor` reads the nullable foreign key off each parent, batches the distinct values into one `WHERE FK IN (...)` query against the target's read repository, groups the results, and assigns each parent its match (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/NavigationLoader.cs:54-90`). The `AssignAction` here therefore ends in `FirstOrDefault()` (`SessionCategoryItemNavigationPopulator.cs:20`), because a reference navigation wants one row out of a list. The descriptor also declares `RequiresChildren => false` (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/FKNavigationDescriptor.cs:23`), which is what lets a caller ask for FK references without paying for child collections: the base checks that flag against the `includeFKs` / `includeChildren` arguments before loading anything (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/DeclarativeNavigationPopulator.cs:36-40`). `[Rubric §12, Performance and Scalability]`: one batched query for the whole page of rows, never one per row. `[Rubric §2, Design Patterns]`: Template Method configured by data rather than by virtual methods. +- **Walkthrough**: the class body is empty (`SessionCategoryItemNavigationPopulator.cs:23-24`). Everything it says is said in the base-constructor argument list: one descriptor with `PropertyName = nameof(SessionCategoryItem.Session)` (`:17`), `ParentKeySelector = e => e.SessionId` (`:18`), `ChildForeignKeySelector = child => child.Id` (`:19`), and `AssignAction = (e, sessions) => e.Session = sessions.FirstOrDefault()` (`:20`). `PropertyName` is not decoration: the base loads a descriptor only when that exact property name appears in the query's `UnsupportedIncludes` metadata (`DeclarativeNavigationPopulator.cs:30-38`), so a name typo means a silently unpopulated navigation rather than a compile error. +- **Why it's built this way**: this indirection exists because of the cross-source degradation rule. When a relationship can span physical data sources, EF's navigation is stripped and only the scalar foreign key survives, so hydration has to be a second batched query rather than an `Include` ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html), [ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)). `[Rubric §3, Clean Architecture]`: the Application layer describes hydration with repository abstractions and property selectors, with no EF Core namespace anywhere in the file. +- **Where it's used**: registered as the `INavigationPopulator` implementation, `services.TryAddScoped, SessionCategoryItemNavigationPopulator>()` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:104`), directly above the base [`EntityQueryService`](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype) registration for the same entity (`:105`), which is the pairing that puts it on every direct read of a session category item ([ADR-034](https://ivanball.github.io/docs/adr/034-generic-entity-query-layer.html)). Covered by [`SessionCategoryItemNavigationPopulatorTests`](group-27-testing-infrastructure.md#sessioncategoryitemnavigationpopulatortests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/SessionCategoryItemNavigationPopulatorTests.cs:9`). ### SessionDTOMapper > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.DTOs` · `MMCA.ADC.Conference.Application/Sessions/DTOs/SessionDTOMapper.cs:14` · Level 10 · class (sealed partial) -- **What it is**: the mapper that turns a [`Session`](group-17-conference-domain.md#session) aggregate into a [`SessionDTO`](group-17-conference-domain.md#sessiondto), including its three child collections. It is the composite of the three child mappers above, and the reason it sits a level higher than they do. -- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) closed over [`Session`](group-17-conference-domain.md#session) / [`SessionDTO`](group-17-conference-domain.md#sessiondto) / `SessionIdentifierType` (`SessionDTOMapper.cs:18`); [`SessionSpeakerDTOMapper`](#sessionspeakerdtomapper), [`SessionQuestionAnswerDTOMapper`](#sessionquestionanswerdtomapper), and [`SessionCategoryItemDTOMapper`](#sessioncategoryitemdtomapper), all three taken by primary constructor (`SessionDTOMapper.cs:14-17`); `Riok.Mapperly.Abstractions`. -- **Concept introduced, composing generated mappers with `[UseMapper]`**: the three injected mappers are stored in fields marked `[UseMapper]` (`SessionDTOMapper.cs:20-27`). That attribute tells Mapperly: when you need to map a `SessionSpeaker` while generating the body of `MapToDTO(Session)`, do not invent a nested mapping, call this field. The result is one generated method per type, reused wherever the type appears, instead of a copy of the child mapping inlined into every parent. Change how a `SessionSpeaker` projects and every parent DTO that embeds one follows automatically. `[Rubric §1, SOLID]`: each mapper has one reason to change; `[Rubric §16, Maintainability]`: the composition is declared in three fields rather than maintained as duplicated assignment code. -- **Walkthrough**: three `[UseMapper]` readonly fields assigned from the primary constructor parameters (`SessionDTOMapper.cs:20-27`), the `partial` `MapToDTO` the generator fills in (`SessionDTOMapper.cs:30`), and the hand-written `MapToDTOs` with its null guard and `Select` spread (`SessionDTOMapper.cs:33-37`). The class is `sealed partial` and carries `[Mapper]` (`SessionDTOMapper.cs:13-14`), which is what makes the generation happen at all. +- **What it is**: the mapper that turns a [`Session`](group-17-conference-domain.md#session) aggregate into a [`SessionDTO`](group-17-conference-domain.md#sessiondto), including its three child collections. It is the composite of the three child mappers in this unit, and that is why it sits a level above them. +- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) closed over [`Session`](group-17-conference-domain.md#session) / [`SessionDTO`](group-17-conference-domain.md#sessiondto) / `SessionIdentifierType` (`MMCA.ADC.Conference.Application/Sessions/DTOs/SessionDTOMapper.cs:18`); [`SessionSpeakerDTOMapper`](#sessionspeakerdtomapper), [`SessionQuestionAnswerDTOMapper`](#sessionquestionanswerdtomapper), and [`SessionCategoryItemDTOMapper`](#sessioncategoryitemdtomapper), all three taken by primary constructor (`SessionDTOMapper.cs:14-17`); `Riok.Mapperly.Abstractions`. +- **Concept introduced, composing generated mappers with `[UseMapper]`**: the three injected mappers are stored in fields marked `[UseMapper]` (`SessionDTOMapper.cs:20-27`). That attribute tells Mapperly: when you need to map a `SessionSpeaker` while generating the body of `MapToDTO(Session)`, do not invent a nested mapping, call this field. The result is one generated method per type, reused wherever the type appears, instead of a copy of the child mapping inlined into every parent. Change how a `SessionSpeaker` projects and every parent DTO that embeds one follows automatically. `[Rubric §1, SOLID]`: each mapper has one reason to change. `[Rubric §16, Maintainability]`: the composition is declared in three fields rather than maintained as duplicated assignment code. +- **Walkthrough**: three `[UseMapper]` readonly fields assigned from the primary-constructor parameters (`SessionDTOMapper.cs:20-27`), the `partial` `MapToDTO` the generator fills in (`:30`), and the hand-written `MapToDTOs` with its null guard and `Select` spread (`:33-37`). The class is `sealed partial` and carries `[Mapper]` (`:13-14`), which is what makes the generation happen at all. - **Why it's built this way**: generated composition keeps the DTO projection compile-checked end to end, so adding a property to [`SessionDTO`](group-17-conference-domain.md#sessiondto) that no entity property feeds is a build error rather than a null in a response ([ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html)). -- **Where it's used**: injected as the concrete type into [`CreateSessionHandler`](#createsessionhandler) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/Create/CreateSessionHandler.cs:26`, mapped at `CreateSessionHandler.cs:135`) and [`UpdateSessionHandler`](#updatesessionhandler) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionHandler.cs:19`, mapped at `UpdateSessionHandler.cs:97`) to shape the response of a write; resolved as `IEntityDTOMapper` by the generic [`EntityQueryService`](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype) registered for sessions (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:59`), which is what puts it on every session read ([ADR-034](https://ivanball.github.io/docs/adr/034-generic-entity-query-layer.html)). Registered self-and-interfaces by the convention scan (`DependencyInjection.cs:112`). Covered by `SessionDTOMapperTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/DTOs/SessionDTOMapperTests.cs`). -- **Caveats / not-in-source**: the generated file is not in the repository, so which properties actually get copied is observable only from the two type definitions and a build. In particular, whether the child collections are populated at map time depends entirely on whether the read path ran [`SessionNavigationPopulator`](#sessionnavigationpopulator) first; the mapper maps what it is given. +- **Where it's used**: injected as the concrete type into [`CreateSessionHandler`](#createsessionhandler) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/Create/CreateSessionHandler.cs:26`, mapped at `:135`) and [`UpdateSessionHandler`](#updatesessionhandler) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionHandler.cs:19`, mapped at `:97`) to shape the response of a write; resolved as `IEntityDTOMapper` by the generic [`EntityQueryService`](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype) registered for sessions (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:63`), which is what puts it on every session read ([ADR-034](https://ivanball.github.io/docs/adr/034-generic-entity-query-layer.html)). Registered self-and-interfaces by the convention scan (`DependencyInjection.cs:125`). Covered by [`SessionDTOMapperTests`](group-27-testing-infrastructure.md#sessiondtomappertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/DTOs/SessionDTOMapperTests.cs:8`). +- **Caveats / not-in-source**: the generated file is not in the repository, so which properties actually get copied is observable only from the two type definitions and a build. In particular, whether the child collections or the `Event` and `Room` references are populated at map time depends entirely on whether the read path ran [`SessionNavigationPopulator`](#sessionnavigationpopulator) for them first; the mapper maps what it is given. ### SessionNavigationPopulator -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions` · `MMCA.ADC.Conference.Application/Sessions/SessionNavigationPopulator.cs:12` · Level 10 · class (sealed) +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions` · `MMCA.ADC.Conference.Application/Sessions/SessionNavigationPopulator.cs:13` · Level 10 · class (sealed) -- **What it is**: the navigation populator for the [`Session`](group-17-conference-domain.md#session) aggregate. It loads the three child collections that EF Core cannot materialize through `.Include()` on this model: `SessionSpeakers`, `SessionQuestionAnswers`, and `SessionCategoryItems` (`SessionNavigationPopulator.cs:7-10`). It is the richest populator in the Conference module and, notably, has an empty class body. -- **Depends on**: [`DeclarativeNavigationPopulator`](group-11-navigation-populators.md#declarativenavigationpopulatortentity) closed over [`Session`](group-17-conference-domain.md#session) (`SessionNavigationPopulator.cs:14`); [`ChildNavigationDescriptor`](group-11-navigation-populators.md#childnavigationdescriptortentity-tparentid-tchild-tchildid); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), passed straight through to the base; and the [`SessionSpeaker`](group-17-conference-domain.md#sessionspeaker), [`SessionQuestionAnswer`](group-17-conference-domain.md#sessionquestionanswer), and [`SessionCategoryItem`](group-17-conference-domain.md#sessioncategoryitem) child entities. -- **Concept reinforced, declarative child loading instead of hand-written joins**: the mechanism is taught in [Group 11](group-11-navigation-populators.md#declarativenavigationpopulatortentity) ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html)); the job here is pure binding. The subclass supplies data, not overrides: the base constructor takes the unit of work plus a collection-expression array of descriptors (`SessionNavigationPopulator.cs:12-37`) and owns the bulk fetch-and-assign algorithm, so the class body is genuinely empty (`SessionNavigationPopulator.cs:38-39`). The reason this indirection exists at all is the cross-source degradation rule: when a relationship spans physical data sources, EF's navigation is stripped and only the scalar foreign key survives, so hydration has to be a second batched query rather than an `Include` ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)). `[Rubric §2, Design Patterns]`: Template Method configured by data rather than by virtual methods. `[Rubric §3, Clean Architecture]`: the Application layer describes hydration with repository abstractions and property selectors, with no EF Core namespace in the file. `[Rubric §12, Performance and Scalability]`: the base batches one query per child collection across all parents, not one per parent. -- **Walkthrough**: three descriptors, each supplying the same four settings. - - `SessionSpeakers` (`SessionNavigationPopulator.cs:16-22`): `PropertyName = nameof(Session.SessionSpeakers)` (`:18`), `ParentKeySelector = e => e.Id` (`:19`), `ChildForeignKeySelector = child => child.SessionId` (`:20`), `AssignAction = (e, sessionSpeakers) => e.SetSessionSpeakers(sessionSpeakers)` (`:21`). - - `SessionQuestionAnswers` (`SessionNavigationPopulator.cs:23-29`) and `SessionCategoryItems` (`:30-36`) repeat the shape against their own child types and `SetSessionQuestionAnswers` / `SetSessionCategoryItems` mutators (`:28`, `:35`). - - Every `AssignAction` goes through the aggregate's own setter rather than a back-door property assignment, so hydration passes through the same door a business operation would. `PropertyName` is not decoration: the base uses it to build the navigation metadata that marks the property as populated, which is how a caller knows the collection is a real empty list and not merely unloaded. -- **Why it's built this way**: one descriptor per collection makes adding a child navigation a data edit rather than a new query method, and every aggregate in the module hydrates through one code path ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html)). -- **Where it's used**: registered as the `INavigationPopulator` implementation, `services.TryAddScoped, SessionNavigationPopulator>()` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:58`), and resolved by the navigation-population step of the generic query layer whenever a `Session` is read with any of these three collections requested ([ADR-034](https://ivanball.github.io/docs/adr/034-generic-entity-query-layer.html)). Its output is what [`SessionDTOMapper`](#sessiondtomapper) projects. Covered by `SessionNavigationPopulatorTests` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/SessionNavigationPopulatorTests.cs`). +- **What it is**: the navigation populator for the [`Session`](group-17-conference-domain.md#session) aggregate, and the richest one in the Conference module. It declares **five** navigations that EF Core cannot materialize through `.Include()` on this model: FK references to `Event` and `Room`, and the three child collections `SessionSpeakers`, `SessionQuestionAnswers`, and `SessionCategoryItems` (`MMCA.ADC.Conference.Application/Sessions/SessionNavigationPopulator.cs:8-12`). Notably, its class body is empty. +- **Depends on**: [`DeclarativeNavigationPopulator`](group-11-navigation-populators.md#declarativenavigationpopulatortentity) closed over [`Session`](group-17-conference-domain.md#session) (`SessionNavigationPopulator.cs:15`); both descriptor kinds, [`FKNavigationDescriptor`](group-11-navigation-populators.md#fknavigationdescriptortentity-tchild-tchildid) (`:17`, `:24`) and [`ChildNavigationDescriptor`](group-11-navigation-populators.md#childnavigationdescriptortentity-tparentid-tchild-tchildid) (`:31`, `:38`, `:45`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), passed straight through to the base (`:14-15`); and the [`Event`](group-17-conference-domain.md#event), [`Room`](group-17-conference-domain.md#room), [`SessionSpeaker`](group-17-conference-domain.md#sessionspeaker), [`SessionQuestionAnswer`](group-17-conference-domain.md#sessionquestionanswer), and [`SessionCategoryItem`](group-17-conference-domain.md#sessioncategoryitem) types. +- **Concept introduced, the two descriptor kinds side by side**: this is the one file in the session slice where both appear, so it is the clearest place to see the difference. An `FKNavigationDescriptor` walks *forward* along a foreign key the parent holds and assigns one row (`AssignAction` ends in `FirstOrDefault()`, `:22`, `:29`); a `ChildNavigationDescriptor` walks *backward* from a foreign key the children hold and assigns the whole list (`:36`, `:43`, `:50`). The base treats them differently at load time: `RequiresChildren` is `false` on the FK kind (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/FKNavigationDescriptor.cs:23`) and `true` on the child kind (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/ChildNavigationDescriptor.cs:25`), and it tests that flag against the caller's `includeFKs` and `includeChildren` arguments before loading (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/DeclarativeNavigationPopulator.cs:36-40`). A session list can therefore fetch its room and event labels without dragging every answer row along with them. `[Rubric §12, Performance and Scalability]` assesses whether a read pays only for what it asked for; `[Rubric §2, Design Patterns]`: the subclass supplies data, not overrides, which is why the body is genuinely empty (`SessionNavigationPopulator.cs:53-54`). +- **Walkthrough**: five descriptors, each supplying the same four settings. + - `Event` (`SessionNavigationPopulator.cs:17-23`): `PropertyName = nameof(Session.Event)` (`:19`), `ParentKeySelector = e => e.EventId` (`:20`), `ChildForeignKeySelector = child => child.Id` (`:21`), `AssignAction = (e, events) => e.Event = events.FirstOrDefault()` (`:22`). + - `Room` (`:24-30`) repeats that shape over `RoomId`, which is nullable on the session because a session need not be scheduled into a room yet; `LoadFKPropertyAsync` drops null keys before it builds the `IN` clause and assigns an empty list to every parent when no key survives (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/NavigationLoader.cs:54-69`). + - `SessionSpeakers` (`:31-37`), `SessionQuestionAnswers` (`:38-44`), and `SessionCategoryItems` (`:45-51`) each invert the direction: `ParentKeySelector = e => e.Id`, `ChildForeignKeySelector = child => child.SessionId`, and an `AssignAction` that calls the aggregate's own `SetSessionSpeakers` / `SetSessionQuestionAnswers` / `SetSessionCategoryItems` mutator (`:36`, `:43`, `:50`, declared at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:403`, `:577`, `:500`). + - Every `AssignAction` goes through an aggregate member rather than a back-door property write, so hydration passes through the same door a business operation would. `[Rubric §4, Domain-Driven Design]`. +- **Why it's built this way**: one descriptor per navigation makes adding a relationship a data edit rather than a new query method, and every aggregate in the module hydrates through one code path ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html)). +- **Where it's used**: registered as the `INavigationPopulator` implementation, `services.TryAddScoped, SessionNavigationPopulator>()` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:62`), immediately above the session query-service and custom-delete registrations that complete the aggregate's block (`:63-64`), and resolved by the navigation-population step of the generic query layer whenever a `Session` is read with any of these five navigations requested ([ADR-034](https://ivanball.github.io/docs/adr/034-generic-entity-query-layer.html)). Its output is what [`SessionDTOMapper`](#sessiondtomapper) projects. Covered by [`SessionNavigationPopulatorTests`](group-27-testing-infrastructure.md#sessionnavigationpopulatortests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/SessionNavigationPopulatorTests.cs:9`). +- **Caveats / not-in-source**: the write handlers in this unit do not go through this populator at all. [`AddSessionCategoryItemHandler`](#addsessioncategoryitemhandler) and [`AddSessionQuestionAnswerHandler`](#addsessionquestionanswerhandler) pass an explicit `includes` array to the repository instead, because they need a *tracked* graph and this populator's loads are untracked (`NavigationLoader.cs:80-84`). Read paths and write paths hydrate the same collections by two different mechanisms, and nothing in either file cross-references the other. -### AddSessionQuestionAnswerCommand +### SessionQuestionAnswerNavigationPopulator -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionQuestionAnswer` · `MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerCommand.cs:11` · Level 9 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions` · `MMCA.ADC.Conference.Application/Sessions/SessionQuestionAnswerNavigationPopulator.cs:11` · Level 10 · class (sealed) + +- **What it is**: the navigation populator for [`SessionQuestionAnswer`](group-17-conference-domain.md#sessionquestionanswer) read as its own entity. One navigation: the parent `Session` back-reference (`MMCA.ADC.Conference.Application/Sessions/SessionQuestionAnswerNavigationPopulator.cs:7-9`). +- **Depends on**: [`DeclarativeNavigationPopulator`](group-11-navigation-populators.md#declarativenavigationpopulatortentity) closed over [`SessionQuestionAnswer`](group-17-conference-domain.md#sessionquestionanswer) (`SessionQuestionAnswerNavigationPopulator.cs:13`); [`FKNavigationDescriptor`](group-11-navigation-populators.md#fknavigationdescriptortentity-tchild-tchildid) (`:15`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:12`); the [`Session`](group-17-conference-domain.md#session) aggregate as the FK target. +- **Concept reinforced**: none new. Structurally identical to [`SessionCategoryItemNavigationPopulator`](#sessioncategoryitemnavigationpopulator), which teaches the FK direction: `PropertyName = nameof(SessionQuestionAnswer.Session)` (`:17`), `ParentKeySelector = e => e.SessionId` (`:18`), `ChildForeignKeySelector = child => child.Id` (`:19`), `AssignAction = (e, sessions) => e.Session = sessions.FirstOrDefault()` (`:20`), and an empty class body (`:23-24`). +- **Where it's used**: registered at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:107`, paired with the base [`EntityQueryService`](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype) for the same entity (`:108`). Covered by [`SessionQuestionAnswerNavigationPopulatorTests`](group-27-testing-infrastructure.md#sessionquestionanswernavigationpopulatortests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/SessionQuestionAnswerNavigationPopulatorTests.cs:9`). +- **Caveats / not-in-source**: this populator hydrates the parent session on an answer row, but it applies no visibility filter of its own; the eligibility rules that gate *writing* an answer ([`AddSessionQuestionAnswerHandler`](#addsessionquestionanswerhandler)) have no counterpart here. Whether a direct answer read is scoped is decided by the query's specification, not by this file. + +### SessionSpeakerNavigationPopulator -- **What it is**: the message an attendee's session feedback travels on. Four positional fields: the owning `SessionId`, an optional `SessionQuestionAnswerId` for the answer row, the `QuestionId` being answered, and the `AnswerValue` text (`AddSessionQuestionAnswerCommand.cs:11-15`). -- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (the pipeline marker it implements, `AddSessionQuestionAnswerCommand.cs:15`), the [`Session`](group-17-conference-domain.md#session) domain type (used only for its `FullName` when building the cache prefix), and the `SessionIdentifierType` / `SessionQuestionAnswerIdentifierType` / `QuestionIdentifierType` module aliases ([ADR-048](https://ivanball.github.io/docs/adr/048-primitive-identifier-type-aliases.html)). -- **Concept introduced, the nullable child id on an Add command**: the second parameter is `SessionQuestionAnswerIdentifierType?`, documented as "explicit ID for the answer entity, or `null` for database-generated identity" (`AddSessionQuestionAnswerCommand.cs:8`). The REST path always passes `null` and lets the database assign the key (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:182`); the parameter exists so a caller that already knows the id can supply it, which is the shape the aggregate's `AddSessionQuestionAnswer` takes (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:512`). `[Rubric §9, API and Contract Design]` assesses whether a contract states exactly what a caller may decide: a nullable id rather than a defaulted one keeps "let the database choose" distinct from "I chose zero". -- **Walkthrough**: the record body holds one member, `CachePrefix => $"{typeof(Session).FullName}:"` (`AddSessionQuestionAnswerCommand.cs:17-18`). That satisfies [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating), so the caching decorator evicts every entry under the `Session` prefix after the command succeeds. Every session read keys under that same prefix, including [`GetNowNextQuery`](#getnownextquery) (`GetNowNextQuery.cs:33`), so one answer submission flushes the session projections in one stroke instead of requiring per-query bookkeeping. -- **Why it's built this way**: the command carries no author id. Ownership is resolved server side from [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) inside [`AddSessionQuestionAnswerHandler`](#addsessionquestionanswerhandler) (`AddSessionQuestionAnswerHandler.cs:52`), so a client cannot submit feedback as someone else. `[Rubric §11, Security]`: identity is never a request field. `[Rubric §10, Cross-Cutting]`: the command declares what it invalidates and knows nothing about how ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html), [ADR-026](https://ivanball.github.io/docs/adr/026-caching-strategy.html)). -- **Where it's used**: validated by [`AddSessionQuestionAnswerCommandValidator`](#addsessionquestionanswercommandvalidator), handled by [`AddSessionQuestionAnswerHandler`](#addsessionquestionanswerhandler), and built from an `AddSessionQuestionAnswerRequest` by the `POST /SessionQuestionAnswers` endpoint (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:176-182`), on a controller whose whole surface requires an authenticated caller (`SessionQuestionAnswersController.cs:55`). +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions` · `MMCA.ADC.Conference.Application/Sessions/SessionSpeakerNavigationPopulator.cs:11` · Level 10 · class (sealed) + +- **What it is**: the navigation populator for [`SessionSpeaker`](group-17-conference-domain.md#sessionspeaker) read as its own entity. One navigation: the parent `Session` back-reference (`MMCA.ADC.Conference.Application/Sessions/SessionSpeakerNavigationPopulator.cs:7-9`). +- **Depends on**: [`DeclarativeNavigationPopulator`](group-11-navigation-populators.md#declarativenavigationpopulatortentity) closed over [`SessionSpeaker`](group-17-conference-domain.md#sessionspeaker) (`SessionSpeakerNavigationPopulator.cs:13`); [`FKNavigationDescriptor`](group-11-navigation-populators.md#fknavigationdescriptortentity-tchild-tchildid) (`:15`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:12`); the [`Session`](group-17-conference-domain.md#session) aggregate as the FK target. +- **Concept reinforced**: none new; see [`SessionCategoryItemNavigationPopulator`](#sessioncategoryitemnavigationpopulator). Same single descriptor over `PropertyName = nameof(SessionSpeaker.Session)` (`:17`), `ParentKeySelector = e => e.SessionId` (`:18`), `ChildForeignKeySelector = child => child.Id` (`:19`), `AssignAction` ending in `FirstOrDefault()` (`:20`), and an empty class body (`:23-24`). Worth noticing what is absent: the descriptor list does not include the `Speaker` side of the join, so a directly read `SessionSpeaker` gets its session hydrated but not its speaker. +- **Where it's used**: registered at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:101`, paired with the base [`EntityQueryService`](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype) for the same entity (`:102`). Covered by [`SessionSpeakerNavigationPopulatorTests`](group-27-testing-infrastructure.md#sessionspeakernavigationpopulatortests), which pins the type to `INavigationPopulator` and asserts the empty-collection short circuit (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/SessionSpeakerNavigationPopulatorTests.cs:9`, `:21`, `:24-30`). +- **Caveats / not-in-source**: no source comment explains why the `Speaker` navigation is left out of this descriptor list while `Session` is present. It is consistent with the module's other join populators, which all hydrate one side only, but the file itself does not say so. ### AddSessionSpeakerCommand > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionSpeaker` · `MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionSpeaker/AddSessionSpeakerCommand.cs:10` · Level 9 · record -- **What it is**: the command that associates a speaker with a session: `SessionId`, the optional join id `SessionSpeakerId`, and the `SpeakerId` (`AddSessionSpeakerCommand.cs:10-13`). -- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`AddSessionSpeakerCommand.cs:13`), the [`Session`](group-17-conference-domain.md#session) type for the prefix, and the `SessionIdentifierType` / `SessionSpeakerIdentifierType` / `SpeakerIdentifierType` aliases. Note that `SpeakerIdentifierType` is a `Guid` in this module while the session ids are integers, which is exactly why the aliases exist rather than bare primitives ([ADR-048](https://ivanball.github.io/docs/adr/048-primitive-identifier-type-aliases.html)). -- **Concept introduced**: none new; the nullable child id and `CachePrefix => $"{typeof(Session).FullName}:"` (`AddSessionSpeakerCommand.cs:15-16`) work exactly as taught on [`AddSessionQuestionAnswerCommand`](#addsessionquestionanswercommand). The one thing worth noticing is what this command does *not* carry: no ordering, no role, no display flag. Everything else about the association is the join entity's own business, decided inside the aggregate. -- **Where it's used**: validated by [`AddSessionSpeakerCommandValidator`](#addsessionspeakercommandvalidator), handled by [`AddSessionSpeakerHandler`](#addsessionspeakerhandler), and constructed by the `POST /SessionSpeakers` endpoint (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:210-216`) on a controller gated by the `SessionsManage` permission (`SessionSpeakersController.cs:46`, [ADR-020](https://ivanball.github.io/docs/adr/020-permission-based-authorization.html)). +- **What it is**: the command that attaches a speaker to an existing session. Three positional parameters: the owning `SessionId`, an optional `SessionSpeakerId` for the join row, and the `SpeakerId` being associated (`MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionSpeaker/AddSessionSpeakerCommand.cs:10-13`). +- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`AddSessionSpeakerCommand.cs:13`); the [`Session`](group-17-conference-domain.md#session) type, referenced only for its `FullName` when building the cache prefix; and the `SessionIdentifierType` / `SessionSpeakerIdentifierType` / `SpeakerIdentifierType` module aliases ([ADR-048](https://ivanball.github.io/docs/adr/048-primitive-identifier-type-aliases.html)). +- **Concept reinforced, the nullable child id on an Add command**: the second parameter is `SessionSpeakerIdentifierType?`, documented in the file as "Explicit ID for the join entity, or `null` for database-generated identity" (`AddSessionSpeakerCommand.cs:8`). The REST path always passes `null` and lets the database assign the key (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:224`); the parameter exists because that is the exact shape the aggregate method takes (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:318-320`). The same shape is taught on [`AddSessionCategoryItemCommand`](#addsessioncategoryitemcommand). +- **Walkthrough**: the record body is a single member, `CachePrefix => $"{typeof(Session).FullName}:"` (`AddSessionSpeakerCommand.cs:15-16`). That is the session-wide prefix every session write in this module declares, so one eviction after a successful command clears every cached session projection instead of requiring per-query bookkeeping ([ADR-026](https://ivanball.github.io/docs/adr/026-caching-strategy.html)). The command itself never touches a cache: it declares what it invalidates and the caching decorator does the work ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). `[Rubric §10, Cross-Cutting]` assesses whether concerns like caching live in one pipeline stage rather than being re-implemented per handler; here the handler has no cache code at all. +- **Where it's used**: constructed by the hand-written `POST /SessionSpeakers` action of [`SessionSpeakersController`](group-20-conference-api-grpc.md#sessionspeakerscontroller) from an [`AddSessionSpeakerRequest`](group-20-conference-api-grpc.md#addsessionspeakerrequest) body (`SessionSpeakersController.cs:217-225`), on a controller gated by the `SessionsManage` permission (`SessionSpeakersController.cs:47`, [ADR-020](https://ivanball.github.io/docs/adr/020-permission-based-authorization.html)) and marked `[Idempotent]` so a retried request replays the first response rather than adding a second row (`SessionSpeakersController.cs:218`, [ADR-017](https://ivanball.github.io/docs/adr/017-request-idempotency.html)); validated by [`AddSessionSpeakerCommandValidator`](#addsessionspeakercommandvalidator); handled by [`AddSessionSpeakerHandler`](#addsessionspeakerhandler). ### DeleteSessionHandler > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.Delete` · `MMCA.ADC.Conference.Application/Sessions/UseCases/Delete/DeleteSessionHandler.cs:16` · Level 9 · class (sealed partial) -- **What it is**: one of the two deletes in the Conference module that is not the framework's generic handler. Deleting a [`Session`](group-17-conference-domain.md#session) has to soft-delete the three owned join collections with it, and the aggregate can only walk children that were actually materialized, so this handler loads them explicitly before calling `Delete()` (`DeleteSessionHandler.cs:9-15` states exactly that rationale, naming BR-55). -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) closed over [`DeleteEntityCommand`](group-05-cqrs-pipeline.md#deleteentitycommandtentity-tidentifiertype)`` and [`Result`](group-01-result-error-handling.md#result) (`DeleteSessionHandler.cs:18`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), the [`Session`](group-17-conference-domain.md#session) aggregate, [`Error`](group-01-result-error-handling.md#error), and `Microsoft.Extensions.Logging` (`DeleteSessionHandler.cs:1-5`). -- **Concept introduced, replacing a generic framework handler by registering a more specific one**: `[Rubric §2, Design Patterns]` assesses whether a general mechanism can be specialized without being forked, and `[Rubric §5, Vertical Slice]` assesses whether one use case can deviate without disturbing its neighbours. The framework ships [`DeleteEntityHandler`](group-05-cqrs-pipeline.md#deleteentityhandlertentity-tidentifiertype), which loads one entity through the id-only overload, calls `Delete()`, and saves (`MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/DeleteEntityHandler.cs:21-35`). Speaker, Category, Question, and Sponsor all take that generic registration (`MMCA.ADC.Conference.Application/DependencyInjection.cs:64`, `:68`, `:73`, `:77`); `Session` and `Event` alone are bound to hand-written classes (`DependencyInjection.cs:60` and `:56`). Because both implement the same closed interface, the substitution is invisible above: [`SessionsController`](group-20-conference-api-grpc.md#sessionscontroller) takes `ICommandHandler, Result>` in its constructor (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionsController.cs:46`) and never names this class. No route and no request shape changes. -- **Concept introduced, the include list is what makes an in-memory cascade work**: `[Rubric §4, Domain-Driven Design]` assesses whether the aggregate stays the owner of its own invariants. `Session.Delete()` soft-deletes the session and then walks its three owned child lists in memory, returning the first child failure unchanged and only then raising `SessionChanged` with `DomainEntityState.Deleted` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:277-308`, the BR-55 contract stated at `:272-275`). A child that was never materialized is a child the loop cannot see. That is why the `includes` array on `DeleteSessionHandler.cs:28` (`SessionSpeakers`, `SessionQuestionAnswers`, `SessionCategoryItems`) matches the three collections that override iterates, one for one. They are not there to shape a response: this handler returns a bare [`Result`](group-01-result-error-handling.md#result) with no payload. `asTracking: true` (`DeleteSessionHandler.cs:29`) is equally load-bearing, since the include-aware repository overload defaults to no-tracking (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:181-184`) and an untracked graph would make the subsequent save a silent no-op. -- **Walkthrough**: a primary constructor taking `unitOfWork` and a typed `ILogger` (`DeleteSessionHandler.cs:16-18`). - - `HandleAsync` (`DeleteSessionHandler.cs:21-42`) resolves the write repository through [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) rather than injecting one (`:25`) and loads the session with its three child collections (`:26-30`). - - A missing session returns `Error.NotFound` stamped with source and target (`:31-32`), the standard failure shape of the [`Result`](group-01-result-error-handling.md#result) pattern ([ADR-013](https://ivanball.github.io/docs/adr/013-result-pattern.html)). - - `entity.Delete()` (`:34`) is the only decision point: the handler never touches a child row itself. - - Only on success does it `SaveChangesAsync` and log (`:35-39`); the failure path returns the domain `Result` unchanged without saving (`:41`), discarding whatever the aborted cascade already applied in memory. - - `LogSessionDeleted` is a source-generated `[LoggerMessage]` partial method (`:44-45`), which is why the class is `partial`. `[Rubric §13, Observability and Operability]`: one structured line, emitted once, after the save. -- **Why it's built this way**: *what* cascades is a business rule and lives in the aggregate; *which rows to load* is an application concern and lives here. Atomicity comes from the one `SaveChangesAsync` covering the parent and its children, which are all rows in the same `ADC_Conference` database ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)). Soft-delete, not erasure, is the workspace default: `Delete()` sets `IsDeleted` and the context's global filter hides the rows from every later read ([ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html)). The `SessionChanged` domain event raised at `Session.cs:304` is captured into the outbox by the same save ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)). -- **Where it's used**: registered as the `Session` delete handler at `MMCA.ADC.Conference.Application/DependencyInjection.cs:60` and reached through `DELETE /Sessions/{id}`, which [`SessionsController`](group-20-conference-api-grpc.md#sessionscontroller) overrides purely to evict output-cache tags after the base call (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionsController.cs:347-355` calling `EvictSessionsCacheAsync`, which drops the `conference:sessions` and `conference` tags at `:357-361`). The controller requires the `SessionsManage` permission (`SessionsController.cs:41`). Covered by [`DeleteSessionHandlerTests`](group-27-testing-infrastructure.md#deletesessionhandlertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DeleteSessionHandlerTests.cs:12`). -- **Caveats / not-in-source**: [`DeleteEntityCommand`](group-05-cqrs-pipeline.md#deleteentitycommandtentity-tidentifiertype) implements `ICacheInvalidating` only, with a `CachePrefix` defaulting to the aggregate full-name convention (`MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/DeleteEntityCommand.cs:11`, `:20`), so the invalidation is automatic but there is no `ITransactional` marker: the single `SaveChangesAsync` is the whole atomicity story ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). The child load is unpaged, so a session with an unusually large answer count materializes all of it in one batch, and no source comment records a ceiling for that. +- **What it is**: the module's replacement for the generic delete handler on the [`Session`](group-17-conference-domain.md#session) aggregate. It exists for one reason: to load the owned join collections before calling `Delete()`, so the aggregate's cascade actually has children to cascade over. +- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) closed over [`DeleteEntityCommand`](group-05-cqrs-pipeline.md#deleteentitycommandtentity-tidentifiertype) and [`Result`](group-01-result-error-handling.md#result) (`DeleteSessionHandler.cs:18`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) and the session repository it hands out (`DeleteSessionHandler.cs:17`, `:25`); [`Error`](group-01-result-error-handling.md#error); `Microsoft.Extensions.Logging` for the source-generated log method. +- **Concept introduced, overriding a generic handler for one entity**: [`DeleteEntityHandler`](group-05-cqrs-pipeline.md#deleteentityhandlertentity-tidentifiertype) is registered for most aggregates (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:68` for `Speaker`, `:72` for `Category`), but the module's DI file substitutes this class for `Session` with a single `TryAddScoped` line (`DependencyInjection.cs:64`). Because the registration is keyed by the closed `ICommandHandler, Result>` interface, nothing upstream changes: [`SessionsController`](group-20-conference-api-grpc.md#sessionscontroller) still injects the same closed interface (`SessionsController.cs:45`) and the base controller's inherited delete action still calls it. `[Rubric §1, SOLID]` assesses substitutability at the abstraction, which is exactly what this swap uses. `[Rubric §5, Vertical Slice]`: the one entity that needs different delete behavior gets its own file rather than a conditional inside the shared handler. +- **Concept introduced, why a soft-delete cascade is load-order sensitive**: `Session.Delete()` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:277-308`) walks `_sessionSpeakers`, `_sessionQuestionAnswers`, and `_sessionCategoryItems` and soft-deletes each non-deleted child (`Session.cs:283-302`). Those are in-memory backing lists, so they contain only what EF materialized. The generic delete handler loads no navigations, which would leave the join rows active under a soft-deleted session; the class summary states this trade-off directly (`DeleteSessionHandler.cs:9-15`). `[Rubric §4, DDD]` assesses whether the aggregate boundary is enforced at write time: the rule lives on the entity, and the handler's only job is to hydrate the boundary before invoking it. `[Rubric §8, Data Architecture]`: soft-delete is the workspace default ([ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html)), so an orphaned active child is a data-correctness bug, not a cosmetic one. +- **Walkthrough**: one public method and one log method. + - `HandleAsync` (`DeleteSessionHandler.cs:21-42`) resolves the session repository from the unit of work (`:25`), then loads by id with all three join collections included and `asTracking: true` (`:26-30`). Tracking is required because the cascade mutates loaded children and `SaveChangesAsync` must see them (the untracked-query trap is covered under [`IRepository`](group-07-persistence-ef-core.md#irepositorytentity-tidentifiertype)). A missing row returns `Error.NotFound` stamped with this handler as source and `Session` as target (`:31-32`). + - The cascade itself is one call, `entity.Delete()` (`:34`). On success the handler persists and logs (`:37-38`); on failure it returns the aggregate's errors untouched (`:41`), so a child that refuses deletion aborts the whole operation with no partial write. + - `LogSessionDeleted` (`:44-45`) is a `[LoggerMessage]` source-generated partial: compile-time-checked template, strongly typed `SessionIdentifierType` parameter, no boxing. This is the logging shape on every handler in the module. `[Rubric §13, Observability and Operability]` ([ADR-041](https://ivanball.github.io/docs/adr/041-observability-and-telemetry.html)). +- **Why it's built this way**: the alternative, teaching the generic handler to include navigations, would push entity-specific knowledge into framework code that has no way to know which navigations are owned. The class summary notes the [`Event`](group-17-conference-domain.md#event) delete path has the same shape (`DeleteSessionHandler.cs:14`), and `DependencyInjection.cs:60` registers `DeleteEventHandler` for the same reason. +- **Where it's used**: registered at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:64`; invoked through the `deleteHandler` constructor parameter of [`SessionsController`](group-20-conference-api-grpc.md#sessionscontroller) (`SessionsController.cs:45`), which passes it into `AggregateRootEntityControllerBase` (`SessionsController.cs:54-55`). Covered by [`DeleteSessionHandlerTests`](group-27-testing-infrastructure.md#deletesessionhandlertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DeleteSessionHandlerTests.cs:12`). +- **Caveats / not-in-source**: the handler declares no cache prefix of its own. `DeleteEntityCommand` is the message travelling the pipeline, so whether a session delete evicts the session cache is decided by that generic command's contract, not by anything in this file. ### GetNowNextQuery > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.NowNext` · `MMCA.ADC.Conference.Application/Sessions/UseCases/NowNext/GetNowNextQuery.cs:23` · Level 9 · record -- **What it is**: the query object for the "happening now / up next" snapshot behind the home-screen widget ([ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html) Wave 8). It carries one nullable parameter, `EventId` (`GetNowNextQuery.cs:23`); a value targets a specific published event, and `null` tells the handler to auto-select the current-or-next published event, which is what the widget passes because it has no event id of its own (`GetNowNextQuery.cs:7-12`). -- **Depends on**: [`IQueryCacheable`](group-05-cqrs-pipeline.md#iquerycacheable) (the marker it implements, `GetNowNextQuery.cs:23`), the [`Session`](group-17-conference-domain.md#session) domain type (used only for its `FullName` when building the cache key), the `EventIdentifierType` alias, and the BCL `CultureInfo` / `TimeSpan`. -- **Concept introduced, query-level read caching**: a query implementing [`IQueryCacheable`](group-05-cqrs-pipeline.md#iquerycacheable) is wrapped by `CachingQueryDecorator` (`MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingQueryDecorator.cs:34`), which returns a stored result on a hit without executing the inner handler, serializes concurrent misses behind a per-key lock so one request repopulates while the rest wait, and treats every cache fault as a miss rather than a 500 (`CachingQueryDecorator.cs:8-23`). `[Rubric §12, Performance and Scalability]` assesses whether hot reads avoid recomputation and database round-trips: a public, non-user-specific read that the home surface hits on every load is memoized instead of recomputed, and the stampede lock keeps a cold key from becoming a thundering herd. `[Rubric §10, Cross-Cutting]`: caching is a pipeline concern the query only declares, never implements. -- **Walkthrough**: `CacheKey` (`GetNowNextQuery.cs:26-35`) composes `"{Session.FullName}:NowNext:{scope}"`, where `scope` is the invariant-culture event id or the literal `"current"` when `EventId` is null (`:30-33`). Placing the key under the `Session` full-name prefix is deliberate: every session write command in this group invalidates on that same prefix (see [`AddSessionQuestionAnswerCommand`](#addsessionquestionanswercommand) and [`RemoveSessionCategoryItemCommand`](#removesessioncategoryitemcommand)), so any session mutation evicts this snapshot. `CacheDuration` (`GetNowNextQuery.cs:38`) is a deliberately short 30 seconds; the remarks state why (`:13-20`): it bounds staleness both from event-level edits and from the continuous now/next time-bucket transitions, and it is the sole backstop when prefix eviction is unavailable. -- **Why it's built this way**: keying under the aggregate prefix lets one prefix eviction cover every derived read of that aggregate, and the short TTL keeps a time-sensitive widget honest even when the distributed cache is absent ([ADR-026](https://ivanball.github.io/docs/adr/026-caching-strategy.html)). -- **Where it's used**: handled by [`GetNowNextHandler`](#getnownexthandler) and dispatched from two anonymous endpoints on [`EventsController`](group-20-conference-api-grpc.md#eventscontroller): `GET /Events/{id}/now-next` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventsController.cs:223-230`) and the id-less `GET /Events/now-next` the home widget calls (`EventsController.cs:238-244`). Both also sit behind the `NowNextCache` output-cache policy (`EventsController.cs:225`, `:240`), so there are two independent cache layers over this read. Covered by [`GetNowNextQueryCacheTests`](group-27-testing-infrastructure.md#getnownextquerycachetests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/NowNext/GetNowNextQueryCacheTests.cs:14`), which pins the key shape and the TTL. +- **What it is**: the request for the "happening now / up next" snapshot. One parameter, `EventIdentifierType? EventId`: a value targets that published event, `null` asks the handler to feature the current-or-next published event (`MMCA.ADC.Conference.Application/Sessions/UseCases/NowNext/GetNowNextQuery.cs:23`). +- **Depends on**: [`IQueryCacheable`](group-05-cqrs-pipeline.md#iquerycacheable) (`GetNowNextQuery.cs:23`); the [`Session`](group-17-conference-domain.md#session) type for the key prefix; `System.Globalization` for the invariant id formatting (`GetNowNextQuery.cs:1`). +- **Concept introduced, a query that declares its own cache key**: commands implement [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) and name a prefix to evict; queries implement [`IQueryCacheable`](group-05-cqrs-pipeline.md#iquerycacheable) and name a key plus a TTL. The caching decorator reads both, so a cached read and the writes that invalidate it agree only because they agree on the prefix string ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html), [ADR-026](https://ivanball.github.io/docs/adr/026-caching-strategy.html)). `[Rubric §12, Performance and Scalability]` assesses whether hot reads are cached at a layer that can be evicted correctly; the doc comment states the reasoning, a hot, public, non-user-specific read behind a home-screen widget (`GetNowNextQuery.cs:13-20`). +- **Walkthrough**: two computed members and no methods. + - `CacheKey` (`GetNowNextQuery.cs:26-35`) builds `{Session full name}:NowNext:{scope}` where `scope` is the event id formatted with `CultureInfo.InvariantCulture`, or the literal `"current"` when the id is null (`:30-33`). Two things matter here. The key sits under the same `Session` aggregate prefix the session commands declare as their `CachePrefix`, so any session write evicts this entry through prefix eviction. And the id-less form gets its own stable key rather than colliding with whichever event happens to be current. + - `CacheDuration` (`GetNowNextQuery.cs:38`) is `TimeSpan.FromSeconds(30)`. The file explains why the TTL is short and why it is not redundant with prefix eviction (`:16-19`): the payload changes with the wall clock as sessions roll over time buckets, event-level edits are not session writes, and the TTL is the sole backstop when prefix eviction is unavailable. `[Rubric §29, Resilience and Business Continuity]`: correctness degrades to "at most 30 seconds stale" rather than "wrong until someone writes a session". +- **Why it's built this way**: the widget has no event id of its own (`GetNowNextQuery.cs:10-12`), so an id-less form has to exist; making it a nullable parameter on one query rather than a second query type keeps one handler, one cache policy, and one payload shape ([ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html) Wave 8, cited in the file at `:8`). +- **Where it's used**: constructed twice by [`EventsController`](group-20-conference-api-grpc.md#eventscontroller), with an id for `GET Events/{id}/now-next` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventsController.cs:231`) and with `EventId: null` for `GET Events/now-next` (`EventsController.cs:245`). Both actions are `[AllowAnonymous]` and carry `[OutputCache(PolicyName = "NowNextCache")]` (`EventsController.cs:225-226`, `:240-241`), a 60-second public policy tagged `conference` and `conference:sessions` (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:252`). Handled by [`GetNowNextHandler`](#getnownexthandler). Covered by [`GetNowNextQueryCacheTests`](group-27-testing-infrastructure.md#getnownextquerycachetests). +- **Caveats / not-in-source**: there are two independent cache layers on this read, the 30-second query cache declared here and the 60-second HTTP output cache declared on the endpoint. Nothing in source ties the two durations together, so the staleness a widget actually sees is bounded by the output cache, not by `CacheDuration`. ### RemoveSessionCategoryItemCommand > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionCategoryItem` · `MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionCategoryItem/RemoveSessionCategoryItemCommand.cs:9` · Level 9 · record -- **What it is**: the command to detach a category-item association from a session. A two-field `sealed record`: the owning `SessionId` plus the join-entity id `SessionCategoryItemId` (`RemoveSessionCategoryItemCommand.cs:9-11`). +- **What it is**: the command that detaches a category item (a topic, level, or locality tag) from a session. Two positional ids: the owning `SessionId` and the `SessionCategoryItemId` join row to remove (`MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionCategoryItem/RemoveSessionCategoryItemCommand.cs:9-11`). - **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`RemoveSessionCategoryItemCommand.cs:11`), the [`Session`](group-17-conference-domain.md#session) type for the prefix, and the `SessionIdentifierType` / `SessionCategoryItemIdentifierType` aliases. -- **Concept introduced**: none new. Note the contrast with the Add commands: a remove targets an existing row, so the join id is non-nullable here (`:11`) where the Add commands make it optional. `CachePrefix => $"{typeof(Session).FullName}:"` (`:13-14`) is the same session prefix, so a removal flushes the cached session projections, [`GetNowNextQuery`](#getnownextquery) included. `[Rubric §9, API and Contract Design]`: the nullability of one field carries the whole "create versus target" distinction, with no extra flag. -- **Where it's used**: handled by [`RemoveSessionCategoryItemHandler`](#removesessioncategoryitemhandler); constructed by `DELETE /SessionCategoryItems/{id}` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:232-239`), which takes the owning session id as a separate argument and is gated by the `SessionsManage` permission (`SessionCategoryItemsController.cs:46`). +- **Concept reinforced**: none new. Note the asymmetry with the matching Add command: [`AddSessionCategoryItemCommand`](#addsessioncategoryitemcommand) takes a **nullable** child id (the database may assign it), while every Remove command takes a **required** one. There is nothing to generate on removal, so a null there would only be a way to express "remove nothing". +- **Walkthrough**: one member, `CachePrefix => $"{typeof(Session).FullName}:"` (`RemoveSessionCategoryItemCommand.cs:13-14`), identical to the Add side, so adding and removing a tag evict the same set of cached session projections. +- **Where it's used**: constructed by the `DELETE /SessionCategoryItems/{id}` action of [`SessionCategoryItemsController`](group-20-conference-api-grpc.md#sessioncategoryitemscontroller), which takes the join id from the route and the session id from an optional query string (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:240-248`), on a controller gated by the `SessionsManage` permission (`SessionCategoryItemsController.cs:47`); handled by [`RemoveSessionCategoryItemHandler`](#removesessioncategoryitemhandler). -### SessionCreateRequest +### RemoveSessionQuestionAnswerCommand -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.Create` · `MMCA.ADC.Conference.Application/Sessions/UseCases/Create/SessionCreateRequest.cs:10` · Level 9 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionQuestionAnswer` · `MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionQuestionAnswer/RemoveSessionQuestionAnswerCommand.cs:9` · Level 9 · record -- **What it is**: the create request DTO for a conference session. It doubles as the create command itself: [`CreateSessionHandler`](#createsessionhandler) is declared as `ICommandHandler>` (`MMCA.ADC.Conference.Application/Sessions/UseCases/Create/CreateSessionHandler.cs:27`), so the request travels the CQRS pipeline unchanged. -- **Depends on**: [`ICreateRequest`](group-05-cqrs-pipeline.md#icreaterequest), so the generic create pipeline can process it, and [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating), so a successful create evicts the session cache (`SessionCreateRequest.cs:10`, `:13`); the [`Session`](group-17-conference-domain.md#session) type for the prefix; and the `SessionIdentifierType` / `EventIdentifierType` / `RoomIdentifierType` aliases. -- **Walkthrough**: a `record class` (`SessionCreateRequest.cs:10`) carrying the session's writable fields as `init`-only properties. `Title` (`:19`) and `EventId` (`:61`) are `required`; everything else is optional, including the schedule (`StartsAt` / `EndsAt`, `:25-28`), the status and lifecycle flags (`Status`, `IsInformed`, `IsConfirmed`, `IsServiceSession`, `IsPlenumSession`, `:31-43`), the URL and info fields (`LiveUrl`, `RecordingUrl`, `AccessibilityInfo`, `ResourceLinks`, `:46-55`), `Duration` (`:58`), and the assigned `RoomId` (`:64`). `Id` (`:16`) is caller-supplied but auto-generated when left at its default (see [`CreateSessionHandler`](#createsessionhandler) for the manual-id logic). `CachePrefix` returns the `Session` full-name prefix (`:13`), matching the read caches and every other session write message. -- **Why it's built this way**: sharing one immutable request type for both the API contract and the internal command keeps the create slice thin. `required` marks the genuinely mandatory inputs at the type level so a malformed request cannot even be constructed, and `init`-only accessors mean the handler can only produce a modified copy with a `with` expression, which is exactly what the manual-id path does (`CreateSessionHandler.cs:94`). `[Rubric §9, API and Contract Design]`: a small, explicit, immutable shape. `[Rubric §5, Vertical Slice]`: request, validator, mapper, and handler all live in the one `UseCases/Create` folder. -- **Where it's used**: validated by [`SessionCreateRequestValidator`](#sessioncreaterequestvalidator), mapped to a domain entity by [`SessionCreateRequestMapper`](#sessioncreaterequestmapper), handled by [`CreateSessionHandler`](#createsessionhandler), and bound by `POST /Sessions` on [`SessionsController`](group-20-conference-api-grpc.md#sessionscontroller) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionsController.cs:290-293`), which also carries `[Idempotent]` so a retried request with the same `Idempotency-Key` does not create a second session (`SessionsController.cs:291`). -- **Caveats / not-in-source**: three accepted fields never reach the domain factory. `IsInformed`, `IsConfirmed`, and `Duration` are on the request (`:34`, `:37`, `:58`) but are not among the fourteen arguments [`SessionCreateRequestMapper`](#sessioncreaterequestmapper) forwards, so the factory's defaults win for them on a create. Whether that is a deliberate "set them on update, not on create" policy is not stated anywhere in source. +- **What it is**: the command that removes one attendee feedback answer from a session. Two positional ids: the owning `SessionId` and the `SessionQuestionAnswerId` (`MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionQuestionAnswer/RemoveSessionQuestionAnswerCommand.cs:9-11`). +- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`RemoveSessionQuestionAnswerCommand.cs:11`), the [`Session`](group-17-conference-domain.md#session) type for the prefix, and the `SessionIdentifierType` / `SessionQuestionAnswerIdentifierType` aliases. +- **Concept reinforced**: the same two-id removal shape as [`RemoveSessionCategoryItemCommand`](#removesessioncategoryitemcommand). What is worth noticing is what this record does **not** carry: no caller identity. Ownership for BR-52 and BR-53 is resolved server side inside [`RemoveSessionQuestionAnswerHandler`](#removesessionquestionanswerhandler), so a client cannot claim to be an answer's author by shaping the request. `[Rubric §11, Security]` assesses whether identity ever travels as request data; here it does not. +- **Walkthrough**: one member, `CachePrefix => $"{typeof(Session).FullName}:"` (`RemoveSessionQuestionAnswerCommand.cs:13-14`). +- **Where it's used**: constructed by the `DELETE /SessionQuestionAnswers/{id}` action of [`SessionQuestionAnswersController`](group-20-conference-api-grpc.md#sessionquestionanswerscontroller) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:218-226`), on a controller whose whole surface requires an authenticated caller (`SessionQuestionAnswersController.cs:56`) rather than the `SessionsManage` permission the other two junction controllers demand; handled by [`RemoveSessionQuestionAnswerHandler`](#removesessionquestionanswerhandler). -### AddSessionQuestionAnswerCommandValidator +### RemoveSessionSpeakerCommand -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionQuestionAnswer` · `MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerCommandValidator.cs:8` · Level 10 · class (sealed) +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionSpeaker` · `MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionSpeaker/RemoveSessionSpeakerCommand.cs:9` · Level 9 · record -- **What it is**: the FluentValidation validator for [`AddSessionQuestionAnswerCommand`](#addsessionquestionanswercommand). One rule: `RuleFor(x => x.AnswerValue).NotEmpty()` with the message "Answer value is required." (`AddSessionQuestionAnswerCommandValidator.cs:10-13`). -- **Depends on**: `FluentValidation`'s `AbstractValidator` and nothing else (`AddSessionQuestionAnswerCommandValidator.cs:1`, `:8`). -- **Concept introduced, the Validating decorator stage**: `[Rubric §24, Forms/Validation/UX Safety]` assesses whether bad input is rejected before it reaches business logic, and `[Rubric §10, Cross-Cutting]` assesses whether that happens uniformly. The handler never calls this class. The validating decorator runs every registered validator for the command type before the transaction opens ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)), so a malformed command costs no database work. Registration is by convention scan, `services.ScanModuleApplicationServices()` (`MMCA.ADC.Conference.Application/DependencyInjection.cs:110-112`): dropping a validator file next to its command is the entire wiring step, which is the vertical-slice payoff, `[Rubric §5, Vertical Slice]`. -- **Why it's built this way**: `NotEmpty` covers null, empty, and whitespace-only text, which is all that can be judged without knowing the question. The *semantic* check, that the answer matches the question's declared type, needs the [`Question`](group-17-conference-domain.md#question) row and therefore lives in the handler as BR-124 (`AddSessionQuestionAnswerHandler.cs:102-103`). Shape rules here, data-dependent rules where the data is. -- **Where it's used**: resolved by the validating decorator for [`AddSessionQuestionAnswerCommand`](#addsessionquestionanswercommand); covered by [`AddSessionQuestionAnswerCommandValidatorTests`](group-27-testing-infrastructure.md#addsessionquestionanswercommandvalidatortests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionCommandValidatorTests.cs:8`), one of the three validator test classes that share that file. +- **What it is**: the command that detaches a speaker from a session. Two positional ids: the owning `SessionId` and the `SessionSpeakerId` join row (`MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionSpeaker/RemoveSessionSpeakerCommand.cs:9-11`). +- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`RemoveSessionSpeakerCommand.cs:11`), the [`Session`](group-17-conference-domain.md#session) type for the prefix, and the `SessionIdentifierType` / `SessionSpeakerIdentifierType` aliases. +- **Concept reinforced**: structurally identical to [`RemoveSessionCategoryItemCommand`](#removesessioncategoryitemcommand). The behavioral difference is downstream, not here: [`RemoveSessionSpeakerHandler`](#removesessionspeakerhandler) treats a `SessionId` of `default` as "not supplied" and resolves the owning session from the join id instead, which is only possible because `SessionIdentifierType` is a value type with a meaningless zero. +- **Walkthrough**: one member, `CachePrefix => $"{typeof(Session).FullName}:"` (`RemoveSessionSpeakerCommand.cs:13-14`). +- **Where it's used**: constructed by the `DELETE /SessionSpeakers/{id}` action of [`SessionSpeakersController`](group-20-conference-api-grpc.md#sessionspeakerscontroller), route id plus `[FromQuery] sessionId` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:241-250`), on a controller gated by the `SessionsManage` permission (`SessionSpeakersController.cs:47`); handled by [`RemoveSessionSpeakerHandler`](#removesessionspeakerhandler). -### AddSessionQuestionAnswerHandler +### SessionCreateRequest -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionQuestionAnswer` · `MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerHandler.cs:20` · Level 10 · class (sealed partial) +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.Create` · `MMCA.ADC.Conference.Application/Sessions/UseCases/Create/SessionCreateRequest.cs:10` · Level 9 · record class -- **What it is**: the richest write path among the session child commands. Where the other Add handlers are three-step orchestrations, this one runs a chain of business rules before it decides between creating a new answer and updating the caller's existing one, and raises a cross-module integration event on the create branch only (`AddSessionQuestionAnswerHandler.cs:14-19` names the rules: BR-91, BR-49, BR-108, BR-128, BR-124, BR-107). -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) closed over the command and `Result` (`AddSessionQuestionAnswerHandler.cs:25`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`ICurrentUserService`](group-08-auth.md#icurrentuserservice), [`SessionQuestionAnswerDTOMapper`](#sessionquestionanswerdtomapper), the BCL `TimeProvider`, the [`Session`](group-17-conference-domain.md#session), [`Event`](group-17-conference-domain.md#event), and [`Question`](group-17-conference-domain.md#question) aggregates with their invariant helpers ([`SessionInvariants`](group-17-conference-domain.md#sessioninvariants), [`EventInvariants`](group-17-conference-domain.md#eventinvariants), [`QuestionInvariants`](group-17-conference-domain.md#questioninvariants)), [`SessionFeedbackSubmitted`](group-17-conference-domain.md#sessionfeedbacksubmitted), [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error), and logging (`AddSessionQuestionAnswerHandler.cs:1-10`, `:20-25`). -- **Concept introduced, an application-level upsert over an aggregate, and where its race is caught**: `[Rubric §6, CQRS and Event-Driven]` assesses whether a command slice owns its full decision, and `[Rubric §8, Data Architecture]` assesses whether integrity rules have a database-level guarantee and not only an in-memory one. BR-107 says one live answer per (session, question, author), so the handler looks for an existing non-deleted answer by the current user for this question in the **already loaded** child collection (`:53-54`) and branches: found means update, not found means create (`:56-59`). That check is in-memory by construction, so two concurrent submissions can both take the create branch. The database is the backstop: a filtered unique index on `(SessionId, QuestionId, CreatedBy)` stops the second write (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SessionQuestionAnswerConfiguration.cs:43-44`). Reading the handler alone would leave you thinking the rule is best-effort; reading the pair shows the real guarantee. -- **Concept introduced, an integration event raised on the aggregate pre-save so the outbox captures it atomically**: `[Rubric §7, Microservices Readiness]` assesses whether modules collaborate without reaching into each other's data. On the create branch only, the handler calls `session.AddDomainEvent(new SessionFeedbackSubmitted(userId, session.Id, session.EventId, timeProvider.GetUtcNow().UtcDateTime))` (`:134`) *before* the save, so the event row and the answer row land in the same transaction ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)). Engagement consumes it to award feedback points (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/SessionFeedbackSubmittedPointsHandler.cs:28`). The event type documents the exactly-once reasoning: a submitted form writes one row per question, so the event fires once per newly created answer and never on the update path, and the consumer is idempotent on its own side so a multi-question form still awards points once (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/IntegrationEvents/SessionFeedbackSubmitted.cs:8-13`). This is also why the handler needs an injected `TimeProvider` (`:24`) rather than `DateTime.UtcNow`: the timestamp on the event is testable. -- **Walkthrough**: five members, and the ordering between them is the rule hierarchy. - - `HandleAsync` (`:28-60`) loads the session **with** its `SessionQuestionAnswers` and `asTracking: true` (`:33-37`), because both the upsert lookup and the subsequent mutation need the children tracked. A missing session is `Error.NotFound` (`:38-39`). - - `ValidateSessionEligibilityAsync` (`:62-83`) reuses domain invariants rather than restating them: `SessionInvariants.EnsureNotServiceSession` (BR-91, a break or a lunch slot takes no feedback, `:67`, defined at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:91`), `SessionInvariants.EnsureStatusIsEligible` (BR-49, the same allow-list [`PublicSessionStatusSpecification`](#publicsessionstatusspecification) expresses for reads, `:72`, defined at `SessionInvariants.cs:107`), then a load of the parent [`Event`](group-17-conference-domain.md#event) and `EventInvariants.EnsureEventIsPublished` (BR-108, `:77-82`, defined at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:153`). Each check short-circuits on failure. - - `ValidateQuestionAsync` (`:85-104`) loads the [`Question`](group-17-conference-domain.md#question) and rejects one that does not exist or whose `QuestionEntity` is not `"Session"` with a validation error coded `Question.NotFoundOrWrongEntity` (BR-128, `:90-99`), then hands the answer text to `QuestionInvariants.EnsureAnswerValueMatchesQuestionType` (BR-124, `:102-103`, defined at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:115`). - - `UpdateExistingAnswerAsync` (`:106-119`) calls `session.UpdateSessionQuestionAnswer(existingAnswer.Id, command.AnswerValue)` (`Session.cs:536`), saves, and maps the same tracked instance back out, so the response carries the new value. - - `CreateNewAnswerAsync` (`:121-139`) calls `session.AddSessionQuestionAnswer(...)` (`Session.cs:512`), raises the integration event, saves, and maps the child the aggregate returned. Both branches emit the same `LogQuestionAnswerAddedToSession` line (`:141-142`), so the log does not distinguish an insert from an update. -- **Why it's built this way**: eligibility rules are shared with other callers, so the handler composes helpers instead of copying conditions, and every check returns a [`Result`](group-01-result-error-handling.md#result) that folds into the same failure channel ([ADR-013](https://ivanball.github.io/docs/adr/013-result-pattern.html)). `[Rubric §3, Clean Architecture]`: the two cross-aggregate reads go through [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) repositories, never EF types. -- **Where it's used**: injected into [`SessionQuestionAnswersController`](group-20-conference-api-grpc.md#sessionquestionanswerscontroller) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:58`), whose whole surface requires an authenticated caller (`:55`), from the `POST /SessionQuestionAnswers` action (`:176-182`). Covered by [`AddSessionQuestionAnswerHandlerTests`](group-27-testing-infrastructure.md#addsessionquestionanswerhandlertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/AddSessionQuestionAnswerHandlerTests.cs:14`). -- **Caveats / not-in-source**: `currentUserService.UserId!.Value` (`:52`) is null-forgiving. Nothing inside this handler enforces that a user id is present; the guarantee comes from the controller policy, so a caller reaching this code with no id would fault rather than fail gracefully. The three validation steps each issue their own round-trip (session, event, question), which is three reads before any write on the create path. +- **What it is**: the POST body for creating a session and, unusually, the command message itself. There is no separate `CreateSessionCommand`: [`CreateSessionHandler`](#createsessionhandler) is declared as `ICommandHandler>` (`MMCA.ADC.Conference.Application/Sessions/UseCases/Create/CreateSessionHandler.cs:27`), so the request record travels the whole decorator pipeline unchanged. +- **Depends on**: [`ICreateRequest`](group-05-cqrs-pipeline.md#icreaterequest) and [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`SessionCreateRequest.cs:10`); the [`Session`](group-17-conference-domain.md#session) type for the prefix; the `SessionIdentifierType`, `EventIdentifierType`, and `RoomIdentifierType` aliases. +- **Concept introduced, the request DTO as the command**: [`ICreateRequest`](group-05-cqrs-pipeline.md#icreaterequest) is a pure marker with no members (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICreateRequest.cs:8-10`); its only job is to be a generic constraint on [`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype). That constraint is what lets the generic create pipeline in `AggregateRootEntityControllerBase` bind a request body straight to a handler with no intermediate command type (`SessionsController.cs:54-55`). `[Rubric §9, API and Contract Design]` assesses whether the wire contract is explicit: it is, but the price is that the HTTP contract and the internal command contract are one type and cannot evolve independently. `[Rubric §16, Maintainability]`: one type instead of two, at the cost of that coupling. +- **Walkthrough**: `CachePrefix` (`SessionCreateRequest.cs:13`) is the same session-wide prefix the child commands use, so a create evicts cached session reads. Seventeen `init`-only data properties follow. Only two are `required`: `Title` (`:19`) and `EventId` (`:61`). `Id` (`:16`) is a plain non-nullable `SessionIdentifierType` documented as "auto-generated if not provided", which in practice means a caller sends nothing and the property arrives as `0`; [`CreateSessionHandler`](#createsessionhandler) reads that `0` as its signal to allocate an id from the reserved manual range. `LiveUrl`, `RecordingUrl`, `AccessibilityInfo`, and `ResourceLinks` (`:46`, `:49`, `:52`, `:55`) are nullable strings rather than `Uri`, matching how Sessionize exports them. `Status` (`:31`) is a nullable string, not an enum, which is what makes a session with no status at all representable (the organizer-created case [`PublicSessionStatusSpecification`](#publicsessionstatusspecification) has to allow for). `[Rubric §15, Best Practices and Code Quality]`: `init`-only accessors make the request immutable once bound, and [`CreateSessionHandler`](#createsessionhandler) uses `with` rather than mutation when it fills in the id (`CreateSessionHandler.cs:94`). +- **Where it's used**: bound from the body of `POST /Sessions` on [`SessionsController`](group-20-conference-api-grpc.md#sessionscontroller) (`SessionsController.cs:290-294`), an action that overrides the base create purely to add the BR-86 date-range warning header and to make the `[Idempotent]` contract visible at the ADC endpoint (`SessionsController.cs:285-291`); validated by [`SessionCreateRequestValidator`](#sessioncreaterequestvalidator); turned into an entity by [`SessionCreateRequestMapper`](#sessioncreaterequestmapper); handled by [`CreateSessionHandler`](#createsessionhandler). +- **Caveats / not-in-source**: three properties on this record never reach the domain. `IsInformed` (`:34`), `IsConfirmed` (`:37`), and `Duration` (`:58`) are not among the fourteen arguments [`SessionCreateRequestMapper`](#sessioncreaterequestmapper) passes to `Session.Create` (`SessionCreateRequestMapper.cs:19-33`), and `Session.Create` has no parameters for them (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:163-177`). For `Duration` that is by design, since the entity computes it from `StartsAt` and `EndsAt` (`Session.cs:80`); for the two booleans a caller can send a value that is silently discarded, and nothing in the contract says so. ### AddSessionSpeakerCommandValidator > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionSpeaker` · `MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionSpeaker/AddSessionSpeakerCommandValidator.cs:8` · Level 10 · class (sealed) -- **What it is**: the validator for [`AddSessionSpeakerCommand`](#addsessionspeakercommand). One rule: `RuleFor(x => x.SpeakerId).NotEqual(default(SpeakerIdentifierType))` with the message "Speaker ID is required." (`AddSessionSpeakerCommandValidator.cs:10-13`). -- **Depends on**: `FluentValidation`'s `AbstractValidator` (`AddSessionSpeakerCommandValidator.cs:8`) and the `SpeakerIdentifierType` alias. -- **Concept introduced**: none new; see [`AddSessionQuestionAnswerCommandValidator`](#addsessionquestionanswercommandvalidator) for the decorator stage. What differs is the sentinel. Because the identifier alias is a value type, `default` is what model binding produces for a missing or unparsable JSON field, so this rule turns a silently omitted speaker into a 400 rather than a lookup miss deeper in. Here the alias is a `Guid`, so the rule rejects `Guid.Empty`. -- **Why it's built this way**: the validator covers only what can be judged from the message itself. Whether the speaker exists, and whether the association is a duplicate, are decided against loaded state in the aggregate (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:318-322`) rather than restated here. `[Rubric §4, Domain-Driven Design]`: the invariant stays in the aggregate; the validator only guards the shape. -- **Where it's used**: resolved by the validating decorator for [`AddSessionSpeakerCommand`](#addsessionspeakercommand); covered by [`AddSessionSpeakerCommandValidatorTests`](group-27-testing-infrastructure.md#addsessionspeakercommandvalidatortests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionCommandValidatorTests.cs:38`). +- **What it is**: a one-rule FluentValidation validator for [`AddSessionSpeakerCommand`](#addsessionspeakercommand), rejecting a missing speaker id before the handler touches the database. +- **Depends on**: FluentValidation's `AbstractValidator` closed over the command (`AddSessionSpeakerCommandValidator.cs:1`, `:8`), and the `SpeakerIdentifierType` alias for the `default` comparison. +- **Concept reinforced, validation at the command boundary**: the validation decorator runs every registered `AbstractValidator` before the handler ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)), which is why [`AddSessionSpeakerHandler`](#addsessionspeakerhandler) contains no shape checks on its inputs. Discovery is by convention: the module's `ScanModuleApplicationServices()` call registers validators alongside handlers and mappers in one line (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`). `[Rubric §24, Forms, Validation and UX Safety]` assesses whether validation is present and applied before business logic; it is, and the failure surfaces as a structured [`Result`](group-01-result-error-handling.md#result) rather than an exception. +- **Walkthrough**: an expression-bodied constructor (`AddSessionSpeakerCommandValidator.cs:10-13`): `RuleFor(x => x.SpeakerId).NotEqual(default(SpeakerIdentifierType)).WithMessage("Speaker ID is required.")`. Because the identifier alias is `int`, `default` is `0`, and this is the module's standard idiom for "a value-typed id was not supplied". +- **Why it's built this way**: the interesting rule, that a speaker cannot be attached twice, cannot live here. Duplicate detection needs the session's existing speaker list, so it is an aggregate invariant instead (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:322-329`, error code `Session.Speaker.Duplicate`). The split is deliberate: the validator checks shape, the aggregate checks state. `[Rubric §4, DDD]`. +- **Where it's used**: discovered by the convention scan (`DependencyInjection.cs:125`) and invoked by the validation decorator ahead of [`AddSessionSpeakerHandler`](#addsessionspeakerhandler). Covered by [`AddSessionSpeakerCommandValidatorTests`](group-27-testing-infrastructure.md#addsessionspeakercommandvalidatortests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionCommandValidatorTests.cs:38`). +- **Caveats / not-in-source**: `SessionId` is not validated. A command with `SessionId == 0` passes validation and fails in the handler as `Error.NotFound` (`AddSessionSpeakerHandler.cs:28-29`), a correct outcome reached by a slower path than the speaker-id check gets. ### AddSessionSpeakerHandler > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionSpeaker` · `MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionSpeaker/AddSessionSpeakerHandler.cs:16` · Level 10 · class (sealed partial) -- **What it is**: the handler for [`AddSessionSpeakerCommand`](#addsessionspeakercommand), and the plainest example of the "add a child through the aggregate root" shape: load the session, delegate to a domain method, save, log, return the mapped DTO (`AddSessionSpeakerHandler.cs:11-15`). -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) closed over the command and `Result` (`AddSessionSpeakerHandler.cs:19`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`SessionSpeakerDTOMapper`](#sessionspeakerdtomapper), the [`Session`](group-17-conference-domain.md#session) aggregate and its [`SessionSpeaker`](group-17-conference-domain.md#sessionspeaker) child, [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error), and `Microsoft.Extensions.Logging`. -- **Concept introduced, loading the children the invariant needs**: this handler calls the include-aware overload, `GetByIdAsync(command.SessionId, [nameof(Session.SessionSpeakers)], asTracking: true, cancellationToken)` (`AddSessionSpeakerHandler.cs:27`). That matters because the aggregate's duplicate rule is evaluated over the in-memory collection: `_sessionSpeakers.Exists(ss => !ss.IsDeleted && ss.SpeakerId == speakerId)` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:322`). Loading the collection is part of satisfying the invariant, not an optimization. `asTracking: true` is required because that overload defaults to no-tracking (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:181-184`), and an untracked aggregate would make the save a silent no-op. `[Rubric §4, Domain-Driven Design]` and `[Rubric §8, Data Architecture]`: the load shape is chosen by what the aggregate must decide, and the filtered unique index on `(SessionId, SpeakerId)` still backs it at the database level (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SessionSpeakerConfiguration.cs:30-31`). -- **Walkthrough**: a primary constructor with `unitOfWork`, `dtoMapper`, and a typed `ILogger` (`AddSessionSpeakerHandler.cs:16-19`). - - `HandleAsync` (`:22-40`) loads as above and returns `Error.NotFound` stamped with source and target for a missing session (`:28-29`). - - `session.AddSessionSpeaker(command.SessionSpeakerId, command.SpeakerId)` (`:31`) is the only write; a domain failure is forwarded verbatim, errors and all (`:32-33`), so a duplicate association surfaces to the client as the aggregate worded it. - - Only on success does it `SaveChangesAsync` with `ConfigureAwait(false)` ([ADR-049](https://ivanball.github.io/docs/adr/049-library-configureawait-policy.html)), log through the generated `LogSpeakerAddedToSession` (`:35-37`, `:42-43`), and return `Result.Success(dtoMapper.MapToDTO(result.Value!))` (`:39`). The new join row is mapped from the value the aggregate returned, not re-queried. -- **Why it's built this way**: the aggregate owns the duplicate rule and the `SessionSpeakerChanged` domain event; the handler owns only the loading strategy that lets the aggregate apply them, plus the DTO shaping. The surrounding decorators already own the rest: validation before it, cache eviction after it ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). `[Rubric §1, SOLID]`: one reason to change, and it is the use case, not the plumbing. -- **Where it's used**: injected into [`SessionSpeakersController`](group-20-conference-api-grpc.md#sessionspeakerscontroller) as `ICommandHandler>` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:49`) and invoked from its `POST` action (`:210-216`), which evicts the sessions output cache afterwards because a speaker assignment changes the cached session reads. Registered by the convention scan (`MMCA.ADC.Conference.Application/DependencyInjection.cs:112`). Covered by [`AddSessionSpeakerHandlerTests`](group-27-testing-infrastructure.md#addsessionspeakerhandlertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/AddSessionSpeakerHandlerTests.cs:12`). +- **What it is**: the handler that loads a session, asks the aggregate to add a speaker association, saves, and returns the new join row as a DTO. +- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) closed over [`AddSessionSpeakerCommand`](#addsessionspeakercommand) and `Result<`[`SessionSpeakerDTO`](group-17-conference-domain.md#sessionspeakerdto)`>` (`AddSessionSpeakerHandler.cs:19`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:17`); the concrete [`SessionSpeakerDTOMapper`](#sessionspeakerdtomapper) (`:18`); the [`Session`](group-17-conference-domain.md#session) aggregate and its [`SessionSpeaker`](group-17-conference-domain.md#sessionspeaker) child; [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error). +- **Concept introduced, the load-delegate-save handler shape**: this is the canonical child-mutation handler in the module and worth reading once carefully, because five siblings in this chapter repeat it. The handler owns orchestration and persistence; the aggregate owns the rule. `[Rubric §3, Clean Architecture]` assesses whether the application layer stays free of business rules: the only decision this class makes is what to do with a failed [`Result`](group-01-result-error-handling.md#result). `[Rubric §6, CQRS and Event-Driven]`: the domain event is raised inside the aggregate (`Session.cs:339`) and dispatched by the unit of work, not by the handler. +- **Walkthrough**: a primary constructor and one method. + - Constructor parameters (`AddSessionSpeakerHandler.cs:16-19`): unit of work, mapper, logger. Note the mapper is injected as the **concrete** [`SessionSpeakerDTOMapper`](#sessionspeakerdtomapper) rather than through the mapper interface, which is what makes the hand-written plural `MapToDTOs` reachable at other call sites; here only the singular is used. + - `HandleAsync` (`:22-40`) resolves the session repository (`:26`), then loads by id including only `SessionSpeakers` with `asTracking: true` (`:27`). Including exactly the one collection the aggregate method will touch is the pattern across all of these handlers: enough to enforce the invariant, no more. A missing session returns `Error.NotFound` stamped with source and target (`:28-29`). + - `session.AddSessionSpeaker(command.SessionSpeakerId, command.SpeakerId)` (`:31`) is where the rules live: the aggregate rejects a duplicate non-deleted association with `Session.Speaker.Duplicate` (`Session.cs:322-329`), delegates row creation to `SessionSpeaker.Create` (`Session.cs:331`), and raises `SessionSpeakerChanged` with `DomainEntityState.Added` (`Session.cs:339`). A failure short-circuits with the aggregate's errors (`:32-33`), so no save happens. + - On success the handler persists (`:35`), logs through the source-generated `LogSpeakerAddedToSession` (`:37`, `:42-43`), and returns `Result.Success(dtoMapper.MapToDTO(result.Value!))` (`:39`). The `!` is safe only because `IsFailure` was checked two lines earlier; that is the standard [`Result`](group-01-result-error-handling.md#result) usage in this codebase. +- **Why it's built this way**: returning the created DTO rather than bare success lets the controller answer `201 Created` with a location route pointing at the new join row (`SessionSpeakersController.cs:235-238`), which is what the generic create action does for aggregates and what a hand-written child create has to do for itself. +- **Where it's used**: registered by the convention scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`); injected into [`SessionSpeakersController`](group-20-conference-api-grpc.md#sessionspeakerscontroller) as `addHandler` (`SessionSpeakersController.cs:50`) and called at `SessionSpeakersController.cs:223-225`. Covered by [`AddSessionSpeakerHandlerTests`](group-27-testing-infrastructure.md#addsessionspeakerhandlertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/AddSessionSpeakerHandlerTests.cs:12`). +- **Caveats / not-in-source**: the handler never checks that `SpeakerId` refers to an existing speaker. A well-formed id for a speaker that does not exist reaches the database and fails there as a foreign-key violation, not as a [`Result`](group-01-result-error-handling.md#result). ### GetNowNextHandler > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.NowNext` · `MMCA.ADC.Conference.Application/Sessions/UseCases/NowNext/GetNowNextHandler.cs:20` · Level 10 · class (sealed) -- **What it is**: the handler that builds the now-next snapshot: the sessions running at the query instant plus the next starting batch, for one published event or the auto-selected current-or-next event (`GetNowNextHandler.cs:12-19`). -- **Depends on**: [`IQueryHandler`](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) closed over [`GetNowNextQuery`](#getnownextquery) and `Result` (`GetNowNextHandler.cs:22`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), the BCL `TimeProvider`, the [`Event`](group-17-conference-domain.md#event) and [`Session`](group-17-conference-domain.md#session) aggregates, [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector) (event auto-selection and live-window math), [`CalendarExportMapper`](#calendarexportmapper) (eligibility plus wall-clock-to-UTC conversion), and the [`NowNextDTO`](group-17-conference-domain.md#nownextdto) / [`NowNextSessionDTO`](group-17-conference-domain.md#nownextsessiondto) shapes. -- **Concept introduced, a `TimeProvider`-injected clock for a time-bucketed read**: the handler takes `TimeProvider timeProvider` in its primary constructor (`GetNowNextHandler.cs:20-22`) and reads `GetUtcNow()` exactly once (`:29`), so "now" is a single injected instant rather than an ambient `DateTime.UtcNow` sampled repeatedly through the method. That matters twice over: a fake clock lets a test place the wall clock precisely inside or across a session boundary, `[Rubric §14, Testability]`, and one sample means the "now" and "next" partitions cannot disagree about where the boundary is. `[Rubric §12, Performance and Scalability]`: pairing this handler with the 30-second cache on its query keeps a per-load widget cheap. -- **Walkthrough**: - - `SelectEventAsync` (`:90-113`) resolves the target event: an explicit id loads that event with its `Rooms` (`:96-101`); a null id loads every published event and defers to [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector)`.SelectCurrentOrNext` (`:103-112`, defined at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/CurrentEventSelector.cs:22`). A missing or unpublished event returns `Error.NotFound` (`:32-36`), so an explicitly requested draft event is as invisible as a nonexistent one. - - It then loads the event's sessions by scalar `EventId` predicate (`:38-40`), builds a room-id to name map from the already-included rooms (`:41`), and resolves the event's IANA time zone, falling back to `TimeZoneInfo.Utc` on `TimeZoneNotFoundException` rather than failing the read (`:43-51`). - - Sessions are filtered through `CalendarExportMapper.IsExportable`, which requires a scheduled, non-service session whose status is on the BR-49 allow-list (`:54`, defined at `MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/CalendarExportMapper.cs:26-28`), and each survivor is projected by `ToRow` (`:80-88`) carrying both the local wall-clock times and their UTC instants via `CalendarExportMapper.ToUtc` (`CalendarExportMapper.cs:47-56`, which shifts spring-forward gap times ahead one hour). - - "Now" is the rows whose `[StartsAtUtc, EndsAtUtc)` interval contains the instant, ordered by start then room name with `StringComparer.OrdinalIgnoreCase` (`:58-62`). "Next" is the batch sharing the earliest future start, not one arbitrary winner, so parallel tracks surface together (`:64-72`, with the intent stated in the comment at `:64`). - - The event's live flag comes from `CurrentEventSelector.GetLiveWindowUtc` (`:74-75`, defined at `CurrentEventSelector.cs:64`), and the result is a `NowNextDTO` carrying the event id, name, live flag, and the two lists (`:77`). -- **Why it's built this way**: reusing the calendar-export eligibility and DST conversion keeps the now-next view consistent with the exported schedule (one rule, two readers), and reusing `CurrentEventSelector` keeps the id-less form agreeing with every other home surface about which event is "current" ([ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html) Wave 8). -- **Where it's used**: injected into [`EventsController`](group-20-conference-api-grpc.md#eventscontroller) as `IQueryHandler>` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventsController.cs:53`) and called from both now-next endpoints (`:230`, `:244`). Covered by [`GetNowNextHandlerTests`](group-27-testing-infrastructure.md#getnownexthandlertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/NowNext/GetNowNextHandlerTests.cs:17`), which drives it with a fake `TimeProvider`. -- **Caveats / not-in-source**: the session load is unpaged and filtered in memory, so a very large event materializes all of its sessions per cache miss. `ToRow` dereferences `session.StartsAt!.Value` and `session.EndsAt!.Value` (`:85-88`); that is safe only because `IsExportable` already rejected unscheduled sessions, a coupling the compiler does not enforce. +- **What it is**: the read handler behind the now-next snapshot. It picks an event, filters that event's sessions down to the publicly exportable ones, and splits them into "running at this instant" and "the next batch to start". +- **Depends on**: [`IQueryHandler`](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) closed over [`GetNowNextQuery`](#getnownextquery) and `Result<`[`NowNextDTO`](group-17-conference-domain.md#nownextdto)`>` (`GetNowNextHandler.cs:22`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:21`); `TimeProvider` from the BCL (`:22`); [`CalendarExportMapper`](#calendarexportmapper) for `IsExportable` and `ToUtc` (`:1`, `:54`, `:87-88`); [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector) for the live window and the current-or-next rule (`:74`, `:107`); the [`Event`](group-17-conference-domain.md#event) and [`Session`](group-17-conference-domain.md#session) aggregates; [`NowNextSessionDTO`](group-17-conference-domain.md#nownextsessiondto). +- **Concept introduced, injecting the clock**: the handler takes `TimeProvider` and reads `timeProvider.GetUtcNow()` once at the top (`GetNowNextHandler.cs:29`), then uses that single instant for every comparison in the method. Two consequences. First, the snapshot is internally consistent: a session cannot be classified as both running and upcoming because the clock moved between two comparisons. Second, the whole "is it 9:30 on conference morning" question becomes a test input, which is exactly how [`GetNowNextHandlerTests`](group-27-testing-infrastructure.md#getnownexthandlertests) pins its scenarios (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/NowNext/GetNowNextHandlerTests.cs:29-30`). `[Rubric §14, Testability]` assesses whether ambient state is injected rather than reached for; a `DateTime.UtcNow` in this method would make the behavior untestable. +- **Concept introduced, wall clock versus instant**: a conference schedule is authored in local wall-clock time, but "is it running now" is a question about instants. The handler resolves the event's `TimeZone` string to a `TimeZoneInfo` (`:46`) and converts each session's stored wall-clock `StartsAt` and `EndsAt` to a `DateTimeOffset` through `CalendarExportMapper.ToUtc` (`:87-88`), which also shifts spring-forward gap times ahead one hour so an invalid local time still yields an instant (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/CalendarExportMapper.cs:47-55`). [`NowNextSessionDTO`](group-17-conference-domain.md#nownextsessiondto) then carries **both** forms, local for printing on a badge or widget and UTC for callers doing their own math (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/NowNextDTO.cs:29-36`). `[Rubric §27, Internationalization]` in its time-zone sense: the displayed value is the event's zone, never the server's. +- **Walkthrough**: one public method and two private helpers. + - `HandleAsync` (`GetNowNextHandler.cs:25-78`) starts with the clock (`:29`), then calls `SelectEventAsync` and refuses anything missing or unpublished with `Error.NotFound` targeting `Event` (`:31-36`). That guard is the access control for this endpoint: both actions are `[AllowAnonymous]`, so "published" is the only thing standing between an anonymous caller and an unannounced event's schedule. `[Rubric §11, Security]`. + - It loads every session for the event with no includes and no visibility specification (`:38-40`), builds a room-id-to-name dictionary from the already-included `Rooms` (`:41`), then resolves the time zone with a `TimeZoneNotFoundException` fallback to UTC (`:43-51`). The fallback is a resilience choice: a bad zone id degrades the snapshot's times rather than failing the widget. `[Rubric §29, Resilience and Business Continuity]`. + - Eligibility reuses the calendar-export rule rather than restating it: `rows = sessions.Where(CalendarExportMapper.IsExportable)` (`:53-56`), which means scheduled at both ends, not a service session, and status-eligible per BR-49 (`CalendarExportMapper.cs:26-28`). One definition, two public surfaces. + - `now` is every row whose UTC window contains the instant, ordered by start then room name case-insensitively (`:58-62`). `next` is deliberately not "the single next session": the handler takes the minimum future start (`:65-66`) and returns **every** row sharing it (`:67-72`), with the comment stating the intent, so parallel tracks show together (`:64`). + - `isLive` compares the instant against the event's live window from [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector)`.GetLiveWindowUtc` (`:74-75`), the same window the home surfaces use, and the payload is assembled at `:77`. + - `ToRow` (`:80-88`) projects one session, resolving the room name through the dictionary and returning `null` when the session has no room (`:84`). + - `SelectEventAsync` (`:90-113`) branches on the nullable id: an explicit id is a direct `GetByIdAsync` including `Rooms` (`:98-100`); otherwise it loads all published events with their rooms (`:103-105`) and hands them to `CurrentEventSelector.SelectCurrentOrNext` with accessor lambdas for start, end, and zone (`:107-112`). Passing accessors rather than an interface is what lets that selector serve both entities and DTOs across the module. +- **Why it's built this way**: the file states the contract at `:12-19`, that eligibility and DST discipline are shared with the calendar export deliberately. A widget and an `.ics` download that disagreed about which sessions are public would be a visible defect, and the only way to guarantee they agree is to call the same predicate. +- **Where it's used**: registered by the convention scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`); injected into [`EventsController`](group-20-conference-api-grpc.md#eventscontroller) as `nowNextHandler` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventsController.cs:54`) and called from both now-next actions (`EventsController.cs:231`, `:245`). The payload is fetched over HTTP by [`NowNextService`](group-22-engagement-module.md#nownextservice) (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/NowNextService.cs:23`), rendered by [`HappeningNow`](group-23-engagement-live-layer.md#happeningnow), and read by the Android [`NowNextWidgetProvider`](group-25-adc-host-composition.md#nownextwidgetprovider). Covered by [`GetNowNextHandlerTests`](group-27-testing-infrastructure.md#getnownexthandlertests). +- **Caveats / not-in-source**: the query at `:38-40` loads **every** session row for the event, then filters, projects, sorts, and buckets in memory (`:53-72`). Nothing is pushed to the database beyond the `EventId` predicate, so the cost scales with the event's total session count rather than with the handful of rows the snapshot returns. The two cache layers on the endpoint make that acceptable in practice, not correct in principle. Separately, `ToRow` dereferences `session.StartsAt!` and `session.EndsAt!` (`:85-88`); that is safe only because `IsExportable` already rejected null-scheduled sessions, a coupling the compiler cannot check. ### RemoveSessionCategoryItemHandler > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionCategoryItem` · `MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionCategoryItem/RemoveSessionCategoryItemHandler.cs:13` · Level 10 · class (sealed partial) -- **What it is**: the handler for [`RemoveSessionCategoryItemCommand`](#removesessioncategoryitemcommand), and the canonical "remove a child from the session aggregate" shape that its sibling remove handlers vary from (`RemoveSessionCategoryItemHandler.cs:9-12`). -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) closed over the command and a bare [`Result`](group-01-result-error-handling.md#result) (`RemoveSessionCategoryItemHandler.cs:15`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), the [`Session`](group-17-conference-domain.md#session) aggregate and its [`SessionCategoryItem`](group-17-conference-domain.md#sessioncategoryitem) child, [`Error`](group-01-result-error-handling.md#error), and `Microsoft.Extensions.Logging`. -- **Concept introduced, load-tracked-then-mutate-through-the-aggregate**: `HandleAsync` (`:18-39`) loads the session with its `SessionCategoryItems` and `asTracking: true` (`:23-27`), returns `Error.NotFound` stamped with the handler as source and `Session` as target when absent (`:28-29`), then delegates the actual removal to `entity.RemoveSessionCategoryItem(command.SessionCategoryItemId)` (`:31`, defined at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:482`) so the aggregate enforces its own invariants and raises its own event. Only on success does it save and log; the domain `Result` is returned unchanged either way (`:32-38`). Note the return type: unlike the Add handlers there is no DTO, because a removal has nothing to hand back. `[Rubric §4, Domain-Driven Design]`: the handler never mutates child state directly, it asks the aggregate root. `[Rubric §13, Observability and Operability]`: logging goes through the source-generated `[LoggerMessage]` partial (`:41-42`), which records both the join id and the session id. -- **Walkthrough**: the primary constructor takes `IUnitOfWork` and a typed `ILogger` (`:13-15`). `asTracking: true` is not incidental: the aggregate mutation has to be observed by the change tracker for the subsequent `SaveChangesAsync` (`:34`) to emit anything. Loading the collection is equally load-bearing, since the aggregate can only remove a child it can see. -- **Why it's built this way**: keeping removal logic in the aggregate and cache eviction on the command (via [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating)) leaves the handler as pure orchestration: load, delegate, save, log. -- **Where it's used**: registered by the convention scan (`MMCA.ADC.Conference.Application/DependencyInjection.cs:112`), injected into [`SessionCategoryItemsController`](group-20-conference-api-grpc.md#sessioncategoryitemscontroller) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:50`), and invoked from its `DELETE /{id}` action (`:232-239`). Covered by [`RemoveSessionCategoryItemHandlerTests`](group-27-testing-infrastructure.md#removesessioncategoryitemhandlertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/RemoveSessionCategoryItemHandlerTests.cs:11`). +- **What it is**: the plainest member of the load-delegate-save family: it loads a session with its category items and asks the aggregate to remove one. +- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) closed over [`RemoveSessionCategoryItemCommand`](#removesessioncategoryitemcommand) and [`Result`](group-01-result-error-handling.md#result) (`RemoveSessionCategoryItemHandler.cs:15`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:14`); the [`Session`](group-17-conference-domain.md#session) aggregate and its [`SessionCategoryItem`](group-17-conference-domain.md#sessioncategoryitem) child; [`Error`](group-01-result-error-handling.md#error). +- **Concept reinforced**: the shape taught on [`AddSessionSpeakerHandler`](#addsessionspeakerhandler), minus the DTO. Removals return a bare [`Result`](group-01-result-error-handling.md#result) because the controller answers `204 No Content` (`SessionCategoryItemsController.cs:256`), so there is nothing to map and no mapper to inject. +- **Walkthrough**: `HandleAsync` (`RemoveSessionCategoryItemHandler.cs:18-39`) resolves the repository (`:22`), loads by `command.SessionId` including only `SessionCategoryItems` with `asTracking: true` (`:23-27`), returns `Error.NotFound` when the session is missing (`:28-29`), and delegates to `entity.RemoveSessionCategoryItem(command.SessionCategoryItemId)` (`:31`, aggregate method at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:482`). Only a successful result saves and logs (`:32-36`); a failure is returned as-is (`:38`). `LogCategoryItemRemovedFromSession` is source-generated (`:41-42`). +- **Where it's used**: registered by the convention scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`); injected into [`SessionCategoryItemsController`](group-20-conference-api-grpc.md#sessioncategoryitemscontroller) as `removeHandler` (`SessionCategoryItemsController.cs:51`) and called at `SessionCategoryItemsController.cs:246-248`, after which the controller evicts both parents' output-cache entries (`SessionCategoryItemsController.cs:255`). Covered by [`RemoveSessionCategoryItemHandlerTests`](group-27-testing-infrastructure.md#removesessioncategoryitemhandlertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/RemoveSessionCategoryItemHandlerTests.cs:11`). +- **Caveats / not-in-source**: the handler takes `SessionId` on faith. Passing a valid join id together with the wrong session id yields a not-found from the aggregate rather than a cross-session removal, but nothing here verifies the pairing before the load. -### SessionCreateRequestMapper +### RemoveSessionQuestionAnswerHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.Create` · `MMCA.ADC.Conference.Application/Sessions/UseCases/Create/SessionCreateRequestMapper.cs:11` · Level 10 · class (sealed) +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionQuestionAnswer` · `MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionQuestionAnswer/RemoveSessionQuestionAnswerHandler.cs:14` · Level 10 · class (sealed partial) -- **What it is**: the adapter that turns a validated [`SessionCreateRequest`](#sessioncreaterequest) into a [`Session`](group-17-conference-domain.md#session) domain entity by calling the aggregate's `Create` factory (`SessionCreateRequestMapper.cs:7-9`). -- **Depends on**: [`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype) closed over `Session` / `SessionCreateRequest` / `SessionIdentifierType` (`SessionCreateRequestMapper.cs:11-12`), the [`Session`](group-17-conference-domain.md#session) factory, and [`Result`](group-01-result-error-handling.md#result). -- **Concept introduced, request-to-entity mapping as its own step**: the create pipeline separates "shape the input" (this mapper) from "orchestrate the use case" (the handler). `CreateEntityAsync` (`:15-34`) guards a null request with `ArgumentNullException.ThrowIfNull` (`:17`), then forwards the request fields positionally into `Session.Create(...)` (`:19-33`), returning that factory's `Result` wrapped in an already-completed `Task`. There is no `async` here at all: the mapping is synchronous, and the `Task` exists only to satisfy the interface, which other entities implement with genuinely asynchronous lookups. `[Rubric §1, SOLID]`: single responsibility, the mapper knows the factory's argument order and nothing else. `[Rubric §2, Design Patterns]`: the handler depends on the interface and never on this class. Manual mapping over reflection-based mapping follows [ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html). -- **Walkthrough**: fourteen positional arguments in factory order (`:20-33`): `Id`, `Title`, `Description`, `StartsAt`, `EndsAt`, `Status`, `IsServiceSession`, `IsPlenumSession`, `LiveUrl`, `RecordingUrl`, `AccessibilityInfo`, `ResourceLinks`, `EventId`, `RoomId`, matching `Session.Create`'s parameter list one for one (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:163-177`). Because the arguments are positional and same-typed neighbours exist (two `bool`s, six `string?`s), a reordering here compiles: the compiler cannot catch it, only the domain-level tests can. Three request fields are deliberately absent from the list: `IsInformed`, `IsConfirmed`, and `Duration` never reach the factory. -- **Why it's built this way**: the factory, not the mapper, is where the entity's creation invariants live: `Session.Create` combines the title, the start-before-end ordering, and the optional-text length checks before it constructs anything (`Session.cs:179-183`). Keeping the mapper argument-shuffling only means there is exactly one place a session can come into existence. -- **Where it's used**: injected into [`CreateSessionHandler`](#createsessionhandler) as `IEntityRequestMapper` (`CreateSessionHandler.cs:25`), so the handler is constructed against the interface and this class is named only at registration (the convention scan, `MMCA.ADC.Conference.Application/DependencyInjection.cs:110-112`). -- **Caveats / not-in-source**: whether the three unmapped fields are an intentional "set them on update, not on create" policy or an oversight is not stated in this file or in the factory's documentation. +- **What it is**: the same load-delegate-save shape as its siblings, with one addition that earns it a careful read: an ownership check. BR-52 and BR-53 say an attendee may delete only their own answers, while an organizer may delete any (`RemoveSessionQuestionAnswerHandler.cs:12`). +- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) closed over [`RemoveSessionQuestionAnswerCommand`](#removesessionquestionanswercommand) and [`Result`](group-01-result-error-handling.md#result) (`RemoveSessionQuestionAnswerHandler.cs:17`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:15`); [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) (`:16`); [`RoleNames`](group-08-auth.md#rolenames) for the `Organizer` constant (`:6`, `:35`); the [`Session`](group-17-conference-domain.md#session) aggregate and its [`SessionQuestionAnswer`](group-17-conference-domain.md#sessionquestionanswer) child; [`Error`](group-01-result-error-handling.md#error). +- **Concept introduced, row-level authorization inside the handler**: permission attributes on a controller answer "may this caller call this endpoint"; they cannot answer "may this caller touch this row". That second question needs the row, so it is asked here, after the load. The check is a single condition (`:35`): if the answer exists, the caller is **not** in the `Organizer` role, and the answer's `CreatedBy` differs from the current user id, the handler returns `Error.Forbidden` with the code `SessionQuestionAnswer.NotOwner` and a caller-safe message (`:37-42`). Ownership comes from the audit stamp the framework writes on insert, not from anything the client sent. `[Rubric §11, Security]` assesses whether authorization decisions are made where the data is, with identity taken from the token rather than the payload; both hold here. +- **Walkthrough**: `HandleAsync` (`RemoveSessionQuestionAnswerHandler.cs:20-52`) loads the session including only `SessionQuestionAnswers`, tracked (`:24-29`), and returns `Error.NotFound` when the session is missing (`:30-31`). It then finds the target answer in the loaded collection, excluding soft-deleted rows (`:34`), runs the ownership condition (`:35-42`), and delegates to `entity.RemoveSessionQuestionAnswer(...)` (`:44`, aggregate method at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:559`). Success saves and logs (`:45-49`). +- **Why it's built this way**: putting the ownership rule in the aggregate would force the domain to know about the current user, which is an application-layer concern; putting it in the controller would require loading the row twice. The handler is the one place that already has both the identity service and the loaded answer. `[Rubric §3, Clean Architecture]`. +- **Where it's used**: registered by the convention scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`); injected into [`SessionQuestionAnswersController`](group-20-conference-api-grpc.md#sessionquestionanswerscontroller) as `removeHandler` (`SessionQuestionAnswersController.cs:61`) and called at `SessionQuestionAnswersController.cs:224-226`. Covered by [`RemoveSessionQuestionAnswerHandlerTests`](group-27-testing-infrastructure.md#removesessionquestionanswerhandlertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/RemoveSessionQuestionAnswerHandlerTests.cs:12`). +- **Caveats / not-in-source**: the condition dereferences `currentUserService.UserId!.Value` (`:35`), so an unauthenticated caller reaching this handler would throw rather than be refused. That cannot happen through the REST surface, because the controller requires an authenticated principal for its entire surface (`SessionQuestionAnswersController.cs:56`), but the guarantee lives in the controller attribute, not in this file. Note also that the branch is skipped entirely when `answer` is `null` (`:35`): a non-existent or already-deleted id falls through to the aggregate, which returns its own not-found rather than a forbidden, so the endpoint does not leak whether an answer the caller cannot see exists. -### SessionCreateRequestValidator +### RemoveSessionSpeakerHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.Create` · `MMCA.ADC.Conference.Application/Sessions/UseCases/Create/SessionCreateRequestValidator.cs:7` · Level 10 · class (sealed) +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionSpeaker` · `MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionSpeaker/RemoveSessionSpeakerHandler.cs:13` · Level 10 · class (sealed partial) -- **What it is**: the FluentValidation validator for [`SessionCreateRequest`](#sessioncreaterequest). It composes reusable per-field rule sets rather than restating each rule inline (`SessionCreateRequestValidator.cs:6`). -- **Depends on**: `FluentValidation` (`AbstractValidator` and its `Include`) and the shared `Session*Rules` classes from `MMCA.ADC.Conference.Application.Sessions.Validation` (`SessionCreateRequestValidator.cs:1-2`). -- **Concept introduced, composed validation via reusable rule includes**: the constructor (`:9-19`) calls `Include(...)` once per field rule set, each rule class generic over the request type and constructed with a property selector, for example `Include(new SessionTitleRules(p => p.Title))` (`:11`). `Include` folds the other validator's rules into this one, so the composite reports a single flat error list. Because the rule classes are generic over the *request* type rather than tied to one DTO, the create and update requests share the identical rule definitions: [`SessionUpdateRequest`](#sessionupdaterequest)'s validator includes the same classes over its own properties. `[Rubric §24, Forms/Validation/UX Safety]` and `[Rubric §16, Maintainability]`: a rule such as title length or URL format is defined once, so it cannot drift between the create and update paths. -- **Walkthrough**: eight includes (`:11-18`), covering `Title` ([`SessionTitleRules`](#sessiontitlerulest)), `EventId` ([`SessionEventIdRules`](#sessioneventidrulest)), `Description` ([`SessionDescriptionRules`](#sessiondescriptionrulest)), `Status` ([`SessionStatusRules`](#sessionstatusrulest)), `LiveUrl` ([`SessionLiveUrlRules`](#sessionliveurlrulest)), `RecordingUrl` ([`SessionRecordingUrlRules`](#sessionrecordingurlrulest)), `AccessibilityInfo` ([`SessionAccessibilityInfoRules`](#sessionaccessibilityinforulest)), and `ResourceLinks` ([`SessionResourceLinksRules`](#sessionresourcelinksrulest)). Note what is *not* validated here: the `StartsAt` / `EndsAt` ordering is enforced by the domain factory (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:181`), and the room assignment by [`CreateSessionHandler`](#createsessionhandler)'s BR-130 check, because both need data this validator does not have. -- **Why it's built this way**: a validator judges the message; anything needing loaded state belongs further in. That division is what keeps the validating decorator able to run before any database work happens ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). -- **Where it's used**: resolved by the validating decorator for [`SessionCreateRequest`](#sessioncreaterequest), which is also the create command. Covered by [`SessionCreateRequestValidatorTests`](group-27-testing-infrastructure.md#sessioncreaterequestvalidatortests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionCreateRequestValidatorTests.cs:7`). +- **What it is**: the speaker-detach handler, and the one member of the family that has to cope with a caller who does not know the parent id. +- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) closed over [`RemoveSessionSpeakerCommand`](#removesessionspeakercommand) and [`Result`](group-01-result-error-handling.md#result) (`RemoveSessionSpeakerHandler.cs:15`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:14`); the [`Session`](group-17-conference-domain.md#session) aggregate and its [`SessionSpeaker`](group-17-conference-domain.md#sessionspeaker) child; [`Error`](group-01-result-error-handling.md#error). +- **Concept introduced, resolving an aggregate root from a child id**: the DELETE endpoint takes the session id as an optional query parameter, but the UI's generic delete component sends only the join-entity id, so `SessionId` arrives as `default` (`RemoveSessionSpeakerHandler.cs:24-26`). The handler branches on that (`:28`): when the session id is unset it queries sessions by a predicate over the child collection, `s => s.SessionSpeakers.Any(ss => ss.Id == command.SessionSpeakerId)`, including the collection and tracking it (`:30-34`), and takes the first match (`:35`); otherwise it loads directly by id (`:39-43`). Either way the rest of the method is identical, so the aggregate boundary is preserved: the removal is still performed by the root, never by reaching into a child repository. `[Rubric §4, DDD]` assesses exactly this, that children are mutated through their root. `[Rubric §9, API and Contract Design]`: the optional query parameter is what makes the two shapes one endpoint rather than two. +- **Walkthrough**: `HandleAsync` (`RemoveSessionSpeakerHandler.cs:18-57`) resolves the repository (`:22`), runs the branch above into a nullable `Session` (`:27-44`), returns `Error.NotFound` when nothing resolved (`:46-47`), delegates to `entity.RemoveSessionSpeaker(command.SessionSpeakerId)` (`:49`, aggregate method at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:385-399`, which soft-deletes the join row and raises `SessionSpeakerChanged` with `DomainEntityState.Deleted`), then saves and logs on success (`:50-54`). +- **Why it's built this way**: the fallback exists because the UI reuses one generic delete affordance across every entity, and that component knows only the row's own id. Teaching the server to resolve the parent is cheaper than special-casing the client, and it keeps the endpoint usable by callers that do have the session id. +- **Where it's used**: registered by the convention scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`); injected into [`SessionSpeakersController`](group-20-conference-api-grpc.md#sessionspeakerscontroller) as `removeHandler` (`SessionSpeakersController.cs:51`) and called at `SessionSpeakersController.cs:248-250`. Covered by [`RemoveSessionSpeakerHandlerTests`](group-27-testing-infrastructure.md#removesessionspeakerhandlertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/RemoveSessionSpeakerHandlerTests.cs:11`). +- **Caveats / not-in-source**: the fallback query returns a collection and takes `FirstOrDefault` (`:30-35`), so it materializes every matching session rather than asking for one. In practice a join id belongs to exactly one session, but the query does not say so. The log statement also records `command.SessionId` (`:53`), which is `0` on the fallback path, so the emitted event names the join row correctly and the session as zero. -### CreateSessionHandler +### SessionCreateRequestMapper -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.Create` · `MMCA.ADC.Conference.Application/Sessions/UseCases/Create/CreateSessionHandler.cs:22` · Level 11 · class (sealed partial) +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.Create` · `MMCA.ADC.Conference.Application/Sessions/UseCases/Create/SessionCreateRequestMapper.cs:11` · Level 10 · class (sealed) -- **What it is**: the command handler for creating a session, and the richest write path in this group. It assigns manual ids in a reserved range, validates the room assignment (BR-130 cross-event plus double-booking), delegates entity construction to the mapper, persists, maps the result to a DTO, and retries on a concurrent id collision (`CreateSessionHandler.cs:15-21`). -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) closed over [`SessionCreateRequest`](#sessioncreaterequest) and `Result` (`CreateSessionHandler.cs:27`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype) (satisfied by [`SessionCreateRequestMapper`](#sessioncreaterequestmapper)), [`SessionDTOMapper`](#sessiondtomapper), the [`Session`](group-17-conference-domain.md#session) and [`Event`](group-17-conference-domain.md#event) aggregates, [`SessionInvariants`](group-17-conference-domain.md#sessioninvariants) (the reserved manual-id range), [`SessionRoomScheduling`](#sessionroomscheduling) (the BR-130 room rules), the [`SessionDTO`](group-17-conference-domain.md#sessiondto) result shape, plus `IServiceScopeFactory` and logging (`CreateSessionHandler.cs:22-27`). -- **Concept introduced, application-assigned ids with a bounded retry on collision**: session ids are application-assigned because the `int` primary key IS the Sessionize id (`:76-78`). When the caller supplies no id (organizer create, where the request `Id` defaults to `0`), `CreateCoreAsync` reads every existing row inside the reserved manual range with `ignoreQueryFilters: true` so soft-deleted rows still reserve their id, then takes `Max(Id) + 1` or the range start when the range is empty (`:79-89`); an exhausted range returns a failure rather than wrapping around (`:91-92`). That range is `SessionInvariants.ManualIdRangeStart` = 999,999,000 through `ManualIdRangeEnd` = 999,999,999 (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:41`, `:44`), deliberately above any real Sessionize id. Because two concurrent creates can compute the same next id, `HandleAsync` wraps the attempt in a bounded loop of `MaxManualIdAttempts` = 3 (`:30`, `:42-62`); a duplicate-key failure recomputes the id in a fresh DI scope via `scopeFactory.CreateAsyncScope()` so a clean `DbContext` is used, since the ambient one still tracks the failed insert (`:51-55`). `[Rubric §8, Data Architecture]`: id allocation is an explicit, range-partitioned concern rather than a database identity column, which is what lets an imported id and a hand-created id share one table. `[Rubric §12, Performance and Scalability]`: the collision path is exceptional and capped at three attempts, not a lock on the hot path. -- **Walkthrough**: - - An explicit caller id is respected as-is and gets a single attempt with no id recomputation, because a collision there is a genuine caller error (`:37-40`). - - `CreateCoreAsync` (`:69-136`) takes the unit of work as a parameter rather than closing over the injected one, which is exactly what makes the fresh-scope retry possible. It resolves the manual id when unset and rewrites the request with a `with` expression, which is available because every request property is `init`-only (`:94`). - - The room rules run only when `RoomId` has a value (`:100`): the parent event is loaded untracked with its `Rooms` (`:102-107`, `Error.NotFound` when missing at `:108-109`), then `SessionRoomScheduling.ValidateRoomAssignmentAsync` (`:111-119`, defined at `MMCA.ADC.Conference.Application/Sessions/Validation/SessionRoomScheduling.cs:44`) checks that the room belongs to this event (BR-130, error code `Session.RoomId.CrossEvent` at `SessionRoomScheduling.cs:63`) and that the `[StartsAt, EndsAt)` slot does not overlap another session in the same room, with `excludeSessionId: null` because nothing exists yet to exclude. The comment at `:97-99` explains why the event load sits inside this branch: a room-less session has nothing to validate and create has no other use for the event. - - It then maps via `requestMapper.CreateEntityAsync` with an early return on failure, adds, saves, logs, and returns `Result.Success(dtoMapper.MapToDTO(entity))` (`:124-135`). - - `IsUniqueKeyViolation` (`:143-152`) walks the whole exception chain looking for the text "duplicate key", case-insensitively. Detection is message-based because the Application layer cannot reference EF Core or SQL Server types, and both SQL Server errors 2601 and 2627 carry that wording (`:138-142`). `[Rubric §3, Clean Architecture]`: the layer boundary holds, at the cost of a string match. -- **Why it's built this way**: keeping organizer-created ids in a reserved high range prevents them from colliding with future Sessionize-assigned ids, and the seeder starts its sample sessions at the range start for the same reason (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/Seeding/ConferenceModuleDbSeeder.cs:232-235`). The fresh-scope retry is the only reliable way to recover from a duplicate-key race without leaking EF types upward. The double-booking half of the room check is documented as a deliberate SOFT guard: the probe and the insert are separate statements, so two concurrent organizer writes can both pass it, accepted because the endpoint is organizer-only and the outcome is repairable (`SessionRoomScheduling.cs:17-25`, `:72`). Structured logging uses the source-generated `[LoggerMessage]` partials `LogSessionCreated` and `LogManualIdCollision` (`:154-158`), the collision one at `Warning` level so a retry storm is visible in telemetry. `[Rubric §13, Observability and Operability]`: the exceptional path is the one that logs loudest. -- **Where it's used**: injected into [`SessionsController`](group-20-conference-api-grpc.md#sessionscontroller) as `ICommandHandler>` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionsController.cs:44`) and invoked from the `POST /Sessions` override, which then adds the BR-86 `X-Warning` header when the session times fall outside the event's date range and evicts the sessions output cache (`SessionsController.cs:290-320`). Covered by [`CreateSessionHandlerTests`](group-27-testing-infrastructure.md#createsessionhandlertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/CreateSessionHandlerTests.cs:16`). -- **Caveats / not-in-source**: this handler is *not* on the Sessionize import path. The importer calls the domain factory directly (`MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionSyncStrategy.cs:97`), so the manual-id logic and the room checks apply to organizer creates only. The manual-id scan loads every row in the reserved range to compute a maximum rather than projecting one scalar, so its cost grows with the number of hand-created sessions; nothing in the file bounds that. Message-based exception matching is also locale-sensitive by nature, and the code names only the SQL Server error numbers, not what a different provider would report. +- **What it is**: the adapter that turns a validated [`SessionCreateRequest`](#sessioncreaterequest) into a [`Session`](group-17-conference-domain.md#session) entity by calling the domain factory. It is one method long and contains no logic of its own. +- **Depends on**: [`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype) closed over [`Session`](group-17-conference-domain.md#session) / [`SessionCreateRequest`](#sessioncreaterequest) / `SessionIdentifierType` (`SessionCreateRequestMapper.cs:11-12`); [`Result`](group-01-result-error-handling.md#result); `Session.Create`. +- **Concept introduced, request-to-entity mapping is not DTO mapping**: entity-to-DTO mapping is source-generated by Mapperly ([ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html)), because it is a mechanical property copy with no rules. Going the other way is the opposite: constructing an entity is where invariants are enforced, so it cannot be generated. This class is therefore hand-written and does exactly one thing, forward the request's fields to the factory in positional order, so that `Session.Create` remains the only path into a valid `Session`. `[Rubric §4, DDD]` assesses whether entities can be constructed in an invalid state; here they cannot, because the mapper has no other constructor available to it. `[Rubric §2, Design Patterns]`: this is an adapter, and its value is precisely that it has no behavior of its own to disagree with the factory. +- **Walkthrough**: `CreateEntityAsync` (`SessionCreateRequestMapper.cs:15-34`) guards its argument with `ArgumentNullException.ThrowIfNull` (`:17`), then returns `Task.FromResult(Session.Create(...))` with fourteen positional arguments (`:19-33`). The method is async in signature only: it returns a completed task because nothing here awaits, which keeps the interface uniform for mappers that do need I/O without paying a state machine for the ones that do not. Validation happens inside the factory, which combines three invariant checks before allocating (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:179-184`: title validity, end-after-start, optional text lengths) and returns `Result.Failure` with the combined errors when any fails. +- **Why it's built this way**: the generic create pipeline needs a uniform way to get from some request type to some entity, and the only thing that can vary per entity is which factory to call with which fields. Isolating that in a one-method class means the pipeline never sees a domain constructor. +- **Where it's used**: registered by the convention scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`); injected into [`CreateSessionHandler`](#createsessionhandler) through the interface, not the concrete type (`CreateSessionHandler.cs:25`), and called at `CreateSessionHandler.cs:124`. +- **Caveats / not-in-source**: the mapper passes `request.Id` into a `SessionIdentifierType?` parameter (`SessionCreateRequestMapper.cs:20`), so a request whose `Id` is still `0` would reach the factory as `0` rather than as `null`. That does not happen on the live path, because [`CreateSessionHandler`](#createsessionhandler) replaces a default id with a computed one before calling the mapper (`CreateSessionHandler.cs:79-95`), but the mapper itself does not enforce it. See also the three request properties this method drops, noted under [`SessionCreateRequest`](#sessioncreaterequest). -### RemoveSessionQuestionAnswerCommand +### SessionCreateRequestValidator -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionQuestionAnswer` · `MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionQuestionAnswer/RemoveSessionQuestionAnswerCommand.cs:9` · Level 9 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.Create` · `MMCA.ADC.Conference.Application/Sessions/UseCases/Create/SessionCreateRequestValidator.cs:7` · Level 10 · class (sealed) -- **What it is**: the command to detach a question answer from a session. It is a two-field `sealed record`: the owning `SessionId` plus the child `SessionQuestionAnswerId` (`MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionQuestionAnswer/RemoveSessionQuestionAnswerCommand.cs:9-11`). -- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (the only interface it implements), the [`Session`](group-17-conference-domain.md#session) domain type (used purely for its `FullName` when building the prefix), and the `SessionIdentifierType` / `SessionQuestionAnswerIdentifierType` module aliases ([ADR-048](https://ivanball.github.io/docs/adr/048-primitive-identifier-type-aliases.html); both resolve to `int` at `MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:15`). -- **Concept reinforced, write-side cache invalidation declared by the message**: a command that implements [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) exposes a `CachePrefix`, and the caching decorator in the CQRS pipeline evicts every entry under that prefix once the command succeeds ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html), [ADR-026](https://ivanball.github.io/docs/adr/026-caching-strategy.html)). Here the prefix is `$"{typeof(Session).FullName}:"` (`RemoveSessionQuestionAnswerCommand.cs:14`), the same namespace-qualified prefix every cached session read keys under, so removing one answer flushes all cached session projections rather than trying to reason about which ones embedded it. `[Rubric §10, Cross-Cutting]` assesses whether concerns like caching are applied uniformly by infrastructure instead of hand-wired per use case: this command *declares* an eviction and implements none. `[Rubric §12, Performance and Scalability]`: the coarse prefix trades some over-eviction for a correctness guarantee that no handler can forget. -- **Walkthrough**: the record body is a single expression-bodied property, `CachePrefix` (`RemoveSessionQuestionAnswerCommand.cs:13-14`); both ids are positional parameters, so the message is immutable by construction. -- **Where it's used**: constructed by the delete endpoint of [`SessionQuestionAnswersController`](group-20-conference-api-grpc.md#sessionquestionanswerscontroller) (`MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:217`) and handled by [`RemoveSessionQuestionAnswerHandler`](#removesessionquestionanswerhandler), which adds the per-record ownership check this message deliberately does not carry. +- **What it is**: the input validator for [`SessionCreateRequest`](#sessioncreaterequest). Its body is eight `Include` calls and nothing else (`SessionCreateRequestValidator.cs:9-19`). +- **Depends on**: FluentValidation's `AbstractValidator` (`:1`, `:7`); the eight reusable rule sets in `MMCA.ADC.Conference.Application.Sessions.Validation` (`:2`), namely [`SessionTitleRules`](#sessiontitlerulest), [`SessionEventIdRules`](#sessioneventidrulest), [`SessionDescriptionRules`](#sessiondescriptionrulest), [`SessionStatusRules`](#sessionstatusrulest), [`SessionLiveUrlRules`](#sessionliveurlrulest), [`SessionRecordingUrlRules`](#sessionrecordingurlrulest), [`SessionAccessibilityInfoRules`](#sessionaccessibilityinforulest), and [`SessionResourceLinksRules`](#sessionresourcelinksrulest). +- **Concept reinforced, composing validators with `Include`**: each rule set is generic in the containing request type and takes a property selector in its constructor, for example `new SessionTitleRules(p => p.Title)` (`:11`). FluentValidation's `Include` folds another validator's rules into this one as though they had been written inline, so the create and update requests share one definition of "what a valid session title is" without sharing a base class or a request shape. `[Rubric §16, Maintainability]` assesses whether a rule has a single home: change the title constraint once and both request paths move together. `[Rubric §1, SOLID]`: each rule set is one reason to change. +- **Walkthrough**: the constructor (`SessionCreateRequestValidator.cs:9-19`) includes the eight sets in a fixed order: title, event id, description, status, live URL, recording URL, accessibility info, resource links. No rule is declared locally, which is the point; the class is a manifest of which shared field rules apply to this request. +- **Why it's built this way**: the parallel [`SessionUpdateRequestValidator`](#sessionupdaterequestvalidator) includes the same generic rule sets closed over its own request type. Parameterizing by both the request type and the property selector is what makes that reuse possible across two records that share no interface. +- **Where it's used**: discovered by the convention scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`) and run by the validation decorator ahead of [`CreateSessionHandler`](#createsessionhandler) ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). Covered by [`SessionCreateRequestValidatorTests`](group-27-testing-infrastructure.md#sessioncreaterequestvalidatortests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionCreateRequestValidatorTests.cs:7`). +- **Caveats / not-in-source**: the validator says nothing about `StartsAt` versus `EndsAt`. Ordering is a domain invariant, checked by `SessionInvariants.EnsureEndsAtIsAfterStartsAt` inside the factory (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:181`), so an inverted range is rejected one layer later than a too-long title is. Room assignment is likewise absent here and enforced by the handler (`CreateSessionHandler.cs:100-122`). -### RemoveSessionSpeakerCommand +### CreateSessionHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionSpeaker` · `MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionSpeaker/RemoveSessionSpeakerCommand.cs:9` · Level 9 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.Create` · `MMCA.ADC.Conference.Application/Sessions/UseCases/Create/CreateSessionHandler.cs:22` · Level 11 · class (sealed partial) -- **What it is**: the command to remove a speaker association from a session: `SessionId` plus the join-entity id `SessionSpeakerId` (`MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionSpeaker/RemoveSessionSpeakerCommand.cs:9-11`). -- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating), the [`Session`](group-17-conference-domain.md#session) type for the prefix, and the `SessionIdentifierType` / `SessionSpeakerIdentifierType` aliases. -- **Concept reinforced**: none new; `CachePrefix` (`RemoveSessionSpeakerCommand.cs:14`) is the identical session prefix explained on [`RemoveSessionQuestionAnswerCommand`](#removesessionquestionanswercommand). -- **Walkthrough**: structurally interchangeable with its sibling, but read the handler before assuming they behave alike: [`RemoveSessionSpeakerHandler`](#removesessionspeakerhandler) tolerates a defaulted `SessionId` and resolves the owning session from the join id, because the UI's generic delete does not send the parent id. The command itself declares no nullability for `SessionId`, so "absent" is expressed as the `int` default rather than as `null`. -- **Where it's used**: constructed by the delete endpoint of [`SessionSpeakersController`](group-20-conference-api-grpc.md#sessionspeakerscontroller) (`MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:241`, where the parent id arrives as `[FromQuery] SessionIdentifierType sessionId` at `:237`) and handled by [`RemoveSessionSpeakerHandler`](#removesessionspeakerhandler). +- **What it is**: the session create handler, and the most involved handler in this chapter. Beyond the usual map-persist-return, it allocates application-assigned ids out of a reserved range, guards room assignment, and retries a bounded number of times when a concurrent create takes the id it computed. +- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) closed over [`SessionCreateRequest`](#sessioncreaterequest) and `Result<`[`SessionDTO`](group-17-conference-domain.md#sessiondto)`>` (`CreateSessionHandler.cs:27`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:23`); `IServiceScopeFactory` from `Microsoft.Extensions.DependencyInjection` (`:24`); [`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype) (`:25`, implemented by [`SessionCreateRequestMapper`](#sessioncreaterequestmapper)); the concrete [`SessionDTOMapper`](#sessiondtomapper) (`:26`); [`SessionInvariants`](group-17-conference-domain.md#sessioninvariants) for the reserved range; [`SessionRoomScheduling`](#sessionroomscheduling) for BR-130; the [`Event`](group-17-conference-domain.md#event) aggregate. +- **Concept introduced, application-assigned ids in a reserved range**: session primary keys are not database-generated, because the `int` PK **is** the Sessionize id (`CreateSessionHandler.cs:76-78`). An organizer-created session therefore needs an id that can never collide with one Sessionize will later import. The domain reserves the top of the range for that: `SessionInvariants.ManualIdRangeStart` is `999_999_000` and `ManualIdRangeEnd` is `999_999_999` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:41`, `:44`), so a thousand manual ids sit above anything Sessionize issues. The handler queries the existing rows in that window with `ignoreQueryFilters: true` (`:81-85`), takes `Max + 1` or the range start when empty (`:87-89`), and fails with a plain `Error.Failure` when the range is exhausted (`:91-92`). Ignoring the query filters is load-bearing: a soft-deleted session still occupies its id, so counting only visible rows would hand out an id the database already holds. `[Rubric §8, Data Architecture]` assesses whether the identity strategy matches the data's provenance; here an externally-owned key space forced the choice, and the reserved range is how the two writers coexist. The same pattern appears for questions and rooms (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:37`, `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:62`). +- **Concept introduced, retrying a lost id race in a fresh DI scope**: computing `Max + 1` and inserting is a read-then-write race, so two concurrent organizer creates can compute the same id. The handler accepts that and recovers instead of locking. `MaxManualIdAttempts` is `3` (`:30`). `HandleAsync` (`:33-63`) loops, and the `catch` filter engages only when attempts remain **and** the exception chain looks like a duplicate key (`:57`). The subtle part is the retry, which does not reuse the ambient unit of work: `scopeFactory.CreateAsyncScope()` produces a fresh scope and a fresh [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:53-55`), because the ambient `DbContext` still tracks the insert that just failed and would replay it (`:51-52`). `[Rubric §29, Resilience and Business Continuity]` assesses whether transient contention is survived rather than surfaced; a bounded, condition-filtered retry is the shape that does not turn a real error into an infinite loop. `[Rubric §12, Performance and Scalability]`: the design trades a rare retry for never taking a table lock. +- **Concept introduced, detecting a database error without referencing the database**: the Application layer cannot reference EF Core or the SQL client, so `IsUniqueKeyViolation` (`:143-152`) walks the `InnerException` chain and matches the message text `"duplicate key"` case-insensitively, which covers SQL Server errors 2601 and 2627 (`:138-142`). The file states both the constraint and the compromise in its own comment. `[Rubric §3, Clean Architecture]` assesses whether layer boundaries hold under pressure; they do here, at the cost of a string match. +- **Walkthrough**: two paths and two helpers. + - `HandleAsync` (`:33-63`) first short-circuits: a caller-supplied id (a Sessionize import, for example) is respected as-is and gets a single attempt with no recomputation, because a collision there is a genuine caller error (`:37-40`). Otherwise the retry loop runs, calling `CreateCoreAsync` on the ambient unit of work for attempt one (`:48-49`) and on a scoped one thereafter (`:53-55`), logging a warning on each collision (`:60`). + - `CreateCoreAsync` (`:69-136`) is one full attempt. It resolves the repository (`:74`), allocates the manual id when needed (`:79-95`, ending in `command = command with { Id = nextId }`, a copy rather than a mutation), then validates room assignment **only when a room was requested** (`:100-122`). That branch loads the parent [`Event`](group-17-conference-domain.md#event) with its `Rooms` untracked (`:102-107`), returns `Error.NotFound` targeting `Event` when it is missing (`:108-109`), and delegates to [`SessionRoomScheduling`](#sessionroomscheduling)`.ValidateRoomAssignmentAsync` for BR-130 cross-event validation plus the double-booking guard (`:111-119`, with `excludeSessionId: null` because nothing exists yet to exclude). The comment at `:97-99` explains why the event is not loaded unconditionally: a room-less session has nothing to validate, and unlike update's BR-86 warning, create has no other use for the event. + - The tail is the ordinary create: map through the request mapper (`:124-128`), `AddAsync` then `SaveChangesAsync` (`:130-131`), log (`:133`), and return the mapped [`SessionDTO`](group-17-conference-domain.md#sessiondto) (`:135`). + - Two `[LoggerMessage]` partials close the file: `LogSessionCreated` at information level (`:154-155`) and `LogManualIdCollision` at warning level with the attempt counters (`:157-158`). The warning is the operational signal that the id race is happening more often than expected. `[Rubric §13, Observability and Operability]`. +- **Why it's built this way**: every complication in this file traces to one fact, that the session key space is shared with an external system. [ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html) gives the module its own database, but not its own id authority for sessions. Given that, the reserved range prevents collision by construction, and the retry handles the only race the range cannot prevent. +- **Where it's used**: registered by the convention scan (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125`); injected into [`SessionsController`](group-20-conference-api-grpc.md#sessionscontroller) as `createHandler` (`SessionsController.cs:44`), passed into `AggregateRootEntityControllerBase` (`SessionsController.cs:54-55`), and called from the overridden `POST /Sessions` action (`SessionsController.cs:296`), which is marked `[Idempotent]` so a retried request replays rather than creating twice (`SessionsController.cs:291`, [ADR-017](https://ivanball.github.io/docs/adr/017-request-idempotency.html)). Covered by [`CreateSessionHandlerTests`](group-27-testing-infrastructure.md#createsessionhandlertests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/CreateSessionHandlerTests.cs:16`), which mocks the scope factory and a second unit of work specifically to exercise the retry path (`CreateSessionHandlerTests.cs:24-26`). +- **Caveats / not-in-source**: the manual-id query loads every session row in the reserved range as entities and computes `Max` in memory (`:81-89`) rather than asking the database for the maximum. The range caps at a thousand rows, so the cost is bounded, but it is not a scalar query. The retry loop is also `while (true)` with its bound expressed only in the `catch` filter (`:44`, `:57`): correct as written, since a non-matching exception or an exhausted budget propagates, but the termination condition is not local to the loop header. Finally, `IsUniqueKeyViolation` matches on message text, so a provider that phrases the error differently, or a localized server message, would not be recognized and the create would surface the raw exception. ### SponsorCreateRequest -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sponsors.UseCases.Create` · `MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequest.cs:11` · Level 9 · record +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sponsors.UseCases.Create` · `MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequest.cs:11` · Level 9 · record class -- **What it is**: the create contract for a conference sponsor or exhibitor. Like the other create requests in this module it doubles as the command itself: [`CreateSponsorHandler`](#createsponsorhandler) is declared as `ICommandHandler>` (`MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/CreateSponsorHandler.cs:20`), so the request travels the whole pipeline unchanged. -- **Depends on**: [`ICreateRequest`](group-05-cqrs-pipeline.md#icreaterequest) (so the generic request-mapper pipeline can process it) and [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`SponsorCreateRequest.cs:11`); the [`Sponsor`](group-17-conference-domain.md#sponsor) type for the cache prefix; the [`SponsorTier`](group-17-conference-domain.md#sponsortier) enum; the `SponsorIdentifierType` / `EventIdentifierType` aliases (`SponsorIdentifierType` is `int`, `MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:20`). -- **Concept reinforced, the create request as both wire contract and command**: `CachePrefix` returns `$"{typeof(Sponsor).FullName}:"` (`SponsorCreateRequest.cs:14`), so a successful create evicts the cached sponsor reads through the pipeline decorator. That is a different cache from the ASP.NET output cache the controller evicts separately (`MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:218`), and both are needed: one holds handler results, the other holds rendered responses. `[Rubric §9, API and Contract Design]` assesses whether the public shape is explicit and minimal; `[Rubric §5, Vertical Slice]`: the request, its validator, its mapper, and its handler all live in the one `UseCases/Create` folder. -- **Walkthrough**: a `record class` of `init`-only properties. Only `Name` is `required` (`SponsorCreateRequest.cs:20`), which is exactly the field the domain treats as mandatory. `Tier` (`:23`) and `Sort` (`:41`) carry their own defaults, `EventId` (`:44`) scopes the sponsor to an event, `IsExhibitor` (`:47`) and `BoothNumber` (`:50`) model the expo-floor half of the concept, and the four link fields (`LogoUrl`, `WebsiteUrl`, `LinkedInUrl`, `TwitterHandle`, `:26`, `:32`, `:35`, `:38`) plus `Description` (`:29`) are all optional. `Id` (`:17`) is present but its XML doc states the rule: it is database-generated and a caller-supplied value is ignored. That is not a comment-only claim; the factory decides it, `Id = isIdValueGenerated ? default : id!.Value` (`MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:126`, `:130`), which is what makes sponsors differ from sessions, whose `int` key is the Sessionize id and therefore application-assigned. -- **Why it's built this way**: one immutable type for the API body and the internal command removes a translation step and a class that could drift, and `init`-only accessors mean any handler adjustment has to be an explicit `with` copy rather than a hidden mutation. -- **Where it's used**: bound by the `[HttpPost]` override on [`SponsorsController`](group-20-conference-api-grpc.md#sponsorscontroller) (`MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:211-217`, gated by `[HasPermission(ConferencePermissions.SponsorsManage)]` at `:212`, see [ADR-020](https://ivanball.github.io/docs/adr/020-permission-based-authorization.html)), validated by [`SponsorCreateRequestValidator`](#sponsorcreaterequestvalidator), turned into an entity by [`SponsorCreateRequestMapper`](#sponsorcreaterequestmapper), and handled by [`CreateSponsorHandler`](#createsponsorhandler). -- **Caveats / not-in-source**: nothing on the request or in its validator checks that `EventId` refers to an existing event; the rule only requires that one be supplied (`MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:101-103`). Referential enforcement is left to the database. +- **What it is**: the body a `POST /Sponsors` binds to, and, without any translation step, the command the CQRS pipeline dispatches. Twelve init-only members describe a sponsor or exhibitor; one extra member, `CachePrefix`, tells the pipeline what to evict when the write succeeds (`MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequest.cs:14`). +- **Depends on**: two framework marker interfaces, [`ICreateRequest`](group-05-cqrs-pipeline.md#icreaterequest) (an empty marker, `MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICreateRequest.cs:8-10`) and [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/ICacheInvalidating.cs:8-15`), plus the [`Sponsor`](group-17-conference-domain.md#sponsor) entity type (referenced only through `typeof` for the cache prefix) and the [`SponsorTier`](group-17-conference-domain.md#sponsortier) enum. `SponsorIdentifierType` and `EventIdentifierType` are both module aliases for `int` (`MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8`, `:21`). +- **Concept introduced, the request that is also the command**: most codebases carry a request DTO at the edge and translate it into an internal command. Here the two collapse. The controller declares `ICommandHandler>` directly (`MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:39`) and passes the same type as the `TCreateRequest` argument of its generic base (`:46-47`), so a single declaration is the OpenAPI schema, the validation target, the mapper input, and the cache-invalidation carrier. The cost is that a wire concern and a use-case concern share one type; the benefit is that there is exactly one place to add a field. `[Rubric §5, Vertical Slice]` assesses whether a feature is expressible as one thin, self-contained slice: the sponsor create slice is this file plus a validator, a mapper, and a handler, all in the same folder. `[Rubric §9, API and Contract Design]` assesses whether the published contract is explicit: `required string Name` (`:20`) is the only member the binder will not default, and every other member is optional by construction. +- **Walkthrough**: the members in the order they matter. + - `CachePrefix => $"{typeof(Sponsor).FullName}:"` (`:14`) is the entity's fully-qualified name plus a colon. That is the framework's own convention, not a local invention: the generic delete command builds its prefix the same way (`MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/DeleteEntityCommand.cs:20`), and `UpdateSponsorCommand` repeats the identical expression (`MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/UpdateSponsorCommand.cs:12`). All three sponsor mutations therefore evict one shared namespace of keys. The eviction is performed by [`CachingCommandDecorator`](group-05-cqrs-pipeline.md#cachingcommanddecoratortcommand-tresult) on success only, and it skips a blank prefix because `RemoveByPrefixAsync("")` would flush the entire cache (`MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingCommandDecorator.cs:73-78`). + - `Id` (`:17`) is documented as database-generated with caller-supplied values ignored, and the factory is what makes that true: it consults `typeof(Sponsor).IsIdValueGenerated` and substitutes `default` whenever the store owns the key (`MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:126-131`). Nothing rejects a supplied id; it is simply discarded. + - `Name` (`:20`) is `required`, so an omitted name fails model binding before any validator runs. `Tier` (`:23`) is the enum, `Sort` (`:41`) the within-tier display order, `EventId` (`:44`) the owning event, `IsExhibitor` (`:47`) and `BoothNumber` (`:50`) the expo-floor pair, and `LogoUrl`, `Description`, `WebsiteUrl`, `LinkedInUrl`, `TwitterHandle` (`:26-38`) the optional branding strings. + - Every member is `init`, so once the binder has filled the instance the validator, the mapper, and the handler all see the same frozen values. +- **Why it's built this way**: the shape mirrors [`SponsorDTO`](group-17-conference-domain.md#sponsordto) member for member (`MMCA.ADC.Conference.Shared/Sponsors/SponsorDTO.cs:9-48`) minus the concurrency token, which keeps the round trip (POST a request, receive a DTO) readable without a mapping table ([ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html)). Declaring cache invalidation as a property rather than calling a cache API keeps the Application layer free of cache infrastructure, which is what `[Rubric §10, Cross-Cutting]` looks for: the concern is declared once, and one decorator implements it for every command that declares it. +- **Where it's used**: [`SponsorsController`](group-20-conference-api-grpc.md#sponsorscontroller) takes the handler for it in its constructor (`MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:39`) and overrides `CreateAsync` to add the capability check and the output-cache eviction (`:211-220`); [`SponsorCreateRequestValidator`](#sponsorcreaterequestvalidator) validates it, [`SponsorCreateRequestMapper`](#sponsorcreaterequestmapper) turns it into an entity, and [`CreateSponsorHandler`](#createsponsorhandler) persists it. +- **Caveats / not-in-source**: sponsors sit behind **two** independent caches, and this property addresses only one. `CachePrefix` drives the framework's `ICacheService` prefix eviction; the ASP.NET output cache in front of the public sponsor reads is a separate store the controller evicts by tag in the same action (`MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:253-257`). Removing either half leaves stale sponsor data visible somewhere. ### SponsorDTOMapper -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sponsors.DTOs` · `MMCA.ADC.Conference.Application/Sponsors/DTOs/SponsorDTOMapper.cs:13` · Level 9 · class +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sponsors.DTOs` · `MMCA.ADC.Conference.Application/Sponsors/DTOs/SponsorDTOMapper.cs:13` · Level 9 · class (sealed, partial) -- **What it is**: the read-side mapper that projects a [`Sponsor`](group-17-conference-domain.md#sponsor) entity into a [`SponsorDTO`](group-17-conference-domain.md#sponsordto). Its class comment states the redaction policy in one line: sponsor data is public by design (it is bought placement), so nothing is withheld (`MMCA.ADC.Conference.Application/Sponsors/DTOs/SponsorDTOMapper.cs:8-11`). -- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) closed over `Sponsor` / `SponsorDTO` / `SponsorIdentifierType` (`SponsorDTOMapper.cs:14`), and the Mapperly source generator (`Riok.Mapperly.Abstractions`, `:4`). -- **Concept reinforced, compile-time generated mapping**: the class is `partial` and carries `[Mapper]` (`SponsorDTOMapper.cs:12-13`), and `MapToDTO` is declared as a `partial` method with no body (`:17`). Mapperly writes the body at compile time by matching property names, so the mapping is ordinary generated C# with no reflection and no runtime configuration to get wrong; a property that cannot be matched is a build warning, which under this workspace's `TreatWarningsAsErrors` is a build failure. This is the [ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html) position: mapping is explicit and checkable, never a runtime convention scan. `[Rubric §15, Best Practices and Code Quality]`: the compiler, not a test, is what proves the projection is total. `[Rubric §12, Performance and Scalability]`: generated assignment code allocates one object and does no member lookup. -- **Walkthrough**: two members. `MapToDTO` (`:17`) is the generated single-entity projection. `MapToDTOs` (`:20-24`) is hand-written rather than generated: it guards its argument with `ArgumentNullException.ThrowIfNull` (`:22`) and then materializes with a collection expression over `Select(MapToDTO)` (`:23`), so the caller always receives a fully realized `IReadOnlyCollection` instead of a deferred sequence that could be enumerated after the `DbContext` is gone. -- **Where it's used**: injected concretely (not through the interface) into [`CreateSponsorHandler`](#createsponsorhandler) (`MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/CreateSponsorHandler.cs:19`) and [`UpdateSponsorHandler`](#updatesponsorhandler) (`MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/UpdateSponsorHandler.cs:17`), and resolved by interface for the generic read path, since sponsors are served by the framework's `EntityQueryService` (`MMCA.ADC.Conference.Application/DependencyInjection.cs:76`, see [ADR-034](https://ivanball.github.io/docs/adr/034-generic-entity-query-layer.html)). Registration happens through the convention scan, `services.ScanModuleApplicationServices()` (`MMCA.ADC.Conference.Application/DependencyInjection.cs:112`). +- **What it is**: the entity-to-DTO mapper for sponsors, and the simplest one in the module: no redaction, no conditional projection, just the Mapperly-generated copy of [`Sponsor`](group-17-conference-domain.md#sponsor) into [`SponsorDTO`](group-17-conference-domain.md#sponsordto), because sponsor data is bought placement and is public by design (`MMCA.ADC.Conference.Application/Sponsors/DTOs/SponsorDTOMapper.cs:8-11`). +- **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) closed over `Sponsor` / `SponsorDTO` / `SponsorIdentifierType` (`:14`), and the Mapperly source generator via `[Mapper]` from `Riok.Mapperly.Abstractions` (`:4`, `:12`). +- **Concept reinforced, source-generated mapping**: the pattern is introduced in [Group 12](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) and governed by [ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html). `MapToDTO` is declared `partial` with no body (`:17`); Mapperly reads both types at compile time and emits the property-by-property assignment, so a member added to the entity but not to the DTO surfaces as a build diagnostic rather than as a silently missing field at runtime. `[Rubric §15, Best Practices and Code Quality]` assesses whether repetitive code is generated rather than hand-maintained: twelve assignments exist, and none of them are in this file. +- **Walkthrough**: two members. + - `public partial SponsorDTO MapToDTO(Sponsor entity);` (`:17`) is the generated one. Because [`SponsorDTO`](group-17-conference-domain.md#sponsordto) also carries `RowVersion` (`MMCA.ADC.Conference.Shared/Sponsors/SponsorDTO.cs:15`), the concurrency token rides along with the projection and is what a later update has to echo back. + - `MapToDTOs` (`:20-24`) is hand-written and, read side by side, is character-for-character what the interface already provides as a default implementation (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEntityDTOMapper.cs:27-32`): a null guard plus `[.. entityCollection.Select(MapToDTO)]`. The duplication is not pointless. A C# default interface member is reachable only through the interface, and this mapper is injected by its **concrete** type in at least one place ([`CreateSponsorHandler`](#createsponsorhandler), `MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/CreateSponsorHandler.cs:19`), which Scrutor supports by registering mappers `AsSelfWithInterfaces` (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:157-161`). Re-declaring the method keeps both call shapes working. +- **Why it's built this way**: contrast it with its sibling. [`SpeakerDTOMapper`](#speakerdtomapper) injects the current-user service and blanks the speaker's email for anyone who is not an Organizer (`MMCA.ADC.Conference.Application/Speakers/DTOs/SpeakerDTOMapper.cs:36`). Sponsors have no such member, so this mapper needs no collaborators and stays a pure function. `[Rubric §11, Security]` assesses whether sensitive data is filtered at the boundary that owns it: here the boundary exists and has nothing to filter, which is a documented conclusion (`:8-11`) rather than an omission. `[Rubric §30, Compliance and Data Governance]` lands in the same place: nothing on the sponsor record is personal data. +- **Where it's used**: registered by the module's convention scan (`MMCA.ADC.Conference.Application/DependencyInjection.cs:125`, which reaches the `IEntityDTOMapper<,,>` sweep at `MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:157-161`), injected concretely into [`CreateSponsorHandler`](#createsponsorhandler) (`CreateSponsorHandler.cs:19`), and resolved through the interface by the closed-generic read service registered for sponsors, `EntityQueryService` (`MMCA.ADC.Conference.Application/DependencyInjection.cs:85`). +- **Caveats / not-in-source**: the generated `MapToDTO` has no null guard, and the suite pins that: `MapToDTO(null!)` throws `NullReferenceException` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sponsors/DTOs/SponsorDTOMapperTests.cs:73-81`), while the hand-written collection overload throws the more conventional `ArgumentNullException` (`SponsorDTOMapper.cs:22`). Every caller in this codebase passes a materialized entity, so the asymmetry is documented behavior rather than a live failure mode. ### UpdateSessionQuestionAnswerCommand -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.UpdateSessionQuestionAnswer` · `MMCA.ADC.Conference.Application/Sessions/UseCases/UpdateSessionQuestionAnswer/UpdateSessionQuestionAnswerCommand.cs:10` · Level 9 · record - -- **What it is**: the command to edit an existing question answer on a session. Same shape as the two Remove commands above plus one payload field: `SessionId`, `SessionQuestionAnswerId`, and the new `AnswerValue` text (`MMCA.ADC.Conference.Application/Sessions/UseCases/UpdateSessionQuestionAnswer/UpdateSessionQuestionAnswerCommand.cs:10-13`). -- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating), the [`Session`](group-17-conference-domain.md#session) type, and the `SessionIdentifierType` / `SessionQuestionAnswerIdentifierType` aliases. -- **Concept reinforced**: none new; `CachePrefix` (`UpdateSessionQuestionAnswerCommand.cs:16`) evicts the same session prefix described on [`RemoveSessionQuestionAnswerCommand`](#removesessionquestionanswercommand). Worth noticing what the command does *not* carry: no author id and no role. Ownership is decided by the handler against the audit trail, never against a client-supplied field, which is why an attacker cannot forge authorship by editing the request body. -- **Where it's used**: constructed by [`SessionQuestionAnswersController`](group-20-conference-api-grpc.md#sessionquestionanswerscontroller) (`MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:201`) and handled by [`UpdateSessionQuestionAnswerHandler`](#updatesessionquestionanswerhandler). +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.UpdateSessionQuestionAnswer` · `MMCA.ADC.Conference.Application/Sessions/UseCases/UpdateSessionQuestionAnswer/UpdateSessionQuestionAnswerCommand.cs:10` · Level 9 · record (sealed) + +- **What it is**: a three-value command to change the text of one answer on one session's questionnaire. It carries the owning session id, the answer id, and the new text (`MMCA.ADC.Conference.Application/Sessions/UseCases/UpdateSessionQuestionAnswer/UpdateSessionQuestionAnswerCommand.cs:10-13`). +- **Depends on**: [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) (`:13`) and the [`Session`](group-17-conference-domain.md#session) type, referenced only through `typeof` to build the cache prefix (`:16`). `SessionIdentifierType` and `SessionQuestionAnswerIdentifierType` are both `int` (`MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:15-16`). +- **Concept reinforced, commands address the aggregate root**: the pattern is taught in [Group 05](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult). Note what the first parameter buys. The REST route already identifies the answer (`PUT /SessionQuestionAnswers/{id}`), yet the command still requires `SessionId`, supplied from the request body (`MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:209`). That is not redundancy: [`SessionQuestionAnswer`](group-17-conference-domain.md#sessionquestionanswer) is a child inside the [`Session`](group-17-conference-domain.md#session) aggregate, so the only legal way to mutate it is to load the root and go through it, and the root's id is what makes that load possible. `[Rubric §4, DDD]` assesses whether aggregate boundaries are respected in the write model: the command's shape enforces the boundary before the handler even runs. +- **Walkthrough**: a positional record with one computed member. + - The three positional parameters `SessionId`, `SessionQuestionAnswerId`, and `AnswerValue` (`:11-13`) become init-only properties, so the command is immutable once constructed. + - `CachePrefix => $"{typeof(Session).FullName}:"` (`:16`) names the **Session**, not the answer. Evicting the parent's namespace is what matters: nothing caches a bare answer, but a session read that includes its answers would otherwise keep serving the old text. +- **Why it's built this way**: the handler's response type is `Result`, not `Result` (`UpdateSessionQuestionAnswerHandler.cs:17`), because a successful update returns nothing beyond a `204` (`MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:214`). Keeping the command a record also makes it structurally comparable, which is what the API tests lean on when they assert the handler was called with the values the route and body supplied (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.API.Tests/Controllers/SessionQuestionAnswersControllerTests.cs:82`). +- **Where it's used**: constructed by [`SessionQuestionAnswersController`](group-20-conference-api-grpc.md#sessionquestionanswerscontroller)`.UpdateAsync` (`MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:202-215`) and handled by [`UpdateSessionQuestionAnswerHandler`](#updatesessionquestionanswerhandler). +- **Caveats / not-in-source**: nothing checks that the `SessionId` in the body actually owns the `{id}` in the route. It does not need to: the handler loads the session named in the command and looks the answer up inside that aggregate's own collection, so a mismatched pair simply finds no child and returns NotFound (`UpdateSessionQuestionAnswerHandler.cs:44`, `MMCA.ADC.Conference.Domain/Sessions/Session.cs:540-542`). + +### ActivityNavigationPopulator + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Activities` · `MMCA.ADC.Conference.Application/Activities/ActivityNavigationPopulator.cs:12` · Level 10 · class (sealed) + +- **What it is**: the navigation populator for [`Activity`](group-17-conference-domain.md#activity). It declares one thing: how to hydrate an activity's parent [`Event`](group-17-conference-domain.md#event) reference when EF Core cannot reach it with `.Include()`. The class body is empty (`MMCA.ADC.Conference.Application/Activities/ActivityNavigationPopulator.cs:24-25`); the entire implementation is the descriptor list handed to the base constructor (`:14-23`). +- **Depends on**: [`DeclarativeNavigationPopulator`](group-11-navigation-populators.md#declarativenavigationpopulatortentity) closed over `Activity` (`:14`), [`FKNavigationDescriptor`](group-11-navigation-populators.md#fknavigationdescriptortentity-tchild-tchildid) closed over `Activity` / `Event` / `EventIdentifierType` (`:16`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) forwarded untouched to the base (`:12-14`), and the [`Activity`](group-17-conference-domain.md#activity) and [`Event`](group-17-conference-domain.md#event) entities. +- **Concept introduced in this group, FK back-reference hydration**: [Group 11](group-11-navigation-populators.md#declarativenavigationpopulatortentity) teaches the populator machinery; what this class introduces here is the **other direction** of it. A child-collection descriptor answers "give me the rows that point at me"; an FK descriptor answers "give me the one row I point at". The framework separates the two with a single boolean: `FKNavigationDescriptor.RequiresChildren` is hard-coded `false` (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/FKNavigationDescriptor.cs:23`), and the base uses it to pick which caller flag gates the load, `includeFKs` rather than `includeChildren` (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/DeclarativeNavigationPopulator.cs:36`). `[Rubric §2, Design Patterns]` assesses whether behavior is factored into reusable shapes: this is Template Method configured by data, so a new navigation is a descriptor rather than a new query method. `[Rubric §7, Microservices Readiness]` assesses whether code survives a physical split: the whole reason this file exists is that a join is unavailable when parent and child live in different data sources ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html), [ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html)). +- **Walkthrough**: one descriptor, four settings, and a base algorithm worth following once. + - `PropertyName = nameof(Activity.Event)` (`:17`) is the match key. The base builds an ordinal `HashSet` of the property names the metadata provider flagged as unsupported and loads only descriptors whose name is in it (`DeclarativeNavigationPopulator.cs:30-37`). The name comes from the navigation property itself, `public Event? Event { get; set; }` (`MMCA.ADC.Conference.Domain/Activities/Activity.cs:57-58`), which carries a bare `[Navigation]` attribute; with `IsCollection` left at its default, metadata discovery files it in the foreign-key bucket (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/NavigationMetadataProvider.cs:74-78`), which is exactly what makes `RequiresChildren => false` the right gate. + - `ParentKeySelector = e => e.EventId` (`:18`). The descriptor types this as `Func` (`FKNavigationDescriptor.cs:26`), and `Activity.EventId` is a non-nullable `int` (`MMCA.ADC.Conference.Domain/Activities/Activity.cs:54`), so the compiler widens it to `int?`. The nullable signature exists for entities whose FK is genuinely optional; here it can never be null. + - `ChildForeignKeySelector = child => child.Id` (`:19`). Read that carefully: for an FK reference the "child foreign key" is the **target's own primary key**, because the predicate being built matches `Event.Id` against the set of `Activity.EventId` values. + - `AssignAction = (e, events) => e.Event = events.FirstOrDefault()` (`:21`). The navigation is a public settable property (`Activity.cs:58`), so no aggregate mutator is needed, unlike the child-collection populators which have to call an `internal` setter. + - The load is `NavigationLoader.LoadFKPropertyAsync` (`FKNavigationDescriptor.cs:39-45`), and it is deliberately batched: collect the distinct non-null keys (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/NavigationLoader.cs:54-59`), return early assigning empty lists when there are none (`:61-69`), build `child => parentIds.Contains(child.Id)` as an expression tree (`:71-78`), run **one** `GetAllAsync` with that predicate against the read repository (`:80-84`), group the results into a dictionary (`:86-90`), and assign per parent (`:92-99`). One query for a page of activities, not one per activity. + - Two guards mean this usually costs nothing: `PopulateAsync` returns immediately when the entity list is empty or when the metadata reported no unsupported includes at all (`DeclarativeNavigationPopulator.cs:27-28`). On a topology where activities and events share a source, that second guard is always true and the populator never touches the database. +- **Why it's built this way**: [ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html) makes hydration a declaration in the Application layer rather than an EF concern, and [`NavigationMetadataProvider`](group-03-querying-specifications.md#navigationmetadataprovider) decides per navigation whether `.Include()` is available (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/NavigationMetadataProvider.cs:37-46`). Because that decision is configuration, this file is inert in the monolith and becomes the hydration path after a split, with no change to the controller, the query service, or the DTO. `[Rubric §3, Clean Architecture]`: there is no EF Core namespace anywhere in the file. `[Rubric §12, Performance and Scalability]`: the batched `IN` shape is what keeps a paged list from degrading into N+1. +- **Where it's used**: registered as `services.TryAddScoped, ActivityNavigationPopulator>()` (`MMCA.ADC.Conference.Application/DependencyInjection.cs:80`) and consumed by the closed-generic read service registered on the next line (`:81`), which invokes it as part of the read pipeline. Its tests assert the DI shape and both empty-input guards without touching the unit of work (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Activities/ActivityNavigationPopulatorTests.cs:15-43`). +- **Caveats / not-in-source**: `AssignAction` runs for every parent, including those whose lookup found nothing, in which case `FirstOrDefault()` assigns `null` (`NavigationLoader.cs:92-99`). Combined with the soft-delete query filter the read repository applies ([ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html)), an activity whose owning event has been soft-deleted comes back with `Event == null` rather than as an error, and a caller that renders the event name has to handle that null. + +### CategoryItemNavigationPopulator + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories` · `MMCA.ADC.Conference.Application/Categories/CategoryItemNavigationPopulator.cs:11` · Level 10 · class (sealed) + +- **What it is**: the FK populator for [`CategoryItem`](group-17-conference-domain.md#categoryitem), hydrating each item's parent [`Category`](group-17-conference-domain.md#category) reference. It is the only populator in the Conference module whose FK target is not [`Event`](group-17-conference-domain.md#event). +- **Depends on**: [`DeclarativeNavigationPopulator`](group-11-navigation-populators.md#declarativenavigationpopulatortentity) over `CategoryItem` (`MMCA.ADC.Conference.Application/Categories/CategoryItemNavigationPopulator.cs:13`), one [`FKNavigationDescriptor`](group-11-navigation-populators.md#fknavigationdescriptortentity-tchild-tchildid) closed over `CategoryItem` / `Category` / `ConferenceCategoryIdentifierType` (`:15`), and [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:12`). +- **Concept reinforced**: identical in mechanism to [`ActivityNavigationPopulator`](#activitynavigationpopulator), which teaches the FK descriptor, the `includeFKs` gate, and the batched loader in full. Only the four settings differ. +- **Walkthrough**: `PropertyName = nameof(CategoryItem.Category)` (`:17`), matching the `[Navigation]`-attributed settable property on the entity (`MMCA.ADC.Conference.Domain/Categories/CategoryItem.cs:23-24`); `ParentKeySelector = e => e.CategoryId` (`:18`), reading the get-only FK (`CategoryItem.cs:27`); `ChildForeignKeySelector = child => child.Id` (`:19`), the parent category's own primary key; and `AssignAction = (e, categories) => e.Category = categories.FirstOrDefault()` (`:20`). The generic argument `ConferenceCategoryIdentifierType` is the module alias for `int` (`MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7`), named with the `Conference` prefix because the entity type is the very generic `Category`. +- **Why it's built this way**: same rationale as its siblings ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html)). `[Rubric §16, Maintainability]`: the difference between two entities that need parent hydration is four lines of configuration, not two query classes. +- **Where it's used**: registered as `INavigationPopulator` (`MMCA.ADC.Conference.Application/DependencyInjection.cs:92`) alongside the closed-generic read service for category items (`:93`). Tests: `MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Categories/CategoryItemNavigationPopulatorTests.cs:15-43`. +- **Caveats / not-in-source**: [`ConferenceCategoryNavigationPopulator`](#conferencecategorynavigationpopulator) is the mirror image of this file, loading items from the category side. The two are independent registrations, so a read that starts at either end hydrates the other without either populator knowing about its counterpart. ### ConferenceCategoryNavigationPopulator > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Categories` · `MMCA.ADC.Conference.Application/Categories/ConferenceCategoryNavigationPopulator.cs:11` · Level 10 · class (sealed) -- **What it is**: the navigation populator for the [`Category`](group-17-conference-domain.md#category) aggregate. It loads the one child collection ([`CategoryItem`](group-17-conference-domain.md#categoryitem)) that the read path cannot materialize through `.Include()` on this model (`MMCA.ADC.Conference.Application/Categories/ConferenceCategoryNavigationPopulator.cs:7-9`). It is the smallest populator in the module: one descriptor and an empty class body (`:23-24`). -- **Depends on**: [`DeclarativeNavigationPopulator`](group-11-navigation-populators.md#declarativenavigationpopulatortentity) (the base, closed over `Category`), [`ChildNavigationDescriptor`](group-11-navigation-populators.md#childnavigationdescriptortentity-tparentid-tchild-tchildid), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (passed straight through to the base, `:11-13`), and the [`Category`](group-17-conference-domain.md#category) / [`CategoryItem`](group-17-conference-domain.md#categoryitem) entities. -- **Concept reinforced, declarative child loading over hand-written joins**: the mechanism is taught in [Group 11](group-11-navigation-populators.md#declarativenavigationpopulatortentity) ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html)); the job here is pure binding. The subclass supplies *data*, not an override: the base constructor takes the unit of work plus a collection-expression array of descriptors (`:13-22`) and owns the bulk fetch-and-assign algorithm. `[Rubric §2, Design Patterns]` assesses whether repetition is factored into a reusable abstraction: this is Template Method configured by data rather than by virtual methods. `[Rubric §3, Clean Architecture]`: the Application layer describes hydration with repository abstractions and property selectors, and no EF Core namespace appears in the file. -- **Walkthrough**: one `ChildNavigationDescriptor` (`:15`) with four settings: `PropertyName = nameof(Category.CategoryItems)` (`:17`), `ParentKeySelector = e => e.Id` (`:18`), `ChildForeignKeySelector = child => child.CategoryId` (`:19`), and `AssignAction = (e, categoryItems) => e.SetCategoryItems(categoryItems)` (`:20`). The assign action goes through the aggregate's own mutator rather than a back-door property setter, and that mutator is `internal` (`MMCA.ADC.Conference.Domain/Categories/Category.cs:210`), reachable from here only because the Domain project grants `` (`MMCA.ADC.Conference.Domain/MMCA.ADC.Conference.Domain.csproj:3`). Note the parent-key type: the Conference module's category alias is `ConferenceCategoryIdentifierType`, not a bare `CategoryIdentifierType`, because the module also owns category *items* ([ADR-048](https://ivanball.github.io/docs/adr/048-primitive-identifier-type-aliases.html)). -- **Why it's built this way**: one descriptor per collection means adding a child navigation is a data edit, not a new query method, and every aggregate in the module hydrates through the same code path ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html)). -- **Where it's used**: registered as the `INavigationPopulator` implementation, `services.TryAddScoped, ConferenceCategoryNavigationPopulator>()` (`MMCA.ADC.Conference.Application/DependencyInjection.cs:66`), and resolved by the read pipeline whenever a `Category` is loaded with `CategoryItems` requested. +- **What it is**: the navigation populator for the [`Category`](group-17-conference-domain.md#category) aggregate, hydrating its one child collection, `CategoryItems`. Like every populator in this module the body is empty (`MMCA.ADC.Conference.Application/Categories/ConferenceCategoryNavigationPopulator.cs:23-24`) and the behavior is the descriptor passed to the base (`:13-22`). +- **Depends on**: [`DeclarativeNavigationPopulator`](group-11-navigation-populators.md#declarativenavigationpopulatortentity) over `Category` (`:13`), one [`ChildNavigationDescriptor`](group-11-navigation-populators.md#childnavigationdescriptortentity-tparentid-tchild-tchildid) closed over `Category` / `ConferenceCategoryIdentifierType` / `CategoryItem` / `CategoryItemIdentifierType` (`:15`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:12`), and the [`Category`](group-17-conference-domain.md#category) and [`CategoryItem`](group-17-conference-domain.md#categoryitem) entities. +- **Concept introduced in this group, declarative child loading**: the counterpart to the FK direction taught on [`ActivityNavigationPopulator`](#activitynavigationpopulator). Three things change. First, `ChildNavigationDescriptor.RequiresChildren` is hard-coded `true` (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/ChildNavigationDescriptor.cs:25`), so the load is gated on the caller's `includeChildren` flag (`DeclarativeNavigationPopulator.cs:36`), matching the `[Navigation(IsCollection = true)]` attribute that puts the property in the child bucket during discovery (`MMCA.ADC.Conference.Domain/Categories/Category.cs:30-31`). Second, the key pair inverts: the parent supplies its own primary key and the child supplies the FK that points back. Third, the assignment cannot be a property set, because the collection is exposed as `IReadOnlyCollection` over a private list (`Category.cs:31`). +- **Walkthrough**: one descriptor. + - `PropertyName = nameof(Category.CategoryItems)` (`:17`), `ParentKeySelector = e => e.Id` (`:18`), `ChildForeignKeySelector = child => child.CategoryId` (`:19`, the FK declared at `MMCA.ADC.Conference.Domain/Categories/CategoryItem.cs:27`). + - `AssignAction = (e, categoryItems) => e.SetCategoryItems(categoryItems)` (`:20`) calls an `internal` aggregate mutator (`MMCA.ADC.Conference.Domain/Categories/Category.cs:210`), reachable from this assembly only because the Domain project grants `` (`MMCA.ADC.Conference.Domain/MMCA.ADC.Conference.Domain.csproj:3`). Nothing outside the module can replace the collection. + - The load routes through `NavigationLoader.LoadChildrenPropertyAsync` (`ChildNavigationDescriptor.cs:41`), which is one batched `WHERE CategoryId IN (...parentIds)` query per descriptor, not one per category. +- **Why it's built this way**: the naming deserves a note. The type is `ConferenceCategoryNavigationPopulator` while the entity is plain `Category`, and the identifier alias is `ConferenceCategoryIdentifierType` (`MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7`): `Category` is a word several modules would claim, so the module-qualified prefix appears on everything that is registered or aliased globally, while the entity keeps its natural name inside its own namespace. `[Rubric §16, Maintainability]` cares about exactly this kind of collision avoidance. The hydration rationale is [ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html) and [ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html), as for every populator here. +- **Where it's used**: registered as `services.TryAddScoped, ConferenceCategoryNavigationPopulator>()` (`MMCA.ADC.Conference.Application/DependencyInjection.cs:70`), beside a closed-generic read service (`:71`) and the generic delete handler (`:72`). Tests: `MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Categories/ConferenceCategoryNavigationPopulatorTests.cs:15-43`. +- **Caveats / not-in-source**: nothing in the descriptor filters soft-deleted items. That exclusion comes from the global query filter on the read repository the loader resolves ([ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html)), not from this file. ### CreateSponsorHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sponsors.UseCases.Create` · `MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/CreateSponsorHandler.cs:16` · Level 10 · class (sealed partial) - -- **What it is**: the command handler for creating a sponsor. It is the module's clearest example of the *minimal* create shape: map, add, save, log, project (`MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/CreateSponsorHandler.cs:12-15`). Compare it with `CreateSessionHandler` in this same group, which carries manual-id allocation, a room-scheduling check, and a duplicate-key retry; the difference between the two is a good measure of how much accidental complexity an application-assigned key buys. -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) closed over [`SponsorCreateRequest`](#sponsorcreaterequest) and `Result` (`:20`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:17`), [`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype) (`:18`, satisfied by [`SponsorCreateRequestMapper`](#sponsorcreaterequestmapper)), [`SponsorDTOMapper`](#sponsordtomapper) (`:19`), the [`Sponsor`](group-17-conference-domain.md#sponsor) aggregate, the [`SponsorDTO`](group-17-conference-domain.md#sponsordto) result shape, [`Result`](group-01-result-error-handling.md#result), and `Microsoft.Extensions.Logging`. -- **Concept reinforced, the handler as orchestration only**: every decision this use case makes lives somewhere else. Field-level validation ran in the pipeline before the handler was reached ([`SponsorCreateRequestValidator`](#sponsorcreaterequestvalidator)); the invariants that survive a bad caller run inside `Sponsor.Create`; the cache eviction is declared on the request; the transaction and logging decorators wrap the call ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). What remains is nine statements. `[Rubric §1, SOLID]`: a single responsibility, and every collaborator is injected as an abstraction except the DTO mapper. `[Rubric §6, CQRS and Event-Driven]`: one message in, one result out, no query concerns mixed in. -- **Walkthrough**: `HandleAsync` (`:23-40`) starts by asking the request mapper to build the entity, and returns early on failure, propagating the domain's error list into a typed failure with `Result.Failure(result.Errors)` (`:27-29`), so a validation failure from `Sponsor.Create` reaches the API as the same error shape any other failure does ([ADR-013](https://ivanball.github.io/docs/adr/013-result-pattern.html)). It then resolves the write repository from the unit of work rather than injecting `IRepository<,>` directly (`:32`), adds (`:34`), and saves once (`:35`); that single `SaveChangesAsync` is also where the audit fields are stamped and where any domain events raised by the factory are captured into the outbox. Success is logged through the source-generated `LogSponsorCreated` (`:37`, declared at `:42-43` at `Information` level, recording id and name), and the entity is projected with `dtoMapper.MapToDTO(entity)` *after* the save (`:39`), so the returned DTO carries the database-generated `Id`. `[Rubric §13, Observability and Operability]`: `[LoggerMessage]` gives compile-checked, allocation-free structured logging instead of interpolated strings. -- **Why it's built this way**: the entity is constructed through a mapper injected as an interface, so this handler never names the concrete mapper and never learns the argument order of the domain factory ([ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html)). Resolving the repository from [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) instead of constructor-injecting a repository keeps one change-tracking scope per request, which is the pattern the whole module follows ([ADR-055](https://ivanball.github.io/docs/adr/055-repository-and-specification-contract.html)). -- **Where it's used**: registered by the convention scan (`MMCA.ADC.Conference.Application/DependencyInjection.cs:112`) and injected into [`SponsorsController`](group-20-conference-api-grpc.md#sponsorscontroller) as `ICommandHandler>` (`MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:39`), behind the `SponsorsManage` permission (`:212`). -- **Caveats / not-in-source**: nothing here checks that the target event exists or that the caller may write to it beyond the controller's permission attribute; a sponsor pointing at a missing `EventId` would fail at the database, not here. - -### DeleteEventHandler - -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.Delete` · `MMCA.ADC.Conference.Application/Events/UseCases/Delete/DeleteEventHandler.cs:17` · Level 10 · class (sealed partial) - -- **What it is**: the custom delete handler for [`Event`](group-17-conference-domain.md#event). It replaces the framework's generic delete for this one entity because deleting an event has to cascade across aggregate boundaries: to sessions (BR-127) and to sponsors (`MMCA.ADC.Conference.Application/Events/UseCases/Delete/DeleteEventHandler.cs:12-16`). -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) closed over [`DeleteEntityCommand`](group-05-cqrs-pipeline.md#deleteentitycommandtentity-tidentifiertype) (`:20`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:18`), [`IEventCascadeDeletionDomainService`](group-17-conference-domain.md#ieventcascadedeletiondomainservice) (`:19`), the [`Event`](group-17-conference-domain.md#event), [`Session`](group-17-conference-domain.md#session), and [`Sponsor`](group-17-conference-domain.md#sponsor) aggregates, [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error), and logging. -- **Concept introduced, cascading a soft-delete across aggregates from the application layer**: an aggregate may cascade to the children it *owns*, and `Event.Delete()` already does that for `Rooms`, `EventSpeakers`, and `EventQuestionAnswers` (BR-72). Sessions and sponsors are separate aggregate roots that merely reference the event, so nothing inside `Event` may reach them; the transactional script that spans them belongs one layer out, which is exactly what this handler is (`:12-16`). Note also what "delete" means here: nothing is removed. The soft-delete convention sets `IsDeleted` and the EF global query filter hides the rows afterwards ([ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html)). `[Rubric §4, Domain-Driven Design]` assesses whether aggregate boundaries are respected: the handler never mutates a session or a sponsor itself, it hands both collections to a domain service that calls each aggregate's own `Delete()`. `[Rubric §8, Data Architecture]`: the cascade is expressed in code rather than as a database `ON DELETE CASCADE`, which is what keeps it valid when these tables live in different databases. -- **Walkthrough**: - - Loads the event tracked, with the three owned collections included so `Event.Delete()` can cascade to them (`:27-32`), and returns `Error.NotFound` stamped with source and target when it is missing (`:33-34`). - - Loads the event's sessions tracked, each with its own children included, filtered to `!s.IsDeleted` so an already-deleted session is not re-processed (`:37-42`). The include list matters: `Session.Delete()` cascades to `SessionSpeakers`, `SessionQuestionAnswers`, and `SessionCategoryItems` (BR-55), and it can only cascade to collections that are actually loaded. - - Loads the event's sponsors tracked with an empty include list, since a sponsor has no children (`:46-51`). The comment explains why they are in scope at all (`:44-45`): sponsors are their own aggregate rooted on the event, and leaving them behind would orphan rows the public sponsor strip still reads. - - Delegates the whole cascade to `eventCascadeDeletionDomainService.CascadeDelete(entity, sessions, sponsors)` (`:54`), and only on success saves once and logs (`:55-59`). The domain service short-circuits on the first failure and the caller saves nothing, so the aborted in-memory mutations are simply discarded (`MMCA.ADC.Conference.Domain/Services/EventCascadeDeletionDomainService.cs:19-33`). One `SaveChangesAsync` for the entire cascade is what makes it atomic. - - The `Result` from the domain service is returned unchanged (`:61`), so a business rule that blocks the delete surfaces with its own error, not a generic failure. -- **Why it's built this way**: putting the multi-aggregate rule in [`EventCascadeDeletionDomainService`](group-17-conference-domain.md#eventcascadedeletiondomainservice) rather than in the handler keeps the rule unit-testable without a database and keeps the handler a loader plus a saver. Registering a hand-written handler for this one command while every other entity keeps the generic `DeleteEntityHandler<,>` is the framework's escape hatch working as designed. -- **Where it's used**: registered explicitly, overriding the generic delete for this entity: `services.TryAddScoped, Result>, ...DeleteEventHandler>()` (`MMCA.ADC.Conference.Application/DependencyInjection.cs:56`). Compare the sponsor line four registrations later, which keeps the generic `DeleteEntityHandler` (`:77`). Invoked from the event delete endpoint on [`EventsController`](group-20-conference-api-grpc.md#eventscontroller). -- **Caveats / not-in-source**: the two `GetAllAsync` calls load whole entity graphs into memory to soft-delete them; for an event with a large schedule that is a substantial materialization, and nothing in this file bounds it. +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sponsors.UseCases.Create` · `MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/CreateSponsorHandler.cs:16` · Level 10 · class (sealed, partial) + +- **What it is**: the command handler for [`SponsorCreateRequest`](#sponsorcreaterequest). Eighteen lines of orchestration: turn the request into an entity, add it, save, log, and return the DTO (`MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/CreateSponsorHandler.cs:23-40`). +- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:17`), [`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype) closed over `Sponsor` / `SponsorCreateRequest` / `SponsorIdentifierType` (`:18`, satisfied at runtime by [`SponsorCreateRequestMapper`](#sponsorcreaterequestmapper)), the concrete [`SponsorDTOMapper`](#sponsordtomapper) (`:19`), and `ILogger` (`:20`). It implements [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) with `Result` as the result (`:20`). +- **Concept reinforced, the thin handler**: what is absent is the lesson. There is no validation call, no transaction scope, no cache eviction, and no `try`/`catch`. Validation is applied by [`ValidatingCommandDecorator`](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult) resolving [`SponsorCreateRequestValidator`](#sponsorcreaterequestvalidator); invalidation is applied by [`CachingCommandDecorator`](group-05-cqrs-pipeline.md#cachingcommanddecoratortcommand-tresult) reading `CachePrefix` off the request; the decorator ordering is taught in [Group 05](group-05-cqrs-pipeline.md). `[Rubric §1, SOLID]` assesses single responsibility: this class does persistence orchestration and nothing else. `[Rubric §10, Cross-Cutting]`: every concern that would otherwise be copy-pasted into thirty handlers lives in a decorator. +- **Walkthrough**: read it top to bottom. + - `requestMapper.CreateEntityAsync(command, cancellationToken)` (`:27`) returns a `Result`, because entity construction can fail on a domain invariant. Failure short-circuits with the mapper's own errors and never touches the repository (`:28-29`). + - `unitOfWork.GetRepository()` (`:32`) resolves the write repository **from the unit of work**, per call. Repositories are never constructor-injected in this codebase; asking the unit of work is what keeps the repository and the change tracker on the same scope. + - `AddAsync` then `SaveChangesAsync` (`:34-35`), both with `ConfigureAwait(false)`. The save is where audit stamping, the soft-delete convention, and outbox persistence happen ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html) covers the outbox side). + - `LogSponsorCreated(logger, entity.Id, entity.Name)` (`:37`) is a source-generated log method declared at `:42-43` with `[LoggerMessage(Level = LogLevel.Information, ...)]`. That is why the class is `partial`: the generator supplies the body, and the template's `{SponsorId}` and `{Name}` become structured fields rather than a formatted string. `[Rubric §13, Observability and Operability]` assesses whether logs are queryable: the created id is a field, not text inside a message. + - `Result.Success(dtoMapper.MapToDTO(entity))` (`:39`) maps the just-saved entity, so the caller receives the store-assigned identity in the response body. +- **Why it's built this way**: the request mapper is injected as the **interface** while the DTO mapper is injected as the **concrete class** (`:18-19`). That asymmetry is a DI fact, not a style choice: Scrutor registers request mappers and DTO mappers `AsSelfWithInterfaces` (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:157-176`), so either shape resolves, and taking the interface for the request mapper keeps the create slice swappable (an async uniqueness check would be a new implementation, not a handler edit). +- **Where it's used**: registered by the `ICommandHandler<,>` assembly scan (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:178-182`, invoked from `MMCA.ADC.Conference.Application/DependencyInjection.cs:125`), injected into [`SponsorsController`](group-20-conference-api-grpc.md#sponsorscontroller) (`MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:39`), and reached through the base `CreateAsync` that the controller's capability-gated override wraps (`:211-220`). Tests cover the success path, the mapper-failure short circuit, and the repository and save calls (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sponsors/UseCases/CreateSponsorHandlerTests.cs:61-133`). +- **Caveats / not-in-source**: nothing in the Application layer proves that `EventId` names a real event. [`SponsorCreateRequestValidator`](#sponsorcreaterequestvalidator) only requires it to be non-zero (`MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:101-103`), the mapper performs no lookup, and `Sponsor.Create` validates only name, logo URL, and booth number (`MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:119-122`). The guarantee comes one layer down: the EF configuration declares a required FK to `Event` (`MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SponsorConfiguration.cs:62-65`), so a bogus id fails at `SaveChangesAsync` as a database error rather than as a validation `Result`. ### EventLiveValidationService > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events` · `MMCA.ADC.Conference.Application/Events/EventLiveValidationService.cs:22` · Level 10 · class (sealed) -- **What it is**: the Conference-side implementation of [`IEventLiveValidationService`](group-17-conference-domain.md#ieventlivevalidationservice), the narrow contract the Engagement module's conference-day live layer calls to learn whether an event, session, sponsor, or room is live and who may act on it (`MMCA.ADC.Conference.Application/Events/EventLiveValidationService.cs:11-21`). -- **Depends on**: [`IEventLiveValidationService`](group-17-conference-domain.md#ieventlivevalidationservice) (the interface lives in `Conference.Shared`, so consumers need no reference to this assembly), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) and the injected `TimeProvider` (`:22`), the [`Event`](group-17-conference-domain.md#event), [`Session`](group-17-conference-domain.md#session), and [`Sponsor`](group-17-conference-domain.md#sponsor) aggregates, [`SessionInvariants`](group-17-conference-domain.md#sessioninvariants), [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector), [`CalendarExportMapper`](#calendarexportmapper), the [`EventLiveInfo`](group-17-conference-domain.md#eventliveinfo) / [`SessionLiveInfo`](group-17-conference-domain.md#sessionliveinfo) / [`SponsorLiveInfo`](group-17-conference-domain.md#sponsorliveinfo) / [`RoomSessionInfo`](group-17-conference-domain.md#roomsessioninfo) payloads, and [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error). -- **Concept introduced, a cross-module read contract implemented in the owning module**: Engagement owns the live experience but not the source of truth for events, sessions, sponsors, and rooms, so it asks Conference through this one interface. In process (tests, single-host runs) the container binds it here, `services.TryAddScoped()` (`MMCA.ADC.Conference.Application/DependencyInjection.cs:108`); across processes the same interface is satisfied by [`EventLiveValidationServiceGrpcAdapter`](group-20-conference-api-grpc.md#eventlivevalidationservicegrpcadapter) in front of [`EventLiveValidationGrpcService`](group-20-conference-api-grpc.md#eventlivevalidationgrpcservice), and Engagement's code does not change ([ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html)). When the Conference module is disabled entirely, the module registration substitutes [`DisabledEventLiveValidationService`](group-17-conference-domain.md#disabledeventlivevalidationservice) (`MMCA.ADC.Conference.API/ConferenceModule.cs:24`). `[Rubric §7, Microservices Readiness]` assesses whether cross-module dependencies flow through interfaces a process boundary can later intercept; this type is the reason a check-in works identically in the modular monolith and in the four-service deployment. `[Rubric §3, Clean Architecture]`: everything runs over repository abstractions, with no EF or transport type in sight. -- **Walkthrough**: four public lookups and two private helpers. - - `GetEventLiveInfoAsync` (`:25-45`) loads the event untracked with an empty include list (`:30-34`), returns a decorated `Error.NotFound` when it is missing (`:36-40`), and otherwise returns an [`EventLiveInfo`](group-17-conference-domain.md#eventliveinfo) carrying the published flag plus the computed UTC window (`:42-44`). - - `GetSessionLiveInfoAsync` (`:48-101`) loads the session with `includes: [nameof(Session.SessionSpeakers)]` (`:53-57`), because the caller needs the speaker list to decide presenter rights (BR-236). It then enforces the bookmark eligibility rules by calling the domain's own invariants instead of restating them: `SessionInvariants.EnsureNotServiceSession` (BR-91, `:67`) and `SessionInvariants.EnsureStatusIsEligible` (BR-49, `:71`), converting either failure into a typed failure with the domain's error list intact (`:68-73`). The parent event is loaded next with the same not-found treatment (`:75-86`), the window is computed (`:88`), and the active speaker ids are projected with soft-deleted joins filtered out (`:90-91`). The returned [`SessionLiveInfo`](group-17-conference-domain.md#sessionliveinfo) (`:93-100`) carries the event id, the published flag, the window, the speaker ids, `IsPlenumSession`, and the event's `QuestionModerationDefault` (BR-233), so one round trip answers every question the live layer has. - - `GetSponsorLiveInfoAsync` (`:104-140`) answers the booth-visit lookup with the owning event and the sponsor's display name (`:136-139`). The comment on the read is the security-relevant part (`:108-109`): the repository applies the soft-delete query filter, so a removed sponsor answers exactly like one that never existed, which means a printed QR code for a pulled sponsor stops working with no extra check anywhere. - - `GetCurrentRoomSessionInfoAsync` (`:143-219`) resolves which session a room is hosting right now, so a check-in never has to trust a client-supplied session id. It loads the room's sessions untracked (`:148-153`), drops any without a schedule (`:157-159`), and treats an empty set the same as an unknown room: `NotFound` (`:161-165`). It resolves the event and its IANA zone (`:167-180`), reads a single `utcNow` from the injected `TimeProvider` (`:181`), and clamps the caller's grace to a non-negative `TimeSpan` (`:182`). Wall-clock session times are converted with [`CalendarExportMapper`](#calendarexportmapper)`.ToUtc` (`:192-193`) rather than compared raw, and the comment says why (`:184-186`): session times are event-zone wall clock, so comparing them against a UTC instant directly would be wrong by the zone offset for the entire conference. Selection then has a deliberate priority (`:197-206`): an in-progress session (`StartsAtUtc <= now < EndsAtUtc`) always wins, and only if none is running does the upcoming-within-grace branch apply, because back-to-back sessions overlap inside the grace window and the attendee scanning the room QR is standing in the one that is actually running. - - `ResolveTimeZone` (`:223-237`) degrades an unrecognized or invalid zone id to `TimeZoneInfo.Utc` rather than failing the lookup, matching what the now-next snapshot does. `ComputeLiveWindowUtc` (`:242-246`) is a pure delegation to [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector)`.GetLiveWindowUtc`, and the comment above it states the rule (`:239-241`): the window (midnight to midnight in the event zone), the unknown-zone degradation, and the spring-forward-gap guard must stay identical to the ones the home surfaces and the now-next snapshot use. -- **Why it's built this way**: centralizing "is this live, and who may act on it" behind one interface, and reusing the domain invariants and the shared window math instead of copying them, gives both modules one authoritative answer. Injecting `TimeProvider` rather than reading `DateTime.UtcNow` makes the room lookup testable at an exact instant. `[Rubric §14, Testability]`: a fake clock can place "now" precisely on a session boundary or inside the grace window. -- **Where it's used**: by Engagement's live layer and check-in paths, all through the interface: [`CheckInProcessor`](group-22-engagement-module.md#checkinprocessor) (`MMCA.ADC.Engagement.Application/CheckIns/Services/CheckInProcessor.cs:104`, `:114`), [`RecordSponsorVisitHandler`](group-22-engagement-module.md#recordsponsorvisithandler) (`MMCA.ADC.Engagement.Application/CheckIns/UseCases/RecordSponsorVisit/RecordSponsorVisitHandler.cs:58`), [`RecordRoomCheckInHandler`](group-22-engagement-module.md#recordroomcheckinhandler) (`MMCA.ADC.Engagement.Application/CheckIns/UseCases/RecordRoomCheckIn/RecordRoomCheckInHandler.cs:52`), [`SubmitQuestionHandler`](group-23-engagement-live-layer.md#submitquestionhandler) (`MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/Submit/SubmitQuestionHandler.cs:37`), and [`OpenLivePollHandler`](group-23-engagement-live-layer.md#openlivepollhandler) (`MMCA.ADC.Engagement.Application/LivePolls/UseCases/Open/OpenLivePollHandler.cs:53`, `:73`), among the other live-poll and moderation handlers. -- **Caveats / not-in-source**: `GetCurrentRoomSessionInfoAsync` loads every session assigned to the room, filters and converts them in memory, and only then narrows to the resolved event (`:187-195`); nothing bounds that set by date. The room-to-event resolution also takes the first scheduled session's `EventId` (`:169`), so a room reused across two events resolves against whichever row comes back first. +- **What it is**: Conference's answer to four questions the Engagement module's conference-day features have to ask before they will record anything: is this event published and when is it live, is this session eligible for the live layer and who speaks at it, does this sponsor exist and which event owns it, and which session is this room hosting right now. It is the implementation behind the cross-module contract [`IEventLiveValidationService`](group-17-conference-domain.md#ieventlivevalidationservice) (`MMCA.ADC.Conference.Application/Events/EventLiveValidationService.cs:22`). +- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) and the BCL `TimeProvider` (`:22`), the [`Event`](group-17-conference-domain.md#event), [`Session`](group-17-conference-domain.md#session), and [`Sponsor`](group-17-conference-domain.md#sponsor) entities, [`SessionInvariants`](group-17-conference-domain.md#sessioninvariants) for the two eligibility rules (`:67`, `:71`), [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector) for the live-window math (`:243`), [`CalendarExportMapper`](#calendarexportmapper) for wall-clock to UTC conversion (`:192-193`), and the four result records [`EventLiveInfo`](group-17-conference-domain.md#eventliveinfo), [`SessionLiveInfo`](group-17-conference-domain.md#sessionliveinfo), [`SponsorLiveInfo`](group-17-conference-domain.md#sponsorliveinfo), and [`RoomSessionInfo`](group-17-conference-domain.md#roomsessioninfo). +- **Concept introduced, the cross-module read contract**: Engagement needs facts about conference data but must not reference Conference's entities, or the two modules could never be deployed apart. The pattern that solves it has three parts. The **interface and its four record types live in `MMCA.ADC.Conference.Shared`** (`MMCA.ADC.Conference.Shared/Events/IEventLiveValidationService.cs:11`), a project both sides may reference. The **implementation lives here**, in Conference.Application, where the entities are. And the **binding is swappable**: in the modular monolith this class is registered (`MMCA.ADC.Conference.Application/DependencyInjection.cs:121`); in a split topology the Contracts package replaces it with a gRPC adapter that implements the same interface over the wire (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/EventLiveValidationServiceGrpcAdapter.cs:25`, swapped in at `MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/DependencyInjection.cs:79`); and in a host that does not load Conference at all, the module registers a disabled stub instead (`MMCA.ADC.Conference.API/ConferenceModule.cs:21-25`). Engagement's handlers see one interface in all three worlds. `[Rubric §7, Microservices Readiness]` assesses whether a module can be extracted without a rewrite: this is the extraction contract itself ([ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html), [ADR-008](https://ivanball.github.io/docs/adr/008-service-extraction-topology.html)). `[Rubric §9, API and Contract Design]`: the DTO-like records carry only scalars and id lists, which is what keeps them serializable over gRPC unchanged. +- **Walkthrough**: four public methods and two private helpers. + - `GetEventLiveInfoAsync` (`:25-45`) loads the event by id with no includes, returns `Error.NotFound` tagged with source and target when it is missing (`:36-40`), computes the window, and returns the published flag plus both boundaries (`:44`). + - `GetSessionLiveInfoAsync` (`:48-101`) loads the session **with** its `SessionSpeakers` (`:55`), then applies the two bookmark-eligibility rules before anything else: `EnsureNotServiceSession` (BR-91, `:67-69`) and `EnsureStatusIsEligible` (BR-49, `:71-73`), both borrowed from the domain's own invariant helper so the live layer cannot drift from the bookmark rules. Only then does it fetch the owning event (`:75-86`), project the non-deleted speaker ids (`:90-91`), and return them with the plenum flag and the event's question-moderation default (`:93-100`). + - `GetSponsorLiveInfoAsync` (`:104-140`) answers the printed-QR booth-visit lookup. The comment at `:108-109` is the design note worth keeping: because the read repository applies the soft-delete filter, a pulled sponsor answers exactly like one that never existed, so a printed QR for a dropped sponsor simply stops working. + - `GetCurrentRoomSessionInfoAsync` (`:143-219`) is the one with real logic. It loads every session in the room (`:148-153`), drops the ones with no schedule (`:157-159`), resolves the owning event from the first survivor (`:167-172`), converts each session's wall-clock start and end into UTC through [`CalendarExportMapper`](#calendarexportmapper)`.ToUtc` (`:187-195`), and then picks: an in-progress session (`StartsAtUtc <= now < EndsAtUtc`) wins, and only if there is none does it accept the earliest session starting inside the grace window (`:199-206`). The comment at `:197-198` explains why the order matters: back-to-back sessions overlap inside the grace window, and the attendee scanning the room QR is standing in the one that is actually running. + - `ResolveTimeZone` (`:223-237`) degrades an unrecognized or invalid IANA id to UTC instead of failing the lookup, catching both `TimeZoneNotFoundException` and `InvalidTimeZoneException`. + - `ComputeLiveWindowUtc` (`:242-246`) delegates to [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector)`.GetLiveWindowUtc` (`MMCA.ADC.Conference.Shared/Events/CurrentEventSelector.cs:64-83`) rather than repeating the rule. That shared helper defines the window as start date at 00:00 local through end date plus one day at 00:00 local (exclusive), degrades an unknown zone the same way, and handles the spring-forward gap where local midnight never existed. The comment at `:239-241` states the reason plainly: the home-page countdown, the now-next snapshot, and this service must agree on when an event is live, or two surfaces disagree in front of an audience. +- **Why it's built this way**: `TimeProvider` is injected rather than `DateTime.UtcNow` being called, which is what makes the room-resolution rules testable at all: the suite drives them with a `FixedTimeProvider` (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/EventLiveValidationServiceTests.cs:388`). `[Rubric §14, Testability]` assesses whether behavior can be exercised deterministically: the suite exercises all four methods, including back-to-back sessions, both grace-window boundaries, unknown rooms, unrecognized time zones, and unpublished events (`:42-386`). The grace window is a parameter, not a Conference setting, because it is check-in policy and Conference only answers the schedule question (`MMCA.ADC.Conference.Shared/Events/IEventLiveValidationService.cs:50-53`). `[Rubric §29, Resilience]`: the time-zone fallbacks mean one bad legacy row degrades a single event's window rather than throwing through every live endpoint. +- **Where it's used**: registered as `services.TryAddScoped()` (`MMCA.ADC.Conference.Application/DependencyInjection.cs:121`) and consumed exclusively by Engagement: the live-poll lifecycle handlers, the session-question submit and moderation handlers, and the check-in flows including [`CheckInProcessor`](group-22-engagement-module.md#checkinprocessor) (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/Services/CheckInProcessor.cs:34`, `:96`), [`RecordRoomCheckInHandler`](group-22-engagement-module.md#recordroomcheckinhandler) (`.../CheckIns/UseCases/RecordRoomCheckIn/RecordRoomCheckInHandler.cs:26`), [`RecordSponsorVisitHandler`](group-22-engagement-module.md#recordsponsorvisithandler) (`.../CheckIns/UseCases/RecordSponsorVisit/RecordSponsorVisitHandler.cs:34`), [`SubmitQuestionHandler`](group-23-engagement-live-layer.md#submitquestionhandler) (`.../SessionQuestions/UseCases/Submit/SubmitQuestionHandler.cs:27`), and [`OpenLivePollHandler`](group-23-engagement-live-layer.md#openlivepollhandler) (`.../LivePolls/UseCases/Open/OpenLivePollHandler.cs:22`). In the split topology it is exposed over gRPC by [`EventLiveValidationGrpcService`](group-20-conference-api-grpc.md#eventlivevalidationgrpcservice) (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Grpc/EventLiveValidationGrpcService.cs:22`). +- **Caveats / not-in-source**: two edges. + - The first two methods resolve the write-capable repository through `unitOfWork.GetRepository<...>()` (`:29`, `:52`, `:75`) while the sponsor and room methods use `GetReadRepository<...>()` (`:110`, `:123`, `:148`, `:167`). Every call passes `asTracking: false`, so the reads are untracked either way; the inconsistency is in which repository is asked for, not in what the query does. + - `GetCurrentRoomSessionInfoAsync` loads **all** sessions for the room and filters in memory (`:149-159`, `:187-195`), because the wall-clock to UTC conversion is compiled code that cannot be translated to SQL. Room-sized session counts make that fine today; nothing in the code bounds it. ### EventNavigationPopulator > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events` · `MMCA.ADC.Conference.Application/Events/EventNavigationPopulator.cs:11` · Level 10 · class (sealed) -- **What it is**: the declarative navigation populator for the [`Event`](group-17-conference-domain.md#event) aggregate. It loads the three child collections the read path cannot materialize through `.Include()` on this model (`MMCA.ADC.Conference.Application/Events/EventNavigationPopulator.cs:7-9`). -- **Depends on**: [`DeclarativeNavigationPopulator`](group-11-navigation-populators.md#declarativenavigationpopulatortentity), [`ChildNavigationDescriptor`](group-11-navigation-populators.md#childnavigationdescriptortentity-tparentid-tchild-tchildid), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), and the [`Event`](group-17-conference-domain.md#event), [`Room`](group-17-conference-domain.md#room), [`EventSpeaker`](group-17-conference-domain.md#eventspeaker), and [`EventQuestionAnswer`](group-17-conference-domain.md#eventquestionanswer) entities. -- **Concept reinforced**: the same declarative loading taught on [`ConferenceCategoryNavigationPopulator`](#conferencecategorynavigationpopulator) and in [Group 11](group-11-navigation-populators.md#declarativenavigationpopulatortentity); this class simply carries three descriptors instead of one, and its body is likewise empty (`:37-38`). `[Rubric §2, Design Patterns]`: adding a child collection is one descriptor, not a new query method. -- **Walkthrough**: three descriptors, all keyed on `Event.Id` against the child's `EventId`. `Rooms` assigned through `SetRooms` (`:15-21`), `EventSpeakers` through `SetEventSpeakers` (`:22-28`), and `EventQuestionAnswers` through `SetEventQuestionAnswers` (`:29-35`). The `AssignAction` targets are worth a look: all three `Set*` methods are `internal` on the aggregate (`MMCA.ADC.Conference.Domain/Events/Event.cs:500`, `:596`, `:673`), reachable from here only because the Domain project grants `` (`MMCA.ADC.Conference.Domain/MMCA.ADC.Conference.Domain.csproj:3`). Hydration therefore goes through the domain's own guarded mutators while staying closed to every other assembly. -- **Why it's built this way**: an aggregate that exposed public collection setters would be mutable by anyone; an aggregate with no setters at all could not be hydrated. The `internal` plus `InternalsVisibleTo` pairing grants the populator exactly the access it needs and nobody else any ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html)). -- **Where it's used**: registered as `INavigationPopulator` (`MMCA.ADC.Conference.Application/DependencyInjection.cs:54`) and resolved by the read pipeline whenever an [`Event`](group-17-conference-domain.md#event) is loaded with these navigations requested. [`DeleteEventHandler`](#deleteeventhandler) bypasses it by naming the same three collections in an explicit `includes` list, because it needs them tracked. +- **What it is**: the navigation populator for the [`Event`](group-17-conference-domain.md#event) aggregate, the largest one in the module: three child-collection descriptors for `Rooms`, `EventSpeakers`, and `EventQuestionAnswers` (`MMCA.ADC.Conference.Application/Events/EventNavigationPopulator.cs:14-36`). The body is empty (`:37-38`). +- **Depends on**: [`DeclarativeNavigationPopulator`](group-11-navigation-populators.md#declarativenavigationpopulatortentity) over `Event` (`:13`), three [`ChildNavigationDescriptor`](group-11-navigation-populators.md#childnavigationdescriptortentity-tparentid-tchild-tchildid) instances (`:15`, `:22`, `:29`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:12`), and the [`Event`](group-17-conference-domain.md#event), [`Room`](group-17-conference-domain.md#room), [`EventSpeaker`](group-17-conference-domain.md#eventspeaker), and [`EventQuestionAnswer`](group-17-conference-domain.md#eventquestionanswer) entities. +- **Concept reinforced**: child-collection binding is taught on [`ConferenceCategoryNavigationPopulator`](#conferencecategorynavigationpopulator). What this class adds is scale (three descriptors evaluated in declaration order, `DeclarativeNavigationPopulator.cs:34-41`) and one genuinely instructive mismatch, in the caveat below. +- **Walkthrough**: all three descriptors key on `Event.Id` against the child's `EventId`, and each supplies the same four settings. -### PublicConferenceVisibility + | Descriptor | File:Line | Property, parent key, child FK, assign | + |------------|-----------|----------------------------------------| + | `ChildNavigationDescriptor` | `MMCA.ADC.Conference.Application/Events/EventNavigationPopulator.cs:15-21` | `nameof(Event.Rooms)`, `e => e.Id`, `child => child.EventId`, `SetRooms` | + | `ChildNavigationDescriptor` | `MMCA.ADC.Conference.Application/Events/EventNavigationPopulator.cs:22-28` | `nameof(Event.EventSpeakers)`, `e => e.Id`, `child => child.EventId`, `SetEventSpeakers` | + | `ChildNavigationDescriptor` | `MMCA.ADC.Conference.Application/Events/EventNavigationPopulator.cs:29-35` | `nameof(Event.EventQuestionAnswers)`, `e => e.Id`, `child => child.EventId`, `SetEventQuestionAnswers` | -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Common` · `MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:28` · Level 10 · class (static) + - All three assign through `internal` aggregate mutators, `SetRooms` (`MMCA.ADC.Conference.Domain/Events/Event.cs:529`), `SetEventSpeakers` (`:625`), and `SetEventQuestionAnswers` (`:702`), reachable only because the Domain project grants `` (`MMCA.ADC.Conference.Domain/MMCA.ADC.Conference.Domain.csproj:3`). The collections themselves are `IReadOnlyCollection<>` over private lists (`Event.cs:89`, `:95`, `:109`). + - Each descriptor's load is one batched `WHERE EventId IN (...)` query (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/ChildNavigationDescriptor.cs:41`), so a fully hydrated page of events costs three extra queries, not three per event. + - All three are gated on `includeChildren`, because `ChildNavigationDescriptor.RequiresChildren` is `true` (`ChildNavigationDescriptor.cs:25`). +- **Why it's built this way**: the entity type parameters are what make this survivable under extraction. `Room` and `EventSpeaker` may end up in a different physical source than `Event`, at which point `.Include()` stops being an option and only a batched key lookup can hydrate them ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html), [ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)). `[Rubric §8, Data Architecture]` assesses how relationships are expressed: a cross-source parent-child link degrades to a scalar FK plus an `IN` lookup, and that is precisely what these three declarations are. +- **Where it's used**: registered as `services.TryAddScoped, EventNavigationPopulator>()` (`MMCA.ADC.Conference.Application/DependencyInjection.cs:58`), beside the closed-generic read service (`:59`) and the module's own delete handler (`:60`). Tests: `MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/EventNavigationPopulatorTests.cs:15-43`. +- **Caveats / not-in-source**: the third descriptor cannot fire through the populator path as the code stands. `Event.EventQuestionAnswers` is deliberately **not** marked `[Navigation]` (`MMCA.ADC.Conference.Domain/Events/Event.cs:100-109` documents why: the collection grows with attendance rather than with the schedule, and it rode along on public reads that never render it). Navigation discovery is attribute-driven (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/NavigationMetadataProvider.cs:74-78`), so the property never appears in `UnsupportedIncludes`, and the base's `unsupportedPropertyNames.Contains(descriptor.PropertyName)` test never matches it (`DeclarativeNavigationPopulator.cs:37`). Handlers that need those answers pass an explicit `includes:` list instead, which is exactly what the entity's own remark instructs. The descriptor is harmless and would become live again the moment the attribute returned. -- **What it is**: the one definition of what an anonymous or non-privileged caller may read from the conference catalog, expressed as id lists that callers turn into `Id IN (...)` specifications. Closing a visibility leak in this file closes it on every public read at once (`MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:10-15`). -- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (read repositories only), the [`Event`](group-17-conference-domain.md#event), [`Session`](group-17-conference-domain.md#session), and [`SessionSpeaker`](group-17-conference-domain.md#sessionspeaker) entities, [`PublicSessionStatusSpecification`](#publicsessionstatusspecification) (the BR-49 status allow-list), [`CrossSourceSpecification`](group-03-querying-specifications.md#crosssourcespecification), [`AndSpecification`](group-03-querying-specifications.md#andspecificationtentity-tidentifiertype), and [`InlineSpecification`](group-03-querying-specifications.md#inlinespecificationtentity-tidentifiertype). -- **Concept introduced, authorization by id list instead of authorization by join**: the three rules are stated in the remarks (`:17-21`): an event is visible when published (BR-108); a session is visible when its event is visible AND its status is on the BR-49 allow-list; a speaker is visible when they have at least one eligible session inside the scoped published-event set (BR-239). Every rule is resolved as a **scalar id projection**, never as a navigation join such as `s.Event.IsPublished` (`:22-26`). Three things follow: the criteria stay translatable on any engine (the polyglot safeguard of [ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html)), each aggregate keeps a by-id boundary to the others so a future extraction can answer the same question over a service call ([ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html)), and the resulting specifications pass the architecture fitness test that bans navigation-property filters. `[Rubric §11, Security]` assesses whether authorization is centralized and fail-closed: this class is a single choke point, and every "nothing visible" path returns an empty list, which callers render as an `IN ()` matching no rows rather than as an unfiltered read. `[Rubric §7, Microservices Readiness]` and `[Rubric §8, Data Architecture]`: id lists cross an aggregate boundary; joins do not. -- **Walkthrough**: three public resolvers plus one private helper, and the ordering between them is the rule hierarchy. - - `GetPublishedEventIdsAsync` (`:36-47`) projects `e.Id` where `e.IsPublished`, untracked (`:42`), then materializes once so callers embed a stable collection EF can translate into an `IN` (`:45-46`). That materialize-once comment is load-bearing: re-enumerating a lazy sequence inside an expression tree is what makes such a filter fail to translate. - - `GetVisibleSessionIdsAsync` (`:56-77`) builds the event scoping through [`CrossSourceSpecification`](group-03-querying-specifications.md#crosssourcespecification)`.BuildAsync`, passing `principalPredicate: e => e.IsPublished`, `dependentForeignKey: s => s.EventId`, and `localPredicate: PublicSessionStatusSpecification.StatusCriteria` (`:62-69`), then projects the matching session ids (`:71-74`). It uses the *same* helper and the *same* criteria the public session read filter uses, so a session hidden from the session list can never stay reachable through a speaker or junction read (`:60-61`). - - `GetVisibleSpeakerIdsAsync` (`:99-127`) takes an optional `eventId` scope. With a scope, the published-event set is narrowed to that one id, and an unpublished or unknown scoped event narrows it to empty rather than raising an error (`:108-112`), which is the fail-closed reading of BR-108. An empty scope, or an empty eligible-session set, short-circuits to `[]` (`:114-119`). Otherwise it projects the distinct `SpeakerId` values off the `SessionSpeaker` join for those sessions (`:121-126`). The remarks record a real leak this shape fixed (`:92-98`): the `EventSpeaker` join is deliberately NOT consulted, because the Sessionize import writes a row there for every speaker in the response, so reading it as a visibility grant published the whole imported roster and made the filter vacuous. The session link is the only acceptance signal a speaker carries. - - `GetEligibleSessionIdsAsync` (`:134-151`) is the private variant the speaker rule uses when it needs to narrow an *already resolved* event scope. It ANDs a `PublicSessionStatusSpecification` instance with an `InlineSpecification` over the scoped id list (`:141-143`), keeping the criteria a translatable `IN` filter with no navigation join, and reuses the one status allow-list rather than restating it. -- **Why it's built this way**: a single shared definition is the only way six independent read handlers can agree on what "public" means. The two entry points into [`PublicSessionStatusSpecification`](#publicsessionstatusspecification) (the raw `StatusCriteria` expression for composition, and an instance for specification algebra) exist precisely so this class can use whichever form each call site needs without a second copy of the predicate ([ADR-055](https://ivanball.github.io/docs/adr/055-repository-and-specification-contract.html)). -- **Where it's used**: six public-read filter handlers call it, all under `MMCA.ADC.Conference.Application/`: [`GetPublicSessionSpeakerFilterHandler`](#getpublicsessionspeakerfilterhandler) (`Sessions/UseCases/GetPublicSessionSpeakerFilter/GetPublicSessionSpeakerFilterHandler.cs:24`), [`GetPublicSessionCategoryItemFilterHandler`](#getpublicsessioncategoryitemfilterhandler) (`Sessions/UseCases/GetPublicSessionCategoryItemFilter/GetPublicSessionCategoryItemFilterHandler.cs:25`), [`GetPublicSpeakerFilterHandler`](#getpublicspeakerfilterhandler) (`Speakers/UseCases/GetPublicSpeakerFilter/GetPublicSpeakerFilterHandler.cs:26`), [`GetPublicSpeakerCategoryItemFilterHandler`](#getpublicspeakercategoryitemfilterhandler) (`Speakers/UseCases/GetPublicSpeakerCategoryItemFilter/GetPublicSpeakerCategoryItemFilterHandler.cs:26`), [`GetPublicSponsorFilterHandler`](#getpublicsponsorfilterhandler) (`Sponsors/UseCases/GetPublicSponsorFilter/GetPublicSponsorFilterHandler.cs:25`, which needs only the published-event rule and turns it into a `Sponsor.EventId IN (...)` filter), and [`GetPublicEventSpeakerFilterHandler`](#getpubliceventspeakerfilterhandler), which calls two of the three resolvers (`Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterHandler.cs:31`, `:38`). -- **Caveats / not-in-source**: the class is `static` and takes its `IUnitOfWork` per call, so it is not injectable and cannot be substituted in a test; callers are tested against it directly. Cost is the other thing the source does not hide: the speaker rule issues up to three sequential round trips (events, sessions, join rows), which is the price of avoiding a cross-aggregate join. +### EventQuestionAnswerNavigationPopulator -### RemoveSessionQuestionAnswerHandler +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events` · `MMCA.ADC.Conference.Application/Events/EventQuestionAnswerNavigationPopulator.cs:11` · Level 10 · class (sealed) -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionQuestionAnswer` · `MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionQuestionAnswer/RemoveSessionQuestionAnswerHandler.cs:14` · Level 10 · class (sealed partial) +- **What it is**: the FK populator for [`EventQuestionAnswer`](group-17-conference-domain.md#eventquestionanswer), hydrating each answer's parent [`Event`](group-17-conference-domain.md#event) reference. +- **Depends on**: [`DeclarativeNavigationPopulator`](group-11-navigation-populators.md#declarativenavigationpopulatortentity) over `EventQuestionAnswer` (`:13`), one [`FKNavigationDescriptor`](group-11-navigation-populators.md#fknavigationdescriptortentity-tchild-tchildid) closed over `EventQuestionAnswer` / `Event` / `EventIdentifierType` (`:15`), and [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:12`). +- **Concept reinforced**: mechanically identical to [`ActivityNavigationPopulator`](#activitynavigationpopulator), which teaches the FK descriptor, the `includeFKs` gate, and the batched loader. +- **Walkthrough**: `PropertyName = nameof(EventQuestionAnswer.Event)` (`:17`), `ParentKeySelector = e => e.EventId` (`:18`, the get-only FK at `MMCA.ADC.Conference.Domain/Events/EventQuestionAnswer.cs:26`), `ChildForeignKeySelector = child => child.Id` (`:19`), and `AssignAction = (e, events) => e.Event = events.FirstOrDefault()` (`:20`) writing the settable navigation (`EventQuestionAnswer.cs:22-23`). +- **Why it's built this way**: note the asymmetry with the parent side. `Event.EventQuestionAnswers` carries no `[Navigation]` attribute, so the forward collection is not auto-hydrated ([see the caveat on `EventNavigationPopulator`](#eventnavigationpopulator)), while `EventQuestionAnswer.Event` **is** attributed, so a read that starts at the answer can still reach its event. The direction that is cheap and bounded is enabled; the direction that is unbounded is not. `[Rubric §12, Performance and Scalability]` is the reason the two directions are configured differently. +- **Where it's used**: registered as `INavigationPopulator` (`MMCA.ADC.Conference.Application/DependencyInjection.cs:98`) alongside the closed-generic read service (`:99`). Tests: `MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/EventQuestionAnswerNavigationPopulatorTests.cs:15-43`. -- **What it is**: the handler for [`RemoveSessionQuestionAnswerCommand`](#removesessionquestionanswercommand). It is the load-then-mutate-through-the-aggregate shape with a per-record ownership guard in front of it (`MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionQuestionAnswer/RemoveSessionQuestionAnswerHandler.cs:10-13`). -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) and [`RoleNames`](group-08-auth.md#rolenames) (`:14-17`), the [`Session`](group-17-conference-domain.md#session) aggregate and its [`SessionQuestionAnswer`](group-17-conference-domain.md#sessionquestionanswer) child, [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error), and logging. -- **Concept introduced, per-record ownership authorization inside the command slice (BR-52 / BR-53)**: role-based attributes on a controller can say "an attendee may call this endpoint", but they cannot say "an attendee may delete *this* row". That second decision needs the record, so it lives here. After loading the session with its answers tracked (`:24-29`), the handler finds the target among the non-soft-deleted answers and, if the caller is not an `Organizer` and did not create it, returns `Error.Forbidden` with code `SessionQuestionAnswer.NotOwner` (`:34-42`). The ownership fact comes from `answer.CreatedBy`, the audit field stamped by the persistence layer, compared against `currentUserService.UserId`; the client never supplies it. `[Rubric §11, Security]` assesses whether authorization is enforced at the right granularity and fails closed: this is record-level, server-derived, and organizer-exempt by an explicit role check. `[Rubric §6, CQRS and Event-Driven]`: the decision lives in the slice that owns the operation rather than scattered across controller attributes. -- **Walkthrough**: `HandleAsync` (`:20-52`) resolves the write repository (`:24`), loads with `asTracking: true` (`:28`, which is not incidental: the aggregate mutation has to be observed by the change tracker for the save to emit anything), and returns `Error.NotFound` stamped with handler and `Session` when absent (`:30-31`). The guard is written so that a *missing* answer fails `answer is not null` (`:35`) and falls straight through to the domain call, which is what produces the not-found style failure instead of a misleading 403. Removal itself is delegated to `entity.RemoveSessionQuestionAnswer(...)` (`:44`) so the aggregate enforces its own invariants, and only on success does it save and log through the generated `LogQuestionAnswerRemovedFromSession` (`:45-49`, declared `:54-55`). The domain `Result` is returned unchanged either way (`:51`). -- **Why it's built this way**: keeping removal logic in the aggregate and cache eviction on the command leaves the handler as pure orchestration: load, authorize, delegate, save, log. `[Rubric §13, Observability and Operability]`: logging goes through a source-generated `[LoggerMessage]` partial, the compile-checked, allocation-free pattern used on every handler in this module. -- **Where it's used**: registered by the convention scan (`MMCA.ADC.Conference.Application/DependencyInjection.cs:112`), injected into [`SessionQuestionAnswersController`](group-20-conference-api-grpc.md#sessionquestionanswerscontroller) (`MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:60`) and invoked from its delete endpoint (`:217`). -- **Caveats / not-in-source**: the guard dereferences `currentUserService.UserId!.Value` (`:35`), so an unauthenticated caller reaching this handler would throw rather than be rejected; it is only safe because the endpoint sits behind authentication. +### EventSpeakerNavigationPopulator -### RemoveSessionSpeakerHandler +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events` · `MMCA.ADC.Conference.Application/Events/EventSpeakerNavigationPopulator.cs:11` · Level 10 · class (sealed) -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionSpeaker` · `MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionSpeaker/RemoveSessionSpeakerHandler.cs:13` · Level 10 · class (sealed partial) +- **What it is**: the FK populator for the [`EventSpeaker`](group-17-conference-domain.md#eventspeaker) junction, hydrating its parent [`Event`](group-17-conference-domain.md#event) reference. +- **Depends on**: [`DeclarativeNavigationPopulator`](group-11-navigation-populators.md#declarativenavigationpopulatortentity) over `EventSpeaker` (`:13`), one [`FKNavigationDescriptor`](group-11-navigation-populators.md#fknavigationdescriptortentity-tchild-tchildid) closed over `EventSpeaker` / `Event` / `EventIdentifierType` (`:15`), and [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:12`). +- **Concept reinforced**: the FK mechanism is taught on [`ActivityNavigationPopulator`](#activitynavigationpopulator); the four settings here are `nameof(EventSpeaker.Event)` (`:17`), `e => e.EventId` (`:18`, `MMCA.ADC.Conference.Domain/Events/EventSpeaker.cs:23`), `child => child.Id` (`:19`), and the `FirstOrDefault` assignment into the settable navigation (`:20`, `EventSpeaker.cs:19-20`). +- **Why it's built this way**: the junction carries only the two parent references, so hydrating the event side is the difference between a usable association read and a row of bare integers. Note that the **speaker** side is not declared here: `EventSpeaker` has no `Speaker` navigation descriptor in this file, which is consistent with speakers being reachable through their own aggregate. +- **Where it's used**: registered as `INavigationPopulator` (`MMCA.ADC.Conference.Application/DependencyInjection.cs:95`) beside its read service (`:96`). Tests: `MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/EventSpeakerNavigationPopulatorTests.cs:15-43`. +- **Caveats / not-in-source**: this junction is also the one the public-visibility rules refuse to trust as an acceptance signal, because the Sessionize import writes a row for every speaker in the response; see [`PublicConferenceVisibility`](#publicconferencevisibility) (`MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:97-103`). Hydration and visibility are separate concerns here, and this file only does the former. + +### PublicConferenceVisibility + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Common` · `MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:28` · Level 10 · class (static) -- **What it is**: the handler for [`RemoveSessionSpeakerCommand`](#removesessionspeakercommand). The same load-delegate-save shape as its question-answer sibling, minus the ownership guard and plus a fallback for resolving the owning session when the command omits `SessionId` (`MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionSpeaker/RemoveSessionSpeakerHandler.cs:9-12`). -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), the [`Session`](group-17-conference-domain.md#session) aggregate and its [`SessionSpeaker`](group-17-conference-domain.md#sessionspeaker) child, [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error), and logging. -- **Concept introduced, resolving the parent aggregate from a join id**: the DELETE endpoint takes the session id as an optional query parameter, but the UI's generic delete sends only the join-entity id, so `SessionId` arrives as the default `0`; the comment records exactly this (`:24-26`). When `SessionId == default` the handler queries for the session whose `SessionSpeakers` contains the join id and takes the first match (`:28-36`); otherwise it loads by id directly (`:37-44`). Both branches include `SessionSpeakers` and load `asTracking: true`, so the two paths hand the domain call an identically hydrated aggregate. `[Rubric §9, API and Contract Design]` assesses whether the contract absorbs real client behavior without weakening the model: the handler adapts, while the domain call still targets exactly one aggregate. `[Rubric §12, Performance and Scalability]`: the `Any(...)`-predicate scan runs only on the id-less path, not on the hot one. -- **Walkthrough**: after resolving the session (or returning `Error.NotFound` at `:46-47`), it delegates to `entity.RemoveSessionSpeaker(...)` (`:49`), then saves and logs on success through the generated `LogSpeakerRemovedFromSession` (`:50-54`, declared `:59-60`). The log statement writes `command.SessionSpeakerId` and `command.SessionId` (`:53`), so on the fallback path it records the defaulted `0` rather than the resolved session id: a small telemetry gap worth knowing about when reading these logs. -- **Where it's used**: registered by the convention scan; injected into [`SessionSpeakersController`](group-20-conference-api-grpc.md#sessionspeakerscontroller) (`MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:50`) and invoked from its delete endpoint (`:241`), which also evicts the sessions output cache afterwards (`:249`). +- **What it is**: the single definition of what an anonymous or non-privileged caller may see in the conference catalog, expressed as three id-list resolvers that the public read filters turn into `IN (...)` specifications (`MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:10-15`). Three rules: an event is visible when published (BR-108), a session is visible when its event is visible and its status is on the BR-49 allow-list, and a speaker is visible when they have at least one eligible session inside the scoped published-event set (BR-239) (`:18-21`). +- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) and [`IEntityQuerier`](group-07-persistence-ef-core.md#ientityqueriertentity-tidentifiertype) (`:40`, `:75`, `:126-127`, `:151`), [`CrossSourceSpecification`](group-03-querying-specifications.md#crosssourcespecification) (`:63`), [`InlineSpecification`](group-03-querying-specifications.md#inlinespecificationtentity-tidentifiertype) (`:149`), [`PublicSessionStatusSpecification`](#publicsessionstatusspecification) (`:68`, `:148`), and the [`Event`](group-17-conference-domain.md#event), [`Session`](group-17-conference-domain.md#session), and [`SessionSpeaker`](group-17-conference-domain.md#sessionspeaker) entities. +- **Concept introduced, authorization as a translatable data filter**: read authorization here is not a check that runs after the query, it *is* the query. Every rule is resolved into a materialized list of ids and embedded in a predicate, never expressed as a navigation join. The file states the three reasons (`:22-26`): the criteria stay translatable on any engine ([ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html)), each aggregate keeps its by-id boundary to the others, and the results pass the specification fitness test. `[Rubric §11, Security]` assesses whether authorization is enforced where the data is read rather than in a view: because one helper backs the session, speaker, sponsor, room, activity, and junction filters, closing a leak in one place closes it everywhere, which is the property that motivates the whole file. `[Rubric §8, Data Architecture]`: a cross-aggregate rule becomes a scalar projection plus an `IN`, the shape a split topology can still execute. +- **Walkthrough**: three public resolvers and one private helper. + - `GetPublishedEventIdsAsync` (`:36-48`) resolves the read repository as an [`IEntityQuerier`](group-07-persistence-ef-core.md#ientityqueriertentity-tidentifiertype) (`:40`) and projects ids with a predicate in one call, `GetProjectedAsync(e => e.Id, e => e.IsPublished, asTracking: false, ...)` (`:42-44`). The result is materialized once so callers embed a stable collection EF can translate (`:46-47`). + - `GetVisibleSessionIdsAsync` (`:57-82`) delegates the two-source AND to [`CrossSourceSpecification`](group-03-querying-specifications.md#crosssourcespecification)`.BuildAsync` (`:63-70`), passing `e => e.IsPublished` as the principal predicate, `s => s.EventId` as the dependent FK, and [`PublicSessionStatusSpecification`](#publicsessionstatusspecification)`.StatusCriteria` as the local predicate. That helper runs the principal projection first and returns an inline specification whose criteria is `localPredicate AND principalKeys.Contains(fk)`, built as an expression tree with no `Expression.Invoke` so it stays translatable on every provider (`MMCA.Common/Source/Core/MMCA.Common.Application/Specifications/CrossSourceSpecification.cs:55-88`). The specification then reaches the repository as a specification: `ListAsync(specification, s => s.Id, cancellationToken)` (`:77-79`) is the untracked, soft-delete-filtered projection that the explicit argument list used to spell out (`:72-74`). + - `GetVisibleSpeakerIdsAsync` (`:104-134`) takes an optional event scope. It resolves the published set first (`:109`), and when a scope is supplied it narrows to that single event **only if** the event is published, otherwise to the empty list (`:113-117`); an unpublished or unknown scoped event is not an error, it simply has no public speakers (`:111-112`). Empty scope and empty eligible-session set both short-circuit to `[]` (`:119-124`), then the join table is projected with `eligibleSessionIds.Contains(ss.SessionId)` and de-duplicated (`:126-133`). + - `GetEligibleSessionIdsAsync` (`:141-158`) is the private narrowing variant: `new PublicSessionStatusSpecification().And(new InlineSpecification(s => scopedEventIds.Contains(s.EventId)))` (`:148-149`), which keeps the criteria a translatable `IN` filter with no navigation join (`:146-147`). +- **Why it's built this way**: the remark at `:97-103` is the most load-bearing comment in the file, and it records a real rule, not a preference. The `EventSpeaker` junction is deliberately **not** treated as a visibility grant, because the Sessionize import writes a row there for every speaker in the response, so reading it as acceptance would publish the entire imported roster and make the filter vacuous. The session link is the only acceptance signal a speaker carries, so it is the only path consulted: a speaker whose sessions are all waitlisted or declined, and one linked to nothing, both stay hidden. `[Rubric §4, DDD]`: the rule is stated in the vocabulary of the business (published, accepted, assigned) and lives beside the aggregates it constrains. +- **Where it's used**: by the eight public-filter query handlers, one per publicly readable entity or junction: [`GetPublicSponsorFilterHandler`](#getpublicsponsorfilterhandler) (`MMCA.ADC.Conference.Application/Sponsors/UseCases/GetPublicSponsorFilter/GetPublicSponsorFilterHandler.cs:25`), [`GetPublicActivityFilterHandler`](#getpublicactivityfilterhandler) (`.../Activities/UseCases/GetPublicActivityFilter/GetPublicActivityFilterHandler.cs:25`), [`GetPublicRoomFilterHandler`](#getpublicroomfilterhandler) (`.../Events/UseCases/GetPublicRoomFilter/GetPublicRoomFilterHandler.cs:25`), [`GetPublicSpeakerFilterHandler`](#getpublicspeakerfilterhandler) (`.../Speakers/UseCases/GetPublicSpeakerFilter/GetPublicSpeakerFilterHandler.cs:26-31`, which passes the query's optional event scope straight through), [`GetPublicSpeakerCategoryItemFilterHandler`](#getpublicspeakercategoryitemfilterhandler) (`.../Speakers/UseCases/GetPublicSpeakerCategoryItemFilter/GetPublicSpeakerCategoryItemFilterHandler.cs:26`), [`GetPublicSessionSpeakerFilterHandler`](#getpublicsessionspeakerfilterhandler) (`.../Sessions/UseCases/GetPublicSessionSpeakerFilter/GetPublicSessionSpeakerFilterHandler.cs:24`), [`GetPublicSessionCategoryItemFilterHandler`](#getpublicsessioncategoryitemfilterhandler) (`.../Sessions/UseCases/GetPublicSessionCategoryItemFilter/GetPublicSessionCategoryItemFilterHandler.cs:25`), and [`GetPublicEventSpeakerFilterHandler`](#getpubliceventspeakerfilterhandler) (`.../Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterHandler.cs:31-44`), which calls two resolvers so a junction row follows the visibility of **both** its parents. +- **Caveats / not-in-source**: the id lists are materialized and embedded, and the framework says so: the helper fits principal sets that are small and bounded, the "published events" shape (`MMCA.Common/Source/Core/MMCA.Common.Application/Specifications/CrossSourceSpecification.cs:17-20`). A single call to `GetVisibleSpeakerIdsAsync` issues three sequential round trips (events, eligible sessions, join rows), and the junction handler calls two resolvers, reading the bounded Event table twice; that trade is stated in the handler itself rather than optimized away (`GetPublicEventSpeakerFilterHandler.cs:35-37`). Nothing in this file caches any of it. + +### RoomNavigationPopulator + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events` · `MMCA.ADC.Conference.Application/Events/RoomNavigationPopulator.cs:11` · Level 10 · class (sealed) + +- **What it is**: the FK populator for [`Room`](group-17-conference-domain.md#room), hydrating each room's parent [`Event`](group-17-conference-domain.md#event) reference. +- **Depends on**: [`DeclarativeNavigationPopulator`](group-11-navigation-populators.md#declarativenavigationpopulatortentity) over `Room` (`:13`), one [`FKNavigationDescriptor`](group-11-navigation-populators.md#fknavigationdescriptortentity-tchild-tchildid) closed over `Room` / `Event` / `EventIdentifierType` (`:15`), and [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:12`). +- **Concept reinforced**: identical in mechanism to [`ActivityNavigationPopulator`](#activitynavigationpopulator). The settings are `nameof(Room.Event)` (`:17`), `e => e.EventId` (`:18`, the get-only FK at `MMCA.ADC.Conference.Domain/Events/Room.cs:37`), `child => child.Id` (`:19`), and the `FirstOrDefault` assignment into the settable, `[Navigation]`-attributed property (`:20`, `Room.cs:33-34`). +- **Why it's built this way**: rooms are the one child of `Event` that is read from both ends in production. [`EventNavigationPopulator`](#eventnavigationpopulator) hydrates `Event.Rooms` for an event-first read, and this class hydrates `Room.Event` for a room-first read, both through the same base and both as batched key lookups ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html)). `[Rubric §7, Microservices Readiness]`: neither direction assumes the two entities share a database. +- **Where it's used**: registered as `INavigationPopulator` (`MMCA.ADC.Conference.Application/DependencyInjection.cs:89`) beside the closed-generic read service for rooms (`:90`). Tests: `MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/RoomNavigationPopulatorTests.cs:15-43`. ### SponsorCreateRequestMapper > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sponsors.UseCases.Create` · `MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestMapper.cs:11` · Level 10 · class (sealed) -- **What it is**: the adapter that turns a validated [`SponsorCreateRequest`](#sponsorcreaterequest) into a [`Sponsor`](group-17-conference-domain.md#sponsor) entity by calling the aggregate's `Create` factory (`MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestMapper.cs:7-9`). -- **Depends on**: [`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype) closed over `Sponsor` / `SponsorCreateRequest` / `SponsorIdentifierType` (`:11-12`), the [`Sponsor`](group-17-conference-domain.md#sponsor) factory, and [`Result`](group-01-result-error-handling.md#result). -- **Concept reinforced, request-to-entity mapping as its own step**: the create pipeline separates shaping the input (this mapper) from orchestrating the use case ([`CreateSponsorHandler`](#createsponsorhandler)). `CreateEntityAsync` (`:15-32`) guards the null request with `ArgumentNullException.ThrowIfNull` (`:17`), then forwards the request fields positionally into `Sponsor.Create(...)` (`:19-31`), returning that factory's `Result` wrapped in an already-completed `Task`. There is no `async` here at all: the mapping is synchronous, and the `Task` exists only to satisfy an interface other entities implement with genuinely asynchronous lookups. `[Rubric §1, SOLID]`: single responsibility, the mapper knows the factory's argument order and nothing else. Manual mapping over reflection-based mapping follows [ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html). -- **Walkthrough**: twelve positional arguments in factory order: `Id`, `Name`, `Tier`, `LogoUrl`, `Description`, `WebsiteUrl`, `LinkedInUrl`, `TwitterHandle`, `Sort`, `EventId`, `IsExhibitor`, `BoothNumber` (`:20-31`), matching `Sponsor.Create` exactly (`MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:105-117`). Unlike the session mapper, no request field is dropped: every property on [`SponsorCreateRequest`](#sponsorcreaterequest) reaches the factory. The `Id` it passes is widened to the factory's nullable parameter and then discarded, because the factory checks `typeof(Sponsor).IsIdValueGenerated` and keeps the database default when the key is store-generated (`Sponsor.cs:126`, `:130`). -- **Why it's built this way**: positional forwarding into a factory means the *domain* decides which combinations are legal, and the compiler catches an argument-order change at the one place that knows it. The factory validates name, logo URL, and booth number through `SponsorInvariants` before constructing anything (`Sponsor.cs:119-124`) and raises a `SponsorChanged` domain event on the new instance (`:133`), so a create is never a silent write. -- **Where it's used**: injected into [`CreateSponsorHandler`](#createsponsorhandler) as `IEntityRequestMapper` (`MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/CreateSponsorHandler.cs:18`), so the handler is constructed against the interface and this class is named only at registration (the convention scan, `MMCA.ADC.Conference.Application/DependencyInjection.cs:112`). +- **What it is**: the one place that knows how to turn a [`SponsorCreateRequest`](#sponsorcreaterequest) into a [`Sponsor`](group-17-conference-domain.md#sponsor). It does not construct the entity itself; it calls the domain factory and hands back whatever `Result` that factory returns (`MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestMapper.cs:19-31`). +- **Depends on**: [`IEntityRequestMapper`](group-12-api-hosting-mapping.md#ientityrequestmappertentity-tcreaterequest-tidentifiertype) closed over `Sponsor` / `SponsorCreateRequest` / `SponsorIdentifierType` (`:12`), the [`Sponsor`](group-17-conference-domain.md#sponsor) aggregate and its `Create` factory, and [`Result`](group-01-result-error-handling.md#result) (`:3`). +- **Concept reinforced, request mapping is not object mapping**: [`SponsorDTOMapper`](#sponsordtomapper) can be source-generated because its target is a settable record. This mapper cannot, because its target is a **factory that returns a `Result`**: the entity's constructor is private and the only way in runs the invariants first. So the direction out of the domain is generated and the direction into it is hand-written, which is exactly the split [ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html) describes. `[Rubric §4, DDD]` assesses whether invariants are unavoidable: there is no path from a request to a `Sponsor` that skips `Sponsor.Create`. +- **Walkthrough**: one method. + - `ArgumentNullException.ThrowIfNull(request)` (`:17`) guards the reference the interface does not declare as nullable. + - `Task.FromResult(Sponsor.Create(...))` (`:19-31`) forwards twelve values in the factory's parameter order. There is no `await` because there is no I/O: the method is `Task`-returning to satisfy the interface (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEntityDTOMapper.cs:54`), not because anything is asynchronous, and wrapping a completed value avoids allocating a state machine. + - The work then happens in the domain: `Sponsor.Create` (`MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:105-136`) combines three invariant checks for name, logo URL, and booth number (`:119-122`), decides whether to honor or discard the supplied id based on `IsIdValueGenerated` (`:126-131`), and raises the `SponsorChanged` domain event with `DomainEntityState.Added` before returning success (`:133`). + - `request.Id` is a non-nullable `int` widened to the factory's `SponsorIdentifierType?` parameter (`Sponsor.cs:106`), which is why the request can declare a plain value type and still reach a nullable factory slot. +- **Why it's built this way**: keeping the factory call behind an interface means the create slice can grow an asynchronous pre-check (a uniqueness lookup, for example) by changing this one class, with no edit to [`CreateSponsorHandler`](#createsponsorhandler), which injects the interface (`CreateSponsorHandler.cs:18`). `[Rubric §1, SOLID]`: dependency inversion applied at the smallest useful granularity. +- **Where it's used**: registered by the `IEntityRequestMapper<,,>` assembly scan (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:172-176`, invoked from `MMCA.ADC.Conference.Application/DependencyInjection.cs:125`) and resolved into [`CreateSponsorHandler`](#createsponsorhandler) (`CreateSponsorHandler.cs:18`, called at `:27`). +- **Caveats / not-in-source**: the async signature is currently unused, and no existence or uniqueness check happens here today; see the caveat on [`CreateSponsorHandler`](#createsponsorhandler) for what does and does not verify `EventId`. ### SponsorCreateRequestValidator > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sponsors.UseCases.Create` · `MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestValidator.cs:7` · Level 10 · class (sealed) -- **What it is**: the FluentValidation validator for [`SponsorCreateRequest`](#sponsorcreaterequest). It composes reusable per-field rule sets rather than restating each rule inline (`MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestValidator.cs:6`). -- **Depends on**: `FluentValidation` (`AbstractValidator` and its `Include`, `:1`, `:7`) and the shared `Sponsor*Rules` classes from `MMCA.ADC.Conference.Application.Sponsors.Validation` (`:2`). -- **Concept reinforced, composed validation via reusable rule includes**: the constructor (`:9-20`) calls `Include(...)` once per field, each rule class generic over the *request* type and constructed with a property selector, for example `Include(new SponsorNameRules(p => p.Name))` (`:11`). `Include` folds the other validator's rules into this one, so the composite reports a single flat error list. Because the rule classes are generic over the request type, the create and the update path share the identical rule shapes: [`SponsorUpdateRequestValidator`](#sponsorupdaterequestvalidator) includes the same eight of them closed over [`SponsorUpdateRequest`](#sponsorupdaterequest) (`MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/SponsorUpdateRequestValidator.cs:11-18`). `[Rubric §24, Forms/Validation/UX Safety]` and `[Rubric §15, Best Practices and Code Quality]`: a length or format rule is defined once, so it cannot drift between create and update. The validator runs in the CQRS pipeline before the handler executes ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). -- **Walkthrough**: nine includes covering `Name`, `EventId`, `Sort`, `LogoUrl`, `Description`, `WebsiteUrl`, `LinkedInUrl`, `TwitterHandle`, and `BoothNumber` (`:11-19`). The rule classes themselves are thin bindings to shared bases and to the domain's own constants: [`SponsorNameRules`](#sponsornamerulest) derives from `RequiredStringRules` with `SponsorInvariants.NameMaxLength` (`MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:13-18`), the six optional string rules derive from `OptionalStringRules` with their matching invariant constants (`:26-91`), [`SponsorEventIdRules`](#sponsoreventidrulest) is a `NotEmpty` with error code `Sponsor.EventId.Required` (`:98-104`), and [`SponsorSortRules`](#sponsorsortrulest) is a `GreaterThanOrEqualTo(0)` with error code `Sponsor.Sort.Negative` (`:110-116`). Taking the length limits from `SponsorInvariants` rather than from literals is what keeps the API rejection and the domain rejection in agreement. -- **Why it's built this way**: notice what is deliberately absent. `Tier` and `IsExhibitor` have no rules at all, because an enum and a bool are already total; and only three of these fields (`Name`, `LogoUrl`, `BoothNumber`) are re-checked by the domain factory (`MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:119-122`). For the rest, this validator is the only gate, which is the practical reason it runs on every path into the use case rather than only at the controller. -- **Where it's used**: resolved by the validation stage of the create pipeline for [`SponsorCreateRequest`](#sponsorcreaterequest); registered by the convention scan (`MMCA.ADC.Conference.Application/DependencyInjection.cs:112`). -- **Caveats / not-in-source**: the logo, website, and LinkedIn rules are length-only. The source states the reason for the logo one (`MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:21-23`): the value is stored as an opaque string, matching the speaker profile-picture precedent. No URL well-formedness check exists for any of the three. +- **What it is**: the FluentValidation validator for [`SponsorCreateRequest`](#sponsorcreaterequest). Its constructor is nine `Include` calls and nothing else (`MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestValidator.cs:9-20`): it owns no rule of its own, it composes rule objects that the update request also composes. +- **Depends on**: `AbstractValidator` from FluentValidation (`:7`) and the nine reusable rule classes in `MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs`, seven of which derive from the framework's [`RequiredStringRules`](group-06-validation.md#requiredstringrulest) or [`OptionalStringRules`](group-06-validation.md#optionalstringrulest). +- **Concept reinforced, composable rule objects**: the technique is introduced in [Group 06](group-06-validation.md#requiredstringrulest). `Include` merges another validator's rules into this one, so the same rule instance definition can be bound to a different property selector on a different request type. The payoff is visible in the sibling: `SponsorUpdateRequestValidator` includes eight of these same nine rule objects, bound to the update request's properties (`MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/SponsorUpdateRequestValidator.cs:11-18`), so the create and update contracts cannot drift on max lengths or error codes. Only the event-id rule is missing there, because the owning event is not updatable at all (`MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:140`). `[Rubric §24, Forms, Validation, and UX Safety]` assesses whether validation is stated once and enforced consistently: the rule text and error codes a client sees are identical on both verbs. +- **Walkthrough**: each `Include` binds a rule object to a property selector. + - `SponsorNameRules(p => p.Name)` (`:11`) derives from [`RequiredStringRules`](group-06-validation.md#requiredstringrulest) with the label "Sponsor Name" and `SponsorInvariants.NameMaxLength` (`MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:13-18`). The max length comes from the **domain's** invariant constants, so the request rule and the entity invariant cannot disagree. + - `SponsorEventIdRules` (`:12`) is a hand-written `AbstractValidator` requiring `NotEmpty` with the error code `Sponsor.EventId.Required` (`SponsorValidationRules.cs:98-104`); `SponsorSortRules` (`:13`) requires `GreaterThanOrEqualTo(0)` with `Sponsor.Sort.Negative` (`:110-116`). + - The six optional strings, `LogoUrl`, `Description`, `WebsiteUrl`, `LinkedInUrl`, `TwitterHandle`, and `BoothNumber` (`:14-19`), all derive from [`OptionalStringRules`](group-06-validation.md#optionalstringrulest) with their own labels and `SponsorInvariants` max lengths (`SponsorValidationRules.cs:26-91`). The logo URL is length-checked only, not parsed as a URI, and the file says why: the value is stored as an opaque string, matching the speaker profile-picture precedent (`:21-23`). +- **Why it's built this way**: no handler calls this class. [`ValidatingCommandDecorator`](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult) resolves it from the container and runs it before the handler, converting failures into a `Result` rather than an exception. `[Rubric §10, Cross-Cutting]` and `[Rubric §6, CQRS]` both point at the same design: validation is a pipeline stage, so a handler that forgets to validate cannot exist. +- **Where it's used**: registered by `AddValidatorsFromAssemblyContaining()` inside the module scan (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:190`, invoked at `MMCA.ADC.Conference.Application/DependencyInjection.cs:125`). Its tests walk the boundaries directly, including the exact-max-length pass and the null-optional-strings pass (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sponsors/Validation/SponsorCreateRequestValidatorTests.cs:30-147`). +- **Caveats / not-in-source**: two gaps are worth knowing. `NotEmpty` on an `int` rejects only zero, so it proves an event was chosen, not that the event exists (the database FK does that, `MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SponsorConfiguration.cs:62-65`). And no rule here constrains `Tier`: an out-of-range enum value passes validation, and `Sponsor.Create` does not check it either (`MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:119-122`). ### UpdateSessionQuestionAnswerHandler -> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.UpdateSessionQuestionAnswer` · `MMCA.ADC.Conference.Application/Sessions/UseCases/UpdateSessionQuestionAnswer/UpdateSessionQuestionAnswerHandler.cs:14` · Level 10 · class (sealed partial) +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sessions.UseCases.UpdateSessionQuestionAnswer` · `MMCA.ADC.Conference.Application/Sessions/UseCases/UpdateSessionQuestionAnswer/UpdateSessionQuestionAnswerHandler.cs:14` · Level 10 · class (sealed, partial) + +- **What it is**: the handler for [`UpdateSessionQuestionAnswerCommand`](#updatesessionquestionanswercommand). It loads the owning [`Session`](group-17-conference-domain.md#session) aggregate with its answers, enforces the BR-52/BR-53 ownership rule, mutates through the root, and saves (`MMCA.ADC.Conference.Application/Sessions/UseCases/UpdateSessionQuestionAnswer/UpdateSessionQuestionAnswerHandler.cs:20-52`). +- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:15`), [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) (`:16`), `ILogger` (`:17`), and [`RoleNames`](group-08-auth.md#rolenames) (`:35`). It implements [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) returning a bare [`Result`](group-01-result-error-handling.md#result) (`:17`). +- **Concept introduced, ownership enforced in the handler**: the controller has already established *that* the caller is authenticated (`[Authorize(Policy = AuthorizationPolicies.RequireAuthenticated)]`, `MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:56`), but only the handler can establish *whether this particular row is theirs*, because that fact lives in the loaded entity's audit column. So the rule sits here, next to the data: an Organizer may edit any answer, everyone else may edit only rows whose `CreatedBy` matches their user id (`:33-42`). `[Rubric §11, Security]` assesses whether authorization decisions are made where the necessary facts exist: role membership comes from the token, row ownership from the aggregate, and both are compared in one expression. `[Rubric §4, DDD]`: the check reads the child through the root's collection, never through a separate repository. +- **Walkthrough**: eight steps, in order. + - `unitOfWork.GetRepository()` (`:24`), then `GetByIdAsync` with `includes: [nameof(Session.SessionQuestionAnswers)]` and **`asTracking: true`** (`:25-29`). The tracking flag is load-bearing: the mutation happens on this graph and `SaveChangesAsync` persists it only because the change tracker is watching. + - A missing session returns `Error.NotFound` tagged with source and target (`:30-31`), so the failure carries where it came from without a string message. + - The ownership pre-check (`:34-42`) finds the active answer in the loaded collection with `a.Id == command.SessionQuestionAnswerId && !a.IsDeleted`, then fails with `Error.Forbidden(code: "SessionQuestionAnswer.NotOwner", ...)` when the answer exists, the caller is not in [`RoleNames`](group-08-auth.md#rolenames)`.Organizer`, and `answer.CreatedBy` differs from the current user id. + - The `answer is not null` guard is the interesting part. When the id names nothing, or names a soft-deleted row, the check is skipped and the call falls through to the domain, which resolves the child with the **same** active-only predicate and returns NotFound (`MMCA.ADC.Conference.Domain/Sessions/Session.cs:540-542` into `MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/AuditableAggregateRootEntity.cs:103-119`). The practical effect is that a non-owner probing for an id they cannot see receives NotFound rather than Forbidden, so the error does not confirm the row exists. + - `entity.UpdateSessionQuestionAnswer(...)` (`:44`) does the real work: resolve the child, call `answer.UpdateAnswer` (`MMCA.ADC.Conference.Domain/Sessions/SessionQuestionAnswer.cs:71-80`, which validates the text before assigning it), and raise `SessionQuestionAnswerChanged` with `DomainEntityState.Updated` (`Session.cs:549`). + - `SaveChangesAsync` runs only on success (`:45-49`). That is safe precisely because the domain validates before it mutates: a failed update leaves the tracked graph unchanged, so skipping the save cannot strand a half-applied edit. + - The success log is source-generated (`:54-55`), which is why the class is `partial`. +- **Why it's built this way**: the handler mirrors its sibling [`UpdateEventQuestionAnswerHandler`](#updateeventquestionanswerhandler) line for line (`MMCA.ADC.Conference.Application/Events/UseCases/UpdateEventQuestionAnswer/UpdateEventQuestionAnswerHandler.cs:35`), which is deliberate: session-level and event-level questionnaires answer to the same two business rules, and reading either one teaches both. The read side enforces the complementary rule with a specification instead of a branch: the controller scopes non-Organizer list reads with `OwnedByUserSpecification` (`MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:67-68`), so ownership shows up as a filter on reads and as a guard on writes. +- **Where it's used**: registered by the `ICommandHandler<,>` assembly scan (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:178-182`), injected into [`SessionQuestionAnswersController`](group-20-conference-api-grpc.md#sessionquestionanswerscontroller) (`MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:60`), and invoked by its `UpdateAsync` action, which returns `204 NoContent` on success (`:202-215`). Note that the PUT carries no `[Idempotent]` attribute, unlike the POST on the same controller (`:183-184`): a replayed update is naturally idempotent. +- **Caveats / not-in-source**: two. + - The non-owner branch is not exercised by this module's unit tests. Every test in the class stubs `IsInRole(RoleNames.Organizer)` as `true` in its constructor (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/UpdateSessionQuestionAnswerHandlerTests.cs:27`), so the five tests (`:79-174`) all take the privileged path through `:35`. + - `currentUserService.UserId!.Value` is dereferenced without a null check (`:35`). It is only reached for an authenticated non-Organizer, and the controller policy makes that the only way in, but a caller with no user id reaching this line would throw rather than return a failure `Result`. -- **What it is**: the handler for [`UpdateSessionQuestionAnswerCommand`](#updatesessionquestionanswercommand). It mirrors [`RemoveSessionQuestionAnswerHandler`](#removesessionquestionanswerhandler) statement for statement, enforcing the same BR-52 / BR-53 ownership rule before applying an edit instead of a removal (`MMCA.ADC.Conference.Application/Sessions/UseCases/UpdateSessionQuestionAnswer/UpdateSessionQuestionAnswerHandler.cs:10-13`). -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`ICurrentUserService`](group-08-auth.md#icurrentuserservice), [`RoleNames`](group-08-auth.md#rolenames), the [`Session`](group-17-conference-domain.md#session) aggregate, [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error), and logging (`:14-17`). -- **Concept reinforced**: none new; the ownership guard is the one taught on [`RemoveSessionQuestionAnswerHandler`](#removesessionquestionanswerhandler). Load the session with its answers, tracked (`:25-29`); block a non-organizer editing another user's answer with `Error.Forbidden`, code `SessionQuestionAnswer.NotOwner` and message "You can only update your own answers." (`:37-42`); then delegate to `entity.UpdateSessionQuestionAnswer(command.SessionQuestionAnswerId, command.AnswerValue)` (`:44`). The pair is a small, deliberate duplication: two use-case folders, one authorization rule stated twice, rather than a shared base class that would couple the slices. `[Rubric §5, Vertical Slice]` assesses whether a feature is independently changeable; the cost of this choice is two places to edit if BR-52 changes, and the benefit is that neither slice can break the other. -- **Walkthrough**: the success path saves once and logs through the generated `LogSessionQuestionAnswerUpdated`, which records only the answer id (`:45-49`, declared `:54-55`). The new text is never logged, which matters because an answer body is attendee-authored content. -- **Where it's used**: injected into [`SessionQuestionAnswersController`](group-20-conference-api-grpc.md#sessionquestionanswerscontroller) (`MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:59`) and invoked from its update endpoint (`:201`). +### DeleteEventHandler + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Events.UseCases.Delete` · `MMCA.ADC.Conference.Application/Events/UseCases/Delete/DeleteEventHandler.cs:18` · Level 10 · class (sealed partial) + +- **What it is**: the module's replacement for the framework's generic delete handler on one entity, [`Event`](group-17-conference-domain.md#event). Deleting an event has to reach three *other* aggregates (sessions, sponsors, activities) that the generic handler cannot see, so Conference registers this handler under the same contract and takes the delete slot over (`MMCA.ADC.Conference.Application/Events/UseCases/Delete/DeleteEventHandler.cs:13-17`). +- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) closed over [`DeleteEntityCommand`](group-05-cqrs-pipeline.md#deleteentitycommandtentity-tidentifiertype) for `Event` and [`Result`](group-01-result-error-handling.md#result) (`:21`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:19`); [`IEventCascadeDeletionDomainService`](group-17-conference-domain.md#ieventcascadedeletiondomainservice) (`:20`); `ILogger` from `Microsoft.Extensions.Logging` (`:21`); and the four aggregates [`Event`](group-17-conference-domain.md#event), [`Session`](group-17-conference-domain.md#session), [`Sponsor`](group-17-conference-domain.md#sponsor), and [`Activity`](group-17-conference-domain.md#activity). +- **Concept introduced, a cross-aggregate cascade split between the application and domain layers**: an aggregate may delete everything it *owns*, and nothing else. `Event.Delete()` cascades to the children the event owns outright, its rooms, event speakers, and event question answers (BR-72, `MMCA.ADC.Conference.Domain/Events/Event.cs:323-328`), and stops there. Sessions, sponsors, and activities are separate aggregate roots that merely carry an `EventId`, so nothing inside `Event` can reach them. The pattern this file demonstrates splits the job in two: the **application layer** owns *loading* the other aggregates, because loading needs repositories, and the **domain layer** owns *deciding and ordering* the deletions, because that is a business rule. `[Rubric §4, Domain-Driven Design]` assesses whether aggregate boundaries are respected as consistency boundaries rather than smeared into one graph; here the boundary is respected literally, and the cross-boundary rule is stated once in a pure domain service ([`EventCascadeDeletionDomainService`](group-17-conference-domain.md#eventcascadedeletiondomainservice)) with no infrastructure dependency (`MMCA.ADC.Conference.Domain/Services/EventCascadeDeletionDomainService.cs:16`). `[Rubric §1, SOLID]`: the generic handler stays closed for modification and this class is the extension, registered against the same interface. `[Rubric §8, Data Architecture]`: because everything is soft-delete, the whole cascade is a set of in-memory flag mutations followed by one write, not four delete statements. +- **Walkthrough**: one method, and its shape is load-load-load-load, decide, save. + - The primary constructor takes three services and declares the contract in the base list (`:18-21`). There is no `IRepository` parameter: every repository is pulled off [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) inside the method, which is the framework's rule for keeping one tracked context per operation. + - `HandleAsync` loads the event through `unitOfWork.GetRepository()` with an explicit `includes` array naming `Rooms`, `EventSpeakers`, and `EventQuestionAnswers`, and with `asTracking: true` (`:28-33`). Tracking is the load-bearing argument in all four reads: the cascade mutates entities in memory and relies on the change tracker to turn those mutations into an `UPDATE`. An untracked graph would produce a silently successful no-op. + - A missing event short-circuits with `Error.NotFound` stamped with source and target (`:34-35`), which is the [`Result`](group-01-result-error-handling.md#result) idiom rather than an exception ([ADR-013](https://ivanball.github.io/docs/adr/013-result-pattern.html)). + - Sessions load next, with their own three child collections included so that each session's own cascade has its children in memory, filtered by `s.EventId.Equals(entity.Id) && !s.IsDeleted` and tracked (`:38-43`). Sponsors (`:47-52`) and activities (`:56-61`) follow the same shape with an empty `includes` array, because neither has children to cascade to. + - `eventCascadeDeletionDomainService.CascadeDelete(entity, sessions, sponsors, activities)` (`:64`) hands all four sets to the domain service, which deletes sessions first (each cascading to its own children, BR-55, `MMCA.ADC.Conference.Domain/Sessions/Session.cs:273-277`), then sponsors, then activities, and only then the event (`EventCascadeDeletionDomainService.cs:28-54`). The first failure in any loop returns that failure unchanged and leaves the event untouched. + - The save is conditional (`:65-69`): `unitOfWork.SaveChangesAsync` runs only when the cascade succeeded, so an aborted cascade's partial in-memory mutations are discarded with the scope rather than persisted. That single `SaveChangesAsync` is what makes the whole cascade atomic. + - `LogEventDeleted` is a source-generated `[LoggerMessage]` partial at `Information` level carrying the event id (`:74-75`). `[Rubric §13, Observability and Operability]` assesses whether operationally interesting transitions are recorded in a structured, queryable form; the generator emits an allocation-free, strongly typed log call instead of an interpolated string, and it fires only on the success path. +- **Why it's built this way**: the module is meant to be extractable as its own service ([ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html), [ADR-008](https://ivanball.github.io/docs/adr/008-service-extraction-topology.html)), and the four aggregates here all live in the Conference database ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)), so an in-process cascade over one unit of work is legitimate. The alternative, database-level `ON DELETE CASCADE`, is unavailable by construction: nothing is hard-deleted, rows are flagged ([ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html)), and a flag update is not something a foreign key can propagate. The comments name the consequence of getting this wrong: sponsors and activities left behind are rows the public sponsor strip and activities page keep reading (`:45-46`, `:54-55`). +- **Where it's used**: registered as `services.TryAddScoped, Result>, ...DeleteEventHandler>()` (`MMCA.ADC.Conference.Application/DependencyInjection.cs:60`), which is what makes it win the slot over the generic [`DeleteEntityHandler`](group-05-cqrs-pipeline.md#deleteentityhandlertentity-tidentifiertype) that [`Sponsor`](group-17-conference-domain.md#sponsor) and the other entities register (`:86`). It is injected into [`EventsController`](group-20-conference-api-grpc.md#eventscontroller) as the delete handler (`MMCA.ADC.Conference.API/Controllers/EventsController.cs:51`) and invoked through the overridden `DeleteAsync`, which delegates to the base action and then evicts three output-cache tags, `conference:events`, `conference:sessions`, and `conference:rooms`, precisely because the cascade reached beyond the event (`:404-414`). Covered by [`DeleteEventHandlerTests`](group-27-testing-infrastructure.md#deleteeventhandlertests), which asserts each of the three cascade legs separately (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/UseCases/DeleteEventHandlerTests.cs:200`, `:222`, `:244`) plus the not-found path and the save (`:184`, `:266`). +- **Caveats / not-in-source**: the handler issues four reads before it writes anything, and three of them are unbounded by page size: an event with many sessions materializes all of them, with their children, into memory. Nothing in the file caps that. The `!s.IsDeleted` predicates (`:41`, `:50`, `:59`) are belt-and-braces on top of the global soft-delete query filter, so an already-deleted child is skipped rather than re-deleted. `ConfigureAwait(false)` appears on the save (`:67`) but not on the four repository awaits, an inconsistency with no visible effect in an ASP.NET Core host. The cache eviction lives in the controller, not here, so a caller invoking this handler by any other route deletes correctly but leaves the output cache stale. + +### SpeakerCategoryItemNavigationPopulator + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers` · `MMCA.ADC.Conference.Application/Speakers/SpeakerCategoryItemNavigationPopulator.cs:11` · Level 10 · class (sealed) + +- **What it is**: the navigation populator for the [`SpeakerCategoryItem`](group-17-conference-domain.md#speakercategoryitem) join entity when it is read *as its own entity* rather than as a child of a speaker. It declares exactly one navigation, the parent `Speaker` back-reference (`MMCA.ADC.Conference.Application/Speakers/SpeakerCategoryItemNavigationPopulator.cs:7-10`). The class body is empty (`:23-24`): everything it does is data passed to the base constructor. +- **Depends on**: [`DeclarativeNavigationPopulator`](group-11-navigation-populators.md#declarativenavigationpopulatortentity) closed over [`SpeakerCategoryItem`](group-17-conference-domain.md#speakercategoryitem) (`:13`); [`FKNavigationDescriptor`](group-11-navigation-populators.md#fknavigationdescriptortentity-tchild-tchildid) (`:15`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), forwarded straight through (`:12-13`); and the [`Speaker`](group-17-conference-domain.md#speaker) aggregate as the reference target. +- **Concept introduced for the speaker slice, the FK direction of a declarative populator**: [Group 11](group-11-navigation-populators.md#declarativenavigationpopulatortentity) teaches the populator pattern itself; what this file shows is the *reference* direction, the mirror image of the *collection* direction [`SpeakerNavigationPopulator`](#speakernavigationpopulator) uses. An `FKNavigationDescriptor` reads the key off each parent row, drops nulls, distincts them, builds `child => parentIds.Contains(child.Id)` as an expression tree, runs one untracked query, groups the results, and assigns each row its match (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/NavigationLoader.cs:54-99`). That is why the `AssignAction` ends in `FirstOrDefault()` (`:20`): the loader always hands back a `List`, and a reference navigation wants one element out of it. The descriptor also declares `RequiresChildren => false` (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/FKNavigationDescriptor.cs:23`), which is what lets a caller ask for FK references without paying for child collections: the base tests that flag against the caller's `includeFKs` / `includeChildren` arguments before loading anything (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/DeclarativeNavigationPopulator.cs:36-40`). `[Rubric §12, Performance and Scalability]` assesses whether reads scale with page size rather than row count: one batched query per descriptor for the whole page, never one per row. `[Rubric §2, Design Patterns]`: Template Method configured by data instead of by overrides, which is why the body is genuinely empty. +- **Walkthrough**: one descriptor, four settings, and a generic quartet worth reading closely. + - `PropertyName = nameof(SpeakerCategoryItem.Speaker)` (`:17`) is not decoration. The base builds a set of the query's `UnsupportedIncludes` property names with an ordinal comparer and loads a descriptor only when its `PropertyName` is in that set (`DeclarativeNavigationPopulator.cs:27-37`), so a typo here is a silently unpopulated navigation, not a compile error. `nameof` is what prevents that. + - `ParentKeySelector = e => e.SpeakerId` (`:18`) reads the join row's FK (`MMCA.ADC.Conference.Domain/Speakers/SpeakerCategoryItem.cs:23`), and `ChildForeignKeySelector = child => child.Id` (`:19`) names the target's primary key, because on the FK direction the "child" of the descriptor is the referenced parent entity. + - `AssignAction = (e, speakers) => e.Speaker = speakers.FirstOrDefault()` (`:20`) writes the settable navigation property, which the entity exposes as `[Navigation] public Speaker? Speaker { get; set; }` (`MMCA.ADC.Conference.Domain/Speakers/SpeakerCategoryItem.cs:19-20`). The attribute is what puts this property in the FK bucket during metadata discovery, and the public setter is what makes the assignment possible at all. + - The closed generic is `FKNavigationDescriptor` (`:15`). Speaker is the one Conference aggregate whose key is not an `int`: `SpeakerIdentifierType` aliases `System.Guid` because speakers carry Sessionize-assigned GUIDs (BR-61, `MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:3`, `:19`). That satisfies the descriptor's `where TChildId : struct` constraint (`FKNavigationDescriptor.cs:17`), and the non-nullable `SpeakerId` widens to the `TChildId?` the `ParentKeySelector` declares (`:26`). +- **Why it's built this way**: the classification that triggers any of this is made per navigation by [`NavigationMetadataProvider`](group-03-querying-specifications.md#navigationmetadataprovider), which asks whether the declaring and target entity types share include support and files the navigation as supported or unsupported accordingly (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/NavigationMetadataProvider.cs:96-99`). Two entities in the same physical source stay on `.Include()`; two entities split across sources cannot be joined, and only a second batched query can hydrate the relationship ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html), [ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html), [ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html)). Since source assignment is configuration, this file is a no-op on a topology where the two entities live together and becomes the hydration path on one where they do not, with no change to the controller or the query service. `[Rubric §3, Clean Architecture]`: the Application layer states hydration as property names and key selectors, with no EF Core namespace anywhere in the file. +- **Where it's used**: registered as `services.TryAddScoped, SpeakerCategoryItemNavigationPopulator>()` (`MMCA.ADC.Conference.Application/DependencyInjection.cs:110`), directly above the closed generic [`EntityQueryService`](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype) registration for the same entity (`:111`), which is the pairing that puts it on every direct read of a speaker category item ([ADR-034](https://ivanball.github.io/docs/adr/034-generic-entity-query-layer.html)); that query service is injected into [`SpeakerCategoryItemsController`](group-20-conference-api-grpc.md#speakercategoryitemscontroller) (`MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:49`). Covered by [`SpeakerCategoryItemNavigationPopulatorTests`](group-27-testing-infrastructure.md#speakercategoryitemnavigationpopulatortests), which pins the type to `INavigationPopulator` and asserts both empty-input short circuits (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/SpeakerCategoryItemNavigationPopulatorTests.cs:9`, `:20`, `:24`, `:35`). +- **Caveats / not-in-source**: [`SpeakerCategoryItemDTO`](group-17-conference-domain.md#speakercategoryitemdto) carries only `Id`, `SpeakerId`, and `CategoryItemId` (`MMCA.ADC.Conference.Shared/Speakers/SpeakerCategoryItemDTO.cs:8-18`), so nothing this populator hydrates reaches the API response on the read path today: the descriptor exists for the shape of the entity, not for a field a caller currently sees. The descriptor list also covers one side of the join only, `Speaker` and not `CategoryItem`, and the file gives no reason for the asymmetry. ### SpeakerEntityQueryService > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers` · `MMCA.ADC.Conference.Application/Speakers/SpeakerEntityQueryService.cs:15` · Level 10 · class (sealed) -- **What it is**: the only subclass of the framework's generic query service in the whole Conference module. It adds exactly one thing to [`EntityQueryService`](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype): a one-entry map that teaches the read pipeline how to filter and sort by `FullName`, a name that exists on the DTO and on the entity but in no database column (`MMCA.ADC.Conference.Application/Speakers/SpeakerEntityQueryService.cs:11-13`). -- **Depends on**: [`EntityQueryService`](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype) closed over [`Speaker`](group-17-conference-domain.md#speaker) / [`SpeakerDTO`](group-17-conference-domain.md#speakerdto) / `SpeakerIdentifierType` (`:21-22`), and the five collaborators it forwards to the base unchanged (`:15-20`): [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`INavigationMetadataProvider`](group-03-querying-specifications.md#inavigationmetadataprovider), [`IEntityQueryPipeline`](group-03-querying-specifications.md#ientityquerypipeline), [`SpeakerDTOMapper`](#speakerdtomapper), and [`INavigationPopulator`](group-11-navigation-populators.md#inavigationpopulatorin-tentity) closed over `Speaker` (satisfied at runtime by [`SpeakerNavigationPopulator`](#speakernavigationpopulator)). `SpeakerIdentifierType` is the module alias for `System.Guid` (`MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:18`), because speakers carry Sessionize-assigned GUIDs (BR-61, `:3`). -- **Concept introduced, the DTO-to-entity property map**: a REST client filters and sorts using the vocabulary of the *DTO* it receives, but the query runs against the *entity*. Most names line up; `FullName` does not. On the entity it is a computed, get-only property, `public string FullName => $"{FirstName} {LastName}"` (`MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:61`), and the EF configuration explicitly removes it from the model with `builder.Ignore(p => p.FullName)` (`MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SpeakerConfiguration.cs:67-68`), so there is nothing named `FullName` for SQL to order by or match against. The map closes that gap by pairing the DTO name with a **Dynamic LINQ expression over the two real columns** rather than with a property path. `[Rubric §12, Performance and Scalability]` assesses whether reads stay translatable and server-side: the alternative to this one dictionary entry is fetching every speaker and matching the search box in memory, which the paged endpoint could not do. `[Rubric §9, API and Contract Design]` assesses whether the contract a caller sees is coherent: callers filter by the field they were served, and the translation stays a server concern. `[Rubric §11, Security]` is relevant too, and the framework is explicit about why: map entries are accepted unconditionally during validation precisely because they are **server-authored** and never client-supplied (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/QueryFieldService.cs:268-274`, `:318-319`); a name the server never mapped still has to survive reflection against the entity, so a client cannot inject an expression of its own. -- **Walkthrough**: the whole class is twenty lines, and all of it is configuration. - - The primary constructor takes the five services and forwards them positionally to the base constructor (`:15-22`). Note that the mapper parameter is the concrete [`SpeakerDTOMapper`](#speakerdtomapper), not the `IEntityDTOMapper<,,>` the base declares (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:35`): the subclass names the implementation and lets the compiler widen it, which is how DI resolves the Mapperly-generated mapper by its own type. +- **What it is**: the only subclass of the framework's generic query service in the whole Conference module. It adds exactly one thing to [`EntityQueryService`](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype): a one-entry map that teaches the read pipeline how to filter and sort by `FullName`, a name that exists on the DTO and on the entity but in no database column (`MMCA.ADC.Conference.Application/Speakers/SpeakerEntityQueryService.cs:11-14`). +- **Depends on**: [`EntityQueryService`](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype) closed over [`Speaker`](group-17-conference-domain.md#speaker) / [`SpeakerDTO`](group-17-conference-domain.md#speakerdto) / `SpeakerIdentifierType` (`:21-22`), and the five collaborators it forwards to the base unchanged (`:15-20`): [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`INavigationMetadataProvider`](group-03-querying-specifications.md#inavigationmetadataprovider), [`IEntityQueryPipeline`](group-03-querying-specifications.md#ientityquerypipeline), [`SpeakerDTOMapper`](#speakerdtomapper), and [`INavigationPopulator`](group-11-navigation-populators.md#inavigationpopulatorin-tentity) closed over `Speaker` (satisfied at runtime by [`SpeakerNavigationPopulator`](#speakernavigationpopulator)). +- **Concept introduced, the DTO-to-entity property map**: a REST client filters and sorts using the vocabulary of the *DTO* it received, but the query runs against the *entity*. Most names line up; `FullName` does not. On the entity it is a computed, get-only property, `public string FullName => $"{FirstName} {LastName}"` (`MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:61`), and the EF configuration explicitly removes it from the model with `builder.Ignore(p => p.FullName)` (`MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SpeakerConfiguration.cs:67-68`), so there is nothing named `FullName` for SQL to order by or match against. The map closes that gap by pairing the DTO name with a **Dynamic LINQ expression over the two real columns** rather than with a property path. `[Rubric §12, Performance and Scalability]` assesses whether reads stay translatable and server-side: the alternative to this one dictionary entry is fetching every speaker and matching the search box in memory, which a paged endpoint cannot do correctly. `[Rubric §9, API and Contract Design]` assesses whether the contract a caller sees is coherent: callers filter by the field they were served, and the translation stays a server concern. `[Rubric §11, Security]` is relevant too, and the framework is explicit about why: map entries are accepted unconditionally during field validation precisely because they are **server-authored**, never client-supplied (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/QueryFieldService.cs:329`, `:377-379`), while any name the server never mapped still has to survive reflection against the entity, so a client cannot inject an expression of its own. +- **Walkthrough**: the whole class is twenty-one lines, and all of it is configuration. + - The primary constructor takes the five services and forwards them positionally to the base (`:15-22`). Note that the mapper parameter is the concrete [`SpeakerDTOMapper`](#speakerdtomapper), not the [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) the base declares (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:31-36`): the subclass names the implementation and lets the compiler widen it, which is how DI resolves the Mapperly-generated mapper by its own type. - `PropertyMap` is a `private static readonly IReadOnlyDictionary` with one entry: `[nameof(SpeakerDTO.FullName)] = "(FirstName + \" \" + LastName)"` (`:28-31`). Using `nameof` keys the map to the DTO property (`MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:24`), so renaming it breaks the build instead of silently breaking a sort. The outer parentheses in the value are load-bearing, and the next bullet shows why. - `DTOToEntityPropertyMap` overrides the base's virtual, empty default and returns that static instance (`:34`). One allocation for the process, not one per request. - - What the base then does with it is the interesting half. The value flows into three places: validation of the sort column and of every filter (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:225`, `:227`), and the `EntityQueryParameters` handed to the pipeline (`:436`). On the filter path, [`QueryFilterService`](group-03-querying-specifications.md#queryfilterservice) resolves the incoming key `FullName` through the map to the expression (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Filtering/QueryFilterService.cs:84-86`), resolves a `PropertyInfo` for the *DTO-facing* name so type resolution still works (`:88`, `:223-229`, which finds the computed `Speaker.FullName` and types the filter as a string), and hands the expression to [`StringFilterStrategy`](group-03-querying-specifications.md#stringfilterstrategy) (`:95-97`). A `CONTAINS` there becomes `query.Where("(FirstName + \" \" + LastName).Contains(@0)", value)` (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Filtering/StringFilterStrategy.cs:23`), which is a plain concatenation predicate EF Core translates to SQL over the two real columns. Drop the parentheses from the map value and the same template would read `FirstName + " " + LastName.Contains(@0)`, a different expression entirely. On the sort path, [`QueryFieldService`](group-03-querying-specifications.md#queryfieldservice)`.ApplySorting` substitutes the mapped expression before appending the direction and calls Dynamic LINQ `OrderBy` (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/QueryFieldService.cs:144-153`). - - Everything else this service can do is inherited untouched: parameter validation, the keyed by-id fast path that skips the dynamic-filter pipeline for a plain primary-key read (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:80-100`, asserted for speakers in `MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/SpeakerEntityQueryServiceTests.cs:180-199`), pagination metadata, and field shaping. -- **Why it's built this way**: the framework offers an override hook rather than a configuration file or an attribute, so the mapping lives in the module that owns the vocabulary and costs nothing for the entities that do not need one. That is visible in the registration block: [`Speaker`](group-17-conference-domain.md#speaker) gets this subclass while [`Event`](group-17-conference-domain.md#event), [`Session`](group-17-conference-domain.md#session), [`Category`](group-17-conference-domain.md#category), [`Question`](group-17-conference-domain.md#question), and [`Sponsor`](group-17-conference-domain.md#sponsor) all register the closed generic base directly (`MMCA.ADC.Conference.Application/DependencyInjection.cs:55`, `:59`, `:67`, `:72`, `:76`). `[Rubric §16, Maintainability]`: the delta between "standard entity" and "entity with a computed sort field" is one dictionary entry. -- **Where it's used**: registered as `services.TryAddScoped, SpeakerEntityQueryService>()` (`MMCA.ADC.Conference.Application/DependencyInjection.cs:63`) and injected into [`SpeakersController`](group-20-conference-api-grpc.md#speakerscontroller) as the interface (`MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:45`), whose paged action passes the caller's filters and sort straight through alongside the BR-239 public-visibility specification (`:178-189`). Both speaker grids drive it with exactly this vocabulary: the organizer list sends `filters["FullName"] = ("contains", _searchString)` and sorts by `"FullName"` (`MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerList.razor.cs:145`, `:158`), and the public list does the same (`MMCA.ADC.Conference.UI/Pages/Public/PublicSpeakerList.razor.cs:179`, `:192`). The unit tests pin the contract, asserting that the captured pipeline parameters carry the `FullName` key mapped to the exact entity expression (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/SpeakerEntityQueryServiceTests.cs:89-105`) and that an unmapped, unknown filter property fails validation with `Filter.Property.NotFound` before the pipeline is touched (`:109-128`). + - What the base does with it is the interesting half. The value flows into three places: validation of the sort column and of every filter (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:264`, `:266`), and the [`EntityQueryParameters`](group-03-querying-specifications.md#entityqueryparameterstentity) handed to the pipeline (`:295`, and `:527` on the by-id path). On the filter path, [`QueryFilterService`](group-03-querying-specifications.md#queryfilterservice) resolves the incoming key `FullName` through the map to the expression, resolves a `PropertyInfo` for the *DTO-facing* name so type resolution still works, and hands the expression to the string strategy (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Filtering/QueryFilterService.cs:84-97`). A `CONTAINS` there becomes `query.Where("(FirstName + \" \" + LastName).Contains(@0)", value)` (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Filtering/StringFilterStrategy.cs:23`), a plain concatenation predicate EF Core translates to SQL over the two real columns. Drop the parentheses from the map value and the same template would read `FirstName + " " + LastName.Contains(@0)`, a different expression entirely. On the sort path, [`QueryFieldService`](group-03-querying-specifications.md#queryfieldservice)`.ApplySorting` resolves the column through the map before appending the direction and the server-supplied tie-break key, then calls Dynamic LINQ `OrderBy` (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/QueryFieldService.cs:163-169`, `:190-201`). + - Everything else this service can do is inherited untouched: parameter validation, the by-id fast path, pagination metadata, field shaping, and the navigation-population step that invokes `NavigationPopulator.PopulateAsync` as a delegate handed down to the pipeline (`EntityQueryService.cs:320`, `:534`). +- **Why it's built this way**: the framework offers an override hook rather than a configuration file or an attribute, so the mapping lives in the module that owns the vocabulary and costs nothing for entities that need none. That is visible in the registration block: [`Speaker`](group-17-conference-domain.md#speaker) gets this subclass (`MMCA.ADC.Conference.Application/DependencyInjection.cs:67`) while [`Sponsor`](group-17-conference-domain.md#sponsor) and [`SpeakerCategoryItem`](group-17-conference-domain.md#speakercategoryitem) register the closed generic base directly (`:85`, `:111`). `[Rubric §16, Maintainability]`: the delta between "standard entity" and "entity with a computed sort field" is one dictionary entry. +- **Where it's used**: registered as `services.TryAddScoped, SpeakerEntityQueryService>()` (`MMCA.ADC.Conference.Application/DependencyInjection.cs:67`) and injected into [`SpeakersController`](group-20-conference-api-grpc.md#speakerscontroller) as the interface (`MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:45`). Both speaker grids drive it with exactly this vocabulary: the organizer list sends `filters["FullName"] = ("contains", _searchString)` and sorts by `"FullName"` (`MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerList.razor.cs:145`, `:158`), and the public list does the same (`MMCA.ADC.Conference.UI/Pages/Public/PublicSpeakerList.razor.cs:275`, `:177`). [`SpeakerEntityQueryServiceTests`](group-27-testing-infrastructure.md#speakerentityqueryservicetests) pins the contract, asserting that the captured pipeline parameters carry the `FullName` key mapped to the exact entity expression (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/SpeakerEntityQueryServiceTests.cs:17`, `:89-104`) and that an unmapped, unknown filter property fails validation before the pipeline is touched (`:109`). - **Caveats / not-in-source**: two edges are worth knowing. - - `FullName` is filterable and sortable but **not** requestable as a shaped field. The `fields` parameter is validated through the overload that takes no map (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:224`), so `?fields=FullName` is rejected as read-only (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/QueryFieldService.cs:335-341`), which is consistent with server-side projection being restricted to writable properties (`:161-163`). - - `PropertyMap` is built with the default (ordinal, case-sensitive) comparer (`:28`), while the base's empty default uses `StringComparer.OrdinalIgnoreCase` (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:61`). Only the exact key `FullName` hits the map. A lowercase filter key misses it and is then rejected cleanly, because filter property lookup is case-sensitive (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Filtering/QueryFilterService.cs:241`). A lowercase *sort* column behaves differently: sort validation resolves names case-insensitively (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/QueryFieldService.cs:322`) and so does `ApplySorting`'s unmapped fallback (`:146-148`), so the request passes validation and Dynamic LINQ receives the bare, EF-ignored `FullName` instead of the mapped expression. What the database layer does with that is not exercised anywhere in this repository: not determinable from source. Every caller in the codebase sends the exact-case key. + - `FullName` is filterable and sortable but **not** requestable as a shaped field. The `fields` parameter is validated through the overload that takes no map (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:263`, resolving to `QueryFieldService.cs:317-318`), so `?fields=FullName` never reaches the map and is rejected as a read-only property, which is consistent with server-side projection being restricted to writable properties (`QueryFieldService.cs:306-311`). + - `PropertyMap` is built with the default (ordinal, case-sensitive) comparer (`:28`), while the base's empty default uses `StringComparer.OrdinalIgnoreCase` (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:100`). Only the exact key `FullName` hits the map. A lowercase *filter* key misses it and is then rejected cleanly, because filter property lookup reflects case-sensitively (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Filtering/QueryFilterService.cs:241`). A lowercase *sort* column behaves differently: sort validation matches property names case-insensitively (`QueryFieldService.cs:381-382`) and the unmapped fallback in `ResolveSortExpression` resolves with `BindingFlags.IgnoreCase` (`:199-201`), so the request passes validation and Dynamic LINQ receives the bare, EF-ignored `FullName` instead of the mapped expression. What the database layer does with that is not exercised anywhere in this repository: not determinable from source. Every caller in the codebase sends the exact-case key. ### SpeakerNavigationPopulator > MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers` · `MMCA.ADC.Conference.Application/Speakers/SpeakerNavigationPopulator.cs:11` · Level 10 · class (sealed) -- **What it is**: the declarative navigation populator for the [`Speaker`](group-17-conference-domain.md#speaker) aggregate. It declares how to hydrate the two child collections the read path may not be able to materialize through `.Include()`, `SpeakerCategoryItems` and `SpeakerQuestionAnswers` (`MMCA.ADC.Conference.Application/Speakers/SpeakerNavigationPopulator.cs:7-9`). Like its siblings, the class body is empty (`:30-31`): everything it does is expressed as data passed to its base constructor. -- **Depends on**: [`DeclarativeNavigationPopulator`](group-11-navigation-populators.md#declarativenavigationpopulatortentity) closed over `Speaker` (`:13`), [`ChildNavigationDescriptor`](group-11-navigation-populators.md#childnavigationdescriptortentity-tparentid-tchild-tchildid) (`:15`, `:22`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (forwarded straight to the base, `:12-13`), and the [`Speaker`](group-17-conference-domain.md#speaker), [`SpeakerCategoryItem`](group-17-conference-domain.md#speakercategoryitem), and [`SpeakerQuestionAnswer`](group-17-conference-domain.md#speakerquestionanswer) entities. -- **Concept reinforced, declarative child loading**: the mechanism is taught in [Group 11](group-11-navigation-populators.md#declarativenavigationpopulatortentity) and in this group on [`ConferenceCategoryNavigationPopulator`](#conferencecategorynavigationpopulator) and [`EventNavigationPopulator`](#eventnavigationpopulator) ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html)); this class is pure binding, with two descriptors instead of one or three. `[Rubric §2, Design Patterns]`: Template Method configured by data, so adding a child collection is a descriptor, not a new query method. `[Rubric §3, Clean Architecture]`: the Application layer states hydration as key selectors and property names, with no EF Core namespace anywhere in the file. +- **What it is**: the declarative navigation populator for the [`Speaker`](group-17-conference-domain.md#speaker) aggregate. It declares how to hydrate the two child collections the read path may not be able to materialize through `.Include()`, `SpeakerCategoryItems` and `SpeakerQuestionAnswers` (`MMCA.ADC.Conference.Application/Speakers/SpeakerNavigationPopulator.cs:7-10`). Like its siblings, the class body is empty (`:23-24`). +- **Depends on**: [`DeclarativeNavigationPopulator`](group-11-navigation-populators.md#declarativenavigationpopulatortentity) closed over `Speaker` (`:13`); [`ChildNavigationDescriptor`](group-11-navigation-populators.md#childnavigationdescriptortentity-tparentid-tchild-tchildid) (`:15`, `:22`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), forwarded straight to the base (`:12-13`); and the [`Speaker`](group-17-conference-domain.md#speaker), [`SpeakerCategoryItem`](group-17-conference-domain.md#speakercategoryitem), and [`SpeakerQuestionAnswer`](group-17-conference-domain.md#speakerquestionanswer) entities. +- **Concept reinforced, the collection direction**: the mechanism is taught in [Group 11](group-11-navigation-populators.md#declarativenavigationpopulatortentity) and, for the reference direction, by [`SpeakerCategoryItemNavigationPopulator`](#speakercategoryitemnavigationpopulator) above ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html)). A `ChildNavigationDescriptor` inverts the FK direction: it reads the *parent's* primary key, matches it against a foreign key the *children* hold, and assigns the whole list rather than one element. It also declares `RequiresChildren => true` (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/ChildNavigationDescriptor.cs:25`), so both descriptors here are gated on the caller's `includeChildren` argument and stay dormant on a read that only asked for FK references. `[Rubric §2, Design Patterns]`: Template Method configured by data, so adding a child collection is a descriptor, not a new query method. `[Rubric §3, Clean Architecture]`: the Application layer states hydration as key selectors and property names, with no EF Core namespace in the file. - **Walkthrough**: two descriptors, both keyed on `Speaker.Id` against the child's `SpeakerId`, each supplying the same four settings. | Descriptor | File:Line | Property, parent key, child FK, assign | @@ -3558,12 +4078,32 @@ strategy so it can evolve, be tested, and ultimately be extracted without distur | `ChildNavigationDescriptor` | `MMCA.ADC.Conference.Application/Speakers/SpeakerNavigationPopulator.cs:15-21` | `nameof(Speaker.SpeakerCategoryItems)`, `e => e.Id`, `child => child.SpeakerId`, `SetSpeakerCategoryItems` | | `ChildNavigationDescriptor` | `MMCA.ADC.Conference.Application/Speakers/SpeakerNavigationPopulator.cs:22-28` | `nameof(Speaker.SpeakerQuestionAnswers)`, `e => e.Id`, `child => child.SpeakerId`, `SetSpeakerQuestionAnswers` | - - The generic quartet is worth reading closely, because Speaker is the one Conference aggregate whose key is not an `int`: the parent key type is `SpeakerIdentifierType` (`System.Guid`) while both child keys are `int` (`MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:17-19`). The descriptor keeps the two apart as separate type parameters, so the batch load compares GUID to GUID (`child => child.SpeakerId` is typed `SpeakerIdentifierType` on both children: `MMCA.ADC.Conference.Domain/Speakers/SpeakerCategoryItem.cs:23`, `MMCA.ADC.Conference.Domain/Speakers/SpeakerQuestionAnswer.cs:26`) while still resolving each child's own read repository by its own key type. - - Both `AssignAction` targets go through the aggregate's own mutators, `SetSpeakerCategoryItems` (`MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:388-389`) and `SetSpeakerQuestionAnswers` (`:465-466`), each a thin delegation to the framework's `SetItems` over the private backing list. Both are `internal`, reachable from here only because the Domain project grants `` (`MMCA.ADC.Conference.Domain/MMCA.ADC.Conference.Domain.csproj:3`). The collections themselves are exposed as `IReadOnlyCollection<>` over private lists (`Speaker.cs:63-73`), so no other assembly can replace them. - - The base owns the algorithm, and its guards decide when any of this runs. `PopulateAsync` returns immediately when there are no entities or no unsupported includes, then loads only descriptors whose `PropertyName` appears in `UnsupportedIncludes` (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/DeclarativeNavigationPopulator.cs:27-41`). Because `ChildNavigationDescriptor.RequiresChildren` is hard-coded `true` (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/ChildNavigationDescriptor.cs:25`), both are gated on `includeChildren`, matching the `[Navigation(IsCollection = true)]` attributes that put them in the child-collection bucket during metadata discovery (`MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:66`, `:72`). The load itself is one batched `WHERE childFK IN (...parentIds)` query per descriptor via [`NavigationLoader`](group-11-navigation-populators.md#navigationloader)`.LoadChildrenPropertyAsync` (`ChildNavigationDescriptor.cs:41-47`), not one query per speaker. -- **Why it's built this way**: the classification that triggers this code is made per navigation by [`NavigationMetadataProvider`](group-03-querying-specifications.md#navigationmetadataprovider), which asks whether the declaring and target entity types share include support and files the navigation as supported or unsupported accordingly (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/NavigationMetadataProvider.cs:96-99`). Two entities in the same physical source are joinable and stay on `.Include()`; two entities split across sources are not, and only manual batch loading can hydrate them ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html), [ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html), [ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html)). Since data-source assignment is configuration, the same populator is a no-op on a topology where speakers and their children live together and becomes the hydration path on one where they do not, with no change to the controller, the query service, or the DTO mapper. `[Rubric §7, Microservices Readiness]` assesses whether the code survives a physical split: this file is the survival kit. `[Rubric §8, Data Architecture]`: the parent-child link is expressed as a scalar FK plus a batched key lookup, which is what a cross-source relationship degrades to. -- **Where it's used**: registered as `services.TryAddScoped, SpeakerNavigationPopulator>()` (`MMCA.ADC.Conference.Application/DependencyInjection.cs:62`), injected into [`SpeakerEntityQueryService`](#speakerentityqueryservice), and invoked by the read pipeline as the `NavigationPopulator.PopulateAsync` delegate the base query service passes down (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:443`). Compare the entities that need none: `Question`, `Sponsor`, `Room`, and `CategoryItem` all register [`NullNavigationPopulator`](group-11-navigation-populators.md#nullnavigationpopulatortentity) instead (`MMCA.ADC.Conference.Application/DependencyInjection.cs:71`, `:75`, `:80`, `:83`). Its unit tests assert the type binds to `INavigationPopulator` and that the empty-input guards complete without touching the unit of work (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/SpeakerNavigationPopulatorTests.cs:15-43`). -- **Caveats / not-in-source**: the descriptors cover child collections only. `Speaker.LinkedUserId` is a scalar cross-module reference to Identity's `User` (`MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:58`) and is deliberately not a navigation here, so nothing in this file hydrates it; the linked user is resolved by the Identity service, not by this populator. Nothing in the descriptors filters soft-deleted children either: that exclusion comes from the EF global query filter applied by the read repository the loader resolves ([ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html)). + - The generic quartet is worth reading closely, because the parent key and the child keys are different types here: `TParentId` is `SpeakerIdentifierType` (`System.Guid`) while both `TChildId` values are `int` (`MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:18-20`). The descriptor keeps the two apart as separate type parameters, so the batch load compares GUID to GUID (`child => child.SpeakerId` is typed `SpeakerIdentifierType` on both children: `MMCA.ADC.Conference.Domain/Speakers/SpeakerCategoryItem.cs:23`, `MMCA.ADC.Conference.Domain/Speakers/SpeakerQuestionAnswer.cs:26`) while each child's own read repository is still resolved by its own `int` key (`ChildNavigationDescriptor.cs:41-47`). + - Both `AssignAction` targets go through the aggregate's own mutators, `SetSpeakerCategoryItems` (`MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:400-401`) and `SetSpeakerQuestionAnswers` (`:477-478`), each a thin delegation to the framework's `SetItems` over the private backing list. Both are `internal`, reachable from here only because the Domain project grants `` (`MMCA.ADC.Conference.Domain/MMCA.ADC.Conference.Domain.csproj:3`). The collections themselves are exposed as `IReadOnlyCollection<>` over private lists (`Speaker.cs:63-73`), so no other assembly can replace them. `[Rubric §4, Domain-Driven Design]`: hydration passes through the same door a business operation would, not a back-door property write. + - The base owns the algorithm and its guards decide when any of this runs. `PopulateAsync` returns immediately when there are no entities or no unsupported includes, then loads only descriptors whose `PropertyName` appears in `UnsupportedIncludes` (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/DeclarativeNavigationPopulator.cs:27-41`). Both names match `[Navigation(IsCollection = true)]` attributes on the aggregate (`Speaker.cs:66`, `:72`), which is what puts them in the child-collection bucket during metadata discovery. The load itself is one batched `WHERE childFK IN (...parentIds)` query per descriptor via [`NavigationLoader`](group-11-navigation-populators.md#navigationloader), not one query per speaker. +- **Why it's built this way**: the same cross-source degradation rule as its siblings ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html), [ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)): when a relationship can span physical data sources, EF's navigation is stripped and only the scalar foreign key survives, so hydration has to be a second batched query rather than an `Include` (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/NavigationMetadataProvider.cs:96-99`). `[Rubric §7, Microservices Readiness]` assesses whether the code survives a physical split: this file is the survival kit for the speaker aggregate. `[Rubric §8, Data Architecture]`: the parent-child link degrades to a scalar FK plus a batched key lookup. +- **Where it's used**: registered as `services.TryAddScoped, SpeakerNavigationPopulator>()` (`MMCA.ADC.Conference.Application/DependencyInjection.cs:66`), immediately above the [`SpeakerEntityQueryService`](#speakerentityqueryservice) registration it is injected into (`:67`), and invoked by the read pipeline as the `NavigationPopulator.PopulateAsync` delegate the base query service passes down (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:320`, `:534`). Compare the entities that need no manual hydration at all: those register [`NullNavigationPopulator`](group-11-navigation-populators.md#nullnavigationpopulatortentity) instead. [`SpeakerNavigationPopulatorTests`](group-27-testing-infrastructure.md#speakernavigationpopulatortests) asserts the type binds to `INavigationPopulator` and that the empty-input guards complete without touching the unit of work (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/SpeakerNavigationPopulatorTests.cs:9`, `:20`, `:24`, `:35`). +- **Caveats / not-in-source**: the descriptors cover child collections only. `Speaker.LinkedUserId` is a scalar cross-module reference into the Identity module and is deliberately not a navigation here, so nothing in this file hydrates a linked user. Nothing in the descriptors filters soft-deleted children either: that exclusion comes from the EF global query filter applied by the read repository the loader resolves ([ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html)). The loader's queries are untracked (`MMCA.Common/Source/Core/MMCA.Common.Application/Services/NavigationLoader.cs:80-84`), which is why write handlers that need a tracked graph pass an explicit `includes` array to the repository instead of relying on this populator. + +### SpeakerQuestionAnswerNavigationPopulator + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Speakers` · `MMCA.ADC.Conference.Application/Speakers/SpeakerQuestionAnswerNavigationPopulator.cs:11` · Level 10 · class (sealed) + +- **What it is**: the navigation populator for [`SpeakerQuestionAnswer`](group-17-conference-domain.md#speakerquestionanswer) read as its own entity. One navigation: the parent `Speaker` back-reference (`MMCA.ADC.Conference.Application/Speakers/SpeakerQuestionAnswerNavigationPopulator.cs:7-10`). +- **Depends on**: [`DeclarativeNavigationPopulator`](group-11-navigation-populators.md#declarativenavigationpopulatortentity) closed over [`SpeakerQuestionAnswer`](group-17-conference-domain.md#speakerquestionanswer) (`:13`); [`FKNavigationDescriptor`](group-11-navigation-populators.md#fknavigationdescriptortentity-tchild-tchildid) (`:15`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:12`); the [`Speaker`](group-17-conference-domain.md#speaker) aggregate as the reference target. +- **Concept reinforced**: none new. Structurally identical to [`SpeakerCategoryItemNavigationPopulator`](#speakercategoryitemnavigationpopulator), which teaches the FK direction: the same closed generic over `Speaker` and `SpeakerIdentifierType` (`:15`), `PropertyName = nameof(SpeakerQuestionAnswer.Speaker)` (`:17`), `ParentKeySelector = e => e.SpeakerId` (`:18`), `ChildForeignKeySelector = child => child.Id` (`:19`), `AssignAction` ending in `FirstOrDefault()` (`:20`), and an empty class body (`:23-24`). The target property is the same settable, attributed navigation shape (`MMCA.ADC.Conference.Domain/Speakers/SpeakerQuestionAnswer.cs:22-23`). +- **Where it's used**: registered as `services.TryAddScoped, SpeakerQuestionAnswerNavigationPopulator>()` (`MMCA.ADC.Conference.Application/DependencyInjection.cs:115`). This is the one populator in the module with **no paired query service**, and the registration says so in a comment: the entity has no query service today, and registering the populator future-proofs the one that would be added alongside it (`:113-114`). Covered by [`SpeakerQuestionAnswerNavigationPopulatorTests`](group-27-testing-infrastructure.md#speakerquestionanswernavigationpopulatortests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/SpeakerQuestionAnswerNavigationPopulatorTests.cs:9`, `:20`, `:24`, `:35`). +- **Caveats / not-in-source**: because nothing resolves `INavigationPopulator` on a read path today, this class is registered but not exercised outside its unit tests. Answers still reach clients as part of a speaker read, through the child-collection descriptor on [`SpeakerNavigationPopulator`](#speakernavigationpopulator), which is a different code path entirely. + +### SponsorNavigationPopulator + +> MMCA.ADC.Conference.Application · `MMCA.ADC.Conference.Application.Sponsors` · `MMCA.ADC.Conference.Application/Sponsors/SponsorNavigationPopulator.cs:12` · Level 10 · class (sealed) + +- **What it is**: the navigation populator for the [`Sponsor`](group-17-conference-domain.md#sponsor) aggregate. One navigation, and it points *up*: the [`Event`](group-17-conference-domain.md#event) a sponsor belongs to (`MMCA.ADC.Conference.Application/Sponsors/SponsorNavigationPopulator.cs:7-11`). +- **Depends on**: [`DeclarativeNavigationPopulator`](group-11-navigation-populators.md#declarativenavigationpopulatortentity) closed over [`Sponsor`](group-17-conference-domain.md#sponsor) (`:14`); [`FKNavigationDescriptor`](group-11-navigation-populators.md#fknavigationdescriptortentity-tchild-tchildid) (`:16`); [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:13`); the [`Event`](group-17-conference-domain.md#event) aggregate as the reference target. +- **Concept reinforced**: none new; see [`SpeakerCategoryItemNavigationPopulator`](#speakercategoryitemnavigationpopulator) for the FK direction. What differs is only which key is read and which aggregate is fetched: `PropertyName = nameof(Sponsor.Event)` (`:18`), `ParentKeySelector = e => e.EventId` (`:19`, over the private-set FK at `MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:45`), `ChildForeignKeySelector = child => child.Id` (`:20`), `AssignAction = (e, events) => e.Event = events.FirstOrDefault()` (`:21`) writing the attributed navigation (`Sponsor.cs:48-49`), and an empty class body (`:24-25`). Worth noting the shape this reveals: [`Sponsor`](group-17-conference-domain.md#sponsor) is an aggregate root that owns no children of its own, which is why it needs exactly one descriptor and why its delete is the generic [`DeleteEntityHandler`](group-05-cqrs-pipeline.md#deleteentityhandlertentity-tidentifiertype) rather than a cascade handler (`MMCA.ADC.Conference.Application/DependencyInjection.cs:86`), even though it is itself swept up by [`DeleteEventHandler`](#deleteeventhandler) when its event goes away. +- **Where it's used**: registered as `services.TryAddScoped, SponsorNavigationPopulator>()` (`MMCA.ADC.Conference.Application/DependencyInjection.cs:84`), immediately above the closed generic [`EntityQueryService`](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype) registration for sponsors (`:85`), which is injected into [`SponsorsController`](group-20-conference-api-grpc.md#sponsorscontroller) (`MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:38`). Covered by [`SponsorNavigationPopulatorTests`](group-27-testing-infrastructure.md#sponsornavigationpopulatortests) (`MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sponsors/SponsorNavigationPopulatorTests.cs:9`, `:20`, `:24`, `:35`). +- **Caveats / not-in-source**: [`SponsorDTO`](group-17-conference-domain.md#sponsordto) carries `EventId` but no `Event` (`MMCA.ADC.Conference.Shared/Sponsors/SponsorDTO.cs:41-42`), and the sponsor mapper projects no event data, so what this populator hydrates does not reach an API response on the read path today. The descriptor keeps the entity self-consistent when a sponsor is materialized with FK includes; it is not currently what any client sees. --- diff --git a/docs-src/onboarding/group-19-conference-infrastructure.md b/docs-src/onboarding/group-19-conference-infrastructure.md index 275afe9..bf2e8d5 100644 --- a/docs-src/onboarding/group-19-conference-infrastructure.md +++ b/docs-src/onboarding/group-19-conference-infrastructure.md @@ -2,18 +2,19 @@ **What this chapter covers.** This is the **adapter** layer of the Conference module, the place where the engine-agnostic domain meets concrete technology. Three concerns live here: (1) **persistence -mapping**, the 16 EF Core entity configurations that turn plain domain classes into SQL Server tables, +mapping**, the 17 EF Core entity configurations that turn plain domain classes into SQL Server tables, the abstract `DbContext` that declares the module's `DbSet`s, and the seeder that puts the real conference events and feedback questions into a fresh database; (2) **outbound integration and background work**, the HTTP clients that talk to **Sessionize** (the conference's session-submission -platform) and to the **Anthropic Claude API** (the AI session scorer), plus the hosted worker that -drains the scoring queue off the request path; and (3) the **DI wiring** that registers those services -with the right resilience policy. It is the per-module realization of Clean Architecture's ports and -adapters idea: the [Application](group-18-conference-application.md) layer declares the ports +platform) and to the **Anthropic Claude API** (the AI session scorer), the hosted worker that drains +the scoring queue off the request path, and the cron job that re-queues a scoring pass a crash cut in +half; and (3) the **DI wiring** that registers those services with the right resilience policy. It is +the per-module realization of Clean Architecture's ports and adapters idea: the +[Application](group-18-conference-application.md) layer declares the ports ([`ISessionizeService`](group-18-conference-application.md#isessionizeservice), [`IAiScoringService`](group-18-conference-application.md#iaiscoringservice), [`SessionScoringQueue`](group-18-conference-application.md#sessionscoringqueue)), and this -Infrastructure layer supplies the adapters and the runner. `[Rubric §3, Clean Architecture]` assesses +Infrastructure layer supplies the adapters and the runners. `[Rubric §3, Clean Architecture]` assesses whether dependencies point inward and the domain stays framework-free; here every EF, HTTP, and Anthropic concern is quarantined in Infrastructure, so the domain entities in [Group 17](group-17-conference-domain.md) carry no persistence or transport attribute at all. @@ -23,14 +24,16 @@ Anthropic concern is quarantined in Infrastructure, so the domain entities in The most important idea in this chapter is one the entities themselves never express: **what storage engine each entity uses is decided here, not in the domain.** A Conference domain entity, [`Session`](group-17-conference-domain.md#session), [`Speaker`](group-17-conference-domain.md#speaker), -[`Event`](group-17-conference-domain.md#event), [`Sponsor`](group-17-conference-domain.md#sponsor), the -join entities, is a plain class. The *only* thing that binds it to SQL Server is which base class its -configuration inherits from. All 16 configs in this group -([`SessionConfiguration`](#sessionconfiguration), [`SpeakerConfiguration`](#speakerconfiguration), -[`EventConfiguration`](#eventconfiguration), [`SponsorConfiguration`](#sponsorconfiguration), and the -rest) derive from +[`Event`](group-17-conference-domain.md#event), [`Sponsor`](group-17-conference-domain.md#sponsor), +[`Activity`](group-17-conference-domain.md#activity), the join entities, is a plain class. The *only* +thing that binds it to SQL Server is which base class its configuration inherits from. All 17 configs in +this group ([`SessionConfiguration`](#sessionconfiguration), +[`SpeakerConfiguration`](#speakerconfiguration), [`EventConfiguration`](#eventconfiguration), +[`SponsorConfiguration`](#sponsorconfiguration), [`ActivityConfiguration`](#activityconfiguration), and +the rest) derive from [`EntityTypeConfigurationSQLServer`](group-07-persistence-ef-core.md#entitytypeconfigurationsqlservertentity-tidentifiertype) -(for example `MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SessionConfiguration.cs:12-13`), +(for example `MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SessionConfiguration.cs:12-13` +and `MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/ActivityConfiguration.cs:11-12`), which is a thin shim carrying `[UseDataSource(DataSource.SQLServer)]` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/EntityTypeConfigurationSQLServer.cs:16-17`) over the engine-neutral @@ -47,12 +50,13 @@ the dominant lens for the whole persistence half of this chapter. ## Each config inherits the cross-cutting behavior, then adds entity specifics Every configuration's `Configure` method begins with `base.Configure(builder)` (for example -`SessionConfiguration.cs:18`) and *then* adds its own mappings. That one `base` call is where the -framework injects the conventions applied uniformly: the strongly-typed key, the table name and module -schema, and the concurrency token, none of which any individual config re-states. The per-entity bodies -then declare what is unique: column lengths sourced from the domain's invariant constants -(`SessionInvariants.TitleMaxLength` at `SessionConfiguration.cs:20-22`, `EventInvariants.NameMaxLength` -at `EventConfiguration.cs:19-21`, `SponsorInvariants.NameMaxLength` at `SponsorConfiguration.cs:19-21`), +`SessionConfiguration.cs:18`, `ActivityConfiguration.cs:17`) and *then* adds its own mappings. That one +`base` call is where the framework injects the conventions applied uniformly: the strongly-typed key, +the table name and module schema, and the concurrency token, none of which any individual config +re-states. The per-entity bodies then declare what is unique: column lengths sourced from the domain's +invariant constants (`SessionInvariants.TitleMaxLength` at `SessionConfiguration.cs:20-22`, +`EventInvariants.NameMaxLength` at `EventConfiguration.cs:19-21`, `SponsorInvariants.NameMaxLength` at +`SponsorConfiguration.cs:19-21`, `ActivityInvariants.NameMaxLength` at `ActivityConfiguration.cs:19-21`), required and optional flags, computed properties excluded with `builder.Ignore(...)` (`Session.Duration` at `SessionConfiguration.cs:67`, `Speaker.FullName` at `SpeakerConfiguration.cs:68`), value conversions (`Speaker.Email` round-trips through @@ -67,21 +71,24 @@ and a 100-character `ModelUsed`, `SessionAiScoreConfiguration.cs:50-56`). learning because it is easy to misread. Unique indexes on a soft-deletable entity get the `IsDeleted = 0` predicate **automatically**, applied by [`SoftDeleteUniqueIndexConvention`](group-07-persistence-ef-core.md#softdeleteuniqueindexconvention) -(`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/SoftDeleteUniqueIndexConvention.cs:43-51`), +(`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/SoftDeleteUniqueIndexConvention.cs:43-54`), so a soft-deleted link never blocks a re-insert; [`CategoryItemConfiguration`](#categoryitemconfiguration) relies on exactly that and declares its unique (CategoryId, Name) index with no filter call at all (`CategoryItemConfiguration.cs:30-31`). A hand-authored **non-unique** index is deliberately left alone by the convention and opts in explicitly through [`IndexBuilderExtensions`](group-07-persistence-ef-core.md#indexbuilderextensions)`.HasSoftDeleteFilter()` -(`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/IndexBuilderExtensions.cs:19-30`), +(`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/IndexBuilderExtensions.cs:20-29`), which replaces the old literal `HasFilter("[IsDeleted] = 0")` by reading the column name from the model -and the quoting from the engine. Three lookup indexes here take that opt-in: `Session.EventId` -(`SessionConfiguration.cs:77-78`), `Sponsor.EventId` (`SponsorConfiguration.cs:67-68`), and -`EventQuestionAnswer.EventId` (`EventQuestionAnswerConfiguration.cs:35-36`). Several unique indexes also -call it explicitly for readability even though the convention would supply it: -[`SessionSpeakerConfiguration`](#sessionspeakerconfiguration)'s (SessionId, SpeakerId) pair -(`SessionSpeakerConfiguration.cs:30-32`), the one-score-per-session index on +and the quoting from the engine. Five lookup indexes here take that opt-in: `Session.EventId` +(`SessionConfiguration.cs:77-78`), `Sponsor.EventId` (`SponsorConfiguration.cs:67-68`), +`EventQuestionAnswer.EventId` (`EventQuestionAnswerConfiguration.cs:35-36`), and both of +[`ActivityConfiguration`](#activityconfiguration)'s, the plain `EventId` lookup +(`ActivityConfiguration.cs:58-59`) and the composite (EventId, StartTime, SortOrder) that serves the +public activities page's ordering directly instead of sorting an event slice in memory +(`ActivityConfiguration.cs:61-64`). Several unique indexes also call it explicitly for readability even +though the convention would supply it: [`SessionSpeakerConfiguration`](#sessionspeakerconfiguration)'s +(SessionId, SpeakerId) pair (`SessionSpeakerConfiguration.cs:30-32`), the one-score-per-session index on [`SessionAiScoreConfiguration`](#sessionaiscoreconfiguration) (`SessionAiScoreConfiguration.cs:59-61`), the equivalent pairs on [`EventSpeakerConfiguration`](#eventspeakerconfiguration) (`EventSpeakerConfiguration.cs:30-32`), @@ -94,32 +101,40 @@ the equivalent pairs on [`EventSpeakerConfiguration`](#eventspeakerconfiguration [`SessionQuestionAnswerConfiguration`](#sessionquestionanswerconfiguration) (`SessionQuestionAnswerConfiguration.cs:43-45`) and [`EventQuestionAnswerConfiguration`](#eventquestionanswerconfiguration) -(`EventQuestionAnswerConfiguration.cs:42-44`). Two configs declare no index at all and map columns only, -[`QuestionConfiguration`](#questionconfiguration) (`QuestionConfiguration.cs:10`) and -[`SpeakerQuestionAnswerConfiguration`](#speakerquestionanswerconfiguration) -(`SpeakerQuestionAnswerConfiguration.cs:10`). **Sparse** filters are a different thing again and stay -literal, because they filter on a nullable business column rather than on soft-delete: -`Speaker.LinkedUserId` is unique only where it is set (`SpeakerConfiguration.cs:63-65`, the -User-to-Speaker link), and `Event.SessionizeCode` is indexed only where present -(`EventConfiguration.cs:41-42`). Two further quirks are worth knowing: +(`EventQuestionAnswerConfiguration.cs:42-44`). + +Two indexes are deliberately **unfiltered**, and both carry a comment explaining why, because in each +case the filtered composite next to them is not a substitute. `RoomConfiguration` re-declares the +conventional foreign-key index on `EventId` (`RoomConfiguration.cs:46-48`) because EF drops it as +redundant once the composite (EventId, Name) index leads with the same column, while the foreign-key +lookups still want it. `SessionQuestionAnswerConfiguration` keeps its plain `SessionId` index +(`SessionQuestionAnswerConfiguration.cs:34-37`) because the Sessionize sync reads that table by +`SessionId` with the global query filters **off**, and a filtered index cannot serve a query that does +not carry the predicate. **Sparse** filters are a different thing again and stay literal, because they +filter on a nullable business column rather than on soft-delete: `Speaker.LinkedUserId` is unique only +where it is set (`SpeakerConfiguration.cs:63-65`, the User-to-Speaker link), and `Event.SessionizeCode` +is indexed only where present (`EventConfiguration.cs:41-42`). Two further quirks are worth knowing: [`ConferenceCategoryConfiguration`](#conferencecategoryconfiguration) calls -`ToTable("Category", "Conference")` explicitly (`ConferenceCategoryConfiguration.cs:24`) so the +`ToTable("Category", "Conference")` explicitly (`ConferenceCategoryConfiguration.cs:22-24`) so the Conference `Category` table cannot collide with another module's `Category`, and [`SessionConfiguration`](#sessionconfiguration) maps the Session-to-Room relationship with `OnDelete(DeleteBehavior.Restrict)` (`SessionConfiguration.cs:83-87`) so deleting a room can never -cascade sessions away. +cascade sessions away. Two configs declare no index at all and map columns only, +[`QuestionConfiguration`](#questionconfiguration) (`QuestionConfiguration.cs:10`) and +[`SpeakerQuestionAnswerConfiguration`](#speakerquestionanswerconfiguration) +(`SpeakerQuestionAnswerConfiguration.cs:10`). ## DbSets, the context shape, and how the configurations are actually found [`ModuleApplicationDbContext`](#moduleapplicationdbcontext) -(`MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:19`) is the -Conference module's abstract `DbContext`. It does one job: declare 14 `internal DbSet` properties +(`MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:20`) is the +Conference module's abstract `DbContext`. It does one job: declare 15 `internal DbSet` properties (`Events`, `Rooms`, `EventSpeakers`, `EventQuestionAnswers`, `Sessions`, `SessionSpeakers`, `SessionQuestionAnswers`, `SessionCategoryItems`, `Speakers`, `SpeakerCategoryItems`, `Categories`, -`CategoryItems`, `Questions`, `Sponsors`, at `ModuleApplicationDbContext.cs:27-66`). It is **abstract** -and inherits from the Common +`CategoryItems`, `Questions`, `Sponsors`, `Activities`, at `ModuleApplicationDbContext.cs:28-70`). It is +**abstract** and inherits from the Common [`ApplicationDbContext`](group-07-persistence-ef-core.md#applicationdbcontext) through its primary -constructor (`ModuleApplicationDbContext.cs:19-24`), from which it gets the real machinery: the +constructor (`ModuleApplicationDbContext.cs:20-25`), from which it gets the real machinery: the `SaveChangesAsync` override that stamps audit fields and captures domain events into the outbox, and the global soft-delete query filters applied to every auditable entity. The concrete class EF actually instantiates is the single [`SQLServerDbContext`](group-07-persistence-ef-core.md#sqlserverdbcontext) in @@ -131,53 +146,57 @@ A detail that surprises most readers: a `DbSet` is *not* what puts an entity in context walks the registered configuration assemblies and applies every `IEntityTypeConfigurationSQLServer<,>` implementation whose entity resolves to this context's data source key -(`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:610-636`, -with the engine-to-interface switch at `:612-618` and the registry filter at `:625-635`). That is why two +(`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:610-637`, +with the engine-to-interface switch at `:612-618` and the registry filter at `:625-636`). That is why two entities with a configuration here, [`SessionAiScore`](group-17-conference-domain.md#sessionaiscore) and [`SpeakerQuestionAnswer`](group-17-conference-domain.md#speakerquestionanswer), are mapped and queryable through the repository layer even though `ModuleApplicationDbContext` declares no `DbSet` for either: -16 configurations, 14 `DbSet`s, and the configurations win. `[Rubric §7, Microservices Readiness]` (can a +17 configurations, 15 `DbSet`s, and the configurations win. `[Rubric §7, Microservices Readiness]` (can a module become its own service without a rewrite?) is embodied here: the Conference module already runs as -`MMCA.ADC.Conference.Service` over its own `ADC_Conference` database with its own `dbo.OutboxMessages`, -and cross-module references (a speaker's linked user, a bookmark's session) are scalar columns resolved -via gRPC and integration events, never cross-database foreign keys. +`MMCA.ADC.Conference.Service` over its own `ADC_Conference` database +(`MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:33`) with its own outbox, and cross-module +references (a speaker's linked user, a bookmark's session) are scalar columns resolved via gRPC and +integration events, never cross-database foreign keys. ## Seeding: two real events always, sample data only in dev and CI [`ConferenceModuleDbSeeder`](#conferencemoduledbseeder) -(`MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/Seeding/ConferenceModuleDbSeeder.cs:24`) +(`MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/Seeding/ConferenceModuleDbSeeder.cs:25`) derives from the framework's [`DbSeeder`](group-07-persistence-ef-core.md#dbseeder) and runs after schema initialization, constructed by [`ConferenceModuleSeeder`](group-20-conference-api-grpc.md#conferencemoduleseeder) in the API layer (`MMCA.ADC.Conference.API/ConferenceModuleSeeder.cs:28`). It is idempotent: every step first issues an `ExistsAsync` check through the repository and returns early if the row is present -(`ConferenceModuleDbSeeder.cs:64-69`, `:98-103`, `:132-137`), which is what makes it safe to run on every +(`ConferenceModuleDbSeeder.cs:69-74`, `:103-108`, `:137-142`), which is what makes it safe to run on every startup under the production `Migrate` init strategy ([ADR-030](https://ivanball.github.io/docs/adr/030-startup-sole-migrator.html)). It **always** seeds three -things (`ConferenceModuleDbSeeder.cs:46-48`): the **2026 Atlanta Cloud + AI Conference** (2026-05-30, -`America/New_York`, Sessionize code `z1ecmzux`, `ConferenceModuleDbSeeder.cs:71-83`), the **2026 Atlanta -Developers Conference** (2026-10-17, Sessionize code `sf1nopko`, `ConferenceModuleDbSeeder.cs:105-117`), -both published immediately after creation (`:88` and `:122`) and both carrying the shared venue address, -map URL and their own published sponsorship-packet URL (`ConferenceModuleDbSeeder.cs:26-38`), and the +things (`ConferenceModuleDbSeeder.cs:50-52`): the **2026 Atlanta Cloud + AI Conference** (2026-05-30, +`America/New_York`, Sessionize code `z1ecmzux`, `ConferenceModuleDbSeeder.cs:76-88`), the **2026 Atlanta +Developers Conference** (2026-10-17, Sessionize code `sf1nopko`, `ConferenceModuleDbSeeder.cs:110-122`), +both published immediately after creation (`:93` and `:127`) and both carrying the shared venue address, +map URL and their own published sponsorship-packet URL (`ConferenceModuleDbSeeder.cs:27-42`), and the fixed set of **10 feedback questions** (5 session ratings plus a session comment, 3 conference ratings -plus a conference comment, `ConferenceModuleDbSeeder.cs:139-151`) whose ids start at +plus a conference comment, `ConferenceModuleDbSeeder.cs:144-156`) whose ids start at [`QuestionInvariants`](group-17-conference-domain.md#questioninvariants)`.ManualIdRangeStart` -(`ConferenceModuleDbSeeder.cs:153`) so they never collide with imported data. +(`ConferenceModuleDbSeeder.cs:158`) so they never collide with imported data. -It **conditionally** seeds four more things (`ConferenceModuleDbSeeder.cs:50-56`): two sample speakers -(Ada Lovelace and Alan Turing, `:179-183`), two sample sessions with app-assigned ids from +It **conditionally** seeds five more things (`ConferenceModuleDbSeeder.cs:54-61`): two sample speakers +(Ada Lovelace and Alan Turing, `:184-188`), two sample sessions with app-assigned ids from [`SessionInvariants`](group-17-conference-domain.md#sessioninvariants)`.ManualIdRangeStart`, one per -seeded event (`:236-240`, and the ids are explicit because a Session's int PK *is* its Sessionize id, so -the sample rows take a reserved range above any real one, `:232-235`), the EventSpeaker plus -SessionSpeaker links between them (`:305-306`), and four sample sponsors across the Platinum, Gold, Silver -and Community tiers, two of them exhibitors with booth numbers (`:344-350`). All of that runs only when -`includeSampleData` is set. The flag comes from `Seeding:IncludeSampleConferenceData` -(`MMCA.ADC.Conference.API/ConferenceModuleSeeder.cs:26`), which the local Aspire AppHost sets -(`MMCA.ADC.AppHost/Program.cs:162`) and production leaves unset. The reason is documented in the seeder's -own remarks (`ConferenceModuleDbSeeder.cs:16-23`): the public-browse E2E tests need at least one session -and one speaker row to exist deterministically, while production's real sessions and speakers arrive -through the Sessionize import. The links are created on *both* paths deliberately, so the direct -(EventSpeaker) and the transitive (SessionSpeaker) branches of the speakers-by-event filter are both -exercised in dev and CI (`ConferenceModuleDbSeeder.cs:302-304`). +seeded event (`:241-245`, and the ids are explicit because a Session's int PK *is* its Sessionize id, so +the sample rows take a reserved range above any real one, `:237-240`), the EventSpeaker plus +SessionSpeaker links between them (`:310-311`), four sample sponsors across the Platinum, Gold, Silver +and Community tiers, two of them exhibitors with booth numbers (`:349-355`), and three sample social +activities (a pre-conference party the evening before the Developers Conference, a morning coffee +connect, and an after-party) whose event-local wall-clock times are anchored on each event's own start +date (`:409-425`, `:441`). All of that runs only when `includeSampleData` is set. The flag comes from +`Seeding:IncludeSampleConferenceData` (`MMCA.ADC.Conference.API/ConferenceModuleSeeder.cs:26`), which the +local Aspire AppHost sets (`MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:162`) and production +leaves unset. The reason is documented in the seeder's own remarks +(`ConferenceModuleDbSeeder.cs:17-24`): the public-browse E2E tests need at least one session and one +speaker row to exist deterministically, while production's real sessions and speakers arrive through the +Sessionize import. The links are created on *both* paths deliberately, so the direct (EventSpeaker) and +the transitive (SessionSpeaker) branches of the speakers-by-event filter are both exercised in dev and CI +(`ConferenceModuleDbSeeder.cs:307-309`). ## The Sessionize adapter @@ -191,7 +210,7 @@ Application layer (`SessionizeService.cs:22-24`). Unlike the AI adapter it **doe status, because the import use-case that calls it is a foreground operation with a caller waiting on the result. It is registered as a typed `HttpClient` in [`DependencyInjection`](#dependencyinjection) with the base address `https://sessionize.com/api/v2/` baked in -(`MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:21-23`), so it inherits the standard Aspire +(`MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:22-24`), so it inherits the standard Aspire resilience handler (Polly retry, timeout, circuit breaker) unchanged: `[Rubric §29, Resilience & Business Continuity]`, the [ADR-009](https://ivanball.github.io/docs/adr/009-resilience-and-recovery-objectives.html) policy that every outbound client gets resilience by default. The thinness is intentional: parsing, @@ -252,8 +271,8 @@ single-reader hosted drain), and it replaced an untracked fire-and-forget task t start. The queue's dedup lives in one process's memory, and Conference runs at `maxReplicas: 2` -(the `conferenceApp` container app at `MMCA.ADC/infra/main.bicep:1219`, scale rule at -`MMCA.ADC/infra/main.bicep:1335`), so the queue alone never stopped two organizer triggers landing on +(the `conferenceApp` container app at `MMCA.ADC/infra/main.bicep:1236`, scale rule at +`MMCA.ADC/infra/main.bicep:1357`), so the queue alone never stopped two organizer triggers landing on different replicas from each running a full paid pass over the same sessions. The worker therefore takes a **cross-replica lock** before invoking the handler: it creates a per-item DI scope (`CreateAsyncScope`, `SessionScoringProcessor.cs:160`) because the drain itself is a singleton while the @@ -265,9 +284,11 @@ rather than queueing behind the winner (`:181-188`), because waiting would only pass twice in a row. The handle is disposed by an `await using` around the whole run, so the lock comes back on success, on failure, and via its time-to-live even when the replica is killed mid-pass: the comment at `:162-174` records that this replaced a cache counter released in a `finally`, which left a -killed replica's key stuck at 1 and locked the event out until an operator cleared it by hand. Note the -doc drift here: ADR-052 still describes dedup as per-replica and the distributed lock as a future step -(`Website/docs-src/adr/052-background-job-execution.md:85-87`), but the lock is in the code today. +killed replica's key stuck at 1 and locked the event out until an operator cleared it by hand, and it +records the honest limit that a host with no Redis configured falls back to the in-process +`IDistributedLock`, where exclusion is per replica again. Note the doc drift here: ADR-052 still +describes dedup as per-replica and a distributed lock as the point at which this would need a real job +system (`Website/docs-src/adr/052-background-job-execution.md:92-95`), but the lock is in the code today. Failure handling is decided once instead of per call site. A cancellation during shutdown logs and returns without requeuing (`SessionScoringProcessor.cs:115-123`); any other exception is caught under an @@ -281,8 +302,8 @@ so retries exist to absorb a rate-limit blip, not to grind against an outage. A the handler answered, and a business refusal replayed twice more just costs money. When every attempt is exhausted the terminal path increments the `scoring.run.failed.terminal` counter tagged by event (`:96-99`, `:150`) on the `MMCA.ADC.Conference.Scoring` meter (`:59`), which the service host exports by -registering that meter name (`MMCA.ADC.Conference.Service/Program.cs:134`): that is -`[Rubric §13, Observability & Operability]` closing the loop on work that no user is waiting for. +registering that meter name (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:134`): that +is `[Rubric §13, Observability & Operability]` closing the loop on work that no user is waiting for. The output cache is evicted **twice** per run, once up front so polling clients stop seeing stale scores and once after a successful pass (`SessionScoringProcessor.cs:158` and `:208`), and it evicts the narrow @@ -294,24 +315,63 @@ Scalability]` and `[Rubric §31, Cost/FinOps]` both live in that one constant ([ADR-026](https://ivanball.github.io/docs/adr/026-caching-strategy.html), [ADR-040](https://ivanball.github.io/docs/adr/040-authenticated-output-caching-for-public-reads.html)). +## The sweep that finishes what a crash interrupted + +The drain is fast but not durable: the channel lives in one replica's memory, so a deploy, a scale-in or +a crash between the organizer's click and the last session's score leaves an event half scored with +nothing anywhere that would pick it up again. +[`SessionScoringSweepJob`](#sessionscoringsweepjob) +(`MMCA.ADC.Conference.Infrastructure/Services/SessionScoringSweepJob.cs:54`) is the backstop for exactly +that. It is an [`IScheduledJob`](group-05-cqrs-pipeline.md#ischeduledjob) named +`conference-session-scoring-sweep` with the cron expression `*/5 * * * *` +(`SessionScoringSweepJob.cs:69`, `:77`), so the framework's recurring-job scheduler +([ADR-074](https://ivanball.github.io/docs/adr/074-recurring-job-scheduler.html)) runs it every five +minutes, once across the whole service rather than once per replica, under the persistent claim lease the +outbox pattern established +(`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IScheduledJob.cs:16-20`). A host overrides +the cadence through `Scheduler:Jobs:conference-session-scoring-sweep:Cron` without touching code +(`SessionScoringSweepJob.cs:72-76`). + +There is no scoring-state column on `Event`, so the job derives the condition from the rows the scoring +handler already writes. It projects every non-service session into +[`SessionScoringCandidate`](#sessionscoringcandidate) (`SessionScoringSweepJob.cs:86-89`, `:208`) and +every persisted score into [`SessionScoreStamp`](#sessionscorestamp) (`:98-100`, `:213`), collapses the +stamps to the newest per session (`:118-132`), then groups the candidates by event (`:105-108`) and +judges each one: an event is mid-pass exactly when **some but not all** of its scorable sessions carry a +score (`:170-173`). Two bounds keep a wrong guess from spending money. An event with **zero** scores is +never enqueued, because nobody asked for it and starting a pass the organizer did not request would bill +every event in the database on the first tick. A partially scored event is enqueued only while its newest +score is inside the 24-hour `RecoveryWindow` (`:66`, `:103`, `:175-179`), so a crash is recovered but a +session the model will never score cannot re-trigger paid passes forever; past that the job logs that it +is leaving the event alone and an organizer re-triggers by hand (`:195-202`). Beyond the enqueue the job +is read-only, and the enqueue itself is safe to repeat because the queue's pending set refuses an event +that is already queued or running, which the job records as the outcome on its log line (`:181-182`, +`:185-193`). `[Rubric §29, Resilience & Business Continuity]` is the lens: the fast path stays in memory, +and a slow, cheap, idempotent sweep notices what the fast path dropped. + ## DI wiring and a deliberate resilience override [`DependencyInjection`](#dependencyinjection) -(`MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:11`) is a single +(`MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:12`) is a single `extension(IServiceCollection)` block (the codebase's standard DI-registration idiom, taught in the -primer) exposing `AddModuleConferenceInfrastructure()` (`DependencyInjection.cs:13-19`). It registers -both adapters as typed HTTP clients and the drain as a hosted service (`DependencyInjection.cs:45`). The -Anthropic client gets a **custom resilience policy**: a 5-minute `HttpClient.Timeout` and the -`anthropic-version: 2023-06-01` header (`DependencyInjection.cs:30-32`), then -`RemoveAllResilienceHandlers()` followed by a re-added `StandardResilienceHandler` with a 3-minute attempt -timeout, a 7-minute circuit-breaker sampling window, a 5-minute total request timeout, and only **one** -retry (`DependencyInjection.cs:34-41`). The inline comment explains why (`DependencyInjection.cs:25-26`): -AI scoring of a large batch can take minutes, which would blow through Aspire's default 30s attempt and -90s total limits, and retrying an expensive LLM call aggressively is wasteful. This is a precise -illustration of [ADR-009](https://ivanball.github.io/docs/adr/009-resilience-and-recovery-objectives.html): -every outbound client is resilient by default, but a client with genuinely different latency -characteristics tunes the policy rather than disabling it. The Sessionize client takes the defaults -unchanged. +primer) exposing `AddModuleConferenceInfrastructure()` (`DependencyInjection.cs:20-57`). It registers +both adapters as typed HTTP clients, the drain as a hosted service (`DependencyInjection.cs:46`), and the +sweep as a scheduled job (`DependencyInjection.cs:54`). That last registration carries a nuance worth +reading: the job is registered by the **module**, the way the framework's own audit-trail retention job +is, and it only actually runs in a host that also calls `AddScheduledJobs` and turns the scheduler on, +which `MMCA.ADC.Conference.Service` does +(`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:313`); anywhere else the registration +is inert (`DependencyInjection.cs:48-53`). The Anthropic client gets a **custom resilience policy**: a +5-minute `HttpClient.Timeout` and the `anthropic-version: 2023-06-01` header +(`DependencyInjection.cs:31-33`), then `RemoveAllResilienceHandlers()` followed by a re-added +`StandardResilienceHandler` with a 3-minute attempt timeout, a 7-minute circuit-breaker sampling window, +a 5-minute total request timeout, and only **one** retry (`DependencyInjection.cs:35-42`). The inline +comment explains why (`DependencyInjection.cs:26-27`): AI scoring of a large batch can take minutes, +which would blow through Aspire's default 30s attempt and 90s total limits, and retrying an expensive LLM +call aggressively is wasteful. This is a precise illustration of +[ADR-009](https://ivanball.github.io/docs/adr/009-resilience-and-recovery-objectives.html): every +outbound client is resilient by default, but a client with genuinely different latency characteristics +tunes the policy rather than disabling it. The Sessionize client takes the defaults unchanged. ## How it fits together at runtime @@ -325,13 +385,15 @@ organizer triggers a Sessionize refresh; the Application use-case calls adapter makes the outbound call inside the default Polly pipeline, and the parsed `SessionizeResponse` flows back for mapping. **Scoring flow:** the organizer POSTs to the scoring endpoint, the controller only calls `TryEnqueue` and returns `202 Accepted` or `409 Conflict` -(`MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:110-128`), +(`MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:110-131`), [`SessionScoringProcessor`](#sessionscoringprocessor) picks the event up, evicts the sessions cache tag, claims the event's distributed lock, runs the scoped command handler which calls [`AnthropicScoringService`](#anthropicscoringservice) once per session under the tuned resilience policy, -persists one `SessionAiScore` row per session behind the unique filtered index, and evicts the tag again. -The two marker types in this assembly, [`AssemblyReference`](#assemblyreference) and -[`ClassReference`](#classreference) (`MMCA.ADC.Conference.Infrastructure/AssemblyReference.cs:5` and +persists one `SessionAiScore` row per session behind the unique filtered index, and evicts the tag again; +if that run dies mid-pass, [`SessionScoringSweepJob`](#sessionscoringsweepjob) notices the partial result +within five minutes and puts the event back on the queue. The two marker types in this assembly, +[`AssemblyReference`](#assemblyreference) and [`ClassReference`](#classreference) +(`MMCA.ADC.Conference.Infrastructure/AssemblyReference.cs:5` and `MMCA.ADC.Conference.Infrastructure/AssemblyReference.cs:11`), exist purely so the module loader and the configuration-assembly scan can reach this assembly by a stable `typeof()` handle instead of a hard-coded type list, the same extension point every module assembly provides. @@ -555,15 +617,15 @@ type list, the same extension point every module assembly provides. > MMCA.ADC.Conference.Infrastructure · `MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/CategoryItemConfiguration.cs:10` · Level 8 · class -- **What it is**: the EF Core persistence map for the [`CategoryItem`](group-17-conference-domain.md#categoryitem) entity: column facets, the parent relationship to [`Category`](group-17-conference-domain.md#category), and a composite unique index. It is the smallest complete member of the sixteen-class configuration family in this folder, so it is the one this chapter uses to teach the shared shape. +- **What it is**: the EF Core persistence map for the [`CategoryItem`](group-17-conference-domain.md#categoryitem) entity: column facets, the parent relationship to [`Category`](group-17-conference-domain.md#category), and a composite unique index. It is the smallest complete member of the seventeen-class configuration family in this folder, so it is the one this chapter uses to teach the shared shape. - **Depends on**: first-party: [`EntityTypeConfigurationSQLServer`](group-07-persistence-ef-core.md#entitytypeconfigurationsqlservertentity-tidentifiertype) (base, `:11`), [`CategoryItem`](group-17-conference-domain.md#categoryitem), [`Category`](group-17-conference-domain.md#category), [`CategoryInvariants`](group-17-conference-domain.md#categoryinvariants) (`:19`). External: `Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder`. - **Concept introduced, the per-entity configuration class and what the base already did.** Every configuration in this folder is an `internal sealed class` deriving from `EntityTypeConfigurationSQLServer` and overriding one method, `Configure(EntityTypeBuilder builder)`, whose first statement is always `base.Configure(builder)` (`:16`). Knowing exactly what that base call does is what stops you re-declaring things by hand: - - `EntityTypeConfigurationSQLServer` is a **shim with no body** (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/EntityTypeConfigurationSQLServer.cs:17`). Its whole contribution is the `[UseDataSource(DataSource.SQLServer)]` attribute it carries (`:16`), an instance of [`UseDataSourceAttribute`](group-14-module-system-composition.md#usedatasourceattribute). - - The real work is in [`EntityTypeConfiguration`](group-07-persistence-ef-core.md#entitytypeconfigurationtentity-tidentifiertype). Its `Configure` reads the attribute off `GetType()` and throws if it is missing (`EntityTypeConfiguration.cs:43-46`), then calls `ApplyEngineConventions` (`:48`). For `DataSource.SQLServer` that means `ToTable(typeof(TEntity).Name, NamespaceConventions.GetModuleName(typeof(TEntity)) ?? "dbo")`, so table name comes from the CLR type and **schema comes from the module segment of the entity's namespace** (`:66`), then `HasKey(p => p.Id)` (`:67`) and either `ValueGeneratedOnAdd()` or `ValueGeneratedNever()` depending on `IsIdValueGenerated` (`:68-71`). + - `EntityTypeConfigurationSQLServer` is a **shim with no body** (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/EntityTypeConfigurationSQLServer.cs:17-20`). Its whole contribution is the `[UseDataSource(DataSource.SQLServer)]` attribute it carries (`:16`), an instance of [`UseDataSourceAttribute`](group-14-module-system-composition.md#usedatasourceattribute). + - The real work is in [`EntityTypeConfiguration`](group-07-persistence-ef-core.md#entitytypeconfigurationtentity-tidentifiertype). Its `Configure` (`EntityTypeConfiguration.cs:37`) reads the attribute off `GetType()` and throws if it is missing (`:43-46`), then calls `ApplyEngineConventions` (`:48`). For `DataSource.SQLServer` that means `ToTable(typeof(TEntity).Name, NamespaceConventions.GetModuleName(typeof(TEntity)) ?? "dbo")`, so the table name comes from the CLR type and **the schema comes from the module segment of the entity's namespace** (`:66`), then `HasKey(p => p.Id)` (`:67`) and either `ValueGeneratedOnAdd()` or `ValueGeneratedNever()` depending on `IsIdValueGenerated` (`:68-71`). - Below that, [`EntityTypeConfigurationBase`](group-07-persistence-ef-core.md#entitytypeconfigurationbasetentity-tidentifiertype) does exactly one thing: `builder.Ignore(nameof(AuditableAggregateRootEntity<>.DomainEvents))` for aggregate roots (`EntityTypeConfigurationBase.cs:29-32`), keeping the in-memory event list out of the schema. - What the base chain does **not** do is equally important. The soft-delete global query filter, the `rowversion` concurrency token and the soft-delete index convention are installed by the context, not by these classes: [`ApplicationDbContext`](group-07-persistence-ef-core.md#applicationdbcontext) adds the query filter at `ApplicationDbContext.cs:348`, marks the concurrency property at `:469` and `:473`, and registers [`SoftDeleteUniqueIndexConvention`](group-07-persistence-ef-core.md#softdeleteuniqueindexconvention) at `:296`. So a configuration class in this folder is only ever about *this entity's* columns, relationships and indexes. - Because the engine is pinned entirely by the base type, re-pointing a Conference entity at SQLite or Cosmos is a base-class swap with no edit to the body of `Configure`: the domain entity, the handlers and everything above stay untouched. All sixteen Conference configurations use the SQL Server base, since ADC runs SQL Server only. + Because the engine is pinned entirely by the base type, re-pointing a Conference entity at SQLite or Cosmos is a base-class swap with no edit to the body of `Configure`: the domain entity, the handlers and everything above stay untouched. All seventeen Conference configurations use the SQL Server base, since ADC runs SQL Server only. `[Rubric §8, Data Architecture]` assesses whether persistence is designed deliberately (typed lengths, correct nullability, FK relationships, purposeful indexes) rather than left to convention defaults: this family is where all of that lives for the Conference database. `[Rubric §3, Clean Architecture]` assesses dependency direction: EF mapping is confined to Infrastructure, and the domain entities carry zero EF attributes, so the domain layer stays framework-free. - **Concept introduced, length constants sourced from the domain invariants.** Nearly every `HasMaxLength` call in this folder reads a constant from the entity's `…Invariants` class instead of a literal. Here it is `CategoryInvariants.CategoryItemNameMaxLength` (`:19`). The same constant is what the Application layer's FluentValidation rules use, so the column width and the request validator are a **single source of truth**: change the constant once and both move. `[Rubric §16, Maintainability]` assesses exactly this kind of single-definition-point discipline. @@ -586,12 +648,12 @@ type list, the same extension point every module assembly provides. - **Depends on**: first-party: [`EntityTypeConfigurationSQLServer`](group-07-persistence-ef-core.md#entitytypeconfigurationsqlservertentity-tidentifiertype), [`Category`](group-17-conference-domain.md#category), [`CategoryInvariants`](group-17-conference-domain.md#categoryinvariants). External: `Microsoft.EntityFrameworkCore` (for `ToTable`). - **Concept**: the shared shape is taught under [`CategoryItemConfiguration`](#categoryitemconfiguration); the only new idea here is the deliberate name/table split. - **Walkthrough** - - **Class name** (`:13-14`): the type is `ConferenceCategoryConfiguration`, not `CategoryConfiguration`. The XML doc (`:8-12`) gives the reason: the ADC codebase carries more than one `Category` concept, and a distinct configuration class name avoids ambiguity for a reader scanning the folder. + - **Class name** (`:13-14`): the type is `ConferenceCategoryConfiguration`, not `CategoryConfiguration`. The XML doc (`:8-12`) gives the reason: more than one `Category` concept exists in the wider codebase vocabulary, and a distinct configuration class name avoids ambiguity for a reader scanning the folder. - **Explicit table mapping** (`:24`): `builder.ToTable("Category", "Conference")`. The comment (`:21-23`) is honest that this is **redundant**, the base would already derive `Category` from `typeof(Category).Name` and `Conference` from the namespace; it is written out for clarity given the class-name mismatch above. - **Columns** (`:26-35`): `Title` required at `CategoryInvariants.TitleMaxLength`; `Sort` required; `Type` optional with a literal `HasMaxLength(100)`, one of the few places in the family that does not read a constant. - **Why it's built this way**: naming the configuration for the bounded context rather than for the CLR type is a small readability trade: the class is findable by module, and the explicit `ToTable` keeps the physical target visible at the call site rather than implied by a base-class convention two files away. - **Where it's used**: same discovery path as the rest of the family (see [`CategoryItemConfiguration`](#categoryitemconfiguration)). -- **Caveats / not-in-source**: the doc comment cites a Catalog-module `Category` as the collision being avoided. Catalog is a **MMCA.Store** module, not an ADC one, so within this repo nothing would actually collide; treat the comment as historical rationale carried over from the shared framework vocabulary. +- **Caveats / not-in-source**: the doc comment (`:10-11`) cites a Catalog-module `Category` as the collision being avoided. Catalog is a **MMCA.Store** module, not an ADC one, so within this repo nothing would actually collide; treat the comment as rationale carried over from the shared framework vocabulary. --- @@ -601,14 +663,14 @@ type list, the same extension point every module assembly provides. - **What it is**: the persistence map for [`Event`](group-17-conference-domain.md#event), the top aggregate of the Conference module (the conference itself: dates, venue, publication state, Sessionize linkage). - **Depends on**: first-party: [`EntityTypeConfigurationSQLServer`](group-07-persistence-ef-core.md#entitytypeconfigurationsqlservertentity-tidentifiertype), [`Event`](group-17-conference-domain.md#event), [`EventInvariants`](group-17-conference-domain.md#eventinvariants), [`QuestionModerationDefault`](group-17-conference-domain.md#questionmoderationdefault). External: `Microsoft.EntityFrameworkCore`. -- **Concept reinforced, the filtered non-unique index.** `HasIndex(p => p.SessionizeCode).HasFilter("[SessionizeCode] IS NOT NULL")` (`:41-42`) is filtered but **not** unique. A filtered index only covers the rows matching its predicate, so this one indexes just the events that carry a Sessionize code, which is the population the import path looks up by. It deliberately does not forbid two events sharing a code, and it costs nothing for the (many) events with a null code. `[Rubric §12, Performance and Scalability]` assesses whether indexes are chosen for the actual query shape rather than sprayed across columns: this is a narrow index sized to one lookup. +- **Concept reinforced, the filtered non-unique index.** `HasIndex(p => p.SessionizeCode).HasFilter("[SessionizeCode] IS NOT NULL")` (`:41-42`) is filtered but **not** unique. A filtered index only covers the rows matching its predicate, so this one indexes just the events that carry a Sessionize code, which is the population the import path looks up by. It deliberately does not forbid two events sharing a code, and it costs nothing for the events with a null code. `[Rubric §12, Performance and Scalability]` assesses whether indexes are chosen for the actual query shape rather than sprayed across columns: this is a narrow index sized to one lookup. - **Walkthrough** - - **Required core** (`:19-35`): `Name` (`EventInvariants.NameMaxLength`), `StartDate`, `EndDate`, and `TimeZone` (`EventInvariants.TimeZoneMaxLength`). Storing the IANA time-zone id as a column rather than baking a UTC offset into the dates is what lets the schedule render correctly across DST. - - **Optional descriptive and venue columns** (`:23-25`, `:44-62`): `Description`, `VenueAddress`, `VenueMapUrl`, `WiFiInfo`, `OrganizerContactEmail`, `SponsorshipPacketUrl`, each `IsRequired(false)` with its own invariant-sourced max length. - - **Sessionize linkage** (`:37-42`, `:71-75`): `SessionizeCode` optional plus the filtered index above; `LastSessionizeRefreshOn` / `LastSessionizeRefreshBy` are optional audit-style columns recording the last import run. `[Rubric §13, Observability and Operability]` assesses whether the system records the provenance of imported data: these two columns answer "when was this event last synced, and by whom" from the row itself. - - **State flags** (`:64-69`): `IsPublished` required; `QuestionModerationDefault` required, with the comment (`:67`) noting it is stored as an `int` through EF's default enum conversion and that `Pending` (0) is the safe default per BR-233. There is no `HasConversion` call, EF's default enum-to-int mapping is used as-is, so the safe default is also the zero value in the database. + - **Required core** (`:19-21`, `:27-35`): `Name` (`EventInvariants.NameMaxLength`), `StartDate`, `EndDate`, and `TimeZone` (`EventInvariants.TimeZoneMaxLength`). Storing the IANA time-zone id as a column rather than baking a UTC offset into the dates is what lets the schedule render correctly across DST. + - **Optional descriptive, venue and link columns** (`:23-25`, `:44-66`): `Description`, `VenueAddress`, `VenueMapUrl`, `WiFiInfo`, `OrganizerContactEmail`, `SponsorshipPacketUrl` and `TicketingUrl`, each `IsRequired(false)` with its own invariant-sourced max length. + - **Sessionize linkage** (`:37-42`, `:75-79`): `SessionizeCode` optional plus the filtered index above; `LastSessionizeRefreshOn` / `LastSessionizeRefreshBy` are optional audit-style columns recording the last import run. `[Rubric §13, Observability and Operability]` assesses whether the system records the provenance of imported data: these two columns answer "when was this event last synced, and by whom" from the row itself. + - **State flags** (`:68-73`): `IsPublished` required; `QuestionModerationDefault` required, with the comment (`:71`) noting it is stored as an `int` through EF's default enum conversion and that `Pending` (0) is the safe default per BR-233. There is no `HasConversion` call, EF's default enum-to-int mapping is used as-is, and the enum really does declare `Pending = 0` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/QuestionModerationDefault.cs:10`), so the safe default is also the zero value in the database. - **Why it's built this way**: everything the organizer may not know at creation time is nullable, so an event can be created early and enriched later without a two-phase workflow; only the four facts that make an event an event are required. -- **Where it's used**: `Event` is the FK target of [`RoomConfiguration`](#roomconfiguration), [`SessionConfiguration`](#sessionconfiguration), [`EventSpeakerConfiguration`](#eventspeakerconfiguration), [`EventQuestionAnswerConfiguration`](#eventquestionanswerconfiguration) and [`SponsorConfiguration`](#sponsorconfiguration). +- **Where it's used**: `Event` is the FK target of [`RoomConfiguration`](#roomconfiguration), [`SessionConfiguration`](#sessionconfiguration), [`EventSpeakerConfiguration`](#eventspeakerconfiguration), [`EventQuestionAnswerConfiguration`](#eventquestionanswerconfiguration), [`ActivityConfiguration`](#activityconfiguration) and [`SponsorConfiguration`](#sponsorconfiguration). --- @@ -620,7 +682,7 @@ type list, the same extension point every module assembly provides. - **Depends on**: first-party: [`EntityTypeConfigurationSQLServer`](group-07-persistence-ef-core.md#entitytypeconfigurationsqlservertentity-tidentifiertype), [`EventQuestionAnswer`](group-17-conference-domain.md#eventquestionanswer), [`Event`](group-17-conference-domain.md#event), [`EventInvariants`](group-17-conference-domain.md#eventinvariants), [`IndexBuilderExtensions`](group-07-persistence-ef-core.md#indexbuilderextensions) (`HasSoftDeleteFilter`). External: `Microsoft.EntityFrameworkCore.Metadata.Builders`. - **Concept introduced, `HasSoftDeleteFilter()` and the database as the concurrency backstop.** - `HasSoftDeleteFilter()` (`IndexBuilderExtensions.cs:50-64`) replaces a hand-typed `HasFilter("[IsDeleted] = 0")`. It builds the predicate through [`SoftDeleteFilterSql`](group-07-persistence-ef-core.md#softdeletefiltersql) from the live model (`:56`), so a renamed soft-delete column follows automatically and the identifier quoting comes from the engine instead of a SQL-Server-shaped literal. Its `engine` parameter defaults to `DataSource.SQLServer` (`:51`), which is exactly what the `…SQLServer` base already implies. On a **unique** index the call is technically redundant with `SoftDeleteUniqueIndexConvention`, which would apply the same predicate at model finalizing; writing it explicitly keeps the intent readable at the call site, and because the convention skips any index that already declares a filter (`SoftDeleteUniqueIndexConvention.cs:53`) the two can never disagree. On a **non-unique** index like the `EventId` lookup here, the convention deliberately does nothing, so the explicit call is the only way to get the filter. - - The `(EventId, QuestionId, CreatedBy)` unique index (`:42-44`) is a **race backstop**, and the comment (`:39-41`) is unusually candid about why: the application-level upsert only inspects the in-memory collection, so two concurrent submits can both take the create branch. The database refuses the second one, and the shared `DbUpdateException` handler turns the violation into a 409 for the client. `[Rubric §8, Data Architecture]` assesses whether invariants that matter are enforced where they cannot be raced, and `[Rubric §15, Best Practices and Code Quality]` assesses whether known limitations are documented at the point of the compensating control rather than left for the next reader to discover. + - The `(EventId, QuestionId, CreatedBy)` unique index (`:42-44`) is a **race backstop**, and the comment (`:38-41`) is unusually candid about why: the application-level upsert only inspects the in-memory collection, so two concurrent submits can both take the create branch. The database refuses the second one, and the shared `DbUpdateException` handler turns the violation into a 409 for the client. `[Rubric §8, Data Architecture]` assesses whether invariants that matter are enforced where they cannot be raced, and `[Rubric §15, Best Practices and Code Quality]` assesses whether known limitations are documented at the point of the compensating control rather than left for the next reader to discover. - **Walkthrough**: required `EventId` and `QuestionId` scalars (`:19-23`); required `AnswerValue` at `EventInvariants.AnswerValueMaxLength` (`:25-27`); required parent relationship `HasOne(p => p.Event).WithMany(p => p.EventQuestionAnswers).HasForeignKey(p => p.EventId)` (`:29-32`); soft-delete-filtered lookup index on `EventId` (`:35-36`); the BR-123 filtered unique index (`:42-44`). - **Why it's built this way**: `CreatedBy` is part of the uniqueness tuple, so "one live answer per question" is scoped **per author**, not globally, which is what a per-attendee feedback form needs. - **Where it's used**: written by the Conference event-feedback command handlers; read by the feedback queries. Compare its sibling [`SessionQuestionAnswerConfiguration`](#sessionquestionanswerconfiguration), which carries the same BR-123 index but treats its parent lookup index differently for a specific reason. @@ -654,7 +716,7 @@ type list, the same extension point every module assembly provides. - **What it is**: the persistence map for [`Question`](group-17-conference-domain.md#question), the definition of a feedback question (its text, what it attaches to, how it renders, and where it came from). - **Depends on**: first-party: [`EntityTypeConfigurationSQLServer`](group-07-persistence-ef-core.md#entitytypeconfigurationsqlservertentity-tidentifiertype), [`Question`](group-17-conference-domain.md#question), [`QuestionInvariants`](group-17-conference-domain.md#questioninvariants). External: `Microsoft.EntityFrameworkCore.Metadata.Builders`. - **Concept**: the shared shape is taught under [`CategoryItemConfiguration`](#categoryitemconfiguration). What is worth noticing here is that this is the flattest configuration in the folder: six required properties, **no relationships and no indexes at all**. -- **Walkthrough** (`:18-38`): all six columns are `IsRequired()`. `QuestionText`, `QuestionEntity`, `QuestionType` and `QuestionSource` each take their length from `QuestionInvariants`; `Sort` and `IsRequired` (the boolean, not the fluent call) are plain required scalars. `QuestionEntity` and `QuestionType` are stored as **strings, not enums**, so adding a question type or a new attachable entity needs no migration and no enum-to-string conversion. +- **Walkthrough** (`:18-38`): all six columns are `IsRequired()`. `QuestionText`, `QuestionEntity`, `QuestionType` and `QuestionSource` each take their length from `QuestionInvariants`; `Sort` and `IsRequired` (the boolean, not the fluent call) are plain required scalars. `QuestionEntity` and `QuestionType` are declared as `string` on the entity (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/Question.cs:20`, `:23`), **not enums**, so adding a question type or a new attachable entity needs no migration and no enum-to-string conversion. - **Why it's built this way**: questions are attached to events, sessions and speakers by the three `…QuestionAnswer` entities, and those answers carry a plain `QuestionId` scalar rather than a navigation, so `Question` itself needs no relationship configuration. Modelling the discriminators as strings keeps the question catalogue extensible from data rather than from code. - **Where it's used**: referenced by `QuestionId` from [`EventQuestionAnswerConfiguration`](#eventquestionanswerconfiguration), [`SessionQuestionAnswerConfiguration`](#sessionquestionanswerconfiguration) and [`SpeakerQuestionAnswerConfiguration`](#speakerquestionanswerconfiguration). @@ -669,7 +731,7 @@ type list, the same extension point every module assembly provides. - **Concept introduced, re-declaring an index EF would otherwise drop.** The explicit `builder.HasIndex(p => p.EventId)` (`:48`) looks redundant next to the `(EventId, Name)` composite below it, and the comment (`:46-47`) says exactly why it is not: EF removes the conventional foreign-key index as redundant once a composite index **leads with the same column**, but the composite is filtered, and the plain FK lookups still want an unfiltered index. This is a good example of a mapping decision that only makes sense once you know EF's own de-duplication rule; without the comment the line reads as a mistake. `[Rubric §12, Performance and Scalability]` assesses whether index choices survive framework conventions rather than being silently optimized away. - **Walkthrough** - **Required** (`:19-24`): `Name` at `EventInvariants.RoomNameMaxLength`, and `Sort`. - - **Optional** (`:26-39`): `Capacity` (a nullable scalar with no length), plus `Floor`, `Location` and `AccessibilityInfo`, each with an invariant-sourced max length. `AccessibilityInfo` being a first-class room column, not a note bolted onto the description, is the schema-level half of ADC's WCAG commitment. `[Rubric §21, Accessibility]` assesses whether accessibility is designed into the data rather than added at the view. + - **Optional** (`:26-39`): `Capacity` (a nullable scalar with no length), plus `Floor`, `Location` and `AccessibilityInfo`, each with an invariant-sourced max length. `AccessibilityInfo` being a first-class room column, not a note bolted onto the description, is the schema-level half of ADC's accessibility commitment. `[Rubric §21, Accessibility]` assesses whether accessibility is designed into the data rather than added at the view. - **Parent relationship** (`:41-44`): required `HasOne(p => p.Event).WithMany(p => p.Rooms).HasForeignKey(p => p.EventId)`. - **Indexes** (`:48`, `:52-54`): the re-declared plain `EventId` index, then `HasIndex(p => new { p.EventId, p.Name }).IsUnique().HasSoftDeleteFilter()`. The comment (`:50-51`) states its purpose plainly: it backstops the aggregate's duplicate-room-name invariant, and the soft-delete filter means a deleted room never blocks reusing its name. - **Why it's built this way**: the domain already refuses a duplicate room name inside the `Event` aggregate; the filtered unique index is the database-side guarantee for the concurrent case the in-memory check cannot see, the same defence-in-depth reasoning as BR-123 in [`EventQuestionAnswerConfiguration`](#eventquestionanswerconfiguration). @@ -684,11 +746,11 @@ type list, the same extension point every module assembly provides. - **What it is**: the persistence map for [`SessionAiScore`](group-17-conference-domain.md#sessionaiscore), the row that stores a language model's rating of one session across seven dimensions plus its written reasoning. - **Depends on**: first-party: [`EntityTypeConfigurationSQLServer`](group-07-persistence-ef-core.md#entitytypeconfigurationsqlservertentity-tidentifiertype), [`SessionAiScore`](group-17-conference-domain.md#sessionaiscore), [`IndexBuilderExtensions`](group-07-persistence-ef-core.md#indexbuilderextensions). External: `Microsoft.EntityFrameworkCore.Metadata.Builders`. - **Concept introduced, sizing a decimal column to the value's actual range.** Each of the seven score columns is declared `HasPrecision(3, 1)`, that is `decimal(3,1)`: three total digits, one after the point (`:22-48`). That is the smallest exact-decimal shape that holds a one-decimal rating without the rounding surprises a `float`/`double` column would introduce. Choosing exact decimal for a value that is compared and sorted, rather than binary floating point, is the point. `[Rubric §8, Data Architecture]` assesses type fidelity of stored values. -- **Concept reinforced, recording the provenance of derived data.** `ModelUsed` (`:54-56`, max 100) and `Reasoning` (`:50-52`, max 4000) are both **required**. Persisting which model produced a score, and the sentence explaining it, alongside the numbers is what makes an AI judgement auditable: you can tell after the fact whether a given score came from a model you have since replaced. `[Rubric §13, Observability and Operability]` assesses whether derived values carry enough context to be explained later. -- **Walkthrough**: required `SessionId` scalar (`:19-20`); seven `decimal(3,1)` required score columns, `OverallScore`, `TopicRelevanceScore`, `DescriptionQualityScore`, `NoveltyScore`, `ActionableTakeawaysScore`, `DepthOrInsightQualityScore`, `CredibilityExperienceScore` (`:22-48`); required `Reasoning` and `ModelUsed` (`:50-56`); and `HasIndex(p => p.SessionId).IsUnique().HasSoftDeleteFilter()` (`:59-61`), commented "One score per session (among non-deleted)". There is **no** `HasOne` relationship to [`Session`](group-17-conference-domain.md#session): `SessionId` is a plain scalar, so the score row is not a child of the session aggregate. +- **Concept reinforced, recording the provenance of derived data.** `ModelUsed` (`:54-56`, a literal max length of 100) and `Reasoning` (`:50-52`, a literal max length of 4000) are both **required**, and both are among the few columns in this folder whose lengths are written as literals rather than read from an invariants class. Persisting which model produced a score, and the sentence explaining it, alongside the numbers is what makes an AI judgement auditable: you can tell after the fact whether a given score came from a model you have since replaced. `[Rubric §13, Observability and Operability]` assesses whether derived values carry enough context to be explained later. +- **Walkthrough**: required `SessionId` scalar (`:19-20`); seven `decimal(3,1)` required score columns, `OverallScore`, `TopicRelevanceScore`, `DescriptionQualityScore`, `NoveltyScore`, `ActionableTakeawaysScore`, `DepthOrInsightQualityScore`, `CredibilityExperienceScore` (`:22-48`); required `Reasoning` and `ModelUsed` (`:50-56`); and `HasIndex(p => p.SessionId).IsUnique().HasSoftDeleteFilter()` (`:59-61`), commented "One score per session (among non-deleted)" (`:58`). There is **no** `HasOne` relationship to [`Session`](group-17-conference-domain.md#session): `SessionId` is a plain scalar, so the score row is not a child of the session aggregate. - **Why it's built this way**: keeping the score in its own table behind a unique-per-session index means re-scoring is a soft-delete plus insert (the filter frees the slot) rather than an in-place overwrite, and the previous scoring run stays on disk for comparison. - **Where it's used**: written by the Conference scoring pipeline, whose adapter and processor are covered earlier in this chapter under [`AnthropicScoringService`](#anthropicscoringservice) and [`SessionScoringProcessor`](#sessionscoringprocessor). -- **Caveats / not-in-source**: this configuration only defines the table. Whether scoring runs in a given environment is a configuration and feature-gating question decided outside this file. Note also that [`ModuleApplicationDbContext`](#moduleapplicationdbcontext) declares no `DbSet` for `SessionAiScore`, and nothing breaks, because that manifest does not drive the model. +- **Caveats / not-in-source**: this configuration only defines the table. Whether scoring runs in a given environment is a configuration and feature-gating question decided outside this file. Note also that [`ModuleApplicationDbContext`](#moduleapplicationdbcontext) declares no `DbSet` for `SessionAiScore` (its fifteen sets are listed at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:28-70`), and nothing breaks, because that manifest does not drive the model. --- @@ -710,15 +772,15 @@ type list, the same extension point every module assembly provides. - **What it is**: the persistence map for [`Speaker`](group-17-conference-domain.md#speaker): name, bio, social links, the optional link to an Identity user, and the one value-object column in the Conference module. - **Depends on**: first-party: [`EntityTypeConfigurationSQLServer`](group-07-persistence-ef-core.md#entitytypeconfigurationsqlservertentity-tidentifiertype), [`Speaker`](group-17-conference-domain.md#speaker), [`SpeakerInvariants`](group-17-conference-domain.md#speakerinvariants), [`NullableEmailValueConverter`](group-07-persistence-ef-core.md#nullableemailvalueconverter), and transitively the [`Email`](group-02-domain-building-blocks.md#email) value object. External: `Microsoft.EntityFrameworkCore`. -- **Concept introduced, mapping a value object with `HasConversion` instead of `OwnsOne`.** `builder.Property(p => p.Email).HasConversion(new NullableEmailValueConverter())` (`:42-43`) round-trips the `Email?` value object to a plain nullable string column. The converter (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conversions/EmailValueConverter.cs:60-70`) passes `null` straight through on both legs, so "no email" stays a SQL `NULL` rather than becoming an empty string or a failed `Email.Create` call. Two design points worth carrying forward: +- **Concept introduced, mapping a value object with `HasConversion` instead of `OwnsOne`.** `builder.Property(p => p.Email).HasConversion(new NullableEmailValueConverter())` (`:42-43`) round-trips the `Email?` value object to a plain nullable string column. The converter (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conversions/EmailValueConverter.cs:60-71`) passes `null` straight through on both legs (`:67-68`), so "no email" stays a SQL `NULL` rather than becoming an empty string or a failed `Email.Create` call. Two design points worth carrying forward: - **Why `HasConversion` and not `OwnsOne`**: the backing column stays a plain string, so adopting the value object on a property that used to be a `string` is not a schema change (`EmailValueConverter.cs:8-10`). - **Facets stay at the call site**: the converter deliberately owns no length or requiredness, which is why `HasMaxLength(SpeakerInvariants.EmailMaxLength)` and `IsRequired(false)` are chained here (`:44-45`). Those differ per entity and are not the converter's business (`EmailValueConverter.cs:20-22`). `[Rubric §4, DDD]` assesses whether value objects survive the trip to storage instead of being flattened into primitives at the boundary. `[Rubric §16, Maintainability]` applies too: the conversion logic lives once in MMCA.Common, so every entity with an email gets identical semantics. -- **Concept reinforced, the partially filtered unique index.** `HasIndex(p => p.LinkedUserId).IsUnique().HasFilter("[LinkedUserId] IS NOT NULL")` (`:63-65`) enforces the one-to-one User to Speaker link **only among speakers that have one**. Without the predicate, SQL Server would treat multiple `NULL`s as duplicates and allow at most one unlinked speaker, which would be nonsense. Note this one is a hand-written literal rather than `HasSoftDeleteFilter()`, because the predicate is about `LinkedUserId`, not about soft delete; the soft-delete clause is added on top automatically, since the index is unique and the convention only skips indexes that already have a filter (`SoftDeleteUniqueIndexConvention.cs:53`). +- **Concept reinforced, the partially filtered unique index.** `HasIndex(p => p.LinkedUserId).IsUnique().HasFilter("[LinkedUserId] IS NOT NULL")` (`:63-65`) enforces the one-to-one User to Speaker link **only among speakers that have one**. Without the predicate, SQL Server would treat multiple `NULL`s as duplicates and allow at most one unlinked speaker, which would be nonsense. Note this one is a hand-written literal rather than `HasSoftDeleteFilter()`, because the predicate is about `LinkedUserId`, not about soft delete; the soft-delete clause is not added on top, because `SoftDeleteUniqueIndexConvention` skips any index that already declares a filter (`SoftDeleteUniqueIndexConvention.cs:53`). A soft-deleted linked speaker therefore keeps holding its `LinkedUserId` slot. - **Walkthrough** - **Required identity** (`:20-26`, `:39-40`): `FirstName`, `LastName`, `IsTopSpeaker`. - - **Optional profile** (`:28-37`, `:47-61`): `Bio` (no max length, so `nvarchar(max)`), `TagLine`, `ProfilePicture`, `TwitterHandle`, `LinkedInUrl`, `GitHubUrl`, `WebsiteUrl`, each length-capped from `SpeakerInvariants`. + - **Optional profile** (`:28-37`, `:47-61`): `Bio` (no max length, so `nvarchar(max)`), `TagLine`, `ProfilePicture`, `TwitterHandle`, `LinkedInUrl`, `GitHubUrl`, `WebsiteUrl`, each length-capped from `SpeakerInvariants` except `Bio`. - **Email** (`:42-45`) and the **`LinkedUserId` index** (`:63-65`), described above. - **Computed property excluded** (`:68`): `builder.Ignore(p => p.FullName)` keeps the derived `FullName` out of the schema. Ignoring computed properties explicitly is how this codebase keeps derived state a domain concern and off the table. - **Why it's built this way**: `LinkedUserId` is a **scalar with no FK**, deliberately. The Identity user lives in a different service database, so a cross-database foreign key is not available under database-per-service; the unique index gives the guarantee the FK would have, within the one database that can enforce it. See [ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html). `[Rubric §7, Microservices Readiness]` assesses whether the schema is already free of cross-service constraints, which is what makes the Conference service extractable. @@ -732,13 +794,31 @@ type list, the same extension point every module assembly provides. - **What it is**: the persistence map for [`SpeakerQuestionAnswer`](group-17-conference-domain.md#speakerquestionanswer), a speaker's answer to a speaker-scoped [`Question`](group-17-conference-domain.md#question) (the fields Sessionize collects on a submission form). - **Depends on**: first-party: [`EntityTypeConfigurationSQLServer`](group-07-persistence-ef-core.md#entitytypeconfigurationsqlservertentity-tidentifiertype), [`SpeakerQuestionAnswer`](group-17-conference-domain.md#speakerquestionanswer), [`Speaker`](group-17-conference-domain.md#speaker), [`SpeakerInvariants`](group-17-conference-domain.md#speakerinvariants). External: `Microsoft.EntityFrameworkCore.Metadata.Builders`. -- **Concept**: the answer-entity shape is taught under [`EventQuestionAnswerConfiguration`](#eventquestionanswerconfiguration). This is the **stripped-down** member of the three: it declares no indexes at all. +- **Concept**: the answer-entity shape is taught under [`EventQuestionAnswerConfiguration`](#eventquestionanswerconfiguration). This is the **stripped-down** member of the three: it declares no indexes at all, and it is the only one of the three that does not import `MMCA.Common.Infrastructure.Persistence.Configuration`, because it never needs `HasSoftDeleteFilter()`. - **Walkthrough** (`:18-31`): required `SpeakerId`, `QuestionId` and `AnswerValue` (at `SpeakerInvariants.AnswerValueMaxLength`), then `HasOne(p => p.Speaker).WithMany(p => p.SpeakerQuestionAnswers).HasForeignKey(p => p.SpeakerId).IsRequired()`. Only the conventional EF index on the `SpeakerId` foreign key exists. - **Why it's built this way**: these rows arrive from the Sessionize import as part of a speaker payload and are read back with the speaker, never queried independently or submitted concurrently by two authors, so neither the BR-123 anti-race unique index nor an extra lookup index earns its cost here. Contrast with the event and session answer configurations, where an attendee-facing form can be double-submitted. - **Where it's used**: populated by the Sessionize sync path and read as part of the speaker detail projection. --- +### ActivityConfiguration + +> MMCA.ADC.Conference.Infrastructure · `MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/ActivityConfiguration.cs:11` · Level 9 · class + +- **What it is**: the persistence map for [`Activity`](group-17-conference-domain.md#activity), a social or networking item attached to an event (a pre-conference party, a coffee connect, an after-party) that is deliberately not a session: no room, no speakers, and often an external venue carried on the row itself. +- **Depends on**: first-party: [`EntityTypeConfigurationSQLServer`](group-07-persistence-ef-core.md#entitytypeconfigurationsqlservertentity-tidentifiertype), [`Activity`](group-17-conference-domain.md#activity), [`ActivityInvariants`](group-17-conference-domain.md#activityinvariants), [`Event`](group-17-conference-domain.md#event), [`IndexBuilderExtensions`](group-07-persistence-ef-core.md#indexbuilderextensions). External: `Microsoft.EntityFrameworkCore.Metadata.Builders`. +- **Concept introduced, indexing for the sort, not just for the filter.** The second index (`:63-64`) is `HasIndex(p => new { p.EventId, p.StartTime, p.SortOrder }).HasSoftDeleteFilter()`: non-unique, filtered, and composed in exactly the order the public agenda page consumes. The comment (`:61-62`) states the intent: the page filters by one event and orders by start time then sort order, so the composite serves the browse query directly instead of the database pulling the event slice and sorting it afterwards. This is the one place in the folder where an index's **column order is chosen for an ORDER BY** rather than for a lookup predicate, and it is worth reading alongside the narrower lookup index above it (`:58-59`, plain `EventId` with the same soft-delete filter). Contrast [`RoomConfiguration`](#roomconfiguration), whose paired indexes exist because the composite is filtered and the FK lookup wanted an unfiltered one; here both carry the filter, because every read of this table goes through the global query filter. `[Rubric §12, Performance and Scalability]` assesses whether index shape follows the queries that actually run. +- **Concept reinforced, event-local wall-clock time.** `StartTime` and `EndTime` are required plain date-times with no offset column (`:29-33`), and the comment (`:27-28`) is explicit that this mirrors `Session.StartsAt`/`EndsAt`: the IANA zone lives once on the owning [`Event`](group-17-conference-domain.md#event) (see [`EventConfiguration`](#eventconfiguration)) and is never repeated per row. The domain entity says the same thing at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:28-33`. Storing one zone for the whole programme is what keeps a schedule internally consistent when an activity is moved. `[Rubric §16, Maintainability]` assesses single-definition-point discipline, and this is the time-zone instance of it. +- **Walkthrough** + - **Required** (`:19-21`, `:29-33`, `:47-51`): `Name` at `ActivityInvariants.NameMaxLength` (200, `ActivityInvariants.cs:13`), `StartTime`, `EndTime`, `SortOrder`, and the `EventId` scalar. + - **Optional** (`:23-25`, `:35-45`): `Description` (`ActivityInvariants.DescriptionMaxLength`), `VenueName`, `VenueAddress` and `VenueUrl`, each `IsRequired(false)` with its own invariant-sourced max length. An absent `VenueName` is a meaningful value rather than missing data: the domain invariant says so (`ActivityInvariants.cs:38-40`), and the public page falls back to the event venue. + - **Event relationship** (`:53-56`): required `HasOne(p => p.Event).WithMany().HasForeignKey(p => p.EventId)`, with the **parameterless** `WithMany()`, so `Event` exposes no activities collection. The same one-sided-navigation choice is made in [`SessionConfiguration`](#sessionconfiguration): activities are read by explicit event-scoped queries, not by walking the event aggregate. + - **Indexes** (`:58-59`, `:63-64`): the filtered `EventId` lookup, then the filtered `(EventId, StartTime, SortOrder)` browse index described above. Neither is unique, so `SoftDeleteUniqueIndexConvention` would not have touched either one, which is why both spell out `HasSoftDeleteFilter()`. +- **Why it's built this way**: an activity is a first-class row rather than a flavour of session because it has a different shape (its own venue, no room, no speakers), and separating it keeps the session table free of columns that only apply to parties. `[Rubric §4, DDD]` assesses whether the model names distinct concepts distinctly instead of overloading one entity with a type discriminator. +- **Where it's used**: exposed as `DbSet Activities` on [`ModuleApplicationDbContext`](#moduleapplicationdbcontext) (`ModuleApplicationDbContext.cs:70`); read by the public agenda queries and written by the organizer-facing activity commands. + +--- + ### SessionCategoryItemConfiguration > MMCA.ADC.Conference.Infrastructure · `MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SessionCategoryItemConfiguration.cs:11` · Level 9 · class @@ -762,8 +842,8 @@ type list, the same extension point every module assembly provides. - **Walkthrough** - **Required** (`:20-22`, `:38-48`, `:69-70`): `Title` at `SessionInvariants.TitleMaxLength`; four booleans, `IsInformed`, `IsConfirmed`, `IsServiceSession`, `IsPlenumSession`; and the `EventId` scalar. - **Optional** (`:24-36`, `:50-64`, `:80-81`): `Description`, `StartsAt`, `EndsAt`, `Status`, `LiveUrl`, `RecordingUrl`, `AccessibilityInfo`, `ResourceLinks`, `RoomId`. That `StartsAt`, `EndsAt` and `RoomId` are all nullable is the schema admitting that a session exists as an accepted talk long before it is scheduled. - - **`Status` is a plain string** (`:34-36`) capped at `SessionInvariants.StatusMaxLength`, not an enum with a conversion, so adding a status value needs no migration. - - **Computed property excluded** (`:67`): `builder.Ignore(p => p.Duration)`, since `Duration` is derived from `StartsAt` and `EndsAt`. + - **`Status` is a plain string** (`:34-36`) capped at `SessionInvariants.StatusMaxLength`, not an enum with a conversion (the entity declares it as `string?` at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:37`), so adding a status value needs no migration. + - **Computed property excluded** (`:67`): `builder.Ignore(p => p.Duration)`, since `Duration` is derived from `StartsAt` and `EndsAt` (`Session.cs:80`). - **Event relationship** (`:72-75`) required, plus `HasIndex(p => p.EventId).HasSoftDeleteFilter()` (`:77-78`), a non-unique filtered lookup index for "all live sessions of this event", the single hottest read in the app. - **Room relationship** (`:83-87`) optional, with the `Restrict` behaviour described above. - **Why it's built this way**: the required or optional split mirrors the real conference workflow (accept first, schedule later), and the two relationship decisions, no inverse navigation and restricted room deletes, both trade a little convenience for predictable performance and predictable schedule integrity. @@ -800,255 +880,23 @@ type list, the same extension point every module assembly provides. > MMCA.ADC.Conference.Infrastructure · `MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SponsorConfiguration.cs:11` · Level 9 · class -- **What it is**: the persistence map for [`Sponsor`](group-17-conference-domain.md#sponsor): a sponsoring organization's branding, tier, links, and optional expo-booth details, scoped to one [`Event`](group-17-conference-domain.md#event). -- **Depends on**: first-party: [`EntityTypeConfigurationSQLServer`](group-07-persistence-ef-core.md#entitytypeconfigurationsqlservertentity-tidentifiertype), [`Sponsor`](group-17-conference-domain.md#sponsor), [`SponsorTier`](group-17-conference-domain.md#sponsortier), [`SponsorInvariants`](group-17-conference-domain.md#sponsorinvariants), [`Event`](group-17-conference-domain.md#event), [`IndexBuilderExtensions`](group-07-persistence-ef-core.md#indexbuilderextensions). External: `Microsoft.EntityFrameworkCore.Metadata.Builders`. -- **Concept introduced, storing an enum as its underlying int on purpose.** `builder.Property(p => p.Tier).HasConversion().IsRequired()` (`:25-27`) makes the [`SponsorTier`](group-17-conference-domain.md#sponsortier) enum a plain `int` column. EF would map an enum to `int` by default anyway, so the value of writing it out is documentary: the comment (`:23-24`) states the two consequences the team wants pinned down, that **tier ordering becomes a plain column sort** (Platinum before Gold falls out of the numeric ordering, no lookup table and no `CASE` expression), and that **adding a package later does not rewrite existing rows**, which a string-backed enum with a renamed member would. `[Rubric §8, Data Architecture]` assesses whether a stored representation is chosen for the queries and the migrations it will have to survive. - Contrast this with `Session.Status` (a plain string, [`SessionConfiguration`](#sessionconfiguration) `:34-36`) and `Question.QuestionType` (also a string, [`QuestionConfiguration`](#questionconfiguration) `:26-28`). The codebase does not apply one rule everywhere: values with a meaningful **order** are ints, open-ended vocabularies stay strings. -- **Walkthrough** - - **Required** (`:19-27`, `:49-53`, `:59-60`): `Name` at `SponsorInvariants.NameMaxLength`; `Tier` as above; `Sort`; `IsExhibitor`; the `EventId` scalar. - - **Optional branding and links** (`:29-47`): `LogoUrl`, `Description`, `WebsiteUrl`, `LinkedInUrl`, `TwitterHandle`, each length-capped from `SponsorInvariants`. A sponsor row is useful the moment it has a name and a tier; everything the sponsor sends over later is nullable. - - **Optional booth detail** (`:55-57`): `BoothNumber`, paired with the required `IsExhibitor` flag. Sponsorship and exhibiting are separate facts: a sponsor can have a tier without a booth. - - **Event relationship** (`:62-65`): required `HasOne(p => p.Event).WithMany().HasForeignKey(p => p.EventId)`, with the parameterless `WithMany()`, so `Event` exposes no sponsors collection, the same one-sided-navigation choice made in [`SessionConfiguration`](#sessionconfiguration). - - **Lookup index** (`:67-68`): `HasIndex(p => p.EventId).HasSoftDeleteFilter()`, a non-unique filtered index for "all live sponsors of this event", which is exactly what the public sponsor wall queries. -- **Why it's built this way**: the sponsor wall is a public, cached read that always filters by event and orders by tier then `Sort`; making the tier an int and the event lookup a filtered index means that page is a single indexed range scan with an ordering the database can satisfy directly. -- **Where it's used**: the Conference sponsor endpoints and the public sponsor wall UI; the schema is snapshotted by `MMCA.ADC.Migrations.SqlServer.Conference` like the rest of the family. - ---- - -### ConferenceModuleDbSeeder - -> MMCA.ADC.Conference.Infrastructure · `MMCA.ADC.Conference.Infrastructure.Persistence.DbContexts.Seeding` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/Seeding/ConferenceModuleDbSeeder.cs:24` · Level 9 · class - -- **What it is**: the Conference module's idempotent database seeder. It always seeds the **two** - conference events (Cloud + AI and Developers) and the standard feedback questions, and *optionally* - seeds sample browse data (speakers, sessions, event/session speaker links, and sponsors) when an - `includeSampleData` flag is set. It derives from the framework's - [`DbSeeder`](group-07-persistence-ef-core.md#dbseeder) base - (`ConferenceModuleDbSeeder.cs:24`), which is the abstract implementation of - [`IDbSeeder`](group-07-persistence-ef-core.md#idbseeder). -- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (the single constructor - dependency, `:24`), from which every seed method pulls a typed - [`IRepository`](group-07-persistence-ef-core.md#irepositorytentity-tidentifiertype) - (`:61`, `:130`, `:177`, `:230`, `:342`); the domain factories for - [`Event`](group-17-conference-domain.md#event), [`Question`](group-17-conference-domain.md#question), - [`Speaker`](group-17-conference-domain.md#speaker), [`Session`](group-17-conference-domain.md#session) - and [`Sponsor`](group-17-conference-domain.md#sponsor); the aggregate methods - `Event.AddEventSpeaker` and `Session.AddSessionSpeaker` that create the - [`EventSpeaker`](group-17-conference-domain.md#eventspeaker) / - [`SessionSpeaker`](group-17-conference-domain.md#sessionspeaker) links; the - [`SponsorTier`](group-17-conference-domain.md#sponsortier) enum from the module's Shared project - (`:6`); and the reserved-id constants - [`QuestionInvariants`](group-17-conference-domain.md#questioninvariants)`.ManualIdRangeStart` and - [`SessionInvariants`](group-17-conference-domain.md#sessioninvariants)`.ManualIdRangeStart`. Externals - are BCL only (`DateOnly`, `TimeOnly`, `DateTime`, tuple arrays, LINQ `FirstOrDefault`). -- **Concept reinforced, idempotent and environment-gated seeding through the domain factories.** - `[Rubric §17, DevOps & Deployment]` assesses whether database initialization is repeatable and safe to - re-run on every start; `[Rubric §14, Testability]` assesses whether the system provides deterministic - fixtures a test tier can rely on. Every seed step asks the repository first - (`ExistsAsync` at `:64`, `:98`, `:132`, `:189`, `:249`, `:359`) and returns or `continue`s when the row - is already there, so re-running against a seeded database writes nothing. `[Rubric §4, DDD]` shows up - in *how* the rows are written: seed data goes through the same `Event.Create` / `Question.Create` / - `Speaker.Create` / `Session.Create` / `Sponsor.Create` factories the command handlers use, each - returning a `Result` that is checked for `IsFailure` before `AddAsync` (`:85`, `:166`, `:206`, - `:275`, `:380`), so seeded rows satisfy exactly the same invariants as user-created rows. There is no - raw-insert back door. -- **Walkthrough** - - **Constants and suppressions** (`:26`-`:38`): the shared `VenueAddress` literal (`:26`), the venue - map embed URL (`:29`), the placeholder sample-sponsor website (`:32`), and the two published - sponsorship-packet URLs, one per event (`:35`, `:38`). Each URL constant carries its own narrowly - scoped `S1075` (`URIs should not be hardcoded`) suppression with a written justification (`:28`, - `:31`, `:34`, `:37`). `[Rubric §15, Best Practices & Code Quality]` assesses exactly this: analyzer - suppressions are per-symbol and explained, never a blanket file- or project-level disable. - - **Constructor** (`:24`): a primary constructor `(IUnitOfWork unitOfWork, bool includeSampleData = false)`. - `unitOfWork` is null-guarded into a readonly field (`:40`) and the flag is copied (`:41`). The default - is `false`, so the production-safe path is the one you get by forgetting the argument. - - **`SeedAsync`** (`:44`-`:57`): the ordered entry point. Three unconditional steps run first, the - Cloud + AI event, the Developers event, then the questions (`:46`-`:48`). Only when - `_includeSampleData` is true does it continue into `SeedSpeakersAsync`, `SeedSessionsAsync`, - `SeedSampleEventLinksAsync`, and `SeedSponsorsAsync` (`:50`-`:56`). That `if` is the entire - **environment gate**: real events and feedback questions always exist, sample browse rows exist only - where the caller asked for them. - - **`SeedCloudAiConferenceEventAsync`** (`:59`-`:92`): the existence probe deliberately matches **two** - names, `"2026 Atlanta Cloud + AI Conference"` and the pre-rename `"Atlanta Cloud + AI Conference"` - (`:64`-`:66`, with the reason in the comment at `:63`), so a database seeded before the rename is not - given a duplicate. When absent it builds the event through `Event.Create` (`:71`-`:83`): single-day - 2026-05-30, `America/New_York`, Sessionize code `z1ecmzux`, the shared venue constants, organizer - contact `atlcloudconf@gmail.com`, and the Cloud + AI sponsorship packet URL. A failed `Result` simply - returns (`:85`-`:86`). It then calls `eventResult.Value!.Publish()` (`:88`) so the event is publicly - visible the moment it lands, and finishes with `AddAsync` + `SaveChangesAsync` (`:90`-`:91`). - - **`SeedDevelopersConferenceEventAsync`** (`:94`-`:126`): structurally identical, one name only - (`"2026 Atlanta Developers Conference"`, `:99`), 2026-10-17, Sessionize code `sf1nopko`, organizer - contact `atldevcon@gmail.com`, and the Developers-edition packet URL. Both events share the same - physical venue constants. - - **`SeedQuestionsAsync`** (`:128`-`:173`): guarded by a single probe for `"Rate the Session"` with - `QuestionSource == "User"` (`:132`-`:134`), it then walks a literal tuple array of ten questions - (`:139`-`:151`): six Session-scoped (five `Rating` plus a free-text `Comments`) and four Event-scoped - (three `Rating` plus `Comments`). Ids are **explicitly assigned**, starting at - `QuestionInvariants.ManualIdRangeStart` and incrementing (`:153`, `:158`); that constant is - `999_999_000` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:37`), - a reserved band sitting above any real Sessionize id so imported questions can never collide with - these. One `SaveChangesAsync` commits the whole set (`:172`). - - **`SeedSpeakersAsync`** (`:175`-`:215`, sample-only): two sample speakers, Ada Lovelace and Alan - Turing (`:179`-`:183`), each existence-checked by first and last name individually (`:189`-`:191`) so - a partially seeded database is topped up rather than skipped wholesale. Both are created with - `isTopSpeaker: true`. An `added` flag means `SaveChangesAsync` is called only when something actually - changed (`:213`-`:214`), the same guard every sample step uses. - - **`SeedSessionsAsync`** (`:217`-`:284`, sample-only): resolves both seeded events through the shared - `GetSampleEventsAsync` helper (`:227`-`:228`), then declares two sample sessions, one per event - (`:236`-`:240`): the keynote on the Cloud + AI event and the Azure talk on the Developers event. Ids - are assigned from `SessionInvariants.ManualIdRangeStart` and `+ 1` (`:238`-`:239`; the constant is - `999_999_000` at - `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:41`), - because a Session's int primary key **is** its Sessionize id (comment at `:232`-`:235`), so app-created - sessions must take ids from a reserved high band. Start time is computed off the owning event's date, - `sessionEvent.StartDate.ToDateTime(new TimeOnly(13, 0), DateTimeKind.Utc)` (`:257`), one hour long - (`:264`), status `"Accepted"`, no room. - - **`SeedSampleEventLinksAsync`** (`:286`-`:310`, sample-only): loads the two sample speakers untracked - (`:290`-`:295`), bails if either is missing (`:299`-`:300`), then calls both link helpers and combines - their results with `added |=` (`:305`-`:306`), the non-short-circuiting operator, so the session-link - pass always runs even when the event-link pass reported nothing new. - - **`LinkSampleEventSpeakersAsync`** (`:312`-`:331`): re-reads the events **tracked** and with the - `EventSpeakers` collection included (`:316`-`:317`, the include is required because the aggregate - checks that collection), then links Ada to Cloud + AI and Alan to Developers (`:324`, `:327`). - Idempotency here is delegated to the aggregate: `Event.AddEventSpeaker` returns a - `Event.Speaker.Duplicate` failure when a non-deleted link for that speaker already exists - (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:515`-`:522`), so the - seeder only has to look at `IsSuccess`. - - **`LinkSampleSessionSpeakersAsync`** (`:410`-`:430`): the same trick on the other side, sessions - loaded tracked with `SessionSpeakers` included (`:414`-`:418`), then each sample session gets its - matching speaker by title (`:424`-`:425`). Linking **both** paths is deliberate (comment at - `:302`-`:304`): the speakers-by-event filter has a direct `EventSpeaker` branch and a transitive - `SessionSpeaker` branch, and dev/CI data exercises both. - - **`SeedSponsorsAsync`** (`:333`-`:389`, sample-only): four sample sponsors spread across the two - events (`:344`-`:350`), a Platinum and a Gold exhibitor with booth numbers on the Cloud + AI event, a - Silver and a Community non-exhibitor on the Developers event. Sponsors are per-event, so a null event - is skipped (`:355`-`:356`) and each name is existence-checked (`:359`-`:361`) before `Sponsor.Create` - (`:366`-`:378`). - - **`GetSampleEventsAsync`** (`:391`-`:408`): a `static` helper that fetches both events in one tracked - `GetAllAsync` with a caller-supplied `includes` list (`:397`-`:402`), picks the Developers event by - exact name and treats "anything else in the result" as the Cloud + AI event (`:404`-`:405`), which is - how the pre-rename name keeps resolving. Passing `includes` in lets one helper serve both the - no-navigation callers (`:228`, `:340`) and the `EventSpeakers`-loading caller (`:317`). -- **Why it's built this way**: seeding through domain factories keeps seed rows valid by construction - rather than by hand-written SQL that drifts from the invariants; per-row existence probes make the - whole seeder safe to run on every startup, which is what the deployed hosts do (each service migrates - and seeds its own database, see [ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)); - and the `includeSampleData` default of `false` keeps browse fixtures out of production while - guaranteeing dev and CI always have at least one session and one speaker. The class remarks - (`:16`-`:23`) name the two public-browse E2E tests (`PublicBrowseTests.PublicSessionList_*` / - `PublicSpeakerList_*`) that depend on exactly that guarantee. -- **Where it's used**: constructed and driven by - [`ConferenceModuleSeeder`](group-20-conference-api-grpc.md#conferencemoduleseeder) - (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/ConferenceModuleSeeder.cs:28`-`:29`), the - module's [`IModuleSeeder`](group-14-module-system-composition.md#imoduleseeder) implementation, which - resolves `IUnitOfWork` from the service provider (`ConferenceModuleSeeder.cs:21`), reads - `Seeding:IncludeSampleConferenceData` from configuration (`:26`), and awaits `SeedAsync`. Module - seeders are invoked by [`ModuleLoader`](group-14-module-system-composition.md#moduleloader) in module - registration order. -- **Caveats / not-in-source**: (1) the seeder reads no configuration itself, the boolean is the caller's - decision, so which hosts set `Seeding:IncludeSampleConferenceData=true` is a configuration fact, not a - code fact (the class remarks at `:17`-`:20` say the local AppHost and the E2E CI workflow do, and - production leaves it unset). (2) Idempotency is by **name/title match**, so renaming a seeded event, - question, speaker, session, or sponsor in the database causes the next run to insert a fresh copy; the - Cloud + AI probe carries an explicit second name (`:65`) precisely because that already happened once. - (3) In `SeedQuestionsAsync` a mid-loop factory failure `return`s before the single `SaveChangesAsync` - (`:166`-`:167`), so the already-added questions in that batch are never committed, deliberate - all-or-nothing behavior, but it means a partial question set is not possible and a silent no-op is. - (4) The comment at `:223`-`:226` records that databases seeded before the sample sessions were split - across the two events keep the old both-on-one-event shape, because the skip-by-title check never moves - an existing row; the documented remedy is resetting the local SQL volume. - ---- - -### ModuleApplicationDbContext - -> MMCA.ADC.Conference.Infrastructure · `MMCA.ADC.Conference.Infrastructure.Persistence.DbContexts` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:19` · Level 9 · class (abstract) - -- **What it is**: the Conference module's abstract EF Core `DbContext`. It adds nothing but a typed - inventory: fourteen `internal DbSet` properties naming the entities this module persists - (`:27`-`:66`), on top of the framework base - [`ApplicationDbContext`](group-07-persistence-ef-core.md#applicationdbcontext). -- **Depends on**: [`ApplicationDbContext`](group-07-persistence-ef-core.md#applicationdbcontext) (base, - `:24`) and its four constructor inputs, EF Core's `DbContextOptions`, `IServiceProvider`, - [`IEntityConfigurationAssemblyProvider`](group-07-persistence-ef-core.md#ientityconfigurationassemblyprovider), - and [`PhysicalDataSource`](group-07-persistence-ef-core.md#physicaldatasource) (`:20`-`:23`); the - Conference domain entities [`Event`](group-17-conference-domain.md#event), - [`Room`](group-17-conference-domain.md#room), - [`EventSpeaker`](group-17-conference-domain.md#eventspeaker), - [`EventQuestionAnswer`](group-17-conference-domain.md#eventquestionanswer), - [`Session`](group-17-conference-domain.md#session), - [`SessionSpeaker`](group-17-conference-domain.md#sessionspeaker), - [`SessionQuestionAnswer`](group-17-conference-domain.md#sessionquestionanswer), - [`SessionCategoryItem`](group-17-conference-domain.md#sessioncategoryitem), - [`Speaker`](group-17-conference-domain.md#speaker), - [`SpeakerCategoryItem`](group-17-conference-domain.md#speakercategoryitem), - [`Category`](group-17-conference-domain.md#category), - [`CategoryItem`](group-17-conference-domain.md#categoryitem), - [`Question`](group-17-conference-domain.md#question), and - [`Sponsor`](group-17-conference-domain.md#sponsor) (`:27`-`:66`). External: `Microsoft.EntityFrameworkCore`. -- **Concept reinforced, one context class per engine, not per module.** `[Rubric §8, Data Architecture]` - assesses whether the persistence topology is a deliberate design rather than an accident of code - organization. The instinctive reading of this file, "each module has its own DbContext class", is - **not** how the runtime works. The context that actually executes queries is the framework's sealed - [`SQLServerDbContext`](group-07-persistence-ef-core.md#sqlserverdbcontext) - (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/SQLServerDbContext.cs:16`), - one class per storage engine, instantiated once per physical database. Its `OnModelCreating` calls - `ApplyConfigurationsForEntitiesInContext(DataSource.SQLServer, modelBuilder)` - (`SQLServerDbContext.cs:88`), which scans every module assembly supplied by the - `IEntityConfigurationAssemblyProvider` for `IEntityTypeConfigurationSQLServer<,>` implementations and - applies only those whose entity maps to *this* instance's `DataSourceKey` - (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:610`-`:637`, - filtering through [`EntityDataSourceRegistry`](group-07-persistence-ef-core.md#entitydatasourceregistry)). - In other words the EF model is built from the **entity configurations**, not from `DbSet` declarations, - and `DataSourceModelCacheKeyFactory` keys the model cache per data source so the same class can hold - different models per database. That is the design recorded in - [ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html) and - [ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html): splitting the context - per module is explicitly rejected, because engine choice, not module membership, is what a context - class encodes. +- **What it is**: the EF Core persistence map for the [`Sponsor`](group-17-conference-domain.md#sponsor) aggregate: eleven column facets, an enum-to-int conversion for the sponsorship tier, the required relationship to the owning [`Event`](group-17-conference-domain.md#event), and one non-unique filtered lookup index. It is the newest member of the seventeen-class configuration family in this folder (seventeen `*Configuration.cs` files today). +- **Depends on**: first-party: [`EntityTypeConfigurationSQLServer`](group-07-persistence-ef-core.md#entitytypeconfigurationsqlservertentity-tidentifiertype) (base, `:12`), [`Sponsor`](group-17-conference-domain.md#sponsor), [`Event`](group-17-conference-domain.md#event), [`SponsorInvariants`](group-17-conference-domain.md#sponsorinvariants) (every `HasMaxLength` argument), [`SponsorTier`](group-17-conference-domain.md#sponsortier) (indirectly, through the `Tier` property it converts), and [`IndexBuilderExtensions`](group-07-persistence-ef-core.md#indexbuilderextensions) for `HasSoftDeleteFilter()` (`:68`, imported at `:3`). External: `Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder` (`:1`). +- **Concept**: the shared shape of this family, an `internal sealed` class over the SQL Server base whose `Configure` opens with `base.Configure(builder)` and therefore inherits table name, schema, key and value generation, is taught once under [`CategoryItemConfiguration`](#categoryitemconfiguration). That section also explains why the length constants come from an `...Invariants` class rather than from literals, and what `HasSoftDeleteFilter()` does. Only the two ideas below are new here. +- **Concept introduced, storing an enum as its underlying `int` on purpose.** `Tier` is a [`SponsorTier`](group-17-conference-domain.md#sponsortier), a four-member enum whose numeric values are deliberately the display order: `Platinum = 0`, `Gold = 1`, `Silver = 2`, `Community = 3` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sponsors/SponsorTier.cs:15-24`, with the ordering rationale in the doc comment at `:4-6` and the CA1008 zero-member note at `:9-10`). The configuration spells the storage out, `builder.Property(p => p.Tier).HasConversion().IsRequired()` (`:25-27`), and the comment above it (`:23-24`) gives both halves of the reason: the tier ordering stays a plain integer column sort, and adding a package later does not rewrite existing rows. The second half is the part worth internalizing. Appending a new member at the high end of the enum leaves every stored row valid, while re-numbering to slot a package into the middle would require a data migration. The shipped column is `Tier int NOT NULL` (`MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Conference/Migrations/20260812202047_AddSponsors.cs:22`). This is the only `HasConversion()` in the Conference configuration folder; the one other converter in the family, [`SpeakerConfiguration`](#speakerconfiguration)'s `NullableEmailValueConverter` (`SpeakerConfiguration.cs:43`), converts a value object, not an enum. `[Rubric §8, Data Architecture]` assesses whether column types are a deliberate choice rather than a convention default: writing the conversion at the call site pins the storage shape where a reader of the mapping will see it, instead of leaving it implied by provider convention two layers away. +- **Concept reinforced, a root that references another root by id.** `Sponsor` is an aggregate root in its own right (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:18`, `sealed class Sponsor : AuditableAggregateRootEntity`), not a child of the `Event` aggregate. The mapping shows that boundary directly: the relationship is declared `HasOne(p => p.Event).WithMany().HasForeignKey(p => p.EventId).IsRequired()` (`:62-65`), and `WithMany()` takes **no** navigation expression because [`Event`](group-17-conference-domain.md#event) exposes no `Sponsors` collection at all (`Event.cs` mentions sponsorship only as the scalar `SponsorshipPacketUrl` at `:62`). So a sponsor knows its event, an event does not enumerate its sponsors, and nothing can load a sponsor set by walking the event aggregate: reads go through a filter on `EventId`. `[Rubric §4, DDD]` assesses whether aggregate boundaries are drawn and then respected in the persistence layer; a one-way navigation is how that boundary gets enforced by the mapping rather than left to discipline. Contrast [`SessionAiScoreConfiguration`](#sessionaiscoreconfiguration), which goes one step further and maps no relationship at all, and [`SessionSpeakerConfiguration`](#sessionspeakerconfiguration), whose `WithMany(p => p.SessionSpeakers)` names both ends because that row genuinely belongs to the session aggregate. - **Walkthrough** - - **Primary constructor** (`:19`-`:24`): four parameters, `DbContextOptions options`, - `IServiceProvider serviceProvider`, `IEntityConfigurationAssemblyProvider assemblyProvider`, and - `PhysicalDataSource physicalDataSource`, forwarded verbatim to the base (`:24`). No parameter is - stored, transformed, or validated here; the whole file is pass-through plus declarations. - - **The fourteen `DbSet` properties** (`:27`-`:66`): aggregate roots (`Events`, `Sessions`, `Speakers`, - `Categories`, `Questions`, `Sponsors`), their children (`Rooms`, `CategoryItems`), and the join and - answer entities (`EventSpeakers`, `EventQuestionAnswers`, `SessionSpeakers`, - `SessionQuestionAnswers`, `SessionCategoryItems`, `SpeakerCategoryItems`). They are `internal`, not - `public`: nothing outside this assembly can reach a `DbSet`, which keeps application code on the - repository and unit-of-work abstractions instead of on EF directly. - - **Inherited behavior**: the class body defines no overrides at all. Audit stamping, soft-delete and - tenant query filters, domain-event capture, and outbox persistence all come from the base and its - interceptors, [`AuditSaveChangesInterceptor`](group-07-persistence-ef-core.md#auditsavechangesinterceptor) - and [`DomainEventSaveChangesInterceptor`](group-07-persistence-ef-core.md#domaineventsavechangesinterceptor), - which is how a Conference `SaveChangesAsync` writes an - [`OutboxMessage`](group-04-events-outbox.md#outboxmessage) row in the same transaction as the - aggregate change ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)). -- **Why it's built this way**: the per-module abstract context is the module's declared persistence - surface, one file you can read to learn exactly which tables the Conference module owns, without - fragmenting the runtime into per-module contexts (which would break cross-module transactions and - multiply model caches). Each sibling module declares its own identically named class in its own - namespace, Engagement at - `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:19` - and Identity at - `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:15`, - so the three read as parallel inventories of three module-owned databases (`ADC_Conference`, - `ADC_Engagement`, `ADC_Identity`). -- **Where it's used**: as a declaration, and only as one, today. A repository-wide search for the type - name across `MMCA.ADC/Source` and `MMCA.ADC/Tests` returns nothing but the three class declarations - themselves, so **no concrete class in this repository derives from it** and no code resolves it from - DI; the Conference tables are reached through - [`SQLServerDbContext`](group-07-persistence-ef-core.md#sqlserverdbcontext) instances created by the - framework's context factories, and the entity configurations covered in the sibling section of this - chapter are what put those tables in the model. -- **Caveats / not-in-source**: (1) The XML doc comment (`:14`-`:18`) says the class "declares the DbSets - for all Conference entities". Two entities that **do** have SQL Server configurations in this module, - [`SessionAiScore`](group-17-conference-domain.md#sessionaiscore) (`SessionAiScoreConfiguration.cs`) and - `SpeakerQuestionAnswer` (`SpeakerQuestionAnswerConfiguration.cs`), have no `DbSet` here, and they are - still mapped and still persisted, which is the clearest available proof that the model comes from the - configurations rather than from this list. Treat the `DbSet` block as a helpful but non-authoritative - index. (2) Because nothing derives from this abstract class, whether a future engine-specific or - test-specific subclass is intended is not determinable from source. + - **Class declaration** (`:11-12`): `internal sealed class SponsorConfiguration : EntityTypeConfigurationSQLServer`, the second type argument being the module's identifier alias. + - **`base.Configure(builder)`** (`:17`): table `Sponsor`, schema `Conference` (both derived, and both visible in the shipped migration at `20260812202047_AddSponsors.cs:15-16`), key on `Id`, identity value generation. + - **Required scalars** (`:19-21`, `:49-53`, `:59-60`): `Name` at `SponsorInvariants.NameMaxLength` (200, `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/SponsorInvariants.cs:13`); `Sort` and `IsExhibitor` required with no configured default; `EventId` required. + - **The tier conversion** (`:25-27`): described above. + - **Optional presentation columns** (`:29-47`, `:55-57`): `LogoUrl`, `Description`, `WebsiteUrl` and `LinkedInUrl` each `IsRequired(false)` at 2000 characters (`SponsorInvariants.cs:16`, `:19`, `:22`, `:25`); `TwitterHandle` at 100 (`:28`); `BoothNumber` at 50 (`:31`). All seven widths read a constant, so this configuration contains no literal lengths. + - **Event relationship** (`:62-65`): the one-way required `HasOne`/`WithMany()` pair described above. The shipped foreign key is `FK_Sponsor_Event_EventId` with `ReferentialAction.Cascade` (`20260812202047_AddSponsors.cs:42-48`). + - **Lookup index** (`:67-68`): `builder.HasIndex(p => p.EventId).HasSoftDeleteFilter()`. It is **not** unique, so [`SoftDeleteUniqueIndexConvention`](group-07-persistence-ef-core.md#softdeleteuniqueindexconvention) would never have touched it and the explicit call is the only way the predicate gets applied; the migration confirms the shipped shape, `IX_Sponsor_EventId` with `filter: "[IsDeleted] = 0"` (`20260812202047_AddSponsors.cs:51-56`). It is aimed at exactly one query: the public sponsor strip fetches a page with `filters["EventId"] = ("equals", ...)` and `sortColumn: "Sort"` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSponsorList.razor.cs:64-74`), an equality predicate on `EventId` intersected with the global soft-delete filter, and the filtered index covers both halves. `[Rubric §12, Performance and Scalability]` assesses whether index shape follows the queries that actually run. + - **What is absent from this file** and still ends up in the table: `IsDeleted`, `CreatedOn`/`CreatedBy`, `LastModifiedOn`/`LastModifiedBy` and the `rowversion` concurrency token are all in the shipped table (`20260812202047_AddSponsors.cs:32-37`) without appearing anywhere in `Configure`. They come from [`ApplicationDbContext`](group-07-persistence-ef-core.md#applicationdbcontext) and the entity base, which makes this the cleanest single illustration in the chapter of the division of labour taught under [`CategoryItemConfiguration`](#categoryitemconfiguration): a configuration class owns only *this entity's* columns, relationships and indexes. +- **Why it's built this way**: sponsors are per-event data with a public, ordered presentation, so the mapping optimizes for the two things the public page does, filter by event and sort within a tier, and for schema stability as sponsorship packages change. Keeping every width on `SponsorInvariants` means the column, the domain guard (`EnsureNameIsValid` at `SponsorInvariants.cs:39`, `EnsureLogoUrlIsValid` at `:51`, `EnsureBoothNumberIsValid` at `:63`) and the Application-layer request validators cannot drift apart, which is what `[Rubric §16, Maintainability]` looks for. Confining all of it to one Infrastructure class keeps the `Sponsor` entity free of EF attributes, the Clean Architecture dependency rule this whole folder exists to serve. +- **Where it's used**: applied when the concrete [`SQLServerDbContext`](group-07-persistence-ef-core.md#sqlserverdbcontext) for the `ADC_Conference` database builds its model by scanning the module assembly, exactly like its sixteen siblings; snapshotted by the Conference migrations project (`MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Conference`, table created by `20260812202047_AddSponsors.cs`). Rows are written by [`ConferenceModuleDbSeeder`](#conferencemoduledbseeder)'s sample-data path and by the sponsor command handlers, and read by [`SponsorService`](group-21-conference-ui.md#sponsorservice) for [`PublicSponsorList`](group-21-conference-ui.md#publicsponsorlist). [`ModuleApplicationDbContext`](#moduleapplicationdbcontext) does declare a `Sponsors` `DbSet`, but as that section explains, the `DbSet` list is an index, not the source of the model. +- **Caveats / not-in-source**: (1) There is **no unique index on `(EventId, Name)`**, or on `Name` at all. The seeder's idempotency check probes `s => s.Name == name` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/Seeding/ConferenceModuleDbSeeder.cs:364-366`), so uniqueness of sponsor names is an application-level convention with no database backstop, unlike the room-name and session-speaker cases elsewhere in this folder. (2) The `Tier` column carries no check constraint, so a value outside `0..3` would be storable by anything that bypasses the domain factory; the enum is enforced in the CLR type, not in SQL. (3) `BoothNumber` is nullable and independent of `IsExhibitor`: the domain deliberately accepts a booth number on a non-exhibitor (`SponsorInvariants.cs:57-58`, "the flag drives display, it does not reject stored data"), and the mapping adds no constraint tying the two together. (4) The `Cascade` delete on the event foreign key is not overridden here; because the codebase soft-deletes rather than hard-deletes, whether that cascade ever executes in a deployed database is not determinable from source. (5) The grouping by tier that the public page renders happens **in memory** after the fetch (`PublicSponsorList.razor.cs:81-85`), not as a SQL `ORDER BY Tier`, so the int conversion enables a cheap column sort that today's read path does not yet ask the database to perform. --- diff --git a/docs-src/onboarding/group-20-conference-api-grpc.md b/docs-src/onboarding/group-20-conference-api-grpc.md index 884577e..a78638b 100644 --- a/docs-src/onboarding/group-20-conference-api-grpc.md +++ b/docs-src/onboarding/group-20-conference-api-grpc.md @@ -1,72 +1,76 @@ # 20. ADC Conference - API, gRPC Contracts & Service Host -This chapter is the **edge of the Conference bounded context**, the layer that turns the rich Conference domain ([G17](group-17-conference-domain.md)) and its CQRS slices ([G18](group-18-conference-application.md)) into a running HTTP + gRPC surface, plus the small amount of glue that lets that surface be hosted **either** inside the ADC monolith **or** as its own extracted microservice (`MMCA.ADC.Conference.Service`) with no change to the application code beneath. Almost nothing here is novel: the controllers are thin shells over the generic REST machinery taught in [G12 (API Hosting, Middleware & DTO Mapping)](group-12-api-hosting-mapping.md), the gRPC pieces are concrete instances of the transport boundary taught in [G13 (gRPC & Inter-Service Contracts)](group-13-grpc-contracts.md), and the module entry point is one implementation of the [`IModule`](group-14-module-system-composition.md#imodule) contract from [G14 (Module System & Composition)](group-14-module-system-composition.md). What this chapter teaches is *how the Conference module wires those reusable pieces into a real, sixteen-controller, twice-gRPC-edged conference API*, and the handful of places where it deviates from the generic shape for a genuine business reason. The headline rubric lenses are `[Rubric §9, API & Contract Design]` (a consistent, versioned REST + gRPC contract), `[Rubric §5, Vertical Slice]` and `[Rubric §6, CQRS & Event-Driven]` (each action dispatches to a single command/query handler), and `[Rubric §7, Microservices Readiness]` (the same code runs in-process or extracted). Everything lives in three projects: `MMCA.ADC.Conference.API` (the REST controllers, the [`ConferenceModule`](#conferencemodule) entry point, the [`ConferenceModuleSeeder`](#conferencemoduleseeder)), `MMCA.ADC.Conference.Service` (the host wiring plus the gRPC servers), and `MMCA.ADC.Conference.Contracts` (the client-side gRPC adapters and the contract-package DI). +**What this chapter covers.** This is the **edge of the Conference bounded context**, the layer that turns the rich Conference domain ([G17](group-17-conference-domain.md)) and its CQRS slices ([G18](group-18-conference-application.md)) into a running HTTP + gRPC surface, plus the small amount of glue that lets that surface be hosted **either** inside a co-located host **or** as its own extracted microservice (`MMCA.ADC.Conference.Service`) with no change to the application code beneath. Almost nothing here is novel: the controllers are thin shells over the generic REST machinery taught in [G12 (API Hosting, Middleware & DTO Mapping)](group-12-api-hosting-mapping.md), the gRPC pieces are concrete instances of the transport boundary taught in [G13 (gRPC & Inter-Service Contracts)](group-13-grpc-contracts.md), and the module entry point is one implementation of the [`IModule`](group-14-module-system-composition.md#imodule) contract from [G14 (Module System & Composition)](group-14-module-system-composition.md). What this chapter teaches is *how the Conference module wires those reusable pieces into a real, seventeen-controller, twice-gRPC-edged conference API*, and the handful of places where it deviates from the generic shape for a genuine business reason. The headline rubric lenses are `[Rubric §9, API & Contract Design]` (a consistent, versioned REST + gRPC contract), `[Rubric §5, Vertical Slice]` and `[Rubric §6, CQRS & Event-Driven]` (each action dispatches to a single command/query handler), and `[Rubric §7, Microservices Readiness]` (the same code runs in-process or extracted). Everything lives in three projects: `MMCA.ADC.Conference.API` (the REST controllers, the [`ConferenceModule`](#conferencemodule) entry point, the [`ConferenceModuleSeeder`](#conferencemoduleseeder)), `MMCA.ADC.Conference.Service` (the host wiring plus the gRPC servers), and `MMCA.ADC.Conference.Contracts` (the client-side gRPC adapters and the contract-package DI). ## The controller hierarchy, almost everything is inherited -The Conference API exposes **sixteen controllers**, and the striking thing about them is how little code each carries. They split into three structural families, all built on the generic controller bases from [G12](group-12-api-hosting-mapping.md). **Aggregate-root controllers** (six: [`SessionsController`](#sessionscontroller), [`SpeakersController`](#speakerscontroller), [`EventsController`](#eventscontroller), [`QuestionsController`](#questionscontroller), [`ConferenceCategoriesController`](#conferencecategoriescontroller), [`SponsorsController`](#sponsorscontroller)) derive from [`AggregateRootEntityControllerBase`](group-12-api-hosting-mapping.md#aggregaterootentitycontrollerbasetentity-tentitydto-tidentifiertype-tcreaterequest) and inherit the full read + create + delete surface, often only `override`-ing actions to add `[AllowAnonymous]`, an `[OutputCache]` policy, or a business rule ([`SessionsController`](#sessionscontroller) derives from that base at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionsController.cs:54`, [`EventsController`](#eventscontroller) at `EventsController.cs:57`, [`QuestionsController`](#questionscontroller) at `QuestionsController.cs:38`, [`ConferenceCategoriesController`](#conferencecategoriescontroller) at `ConferenceCategoriesController.cs:39`, [`SpeakersController`](#speakerscontroller) at `SpeakersController.cs:59`, [`SponsorsController`](#sponsorscontroller) at `SponsorsController.cs:46`). **Child-and-join controllers** (eight: [`RoomsController`](#roomscontroller), [`CategoryItemsController`](#categoryitemscontroller), [`EventSpeakersController`](#eventspeakerscontroller), [`SessionSpeakersController`](#sessionspeakerscontroller), [`SessionCategoryItemsController`](#sessioncategoryitemscontroller), [`SpeakerCategoryItemsController`](#speakercategoryitemscontroller), [`EventQuestionAnswersController`](#eventquestionanswerscontroller), [`SessionQuestionAnswersController`](#sessionquestionanswerscontroller)) derive from the read-oriented [`EntityControllerBase`](group-12-api-hosting-mapping.md#entitycontrollerbasetentity-tentitydto-tidentifiertype) (`RoomsController.cs:92`, `CategoryItemsController.cs:68`, `EventSpeakersController.cs:54`, `SessionSpeakersController.cs:55`, `SessionCategoryItemsController.cs:55`, `SpeakerCategoryItemsController.cs:55`, `EventQuestionAnswersController.cs:63`, `SessionQuestionAnswersController.cs:63`) and add their own `POST`/`PUT`/`DELETE` actions by hand, because they manipulate a *child* of an aggregate (a room belongs to an event, a category item to a category) and so their write commands carry a parent identifier the generic create/delete cannot supply. And **bespoke controllers** (two: [`ServiceInfoController`](#serviceinfocontroller) and [`SessionSelectionController`](#sessionselectioncontroller)) sit apart: `SessionSelectionController` derives from Common's [`ApiControllerBase`](group-12-api-hosting-mapping.md#apicontrollerbase) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:36`) and `ServiceInfoController` from the shared [`ServiceInfoControllerBase`](group-12-api-hosting-mapping.md#serviceinfocontrollerbase) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/ServiceInfoController.cs:20`), because neither exposes a CRUD entity at all. +The Conference API exposes **seventeen controllers**, and the striking thing about them is how little code each carries. They split into three structural families, all built on the generic controller bases from [G12](group-12-api-hosting-mapping.md). **Aggregate-root controllers** (seven: [`SessionsController`](#sessionscontroller), [`SpeakersController`](#speakerscontroller), [`EventsController`](#eventscontroller), [`QuestionsController`](#questionscontroller), [`ConferenceCategoriesController`](#conferencecategoriescontroller), [`SponsorsController`](#sponsorscontroller), [`ActivitiesController`](#activitiescontroller)) derive from [`AggregateRootEntityControllerBase`](group-12-api-hosting-mapping.md#aggregaterootentitycontrollerbasetentity-tentitydto-tidentifiertype-tcreaterequest) and inherit the full read + create + delete surface, often only `override`-ing actions to add `[AllowAnonymous]`, an `[OutputCache]` policy, or a business rule ([`SessionsController`](#sessionscontroller) derives from that base at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionsController.cs:54`, [`EventsController`](#eventscontroller) at `EventsController.cs:58`, [`QuestionsController`](#questionscontroller) at `QuestionsController.cs:38`, [`ConferenceCategoriesController`](#conferencecategoriescontroller) at `ConferenceCategoriesController.cs:39`, [`SpeakersController`](#speakerscontroller) at `SpeakersController.cs:59`, [`SponsorsController`](#sponsorscontroller) at `SponsorsController.cs:46`, [`ActivitiesController`](#activitiescontroller) at `ActivitiesController.cs:46`). **Child-and-join controllers** (eight: [`RoomsController`](#roomscontroller), [`CategoryItemsController`](#categoryitemscontroller), [`EventSpeakersController`](#eventspeakerscontroller), [`SessionSpeakersController`](#sessionspeakerscontroller), [`SessionCategoryItemsController`](#sessioncategoryitemscontroller), [`SpeakerCategoryItemsController`](#speakercategoryitemscontroller), [`EventQuestionAnswersController`](#eventquestionanswerscontroller), [`SessionQuestionAnswersController`](#sessionquestionanswerscontroller)) derive from the read-oriented [`EntityControllerBase`](group-12-api-hosting-mapping.md#entitycontrollerbasetentity-tentitydto-tidentifiertype) (`RoomsController.cs:101`, `CategoryItemsController.cs:69`, `EventSpeakersController.cs:55`, `SessionSpeakersController.cs:56`, `SessionCategoryItemsController.cs:56`, `SpeakerCategoryItemsController.cs:56`, `EventQuestionAnswersController.cs:64`, `SessionQuestionAnswersController.cs:64`) and add their own `POST`/`PUT`/`DELETE` actions by hand, because they manipulate a *child* of an aggregate (a room belongs to an event, a category item to a category) and so their write commands carry a parent identifier the generic create/delete cannot supply. And **bespoke controllers** (two: [`ServiceInfoController`](#serviceinfocontroller) and [`SessionSelectionController`](#sessionselectioncontroller)) sit apart: `SessionSelectionController` derives from Common's [`ApiControllerBase`](group-12-api-hosting-mapping.md#apicontrollerbase) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:37`) and `ServiceInfoController` from the shared [`ServiceInfoControllerBase`](group-12-api-hosting-mapping.md#serviceinfocontrollerbase) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/ServiceInfoController.cs:20`), because neither exposes a CRUD entity at all. -The reason a concrete controller can be short is that the generic bases already supply `GET` (capped, returning [`CollectionResult`](group-01-result-error-handling.md#collectionresultt)), `GET /paged` (filtered/sorted/paged, returning [`PagedCollectionResult`](group-01-result-error-handling.md#pagedcollectionresultt)), `GET /lookup` (id+name pairs as [`BaseLookup`](group-12-api-hosting-mapping.md#baselookuptidentifiertype) for dropdowns), `GET /{id}`, `GET /export` (a streamed CSV), and, on the aggregate base, `POST` (to `201 Created`) and `DELETE` (to `204`). Each Conference controller's constructor simply injects the [`IEntityQueryService`](group-03-querying-specifications.md#ientityqueryservicetentity-tentitydto-tidentifiertype) for reads and the specific [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) / [`IQueryHandler`](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) instances for its writes and bespoke reads (`SessionsController.cs:42-53`), then folds any `Result.Failure` back through the inherited `HandleFailure` (`SessionsController.cs:242`, `SessionSelectionController.cs:49`). That is the `[Rubric §1, SOLID]` / `[Rubric §16, Maintainability & Evolvability]` payoff the generic base exists for (the generic-controller + dynamic-query contract of [ADR-034](https://ivanball.github.io/docs/adr/034-generic-entity-query-layer.html)): the CRUD logic is written once in Common, and a per-entity controller has almost no reason to change. +The reason a concrete controller can be short is that the generic bases already supply `GET` (capped, returning [`CollectionResult`](group-01-result-error-handling.md#collectionresultt)), `GET /paged` (filtered/sorted/paged, returning [`PagedCollectionResult`](group-01-result-error-handling.md#pagedcollectionresultt)), `GET /lookup` (id+name pairs as [`BaseLookup`](group-12-api-hosting-mapping.md#baselookuptidentifiertype) for dropdowns), `GET /{id}`, `GET /export` (a streamed CSV), and, on the aggregate base, `POST` (to `201 Created`) and `DELETE` (to `204`). Each Conference controller's constructor simply injects the [`IEntityQueryService`](group-03-querying-specifications.md#ientityqueryservicetentity-tentitydto-tidentifiertype) for reads and the specific [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) / [`IQueryHandler`](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) instances for its writes and bespoke reads (`SessionsController.cs:42-53`), then folds any `Result.Failure` back through the inherited `HandleFailure` (`SessionsController.cs:242`, `SessionSelectionController.cs:50`). That is the `[Rubric §1, SOLID]` / `[Rubric §16, Maintainability & Evolvability]` payoff the generic base exists for (the generic-controller + dynamic-query contract of [ADR-034](https://ivanball.github.io/docs/adr/034-generic-entity-query-layer.html)): the CRUD logic is written once in Common, and a per-entity controller has almost no reason to change. ## Authorization at the edge, three shapes not one -Authorization is **capability-based by default but not uniform**, and the differences are the interesting part. Most write-bearing controllers carry a class-level [`HasPermissionAttribute`](group-08-auth.md#haspermissionattribute) gate naming one [`ConferencePermissions`](group-17-conference-domain.md#conferencepermissions) capability rather than a role policy: `SessionsManage` on [`SessionsController`](#sessionscontroller) (`SessionsController.cs:41`) and on the two session-join controllers (`SessionSpeakersController.cs:46`, `SessionCategoryItemsController.cs:46`), `EventsManage` (`EventsController.cs:43`, `EventSpeakersController.cs:45`), `RoomsManage` (`RoomsController.cs:84`), `CategoriesManage` (`ConferenceCategoriesController.cs:31`, `CategoryItemsController.cs:60`), `QuestionsManage` (`QuestionsController.cs:30`), `SpeakersManage` (`SpeakerCategoryItemsController.cs:46`), and `SessionSelectionManage` (`SessionSelectionController.cs:28`). Reads are then re-opened action by action with `[AllowAnonymous]` (BR-43 public browse, for example `SessionsController.cs:126`, `RoomsController.cs:95`). +Authorization is **capability-based by default but not uniform**, and the differences are the interesting part. Most write-bearing controllers carry a class-level [`HasPermissionAttribute`](group-08-auth.md#haspermissionattribute) gate naming one [`ConferencePermissions`](group-17-conference-domain.md#conferencepermissions) capability rather than a role policy: `SessionsManage` on [`SessionsController`](#sessionscontroller) (`SessionsController.cs:41`) and on the two session-join controllers (`SessionSpeakersController.cs:47`, `SessionCategoryItemsController.cs:47`), `EventsManage` (`EventsController.cs:44`, `EventSpeakersController.cs:46`), `RoomsManage` (`RoomsController.cs:91`), `CategoriesManage` (`ConferenceCategoriesController.cs:31`, `CategoryItemsController.cs:61`), `QuestionsManage` (`QuestionsController.cs:30`), `SpeakersManage` (`SpeakerCategoryItemsController.cs:47`), and `SessionSelectionManage` (`SessionSelectionController.cs:29`). Reads are then re-opened action by action with `[AllowAnonymous]` (BR-43 public browse, for example `SessionsController.cs:126`, `RoomsController.cs:131`). Nine capability constants exist in total, declared once in `ConferencePermissions.All` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Authorization/ConferencePermissions.cs:39-50`). -Three controllers deliberately break that pattern, and knowing why saves you from "fixing" them. [`SpeakersController`](#speakerscontroller) carries only a plain `[Authorize]` at class level (`SpeakersController.cs:43`) and pushes `[HasPermission(ConferencePermissions.SpeakersManage)]` down onto the individual organizer write actions (`SpeakersController.cs:290,309,353,365,384`), because one of its writes is an authenticated self-service surface: the BR-214 profile update re-declares plain `[Authorize]` (`SpeakersController.cs:327-328`) and then decides inside the action whether the caller is an organizer or the speaker themselves, by comparing the `speaker_id` JWT claim to the route id and passing the answer down as `CallerIsOrganizer` so the handler can refuse a self-edit of the organizer-only `IsTopSpeaker` field (`SpeakersController.cs:335-341`). [`SponsorsController`](#sponsorscontroller) copies that shape for the same mechanical reason (`SponsorsController.cs:36`, per-action `SponsorsManage` at `SponsorsController.cs:193,212,224,243`): a bare `[Authorize]` is what the *inherited* export action needs to pick up, so the capability is declared per action instead. And [`EventQuestionAnswersController`](#eventquestionanswerscontroller) / [`SessionQuestionAnswersController`](#sessionquestionanswerscontroller) gate on `[Authorize(Policy = AuthorizationPolicies.RequireAuthenticated)]` instead (`EventQuestionAnswersController.cs:55`, `SessionQuestionAnswersController.cs:55`), because *any* signed-in attendee may submit feedback answers, so no organizer capability applies. Which roles hold which capability is declared once in `AddModuleConferenceAPI` (see below), the permission-over-RBAC model of [ADR-020](https://ivanball.github.io/docs/adr/020-permission-based-authorization.html); `[Rubric §11, Security]` is the lens, and these exceptions are the evidence that the model is applied per endpoint rather than pasted. +Three shapes break that pattern, and knowing why saves you from "fixing" them. [`SpeakersController`](#speakerscontroller) carries only a plain `[Authorize]` at class level (`SpeakersController.cs:43`) and pushes `[HasPermission(ConferencePermissions.SpeakersManage)]` down onto the individual organizer write actions (`SpeakersController.cs:290,309,353,365,384`), because one of its writes is an authenticated self-service surface: the BR-214 profile update re-declares plain `[Authorize]` (`SpeakersController.cs:327-328`) and then decides inside the action whether the caller is an organizer or the speaker themselves, by comparing the `speaker_id` JWT claim to the route id and passing the answer down as `CallerIsOrganizer` so the handler can refuse a self-edit of the organizer-only `IsTopSpeaker` field (`SpeakersController.cs:334-341`). [`SponsorsController`](#sponsorscontroller) and [`ActivitiesController`](#activitiescontroller) copy that shape for the same mechanical reason (`SponsorsController.cs:36`, `ActivitiesController.cs:36`, with per-action `SponsorsManage` at `SponsorsController.cs:193,212,224,243` and `ActivitiesManage` at `ActivitiesController.cs:193,212,224,243`): a bare `[Authorize]` is what the *inherited* export action needs to pick up, so the capability is declared per action instead. And [`EventQuestionAnswersController`](#eventquestionanswerscontroller) / [`SessionQuestionAnswersController`](#sessionquestionanswerscontroller) gate on `[Authorize(Policy = AuthorizationPolicies.RequireAuthenticated)]` instead (`EventQuestionAnswersController.cs:56`, `SessionQuestionAnswersController.cs:56`), because *any* signed-in attendee may submit feedback answers, so no organizer capability applies. Which roles hold which capability is declared once in `AddModuleConferenceAPI` (see below), the permission-over-RBAC model of [ADR-020](https://ivanball.github.io/docs/adr/020-permission-based-authorization.html); `[Rubric §11, Security]` is the lens, and these exceptions are the evidence that the model is applied per endpoint rather than pasted. -Orthogonal to all three shapes is the **read audience**, which no attribute can express because it changes the *rows* rather than the verdict. Eight controllers ask [`CurrentUserServiceExtensions`](#currentuserserviceextensions)`.IsPrivilegedConferenceReader()` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Authorization/CurrentUserServiceExtensions.cs:24`) and turn the answer into a specification or `null`: `SessionsController.cs:58`, `SpeakersController.cs:63`, `EventsController.cs:67`, `SponsorsController.cs:50`, `EventSpeakersController.cs:57`, `SessionSpeakersController.cs:58`, `SessionCategoryItemsController.cs:58`, and `SpeakerCategoryItemsController.cs:58`. The helper is one line over `ICurrentUserService` (`CurrentUserServiceExtensions.cs:25`) and answers against the [`ConferenceReadAudience`](group-17-conference-domain.md#conferencereadaudience)`.PrivilegedRoles` list declared once in G17, with an explicit remark in its own doc comment that this is a read-visibility check and never a substitute for a `[HasPermission(...)]` gate (`CurrentUserServiceExtensions.cs:20-23`). +Orthogonal to all three shapes is the **read audience**, which no attribute can express because it changes the *rows* rather than the verdict. Ten controllers ask [`CurrentUserServiceExtensions`](#currentuserserviceextensions)`.IsPrivilegedConferenceReader()` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Authorization/CurrentUserServiceExtensions.cs:24`) and turn the answer into a specification or `null`: `SessionsController.cs:58`, `SpeakersController.cs:63`, `EventsController.cs:68`, `SponsorsController.cs:50`, `ActivitiesController.cs:50`, `RoomsController.cs:104`, `EventSpeakersController.cs:58`, `SessionSpeakersController.cs:59`, `SessionCategoryItemsController.cs:59`, and `SpeakerCategoryItemsController.cs:59`. The helper is one line over `ICurrentUserService` (`CurrentUserServiceExtensions.cs:25`) and answers against the [`ConferenceReadAudience`](group-17-conference-domain.md#conferencereadaudience)`.PrivilegedRoles` list declared once in G17 (Organizer and ContentEditor, `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Authorization/ConferenceReadAudience.cs:91-95`), with an explicit remark in its own doc comment that this is a read-visibility check and never a substitute for a `[HasPermission(...)]` gate (`CurrentUserServiceExtensions.cs:20-23`). The two feedback-answer controllers use a different scoping axis for the same purpose: BR-8 narrows an attendee to their own answers through an [`OwnedByUserSpecification`](group-03-querying-specifications.md#ownedbyuserspecificationtentity-tidentifiertype) built from the caller's user id, or `null` for an Organizer (`EventQuestionAnswersController.cs:67-68`, applied at `:81,109,145`). -The same helper closes a specific hole worth naming, because it recurs in seven files and is easy to reintroduce. The framework's inherited CSV export streams with **no** specification, so a non-privileged caller would receive the unfiltered catalog in one file: declined sessions, draft events, hidden speakers, unannounced sponsorships. Every controller whose reads are row-filtered therefore overrides `ExportAsync` and returns `Forbid()` for a non-privileged caller rather than serving a scoped file (`SessionsController.cs:251-266` BR-49/BR-132, `SpeakersController.cs:289-306` BR-239, `EventsController.cs:185-201` BR-108, `SponsorsController.cs:192-208` BR-108, and the four join controllers at `EventSpeakersController.cs:200`, `SessionSpeakersController.cs:201`, `SessionCategoryItemsController.cs:201`, `SpeakerCategoryItemsController.cs:201`). Privileged readers already read everything and may export it. That is `[Rubric §11, Security]` again, applied to the one action a generic base cannot make safe on its own. +The read audience closes a specific hole worth naming, because it recurs in eleven files and is easy to reintroduce. The framework's inherited CSV export streams with **no** specification, so a non-privileged caller would receive the unfiltered catalog in one file: declined sessions, draft events, hidden speakers, unannounced sponsorships and activities. Every controller whose reads are row-filtered therefore overrides `ExportAsync` and returns `Forbid()` for a non-privileged caller rather than serving a scoped file (`SessionsController.cs:251-266` BR-49/BR-132, `SpeakersController.cs:289-305` BR-239, `EventsController.cs:186-201` BR-108, `SponsorsController.cs:192-208` BR-108, `ActivitiesController.cs:192-208` BR-108, and the four join controllers at `EventSpeakersController.cs:193,203`, `SessionSpeakersController.cs:194,204`, `SessionCategoryItemsController.cs:194,204`, `SpeakerCategoryItemsController.cs:194,204`); the two answer controllers do the same against the Organizer role, matching their BR-8 row scope (`EventQuestionAnswersController.cs:159-174`, `SessionQuestionAnswersController.cs:159-174`). Privileged readers already read everything and may export it. The controllers with a class-level capability gate and no anonymous export ([`RoomsController`](#roomscontroller), [`CategoryItemsController`](#categoryitemscontroller), [`QuestionsController`](#questionscontroller), [`ConferenceCategoriesController`](#conferencecategoriescontroller)) need no such override, because their inherited export is still behind the class attribute. That is `[Rubric §11, Security]` again, applied to the one action a generic base cannot make safe on its own. ## The request records, the inbound write shapes -Several controllers declare small `record class` request types alongside themselves, co-located in the same file: [`AddRoomRequest`](#addroomrequest)/[`UpdateRoomRequest`](#updateroomrequest) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/RoomsController.cs:24,52`), [`AddCategoryItemRequest`](#addcategoryitemrequest)/[`UpdateCategoryItemRequest`](#updatecategoryitemrequest) (`CategoryItemsController.cs:24,40`), [`AddEventSpeakerRequest`](#addeventspeakerrequest) (`EventSpeakersController.cs:28`), [`AddSessionSpeakerRequest`](#addsessionspeakerrequest) (`SessionSpeakersController.cs:28`), [`AddSpeakerCategoryItemRequest`](#addspeakercategoryitemrequest) (`SpeakerCategoryItemsController.cs:28`), [`AddSessionCategoryItemRequest`](#addsessioncategoryitemrequest) (`SessionCategoryItemsController.cs:28`), [`AddEventQuestionAnswerRequest`](#addeventquestionanswerrequest)/[`UpdateEventQuestionAnswerRequest`](#updateeventquestionanswerrequest) (`EventQuestionAnswersController.cs:26,39`), and [`AddSessionQuestionAnswerRequest`](#addsessionquestionanswerrequest)/[`UpdateSessionQuestionAnswerRequest`](#updatesessionquestionanswerrequest) (`SessionQuestionAnswersController.cs:26,39`). These are the **wire shapes** for the child-entity writes the generic base cannot model: each carries the parent identifier (`EventId` at `RoomsController.cs:27`) plus the child's own fields, all `required`/`init` for immutability (`RoomsController.cs:26-48`), and the controller action unpacks the record positionally into the matching `Add*Command`/`Update*Command` from [G18](group-18-conference-application.md) (`RoomsController.cs:149-159`). They are deliberately separate from the inbound *application* command types (and from the outbound DTOs), the §9 "DTOs decoupled from entities" discipline, so the HTTP contract can evolve independently of the command's parameter list. The aggregate-root controllers, by contrast, reuse the application layer's create-request command directly (for example [`SessionsController`](#sessionscontroller) binds `SessionCreateRequest` as its `TCreateRequest`, `SessionsController.cs:54`), so they need no per-controller record. +Several controllers declare small `record class` request types alongside themselves, co-located in the same file: [`AddRoomRequest`](#addroomrequest)/[`UpdateRoomRequest`](#updateroomrequest) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/RoomsController.cs:30,58`), [`AddCategoryItemRequest`](#addcategoryitemrequest)/[`UpdateCategoryItemRequest`](#updatecategoryitemrequest) (`CategoryItemsController.cs:25,41`), [`AddEventSpeakerRequest`](#addeventspeakerrequest) (`EventSpeakersController.cs:29`), [`AddSessionSpeakerRequest`](#addsessionspeakerrequest) (`SessionSpeakersController.cs:29`), [`AddSpeakerCategoryItemRequest`](#addspeakercategoryitemrequest) (`SpeakerCategoryItemsController.cs:29`), [`AddSessionCategoryItemRequest`](#addsessioncategoryitemrequest) (`SessionCategoryItemsController.cs:29`), [`AddEventQuestionAnswerRequest`](#addeventquestionanswerrequest)/[`UpdateEventQuestionAnswerRequest`](#updateeventquestionanswerrequest) (`EventQuestionAnswersController.cs:27,40`), and [`AddSessionQuestionAnswerRequest`](#addsessionquestionanswerrequest)/[`UpdateSessionQuestionAnswerRequest`](#updatesessionquestionanswerrequest) (`SessionQuestionAnswersController.cs:27,40`). These are the **wire shapes** for the child-entity writes the generic base cannot model: each carries the parent identifier (`EventId` at `RoomsController.cs:33`) plus the child's own fields, all `required`/`init` for immutability (`RoomsController.cs:30-55`), and the controller action unpacks the record positionally into the matching `Add*Command`/`Update*Command` from [G18](group-18-conference-application.md) (`RoomsController.cs:262-272` on create, `:291-301` on update, `:317-319` on delete). They are deliberately separate from the inbound *application* command types (and from the outbound DTOs), the §9 "DTOs decoupled from entities" discipline, so the HTTP contract can evolve independently of the command's parameter list. The aggregate-root controllers, by contrast, reuse the application layer's create-request command directly (for example [`SessionsController`](#sessionscontroller) binds `SessionCreateRequest` as its `TCreateRequest`, `SessionsController.cs:54`), so they need no per-controller record. ## Where the generic shape gives way: filtering, warnings, and calendars [`SessionsController`](#sessionscontroller) is the best illustration of *how* a controller earns its overrides. Every read action is `[AllowAnonymous]` and `[OutputCache(PolicyName = "SessionsCache")]` (`SessionsController.cs:125-127,151-153,200-202,222-224,272-274`), and the reads thread a specification built by `BuildPublicSessionSpecificationAsync` (`SessionsController.cs:67`), which returns `null` for privileged readers and otherwise dispatches the [`GetPublicSessionFilterQuery`](group-18-conference-application.md#getpublicsessionfilterquery) handler so non-organizers never see declined sessions (BR-132/BR-49). The cross-source part matters: `Session` and `Event` can live in different data sources, so the published-event check is resolved by that handler through the framework's cross-source specification helper rather than by a join (`SessionsController.cs:60-66`; [ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html)). The paged read adds a second layer, `BuildPagedSessionSpecificationAsync` (`SessionsController.cs:96`): `Session` has no `SpeakerId` column, so that filter key is intercepted and `Remove`d before the generic filter pipeline can reject it, resolved to an id list through [`GetSessionsBySpeakerFilterQuery`](group-18-conference-application.md#getsessionsbyspeakerfilterquery), and **ANDed** with the public filter via [`AndSpecification`](group-03-querying-specifications.md#andspecificationtentity-tidentifiertype) rather than substituted for it (`SessionsController.cs:102-118`), because substituting would leak non-accepted sessions to anonymous callers; an unparseable value simply ignores the key (`SessionsController.cs:104`). -The same controller adds three things the base has no notion of. A `PUT /{id}` update that surfaces a BR-86 `X-Warning` header when the update handler reports `HasDateRangeWarning` (`SessionsController.cs:323-344`), with the matching check done inline on create by comparing the request times against the event's `StartDate`/`EndDate` (`SessionsController.cs:302-316`). A `GET /{id}/ics` action that streams one public session as a `text/calendar` document for the add-to-calendar affordance (`SessionsController.cs:272-283`) via [`ExportSessionCalendarQuery`](group-18-conference-application.md#exportsessioncalendarquery). And an explicit `[Idempotent]` declaration on the create override (`SessionsController.cs:291`) so the `Idempotency-Key` contract from [`IdempotentAttribute`](group-12-api-hosting-mapping.md#idempotentattribute) is visible at the ADC endpoint rather than only inherited (the attribute is single-use, so the declaration coincides with the inherited one instead of duplicating it, `SessionsController.cs:285-289`). Every mutating action finishes by calling `EvictSessionsCacheAsync`, which evicts both the `conference:sessions` and `conference` output-cache tags (`SessionsController.cs:318,342,353,357-361`), the write-side half of the caching contract. +The `GET /lookup` action is worth internalizing as a family, because five controllers override it for the same reason. A lookup returns id plus label pairs and is anonymous, so left inherited it becomes a side channel that names exactly the rows the list and detail endpoints hide. Each of [`SessionsController`](#sessionscontroller) (`SessionsController.cs:200-220`), [`EventsController`](#eventscontroller) (`EventsController.cs:135-155`), [`RoomsController`](#roomscontroller) (`RoomsController.cs:200-220`), [`SponsorsController`](#sponsorscontroller) (`SponsorsController.cs:136-156`) and [`ActivitiesController`](#activitiescontroller) (`ActivitiesController.cs:136-156`) therefore short-circuits to the base action when the specification is `null` (a privileged reader) and otherwise forwards `specification.Criteria` to the query service as the lookup filter. [`SpeakersController`](#speakerscontroller) goes one step further and also constrains the *label*: only `FirstName` and `LastName` may be requested by a non-privileged caller (`SpeakersController.cs:66,219-230`), because `nameProperty=Email` would project the speaker email straight into the label and go around the DTO mapper that redacts it (BR-66). -[`EventsController`](#eventscontroller) follows the same recipe and adds its own `GET /{id}/ics` (`EventsController.cs:206-217`) plus per-event and global `now-next` snapshot actions under the short-lived `NowNextCache` policy (`EventsController.cs:223-247`), both dispatching [`GetNowNextQuery`](group-18-conference-application.md#getnownextquery) and returning a [`NowNextDTO`](group-17-conference-domain.md#nownextdto); the id-less form exists because the home-screen widget has no event id to pass (`EventsController.cs:244`). Its read filter is the simplest of the group, a plain [`PublishedEventSpecification`](group-18-conference-application.md#publishedeventspecification) or `null` (`EventsController.cs:67`), because `Event` owns its own publish flag. It also carries the publish, unpublish, and Sessionize-refresh commands that have no generic equivalent (`EventsController.cs:294,316,337`), the first two optionally carrying the client's last-seen rowversion for the [ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html) stale-view check (`EventsController.cs:290-293`), and the refresh mapping two domain error codes onto HTTP `429` (with a `Retry-After: 300`) and `502` (`EventsController.cs:348-357`). Cache eviction is proportional to blast radius: an ordinary event write evicts only `conference:events` (`EventsController.cs:387-388`), a delete also evicts sessions and rooms (`EventsController.cs:382-383`), and a Sessionize refresh evicts all six tags it can touch (`EventsController.cs:363-368`). `[Rubric §12, Performance & Scalability]` is the lens for the whole caching story here. +The same controller adds three things the base has no notion of. A `PUT /{id}` update that surfaces a BR-86 `X-Warning` header when the update handler reports `HasDateRangeWarning` (`SessionsController.cs:323-344`), with the matching check done inline on create by comparing the request times against the event's `StartDate`/`EndDate` (`SessionsController.cs:302-316`). A `GET /{id}/ics` action that streams one public session as a `text/calendar` document for the add-to-calendar affordance (`SessionsController.cs:272-283`) via [`ExportSessionCalendarQuery`](group-18-conference-application.md#exportsessioncalendarquery). And an explicit [`Idempotent`](group-12-api-hosting-mapping.md#idempotentattribute) declaration on the create override (`SessionsController.cs:291`) so the `Idempotency-Key` contract is visible at the ADC endpoint rather than only inherited (the attribute is single-use, so the declaration coincides with the inherited one instead of duplicating it, `SessionsController.cs:285-289`). Every mutating action finishes by calling `EvictSessionsCacheAsync`, which evicts both the `conference:sessions` and `conference` output-cache tags (`SessionsController.cs:318,342,353,357-361`), the write-side half of the caching contract. -Two read-path carve-outs are worth internalizing before you touch either file. [`SpeakersController`](#speakerscontroller)`.GetByIdAsync` normally applies the BR-239 public-speaker specification, but drops it when the caller's `speaker_id` claim equals the route id, because the self-edit form cannot load without reading the profile it edits; and because the output-cache key does not vary by caller, that same branch turns storage off for the response through `IOutputCacheFeature` so a private profile can never land in the shared entry (`SpeakersController.cs:253-267`). [`SponsorsController`](#sponsorscontroller) is the mirror image of the Sessions filter problem: `Sponsor` carries a real `EventId` column, so an event-scoped request goes through the generic filter pipeline unchanged and the published-event specification from [`GetPublicSponsorFilterQuery`](group-18-conference-application.md#getpublicsponsorfilterquery) is ANDed on top of it rather than intercepted, which means scoping to an unpublished event yields an empty page instead of leaking the roster (`SponsorsController.cs:60-70,94-134`). Its `PUT /{id}` dispatches [`UpdateSponsorCommand`](group-18-conference-application.md#updatesponsorcommand) and, like every other Sponsors mutation, evicts `conference:sponsors` plus `conference` (`SponsorsController.cs:225-239,253-257`). +[`EventsController`](#eventscontroller) follows the same recipe and adds its own `GET /{id}/ics` (`EventsController.cs:207-218`) plus per-event and global `now-next` snapshot actions under the short-lived `NowNextCache` policy (`EventsController.cs:224-247`), both dispatching [`GetNowNextQuery`](group-18-conference-application.md#getnownextquery) and returning a [`NowNextDTO`](group-17-conference-domain.md#nownextdto); the id-less form exists because the home-screen widget has no event id to pass (`EventsController.cs:239-247`). Its read filter is the simplest of the group, a plain [`PublishedEventSpecification`](group-18-conference-application.md#publishedeventspecification) or `null` (`EventsController.cs:67-68`), because `Event` owns its own publish flag. It also carries the publish, unpublish, and Sessionize-refresh commands that have no generic equivalent (`EventsController.cs:310,341,367`). Publish and unpublish state their precondition two ways: an optional body carrying the client's last-seen rowversion for the [ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html) stale-view check, and a [`SupportsIfMatch`](group-12-api-hosting-mapping.md#supportsifmatchattribute) declaration (`EventsController.cs:307,338`) that lets the same token arrive as an HTTP `If-Match` header, in which case a stale token answers `412` instead of `409` (`EventsController.cs:291-309`). The refresh maps two domain error codes onto HTTP `429` (with a `Retry-After: 300`) and `502` (`EventsController.cs:377-386`). Cache eviction is proportional to blast radius: an ordinary event write evicts only `conference:events` (`EventsController.cs:416-417`), a delete also evicts sessions and rooms (`EventsController.cs:410-412`), and a Sessionize refresh evicts all six tags it can touch (`EventsController.cs:392-397`). `[Rubric §12, Performance & Scalability]` is the lens for the whole caching story here. + +Three read-path carve-outs are worth internalizing before you touch these files. [`SpeakersController`](#speakerscontroller)`.GetByIdAsync` normally applies the BR-239 public-speaker specification, but drops it when the caller's `speaker_id` claim equals the route id, because the self-edit form cannot load without reading the profile it edits; and because the output-cache key does not vary by caller, that same branch turns storage off for the response through `IOutputCacheFeature` so a private profile can never land in the shared entry (`SpeakersController.cs:253-267`). The same controller's per-session feedback read is gated self-or-organizer in code and deliberately left uncached, since every response is authorization-dependent (`SpeakersController.cs:406-425`), while its two bookmark-count reads are anonymous under the short-TTL `BookmarkCountsCache` policy (`SpeakersController.cs:429-431,449-451`). And [`SponsorsController`](#sponsorscontroller), with [`ActivitiesController`](#activitiescontroller) as its twin, is the mirror image of the Sessions filter problem: `Sponsor` and `Activity` each carry a real `EventId` column, so an event-scoped request goes through the generic filter pipeline unchanged and the published-event specification from [`GetPublicSponsorFilterQuery`](group-18-conference-application.md#getpublicsponsorfilterquery) / [`GetPublicActivityFilterQuery`](group-18-conference-application.md#getpublicactivityfilterquery) is ANDed on top of it rather than intercepted, which means scoping to an unpublished event yields an empty page instead of leaking the roster (`SponsorsController.cs:60-70,94-99`, `ActivitiesController.cs:60-70,94-99`). Their `PUT /{id}` dispatches [`UpdateSponsorCommand`](group-18-conference-application.md#updatesponsorcommand) / [`UpdateActivityCommand`](group-18-conference-application.md#updateactivitycommand) and, like every other mutation on those two, evicts the entity tag plus `conference` (`SponsorsController.cs:225-239,253-257`, `ActivitiesController.cs:225-239,253-257`). ## Two more deviations, versioning and decision support -[`ServiceInfoController`](#serviceinfocontroller) exists to **prove the API-versioning machinery works beyond a single version** (`[Rubric §9, API & Contract Design]`). It is a one-member shell over Common's [`ServiceInfoControllerBase`](group-12-api-hosting-mapping.md#serviceinfocontrollerbase): it overrides only `ServiceName => "Conference"` (`ServiceInfoController.cs:23`) and carries the class-level `[AllowAnonymous]`, `[ApiVersion("1.0", Deprecated = true)]`, and `[ApiVersion("2.0")]` attributes (`ServiceInfoController.cs:17-19`), which are placed here because they are not reliably inherited from the base (`ServiceInfoController.cs:12-13`). The shared base serves the same `/ServiceInfo` route at two API versions selected by the `api-version` header: `1.0` (deprecated) returns the minimal shape, `2.0` the evolved shape that also advertises the supported and deprecated version lists. Every other Conference controller declares a single `[ApiVersion("1.0")]`; this one demonstrates the deprecation story end to end. +[`ServiceInfoController`](#serviceinfocontroller) exists to **prove the API-versioning machinery works beyond a single version** (`[Rubric §9, API & Contract Design]`). It is a one-member shell over Common's [`ServiceInfoControllerBase`](group-12-api-hosting-mapping.md#serviceinfocontrollerbase): it overrides only `ServiceName => "Conference"` (`ServiceInfoController.cs:23`) and carries the class-level `[AllowAnonymous]`, `[ApiVersion("1.0", Deprecated = true)]`, and `[ApiVersion("2.0")]` attributes (`ServiceInfoController.cs:17-19`), which are placed here because they are not reliably inherited from the base (`ServiceInfoController.cs:11-13`). The shared base serves the same `/ServiceInfo` route at two API versions selected by the `api-version` header: `1.0` (deprecated) returns the minimal shape, `2.0` the evolved shape that also advertises the supported and deprecated version lists. Every other Conference controller declares a single `[ApiVersion("1.0")]`; this one demonstrates the deprecation story end to end. -[`SessionSelectionController`](#sessionselectioncontroller) is the most behaviour-rich controller in the group and the one furthest from the generic shape. It is **organizer-only** (`[HasPermission(ConferencePermissions.SessionSelectionManage)]`, `SessionSelectionController.cs:28`) decision support over an event's session pool: a composite dashboard, category distribution, speaker overlap, and content similarity, each `GET` delegating to a dedicated [`IQueryHandler`](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) and output-cached under the `ConferenceCache` policy (`SessionSelectionController.cs:39-40,53-54,67-68,81-82`; content similarity also takes a `minimumSimilarity` threshold defaulting to `0.3`, `SessionSelectionController.cs:85`). Its `POST score/{eventId}` action is the notable one: AI scoring of every eligible session can take minutes, so the action does not run the work at all. It calls [`ISessionScoringQueue`](group-18-conference-application.md#isessionscoringqueue)`.TryEnqueue(eventId)` and switches on the returned [`SessionScoringEnqueueResult`](group-18-conference-application.md#sessionscoringenqueueresult) (`SessionSelectionController.cs:108-129`): `Queued` logs through a `[LoggerMessage]`-sourced structured log and returns `202 Accepted` (`SessionSelectionController.cs:112-114,131-132`, `[Rubric §13, Observability & Operability]`), while `AlreadyPending` and `QueueFull` both fold into a `409 Conflict` through `HandleFailure` with distinct error codes (`SessionSelectionController.cs:116-127`). Refusing a second concurrent run is a cost decision stated in the source: each pass issues one paid Anthropic call per session, so two passes would double the spend while racing each other's writes (`SessionSelectionController.cs:100-104`, `[Rubric §31, Cost/FinOps]`). The actual work runs on the background [`SessionScoringProcessor`](group-19-conference-infrastructure.md#sessionscoringprocessor) in [G19](group-19-conference-infrastructure.md), which keeps the controller free of any scope-lifetime handling. +[`SessionSelectionController`](#sessionselectioncontroller) is the most behaviour-rich controller in the group and the one furthest from the generic shape. It is **organizer-only** (`[HasPermission(ConferencePermissions.SessionSelectionManage)]`, `SessionSelectionController.cs:29`) decision support over an event's session pool: a composite dashboard, category distribution, speaker overlap, and content similarity, each `GET` delegating to a dedicated [`IQueryHandler`](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) and output-cached under the `ConferenceCache` policy (`SessionSelectionController.cs:40-41,54-55,68-69,82-83`; content similarity also takes a `minimumSimilarity` threshold defaulting to `0.3`, `SessionSelectionController.cs:86`). Its `POST score/{eventId}` action is the notable one: AI scoring of every eligible session can take minutes, so the action does not run the work at all. It calls [`ISessionScoringQueue`](group-18-conference-application.md#isessionscoringqueue)`.TryEnqueue(eventId)` and switches on the returned [`SessionScoringEnqueueResult`](group-18-conference-application.md#sessionscoringenqueueresult) (`SessionSelectionController.cs:110-131`): `Queued` logs through a `[LoggerMessage]`-sourced structured log and returns `202 Accepted` (`SessionSelectionController.cs:114-116,133-134`, `[Rubric §13, Observability & Operability]`), while `AlreadyPending` and `QueueFull` both fold into a `409 Conflict` through `HandleFailure` with distinct error codes (`SessionSelectionController.cs:118-129`). Refusing a second concurrent run is a cost decision stated in the source: each pass issues one paid Anthropic call per session, so two passes would double the spend while racing each other's writes (`SessionSelectionController.cs:101-105`, `[Rubric §31, Cost/FinOps]`). The same reasoning drives an explicit [`NonIdempotent`](group-12-api-hosting-mapping.md#nonidempotentattribute) declaration with a written justification (`SessionSelectionController.cs:107`): the queue already deduplicates, so replaying a cached `202` would report acceptance for a request the queue never saw and hide both the already-running refusal and a queue-full rejection the caller has to act on. The actual work runs on the background [`SessionScoringProcessor`](group-19-conference-infrastructure.md#sessionscoringprocessor) in [G19](group-19-conference-infrastructure.md), which keeps the controller free of any scope-lifetime handling. ## The module entry point and seeder, how Conference plugs in -[`ConferenceModule`](#conferencemodule) is the Conference implementation of [`IModule`](group-14-module-system-composition.md#imodule). It is tiny by design: `Register(...)` calls the [`DependencyInjection`](#dependencyinjection) extension's `AddConferenceModule(...)` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/ConferenceModule.cs:28-29`), which chains the Application, Infrastructure, and API-layer registrations in dependency order into one call (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/DependencyInjection.cs:25-27`). The API layer's `AddModuleConferenceAPI` is not a no-op: it calls `AddPermissions` to grant [`RoleNames`](group-08-auth.md#rolenames)`.Organizer` and `.Admin` every [`ConferencePermissions`](group-17-conference-domain.md#conferencepermissions) capability, and `ContentEditor` only the `ContentManagement` curation subset with no event structure, rooms, questions, or session selection (`DependencyInjection.cs:41-51`). Attendees are granted nothing here, so attendee-facing endpoints stay on the plain [`AuthorizationPolicies`](group-08-auth.md#authorizationpolicies)`.RequireAuthenticated` policy (`DependencyInjection.cs:35-36`). And `RegisterDisabledStubs(...)` registers **both** a [`DisabledSessionBookmarkValidationService`](group-17-conference-domain.md#disabledsessionbookmarkvalidationservice) and a [`DisabledEventLiveValidationService`](group-17-conference-domain.md#disabledeventlivevalidationservice) as singletons (`ConferenceModule.cs:23-24`) so that *other* hosts which depend on Conference's [`ISessionBookmarkValidationService`](group-17-conference-domain.md#isessionbookmarkvalidationservice) or [`IEventLiveValidationService`](group-17-conference-domain.md#ieventlivevalidationservice) but do **not** host Conference still resolve those interfaces (they no-op, or are later `Replace`d by the gRPC adapters). The [`ModuleLoader`](group-14-module-system-composition.md#moduleloader) ([G14](group-14-module-system-composition.md)) discovers `ConferenceModule` by reflection and registers it in topological order, the same mechanism whether Conference runs in the monolith or alone in its service. +[`ConferenceModule`](#conferencemodule) is the Conference implementation of [`IModule`](group-14-module-system-composition.md#imodule). It is tiny by design: `Register(...)` calls the [`DependencyInjection`](#dependencyinjection) extension's `AddConferenceModule(...)` (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/ConferenceModule.cs:28-29`), which chains the Application, Infrastructure, and API-layer registrations in dependency order into one call (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/DependencyInjection.cs:25-27`). The API layer's `AddModuleConferenceAPI` is not a no-op: it calls `AddPermissions` to grant [`RoleNames`](group-08-auth.md#rolenames)`.Organizer` and `.Admin` every [`ConferencePermissions`](group-17-conference-domain.md#conferencepermissions) capability, and `ContentEditor` only the five-capability `ContentManagement` curation subset with no event structure, rooms, questions, or session selection (`DependencyInjection.cs:41-51`; the subset itself is `ConferencePermissions.cs:57-64`). Attendees are granted nothing here, so attendee-facing endpoints stay on the plain [`AuthorizationPolicies`](group-08-auth.md#authorizationpolicies)`.RequireAuthenticated` policy (`DependencyInjection.cs:35-36`). And `RegisterDisabledStubs(...)` registers **both** a [`DisabledSessionBookmarkValidationService`](group-17-conference-domain.md#disabledsessionbookmarkvalidationservice) and a [`DisabledEventLiveValidationService`](group-17-conference-domain.md#disabledeventlivevalidationservice) as singletons (`ConferenceModule.cs:23-24`) so that *other* hosts which depend on Conference's [`ISessionBookmarkValidationService`](group-17-conference-domain.md#isessionbookmarkvalidationservice) or [`IEventLiveValidationService`](group-17-conference-domain.md#ieventlivevalidationservice) but do **not** host Conference still resolve those interfaces (they no-op, or are later `Replace`d by the gRPC adapters). The [`ModuleLoader`](group-14-module-system-composition.md#moduleloader) ([G14](group-14-module-system-composition.md)) discovers `ConferenceModule` by reflection and registers it in topological order, the same mechanism whether Conference is co-hosted or runs alone in its service. -[`ConferenceModuleSeeder`](#conferencemoduleseeder) implements [`IModuleSeeder`](group-14-module-system-composition.md#imoduleseeder) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/ConferenceModuleSeeder.cs:13`) and is the API layer's thin bridge to the real seeding logic: it resolves `IUnitOfWork` and `IConfiguration` from the passed service provider, reads `Seeding:IncludeSampleConferenceData` (defaulting to false when the key is absent, and set only on the local AppHost and in E2E CI), then constructs and runs `ConferenceModuleDbSeeder` from [G19](group-19-conference-infrastructure.md) with that flag (`ConferenceModuleSeeder.cs:21-29`). The two markers [`AssemblyReference`](#assemblyreference) / [`ClassReference`](#classreference) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/AssemblyReference.cs:5,11`) are the per-package anchors the module scan and the architecture fitness tests pin against, and [`ConferenceErrorResources`](#conferenceerrorresources) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Resources/ConferenceErrorResources.cs:11`) is a similarly empty sealed class acting as the anchor for the module's `.resx` error-code translations, keyed by domain error `Code` and deliberately omitting runtime-variable messages so they degrade to English with the interpolated value intact (`ConferenceErrorResources.cs:3-10`). +[`ConferenceModuleSeeder`](#conferencemoduleseeder) implements [`IModuleSeeder`](group-14-module-system-composition.md#imoduleseeder) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/ConferenceModuleSeeder.cs:13`) and is the API layer's thin bridge to the real seeding logic: it resolves `IUnitOfWork` and `IConfiguration` from the passed service provider, reads `Seeding:IncludeSampleConferenceData` (defaulting to false when the key is absent, and set to `true` only by the local AppHost at `MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:162`), then constructs and runs [`ConferenceModuleDbSeeder`](group-19-conference-infrastructure.md#conferencemoduledbseeder) from [G19](group-19-conference-infrastructure.md) with that flag (`ConferenceModuleSeeder.cs:21-29`). The two markers [`AssemblyReference`](#assemblyreference) / [`ClassReference`](#classreference) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/AssemblyReference.cs:5,11`) are the per-package anchors the module scan and the architecture fitness tests pin against, and [`ConferenceErrorResources`](#conferenceerrorresources) (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Resources/ConferenceErrorResources.cs:11`) is a similarly empty sealed class acting as the anchor for the module's `.resx` error-code translations, keyed by domain error `Code` and deliberately omitting runtime-variable messages so they degrade to English with the interpolated value intact (`ConferenceErrorResources.cs:3-10`). ## The gRPC edge, Conference as both server and client -When Conference is extracted into its own process, two of its in-process collaborations must cross a network boundary, and both are handled by the [G13](group-13-grpc-contracts.md) transport boundary (`Result` over the wire, transport at the edge, [ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html)). Conference is the **server** for two contracts. [`SessionBookmarksGrpcService`](#sessionbookmarksgrpcservice) (in `MMCA.ADC.Conference.Service`) exposes Conference's `ISessionBookmarkValidationService` to Engagement, answering "is this session valid to bookmark?" (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Grpc/SessionBookmarksGrpcService.cs:27`) and "give me the session ids for this event" (`SessionBookmarksGrpcService.cs:45`). [`EventLiveValidationGrpcService`](#eventlivevalidationgrpcservice) exposes `IEventLiveValidationService` to Engagement's conference-day live layer across **four** methods, each projecting a domain record onto the wire shape: `GetEventLiveInfo` returns an [`EventLiveInfo`](group-17-conference-domain.md#eventliveinfo) as publish state plus live-window bounds converted to Unix seconds (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Grpc/EventLiveValidationGrpcService.cs:41-46`), `GetSessionLiveInfo` adds a [`SessionLiveInfo`](group-17-conference-domain.md#sessionliveinfo)'s stringified speaker ids, plenum flag, and moderation default cast to an int (`EventLiveValidationGrpcService.cs:65-75`), `GetSponsorLiveInfo` returns a [`SponsorLiveInfo`](group-17-conference-domain.md#sponsorliveinfo) (`EventLiveValidationGrpcService.cs:94-99`), and `GetCurrentRoomSessionInfo` resolves the room's currently-running session within a caller-supplied grace window as a [`RoomSessionInfo`](group-17-conference-domain.md#roomsessioninfo) (`EventLiveValidationGrpcService.cs:110-124`). Each server method is a constructor-injected wrapper over the inner C# service: it null-guards request and context, awaits the inner call, and on a failed `Result` calls `result.ThrowIfFailure()` (`SessionBookmarksGrpcService.cs:39,57`, `EventLiveValidationGrpcService.cs:38,62,91,115`) so the [`GrpcResultExceptionInterceptor`](group-13-grpc-contracts.md#grpcresultexceptioninterceptor) (wired by `AddGrpcServiceDefaults()`) can translate the failure into an `RpcException` with structured `error-{i}-*` trailers. +When Conference runs in its own process, two of its in-process collaborations must cross a network boundary, and both are handled by the [G13](group-13-grpc-contracts.md) transport boundary (`Result` over the wire, transport at the edge, [ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html)). Conference is the **server** for two contracts. [`SessionBookmarksGrpcService`](#sessionbookmarksgrpcservice) (in `MMCA.ADC.Conference.Service`) exposes Conference's `ISessionBookmarkValidationService` to Engagement, answering "is this session valid to bookmark?" (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Grpc/SessionBookmarksGrpcService.cs:27`) and "give me the session ids for this event" (`SessionBookmarksGrpcService.cs:45`). [`EventLiveValidationGrpcService`](#eventlivevalidationgrpcservice) exposes `IEventLiveValidationService` to Engagement's conference-day live layer across **four** methods, each projecting a domain record onto the wire shape: `GetEventLiveInfo` returns an [`EventLiveInfo`](group-17-conference-domain.md#eventliveinfo) as publish state plus live-window bounds converted to Unix seconds (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Grpc/EventLiveValidationGrpcService.cs:41-46`), `GetSessionLiveInfo` adds a [`SessionLiveInfo`](group-17-conference-domain.md#sessionliveinfo)'s stringified speaker ids, plenum flag, and moderation default cast to an int (`EventLiveValidationGrpcService.cs:65-75`), `GetSponsorLiveInfo` returns a [`SponsorLiveInfo`](group-17-conference-domain.md#sponsorliveinfo) (`EventLiveValidationGrpcService.cs:94-99`), and `GetCurrentRoomSessionInfo` resolves the room's currently-running session within a caller-supplied grace window as a [`RoomSessionInfo`](group-17-conference-domain.md#roomsessioninfo) (`EventLiveValidationGrpcService.cs:103,118-124`). Each server method is a constructor-injected wrapper over the inner C# service: it null-guards request and context, awaits the inner call, and on a failed `Result` calls `result.ThrowIfFailure()` (`SessionBookmarksGrpcService.cs:39,57`, `EventLiveValidationGrpcService.cs:38,62,91,115`) so the [`GrpcResultExceptionInterceptor`](group-13-grpc-contracts.md#grpcresultexceptioninterceptor) (wired by `AddGrpcServiceDefaults()`) can translate the failure into an `RpcException` with structured `error-{i}-*` trailers. -On the **client** side, each contract has a hand-written adapter in `MMCA.ADC.Conference.Contracts` that Engagement uses. [`SessionBookmarkValidationServiceGrpcAdapter`](#sessionbookmarkvalidationservicegrpcadapter) implements the *identical* `ISessionBookmarkValidationService` interface on top of the generated gRPC client (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/SessionBookmarkValidationServiceGrpcAdapter.cs:24-26`), and [`EventLiveValidationServiceGrpcAdapter`](#eventlivevalidationservicegrpcadapter) does the same for all four `IEventLiveValidationService` methods (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/EventLiveValidationServiceGrpcAdapter.cs:23-25`), converting the Unix-second live-window fields back into UTC `DateTime`s and the speaker-id strings back into `Guid`s (`EventLiveValidationServiceGrpcAdapter.cs:84-91`). Both pin a **5-second per-call deadline** on every RPC (`SessionBookmarkValidationServiceGrpcAdapter.cs:32,46,79`, `EventLiveValidationServiceGrpcAdapter.cs:30,44,81,122,161`), much tighter than the shared resilience pipeline's 30s attempt / 90s total budget, precisely because these calls sit inline in user request paths (bookmark create and list, live-layer poll and question commands) and a *hung* (as opposed to refused) Conference peer must fail fast rather than hold the caller hostage. Both catch `RpcException` and reconstruct `Result.Failure(errors)` from the trailers, falling back to a generic `Error.Failure` coded `Grpc.{StatusCode}` for pure transport faults such as connection reset or deadline exceeded (`SessionBookmarkValidationServiceGrpcAdapter.cs:50-64,84-100`, `EventLiveValidationServiceGrpcAdapter.cs:52-66,93-107,130-144,170-184`). The trailer parsing lives once in [`GrpcErrorTrailerParser`](#grpcerrortrailerparser) (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/GrpcErrorTrailerParser.cs:14`), whose `Parse` walks `error-{i}-*` trailers by index until the first missing code and rebuilds each [`Error`](group-01-result-error-handling.md#error) with the correct factory per `ErrorType` (`GrpcErrorTrailerParser.cs:17,25-44,56-68`), so the round-trip logic is shared by both adapters. Because both the in-process implementation and each adapter satisfy the same interface, swapping monolith for microservice is a registration change, not a rewrite ([ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html); `[Rubric §7, Microservices Readiness]`). +On the **client** side, each contract has a hand-written adapter in `MMCA.ADC.Conference.Contracts` that Engagement uses. [`SessionBookmarkValidationServiceGrpcAdapter`](#sessionbookmarkvalidationservicegrpcadapter) implements the *identical* `ISessionBookmarkValidationService` interface on top of the generated gRPC client (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/SessionBookmarkValidationServiceGrpcAdapter.cs:24-26`), and [`EventLiveValidationServiceGrpcAdapter`](#eventlivevalidationservicegrpcadapter) does the same for all four `IEventLiveValidationService` methods (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/EventLiveValidationServiceGrpcAdapter.cs:23-25`), converting the Unix-second live-window fields back into UTC `DateTime`s and the speaker-id strings back into `Guid`s (`EventLiveValidationServiceGrpcAdapter.cs:47-50,84-91`). Both pin a **5-second per-call deadline** on every RPC (`SessionBookmarkValidationServiceGrpcAdapter.cs:32,46,79`, `EventLiveValidationServiceGrpcAdapter.cs:30,44,81,122,161`), much tighter than the shared resilience pipeline's 30s attempt / 90s total budget, precisely because these calls sit inline in user request paths (bookmark create and list, live-layer poll and question commands) and a *hung* (as opposed to refused) Conference peer must fail fast rather than hold the caller hostage (`EventLiveValidationServiceGrpcAdapter.cs:27-29`). Both catch `RpcException` and reconstruct `Result.Failure(errors)` from the trailers, falling back to a generic `Error.Failure` coded `Grpc.{StatusCode}` for pure transport faults such as connection reset or deadline exceeded (`SessionBookmarkValidationServiceGrpcAdapter.cs:50-64,84-100`, `EventLiveValidationServiceGrpcAdapter.cs:52-66,93-107,130-144,170-184`). The trailer parsing lives once in [`GrpcErrorTrailerParser`](#grpcerrortrailerparser) (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/GrpcErrorTrailerParser.cs:14`), whose `Parse` walks `error-{i}-*` trailers by index until the first missing code and rebuilds each [`Error`](group-01-result-error-handling.md#error) with the correct factory per `ErrorType` (`GrpcErrorTrailerParser.cs:17,25-44,56-68`), so the round-trip logic is shared by both adapters. Because both the in-process implementation and each adapter satisfy the same interface, swapping a co-located module for a remote service is a registration change, not a rewrite ([ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html); `[Rubric §7, Microservices Readiness]`). -Those registration swaps are performed by the contract package's [`DependencyInjection`](#dependencyinjection) extension, one method per contract: `AddConferenceSessionValidationClient(serviceName = "conference")` (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/DependencyInjection.cs:43`) and `AddConferenceEventLiveValidationClient(...)` (`DependencyInjection.cs:73`). Each does exactly two things: registers a typed gRPC client via Common's `AddTypedGrpcClient(serviceName)` (`DependencyInjection.cs:45,75`, which resolves `http://conference` through Aspire service discovery and attaches the JWT-forwarding interceptor plus Polly resilience handler), then calls `services.Replace(...)` with a *scoped* descriptor rather than `TryAdd` (`DependencyInjection.cs:49,79`), to overwrite whatever implementation is already in the container (the real in-process service if Conference is co-hosted, or the `Disabled...` stub if not) with the gRPC adapter. The `Replace` is deliberate so the adapter wins in either case; it must be called from the consumer's `Program.cs` *after* `ModuleLoader.DiscoverAndRegister(...)` so the in-process or stub registration is already present for `Replace` to find (`DependencyInjection.cs:36-39`). Note the **bidirectional** Conference-to-Engagement gRPC relationship: Conference *serves* these two contracts and also *consumes* Engagement's [`IBookmarkCountService`](group-22-engagement-module.md#ibookmarkcountservice), so the Conference service host registers `AddEngagementBookmarkCountClient()` (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:329`) and the AppHost deliberately omits a reciprocal startup `WaitFor` to avoid a deadlock; transient "peer not ready" errors self-heal through the resilience pipeline ([ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html) / [ADR-008](https://ivanball.github.io/docs/adr/008-service-extraction-topology.html); `[Rubric §29, Resilience]`). +Those registration swaps are performed by the contract package's `DependencyInjection` extension, one method per contract: `AddConferenceSessionValidationClient(serviceName = "conference")` (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/DependencyInjection.cs:43`) and `AddConferenceEventLiveValidationClient(...)` (`DependencyInjection.cs:73`). Each does exactly two things: registers a typed gRPC client via Common's `AddTypedGrpcClient(serviceName)` (`DependencyInjection.cs:45,75`, which resolves `http://conference` through Aspire service discovery and attaches the JWT-forwarding interceptor plus Polly resilience handler), then calls `services.Replace(...)` with a *scoped* descriptor rather than `TryAdd` (`DependencyInjection.cs:49,79`), to overwrite whatever implementation is already in the container (the real in-process service if Conference is co-hosted, or the `Disabled...` stub if not) with the gRPC adapter. The `Replace` is deliberate so the adapter wins in either case; it must be called from the consumer's `Program.cs` *after* `ModuleLoader.DiscoverAndRegister(...)` so the in-process or stub registration is already present for `Replace` to find (`DependencyInjection.cs:36-39`). Note the **bidirectional** Conference-to-Engagement gRPC relationship: Conference *serves* these two contracts and also *consumes* Engagement's [`IBookmarkCountService`](group-22-engagement-module.md#ibookmarkcountservice), so the Conference service host registers `AddEngagementBookmarkCountClient()` (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:350`) and the AppHost deliberately gives only the Engagement-to-Conference edge a startup `WaitFor`, leaving the reverse edge a plain `WithReference` so the pair cannot deadlock; transient "peer not ready" errors self-heal through the resilience pipeline (`MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:203-218`; [ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html) / [ADR-008](https://ivanball.github.io/docs/adr/008-service-extraction-topology.html); `[Rubric §29, Resilience]`). ## The service host: Kestrel first, and why -The `MMCA.ADC.Conference.Service` `Program.cs` boots only the Conference module (`Modules:Conference:Enabled=true`). Kestrel is configured before anything else, and the whole of it is one line: `builder.ConfigureEndpointsWithHealthProbe(HttpProtocols.Http2)` (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:85`), the shared extension from Common's [`KestrelEndpointExtensions`](group-16-aspire-orchestration.md#kestrelendpointextensions) ([G16](group-16-aspire-orchestration.md)). Passing `HttpProtocols.Http2` sets every endpoint default to HTTP/2-only on cleartext (h2c prior knowledge), so cross-service gRPC clients can negotiate HTTP/2 without TLS or ALPN; on a cleartext endpoint `Http1AndHttp2` would effectively disable HTTP/2 and Kestrel would reject gRPC frames with `GOAWAY HTTP_1_1_REQUIRED` (`Program.cs:75-81`). That host-transport choice is [ADR-012](https://ivanball.github.io/docs/adr/012-grpc-host-transport.html). The operational half lives in the shared helper: only when `HealthProbe:Port` is configured (injected by `infra/main.bicep`, deliberately absent locally so Aspire's dynamic ports keep working) does it add a dedicated **HTTP/1.1-only** listener for the ACA `httpGet` probes (`Program.cs:82-84`), because the h2c-only endpoint rejects the platform's HTTP/1.1 probe requests. `MapDefaultEndpoints` (`Program.cs:372`) maps the health endpoints on every listener, so the probe port serves the real health pipeline while staying off the ACA ingress. The rest of the host is the standard ADC REST composition: Serilog registered as one provider rather than through `UseSerilog()` so the OpenTelemetry-to-Azure-Monitor provider survives (`Program.cs:108-115`), an optional Key Vault configuration source layered in before anything binds settings (`Program.cs:124`), the Conference-owned `MMCA.ADC.Conference.Scoring` meter (`Program.cs:133-134`), health checks with SQL required (`Program.cs:184`), CORS, API versioning and rate limiting (`Program.cs:187-189`), response compression (`Program.cs:265`), OpenAPI outside Production (`Program.cs:270,380-383`), RS256 JWT validation via JWKS discovery forwarded through the Gateway (`Program.cs:275-281`), exception handlers (`Program.cs:284`), the scheduler and audit-trail extension points (`Program.cs:292,296`), and the shared middleware pipeline (`Program.cs:373`; [ADR-004](https://ivanball.github.io/docs/adr/004-authentication-dual-fetch.html) / [ADR-019](https://ivanball.github.io/docs/adr/019-rate-limiting.html)). +The `MMCA.ADC.Conference.Service` `Program.cs` boots only the Conference module (`Modules:Conference:Enabled=true`). Kestrel is configured before anything else, and the whole of it is one line: `builder.ConfigureEndpointsWithHealthProbe(HttpProtocols.Http2)` (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:85`), the shared extension from Common's [`KestrelEndpointExtensions`](group-16-aspire-orchestration.md#kestrelendpointextensions) ([G16](group-16-aspire-orchestration.md)). Passing `HttpProtocols.Http2` sets every endpoint default to HTTP/2-only on cleartext (h2c prior knowledge), so cross-service gRPC clients can negotiate HTTP/2 without TLS or ALPN; on a cleartext endpoint `Http1AndHttp2` would effectively disable HTTP/2 and Kestrel would reject gRPC frames with `GOAWAY HTTP_1_1_REQUIRED` (`Program.cs:75-81`). That host-transport choice is [ADR-012](https://ivanball.github.io/docs/adr/012-grpc-host-transport.html). The operational half lives in the shared helper: only when `HealthProbe:Port` is configured (injected by `infra/main.bicep`, deliberately absent locally so Aspire's dynamic ports keep working) does it add a dedicated **HTTP/1.1-only** listener for the ACA `httpGet` probes (`Program.cs:82-84`), because the h2c-only endpoint rejects the platform's HTTP/1.1 probe requests. `MapDefaultEndpoints` (`Program.cs:398`) maps the health endpoints on every listener, so the probe port serves the real health pipeline while staying off the ACA ingress. The rest of the host is the standard ADC REST composition: Serilog registered as one provider rather than through `UseSerilog()` so the OpenTelemetry-to-Azure-Monitor provider survives (`Program.cs:108-115`), an optional Key Vault configuration source layered in before anything binds settings (`Program.cs:124`), the Conference-owned `MMCA.ADC.Conference.Scoring` meter (`Program.cs:133-134`), health checks with SQL required (`Program.cs:184`), CORS, API versioning and rate limiting (`Program.cs:187-189`), response compression (`Program.cs:280`), OpenAPI outside Production (`Program.cs:285,406-409`), RS256 JWT validation via JWKS discovery forwarded through the Gateway (`Program.cs:294-302`), exception handlers (`Program.cs:305`), the scheduler and audit-trail extension points (`Program.cs:313,317`), and the shared middleware pipeline (`Program.cs:399`; [ADR-004](https://ivanball.github.io/docs/adr/004-authentication-dual-fetch.html) / [ADR-019](https://ivanball.github.io/docs/adr/019-rate-limiting.html) / [ADR-079](https://ivanball.github.io/docs/adr/079-shared-http-middleware-pipeline.html)). ## Output caching and warm-up, the two performance extension points -Output caching is where this host carries the most bespoke configuration (`Program.cs:191-255`). The base policy is deny-by-default `NoCache` (`Program.cs:198`), so only explicitly decorated endpoints cache at all. `ConferenceCache` stays on the built-in default semantics because the permission-gated [`SessionSelectionController`](#sessionselectioncontroller) references it, and [ADR-040](https://ivanball.github.io/docs/adr/040-authenticated-output-caching-for-public-reads.html)'s public policy must never back a permission-gated endpoint since a cached hit is served before MVC's filters run (`Program.cs:200-206`). Eight further policies (`ConferencePublicCache`, `EventsCache`, `SessionsCache`, `SpeakersCache`, `RoomsCache`, `CategoriesCache`, `QuestionsCache`, `SponsorsCache`) are registered through `AddPublicEndpointPolicy` at a 5-minute TTL with hierarchical tags (`Program.cs:231-243`), and each one **bypasses the cache entirely for the privileged read audience** (`Program.cs:230`, the bypass list built from `ConferenceReadAudience.PrivilegedRoles` so it can never diverge from the API-layer visibility checks), for two reasons spelled out in the source (`Program.cs:214-229`): privileged responses include unpublished rows that must never land in a shared public entry, and admin surfaces read back immediately after writing, where a stale cached row version would make the next save throw `DbUpdateConcurrencyException`. Two policies then sit at a 60-second TTL for different reasons: `NowNextCache` because its payload changes with the clock and is identical for every role, so it takes no bypass at all (`Program.cs:246`), and `BookmarkCountsCache` because bookmark counts are owned by Engagement in another process with no handle on this service's cache store, so no tag eviction can ever reach those entries and a short TTL is the only lever available (`Program.cs:248-254`). All of this is [ADR-040](https://ivanball.github.io/docs/adr/040-authenticated-output-caching-for-public-reads.html): [`PublicEndpointOutputCachePolicy`](group-12-api-hosting-mapping.md#publicendpointoutputcachepolicy) exists because the UI attaches a Bearer token to every request and the built-in default policy refuses to cache anything carrying `Authorization`, which on conference day meant the cache served none of the real traffic. Two more details are easy to miss and load-bearing at two replicas: when a Redis connection string is present the host backs the **output** cache with Redis as well as the distributed cache (`Program.cs:156`), because the default per-replica memory store meant an `EvictByTagAsync` reached only the replica that served the mutation while the other kept serving the pre-edit payload for the full TTL; and the same branch adds a two-level cache, an in-process L1 over the Redis L2 under a disjoint keyspace, so a repeat read inside one replica never leaves the process while invalidation still crosses replicas (`Program.cs:164`). +Output caching is where this host carries the most bespoke configuration (`Program.cs:196-265`). The base policy is deny-by-default `NoCache` (`Program.cs:198`), so only explicitly decorated endpoints cache at all. `ConferenceCache` stays on the built-in default semantics because the permission-gated [`SessionSelectionController`](#sessionselectioncontroller) references it, and [ADR-040](https://ivanball.github.io/docs/adr/040-authenticated-output-caching-for-public-reads.html)'s public policy must never back a permission-gated endpoint since a cached hit is served before MVC's filters run (`Program.cs:200-206`). Nine further policies (`ConferencePublicCache`, `EventsCache`, `SessionsCache`, `SpeakersCache`, `RoomsCache`, `CategoriesCache`, `QuestionsCache`, `SponsorsCache`, `ActivitiesCache`) are registered through `AddPublicEndpointPolicy` at a 5-minute TTL with hierarchical tags (`Program.cs:236-249`), and each one **bypasses the cache entirely for the privileged read audience** (`Program.cs:235`, the bypass list built from `ConferenceReadAudience.PrivilegedRoles` so it can never diverge from the API-layer visibility checks), for two reasons spelled out in the source (`Program.cs:214-234`): privileged responses include unpublished rows that must never land in a shared public entry, and admin surfaces read back immediately after writing, where a stale cached row version would make the next save throw `DbUpdateConcurrencyException`. Two policies then sit at a 60-second TTL for different reasons: `NowNextCache` because its payload changes with the clock and is identical for every role, so it takes no bypass at all (`Program.cs:252`), and `BookmarkCountsCache` because bookmark counts are owned by Engagement in another process (`Program.cs:255-264`). All of this is [ADR-040](https://ivanball.github.io/docs/adr/040-authenticated-output-caching-for-public-reads.html): [`PublicEndpointOutputCachePolicy`](group-12-api-hosting-mapping.md#publicendpointoutputcachepolicy) exists because the UI attaches a Bearer token to every request and the built-in default policy refuses to cache anything carrying `Authorization`, which on conference day meant the cache served none of the real traffic. + +Two mechanisms close the distance that TTLs alone cannot. First, at two replicas the store itself must be shared: when a Redis connection string is present the host backs the **output** cache with Redis as well as the distributed cache (`Program.cs:156`), because the default per-replica memory store meant an `EvictByTagAsync` reached only the replica that served the mutation while the other kept serving the pre-edit payload for the full TTL; the same branch adds a two-level cache, an in-process L1 over the Redis L2 under a disjoint keyspace, so a repeat read inside one replica never leaves the process while invalidation still crosses replicas (`Program.cs:164`). Second, a write that never touches a Conference controller still has to reach this cache: an Engagement bookmark or an application-layer speaker auto-link has no handle on `IOutputCacheStore`, so the writer publishes an [`OutputCacheEvictionRequested`](group-04-events-outbox.md#outputcacheevictionrequested) integration event, this host registers the consumer half with `AddOutputCacheEvictionHandler()` (`Program.cs:270`) and the broker half with `RegisterOutputCacheEvictionConsumer()` (`Program.cs:373`), and the tag is dropped on arrival. Registering only one of the two halves is a silent no-op (`Program.cs:267-269`); the 60-second `BookmarkCountsCache` TTL stays deliberately as the backstop for a message that never lands. -The host also contributes the module's error-code translations to the edge localizer by calling `AddErrorResources()` (`Program.cs:311`), so a Conference domain error like `Event.Name.Empty` is rendered in the caller's culture by the shared [`ErrorLocalizer`](group-12-api-hosting-mapping.md#errorlocalizer) ([ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html)). And one more startup extension point matters: [`SelfHttpOutputCacheWarmupTask`](#selfhttpoutputcachewarmuptask), registered via `AddWarmupTask()` (`Program.cs:262`) as an [ADR-025](https://ivanball.github.io/docs/adr/025-startup-warmup-readiness.html) [`IWarmupTask`](group-16-aspire-orchestration.md#iwarmuptask). The task itself is almost empty: it derives from [`SelfHttpWarmupTaskBase`](group-16-aspire-orchestration.md#selfhttpwarmuptaskbase) (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/SelfHttpOutputCacheWarmupTask.cs:22-28`) and contributes only a name (`SelfHttpOutputCacheWarmupTask.cs:59`) and a list of paths (`SelfHttpOutputCacheWarmupTask.cs:62`), while the base owns the request machinery (waiting for the server to start, resolving the actually-bound cleartext port, pinning HTTP/2 prior knowledge, and treating a failure as non-fatal). The paths are the interesting part, and there are **eight** of them in two families (`SelfHttpOutputCacheWarmupTask.cs:42-56`), because OutputCache keys on the full URL and a warmed entry is only ever hit by a byte-identical query string: family one mirrors the Blazor list pages, whose service base interpolates C# bools and so writes capital `False`/`True`; family two mirrors the hand-written lookup services, which write lowercase literals and `pageSize=10000`. Warming one family left the other paying a cold read on its first real caller. Every path is `[AllowAnonymous]`, so the base's require-success loop sees `200` and skips nothing. +The host also contributes the module's error-code translations to the edge localizer by calling `AddErrorResources()` (`Program.cs:332`), so a Conference domain error like `Event.Name.Empty` is rendered in the caller's culture by the shared [`ErrorLocalizer`](group-12-api-hosting-mapping.md#errorlocalizer) ([ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html)). And one more startup extension point matters: [`SelfHttpOutputCacheWarmupTask`](#selfhttpoutputcachewarmuptask), registered via `AddWarmupTask()` (`Program.cs:277`) as an [ADR-025](https://ivanball.github.io/docs/adr/025-startup-warmup-readiness.html) [`IWarmupTask`](group-16-aspire-orchestration.md#iwarmuptask). The task itself is almost empty: it derives from [`SelfHttpWarmupTaskBase`](group-16-aspire-orchestration.md#selfhttpwarmuptaskbase) (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/SelfHttpOutputCacheWarmupTask.cs:22-28`) and contributes only a name (`SelfHttpOutputCacheWarmupTask.cs:59`) and a list of paths (`SelfHttpOutputCacheWarmupTask.cs:62`), while the base owns the request machinery (waiting for the server to start, resolving the actually-bound cleartext port, pinning HTTP/2 prior knowledge, and treating a failure as non-fatal). The paths are the interesting part, and there are **eight** of them in two families (`SelfHttpOutputCacheWarmupTask.cs:42-56`), because OutputCache keys on the full URL and a warmed entry is only ever hit by a byte-identical query string: family one mirrors the Blazor list pages, whose service base interpolates C# bools and so writes capital `False`/`True`; family two mirrors the hand-written lookup services, which write lowercase literals and `pageSize=10000` (`SelfHttpOutputCacheWarmupTask.cs:30-41`). Warming one family left the other paying a cold read on its first real caller. Every path is `[AllowAnonymous]`, so the base's require-success loop sees `200` and skips nothing. ## The runtime picture, one host, two transports -After module discovery (`Program.cs:313-319`) the host wires the Engagement gRPC client (`Program.cs:329`), the broker (`AddBrokerMessaging` registering the `UserRegistered` integration-event consumer that drives the BR-207 email-match speaker auto-link through [`UserRegisteredHandler`](group-18-conference-application.md#userregisteredhandler), `Program.cs:346-347`, falling back to in-process mode when `MessageBus:Provider` is unset so integration tests are unaffected), the decorator pipeline (`Program.cs:349`), `AddGrpcServiceDefaults()` (`Program.cs:358`), and the per-module health checks (`Program.cs:361`). It initializes the database before serving traffic (`Program.cs:370`), then publishes **both** gRPC endpoints over the same Kestrel HTTP/2 channel the REST controllers serve: `MapGrpcService().RequireAuthorization()` (`Program.cs:393`) and `MapGrpcService().RequireAuthorization()` (`Program.cs:394`), adding gRPC reflection in Development only (`Program.cs:396-399`). The `RequireAuthorization()` is not decoration: both contracts answer conference-state questions raised on behalf of a specific end user, so internal-only ingress is not considered sufficient, and every caller is an Engagement handler sitting behind an authenticated controller whose bearer token the JWT-forwarding interceptor carries across (`Program.cs:388-392`, `[Rubric §11, Security]`). +After module discovery (`Program.cs:335-339`) the host wires the Engagement gRPC client (`Program.cs:350`), the broker (`AddBrokerMessaging` registering the `UserRegistered` integration-event consumer that drives the BR-207 email-match speaker auto-link through [`UserRegisteredHandler`](group-18-conference-application.md#userregisteredhandler), `Program.cs:371-373`, falling back to in-process mode when `MessageBus:Provider` is unset so integration tests are unaffected), the decorator pipeline (`Program.cs:375`), `AddGrpcServiceDefaults()` (`Program.cs:384`), and the per-module health checks (`Program.cs:387`). It initializes the database before serving traffic (`Program.cs:396`), then publishes **both** gRPC endpoints over the same Kestrel HTTP/2 channel the REST controllers serve: `MapGrpcService().RequireAuthorization()` (`Program.cs:419`) and `MapGrpcService().RequireAuthorization()` (`Program.cs:420`), adding gRPC reflection in Development only (`Program.cs:422-425`). The `RequireAuthorization()` is not decoration: both contracts answer conference-state questions raised on behalf of a specific end user, so internal-only ingress is not considered sufficient, and every caller is an Engagement handler sitting behind an authenticated controller whose bearer token the JWT-forwarding interceptor carries across (`Program.cs:414-418`, `[Rubric §11, Security]`). -A browser request to `GET /Sessions` enters the Gateway, is forwarded as HTTP/2 to this host, flows through the shared middleware pipeline, hits an output-cached [`SessionsController`](#sessionscontroller) action that excludes declined sessions for non-privileged readers, runs the query handler's CQRS pipeline, and returns a `CollectionResult<`[`SessionDTO`](group-17-conference-domain.md#sessiondto)`>`. Meanwhile an Engagement service can simultaneously call `ValidateSessionForBookmark` or `GetSessionLiveInfo` over gRPC against the very same process, and a `UserRegistered` message from Identity can arrive over the broker and auto-link a speaker, all without any of the three paths knowing about the others. That *one module, three ingress paths, identical whether monolith or extracted* property is the whole point of this chapter, and the reason the Conference edge is mostly thin glue over reusable Common machinery: the version-header contract and the two-version `ServiceInfo` surface are the `[Rubric §9, API & Contract Design]` evidence, and the `Replace`-driven client swaps are the `[Rubric §7, Microservices Readiness]` extension point that keeps extraction reversible. +A browser request to `GET /Sessions` enters the Gateway, is forwarded as HTTP/2 to this host, flows through the shared middleware pipeline, hits an output-cached [`SessionsController`](#sessionscontroller) action that excludes declined sessions for non-privileged readers, runs the query handler's CQRS pipeline, and returns a `CollectionResult<`[`SessionDTO`](group-17-conference-domain.md#sessiondto)`>`. Meanwhile an Engagement service can simultaneously call `ValidateSessionForBookmark` or `GetSessionLiveInfo` over gRPC against the very same process, and a `UserRegistered` message from Identity can arrive over the broker and auto-link a speaker, all without any of the three paths knowing about the others. That *one module, three ingress paths, identical whether co-hosted or standalone* property is the whole point of this chapter, and the reason the Conference edge is mostly thin glue over reusable Common machinery: the version-header contract and the two-version `ServiceInfo` surface are the `[Rubric §9, API & Contract Design]` evidence, and the `Replace`-driven client swaps are the `[Rubric §7, Microservices Readiness]` extension point that keeps the topology reversible. ### AssemblyReference > MMCA.ADC.Conference.API · `MMCA.ADC.Conference.API` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/AssemblyReference.cs:5` · Level 0 · class (static) @@ -832,33 +836,34 @@ A browser request to `GET /Sessions` enters the Gateway, is forwarded as HTTP/2 `CategoriesManage` permission and the same `conference:categories` cache tag. ### EventQuestionAnswersController -> MMCA.ADC.Conference.API · `MMCA.ADC.Conference.API.Controllers` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:56` · Level 9 · class (sealed) +> MMCA.ADC.Conference.API · `MMCA.ADC.Conference.API.Controllers` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:57` · Level 9 · class (sealed) - **What it is**: the REST controller for event feedback answers (`/EventQuestionAnswers`). Unlike the public-catalog controllers in this group, **both** reads and writes require authentication (`[Authorize(Policy = AuthorizationPolicies.RequireAuthenticated)]`, - `EventQuestionAnswersController.cs:55`), and the reads are **owner-scoped** by BR-8: organizers see every + `EventQuestionAnswersController.cs:56`), and the reads are **owner-scoped** by BR-8: organizers see every answer, everyone else sees only their own. - **Depends on**: [`EntityControllerBase`](group-12-api-hosting-mapping.md#entitycontrollerbasetentity-tentitydto-tidentifiertype) - (the read-only base, `EventQuestionAnswersController.cs:63`); + (the read-only base, `EventQuestionAnswersController.cs:64`); [`IEntityQueryService`](group-03-querying-specifications.md#ientityqueryservicetentity-tentitydto-tidentifiertype) for reads; three [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) injections for [`AddEventQuestionAnswerCommand`](group-18-conference-application.md#addeventquestionanswercommand), [`UpdateEventQuestionAnswerCommand`](group-18-conference-application.md#updateeventquestionanswercommand) and [`RemoveEventQuestionAnswerCommand`](group-18-conference-application.md#removeeventquestionanswercommand) - (`:58-60`); [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) and + (`:59-61`); [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) and [`RoleNames`](group-08-auth.md#rolenames) for the scoping decision; [`OwnedByUserSpecification`](group-03-querying-specifications.md#ownedbyuserspecificationtentity-tidentifiertype) - as the filter it builds; [`AuthorizationPolicies`](group-08-auth.md#authorizationpolicies); - the [`EventQuestionAnswerDTO`](group-17-conference-domain.md#eventquestionanswerdto); and its two request + as the filter it builds; [`AuthorizationPolicies`](group-08-auth.md#authorizationpolicies); the + [`IdempotentAttribute`](group-12-api-hosting-mapping.md#idempotentattribute) on its create; the + [`EventQuestionAnswerDTO`](group-17-conference-domain.md#eventquestionanswerdto); and its two request records [`AddEventQuestionAnswerRequest`](#addeventquestionanswerrequest) / - [`UpdateEventQuestionAnswerRequest`](#updateeventquestionanswerrequest) (`:26-46`). Externals: ASP.NET Core + [`UpdateEventQuestionAnswerRequest`](#updateeventquestionanswerrequest) (`:26-47`). Externals: ASP.NET Core MVC (`[ApiController]`, `[HttpGet]`, `[FromQuery]`), `Asp.Versioning`, `ILogger`. - **Concept introduced, per-user read scoping via a specification.** `[Rubric §11, Security]` assesses whether authorization is enforced server-side and whether results are scoped per user rather than merely - hidden in the UI. The private `GetUserScopingSpecification()` (`EventQuestionAnswersController.cs:66-67`) + hidden in the UI. The private `GetUserScopingSpecification()` (`EventQuestionAnswersController.cs:67-68`) returns `null` when `currentUserService.IsInRole(RoleNames.Organizer)` (no filter, sees all), otherwise a `new OwnedByUserSpecification(currentUserService.UserId!.Value)`. That specification is threaded into `QueryService.GetAllAsync` / `GetByIdAsync`, so the *database query @@ -866,47 +871,51 @@ A browser request to `GET /Sessions` enters the Gateway, is forwarded as HTTP/2 filtered out of an already-fetched page. This is why the reads here fully `override` the base actions (threading the specification, `asTracking: false`, and a `MaxPageSize` cap) instead of delegating with `=> base....` the way the public controllers do. `[Rubric §9, API & Contract Design]`: the write records - carry no `UserId` at all (`:26-46`); identity comes from the authenticated principal and `CreatedBy` is + carry no `UserId` at all (`:26-47`); identity comes from the authenticated principal and `CreatedBy` is stamped by the audit pipeline, never trusted from the client. Note the absence of any `[OutputCache]` - attribute: a per-caller response must not land in a shared cache entry, and the controller simply never - opts in. + attribute on any action in this file: a per-caller response must not land in a shared cache entry, and the + controller simply never opts in. - **Concept introduced, closing the CSV export as a row-scoping bypass.** The framework base ships a streaming CSV endpoint, `ExportAsync` - (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:234`), and its own + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:235`), and its own remarks state the hazard plainly: the rows it streams are whatever `GetExportSpecification()` allows, and that hook returns `null` by default, so an export is unscoped unless the concrete controller says - otherwise (`EntityControllerBase.cs:213-218, 466-488`). A controller that row-scopes its list endpoints + otherwise (`EntityControllerBase.cs:214-219, 520-542`). A controller that row-scopes its list endpoints but inherits the default export therefore hands every caller the whole table in one request. This controller closes that with the **role gate** the base describes as the interim form of the mitigation - (`EntityControllerBase.cs:478-482`): `ExportAsync` is overridden to `Forbid()` unless the caller is an - organizer, then delegate to the base (`EventQuestionAnswersController.cs:159-173`). Every row-scoped + (`EntityControllerBase.cs:533-537`): `ExportAsync` is overridden to `Forbid()` unless the caller is an + organizer, then delegate to the base (`EventQuestionAnswersController.cs:159-174`). Every row-scoped controller in this unit repeats one of the two variants of this gate, and the rationale is written into - each override's doc comment (`:152-157` here). `[Rubric §11, Security]` again, and `[Rubric §30, + each override's doc comment (`:153-158` here). `[Rubric §11, Security]` again, and `[Rubric §30, Compliance/Privacy/Data Governance]`, which assesses whether personal data has a single governed exit path: feedback answers are attributable personal content, so a bulk download stays with the role that already reads every row. - **Walkthrough** - - Primary-constructor injection (`EventQuestionAnswersController.cs:56-62`): query service, three command - handlers, `ICurrentUserService`, logger. The base is constructed with `(queryService, logger)` (`:63`). - - `GetUserScopingSpecification()` (`:66-67`): the organizer-or-own branch described above. - - `GetAllAsync` (`:69-88`): fully overridden, calls `QueryService.GetAllAsync(specification: - GetUserScopingSpecification(), pageSize: MaxPageSize, asTracking: false, ...)` (`:77-85`) and returns + - Primary-constructor injection (`EventQuestionAnswersController.cs:57-63`): query service, three command + handlers, `ICurrentUserService`, logger. The base is constructed with `(queryService, logger)` (`:64`). + - `GetUserScopingSpecification()` (`:67-68`): the organizer-or-own branch described above. + - `GetAllAsync` (`:70-89`): fully overridden, calls `QueryService.GetAllAsync(specification: + GetUserScopingSpecification(), pageSize: MaxPageSize, asTracking: false, ...)` (`:78-86`) and returns `Ok(result.Value)` or `HandleFailure(result.Errors)`. - - The paged `GetAllAsync` (`:90-123`): clamps `pageSize = Math.Min(pageSize, MaxPageSize)` (`:102`), - threads the same specification (`:108`), binds `filters` through the - [`QueryFilterModelBinder`](group-12-api-hosting-mapping.md#queryfiltermodelbinder) (`:99`), and appends - the `X-Pagination` header carrying the result's pagination metadata (`:121`). - - `GetAllForLookupAsync` (`:125-129`) delegates straight to the base; `GetByIdAsync` (`:131-150`) threads - the specification (`:144`) so an attendee cannot fetch another user's answer by id. - - `ExportAsync` (`:159-173`): the organizer gate above, `Forbid()` at `:169`, otherwise - `base.ExportAsync(...)` at `:172`. - - `CreateAsync` (`:176-191`): dispatches `new AddEventQuestionAnswerCommand(request.EventId, null, - request.QuestionId, request.AnswerValue)` (`:182`), the `null` being the child id the domain mints, then + - The paged `GetAllAsync` (`:91-124`): clamps `pageSize = Math.Min(pageSize, MaxPageSize)` (`:103`), + threads the same specification (`:109`), binds `filters` through the + [`QueryFilterModelBinder`](group-12-api-hosting-mapping.md#queryfiltermodelbinder) (`:100`), and appends + the `X-Pagination` header carrying the result's + [`PaginationMetadata`](group-01-result-error-handling.md#paginationmetadata) (`:122`). + - `GetAllForLookupAsync` (`:126-130`) delegates straight to the base; `GetByIdAsync` (`:132-151`) threads + the specification (`:145`) so an attendee cannot fetch another user's answer by id. + - `ExportAsync` (`:159-174`): the organizer gate above, `Forbid()` at `:170`, otherwise + `base.ExportAsync(...)` at `:173`. + - `CreateAsync` (`:185-199`) carries `[Idempotent]` (`:184`), so a retried POST with the same + `Idempotency-Key` replays the stored response instead of writing a second answer row. The doc comment + records why the attribute is declared here rather than inherited: this create is hand-written, not the + base action (`:176-182`). It dispatches `new AddEventQuestionAnswerCommand(request.EventId, null, + request.QuestionId, request.AnswerValue)` (`:190`), the `null` being the child id the domain mints, then `CreatedAtRoute("GetEventQuestionAnswerById", ...)`. - - `UpdateAsync` (`:194-207`): dispatches `new UpdateEventQuestionAnswerCommand(request.EventId, id, - request.AnswerValue)` (`:201`) and returns `NoContent()`. - - `DeleteAsync` (`:210-223`): takes the parent `eventId` `[FromQuery]` (`:213`) because the route only - carries the child id, dispatches `RemoveEventQuestionAnswerCommand(eventId, id)` (`:217`), and returns + - `UpdateAsync` (`:203-215`): dispatches `new UpdateEventQuestionAnswerCommand(request.EventId, id, + request.AnswerValue)` (`:209`) and returns `NoContent()`. + - `DeleteAsync` (`:219-231`): takes the parent `eventId` `[FromQuery]` (`:221`) because the route only + carries the child id, dispatches `RemoveEventQuestionAnswerCommand(eventId, id)` (`:225`), and returns `NoContent()`. - **Why it's built this way**: BR-8 mandates that non-organizers see only their own answers, so the controller injects an ownership specification into the query pipeline rather than filtering after the @@ -919,35 +928,36 @@ A browser request to `GET /Sessions` enters the Gateway, is forwarded as HTTP/2 exact session-scoped sibling (BR-9), built the same way. - **Caveats / not-in-source**: the controller gates the export by role instead of overriding `GetExportSpecification()`, so an attendee cannot export their *own* answers at all. The base's remarks - describe the specification override as the form that would restore that (`EntityControllerBase.cs:478-482`); + describe the specification override as the form that would restore that (`EntityControllerBase.cs:533-537`); whether that change is planned is not determinable from source. --- ### EventSpeakersController -> MMCA.ADC.Conference.API · `MMCA.ADC.Conference.API.Controllers` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:46` · Level 9 · class (sealed) +> MMCA.ADC.Conference.API · `MMCA.ADC.Conference.API.Controllers` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:47` · Level 9 · class (sealed) - **What it is**: the REST controller for the many-to-many link between an event and a speaker (`/EventSpeakers`). It exposes anonymous read endpoints and organizer-only add/remove endpoints. Because an [`EventSpeaker`](group-17-conference-domain.md#eventspeaker) is a *child* of the [`Event`](group-17-conference-domain.md#event) aggregate, this controller reads the child directly but mutates it only through the parent aggregate's commands. It is the reference implementation of the - junction controller shape that four controllers in this unit share. + junction controller shape that three more controllers in this unit share. - **Depends on**: [`EntityControllerBase`](group-12-api-hosting-mapping.md#entitycontrollerbasetentity-tentitydto-tidentifiertype) - (the read-only base, `EventSpeakersController.cs:54`), + (the read-only base, `EventSpeakersController.cs:55`), [`IEntityQueryService`](group-03-querying-specifications.md#ientityqueryservicetentity-tentitydto-tidentifiertype) for reads, two [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult)s ([`AddEventSpeakerCommand`](group-18-conference-application.md#addeventspeakercommand) / [`RemoveEventSpeakerCommand`](group-18-conference-application.md#removeeventspeakercommand), - `EventSpeakersController.cs:48-49`), an + `EventSpeakersController.cs:49-50`), an [`IQueryHandler`](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) for [`GetPublicEventSpeakerFilterQuery`](group-18-conference-application.md#getpubliceventspeakerfilterquery) - (`:50`), [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) plus the + (`:51`), [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) plus the [`CurrentUserServiceExtensions`](#currentuserserviceextensions) read-audience helper, ASP.NET Core's - `IOutputCacheStore` (`:52`), the [`EventSpeakerDTO`](group-17-conference-domain.md#eventspeakerdto), the + `IOutputCacheStore` (`:53`), the [`EventSpeakerDTO`](group-17-conference-domain.md#eventspeakerdto), the [`HasPermissionAttribute`](group-08-auth.md#haspermissionattribute) and the - [`ConferencePermissions`](group-17-conference-domain.md#conferencepermissions) catalog, and its request - record [`AddEventSpeakerRequest`](#addeventspeakerrequest) (`:28-35`). + [`ConferencePermissions`](group-17-conference-domain.md#conferencepermissions) catalog, the + [`IdempotentAttribute`](group-12-api-hosting-mapping.md#idempotentattribute), and its request record + [`AddEventSpeakerRequest`](#addeventspeakerrequest) (`:28-36`). - **Concept introduced, the junction controller and its inherited visibility.** `[Rubric §4, Domain-Driven Design]` assesses whether aggregate boundaries are respected: you never POST straight at a child row. The controller derives from the read-only @@ -955,50 +965,55 @@ A browser request to `GET /Sessions` enters the Gateway, is forwarded as HTTP/2 (which supplies only `GetAll` / `GetById` / `GetAllForLookup` / `Export`, no create or delete) and hand-rolls its two mutations, each dispatching a command that loads the parent aggregate. `[Rubric §11, Security]`: the class carries `[HasPermission(ConferencePermissions.EventsManage)]` - (`EventSpeakersController.cs:45`) so writes require the organizer capability (BR-41), while every read + (`EventSpeakersController.cs:46`) so writes require the organizer capability (BR-41), while every read overrides that with `[AllowAnonymous]` (BR-43). The subtle half is BR-108: a junction row must not leak the existence of an unpublished event, so the reads do **not** simply forward to the base. `IsPrivileged` - (`:57`) asks `currentUserService.IsPrivilegedConferenceReader()`, and `BuildPublicSpecificationAsync` - (`:65-74`) returns `null` for a privileged reader or, for everyone else, the + (`:58`) asks `currentUserService.IsPrivilegedConferenceReader()`, and `BuildPublicSpecificationAsync` + (`:66-75`) returns `null` for a privileged reader or, for everyone else, the [`Specification`](group-03-querying-specifications.md#specificationtentity-tidentifiertype) produced by the `GetPublicEventSpeakerFilterQuery` handler, which resolves the published-event id list in the Application layer. Every read threads that specification into the query service, so a hidden parent yields a 404 rather than a redacted row. `[Rubric §12, Performance & Scalability]`: the reads are cached under the `EventsCache` policy (5-minute TTL, tags `conference` and `conference:events`, registered at - `MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:232`), which is exactly why the writes - must evict. + `MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:237`), which is exactly why the writes + must evict. That policy is a + [`PublicEndpointOutputCachePolicy`](group-12-api-hosting-mapping.md#publicendpointoutputcachepolicy) + registration and it bypasses the cache entirely for the privileged read audience + ([ADR-040](https://ivanball.github.io/docs/adr/040-authenticated-output-caching-for-public-reads.html)), + which is what keeps an organizer's everything-inclusive payload out of the shared public entry. - **Walkthrough** - - `GetAllAsync` (`EventSpeakersController.cs:79-97`) and the paged overload (`:102-134`) are full - overrides: `[AllowAnonymous]` + `[OutputCache(PolicyName = "EventsCache")]` (`:77-78, 100-101`), the - public specification threaded in (`:89, 119`), the page size clamped to `MaxPageSize` (`:113`), and the - `X-Pagination` header appended (`:132`). - - `GetAllForLookupAsync` (`:143-160`) is the anti-side-channel path: a privileged reader (null - specification) falls through to the framework base action (`:148-149`); everyone else gets - `QueryService.GetAllForLookupAsync(nameProperty, where: specification.Criteria, ...)` (`:151-155`), the + - `GetAllAsync` (`EventSpeakersController.cs:80-98`) and the paged overload (`:103-135`) are full + overrides: `[AllowAnonymous]` + `[OutputCache(PolicyName = "EventsCache")]` (`:78-79, 101-102`), the + public specification threaded in (`:90, 120`), the page size clamped to `MaxPageSize` (`:114`), and the + `X-Pagination` header appended (`:133`). + - `GetAllForLookupAsync` (`:144-161`) is the anti-side-channel path: a privileged reader (null + specification) falls through to the framework base action (`:149-150`); everyone else gets + `QueryService.GetAllForLookupAsync(nameProperty, where: specification.Criteria, ...)` (`:152-156`), the `where` overload declared at - `MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEntityQueryService.cs:87-91`, and the rows + `MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEntityQueryService.cs:87`, and the rows are wrapped back into a [`CollectionResult`](group-01-result-error-handling.md#collectionresultt) of - [`BaseLookup`](group-12-api-hosting-mapping.md#baselookuptidentifiertype) (`:159`). + [`BaseLookup`](group-12-api-hosting-mapping.md#baselookuptidentifiertype) (`:160`). Without this, a dropdown would enumerate the names the list endpoint hides. - - `GetByIdAsync` (`:165-183`) threads the same specification (`:177`). - - `ExportAsync` (`:192-206`) is the export gate taught at + - `GetByIdAsync` (`:166-184`) threads the same specification (`:178`). + - `ExportAsync` (`:193-207`) is the export gate taught at [`EventQuestionAnswersController`](#eventquestionanswerscontroller), in its privileged-reader form: - `if (!IsPrivileged) return Forbid();` (`:200-203`), then `base.ExportAsync(...)` (`:205`). The doc + `if (!IsPrivileged) return Forbid();` (`:201-204`), then `base.ExportAsync(...)` (`:206`). The doc comment states the leak it prevents: an unscoped CSV would carry the junction rows of unpublished - events, "leaking exactly the existence the reads above hide" (`:186-190`). - - `CreateAsync` (`:210-228`) dispatches `AddEventSpeakerCommand(request.EventId, null, request.SpeakerId)` - (`:215`), returns `HandleFailure(result.Errors)` on failure (`:218-221`), then evicts and returns - `CreatedAtRoute("GetEventSpeakerById", ...)` (`:223-227`). - - `DeleteAsync` (`:232-248`) reads the parent `eventId` `[FromQuery]` (`:234`), dispatches - `RemoveEventSpeakerCommand(eventId, id)` (`:238`), evicts (`:246`), and returns `NoContent()`. - - `EvictJunctionCacheAsync` (`:255-260`) clears **both** parents' tags plus the broad one: + events, "leaking exactly the existence the reads above hide" (`:186-191`). + - `CreateAsync` (`:218-236`) is `[Idempotent]` (`:217`) and dispatches + `AddEventSpeakerCommand(request.EventId, null, request.SpeakerId)` (`:223`), returns + `HandleFailure(result.Errors)` on failure (`:226-229`), then evicts and returns + `CreatedAtRoute("GetEventSpeakerById", ...)` (`:231-235`). + - `DeleteAsync` (`:240-256`) reads the parent `eventId` `[FromQuery]` (`:242`), dispatches + `RemoveEventSpeakerCommand(eventId, id)` (`:246`), evicts (`:254`), and returns `NoContent()`. + - `EvictJunctionCacheAsync` (`:263-268`) clears **both** parents' tags plus the broad one: `conference:events`, `conference:speakers`, `conference`. Note the ordering guard in both mutations: the failure return happens before the eviction, so a rejected command never disturbs the cache. - Error-to-HTTP translation is inherited from [`ApiControllerBase`](group-12-api-hosting-mapping.md#apicontrollerbase)`.HandleFailure`. - **Why it's built this way**: a child has no independent lifecycle, so it earns free read endpoints but - explicit, aggregate-routed mutations. The visibility filter lives at the controller edge as a + explicit, aggregate-routed mutations. The visibility filter lives at the controller boundary as a specification because that is the one place that knows the caller's role, while the *rule* (which parents are public) stays in an Application-layer query handler (`[Rubric §3, Clean Architecture]`). Evicting both parents' tags is deliberate: the association shows up @@ -1044,10 +1059,14 @@ A browser request to `GET /Sessions` enters the Gateway, is forwarded as HTTP/2 the sibling controllers' doc comments describe, for example `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:286-287`). - **Walkthrough** - - The class is gated by `[HasPermission(ConferencePermissions.QuestionsManage)]` (`QuestionsController.cs:30`); - all four reads override that with `[AllowAnonymous]` and attach + - The class is gated by `[HasPermission(ConferencePermissions.QuestionsManage)]` (`QuestionsController.cs:30`), + a capability granted to Organizer and Admin only + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/DependencyInjection.cs:43-44`; it is absent + from the ContentEditor subset, + `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Authorization/ConferencePermissions.cs:57-64`). + All four reads override that with `[AllowAnonymous]` and attach `[OutputCache(PolicyName = "QuestionsCache")]` (5-minute TTL, tags `conference` and - `conference:questions`, `MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:242`). + `conference:questions`, `MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:247`). - `CreateAsync` (`:92-99`) and `DeleteAsync` (`:121-128`) are thin overrides: call `base.CreateAsync` / `base.DeleteAsync`, then `await EvictQuestionsCacheAsync(...)`, then return the base's result. Because the base hands back an `ActionResult` rather than a @@ -1063,68 +1082,102 @@ A browser request to `GET /Sessions` enters the Gateway, is forwarded as HTTP/2 - **Why it's built this way**: questions carry no per-role visibility rule, so the controller carries none. It is the reference case for how little an aggregate-root controller must write when the base does the work: policy, one update action, and eviction. -- **Where it's used**: the Conference service host; the feedback-form builder UI is the main client, and the - answers flow through [`EventQuestionAnswersController`](#eventquestionanswerscontroller) and +- **Where it's used**: the Conference service host, reached through the Gateway route + `/Questions/{**catch-all}` (`MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:92`); the + feedback-form builder UI is the main client, and the answers flow through + [`EventQuestionAnswersController`](#eventquestionanswerscontroller) and [`SessionQuestionAnswersController`](#sessionquestionanswerscontroller). --- ### RoomsController -> MMCA.ADC.Conference.API · `MMCA.ADC.Conference.API.Controllers` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/RoomsController.cs:85` · Level 9 · class (sealed) +> MMCA.ADC.Conference.API · `MMCA.ADC.Conference.API.Controllers` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/RoomsController.cs:92` · Level 9 · class (sealed) - **What it is**: the REST controller for conference [`Room`](group-17-conference-domain.md#room)s (`/Rooms`). Rooms are child entities of an [`Event`](group-17-conference-domain.md#event) but are exposed - at a top-level route for convenient querying (`RoomsController.cs:76-80`). It is a child-collection - controller like [`EventSpeakersController`](#eventspeakerscontroller), but with a fuller add / update / - remove surface, real editable content, and no inherited visibility filter. + at a top-level route for convenient querying (`RoomsController.cs:82-87`). It is a child-collection + controller like [`EventSpeakersController`](#eventspeakerscontroller), with the same BR-108 parent + visibility rule on its reads, but a fuller add / update / remove surface because a room has real editable + content rather than just an association. - **Depends on**: [`EntityControllerBase`](group-12-api-hosting-mapping.md#entitycontrollerbasetentity-tentitydto-tidentifiertype) - (`RoomsController.cs:92`), + (`RoomsController.cs:101`), [`IEntityQueryService`](group-03-querying-specifications.md#ientityqueryservicetentity-tentitydto-tidentifiertype), three [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult)s for [`AddRoomCommand`](group-18-conference-application.md#addroomcommand) / [`UpdateRoomCommand`](group-18-conference-application.md#updateroomcommand) / - [`RemoveRoomCommand`](group-18-conference-application.md#removeroomcommand) (`:87-89`), - `IOutputCacheStore` (`:90`), the [`RoomDTO`](group-17-conference-domain.md#roomdto), and its two request - records [`AddRoomRequest`](#addroomrequest) / [`UpdateRoomRequest`](#updateroomrequest) (`:24-74`), which + [`RemoveRoomCommand`](group-18-conference-application.md#removeroomcommand) (`:94-96`), an + [`IQueryHandler`](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) for + [`GetPublicRoomFilterQuery`](group-18-conference-application.md#getpublicroomfilterquery) (`:97`), + [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) with the + [`CurrentUserServiceExtensions`](#currentuserserviceextensions) read-audience helper (`:98, 104`), + `IOutputCacheStore` (`:99`), the [`RoomDTO`](group-17-conference-domain.md#roomdto), the + [`IdempotentAttribute`](group-12-api-hosting-mapping.md#idempotentattribute), and its two request + records [`AddRoomRequest`](#addroomrequest) / [`UpdateRoomRequest`](#updateroomrequest) (`:30-80`), which carry the room's name, sort order, and optional capacity / floor / location / accessibility fields. +- **Concept introduced, scoping by a parent's real foreign key.** `[Rubric §11, Security]` and `[Rubric §9, + API & Contract Design]`: BR-108 hides an unpublished event's venue layout, and `Room` carries a real + `EventId` column, so the controller does not have to intercept anything. `BuildPublicRoomSpecificationAsync` + (`RoomsController.cs:114-124`) returns `null` for a privileged reader (`IsPrivileged`, `:104`) and + otherwise the specification resolved by the `GetPublicRoomFilterQuery` handler; a failed handler result + degrades to `null` rather than failing the read (`:123`). Because the caller's own `EventId` filter goes + through the generic filter pipeline unchanged, the two predicates are **composed** by the query service + rather than substituted, so scoping to an unpublished event returns an empty page instead of that event's + rooms. The doc comment at `:152-157` states exactly that contract. Compare + [`SpeakersController`](#speakerscontroller), where `EventId` is *not* a column and the paged action must + intercept the key by hand. - **Concept introduced, output-cache eviction on mutation.** `[Rubric §12, Performance & Scalability]` assesses caching strategy: every read here is decorated `[OutputCache(PolicyName = "RoomsCache")]` - (`RoomsController.cs:96, 106, 121, 129`), so anonymous room reads are served from a 5-minute entry tagged + (`RoomsController.cs:132, 160, 202, 229`), so anonymous room reads are served from a 5-minute entry tagged `conference` and `conference:rooms` - (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:240`). The correctness half is - eviction: each mutation ends by calling `EvictRoomsCacheAsync` (`RoomsController.cs:215-216`), which does + (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:245`). The correctness half is + eviction: each mutation ends by calling `EvictRoomsCacheAsync` (`RoomsController.cs:328-329`), which does `outputCacheStore.EvictByTagAsync("conference:rooms", ...)`, invalidating exactly the room reads and nothing else. `[Rubric §3, Clean Architecture]`: the eviction lives in the controller, not the command - handler, because `IOutputCacheStore` is an ASP.NET concern the Application layer must not reference. Note - also what is *absent*: rooms carry no BR-108-style parent visibility filter, so the reads delegate - straight to the base with `=> base....` (`:97-141`), and, exactly as in - [`QuestionsController`](#questionscontroller), there is no `ExportAsync` override either, because there is - no row scoping for an export to bypass. A room name is not considered a leak the way a draft event's title - is. + handler, because `IOutputCacheStore` is an ASP.NET concern the Application layer must not reference. - **Walkthrough** - - The class gate is `[HasPermission(ConferencePermissions.RoomsManage)]` (`RoomsController.cs:84`), a + - The class gate is `[HasPermission(ConferencePermissions.RoomsManage)]` (`RoomsController.cs:91`), a room-specific capability rather than the event one, even though rooms hang off the event aggregate; each read re-opens with `[AllowAnonymous]`. - - `CreateAsync` (`:145-169`) maps `AddRoomRequest` to `AddRoomCommand` positionally (`:150-158`, note the - optional client-supplied `RoomId` in slot two), returns `HandleFailure` on failure (`:161-162`), evicts - (`:164`), and returns `CreatedAtRoute("GetRoomById", ...)`. - - `UpdateAsync` (`:173-195`) dispatches `UpdateRoomCommand` with the parent `EventId` from the body and - the child id from the route (`:179-187`), evicts (`:193`), and returns `NoContent()`. - - `DeleteAsync` (`:199-213`) reads the parent `eventId` `[FromQuery]` (`:201`), dispatches - `RemoveRoomCommand(eventId, id)` (`:205`), evicts (`:211`), and returns `NoContent()`. + - `GetAllAsync` (`:133-150`) and the paged overload (`:161-192`) thread the public specification + (`:142, 177`), clamp `pageSize` to `MaxPageSize` (`:172`), and append the `X-Pagination` header + (`:190`). + - `GetAllForLookupAsync` (`:203-220`) is the anti-side-channel path: privileged readers fall through to + the base (`:208-209`), everyone else forwards `specification.Criteria` as the lookup `where` + (`:211-215`) and the rows are rewrapped into a + [`CollectionResult`](group-01-result-error-handling.md#collectionresultt) of + [`BaseLookup`](group-12-api-hosting-mapping.md#baselookuptidentifiertype) (`:219`). + - `GetByIdAsync` (`:230-247`) threads the same specification (`:241`). Its doc comment states the rule + precisely: a room of an unpublished event is a 404, "not a redacted record, so a guessed id cannot + confirm that an unannounced event exists or that a venue has been booked for it" (`:222-226`). + - `CreateAsync` (`:258-282`) is `[Idempotent]` (`:257`), maps `AddRoomRequest` to `AddRoomCommand` + positionally (`:263-271`, note the optional client-supplied `RoomId` in slot two), returns + `HandleFailure` on failure (`:274-275`), evicts (`:277`), and returns `CreatedAtRoute("GetRoomById", ...)`. + - `UpdateAsync` (`:286-308`) dispatches `UpdateRoomCommand` with the parent `EventId` from the body and + the child id from the route (`:292-300`), evicts (`:306`), and returns `NoContent()`. + - `DeleteAsync` (`:312-326`) reads the parent `eventId` `[FromQuery]` (`:314`), dispatches + `RemoveRoomCommand(eventId, id)` (`:318`), evicts (`:324`), and returns `NoContent()`. - All three mutations return `HandleFailure` *before* they evict, so a failed command never disturbs the cache. - **Why it's built this way**: rooms are read far more than they are edited (venue maps, schedule grids), so - caching the public reads is worth the eviction bookkeeping on the rare write. The add / update / remove - trio (richer than the two-verb junction controllers) reflects that a room has real editable content, not - just an association. -- **Where it's used**: the Conference service host; consumed by the room-management UI and by any schedule - view that resolves a session's room. + caching the public reads is worth the eviction bookkeeping on the rare write. Scoping the reads through + the Application-layer filter query rather than a controller-side join keeps the persistence knowledge out + of the boundary, the same division [`EventSpeakersController`](#eventspeakerscontroller) uses. +- **Where it's used**: the Conference service host, behind the Gateway route `/Rooms/{**catch-all}` + (`MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:52`); consumed by the room-management UI under + `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Room/` and by any schedule view that + resolves a session's room. +- **Caveats / not-in-source**: this is the one row-scoped controller in the unit that does **not** override + `ExportAsync`, so `/Rooms/export` streams unscoped, protected only by the class-level `RoomsManage` + capability. That capability is granted to Organizer and Admin (`DependencyInjection.cs:43-44`) while the + privileged read audience is Organizer and ContentEditor + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Authorization/ConferenceReadAudience.cs:26-30`), + so the two sets are not identical. Whether the missing override is deliberate is not determinable from + source: unlike its siblings, the file carries no doc comment on the subject. --- ### SpeakerCategoryItemsController -> MMCA.ADC.Conference.API · `MMCA.ADC.Conference.API.Controllers` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:47` · Level 9 · class (sealed) +> MMCA.ADC.Conference.API · `MMCA.ADC.Conference.API.Controllers` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:48` · Level 9 · class (sealed) - **What it is**: the REST controller for the link between a [`Speaker`](group-17-conference-domain.md#speaker) and a @@ -1133,44 +1186,48 @@ A browser request to `GET /Sessions` enters the Gateway, is forwarded as HTTP/2 [`EventSpeakersController`](#eventspeakerscontroller): anonymous reads that inherit the parent's visibility, organizer add/remove, no update. - **Depends on**: [`EntityControllerBase`](group-12-api-hosting-mapping.md#entitycontrollerbasetentity-tentitydto-tidentifiertype) - (`SpeakerCategoryItemsController.cs:55`), + (`SpeakerCategoryItemsController.cs:56`), [`IEntityQueryService`](group-03-querying-specifications.md#ientityqueryservicetentity-tentitydto-tidentifiertype), the [`AddSpeakerCategoryItemCommand`](group-18-conference-application.md#addspeakercategoryitemcommand) / [`RemoveSpeakerCategoryItemCommand`](group-18-conference-application.md#removespeakercategoryitemcommand) - [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult)s (`:49-50`), an + [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult)s (`:50-51`), an [`IQueryHandler`](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) for [`GetPublicSpeakerCategoryItemFilterQuery`](group-18-conference-application.md#getpublicspeakercategoryitemfilterquery) - (`:51`), [`ICurrentUserService`](group-08-auth.md#icurrentuserservice), `IOutputCacheStore` (`:53`), the - [`SpeakerCategoryItemDTO`](group-17-conference-domain.md#speakercategoryitemdto), and the - [`AddSpeakerCategoryItemRequest`](#addspeakercategoryitemrequest) record (`:28-35`). + (`:52`), [`ICurrentUserService`](group-08-auth.md#icurrentuserservice), `IOutputCacheStore` (`:54`), the + [`SpeakerCategoryItemDTO`](group-17-conference-domain.md#speakercategoryitemdto), the + [`IdempotentAttribute`](group-12-api-hosting-mapping.md#idempotentattribute), and the + [`AddSpeakerCategoryItemRequest`](#addspeakercategoryitemrequest) record (`:29-36`). - **Concept introduced**: none new; this is the junction controller pattern taught at [`EventSpeakersController`](#eventspeakerscontroller). Two differences are worth noting. `[Rubric §11, Security]`, first, the permission vocabulary tracks the *owning* aggregate: the class is guarded by `[HasPermission(ConferencePermissions.SpeakersManage)]` - (`SpeakerCategoryItemsController.cs:46`) rather than the `EventsManage` its event-side twin uses, so + (`SpeakerCategoryItemsController.cs:47`) rather than the `EventsManage` its event-side twin uses, so managing a speaker's tags requires speaker-management rights. Second, the inherited visibility rule is BR-239 (a junction row must not reveal a speaker the caller cannot read) rather than BR-108, resolved by - the `GetPublicSpeakerCategoryItemFilterQuery` handler through `BuildPublicSpecificationAsync` (`:66-75`), - with `IsPrivileged` (`:58`) short-circuiting for Organizer / ContentEditor. + the `GetPublicSpeakerCategoryItemFilterQuery` handler through `BuildPublicSpecificationAsync` (`:67-76`), + with `IsPrivileged` (`:59`) short-circuiting for Organizer / ContentEditor. - **Walkthrough**: shape-for-shape the same as [`EventSpeakersController`](#eventspeakerscontroller), with the `SpeakersCache` policy instead of - `EventsCache` (`Program.cs:239`). `GetAllAsync` (`SpeakerCategoryItemsController.cs:80-98`) and the paged - overload (`:103-135`) thread the public specification (`:90, 120`) and append `X-Pagination` (`:133`); - `GetAllForLookupAsync` (`:144-161`) delegates to the base for privileged readers and otherwise forwards - `specification.Criteria` as the lookup `where` (`:152-156`); `GetByIdAsync` (`:166-184`) threads the same - specification (`:178`). `ExportAsync` (`:193-207`) repeats the privileged-reader export gate - (`Forbid()` at `:203`, doc comment at `:186-191`). `CreateAsync` (`:211-229`) dispatches - `AddSpeakerCategoryItemCommand(request.SpeakerId, null, request.CategoryItemId)` (`:216`), evicts, and - returns `CreatedAtRoute("GetSpeakerCategoryItemById", ...)`; `DeleteAsync` (`:233-249`) reads the parent - `speakerId` `[FromQuery]` (`:235`), dispatches `RemoveSpeakerCategoryItemCommand(speakerId, id)` (`:239`), - evicts, and returns `NoContent()`. `EvictJunctionCacheAsync` (`:256-261`) clears `conference:speakers`, + `EventsCache` (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:244`). `GetAllAsync` + (`SpeakerCategoryItemsController.cs:81-99`) and the paged overload (`:104-136`) thread the public + specification (`:91, 121`) and append `X-Pagination` (`:134`); + `GetAllForLookupAsync` (`:145-162`) delegates to the base for privileged readers (`:150-151`) and + otherwise forwards `specification.Criteria` as the lookup `where` (`:153-157`); `GetByIdAsync` + (`:167-185`) threads the same specification (`:179`). `ExportAsync` (`:194-208`) repeats the + privileged-reader export gate (`Forbid()` at `:204`, doc comment at `:187-192`). `CreateAsync` + (`:219-237`) is `[Idempotent]` (`:218`), dispatches + `AddSpeakerCategoryItemCommand(request.SpeakerId, null, request.CategoryItemId)` (`:224`), evicts, and + returns `CreatedAtRoute("GetSpeakerCategoryItemById", ...)`; `DeleteAsync` (`:241-257`) reads the parent + `speakerId` `[FromQuery]` (`:243`), dispatches `RemoveSpeakerCategoryItemCommand(speakerId, id)` (`:247`), + evicts, and returns `NoContent()`. `EvictJunctionCacheAsync` (`:264-269`) clears `conference:speakers`, `conference:categories`, and `conference`. - **Why it's built this way**: it shares the exact shape of the other junction controllers because the underlying rules (mutate the child only through its parent aggregate; never let a junction row out-live its parent's visibility) are identical. Only the aggregate, the DTO, the permission, and the pair of cache tags change, which is `[Rubric §16, Maintainability]` in practice: one shape learned once, repeated without variation. -- **Where it's used**: the Conference service host; consumed by the speaker-profile editing UI. +- **Where it's used**: the Conference service host; consumed by the speaker-profile editing UI under + `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/`. --- @@ -1203,14 +1260,16 @@ A browser request to `GET /Sessions` enters the Gateway, is forwarded as HTTP/2 [`SpeakerUpdateRequest`](group-18-conference-application.md#speakerupdaterequest), [`LinkUserRequest`](group-17-conference-domain.md#linkuserrequest), [`SessionFeedbackDTO`](group-17-conference-domain.md#sessionfeedbackdto), the - [`HasPermissionAttribute`](group-08-auth.md#haspermissionattribute), + [`HasPermissionAttribute`](group-08-auth.md#haspermissionattribute), the + [`SpecificationExtensions`](group-03-querying-specifications.md#specificationextensions) `And` composer + that yields an [`AndSpecification`](group-03-querying-specifications.md#andspecificationtentity-tidentifiertype), and [`Error`](group-01-result-error-handling.md#error). - **Concept introduced, per-action authorization plus row-level ownership checks.** `[Rubric §11, Security]`: unlike the other aggregate-root controllers (which gate the whole class with one `[HasPermission(...)]`), `SpeakersController` carries a bare class-level `[Authorize]` - (`SpeakersController.cs:43`) and then varies authorization per action. Reads are `[AllowAnonymous]`; - export / create / delete / link / unlink each re-assert + (`SpeakersController.cs:43`) and then varies authorization per action. The catalog reads are + `[AllowAnonymous]`; export / create / delete / link / unlink each re-assert `[HasPermission(ConferencePermissions.SpeakersManage)]` (`:290, 309, 353, 365, 384`); and `UpdateAsync` performs a *resource-ownership* check in code, comparing the caller's `speaker_id` JWT claim with the route id and returning `Forbid()` when the caller is neither the speaker nor an organizer (`:335-338`, @@ -1221,9 +1280,11 @@ A browser request to `GET /Sessions` enters the Gateway, is forwarded as HTTP/2 demonstrates a *virtual filter key*. `EventId` is not a `Speaker` column, so the action removes it from the generic filter dictionary before the generic pipeline can reject it (`:154-160`), translates it into a specification via `GetSpeakersByEventFilterQuery`, and **ANDs** it with the public-speaker specification - rather than substituting it (`:162-176`, using `AndSpecification` at `:174`). Substituting would leak - hidden speakers to a non-privileged caller; an unparseable value simply drops the scope instead of failing - the request. + rather than substituting it (`:162-176`, `publicSpecification.And(...)` at `:174`, the extension member + declared at + `MMCA.Common/Source/Core/MMCA.Common.Domain/Specifications/SpecificationExtensions.cs:48`). Substituting + would leak hidden speakers to a non-privileged caller; an unparseable value simply drops the scope instead + of failing the request. - **Walkthrough** - `IsPrivileged` (`SpeakersController.cs:63`) and `BuildPublicSpeakerSpecificationAsync` (`:82-93`): the BR-239 projection, parameterized by an optional `eventId` because a speaker accepted for one event is @@ -1238,7 +1299,7 @@ A browser request to `GET /Sessions` enters the Gateway, is forwarded as HTTP/2 mapper that redacts it. The check runs before the query service, so a rejected label is never queried. - `GetByIdAsync` (`:246-279`) carries the self-read carve-out: when the caller's `speaker_id` claim matches the route id, the specification is dropped so the speaker can always load their own profile - (`:257-262`), and because that response can contain data the public cannot see while the output-cache + (`:257-267`), and because that response can contain data the public cannot see while the output-cache key does not vary by caller, the action turns storage off for this response via `HttpContext.Features.Get()?.Context.AllowCacheStorage = false` (`:266`). The policy only ever turns storage off, never back on, so the opt-out sticks. @@ -1255,37 +1316,123 @@ A browser request to `GET /Sessions` enters the Gateway, is forwarded as HTTP/2 `UpdateAsync` (`:329-349`) runs the BR-214 check, dispatches, and evicts. `LinkUserAsync` / `UnlinkUserAsync` (`:366-398`) dispatch the link/unlink commands, which drive the cross-module User-to-Speaker association over integration events, and evict. - - The three BR-210 projections are `[AllowAnonymous]`: `GetSessionFeedbackAsync` (`:405-417`) under the - broad `ConferencePublicCache` policy (`Program.cs:231`) because it spans speakers and sessions, and - `GetSessionBookmarkCountAsync` (`:424-436`) plus the batched - `GetSessionBookmarkCountsAsync` (`:444-456`) under `BookmarkCountsCache`, a 60-second policy - (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:254`). `[Rubric §7, Microservices - Readiness]`: the short TTL exists because bookmark counts are owned by the Engagement service, in - another process, whose writes have no handle on this service's cache store, so no tag eviction can ever - reach these entries and a short TTL is the only lever available from this side. - - `EvictSpeakersCacheAsync` (`:458-462`) clears `conference:speakers` and the broad `conference` tag. + - The three BR-210 projections split by sensitivity. `GetSessionFeedbackAsync` (`:408-425`) is + `[Authorize]` (`:407`) and repeats the self-or-organizer gate of the update path (`:413-416`), and it + carries **no** `[OutputCache]` at all: its doc comment records that every response is + authorization-dependent, so a shared public entry could serve one speaker's free-text feedback to + another caller (`:400-405`). The two count endpoints stay `[AllowAnonymous]`: + `GetSessionBookmarkCountAsync` (`:432-444`) and the batched `GetSessionBookmarkCountsAsync` + (`:452-464`) run under `BookmarkCountsCache`, a 60-second policy tagged `conference` and + `conference:sessions` (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:264`). + `[Rubric §7, Microservices Readiness]`: bookmark counts are owned by the Engagement service, in another + process, whose writes have no handle on this host's cache store, so Engagement's bookmark handler + publishes an eviction request over the broker that this host turns into a tag drop, and the short TTL + stays as the backstop for a message that never lands (`Program.cs:253-264`). + - `EvictSpeakersCacheAsync` (`:466-470`) clears `conference:speakers` and the broad `conference` tag. - **Why it's built this way**: speaker profiles are edited both by organizers and by the speakers themselves, so the controller needs row-aware authorization that a static policy cannot provide; keeping that check inline mirrors the per-mutation ownership pattern used across the codebase. The virtual `EventId` filter gives clients an event-scoped speaker list without adding a denormalized column to the aggregate, and the batched counts endpoint exists to replace the Speaker Dashboard's per-session fan-out - (`:438-440`), which is `[Rubric §12, Performance & Scalability]` applied at the contract level. -- **Where it's used**: the Conference service host; consumed by the speaker directory, the speaker - self-service profile page, organizer linking tools, and the speaker dashboard's feedback and bookmark - tiles. + (`:446-448`), which is `[Rubric §12, Performance & Scalability]` applied at the contract level. +- **Where it's used**: the Conference service host behind the Gateway route `/Speakers/{**catch-all}` + (`MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:48`); consumed by the public speaker directory + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSpeakerList.razor`), the + speaker self-service profile page, organizer linking tools, and the speaker dashboard's feedback and + bookmark tiles. + +--- + +### ActivitiesController +> MMCA.ADC.Conference.API · `MMCA.ADC.Conference.API.Controllers` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/ActivitiesController.cs:37` · Level 10 · class (sealed) + +- **What it is**: the REST controller for the [`Activity`](group-17-conference-domain.md#activity) aggregate + root (`/Activities`), the conference's social and networking programme (parties, meetups, sponsor + receptions). Anonymous reads scoped to published events, and create / update / delete / export behind the + activities-manage capability (`ActivitiesController.cs:27-31`). +- **Depends on**: [`AggregateRootEntityControllerBase`](group-12-api-hosting-mapping.md#aggregaterootentitycontrollerbasetentity-tentitydto-tidentifiertype-tcreaterequest) + (`ActivitiesController.cs:46-47`), + [`IEntityQueryService`](group-03-querying-specifications.md#ientityqueryservicetentity-tentitydto-tidentifiertype) + (`:38`), three [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult)s + ([`ActivityCreateRequest`](group-18-conference-application.md#activitycreaterequest), + [`UpdateActivityCommand`](group-18-conference-application.md#updateactivitycommand), and a + [`DeleteEntityCommand`](group-05-cqrs-pipeline.md#deleteentitycommandtentity-tidentifiertype) + delete handler, `:39-41`), an + [`IQueryHandler`](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) for + [`GetPublicActivityFilterQuery`](group-18-conference-application.md#getpublicactivityfilterquery) (`:42`), + [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) plus the + [`CurrentUserServiceExtensions`](#currentuserserviceextensions) read-audience helper (`:43, 50`), + `IOutputCacheStore` (`:44`), the [`ActivityDTO`](group-17-conference-domain.md#activitydto), + [`ActivityUpdateRequest`](group-18-conference-application.md#activityupdaterequest) as the PUT body + (`:227`), the [`HasPermissionAttribute`](group-08-auth.md#haspermissionattribute) and the + [`ConferencePermissions`](group-17-conference-domain.md#conferencepermissions) catalog, and the + [`QueryFilterModelBinder`](group-12-api-hosting-mapping.md#queryfiltermodelbinder). +- **Concept introduced**: none new. This controller is the exact structural twin of + [`SponsorsController`](#sponsorscontroller): the same bare `[Authorize]` class gate with a per-mutation + capability, the same real-`EventId`-column scoping (no filter interception), the same + attribute-plus-imperative export gate. What differs is the vocabulary. `[Rubric §11, Security]`: the class + carries `[Authorize]` (`ActivitiesController.cs:36`) and each mutation re-asserts + `[HasPermission(ConferencePermissions.ActivitiesManage)]` (`:193, 212, 224, 243`), the capability declared + at + `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Authorization/ConferencePermissions.cs:36` + and included in the `ContentManagement` curation subset (`ConferencePermissions.cs:57-64`), so a content + editor can run the social programme without holding event, room, or question rights. + `[Rubric §12, Performance & Scalability]`: reads run under the `ActivitiesCache` policy (5-minute TTL, + tags `conference` and `conference:activities`, + `MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:249`) and every mutation evicts both + tags. +- **Walkthrough** + - `IsPrivileged` (`ActivitiesController.cs:50`) is the shared + `currentUserService.IsPrivilegedConferenceReader()` read-audience check; + `BuildPublicActivitySpecificationAsync` (`:60-70`) returns `null` for a privileged reader and otherwise + the [`Specification`](group-03-querying-specifications.md#specificationtentity-tidentifiertype) + the `GetPublicActivityFilterQuery` handler resolves (`:66-69`); a failed handler result degrades to + `null` rather than failing the read. + - `GetAllAsync` (`:75-92`) and the paged overload (`:103-134`) are `[AllowAnonymous]` + + `[OutputCache(PolicyName = "ActivitiesCache")]` full overrides that thread the specification + (`:84, 119`), clamp `pageSize` to `MaxPageSize` (`:114`), and append the `X-Pagination` header (`:132`). + The paged action's doc comment states the composition contract explicitly: `EventId` is a real column, + so the caller's event filter travels through the generic pipeline and the published-event rule is ANDed + on top of it rather than substituted (`:94-99`). + - `GetAllForLookupAsync` (`:139-156`) is the anti-side-channel path: base action for a privileged reader + (`:144-145`), otherwise `specification.Criteria` forwarded as the lookup `where` (`:147-151`) and the + rows rewrapped into a [`CollectionResult`](group-01-result-error-handling.md#collectionresultt) of + [`BaseLookup`](group-12-api-hosting-mapping.md#baselookuptidentifiertype) (`:155`). + - `GetByIdAsync` (`:165-182`) threads the same specification (`:176`); its doc comment states that an + activity of an unpublished event is a 404, "not a redacted record, so a guessed id cannot confirm that a + party has been scheduled" (`:158-161`). + - `ExportAsync` (`:194-208`) pairs the declarative + `[HasPermission(ConferencePermissions.ActivitiesManage)]` (`:193`) with the imperative + `if (!IsPrivileged) return Forbid();` (`:202-205`), then delegates to the base (`:207`). The doc comment + names the leak an unscoped CSV would be: a social programme that has not been announced (`:184-191`). + - `CreateAsync` (`:213-220`) and `DeleteAsync` (`:244-251`) are thin overrides that call the base and then + evict; `UpdateAsync` (`:225-239`) is the hand-rolled action the base does not supply, wrapping the route + id and body in `new UpdateActivityCommand(id, request)` (`:231`), folding a failure through + `HandleFailure` (`:234-235`), evicting (`:237`), and returning `Ok(result.Value)`. + - `EvictActivitiesCacheAsync` (`:253-257`) clears `conference:activities` and the broad `conference` tag, + the latter because the activity strip renders alongside other conference reads. +- **Why it's built this way**: the social programme has the same publish-gated lifecycle as the rest of the + catalog, so it reuses the specification pattern rather than inventing an activity-specific visibility + flag, and because `Activity` owns a real `EventId` none of that scoping needs a virtual key. Repeating the + sponsor controller's shape verbatim is `[Rubric §16, Maintainability]` in practice: two aggregates with + identical rules get identical code, so the reader who has learned one has learned both. +- **Where it's used**: the Conference service host behind the Gateway route `/Activities/{**catch-all}` + (`MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:100`). Clients are the public activity page + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicActivityList.razor`) and + the organizer list, create, and detail pages under + `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Activity/`. --- ### EventsController -> MMCA.ADC.Conference.API · `MMCA.ADC.Conference.API.Controllers` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventsController.cs:44` · Level 10 · class (sealed) +> MMCA.ADC.Conference.API · `MMCA.ADC.Conference.API.Controllers` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventsController.cs:45` · Level 10 · class (sealed) - **What it is**: the REST controller for the [`Event`](group-17-conference-domain.md#event) aggregate root (`/Events`), and the richest controller in the group. On top of the standard aggregate-root CRUD it adds - visibility scoping, a scoped CSV export, publish / unpublish with optimistic-concurrency checks, a - Sessionize refresh with bespoke error mapping, iCalendar export, and the "happening now / up next" - snapshot. + visibility scoping, a scoped CSV export, publish / unpublish with conditional-write support, a Sessionize + refresh with bespoke error mapping, iCalendar export, and the "happening now / up next" snapshot. - **Depends on**: [`AggregateRootEntityControllerBase`](group-12-api-hosting-mapping.md#aggregaterootentitycontrollerbasetentity-tentitydto-tidentifiertype-tcreaterequest) - (`EventsController.cs:57-58`), + (`EventsController.cs:58-59`), [`IEntityQueryService`](group-03-querying-specifications.md#ientityqueryservicetentity-tentitydto-tidentifiertype), six [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult)s ([`EventCreateRequest`](group-18-conference-application.md#eventcreaterequest), @@ -1293,10 +1440,10 @@ A browser request to `GET /Sessions` enters the Gateway, is forwarded as HTTP/2 [`PublishEventCommand`](group-18-conference-application.md#publisheventcommand), [`UnpublishEventCommand`](group-18-conference-application.md#unpublisheventcommand), [`DeleteEntityCommand`](group-05-cqrs-pipeline.md#deleteentitycommandtentity-tidentifiertype), - [`RefreshFromSessionizeCommand`](group-18-conference-application.md#refreshfromsessionizecommand), `:46-51`), + [`RefreshFromSessionizeCommand`](group-18-conference-application.md#refreshfromsessionizecommand), `:47-52`), two [`IQueryHandler`](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult)s ([`ExportEventCalendarQuery`](group-18-conference-application.md#exporteventcalendarquery), - [`GetNowNextQuery`](group-18-conference-application.md#getnownextquery), `:52-53`), + [`GetNowNextQuery`](group-18-conference-application.md#getnownextquery), `:53-54`), [`ICurrentUserService`](group-08-auth.md#icurrentuserservice), `IOutputCacheStore`, the [`PublishedEventSpecification`](group-18-conference-application.md#publishedeventspecification), the [`EventDTO`](group-17-conference-domain.md#eventdto), @@ -1304,61 +1451,73 @@ A browser request to `GET /Sessions` enters the Gateway, is forwarded as HTTP/2 [`EventUpdateRequest`](group-18-conference-application.md#eventupdaterequest), [`EventTransitionRequest`](group-17-conference-domain.md#eventtransitionrequest), [`NowNextDTO`](group-17-conference-domain.md#nownextdto), - [`RefreshFromSessionizeResultDTO`](group-17-conference-domain.md#refreshfromsessionizeresultdto), and the - [`IdempotentAttribute`](group-12-api-hosting-mapping.md#idempotentattribute). + [`RefreshFromSessionizeResultDTO`](group-17-conference-domain.md#refreshfromsessionizeresultdto), the + [`IdempotentAttribute`](group-12-api-hosting-mapping.md#idempotentattribute), and the + [`SupportsIfMatchAttribute`](group-12-api-hosting-mapping.md#supportsifmatchattribute). - **Concept introduced, business-rule visibility scoping via a specification.** `[Rubric §11, Security]` and `[Rubric §3, Clean Architecture]`: BR-108 says non-privileged readers see only published events. Rather - than branch inside each query, the controller builds a specification at the edge: - `GetPublishedEventSpecification()` (`EventsController.cs:66-67`) returns `null` for a privileged reader + than branch inside each query, the controller builds a specification at the boundary: + `GetPublishedEventSpecification()` (`EventsController.cs:67-68`) returns `null` for a privileged reader (`currentUserService.IsPrivilegedConferenceReader()`, so a ContentEditor who reads every session can also read the events those sessions belong to) and a `PublishedEventSpecification` for everyone else. Each read - passes it into `QueryService.GetAllAsync` / `GetByIdAsync` (`:82, 112, 171`), so the authorization + passes it into `QueryService.GetAllAsync` / `GetByIdAsync` (`:83, 113, 172`), so the authorization predicate is a data specification the query service composes into SQL, not imperative post-filtering. The - lookup endpoint (`:137-154`) applies the same predicate as the `where` argument, closing the side channel - that would otherwise list draft events by name, and `ExportAsync` (`:186-200`) closes the same channel on - the CSV path with the privileged-reader `Forbid()` gate (`:194-197`). `[Rubric §29, Resilience & Business - Continuity]`: `RefreshAsync` (`:338-370`) maps upstream trouble to retryable HTTP, an - `Event.Sessionize.Throttled` error becoming `429` with a `Retry-After: 300` header (`:349-353`, BR-63) and - `Event.Sessionize.Unavailable` becoming `502` (`:356-357`), so an upstream throttle reaches the client as - a signal rather than a 500. + lookup endpoint (`:138-155`) applies the same predicate as the `where` argument, closing the side channel + that would otherwise list draft events by name, and `ExportAsync` (`:187-201`) closes the same channel on + the CSV path with the privileged-reader `Forbid()` gate (`:195-198`). +- **Concept introduced, conditional writes on a state transition.** `[Rubric §9, API & Contract Design]` + assesses whether a contract expresses concurrency honestly. `PublishAsync` and `UnpublishAsync` take an + **optional** body (`[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] EventTransitionRequest?`, + `:312, 343`) carrying the client's last-seen row version, so omitting it skips the stale-view check + ([ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html)). Both also carry + `[SupportsIfMatch]` (`:307, 338`), which lets a caller state the same precondition as an HTTP `If-Match` + header; the difference is the failure code, 412 rather than 409, and both are declared as + `[ProducesResponseType]` on each action (`:308-309, 339-340`). The doc comment records the one constraint + that is easy to trip over: the header populates the bound body, so a caller using `If-Match` must still + send a body, and `{}` is enough (`:291-304`). Both transitions are also `[Idempotent]` (`:306, 337`), + because publishing is a state assertion and replaying the stored response for a retried key is exactly + what the caller meant. `[Rubric §29, Resilience & Business Continuity]`: `RefreshAsync` (`:367-399`) maps + upstream trouble to retryable HTTP, an `Event.Sessionize.Throttled` error becoming `429` with a + `Retry-After: 300` header (`:378-382`, BR-63) and `Event.Sessionize.Unavailable` becoming `502` + (`:385-386`), so an upstream throttle reaches the client as a signal rather than a 500. - **Walkthrough** - - The reads (`EventsController.cs:69-177`) attach `[AllowAnonymous]` + + - The reads (`EventsController.cs:70-178`) attach `[AllowAnonymous]` + `[OutputCache(PolicyName = "EventsCache")]` and the published-event specification; the paged overload - serializes `PaginationMetadata` into the `X-Pagination` header (`:125`). - - `ExportCalendarAsync` (`:209-217`) streams an `.ics` document via `File(...)` with the - `text/calendar` content type and an `event-{id}.ics` file name. - - `GetNowNextAsync` (`:226-232`) and `GetCurrentNowNextAsync` (`:241-246`) serve the now / next snapshot - for a given event or, with `GetNowNextQuery(EventId: null)`, for the current one; both use the + serializes [`PaginationMetadata`](group-01-result-error-handling.md#paginationmetadata) into the + `X-Pagination` header (`:126`). + - `ExportCalendarAsync` (`:210-218`) streams an `.ics` document via `File(...)` with the + `text/calendar` content type and an `event-{id}.ics` file name (`:217`). + - `GetNowNextAsync` (`:227-233`) and `GetCurrentNowNextAsync` (`:242-247`) serve the now / next snapshot + for a given event or, with `GetNowNextQuery(EventId: null)` (`:245`), for the current one; both use the short-TTL `NowNextCache` policy (60 seconds, - `MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:246`) because the payload changes with - the clock. - - `CreateAsync` (`:255-262`) is an override marked `[Idempotent]` (`:254`), so a retried POST carrying the + `MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:252`) because the payload changes with + the clock. That policy is registered without the privileged-reader bypass, because the snapshot is + identical for every role (`Program.cs:250-252`). + - `CreateAsync` (`:256-263`) is an override marked `[Idempotent]` (`:255`), so a retried POST carrying the same `Idempotency-Key` is deduplicated; it calls `base.CreateAsync` then evicts. The attribute is - single-use, so declaring it here coincides with the inherited one instead of duplicating it (`:248-252`). - - `UpdateAsync` (`:266-288`) appends a non-fatal `X-Warning` header when a timezone change leaves existing - sessions semantically stale (BR-131, `:279-284`) and returns `Ok(result.Value.Event)`. - - `PublishAsync` (`:296-310`) and `UnpublishAsync` (`:318-332`) take an **optional** body - (`[FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] EventTransitionRequest?`) carrying the - client's last-seen row version, so omitting it skips the stale-view check - ([ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html)); both declare - `[ProducesResponseType(StatusCodes.Status409Conflict)]` (`:295, 317`). + single-use, so declaring it here coincides with the inherited one instead of duplicating it (`:249-253`). + - `UpdateAsync` (`:267-289`) appends a non-fatal `X-Warning` header when a timezone change leaves existing + sessions semantically stale (BR-131, `:280-285`) and returns `Ok(result.Value.Event)`. - `RefreshAsync` triggers a Sessionize import and, because that import touches six entity types, evicts - six tags: events, sessions, speakers, categories, rooms, and questions (`:363-368`). - - `DeleteAsync` (`:376-385`) additionally evicts `conference:sessions` and `conference:rooms` because - soft-deleting an event cascades to its children. `EvictEventsCacheAsync` (`:387-388`) is the single-tag + six tags: events, sessions, speakers, categories, rooms, and questions (`:392-397`). + - `DeleteAsync` (`:405-414`) additionally evicts `conference:sessions` and `conference:rooms` because + soft-deleting an event cascades to its children. `EvictEventsCacheAsync` (`:416-417`) is the single-tag helper the other mutations share. - **Why it's built this way**: the base still owns the plain CRUD, so all the event-specific behavior (scoping, export gating, publish lifecycle, external refresh, calendar and now-next projections) reads as a flat list of extra actions. Mapping Sessionize failures to distinct status codes here keeps that operational nuance at the boundary while the handler stays a pure `Result` producer, and the fan-out of eviction tags is written where the knowledge of "what this operation touched" actually lives. -- **Where it's used**: the Conference service host; the home-screen widget calls `now-next`, the schedule UI - calls the reads and the `.ics` export, and organizer tooling drives publish, unpublish, and refresh. +- **Where it's used**: the Conference service host behind the Gateway route `/Events/{**catch-all}` + (`MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:40`); the home-screen widget calls `now-next`, + the public schedule UI + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicEventList.razor`) calls the + reads and the `.ics` export, and organizer tooling drives publish, unpublish, and refresh. --- ### SessionCategoryItemsController -> MMCA.ADC.Conference.API · `MMCA.ADC.Conference.API.Controllers` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:47` · Level 10 · class (sealed) +> MMCA.ADC.Conference.API · `MMCA.ADC.Conference.API.Controllers` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:48` · Level 10 · class (sealed) - **What it is**: the REST controller for the link between a [`Session`](group-17-conference-domain.md#session) and a @@ -1367,95 +1526,100 @@ A browser request to `GET /Sessions` enters the Gateway, is forwarded as HTTP/2 [`EventSpeakersController`](#eventspeakerscontroller): anonymous reads that inherit the parent's visibility, organizer add/remove, no update. - **Depends on**: [`EntityControllerBase`](group-12-api-hosting-mapping.md#entitycontrollerbasetentity-tentitydto-tidentifiertype) - (`SessionCategoryItemsController.cs:55`), + (`SessionCategoryItemsController.cs:56`), [`IEntityQueryService`](group-03-querying-specifications.md#ientityqueryservicetentity-tentitydto-tidentifiertype), the [`AddSessionCategoryItemCommand`](group-18-conference-application.md#addsessioncategoryitemcommand) / [`RemoveSessionCategoryItemCommand`](group-18-conference-application.md#removesessioncategoryitemcommand) - [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult)s (`:49-50`), an + [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult)s (`:50-51`), an [`IQueryHandler`](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) for [`GetPublicSessionCategoryItemFilterQuery`](group-18-conference-application.md#getpublicsessioncategoryitemfilterquery) - (`:51`), [`ICurrentUserService`](group-08-auth.md#icurrentuserservice), `IOutputCacheStore` (`:53`), the - [`SessionCategoryItemDTO`](group-17-conference-domain.md#sessioncategoryitemdto), and the - [`AddSessionCategoryItemRequest`](#addsessioncategoryitemrequest) record (`:28-35`). + (`:52`), [`ICurrentUserService`](group-08-auth.md#icurrentuserservice), `IOutputCacheStore` (`:54`), the + [`SessionCategoryItemDTO`](group-17-conference-domain.md#sessioncategoryitemdto), the + [`IdempotentAttribute`](group-12-api-hosting-mapping.md#idempotentattribute), and the + [`AddSessionCategoryItemRequest`](#addsessioncategoryitemrequest) record (`:29-36`). - **Concept introduced**: none new; see the junction controller pattern at [`EventSpeakersController`](#eventspeakerscontroller). The class is guarded by - `[HasPermission(ConferencePermissions.SessionsManage)]` (`SessionCategoryItemsController.cs:46`) because + `[HasPermission(ConferencePermissions.SessionsManage)]` (`SessionCategoryItemsController.cs:47`) because the association belongs to the session aggregate, and its inherited visibility rule is BR-49 (a junction row must not reveal a session the caller cannot read), resolved by `BuildPublicSpecificationAsync` - (`:66-75`) with `IsPrivileged` (`:58`) short-circuiting for Organizer / ContentEditor. + (`:67-76`) with `IsPrivileged` (`:59`) short-circuiting for Organizer / ContentEditor. `[Rubric §11, Security]`: as with every junction controller here, the write permission follows the owning aggregate while the read filter follows the parent's visibility, and the CSV export repeats the privileged-reader gate so the scoping cannot be bypassed by asking for the file instead of the page. - **Walkthrough**: four `[AllowAnonymous]` + `[OutputCache(PolicyName = "SessionsCache")]` reads - (`SessionCategoryItemsController.cs:77-184`) thread the public specification (`:90, 120, 178`), append - `X-Pagination` (`:133`), and forward `specification.Criteria` as the lookup `where` for non-privileged - callers (`:148-156`). `ExportAsync` (`:193-207`) returns `Forbid()` for a non-privileged caller (`:203`) - and otherwise delegates to the base (`:206`). `CreateAsync` (`:211-229`) dispatches - `AddSessionCategoryItemCommand(request.SessionId, null, request.CategoryItemId)` (`:216`), evicts, and - returns `CreatedAtRoute("GetSessionCategoryItemById", ...)`; `DeleteAsync` (`:233-249`) reads the parent - `sessionId` `[FromQuery]` (`:235`), dispatches `RemoveSessionCategoryItemCommand(sessionId, id)` (`:239`), - evicts, and returns `NoContent()`. `EvictJunctionCacheAsync` (`:256-261`) clears `conference:sessions`, - `conference:categories`, and `conference`. + (`SessionCategoryItemsController.cs:78-185`) thread the public specification (`:91, 121, 179`), append + `X-Pagination` (`:134`), and forward `specification.Criteria` as the lookup `where` for non-privileged + callers (`:149-157`). `ExportAsync` (`:194-208`) returns `Forbid()` for a non-privileged caller (`:204`) + and otherwise delegates to the base (`:207`). `CreateAsync` (`:219-237`) is `[Idempotent]` (`:218`), + dispatches `AddSessionCategoryItemCommand(request.SessionId, null, request.CategoryItemId)` (`:224`), + evicts, and returns `CreatedAtRoute("GetSessionCategoryItemById", ...)`; `DeleteAsync` (`:241-257`) reads + the parent `sessionId` `[FromQuery]` (`:243`), dispatches + `RemoveSessionCategoryItemCommand(sessionId, id)` (`:247`), evicts, and returns `NoContent()`. + `EvictJunctionCacheAsync` (`:264-269`) clears `conference:sessions`, `conference:categories`, and + `conference`. - **Why it's built this way**: same rationale as the other junction controllers, the child mutates only through its parent aggregate, so it gets free reads and explicit, command-routed writes; and because a tag on a session is visible from both the session page and the category page, both parents' cache tags are evicted. - **Where it's used**: the Conference service host; consumed by the session-editing UI's tag picker and by - the public schedule filters. + the public schedule filters + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSessionListFilterBar.razor`). --- ### SessionQuestionAnswersController -> MMCA.ADC.Conference.API · `MMCA.ADC.Conference.API.Controllers` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:56` · Level 10 · class (sealed) +> MMCA.ADC.Conference.API · `MMCA.ADC.Conference.API.Controllers` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:57` · Level 10 · class (sealed) - **What it is**: the REST controller for a session's answered feedback questions (`/SessionQuestionAnswers`). It is the exact session-scoped sibling of [`EventQuestionAnswersController`](#eventquestionanswerscontroller): reads require authentication and are owner-scoped, so an attendee sees only their own answers and an organizer sees all (BR-9). - **Depends on**: [`EntityControllerBase`](group-12-api-hosting-mapping.md#entitycontrollerbasetentity-tentitydto-tidentifiertype) - (`SessionQuestionAnswersController.cs:63`), + (`SessionQuestionAnswersController.cs:64`), [`IEntityQueryService`](group-03-querying-specifications.md#ientityqueryservicetentity-tentitydto-tidentifiertype), the add / update / remove [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult)s for [`AddSessionQuestionAnswerCommand`](group-18-conference-application.md#addsessionquestionanswercommand), [`UpdateSessionQuestionAnswerCommand`](group-18-conference-application.md#updatesessionquestionanswercommand) and [`RemoveSessionQuestionAnswerCommand`](group-18-conference-application.md#removesessionquestionanswercommand) - (`:58-60`), [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) + + (`:59-61`), [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) + [`RoleNames`](group-08-auth.md#rolenames) for the scoping decision, [`OwnedByUserSpecification`](group-03-querying-specifications.md#ownedbyuserspecificationtentity-tidentifiertype), [`AuthorizationPolicies`](group-08-auth.md#authorizationpolicies), the + [`IdempotentAttribute`](group-12-api-hosting-mapping.md#idempotentattribute), the [`SessionQuestionAnswerDTO`](group-17-conference-domain.md#sessionquestionanswerdto), and its two request records [`AddSessionQuestionAnswerRequest`](#addsessionquestionanswerrequest) / - [`UpdateSessionQuestionAnswerRequest`](#updatesessionquestionanswerrequest) (`:26-46`). + [`UpdateSessionQuestionAnswerRequest`](#updatesessionquestionanswerrequest) (`:26-47`). - **Concept introduced**: none new; owner-scoped reads and the organizer-only export gate are taught at [`EventQuestionAnswersController`](#eventquestionanswerscontroller). `[Rubric §11, Security]`: the class is gated with `[Authorize(Policy = AuthorizationPolicies.RequireAuthenticated)]` - (`SessionQuestionAnswersController.cs:55`) so no endpoint here is anonymous, and - `GetUserScopingSpecification()` (`:66-67`) returns `null` for + (`SessionQuestionAnswersController.cs:56`) so no endpoint here is anonymous, and + `GetUserScopingSpecification()` (`:67-68`) returns `null` for `currentUserService.IsInRole(RoleNames.Organizer)` and an `OwnedByUserSpecification(currentUserService.UserId!.Value)` otherwise. This is a distinct posture from the other session-scoped child controllers, whose reads are fully anonymous and filtered by the *parent's* visibility rather than by ownership. As with its event-side twin, no read carries an `[OutputCache]` attribute, because a per-caller payload must never enter a shared cache entry. -- **Walkthrough**: the reads (`SessionQuestionAnswersController.cs:69-150`) forward to - `QueryService.GetAllAsync` / `GetByIdAsync` with the scoping specification (`:80, 108, 144`), clamp the - page size (`:102`), and append the `X-Pagination` header (`:121`); `GetAllForLookupAsync` (`:125-129`) - delegates straight to the base. `ExportAsync` (`:159-173`) returns `Forbid()` unless the caller is an - organizer (`:167-170`), the BR-9 form of the row-scoping bypass gate, with the reasoning in its doc - comment (`:152-157`). `CreateAsync` (`:176-191`) dispatches +- **Walkthrough**: the reads (`SessionQuestionAnswersController.cs:70-151`) forward to + `QueryService.GetAllAsync` / `GetByIdAsync` with the scoping specification (`:81, 109, 145`), clamp the + page size (`:103`), and append the `X-Pagination` header (`:122`); `GetAllForLookupAsync` (`:126-130`) + delegates straight to the base. `ExportAsync` (`:159-174`) returns `Forbid()` unless the caller is an + organizer (`:168-171`), the BR-9 form of the row-scoping bypass gate, with the reasoning in its doc + comment (`:153-158`). `CreateAsync` (`:185-199`) is `[Idempotent]` (`:184`), dispatches `AddSessionQuestionAnswerCommand(request.SessionId, null, request.QuestionId, request.AnswerValue)` - (`:182`) and returns `CreatedAtRoute`. `UpdateAsync` (`:194-207`) dispatches - `UpdateSessionQuestionAnswerCommand(request.SessionId, id, request.AnswerValue)` (`:201`) and returns - `NoContent()`. `DeleteAsync` (`:210-223`) reads the parent `sessionId` `[FromQuery]` (`:213`) and - dispatches `RemoveSessionQuestionAnswerCommand(sessionId, id)` (`:217`). + (`:190`) and returns `CreatedAtRoute`. `UpdateAsync` (`:203-215`) dispatches + `UpdateSessionQuestionAnswerCommand(request.SessionId, id, request.AnswerValue)` (`:209`) and returns + `NoContent()`. `DeleteAsync` (`:219-231`) reads the parent `sessionId` `[FromQuery]` (`:221`) and + dispatches `RemoveSessionQuestionAnswerCommand(sessionId, id)` (`:225`). - **Why it's built this way**: answers are personal feedback, so the read surface cannot be public; scoping by specification keeps the authorization rule in one place and lets the query service compose it into the database query rather than filtering in memory. Mirroring the event-side controller line for line is - deliberate: two rules (BR-8 and BR-9) with the same shape get the same implementation, export gate - included. -- **Where it's used**: the Conference service host; consumed by the attendee feedback UI and by organizer - reporting screens. + deliberate: two rules (BR-8 and BR-9) with the same shape get the same implementation, export gate and + replay contract included. +- **Where it's used**: the Conference service host; consumed by the attendee feedback UI under + `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Feedback/` and by organizer reporting + screens. --- @@ -1481,7 +1645,9 @@ A browser request to `GET /Sessions` enters the Gateway, is forwarded as HTTP/2 [`ICurrentUserService`](group-08-auth.md#icurrentuserservice), `IOutputCacheStore`, the [`SessionDTO`](group-17-conference-domain.md#sessiondto), [`UpdateSessionResult`](group-18-conference-application.md#updatesessionresult), - [`SessionUpdateRequest`](group-18-conference-application.md#sessionupdaterequest), + [`SessionUpdateRequest`](group-18-conference-application.md#sessionupdaterequest), the + [`SpecificationExtensions`](group-03-querying-specifications.md#specificationextensions) `And` composer + yielding an [`AndSpecification`](group-03-querying-specifications.md#andspecificationtentity-tidentifiertype), the [`EventDTO`](group-17-conference-domain.md#eventdto), and the [`IdempotentAttribute`](group-12-api-hosting-mapping.md#idempotentattribute). @@ -1497,27 +1663,29 @@ A browser request to `GET /Sessions` enters the Gateway, is forwarded as HTTP/2 [`Specification`](group-03-querying-specifications.md#specificationtentity-tidentifiertype) the query service can apply; privileged readers get `null`. `[Rubric §12, Performance & Scalability]`: reads are `[OutputCache(PolicyName = "SessionsCache")]` - (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:238`) and the default sort is the + (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:243`) and the default sort is the `"StartsAt,RoomId"` string (`:123`), which sorts the schedule chronologically then by room. The comment above it (`:121-122`) records the mechanism: the "ascending" suffix `QueryFieldService.ApplySorting` appends binds only to the last column in Dynamic LINQ, and the leading column defaults to ascending, so one string sorts both columns ascending. - **Walkthrough** - `BuildPagedSessionSpecificationAsync` (`SessionsController.cs:96-119`) is the paged read's specification - builder: it takes the public filter, then intercepts and removes the virtual `SpeakerId` filter key - (`Session` has no such column, `:102-107`), resolves it through `GetSessionsBySpeakerFilterQuery`, and - **ANDs** the two with `AndSpecification` (`:116-118`). As in + builder: it takes the public filter (`:100`), then intercepts and removes the virtual `SpeakerId` filter + key (`Session` has no such column, `:102-107`), resolves it through `GetSessionsBySpeakerFilterQuery` + (`:109-111`), and **ANDs** the two with `publicSpecification.And(...)` (`:116-118`). As in [`SpeakersController`](#speakerscontroller), substitution would leak non-accepted sessions, and an - unparseable value simply drops the scope. - - `GetAllAsync` (`:128-149`) applies the public specification and the default sort; the paged overload - (`:154-192`) defaults the sort when none was supplied (`:167-171`), calls the builder above (`:177`), - and writes `X-Pagination` (`:190`). `GetAllForLookupAsync` (`:203-220`) delegates to the base for - privileged readers and otherwise forwards `specification.Criteria` as the lookup `where`. - `GetByIdAsync` (`:225-243`) threads the same specification, so a hidden session is a 404. + unparseable value or a failed handler result simply drops the scope (`:102-107, 113-114`). + - `GetAllAsync` (`:128-149`) applies the public specification (`:138`) and the default sort (`:140-141`); + the paged overload (`:154-192`) defaults the sort when none was supplied (`:167-171`), calls the builder + above (`:177`), and writes `X-Pagination` (`:190`). `GetAllForLookupAsync` (`:203-220`) delegates to the + base for privileged readers (`:208-209`) and otherwise forwards `specification.Criteria` as the lookup + `where` (`:211-215`). `GetByIdAsync` (`:225-243`) threads the same specification (`:237`), so a hidden + session is a 404. - `ExportAsync` (`:252-266`) is the same bypass gate the other row-scoped controllers use: `Forbid()` for - a non-privileged caller (`:260-263`), otherwise the base. Its doc comment spells out what an unscoped - CSV would hand over: the whole catalog, "declined and draft-event sessions included" (`:245-250`). - - `ExportCalendarAsync` (`:275-283`) streams a single session `.ics` via `File(...)`. + a non-privileged caller (`:260-263`), otherwise the base (`:265`). Its doc comment spells out what an + unscoped CSV would hand over: the whole catalog, "declined and draft-event sessions included" + (`:245-250`). + - `ExportCalendarAsync` (`:275-283`) streams a single session `.ics` via `File(...)` (`:282`). - `CreateAsync` (`:292-320`) is an override marked `[Idempotent]` (`:291`) that calls `CreateHandler.HandleAsync` directly (`:296`) rather than `base.CreateAsync`, because it needs the `Result` in order to run the BR-86 check: when the request set start or end times, it re-reads the @@ -1525,60 +1693,65 @@ A browser request to `GET /Sessions` enters the Gateway, is forwarded as HTTP/2 range (`:303-316`). Note the parent re-read pattern-matches the widened query result with `eventResult.Value is EventDTO evt` (`:310`) rather than a dynamic member access, because `IEntityQueryService` widens its return to `object` for field projection, so the controller narrows it - back with a type pattern. + back with a type pattern (the reason is written into the comment at `:305-306`). - `UpdateAsync` (`:324-344`) surfaces the same BR-86 warning from `result.Value!.HasDateRangeWarning` (`:337`) and returns `Ok(result.Value.Session)`. `DeleteAsync` (`:348-355`) calls the base and evicts. - Every mutation ends at `EvictSessionsCacheAsync` (`:357-361`), which clears both the `conference:sessions` tag and the broad `conference` tag, the latter because cross-entity projections - (the speaker feedback and bookmark endpoints) are cached under the broad tag alone. + (the speaker bookmark-count endpoints) are cached under `conference:sessions` and `conference` rather + than under a speakers tag. - **Why it's built this way**: pushing the cross-source published-event check into a query handler keeps the controller free of persistence knowledge (`[Rubric §3, Clean Architecture]`), and the warning headers let the API accept a slightly-off schedule while telling the client, rather than rejecting the write outright. Calling the create handler directly instead of the base is the deliberate cost of needing the typed result at the boundary. -- **Where it's used**: the Conference service host; the schedule UI, the "add to calendar" affordance, the - speaker dashboard's `SpeakerId`-filtered list, and the k6 load test's read endpoints (`/Sessions/paged`) - all hit it. +- **Where it's used**: the Conference service host behind the Gateway route `/Sessions/{**catch-all}` + (`MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:44`); the public schedule UI + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSessionList.razor`), the + "add to calendar" affordance, the speaker dashboard's `SpeakerId`-filtered list, and the k6 load test's + read endpoints (`/Sessions/paged`) all hit it. --- ### SessionSpeakersController -> MMCA.ADC.Conference.API · `MMCA.ADC.Conference.API.Controllers` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:47` · Level 10 · class (sealed) +> MMCA.ADC.Conference.API · `MMCA.ADC.Conference.API.Controllers` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:48` · Level 10 · class (sealed) - **What it is**: the REST controller for the link between a [`Session`](group-17-conference-domain.md#session) and its [`Speaker`](group-17-conference-domain.md#speaker)s (`/SessionSpeakers`). A junction controller like [`EventSpeakersController`](#eventspeakerscontroller), with one distinguishing detail in its eviction set. - **Depends on**: [`EntityControllerBase`](group-12-api-hosting-mapping.md#entitycontrollerbasetentity-tentitydto-tidentifiertype) - (`SessionSpeakersController.cs:55`), + (`SessionSpeakersController.cs:56`), [`IEntityQueryService`](group-03-querying-specifications.md#ientityqueryservicetentity-tentitydto-tidentifiertype), the [`AddSessionSpeakerCommand`](group-18-conference-application.md#addsessionspeakercommand) / [`RemoveSessionSpeakerCommand`](group-18-conference-application.md#removesessionspeakercommand) - [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult)s (`:49-50`), an + [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult)s (`:50-51`), an [`IQueryHandler`](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) for [`GetPublicSessionSpeakerFilterQuery`](group-18-conference-application.md#getpublicsessionspeakerfilterquery) - (`:51`), [`ICurrentUserService`](group-08-auth.md#icurrentuserservice), `IOutputCacheStore` (`:53`), the - [`SessionSpeakerDTO`](group-17-conference-domain.md#sessionspeakerdto), and the - [`AddSessionSpeakerRequest`](#addsessionspeakerrequest) record (`:28-35`). + (`:52`), [`ICurrentUserService`](group-08-auth.md#icurrentuserservice), `IOutputCacheStore` (`:54`), the + [`SessionSpeakerDTO`](group-17-conference-domain.md#sessionspeakerdto), the + [`IdempotentAttribute`](group-12-api-hosting-mapping.md#idempotentattribute), and the + [`AddSessionSpeakerRequest`](#addsessionspeakerrequest) record (`:29-36`). - **Concept introduced**: none new; the junction controller pattern is taught at [`EventSpeakersController`](#eventspeakerscontroller), and the BR-49 parent-visibility filter - (`BuildPublicSpecificationAsync`, `SessionSpeakersController.cs:66-75`) is the same one + (`BuildPublicSpecificationAsync`, `SessionSpeakersController.cs:67-76`) is the same one [`SessionCategoryItemsController`](#sessioncategoryitemscontroller) uses, export gate included - (`:192-207`). The difference is the eviction set: `[Rubric §12, Performance & Scalability]`, - `EvictSessionsCacheAsync` (`:253-257`) clears `conference:sessions` and the broad `conference` tag, and + (`:194-208`). The difference is the eviction set: `[Rubric §12, Performance & Scalability]`, + `EvictSessionsCacheAsync` (`:261-265`) clears `conference:sessions` and the broad `conference` tag, and deliberately does **not** clear `conference:speakers` the way the other two-parent junction controllers - do. The comment at `:224-225` gives the reason: what a speaker assignment changes is the cached session + do. The comment at `:232-233` gives the reason: what a speaker assignment changes is the cached session detail and list reads (which the speaker dashboard relies on), so the sessions tag is the one that must go. - **Walkthrough**: four `[AllowAnonymous]` + `[OutputCache(PolicyName = "SessionsCache")]` reads - (`SessionSpeakersController.cs:77-184`) thread the public specification (`:90, 120, 178`), append - `X-Pagination` (`:133`), and forward `specification.Criteria` as the lookup `where` for non-privileged - callers (`:148-156`). `ExportAsync` (`:193-207`) returns `Forbid()` for a non-privileged caller (`:203`). - `CreateAsync` (`:211-231`) dispatches `AddSessionSpeakerCommand(request.SessionId, null, - request.SpeakerId)` (`:216`), evicts on success only (`:219-226`), and returns - `CreatedAtRoute("GetSessionSpeakerById", ...)`; `DeleteAsync` (`:235-251`) reads the parent `sessionId` - `[FromQuery]` (`:237`), dispatches `RemoveSessionSpeakerCommand(sessionId, id)` (`:241`), evicts, and - returns `NoContent()`. The class gate is `[HasPermission(ConferencePermissions.SessionsManage)]` (`:46`). + (`SessionSpeakersController.cs:78-185`) thread the public specification (`:91, 121, 179`), append + `X-Pagination` (`:134`), and forward `specification.Criteria` as the lookup `where` for non-privileged + callers (`:149-157`). `ExportAsync` (`:194-208`) returns `Forbid()` for a non-privileged caller (`:204`). + `CreateAsync` (`:219-239`) is `[Idempotent]` (`:218`), dispatches + `AddSessionSpeakerCommand(request.SessionId, null, request.SpeakerId)` (`:224`), evicts on success only + (`:227-234`), and returns `CreatedAtRoute("GetSessionSpeakerById", ...)`; `DeleteAsync` (`:243-259`) reads + the parent `sessionId` `[FromQuery]` (`:245`), dispatches `RemoveSessionSpeakerCommand(sessionId, id)` + (`:249`), evicts (`:257`), and returns `NoContent()`. The class gate is + `[HasPermission(ConferencePermissions.SessionsManage)]` (`:47`). - **Why it's built this way**: the eviction crosses aggregates deliberately, because the session's cached representation includes its speakers, so mutating the link must invalidate the session cache to keep reads correct. Everything else is the shared junction shape, which is the point: an engineer who has read @@ -1621,16 +1794,17 @@ A browser request to `GET /Sessions` enters the Gateway, is forwarded as HTTP/2 pipeline unchanged and `BuildPublicSponsorSpecificationAsync` (`:60-70`) only adds the published-event rule on top of it (`:52-58, 94-99`). The published rule and the caller's filter are composed by the query service rather than substituted, so scoping to an unpublished event returns an empty page to a - non-privileged caller instead of leaking the roster. That is why this controller, alone among the - scope-carrying aggregate roots in this unit, has no filter-interception block at all. + non-privileged caller instead of leaking the roster. That is why this controller, like its + [`ActivitiesController`](#activitiescontroller) twin and unlike the speaker and session roots, has no + filter-interception block at all. `[Rubric §11, Security]`: the class carries a bare `[Authorize]` (`:36`) and each mutation re-asserts `[HasPermission(ConferencePermissions.SponsorsManage)]` (`:193, 212, 224, 243`), the capability declared at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Authorization/ConferencePermissions.cs:33` - and included in the `ContentManagement` curation subset (`ConferencePermissions.cs:53-59`), so a content + and included in the `ContentManagement` curation subset (`ConferencePermissions.cs:57-64`), so a content editor can manage the sponsor roster without holding event, room, or question rights. `[Rubric §12, Performance & Scalability]`: reads run under the `SponsorsCache` policy (5-minute TTL, tags `conference` - and `conference:sponsors`, `MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:243`) and + and `conference:sponsors`, `MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:248`) and every mutation evicts both tags. - **Walkthrough** - `IsPrivileged` (`SponsorsController.cs:50`) is the shared @@ -1668,7 +1842,7 @@ A browser request to `GET /Sessions` enters the Gateway, is forwarded as HTTP/2 the simplest of the scope-carrying aggregate-root controllers. - **Where it's used**: hosted by `MMCA.ADC.Conference.Service` and reached through the YARP Gateway, which forwards `/Sponsors/{**catch-all}` to the Conference service - (`MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:144`). Clients are the public sponsor page + (`MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:96`). Clients are the public sponsor page (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSponsorList.razor`) and the organizer sponsor list, create, and detail pages under `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sponsor/`. diff --git a/docs-src/onboarding/group-21-conference-ui.md b/docs-src/onboarding/group-21-conference-ui.md index 70a2773..e5deb6a 100644 --- a/docs-src/onboarding/group-21-conference-ui.md +++ b/docs-src/onboarding/group-21-conference-ui.md @@ -1,150 +1,188 @@ # 21. ADC Conference - UI -**What this chapter covers.** This is the **consumer half** of the "write-once UI, render everywhere" story ([primer §2](00-primer.md#2-architectural-styles-this-codebase-commits-to)): the Blazor pages and per-page HTTP services that turn the Conference REST surface ([G20](group-20-conference-api-grpc.md)) into the screens an organizer, a speaker, a sponsor, or an anonymous attendee actually touches. Everything here lives in the per-module Razor Class Library `MMCA.ADC.Conference.UI` (under `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/`, the path all `File:line` citations below are relative to), which, like every consumer UI, assembles the reusable primitives taught in [G15 (Common UI Framework)](group-15-common-ui-framework.md) into concrete pages. There is almost no new *infrastructure* here: the value is in seeing how a real, eleven-area feature surface (events, sessions, speakers, categories, questions, rooms, sponsors, feedback, public browsing, session selection, and the conference landing page) is *composed* from the framework's list-page base, typed HTTP service base, device-capability abstractions, and module system. The headline lens is `[Rubric §18, UI Architecture & Component Design]`, which assesses component reuse, separation of presentation from data access, and a coherent composition model. Because the same Razor components compile into the Blazor Server, WebAssembly, and .NET MAUI hybrid heads, this one library renders the conference across web, Android, iOS, macOS, and Windows with no per-platform reimplementation. `[Rubric §22, Responsive & Cross-Browser/Device]`. +**What this chapter covers.** This is the **consumer half** of the "write-once UI, render everywhere" story ([primer §2](00-primer.md#2-architectural-styles-this-codebase-commits-to)): the Blazor pages and per-page HTTP services that turn the Conference REST surface ([G20](group-20-conference-api-grpc.md)) into the screens an organizer, a speaker, a sponsor, or an anonymous attendee actually touches. Everything here lives in the per-module Razor Class Library `MMCA.ADC.Conference.UI` (under `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/`, the path all `File:line` citations below are relative to), which, like every consumer UI, assembles the reusable primitives taught in [G15 (Common UI Framework)](group-15-common-ui-framework.md) into concrete pages. There is almost no new *infrastructure* here: the value is in seeing how a real, twelve-area feature surface (events, sessions, speakers, conference categories and their items, questions, rooms, sponsors, activities, feedback moderation, public browsing, session selection, and the conference landing page) is *composed* from the framework's list-page base, typed HTTP service base, device-capability abstractions, and module system. The headline lens is `[Rubric §18, UI Architecture & Component Design]`, which assesses component reuse, separation of presentation from data access, and a coherent composition model. Because the same Razor components compile into the Blazor Server, WebAssembly, and .NET MAUI hybrid heads, this one library renders the conference across web, Android, iOS, macOS, and Windows with no per-platform reimplementation. `[Rubric §22, Responsive & Cross-Browser/Device]`. ## The layering inside the UI: a page never touches HttpClient -Each page is a `.razor` + `.razor.cs` code-behind pair that depends only on a *UI service interface*, never on `HttpClient` and never on the API's internals. The eight CRUD-shaped entities (events, sessions, speakers, conference categories, category items, questions, rooms, sponsors) each get a service deriving from Common's [`EntityServiceBase`](group-15-common-ui-framework.md#entityservicebasetentitydto-tidentifiertype) and exposing the [`IEntityService`](group-15-common-ui-framework.md#ientityservicetentitydto-tidentifiertype) contract: [`EventService`](#eventservice), [`SessionService`](#sessionservice), [`SpeakerService`](#speakerservice), [`ConferenceCategoryService`](#conferencecategoryservice), [`CategoryItemService`](#categoryitemservice), [`QuestionService`](#questionservice), [`RoomService`](#roomservice), and [`SponsorService`](#sponsorservice). They inherit `GetAllAsync`/`GetPagedAsync`/`GetByIdAsync`/`AddAsync`/`UpdateAsync`/`DeleteAsync` and only *add* the handful of bespoke verbs the conference needs. Most add nothing at all: `SponsorService` is a body-less class whose entire job is to bind the `sponsors` endpoint to `SponsorDTO` and `SponsorIdentifierType` (`MMCA.ADC.Conference.UI/Services/SponsorService.cs:10` to `:14`), and [`ISponsorUIService`](#isponsoruiservice) is an equally empty extension of the generic contract (`MMCA.ADC.Conference.UI/Services/ISponsorUIService.cs:9`). `EventService` is the counter-example that shows where extension goes: it layers `PublishAsync`, `UnpublishAsync`, and `RefreshFromSessionizeAsync` onto the inherited CRUD (`MMCA.ADC.Conference.UI/Services/EventService.cs:17`, `:32`, `:47`), each routed through the inherited `SendRequestAsync` helper so a back-end `Result.Failure` is unwrapped into a typed, displayable error via [`ServiceExceptionHelper`](group-15-common-ui-framework.md#serviceexceptionhelper) before `EnsureSuccessStatusCode` can throw something contextless. `[Rubric §3, Clean Architecture]` and `[Rubric §9, API & Contract Design]`: the page binds to a DTO contract ([`EventDTO`](group-17-conference-domain.md#eventdto), [`SessionDTO`](group-17-conference-domain.md#sessiondto), [`SpeakerDTO`](group-17-conference-domain.md#speakerdto), [`SponsorDTO`](group-17-conference-domain.md#sponsordto)) and an interface, and the wire envelope is the uniform [`PagedCollectionResult`](group-01-result-error-handling.md#pagedcollectionresultt) / [`CollectionResult`](group-01-result-error-handling.md#collectionresultt) the API returns for every entity. Each entity also gets its own per-feature interface, [`IEventUIService`](#ieventuiservice), [`ISessionUIService`](#isessionuiservice), [`ISpeakerUIService`](#ispeakeruiservice), [`IConferenceCategoryUIService`](#iconferencecategoryuiservice), [`ICategoryItemUIService`](#icategoryitemuiservice), [`IQuestionUIService`](#iquestionuiservice), [`IRoomUIService`](#iroomuiservice), and `ISponsorUIService`, which extends the generic contract and declares only that entity's extra verbs. +Each page is a `.razor` + `.razor.cs` code-behind pair that depends only on a *UI service interface*, never on `HttpClient` and never on the API's internals. The nine CRUD-shaped entities (events, sessions, speakers, conference categories, category items, questions, rooms, sponsors, activities) each get a service deriving from Common's [`EntityServiceBase`](group-15-common-ui-framework.md#entityservicebasetentitydto-tidentifiertype) and exposing the [`IEntityService`](group-15-common-ui-framework.md#ientityservicetentitydto-tidentifiertype) contract: [`EventService`](#eventservice), [`SessionService`](#sessionservice), [`SpeakerService`](#speakerservice), [`ConferenceCategoryService`](#conferencecategoryservice), [`CategoryItemService`](#categoryitemservice), [`QuestionService`](#questionservice), [`RoomService`](#roomservice), [`SponsorService`](#sponsorservice), and [`ActivityService`](#activityservice) (`MMCA.ADC.Conference.UI/Services/EventService.cs:14`, `Services/SessionService.cs:11`, `Services/SpeakerService.cs:13`, `Services/ConferenceCategoryService.cs:11`, `Services/CategoryItemService.cs:11`, `Services/QuestionService.cs:11`, `Services/RoomService.cs:14`, `Services/SponsorService.cs:11`, `Services/ActivityService.cs:11`). They inherit `GetAllAsync`/`GetPagedAsync`/`GetByIdAsync`/`AddAsync`/`UpdateAsync`/`DeleteAsync` and only *add* the handful of bespoke verbs the conference needs. Most add nothing at all: `ActivityService` is a body-less class whose entire job is to bind the `activities` endpoint to `ActivityDTO` and `ActivityIdentifierType` (`MMCA.ADC.Conference.UI/Services/ActivityService.cs:10` to `:14`), and [`IActivityUIService`](#iactivityuiservice) is an equally empty extension of the generic contract (`MMCA.ADC.Conference.UI/Services/IActivityUIService.cs:9`); `SponsorService` and [`ISponsorUIService`](#isponsoruiservice) have exactly the same shape (`Services/SponsorService.cs:10`, `Services/ISponsorUIService.cs:9`). + +Three services show where extension goes. `EventService` layers `PublishAsync`, `UnpublishAsync`, and `RefreshFromSessionizeAsync` onto the inherited CRUD (`MMCA.ADC.Conference.UI/Services/EventService.cs:17`, `:32`, `:47`), each routed through the inherited `SendRequestAsync` helper so a back-end `Result.Failure` is unwrapped into a typed, displayable error via [`ServiceExceptionHelper`](group-15-common-ui-framework.md#serviceexceptionhelper) before `EnsureSuccessStatusCode` can throw something contextless; [`EventDetail`](#eventdetail) is the page that calls all three (`MMCA.ADC.Conference.UI/Pages/Event/EventDetail.razor.cs:221`, `:249`, `:304`). `RoomService` *overrides* `AddAsync` to reshape the POST body, because the API's `AddRoomRequest` contract names the key `RoomId` while the DTO calls it `Id` (`MMCA.ADC.Conference.UI/Services/RoomService.cs:17` to `:33`), and adds a two-argument `DeleteAsync` that passes the owning event on the query string (`Services/RoomService.cs:35`). `SpeakerService` adds `LinkUserAsync`/`UnlinkUserAsync` for binding a speaker record to an identity account (`MMCA.ADC.Conference.UI/Services/SpeakerService.cs:16`, `:31`). `[Rubric §3, Clean Architecture]` and `[Rubric §9, API & Contract Design]`: the page binds to a DTO contract ([`EventDTO`](group-17-conference-domain.md#eventdto), [`SessionDTO`](group-17-conference-domain.md#sessiondto), [`SpeakerDTO`](group-17-conference-domain.md#speakerdto), [`SponsorDTO`](group-17-conference-domain.md#sponsordto), [`ActivityDTO`](group-17-conference-domain.md#activitydto)) and an interface, and the wire envelope is the uniform [`PagedCollectionResult`](group-01-result-error-handling.md#pagedcollectionresultt) / [`CollectionResult`](group-01-result-error-handling.md#collectionresultt) the API returns for every entity. Each entity also gets its own per-feature interface, [`IEventUIService`](#ieventuiservice), [`ISessionUIService`](#isessionuiservice), [`ISpeakerUIService`](#ispeakeruiservice), [`IConferenceCategoryUIService`](#iconferencecategoryuiservice), [`ICategoryItemUIService`](#icategoryitemuiservice), [`IQuestionUIService`](#iquestionuiservice), [`IRoomUIService`](#iroomuiservice), `ISponsorUIService`, and `IActivityUIService`, which extends the generic contract and declares only that entity's extra verbs. ## The list pages: derive from DataGridListPageBase, get everything for free -Ten list screens, the organizer [`EventList`](#eventlist), [`SessionList`](#sessionlist), [`SpeakerList`](#speakerlist), [`ConferenceCategoryList`](#conferencecategorylist), [`QuestionList`](#questionlist), [`RoomList`](#roomlist), [`SponsorList`](#sponsorlist), and the public [`PublicEventList`](#publiceventlist), [`PublicSessionList`](#publicsessionlist), [`PublicSpeakerList`](#publicspeakerlist), inherit [`DataGridListPageBase`](group-15-common-ui-framework.md#datagridlistpagebasetdto). That base supplies server-side paging against `MudDataGrid`, cancellation lifecycle, loading and load-failed state, filter/sort extraction from MudBlazor's `GridState`, `ISnackbar` error surfacing, saved page/rows-per-page/scroll restoration, and viewport-driven mobile rendering that swaps the grid for a [`MobileInfiniteScrollList`](group-15-common-ui-framework.md#mobileinfinitescrolllisttitem). A concrete page therefore reduces to overriding `Title`, `GridRef`, `SaveFilters`/`RestoreFilters`, and a `LoadServerData` delegate that calls its service's `GetPagedAsync` and folds in page-specific filters. `MMCA.ADC.Conference.UI/Pages/Event/EventList.razor.cs:48` is the roughly ten-line canonical example, with the mobile path reusing the same service call through `FetchMobilePage` (`EventList.razor.cs:60`) and delete-with-confirmation delegated to the shared [`ListPageActions`](group-24-identity-module.md#listpageactions) helper (`EventList.razor.cs:72`). `[Rubric §23, Front-End Performance & Rendering]` (avoiding redundant fetches and round-trips) and `[Rubric §19, State Management & Data Flow]` (paging, sort, and filter state persisted across navigation). This is the "compose, do not repeat" thesis of [G15](group-15-common-ui-framework.md) made concrete ten times over. The one public list page that does *not* use the base is [`PublicSponsorList`](#publicsponsorlist): a sponsor roster is bounded (its `MaxSponsors` cap is 200, `MMCA.ADC.Conference.UI/Pages/Public/PublicSponsorList.razor.cs:21`) and is rendered as tier-grouped logo cards rather than a grid, so it fetches one page and groups it in memory instead (`PublicSponsorList.razor.cs:68` to `:86`). +Eleven list screens, the organizer [`EventList`](#eventlist), [`SessionList`](#sessionlist), [`SpeakerList`](#speakerlist), [`ConferenceCategoryList`](#conferencecategorylist), [`QuestionList`](#questionlist), [`RoomList`](#roomlist), [`SponsorList`](#sponsorlist), [`ActivityList`](#activitylist), and the public [`PublicEventList`](#publiceventlist), [`PublicSessionList`](#publicsessionlist), [`PublicSpeakerList`](#publicspeakerlist), inherit [`DataGridListPageBase`](group-15-common-ui-framework.md#datagridlistpagebasetdto) (`MMCA.ADC.Conference.UI/Pages/Event/EventList.razor.cs:16`, `Pages/Session/SessionList.razor.cs:18`, `Pages/Speaker/SpeakerList.razor.cs:19`, `Pages/ConferenceCategory/ConferenceCategoryList.razor.cs:11`, `Pages/Question/QuestionList.razor.cs:11`, `Pages/Room/RoomList.razor.cs:12`, `Pages/Sponsor/SponsorList.razor.cs:19`, `Pages/Activity/ActivityList.razor.cs:19`, `Pages/Public/PublicEventList.razor.cs:30`, `Pages/Public/PublicSessionList.razor.cs:25`, `Pages/Public/PublicSpeakerList.razor.cs:35`). That base supplies server-side paging against `MudDataGrid`, cancellation lifecycle, loading and load-failed state, filter and sort extraction from MudBlazor's `GridState`, `ISnackbar` error surfacing, saved page/rows-per-page/scroll restoration, and viewport-driven mobile rendering that swaps the grid for a [`MobileInfiniteScrollList`](group-15-common-ui-framework.md#mobileinfinitescrolllisttitem). A concrete page therefore reduces to overriding `Title`, `GridRef`, `SaveFilters`/`RestoreFilters`, and a `LoadServerData` delegate that calls its service's `GetPagedAsync` and folds in page-specific filters. `MMCA.ADC.Conference.UI/Pages/Event/EventList.razor.cs:48` is the roughly ten-line canonical example, with the mobile path reusing the same service call through `FetchMobilePage` (`Pages/Event/EventList.razor.cs:60`) and delete-with-confirmation delegated to the shared [`ListPageActions`](group-24-identity-module.md#listpageactions) helper (`Pages/Event/EventList.razor.cs:72`). `ActivityList` shows the next increment of the same recipe: it resolves a default event filter before the grid's first fetch by starting the lookup in `OnInitializedAsync` and awaiting that task inside `LoadServerData` (`Pages/Activity/ActivityList.razor.cs:77`, awaited at `:136`), persists the choice with an explicit `"all"` sentinel so an intentional clear is distinguishable from no saved state (`Pages/Activity/ActivityList.razor.cs:50`), and drops a restored id that no longer exists back to the computed default (`Pages/Activity/ActivityList.razor.cs:98` to `:112`). `[Rubric §23, Front-End Performance & Rendering]` (avoiding redundant fetches and round-trips) and `[Rubric §19, State Management & Data Flow]` (paging, sort, and filter state persisted across navigation). This is the "compose, do not repeat" thesis of [G15](group-15-common-ui-framework.md) made concrete eleven times over. Each organizer entity pairs its list with a create page and a detail page in the same shape, a MudBlazor form over the entity's DTO with breadcrumbs, snackbar feedback, and an owned `CancellationTokenSource` disposed with the component: [`EventCreate`](#eventcreate) / [`EventDetail`](#eventdetail) (`Pages/Event/EventCreate.razor.cs:13`, `Pages/Event/EventDetail.razor.cs:15`), [`SessionCreate`](#sessioncreate) / [`SessionDetail`](#sessiondetail) (`Pages/Session/SessionCreate.razor.cs:15`, `Pages/Session/SessionDetail.razor.cs:17`), [`SpeakerCreate`](#speakercreate) / [`SpeakerDetail`](#speakerdetail) (`Pages/Speaker/SpeakerCreate.razor.cs:13`, `Pages/Speaker/SpeakerDetail.razor.cs:19`), [`ConferenceCategoryCreate`](#conferencecategorycreate) / [`ConferenceCategoryDetail`](#conferencecategorydetail) (`Pages/ConferenceCategory/ConferenceCategoryCreate.razor.cs:9`, `Pages/ConferenceCategory/ConferenceCategoryDetail.razor.cs:11`), [`QuestionCreate`](#questioncreate) / [`QuestionDetail`](#questiondetail) (`Pages/Question/QuestionCreate.razor.cs:9`, `Pages/Question/QuestionDetail.razor.cs:11`), [`RoomCreate`](#roomcreate) / [`RoomDetail`](#roomdetail) (`Pages/Room/RoomCreate.razor.cs:9`, `Pages/Room/RoomDetail.razor.cs:12`), plus the sponsor and activity pairs covered below. + +Two public lists deliberately opt out of the grid. [`PublicSponsorList`](#publicsponsorlist) and [`PublicActivityList`](#publicactivitylist) are plain `ComponentBase` pages (`MMCA.ADC.Conference.UI/Pages/Public/PublicSponsorList.razor.cs:18`, `Pages/Public/PublicActivityList.razor.cs:19`), because a sponsor roster and an activity programme are bounded (both cap the fetch at 200 rows, `Pages/Public/PublicSponsorList.razor.cs:21`, `Pages/Public/PublicActivityList.razor.cs:22`) and render as tier-grouped logo cards and a chronological programme rather than a sortable table, so each fetches one page and orders it in memory (`Pages/Public/PublicSponsorList.razor.cs:68` to `:86`, `Pages/Public/PublicActivityList.razor.cs:68` to `:85`). A third page splits the difference: [`PublicSpeakerList`](#publicspeakerlist) keeps the base class's page-based mobile fetch path but *appends* each page to an accumulating list and hangs an [`InfiniteScrollSentinel`](#infinitescrollsentinel) below its card grid, twelve cards per chunk so a full chunk fills whole rows (`Pages/Public/PublicSpeakerList.razor.cs:38`, `:40`, next page at `:213`, sentinel rendered at `Pages/Public/PublicSpeakerList.razor:144`). The sentinel itself is the module's one piece of new UI infrastructure: it drives the same shared `_content/MMCA.Common.UI/infinite-scroll.js` IntersectionObserver module `MobileInfiniteScrollList` uses, but owns only the observer, so a page keeps its own card markup, empty state, and error state (`MMCA.ADC.Conference.UI/Components/InfiniteScrollSentinel.razor.cs:21`, observer attach at `:59`, JS-invoked callback at `:46`). Owning the observer in a child component is also what makes the lifecycle correct: a page deriving from `DataGridListPageBase` cannot hook async disposal, while the renderer disposes this component the moment the host stops rendering it, which is exactly when the last page has loaded (`Components/InfiniteScrollSentinel.razor.cs:76` to `:111`). ## Container and presentational split -The behaviour-heavy screens do not keep everything in one code-behind: the page stays the *container* (data fetching, filter and paging state, service calls) and hands rendering to *presentational* children that receive parameters and raise callbacks. `PublicSessionList` is the fullest example, splitting into [`PublicSessionListFilterBar`](#publicsessionlistfilterbar) (organizer event picker or locked chip, debounced search, All Sessions / My Schedule toggle, share action, `MMCA.ADC.Conference.UI/Pages/Public/PublicSessionListFilterBar.razor.cs:15`) and [`PublicSessionListView`](#publicsessionlistview) (the mobile card list and the desktop grid plus the inline bookmark stars, `MMCA.ADC.Conference.UI/Pages/Public/PublicSessionListView.razor.cs:21`). The view exposes `Grid` and `ReloadAsync` back to the page (`PublicSessionListView.razor.cs:85`, `:88`) so the base class's grid plumbing keeps working unchanged, and it patches the container-owned bookmark dictionary in place when a star is toggled (`PublicSessionListView.razor.cs:137`, `:152`). The same split shows up on the speaker detail page via [`SpeakerCategoryItemsPanel`](#speakercategoryitemspanel), which raises `Changed` so the page reloads the speaker (`MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerCategoryItemsPanel.razor.cs:31`, invoked at `:73` and `:87`), and on the selection dashboard via [`SessionSelectionSpeakerOverlap`](#sessionselectionspeakeroverlap) and [`SessionSelectionAiScores`](#sessionselectionaiscores), each taking the five filter values as plain parameters (`SessionSelectionSpeakerOverlap.razor.cs:15` to `:19`, `SessionSelectionAiScores.razor.cs:17` to `:21`). The pure display and filter-matching rules those children share (locality-tier detection, status and score chip colors, score-tier and status predicates) live in the static [`SessionSelectionDisplay`](#sessionselectiondisplay) (`MMCA.ADC.Conference.UI/Pages/SessionSelection/SessionSelectionDisplay.cs:11`, helpers at `:13` to `:53`), testable without rendering anything. `[Rubric §18, UI Architecture]` and `[Rubric §28, Front-End Testing]`. +The behaviour-heavy screens do not keep everything in one code-behind: the page stays the *container* (data fetching, filter and paging state, service calls) and hands rendering to *presentational* children that receive parameters and raise callbacks. `PublicSessionList` is the fullest example, splitting into [`PublicSessionListFilterBar`](#publicsessionlistfilterbar) (organizer event picker or locked chip, debounced title search, room picker, All Sessions / My Schedule toggle, share action, `MMCA.ADC.Conference.UI/Pages/Public/PublicSessionListFilterBar.razor.cs:15`, parameters at `:25` to `:58`) and [`PublicSessionListView`](#publicsessionlistview) (the mobile card list and the desktop grid plus the inline bookmark stars, `Pages/Public/PublicSessionListView.razor.cs:23`). The view exposes `Grid` and `ReloadAsync` back to the page (`Pages/Public/PublicSessionListView.razor.cs:87`, `:90`) so the base class's grid plumbing keeps working unchanged, and it patches the container-owned bookmark dictionary in place when a star is toggled (`Pages/Public/PublicSessionListView.razor.cs:139`, `:154`). The same split shows up on the speaker detail page via [`SpeakerCategoryItemsPanel`](#speakercategoryitemspanel), which raises `Changed` so the page reloads the speaker (`MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerCategoryItemsPanel.razor.cs:31`, invoked at `:73` and `:87`), and on the selection dashboard via [`SessionSelectionSpeakerOverlap`](#sessionselectionspeakeroverlap) and [`SessionSelectionAiScores`](#sessionselectionaiscores), each taking the five filter values as plain parameters (`Pages/SessionSelection/SessionSelectionSpeakerOverlap.razor.cs:15` to `:19`, `Pages/SessionSelection/SessionSelectionAiScores.razor.cs:17` to `:21`). The pure display and filter-matching rules those children share (locality-tier detection, status and score chip colors, score-tier and status predicates) live in the static [`SessionSelectionDisplay`](#sessionselectiondisplay) (`MMCA.ADC.Conference.UI/Pages/SessionSelection/SessionSelectionDisplay.cs:11`, helpers at `:13` to `:56`), testable without rendering anything. `[Rubric §18, UI Architecture]` and `[Rubric §28, Front-End Testing]`. ## Child-and-join entities: a thin POST/DELETE base -Sessions, speakers, and events own *join* relationships (a speaker added to a session, a category item to a speaker) that the generic CRUD base cannot model, because the write carries a *parent* id. These get four near-identical services ([`EventSpeakerService`](#eventspeakerservice), [`SessionSpeakerService`](#sessionspeakerservice), [`SessionCategoryItemService`](#sessioncategoryitemservice), [`SpeakerCategoryItemService`](#speakercategoryitemservice)) over the shared, purpose-built [`ChildEntityServiceBase`](group-15-common-ui-framework.md#childentityservicebase), which was **hoisted out of this module into `MMCA.Common.UI`** so every consumer module can reuse it (the note is left in place at `MMCA.ADC.Conference.UI/Services/ChildEntityServices.cs:75`). Each Conference join service reduces to supplying its endpoint and adding typed `AddAsync`/`DeleteAsync` wrappers over the base's two verbs (`ChildEntityServices.cs:14`, `:30`, `:46`, `:62`). Their interfaces ([`IEventSpeakerUIService`](#ieventspeakeruiservice), [`ISessionSpeakerUIService`](#isessionspeakeruiservice), [`ISessionCategoryItemUIService`](#isessioncategoryitemuiservice), [`ISpeakerCategoryItemUIService`](#ispeakercategoryitemuiservice)) live together in `MMCA.ADC.Conference.UI/Services/IChildEntityUIService.cs`. Note the hard-won detail: the add payload always names the parent explicitly (`new { EventId = eventId, SpeakerId = speakerId }`, `ChildEntityServices.cs:19`), because a controller that binds a `parentId` from the query string will 404 a remove that sends only the child id. `[Rubric §24, Forms, Validation & UX Safety]`. +Sessions, speakers, and events own *join* relationships (a speaker added to a session, a category item to a speaker) that the generic CRUD base cannot model, because the write carries a *parent* id. These get four near-identical services ([`EventSpeakerService`](#eventspeakerservice), [`SessionSpeakerService`](#sessionspeakerservice), [`SessionCategoryItemService`](#sessioncategoryitemservice), [`SpeakerCategoryItemService`](#speakercategoryitemservice)) over the shared, purpose-built [`ChildEntityServiceBase`](group-15-common-ui-framework.md#childentityservicebase), which was **hoisted out of this module into `MMCA.Common.UI`** so every consumer module can reuse it (the note is left in place at `MMCA.ADC.Conference.UI/Services/ChildEntityServices.cs:75`). Each Conference join service reduces to supplying its endpoint and adding typed `AddAsync`/`DeleteAsync` wrappers over the base's two verbs (`Services/ChildEntityServices.cs:14`, `:30`, `:46`, `:62`). Their interfaces ([`IEventSpeakerUIService`](#ieventspeakeruiservice), [`ISessionSpeakerUIService`](#isessionspeakeruiservice), [`ISessionCategoryItemUIService`](#isessioncategoryitemuiservice), [`ISpeakerCategoryItemUIService`](#ispeakercategoryitemuiservice)) live together in one file (`MMCA.ADC.Conference.UI/Services/IChildEntityUIService.cs:10`, `:19`, `:28`, `:37`). Note the hard-won detail: the add payload always names the parent explicitly (`new { EventId = eventId, SpeakerId = speakerId }`, `Services/ChildEntityServices.cs:19`), because a controller that binds a `parentId` from the query string will 404 a remove that sends only the child id. `[Rubric §24, Forms, Validation & UX Safety]`. ## Display-enrichment lookups: the GetAll-vs-GetById populator gap, worked around in the UI -Because the API's list endpoints do not always populate every cross-entity navigation, several pages need a cheap id-to-name map to render speaker names beside a session or an event name beside a room or a sponsor. Three lookup services fill that role, [`SpeakerLookupService`](#speakerlookupservice), [`EventLookupService`](#eventlookupservice), and [`CategoryItemLookupService`](#categoryitemlookupservice) (behind [`ISpeakerLookupService`](#ispeakerlookupservice), [`IEventLookupService`](#ieventlookupservice), [`ICategoryItemLookupService`](#icategoryitemlookupservice)). Each does one `pageSize=10000` fetch with children and foreign keys suppressed, and folds the result into a `Dictionary` of lightweight projection records, [`SpeakerInfo`](#speakerinfo), [`EventInfo`](#eventinfo), [`CategoryItemInfo`](#categoryiteminfo) (`MMCA.ADC.Conference.UI/Services/SpeakerLookupService.cs:20` and `:25`, `MMCA.ADC.Conference.UI/Services/EventLookupService.cs:20` and `:25`, `MMCA.ADC.Conference.UI/Services/CategoryItemLookupService.cs:33` and `:38`); the category-item lookup makes a second, unpaged call first so each item can carry its owning category's title (`CategoryItemLookupService.cs:19` to `:26`). `EventInfo` is the one projection that grew a feature-specific field: `SponsorshipPacketUrl` is an *optional* trailing parameter defaulting to `null` precisely so the many call sites that need only identity and dates stayed unchanged, and only the public sponsor page reads it (`MMCA.ADC.Conference.UI/Services/IEventLookupService.cs:12` to `:19`). `PublicSessionList` fetches the speaker lookup once while resolving its event filter (`MMCA.ADC.Conference.UI/Pages/Public/PublicSessionList.razor.cs:170`) and the view joins each session's `SessionSpeakers` against it to display names (`PublicSessionListView.razor.cs:163`). This is a deliberate client-side join over the [navigation-populator](group-11-navigation-populators.md) ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html)) gap between the API's list and by-id read shapes. +Because the API's list endpoints do not always populate every cross-entity navigation, several pages need a cheap id-to-name map to render speaker names beside a session or an event name beside a room, a sponsor, or an activity. Three lookup services fill that role, [`SpeakerLookupService`](#speakerlookupservice), [`EventLookupService`](#eventlookupservice), and [`CategoryItemLookupService`](#categoryitemlookupservice) (behind [`ISpeakerLookupService`](#ispeakerlookupservice), [`IEventLookupService`](#ieventlookupservice), [`ICategoryItemLookupService`](#icategoryitemlookupservice)). Each does one `pageSize=10000` fetch with children and foreign keys suppressed, and folds the result into a `Dictionary` of lightweight projection records, [`SpeakerInfo`](#speakerinfo), [`EventInfo`](#eventinfo), [`CategoryItemInfo`](#categoryiteminfo) (`MMCA.ADC.Conference.UI/Services/SpeakerLookupService.cs:20` and `:28`, `Services/EventLookupService.cs:20` and `:28`, `Services/CategoryItemLookupService.cs:33` and `:42`); the category-item lookup makes a second, unpaged call *first* so each item can carry its owning category's title (`Services/CategoryItemLookupService.cs:19` to `:30`). `EventInfo` is the one projection that grew a feature-specific field: `SponsorshipPacketUrl` is an *optional* trailing parameter defaulting to `null` precisely so the many call sites that need only identity and dates stayed unchanged, and only the public sponsor page reads it (`MMCA.ADC.Conference.UI/Services/IEventLookupService.cs:12` to `:19`, read at `Pages/Public/PublicSponsorList.razor.cs:61`). `PublicSessionList` fetches the speaker lookup once while resolving its event filter (`MMCA.ADC.Conference.UI/Pages/Public/PublicSessionList.razor.cs:182`) and the view joins each session's `SessionSpeakers` against it to display names (`Pages/Public/PublicSessionListView.razor.cs:170` to `:172`). This is a deliberate client-side join over the [navigation-populator](group-11-navigation-populators.md) ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html)) gap between the API's list and by-id read shapes. ## Three feature areas that go beyond CRUD -First, the **speaker self-service dashboard**: [`SpeakerDashboard`](#speakerdashboard) is gated on the `speaker_id` JWT claim (read from the cascaded authentication state and parsed as a `Guid`, `MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerDashboard.razor.cs:74`) and shows the linked speaker's sessions for the current or next event, per-session bookmark counts, and feedback, with inline profile editing (BR-214). It leans on [`SpeakerDashboardService`](#speakerdashboardservice) (behind [`ISpeakerDashboardUIService`](#ispeakerdashboarduiservice)), whose session read pushes the speaker filter server-side, caps the page at 100 rows, and appends a per-call cache-bust query parameter so it is a guaranteed miss against the shared sessions output cache and a just-made speaker assignment shows immediately (`MMCA.ADC.Conference.UI/Services/SpeakerDashboardService.cs:20`, `:36`, `:39`), and whose bookmark counts come back from one batched endpoint rather than one cross-service hop per session (`SpeakerDashboardService.cs:55`, consumed at `SpeakerDashboard.razor.cs:117`). It derives from Common's [`AuthenticatedServiceBase`](group-15-common-ui-framework.md#authenticatedservicebase) so its calls carry the bearer token and run through the shared retry policy (`SpeakerDashboardService.cs:97`). Second, **organizer feedback moderation** (BR-53): [`OrganizerEventFeedback`](#organizereventfeedback) / [`OrganizerSessionFeedback`](#organizersessionfeedback) let organizers review and delete answers via [`OrganizerEventFeedbackService`](#organizereventfeedbackservice) / [`OrganizerSessionFeedbackService`](#organizersessionfeedbackservice) (interfaces [`IOrganizerEventFeedbackUIService`](#iorganizereventfeedbackuiservice) / [`IOrganizerSessionFeedbackUIService`](#iorganizersessionfeedbackuiservice)); organizers get the unscoped server-side view, and each delete passes the parent id explicitly on the query string to satisfy the controller's binding (`MMCA.ADC.Conference.UI/Services/OrganizerFeedbackService.cs:48`, `:95`), unwrapping domain failures through `ServiceExceptionHelper` before throwing (`OrganizerFeedbackService.cs:53`, `:100`). `[Rubric §11, Security]`: the scoping is server-side, not a client-side hide. Third, **QR self-service**: [`SpeakerQr`](#speakerqr) renders a full-screen code a speaker can hold up at the podium, with **no backend call at all**, since the speaker comes from the `speaker_id` claim and the payload is built locally, so the page renders identically on the prerender and interactive passes (`MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerQr.razor.cs:49` to `:55`). The payload is always the absolute public URL from [`IPublicLinkBuilder`](#ipubliclinkbuilder), never the WebView-internal origin, or a code scanned off the MAUI head would open for nobody else. The module's shared `QrCodeButton` component puts the same capability on four organizer and public print surfaces: sponsor detail (`MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorDetail.razor:93`), room detail (`MMCA.ADC.Conference.UI/Pages/Room/RoomDetail.razor:56`), public event detail (`MMCA.ADC.Conference.UI/Pages/Public/PublicEventDetail.razor:30`), and public session detail (`MMCA.ADC.Conference.UI/Pages/Public/PublicSessionDetail.razor:41`). +First, the **speaker self-service dashboard**: [`SpeakerDashboard`](#speakerdashboard) is gated on the `speaker_id` JWT claim (read from the cascaded authentication state and parsed as a `Guid`, `MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerDashboard.razor.cs:74`, `:76`) behind a plain `[Authorize]` attribute (`Pages/Speaker/SpeakerDashboard.razor:2`), and shows the linked speaker's sessions for the current or next event, per-session bookmark counts, and feedback, with inline profile editing (BR-214). It leans on [`SpeakerDashboardService`](#speakerdashboardservice) (behind [`ISpeakerDashboardUIService`](#ispeakerdashboarduiservice)), whose session read pushes the speaker filter server-side, caps the page at 100 rows, and appends a per-call cache-bust query parameter so it is a guaranteed miss against the shared sessions output cache and a just-made speaker assignment shows immediately (`MMCA.ADC.Conference.UI/Services/SpeakerDashboardService.cs:20`, `:36`, `:39`), and whose bookmark counts come back from one batched endpoint rather than one cross-service hop per session (`Services/SpeakerDashboardService.cs:55`, consumed at `Pages/Speaker/SpeakerDashboard.razor.cs:117`). It derives from Common's [`AuthenticatedServiceBase`](group-15-common-ui-framework.md#authenticatedservicebase) so its calls carry the bearer token and run through the shared retry policy (`Services/SpeakerDashboardService.cs:97`), with a private dispatch helper that unwraps domain failures and treats 404 as "no feedback yet" rather than an error (`Services/SpeakerDashboardService.cs:99` to `:104`). Second, **organizer feedback moderation** (BR-53): [`OrganizerEventFeedback`](#organizereventfeedback) / [`OrganizerSessionFeedback`](#organizersessionfeedback) let organizers review and delete answers via [`OrganizerEventFeedbackService`](#organizereventfeedbackservice) / [`OrganizerSessionFeedbackService`](#organizersessionfeedbackservice) (interfaces [`IOrganizerEventFeedbackUIService`](#iorganizereventfeedbackuiservice) / [`IOrganizerSessionFeedbackUIService`](#iorganizersessionfeedbackuiservice)); organizers get the unscoped server-side view, and each delete passes the parent id explicitly on the query string to satisfy the controller's binding (`MMCA.ADC.Conference.UI/Services/OrganizerFeedbackService.cs:48`, `:95`), unwrapping domain failures through `ServiceExceptionHelper` before throwing (`Services/OrganizerFeedbackService.cs:53`, `:100`). `[Rubric §11, Security]`: the scoping is server-side and the pages carry `[Authorize(Roles = "Organizer")]` (`Pages/Feedback/OrganizerEventFeedback.razor:2`), not a client-side hide. Third, **QR self-service**: [`SpeakerQr`](#speakerqr) renders a full-screen code a speaker can hold up at the podium, with **no backend call at all**, since the speaker comes from the `speaker_id` claim and the payload is built locally, so the page renders identically on the prerender and interactive passes (`MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerQr.razor.cs:49` to `:55`). The payload is always the absolute public URL from [`IPublicLinkBuilder`](#ipubliclinkbuilder), never the WebView-internal origin, or a code scanned off the MAUI head would open for nobody else. The module's own `QrCodeButton` component (`MMCA.ADC.Conference.UI/Components/QrCodeButton.razor`) puts the same capability on five organizer and public print surfaces: sponsor detail (`Pages/Sponsor/SponsorDetail.razor:93`), room detail (`Pages/Room/RoomDetail.razor:56`), public event detail (`Pages/Public/PublicEventDetail.razor:30`), public session detail (`Pages/Public/PublicSessionDetail.razor:41`), and public speaker detail (`Pages/Public/PublicSpeakerDetail.razor:45`). ## Session-selection decision support, the asynchronous edge -The most behaviour-rich page is the organizer-only [`SessionSelectionDashboard`](#sessionselectiondashboard), which renders category distribution, speaker overlap, locality breakdown, and AI content-similarity scoring over an event's session pool via [`SessionSelectionService`](#sessionselectionservice) (behind [`ISessionSelectionUIService`](#isessionselectionuiservice)). It defaults the event picker to the live-or-next event through the shared [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector) (`MMCA.ADC.Conference.UI/Pages/SessionSelection/SessionSelectionDashboard.razor.cs:80`) and derives its four filter option lists from the returned [`SessionSelectionDashboardDTO`](group-17-conference-domain.md#sessionselectiondashboarddto) itself, through the pure projection record [`SessionSelectionFilterOptions`](#sessionselectionfilteroptions) (`SessionSelectionDashboard.razor.cs:191`, projection at `MMCA.ADC.Conference.UI/Pages/SessionSelection/SessionSelectionFilterOptions.cs:20`). `GetDashboardAsync` reads that DTO through the inherited retry policy (`MMCA.ADC.Conference.UI/Services/SessionSelectionService.cs:23`); `ScoreSessionsAsync` POSTs to the scoring endpoint and **handles `202 Accepted` explicitly**: because AI scoring of every eligible session can take minutes, the API runs the [`ScoreEventSessionsCommand`](group-18-conference-application.md#scoreeventsessionscommand) in a background scope and returns `202` immediately, so the UI service maps that to a sentinel [`ScoreEventSessionsResultDTO`](group-17-conference-domain.md#scoreeventsessionsresultdto) with `SessionsScored = -1` to signal "started in background" rather than a completed count (`SessionSelectionService.cs:41` to `:44`). The page then starts a fire-and-forget poll loop on an 8-second cadence, held in an `internal` property so a bUnit test can shrink it (`SessionSelectionDashboard.razor.cs:246`, loop at `:262` and `:272`), and the decision logic for that loop is factored out into the pure state machine [`ScorePollTracker`](#scorepolltracker), which turns each observation into a [`ScorePollSignal`](#scorepollsignal): keep polling, apply-and-continue, all sessions scored, counts stable long enough, or no scores at all within the zero-progress budget (`MMCA.ADC.Conference.UI/Pages/SessionSelection/ScorePollTracker.cs:74`, dispatched at `SessionSelectionDashboard.razor.cs:317`). Its budgets are explicit constants: 225 polls, a 30-minute cap (`ScorePollTracker.cs:34`), 5 consecutive fetch failures (`ScorePollTracker.cs:41`), 10 zero-progress polls (`ScorePollTracker.cs:48`), and 3 stable polls before completion (`ScorePollTracker.cs:51`). `[Rubric §6, CQRS & Event-Driven]` and `[Rubric §29, Resilience]`: the fire-and-forget contract is honoured on both sides, transient poll failures are absorbed rather than wedging the Score button, and the dashboard read goes through the retry policy so a blip self-heals. +The most behaviour-rich page is the organizer-only [`SessionSelectionDashboard`](#sessionselectiondashboard) (`MMCA.ADC.Conference.UI/Pages/SessionSelection/SessionSelectionDashboard.razor:2` carries the `Organizer` role attribute), which renders category distribution, speaker overlap, locality breakdown, and AI content-similarity scoring over an event's session pool via [`SessionSelectionService`](#sessionselectionservice) (behind [`ISessionSelectionUIService`](#isessionselectionuiservice)). It defaults the event picker to the live-or-next event through the shared [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector) (`Pages/SessionSelection/SessionSelectionDashboard.razor.cs:80`) and derives its four filter option lists from the returned [`SessionSelectionDashboardDTO`](group-17-conference-domain.md#sessionselectiondashboarddto) itself, through the pure projection record [`SessionSelectionFilterOptions`](#sessionselectionfilteroptions) (`Pages/SessionSelection/SessionSelectionDashboard.razor.cs:193`, projection at `Pages/SessionSelection/SessionSelectionFilterOptions.cs:20`). Every load is stamped with a monotonic **generation** rather than an event id, so switching away from an event and back still discards the first response even though both carry the same id: the field is bumped on each selection (`Pages/SessionSelection/SessionSelectionDashboard.razor.cs:36`), snapshotted before the fetch (`:131`), and re-checked before the board is replaced (`:143`), before an error banner is painted (`:165`), and before the spinner is cleared (`:172`). `[Rubric §19, State Management & Data Flow]`. + +`GetDashboardAsync` reads that DTO through the inherited retry policy (`MMCA.ADC.Conference.UI/Services/SessionSelectionService.cs:23`); `ScoreSessionsAsync` POSTs to the scoring endpoint and **handles `202 Accepted` explicitly**: because AI scoring of every eligible session can take minutes, the API runs the [`ScoreEventSessionsCommand`](group-18-conference-application.md#scoreeventsessionscommand) in a background scope and returns `202` immediately, so the UI service maps that to a sentinel [`ScoreEventSessionsResultDTO`](group-17-conference-domain.md#scoreeventsessionsresultdto) with `SessionsScored = -1` to signal "started in background" rather than a completed count (`Services/SessionSelectionService.cs:42` to `:45`). The page then starts a fire-and-forget poll loop (`Pages/SessionSelection/SessionSelectionDashboard.razor.cs:215`) on an 8-second cadence held in an `internal` property so a bUnit test can shrink it (`Pages/SessionSelection/SessionSelectionDashboard.razor.cs:246`, loop at `:262`, delay at `:276`), and the decision logic for that loop is factored out into the pure state machine [`ScorePollTracker`](#scorepolltracker), which turns each observation into a [`ScorePollSignal`](#scorepollsignal): keep polling, apply-and-continue, all sessions scored, counts stable long enough, or no scores at all within the zero-progress budget (`Pages/SessionSelection/ScorePollTracker.cs:74`, observed at `Pages/SessionSelection/SessionSelectionDashboard.razor.cs:285`, failures at `:299`, dispatched at `:319`). Its budgets are explicit constants: 225 polls, a 30-minute cap (`Pages/SessionSelection/ScorePollTracker.cs:34`), 5 consecutive fetch failures (`:41`), 10 zero-progress polls (`:48`), and 3 stable polls before completion (`:51`). `[Rubric §6, CQRS & Event-Driven]` and `[Rubric §29, Resilience]`: the fire-and-forget contract is honoured on both sides, transient poll failures are absorbed rather than wedging the Score button, and the dashboard read goes through the retry policy so a blip self-heals. ## Public versus authenticated rendering, and the device-capability path -A recurring `[Rubric §11, Security]` pattern: the same conference entity is exposed through *two* page families. The public family ([`PublicEventList`](#publiceventlist)/[`PublicEventDetail`](#publiceventdetail), [`PublicSessionList`](#publicsessionlist)/[`PublicSessionDetail`](#publicsessiondetail), [`PublicSpeakerList`](#publicspeakerlist)/[`PublicSpeakerDetail`](#publicspeakerdetail), [`PublicSponsorList`](#publicsponsorlist)) is anonymous-readable and output-cached at the API; the organizer family exposes edit controls behind role gating. `PublicSessionList` shows the nuance well. It is read-only for anonymous users (BR-43), but an authenticated user gets inline bookmark stars and a My Schedule toggle wired through the *optional* [`ISessionBookmarkUIService`](group-22-engagement-module.md#isessionbookmarkuiservice); because Blazor's `[Inject]` has no optional mode (an unregistered service throws at render), the page declares that dependency as a nullable property and resolves it via `IServiceProvider.GetService` (`PublicSessionList.razor.cs:38`, resolved at `:114`), so it stays null when the Engagement module is disabled. `[Rubric §7, Microservices Readiness]`. Non-organizers are always locked server-side to the computed current or next event via [`CurrentEventDefaults`](group-17-conference-domain.md#currenteventdefaults) and the privileged-reader list [`ConferenceReadAudience`](group-17-conference-domain.md#conferencereadaudience), so a shared organizer URL cannot pin an attendee to a different or unpublished event (`PublicSessionList.razor.cs:149`, `:186`, `:193`). My Schedule is a true server-side paged fetch, scoping the query with an `Id IN (...)` filter over the bookmarked ids rather than over-fetching and filtering in memory (`PublicSessionList.razor.cs:296` to `:304`). The page also participates in the device-capability layer ([G26](group-26-device-capability-layer.md), [ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html)): the last successful first page is written to [`ILocalCacheStore`](group-26-device-capability-layer.md#ilocalcachestore) as a [`CachedSessionPage`](#cachedsessionpage) record and replayed when [`IConnectivityStatusService`](group-26-device-capability-layer.md#iconnectivitystatusservice) reports offline (`PublicSessionList.razor.cs:316`, `:325`, record at `:342`), the star toggle fires [`IHapticFeedbackService`](group-26-device-capability-layer.md#ihapticfeedbackservice) (`PublicSessionListView.razor.cs:111`), the filter bar shares a schedule screenshot through [`IScreenshotService`](group-26-device-capability-layer.md#iscreenshotservice) and [`IShareService`](group-26-device-capability-layer.md#ishareservice) (`PublicSessionListFilterBar.razor.cs:51` to `:57`), and `/conference/sessions?mine=true` is a deep link the MAUI head's home-screen quick action targets (`PublicSessionList.razor.cs:65`). Each of those is a no-op on the web heads, so one page serves both worlds. `[Rubric §29, Resilience]` and `[Rubric §22, Responsive & Cross-Browser/Device]`. +A recurring `[Rubric §11, Security]` pattern: the same conference entity is exposed through *two* page families. The public family ([`PublicEventList`](#publiceventlist)/[`PublicEventDetail`](#publiceventdetail), [`PublicSessionList`](#publicsessionlist)/[`PublicSessionDetail`](#publicsessiondetail), [`PublicSpeakerList`](#publicspeakerlist)/[`PublicSpeakerDetail`](#publicspeakerdetail), [`PublicSponsorList`](#publicsponsorlist), [`PublicActivityList`](#publicactivitylist)) carries no `@attribute [Authorize]` at all and is output-cached at the API; the organizer family gates on the `Organizer` role in markup (`MMCA.ADC.Conference.UI/Pages/Event/EventList.razor:2`). `PublicSessionList` shows the nuance well. It is read-only for anonymous users (BR-43), but an authenticated user gets inline bookmark stars and a My Schedule toggle wired through the *optional* [`ISessionBookmarkUIService`](group-22-engagement-module.md#isessionbookmarkuiservice); because Blazor's `[Inject]` has no optional mode (an unregistered service throws at render), the page declares that dependency as a nullable property and resolves it via `IServiceProvider.GetService` (`Pages/Public/PublicSessionList.razor.cs:38`, resolved at `:126`), so it stays null when the Engagement module is disabled. `[Rubric §7, Microservices Readiness]`. Non-organizers are always locked server-side to the computed current or next event via [`CurrentEventDefaults`](group-17-conference-domain.md#currenteventdefaults) and the privileged-reader list [`ConferenceReadAudience`](group-17-conference-domain.md#conferencereadaudience), so a shared organizer URL cannot pin an attendee to a different or unpublished event (`Pages/Public/PublicSessionList.razor.cs:161`, `:203`, `:210`). The room picker costs no extra fetch: [`PublicScheduleRoomOptions`](#publicscheduleroomoptions) scopes the rooms the already-loaded events carry, de-duplicates and orders them, and clears a room filter the newly scoped list no longer offers rather than leaving it filtering out everything (`Pages/Public/PublicScheduleRoomOptions.cs:21` to `:41`, called at `Pages/Public/PublicSessionList.razor.cs:195`). My Schedule is a true server-side paged fetch, scoping the query with an `Id IN (...)` filter over the bookmarked ids rather than over-fetching and filtering in memory (`Pages/Public/PublicSessionList.razor.cs:317`). + +The page also participates in the device-capability layer ([G26](group-26-device-capability-layer.md), [ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html)): the last successful first page is written to [`ILocalCacheStore`](group-26-device-capability-layer.md#ilocalcachestore) as a [`CachedSessionPage`](#cachedsessionpage) record and replayed when [`IConnectivityStatusService`](group-26-device-capability-layer.md#iconnectivitystatusservice) reports offline (`MMCA.ADC.Conference.UI/Pages/Public/PublicSessionList.razor.cs:342`, `:351`, record at `:366`), the star toggle fires [`IHapticFeedbackService`](group-26-device-capability-layer.md#ihapticfeedbackservice) (`Pages/Public/PublicSessionListView.razor.cs:113`), the filter bar shares a schedule screenshot through [`IScreenshotService`](group-26-device-capability-layer.md#iscreenshotservice) and [`IShareService`](group-26-device-capability-layer.md#ishareservice) (`Pages/Public/PublicSessionListFilterBar.razor.cs:65` to `:69`), the public activity page opens directions through [`IMapNavigationService`](group-26-device-capability-layer.md#imapnavigationservice), which launches the platform maps app on native heads and a maps site in a browser tab otherwise (`Pages/Public/PublicActivityList.razor.cs:109`), and `/conference/sessions?mine=true` is a deep link the MAUI head's home-screen quick action targets (`Pages/Public/PublicSessionList.razor.cs:67`). Each of those is a no-op on the web heads, so one page serves both worlds. `[Rubric §29, Resilience]` and `[Rubric §22, Responsive & Cross-Browser/Device]`. -## Sponsors, a feature area in miniature +## Sponsors and activities, two feature areas in miniature -The sponsor surface is worth reading as a compact tour of every pattern above, because it is the newest and touches all of them. Organizers manage the roster through [`SponsorList`](#sponsorlist) / [`SponsorCreate`](#sponsorcreate) / [`SponsorDetail`](#sponsordetail): the list is a plain `DataGridListPageBase` whose event filter defaults to the current or next event and persists across navigation (`MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorList.razor.cs:19`, `:44`, `:77`, `:95`), the create page offers the [`SponsorTier`](group-17-conference-domain.md#sponsortier) values in package order straight off `Enum.GetValues` and defaults the event the same way (`MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorCreate.razor.cs:18`, `:56`, `:77`), and the detail page edits every field *except* the owning event, on the stated rationale that moving a sponsorship between events is a create plus a delete (`MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorDetail.razor.cs:10` to `:15`), resolving the event name through the shared `EventLookupService` (`SponsorDetail.razor.cs:38` to `:41`). Attendees see the same data twice. `PublicSponsorList` resolves the featured event, filters the roster to it, and groups by tier ascending (package order) then by `Sort` and name, so the render order is deterministic rather than insertion-dependent (`PublicSponsorList.razor.cs:49` to `:86`); when the roster is empty it falls back to the sponsorship call to action, and when the event publishes no packet URL that call to action is hidden entirely rather than offering a dead link (`PublicSponsorList.razor.cs:36`, `:61`). [`ADCHome`](#adchome) renders the same roster as a logo strip using the same tier-then-sort-then-name rule (`MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:210` to `:219`), filtering client-side to the featured event so a second published edition's sponsors cannot bleed onto the landing page (`ADCHome.razor.cs:213`), and any failure leaves the list empty and the call to action standing (`ADCHome.razor.cs:221` to `:228`). Because that page reads the anonymous endpoint directly rather than through a typed service, its wire shapes are private records on the component itself: [`ADCSponsorCollectionResult`](#adcsponsorcollectionresult) and [`ADCSponsorInfo`](#adcsponsorinfo) (`ADCHome.razor.cs:295`, `:297`). +The sponsor surface is worth reading as a compact tour of every pattern above. Organizers manage the roster through [`SponsorList`](#sponsorlist) / [`SponsorCreate`](#sponsorcreate) / [`SponsorDetail`](#sponsordetail): the list is a plain `DataGridListPageBase` whose event filter defaults to the current or next event and persists across navigation (`MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorList.razor.cs:19`, `:44`, `:54`, `:106`), the create page offers the [`SponsorTier`](group-17-conference-domain.md#sponsortier) values in package order straight off `Enum.GetValues` and defaults the event the same way (`Pages/Sponsor/SponsorCreate.razor.cs:18`, `:56`), and the detail page edits every field *except* the owning event, on the stated rationale that moving a sponsorship between events is a create plus a delete (`Pages/Sponsor/SponsorDetail.razor.cs:10` to `:13`), resolving the event name through the shared `EventLookupService` (`Pages/Sponsor/SponsorDetail.razor.cs:100`). Attendees see the same data twice. `PublicSponsorList` resolves the featured event, filters the roster to it, and groups by tier ascending (package order) then by `Sort` and name, so the render order is deterministic rather than insertion-dependent (`Pages/Public/PublicSponsorList.razor.cs:44` to `:86`); when the roster is empty it falls back to the sponsorship call to action, and when the event publishes no packet URL that call to action is hidden entirely rather than offering a dead link (`Pages/Public/PublicSponsorList.razor.cs:36`, `:61`). [`ADCHome`](#adchome) renders the same roster as a logo strip using the same tier-then-sort-then-name rule (`Pages/Home/ADCHome.razor.cs:225` to `:234`), filtering client-side to the featured event so a second published edition's sponsors cannot bleed onto the landing page (`Pages/Home/ADCHome.razor.cs:228`), and any failure leaves the list empty and the call to action standing (`Pages/Home/ADCHome.razor.cs:236` to `:243`). Because that page reads the anonymous endpoint directly rather than through a typed service, its wire shapes are private records on the component itself: [`ADCSponsorCollectionResult`](#adcsponsorcollectionresult) and [`ADCSponsorInfo`](#adcsponsorinfo) (`Pages/Home/ADCHome.razor.cs:311`, `:313`). + +The activities area (the social programme: pre-conference party, coffee connect, after-party, closing ceremony) is the newest and repeats the shape with two twists. [`ActivityCreate`](#activitycreate), [`ActivityDetail`](#activitydetail), and `ActivityList` are the organizer trio, with the create page defaulting the owning event to the current or next one and deriving the start and end defaults from that event's dates (`MMCA.ADC.Conference.UI/Pages/Activity/ActivityCreate.razor.cs:51`, `:58`). Because `EventId` is a real Activity column, the list's event filter needs no virtual-key resolution and goes straight through the generic filter pipeline (`Pages/Activity/ActivityList.razor.cs:150`), unlike the speaker list, whose event filter travels as a virtual key resolved server-side through the join tables (`Pages/Public/PublicSpeakerList.razor.cs:21` to `:22`). `PublicActivityList` renders the same programme chronologically for attendees, ordering by start time, then display order, then name so ties are deterministic (`Pages/Public/PublicActivityList.razor.cs:79` to `:85`), and offers the directions affordance for an activity that carries its own off-site venue (`Pages/Public/PublicActivityList.razor.cs:101`). ## The landing page -`ADCHome` is the conference front door, shared by the web and MAUI heads; both serve the editorial images from their own site root today, so neither overrides the `ImageBasePath` parameter (`MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:35`). It fetches the events list through the named `"APIClient"` and features the live-or-next published event via `CurrentEventSelector` (`ADCHome.razor.cs:160`, `:165`), deserializing into two more private API models, [`ADCCollectionResult`](#adccollectionresult) and [`ADCEventInfo`](#adceventinfo) (`ADCHome.razor.cs:282`, `:284`). Three rendering decisions are worth internalizing. First, during SSR prerender it skips the backend fetch and the timer entirely and renders the static fallback, because an untimed server-side call to a cold backend would block the prerender and therefore the post-login navigation (`ADCHome.razor.cs:101`). Second, the per-second countdown ticking lives in a child component behind a render fence, so this page arms only a single one-shot `Timer` for the Live-to-Ended flip (`ADCHome.razor.cs:113`, armed at `:128`), classifying the moment into the [`EventPhase`](#eventphase) enum Upcoming/Live/Ended from the event's own time zone (`ADCHome.razor.cs:255`). Third, the fallback date is a named constant with an explicit warning that it must track the published event date, since a stale value makes the hero date and the countdown visibly jump once the real event loads (`ADCHome.razor.cs:25`). `[Rubric §23, Front-End Performance & Rendering]`. The editorial content it renders (keynote and the eight-track catalog) is held as static records, [`KeynoteSpeakerInfo`](#keynotespeakerinfo) and [`ConferenceTrackInfo`](#conferencetrackinfo) (`ADCHome.razor.cs:340`, `:341`, data at `:309` and `:320`). +`ADCHome` is the conference front door, shared by the web and MAUI heads; both serve the editorial images from their own site root today, so neither overrides the `ImageBasePath` parameter (`MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:51`). It fetches the events list through the named `"APIClient"` and features the live-or-next published event via `CurrentEventSelector` (`Pages/Home/ADCHome.razor.cs:176`, `:180`), deserializing into two more private API models, [`ADCCollectionResult`](#adccollectionresult) and [`ADCEventInfo`](#adceventinfo) (`Pages/Home/ADCHome.razor.cs:297`, `:299`). Three rendering decisions are worth internalizing. First, during SSR prerender it skips the backend fetch and the timer entirely and renders the static fallback, because an untimed server-side call to a cold backend would block the prerender and therefore the post-login navigation (`Pages/Home/ADCHome.razor.cs:116`). Second, the per-second countdown ticking lives in a child component behind a render fence, so this page arms only a single one-shot `Timer` for the Live-to-Ended flip (`Pages/Home/ADCHome.razor.cs:126`, armed at `:128`), classifying the moment into the [`EventPhase`](#eventphase) enum Upcoming/Live/Ended from the event's own time zone and going through `CurrentEventSelector.ToUtc` so the spring-forward gap at a midnight boundary cannot throw out of the render path (`Pages/Home/ADCHome.razor.cs:246` to `:275`). Third, the fallback date is a named constant with an explicit warning that it must track the published event date, since a stale value makes the hero date and the countdown visibly jump once the real event loads (`Pages/Home/ADCHome.razor.cs:40`). `[Rubric §23, Front-End Performance & Rendering]`. The editorial content it renders (the keynote, the eight-track catalog, and the two pre-conference workshops) is held as static records, [`KeynoteSpeakerInfo`](#keynotespeakerinfo), [`ConferenceTrackInfo`](#conferencetrackinfo), and [`PreConferenceWorkshopInfo`](#preconferenceworkshopinfo) (`Pages/Home/ADCHome.razor.cs:371`, `:372`, `:379`, data at `:325`, `:336`, and `:359`); the workshop record carries only proper nouns plus a resource-key stem, so its audience and description lines stay localized (`Pages/Home/ADCHome.razor.cs:356` to `:358`). -## Routes and navigation +## Routes, navigation, and localized strings -All paths are centralized in [`ConferenceRoutePaths`](#conferenceroutepaths), a static catalogue of literal routes and id-parameterized builder methods (`EventDetails(id)`, `PublicSessionDetails(id)`, `SponsorDetails(id)`, `EventFeedbackOrganizer(id)`, and so on) typed against the module's identifier aliases and formatted culture-invariantly; pages navigate with `NavigationManager.NavigateTo(ConferenceRoutePaths.EventDetails(id))` rather than hand-building URL strings, so a route change happens in one file (`MMCA.ADC.Conference.UI/ConferenceRoutePaths.cs:10` to `:63`). Two entries in that file are deliberate duplicates of routes **owned by Engagement.UI**, `SponsorVisitLink` and `RoomCheckInLink` (`ConferenceRoutePaths.cs:55`, `:56`), because Conference.UI must not reference Engagement.UI yet the organizer print surfaces need those links to encode into a QR; the reason is recorded inline at `ConferenceRoutePaths.cs:51` to `:54`. `[Rubric §25, Navigation, Routing & Information Architecture]`. Public share links are built through the injectable `IPublicLinkBuilder`, whose default [`NavigationPublicLinkBuilder`](#navigationpubliclinkbuilder) resolves against the browser origin (`MMCA.ADC.Conference.UI/Services/NavigationPublicLinkBuilder.cs:19`), with the MAUI head overriding the registration after module registration so shared links always point at the web app (`MMCA.ADC.Conference.UI/DependencyInjection.cs:46` to `:49`). User-facing strings are **not** inline English: every page resolves its labels and snackbar messages through an injected `IStringLocalizer` (the `L["..."]` calls in each code-behind, for example the title in `EventList.razor.cs:19` and the delete toast at `EventList.razor.cs:77`, or the breadcrumbs in `SpeakerDashboard.razor.cs:56`) over co-located `.resx` resources. Where a string is deliberately left untranslated (the conference brand name, a postal address, the English-only editorial content on the landing page) the code carries an explicit `// i18n: allow` marker with a reason (`ADCHome.razor.cs:64`, `:68`, `:80`, `:307`). `[Rubric §27, Internationalization & Localization]` assesses externalized strings and culture-aware formatting; this area embodies it under [ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html), which superseded the single-locale [ADR-011](https://ivanball.github.io/docs/adr/011-single-locale-i18n.html) ([primer §6](00-primer.md#6-the-34-category-architecture-evaluation-lens)). +All paths are centralized in [`ConferenceRoutePaths`](#conferenceroutepaths), a static catalogue of literal routes and id-parameterized builder methods (`EventDetails(id)`, `PublicSessionDetails(id)`, `SponsorDetails(id)`, `ActivityDetails(id)`, `EventFeedbackOrganizer(id)`, and so on) typed against the module's identifier aliases and formatted culture-invariantly; pages navigate with `NavigationManager.NavigateTo(ConferenceRoutePaths.EventDetails(id))` rather than hand-building URL strings, so a route change happens in one file (`MMCA.ADC.Conference.UI/ConferenceRoutePaths.cs:10` to `:68`). Two entries in that file are deliberate duplicates of routes **owned by Engagement.UI**, `SponsorVisitLink` and `RoomCheckInLink` (`ConferenceRoutePaths.cs:60`, `:61`), because Conference.UI must not reference Engagement.UI yet the organizer print surfaces need those links to encode into a QR; the reason is recorded inline at `ConferenceRoutePaths.cs:56` to `:59`. `[Rubric §25, Navigation, Routing & Information Architecture]`. Public share links are built through the injectable `IPublicLinkBuilder`, whose default [`NavigationPublicLinkBuilder`](#navigationpubliclinkbuilder) resolves against the browser origin (`MMCA.ADC.Conference.UI/Services/NavigationPublicLinkBuilder.cs:19`), with the MAUI head overriding the registration after module registration so shared links always point at the web app (`MMCA.ADC.Conference.UI/DependencyInjection.cs:46` to `:49`). User-facing strings are **not** inline English: every page injects an `IStringLocalizer` in its markup (`Pages/Event/EventList.razor:5`) and resolves labels and snackbar messages through `L["..."]` over co-located `.resx` resources, including format patterns such as the hero date layout so month names follow the selected culture (`Pages/Home/ADCHome.razor.cs:284`). Where a string is deliberately left untranslated (the conference brand name, a postal address, a ticketing URL, the English-only editorial content) the code carries an explicit `// i18n: allow` marker with a reason (`Pages/Home/ADCHome.razor.cs:31`, `:79`, `:83`, `:323`). `[Rubric §27, Internationalization & Localization]` assesses externalized strings and culture-aware formatting; this area embodies it under [ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html), which superseded the single-locale [ADR-011](https://ivanball.github.io/docs/adr/011-single-locale-i18n.html) ([primer §6](00-primer.md#6-the-34-category-architecture-evaluation-lens)). ## How it all plugs into the shell -Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimodule) implements Common's [`IUIModule`](group-15-common-ui-framework.md#iuimodule) (the front-end counterpart of the [`IModule`](group-14-module-system-composition.md#imodule) back-end contract): it declares the module's fourteen [`NavItem`](group-15-common-ui-framework.md#navitem) entries, whose labels are [ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html) resource *keys* (`Nav.Events`, `Nav.Dashboard`, and so on) each carrying a `TitleResource` so the shared NavMenu localizes them at render time against the co-located `ConferenceUIModule.resx` pair (`MMCA.ADC.Conference.UI/ConferenceUIModule.cs:18` to `:39`). Those fourteen split three ways: four public entries for everyone including the sponsor page (`ConferenceUIModule.cs:21` to `:24`), two `speaker_id`-claim-gated entries in the user section, the dashboard and the speaker's own QR (`ConferenceUIModule.cs:27`, `:28`), and an `Organizer`-role-gated admin group of eight, Events, Sessions, Speakers, Categories, Questions, Rooms, Sponsors, and Session Selection (`ConferenceUIModule.cs:31` to `:38`); it then exposes its assembly so the host can discover the Razor routes (`ConferenceUIModule.cs:41`). The companion [`DependencyInjection`](#dependencyinjection) extension `AddConferenceUI()` (a C# `extension(IServiceCollection)` member, [primer §4](00-primer.md#c-extensiont-types-read-this-once)) is the one call a host makes (`MMCA.ADC.Conference.UI/DependencyInjection.cs:19`): it delegates the two-step prologue to Common's `AddUIModule()`, which Scrutor-scans the module assembly for every `IEntityService<,>` implementation as scoped and registers the descriptor as a singleton `IUIModule` (`DependencyInjection.cs:23`, implementation at `MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:152` to `:161`), then explicitly registers the four child-entity services (`DependencyInjection.cs:26` to `:29`), the speaker dashboard (`:32`), the two organizer feedback services (`:35`, `:36`), session selection (`:39`), the three lookup services (`:42` to `:44`), and the public-link builder (`:49`). Because the scan covers the entity services, adding a ninth CRUD entity needs no edit here at all, and because the module contributes its own nav and assembly, the shell folds it in with no edit to the shell either. `[Rubric §1, SOLID]` (Open/Closed) and `[Rubric §18, UI Architecture]`. Read the per-type sections that follow for the mechanics of each page and service; the bUnit and Playwright tests that exercise this library live in the testing chapter ([G27](group-27-testing-infrastructure.md)). +Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimodule) implements Common's [`IUIModule`](group-15-common-ui-framework.md#iuimodule) (the front-end counterpart of the [`IModule`](group-14-module-system-composition.md#imodule) back-end contract): it declares the module's sixteen [`NavItem`](group-15-common-ui-framework.md#navitem) entries, whose labels are [ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html) resource *keys* (`Nav.Events`, `Nav.Dashboard`, and so on) each carrying a `TitleResource` so the shared NavMenu localizes them at render time against the co-located `ConferenceUIModule.resx` pair (`MMCA.ADC.Conference.UI/ConferenceUIModule.cs:18` to `:41`). Those sixteen split three ways: five public entries for everyone, Events, Sessions, Speakers, Sponsors, and Activities (`ConferenceUIModule.cs:21` to `:25`), two `speaker_id`-claim-gated entries in the user section, the dashboard and the speaker's own QR (`ConferenceUIModule.cs:28`, `:29`), and an `Organizer`-role-gated admin group of nine, Events, Sessions, Speakers, Categories, Questions, Rooms, Sponsors, Activities, and Session Selection (`ConferenceUIModule.cs:32` to `:40`); it then exposes its assembly so the host can discover the Razor routes (`ConferenceUIModule.cs:43`). The companion [`DependencyInjection`](#dependencyinjection) extension `AddConferenceUI()` (a C# `extension(IServiceCollection)` member, [primer §4](00-primer.md#c-extensiont-types-read-this-once)) is the one call a host makes (`MMCA.ADC.Conference.UI/DependencyInjection.cs:19`): it delegates the two-step prologue to Common's `AddUIModule()`, which Scrutor-scans the module assembly for every `IEntityService<,>` implementation as scoped and registers the descriptor as a singleton `IUIModule` (`DependencyInjection.cs:23`, implementation at `MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:152` to `:162`), then explicitly registers the four child-entity services (`DependencyInjection.cs:26` to `:29`), the speaker dashboard (`:32`), the two organizer feedback services (`:35`, `:36`), session selection (`:39`), the three lookup services (`:42` to `:44`), and the public-link builder (`:49`). Because the scan covers the entity services, adding a tenth CRUD entity needs no edit here at all: the Activities area added `ActivityService` with no line in this file. And because the module contributes its own nav and assembly, the shell folds it in with no edit to the shell either. `[Rubric §1, SOLID]` (Open/Closed) and `[Rubric §18, UI Architecture]`. Read the per-type sections that follow for the mechanics of each page and service; the bUnit and Playwright tests that exercise this library live in the testing chapter ([G27](group-27-testing-infrastructure.md)). ### ADCEventInfo -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Home` · `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:284` · Level 0 · record (sealed, private) +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Home` · `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:299` · Level 0 · record (sealed, private) -- **What it is**: the deserialization-only projection of one published event as the landing page needs it. It is declared `private sealed record` inside [ADCHome](#adchome) (`:284`), so it is not a shared contract: it exists purely to give `System.Text.Json` a shape to bind the `events` response into. +- **What it is**: the deserialization-only projection of one published event as the landing page needs it. It is declared `private sealed record` inside [ADCHome](#adchome) (`:299`), so it is not a shared contract: it exists purely to give `System.Text.Json` a shape to bind the `events` response into. - **Depends on**: no first-party types. BCL only (`DateOnly` for the two dates). -- **Concept introduced: the page-local wire model.** [Rubric §9, API and Contract Design] assesses whether consumers bind to explicit, minimal contracts rather than reaching for the server's internal types. The landing page needs nine fields (`Id`, `Name`, `Description?`, `StartDate`, `EndDate`, `TimeZone`, `VenueAddress?`, `VenueMapUrl?`, `SponsorshipPacketUrl?`, `:284-293`) out of the much larger event DTO the API serves, so it declares exactly those and lets the serializer ignore the rest. Because the record is private to the component, no other page can accidentally couple to it; a second consumer declares its own projection. Every optional field is nullable, which is what lets the page fall back to hard-coded defaults without null checks scattered through the markup. -- **Walkthrough**: a positional record with no methods. `Name` feeds the `EventName` property and therefore `HeroTitleParts()` (`:64`, `:78`); `Description` feeds `EventDescription`, falling back to the localized `Fallback.EventDescription` resource (`:66`); `StartDate`/`EndDate`/`TimeZone` are the three inputs `UpdateCountdown()` converts into the UTC live window (`:233-246`); `VenueAddress` backs the venue block and the Google Maps search URL (`:68-71`); `Id` is the filter key that keeps a second published edition's sponsors off the page (`:214`); `SponsorshipPacketUrl` gates the whole sponsorship call to action block, heading and button included (`ADCHome.razor:208-227`). -- **Why it's built this way**: the page must render before, during, and after the API call, so it stores a single nullable `ADCEventInfo? _event` (`:50`) and every derived property is written as `_event?.X ?? `. One nullable field is the whole "loaded or not" state machine, with no extra flags. -- **Where it's used**: the `Items` list of [ADCCollectionResult](#adccollectionresult) (`:282`), selected by `CurrentEventSelector.SelectCurrentOrNext` in `LoadEventAsync` (`:165-170`), used as the sponsor filter key in `LoadSponsorsAsync` (`:198`, `:214`), and read by every derived display property on [ADCHome](#adchome). -- **Caveats / not-in-source**: `VenueMapUrl` is bound from the wire but never read: the map button builds its own Google Maps search URL from `VenueAddress` instead (`:70-71`, `ADCHome.razor:248-258`). Whether the field is kept for a planned direct-map link is not determinable from source. +- **Concept introduced: the page-local wire model.** [Rubric §9, API and Contract Design] assesses whether consumers bind to explicit, minimal contracts rather than reaching for the server's internal types. The landing page needs ten fields (`Id`, `Name`, `Description?`, `StartDate`, `EndDate`, `TimeZone`, `VenueAddress?`, `VenueMapUrl?`, `SponsorshipPacketUrl?`, `TicketingUrl?`, `:299-309`) out of the much larger event DTO the API serves, so it declares exactly those and lets the serializer ignore the rest. Because the record is private to the component, no other page can accidentally couple to it; a second consumer declares its own projection. Every optional field is nullable, which is what lets the page fall back to hard-coded defaults without null checks scattered through the markup. +- **Walkthrough**: a positional record with no methods. `Name` feeds the `EventName` property and therefore `HeroTitleParts()` (`:79`, `:93`); `Description` feeds `EventDescription`, falling back to the localized `Fallback.EventDescription` resource (`:81`); `StartDate`/`EndDate`/`TimeZone` are the three inputs `UpdateCountdown()` converts into the UTC live window (`:248-252`); `VenueAddress` backs the venue block and the Google Maps search URL (`:83-86`); `Id` is the filter key that keeps a second published edition's sponsors off the page (`:228`); `SponsorshipPacketUrl` gates the whole sponsorship call to action block, heading and button included (`ADCHome.razor:310-329`); `TicketingUrl` gates the hero's "get tickets" button the same way (`ADCHome.razor:71-79`), so an event that has not opened sales renders no button rather than a dead link. +- **Why it's built this way**: the page must render before, during, and after the API call, so it stores a single nullable `ADCEventInfo? _event` (`:65`) and every derived property is written as `_event?.X ?? `. One nullable field is the whole "loaded or not" state machine, with no extra flags. The two ticketing surfaces sit on opposite sides of that line: the conference-day button reads the event field, while the pre-conference workshop button reads the fixed `PreConferenceTicketingUrl` constant (`:30-31`), because the workshop day sells through its own TicketLeap page. +- **Where it's used**: the `Items` list of [ADCCollectionResult](#adccollectionresult) (`:297`), selected by `CurrentEventSelector.SelectCurrentOrNext` in `LoadEventAsync` (`:180-185`), used as the sponsor filter key in `LoadSponsorsAsync` (`:213`, `:228`), and read by every derived display property on [ADCHome](#adchome). +- **Caveats / not-in-source**: `VenueMapUrl` is bound from the wire (`:307`) but never read by this page: the map button builds its own Google Maps search URL from `VenueAddress` instead (`:85-86`, `ADCHome.razor:353-363`). The field is a real event column, edited and displayed on the organizer event page (`MMCA.ADC.Conference.UI/Pages/Event/EventDetail.razor:68-69`, `:131`); why the landing page projects it without using it is not determinable from source. ### ConferenceRoutePaths > MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI` · `MMCA.ADC.Conference.UI/ConferenceRoutePaths.cs:8` · Level 0 · class (static) - **What it is**: one static class holding every Conference UI route, as `public static readonly string` constants for fixed paths and small factory methods for id-bearing paths. It covers the organizer management routes, the public attendee routes, the speaker surfaces, and the two QR landing links, so no `@page` directive or `NavigateTo` call has to hard-code a URL. -- **Depends on**: no first-party types. It uses the module's identifier aliases (`EventIdentifierType`, `SessionIdentifierType`, `SpeakerIdentifierType`, `ConferenceCategoryIdentifierType`, `QuestionIdentifierType`, `RoomIdentifierType`, `SponsorIdentifierType`) that the Conference Shared project declares as `global using` (see the primer on identifier-type aliases), plus `System.Globalization.CultureInfo` (`:1`). -- **Concept introduced: a centralized navigation vocabulary.** [Rubric §25, Navigation and Information Architecture] assesses whether routes form a coherent, role-aware information architecture instead of scattered magic strings; this class is that story in miniature. The paths split into two deliberate namespaces mirroring the module's two audiences: organizers work under bare prefixes (`/events` `:10`, `/sessions` `:14`, `/speakers` `:18`, `/conferencecategories` `:22`, `/questions` `:26`, `/rooms` `:30`, `/sponsors` `:34`) while attendees work under a `/conference/...` prefix (`PublicSessions` `:39`, `PublicEvents` `:40`, `PublicSpeakers` `:43`, `PublicSponsors` `:45`). Detail routes are methods rather than constants because they interpolate a typed id: `EventDetails(EventIdentifierType id)` (`:12`) builds `/events/{id}` with `string.Create(CultureInfo.InvariantCulture, ...)` so an integer id can never be formatted with a culture-specific group separator. [Rubric §27, Internationalization] shows up here as the negative case: URLs are the one place culture-aware formatting must be suppressed. -- **Walkthrough**: the file is a flat list grouped by entity, each group contributing a list route, a create route, and a details factory: events (`:10-12`), sessions (`:14-16`), speakers (`:18-20`), conference categories (`:22-24`), questions (`:26-28`), rooms (`:30-32`), sponsors (`:34-36`). The public attendee block follows (`:38-45`), then the speaker surfaces `SpeakerDashboard` and `SpeakerQr` (`:48-49`), two QR self-service links, the organizer feedback factories, and the selection dashboard. - - **The two QR links are deliberate duplicates** (`:55-56`). `SponsorVisitLink` and `RoomCheckInLink` build `/engage/sponsors/{id}` and `/engage/rooms/{id}`, but those two pages are owned by Engagement.UI. The comment (`:51-54`) records the reason: Conference.UI must not reference Engagement.UI, yet the organizer print surfaces need the URL to encode into a QR code, and [EngagementRoutePaths](group-22-engagement-module.md#engagementroutepaths) duplicates a Conference session route the same way. Both are consumed by a `QrCodeButton` on the sponsor and room detail pages (`MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorDetail.razor:93`, `MMCA.ADC.Conference.UI/Pages/Room/RoomDetail.razor:56`). - - **Feedback routes nest under their parent entity**: `EventFeedbackOrganizer` gives `/events/{id}/feedback` and `SessionFeedbackOrganizer` gives `/sessions/{id}/feedback` (`:59-60`), so the URL itself expresses the ownership hierarchy. `SessionSelectionDashboard` closes the file (`:63`). - - Two factories differ from the rest: `SpeakerDetails` (`:20`) and `PublicSpeakerDetails` (`:44`) use plain interpolation rather than `string.Create(CultureInfo.InvariantCulture, ...)`, because `SpeakerIdentifierType` is a `Guid` whose `ToString()` is already culture-invariant. +- **Depends on**: no first-party types. It uses the module's identifier aliases (`EventIdentifierType`, `SessionIdentifierType`, `SpeakerIdentifierType`, `ConferenceCategoryIdentifierType`, `QuestionIdentifierType`, `RoomIdentifierType`, `SponsorIdentifierType`, `ActivityIdentifierType`) that the Conference Shared project declares as `global using` (see the primer on identifier-type aliases), plus `System.Globalization.CultureInfo` (`:1`). +- **Concept introduced: a centralized navigation vocabulary.** [Rubric §25, Navigation and Information Architecture] assesses whether routes form a coherent, role-aware information architecture instead of scattered magic strings; this class is that story in miniature. The paths split into two deliberate namespaces mirroring the module's two audiences: organizers work under bare prefixes (`/events` `:10`, `/sessions` `:14`, `/speakers` `:18`, `/conferencecategories` `:22`, `/questions` `:26`, `/rooms` `:30`, `/sponsors` `:34`, `/activities` `:38`) while attendees work under a `/conference/...` prefix (`PublicSessions` `:43`, `PublicEvents` `:44`, `PublicSpeakers` `:47`, `PublicSponsors` `:49`, `PublicActivities` `:50`). Detail routes are methods rather than constants because they interpolate a typed id: `EventDetails(EventIdentifierType id)` (`:12`) builds `/events/{id}` with `string.Create(CultureInfo.InvariantCulture, ...)` so an integer id can never be formatted with a culture-specific group separator. [Rubric §27, Internationalization] shows up here as the negative case: URLs are the one place culture-aware formatting must be suppressed. +- **Walkthrough**: the file is a flat list grouped by entity, each group contributing a list route, a create route, and a details factory: events (`:10-12`), sessions (`:14-16`), speakers (`:18-20`), conference categories (`:22-24`), questions (`:26-28`), rooms (`:30-32`), sponsors (`:34-36`), activities (`:38-40`). The public attendee block follows (`:42-50`), then the speaker surfaces `SpeakerDashboard` and `SpeakerQr` (`:52-54`), two QR self-service links, the organizer feedback factories, and the selection dashboard. + - **The two QR links are deliberate duplicates** (`:60-61`). `SponsorVisitLink` and `RoomCheckInLink` build `/engage/sponsors/{id}` and `/engage/rooms/{id}`, but those two pages are owned by Engagement.UI. The comment (`:56-59`) records the reason: Conference.UI must not reference Engagement.UI, yet the organizer print surfaces need the URL to encode into a QR code, and [EngagementRoutePaths](group-22-engagement-module.md#engagementroutepaths) duplicates a Conference session route the same way. Both are consumed by a `QrCodeButton` on the sponsor and room detail pages (`MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorDetail.razor:93`, `MMCA.ADC.Conference.UI/Pages/Room/RoomDetail.razor:56`). + - **Feedback routes nest under their parent entity**: `EventFeedbackOrganizer` gives `/events/{id}/feedback` and `SessionFeedbackOrganizer` gives `/sessions/{id}/feedback` (`:64-65`), so the URL itself expresses the ownership hierarchy. `SessionSelectionDashboard` closes the file (`:68`). + - Two factories differ from the rest: `SpeakerDetails` (`:20`) and `PublicSpeakerDetails` (`:48`) use plain interpolation rather than `string.Create(CultureInfo.InvariantCulture, ...)`, because `SpeakerIdentifierType` is a `Guid` whose `ToString()` is already culture-invariant. - **Why it's built this way**: if the admin prefix ever moves (say `/events` becomes `/admin/events`), editing the one constant propagates the change to every navigation call, with no grep-and-replace and no risk of a stale link. Keeping the parameterized routes as methods typed against the identifier aliases means a wrong-entity id is a compile error, not a 404. -- **Where it's used**: every Conference UI Blazor page's `@page` directive and `NavigationManager.NavigateTo` call (for example `MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorDetail.razor.cs:226`), the "see all sponsors" link on the landing page (`ADCHome.razor:201`), the `NavItems` collection in [ConferenceUIModule](#conferenceuimodule) (`ConferenceUIModule.cs:21-38`), and even one Engagement page, which links back to `PublicSponsors` (`MMCA.ADC.Engagement.UI/Pages/Sponsors/SponsorVisit.razor:42`). +- **Where it's used**: every Conference UI Blazor page's `@page` directive and `NavigationManager.NavigateTo` call (for example `MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorDetail.razor.cs:226`), the "see all sponsors" link on the landing page (`ADCHome.razor:303`), the `NavItems` collection in [ConferenceUIModule](#conferenceuimodule) (`ConferenceUIModule.cs:21-40`), and even one Engagement page, which links back to `PublicSponsors` (`MMCA.ADC.Engagement.UI/Pages/Sponsors/SponsorVisit.razor:42`). ### ConferenceTrackInfo -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Home` · `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:341` · Level 0 · record (sealed, private) +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Home` · `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:372` · Level 0 · record (sealed, private) - **What it is**: one row of the landing page's track catalogue: a track `Name`, an `Icon` (a MudBlazor icon path constant), and a `Topics` string listing the track's subject areas. - **Depends on**: no first-party types. The `Icon` values are MudBlazor `Icons.Material.Filled.*` constants (external). -- **Concept introduced**: this is the second of the two static-content records on the landing page; the pattern is introduced under [KeynoteSpeakerInfo](#keynotespeakerinfo). -- **Walkthrough**: a three-property positional record (`:341`). The whole catalogue is a `private static readonly ConferenceTrackInfo[] Tracks` (`:320`) initialized inline as a collection expression (`:321-338`) with eight entries (`:322`, `:324`, `:326`, `:328`, `:330`, `:332`, `:334`, `:336`), each spanning two lines: the track name and its icon constant on the first, the topics blurb on the second. They run from "Foundations (Beginner & Student)" (`:322`) to "Career, Leadership & Community" (`:336`), with the two AI tracks, languages, cross-platform, security, and game/XR in between. Every `Icon` is a MudBlazor `Icons.Material.Filled.*` constant picked per track (`School`, `Psychology`, `AutoAwesome`, `Code`, `Devices`, `Security`, `SportsEsports`, `Groups`). Storing the icon as a `string` (rather than a `RenderFragment` or an enum) is what keeps the record a plain data type: the markup passes it straight to `` (`ADCHome.razor:138`). -- **Why it's built this way**: the track list changes once per conference cycle and is editorial rather than transactional, so it lives in the assembly instead of behind an API call or a CMS; the `// i18n: allow` marker above the block names "the track catalog" alongside the keynote bio as deliberately English-only editorial content (`:307-308`). The array is `static readonly`, so it is allocated once per process, not per render. -- **Where it's used**: the `Tracks` array on [ADCHome](#adchome) (`:320`), rendered as the track grid in `ADCHome.razor` (`:130-147`): one `MudItem`/`MudCard` per entry, keyed by `track.Name` (`ADCHome.razor:133`), showing the icon (`:138`), the name (`:140`), and the topics line (`:142`). +- **Concept introduced**: this is one of three static-content records on the landing page; the pattern is introduced under [KeynoteSpeakerInfo](#keynotespeakerinfo) and reused by [PreConferenceWorkshopInfo](#preconferenceworkshopinfo). +- **Walkthrough**: a three-property positional record (`:372`). The whole catalogue is a `private static readonly ConferenceTrackInfo[] Tracks` (`:336`) initialized inline as a collection expression (`:337-354`) with eight entries (`:338`, `:340`, `:342`, `:344`, `:346`, `:348`, `:350`, `:352`), each spanning two lines: the track name and its icon constant on the first, the topics blurb on the second. They run from "Foundations (Beginner & Student)" (`:338`) to "Career, Leadership & Community" (`:352`), with the two AI tracks, languages, cross-platform, security, and game/XR in between. Every `Icon` is a MudBlazor `Icons.Material.Filled.*` constant picked per track (`School`, `Psychology`, `AutoAwesome`, `Code`, `Devices`, `Security`, `SportsEsports`, `Groups`). Storing the icon as a `string` (rather than a `RenderFragment` or an enum) is what keeps the record a plain data type: the markup passes it straight to `` (`ADCHome.razor:237`). +- **Why it's built this way**: the track list changes once per conference cycle and is editorial rather than transactional, so it lives in the assembly instead of behind an API call or a CMS; the `// i18n: allow` marker above the block names "the track catalog" alongside the keynote bio as deliberately English-only editorial content (`:323-324`). The array is `static readonly`, so it is allocated once per process, not per render. +- **Where it's used**: the `Tracks` array on [ADCHome](#adchome) (`:336`), rendered as the track grid in `ADCHome.razor` (`:229-246`): one `MudItem`/`MudCard` per entry, keyed by `track.Name` (`ADCHome.razor:232`), showing the icon (`:237`), the name (`:239`), and the topics line (`:241`). ### EventPhase -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Home` · `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:57` · Level 0 · enum (private) +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Home` · `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:72` · Level 0 · enum (private) -- **What it is**: the three-state classification of the featured event relative to now: `Upcoming`, `Live`, `Ended` (`:57-62`). It is the single switch the landing page's hero renders from. +- **What it is**: the three-state classification of the featured event relative to now: `Upcoming`, `Live`, `Ended` (`:72-77`). It is the single switch the landing page's hero renders from. - **Depends on**: nothing. -- **Concept introduced: deriving a render state from a clock instead of storing it.** [Rubric §19, State Management and Data Flow] assesses whether UI state is derived from a single source of truth or duplicated into flags. There is no `IsLive` boolean anywhere on the page: `UpdateCountdown()` recomputes `_phase` from `DateTime.UtcNow` against the converted UTC window every time it runs (`:254-260`), and the markup branches on that one field. Recomputing rather than storing means a stale phase is impossible after a timer callback, a parameter change, or the interactive render pass that follows prerender. -- **Walkthrough**: the assignment is a switch expression over `now` (`:255-260`): `now < _startUtc` gives `Upcoming`, `now < _endUtc` gives `Live`, anything later gives `Ended`. `ArmPhaseTimerForEventEnd()` reads it as its guard, returning immediately unless the phase is `Live` (`:130-133`), which is what makes the Live-to-Ended timer a single one-shot rather than a recurring tick. In the markup, `Upcoming` renders the `HomeCountdown` child, `Live` renders the "event live" chip plus a button to `/happening-now`, and `Ended` renders the post-event chip (`ADCHome.razor:33-65`). +- **Concept introduced: deriving a render state from a clock instead of storing it.** [Rubric §19, State Management and Data Flow] assesses whether UI state is derived from a single source of truth or duplicated into flags. There is no `IsLive` boolean anywhere on the page: `UpdateCountdown()` recomputes `_phase` from `DateTime.UtcNow` against the converted UTC window every time it runs (`:269-275`), and the markup branches on that one field. Recomputing rather than storing means a stale phase is impossible after a timer callback, a parameter change, or the interactive render pass that follows prerender. +- **Walkthrough**: the assignment is a switch expression over `now` (`:270-275`): `now < _startUtc` gives `Upcoming`, `now < _endUtc` gives `Live`, anything later gives `Ended`. `ArmPhaseTimerForEventEnd()` reads it as its guard, returning immediately unless the phase is `Live` (`:145-148`), which is what makes the Live-to-Ended timer a single one-shot rather than a recurring tick. In the markup, `Upcoming` renders the `HomeCountdown` child (`ADCHome.razor:36-41`), `Live` renders the "event live" chip plus a button to `/happening-now` (`ADCHome.razor:42-56`), and `Ended` renders the post-event chip (`ADCHome.razor:57-65`). The hero's ticketing button sits outside that branch (`ADCHome.razor:67-80`), so it shows in every phase the event publishes a URL. - **Why it's built this way**: three named states read far better at the call site than nested date comparisons, and keeping the enum private to the component signals it is a view concern, not a domain concept. The domain's own notion of a live window lives server-side and in [CurrentEventSelector](group-17-conference-domain.md#currenteventselector). -- **Where it's used**: the `_phase` field on [ADCHome](#adchome) (`:49`) and its Razor markup only. +- **Where it's used**: the `_phase` field on [ADCHome](#adchome) (`:64`) and its Razor markup only. + +### InfiniteScrollSentinel +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Components` · `MMCA.ADC.Conference.UI/Components/InfiniteScrollSentinel.razor.cs:21` · Level 0 · class (partial component) + +- **What it is**: a one-`div` child component that a list page renders below its last item. When that `div` scrolls to within 200px of the viewport, the component raises an `OnVisible` callback so the page can fetch and append its next page. It owns the browser observer and nothing else: the item markup, the fetch, and the accumulated list all stay with the host page. +- **Depends on**: `IJSRuntime` (`:23`), `ElementReference`, `EventCallback`, and `DotNetObjectReference` from Blazor, plus the shared JavaScript module `_content/MMCA.Common.UI/infinite-scroll.js` shipped by `MMCA.Common.UI` (`MMCA.Common.UI/wwwroot/infinite-scroll.js`). No first-party C# types. +- **Concept introduced: the JS-interop observer wrapped as a disposable child component.** [Rubric §23, Front-End Performance and Rendering] assesses whether a UI loads work incrementally instead of rendering everything up front; an `IntersectionObserver` is the browser-native way to do that, and it costs no scroll-event handler and no polling. The interop is two-way: C# imports the module and calls `observe` (`:63-66`), and JavaScript calls back into C# by name through `dotNetRef.invokeMethodAsync('OnSentinelVisible')` (`infinite-scroll.js:8`), which is why the `[JSInvokable]` method's name is fixed and the code comment says so (`:41-44`). Each instance mints its own `_observerId` from a `Guid` (`:34`) so the module's `observers` map can detach exactly this one on teardown (`infinite-scroll.js:1`, `:16-21`). + The second idea is the *reason it is a separate component at all*, spelled out in the class doc (`:6-19`). The shared `MobileInfiniteScrollList` drives the same JS module (`MMCA.Common.UI/Components/MobileInfiniteScrollList.razor.cs:86`) but also owns the item markup, which a page with its own card grid cannot use. And a page deriving from [DataGridListPageBase](group-15-common-ui-framework.md#datagridlistpagebasetdto) cannot hook async disposal to detach an observer, because that base's `DisposeAsync` is not virtual (`MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:725`). Putting the observer in a child solves both: the renderer disposes the child the moment the host stops rendering it, which is exactly when the last page has loaded. [Rubric §1, SOLID] is the underlying move, single responsibility applied to a lifecycle rather than to data. +- **Walkthrough**: + - **Parameters** (`:26-32`): `OnVisible` is the `EventCallback` the host binds its load-more method to, `IsLoading` renders the inline progress row while that fetch is in flight, and `LoadingLabel` is the accessible name for it, localized by the host per [ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html). + - **State** (`:34-39`): the per-instance `_observerId`, the `ElementReference` bound with `@ref` in the markup (`InfiniteScrollSentinel.razor:4`), the imported `IJSObjectReference`, the `DotNetObjectReference`, and two flags, `_observing` and `_disposed`. + - **`OnSentinelVisible`** (`:45-47`): the `[JSInvokable]` entry point. It short-circuits to `Task.CompletedTask` when already disposed, otherwise marshals onto the renderer's synchronization context with `InvokeAsync` before raising `OnVisible`. Both halves matter: the call arrives from a JS callback on an arbitrary context, and a disposed component must not raise into a torn-down host. + - **`OnAfterRenderAsync`** (`:49-57`): attaches on `firstRender` only, and only when not disposed. Attaching after render is required because `_sentinelRef` is not populated until the element exists. + - **`AttachObserverAsync`** (`:59-74`): imports the module lazily with `??=`, creates the `DotNetObjectReference` the same way, calls `observe` with the reference, the element, and the id, then sets `_observing`. It catches `JSDisconnectedException` and does nothing, with the comment recording the consequence (`:71-72`): during prerendering or circuit teardown there is no JS to talk to, so the list simply stops at the pages already loaded rather than failing the render. + - **`DisposeAsync`** (`:76-111`): `GC.SuppressFinalize` first, then an idempotency guard on `_disposed`, then a best-effort detach: `unobserve` only if the observer was actually attached, then `DisposeAsync` on the module. Two catch arms swallow `JSDisconnectedException` (circuit already gone) and `JSException` (shutdown-time interop races), and the `finally` always disposes the `DotNetObjectReference` so the managed reference cannot leak even when interop fails. + - **Markup** (`InfiniteScrollSentinel.razor:1-14`): the sentinel `div`, and inside it, only while `IsLoading`, a `MudProgressCircular` in a wrapper carrying `role="status"`, `aria-live="polite"`, and `aria-busy="true"` (`:9`). [Rubric §21, Accessibility]: the comment states the intent (`:7-8`), a screen reader hears that more items are loading without the announcement interrupting reading, matching `PageLoadingState`'s politeness, and the spinner carries the host's localized `aria-label` (`:11`). The class names are the shared ones from `MMCA.Common.UI`'s stylesheet, so the sentinel looks identical here and inside `MobileInfiniteScrollList` (`InfiniteScrollSentinel.razor:1-3`). +- **Why it's built this way**: the host must render the sentinel **only while more pages exist**, which the class doc states as a usage contract (`:18-19`). That is not just an optimization: absence of the sentinel is what stops the observer from firing at the end of the list, and a filter reset that refills the list gets a fresh instance and therefore a fresh observer. [Rubric §16, Maintainability]: the contract is one line of markup at the call site rather than a `Reset()` method on this component. +- **Where it's used**: the public speaker card grid, [PublicSpeakerList](#publicspeakerlist), which renders it under `@if (HasMoreSpeakers)` and binds `OnVisible` to its `LoadMoreSpeakersAsync` (`MMCA.ADC.Conference.UI/Pages/Public/PublicSpeakerList.razor:133-146`). The comment there records the same contract from the host's side (`:131-132`): the sentinel exists only while pages remain, so its absence is the "everything is loaded" signal for the reader and for the tests. When a load fails, the page swaps the sentinel for an error row with a retry button (`:135-141`), which also detaches the observer until the reader asks again. ### KeynoteSpeakerInfo -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Home` · `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:340` · Level 0 · record (sealed, private) +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Home` · `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:371` · Level 0 · record (sealed, private) -- **What it is**: the keynote block's content: the speaker's `Name`, `Title` (their role), the `TalkTitle`, an optional `PhotoFileName`, and `BioParagraphs` as a `string[]` (`:340`). +- **What it is**: the keynote block's content: the speaker's `Name`, `Title` (their role), the `TalkTitle`, an optional `PhotoFileName`, and `BioParagraphs` as a `string[]` (`:371`). - **Depends on**: no first-party types. BCL only. -- **Concept introduced: the two-tier content model of a landing page.** The page splits its content into *dynamic* data fetched from the API (dates, venue, name, sponsors, via [ADCEventInfo](#adceventinfo) and [ADCSponsorInfo](#adcsponsorinfo)) and *editorial* data compiled into the assembly (keynote and tracks, via this record and its sibling [ConferenceTrackInfo](#conferencetrackinfo)). [Rubric §23, Front-End Performance and Rendering] is the payoff: the keynote and the track grid render on the first frame with zero network dependency, so a cold or unreachable backend degrades only the countdown and the sponsor strip, never the page. [Rubric §27, Internationalization] is the deliberate exception: the block carries an explicit `// i18n: allow` marker with a written reason (`:307-308`) recording that this English-only editorial content is the same copy the API would serve, while the chrome around it is localized. That marker convention is how [ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html) distinguishes "not yet translated" from "intentionally untranslated". -- **Walkthrough**: a five-property positional record (`:340`). The single instance is a `private static readonly KeynoteSpeakerInfo Keynote` initialized inline (`:309-318`): Jared Rhodes, "Microsoft MVP and Principal Engineer", the talk "More Software, Different Work", `PhotoFileName: "jared-rhodes.jpg"` (`:313`), and a three-paragraph biography (`:315-317`). `BioParagraphs` is an array rather than one string so the template can emit each paragraph in its own element instead of relying on whitespace preservation (`ADCHome.razor:103-106`). The record carries only the portrait's *file name*, not a path: the usable `src` is composed from the head-specific `ImageBasePath` parameter through `KeynoteImageSrc`, which returns `$"{ImageBasePath}/speakers/{fileName}"` or `null` when no file name is supplied (`:42-43`). That split exists because a head could package its assets elsewhere, and the `null` case is still guarded in the markup so the card renders name and title without a portrait (`ADCHome.razor:83-92`). +- **Concept introduced: the two-tier content model of a landing page.** The page splits its content into *dynamic* data fetched from the API (dates, venue, name, ticketing link, sponsors, via [ADCEventInfo](#adceventinfo) and [ADCSponsorInfo](#adcsponsorinfo)) and *editorial* data compiled into the assembly (keynote, tracks, and workshops, via this record and its siblings [ConferenceTrackInfo](#conferencetrackinfo) and [PreConferenceWorkshopInfo](#preconferenceworkshopinfo)). [Rubric §23, Front-End Performance and Rendering] is the payoff: the keynote, the workshop cards, and the track grid render on the first frame with zero network dependency, so a cold or unreachable backend degrades only the countdown, the hero ticketing button, and the sponsor strip, never the page. [Rubric §27, Internationalization] is the deliberate exception: the block carries an explicit `// i18n: allow` marker with a written reason (`:323-324`) recording that this English-only editorial content is the same copy the API would serve, while the chrome around it is localized. That marker convention is how [ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html) distinguishes "not yet translated" from "intentionally untranslated". +- **Walkthrough**: a five-property positional record (`:371`). The single instance is a `private static readonly KeynoteSpeakerInfo Keynote` initialized inline (`:325-334`): Jared Rhodes, "Microsoft MVP and Principal Engineer", the talk "More Software, Different Work", `PhotoFileName: "jared-rhodes.jpg"` (`:329`), and a three-paragraph biography (`:331-333`). `BioParagraphs` is an array rather than one string so the template can emit each paragraph in its own element instead of relying on whitespace preservation (`ADCHome.razor:199-202`). The record carries only the portrait's *file name*, not a path: the usable `src` is composed from the head-specific `ImageBasePath` parameter through `KeynoteImageSrc`, which returns `$"{ImageBasePath}/speakers/{fileName}"` or `null` when no file name is supplied (`:57-58`). That split exists because a head could package its assets elsewhere, and the `null` case is still guarded in the markup so the card renders name and title without a portrait (`ADCHome.razor:179-192`). - **Why it's built this way**: the keynote changes once per conference cycle, so a database round-trip and an admin screen would be pure overhead. Keeping it `static readonly` also means it is shared by every circuit on the server head rather than re-allocated per user. -- **Where it's used**: the `Keynote` field on [ADCHome](#adchome) (`:309`), read by `KeynoteImageSrc` (`:42-43`) and rendered in the keynote section of `ADCHome.razor` (`:70-111`). +- **Where it's used**: the `Keynote` field on [ADCHome](#adchome) (`:325`), read by `KeynoteImageSrc` (`:57-58`) and rendered in the keynote section of `ADCHome.razor` (`:163-207`). + +### PreConferenceWorkshopInfo +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Home` · `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:379` · Level 0 · record (sealed, private) + +- **What it is**: one pre-conference workshop card: a resource-key stem `Key`, the workshop `Title`, the `Presenter` name, and an `Icon` (`:379`). Two instances make up the whole workshops section. +- **Depends on**: no first-party types. The `Icon` values are MudBlazor `Icons.Material.Filled.*` constants (external). +- **Concept introduced: the half-localized content record.** [Rubric §27, Internationalization] assesses whether user-facing text resolves through resources rather than sitting in code, and this record is the interesting middle case that the other two content records do not show. Instead of choosing "all in code" or "all in resources", it splits by *kind of string*: proper nouns stay in code with `// i18n: allow` markers (workshop titles `:362`, `:366`; presenter names `:363`, `:367`) because translating a talk title or a person's name would be wrong, while the prose that describes each workshop lives in the `.resx` pair. The bridge is `Key`, documented on the record itself (`:374-378`): the markup composes `Workshops.{Key}.Audience` and `Workshops.{Key}.Description` at render time (`ADCHome.razor:136`, `:139`), and those keys exist in both locales (`ADCHome.resx:26-29`). One consequence worth noticing: adding a workshop means adding four resource entries per locale, and a typo in `Key` fails at runtime as a missing resource rather than at compile time. +- **Walkthrough**: a four-property positional record (`:379`). The catalogue is a `private static readonly PreConferenceWorkshopInfo[] Workshops` (`:359`) with two entries: `"ModularMonolith"` presented by Ivan Ball-llovera with the `Hub` icon (`:361-364`), and `"SoftwareFactory"` presented by Tim Rayburn with the `PrecisionManufacturing` icon (`:365-368`). The comment above the array states the split in one sentence (`:356-358`). The markup renders the array as a two-column grid (`ADCHome.razor:120-145`), keyed by `workshop.Key` (`:123`), each card showing the icon in a circle (`:128`), the title (`:130`), the localized `Workshops.PresenterLabel` formatted with the presenter name (`:132-134`), the audience line (`:135-137`), and the description (`:138-140`). +- **Why it's built this way**: the workshop day runs before the conference day, and the section is placed between the hero and the keynote so the page reads in the same order as the event (`ADCHome.razor:86-88`). Two facts the cards would otherwise repeat, the schedule and the venue, are hoisted into one shared logistics line above the grid (`ADCHome.razor:107-118`), which is why they are resources (`Workshops.Schedule`, `Workshops.Venue`) rather than record fields. Ticketing is the other deliberate asymmetry: the workshop day sells on its own TicketLeap page, so its call to action reads the `PreConferenceTicketingUrl` constant (`:30-31`) and always renders (`ADCHome.razor:147-159`), while the hero's conference-day button reads `TicketingUrl` off the featured event and hides itself when absent. The constant carries an `S1075` suppression with the reason inline (`:27-32`): it is a published product page, not an environment-dependent path, so there is nothing to configure. [Rubric §26, Front-End Security]: the workshop ticketing button opens with `Target="_blank"` together with `rel="noopener noreferrer"` (`ADCHome.razor:155`). +- **Where it's used**: the `Workshops` array on [ADCHome](#adchome) (`:359`) and the workshops section of `ADCHome.razor` (`:85-161`) only. ### ADCCollectionResult -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Home` · `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:282` · Level 1 · record (sealed, private) +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Home` · `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:297` · Level 1 · record (sealed, private) -- **What it is**: the one-property envelope the landing page deserializes the `events` response into: `List? Items` (`:282`). It exists because the API returns a collection *envelope*, not a bare array. +- **What it is**: the one-property envelope the landing page deserializes the `events` response into: `List? Items` (`:297`). It exists because the API returns a collection *envelope*, not a bare array. - **Depends on**: [ADCEventInfo](#adceventinfo) (its element type), which is what puts it one level above the plain records. - **Concept introduced: mirroring only the slice of the envelope you consume.** The API's uniform collection contract is [CollectionResult](group-01-result-error-handling.md#collectionresultt), which carries more than a list. Rather than referencing that type, the page declares a minimal structural twin containing just `Items`, keeping the landing page free of any dependency on the API's shared contract assembly. [Rubric §9, API and Contract Design]: the wire format is honoured, the coupling is not. -- **Walkthrough**: consumed in exactly one place, `LoadEventAsync` (`:161`): `await client.GetFromJsonAsync("events", ApiJsonOptions, _cts!.Token)`. The `ApiJsonOptions` field is a `JsonSerializerOptions(JsonSerializerDefaults.Web)` allocated once as `static readonly` (`:19`), which is what makes the camelCase wire names bind to the PascalCase record properties. `Items` is nullable and immediately coalesced to an empty collection at the call site (`result?.Items ?? []`, `:166`), so a null body, a null `Items`, and an empty list all take the same path. +- **Walkthrough**: consumed in exactly one place, `LoadEventAsync` (`:176`): `await client.GetFromJsonAsync("events", ApiJsonOptions, _cts!.Token)`. The `ApiJsonOptions` field is a `JsonSerializerOptions(JsonSerializerDefaults.Web)` allocated once as `static readonly` (`:34`), which is what makes the camelCase wire names bind to the PascalCase record properties. `Items` is nullable and immediately coalesced to an empty collection at the call site (`result?.Items ?? []`, `:181`), so a null body, a null `Items`, and an empty list all take the same path. - **Why it's built this way**: `GetFromJsonAsync` returns `null` for an empty response body, so the nullable property plus the coalesce covers both failure shapes without a branch. -- **Where it's used**: [ADCHome](#adchome)`.LoadEventAsync` only (`:161`). +- **Where it's used**: [ADCHome](#adchome)`.LoadEventAsync` only (`:176`). ### ADCSponsorInfo -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Home` · `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:297` · Level 1 · record (sealed, private) +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Home` · `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:313` · Level 1 · record (sealed, private) -- **What it is**: the landing page's projection of one sponsor: `Id`, `Name`, `Tier`, `LogoUrl?`, `WebsiteUrl?`, `Sort`, and `EventId` (`:297-304`). Seven fields, exactly what the sponsor logo strip renders and sorts by. +- **What it is**: the landing page's projection of one sponsor: `Id`, `Name`, `Tier`, `LogoUrl?`, `WebsiteUrl?`, `Sort`, and `EventId` (`:313-320`). Seven fields, exactly what the sponsor logo strip renders and sorts by. - **Depends on**: [SponsorTier](group-17-conference-domain.md#sponsortier) from `MMCA.ADC.Conference.Shared.Sponsors` (imported at `:5`). That one shared enum is the single first-party type the landing page's wire models reference, and it is what raises this record above level 0. -- **Concept introduced: sharing the enum, not the DTO.** The page could have referenced the API's [SponsorDTO](group-17-conference-domain.md#sponsordto) and taken everything with it. Instead it declares its own seven-field record and imports only `SponsorTier`, because the tier is a *domain vocabulary* term whose numeric ordering is load-bearing here: `Platinum = 0`, `Gold = 1`, `Silver = 2` (`MMCA.ADC.Conference.Shared/Sponsors/SponsorTier.cs:15-21`), so an `OrderBy(g => g.Key)` on the enum value yields package order without a lookup table (`:215`). Re-declaring the enum locally would have duplicated that ordering contract in a place no test guards. [Rubric §9, API and Contract Design] is the balance being struck: copy the shape, share the vocabulary. -- **Walkthrough**: a positional record with no methods, used only inside `LoadSponsorsAsync` and the markup. `Tier` is the grouping key (`:214`), `Sort` then `Name` are the intra-tier tie-breakers (`:218`), `EventId` is the filter that scopes the strip to the featured event (`:214`), and `LogoUrl`/`WebsiteUrl` drive a four-way render fallback in the markup (`ADCHome.razor:171-194`): linked logo, linked name, bare logo, or bare name, depending on which of the two optional URLs are present. [Rubric §26, Front-End Security] is visible in that block: the outbound sponsor link carries `Target="_blank"` together with `rel="noopener noreferrer"` (`ADCHome.razor:174`), so a sponsor site can never reach back through `window.opener`. [Rubric §21, Accessibility]: the link also carries a localized `aria-label` built from the sponsor name (`ADCHome.razor:175`), and the logo image its `Alt` (`ADCHome.razor:178`), so a logo-only card is still announced. -- **Why it's built this way**: `Sort` exists so organizers can order sponsors inside a tier by hand, and the code comment states why the sort is explicit at all (`:208-209`): tier ascending is package order, and `Sort` then `Name` breaks ties so the strip is deterministic rather than dependent on insertion order. `StringComparer.CurrentCulture` on the name tie-break (`:218`) keeps that alphabetical fallback correct under the selected culture. -- **Where it's used**: the `Items` list of [ADCSponsorCollectionResult](#adcsponsorcollectionresult) (`:295`), the grouped `_sponsorTiers` field (`:53`), and the sponsor section of `ADCHome.razor` (`:151-229`). +- **Concept introduced: sharing the enum, not the DTO.** The page could have referenced the API's [SponsorDTO](group-17-conference-domain.md#sponsordto) and taken everything with it. Instead it declares its own seven-field record and imports only `SponsorTier`, because the tier is a *domain vocabulary* term whose numeric ordering is load-bearing here: `Platinum = 0`, `Gold = 1`, `Silver = 2` (`MMCA.ADC.Conference.Shared/Sponsors/SponsorTier.cs:15-21`), so an `OrderBy(g => g.Key)` on the enum value yields package order without a lookup table (`:230`). Re-declaring the enum locally would have duplicated that ordering contract in a place no test guards. [Rubric §9, API and Contract Design] is the balance being struck: copy the shape, share the vocabulary. +- **Walkthrough**: a positional record with no methods, used only inside `LoadSponsorsAsync` and the markup. `Tier` is the grouping key (`:229`), `Sort` then `Name` are the intra-tier tie-breakers (`:233`), `EventId` is the filter that scopes the strip to the featured event (`:228`), and `LogoUrl`/`WebsiteUrl` drive a four-way render fallback in the markup (`ADCHome.razor:274-295`): linked logo, linked name, bare logo, or bare name, depending on which of the two optional URLs are present. [Rubric §26, Front-End Security] is visible in that block: the outbound sponsor link carries `Target="_blank"` together with `rel="noopener noreferrer"` (`ADCHome.razor:276`), so a sponsor site can never reach back through `window.opener`. [Rubric §21, Accessibility]: the link also carries a localized `aria-label` built from the sponsor name (`ADCHome.razor:277`), and the logo image its `Alt` (`ADCHome.razor:280`), so a logo-only card is still announced. +- **Why it's built this way**: `Sort` exists so organizers can order sponsors inside a tier by hand, and the code comment states why the sort is explicit at all (`:223-224`): tier ascending is package order, and `Sort` then `Name` breaks ties so the strip is deterministic rather than dependent on insertion order. `StringComparer.CurrentCulture` on the name tie-break (`:233`) keeps that alphabetical fallback correct under the selected culture. +- **Where it's used**: the `Items` list of [ADCSponsorCollectionResult](#adcsponsorcollectionresult) (`:311`), the grouped `_sponsorTiers` field (`:68`), and the sponsor section of `ADCHome.razor` (`:250-331`). ### ADCSponsorCollectionResult -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Home` · `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:295` · Level 2 · record (sealed, private) +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Home` · `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:311` · Level 2 · record (sealed, private) -- **What it is**: the envelope for the `sponsors` response: `List? Items` (`:295`). It is the sponsor-side twin of [ADCCollectionResult](#adccollectionresult), declared separately because C# records are not structurally typed. +- **What it is**: the envelope for the `sponsors` response: `List? Items` (`:311`). It is the sponsor-side twin of [ADCCollectionResult](#adccollectionresult), declared separately because C# records are not structurally typed. - **Depends on**: [ADCSponsorInfo](#adcsponsorinfo), and transitively [SponsorTier](group-17-conference-domain.md#sponsortier). - **Concept introduced**: nothing new; the minimal-envelope idea is taught under [ADCCollectionResult](#adccollectionresult). -- **Walkthrough**: deserialized in `LoadSponsorsAsync` with the same shared `ApiJsonOptions` and the same cancellation token (`:206`), then reduced in one collection expression (`:210-219`): `result?.Items ?? []` for the null-safe start, `.Where(s => s.EventId == _event.Id)` to scope to the featured event, `.GroupBy(s => s.Tier)`, `.OrderBy(g => g.Key)` for package order, and a `Select` that materializes each group as a `KeyValuePair>` with its members ordered by `Sort` then `Name` (`:216-218`). The result lands in `_sponsorTiers` (`:53`), whose doc comment states the intent in one line: sponsors grouped by tier in package order, each group ordered by Sort then Name (`:52`). -- **Why it's built this way**: the method-level remarks (`:190-195`) record the two safety properties. The `sponsors` endpoint is the same anonymous read path as the events call and already scopes anonymous callers to sponsors of published events (`MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:72-89`, whose specification resolves published event ids in `MMCA.ADC.Conference.Application/Sponsors/UseCases/GetPublicSponsorFilter/GetPublicSponsorFilterHandler.cs:25-30`); the client-side `EventId` filter is the second half, so a second published edition's sponsors never bleed onto this page. And any failure leaves the list empty, which falls back to the sponsorship call to action rather than a blank strip. -- **Where it's used**: [ADCHome](#adchome)`.LoadSponsorsAsync` only (`:206`). +- **Walkthrough**: deserialized in `LoadSponsorsAsync` with the same shared `ApiJsonOptions` and the same cancellation token (`:221`), then reduced in one collection expression (`:225-234`): `result?.Items ?? []` for the null-safe start, `.Where(s => s.EventId == _event.Id)` to scope to the featured event (`:228`), `.GroupBy(s => s.Tier)` (`:229`), `.OrderBy(g => g.Key)` for package order (`:230`), and a `Select` that materializes each group as a `KeyValuePair>` with its members ordered by `Sort` then `Name` (`:231-233`). The result lands in `_sponsorTiers` (`:68`), whose doc comment states the intent in one line: sponsors grouped by tier in package order, each group ordered by Sort then Name (`:67`). +- **Why it's built this way**: the method-level remarks (`:205-210`) record the two safety properties. The `sponsors` endpoint is the same anonymous read path as the events call and already scopes anonymous callers to sponsors of published events (`MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:72-92`, whose specification is built at `:60-70` and resolves published event ids in `MMCA.ADC.Conference.Application/Sponsors/UseCases/GetPublicSponsorFilter/GetPublicSponsorFilterHandler.cs:25-30`); the client-side `EventId` filter is the second half, so a second published edition's sponsors never bleed onto this page. And any failure leaves the list empty, which falls back to the sponsorship call to action rather than a blank strip. +- **Where it's used**: [ADCHome](#adchome)`.LoadSponsorsAsync` only (`:221`). ### ConferenceUIModule > MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI` · `MMCA.ADC.Conference.UI/ConferenceUIModule.cs:14` · Level 3 · class (sealed) -- **What it is**: the Conference module's UI descriptor. It contributes the navigation items for the whole conference capability (public Events/Sessions/Speakers/Sponsors, the claim-gated speaker Dashboard and QR page, and an organizer admin group covering Events, Sessions, Speakers, Categories, Questions, Rooms, Sponsors, and Session Selection) and exposes its assembly so the host can discover the module's routable Blazor components. +- **What it is**: the Conference module's UI descriptor. It contributes the navigation items for the whole conference capability (public Events/Sessions/Speakers/Sponsors/Activities, the claim-gated speaker Dashboard and QR page, and an organizer admin group covering Events, Sessions, Speakers, Categories, Questions, Rooms, Sponsors, Activities, and Session Selection) and exposes its assembly so the host can discover the module's routable Blazor components. - **Depends on**: [IUIModule](group-15-common-ui-framework.md#iuimodule) (the contract it implements), [NavItem](group-15-common-ui-framework.md#navitem) and [NavSection](group-15-common-ui-framework.md#navsection) (the nav vocabulary from `MMCA.Common.UI.Common`), [RoleNames](group-08-auth.md#rolenames) (the `Organizer` role string), [ConferenceRoutePaths](#conferenceroutepaths) (the URLs), plus MudBlazor `Icons` and `System.Reflection.Assembly` (externals) and the co-located `ConferenceUIModule.resx` / `ConferenceUIModule.es.resx` pair. - **Concept introduced: the modular-UI descriptor, the front-end analogue of `IModule`.** [Rubric §18, UI Architecture and Component Design] assesses whether UI is composed from cohesive, self-describing modules rather than a hard-coded master shell; a module declaring its own menu is exactly that, and it is the Open/Closed half of [Rubric §1, SOLID]: enabling a module adds its navigation with no edit to the shell. [Rubric §25, Navigation and Information Architecture] is served because the items are role- and claim-aware and grouped into sections. [Rubric §11, Security] applies with an important caveat: hiding a nav item is UX only. The services still enforce authorization server-side, so the claim and role here are not the security boundary. Per [ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html) the `Title` and `Group` strings are resource *keys*, not literals: `TitleResource: typeof(ConferenceUIModule)` on every item tells the shared NavMenu to resolve them against the co-located `.resx` at render time, which the file's own comment records (`:16-17`). -- **Walkthrough**: `NavItems` (`:18-39`) is an `IReadOnlyList` initialized with a collection expression in three tiers, fourteen items in all. - - **Public** (`:21-24`): four items for everyone, anonymous included, pointing at the `/conference/...` routes: Events, Sessions, Speakers, Sponsors. They carry no `RequiredRole` and no `Section`, so they default to `NavSection.General` (`MMCA.Common.UI/Common/NavItem.cs:17`). - - **Speaker** (`:27-28`): the Dashboard and the QR page, both carrying `RequiredClaim: "speaker_id"` and `Section: NavSection.User`, so they appear only for a user whose JWT links them to a speaker record and they render in the user menu rather than the main list. - - **Organizer** (`:31-38`): eight items, each carrying `RoleNames.Organizer`, `Section: NavSection.Admin`, and `Group: "Nav.Group.Conference"` so they fold into one labelled admin group, ending with the Session Selection entry (`:38`). - - `Assembly` (`:41`) returns `typeof(ConferenceUIModule).Assembly` so the host's Blazor router can discover this library's routable components. Note that "Events", "Sessions", "Speakers", and "Sponsors" each appear twice in the list, once public and once organizer, differing only in route and gating: the same label serves two audiences with two destinations. +- **Walkthrough**: `NavItems` (`:18-41`) is an `IReadOnlyList` initialized with a collection expression in three tiers, sixteen items in all. + - **Public** (`:21-25`): five items for everyone, anonymous included, pointing at the `/conference/...` routes: Events, Sessions, Speakers, Sponsors, Activities. They carry no `RequiredRole` and no `Section`, so they default to `NavSection.General` (`MMCA.Common.UI/Common/NavItem.cs:17`). + - **Speaker** (`:28-29`): the Dashboard and the QR page, both carrying `RequiredClaim: "speaker_id"` and `Section: NavSection.User`, so they appear only for a user whose JWT links them to a speaker record and they render in the user menu rather than the main list. + - **Organizer** (`:32-40`): nine items, each carrying `RoleNames.Organizer`, `Section: NavSection.Admin`, and `Group: "Nav.Group.Conference"` so they fold into one labelled admin group, ending with the Session Selection entry (`:40`). + - `Assembly` (`:43`) returns `typeof(ConferenceUIModule).Assembly` so the host's Blazor router can discover this library's routable components. Note that "Events", "Sessions", "Speakers", "Sponsors", and "Activities" each appear twice in the list, once public and once organizer, differing only in route and gating: the same label serves two audiences with two destinations. - **Why it's built this way**: mirroring the backend [IModule](group-14-module-system-composition.md#imodule) pattern on the UI side keeps the app extensible. A host that boots without the Conference module simply has no conference nav and no conference routes, with no conditional code anywhere in the shell. The class also leaves `AppBarComponentTypes` and `LayoutComponentTypes` at their interface defaults (`MMCA.Common.UI/Common/Interfaces/IUIModule.cs:19-22`): Conference contributes no app-bar badge or root overlay. - **Where it's used**: registered as a singleton `IUIModule` by this module's [DependencyInjection](#dependencyinjection) through `AddUIModule()` (`DependencyInjection.cs:23`) and aggregated by the shared UI navigation builder in [group 15](group-15-common-ui-framework.md#iuimodule). @@ -155,32 +193,33 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod - **Depends on**: [ConferenceUIModule](#conferenceuimodule) and, through `AddUIModule` (`MMCA.Common.UI/DependencyInjection.cs:152-162`), Scrutor's assembly-scanning API and the open generic [IEntityService](group-15-common-ui-framework.md#ientityservicetentitydto-tidentifiertype). Then this module's own service contracts: [IEventSpeakerUIService](#ieventspeakeruiservice), [ISessionSpeakerUIService](#isessionspeakeruiservice), [ISessionCategoryItemUIService](#isessioncategoryitemuiservice), [ISpeakerCategoryItemUIService](#ispeakercategoryitemuiservice), [ISpeakerDashboardUIService](#ispeakerdashboarduiservice), [IOrganizerEventFeedbackUIService](#iorganizereventfeedbackuiservice), [IOrganizerSessionFeedbackUIService](#iorganizersessionfeedbackuiservice), [ISessionSelectionUIService](#isessionselectionuiservice), [ISpeakerLookupService](#ispeakerlookupservice), [IEventLookupService](#ieventlookupservice), [ICategoryItemLookupService](#icategoryitemlookupservice), and [IPublicLinkBuilder](#ipubliclinkbuilder) with its [NavigationPublicLinkBuilder](#navigationpubliclinkbuilder) implementation. - **Concept introduced: the `extension(IServiceCollection)` registration block, half convention and half explicit.** [Rubric §3, Clean Architecture] and [Rubric §16, Maintainability] both come down to keeping wiring at the edges; this file is the module's one wiring point. It uses the C# preview extension-type syntax `extension(IServiceCollection services)` (`:13`) to hang `AddConferenceUI` (`:19`) off `IServiceCollection`, the same idiom every module's `DependencyInjection` uses. The convention half is delegated to `AddUIModule()` (`:23`), which does two things in one call (`MMCA.Common.UI/DependencyInjection.cs:155-161`): a Scrutor scan of this assembly registering every `IEntityService<,>` implementation `AsImplementedInterfaces().WithScopedLifetime()`, and the singleton registration of the descriptor itself. Registering `AsImplementedInterfaces` is what makes a page able to inject the narrow per-entity interface rather than the open generic, and it means adding a new entity service needs no edit here. - **Walkthrough**: the scan runs first (`:23`), then the method registers by hand exactly the services the scan cannot see, because they do not implement `IEntityService<,>`: four child-entity managers for the join relationships (`:26-29`), the speaker dashboard service (`:32`), the two BR-53 organizer-feedback moderation services (`:35-36`), the session-selection decision-support service (`:39`), and three cross-module lookup services (`:42-44`). It then registers [IPublicLinkBuilder](#ipubliclinkbuilder) as [NavigationPublicLinkBuilder](#navigationpubliclinkbuilder) (`:49`) and returns `services` for chaining (`:51`). Every explicit registration is `AddScoped`; only the descriptor is a singleton, which is correct because it is immutable data. -- **Why it's built this way**: scanning the uniform bulk and spelling out the one-off collaborators keeps registration short without hiding the non-trivial wiring. One such subtlety is documented inline (`:46-48`): the public share-link builder resolves against the browser origin by default, but the MAUI head re-registers `IPublicLinkBuilder` *after* this call so last-registration-wins points shared links at the configured public web URL. That ordering dependency is exactly the kind of thing that belongs in a comment next to the registration. +- **Why it's built this way**: scanning the uniform bulk and spelling out the one-off collaborators keeps registration short without hiding the non-trivial wiring. One such subtlety is documented inline (`:46-48`): the public share-link builder resolves against the browser origin by default, but the MAUI head re-registers `IPublicLinkBuilder` *after* this call so last-registration-wins points shared links at the configured public web URL (`MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/MauiProgram.cs:105-107`, citing [ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html)). That ordering dependency is exactly the kind of thing that belongs in a comment next to the registration. - **Where it's used**: called once during startup by each of the three UI heads: the Blazor Server host (`MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:83`), the WebAssembly client (`MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web.Client/Program.cs:63`), and the MAUI host (`MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/MauiProgram.cs:97`), alongside the other modules' `AddXxxUI()` extensions. ### ADCHome -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Home` · `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:17` · Level 9 · class (sealed partial component) - -- **What it is**: the conference landing page: hero with a live countdown, keynote, track catalogue, sponsor strip with a sponsorship call to action, and venue block. It fetches the published events list to find which event to feature, classifies that event as Upcoming/Live/Ended, loads that event's sponsors, and renders the rest from compiled-in editorial content. It is shared verbatim by the Web and MAUI heads, and its class doc records that both heads serve the static images from their own site root, so neither overrides `ImageBasePath` today (`:10-16`). -- **Depends on**: [ADCCollectionResult](#adccollectionresult), [ADCEventInfo](#adceventinfo), [ADCSponsorCollectionResult](#adcsponsorcollectionresult), [ADCSponsorInfo](#adcsponsorinfo) (the API models), [EventPhase](#eventphase), [KeynoteSpeakerInfo](#keynotespeakerinfo), [ConferenceTrackInfo](#conferencetrackinfo) (the content records, all private inner types of this class), [CurrentEventSelector](group-17-conference-domain.md#currenteventselector) from `MMCA.ADC.Conference.Shared.Events` (`:4`), [SponsorTier](group-17-conference-domain.md#sponsortier) (`:5`), and [ConferenceRoutePaths](#conferenceroutepaths) for the "see all sponsors" link (`ADCHome.razor:201`). Externals: `IHttpClientFactory` and `GetFromJsonAsync` (`:1`, `:27-28`), `IStringLocalizer` injected in the markup as `L` (`ADCHome.razor:1`), `System.Threading.Timer`, `TimeZoneInfo`, MudBlazor, and the Blazor `RendererInfo` API. It composes one first-party child component, `HomeCountdown` (`ADCHome.razor:40`), which lives in the same folder as a single `.razor` file with no code-behind. -- **Concept introduced: rendering correctly across the prerender and interactive passes.** [Rubric §23, Front-End Performance and Rendering] assesses whether a page avoids wasted renders and blocking work; this component is the chapter's clearest case study, and both of its decisions were learned the hard way, as the code comments record. - - **Skip the fetch during prerender.** `OnInitializedAsync` checks `RendererInfo.IsInteractive` and, when false, sets `_isLoading = false`, computes the countdown from defaults, and returns without touching the network (`:101-106`). The comment (`:96-100`) states why: an untimed server-side call to a cold or unreachable backend would block the prerender, and therefore the page load *and* the post-login `NavigateTo("/")`, indefinitely. The static fallback renders immediately and the interactive pass loads the real event. [Rubric §29, Resilience] is the same point from the availability angle. - - **Fence the per-second re-render.** The ticking digits live in the `HomeCountdown` child, which owns its own timer, so this page arms only a *single one-shot* `Timer` for the Live-to-Ended flip (`:128-143`). The comment at `:111-112` records the prior behaviour: a 1-second timer that re-rendered the entire landing page, the largest static page in the app, for the whole event, per circuit, just to catch one transition. The child goes further still: it ticks once a minute while more than 65 minutes remain and switches to once a second only for the final hour (`HomeCountdown.razor:32`, `:52-59`, `:70-74`). - Three more rubric threads run through it. [Rubric §22, Responsive and Cross-Browser/Device]: one component compiles into the Blazor Server, WebAssembly, and MAUI heads, with the per-head difference reduced to the `ImageBasePath` parameter (`:35-36`). [Rubric §27, Internationalization]: user-facing chrome resolves through `L[...]`, while three strings carry explicit `// i18n: allow` markers with reasons (the brand name `:64`, the postal address `:68`, the editorial content block `:307-308`). [Rubric §20, Design System and Theming]: the page's scoped stylesheet is a single shared copy rendered by both heads, and an architecture fitness test embeds it and fails the build if it re-hardcodes the brand hex instead of using `var(--mmca-primary)` (`MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/BrandColorTokenTests.cs:14-17`, `MMCA.ADC.Architecture.Tests.csproj:11-13`). +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Home` · `MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:18` · Level 9 · class (sealed partial component) + +- **What it is**: the conference landing page: hero with a live countdown and a ticketing button, pre-conference workshops, keynote, track catalogue, sponsor strip with a sponsorship call to action, and venue block. It fetches the published events list to find which event to feature, classifies that event as Upcoming/Live/Ended, loads that event's sponsors, and renders the rest from compiled-in editorial content. It is shared verbatim by the Web and MAUI heads, and its class doc records that both heads serve the static images from their own site root, so neither overrides `ImageBasePath` today (`:10-17`). +- **Depends on**: [ADCCollectionResult](#adccollectionresult), [ADCEventInfo](#adceventinfo), [ADCSponsorCollectionResult](#adcsponsorcollectionresult), [ADCSponsorInfo](#adcsponsorinfo) (the API models), [EventPhase](#eventphase), [KeynoteSpeakerInfo](#keynotespeakerinfo), [ConferenceTrackInfo](#conferencetrackinfo), [PreConferenceWorkshopInfo](#preconferenceworkshopinfo) (the content records, all private inner types of this class), [CurrentEventSelector](group-17-conference-domain.md#currenteventselector) from `MMCA.ADC.Conference.Shared.Events` (`:4`), [SponsorTier](group-17-conference-domain.md#sponsortier) (`:5`), and [ConferenceRoutePaths](#conferenceroutepaths) for the "see all sponsors" link (`ADCHome.razor:303`). Externals: `IHttpClientFactory` and `GetFromJsonAsync` (`:1`, `:42-43`), `IStringLocalizer` injected in the markup as `L` (`ADCHome.razor:1`), `System.Threading.Timer`, `TimeZoneInfo`, MudBlazor, and the Blazor `RendererInfo` API. It composes one first-party child component, `HomeCountdown` (`ADCHome.razor:40`), which lives in the same folder as a single `.razor` file with no code-behind. +- **Concept introduced: rendering correctly across the prerender and interactive passes.** [Rubric §23, Front-End Performance and Rendering] assesses whether a page avoids wasted renders and blocking work; this component is the chapter's clearest case study, and both of its decisions are recorded in the code comments. + - **Skip the fetch during prerender.** `OnInitializedAsync` checks `RendererInfo.IsInteractive` and, when false, sets `_isLoading = false`, computes the countdown from defaults, and returns without touching the network (`:116-121`). The comment (`:111-115`) states why: an untimed server-side call to a cold or unreachable backend would block the prerender, and therefore the page load *and* the post-login `NavigateTo("/")`, indefinitely. The static fallback renders immediately and the interactive pass loads the real event. [Rubric §29, Resilience] is the same point from the availability angle. + - **Fence the per-second re-render.** The ticking digits live in the `HomeCountdown` child, which owns its own timer, so this page arms only a *single one-shot* `Timer` for the Live-to-Ended flip (`:143-158`). The comment at `:126-127` records the alternative: a 1-second timer would re-render the entire landing page, the largest static page in the app, for the whole event, per circuit, just to catch one transition. The child goes further still: it ticks once a minute while more than 65 minutes remain and switches to once a second only for the final hour (`HomeCountdown.razor:32`, `:55`, `:59`, `:73`). + + Three more rubric threads run through it. [Rubric §22, Responsive and Cross-Browser/Device]: one component compiles into the Blazor Server, WebAssembly, and MAUI heads, with the per-head difference reduced to the `ImageBasePath` parameter (`:50-51`). [Rubric §27, Internationalization]: user-facing chrome resolves through `L[...]`, while four strings carry explicit `// i18n: allow` markers with reasons (the ticketing URL `:31`, the brand name `:79`, the postal address `:83`, the editorial content block `:323-324`). [Rubric §20, Design System and Theming]: the page's scoped stylesheet is a single shared copy rendered by both heads, and an architecture fitness test embeds it and fails the build if it re-hardcodes the brand hex instead of using `var(--mmca-primary)` (`MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/BrandColorTokenTests.cs:12-17`, `MMCA.ADC.Architecture.Tests.csproj:11-13`). - **Walkthrough**, in lifecycle order: - - **State** (`:45-55`): a `CancellationTokenSource`, the one-shot `_phaseTimer`, the computed `_startUtc`/`_endUtc`, `_phase`, the nullable `_event`, the grouped `_sponsorTiers` (`:53`), `_isLoading` (starting `true`), and a `_disposed` guard the timer callback checks. - - **Derived display properties** (`:64-71`): `EventName`, `EventDescription`, `VenueAddress`, and `MapSearchUrl` are each `_event?.X ?? `, so the page is fully renderable before and without a successful fetch. `MapSearchUrl` builds a Google Maps search URL with `Uri.EscapeDataString` over the address (`:70-71`). - - **`HeroTitleParts()`** (`:78-90`): splits the event name so the hero can accent the keyword between "Atlanta " and " Conference" (in "2026 Atlanta Developers Conference" it accents "Developers"). It uses `IndexOf`/`LastIndexOf` with `StringComparison.Ordinal` and falls back to rendering the whole name plain when the name does not match the brand shape, which is why an arbitrary event name never renders broken markup. - - **`OnInitializedAsync`** (`:92-114`): creates the CTS, takes the prerender short-circuit described above, otherwise awaits `LoadEventAsync()` then `LoadSponsorsAsync()` in sequence (`:108-109`, the sponsor call needs the featured event id) and arms the phase timer (`:113`). - - **`LoadEventAsync`** (`:156-185`): creates the named `"APIClient"` from `IHttpClientFactory` (`:160`), deserializes into [ADCCollectionResult](#adccollectionresult) under the cancellation token (`:161`), and picks the event with `CurrentEventSelector.SelectCurrentOrNext(...)` passing four accessor lambdas plus `DateTime.UtcNow` (`:165-170`). The comment at `:163-164` is the reason it is not a `FirstOrDefault`: the anonymous endpoint returns published events unordered, so a naive first-item pick would pin the oldest seeded event. Two catch arms are deliberately silent: `OperationCanceledException` means the component was disposed mid-load (`:172`), `HttpRequestException` means the API is unavailable and the fallback content stands (`:176`). The `finally` block always clears `_isLoading` and recomputes the countdown (`:180-184`), so no failure path leaves a spinner on screen. - - **`LoadSponsorsAsync`** (`:196-229`): returns immediately when no event was featured (`:198-201`), then runs the same anonymous read path against `sponsors` and reduces the payload to the tier-grouped list described under [ADCSponsorCollectionResult](#adcsponsorcollectionresult). Its two catch arms mirror `LoadEventAsync` and both leave `_sponsorTiers` empty, which is a supported render state rather than an error state. - - **`UpdateCountdown`** (`:231-261`): converts the event's local start and end into UTC using `TimeZoneInfo.FindSystemTimeZoneById(timeZoneId)` with `"America/New_York"` as the default (`:237`, `:244`), calling `CurrentEventSelector.ToUtc` rather than `ConvertTimeToUtc` because, as the comment records (`:241-243`), the midnight end boundary does not exist in zones that transition at 00:00 and a raw conversion would throw out of the render path (`MMCA.ADC.Conference.Shared/Events/CurrentEventSelector.cs:96`). An unknown zone id falls back to treating the local values as UTC (`:248-252`), then `_phase` is assigned from the switch described under [EventPhase](#eventphase). - - **Phase timing** (`:117-154`): `OnCountdownElapsedAsync` is the `EventCallback` the `HomeCountdown` child raises at zero (`HomeCountdown.razor:82`), which recomputes the phase, re-arms, and calls `InvokeAsync(StateHasChanged)` (`:117-122`). `ArmPhaseTimerForEventEnd` returns unless the phase is `Live` and the remaining time is positive, then disposes any prior timer and schedules one callback at `untilEnd` with `Timeout.InfiniteTimeSpan` as the period, meaning fire once and never repeat (`:128-143`). `OnEventEnded` checks `_disposed` before re-rendering (`:145-154`). - - **`FormatEventDate`** (`:263-270`): formats the date with a pattern read from a *resource* (`L["Hero.DateFormat"]`) against `CultureInfo.CurrentCulture`, so both the layout and the month names follow the selected language ([ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html)). - - **`Dispose`** (`:272-279`): sets `_disposed`, cancels and disposes the CTS, and both stops (`Change(-1, -1)`) and disposes the phase timer. Stopping before disposing is what prevents a callback already in flight from touching a torn-down component. -- **Why it's built this way**: the landing page is the app's most-hit surface and the post-login destination, so its correctness budget is dominated by two failure modes that have nothing to do with its content: a slow backend blocking the prerender, and a per-second render loop multiplied by every connected circuit. Both are solved structurally (skip the fetch, fence the tick) rather than by tuning, and every dynamic block has a defined empty state, so the page is never blank. -- **Where it's used**: resolved as the home component by each head's `ADCHomePageContent`. The Web client points `ComponentType` straight at this shared component (`MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web.Client/Pages/ADCHomePageContent.cs:13`, registered at `.../MMCA.ADC.UI.Web.Client/Program.cs:49` and `.../MMCA.ADC.UI.Web/Program.cs:60`); the MAUI head points at a thin local wrapper page that renders `` with no parameters (`MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/Pages/ADCHomePageContent.cs:10`, `MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/Pages/ADCHome.razor:6`). [Rubric §28, Front-End Testing]: the page has no bUnit test, but two suites hold it to account from outside, the brand-token fitness test above and the E2E pseudo-localization sentinel, which probes this page's `Location.OpenInMaps` resource precisely because that button is static markup rather than event-load-gated (`MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/PseudoLocalizationTests.cs:46-50`). -- **Caveats / not-in-source**: the page's own countdown window is not identical to the selector's. `UpdateCountdown` starts the event at `EventStartTime = 08:00` local (`:20`, `:235`), while [CurrentEventSelector](group-17-conference-domain.md#currenteventselector) starts its live window at midnight local (`MMCA.ADC.Conference.Shared/Events/CurrentEventSelector.cs:5-6`). Both end at midnight after the last day. So between midnight and 08:00 on day one, the selector already treats the event as live while the hero still shows a countdown. Whether that is intended is not determinable from source. Also note the two hard-coded fallbacks used when no event loads: the date `2026-10-17`, whose comment warns it must track the published event date or the hero date and countdown visibly jump once the real event arrives (`:22-25`), and the venue address (`:68`). + - **Constants and state** (`:20-70`): the `PreConferenceTicketingUrl` constant placed first because SA1203 requires constants before fields (`:24-25`, `:30-31`), the shared `ApiJsonOptions` and `EventStartTime` (`:34-35`), the `FallbackStartDate` (`:40`), then a `CancellationTokenSource`, the one-shot `_phaseTimer`, the computed `_startUtc`/`_endUtc`, `_phase`, the nullable `_event`, the grouped `_sponsorTiers` (`:68`), `_isLoading` (starting `true`), and a `_disposed` guard the timer callback checks. + - **Derived display properties** (`:79-86`): `EventName`, `EventDescription`, `VenueAddress`, and `MapSearchUrl` are each `_event?.X ?? `, so the page is fully renderable before and without a successful fetch. `MapSearchUrl` builds a Google Maps search URL with `Uri.EscapeDataString` over the address (`:85-86`). + - **`HeroTitleParts()`** (`:93-105`): splits the event name so the hero can accent the keyword between "Atlanta " and " Conference" (in "2026 Atlanta Developers Conference" it accents "Developers"). It uses `IndexOf`/`LastIndexOf` with `StringComparison.Ordinal` and falls back to rendering the whole name plain when the name does not match the brand shape (`:102-104`), which is why an arbitrary event name never renders broken markup. + - **`OnInitializedAsync`** (`:107-129`): creates the CTS, takes the prerender short-circuit described above, otherwise awaits `LoadEventAsync()` then `LoadSponsorsAsync()` in sequence (`:123-124`, the sponsor call needs the featured event id) and arms the phase timer (`:128`). + - **`LoadEventAsync`** (`:171-200`): creates the named `"APIClient"` from `IHttpClientFactory` (`:175`), deserializes into [ADCCollectionResult](#adccollectionresult) under the cancellation token (`:176`), and picks the event with `CurrentEventSelector.SelectCurrentOrNext(...)` passing four accessor lambdas plus `DateTime.UtcNow` (`:180-185`). The comment at `:178-179` is the reason it is not a `FirstOrDefault`: the anonymous endpoint returns published events unordered, so a naive first-item pick would pin the oldest seeded event. Two catch arms are deliberately silent: `OperationCanceledException` means the component was disposed mid-load (`:187`), `HttpRequestException` means the API is unavailable and the fallback content stands (`:191`). The `finally` block always clears `_isLoading` and recomputes the countdown (`:195-199`), so no failure path leaves a spinner on screen. + - **`LoadSponsorsAsync`** (`:211-244`): returns immediately when no event was featured (`:213-216`), then runs the same anonymous read path against `sponsors` and reduces the payload to the tier-grouped list described under [ADCSponsorCollectionResult](#adcsponsorcollectionresult). Its two catch arms mirror `LoadEventAsync` (`:236`, `:240`) and both leave `_sponsorTiers` empty, which is a supported render state rather than an error state. + - **`UpdateCountdown`** (`:246-276`): converts the event's local start and end into UTC using `TimeZoneInfo.FindSystemTimeZoneById(timeZoneId)` with `"America/New_York"` as the default (`:252`, `:259`), calling `CurrentEventSelector.ToUtc` rather than `ConvertTimeToUtc` because, as the comment records (`:256-258`), the midnight end boundary does not exist in zones that transition at 00:00 and a raw conversion would throw out of the render path (`MMCA.ADC.Conference.Shared/Events/CurrentEventSelector.cs:96-106`). An unknown zone id falls back to treating the local values as UTC (`:263-267`), then `_phase` is assigned from the switch described under [EventPhase](#eventphase). + - **Phase timing** (`:132-169`): `OnCountdownElapsedAsync` is the `EventCallback` the `HomeCountdown` child raises at zero (`HomeCountdown.razor:82`), which recomputes the phase, re-arms, and calls `InvokeAsync(StateHasChanged)` (`:132-137`). `ArmPhaseTimerForEventEnd` returns unless the phase is `Live` and the remaining time is positive, then disposes any prior timer and schedules one callback at `untilEnd` with `Timeout.InfiniteTimeSpan` as the period, meaning fire once and never repeat (`:143-158`). `OnEventEnded` checks `_disposed` before re-rendering (`:160-169`). + - **`FormatEventDate`** (`:278-285`): formats the date with a pattern read from a *resource* (`L["Hero.DateFormat"]`) against `CultureInfo.CurrentCulture`, so both the layout and the month names follow the selected language ([ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html)). + - **`Dispose`** (`:287-294`): sets `_disposed`, cancels and disposes the CTS, and both stops (`Change(-1, -1)`) and disposes the phase timer. Stopping before disposing is what prevents a callback already in flight from touching a torn-down component. +- **Why it's built this way**: the landing page is the app's most-hit surface and the post-login destination, so its correctness budget is dominated by two failure modes that have nothing to do with its content: a slow backend blocking the prerender, and a per-second render loop multiplied by every connected circuit. Both are solved structurally (skip the fetch, fence the tick) rather than by tuning, and every dynamic block has a defined empty state, so the page is never blank. The two conditional calls to action follow the same discipline: the hero ticketing button (`ADCHome.razor:67-80`) and the sponsorship packet block (`ADCHome.razor:307-329`) each render only when the featured event publishes the corresponding URL, and hide entirely otherwise rather than offering a dead link. +- **Where it's used**: resolved as the home component by each head's `ADCHomePageContent`. The Web client points `ComponentType` straight at this shared component (`MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web.Client/Pages/ADCHomePageContent.cs:13`, registered at `.../MMCA.ADC.UI.Web.Client/Program.cs:49` and `.../MMCA.ADC.UI.Web/Program.cs:60`); the MAUI head points at a thin local wrapper page that renders `` with no parameters (`MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/Pages/ADCHomePageContent.cs:10`, `MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/Pages/ADCHome.razor:6`). [Rubric §28, Front-End Testing]: the page has no bUnit test, but two suites hold it to account from outside, the brand-token fitness test above and the E2E pseudo-localization sentinel, which probes this page's `Location.OpenInMaps` resource precisely because that button is static markup rather than event-load-gated (`MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/PseudoLocalizationTests.cs:46-56`). +- **Caveats / not-in-source**: the page's own countdown window is not identical to the selector's. `UpdateCountdown` starts the event at `EventStartTime = 08:00` local (`:35`, `:250`), while [CurrentEventSelector](group-17-conference-domain.md#currenteventselector) starts its live window at midnight local (`MMCA.ADC.Conference.Shared/Events/CurrentEventSelector.cs:69`). Both end at midnight after the last day. So between midnight and 08:00 on day one, the selector already treats the event as live while the hero still shows a countdown. Whether that is intended is not determinable from source. Also note the two hard-coded fallbacks used when no event loads: the date `2026-10-17`, whose comment warns it must track the published event date or the hero date and countdown visibly jump once the real event arrives (`:37-40`), and the venue address (`:83`). ### ScorePollSignal > MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.SessionSelection` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/SessionSelection/ScorePollTracker.cs:6` · Level 0 · enum (internal) @@ -752,41 +791,74 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod (`EventLookupService.cs:20`), and the same absence of memoization: every call re-fetches the full event collection. -### ICategoryItemUIService +### IActivityUIService -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Services` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/ICategoryItemUIService.cs:9` · Level 3 · interface +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Services` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/IActivityUIService.cs:9` · Level 3 · interface -- **What it is**: the UI-service contract for the `categoryitems` REST resource. It is an empty marker - interface, `public interface ICategoryItemUIService : IEntityService` - (`ICategoryItemUIService.cs:9-11`), that adds no members of its own. +- **What it is**: the UI-service contract for the `activities` REST resource (the conference's social + and networking programme). It is an empty marker interface, + `public interface IActivityUIService : IEntityService` + (`IActivityUIService.cs:9-11`), that adds no members of its own. - **Depends on**: [`IEntityService`](group-15-common-ui-framework.md#ientityservicetentitydto-tidentifiertype) - (the shared CRUD contract, Level 2, imported from `MMCA.Common.UI.Common.Interfaces` at - `ICategoryItemUIService.cs:2`) and [`CategoryItemDTO`](group-17-conference-domain.md#categoryitemdto) - (the transported shape, Level 1). `CategoryItemIdentifierType` is the module id alias. + (the shared CRUD contract, imported from `MMCA.Common.UI.Common.Interfaces` at + `IActivityUIService.cs:2`) and [`ActivityDTO`](group-17-conference-domain.md#activitydto) (the + transported shape, from `MMCA.ADC.Conference.Shared.Activities` at `IActivityUIService.cs:1`). + `ActivityIdentifierType` is the module id alias, `int` + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:5`). - **Concept introduced, the per-entity marker UI-service interface.** `[Rubric §18, UI Architecture]` - (assesses whether the front end talks to a typed service abstraction rather than raw `HttpClient`; + (assesses whether the front end talks to a typed service abstraction rather than a raw `HttpClient`; here every Blazor page injects an *interface*, never the concrete HTTP class). `[Rubric §1, SOLID]` - (the marker gives each aggregate its own injection point so a page depends only on the contract it - needs, even though the shape is inherited). The generic CRUD surface all comes from + (the marker gives each aggregate its own injection point, so a page depends only on the contract it + needs even though the shape is entirely inherited). The generic CRUD surface all comes from [`IEntityService`](group-15-common-ui-framework.md#ientityservicetentitydto-tidentifiertype); see that type for the mechanism. There is a second, load-bearing reason for the body-less - specialization: registration is done by a Scrutor scan, not by hand. `AddUIModule()` - scans the Conference UI assembly for every `IEntityService<,>` implementation and registers it - `AsImplementedInterfaces()` with a scoped lifetime + specialization: registration is done by a Scrutor assembly scan, not by hand. + `AddUIModule()` scans the Conference UI assembly for every `IEntityService<,>` + implementation and registers it `AsImplementedInterfaces()` with a scoped lifetime (`MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:155-159`, called at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:23`), so the named - marker is exactly what a page gets to inject. -- **Walkthrough**: no members. The whole contract is "be an `IEntityService` bound to `CategoryItemDTO` - plus `CategoryItemIdentifierType`, under a name pages can inject". The doc comment - (`ICategoryItemUIService.cs:6-8`) states plainly that it "uses generic CRUD". + marker is exactly what a page gets to inject. Every plain-CRUD sibling in this group repeats this + shape. +- **Walkthrough**: no members. The whole contract is "be an `IEntityService` bound to `ActivityDTO` + plus `ActivityIdentifierType`, under a name pages can inject". The doc comment + (`IActivityUIService.cs:6-8`) states plainly that it "uses generic CRUD". - **Why it's built this way**: a named per-entity interface (rather than injecting the open generic - directly) keeps the scan's `AsImplementedInterfaces()` registration unambiguous and lets a specific + directly) keeps the scan's `AsImplementedInterfaces()` registration unambiguous, and it lets one entity later grow an extra method without disturbing the others (exactly what [`IEventUIService`](#ieventuiservice), [`IRoomUIService`](#iroomuiservice), and [`ISpeakerUIService`](#ispeakeruiservice) did). +- **Where it's used**: implemented by [`ActivityService`](#activityservice) (Level 4); injected into + the organizer activity list, detail, and create pages + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Activity/ActivityList.razor.cs:24`, + `Pages/Activity/ActivityDetail.razor.cs:20`, `Pages/Activity/ActivityCreate.razor.cs:18`) and into + the anonymous public activity page + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicActivityList.razor.cs:26`). + Note that the *same* contract serves both audiences: the organizer list sits behind + `[Authorize(Roles = "Organizer")]` (`Pages/Activity/ActivityList.razor:2`) while the public page is + anonymous (`Pages/Public/PublicActivityList.razor:1`), and the server, not the client, is what scopes + non-privileged callers to published events (`Pages/Public/PublicActivityList.razor.cs:11-18`). + +### ICategoryItemUIService + +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Services` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/ICategoryItemUIService.cs:9` · Level 3 · interface + +- **What it is**: the UI-service contract for the `categoryitems` REST resource, an empty marker over + [`IEntityService`](group-15-common-ui-framework.md#ientityservicetentitydto-tidentifiertype) + bound to [`CategoryItemDTO`](group-17-conference-domain.md#categoryitemdto) and + `CategoryItemIdentifierType` (`ICategoryItemUIService.cs:9-11`). +- **Depends on**: [`IEntityService`](group-15-common-ui-framework.md#ientityservicetentitydto-tidentifiertype) + (imported at `ICategoryItemUIService.cs:2`) and + [`CategoryItemDTO`](group-17-conference-domain.md#categoryitemdto) (imported at + `ICategoryItemUIService.cs:1`). +- **Concept**: identical shape to [`IActivityUIService`](#iactivityuiservice); see it for the + marker-interface and Scrutor-scan rationale. `[Rubric §18, UI Architecture]`. +- **Walkthrough**: no members. The doc comment (`ICategoryItemUIService.cs:6-8`) repeats the "uses + generic CRUD" formula. - **Where it's used**: implemented by [`CategoryItemService`](#categoryitemservice) (Level 4); injected into the conference-category detail page, which edits the items belonging to a category (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/ConferenceCategory/ConferenceCategoryDetail.razor.cs:16`). + It is the one CRUD marker in this family with a single consumer: category items are only ever managed + from inside their parent category, never as a top-level list. ### IConferenceCategoryUIService @@ -798,17 +870,18 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod `ConferenceCategoryIdentifierType` (`IConferenceCategoryUIService.cs:9-11`). - **Depends on**: [`IEntityService`](group-15-common-ui-framework.md#ientityservicetentitydto-tidentifiertype) and [`ConferenceCategoryDTO`](group-17-conference-domain.md#conferencecategorydto). -- **Concept**: identical shape to [`ICategoryItemUIService`](#icategoryitemuiservice); see it for the +- **Concept**: identical shape to [`IActivityUIService`](#iactivityuiservice); see it for the marker-interface and Scrutor-scan rationale. `[Rubric §18, UI Architecture]` and `[Rubric §16, Maintainability]` (a new aggregate resource costs one empty interface plus one thin class). - **Walkthrough**: no members (doc comment `IConferenceCategoryUIService.cs:6-8`). - **Where it's used**: implemented by [`ConferenceCategoryService`](#conferencecategoryservice); injected into the conference-category list, detail, and create pages - (`Pages/ConferenceCategory/ConferenceCategoryList.razor.cs`, - `ConferenceCategoryDetail.razor.cs`, `ConferenceCategoryCreate.razor.cs`) and into the speaker detail - page, which reads the category tree to tag a speaker - (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerDetail.razor.cs`). + (`Pages/ConferenceCategory/ConferenceCategoryList.razor.cs:16`, + `Pages/ConferenceCategory/ConferenceCategoryDetail.razor.cs:15`, + `Pages/ConferenceCategory/ConferenceCategoryCreate.razor.cs:11`) and into the speaker detail page, + which reads the category tree to tag a speaker + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerDetail.razor.cs:25`). ### IEventUIService @@ -820,41 +893,41 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod - **Depends on**: [`IEntityService`](group-15-common-ui-framework.md#ientityservicetentitydto-tidentifiertype) bound to [`EventDTO`](group-17-conference-domain.md#eventdto), and [`RefreshFromSessionizeResultDTO`](group-17-conference-domain.md#refreshfromsessionizeresultdto) (the - refresh outcome, Level 0). BCL `Task`, `CancellationToken`, `byte[]`. + refresh outcome). BCL `Task`, `CancellationToken`, `byte[]`. - **Concept introduced, extending the generic UI service with resource-specific verbs.** `[Rubric §9, API & Contract Design]` (assesses whether non-CRUD state transitions get first-class, intention-revealing operations instead of being forced through a generic update). Publish and unpublish are lifecycle transitions on an event, and refresh triggers an external Sessionize sync, none of which is a CRUD `Update`, so they earn their own methods mapped to dedicated WebAPI endpoints - (the doc comment, `IEventUIService.cs:6-9`, says exactly this). The second concept is on the + (the doc comment, `IEventUIService.cs:6-9`, says exactly this). The second concept is in the signatures: both transitions take an **optional `byte[]? rowVersion`** (`IEventUIService.cs:12,14`), the optimistic-concurrency token the client echoes back from the [`EventDTO`](group-17-conference-domain.md#eventdto) it acted on, so a publish decided against a stale - view surfaces as `409 Conflict` rather than applying silently (the contract for that round-trip is - [`EventTransitionRequest`](group-17-conference-domain.md#eventtransitionrequest), documented at - `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/EventTransitionRequest.cs:5-17`, + view surfaces as `409 Conflict` rather than applying silently. The contract for that round-trip is + [`EventTransitionRequest`](group-17-conference-domain.md#eventtransitionrequest) + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/EventTransitionRequest.cs:5-18`, rationale in [ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html)). That - makes this contract a `[Rubric §8, Data Architecture]` touch point as well: concurrency control reaches - all the way up into the UI service signature instead of stopping at the database. + makes this contract a `[Rubric §8, Data Architecture]` touch point as well: concurrency control + reaches all the way up into the UI service signature instead of stopping at the database. - **Walkthrough**: three declared members. - - `PublishAsync(EventIdentifierType id, byte[]? rowVersion = null, CancellationToken)` (line 12), - returns `Task`. - - `UnpublishAsync(EventIdentifierType id, byte[]? rowVersion = null, CancellationToken)` (line 14), - the mirror transition, same shape. - - `RefreshFromSessionizeAsync(EventIdentifierType id, CancellationToken)` (line 16), returns - `Task` (the sync summary, nullable when the call yields no body). - It takes no `rowVersion`: a Sessionize pull is not a lifecycle transition on the event row. + - `PublishAsync(EventIdentifierType id, byte[]? rowVersion = null, CancellationToken)` + (`IEventUIService.cs:12`), returns `Task`. + - `UnpublishAsync(EventIdentifierType id, byte[]? rowVersion = null, CancellationToken)` + (`IEventUIService.cs:14`), the mirror transition, same shape. + - `RefreshFromSessionizeAsync(EventIdentifierType id, CancellationToken)` (`IEventUIService.cs:16`), + returns `Task` (the sync summary, nullable when the call yields no + body). It takes no `rowVersion`: a Sessionize pull is not a lifecycle transition on the event row. - **Why it's built this way**: the extra verbs live on the *interface* so the concrete [`EventService`](#eventservice) is the only place that knows the endpoint URLs; pages stay transport-agnostic. The `rowVersion` parameter is optional so a caller that has no token (or does not - care) still compiles and falls back to the server's fresh-load domain guard - (`EventTransitionRequest.cs:10-11`). + care) still compiles and falls back to the server's fresh-load domain guard, which the request record + spells out (`EventTransitionRequest.cs:10-11`). - **Where it's used**: implemented by [`EventService`](#eventservice) (Level 4); injected into the event - list, detail, and create pages plus the public event browse pages - (`Pages/Event/EventList.razor.cs`, `Pages/Event/EventDetail.razor.cs`, `Pages/Event/EventCreate.razor.cs`, - `Pages/Public/PublicEventList.razor.cs`, `Pages/Public/PublicEventDetail.razor.cs`) and into the session - list pages that need the owning event (`Pages/Session/SessionList.razor.cs`, - `Pages/Public/PublicSessionList.razor.cs`). + list, detail, and create pages (`Pages/Event/EventList.razor.cs:21`, + `Pages/Event/EventDetail.razor.cs:19`, `Pages/Event/EventCreate.razor.cs:15`), the public event browse + pages (`Pages/Public/PublicEventList.razor.cs:34`, `Pages/Public/PublicEventDetail.razor.cs:18`), and + the session list pages that need the owning event (`Pages/Session/SessionList.razor.cs:24`, + `Pages/Public/PublicSessionList.razor.cs:32`). ### IQuestionUIService @@ -865,15 +938,17 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod bound to [`QuestionDTO`](group-17-conference-domain.md#questiondto) (`IQuestionUIService.cs:9-11`). - **Depends on**: [`IEntityService`](group-15-common-ui-framework.md#ientityservicetentitydto-tidentifiertype) and [`QuestionDTO`](group-17-conference-domain.md#questiondto). -- **Concept**: same marker shape as [`ICategoryItemUIService`](#icategoryitemuiservice); see there. +- **Concept**: same marker shape as [`IActivityUIService`](#iactivityuiservice); see there. `[Rubric §18, UI Architecture]`. - **Walkthrough**: no members (doc comment `IQuestionUIService.cs:6-8`). - **Where it's used**: injected into the question list, detail, and create pages - (`Pages/Question/QuestionList.razor.cs`, `QuestionDetail.razor.cs`, `QuestionCreate.razor.cs`), into - both organizer feedback pages, which need the question text to label the answers - (`Pages/Feedback/OrganizerEventFeedback.razor.cs`, `Pages/Feedback/OrganizerSessionFeedback.razor.cs`), - and into the speaker detail page (`Pages/Speaker/SpeakerDetail.razor.cs`). The concrete implementation - is a thin `EntityServiceBase` subclass picked up by the assembly scan. + (`Pages/Question/QuestionList.razor.cs:16`, `Pages/Question/QuestionDetail.razor.cs:15`, + `Pages/Question/QuestionCreate.razor.cs:11`), into both organizer feedback pages, which need the + question text to label the answers (`Pages/Feedback/OrganizerEventFeedback.razor.cs:17`, + `Pages/Feedback/OrganizerSessionFeedback.razor.cs:17`), and into the speaker detail page + (`Pages/Speaker/SpeakerDetail.razor.cs:27`). The concrete implementation, + [`QuestionService`](#questionservice), is a thin `EntityServiceBase` subclass picked up by the + assembly scan. ### IRoomUIService @@ -882,22 +957,24 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod - **What it is**: the UI-service contract for the `rooms` resource. It extends the generic CRUD surface with a single specialized delete that also carries the owning event id (`IRoomUIService.cs:9-13`). - **Depends on**: [`IEntityService`](group-15-common-ui-framework.md#ientityservicetentitydto-tidentifiertype) - bound to [`RoomDTO`](group-17-conference-domain.md#roomdto). `RoomIdentifierType` and - `EventIdentifierType` id aliases. + bound to [`RoomDTO`](group-17-conference-domain.md#roomdto) (note that `RoomDTO` lives in the + `MMCA.ADC.Conference.Shared.Events` namespace, `IRoomUIService.cs:1`, because a room belongs to an + event). `RoomIdentifierType` and `EventIdentifierType` id aliases. - **Concept**: `[Rubric §9, API & Contract Design]` (assesses contracts that carry the parameters the server actually requires). A room is scoped to an event, so its delete needs the `EventIdentifierType` the WebAPI endpoint expects; the generic `DeleteAsync(id)` would omit it. The doc comment (`IRoomUIService.cs:11`) states the added overload "passes the required event ID to the - API". This is the UI-side counterpart to the child-scoped delete used by the join and - organizer-feedback services. + API". This is the UI-side counterpart to the child-scoped delete used by the join services and by the + organizer-feedback services in this same part. - **Walkthrough**: one added member, - `DeleteAsync(RoomIdentifierType roomId, EventIdentifierType eventId, CancellationToken)` (line 12), - returning `Task`. It supplements, rather than replaces, the inherited single-argument delete. -- **Where it's used**: injected into the room list, detail, and create pages - (`Pages/Room/RoomList.razor.cs`, `RoomDetail.razor.cs`, `RoomCreate.razor.cs`) and into the session - create/detail and public session detail pages, which render the room a session is scheduled in - (`Pages/Session/SessionCreate.razor.cs`, `Pages/Session/SessionDetail.razor.cs`, - `Pages/Public/PublicSessionDetail.razor.cs`). + `DeleteAsync(RoomIdentifierType roomId, EventIdentifierType eventId, CancellationToken)` + (`IRoomUIService.cs:12`), returning `Task`. It supplements, rather than replaces, the inherited + single-argument delete: both overloads are visible on the interface. +- **Where it's used**: implemented by [`RoomService`](#roomservice); injected into the room list, + detail, and create pages (`Pages/Room/RoomList.razor.cs:17`, `Pages/Room/RoomDetail.razor.cs:16`, + `Pages/Room/RoomCreate.razor.cs:11`) and into the session create/detail and public session detail + pages, which render the room a session is scheduled in (`Pages/Session/SessionCreate.razor.cs:19`, + `Pages/Session/SessionDetail.razor.cs:27`, `Pages/Public/PublicSessionDetail.razor.cs:24`). ### ISessionUIService @@ -908,17 +985,20 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod bound to [`SessionDTO`](group-17-conference-domain.md#sessiondto) (`ISessionUIService.cs:9-11`). - **Depends on**: [`IEntityService`](group-15-common-ui-framework.md#ientityservicetentitydto-tidentifiertype) and [`SessionDTO`](group-17-conference-domain.md#sessiondto). -- **Concept**: same marker shape as [`ICategoryItemUIService`](#icategoryitemuiservice). - `[Rubric §18, UI Architecture]`. Note that the personalized speaker-facing session reads live on a - *separate* contract, [`ISpeakerDashboardUIService`](#ispeakerdashboarduiservice), because they must - bypass the shared output cache: keeping them apart is what lets this contract stay cache-friendly. +- **Concept**: same marker shape as [`IActivityUIService`](#iactivityuiservice). + `[Rubric §18, UI Architecture]`. Worth pausing on what this contract does *not* carry: the + personalized speaker-facing session reads live on a separate contract, + [`ISpeakerDashboardUIService`](#ispeakerdashboarduiservice), because they must bypass the shared + output cache. Keeping them apart is what lets this contract stay cache-friendly. - **Walkthrough**: no members (doc comment `ISessionUIService.cs:6-8`). -- **Where it's used**: injected into the session list, detail, and create pages - (`Pages/Session/SessionList.razor.cs`, `SessionDetail.razor.cs`, `SessionCreate.razor.cs`), the public - session pages (`Pages/Public/PublicSessionList.razor.cs`, `PublicSessionDetail.razor.cs`), the - organizer session-feedback page (`Pages/Feedback/OrganizerSessionFeedback.razor.cs`), and the speaker - detail / public speaker detail pages that list a speaker's sessions - (`Pages/Speaker/SpeakerDetail.razor.cs`, `Pages/Public/PublicSpeakerDetail.razor.cs`). +- **Where it's used**: implemented by [`SessionService`](#sessionservice); injected into the session + list, detail, and create pages (`Pages/Session/SessionList.razor.cs:23`, + `Pages/Session/SessionDetail.razor.cs:21`, `Pages/Session/SessionCreate.razor.cs:17`), the public + session pages (`Pages/Public/PublicSessionList.razor.cs:29`, + `Pages/Public/PublicSessionDetail.razor.cs:22`), the organizer session-feedback page + (`Pages/Feedback/OrganizerSessionFeedback.razor.cs:18`), and the speaker detail / public speaker + detail pages that list a speaker's sessions (`Pages/Speaker/SpeakerDetail.razor.cs:24`, + `Pages/Public/PublicSpeakerDetail.razor.cs:17`). ### ISpeakerDashboardUIService @@ -931,8 +1011,10 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod it is its own read-only interface, and it imports no `MMCA.Common.UI` interface at all (`ISpeakerDashboardUIService.cs:1-2`). - **Depends on**: [`SessionDTO`](group-17-conference-domain.md#sessiondto) and - [`SessionFeedbackDTO`](group-17-conference-domain.md#sessionfeedbackdto). `SpeakerIdentifierType` and - `SessionIdentifierType` id aliases. + [`SessionFeedbackDTO`](group-17-conference-domain.md#sessionfeedbackdto). `SpeakerIdentifierType` + (a `Guid` in this module, + `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:19`) + and `SessionIdentifierType` id aliases. - **Concept introduced, a cache-bypassing personalized read.** `[Rubric §23, Front-End Performance]` and `[Rubric §19, State Management]` (assess how the front end balances shared caching against read-your-writes freshness for a personalized view). The doc comment on `GetSpeakerSessionsAsync` @@ -942,26 +1024,30 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod freshly assigned speaker seeing "no sessions". The contract, not just the implementation, is where that decision is written down. - **Walkthrough**: four read methods, all `SpeakerIdentifierType`-scoped. - - `GetSpeakerSessionsAsync(speakerId, ct)` (lines 17-19): returns `Task>`, - the speaker's sessions, uncached. - - `GetSessionBookmarkCountAsync(speakerId, sessionId, ct)` (lines 21-24): returns `Task`, the - bookmark count for one of the speaker's sessions. - - `GetSessionBookmarkCountsAsync(speakerId, sessionIds, ct)` (lines 31-34): returns + - `GetSpeakerSessionsAsync(speakerId, ct)` (`ISpeakerDashboardUIService.cs:17-19`): returns + `Task>`, the speaker's sessions, uncached. + - `GetSessionBookmarkCountAsync(speakerId, sessionId, ct)` (`ISpeakerDashboardUIService.cs:21-24`): + returns `Task`, the bookmark count for one of the speaker's sessions. + - `GetSessionBookmarkCountsAsync(speakerId, sessionIds, ct)` + (`ISpeakerDashboardUIService.cs:31-34`): returns `Task>`, every requested session's active bookmark - count in a single request. The doc comment (lines 26-30) records that it replaces the dashboard's - per-session fan-out, that only sessions assigned to the speaker come back, and that sessions with no - bookmarks map to 0. That is the `[Rubric §12, Performance & Scalability]` point in one signature: an - N+1 of HTTP calls collapsed into one. - - `GetSessionFeedbackAsync(speakerId, sessionId, ct)` (lines 36-39): returns - `Task`, nullable when no feedback exists. + count in a single request. The doc comment (`ISpeakerDashboardUIService.cs:26-30`) records that it + replaces the dashboard's per-session fan-out, that only sessions assigned to the speaker come back, + and that sessions with no bookmarks map to 0. That is the `[Rubric §12, Performance & Scalability]` + point in one signature: an N+1 of HTTP calls collapsed into one. + - `GetSessionFeedbackAsync(speakerId, sessionId, ct)` (`ISpeakerDashboardUIService.cs:36-39`): + returns `Task`, nullable when no feedback exists. - **Why it's built this way**: keeping these on a dedicated interface (rather than folding them into [`ISessionUIService`](#isessionuiservice)) isolates the cache-bypass semantics to the personalized - surface and keeps the generic session CRUD cache-friendly. -- **Where it's used**: registered as `ISpeakerDashboardUIService` in the Conference UI DI + surface and keeps the generic session CRUD cache-friendly. It also keeps the speaker-scoped + authorization story simple: every method takes the speaker id explicitly, so the server has the + subject it needs to check ownership on every call. +- **Where it's used**: implemented by [`SpeakerDashboardService`](#speakerdashboardservice) and + registered explicitly in the Conference UI DI (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:32`, an explicit - `AddScoped` because it is not an `IEntityService<,>` and the assembly scan would not find it) and + `AddScoped` because it is not an `IEntityService<,>` and the assembly scan would not find it); injected into the speaker dashboard page - (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerDashboard.razor.cs`). + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerDashboard.razor.cs:22`). ### ISpeakerUIService @@ -978,18 +1064,21 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod operation, not a field edit, so it gets `LinkUserAsync` / `UnlinkUserAsync`. `[Rubric §7, Microservices Readiness]`: the UI issues one call against Conference, and the Identity side of the association is reconciled asynchronously by the `SpeakerLinkedToUser` / - `SpeakerUnlinkedFromUser` integration events, so this contract deliberately says nothing about - Identity. `[Rubric §18, UI Architecture]`. + `SpeakerUnlinkedFromUser` integration events + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/IntegrationEvents/SpeakerLinkedToUser.cs:20` + and `.../SpeakerUnlinkedFromUser.cs:17`, handled at + `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Speakers/IntegrationEventHandlers/SpeakerLinkedToUserHandler.cs:20`), + so this contract deliberately says nothing about Identity. `[Rubric §18, UI Architecture]`. - **Walkthrough**: two added members. - `LinkUserAsync(SpeakerIdentifierType speakerId, UserIdentifierType userId, CancellationToken)` - (line 11): returns `Task`. - - `UnlinkUserAsync(SpeakerIdentifierType speakerId, CancellationToken)` (line 13): returns - `Task`; unlink needs only the speaker id. -- **Where it's used**: injected into the speaker list, detail, create, and dashboard pages - (`Pages/Speaker/SpeakerList.razor.cs`, `SpeakerDetail.razor.cs`, `SpeakerCreate.razor.cs`, - `SpeakerDashboard.razor.cs`) and the public speaker pages - (`Pages/Public/PublicSpeakerList.razor.cs`, `PublicSpeakerDetail.razor.cs`). The concrete - `EntityServiceBase` subclass maps the two verbs onto the speaker link/unlink endpoints. + (`ISpeakerUIService.cs:11`): returns `Task`. + - `UnlinkUserAsync(SpeakerIdentifierType speakerId, CancellationToken)` (`ISpeakerUIService.cs:13`): + returns `Task`; unlink needs only the speaker id. +- **Where it's used**: implemented by [`SpeakerService`](#speakerservice); injected into the speaker + list, detail, create, and dashboard pages (`Pages/Speaker/SpeakerList.razor.cs:24`, + `Pages/Speaker/SpeakerDetail.razor.cs:23`, `Pages/Speaker/SpeakerCreate.razor.cs:15`, + `Pages/Speaker/SpeakerDashboard.razor.cs:21`) and the public speaker pages + (`Pages/Public/PublicSpeakerList.razor.cs:44`, `Pages/Public/PublicSpeakerDetail.razor.cs:16`). ### ISponsorUIService @@ -1002,20 +1091,19 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod - **Depends on**: [`IEntityService`](group-15-common-ui-framework.md#ientityservicetentitydto-tidentifiertype) and [`SponsorDTO`](group-17-conference-domain.md#sponsordto) (from `MMCA.ADC.Conference.Shared.Sponsors`, `ISponsorUIService.cs:1`). -- **Concept**: the same marker shape taught under [`ICategoryItemUIService`](#icategoryitemuiservice); - the doc comment (`ISponsorUIService.cs:6-8`) repeats the "uses generic CRUD" formula verbatim. - `[Rubric §16, Maintainability]` is the point worth pausing on: sponsors were the newest Conference - aggregate to reach the UI, and adding the whole admin surface plus a public sponsor page cost exactly - one empty interface and one three-line class ([`SponsorService`](#sponsorservice)), because the CRUD - algorithm, the auth, the retry, and the error translation were already inherited. - `[Rubric §18, UI Architecture]`. +- **Concept**: the same marker shape taught under [`IActivityUIService`](#iactivityuiservice); the doc + comment (`ISponsorUIService.cs:6-8`) repeats the "uses generic CRUD" formula verbatim. + `[Rubric §16, Maintainability]` is the point worth pausing on: the whole sponsor admin surface plus a + public sponsor page costs exactly one empty interface and one four-line class + ([`SponsorService`](#sponsorservice)), because the CRUD algorithm, the auth, the retry, and the error + translation are all inherited. `[Rubric §18, UI Architecture]`. - **Walkthrough**: no members. - **Why it's built this way**: sponsor management is plain CRUD from the client's point of view, so the - contract adds nothing; the named marker exists so the Scrutor scan in + contract adds nothing; the named marker exists so the Scrutor scan inside `AddUIModule()` can bind a concrete implementation to a name the pages inject (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:23`). - **Where it's used**: implemented by [`SponsorService`](#sponsorservice) - (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/SponsorService.cs:12`); injected + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/SponsorService.cs:10`); injected into the sponsor list, detail, and create pages and the anonymous public sponsor list (`Pages/Sponsor/SponsorList.razor.cs:24`, `Pages/Sponsor/SponsorDetail.razor.cs:22`, `Pages/Sponsor/SponsorCreate.razor.cs:20`, `Pages/Public/PublicSponsorList.razor.cs:25`). @@ -1050,23 +1138,26 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod - **Walkthrough** - `Endpoint` (`OrganizerFeedbackService.cs:19`): the `private const string "eventquestionanswers"` resource root. - - `GetAllAnswersAsync(eventId, ct)` (line 21): takes a client from + - `GetAllAnswersAsync(eventId, ct)` (`OrganizerFeedbackService.cs:21`): takes a client from `CreateAuthenticatedClientAsync()` (line 25), builds `{Endpoint}/paged?filters[EventId].operator=equals&filters[EventId].value={eventId}&pageSize=500&includeChildren=false` with `string.Create(CultureInfo.InvariantCulture, ...)` (lines 27-28, culture-invariant so the numeric id renders stably), runs the GET inside `RetryPolicy.ExecuteAsync` (lines 30-31), calls `EnsureSuccessStatusCode()` (line 33), deserializes a - [`PagedCollectionResult`](group-01-result-error-handling.md#pagedcollectionresultt) - (lines 35-36) and returns its `Items`, an empty list when the body was null (line 38). The - `filters[...]` query grammar is the same dynamic-filter contract the Conference REST controllers - expose, so the client does not need a bespoke endpoint. - - `DeleteAnswerAsync(eventId, answerId, ct)` (line 41): builds + [`PagedCollectionResult`](group-01-result-error-handling.md#pagedcollectionresultt) of + [`EventQuestionAnswerDTO`](group-17-conference-domain.md#eventquestionanswerdto) (lines 35-36), and + returns its `Items`, an empty list when the body was null (line 38). The `filters[...]` query + grammar is the same dynamic-filter contract the Conference REST controllers expose (see + [ADR-034](https://ivanball.github.io/docs/adr/034-generic-entity-query-layer.html)), so the client + needs no bespoke endpoint. + - `DeleteAnswerAsync(eventId, answerId, ct)` (`OrganizerFeedbackService.cs:41`): builds `{Endpoint}/{answerId}?eventId={eventId}` (line 48, the event id is a required query argument, mirroring the child-scoped delete pattern), issues the DELETE through the retry policy (lines 49-50), and on a non-success status routes the response through - [`ServiceExceptionHelper.ThrowIfDomainExceptionAsync`](group-15-common-ui-framework.md#serviceexceptionhelper) - (lines 52-53) so a domain error surfaces as a typed exception before the final - `EnsureSuccessStatusCode()` (line 55). It returns a bare `Task`: success is "did not throw". + [`ServiceExceptionHelper`](group-15-common-ui-framework.md#serviceexceptionhelper)'s + `ThrowIfDomainExceptionAsync` (lines 52-53) so a domain error surfaces as a typed exception before + the final `EnsureSuccessStatusCode()` (line 55). It returns a bare `Task`: success is "did not + throw". - **Why it's built this way**: inheriting the authenticated base means token attachment and the Polly retry live in one shared place; the service owns only the URL shapes and the organizer-sees-all read. Asking for `pageSize=500` in a single call keeps the organizer feedback grid simple (no client-side @@ -1098,18 +1189,17 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod authenticated-read pattern; this class differs only in the entity it keys on. The two are the same shape at different resource roots, which is why they share a file. - | Member | File:Line | Differs from the event sibling | - |--------|-----------|--------------------------------| - | `Endpoint` const | `OrganizerFeedbackService.cs:66` | `"sessionquestionanswers"` (vs `"eventquestionanswers"`) | - | `GetAllAnswersAsync(sessionId, ct)` | `OrganizerFeedbackService.cs:68` | filters on `SessionId`; returns `SessionQuestionAnswerDTO` | - | `DeleteAnswerAsync(sessionId, answerId, ct)` | `OrganizerFeedbackService.cs:88` | scopes the delete with `?sessionId={sessionId}` (line 95) | - -- **Walkthrough**: mechanically the same as the event service. The paged GET (line 68) uses the same - `pageSize=500&includeChildren=false` shape and culture-invariant URL build (lines 74-75), runs inside - `RetryPolicy.ExecuteAsync` (lines 77-78), and returns `Items` or an empty list (line 85); the DELETE - (line 88) routes non-success responses through - [`ServiceExceptionHelper.ThrowIfDomainExceptionAsync`](group-15-common-ui-framework.md#serviceexceptionhelper) - (lines 99-100) before `EnsureSuccessStatusCode()` (line 102). + | Type | File:Line | Notes (what differs) | + |------|-----------|----------------------| + | `OrganizerEventFeedbackService` | `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/OrganizerFeedbackService.cs:15` | `eventquestionanswers` root (line 19); filters on `EventId`; delete scoped `?eventId=` (line 48) | + | `OrganizerSessionFeedbackService` | `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/OrganizerFeedbackService.cs:62` | `sessionquestionanswers` root (line 66); filters on `SessionId`; delete scoped `?sessionId=` (line 95) | + +- **Walkthrough**: mechanically the same as the event service. The paged GET + (`OrganizerFeedbackService.cs:68`) uses the same `pageSize=500&includeChildren=false` shape and + culture-invariant URL build (lines 74-75), runs inside `RetryPolicy.ExecuteAsync` (lines 77-78), and + returns `Items` or an empty list (line 85); the DELETE (line 88) routes non-success responses through + [`ServiceExceptionHelper`](group-15-common-ui-framework.md#serviceexceptionhelper)'s + `ThrowIfDomainExceptionAsync` (lines 99-100) before `EnsureSuccessStatusCode()` (line 102). - **Why it's built this way**: two small parallel classes are cheaper to read than one generic service parameterized over "the parent key", and each one's URL shape stays literal and greppable. - **Where it's used**: registered as @@ -1131,9 +1221,9 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod - **Depends on**: [`SpeakerInfo`](#speakerinfo) (the lightweight projection it emits), [`SpeakerDTO`](group-17-conference-domain.md#speakerdto) (the wire shape it reads), [`PagedCollectionResult`](group-01-result-error-handling.md#pagedcollectionresultt); BCL - `IHttpClientFactory` and `System.Net.Http.Json`. Note it takes only `IHttpClientFactory` and no token - storage (`SpeakerLookupService.cs:11`): this is an unauthenticated public read, and it does not derive - from [`AuthenticatedServiceBase`](group-15-common-ui-framework.md#authenticatedservicebase). + `IHttpClientFactory` and `System.Net.Http.Json`. Note that it takes only `IHttpClientFactory` and no + token storage (`SpeakerLookupService.cs:11`): this is an unauthenticated public read, and it does not + derive from [`AuthenticatedServiceBase`](group-15-common-ui-framework.md#authenticatedservicebase). - **Concept introduced, the client-side denormalizing lookup.** `[Rubric §23, Front-End Performance]` (assesses avoiding N per-item round-trips). Session and event pages hold speaker *ids* but must show speaker *names*; rather than fetch each speaker individually, this service pulls the whole speaker set @@ -1141,8 +1231,8 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod (`SpeakerLookupService.cs:7-10`) states that use directly. `[Rubric §9, API & Contract Design]` shows up in the query string: `includeFKs=false&includeChildren=false` asks the server for the flat rows only, so the bulk read stays cheap on both ends. -- **Walkthrough**: one method, `GetAllAsync(ct)` (lines 14-15). It resolves the named `"APIClient"` - `HttpClient` from the factory (line 17), GETs +- **Walkthrough**: one method, `GetAllAsync(ct)` (`SpeakerLookupService.cs:14-15`). It resolves the + named `"APIClient"` `HttpClient` from the factory (line 17), GETs `speakers?includeFKs=false&includeChildren=false&pageSize=10000` (a deliberately large page to pull every speaker in one request, lines 19-21), takes `wrapper?.Items` or an empty list (line 23), then loops building a `Dictionary` whose entries carry `Id`, @@ -1156,47 +1246,77 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod "cross-module lookup services" (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:42`) and injected into the session list, session detail, public session list, and public session detail pages - (`Pages/Session/SessionList.razor.cs`, `Pages/Session/SessionDetail.razor.cs`, - `Pages/Public/PublicSessionList.razor.cs`, `Pages/Public/PublicSessionDetail.razor.cs`). + (`Pages/Session/SessionList.razor.cs:25`, `Pages/Session/SessionDetail.razor.cs:23`, + `Pages/Public/PublicSessionList.razor.cs:33`, `Pages/Public/PublicSessionDetail.razor.cs:23`). - **Caveats / not-in-source**: the `pageSize=10000` ceiling (`SpeakerLookupService.cs:20`) assumes the conference never exceeds 10,000 speakers; beyond that the lookup would silently miss speakers. The dictionary is built fresh on every call (there is no memoization in this class), so a page that needs it twice pays for it twice. -### CategoryItemService +### ActivityService -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Services` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/CategoryItemService.cs:10` · Level 4 · class (sealed) +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Services` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/ActivityService.cs:10` · Level 4 · class (sealed) -- **What it is**: the concrete HTTP service for the `categoryitems` resource, a body-less class that - inherits all CRUD from the shared base and binds the endpoint name (`CategoryItemService.cs:10-14`). - It implements [`ICategoryItemUIService`](#icategoryitemuiservice). +- **What it is**: the concrete HTTP service for the `activities` resource, a body-less class that + inherits all CRUD from the shared base and binds the endpoint name (`ActivityService.cs:10-14`). It + implements [`IActivityUIService`](#iactivityuiservice). - **Depends on**: [`EntityServiceBase`](group-15-common-ui-framework.md#entityservicebasetentitydto-tidentifiertype) - (its base), [`ITokenStorageService`](group-15-common-ui-framework.md#itokenstorageservice), - [`CategoryItemDTO`](group-17-conference-domain.md#categoryitemdto); BCL `IHttpClientFactory`. -- **Concept introduced, the three-line concrete UI service (Template Method with a supplied endpoint).** + (its base, from `MMCA.Common.UI.Services` at `ActivityService.cs:2`), + [`ITokenStorageService`](group-15-common-ui-framework.md#itokenstorageservice) (from + `MMCA.Common.UI.Services.Auth` at `ActivityService.cs:3`), and + [`ActivityDTO`](group-17-conference-domain.md#activitydto); BCL `IHttpClientFactory`. +- **Concept introduced, the four-line concrete UI service (Template Method with a supplied endpoint).** `[Rubric §2, Design Patterns]` (assesses whether a shared algorithm is factored once and specialized - by leaves; here the base owns the CRUD algorithm and the leaf supplies the resource name) and + by leaves; here the base owns the CRUD algorithm and the leaf supplies only the resource name) and `[Rubric §16, Maintainability]` (a new plain-CRUD resource costs one tiny class). The primary constructor forwards `IHttpClientFactory` and [`ITokenStorageService`](group-15-common-ui-framework.md#itokenstorageservice) plus the literal - resource name `"categoryitems"` to - [`EntityServiceBase`](group-15-common-ui-framework.md#entityservicebasetentitydto-tidentifiertype) - (`CategoryItemService.cs:10-12`); the class body is empty (`CategoryItemService.cs:13-14`). Every CRUD - method, along with the auth, the Polly retry, the serialization, and the domain-error translation, - comes from the base, see + resource name `"activities"` to + [`EntityServiceBase`](group-15-common-ui-framework.md#entityservicebasetentitydto-tidentifiertype) + closed over `ActivityDTO` and `ActivityIdentifierType` (`ActivityService.cs:10-12`); the class body is + empty (`ActivityService.cs:13-14`). Every CRUD method, along with the auth, the Polly retry, the + serialization, and the domain-error translation, comes from the base, see [`EntityServiceBase`](group-15-common-ui-framework.md#entityservicebasetentitydto-tidentifiertype) - in Group 15. + in Group 15. Every plain-CRUD concrete service in this group repeats this shape. - **Walkthrough**: no members. The whole class is the base call carrying the resource root - `"categoryitems"` (`CategoryItemService.cs:12`) and the declaration that it satisfies - [`ICategoryItemUIService`](#icategoryitemuiservice) (same line). + `"activities"` (`ActivityService.cs:12`) and the declaration that it satisfies + [`IActivityUIService`](#iactivityuiservice) (same line). The doc comment (`ActivityService.cs:7-9`) + says only that it "provides standard CRUD". - **Why it's built this way**: the endpoint name is the only thing that varies for a plain CRUD - aggregate, so the concrete class carries exactly that and nothing else. + aggregate, so the concrete class carries exactly that and nothing else. `sealed` + (`ActivityService.cs:10`) closes the leaf: specialization belongs on the interface or in the base, not + in a subclass of a subclass. - **Where it's used**: never named in DI by hand. Because it is an `IEntityService<,>` implementation in the Conference UI assembly, the Scrutor scan inside `AddUIModule()` registers it `AsImplementedInterfaces()` with a scoped lifetime (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:23` calling `MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:155-159`), which is what makes - [`ICategoryItemUIService`](#icategoryitemuiservice) resolvable in the conference-category detail page. + [`IActivityUIService`](#iactivityuiservice) resolvable in [`ActivityList`](#activitylist), + [`ActivityDetail`](#activitydetail), [`ActivityCreate`](#activitycreate), and + [`PublicActivityList`](#publicactivitylist). + +### CategoryItemService + +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Services` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/CategoryItemService.cs:10` · Level 4 · class (sealed) + +- **What it is**: the concrete HTTP service for the `categoryitems` resource, structurally identical to + [`ActivityService`](#activityservice) but bound to + [`CategoryItemDTO`](group-17-conference-domain.md#categoryitemdto) and `CategoryItemIdentifierType` + (`CategoryItemService.cs:10-14`). It implements [`ICategoryItemUIService`](#icategoryitemuiservice). +- **Depends on**: [`EntityServiceBase`](group-15-common-ui-framework.md#entityservicebasetentitydto-tidentifiertype) + (its base), [`ITokenStorageService`](group-15-common-ui-framework.md#itokenstorageservice), + [`CategoryItemDTO`](group-17-conference-domain.md#categoryitemdto); BCL `IHttpClientFactory`. +- **Concept**: identical to [`ActivityService`](#activityservice); see it for the thin-class rationale. + The only differences are the resource root `"categoryitems"` (`CategoryItemService.cs:12`), the DTO + plus identifier alias, and the interface it satisfies. `[Rubric §16, Maintainability]`. +- **Walkthrough**: no members. The base call passes `"categoryitems"` alongside the factory and token + storage (`CategoryItemService.cs:10-12`), and the same line declares + [`ICategoryItemUIService`](#icategoryitemuiservice). +- **Where it's used**: picked up by the same assembly scan as its siblings + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:23`) and resolved + through [`ICategoryItemUIService`](#icategoryitemuiservice) in + [`ConferenceCategoryDetail`](#conferencecategorydetail) + (`Pages/ConferenceCategory/ConferenceCategoryDetail.razor.cs:16`). ### ConferenceCategoryService @@ -1210,113 +1330,22 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod - **Depends on**: [`EntityServiceBase`](group-15-common-ui-framework.md#entityservicebasetentitydto-tidentifiertype), [`ITokenStorageService`](group-15-common-ui-framework.md#itokenstorageservice), [`ConferenceCategoryDTO`](group-17-conference-domain.md#conferencecategorydto). -- **Concept**: identical to [`CategoryItemService`](#categoryitemservice); see it for the thin-class - rationale. The only differences are the resource root `"conferencecategories"` +- **Concept**: identical to [`ActivityService`](#activityservice); see it for the thin-class rationale. + The only differences are the resource root `"conferencecategories"` (`ConferenceCategoryService.cs:12`), the DTO plus identifier alias, and the interface it satisfies. - `[Rubric §16, Maintainability]`. + `[Rubric §16, Maintainability]`. Reading these three classes back to back is the clearest evidence of + what the shared base buys: three resources, twelve lines of code, zero duplicated HTTP handling. - **Walkthrough**: no members; the base call passes `"conferencecategories"` alongside the factory and token storage (`ConferenceCategoryService.cs:10-12`). -- **Where it's used**: picked up by the same assembly scan as its sibling and resolved through - [`IConferenceCategoryUIService`](#iconferencecategoryuiservice) in the conference-category list, - detail, and create pages and the speaker detail page. - -### EventService - -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Services` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/EventService.cs:13` · Level 4 · class (sealed) - -- **What it is**: the concrete HTTP service for the `events` resource. It inherits generic CRUD from the - base and adds the three event-specific calls promised by [`IEventUIService`](#ieventuiservice): - publish, unpublish, and Sessionize refresh (`EventService.cs:13-56`). -- **Depends on**: [`EntityServiceBase`](group-15-common-ui-framework.md#entityservicebasetentitydto-tidentifiertype) - and its inherited `Endpoint` / `SendRequestAsync` members, - [`ITokenStorageService`](group-15-common-ui-framework.md#itokenstorageservice), - [`EventDTO`](group-17-conference-domain.md#eventdto), - [`EventTransitionRequest`](group-17-conference-domain.md#eventtransitionrequest), - [`RefreshFromSessionizeResultDTO`](group-17-conference-domain.md#refreshfromsessionizeresultdto); BCL - `System.Net.Http.Json`, `System.Globalization`. -- **Concept introduced, adding action endpoints on top of the CRUD base via `SendRequestAsync`.** - `[Rubric §18, UI Architecture]` and `[Rubric §9, API & Contract Design]`. Where the plain CRUD - services have empty bodies, this one implements three extra verbs by calling the inherited - `SendRequestAsync` with a lambda that issues the actual HTTP call, so the concrete class writes - only URL plus verb plus body while the base owns auth, retry, and deserialization. The inherited - `Endpoint` (the resource root supplied to the base at `EventService.cs:14-15`) is reused to build the - action URLs. `[Rubric §8, Data Architecture]` also lands here: both transitions post an - [`EventTransitionRequest`](group-17-conference-domain.md#eventtransitionrequest) carrying the - optimistic-concurrency `RowVersion` the caller passed in - (`EventService.cs:25`, `EventService.cs:40`), so a transition decided against a stale view of the - event is rejected by the server instead of applied silently - ([ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html)). -- **Walkthrough** - - Constructor (`EventService.cs:13-15`): forwards the factory, token storage, and `"events"` to - [`EntityServiceBase`](group-15-common-ui-framework.md#entityservicebasetentitydto-tidentifiertype). - - `PublishAsync(id, rowVersion, ct)` (`EventService.cs:17-30`): `SendRequestAsync` posting a - `new EventTransitionRequest { RowVersion = rowVersion }` body via `PostAsJsonAsync` to - `{Endpoint}/{id}/publish`, with the URL built through - `string.Create(CultureInfo.InvariantCulture, ...)` (line 24) and `expectContent: false` (line 28) - because the endpoint returns no body; returns a constant `true` (line 29). - - `UnpublishAsync(id, rowVersion, ct)` (`EventService.cs:32-45`): the mirror call to - `{Endpoint}/{id}/unpublish` (line 39), same body, same `expectContent: false`, same `true`. - - `RefreshFromSessionizeAsync(id, ct)` (`EventService.cs:47-55`): an expression-bodied member that - POSTs to `{Endpoint}/{id}/refresh` with a **null** content (line 53) and, unlike the transition - pair, expects a body, so `SendRequestAsync` deserializes the sync - summary and returns it (nullable). -- **Why it's built this way**: publish, unpublish, and refresh are distinct server actions, not CRUD - updates, so they map to dedicated `/{id}/action` endpoints; routing them through the inherited - `SendRequestAsync` keeps the auth, retry, and domain-error behavior identical to the inherited CRUD - rather than growing a second, divergent HTTP path in this class. -- **Where it's used**: resolved as [`IEventUIService`](#ieventuiservice) through the Conference UI - assembly scan; injected into the event list/detail/create pages that expose the publish and - Sessionize-refresh buttons, the public event pages, and the session lists that need their owning - event. -- **Caveats / not-in-source**: `PublishAsync` and `UnpublishAsync` return a constant `true` - (`EventService.cs:29`, `EventService.cs:44`); the `bool` carries no failure signal of its own, because - failures (including a `409 Conflict` from a stale `RowVersion`) surface as exceptions thrown by the - base dispatch and are handled by the calling page. - -### EventSpeakerService - -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Services` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/ChildEntityServices.cs:14` · Level 4 · class (sealed) - -- **What it is**: the HTTP service for the `EventSpeaker` join entity, POST to add a speaker to an event - and DELETE to remove one (`ChildEntityServices.cs:14-25`). It implements - [`IEventSpeakerUIService`](#ieventspeakeruiservice) and is the first of four structurally identical - join-entity services in this file (`SessionSpeakerService`, `SessionCategoryItemService`, and - `SpeakerCategoryItemService` at `ChildEntityServices.cs:30`, `:46`, and `:62`, documented with the - other Conference UI join services). -- **Depends on**: [`ChildEntityServiceBase`](group-15-common-ui-framework.md#childentityservicebase) - (its base, which owns `PostAsync` and `DeleteByIdAsync`), - [`ITokenStorageService`](group-15-common-ui-framework.md#itokenstorageservice), - [`EventSpeakerDTO`](group-17-conference-domain.md#eventspeakerdto); BCL `IHttpClientFactory`, - `System.Net.Http.Json`, `System.Globalization`. -- **Concept introduced, the join-entity UI service.** `[Rubric §18, UI Architecture]` (assesses a - consistent typed abstraction for many-to-many association edits). A join entity has no rich lifecycle - and no CRUD detail page; it is only ever created or removed, so it derives from the leaner - [`ChildEntityServiceBase`](group-15-common-ui-framework.md#childentityservicebase) rather than - [`EntityServiceBase`](group-15-common-ui-framework.md#entityservicebasetentitydto-tidentifiertype). - The primary constructor forwards the factory, token storage, and resource name `"eventspeakers"` to - the base (`ChildEntityServices.cs:14-15`), which centralizes auth, domain-error translation, and the - add/remove HTTP mechanics. Because it is *not* an `IEntityService<,>`, the assembly scan does not see - it, which is exactly why it (and its three siblings) are registered by hand. -- **Walkthrough** - - `AddAsync(eventId, speakerId, ct)` (`ChildEntityServices.cs:17-21`): calls the base `PostAsync` with - an anonymous payload `new { EventId = eventId, SpeakerId = speakerId }` (line 19) and deserializes - the created [`EventSpeakerDTO`](group-17-conference-domain.md#eventspeakerdto) from the response body - (line 20, nullable). - - `DeleteAsync(id, ct)` (`ChildEntityServices.cs:23-24`): delegates to the base `DeleteByIdAsync`, - formatting the join id with `CultureInfo.InvariantCulture` so the URL segment is culture-stable, and - returns the base's `bool` (the base maps a 404 to `false`, an idempotent remove). -- **Why it's built this way**: all four join services in this file share the same add/remove contract, so - the base holds the HTTP and error handling and each subclass supplies only the resource name and a - strongly typed `AddAsync` overload with the correct id fields. The trailing comment - (`ChildEntityServices.cs:75-76`) records that the base was hoisted out of this file into the shared - `MMCA.Common.UI.Services` namespace, which is the `[Rubric §16, Maintainability]` payoff: the pattern - now belongs to the framework, not to ADC. -- **Where it's used**: registered explicitly as [`IEventSpeakerUIService`](#ieventspeakeruiservice) - (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:26`). -- **Caveats / not-in-source**: no Blazor page or component in this repository injects - [`IEventSpeakerUIService`](#ieventspeakeruiservice) today; the registration and the typed service exist - but the only references to the interface are its declaration, this implementation, and the DI line - above. Its three siblings in the same file are consumed by the session and speaker detail editors. +- **Where it's used**: picked up by the same assembly scan as its siblings and resolved through + [`IConferenceCategoryUIService`](#iconferencecategoryuiservice) in + [`ConferenceCategoryList`](#conferencecategorylist) + (`Pages/ConferenceCategory/ConferenceCategoryList.razor.cs:16`), + [`ConferenceCategoryDetail`](#conferencecategorydetail) + (`Pages/ConferenceCategory/ConferenceCategoryDetail.razor.cs:15`), + [`ConferenceCategoryCreate`](#conferencecategorycreate) + (`Pages/ConferenceCategory/ConferenceCategoryCreate.razor.cs:11`), and + [`SpeakerDetail`](#speakerdetail) (`Pages/Speaker/SpeakerDetail.razor.cs:25`). ### ISessionSelectionUIService @@ -1801,113 +1830,259 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod ### SpeakerQr > MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Speaker` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerQr.razor.cs:19` · Level 1 · class (Blazor code-behind) -- **What it is**: the speaker-facing side of the speaker QR code. It renders one full-screen code that resolves to the speaker's own **public** profile page, for holding up at the podium or parking on a booth screen (`SpeakerQr.razor.cs:8-18`). -- **Depends on**: [`IPublicLinkBuilder`](#ipubliclinkbuilder) (`SpeakerQr.razor.cs:21`) and [`ConferenceRoutePaths`](#conferenceroutepaths) (`:55`); the cascading `Task` (`:23-24`); MudBlazor `BreadcrumbItem`/`Icons` and the shared `QrCodeImage` component from `MMCA.Common.UI` (rendered at `SpeakerQr.razor:26-30`). No UI service, no HTTP client, no DTO. +- **What it is**: the speaker-facing side of the speaker QR code. It renders one card-sized code that resolves to the speaker's own **public** profile page, for holding up at the podium or parking on a booth screen (`SpeakerQr.razor.cs:8-18`). +- **Depends on**: [`IPublicLinkBuilder`](#ipubliclinkbuilder) (`SpeakerQr.razor.cs:21`) and [`ConferenceRoutePaths`](#conferenceroutepaths) (`:55`); the cascading `Task` (`:23-24`); MudBlazor's `BreadcrumbItem` and `Icons`, plus the shared `QrCodeImage` component from `MMCA.Common.UI` and its [`QrErrorCorrectionLevel`](group-15-common-ui-framework.md#qrerrorcorrectionlevel) enum (rendered at `SpeakerQr.razor:26-30`). No UI service, no HTTP client, no DTO. - **Concept introduced, the zero-fetch page and the absolute-link rule.** This is the smallest page in the group and the clearest place to see two ideas. - 1. **Nothing is fetched.** The identity comes from the `speaker_id` JWT claim (`SpeakerQr.razor.cs:49-53`) and the payload is composed locally, so the page renders identically on the SSR prerender pass and on the interactive pass. No `CancellationTokenSource`, no loading flag, and no `IDisposable`: there is no in-flight request to cancel. `[Rubric §23, Front-End Performance & Rendering]` (assesses how much work a view costs to paint): this one costs a claim read and a string build. - 2. **The payload must be an absolute public URL.** `LinkBuilder.BuildAbsolute(...)` (`:55`, contract at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/IPublicLinkBuilder.cs:12`) converts the relative route into a fully-qualified public URL. The class doc records why (`SpeakerQr.razor.cs:15-16`): the MAUI head serves the Blazor app from a WebView-internal origin, so a code built from the ambient base URI would scan to an address that exists for nobody but that device. `[Rubric §22, Responsive & Cross-Browser]` and `[Rubric §7, Microservices Readiness]`: the link is built against the public host, not the head the code happens to run in. - Claim-derived scoping is the same mechanism [`SpeakerDashboard`](#speakerdashboard) uses; see that section for the security discussion. `[Rubric §26, Front-End Security]`: the speaker can only ever render their own code because the id is read from the validated token, never from a route parameter. + 1. **Nothing is fetched.** The identity comes from the `speaker_id` JWT claim (`SpeakerQr.razor.cs:49-53`) and the payload is composed locally, so the page renders identically on the SSR prerender pass and on the interactive pass. No `CancellationTokenSource`, no loading flag, and no `IDisposable`: there is no in-flight request to cancel, which is why this class has none of the disposal plumbing every other page in this unit carries. `[Rubric §23, Front-End Performance & Rendering]` (assesses how much work a view costs to paint): this one costs a claim read and a string build. + 2. **The payload must be an absolute public URL.** `LinkBuilder.BuildAbsolute(...)` (`:55`, contract at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/IPublicLinkBuilder.cs:12`) converts the relative route into a fully-qualified public URL. The interface doc records why (`IPublicLinkBuilder.cs:3-8`, echoed on the page at `SpeakerQr.razor.cs:15-16`): web heads can derive an origin from the browser, but the MAUI head serves the Blazor app from the WebView's virtual host, so a code built from the ambient base URI would scan to an address that exists for nobody but that device. `[Rubric §22, Responsive & Cross-Browser]` and `[Rubric §7, Microservices Readiness]`: the link is built against the public site, not the head the code happens to run in, so the same page is correct on every head. + Claim-derived scoping is the same mechanism [`SpeakerDashboard`](#speakerdashboard) uses; see that section for the fuller discussion. `[Rubric §26, Front-End Security]`: the speaker can only ever render their own code, because the id is read from the validated token and never from a route parameter. - **Walkthrough** - - Fields (`SpeakerQr.razor.cs:26-28`): the breadcrumb list, the nullable `_payload` (null keeps the code hidden), and `_displayName`. + - Fields (`SpeakerQr.razor.cs:26-28`): the breadcrumb list, the nullable `_payload` (null keeps the code hidden and swaps in an explanatory alert), and `_displayName`. - `OnInitializedAsync` (`:30-56`): builds the two-item breadcrumb trail (`:32-36`), returns early when there is no cascading auth state (`:38-41`), reads `state.User.Identity?.Name` into `_displayName` so a scanner can see whose profile the code opens before opening it (`:45-47`), then requires a parsable `speaker_id` claim (`:49-53`) before building the payload (`:55`). - - The markup passes the payload to `QrCodeImage` with a localized `AltText` and `QrErrorCorrectionLevel.Medium` (`SpeakerQr.razor:26-30`). `[Rubric §21, Accessibility]`: the image carries alt text rather than being a decorative canvas. -- **Why it's built this way**: a speaker holding up a phone at a podium needs the code to appear instantly and to work when scanned by a stranger's camera; both requirements point at a locally-composed absolute URL and no network dependency at all. -- **Where it's used**: the `/speaker/qr` route (`SpeakerQr.razor:1`), the speaker portal companion to [`SpeakerDashboard`](#speakerdashboard). The same target URL is offered from the reader's side by the `QrCodeButton` on the public profile (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSpeakerDetail.razor:44-45`). -- **Caveats / not-in-source**: the `speaker_id` claim is issued by the Identity service when an organizer links a User to a Speaker (see [`SpeakerDetail`](#speakerdetail)); this page only reads it. + - The markup renders `QrCodeImage` with the payload, a localized `AltText`, `PixelsPerModule="14"`, and `QrErrorCorrectionLevel.Medium` (`SpeakerQr.razor:26-30`); the in-markup comment (`SpeakerQr.razor:24-25`) records the reasoning: readable from a few steps away, with enough error correction to survive screen glare. `[Rubric §21, Accessibility]`: the code carries alt text rather than being a decorative canvas. + - The no-claim branch is not an empty card. The markup renders an informational alert (`SpeakerQr.razor:11-18`) because the nav entry is claim-gated but a bookmarked or typed URL still lands here. `[Rubric §24, Forms, Validation & UX Safety]`: the dead end is explained rather than rendered blank. +- **Why it's built this way**: a speaker holding up a phone at a podium needs the code to appear instantly and to work when scanned by a stranger's camera. Both requirements point at a locally composed absolute URL and no network dependency at all. +- **Where it's used**: the `/speaker/qr` route, `[Authorize]` with no role requirement (`SpeakerQr.razor:1-2`), the speaker-portal companion to [`SpeakerDashboard`](#speakerdashboard). The same target URL is offered from the reader's side by the `QrCodeButton` on the public profile (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSpeakerDetail.razor:45-46`). +- **Caveats / not-in-source**: the `speaker_id` claim is issued when an organizer links a User to a Speaker (see [`SpeakerDetail`](#speakerdetail)); the claim's issuance lives in the Identity service, and this page only reads it. + +--- + +### SpeakerCreate +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Speaker` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerCreate.razor.cs:13` · Level 5 · class (Blazor code-behind) + +- **What it is**: the organizer's speaker-creation form. It collects first and last name, bio, tagline, email, profile picture, and the four social links, packs them into a new [`SpeakerDTO`](group-17-conference-domain.md#speakerdto), and posts it (`SpeakerCreate.razor.cs:9-12`). +- **Depends on**: [`ISpeakerUIService`](#ispeakeruiservice) (`:15`), [`SpeakerDTO`](group-17-conference-domain.md#speakerdto) (`:69`), [`ConferenceRoutePaths`](#conferenceroutepaths) (`:30,88,104`), and [`ErrorMessages`](group-15-common-ui-framework.md#errormessages) (`:62`); MudBlazor's `MudForm` and `ISnackbar` (`:17,47`), Blazor's `NavigationManager` (`:16`), and the shared `UnsavedChangesGuard` component (`SpeakerCreate.razor:8`). +- **Concept**: the create-page pattern this group teaches once (validate before mutate, dirty tracking, cancel on disposal), here in its widest-form variant. `[Rubric §24, Forms, Validation & UX Safety]` (assesses validate-before-submit and unsaved-change protection): `CreateSpeakerAsync` calls `await _form.ValidateAsync()` and bails with a warning snackbar when `!_form.IsValid` (`:59-64`) before touching the service, and `_isDirty` (set by `MarkDirty()`, `:50`) is cleared the instant the save succeeds, **before** navigating (`:86`), so the guard cannot block its own redirect. The `CancellationTokenSource` (`:19`) is passed to the service call (`:85`) and cancelled in the standard dispose pattern (`:106-128`), with `OperationCanceledException` swallowed as the expected teardown outcome (`:90-93`). + The identifier detail is worth pausing on. `Speaker` is Guid-keyed, so this page mints a genuinely unique id client-side with `Guid.NewGuid()` (`:71`), while the int-keyed create forms in this same module send `Id = default` and let the database assign one (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Event/EventCreate.razor.cs:78`, `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/ConferenceCategory/ConferenceCategoryCreate.razor.cs:58`). `[Rubric §8, Data Architecture]` (assesses a deliberate identity strategy): the per-entity identifier alias ([ADR-048](https://ivanball.github.io/docs/adr/048-primitive-identifier-type-aliases.html)) keeps the key type out of the page's own logic, and either way the page reads `created.Id` back from the response (`:88`) rather than trusting what it sent. +- **Walkthrough**: `OnInitialized` (`:24-33`) builds the Home / Speakers / Create breadcrumb trail; the private `Title` property (`:20`) pulls the page title from the localizer so `PageTitle` and the heading share one resource key (`[Rubric §27, Internationalization]`). `CreateSpeakerAsync` (`:52-102`) validates, sets `IsSaving` (`:66`, the flag the markup uses to disable the submit button), composes `FullName` from the two name fields (`:74`), builds the DTO with all optional profile and social fields (`:69-83`), calls `SpeakerService.AddAsync` (`:85`), snackbars, and navigates to `ConferenceRoutePaths.SpeakerDetails(created.Id)` (`:87-88`). A non-cancellation failure raises one error snackbar (`:94-97`) and the `finally` always clears `IsSaving` (`:98-101`), so a failed save leaves the form editable rather than stuck. +- **Why it's built this way**: one create-form shape reused per entity keeps the flow uniform (validate, post, redirect to detail) while each page varies only in the fields it collects. +- **Where it's used**: the `/speakers/create` route, restricted to the Organizer role (`SpeakerCreate.razor:1-2`), reached from [`SpeakerList`](#speakerlist)'s create button; it redirects to [`SpeakerDetail`](#speakerdetail). +- **Caveats / not-in-source**: whether the server honors or replaces the client-minted Guid is a server-side decision not visible here; the page uses the id from the response either way. + +--- + +### SpeakerCategoryItemsPanel +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Speaker` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerCategoryItemsPanel.razor.cs:16` · Level 8 · class (Blazor code-behind) + +- **What it is**: the "Additional Info" panel carved out of [`SpeakerDetail`](#speakerdetail). It renders a speaker's category items grouped by category and hosts the add and remove chip actions (`SpeakerCategoryItemsPanel.razor.cs:9-15`). +- **Depends on**: [`ISpeakerCategoryItemUIService`](#ispeakercategoryitemuiservice) (`:18`), [`SpeakerDTO`](group-17-conference-domain.md#speakerdto) (`:22`), [`SpeakerCategoryItemDTO`](group-17-conference-domain.md#speakercategoryitemdto) (`:42`), [`CategoryItemInfo`](#categoryiteminfo) (`:25`), and the `CategoryItem` / `ConferenceCategory` / `SpeakerCategoryItem` identifier aliases (`:25,28,34,81`); MudBlazor's `ISnackbar` (`:19`). +- **Concept introduced, the container/presentational split with an `EventCallback` up-channel.** The page (the container) passes down the `Speaker` plus the two lookups it already owns (`:22-28`), and the panel signals mutations back up through the `Changed` callback (`:31`). After an add or a remove the panel calls `await Changed.InvokeAsync()` (`:73,87`), and the page responds by reloading the speaker, so behavior is identical to the pre-split page (class doc, `:12-14`). `[Rubric §18, UI Architecture & Component Design]` (assesses cohesive, single-responsibility components): the split trims an already-large parent and gives this sub-view one job. `[Rubric §19, State Management & Data Flow]` (assesses where state lives and how it flows): the panel holds no source-of-truth state, only the transient `_selectedCategoryItemId` (`:34`); data flows down as parameters and mutations flow up through the callback, the canonical unidirectional Blazor pattern. + One localization detail follows from the split: the panel injects `IStringLocalizer`, not a localizer of its own (`SpeakerCategoryItemsPanel.razor:2`), so the extracted markup keeps using the parent page's resource files instead of forking a second `.resx` pair. `[Rubric §27, Internationalization]` and `[Rubric §16, Maintainability]`. +- **Walkthrough** + - `GetCategoryTitle` (`:36-37`) and `GetCategoryItemName` (`:39-40`): resolve ids to display names, falling back to the invariant-culture id when the lookup has no entry, so a missing lookup degrades to a number rather than an exception. + - `GetCategoryItemsGroupedByCategory` (`:42-50`): filters the speaker's assigned items to those present in the lookup and groups them by their parent category id; `GetAvailableCategoryItems` (`:52-59`) builds the add dropdown by excluding already-assigned items, so the same item cannot be added twice from the UI. + - `AddCategoryItemAsync` (`:61-79`): no-ops without a selection (`:63-66`), posts the item against the speaker id (`:70`), clears the selection, snackbars, and invokes `Changed` (`:71-73`); `RemoveCategoryItemAsync` (`:81-93`) deletes by the **join-entity** id (the `SpeakerCategoryItem` row, not the category item) and invokes `Changed` (`:85-87`). Both catch broadly and report through a snackbar (`:75-78,89-92`) rather than surfacing an exception into the render tree. `[Rubric §29, Resilience & Business Continuity]`. + - The panel owns its own `CancellationTokenSource` (`:33`), cancelled in the standard dispose pattern (`:95-117`), because unlike a purely presentational child it makes its own service calls. +- **Why it's built this way**: the speaker editor grew large enough that carving out a self-contained sub-view (owning its own service call, delegating state to the page) shrinks the parent and makes the panel independently testable, with no change in observable behavior. +- **Where it's used**: rendered inside [`SpeakerDetail`](#speakerdetail) (`SpeakerDetail.razor:181`), which supplies `Speaker`, `CategoryItems`, `CategoryTitles`, and a `Changed` handler that reloads the speaker. + +--- + +### SpeakerDetail +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Speaker` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerDetail.razor.cs:19` · Level 8 · class (Blazor code-behind) + +- **What it is**: the full speaker console. Beyond load, inline edit, and delete it composes the category-item panel, resolves question-answer text, lists the speaker's sessions, and runs the **link/unlink a User to this Speaker** flow (`SpeakerDetail.razor.cs:14-18`). +- **Depends on**: six UI clients, [`ISpeakerUIService`](#ispeakeruiservice), [`ISessionUIService`](#isessionuiservice), [`IConferenceCategoryUIService`](#iconferencecategoryuiservice), [`ICategoryItemLookupService`](#icategoryitemlookupservice), [`IQuestionUIService`](#iquestionuiservice), and [`IUserUIService`](group-24-identity-module.md#iuseruiservice) (`:23-28`); [`SpeakerDTO`](group-17-conference-domain.md#speakerdto), [`SessionDTO`](group-17-conference-domain.md#sessiondto), [`UserListDTO`](group-24-identity-module.md#userlistdto), [`CategoryItemInfo`](#categoryiteminfo), [`ConferenceRoutePaths`](#conferenceroutepaths) (`:48,359,361`), [`ErrorMessages`](group-15-common-ui-framework.md#errormessages) (`:100,115,204,241,274`), and the shared `DeleteConfirmation` component (`:71`). It hosts [`SpeakerCategoryItemsPanel`](#speakercategoryitemspanel). +- **Concept introduced, the cross-module composition page.** One page composes data from Conference **and** Identity plus three lookups resolved into display names. `[Rubric §7, Microservices Readiness]` (assesses that cross-module access goes through abstractions rather than direct references): the Identity reach is [`IUserUIService`](group-24-identity-module.md#iuseruiservice), an HTTP client behind an interface, so the page is indifferent to Identity running as its own service behind the gateway. `[Rubric §18, UI Architecture & Component Design]` (a high dependency count is a cohesion signal worth watching): the page delegates its category-item sub-view to a child component and keeps the rest, so its remaining size comes from orchestrating six clients and three lookups rather than from bespoke mechanics. + Note the authorization shape, because it differs from its siblings: the route carries a bare `[Authorize]` (`SpeakerDetail.razor:2`), while [`SpeakerList`](#speakerlist) and [`SpeakerCreate`](#speakercreate) both require `Roles = "Organizer"`. The page is reachable by any authenticated user who has the id, and the actual read and write authorization is enforced by the Conference API behind each service call. `[Rubric §11, Security]` (assesses that the server, not the route attribute, is the authorization boundary). +- **Walkthrough** + - Load once per id: `OnParametersSetAsync` (`:80-89`) compares the route `[Parameter] string Id` (`:32`) against `_loadedId` (`:53`) so a re-render does not refetch. + - `LoadAsync` (`:91-121`): `GetByIdAsync(speakerId, true, ...)` with children included (`:97`), a not-found snackbar through [`ErrorMessages`](group-15-common-ui-framework.md#errormessages) (`:100`), then three lookups hydrated lazily with `??=` so a reload does not re-fetch them: category items (`:104`), category titles (`LoadCategoryTitlesAsync`, `:150-155`), and question texts (`LoadQuestionTextsAsync`, `:157-162`). `GetQuestionText` (`:164-165`) resolves an answer's question id with the same invariant-culture id fallback the panel uses. + - `LoadSpeakerSessionsAsync` (`:131-148`): a server-side `SpeakerId equals` filter (`:133-136`), sorted by `StartsAt` ascending, `includeChildren: false`, capped at `MaxSpeakerSessions = 100` (`:34-35`). The remarks block (`:126-130`) records what this replaced: the page used to pull the entire session catalog with all child collections and filter it in memory on `SessionSpeakers`, even though it never renders those children. `[Rubric §12, Performance & Scalability]` (assesses moving work to where the data lives) and `[Rubric §8, Data Architecture]` (a bounded page size instead of an unbounded read). + - Inline edit (`:167-247`): `StartEditing` seeds the `_edit*` **shadow fields** from the loaded record (`:167-186`) and `CancelEditing` simply discards them (`:188-192`), so the live `Speaker` object is never mutated until a validated save succeeds. `SaveChangesAsync` (`:194-247`) validates the `MudForm` first (`:201-206`), rebuilds the DTO preserving `RowVersion` (`:214`) and `LinkedUserId` (`:226`) so a profile edit cannot silently clear the organizer-managed link, updates, and re-fetches the record (`:229-230`). `[Rubric §24, Forms, Validation & UX Safety]` and `[Rubric §8, Data Architecture]`: the round-tripped `RowVersion` is the client half of optimistic concurrency. + - Delete (`:249-276`): confirm through the shared `DeleteConfirmation` dialog with the speaker's own name in the prompt (`:256`), delete, then navigate back to the list (`:264-266`). + - **User link and unlink** (`:279-357`): `SearchUsersAsync` is the notable one. `GetPagedAsync` ANDs its filters server-side (in-code comment, `:288-290`), so a single call with email, first name, and last name all set to the same term would return the empty intersection. The page instead fans out **three parallel calls** (`:291-295`), unions the results with `DistinctBy(u => u.UserId)` and takes 10 (`:301-305`). A cancellation returns an empty list rather than throwing into the autocomplete (`:307-310`). `OnUserPickedAsync` (`:313-334`) calls `LinkUserAsync` and reloads; `UnlinkUserAsync` (`:336-357`) clears the link. This is the flow that produces the `speaker_id` claim that [`SpeakerDashboard`](#speakerdashboard) and [`SpeakerQr`](#speakerqr) depend on. + - Disposal (`:363-385`) is the standard cancel-on-disposal pattern over the page's `CancellationTokenSource` (`:37`); the unsaved-changes guard is wired from `_isDirty` (`:70`, `SpeakerDetail.razor:11`). +- **Why it's built this way**: an organizer needs one console to fully administer a speaker, including wiring them to a login account; composing the views here (and delegating the category panel) trades page breadth for a one-stop editor. The three-call user search is a deliberate workaround for AND-only server filtering. +- **Where it's used**: the `/speakers/{Id}` route (`SpeakerDetail.razor:1`), reached from [`SpeakerList`](#speakerlist) rows and from [`SpeakerCreate`](#speakercreate) redirects; it hosts [`SpeakerCategoryItemsPanel`](#speakercategoryitemspanel) (`SpeakerDetail.razor:181`) and routes onward to session details (`:361`). +- **Caveats / not-in-source**: the AND-only semantics of `GetPagedAsync` are asserted by the in-code comment (`:288-290`); the filter behavior itself lives in the Identity API, not this page. Likewise the effective read/write authorization for a non-Organizer who reaches this route is enforced server-side and is not visible here. + +--- + +### SpeakerDashboard +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Speaker` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerDashboard.razor.cs:19` · Level 9 · class (Blazor code-behind) + +- **What it is**: the **speaker's own** self-service dashboard, not an organizer page. It reads the linked speaker from the `speaker_id` JWT claim, shows that speaker's profile and their sessions narrowed to the current or next event, with per-session bookmark counts and lazily loaded per-session feedback, and lets the speaker edit their own bio and social profile, BR-214 (`SpeakerDashboard.razor.cs:11-18,44`). +- **Depends on**: [`ISpeakerUIService`](#ispeakeruiservice), [`ISpeakerDashboardUIService`](#ispeakerdashboarduiservice), [`IEventLookupService`](#ieventlookupservice), and Blazor's `AuthenticationStateProvider` (`:21-25`); [`SpeakerDTO`](group-17-conference-domain.md#speakerdto), [`SessionDTO`](group-17-conference-domain.md#sessiondto), [`SessionFeedbackDTO`](group-17-conference-domain.md#sessionfeedbackdto) (`:40`), [`EventInfo`](#eventinfo) (`:142`), and [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector) (`:147-152`); MudBlazor's `ISnackbar`. +- **Concept introduced, claim-driven identity scoping, plus prerender-safe loading and lazy expand.** Three ideas converge here. + 1. **Claim-driven scoping.** Instead of an id from the route, the page derives *who you are* from the token: `OnInitializedAsync` reads `speaker_id` from the auth state (`:72-80`) and falls into a "not linked" state (`_hasSpeakerId = false`, `:78`) when the claim is absent or unparsable, which the markup renders as an explanatory alert (`SpeakerDashboard.razor:17-21`). `[Rubric §11, Security]` and `[Rubric §26, Front-End Security]` (assess that scoping derives from trusted server-issued claims, not client-supplied ids): a speaker can only load their own dashboard. The class doc adds the corollary (`:15-17`): a speaker is *not* a privileged reader, so the server returns only publicly visible sessions (BR-49, accepted or unset), and a submission still under review does not appear here, which the empty-state copy names. + 2. **Prerender-safe loading.** The method returns early when `!RendererInfo.IsInteractive` (`:62-68`), so the profile, the sessions, and the bookmark counts are not fetched twice per visit; the prerender pass paints the loading skeleton instead, and the in-code comment names the ADCHome page as the precedent (`:62-64`). `[Rubric §23, Front-End Performance & Rendering]`. + 3. **Lazy expand.** `ToggleFeedbackAsync` (`:226-262`) uses the `HashSet.Add` return value as the toggle itself (`:228-232`), fetches a session's feedback only the first time its panel opens, and caches it in `_sessionFeedback` (`:234-237,244-248`), so first paint never fans out one feedback call per session. A per-session `_feedbackLoading` set (`:42,239,260`) drives the spinner for just the row being expanded. `[Rubric §19, State Management & Data Flow]`. +- **Walkthrough** + - Load (`:54-140`): breadcrumbs (`:56-60`), prerender guard, claim read, `GetByIdAsync(_speakerId, true, ...)` (`:86`), then the speaker's sessions through `DashboardService.GetSpeakerSessionsAsync`, ordered by `StartsAt` (`:95-96`). The comment at `:92-94` records why that read goes through the dashboard service: it bypasses the shared sessions output cache ([ADR-040](https://ivanball.github.io/docs/adr/040-authenticated-output-caching-for-public-reads.html)), so a just-made speaker assignment shows immediately instead of lagging behind a cached public list. + - Narrowing (`:98-109`): `ResolveCurrentEventAsync` (`:142-159`) resolves the current or next event through [`CurrentEventSelector.SelectCurrentOrNext`](group-17-conference-domain.md#currenteventselector), passing the start, end, and time-zone accessors plus `DateTime.UtcNow` (`:147-152`). The page keeps both lists: `_allSpeakerSessions` and the filtered `_speakerSessions` (`:36-37`), falling back to the unfiltered list when no event resolves (`:107-108`). A failed lookup is swallowed and treated as "no event" (`:154-158`). + - Bookmark counts (`:111-126`): one **batched** call, `GetSessionBookmarkCountsAsync`, fills every count, with `GetValueOrDefault` supplying zero for a session nobody bookmarked (`:117-121`). The comment (`:111-113`) records what it replaced: each count used to be its own cross-service hop (HTTP to Conference, then gRPC to Engagement). The call sits in its own best-effort `catch` that re-raises nothing (`:123-126`), so a failed count read never breaks the render. `[Rubric §12, Performance & Scalability]` and `[Rubric §29, Resilience & Business Continuity]`. + - Profile editing (`:161-224`): `StartEditingProfile` seeds six `_edit*` fields (`:161-175`) and `CancelEditingProfile` is a one-line discard (`:177`). `SaveProfileAsync` (`:179-224`) rebuilds a [`SpeakerDTO`](group-17-conference-domain.md#speakerdto) that preserves `RowVersion`, first, last, and full name, `Email`, `ProfilePicture`, and `LinkedUserId` from the loaded record (`:189-205`), so a self-edit can only change the six fields the speaker owns and cannot clear the organizer-managed ones. `[Rubric §11, Security]` and `[Rubric §24, Forms, Validation & UX Safety]`. + - Disposal (`:264-286`) is the standard cancel-on-disposal pattern over the `CancellationTokenSource` at `:27`. +- **Why it's built this way**: the speaker portal is a distinct actor view. Scoping by claim is the secure way to hand a speaker exactly their own data without an authorization argument on every call, and the batched counts plus the prerender skip keep a cross-service-heavy page responsive. +- **Where it's used**: the `/speaker/dashboard` route, `[Authorize]` with no role requirement (`SpeakerDashboard.razor:1-2`), gated in practice on the `speaker_id` claim that appears once an organizer links a User to a Speaker in [`SpeakerDetail`](#speakerdetail). [`SpeakerQr`](#speakerqr) is its companion page. +- **Caveats / not-in-source**: the output-cache bypass and the batched-endpoint rationale are documented by in-code comments (`:92-94,111-113`); the caching and batching behavior itself lives in the Conference service. --- +### SpeakerList +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Speaker` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerList.razor.cs:19` · Level 9 · class (Blazor code-behind) + +- **What it is**: the organizer's speaker browse page: server-paged search with avatars, an event filter, delete-with-confirmation, and a card layout on mobile viewports instead of the data grid (`SpeakerList.razor.cs:13-18`). +- **Depends on**: extends [`DataGridListPageBase`](group-15-common-ui-framework.md#datagridlistpagebasetdto) (`:19`, `SpeakerList.razor:4`); [`ISpeakerUIService`](#ispeakeruiservice) and [`IEventLookupService`](#ieventlookupservice) (`:24-25`), [`SpeakerDTO`](group-17-conference-domain.md#speakerdto), [`EventInfo`](#eventinfo) (`:39`), [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector) (`:103-108`), [`ListPageActions`](group-24-identity-module.md#listpageactions) (`:113,165`), [`ErrorMessages`](group-15-common-ui-framework.md#errormessages) (`:171`), [`ConferenceRoutePaths`](#conferenceroutepaths) (`:174-175`), [`MobileInfiniteScrollList`](group-15-common-ui-framework.md#mobileinfinitescrolllisttitem) (`:33`), and the shared `DeleteConfirmation` component (`:34`). +- **Concept**: the same event-filtered list shape as [`PublicSpeakerList`](#publicspeakerlist), with the audience logic removed. Every reader of this page is already an Organizer (`SpeakerList.razor:2`), so the filter choice is persisted unconditionally (`:41-49`) and the `"all"` sentinel is the only distinction that matters: it separates an explicit clear from *no saved state*, which is what triggers the computed default (in-code comment, `:46`). Reading the two pages side by side is the clearest way to see what the privileged/non-privileged split actually costs on the public one: a role check and a narrowed persistence rule. + Three mechanisms are worth naming. + 1. **Restore, then reconcile.** `RestoreFilters` (`:51-68`) parses the saved search term and event id; `ResolveDefaultEventFilter` (`:92-110`) keeps a restored id **only if it still exists** in the loaded event set and otherwise falls back to [`CurrentEventSelector.SelectCurrentOrNext`](group-17-conference-domain.md#currenteventselector) (`:101-108`), so a dangling id from a deleted event produces the current conference rather than an empty grid. `[Rubric §19, State Management & Data Flow]` and `[Rubric §25, Navigation & Information Architecture]`. + 2. **A startup race guard.** `OnInitializedAsync` assigns `_eventsLoadTask` before awaiting it (`:70-76`) and both `LoadServerData` (`:128-140`) and `FetchMobilePage` (`:151-159`) await that same task before applying filters, because the `MudDataGrid`'s first `ServerData` call can run ahead of initialization completing. The in-code comments state the invariant twice (`:72-73,130-131`): the default event filter must be resolved before the first fetch, or the grid loads an unfiltered page. + 3. **A non-fatal lookup.** `LoadEventsAndResolveDefaultAsync` (`:78-90`) swallows a failed event lookup (`:84-87`), leaving the picker hidden and the filter unset rather than failing the page. `[Rubric §29, Resilience & Business Continuity]`. + `[Rubric §16, Maintainability]` (assesses reuse of one tested shape rather than parallel implementations): paging, rows-per-page, scroll restoration, the `IsLoading` / `LoadFailed` flags, `CancelLoading`, and the mobile/desktop switch all live in the base (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:20,40,44,722`), so this page supplies only its filters, its two fetch delegates, and its navigation. +- **Walkthrough** + - `ApplyFilters` (`:142-148`): `FullName contains` plus the **virtual** `EventId equals` filter, which the speakers/paged endpoint intercepts and resolves through the EventSpeaker and SessionSpeaker joins, because a Speaker row has no `EventId` column (class doc, `:15-17`). + - `LoadServerData` (`:128-140`) does **not** pass `showCancelSnackbar: false`, so the base's default of `true` applies (`DataGridListPageBase.cs:433-438`): an organizer page notifies on a cancelled fetch, where the public lists stay silent. + - `RetryLoadAsync` (`:31-32`) re-runs the server fetch from the inline error state the base renders when `LoadFailed` is set, so a failed load offers a retry instead of a dead grid. + - `OnSearchChanged` (`:115-119`) and `OnEventFilterChanged` (`:121-126`) both funnel through `ReloadActiveLayoutAsync` (`:112-113`), which delegates to `ListPageActions.ReloadActiveLayoutAsync` and reloads whichever of the grid or the infinite-scroll list is currently mounted. `[Rubric §22, Responsive & Cross-Browser]`: one filter change, two possible layouts, one code path. + - `FetchMobilePage` (`:151-159`) builds the same filters and always sorts by `FullName` ascending, since the card list has no sortable headers. + - `DeleteSpeakerAsync` (`:164-172`) delegates the whole confirm, delete, notify, reload cycle to `ListPageActions.DeleteWithConfirmationAsync`, passing the delete lambda and the localized messages; a Speaker is a top-level entity, so it deletes by a single id (contrast the child-entity list pages, which pass a parent id too). + - `NavigateToCreate` and `NavigateToDetails` (`:174-175`) reach [`SpeakerCreate`](#speakercreate) and [`SpeakerDetail`](#speakerdetail); `OnMobileCardClick` (`:161`) reuses the same detail navigation. +- **Why it's built this way**: organizers work one conference at a time, so the list defaults to the current or next event; everything else is the shared base doing the paging, restoration, and layout switching. +- **Where it's used**: the `/speakers` route, restricted to the Organizer role (`SpeakerList.razor:1-2`), the entry point for the whole speaker admin flow. +- **Caveats / not-in-source**: the join-based resolution of the virtual `EventId` filter is asserted by the class doc comment (`:15-17`); the resolution itself lives in the Conference API. + ### CachedSessionPage -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Public` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSessionList.razor.cs:342` · Level 3 · record (private sealed, nested) +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Public` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSessionList.razor.cs:366` · Level 3 · record (private sealed, nested) -- **What it is**: the serialization payload for the offline schedule snapshot: a `List Items` plus the `int TotalItems` count, declared as a private nested record inside [`PublicSessionList`](#publicsessionlist) (`PublicSessionList.razor.cs:342`). +- **What it is**: the serialization payload for the offline schedule snapshot, a `List Items` plus the `int TotalItems` count, declared as a private sealed record nested inside [`PublicSessionList`](#publicsessionlist) (`PublicSessionList.razor.cs:366`). - **Depends on**: [`SessionDTO`](group-17-conference-domain.md#sessiondto); persisted through [`ILocalCacheStore`](group-26-device-capability-layer.md#ilocalcachestore) and gated by [`IConnectivityStatusService`](group-26-device-capability-layer.md#iconnectivitystatusservice). - **Concept introduced, the offline read snapshot ([ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html) Wave 3).** Conference day is exactly when the venue network is worst and the schedule matters most, so the page keeps the last good first page in device storage and replays it when a live fetch throws while offline. `[Rubric §29, Resilience & Business Continuity]` (assesses graceful degradation when a dependency is unreachable): the failure mode becomes stale-but-useful instead of an empty grid. `[Rubric §23, Front-End Performance & Rendering]` (assesses caching of read payloads): the snapshot is written on the success path and read only on the failure path, so it never adds latency to a healthy fetch. -- **Walkthrough**: a one-line positional record (`PublicSessionList.razor.cs:342`). It is written after every successful page-1 fetch when the store reports itself available (`:316-320`), keyed by the constant `ScheduleCacheKey = "conference.publicSessions.page1"` (`:42`). It is read back only inside the exception filter `when (!Connectivity.IsOnline && CacheStore.IsAvailable && page == 1)` (`:325-336`); if no snapshot exists the original exception is rethrown (`:328-331`), and a successful replay sets `_showingCachedData = true` (`:333`, field at `:340`) so the markup can flag the view as cached. +- **Walkthrough**: a one-line positional record (`PublicSessionList.razor.cs:366`). It is written after every successful page-1 fetch when the store reports itself available (`:340-344`), keyed by the constant `ScheduleCacheKey = "conference.publicSessions.page1"` (`:42`). It is read back only inside the exception filter `when (!Connectivity.IsOnline && CacheStore.IsAvailable && page == 1)` (`:349`); if no snapshot exists the original exception is rethrown (`:352-355`), and a successful replay sets `_showingCachedData = true` (`:357`, field at `:364`) and calls `StateHasChanged()` (`:358`) so the markup can flag the view as cached. The success path clears that flag (`:346`), so a recovered network drops the banner on the next fetch. The banner itself is a warning-coloured cloud-off chip (`PublicSessionList.razor:23-29`). - **Why it's built this way**: pairing the items with their total gives the grid's paging math a coherent shape to replay, and restricting the snapshot to page 1 keeps the stored payload bounded (page 1 is what an offline attendee lands on). - **Where it's used**: read and written exclusively by [`PublicSessionList`](#publicsessionlist)'s `FetchSessionsAsync`. --- +### PublicScheduleRoomOptions +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Public` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicScheduleRoomOptions.cs:11` · Level 3 · class (internal static) + +- **What it is**: the pure function behind the public schedule's Room picker. Given the events [`PublicSessionList`](#publicsessionlist) has already loaded, it returns the ordered room options for the active event filter plus the room filter that survives that scoping (`PublicScheduleRoomOptions.cs:21-42`). +- **Depends on**: [`RoomDTO`](group-17-conference-domain.md#roomdto) and [`EventDTO`](group-17-conference-domain.md#eventdto) from `MMCA.ADC.Conference.Shared.Events` (`:1`), plus the `RoomIdentifierType` and `EventIdentifierType` aliases; nothing else. No injected service, no fetch, no component base. +- **Concept introduced, the derived filter option set with a self-healing selection.** Two ideas are worth extracting from a 30-line file. + 1. **Derive, do not fetch.** The class doc states the rule (`:5-10`): the page already reads `/events?includeChildren=true`, so its rooms are in memory and narrowing the schedule by room costs zero extra round trips. The same doc records the security consequence that comes for free: that events read is published-only for non-privileged audiences server-side, so an unpublished event's rooms can never reach the picker. `[Rubric §23, Front-End Performance & Rendering]` (assesses avoidable network work per interaction) and `[Rubric §26, Front-End Security]` (assesses that a client-derived option set cannot widen what the server already scoped). + 2. **A selection that cannot go stale.** The last block (`:35-39`) re-validates `selectedRoomId` against the freshly scoped list and returns `null` when the room is no longer offered. The comment names both ways that happens: the reader switched events, or a stale choice was restored from saved page state. Without it, an unreachable room id would filter every session out and the reader would see an empty schedule with no visible cause. `[Rubric §19, State Management & Data Flow]` (assesses that derived state is reconciled rather than left to drift) and `[Rubric §24, Forms, Validation & UX Safety]`. +- **Walkthrough**: `Scope` (`:21-42`) is the only member. + - Scoping (`:26-28`): a non-null `eventId` takes that event's `Rooms` (an unknown event id yields an empty list through the `?? []` fallback); a null id, which only a privileged reader viewing every event can produce, takes the union across all loaded events. + - Shaping (`:30-33`): `DistinctBy(r => r.Id)` because the union can repeat a room, then `OrderBy(Sort).ThenBy(Name, StringComparer.OrdinalIgnoreCase)` so the picker order is the organizer's intended order with a deterministic case-insensitive tiebreak. + - Reconciliation (`:37-39`) and the tuple return (`:41`), which the caller destructures straight into its two fields. +- **Why it's built this way**: keeping this out of the page makes it a plain static function over data, which is directly unit-testable without a renderer, and it keeps the page's own code down to one line (`PublicSessionList.razor.cs:194-195`). `[Rubric §14, Testability]` (assesses whether logic can be exercised without its host) and `[Rubric §1, SOLID]`. +- **Where it's used**: called only by [`PublicSessionList`](#publicsessionlist)'s `RefreshRoomOptions`, from the initial event load (`PublicSessionList.razor.cs:190`) and from every event-filter change (`:255`). The options it returns are passed down to [`PublicSessionListFilterBar`](#publicsessionlistfilterbar)'s `Rooms` parameter. + +--- + +### ConferenceCategoryCreate +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.ConferenceCategory` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/ConferenceCategory/ConferenceCategoryCreate.razor.cs:9` · Level 5 · class (Blazor code-behind) + +- **What it is**: the organizer's category-creation form. It collects three fields (title, sort order, type), posts one [`ConferenceCategoryDTO`](group-17-conference-domain.md#conferencecategorydto) through the UI service, and redirects to the detail page for the record it just made. +- **Depends on**: [`IConferenceCategoryUIService`](#iconferencecategoryuiservice) (`:11`), [`ConferenceCategoryDTO`](group-17-conference-domain.md#conferencecategorydto) (`:58`), [`ConferenceRoutePaths`](#conferenceroutepaths) (`:26,62,78`), and [`ErrorMessages`](group-15-common-ui-framework.md#errormessages) (`:51`); MudBlazor's `MudForm`, `ISnackbar` and `BreadcrumbItem`, plus `NavigationManager` and the page's `IStringLocalizer` (`ConferenceCategoryCreate.razor:4`). +- **Concept introduced, the create-page shape and its three safety rails.** This is the smallest create form in the group, which makes it the clearest place to read the shape every other one repeats. + 1. **Validate before you mutate.** `CreateCategoryAsync` calls `await _form.ValidateAsync()` and returns with a warning snackbar when `!_form.IsValid` (`:48-53`), before any service call. The server validates again; this pass exists to keep a round trip off the wire and to put the message next to the field. `[Rubric §24, Forms, Validation & UX Safety]` (assesses whether a form can submit itself into a predictable failure). + 2. **Dirty tracking that cannot block its own redirect.** Every editable control calls `MarkDirty()` (`:39`) and the markup mounts the shared guard as `` (`ConferenceCategoryCreate.razor:8`). The accessor is the load-bearing half: the guard prefers `IsDirtyAccessor?.Invoke()` over the parameter snapshot (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/UnsavedChangesGuard.razor:33,35`), because clearing the flag and calling `NavigateTo` without an intervening `StateHasChanged()` would otherwise still prompt. The page clears `_isDirty` on the success path **before** navigating, with the reason written on the line (`:60`). + 3. **Cancel on disposal.** A private `CancellationTokenSource` (`:15`) is passed into the service call (`:59`) and cancelled in a full `Dispose(bool)` pattern (`:82-102`), with `OperationCanceledException` caught and ignored as the expected teardown outcome (`:64-67`). `[Rubric §23, Front-End Performance & Rendering]`: a form abandoned mid-post does not keep a response alive for a component that no longer exists. + Unlike the int-keyed create forms elsewhere in this module, this page sends `Id = default` (`:58`) and lets the server assign the key, then navigates on `created.Id` read back from the response (`:62`). `[Rubric §8, Data Architecture]` (assesses a deliberate identity strategy): the per-entity identifier alias ([ADR-048](https://ivanball.github.io/docs/adr/048-primitive-identifier-type-aliases.html)) keeps the key type out of the page's own logic. +- **Walkthrough** + - `OnInitialized` (`:20-29`) builds the Home / Categories / Create breadcrumb trail from localized resource strings, with the middle crumb pointing at `ConferenceRoutePaths.ConferenceCategories` (`:26`). + - The field block carries a comment worth reading (`:32-33`): the backing field is `_categoryTitle`, not `_title`, so it does not collide with the localized `Title` page property that SonarAnalyzer S4275 would flag. This is the analyzers-as-errors baseline showing up in page code. + - `CreateCategoryAsync` (`:41-76`): null-guard the form (`:43-46`), validate, set `IsSaving` (`:55`), build the DTO (`:58`), post (`:59`), clear the dirty flag, snackbar the success (`:61`), redirect to the detail route (`:62`); the `finally` always clears `IsSaving` (`:72-75`) so a failed save leaves an enabled button rather than a stuck spinner. + - `NavigateToList` (`:78`) is the cancel action, and it routes through [`ConferenceRoutePaths`](#conferenceroutepaths) rather than a literal. `[Rubric §25, Navigation & Information Architecture]`: every route in the module is a named constant in one file. +- **Why it's built this way**: one create shape repeated per entity keeps the organizer's mental model constant (fill, validate, save, land on the new record) while each page varies only in the fields it collects. +- **Where it's used**: the `/conferencecategories/create` route, carrying `[Authorize(Roles = "Organizer")]` on the page itself (`ConferenceCategoryCreate.razor:1-2`). It is reached from [`ConferenceCategoryList`](#conferencecategorylist)'s create button and redirects to [`ConferenceCategoryDetail`](#conferencecategorydetail). + +--- + ### PublicSessionListFilterBar > MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Public` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSessionListFilterBar.razor.cs:15` · Level 5 · class (Blazor code-behind) -- **What it is**: the presentational filter bar for [`PublicSessionList`](#publicsessionlist): the privileged-reader Filter-by-Event picker (or the locked "Showing" chip for everyone else), the title search box, the All Sessions / My Schedule toggle, and the share-my-schedule action (`PublicSessionListFilterBar.razor.cs:8-14`). -- **Depends on**: [`EventDTO`](group-17-conference-domain.md#eventdto); [`IScreenshotService`](group-26-device-capability-layer.md#iscreenshotservice), [`IShareService`](group-26-device-capability-layer.md#ishareservice), and MudBlazor's `ISnackbar` (`:17-19`). -- **Concept introduced, the container/presentational split.** The bar owns **no** filter state. Every value arrives as a `[Parameter]` and every change leaves through a matching `EventCallback`: `IsPrivileged` (`:25`), `Events` (`:28`), `SelectedEventId` / `SelectedEventIdChanged` (`:31-34`), `SearchString` / `SearchStringChanged` (`:37-40`), and `ShowMyScheduleOnly` / `ShowMyScheduleOnlyChanged` (`:43-46`). The page stays the single source of truth and the bar is a pure view over it. `[Rubric §18, UI Architecture & Component Design]` (assesses decomposition and separation of layout from behavior) and `[Rubric §19, State Management & Data Flow]` (assesses where mutable state lives): with no lifecycle of its own, the bar cannot drift from the data the grid actually fetched. Note the parameter name: it is `IsPrivileged`, not "is organizer", because the privileged read audience is a role set ([`ConferenceReadAudience`](group-17-conference-domain.md#conferencereadaudience)) rather than one role. +- **What it is**: the presentational filter bar for [`PublicSessionList`](#publicsessionlist): the privileged-reader Filter-by-Event picker (or the locked "Showing" chip for everyone else), the debounced title search box, the Room picker, the All Sessions / My Schedule toggle, and the share-my-schedule action (`PublicSessionListFilterBar.razor.cs:8-14`). +- **Depends on**: [`EventDTO`](group-17-conference-domain.md#eventdto) and [`RoomDTO`](group-17-conference-domain.md#roomdto) (`:2`); [`IScreenshotService`](group-26-device-capability-layer.md#iscreenshotservice), [`IShareService`](group-26-device-capability-layer.md#ishareservice), and MudBlazor's `ISnackbar` (`:17-19`). Its `Rooms` option set is produced by [`PublicScheduleRoomOptions`](#publicscheduleroomoptions). +- **Concept introduced, the container/presentational split.** The bar owns **no** filter state. Every value arrives as a `[Parameter]` and every change leaves through a matching `EventCallback`: `IsPrivileged` (`:25`), `Events` (`:28`), `SelectedEventId` / `SelectedEventIdChanged` (`:31,34`), `SearchString` / `SearchStringChanged` (`:37,40`), `Rooms` (`:46`), `SelectedRoomId` / `SelectedRoomIdChanged` (`:49,52`), and `ShowMyScheduleOnly` / `ShowMyScheduleOnlyChanged` (`:55,58`). The page stays the single source of truth and the bar is a pure view over it, with no lifecycle method of its own. `[Rubric §18, UI Architecture & Component Design]` (assesses decomposition and separation of layout from behavior) and `[Rubric §19, State Management & Data Flow]` (assesses where mutable state lives): with nothing to initialize, the bar cannot drift from the data the grid actually fetched. + Two naming and behavior details reward a close read. The parameter is `IsPrivileged`, not "is organizer", because the privileged read audience is a role set ([`ConferenceReadAudience`](group-17-conference-domain.md#conferencereadaudience)) rather than one role. And `Rooms` documents its own empty case (`:42-45`): an empty list hides the Room picker entirely, because an event with no rooms has nothing to narrow by. `[Rubric §24, Forms, Validation & UX Safety]`: a control with no meaningful options is removed rather than shown disabled. - **Walkthrough** - - `GetSelectedEventName()` (`:48-49`): resolves the chip label from the passed-in `Events` list, returning empty when nothing is selected. - - `ShareScheduleAsync()` (`:51-59`): captures the current view to a file through [`IScreenshotService`](group-26-device-capability-layer.md#iscreenshotservice) and hands it to [`IShareService`](group-26-device-capability-layer.md#ishareservice); a null capture or a failed share raises one warning snackbar (`:54-58`). This is a native-head capability ([ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html)) that degrades quietly on the web. + - `GetSelectedEventName()` (`:60-61`): resolves the chip label from the passed-in `Events` list, returning empty when nothing is selected. + - `ShareScheduleAsync()` (`:63-71`): captures the current view to a file through [`IScreenshotService`](group-26-device-capability-layer.md#iscreenshotservice) and hands it to [`IShareService`](group-26-device-capability-layer.md#ishareservice) as `image/png` (`:65-67`); a null capture or a failed share collapses into one warning snackbar (`:69`). This is a native-head capability ([ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html)) that degrades quietly on the web. `[Rubric §29, Resilience & Business Continuity]`. - **Why it's built this way**: pushing all filter state to the page means the same chrome can sit above both the desktop grid and the mobile card list without either layout owning a second copy of the filters. -- **Where it's used**: rendered by [`PublicSessionList`](#publicsessionlist); its callbacks land on that page's `OnSearchChanged` / `OnEventFilterChanged` / `OnMyScheduleToggled` handlers (`PublicSessionList.razor.cs:229-245`). +- **Where it's used**: rendered once by [`PublicSessionList`](#publicsessionlist) (`PublicSessionList.razor:11-21`); its callbacks land on that page's `OnEventFilterChanged`, `OnSearchChanged`, `OnRoomFilterChanged` and `OnMyScheduleToggled` handlers (`PublicSessionList.razor.cs:246-269`). --- -### SpeakerCreate -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Speaker` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerCreate.razor.cs:13` · Level 5 · class (Blazor code-behind) +### ConferenceCategoryDetail +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.ConferenceCategory` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/ConferenceCategory/ConferenceCategoryDetail.razor.cs:11` · Level 7 · class (Blazor code-behind) -- **What it is**: the organizer's speaker-creation form: first/last name, bio, tagline, email, profile picture, and the four social links, collected into a new [`SpeakerDTO`](group-17-conference-domain.md#speakerdto) and posted (`SpeakerCreate.razor.cs:9-12`). -- **Depends on**: [`ISpeakerUIService`](#ispeakeruiservice) (`:15`), [`SpeakerDTO`](group-17-conference-domain.md#speakerdto), [`ConferenceRoutePaths`](#conferenceroutepaths) (`:30,88,104`), and [`ErrorMessages`](group-15-common-ui-framework.md#errormessages) (`:62`); MudBlazor (`MudForm`, `ISnackbar`, `BreadcrumbItem`) and `NavigationManager`. -- **Concept**: the create-page pattern the group teaches once (validate before mutate, dirty tracking, cancel on disposal), here in its widest-form variant. `[Rubric §24, Forms, Validation & UX Safety]` (assesses validate-before-submit and unsaved-change protection): `CreateSpeakerAsync` calls `await _form.ValidateAsync()` and bails with a warning snackbar when `!_form.IsValid` (`:59-64`) before touching the service, and `_isDirty` (set by `MarkDirty()`, `:50`) is cleared the instant the save succeeds, **before** navigating (`:86`), so the unsaved-changes guard cannot block its own redirect. The `CancellationTokenSource` (`:19`) is passed to the service call (`:85`) and cancelled in the standard dispose pattern (`:108-128`), with `OperationCanceledException` swallowed as the expected teardown outcome (`:90-93`). - The identifier detail worth noting: `Speaker` is Guid-keyed, so the page mints a genuinely unique id client-side with `Guid.NewGuid()` (`:71`) rather than the random-int placeholder the int-keyed create forms use. `[Rubric §8, Data Architecture]` (assesses a deliberate identity strategy): the per-entity identifier alias ([ADR-048](https://ivanball.github.io/docs/adr/048-primitive-identifier-type-aliases.html)) keeps the key type out of the page's own logic, and the page reads `created.Id` back from the response (`:88`) either way. -- **Walkthrough**: `OnInitialized` (`:24-33`) builds the Home / Speakers / Create breadcrumb trail; `CreateSpeakerAsync` (`:52-102`) validates, sets `IsSaving`, composes `FullName` from the two name fields (`:74`), builds the DTO with all optional profile and social fields (`:69-83`), calls `SpeakerService.AddAsync` (`:85`), and navigates to `ConferenceRoutePaths.SpeakerDetails(created.Id)` (`:88`); the `finally` always clears `IsSaving` (`:98-101`). -- **Why it's built this way**: one create-form shape reused per entity keeps the flow uniform (validate, post, redirect to detail) while each page varies only in the fields it collects. -- **Where it's used**: the `/speakers/create` route (`SpeakerCreate.razor:1`), reached from [`SpeakerList`](#speakerlist)'s create button; it redirects to [`SpeakerDetail`](#speakerdetail). -- **Caveats / not-in-source**: whether the server honors or replaces the client-minted Guid is a server-side decision not visible here; the page uses the id from the response. +- **What it is**: the organizer's category console. It loads one [`ConferenceCategoryDTO`](group-17-conference-domain.md#conferencecategorydto) with its children, inline-edits the category itself, and runs a full add / edit / delete loop over its [`CategoryItemDTO`](group-17-conference-domain.md#categoryitemdto) rows on the same page. +- **Depends on**: [`IConferenceCategoryUIService`](#iconferencecategoryuiservice) and [`ICategoryItemUIService`](#icategoryitemuiservice) (`:15-16`), [`ConferenceCategoryDTO`](group-17-conference-domain.md#conferencecategorydto) and [`CategoryItemDTO`](group-17-conference-domain.md#categoryitemdto), the `CategoryItemIdentifierType` alias (`:60`), [`DomainHelper`](group-02-domain-building-blocks.md#domainhelper)'s `Id.Parse` extension (`:76`), [`ConferenceRoutePaths`](#conferenceroutepaths) (`:33,302`), [`ErrorMessages`](group-15-common-ui-framework.md#errormessages) (`:80,89,127,147,180,205`), and the shared `DeleteConfirmation` component twice over (`:49,59`). +- **Concept introduced, the parent-with-children editor, and shadow fields as the edit buffer.** Two mechanisms carry this page. + 1. **Shadow fields.** Entering edit mode copies the live record into `_edit*` fields (`StartEditing`, `:97-109`) and cancelling simply drops them (`CancelEditing`, `:111-115`). The loaded `Category` is never mutated, so an abandoned edit leaves nothing behind and the rendered values stay exactly what the server last returned. The item editor repeats the idea with `_editingItemId` / `_editItemName` / `_editItemSort` (`:60-62`, seeded at `:232-238`). `[Rubric §19, State Management & Data Flow]` (assesses where mutable state lives and how long it lives). + 2. **Refetch, do not patch.** Every mutation is followed by `Category = await CategoryService.GetByIdAsync(Category.Id, true, _cts.Token)` (`:136`, `:214`, `:260`, `:289`). The page never edits its local child collection: the server's answer is the only rendering source. That costs one extra read per action and removes an entire class of drift between what was saved and what is shown. `[Rubric §19, State Management & Data Flow]` and `[Rubric §8, Data Architecture]`. + The save path also round-trips the concurrency token: the updated DTO carries `RowVersion = Category.RowVersion` (`:134`), which is the client half of the optimistic-concurrency contract in [ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html). `[Rubric §8, Data Architecture]` (assesses how concurrent writes are reconciled): a stale editor loses the write instead of silently overwriting a newer one. Note that the item update path (`:258`) does **not** carry a `RowVersion`, so a category item is a last-writer-wins edit while the category itself is not. +- **Walkthrough** + - `OnInitialized` (`:27-36`) builds the Home / Categories / Details breadcrumb trail. + - `OnParametersSetAsync` (`:64-95`): the load-once-on-parameters guard compares the route `Id` against `_loadedId` (`:66-71`) so a re-render does not refetch, parses the id to `ConferenceCategoryIdentifierType` (`:76`), fetches with children (`:77`), and reports a null result as a not-found snackbar through [`ErrorMessages`](group-15-common-ui-framework.md#errormessages) (`:80`). `OperationCanceledException` is swallowed as the expected teardown (or InteractiveAuto transition) outcome (`:83-86`) and the `finally` always clears `IsLoading`. + - Category edit (`:97-153`): `StartEditing` / `CancelEditing` as above, then `SaveChangesAsync` (`:117-153`) validates the `MudForm` first (`:124-129`), rebuilds the DTO with the round-tripped `RowVersion` (`:134`), updates, refetches, and clears both `_isDirty` and `_isEditing` on success (`:138-139`). + - Category delete (`:155-182`): confirm through the shared `DeleteConfirmation` dialog seeded with the category title (`:162`), delete, then navigate back to the list (`:172`). + - Item CRUD (`:184-300`): `StartAddingItem` resets the new-item fields and closes any open row edit (`:185-191`), which is what keeps the two editors mutually exclusive; `AddItemAsync` (`:195-230`) validates its own separate `MudForm` (`:202-207`), posts a `CategoryItemDTO` stamped with the parent `CategoryId` (`:212`), and refetches. `UpdateItemAsync` (`:242-276`) is the one path that does **not** use a `MudForm`: it hand-checks `string.IsNullOrWhiteSpace(_editItemName)` and warns (`:249-253`), because the row editor is inline in the table rather than a form. `DeleteItemAsync` (`:278-300`) confirms through the second dialog instance (`:280`) and refetches. + - Disposal (`:306-326`) is the standard cancel-on-disposal pattern over the `CancellationTokenSource` at `:22`; the markup mounts the unsaved-changes guard with the same accessor form the create page uses (`ConferenceCategoryDetail.razor:9`). +- **Why it's built this way**: a category is only meaningful together with its items (a topic list, a session-level list, a locality list), so editing them on two routes would be worse than a slightly larger page. Refetching after every mutation is the cheap way to keep a composite view coherent without a client-side store. +- **Where it's used**: the `/conferencecategories/{Id}` route with `[Authorize(Roles = "Organizer")]` (`ConferenceCategoryDetail.razor:1-2`), reached from [`ConferenceCategoryList`](#conferencecategorylist) rows and [`ConferenceCategoryCreate`](#conferencecategorycreate) redirects. The items it authors are what [`ICategoryItemLookupService`](#icategoryitemlookupservice) resolves for the session and speaker pages, including [`PublicSessionDetail`](#publicsessiondetail)'s category chips. --- -### PublicEventList -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Public` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicEventList.razor.cs:17` · Level 7 · class (Blazor code-behind) +### ConferenceCategoryList +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.ConferenceCategory` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/ConferenceCategory/ConferenceCategoryList.razor.cs:11` · Level 7 · class (Blazor code-behind) -- **What it is**: the anonymous-friendly event browse page. It lists published events for everyone; privileged readers (Organizer/ContentEditor) additionally see unpublished ones, because the server applies the published-event specification only to non-privileged callers, BR-108 (`PublicEventList.razor.cs:11-16`). -- **Depends on**: extends [`DataGridListPageBase`](group-15-common-ui-framework.md#datagridlistpagebasetdto) (`:17`); [`IEventUIService`](#ieventuiservice) (`:21`), [`EventDTO`](group-17-conference-domain.md#eventdto), [`ConferenceRoutePaths`](#conferenceroutepaths) (`:66`), [`MobileInfiniteScrollList`](group-15-common-ui-framework.md#mobileinfinitescrolllisttitem) (`:29`), and [`ListPageActions`](group-24-identity-module.md#listpageactions) (`:41`). Server-side the audience split is enforced by [`PublishedEventSpecification`](group-18-conference-application.md#publishedeventspecification). -- **Concept**: the simplest instance of the ADC list-page pattern (introduced on the organizer list pages): the base owns paging, rows-per-page, scroll restoration, the loading and `LoadFailed` flags, and the mobile/desktop switch, so this page supplies only four things: the `GridRef` override (`:25`), `SaveFilters` / `RestoreFilters` for the search box (`:32-36`), a `LoadServerData` delegate that turns the search string into a `Name contains` server filter (`:44-54`), and the parallel `FetchMobilePage` for the infinite-scroll card list (`:57-63`). `[Rubric §22, Responsive & Cross-Browser]` (assesses a real mobile layout rather than a shrunk grid): the same service call backs both branches, selected by the base's `IsMobile`. `[Rubric §23, Front-End Performance & Rendering]`: search and paging are pushed to the server, so the client never materializes the full event table. `[Rubric §25, Navigation & Information Architecture]`: the search term is persisted through the base's filter contract, so a back-navigation returns the reader to the same view. -- **Walkthrough** - - `RetryLoadAsync` (`:28`) re-runs the server fetch from the inline error state the base renders when `LoadFailed` is set: the failure path offers a retry instead of a dead grid. `[Rubric §29, Resilience & Business Continuity]`. - - `OnSearchChanged` (`:38-42`) stores the term then reloads whichever layout is active through `ListPageActions.ReloadActiveLayoutAsync`. - - `LoadServerData` (`:44-54`) passes `showCancelSnackbar: false`, so a superseded fetch (the reader typed another character) is silent rather than raising a toast. - - `OnMobileCardClick` (`:65-66`) routes to [`PublicEventDetail`](#publiceventdetail). -- **Why it's built this way**: the public and organizer event lists differ only in audience and route, so the public one is a thin binding over the same shared base rather than a second grid implementation. -- **Where it's used**: the `/conference/events` route (`PublicEventList.razor:1`); rows and cards navigate to [`PublicEventDetail`](#publiceventdetail). +- **What it is**: the organizer's category browse page: a server-paged grid with a single title search box, delete-with-confirmation, and a mobile card layout. It is a thin binding over the shared list-page base. +- **Depends on**: extends [`DataGridListPageBase`](group-15-common-ui-framework.md#datagridlistpagebasetdto) closed over [`ConferenceCategoryDTO`](group-17-conference-domain.md#conferencecategorydto) (`:11`); [`IConferenceCategoryUIService`](#iconferencecategoryuiservice) (`:16`), [`MobileInfiniteScrollList`](group-15-common-ui-framework.md#mobileinfinitescrolllisttitem) (`:24`), [`ListPageActions`](group-24-identity-module.md#listpageactions) (`:35,68`), [`ErrorMessages`](group-15-common-ui-framework.md#errormessages) (`:74`), [`ConferenceRoutePaths`](#conferenceroutepaths) (`:64,77`), and the shared `DeleteConfirmation` component (`:25`). +- **Concept introduced, the list page as a set of overrides.** The base owns the machinery: `LoadFailed`, the abstract `Title`, the `IsMobile` switch, the mobile paging fields, the filter save/restore contract, and `LoadServerDataAsync` (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:40,41,44,47-50,108,111,121,434`). This page supplies five things and nothing else. + - The captured grid reference, through the `GridRef` override (`:19-20`), which is how the base restores rows-per-page and the current page after a back-navigation. + - `SaveFilters` / `RestoreFilters` for the one search term (`:28-32`). `[Rubric §25, Navigation & Information Architecture]`: a reader who opens a record and comes back finds the same view, not a reset grid. + - `LoadServerData` (`:43-52`), which hands the base a fetch delegate and a filter builder that turns the search string into a server-side `Title contains` filter. `[Rubric §12, Performance & Scalability]` and `[Rubric §23, Front-End Performance & Rendering]`: search, sort and paging all execute where the data is, so the client never materializes a whole table. + - `FetchMobilePage` (`:55-61`), the parallel path for the infinite-scroll card list, hard-sorted by `Title` ascending. `[Rubric §22, Responsive & Cross-Browser]` (assesses a genuine mobile layout rather than a shrunk grid): the same service call backs both branches, selected by the base's `IsMobile`. + - `RetryLoadAsync` (`:23`), which re-runs the fetch from the inline error state the base renders when `LoadFailed` is set. `[Rubric §29, Resilience & Business Continuity]`: a failed load offers a retry instead of a dead grid. + Two details separate this page from its siblings. Both fetch paths pass `includeChildren: true` (`:47,60`), because both layouts render the child item count, which is the one place a list page in this module pays for children. And deletion is not reimplemented: `ListPageActions.DeleteWithConfirmationAsync` (`:67-75`) takes the dialog, the label to show, the delete call, the snackbar, the localized success text, an error formatter, and the reload callback. `[Rubric §1, SOLID]` and `[Rubric §16, Maintainability]` (assess whether repeated behavior has one implementation): confirm, delete, toast, reload lives in one helper for every list page in the app. +- **Walkthrough**: `ReloadActiveLayoutAsync` (`:34-35`) asks [`ListPageActions`](group-24-identity-module.md#listpageactions) to reload whichever of the two layouts is live, and is the single reload entry point shared by search changes and post-delete refreshes; `OnSearchChanged` (`:37-41`) stores the term and calls it; `LoadServerData` (`:43-52`) and `FetchMobilePage` (`:55-61`) apply the same `contains` filter to the desktop and mobile paths; `OnMobileCardClick` (`:63-64`) and `NavigateToCreate` (`:77`) route through [`ConferenceRoutePaths`](#conferenceroutepaths). Note that `LoadServerData` does **not** pass `showCancelSnackbar: false`, so unlike the public lists the base's default cancel notification applies here. +- **Why it's built this way**: several near-identical organizer browse surfaces are exactly the case a base class is for. Because each page is only its overrides, a change to paging, scroll restoration or the mobile switch lands in one place and every list inherits it. +- **Where it's used**: the `/conferencecategories` route with `[Authorize(Roles = "Organizer")]` (`ConferenceCategoryList.razor:1-2`). Rows and cards navigate to [`ConferenceCategoryDetail`](#conferencecategorydetail); the create button opens [`ConferenceCategoryCreate`](#conferencecategorycreate). --- ### PublicSessionListView -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Public` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSessionListView.razor.cs:21` · Level 7 · class (Blazor code-behind) +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Public` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSessionListView.razor.cs:23` · Level 7 · class (Blazor code-behind) -- **What it is**: the presentational session-list view for [`PublicSessionList`](#publicsessionlist): the mobile infinite-scroll card list and the desktop server-paged data grid, including the inline bookmark stars and their toggle flow (`PublicSessionListView.razor.cs:12-20`). -- **Depends on**: [`SessionDTO`](group-17-conference-domain.md#sessiondto), [`ISessionBookmarkUIService`](group-22-engagement-module.md#isessionbookmarkuiservice) (optional, `:55`), [`SpeakerInfo`](#speakerinfo) (`:64`), [`ConferenceRoutePaths`](#conferenceroutepaths) (`:174`), [`MobileInfiniteScrollList`](group-15-common-ui-framework.md#mobileinfinitescrolllisttitem) (`:75`), [`ListPageActions`](group-24-identity-module.md#listpageactions) (`:89`), and [`IHapticFeedbackService`](group-26-device-capability-layer.md#ihapticfeedbackservice) (`:24`); MudBlazor grid types and `NavigationManager`. -- **Concept introduced, the presentational child that patches container-owned state in place.** Like [`PublicSessionListFilterBar`](#publicsessionlistfilterbar), the view owns no fetch or filter state: the page hands down its `ServerData` and `FetchPage` delegates (`:70,73`), its paging parameters (`:37-43`), the speaker and room lookups (`:64,67`), and the shared `BookmarkedSessions` dictionary (`:61`). The subtlety is that the view **mutates that dictionary in place** when a star is toggled (`AddBookmarkAsync` writes `BookmarkedSessions[sessionId] = bookmark.Id` at `:152`, `RemoveBookmarkAsync` removes at `:137`), so the page's "My Schedule" fetch, which reads the same dictionary to build its `Id IN (...)` filter, sees the change without a round trip. It also exposes the captured `Grid` reference (`:85`) and `ReloadAsync()` (`:88-89`) so the page's [`DataGridListPageBase`](group-15-common-ui-framework.md#datagridlistpagebasetdto) plumbing keeps restoring rows-per-page and current page unchanged. `[Rubric §18, UI Architecture & Component Design]` and `[Rubric §19, State Management & Data Flow]`: state has exactly one owner (the page) and one mutation point (this component). +- **What it is**: the presentational session-list view for [`PublicSessionList`](#publicsessionlist): the mobile infinite-scroll card list and the desktop server-paged data grid, including the inline bookmark stars and their toggle flow (`PublicSessionListView.razor.cs:12-22`). +- **Depends on**: [`SessionDTO`](group-17-conference-domain.md#sessiondto), [`ISessionBookmarkUIService`](group-22-engagement-module.md#isessionbookmarkuiservice) (optional, `:57`), [`SpeakerInfo`](#speakerinfo) (`:66`), [`ConferenceRoutePaths`](#conferenceroutepaths) (`:176`), [`MobileInfiniteScrollList`](group-15-common-ui-framework.md#mobileinfinitescrolllisttitem) (`:77`, rendered at `PublicSessionListView.razor:6`), [`ListPageActions`](group-24-identity-module.md#listpageactions) (`:91`), and [`IHapticFeedbackService`](group-26-device-capability-layer.md#ihapticfeedbackservice) (`:26`); MudBlazor grid types and `NavigationManager`. +- **Concept introduced, the presentational child that patches container-owned state in place.** Like [`PublicSessionListFilterBar`](#publicsessionlistfilterbar), the view owns no fetch or filter state: the page hands down its `ServerData` and `FetchPage` delegates (`:72,75`), its paging parameters (`:39-45`), the speaker and room lookups (`:66,69`), and the shared `BookmarkedSessions` dictionary (`:63`). The subtlety is that the view **mutates that dictionary in place** when a star is toggled (`AddBookmarkAsync` writes `BookmarkedSessions[sessionId] = bookmark.Id` at `:154`, `RemoveBookmarkAsync` removes at `:139`), so the page's My Schedule fetch, which reads the same dictionary to build its `Id IN (...)` filter, sees the change without a round trip. The class doc names the sibling that uses the same pattern (`:16-18`). It also exposes the captured `Grid` reference (`:87`) and `ReloadAsync()` (`:90-91`) so the page's [`DataGridListPageBase`](group-15-common-ui-framework.md#datagridlistpagebasetdto) plumbing keeps restoring rows-per-page and current page unchanged. `[Rubric §18, UI Architecture & Component Design]` and `[Rubric §19, State Management & Data Flow]`: state has exactly one owner (the page) and one mutation point (this component). + The class doc also records a deliberate omission (`:20-21`): the list shows no track or category chips, because the detail page is where a session's categories are read and the list stays scannable on time, speakers, and room. `[Rubric §25, Navigation & Information Architecture]`. - **Walkthrough** - - `CanBookmark` (`:98-103`): a session is bookmarkable only when the user is authenticated, the Engagement-owned service resolved, the session is not a service session, and its status is unset or `"Accepted"`. The comment (`:94-97`) records that this literal mirrors [`SessionStatuses`](group-17-conference-domain.md#sessionstatuses) in Conference.Domain, which is the source of truth: the UI layer depends on Shared only, so the check is duplicated rather than referenced, precisely so the UI never shows a star the server would reject. `[Rubric §11, Security]` and `[Rubric §24, Forms, Validation & UX Safety]`. - - `ToggleBookmarkAsync` (`:105-132`): guards re-entry with a **per-session** `HashSet` (`:78`), fires `Haptics.Click()` (`:111`, a no-op off native heads), then adds or removes. The per-session guard is a fixed defect worth reading: the comment at `:76-77` records that a single global in-flight flag made one slow toggle swallow every other star's click. - - `AddBookmarkAsync` (`:147-161`): a 2xx whose body deserialized to null leaves the star unset, so the page reports a warning rather than a success toast that would contradict its own UI (`:156-160`). - - `RemoveBookmarkAsync` (`:134-145`): removes the entry and, when the My Schedule view is active, reloads so the removed row disappears. - - `GetSpeakerList` (`:163-171`) maps a session's `SessionSpeakers` to display names through the passed-in lookup; `OnMobileCardClick` (`:173-174`) routes to [`PublicSessionDetail`](#publicsessiondetail). + - `IsBookmarked` (`:93-94`) is a dictionary lookup, so star state costs nothing per row. + - `CanBookmark` (`:100-105`): a session is bookmarkable only when the user is authenticated, the Engagement-owned service resolved, the session is not a service session, and its status is unset or `"Accepted"`. The comment (`:96-99`) records that this literal mirrors [`SessionStatuses`](group-17-conference-domain.md#sessionstatuses) in Conference.Domain, which is the source of truth: the UI layer depends on Shared only, so the check is duplicated rather than referenced, precisely so the UI never shows a star the server would reject. `[Rubric §11, Security]` and `[Rubric §24, Forms, Validation & UX Safety]`. + - `ToggleBookmarkAsync` (`:107-134`): guards re-entry with a **per-session** `HashSet` whose `Add` doubles as the guard test (`:109`, field at `:80`), fires `Haptics.Click()` (`:113`, a no-op off native heads), then adds or removes, with a single error snackbar around both (`:126-129`) and removal from the guard set in the `finally` (`:132`). The per-session guard is a fixed defect worth reading: the comment at `:78-79` records that a single global in-flight flag made one slow toggle swallow every other star's click. + - `AddBookmarkAsync` (`:149-163`): a 2xx whose body deserialized to null leaves the star unset, so the page reports a warning rather than a success toast that would contradict its own UI (`:157-162`). + - `RemoveBookmarkAsync` (`:136-147`): removes the entry and, when the My Schedule view is active, reloads so the removed row disappears (`:143-146`). + - `GetSpeakerList` (`:165-173`) maps a session's `SessionSpeakers` to display names through the passed-in lookup, skipping ids the lookup does not know; `OnMobileCardClick` (`:175-176`) routes to [`PublicSessionDetail`](#publicsessiondetail). - **Why it's built this way**: separating the grid and card layouts from the page's fetch-and-filter logic lets one bookmark implementation serve both, while the page remains the owner of every piece of state either layout renders. -- **Where it's used**: rendered by [`PublicSessionList`](#publicsessionlist), which holds it as `_view` (`PublicSessionList.razor.cs:44`) and reads `_view?.Grid` for its `GridRef` override (`:70`). +- **Where it's used**: rendered by [`PublicSessionList`](#publicsessionlist), which holds it as `_view` (`PublicSessionList.razor.cs:44`, captured at `PublicSessionList.razor:31`) and reads `_view?.Grid` for its `GridRef` override (`:72`) and `_view?.ReloadAsync()` for every filter change (`:271`). --- ### PublicEventDetail -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Public` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicEventDetail.razor.cs:14` · Level 8 · class (Blazor code-behind) - -- **What it is**: the read-only public view of one event: venue information, rooms, support contacts, and the conference-day conveniences (copy the Wi-Fi details, open directions, a distance-to-venue hint, a QR code for the page itself). -- **Depends on**: [`IEventUIService`](#ieventuiservice) (`:16`), [`EventDTO`](group-17-conference-domain.md#eventdto), [`ConferenceRoutePaths`](#conferenceroutepaths) (`:45,102,104`), [`DomainHelper`](group-02-domain-building-blocks.md#domainhelper)'s `Id.Parse` extension for the route string (`:70`), and four device-capability abstractions: [`IClipboardService`](group-26-device-capability-layer.md#iclipboardservice), [`IMapNavigationService`](group-26-device-capability-layer.md#imapnavigationservice), [`IGeolocationService`](group-26-device-capability-layer.md#igeolocationservice), [`IGeocodingService`](group-26-device-capability-layer.md#igeocodingservice) (`:19-22`). It also reads `IConfiguration` for the host-wide support address (`:23,40-41`). -- **Concept introduced, load-once-on-parameters plus best-effort progressive enhancement.** Two mechanisms recur across every public detail page. - 1. **Load once per id.** The route value arrives as `[Parameter] string Id` (`:25`), and `OnParametersSetAsync` compares it against `_loadedId` (`:54-63`) so a re-render does not refetch; the typed id is produced by `Id.Parse()` (`:70`). - 2. **Every capability is optional.** `TryComputeDistanceAsync` (`:141-163`) returns early when geolocation or geocoding is unsupported or the venue address is blank, and again on any null result, so a denied permission or an offline geocoder simply leaves the hint off. The doc comment states the rule plainly: this must never block the page (`:136-140`). `[Rubric §29, Resilience & Business Continuity]` (assesses degradation when an optional dependency is absent) and `[Rubric §26, Front-End Security]` (a location read is soft and unblocking, never a gate on content). These come from the device-capability layer of [ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html). - A third detail is a small but real configuration rule: a per-event `OrganizerContactEmail` wins over the host-wide `Support:Email`, and it is re-evaluated on every load so navigating between events never leaves the previous organizer's address on screen (`:78-83`). `[Rubric §16, Maintainability]`: a conference can publish its own contact without a redeploy. +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Public` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicEventDetail.razor.cs:16` · Level 8 · class (Blazor code-behind) + +- **What it is**: the read-only public view of one event: venue information, rooms, support contacts, and the conference-day conveniences (copy the Wi-Fi details, open directions, a distance-to-venue hint, a QR code for the page itself). For a public visitor it is also the landing page of the whole conference, because [`PublicEventList`](#publiceventlist) redirects them here. +- **Depends on**: [`IEventUIService`](#ieventuiservice) (`:18`), [`EventDTO`](group-17-conference-domain.md#eventdto), [`ConferenceReadAudience`](group-17-conference-domain.md#conferencereadaudience) (`:62`), [`ConferenceRoutePaths`](#conferenceroutepaths) (`:78,138,140,142`), [`DomainHelper`](group-02-domain-building-blocks.md#domainhelper)'s `Id.Parse` extension for the route string (`:104`), and four device-capability abstractions: [`IClipboardService`](group-26-device-capability-layer.md#iclipboardservice), [`IMapNavigationService`](group-26-device-capability-layer.md#imapnavigationservice), [`IGeolocationService`](group-26-device-capability-layer.md#igeolocationservice), [`IGeocodingService`](group-26-device-capability-layer.md#igeocodingservice) (`:21-24`). It also reads `IConfiguration` for the host-wide support contacts (`:25,54-55`). +- **Concept introduced, load-once-on-parameters, an audience-shaped breadcrumb trail, and best-effort progressive enhancement.** Three mechanisms are worth extracting. + 1. **Load once per id.** The route value arrives as `[Parameter] string Id` (`:27`), and `OnParametersSetAsync` compares it against `_loadedId` (`:88-97`) so a re-render does not refetch; the typed id is produced by `Id.Parse()` (`:104`). + 2. **The breadcrumb trail depends on the audience, and the audience is awaited first.** `OnInitializedAsync` resolves privileged status from role membership before building the trail (`:57-68`), then adds the "Events" crumb only for a privileged reader (`:76-79`). The doc comment states both halves of the reasoning (`:44-51`): a public visitor was redirected *to* this page by the event list, so an Events crumb would bounce them straight back here, and the access token hydrates asynchronously from the HttpOnly cookie, so reading roles synchronously would render the wrong trail and correct it on the next render. A failed auth read is treated as non-privileged (`:63-67`). `[Rubric §25, Navigation & Information Architecture]` (assesses that navigation affordances lead somewhere the reader can actually use) and `[Rubric §26, Front-End Security]` (fail closed to the narrower audience). + 3. **Every capability is optional.** `TryComputeDistanceAsync` (`:179-201`) returns early when geolocation or geocoding is unsupported or the venue address is blank (`:181-184`), and again on any null result (`:186-196`), so a denied permission or an offline geocoder simply leaves the hint off. The doc comment states the rule plainly: this must never block the page (`:174-178`). `[Rubric §29, Resilience & Business Continuity]` (assesses degradation when an optional dependency is absent) and `[Rubric §26, Front-End Security]` (a location read is soft and unblocking, never a gate on content). These come from the device-capability layer of [ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html). + A fourth detail is a small but real configuration rule: a per-event `OrganizerContactEmail` wins over the host-wide `Support:Email`, and it is re-evaluated on every load so navigating between events never leaves the previous organizer's address on screen (`:112-117`). `[Rubric §16, Maintainability]`: a conference can publish its own contact without a redeploy. - **Walkthrough** - - `OnInitialized` (`:37-48`): reads the configured support email and phone and builds the breadcrumb trail. - - `LoadEventAsync` (`:65-100`): parses the id, fetches with children (`GetByIdAsync(eventId, true, ...)`, `:71`), snackbars a not-found (`:74`), otherwise resolves the support address and kicks off the distance hint (`:81-85`); `OperationCanceledException` is swallowed as expected teardown and the `finally` always clears `IsLoading`. - - `CopyWifiAsync` (`:108-119`): copies `Event.WiFiInfo` through the clipboard abstraction and reports success or failure with one snackbar. - - `OpenDirectionsAsync` (`:121-134`): native heads launch the platform maps app, browsers open a maps site (`:128-129`); a false return raises a warning. - - `TryComputeDistanceAsync` (`:141-163`): geocodes the venue, reads the current-or-last-known position, converts kilometres to miles with an explicit constant (`:160-161`), and calls `StateHasChanged()` because the value arrives after the render that requested it. - - Navigation helpers (`:102-106`) route back to the list, on to the public schedule, and to the event feedback form. + - `LoadEventAsync` (`:99-134`): parse the id, fetch with children (`GetByIdAsync(eventId, true, ...)`, `:105`), snackbar a not-found (`:108`), otherwise resolve the support address and kick off the distance hint (`:115-119`); `OperationCanceledException` is swallowed as expected teardown and the `finally` always clears `IsLoading`. + - `CopyWifiAsync` (`:146-157`): copies `Event.WiFiInfo` through the clipboard abstraction and reports success or failure with one snackbar whose severity flips on the result (`:154-156`). + - `OpenDirectionsAsync` (`:159-172`): native heads launch the platform maps app, browsers open a maps site (`:166-167`); a false return raises a warning. + - `TryComputeDistanceAsync` (`:179-201`): geocodes the venue, reads the current-or-last-known position, converts kilometres to miles with an explicit named constant (`:198-199`), and calls `StateHasChanged()` (`:200`) because the value arrives after the render that requested it. + - Navigation helpers (`:136-144`) route back to the list (privileged readers only, per the comment at `:136-137`), on to the public schedule, on to the activities page, and to the event feedback form. Disposal (`:205-225`) is the standard cancel-on-disposal pattern over the `CancellationTokenSource` at `:31`. - **Why it's built this way**: the public event page is the one an attendee opens while standing in the building, so its extras (Wi-Fi, directions, distance) are worth having and none of them is worth failing the page over. -- **Where it's used**: the `/conference/events/{Id}` route (`PublicEventDetail.razor:1`), reached from [`PublicEventList`](#publiceventlist); its markup also renders the `QrCodeButton` for this page's own public link (`PublicEventDetail.razor:30-31`). +- **Where it's used**: the `/conference/events/{Id}` route (`PublicEventDetail.razor:1`), reached from [`PublicEventList`](#publiceventlist) either as a grid row (privileged) or as a `replace: true` redirect (everyone else); its markup also renders the `QrCodeButton` for this page's own public link (`PublicEventDetail.razor:30`). --- @@ -1917,49 +2092,58 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod - **What it is**: the public speaker profile: photo, bio, social links, and the sessions that speaker presents. Email is deliberately **not** rendered, BR-66 (`PublicSpeakerDetail.razor.cs:10-13`). - **Depends on**: [`ISpeakerUIService`](#ispeakeruiservice) and [`ISessionUIService`](#isessionuiservice) (`:16-17`), [`SpeakerDTO`](group-17-conference-domain.md#speakerdto) and [`SessionDTO`](group-17-conference-domain.md#sessiondto), [`ConferenceRoutePaths`](#conferenceroutepaths) (`:38,130,132`), and [`DomainHelper`](group-02-domain-building-blocks.md#domainhelper)'s `Id.Parse` (`:78`). - **Concept introduced, the prerender skip and the server-side filter that replaced an in-memory one.** - 1. **Prerender skip.** `OnParametersSetAsync` returns immediately when `!RendererInfo.IsInteractive` (`:56-62`): under InteractiveAuto the interactive instance re-runs the method, so without this guard every visit fetched the speaker and their sessions twice. The prerender pass renders the loading skeleton instead. `[Rubric §23, Front-End Performance & Rendering]` (assesses avoidable duplicate work per view). - 2. **Push the filter to the server.** `LoadSpeakerSessionsAsync` (`:111-128`) sends a `SpeakerId equals` filter with `includeChildren: false`, sorted by `StartsAt` ascending, capped at `MaxSpeakerSessions = 100` (`:24`). The remarks block (`:105-110`) records what this replaced: the page used to pull the entire session catalog with all child collections and filter it in memory on `SessionSpeakers`, so viewing one speaker cost a full-catalog read. Since the page never renders those children, they are gone from the request too. `[Rubric §12, Performance & Scalability]` (assesses moving work to where the data lives) and `[Rubric §8, Data Architecture]` (a bounded page size instead of an unbounded read). + 1. **Prerender skip.** `OnParametersSetAsync` returns immediately when `!RendererInfo.IsInteractive` (`:59-62`): under InteractiveAuto the interactive instance re-runs the method, so without this guard every visit fetched the speaker and their sessions twice. The prerender pass renders the loading skeleton instead, and the comment names the sibling pages that use the same guard (`:56-58`). `[Rubric §23, Front-End Performance & Rendering]` (assesses avoidable duplicate work per view). + 2. **Push the filter to the server.** `LoadSpeakerSessionsAsync` (`:111-128`) sends a `SpeakerId equals` filter with `includeChildren: false`, sorted by `StartsAt` ascending, capped at `MaxSpeakerSessions = 100` (`:23-24`). The remarks block (`:105-110`) records what this replaced: the page used to pull the entire session catalog with all child collections and filter it in memory on `SessionSpeakers`, so viewing one speaker cost a full-catalog read. Since the page never renders those children, they are gone from the request too. `[Rubric §12, Performance & Scalability]` (assesses moving work to where the data lives) and `[Rubric §8, Data Architecture]` (a bounded page size instead of an unbounded read). `[Rubric §30, Compliance, Privacy & Data Governance]` (assesses deliberate handling of personal data): the speaker email exists on the DTO but is never rendered on the public page, and the class doc names the rule. - **Walkthrough** - - `OnInitialized` (`:32-41`) builds the Home / Speakers / Profile breadcrumbs; `HasSocialLinks` (`:48-52`) collapses the four optional link fields into a single render guard. - - `LoadSpeakerAsync` (`:73-100`): parse the id, `GetByIdAsync(speakerId, true, ...)` (`:79`), snackbar and return on not-found (`:81-84`), then load the sessions; `OperationCanceledException` is swallowed and the `finally` clears `IsLoading`. + - `OnInitialized` (`:32-41`) builds the Home / Speakers / Profile breadcrumbs; unlike [`PublicEventDetail`](#publiceventdetail) it is synchronous, because the speaker list is reachable by every audience and the trail does not vary. + - `HasSocialLinks` (`:48-52`) collapses the four optional link fields into a single render guard, so the social row is absent rather than empty when a speaker supplied none. + - `LoadSpeakerAsync` (`:73-100`): parse the id, `GetByIdAsync(speakerId, true, ...)` (`:79`), snackbar and return on not-found (`:80-84`), then load the sessions (`:86`); `OperationCanceledException` is swallowed (`:88-91`) and the `finally` clears `IsLoading`. - Navigation (`:130-132`) routes to a session or back to the speaker list. Disposal (`:136-156`) is the standard cancel-on-disposal pattern over the page's `CancellationTokenSource` (`:26`). - **Why it's built this way**: a public profile is a read-only, cache-friendly page; keeping its fetches narrow (one speaker, that speaker's sessions, no children) is what makes it cheap enough to serve to an anonymous crowd. -- **Where it's used**: the `/conference/speakers/{Id}` route (`PublicSpeakerDetail.razor:1`), reached from [`PublicSpeakerList`](#publicspeakerlist) and from session pages. Its markup renders the `QrCodeButton` for its own link (`PublicSpeakerDetail.razor:44-45`), the reader-facing counterpart of [`SpeakerQr`](#speakerqr). +- **Where it's used**: the `/conference/speakers/{Id}` route (`PublicSpeakerDetail.razor:1`), reached from [`PublicSpeakerList`](#publicspeakerlist) cards and from session pages. Its markup renders the `QrCodeButton` for its own link (`PublicSpeakerDetail.razor:45`). --- -### SpeakerCategoryItemsPanel -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Speaker` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerCategoryItemsPanel.razor.cs:16` · Level 8 · class (Blazor code-behind) - -- **What it is**: the "Additional Info" panel carved out of [`SpeakerDetail`](#speakerdetail). It renders a speaker's category items grouped by category and hosts the add/remove chip actions (`SpeakerCategoryItemsPanel.razor.cs:9-15`). -- **Depends on**: [`ISpeakerCategoryItemUIService`](#ispeakercategoryitemuiservice) (`:18`), [`SpeakerDTO`](group-17-conference-domain.md#speakerdto) (`:22`), [`SpeakerCategoryItemDTO`](group-17-conference-domain.md#speakercategoryitemdto) (`:42`), [`CategoryItemInfo`](#categoryiteminfo) (`:25`), and the `CategoryItem` / `ConferenceCategory` / `SpeakerCategoryItem` identifier aliases; MudBlazor's `ISnackbar`. -- **Concept introduced, the container/presentational split with an `EventCallback` up-channel.** The page (the container) passes the `Speaker` plus the two lookups it already owns as parameters (`:22-28`), and the panel signals mutations back up through the `Changed` callback (`:31`). After an add or a remove the panel calls `await Changed.InvokeAsync()` (`:73,87`), which the page handles by reloading the speaker, so behavior is identical to the pre-split page. `[Rubric §18, UI Architecture & Component Design]` (assesses cohesive, single-responsibility components): the split trims an already-large parent and gives this sub-view one job. `[Rubric §19, State Management & Data Flow]` (assesses where state lives and how it flows): the panel holds no source-of-truth state (only the transient `_selectedCategoryItemId`, `:34`); data flows down as parameters and mutations flow up through the callback, the canonical unidirectional Blazor pattern. +### PublicActivityList +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Public` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicActivityList.razor.cs:19` · Level 9 · class (Blazor code-behind) + +- **What it is**: the public social and networking programme. It lists the current (or next) event's activities (pre-conference party, coffee connect, after-party, closing ceremony) ordered by start time then display order, read-only and anonymous, BR-43 (`PublicActivityList.razor.cs:11-18`). +- **Depends on**: [`IActivityUIService`](#iactivityuiservice) and [`IEventLookupService`](#ieventlookupservice) (`:26-27`), [`ActivityDTO`](group-17-conference-domain.md#activitydto), [`EventInfo`](#eventinfo) (through the lookup), [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector) (`:53-58`), and [`IMapNavigationService`](group-26-device-capability-layer.md#imapnavigationservice) (`:28`); MudBlazor's `ISnackbar` and the page's `IStringLocalizer`. +- **Concept introduced, the bounded, deterministically ordered, single-shot read.** This page is not a data grid, and reading it next to [`ConferenceCategoryList`](#conferencecategorylist) is the clearest way to see when the base class is the wrong tool. An activity programme is a handful of items with a fixed narrative order (chronological), so there is nothing to page, sort or search. + - **Bounded read**: `MaxActivities = 200` with the reasoning written on the constant, a conference schedules a handful, not thousands (`:21-22`). `[Rubric §12, Performance & Scalability]` (assesses that unbounded reads are avoided by design, not by luck). + - **Deterministic order**: the server is asked for `StartTime` ascending (`:72-73`), and the result is re-ordered in memory by `StartTime`, then `SortOrder`, then `Name` (`:79-85`). The comment (`:76-78`) explains the layering: start time is the programme order, sort order breaks ties between activities that start together, and name is the final tiebreak so the list never depends on insertion order. + - **Failure is non-fatal**: `OperationCanceledException` is separated out as expected teardown (`:87-90`) and the broad `catch` (`:91-94`) deliberately leaves the page on its empty state rather than surfacing an error; the `finally` always clears `_isLoading`. `[Rubric §29, Resilience & Business Continuity]`. + - **Culture-aware time rendering**: `FormatTimeRange` (`:39-43`) formats start and end with `CultureInfo.CurrentCulture` and composes them through a localized `Text.TimeRange` resource, so both the times and the separator follow the viewer's culture. `[Rubric §27, Internationalization]` (assesses that formatting and phrasing are both localized, not just the strings). + The class doc also draws the domain line that shapes the whole page (`:15-17`): activities are not sessions. They carry no room and no speakers, and an activity with its own venue gets the same directions affordance the public event page uses for the conference venue. - **Walkthrough** - - `GetCategoryTitle` / `GetCategoryItemName` (`:36-40`): resolve ids to display names with an invariant-culture id fallback, so a missing lookup entry degrades to a number rather than an exception. - - `GetCategoryItemsGroupedByCategory` (`:42-50`): groups the speaker's assigned items by their parent category id for display; `GetAvailableCategoryItems` (`:52-59`) excludes already-assigned items from the add dropdown. - - `AddCategoryItemAsync` (`:61-79`): posts the selected item, clears the selection, snackbars, and invokes `Changed`; `RemoveCategoryItemAsync` (`:81-93`) deletes by the join-entity id and invokes `Changed`. Both catch broadly and report through a snackbar rather than surfacing an exception. - - The panel owns its own `CancellationTokenSource` (`:33`) cancelled in `Dispose` (`:97-117`), because it makes its own service calls. -- **Why it's built this way**: the speaker editor grew large enough that carving out a self-contained sub-view (owning its own service call, delegating state to the page) shrinks the parent and makes the panel independently testable, with no change in observable behavior. -- **Where it's used**: rendered inside [`SpeakerDetail`](#speakerdetail), which supplies `Speaker`, `CategoryItems`, `CategoryTitles`, and a `Changed` handler that reloads the speaker. + - `OnInitializedAsync` (`:45-99`): load the event lookup (`:50`), resolve the current or next event through [`CurrentEventSelector.SelectCurrentOrNext`](group-17-conference-domain.md#currenteventselector) with the four accessors passed explicitly because the lookup returns `EventInfo` rather than `EventDTO` (`:53-58`), remember its id and name (`:60-61`), build an `EventId equals` filter when one resolved (`:64-66`), fetch one bounded page (`:68-74`), and materialize the ordered list (`:79-85`). + - `OpenDirectionsAsync` (`:101-118`): does nothing for an activity with no venue address (`:103-106`), otherwise launches the platform maps app on native heads or a maps site in a browser (`:109-112`), labelling the pin with the venue name and falling back to the activity name when the venue is unnamed (`:111`); a false return raises one warning snackbar (`:114-117`). + - Disposal (`:122-142`) is the standard cancel-on-disposal pattern over the `CancellationTokenSource` at `:31`. +- **Why it's built this way**: a fixed-order programme wants a readable timeline, not sortable columns, and the read is small enough that one bounded call beats the machinery of server paging. +- **Where it's used**: the `/conference/activities` route (`PublicActivityList.razor:1`), reached from [`PublicEventDetail`](#publiceventdetail)'s `ViewActivities` action (`PublicEventDetail.razor.cs:142`). +- **Caveats / not-in-source**: the page relies on the server scoping non-privileged callers to published events (class doc, `:13-15`); that scoping is enforced in the Conference API, not here. --- -### SpeakerDetail -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Speaker` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerDetail.razor.cs:19` · Level 8 · class (Blazor code-behind) - -- **What it is**: the organizer's full speaker console. Beyond load / inline-edit / delete it composes the category-item panel, resolves question-answer text, lists the speaker's sessions, and runs the **link/unlink a User to this Speaker** flow (`SpeakerDetail.razor.cs:14-18`). -- **Depends on**: [`ISpeakerUIService`](#ispeakeruiservice), [`ISessionUIService`](#isessionuiservice), [`IConferenceCategoryUIService`](#iconferencecategoryuiservice), [`ICategoryItemLookupService`](#icategoryitemlookupservice), [`IQuestionUIService`](#iquestionuiservice), and [`IUserUIService`](group-24-identity-module.md#iuseruiservice) (`:23-28`); [`SpeakerDTO`](group-17-conference-domain.md#speakerdto), [`SessionDTO`](group-17-conference-domain.md#sessiondto), [`UserListDTO`](group-24-identity-module.md#userlistdto), [`CategoryItemInfo`](#categoryiteminfo), [`ConferenceRoutePaths`](#conferenceroutepaths), [`ErrorMessages`](group-15-common-ui-framework.md#errormessages), and the shared `DeleteConfirmation` component (`:71`). It hosts [`SpeakerCategoryItemsPanel`](#speakercategoryitemspanel). -- **Concept introduced, the cross-module composition page.** One page composes data from Conference **and** Identity plus three lookups resolved into display names. `[Rubric §7, Microservices Readiness]` (assesses that cross-module access goes through abstractions rather than direct references): the Identity reach is [`IUserUIService`](group-24-identity-module.md#iuseruiservice), an HTTP client behind an interface, so the page is indifferent to Identity running as its own service. `[Rubric §18, UI Architecture & Component Design]` (a high dependency count is a cohesion signal to watch): the page delegates its category-item sub-view to a child component and keeps the rest, so its remaining size comes from orchestrating six clients and three lookups rather than from bespoke mechanics. +### PublicEventList +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Public` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicEventList.razor.cs:30` · Level 9 · class (Blazor code-behind) + +- **What it is**: the `/conference/events` route, where the audience decides whether a list is shown at all. A privileged reader (Organizer or ContentEditor) gets the full grid of published **and** unpublished events; every other visitor is redirected to the current or next event's detail page (`PublicEventList.razor.cs:13-29`). +- **Depends on**: extends [`DataGridListPageBase`](group-15-common-ui-framework.md#datagridlistpagebasetdto) closed over [`EventDTO`](group-17-conference-domain.md#eventdto) (`:30`); [`IEventUIService`](#ieventuiservice) and [`IEventLookupService`](#ieventlookupservice) (`:34-35`), [`ConferenceReadAudience`](group-17-conference-domain.md#conferencereadaudience) (`:76`), [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector) (`:104`), [`EventInfo`](#eventinfo) (`:90`), [`ConferenceRoutePaths`](#conferenceroutepaths) (`:116,159`), [`MobileInfiniteScrollList`](group-15-common-ui-framework.md#mobileinfinitescrolllisttitem) (`:45`, rendered at `PublicEventList.razor:23`), and [`ListPageActions`](group-24-identity-module.md#listpageactions) (`:134`). Server-side the audience split is enforced by [`PublishedEventSpecification`](group-18-conference-application.md#publishedeventspecification). +- **Concept introduced, the audience gate as a routing decision, and the three-state render.** This page is the sharpest example in the group of a UI decision that has to wait for identity. + 1. **Resolve the audience before deciding anything.** `OnInitializedAsync` awaits the cascading `Task` and reads role membership first (`:71-82`). The comment (`:68-70`) and the class doc (`:21-25`) both name the failure this prevents: on all three heads (Blazor Server, WebAssembly, MAUI) the access token hydrates asynchronously from the HttpOnly cookie, so a synchronous role read would see an anonymous principal and bounce an organizer off their own list. A failed read is treated as non-privileged (`:78-81`). `[Rubric §11, Security]` and `[Rubric §26, Front-End Security]` (assess that an authorization-shaped branch reads a settled principal, and fails to the narrower audience). + 2. **Three states, and one of them renders nothing.** `_showGrid` (`:52`) opens the search box and the layout switch for a privileged reader (`:84-88`); `_showEmpty` (`:60`) is set only when nothing is published anywhere, so there is no redirect target and the page has to stay and say so (`:120-122`); and the redirect path deliberately leaves **both** false (`:111-118`), so the page stays blank until the navigation takes effect instead of flashing a list on the way out. The field doc spells this out (`:54-59`). `[Rubric §18, UI Architecture & Component Design]` and `[Rubric §22, Responsive & Cross-Browser]` (assess that intermediate states are designed rather than incidental). + 3. **`replace: true` on the redirect.** The comment (`:113-115`) records the exact bug it prevents: left in the history stack, Back from the event detail would land here and redirect straight forward again, trapping the visitor on the detail page. `[Rubric §25, Navigation & Information Architecture]` (assesses that the Back button keeps working). + The redirect target is computed with [`CurrentEventSelector.SelectCurrentOrNext`](group-17-conference-domain.md#currenteventselector), passing the four accessors explicitly because the lookup returns `EventInfo` rather than `EventDTO`, which the comment calls out (`:100-109`). It is the same live-window math every other landing surface uses, so a visitor always lands on the conference that is actually happening. - **Walkthrough** - - `LoadAsync` (`:91-121`): `GetByIdAsync(speakerId, true, ...)` (children included, `:97`), then lazily hydrate three lookups with `??=` so a re-load does not re-fetch them: category items (`:104`), category titles (`LoadCategoryTitlesAsync`, `:150-155`), and question texts (`LoadQuestionTextsAsync`, `:157-162`). Errors report through [`ErrorMessages`](group-15-common-ui-framework.md#errormessages) helpers (`:100,115`). - - `LoadSpeakerSessionsAsync` (`:131-148`) uses the same server-side `SpeakerId equals` filter as the public page, capped at `MaxSpeakerSessions = 100` (`:35`) with `includeChildren: false`; the remarks (`:126-130`) record that this replaced a full-catalog read filtered in memory. `[Rubric §12, Performance & Scalability]`. - - Inline edit (`:167-247`): `StartEditing` seeds the `_edit*` **shadow fields** (`:167-186`) and `CancelEditing` discards them (`:188-192`), so the live record is never mutated until a validated save succeeds. `SaveChangesAsync` validates the `MudForm`, rebuilds the DTO preserving `RowVersion` (`:214`) and `LinkedUserId` (`:226`) so a profile edit cannot clear the org-managed link, updates, and re-fetches. `[Rubric §24, Forms, Validation & UX Safety]` and `[Rubric §8, Data Architecture]` (the round-tripped `RowVersion` is the client half of optimistic concurrency). - - Delete (`:249-276`): confirm through the shared `DeleteConfirmation` dialog, delete, then navigate back to the list. - - **User link/unlink** (`:279-357`): `SearchUsersAsync` is the notable one. `GetPagedAsync` ANDs its filters server-side (in-code comment, `:288-290`), so a single call with email, first name, and last name all set to the same term would return the empty intersection. The page instead fans out **three parallel calls** (`:291-295`), unions them with `DistinctBy(u => u.UserId)` and takes 10 (`:301-305`). `OnUserPickedAsync` (`:313-334`) calls `LinkUserAsync` and reloads; `UnlinkUserAsync` (`:336-357`) clears it. This is the flow that produces the `speaker_id` claim [`SpeakerDashboard`](#speakerdashboard) and [`SpeakerQr`](#speakerqr) depend on. -- **Why it's built this way**: an organizer needs one console to fully administer a speaker, including wiring them to a login account; composing the views here (and delegating the category panel) trades page breadth for a one-stop editor. The three-call user search is a deliberate workaround for AND-only server filtering. -- **Where it's used**: the `/speakers/{Id}` route (`SpeakerDetail.razor:1`), reached from [`SpeakerList`](#speakerlist) rows and [`SpeakerCreate`](#speakercreate) redirects; it hosts [`SpeakerCategoryItemsPanel`](#speakercategoryitemspanel). -- **Caveats / not-in-source**: the AND-only semantics of `GetPagedAsync` are asserted by the in-code comment; the filter behavior itself lives in the Identity API, not this page. + - `GridRef` (`:41`) exposes the captured grid so the base can restore rows-per-page and current page; `RetryLoadAsync` (`:43-44`) re-runs the fetch from the inline error state the base renders when `LoadFailed` is set. `[Rubric §29, Resilience & Business Continuity]`. + - `SaveFilters` / `RestoreFilters` (`:125-129`) persist the one search term; `OnSearchChanged` (`:131-135`) stores it and reloads whichever layout is active through `ListPageActions.ReloadActiveLayoutAsync`. + - `LoadServerData` (`:137-147`) passes `showCancelSnackbar: false`, so a superseded fetch (the reader typed another character) is silent rather than raising a toast, and turns the search string into a `Name contains` server filter. + - `FetchMobilePage` (`:150-156`) is the parallel infinite-scroll path, hard-sorted by `Name` ascending; `OnMobileCardClick` (`:158-159`) routes to [`PublicEventDetail`](#publiceventdetail). +- **Why it's built this way**: a public visitor cares about the conference that is running or coming up, not about a roster of past editions, while an organizer curating the catalog needs every row including the unpublished ones. One route serving both is cheaper than two, provided the audience is known before the branch is taken. +- **Where it's used**: the `/conference/events` route (`PublicEventList.razor:1`). Every non-privileged arrival leaves immediately for [`PublicEventDetail`](#publiceventdetail); privileged rows and cards navigate to the same page. +- **Caveats / not-in-source**: the published-only scoping of the underlying reads for non-privileged callers (BR-108) is enforced in the Conference API through [`PublishedEventSpecification`](group-18-conference-application.md#publishedeventspecification), not on this page. --- @@ -1967,36 +2151,40 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod > MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Public` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSessionDetail.razor.cs:20` · Level 9 · class (Blazor code-behind) - **What it is**: the public read-only view of one session (speakers, categories, room and wayfinding) plus the contextual actions an authenticated attendee gets: the bookmark toggle, the feedback link, a listen-aloud button, and the Live entry point when the Engagement module is present. -- **Depends on**: [`ISessionUIService`](#isessionuiservice), [`ISpeakerLookupService`](#ispeakerlookupservice), [`IRoomUIService`](#iroomuiservice), [`ICategoryItemLookupService`](#icategoryitemlookupservice) (`:22-25`); optionally [`ISessionBookmarkUIService`](group-22-engagement-module.md#isessionbookmarkuiservice) and [`ISessionLiveUIService`](group-23-engagement-live-layer.md#isessionliveuiservice) (`:34,37`); [`IHapticFeedbackService`](group-26-device-capability-layer.md#ihapticfeedbackservice) and [`ITextToSpeechService`](group-26-device-capability-layer.md#itexttospeechservice) (`:29-30`); [`SessionDTO`](group-17-conference-domain.md#sessiondto), [`RoomDTO`](group-17-conference-domain.md#roomdto), [`ConferenceRoutePaths`](#conferenceroutepaths), and [`DomainHelper`](group-02-domain-building-blocks.md#domainhelper)'s `Id.Parse` (`:109`). -- **Concept introduced, optional cross-module services resolved through the container.** Blazor's `[Inject]` has no optional mode (an unregistered service throws at render), so the two Engagement-owned services are resolved with `ServiceProvider.GetService()` in `OnInitialized` and left null when that module is disabled (`:32-37,52-53`). Every use site then null-checks. `[Rubric §7, Microservices Readiness]` (assesses that a module can be switched off without breaking its consumers): the Conference page degrades to a plain read-only session view when Engagement is absent, rather than failing to render. `[Rubric §3, Clean Architecture]`: the dependency is on an interface owned by the other module's Shared/UI contract, never on its internals. - The page repeats the two mechanisms taught above: the **prerender skip** (`:84-93`, whose comment names the category-item read as the expensive duplicate) and **load-once-on-parameters** (`:95-101`). It also repeats the BR-49 status allow-list as `IsStatusIneligible` (`:77-82`), with the comment again pointing at [`SessionStatuses`](group-17-conference-domain.md#sessionstatuses) as the server-side source of truth. +- **Depends on**: [`ISessionUIService`](#isessionuiservice), [`ISpeakerLookupService`](#ispeakerlookupservice), [`IRoomUIService`](#iroomuiservice), [`ICategoryItemLookupService`](#icategoryitemlookupservice) (`:22-25`); optionally [`ISessionBookmarkUIService`](group-22-engagement-module.md#isessionbookmarkuiservice) and [`ISessionLiveUIService`](group-23-engagement-live-layer.md#isessionliveuiservice) (`:34,37`); [`IHapticFeedbackService`](group-26-device-capability-layer.md#ihapticfeedbackservice) and [`ITextToSpeechService`](group-26-device-capability-layer.md#itexttospeechservice) (`:29-30`); [`SessionDTO`](group-17-conference-domain.md#sessiondto), [`RoomDTO`](group-17-conference-domain.md#roomdto), [`ConferenceRoutePaths`](#conferenceroutepaths) (`:57,236`), and [`DomainHelper`](group-02-domain-building-blocks.md#domainhelper)'s `Id.Parse` (`:109`). +- **Concept introduced, optional cross-module services resolved through the container.** Blazor's `[Inject]` has no optional mode (an unregistered service throws at render), so the two Engagement-owned services are resolved with `ServiceProvider.GetService()` in `OnInitialized` and left null when that module is disabled (`:32-37,52-53`). Every use site then null-checks. `[Rubric §7, Microservices Readiness]` (assesses that a module can be switched off without breaking its consumers): the Conference page degrades to a plain read-only session view when Engagement is absent, rather than failing to render. `[Rubric §3, Clean Architecture]`: the dependency is on an interface owned by the other module's Shared or UI contract, never on its internals. + The page repeats two mechanisms taught above. The **prerender skip** (`:84-93`) carries the most specific comment of the three that use it: it names the category-item read as the expensive duplicate, a full-table read per view. And **load-once-on-parameters** (`:95-101`) keeps a re-render from refetching. It also repeats the BR-49 status allow-list as `IsStatusIneligible` (`:77-82`), with the comment again pointing at [`SessionStatuses`](group-17-conference-domain.md#sessionstatuses) as the server-side source of truth and explaining that the UI layer depends on Shared only. - **Walkthrough** - - `LoadSessionAsync` (`:104-134`): fetch the session with children, then resolve speaker names (`:136-142`), category names (`:144-154`, prefixing the category title when present), the room including wayfinding info (`:156-166`, BR-94), and the caller's bookmark state (`:168-189`, keyed off the `user_id` claim). - - `ToggleBookmarkAsync` (`:191-234`): a single `_isTogglingBookmark` re-entry guard (this page shows one session, so the per-session set the list view needs is unnecessary here), a haptic click, then delete-or-create with the same null-body warning path the list view uses (`:218-223`). - - `ToggleListenAsync` (`:243-266`): text to speech over the description, where the same button stops playback (`:250-254`); `SpeakAsync` completes when playback finishes or `StopAsync` cancels it. `[Rubric §21, Accessibility]` (assesses alternative modalities for content) and [ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html) Wave 3. + - `LoadSessionAsync` (`:104-134`): fetch the session with children (`:110`), then resolve speaker names (`:136-142`), category names (`:144-154`, prefixing the category title when present so a chip reads "Level: Intermediate"), the room including wayfinding info (`:156-166`, BR-94, and skipped entirely for a session with no room), and the caller's bookmark state (`:168-189`, keyed off the `user_id` claim). Each resolver runs only after the session loaded, so a not-found short-circuits the whole chain (`:111-115`). + - `ToggleBookmarkAsync` (`:191-234`): a single `_isTogglingBookmark` re-entry guard (this page shows one session, so the per-session set [`PublicSessionListView`](#publicsessionlistview) needs is unnecessary here), a haptic click (`:197`), then delete-or-create with the same null-body warning path the list view uses (`:219-223`). + - `ToggleListenAsync` (`:243-266`): text to speech over the description, where the same button stops playback (`:250-254`); `SpeakAsync` completes when playback finishes or `StopAsync` cancels it, and the `finally` clears `_isSpeaking` either way. `[Rubric §21, Accessibility]` (assesses alternative modalities for content) and [ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html) Wave 3. - Navigation (`:236-238`) returns to the schedule or opens the session feedback form; disposal (`:270-290`) is the standard cancel-on-disposal pattern. - **Why it's built this way**: this is the page an attendee opens in a hallway, so the expensive lookups are done once per id, the optional capabilities fail soft, and the actions (star, feedback, listen, Live) sit inline instead of on separate routes. -- **Where it's used**: the `/conference/sessions/{Id}` route (`PublicSessionDetail.razor:1`), reached from [`PublicSessionListView`](#publicsessionlistview) rows and cards; its markup renders the `QrCodeButton` for its own public link (`PublicSessionDetail.razor:41-42`). +- **Where it's used**: the `/conference/sessions/{Id}` route (`PublicSessionDetail.razor:1`), reached from [`PublicSessionListView`](#publicsessionlistview) rows and cards and from [`PublicSpeakerDetail`](#publicspeakerdetail); its markup renders the `QrCodeButton` for its own public link (`PublicSessionDetail.razor:41`). --- ### PublicSpeakerList -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Public` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSpeakerList.razor.cs:27` · Level 9 · class (Blazor code-behind) - -- **What it is**: the public speaker directory: photos and taglines, no emails (BR-66), read-only for everyone (BR-43). The server returns only speakers with a visible session in the listed event, or in any published event when no event filter is applied, BR-239 (`PublicSpeakerList.razor.cs:15-26`). -- **Depends on**: extends [`DataGridListPageBase`](group-15-common-ui-framework.md#datagridlistpagebasetdto) (`:27`); [`ISpeakerUIService`](#ispeakeruiservice) and [`IEventLookupService`](#ieventlookupservice) (`:31-32`), [`SpeakerDTO`](group-17-conference-domain.md#speakerdto), [`EventInfo`](#eventinfo) (`:48`), [`ConferenceReadAudience`](group-17-conference-domain.md#conferencereadaudience) (`:97`), [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector) (`:131`), [`ListPageActions`](group-24-identity-module.md#listpageactions) (`:146`), [`MobileInfiniteScrollList`](group-15-common-ui-framework.md#mobileinfinitescrolllisttitem) (`:42`), and [`ConferenceRoutePaths`](#conferenceroutepaths) (`:196`). -- **Concept introduced, the audience-aware default filter (and why the default is not just a convenience).** This page layers three things on the base list shape. - 1. **A persisted event filter with an `"all"` sentinel** (`:50-80`): the sentinel distinguishes an explicit "show all events" from *no saved state*, which is what triggers the computed default. Crucially, the choice is persisted **only for privileged readers** (`:56`): everyone else is always locked to the computed event, so a privileged reader's shared URL cannot pin an attendee to a different or unpublished event. - 2. **A computed default** via [`CurrentEventSelector.SelectCurrentOrNext`](group-17-conference-domain.md#currenteventselector) (`:117-138`): a restored id that still exists wins for privileged readers, a dangling one falls back to the current-or-next event rather than rendering an empty grid. - 3. **A startup race guard**: `OnInitializedAsync` assigns `_eventsLoadTask` before its first `await` (`:82-88`) and both `LoadServerData` (`:161-174`) and `FetchMobilePage` (`:185-193`) await that same task before applying filters, because the `MudDataGrid`'s first `ServerData` call can run ahead of `OnInitializedAsync` completing. Without it the first fetch would apply an unresolved filter. - `[Rubric §11, Security]` and `[Rubric §26, Front-End Security]` (assess that a client-persisted preference cannot widen what a user sees): the privileged/non-privileged split is decided from role membership (`:92-103`) and the server independently scopes the underlying reads. `[Rubric §19, State Management & Data Flow]`: filter state is restored, defaulted, and reconciled against the live event set in exactly one method. `[Rubric §25, Navigation & Information Architecture]`: an attendee lands on the conference that is actually happening. +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Public` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSpeakerList.razor.cs:35` · Level 9 · class (Blazor code-behind) + +- **What it is**: the public speaker directory, rendered as a photo-forward responsive **card grid** with infinite scroll, the same layout on desktop and mobile. Read-only for everyone (BR-43), no emails (BR-66), and the server returns only speakers with a visible session in the listed event, or in any published event when no event filter is applied, BR-239 (`PublicSpeakerList.razor.cs:12-34`). +- **Depends on**: extends [`DataGridListPageBase`](group-15-common-ui-framework.md#datagridlistpagebasetdto) closed over [`SpeakerDTO`](group-17-conference-domain.md#speakerdto) (`:35`); [`ISpeakerUIService`](#ispeakeruiservice) and [`IEventLookupService`](#ieventlookupservice) (`:44-45`), [`EventInfo`](#eventinfo) (`:54`), [`ConferenceReadAudience`](group-17-conference-domain.md#conferencereadaudience) (`:122`), [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector) (`:156`), and [`InfiniteScrollSentinel`](#infinitescrollsentinel) (`PublicSpeakerList.razor:144`). Note what is **absent**: no `MudDataGrid`, no `GridRef` override, no [`MobileInfiniteScrollList`](group-15-common-ui-framework.md#mobileinfinitescrolllisttitem). +- **Concept introduced, borrowing a base class's plumbing for a layout it was not written for.** A speaker is a face and a tagline, not a row of columns, so this page throws away the grid and keeps everything else. The class doc explains the trade (`:23-30`): paging keeps the page-based model of the base's mobile path (`MobileItems`, `MobileCurrentPage`, `MobileTotalItems`, `MobilePageSize`, declared at `DataGridListPageBase.cs:47-50`), which already owns the cancellation token, the loading and failure flags, and the saved-state plumbing, and layers infinite scroll on top by **appending** each fetched page to `_loadedSpeakers` (`:56`) rather than replacing, which is what the base's mobile path does on its own. `[Rubric §16, Maintainability]` and `[Rubric §1, SOLID]` (assess reuse of one tested mechanism rather than a parallel implementation). + Three correctness details make that borrowing safe, and they are the real lesson of this page. + 1. **A generation counter supersedes in-flight fetches.** `_generation` (`:66`) is bumped by every reset (search, event filter, breakpoint change, retry) inside `LoadSpeakersAsync` (`:186`), and both `LoadSpeakersAsync` (`:197-200`) and `LoadMoreSpeakersAsync` (`:229-232`) discard their rows when a newer generation has taken over. Without it a slow page-1 fetch could append to the list a later query had already cleared. `[Rubric §19, State Management & Data Flow]`. + 2. **The list is cleared before the await, not after.** The comment (`:190-191`) states why: the grid, and with it the sentinel, is gone while page 1 is in flight, so a stray intersection-observer callback cannot ask for page 2 of the query being replaced. `[Rubric §18, UI Architecture & Component Design]`. + 3. **The page number is committed only on success.** `LoadMoreSpeakersAsync` saves `previousPage`, optimistically advances, and rolls back when `LoadFailed` is set (`:221-241`), so the retry button re-requests the same page instead of silently skipping it. `[Rubric §29, Resilience & Business Continuity]`. + Layered on top is the same **audience-aware default filter** [`PublicSessionList`](#publicsessionlist) uses: an `"all"` sentinel distinguishes an explicit "show all events" from no saved state (`:82-83`), the choice is persisted **only** for privileged readers (`:80`), and `ResolveDefaultEventFilter` (`:142-163`) keeps a restored id that still exists for a privileged reader while locking everyone else to the computed current or next event. The comment (`:144-146`) states the security consequence, and the roles come from [`ConferenceReadAudience`](group-17-conference-domain.md#conferencereadaudience) with a failed read treated as non-privileged (`:117-128`). `[Rubric §11, Security]` and `[Rubric §26, Front-End Security]` (assess that a client-persisted preference cannot widen what a user sees). - **Walkthrough** - - `LoadEventsAndResolveDefaultAsync` (`:90-115`): reads role membership from the cascading auth state (failures are treated as non-privileged, `:99-102`), loads the event lookup (a failure leaves the picker hidden and the filter unset, `:105-112`), then resolves the default. - - `ApplyFilters` (`:176-182`): emits `FullName contains` plus the **virtual** `EventId equals` filter that the speakers/paged endpoint resolves through the EventSpeaker/SessionSpeaker joins, since a Speaker row has no `EventId` column (class doc, `:23-24`). - - `GetSelectedEventName` (`:140-143`) feeds the chip label; `OnSearchChanged` / `OnEventFilterChanged` (`:148-159`) reload whichever layout is active; `RetryLoadAsync` (`:41`) re-runs a failed fetch from the inline error state. -- **Why it's built this way**: attendees browse "the speakers at this conference", not a lifetime roster, so the default filter is the primary behavior and the picker is the privileged exception. -- **Where it's used**: the `/conference/speakers` route (`PublicSpeakerList.razor:1`); rows and cards navigate to [`PublicSpeakerDetail`](#publicspeakerdetail). -- **Caveats / not-in-source**: the join-based resolution of the virtual `EventId` filter is asserted by the class doc comment; the resolution itself lives in the Conference API. + - `CardsPerPage = 12` (`:37-38`) is assigned to the base's `MobilePageSize` in the constructor (`:40`), with the reasoning on the constant: a multiple of 2, 3 and 4 so a full chunk fills whole rows at every breakpoint. `[Rubric §22, Responsive & Cross-Browser]`. + - `HasMoreSpeakers` (`:72`) is true exactly while pages remain unfetched, and that is exactly when the sentinel renders, so the trigger and the condition cannot disagree. + - `FetchCurrentPageAsync` (`:174-178`) delegates to the base's `LoadMobileDataAsync` with a fixed `FullName` ascending sort; `ApplyFilters` (`:272-278`) emits `FullName contains` plus the **virtual** `EventId equals` filter that the speakers endpoint resolves through the EventSpeaker and SessionSpeaker joins, since a Speaker row has no `EventId` column (class doc, `:21-22`). + - `OnMobileDataRequestedAsync` (`:257`) is the base's breakpoint-change hook (`DataGridListPageBase.cs:720`, called at `:272`), overridden here to restart the accumulation rather than fetch one replacement page. + - `RetryLoadAsync` (`:254`), `OnSearchChanged` (`:259-263`) and `OnEventFilterChanged` (`:265-270`) all funnel through `LoadSpeakersAsync`, which is the single reset entry point; `GetSelectedEventName` (`:165-168`) feeds the chip label. + - `Initials` (`:281-288`) builds the no-photo avatar text with spans, tolerating a blank first or last name; `HasSocialLinks` (`:290-294`) hides the social row when a speaker supplied none. +- **Why it's built this way**: attendees browse "the speakers at this conference", not a lifetime roster, so the default filter is the primary behavior and the picker is the privileged exception; and a directory of faces reads better as an endless wall of cards than as a pager. The class doc adds one more deliberate omission (`:31-33`): category chips are absent because the paged endpoint is called with `includeChildren=false`, so asking for them would both enlarge the payload and change the URL the output-cache warmup pins ([ADR-040](https://ivanball.github.io/docs/adr/040-authenticated-output-caching-for-public-reads.html)). +- **Where it's used**: the `/conference/speakers` route (`PublicSpeakerList.razor:1`); cards navigate to [`PublicSpeakerDetail`](#publicspeakerdetail). +- **Caveats / not-in-source**: the join-based resolution of the virtual `EventId` filter is asserted by the class doc comment; the resolution itself lives in the Conference API. A restored mobile page number is deliberately ignored (class doc, `:29-30`), so a reader returning to this page starts at page 1 rather than at the scroll depth they left. --- @@ -2004,251 +2192,297 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod > MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Public` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSponsorList.razor.cs:18` · Level 9 · class (Blazor code-behind) - **What it is**: the public sponsor and exhibitor page. It groups the current (or next) event's sponsors by tier, orders them within each tier, and renders them as logo cards. Read-only and anonymous, BR-43 (`PublicSponsorList.razor.cs:10-17`). -- **Depends on**: [`ISponsorUIService`](#isponsoruiservice) and [`IEventLookupService`](#ieventlookupservice) (`:25-26`), [`SponsorDTO`](group-17-conference-domain.md#sponsordto) and [`SponsorTier`](group-17-conference-domain.md#sponsortier), [`EventInfo`](#eventinfo) (through the lookup), and [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector) (`:52-57`); MudBlazor. -- **Concept introduced, the deterministic grouped read and the graceful empty state.** Unlike the other public browse pages this one is not a data grid: the roster is small and needs a fixed visual hierarchy, so the page fetches one bounded page and shapes it in memory. +- **Depends on**: [`ISponsorUIService`](#isponsoruiservice) and [`IEventLookupService`](#ieventlookupservice) (`:25-26`), [`SponsorDTO`](group-17-conference-domain.md#sponsordto) and [`SponsorTier`](group-17-conference-domain.md#sponsortier), [`EventInfo`](#eventinfo) (through the lookup), and [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector) (`:52-57`); MudBlazor and the page's `IStringLocalizer`. +- **Concept introduced, the deterministic grouped read and the graceful empty state.** Like [`PublicActivityList`](#publicactivitylist), this page is not a data grid: the roster is small and needs a fixed visual hierarchy, so the page fetches one bounded page and shapes it in memory. - **Bounded read**: `MaxSponsors = 200` with the reasoning stated on the constant, a conference sells dozens, not thousands (`:20-21`). `[Rubric §12, Performance & Scalability]` (assesses that unbounded reads are avoided by design, not by luck). - - **Deterministic order**: sponsors are grouped by tier, tiers ordered ascending because that is package order (Platinum first), and each group ordered by `Sort` then `Name` (`:76-86`), so the strip does not depend on insertion order. - - **Empty state with no dead link**: when the event has no sponsors the page falls back to the sponsorship-packet call to action, and when the event publishes no packet URL that call to action is hidden entirely rather than offering a dead link (`:14-16`, field at `:32-36`, assigned at `:61`). `[Rubric §24, Forms, Validation & UX Safety]` and `[Rubric §25, Navigation & Information Architecture]`: a missing value removes an affordance instead of producing a broken one. - - **Failure is non-fatal**: the broad `catch` (`:92-95`) deliberately leaves the page on its call-to-action fallback rather than surfacing an error, and the `finally` always clears `_isLoading`. `[Rubric §29, Resilience & Business Continuity]`. -- **Walkthrough**: `OnInitializedAsync` (`:44-100`) loads the event lookup, resolves the current or next event with [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector) (`:52-57`), remembers its name and sponsorship packet URL (`:60-61`), builds an `EventId equals` filter when an event resolved (`:64-66`), fetches one page sorted by `Sort` ascending (`:68-74`), and materializes `_tiers` (`:78-86`). `TierLabel` (`:42`) localizes each tier name through the page's `IStringLocalizer`, so the tier enum never reaches the screen untranslated (`[Rubric §27, Internationalization]`). Disposal (`:104-124`) is the standard cancel-on-disposal pattern over the `CancellationTokenSource` at `:28`. + - **Deterministic order**: sponsors are grouped by tier, tiers ordered ascending because that is package order (Platinum first), and each group ordered by `Sort` then `Name` (`:78-86`), so the strip does not depend on insertion order. The comment states the rule (`:76-77`). + - **Empty state with no dead link**: when the event has no sponsors the page falls back to the sponsorship-packet call to action, and when the event publishes no packet URL that call to action is hidden entirely rather than offering a dead link (class doc `:14-16`, field doc `:32-36`, assigned at `:61`). `[Rubric §24, Forms, Validation & UX Safety]` and `[Rubric §25, Navigation & Information Architecture]`: a missing value removes an affordance instead of producing a broken one. + - **Failure is non-fatal**: `OperationCanceledException` is separated out as expected teardown or an InteractiveAuto render-mode transition (`:88-91`), and the broad `catch` (`:92-95`) deliberately leaves the page on its call-to-action fallback rather than surfacing an error; the `finally` always clears `_isLoading`. `[Rubric §29, Resilience & Business Continuity]`. +- **Walkthrough**: `OnInitializedAsync` (`:44-100`) loads the event lookup (`:49`), resolves the current or next event with [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector) passing the four accessors explicitly (`:52-57`), remembers its name and sponsorship packet URL (`:60-61`), builds an `EventId equals` filter when an event resolved (`:64-66`), fetches one page sorted by `Sort` ascending (`:68-74`), and materializes `_tiers` as an ordered list of tier-to-sponsors pairs (`:78-86`). `TierLabel` (`:42`) localizes each tier name through the page's `IStringLocalizer`, so the tier enum never reaches the screen untranslated (`[Rubric §27, Internationalization]`). Disposal (`:104-124`) is the standard cancel-on-disposal pattern over the `CancellationTokenSource` at `:28`. - **Why it's built this way**: the sponsor page is a marketing surface with a fixed hierarchy, so it wants deterministic grouping rather than sortable columns, and it must look intentional on an event that has not sold a sponsorship yet. -- **Where it's used**: the `/conference/sponsors` route (`PublicSponsorList.razor:1`). The roster it renders is authored by the organizer through [`SponsorList`](#sponsorlist) and [`SponsorDetail`](#sponsordetail). -- **Caveats / not-in-source**: the page relies on the server scoping non-privileged callers to published events (class doc, `:13-15`); that scoping is enforced in the Conference API, not here. - ---- - -### SpeakerDashboard -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Speaker` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerDashboard.razor.cs:19` · Level 9 · class (Blazor code-behind) - -- **What it is**: the **speaker's own** self-service dashboard (not an organizer page). It reads the linked speaker from the `speaker_id` JWT claim, shows that speaker's profile and their sessions narrowed to the current or next event, with per-session bookmark counts and lazily-loaded per-session feedback, and lets the speaker edit their own bio and social profile, BR-214 (`SpeakerDashboard.razor.cs:11-18`). -- **Depends on**: [`ISpeakerUIService`](#ispeakeruiservice), [`ISpeakerDashboardUIService`](#ispeakerdashboarduiservice), [`IEventLookupService`](#ieventlookupservice), and Blazor's `AuthenticationStateProvider` (`:21-25`); [`SpeakerDTO`](group-17-conference-domain.md#speakerdto), [`SessionDTO`](group-17-conference-domain.md#sessiondto), [`SessionFeedbackDTO`](group-17-conference-domain.md#sessionfeedbackdto), [`EventInfo`](#eventinfo), and [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector) (`:147`). -- **Concept introduced, claim-driven identity scoping, plus prerender-safe loading and lazy expand.** Three ideas converge here. - 1. **Claim-driven scoping.** Instead of an id from the route, the page derives *who you are* from the token: `OnInitializedAsync` reads `speaker_id` from the auth state (`:73-80`) and falls into a "not linked" state when the claim is absent or unparsable. `[Rubric §11, Security]` and `[Rubric §26, Front-End Security]` (assess that authorization derives from trusted server-issued claims, not client-supplied ids): a speaker can only load their own dashboard. The class doc adds the corollary (`:15-17`): a speaker is *not* a privileged reader, so the server returns only publicly visible sessions and a submission still under review does not appear, which the empty-state copy names. - 2. **Prerender-safe loading.** The method returns early when `!RendererInfo.IsInteractive` (`:62-68`), so the profile, sessions, and bookmark counts are not fetched twice per visit. `[Rubric §23, Front-End Performance & Rendering]`. - 3. **Lazy expand.** `ToggleFeedbackAsync` (`:226-262`) fetches a session's feedback only the first time its panel is expanded and caches it in `_sessionFeedback` (`:234-237`), so first paint never fans out one feedback call per session. `[Rubric §19, State Management & Data Flow]`. -- **Walkthrough** - - Load (`:54-140`): breadcrumbs, prerender guard, claim read, `GetByIdAsync(_speakerId, true, ...)` (`:86`), then the speaker's sessions through `DashboardService.GetSpeakerSessionsAsync` (`:95-96`). The comment at `:92-94` records why that read goes through the dashboard service: it bypasses the shared sessions output cache ([ADR-040](https://ivanball.github.io/docs/adr/040-authenticated-output-caching-for-public-reads.html)), so a just-made speaker assignment shows immediately instead of lagging behind a cached public list. - - Narrowing (`:98-109`): `ResolveCurrentEventAsync` (`:142-159`) resolves the current or next event through [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector) and the page filters to it, falling back to all of the speaker's sessions when none resolves. - - Bookmark counts (`:111-126`): one **batched** call, `GetSessionBookmarkCountsAsync`, fills every count. The comment (`:111-113`) records what it replaced: each count used to be its own cross-service hop (HTTP to Conference, gRPC to Engagement). The call sits in its own best-effort `catch` so a failed count read never breaks the render. `[Rubric §12, Performance & Scalability]` and `[Rubric §29, Resilience & Business Continuity]`. - - Profile editing (`:161-224`): `StartEditingProfile` seeds the `_edit*` fields (`:161-175`); `SaveProfileAsync` rebuilds a [`SpeakerDTO`](group-17-conference-domain.md#speakerdto) that preserves `RowVersion`, first/last/full name, `Email`, `ProfilePicture`, and `LinkedUserId` from the loaded record (`:189-205`), so a self-edit can only change the six fields the speaker owns and cannot clear the organizer-managed ones. `[Rubric §11, Security]` and `[Rubric §24, Forms, Validation & UX Safety]`. -- **Why it's built this way**: the speaker portal is a distinct actor view; scoping by claim is the secure way to hand a speaker exactly their own data without an authorization argument on every call, and the batched counts plus the prerender skip keep a cross-service-heavy page responsive. -- **Where it's used**: the `/speaker/dashboard` route (`SpeakerDashboard.razor:1`), gated on the `speaker_id` claim that appears when an organizer links a User to a Speaker in [`SpeakerDetail`](#speakerdetail). [`SpeakerQr`](#speakerqr) is its companion page. -- **Caveats / not-in-source**: the output-cache bypass is documented by the in-code comment; the caching behavior itself lives in the Conference service. - ---- - -### SpeakerList -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Speaker` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerList.razor.cs:19` · Level 9 · class (Blazor code-behind) - -- **What it is**: the organizer's speaker browse page: server-paged search with avatars, an event filter, delete-with-confirmation, and a mobile card layout (`SpeakerList.razor.cs:13-18`). -- **Depends on**: extends [`DataGridListPageBase`](group-15-common-ui-framework.md#datagridlistpagebasetdto) (`:19`); [`ISpeakerUIService`](#ispeakeruiservice) and [`IEventLookupService`](#ieventlookupservice) (`:24-25`), [`SpeakerDTO`](group-17-conference-domain.md#speakerdto), [`EventInfo`](#eventinfo) (`:39`), [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector) (`:103`), [`ListPageActions`](group-24-identity-module.md#listpageactions) (`:113,165`), [`ErrorMessages`](group-15-common-ui-framework.md#errormessages) (`:171`), [`ConferenceRoutePaths`](#conferenceroutepaths) (`:174-175`), [`MobileInfiniteScrollList`](group-15-common-ui-framework.md#mobileinfinitescrolllisttitem) (`:33`), and the shared `DeleteConfirmation` component (`:34`). -- **Concept**: the same event-filtered list shape as [`PublicSpeakerList`](#publicspeakerlist), with the audience logic removed. Every reader of this page is already an organizer, so the filter choice is persisted unconditionally (`:41-49`) and the `"all"` sentinel is the only distinction that matters; `ResolveDefaultEventFilter` (`:92-110`) keeps a restored id that still exists and otherwise falls back to the current-or-next event; and the same **startup race guard** applies, with `_eventsLoadTask` started before the first `await` (`:70-76`) and awaited inside both `LoadServerData` (`:128-140`) and `FetchMobilePage` (`:151-159`). Reading the two pages side by side is the clearest way to see what the privileged/non-privileged split actually costs: one extra role check and one narrowed persistence rule. - `[Rubric §19, State Management & Data Flow]`, `[Rubric §25, Navigation & Information Architecture]`, and `[Rubric §16, Maintainability]` (assesses reuse of one tested shape rather than parallel implementations). -- **Walkthrough** - - `ApplyFilters` (`:142-148`): `FullName contains` plus the same **virtual** `EventId equals` filter resolved server-side through the EventSpeaker/SessionSpeaker joins (class doc, `:15-17`). - - `LoadServerData` (`:128-140`) does **not** pass `showCancelSnackbar: false`, unlike the public lists, so the base's default cancel notification applies here. - - `DeleteSpeakerAsync` (`:164-172`) delegates the whole confirm, delete, notify, reload cycle to `ListPageActions.DeleteWithConfirmationAsync`, passing the delete lambda and the localized messages; the speaker is a top-level entity, so it deletes by a single id (contrast the child-entity list pages, which pass a parent id too). - - `NavigateToCreate` / `NavigateToDetails` (`:174-175`) reach [`SpeakerCreate`](#speakercreate) and [`SpeakerDetail`](#speakerdetail); `OnMobileCardClick` (`:161`) reuses the same detail navigation. -- **Why it's built this way**: organizers work one conference at a time, so the list defaults to the current or next event; everything else is the shared base doing the paging, restoration, and layout switching. -- **Where it's used**: the `/speakers` route (`SpeakerList.razor:1`), the entry point for the whole speaker admin flow. +- **Where it's used**: the `/conference/sponsors` route (`PublicSponsorList.razor:1`). The roster it renders is authored by the organizer through the sponsor admin pages in this module. +- **Caveats / not-in-source**: the page relies on the server scoping non-privileged callers to published events (class doc, `:12-14`); that scoping is enforced in the Conference API, not here. --- ### PublicSessionList > MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Public` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSessionList.razor.cs:25` · Level 10 · class (Blazor code-behind) -- **What it is**: the public conference schedule and the most heavily-wired page in this unit. It is the container half of a three-part page (this class, [`PublicSessionListFilterBar`](#publicsessionlistfilterbar), [`PublicSessionListView`](#publicsessionlistview)): it owns the events and speaker lookups, the event/search/My-Schedule filter state, the bookmark dictionary, the server-paged fetch, and the offline snapshot (`PublicSessionList.razor.cs:16-24`). -- **Depends on**: extends [`DataGridListPageBase`](group-15-common-ui-framework.md#datagridlistpagebasetdto) (`:25`); [`ISessionUIService`](#isessionuiservice), [`IEventUIService`](#ieventuiservice), [`ISpeakerLookupService`](#ispeakerlookupservice) (`:29-33`), the optional [`ISessionBookmarkUIService`](group-22-engagement-module.md#isessionbookmarkuiservice) (`:38`), [`ILocalCacheStore`](group-26-device-capability-layer.md#ilocalcachestore) and [`IConnectivityStatusService`](group-26-device-capability-layer.md#iconnectivitystatusservice) (`:30-31`); [`SessionDTO`](group-17-conference-domain.md#sessiondto), [`EventDTO`](group-17-conference-domain.md#eventdto), [`SpeakerInfo`](#speakerinfo), [`ConferenceReadAudience`](group-17-conference-domain.md#conferencereadaudience) (`:149`), [`CurrentEventDefaults`](group-17-conference-domain.md#currenteventdefaults) (`:193`), and the nested [`CachedSessionPage`](#cachedsessionpage) record (`:342`). +- **What it is**: the public conference schedule and the most heavily-wired page in this unit. It is the container half of a three-part page (this class, [`PublicSessionListFilterBar`](#publicsessionlistfilterbar), [`PublicSessionListView`](#publicsessionlistview)): it owns the events, room and speaker lookups, the event/room/search/My-Schedule filter state, the bookmark dictionary, the server-paged fetch, and the offline snapshot (`PublicSessionList.razor.cs:16-24`). +- **Depends on**: extends [`DataGridListPageBase`](group-15-common-ui-framework.md#datagridlistpagebasetdto) closed over [`SessionDTO`](group-17-conference-domain.md#sessiondto) (`:25`); [`ISessionUIService`](#isessionuiservice), [`IEventUIService`](#ieventuiservice), [`ISpeakerLookupService`](#ispeakerlookupservice) (`:29,32,33`), the optional [`ISessionBookmarkUIService`](group-22-engagement-module.md#isessionbookmarkuiservice) (`:38`), [`ILocalCacheStore`](group-26-device-capability-layer.md#ilocalcachestore) and [`IConnectivityStatusService`](group-26-device-capability-layer.md#iconnectivitystatusservice) (`:30-31`); [`EventDTO`](group-17-conference-domain.md#eventdto), [`RoomDTO`](group-17-conference-domain.md#roomdto), [`SpeakerInfo`](#speakerinfo) (`:54`), [`ConferenceReadAudience`](group-17-conference-domain.md#conferencereadaudience) (`:161`), [`CurrentEventDefaults`](group-17-conference-domain.md#currenteventdefaults) (`:210`), [`PublicScheduleRoomOptions`](#publicscheduleroomoptions) (`:195`), and the nested [`CachedSessionPage`](#cachedsessionpage) record (`:366`). - **Concept introduced, the container page with two racing loads and a dual-branch fetch.** Everything the sibling list pages do once, this page does twice and then adds a mode switch. - 1. **Two startup tasks, both awaited by the fetch path.** `OnInitializedAsync` (`:112-140`) starts `_bookmarkLoadTask` and `_eventsLoadTask` **before** its first `await`. The comments (`:122-131`) name the exact failure each guards: the `MudDataGrid`'s first `ServerData` call can run ahead of initialization, notably on in-app back-navigation where there is no SSR prerender to supply grid data, and a half-initialized `_isAuthenticated == false` would make the My Schedule branch silently fall through to fetching all sessions. `LoadServerData` (`:249-261`) awaits the events task and `FetchSessionsAsync` (`:268-281`) awaits the bookmark task. - 2. **Two fetch branches, both truly server-paged.** In My Schedule mode with bookmarks present, the page adds an `Id IN (...)` server filter built from the bookmark dictionary keys (`:296-305`) and lets the server page; the comment (`:293-295`) records that this replaced pulling a 500-row page and paging in memory, which also reported a wrong total past 500. An empty bookmark set short-circuits to `([], 0)` (`:288-291`). `[Rubric §12, Performance & Scalability]`. - 3. **Audience-scoped filter persistence.** As on [`PublicSpeakerList`](#publicspeakerlist), only privileged readers persist an event choice (`:73-85`), and `ResolveDefaultEventFilter` (`:180-195`) locks everyone else to the computed current/next event via [`CurrentEventDefaults`](group-17-conference-domain.md#currenteventdefaults); the comment (`:182-185`) states the security consequence: a shared privileged URL can never pin an attendee to a different or unpublished event. `[Rubric §11, Security]` and `[Rubric §26, Front-End Security]`. - 4. **A deep link that beats saved state.** `[SupplyParameterFromQuery(Name = "mine")]` (`:60-66`) carries the MAUI head's home-screen quick action into the My Schedule view, and `OnInitializedAsync` applies it *after* the base has restored saved page state so intent wins (`:116-120`). `[Rubric §25, Navigation & Information Architecture]` and [ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html) Wave 2. - 5. **The offline snapshot** taught at [`CachedSessionPage`](#cachedsessionpage) (`:314-336`). `[Rubric §29, Resilience & Business Continuity]`. + 1. **Two startup tasks, both awaited by the fetch path.** `OnInitializedAsync` (`:124-152`) starts `_bookmarkLoadTask` (`:140`) and `_eventsLoadTask` (`:144`) **before** its first `await`. The comments (`:134-143`) name the exact failure each guards: the `MudDataGrid`'s first `ServerData` call can run ahead of initialization, notably on in-app back-navigation where there is no SSR prerender to supply grid data, and a half-initialized `_isAuthenticated == false` would make the My Schedule branch silently fall through to fetching all sessions. `LoadServerData` (`:273-285`) awaits the events task and `FetchSessionsAsync` (`:292-361`) awaits the bookmark task. `[Rubric §19, State Management & Data Flow]`. + 2. **Two fetch branches, both truly server-paged.** In My Schedule mode with bookmarks present, the page adds an `Id IN (...)` server filter built from the bookmark dictionary keys (`:320-328`) and lets the server page; the comment (`:317-319`) records that this replaced pulling a 500-row page and paging in memory, which also reported a wrong total past 500. An empty bookmark set short-circuits to `([], 0)` (`:312-315`) rather than issuing a query that would return the whole catalog. `[Rubric §12, Performance & Scalability]`. + 3. **Audience-scoped filter persistence, and one filter that is safe for everyone.** Only privileged readers persist an event choice (`:83-89`), and `ResolveDefaultEventFilter` (`:197-212`) locks everyone else to the computed current or next event via [`CurrentEventDefaults.SelectCurrentOrNext`](group-17-conference-domain.md#currenteventdefaults); the comment (`:199-202`) states the security consequence, that a shared privileged URL can never pin an attendee to a different or unpublished event. The room filter, by contrast, is persisted for **every** audience, and the comment says why in one line (`:80`): a room only narrows within the reader's own event, so it cannot widen anything. `[Rubric §11, Security]` and `[Rubric §26, Front-End Security]`. + 4. **A deep link that beats saved state.** `[SupplyParameterFromQuery(Name = "mine")]` (`:67-68`) carries the MAUI head's home-screen quick action into the My Schedule view, and `OnInitializedAsync` applies it *after* the base has restored saved page state so intent wins (`:128-132`). `[Rubric §25, Navigation & Information Architecture]` and [ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html) Wave 2. + 5. **The offline snapshot** taught at [`CachedSessionPage`](#cachedsessionpage) (`:338-360`). `[Rubric §29, Resilience & Business Continuity]`. - **Walkthrough** - - `SaveFilters` / `RestoreFilters` (`:73-110`): persist search, the My Schedule toggle, and (privileged only) the event id with the `"all"` sentinel. - - `LoadEventsAndResolveDefaultAsync` (`:142-178`): resolves privileged status from role membership, fetches events with children and flattens their rooms into `_roomNames` (`:159-168`), loads the speaker lookup (`:170`), then resolves the default event. One children-loaded events fetch plus one speaker lookup replace per-row enrichment calls. `[Rubric §23, Front-End Performance & Rendering]`. - - `LoadBookmarkStateAsync` (`:202-226`): reads the `user_id` claim and loads the bookmarked session ids into the dictionary the view patches in place; a failure is non-critical (stars do not appear, sessions still load). - - `ApplyAdditionalFilters` (`:344-355`): `Title contains` and `EventId equals`; `FetchMobilePage` (`:358-366`) builds the same filters for the infinite-scroll list and reuses `FetchSessionsAsync`, so both layouts share one fetch implementation including its offline path. - - The optional Engagement service is resolved with `GetService` (`:114`) for the same reason as on [`PublicSessionDetail`](#publicsessiondetail): `[Inject]` has no optional mode. -- **Why it's built this way**: this is the highest-traffic page of the conference, viewed on bad networks by both anonymous browsers and signed-in attendees managing a personal schedule. That drives every design decision visible here: server-side everything, one enrichment fetch, ordering guarantees around the grid's eager first call, an audience-locked event filter, and a cached last-known-good first page. -- **Where it's used**: the `/conference/sessions` route (`PublicSessionList.razor:1`), including the `?mine=true` deep link; it renders [`PublicSessionListFilterBar`](#publicsessionlistfilterbar) and [`PublicSessionListView`](#publicsessionlistview) and routes onward to [`PublicSessionDetail`](#publicsessiondetail). - -### ConferenceCategoryCreate, QuestionCreate, RoomCreate -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.{ConferenceCategory,Question,Room}` · Level 5 · classes (Blazor code-behind) - -| Type | File:Line | Notes (what differs) | -|------|-----------|----------------------| -| `ConferenceCategoryCreate` | `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/ConferenceCategory/ConferenceCategoryCreate.razor.cs:9` | Three fields (title, sort, type). Posts `Id = default` (`:58`) and lets the server assign the key. | -| `QuestionCreate` | `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Question/QuestionCreate.razor.cs:9` | Adds the entity/type/required triple, defaulted to `"Session"` and `"Rating"` (`:33-34`). Mints a placeholder int id in a reserved high band (`:61`). | -| `RoomCreate` | `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Room/RoomCreate.razor.cs:9` | The only one with a prerequisite fetch: it loads the event lookup in `OnInitializedAsync` and auto-selects the event when exactly one exists (`:46-50`). Mints a placeholder int id (`:81`). | - -- **What they are**: the three narrow organizer create forms in this group. Each collects a handful of fields, posts one DTO through its UI service, and redirects to the detail page for the record it just made. -- **Depends on**: [`IConferenceCategoryUIService`](#iconferencecategoryuiservice) (`ConferenceCategoryCreate.razor.cs:11`), [`IQuestionUIService`](#iquestionuiservice) (`QuestionCreate.razor.cs:11`), [`IRoomUIService`](#iroomuiservice) plus [`IEventLookupService`](#ieventlookupservice) (`RoomCreate.razor.cs:11-12`); the matching DTOs [`ConferenceCategoryDTO`](group-17-conference-domain.md#conferencecategorydto), [`QuestionDTO`](group-17-conference-domain.md#questiondto), [`RoomDTO`](group-17-conference-domain.md#roomdto); [`EventInfo`](#eventinfo) (`RoomCreate.razor.cs:30`); [`ConferenceRoutePaths`](#conferenceroutepaths) and [`ErrorMessages`](group-15-common-ui-framework.md#errormessages); MudBlazor's `MudForm` and `ISnackbar`, plus `NavigationManager`. -- **Concept introduced, the create-page shape and its two safety rails.** [`SpeakerCreate`](#speakercreate) shows the same flow on the widest form; these three are the compact version, and together they make the shape easy to read. - 1. **Validate before you mutate.** Each `Create*Async` calls `await _form.ValidateAsync()` and returns with a warning snackbar when `!_form.IsValid`, before any service call (`ConferenceCategoryCreate.razor.cs:48-53`, `QuestionCreate.razor.cs:49-54`, `RoomCreate.razor.cs:69-74`). The server validates again; this pass exists to keep a round trip off the wire and to put the message next to the field. `[Rubric §24, Forms, Validation & UX Safety]` (assesses whether a form can submit itself into a predictable failure). - 2. **Dirty tracking that cannot block its own redirect.** Every editable control calls `MarkDirty()` (`ConferenceCategoryCreate.razor.cs:39`, `QuestionCreate.razor.cs:40`, `RoomCreate.razor.cs:33`) and the markup mounts the shared guard as `` (`ConferenceCategoryCreate.razor:8`, `QuestionCreate.razor:8`, `RoomCreate.razor:9`). The accessor is the load-bearing half: the guard prefers `IsDirtyAccessor?.Invoke()` over the parameter snapshot (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/UnsavedChangesGuard.razor:33-35`), and its own doc comment records why (`:28-32`), because clearing the flag and calling `NavigateTo` without an intervening `StateHasChanged()` would otherwise still prompt. The pages clear `_isDirty` on the success path **before** navigating (`ConferenceCategoryCreate.razor.cs:60`, `QuestionCreate.razor.cs:69`, `RoomCreate.razor.cs:91`). - There is a third rail these pages share with every other page in the group: a private `CancellationTokenSource` (`ConferenceCategoryCreate.razor.cs:15`) passed into the service call and cancelled in a full `Dispose(bool)` pattern (`:80-102`), with `OperationCanceledException` caught and ignored as the expected teardown outcome (`:64-67`). `[Rubric §23, Front-End Performance & Rendering]`: a form abandoned mid-post does not keep a response alive for a component that no longer exists. - Two of the three mint their own primary key client-side, because `Question` and `Room` are int-keyed and the POST contract carries the id: `QuestionCreate` uses `RandomNumberGenerator.GetInt32(999_999_000, 999_999_999)` (`:61`) and `RoomCreate` uses `RandomNumberGenerator.GetInt32(100_000, int.MaxValue)` (`:81`); `ConferenceCategoryCreate` sends `Id = default` (`:58`). All three then navigate using `created.Id` from the response (`ConferenceCategoryCreate.razor.cs:62`, `QuestionCreate.razor.cs:71`, `RoomCreate.razor.cs:93`), so a server-assigned key wins regardless. `[Rubric §8, Data Architecture]` (assesses a deliberate identity strategy): the identifier alias ([ADR-048](https://ivanball.github.io/docs/adr/048-primitive-identifier-type-aliases.html)) keeps the key type out of the page's own logic. -- **Walkthrough** (using `ConferenceCategoryCreate` as the reference) - - `OnInitialized` (`:20-29`) builds the Home / Categories / Create breadcrumb trail from the localized resource strings; `RoomCreate` does the same inside `OnInitializedAsync` and then loads the event lookup (`RoomCreate.razor.cs:37-59`), reporting a lookup failure with one snackbar rather than blocking the form (`:56-59`). - - `CreateCategoryAsync` (`:41-76`): null-guard the form, validate, set `IsSaving`, build the DTO (`:58`), post (`:59`), clear the dirty flag, snackbar, redirect to the detail route (`:62`); the `finally` always clears `IsSaving` (`:72-75`). - - The field block carries a comment worth reading (`:32-33`): the backing field is `_categoryTitle`, not `_title`, so it does not collide with the localized `Title` page property that SonarAnalyzer S4275 would flag. - - `NavigateToList` (`:78`) is the cancel action, and it routes through [`ConferenceRoutePaths`](#conferenceroutepaths) rather than a literal. `[Rubric §25, Navigation & Information Architecture]`: every route in the module is a named constant in one file. -- **Why they're built this way**: one create shape repeated per entity keeps the organizer's mental model constant (fill, validate, save, land on the new record) while each page varies only in the fields it collects and whether it needs a lookup first. -- **Where they're used**: the `/conferencecategories/create`, `/questions/create`, and `/rooms/create` routes, each carrying `[Authorize(Roles = "Organizer")]` on the page (`ConferenceCategoryCreate.razor:1-2`, `QuestionCreate.razor:1-2`, `RoomCreate.razor:1-2`). Each is reached from its list page's create button and redirects to [`ConferenceCategoryDetail`](#conferencecategorydetail), [`QuestionDetail`](#questiondetail), or [`RoomDetail`](#roomdetail). -- **Caveats / not-in-source**: whether the API honors or replaces a client-minted id is decided in the Conference service, not here; the pages read the id back from the response either way. - ---- + - `SaveFilters` / `RestoreFilters` (`:75-122`): persist search, the My Schedule toggle, the room id, and (privileged only) the event id with the `"all"` sentinel. A restored room the resolved event does not offer is dropped downstream, which the comment points at (`:111`). + - `LoadEventsAndResolveDefaultAsync` (`:154-191`): resolve privileged status from role membership with a failed read treated as non-privileged (`:156-167`), fetch events with children and flatten their rooms into `_roomNames` (`:171-180`), load the speaker lookup (`:182`), then resolve the default event (`:189`) and scope the room options (`:190`). One children-loaded events fetch plus one speaker lookup replace per-row enrichment calls. `[Rubric §23, Front-End Performance & Rendering]`. + - `RefreshRoomOptions` (`:194-195`) is a one-line delegation to [`PublicScheduleRoomOptions.Scope`](#publicscheduleroomoptions), destructuring straight into `_rooms` and `_selectedRoomId`; it runs after the initial load and again on every event-filter change (`:255`). + - `LoadBookmarkStateAsync` (`:219-243`): reads the `user_id` claim and loads the bookmarked session ids into the dictionary the view patches in place; a failure is non-critical, so the stars do not appear but sessions still load (`:239-242`). + - Filter handlers (`:246-269`) each update one field and call `ReloadViewAsync` (`:271`), which forwards to the view child's `ReloadAsync()` and no-ops when the child is not yet rendered. + - `ApplyAdditionalFilters` (`:368-386`): `Title contains`, `EventId equals`, and `RoomId equals`. The comment on the room branch (`:380-381`) is worth reading against [`PublicSpeakerList`](#publicspeakerlist): `Session.RoomId` is a real nullable column, so it rides the generic filter pipeline with no virtual-key interception in the controller, unlike the speaker page's `EventId`. + - `FetchMobilePage` (`:389-397`) builds the same filters for the infinite-scroll list and reuses `FetchSessionsAsync`, so both layouts share one fetch implementation including its offline path. + - The optional Engagement service is resolved with `GetService` (`:126`) for the same reason as on [`PublicSessionDetail`](#publicsessiondetail): `[Inject]` has no optional mode. +- **Why it's built this way**: this is the highest-traffic page of the conference, viewed on bad networks by both anonymous browsers and signed-in attendees managing a personal schedule. That drives every design decision visible here: server-side everything, one enrichment fetch, ordering guarantees around the grid's eager first call, an audience-locked event filter, a room filter derived from data already in hand, and a cached last-known-good first page. +- **Where it's used**: the `/conference/sessions` route (`PublicSessionList.razor:1`), including the `?mine=true` deep link; it renders [`PublicSessionListFilterBar`](#publicsessionlistfilterbar) (`PublicSessionList.razor:11-21`) and [`PublicSessionListView`](#publicsessionlistview) (`:31`) and routes onward to [`PublicSessionDetail`](#publicsessiondetail). ### EventCreate -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Event` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Event/EventCreate.razor.cs:13` · Level 5 · class (Blazor code-behind) - -- **What it is**: the organizer form that creates a conference. It collects the name, description, date range, time zone, Sessionize code, and the optional venue block (address, map URL, Wi-Fi, organizer contact, sponsorship packet URL), per its class doc (`EventCreate.razor.cs:9-12`). -- **Depends on**: [`IEventUIService`](#ieventuiservice) (`:15`), [`EventDTO`](group-17-conference-domain.md#eventdto), [`ConferenceRoutePaths`](#conferenceroutepaths) (`:31,95,111`), and [`ErrorMessages`](group-15-common-ui-framework.md#errormessages) (`:62`); MudBlazor and `NavigationManager`. -- **Concept**: the create shape taught above, with two additions specific to an event. - 1. **A second validation gate the form cannot express.** `MudForm` validates each field independently, so the page adds an explicit check that both ends of the date range are present before it builds the DTO (`:66-70`), with its own localized message. `[Rubric §24, Forms, Validation & UX Safety]` (assesses validation that spans fields, not just single inputs). - 2. **The page decides the initial lifecycle state.** The DTO is posted with `IsPublished = false` (`:89`), so a new event always starts private and becomes visible only through the explicit publish action on [`EventDetail`](#eventdetail). `[Rubric §11, Security]` and `[Rubric §26, Front-End Security]` (assess a safe default): an event cannot leak to the public browse pages because someone saved a draft. - The time zone field is seeded with `"America/New_York"` (`:42`), the IANA zone the conference actually runs in, and the date pickers hand back `DateTime?` which the page narrows to `DateOnly` with `DateOnly.FromDateTime(...)` (`:80-81`) to match the DTO's calendar-day shape. -- **Walkthrough**: `OnInitialized` (`:25-34`) builds the Home / Events / Create breadcrumbs; the field block (`:38-50`) is the widest in the group; `CreateEventAsync` (`:54-109`) validates the form (`:59-64`), enforces the date range (`:66-70`), composes the full `EventDTO` (`:75-90`), posts it (`:92`), clears `_isDirty` before navigating to `ConferenceRoutePaths.EventDetails(created.Id)` (`:93-95`), and always clears `IsSaving` in the `finally` (`:105-108`). Disposal (`:113-131`) is the standard cancel-on-disposal pattern over the `CancellationTokenSource` at `:19`. -- **Why it's built this way**: an event is the root of every other Conference record (rooms, sessions, sponsors and feedback all hang off it), so the form is deliberately complete on the first save and deliberately unpublished until an organizer says otherwise. -- **Where it's used**: the `/events/create` route with `[Authorize(Roles = "Organizer")]` (`EventCreate.razor:1-2`), reached from [`EventList`](#conferencecategorylist-eventlist-questionlist); it redirects to [`EventDetail`](#eventdetail). - ---- -### OrganizerEventFeedback, OrganizerSessionFeedback -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Feedback` · Level 5 · classes (Blazor code-behind) - -| Type | File:Line | Notes (what differs) | -|------|-----------|----------------------| -| `OrganizerEventFeedback` | `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Feedback/OrganizerEventFeedback.razor.cs:14` | Route parameter `EventId` (`:21`). Resolves the heading through [`IEventLookupService`](#ieventlookupservice) (`:49-58`) and filters questions on `QuestionEntity equals "Event"` (`:61-64`). | -| `OrganizerSessionFeedback` | `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Feedback/OrganizerSessionFeedback.razor.cs:14` | Route parameter `SessionId` (`:21`). Resolves the heading with a direct `GetByIdAsync(..., includeChildren: false, ...)` (`:49`) and filters on `QuestionEntity equals "Session"` (`:59-62`). | +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Event` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Event/EventCreate.razor.cs:13` · Level 5 · class (Blazor code-behind) -- **What they are**: the organizer's feedback readers. Each loads every answer for one event or one session, groups them under the question they answer, renders ratings as an average and free text verbatim, and offers per-answer deletion for moderation, BR-53 (`OrganizerEventFeedback.razor.cs:10-13`, `OrganizerSessionFeedback.razor.cs:10-13`). -- **Depends on**: [`IOrganizerEventFeedbackUIService`](#iorganizereventfeedbackuiservice) / [`IOrganizerSessionFeedbackUIService`](#iorganizersessionfeedbackuiservice) and [`IQuestionUIService`](#iquestionuiservice) (`OrganizerEventFeedback.razor.cs:16-17`, `OrganizerSessionFeedback.razor.cs:16-17`); [`IEventLookupService`](#ieventlookupservice) (`OrganizerEventFeedback.razor.cs:18`) and [`ISessionUIService`](#isessionuiservice) (`OrganizerSessionFeedback.razor.cs:18`); [`QuestionDTO`](group-17-conference-domain.md#questiondto) plus [`EventQuestionAnswerDTO`](group-17-conference-domain.md#eventquestionanswerdto) / [`SessionQuestionAnswerDTO`](group-17-conference-domain.md#sessionquestionanswerdto); [`DomainHelper`](group-02-domain-building-blocks.md#domainhelper)'s `Id.Parse` extension (`OrganizerEventFeedback.razor.cs:46`); [`ConferenceRoutePaths`](#conferenceroutepaths) (`:40`). -- **Concept introduced, the two-fetch join done in the page, and aggregation that lives in the markup.** Feedback is stored as answer rows that carry a `QuestionId` and a free-form `AnswerValue`; the question text and its type live on a separate record. Neither page asks the API for a joined shape. Each fetches the questions for its entity kind (one page, size 100, sorted by `Sort` ascending: `OrganizerEventFeedback.razor.cs:65-67`) and the answers (`:70`), then the markup pairs them with `_answers.Where(a => a.QuestionId == question.Id)` per question (`OrganizerEventFeedback.razor:39`). - The aggregation is markup-level too, and the branch on question type is the interesting part (`OrganizerEventFeedback.razor:55-84`): a `"Rating"` question parses each `AnswerValue` to an int, drops the unparsable ones, and renders the average as a read-only `MudRating` plus a one-decimal number (`:57-69`); every other type renders each answer verbatim with a delete button next to it (`:74-83`). So moderation is offered exactly where it is meaningful, on free text, and a numeric rating cannot be individually removed from the UI. `[Rubric §24, Forms, Validation & UX Safety]` (assesses that an action is offered only where it applies) and `[Rubric §30, Compliance, Privacy & Data Governance]` (assesses deliberate handling of user-submitted content): the answers are unattributed on screen, and the only operation offered is removal. - `[Rubric §12, Performance & Scalability]`: the question fetch is bounded at 100 rows by an explicit page size, but `GetAllAnswersAsync` is unbounded by design, since the page's whole purpose is the full response set for one entity. `[Rubric §21, Accessibility]`: the delete control carries an explicit `aria-label` (`OrganizerEventFeedback.razor:80`) because its icon carries no text. `[Rubric §27, Internationalization]`: every label, including the composite "N responses" and "average X" strings, resolves through the page's `IStringLocalizer` (`OrganizerEventFeedback.razor:34,46,67-68`). +- **What it is**: the organizer form that creates a conference event. It collects the name, description, + start and end dates, IANA time zone, Sessionize code, and the optional venue fields (address, map URL, + Wi-Fi, organizer contact, sponsorship packet, ticketing link), posts the record unpublished, and + redirects to the new event's detail page. It is the first page in this unit and the place where the + Conference create-form shape is taught. +- **Depends on**: [`IEventUIService`](#ieventuiservice) (injected at + `.../Pages/Event/EventCreate.razor.cs:15`), [`EventDTO`](group-17-conference-domain.md#eventdto), + [`ConferenceRoutePaths`](#conferenceroutepaths), and + [`ErrorMessages`](group-15-common-ui-framework.md#errormessages). Externals: Blazor (`[Inject]`, + `NavigationManager`, `OnInitialized`), MudBlazor (`MudForm`, `ISnackbar`, `BreadcrumbItem`, + `Icons.Material.Filled.Home`), the shared `UnsavedChangesGuard` component from `MMCA.Common.UI`, and + the `IStringLocalizer` the template injects as `L` + (`.../Pages/Event/EventCreate.razor:4`). +- **Concept introduced, the partial-class code-behind create form.** Every Conference page is a `.razor` + template plus a `.razor.cs` partial holding the injected services, backing fields, and handlers. The + create leg layers four recurring mechanisms on that split, and every other create page in this unit + repeats them: + 1. **Cancel-on-disposal**: a `CancellationTokenSource _cts` (line 19) is passed to every service call + and cancelled plus disposed through the standard `Dispose(bool)` pattern (lines 115-133), so an + in-flight save cannot resolve against a torn-down component. + 2. **Validate-then-submit**: `await _form.ValidateAsync()` followed by an `IsValid` guard (lines 60-65) + with a hand-written cross-field check for the date range (lines 67-71), while the `IsSaving` flag + (line 36) disables the button for the round trip and is always cleared in `finally` (lines 107-110). + 3. **An unsaved-changes guard**: `_isDirty` is set by `MarkDirty()` (line 53) and consumed by the + `UnsavedChangesGuard` component in the template (`.../Pages/Event/EventCreate.razor:8`); it is + cleared *before* the success redirect (line 95) so the guard does not block the page's own + navigation. + 4. **Two-tier failure handling**: `OperationCanceledException` is swallowed as expected during + disposal or an InteractiveAuto render-mode transition (lines 99-102), everything else snackbars a + localized error (lines 103-106). ADR-056 (`Website/docs-src/adr/056-blazor-render-mode-strategy.md`) + is the record behind that first catch. + `[Rubric §24, Forms, Validation & UX Safety]` (assesses client validation, unsaved-change protection, + and safe submits): this page validates before posting, tracks dirty state, and guards navigation away. + `[Rubric §18, UI Architecture & Component Design]` (assesses logic separated from markup): the + code-behind keeps the template declarative. `[Rubric §11, Security]` (assesses authorization at the + boundary the user actually reaches): the route is organizer-only via + `@attribute [Authorize(Roles = "Organizer")]` (`.../Pages/Event/EventCreate.razor:2`). + `[Rubric §27, Internationalization]` (assesses externalized user-facing text): every label, breadcrumb, + and snackbar reads through `L` (for example `L["Snackbar.Created"]`, line 96), per ADR-011 and ADR-027. - **Walkthrough** - - `OnInitializedAsync` (`OrganizerEventFeedback.razor.cs:35-84`): build the breadcrumbs, parse the route id (`:46`), resolve the display name and fail into `_loadError` when the entity is unknown (`:50-58`), load the questions, then the answers. Any other exception collapses to one `_loadError` string (`:76-79`) and the `finally` clears `IsLoading` (`:80-83`). - - `DeleteAnswerAsync` (`:86-102`): delete the answer, then refetch the whole answer set rather than patching the local list (`:90-91`), so the counts and averages the markup computes can never drift from the server. - - The load states are rendered by the shared `PageLoadingState` and `PageErrorState` components (`OrganizerEventFeedback.razor:13-20`), so a failure is an inline panel rather than a blank page. `[Rubric §29, Resilience & Business Continuity]`. - - The two files are otherwise byte-identical in markup apart from the route, the heading (the session page makes its title a link back to the session, `OrganizerSessionFeedback.razor:23`), and the back button target. -- **Why they're built this way**: an organizer reading feedback wants one page per subject with the numbers already summarized, and the summarizing is cheap over a single event's answers. Refetching after a delete keeps that arithmetic honest for the price of one extra call on a rare action. -- **Where they're used**: the `/events/{EventId}/feedback` and `/sessions/{SessionId}/feedback` routes, both `[Authorize(Roles = "Organizer")]` (`OrganizerEventFeedback.razor:1-2`, `OrganizerSessionFeedback.razor:1-2`), reached from [`EventDetail`](#eventdetail) and [`SessionDetail`](#sessiondetail); the attendee-facing sides of the same data are the public feedback forms. -- **Caveats / not-in-source**: the answers endpoints are scoped to organizers server-side; these pages assume that scoping and only enforce the role on the route. - ---- - -### ConferenceCategoryDetail -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.ConferenceCategory` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/ConferenceCategory/ConferenceCategoryDetail.razor.cs:11` · Level 7 · class (Blazor code-behind) - -- **What it is**: the organizer's category console. It loads one [`ConferenceCategoryDTO`](group-17-conference-domain.md#conferencecategorydto) with its children, inline-edits the category itself, and runs a full add / edit / delete loop over its [`CategoryItemDTO`](group-17-conference-domain.md#categoryitemdto) rows on the same page. -- **Depends on**: [`IConferenceCategoryUIService`](#iconferencecategoryuiservice) and [`ICategoryItemUIService`](#icategoryitemuiservice) (`:15-16`), [`ConferenceCategoryDTO`](group-17-conference-domain.md#conferencecategorydto) and [`CategoryItemDTO`](group-17-conference-domain.md#categoryitemdto), the `CategoryItem` identifier alias (`:60`), [`DomainHelper`](group-02-domain-building-blocks.md#domainhelper)'s `Id.Parse` (`:76`), [`ConferenceRoutePaths`](#conferenceroutepaths) (`:33,302`), [`ErrorMessages`](group-15-common-ui-framework.md#errormessages) (`:80,89,127,147,180`), and the shared `DeleteConfirmation` component twice over (`:49,59`). -- **Concept introduced, the parent-with-children editor, and shadow fields as the edit buffer.** Two mechanisms carry this page. - 1. **Shadow fields.** Entering edit mode copies the live record into `_edit*` fields (`StartEditing`, `:97-109`) and cancelling simply drops them (`CancelEditing`, `:111-115`). The loaded `Category` is never mutated, so an abandoned edit leaves nothing behind and the rendered values stay exactly what the server last returned. The item editor repeats the same idea with `_editingItemId` / `_editItemName` / `_editItemSort` (`:60-62`, seeded at `:232-238`). `[Rubric §19, State Management & Data Flow]` (assesses where mutable state lives and how long it lives). - 2. **Refetch, do not patch.** Every mutation is followed by `Category = await CategoryService.GetByIdAsync(Category.Id, true, _cts.Token)` (`:136`, `:214`, `:260`, `:289`). The page never edits its local child collection: the server's answer is the only rendering source. That costs one extra read per action and removes an entire class of drift between what was saved and what is shown. `[Rubric §19, State Management & Data Flow]` and `[Rubric §8, Data Architecture]`. - The save path also round-trips the concurrency token: the updated DTO carries `RowVersion = Category.RowVersion` (`:134`), which is the client half of the optimistic-concurrency contract in [ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html). `[Rubric §8, Data Architecture]` (assesses how concurrent writes are reconciled): a stale editor loses the write instead of silently overwriting a newer one. + - `OnInitialized` (lines 25-34) builds the three-item breadcrumb trail: Home, the events list, and a + disabled "Create" leaf (lines 28-33). + - Backing fields (lines 38-49) hold the form values, with `_timeZone` seeded to `"America/New_York"` + (line 42), the conference's home zone, so the common case needs no edit. + - `CreateEventAsync` (lines 55-111) validates, converts the two `DateTime?` pickers to `DateOnly` + (lines 81-82), builds the [`EventDTO`](group-17-conference-domain.md#eventdto) with **`Id = default`** + (line 78) and `IsPublished = false` (line 91), posts with `AddAsync` (line 94), clears `_isDirty`, + snackbars success, and navigates to `ConferenceRoutePaths.EventDetails(created.Id)` (line 97) using + the id the server returned. + - `NavigateToList` (line 113) is the cancel path back to `/events`. +- **Why it's built this way**: a new event starts unpublished so an organizer can fill in venue details + and refresh from Sessionize before anything is publicly visible; publishing is a separate deliberate + action on [`EventDetail`](#eventdetail). Posting `Id = default` hands identifier assignment to the + server rather than the browser, which is the opposite choice from the sibling create pages below. +- **Where it's used**: the `/events/create` route (`.../Pages/Event/EventCreate.razor:1`), reached from + [`EventList`](#eventlist)'s create button; on success it hands off to [`EventDetail`](#eventdetail). + +### OrganizerEventFeedback + +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Feedback` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Feedback/OrganizerEventFeedback.razor.cs:14` · Level 5 · class (Blazor code-behind) + +- **What it is**: the organizer's read-and-moderate view of event feedback. It loads every answer + submitted for one event, groups the answers under their question, averages the rating questions, and + lets the organizer delete an individual free-text answer. +- **Depends on**: [`IOrganizerEventFeedbackUIService`](#iorganizereventfeedbackuiservice) (line 16), + [`IQuestionUIService`](#iquestionuiservice) (line 17), and + [`IEventLookupService`](#ieventlookupservice) (line 18) returning [`EventInfo`](#eventinfo); the + [`QuestionDTO`](group-17-conference-domain.md#questiondto) and + [`EventQuestionAnswerDTO`](group-17-conference-domain.md#eventquestionanswerdto) shapes; + [`ConferenceRoutePaths`](#conferenceroutepaths); and + [`DomainHelper`](group-02-domain-building-blocks.md#domainhelper)'s `Parse` string extension + (`MMCA.Common.Shared.Extensions`, line 5). Externals: Blazor `[Parameter]`, MudBlazor (`ISnackbar`, + `MudRating`, `MudCard`), and the `PageLoadingState` / `PageErrorState` components from + `MMCA.Common.UI`. +- **Concept introduced, the inline page-level error state.** Unlike the create and detail pages, which + snackbar their failures, this page keeps a `_loadError` string (line 29) and renders `PageErrorState` + instead of the body when the load failed (`.../Pages/Feedback/OrganizerEventFeedback.razor:17-20`). The + distinction is deliberate: a snackbar expires, and a feedback page that silently shows zero responses + after a failed fetch reads as "nobody answered". A missing event sets the same field with + `L["Error.EventNotFound"]` (line 56) rather than a generic message. + `[Rubric §19, State Management & Data Flow]` (assesses where view state lives and how failure is + represented): loading, error, empty, and populated are four distinct rendered states driven by + `IsLoading` (line 27), `_loadError`, and the two collections. + `[Rubric §30, Compliance, Privacy & Data Governance]` (assesses control over user-submitted content): + answer deletion is the organizer's moderation lever over free-text feedback (BR-53 in the type's own + doc comment, lines 10-13). + `[Rubric §23, Front-End Performance & Rendering]`: aggregation happens client-side over one bulk + answer fetch (line 70) rather than per-question round trips. - **Walkthrough** - - `OnParametersSetAsync` (`:64-95`): the load-once-on-parameters guard compares the route `Id` against `_loadedId` (`:66-71`) so a re-render does not refetch, parses the id to `ConferenceCategoryIdentifierType` (`:76`), fetches with children (`:77`), and reports a null result as a not-found snackbar through [`ErrorMessages`](group-15-common-ui-framework.md#errormessages) (`:80`). - - Category edit (`:97-153`): `StartEditing` / `CancelEditing` as above, then `SaveChangesAsync` (`:117-153`) validates the `MudForm` first (`:124-129`), rebuilds the DTO with the round-tripped `RowVersion` (`:134`), updates, refetches, and clears both `_isDirty` and `_isEditing` on success (`:138-139`). - - Category delete (`:155-182`): confirm through the shared `DeleteConfirmation` dialog seeded with the category title (`:162`), delete, then navigate back to the list (`:172`). - - Item CRUD (`:184-300`): `StartAddingItem` resets the new-item fields and closes any open row edit (`:185-191`); `AddItemAsync` (`:195-230`) validates its own separate `MudForm` (`:202-207`), posts a `CategoryItemDTO` stamped with the parent `CategoryId` (`:212`), and refetches. `UpdateItemAsync` (`:242-276`) is the one path that does **not** use a `MudForm`: it hand-checks `string.IsNullOrWhiteSpace(_editItemName)` and warns (`:249-253`), because the row editor is inline in the table rather than a form. `DeleteItemAsync` (`:278-300`) confirms through the second dialog instance (`:280`) and refetches. - - Disposal (`:304-326`) is the standard cancel-on-disposal pattern over the `CancellationTokenSource` at `:22`; the markup mounts the unsaved-changes guard (`ConferenceCategoryDetail.razor:9`). -- **Why it's built this way**: a category is only meaningful together with its items (a topic list, a locality list), so editing them on two routes would be worse than a slightly larger page. Refetching after every mutation is the cheap way to keep a composite view coherent without a client-side store. -- **Where it's used**: the `/conferencecategories/{Id}` route with `[Authorize(Roles = "Organizer")]` (`ConferenceCategoryDetail.razor:1-2`), reached from [`ConferenceCategoryList`](#conferencecategorylist-eventlist-questionlist) rows and [`ConferenceCategoryCreate`](#conferencecategorycreate-questioncreate-roomcreate) redirects. The items it authors are what [`CategoryItemLookupService`](#categoryitemlookupservice) resolves for the session and speaker pages. - ---- - -### ConferenceCategoryList, EventList, QuestionList -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.{ConferenceCategory,Event,Question}` · Level 7 · classes (Blazor code-behind) - -| Type | File:Line | Notes (what differs) | -|------|-----------|----------------------| -| `ConferenceCategoryList` | `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/ConferenceCategory/ConferenceCategoryList.razor.cs:11` | Searches `Title`; the only one that fetches with `includeChildren: true` (`:47,60`), because both layouts render `CategoryItems.Count` (`ConferenceCategoryList.razor:37,92`). | -| `EventList` | `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Event/EventList.razor.cs:16` | Searches `Name`. Routes through a named `NavigateToDetails(EventIdentifierType)` helper (`:84-85`) shared by the grid rows and the mobile cards. | -| `QuestionList` | `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Question/QuestionList.razor.cs:11` | Searches `QuestionText`, and uses the same field as the delete-confirmation label (`:70`). | - -- **What they are**: the three organizer browse pages with a single search box and no other filter. Each is a thin binding over the shared list-page base. -- **Depends on**: all three extend [`DataGridListPageBase`](group-15-common-ui-framework.md#datagridlistpagebasetdto) (`ConferenceCategoryList.razor.cs:11`, `EventList.razor.cs:16`, `QuestionList.razor.cs:11`); their UI services [`IConferenceCategoryUIService`](#iconferencecategoryuiservice), [`IEventUIService`](#ieventuiservice), [`IQuestionUIService`](#iquestionuiservice); [`MobileInfiniteScrollList`](group-15-common-ui-framework.md#mobileinfinitescrolllisttitem) (`ConferenceCategoryList.razor.cs:24`), [`ListPageActions`](group-24-identity-module.md#listpageactions) (`:35,68`), [`ConferenceRoutePaths`](#conferenceroutepaths), [`ErrorMessages`](group-15-common-ui-framework.md#errormessages) (`:74`), and the shared `DeleteConfirmation` component (`:25`). -- **Concept introduced, the list page as a set of overrides.** The base owns the machinery: `IsLoading`, `LoadFailed`, the abstract `Title`, the `IsMobile` switch, the filter save/restore contract, and `LoadServerDataAsync` (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:31,40,41,44,108,111,121,434`). Each page supplies five things and nothing else. - - The captured grid reference, through the `GridRef` override (`ConferenceCategoryList.razor.cs:19-20`), which is how the base restores rows-per-page and the current page after a back-navigation. - - `SaveFilters` / `RestoreFilters` for the one search term (`:28-32`). `[Rubric §25, Navigation & Information Architecture]`: a reader who opens a record and comes back finds the same view, not a reset grid. - - `LoadServerData`, which hands the base a fetch delegate and a filter builder that turns the search string into a server-side `contains` filter (`:43-52`). `[Rubric §12, Performance & Scalability]` and `[Rubric §23, Front-End Performance & Rendering]`: search, sort and paging all execute where the data is, so the client never materializes a whole table. - - `FetchMobilePage`, the parallel path for the infinite-scroll card list, hard-sorted by the display column ascending (`:55-61`). `[Rubric §22, Responsive & Cross-Browser]` (assesses a genuine mobile layout rather than a shrunk grid): the same service call backs both branches, selected by the base's `IsMobile`. - - `RetryLoadAsync` (`:23`), which re-runs the fetch from the inline error state the base renders when `LoadFailed` is set. `[Rubric §29, Resilience & Business Continuity]`: a failed load offers a retry instead of a dead grid. - Deletion is also shared, not reimplemented: `ListPageActions.DeleteWithConfirmationAsync` takes the dialog, the label to show, the delete call, the snackbar, the success text, an error formatter, and the reload callback (`ConferenceCategoryList.razor.cs:67-75`). `[Rubric §1, SOLID]` and `[Rubric §16, Maintainability]` (assess whether repeated behavior has one implementation): confirm, delete, toast, reload lives in one helper for every list page in the app. -- **Walkthrough** (using `ConferenceCategoryList` as the reference): `OnSearchChanged` (`:37-41`) stores the term and calls `ReloadActiveLayoutAsync` (`:34-35`), which asks [`ListPageActions`](group-24-identity-module.md#listpageactions) to reload whichever of the two layouts is live; `LoadServerData` (`:43-52`) and `FetchMobilePage` (`:55-61`) apply the same `contains` filter to the desktop and mobile paths; `OnMobileCardClick` (`:63-64`) and `NavigateToCreate` (`:77`) route through [`ConferenceRoutePaths`](#conferenceroutepaths). -- **Why they're built this way**: three near-identical browse surfaces are exactly the case a base class is for. Because each page is only its overrides, a change to paging, scroll restoration or the mobile switch lands in one place and every list inherits it. -- **Where they're used**: the `/conferencecategories`, `/events`, and `/questions` routes, each `[Authorize(Roles = "Organizer")]` (`ConferenceCategoryList.razor:1-2`, `EventList.razor:1-2`, `QuestionList.razor:1-2`). Rows and cards navigate to the matching detail page; the create buttons open the matching create page. [`PublicEventList`](#publiceventlist) is the anonymous counterpart of `EventList` over the same service. - ---- - -### EventDetail -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Event` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Event/EventDetail.razor.cs:15` · Level 8 · class (Blazor code-behind) - -- **What it is**: the organizer's event console. Beyond load, inline edit and delete it owns the two operations that make an event more than a record: the publish/unpublish lifecycle switch and the Sessionize import (`EventDetail.razor.cs:11-14`). -- **Depends on**: [`IEventUIService`](#ieventuiservice) (`:19`), [`EventDTO`](group-17-conference-domain.md#eventdto), [`RefreshFromSessionizeResultDTO`](group-17-conference-domain.md#refreshfromsessionizeresultdto) (`:68`), [`QuestionModerationDefault`](group-17-conference-domain.md#questionmoderationdefault) (`:60`), [`DomainHelper`](group-02-domain-building-blocks.md#domainhelper)'s `Id.Parse` (`:91`), [`ConferenceRoutePaths`](#conferenceroutepaths) (`:37,348`), [`ErrorMessages`](group-15-common-ui-framework.md#errormessages) (`:95,108,200,344`), and the shared `DeleteConfirmation` component (`:72`). -- **Concept introduced, the state transition as its own endpoint, carrying the concurrency token.** Publishing is not modelled as an edit of a boolean. `PublishAsync` and `UnpublishAsync` call dedicated service operations that take the id and the row version, `PublishAsync(Event.Id, Event.RowVersion, _cts.Token)` (`:218`, contract at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/IEventUIService.cs:12-14`), and the edit path deliberately preserves the current flag instead of exposing it as a field (`IsPublished = Event.IsPublished`, `:183`). `[Rubric §6, CQRS & Event-Driven]` (assesses whether intent is expressed as a named operation rather than a field write): "publish this event" and "correct the venue address" are different commands with different authorization and different consequences. `[Rubric §9, API & Contract Design]`: the transition endpoint takes exactly the two things it needs, and the row version makes it safe to replay ([ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html), which the [`EventTransitionRequest`](group-17-conference-domain.md#eventtransitionrequest) body documents on the server side). - The second idea is **the long-running import with a structured result**. `RefreshFromSessionizeAsync` (`:264-317`) is the only action in the group that reports on what it did rather than just succeeding: it returns a [`RefreshFromSessionizeResultDTO`](group-17-conference-domain.md#refreshfromsessionizeresultdto) with six per-entity counts, a count of soft-deleted records it skipped (BR-136), and a list of non-fatal warnings (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/RefreshFromSessionizeResultDTO.cs:9-31`), all of which the markup renders after the call (`EventDetail.razor:210-224`). `[Rubric §13, Observability & Operability]` (assesses whether an operator can see what an operation actually did): an import that quietly succeeds is indistinguishable from one that skipped half its input, so the page shows the counts. `[Rubric §29, Resilience & Business Continuity]`: warnings are non-fatal by design, so a duration violation on one session does not abort the import. + - The route id arrives as `[Parameter] public string EventId` (line 21) and is converted to the typed + alias with `EventId.Parse()` (line 46), so the page compiles unchanged whichever + primitive the alias maps to (ADR-048, revisited in ADR-085). + - `OnInitializedAsync` (lines 35-84) builds breadcrumbs (lines 37-42), resolves the event name from the + lookup and bails with `Error.EventNotFound` when the id is unknown (lines 49-58), fetches the + event-scoped questions with the server filter `QuestionEntity equals "Event"` sorted by `Sort` + (page 1, size 100, lines 61-66), then loads **all** answers for the event (line 70). The `finally` + always clears `IsLoading` (lines 80-83). + - Rendering (`.../Pages/Feedback/OrganizerEventFeedback.razor:37-84`) pairs each question with + `_answers.Where(a => a.QuestionId == question.Id)` (line 37). A question whose `QuestionType` is + `"Rating"` (case-insensitive, line 55) parses the answer values to integers, drops the unparseable + ones, and renders a read-only `MudRating` at the rounded average plus the average to one decimal and + the ratings count (lines 57-69). Anything else renders each answer as pre-wrapped text with a delete + icon button carrying an `aria-label` (lines 73-83). `[Rubric §21, Accessibility]`. + - `DeleteAnswerAsync` (lines 86-102) deletes one answer, **refetches the whole answer set** (line 91), + and snackbars the outcome, so the page never hand-patches its local collection. +- **Why it's built this way**: the organizer needs one screen that answers "what did attendees say about + this event", and the aggregate/free-text split follows from the question model itself: ratings are only + meaningful in aggregate, free text is only meaningful individually (and is the only thing that can need + moderating). +- **Where it's used**: the `/events/{EventId}/feedback` route + (`.../Pages/Feedback/OrganizerEventFeedback.razor:1-2`, organizer-only), reached from the "view + feedback" button on [`EventDetail`](#eventdetail) (`.../Pages/Event/EventDetail.razor:176`). The + attendee-facing counterpart lives in the Engagement module + ([`EventFeedback`](group-22-engagement-module.md#eventfeedback)). +- **Caveats / not-in-source**: the questions fetch takes a single 100-row page (line 66); behavior beyond + 100 event questions is not handled in this file. + +### OrganizerSessionFeedback + +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Feedback` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Feedback/OrganizerSessionFeedback.razor.cs:14` · Level 5 · class (Blazor code-behind) + +- **What it is**: the session-scoped twin of [`OrganizerEventFeedback`](#organizereventfeedback). Same + load-group-aggregate-moderate flow, one level down the hierarchy: answers for a single session instead + of a whole event. +- **Depends on**: [`IOrganizerSessionFeedbackUIService`](#iorganizersessionfeedbackuiservice) (line 16), + [`IQuestionUIService`](#iquestionuiservice) (line 17), and [`ISessionUIService`](#isessionuiservice) + (line 18) in place of the event lookup; the + [`SessionQuestionAnswerDTO`](group-17-conference-domain.md#sessionquestionanswerdto) and + [`QuestionDTO`](group-17-conference-domain.md#questiondto) shapes; + [`ConferenceRoutePaths`](#conferenceroutepaths); and the same `Parse` extension (line 5). +- **Concept introduced**: none new. The page-level error state, the rating-versus-text rendering split, + and the refetch-after-delete rule are the ones taught in + [`OrganizerEventFeedback`](#organizereventfeedback). +- **Walkthrough** (only the differences from its twin): + - The route parameter is `[Parameter] public string SessionId` (line 21), parsed to + `SessionIdentifierType` (line 46). + - The title comes from the session itself rather than a lookup dictionary: + `SessionService.GetByIdAsync(_parsedSessionId, false, ...)` with `includeChildren: false` (line 49), + since only `session.Title` is needed (line 56); a null result sets `L["Error.SessionNotFound"]` + (line 52). + - The question filter is `QuestionEntity equals "Session"` (lines 59-62), the other half of the same + question table that the event page filters on `"Event"`. + - `DeleteAnswerAsync` (lines 84-100) takes a `SessionQuestionAnswerIdentifierType` and passes the + parsed session id alongside it (line 88). + - The template links the session title back to the detail page + (`.../Pages/Feedback/OrganizerSessionFeedback.razor:23`) and ends with a back button to the same + route (line 92), where the event page instead links back to the event detail. +- **Why it's built this way**: session and event feedback are two instances of one questionnaire model, + so the two pages stay structurally identical rather than sharing a parameterized component; the cost is + duplication, the benefit is that each page's queries and route contract read literally. +- **Where it's used**: the `/sessions/{SessionId}/feedback` route + (`.../Pages/Feedback/OrganizerSessionFeedback.razor:1-2`), reached from the "view feedback" button on + [`SessionDetail`](#sessiondetail) (`.../Pages/Session/SessionDetail.razor:190`). The attendee-facing + counterpart is [`SessionFeedback`](group-22-engagement-module.md#sessionfeedback). + +### QuestionCreate + +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Question` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Question/QuestionCreate.razor.cs:9` · Level 5 · class (Blazor code-behind) + +- **What it is**: the organizer form that defines a feedback question: its text, which entity it attaches + to (`Event` or `Session`), its type (for example `Rating`), its sort order, and whether an answer is + required. +- **Depends on**: [`IQuestionUIService`](#iquestionuiservice) (line 11), + [`QuestionDTO`](group-17-conference-domain.md#questiondto), + [`ConferenceRoutePaths`](#conferenceroutepaths), and + [`ErrorMessages`](group-15-common-ui-framework.md#errormessages). Externals: MudBlazor (`MudForm`, + `ISnackbar`), `System.Security.Cryptography.RandomNumberGenerator`, and the + `IStringLocalizer` injected as `L`. +- **Concept introduced, the client-minted identifier.** `QuestionDTO.Id` is a `required` non-nullable + alias over `int`, so the form has to put *something* there. This page fabricates a value with + `RandomNumberGenerator.GetInt32(999_999_000, 999_999_999)` (line 61), which is the reserved + user-created question range recorded on the domain side as `QuestionInvariants.ManualIdRangeStart` and + `ManualIdRangeEnd` + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:37,40`), + the band that sits above every Sessionize-assigned id. The server does not trust it either way: + `CreateQuestionHandler` always allocates the next free id in that range and overwrites the request + ("Caller-provided IDs are ignored", + `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Questions/UseCases/Create/CreateQuestionHandler.cs:71-87`). + The page is written to tolerate that: it navigates using `created.Id` from the response (line 71), not + the value it sent. Compare [`EventCreate`](#eventcreate), which posts `Id = default` instead, and + [`RoomCreate`](#roomcreate) / [`SessionCreate`](#sessioncreate), whose minted ids the server *does* + respect. + `[Rubric §8, Data Architecture]` (assesses who owns identifier assignment): identity here is + server-owned inside a reserved range, because the same table also holds rows imported from Sessionize + under externally assigned ids. + `[Rubric §24, Forms, Validation & UX Safety]`: the validate-then-submit, dirty-guard, and + cancel-on-disposal mechanics are the ones [`EventCreate`](#eventcreate) introduces. - **Walkthrough** - - `OnParametersSetAsync` (`:74-84`) is the load-once-on-parameters guard, delegating to `LoadEventAsync` (`:86-114`), which parses the id (`:91`), fetches with children (`:92`), snackbars a not-found (`:95`), and copies the stored Sessionize code into the editable `_sessionizeCode` field (`:99`). - - Inline edit (`:116-206`): `StartEditing` seeds twelve shadow fields including the question-moderation default (`:123-136`), `CancelEditing` drops them (`:139-143`), and `SaveChangesAsync` validates the form, re-checks the date range (`:159-163`) exactly as [`EventCreate`](#eventcreate) does, rebuilds the DTO with the round-tripped `RowVersion` (`:171`), updates, refetches, and resyncs `_sessionizeCode` from the refetched record (`:189`). - - `PublishAsync` / `UnpublishAsync` (`:208-262`): identical shape, each calling its own endpoint and then refetching so the rendered state comes from the server rather than from an assumption. The markup swaps the two buttons on `Event.IsPublished` (`EventDetail.razor:149-163`). - - `RefreshFromSessionizeAsync` (`:264-317`): guards on a blank code (`:266-269`), clears the previous result (`:272`), and, when the code in the box differs from the stored one, saves the event first with an ordinal comparison (`:276-298`) so the import runs against the code the organizer just typed. Then it imports (`:300`), refetches the event (`:301`), and reports. - - `DeleteEventAsync` (`:319-346`) confirms through the shared dialog and returns to the list; disposal (`:350-372`) is the standard cancel-on-disposal pattern over the `CancellationTokenSource` at `:25`. -- **Why it's built this way**: the ADC schedule is authored in Sessionize and mirrored here, so the console has to make the import auditable and has to keep publication a deliberate, separately-authorized act rather than a checkbox in a form full of venue text. -- **Where it's used**: the `/events/{Id}` route with `[Authorize(Roles = "Organizer")]` (`EventDetail.razor:1-2`), reached from [`EventList`](#conferencecategorylist-eventlist-questionlist) rows and [`EventCreate`](#eventcreate) redirects. Publishing is what makes the event visible to [`PublicEventList`](#publiceventlist) and [`PublicEventDetail`](#publiceventdetail); the feedback link opens [`OrganizerEventFeedback`](#organizereventfeedback-organizersessionfeedback). -- **Caveats / not-in-source**: what the import creates, updates or skips is decided by the Conference service; the page only displays the counts it is handed. - ---- - -### QuestionDetail -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Question` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Question/QuestionDetail.razor.cs:11` · Level 8 · class (Blazor code-behind) - -- **What it is**: the organizer's editor for one feedback question: its text, its sort order, and whether an answer is required. -- **Depends on**: [`IQuestionUIService`](#iquestionuiservice) (`:15`), [`QuestionDTO`](group-17-conference-domain.md#questiondto), [`DomainHelper`](group-02-domain-building-blocks.md#domainhelper)'s `Id.Parse` (`:63`), [`ConferenceRoutePaths`](#conferenceroutepaths) (`:32,180`), [`ErrorMessages`](group-15-common-ui-framework.md#errormessages) (`:67,76,114,143,176`), and the shared `DeleteConfirmation` component (`:48`). -- **Concept**: the detail-page shape taught on [`ConferenceCategoryDetail`](#conferencecategorydetail) (load-once-on-parameters, shadow fields, validate before save, refetch after save, confirm before delete), in its smallest form. The detail worth naming here is **which fields the editor deliberately does not offer**. Only three shadow fields exist (`:43-45`), and the save path copies `QuestionEntity` and `QuestionType` straight off the loaded record (`:126-127`). A question's entity kind ("Event" or "Session") is what the feedback pages filter on (`OrganizerEventFeedback.razor.cs:61-64`) and its type is what decides whether answers are averaged or listed (`OrganizerEventFeedback.razor:55`), so changing either after answers exist would silently reinterpret data already collected. Fixing them at creation and preserving them on update is the guard. `[Rubric §4, DDD]` and `[Rubric §8, Data Architecture]` (assess whether the model protects an invariant that spans records rather than trusting the editor). - The update also round-trips `RowVersion` (`:124`), the client half of [ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html). Note the load here uses `GetByIdAsync(id, cancellationToken: _cts.Token)` (`:64`), leaving `includeChildren` at its default of `false` ([`IEntityService`](group-15-common-ui-framework.md#ientityservicetentitydto-tidentifiertype)`.GetByIdAsync`, `MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IEntityService.cs:38-41`), because a question has no child collection this page renders. -- **Walkthrough**: `OnInitialized` (`:26-35`) builds the Home / Questions / Details breadcrumbs; `OnParametersSetAsync` (`:52-82`) guards on `_loadedId`, parses, fetches, and snackbars a not-found (`:67`); `StartEditing` / `CancelEditing` (`:84-102`) seed and drop the three shadow fields; `SaveChangesAsync` (`:104-149`) validates the `MudForm` (`:111-116`), rebuilds the DTO preserving entity, type and row version (`:121-130`), updates, refetches (`:132`), and clears the edit flags; `DeleteQuestionAsync` (`:151-178`) confirms with the question text as the label (`:158`) and navigates back on success; disposal (`:182-204`) is the standard pattern over the `CancellationTokenSource` at `:21`. -- **Why it's built this way**: questions are configuration that answers point at, so the editor is intentionally narrow: presentation attributes are editable, the two fields that give existing answers their meaning are not. -- **Where it's used**: the `/questions/{Id}` route with `[Authorize(Roles = "Organizer")]` (`QuestionDetail.razor:1-2`), reached from [`QuestionList`](#conferencecategorylist-eventlist-questionlist) rows and [`QuestionCreate`](#conferencecategorycreate-questioncreate-roomcreate) redirects. The records it edits drive both feedback readers, [`OrganizerEventFeedback` and `OrganizerSessionFeedback`](#organizereventfeedback-organizersessionfeedback). - ---- - -### RoomDetail -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Room` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Room/RoomDetail.razor.cs:12` · Level 8 · class (Blazor code-behind) - -- **What it is**: the organizer's editor for one room: name, sort order, capacity, floor, location, and the free-text accessibility note that the public session page surfaces as wayfinding. -- **Depends on**: [`IRoomUIService`](#iroomuiservice) and [`IEventLookupService`](#ieventlookupservice) (`:16-17`), [`RoomDTO`](group-17-conference-domain.md#roomdto), [`EventInfo`](#eventinfo) (`:54`), [`DomainHelper`](group-02-domain-building-blocks.md#domainhelper)'s `Id.Parse` (`:69`), [`ConferenceRoutePaths`](#conferenceroutepaths) (`:34,196`), [`ErrorMessages`](group-15-common-ui-framework.md#errormessages) (`:73,85,129,159,192`), and the shared `DeleteConfirmation` component (`:53`). -- **Concept**: the same detail shape as [`QuestionDetail`](#questiondetail), with three points of its own. - 1. **The parent event is displayed, never edited.** The page hydrates the event lookup once with `??=` after a successful load (`:77`) purely so `GetEventName` can turn the foreign key into a name, falling back to the invariant-culture id when the lookup has no entry (`:93-94`, rendered at `RoomDetail.razor:68`). The save path copies `EventId = Room.EventId` (`:140`): a room cannot be moved between events from here. `[Rubric §4, DDD]` (assesses that a child stays inside its aggregate boundary). - 2. **No concurrency token.** Unlike every other DTO edited in this group, [`RoomDTO`](group-17-conference-domain.md#roomdto) carries no `RowVersion` property at all (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/RoomDTO.cs:8-33`), so the update at `:147` has nothing to round-trip and two organizers editing the same room concurrently resolve last-write-wins. `[Rubric §8, Data Architecture]`: this is the one place in the group where the [ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html) round-trip is absent from the client contract. - 3. **Accessibility text is first-class data.** `AccessibilityInfo` is an editable field (`:50`, `RoomDetail.razor:37`) rendered on the detail view only when non-blank (`RoomDetail.razor:74-77`). `[Rubric §21, Accessibility]` (assesses accommodations as content, not just markup): the note an organizer writes here is what an attendee reads on the public session page. -- **Walkthrough**: `OnInitialized` (`:28-37`) builds the breadcrumbs; `OnParametersSetAsync` (`:58-91`) guards on `_loadedId`, parses to `RoomIdentifierType` (`:69`), fetches, returns early on a not-found after the snackbar (`:71-75`), then hydrates the event lookup (`:77`); `StartEditing` (`:96-111`) seeds six shadow fields; `SaveChangesAsync` (`:119-165`) validates, rebuilds the DTO with the preserved `EventId` (`:140`), updates and refetches; `DeleteRoomAsync` (`:167-194`) confirms and navigates back; disposal (`:198-220`) is the standard pattern over the `CancellationTokenSource` at `:23`. -- **Why it's built this way**: rooms are venue facts owned by their event, so the editor keeps the parent fixed and spends its surface on the operational details (capacity, floor, wayfinding) that matter on conference day. -- **Where it's used**: the `/rooms/{Id}` route with `[Authorize(Roles = "Organizer")]` (`RoomDetail.razor:1-2`), reached from [`RoomList`](#roomlist) rows and [`RoomCreate`](#conferencecategorycreate-questioncreate-roomcreate) redirects. The rooms it edits are resolved for display by [`PublicSessionDetail`](#publicsessiondetail). -- **Caveats / not-in-source**: the delete here calls the base one-argument `DeleteAsync(Room.Id, _cts.Token)` (`:182`), not the ADC-specific overload that also sends the event id (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/IRoomUIService.cs:12`, implemented as a `?eventId=` query argument at `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/RoomService.cs:35-43`), while the API binds `eventId` as a non-nullable `[FromQuery]` parameter (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/RoomsController.cs:198-206`). [`RoomList`](#roomlist) passes it (`RoomList.razor.cs:165`). What the service does with an unsupplied event id is decided in the Conference API and is not determinable from this layer. - ---- - -### RoomList -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Room` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Room/RoomList.razor.cs:12` · Level 9 · class (Blazor code-behind) - -- **What it is**: the organizer's room browse page. It is the list-page shape taught on [`ConferenceCategoryList, EventList, QuestionList`](#conferencecategorylist-eventlist-questionlist) plus a persisted event filter that defaults to the conference actually happening. -- **Depends on**: extends [`DataGridListPageBase`](group-15-common-ui-framework.md#datagridlistpagebasetdto) (`:12`); [`IRoomUIService`](#iroomuiservice) and [`IEventLookupService`](#ieventlookupservice) (`:17-18`), [`RoomDTO`](group-17-conference-domain.md#roomdto), [`EventInfo`](#eventinfo) (`:32`), [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector) (`:96`), [`MobileInfiniteScrollList`](group-15-common-ui-framework.md#mobileinfinitescrolllisttitem) (`:26`), [`ListPageActions`](group-24-identity-module.md#listpageactions) (`:109,162`), [`ConferenceRoutePaths`](#conferenceroutepaths) (`:158,171`), and [`ErrorMessages`](group-15-common-ui-framework.md#errormessages) (`:168`). -- **Concept introduced, the defaulted filter and the startup race it has to survive.** Three mechanisms layer on top of the base, and they are the same three [`PublicSpeakerList`](#publicspeakerlist) uses; reading them here on the simpler page is the easier introduction. - 1. **An `"all"` sentinel in the persisted filter** (`:34-61`). `SaveFilters` writes the selected event id, or the literal `"all"` when the organizer explicitly cleared it (`:40`); `RestoreFilters` maps `"all"` back to a null selection with `_eventFilterResolved = true` (`:50-54`). The sentinel distinguishes "show every event" from "no saved state", and only the second triggers the computed default. The in-code comment states exactly that (`:39`). - 2. **A computed default** (`ResolveDefaultEventFilter`, `:85-103`). A restored id that still exists wins; a dangling one falls through to [`CurrentEventSelector.SelectCurrentOrNext`](group-17-conference-domain.md#currenteventselector), which picks the in-progress or next event from the lookup's start/end dates and time zones against `DateTime.UtcNow` (`:94-101`). `[Rubric §25, Navigation & Information Architecture]`: an organizer lands on the conference they are working on rather than an empty or historical grid. - 3. **A startup race guard.** `OnInitializedAsync` assigns `_eventsLoadTask` before awaiting it (`:63-69`) and both `LoadServerData` (`:124-136`) and `FetchMobilePage` (`:147-155`) await that same task before applying filters (`:128-129`, `:149-150`). The comments name the hazard (`:65-66`, `:126-127`): the `MudDataGrid`'s first `ServerData` call can run ahead of `OnInitializedAsync` completing, and `ApplyFilters` runs inside `LoadServerDataAsync`, so without the guard the first fetch would apply an unresolved filter. `[Rubric §19, State Management & Data Flow]` (assesses ordering guarantees between initialization and the first render pass). - A fourth detail is the graceful lookup failure: a failed `GetAllAsync` is swallowed with a comment marking it non-critical (`:71-83`), leaving `_events` null so `GetEventName` falls back to the invariant-culture id (`:105-106`) and `ResolveDefaultEventFilter` leaves the filter unset instead of throwing. `[Rubric §29, Resilience & Business Continuity]`: losing the name lookup degrades the labels, not the page. + - `OnInitialized` (lines 20-29) builds the Home / Questions / Create breadcrumb trail. + - The backing fields (lines 32-36) default `_questionEntity` to `"Session"` and `_questionType` to + `"Rating"` (lines 33-34), the most common combination. + - `CreateQuestionAsync` (lines 42-85) validates the form, builds the DTO with the minted id (lines + 59-67), posts with `AddAsync` (line 68), clears `_isDirty` (line 69), and navigates to + `ConferenceRoutePaths.QuestionDetails(created.Id)` (line 71). +- **Why it's built this way**: questions are the schema behind every feedback screen in both the + Conference and Engagement modules, so they are organizer-authored data rather than configuration; the + reserved id band keeps hand-authored questions from ever colliding with imported ones. +- **Where it's used**: the `/questions/create` route (`.../Pages/Question/QuestionCreate.razor:1`), + reached from [`QuestionList`](#questionlist); on success it hands off to + [`QuestionDetail`](#questiondetail). The questions it creates are what + [`OrganizerEventFeedback`](#organizereventfeedback) and + [`OrganizerSessionFeedback`](#organizersessionfeedback) group their answers under. + +### RoomCreate + +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Room` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Room/RoomCreate.razor.cs:9` · Level 5 · class (Blazor code-behind) + +- **What it is**: the organizer form that adds a room to an event. It collects the owning event, name, + sort order, capacity, floor, location, and accessibility information, then redirects to the room's + detail page. +- **Depends on**: [`IRoomUIService`](#iroomuiservice) (line 11) and + [`IEventLookupService`](#ieventlookupservice) (line 12) returning [`EventInfo`](#eventinfo); + [`RoomDTO`](group-17-conference-domain.md#roomdto); [`ConferenceRoutePaths`](#conferenceroutepaths); + and [`ErrorMessages`](group-15-common-ui-framework.md#errormessages). Uses the `Event` and `Room` + identifier aliases. +- **Concept introduced, the single-option auto-select.** A room is meaningless without a parent event, so + the page loads the event lookup in `OnInitializedAsync` and, **when the lookup holds exactly one + event**, preselects it (lines 46-50). ADC normally runs one conference at a time, so this removes a + mandatory click that has only one possible answer while still rendering a real picker when more than + one event exists. A lookup failure is non-fatal: it snackbars `Snackbar.LoadEventsFailed` (line 58) and + leaves the form usable. + `[Rubric §24, Forms, Validation & UX Safety]`: fewer required inputs with no loss of correctness, since + the field is still validated on submit. + This page also mints a client id, `RandomNumberGenerator.GetInt32(100_000, int.MaxValue)` (line 81), + which [`RoomService`](#roomservice) forwards as the API contract's `RoomId` field + (`.../Services/RoomService.cs:17-33`). Unlike the question path, `AddRoomHandler` treats a supplied id + as authoritative and only auto-allocates from the reserved range when `RoomId is null` + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/AddRoom/AddRoomHandler.cs:37-40,87-108`), + so a room created here keeps the value the browser generated. - **Walkthrough** - - `ApplyFilters` (`:138-144`) is shared by both layouts and emits at most two server filters: `Name contains` for the search box and `EventId equals` for the selected event, formatted with `CultureInfo.InvariantCulture` (`:143`) so a localized thread culture cannot corrupt the wire value. `[Rubric §27, Internationalization]`. - - `OnSearchChanged` (`:111-115`) and `OnEventFilterChanged` (`:117-122`) both set state, mark the filter resolved, and reload whichever layout is active through `ReloadActiveLayoutAsync` (`:108-109`). - - `DeleteRoomAsync` (`:161-169`) is the shared confirm-delete-toast-reload helper, and it is the one call site that supplies both arguments the rooms delete endpoint expects, `room.Id` and `room.EventId` (`:165`). - - `OnMobileCardClick` (`:157-158`) and `NavigateToCreate` (`:171`) route through [`ConferenceRoutePaths`](#conferenceroutepaths). -- **Why it's built this way**: rooms only mean anything inside an event, so an unfiltered room list would be noise; defaulting to the current or next conference makes the common case zero-click while leaving the picker for the archive. -- **Where it's used**: the `/rooms` route with `[Authorize(Roles = "Organizer")]` (`RoomList.razor:1-2`); rows and cards navigate to [`RoomDetail`](#roomdetail), and the create button opens [`RoomCreate`](#conferencecategorycreate-questioncreate-roomcreate). + - `OnInitializedAsync` (lines 35-60) builds the breadcrumbs (lines 37-42), fetches the event lookup + (line 46), applies the single-event preselect (lines 47-50), and splits cancellation from real + failures (lines 52-59). + - `CreateRoomAsync` (lines 62-107) runs the standard validate-then-submit, builds the + [`RoomDTO`](group-17-conference-domain.md#roomdto) with the minted id and the chosen `EventId` (lines + 79-89), posts it (line 90), clears `_isDirty` (line 91), and navigates to + `ConferenceRoutePaths.RoomDetails(created.Id)` (line 93). + - `NavigateToList` (line 109) and the `Dispose(bool)` pair (lines 111-133) are identical to the other + create pages. +- **Why it's built this way**: rooms are per-event data that the Sessionize sync also writes, so the UI + create path has to coexist with imported rows; the event picker is mandatory because the server rejects + a room whose event does not exist, and the reserved id band is what keeps the two sources apart. +- **Where it's used**: the `/rooms/create` route (`.../Pages/Room/RoomCreate.razor:1`), reached from + [`RoomList`](#roomlist); on success it hands off to [`RoomDetail`](#roomdetail). Rooms created here are + what [`SessionCreate`](#sessioncreate) and [`SessionDetail`](#sessiondetail) offer in their room + pickers. +- **Caveats / not-in-source**: the minted range `100_000 .. int.MaxValue` sits below the reserved + `RoomManualIdRangeStart` of `999_999_000` + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:62,65`), and + because the handler respects an explicit id, the value is persisted as sent. Whether a generated value + can collide with a Sessionize-assigned room id is a data question this file cannot answer. ### SessionCreate @@ -2256,55 +2490,41 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod - **What it is**: the organizer form that creates a session. It collects a title, description, owning event, optional room, start/end date-and-time, and the "service session" flag, posts the new record, - and redirects to that session's detail page. It is the create leg of the Conference CRUD triad and the - clearest place to see the two mechanics that make session editing awkward: a **dependent lookup** (rooms + and redirects to that session's detail page. It is the most mechanical of the create pages and the + clearest place to see the two things that make session editing awkward: a **dependent lookup** (rooms belong to the chosen event) and **split date/time pickers**. - **Depends on**: [`ISessionUIService`](#isessionuiservice) (the create client, injected at `.../Pages/Session/SessionCreate.razor.cs:17`), [`IEventLookupService`](#ieventlookupservice) returning - [`EventInfo`](#eventinfo) (line 18), [`IRoomUIService`](#iroomuiservice) for the room dropdown (line 19), - [`SessionDTO`](group-17-conference-domain.md#sessiondto), + [`EventInfo`](#eventinfo) (line 18), [`IRoomUIService`](#iroomuiservice) for the room dropdown (line + 19), [`SessionDTO`](group-17-conference-domain.md#sessiondto), [`RoomDTO`](group-17-conference-domain.md#roomdto), [`ConferenceRoutePaths`](#conferenceroutepaths), and [`ErrorMessages`](group-15-common-ui-framework.md#errormessages). It uses the `Event`, `Room`, and - `Session` identifier aliases. Externals: Blazor (`[Inject]`, `NavigationManager`), MudBlazor (`MudForm`, - `ISnackbar`, `BreadcrumbItem`), `System.Security.Cryptography.RandomNumberGenerator`, and the - `IStringLocalizer` injected by the template - (`.../Pages/Session/SessionCreate.razor:6`). -- **Concept introduced, the partial-class code-behind create form.** Every Conference page is a `.razor` - template plus a `.razor.cs` partial holding the injected services, backing fields, and handlers. A - create form layers three recurring mechanisms on that split: - 1. **Cancel-on-disposal**: a `CancellationTokenSource _cts` (line 23) passed to every call and cancelled - plus disposed in `Dispose` (lines 177-197), so an in-flight save cannot resolve against a torn-down - component. - 2. **Validate-then-submit**: `await _form.ValidateAsync()` followed by an `IsValid` guard (lines - 127-132), with the `IsSaving` flag (line 28) disabling the button for the round trip. - 3. **An unsaved-changes guard**: `_isDirty` set by `MarkDirty()` (lines 43-45) and consumed by the - shared `UnsavedChangesGuard` component in the template - (`.../Pages/Session/SessionCreate.razor:10`), cleared *before* the success redirect (line 155) so the - guard does not block it. - `[Rubric §24, Forms, Validation & UX Safety]` (assesses client validation, unsaved-change protection, - and safe submits): this page validates before posting, tracks dirty state, and guards navigation. - `[Rubric §18, UI Architecture & Component Design]` (assesses logic separated from markup): the - code-behind keeps the template declarative. `[Rubric §11, Security]`: the route is organizer-only - (`@attribute [Authorize(Roles = "Organizer")]`, `.../Pages/Session/SessionCreate.razor:2`). - `[Rubric §27, Internationalization]` (assesses externalized user-facing text): every label, breadcrumb, - and snackbar reads through the injected `IStringLocalizer` (`L["Snackbar.Created"]`, line 156). - A second idea this page shows is the **client-minted identifier**: because `SessionIdentifierType` is - `int`, the form fabricates a temporary id with + `Session` identifier aliases. Externals: Blazor (`[Inject]`, `NavigationManager`), MudBlazor + (`MudForm`, `ISnackbar`, `BreadcrumbItem`), `System.Security.Cryptography.RandomNumberGenerator`, and + the `IStringLocalizer` injected by the template. +- **Concept introduced, the dependent lookup.** The create-form shape itself is + [`EventCreate`](#eventcreate)'s; what is new here is that one field's options depend on another field's + value. `LoadRoomsAsync` (lines 81-96) fetches rooms filtered by the selected event, and + `OnEventChangedAsync` (lines 99-118) reloads them and clears the previous choice whenever the event + changes. The doc comment (lines 76-80) records why this is not cosmetic: BR-130 rejects a room from + another event server-side, so the dropdown must only ever offer rooms of the chosen event. + `[Rubric §24, Forms, Validation & UX Safety]`: the client is shaped so it cannot compose a request the + server will refuse. `[Rubric §19, State Management & Data Flow]`: `_rooms` is derived state, explicitly + invalidated when its input changes rather than left to go stale. + A second idea this page shows is the **client-minted identifier**: it fabricates `RandomNumberGenerator.GetInt32(100_000, int.MaxValue)` (line 144) to satisfy the required - `SessionDTO.Id`, then reads `created.Id` back from the server response (line 157) so it tolerates the - server honoring or overwriting that value. Contrast - [`SponsorCreate`](#sponsorcreate), which posts `Id = default` instead. + `SessionDTO.Id`, and because `CreateSessionHandler` only auto-allocates from the reserved range when + `command.Id == default` and otherwise respects an explicit id + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/Create/CreateSessionHandler.cs:76-95`), + the generated value is what gets persisted. The page still reads `created.Id` back from the response + (line 157), so it works either way. Contrast [`EventCreate`](#eventcreate), which posts `Id = default`, + and [`QuestionCreate`](#questioncreate), whose id is always overwritten. - **Walkthrough** - `OnInitializedAsync` (lines 47-74) builds the breadcrumb trail (lines 49-54), loads the event lookup (line 58), **auto-selects the only event** when the lookup has exactly one entry (lines 59-62, the - single-conference convenience), then calls `LoadRoomsAsync`. `OperationCanceledException` is swallowed - as expected during disposal or an InteractiveAuto render-mode transition; any other failure snackbars - a lookup error. - - `LoadRoomsAsync` (lines 81-96) is the **dependent lookup**: with no event chosen it clears `_rooms` - and returns (lines 83-87); otherwise it fetches up to 500 rooms filtered by - `EventId equals ` (lines 89-94). The doc comment (lines 76-80) records why the filter is not - cosmetic: BR-130 rejects a room from another event server-side, so the dropdown must only ever offer - rooms of the chosen event. + same single-conference convenience as [`RoomCreate`](#roomcreate)), then calls `LoadRoomsAsync`. + - `LoadRoomsAsync` (lines 81-96): with no event chosen it clears `_rooms` and returns (lines 83-87); + otherwise it fetches up to 500 rooms filtered by `EventId equals ` (lines 89-94). - `OnEventChangedAsync` (lines 99-118) marks the form dirty, **clears the previously picked room** (line 104, with the in-code note that keeping it would have the server reject the save), and reloads the room list. @@ -2319,8 +2539,297 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod event-scoped room reload keeps the client from ever offering a value the server will reject. - **Where it's used**: the `/sessions/create` route, reached from [`SessionList`](#sessionlist)'s create button; on success it hands off to [`SessionDetail`](#sessiondetail). -- **Caveats / not-in-source**: whether the API honors or replaces the client-minted id is a server-side - decision not visible here; the page reads `created.Id` from the response either way. + +### EventList + +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Event` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Event/EventList.razor.cs:16` · Level 7 · class (Blazor code-behind) + +- **What it is**: the organizer browse page for events: a server-paged, server-sorted grid on desktop, an + infinite-scroll card list on mobile, with a name search, a delete-with-confirmation action, and + navigation into create and detail. It is the simplest inheritor of the shared list base and the place + where the Conference list-page shape is taught. +- **Depends on**: extends + [`DataGridListPageBase`](group-15-common-ui-framework.md#datagridlistpagebasetdto) over + [`EventDTO`](group-17-conference-domain.md#eventdto) (line 16) and injects + [`IEventUIService`](#ieventuiservice) (line 21). It uses + [`ListPageActions`](group-24-identity-module.md#listpageactions), + [`ErrorMessages`](group-15-common-ui-framework.md#errormessages), + [`ConferenceRoutePaths`](#conferenceroutepaths), and the + [`MobileInfiniteScrollList`](group-15-common-ui-framework.md#mobileinfinitescrolllisttitem), + `DeleteConfirmation`, and `ListNoRecordsContent` components from `MMCA.Common.UI`. +- **Concept introduced, the two-layout list page over one shared base.** A Conference list page is + roughly forty lines because everything hard lives in + [`DataGridListPageBase`](group-15-common-ui-framework.md#datagridlistpagebasetdto). The derived + page supplies five things and nothing else: + 1. **A grid reference**: `_dataGrid` captured via `@ref` and surfaced through the overridden `GridRef` + (lines 24-25), which the base needs to restore rows-per-page after first render. + 2. **Filter persistence**: `SaveFilters` / `RestoreFilters` (lines 33-37) write and read the page's own + search string, and the base persists them to the URL query string, an in-memory service, and + session storage, so filters survive navigation, refresh, and a shared link. + `[Rubric §25, Navigation & Information Architecture]`. + 3. **The fetch delegates**: `LoadServerData` (lines 48-57) hands the base a lambda that calls + `EventService.GetPagedAsync` and an `additionalFilters` callback that appends + `Name contains ` (lines 53-57). `LoadServerDataAsync` in the base + (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:434-511`) + owns cancellation-token resetting, the SSR pre-render hand-off, page-plus-one index conversion, + sort extraction, the `IsLoading` and `LoadFailed` flags, and uniform error snackbars. + 4. **A mobile fetch**: `FetchMobilePage` (lines 60-66) repeats the same filter build for the + card list, which pages by "load more" instead of a pager and sorts by `Name asc`. + `[Rubric §22, Responsive & Cross-Browser]`: the page renders two genuinely different layouts off the + base's `IsMobile` flag rather than reflowing one grid. + 5. **Actions**: `DeleteEventAsync` (lines 71-79) delegates the confirm, delete, snackbar, and reload + sequence to [`ListPageActions`](group-24-identity-module.md#listpageactions)`.DeleteWithConfirmationAsync`, + and `ReloadActiveLayoutAsync` (lines 39-40) dispatches a refresh to whichever layout is live. + The failure path is worth noting: when a fetch fails the base sets `LoadFailed`, and the template feeds + it to `ListNoRecordsContent` with an `OnRetry` handler wired to `RetryLoadAsync` (line 28, + `.../Pages/Event/EventList.razor:131`), so a failed load renders an inline retry instead of an empty + list that looks like "no events". `[Rubric §19, State Management & Data Flow]`. + Data access itself follows ADR-094 (`Website/docs-src/adr/094-client-entity-data-access.md`): the page + never touches `HttpClient`, only the typed `I*UIService` client, and expresses filters as + operator-plus-value pairs the server model binder understands. +- **Walkthrough** + - `Title` and `EntityName` (lines 18-19) come from the injected localizer; `Title` is the abstract + member the base uses in its error messages. + - `OnSearchChanged` (lines 42-46) stores the text and reloads the active layout, so typing drives a + server round trip rather than a client-side filter over the current page. + - `OnMobileCardClick` (line 68) and `NavigateToDetails` (lines 84-85) both route through + `ConferenceRoutePaths.EventDetails(id)`, and `NavigateToCreate` (lines 81-82) opens + [`EventCreate`](#eventcreate). +- **Why it's built this way**: nineteen list pages across the workspace share this base (ADR-056 records + the count and the render-mode strategy they run under), so browse behavior, state persistence, and + error handling stay identical everywhere and a new list page costs a few dozen lines. +- **Where it's used**: the `/events` organizer route (`.../Pages/Event/EventList.razor:1-2`, + `Authorize(Roles = "Organizer")`); rows open [`EventDetail`](#eventdetail) and the create button opens + [`EventCreate`](#eventcreate). + +### QuestionList + +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Question` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Question/QuestionList.razor.cs:11` · Level 7 · class (Blazor code-behind) + +- **What it is**: the organizer browse page for feedback questions. Structurally the twin of + [`EventList`](#eventlist): same base class, same two layouts, same delete flow, with the search bound to + the question text instead of a name. +- **Depends on**: extends + [`DataGridListPageBase`](group-15-common-ui-framework.md#datagridlistpagebasetdto) over + [`QuestionDTO`](group-17-conference-domain.md#questiondto) (line 11) and injects + [`IQuestionUIService`](#iquestionuiservice) (line 16); plus + [`ListPageActions`](group-24-identity-module.md#listpageactions), + [`ErrorMessages`](group-15-common-ui-framework.md#errormessages), + [`ConferenceRoutePaths`](#conferenceroutepaths), and the same three shared components. +- **Concept introduced**: none new. See [`EventList`](#eventlist) for the base-class contract (grid ref, + filter persistence, the two fetch delegates, and the shared delete action). +- **Walkthrough** (only the differences from [`EventList`](#eventlist)): + - The search filter targets `QuestionText contains ` on both the desktop (lines 48-52) and + mobile (lines 57-60) paths, and the mobile fetch sorts by `QuestionText asc` (line 60). + - `DeleteQuestionAsync` (lines 67-75) passes `question.QuestionText` as the confirmation label, so the + dialog names the question being removed. + - There is no detail-navigation helper: the mobile card click navigates inline (lines 63-64) and the + grid rows link from the template. +- **Why it's built this way**: questions are low-volume reference data, so the page needs browse, search, + and delete but no filters or enrichment; keeping it on the same base means it inherits URL-persisted + paging, sorting, and the inline retry state for free. +- **Where it's used**: the `/questions` organizer route (`.../Pages/Question/QuestionList.razor:1`); the + create button opens [`QuestionCreate`](#questioncreate) (line 77) and rows open + [`QuestionDetail`](#questiondetail). + +### EventDetail + +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Event` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Event/EventDetail.razor.cs:15` · Level 8 · class (Blazor code-behind) + +- **What it is**: the organizer's event console. It loads one event by route id and offers four distinct + operations on it: inline edit, publish/unpublish, refresh from Sessionize, and delete. It is the + richest detail page in the Conference UI in terms of *verbs*, and the place where the detail-page shape + is taught. +- **Depends on**: [`IEventUIService`](#ieventuiservice) (line 19) for all four operations; + [`EventDTO`](group-17-conference-domain.md#eventdto), + [`RefreshFromSessionizeResultDTO`](group-17-conference-domain.md#refreshfromsessionizeresultdto), and + [`QuestionModerationDefault`](group-17-conference-domain.md#questionmoderationdefault); + [`ConferenceRoutePaths`](#conferenceroutepaths), + [`ErrorMessages`](group-15-common-ui-framework.md#errormessages), + [`DomainHelper`](group-02-domain-building-blocks.md#domainhelper)'s `Parse` extension (line 4), and + the `DeleteConfirmation` plus `UnsavedChangesGuard` components from `MMCA.Common.UI`. +- **Concept introduced, load-once-on-parameters plus shadow-field editing.** Three mechanisms combine + here and recur in every detail page below: + 1. **Route id as a string**: the id arrives as `[Parameter] public string Id` (line 23) and is + converted with `Id.Parse()` (line 92), so the page compiles unchanged whichever + primitive the alias maps to (ADR-048, ADR-085). + 2. **Load once per id**: `OnParametersSetAsync` compares against `_loadedId` and returns early when the + id is unchanged (lines 75-85), so a re-render does not refetch. + 3. **Shadow fields**: `StartEditing` copies the loaded record into the `_edit*` fields (lines 117-139) + and `CancelEditing` simply drops edit mode (lines 141-145), so the live `Event` object is never + mutated until a validated save succeeds. `[Rubric §24, Forms, Validation & UX Safety]`. + `[Rubric §8, Data Architecture]` (assesses a deliberate concurrency strategy): every mutating call + re-sends the loaded `RowVersion`, on save (line 173) and on publish/unpublish (lines 221, 249), which is + the client half of the optimistic-concurrency token described in + `Website/docs-src/adr/035-optimistic-concurrency.md`; the server rejects a stale token rather than + silently overwriting a concurrent edit. + `[Rubric §6, CQRS & Event-Driven]`: publish and unpublish are not `IsPublished` flag edits, they are + their own service operations (`PublishAsync` / `UnpublishAsync`, + `.../Services/IEventUIService.cs:12,14`), so the state transition stays a named use case; note that + `SaveChangesAsync` deliberately re-sends the *existing* `IsPublished` value (line 186) rather than + letting the edit form move it. + `[Rubric §13, Observability & Operability]`: the Sessionize refresh returns a + [`RefreshFromSessionizeResultDTO`](group-17-conference-domain.md#refreshfromsessionizeresultdto) held in + `_refreshResult` (line 69) so the organizer sees what the import actually did. +- **Walkthrough** + - `LoadEventAsync` (lines 87-115): parse the id, `GetByIdAsync(eventId, true, ...)` so children arrive + with the record (line 93), snackbar `ErrorMessages.NotFound` when it is missing (line 96), otherwise + seed `_sessionizeCode` from the loaded event (line 100). The `finally` always clears `IsLoading`. + - `SaveChangesAsync` (lines 147-209): validate the form, re-check the date range (lines 161-165), + rebuild the DTO from the shadow fields including `RowVersion` (line 173) and the edited + `QuestionModerationDefault` (line 187), `UpdateAsync`, then **refetch** the record (lines 190-191) so + the page shows server truth rather than the values it just sent. + - `PublishAsync` / `UnpublishAsync` (lines 211-265) are the same shape: call the named operation with + the current `RowVersion`, refetch, snackbar. Each has its own failure message. + - `RefreshFromSessionizeAsync` (lines 267-321) first **persists a changed Sessionize code** before + importing (lines 279-302), since the code the organizer just typed is what the import must use, then + calls `RefreshFromSessionizeAsync`, refetches the event, and reports completion (lines 304-307). + - `DeleteEventAsync` (lines 323-350): confirm through `_deleteConfirm.ShowAsync(Event.Name)` + (line 330), delete, then navigate back to the list. +- **Why it's built this way**: an event is the root aggregate of the whole conference, so publishing, + importing, and editing are separate operations with separate audit meaning rather than one PUT; keeping + each as its own service call is also what lets the server enforce its own rules per transition. +- **Where it's used**: the `/events/{Id}` organizer route (`.../Pages/Event/EventDetail.razor:1-2`), + reached from [`EventList`](#eventlist) rows and from [`EventCreate`](#eventcreate)'s success redirect. + Its "view feedback" button opens [`OrganizerEventFeedback`](#organizereventfeedback) + (`.../Pages/Event/EventDetail.razor:176`). +- **Caveats / not-in-source**: what the Sessionize refresh imports, and how it reconciles existing rows, + is server-side (the sync strategies in the Application layer); this page only shows the returned + summary. + +### QuestionDetail + +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Question` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Question/QuestionDetail.razor.cs:11` · Level 8 · class (Blazor code-behind) + +- **What it is**: the organizer's view-edit-delete page for a single feedback question. It is the + smallest detail page in the group: three editable fields and no lookups. +- **Depends on**: [`IQuestionUIService`](#iquestionuiservice) (line 15), + [`QuestionDTO`](group-17-conference-domain.md#questiondto), + [`ConferenceRoutePaths`](#conferenceroutepaths), + [`ErrorMessages`](group-15-common-ui-framework.md#errormessages), the `Parse` extension from + [`DomainHelper`](group-02-domain-building-blocks.md#domainhelper) (line 4), and the + `DeleteConfirmation` plus `UnsavedChangesGuard` components. +- **Concept introduced**: none new. Route-id parsing, load-once-on-parameters, shadow-field editing, and + the `RowVersion` round-trip are all introduced in [`EventDetail`](#eventdetail). +- **Walkthrough** + - `OnParametersSetAsync` (lines 52-82) does the load inline rather than in a helper: guard on + `_loadedId`, parse to `QuestionIdentifierType` (line 63), `GetByIdAsync` (line 64), snackbar + `NotFound` when absent (line 67). + - `StartEditing` (lines 84-96) copies only `QuestionText`, `Sort`, and `IsRequired` into shadow fields; + `QuestionEntity` and `QuestionType` are not editable. + - `SaveChangesAsync` (lines 104-149) rebuilds the DTO with `RowVersion` (line 123) and re-sends the + **unchanged** `QuestionEntity` and `QuestionType` (lines 126-127), updates, then refetches (line 132). + `[Rubric §8, Data Architecture]`: the immutable-after-create fields are round-tripped rather than + omitted, so the update contract stays a full replacement. + - `DeleteQuestionAsync` (lines 151-178) confirms with the question text as the label (line 158) and + navigates back to the list on success. +- **Why it's built this way**: a question's entity and type determine how every existing answer was + captured, so changing them after answers exist would invalidate stored data; restricting the edit + surface to text, order, and required-ness is the simplest way to keep answers interpretable. +- **Where it's used**: the `/questions/{Id}` organizer route + (`.../Pages/Question/QuestionDetail.razor:1`), reached from [`QuestionList`](#questionlist) and from + [`QuestionCreate`](#questioncreate)'s success redirect. + +### RoomDetail + +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Room` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Room/RoomDetail.razor.cs:12` · Level 8 · class (Blazor code-behind) + +- **What it is**: the organizer's view-edit-delete page for one room, showing its parent event by name and + editing name, sort, capacity, floor, location, and accessibility information. +- **Depends on**: [`IRoomUIService`](#iroomuiservice) (line 16) and + [`IEventLookupService`](#ieventlookupservice) (line 17) returning [`EventInfo`](#eventinfo); + [`RoomDTO`](group-17-conference-domain.md#roomdto); [`ConferenceRoutePaths`](#conferenceroutepaths); + [`ErrorMessages`](group-15-common-ui-framework.md#errormessages); the `Parse` extension (line 5); + and the `DeleteConfirmation` plus `UnsavedChangesGuard` components. +- **Concept introduced, lookup-with-id-fallback.** `GetEventName` (lines 93-94) resolves the parent event + through the lookup dictionary and falls back to the invariant-culture id when the lookup misses, so a + dangling or not-yet-loaded reference degrades to a visible id rather than a blank cell. Every name + resolver in this unit follows the same rule (compare [`SessionDetail`](#sessiondetail) lines 139-154). + The lookup itself is hydrated lazily with `??=` (line 77), so revisiting the page does not refetch it. + `[Rubric §19, State Management & Data Flow]`. + `[Rubric §8, Data Architecture]`: unlike the other detail pages, the update here carries no + `RowVersion`, because [`RoomDTO`](group-17-conference-domain.md#roomdto) does not define one + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/RoomDTO.cs:8-32`); rooms are + edited without an optimistic-concurrency token. +- **Walkthrough** + - `OnParametersSetAsync` (lines 58-91): the `_loadedId` guard, parse to `RoomIdentifierType` (line 69), + `GetByIdAsync` with a `NotFound` snackbar and early return (lines 70-75), then the lazy event lookup + (line 77). + - `StartEditing` / `CancelEditing` (lines 96-117) are the standard shadow-field pair. + - `SaveChangesAsync` (lines 119-165) rebuilds the [`RoomDTO`](group-17-conference-domain.md#roomdto) + with the **unchanged** `EventId` (line 140), so a room cannot be moved between events from this page, + calls `UpdateAsync`, and refetches (lines 147-148). + - `DeleteRoomAsync` (lines 167-194) confirms on the room name (line 174) and calls + `RoomService.DeleteAsync(Room.Id, _cts.Token)` (line 182). +- **Why it's built this way**: rooms belong to exactly one event for their whole life (their identity is + shared with the Sessionize import), so the detail page treats the parent as read-only and offers only + the descriptive fields for editing. +- **Where it's used**: the `/rooms/{Id}` organizer route (`.../Pages/Room/RoomDetail.razor:1`), reached + from [`RoomList`](#roomlist) rows and from [`RoomCreate`](#roomcreate)'s success redirect. +- **Caveats / not-in-source**: the delete call binds the **inherited single-id overload** from + `EntityServiceBase`, which issues `DELETE rooms/{id}` with no query string + (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/EntityServiceBase.cs:152-162`), whereas + [`RoomList`](#roomlist) calls the room-specific overload that appends `?eventId={eventId}` + (`.../Services/RoomService.cs:35-43`) for the `[FromQuery] EventIdentifierType eventId` parameter the + controller binds + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/RoomsController.cs:311-319`). + What the API does with the absent parameter is a server-side outcome not determinable from this file. + +### RoomList + +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Room` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Room/RoomList.razor.cs:12` · Level 9 · class (Blazor code-behind) + +- **What it is**: the organizer browse page for rooms. It adds an **event filter** to the + [`EventList`](#eventlist) shape, defaults that filter to the current or next conference, and enriches + each row with the event's name. +- **Depends on**: extends + [`DataGridListPageBase`](group-15-common-ui-framework.md#datagridlistpagebasetdto) over + [`RoomDTO`](group-17-conference-domain.md#roomdto) (line 12) and injects + [`IRoomUIService`](#iroomuiservice) and [`IEventLookupService`](#ieventlookupservice) (lines 17-18). It + uses [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector), + [`EventInfo`](#eventinfo), [`ListPageActions`](group-24-identity-module.md#listpageactions), + [`ErrorMessages`](group-15-common-ui-framework.md#errormessages), + [`ConferenceRoutePaths`](#conferenceroutepaths), and the shared list components. +- **Concept introduced, the defaulted filter and its startup race.** An organizer almost always wants the + rooms of the conference that is running or coming up next, so the page computes that default instead of + showing every room ever created. Three parts make it work: + 1. **The default itself**: `ResolveDefaultEventFilter` (lines 85-103) calls + `CurrentEventSelector.SelectCurrentOrNext` with accessor lambdas for start date, end date, and time + zone (lines 96-101), the same live-window math the backend uses. [`EventList`](#eventlist)'s sibling + [`SessionList`](#sessionlist) can use the + [`CurrentEventDefaults`](group-17-conference-domain.md#currenteventdefaults) wrapper instead because + it holds [`EventDTO`](group-17-conference-domain.md#eventdto)s; this page holds + [`EventInfo`](#eventinfo) records, so it calls the generic selector directly. + 2. **A restored id wins, but only if it still exists**: the guard at lines 88-92 keeps a restored + selection and falls back to the computed default when the saved id no longer resolves, so a stale + bookmark does not produce a silently empty grid. The `"all"` sentinel written by `SaveFilters` + (line 40) is what distinguishes "the user explicitly cleared the filter" from "there is no saved + state", which would otherwise both look like `null`. `[Rubric §25, Navigation & Information + Architecture]`. + 3. **The race guard**: `_eventsLoadTask` is started in `OnInitializedAsync` (line 67) and awaited again + inside both `LoadServerData` (lines 128-129) and `FetchMobilePage` (lines 149-150), because the grid's + first `ServerData` call can run before initialization finishes and `ApplyFilters` executes *inside* + `LoadServerDataAsync`. Without the second await the first page would be fetched unfiltered. + `[Rubric §19, State Management & Data Flow]`. + The event lookup failure is non-fatal by design: the catch comment says name enrichment falls back to + ID display (lines 77-80), matching `GetEventName`'s own fallback (lines 105-106). +- **Walkthrough** + - `SaveFilters` / `RestoreFilters` (lines 34-61) persist the search string plus the event id, parsing + the sentinel and the integer id back out (lines 48-60). + - `ApplyFilters` (lines 138-144) is shared by both fetch paths and emits `Name contains ` and + `EventId equals `. + - `OnSearchChanged` and `OnEventFilterChanged` (lines 111-122) each update one filter, mark the filter + resolved, and reload the active layout through + [`ListPageActions`](group-24-identity-module.md#listpageactions)`.ReloadActiveLayoutAsync` + (lines 108-109). + - `DeleteRoomAsync` (lines 161-169) passes both ids to the room-specific delete overload (line 165), + the event-scoped call the API expects. + - `NavigateToCreate` (line 171) opens [`RoomCreate`](#roomcreate). +- **Why it's built this way**: rooms accumulate across every conference the instance has ever hosted, so + an unfiltered list is close to useless on day one of an event; computing the default from the same + live-window rule the backend uses keeps the UI and the server agreeing on which conference is "now". +- **Where it's used**: the `/rooms` organizer route (`.../Pages/Room/RoomList.razor:1-2`); rows open + [`RoomDetail`](#roomdetail) and the create button opens [`RoomCreate`](#roomcreate). ### SessionDetail @@ -2341,29 +2850,24 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod [`ConferenceRoutePaths`](#conferenceroutepaths), [`ErrorMessages`](group-15-common-ui-framework.md#errormessages), the `DeleteConfirmation` component from `MMCA.Common.UI`, and [`DomainHelper`](group-02-domain-building-blocks.md#domainhelper)'s - `Id.Parse` extension (`MMCA.Common.Shared.Extensions`, line 6). Uses the + `Parse` extension (`MMCA.Common.Shared.Extensions`, line 6). Uses the `Event`/`Room`/`Speaker`/`Session`/`SessionSpeaker`/`SessionCategoryItem`/`CategoryItem` aliases. -- **Concept introduced, route-id parsing, load-once-on-parameters, shadow-field editing, and an - event-keyed lookup cache.** Four mechanisms combine here: - 1. **Route id as a string**: the id arrives as `[Parameter] public string Id` (line 31) and is converted - to the typed alias with `Id.Parse()` (line 101), so the page compiles - unchanged whether the alias is `int` or `Guid`. - 2. **Load once per id**: `OnParametersSetAsync` compares against `_loadedId` and returns early when the - id is unchanged (lines 85-94), so a re-render does not refetch. - 3. **Shadow fields**: `StartEditing` copies the loaded record into `_edit*` fields (lines 156-176) and - `CancelEditing` simply drops edit mode (lines 178-182), so the live `Session` is never mutated until - a validated save succeeds. `[Rubric §24, Forms, Validation & UX Safety]`. - 4. **An event-keyed room cache**: the global lookups are hydrated once with `??=` (lines 109-111), but +- **Concept introduced, the event-keyed lookup cache and the join-collection editor.** The route-id + parsing, load-once-on-parameters, and shadow-field mechanics are [`EventDetail`](#eventdetail)'s; two + things are new: + 1. **An event-keyed room cache**: the global lookups are hydrated once with `??=` (lines 109-111), but rooms are per-event, so they are cached against `_roomsForEventId` and refetched when the session's event differs (lines 113-123). The in-code comment (lines 77-79) records the bug this prevents: - without the key, navigating to a session in another event renders the previous event's room names and - offers its rooms in the edit picker. `[Rubric §19, State Management & Data Flow]` (assesses where + without the key, navigating to a session in another event renders the previous event's room names + and offers its rooms in the edit picker. `[Rubric §19, State Management & Data Flow]` (assesses where view state lives and when it is invalidated). - `[Rubric §8, Data Architecture]` (assesses a deliberate concurrency strategy): the update DTO re-sends - the loaded `RowVersion` (line 209), the client half of the optimistic-concurrency token, so the server - can reject a stale concurrent edit. `[Rubric §18, UI Architecture & Component Design]`: the page's size - comes from breadth (two join collections plus four lookups), not from bespoke mechanics, since the - add/remove/available-items triple is one pattern applied twice. + 2. **Join management as an add/remove/available triple**: the same three-method pattern is applied + twice, once for speakers and once for category items, and each mutation is followed by a full + `LoadAsync` rather than a local patch. + `[Rubric §8, Data Architecture]`: the update DTO re-sends the loaded `RowVersion` (line 209), the client + half of the optimistic-concurrency token (ADR-035), so the server can reject a stale concurrent edit. + `[Rubric §18, UI Architecture & Component Design]`: the page's size comes from breadth (two join + collections plus four lookups), not from bespoke mechanics. - **Walkthrough** - `LoadAsync` (lines 96-137): parse the id, `GetByIdAsync(sessionId, true, ...)` so the join collections arrive with the record (line 102), snackbar `NotFound` and bail when the session is missing (lines @@ -2371,9 +2875,9 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod event's rooms into `_roomNames` and `_editableRooms` (lines 113-123). The `finally` always clears `IsLoading`. - Name resolution (lines 139-154): `GetEventName`, `GetSpeakerName`, `GetCategoryItemDisplayName`, and - `GetRoomName` each fall back to the invariant-culture id when the lookup misses, so a stale reference - degrades to an id rather than a blank cell; category items render as `"{CategoryTitle}: {Name}"` when - a title exists (lines 147, 150-151). + `GetRoomName` each fall back to the id when the lookup misses, so a stale reference degrades to an id + rather than a blank cell; category items render as `"{CategoryTitle}: {Name}"` when a title exists + (lines 147, 150-151). - Edit and save (lines 156-240): `StartEditing` seeds the shadow fields including the split date/time pairs (lines 165-168); `SaveChangesAsync` validates, recombines date plus time only when both parts are set (lines 201-204), rebuilds the DTO with `RowVersion` (line 209) and the **unchanged** `EventId` @@ -2390,17 +2894,323 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod reusable detail-page scaffolding the other Conference detail pages use; reloading after each join mutation keeps the page a single source of truth instead of hand-patching local collections. - **Where it's used**: the `/sessions/{Id}` organizer route, reached from [`SessionList`](#sessionlist) - rows and from [`SessionCreate`](#sessioncreate)'s success redirect. + rows and from [`SessionCreate`](#sessioncreate)'s success redirect; its "view feedback" button opens + [`OrganizerSessionFeedback`](#organizersessionfeedback) (`.../Pages/Session/SessionDetail.razor:190`). - **Caveats / not-in-source**: reads pass `includeChildren: true` so the join collections populate; how the GetAll path populates children is a server-side concern this page does not exercise. +### SessionList + +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Session` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Session/SessionList.razor.cs:18` · Level 10 · class (Blazor code-behind) + +- **What it is**: the organizer browse page for sessions and the richest list in the Conference UI. It + carries three filters (free-text title search, session status, and event), enriches each row with room + and speaker names, and color-codes the Sessionize status. It sits at the top of the group's dependency + order because it transitively pulls in the most lookups and defaults. +- **Depends on**: extends + [`DataGridListPageBase`](group-15-common-ui-framework.md#datagridlistpagebasetdto) (line 18) and + injects [`ISessionUIService`](#isessionuiservice), [`IEventUIService`](#ieventuiservice), and + [`ISpeakerLookupService`](#ispeakerlookupservice) (lines 23-25). It uses + [`SessionDTO`](group-17-conference-domain.md#sessiondto), + [`EventDTO`](group-17-conference-domain.md#eventdto), [`SpeakerInfo`](#speakerinfo), + [`CurrentEventDefaults`](group-17-conference-domain.md#currenteventdefaults) (the `EventDTO`-typed + wrapper over [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector)), + [`ConferenceRoutePaths`](#conferenceroutepaths), + [`ErrorMessages`](group-15-common-ui-framework.md#errormessages), + [`ListPageActions`](group-24-identity-module.md#listpageactions), and the + [`MobileInfiniteScrollList`](group-15-common-ui-framework.md#mobileinfinitescrolllisttitem) plus + `DeleteConfirmation` components. Uses the `Event`/`Room`/`Session`/`Speaker` aliases. +- **Concept introduced, the multi-filter enriched list.** `SessionList` layers three refinements on the + event-filtered shape [`RoomList`](#roomlist), [`SponsorList`](#sponsorlist), and + [`SpeakerList`](#speakerlist) share: + 1. **A third filter**: `_searchString`, `_selectedStatus`, and `_selectedEventId` persist together + (`SaveFilters` lines 44-53, `RestoreFilters` lines 55-73) and are emitted as `Title contains`, + `Status equals`, and `EventId equals` server filters (`ApplyFilters`, lines 208-216). + 2. **Enrichment from two bulk loads instead of per-row fetches**: + `LoadEventsAndResolveDefaultAsync` fetches events with `includeChildren: true` (line 98) and folds + every event's rooms into one `_roomNames` dictionary (`PopulateRoomNames`, lines 124-138), while the + speaker lookup loaded in `OnInitializedAsync` (line 84) backs `GetSpeakerList` (lines 140-148), which + maps a row's `SessionSpeakers` to display names and skips ids the lookup does not know. Both loads + are wrapped in best-effort catches whose comments say the fallback is dash display, not a broken + page (lines 86-89, 102-105). The paged fetch itself also passes `includeChildren: true` (lines 193, + 205) so each row arrives with its speaker joins. + 3. **Status color coding**: `GetStatusColor` (lines 150-159) maps the Sessionize status strings + `Accepted`, `Waitlisted`, `Accept_Queue`, `Nominated`, `Decline_Queue`, and `Declined` to MudBlazor + colors, defaulting to `Color.Default` for anything else. + The startup race guard is the same one [`RoomList`](#roomlist) uses, with the clearest explanation in + this file: `_eventsLoadTask` is started before the first `await` (lines 77-80) and awaited inside both + `LoadServerData` (lines 187-188) and `FetchMobilePage` (lines 200-201), because `ApplyFilters` runs + inside `LoadServerDataAsync`, so the default event must be resolved before entering it, "not merely + before the fetch delegate runs" (in-code comment, lines 185-186). + `[Rubric §18, UI Architecture & Component Design]`: the status filter surfaces the program-committee + workflow inline instead of hiding it behind a separate screen. + `[Rubric §23, Front-End Performance & Rendering]`: one children-loaded events fetch plus one speaker + lookup replace what would otherwise be per-row enrichment calls. + `[Rubric §25, Navigation & Information Architecture]`: all three filters survive navigation through the + base class's persistence contract, with the same `"all"` sentinel and computed default. +- **Walkthrough** + - `OnInitializedAsync` (lines 75-92): start the events task, load the speaker lookup (tolerating + failure), then await the events task. + - `ResolveDefaultEventFilter` (lines 110-122): keep a restored id that still exists in `_events`, + otherwise take `CurrentEventDefaults.SelectCurrentOrNext(_events, DateTime.UtcNow)?.Id` (line 120). + - `OnSearchChanged`, `OnStatusChanged`, and `OnEventFilterChanged` (lines 164-181) each update one + filter and reload whichever layout is active via + [`ListPageActions`](group-24-identity-module.md#listpageactions)`.ReloadActiveLayoutAsync` + (lines 161-162). + - `LoadServerData` (lines 183-195) and `FetchMobilePage` (lines 198-206) are the desktop and mobile + fetch paths over the same `ApplyFilters`. + - `DeleteSessionAsync` (lines 221-229) delegates the confirm, delete, snackbar, and reload sequence to + `ListPageActions.DeleteWithConfirmationAsync`; `NavigateToCreate` and `NavigateToDetails` (lines + 231-232) route to [`SessionCreate`](#sessioncreate) and [`SessionDetail`](#sessiondetail). +- **Why it's built this way**: sessions are the central editable entity of the program, so the list has to + answer "what is in this conference, in what state, presented by whom" at a glance; defaulting to the + active event and enriching from two bulk loads keeps that view both relevant and cheap. +- **Where it's used**: the `/sessions` organizer route + (`.../Pages/Session/SessionList.razor:1-2`, `Authorize(Roles = "Organizer")`); rows open + [`SessionDetail`](#sessiondetail) and the create button opens [`SessionCreate`](#sessioncreate). +- **Caveats / not-in-source**: the page builds speaker names from its own lookup rather than trusting the + paged payload alone, so it degrades to a dash rather than a wrong name when a speaker id is unknown; + how the paged endpoint populates `SessionSpeakers` is a server-side concern outside this file. + +### ActivityCreate + +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Activity` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Activity/ActivityCreate.razor.cs:16` · Level 9 · class (Blazor code-behind) + +- **What it is**: the organizer form that schedules an activity, the non-session items on a conference + programme (registration, breaks, receptions, after-parties). It collects the name, the owning event, the + start and end of the window as **separate date and time pickers**, the display order used to break ties, + and an optional off-site venue. The event picker is required, because activities are scheduled per event + and the owning event cannot be changed afterwards (class doc, lines 10-14). +- **Depends on**: [`IActivityUIService`](#iactivityuiservice) (line 18), + [`IEventLookupService`](#ieventlookupservice) returning [`EventInfo`](#eventinfo) (line 19), + [`ActivityDTO`](group-17-conference-domain.md#activitydto), + [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector) from + `MMCA.ADC.Conference.Shared.Events`, [`ConferenceRoutePaths`](#conferenceroutepaths), and + [`ErrorMessages`](group-15-common-ui-framework.md#errormessages). Externals: Blazor + (`[Inject]`, `NavigationManager`), MudBlazor (`MudForm`, `ISnackbar`, `BreadcrumbItem`, + `MudDatePicker` / `MudTimePicker`), and the `IStringLocalizer` injected by the template + (`.../Pages/Activity/ActivityCreate.razor:5`). The `UnsavedChangesGuard` component from `MMCA.Common.UI` + is wired in the markup (`.../Pages/Activity/ActivityCreate.razor:9`). +- **Concept introduced, the split date/time picker pair and its out-of-band validation.** Every other + create form in this group binds one control per DTO property and lets `MudForm` validate it. An activity + carries two `DateTime` values that no single MudBlazor control captures well, so the page keeps **four** + backing fields (`_startDate`, `_startTime`, `_endDate`, `_endTime`, lines 75-78) and recombines them in + `TryBuildSchedule` (lines 116-144): a missing date or time yields `Error.StartRequired` or + `Error.EndRequired`, an end before the start yields `Error.EndBeforeStart`, and the message lands in + `_scheduleError` (lines 123, 129, 138) rather than in the form's own error list. The template renders + that string in its own `MudAlert` directly above the form's error summary + (`.../Pages/Activity/ActivityCreate.razor:78-81`, summary at `:83-95`), so a cross-field rule reads like + every other validation error to the user even though `MudForm` knows nothing about it. + `[Rubric §24, Forms, Validation & UX Safety]` assesses whether a form can express only legal input and + explains a rejection in place: the schedule rule runs before anything is posted (line 160), a failure + snackbars the specific message rather than a generic one (line 162), and `_isDirty` (lines 85, 88) drives + `UnsavedChangesGuard` so navigating away mid-edit prompts. + The page also uses the smart default the other event-scoped create forms share: rather than + auto-selecting an event only when exactly one exists, `OnInitializedAsync` seeds the picker with + [`CurrentEventSelector.SelectCurrentOrNext`](group-17-conference-domain.md#currenteventselector) + evaluated over each event's start date, end date and IANA time zone against `DateTime.UtcNow` + (lines 49-56). The `??=` means a value the organizer already picked is never overwritten. + One idea is genuinely new here: `ApplyEventDateDefaults` (lines 100-110) also seeds **both date pickers** + with the selected event's first day (`info.StartDate.ToDateTime(TimeOnly.MinValue)`, line 107), so the + organizer normally only picks the times, and it re-runs on every event change through `OnEventSelected` + (lines 90-94). Again `??=` (lines 108-109) protects dates the organizer already chose. +- **Walkthrough** + - `OnInitialized` (lines 28-37) builds the three breadcrumbs synchronously (Home, Activities, Create), + the last one `disabled: true` so it renders as the current page. + - `OnInitializedAsync` (lines 39-68) loads the event lookup through the cancellable `_cts.Token` + (line 45), resolves the default event (lines 49-56), then calls `ApplyEventDateDefaults` (line 58). + `OperationCanceledException` is swallowed as expected during disposal or an `InteractiveAuto` render + mode transition (in-code comment, lines 60-63; see + [ADR-056](https://ivanball.github.io/docs/adr/056-blazor-render-mode-strategy.html)); any other failure + is swallowed too, because the picker then renders empty and the required-field error guides the user + (comment, lines 65-67). + - `CreateActivityAsync` (lines 146-200) awaits `_form.ValidateAsync()`, re-checks `_eventId is null` + (line 154), then runs `TryBuildSchedule` (line 160). Only then does it build the + [`ActivityDTO`](group-17-conference-domain.md#activitydto) with `Id = default` (line 171), so the + server mints the key and the page reads it back from `created.Id`. It posts through `AddAsync` + (line 183), clears `_isDirty` **before** navigating (line 184), snackbars success, and routes to + `ConferenceRoutePaths.ActivityDetails(created.Id)` (line 186). `IsSaving` is cleared in the `finally` + (line 198), which is what re-enables the submit button after a failure. + - `NavigateToList` (line 202) and the standard `Dispose(bool)` / `Dispose` pair (lines 206-226) close the + page out: the `_cts` is cancelled and disposed exactly once, guarded by `_disposed`. +- **Why it's built this way**: an activity belongs to exactly one event and its schedule is a window, not a + point, so the form's job is to make the window easy to enter and impossible to invert. Defaulting the + event and both dates from the selected conference removes the two most common clicks without hiding + either field, and keeping the cross-field check in the code-behind lets one message name the actual + problem (a missing part vs an inverted window) instead of a generic "invalid form". +- **Where it's used**: the `/activities/create` organizer route + (`.../Pages/Activity/ActivityCreate.razor:1-2`, `Authorize(Roles = "Organizer")`), reached from + [`ActivityList`](#activitylist)'s create button; on success it hands off to + [`ActivityDetail`](#activitydetail). +- **Caveats / not-in-source**: `TryBuildSchedule` composes local `DateTime` values from the pickers and + does no time-zone conversion of its own, even though the event's `TimeZone` is available on + [`EventInfo`](#eventinfo). How the resulting `StartTime` and `EndTime` are interpreted downstream is + decided by the command handler, not by this page. + +### ActivityDetail + +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Activity` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Activity/ActivityDetail.razor.cs:16` · Level 9 · class (Blazor code-behind) + +- **What it is**: the organizer's activity record page: load one activity by route id, inline-edit every + field **except** the owning event, and delete with confirmation. The class doc (lines 11-15) states the + rule the page enforces: moving an activity between events is a create plus a delete, so the event is + displayed but never edited here. +- **Depends on**: [`IActivityUIService`](#iactivityuiservice) (line 20), + [`IEventLookupService`](#ieventlookupservice) returning [`EventInfo`](#eventinfo) (line 21), + [`ActivityDTO`](group-17-conference-domain.md#activitydto), + [`ConferenceRoutePaths`](#conferenceroutepaths), + [`ErrorMessages`](group-15-common-ui-framework.md#errormessages), and the `DeleteConfirmation` component + from `MMCA.Common.UI` (line 78). Externals: Blazor (`[Parameter]`, `NavigationManager`), MudBlazor + (`MudForm`, `ISnackbar`), `System.Globalization`, and the `IStringLocalizer` from the + template (`.../Pages/Activity/ActivityDetail.razor:5`). +- **Concept introduced, culture-formatted display on a shadow-field edit form.** The page reuses the + detail-page shape [`SessionDetail`](#sessiondetail) teaches (load-once guard on `_loadedId` + lines 82-91, `_edit*` shadow fields lines 64-74, `RowVersion` round-trip line 210, confirm-then-delete + lines 242-269, cancel-on-disposal `_cts` at lines 27 and 275-295) and adds two wrinkles: + 1. **Two computed display properties.** `EventName` (lines 33-36) resolves the activity's `EventId` + against the event lookup and falls back to the invariant-culture id when the lookup is unavailable, so + the read-only event line never renders blank. `TimeRange` (lines 39-45) formats the window as a single + localized string by passing the start (`"f"`, full date and time) and the end (`"t"`, short time) + rendered in `CultureInfo.CurrentCulture` into the `Text.TimeRange` resource. The joining sentence + lives in the `.resx`, not in the code, so a locale can reorder or reword the range. + `[Rubric §27, Internationalization]` assesses whether user-visible text and formats follow the request + culture rather than the server's: here both the values and the sentence that joins them do (see + [ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html)). + 2. **The immutable field on an editable record.** `StartEditing` (lines 121-141) seeds shadow fields for + the name, sort order, the four date and time parts, the description and the three venue fields, but + **not** the event; the update DTO re-sends `EventId = Activity.EventId` unchanged (line 219). Making + the field un-editable in the form is what enforces the "a move is a create plus a delete" rule + client-side. `[Rubric §24, Forms, Validation & UX Safety]`. + The schedule rule from [`ActivityCreate`](#activitycreate) reappears here as a second `TryBuildSchedule` + over the `_edit*` fields (lines 154-182), with the same three localized error keys, so the create and + edit paths validate a window identically. They are two copies of the same method rather than one shared + helper, which is the maintenance cost of keeping each page self-contained + (`[Rubric §16, Maintainability]`). + `[Rubric §8, Data Architecture]`: the update carries the loaded `RowVersion` (line 210), so a concurrent + edit is detected server-side rather than silently overwritten (see + [ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html)). +- **Walkthrough** + - `OnParametersSetAsync` (lines 82-91) returns early when `Id == _loadedId`, otherwise records the id and + calls `LoadAsync`; that guard is what stops a re-render from re-fetching. + - `LoadAsync` (lines 93-119) fetches with `GetByIdAsync(Id, true, _cts.Token)` (line 98), snackbars + `ErrorMessages.NotFound` and bails when the record is missing (lines 99-103), then hydrates the event + lookup lazily with `??=` (line 105). Cancellation is swallowed; any other failure snackbars + `ErrorMessages.LoadError`; the `finally` clears `IsLoading`, which the template uses to switch between + `PageLoadingState`, `PageErrorState` and the record (`.../Pages/Activity/ActivityDetail.razor:14-21`). + - `StartEditing` / `CancelEditing` (lines 121-148) enter and leave edit mode, clearing `_scheduleError` + and `_isDirty` on both paths, so neither a stale cross-field error nor the unsaved-changes guard can + fire after a cancel. + - `SaveChangesAsync` (lines 184-240) validates the `MudForm`, runs `TryBuildSchedule`, rebuilds the + [`ActivityDTO`](group-17-conference-domain.md#activitydto) from the shadow fields plus the preserved + `Id`, `RowVersion` and `EventId` (lines 207-220), calls `UpdateAsync`, then **re-fetches** the record + (line 223) so the page shows the server's version including the new `RowVersion`, and finally clears + `_isDirty` and exits edit mode. + - `DeleteActivityAsync` (lines 242-269) confirms through `_deleteConfirm.ShowAsync(Activity.Name)` + (line 249), returns unless the answer is exactly `true` (line 250, so a dismissed dialog is not a + delete), deletes, snackbars, and navigates back to the list. +- **Why it's built this way**: an activity is a per-event programme item, so its name, window, order and + venue are freely editable while its owning event is not. Keeping that constraint in the form (no shadow + field, DTO re-sends the loaded value) means the page cannot even express the illegal update, and the + re-fetch after save keeps the concurrency token current for the next edit. +- **Where it's used**: the `/activities/{Id:int}` organizer route + (`.../Pages/Activity/ActivityDetail.razor:1-2`), reached from [`ActivityList`](#activitylist) rows and + from [`ActivityCreate`](#activitycreate)'s success redirect. It edits the same aggregate the + [`Activity`](group-17-conference-domain.md#activity) entity models. + +### ActivityList + +> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Activity` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Activity/ActivityList.razor.cs:19` · Level 9 · class (Blazor code-behind) + +- **What it is**: the organizer browse page for activities: a server-paged `MudDataGrid` with a name search, + start-time, venue and display-order columns, an event filter, a mobile card layout, and + delete-with-confirmation. +- **Depends on**: extends + [`DataGridListPageBase`](group-15-common-ui-framework.md#datagridlistpagebasetdto) closed over + [`ActivityDTO`](group-17-conference-domain.md#activitydto) (line 19), and injects + [`IActivityUIService`](#iactivityuiservice) and [`IEventLookupService`](#ieventlookupservice) + (lines 24-25). It uses [`EventInfo`](#eventinfo), + [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector), + [`ConferenceRoutePaths`](#conferenceroutepaths), + [`ErrorMessages`](group-15-common-ui-framework.md#errormessages), + [`ListPageActions`](group-24-identity-module.md#listpageactions) (the shared reload and + delete-with-confirmation helpers, lines 116 and 170), and the + [`MobileInfiniteScrollList`](group-15-common-ui-framework.md#mobileinfinitescrolllisttitem) plus + `DeleteConfirmation` components from `MMCA.Common.UI`. +- **Concept introduced, a chronological mobile list over the same filter set.** `ActivityList` follows the + event-filtered list shape that [`RoomList`](#roomlist) and [`SponsorList`](#sponsorlist) establish: + 1. **Persisted filters with an `"all"` sentinel** (`SaveFilters` lines 44-52, `RestoreFilters` + lines 54-71): the sentinel distinguishes an explicit "show every event" from *no saved state*, which + is what lets the computed default apply on a first visit only. + 2. **A computed default**: `ResolveDefaultEventFilter` (lines 95-113) keeps a restored id that still + exists in the lookup and otherwise falls back to + [`CurrentEventSelector.SelectCurrentOrNext`](group-17-conference-domain.md#currenteventselector) + (lines 104-111), so a dangling saved id shows the current conference rather than an empty grid. + 3. **The startup race guard**: `OnInitializedAsync` assigns `_eventsLoadTask` before its first `await` + (lines 73-79), and both `LoadServerData` (lines 135-136) and `FetchMobilePage` (lines 155-156) await + that task before applying filters, because the grid's first `ServerData` call can race ahead of + initialization and `ApplyFilters` runs *inside* the base class's `LoadServerDataAsync` (in-code + comment, lines 133-134). + What is specific to activities is the **mobile sort**: the desktop grid opens on name ascending + (`.../Pages/Activity/ActivityList.razor:72`) and lets the user re-sort any sortable column, but + `FetchMobilePage` pins `"StartTime", "asc"` (line 163) because, as the in-code comment says, the + programme reads chronologically (line 162). The two layouts therefore share filters and a data contract + but not ordering. + `[Rubric §19, State Management & Data Flow]` (assesses where view state lives and how it is restored): + filter state is saved, restored, defaulted and reconciled against the live event set in one method, while + the grid's page and page size live in the base class's `CurrentPageState` and `RowsPerPageState` + (`MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:57`, + `MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:67`). + `[Rubric §23, Front-End Performance & Rendering]` (assesses work pushed off the client): paging, + searching, sorting and filtering all happen server-side, the event lookup is fetched once and reused, and + the search box debounces at 300 ms (`.../Pages/Activity/ActivityList.razor:23`). + `[Rubric §22, Responsive & Cross-Browser]`: one DTO feeds both the desktop grid and the mobile + infinite-scroll list, switched on the base class's `IsMobile` + (`.../Pages/Activity/ActivityList.razor:39-57`), and the venue and sort columns carry + `hide-below-desktop` classes so the grid sheds columns before it scrolls + (`.../Pages/Activity/ActivityList.razor:47-48`, `.../Pages/Activity/ActivityList.razor:60-61`). + `[Rubric §21, Accessibility]`: the cancel-load button and both delete buttons carry localized + `aria-label`s (`.../Pages/Activity/ActivityList.razor:13,54,104`). +- **Walkthrough** + - `LoadEventsAndResolveDefaultAsync` (lines 81-93) loads the lookup, swallows a failure as non-critical + (the picker stays hidden and the default filter stays unset, comment lines 88-90), then resolves the + default. The picker itself only renders when more than one event exists + (`.../Pages/Activity/ActivityList.razor:25`). + - `LoadServerData` (lines 131-143) awaits the events task and delegates to the base's + `LoadServerDataAsync`, handing it the paged fetch delegate and `ApplyFilters`. + `ApplyFilters` (lines 145-151) emits `Name contains` and `EventId equals` server filters, adding each + only when set; `EventId` is a real Activity column + (`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Activities/ActivityDTO.cs:43`), so the + filter goes straight through the generic filter pipeline with no join-based resolution on the server + (class doc, lines 13-18). + - `FetchMobilePage` (lines 154-164) is the parallel mobile path: same filters, fixed `StartTime asc` sort; + `OnMobileCardClick` (line 166) routes a card tap to the detail page. + - `OnSearchChanged` and `OnEventFilterChanged` (lines 118-129) update state then call + `ReloadActiveLayoutAsync`, which routes to the grid or the infinite list through + [`ListPageActions`](group-24-identity-module.md#listpageactions) (lines 115-116). + - `DeleteActivityAsync` (lines 169-177) is the whole delete flow expressed as one call to + `ListPageActions.DeleteWithConfirmationAsync`, passing the dialog, the display name, the delete + delegate, the snackbar, the success message, an error formatter and the reload callback. + - `RetryLoadAsync` (line 32) re-runs the grid fetch from the base class's inline error state + (`LoadFailed`, surfaced by `ListNoRecordsContent` at + `.../Pages/Activity/ActivityList.razor:109`), so a transient failure does not require a page reload. + - `FormatStartTime` (line 42) renders the start as short date plus short time in + `CultureInfo.CurrentCulture`, and is shared by the grid cell and the mobile card. +- **Why it's built this way**: activities are browsed per conference, so the list defaults to the current or + next event exactly like the room and sponsor lists. Because `EventId` is a first-class column, the page + needs none of the virtual-filter machinery the speaker list requires, and the only real decision left is + which order each layout opens in: alphabetical where the user can re-sort, chronological where a thumb + scrolls a programme. +- **Where it's used**: the `/activities` organizer route + (`.../Pages/Activity/ActivityList.razor:1-2`, `Authorize(Roles = "Organizer")`); the name cell links to + [`ActivityDetail`](#activitydetail) and the create button to [`ActivityCreate`](#activitycreate). + ### SponsorCreate > MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Sponsor` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorCreate.razor.cs:15` · Level 9 · class (Blazor code-behind) -- **What it is**: the organizer form that creates a sponsorship record: name, tier, owning event, - branding links (logo, website, LinkedIn, X handle), sort order, and the optional expo-booth details. The - event picker is required, because sponsorships are sold per event and the owning event cannot be changed +- **What it is**: the organizer form that creates a sponsorship record: name, tier, owning event, branding + links (logo, website, LinkedIn, X handle), sort order, and the optional expo-booth details. The event + picker is required, because sponsorships are sold per event and the owning event cannot be changed afterwards (class doc, lines 10-14). - **Depends on**: [`ISponsorUIService`](#isponsoruiservice) (line 20), [`IEventLookupService`](#ieventlookupservice) returning [`EventInfo`](#eventinfo) (line 21), @@ -2485,7 +3295,8 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod lookup and falls back to the invariant-culture id. `[Rubric §24, Forms, Validation & UX Safety]`: making the field un-editable in the form is what enforces the "a move is a create plus a delete" rule client-side. `[Rubric §8, Data Architecture]`: the update also carries the loaded `RowVersion` - (line 163), so a concurrent edit is detectable server-side. + (line 163), so a concurrent edit is detectable server-side (see + [ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html)). - **Walkthrough** - `OnParametersSetAsync` (lines 77-86) returns early when `Id == _loadedId`, otherwise records the id and calls `LoadAsync`. @@ -2580,79 +3391,6 @@ Two registration types wire the area in. [`ConferenceUIModule`](#conferenceuimod (`.../Pages/Sponsor/SponsorList.razor:1-2`, `Authorize(Roles = "Organizer")`); rows navigate to [`SponsorDetail`](#sponsordetail) and the create button to [`SponsorCreate`](#sponsorcreate). -### SessionList - -> MMCA.ADC.Conference.UI · `MMCA.ADC.Conference.UI.Pages.Session` · `MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Session/SessionList.razor.cs:18` · Level 10 · class (Blazor code-behind) - -- **What it is**: the organizer browse page for sessions and the richest list in the Conference UI. It - carries three filters (free-text title search, session status, and event), enriches each row with room - and speaker names, and color-codes the Sessionize status. It sits at the top of the group's dependency - order because it transitively pulls in the most lookups and defaults. -- **Depends on**: extends - [`DataGridListPageBase`](group-15-common-ui-framework.md#datagridlistpagebasetdto) (line 18) and - injects [`ISessionUIService`](#isessionuiservice), [`IEventUIService`](#ieventuiservice), and - [`ISpeakerLookupService`](#ispeakerlookupservice) (lines 23-25). It uses - [`SessionDTO`](group-17-conference-domain.md#sessiondto), - [`EventDTO`](group-17-conference-domain.md#eventdto), [`SpeakerInfo`](#speakerinfo), - [`CurrentEventDefaults`](group-17-conference-domain.md#currenteventdefaults) (the `EventDTO`-typed - wrapper over [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector)), - [`ConferenceRoutePaths`](#conferenceroutepaths), - [`ErrorMessages`](group-15-common-ui-framework.md#errormessages), - [`ListPageActions`](group-24-identity-module.md#listpageactions), and the - [`MobileInfiniteScrollList`](group-15-common-ui-framework.md#mobileinfinitescrolllisttitem) plus - `DeleteConfirmation` components. Uses the `Event`/`Room`/`Session`/`Speaker` aliases. -- **Concept introduced, the multi-filter enriched list.** `SessionList` layers three refinements on the - event-filtered shape [`SponsorList`](#sponsorlist), [`RoomList`](#roomlist), and - [`SpeakerList`](#speakerlist) share: - 1. **A third filter**: `_searchString`, `_selectedStatus`, and `_selectedEventId` persist together - (`SaveFilters` lines 44-53, `RestoreFilters` lines 55-73) and are emitted as `Title contains`, - `Status equals`, and `EventId equals` server filters (`ApplyFilters`, lines 208-216). - 2. **Enrichment from two bulk loads instead of per-row fetches**: - `LoadEventsAndResolveDefaultAsync` fetches events with `includeChildren: true` (line 98) and folds - every event's rooms into one `_roomNames` dictionary (`PopulateRoomNames`, lines 124-138), while the - speaker lookup loaded in `OnInitializedAsync` (line 84) backs `GetSpeakerList` (lines 140-148), which - maps a row's `SessionSpeakers` to display names and skips ids the lookup does not know. Both loads - are wrapped in best-effort catches whose comments say the fallback is dash display, not a broken - page (lines 86-89, 102-105). The paged fetch itself also passes `includeChildren: true` (lines 193, - 205) so each row arrives with its speaker joins. - 3. **Status color coding**: `GetStatusColor` (lines 150-159) maps the Sessionize status strings - `Accepted`, `Waitlisted`, `Accept_Queue`, `Nominated`, `Decline_Queue`, and `Declined` to MudBlazor - colors, defaulting to `Color.Default` for anything else. - The startup race guard is the same one the sibling lists use, with the clearest explanation in this - file: `_eventsLoadTask` is started before the first `await` (lines 77-80) and awaited inside both - `LoadServerData` (lines 187-188) and `FetchMobilePage` (lines 200-201), because `ApplyFilters` runs - inside `LoadServerDataAsync`, so the default event must be resolved before entering it, "not merely - before the fetch delegate runs" (in-code comment, lines 185-186). - `[Rubric §18, UI Architecture & Component Design]`: the status filter surfaces the program-committee - workflow inline instead of hiding it behind a separate screen. - `[Rubric §23, Front-End Performance & Rendering]`: one children-loaded events fetch plus one speaker - lookup replace what would otherwise be per-row enrichment calls. - `[Rubric §25, Navigation & Information Architecture]`: all three filters survive navigation through the - base class's persistence contract, with the same `"all"` sentinel and computed default. -- **Walkthrough** - - `OnInitializedAsync` (lines 75-92): start the events task, load the speaker lookup (tolerating - failure), then await the events task. - - `ResolveDefaultEventFilter` (lines 110-122): keep a restored id that still exists in `_events`, - otherwise take `CurrentEventDefaults.SelectCurrentOrNext(_events, DateTime.UtcNow)?.Id` (line 120). - - `OnSearchChanged`, `OnStatusChanged`, and `OnEventFilterChanged` (lines 164-181) each update one - filter and reload whichever layout is active via - [`ListPageActions`](group-24-identity-module.md#listpageactions)`.ReloadActiveLayoutAsync` - (lines 161-162). - - `LoadServerData` (lines 183-195) and `FetchMobilePage` (lines 198-206) are the desktop and mobile - fetch paths over the same `ApplyFilters`. - - `DeleteSessionAsync` (lines 221-229) delegates the confirm, delete, snackbar, and reload sequence to - `ListPageActions.DeleteWithConfirmationAsync`; `NavigateToCreate` and `NavigateToDetails` (lines - 231-232) route to [`SessionCreate`](#sessioncreate) and [`SessionDetail`](#sessiondetail). -- **Why it's built this way**: sessions are the central editable entity of the program, so the list has to - answer "what is in this conference, in what state, presented by whom" at a glance; defaulting to the - active event and enriching from two bulk loads keeps that view both relevant and cheap. -- **Where it's used**: the `/sessions` organizer route - (`.../Pages/Session/SessionList.razor:1-2`, `Authorize(Roles = "Organizer")`); rows open - [`SessionDetail`](#sessiondetail) and the create button opens [`SessionCreate`](#sessioncreate). -- **Caveats / not-in-source**: the page builds speaker names from its own lookup rather than trusting the - paged payload alone, so it degrades to a dash rather than a wrong name when a speaker id is unknown; - how the paged endpoint populates `SessionSpeakers` is a server-side concern outside this file. - --- [⬅ ADC Conference - API, gRPC Contracts & Service Host](group-20-conference-api-grpc.md) • [Index](00-index.md) • [ADC Engagement Module (Session Bookmarks) ➡](group-22-engagement-module.md) diff --git a/docs-src/onboarding/group-22-engagement-module.md b/docs-src/onboarding/group-22-engagement-module.md index f00dcb9..e530b70 100644 --- a/docs-src/onboarding/group-22-engagement-module.md +++ b/docs-src/onboarding/group-22-engagement-module.md @@ -2031,47 +2031,47 @@ ### AttendeeCheckedIn -> MMCA.ADC.Engagement.Shared · `MMCA.ADC.Engagement.Shared.CheckIns.IntegrationEvents` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/IntegrationEvents/AttendeeCheckedIn.cs:22` · Level 3 · record +> MMCA.ADC.Engagement.Shared · `MMCA.ADC.Engagement.Shared.CheckIns.IntegrationEvents` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/IntegrationEvents/AttendeeCheckedIn.cs:22` · 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`](group-04-events-outbox.md#baseintegrationevent) (the base it derives from, `AttendeeCheckedIn.cs:30`) and the module identifier aliases (`UserIdentifierType`, `EventIdentifierType`, `SessionIdentifierType`, `SponsorIdentifierType`). It names [`CheckInScopeNames`](#checkinscopenames) only in its documentation, never as a type in its signature. 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 two deliberate choices for that. First, `Scope` is a `string` (`AttendeeCheckedIn.cs:24`), not the [`CheckInScope`](#checkinscope) enum: the doc comment (`:10-13`) 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 (`:29`), with the doc comment (`:21`) recording that placement so a consumer keeps deserializing payloads written before sponsor visits existed. `[Rubric §6, CQRS & Event-Driven]` covers the delivery half: this is an integration event, not a domain event, so it does not dispatch in process. It is captured into the outbox with the row that produced it and published by the outbox processor ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)). `[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` (`:23`) is the attendee. `Scope` (`:24`) is one of the [`CheckInScopeNames`](#checkinscopenames) string constants. `EventId` (`:25`) is set for every scope, which is what lets a consumer bucket any check-in by conference without a lookup. `SessionId` (`:26`) is nullable and set only for a Session scope. `CheckedInByUserId` (`:27`) records who performed the check-in, an organizer for a scan and the attendee themselves for a self-recorded visit (`:19`), which is what keeps self-recorded rows distinguishable downstream. `CheckedInOn` (`:28`) is the recorded instant. `SponsorId` (`:29`) is the trailing optional member. - **Why it's built this way**: the doc comment (`:6-9`) ties the event to the aggregate factory: it is added inside [`CheckIn`](#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. See [ADR-072](https://ivanball.github.io/docs/adr/072-qr-badge-check-in-and-points.html) for the surrounding badge-and-points decision. -- **Where it's used**: raised by [`CheckIn`](#checkin) (`CheckIn.cs:112`), consumed by [`AttendeeCheckedInPointsHandler`](#attendeecheckedinpointshandler). The Engagement service subscribes its own receive endpoint to this type (`MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:299`), which the surrounding comment (`:287-293`) calls out as ADC's first broker self-consumption: the message leaves this process through the broker and comes back to it. +- **Where it's used**: raised by [`CheckIn`](#checkin) (`CheckIn.cs:112`), consumed by [`AttendeeCheckedInPointsHandler`](#attendeecheckedinpointshandler). The Engagement service subscribes its own receive endpoint to this type (`MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:305`), which the surrounding comment (`:293-299`) calls out as ADC's first broker self-consumption: the message leaves this process through the broker and comes back to it. --- ### 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 +> 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`](#livechannelpublishqueue) and forwards each queued broadcast to [`ILiveChannelPublisher`](group-10-notifications.md#ilivechannelpublisher). It is the piece that keeps live-layer broadcasts (poll opened, results changed, question approved) off the command request path. - **Depends on**: [`LiveChannelPublishQueue`](#livechannelpublishqueue) (the concrete queue, injected as itself for its `Reader`, `LiveChannelPublishProcessor.cs:31`), [`ILiveChannelPublisher`](group-10-notifications.md#ilivechannelpublisher) resolved per item (`:51`), and [`BestEffort`](group-03-querying-specifications.md#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](https://ivanball.github.io/docs/adr/039-live-channel-push.html)), 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 (`:16-19`). `[Rubric §13, Observability & Operability]` covers the diagnostics, and this is where the class has moved on from a hand-rolled `catch`: the swallow is delegated to [`BestEffort`](group-03-querying-specifications.md#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 (`LiveChannelPublishQueue.cs:61-70`). `[Rubric §10, Cross-Cutting Concerns]` 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`). +- **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](https://ivanball.github.io/docs/adr/039-live-channel-push.html)), 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 (`:16-19`). `[Rubric §13, Observability & Operability]` covers the diagnostics: the swallow is delegated to [`BestEffort`](group-03-querying-specifications.md#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 (`LiveChannelPublishQueue.cs:61-70`). `[Rubric §10, Cross-Cutting Concerns]` 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`, no longer `partial`: the logging it used to generate itself now lives inside `BestEffort`. + - 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 (`:23-27`) 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:13-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 still separated from failure, but the split is now 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. This worker's job is only to be the one reader that gives that channel its ordering guarantee, and, since the move to `BestEffort`, 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 (`MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:263`, [ADR-012](https://ivanball.github.io/docs/adr/012-grpc-host-transport.html)). Covered by [`LiveChannelPublishProcessorTests`](group-27-testing-infrastructure.md#livechannelpublishprocessortests). + - 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. 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](https://ivanball.github.io/docs/adr/012-grpc-host-transport.html)). Covered by [`LiveChannelPublishProcessorTests`](group-27-testing-infrastructure.md#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. --- ### CheckInsController -> MMCA.ADC.Engagement.API · `MMCA.ADC.Engagement.API.Controllers` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/CheckInsController.cs:35` · Level 4 · class +> MMCA.ADC.Engagement.API · `MMCA.ADC.Engagement.API.Controllers` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/CheckInsController.cs:35` · Level 4 · 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`](group-12-api-hosting-mapping.md#apicontrollerbase) (for `HandleFailure`), six injected handlers over [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) and [`IQueryHandler`](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) (`CheckInsController.cs:36-41`), the request and result contracts [`CheckInAttendeeRequest`](#checkinattendeerequest), [`ManualCheckInRequest`](#manualcheckinrequest), [`SponsorVisitRequest`](#sponsorvisitrequest), [`RoomCheckInRequest`](#roomcheckinrequest), [`CheckInResultDTO`](#checkinresultdto), [`SponsorVisitResultDTO`](#sponsorvisitresultdto), [`RoomCheckInResultDTO`](#roomcheckinresultdto), [`MyBadgeDTO`](#mybadgedto), [`AttendanceStatsDTO`](#attendancestatsdto), the use-case types [`GetOrCreateMyBadgeCommand`](#getorcreatemybadgecommand) and [`GetAttendanceStatsQuery`](#getattendancestatsquery), plus [`IdempotentAttribute`](group-12-api-hosting-mapping.md#idempotentattribute), [`EngagementFeatures`](#engagementfeatures), [`EngagementPermissions`](#engagementpermissions), [`AuthorizationPolicies`](group-08-auth.md#authorizationpolicies) and [`Result`](group-01-result-error-handling.md#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(Policy = AuthorizationPolicies.RequireAuthenticated)]` (`:34`) is the floor. 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 §10, Cross-Cutting Concerns]` 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](https://ivanball.github.io/docs/adr/031-feature-flag-management.html)). -- **Concept introduced, idempotency layered onto an already-repeatable endpoint.** Every one of the four POSTs now carries `[Idempotent]` (`:74`, `:98`, `:129`, `:160`), the framework's replay cache ([ADR-017](https://ivanball.github.io/docs/adr/017-request-idempotency.html), [`IdempotentAttribute`](group-12-api-hosting-mapping.md#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-71`), so a replayed response says exactly what a re-executed one would; for sponsor visits it is `AlreadyVisited` (`:124-126`); for room check-in the comment adds the load-bearing extra clause, that the session is resolved server-side (`:154-157`), 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. +- **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](https://ivanball.github.io/docs/adr/017-request-idempotency.html), [`IdempotentAttribute`](group-12-api-hosting-mapping.md#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-71`), so a replayed response says exactly what a re-executed one would; for sponsor visits it is `AlreadyVisited` (`:124-126`); for room check-in the comment adds the load-bearing extra clause, that the session is resolved server-side (`:154-157`), 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`](#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`](#checkinattendeerequest) to [`CheckInAttendeeHandler`](#checkinattendeehandler). Declares 400, 403 and 404 alongside the 200 (`:76-79`). + - `CheckInAsync` (`:80`), `POST`: the organizer scan path, dispatching [`CheckInAttendeeRequest`](#checkinattendeerequest) to [`CheckInAttendeeHandler`](#checkinattendeehandler). Declares 400, 403 and 404 alongside the 200 (`:77-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`). @@ -2085,24 +2085,24 @@ ### EventFeedbackSubmittedPointsHandler -> MMCA.ADC.Engagement.Application · `MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/EventFeedbackSubmittedPointsHandler.cs:26` · Level 4 · class +> MMCA.ADC.Engagement.Application · `MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/EventFeedbackSubmittedPointsHandler.cs:26` · Level 4 · class (sealed partial) -- **What it is**: the award adapter that turns Conference's [`EventFeedbackSubmitted`](group-17-conference-domain.md#eventfeedbacksubmitted) into an event-scoped points award. It is one of four handlers in this folder and the simplest of them. +- **What it is**: the award adapter that turns Conference's [`EventFeedbackSubmitted`](group-17-conference-domain.md#eventfeedbacksubmitted) into an event-scoped points award. It is one of the handlers in this folder and the simplest of them. - **Depends on**: [`IIntegrationEventHandler`](group-04-events-outbox.md#iintegrationeventhandlerin-tintegrationevent) (implemented over [`EventFeedbackSubmitted`](group-17-conference-domain.md#eventfeedbacksubmitted), `:28`), [`IPointsAwarder`](#ipointsawarder) (resolved per event, `:36`), [`PointsSubjectKeys`](#pointssubjectkeys) (`:38`) and [`PointsActivityType`](#pointsactivitytype) (`:42`). Externals: `IServiceScopeFactory` and a source-generated `[LoggerMessage]` (`:51-52`). - **Concept introduced, the thin award adapter.** `[Rubric §3, Clean Architecture]` assesses where knowledge sits. The rule the module protects is stated on [`IPointsAwarder`](#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 §6, CQRS & Event-Driven]` covers the delivery posture: at-least-once means redelivery is normal, so the handler is written to be safely repeatable rather than to guard against a second call. `[Rubric §1, SOLID]` shows up as the single-responsibility split: mapping lives here, idempotency and the per-rule kill switch live once in the awarder. - **Walkthrough**: `HandleAsync` (`:31`) null-guards the event (`:33`), opens one async DI scope (`:35`) and resolves the scoped [`IPointsAwarder`](#ipointsawarder) from it (`:36`), because the handler itself is registered as a singleton (see below). It builds the subject key with `PointsSubjectKeys.ForEvent(integrationEvent.EventId)` (`:38`), which produces the invariant-culture `event:{id}` string (`PointsSubjectKeys.cs:18-19`), then awards `PointsActivityType.EventFeedback` at the event's own `SubmittedOnUtc` (`:40-45`). A failed award is logged at warning (`:47-48`) and not rethrown, 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`). - **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:82`), which reaches MMCA.Common's singleton registration for every `IIntegrationEventHandler<>` implementation (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:143-147`); the singleton lifetime is exactly why the handler opens its own scope (`:19-22`). The Engagement service subscribes the matching consumer (`MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:301`). Covered by [`EventFeedbackSubmittedPointsHandlerTests`](group-27-testing-infrastructure.md#eventfeedbacksubmittedpointshandlertests). +- **Where it's used**: registered by the convention scan (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:87`), which reaches MMCA.Common's singleton registration for every `IIntegrationEventHandler<>` implementation (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:151-155`); the singleton lifetime is exactly why the handler opens its own scope (`:19-22`). The Engagement service subscribes the matching consumer (`MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:307`). Covered by [`EventFeedbackSubmittedPointsHandlerTests`](group-27-testing-infrastructure.md#eventfeedbacksubmittedpointshandlertests). --- ### PointsController -> MMCA.ADC.Engagement.API · `MMCA.ADC.Engagement.API.Controllers` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/PointsController.cs:36` · Level 4 · class +> MMCA.ADC.Engagement.API · `MMCA.ADC.Engagement.API.Controllers` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/PointsController.cs:36` · Level 4 · 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`](group-12-api-hosting-mapping.md#apicontrollerbase), four handlers over [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) / [`IQueryHandler`](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) (`PointsController.cs:37-40`), the queries [`GetMyPointsQuery`](#getmypointsquery), [`GetLeaderboardQuery`](#getleaderboardquery) and [`GetPointsOverviewQuery`](#getpointsoverviewquery), the request [`SetLeaderboardParticipationRequest`](#setleaderboardparticipationrequest), the contracts [`MyPointsDTO`](#mypointsdto), [`LeaderboardEntryDTO`](#leaderboardentrydto) and [`PointsOverviewDTO`](#pointsoverviewdto), plus [`EngagementFeatures`](#engagementfeatures), [`EngagementPermissions`](#engagementpermissions), [`AuthorizationPolicies`](group-08-auth.md#authorizationpolicies) and [`Result`](group-01-result-error-handling.md#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`](#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 (`:25-28`) 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 (`:70-73`), 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 (`:117-118`). `[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 (`:46-47`). +- **Concept introduced, the surface with no ownership argument.** `[Rubric §11, Security]` assesses how ownership is enforced per endpoint. Contrast this controller with [`BookmarksController`](#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 (`:45-47`). - **Walkthrough** (endpoints in file order) - `GetMyPointsAsync` (`:53`), `GET me`: dispatches [`GetMyPointsQuery`](#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`](#getleaderboardquery) (`:82`). The board length is fixed by configuration (`Points:LeaderboardSize`), not by the caller (`:72-73`). @@ -2117,34 +2117,56 @@ ### SessionFeedbackSubmittedPointsHandler -> MMCA.ADC.Engagement.Application · `MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/SessionFeedbackSubmittedPointsHandler.cs:28` · Level 4 · class +> MMCA.ADC.Engagement.Application · `MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/SessionFeedbackSubmittedPointsHandler.cs:28` · Level 4 · class (sealed partial) - **What it is**: the award adapter for session feedback, mapping Conference's [`SessionFeedbackSubmitted`](group-17-conference-domain.md#sessionfeedbacksubmitted) onto a session-scoped award. The structural twin of [`EventFeedbackSubmittedPointsHandler`](#eventfeedbacksubmittedpointshandler). - **Depends on**: the same set as its sibling, with [`SessionFeedbackSubmitted`](group-17-conference-domain.md#sessionfeedbacksubmitted) as the handled type (`:30`) and [`IPointsAwarder`](#ipointsawarder) resolved per event (`:38`). - **Concept**: the thin award adapter is taught on [`EventFeedbackSubmittedPointsHandler`](#eventfeedbacksubmittedpointshandler); the same reasoning applies unchanged. `[Rubric §16, Maintainability]` 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 a shared base would add indirection to five lines of mapping. - **Walkthrough of what differs**: only two lines. The subject key is built with `PointsSubjectKeys.ForSession(integrationEvent.SessionId)` (`:40`), producing `session:{id}` (`PointsSubjectKeys.cs:24-25`), and the activity is `PointsActivityType.SessionFeedback` (`:44`). Everything else, the null guard (`:35`), the per-event scope (`:37-38`), the `SubmittedOnUtc` timestamp (`:46`) and the log-and-continue failure path (`:49-50`), 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:82`, `MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:143-147`); the consumer is wired at `MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:300`. Covered by [`SessionFeedbackSubmittedPointsHandlerTests`](group-27-testing-infrastructure.md#sessionfeedbacksubmittedpointshandlertests). +- **Where it's used**: registered by the convention scan as a singleton (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:87`, `MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:151-155`); the consumer is wired at `MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:306`. Covered by [`SessionFeedbackSubmittedPointsHandlerTests`](group-27-testing-infrastructure.md#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`](group-04-events-outbox.md#idomaineventhandlerin-tdomainevent) implemented over [`SessionQuestionChanged`](group-23-engagement-live-layer.md#sessionquestionchanged) (`:53`), [`IPointsAwarder`](#ipointsawarder) resolved per event (`:78`), [`PointsActivityType`](#pointsactivitytype) (`:84`), [`PointsSubjectKeys`](#pointssubjectkeys) (`:85`) and [`DomainEntityState`](group-02-domain-building-blocks.md#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`](#userdeletedpointshandler), which is an integration-event handler and deliberately rethrows, 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 `:22-29` records the trap this file is written around: [`SessionQuestionChanged`](group-23-engagement-live-layer.md#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`](#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:143-148`), 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`](group-23-engagement-live-layer.md#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`](#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 points design as a whole is [ADR-072](https://ivanball.github.io/docs/adr/072-qr-badge-check-in-and-points.html). +- **Where it's used**: discovered and registered as a singleton by `ScanModuleApplicationServices()` (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:87`), and invoked by the framework's domain event dispatcher after the Q and A write path's `SaveChangesAsync`. Its raise site is [`SessionQuestion`](group-23-engagement-live-layer.md#sessionquestion)`.Create` (`SessionQuestion.cs:109`). Covered by [`SessionQuestionSubmittedPointsHandlerTests`](group-27-testing-infrastructure.md#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`](#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 +> 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`](group-04-events-outbox.md#idomaineventhandlerin-tdomainevent) implemented over [`UserSessionBookmarkChanged`](#usersessionbookmarkchanged) (`:46`), [`IEventBus`](group-04-events-outbox.md#ieventbus) resolved per event (`:74`), [`OutputCacheEvictionRequested`](group-04-events-outbox.md#outputcacheevictionrequested) (`:77`) and [`BestEffort`](group-03-querying-specifications.md#besteffort) (`:68`). Externals: `IServiceScopeFactory`, `ILogger`. -- **Concept introduced, evicting a cache you do not own.** `[Rubric §10, Cross-Cutting Concerns]` 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:9-14`). 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`](group-04-events-outbox.md#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:269`, and the reasoning at `:253-263`). `[Rubric §7, Microservices Readiness]` is why it has to be an event at all: under database-per-service ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)) 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`](group-03-querying-specifications.md#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:261-263`). -- **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`](group-05-cqrs-pipeline.md#deleteentitycommandtentity-tidentifiertype) and has no ADC handler at all to add a line to. [`UserSessionBookmarkChanged`](#usersessionbookmarkchanged) is raised by the aggregate itself on every path that moves a count (`UserSessionBookmark.cs:55` on create, `:71` on reactivate, `:86` 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. +- **Concept introduced, evicting a cache you do not own.** `[Rubric §10, Cross-Cutting Concerns]` 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`](group-04-events-outbox.md#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:270`, and the reasoning at `:254-263`). `[Rubric §7, Microservices Readiness]` is why it has to be an event at all: under database-per-service ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)) 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`](group-03-querying-specifications.md#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:262-263`). +- **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`](group-05-cqrs-pipeline.md#deleteentitycommandtentity-tidentifiertype) and has no ADC handler at all to add a line to. [`UserSessionBookmarkChanged`](#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 (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:243`, `:251`, `:263`). Nothing in the type system checks that agreement. + - `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 (`MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:243`, `:252`, `:264`). 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`](group-04-events-outbox.md#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](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)). - 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`](group-03-querying-specifications.md#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](https://ivanball.github.io/docs/adr/026-caching-strategy.html). -- **Where it's used**: registered as a singleton by the convention scan (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:82`, reaching `MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:136-140`). Its counterpart on the receiving side is [`OutputCacheEvictionHandler`](group-12-api-hosting-mapping.md#outputcacheevictionhandler), registered on the Conference host. Covered by [`UserSessionBookmarkCacheEvictionHandlerTests`](group-27-testing-infrastructure.md#usersessionbookmarkcacheevictionhandlertests). -- **Caveats / not-in-source**: the Conference host comment records that both halves are needed and that registering only one is a silent no-op (`Program.cs:266-268`); 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. +- **Where it's used**: registered as a singleton by the convention scan (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:87`, reaching `MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:143-148`). Its counterpart on the receiving side is [`OutputCacheEvictionHandler`](group-12-api-hosting-mapping.md#outputcacheevictionhandler), registered on the Conference host. Covered by [`UserSessionBookmarkCacheEvictionHandlerTests`](group-27-testing-infrastructure.md#usersessionbookmarkcacheevictionhandlertests). +- **Caveats / not-in-source**: the Conference host comment records that both halves are needed and that registering only one is a silent no-op (`Program.cs:267-269`); 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. --- @@ -2191,33 +2213,15 @@ - **What it is**: the one invariant rule for [`AttendeeBadge`](#attendeebadge): a badge must be bound to a real user. - **Depends on**: [`CommonInvariants`](group-02-domain-building-blocks.md#commoninvariants) (`AttendeeBadgeInvariants.cs:16`) and [`Result`](group-01-result-error-handling.md#result). - **Concept**: the static invariant class beside its aggregate is taught in [group 02](group-02-domain-building-blocks.md#commoninvariants). `[Rubric §4, DDD]` assesses whether invariants are stated where the model can enforce them: keeping them in a static class that the factory composes with `Result.Combine` 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 every caller as `nameof(Create)` (`AttendeeBadge.cs:42`), which is what puts the failing member into the error rather than a stack trace. +- **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`](#checkininvariants). - **Where it's used**: [`AttendeeBadge.Create`](#attendeebadge) (`AttendeeBadge.cs:42`) is the only caller in the module. --- -### 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`](#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`](group-02-domain-building-blocks.md#commoninvariants), [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error) (`Error.Invariant`, `:88`, `:93`, `:104`), plus [`CheckInScope`](#checkinscope) (`:45`). -- **Concept introduced, the discriminated-shape invariant.** `[Rubric §4, DDD]` 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 (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/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`) 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`) 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 four 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) (`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`). - ---- - ### 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 +> 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`](group-02-domain-building-blocks.md#auditableaggregaterootentitytidentifiertype) (the base, `:18`), [`AttendeeBadgeInvariants`](#attendeebadgeinvariants) (`:42`), [`Result`](group-01-result-error-handling.md#result), and [`IdValueGeneratedAttribute`](group-02-domain-building-blocks.md#idvaluegeneratedattribute) (`:17`), which tells the persistence layer the database assigns the id. Externals: `System.Guid`. @@ -2229,14 +2233,14 @@ - `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](https://ivanball.github.io/docs/adr/072-qr-badge-check-in-and-points.html), which also fixes the encoded form the scanner reads. -- **Where it's used**: minted on first use by [`GetOrCreateMyBadgeHandler`](#getorcreatemybadgehandler) (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/GetOrCreateMyBadge/GetOrCreateMyBadgeHandler.cs:40`) behind [`CheckInsController`](#checkinscontroller)'s `my-badge` endpoint; read back by [`CheckInAttendeeHandler`](#checkinattendeehandler) to resolve a scanned credential to an attendee (`.../CheckInAttendee/CheckInAttendeeHandler.cs:46-49`). Persisted by `AttendeeBadgeConfiguration` (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/AttendeeBadgeConfiguration.cs:15`) 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:75-77`) because it has no navigation. Covered by [`AttendeeBadgeTests`](group-27-testing-infrastructure.md#attendeebadgetests). +- **Where it's used**: minted on first use by [`GetOrCreateMyBadgeHandler`](#getorcreatemybadgehandler) (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/GetOrCreateMyBadge/GetOrCreateMyBadgeHandler.cs:40`) behind [`CheckInsController`](#checkinscontroller)'s `my-badge` endpoint; read back by [`CheckInAttendeeHandler`](#checkinattendeehandler) to resolve a scanned credential to an attendee (`.../CheckInAttendee/CheckInAttendeeHandler.cs:46`). Persisted by `AttendeeBadgeConfiguration` (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/AttendeeBadgeConfiguration.cs:15`) 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:80-82`) because it has no navigation. Covered by [`AttendeeBadgeTests`](group-27-testing-infrastructure.md#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. --- ### UserDeletedPointsHandler -> MMCA.ADC.Engagement.Application · `MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/UserDeletedPointsHandler.cs:36` · Level 8 · class +> MMCA.ADC.Engagement.Application · `MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/UserDeletedPointsHandler.cs:36` · 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**: [`IIntegrationEventHandler`](group-04-events-outbox.md#iintegrationeventhandlerin-tintegrationevent) over Identity's [`UserDeleted`](group-24-identity-module.md#userdeleted) (`:38`), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:48`), the [`IRepository`](group-07-persistence-ef-core.md#irepositorytentity-tidentifiertype) it obtains for [`LeaderboardOptIn`](#leaderboardoptin) (`:49`) and that aggregate's own `Delete()` / `EraseDisplayName()` members (`:67`, `:79`). Externals: `IServiceScopeFactory`, source-generated `[LoggerMessage]` (`:94-101`). @@ -2247,16 +2251,16 @@ - The loop (`:63-82`) 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 (`:65-75`). If the display name is not already erased it calls `EraseDisplayName()` (`:77-81`). Either action sets `changed`. - When nothing changed the handler logs and returns without a save (`:84-88`); otherwise it saves once for the whole batch and logs the row count (`:90-91`). - **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:82`, `MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:143-147`), with the consumer wired at `MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:302`; the surrounding comment (`:283-285`) records that this is the one consumer here that earns nothing. Covered by [`UserDeletedPointsHandlerTests`](group-27-testing-infrastructure.md#userdeletedpointshandlertests). +- **Where it's used**: registered as a singleton by the convention scan (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:87`, `MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:151-155`), with the consumer wired at `MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:308`; the surrounding comment (`:289-291`) records that this is the one consumer here that earns nothing. Covered by [`UserDeletedPointsHandlerTests`](group-27-testing-infrastructure.md#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`](#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:30` · Level 10 · class +> MMCA.ADC.Engagement.Application · `MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/AttendeeCheckedInPointsHandler.cs:30` · Level 10 · class (sealed partial) -- **What it is**: the award adapter for check-ins. It turns an [`AttendeeCheckedIn`](#attendeecheckedin) into the (activity, subject key) pair the ledger understands and hands it to [`IPointsAwarder`](#ipointsawarder). It is the richest of the four adapters because the incoming scope is a string that has to be mapped. +- **What it is**: the award adapter for check-ins. It turns an [`AttendeeCheckedIn`](#attendeecheckedin) into the (activity, subject key) pair the ledger understands and hands it to [`IPointsAwarder`](#ipointsawarder). It is the richest of the award adapters because the incoming scope is a string that has to be mapped. - **Depends on**: [`IIntegrationEventHandler`](group-04-events-outbox.md#iintegrationeventhandlerin-tintegrationevent) over [`AttendeeCheckedIn`](#attendeecheckedin) (`:32`), [`IPointsAwarder`](#ipointsawarder) (`:46`), [`CheckInScopeNames`](#checkinscopenames) (`:68`, `:75`, `:86`), [`PointsActivityType`](#pointsactivitytype) and [`PointsSubjectKeys`](#pointssubjectkeys) (`:70-71`, `:78-79`, `:89-90`). Externals: `IServiceScopeFactory`, `StringComparison.Ordinal`, source-generated `[LoggerMessage]` (`:99-103`). - **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** @@ -2264,36 +2268,15 @@ - On a successful map it opens one async scope (`:45`), resolves the scoped [`IPointsAwarder`](#ipointsawarder) (`:46`) and awards with the event's `CheckedInOn.UtcDateTime` (`:48-53`). A rejected award is logged at warning with the activity, the user and the subject key (`:55-56`), not rethrown. - `TryMapAward` (`:63`) is three ordinal string comparisons in scope order. `Event` maps to `PointsActivityType.EventCheckIn` scoped by `PointsSubjectKeys.ForEvent` (`:68-73`). `Session` maps to `SessionCheckIn` only when the payload actually carries a session id, using a property pattern (`:75-81`). `Sponsor` maps to `SponsorVisit` under the same condition on the sponsor id (`:86-92`), and the comment above it (`:83-85`) 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` (`:94-96`), 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:287-293`). 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 (`:294-296`): 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. -- **Where it's used**: registered as a singleton by the convention scan (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:82`, `MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:143-147`), which is why it opens its own scope per event (`:23-26`); the consumer is wired at `MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:299`. Covered by [`AttendeeCheckedInPointsHandlerTests`](group-27-testing-infrastructure.md#attendeecheckedinpointshandlertests). +- **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:293-299`). 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 (`:300-302`): 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`](#sessionquestionsubmittedpointshandler). +- **Where it's used**: registered as a singleton by the convention scan (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:87`, `MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:151-155`), which is why it opens its own scope per event (`:23-26`); the consumer is wired at `MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:305`. Covered by [`AttendeeCheckedInPointsHandlerTests`](group-27-testing-infrastructure.md#attendeecheckedinpointshandlertests). - **Caveats / not-in-source**: how many points each activity is worth, and whether a rule is switched off, is decided inside [`PointsAwarder`](#pointsawarder) from configuration, not here. --- -### 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 - -- **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`](group-02-domain-building-blocks.md#auditableaggregaterootentitytidentifiertype) and [`IAuditedEntity`](group-02-domain-building-blocks.md#iauditedentity) (both on `:28`), [`CheckInInvariants`](#checkininvariants) (`:99-103`), [`CheckInScope`](#checkinscope) (`:34`), [`CheckInScopeNames`](#checkinscopenames) (`:114`), [`AttendeeCheckedIn`](#attendeecheckedin) (`:112`), [`Result`](group-01-result-error-handling.md#result) and [`IdValueGeneratedAttribute`](group-02-domain-building-blocks.md#idvaluegeneratedattribute) (`:27`). Externals: `DateTimeOffset`. -- **Concept introduced, one aggregate for a family of shapes.** `[Rubric §4, DDD]` 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`](#checkininvariants)`.EnsureTargetMatchesScope` exists for. `[Rubric §6, CQRS & Event-Driven]` covers the event placement: `AddDomainEvent` is called inside the factory (`:112`), not by a handler, so the outbox captures the announcement in the same transaction as the row ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)). `[Rubric §30, Compliance, Privacy & 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](https://ivanball.github.io/docs/adr/075-audit-trail.html)). -- **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`) 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](https://ivanball.github.io/docs/adr/072-qr-badge-check-in-and-points.html)). -- **Where it's used**: created by [`CheckInAttendeeHandler`](#checkinattendeehandler), [`ManualCheckInHandler`](#manualcheckinhandler), [`RecordSponsorVisitHandler`](#recordsponsorvisithandler) and [`RecordRoomCheckInHandler`](#recordroomcheckinhandler); read by [`GetAttendanceStatsHandler`](#getattendancestatshandler). 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/CheckInConfiguration.cs:48-62`) plus two non-unique indexes for the attendance rollup (`:64-69`). Its event feeds [`AttendeeCheckedInPointsHandler`](#attendeecheckedinpointshandler). Covered by [`CheckInTests`](group-27-testing-infrastructure.md#checkintests). -- **Caveats / not-in-source**: the duplicate-scan short-circuit the factory comment relies on lives in the use-case handlers, 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`). - ---- - ### BookmarksController -> MMCA.ADC.Engagement.API · `MMCA.ADC.Engagement.API.Controllers` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:33` · Level 11 · class +> MMCA.ADC.Engagement.API · `MMCA.ADC.Engagement.API.Controllers` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:33` · 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`](group-12-api-hosting-mapping.md#apicontrollerbase), a create handler over [`CreateBookmarkRequest`](#createbookmarkrequest) (`:34`), query handlers for [`GetUserBookmarksQuery`](#getuserbookmarksquery) (`:35`) and [`GetBookmarkedSessionIdsQuery`](#getbookmarkedsessionidsquery) (`:36`), a delete handler over [`DeleteEntityCommand`](group-05-cqrs-pipeline.md#deleteentitycommandtentity-tidentifiertype) (`:37`), [`IEntityQueryService`](group-03-querying-specifications.md#ientityqueryservicetentity-tentitydto-tidentifiertype) for the ownership probe (`:38`), [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) (`:39`), plus [`UserSessionBookmark`](#usersessionbookmark), [`UserSessionBookmarkDTO`](#usersessionbookmarkdto), [`PagedCollectionResult`](group-01-result-error-handling.md#pagedcollectionresultt), [`Error`](group-01-result-error-handling.md#error), [`RoleNames`](group-08-auth.md#rolenames), [`OwnerOrAdminFilter`](group-08-auth.md#owneroradminfilter), [`IdempotentAttribute`](group-12-api-hosting-mapping.md#idempotentattribute) and [`EngagementFeatures`](#engagementfeatures). Externals: ASP.NET Core MVC, `Asp.Versioning`, `[FeatureGate]`, `CultureInfo` for the `Created` location. @@ -2312,13 +2295,31 @@ - **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`](#usersessionbookmarkcacheevictionhandler): the aggregate raises [`UserSessionBookmarkChanged`](#usersessionbookmarkchanged) on every path this controller reaches, so a star or an un-star broadcasts an output-cache eviction to Conference. Covered by [`BookmarksControllerTests`](group-27-testing-infrastructure.md#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. +### 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`](#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`](group-02-domain-building-blocks.md#commoninvariants) (`CheckInInvariants.cs:2`), [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error) (`Error.Invariant` at `:88`, `:93`, `:104`), plus [`CheckInScope`](#checkinscope) (`:45`) and the `UserIdentifierType` / `EventIdentifierType` / `SessionIdentifierType` / `SponsorIdentifierType` aliases (solution-wide `global using`, see [primer §2](00-primer.md#2-architectural-styles-this-codebase-commits-to)). 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`](#checkinconfiguration), `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/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) (`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`](group-02-domain-building-blocks.md#auditableaggregaterootentitytidentifiertype) (bound to `LeaderboardOptInIdentifierType`), [`LeaderboardOptInInvariants`](#leaderboardoptininvariants), the [`LeaderboardOptInChanged`](#leaderboardoptinchanged) domain event, [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate), [`IdValueGeneratedAttribute`](group-02-domain-building-blocks.md#idvaluegeneratedattribute), and [`Result`](group-01-result-error-handling.md#result) / [`Result`](group-01-result-error-handling.md#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:13-16`). That single choice is what lets [`GetLeaderboardHandler`](#getleaderboardhandler) project the whole board out of the Engagement database (`GetLeaderboardHandler.cs:39-41`) 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 & Data Governance]` is the other half, taught below on `EraseDisplayName`. +- **Depends on**: [`AuditableAggregateRootEntity`](group-02-domain-building-blocks.md#auditableaggregaterootentitytidentifiertype) (bound to `LeaderboardOptInIdentifierType`), [`LeaderboardOptInInvariants`](#leaderboardoptininvariants), the [`LeaderboardOptInChanged`](#leaderboardoptinchanged) domain event, [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate), [`IdValueGeneratedAttribute`](group-02-domain-building-blocks.md#idvaluegeneratedattribute), and [`Result`](group-01-result-error-handling.md#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`](#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`](group-02-domain-building-blocks.md#idvaluegeneratedattribute)). @@ -2329,10 +2330,10 @@ - `Create(userId, displayName)` (`:60-78`): `Result.Combine` over both invariants (`:64-66`), errors re-wrapped as `Result.Failure` 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 & 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](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html)), 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), `LeaderboardOptInConfiguration.cs:33-35`), so an attendee can hold at most one active opt-in while their history survives. 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:66-68`). -- **Where it's used**: created and reactivated by [`SetLeaderboardParticipationHandler`](#setleaderboardparticipationhandler) (`SetLeaderboardParticipationHandler.cs:86`, `:92`); read by [`GetLeaderboardHandler`](#getleaderboardhandler) (`GetLeaderboardHandler.cs:39-41`, ordering tie-break on `DisplayName` at `:71`); soft-deleted and erased by [`UserDeletedPointsHandler`](#userdeletedpointshandler) (`UserDeletedPointsHandler.cs:65-81`); persisted per [`LeaderboardOptInConfiguration`](#leaderboardoptinconfiguration). Unit-tested by [`LeaderboardOptInTests`](group-27-testing-infrastructure.md#leaderboardoptintests). -- **Caveats / not-in-source**: the erasure comment cites `PRIVACY.md` section 5 (`LeaderboardOptIn.cs:121`); that document lives in the private ADC repo and is not part of this library, so the citation cannot be verified from published source. + - `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](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html)), 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), `LeaderboardOptInConfiguration.cs:33-35`), so an attendee can hold at most one active opt-in while their history survives ([ADR-095](https://ivanball.github.io/docs/adr/095-soft-delete-unique-indexes.html)). 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:66-68`). +- **Where it's used**: reactivated and created by [`SetLeaderboardParticipationHandler`](#setleaderboardparticipationhandler) (`SetLeaderboardParticipationHandler.cs:86` and `:92` respectively); read by [`GetLeaderboardHandler`](#getleaderboardhandler) (`GetLeaderboardHandler.cs:39-43`, ordering tie-break on `DisplayName` at `:71`); soft-deleted and erased by [`UserDeletedPointsHandler`](#userdeletedpointshandler) (`UserDeletedPointsHandler.cs:63-82`); persisted per [`LeaderboardOptInConfiguration`](#leaderboardoptinconfiguration). Unit-tested by [`LeaderboardOptInTests`](group-27-testing-infrastructure.md#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. --- @@ -2341,8 +2342,8 @@ > 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`](#leaderboardoptin): the opt-in must name a real attendee, and the published name must be present and fit the column. -- **Depends on**: `CommonInvariants` (see [`CommonInvariants`](group-02-domain-building-blocks.md#commoninvariants), `LeaderboardOptInInvariants.cs:1`), [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error), the `UserIdentifierType` alias (solution-wide `global using`, see [primer §2](00-primer.md#2-architectural-styles-this-codebase-commits-to)), 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`](group-01-result-error-handling.md#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](group-02-domain-building-blocks.md); the two siblings in this unit ([`PointsEntryInvariants`](#pointsentryinvariants) and [`UserSessionBookmarkInvariants`](#usersessionbookmarkinvariants)) are the same shape with different rules. +- **Depends on**: [`CommonInvariants`](group-02-domain-building-blocks.md#commoninvariants) (`LeaderboardOptInInvariants.cs:1`), [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error), the `UserIdentifierType` alias (solution-wide `global using`, see [primer §2](00-primer.md#2-architectural-styles-this-codebase-commits-to)), 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`](group-01-result-error-handling.md#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](group-02-domain-building-blocks.md); the three siblings in this unit ([`CheckInInvariants`](#checkininvariants), [`PointsEntryInvariants`](#pointsentryinvariants) and [`UserSessionBookmarkInvariants`](#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`). @@ -2355,14 +2356,14 @@ > 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`](#pointsentry). It is the widest invariant class in 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` ([`CommonInvariants`](group-02-domain-building-blocks.md#commoninvariants)), [`Result`](group-01-result-error-handling.md#result) / [`Error`](group-01-result-error-handling.md#error), [`PointsActivityType`](#pointsactivitytype) and [`PointsSubjectKeys`](#pointssubjectkeys) from `MMCA.ADC.Engagement.Shared` (`PointsEntryInvariants.cs:1`), and the `UserIdentifierType` alias. Externals: `System.Enum`. +- **What it is**: the four-rule invariant helper for [`PointsEntry`](#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`](group-02-domain-building-blocks.md#commoninvariants), [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error), [`PointsActivityType`](#pointsactivitytype) and [`PointsSubjectKeys`](#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`](#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`](#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 & Code Quality]`: [`PointsActivityType`](#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. + - `EnsureActivityTypeIsDefined(activityType, source)` (`:51-54`): `Enum.IsDefined(activityType)`, code `"PointsEntry.ActivityType.Invalid"`. `[Rubric §15, Best Practices and Code Quality]`: [`PointsActivityType`](#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), `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) (`PointsEntry.cs:83-87`), combined through `Result.Combine`. Unit-tested by [`PointsEntryInvariantsTests`](group-27-testing-infrastructure.md#pointsentryinvariantstests). @@ -2373,7 +2374,7 @@ > 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`](#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` ([`CommonInvariants`](group-02-domain-building-blocks.md#commoninvariants), `UserSessionBookmarkInvariants.cs:1`), [`Result`](group-01-result-error-handling.md#result), and the `UserIdentifierType` / `SessionIdentifierType` aliases (solution-wide `global using`, see [primer §2](00-primer.md#2-architectural-styles-this-codebase-commits-to)). +- **Depends on**: [`CommonInvariants`](group-02-domain-building-blocks.md#commoninvariants) (`UserSessionBookmarkInvariants.cs:1`), [`Result`](group-01-result-error-handling.md#result), and the `UserIdentifierType` / `SessionIdentifierType` aliases (solution-wide `global using`, see [primer §2](00-primer.md#2-architectural-styles-this-codebase-commits-to)). - **Concept**: the static invariant class is taught on [`LeaderboardOptInInvariants`](#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."`. @@ -2381,7 +2382,7 @@ - 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](https://ivanball.github.io/docs/adr/006-database-per-service.html)), 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) (`UserSessionBookmark.cs:44-46`), combined through `Result.Combine`. -- **Caveats / not-in-source**: unlike its two 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. +- **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. --- @@ -2390,17 +2391,17 @@ > 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`](group-02-domain-building-blocks.md#auditableaggregaterootentitytidentifiertype) (bound to `PointsEntryIdentifierType`), [`IAuditedEntity`](group-02-domain-building-blocks.md#iauditedentity), [`PointsEntryInvariants`](#pointsentryinvariants), the [`PointsEntryChanged`](#pointsentrychanged) domain event, [`PointsActivityType`](#pointsactivitytype) and [`PointsSubjectKeys`](#pointssubjectkeys), [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate), [`IdValueGeneratedAttribute`](group-02-domain-building-blocks.md#idvaluegeneratedattribute), and [`Result`](group-01-result-error-handling.md#result). +- **Depends on**: [`AuditableAggregateRootEntity`](group-02-domain-building-blocks.md#auditableaggregaterootentitytidentifiertype) (bound to `PointsEntryIdentifierType`), [`IAuditedEntity`](group-02-domain-building-blocks.md#iauditedentity), [`PointsEntryInvariants`](#pointsentryinvariants), the [`PointsEntryChanged`](#pointsentrychanged) domain event, [`PointsActivityType`](#pointsactivitytype) and [`PointsSubjectKeys`](#pointssubjectkeys), [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate), [`IdValueGeneratedAttribute`](group-02-domain-building-blocks.md#idvaluegeneratedattribute), and [`Result`](group-01-result-error-handling.md#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 (`:71-72`). `[Rubric §16, Maintainability]` 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 & Scalability]` and `[Rubric §11, Security]` both apply. The unique index on `(UserId, ActivityType, SubjectKey)` ([`PointsEntryConfiguration`](#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`](#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`](#duplicatekeydetection)). -- **Concept, the audit marker on a ledger.** `[Rubric §13, Observability & Operability]` assesses whether operationally load-bearing writes leave a trail. The class implements [`IAuditedEntity`](group-02-domain-building-blocks.md#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, 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. +- **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 §16, Maintainability]` 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), `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`](#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`](#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`](group-02-domain-building-blocks.md#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](https://ivanball.github.io/docs/adr/075-audit-trail.html)), 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` (`: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`](#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 (such as [`SessionQuestionSubmittedPointsHandler`](#sessionquestionsubmittedpointshandler)) behind in ADC. The scan surfaces that feed it are described in [ADR-072](https://ivanball.github.io/docs/adr/072-qr-badge-check-in-and-points.html). +- **Why it's built this way**: the contract is deliberately conference-agnostic. Neither this entity nor [`IPointsAwarder`](#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](https://ivanball.github.io/docs/adr/072-qr-badge-check-in-and-points.html). - **Where it's used**: written only by [`PointsAwarder`](#pointsawarder) (`PointsAwarder.cs:65`, the single write path into the ledger); read by [`GetMyPointsHandler`](#getmypointshandler) (`GetMyPointsHandler.cs:53`), [`GetPointsOverviewHandler`](#getpointsoverviewhandler) (`GetPointsOverviewHandler.cs:40`) and [`GetLeaderboardHandler`](#getleaderboardhandler) (`GetLeaderboardHandler.cs:54`), all projecting to [`PointsEntryDTO`](#pointsentrydto) rather than returning the aggregate; exported by [`UserEngagementExportService`](#userengagementexportservice); persisted per [`PointsEntryConfiguration`](#pointsentryconfiguration), which adds a `UserId`-leading index so the "my points" read is a seek (`PointsEntryConfiguration.cs:51-52`). Unit-tested by [`PointsEntryTests`](group-27-testing-infrastructure.md#pointsentrytests). --- @@ -2411,15 +2412,15 @@ - **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`](group-02-domain-building-blocks.md#auditableaggregaterootentitytidentifiertype) (bound to `UserSessionBookmarkIdentifierType`), [`UserSessionBookmarkInvariants`](#usersessionbookmarkinvariants), the [`UserSessionBookmarkChanged`](#usersessionbookmarkchanged) domain event, [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate), [`IdValueGeneratedAttribute`](group-02-domain-building-blocks.md#idvaluegeneratedattribute), and [`Result`](group-01-result-error-handling.md#result). -- **Concept introduced, one domain event with a state enum (BR-60).** `[Rubric §4, Domain-Driven Design]` and `[Rubric §6, CQRS & 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`](#usersessionbookmarkchanged) carrying a [`DomainEntityState`](group-02-domain-building-blocks.md#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. [`LeaderboardOptIn`](#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`](#usersessionbookmarkconfiguration)'s soft-delete-filtered unique index (`UserSessionBookmarkConfiguration.cs:32-34`); the decision half is [`BookmarkManagementDomainService`](#bookmarkmanagementdomainservice). +- **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`](#usersessionbookmarkchanged) carrying a [`DomainEntityState`](group-02-domain-building-blocks.md#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](https://ivanball.github.io/docs/adr/083-crud-lifecycle-event-taxonomy.html); [`LeaderboardOptIn`](#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`](#usersessionbookmarkconfiguration)'s soft-delete-filtered unique index (`UserSessionBookmarkConfiguration.cs:32-34`, the convention behind it taught on [`SoftDeleteUniqueIndexConvention`](group-07-persistence-ef-core.md#softdeleteuniqueindexconvention) and decided in [ADR-095](https://ivanball.github.io/docs/adr/095-soft-delete-unique-indexes.html)); the decision half is [`BookmarkManagementDomainService`](#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](https://ivanball.github.io/docs/adr/006-database-per-service.html)), 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-58`): combines both invariants via `Result.Combine` (`:44-46`); on failure re-wraps the errors as `Result.Failure` (`:47-48`); on success builds the entity with `Id = default` (`:50-53`) and raises `UserSessionBookmarkChanged(DomainEntityState.Added, ...)` (`:55`). - - `Reactivate()` (`:66-74`): calls the inherited `Undelete()` (`:68`) and, only if that succeeds, re-raises the same `Added` event (`:71`). The row keeps its identity and audit trail. Note the contrast with [`LeaderboardOptIn.Reactivate`](#leaderboardoptin), which takes a display name and refreshes it: a bookmark carries no snapshot to refresh, so this overload takes no arguments. - - `Delete()` (`:81-89`): overrides the base soft-delete, calls `base.Delete()` first (`:83`) and, on success, raises `UserSessionBookmarkChanged(DomainEntityState.Deleted, ...)` (`:86`). + - `Create(userId, sessionId)` (`:40-60`): combines both invariants via `Result.Combine` (`:44-46`); on failure re-wraps the errors as `Result.Failure` (`: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`](#leaderboardoptin), 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](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html)). 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`](#bookmarkmanagementdomainservice); counted by [`BookmarkCountService`](#bookmarkcountservice); mapped by [`UserSessionBookmarkDTOMapper`](#usersessionbookmarkdtomapper); persisted per [`UserSessionBookmarkConfiguration`](#usersessionbookmarkconfiguration); the aggregate type parameter for the CRUD surface on [`BookmarksController`](#bookmarkscontroller). Unit-tested by [`UserSessionBookmarkTests`](group-27-testing-infrastructure.md#usersessionbookmarktests). @@ -2432,7 +2433,7 @@ - **What it is**: the in-process implementation of the cross-module [`IBookmarkCountService`](#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`](group-07-persistence-ef-core.md#iunitofwork), [`IQueryableExecutor`](group-07-persistence-ef-core.md#iqueryableexecutor), [`UserSessionBookmark`](#usersessionbookmark), [`IBookmarkCountService`](#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`](#ibookmarkcountservice), which lives in `MMCA.ADC.Engagement.Shared`. In process this class satisfies that contract; once Engagement runs as its own service, [`BookmarkCountServiceGrpcAdapter`](#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`](group-17-conference-domain.md#isessionbookmarkvalidationservice)). -- **Concept introduced, the batch method that replaces a caller's fan-out.** `[Rubric §12, Performance & 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`). +- **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`](group-07-persistence-ef-core.md#iunitofwork) and [`IQueryableExecutor`](group-07-persistence-ef-core.md#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()` (`: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. @@ -2447,47 +2448,25 @@ > MMCA.ADC.Engagement.Domain · `MMCA.ADC.Engagement.Domain.Services` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Services/IBookmarkManagementDomainService.cs:12` · 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. -- **Depends on**: [`Result`](group-01-result-error-handling.md#result), [`UserSessionBookmark`](#usersessionbookmark), and the `UserIdentifierType` / `SessionIdentifierType` aliases (solution-wide `global using`, see [primer §2](00-primer.md#2-architectural-styles-this-codebase-commits-to)). No EF, no repository, no `CancellationToken`. +- **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:6-11`). +- **Depends on**: [`Result`](group-01-result-error-handling.md#result), [`UserSessionBookmark`](#usersessionbookmark), and the `UserIdentifierType` / `SessionIdentifierType` aliases (solution-wide `global using`, see [primer §2](00-primer.md#2-architectural-styles-this-codebase-commits-to)). 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` (`IBookmarkManagementDomainService.cs: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:51-57`). Everything the service needs is passed in, so it touches no repository and returns synchronously. +- **Walkthrough**: one method, `CreateOrReactivate(existingDeletedBookmark?, userId, sessionId)` returning `Result` (`: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:51-57`). 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](00-primer.md#1-the-big-picture)). `[Rubric §14, Testability]`. - **Where it's used**: implemented by [`BookmarkManagementDomainService`](#bookmarkmanagementdomainservice), registered `TryAddSingleton` (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:37`), and injected by [`CreateBookmarkHandler`](#createbookmarkhandler) (`CreateBookmarkHandler.cs:21`). --- -### SessionQuestionSubmittedPointsHandler - -> MMCA.ADC.Engagement.Application · `MMCA.ADC.Engagement.Application.Points.DomainEventHandlers` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/DomainEventHandlers/SessionQuestionSubmittedPointsHandler.cs:39` · Level 8 · class (sealed partial) - -- **What it is**: the award adapter for session Q and A. It listens for [`SessionQuestionChanged`](group-23-engagement-live-layer.md#sessionquestionchanged) and awards `PointsActivityType.QuestionAsked` the first time an attendee asks a question in a given session. -- **Depends on**: [`IDomainEventHandler`](group-04-events-outbox.md#idomaineventhandlerin-tdomainevent) (closed over [`SessionQuestionChanged`](group-23-engagement-live-layer.md#sessionquestionchanged)), [`IPointsAwarder`](#ipointsawarder), [`PointsActivityType`](#pointsactivitytype), [`PointsSubjectKeys`](#pointssubjectkeys), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`SessionQuestion`](group-23-engagement-live-layer.md#sessionquestion), and [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate). Externals: `IServiceScopeFactory`, `ILogger` and the `[LoggerMessage]` source generator (which is why the class is `partial`, `:89-93`). -- **Concept introduced, the award adapter.** `[Rubric §6, CQRS & Event-Driven]` assesses whether side effects hang off events instead of being wired into the originating use case. The Q and A use case knows nothing about points: it raises its own aggregate event, and this small class translates that event into an `(activity, subjectKey)` pair for [`IPointsAwarder`](#ipointsawarder). Every earn rule in the module has an adapter of this shape, which is what keeps the ledger conference-agnostic (`IPointsAwarder.cs:10-17`) while the ADC-specific translation stays in ADC. -- **Concept introduced, the deliberate at-most-once side effect.** `[Rubric §29, Resilience, Reliability & Business Continuity]` assesses whether a delivery guarantee is chosen rather than inherited. This handler rides an **in-process domain event**, not an integration event through the outbox (`:23-32`). Dispatch happens after the question's transaction commits, so a crash in the window between commit and this handler loses one small award and nothing else: no question is lost and no total is corrupted. The stated trade-off is that a second outbox contract, a broker round trip and inbox dedup for five points buys durability the feature does not need, and that promoting it later is a one-file change on each side. Contrast [`UserDeletedPointsHandler`](#userdeletedpointshandler), which is an integration-event handler because erasure is not a game. -- **Concept, the subject key as the anti-farming rule.** The key is the **session**, never the question (`:74`, `PointsSubjectKeys.ForSession`), so an attendee who asks five questions in one session is awarded once. `[Rubric §11, Security]`: that limit is enforced by the ledger's unique index rather than by counting here (`:19-21`), so a concurrent double-submit cannot slip past a read-then-write check. -- **Walkthrough** - - The primary constructor takes `IServiceScopeFactory` and `ILogger` (`:39-41`). Domain event handlers are registered as **singletons** by the framework's convention scan (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:118-123`), which is why this one creates its own scope instead of injecting scoped services. - - `HandleAsync(domainEvent, cancellationToken)` (`:44`): null-guards the event (`:46`), then returns immediately unless `domainEvent.State == DomainEntityState.Added` (`:48-49`). [`SessionQuestionChanged`](group-23-engagement-live-layer.md#sessionquestionchanged) is also raised for moderation and deletion (`SessionQuestionChanged.cs:7-12`), so the state filter is what keeps a moderator approving a question from paying the asker twice. - - `await using var scope = scopeFactory.CreateAsyncScope()` (`:53`), then [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (`:54`) and [`IPointsAwarder`](#ipointsawarder) (`:55`) are resolved from it. - - The asker is read back from the aggregate rather than carried on the event (`:60-61`): the event names the question and the session but not the user, and the comment (`:57-59`) states the reason, the question row is the only authority on whose question it is. A question removed between the commit and this dispatch simply returns (`:63-67`). - - `awarder.AwardAsync(question.UserId, PointsActivityType.QuestionAsked, PointsSubjectKeys.ForSession(domainEvent.SessionId), domainEvent.DateOccurred, cancellationToken)` (`:71-76`). `DateOccurred` comes from `BaseDomainEvent` and 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 is injected here (`:69-70`). - - A rejected award is logged at warning through the source-generated `LogAwardRejected` (`:78-79`, `:89-90`). - - The `catch` (`:82`) swallows everything except `OperationCanceledException` behind an inline `CA1031` suppression whose justification is written into the pragma (`:81-83`): the award is best-effort and must never fail the question that was already committed. `[Rubric §13, Observability & Operability]`: the swallow is not silent, `LogAwardFailed` records the exception against the question id (`:85`, `:92-93`). -- **Why it's built this way**: the handler is the boundary where "a question was asked" becomes "points were earned", and both of its guards exist so that the game can never damage the feature it decorates. The state filter keeps the ledger honest; the broad catch keeps a points outage from turning into a Q and A outage. -- **Where it's used**: discovered and registered as a singleton by `ScanModuleApplicationServices()` (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:119-123`) and invoked by the framework's domain event dispatcher after `SaveChangesAsync` on the Q and A write path. - ---- - ### 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`](#usersessionbookmark) aggregate to its wire-facing [`UserSessionBookmarkDTO`](#usersessionbookmarkdto). - **Depends on**: [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) (closed over the bookmark triple, `UserSessionBookmarkDTOMapper.cs:13`), [`UserSessionBookmark`](#usersessionbookmark), [`UserSessionBookmarkDTO`](#usersessionbookmarkdto). Externals: `Riok.Mapperly.Abstractions` (the `[Mapper]` source generator). -- **Concept, compile-time DTO mapping with Mapperly ([ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html)).** `[Rubric §2, Design Patterns]` and `[Rubric §15, Best Practices & 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](group-12-api-hosting-mapping.md)). +- **Concept, compile-time DTO mapping with Mapperly ([ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html)).** `[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](group-12-api-hosting-mapping.md)). - **Walkthrough** (`:12-24`): the class implements the shared [`IEntityDTOMapper`](group-12-api-hosting-mapping.md#ientitydtomappertentity-tentitydto-tidentifiertype) 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) (`CreateBookmarkHandler.cs:22`, used at `:90`) and [`GetUserBookmarksHandler`](#getuserbookmarkshandler) (`GetUserBookmarksHandler.cs:21`); also resolved by the generic [`EntityQueryService`](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype) registered for bookmarks (`DependencyInjection.cs:41`) behind [`BookmarksController`](#bookmarkscontroller). Unit-tested by [`UserSessionBookmarkDTOMapperTests`](group-27-testing-infrastructure.md#usersessionbookmarkdtomappertests). +- **Where it's used**: auto-registered by the module's convention scan and injected directly by [`CreateBookmarkHandler`](#createbookmarkhandler) (`CreateBookmarkHandler.cs:22`, used at `:90`) and [`GetUserBookmarksHandler`](#getuserbookmarkshandler) (`GetUserBookmarksHandler.cs:21`, used at `:72`); also resolved by the generic [`EntityQueryService`](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype) registered for bookmarks (`DependencyInjection.cs:41`) behind [`BookmarksController`](#bookmarkscontroller). Unit-tested by [`UserSessionBookmarkDTOMapperTests`](group-27-testing-infrastructure.md#usersessionbookmarkdtomappertests). --- @@ -2496,14 +2475,37 @@ > MMCA.ADC.Engagement.Domain · `MMCA.ADC.Engagement.Domain.Services` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Services/BookmarkManagementDomainService.cs:10` · Level 9 · class (sealed) - **What it is**: the single implementation of [`IBookmarkManagementDomainService`](#ibookmarkmanagementdomainservice). A `sealed`, dependency-free class that decides between reactivating a soft-deleted bookmark and creating a fresh one. -- **Depends on**: [`IBookmarkManagementDomainService`](#ibookmarkmanagementdomainservice), [`UserSessionBookmark`](#usersessionbookmark), [`Result`](group-01-result-error-handling.md#result). No injected collaborators: the constructor is implicit (`BookmarkManagementDomainService.cs:10`). -- **Concept, soft-delete meets a filtered unique index.** `[Rubric §8, Data Architecture]` assesses deliberate schema semantics. [`UserSessionBookmarkConfiguration`](#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. The same pairing appears one aggregate over on [`LeaderboardOptIn`](#leaderboardoptin), where the reactivate branch lives in the handler instead. +- **Depends on**: [`IBookmarkManagementDomainService`](#ibookmarkmanagementdomainservice), [`UserSessionBookmark`](#usersessionbookmark), [`Result`](group-01-result-error-handling.md#result). No injected collaborators: the constructor is implicit (`BookmarkManagementDomainService.cs:10`). +- **Concept, soft-delete meets a filtered unique index.** `[Rubric §8, Data Architecture]` assesses deliberate schema semantics. [`UserSessionBookmarkConfiguration`](#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`](group-07-persistence-ef-core.md#softdeleteuniqueindexconvention) ([ADR-095](https://ivanball.github.io/docs/adr/095-soft-delete-unique-indexes.html)). The same pairing appears one aggregate over on [`LeaderboardOptIn`](#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:66-74`). If reactivation fails, its errors are propagated as `Result.Failure(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-58`). -- **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](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html)). 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`](#usersessionbookmarkchanged) carrying [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate) rather than separate Created/Changed events). Being state-free is what lets it be registered as a singleton (`DependencyInjection.cs:37`). + - 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(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](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html)). 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`](#usersessionbookmarkchanged) carrying [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate) rather than separate Created/Changed events, [ADR-083](https://ivanball.github.io/docs/adr/083-crud-lifecycle-event-taxonomy.html)). Being state-free is what lets it be registered as a singleton (`DependencyInjection.cs:37`). - **Where it's used**: [`CreateBookmarkHandler`](#createbookmarkhandler) calls it after querying for a soft-deleted match (`CreateBookmarkHandler.cs:60`), and only adds the returned entity to the repository when there was no prior row to revive (`CreateBookmarkHandler.cs:65-68`). 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`](#duplicatekeydetection) (`CreateBookmarkHandler.cs:74-86`). +--- + +### 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`](group-02-domain-building-blocks.md#auditableaggregaterootentitytidentifiertype) and [`IAuditedEntity`](group-02-domain-building-blocks.md#iauditedentity) (both on `:28`), [`CheckInInvariants`](#checkininvariants) (`:99-103`), [`CheckInScope`](#checkinscope) (`:34`), [`CheckInScopeNames`](#checkinscopenames) (`:114`), [`AttendeeCheckedIn`](#attendeecheckedin) (`:112`), [`Result`](group-01-result-error-handling.md#result) and [`IdValueGeneratedAttribute`](group-02-domain-building-blocks.md#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`](#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`](#attendeecheckedin), which derives from [`BaseIntegrationEvent`](group-04-events-outbox.md#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](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)): 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`](group-02-domain-building-blocks.md#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](https://ivanball.github.io/docs/adr/075-audit-trail.html)). This is the same marker [`PointsEntry`](#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](https://ivanball.github.io/docs/adr/072-qr-badge-check-in-and-points.html)). +- **Where it's used**: created through [`CheckInProcessor`](#checkinprocessor) on behalf of [`CheckInAttendeeHandler`](#checkinattendeehandler) and [`ManualCheckInHandler`](#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`](#recordsponsorvisithandler) (`.../CheckIns/UseCases/RecordSponsorVisit/RecordSponsorVisitHandler.cs:97`, scope `Sponsor`) and [`RecordRoomCheckInHandler`](#recordroomcheckinhandler) (`.../CheckIns/UseCases/RecordRoomCheckIn/RecordRoomCheckInHandler.cs:99`, scope `Session` with the attendee as their own recorder); read by [`GetAttendanceStatsHandler`](#getattendancestatshandler) (`.../CheckIns/UseCases/GetAttendanceStats/GetAttendanceStatsHandler.cs:25-35`) and exported by [`UserEngagementExportService`](#userengagementexportservice) (`.../Exports/UserEngagementExportService.cs:51-59`). Persisted by [`CheckInConfiguration`](#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/CheckInConfiguration.cs:48-62`) plus two non-unique indexes for the attendance rollup (`:64-69`). Its event feeds [`AttendeeCheckedInPointsHandler`](#attendeecheckedinpointshandler). Covered by [`CheckInTests`](group-27-testing-infrastructure.md#checkintests). +- **Caveats / not-in-source**: the duplicate-scan short-circuit the factory comment relies on lives in the use-case handlers and in [`CheckInProcessor`](#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` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/AttendeeBadgeConfiguration.cs:15` · Level 8 · class (internal sealed) diff --git a/docs-src/onboarding/group-23-engagement-live-layer.md b/docs-src/onboarding/group-23-engagement-live-layer.md index 4114361..4dfa4ad 100644 --- a/docs-src/onboarding/group-23-engagement-live-layer.md +++ b/docs-src/onboarding/group-23-engagement-live-layer.md @@ -13,51 +13,57 @@ Engagement (the bookmarks of [Group 22](group-22-engagement-module.md)) is that fan out to every open page in **under a second**, so the whole chapter is really about one transport decision: how a vote cast on one phone lights up the tally on two hundred others. -That transport is the SignalR **hub-channel** push introduced by **[ADR-039](https://ivanball.github.io/docs/adr/039-live-channel-push.html)**, and it is deliberately -the *opposite* of the durable notification pipeline ([ADR-024](https://ivanball.github.io/docs/adr/024-push-notifications.html)) that the same hub also carries. A -durable notification writes a per-user inbox row and is worth finding minutes later; a live tally is -broadcast to whoever is looking *right now* and is worthless a second later, so it is never -persisted and carries no delivery guarantee. Everything in this chapter treats a channel event as a -**cache-invalidation hint over fetchable state**, not as the state itself: if a client connects late -and misses an event, its next fetch still shows the truth. That single design rule ([ADR-039](https://ivanball.github.io/docs/adr/039-live-channel-push.html)'s +That transport is the SignalR **hub-channel** push introduced by +**[ADR-039](https://ivanball.github.io/docs/adr/039-live-channel-push.html)**, and it is +deliberately the *opposite* of the durable notification pipeline +([ADR-024](https://ivanball.github.io/docs/adr/024-push-notifications.html)) that the same hub also +carries. A durable notification writes a per-user inbox row and is worth finding minutes later; a +live tally is broadcast to whoever is looking *right now* and is worthless a second later, so it is +never persisted and carries no delivery guarantee. Everything in this chapter treats a channel event +as a **cache-invalidation hint over fetchable state**, not as the state itself: if a client connects +late and misses an event, its next fetch still shows the truth. That single design rule (ADR-039's "ephemeral means lossy") explains most of the code you will read here. ## The two aggregates and their invariants -Both aggregates are sealed [`AuditableAggregateRootEntity`](group-02-domain-building-blocks.md#auditableaggregaterootentitytidentifiertype) -subclasses that follow the framework's factory-plus-`Result` discipline (primer §2). [`LivePoll`](#livepoll) -(`MMCA.ADC.Engagement.Domain/LivePolls/LivePoll.cs:18`) holds an -`EventId`, an optional `SessionId` (null for an event-wide poll, BR-230, `LivePoll.cs:24`), a -question, its authored [`LivePollOption`](#livepolloption) children, and a strict lifecycle `Status` -([`LivePollStatus`](#livepollstatus)): `Draft` to `Open` to `Closed`, no reopen (BR-221). Its -`Create` factory (`LivePoll.cs:64`) validates through [`LivePollInvariants`](#livepollinvariants) -(2 to 10 unique options, question at most 200 characters, -`MMCA.ADC.Engagement.Domain/LivePolls/LivePollInvariants.cs:12`, `:18`, `:21`), and -`Open`/`Close`/`Delete` each guard the transition: an open poll cannot be deleted (BR-228, -`LivePoll.cs:210-219`), and a successful delete cascades a soft-delete over the options -(`LivePoll.cs:225-232`). [`SessionQuestion`](#sessionquestion) +Both aggregates are sealed +[`AuditableAggregateRootEntity`](group-02-domain-building-blocks.md#auditableaggregaterootentitytidentifiertype) +subclasses that follow the framework's factory-plus-`Result` discipline (primer §2). +[`LivePoll`](#livepoll) (`MMCA.ADC.Engagement.Domain/LivePolls/LivePoll.cs:18`) holds an `EventId`, +an optional `SessionId` (null for an event-wide poll, BR-230, `LivePoll.cs:24`), a question, its +authored [`LivePollOption`](#livepolloption) children, and a strict lifecycle `Status` +([`LivePollStatus`](#livepollstatus)): Draft to Open to Closed, no reopen (BR-221). Its `Create` +factory (`LivePoll.cs:64`) validates through [`LivePollInvariants`](#livepollinvariants) (2 to 10 +unique options, question at most 200 characters, +`MMCA.ADC.Engagement.Domain/LivePolls/LivePollInvariants.cs:12`, `:18`, `:21`, uniqueness compared +case-insensitively at `:54`), and `Open`/`Close`/`Delete` each guard the transition: an open poll +cannot be deleted (BR-228, `LivePoll.cs:210-219`), and a successful delete cascades a soft-delete +over the options (`LivePoll.cs:225-232`). [`SessionQuestion`](#sessionquestion) (`MMCA.ADC.Engagement.Domain/SessionQuestions/SessionQuestion.cs:19`) holds a `SessionId`, a denormalized `EventId` (deliberately not validated, since the disabled-stub fallback reports a -default, `SessionQuestion.cs:24`), the submitter's `UserId` (never exposed on a DTO, BR-238), the -text (at most 500 characters, +default, `SessionQuestion.cs:24`, `:64-67`), the submitter's `UserId` (never exposed on a DTO, +BR-238), the text (at most 500 characters, `MMCA.ADC.Engagement.Domain/SessionQuestions/SessionQuestionInvariants.cs:12`), a [`QuestionStatus`](#questionstatus) (`Pending`/`Approved`/`Dismissed`), and an `IsAnswered` flag; -`Approve` (`SessionQuestion.cs:117`), `Dismiss` (`:141`), and `MarkAnswered` (`:164`) are the -moderation transitions (BR-234), each rejecting the no-op repeat. +`Approve` (`SessionQuestion.cs:121`), `Dismiss` (`:145`), and `MarkAnswered` (`:168`) are the +moderation transitions (BR-234), each rejecting the no-op repeat, and `Create` refuses any initial +status other than Pending or Approved (`SessionQuestion.cs:92-99`). The one design idea worth internalizing early is the **live-window snapshot**. When a poll is opened (`LivePoll.Open`, `LivePoll.cs:108`, stamping `LiveWindowEndUtc` at `:129`) or a question is -submitted (`SessionQuestion.Create`, `SessionQuestion.cs:77`), the event's live-window end is copied -*onto* the aggregate. From then on the aggregate can answer "is this vote still allowed?" -(`CanAcceptVote`, `LivePoll.cs:167`) or "is this upvote still allowed?" (`CanAcceptUpvote`, -`SessionQuestion.cs:197`) against its own snapshotted field, with **no cross-service call per vote** -(BR-224/BR-237). That matters because votes and upvotes are the high-frequency operations; paying a -gRPC hop on each one would not scale. And like the bookmark aggregate, both use a **single** -domain event carrying a [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate) -discriminator, [`LivePollChanged`](#livepollchanged) and [`SessionQuestionChanged`](#sessionquestionchanged) -(BR-60, raised at `LivePoll.cs:94`, `:131`, `:154`, `:232`), rather than separate per-transition -events. Those domain events are durable -[`BaseDomainEvent`](group-04-events-outbox.md#basedomainevent)s captured by the outbox ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)). +submitted (`SessionQuestion.Create`, `SessionQuestion.cs:77`, taking the window end as a parameter at +`:83`), the event's live-window end is copied *onto* the aggregate. From then on the aggregate can +answer "is this vote still allowed?" (`CanAcceptVote`, `LivePoll.cs:167`) or "is this upvote still +allowed?" (`CanAcceptUpvote`, `SessionQuestion.cs:201`) against its own snapshotted field, with **no +cross-service call per vote** (BR-224/BR-237). That matters because votes and upvotes are the +high-frequency operations; paying a gRPC hop on each one would not scale. And like the bookmark +aggregate, both use a **single** domain event carrying a +[`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate) discriminator, +[`LivePollChanged`](#livepollchanged) and [`SessionQuestionChanged`](#sessionquestionchanged) (BR-60, +raised at `LivePoll.cs:94`, `:131`, `:154`, `:232`), rather than separate per-transition events. +Those domain events are durable [`BaseDomainEvent`](group-04-events-outbox.md#basedomainevent)s +captured by the outbox +([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)). A vote and an upvote are themselves small aggregates, [`LivePollVote`](#livepollvote) and [`SessionQuestionUpvote`](#sessionquestionupvote), each with a "one active row per (poll/question, @@ -69,7 +75,9 @@ new one (`MMCA.ADC.Engagement.Application/LivePolls/UseCases/CastVote/CastVoteHa a user who changes their mind never piles up tombstones. [`ToggleUpvoteHandler`](#toggleupvotehandler) does the mirror image and additionally refuses to let an author upvote their own question (BR-235, -`MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/ToggleUpvote/ToggleUpvoteHandler.cs:41-48`). +`MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/ToggleUpvote/ToggleUpvoteHandler.cs:40-48`). +Both tables are indexed for the way they are actually read: the vote table carries a second +`(LivePollId, OptionId)` index for the grouped tally (`LivePollVoteConfiguration.cs:40`). ## The write path, and where the realtime broadcast actually happens @@ -85,89 +93,130 @@ The **hot paths raise a domain event and broadcast from the handler for it.** Ca themselves. The vote and upvote aggregates raise [`LivePollVoteChanged`](#livepollvotechanged) and [`SessionQuestionUpvoteChanged`](#sessionquestionupvotechanged), and the matching domain-event handlers, [`LivePollVoteChangedHandler`](#livepollvotechangedhandler) -(`MMCA.ADC.Engagement.Application/LivePolls/DomainEventHandlers/LivePollVoteChangedHandler.cs:31`) +(`MMCA.ADC.Engagement.Application/LivePolls/DomainEventHandlers/LivePollVoteChangedHandler.cs:38`) and [`SessionQuestionUpvoteChangedHandler`](#sessionquestionupvotechangedhandler) -(`MMCA.ADC.Engagement.Application/SessionQuestions/DomainEventHandlers/SessionQuestionUpvoteChangedHandler.cs:32`), -rebuild the fresh tally and hand a [`LiveChannelPublishWorkItem`](group-22-engagement-module.md#livechannelpublishworkitem) -to [`ILiveChannelPublishQueue`](group-22-engagement-module.md#ilivechannelpublishqueue) -(`LivePollVoteChangedHandler.cs:69-72`, `SessionQuestionUpvoteChangedHandler.cs:70-73`). Both -in-code rationales are worth reading (`LivePollVoteChangedHandler.cs:17-23`, -`SessionQuestionUpvoteChangedHandler.cs:17-24`): domain-event dispatch inside a transactional -command is deferred until after the commit and dropped on rollback, so clients can no longer be told -about a vote that never persisted, and the request never awaits a gRPC publish, so a hung -Notification peer cannot add its latency to every upvote. Both handlers are singletons that open -their own DI scope (`:43-45` and `:44-45`) and swallow-and-log any failure behind a justified -`CA1031` suppression. - -[`CloseLivePollHandler`](#closelivepollhandler) shows the same queue used **directly** from a -command handler (`.../UseCases/Close/CloseLivePollHandler.cs:85-97`): its `EnqueueClosed` serializes a -[`LivePollClosedPayload`](#livepollclosedpayload) and hands it to `Enqueue` (`:95-96`), with no -rejection branch to write because the queue never refuses an item; the only log left on that path is -the Information "live poll closed" line emitted before the enqueue (`:72`, `:99-100`). The other -three poll and question command handlers enqueue the same way rather than awaiting the publish: -[`OpenLivePollHandler`](#openlivepollhandler) (`.../UseCases/Open/OpenLivePollHandler.cs:100-112`), +(`MMCA.ADC.Engagement.Application/SessionQuestions/DomainEventHandlers/SessionQuestionUpvoteChangedHandler.cs:39`), +rebuild the fresh tally and hand a +[`LiveChannelPublishWorkItem`](group-22-engagement-module.md#livechannelpublishworkitem) to +[`ILiveChannelPublishQueue`](group-22-engagement-module.md#ilivechannelpublishqueue) +(`LivePollVoteChangedHandler.cs:79-82`, `SessionQuestionUpvoteChangedHandler.cs:80-83`). Both +in-code rationales are worth reading (`LivePollVoteChangedHandler.cs:18-24`, +`SessionQuestionUpvoteChangedHandler.cs:18-25`): domain-event dispatch inside a transactional command +is deferred until after the commit and dropped on rollback, so clients are never told about a vote +that never persisted, and the request never awaits a gRPC publish, so a hung Notification peer cannot +add its latency to every upvote. Both handlers are singletons that open their own DI scope +(`LivePollVoteChangedHandler.cs:53`, `SessionQuestionUpvoteChangedHandler.cs:54`), and neither +hand-rolls a catch: the whole body runs inside +[`BestEffort`](group-03-querying-specifications.md#besteffort)`.ExecuteAsync` +(`LivePollVoteChangedHandler.cs:51`, `SessionQuestionUpvoteChangedHandler.cs:52`), the framework +helper that turns a failed side effect into exactly one Warning plus one increment of +`besteffort.dispatch.failed` on the `MMCA.Common.BestEffort` meter while still rethrowing the +caller's own cancellation +(`MMCA.Common/Source/Core/MMCA.Common.Application/Services/BestEffort.cs:19-22`, `:45`, `:59`). A +broadcast path that has quietly stopped working is therefore countable, not just loggable. + +[`CloseLivePollHandler`](#closelivepollhandler) shows the same queue used **directly** from a command +handler (`.../UseCases/Close/CloseLivePollHandler.cs:85-97`): its `EnqueueClosed` serializes a +[`LivePollClosedPayload`](#livepollclosedpayload) and hands it to `Enqueue` (`:95-96`) with no guard +at all, because `Enqueue` cannot fail and the queue never refuses an item +(`MMCA.ADC.Engagement.Application/Live/ILiveChannelPublishQueue.cs:24-30`); the only log left on that +path is the Information "live poll closed" line emitted before the enqueue (`:72`, `:99-100`). +[`OpenLivePollHandler`](#openlivepollhandler) is identical in shape +(`.../UseCases/Open/OpenLivePollHandler.cs:100-112`). The two question command handlers add the +best-effort wrapper back, because their enqueue block also *reads the database*: [`SubmitQuestionHandler`](#submitquestionhandler) -(`.../UseCases/Submit/SubmitQuestionHandler.cs:117-155`), and +(`.../UseCases/Submit/SubmitQuestionHandler.cs:130-160`) and [`ModerateQuestionHandler`](#moderatequestionhandler) -(`.../UseCases/Moderate/ModerateQuestionHandler.cs:92-149`) each resolve a channel key, serialize a -small payload record to JSON, and call `ILiveChannelPublishQueue.Enqueue` -(`OpenLivePollHandler.cs:110-111`, `SubmitQuestionHandler.cs:130-131` and `:145-146`, -`ModerateQuestionHandler.cs:125` and `:139-140`). The two question handlers still wrap that block in a -`CA1031`-suppressed swallow-and-log catch (`SubmitQuestionHandler.cs:149-154`, -`ModerateQuestionHandler.cs:143-148`), because their Pending branch reads a fresh count from the -database before enqueueing and that read **must never fail the command**. -[`CreateLivePollHandler`](#createlivepollhandler) broadcasts -nothing at all: a poll is created as `Draft` and there is nothing for an audience to see yet. -Channel keys come from the two contract classes shared by publisher and subscriber, -[`LivePollChannel`](#livepollchannel) (`ForEvent` gives `event:1`, `ForSession` gives `session:123`, -`MMCA.ADC.Engagement.Shared/LivePolls/LivePollChannel.cs:24-30`) and +(`.../UseCases/Moderate/ModerateQuestionHandler.cs:104-156`) resolve the session channel key, +serialize a small payload record to JSON, and enqueue, but their Pending branch first counts the +session's pending questions (`SubmitQuestionHandler.cs:149-151`, `ModerateQuestionHandler.cs:144-146`) +and that read **must never fail a command that has already committed**. Both route through +`BestEffort.ExecuteAsync` (`SubmitQuestionHandler.cs:131`, `ModerateQuestionHandler.cs:136`) and both +deliberately withhold the caller's cancellation token, so an abandoned request cannot turn a saved +question into a cancelled broadcast (`SubmitQuestionHandler.cs:119-128`, +`ModerateQuestionHandler.cs:95-102`). One detail in `ModerateQuestionHandler` is worth copying: the +action-to-payload switch is built *outside* the guard (`:116-134`) so an unknown moderation action +faults loudly as an `ArgumentOutOfRangeException` instead of being swallowed as a missed broadcast. +[`CreateLivePollHandler`](#createlivepollhandler) broadcasts nothing at all and takes no queue in its +constructor (`.../UseCases/Create/CreateLivePollHandler.cs:20-24`): a poll is created as Draft and +there is nothing for an audience to see yet. Channel keys come from the two contract classes shared +by publisher and subscriber, [`LivePollChannel`](#livepollchannel) (`ForEvent` gives `event:1`, +`ForSession` gives `session:123`, `MMCA.ADC.Engagement.Shared/LivePolls/LivePollChannel.cs:24-30`) and [`SessionQuestionChannel`](#sessionquestionchannel); questions reuse the session key, so a session's -polls and questions ride one channel. +polls and questions ride one channel +(`MMCA.ADC.Engagement.Shared/SessionQuestions/SessionQuestionChannel.cs:6-8`). Two rules govern what is allowed on the channel. First, **broadcasts never carry per-user data** (BR-229): the results broadcast is built with `userId: null` so `MyVoteOptionId` stays null -(`LivePollVoteChangedHandler.cs:61-63`), and the upvote broadcast carries only the fresh count -(`SessionQuestionUpvoteChangedHandler.cs:65-73`). Second, **pending question content is never +(`LivePollVoteChangedHandler.cs:71-73`), and the upvote broadcast carries only the fresh count +(`SessionQuestionUpvoteChangedHandler.cs:75-83`). Second, **pending question content is never broadcast** (BR-238): full text rides the channel only on the approved payload, so when a pending question is submitted or leaves the queue the channel carries a `question.pending-count-changed` count instead, and moderators see the badge move without unmoderated text leaking -(`SubmitQuestionHandler.cs:126-140`, `ModerateQuestionHandler.cs:123-140`). +(`SubmitQuestionHandler.cs:147-158`, `ModerateQuestionHandler.cs:140-153`). -Three server-side guards round out the write path. Poll open/close and question moderation accept -the client's last-seen rowversion and stamp it back as the original ([ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html)), so a transition +Server-side guards round out the write path. Poll open/close and question moderation accept the +client's last-seen rowversion and stamp it back as the original +([ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html)), so a transition decided against a stale view fails with 409 Conflict rather than silently applying -(`OpenLivePollHandler.cs:46`, `CloseLivePollHandler.cs:45`, `ModerateQuestionHandler.cs:48`). The -vote path adds an explicit TOCTOU re-check (`CastVoteHandler.cs:108-122`): a rowversion conflict -cannot catch a concurrent close, because a vote only touches the `LivePollVote` row and never the -poll row, so the handler re-reads the poll immediately before saving and documents the accepted -millisecond residue (`CastVoteHandler.cs:98-107`). And question submission carries a spam cap: a -user may hold at most ten open (non-dismissed) questions per session -(`SessionQuestionInvariants.cs:19`, enforced at `SubmitQuestionHandler.cs:61-74`), so an -auto-approving event default cannot be used to flood the channel. +(`OpenLivePollHandler.cs:47`, `CloseLivePollHandler.cs:45`, `ModerateQuestionHandler.cs:53`). The two +hot paths add an explicit TOCTOU re-check instead (`CastVoteHandler.cs:98-122`, +`ToggleUpvoteHandler.cs:98-122`): a rowversion conflict cannot catch a concurrent close or dismissal, +because a vote only touches the `LivePollVote` row and never the poll row, so the handler re-reads +the aggregate immediately before saving and documents the accepted millisecond residue +(`CastVoteHandler.cs:98-107`). Only the upvote-*on* path re-checks; clearing an upvote is +deliberately still allowed after a dismissal or after the window closes +(`ToggleUpvoteHandler.cs:73-80`). And question submission carries a spam cap: a user may hold at most +ten open (non-dismissed) questions per session (`SessionQuestionInvariants.cs:21`, enforced at +`SubmitQuestionHandler.cs:72-83`), so an auto-approving event default cannot be used to flood the +channel. Both the constant and its enforcement document themselves as a **soft** cap: the count and +the insert are not one atomic step, so concurrent submits from the same user can briefly exceed it +and moderation drains the overflow (`SessionQuestionInvariants.cs:14-21`, +`SubmitQuestionHandler.cs:66-71`). ## One WebSocket, one publisher port, and a cross-service ingress -The transport itself is framework-owned ([ADR-039](https://ivanball.github.io/docs/adr/039-live-channel-push.html), [Group 10](group-10-notifications.md)). The single +The transport itself is framework-owned (ADR-039, [Group 10](group-10-notifications.md)). The single [`NotificationHub`](group-10-notifications.md#notificationhub) carries both durable notifications and channel events on one connection, and the application-layer port [`ILiveChannelPublisher`](group-10-notifications.md#ilivechannelpublisher) keeps the handlers -transport-free, exactly the way [`IPushNotificationSender`](group-10-notifications.md#ipushnotificationsender) -does for the durable path. Which implementation resolves tells you the deployment topology, the same -"resolvable everywhere, active only where configured" convention as the rest of the framework: +transport-free, exactly the way +[`IPushNotificationSender`](group-10-notifications.md#ipushnotificationsender) does for the durable +path. Which implementation resolves tells you the deployment topology, the same "resolvable +everywhere, active only where configured" convention as the rest of the framework: [`SignalRLiveChannelPublisher`](group-10-notifications.md#signalrlivechannelpublisher) group-sends -over the hub in a host that maps it; [`NullLiveChannelPublisher`](group-10-notifications.md#nulllivechannelpublisher) -is the no-op default. In ADC the twist is that the Engagement service does **not** map the hub (the -Notification service does), so Engagement's composition root replaces the registration with a -**gRPC adapter**, [`LiveChannelPublisherGrpcAdapter`](group-10-notifications.md#livechannelpublishergrpcadapter), -that forwards the pre-serialized JSON payload to the Notification service's +over the hub in a host that maps it; +[`NullLiveChannelPublisher`](group-10-notifications.md#nulllivechannelpublisher) is the no-op default. +In ADC the twist is that the Engagement service does **not** map the hub (the Notification service +does), so Engagement's composition root replaces the registration with a **gRPC adapter**, +[`LiveChannelPublisherGrpcAdapter`](group-10-notifications.md#livechannelpublishergrpcadapter), that +forwards the pre-serialized JSON payload to the Notification service's [`LiveChannelGrpcService`](group-10-notifications.md#livechannelgrpcservice) ingress, which then does -the real group send (`MMCA.ADC.Engagement.Service/Program.cs:188-197`). This is exactly the "a host -that does not map the hub can replace the registration with its own transport" extension point -[ADR-039](https://ivanball.github.io/docs/adr/039-live-channel-push.html) anticipates, and it rides the [ADR-012](https://ivanball.github.io/docs/adr/012-grpc-host-transport.html) mixed-endpoint gRPC profile (the Notification service -serves a dedicated `Http2`-only endpoint for this ingress alongside its WebSocket endpoint). Because -the payload is an opaque string at every hop, no serializer dependency crosses the wire, and the -queue drain, [`LiveChannelPublishProcessor`](group-22-engagement-module.md#livechannelpublishprocessor), -resolves the scoped adapter per item and logs-and-swallows every failure. +the real group send. The host calls one line for it (`MMCA.ADC.Engagement.Service/Program.cs:269`, +rationale at `:259-268`), and the extension behind that line does a `Replace`, not a `TryAdd`, so the +adapter beats the framework's Null default +(`MMCA.ADC.Notification.Contracts/DependencyInjection.cs:42-51`). This is exactly the "a host that +does not map the hub can replace the registration with its own transport" extension point ADR-039 +anticipates, and it rides the +[ADR-012](https://ivanball.github.io/docs/adr/012-grpc-host-transport.html) mixed-endpoint gRPC +profile (the Notification service serves a dedicated `Http2`-only endpoint for this ingress alongside +its WebSocket endpoint). Because the payload is an opaque string at every hop, no serializer +dependency crosses the wire. + +The queue between the handlers and that adapter is the part to understand before you trust the +latency story. `LiveChannelPublishQueue` is a bounded `System.Threading.Channels` channel of capacity +1024 with `FullMode = DropOldest` and `SingleReader = true` +(`MMCA.ADC.Engagement.Application/Live/LiveChannelPublishQueue.cs:18`, `:33-40`): under sustained +backpressure the *freshest* broadcast wins, which is the right trade for ephemeral data, and every +discard is counted and logged as a Warning through the channel's `itemDropped` callback (`:47`, +`:61-70`), because `TryWrite` under `DropOldest` can never report the drop itself (`:30-32`). The +single reader is +[`LiveChannelPublishProcessor`](group-22-engagement-module.md#livechannelpublishprocessor) +(`MMCA.ADC.Engagement.Infrastructure/Live/LiveChannelPublishProcessor.cs:30`), a `BackgroundService` +that resolves the scoped publisher per item (`:50-51`) and wraps each publish in `BestEffort` keyed by +the event name (`:45-46`), so a peer that stops accepting broadcasts is visible on the same meter; a +shutdown mid-publish stops the drain quietly (`:60-65`). FIFO through one reader is what preserves +per-session event ordering (`:13-14`). On the browser side, [`NotificationHubService`](group-15-common-ui-framework.md#notificationhubservice) (Common, [Group 15](group-15-common-ui-framework.md)) owns the one connection and exposes @@ -177,11 +226,15 @@ reconnect). Each page subscribes and joins in `OnAfterRenderAsync`, and leaves o [`SessionLive`](#sessionlive) does exactly this (`MMCA.ADC.Engagement.UI/Pages/SessionLive/SessionLive.razor.cs:117-132`, teardown at `:349-361`). The join is deliberately **not** `firstRender`-gated: the first render fires at the first `await` in -`OnInitializedAsync` while the session is still null, so a `firstRender`-only join never attached; -the stored channel key doubles as the already-joined guard, and the `RendererInfo.IsInteractive` -check keeps the prerender pass and the bUnit suite from dialing the hub -(`SessionLive.razor.cs:119-123`, and the same comment on -`Pages/HappeningNow/HappeningNow.razor.cs:111-118` and `Pages/SessionLive/PresenterView.razor.cs:90-97`). +`OnInitializedAsync` while the session is still null, so a `firstRender`-only join never attached; the +stored channel key doubles as the already-joined guard, and the `RendererInfo.IsInteractive` check +keeps the prerender pass and the bUnit suite from dialing the hub (`SessionLive.razor.cs:119-123`, +and the same comment on `Pages/HappeningNow/HappeningNow.razor.cs:111-115` and +`Pages/SessionLive/PresenterView.razor.cs:90-97`). `SessionLive` and `PresenterView` also skip their +data loads entirely on the prerender pass, since the interactive instance re-runs +`OnInitializedAsync` and nothing here is cache-served for a logged-in user +(`SessionLive.razor.cs:66-72`, `PresenterView.razor.cs:54-59`); `HappeningNow` knowingly does not, +and says why in a NOTE at `HappeningNow.razor.cs:71-72`. ## The read path and how the UI reacts @@ -190,114 +243,146 @@ Reads do not go through the generic entity-query machinery; the live views need (`MMCA.ADC.Engagement.Application/LivePolls/Services/LivePollResultsBuilder.cs:12`) computes each option's tally with a **grouped `COUNT` pushed into SQL** (one row per option instead of one row per vote, `:42-47`) and adds the caller's own `MyVoteOptionId` as a separate point read that broadcast -payloads skip entirely (`:51-60`); votes cast on an option later removed are excluded so the -per-option numbers still add up to the total (`:31-37`). -[`SessionQuestionViewBuilder`](#sessionquestionviewbuilder) +payloads skip entirely (`:51-61`); votes cast on an option later removed are excluded so the +per-option numbers still add up to the total (`:31-37`, `:79-81`), and the poll's concurrency token +travels back on the results DTO so a surface fed only by tallies can still issue an open or close +(`:84-87`). [`SessionQuestionViewBuilder`](#sessionquestionviewbuilder) (`.../SessionQuestions/Services/SessionQuestionViewBuilder.cs:12`) is its mirror for questions -(`:39-46`), adding per-caller `MyUpvote`/`IsMine` flags. Those two feed the query handlers behind -`GET /livepolls/open`, `/livepolls/{id}/results`, `/sessionquestions`, and +(`:39-46`), adding per-caller `MyUpvote`/`IsMine` flags (`:48-58`). Those two feed the query handlers +behind `GET /livepolls/open`, `/livepolls/{id}/results`, `/sessionquestions`, and `/sessionquestions/moderation`. [`GetOpenPollsHandler`](#getopenpollshandler) requires an explicit event or session scope (`.../GetOpenPolls/GetOpenPollsHandler.cs:24-30`) and excludes session-scoped -polls from the event-wide list (BR-230, `:41`); both question reads are bounded server-side at 200 -rows so a flooded session cannot produce an unbounded payload -(`.../GetSessionQuestions/GetSessionQuestionsHandler.cs:22`, -`.../GetModerationQueue/GetModerationQueueHandler.cs:26`), with the attendee view returning approved -questions most-upvoted-first followed by the caller's own non-approved ones (`:39-49`) and the -moderation view ordering Pending first. [`LivePollNavigationPopulator`](#livepollnavigationpopulator) -loads a poll's `Options` on query-service paths EF cannot `.Include()` ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html), -`.../LivePolls/Services/LivePollNavigationPopulator.cs:11`), the EF configurations +polls from the event-wide list (BR-230, `:41`). Both question reads are bounded server-side so a +flooded session cannot produce an unbounded payload, and the attendee read is the more interesting of +the two: [`GetSessionQuestionsHandler`](#getsessionquestionshandler) spends **two separate budgets**, +200 approved questions ranked by upvote count *in the database* before the cap applies (a correlated +`COUNT` subquery over the upvote table, since question and upvote are separate aggregates with no +navigation between them, `.../GetSessionQuestions/GetSessionQuestionsHandler.cs:32`, `:45-58`) plus +25 of the caller's own non-approved questions taken newest first (`:35`, `:60-69`), because one +shared budget filled by oldest id let a flood of low-value questions push both the most upvoted +question and the caller's own newest submission out of the payload (`:17-24`). The moderation read +caps at 200 and orders Pending first +(`.../GetModerationQueue/GetModerationQueueHandler.cs:26`, `:46-47`, `:54`). +[`LivePollNavigationPopulator`](#livepollnavigationpopulator) loads a poll's `Options` on +query-service paths EF cannot `.Include()` +([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html), +`.../LivePolls/Services/LivePollNavigationPopulator.cs:11-22`), the EF configurations ([`LivePollConfiguration`](#livepollconfiguration) and siblings) keep the Conference references as -scalar FK columns under database-per-service ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html), +scalar FK columns under database-per-service +([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html), `.../EntityConfiguration/LivePollConfiguration.cs:10-14`) and index the conference-day hot filter -`(SessionId, Status)` (`:40`), and entity-to-DTO mapping is a compile-time Mapperly mapper, -[`LivePollDTOMapper`](#livepolldtomapper) ([ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html), `.../LivePolls/DTOs/LivePollDTOMapper.cs:13`). +`(SessionId, Status)` (`:36-40`), and entity-to-DTO mapping is a compile-time Mapperly mapper, +[`LivePollDTOMapper`](#livepolldtomapper) +([ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html), +`.../LivePolls/DTOs/LivePollDTOMapper.cs:13`). When a channel event arrives, the page decides between **patch-in-place** and **reload**, and this is -the chapter's key performance lesson. The two high-frequency tally events -(`poll.results-changed`, `question.upvote-changed`) already carry the fresh counts in their payload, -so the page patches its in-memory model and calls `StateHasChanged` with **no HTTP refetch** -(`SessionLive.razor.cs:187-258`), falling back to a targeted reload when the payload cannot be -applied. The comment there records why: reloading on every broadcast turned *V* voters times *C* -viewers into *V\*C* authenticated refetches per hot poll, which collided with the per-user rate -limiter under burst voting (`SessionLive.razor.cs:136-140`). Structural events (opened, closed, -approved, answered, dismissed, pending-count-changed) are rarer and *do* trigger a targeted reload of -the affected list (`:146-179`). `SessionLive` itself is the container that owns the lists, the -channel subscription, and the shared saving flag, while the three sections render through the -presentational [`SessionLivePollPanel`](#sessionlivepollpanel), +the chapter's key performance lesson. The two high-frequency tally events (`poll.results-changed`, +`question.upvote-changed`) already carry the fresh counts in their payload, so the page patches its +in-memory model and calls `StateHasChanged` with **no HTTP refetch** +(`SessionLive.razor.cs:187-258`), preserving this circuit's own vote marker across the patch because +the broadcast strips per-user data (`:229`), and falling back to a targeted reload when the payload +cannot be applied (`:203-208`). The comment there records why: reloading on every broadcast turned V +voters times C viewers into V times C authenticated refetches per hot poll, which collided with the +per-user rate limiter under burst voting (`SessionLive.razor.cs:136-140`). Structural events (opened, +closed, approved, answered, dismissed, pending-count-changed) are rarer and *do* trigger a targeted +reload of the affected list (`:146-179`), and a failed background refresh degrades to a snackbar +rather than crashing the page (`:271-276`). `SessionLive` itself is the container that owns the +lists, the channel subscription, and the shared saving flag, while the three sections render through +the presentational [`SessionLivePollPanel`](#sessionlivepollpanel), [`SessionLiveQuestionPanel`](#sessionlivequestionpanel), and [`SessionLiveModerationPanel`](#sessionlivemoderationpanel) children (`SessionLive.razor.cs:14-24`). -Whether the layer is even active is decided by [`LiveEventService`](#liveeventservice) +The single session it renders is a point read through +[`ISessionLookupService`](#isessionlookupservice) rather than a full catalog fetch +(`SessionLive.razor.cs:85-87`, `PresenterView.razor.cs:63-65`). Whether the layer is even active is +decided by [`LiveEventService`](#liveeventservice) (`MMCA.ADC.Engagement.UI/Services/LiveEventService.cs:14`): it fetches the current-or-next published event through [`CurrentEventSelector`](group-17-conference-domain.md#currenteventselector) and -computes its live window into a [`LiveEventContext`](#liveeventcontext) with the same math the -backend enforces (`:27-46`), degrading to `null` on an API failure (`:48-52`) so the live surfaces -simply stay dormant rather than error; `HappeningNow` joins the event channel only while -`IsLiveAt` is true (`LiveEventContext.cs:22`, `HappeningNow.razor.cs:120-128`). The cross-module -[`ISessionLiveUIService`](#isessionliveuiservice) / [`SessionLiveUIService`](#sessionliveuiservice) -contract is what lets a Conference session page light up its "Live" button when Engagement is -deployed (`MMCA.ADC.Engagement.UI/Services/SessionLiveUIService.cs:13-14`). +computes its live window with the same math the backend enforces (`:27-46`), degrading to `null` on an +API failure (`:48-52`) so the live surfaces simply stay dormant rather than error; `HappeningNow` +joins the event channel only while `IsLiveAt` is true +(`MMCA.ADC.Engagement.UI/Services/LiveEventContext.cs:22`, `HappeningNow.razor.cs:120-128`). The +cross-module [`ISessionLiveUIService`](#isessionliveuiservice) / +[`SessionLiveUIService`](#sessionliveuiservice) contract is what lets a Conference session page light +up its "Live" button when Engagement is deployed +(`MMCA.ADC.Engagement.UI/Services/SessionLiveUIService.cs:10-14`). ## Authorization, feature gating, and the cross-service dependency on Conference Both controllers, [`LivePollsController`](#livepollscontroller) -(`MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:42`) and +(`MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:44`) and [`SessionQuestionsController`](#sessionquestionscontroller) -(`.../Controllers/SessionQuestionsController.cs:37`), sit behind +(`.../Controllers/SessionQuestionsController.cs:39`), sit behind [`ApiControllerBase`](group-12-api-hosting-mapping.md#apicontrollerbase) and are gated two ways: `[Authorize(Policy = AuthorizationPolicies.RequireAuthenticated)]` ([`AuthorizationPolicies`](group-08-auth.md#authorizationpolicies), no anonymous participation) and a `[FeatureGate]` per feature ([`EngagementFeatures`](group-22-engagement-module.md#engagementfeatures) `LivePolls` / `SessionQA`) that makes the whole surface vanish (404) when toggled off -(`LivePollsController.cs:40-41`, `SessionQuestionsController.cs:35-36`). The finer +(`LivePollsController.cs:42-43`, `SessionQuestionsController.cs:37-38`). The finer authoring/moderation rights (BR-236) are enforced **in the handlers**, not by an attribute, through the shared [`LivePollAuthorization`](#livepollauthorization) check (`.../LivePolls/Services/LivePollAuthorization.cs:22-44`): organizers and admins manage everything, and a speaker manages only content scoped to a session they are assigned to (matched against the -[`SessionLiveInfo`](group-17-conference-domain.md#sessionliveinfo)`.SpeakerIds` list from -Conference). The organizer-only manage list and the delete endpoint additionally carry -`[HasPermission(EngagementPermissions.LiveManage)]` ([ADR-020](https://ivanball.github.io/docs/adr/020-permission-based-authorization.html), `LivePollsController.cs:120`, `:139`). -Crucially, the caller's identity (user id, `speaker_id` claim, roles) is always bound from the token -via [`ICurrentUserService`](group-08-auth.md#icurrentuserservice), never from the request body -(`LivePollsController.cs:228-236`). Lifecycle POSTs take an optional -[`LifecycleTransitionRequest`](group-22-engagement-module.md#lifecycletransitionrequest) body purely -to carry the rowversion (`LivePollsController.cs:83`). +[`SessionLiveInfo`](group-17-conference-domain.md#sessionliveinfo)`.SpeakerIds` list from Conference). +The organizer-only manage list and the delete endpoint additionally carry +[`[HasPermission(EngagementPermissions.LiveManage)]`](group-08-auth.md#haspermissionattribute) +([ADR-020](https://ivanball.github.io/docs/adr/020-permission-based-authorization.html), +`LivePollsController.cs:150`, `:169`). Crucially, the caller's identity (user id, `speaker_id` claim, +roles) is always bound from the token via +[`ICurrentUserService`](group-08-auth.md#icurrentuserservice), never from the request body +(`LivePollsController.cs:262-271`). Two Common API behaviors show up on these routes as well: every +mutating endpoint is marked [`[Idempotent]`](group-12-api-hosting-mapping.md#idempotentattribute) +([ADR-017](https://ivanball.github.io/docs/adr/017-request-idempotency.html)) so a conference-day +retry over flaky wifi replays the first response instead of creating a second poll, question, or vote +(`LivePollsController.cs:62`, `:92`, `:238`, `SessionQuestionsController.cs:54`), and the two +lifecycle POSTs add [`[SupportsIfMatch]`](group-12-api-hosting-mapping.md#supportsifmatchattribute) so +the same rowversion may instead be stated as an HTTP `If-Match` header, in which case a stale token +answers 412 rather than 409 (`LivePollsController.cs:93`, `:128`, rationale at `:82-89`). The optional +[`LifecycleTransitionRequest`](group-22-engagement-module.md#lifecycletransitionrequest) body exists +purely to carry that token (`LivePollsController.cs:102`). This makes the live layer **dependent on Conference**, the same modular-monolith boundary Group 22 -demonstrated ([ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html)/[ADR-008](https://ivanball.github.io/docs/adr/008-service-extraction-topology.html)). Engagement calls Conference's +demonstrated ([ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html) / +[ADR-008](https://ivanball.github.io/docs/adr/008-service-extraction-topology.html)). Engagement +calls Conference's [`IEventLiveValidationService`](group-17-conference-domain.md#ieventlivevalidationservice) to fetch the live window, the session's assigned speakers, and the event's moderation default (in-process when -co-hosted, over gRPC when extracted: `MMCA.ADC.Engagement.Service/Program.cs:183-186`), and on the +co-hosted, over gRPC when extracted: `MMCA.ADC.Engagement.Service/Program.cs:254-257`), and on the client side the Conference session page reaches back through the Engagement UI's `ISessionLiveUIService` implementation for the Live route. The [`EngagementModule`](group-22-engagement-module.md#engagementmodule) declares the dependency, and the same disabled-stub registrations keep every interface resolvable in a single-module service host, -which is why `SessionQuestion.Create` tolerates a default `EventId` (`SessionQuestion.cs:24`, `:64-67`). -The UI clients ([`LivePollUIService`](#livepolluiservice), [`SessionQuestionUIService`](#sessionquestionuiservice)) -extend Common's [`AuthenticatedServiceBase`](group-15-common-ui-framework.md#authenticatedservicebase) -and go back through the Gateway's public REST routes, not a back channel -(`MMCA.ADC.Engagement.UI/Services/LivePollUIService.cs:14-18`). +which is why `SessionQuestion.Create` tolerates a default `EventId` (`SessionQuestion.cs:24`, +`:64-67`). The UI clients ([`LivePollUIService`](#livepolluiservice), +[`SessionQuestionUIService`](#sessionquestionuiservice)) extend Common's +[`AuthenticatedServiceBase`](group-15-common-ui-framework.md#authenticatedservicebase) and go back +through the Gateway's public REST routes, not a back channel +(`MMCA.ADC.Engagement.UI/Services/LivePollUIService.cs:15-19`). **Rubric lenses this chapter exercises.** `[Rubric §4, DDD]` (two aggregates with lifecycle state machines, invariant guards, the live-window snapshot, and the single-event-with-state design); `[Rubric §6, CQRS & Event-Driven]` (command/query slices, durable domain events over the outbox, and -the *separate* ephemeral channel broadcast that two of those domain events now trigger); -`[Rubric §7, Microservices Readiness]` (the `ILiveChannelPublisher` port with a SignalR -implementation, a Null default, and a gRPC forwarding adapter, plus the Conference validation -boundary); `[Rubric §12, Performance & Scalability]` (per-vote checks against a snapshotted window -with no cross-service hop, grouped-`COUNT` tallies, bounded reads, an off-request-path publish queue, -and patch-in-place tally updates that avoid the *V\*C* refetch storm against the rate limiter); -`[Rubric §11, Security]` (RequireAuthenticated plus feature gates plus handler-enforced -speaker-scoped rights plus `HasPermission`, identity from token, anonymous question display, the -open-question spam cap, and pending text kept off the channel, BR-238); `[Rubric §9, API & Contract -Design]` (feature-gated, versioned REST endpoints returning Problem Details, with 409 on a stale -lifecycle transition); `[Rubric §18/§19, UI Architecture / State Management]` (three live surfaces -over one multicast hub subscription, a container page with presentational panels, patch-vs-reload -event handling, re-join on reconnect); `[Rubric §29, Resilience]` (post-commit best-effort -broadcasts that never fail the command and a UI that treats channel events as hints over fetchable -state, degrading to dormant on failure); and `[Rubric §13, Observability]` (every failed publish and -every broadcast discarded under backpressure is logged as a warning). Each is taught in full at the -relevant per-type section -below. +the *separate* ephemeral channel broadcast that two of those domain events trigger); `[Rubric §7, +Microservices Readiness]` (the `ILiveChannelPublisher` port with a SignalR implementation, a Null +default, and a gRPC forwarding adapter swapped in by `Replace`, plus the Conference validation +boundary); `[Rubric §8, Data Architecture]` (filtered unique indexes behind the create-or-reactivate +rule, the `(SessionId, Status)` conference-day index, and cross-context references kept as scalar FK +columns); `[Rubric §12, Performance & Scalability]` (per-vote checks against a snapshotted window +with no cross-service hop, grouped-`COUNT` tallies, database-side ranking before a cap, a bounded +drop-oldest publish queue off the request path, and patch-in-place tally updates that avoid the +V-times-C refetch storm against the rate limiter); `[Rubric §11, Security]` (RequireAuthenticated plus +feature gates plus handler-enforced speaker-scoped rights plus `HasPermission`, identity from token, +anonymous question display, the open-question spam cap, and pending text kept off the channel, +BR-238); `[Rubric §9, API & Contract Design]` (feature-gated, versioned REST endpoints returning +Problem Details, idempotent mutations, and 409-or-412 on a stale lifecycle transition); `[Rubric +§18/§19, UI Architecture / State Management]` (three live surfaces over one multicast hub +subscription, a container page with presentational panels, patch-vs-reload event handling, re-join on +reconnect, prerender-skipped loads); `[Rubric §29, Resilience]` (post-commit best-effort broadcasts +that never fail the command, a drain that swallows every publish failure, and a UI that treats +channel events as hints over fetchable state, degrading to dormant on failure); and `[Rubric §13, +Observability]` (every failed broadcast counted on `besteffort.dispatch.failed` and every broadcast +discarded under backpressure logged with a running total). Each is taught in full at the relevant +per-type section below. ### CastVoteCommand > MMCA.ADC.Engagement.Application · `MMCA.ADC.Engagement.Application.LivePolls.UseCases.CastVote` · `MMCA.ADC.Engagement.Application/LivePolls/UseCases/CastVote/CastVoteCommand.cs:11` · Level 0 · record @@ -1286,163 +1371,189 @@ below. ### ModerateQuestionCommand > MMCA.ADC.Engagement.Application · `MMCA.ADC.Engagement.Application.SessionQuestions.UseCases.Moderate` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/Moderate/ModerateQuestionCommand.cs:15` · Level 1 · record -- **What it is**: the CQRS command that carries one moderation action (approve / dismiss / mark-answered) against a single session question, together with the caller's identity as resolved at the API edge. -- **Depends on**: [`ModerationAction`](#moderationaction) (the action enum, same group) and the module identifier aliases `SessionQuestionIdentifierType` / `SpeakerIdentifierType` (Engagement/Conference `Shared`); dispatched to [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult). -- **Concept introduced, identity-from-token commands.** `[Rubric §11, Security]` assesses whether authorization inputs come from a trusted source rather than the request body; here the command records `CallerSpeakerId` and `CallerIsOrganizer` (`ModerateQuestionCommand.cs:17-18`) which the controller binds from JWT claims, never from client-supplied JSON, so an attacker cannot claim organizer rights by editing the payload. `[Rubric §6, CQRS & Event-Driven]` is the plain command-as-record shape. -- **Walkthrough**: a `sealed record` with four positional members (`ModerateQuestionCommand.cs:14-18`): `QuestionId` (which question), `Action` (the [`ModerationAction`](#moderationaction) to apply), `CallerSpeakerId` (nullable `SpeakerIdentifierType?`, present only for speakers), and `CallerIsOrganizer` (a `bool` set when the caller holds the Organizer or Admin role). The last two are the BR-236 rights inputs the handler checks. -- **Why it's built this way**: keeping caller identity *in the command* (rather than reaching into `HttpContext` from the handler) keeps the Application layer host-agnostic and unit-testable, and makes the trust boundary explicit: the API edge is the only place that reads claims. -- **Where it's used**: constructed by [`SessionQuestionsController`](#sessionquestionscontroller)'s private `ModerateAsync` (`SessionQuestionsController.cs:171`) and handled by [`ModerateQuestionHandler`](#moderatequestionhandler). +- **What it is**: the CQRS command that carries one moderation action (approve / dismiss / mark-answered) against a single session question, together with the caller's identity as resolved at the API edge and the client's last-seen concurrency token. +- **Depends on**: [`ModerationAction`](#moderationaction) (the action enum, same group) and the module identifier aliases `SessionQuestionIdentifierType` (Engagement `Shared`) / `SpeakerIdentifierType` (Conference `Shared`); dispatched through [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult). +- **Concept introduced, identity-from-token commands.** `[Rubric §11, Security]` assesses whether authorization inputs come from a trusted source rather than the request body. Here the command records `CallerSpeakerId` and `CallerIsOrganizer` (`ModerateQuestionCommand.cs:18-19`), which the controller binds from JWT claims, never from client-supplied JSON, so an attacker cannot claim organizer rights by editing the payload. The doc comment states the rule the pair encodes (BR-236: organizers and admins moderate everything, a session's assigned speakers moderate their own session's questions, `ModerateQuestionCommand.cs:6-8`). `[Rubric §6, CQRS & Event-Driven]` is the plain command-as-record shape. +- **Walkthrough**: a `sealed record` with five positional members (`ModerateQuestionCommand.cs:15-20`): `QuestionId` (which question), `Action` (the [`ModerationAction`](#moderationaction) to apply), `CallerSpeakerId` (nullable `SpeakerIdentifierType?`, present only for speakers), `CallerIsOrganizer` (a `bool` set when the caller holds the Organizer or Admin role), and `RowVersion` (a `byte[]?` defaulting to `null`, `:20`). That last member is the optimistic-concurrency token from [ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html): passing `null` deliberately **skips** the stale-view check (`:14`), so a caller without a token can still moderate while the UI always sends one. +- **Why it's built this way**: keeping caller identity *in the command* (rather than reaching into `HttpContext` from the handler) keeps the Application layer host-agnostic and unit-testable, and makes the trust boundary explicit, since the API edge is the only place that reads claims. Making `RowVersion` an optional trailing parameter lets the concurrency check be opt-in per call site without a second command type. +- **Where it's used**: constructed by [`SessionQuestionsController`](#sessionquestionscontroller)'s private `ModerateAsync` (`SessionQuestionsController.cs:238`), which the three moderation verbs delegate to with a fixed [`ModerationAction`](#moderationaction) (`SessionQuestionsController.cs:149,177,205`); handled by [`ModerateQuestionHandler`](#moderatequestionhandler). ### LivePollChanged > MMCA.ADC.Engagement.Domain · `MMCA.ADC.Engagement.Domain.LivePolls.DomainEvents` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/DomainEvents/LivePollChanged.cs:17` · Level 2 · record - **What it is**: the single domain event a [`LivePoll`](#livepoll) raises for its whole lifecycle: created, opened, closed, or soft-deleted. - **Depends on**: [`BaseDomainEvent`](group-04-events-outbox.md#basedomainevent) (base), [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate) (the change classifier), [`LivePollStatus`](#livepollstatus) (the lifecycle status), and the `LivePollIdentifierType` / `EventIdentifierType` aliases. -- **Concept introduced, one event carrying a state discriminator (BR-60).** `[Rubric §6, CQRS & Event-Driven]` assesses whether events carry enough context to be acted on without a re-read. Rather than four separate `Created` / `Opened` / `Closed` / `Deleted` events, this codebase uses **one** event whose [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate) says *what kind* of change happened and whose [`LivePollStatus`](#livepollstatus) says the *resulting* lifecycle state (doc comment, `LivePollChanged.cs:7-11`). A consumer switches on those two fields. This BR-60 convention is shared by all four live-layer events below, so learn it once here. -- **Walkthrough**: a `sealed record class` with four positional members deriving from [`BaseDomainEvent`](group-04-events-outbox.md#basedomainevent) (`LivePollChanged.cs:17-22`): `State`, `PollId`, `EventId`, `Status`. There is no behavior, an event is an immutable fact. -- **Why it's built this way**: the base carries the event id and timestamp; collapsing the transition matrix into one typed record keeps the outbox schema and the handler set small while still letting handlers distinguish an open from a close ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html) for the outbox that drains these; [ADR-039](https://ivanball.github.io/docs/adr/039-live-channel-push.html) for the live-channel transport that rebroadcasts them). -- **Where it's used**: raised inside [`LivePoll`](#livepoll)'s `Create` / `Open` / `Close` / `Delete` (`LivePoll.cs:94,131,154,232`); drained by the outbox and rebroadcast onto the SignalR live channel. +- **Concept introduced, one event carrying a state discriminator (BR-60).** `[Rubric §6, CQRS & Event-Driven]` assesses whether events carry enough context to be acted on without a re-read. Rather than four separate `Created` / `Opened` / `Closed` / `Deleted` events, this codebase raises **one** event whose [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate) says *what kind* of change happened and whose [`LivePollStatus`](#livepollstatus) says the *resulting* lifecycle state (doc comment, `LivePollChanged.cs:7-11`). A consumer switches on those two fields. This BR-60 convention is shared by all four live-layer events below, so learn it once here. +- **Walkthrough**: a `sealed record class` deriving from [`BaseDomainEvent`](group-04-events-outbox.md#basedomainevent) with four positional members (`LivePollChanged.cs:17-22`): `State` (`:18`), `PollId` (`:19`), `EventId` (`:20`), `Status` (`:21`). There is no behavior; an event is an immutable fact. +- **Why it's built this way**: the base carries the event identity and timestamp, and collapsing the transition matrix into one typed record keeps the outbox schema and the handler set small while still letting a handler distinguish an open from a close ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html) for the outbox that drains domain events; [ADR-039](https://ivanball.github.io/docs/adr/039-live-channel-push.html) for the live-channel transport). +- **Where it's used**: raised inside [`LivePoll`](#livepoll)'s `Create` / `Open` / `Close` / `Delete` (`LivePoll.cs:94,131,154,232`). +- **Caveats / not-in-source**: unlike its three siblings, `LivePollChanged` has **no** `IDomainEventHandler` implementation anywhere in the ADC source today. Poll lifecycle broadcasts are enqueued directly by the poll command handlers; the event is raised and dispatched, but nothing in-repo subscribes to it. ### LivePollVoteChanged -> MMCA.ADC.Engagement.Domain · `MMCA.ADC.Engagement.Domain.LivePolls.DomainEvents` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/DomainEvents/LivePollVoteChanged.cs:15` · Level 2 · record +> MMCA.ADC.Engagement.Domain · `MMCA.ADC.Engagement.Domain.LivePolls.DomainEvents` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/DomainEvents/LivePollVoteChanged.cs:21` · Level 2 · record - **What it is**: the single domain event a [`LivePollVote`](#livepollvote) raises when a vote is cast, changed to another option, or soft-deleted. - **Depends on**: [`BaseDomainEvent`](group-04-events-outbox.md#basedomainevent), [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate), and the `LivePollVoteIdentifierType` / `LivePollIdentifierType` / `LivePollOptionIdentifierType` / `UserIdentifierType` aliases. -- **Concept reinforced, BR-60 single-event pattern** (introduced at [`LivePollChanged`](#livepollchanged)). `[Rubric §6, CQRS & Event-Driven]`. Here the payload additionally carries the `OptionId` chosen after the change, so a downstream tally re-computation knows which option moved. -- **Walkthrough**: a `sealed record class : BaseDomainEvent` with five positional members (`LivePollVoteChanged.cs:15-21`): `State`, `VoteId`, `PollId`, `OptionId`, `UserId`. -- **Why it's built this way**: votes are high-frequency, so the event stays a thin id-only fact (no denormalized counts); consumers that need tallies recompute them via [`LivePollResultsBuilder`](#livepollresultsbuilder). -- **Where it's used**: raised inside [`LivePollVote`](#livepollvote)'s `Create` / `ChangeOption` / `Reactivate` / `Delete` (`LivePollVote.cs:66,85,108,124`). +- **Concept introduced, the zero-id trap on `Added` events.** `[Rubric §6, CQRS & Event-Driven]` also covers whether a consumer can actually correlate an event back to its row. This entity's identity is database-generated (`[IdValueGenerated]`, see [`LivePollVote`](#livepollvote)), and the event is constructed **before** the INSERT runs and captured by value, so `VoteId` is **zero** for a brand-new vote and is never re-stamped afterwards (`LivePollVoteChanged.cs:11-17`). A reactivated vote does carry a real id, because that row already exists. The documented contract is therefore: correlate on `PollId` and `UserId`, which are both set before the event is raised, and never on `VoteId`. The same trap and the same workaround appear on [`SessionQuestionChanged`](#sessionquestionchanged) and [`SessionQuestionUpvoteChanged`](#sessionquestionupvotechanged). +- **Concept reinforced, BR-60 single-event pattern** (introduced at [`LivePollChanged`](#livepollchanged); restated at `LivePollVoteChanged.cs:8`). Here the payload additionally carries the `OptionId` chosen *after* the change, so a downstream tally recomputation knows which option moved. +- **Walkthrough**: a `sealed record class : BaseDomainEvent` with five positional members (`LivePollVoteChanged.cs:21-27`): `State` (`:22`), `VoteId` (`:23`), `PollId` (`:24`), `OptionId` (`:25`), `UserId` (`:26`). +- **Why it's built this way**: votes are high-frequency, so the event stays a thin id-only fact with no denormalized counts; consumers that need tallies recompute them through [`LivePollResultsBuilder`](#livepollresultsbuilder). +- **Where it's used**: raised inside [`LivePollVote`](#livepollvote)'s `Create` / `ChangeOption` / `Reactivate` / `Delete` (`LivePollVote.cs:67,86,109,125`); consumed by [`LivePollVoteChangedHandler`](#livepollvotechangedhandler), which rebuilds the tallies and enqueues a `poll.results-changed` broadcast (`LivePollVoteChangedHandler.cs:16-17,41,47`). ### SessionQuestionChanged -> MMCA.ADC.Engagement.Domain · `MMCA.ADC.Engagement.Domain.SessionQuestions.DomainEvents` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/SessionQuestions/DomainEvents/SessionQuestionChanged.cs:17` · Level 2 · record +> MMCA.ADC.Engagement.Domain · `MMCA.ADC.Engagement.Domain.SessionQuestions.DomainEvents` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/SessionQuestions/DomainEvents/SessionQuestionChanged.cs:30` · Level 2 · record - **What it is**: the single domain event a [`SessionQuestion`](#sessionquestion) raises when it is submitted, moderated, or soft-deleted. -- **Depends on**: [`BaseDomainEvent`](group-04-events-outbox.md#basedomainevent), [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate), [`QuestionStatus`](#questionstatus), and the `SessionQuestionIdentifierType` / `SessionIdentifierType` aliases. -- **Concept reinforced, BR-60 single-event pattern** (see [`LivePollChanged`](#livepollchanged)). `[Rubric §6, CQRS & Event-Driven]`. The Q&A analogue of [`LivePollChanged`](#livepollchanged): [`QuestionStatus`](#questionstatus) rides along so a handler can tell a Submitted question from an Approved / Dismissed / Answered one (doc comment, `SessionQuestionChanged.cs:7-11`). -- **Walkthrough**: a `sealed record class : BaseDomainEvent` with four positional members (`SessionQuestionChanged.cs:17-22`): `State`, `QuestionId`, `SessionId`, `Status`. -- **Why it's built this way**: identical rationale to [`LivePollChanged`](#livepollchanged), a compact lifecycle fact instead of five per-transition event types. -- **Where it's used**: raised inside the [`SessionQuestion`](#sessionquestion) aggregate on submit and each moderation transition. +- **Depends on**: [`BaseDomainEvent`](group-04-events-outbox.md#basedomainevent), [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate), [`QuestionStatus`](#questionstatus), and the `SessionQuestionIdentifierType` / `SessionIdentifierType` / `UserIdentifierType` aliases. +- **Concept reinforced, BR-60 single-event pattern** (see [`LivePollChanged`](#livepollchanged)). `[Rubric §6, CQRS & Event-Driven]`. The Q&A analogue of [`LivePollChanged`](#livepollchanged): [`QuestionStatus`](#questionstatus) rides along so a handler can tell a Submitted question from an Approved / Dismissed / Answered one (doc comment, `SessionQuestionChanged.cs:8-11`). +- **Concept reinforced, the zero-id trap** (see [`LivePollVoteChanged`](#livepollvotechanged)). `QuestionId` is **zero** on the `Added` path because the identity is generated by the INSERT and the event is captured while the aggregate is still new (`SessionQuestionChanged.cs:16-22`). This is exactly why `UserId` is on the event at all: it is carried rather than read back from the row precisely because `QuestionId` is unusable on that path (`:24-28`). `[Rubric §30, Compliance/Privacy/Data Governance]` is worth noting here: the same doc comment states `UserId` is **never surfaced on a DTO**, because questions display anonymously (BR-238). The event is an internal correlation channel, not a projection source. +- **Walkthrough**: a `sealed record class : BaseDomainEvent` with five positional members (`SessionQuestionChanged.cs:30-36`): `State` (`:31`), `QuestionId` (`:32`), `SessionId` (`:33`), `UserId` (`:34`), `Status` (`:35`). +- **Why it's built this way**: identical rationale to [`LivePollChanged`](#livepollchanged), a compact lifecycle fact instead of five per-transition event types, with the submitter id added as the only reliable correlation key on the create path. +- **Where it's used**: raised inside [`SessionQuestion`](#sessionquestion) on create, on each moderation transition, and on delete (`SessionQuestion.cs:109,134,158,190,234`); consumed by [`SessionQuestionSubmittedPointsHandler`](group-22-engagement-module.md#sessionquestionsubmittedpointshandler), which must filter to the submission case because the same event also fires for moderation and deletion (`SessionQuestionSubmittedPointsHandler.cs:15,53`). ### SessionQuestionUpvoteChanged -> MMCA.ADC.Engagement.Domain · `MMCA.ADC.Engagement.Domain.SessionQuestions.DomainEvents` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/SessionQuestions/DomainEvents/SessionQuestionUpvoteChanged.cs:14` · Level 2 · record +> MMCA.ADC.Engagement.Domain · `MMCA.ADC.Engagement.Domain.SessionQuestions.DomainEvents` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/SessionQuestions/DomainEvents/SessionQuestionUpvoteChanged.cs:20` · Level 2 · record - **What it is**: the single domain event a [`SessionQuestionUpvote`](#sessionquestionupvote) raises when an upvote is cast, reactivated, or removed (soft-deleted). - **Depends on**: [`BaseDomainEvent`](group-04-events-outbox.md#basedomainevent), [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate), and the `SessionQuestionUpvoteIdentifierType` / `SessionQuestionIdentifierType` / `UserIdentifierType` aliases. -- **Concept reinforced, BR-60 single-event pattern** (see [`LivePollChanged`](#livepollchanged)). `[Rubric §6, CQRS & Event-Driven]`. The thinnest of the four: an upvote has only two meaningful states, so the doc comment notes `Added` covers cast/reactivated and `Deleted` covers un-upvoted (`SessionQuestionUpvoteChanged.cs:10`). -- **Walkthrough**: a `sealed record class : BaseDomainEvent` with four positional members (`SessionQuestionUpvoteChanged.cs:14-19`): `State`, `UpvoteId`, `QuestionId`, `UserId`. No status field, upvotes have no lifecycle beyond active/removed. -- **Why it's built this way**: same BR-60 economy as its siblings; because there is no status enum, the `DomainEntityState` alone fully describes the change. -- **Where it's used**: raised inside the [`SessionQuestionUpvote`](#sessionquestionupvote) aggregate on cast / reactivate / remove. +- **Concept reinforced, BR-60 single-event pattern** (see [`LivePollChanged`](#livepollchanged)) plus the zero-id trap (see [`LivePollVoteChanged`](#livepollvotechanged)). `[Rubric §6, CQRS & Event-Driven]`. This is the thinnest of the four: an upvote has only two meaningful states, so the doc comment notes `Added` covers both cast and reactivated while `Deleted` covers un-upvoted (`SessionQuestionUpvoteChanged.cs:10`), and `UpvoteId` carries the same "zero on a brand-new row, real on a reactivation" caveat with `QuestionId` and `UserId` as the correlation keys (`:11-17`). +- **Walkthrough**: a `sealed record class : BaseDomainEvent` with four positional members (`SessionQuestionUpvoteChanged.cs:20-25`): `State` (`:21`), `UpvoteId` (`:22`), `QuestionId` (`:23`), `UserId` (`:24`). No status field: upvotes have no lifecycle beyond active and removed. +- **Why it's built this way**: same BR-60 economy as its siblings, and because there is no status enum the [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate) alone fully describes the change. +- **Where it's used**: raised inside [`SessionQuestionUpvote`](#sessionquestionupvote) on create, reactivate, and delete (`SessionQuestionUpvote.cs:60,76,91`); consumed by [`SessionQuestionUpvoteChangedHandler`](#sessionquestionupvotechangedhandler). ### LivePollAuthorization > MMCA.ADC.Engagement.Application · `MMCA.ADC.Engagement.Application.LivePolls.Services` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/Services/LivePollAuthorization.cs:12` · Level 3 · class (static, internal) -- **What it is**: the one shared rights check for the whole live layer: decides whether a caller may manage (author, open, close, moderate) content in a given scope. +- **What it is**: the one shared rights check for the whole live layer. It decides whether a caller may manage (author, open, close, moderate) content in a given scope. - **Depends on**: [`SessionLiveInfo`](group-17-conference-domain.md#sessionliveinfo) (the Conference-owned session snapshot it inspects), [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error). -- **Concept introduced, the BR-236 rights shape as one authorization gate.** `[Rubric §11, Security]` assesses whether authorization is centralized and consistent rather than re-implemented per endpoint. Every live-layer mutation (poll create/open/close and question moderate) routes its rights decision through this single method, so the rule "organizers/admins do everything; a speaker manages only content scoped to a session they are assigned to" lives in exactly one place. `[Rubric §1, SOLID]` (single responsibility: authorization is not smeared across handlers). `[Rubric §7, Microservices Readiness]`: the speaker-assignment fact comes from the Conference service via [`SessionLiveInfo.SpeakerIds`](group-17-conference-domain.md#sessionliveinfo), so this check consumes a cross-service snapshot rather than reaching into another module's tables. -- **Walkthrough**: one static method `EnsureCanManage(bool callerIsOrganizer, SpeakerIdentifierType? callerSpeakerId, SessionLiveInfo? sessionInfo, string source)` (`LivePollAuthorization.cs:22-44`). Order matters: an organizer/admin short-circuits to `Result.Success()` (`:28-31`); otherwise, if a session scope is supplied *and* the caller has a speaker id *and* that id is in `sessionInfo.SpeakerIds` (`:33-35`), success; anything else returns `Error.Forbidden("LivePoll.NotAuthorized", …)` (`:40-43`). Passing `sessionInfo` as `null` (event-wide scope) means only organizers/admins pass, exactly the intent for event-wide polls. -- **Why it's built this way**: a pure static helper keeps the rule dependency-free and trivially unit-testable, and the explicit `source` parameter threads the calling handler name into the error for stack-free tracing (the codebase's invariant-error convention). -- **Where it's used**: called by [`ModerateQuestionHandler`](#moderatequestionhandler) (`ModerateQuestionHandler.cs:55-56`) and, per its doc comment, by the poll create/open/close handlers (the BR-236 shape referenced from [`LivePollsController`](#livepollscontroller)). +- **Concept introduced, the BR-236 rights shape as one authorization gate.** `[Rubric §11, Security]` assesses whether authorization is centralized and consistent rather than re-implemented per endpoint. Every live-layer mutation routes its rights decision through this single method, so the rule "organizers and admins manage everything; a speaker manages only content scoped to a session they are assigned to" lives in exactly one place (doc comment, `LivePollAuthorization.cs:7-10`). `[Rubric §1, SOLID]`: authorization is one responsibility, not smeared across five handlers. `[Rubric §7, Microservices Readiness]`: the speaker-assignment fact arrives as [`SessionLiveInfo.SpeakerIds`](group-17-conference-domain.md#sessionliveinfo) from the Conference service, so this check consumes a cross-service snapshot rather than reaching into another module's tables. +- **Walkthrough**: one static method, `EnsureCanManage(bool callerIsOrganizer, SpeakerIdentifierType? callerSpeakerId, SessionLiveInfo? sessionInfo, string source)` (`LivePollAuthorization.cs:22-44`). Order matters. An organizer or admin short-circuits to `Result.Success()` (`:28-31`). Otherwise, if a session scope is supplied **and** the caller has a speaker id **and** that id is in `sessionInfo.SpeakerIds` (`:33-35`), success. Anything else returns `Error.Forbidden("LivePoll.NotAuthorized", …)` carrying the caller-supplied `source` (`:40-43`). Passing `sessionInfo` as `null` (event-wide scope) means only organizers and admins pass, which is exactly the intent for event-wide polls (`:15-16`). +- **Why it's built this way**: a pure static helper keeps the rule dependency-free and trivially unit-testable, and the explicit `source` parameter threads the calling handler name into the error, which is this codebase's convention for stack-free tracing. +- **Where it's used**: five call sites across both live-layer verticals: [`CreateLivePollHandler`](#createlivepollhandler) (`CreateLivePollHandler.cs:54,64`), [`OpenLivePollHandler`](#openlivepollhandler) (`OpenLivePollHandler.cs:58,68`), [`CloseLivePollHandler`](#closelivepollhandler) (`CloseLivePollHandler.cs:53,60`), [`GetModerationQueueHandler`](#getmoderationqueuehandler) (`GetModerationQueueHandler.cs:37`), and [`ModerateQuestionHandler`](#moderatequestionhandler) (`ModerateQuestionHandler.cs:59`). The Q&A moderation queue is a **read** that still runs the check, which is the point of centralizing it: moderator-only reads and writes cannot drift apart. ### LivePollInvariants -> MMCA.ADC.Engagement.Domain · `MMCA.ADC.Engagement.Domain.LivePolls` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/LivePollInvariants.cs:9` · Level 4 · class (static) +> MMCA.ADC.Engagement.Domain · `MMCA.ADC.Engagement.Domain.LivePolls` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/LivePollInvariants.cs:9` · Level 6 · class (static) -- **What it is**: the invariant helper for [`LivePoll`](#livepoll) and [`LivePollOption`](#livepolloption): it owns the poll's field-length and option-count constants and the `Result`-returning checks that guard them (BR-220). +- **What it is**: the invariant helper for [`LivePoll`](#livepoll) and [`LivePollOption`](#livepolloption). It owns the poll's field-length and option-count constants and the `Result`-returning checks that guard them (BR-220). - **Depends on**: [`CommonInvariants`](group-02-domain-building-blocks.md#commoninvariants) (for the shared `EnsureIdIsNotDefault`), [`Result`](group-01-result-error-handling.md#result), [`Error`](group-01-result-error-handling.md#error). -- **Concept reinforced, the shared-constants invariant class** (introduced by `AddressInvariants` in [Group 02](group-02-domain-building-blocks.md#addressinvariants)). `[Rubric §16, Maintainability & Evolvability]` (one place to change a constraint) and `[Rubric §4, DDD]` (invariants owned by the domain). The four public constants, `QuestionMaxLength = 200`, `OptionTextMaxLength = 100`, `MinOptions = 2`, `MaxOptions = 10` (`LivePollInvariants.cs:12-21`), are the single source of truth reused by the factory checks here and by the EF configuration and any validator. -- **Walkthrough**: five static check methods, each returning [`Result`](group-01-result-error-handling.md#result). `EnsureEventIdIsValid` (`:23`) delegates to [`CommonInvariants.EnsureIdIsNotDefault`](group-02-domain-building-blocks.md#commoninvariants); `EnsureQuestionIsValid` (`:26`) rejects empty or over-length questions; `EnsureOptionTextIsValid` (`:35`) does the same per option; `EnsureOptionCountIsValid` (`:44`) enforces the 2-10 range with a C# range pattern (`count is < MinOptions or > MaxOptions`); `EnsureOptionTextsAreUnique` (`:53`) groups the texts case-insensitively (`StringComparer.OrdinalIgnoreCase`) and fails if any group has a duplicate. Each failure carries a stable `code` (e.g. `"LivePoll.Options.Duplicate"`) and the `source` for tracing. +- **Concept reinforced, the shared-constants invariant class** (introduced in [Group 02](group-02-domain-building-blocks.md#commoninvariants)). `[Rubric §16, Maintainability & Evolvability]` (one place to change a constraint) and `[Rubric §4, DDD]` (invariants owned by the domain). The four public constants, `QuestionMaxLength = 200` (`LivePollInvariants.cs:12`), `OptionTextMaxLength = 100` (`:15`), `MinOptions = 2` (`:18`), and `MaxOptions = 10` (`:21`), are the single source of truth reused by the factory checks here and by anything else that needs the same numbers. +- **Walkthrough**: five static check methods, each returning [`Result`](group-01-result-error-handling.md#result). + - `EnsureEventIdIsValid` (`:23-24`) delegates to [`CommonInvariants.EnsureIdIsNotDefault`](group-02-domain-building-blocks.md#commoninvariants) with the code `"LivePoll.EventId.Invalid"`. + - `EnsureQuestionIsValid` (`:26-33`) rejects whitespace-only or over-length questions and interpolates `QuestionMaxLength` into the message (`:30`), so the message can never disagree with the constant. + - `EnsureOptionTextIsValid` (`:35-42`) does the same per option against `OptionTextMaxLength`. + - `EnsureOptionCountIsValid` (`:44-51`) enforces the 2 to 10 range with a C# range pattern, `count is < MinOptions or > MaxOptions` (`:45`). + - `EnsureOptionTextsAreUnique` (`:53-60`) groups the texts case-insensitively with `StringComparer.OrdinalIgnoreCase` and fails if any group has more than one member (`:54`), so "Yes" and "yes" cannot both be options on the same poll. + Each failure carries a stable `code` (for example `"LivePoll.Options.Duplicate"`), a `source`, and a `target` for tracing. - **Why it's built this way**: separating the constants and checks from the entity lets EF configuration and validators reference `LivePollInvariants.QuestionMaxLength` without depending on the [`LivePoll`](#livepoll) type itself, keeping the constraint values in lockstep across layers. -- **Where it's used**: combined via `Result.Combine` inside [`LivePoll.Create`](#livepoll) (`LivePoll.cs:72-76`) and [`LivePollOption.Create`](#livepolloption) (`LivePollOption.cs:45`). +- **Where it's used**: combined through `Result.Combine` inside [`LivePoll.Create`](#livepoll) (`LivePoll.cs:72-76`) and singly inside [`LivePollOption.Create`](#livepolloption) (`LivePollOption.cs:45`). ### LivePollVoteInvariants -> MMCA.ADC.Engagement.Domain · `MMCA.ADC.Engagement.Domain.LivePolls` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/LivePollVoteInvariants.cs:9` · Level 4 · class (static) +> MMCA.ADC.Engagement.Domain · `MMCA.ADC.Engagement.Domain.LivePolls` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/LivePollVoteInvariants.cs:9` · Level 6 · class (static) - **What it is**: the invariant helper for [`LivePollVote`](#livepollvote): three id-presence checks. - **Depends on**: [`CommonInvariants`](group-02-domain-building-blocks.md#commoninvariants), [`Result`](group-01-result-error-handling.md#result). -- **Concept reinforced, the shared-constants invariant class** (see [`LivePollInvariants`](#livepollinvariants)). `[Rubric §4, DDD]`. This is the compact sibling: a vote has no free-text fields, so all three methods, `EnsurePollIdIsValid` (`:11`), `EnsureOptionIdIsValid` (`:14`), `EnsureUserIdIsValid` (`:17`), just delegate to [`CommonInvariants.EnsureIdIsNotDefault`](group-02-domain-building-blocks.md#commoninvariants) with a vote-specific error code. -- **Walkthrough**: three one-line static methods returning [`Result`](group-01-result-error-handling.md#result); each rejects a default (zero/empty) identifier with a code like `"LivePollVote.OptionId.Invalid"`. -- **Why it's built this way**: even a trivial guard is expressed as a named invariant so the factory reads as a `Result.Combine` of intent, and every id-presence failure produces a consistent, traceable error. -- **Where it's used**: combined inside [`LivePollVote.Create`](#livepollvote) (`LivePollVote.cs:54-57`); `EnsureOptionIdIsValid` is also called on its own by `ChangeOption` and `Reactivate` (`LivePollVote.cs:79,99`). +- **Concept reinforced, the shared-constants invariant class** (see [`LivePollInvariants`](#livepollinvariants)). `[Rubric §4, DDD]`. This is the compact sibling: a vote has no free-text fields and no counts, so it declares no constants and all three methods just delegate to [`CommonInvariants.EnsureIdIsNotDefault`](group-02-domain-building-blocks.md#commoninvariants) with a vote-specific error code. +- **Walkthrough**: three one-line static methods returning [`Result`](group-01-result-error-handling.md#result): `EnsurePollIdIsValid` (`LivePollVoteInvariants.cs:11-12`, code `"LivePollVote.PollId.Invalid"`), `EnsureOptionIdIsValid` (`:14-15`, code `"LivePollVote.OptionId.Invalid"`), and `EnsureUserIdIsValid` (`:17-18`, code `"LivePollVote.UserId.Invalid"`). Each rejects a default (zero or empty) identifier and passes `nameof(...)` as the error target. +- **Why it's built this way**: even a trivial guard is expressed as a named invariant so the factory reads as a `Result.Combine` of intent rather than a stack of `if`s, and every id-presence failure produces a consistent, traceable error code. +- **Where it's used**: combined inside [`LivePollVote.Create`](#livepollvote) (`LivePollVote.cs:53-56`); `EnsureOptionIdIsValid` is also called on its own by `ChangeOption` and `Reactivate` (`LivePollVote.cs:80,100`). ### LivePollVote -> MMCA.ADC.Engagement.Domain · `MMCA.ADC.Engagement.Domain.LivePolls` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/LivePollVote.cs:19` · Level 5 · class (sealed aggregate root) +> MMCA.ADC.Engagement.Domain · `MMCA.ADC.Engagement.Domain.LivePolls` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/LivePollVote.cs:19` · Level 7 · class (sealed aggregate root) - **What it is**: the aggregate root for one user's vote on a live poll. Deliberately a **separate** aggregate from [`LivePoll`](#livepoll), not a child of it. - **Depends on**: [`AuditableAggregateRootEntity`](group-02-domain-building-blocks.md#auditableaggregaterootentitytidentifiertype) (base), [`LivePollVoteChanged`](#livepollvotechanged), [`LivePollVoteInvariants`](#livepollvoteinvariants), [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate), [`IdValueGeneratedAttribute`](group-02-domain-building-blocks.md#idvaluegeneratedattribute), [`Result`](group-01-result-error-handling.md#result). -- **Concept introduced, splitting a high-frequency child into its own aggregate for write scalability.** `[Rubric §12, Performance & Scalability]` (assesses contention and change-tracker load) and `[Rubric §4, DDD]` (aggregate boundaries chosen for consistency, not convenience). The doc comment (`LivePollVote.cs:10-18`) states the reasoning explicitly: votes are high-frequency attendee writes, so folding them into the [`LivePoll`](#livepoll) aggregate would bloat the change tracker and make every vote contend on the poll row. Instead each vote is its own root, and "one active vote per (poll, user)" is enforced by a **filtered unique index** at the database (BR-225), not by loading sibling votes into memory. `[Rubric §8, Data Architecture]`: the reactivation-over-reinsert pattern (below) keeps that filtered index from accumulating soft-deleted duplicates. +- **Concept introduced, splitting a high-frequency child into its own aggregate for write scalability.** `[Rubric §12, Performance & Scalability]` (which assesses contention and change-tracker load) and `[Rubric §4, DDD]` (aggregate boundaries chosen for consistency, not convenience). The doc comment states the reasoning explicitly (`LivePollVote.cs:9-17`): votes are high-frequency attendee writes, so folding them into the [`LivePoll`](#livepoll) aggregate would bloat the change tracker and make every vote contend on the poll row. Instead each vote is its own root, and "one active vote per (poll, user)" is enforced by a **filtered unique index** at the database (BR-225), not by loading sibling votes into memory. `[Rubric §8, Data Architecture]`: the reactivation-over-reinsert pattern below is what keeps that filtered index from tripping over soft-deleted duplicates ([ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html) for the soft-delete model). - **Walkthrough** - - Marked `[IdValueGenerated]` (`:19`), so the database assigns the identity; the factory sets `Id = default` and lets SQL Server fill it. - - Three private-set FK properties: `LivePollId`, `OptionId`, `UserId` (`:23-29`), plus the EF parameterless ctor (`:32`) and a private field ctor (`:34`). - - `Create(livePollId, optionId, userId)` (`:49`): combines the three [`LivePollVoteInvariants`](#livepollvoteinvariants) id checks, constructs the vote with `Id = default`, and raises [`LivePollVoteChanged`](#livepollvotechanged) with `DomainEntityState.Added` (`:66`). - - `ChangeOption(optionId)` (`:77`): the re-vote path while a poll is open (BR-225), validates the new option, reassigns `OptionId`, and raises the event with `DomainEntityState.Updated`. - - `Reactivate(optionId)` (`:97`): the BR-135 pattern, validates the option, calls the base `Undelete()`, and on success reassigns the option and raises `Added`, so a user who un-votes then re-votes reuses the same soft-deleted row instead of inserting a new one. - - `Delete()` (`:119`): overrides the base soft-delete and raises [`LivePollVoteChanged`](#livepollvotechanged) with `DomainEntityState.Deleted`; the row stays, `IsDeleted` flips. -- **Why it's built this way**: separating the write-hot vote from the read-hot poll is the central scalability decision of the poll subsystem; combined with the filtered unique index and reactivation, a poll can absorb a burst of conference-day votes without serializing them on one row. -- **Where it's used**: written by the cast-vote handler; tallied (read-side) by [`LivePollResultsBuilder`](#livepollresultsbuilder) via a grouped `COUNT`. + - Marked `[IdValueGenerated]` (`:18`), so the database assigns the identity; the factory sets `Id = default` and lets SQL Server fill it in. + - Three FK properties with private setters: `LivePollId` (`:22`), `OptionId` (`:25`), `UserId` (`:28`), plus the EF parameterless constructor (`:31`) and a private field constructor (`:33-38`). + - `Create(livePollId, optionId, userId)` (`:48`): combines the three [`LivePollVoteInvariants`](#livepollvoteinvariants) id checks (`:53-56`), constructs the vote with `Id = default` (`:60-63`), and raises [`LivePollVoteChanged`](#livepollvotechanged) with `DomainEntityState.Added` (`:67`). The comment immediately above that line (`:65-66`) is the source of the zero-id contract described at [`LivePollVoteChanged`](#livepollvotechanged). + - `ChangeOption(optionId)` (`:78`): the re-vote path while a poll is open (BR-225). It validates the new option (`:80`), reassigns `OptionId` (`:84`), and raises the event with `DomainEntityState.Updated` (`:86`). Note there is no lifecycle guard here: whether the poll is still open is checked by [`LivePoll.CanAcceptVote`](#livepoll) before this is called. + - `Reactivate(optionId)` (`:98`): the BR-135 pattern. It validates the option (`:100`), calls the base `Undelete()` (`:104`), and only on success reassigns the option and raises `Added` (`:106-110`), so a user who un-votes and then re-votes reuses the same soft-deleted row instead of inserting a new one that would collide with the filtered unique index. + - `Delete()` (`:120`): overrides the base soft-delete, calls `base.Delete()` first (`:122`), and raises [`LivePollVoteChanged`](#livepollvotechanged) with `DomainEntityState.Deleted` only when that succeeded (`:124-125`). The row stays; `IsDeleted` flips. +- **Why it's built this way**: separating the write-hot vote from the read-hot poll is the central scalability decision of the poll subsystem. Combined with the filtered unique index and reactivation, a poll can absorb a burst of conference-day votes without serializing them on one row. +- **Where it's used**: created by [`CastVoteHandler`](#castvotehandler) (`CastVoteHandler.cs:75`); tallied on the read side by [`LivePollResultsBuilder`](#livepollresultsbuilder) through a grouped `COUNT`. ### LivePoll -> MMCA.ADC.Engagement.Domain · `MMCA.ADC.Engagement.Domain.LivePolls` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/LivePoll.cs:18` · Level 6 · class (sealed aggregate root) +> MMCA.ADC.Engagement.Domain · `MMCA.ADC.Engagement.Domain.LivePolls` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/LivePoll.cs:18` · Level 8 · class (sealed aggregate root) -- **What it is**: the aggregate root for a live poll: a question with 2-10 authored options and a strict `Draft -> Open -> Closed` lifecycle, scoped either to a whole event or to a single session. -- **Depends on**: [`AuditableAggregateRootEntity`](group-02-domain-building-blocks.md#auditableaggregaterootentitytidentifiertype), [`LivePollOption`](#livepolloption) (its child), [`LivePollChanged`](#livepollchanged), [`LivePollInvariants`](#livepollinvariants), [`LivePollStatus`](#livepollstatus), [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate), [`IdValueGeneratedAttribute`](group-02-domain-building-blocks.md#idvaluegeneratedattribute), the `[Navigation]` marker ([`NavigationAttribute`](group-11-navigation-populators.md#navigationattribute)), [`Result`](group-01-result-error-handling.md#result). -- **Concept introduced, a lifecycle state machine with a snapshotted cross-service fact.** `[Rubric §4, DDD]` (a root that guards its own transitions) and `[Rubric §7, Microservices Readiness]` (avoiding a synchronous cross-service call on the hot vote path). The lifecycle is enforced as explicit guarded transitions, and `Open` snapshots the event's live-window end onto the poll (`LiveWindowEndUtc`, `LivePoll.cs:32-36`) so later vote checks never need to call the Conference service again (BR-223/BR-224). `[Rubric §8, Data Architecture]`: the child options are held in an encapsulated list behind a read-only view. +- **What it is**: the aggregate root for a live poll: a question with 2 to 10 authored options and a strict `Draft -> Open -> Closed` lifecycle, scoped either to a whole event or to a single session. +- **Depends on**: [`AuditableAggregateRootEntity`](group-02-domain-building-blocks.md#auditableaggregaterootentitytidentifiertype), [`LivePollOption`](#livepolloption) (its child), [`LivePollChanged`](#livepollchanged), [`LivePollInvariants`](#livepollinvariants), [`LivePollStatus`](#livepollstatus), [`DomainEntityState`](group-02-domain-building-blocks.md#domainentitystate), [`IdValueGeneratedAttribute`](group-02-domain-building-blocks.md#idvaluegeneratedattribute), the `[Navigation]` marker ([`NavigationAttribute`](group-11-navigation-populators.md#navigationattribute)), [`Result`](group-01-result-error-handling.md#result) and [`Error`](group-01-result-error-handling.md#error). +- **Concept introduced, a lifecycle state machine with a snapshotted cross-service fact.** `[Rubric §4, DDD]` (a root that guards its own transitions) and `[Rubric §7, Microservices Readiness]` (avoiding a synchronous cross-service call on the hot vote path). The lifecycle is enforced as explicit guarded transitions, and `Open` snapshots the event's live-window end onto the poll (`LiveWindowEndUtc`, `LivePoll.cs:32-36`) so later vote checks never call the Conference service again (BR-223/BR-224, doc comment `:10-15`). `[Rubric §8, Data Architecture]`: the child options are held in an encapsulated `List` exposed only as a read-only view. - **Walkthrough** - - `[IdValueGenerated]` (`:17`); properties `EventId`, `SessionId?` (null = event-wide, BR-230), `Question`, `Status`, and `LiveWindowEndUtc?` all have private setters (`:20-36`). Options live in a private `List _options` exposed as a read-only `[Navigation(IsCollection = true)] Options` (`:38-42`). - - `Create(eventId, sessionId, question, optionTexts)` (`:64`): null-checks the texts, combines four [`LivePollInvariants`](#livepollinvariants) checks (event id, question, option count, option uniqueness), constructs the poll as `Draft`, then builds each [`LivePollOption`](#livepolloption) in display order (`:85-92`), and raises [`LivePollChanged`](#livepollchanged) `Added`. - - `Open(nowUtc, liveWindowStartUtc, liveWindowEndUtc)` (`:108`): rejects any non-`Draft` poll (`LivePoll.InvalidTransition`) and any attempt outside the live window (`LivePoll.OutsideLiveWindow`), then flips to `Open` and snapshots `LiveWindowEndUtc` (`:128-129`). - - `Close()` (`:141`): `Open`-only, no reopen, flips to `Closed`. - - `CanAcceptVote(nowUtc, optionId)` (`:167`): the guard the vote handler calls, requires `Open` status, `nowUtc` before the snapshotted window end, and the option to exist and be non-deleted on this poll (`:187`); returns a specific [`Error`](group-01-result-error-handling.md#error) for each failure. This runs entirely against in-memory state, no cross-service call. - - `SetOptions(options)` (`:201`): an `internal` hook that routes through the base `SetItems`, used only by the navigation populator to rehydrate the collection. - - `Delete()` (`:210`): refuses to delete an `Open` poll (BR-228, `LivePoll.DeleteWhileOpen`), then soft-deletes the poll and cascade soft-deletes each non-deleted option before raising [`LivePollChanged`](#livepollchanged) `Deleted`. -- **Why it's built this way**: snapshotting the live-window end at `Open` trades a tiny bit of staleness for removing a synchronous Conference call from every vote, and the explicit transition guards mean an invalid lifecycle move is impossible regardless of which handler calls in ([ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html) for the gRPC boundary this snapshot sidesteps). -- **Where it's used**: created/opened/closed/deleted by the poll handlers behind [`LivePollsController`](#livepollscontroller); its options rehydrated by [`LivePollNavigationPopulator`](#livepollnavigationpopulator); tallied by [`LivePollResultsBuilder`](#livepollresultsbuilder). + - `[IdValueGenerated]` (`:17`); the properties `EventId` (`:21`), `SessionId?` (`:24`, null means event-wide, BR-230), `Question` (`:27`), `Status` (`:30`), and `LiveWindowEndUtc?` (`:36`) all have private setters. Options live in a private `List _options` (`:38`) exposed as `[Navigation(IsCollection = true)] IReadOnlyCollection Options => _options.AsReadOnly()` (`:41-42`). + - `Create(eventId, sessionId, question, optionTexts)` (`:64`): null-checks the texts (`:70`), combines four [`LivePollInvariants`](#livepollinvariants) checks, event id, question, option count, and option uniqueness (`:72-76`), constructs the poll with `Id = default` and `Status = Draft` (private constructor at `:47-53`, object initializer at `:80-83`), then builds each [`LivePollOption`](#livepolloption) in display order using the loop index as `Sort` (`:85-92`), and raises [`LivePollChanged`](#livepollchanged) `Added` (`:94`). + - `Open(nowUtc, liveWindowStartUtc, liveWindowEndUtc)` (`:108`): rejects any non-`Draft` poll with `"LivePoll.InvalidTransition"` (`:110-117`) and any attempt outside the live window with `"LivePoll.OutsideLiveWindow"` (`:119-126`; note the end bound is exclusive, `nowUtc >= liveWindowEndUtc` fails), then flips `Status` to `Open` and snapshots `LiveWindowEndUtc` (`:128-129`) before raising `Updated` (`:131`). + - `Close()` (`:141`): `Open` only, and no reopen path exists (`:143-150`); flips to `Closed` (`:152`) and raises `Updated` (`:154`). + - `CanAcceptVote(nowUtc, optionId)` (`:167`): the guard the vote handler calls. It requires `Open` status (`:169-176`), requires `nowUtc` to be strictly before a snapshotted window end that is actually set (`:178-185`), and requires the option to exist, be non-deleted, and belong to this poll (`:187-194`). Each failure returns its own [`Error`](group-01-result-error-handling.md#error) code. This runs entirely against in-memory state, with no cross-service call. + - `SetOptions(options)` (`:201-202`): an `internal` hook that routes through the base `SetItems`, used only by [`LivePollNavigationPopulator`](#livepollnavigationpopulator) to rehydrate the collection. + - `Delete()` (`:210`): refuses to delete an `Open` poll (BR-228, `"LivePoll.DeleteWhileOpen"`, `:212-219`), then soft-deletes the poll via the base (`:221`) and cascade soft-deletes each non-deleted option (`:225-230`) before raising [`LivePollChanged`](#livepollchanged) `Deleted` (`:232`). +- **Why it's built this way**: snapshotting the live-window end at `Open` trades a small amount of staleness for removing a synchronous Conference call from every single vote ([ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html) describes the gRPC boundary this sidesteps), and the explicit transition guards make an invalid lifecycle move impossible regardless of which handler calls in. Refusing to delete an open poll rather than silently closing it means a delete can never end a running vote behind the audience's back. +- **Where it's used**: created, opened, closed, and deleted by [`CreateLivePollHandler`](#createlivepollhandler), [`OpenLivePollHandler`](#openlivepollhandler), and [`CloseLivePollHandler`](#closelivepollhandler) behind [`LivePollsController`](#livepollscontroller); its options rehydrated by [`LivePollNavigationPopulator`](#livepollnavigationpopulator); tallied by [`LivePollResultsBuilder`](#livepollresultsbuilder). ### LivePollOption -> MMCA.ADC.Engagement.Domain · `MMCA.ADC.Engagement.Domain.LivePolls` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/LivePollOption.cs:13` · Level 6 · class (sealed child entity) +> MMCA.ADC.Engagement.Domain · `MMCA.ADC.Engagement.Domain.LivePolls` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/LivePollOption.cs:13` · Level 8 · class (sealed child entity) -- **What it is**: a single answer option belonging to a [`LivePoll`](#livepoll): display text plus a sort order, authored with the poll and immutable afterward. +- **What it is**: a single answer option belonging to a [`LivePoll`](#livepoll): display text plus a sort order, authored with the poll and immutable afterwards. - **Depends on**: [`AuditableBaseEntity`](group-02-domain-building-blocks.md#auditablebaseentitytidentifiertype) (note: a plain auditable **child**, not an aggregate root), [`LivePollInvariants`](#livepollinvariants), [`IdValueGeneratedAttribute`](group-02-domain-building-blocks.md#idvaluegeneratedattribute), the `[Navigation]` marker ([`NavigationAttribute`](group-11-navigation-populators.md#navigationattribute)), [`Result`](group-01-result-error-handling.md#result). -- **Concept reinforced, the child entity inside an aggregate boundary.** `[Rubric §4, DDD]`. Unlike [`LivePollVote`](#livepollvote), an option is a genuine child of the poll: it derives from [`AuditableBaseEntity`](group-02-domain-building-blocks.md#auditablebaseentitytidentifiertype) (no domain-event list of its own) and is only ever created and soft-deleted through its parent [`LivePoll`](#livepoll). It carries a back-reference `[Navigation] LivePoll?` and an FK `LivePollId` (`LivePollOption.cs:22-26`). -- **Walkthrough**: `[IdValueGenerated]` (`:12`); `Text` and `Sort` with private setters (`:16-19`); EF ctor and private field ctor (`:29-35`). The only factory, `Create(text, sort)` (`:43`), validates the text via [`LivePollInvariants.EnsureOptionTextIsValid`](#livepollinvariants) and constructs the option with `Id = default`. There is no mutation method, immutability is enforced by omission (the doc comment, `:9-10`, says re-author the Draft poll instead). -- **Why it's built this way**: modeling the option as an immutable child keeps the poll's consistency boundary simple, tally math only ever adds new options via re-authoring, never mutates an existing option's meaning under a live vote count. -- **Where it's used**: built inside [`LivePoll.Create`](#livepoll) and rehydrated by [`LivePollNavigationPopulator`](#livepollnavigationpopulator); read by [`LivePollResultsBuilder`](#livepollresultsbuilder) to label each tally. - -### LivePollResultsBuilder -> MMCA.ADC.Engagement.Application · `MMCA.ADC.Engagement.Application.LivePolls.Services` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/Services/LivePollResultsBuilder.cs:12` · Level 8 · class (sealed) - -- **What it is**: the shared read-side service that computes a poll's result tallies: per-option active-vote counts, the total, and optionally the caller's own vote. -- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (for the read repository), [`IQueryableExecutor`](group-07-persistence-ef-core.md#iqueryableexecutor) (async materialization), [`LivePoll`](#livepoll) / [`LivePollVote`](#livepollvote), and the result DTOs [`LivePollResultsDTO`](#livepollresultsdto) / [`LivePollOptionResultDTO`](#livepolloptionresultdto). -- **Concept introduced, computing tallies with a grouped SQL COUNT instead of materializing votes.** `[Rubric §12, Performance & Scalability]` (assesses whether hot read paths avoid loading whole tables). The comment at `LivePollResultsBuilder.cs:31-33` states the intent: tallies come from a `GroupBy(OptionId).Select(Count())` that returns one row per option, so a hot poll no longer re-materializes its entire vote table on every vote, results read, and open-polls listing. Centralizing this in one builder means every surface (`CastVote`, `GetPollResults`, `GetOpenPolls`) computes results identically. -- **Walkthrough**: one method `BuildAsync(poll, userId?, ct)` (`:22`). It null-checks the poll, takes a no-tracking read repository for [`LivePollVote`](#livepollvote) (`:29`), runs the grouped count over `TableNoTracking` filtered to this poll (`:34-39`) and folds it into a `countsByOption` dictionary (`:41`). The caller's own vote is a **separate point read** issued only when `userId` is non-null (broadcast payloads pass `null` and skip it, BR-229, `:44-53`). It then projects the poll's non-deleted options ordered by `Sort` into [`LivePollOptionResultDTO`](#livepolloptionresultdto)s, filling each `VoteCount` from the dictionary (`:55-64`), and returns a [`LivePollResultsDTO`](#livepollresultsdto) with poll id/question/status, `TotalVotes` (sum of the counts), the options, and `MyVoteOptionId` (`:66-74`). -- **Why it's built this way**: the grouped count keeps the tally cost proportional to option count, not vote count; skipping the "my vote" read for broadcast payloads (which have no single caller) avoids a pointless query on the fan-out path. -- **Where it's used**: injected into the cast-vote, poll-results, and open-polls handlers so all three return the same [`LivePollResultsDTO`](#livepollresultsdto) shape. -- **Caveats / not-in-source**: `Options` must already be loaded on the passed [`LivePoll`](#livepoll) (via [`LivePollNavigationPopulator`](#livepollnavigationpopulator)); the builder reads `poll.Options` directly and does not itself load them. +- **Concept reinforced, the child entity inside an aggregate boundary.** `[Rubric §4, DDD]`. Unlike [`LivePollVote`](#livepollvote), an option is a genuine child of the poll: it derives from [`AuditableBaseEntity`](group-02-domain-building-blocks.md#auditablebaseentitytidentifiertype), so it has **no** domain-event list of its own, and it is only ever created and soft-deleted through its parent [`LivePoll`](#livepoll). Its changes are announced by the parent's [`LivePollChanged`](#livepollchanged), which is the practical meaning of "inside the boundary". +- **Walkthrough**: `[IdValueGenerated]` (`:12`); `Text` (`:16`) and `Sort` (`:19`) have private setters; the back-reference `[Navigation] public LivePoll? LivePoll { get; set; }` (`:22-23`) is a settable navigation because the populator assigns it, while the FK `LivePollId` (`:26`) is **get-only** and is written by EF Core. The EF parameterless constructor seeds `Text` to `string.Empty` to satisfy nullability (`:29`), and a private field constructor takes the two real values (`:31-35`). The only factory, `Create(text, sort)` (`:43`), validates through [`LivePollInvariants.EnsureOptionTextIsValid`](#livepollinvariants) (`:45`) and constructs the option with `Id = default` (`:49-52`). There is no mutation method: immutability is enforced by omission, and the doc comment says to re-author the Draft poll instead (`:8-10`). +- **Why it's built this way**: modeling the option as an immutable child keeps the poll's consistency boundary simple. Tally math only ever gains new options through re-authoring, so an existing option's meaning can never change under a live vote count. +- **Where it's used**: built inside [`LivePoll.Create`](#livepoll) (`LivePoll.cs:87`) and cascade-deleted by [`LivePoll.Delete`](#livepoll) (`LivePoll.cs:227`); rehydrated by [`LivePollNavigationPopulator`](#livepollnavigationpopulator); its own back-reference filled by [`LivePollOptionNavigationPopulator`](#livepolloptionnavigationpopulator); read by [`LivePollResultsBuilder`](#livepollresultsbuilder) to label and order each tally. ### ModerateQuestionHandler -> MMCA.ADC.Engagement.Application · `MMCA.ADC.Engagement.Application.SessionQuestions.UseCases.Moderate` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/Moderate/ModerateQuestionHandler.cs:22` · Level 8 · class (sealed partial) +> MMCA.ADC.Engagement.Application · `MMCA.ADC.Engagement.Application.SessionQuestions.UseCases.Moderate` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/Moderate/ModerateQuestionHandler.cs:23` · Level 8 · class (sealed partial) - **What it is**: the command handler that applies a moderation transition to a [`SessionQuestion`](#sessionquestion) (BR-234), enforcing the BR-236 rights, then best-effort enqueues the matching live-channel event (BR-238) for the off-request-path drain worker. -- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`IEventLiveValidationService`](group-17-conference-domain.md#ieventlivevalidationservice) (the Conference gRPC boundary for session info), [`ILiveChannelPublishQueue`](group-22-engagement-module.md#ilivechannelpublishqueue) (`ModerateQuestionHandler.cs:25`, the in-process broadcast queue a hosted drain forwards to the Notification gRPC ingress), [`LivePollAuthorization`](#livepollauthorization), the [`SessionQuestionChannel`](#sessionquestionchannel) event names, the channel payload DTOs ([`SessionQuestionApprovedPayload`](#sessionquestionapprovedpayload) and siblings), and `ILogger`. -- **Concept introduced, best-effort side-channel broadcast that never fails the command (BR-238).** `[Rubric §29, Resilience & Business Continuity]` and `[Rubric §7, Microservices Readiness]` (a downstream service being unreachable must not fail the local write). The mutation is committed first via `SaveChangesAsync`; only *then* does the handler hand the broadcast to the queue, still inside a `try/catch (Exception)` that logs and swallows so a Notification outage cannot roll back a moderation (`ModerateQuestionHandler.cs:92-149`, with a justified `#pragma warning disable CA1031` at `:143`). Read that catch precisely: `Enqueue` is a `void` call that never rejects ([`ILiveChannelPublishQueue`](group-22-engagement-module.md#ilivechannelpublishqueue), `ILiveChannelPublishQueue.cs:30`), so what it actually guards is the Pending-count follow-up's database read (`:131-133`, rationale at `:85-91`). `[Rubric §13, Observability & Operability]`: both the success and the swallowed-failure paths emit source-generated `[LoggerMessage]` logs (`:151-155`). +- **Depends on**: [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`IEventLiveValidationService`](group-17-conference-domain.md#ieventlivevalidationservice) (the Conference gRPC boundary for session info), [`ILiveChannelPublishQueue`](group-22-engagement-module.md#ilivechannelpublishqueue) (`ModerateQuestionHandler.cs:26`), [`LiveChannelPublishWorkItem`](group-22-engagement-module.md#livechannelpublishworkitem), [`BestEffort`](group-03-querying-specifications.md#besteffort), [`LivePollAuthorization`](#livepollauthorization), the [`SessionQuestionChannel`](#sessionquestionchannel) event names, [`LivePollChannel`](#livepollchannel) for the channel key, the channel payload records ([`SessionQuestionApprovedPayload`](#sessionquestionapprovedpayload), [`SessionQuestionDismissedPayload`](#sessionquestiondismissedpayload), [`SessionQuestionAnsweredPayload`](#sessionquestionansweredpayload), [`SessionQuestionPendingCountChangedPayload`](#sessionquestionpendingcountchangedpayload)), plus `System.Text.Json` and `ILogger`. +- **Concept introduced, the best-effort side channel that can never fail the command (BR-238).** `[Rubric §29, Resilience & Business Continuity]` and `[Rubric §7, Microservices Readiness]`: a downstream service being unreachable must not fail the local write. The mutation is committed first (`:80`); only then is the broadcast handed to the queue, and that work runs inside [`BestEffort.ExecuteAsync`](group-03-querying-specifications.md#besteffort) (`:136`) rather than a hand-rolled `try/catch`. Read the guard precisely: `Enqueue` is a `void` call that never rejects, so what the guard actually covers is the Pending-count follow-up's database read (`:143-146`, rationale at `:89-102`). `[Rubric §13, Observability & Operability]`: using the shared helper means a broadcast that has quietly stopped working increments `besteffort.dispatch.failed` on a meter, tagged with the low-cardinality operation constant `"session-question-moderation-broadcast"` (`:30`), instead of only producing a log line (`BestEffort.cs:18-23`). The caller's `CancellationToken` is deliberately **not** passed (`:97-100`), so the helper's token parameter falls back to its default (`BestEffort.cs:45-49`) and the broadcast outlives an abandoned request instead of turning a saved moderation into a cancelled one. - **Walkthrough** - - `HandleAsync` (`:29`): loads the tracked [`SessionQuestion`](#sessionquestion) by id (`:34`), returns `Error.NotFound` if missing (`:40-44`). - - Stamps the client's last-seen rowversion back as the original ([ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html), `:46-49`), so two moderators racing approve-vs-dismiss surface as 409 Conflict rather than the second decision silently applying. - - Fetches the session's live info via [`IEventLiveValidationService`](group-17-conference-domain.md#ieventlivevalidationservice) (`:51`) and runs the [`LivePollAuthorization.EnsureCanManage`](#livepollauthorization) rights check (`:55-58`); a rights failure short-circuits. - - Captures `wasPending` before the transition (`:60`), then dispatches the action through a `switch` to the domain method `Approve()` / `Dismiss()` / `MarkAnswered()` (`:62-72`); an unknown action is an invariant failure. - - Persists via `SaveChangesAsync` (`:76`), logs the moderation (`:78`), then calls the private `EnqueueModeratedAsync` (`:80`). - - `EnqueueModeratedAsync` (`:92`): resolves the session channel key via `LivePollChannel.ForSession` (`:100`), then builds `(eventName, payload)` per action, only universally-visible data rides the channel, and the Approve arm is the single place question **content** is broadcast (`:105-123`). It hands that work item to [`ILiveChannelPublishQueue.Enqueue`](group-22-engagement-module.md#ilivechannelpublishqueue) (`:125`), and when a *Pending* question left the queue on Approve/Dismiss it issues a fresh Pending-count read and enqueues a [`SessionQuestionPendingCountChangedPayload`](#sessionquestionpendingcountchangedpayload) so moderators' badges update (`:127-141`). -- **Why it's built this way**: committing before enqueueing, plus the swallow-and-log catch, gives the live layer at-most-once broadcast semantics layered over a durably-committed write, the correct trade for ephemeral UI signals that must never block a moderation; queueing rather than awaiting the publish also keeps a hung Notification peer off the moderator's request path ([ADR-039](https://ivanball.github.io/docs/adr/039-live-channel-push.html) for the channel transport). -- **Where it's used**: registered for [`ModerateQuestionCommand`](#moderatequestioncommand) and invoked by [`SessionQuestionsController`](#sessionquestionscontroller)'s approve/dismiss/answered verbs. -- **Caveats / not-in-source**: the `switch` discard arm in `EnqueueModeratedAsync` throws `ArgumentOutOfRangeException` (`:122`) but is unreachable, the handler already applied a known action before enqueueing (noted in the comment, `:102-104`). + - `HandleAsync` (`:33`): takes a tracked repository and loads the [`SessionQuestion`](#sessionquestion) by id (`:37-42`), returning `Error.NotFound` when it is missing (`:44-48`). + - Stamps the client's last-seen rowversion back as the original ([ADR-035](https://ivanball.github.io/docs/adr/035-optimistic-concurrency.html), `:50-53`), so two moderators racing approve against dismiss surface as a 409 Conflict rather than the second decision silently applying. A `null` `RowVersion` skips the check. + - Fetches the session's live info through [`IEventLiveValidationService`](group-17-conference-domain.md#ieventlivevalidationservice) (`:55-57`) and runs [`LivePollAuthorization.EnsureCanManage`](#livepollauthorization) (`:59-62`); a rights failure short-circuits before any state change. + - Captures `wasPending` **before** the transition (`:64`), then dispatches the action through a `switch` expression to the domain methods `Approve()` / `Dismiss()` / `MarkAnswered()` (`:66-76`); an unknown action becomes an invariant failure rather than a silent no-op. + - Persists via `SaveChangesAsync` (`:80`), emits the source-generated moderation log (`:82`, declared at `:158-159`), then calls the private `EnqueueModeratedAsync` (`:84`). + - `EnqueueModeratedAsync` (`:104`): resolves the session channel key with `LivePollChannel.ForSession` (`:109`), then builds the `(eventName, payload)` pair per action (`:116-134`). Only universally visible data rides the channel, and the Approve arm is the single place question **content** is broadcast (`:118-122`). This `switch` is built **outside** the best-effort guard on purpose (`:111-115`): its discard arm throws `ArgumentOutOfRangeException` (`:133`) because an unknown action is a programming error that must fault loudly, not a transient publish failure to be swallowed. + - Inside the guard (`:136-155`) it enqueues the work item (`:138`), and when a *Pending* question left the queue on Approve or Dismiss it issues a fresh Pending-count read (`:143-146`) and enqueues a [`SessionQuestionPendingCountChangedPayload`](#sessionquestionpendingcountchangedpayload) so moderators' badges update (`:148-153`). +- **Why it's built this way**: committing before enqueueing, plus a swallow-and-count guard, gives the live layer at-most-once broadcast semantics layered over a durably committed write, which is the correct trade for ephemeral UI signals that must never block a moderation. Queueing rather than awaiting the publish also keeps a hung Notification peer off the moderator's request path ([ADR-039](https://ivanball.github.io/docs/adr/039-live-channel-push.html) for the channel transport). +- **Where it's used**: registered for [`ModerateQuestionCommand`](#moderatequestioncommand) and invoked by [`SessionQuestionsController`](#sessionquestionscontroller)'s approve, dismiss, and mark-answered verbs (`SessionQuestionsController.cs:41,238`). +- **Caveats / not-in-source**: the `switch` discard arm at `:133` is unreachable in practice, since `HandleAsync` already applied a known action before enqueueing (the comment at `:114-115` says as much). + +### LivePollResultsBuilder +> MMCA.ADC.Engagement.Application · `MMCA.ADC.Engagement.Application.LivePolls.Services` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/Services/LivePollResultsBuilder.cs:12` · Level 9 · class (sealed) + +- **What it is**: the shared read-side service that computes a poll's result tallies: per-option active-vote counts, the total, the caller's own vote when there is a caller, and the poll's concurrency token. +- **Depends on**: [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) (for the read repository), [`IQueryableExecutor`](group-07-persistence-ef-core.md#iqueryableexecutor) (async materialization without an EF dependency in the Application layer), [`LivePoll`](#livepoll) / [`LivePollVote`](#livepollvote), and the result DTOs [`LivePollResultsDTO`](#livepollresultsdto) / [`LivePollOptionResultDTO`](#livepolloptionresultdto). +- **Concept introduced, computing tallies with a grouped SQL COUNT instead of materializing votes.** `[Rubric §12, Performance & Scalability]` assesses whether hot read paths avoid loading whole tables. The comment at `LivePollResultsBuilder.cs:39-41` states the intent: tallies come from a `GroupBy(OptionId).Select(Count())` that returns one row per option rather than one row per vote, on a path that runs on every vote, every results read, and every open-polls listing. Centralizing this in one builder means all three surfaces compute results identically (`:9-10`). +- **Concept introduced, making the parts add up to the whole.** `[Rubric §9, API & Contract Design]` covers whether a payload is internally consistent. The builder first computes `activeOptionIds`, the non-deleted options this poll still presents (`:34-37`), and restricts the count query to them (`:44`). Votes cast on an option that was later removed are therefore excluded from both the breakdown **and** the total, and `TotalVotes` is summed from the same projected list the client sees (`:81`), so a client computing percentages from the parts always reconciles with the total (comments at `:31-33` and `:79-80`). +- **Walkthrough**: one method, `BuildAsync(poll, userId?, cancellationToken)` (`:22-25`). + - Null-checks the poll (`:27`) and takes a no-tracking read repository for [`LivePollVote`](#livepollvote) (`:29`). + - Builds `activeOptionIds` from `poll.Options` (`:34-37`), then runs the grouped count over `voteRepo.TableNoTracking` filtered to this poll and those options (`:42-47`) and folds it into a `countsByOption` dictionary (`:49`). + - The caller's own vote is a **separate point read** issued only when `userId` is non-null: broadcast payloads pass `null` and skip it entirely (BR-229, `:51-61`). It uses `GetProjectedAsync` to fetch just the `OptionId` (`:56-59`). + - Projects the non-deleted options ordered by `Sort` into [`LivePollOptionResultDTO`](#livepolloptionresultdto)s, filling each `VoteCount` from the dictionary with `GetValueOrDefault` so an option with zero votes still appears (`:63-72`). + - Returns a [`LivePollResultsDTO`](#livepollresultsdto) with poll id, question, status, `TotalVotes`, the options, `MyVoteOptionId`, and `RowVersion` (`:74-88`). That last line is deliberate: the concurrency token travels with the results so a surface fed only by results can still issue an open or close with a real token, and an unset token stays `null` rather than shipping an empty array that a caller would read as a token (`:84-87`). +- **Why it's built this way**: the grouped count keeps the tally cost proportional to option count rather than vote count; skipping the "my vote" read for broadcast payloads (which have no single caller) avoids a pointless query on the fan-out path. +- **Where it's used**: registered as scoped in the module's DI (`DependencyInjection.cs:68`) and injected into [`CastVoteHandler`](#castvotehandler) (`CastVoteHandler.cs:21`), [`GetPollResultsHandler`](#getpollresultshandler) (`GetPollResultsHandler.cs:15`), and [`GetOpenPollsHandler`](#getopenpollshandler) (`GetOpenPollsHandler.cs:17`), and resolved out of a fresh scope by [`LivePollVoteChangedHandler`](#livepollvotechangedhandler) for the results broadcast (`LivePollVoteChangedHandler.cs:55`). +- **Caveats / not-in-source**: `Options` must already be loaded on the passed [`LivePoll`](#livepoll) (via [`LivePollNavigationPopulator`](#livepollnavigationpopulator) or an explicit include). The builder reads `poll.Options` directly and does not load them itself; the XML doc says so at `:15-16`. ### LivePollNavigationPopulator > MMCA.ADC.Engagement.Application · `MMCA.ADC.Engagement.Application.LivePolls.Services` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/Services/LivePollNavigationPopulator.cs:11` · Level 10 · class (sealed) -- **What it is**: the declarative navigation populator that manually loads a [`LivePoll`](#livepoll)'s `Options` collection on query-service paths where EF Core `.Include()` is not applied. +- **What it is**: the declarative navigation populator that loads a [`LivePoll`](#livepoll)'s `Options` collection on query-service paths where EF Core `.Include()` is not applied. - **Depends on**: [`DeclarativeNavigationPopulator`](group-11-navigation-populators.md#declarativenavigationpopulatortentity) (base), [`ChildNavigationDescriptor`](group-11-navigation-populators.md#childnavigationdescriptortentity-tparentid-tchild-tchildid) (the descriptor), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`LivePoll`](#livepoll) / [`LivePollOption`](#livepolloption). -- **Concept reinforced, declarative navigation population ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html)).** `[Rubric §2, Design Patterns]`. The framework's entity-query path returns entities without EF `Include`s; a populator declares, in data, which child collections to rehydrate and how. This is the whole class: it subclasses [`DeclarativeNavigationPopulator`](group-11-navigation-populators.md#declarativenavigationpopulatortentity) and passes exactly one [`ChildNavigationDescriptor`](group-11-navigation-populators.md#childnavigationdescriptortentity-tparentid-tchild-tchildid) for `Options` (`LivePollNavigationPopulator.cs:11-23`). -- **Walkthrough**: a primary constructor takes [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) and forwards a single-element descriptor array to the base (`:13-22`). The descriptor wires `PropertyName = nameof(LivePoll.Options)`, `ParentKeySelector = p => p.Id`, `ChildForeignKeySelector = child => child.LivePollId`, and `AssignAction = (p, options) => p.SetOptions(options)`, the last calling the aggregate's `internal` [`SetOptions`](#livepoll) so the collection is rehydrated through the root's own `SetItems` guard rather than by writing the backing field directly. The class body is empty; all behavior lives in the base. -- **Why it's built this way**: expressing the load as a descriptor (not hand-written query code) keeps every populator uniform and lets the base handle batching and assignment; routing the assignment through `SetOptions` preserves the aggregate boundary even during rehydration. -- **Where it's used**: resolved and run by the query-service pipeline before [`LivePollResultsBuilder`](#livepollresultsbuilder) reads `poll.Options`, and behind the poll read endpoints on [`LivePollsController`](#livepollscontroller). +- **Concept reinforced, declarative navigation population ([ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html)).** `[Rubric §2, Design Patterns]`. The framework's entity-query path returns entities without EF includes; a populator declares, in data, which child collections to rehydrate and how. That is the whole class: it subclasses [`DeclarativeNavigationPopulator`](group-11-navigation-populators.md#declarativenavigationpopulatortentity) and passes exactly one [`ChildNavigationDescriptor`](group-11-navigation-populators.md#childnavigationdescriptortentity-tparentid-tchild-tchildid) (`LivePollNavigationPopulator.cs:11-23`). +- **Walkthrough**: a primary constructor takes [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) and forwards a single-element descriptor array to the base (`:11-22`). The descriptor wires `PropertyName = nameof(LivePoll.Options)` (`:17`), `ParentKeySelector = p => p.Id` (`:18`), `ChildForeignKeySelector = child => child.LivePollId` (`:19`), and `AssignAction = (p, options) => p.SetOptions(options)` (`:20`). That last line calls the aggregate's `internal` [`SetOptions`](#livepoll), so the collection is rehydrated through the root's own `SetItems` path rather than by writing the backing field directly. The class body is empty (`:23-24`); all behavior lives in the base. +- **Why it's built this way**: expressing the load as a descriptor rather than hand-written query code keeps every populator uniform and lets the base own batching and assignment. Routing the assignment through `SetOptions` preserves the aggregate boundary even during rehydration. +- **Where it's used**: registered as `INavigationPopulator` in the module's DI (`DependencyInjection.cs:59`), so the query pipeline runs it before [`LivePollResultsBuilder`](#livepollresultsbuilder) reads `poll.Options`. Note the sibling registration one line on: [`LivePollVote`](#livepollvote) gets a `NullNavigationPopulator` (`DependencyInjection.cs:60`), because a vote has nothing to rehydrate. + +### LivePollOptionNavigationPopulator +> MMCA.ADC.Engagement.Application · `MMCA.ADC.Engagement.Application.LivePolls.Services` · `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/Services/LivePollOptionNavigationPopulator.cs:11` · Level 10 · class (sealed) + +- **What it is**: the mirror-image populator for [`LivePollOption`](#livepolloption): it fills the option's back-reference to its parent [`LivePoll`](#livepoll) when an option is queried on its own. +- **Depends on**: [`DeclarativeNavigationPopulator`](group-11-navigation-populators.md#declarativenavigationpopulatortentity) (base), [`FKNavigationDescriptor`](group-11-navigation-populators.md#fknavigationdescriptortentity-tchild-tchildid) (the descriptor), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`LivePoll`](#livepoll) / [`LivePollOption`](#livepolloption). +- **Concept reinforced, the two descriptor flavors** (see [`LivePollNavigationPopulator`](#livepollnavigationpopulator) and [ADR-002](https://ivanball.github.io/docs/adr/002-navigation-populators.html)). `[Rubric §2, Design Patterns]`. This pair is the clearest illustration of the difference anywhere in the module. A [`ChildNavigationDescriptor`](group-11-navigation-populators.md#childnavigationdescriptortentity-tparentid-tchild-tchildid) walks **down** from a parent key to many children, while an [`FKNavigationDescriptor`](group-11-navigation-populators.md#fknavigationdescriptortentity-tchild-tchildid) walks **up** an FK to a single parent, which is why its `AssignAction` ends in `FirstOrDefault()`. +- **Walkthrough**: a primary constructor takes [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) and forwards one [`FKNavigationDescriptor`](group-11-navigation-populators.md#fknavigationdescriptortentity-tchild-tchildid) to the base (`LivePollOptionNavigationPopulator.cs:11-22`). The descriptor sets `PropertyName = nameof(LivePollOption.LivePoll)` (`:17`), `ParentKeySelector = e => e.LivePollId` (`:18`, the FK **on the option**, which is the inversion relative to the child descriptor), `ChildForeignKeySelector = child => child.Id` (`:19`, the poll's own primary key), and `AssignAction = (e, livePolls) => e.LivePoll = livePolls.FirstOrDefault()` (`:20`). The class body is empty (`:23-24`). +- **Why it's built this way**: the base loads parents in one batched query for a whole page of options rather than one query per option, so declaring the relationship in data is what removes the N+1 a naive lazy-loaded back-reference would create. It also explains why [`LivePollOption.LivePoll`](#livepolloption) is a settable property while its FK `LivePollId` is get-only: the populator is the writer. +- **Where it's used**: registered as `INavigationPopulator` in the module's DI (`DependencyInjection.cs:64`). ### SessionQuestionInvariants > MMCA.ADC.Engagement.Domain · `MMCA.ADC.Engagement.Domain.SessionQuestions` · `MMCA.ADC.Engagement.Domain/SessionQuestions/SessionQuestionInvariants.cs:9` · Level 4 · class (static) diff --git a/docs-src/onboarding/group-24-identity-module.md b/docs-src/onboarding/group-24-identity-module.md index e53b436..c9665f7 100644 --- a/docs-src/onboarding/group-24-identity-module.md +++ b/docs-src/onboarding/group-24-identity-module.md @@ -6,15 +6,17 @@ module dependency graph (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API but it touches every layer end to end, so this chapter doubles as a compact tour of one full vertical slice built on the framework taught in groups 1 through 15. The single aggregate is [`User`](#user), and around it sit the credential and refresh-token lifecycle, the role vocabulary, -the change-password / change-preferences / avatar use cases, the two privacy use cases that make ADC -compliant (data-subject **export** and **erasure**), the persistence and EF configuration, the REST -controllers, the gRPC contract that lets a peer service ask Identity a question, the integration -events that keep the User-to-Speaker link consistent across the service split, and the Blazor profile -and user-list UI. The per-type sections follow; this overview shows how the pieces fit and how a -request flows through them. +the change-password / password-recovery / change-preferences / avatar use cases, the two privacy use +cases that make ADC compliant (data-subject **export** and **erasure**), the persistence and EF +configuration, the REST controllers, the gRPC contract that lets a peer service ask Identity a +question, the integration events that keep the User-to-Speaker link consistent across the service +split, and the Blazor profile and user-list UI. The per-type sections follow; this overview shows how +the pieces fit and how a request flows through them. Almost everything here is an *instantiation* of upstream framework machinery, cross-referenced rather -than re-taught: the [`Result`](group-01-result-error-handling.md#result) pattern (G01), the +than re-taught (the conventions themselves are introduced once in the +[primer](00-primer.md#2-architectural-styles-this-codebase-commits-to)): the +[`Result`](group-01-result-error-handling.md#result) pattern (G01), the [`AuditableAggregateRootEntity`](group-02-domain-building-blocks.md#auditableaggregaterootentitytidentifiertype) entity chain plus the [`IAnonymizable`](group-02-domain-building-blocks.md#ianonymizable) and [`PiiAttribute`](group-02-domain-building-blocks.md#piiattribute) governance markers (G02), the outbox @@ -23,19 +25,22 @@ command/query handler pipeline (G05), the shared auth engine ([`AuthenticationServiceBase`](group-08-auth.md#authenticationservicebasetuser), [`RoleValue`](group-08-auth.md#rolevalue), [`HasPermissionAttribute`](group-08-auth.md#haspermissionattribute), -[`SoftDeletedUserCache`](group-08-auth.md#softdeletedusercache)) from G08, and the hoisted user -use-case bases from G14 +[`SoftDeletedUserCache`](group-08-auth.md#softdeletedusercache), +[`IPasswordResetTokenService`](group-08-auth.md#ipasswordresettokenservice)) from G08, and the hoisted +user use-case bases from G14 ([`ChangePasswordHandlerBase`](group-14-module-system-composition.md#changepasswordhandlerbasetuser-tcommand), +[`ForgotPasswordHandlerBase`](group-14-module-system-composition.md#forgotpasswordhandlerbasetuser-tcommand), +[`ResetPasswordHandlerBase`](group-14-module-system-composition.md#resetpasswordhandlerbasetuser-tcommand), [`GetUserPreferencesHandlerBase`](group-14-module-system-composition.md#getuserpreferenceshandlerbasetuser), [`DeleteUserHandlerBase`](group-14-module-system-composition.md#deleteuserhandlerbasetuser-tcommand), [`ExportUserDataHandlerBase`](group-14-module-system-composition.md#exportuserdatahandlerbasetuser-tquery)) alongside the [`IModule`](group-14-module-system-composition.md#imodule) composition system. The lenses this chapter most strongly embodies are [Rubric §4, Domain-Driven Design] (a behavior-rich aggregate that guards its own invariants), [Rubric §11, Security] (credential handling, RS256 JWTs, -permission-based authorization, a fail-closed OAuth link gate), and [Rubric §30, Compliance / Privacy -/ Data Governance] (the export and erasure flows). The `// BR-NN` markers quoted below are the -in-code business-requirement references, catalogued in the ADC business-requirements guide; the -privacy promises they implement live in `MMCA.ADC/PRIVACY.md`. +permission-based authorization, a fail-closed OAuth link gate, single-use reset tokens), and [Rubric +§30, Compliance / Privacy / Data Governance] (the export and erasure flows). The `// BR-NN` markers +quoted below are the in-code business-requirement references, catalogued in the ADC +business-requirements guide; the privacy promises they implement live in `MMCA.ADC/PRIVACY.md`. ## Projects, one bounded context @@ -52,10 +57,14 @@ only on `MMCA.Common.Domain` and `MMCA.Common.Shared` and knows nothing of EF or **`MMCA.ADC.Identity.Application`** holds the use-case handlers, the Mapperly-generated [`UserDTOMapper`](#userdtomapper) (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/DTOs/UserDTOMapper.cs:13-14`, +which excludes `PasswordHash`, `PasswordSalt`, and `RefreshToken` from the [`UserDTO`](#userdto) +projection, `:10-11`, [ADR-001](https://ivanball.github.io/docs/adr/001-manual-dto-mapping.html)), the FluentValidation -validators, and the cross-module service implementations; its -[`DependencyInjection`](#dependencyinjection) registers four services explicitly, including the -shared [`SoftDeletedUserValidator`](group-14-module-system-composition.md#softdeleteduservalidatortuser) +validators [`RegisterRequestValidator`](#registerrequestvalidator) and +[`ChangePasswordRequestValidator`](#changepasswordrequestvalidator), and the cross-module service +implementations; its [`DependencyInjection`](#dependencyinjection) registers four services explicitly, +including the shared +[`SoftDeletedUserValidator`](group-14-module-system-composition.md#softdeleteduservalidatortuser) closed over `User` (`MMCA.ADC.Identity.Application/DependencyInjection.cs:33-36`), contributes the two export sections in the order they appear in the exported document (`:42-43`), and leaves handlers, mappers, validators, and domain-event handlers to `ScanModuleApplicationServices()` @@ -68,23 +77,26 @@ no-op, kept only so every module has the same shape (`MMCA.ADC.Identity.Infrastructure/DependencyInjection.cs:20`). **`MMCA.ADC.Identity.API`** holds the REST controllers, the [`IdentityModule`](#identitymodule) descriptor (`IdentityModule.cs:13`), and the [`IdentityErrorResources`](#identityerrorresources) anchor whose `.resx` siblings translate domain -error codes into the supported languages (`IdentityErrorResources.cs:11`, +error codes into the supported languages (`IdentityErrorResources.cs:11`, rationale at `:3-9`, [ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html)). **`MMCA.ADC.Identity.Shared`** is the contract package every other layer (including the WebAssembly -client) can reference without dragging in the domain: it carries the DTOs, the +client) can reference without dragging in the domain: it carries the DTOs +([`UserDTO`](#userdto) at `UserDTO.cs:8`, [`UserListDTO`](#userlistdto) at `UserListDTO.cs:7`, +[`UserAvatarDTO`](#useravatardto) at `UserAvatarDTO.cs:6`, and the export family headed by +[`UserDataExportSubjectDTO`](#userdataexportsubjectdto)), the [`IAttendeeQueryService`](#iattendeequeryservice) cross-module interface (`MMCA.ADC.Identity.Shared/Users/IAttendeeQueryService.cs:8`), the [`UserRegistered`](#userregistered) and [`UserDeleted`](#userdeleted) integration events, and the [`IdentityPermissions`](#identitypermissions) / [`IdentitySettings`](#identitysettings) constants (the latter carrying the BR-213 registration budget, `MaxRegistrationsPerIpPerHour = 10`, -`IdentitySettings.cs:15`). Three more projects sit outside the module folder: -**`MMCA.ADC.Identity.Contracts`** (the gRPC adapter), **`MMCA.ADC.Identity.Service`** (the extracted -process host), and **`MMCA.ADC.Identity.UI`** (the Blazor pages). The identifier alias for this -context is `UserIdentifierType = int`, a database-generated identity +`IdentitySettings.cs:15`). **`MMCA.ADC.Identity.UI`** sits in the same module folder and holds the +Blazor pages; two further projects live under `MMCA.ADC/Source/Services/` instead, because they exist +only for the extracted topology: **`MMCA.ADC.Identity.Contracts`** (the gRPC adapter) and +**`MMCA.ADC.Identity.Service`** (the extracted process host). The identifier alias for this context is +`UserIdentifierType = int`, a database-generated identity (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/MMCA.ADC.Identity.GlobalUsings.IdentifierType.cs:2`), while the cross-context `LinkedSpeakerId` uses `SpeakerIdentifierType = System.Guid` -(`MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:18`, -[ADR-048](https://ivanball.github.io/docs/adr/048-primitive-identifier-type-aliases.html)). +([ADR-048](https://ivanball.github.io/docs/adr/048-primitive-identifier-type-aliases.html)). ## The User aggregate: credentials, profile, and cross-context links in one root @@ -120,12 +132,12 @@ It follows the standard framework shape: a private EF constructor (`User.cs:120` constructor (`:130`), and static factory methods returning [`Result`](group-01-result-error-handling.md#result). `Create` (`:163`) validates every invariant with `Result.Combine(...)` *before* constructing anything (`:172-179`), so an invalid user is -unrepresentable; `CreateExternal` (`:205`) builds an OAuth account with empty credential arrays -(`:223`). A subtlety worth carrying forward: the factory deliberately does **not** raise a -registration event. The `Id` is database-generated (`[IdValueGenerated]` at `:32`, `Id` set to -`default` at `:187`), so the cross-module [`UserRegistered`](#userregistered) is raised by the -application layer only after the insert has executed and a real id exists (`:149-155` records exactly -that). The behavior methods each guard their own rule: `ChangePassword` re-validates and raises +unrepresentable; `CreateExternal` (`:205`) builds an OAuth account with empty credential arrays. A +subtlety worth carrying forward: the factory deliberately does **not** raise a registration event. The +`Id` is database-generated (`[IdValueGenerated]` at `:32`, `Id` set to `default` at `:187`), so the +cross-module [`UserRegistered`](#userregistered) is raised by the application layer only after the +insert has executed and a real id exists (`:149-155` records exactly that). The behavior methods each +guard their own rule: `ChangePassword` re-validates and raises [`UserPasswordChanged`](#userpasswordchanged) (`:318-335`); `UpdatePreferences` validates against the supported-culture allowlist and the light/dark theme values (`:288-301`); `Delete()` revokes the refresh token as a security measure, calls the G02 soft-delete, and raises @@ -134,7 +146,7 @@ refresh token as a security measure, calls the G02 soft-delete, and raises [`UserInvariants`](#userinvariants) (`UserInvariants.cs:9`) is the co-located static rule class whose methods each return a [`Result`](group-01-result-error-handling.md#result), several of them delegating to the shared [`CommonInvariants`](group-02-domain-building-blocks.md#commoninvariants) -(`UserInvariants.cs:45-59`). Centralizing each rule as a named, side-effect-free method is what makes +(`UserInvariants.cs:25-28`). Centralizing each rule as a named, side-effect-free method is what makes the domain exhaustively unit-testable ([Rubric §14, Testability]), and its `const` length limits (`FirstNameMaxLength = 100`, `LastNameMaxLength = 100`, `EmailMaxLength = 100`, `DeviceFieldMaxLength = 256`, `UserInvariants.cs:12-21`) are the *same* constants @@ -200,71 +212,121 @@ keep an erased address reserved. Erasure always pairs `Delete` with `Anonymize`, address to a placeholder, so a real erased email is re-registrable by design (GDPR). The filter bypass exists so the two rows the unfiltered unique Email index would otherwise turn into a 500 (legacy rows soft-deleted without anonymization, and the placeholder addresses themselves) come back as a clean -conflict instead (`:77-84`). +conflict instead (`:76-84`). The HTTP surface is equally thin. [`AuthController`](#authcontroller) (`MMCA.ADC.Identity.API/Controllers/AuthController.cs:29`) extends [`UserAccountAuthControllerBase`](group-12-api-hosting-mapping.md#useraccountauthcontrollerbasetchangepasswordcommand-tchangepreferencescommand) (G12), which supplies the login / register / refresh / revoke actions plus the three self-service -account actions (`PUT password`, `PUT preferences`, `GET preferences`, `AuthController.cs:20-24`); the +account actions (`PUT password`, `PUT preferences`, `GET preferences`, `AuthController.cs:18-25`); the ADC subclass adds only two overrides and two command factories. `RegisterAsync` (`:52`) captures the client IP for registration rate limiting (BR-213, `:57`) and carries the per-IP `auth-ip` fixed window (`:48`); `LoginAsync` (`:76`) re-declares the same window as a password-spray guard, because the per-email lockout alone cannot throttle one source spraying one password across many addresses -(`:66-69`). `CreateChangePasswordCommand` and `CreateChangePreferencesCommand` (`:82`, `:87`) are the -only wiring the base needs to dispatch [`ChangePasswordCommand`](#changepasswordcommand) and -[`ChangePreferencesCommand`](#changepreferencescommand) through the -[G05 decorator pipeline](group-05-cqrs-pipeline.md), where the preferences write declares +(`:65-68`). `CreateChangePasswordCommand` and `CreateChangePreferencesCommand` (`:82`, `:87`) are the +only wiring the base needs to dispatch [`ChangePasswordCommand`](#changepasswordcommand) +(`ChangePasswordCommand.cs:14`) and [`ChangePreferencesCommand`](#changepreferencescommand) +(`ChangePreferencesCommand.cs:14`) through the +[G05 decorator pipeline](group-05-cqrs-pipeline.md), where both writes declare [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) with a `User`-typed cache prefix -so a stale cached read cannot mask a preference change (`ChangePreferencesCommand.cs:15`, `:18`). -The three handlers on that path are all body-less subclasses of a G14 base whose only purpose is that -the `source` reported on every error stays the ADC class name, which clients match on: -[`ChangePasswordHandler`](#changepasswordhandler) (`ChangePasswordHandler.cs:12-23`, +so a stale cached read cannot mask the change (`ChangePreferencesCommand.cs:15`, `:18`). The three +handlers on that path are all body-less subclasses of a G14 base whose only purpose is that the +`source` reported on every error stays the ADC class name, which clients match on: +[`ChangePasswordHandler`](#changepasswordhandler) (`ChangePasswordHandler.cs:12-17`, [ADR-032](https://ivanball.github.io/docs/adr/032-password-hashing.html)), [`ChangePreferencesHandler`](#changepreferenceshandler), and -[`GetUserPreferencesHandler`](#getuserpreferenceshandler) (`GetUserPreferencesHandler.cs:8-16`). +[`GetUserPreferencesHandler`](#getuserpreferenceshandler) (`GetUserPreferencesHandler.cs:8-13`). [`OAuthController`](#oauthcontroller) (`OAuthController.cs:20`) is a body-less subclass of [`OAuthControllerBase`](group-12-api-hosting-mapping.md#oauthcontrollerbase) (G12) that drives the Google/GitHub challenge, callback, complete, single-use-code-exchange flow, with the class-level routing and versioning attributes re-declared locally because they are not reliably inherited (`:14-19`); it is an ADC-only feature, since MMCA.Store uses local credentials only. [`UserClaimsController`](#userclaimscontroller) (`UserClaimsController.cs:16`) reflects the -authenticated JWT's claims back to the client. [`UsersController`](#userscontroller) -(`UsersController.cs:31`) hosts the rest: the three avatar endpoints, the organizer user list, the -data export (`:149`), and the account delete (`:171`). Its list endpoint is gated by capability rather -than by role name, `[HasPermission(IdentityPermissions.UsersRead)]` (`:125`), and the +authenticated JWT's claims back to the client (`:27-30`). [`UsersController`](#userscontroller) +(`UsersController.cs:32`) hosts the rest: the three avatar endpoints, the organizer user list, the +data export (`:157`), and the account delete (`:179`). Its list endpoint is gated by capability rather +than by role name, `[HasPermission(IdentityPermissions.UsersRead)]` (`:133`), and the `identity:users:read` grant (`MMCA.ADC.Identity.Shared/Authorization/IdentityPermissions.cs:11`) is handed to Organizer and Admin in `AddModuleIdentityAPI` (`MMCA.ADC.Identity.API/DependencyInjection.cs:44-48`, [ADR-020](https://ivanball.github.io/docs/adr/020-permission-based-authorization.html)). That list itself is served by [`GetUsersHandler`](#getusershandler) -(`MMCA.ADC.Identity.Application/Users/UseCases/GetUsers/GetUsersHandler.cs:16`), which clamps the page -size at 500 through [`PagingMath`](group-03-querying-specifications.md#pagingmath) before touching the -database (`:28`, BR-11) and pushes filtering, `COUNT`, ordering, OFFSET/FETCH paging, and the -projection into SQL (`:34-57`), so the credential columns are never materialized ([Rubric §12, -Performance and Scalability]). Its sort carries an `Id` tie-break (`:93-95`) that makes the `ORDER BY` -total: without it, rows sharing a sort key can repeat or vanish across pages, because OFFSET/FETCH has -no stable row order to page over. +(`MMCA.ADC.Identity.Application/Users/UseCases/GetUsers/GetUsersHandler.cs:16`) from a +[`GetUsersQuery`](#getusersquery) carrying the filter, sort, and paging values (`GetUsersQuery.cs:12`); +the handler clamps the page size at 500 through +[`PagingMath`](group-03-querying-specifications.md#pagingmath) before touching the database (`:28`, +BR-11) and pushes filtering, `COUNT`, ordering, OFFSET/FETCH paging, and the +[`UserListDTO`](#userlistdto) projection into SQL (`:34-57`), so the credential columns are never +materialized ([Rubric §12, Performance and Scalability]). Its sort carries an `Id` tie-break (`:91-95`) +that makes the `ORDER BY` total: without it, rows sharing a sort key can repeat or vanish across pages, +because OFFSET/FETCH has no stable row order to page over. + +## Password recovery: the anonymous half of the credential lifecycle + +`PUT /Auth/password` only serves a user who can already sign in. The recovery pair that serves one who +cannot is a second, anonymous vertical, and it is assembled the same way: two ADC command records over +two G14 workflow bases, exposed by a sibling controller. +[`ForgotPasswordCommand`](#forgotpasswordcommand) +(`MMCA.ADC.Identity.Application/Users/UseCases/ForgotPassword/ForgotPasswordCommand.cs:12`) carries +nothing but the address, and the record's own summary states the two properties that follow from that +(`:7-9`): it is anonymous by design, because a caller who has lost the credential has no user +identifier to scope the command to, and every outcome is reported as success so the response cannot be +used to enumerate registered addresses. [`ForgotPasswordHandler`](#forgotpasswordhandler) +(`ForgotPasswordHandler.cs:20`) inherits +[`ForgotPasswordHandlerBase`](group-14-module-system-composition.md#forgotpasswordhandlerbasetuser-tcommand), +which mints the single-use token through +[`IPasswordResetTokenService`](group-08-auth.md#ipasswordresettokenservice) and mails it through +[`IEmailSender`](group-10-notifications.md#iemailsender) under +[`PasswordResetSettings`](group-08-auth.md#passwordresetsettings); ADC overrides exactly one member, +the untracked lookup by address (`:29-36`), which mirrors the one +[`AuthenticationService`](#authenticationservice) already uses for login. + +The redemption half is [`ResetPasswordCommand`](#resetpasswordcommand) (`ResetPasswordCommand.cs:14`), +which carries the address, the token, and the new password, and which declares +[`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) with the same `User`-typed prefix +as the authenticated change (`:15`, `:18`) for the same reason: the credential the cached aggregate +carries has just changed. [`ResetPasswordHandler`](#resetpasswordhandler) +(`ResetPasswordHandler.cs:18`) is a body-less subclass of +[`ResetPasswordHandlerBase`](group-14-module-system-composition.md#resetpasswordhandlerbasetuser-tcommand) +kept only so the reported error `source` stays `ResetPasswordHandler` (`:13-17`); it takes +[`ILoginProtectionService`](group-08-auth.md#iloginprotectionservice) as a constructor dependency +(`:22`) because the base clears the account's lockout after a successful reset, so a user who locked +themselves out by guessing can use the new credential immediately. Both actions are exposed by +[`PasswordResetController`](#passwordresetcontroller) (`PasswordResetController.cs:28`), a subclass of +[`PasswordResetAuthControllerBase`](group-12-api-hosting-mapping.md#passwordresetauthcontrollerbasetforgotpasswordcommand-tresetpasswordcommand) +that supplies only the two command factories (`:36`, `:39`). It is routed to the same `Auth` prefix as +[`AuthController`](#authcontroller) (`:26`) and is a *sibling* controller rather than more actions on +that class, because `AuthController` already occupies the single inheritance chain, and riding the +existing `/Auth` route means the YARP Gateway needs no change (`:19-24`). The link the email carries is +host configuration, not code: the AppHost injects `PasswordReset__ResetUrl` pointing at the UI's +`/reset-password` page, because the UI port is dynamic under Aspire and the appsettings default would +otherwise address a host that is not listening +(`MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:340-345`). The storage decision behind the token +itself (cache-backed, rather than columns on the user row or a self-contained signed payload) is +recorded in +[ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html) ([Rubric §11, +Security]). ## The privacy pair: export and erasure Two use cases make this module the codebase's clearest [Rubric §30, Compliance / Privacy / Data -Governance] story, and both are now thin ADC specializations of a G14 base. The erasure workflow lives -in [`DeleteUserHandlerBase`](group-14-module-system-composition.md#deleteuserhandlerbasetuser-tcommand); +Governance] story, and both are thin ADC specializations of a G14 base. The erasure workflow lives in +[`DeleteUserHandlerBase`](group-14-module-system-composition.md#deleteuserhandlerbasetuser-tcommand); [`DeleteUserHandler`](#deleteuserhandler) (`MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs:28`) keeps the class name so the reported error `source` stays stable for clients, and supplies the ADC-specific pieces -(`:18-27`). `HasDeletePrivilege` (`:42`) says the Organizer role bypasses the ownership rule, -delegating to `UserRole.IsOrganizer` so the claim's casing does not matter. `OnAfterSoftDeleteAsync` -(`:46`) does two things. It raises the cross-service [`UserDeleted`](#userdeleted) integration event on -the aggregate (`:62`), so the outbox row is written by the very `SaveChangesAsync` that commits the -erasure: Engagement holds a `DisplayName` snapshot on its leaderboard opt-in, lives in its own process -with its own database, and never sees Identity's in-process domain event, so the fact and its -announcement must not be able to come apart (`:56-61`). Its payload is deliberately just the user id -and a timestamp, because carrying a name or email would publish onto a persistent broker the very -personal data the erasure exists to remove -(`MMCA.ADC.Identity.Shared/Users/IntegrationEvents/UserDeleted.cs:16-20`). It then queues two -**after-commit** actions: writing the shared +(`:18-27`) over a [`DeleteUserCommand`](#deleteusercommand) that carries the target id plus the +caller's own id and role (`DeleteUserCommand.cs:11-17`). `HasDeletePrivilege` (`:42`) says the +Organizer role bypasses the ownership rule, delegating to `UserRole.IsOrganizer` so the claim's casing +does not matter. `OnAfterSoftDeleteAsync` (`:46`) does two things. It raises the cross-service +[`UserDeleted`](#userdeleted) integration event on the aggregate (`:62`), so the outbox row is written +by the very `SaveChangesAsync` that commits the erasure: Engagement holds a `DisplayName` snapshot on +its leaderboard opt-in, lives in its own process with its own database, and never sees Identity's +in-process domain event, so the fact and its announcement must not be able to come apart (`:56-61`). +Its payload is deliberately just the user id and a timestamp, because carrying a name or email would +publish onto a persistent broker the very personal data the erasure exists to remove +(`MMCA.ADC.Identity.Shared/Users/IntegrationEvents/UserDeleted.cs:16-20`, record at `:24-27`). It then +queues two **after-commit** actions: writing the shared [`SoftDeletedUserCache`](group-08-auth.md#softdeletedusercache) marker so the API middleware rejects requests still carrying an already-issued access token for the erased account (`:68-80`, BR-133, [ADR-047](https://ivanball.github.io/docs/adr/047-soft-deleted-user-session-revocation.html)), and @@ -287,10 +349,11 @@ The data-subject *access* request (PRIVACY.md §7) is assembled the same way. (`MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/ExportUserDataHandler.cs:30`) inherits [`ExportUserDataHandlerBase`](group-14-module-system-composition.md#exportuserdatahandlerbasetuser-tquery), which owns the owner-or-privileged authorization, the account load, and the section fan-out; ADC -contributes exactly two things (`:10-14`): `HasExportPrivilege`, again `UserRole.IsOrganizer` (`:38`), -and `BuildSubjectSnapshotAsync` (`:41`), which projects the account's own portable fields into a -[`UserDataExportSubjectDTO`](#userdataexportsubjectdto) (`:48-74`). Credentials are **deliberately -excluded**: no password hash, no salt, no refresh token, no provider key +contributes exactly two things (`:10-14`) on top of an [`ExportUserDataQuery`](#exportuserdataquery) +shaped like the delete command (`ExportUserDataQuery.cs:12-15`): `HasExportPrivilege`, again +`UserRole.IsOrganizer` (`:38`), and `BuildSubjectSnapshotAsync` (`:41`), which projects the account's +own portable fields into a [`UserDataExportSubjectDTO`](#userdataexportsubjectdto) (`:48-74`). +Credentials are **deliberately excluded**: no password hash, no salt, no refresh token, no provider key (`MMCA.ADC.Identity.Shared/Users/UserDataExportSubjectDTO.cs:3-7`). One small correctness detail sits at `:67-73`: SQL Server hands audit timestamps back as `Kind=Unspecified`, so the handler re-stamps them UTC, which is the only reason the exported JSON carries the `Z` marker the DTO documents. The @@ -302,15 +365,21 @@ two, in document order (`MMCA.ADC.Identity.Application/DependencyInjection.cs:42 [`EngagementUserDataExportSection`](#engagementuserdataexportsection) (`.../ExportUserData/EngagementUserDataExportSection.cs:19`) reads bookmarks, submitted session questions, the points ledger, check-in history, and leaderboard participation through -[`IUserEngagementExportService`](group-22-engagement-module.md#iuserengagementexportservice) (`:30-31`) +[`IUserEngagementExportService`](group-22-engagement-module.md#iuserengagementexportservice) (`:30-32`) and shapes them into [`UserDataExportEngagementSectionDTO`](#userdataexportengagementsectiondto) -(`:34-68`), turning enum values into their readable names because a data subject reads this document -(`:49-51`, `:58-60`); [`NotificationUserDataExportSection`](#notificationuserdataexportsection) +(`:34-68`) over the per-row records [`UserDataExportBookmarkDTO`](#userdataexportbookmarkdto), +[`UserDataExportSubmittedQuestionDTO`](#userdataexportsubmittedquestiondto), +[`UserDataExportPointsEntryDTO`](#userdataexportpointsentrydto), and +[`UserDataExportCheckInDTO`](#userdataexportcheckindto), turning enum values into their readable names +because a data subject reads this document (`:49-51`, `:58-60`); +[`NotificationUserDataExportSection`](#notificationuserdataexportsection) (`.../ExportUserData/NotificationUserDataExportSection.cs:18`) does the same for inbox rows through -[`IUserNotificationExportService`](group-10-notifications.md#iusernotificationexportservice) (`:29-31`). -Neither section catches transport failures (`:12-15` in both files): that is the point. The base wraps -every section, so a peer that stays unreachable after the standard Polly resilience pipeline degrades -to `Available = false` and the export still succeeds, which is [Rubric §29, Resilience] and [Rubric §7, +[`IUserNotificationExportService`](group-10-notifications.md#iusernotificationexportservice) +(`:29-31`), into [`UserDataExportNotificationSectionDTO`](#userdataexportnotificationsectiondto) and +its [`UserDataExportNotificationDTO`](#userdataexportnotificationdto) items (`:33-40`). Neither section +catches transport failures (`:12-15` in both files): that is the point. The base wraps every section, +so a peer that stays unreachable after the standard Polly resilience pipeline degrades to +`Available = false` and the export still succeeds, which is [Rubric §29, Resilience] and [Rubric §7, Microservices Readiness] applied to a compliance workflow. ## Avatars: the third mutating slice @@ -320,19 +389,23 @@ content boundary, [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-storage-and-avatars.html)). [`UsersController`](#userscontroller) caps the multipart upload at 2 MB in two places, declaratively via `[RequestSizeLimit(MaxAvatarBytes)]` and imperatively via an explicit length check that returns an -`Avatar.InvalidUpload` validation error (`UsersController.cs:41`, `:67`, `:78-84`, BR-116a). +`Avatar.InvalidUpload` validation error (`UsersController.cs:42`, `:75`, `:86-92`, BR-116a). [`SetUserAvatarHandler`](#setuseravatarhandler) -(`MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarHandler.cs:16`) never trusts -the client-declared content type: it sniffs magic bytes through the shared -[`ImageContentSniffer`](group-07-persistence-ef-core.md#imagecontentsniffer) (`:32`), re-encodes to a -canonical 256x256 JPEG via [`IImageProcessor`](group-07-persistence-ef-core.md#iimageprocessor) (`:23`, -`:52`), uploads under a randomized blob name through -[`IFileStorageService`](group-07-persistence-ef-core.md#ifilestorageservice) (`:60-66`), and only then -persists the new URL, deleting the replaced blob *after* the save so a failure leaks one orphaned -image rather than breaking a live avatar (`:74-83`). The random suffix means a replacement never -reuses the old URL, so stale caches self-resolve (`:10-14`). -[`RemoveUserAvatarHandler`](#removeuseravatarhandler) and -[`GetUserAvatarHandler`](#getuseravatarhandler) are the trivial siblings on the same resource. +(`MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarHandler.cs:16`), handling a +[`SetUserAvatarCommand`](#setuseravatarcommand) that carries the bytes as a `ReadOnlyMemory` +(`SetUserAvatarCommand.cs:10`), never trusts the client-declared content type: it sniffs magic bytes +through the shared [`ImageContentSniffer`](group-07-persistence-ef-core.md#imagecontentsniffer) +(`:32`), re-encodes to a canonical 256x256 JPEG via +[`IImageProcessor`](group-07-persistence-ef-core.md#iimageprocessor) (`:23`, `:52`), uploads under a +randomized blob name through +[`IFileStorageService`](group-07-persistence-ef-core.md#ifilestorageservice) (`:64-66`), and only then +persists the new URL, deleting the replaced blob *after* the save so a failure leaks one orphaned image +rather than breaking a live avatar (`:74-82`). The random suffix means a replacement never reuses the +old URL, so stale caches self-resolve (`:10-14`). The result travels back as a +[`UserAvatarDTO`](#useravatardto). [`RemoveUserAvatarHandler`](#removeuseravatarhandler) (over +[`RemoveUserAvatarCommand`](#removeuseravatarcommand)) and +[`GetUserAvatarHandler`](#getuseravatarhandler) (over [`GetUserAvatarQuery`](#getuseravatarquery)) are +the trivial siblings on the same resource. ## Persistence, seeding, and the disabled stub @@ -341,14 +414,14 @@ abstract, engine-agnostic context declaring the single `Users` set (`:22`); the class (`SQLServerDbContext` today) inherits it, and the base [`ApplicationDbContext`](group-07-persistence-ef-core.md#applicationdbcontext) supplies audit stamping, soft-delete query filters, and outbox / domain-event dispatch via interceptors (`:9-13`, `:20`). -Identity owns its own `ADC_Identity` database with its own `dbo.OutboxMessages`, so it never races -another service's outbox (`MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:32`, +Identity owns its own `ADC_Identity` database with its own outbox table, so it never races another +service's outbox (`MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:32`, [ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)). [`UserConfiguration`](#userconfiguration) (`MMCA.ADC.Identity.Infrastructure/Persistence/EntityConfiguration/UserConfiguration.cs:12`) extends [`EntityTypeConfigurationSQLServer`](group-07-persistence-ef-core.md#entitytypeconfigurationsqlservertentity-tidentifiertype), maps the [`Email`](group-02-domain-building-blocks.md#email) value object through the shared -[`EmailValueConverter`](group-07-persistence-ef-core.md#emailvalueconverter) (`:20-24`), mirrors the +[`EmailValueConverter`](group-07-persistence-ef-core.md#emailvalueconverter) (`:21-22`), mirrors the invariant length constants onto the columns, ignores the computed `FullName` and `IsExternalLogin` members (`:112-113`), and pins four indexes that encode business rules as schema ([Rubric §8, Data Architecture]): unique `Email` (`:115`), a filtered index on `RefreshToken` for the refresh lookup @@ -363,14 +436,14 @@ nothing seeds nothing), and only then runs [`IdentityModuleDbSeeder`](#identitym (`MMCA.ADC.Identity.Infrastructure/Persistence/DbContexts/Seeding/IdentityModuleDbSeeder.cs:27`), a subclass of [`IdentityModuleDbSeederBase`](group-07-persistence-ef-core.md#identitymoduledbseederbasetuser) that contributes only the three-account list (`:33-38`), the existence predicate (`:41`) and ADC's -`User.Create` parameter order (`:51`); the check-then-insert idiom in the base is what makes the -seeder idempotent, and the deliberately weak development credentials are documented in its own -remarks (`:21-25`). Note the base's `ShouldSeed` is deliberately *not* overridden, so the -configuration gate has exactly one home (`:17-19`). When the Identity module is *disabled* in a host, -the [`IdentityModule`](#identitymodule) descriptor registers the +`User.Create` parameter order (`:51`); the check-then-insert idiom in the base is what makes the seeder +idempotent, and the deliberately weak development credentials are documented in its own remarks +(`:21-25`). Note the base's `ShouldSeed` is deliberately *not* overridden, so the configuration gate +has exactly one home (`:17-19`). When the Identity module is *disabled* in a host, the +[`IdentityModule`](#identitymodule) descriptor registers the [`DisabledAttendeeQueryService`](#disabledattendeequeryservice) null-object stub through `RegisterDisabledStubs` (`IdentityModule.cs:19-20`), so a consumer that only needs the attendee list -still composes. +still composes (`DisabledAttendeeQueryService.cs:10-11` returns an empty list). ## Crossing the service boundary: gRPC and integration events @@ -399,7 +472,7 @@ so it overwrites both the real service and the disabled stub (`:47`), and which host runs h2c-only for cross-service gRPC, with an optional HTTP/1.1-only health-probe listener, both configured by one call to [`KestrelEndpointExtensions`](group-16-aspire-orchestration.md#kestrelendpointextensions)`.ConfigureEndpointsWithHealthProbe(HttpProtocols.Http2)` -(`Program.cs:81`, rationale at `:71-80`, +(`Program.cs:81`, rationale at `:70-80`, [ADR-012](https://ivanball.github.io/docs/adr/012-grpc-host-transport.html)), and it primes its own request pipeline at startup: [`SelfHttpWarmupTask`](#selfhttpwarmuptask) (`MMCA.ADC.Identity.Service/SelfHttpWarmupTask.cs:23`), a @@ -431,7 +504,7 @@ copying: the exception filter **logs and returns false**, so the exception keeps transient database fault lost the BR-209 back-link permanently, because the delivery had already been acked. Letting the exception through hands the decision to the delivery mechanism, which leaves the inbox row unprocessed so MassTransit redelivers and then dead-letters. The host registers both as -broker consumers (`Program.cs:297-301`). This event-carried link is what lets the bidirectional +broker consumers (`Program.cs:299-300`). This event-carried link is what lets the bidirectional User-to-Speaker relationship survive the service split ([Rubric §6, CQRS and Event-Driven], [ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html) / [ADR-008](https://ivanball.github.io/docs/adr/008-service-extraction-topology.html)). @@ -451,28 +524,29 @@ The [`Profile`](#profile) page (`MMCA.ADC.Identity.UI/Pages/Profile/Profile.razo authenticated user change their password, manage their avatar, and delete their account. It mirrors the server's 2 MB cap client-side before any upload starts (`:25`, `:125-129`), validates the new password inline for length and confirmation match so the form error summary carries the message rather than a -server round-trip (`:43-49`), and accepts an image from either a browser file input (`:117`) or, on -MAUI, the camera and gallery through +server round-trip (`:40-49`, [Rubric §24, Forms / Validation / UX Safety]), and accepts an image from +either a browser file input (`:117`) or, on MAUI, the camera and gallery through [`IMediaPickerService`](group-26-device-capability-layer.md#imediapickerservice) (`:19`, `:86`, `:88`). -It talks to the API through the [`IUserUIService`](#iuseruiservice) abstraction implemented by -[`UserService`](#userservice) (`MMCA.ADC.Identity.UI/Services/UserService.cs:14`), an +It talks to the API through the [`IUserUIService`](#iuseruiservice) abstraction +(`MMCA.ADC.Identity.UI/Services/IUserUIService.cs:11`) implemented by [`UserService`](#userservice) +(`MMCA.ADC.Identity.UI/Services/UserService.cs:14`), an [`AuthenticatedServiceBase`](group-15-common-ui-framework.md#authenticatedservicebase) subclass that attaches the bearer token and calls the REST `users` resource (`:17`), and which deliberately skips the -retry policy on the avatar upload because a picker stream is single-shot and cannot rewind -(`:106-110`, against the `RetryPolicy.ExecuteAsync` every other call uses, `:49`, `:73`, `:88`, -`:127`). [`UserList`](#userlist) (`MMCA.ADC.Identity.UI/Pages/User/UserList.razor.cs:16`) is the -Organizer-only management grid: a +retry policy on the avatar upload because a picker stream is single-shot and cannot rewind (`:106`, +`:111`, against the `RetryPolicy.ExecuteAsync` every other call uses, `:49`, `:73`, `:88`, `:127`). +[`UserList`](#userlist) (`MMCA.ADC.Identity.UI/Pages/User/UserList.razor.cs:16`) is the Organizer-only +management grid: a [`DataGridListPageBase`](group-15-common-ui-framework.md#datagridlistpagebasetdto) closed over `UserListDTO` with server-side filtering, sorting, and paging on a desktop data grid (`:47-64`), plus a [`MobileInfiniteScrollList`](group-15-common-ui-framework.md#mobileinfinitescrolllisttitem) card layout on mobile viewports (`:67-72`), the two kept in sync by the shared -[`ListPageActions`](#listpageactions) helper (`:38-39`, `:76`), which lives in Identity.UI because that +[`ListPageActions`](#listpageactions) helper (`:39`, `:76`), which lives in Identity.UI because that project is the root of the ADC module-UI reference chain (`MMCA.ADC.Identity.UI/Common/ListPageActions.cs:6-12`). The UI targets WCAG 2.1 AA; the login and register flows are scanned by the shared `MMCA.Common.Testing.E2E` workflow bases and the profile page has its own axe-core scan in ADC's suite -(`MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/AccessibilityTests.cs:360`, with the rationale for -not inheriting the Common profile base at `:362-363`), all of it running in the deploy-gating chromium +(`MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/AccessibilityTests.cs:366`, with the rationale for +not inheriting the Common profile base at `:368-369`), all of it running in the deploy-gating chromium E2E leg ([Rubric §21, Accessibility], [Rubric §22, Responsive and Cross-Browser]). ## End-to-end: one registration @@ -514,9 +588,11 @@ readiness), [ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n. [ADR-029](https://ivanball.github.io/docs/adr/029-authentication-brute-force-protection.html) (login protection), [ADR-036](https://ivanball.github.io/docs/adr/036-external-oauth-login.html) (external OAuth login), [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-storage-and-avatars.html) -(file storage and avatars), and +(file storage and avatars), [ADR-047](https://ivanball.github.io/docs/adr/047-soft-deleted-user-session-revocation.html) -(soft-deleted session revocation) are the primary references. +(soft-deleted session revocation), and +[ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html) (cache-backed +password reset) are the primary references. ### AssemblyReference > MMCA.ADC.Identity.{API,Application} · `MMCA.ADC.Identity.{API,Application}` · `MMCA.ADC.Identity.API/AssemblyReference.cs:5` · Level 0 · class (static) @@ -1337,6 +1413,18 @@ OAuth login), [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-sto - **Why it's built this way**: the *current* password is deliberately only checked for presence here, never for strength. Its correctness is a credential comparison against the stored hash, which lives in [`ChangePasswordHandler`](#changepasswordhandler) via `ChangePasswordHandlerBase` (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs`), and an old account may legitimately hold a password that no longer meets today's rules. Applying strength rules to it would lock those users out of the very screen that would fix the problem ([ADR-032](https://ivanball.github.io/docs/adr/032-password-hashing.html)). - **Where it's used**: resolved as `IValidator` by `CommandRequestValidator`, which the Validating decorator runs before [`ChangePasswordHandler`](#changepasswordhandler) for the `PUT auth/password` endpoint on [`AuthController`](#authcontroller). +### ForgotPasswordCommand +> MMCA.ADC.Identity.Application · `MMCA.ADC.Identity.Application.Users.UseCases.ForgotPassword` · `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ForgotPassword/ForgotPasswordCommand.cs:12` · Level 1 · record (sealed) + +- **What it is**: the command that starts a password reset. It wraps the address the reset was requested for, and nothing else. +- **Depends on**: [`ForgotPasswordRequest`](group-08-auth.md#forgotpasswordrequest) (the shared wire payload), [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest). +- **Concept introduced, the command with no caller identity.** `[Rubric §11, Security]` (assesses whether an anonymous surface leaks information through its shape) and `[Rubric §5, Vertical Slice]` (assesses whether a use case owns its own request shape). Every other user command in this module carries the caller: [`ChangePasswordCommand`](#changepasswordcommand) is `(UserIdentifierType UserId, ChangePasswordRequest Request)` and additionally marks itself `ICacheInvalidating` and `IUserScopedCommand` (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordCommand.cs:14-15`). This one carries none of that, and the record's own doc comment says why (`ForgotPasswordCommand.cs:7-9`): the caller has lost the credential, so there is no authenticated user id to scope it to, and the handler answers success whether or not the address holds an account. A caller-scoped marker would be a lie here, and an authorization-flavored failure would be the enumeration oracle the workflow exists to close. +- **Walkthrough**: a one-line positional record with a single member (`ForgotPasswordCommand.cs:12-13`). + - `Request` is the whole payload, so the marker interface is the load-bearing part of the declaration. Because the record implements `ICommandWithRequest`, the module scan auto-registers `IValidator` as a [`CommandRequestValidator`](group-06-validation.md#commandrequestvalidatortcommand-trequest) that delegates to whatever `IValidator` is registered (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:192-210`). + - That request validator is [`ForgotPasswordRequestValidator`](group-08-auth.md#forgotpasswordrequestvalidator), and it lives in the framework rather than in ADC (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ForgotPasswordRequestValidator.cs:11`), registered by `AddValidatorsFromAssemblyContaining()` because a module scan only sees its own assembly (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:45-48`). It checks the shape of the address and nothing else, and its doc comment states the reason (`ForgotPasswordRequestValidator.cs:7-9`): a 400 that depended on whether the address had an account would be exactly the oracle the always-accepted response closes. +- **Why it's built this way**: the command record stays app-side even though the workflow is entirely shared, the same split the change-password use case uses. The framework's controller and handler read it back only through `ICommandWithRequest` (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:32-39`, `MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:26-31`), which leaves each app free to attach its own markers: ADC marks its [`ResetPasswordCommand`](#resetpasswordcommand) `ICacheInvalidating` and this one deliberately carries no marker at all, because starting a reset changes no cached state ([ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html)). +- **Where it's used**: built by [`PasswordResetController`](#passwordresetcontroller) in its one-line factory override `CreateForgotPasswordCommand` (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:36`) for `POST Auth/forgot-password`, and handled by [`ForgotPasswordHandler`](#forgotpasswordhandler), which the controller receives as `ICommandHandler` (`PasswordResetController.cs:29`). + ### HttpContextExternalLoginEmailVerifier > MMCA.ADC.Identity.API · `MMCA.ADC.Identity.API.Authentication` · `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Authentication/HttpContextExternalLoginEmailVerifier.cs:17` · Level 1 · class (sealed) @@ -1346,7 +1434,7 @@ OAuth login), [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-sto - **Walkthrough**: a primary-constructor class taking one dependency (`HttpContextExternalLoginEmailVerifier.cs:17-18`) and exposing one method. - `EmailVerifiedClaimType` (`HttpContextExternalLoginEmailVerifier.cs:21`), the `internal const string "email_verified"`. `internal` rather than `private` so the claim name is nameable from the test assembly instead of being re-typed as a literal. - `IsCurrentExternalLoginEmailVerifiedAsync()` (`HttpContextExternalLoginEmailVerifier.cs:24-36`). It reads `httpContextAccessor.HttpContext` and returns `false` when there is none (`:26-30`), so a call outside a request reports unverified instead of throwing. - - It then re-authenticates the short-lived `ExternalLogin` cookie (`:32`, `ExternalAuthExtensions.ExternalLoginScheme`, the constant at `MMCA.Common/Source/Presentation/MMCA.Common.API/Authentication/ExternalAuthExtensions.cs:27`). That is the same principal `OAuthControllerBase.CompleteAsync` just authenticated (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:78`), so the claim is read from the provider's own freshly minted principal, never from anything a client supplied. + - It then re-authenticates the short-lived `ExternalLogin` cookie (`:32`, `ExternalAuthExtensions.ExternalLoginScheme`, the constant at `MMCA.Common/Source/Presentation/MMCA.Common.API/Authentication/ExternalAuthExtensions.cs:27`). That is the same principal `OAuthControllerBase.CompleteAsync` just authenticated (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:79`), so the claim is read from the provider's own freshly minted principal, never from anything a client supplied. - The tail collapses three failure modes into one expression (`:33-35`): a null principal, a missing claim, or a value that is not a parseable `true` all yield `false`, via `authenticateResult.Principal?.FindFirst(...)?.Value` plus `bool.TryParse(claimValue, out var verified) && verified`. - **Why it's built this way**: the claim only exists because the Identity service host maps it. `services.PostConfigure(...)` maps both the v3 `email_verified` key and the legacy v2 `verified_email` key onto the single `email_verified` claim (`MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:219-223`), and the comment above it records the consequence (`Program.cs:213-218`): GitHub's OAuth user payload asserts nothing, so a GitHub login reads as unverified by design and the link-by-email path is refused for it. Registration is `services.TryAddScoped()` immediately after `AddHttpContextAccessor()` in `AddModuleIdentityAPI` (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/DependencyInjection.cs:54-55`), with the rationale for placing it in the API layer in the comment above (`DependencyInjection.cs:50-53`). - **Where it's used**: injected into [`AuthenticationService`](#authenticationservice) (`AuthenticationService.cs:40`) and called from the external-login path when an account with that email already exists (`AuthenticationService.cs:196-197`). An unverified answer returns `Error.Unauthorized` with the code `Auth.ExternalEmailNotVerified` and a message steering the user back to the local credential flow (`AuthenticationService.cs:199-205`); only a verified answer reaches `existingUser.LinkExternalProvider(...)` (`AuthenticationService.cs:207`). The [`OAuthController`](#oauthcontroller) endpoints are the surface this runs behind. @@ -1370,10 +1458,10 @@ OAuth login), [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-sto - **What it is**: the request for a data-subject export (PRIVACY.md §7): which account to export, plus who is asking and with what role. - **Depends on**: [`IUserOwnedRequest`](group-14-module-system-composition.md#iuserownedrequest), the `UserIdentifierType` alias. -- **Concept introduced, carrying the caller inside the query.** `[Rubric §11, Security]` (assesses whether authorization decisions are made on trusted, explicit inputs) and `[Rubric §5, Vertical Slice]` (assesses whether a use case owns its own request shape). Three positional members (`ExportUserDataQuery.cs:12-15`): `UserId`, `CurrentUserId`, and the nullable `CurrentUserRole`. The handler never reaches for `HttpContext`; the controller reads the claims once and puts them in the query ([`UsersController`](#userscontroller), `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:157-162`). That is what makes the whole use case testable without a web host, and it is why the ownership rule can live in the shared base rather than in a controller filter. Implementing [`IUserOwnedRequest`](group-14-module-system-composition.md#iuserownedrequest) is the load-bearing part: the framework's generic constraint is `where TQuery : IUserOwnedRequest` (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/ExportUserDataHandlerBase.cs:55`), so the shared [`UserOwnershipRule`](group-14-module-system-composition.md#userownershiprule) can read `UserId`, `CurrentUserId`, and `CurrentUserRole` off any app's query record (`ExportUserDataHandlerBase.cs:81-86`). +- **Concept introduced, carrying the caller inside the query.** `[Rubric §11, Security]` (assesses whether authorization decisions are made on trusted, explicit inputs) and `[Rubric §5, Vertical Slice]` (assesses whether a use case owns its own request shape). Three positional members (`ExportUserDataQuery.cs:12-15`): `UserId`, `CurrentUserId`, and the nullable `CurrentUserRole`. The handler never reaches for `HttpContext`; the controller reads the claims once and puts them in the query ([`UsersController`](#userscontroller), `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:169-171`). That is what makes the whole use case testable without a web host, and it is why the ownership rule can live in the shared base rather than in a controller filter. Implementing [`IUserOwnedRequest`](group-14-module-system-composition.md#iuserownedrequest) is the load-bearing part: the framework's generic constraint is `where TQuery : IUserOwnedRequest` (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/ExportUserDataHandlerBase.cs:55`), so the shared [`UserOwnershipRule`](group-14-module-system-composition.md#userownershiprule) can read `UserId`, `CurrentUserId`, and `CurrentUserRole` off any app's query record (`ExportUserDataHandlerBase.cs:81-86`). - **Walkthrough**: a positional record with an XML doc per parameter and nothing else (`ExportUserDataQuery.cs:5-15`). What it deliberately does **not** implement is as informative as what it does: it carries no `IQueryCacheable`, so the Caching decorator has nothing to act on. The base handler's remarks say why (`ExportUserDataHandlerBase.cs:42-45`): the document it produces is PII by design and must never be logged or cached. - **Why it's built this way**: the nullable `CurrentUserRole` mirrors reality at the edge, where a role claim may simply be absent. The privilege check is therefore written to accept null and answer `false` rather than to assume a role exists ([`ExportUserDataHandler.HasExportPrivilege`](#exportuserdatahandler), `ExportUserDataHandler.cs:38`). -- **Where it's used**: constructed by [`UsersController`](#userscontroller) for `GET users/{userId}/export` (`UsersController.cs:149-162`) and handled by [`ExportUserDataHandler`](#exportuserdatahandler), which the controller receives as `IQueryHandler>` (`UsersController.cs:34`). +- **Where it's used**: constructed by [`UsersController`](#userscontroller) for `GET users/{userId}/export` (`UsersController.cs:155-176`) and handled by [`ExportUserDataHandler`](#exportuserdatahandler), which the controller receives as `IQueryHandler>` (`UsersController.cs:35`). ### SelfHttpWarmupTask > MMCA.ADC.Identity.Service · `MMCA.ADC.Identity.Service` · `MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/SelfHttpWarmupTask.cs:23` · Level 2 · class (sealed, internal) @@ -1396,7 +1484,7 @@ OAuth login), [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-sto - **Depends on**: [`BaseDomainEvent`](group-04-events-outbox.md#basedomainevent), the `UserIdentifierType` alias. - **Concept reinforced, the domain event as an internal fact.** `[Rubric §4, DDD]` (assesses whether state changes that other code cares about are expressed as named domain facts instead of inferred from a database write) and `[Rubric §6, CQRS & Event-Driven]`. The distinction from an integration event is the one to keep straight, and this module makes it unusually concrete: there is a **second** `UserDeleted` record, in `MMCA.ADC.Identity.Shared.Users.IntegrationEvents`, carrying the same fact to other processes. A `BaseDomainEvent` such as this one is dispatched in-process by the framework's `DomainEventDispatcher` after `SaveChangesAsync` (deferred until after commit when a transaction is open), while a [`BaseIntegrationEvent`](group-04-events-outbox.md#baseintegrationevent) leaves its outbox row unprocessed for the [`OutboxProcessor`](group-04-events-outbox.md#outboxprocessor) to publish to the broker ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)). Same `AddDomainEvent` call at the aggregate, two very different delivery paths, chosen purely by base type. - **Walkthrough**: a one-line positional record, `public sealed record class UserDeleted(UserIdentifierType UserId) : BaseDomainEvent` (`UserDeleted.cs:10-12`). The id-only payload is deliberate: an in-process subscriber can load whatever else it needs from the same unit of work, and a fat payload would go stale between raise and dispatch. -- **Why it's built this way**: raising the event inside `User.Delete` (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:370`) and only when the base soft-delete actually succeeded (`User.cs:367-369`) means a second delete on an already-deleted account raises nothing, so subscribers cannot see a duplicate fact. `Delete` also revokes the refresh token first (`User.cs:366`), so outstanding sessions die whether or not anybody listens to the event. +- **Why it's built this way**: raising the event inside `User.Delete` (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:370`) and only when the base soft-delete actually succeeded (`User.cs:367-371`) means a second delete on an already-deleted account raises nothing, so subscribers cannot see a duplicate fact. `Delete` also revokes the refresh token first (`User.cs:366`), so outstanding sessions die whether or not anybody listens to the event. - **Where it's used**: raised by [`User.Delete`](#user) (`User.cs:364-374`) and asserted by the domain tests (`MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Domain.Tests/Users/UserInvariantsAndRoleTests.cs:255-263`). - **Caveats / not-in-source**: no `IDomainEventHandler` exists anywhere in ADC source today. The doc comment names cascade cleanup and audit logging as the intended consumers (`UserDeleted.cs:6-7`), but that is an available extension point, not shipped behavior. The cross-process cleanup that *is* shipped travels on the Shared integration event of the same name, raised separately by [`DeleteUserHandler`](#deleteuserhandler); the two records are not connected in code. @@ -1408,20 +1496,20 @@ OAuth login), [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-sto - **Concept**: structurally identical to the domain [`UserDeleted`](#userdeleted), and the same domain-event-versus-integration-event distinction applies. What is worth noticing is the omission: the payload is the id only (`UserPasswordChanged.cs:9-11`), never the new hash or salt. A security-relevant event that carried credential material would turn every future subscriber, and every log line that serialized it, into a leak (`[Rubric §11, Security]`). - **Walkthrough**: `public sealed record class UserPasswordChanged(UserIdentifierType UserId) : BaseDomainEvent` (`UserPasswordChanged.cs:9-11`). - **Where it's used**: raised by [`User.ChangePassword`](#user) after the two credential invariants pass and the new hash and salt are assigned (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:329-332`), and asserted by the domain tests (`MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Domain.Tests/Users/UserTests.cs:200`). -- **Caveats / not-in-source**: like the domain [`UserDeleted`](#userdeleted), no handler subscribes to it in ADC source today. Session revocation on a password change is not driven from this event; refresh-token revocation is an explicit aggregate call (`User.RevokeRefreshToken`). +- **Caveats / not-in-source**: like the domain [`UserDeleted`](#userdeleted), no handler subscribes to it in ADC source today. Session revocation on a password change is not driven from this event; refresh-token revocation is an explicit aggregate call (`User.RevokeRefreshToken`, `User.cs:259`). ### NotificationUserDataExportSection > MMCA.ADC.Identity.Application · `MMCA.ADC.Identity.Application.Users.UseCases.ExportUserData` · `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/NotificationUserDataExportSection.cs:18` · Level 3 · class (sealed) - **What it is**: the Notifications contribution to a data-subject export: the user's notification inbox rows, fetched from the Notification service and projected into the export's own DTO shape. - **Depends on**: [`IUserDataExportSection`](group-14-module-system-composition.md#iuserdataexportsection) (the contract), [`UserDataExportSectionResult`](group-14-module-system-composition.md#userdataexportsectionresult), [`IUserNotificationExportService`](group-10-notifications.md#iusernotificationexportservice) (the cross-service peer), [`UserDataExportNotificationSectionDTO`](#userdataexportnotificationsectiondto), [`UserDataExportNotificationDTO`](#userdataexportnotificationdto). -- **Concept introduced, the export section as a pluggable contributor.** `[Rubric §30, Compliance, Privacy & Data Governance]` (assesses whether a data-subject access request can actually be satisfied across every store that holds the subject's data) and `[Rubric §7, Microservices Readiness]` (assesses that a module reaches a peer through an interface it could satisfy in-process or over the wire). An access request is only as complete as the list of places that answer it, and in ADC those places are separate processes with separate databases. The framework's answer is a small interface, [`IUserDataExportSection`](group-14-module-system-composition.md#iuserdataexportsection) (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/IUserDataExportSection.cs:20`), with a stable `SectionName` that appears verbatim in the document a subject reads (`IUserDataExportSection.cs:22-27`) and one `ExportAsync` per user. Sections accumulate through `AddUserDataExportSection()`, which registers them scoped and via `TryAddEnumerable` so a double registration adds one entry (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:206-212`), and they are exported in registration order. +- **Concept introduced, the export section as a pluggable contributor.** `[Rubric §30, Compliance, Privacy & Data Governance]` (assesses whether a data-subject access request can actually be satisfied across every store that holds the subject's data) and `[Rubric §7, Microservices Readiness]` (assesses that a module reaches a peer through an interface it could satisfy in-process or over the wire). An access request is only as complete as the list of places that answer it, and in ADC those places are separate processes with separate databases. The framework's answer is a small interface, [`IUserDataExportSection`](group-14-module-system-composition.md#iuserdataexportsection) (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/IUserDataExportSection.cs:20`), with a stable `SectionName` that appears verbatim in the document a subject reads (`IUserDataExportSection.cs:22-27`) and one `ExportAsync` per user. Sections accumulate through `AddUserDataExportSection()`, which registers them scoped and via `TryAddEnumerable` so a double registration adds one entry (`MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:240-246`), and they are exported in registration order. - **Walkthrough**: a primary-constructor class over one dependency (`NotificationUserDataExportSection.cs:18-19`). - `SectionName => "Notifications"` (`NotificationUserDataExportSection.cs:22`). Treat it as contract text, not a label: it is the key a subject (or a regulator) reads in the JSON. - `ExportAsync` (`NotificationUserDataExportSection.cs:25-46`) awaits `GetUserNotificationExportAsync(userId, ...)` on the peer (`:29-31`), then projects each row into a [`UserDataExportNotificationDTO`](#userdataexportnotificationdto) with `NotificationId`, `Title`, `SentOn`, `IsRead`, and `ReadOn` (`:35-42`), wrapped in a [`UserDataExportNotificationSectionDTO`](#userdataexportnotificationsectiondto) (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/UserDataExportNotificationSectionDTO.cs:10`). The re-projection is what keeps the exported wire shape owned by `Identity.Shared` rather than by whatever the peer happens to return today. - `UserDataExportSectionResult.Complete(SectionName, data)` (`:45`) closes it out. A user with an empty inbox produces an empty list and a `Complete` result, which is a truthful "nothing here" rather than the ambiguous "could not tell" an `Unavailable` would report (`IUserDataExportSection.cs:8-14`). -- **Why it's built this way**: the class doc (`NotificationUserDataExportSection.cs:12-15`) makes the omission explicit: transport failures are **not** caught here. Catching them locally would mean every section reinventing the degrade policy; letting them propagate one frame lets the base of [`ExportUserDataHandler`](#exportuserdatahandler) apply one policy to all sections (`[Rubric §29, Resilience & Business Continuity]`). -- **Where it's used**: registered second, after Engagement, in `AddModuleIdentityApplication` (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:43`), which fixes its position in the document; the peer client is wired in the Identity service host with `services.AddNotificationUserExportClient()` (`MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:281`), resolving to the gRPC adapter (`MMCA.ADC/Source/Services/MMCA.ADC.Notification.Contracts/UserNotificationExportServiceGrpcAdapter.cs:10`) outside the Notification process. Covered by `MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/NotificationUserDataExportSectionTests.cs`. +- **Why it's built this way**: the class doc (`NotificationUserDataExportSection.cs:11-15`) makes the omission explicit: transport failures are **not** caught here. Catching them locally would mean every section reinventing the degrade policy; letting them propagate one frame lets the base of [`ExportUserDataHandler`](#exportuserdatahandler) apply one policy to all sections (`[Rubric §29, Resilience & Business Continuity]`). +- **Where it's used**: registered second, after Engagement, in `AddModuleIdentityApplication` (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:43`), which fixes its position in the document; the peer client is wired in the Identity service host with `services.AddNotificationUserExportClient()` (`MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:281`), resolving to the gRPC adapter (`MMCA.ADC/Source/Services/MMCA.ADC.Notification.Contracts/UserNotificationExportServiceGrpcAdapter.cs:17`) outside the Notification process. Covered by `MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/NotificationUserDataExportSectionTests.cs`. ### UserDeleted > MMCA.ADC.Identity.Shared · `MMCA.ADC.Identity.Shared.Users.IntegrationEvents` · `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/IntegrationEvents/UserDeleted.cs:24` · Level 3 · record (sealed) @@ -1431,14 +1519,14 @@ OAuth login), [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-sto - **Concept introduced, the erasure announcement, and why its payload is nearly empty.** `[Rubric §30, Compliance, Privacy & Data Governance]` (assesses whether an erasure reaches every store that holds the subject's personal data) and `[Rubric §11, Security]`. Two positional members only (`UserDeleted.cs:24-27`): `UserId` and `DeletedOn`. The doc comment gives the reason in one sentence (`UserDeleted.cs:16-20`): a downstream module reacting to an erasure already stores the scalar user id, and carrying a name or an email here would publish the very personal data the erasure exists to remove, onto a broker that persists messages. An erasure event that leaked PII would be self-defeating. - **Walkthrough**: the interesting mechanics are not in the record, they are at the raise site. [`DeleteUserHandler`](#deleteuserhandler) calls `user.AddDomainEvent(new UserDeleted(command.UserId, timeProvider.GetUtcNow()))` inside `OnAfterSoftDeleteAsync`, **before** the erasure is saved (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs:62`), and the comment above it spells out what that buys (`DeleteUserHandler.cs:56-61`): the outbox row is written by the very `SaveChangesAsync` that commits the soft-delete and the anonymization, so an account that is erased has always produced exactly one event, and an erasure that rolls back produces none. Publishing after the commit instead would leave a crash window in which the account is gone and the published name is not. The timestamp comes from the injected `TimeProvider`, not `DateTimeOffset.UtcNow`, so the handler stays testable. - **Why it's built this way**: the doc comment (`UserDeleted.cs:10-15`) is explicit that this record does **not** replace the in-process domain [`UserDeleted`](#userdeleted): Engagement is a different process with its own database and never sees an Identity in-process dispatch. Two records carrying one fact is the honest modelling of a two-process reality, and the base type is the only thing that decides which path a raise takes ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)). -- **Where it's used**: consumed by Engagement's [`UserDeletedPointsHandler`](group-22-engagement-module.md#userdeletedpointshandler) (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/UserDeletedPointsHandler.cs:36-38`), which takes the account off the public leaderboard and overwrites the [`LeaderboardOptIn`](group-22-engagement-module.md#leaderboardoptin) display name the attendee had published there, the one place Engagement holds a name rather than a scalar id (`UserDeletedPointsHandler.cs:13-21`). The subscription is wired in the Engagement service host with `x.RegisterIntegrationEventConsumer()` (`MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:302`). That handler is idempotent in both directions and deliberately does not swallow exceptions, so a failed erasure is retried by the outbox and the broker instead of being acked away with a log line (`UserDeletedPointsHandler.cs:22-28`). +- **Where it's used**: consumed by Engagement's [`UserDeletedPointsHandler`](group-22-engagement-module.md#userdeletedpointshandler) (`MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/UserDeletedPointsHandler.cs:36-38`), which takes the account off the public leaderboard and overwrites the [`LeaderboardOptIn`](group-22-engagement-module.md#leaderboardoptin) display name the attendee had published there, the one place Engagement holds a name rather than a scalar id (`UserDeletedPointsHandler.cs:13-21`). The subscription is wired in the Engagement service host with `x.RegisterIntegrationEventConsumer()` (`MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:308`). That handler is idempotent in both directions and deliberately does not swallow exceptions, so a failed erasure is retried by the outbox and the broker instead of being acked away with a log line (`UserDeletedPointsHandler.cs:22-28`). ### UserRegistered > MMCA.ADC.Identity.Shared · `MMCA.ADC.Identity.Shared.Users.IntegrationEvents` · `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/IntegrationEvents/UserRegistered.cs:23` · Level 3 · record (sealed) - **What it is**: the cross-service announcement that a new account exists. It carries the database-generated user id plus the identity fields another context needs to match on, and it is the event that drives the BR-207 speaker auto-link. - **Depends on**: [`BaseIntegrationEvent`](group-04-events-outbox.md#baseintegrationevent), the `UserIdentifierType` alias. -- **Concept introduced, the integration event as a published contract.** `[Rubric §6, CQRS & Event-Driven]` (assesses whether contexts collaborate through facts rather than commands), `[Rubric §7, Microservices Readiness]` (assesses that the producer does not know its consumers), and `[Rubric §9, API & Contract Design]`. Three properties of this record make it a contract rather than an internal message. It lives in `Shared`, the assembly a consumer may reference without touching Identity's Domain or Application. It carries denormalized primitives (`string Email`, `string Role`) rather than the [`Email`](group-02-domain-building-blocks.md#email) value object or [`UserRole`](#userrole), so a subscriber needs no Identity types to deserialize it. And it is a `record` with positional members, so adding an optional member later is an additive change consumers can ignore ([ADR-010](https://ivanball.github.io/docs/adr/010-integration-event-schema-versioning.html)). +- **Concept introduced, the integration event as a published contract.** `[Rubric §6, CQRS & Event-Driven]` (assesses whether contexts collaborate through facts rather than commands), `[Rubric §7, Microservices Readiness]` (assesses that the producer does not know its consumers), and `[Rubric §9, API & Contract Design]`. Three properties of this record make it a contract rather than an internal message. It lives in `Shared`, the assembly a consumer may reference without touching Identity's Domain or Application. It carries denormalized primitives (`string Email`, `string FirstName`, `string LastName`, `string Role`) rather than the [`Email`](group-02-domain-building-blocks.md#email) value object or [`UserRole`](#userrole), so a subscriber needs no Identity types to deserialize it. And it is a `record` with positional members, so adding an optional member later is an additive change consumers can ignore ([ADR-010](https://ivanball.github.io/docs/adr/010-integration-event-schema-versioning.html)). - **Walkthrough**: five positional members (`UserRegistered.cs:23-29`): `UserId`, `Email`, `FirstName`, `LastName`, `Role`. `Email` is the field the auto-link actually matches on; the two name fields exist so a subscriber can report candidates without a call back into Identity. - **Why it's built this way**: the ordering problem is the whole story. The id is a database-generated identity column, so the event cannot be raised inside [`User.Create`](#user) (the factory remarks say so outright, `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:149-155`): the id is still `default` at that point, and the outbox serializes the payload at capture time, so it would persist `UserId = 0`, which the cross-service consumer cannot resolve (`AuthenticationService.cs:22-25`). Instead [`AuthenticationService`](#authenticationservice) wraps the base registration workflow in a single transaction (`AuthenticationService.cs:57-63`), then in `OnUserRegisteredAsync` raises the event on the already-persisted aggregate and saves a second time (`AuthenticationService.cs:112-117`). The first save populates the real id; the second save writes the outbox row; both are inside one transaction, so the user and the event commit atomically. The remarks on that override (`AuthenticationService.cs:103-111`) name the consequence honestly: this is eventual consistency, and the token handed back to the just-registered user does not yet carry the `speaker_id` claim. The same raise happens for brand-new external OAuth users (`AuthenticationService.cs:230-238`). - **Where it's used**: consumed by Conference's [`UserRegisteredHandler`](group-18-conference-application.md#userregisteredhandler), which runs the email-match speaker auto-link and publishes `SpeakerLinkedToUser` back so Identity can set `User.LinkedSpeakerId`; Identity registers the consumers for that return event in its own host (`MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:299-300`). @@ -1468,11 +1556,11 @@ OAuth login), [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-sto - **Walkthrough**: `ExportAsync` (`EngagementUserDataExportSection.cs:26-71`) makes a single peer call (`:30-32`) and then assembles one section DTO (`:34-68`). - `Bookmarks` (`:36-40`) and `SubmittedQuestions` (`:41-46`) project scalar ids plus `CreatedOn`. No session or question titles are pulled across: Engagement stores ids, and the export is truthful about what Engagement actually holds. - `PointsEntries` (`:47-55`) converts `ActivityType` with `.ToString()`, and the comment says why (`:49-50`): the export document is read by the data subject, so an activity travels as its readable name rather than an enum number. That is a real API-design decision, because it means renaming an enum member changes an externally visible document. - - `CheckIns` (`:56-65`) applies the same rule to `Scope` (`:57-59`) and carries the nullable `EventId`, `SessionId`, and `SponsorId` alongside `CheckedInOn`, which is what makes the three check-in scopes distinguishable in the output. + - `CheckIns` (`:56-65`) applies the same rule to `Scope` (`:58-60`) and carries the nullable `EventId`, `SessionId`, and `SponsorId` alongside `CheckedInOn`, which is what makes the three check-in scopes distinguishable in the output. - `IsOnLeaderboard` and `LeaderboardDisplayName` (`:66-67`) close the section. Those are the same two facts Engagement's [`UserDeletedPointsHandler`](group-22-engagement-module.md#userdeletedpointshandler) erases on account deletion, which is the neat symmetry of this module: access and erasure operate on the identical set of data. - `UserDataExportSectionResult.Complete(SectionName, data)` (`:70`). - **Why it's built this way**: registration order is document order, and Engagement is registered first (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:38-43`), with the comment above the two calls recording that this is deliberate rather than incidental. Stable ordering means an exported document can be diffed across runs. -- **Where it's used**: the peer client is wired in the Identity service host with `services.AddEngagementUserExportClient()` (`MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:280`), which resolves to the gRPC adapter outside the Engagement process (`MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Contracts/UserEngagementExportServiceGrpcAdapter.cs:12`). The host comment states the coupling explicitly (`Program.cs:276-279`): both calls are best-effort consumers, so Identity's startup never waits on either peer. Covered by `MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/EngagementUserDataExportSectionTests.cs`. +- **Where it's used**: the peer client is wired in the Identity service host with `services.AddEngagementUserExportClient()` (`MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:280`), which resolves to the gRPC adapter outside the Engagement process (`MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Contracts/UserEngagementExportServiceGrpcAdapter.cs:18`). The host comment states the coupling explicitly (`Program.cs:276-279`): both calls are best-effort consumers, so Identity's startup never waits on either peer. Covered by `MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/EngagementUserDataExportSectionTests.cs`. ### RegisterRequestValidator > MMCA.ADC.Identity.Application · `MMCA.ADC.Identity.Application.Users.Validation` · `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/Validation/RegisterRequestValidator.cs:12` · Level 7 · class (sealed) @@ -1493,16 +1581,32 @@ OAuth login), [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-sto - **What it is**: ADC's data-subject export handler. It is a thin subclass of the framework's export workflow that answers exactly two app-specific questions: which role may export somebody else's account, and which of the account's own fields count as portable personal data. - **Depends on**: [`ExportUserDataHandlerBase`](group-14-module-system-composition.md#exportuserdatahandlerbasetuser-tquery), [`User`](#user), [`ExportUserDataQuery`](#exportuserdataquery), [`UserRole`](#userrole), [`UserDataExportSubjectDTO`](#userdataexportsubjectdto), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`IUserDataExportSection`](group-14-module-system-composition.md#iuserdataexportsection); externals: `TimeProvider`, `ILogger`. -- **Concept introduced, the template-method handler where the app supplies only its vocabulary.** `[Rubric §30, Compliance, Privacy & Data Governance]` (assesses whether access and portability are implemented once and identically everywhere), `[Rubric §2, Design Patterns]` (template method), and `[Rubric §1, SOLID]` (the base is closed for modification and open through three protected hooks). The base (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/ExportUserDataHandlerBase.cs:49`) owns the whole workflow: the owner-or-privileged-role check via `UserOwnershipRule.CheckOwnership`, returning a `User.ExportForbidden` error (`ExportUserDataHandlerBase.cs:81-90`), a no-tracking load through `GetReadRepository` with `Error.NotFound` when the account is gone (`:92-98`), the subject snapshot, the section fan-out (`:104-108`), and the envelope stamped with `FormatVersion` "1.0" and a `GeneratedOn` from `TimeProvider` (`:61`, `:110-117`). Two of those choices carry real weight. The fan-out is sequential on purpose, because sections share the scoped unit of work and its `DbContext`, which is not thread-safe, and because registration order is the published order of the document (`:102-103`). And each section runs inside its own try/catch (`:173-198`) that degrades a failing section to `Available = false` with a deliberately generic reason, sending the exception detail to the log and never to the subject (`:187-197`); `OperationCanceledException` is explicitly excluded, because a cancelled request is not a degraded one. +- **Concept introduced, the template-method handler where the app supplies only its vocabulary.** `[Rubric §30, Compliance, Privacy & Data Governance]` (assesses whether access and portability are implemented once and identically everywhere), `[Rubric §2, Design Patterns]` (template method), and `[Rubric §1, SOLID]` (the base is closed for modification and open through three protected hooks). The base (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/ExportUserDataHandlerBase.cs:49`) owns the whole workflow: the owner-or-privileged-role check via `UserOwnershipRule.CheckOwnership`, returning a `User.ExportForbidden` error (`ExportUserDataHandlerBase.cs:81-90`), a no-tracking load through `GetReadRepository` with `Error.NotFound` when the account is gone (`:92-98`), the subject snapshot, the section fan-out (`:104-108`), and the envelope stamped with `CurrentFormatVersion` "1.0" and a `GeneratedOn` from `TimeProvider` (`:61`, `:110-117`). Two of those choices carry real weight. The fan-out is sequential on purpose, because sections share the scoped unit of work and its `DbContext`, which is not thread-safe, and because registration order is the published order of the document (`:102-103`). And each section runs inside its own try/catch (`:173-198`) that degrades a failing section to `Available = false` with a deliberately generic reason, sending the exception detail to the log and never to the subject (`:185-197`); `OperationCanceledException` is explicitly excluded, because a cancelled request is not a degraded one. - **Walkthrough**: a primary constructor forwarding four dependencies to the base (`ExportUserDataHandler.cs:30-35`) and two overrides. - `HasExportPrivilege(string? currentUserRole) => UserRole.IsOrganizer(currentUserRole)` (`ExportUserDataHandler.cs:38`). One line, and the case-insensitive comparison lives inside [`UserRole.IsOrganizer`](#userrole) rather than here, so a claim with unexpected casing cannot silently deny an organizer. - - `BuildSubjectSnapshotAsync` (`ExportUserDataHandler.cs:41-77`) guards its argument (`:46`) and projects the aggregate into a [`UserDataExportSubjectDTO`](#userdataexportsubjectdto): identity and profile fields, `IsExternalLogin` and `LoginProvider`, `LinkedSpeakerId`, `AvatarUrl`, the seven MAUI device fields, and the two audit timestamps (`:48-73`). What is **absent** is the point: no `PasswordHash`, no `PasswordSalt`, no refresh token, no external `ProviderKey`. The class doc states the principle (`ExportUserDataHandler.cs:16-19`) and the base repeats it (`ExportUserDataHandlerBase.cs:132-135`): credentials are secrets, not portable personal data, and a portability right is not a right to a copy of your own password hash. + - `BuildSubjectSnapshotAsync` (`ExportUserDataHandler.cs:41-77`) guards its argument (`:46`) and projects the aggregate into a [`UserDataExportSubjectDTO`](#userdataexportsubjectdto): identity and profile fields, `IsExternalLogin` and `LoginProvider`, `LinkedSpeakerId`, `AvatarUrl`, the seven MAUI device fields, and the two audit timestamps (`:48-73`). What is **absent** is the point: no `PasswordHash`, no `PasswordSalt`, no refresh token, no external `ProviderKey`. The class doc states the principle (`ExportUserDataHandler.cs:16-19`) and the base repeats it (`ExportUserDataHandlerBase.cs:133-135`): credentials are secrets, not portable personal data, and a portability right is not a right to a copy of your own password hash. - The timestamp handling is the subtle bit (`ExportUserDataHandler.cs:67-73`). SQL Server hands audit timestamps back as `Kind=Unspecified`, and the DTO documents them as UTC but serializes them without a `Z` marker in that state, so `DateTime.SpecifyKind(..., DateTimeKind.Utc)` only restores the marker on a value that was already UTC. `LastModifiedOn` is null-checked first, since a never-updated account has none. - The method returns `Task.FromResult(subject)` (`:76`): the hook is asynchronous because some apps read a second aggregate for the snapshot, and ADC does not need to. - **Why it's built this way**: the handler needs no DI registration of its own. `ScanModuleApplicationServices()` finds it through the base class's `IQueryHandler>` implementation, which the Identity DI comment records explicitly (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:40-41`) and a dedicated test pins (`MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/ExportUserDataRegistrationTests.cs:19`, with a second test at `:34` asserting that section registration order is preserved). Best-effort section aggregation is the same posture the module takes for the best-effort live-channel publish (`ExportUserDataHandler.cs:23-28`): one peer outage costs the subject one section, never the whole document (`[Rubric §29, Resilience & Business Continuity]`). -- **Where it's used**: injected into [`UsersController`](#userscontroller) as `IQueryHandler>` (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:34`) and invoked from `GET users/{userId}/export`, which maps the handler's failures to 403 or 404 (`UsersController.cs:149-168`). Covered by `MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/ExportUserDataHandlerTests.cs`. +- **Where it's used**: injected into [`UsersController`](#userscontroller) as `IQueryHandler>` (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:35`) and invoked from `GET users/{userId}/export`, which maps the handler's failures to 403 or 404 (`UsersController.cs:155-176`). Covered by `MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/ExportUserDataHandlerTests.cs`. - **Caveats / not-in-source**: ADC does not override `OnExportCompletedAsync`, the base's post-assembly hook for an access-log row or a metric (`ExportUserDataHandlerBase.cs:159-164`), so an export leaves no application-level audit record beyond ordinary request logging. +### ForgotPasswordHandler +> MMCA.ADC.Identity.Application · `MMCA.ADC.Identity.Application.Users.UseCases.ForgotPassword` · `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandler.cs:20` · Level 14 · class (sealed) + +- **What it is**: ADC's start-a-password-reset handler. Like the export handler it is a thin subclass of a shared workflow, and it supplies exactly one app-specific step: how to find an ADC account from an email address without tracking it. +- **Depends on**: [`ForgotPasswordHandlerBase`](group-14-module-system-composition.md#forgotpasswordhandlerbasetuser-tcommand), [`User`](#user), [`ForgotPasswordCommand`](#forgotpasswordcommand), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`IPasswordResetTokenService`](group-08-auth.md#ipasswordresettokenservice), [`IEmailSender`](group-10-notifications.md#iemailsender), [`PasswordResetSettings`](group-08-auth.md#passwordresetsettings), [`Email`](group-02-domain-building-blocks.md#email); externals: `IOptions`, `ILogger`. +- **Concept introduced, the success-always workflow.** `[Rubric §11, Security]` (assesses whether an anonymous endpoint's responses and logs leak which accounts exist) and `[Rubric §2, Design Patterns]` (template method again, with a single abstract member). The base is worth reading end to end (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:51-100`), because every exit from it is `Result.Success()`: a malformed address (`:57-62`), an address with no account (`:65-70`), a request the token service throttled (`:72-77`), and an email send that threw (`:81-96`) all log a reason and report success, exactly like the happy path (`:98-99`). The remarks state the rule outright (`ForgotPasswordHandlerBase.cs:20-25`): only the request validator can produce a 400, and it only inspects the shape of the address, so nothing about the response distinguishes a registered address from an unregistered one. + - The logging is part of that contract rather than an afterthought. `UserUseCaseLog.PasswordResetRejected` takes a plain reason string and neither an address nor an account id (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UserUseCaseLog.cs:36-37`), and the comment above it says why (`UserUseCaseLog.cs:34-35`): the log must not become the enumeration oracle the responses are not. The two log lines that *do* carry a user id (`:25-26`, `:28-29`) are only reachable once an account has already been resolved. + - The send-failure branch is a small resilience decision worth copying (`ForgotPasswordHandlerBase.cs:90-96`): the token has already been issued and is still valid, so the user can simply retry, and reporting the SMTP failure to the caller would be an oracle of a different kind (`[Rubric §29, Resilience & Business Continuity]`). +- **Walkthrough**: a primary-constructor class over five dependencies, every one of them forwarded straight to the base (`ForgotPasswordHandler.cs:20-26`), plus a single override. + - `FindUntrackedByEmailAsync(Email email, CancellationToken)` (`ForgotPasswordHandler.cs:29-37`) is the whole ADC contribution. It takes a read repository off the unit of work, `UnitOfWork.GetReadRepository()` (`:31`), and calls `GetAllAsync` with no includes, `where: u => u.Email == email`, and `asTracking: false` (`:32-35`), then returns `users.FirstOrDefault()` (`:36`). + - Three details in those five lines. The predicate compares the [`Email`](group-02-domain-building-blocks.md#email) value object rather than a raw string, so normalization is the value object's job and not a lowercase call here. `asTracking: false` is correct because this handler never mutates the account: it only needs the id to mint a token against, and the redeem side is a separate use case ([`ResetPasswordHandler`](#resetpasswordhandler)). And the unit of work is reached through the base's protected `UnitOfWork` property, which exists precisely so the lookup override has a repository to reach (`ForgotPasswordHandlerBase.cs:44-45`). + - The base declares this one member abstract for a stated reason (`ForgotPasswordHandlerBase.cs:26-31`): each app's `User` stores the address differently, so resolving an account by email is the only step the framework cannot write. Everything else, including the email body, is shared. +- **Why it's built this way**: the reset credential is a cache record rather than three new columns on the hottest table in the system ([ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html)), so this handler needs no migration and no sweeper: `IPasswordResetTokenService.IssueAsync` owns the token, its lifetime, and the per-email throttle (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IPasswordResetTokenService.cs:11-23`). The knobs live in [`PasswordResetSettings`](group-08-auth.md#passwordresetsettings), bound from the `PasswordReset` section with `ValidateDataAnnotations().ValidateOnStart()` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:139-143`), which is what turns a bad `TokenLifetimeMinutes` into a startup failure rather than a runtime surprise. One default is deliberately permissive: `ResetUrl` is not required, and an unconfigured host degrades to a token-only email the user pastes into the reset page by hand rather than shipping a broken link (`PasswordResetSettings.cs:15-25`, `ForgotPasswordHandlerBase.cs:144-147`). The default body carries both the link and the raw code for the same reason, because the MAUI head has no deep linking (`ForgotPasswordHandlerBase.cs:115-134`). +- **Where it's used**: registered as `ICommandHandler` by the module scan, which finds it through the base class's interface exactly as it finds the export handler (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:47`, `MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:178-182`). It is injected into [`PasswordResetController`](#passwordresetcontroller) (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:29`), whose inherited `POST Auth/forgot-password` action is `[AllowAnonymous]`, `[Idempotent]`, rate-limited by the auth-ip policy, and answers `202 Accepted` on every success (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:75-93`). Covered by `MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/ForgotPasswordHandlerTests.cs`, whose three cases pin the shipped behavior: a registered address issues a token and sends (`:69`), an unknown address still succeeds without issuing one (`:88`), and the lookup runs untracked (`:102`). +- **Caveats / not-in-source**: what actually delivers the mail is not visible here. `IEmailSender` is resolved from the host, so whether a reset email leaves the process depends on the Identity service's SMTP configuration, which this type neither reads nor validates. + ### UserClaimsController > MMCA.ADC.Identity.API · `MMCA.ADC.Identity.API.Controllers` · `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UserClaimsController.cs:16` · Level 4 · class (sealed) @@ -1541,7 +1645,7 @@ OAuth login), [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-sto - `DeleteWithConfirmationAsync(...)` (`ListPageActions.cs:51-86`) takes the page's `DeleteConfirmation` ref, the entity display name, the delete call, the snackbar, a localized success message, a failure-to-message mapper, and the reload callback. It guards every reference argument with `ArgumentNullException.ThrowIfNull` (`ListPageActions.cs:60-64`), shows the dialog, and returns immediately unless the result is exactly `true` (`ListPageActions.cs:66-70`): a dialog dismissed with `null` is a cancel, not a confirm. On confirm it awaits the delete, toasts success, and reloads (`ListPageActions.cs:74-76`). - The two-catch tail is the subtle part (`ListPageActions.cs:78-85`). `OperationCanceledException` is swallowed with a comment naming the two causes: component disposal, and the InteractiveAuto render-mode transition where a Server-rendered circuit is torn down as WebAssembly takes over. Any other exception is mapped through the caller's `errorMessage` delegate and toasted at `Severity.Error` (`ListPageActions.cs:84`), so a failed delete is visible rather than silent. - **Why it's built this way**: passing the localized strings and the error mapper in as parameters keeps this class free of any resource dependency, so each page supplies its own translated text ([ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html)) while the flow itself stays identical everywhere. -- **Where it's used**: twelve pages in current source. Identity's [`UserList`](#userlist) uses both methods (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/User/UserList.razor.cs:39`, `:76-83`); the Conference UI calls them from `EventList`, `SessionList`, `SpeakerList`, `RoomList`, `QuestionList`, `ConferenceCategoryList`, `SponsorList`, `PublicEventList`, `PublicSessionListView`, and `PublicSpeakerList`; and the Engagement UI calls them from `AttendeeSearchPanel`. That last caller is the proof the placement argument holds: Engagement.UI reaches this type transitively, without a direct reference to Identity.UI's pages. +- **Where it's used**: twelve pages in current source. Identity's [`UserList`](#userlist) uses both methods (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/User/UserList.razor.cs:38-39`, `:75-83`); the Conference UI calls them from `EventList`, `SessionList`, `SpeakerList`, `RoomList`, `QuestionList`, `ConferenceCategoryList`, `SponsorList`, `ActivityList`, `PublicEventList`, and `PublicSessionListView`; and the Engagement UI calls them from `AttendeeSearchPanel`. That last caller is the proof the placement argument holds: Engagement.UI reaches this type transitively, without a direct reference to Identity.UI's pages. ### Profile > MMCA.ADC.Identity.UI · `MMCA.ADC.Identity.UI.Pages.Profile` · `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Profile/Profile.razor.cs:15` · Level 6 · class (partial) @@ -1596,7 +1700,7 @@ OAuth login), [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-sto - **What it is**: the Identity aggregate root. One row per account, holding credentials, role, refresh-token lifecycle, profile fields, optional MAUI device metadata, external-login identifiers, UI preferences, the avatar URL, and the scalar link to a Conference `Speaker`. - **Depends on**: [`AuditableAggregateRootEntity`](group-02-domain-building-blocks.md#auditableaggregaterootentitytidentifiertype), [`IPasswordChangeableUser`](group-08-auth.md#ipasswordchangeableuser), [`IUserPreferences`](group-08-auth.md#iuserpreferences), [`IErasableUser`](group-08-auth.md#ierasableuser) (which extends [`IAnonymizable`](group-02-domain-building-blocks.md#ianonymizable)), [`IAuditedEntity`](group-02-domain-building-blocks.md#iauditedentity), [`Email`](group-02-domain-building-blocks.md#email), [`UserRole`](#userrole), [`UserInvariants`](#userinvariants), [`UserPasswordChanged`](#userpasswordchanged), [`UserDeleted`](#userdeleted), [`PiiAttribute`](group-02-domain-building-blocks.md#piiattribute), [`IdValueGeneratedAttribute`](group-02-domain-building-blocks.md#idvaluegeneratedattribute), [`Result`](group-01-result-error-handling.md#result); externals: BCL only. -- **Concept introduced, the interface list as a workflow contract.** `[Rubric §4, DDD]` assesses whether the aggregate is the consistency boundary and enforces its own invariants, and `[Rubric §1, SOLID]` assesses interface segregation: four narrow capability interfaces instead of one fat base (`User.cs:33-34`). The class remarks (`User.cs:17-31`) explain something genuinely easy to get wrong. The shared framework workflows are generic over capability interfaces: [`ChangePasswordHandlerBase`](group-14-module-system-composition.md#changepasswordhandlerbasetuser-tcommand) constrains on `IPasswordChangeableUser`, and the erasure workflow constrains on `IErasableUser`. Listing `IErasableUser` **on this type directly** is load-bearing because `Delete` here *hides* the base soft-delete with `new` (`User.cs:364`); only re-declaring the interface on `User` re-maps the interface slot onto this type's own member, which is what keeps the refresh-token revocation inside the shared erasure path. Remove the interface from the declaration list and the code still compiles while quietly calling the base method instead. +- **Concept introduced, the interface list as a workflow contract.** `[Rubric §4, DDD]` assesses whether the aggregate is the consistency boundary and enforces its own invariants, and `[Rubric §1, SOLID]` assesses interface segregation: four narrow capability interfaces instead of one fat base (`User.cs:33-34`). The class remarks (`User.cs:17-31`) explain something genuinely easy to get wrong. The shared framework workflows are generic over capability interfaces: [`ChangePasswordHandlerBase`](group-14-module-system-composition.md#changepasswordhandlerbasetuser-tcommand) constrains on `IPasswordChangeableUser` (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:28`), and the erasure workflow constrains on `IErasableUser`. Listing `IErasableUser` **on this type directly** is load-bearing because `Delete` here *hides* the base soft-delete with `new` (`User.cs:364`); only re-declaring the interface on `User` re-maps the interface slot onto this type's own member, which is what keeps the refresh-token revocation inside the shared erasure path. Remove the interface from the declaration list and the code still compiles while quietly calling the base method instead. - `IAuditedEntity` (`User.cs:34`) is the one non-behavioural marker in the list, and the remarks justify it (`User.cs:25-30`): it opts the aggregate into a change history rather than just the last-writer audit fields. `[Rubric §30, Compliance, Privacy & Data Governance]`: account records are where a support or compliance question actually gets asked (who changed this attendee's email, when was this account raised to Organizer, who anonymized it), and `LastModifiedOn/By` alone answers only "who touched it last". - `[IdValueGenerated]` (`User.cs:32`) tells the persistence layer the id is database-generated. That single attribute is the root cause of the [`UserRegistered`](#userregistered) two-save dance: the id does not exist until after the insert, which the `Create` doc states outright (`User.cs:149-155`). - **Walkthrough** @@ -1611,7 +1715,7 @@ OAuth login), [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-sto - **`Delete`** (`User.cs:364-374`): the `new` hiding method. It revokes the refresh token *first* (`:366`), so an account being deleted cannot keep minting access tokens from an outstanding refresh token, then calls `base.Delete()` and raises [`UserDeleted`](#userdeleted) only when that succeeded (`:368-371`). - **`Anonymize`** (`User.cs:387-422`): the erasure half. It builds the placeholder address `deleted-{Id}@anonymized.invalid` with `CultureInfo.InvariantCulture` (`:392`), and the id embedded in the address is what keeps the unique-email invariant (BR-200) satisfiable across many erased accounts. It is idempotent by construction: if the current email already equals the placeholder there is nothing left to erase and it returns success (`:399-402`). Otherwise it overwrites the email, names, credentials, all seven device fields, both external-login fields, and the avatar URL, and revokes the refresh token (`:404-419`). - **Why it's built this way**: `Delete` and `Anonymize` are separate operations, and the doc on `Anonymize` (`User.cs:376-386`) says exactly why: the row survives so cross-context scalar references (bookmarks, notifications) and the audit trail do not break, which is the anonymize-in-place policy of [ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html). It is also why a re-registration with an erased account's original email succeeds by design, a nuance [`AuthenticationService`](#authenticationservice)'s `EmailExistsAsync` documents. The avatar comment (`User.cs:105-109`) draws the matching line for storage: the domain nulls the URL, and the use case, which knows the blob boundary, deletes the file ([ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-storage-and-avatars.html)). -- **Where it's used**: persisted by [`UserConfiguration`](#userconfiguration), projected by [`UserDTOMapper`](#userdtomapper), driven by [`AuthenticationService`](#authenticationservice) and the Users use cases ([`ChangePasswordHandler`](#changepasswordhandler), [`ChangePreferencesHandler`](#changepreferenceshandler), [`DeleteUserHandler`](#deleteuserhandler), [`ExportUserDataHandler`](#exportuserdatahandler), [`SetUserAvatarHandler`](#setuseravatarhandler), [`RemoveUserAvatarHandler`](#removeuseravatarhandler)), mutated on the cross-context link by [`SpeakerLinkedToUserHandler`](#speakerlinkedtouserhandler) and [`SpeakerUnlinkedFromUserHandler`](#speakerunlinkedfromuserhandler), and read by [`GetUsersHandler`](#getusershandler). +- **Where it's used**: persisted by [`UserConfiguration`](#userconfiguration), projected by [`UserDTOMapper`](#userdtomapper), driven by [`AuthenticationService`](#authenticationservice) and the Users use cases ([`ChangePasswordHandler`](#changepasswordhandler), [`ChangePreferencesHandler`](#changepreferenceshandler), [`DeleteUserHandler`](#deleteuserhandler), [`ExportUserDataHandler`](#exportuserdatahandler), [`SetUserAvatarHandler`](#setuseravatarhandler), [`RemoveUserAvatarHandler`](#removeuseravatarhandler), and the reset-password path behind [`PasswordResetController`](#passwordresetcontroller)), mutated on the cross-context link by [`SpeakerLinkedToUserHandler`](#speakerlinkedtouserhandler) and [`SpeakerUnlinkedFromUserHandler`](#speakerunlinkedfromuserhandler), and read by [`GetUsersHandler`](#getusershandler). - **Caveats / not-in-source**: `Anonymize` leaves `Role`, `PreferredCulture`, `PreferredTheme`, and `LinkedSpeakerId` untouched (`User.cs:404-419`), so an erased account keeps its role, its UI preferences, and any speaker link. Those are treated as non-identifying here; nothing in this file states that judgement, so it is a behavior to notice rather than a documented decision. ### UserList @@ -1641,7 +1745,7 @@ OAuth login), [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-sto - **Depends on**: [`ChangePasswordRequest`](group-08-auth.md#changepasswordrequest); the `UserIdentifierType` alias (`= int`); [`User`](#user) (only for `typeof(User).FullName`); and three framework markers, [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest), [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating), and [`IUserScopedCommand`](group-14-module-system-composition.md#iuserscopedcommandout-trequest). - **Concept introduced, three markers that each buy exactly one pipeline behavior.** `[Rubric §6, CQRS & Event-Driven]` (a command is a named intention carrying exactly what the write needs) and `[Rubric §2, Design Patterns]`. The record declares no members beyond `CachePrefix`; everything else it does is expressed by which interfaces it lists (`ChangePasswordCommand.cs:15`). - `ICommandWithRequest` opts the command into **automatic validation**: the framework registers a `CommandRequestValidator` that delegates to the registered `IValidator` through FluentValidation's `SetValidator`, with `TryAdd` semantics so an explicit command-level validator still wins (`MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/ICommandWithRequest.cs:5-11`). - - `ICacheInvalidating` gives the caching decorator a prefix to evict, `$"{typeof(User).FullName}:"` (`ChangePasswordCommand.cs:18`). Deriving it from the type rather than from a string literal keeps it in lockstep with the key the user cache actually uses: rename or move [`User`](#user) and the prefix follows. + - `ICacheInvalidating` gives the caching decorator a prefix to evict, `$"{typeof(User).FullName}:"` (`ChangePasswordCommand.cs:18`). Deriving it from the type rather than from a string literal keeps it in lockstep with the key the user cache actually uses: rename or move [`User`](#user) and the prefix follows. [`ResetPasswordCommand`](#resetpasswordcommand) carries the identical prefix for the same reason, and its doc says so explicitly (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordCommand.cs:9-11`, `:18`). - `IUserScopedCommand` is the view the shared handler base reads the command through, and it changes no pipeline behavior on its own. Its doc comment records why the two are separate rather than merged: the automatic-validation opt-in is a per-application decision, and ADC and Store agree on it for password change but disagree for preferences (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/IUserScopedCommand.cs:6-11`). - **Walkthrough**: a two-parameter positional record `(UserIdentifierType UserId, ChangePasswordRequest Request)` (`ChangePasswordCommand.cs:14`) plus the single computed `CachePrefix` property (`:17-18`). `UserId` comes from the authenticated principal, `Request` from the body. - **Why it's built this way**: splitting "who" (the token) from "what" (the body) makes it structurally impossible for a request to target another account, and the shared handler base stays generic in `TCommand` precisely so each application can keep its own marker set on the record while sharing one workflow. @@ -1694,48 +1798,64 @@ OAuth login), [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-sto - **What it is**: ADC's change-password handler. The class body is empty: the whole workflow lives in the framework base [`ChangePasswordHandlerBase`](group-14-module-system-composition.md#changepasswordhandlerbasetuser-tcommand), and this type exists to bind the generic parameters and to keep the name. - **Depends on**: [`ChangePasswordHandlerBase`](group-14-module-system-composition.md#changepasswordhandlerbasetuser-tcommand) (base), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`IPasswordHasher`](group-08-auth.md#ipasswordhasher), `ILogger`, [`User`](#user), and [`ChangePasswordCommand`](#changepasswordcommand). -- **Concept introduced, the name-preserving thin subclass.** `[Rubric §16, Maintainability]` assesses de-duplication across applications, and `[Rubric §9, API & Contract Design]` covers why the class name survives the move. ADC and Store carried line-identical copies of this handler, so the workflow was hoisted into `MMCA.Common` (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:11-15`). What could not be hoisted is the **error payload**: every failure the framework returns carries a `source`, the base defaults it to `GetType().Name` through a virtual `HandlerName` (`ChangePasswordHandlerBase.cs:34-39`), and clients match on the string `ChangePasswordHandler`. Keeping an empty subclass under the original name makes the hoist invisible on the wire, and the `` says so outright (`ChangePasswordHandler.cs:12-16`). It is a small pattern with a large consequence: a refactor that would otherwise be a breaking API change becomes a no-op for consumers. The same shape recurs at [`ChangePreferencesHandler`](#changepreferenceshandler) and [`GetUserPreferencesHandler`](#getuserpreferenceshandler). +- **Concept introduced, the name-preserving thin subclass.** `[Rubric §16, Maintainability]` assesses de-duplication across applications, and `[Rubric §9, API & Contract Design]` covers why the class name survives the move. ADC and Store carried line-identical copies of this handler, so the workflow was hoisted into `MMCA.Common` (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:11-15`). What could not be hoisted is the **error payload**: every failure the framework returns carries a `source`, the base defaults it to `GetType().Name` through a virtual `HandlerName` (`ChangePasswordHandlerBase.cs:34-39`), and clients match on the string `ChangePasswordHandler`. Keeping an empty subclass under the original name makes the hoist invisible on the wire, and the `` says so outright (`ChangePasswordHandler.cs:12-16`). It is a small pattern with a large consequence: a refactor that would otherwise be a breaking API change becomes a no-op for consumers. The same shape recurs at [`ChangePreferencesHandler`](#changepreferenceshandler), [`GetUserPreferencesHandler`](#getuserpreferenceshandler), and (for the controller layer) at [`PasswordResetController`](#passwordresetcontroller). - **Walkthrough**: a primary constructor taking [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`IPasswordHasher`](group-08-auth.md#ipasswordhasher), and `ILogger` and forwarding all three to the base (`ChangePasswordHandler.cs:17-21`), with an empty body (`:22-23`). The inherited workflow (`ChangePasswordHandlerBase.cs:42-70`) is: null-guard the command (`:46`); load the user through the mutating repository and return `Error.NotFound` tagged with `HandlerName` when absent (`:48-53`); verify the supplied current password with `passwordHasher.VerifyPassword(command.Request.CurrentPassword, user.PasswordHash, user.PasswordSalt)` and return `Error.Unauthorized("Auth.InvalidCurrentPassword", ...)` on a mismatch (`:55-59`); hash the new password into a fresh hash and salt pair (`:61`); call `user.ChangePassword(newHash, newSalt)` and, only when that succeeds, save and log (`:62-67`); return the aggregate's [`Result`](group-01-result-error-handling.md#result) unchanged (`:69`). - **Why it's built this way**: `[Rubric §11, Security]`. Note the division of responsibility the base encodes. Proving knowledge of the current password is a cryptographic operation, so it happens where the stored hash and salt are in hand, not in a request validator (a validator can only check that a value is present). The domain then applies its own invariants on the new credential material, and nothing is persisted unless both gates pass. The generic constraint `where TUser : AuditableAggregateRootEntity, IPasswordChangeableUser` (`ChangePasswordHandlerBase.cs:28`) is what lets the base call `ChangePassword` on an application's aggregate without knowing the concrete type, and the hashing scheme itself is [ADR-032](https://ivanball.github.io/docs/adr/032-password-hashing.html). - **Where it's used**: resolved as `ICommandHandler` and injected into [`AuthController`](#authcontroller) (`AuthController.cs:32`), which exposes it as `PUT /auth/password`; the [`Profile`](#profile) page is the client. ### UsersController -> MMCA.ADC.Identity.API · `MMCA.ADC.Identity.API.Controllers` · `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:31` · Level 9 · class (sealed) +> MMCA.ADC.Identity.API · `MMCA.ADC.Identity.API.Controllers` · `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:32` · Level 9 · class (sealed) - **What it is**: the `/users` REST surface: the organizer user list, account delete, the GDPR data export, and the three avatar endpoints for the signed-in user. Six actions, each a thin adapter over one handler. -- **Depends on**: [`ApiControllerBase`](group-12-api-hosting-mapping.md#apicontrollerbase), [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) and [`IQueryHandler`](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) closed over six use cases (`UsersController.cs:32-37`), [`ICurrentUserService`](group-08-auth.md#icurrentuserservice), [`HasPermissionAttribute`](group-08-auth.md#haspermissionattribute), [`IdentityPermissions`](#identitypermissions), [`UserListDTO`](#userlistdto), [`UserAvatarDTO`](#useravatardto), [`UserDataExportDTO`](group-08-auth.md#userdataexportdto), [`PagedCollectionResult`](group-01-result-error-handling.md#pagedcollectionresultt); externals: ASP.NET Core MVC (`IFormFile`, `[RequestSizeLimit]`, `[Range]`). +- **Depends on**: [`ApiControllerBase`](group-12-api-hosting-mapping.md#apicontrollerbase), [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) and [`IQueryHandler`](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) closed over six use cases (`UsersController.cs:33-38`), [`ICurrentUserService`](group-08-auth.md#icurrentuserservice) (`:39`), [`HasPermissionAttribute`](group-08-auth.md#haspermissionattribute), [`IdentityPermissions`](#identitypermissions), [`IdempotentAttribute`](group-12-api-hosting-mapping.md#idempotentattribute), [`UserListDTO`](#userlistdto), [`UserAvatarDTO`](#useravatardto), [`UserDataExportDTO`](group-08-auth.md#userdataexportdto), [`PagedCollectionResult`](group-01-result-error-handling.md#pagedcollectionresultt); externals: ASP.NET Core MVC (`IFormFile`, `[RequestSizeLimit]`, `[Range]`). - **Concept introduced, the controller as a translator between HTTP and the handler pipeline, and the two shapes of authorization.** `[Rubric §9, API & Contract Design]`, `[Rubric §11, Security]`, `[Rubric §5, Vertical Slice]` (assesses whether each endpoint routes to its own use case rather than into a shared service). Every action follows the same four lines: read the caller from `ICurrentUserService`, build the command or query record, `await handler.HandleAsync(...)`, then `result.IsFailure ? HandleFailure(result.Errors) : Ok(...)`. The controller holds no business logic, which is why the decorator pipeline (logging, caching, validation, transaction) applies uniformly. The authorization split is the interesting part: - - **Declarative**, for a role-shaped rule: the list endpoint carries `[HasPermission(IdentityPermissions.UsersRead)]` (`UsersController.cs:125`), the permission-based check of [ADR-020](https://ivanball.github.io/docs/adr/020-permission-based-authorization.html). A caller without that capability never reaches the handler. - - **In-handler**, for an ownership-shaped rule: export and delete pass the caller's id and role *into* the query or command (`UsersController.cs:162`, `:184`) so the handler can apply owner-or-Organizer and, importantly, return 404 rather than 403 for a stranger's id, which avoids leaking whether that account exists ([ADR-033](https://ivanball.github.io/docs/adr/033-resource-ownership-authorization.html)). -- **Walkthrough**: class-level `[Authorize]` (`UsersController.cs:30`) makes every action authenticated by default, and each action re-checks `currentUserService.UserId is null` and returns `Unauthorized()` for the "authenticated but no usable id" case. - - `MaxAvatarBytes = 2 * 1024 * 1024` (`UsersController.cs:41`), the BR-116a ceiling. - - `GET me/avatar` (`UsersController.cs:44-59`), resolving the subject from the token rather than a route parameter. - - `POST me/avatar` (`UsersController.cs:66-102`) is the richest action. `[RequestSizeLimit(MaxAvatarBytes)]` (`:67`) rejects an oversized body at the pipeline before any of it is buffered; the inline guard then re-checks null, zero-length, and over-limit and returns a validation error (`:78-84`), so the limit is enforced twice for two different failure modes. The stream is copied into a right-sized `MemoryStream` (`:86-93`), with both `await using` scopes carrying `ConfigureAwait(false)`, and the byte array is handed to [`SetUserAvatarCommand`](#setuseravatarcommand) (`:95-97`). The action doc records what the handler then does (`:61-65`): sniff the real format, re-encode to 256x256 JPEG, return the public URL. Trusting a declared content type here would be an upload vulnerability. - - `DELETE me/avatar` (`UsersController.cs:105-120`), documented idempotent, returning 204. - - `GET users` (`UsersController.cs:124-145`): eight `[FromQuery]` parameters with `[Range(1, int.MaxValue)]` on both paging values (`:132-133`), so a `pageNumber=0` is a model-binding 400 rather than a handler concern. - - `GET {userId}/export` (`UsersController.cs:149-168`) and `DELETE {userId}` (`UsersController.cs:171-190`), the two ownership-checked actions, both declaring 403 and 404 in their `ProducesResponseType` set (`:151-152`, `:173-174`). + - **Declarative**, for a role-shaped rule: the list endpoint carries `[HasPermission(IdentityPermissions.UsersRead)]` (`UsersController.cs:133`, the constant is `"identity:users:read"` at `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Authorization/IdentityPermissions.cs:11`), the permission-based check of [ADR-020](https://ivanball.github.io/docs/adr/020-permission-based-authorization.html). A caller without that capability never reaches the handler. + - **In-handler**, for an ownership-shaped rule: export and delete pass the caller's id and role *into* the query or command (`UsersController.cs:170`, `:192`) so the handler can apply owner-or-Organizer and, importantly, return 404 rather than 403 for a stranger's id, which avoids leaking whether that account exists ([ADR-033](https://ivanball.github.io/docs/adr/033-resource-ownership-authorization.html)). +- **Walkthrough**: class-level `[Authorize]` (`UsersController.cs:31`) makes every action authenticated by default, and each action re-checks `currentUserService.UserId is null` and returns `Unauthorized()` for the "authenticated but no usable id" case. + - `MaxAvatarBytes = 2 * 1024 * 1024` (`UsersController.cs:42`), the BR-116a ceiling. + - `GET me/avatar` (`UsersController.cs:45-60`), resolving the subject from the token rather than a route parameter. + - `POST me/avatar` (`UsersController.cs:73-110`) is the richest action, and it carries three attributes worth reading together. `[Idempotent]` (`:74`) routes the request through the framework's `Idempotency-Key` filter ([ADR-017](https://ivanball.github.io/docs/adr/017-request-idempotency.html)); the action doc explains the judgement call (`:66-71`), which is that the upload *replaces* the caller's avatar rather than appending one, so replaying the stored URL for a repeated key is both safe and useful (it skips a second re-encode of identical bytes and stops a flaky mobile upload from looking like a failure the user has to redo). `[RequestSizeLimit(MaxAvatarBytes)]` (`:75`) rejects an oversized body at the pipeline before any of it is buffered; the inline guard then re-checks null, zero-length, and over-limit and returns a validation error (`:86-92`), so the limit is enforced twice for two different failure modes. The stream is copied into a right-sized `MemoryStream` (`:94-101`), with both `await using` scopes carrying `ConfigureAwait(false)`, and the byte array is handed to [`SetUserAvatarCommand`](#setuseravatarcommand) (`:103-105`). The action doc also records what the handler then does (`:62-65`): sniff the real format (jpeg/png/webp), re-encode to 256x256 JPEG, return the public URL. Trusting a declared content type here would be an upload vulnerability. + - `DELETE me/avatar` (`UsersController.cs:112-128`), documented idempotent, returning 204. + - `GET users` (`UsersController.cs:130-153`): eight `[FromQuery]` parameters with `[Range(1, int.MaxValue)]` on both paging values (`:140-141`), so a `pageNumber=0` is a model-binding 400 rather than a handler concern. + - `GET {userId}/export` (`UsersController.cs:155-176`) and `DELETE {userId}` (`UsersController.cs:178-198`), the two ownership-checked actions, both declaring 403 and 404 in their `ProducesResponseType` set (`:159-160`, `:181-182`). - **Why it's built this way**: injecting six separate closed handler interfaces rather than one "user service" is what keeps each endpoint on its own vertical slice and makes the decorator pipeline the single place where cross-cutting behavior lives ([ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html)). The consistent `HandleFailure(result.Errors)` tail means every failure becomes a `ProblemDetails` with the same shape, mapped once in [`ApiControllerBase`](group-12-api-hosting-mapping.md#apicontrollerbase). -- **Where it's used**: mounted by the Identity service host and routed through the Gateway; consumed by [`UserService`](#userservice) on the client side, and warmed at startup by [`SelfHttpWarmupTask`](#selfhttpwarmuptask), whose expected 401 comes from this class's `[Authorize]` plus `[HasPermission]` pair. +- **Where it's used**: mounted by the Identity service host and routed through the Gateway; consumed by [`UserService`](#userservice) on the client side, and warmed at startup by [`SelfHttpWarmupTask`](#selfhttpwarmuptask), which replays `users?pageNumber=1&pageSize=10` against this host's own Kestrel endpoint (`MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/SelfHttpWarmupTask.cs:35`) and treats the resulting 401 as the expected outcome (`:45`), because an unauthenticated self-request hits this class's `[Authorize]` plus `[HasPermission]` pair. + +### PasswordResetController +> MMCA.ADC.Identity.API · `MMCA.ADC.Identity.API.Controllers` · `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:28` · Level 16 · class (sealed) + +- **What it is**: the anonymous password-recovery surface, `POST /Auth/forgot-password` and `POST /Auth/reset-password`. Like [`OAuthController`](#oauthcontroller) it declares no actions of its own: the two endpoints are inherited, and this class supplies the route, the version, and two one-line command factories. +- **Depends on**: `PasswordResetAuthControllerBase` from `MMCA.Common.API` (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:43`), [`ICommandHandler`](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) closed over [`ForgotPasswordCommand`](#forgotpasswordcommand) and [`ResetPasswordCommand`](#resetpasswordcommand), [`Result`](group-01-result-error-handling.md#result), and the shared `ForgotPasswordRequest` / `ResetPasswordRequest` contracts from `MMCA.Common.Shared.Auth`; externals: Asp.Versioning, ASP.NET Core MVC. +- **Concept introduced (1), the sibling controller as a way around single inheritance.** `[Rubric §9, API & Contract Design]` assesses whether the URL surface stays coherent as capabilities are added, and `[Rubric §16, Maintainability]` covers the cost of getting there. [`AuthController`](#authcontroller) already spends its one base class on the shared account controller, so recovery could not be more actions on that type. The resolution is a second controller with `[Route("Auth")]` written as a **literal** rather than `[controller]` (`PasswordResetController.cs:26`), which lands both endpoints on the same `/Auth` prefix the Gateway already fronts. The `` state that motivation directly (`PasswordResetController.cs:19-24`): a client and a gateway see one coherent `/Auth` resource, and no route table changed to get it. Contrast this with `UserClaimsController`'s `[Route("[controller]")]`, where the class name *is* the resource. +- **Concept introduced (2), anonymous by necessity, and the response posture that follows.** `[Rubric §11, Security]` and `[Rubric §30, Compliance, Privacy & Data Governance]`. A user who has lost a credential cannot present one, so requiring authentication here would be circular; the base marks both actions `[AllowAnonymous]` (`PasswordResetAuthControllerBase.cs:77`, `:101`) and the framework's anonymous-endpoint architecture test lists them explicitly rather than letting them slip through unnoticed. Because the endpoints are open, the response shapes are chosen to reveal nothing: forgot-password always answers `202 Accepted` (`:92`) whether or not the address holds an account, so a caller cannot enumerate registered addresses, and every reset rejection collapses into one `401` (`:105`) so an invalid token and an unknown address are indistinguishable. Both carry `[EnableRateLimiting(WebApplicationBuilderExtensions.RateLimitPolicyAuthIp)]` (`:78`, `:102`), the same `"auth-ip"` fixed window that guards login and register ([ADR-019](https://ivanball.github.io/docs/adr/019-rate-limiting.html)), and both carry `[Idempotent]` (`:76`, `:100`) so a retried mobile submit does not send a second reset email or burn a second token ([ADR-017](https://ivanball.github.io/docs/adr/017-request-idempotency.html)). +- **Walkthrough**: three class attributes and four members. + - `[ApiController]`, `[Route("Auth")]`, `[ApiVersion("1.0")]` (`PasswordResetController.cs:25-27`), the same routing-and-versioning triple every ADC controller repeats because those attributes are not reliably inherited. + - The primary constructor (`PasswordResetController.cs:28-33`) takes the two closed command handlers and forwards them to the base, which exposes them as `ForgotPasswordHandler` and `ResetPasswordHandler` (`PasswordResetAuthControllerBase.cs:50`, `:53`). + - `CreateForgotPasswordCommand(ForgotPasswordRequest request) => new(request)` (`PasswordResetController.cs:36`) and `CreateResetPasswordCommand(ResetPasswordRequest request) => new(request)` (`:39`), the two abstract factories the base declares (`PasswordResetAuthControllerBase.cs:61`, `:69`). This is the same split [`AuthController`](#authcontroller) uses for change-password: the workflow is shared, the command *records* are not, because ADC marks its reset command `ICacheInvalidating` and Store does not (`PasswordResetAuthControllerBase.cs:32-39`). + - The inherited actions themselves: `ForgotPasswordAsync` (`PasswordResetAuthControllerBase.cs:82-93`) dispatches the app command and returns `Accepted()`; `ResetPasswordAsync` (`:107-118`) dispatches and returns `NoContent()`. Both end in the same `result.IsFailure ? HandleFailure(result.Errors) : ...` tail as every other controller in the module. +- **Why it's built this way**: the reset credential is a cache record keyed by the address rather than columns on the user row, which is the decision [ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html) records: it costs no migration in any consumer, adds nothing to the hottest entity in the system, needs no sweeper because the cache enforces expiry itself, and reuses the substrate [ADR-029](https://ivanball.github.io/docs/adr/029-authentication-brute-force-protection.html) already chose for login lockout. That choice is invisible from this file, which is the point: the controller only names the two commands. +- **Where it's used**: handled by [`ForgotPasswordHandler`](#forgotpasswordhandler) and [`ResetPasswordHandler`](#resetpasswordhandler); driven by unauthenticated browser and MAUI clients through the Gateway's existing `/Auth` route. +- **Caveats / not-in-source**: nothing about token generation, its TTL, the attempt cap, or the email send is visible here; all of it lives in the two handlers and the cache-backed store behind them. ### AuthController -> MMCA.ADC.Identity.API · `MMCA.ADC.Identity.API.Controllers` · `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AuthController.cs:29` · Level 12 · class (sealed) +> MMCA.ADC.Identity.API · `MMCA.ADC.Identity.API.Controllers` · `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AuthController.cs:29` · Level 17 · class (sealed) - **What it is**: the `/auth` endpoint surface: login, register, refresh, revoke, change password, and get/set the stored culture and theme preferences. Most of it is inherited; this class overrides two actions and supplies the two command factories. - **Depends on**: [`UserAccountAuthControllerBase`](group-12-api-hosting-mapping.md#useraccountauthcontrollerbasetchangepasswordcommand-tchangepreferencescommand) (which itself extends [`AuthControllerBase`](group-12-api-hosting-mapping.md#authcontrollerbase)), [`IAuthenticationService`](group-08-auth.md#iauthenticationservice), [`ICurrentUserService`](group-08-auth.md#icurrentuserservice), [`ChangePasswordCommand`](#changepasswordcommand), [`ChangePreferencesCommand`](#changepreferencescommand), [`GetUserPreferencesQuery`](group-14-module-system-composition.md#getuserpreferencesquery), [`AuthenticationResponse`](group-08-auth.md#authenticationresponse), [`RegisterRequest`](group-08-auth.md#registerrequest), [`LoginRequest`](group-08-auth.md#loginrequest); externals: ASP.NET Core rate limiting (`[EnableRateLimiting]`). -- **Concept introduced, the generic controller base parameterized by the app's command types.** `[Rubric §16, Maintainability]`, `[Rubric §1, SOLID]`, `[Rubric §11, Security]`. The base owns the four token actions plus `PUT password` (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:86`), `PUT preferences` (`:112`), and `GET preferences` (`:138`). It cannot own the command *records*, because ADC marks its change-password command `ICacheInvalidating` with a cache prefix built from ADC's own [`User`](#user) type while Store does not; the shared handler base's remarks record that reason (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:16-21`). The resolution is two abstract factory methods, which this class implements as one-liners (`AuthController.cs:82-89`): the shared workflow stays shared, and each app keeps its own command semantics. +- **Concept introduced, the generic controller base parameterized by the app's command types.** `[Rubric §16, Maintainability]`, `[Rubric §1, SOLID]`, `[Rubric §11, Security]`. The base owns the four token actions plus `PUT password` (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:86`), `PUT preferences` (`:112`), and `GET preferences` (`:138`). It cannot own the command *records*, because ADC marks its change-password command `ICacheInvalidating` with a cache prefix built from ADC's own [`User`](#user) type while Store does not; the shared handler base's remarks record that reason (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:16-21`). The resolution is two abstract factory methods, which this class implements as one-liners (`AuthController.cs:82-89`): the shared workflow stays shared, and each app keeps its own command semantics. [`PasswordResetController`](#passwordresetcontroller) is the same pattern applied to the recovery pair. - **Walkthrough**: primary constructor forwarding five dependencies to the base (`AuthController.cs:29-40`), then four members. - `RegisterAsync` (`AuthController.cs:52-63`) is a genuine override rather than a pass-through. It reads `HttpContext.Connection.RemoteIpAddress` and passes it to `AuthenticationService.RegisterAsync` (`:57-58`), which is the BR-213 registration rate limiting: the Application layer cannot see the connection, so the IP has to be captured here and handed down. Success returns `201 Created` explicitly rather than `200` (`:62`). - - `LoginAsync` (`AuthController.cs:76-79`) overrides only to re-declare attributes, then calls `base.LoginAsync`. The reason is the attribute set: `[EnableRateLimiting(WebApplicationBuilderExtensions.RateLimitPolicyAuthIp)]` (`:72`, the constant resolves to `"auth-ip"` at `MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:41`) and the documented 429 (`:75`). The doc comment (`:65-69`) states the threat model precisely: the per-email lockout of BR-212 cannot stop one source spraying a single common password across many different emails, so a per-IP fixed window sits on top of it ([ADR-019](https://ivanball.github.io/docs/adr/019-rate-limiting.html), [ADR-029](https://ivanball.github.io/docs/adr/029-authentication-brute-force-protection.html)). Both anonymous endpoints, register and login, carry the same policy (`:48`, `:72`). + - `LoginAsync` (`AuthController.cs:76-79`) overrides only to re-declare attributes, then calls `base.LoginAsync`. The reason is the attribute set: `[EnableRateLimiting(WebApplicationBuilderExtensions.RateLimitPolicyAuthIp)]` (`:72`, the constant resolves to `"auth-ip"` at `MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:46`) and the documented 429 (`:75`). The doc comment (`:65-69`) states the threat model precisely: the per-email lockout of BR-212 cannot stop one source spraying a single common password across many different emails, so a per-IP fixed window sits on top of it ([ADR-019](https://ivanball.github.io/docs/adr/019-rate-limiting.html), [ADR-029](https://ivanball.github.io/docs/adr/029-authentication-brute-force-protection.html)). Both anonymous endpoints, register and login, carry the same policy (`:48`, `:72`). - `CreateChangePasswordCommand` and `CreateChangePreferencesCommand` (`AuthController.cs:82-89`), the two factory implementations that bind ADC's command records to the base's workflow. - **Why it's built this way**: `[Route("[controller]")]` (`AuthController.cs:27`) makes the prefix `/auth`, which is what the Gateway's route map fronts, and `[ApiVersion("1.0")]` (`:28`) keeps it on the header-based versioning scheme. Both anonymous actions are marked `[AllowAnonymous]` with the fully qualified attribute name (`:47`, `:71`) because the file's `using` set does not import the authorization namespace. -- **Where it's used**: the entry point for every authenticated ADC client. The Blazor and MAUI clients call login/register/refresh through the Gateway; the [`Profile`](#profile) page uses `PUT auth/password` and the preferences pair; [`ChangePasswordRequestValidator`](#changepasswordrequestvalidator) guards the password action through the Validating decorator. +- **Where it's used**: the entry point for every authenticated ADC client. The Blazor and MAUI clients call login/register/refresh through the Gateway; the [`Profile`](#profile) page uses `PUT auth/password` and the preferences pair; [`ChangePasswordRequestValidator`](#changepasswordrequestvalidator) guards the password action through the Validating decorator. Its anonymous sibling on the same `/Auth` prefix is [`PasswordResetController`](#passwordresetcontroller). ### ChangePreferencesCommand > MMCA.ADC.Identity.Application · `MMCA.ADC.Identity.Application.Users.UseCases.ChangePreferences` · `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesCommand.cs:14` · Level 8 · record (sealed) - **What it is**: the command that persists one user's culture and theme preferences, the write side of [ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html) / [ADR-028](https://ivanball.github.io/docs/adr/028-dark-theme-mode.html). It pairs the target `UserId` with the partial [`ChangePreferencesRequest`](group-08-auth.md#changepreferencesrequest) and evicts the user cache so a preference change cannot be masked by a stale cached read. - **Depends on**: [`ChangePreferencesRequest`](group-08-auth.md#changepreferencesrequest); the `UserIdentifierType` alias (`= int` in this module); [`User`](#user) (only for `typeof(User).FullName`); [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating) and [`IUserScopedCommand`](group-14-module-system-composition.md#iuserscopedcommandout-trequest). -- **Concept reinforced, marker-driven pipeline behavior** (introduced at [`ChangePasswordCommand`](#changepasswordcommand)). `[Rubric §12, Performance & Scalability]` assesses caching with correct invalidation, and `[Rubric §6, CQRS & Event-Driven]` assesses whether a command is a named intention carrying exactly what the write needs. The instructive detail is what this record does **not** implement: the declaration lists only `ICacheInvalidating` and `IUserScopedCommand` (`ChangePreferencesCommand.cs:15`), with no [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest), so no `CommandRequestValidator` is auto-registered and the payload is not run through FluentValidation at the edge. `IUserScopedCommand`'s own `` records why the two markers are separate rather than merged: the automatic-validation opt-in is a per-application decision, and ADC and Store agree on it for the password change but disagree for preferences (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/IUserScopedCommand.cs:6-11`). Skipping edge validation is safe here only because the aggregate checks both values itself: `User.UpdatePreferences` combines the supported-culture allowlist and the light/dark rule through [`UserInvariants`](#userinvariants), and the shared handler base propagates that invariant failure as the command's failure. +- **Concept reinforced, marker-driven pipeline behavior** (introduced at [`ChangePasswordCommand`](#changepasswordcommand)). `[Rubric §12, Performance & Scalability]` assesses caching with correct invalidation, and `[Rubric §6, CQRS & Event-Driven]` assesses whether a command is a named intention carrying exactly what the write needs. The instructive detail is what this record does **not** implement: the declaration lists only `ICacheInvalidating` and `IUserScopedCommand` (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesCommand.cs:15`), with no [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest), so no [`CommandRequestValidator`](group-06-validation.md#commandrequestvalidatortcommand-trequest) is auto-registered and the payload is not run through FluentValidation at the edge. `IUserScopedCommand`'s own `` records why the two markers are separate rather than merged: the automatic-validation opt-in is a per-application decision, and ADC and Store agree on it for the password change but disagree for preferences (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/IUserScopedCommand.cs:6-11`). Skipping edge validation is safe here only because the aggregate checks both values itself: `User.UpdatePreferences` combines the supported-culture allowlist and the light/dark rule through [`UserInvariants`](#userinvariants), and the shared handler base propagates that invariant failure as the command's failure. - **Walkthrough**: a two-parameter positional record `(UserIdentifierType UserId, ChangePreferencesRequest Request)` (`ChangePreferencesCommand.cs:14`), plus one computed member, `CachePrefix => $"{typeof(User).FullName}:"` (`:18`). Deriving the prefix from the type rather than from a string literal keeps it in lockstep with the key the user cache actually uses: rename or move [`User`](#user) and the prefix follows. - **Why it's built this way**: the command record deliberately stays application-side rather than moving into the framework alongside its handler, because ADC marks it `ICacheInvalidating` with a prefix built from its own `User` type and Store does not, so a single shared record could not preserve both behaviors (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:16-20`). Expressing eviction as an interface the command implements keeps cache management a decorator concern instead of handler boilerplate. - **Where it's used**: constructed by [`AuthController`](#authcontroller)'s `CreateChangePreferencesCommand` override (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AuthController.cs:87-89`) for the profile page and the app-bar culture and theme switchers; handled by [`ChangePreferencesHandler`](#changepreferenceshandler). @@ -1748,17 +1868,17 @@ OAuth login), [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-sto - **Concept introduced, the owner-or-privileged-role request shape.** `[Rubric §11, Security]` assesses whether authorization decisions are made from data the caller cannot forge, and `[Rubric §1, SOLID]` covers why this is an interface rather than a naming convention. [`IUserOwnedRequest`](group-14-module-system-composition.md#iuserownedrequest) extends [`IUserScopedRequest`](group-14-module-system-composition.md#iuserscopedrequest) with `CurrentUserId` and a nullable `CurrentUserRole` (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/IUserOwnedRequest.cs:8-15`), which is exactly the triple the shared [`UserOwnershipRule`](group-14-module-system-composition.md#userownershiprule) needs to answer "may this caller act on that account". Both extra values are filled from the token by the controller, never from the body, so a client cannot claim a role it was not issued. Only two ADC use cases wear this shape, deletion and data export, and they are precisely the two that must let an Organizer act on someone else's row. - **Walkthrough**: a three-parameter positional record, `UserId`, `CurrentUserId`, `CurrentUserRole` (`DeleteUserCommand.cs:11-14`), plus the computed `CachePrefix => $"{typeof(User).FullName}:"` (`:17`). `CurrentUserRole` is `string?` because a token may carry no role claim at all, and the null case must resolve to "no privilege" rather than to an exception. - **Why it's built this way**: modelling the caller as part of the command, rather than reaching for an ambient `HttpContext` inside the handler, is what keeps the handler testable without a web host and keeps the Application layer free of ASP.NET types (`[Rubric §3, Clean Architecture]`, `[Rubric §14, Testability]`). -- **Where it's used**: constructed by [`UsersController`](#userscontroller)'s `DeleteAsync` from `currentUserService.UserId` and `currentUserService.Role` (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:183-185`); handled by [`DeleteUserHandler`](#deleteuserhandler). +- **Where it's used**: constructed by [`UsersController`](#userscontroller)'s `DeleteAsync` from `currentUserService.UserId` and `currentUserService.Role` (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:183-192`); handled by [`DeleteUserHandler`](#deleteuserhandler). -### ModuleApplicationDbContext -> MMCA.ADC.Identity.Infrastructure · `MMCA.ADC.Identity.Infrastructure.Persistence.DbContexts` · `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:15` · Level 8 · class (abstract) +### ResetPasswordCommand +> MMCA.ADC.Identity.Application · `MMCA.ADC.Identity.Application.Users.UseCases.ResetPassword` · `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordCommand.cs:14` · Level 8 · record (sealed) -- **What it is**: the Identity module's abstract EF Core context. It declares the module's one entity set, `Users`, and inherits everything else from the framework [`ApplicationDbContext`](group-07-persistence-ef-core.md#applicationdbcontext). -- **Depends on**: [`ApplicationDbContext`](group-07-persistence-ef-core.md#applicationdbcontext) (base), [`IEntityConfigurationAssemblyProvider`](group-07-persistence-ef-core.md#ientityconfigurationassemblyprovider), [`PhysicalDataSource`](group-07-persistence-ef-core.md#physicaldatasource), EF Core's `DbContextOptions`, `IServiceProvider`, and [`User`](#user). -- **Concept reinforced, one context class per engine, never one per module.** `[Rubric §8, Data Architecture]` assesses how ownership of tables is expressed. The name can mislead on first reading: this type is not what gets instantiated. It is an **abstract** declaration of what Identity contributes to a context, and the concrete per-engine class ([`SQLServerDbContext`](group-07-persistence-ef-core.md#sqlserverdbcontext) in production today) inherits it and supplies the provider options ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html), [ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html)). Every other module declares a same-named abstract class in its own namespace, and the doc comment (`ModuleApplicationDbContext.cs:9-14`) states the division of labour plainly: the base handles audit fields, soft deletes, and domain-event dispatch through EF interceptors, so a module context is a declaration of entity sets and nothing more. -- **Walkthrough**: a primary constructor forwarding all four parameters straight to the base (`ModuleApplicationDbContext.cs:15-20`), then the single member `internal DbSet Users { get; set; }` (`:22`). Note the accessibility: `internal`, not `public`. Application code reaches users through [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) and the repositories, so nothing outside the Infrastructure assembly has a reason to touch the set directly. The mapping is not here either: it is discovered from the assembly named by [`IEntityConfigurationAssemblyProvider`](group-07-persistence-ef-core.md#ientityconfigurationassemblyprovider) and supplied by [`UserConfiguration`](#userconfiguration). -- **Why it's built this way**: keeping the module's contribution abstract is what lets the same entity declarations be hosted by a SQL Server context in production and, with no code change, by a different engine's context. It is also what makes the "never split the context per module" rule enforceable: modules add abstract declarations, they never introduce a second concrete context class ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)). -- **Where it's used**: inherited by the concrete engine context the framework's physical context factory builds for the `ADC_Identity` database; that database also carries its own `dbo.OutboxMessages` table, so Identity's outbox never contends with another service's. +- **What it is**: the second half of the forgot-password vertical ([ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html)). It carries the address, the single-use token from the reset email, and the new password, and it evicts the user cache because the credential the cached aggregate holds has just changed. +- **Depends on**: [`ResetPasswordRequest`](group-08-auth.md#resetpasswordrequest) (from `MMCA.Common.Shared.Auth`); [`ICommandWithRequest`](group-05-cqrs-pipeline.md#icommandwithrequestout-trequest) and [`ICacheInvalidating`](group-05-cqrs-pipeline.md#icacheinvalidating); [`User`](#user), for `typeof(User).FullName` only. +- **Concept reinforced, the same two markers with the opposite validation decision.** Put this record next to [`ChangePreferencesCommand`](#changepreferencescommand) above and the marker system explains itself. Both are one-line records with the same `CachePrefix`; the difference is that this one **does** implement `ICommandWithRequest` (`ResetPasswordCommand.cs:15`), which opts it into automatic [`CommandRequestValidator`](group-06-validation.md#commandrequestvalidatortcommand-trequest) registration, so the validating decorator runs [`ResetPasswordRequestValidator`](group-08-auth.md#resetpasswordrequestvalidator) before the handler ever sees the command. `[Rubric §11, Security]`: that validator includes [`StrongPasswordRules`](group-06-validation.md#strongpasswordrulest) over `NewPassword` (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ResetPasswordRequestValidator.cs:23`), with the stated reason that a reset must not become a way around the complexity policy that registration and change-password enforce (`:8-10`). `[Rubric §6, CQRS & Event-Driven]`: notice there is no `UserId` parameter. The caller is anonymous at this point, so the account is not named by the request at all: it is recovered from the redeemed token inside the handler, which is what stops the endpoint from being usable to set an arbitrary account's password. +- **Walkthrough**: a single-parameter positional record `(ResetPasswordRequest Request)` (`ResetPasswordCommand.cs:14`) implementing both markers (`:15`), plus `CachePrefix => $"{typeof(User).FullName}:"` (`:18`). The payload itself is a `readonly record struct` of three strings, `Email`, `Token`, `NewPassword` (`MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ResetPasswordRequest.cs:9-12`), whose doc comment marks `NewPassword` as transmitted over TLS and never logged (`:8`). +- **Why it's built this way**: the record stays application-side while its workflow lives in the framework, for the reason the base states in its own ``: the shared handler reads the command only through `ICommandWithRequest`, so each application keeps its own record and its own cache-invalidation decision (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:23-26`). That is the same hoist boundary the change-password vertical uses, and the summary on this record says so explicitly (`ResetPasswordCommand.cs:9-11`). +- **Where it's used**: built by [`PasswordResetController`](#passwordresetcontroller)'s `CreateResetPasswordCommand` override, a single expression-bodied `new(request)` (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:39`), behind `POST /Auth/reset-password`; handled by [`ResetPasswordHandler`](#resetpasswordhandler). ### UserConfiguration > MMCA.ADC.Identity.Infrastructure · `MMCA.ADC.Identity.Infrastructure.Persistence.EntityConfiguration` · `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/Persistence/EntityConfiguration/UserConfiguration.cs:12` · Level 8 · class (internal, sealed) @@ -1776,8 +1896,10 @@ OAuth login), [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-sto - `LinkedSpeakerId` (`:57`): configured as a plain scalar property with no relationship at all. That is the visible consequence of [ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html): Speaker lives in another database, so there is no foreign key to declare. - Ignored members (`:112-113`): `FullName` and `IsExternalLogin` are computed on the aggregate, so `builder.Ignore` keeps EF from expecting columns for them. - Indexes (`:115-126`): a unique index on `Email`, which is the BR-200 "email is the identity" rule enforced at the storage layer; a **filtered** index on `RefreshToken` with `HasFilter("[RefreshToken] IS NOT NULL")`, indexing only the rows that have an outstanding session; a unique filtered index on `LinkedSpeakerId`, which makes the User-to-Speaker link 1:1 while still allowing the many users who have no linked speaker; and a unique filtered composite on `(LoginProvider, ProviderKey)`, so one external identity cannot be attached to two accounts. +- **Concept introduced, what soft delete does to a unique index.** `[Rubric §8, Data Architecture]`. Read the `Email` index again: it declares `IsUnique()` and no filter (`:115`), yet the row it guards is never physically deleted. A soft-deleted row still occupies its unique slot, so without help the address of a deleted account could never be reused. The help is a model-finalizing convention rather than a hand-written filter: `SoftDeleteUniqueIndexConvention` walks every soft-deletable entity type at model finalization and sets an `IsDeleted = 0` filter on each unique index that does not already declare one (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/SoftDeleteUniqueIndexConvention.cs:36-55`), building the predicate through the same `SoftDeleteFilterSql.Build` a hand-authored index would reach (`:47`), and no-opping for Cosmos (`:33-34`) ([ADR-095](https://ivanball.github.io/docs/adr/095-soft-delete-unique-indexes.html)). The rule that matters when reading this file is stated in the convention's own doc comment: **hand-authored filters win**, an index that already declares a filter is left untouched (`:17-21`, `:53`). So `Email` gains the deleted-row exclusion automatically, while the two indexes that spell their own `HasFilter` (`LinkedSpeakerId`, and the `LoginProvider`/`ProviderKey` composite) keep exactly the predicate written here and nothing more. - **Why it's built this way**: `[Rubric §12, Performance & Scalability]`: the three filtered indexes are all sparse-column cases where the vast majority of rows are null, so filtering keeps each index small and keeps writes to the common rows out of it entirely. `[Rubric §11, Security]`: the unique constraints on email and on the provider pair are the last line of defence behind the application-level uniqueness probes, so a race between two concurrent registrations fails at the database rather than producing two accounts. - **Where it's used**: discovered by assembly scan through [`IEntityConfigurationAssemblyProvider`](group-07-persistence-ef-core.md#ientityconfigurationassemblyprovider) and applied when the concrete engine context builds the model declared by [`ModuleApplicationDbContext`](#moduleapplicationdbcontext); the resulting schema is materialized by the per-service Identity migrations project. +- **Caveats / not-in-source**: because the hand-written filters take precedence, the erasure path is what frees the two provider slots: `User.Anonymize` nulls `LoginProvider` and `ProviderKey` and rewrites the address to a per-id `deleted-{Id}@anonymized.invalid` placeholder (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:392-418`). `LinkedSpeakerId` is not cleared there, so a soft-deleted user keeps its speaker link occupied in that unique index. ### AttendeeQueryServiceGrpcAdapter > MMCA.ADC.Identity.Contracts · `MMCA.ADC.Identity.Contracts` · `MMCA.ADC/Source/Services/MMCA.ADC.Identity.Contracts/AttendeeQueryServiceGrpcAdapter.cs:14` · Level 9 · class (sealed) @@ -1790,7 +1912,7 @@ OAuth login), [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-sto - `CallDeadline = TimeSpan.FromSeconds(5)` (`:20`): a per-call deadline, and the comment (`:17-19`) explains why it is far tighter than the shared resilience pipeline's 30s attempt and 90s total budget. The failure being defended against is a **hung** peer, not a refused one: a refused call fails immediately, but a hung one would stall the broadcast-notification request that triggered this lookup for the full pipeline budget. Transport failures propagate to the caller by design rather than degrading into an empty audience. - `GetAttendeeUserIdsAsync` (`:23-34`): one call, passing `deadline: DateTime.UtcNow.Add(CallDeadline)` alongside the cancellation token (`:26-29`), then `return [.. response.UserIds]` (`:33`). The collection expression materializes protobuf's `RepeatedField` into a plain `IReadOnlyList`, which the comment (`:31-32`) notes is deliberate so callers do not leak the generated type; because `UserIdentifierType` is `int`, the projection is a no-op cast. - **Why it's built this way**: `[Rubric §29, Resilience & Business Continuity]`. Choosing a deadline shorter than the retry budget is the difference between "this dependency is slow" and "this request never returns". Letting transport failures surface rather than swallowing them means a broadcast that could not determine its audience fails visibly instead of quietly notifying nobody. -- **Where it's used**: registered by [`DependencyInjection`](#dependencyinjection)'s `AddIdentityAttendeeClient` in this same project, which the Notification service host calls after module registration (`MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:210-218`). Its server-side counterpart is [`AttendeesGrpcService`](#attendeesgrpcservice). +- **Where it's used**: registered by [`DependencyInjection`](#dependencyinjection)'s `AddIdentityAttendeeClient` in this same project, which the Notification service host calls after module registration (`MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:216-224`). Its server-side counterpart is [`AttendeesGrpcService`](#attendeesgrpcservice). - **Caveats / not-in-source**: the interface returns a plain list rather than a [`Result`](group-01-result-error-handling.md#result), so a transport fault reaches the caller as a raw `RpcException` after the Polly pipeline gives up; there is no `Result.Failure` translation on this path. ### AttendeesGrpcService @@ -1823,15 +1945,15 @@ OAuth login), [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-sto - **What it is**: ADC's account-deletion handler (UC-21). Unlike the two thin siblings it is not empty: it inherits the shared erasure workflow from [`DeleteUserHandlerBase`](group-14-module-system-composition.md#deleteuserhandlerbasetuser-tcommand) and supplies the three genuinely ADC-specific pieces, the privileged role, the cross-service erasure announcement, and the post-commit tail. - **Depends on**: [`DeleteUserHandlerBase`](group-14-module-system-composition.md#deleteuserhandlerbasetuser-tcommand) (base), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`IFileStorageService`](group-07-persistence-ef-core.md#ifilestorageservice), [`ICacheService`](group-09-caching.md#icacheservice), `TimeProvider`, [`SoftDeletedUserCache`](group-08-auth.md#softdeletedusercache), [`UserRole`](#userrole), [`UserDeleted`](#userdeleted) (the Identity.Shared integration event), [`SetUserAvatarHandler`](#setuseravatarhandler) (for its `TryGetBlobName` helper), [`Result`](group-01-result-error-handling.md#result), and `[LoggerMessage]` logging. - **Concept introduced (1), erasure as a fixed workflow with application-specific hooks.** `[Rubric §30, Compliance, Privacy & Data Governance]` assesses whether a deletion request actually destroys personal data. The base (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:55-119`) runs a fixed order: check ownership through `UserOwnershipRule.CheckOwnership` using the application's `HasDeletePrivilege` answer (`:62-71`); load the user, `Error.NotFound` when absent (`:73-78`); soft-delete (`:88-93`); run the application's tail (`:95-100`); anonymize (`:103-107`); save (`:109`); then run whatever post-commit actions the tail enqueued (`:111-114`) and log the erasure (`:116`). The two-step delete-then-anonymize is [ADR-005](https://ivanball.github.io/docs/adr/005-soft-delete-vs-erasure.html)'s resolution of a real tension: the row must survive because other bounded contexts hold scalar `UserId` references and the audit trail depends on it, but the personal data must not survive, because the privacy promise is erasure. One detail in the base rewards a second read (`:83-89`): it dispatches through `IErasableUser erasable = user;` rather than calling `user.Delete()` directly, because member lookup on a type parameter prefers its class constraint, and ADC's [`User`](#user) **hides** the base `Delete()` with `public new Result Delete()` (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:364`). Interface dispatch is what guarantees the aggregate's own version (the one that revokes the refresh token first) actually runs. -- **Concept introduced (2), raising a cross-service integration event from inside the erasure.** `[Rubric §6, CQRS & Event-Driven]` and `[Rubric §30]`. Personal data this account published into **another** service has to travel: Engagement holds a `DisplayName` snapshot on the leaderboard opt-in, Engagement is its own process with its own database, and the in-process domain event `User.Delete()` raises never reaches it. So the override calls `user.AddDomainEvent(new UserDeleted(command.UserId, timeProvider.GetUtcNow()))` (`DeleteUserHandler.cs:62`) on the aggregate, **before** the save. The comment (`:56-61`) states the invariant this buys: the outbox row is written by the very `SaveChangesAsync` that commits the erasure, so the fact and its announcement cannot come apart, whereas publishing after the commit would leave a crash window in which the account is gone and the published name is not. The event payload is deliberately just the id and a timestamp, because carrying a name or email would publish the very data the erasure exists to remove onto a broker that persists messages (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/IntegrationEvents/UserDeleted.cs:16-27`). +- **Concept introduced (2), raising a cross-service integration event from inside the erasure.** `[Rubric §6, CQRS & Event-Driven]` and `[Rubric §30]`. Personal data this account published into **another** service has to travel: Engagement holds a `DisplayName` snapshot on the leaderboard opt-in, Engagement is its own process with its own database, and the in-process domain event `User.Delete()` raises never reaches it. So the override calls `user.AddDomainEvent(new UserDeleted(command.UserId, timeProvider.GetUtcNow()))` (`DeleteUserHandler.cs:62`) on the aggregate, **before** the save. The comment (`:56-61`) states the invariant this buys: the outbox row is written by the very `SaveChangesAsync` that commits the erasure, so the fact and its announcement cannot come apart, whereas publishing after the commit would leave a crash window in which the account is gone and the published name is not. The event payload is deliberately just the id and a timestamp, because carrying a name or email would publish the very data the erasure exists to remove onto a broker that persists messages (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/IntegrationEvents/UserDeleted.cs:16-20`). - **Concept introduced (3), the post-commit action list.** `[Rubric §29, Resilience & Business Continuity]`. The hook signature takes an `ICollection> afterCommit` (`DeleteUserHandler.cs:46-50`). Work that must not happen if the save fails is **enqueued** rather than run inline, which lets the override hand values it captured before anonymization into a post-commit closure without parking them in mutable handler state. - **Walkthrough** - Constructor and `_logger` field (`:28-38`): five dependencies, and the logger is then held explicitly in a field rather than captured from the primary constructor. The comment says why: the base also receives `logger`, and capturing the same parameter into this type's state would be the compiler error CS9107. - `HasDeletePrivilege` (`:42-43`): `UserRole.IsOrganizer(currentUserRole)`, whose implementation is an `OrdinalIgnoreCase` comparison (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/UserRole.cs:76`), with a `` noting it is case-insensitive because a role claim may carry any casing. Store's equivalent answers with its Admin role; that one line is the entire difference in the authorization model between the two applications. - `OnAfterSoftDeleteAsync` (`:46-88`): it first captures the avatar blob name **before** anonymization clears the URL (`:54`), reusing `SetUserAvatarHandler.TryGetBlobName` (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarHandler.cs:90`). That ordering is the whole reason the hook runs where it does. It then raises the integration event (`:62`) and enqueues two post-commit actions in the order the pre-hoist handler ran them (`:64`): writing the shared soft-deleted marker through `SoftDeletedUserCache.MarkDeletedAsync` (`:68-80`), then deleting the avatar blob when there was one (`:82-85`). It returns `Result.Success()` (`:87`); returning a failure here would abort the erasure before anything is persisted. - - Marker failure policy (`:76-79`, `:90-93`): the cache write is wrapped in its own try/catch that swallows everything except `OperationCanceledException` and logs a warning whose message spells out the consequence, that the deleted user's existing access token stays usable until it expires. The comment (`:65-67`) states the reasoning: the deletion is already committed by the time this runs, the marker only shortens the window in which an already-issued token keeps working, and a cache fault must not turn a successful erasure into a failure the caller would retry. + - Marker failure policy (`:76-79`, `:90-93`): the cache write is wrapped in its own try/catch that swallows everything except `OperationCanceledException` and logs a warning whose message spells out the consequence, that the deleted user's existing access token stays usable until it expires. The comment (`:65-67`) states the reasoning: the deletion is already committed by the time this runs, the marker only shortens the window in which an already-issued token keeps working, and a cache fault must not turn a successful erasure into a failure the caller would retry. That is the shape [ADR-096](https://ivanball.github.io/docs/adr/096-best-effort-side-effects.html) later generalized into a policy, and this call site still spells the `catch` out by hand. - **Why it's built this way**: `[Rubric §11, Security]`. Access tokens are self-contained and valid until they expire, so deleting an account does not by itself stop a token already in the wild; the marker is what lets the shared [`SoftDeletedUserMiddleware`](group-12-api-hosting-mapping.md#softdeletedusermiddleware) reject those requests. Treating it as best-effort is the correct trade: erasure is the promise that must hold, and the token window is bounded anyway. `[Rubric §30]`: the avatar photo is personal data too (BR-116a), so the blob is deleted rather than merely unreferenced. -- **Where it's used**: resolved as `ICommandHandler` and invoked by [`UsersController`](#userscontroller)'s `DeleteAsync` (`UsersController.cs:183-185`), which returns 204 on success; the callers are the [`Profile`](#profile) page's self-service deletion and the organizer [`UserList`](#userlist). The [`UserDeleted`](#userdeleted) integration event it raises is consumed downstream by Engagement's [`UserDeletedPointsHandler`](group-22-engagement-module.md#userdeletedpointshandler). +- **Where it's used**: resolved as `ICommandHandler` and invoked by [`UsersController`](#userscontroller)'s `DeleteAsync` (`UsersController.cs:183-192`), which returns 204 on success; the callers are the [`Profile`](#profile) page's self-service deletion and the organizer [`UserList`](#userlist). The [`UserDeleted`](#userdeleted) integration event it raises is consumed downstream by Engagement's [`UserDeletedPointsHandler`](group-22-engagement-module.md#userdeletedpointshandler). - **Caveats / not-in-source**: the post-commit actions run sequentially inside the calling request (`DeleteUserHandlerBase.cs:111-114`), so a slow blob delete adds latency to the response; there is no background dispatch here. ### GetUserPreferencesHandler @@ -1844,6 +1966,18 @@ OAuth login), [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-sto - **Why it's built this way**: the base's `` (`GetUserPreferencesHandlerBase.cs:15-19`) records that the two application copies disagreed on the repository (ADC read, Store write) and that the read repository is the correct choice for a handler which never calls `SaveChangesAsync`, so Store gained a no-tracking read on adoption. That is the ordinary payoff of consolidating duplicated code: the merge forces a decision, and the better of the two behaviors wins for everyone. - **Where it's used**: resolved as `IQueryHandler>` and injected into [`AuthController`](#authcontroller) (`AuthController.cs:34`) as `GET /Auth/preferences`; the response seeds the client's culture and theme at startup. +### ResetPasswordHandler +> MMCA.ADC.Identity.Application · `MMCA.ADC.Identity.Application.Users.UseCases.ResetPassword` · `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordHandler.cs:18` · Level 9 · class (sealed) + +- **What it is**: ADC's completion step for a forgotten password: redeem the single-use token, set the new credential, then clear the account's lockout. Like its change-preferences and get-preferences siblings it is an **empty** subclass; the workflow lives in [`ResetPasswordHandlerBase`](group-14-module-system-composition.md#resetpasswordhandlerbasetuser-tcommand). +- **Depends on**: [`ResetPasswordHandlerBase`](group-14-module-system-composition.md#resetpasswordhandlerbasetuser-tcommand) (base), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`IPasswordHasher`](group-08-auth.md#ipasswordhasher), [`IPasswordResetTokenService`](group-08-auth.md#ipasswordresettokenservice), [`ILoginProtectionService`](group-08-auth.md#iloginprotectionservice), `ILogger`, [`User`](#user), and [`ResetPasswordCommand`](#resetpasswordcommand). +- **Concept introduced, uniform failure as an anti-enumeration device.** `[Rubric §11, Security]` assesses whether an anonymous endpoint leaks facts about accounts it will not authenticate. The base collapses **every** rejection to one error: `Error.Unauthorized("Auth.InvalidResetToken", "The reset link is invalid or has expired. Please request a new one.", HandlerName)` (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:95-99`). An unknown token, an expired token, a token issued for a different address, a token past its validation-attempt cap and an account that has since vanished are all indistinguishable to the caller, which the `` states as the point: the endpoint reveals nothing about which addresses hold accounts or which tokens exist (`:18-22`). The two failure branches differ only in the reason string handed to the log, `"token rejected"` (`:66`) and `"account no longer resolvable"` (`:75`), so an operator can still tell them apart while the client cannot. `[Rubric §13, Observability & Operability]`: both go through the shared [`UserUseCaseLog`](group-14-module-system-composition.md#userusecaselog) helpers (`MMCA.Common/Source/Core/MMCA.Common.Application/Users/UserUseCaseLog.cs:32`, `:37`), and the success line records only the user id, never the address or the token. +- **Concept introduced, consume the token before the write.** `[Rubric §11, Security]` again, and the ordering is the interesting part. The base redeems the token first (`ResetPasswordHandlerBase.cs:61-63`) and only then loads the user, hashes and saves. The comment states the trade explicitly (`:58-60`): leaving the token live until the write succeeds would open a replay window in which the same token redeems twice, so the token is burned up front, and the cost of a later invariant failure is that the user requests one more reset. Note also that the account is never named by the request: `userId` comes out of the redeemed token (`:70`), so the endpoint cannot be pointed at somebody else's account. +- **Walkthrough**: a primary constructor taking the five dependencies and forwarding all of them to the base (`ResetPasswordHandler.cs:18-29`), with an empty body (`:30-31`). The inherited `HandleAsync` (`ResetPasswordHandlerBase.cs:50-93`): null guard (`:54`); read the payload through the `ICommandWithRequest` constraint (`:56`), which is the whole reason the base never mentions ADC's command type; `tokenService.ValidateAndConsumeAsync(request.Email, request.Token, ...)` and the uniform failure on rejection (`:61-68`); load through the **mutating** repository by the id the token yielded, same uniform failure when the row is gone (`:71-77`); `passwordHasher.HashPassword(request.NewPassword)` and `user.ChangePassword(newHash, newSalt)` (`:79-84`), which returns the aggregate's own [`Result`](group-01-result-error-handling.md#result) and is propagated untouched on failure; `SaveChangesAsync` (`:86`); then `loginProtection.ResetFailedAttemptsAsync(request.Email, ...)` (`:89`) with the one-line reason above it, that a user who reset the password because of a lockout must not stay locked out (`:88`); finally the success log and the result (`:91-92`). +- **Why it's built this way**: the token material never touches the database. [ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html) puts it in the cache, hashed at rest, with a per-email request throttle and a per-token validation-attempt cap owned by [`IPasswordResetTokenService`](group-08-auth.md#ipasswordresettokenservice) (`MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IPasswordResetTokenService.cs:5-9`), so the reset vertical adds no schema and no migration. The clean split between "who owns the token" (the service) and "who owns the credential" (the aggregate, through `User.ChangePassword` at `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:318`) is what lets this handler be nine lines of forwarding. +- **Where it's used**: resolved as `ICommandHandler` and injected into [`PasswordResetController`](#passwordresetcontroller) (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:30`), which exposes it as `POST /Auth/reset-password` with `[AllowAnonymous]`, `[Idempotent]` and the shared auth-ip rate-limiting policy, all inherited from the framework base (`MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:99-102`). +- **Caveats / not-in-source**: `User.ChangePassword` raises the in-process `UserPasswordChanged` domain event (`User.cs:332`), so the successful save writes an outbox row as well as the new credential; what subscribes to it is outside this vertical. The lockout clear at `ResetPasswordHandlerBase.cs:89` runs after the commit and is awaited without a try/catch, so a failure there surfaces to the caller even though the password has already changed. + ### DependencyInjection > MMCA.ADC.Identity.Contracts · `MMCA.ADC.Identity.Contracts` · `MMCA.ADC/Source/Services/MMCA.ADC.Identity.Contracts/DependencyInjection.cs:14` · Level 10 · class (static) @@ -1853,20 +1987,30 @@ OAuth login), [ADR-045](https://ivanball.github.io/docs/adr/045-managed-file-sto The consequence is an ordering rule, stated in the same comment (`:33-37`): call this from the host's `Program.cs` **after** [`ModuleLoader`](group-14-module-system-composition.md#moduleloader)`.DiscoverAndRegister(...)`, so the in-process or stub registration is in the container by the time `Replace` looks for it. Calling it earlier is not an error the compiler or the container reports; it simply leaves the wrong implementation in place. - **Walkthrough**: the method lives inside an `extension(IServiceCollection services)` block (`:16`), the workspace idiom for DI registration (see [primer §4](00-primer.md#4-c-build-and-code-style-conventions)). `AddIdentityAttendeeClient(string serviceName = "identity")` (`:41`) does two things: `AddTypedGrpcClient(serviceName)` (`:43`), which the framework wires to Aspire service discovery at `http://{serviceName}` over HTTP/2 cleartext with the standard [`JwtForwardingClientInterceptor`](group-13-grpc-contracts.md#jwtforwardingclientinterceptor) and Polly resilience handler; and `services.Replace(ServiceDescriptor.Scoped())` (`:47`), with the inline comment restating the `Replace`-not-`TryAdd` rule at the point of use (`:45-46`). It then returns `services` for chaining (`:49`). The `serviceName` default of `"identity"` matches the AppHost resource name, so the common case passes no argument. - **Why it's built this way**: keeping this helper in the `.Contracts` project rather than in the consuming service means the knowledge of "how you talk to Identity remotely" lives once, next to the `.proto` that defines the call, and every future consumer gets it with a project reference plus one line ([ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html)). The `Scoped` lifetime matches the in-process implementation it replaces, so swapping transports changes no lifetime assumption anywhere in the graph. -- **Where it's used**: called by the Notification service host (`MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:218`), whose surrounding comment (`:210-217`) repeats the `Replace` rationale at the call site; the matching AppHost wiring is noted at `MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:212`. +- **Where it's used**: called by the Notification service host (`MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:224`), whose surrounding comment (`:216-223`) repeats the `Replace` rationale at the call site; the matching AppHost wiring is noted at `MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:213`. - **Caveats / not-in-source**: this is the only public member of the class today, so the `.Contracts` project's DI surface is exactly this one call. +### ModuleApplicationDbContext +> MMCA.ADC.Identity.Infrastructure · `MMCA.ADC.Identity.Infrastructure.Persistence.DbContexts` · `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:15` · Level 12 · class (abstract) + +- **What it is**: the Identity module's abstract EF Core context. It declares the module's one entity set, `Users`, and inherits everything else from the framework [`ApplicationDbContext`](group-07-persistence-ef-core.md#applicationdbcontext). +- **Depends on**: [`ApplicationDbContext`](group-07-persistence-ef-core.md#applicationdbcontext) (base), [`IEntityConfigurationAssemblyProvider`](group-07-persistence-ef-core.md#ientityconfigurationassemblyprovider), [`PhysicalDataSource`](group-07-persistence-ef-core.md#physicaldatasource), EF Core's `DbContextOptions`, `IServiceProvider`, and [`User`](#user). +- **Concept reinforced, one context class per engine, never one per module.** `[Rubric §8, Data Architecture]` assesses how ownership of tables is expressed. The name can mislead on first reading: this type is not what gets instantiated. It is an **abstract** declaration of what Identity contributes to a context, and the concrete per-engine class ([`SQLServerDbContext`](group-07-persistence-ef-core.md#sqlserverdbcontext) in production today) inherits it and supplies the provider options ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html), [ADR-018](https://ivanball.github.io/docs/adr/018-polyglot-persistence.html)). Every other module declares a same-named abstract class in its own namespace, and the doc comment (`ModuleApplicationDbContext.cs:9-14`) states the division of labour plainly: the base handles audit fields, soft deletes, and domain-event dispatch through EF interceptors, so a module context is a declaration of entity sets and nothing more. +- **Walkthrough**: a primary constructor forwarding all four parameters straight to the base (`ModuleApplicationDbContext.cs:15-20`), then the single member `internal DbSet Users { get; set; }` (`:22`). Note the accessibility: `internal`, not `public`. Application code reaches users through [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) and the repositories, so nothing outside the Infrastructure assembly has a reason to touch the set directly. The mapping is not here either: it is discovered from the assembly named by [`IEntityConfigurationAssemblyProvider`](group-07-persistence-ef-core.md#ientityconfigurationassemblyprovider) and supplied by [`UserConfiguration`](#userconfiguration). +- **Why it's built this way**: keeping the module's contribution abstract is what lets the same entity declarations be hosted by a SQL Server context in production and, with no code change, by a different engine's context. It is also what makes the "never split the context per module" rule enforceable: modules add abstract declarations, they never introduce a second concrete context class ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)). +- **Where it's used**: inherited by the concrete engine context the framework's physical context factory builds for the `ADC_Identity` database; that database also carries its own `dbo.OutboxMessages` table, so Identity's outbox never contends with another service's. + ### IdentityModuleDbSeeder -> MMCA.ADC.Identity.Infrastructure · `MMCA.ADC.Identity.Infrastructure.Persistence.DbContexts.Seeding` · `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/Persistence/DbContexts/Seeding/IdentityModuleDbSeeder.cs:27` · Level 10 · class +> MMCA.ADC.Identity.Infrastructure · `MMCA.ADC.Identity.Infrastructure.Persistence.DbContexts.Seeding` · `MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/Persistence/DbContexts/Seeding/IdentityModuleDbSeeder.cs:27` · Level 15 · class - **What it is**: the development and test seeder for Identity. It supplies three fixed accounts (one Organizer, two Attendees) to the framework's [`IdentityModuleDbSeederBase`](group-07-persistence-ef-core.md#identitymoduledbseederbasetuser), which owns the per-account idiom. - **Depends on**: [`IdentityModuleDbSeederBase`](group-07-persistence-ef-core.md#identitymoduledbseederbasetuser) (base, itself a [`DbSeeder`](group-07-persistence-ef-core.md#dbseeder)), [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork), [`IPasswordHasher`](group-08-auth.md#ipasswordhasher), [`SeedAccount`](group-07-persistence-ef-core.md#seedaccount), [`Email`](group-02-domain-building-blocks.md#email), [`User`](#user) and [`UserRole`](#userrole), and [`Result`](group-01-result-error-handling.md#result) in its generic form. - **Concept introduced, the hoisted seeder with two typed hooks.** `[Rubric §17, DevOps]` assesses repeatable environment setup, and `[Rubric §16, Maintainability]` covers the de-duplication. The five-step idiom (normalize the email, skip if it already exists, hash the password, build the aggregate, add, save) was written out five times across the two applications' Identity modules and now lives once in the base (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Seeding/IdentityModuleDbSeederBase.cs:92-113`). Only two things could not be hoisted, and the base's `` (`:13-24`) names both: `CreateUser`, because the two applications' `User.Create(...)` factories take the same values in **different parameter orders** and only the application can spell its own role vocabulary; and `EmailExistsAsync`, because the existence predicate must be written against the concrete `User` (never an interface member) so EF translates it byte-for-byte the way it did before the hoist. That second point is the general lesson for hoisting anything that ends up inside an expression tree: a `where TUser : ISomething` constraint would compile and then fail at query translation. -- **Concept introduced, seeding gated where the gate has one home.** `[Rubric §11, Security]`. The base exposes a `ShouldSeed` opt-in that defaults to `true` (`IdentityModuleDbSeederBase.cs:57`), and this subclass deliberately does **not** override it (`IdentityModuleDbSeeder.cs:17-19`): ADC's `Seeding:IncludeSampleUsers` gate stays in [`IdentityModuleSeeder`](#identitymoduleseeder) in the API layer, which reads the key with `GetValue` and returns before constructing this seeder at all (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/IdentityModuleSeeder.cs:28-35`). One gate, one home. The class doc carries an explicit security notice (`IdentityModuleDbSeeder.cs:21-25`): the seed credentials ("Admin123!", "Password") are intentionally weak, exist only for local development convenience, and a deployed host must either disable seeding or supply environment-sourced secrets. The default of `false` when the configuration key is absent (`IdentityModuleSeeder.cs:26-27`) is what makes that safe by omission rather than by remembering. +- **Concept introduced, seeding gated where the gate has one home.** `[Rubric §11, Security]`. The base exposes a `ShouldSeed` opt-in that defaults to `true` (`IdentityModuleDbSeederBase.cs:57`), and this subclass deliberately does **not** override it (`IdentityModuleDbSeeder.cs:17-19`): ADC's `Seeding:IncludeSampleUsers` gate stays in [`IdentityModuleSeeder`](#identitymoduleseeder) in the API layer, which reads the key with `GetValue` and returns before constructing this seeder at all (`MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/IdentityModuleSeeder.cs:28-30`). One gate, one home. The class doc carries an explicit security notice (`IdentityModuleDbSeeder.cs:21-25`): the seed credentials ("Admin123!", "Password") are intentionally weak, exist only for local development convenience, and a deployed host must either disable seeding or supply environment-sourced secrets. The default of `false` when the configuration key is absent (`IdentityModuleSeeder.cs:26-27`) is what makes that safe by omission rather than by remembering. - **Walkthrough** - `Accounts` (`:33-38`): a collection-expression `IReadOnlyList` with three entries, each `(email, password, role, firstName, lastName)`. The roles come from [`UserRole`](#userrole)'s constants, not string literals. - `EmailExistsAsync` (`:41-48`): resolves the mutating repository through `UnitOfWork.GetRepository()` and calls `ExistsAsync(u => u.Email == email, ...)`. The predicate compares the `Email` value object, which is why the base normalizes the raw string with `Email.Create(account.Email).Value` first (`IdentityModuleDbSeederBase.cs:95`): both sides of the comparison then go through the same value converter and the SQL matches. - - `CreateUser` (`:51-62`): a null guard, then `User.Create(email, firstName, lastName, passwordHash, passwordSalt, role)` in ADC's parameter order, returning the aggregate's own [`Result`](group-01-result-error-handling.md#result). A failure here is not thrown: the base skips that one account and moves on (`IdentityModuleDbSeederBase.cs:104-108`). + - `CreateUser` (`:51-62`): a null guard, then `User.Create(email, firstName, lastName, passwordHash, passwordSalt, role)` in ADC's parameter order, returning the aggregate's own generic [`Result`](group-01-result-error-handling.md#result). A failure here is not thrown: the base skips that one account and moves on (`IdentityModuleDbSeederBase.cs:104-108`). - Idempotency and isolation come from the base loop: `SeedAsync` iterates the accounts (`:67-70`) and each account is saved individually (`:110-112`), so re-running against an already-seeded database is a no-op and one invalid account cannot roll back the others. - **Why it's built this way**: seeding goes through [`IUnitOfWork`](group-07-persistence-ef-core.md#iunitofwork) and the domain factory rather than through raw SQL or EF `HasData`, so seeded rows satisfy exactly the same invariants, audit stamping, and password hashing ([ADR-032](https://ivanball.github.io/docs/adr/032-password-hashing.html)) as rows created through the API. That is what makes the seeded database a faithful small copy of a real one rather than a fixture that only looks like one. - **Where it's used**: constructed by [`IdentityModuleSeeder`](#identitymoduleseeder) (`IdentityModuleSeeder.cs:34-35`), which the module system invokes through the `IModuleSeeder` contract during database initialization ([`DatabaseInitializationExtensions`](group-12-api-hosting-mapping.md#databaseinitializationextensions)); in practice that means the local Aspire AppHost and E2E CI, and nothing else. diff --git a/docs-src/onboarding/group-27-testing-infrastructure.md b/docs-src/onboarding/group-27-testing-infrastructure.md index 7e2f8b7..e9696d8 100644 --- a/docs-src/onboarding/group-27-testing-infrastructure.md +++ b/docs-src/onboarding/group-27-testing-infrastructure.md @@ -28,8 +28,8 @@ There are five moving parts, and they map onto the test pyramid plus one governa 2. **Architecture fitness functions** ([`IArchitectureMap`](#iarchitecturemap), [`ArchitectureMapBase`](#architecturemapbase), [`Layer`](#layer), [`LayerRef`](#layerref), [`ArchitectureAssert`](#architectureassert), [`RuleHelpers`](#rulehelpers), - [`CrossEntityNavigationFinder`](#crossentitynavigationfinder), the twenty - [`ArchitectureRules`](#architecturerules) partial files, and the thirty-six abstract `*TestsBase` + [`CrossEntityNavigationFinder`](#crossentitynavigationfinder), the twenty-two + [`ArchitectureRules`](#architecturerules) partial files, and the thirty-eight abstract `*TestsBase` classes including [`RouteAuthorizationTestsBase`](#routeauthorizationtestsbase), [`ModuleConformanceTestsBase`](#moduleconformancetestsbasetmodule) and [`BrandColorTokenTestsBase`](#brandcolortokentestsbase)) turn architectural rules into @@ -45,7 +45,8 @@ There are five moving parts, and they map onto the test pyramid plus one governa [`PageExtensions`](#pageextensions), [`AxeOptions`](#axeoptions), [`AccessibilityViolationException`](#accessibilityviolationexception), [`WebVitalsCollector`](#webvitalscollector), the reusable page objects - [`LoginPage`](#loginpage) / [`RegisterPage`](#registerpage) / [`ProfilePage`](#profilepage), and + [`LoginPage`](#loginpage) / [`RegisterPage`](#registerpage) / [`ProfilePage`](#profilepage) / + [`ForgotPasswordPage`](#forgotpasswordpage) / [`ResetPasswordPage`](#resetpasswordpage), and the shipped workflow suites such as [`AuthorizationTestsBase`](#authorizationtestsbase)) drive a real browser against a running app, asserting accessibility and performance alongside behavior. @@ -55,6 +56,7 @@ There are five moving parts, and they map onto the test pyramid plus one governa [`ServiceInfoVersioningContractTestsBase`](#serviceinfoversioningcontracttestsbasetfixture), [`GracefulShutdownTestsBase`](#gracefulshutdowntestsbasetentrypoint), [`DecoratorPipelineOrderTestsBase`](#decoratorpipelineordertestsbasetcommand-tcommandresult-tquery-tqueryresult), + [`MiddlewarePipelineOrderTestsBase`](#middlewarepipelineordertestsbase), [`DependencyInjectionAssert`](#dependencyinjectionassert), [`HandlerTestBase`](#handlertestbasethandler)) pin cross-cutting HTTP and pipeline guarantees so a refactor cannot silently drop them. @@ -70,7 +72,7 @@ structural rules, and [ADR-058](https://ivanball.github.io/docs/adr/058-runtime-conformance-suites-as-a-package.html) for the runtime conformance suites that cover exactly what ADR-015 declared out of scope: "the tests assert **structure / registration**, not runtime behavior" -(`Website/docs-src/adr/015-architecture-fitness-functions.md:57`). +(`Website/docs-src/adr/015-architecture-fitness-functions.md:61-62`). ## Integration tests: a real host, a throwaway database, a per-test reset @@ -111,10 +113,13 @@ environment is chosen deliberately so `appsettings.Development.json` (which poin `DataSources` entry at `localhost`) does not load, leaving the resolver to collapse onto the overridden top-level connection string, a single-database monolith shape (`:16-24`). Server selection defaults to LocalDB but is overridable through `SqlBaseEnvironmentVariable` (`:58`, read at `:69-70`) -so CI can target a SQL service container. The fixture also exposes `ConnectionString` (`:45`) so -SQL-fidelity tests can read the raw tables, and `Services` (`:52`) so a cross-service test can -resolve a consumer-side handler out of the booted host. Because these fixtures need a reachable SQL -Server, the per-module `*.Integration.slnf` suites build in a headless sandbox but only *run* in CI. +so CI can target a SQL service container. Subclasses push their own host-specific settings (test JWT +key material, throttle lifts, faked gRPC edges) through the `ConfigureTestEnvironment` hook (`:142`, +invoked at `:77`), which routes them through the same restore bookkeeping. The fixture also exposes +`ConnectionString` (`:45`) so SQL-fidelity tests can read the raw tables, and `Services` (`:52`) so a +cross-service test can resolve a consumer-side handler out of the booted host. Because these fixtures +need a reachable SQL Server, the per-module `*.Integration.slnf` suites build in a headless sandbox +but only *run* in CI. One tier up sits [`CrossServiceFixtureBase`](#crossservicefixturebase) (`MMCA.Common.Testing/CrossServiceFixtureBase.cs:41`), which boots **several** hosts in one process @@ -149,8 +154,10 @@ Four helpers round out the tier. [`JwtTokenGenerator`](#jwttokengenerator) RSA keypair (`DefaultPublicKeyPem` at `:49`, `DefaultPrivateKeyPem` at `:68`) under a fixed `kid` of `mmca-test-key` (`:41`), so integration tests exercise the exact JWKS/RS256 validation code path production runs ([ADR-004](https://ivanball.github.io/docs/adr/004-authentication-dual-fetch.html)); -the class remarks flag, correctly, that the committed keypair is insecure by design and must never be -used in a real deployment (`:22-28`). [`FeatureManagementTestExtensions`](#featuremanagementtestextensions) +its `ConfigureInProcessTokenValidation` (`:167`) is what a test factory calls to re-point a host's +`JwtBearerOptions` at that committed key instead of a network authority. The class remarks flag, +correctly, that the committed keypair is insecure by design and must never be used in a real +deployment (`:22-28`). [`FeatureManagementTestExtensions`](#featuremanagementtestextensions) (`MMCA.Common.Testing/FeatureManagementTestExtensions.cs:10`) adds a `ConfigureTestFeatureFlags` extension member (`:21`) that builds an in-memory `FeatureManagement:*` configuration (`:24-32`) so a test `WebApplicationFactory` can flip a gate without touching `appsettings.json`. @@ -189,11 +196,11 @@ deliberately includes optional layers (`Ui`, `Grpc`, `Contracts`, `ServiceHost`, `IArchitectureMap.cs:16-19`) that a repo simply omits, so a rule iterating them is vacuously satisfied with no compile dependency on an absent assembly (`IArchitectureMap.cs:3-7`). -The rule bodies are split across twenty [`ArchitectureRules`](#architecturerules) partial files -(cancellation tokens, controllers, cycles, entities, events, governance, handlers, handler results, -idempotency, immutability, layers, localization, localized text, modules, naming, protos, purity, -slices, specifications, and transport; the partial type is declared in the first of them at -`MMCA.Common.Testing.Architecture/ArchitectureRules.CancellationTokens.cs:5`). The +The rule bodies are split across twenty-two [`ArchitectureRules`](#architecturerules) partial files +(cancellation tokens, contracts, controllers, cycles, entities, events, governance, handlers, handler +results, idempotency, immutability, layers, localization, localized text, modules, naming, protos, +purity, slices, specifications, transport, and upcasters; the partial type is declared in the first +of them at `MMCA.Common.Testing.Architecture/ArchitectureRules.CancellationTokens.cs:5`). The aggregate-convention rules live inside `ArchitectureRules.Entities.cs` (for example `DomainExposesAggregateRoots` at `MMCA.Common.Testing.Architecture/ArchitectureRules.Entities.cs:8`, `AggregateRootsHaveResultFactory` at `:19`, and the generalized `DomainFactoriesReturnResult` at @@ -209,9 +216,10 @@ and more), each exposing its rules as `[Fact]`s that a sealed per-repo subclass supplying its map. `AggregateConventionTestsBase` shows the shape in miniature: one abstract `Map` property and one `[Fact]` per rule (`MMCA.Common.Testing.Architecture/Bases/AggregateConventionTestsBase.cs:12-24`). The package ships -**104 test methods across 36 abstract `*TestsBase` classes, of which MMCA.Common's own build executes -99** (`MMCA.Common/FACTS.md:44-48`, a generated and CI-gated count: read it there rather than -restating it elsewhere). +**110 test methods across 38 abstract `*TestsBase` classes**, and MMCA.Common's own build executes +**129** of them (the methods of the bases its arch-tests subclass, plus its Common-only direct tests +such as `FrameworkSanityTests` and `SpecificationFitnessTests`), per `MMCA.Common/FACTS.md:43-48`: a +generated and CI-gated count, so read it there rather than restating it elsewhere. Failures report through [`ArchitectureAssert`](#architectureassert) (`MMCA.Common.Testing.Architecture/ArchitectureAssert.cs:8`), which has two overloads: one lists the @@ -259,7 +267,7 @@ host re-hardcodes the brand hex `#1565C0` instead of sourcing `var(--mmca-primar token (`BrandColorTokenTestsBase.cs:15-16,41-49`), with a non-empty check on the embedded list so the guard cannot pass vacuously (`:27-28`). [`DependencyVersionTestsBase`](#dependencyversiontestsbase) (`MMCA.Common.Testing.Architecture/Bases/DependencyVersionTestsBase.cs:15`, [Rubric §32, Dependency -& Supply-Chain]) parses `Directory.Packages.props` and fails the build on two commercial-license +& Supply-Chain]) checks the repo's pinned package majors and fails the build on two commercial-license traps a blanket package bump would otherwise walk into unnoticed: MassTransit at major 9 (`DependencyVersionTestsBase.cs:24-37`, [ADR-016](https://ivanball.github.io/docs/adr/016-lockstep-versioning-masstransit-pin.html)) and @@ -302,9 +310,17 @@ synchronous counterpart to `IntegrationEventContractTestsBase`: it rebuilds a re `.proto` contract and diffs it against a committed snapshot, and it is explicitly consumer-facing, because MMCA.Common ships the gRPC plumbing rather than any contracts of its own (`Bases/ProtoContractTestsBase.cs:3-11`). Sibling bases pin integration-event contracts -([ADR-010](https://ivanball.github.io/docs/adr/010-integration-event-schema-versioning.html)), -data residency, forms conventions, localization resources, -[concurrency](#concurrencyconventiontestsbase), [controller shape](#controllerconventiontestsbase), +([ADR-010](https://ivanball.github.io/docs/adr/010-integration-event-schema-versioning.html)), the +one-upcaster-per-source-contract rule that keeps the upcast chain a function (so which contract a +handler receives cannot depend on DI registration order, +`MMCA.Common.Testing.Architecture/ArchitectureRules.Upcasters.cs:7-12`, +[ADR-090](https://ivanball.github.io/docs/adr/090-event-upcaster-registration.html)), +[service-contract purity](#servicecontractpuritytestsbase) so an extracted service's wire surface +carries only Shared and contract types and never the producer's internals +(`MMCA.Common.Testing.Architecture/ArchitectureRules.Contracts.cs:13-18`, +[ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html)), data residency, forms +conventions, localization resources, [concurrency](#concurrencyconventiontestsbase), +[controller shape](#controllerconventiontestsbase), [state management](#statemanagementconventiontestsbase), [UI architecture](#uiarchitectureconventiontestsbase), and framework-version consistency, so the governance-as-tests pattern spans much of the 34-category rubric. @@ -453,17 +469,23 @@ Web Vitals "good" band, LCP 2500 ms / FCP 1800 ms / TTFB 800 ms / CLS 0.1 / INP (`WebVitalsCollector.cs:104-108`), and skipping the INP assertion when no interaction cleared the 16 ms threshold (`:148-151`), [Rubric §23, Front-End Performance] (the source tags it rubric §12). LCP and CLS are Chromium-only, so on Firefox and WebKit those fields stay 0 and the observers fail -silently rather than throwing (`WebVitalsCollector.cs:14-16,22-25`). The reusable identity page -objects [`LoginPage`](#loginpage) (`MMCA.Common.Testing.E2E/PageObjects/LoginPage.cs:6`), -[`RegisterPage`](#registerpage) (`MMCA.Common.Testing.E2E/PageObjects/RegisterPage.cs:6`), and -[`ProfilePage`](#profilepage) (`MMCA.Common.Testing.E2E/PageObjects/ProfilePage.cs:6`) wrap the -framework's real auth surfaces with role- and label-based locators (`LoginPage.cs:12-18`) and route -their own fills through the anti-race helper (`LoginPage.cs:31-32`, invoked at `:25-26`); downstream -apps add their own family, for example the 45 `MMCA.ADC.E2E.Tests` page objects covering events, -sessions, speakers, rooms, questions, feedback, sponsors, and the QR check-in and points surfaces. -Whole *workflows* ship too, not just page objects: six abstract suites under -`MMCA.Common.Testing.E2E/Workflows/` (`AuthorizationTestsBase`, `UserLoginTestsBase`, -`UserRegistrationTestsBase`, `LogoutTestsBase`, `ProfileManagementTestsBase`, and +silently rather than throwing (`WebVitalsCollector.cs:14-16,22-25`). + +Five reusable identity page objects ship with the package: [`LoginPage`](#loginpage) +(`MMCA.Common.Testing.E2E/PageObjects/LoginPage.cs:6`), [`RegisterPage`](#registerpage) +(`MMCA.Common.Testing.E2E/PageObjects/RegisterPage.cs:6`), [`ProfilePage`](#profilepage) +(`MMCA.Common.Testing.E2E/PageObjects/ProfilePage.cs:6`), +[`ForgotPasswordPage`](#forgotpasswordpage) +(`MMCA.Common.Testing.E2E/PageObjects/ForgotPasswordPage.cs:6`), and +[`ResetPasswordPage`](#resetpasswordpage) +(`MMCA.Common.Testing.E2E/PageObjects/ResetPasswordPage.cs:6`). They wrap the framework's real auth +surfaces with role- and label-based locators (`LoginPage.cs:12-18`) and route their own fills through +the anti-race helper (`LoginPage.cs:31-32`, invoked at `:25-26`); downstream apps add their own +family, for example the 45 `MMCA.ADC.E2E.Tests` page objects covering events, sessions, speakers, +rooms, questions, feedback, sponsors, and the QR check-in and points surfaces. Whole *workflows* +ship too, not just page objects: seven abstract suites under `MMCA.Common.Testing.E2E/Workflows/` +(`AuthorizationTestsBase`, `UserLoginTestsBase`, `UserRegistrationTestsBase`, `LogoutTestsBase`, +`ProfileManagementTestsBase`, [`PasswordResetTestsBase`](#passwordresettestsbase), and `UserPreferencesTestsBase`) are authored once and re-run per consumer. Their shape is the same supply-only-your-facts contract as the fitness bases: [`AuthorizationTestsBase`](#authorizationtestsbase) @@ -471,6 +493,12 @@ supply-only-your-facts contract as the fitness bases: for its route lists (`ProtectedPaths` `:26`, `PublicPaths` `:29`, optional `AuthenticatedUserPath` `:35` and `AdminPaths` `:44`) and owns the assertions, including the non-empty guard that keeps the anonymous-redirect check from passing vacuously (`:49-50`). +[`PasswordResetTestsBase`](#passwordresettestsbase) +(`MMCA.Common.Testing.E2E/Workflows/Identity/PasswordResetTestsBase.cs:17`) shows where the tier +draws its own boundary: it asserts the recovery flow is reachable from the login page (`:24-41`) and +that an unknown address produces the identical anti-enumeration confirmation (`:43-58`), but +deliberately does not consume a real reset token, because that token only reaches the user by email +and so belongs to an app-side integration test (`:10-16`). ## The Gallery harness @@ -549,7 +577,7 @@ Continuity]): it boots a host through `ProductionHostApplicationFactory`, calls `ApplicationStopping` then `ApplicationStopped` fired (`:58-61`). The failure it catches, a hosted service that refuses to drain, is invisible in production until it wedges a rolling deploy. -Three bases guard the composition of the pipeline itself. +Four bases guard the composition of the pipelines themselves. [`DecoratorPipelineOrderTestsBase`](#decoratorpipelineordertestsbasetcommand-tcommandresult-tquery-tqueryresult) (`MMCA.Common.Testing/DecoratorPipelineOrderTestsBase.cs:38`) is the opt-in fitness function for [ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html): it builds a real @@ -565,6 +593,18 @@ two `[Fact]`s at `:70-76`, with a final check that the innermost element is not `TryDecorate` applies decorators in reverse registration order, an innocent-looking reorder of the `AddApplicationDecorators()` lines silently changes runtime behavior, and this base turns that into a test failure (see [group 5](group-05-cqrs-pipeline.md)). +[`MiddlewarePipelineOrderTestsBase`](#middlewarepipelineordertestsbase) +(`MMCA.Common.Testing/MiddlewarePipelineOrderTestsBase.cs:29`) is its counterpart on the HTTP edge: +it seeds `MiddlewarePipelineBuilder.CreateDefault`, applies the host's own `Configure` customization +when it has one (`:35`), and asserts the eighteen-step order from the exception handler down to the +controller endpoints (`:38-58`). Several adjacencies there are load-bearing (the pre-forwarded +capture immediately before `UseForwardedHeaders`, authentication immediately before tenant +resolution, authentication before the rate limiter per +[ADR-019](https://ivanball.github.io/docs/adr/019-rate-limiting.html), forwarded headers before the +HTTPS redirect), and a reorder that breaks one of them fails at runtime looking like a configuration +bug: an unreachable `jwks_uri`, a tenant that never resolves, a per-user rate cap that never engages +(`:13-18`). Unlike the contract bases it needs no host at all, because the steps are pure data until +they are applied, so it runs in the fast unit tier (`:24-27`). [`DependencyInjectionAssert`](#dependencyinjectionassert) (`MMCA.Common.Testing/DependencyInjectionAssert.cs:13`) guards the other half of that composition: `ReturnsSameCollection` (`:21`) asserts a registration extension hands back the very @@ -578,7 +618,7 @@ pre-configured to succeed (`HandlerTestBase.cs:41-42,45`), a `NullLogger()` (`:72`) helpers that wire a repository mock into the read and write accessors (the read-only variant exists for child entities, which expose no read-write repository). -A smaller fourth tier measures rather than asserts behavior. `MMCA.Common.Benchmarks` +A smaller final tier measures rather than asserts behavior. `MMCA.Common.Benchmarks` (BenchmarkDotNet) covers the per-request query pipeline, where the dynamic-LINQ predicate is re-parsed per call and the shaper reflects over DTO properties (`MMCA.Common.Benchmarks/QueryPipelineBenchmarks.cs:9-17`), and the specification hot path @@ -601,6 +641,24 @@ tiers, which is the standing caveat in ADR-015 and ADR-058 alike: the framework host gets it only once someone writes the subclass. Every remaining concrete test class is cataloged by project in the companion per-project test rollup for this chapter. +### AbstractAnonymousFixtureControllerBase, AnonymousFixtureController, TypeLevelAnonymousFixtureController +> MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · (see per-type table) · Level 0 · class + +Three throwaway MVC controllers nested inside [AnonymousEndpointTestsBaseTests](#anonymousendpointtestsbasetests) (`MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs`). Between them they cover every placement of `[AllowAnonymous]` the scan has to recognize: on an action of a concrete controller, on an action of an abstract base, and on the controller type itself. They are the input side of the anonymous-endpoint allow-list gate; the subclasses that consume them supply the expectations. + +- **Depends on** - `Microsoft.AspNetCore.Mvc.ControllerBase` and `Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute` (`AnonymousEndpointTestsBaseTests.cs:1`-`:2`). No state, no behavior: every action is `=> Ok()`. +- **Concept introduced** - *an allow-list is only as good as the identifier shapes it can be written in.* [AnonymousEndpointTestsBase](#anonymousendpointtestsbase) turns each discovered `[AllowAnonymous]` into a string, and there are exactly two shapes: a type-level attribute becomes the type's `FullName`, and a method-level attribute becomes `{declaring type FullName}.{method name}` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/AnonymousEndpointTestsBase.cs:133`-`:152`). A repo's allow-list is hand-written against those strings, so if the emitter and the hand-written convention ever disagree the gate silently reports phantom offenders or, worse, accepts a stale entry. These three fixtures exist so both shapes are produced by a real scan and matched by a real allow-list in the framework's own CI. Two scoping decisions in the base are also on display here: `ControllerBase`, `RouteAttribute` and `AllowAnonymousAttribute` are all matched by full name through reflection (`:32`-`:34`) so the rule package carries no ASP.NET reference, and abstract controllers are deliberately *included* in the scan (`IsController` walks `BaseType`, `:101`-`:112`) because a framework base action is where the attribute is declared. [Rubric §11 - Security] assesses whether the authorization posture of the HTTP surface is deliberate; an ungated endpoint that nobody reviewed is the single cheapest way to lose an app. [Rubric §26 - Front-End Security] extends the same scan to routable Blazor components (`IsRoutableComponent`, `:117`-`:118`). [Rubric §14 - Testability] covers the fixtures themselves. +- **Walkthrough** - the scan enumerates `LoadableTypes` of each target assembly, keeps controllers and routable components, and projects each survivor through `AnonymousEndpointsOf`, distinct and ordinal-ordered (`AnonymousEndpointTestsBase.cs:125`-`:131`). Methods are read with `BindingFlags.DeclaredOnly` and `inherit: false` (`:142`,`:146`), which is what decides where an inherited action gets reported (see [InheritingFixtureController](#inheritingfixturecontroller)). + +| Type | File:Line | The identifier shape it produces | +|------|-----------|----------------------------------| +| `AnonymousFixtureController` | `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:74` | The ordinary case: a sealed `ControllerBase` whose `PeekAsync` carries `[HttpGet]` and `[AllowAnonymous]` (`:78`-`:80`). Emits the method-level identifier `...AnonymousFixtureController.PeekAsync`, and is the name the drifted subclass's failure message must contain (`:22`). | +| `AbstractAnonymousFixtureControllerBase` | `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:84` | The declaration site: an `abstract` controller whose `virtual InheritedAnonymousAsync` carries `[HttpGet("inherited")]` and `[AllowAnonymous]` (`:88`-`:90`). Emits one identifier at the base, mirroring how the framework's own `AuthControllerBase` actions are declared once for every consumer that derives from them. | +| `TypeLevelAnonymousFixtureController` | `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:98` | The other identifier shape: `[AllowAnonymous]` on the type (`:97`) with a body-less declaration and no actions at all. Emits the bare `FullName`, with no method suffix. | + +- **Why they're built this way** - a gate whose fixtures only ever exercise one attribute placement would pass while being blind to the other, and the failure would land in a consumer repo rather than here. Nesting the fixtures inside the test class keeps them out of the assembly's public surface and, critically, out of the framework's real [AnonymousEndpointTests](#anonymousendpointtests) scan, which targets the API and UI assemblies rather than this one. See [ADR-015](https://ivanball.github.io/docs/adr/015-architecture-fitness-functions.html). +- **Where they're used** - scanned by the four nested subclasses [DriftedTests](#driftedtests), [StaleAllowListTests](#staleallowlisttests), [EmptyScanTests](#emptyscantests) and [ConformantTests](#conformanttests), and named in the assertions of [AnonymousEndpointTestsBaseTests](#anonymousendpointtestsbasetests). + ### AbstractFitnessControllerBase, IdempotentFitnessController, NonIdempotentFitnessController, UndeclaredFitnessController > MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · (see per-type table) · Level 0 · class @@ -676,6 +734,14 @@ Six tiny services in one file (`MMCA.Common/Tests/Architecture/MMCA.Common.Archi - **Walkthrough** - no members at all; the declaration ends at its semicolon (`:81`). Being concrete is what puts it into the rule's `ConcreteClasses` scan while its abstract base stays out. - **Where it's used** - asserted absent from the rule's failure message by `Rule_AcceptsDirectAndInheritedAndOptedOutDeclarations` (`IdempotencyFitnessTests.cs:34`-`:36`). +### InheritingFixtureController +> MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:94` · Level 1 · class +- **What it is** - a body-less concrete controller deriving [AbstractAnonymousFixtureControllerBase](#abstractanonymousfixturecontrollerbase-anonymousfixturecontroller-typelevelanonymousfixturecontroller). It is the fixture for the ruling that an inherited `[AllowAnonymous]` is reported once, at the base that declares it, and never again on the derived type. +- **Depends on** - [AbstractAnonymousFixtureControllerBase](#abstractanonymousfixturecontrollerbase-anonymousfixturecontroller-typelevelanonymousfixturecontroller) (`public sealed class InheritingFixtureController : AbstractAnonymousFixtureControllerBase;`, `AnonymousEndpointTestsBaseTests.cs:94`). +- **Concept introduced** - *the same inheritance question, answered the opposite way from the idempotency gate.* [InheritingFitnessController](#inheritingfitnesscontroller) exists because the idempotency rule reads attributes with `inherit: true`, so a base declaration covers every subclass. The anonymous-endpoint scan does the reverse: it reads methods with `BindingFlags.DeclaredOnly` and attributes with `inherit: false` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/AnonymousEndpointTestsBase.cs:142`,`:146`). Both choices are correct for their own rule, and the reason is who writes the allow-list. Idempotency intent is *declared* by the framework and must flow down to consumers, so inherited reads are what stop a wall of duplicate annotations. An anonymous endpoint must be *reviewed*, and if the scan reported the framework's base action once per derived controller, every consumer repo would have to re-approve the framework's four credential-exchange endpoints under its own type names, forever. The base's own comment states exactly that (`:140`-`:141`). [Rubric §11 - Security] is the property; [Rubric §33 - Developer Experience] is the reason for the shape, since a gate that demands the same approval in every downstream repo gets rubber-stamped rather than read. +- **Walkthrough** - no members; the declaration ends at its semicolon (`:94`). Being concrete puts it in the scan (`IsController` walks the base chain, `AnonymousEndpointTestsBase.cs:101`-`:112`), and the `DeclaredOnly` filter is what keeps it silent: it declares no methods of its own, so `AnonymousEndpointsOf` yields nothing for it. +- **Where it's used** - the subject of `Base_DoesNotReport_AnInheritedAttributeOnTheDerivedController` (`AnonymousEndpointTestsBaseTests.cs:61`-`:71`), which reads [ConformantTests](#conformanttests)'s scan output and asserts it does not contain `{InheritingFixtureController.FullName}.InheritedAnonymousAsync` (`:69`-`:70`). The inline comment there names the consequence being prevented (`:64`-`:66`). + ### NavigationContractTests > MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/NavigationContractTests.cs:17` · Level 1 · class - **What it is** - a documentation-drift gate for navigation (rubric §25): it asserts that the "Routes shipped by the framework" table in `NavigationFlow.md` stays in lockstep with the routable pages the `MMCA.Common.UI` assembly actually ships, and that each route's documented auth posture matches the `[Authorize]` reality on the page. @@ -711,6 +777,18 @@ Six tiny services in one file (`MMCA.Common/Tests/Architecture/MMCA.Common.Archi - **Walkthrough** - one member, `public RightModel? Model { get; set; }` (`:9`). Nullability is irrelevant to the rule (it reads the property type); the reference itself is the fixture. - **Where it's used** - referenced by [AcyclicConsumer](#acyclicconsumer) and scanned by [NamespaceCycleFitnessTests](#namespacecyclefitnesstests). +### PasswordHashingFitnessTests +> MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/PasswordHashingFitnessTests.cs:15` · Level 2 · class +- **What it is** - a structural credential-storage gate asserted against compiled IL rather than source text: [PasswordHasher](group-08-auth.md#passwordhasher) must depend on `Rfc2898DeriveBytes` (a deliberately slow key-derivation function) and on `CryptographicOperations` (the constant-time comparison), so a rewrite cannot quietly swap either out. +- **Depends on** - [PasswordHasher](group-08-auth.md#passwordhasher) as the assembly anchor and the type under scan (`PasswordHashingFitnessTests.cs:1`,`:21`), [ArchitectureAssert](#architectureassert) (`:37`,`:50`), NetArchTest's `Types` / `PredicateList` query API (`:56`-`:59`), and externals xUnit plus AwesomeAssertions. +- **Concept introduced** - *asserting on a dependency edge instead of on an output value.* A hashing routine can be verified two ways. A known-answer test pins the *values* (iteration count, salt length, digest length) and is what `PasswordHasherSecurityTests` does; this class pins the *shape*, which catches a class of change the value tests cannot. Swap PBKDF2 for a single SHA-512 pass and keep the same 32-byte output length and the known-answer pin can be regenerated by whoever made the change, but the `Rfc2898DeriveBytes` reference disappears from the IL and this test goes red. Same for replacing `CryptographicOperations.FixedTimeEquals` with `SequenceEqual`: identical behavior on every test input, and a timing side channel in production. The two `because` strings spell out the attacks precisely: commodity GPUs trying billions of candidates per second against a stolen table (`:38`-`:39`), and a short-circuiting comparison leaking the matching prefix length so a digest is recovered byte by byte (`:51`-`:53`). [Rubric §11 - Security] assesses whether credential storage resists offline cracking and side channels; this is the build-time half of that guarantee. [Rubric §14 - Testability] covers the technique, and [Rubric §32 - Dependency & Supply-Chain] applies at the edges, since the assertion is that a specific BCL cryptographic primitive is actually reached. +- **Walkthrough** - two `private const string` fully-qualified type names, `CryptographicOperationsType` (`:17`) and `Rfc2898DeriveBytesType` (`:19`), keep the matched names in one place. `Infrastructure` (`:21`) anchors the scanned assembly through `typeof(PasswordHasher).Assembly`, and the private `PasswordHasherTypes()` helper (`:56`-`:59`) narrows it with `Types.InAssembly(Infrastructure).That().HaveName(nameof(PasswordHasher))`. + - `ScannedPasswordHasherSet_IsNotEmpty` (`:23`-`:27`): the non-vacuity guard, and the first fact for a reason. `HaveName` matches on the simple name, so a renamed or moved type would leave the predicate list empty and both dependency assertions would pass having inspected nothing. `ContainSingle` on the full name (`:25`-`:26`) closes that. + - `PasswordHasher_DependsOnASlowKeyDerivationFunction` (`:29`-`:40`) and `PasswordHasher_DependsOnConstantTimeComparison` (`:42`-`:54`): each runs `.Should().HaveDependencyOnAll().GetResult()` and routes the result through `ArchitectureAssert.NoViolations`. `HaveDependencyOnAll` is the positive form (the dependency must be present), which is the unusual direction for an architecture rule and the right one here. +- **Why it's built this way** - [ADR-032](https://ivanball.github.io/docs/adr/032-password-hashing.html) fixes the hashing scheme; the real implementation calls `Rfc2898DeriveBytes.Pbkdf2` (`MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/PasswordHasher.cs:35`) and `CryptographicOperations.FixedTimeEquals` (`:58`). Pinning the decision as a fitness function rather than a code-review convention is the [ADR-015](https://ivanball.github.io/docs/adr/015-architecture-fitness-functions.html) approach, and it is the one that survives a refactor by someone who has not read the ADR. +- **Where it's used** - an independent class in the Common architecture suite. It is the shape half of a pair; the parameter values are pinned by `PasswordHasherSecurityTests` in the Infrastructure unit-test project (`MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/PasswordHasherSecurityTests.cs:18`), which holds PBKDF2-HMAC-SHA512 at 600,000 iterations with a 32-byte salt and a 64-byte output as reflected private constants (`:20`-`:22`). The class doc here records the division of labour (`PasswordHashingFitnessTests.cs:10`-`:13`). +- **Caveats / not-in-source** - `HaveDependencyOnAll` reports a reference in the compiled IL, not that the reference is on the hashing path. It cannot tell an actually-used PBKDF2 call from a dead one, which is why the value pins in the companion test remain load-bearing. + ### AcyclicConsumer > MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests.CycleFixtures.Acyclic` · `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/CycleFixtures/Acyclic/AcyclicFixtures.cs:6` · Level 3 · class - **What it is** - the negative control for the namespace-cycle rule: a third fixture namespace that points *into* `Left` and that nothing points back at, so it must never appear in a cycle report. @@ -719,6 +797,15 @@ Six tiny services in one file (`MMCA.Common/Tests/Architecture/MMCA.Common.Archi - **Walkthrough** - one member, `public LeftService? Service { get; set; }` (`:9`). - **Where it's used** - asserted *absent* from the failure message by `Rule_FlagsTwoNamespaceCycle_ButNotAcyclicNamespaces` (`NamespaceCycleFitnessTests.cs:23`-`:25`). +### EmptyScanTests +> MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:117` · Level 3 · class +- **What it is** - the adversarial fixture for the anonymous-endpoint gate's non-vacuity guard: a subclass pointed at an assembly that contains no controllers and no routable components at all, so the scan finds nothing and the guard must fail. +- **Depends on** - [AnonymousEndpointTestsBase](#anonymousendpointtestsbase) (`private sealed class EmptyScanTests : AnonymousEndpointTestsBase`, `AnonymousEndpointTestsBaseTests.cs:117`) and the `MMCA.Common.Shared` assembly reached through `typeof(Shared.Abstractions.Result).Assembly` (`:121`). +- **Concept introduced** - *guarding the guard: why a fitness function needs a floor.* The allow-list assertion is `offenders.Should().BeEmpty()` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/AnonymousEndpointTestsBase.cs:60`-`:62`), and an empty scan produces zero offenders, so the gate passes loudest exactly when it has stopped looking. A renamed assembly, a moved anchor type, or a repo re-layout all produce that failure mode, and none of them is visible in a green run. The base therefore ships a third fact, `ScannedEndpointSet_IsNotEmpty` (`:65`-`:76`), that counts the discovered controller and routable-component types and requires at least `MinimumScannedTypes`, whose default is 1 (`:51`). This fixture is what proves the counter is real: point it at an assembly that genuinely has neither shape and the fact must throw. Compare the equivalent floors elsewhere in the suite: `MinimumScannedTypes => 21` on [AnonymousEndpointTests](#anonymousendpointtests), `MinimumBaseResources => 3` on [LocalizationResourceTests](#localizationresourcetests), `MinimumRoutes = 8` in [NavigationContractTests](#navigationcontracttests). [Rubric §14 - Testability] assesses whether the guardrails are themselves trustworthy, and a vacuity floor is the cheapest thing that keeps one honest over a decade of refactors. +- **Walkthrough** - two overrides and an inline comment that states why the chosen assembly works: the Shared package has neither controllers nor routable components (`:119`). `TargetAssemblies` returns that one assembly (`:120`-`:121`), and `AllowedAnonymousEndpoints` is empty (`:123`), which is irrelevant here because nothing is discovered to compare against. Being `private` and nested is what keeps xUnit from collecting its three inherited facts as deliberately-red tests of their own (class doc, `:10`-`:11`). +- **Where it's used** - the subject of `Base_Fails_WhenNothingWasScanned` (`AnonymousEndpointTestsBaseTests.cs:37`-`:43`), which converts the inherited `ScannedEndpointSet_IsNotEmpty` into a delegate and asserts it throws (`:40`-`:42`). +- **Caveats / not-in-source** - that fact asserts only that *an* exception is thrown, not what its message says. It proves the floor bites; it does not pin the wording. + ### FakeDependentModule > MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/ModuleConformanceTestsBaseTests.cs:31` · Level 3 · class - **What it is** - the "hard" module fixture: a module that declares dependencies, refuses to start without them, and exports a disabled stub. It exercises every member of the module contract that a leaf module leaves at its default. @@ -742,13 +829,79 @@ Six tiny services in one file (`MMCA.Common/Tests/Architecture/MMCA.Common.Archi - **Why it's built this way** - this is the exact shape the three byte-identical consumer `{X}ModuleTests` files collapse into (class doc, `:12`-`:13`), so the leaf path is the most-travelled one and the one whose silent breakage would be widest. - **Where it's used** - the `TModule` of [FakeLeafModuleConformanceTests](#fakeleafmoduleconformancetests) (`:51`), which in turn is driven directly by two of [ModuleConformanceTestsBaseTests](#moduleconformancetestsbasetests)'s facts (`:112`-`:129`). +### FixtureCompliantV1, FixtureCompliantV2, FixtureCompliantV3, FixtureContestedV1, FixtureContestedV2, FixtureContestedV3, FixtureBackwardsV1, FixtureBackwardsV2 +> MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEvents` · (see per-type table) · Level 3 · record + +Eight throwaway integration-event contracts in one file (`MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs`), forming three groups: a compliant three-rung version ladder, a pair of rival successors to one contested source, and a backwards pair whose "successor" is older than its source. They are the data the two event-upcaster fitness rules are proven against. + +- **Depends on** - [BaseIntegrationEvent](group-04-events-outbox.md#baseintegrationevent) (every one of them derives from it, `EventUpcasterFixtures.cs:2`). Each is a one-parameter positional record carrying a single `string Sku`. +- **Concept introduced** - *`SchemaVersion` as the ordering a fixture set has to make visible.* [BaseIntegrationEvent](group-04-events-outbox.md#baseintegrationevent) declares `public virtual int SchemaVersion => 1` (`MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/BaseIntegrationEvent.cs:32`), so a contract that overrides nothing *is* version 1 and a successor states its own number ([ADR-010](https://ivanball.github.io/docs/adr/010-integration-event-schema-versioning.html)). That default is what lets these fixtures be as small as they are: the V1 of each group is a bare record and only the successors carry an override. The rule reads the property without running a constructor, through `RuntimeHelpers.GetUninitializedObject` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Upcasters.cs:103`), which is why a get-only virtual returning a literal is the required shape and why no event needs a parameterless factory just to be inspected. [Rubric §6 - CQRS & Event-Driven] assesses whether event contracts are versioned and governed rather than edited in place; [Rubric §9 - API & Contract Design] applies because a published event is a wire contract; [Rubric §14 - Testability] covers the fixtures. +- **Walkthrough** - the file's header doc records a subtlety worth internalizing (`:9`-`:13`): the contracts sit in a `*.IntegrationEvents` namespace so that the *residency* rule, which [EventScopeFitnessTests](#eventscopefitnesstests) exercises over this same assembly through a consumer-shaped map, stays satisfied. A fixture that broke a neighbouring rule to prove its own would be a poor fixture. They also never leave this test assembly, so no shipped event contract churns because of them. + +| Type | File:Line | SchemaVersion | Role | +|------|-----------|---------------|------| +| `FixtureCompliantV1` | `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:15` | 1 (inherited default) | First rung of the compliant ladder, and the source of exactly one upcaster. | +| `FixtureCompliantV2` | `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:18` | 2 (`:20`) | The middle rung: a target of one upcaster and the source of the next. That dual role is the whole point, since it is a chain rather than a duplicate claim. | +| `FixtureCompliantV3` | `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:24` | 3 (`:26`) | Terminal contract of the ladder. | +| `FixtureContestedV1` | `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:30` | 1 (inherited default) | The offending source: two upcasters both read it, which is what the unique-source rule must report. | +| `FixtureContestedV2` | `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:33` | 2 (`:35`) | One of the two rival successors. | +| `FixtureContestedV3` | `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:39` | 3 (`:41`) | The other rival successor. Both targets are legal on their own; the offence is the shared source. | +| `FixtureBackwardsV1` | `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:45` | 1 (inherited default) | The older contract of the backwards pair, used as the upcaster's *target*, which is the offence. | +| `FixtureBackwardsV2` | `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:48` | 2 (`:50`) | The newer contract, used as the upcaster's *source*. | + +- **Why they're built this way** - three groups because the two rules have three distinct outcomes to prove between them (clean, duplicate claim, wrong direction), and because a chain has to be distinguishable from a duplicate claim. Nothing but `Sku` on any of them: the payload is irrelevant to both rules, and every byte of fixture that is not load-bearing is a byte that can mislead a future reader about what is being tested. See [ADR-090](https://ivanball.github.io/docs/adr/090-event-upcaster-registration.html). +- **Where they're used** - the type arguments of the five fixture upcasters ([FixtureCompliantV1ToV2Upcaster and family](#fixturecompliantv1tov2upcaster-fixturecompliantv2tov3upcaster-fixturecontestedclaimupcaster-fixturerivalclaimupcaster-fixturebackwardsversionupcaster)), and named by `nameof` in the assertions of [EventUpcasterFitnessTests](#eventupcasterfitnesstests). + +### AnonymousEndpointTests +> MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTests.cs:14` · Level 4 · class +- **What it is** - the framework's own anonymous-endpoint allow-list: the live, reviewed statement of every controller action MMCA.Common ships without an authorization gate. Six entries today, all of them credential-exchange endpoints. +- **Depends on** - [AnonymousEndpointTestsBase](#anonymousendpointtestsbase) (`public sealed class AnonymousEndpointTests : AnonymousEndpointTestsBase`, `AnonymousEndpointTests.cs:14`), [ApiControllerBase](group-12-api-hosting-mapping.md#apicontrollerbase) as the anchor for the API assembly (`:18`), and [UISharedAssemblyReference](group-15-common-ui-framework.md#uisharedassemblyreference) as the anchor for the shared UI assembly (`:19`). +- **Concept introduced** - *an allow-list as a review artifact, not a suppression list.* The gate's value is not that it blocks `[AllowAnonymous]`; it is that adding one becomes a line in a test file with a comment next to it, landing in a diff that a human reads. Every entry here carries its justification inline, and each justification is the same argument: requiring a token would be circular, because minting or recovering the token is what the endpoint does. Login, register and refresh mint or rotate the token pair (`:24`-`:28`); the OAuth completion code is the caller's only credential at that point and is single-use, burned on first exchange (`:29`-`:31`); forgot-password and reset-password are for a caller who has lost the credential, are throttled by the same auth-ip rate-limit policy, and forgot-password always answers 202 so it reveals nothing about which addresses hold accounts (`:32`-`:34`). [Rubric §11 - Security] assesses the deliberateness of the authorization posture; note that the compensating control for every entry is rate limiting rather than authentication ([ADR-029](https://ivanball.github.io/docs/adr/029-authentication-brute-force-protection.html)). [Rubric §9 - API & Contract Design] applies because the anonymous surface is part of the published contract, and [Rubric §34 - Architecture Governance & Documentation] because the reasoning lives in compiled code rather than a wiki page. +- **Walkthrough** + - `TargetAssemblies` (`:16`-`:20`) names two assemblies by anchor type, so the scan covers both the controllers and the routable Blazor pages the framework ships. + - `AllowedAnonymousEndpoints` (`:22`-`:39`) is the six-entry list. Two of them are worth reading closely: ``PasswordResetAuthControllerBase`2.ForgotPasswordAsync`` and its reset sibling (`:37`-`:38`) carry a backtick-2 arity suffix, because [PasswordResetAuthControllerBase](group-12-api-hosting-mapping.md#passwordresetauthcontrollerbasetforgotpasswordcommand-tresetpasswordcommand) is generic over the app's command records and reflection renders a generic type's `FullName` that way. The comment above them says exactly that (`:35`-`:36`), which is the kind of note that saves the next person an hour. + - `MinimumScannedTypes => 21` (`:43`) raises the base's default floor of 1 to this repo's known count, described in the comment as a floor and not an equality: 12 API controller types plus the routable UI pages (`:41`-`:42`). Removing a scanned type is therefore a failure rather than a quietly smaller scan. +- **Why it's built this way** - the four endpoints that cannot require a token are exactly the ones an attacker reaches first, so the framework's position is that they must be enumerated, justified, and rate-limited rather than merely working. The base's own doc records the one thing this gate cannot see: minimal-API endpoints opt out through the `.AllowAnonymous()` builder call, which is endpoint metadata produced at map time and invisible to static reflection (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/AnonymousEndpointTestsBase.cs:18`-`:24`). The framework's minimal-API anonymous surface (JWKS, OIDC discovery, app-association, session-cookie refresh, health) is deliberate and small, but it is not covered here. +- **Where it's used** - collected and run by xUnit in the Common architecture suite; its three facts come entirely from the base. Consumers write their own subclass with their own list. +- **Caveats / not-in-source** - the minimal-API blind spot above is stated in the base's documentation, and closing it would need an endpoint-metadata check over a built host. Nothing in this class compensates for it. + +### AnonymousEndpointTestsBaseTests +> MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:13` · Level 4 · class +- **What it is** - the meta-test for the shipped anonymous-endpoint base: five facts proving each of its three assertions fails on the drift it claims to catch, that both allow-list identifier shapes are accepted, and that an inherited attribute is not double-reported on the derived controller. +- **Depends on** - [AnonymousEndpointTestsBase](#anonymousendpointtestsbase) (the type under test), its own nested fixtures ([AnonymousFixtureController and family](#abstractanonymousfixturecontrollerbase-anonymousfixturecontroller-typelevelanonymousfixturecontroller), [InheritingFixtureController](#inheritingfixturecontroller), [DriftedTests](#driftedtests), [StaleAllowListTests](#staleallowlisttests), [EmptyScanTests](#emptyscantests), [ConformantTests](#conformanttests)), plus xUnit `[Fact]` and AwesomeAssertions' delegate assertions. +- **Concept introduced** - cross-references the "test a shipped test base from the outside by invoking its facts as delegates" technique introduced by [ModuleConformanceTestsBaseTests](#moduleconformancetestsbasetests). What this class adds is a third assertion axis. The module base is proven by *failing* it three ways; this base also has to be proven to *accept* the two identifier shapes its allow-list is written in, because that is a data contract between the base's emitter and every consumer's hand-written list, and getting it wrong makes the gate fail in the one repo the framework's CI never runs. The class doc states both halves (`:8`-`:11`). [Rubric §14 - Testability] assesses whether guardrails are trustworthy; [Rubric §11 - Security] is the property at stake, since a broken anonymous-endpoint gate is a gate that stops noticing lost authorization; [Rubric §33 - Developer Experience] covers the failure mode, a break that would only appear downstream after a release ([ADR-058](https://ivanball.github.io/docs/adr/058-runtime-conformance-suites-as-a-package.html)). +- **Walkthrough** + - `Base_Fails_WhenAnAnonymousEndpointIsNotAllowListed` (`:15`-`:24`): drives [DriftedTests](#driftedtests) and asserts the thrown message names `AnonymousFixtureController` (`:22`), with the `because` stating the ruling, that the offender message must name the endpoint that lost its gate (`:23`). + - `Base_Fails_WhenTheAllowListHasAStaleEntry` (`:26`-`:35`): drives [StaleAllowListTests](#staleallowlisttests) and asserts the message contains `"NoLongerAnonymous"` (`:33`), the type name from an entry that matches nothing. + - `Base_Fails_WhenNothingWasScanned` (`:37`-`:43`): drives [EmptyScanTests](#emptyscantests) and asserts the non-vacuity fact throws (`:42`). + - `Base_Accepts_TypeLevelAndMethodLevelEntries` (`:45`-`:59`): the positive case. It calls all three of [ConformantTests](#conformanttests)'s inherited facts inside one delegate (`:50`-`:55`) and asserts `NotThrow` (`:57`-`:58`). Running all three together matters: the allow-list is only correct if it is simultaneously complete (no offenders) and exact (no stale entries). + - `Base_DoesNotReport_AnInheritedAttributeOnTheDerivedController` (`:61`-`:71`): reads the scan output through [ConformantTests](#conformanttests)'s `AnonymousEndpointsForTest()` and asserts the derived-controller identifier is absent (`:69`-`:70`). This is the only fact that inspects the emitted set rather than an assertion's outcome. +- **Why it's built this way** - the four drifted and conformant subclasses are all `private`, which is what keeps xUnit from collecting their inherited facts as deliberately-failing tests of their own (class doc, `:10`-`:11`). The base ships in the `MMCA.Common.Testing.Architecture` package, so proving it here is proving it before a release rather than after one. +- **Where it's used** - an independent class in the Common architecture suite. The base it protects is bound for real by [AnonymousEndpointTests](#anonymousendpointtests) here and by the equivalent subclass in each consumer repo. + +### ConformantTests +> MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:126` · Level 4 · class +- **What it is** - the positive fixture for the anonymous-endpoint gate: a subclass whose allow-list correctly names every anonymous endpoint in the fixture set, in both identifier shapes, so all three inherited facts must pass. +- **Depends on** - [AnonymousEndpointTestsBase](#anonymousendpointtestsbase) (`private sealed class ConformantTests : AnonymousEndpointTestsBase`, `AnonymousEndpointTestsBaseTests.cs:126`) and the three fixture controllers it allow-lists. +- **Concept introduced** - *building allow-list entries with `typeof(...).FullName` and `nameof(...)` rather than string literals.* Each of the three entries is interpolated from live metadata (`:133`-`:135`): `$"{typeof(AnonymousFixtureController).FullName}.{nameof(AnonymousFixtureController.PeekAsync)}"` for the method-level shape, the same construction against the abstract base for the inherited action, and a bare `typeof(TypeLevelAnonymousFixtureController).FullName!` for the type-level shape. Renaming any fixture is then a compile error instead of a silently-drifting test. That discipline is not available to a real consumer's list (the framework's endpoints are strings there, as in [AnonymousEndpointTests](#anonymousendpointtests)), which is precisely why the stale-entry fact exists to catch what `nameof` would have caught. [Rubric §15 - Best Practices & Code Quality] and [Rubric §14 - Testability] apply. +- **Walkthrough** - `TargetAssemblies` is this test assembly (`:128`-`:129`), so the scan sees the nested fixture controllers. `AllowedAnonymousEndpoints` (`:131`-`:136`) holds the three entries described above; note the abstract base is listed at *its own* name rather than the deriving controller's, which is the ruling [InheritingFixtureController](#inheritingfixturecontroller) pins. `MinimumScannedTypes` is left at the base's default of 1, which the fixture set clears comfortably. One member is added beyond the base's surface: `internal IReadOnlyCollection AnonymousEndpointsForTest() => [.. AnonymousEndpoints()];` (`:138`), which materializes the base's `protected` enumeration so the enclosing test class can assert on the emitted set directly. The base exposes `AnonymousEndpoints()` as `protected` for exactly this kind of extension (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/AnonymousEndpointTestsBase.cs:120`-`:125`). +- **Where it's used** - `Base_Accepts_TypeLevelAndMethodLevelEntries` (`AnonymousEndpointTestsBaseTests.cs:45`-`:59`) runs its three inherited facts, and `Base_DoesNotReport_AnInheritedAttributeOnTheDerivedController` (`:61`-`:71`) reads its `AnonymousEndpointsForTest()` output. + ### DriftedTests -> MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/ModuleConformanceTestsBaseTests.cs:131` · Level 4 · class -- **What it is** - the adversarial fixture: a conformance subclass whose three expectations are all deliberately wrong for the module it points at, so the base's assertions can be proven to actually fail on drift instead of passing regardless. -- **Depends on** - [ModuleConformanceTestsBase](#moduleconformancetestsbasetmodule) (`private sealed class DriftedTests : ModuleConformanceTestsBase`, `ModuleConformanceTestsBaseTests.cs:131`) and [FakeDependentModule](#fakedependentmodule) (the module under test). -- **Concept introduced** - *negative fixtures, and hiding them from test discovery.* A fitness base that asserts nothing passes everywhere; the only way to know an assertion bites is to feed it a case that must fail. But a public subclass of an xUnit base is itself collected: its inherited `[Fact]`s would run and report as three red tests. Declaring the drifted subclass `private` (nested inside [ModuleConformanceTestsBaseTests](#moduleconformancetestsbasetests)) keeps xUnit from collecting it, while the enclosing class can still instantiate it and invoke the inherited methods directly as delegates. [Rubric §14 - Testability] assesses whether the guardrails themselves are trustworthy; this is the fixture that earns that trust. Compare [NavigatingSpec](#navigatingspec), the same negative-fixture technique applied to a specification rule. -- **Walkthrough** - three overrides, each wrong in a different way against [FakeDependentModule](#fakedependentmodule)'s real declarations: `ExpectedName => "NotTheDeclaredName"` (`:133`) against the module's `"FakeDependent"`; `ExpectedDependencies => ["FakeLeaf"]` (`:135`) against the module's two-entry list, so one dependency is missing; and `ExpectedRequiresDependencies => false` (`:137`) against the module's `true`. `AssertDisabledStubs` is deliberately *not* overridden, so the fourth inherited fact stays vacuous here. -- **Where it's used** - instantiated three times by [ModuleConformanceTestsBaseTests](#moduleconformancetestsbasetests) (`:91`,`:99`,`:107`), once per assertion under proof. +> MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · (see per-type table) · Level 4 · class + +The name is used twice in this assembly, once per fitness base under proof, and both are the same idea: a `private sealed` subclass of a shipped `*TestsBase` whose configuration is deliberately wrong, so the base's assertions can be shown to actually fail on the drift they claim to catch. They are nested in different outer classes, so their full names differ and neither is visible outside its own file. + +- **Depends on** - the base each one drifts from, plus the fixture it points at: [ModuleConformanceTestsBase](#moduleconformancetestsbasetmodule) with [FakeDependentModule](#fakedependentmodule) for one, [AnonymousEndpointTestsBase](#anonymousendpointtestsbase) with the fixture controllers for the other. +- **Concept introduced** - *negative fixtures, and hiding them from test discovery.* A fitness base that asserts nothing passes everywhere; the only way to know an assertion bites is to feed it a case that must fail. But a public subclass of an xUnit base is itself collected, so its inherited `[Fact]`s would run and report as red tests of their own. Declaring the drifted subclass `private` and nested keeps xUnit from collecting it, while the enclosing class can still instantiate it and invoke the inherited methods directly as delegates (`var assert = new DriftedTests().Module_ShouldDeclare_ExpectedName;`, `ModuleConformanceTestsBaseTests.cs:91`). Both class docs record that reasoning in the same words (`ModuleConformanceTestsBaseTests.cs:83`-`:84`, `AnonymousEndpointTestsBaseTests.cs:10`-`:11`). [Rubric §14 - Testability] assesses whether the guardrails themselves are trustworthy; this is the fixture shape that earns that trust. Compare [NavigatingSpec](#navigatingspec), the same negative-fixture technique applied to a specification rule. + +| Type | File:Line | What is deliberately wrong | +|------|-----------|----------------------------| +| `DriftedTests` (in [AnonymousEndpointTestsBaseTests](#anonymousendpointtestsbasetests)) | `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:100` | Points `TargetAssemblies` at this test assembly (`:102`-`:103`), so the scan finds the fixture controllers' `[AllowAnonymous]` attributes, and then supplies an empty `AllowedAnonymousEndpoints` (`:105`). Every discovered endpoint is therefore an offender, and the failure message must name `AnonymousFixtureController`. | +| `DriftedTests` (in [ModuleConformanceTestsBaseTests](#moduleconformancetestsbasetests)) | `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/ModuleConformanceTestsBaseTests.cs:131` | Three overrides, each wrong in a different way against [FakeDependentModule](#fakedependentmodule)'s real declarations: `ExpectedName => "NotTheDeclaredName"` (`:133`) against the module's `"FakeDependent"`; `ExpectedDependencies => ["FakeLeaf"]` (`:135`) against the module's two-entry list, so one dependency is missing; and `ExpectedRequiresDependencies => false` (`:137`) against the module's `true`. `AssertDisabledStubs` is deliberately *not* overridden, so the fourth inherited fact stays vacuous here. | + +- **Why they're built this way** - one wrong value per assertion, and no more. If a single drifted subclass were wrong in three ways at once and the base only ever threw on the first, the other two assertions could rot undetected; the module-side fixture avoids that by being driven three separate times, once per fact. +- **Where they're used** - the anonymous-endpoint one drives `Base_Fails_WhenAnAnonymousEndpointIsNotAllowListed` (`AnonymousEndpointTestsBaseTests.cs:15`-`:24`); the module one is instantiated three times by [ModuleConformanceTestsBaseTests](#moduleconformancetestsbasetests) (`:91`,`:99`,`:107`), once per assertion under proof. ### FakeDependentModuleConformanceTests > MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/ModuleConformanceTestsBaseTests.cs:60` · Level 4 · class @@ -775,6 +928,34 @@ Six tiny services in one file (`MMCA.Common/Tests/Architecture/MMCA.Common.Archi - **Why it's built this way** - the fitness test must be *non-vacuous*: it needs a real cross-entity navigation to flag. A minimal principal with a single scalar is the smallest thing a specification can legally navigate into. - **Where it's used** - referenced by [FitnessDependent](#fitnessdependent) and, through it, by [NavigatingSpec](#navigatingspec) and [NavigatingQuerySpec](#navigatingqueryspec-scalaronlyqueryspec); the whole fixture set drives [SpecificationFitnessTests](#specificationfitnesstests). +### FixtureCompliantV1ToV2Upcaster, FixtureCompliantV2ToV3Upcaster, FixtureContestedClaimUpcaster, FixtureRivalClaimUpcaster, FixtureBackwardsVersionUpcaster +> MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEvents` · (see per-type table) · Level 4 · class + +Five `internal sealed` upcasters over the eight fixture contracts, each a single expression-bodied `Upcast` that copies the one field across. Between them they are the truth table for the two event-upcaster fitness rules ([ADR-090](https://ivanball.github.io/docs/adr/090-event-upcaster-registration.html)): two clean rungs of a chain, two rivals claiming one source, and one pointing backwards down the version ladder. + +- **Depends on** - [IEventUpcaster](group-05-cqrs-pipeline.md#ieventupcaster) in its two-parameter form (`EventUpcasterFixtures.cs:1`) and the [fixture event contracts](#fixturecompliantv1-fixturecompliantv2-fixturecompliantv3-fixturecontestedv1-fixturecontestedv2-fixturecontestedv3-fixturebackwardsv1-fixturebackwardsv2) they are generic over. +- **Concept introduced** - *the upcast chain must be a function, and it must run forwards.* When a retired contract arrives from the outbox, the consumer resolves the one upcaster registered for that type and replays the message as its successor. Two properties make that mechanical rather than lucky. First, at most one upcaster may read a given source contract: with two claimants, which one runs would depend on DI registration order, so `EventUpcastersHaveUniqueSourceTypes` groups by source and reports any group with more than one entry (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Upcasters.cs:14`-`:17`). Second, the target must declare a strictly higher `SchemaVersion` than the source, because an upcaster that moves sideways or down is not producing a successor at all and the chain stops being a ladder anyone can reason about; `EventUpcastersIncreaseSchemaVersion` compares the two declared versions and flags `targetVersion <= sourceVersion` (`:44`-`:48`). Note what the second rule deliberately does *not* do: a missing or non-`int` `SchemaVersion` is skipped rather than reported (`:37`-`:42`), because that is the business of the separate `IntegrationEventsDeclareSchemaVersion` rule. One rule, one judgement. Both rules find their subjects by matching the interface on name and arity, ``"IEventUpcaster`2"`` (`:81`-`:83`), which is how the rule library stays free of a compile dependency on the framework's own Application package. [Rubric §6 - CQRS & Event-Driven] assesses whether asynchronous contracts evolve safely; [Rubric §9 - API & Contract Design] covers the versioning discipline; [Rubric §29 - Resilience & Business Continuity] is the operational consequence, since an outbox row written against a retired contract has to be replayable weeks later. +- **Walkthrough** - every one of the five has the same body shape, `public {Target} Upcast({Source} integrationEvent) => new(integrationEvent.Sku);`, which is the typed overload declared by [IEventUpcaster](group-05-cqrs-pipeline.md#ieventupcaster) (`MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEventUpcaster.cs:82`); `SourceType` and `TargetType` come free as default interface implementations off the generic arguments (`:72`,`:75`), which is exactly what the rules read. + +| Type | File:Line | The case it models | +|------|-----------|--------------------| +| `FixtureCompliantV1ToV2Upcaster` | `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:54` | Clean: the only claimant on `FixtureCompliantV1`, and its target declares version 2. Must be absent from both rules' reports. | +| `FixtureCompliantV2ToV3Upcaster` | `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:60` | The second rung, and the sharper of the two clean cases: its source is the previous upcaster's *target*. That is a chain, not a duplicate claim, and a naive implementation that grouped on "types that appear in more than one upcaster" would wrongly flag it. It gets its own dedicated fact. | +| `FixtureContestedClaimUpcaster` | `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:66` | Offender: reads `FixtureContestedV1` and produces V2. Legal in isolation. | +| `FixtureRivalClaimUpcaster` | `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:72` | The second claimant on the same `FixtureContestedV1`, producing V3 instead. The offence is the pair, so the rule's message must name the contested contract *and* both upcasters. | +| `FixtureBackwardsVersionUpcaster` | `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:78` | Offender for the other rule: source `FixtureBackwardsV2` (version 2), target `FixtureBackwardsV1` (version 1). It is the only claimant on its source, so it passes the unique-source rule and fails the version rule, which is what keeps the two rules' proofs independent. | + +- **Why they're built this way** - the two rules judge different things about the same set of types, so the fixture set is designed so that each offender trips exactly one of them. If the backwards upcaster also shared a source, a failure in the unique-source rule would mask a regression in the version rule. Being `internal` keeps them off the assembly's public surface while still visible to `ConcreteClasses`, which is what the rule enumerates (`ArchitectureRules.Upcasters.cs:67`). +- **Where they're used** - reflected over by [EventUpcasterFitnessTests](#eventupcasterfitnesstests) through [UpcasterTestMap](#upcastertestmap), and named by `nameof` in its assertions. + +### StaleAllowListTests +> MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:108` · Level 4 · class +- **What it is** - the adversarial fixture for the stale-entry half of the anonymous-endpoint gate: a subclass whose allow-list names an endpoint that does not exist, so the base's `AllowList_HasNoStaleEntries` fact must report it rather than shrug. +- **Depends on** - [AnonymousEndpointTestsBase](#anonymousendpointtestsbase) (`private sealed class StaleAllowListTests : AnonymousEndpointTestsBase`, `AnonymousEndpointTestsBaseTests.cs:108`). +- **Concept introduced** - *an allow-list rots in two directions, and only one of them is obvious.* A missing entry fails loudly the moment a new `[AllowAnonymous]` lands. A *stale* entry fails silently and permanently: the endpoint gets renamed, re-gated with `[Authorize]`, or deleted, and the list keeps granting a permission that is no longer being requested. Nothing breaks, so nobody looks, and the next endpoint that happens to match that identifier inherits an approval nobody granted it. The base therefore runs the comparison both ways, computing `stale` as the allow-list entries with no match in the scanned set (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/AnonymousEndpointTestsBase.cs:83`-`:89`), with a `because` that spells out the consequence: an entry that no longer matches hides a renamed or re-gated endpoint behind a permission that is no longer being granted (`:88`). [Rubric §11 - Security] is the property; [Rubric §16 - Maintainability] is the mechanism, since a self-pruning list is one that stays readable. +- **Walkthrough** - `TargetAssemblies` is this test assembly (`:110`-`:111`), so the fixture controllers are discovered normally. `AllowedAnonymousEndpoints` holds exactly one entry, `"MMCA.Common.Architecture.Tests.NoLongerAnonymousController.ReadAsync"` (`:113`-`:114`), naming a controller that has never existed. That makes the fixture fail *both* facts at once (every real fixture endpoint is now an unlisted offender as well), which is harmless because the fact under proof invokes only `AllowList_HasNoStaleEntries` as a delegate. +- **Where it's used** - the subject of `Base_Fails_WhenTheAllowListHasAStaleEntry` (`AnonymousEndpointTestsBaseTests.cs:26`-`:35`), which asserts the message contains `"NoLongerAnonymous"` (`:33`), the distinctive fragment of the phantom identifier. + ### CancellationTestMap > MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/CancellationTokenFitnessTests.cs:63` · Level 5 · class - **What it is** - a one-layer architecture map used only by [CancellationTokenFitnessTests](#cancellationtokenfitnesstests): it registers this test assembly as the map's single Application layer so the cancellation-token rule scans the fixture services and nothing else. @@ -941,24 +1122,6 @@ The same unsafe/safe pair as [NavigatingSpec](#navigatingspec) and [ScalarOnlySp - **Walkthrough** - one overridden `Criteria` filtering on `PrincipalId` and `Flag` (`:71`). The test asserts the rule's exception message does *not* contain this type's name. - **Where it's used** - the "should not be flagged" input to [SpecificationFitnessTests](#specificationfitnesstests). -### CommonArchitectureMap -> MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/CommonArchitectureMap.cs:15` · Level 7 · class -- **What it is** - the architecture map for the MMCA.Common framework: it names each package's layer and pins the layer to a concrete assembly, so the shared rule library knows which assembly is Shared, Domain, Application, and so on for this repo. -- **Depends on** - [ArchitectureMapBase](#architecturemapbase) (`internal sealed class CommonArchitectureMap : ArchitectureMapBase`, `CommonArchitectureMap.cs:15`), the [Layer](#layer) enum, [LayerRef](#layerref), and one anchor type per package (`Result`, `BaseEntity<>`, `DomainEventDispatcher`, `ApplicationDbContext`, `ApiControllerBase`, `ResultGrpcExtensions`, `UISharedAssemblyReference`, `:21`-`:27`). -- **Concept introduced** - *the map as the single point of repo-specific truth for architecture rules.* The rule bodies live once in `MMCA.Common.Testing.Architecture` and are parameterized by an [IArchitectureMap](#iarchitecturemap); each repo supplies exactly one map so the same rules run identically across Common, Store, and ADC. Because Common is a module-less framework, every layer is registered as a *framework* layer via the `Framework(...)` helper rather than a module layer, and that distinction is load-bearing well beyond bookkeeping: several rules branch on `map.ModuleNames.Count` to decide whether they are judging a framework or a consumer (see [EventScopeFitnessTests](#eventscopefitnesstests)). [Rubric §3 - Clean Architecture] assesses whether layer boundaries are explicit and enforced; this map is the machine-readable statement of those boundaries. -- **Walkthrough** - `RepoToken => "MMCA.Common"` (`:17`) identifies the repo and is what the source-scanning rules use to locate the repo root (they look for `{RepoToken}.slnx`). `DefineLayers()` (`:19`-`:28`) returns one `Framework(Layer.X, anchorType.Assembly)` entry per package, using a single anchor type to resolve each assembly (mirrors the old `PackageAssemblies` helper): Shared, Domain, Application, Infrastructure, Api, Grpc, and Ui (`:21`-`:27`). The doc comment (`:8`-`:13`) records a deliberate omission: `MMCA.Common.UI.Maui` ([ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html)) is absent because its four MAUI TFM assemblies cannot load in the ubuntu net10.0 test process, so its UI-plus-Shared boundary is enforced at compile time by `EnforceUIMauiLayerBoundary` in `Source/Build/MMCA.Common.LayerEnforcement.targets` and the windows `build-maui` CI job instead. -- **Why it's built this way** - one map per repo keeps the rule bodies DRY and identical everywhere (see the "Architecture Enforcement" section in `MMCA.Common/CLAUDE.md`); anchoring by type keeps the assembly reference refactor-safe. -- **Where it's used** - supplied as `Map` by every thin `*ConventionTests` subclass in this unit, used directly by [EventScopeFitnessTests](#eventscopefitnesstests) as the module-less contrast case (`EventScopeFitnessTests.cs:39`), and is the pattern the single-layer fitness maps ([SpecTestMap](#spectestmap), [IdempotencyTestMap](#idempotencytestmap), [CancellationTestMap](#cancellationtestmap), [CycleTestMap](#cycletestmap)) collapse. - -### FrameworkSanityTests -> MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/FrameworkSanityTests.cs:13` · Level 7 · class -- **What it is** - the home for the few architecture checks that are Common-only and do not generalize into the shared rule library: the `MMCA.Common.Grpc` transport boundary and the placement of the `IMessageBus`, `IJwksProvider`, and `ILiveChannelPublisher` abstractions. -- **Depends on** - [IMessageBus](group-04-events-outbox.md#imessagebus), [IJwksProvider](group-08-auth.md#ijwksprovider), [ILiveChannelPublisher](group-10-notifications.md#ilivechannelpublisher), and the NetArchTest `Types` query API routed through [ArchitectureAssert](#architectureassert). -- **Concept introduced** - *repo-specific sanity next to the shared library.* Not every rule fits the parameterized base classes; some assert facts true only of the framework repo. Keeping them in one explicitly-named class documents the boundary between "shared rule applied here" and "Common-only invariant." [Rubric §7 - Microservices Readiness] (transport isolation) and [Rubric §3 - Clean Architecture] (abstraction placement) both apply: gRPC is pure transport and must not couple to Domain, Application, or Infrastructure, and the cross-cutting abstractions must sit in the layer their consumers depend on. -- **Walkthrough** - three private static `Assembly` accessors anchor the Grpc, Application, and Infrastructure assemblies by an anchor type each (`:15`-`:19`). Three `[Fact]`s assert `MMCA.Common.Grpc` has no dependency on Domain, Application, or Infrastructure (`:21`-`:34`) via the `AssertNoDependency` helper (`:51`-`:59`), which runs a `Types.InAssembly(...).ShouldNot().HaveDependencyOnAny(...)` NetArchTest query and routes the result through `ArchitectureAssert.NoViolations` (`:58`). Three more `[Fact]`s assert placement by comparing the abstraction's declaring assembly against the anchored layer assembly: `IMessageBus` lives in Application (`:36`-`:39`), `IJwksProvider` in Infrastructure because it handles crypto and PEM material (`:41`-`:44`), and `ILiveChannelPublisher` in Application beside `IPushNotificationSender` (`:46`-`:49`). -- **Why it's built this way** - the message-bus abstraction must stay in Application so application code depends on transport through it (extraction boundary, [ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html)); the JWKS provider is crypto and belongs in Infrastructure ([ADR-004](https://ivanball.github.io/docs/adr/004-authentication-dual-fetch.html)). These are load-bearing placements, so they get their own asserted facts. -- **Where it's used** - an independent class in the Common architecture suite; it has no counterpart in Store or ADC because only Common owns the Grpc package and defines these abstractions. - ### SpecificationFitnessTests > MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/SpecificationFitnessTests.cs:13` · Level 7 · class - **What it is** - the test that verifies the `SpecificationsDoNotNavigateToOtherEntities` fitness function actually discriminates: it must flag a specification that navigates into another entity, must leave a scalar-only specification alone, and must reach both shapes through the `QuerySpecification` subclass as well. @@ -976,8 +1139,26 @@ The same unsafe/safe pair as [NavigatingSpec](#navigatingspec) and [ScalarOnlySp - **Walkthrough** - `RepoToken => "MMCA.Common"` (`:42`) and a one-entry `DefineLayers()` (`:44`-`:45`) pointing at this assembly. - **Where it's used** - instantiated once per fact inside [SpecificationFitnessTests](#specificationfitnesstests) (`:18`,`:29`). +### CommonArchitectureMap +> MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/CommonArchitectureMap.cs:15` · Level 12 · class +- **What it is** - the architecture map for the MMCA.Common framework: it names each package's layer and pins the layer to a concrete assembly, so the shared rule library knows which assembly is Shared, Domain, Application, and so on for this repo. +- **Depends on** - [ArchitectureMapBase](#architecturemapbase) (`internal sealed class CommonArchitectureMap : ArchitectureMapBase`, `CommonArchitectureMap.cs:15`), the [Layer](#layer) enum, [LayerRef](#layerref), and one anchor type per package (`Result`, `BaseEntity<>`, `DomainEventDispatcher`, `ApplicationDbContext`, `ApiControllerBase`, `ResultGrpcExtensions`, `UISharedAssemblyReference`, `:21`-`:27`). +- **Concept introduced** - *the map as the single point of repo-specific truth for architecture rules.* The rule bodies live once in `MMCA.Common.Testing.Architecture` and are parameterized by an [IArchitectureMap](#iarchitecturemap); each repo supplies exactly one map so the same rules run identically across Common, Store, and ADC. Because Common is a module-less framework, every layer is registered as a *framework* layer via the `Framework(...)` helper rather than a module layer, and that distinction is load-bearing well beyond bookkeeping: several rules branch on `map.ModuleNames.Count` to decide whether they are judging a framework or a consumer (see [EventScopeFitnessTests](#eventscopefitnesstests)). [Rubric §3 - Clean Architecture] assesses whether layer boundaries are explicit and enforced; this map is the machine-readable statement of those boundaries. +- **Walkthrough** - `RepoToken => "MMCA.Common"` (`:17`) identifies the repo and is what the source-scanning rules use to locate the repo root (they look for `{RepoToken}.slnx`). `DefineLayers()` (`:19`-`:28`) returns one `Framework(Layer.X, anchorType.Assembly)` entry per package, using a single anchor type to resolve each assembly (mirrors the old `PackageAssemblies` helper): Shared, Domain, Application, Infrastructure, Api, Grpc, and Ui (`:21`-`:27`). The doc comment (`:8`-`:13`) records a deliberate omission: `MMCA.Common.UI.Maui` ([ADR-042](https://ivanball.github.io/docs/adr/042-device-capability-abstraction.html)) is absent because its four MAUI TFM assemblies cannot load in the ubuntu net10.0 test process, so its UI-plus-Shared boundary is enforced at compile time by `EnforceUIMauiLayerBoundary` in `Source/Build/MMCA.Common.LayerEnforcement.targets` and the windows `build-maui` CI job instead. +- **Why it's built this way** - one map per repo keeps the rule bodies DRY and identical everywhere (see the "Architecture Enforcement" section in `MMCA.Common/CLAUDE.md`); anchoring by type keeps the assembly reference refactor-safe. +- **Where it's used** - supplied as `Map` by every thin `*ConventionTests` subclass in this unit, used directly by [EventScopeFitnessTests](#eventscopefitnesstests) as the module-less contrast case (`EventScopeFitnessTests.cs:39`), and is the pattern the single-layer fitness maps ([SpecTestMap](#spectestmap), [IdempotencyTestMap](#idempotencytestmap), [CancellationTestMap](#cancellationtestmap), [CycleTestMap](#cycletestmap)) collapse. + +### FrameworkSanityTests +> MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/FrameworkSanityTests.cs:13` · Level 12 · class +- **What it is** - the home for the few architecture checks that are Common-only and do not generalize into the shared rule library: the `MMCA.Common.Grpc` transport boundary and the placement of the `IMessageBus`, `IJwksProvider`, and `ILiveChannelPublisher` abstractions. +- **Depends on** - [IMessageBus](group-04-events-outbox.md#imessagebus), [IJwksProvider](group-08-auth.md#ijwksprovider), [ILiveChannelPublisher](group-10-notifications.md#ilivechannelpublisher), and the NetArchTest `Types` query API routed through [ArchitectureAssert](#architectureassert). +- **Concept introduced** - *repo-specific sanity next to the shared library.* Not every rule fits the parameterized base classes; some assert facts true only of the framework repo. Keeping them in one explicitly-named class documents the boundary between "shared rule applied here" and "Common-only invariant." [Rubric §7 - Microservices Readiness] (transport isolation) and [Rubric §3 - Clean Architecture] (abstraction placement) both apply: gRPC is pure transport and must not couple to Domain, Application, or Infrastructure, and the cross-cutting abstractions must sit in the layer their consumers depend on. +- **Walkthrough** - three private static `Assembly` accessors anchor the Grpc, Application, and Infrastructure assemblies by an anchor type each (`:15`-`:19`). Three `[Fact]`s assert `MMCA.Common.Grpc` has no dependency on Domain, Application, or Infrastructure (`:21`-`:34`) via the `AssertNoDependency` helper (`:51`-`:59`), which runs a `Types.InAssembly(...).ShouldNot().HaveDependencyOnAny(...)` NetArchTest query and routes the result through `ArchitectureAssert.NoViolations` (`:58`). Three more `[Fact]`s assert placement by comparing the abstraction's declaring assembly against the anchored layer assembly: `IMessageBus` lives in Application (`:36`-`:39`), `IJwksProvider` in Infrastructure because it handles crypto and PEM material (`:41`-`:44`), and `ILiveChannelPublisher` in Application beside `IPushNotificationSender` (`:46`-`:49`). +- **Why it's built this way** - the message-bus abstraction must stay in Application so application code depends on transport through it (extraction boundary, [ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html)); the JWKS provider is crypto and belongs in Infrastructure ([ADR-004](https://ivanball.github.io/docs/adr/004-authentication-dual-fetch.html)). These are load-bearing placements, so they get their own asserted facts. +- **Where it's used** - an independent class in the Common architecture suite; it has no counterpart in Store or ADC because only Common owns the Grpc package and defines these abstractions. + ### AggregateConventionTests, CancellationTokenConventionTests, DomainPurityTests, EventVersioningConventionTests, HandlerResultConventionTests, IdempotencyConventionTests, LayerDependencyTests, LocalizedTextConventionTests, MicroserviceExtractionTests, NamespaceCycleTests, PiiConventionTests, RawQueryableConventionTests, SliceCohesionTests, StateManagementConventionTests, UIArchitectureConventionTests -> MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · (see per-type table) · Level 8 · class +> MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · (see per-type table) · Level 13 · class These fifteen sealed classes share one shape: each is a **thin subclass of a shared `*TestsBase` rule** from the `MMCA.Common.Testing.Architecture` package, supplying the repo's [CommonArchitectureMap](#commonarchitecturemap) (and, for a few, one extra override) so the same rule body runs identically across MMCA.Common, MMCA.Store, and MMCA.ADC. This is the [Rubric §34 - Architecture Governance & Documentation] and [Rubric §14 - Testability] story: architecture conventions are executable and enforced in CI rather than left to review, and the rule logic lives in exactly one place ([ADR-015](https://ivanball.github.io/docs/adr/015-architecture-fitness-functions.html)). See the thin-subclass pattern introduced by [DependencyVersionTests](#dependencyversiontests). The canonical body of each rule is the corresponding `*TestsBase`; these subclasses only wire in the map and any repo-specific floor or allowlist. Each fails the `build-and-test` CI job on violation, and a couple are deliberately *vacuous today* (they assert nothing until the framework grows a type that could break the convention, at which point they fire). @@ -1006,7 +1187,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - **Caveats / not-in-source** - the per-rule fact counts live in each `*TestsBase`, not in these subclasses; the base sections elsewhere in this chapter are the authority on exactly what each rule asserts. ### EventScopeFitnessTests -> MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/EventScopeFitnessTests.cs:13` · Level 8 · class +> MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/EventScopeFitnessTests.cs:13` · Level 13 · class - **What it is** - the ownership-scoping guard for the integration-event rules: three facts pinning that a consumer-shaped map neither snapshots nor polices the framework's own events, while the framework's module-less map still covers them at the source. - **Depends on** - [ArchitectureRules](#architecturerules) (`BuildIntegrationEventContract` and `IntegrationEventsResideInSharedIntegrationEventsNamespace`, `EventScopeFitnessTests.cs:18`,`:30`), [CommonArchitectureMap](#commonarchitecturemap) (`:39`), its own [FakeConsumerMap](#fakeconsumermap), and [BaseIntegrationEvent](group-04-events-outbox.md#baseintegrationevent) as the Domain-assembly anchor (`:1`,`:56`). - **Concept introduced** - *a rule's scope is part of its contract, and ownership decides it.* Both event rules ask the same question of a map, and both answer differently depending on whether the map declares modules. `IntegrationEvents` includes framework layers only when `map.ModuleNames.Count == 0` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Events.cs:68`-`:73`), and the residency rule enforces the Shared-layer requirement only when the map is module-bearing (`:31`,`:33`). The reasoning is ownership: a framework-shipped event is the framework's contract, gated by the framework's own conventions and public-API baseline, so a consumer's frozen snapshot must neither churn on it nor claim jurisdiction over where it lives. This class is a *regression* guard, and the class doc names the incident that motivated it (`:6`-`:12`): when the framework shipped its first concrete integration event, [OutputCacheEvictionRequested](group-04-events-outbox.md#outputcacheevictionrequested), the Helpdesk canary broke. [Rubric §6 - CQRS & Event-Driven] assesses whether event contracts are governed; [Rubric §9 - API & Contract Design] covers the frozen-snapshot mechanism; [Rubric §33 - Developer Experience] is the failure mode being prevented, a framework release that reds every consumer's architecture suite for a change they did not make. @@ -1017,40 +1198,72 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - **Why it's built this way** - a scoping fix is exactly the kind of change that silently over-corrects. Pinning both the exclusion and the retention, against a real framework event rather than a fixture one, is what keeps the fix from becoming a hole. - **Where it's used** - an independent class in the Common architecture suite; the rules it scopes are bound for real by `EventVersioningConventionTests` here and by the per-repo `IntegrationEventContractTestsBase` subclasses in the consumers. +### EventUpcasterFitnessTests +> MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/EventUpcasterFitnessTests.cs:12` · Level 13 · class +- **What it is** - the meta-test for the two event-upcaster fitness rules: four facts proving the unique-source rule reports a contract claimed twice, that it leaves a legitimate chain alone, that the version rule reports an upcaster pointing at a lower `SchemaVersion`, and that both rules pass on a map owning no upcasters at all. +- **Depends on** - [ArchitectureRules](#architecturerules) (`EventUpcastersHaveUniqueSourceTypes` and `EventUpcastersIncreaseSchemaVersion`, `EventUpcasterFitnessTests.cs:53`,`:55`,`:61`,`:68`), the [fixture contracts](#fixturecompliantv1-fixturecompliantv2-fixturecompliantv3-fixturecontestedv1-fixturecontestedv2-fixturecontestedv3-fixturebackwardsv1-fixturebackwardsv2) and [fixture upcasters](#fixturecompliantv1tov2upcaster-fixturecompliantv2tov3upcaster-fixturecontestedclaimupcaster-fixturerivalclaimupcaster-fixturebackwardsversionupcaster) (`:1`), its nested [UpcasterTestMap](#upcastertestmap), [CommonArchitectureMap](#commonarchitecturemap) (`:51`), plus xUnit and AwesomeAssertions. +- **Concept introduced** - *proving a rule is not vacuous when the framework itself has nothing to judge.* MMCA.Common ships no upcaster of its own, so running either rule over the real [CommonArchitectureMap](#commonarchitecturemap) proves only that it does not crash. That is a real property worth pinning (a rule that threw on an empty set would red every consumer that has not adopted upcasting yet), and the fourth fact pins exactly it. But the other three facts have to manufacture their subjects, which is what the `UpcasterFixtures` namespace and the private map are for. The pattern to take away: a fitness function for a *consumer-facing* convention is proven in the framework repo against fixtures, and only smoke-tested against the framework's own code. [ProtoContractFitnessTests](#protocontractfitnesstests) and [ServiceContractPurityTests](#servicecontractpuritytests) sit in the same position for their rules. [Rubric §6 - CQRS & Event-Driven] and [Rubric §9 - API & Contract Design] are the properties defended; [Rubric §14 - Testability] is the technique. +- **Walkthrough** - two private helpers each run one rule against a fresh [UpcasterTestMap](#upcastertestmap) and return the thrown message: `RunUniqueSourceRule` (`:59`-`:64`) and `RunSchemaVersionRule` (`:66`-`:71`). Because the fixtures always contain an offender for each rule, both helpers can assert `Should().Throw().Which.Message` unconditionally and let the facts make positive and negative `Contain` assertions against the one string. + - `UniqueSourceRule_FlagsTheContestedContract_ButNotTheCompliantLadder` (`:14`-`:25`): asserts the message names the contested contract *and* both rival upcasters (`:19`-`:21`), which is what makes the failure actionable, and that the compliant first rung is absent (`:22`-`:24`). + - `UniqueSourceRule_DoesNotFlag_AnUpcasterWhoseSourceIsAnotherUpcastersTarget` (`:31`-`:33`): the sharp one, given its own fact and its own doc comment (`:27`-`:30`). The compliant ladder's middle contract is both a target and a source; that is a chain, not a duplicate claim, and the rule must leave it alone. + - `SchemaVersionRule_FlagsTheBackwardsUpcaster_ButNotTheCompliantLadder` (`:35`-`:46`): names the offender (`:40`) and, notably, pins the message text `"must declare a HIGHER SchemaVersion"` (`:41`-`:43`), because the wording is what tells a developer which direction is allowed. Both clean rungs are asserted absent (`:44`-`:45`). + - `BothRules_Pass_OnAMapThatOwnsNoUpcasters` (`:48`-`:57`): runs both rules against the real framework map and asserts `NotThrow`, with the `because` stating that the framework ships no upcaster so the rule passes vacuously (`:54`). +- **Why it's built this way** - the two rules exist because an upcast chain that is not a function, or that runs backwards, produces a bug that only appears when an old outbox row is replayed, potentially long after the change that caused it ([ADR-090](https://ivanball.github.io/docs/adr/090-event-upcaster-registration.html), building on [ADR-010](https://ivanball.github.io/docs/adr/010-integration-event-schema-versioning.html)). Catching it at build time is worth a fixture namespace. +- **Where it's used** - an independent class in the Common architecture suite. The shipped binding of both rules is [EventConventionTestsBase](#eventconventiontestsbase), whose two facts call them for every repo (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/EventConventionTestsBase.cs:23`,`:26`), and which MMCA.Common activates through [EventVersioningConventionTests](#aggregateconventiontests-cancellationtokenconventiontests-domainpuritytests-eventversioningconventiontests-handlerresultconventiontests-idempotencyconventiontests-layerdependencytests-localizedtextconventiontests-microserviceextractiontests-namespacecycletests-piiconventiontests-rawqueryableconventiontests-slicecohesiontests-statemanagementconventiontests-uiarchitectureconventiontests). + ### FakeConsumerMap -> MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/EventScopeFitnessTests.cs:50` · Level 8 · class +> MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/EventScopeFitnessTests.cs:50` · Level 13 · class - **What it is** - a consumer-shaped architecture map: the framework's Domain assembly registered as a framework layer plus one *module* layer, which is the minimum that makes a map module-bearing. - **Depends on** - [ArchitectureMapBase](#architecturemapbase) (`private sealed class FakeConsumerMap : ArchitectureMapBase`, `EventScopeFitnessTests.cs:50`), [LayerRef](#layerref), the [Layer](#layer) enum, and [BaseIntegrationEvent](group-04-events-outbox.md#baseintegrationevent) as the anchor for the framework Domain assembly (`:56`). - **Concept introduced** - *the `Module(...)` factory, and what a single module entry changes.* Every other map in this chapter uses only `Framework(...)`, whose `LayerRef.Module` is the empty string (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureMapBase.cs:94`-`:95`). `Module(name, layer, assembly)` sets it to the module name and derives the root namespace as `{RepoToken}.{module}.{segment}` (`:98`-`:99`), and `ModuleNames` is computed from exactly those non-empty entries (`:27`-`:32`). One module layer is therefore all it takes to flip a map from "framework" to "consumer" for every rule that branches on ownership. Building that shape from the framework's own assemblies, rather than referencing a real consumer, keeps the regression guard inside MMCA.Common's CI where it can run before a release. - **Walkthrough** - `RepoToken => "MMCA.FakeConsumer"` (`:52`), deliberately not `MMCA.Common`, so the derived module namespace looks like a consumer's. `DefineLayers()` (`:54`-`:58`) is a `yield`-based iterator returning two entries: `Framework(Layer.Domain, typeof(BaseIntegrationEvent).Assembly)` (`:56`), which brings the framework's real integration event into the map, and `Module("Fake", Layer.Shared, typeof(EventScopeFitnessTests).Assembly)` (`:57`), a stand-in module Shared layer that ships no integration events of its own (class doc, `:46`-`:49`). That combination is the exact situation the guard exists for: a consumer whose map can *see* a framework event but does not own it. - **Where it's used** - the input to the first two facts of [EventScopeFitnessTests](#eventscopefitnesstests) (`:18`,`:31`). +### ServiceContractPurityTests +> MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/ServiceContractPurityTests.cs:11` · Level 13 · class +- **What it is** - the MMCA.Common binding of the `[ServiceContract]` purity rule: a two-line subclass that supplies the repo's map so any type marked as part of a published wire surface is checked for dependencies on the producing service's Domain, Application or Infrastructure. +- **Depends on** - [ServiceContractPurityTestsBase](#servicecontractpuritytestsbase) (`public sealed class ServiceContractPurityTests : ServiceContractPurityTestsBase`, `ServiceContractPurityTests.cs:11`), [IArchitectureMap](#iarchitecturemap) and [CommonArchitectureMap](#commonarchitecturemap) (the single override, `:13`), and indirectly [ServiceContractAttribute](group-13-grpc-contracts.md#servicecontractattribute), which the rule matches by full name rather than by reference. +- **Concept introduced** - *a rule that is deliberately vacuous today, kept as a ratchet.* Most gates in this chapter earn their place by failing on real code. This one asserts nothing in MMCA.Common, because the framework marks no type with `[ServiceContract]`, and both the subclass doc (`:9`-`:10`) and the base's remarks (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/ServiceContractPurityTestsBase.cs:12`-`:17`) say so plainly. The value is in the timing: the invariant is enforced from the *first* marked type onward, with no test for anyone to remember to write, at the moment when a contract package's shape is still cheap to change. Two design choices follow from that. It is attribute-driven rather than [Layer](#layer)`.Contracts`-driven, because no repo registers that layer today and a layer-iterating rule would pass vacuously forever (base remarks, `:9`-`:11`); and it scans every assembly the map registers, so a marked type is judged wherever it lives. A marked type sitting *inside* a Domain, Application or Infrastructure assembly then fails by construction, which the rule's own remarks call the intent: a published contract belongs in a `*.Contracts` or Shared assembly, not inside the service it describes (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Contracts.cs:26`-`:29`). [Rubric §7 - Microservices Readiness] is the property: a contract that leaks a domain entity, a handler abstraction or a persistence type forces every consumer to take the producer's internals as a package dependency, which is what makes an extraction irreversible (`:16`-`:18`). [Rubric §9 - API & Contract Design] covers the wire surface itself, and [Rubric §34 - Architecture Governance & Documentation] the ratchet. +- **Walkthrough** - one member, `protected override IArchitectureMap Map { get; } = new CommonArchitectureMap();` (`:13`). Everything else is inherited: the base contributes a single `[Fact]`, `ServiceContracts_ShouldNotDependOn_ServiceInternals` (`ServiceContractPurityTestsBase.cs:24`-`:26`), which calls `ArchitectureRules.ServiceContractsDoNotDependOnServiceInternals(Map)`. The rule first derives the forbidden namespace set from the map's Domain, Application and Infrastructure layers (`ArchitectureRules.Contracts.cs:57`-`:66`) and returns immediately when that set is empty (`:35`-`:38`), then walks every layer, selects marked types through the Mono.Cecil custom rule `CarriesServiceContractAttribute` (`:44`,`:69`-`:74`), and asserts `ShouldNot().HaveDependencyOnAny(forbidden)` per layer with the layer's root namespace in the message (`:42`-`:52`). +- **Why it's built this way** - matching the marker by its full-name string, `"MMCA.Common.Shared.Abstractions.ServiceContractAttribute"` (`ArchitectureRules.Contracts.cs:10`-`:11`), is the same zero-reference idiom the rest of the rule library uses: the testing package deliberately takes no compile dependency on the framework assemblies it inspects. The rule complements, and does not replace, the transport- and layer-purity rules that guard the same boundary from the layer side ([ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html), [ADR-015](https://ivanball.github.io/docs/adr/015-architecture-fitness-functions.html)). +- **Where it's used** - run by the `MMCA.Common.Architecture.Tests` suite in CI's `build-and-test` job. Its sibling in this chapter is [ProtoContractFitnessTests](#protocontractfitnesstests), the other consumer-facing contract gate the framework exercises without owning any subject of its own. +- **Caveats / not-in-source** - because the framework marks no type, this class asserts nothing today; there is no fixture proving the rule fires. That proof exists only in whichever repo first marks a type. + +### UpcasterTestMap +> MMCA.Common.Architecture.Tests · `MMCA.Common.Architecture.Tests` · `MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/EventUpcasterFitnessTests.cs:74` · Level 13 · class +- **What it is** - a one-layer architecture map used only by [EventUpcasterFitnessTests](#eventupcasterfitnesstests): it registers this test assembly as the map's single Application layer so the two upcaster rules see the fixture upcasters and nothing else. +- **Depends on** - [ArchitectureMapBase](#architecturemapbase) (`private sealed class UpcasterTestMap : ArchitectureMapBase`, `EventUpcasterFitnessTests.cs:74`), [LayerRef](#layerref), and the [Layer](#layer) enum. +- **Concept** - cross-references the map concept from [CommonArchitectureMap](#commonarchitecturemap), with one detail specific to these rules. Both of them scope by ownership exactly as the integration-event rules do: `EventUpcasters` includes framework layers only when the map declares no modules (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Upcasters.cs:62`-`:64`). Because this map is built entirely from `Framework(...)` entries, `ModuleNames` is empty and the single Application layer is scanned, which is what puts the fixtures in scope. Registering a module layer instead would silently exclude them and both rules would pass vacuously, so the choice is load-bearing rather than incidental. Compare [FakeConsumerMap](#fakeconsumermap), which flips exactly that switch on purpose. +- **Walkthrough** - `RepoToken => "MMCA.Common"` (`:76`) and a one-entry `DefineLayers()` returning `Framework(Layer.Application, typeof(EventUpcasterFitnessTests).Assembly)` (`:78`-`:79`). The doc comment states the intent in one line (`:73`). The layer's derived root namespace is unused by these rules, which enumerate `ConcreteClasses` across whole assemblies (`ArchitectureRules.Upcasters.cs:67`) rather than namespace-scoped subsets. +- **Where it's used** - constructed on every call of the test class's two private rule helpers (`EventUpcasterFitnessTests.cs:61`,`:68`). + ### CrossServiceDataSource > MMCA.Common.Testing · `MMCA.Common.Testing` · `MMCA.Common/Source/Hosting/MMCA.Common.Testing/CrossServiceFixtureBase.cs:15` · Level 0 · sealed record - **What it is**: a two-field record naming one logical data source a cross-service fixture routes to its own physical database: the logical name the framework's `DataSources` configuration section keys on (normally the module name) and the database that name resolves to on the shared SQL Server container - (`CrossServiceFixtureBase.cs:8-15`). + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/CrossServiceFixtureBase.cs:8-15`). - **Depends on**: nothing. A positional `sealed record` of two strings, declared above [CrossServiceFixtureBase](#crossservicefixturebase) in the same file. - **Concept**: it is the declarative half of database-per-service ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html), taught in [primer §2](00-primer.md#2-architectural-styles-this-codebase-commits-to)) expressed as test data. The doc's own examples are the shape to hold onto: `LogicalName` is `Conference`, `DatabaseName` is - `ADC_Conference` (`CrossServiceFixtureBase.cs:13-14`). `[Rubric §8, Data Architecture]` assesses whether - each service owns its own store; this record is how a test fixture states that ownership once and derives - everything else from it. + `ADC_Conference` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/CrossServiceFixtureBase.cs:13-14`). + `[Rubric §8, Data Architecture]` assesses whether each service owns its own store; this record is how a + test fixture states that ownership once and derives everything else from it. - **Walkthrough**: two positional members, `LogicalName` and `DatabaseName` - (`CrossServiceFixtureBase.cs:15`), so it gets structural equality and immutability for free. The base - consumes each instance three ways: the database name drives the pre-create loop - (`CrossServiceFixtureBase.cs:213`), and the logical name drives both environment keys + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/CrossServiceFixtureBase.cs:15`), so it gets structural + equality and immutability for free. The base consumes each instance three ways: the database name drives + the pre-create loop (`CrossServiceFixtureBase.cs:213`), and the logical name drives both environment keys `SetNamedDataSource` pushes, `DataSources__{LogicalName}__SQLServerConnectionString` and `DataSources__{LogicalName}__SQLServerMigrationsAssembly` (`CrossServiceFixtureBase.cs:272-275`). - **Where it's used**: as the `DataSources` list a subclass supplies (`CrossServiceFixtureBase.cs:60`); ADC - declares three (`MMCA.ADC/Tests/Integration/MMCA.ADC.CrossService.IntegrationTests/Infrastructure/CrossServiceFixture.cs:61-66`) - and Store its own set - (`MMCA.Store/Tests/Integration/MMCA.Store.CrossService.IntegrationTests/Infrastructure/CrossServiceFixture.cs:29`). + declares three, Identity, Conference and Engagement + (`MMCA.ADC/Tests/Integration/MMCA.ADC.CrossService.IntegrationTests/Infrastructure/CrossServiceFixture.cs:61-66`), + and Store declares two, Catalog and Sales + (`MMCA.Store/Tests/Integration/MMCA.Store.CrossService.IntegrationTests/Infrastructure/CrossServiceFixture.cs:56-60`). ### DependencyInjectionAssert > MMCA.Common.Testing · `MMCA.Common.Testing` · `MMCA.Common/Source/Hosting/MMCA.Common.Testing/DependencyInjectionAssert.cs:13` · Level 0 · class (static) @@ -1059,29 +1272,33 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc exposes. It proves a registration extension hands back the very `IServiceCollection` it was given, so a fluent chain stays intact. - **Depends on**: `AwesomeAssertions` and `Microsoft.Extensions.DependencyInjection` - (`DependencyInjectionAssert.cs:1-2`). No first-party dependency. + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/DependencyInjectionAssert.cs:1-2`). No first-party + dependency. - **Concept introduced, the fluent-contract guard.** The framework's registration methods are fluent by convention: hosts chain `AddApplication().AddInfrastructure(...).AddAPI(...)`. An extension that returns a *new* collection silently drops every registration chained after it, and no other test catches that, - because the dropped services are simply absent rather than wrong (`DependencyInjectionAssert.cs:6-11`). + because the dropped services are simply absent rather than wrong + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/DependencyInjectionAssert.cs:6-11`). `[Rubric §14, Testability]` assesses whether an invariant can be checked cheaply; this one turns an otherwise invisible composition failure into a one-line test. `[Rubric §16, Maintainability]` covers the convention itself: the return-the-same-collection contract is what lets host composition stay declarative. - **Walkthrough** - `ReturnsSameCollection(Func register)` - (`DependencyInjectionAssert.cs:21-32`): null-guards the delegate (`:23`), creates the `ServiceCollection` - itself so the call site stays one line (`:25`, the doc shows the shape at `:16-18`), invokes the - registration under test (`:27`), and asserts `result.Should().BeSameAs(services, ...)` with a - because-reason that spells out the consequence of failing (`:29-31`). + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/DependencyInjectionAssert.cs:21-32`): null-guards the + delegate (`:23`), creates the `ServiceCollection` itself so the call site stays one line (`:25`, the doc + shows the shape at `:16-18`), invokes the registration under test (`:27`), and asserts + `result.Should().BeSameAs(services, ...)` with a because-reason that spells out the consequence of + failing (`:29-31`). - Reference equality is the whole assertion. It deliberately says nothing about *what* was registered; the per-module tests that call it assert their own service descriptors separately. - **Why it's built this way**: creating the collection inside the helper is what keeps adoption free. A module's DI test adds one line per registration extension rather than three lines of arrange plus an assertion nobody remembers to write. -- **Where it's used**: across the module DI test classes in both apps, for example +- **Where it's used**: seven module DI test classes across the two apps, for example `MMCA.Store/Tests/Modules/Sales/MMCA.Store.Sales.Infrastructure.Tests/DependencyInjectionTests.cs:29`, `MMCA.Store/Tests/Modules/Sales/MMCA.Store.Sales.API.Tests/DependencyInjectionTests.cs:63,68`, `MMCA.Store/Tests/Modules/Catalog/MMCA.Store.Catalog.API.Tests/DependencyInjectionTests.cs:68,73`, + `MMCA.Store/Tests/Modules/Identity/MMCA.Store.Identity.API.Tests/DependencyInjectionTests.cs:49,54`, `MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.API.Tests/DependencyInjectionTests.cs:26,31`, and `MMCA.ADC/Tests/Modules/Notification/MMCA.ADC.Notification.Application.Tests/DependencyInjectionTests.cs:35`. MMCA.Common self-tests the helper, including that it fails for an extension returning a different @@ -1094,18 +1311,20 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc defaults for one entity type so a test only has to state the properties it actually cares about, then calls `Build()` to materialize the entity through its real domain factory. - **Depends on**: nothing first-party, and no BCL surface beyond `object`. Two type parameters and one - abstract method is the whole type (`EntityBuilderBase.cs:9-18`). + abstract method is the whole type + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/Builders/EntityBuilderBase.cs:9-18`). - **Concept introduced, the Test Data Builder plus the self-referencing generic (CRTP).** `[Rubric §14, Testability]` assesses how easily the code can be exercised in isolation; a builder base is a textbook §14 affordance, it removes the copy-pasted setup that otherwise bloats every arrange step. The signature `EntityBuilderBase where TBuilder : EntityBuilderBase` (`EntityBuilderBase.cs:9-10`) is the curiously-recurring template pattern: a concrete builder - passes *itself* as `TBuilder`, so the `WithX(...)` methods a subclass adds can return the concrete - builder type and keep a fluent chain strongly typed without a cast. + TEntity>` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/Builders/EntityBuilderBase.cs:9-10`) is the + curiously-recurring template pattern: a concrete builder passes *itself* as `TBuilder`, so the + `WithX(...)` methods a subclass adds can return the concrete builder type and keep a fluent chain + strongly typed without a cast. - **Walkthrough** - - `Build()` (`EntityBuilderBase.cs:17`): the single abstract member. The XML doc - (`EntityBuilderBase.cs:12-15`) records the contract, the subclass calls the entity's - [Result](group-01-result-error-handling.md#result)-returning factory + - `Build()` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/Builders/EntityBuilderBase.cs:17`): the + single abstract member. The XML doc (`EntityBuilderBase.cs:12-15`) records the contract, the subclass + calls the entity's [Result](group-01-result-error-handling.md#result)-returning factory ([ADR-013](https://ivanball.github.io/docs/adr/013-result-pattern.html)) and throws if it failed, so a builder never yields a domain object that violated its invariants. The base deliberately owns no state and no default `WithX` helpers, those live on each concrete builder because defaults are @@ -1113,9 +1332,10 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - **Why it's built this way**: keeping the base to one abstract method means it adds zero coupling and zero opinions beyond "a builder produces a `TEntity`". The CRTP is the only structural rule it enforces, and it exists purely so fluent chaining stays type-safe down in the subclasses. -- **Where it's used**: the domain-test builders in both apps subclass it, eight today: - `MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Domain.Tests/Builders/EventBuilder.cs:10`, - `.../Builders/SessionBuilder.cs:10`, `.../Builders/SpeakerBuilder.cs:10`, +- **Where it's used**: the domain-test builders in both apps subclass it, ten today: + `MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Domain.Tests/Builders/ActivityBuilder.cs:10`, + `.../Builders/EventBuilder.cs:10`, `.../Builders/SessionBuilder.cs:10`, + `.../Builders/SpeakerBuilder.cs:10`, `.../Builders/SponsorBuilder.cs:11`, `MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Domain.Tests/Builders/UserBuilder.cs:10`, `MMCA.Store/Tests/Modules/Catalog/MMCA.Store.Catalog.Domain.Tests/Builders/CategoryBuilder.cs:10`, `.../Builders/ProductBuilder.cs:10`, @@ -1132,7 +1352,8 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc assert both branches of a feature-gated command or query. - **Depends on**: BCL and NuGet only, `IServiceCollection` and `IConfiguration` from `Microsoft.Extensions.*` plus `AddFeatureManagement` from `Microsoft.FeatureManagement` - (`FeatureManagementTestExtensions.cs:1-3`). No first-party dependency. + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/FeatureManagementTestExtensions.cs:1-3`). No + first-party dependency. - **Concept**: this is the test-side counterpart to the framework's [FeatureGateCommandDecorator](group-05-cqrs-pipeline.md#featuregatecommanddecoratortcommand-tresult), the outermost link in the CQRS pipeline (taught in @@ -1143,22 +1364,26 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc concern, and this helper keeps its test-time configuration in one reusable place. - **Walkthrough** - The whole class body is a single C# preview `extension(IServiceCollection services)` block - (`FeatureManagementTestExtensions.cs:12`), the same extension-member style the framework uses for DI - registration (see [primer §4](00-primer.md#c-extensiont-types-read-this-once)), not a classic - `this`-parameter extension method. + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/FeatureManagementTestExtensions.cs:12`), the same + extension-member style the framework uses for DI registration (see + [primer §4](00-primer.md#c-extensiont-types-read-this-once)), not a classic `this`-parameter + extension method. - `ConfigureTestFeatureFlags(Dictionary features)` - (`FeatureManagementTestExtensions.cs:21-35`): projects each name-to-bool pair into an in-memory - configuration key under the `FeatureManagement:` section (`:24-29`), registers that `IConfiguration` - as a singleton (`:31`), calls `AddFeatureManagement` against the section (`:32`), and returns the - collection for chaining (`:34`). + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/FeatureManagementTestExtensions.cs:21-35`): projects + each name-to-bool pair into an in-memory configuration key under the `FeatureManagement:` section + (`:24-29`), registers that `IConfiguration` as a singleton (`:31`), calls `AddFeatureManagement` + against the section (`:32`), and returns the collection for chaining (`:34`). - **Why it's built this way**: pushing overrides through the real `IConfiguration` plus `AddFeatureManagement` path (rather than mocking an `IFeatureManager`) means the test exercises the same feature-evaluation code the production host runs, only the source of the flag value changes. - **Where it's used**: it is intended for a test `WebApplicationFactory`'s `ConfigureServices`, and the - XML doc says exactly that (`FeatureManagementTestExtensions.cs:14-18`). + XML doc says exactly that + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/FeatureManagementTestExtensions.cs:14-18`). - **Caveats / not-in-source**: as of this pass **no first-party caller exists**. A workspace-wide search - finds `ConfigureTestFeatureFlags` only at its definition; every other hit is documentation. It ships in - the package as available capability, not as a technique any suite currently uses. + finds `ConfigureTestFeatureFlags` only at its definition + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/FeatureManagementTestExtensions.cs:21`); every other + hit is documentation. It ships in the package as available capability, not as a technique any suite + currently uses. ### IIntegrationTestFixture > MMCA.Common.Testing · `MMCA.Common.Testing` · `MMCA.Common/Source/Hosting/MMCA.Common.Testing/IIntegrationTestFixture.cs:8` · Level 0 · interface @@ -1175,11 +1400,11 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc app-specific wiring in each repo, which is the whole premise of [ADR-058](https://ivanball.github.io/docs/adr/058-runtime-conformance-suites-as-a-package.html). - **Walkthrough** - - `CreateClient()` (`IIntegrationTestFixture.cs:11`): returns an `HttpClient` configured for the - in-process test server. - - `ResetDatabaseAsync()` (`IIntegrationTestFixture.cs:19`): resets the database between tests (the doc - names Respawn as the typical mechanism). The doc comment (`IIntegrationTestFixture.cs:13-18`) records - a load-bearing rule for the database-per-service topology + - `CreateClient()` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/IIntegrationTestFixture.cs:11`): + returns an `HttpClient` configured for the in-process test server. + - `ResetDatabaseAsync()` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/IIntegrationTestFixture.cs:19`): + resets the database between tests (the doc names Respawn as the typical mechanism). The doc comment + (`IIntegrationTestFixture.cs:13-18`) records a load-bearing rule for the database-per-service topology ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)): a host with multiple physical data sources must reset **every** relational source, and can enumerate them by resolving [IEntityDataSourceRegistry](group-07-persistence-ef-core.md#ientitydatasourceregistry) and @@ -1189,9 +1414,11 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc base's. - **Where it's used**: implemented by [SqlServerIntegrationTestFixtureBase](#sqlserverintegrationtestfixturebasetentrypoint) - (`SqlServerIntegrationTestFixtureBase.cs:27`) and through it by every per-service fixture in both apps; - consumed as the `TFixture` constraint on [IntegrationTestBase](#integrationtestbasetfixture) - (`IntegrationTestBase.cs:14`) and therefore by all three contract bases in this unit. + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/SqlServerIntegrationTestFixtureBase.cs:27`) and through + it by every per-service fixture in both apps; consumed as the `TFixture` constraint on + [IntegrationTestBase](#integrationtestbasetfixture) + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/IntegrationTestBase.cs:14`) and therefore by all three + contract bases in this unit. ### JwtTokenGenerator > MMCA.Common.Testing · `MMCA.Common.Testing` · `MMCA.Common/Source/Hosting/MMCA.Common.Testing/JwtTokenGenerator.cs:30` · Level 0 · class (static) @@ -1200,13 +1427,15 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc matching switch that re-points a test host's Bearer scheme at the same committed key. Together they let a test call an authorized endpoint as any role or user without standing up the real login flow or a reachable JWKS endpoint. Each downstream project wraps the generator with role-specific convenience - methods (AdminToken, OrganizerToken, and so on, `JwtTokenGenerator.cs:11-12`). + methods (AdminToken, OrganizerToken, and so on, + `MMCA.Common/Source/Hosting/MMCA.Common.Testing/JwtTokenGenerator.cs:11-12`). - **Depends on**: BCL and NuGet only, `System.Globalization`, `System.IdentityModel.Tokens.Jwt`, `System.Security.Claims`, `System.Security.Cryptography` (RSA), `Microsoft.AspNetCore.Authentication.JwtBearer` (for the options type the second member configures), and - `Microsoft.IdentityModel.Tokens` (`JwtTokenGenerator.cs:1-6`). The generated claim layout mirrors the - framework's [ITokenService](group-08-auth.md#itokenservice) so downstream auth middleware cannot tell a - test token from a real one (`JwtTokenGenerator.cs:99-102`). The `userId` parameter is typed + `Microsoft.IdentityModel.Tokens` + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/JwtTokenGenerator.cs:1-6`). The generated claim layout + mirrors the framework's [ITokenService](group-08-auth.md#itokenservice) so downstream auth middleware + cannot tell a test token from a real one (`JwtTokenGenerator.cs:99-102`). The `userId` parameter is typed `UserIdentifierType` (`JwtTokenGenerator.cs:114`), the solution-wide identifier alias ([ADR-048](https://ivanball.github.io/docs/adr/048-primitive-identifier-type-aliases.html)). - **Concept introduced, exercising the real RS256 path in tests.** `[Rubric §11, Security]` assesses @@ -1219,12 +1448,13 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc shortcut. `[Rubric §14, Testability]` covers the ergonomics: deterministic tokens with no per-run key generation, and a host that validates them with no network dependency at all. - **Walkthrough** - - Public constants (`JwtTokenGenerator.cs:33-96`): `DefaultIssuer` (`https://localhost:6001`, line 33), - `DefaultKeyId` (`mmca-test-key`, line 41, the `kid` the host advertises on its JWKS document), and - the paired `DefaultPublicKeyPem` (line 49) and `DefaultPrivateKeyPem` (line 68). The class doc records - the wiring contract: test host appsettings set `Jwt:SigningAlgorithm=RS256`, `Jwt:RsaPublicKeyPem`, - and `Jwks:KeyId` (`JwtTokenGenerator.cs:18-20`) so - [RsaJwksProvider](group-08-auth.md#rsajwksprovider) publishes a JWKS entry with the matching `kid`. + - Public constants (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/JwtTokenGenerator.cs:33-96`): + `DefaultIssuer` (`https://localhost:6001`, line 33), `DefaultKeyId` (`mmca-test-key`, line 41, the + `kid` the host advertises on its JWKS document), and the paired `DefaultPublicKeyPem` (line 49) and + `DefaultPrivateKeyPem` (line 68). The class doc records the wiring contract: test host appsettings set + `Jwt:SigningAlgorithm=RS256`, `Jwt:RsaPublicKeyPem`, and `Jwks:KeyId` + (`JwtTokenGenerator.cs:18-20`) so [RsaJwksProvider](group-08-auth.md#rsajwksprovider) publishes a JWKS + entry with the matching `kid`. - `GenerateToken(...)` (`JwtTokenGenerator.cs:112-153`): imports the PEM private key into `RSAParameters` inside a `using` so the `RSA` instance can be disposed without invalidating the key held by `SigningCredentials` (`:121-131`), assembles the standard claim set @@ -1244,19 +1474,26 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc fixture and a multi-host cross-service fixture share one committed keypair. - **Where it's used**: tokens are applied to a client through [IntegrationTestBase](#integrationtestbasetfixture)'s `SetBearerToken(...)` - (`IntegrationTestBase.cs:42-44`) and wrapped by each app's role-specific token helpers. - `ConfigureInProcessTokenValidation` is called from a `PostConfigure` in the test - factories of the non-Identity hosts, for example - `MMCA.Store/Tests/Integration/MMCA.Store.Catalog.IntegrationTests/Infrastructure/CatalogTestWebApplicationFactory.cs:34`, - `MMCA.ADC/Tests/Integration/MMCA.ADC.Notification.IntegrationTests/Infrastructure/NotificationTestWebApplicationFactory.cs:44`, - and both Store cross-service factories - (`MMCA.Store/Tests/Integration/MMCA.Store.CrossService.IntegrationTests/Infrastructure/CatalogCrossServiceFactory.cs:44`, - `.../SalesCrossServiceFactory.cs:46`). It is covered directly by + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/IntegrationTestBase.cs:42-44`) and wrapped by each + app's role-specific token helpers. `ConfigureInProcessTokenValidation` is called from a + `PostConfigure` in the test factories of the non-Identity hosts, in ADC + (`MMCA.ADC/Tests/Integration/MMCA.ADC.Conference.IntegrationTests/Infrastructure/ConferenceTestWebApplicationFactory.cs:49,77`, + `.../MMCA.ADC.Engagement.IntegrationTests/Infrastructure/EngagementTestWebApplicationFactory.cs:56,86`, + `.../MMCA.ADC.Notification.IntegrationTests/Infrastructure/NotificationTestWebApplicationFactory.cs:50,69`) + and in Store + (`MMCA.Store/Tests/Integration/MMCA.Store.Catalog.IntegrationTests/Infrastructure/CatalogTestWebApplicationFactory.cs:40,43`, + `.../MMCA.Store.Sales.IntegrationTests/Infrastructure/SalesTestWebApplicationFactory.cs:47,57`), plus + all four cross-service factories + (`MMCA.ADC/Tests/Integration/MMCA.ADC.CrossService.IntegrationTests/Infrastructure/ConferenceCrossServiceFactory.cs:45`, + `.../EngagementCrossServiceFactory.cs:54`, + `MMCA.Store/Tests/Integration/MMCA.Store.CrossService.IntegrationTests/Infrastructure/CatalogCrossServiceFactory.cs:50`, + `.../SalesCrossServiceFactory.cs:52`). It is covered directly by `MMCA.Common/Tests/Hosting/MMCA.Common.Testing.Tests/JwtTokenGeneratorTests.cs:38-94`. -- **Caveats / not-in-source**: the class doc (`JwtTokenGenerator.cs:22-28`) carries an explicit security - warning, the embedded keypair is committed to the public git repo and is insecure by design, it exists - only to make integration tests deterministic. Production keys are provisioned via user-secrets or Azure - Key Vault per `JwtSettings.RsaPrivateKeyPem` and must never be this keypair. +- **Caveats / not-in-source**: the class doc + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/JwtTokenGenerator.cs:22-28`) carries an explicit + security warning, the embedded keypair is committed to the public git repo and is insecure by design, it + exists only to make integration tests deterministic. Production keys are provisioned via user-secrets or + Azure Key Vault per `JwtSettings.RsaPrivateKeyPem` and must never be this keypair. ### ProductionHostApplicationFactory > MMCA.Common.Testing · `MMCA.Common.Testing` · `MMCA.Common/Source/Hosting/MMCA.Common.Testing/ProductionHostApplicationFactory.cs:22` · Level 0 · class @@ -1265,12 +1502,15 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc pins the hosting environment to `Production` and hangs on to the started `IHost`, so a test can both exercise production-only middleware branches and drive the host's own lifetime. - **Depends on**: `Microsoft.AspNetCore.Mvc.Testing`'s `WebApplicationFactory` (extended, - `ProductionHostApplicationFactory.cs:22`) and `Microsoft.Extensions.Hosting`'s `IHost` / `IHostBuilder` + `MMCA.Common/Source/Hosting/MMCA.Common.Testing/ProductionHostApplicationFactory.cs:22`) and + `Microsoft.Extensions.Hosting`'s `IHost` / `IHostBuilder` (`ProductionHostApplicationFactory.cs:1-2`). No first-party dependency. - **Concept introduced, the second boot path.** The integration tier has two ways to get a running host: [SqlServerIntegrationTestFixtureBase](#sqlserverintegrationtestfixturebasetentrypoint) for hosts that need a real database, and this one for hosts that do not (a YARP reverse-proxy gateway - is the usual case, `ProductionHostApplicationFactory.cs:16-19`). Both are named as the two paths in + is the usual case, + `MMCA.Common/Source/Hosting/MMCA.Common.Testing/ProductionHostApplicationFactory.cs:16-19`). Both are + named as the two paths in [ADR-058](https://ivanball.github.io/docs/adr/058-runtime-conformance-suites-as-a-package.html). `[Rubric §11, Security]` is the reason `Production` is pinned: the restrictive CORS policy, HSTS emission, and other production-only middleware are branches a default `Development` boot skips @@ -1278,22 +1518,25 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc (`ProductionHostApplicationFactory.cs:9-12`). `[Rubric §14, Testability]` covers the second half, capturing the host is what makes a lifetime test possible at all. - **Walkthrough** - - `StartedHost` (`ProductionHostApplicationFactory.cs:29`): a public property with a private setter, - nullable because `WebApplicationFactory` builds its host lazily, so it stays null until the first - client is created (`:25-28`). - - `CreateHost(IHostBuilder builder)` (`ProductionHostApplicationFactory.cs:32-39`): null-guards the - builder (`:34`), calls `builder.UseEnvironment("Production")` (`:36`), then assigns and returns - `base.CreateHost(builder)` (`:37-38`). Three lines of override, and the assignment is the entire - reason the class exists. + - `StartedHost` + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/ProductionHostApplicationFactory.cs:29`): a public + property with a private setter, nullable because `WebApplicationFactory` builds its host lazily, so it + stays null until the first client is created (`:25-28`). + - `CreateHost(IHostBuilder builder)` + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/ProductionHostApplicationFactory.cs:32-39`): + null-guards the builder (`:34`), calls `builder.UseEnvironment("Production")` (`:36`), then assigns and + returns `base.CreateHost(builder)` (`:37-38`). Three lines of override, and the assignment is the + entire reason the class exists. - **Why it's built this way**: `IHost.StopAsync` is not reachable through the `WebApplicationFactory` surface alone (`ProductionHostApplicationFactory.cs:12-14`), so a graceful-shutdown test has no handle to pull without this capture. The class is deliberately left unsealed and non-abstract so it can be used directly as an xUnit `IClassFixture<...>` with no subclass. - **Where it's used**: as the default factory of [GracefulShutdownTestsBase](#gracefulshutdowntestsbasetentrypoint) - (`GracefulShutdownTestsBase.cs:31`), and directly as the class fixture of both gateway security-header - tests (`MMCA.Store/Tests/Hosts/MMCA.Store.Gateway.Tests/SecurityHeadersTests.cs:11-12`, - `MMCA.ADC/Tests/Hosts/MMCA.ADC.Gateway.Tests/SecurityHeadersTests.cs:11-12`). + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/GracefulShutdownTestsBase.cs:31`), and directly as the + class fixture of both gateway security-header tests + (`MMCA.Store/Tests/Hosts/MMCA.Store.Gateway.Tests/SecurityHeadersTests.cs:12`, + `MMCA.ADC/Tests/Hosts/MMCA.ADC.Gateway.Tests/SecurityHeadersTests.cs:12`). - **Caveats / not-in-source**: the doc is explicit that a host which migrates or seeds on startup needs its own fixture (`ProductionHostApplicationFactory.cs:16-19`); this factory does nothing about a database. @@ -1304,7 +1547,8 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - **What it is**: a one-test conformance base that asserts a booted host emits the hardened set of security response headers on every response, so a later pipeline refactor cannot silently drop them. Authored once, re-run as a thin subclass per host under test. -- **Depends on**: `AwesomeAssertions` and `Xunit` (`SecurityHeadersTestsBase.cs:1-2`). It deliberately +- **Depends on**: `AwesomeAssertions` and `Xunit` + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/SecurityHeadersTestsBase.cs:1-2`). It deliberately does **not** extend [IntegrationTestBase](#integrationtestbasetfixture): it needs only an `HttpClient`, so it takes one through an abstract factory rather than inheriting the SQL fixture machinery. @@ -1315,28 +1559,31 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc [ADR-023](https://ivanball.github.io/docs/adr/023-security-response-headers.html)) is expected to emit. `[Rubric §14, Testability]` covers the reusable-base shape. - **Walkthrough** - - `ProbePath` (`SecurityHeadersTestsBase.cs:19`): overridable, defaults to `/alive` because the - liveness endpoint always answers independent of any backend being reachable, so the header check is - never flaky for the wrong reason (rationale in the class doc, `:12-14`). - - `AliveResponse_CarriesHardenedSecurityHeaders` (`SecurityHeadersTestsBase.cs:21-36`): the single + - `ProbePath` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/SecurityHeadersTestsBase.cs:19`): + overridable, defaults to `/alive` because the liveness endpoint always answers independent of any + backend being reachable, so the header check is never flaky for the wrong reason (rationale in the + class doc, `:12-14`). + - `AliveResponse_CarriesHardenedSecurityHeaders` + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/SecurityHeadersTestsBase.cs:21-36`): the single `[Fact]`. It GETs `ProbePath` (`:26-27`, threading `TestContext.Current.CancellationToken`) and asserts six headers (`:29-35`): `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, a `Permissions-Policy` containing `geolocation=()`, a `Content-Security-Policy` containing `frame-ancestors 'none'`, and (because the host under test boots in the Production environment) an HSTS `Strict-Transport-Security` header with a `max-age=`. - - `CreateClient()` (`SecurityHeadersTestsBase.cs:42`): abstract, the subclass supplies it from its - `WebApplicationFactory` class fixture. `Header(...)` (`:44-45`) is the private helper that joins a - header's values or returns null when the header is absent, which is what makes a missing header fail - with a readable null-versus-expected message. + - `CreateClient()` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/SecurityHeadersTestsBase.cs:42`): + abstract, the subclass supplies it from its `WebApplicationFactory` class fixture. `Header(...)` + (`:44-45`) is the private helper that joins a header's values or returns null when the header is + absent, which is what makes a missing header fail with a readable null-versus-expected message. - **Why it's built this way**: pinning literal header values (not just presence) turns "we harden responses" into an executable, per-host guarantee, and probing `/alive` keeps the test independent of application state. Booting the subclass fixture in Production is what makes the HSTS assertion valid, which is why the two adopters pair it with [ProductionHostApplicationFactory](#productionhostapplicationfactorytentrypoint). - **Where it's used**: both gateway hosts subclass it with a single `CreateClient` override, - `MMCA.Store/Tests/Hosts/MMCA.Store.Gateway.Tests/SecurityHeadersTests.cs:11-12` and - `MMCA.ADC/Tests/Hosts/MMCA.ADC.Gateway.Tests/SecurityHeadersTests.cs:11-12`. + `MMCA.Store/Tests/Hosts/MMCA.Store.Gateway.Tests/SecurityHeadersTests.cs:12` and + `MMCA.ADC/Tests/Hosts/MMCA.ADC.Gateway.Tests/SecurityHeadersTests.cs:12`, each also taking a + `ProductionHostApplicationFactory` as its xUnit class fixture on the same line. ### TestPolling > MMCA.Common.Testing · `MMCA.Common.Testing` · `MMCA.Common/Source/Hosting/MMCA.Common.Testing/TestPolling.cs:9` · Level 0 · class (static) @@ -1347,17 +1594,19 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc library, so the caller keeps ownership of the assertion. - **Concept introduced, replacing the pre-assert sleep.** Anything that travels the outbox to a broker and back, or any other eventually-consistent path, arrives at a time the test cannot know - (`TestPolling.cs:3-8`). A fixed `Task.Delay` before the assertion is both slow and flaky: too short and - the suite reds intermittently, too long and every green run pays the worst case. Polling returns as soon - as the condition holds and bounds the wait. `[Rubric §14, Testability]` assesses whether the suite is - deterministic; `[Rubric §6, CQRS & Event-Driven]` is why the problem exists at all, since the outbox - ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-pattern.html)) is asynchronous by design and - offers no synchronous handle to await. + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/TestPolling.cs:3-8`). A fixed `Task.Delay` before the + assertion is both slow and flaky: too short and the suite reds intermittently, too long and every green + run pays the worst case. Polling returns as soon as the condition holds and bounds the wait. + `[Rubric §14, Testability]` assesses whether the suite is deterministic; `[Rubric §6, CQRS & + Event-Driven]` is why the problem exists at all, since the outbox + ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)) is asynchronous by design + and offers no synchronous handle to await. - **Walkthrough** - `PollUntilAsync(Func> probe, Func isSatisfied, TimeSpan? timeout = null, TimeSpan? interval = null)` - (`TestPolling.cs:22-41`): null-guards both delegates (`:28-29`), computes a deadline from the - **60-second** default budget (`:31`) and a **500 ms** default interval (`:32`), probes once before the - loop (`:33`), then loops while the condition is unmet and the deadline has not passed (`:34-38`). + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/TestPolling.cs:22-41`): null-guards both delegates + (`:28-29`), computes a deadline from the **60-second** default budget (`:31`) and a **500 ms** default + interval (`:32`), probes once before the loop (`:33`), then loops while the condition is unmet and the + deadline has not passed (`:34-38`). - The return is the design decision worth noticing: it returns `last` unconditionally (`:40`) rather than throwing on timeout, so a timed-out poll still fails on the caller's real assertion message rather than on a bare timeout exception (the doc states exactly this at `:11-14`). @@ -1372,7 +1621,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc with call sites such as `MMCA.Store/Tests/Integration/MMCA.Store.CrossService.IntegrationTests/CrossService/ProductVariantChangedRoundTripTests.cs:28,48,51`. MMCA.Common covers the helper itself, including the null-argument guards - (`MMCA.Common/Tests/Hosting/MMCA.Common.Testing.Tests/TestPollingTests.cs:18-63`). + (`MMCA.Common/Tests/Hosting/MMCA.Common.Testing.Tests/TestPollingTests.cs:14-63`). ### CrossServiceFixtureBase > MMCA.Common.Testing · `MMCA.Common.Testing` · `MMCA.Common/Source/Hosting/MMCA.Common.Testing/CrossServiceFixtureBase.cs:41` · Level 1 · class (abstract) @@ -1380,16 +1629,18 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - **What it is**: the shared scaffolding for the cross-service **real-broker** integration tier. It boots several service hosts in ONE process against a real Testcontainers SQL Server and a real Testcontainers RabbitMQ, so the genuine outbox to broker to consumer round-trip (and any real cross-service gRPC read) - is exercised end to end rather than faked (`CrossServiceFixtureBase.cs:17-25`). + is exercised end to end rather than faked + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/CrossServiceFixtureBase.cs:17-25`). - **Depends on**: [CrossServiceDataSource](#crossservicedatasource) (the per-source declaration), plus `Microsoft.Data.SqlClient`, `Testcontainers.MsSql`, `Testcontainers.RabbitMq`, and xUnit's - `IAsyncLifetime` (`CrossServiceFixtureBase.cs:1-4,41`). + `IAsyncLifetime` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/CrossServiceFixtureBase.cs:1-4,41`). - **Concept introduced, the multi-host in-process topology and its configuration channel.** Where [SqlServerIntegrationTestFixtureBase](#sqlserverintegrationtestfixturebasetentrypoint) boots one host with the cross-service edges faked and no broker, this base owns a whole topology. Two mechanisms are load-bearing, and both are documented on the class. First, **process environment - variables are the only override channel these hosts honour** (`CrossServiceFixtureBase.cs:26-39`): each - host reads its connection string, `MessageBus` settings, and JWT settings from `builder.Configuration` at + variables are the only override channel these hosts honor** + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/CrossServiceFixtureBase.cs:26-39`): each host reads its + connection string, `MessageBus` settings, and JWT settings from `builder.Configuration` at configure-time, before `builder.Build()`, which is before `WebApplicationFactory.ConfigureAppConfiguration` deltas apply, so in-memory config would arrive too late. Second, because the one genuinely per-host key is the SQL connection string, hosts must boot @@ -1397,12 +1648,13 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc connection (the data-source resolver, the context factory, the outbox processor, and the MassTransit bus are all built during `StartAsync`). `[Rubric §7, Microservices Readiness]` assesses whether extracted services really do collaborate over their declared transports; `[Rubric §6, CQRS & Event-Driven]` covers - the outbox path ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-pattern.html)); + the outbox path ([ADR-003](https://ivanball.github.io/docs/adr/003-outbox-dual-dispatch.html)); `[Rubric §8, Data Architecture]` covers database-per-service ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)); and `[Rubric §14, Testability]` covers shipping the whole topology as a reusable base. - **Walkthrough** - - State: the private `DummyBearerAuthority` constant (`CrossServiceFixtureBase.cs:45`), the + - State: the private `DummyBearerAuthority` constant + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/CrossServiceFixtureBase.cs:45`), the original-environment snapshot map (`:47`), the two nullable containers (`:49-50`), and the public `RabbitMqConnectionString` (`:53`). - Subclass knobs: `DataSources` (`:60`, the logical sources in the order their databases are created), @@ -1451,82 +1703,102 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc `MMCA.ADC/Tests/Integration/MMCA.ADC.CrossService.IntegrationTests/Infrastructure/CrossServiceFixture.cs:23` (three databases for three REST hosts at `:61-66`, migrations prefix `MMCA.ADC.Migrations.SqlServer` at `:69`) and - `MMCA.Store/Tests/Integration/MMCA.Store.CrossService.IntegrationTests/Infrastructure/CrossServiceFixture.cs:29`. - MMCA.Common covers the container-free half of the base through its own private `FakeCrossServiceFixture` - (`MMCA.Common/Tests/Hosting/MMCA.Common.Testing.Tests/CrossServiceFixtureBaseTests.cs:13,106`). + `MMCA.Store/Tests/Integration/MMCA.Store.CrossService.IntegrationTests/Infrastructure/CrossServiceFixture.cs:29` + (two databases at `:56-60`, prefix `MMCA.Store.Migrations.SqlServer` at `:63`). MMCA.Common covers the + container-free half of the base through its own private `FakeCrossServiceFixture` + (`MMCA.Common/Tests/Hosting/MMCA.Common.Testing.Tests/CrossServiceFixtureBaseTests.cs:106`). - **Caveats / not-in-source**: this tier needs a Docker daemon. Where each repo schedules it is a CI decision recorded outside this class; the base itself says nothing about scheduling. ### DecoratorPipelineOrderTestsBase -> MMCA.Common.Testing · `MMCA.Common.Testing` · `MMCA.Common/Source/Hosting/MMCA.Common.Testing/DecoratorPipelineOrderTestsBase.cs:36` · Level 1 · class (abstract) +> MMCA.Common.Testing · `MMCA.Common.Testing` · `MMCA.Common/Source/Hosting/MMCA.Common.Testing/DecoratorPipelineOrderTestsBase.cs:38` · Level 1 · class (abstract) -- **What it is**: an opt-in conformance base that builds a real `ServiceCollection` through a repo's own +- **What it is**: an opt-in fitness function that builds a real `ServiceCollection` through a repo's own registration sequence, resolves the decorated command and query handlers out of the built provider, and asserts the *runtime object graph* nests the decorators in exactly the [ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html) order. - **Depends on**: [ICommandHandler](group-05-cqrs-pipeline.md#icommandhandlerin-tcommand-tresult) and [IQueryHandler](group-05-cqrs-pipeline.md#iqueryhandlerin-tquery-tresult) from - `MMCA.Common.Application.UseCases` (`DecoratorPipelineOrderTestsBase.cs:4`), plus `System.Reflection`, - `Microsoft.Extensions.DependencyInjection`, `AwesomeAssertions`, and `Xunit` (`:1-5`). + `MMCA.Common.Application.UseCases` + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/DecoratorPipelineOrderTestsBase.cs:4`), plus + `System.Reflection`, `Microsoft.Extensions.DependencyInjection`, `AwesomeAssertions`, and `Xunit` + (`:1-5`). - **Concept introduced, verifying a decorator chain by unwrapping the constructed graph.** The decorator pipeline itself is taught in [group-05](group-05-cqrs-pipeline.md#loggingcommanddecoratortcommand-tresult); what is new here is *how you prove it*. Scrutor's `TryDecorate` applies decorators in **reverse registration order**, so the outermost decorator is the last one registered, and an innocent-looking reorder of the `AddApplicationDecorators()` lines (or a module scan that runs after it) silently changes runtime - behavior with no compile error (class doc, `DecoratorPipelineOrderTestsBase.cs:15-18`). Rather than + behavior with no compile error (class doc, + `MMCA.Common/Source/Hosting/MMCA.Common.Testing/DecoratorPipelineOrderTestsBase.cs:16-19`). Rather than inspecting the registration list, this base resolves the service and walks the real chain by reflection - (`:27-30`). `[Rubric §6, CQRS & Event-Driven]` assesses whether the command/query pipeline is coherent + (`:29-32`). `[Rubric §6, CQRS & Event-Driven]` assesses whether the command/query pipeline is coherent and intentional; `[Rubric §2, Design Patterns]` assesses correct application of the decorator pattern; `[Rubric §14, Testability]` covers turning an ordering convention into an executable check; and `[Rubric §34, Architecture Governance & Documentation]` covers the fact that a decision record is - enforced here rather than merely written down. It is also the one non-HTTP member of the + enforced here rather than merely written down + ([ADR-015](https://ivanball.github.io/docs/adr/015-architecture-fitness-functions.html) is the general + case). It is also the one non-HTTP member of the [ADR-058](https://ivanball.github.io/docs/adr/058-runtime-conformance-suites-as-a-package.html) conformance tier. - **Walkthrough** - - Four type parameters (`DecoratorPipelineOrderTestsBase.cs:32-35`): a representative command with its - `TResult` and a representative query with its `TResult`, each of which must have a concrete - registered handler. - - `ConfigureServices(IServiceCollection services)` (`DecoratorPipelineOrderTestsBase.cs:44`): the one + - Four type parameters + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/DecoratorPipelineOrderTestsBase.cs:34-37`): a + representative command with its `TResult` and a representative query with its `TResult`, each of which + must have a concrete registered handler. + - `ConfigureServices(IServiceCollection services)` + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/DecoratorPipelineOrderTestsBase.cs:46`): the one abstract member. The subclass registers test doubles for the decorator dependencies (`IFeatureManager`, + [ICurrentUserService](group-08-auth.md#icurrentuserservice), + [IPermissionRegistry](group-08-auth.md#ipermissionregistry), [ICorrelationContext](group-12-api-hosting-mapping.md#icorrelationcontext), [ICacheService](group-09-caching.md#icacheservice), [IUnitOfWork](group-07-persistence-ef-core.md#iunitofwork), `ILogger<>`) and then runs the repo's - real registration sequence, module scans first and `AddApplicationDecorators()` last (doc `:19-26`). - - `ExpectedCommandDecorators` (`:47-54`) pins, outermost first, + real registration sequence, module scans first and `AddApplicationDecorators()` last (doc `:20-28`). + - `ExpectedCommandDecorators` (`:49-58`) pins seven links, outermost first: [FeatureGateCommandDecorator](group-05-cqrs-pipeline.md#featuregatecommanddecoratortcommand-tresult), + [AuthorizationCommandDecorator](group-05-cqrs-pipeline.md#authorizationcommanddecoratortcommand-tresult), [LoggingCommandDecorator](group-05-cqrs-pipeline.md#loggingcommanddecoratortcommand-tresult), [CachingCommandDecorator](group-05-cqrs-pipeline.md#cachingcommanddecoratortcommand-tresult), [ValidatingCommandDecorator](group-05-cqrs-pipeline.md#validatingcommanddecoratortcommand-tresult), + [TimeoutCommandDecorator](group-05-cqrs-pipeline.md#timeoutcommanddecoratortcommand-tresult), [TransactionalCommandDecorator](group-05-cqrs-pipeline.md#transactionalcommanddecoratortcommand-tresult). - `ExpectedQueryDecorators` (`:57-62`) pins FeatureGate, Logging, Caching, the query pipeline having - neither validation nor a transaction. Both are `virtual`, so a host with a deliberately different - chain can narrow them. - - The two `[Fact]`s, `CommandPipeline_NestsDecorators_InAdr014Order` (`:64-66`) and - `QueryPipeline_NestsDecorators_InAdr014Order` (`:68-70`), each hand the closed handler interface and + `ExpectedQueryDecorators` (`:61-68`) pins five: + [FeatureGateQueryDecorator](group-05-cqrs-pipeline.md#featuregatequerydecoratortquery-tresult), + [AuthorizationQueryDecorator](group-05-cqrs-pipeline.md#authorizationquerydecoratortquery-tresult), + [LoggingQueryDecorator](group-05-cqrs-pipeline.md#loggingquerydecoratortquery-tresult), + [CachingQueryDecorator](group-05-cqrs-pipeline.md#cachingquerydecoratortquery-tresult), + [TimeoutQueryDecorator](group-05-cqrs-pipeline.md#timeoutquerydecoratortquery-tresult), + the query pipeline having neither validation nor a transaction. Both lists are `virtual`, so a host + with a deliberately different chain can narrow them. + - The two `[Fact]`s, `CommandPipeline_NestsDecorators_InAdr014Order` (`:70-72`) and + `QueryPipeline_NestsDecorators_InAdr014Order` (`:74-76`), each hand the closed handler interface and the expected list to `AssertPipeline`. - - `AssertPipeline` (`:72-91`): builds the collection, builds a provider, opens a scope (handlers are - scoped, `:77-78`), resolves the outermost handler and asserts it is non-null with a message that - tells the subclass author what is missing (`:80-82`). It then unwraps the chain, maps each link to a + - `AssertPipeline` (`:78-97`): builds the collection, builds a provider, opens a scope (handlers are + scoped, `:83-84`), resolves the outermost handler and asserts it is non-null with a message that + tells the subclass author what is missing (`:86-88`). It then unwraps the chain, maps each link to a simple type name, and asserts every element *except the last* equals the expected decorator list in - order (`:84-87`), finally asserting the innermost element does **not** end in `Decorator`, that is, - it is the concrete handler (`:89-90`). - - `UnwrapChain` (`:98-118`): walks outermost to innermost by reflecting over each object's instance + order (`:90-93`), finally asserting the innermost element does **not** end in `Decorator`, that is, + it is the concrete handler (`:95-96`). + - `UnwrapChain` (`:104-124`): walks outermost to innermost by reflecting over each object's instance fields (public and non-public) and picking the first value that implements the same closed handler - interface and is not the object itself (`:105-108`), which is how it finds the compiler-generated - backing field holding the inner handler. `SimpleTypeName` (`:120-125`) strips the generic-arity + interface and is not the object itself (`:111-114`), which is how it finds the compiler-generated + backing field holding the inner handler. `SimpleTypeName` (`:126-131`) strips the generic-arity backtick suffix so a two-arity `LoggingCommandDecorator` compares as the plain name. - **Why it's built this way**: asserting the constructed object graph is strictly stronger than asserting the registration list, it catches a decorator that was registered but never applied (for example because a module scan re-registered the handler afterwards). Comparing simple type names keeps the base free of a compile-time reference to the decorator classes, which live in `MMCA.Common.Application`. -- **Where it's used**: three subclasses today. MMCA.Common self-tests the base against its own +- **Where it's used**: four subclasses today. MMCA.Common self-tests the base against its own registration sequence with a synthetic `PingCommand` / `PingQuery` pair - (`MMCA.Common/Tests/Hosting/MMCA.Common.Testing.Tests/DecoratorPipelineOrderTests.cs:20-21`), and both - apps subclass it in their architecture tiers over the real Identity pair - (`MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/DecoratorPipelineOrderTests.cs:26-27`, - `MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/DecoratorPipelineOrderTests.cs:26-27`). + (`MMCA.Common/Tests/Hosting/MMCA.Common.Testing.Tests/DecoratorPipelineOrderTests.cs:21-22`), and the + three applications subclass it in their architecture tiers, + `MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/DecoratorPipelineOrderTests.cs:27-28` and + `MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/DecoratorPipelineOrderTests.cs:26-27` over + the real Identity `ChangePreferencesCommand` / `GetUserPreferencesQuery` pair, and + `MMCA.Helpdesk/Tests/Architecture/MMCA.Helpdesk.Architecture.Tests/DecoratorPipelineOrderTests.cs:30-31` + over the Tickets `UpdateTicketCommand` / `GetTicketByIdQuery` pair. - **Caveats / not-in-source**: each subclass pins one representative command/query pair, not every handler, so the guard proves the *ordering* is right, not that every handler is decorated. @@ -1538,23 +1810,25 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc then `ApplicationStopped` inside the timeout. - **Depends on**: [ProductionHostApplicationFactory](#productionhostapplicationfactorytentrypoint) - (`GracefulShutdownTestsBase.cs:31`), plus `Microsoft.Extensions.Hosting`'s `IHost` / - `IHostApplicationLifetime`, `Microsoft.Extensions.DependencyInjection`, `AwesomeAssertions`, and `Xunit` - (`:1-4`). + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/GracefulShutdownTestsBase.cs:31`), plus + `Microsoft.Extensions.Hosting`'s `IHost` / `IHostApplicationLifetime`, + `Microsoft.Extensions.DependencyInjection`, `AwesomeAssertions`, and `Xunit` (`:1-4`). - **Concept introduced, the bounded-stop drain check.** `[Rubric §29, Resilience & Business Continuity]` - (named in the class doc itself, `GracefulShutdownTestsBase.cs:9`) assesses whether the system survives - planned and unplanned interruption; a rolling deploy is the planned one. The failure this catches is a - hosted service (a warm-up runner, service discovery, proxy infrastructure) that refuses to drain, which - in production does not announce itself: it silently wedges a rolling deploy while the platform waits out - its termination grace period (`:13-17`). `[Rubric §13, Observability & Operability]` is the operational - half, lifetime events firing in order are what a platform's shutdown handling depends on. The - recovery-objective framing is + (named in the class doc itself, + `MMCA.Common/Source/Hosting/MMCA.Common.Testing/GracefulShutdownTestsBase.cs:9`) assesses whether the + system survives planned and unplanned interruption; a rolling deploy is the planned one. The failure this + catches is a hosted service (a warm-up runner, service discovery, proxy infrastructure) that refuses to + drain, which in production does not announce itself: it silently wedges a rolling deploy while the + platform waits out its termination grace period (`:13-17`). `[Rubric §13, Observability & Operability]` + is the operational half, lifetime events firing in order are what a platform's shutdown handling depends + on. The recovery-objective framing is [ADR-009](https://ivanball.github.io/docs/adr/009-resilience-and-recovery-objectives.html); the base itself is one of the suites recorded in [ADR-058](https://ivanball.github.io/docs/adr/058-runtime-conformance-suites-as-a-package.html). - **Walkthrough** - - `ShutdownTimeoutSeconds` (`GracefulShutdownTestsBase.cs:28`): `virtual`, defaults to **20** seconds. - This number is the test: a host that drains slower than this fails. + - `ShutdownTimeoutSeconds` + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/GracefulShutdownTestsBase.cs:28`): `virtual`, defaults + to **20** seconds. This number is the test: a host that drains slower than this fails. - `CreateFactory()` (`:31`): `virtual`, returns a plain `ProductionHostApplicationFactory`. The doc says to override it only when the host needs a fixture beyond a Production-pinned boot (`:18-21`). @@ -1588,14 +1862,15 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc client and lifecycle, typed request helpers, bearer-token management, and a thread-safe id counter, so a concrete test class is left with just its arrange/act/assert. - **Depends on**: [IIntegrationTestFixture](#iintegrationtestfixture) (the `TFixture` constraint, - `IntegrationTestBase.cs:14`), plus `Xunit`'s `IAsyncLifetime`, `System.Net.Http.Headers`, and - `System.Net.Http.Json` (`:1-3`). + `MMCA.Common/Source/Hosting/MMCA.Common.Testing/IntegrationTestBase.cs:14`), plus `Xunit`'s + `IAsyncLifetime`, `System.Net.Http.Headers`, and `System.Net.Http.Json` (`:1-3`). - **Concept introduced, the xUnit async test lifecycle and per-test isolation.** `[Rubric §14, Testability]`: the base implements `IAsyncLifetime` so `InitializeAsync` runs **before each test** and `DisposeAsync` **after**, and it hangs the database reset off that hook so every test starts from a clean database, the single most important property for reliable integration tests. - **Walkthrough** - - Fields and properties: a `static int _nextId = 1000` seed (`IntegrationTestBase.cs:16`), and the + - Fields and properties: a `static int _nextId = 1000` seed + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/IntegrationTestBase.cs:16`), and the `Fixture` / `Client` protected properties (`:19-22`). - Constructor (`:24-28`): stores the injected fixture and eagerly creates the `HttpClient` from it. - `InitializeAsync` (`:31`): a `ValueTask` that awaits `Fixture.ResetDatabaseAsync()` before each test. @@ -1627,9 +1902,9 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc Respawn, and drops the database on disposal. It is the concrete engine behind [IIntegrationTestFixture](#iintegrationtestfixture) for SQL Server hosts. - **Depends on**: [IIntegrationTestFixture](#iintegrationtestfixture) (implemented, - `SqlServerIntegrationTestFixtureBase.cs:27`), plus `Microsoft.AspNetCore.Mvc.Testing` - (`WebApplicationFactory`), `Microsoft.Data.SqlClient`, `Respawn`, and `Xunit`'s `IAsyncLifetime` - (`:1-4`). + `MMCA.Common/Source/Hosting/MMCA.Common.Testing/SqlServerIntegrationTestFixtureBase.cs:27`), plus + `Microsoft.AspNetCore.Mvc.Testing` (`WebApplicationFactory`), `Microsoft.Data.SqlClient`, `Respawn`, and + `Xunit`'s `IAsyncLifetime` (`:1-4`). - **Concept introduced, the disposable-database integration fixture and environment-variable overrides.** `[Rubric §14, Testability]` and `[Rubric §8, Data Architecture]`: real integration coverage needs a real relational database, and this fixture makes that cheap and hermetic, a fresh GUID-named database @@ -1638,11 +1913,11 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc ([ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html)) is why the class doc stresses the `DataSources` collapse onto a single overridden connection string (`:16-24`). - **Walkthrough** - - State (`SqlServerIntegrationTestFixtureBase.cs:30-45`): the recorded original-environment map, the - server-base and database-name strings, the `WebApplicationFactory`, the `Respawner`, a - `_databaseCreated` flag, and the public `Client` / `ConnectionString`. `ConnectionString` (`:45`) is - exposed so SQL-fidelity tests can read raw tables (for example to assert an integration event landed - in the outbox). + - State (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/SqlServerIntegrationTestFixtureBase.cs:30-45`): + the recorded original-environment map, the server-base and database-name strings, the + `WebApplicationFactory`, the `Respawner`, a `_databaseCreated` flag, and the public `Client` / + `ConnectionString`. `ConnectionString` (`:45`) is exposed so SQL-fidelity tests can read raw tables + (for example to assert an integration event landed in the outbox). - `Services` (`:52`): the booted host's root service provider, exposed so cross-service tests can resolve a consumer-side integration-event handler or a repository and drive the flow directly against the real database. @@ -1696,14 +1971,15 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc resources, so an accidental controller or route removal fails CI instead of silently changing the published contract. - **Depends on**: [IntegrationTestBase](#integrationtestbasetfixture) (inherited, - `OpenApiContractTestsBase.cs:21`), `System.Net`, `System.Text.Json`, `AwesomeAssertions`, and `Xunit` - (`:1-4`). + `MMCA.Common/Source/Hosting/MMCA.Common.Testing/OpenApiContractTestsBase.cs:21`), `System.Net`, + `System.Text.Json`, `AwesomeAssertions`, and `Xunit` (`:1-4`). - **Concept introduced, the contract guard on the live document.** `[Rubric §9, API & Contract Design]` assesses whether the API surface is described and kept stable; the pattern across all three Level 2 - bases is a **live-document guard with no committed snapshot** (`OpenApiContractTestsBase.cs:14-16`), - the assertions run against the document the host actually serves, so new controllers can never leave a - stale snapshot behind and a removed one is caught immediately. This is one of the suites recorded - in [ADR-058](https://ivanball.github.io/docs/adr/058-runtime-conformance-suites-as-a-package.html), and + bases is a **live-document guard with no committed snapshot** + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/OpenApiContractTestsBase.cs:14-16`), the assertions run + against the document the host actually serves, so new controllers can never leave a stale snapshot behind + and a removed one is caught immediately. This is one of the suites recorded in + [ADR-058](https://ivanball.github.io/docs/adr/058-runtime-conformance-suites-as-a-package.html), and the one with the widest adoption. - **Walkthrough** - Overridable and abstract knobs: `OpenApiDocumentPath` (`:30`, defaults to `/openapi/v1.json`), @@ -1740,10 +2016,12 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc Details documents, machine-readable bodies carrying `status`, `title`, and a diagnostic extension, across both error-shaping paths the framework uses. - **Depends on**: [IntegrationTestBase](#integrationtestbasetfixture) (inherited, - `ProblemDetailsContractTestsBase.cs:21`), `System.Net`, `System.Net.Http.Json`, `System.Text.Json`, - `AwesomeAssertions`, and `Xunit` (`:1-5`). Same live-guard shape as the OpenAPI base above. + `MMCA.Common/Source/Hosting/MMCA.Common.Testing/ProblemDetailsContractTestsBase.cs:21`), `System.Net`, + `System.Net.Http.Json`, `System.Text.Json`, `AwesomeAssertions`, and `Xunit` (`:1-5`). Same live-guard + shape as the OpenAPI base above. - **Concept**: still `[Rubric §9, API & Contract Design]`, here the pinned contract is the **error - shape**. The class covers the two distinct paths that produce errors (class doc, `:10-18`): ASP.NET + shape**. The class covers the two distinct paths that produce errors (class doc, + `MMCA.Common/Source/Hosting/MMCA.Common.Testing/ProblemDetailsContractTestsBase.cs:10-18`): ASP.NET Core model validation (a 400 `application/problem+json` body) and the framework's `HandleFailure` `Result`-error mapping (see [ApiControllerBase](group-12-api-hosting-mapping.md#apicontrollerbase)), which turns a @@ -1751,7 +2029,8 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc [Error](group-01-result-error-handling.md#error) not-found into a 404 problem ([ADR-013](https://ivanball.github.io/docs/adr/013-result-pattern.html) defines that edge contract). - **Walkthrough** - - `Validation_400_HasProblemDetailsShape` (`ProblemDetailsContractTestsBase.cs:29-39`): sends the + - `Validation_400_HasProblemDetailsShape` + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/ProblemDetailsContractTestsBase.cs:29-39`): sends the subclass's validation probe, asserts the shared shape at 400, then checks the `problem+json` content type and the model-validation-only extensions `type`, `traceId`, and `errors` (`:35-38`). - `NotFound_404_HasProblemDetailsShape` (`:41-47`): sends the 404 probe and asserts the shared shape. @@ -1766,14 +2045,18 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc means a regression in either error channel breaks CI, and factoring the shape assertion into a shared static keeps every host's error contract identical while still letting a host with a reachable 409-conflict path layer its own test on top (`:16-18`). -- **Where it's used**: subclassed per host, three in ADC +- **Where it's used**: subclassed per host, four in ADC (`MMCA.ADC/Tests/Integration/MMCA.ADC.Conference.IntegrationTests/Contract/ProblemDetailsContractTests.cs:20`, `.../MMCA.ADC.Engagement.IntegrationTests/Contract/ProblemDetailsContractTests.cs:17`, - `.../MMCA.ADC.Identity.IntegrationTests/Contract/ProblemDetailsContractTests.cs:17`) and three in Store + `.../MMCA.ADC.Identity.IntegrationTests/Contract/ProblemDetailsContractTests.cs:17`, + `.../MMCA.ADC.Notification.IntegrationTests/Contract/ProblemDetailsContractTests.cs:16`) and three in + Store (`MMCA.Store/Tests/Integration/MMCA.Store.Catalog.IntegrationTests/Contract/ProblemDetailsContractTests.cs:20`, `.../MMCA.Store.Identity.IntegrationTests/Contract/ProblemDetailsContractTests.cs:16`, `.../MMCA.Store.Sales.IntegrationTests/Contract/ProblemDetailsContractTests.cs:16`). ADC Conference is - the one that adds a 409 stale-`RowVersion` conflict test on top of the inherited facts. + the one that adds a 409 stale-`RowVersion` conflict test on top of the inherited facts + (`.../MMCA.ADC.Conference.IntegrationTests/Contract/ProblemDetailsContractTests.cs:40-67`, reusing + `AssertProblemDetailsShapeAsync` at `:67`). ### ServiceInfoVersioningContractTestsBase > MMCA.Common.Testing · `MMCA.Common.Testing` · `MMCA.Common/Source/Hosting/MMCA.Common.Testing/ServiceInfoVersioningContractTestsBase.cs:19` · Level 2 · class (abstract) @@ -1783,8 +2066,8 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc selected by the `api-version` header, and that the host reports supported and deprecated versions in response headers. - **Depends on**: [IntegrationTestBase](#integrationtestbasetfixture) (inherited, - `ServiceInfoVersioningContractTestsBase.cs:19`), `System.Net`, `System.Text.Json`, - `AwesomeAssertions`, and `Xunit` (`:1-4`). + `MMCA.Common/Source/Hosting/MMCA.Common.Testing/ServiceInfoVersioningContractTestsBase.cs:19`), + `System.Net`, `System.Text.Json`, `AwesomeAssertions`, and `Xunit` (`:1-4`). - **Concept**: `[Rubric §9, API & Contract Design]` again, the versioning axis ([ADR-046](https://ivanball.github.io/docs/adr/046-http-api-versioning.html)). The class doc (`:8-17`) makes the point that without a second working version the whole versioning story would be @@ -1794,9 +2077,10 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc test body is identical across repos; a subclass supplies only its fixture. - **Walkthrough** - `ServiceInfo_V1_ReturnsMinimalShape_AndIsReportedDeprecated` - (`ServiceInfoVersioningContractTestsBase.cs:27-41`): requests v1.0, asserts 200, checks - `apiVersion == "1.0"` and that the evolved `supportedVersions` list is **absent** in the v1 shape - (`:35-36`), then asserts an `api-deprecated-versions` response header contains `1.0` (`:38-40`). + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/ServiceInfoVersioningContractTestsBase.cs:27-41`): + requests v1.0, asserts 200, checks `apiVersion == "1.0"` and that the evolved `supportedVersions` list + is **absent** in the v1 shape (`:35-36`), then asserts an `api-deprecated-versions` response header + contains `1.0` (`:38-40`). - `ServiceInfo_V2_ReturnsEvolvedShape_AndIsReportedSupported` (`:43-57`): requests v2.0, asserts 200, checks `apiVersion == "2.0"` and that `supportedVersions` contains `2.0` (`:50-52`), then asserts an `api-supported-versions` header advertises `2.0` (`:54-56`). @@ -1812,8 +2096,76 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - **Caveats / not-in-source**: adoption is per-repo partial, one host each in ADC and Store, not every extracted REST service. +### MiddlewarePipelineOrderTestsBase +> MMCA.Common.Testing · `MMCA.Common.Testing` · `MMCA.Common/Source/Hosting/MMCA.Common.Testing/MiddlewarePipelineOrderTestsBase.cs:29` · Level 11 · class (abstract) + +- **What it is**: the HTTP-edge counterpart of + [DecoratorPipelineOrderTestsBase](#decoratorpipelineordertestsbasetcommand-tcommandresult-tquery-tqueryresult). + It seeds the framework's default middleware step list, applies the host's own customization if it has + one, and asserts the resulting step order is exactly the documented pipeline, plus that the + startup-validated adjacency invariants still hold. +- **Depends on**: + [MiddlewarePipelineBuilder](group-12-api-hosting-mapping.md#middlewarepipelinebuilder) and + [MiddlewarePipelineStepNames](group-12-api-hosting-mapping.md#middlewarepipelinestepnames) from + `MMCA.Common.API.Startup` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/MiddlewarePipelineOrderTestsBase.cs:2`), + plus `AwesomeAssertions` and `Xunit` (`:1,3`). This is the reference that makes + `MMCA.Common.Testing` depend on `MMCA.Common.API` + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/MMCA.Common.Testing.csproj:44`). +- **Concept introduced, order-as-data at the HTTP edge.** In ASP.NET Core middleware order *is* behavior, + not style, and the failures it produces do not look like ordering bugs. The class doc names three + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/MiddlewarePipelineOrderTestsBase.cs:13-18`): an + unreachable `jwks_uri` when the pre-forwarded capture drifts away from `UseForwardedHeaders`, a tenant + that never resolves when tenant resolution runs before authentication, and a per-user rate cap that + never engages when the limiter runs before authentication. What makes the check cheap is that + [ADR-079](https://ivanball.github.io/docs/adr/079-shared-http-middleware-pipeline.html) turned the + order into **data**: `MiddlewarePipelineBuilder.CreateDefault()` produces a list of named steps that are + inert until applied, so no `WebApplication` has to be built and the test runs in the fast unit tier with + no database and no host (`:24-27`). `[Rubric §10, Cross-Cutting]` assesses whether cross-cutting edge + concerns are composed deliberately; `[Rubric §11, Security]` is why authentication before the rate + limiter is load-bearing ([ADR-019](https://ivanball.github.io/docs/adr/019-rate-limiting.html)); + `[Rubric §14, Testability]` and + `[Rubric §34, Architecture Governance & Documentation]` cover the fitness-function form itself + ([ADR-015](https://ivanball.github.io/docs/adr/015-architecture-fitness-functions.html)). +- **Walkthrough** + - `Configure` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/MiddlewarePipelineOrderTestsBase.cs:35`): + `virtual`, defaults to `null`, meaning the host under test calls the zero-argument + `UseCommonMiddlewarePipeline()` overload. A host that customizes the pipeline overrides this with the + same `Action` its `Program.cs` passes (`:20-23`). + - `ExpectedStepNames` (`:38-58`): the pinned order, outermost first, all eighteen steps named through + `MiddlewarePipelineStepNames` constants rather than string literals: ExceptionHandler, CorrelationId, + RequestLocalization, PreForwardedCapture, ForwardedHeaders, HttpsRedirection, ResponseCompression, + Routing, Cors, Authentication, TenantResolution, RateLimiting, SoftDeletedUserFilter, Authorization, + OutputCache, JwksEndpoint, OidcDiscoveryEndpoint, Controllers. It is `virtual`, so a host with a + deliberately different pipeline states its own order. + - `EdgePipeline_OrdersSteps_InDocumentedOrder` (`:60-67`): the first `[Fact]`. It builds the seeded + builder and asserts `builder.StepNames` equals `ExpectedStepNames`, with a because-reason (`:66`) that + spells out the three adjacencies rather than just reporting a list mismatch. + - `EdgePipeline_SatisfiesLoadBearingInvariants` (`:69-77`): the second `[Fact]`. It asserts + `builder.Build()` does not throw. `Build()` + (`MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/MiddlewarePipelineBuilder.cs:257-280`) + re-checks four invariants at startup, PreForwardedCapture immediately before ForwardedHeaders + (`:259-262`), Authentication immediately before TenantResolution (`:264-267`), Authentication before + RateLimiting (`:269-272`), and ForwardedHeaders before HttpsRedirection (`:274-277`), so a pipeline + that fails here would have thrown while the host was starting. + - `CreateBuilder` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/MiddlewarePipelineOrderTestsBase.cs:79-84`): + the two-line private helper both facts share, `MiddlewarePipelineBuilder.CreateDefault()` followed by + `Configure?.Invoke(builder)`. +- **Why it's built this way**: the two facts are complementary rather than redundant. The first pins the + exact order, so any reorder (including one that still satisfies every adjacency) fails visibly; the + second re-runs the host's own startup validation in the unit tier, so an override that breaks an + adjacency fails in a test rather than at boot. Naming steps through `MiddlewarePipelineStepNames` + constants means a step rename is a compile error in the test rather than a silent string mismatch. +- **Where it's used**: four subclasses, every one of them body-less because every host calls the + zero-argument overload, + `MMCA.Common/Tests/Hosting/MMCA.Common.Testing.Tests/MiddlewarePipelineOrderTests.cs:10`, + `MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/MiddlewarePipelineOrderTests.cs:15`, + `MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/MiddlewarePipelineOrderTests.cs:15`, and + `MMCA.Helpdesk/Tests/Architecture/MMCA.Helpdesk.Architecture.Tests/MiddlewarePipelineOrderTests.cs:15`. + The framework's own `UseCommonMiddlewarePipeline` doc points back at this base as the way to freeze the + order (`MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:42`). + ### HandlerTestBase -> MMCA.Common.Testing · `MMCA.Common.Testing` · `MMCA.Common/Source/Hosting/MMCA.Common.Testing/HandlerTestBase.cs:38` · Level 9 · class (abstract) +> MMCA.Common.Testing · `MMCA.Common.Testing` · `MMCA.Common/Source/Hosting/MMCA.Common.Testing/HandlerTestBase.cs:38` · Level 14 · class (abstract) - **What it is**: the reusable Moq scaffold for command/query handler **unit** tests. It hands a derived test class a pre-configured `Mock`, a no-op logger typed to the handler under test, and @@ -1825,21 +2177,23 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc and [AuditableBaseEntity](group-02-domain-building-blocks.md#auditablebaseentitytidentifiertype) (the two generic constraints), plus `Moq`, `Microsoft.Extensions.Logging`, and `NullLogger` - (`HandlerTestBase.cs:1-5`). + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/HandlerTestBase.cs:1-5`). - **Concept**: *the arrange-phase base class.* Where [IntegrationTestBase](#integrationtestbasetfixture) gives an end-to-end test a booted host, this gives an isolated unit test a mocked persistence boundary: no database, no host, no HTTP. The - class doc (`HandlerTestBase.cs:10-12`) frames it as the shared replacement for the per-test copy-paste - of `Mock` plus `GetRepository` wiring plus `SaveChangesAsync` setup. `[Rubric §14, - Testability]` assesses whether the design permits fast isolated tests; the fact that handlers depend on - `IUnitOfWork` (an Application-layer abstraction) rather than a `DbContext` is what makes this scaffold - possible at all, which is `[Rubric §3, Clean Architecture]` paying off in the test tier + class doc (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/HandlerTestBase.cs:10-12`) frames it as the + shared replacement for the per-test copy-paste of `Mock` plus `GetRepository` wiring plus + `SaveChangesAsync` setup. `[Rubric §14, Testability]` assesses whether the design permits fast isolated + tests; the fact that handlers depend on `IUnitOfWork` (an Application-layer abstraction) rather than a + `DbContext` is what makes this scaffold possible at all, which is `[Rubric §3, Clean Architecture]` + paying off in the test tier ([ADR-055](https://ivanball.github.io/docs/adr/055-repository-and-specification-contract.html) records that contract). `[Rubric §16, Maintainability]` covers the deduplication itself. - **Walkthrough** - - Constructor (`HandlerTestBase.cs:41-42`): a single expression-bodied statement that pre-configures - `UnitOfWork.SaveChangesAsync(...)` to return `1`, the success path, so a happy-path test writes no - persistence setup at all. Failure-path tests override it with their own `Setup` (doc `:32-35`). + - Constructor (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/HandlerTestBase.cs:41-42`): a single + expression-bodied statement that pre-configures `UnitOfWork.SaveChangesAsync(...)` to return `1`, the + success path, so a happy-path test writes no persistence setup at all. Failure-path tests override it + with their own `Setup` (doc `:32-35`). - `UnitOfWork` (`:45`): the `Mock` every registered repository is wired into, created by a property initializer so the constructor can configure it. - `Logger` (`:48`): `NullLogger.Instance`, typed by the handler type parameter so it binds @@ -1856,14 +2210,33 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc may read through `GetReadRepository` and write through `GetRepository` on the same aggregate; a test forced to register two mocks would have to keep their state in sync. Pre-succeeding `SaveChangesAsync` encodes the common case so only the interesting deviation appears in a test. -- **Where it's used**: the base of handler unit-test classes across the framework and the downstream - application modules, 87 classes today, including the framework's own scaffold test - (`MMCA.Common/Tests/Hosting/MMCA.Common.Testing.Tests/HandlerTestBaseTests.cs:12`) and dozens of ADC - Application-tier classes (for example +- **Where it's used**: 116 test classes today, the framework's own scaffold test + (`MMCA.Common/Tests/Hosting/MMCA.Common.Testing.Tests/HandlerTestBaseTests.cs:12`) plus 115 ADC + Application-tier classes spread across all four modules (for example `MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Categories/UseCases/CreateConferenceCategoryHandlerTests.cs:13`, `MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Application.Tests/LivePolls/UseCases/CastVoteHandlerTests.cs:13`, - `MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/ChangePasswordHandlerTests.cs:12`). - The class doc carries a worked `CreateEventHandlerTests` example (`HandlerTestBase.cs:19-31`). + `MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/ChangePasswordHandlerTests.cs:12`, + `MMCA.ADC/Tests/Modules/Notification/MMCA.ADC.Notification.Application.Tests/UserNotificationExportServiceTests.cs`). + The class doc carries a worked `CreateEventHandlerTests` example + (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/HandlerTestBase.cs:19-31`). +- **Caveats / not-in-source**: adoption is uneven. MMCA.Store and MMCA.Helpdesk handler tests do not + subclass it at all: a workspace-wide search finds no `HandlerTestBase` reference under either repo's + `Tests/` tree. Why those two arrange their handler tests by hand is not recorded in source. + +### AnonymousEndpointTestsBase +> MMCA.Common.Testing.Architecture · `MMCA.Common.Testing.Architecture` · `MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/AnonymousEndpointTestsBase.cs:30` · Level 0 · abstract class + +- **What it is**: an allow-list gate for authorization opt-outs. Every `[AllowAnonymous]` in the assemblies a subclass names must appear in an explicit, reviewed list, so an endpoint cannot quietly lose its authorization gate. +- **Depends on**: `[Fact]` (xUnit), AwesomeAssertions, [RuleHelpers](#rulehelpers)`.LoadableTypes`, and reflection over attribute instances matched by full name: `AllowAnonymousAttribute`, `ControllerBase`, and Blazor's `RouteAttribute` are three private full-name constants (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/AnonymousEndpointTestsBase.cs:32-34`), not compile-time references. +- **Concept introduced, the two-directional allow-list gate.** A one-directional "no anonymous endpoints" rule is unusable (login has to be anonymous) and a one-directional "these are allowed" rule rots. This base asserts both halves: no unlisted `[AllowAnonymous]` exists, and no list entry matches nothing. The second half is the subtle one, since a stale entry hides a renamed or re-gated endpoint behind a permission that is no longer being granted (line 88). `[Rubric §11, Security]` assesses whether authentication gates stay where they were put; `[Rubric §14, Testability]` assesses turning a review-only property into an executable one; `[Rubric §34, Architecture Governance]` applies because the exception list, with its per-entry justification comments, becomes the reviewed record of the anonymous surface. +- **Walkthrough** + - The subclass supplies `TargetAssemblies` (line 37), `AllowedAnonymousEndpoints` (line 44, identified as the type's `FullName` for a type-level attribute and `FullName.MethodName` for a method-level one, lines 40-42), and a `MinimumScannedTypes` floor (line 51, default 1). + - Three `[Fact]`s. `AnonymousEndpoints_AreAllowListed` (line 54) subtracts the allow-list from the discovered set and names what is left. `ScannedEndpointSet_IsNotEmpty` (line 66) is the non-vacuity guard: with no controllers and no routable components discovered, the first assertion passes without having looked at anything (comment, lines 68-69). `AllowList_HasNoStaleEntries` (line 79) runs the comparison the other way. + - Two shapes are scanned, both by reflection: `IsController` (line 101) walks the base chain for `ControllerBase`, and `IsRoutableComponent` (line 117) looks for `RouteAttribute` on the type, combined in `IsScannedEndpointType` (line 95). + - `AnonymousEndpoints()` (line 125) is `protected` rather than private so a subclass can build a richer report over the same data (doc, lines 120-123); it flattens the assemblies, filters to the scanned shapes, and returns a distinct, ordinally-ordered set. + - `AnonymousEndpointsOf` (line 133) is the load-bearing detail: type-level attributes are read with `inherit: false` (line 135) and methods are enumerated `DeclaredOnly` (line 142), so a framework base action is reported once at its declaration site instead of once per derived controller in every consumer repo (comment, lines 140-141). +- **Why it's built this way**: the class doc is explicit about the limit (lines 18-24). Minimal-API endpoints opt out through the `.AllowAnonymous()` builder call, which produces endpoint metadata at map time and is invisible to static reflection, so the framework's own minimal-API anonymous surface (JWKS, OIDC discovery, app-association, session-cookie refresh, health) is outside this gate; catching it would need an endpoint-metadata check over a built host. Matching ASP.NET types by full name keeps the package free of an ASP.NET reference, the same stance the whole rule library takes (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/MMCA.Common.Testing.Architecture.csproj:22-29`). +- **Where it's used**: subclassed in all four repos, and the `DeclaredOnly` decision is what keeps each list local to what that repo declares. MMCA.Common's [AnonymousEndpointTests](#anonymousendpointtests) lists six credential-exchange actions on `AuthControllerBase`, `OAuthControllerBase` and the generic `PasswordResetAuthControllerBase` (whose two entries carry the reflected generic-arity suffix, a detail the file comments call out at `:35-36`), with a floor of 21 (`MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTests.cs:14`, list at `:22-39`, floor at `:43`). ADC's lists the two Identity credential actions plus the public conference-browse reads, calendar exports and bookmark counts, with a floor of 79 (`MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/AnonymousEndpointTests.cs:21`, floor at `:108`). Store's covers the public storefront reads, product images, registration and the Stripe webhook, with a floor of 32 (`MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/AnonymousEndpointTests.cs:10`, floor at `:55`). Helpdesk's single entry is its whole `TicketsController`, because the seed ships without an Identity issuer so there is no token to require (`MMCA.Helpdesk/Tests/Architecture/MMCA.Helpdesk.Architecture.Tests/AnonymousEndpointTests.cs:17`, entry and reason at `:26-30`). MMCA.Common also carries the adversarial coverage for the base itself in [AnonymousEndpointTestsBaseTests](#anonymousendpointtestsbasetests) (`MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:13`), which proves each assertion fails on its own drift through private `DriftedTests`, `StaleAllowListTests` and `EmptyScanTests` subclasses (`:100`, `:108`, `:117`) and pins the inheritance behavior with a fixture base controller whose anonymous action must NOT be re-reported on the derived controller (`:61-71`). ### ArchitectureAssert > MMCA.Common.Testing.Architecture · `MMCA.Common.Testing.Architecture` · `MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureAssert.cs:8` · Level 0 · static class @@ -1888,7 +2261,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - The subclass supplies `EmbeddedCssLogicalNames` (line 22), the manifest-resource names of its landing-page stylesheets. - The single `[Fact]` `LandingPageCss_SourcesBrandColorFromToken_NotHardcodedHex` (line 25) first asserts the list is non-empty (a non-vacuity guard, lines 27-28), then for each stylesheet reads it via `ReadEmbeddedCss` (line 56, which throws a clear `InvalidOperationException` when the resource is missing, lines 58-60) and records a violation when the file is blank (line 37), when the token is absent (line 43), or when the raw hex is present (line 48, matched with `OrdinalIgnoreCase` so `#1565c0` cannot slip past). - Resources are resolved from `GetType().Assembly` (line 33), that is, the *subclass's* assembly, which is what lets a package-shipped base read a consumer's stylesheet. -- **Why it's built this way**: the doc (lines 3-12) explains the split. MMCA.Common's own [BrandColorTokenTests](#brandcolortokentests) guards the C#-to-CSS token *definition* (from `BrandColors.Primary`), while this base guards every downstream *consumer* of it, embedding the stylesheets as manifest resources so the package needs no file-system access into the consumer repo. +- **Why it's built this way**: the doc (lines 3-12) explains the split. MMCA.Common's own [BrandColorTokenTests](#brandcolortokentests) guards the C#-to-CSS token *definition* (from `BrandColors.Primary`, `MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Theme/BrandColorTokenTests.cs:14`), while this base guards every downstream *consumer* of it, embedding the stylesheets as manifest resources so the package needs no file-system access into the consumer repo. - **Where it's used**: subclassed once per repo that ships a branded landing page, as `BrandColorTokenTests` in ADC (`MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/BrandColorTokenTests.cs:12`) and Store (`MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/BrandColorTokenTests.cs:10`). ### CrossEntityNavigationFinder @@ -1912,7 +2285,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - **Depends on**: nothing; a plain enum. - **Concept introduced**: the Clean Architecture layer taxonomy made into a type. The layer flow itself is taught in [primer §1](00-primer.md#1-the-big-picture); here it becomes an enum the rule library keys off, so a rule that iterates layers is written once against the enum rather than hard-coded per repo. `[Rubric §3, Clean Architecture]` assesses whether the layering is explicit and enforced; this enum is the shared alphabet. - **Walkthrough**: the doc (lines 3-8) notes that `Ui`, `Grpc`, `Contracts`, and `ServiceHost` are optional: a repo simply omits them from its map when absent, so a rule iterating them is vacuously satisfied with no compile dependency on the missing assembly. [ArchitectureMapBase](#architecturemapbase)`.Segment` translates each member to its namespace segment, and two of those translations are not the identity mapping: `Api` becomes `"API"` and `ServiceHost` becomes `"Service"` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureMapBase.cs:105-117`). -- **Where it's used**: carried by [LayerRef](#layerref), projected by [IArchitectureMap](#iarchitecturemap)`.OfLayer`, and threaded through nearly every method in [ArchitectureRules](#architecturerules). +- **Where it's used**: carried by [LayerRef](#layerref), projected by [IArchitectureMap](#iarchitecturemap)`.OfLayer`, and threaded through nearly every method in [ArchitectureRules](#architecturerules). `Contracts` is the one member no repo registers today, which is exactly why [ServiceContractPurityTestsBase](#servicecontractpuritytestsbase) is attribute-driven rather than layer-driven. ### ModuleConformanceTestsBase > MMCA.Common.Testing.Architecture · `MMCA.Common.Testing.Architecture` · `MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/ModuleConformanceTestsBase.cs:21` · Level 0 · abstract generic class @@ -1959,7 +2332,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - **What it is**: an abstract test base that reflects over a UI assembly's routable Blazor pages and fails the build if a page the subclass marks as governed has lost its `[Authorize(Roles = "...")]` role gate. - **Depends on**: `[Fact]` (xUnit), AwesomeAssertions, [RuleHelpers](#rulehelpers)`.LoadableTypes`, and pure reflection over attribute instances matched by full name (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/RouteAuthorizationTestsBase.cs:24-25`). -- **Concept introduced, the security-regression fitness function.** `[Rubric §11, Security]` and `[Rubric §25, Navigation & IA]` assess whether protected routes stay protected; this base turns "the admin page must require the Organizer role" from a review checklist into a compiled assertion, so a page cannot silently regress from `[Authorize(Roles=...)]` to a bare `[Authorize]` reachable by any authenticated user. +- **Concept introduced, the security-regression fitness function.** `[Rubric §11, Security]` and `[Rubric §25, Navigation & IA]` assess whether protected routes stay protected; this base turns "the admin page must require the Organizer role" from a review checklist into a compiled assertion, so a page cannot silently regress from `[Authorize(Roles=...)]` to a bare `[Authorize]` reachable by any authenticated user. It is the page-level counterpart to [AnonymousEndpointTestsBase](#anonymousendpointtestsbase), which guards the opt-out side of the same question. - **Walkthrough** - The subclass supplies `TargetAssembly` (line 28), the exact `RequiredRole` (line 31), an `IsGovernedPage` strategy (line 40), and a `MinimumGovernedPages` non-vacuity floor (line 47, default 1). - `GovernedPages_RequireDeclaredRole` (line 50) collects pages that are routable, governed, and do not require the role, then asserts the offender set is empty, naming each offender's route templates (lines 52-60). @@ -1979,7 +2352,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - `extension(Type type)` (line 40): `SimpleName` (line 47) strips the generic-arity backtick so suffix conventions match generic types too. `InheritsGeneric` (line 58) walks the base chain and `ImplementsGeneric` (line 72) scans the interface set for an open generic. `HasBaseTypeStartingWith` (line 80) detects a framework base by full-name prefix without a compile dependency (for example FluentValidation's `AbstractValidator`). `DeclaredPublicProperties` (line 94) narrows to declared-only public instance properties. `InheritsAggregateRoot` (line 101) and `InheritsAuditableEntity` (line 108) hard-code the MMCA entity base full names ([AuditableAggregateRootEntity](group-02-domain-building-blocks.md#auditableaggregaterootentitytidentifiertype) plus the [AuditableBaseEntity](group-02-domain-building-blocks.md#auditablebaseentitytidentifiertype) and [BaseEntity](group-02-domain-building-blocks.md#baseentitytidentifiertype) ancestors) so the entity rules can classify types cross-repo. - `extension(PropertyInfo property)` (line 114): `HasPublicMutableSetter` (line 121) is the immutability primitive. It reports `false` when there is no public setter, and `false` for `init`-only setters by looking for the `System.Runtime.CompilerServices.IsExternalInit` required custom modifier on the setter's return parameter (lines 131-135). - **Why it's built this way**: every helper avoids a compile-time reference to the type it detects (base types matched by string prefix), which is what lets one rule body run identically across four repos that do not reference each other. The class carries a file-level `[SuppressMessage]` for CA1708 (lines 10-13): with multiple `extension(T)` blocks in one static class the analyzer flags the compiler-generated grouping members as case-colliding, a documented false positive. -- **Where it's used**: throughout the [ArchitectureRules](#architecturerules) partials, inside [CrossEntityNavigationFinder](#crossentitynavigationfinder), and directly by [RouteAuthorizationTestsBase](#routeauthorizationtestsbase). +- **Where it's used**: throughout the [ArchitectureRules](#architecturerules) partials, inside [CrossEntityNavigationFinder](#crossentitynavigationfinder), and directly by [RouteAuthorizationTestsBase](#routeauthorizationtestsbase) and [AnonymousEndpointTestsBase](#anonymousendpointtestsbase). - **Caveats / not-in-source**: the type is `internal`, so consumer repos cannot call these helpers directly; they reach the same behavior only through the public rules and bases. [StateManagementConventionTestsBase](#statemanagementconventiontestsbase) is the one base that re-implements the tolerant type load privately rather than using this class. ### LayerRef @@ -1989,7 +2362,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - **Depends on**: [Layer](#layer) and `System.Reflection.Assembly`. - **Concept introduced**: the atomic unit of an architecture map. `Module` is the empty string for framework (MMCA.Common) layers that belong to no business module (lines 22-30), which is how the same record models both a module assembly (`("Catalog", Application, ...)`) and a shared framework assembly (`("", Shared, ...)`). Every projection and every isolation rule keys off that one convention. - **Walkthrough**: a four-parameter positional `sealed record` (line 31), so it gets structural equality and immutability for free; its members are set once at construction by the map's `DefineLayers`. -- **Where it's used**: [ArchitectureMapBase](#architecturemapbase) stores a lazy `IReadOnlyList` and derives every projection from it; its `Framework` and `Module` factory helpers are what build these. The namespace-cycle rule takes a `LayerRef` directly, using its `RootNamespace` to decide which namespace node a type belongs to (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Cycles.cs:84`). +- **Where it's used**: [ArchitectureMapBase](#architecturemapbase) stores a lazy `IReadOnlyList` and derives every projection from it; its `Framework` and `Module` factory helpers are what build these. The namespace-cycle rule takes a `LayerRef` directly, using its `RootNamespace` to decide which namespace node a type belongs to (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Cycles.cs:84`), and the `[ServiceContract]` purity rule iterates `map.Layers` directly rather than one projection (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Contracts.cs:40`). ### ProtoScope > MMCA.Common.Testing.Architecture · `MMCA.Common.Testing.Architecture` · `MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Protos.cs:297` · Level 1 · private sealed record @@ -2003,7 +2376,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc ### IArchitectureMap > MMCA.Common.Testing.Architecture · `MMCA.Common.Testing.Architecture` · `MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/IArchitectureMap.cs:39` · Level 2 · interface -- **What it is**: the single per-repo abstraction every architecture fitness function keys off. Each repo supplies one implementation declaring its layer and module assemblies; the shared rule library and abstract test bases consume *only* this interface, so a rule is written once and runs identically across MMCA.Common, MMCA.Store, MMCA.ADC, and MMCA.Helpdesk (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/IArchitectureMap.cs:33-38`). +- **What it is**: the single per-repo abstraction every architecture fitness function keys off. Each repo supplies one implementation declaring its layer and module assemblies; the shared rule library and abstract test bases consume *only* this interface, so a rule is written once and runs identically across every repo that supplies a map (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/IArchitectureMap.cs:33-38`, whose doc names MMCA.Common, MMCA.Store and MMCA.ADC; MMCA.Helpdesk supplies a fourth map). - **Depends on**: [LayerRef](#layerref), [Layer](#layer), `System.Reflection.Assembly`. - **Concept introduced, the architecture map as the fitness-function extension point.** This is a classic Dependency Inversion: the rules depend on an abstraction (the map), and each repo provides the concrete inventory of its assemblies. `[Rubric §1, SOLID]` (DIP) and `[Rubric §7, Microservices Readiness]` apply: the map also models the per-module layers a would-be extracted service owns, so the isolation rules can check module boundaries the same way in any repo. - **Walkthrough**: the interface exposes identity (`RepoToken` line 42, `ModuleNames` line 45), the raw `Layers` inventory (line 48), and the projections the rules lean on: `OfLayer` (all assemblies of a kind, line 51), the per-module `ModuleDomain`/`ModuleApplication`/`ModuleShared` (lines 54-60), `Infrastructure()`/`Api()` across framework plus modules (lines 63-66), the lookups `For(module, layer)` (line 69) and `ModuleOf(assembly)` (line 72), namespace derivation `RootNamespace(module, layer)` (line 75), and `OtherModuleNamespaces` (line 81), which returns the same-layer namespaces of every *other* module (the forbidden targets for a module-isolation rule, empty for framework layers and single-module repos). @@ -2040,17 +2413,18 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc ### ArchitectureRules > MMCA.Common.Testing.Architecture · `MMCA.Common.Testing.Architecture` · `MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.CancellationTokens.cs:5` · Level 4 · static partial class -- **What it is**: the reusable rule library: one large `static partial class` split across twenty `ArchitectureRules.*.cs` files, whose methods each assert one architectural invariant across every applicable assembly a map declares. A repo's test classes reduce to a sealed subclass of the matching `*TestsBase` supplying its own map. -- **Depends on**: [IArchitectureMap](#iarchitecturemap), [Layer](#layer), [ArchitectureAssert](#architectureassert), [RuleHelpers](#rulehelpers), NetArchTest (`Types.InAssembly(...)`), `System.Xml.Linq` for the props-file and `.resx` rules, source-generated `System.Text.RegularExpressions` for the proto and localized-text parsers, and, for the specification rule, `System.Linq.Expressions` plus [CrossEntityNavigationFinder](#crossentitynavigationfinder). -- **Concept introduced, the rule as a parameterized function.** Each method takes an `IArchitectureMap` and does its own loop, so the `*TestsBase` classes are thin `[Fact]` shells that delegate. The partial is organized by concern across the files `ArchitectureRules.{CancellationTokens, Controllers, Cycles, Entities, Events, Governance, HandlerResults, Handlers, Idempotency, Immutability, Layers, Localization, LocalizedText, Modules, Naming, Protos, Purity, Slices, Specifications, Transport}.cs`. `[Rubric §3, Clean Architecture]`, `[Rubric §4, DDD]`, `[Rubric §7, Microservices Readiness]`, and `[Rubric §34, Architecture Governance]` all apply: this is where the codebase's structural decisions become executable assertions. -- **Walkthrough**: four representative shapes. +- **What it is**: the reusable rule library: one large `static partial class` split across twenty-two `ArchitectureRules.*.cs` files, whose methods each assert one architectural invariant across every applicable assembly a map declares. A repo's test classes reduce to a sealed subclass of the matching `*TestsBase` supplying its own map. +- **Depends on**: [IArchitectureMap](#iarchitecturemap), [Layer](#layer), [ArchitectureAssert](#architectureassert), [RuleHelpers](#rulehelpers), NetArchTest (`Types.InAssembly(...)`, and `Mono.Cecil.TypeDefinition` for the one custom rule), `System.Xml.Linq` for the props-file and `.resx` rules, source-generated `System.Text.RegularExpressions` for the proto and localized-text parsers, and, for the specification rule, `System.Linq.Expressions` plus [CrossEntityNavigationFinder](#crossentitynavigationfinder). +- **Concept introduced, the rule as a parameterized function.** Each method takes an `IArchitectureMap` and does its own loop, so the `*TestsBase` classes are thin `[Fact]` shells that delegate. The partial is organized by concern across the files `ArchitectureRules.{CancellationTokens, Contracts, Controllers, Cycles, Entities, Events, Governance, HandlerResults, Handlers, Idempotency, Immutability, Layers, Localization, LocalizedText, Modules, Naming, Protos, Purity, Slices, Specifications, Transport, Upcasters}.cs`. `[Rubric §3, Clean Architecture]`, `[Rubric §4, DDD]`, `[Rubric §7, Microservices Readiness]`, and `[Rubric §34, Architecture Governance]` all apply: this is where the codebase's structural decisions become executable assertions. +- **Walkthrough**: five representative shapes. - *NetArchTest shape*, `ControllersDoNotDependOnInfrastructure` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Controllers.cs:6`): loops the map's per-module API layer refs, computes the forbidden Infrastructure namespace via `map.RootNamespace(...)`, runs `Types.InAssembly(...).That().HaveNameEndingWith("Controller").ShouldNot().HaveDependencyOnAny(forbidden)`, and reports through `ArchitectureAssert.NoViolations(result, ...)`. - *Layer-flow shape*, `ArchitectureRules.Layers.cs`: one public method per forbidden edge, `DomainDoesNotDependOnApplication` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Layers.cs:12`) through `UiDoesNotDependOnInfrastructure` (line 60), all delegating to the private `LayerNotDependOnLayer` (line 101), which loops every assembly of the `from` layer and asserts no dependency on the `to` layer's namespace. Two non-vacuity rules sit alongside them: `LayerMapDeclaresLayers` (line 72) and `ModulesDeclareLayers` (line 89). - *Reflection shape*, `ControllersAreSealed` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Controllers.cs:37`): enumerates `map.Api().ConcreteClasses` (the [RuleHelpers](#rulehelpers) extension property), filters non-sealed controllers via the private `IsController` (line 70, which matches on the `Controller` suffix or an MVC base type), and asserts the string offender list is empty. `ControllersInheritApiControllerBase` (line 54) is the same shape with a caller-supplied exempt set, accepting either [ApiControllerBase](group-12-api-hosting-mapping.md#apicontrollerbase) or [EntityControllerBase](group-12-api-hosting-mapping.md#entitycontrollerbasetentity-tentitydto-tidentifiertype) as the base. + - *Custom-rule shape*, `ServiceContractsDoNotDependOnServiceInternals` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Contracts.cs:32`): the one rule that hands NetArchTest a `MeetCustomRule` predicate (line 44) reading Cecil metadata directly, because the selector is an attribute, not a name or a namespace. - *Graph shape*, `NamespacesHaveNoDependencyCycles` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Cycles.cs:45`): builds a namespace-to-namespace graph per layer assembly from signature-level references (`BuildNamespaceGraph`, line 84), finds every strongly connected component (`FindNamespaceCycles`, line 253), and reports the shortest cycle path plus any extra members of the component (lines 66-68). This is the largest single rule file in the library. - **Why it's built this way**: [ADR-015](https://ivanball.github.io/docs/adr/015-architecture-fitness-functions.html) records the intent: the rule bodies live *once* here, and each repo's architecture test project is a set of sealed subclasses supplying its map, so all four repos enforce identical rules. The compile-time `MMCA.Common/Source/Build/MMCA.Common.LayerEnforcement.targets` guards the same layer flow at build time as a second, faster gate. - **Where it's used**: every `*TestsBase` in this group calls into it; those `[Fact]` methods are its public surface. A handful of rules are also called directly from MMCA.Common's own fitness self-tests, for example `BuildProtoContract`/`AssertProtoContract` from [ProtoContractFitnessTests](#protocontractfitnesstests) (`MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/ProtoContractFitnessTests.cs:14`). -- **Caveats / not-in-source**: the full method roster spans twenty partials; only the entry file and representative methods are cited here. The authoritative fitness-method and base-class counts are generated into `MMCA.Common/FACTS.md:43-48` and CI-gated, so read them there rather than counting by hand. +- **Caveats / not-in-source**: the full method roster spans twenty-two partials; only the entry file and representative methods are cited here. The authoritative fitness-method and base-class counts are generated into `MMCA.Common/FACTS.md:43-48` and CI-gated, so read them there rather than counting by hand. ### DataResidencyTestsBase > MMCA.Common.Testing.Architecture · `MMCA.Common.Testing.Architecture` · `MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/DataResidencyTestsBase.cs:14` · Level 4 · abstract class @@ -2190,12 +2564,12 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - **Where it's used**: subclassed in Store, ADC, and Helpdesk (`MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/EntityConventionTests.cs:3`, `MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/EntityConventionTests.cs:3`, `MMCA.Helpdesk/Tests/Architecture/MMCA.Helpdesk.Architecture.Tests/ArchitectureTests.cs:87`). ### EventConventionTestsBase -> MMCA.Common.Testing.Architecture · `MMCA.Common.Testing.Architecture` · `MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/EventConventionTestsBase.cs:8` · Level 5 · abstract class +> MMCA.Common.Testing.Architecture · `MMCA.Common.Testing.Architecture` · `MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/EventConventionTestsBase.cs:9` · Level 5 · abstract class -- **What it is**: an integration-event convention base (the doc cites [ADR-010](https://ivanball.github.io/docs/adr/010-integration-event-schema-versioning.html)): every concrete integration event inherits [BaseIntegrationEvent](group-04-events-outbox.md#baseintegrationevent), declares an `int SchemaVersion`, and lives in a `*.IntegrationEvents` namespace in the Shared layer. -- **Depends on**: [IArchitectureMap](#iarchitecturemap), [ArchitectureRules](#architecturerules) (`ArchitectureRules.Events.cs`). +- **What it is**: an integration-event convention base (the doc cites [ADR-010](https://ivanball.github.io/docs/adr/010-integration-event-schema-versioning.html)): every concrete integration event inherits [BaseIntegrationEvent](group-04-events-outbox.md#baseintegrationevent), declares an `int SchemaVersion`, and lives in a `*.IntegrationEvents` namespace in the Shared layer. It also polices the upcasters that carry a retired contract forward. +- **Depends on**: [IArchitectureMap](#iarchitecturemap), [ArchitectureRules](#architecturerules) (`ArchitectureRules.Events.cs` and `ArchitectureRules.Upcasters.cs`). - **Concept**: cross-references the delegating-base shape ([AggregateConventionTestsBase](#aggregateconventiontestsbase)). `[Rubric §6, CQRS & Event-Driven]` and `[Rubric §9, API & Contract Design]` assess versioned, discoverable cross-service event contracts. It pairs with [IntegrationEventContractTestsBase](#integrationeventcontracttestsbase), which freezes the exact shape. -- **Walkthrough**: three `[Fact]`s: `IntegrationEvents_ShouldDeclare_SchemaVersion` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/EventConventionTestsBase.cs:13`), `IntegrationEvents_ShouldInherit_BaseIntegrationEvent` (line 16), `IntegrationEvents_ShouldResideIn_SharedIntegrationEventsNamespace` (line 19). +- **Walkthrough**: five `[Fact]`s. The three schema rules come first: `IntegrationEvents_ShouldDeclare_SchemaVersion` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/EventConventionTestsBase.cs:14`), `IntegrationEvents_ShouldInherit_BaseIntegrationEvent` (line 17), `IntegrationEvents_ShouldResideIn_SharedIntegrationEventsNamespace` (line 20). Two upcaster rules follow ([ADR-090](https://ivanball.github.io/docs/adr/090-event-upcaster-registration.html), doc lines 6-7): `EventUpcasters_ShouldHave_UniqueSourceTypes` (line 23, delegating to `ArchitectureRules.Upcasters.cs:12`, because with two [IEventUpcaster](group-05-cqrs-pipeline.md#ieventupcaster) implementations reading one source contract the message a handler receives would depend on DI registration order) and `EventUpcasters_ShouldIncrease_SchemaVersion` (line 26, delegating to `ArchitectureRules.Upcasters.cs:28`, which skips a source or target whose `SchemaVersion` is missing or non-int, that being the first rule's business, lines 37-42). A repo with no upcasters passes both vacuously (doc, line 7). - **Where it's used**: subclassed in every repo that publishes integration events: Store (`MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/EventConventionTests.cs:3`), ADC (`MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/EventConventionTests.cs:3`), Helpdesk (`MMCA.Helpdesk/Tests/Architecture/MMCA.Helpdesk.Architecture.Tests/ArchitectureTests.cs:38`), and MMCA.Common itself under the name `EventVersioningConventionTests` (`MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/EventVersioningConventionTests.cs:12`). ### HandlerConventionTestsBase @@ -2281,7 +2655,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - **What it is**: a transport-boundary base for the modular-monolith to microservices path: MassTransit, gRPC, and Protobuf must never leak into Domain, Application, or Shared, so a module behaves identically in-process or extracted and the split stays reversible. - **Depends on**: [IArchitectureMap](#iarchitecturemap), [ArchitectureRules](#architecturerules) (`ArchitectureRules.Transport.cs:19`). -- **Concept**: cross-references the delegating-base shape ([AggregateConventionTestsBase](#aggregateconventiontestsbase)); the extraction invariant (application and domain code talks to abstractions, transport choices live at the edges) is the [ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html) / [ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html) / [ADR-008](https://ivanball.github.io/docs/adr/008-service-extraction-topology.html) story the doc cites (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/MicroserviceExtractionTestsBase.cs:3-7`). `[Rubric §7, Microservices Readiness]` assesses exactly this reversibility. +- **Concept**: cross-references the delegating-base shape ([AggregateConventionTestsBase](#aggregateconventiontestsbase)); the extraction invariant (application and domain code talks to abstractions, transport choices live at the edges) is the [ADR-006](https://ivanball.github.io/docs/adr/006-database-per-service.html) / [ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html) / [ADR-008](https://ivanball.github.io/docs/adr/008-service-extraction-topology.html) story the doc cites (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/MicroserviceExtractionTestsBase.cs:3-7`). `[Rubric §7, Microservices Readiness]` assesses exactly this reversibility. [ServiceContractPurityTestsBase](#servicecontractpuritytestsbase) guards the same boundary from the contract side. - **Walkthrough**: one `[Fact]` `CoreLayers_ShouldNotDependOn_Transport` (line 13) delegating to `ArchitectureRules.TransportDoesNotLeakIntoCoreLayers(Map)`. - **Where it's used**: subclassed in all four repos (`MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/MicroserviceExtractionTests.cs:10`, `MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/MicroserviceExtractionTests.cs:3`, `MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/MicroserviceExtractionTests.cs:3`, and Helpdesk at `MMCA.Helpdesk/Tests/Architecture/MMCA.Helpdesk.Architecture.Tests/ArchitectureTests.cs:109`). @@ -2304,7 +2678,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - The subclass supplies `Map` (line 18) and optionally `AllowedCycleNamespaces` (line 26), fully-qualified nodes whose cycle is accepted by design. - The allowance rule is the interesting part: a cycle is skipped only when *every* namespace on its reported path appears in the list, so an allowance can never hide a NEW cycle that merely touches an accepted namespace (doc, lines 21-25). The rule enforces that over the whole strongly connected component, not just the rendered shortest path (`ArchitectureRules.Cycles.cs:58-64`). - The single `[Fact]` `Namespaces_ShouldNotHave_DependencyCycles` (line 29) forwards both to the rule, which builds a per-assembly namespace graph from base types, interfaces, field, property, method return and parameter types, and attribute types, with generic arguments and array or by-ref element types expanded (`ArchitectureRules.Cycles.cs:25-28`, `:84`, `:174`). -- **Caveats / not-in-source**: the rule is signature-level reflection and blind to method bodies, because the package carries no IL or Roslyn dependency, so a green result means "no STRUCTURAL cycle", not "no coupling" (doc, lines 10-13; the rule's own statement of the limit at `ArchitectureRules.Cycles.cs:30-37`). Compiler-generated types are skipped deliberately so the answer stays a signature-level one. +- **Caveats / not-in-source**: the rule is signature-level reflection and blind to method bodies, because the package carries no IL or Roslyn dependency, so a green result means "no STRUCTURAL cycle", not "no coupling" (doc, lines 10-13; the rule's own statement of the limit at `ArchitectureRules.Cycles.cs:29-37`). Compiler-generated types are skipped deliberately so the answer stays a signature-level one. - **Where it's used**: subclassed today only in MMCA.Common, as [NamespaceCycleTests](#namespacecycletests) (`MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/NamespaceCycleTests.cs:9`). Its `AllowedCycleNamespaces` records the single accepted tangle in the framework, `MMCA.Common.Infrastructure` to `Settings` to `Persistence` and back (`:39-44`), with each of the three edges justified in the doc comment above it (`:13-38`). ### NamingConventionTestsBase @@ -2338,6 +2712,20 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - **Why it's built this way**: the remarks say the snapshot is meant to be regenerated deliberately by printing `ArchitectureRules.BuildProtoContract(...)` for the same files, as part of the commit that changes the contract, never edited to make a red test go green (`Bases/ProtoContractTestsBase.cs:12-17`). MMCA.Common ships no `.proto` of its own (it supplies the gRPC plumbing, not the contracts), so the framework does NOT subclass this (lines 9-11). - **Where it's used**: subclassed in the two repos with `*.Contracts` projects: ADC, pinning seven protos across four Contracts projects (`MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/ProtoContractTests.cs:3`, file list at `:9-18`), and Store, pinning three (`MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/ProtoContractTests.cs:9`, file list at `:13-18`). MMCA.Common exercises the underlying rule instead, from fixture protos including a deliberately drifted copy, in [ProtoContractFitnessTests](#protocontractfitnesstests) (`MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/ProtoContractFitnessTests.cs:14`). +### ServiceContractPurityTestsBase +> MMCA.Common.Testing.Architecture · `MMCA.Common.Testing.Architecture` · `MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/ServiceContractPurityTestsBase.cs:20` · Level 5 · abstract class + +- **What it is**: a one-rule delegating base asserting that every type marked with the framework's [ServiceContractAttribute](group-13-grpc-contracts.md#servicecontractattribute) stays free of the producing service's Domain, Application, and Infrastructure, so a consumer can take the contract package without taking the producer's internals. +- **Depends on**: [IArchitectureMap](#iarchitecturemap) and [ArchitectureRules](#architecturerules)`.ServiceContractsDoNotDependOnServiceInternals` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Contracts.cs:32`). +- **Concept introduced, the attribute-driven ratchet.** Two design choices are worth reading closely, and both are recorded in the base's remarks (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/ServiceContractPurityTestsBase.cs:8-18`). First, the rule is attribute-driven rather than [Layer](#layer)`.Contracts`-driven, because no repo registers that layer in its map today, so a layer-iterating rule would pass vacuously forever; scanning every registered assembly for the marker enforces the invariant wherever the contract types live. Second, the base is honest about the vacuous case: a repo that has marked no type yet asserts nothing, and the value is the *ratchet*, the invariant bites from the first marked type onward with no test left to remember. `[Rubric §7, Microservices Readiness]` assesses whether an extraction stays reversible; a contract that leaks a domain entity, a handler abstraction, or a persistence type forces every consumer to depend on the producer's internals, which is what makes an extraction irreversible (`ArchitectureRules.Contracts.cs:13-19`). `[Rubric §9, API & Contract Design]` assesses the published surface itself ([ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html)). +- **Walkthrough** + - The subclass supplies `Map` (line 22); the single `[Fact]` `ServiceContracts_ShouldNotDependOn_ServiceInternals` (line 25) forwards to the rule. + - In the rule, `ServiceInternalNamespaces` (`ArchitectureRules.Contracts.cs:57`) collects the distinct, ordered root namespaces of every Domain, Application and Infrastructure ref in the map (lines 59-65) and the rule returns immediately when that set is empty (lines 35-38). + - It then loops `map.Layers` and runs NetArchTest per assembly with `MeetCustomRule(CarriesServiceContractAttribute)` as the selector (lines 40-47). `CarriesServiceContractAttribute` (line 69) reads `Mono.Cecil.TypeDefinition` custom attributes and matches the constant `ServiceContractAttributeFullName` (line 10, `"MMCA.Common.Shared.Abstractions.ServiceContractAttribute"`) by string, the same zero-reference stance the rest of the library takes. + - Because the rule scans *every* registered assembly, a marked type that lives inside a Domain, Application or Infrastructure assembly fails by construction, and the remarks say that is the intent: a published contract belongs in a `*.Contracts` or Shared assembly (`ArchitectureRules.Contracts.cs:25-29`). +- **Where it's used**: subclassed once per repo, in all four: MMCA.Common (`MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/ServiceContractPurityTests.cs:11`), ADC (`MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/ServiceContractPurityTests.cs:9`), Store (`MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/ServiceContractPurityTests.cs:9`), and Helpdesk (`MMCA.Helpdesk/Tests/Architecture/MMCA.Helpdesk.Architecture.Tests/ServiceContractPurityTests.cs:9`); see [ServiceContractPurityTests](#servicecontractpuritytests). It complements, and does not replace, the transport-purity rule behind [MicroserviceExtractionTestsBase](#microserviceextractiontestsbase) and the layer-purity rules behind [LayerDependencyTestsBase](#layerdependencytestsbase), which guard the same boundary from the layer side ([ADR-015](https://ivanball.github.io/docs/adr/015-architecture-fitness-functions.html)). +- **Caveats / not-in-source**: no first-party type in any of the four repos carries `[ServiceContract]` today (the attribute's own doc records that MMCA.Common applies it to no type, `MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/ServiceContractAttribute.cs:10-12`), so every one of the four subclasses currently passes without asserting anything. That is the documented ratchet state, not a gap in the rule. + ### SharedLayerTestsBase > MMCA.Common.Testing.Architecture · `MMCA.Common.Testing.Architecture` · `MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/SharedLayerTestsBase.cs:7` · Level 5 · abstract class @@ -2394,7 +2782,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - `Wcag21Aa` (`AxeOptions.cs:17`) sets `RunOnly` to `Type = "tag"` with the four WCAG A/AA tag values `wcag2a`, `wcag2aa`, `wcag21a`, `wcag21aa` (`:19-23`). This is the target for every strict scan. - `Wcag21AaExceptMudPagerCombobox` (`:35`) repeats that tag set and adds a `Rules` dictionary disabling `aria-input-field-name` (`:42-45`), for grid list pages whose only violation is MudBlazor's internal `MudTablePager` "rows per page" select. The XML doc (`:26-34`) records the detail: MudBlazor 9.6.0 mirrored combobox semantics onto the hidden-input presenter, the pager's own select gets no accessible name, and it is not reachable from app markup (no `Label` or `aria-label` parameter on `MudTablePager`), so this is an accepted upstream limitation. The doc warns it must be used only on a page whose sole combobox is a pager. - **Why it's built this way**: shipping the options in the package rather than re-declaring them per test guarantees every consumer scans the identical rule set; the narrowly scoped pager exception keeps one known third-party gap from forcing a blanket rule-disable across all scans. -- **Where it's used**: passed to [PageExtensions](#pageextensions)`.AssertNoAccessibilityViolationsAsync` through [E2ETestBase](#e2etestbase)`.ScanAsync` (strict `Wcag21Aa`, `MMCA.Common.Testing.E2E/Infrastructure/E2ETestBase.cs:296`) and `.ScanGridAsync` (the pager exception, `:288`), and directly by the `*_ShouldHaveNoAccessibilityViolations` facts on [UserLoginTestsBase](#userlogintestsbase), [UserRegistrationTestsBase](#userregistrationtestsbase), and [ProfileManagementTestsBase](#profilemanagementtestsbase). +- **Where it's used**: passed to [PageExtensions](#pageextensions)`.AssertNoAccessibilityViolationsAsync` through [E2ETestBase](#e2etestbase)`.ScanAsync` (strict `Wcag21Aa`, `MMCA.Common.Testing.E2E/Infrastructure/E2ETestBase.cs:296`) and `.ScanGridAsync` (the pager exception, `:288`), and directly by the `*_ShouldHaveNoAccessibilityViolations` facts on [UserLoginTestsBase](#userlogintestsbase) (`MMCA.Common.Testing.E2E/Workflows/Identity/UserLoginTestsBase.cs:83`), [UserRegistrationTestsBase](#userregistrationtestsbase) (`:91`), [ProfileManagementTestsBase](#profilemanagementtestsbase) (`:180`), and [PasswordResetTestsBase](#passwordresettestsbase) (`MMCA.Common.Testing.E2E/Workflows/Identity/PasswordResetTestsBase.cs:88`, `:99`). ### E2ETestConfiguration > MMCA.Common.Testing.E2E · `MMCA.Common.Testing.E2E.Infrastructure` · `MMCA.Common.Testing.E2E/Infrastructure/E2ETestConfiguration.cs:8` · Level 0 · static class @@ -2410,6 +2798,15 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - **Why it's built this way**: separating `AuthTimeout` and `AuthGraceTimeout` from the general `DefaultTimeout` is deliberate. The auth round-trip (full sign-in plus `forceLoad` reload plus re-render) can spike past a normal action budget on a contended CI runner, so it is tuned independently rather than by inflating every timeout in the suite. The doc ties the grace window to the TD-06/07 contention cluster and names the rejected alternative, forcing WASM, which broke login (`:30-36`). - **Where it's used**: read throughout [PlaywrightFixture](#playwrightfixture) (engine, headless, slow-mo) and [E2ETestBase](#e2etestbase) (base URL, timeouts, trace path, credentials). +### ForgotPasswordPage +> MMCA.Common.Testing.E2E · `MMCA.Common.Testing.E2E.PageObjects` · `MMCA.Common.Testing.E2E/PageObjects/ForgotPasswordPage.cs:6` · Level 0 · sealed class + +- **What it is**: the Page Object for the shared `/forgot-password` screen, the entry point of the password-recovery flow. It exposes the address field, the submit button, the confirmation alert, and the return-to-login link, plus a one-call `RequestResetAsync` action. +- **Depends on**: `Microsoft.Playwright` (`IPage`, `ILocator`, `AriaRole`) and the [PageExtensions](#pageextensions) helpers `GotoAndWaitForBlazorAsync` and `FillAndVerifyAsync` (`MMCA.Common.Testing.E2E/PageObjects/ForgotPasswordPage.cs:1-2`). +- **Concept**: the Page Object Model taught in [LoginPage](#loginpage). One locator carries a design decision rather than a selector detail: `ConfirmationAlert` targets the success alert unconditionally, and the inline comment states why, the page lands on the same success alert whether or not the address has an account (`ForgotPasswordPage.cs:15-16`). That is the anti-enumeration contract of [ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html) expressed as a test affordance: there is deliberately no "unknown address" locator to assert on, because the UI must not render one. `[Rubric §11, Security]` assesses whether account enumeration is closed off; a Page Object that cannot express the enumerating assertion is a small structural guard on that. `[Rubric §28, Front-End Testing]` applies as with every Page Object here. +- **Walkthrough**: a private `IPage` field set in the constructor (`ForgotPasswordPage.cs:8-10`); `EmailField` located by label and `SubmitButton` by its accessible name "Send a password reset link" (`:12-13`); `ConfirmationAlert` as MudBlazor's `.mud-alert-text-success` class (`:16`); `BackToLoginLink` located by **link** role, with the comment recording that "Back to Sign In" is a MudButton with `Href` and therefore renders as an `` (`:18-19`). `GotoAsync` full-loads `/forgot-password` and waits for interactivity (`:21-22`). `RequestResetAsync` fills the address through [PageExtensions](#pageextensions)`.FillAndVerifyAsync` and clicks submit (`:24-28`). +- **Where it's used**: driven by [PasswordResetTestsBase](#passwordresettestsbase) for the unknown-address confirmation fact and the a11y fact (`MMCA.Common.Testing.E2E/Workflows/Identity/PasswordResetTestsBase.cs:47-57`, `:82-88`), and by the framework's own gallery suite, which exercises it against the backend-less gallery host including the confirmation state's own separate scan (`MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/ForgotPasswordPageE2ETests.cs:19-24`, `:32-37`, `:43-46`, `:54-60`). + ### LoginPage > MMCA.Common.Testing.E2E · `MMCA.Common.Testing.E2E.PageObjects` · `MMCA.Common.Testing.E2E/PageObjects/LoginPage.cs:6` · Level 0 · sealed class @@ -2417,7 +2814,8 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - **Depends on**: `Microsoft.Playwright` (`IPage`, `ILocator`, `AriaRole`) and the [PageExtensions](#pageextensions) helpers `GotoAndWaitForBlazorAsync` and `FillAndVerifyAsync` (`MMCA.Common.Testing.E2E/PageObjects/LoginPage.cs:1-2`). - **Concept introduced, the Page Object Model.** A Page Object wraps one screen behind an intention-revealing API, locating controls by their accessible name (`GetByLabel("Email")`, `GetByRole(AriaRole.Button, Name = "Sign in to your account")`) rather than by brittle CSS. That keeps tests coupled to what a user sees, not to MudBlazor's internal class names, and it centralizes each selector in one place. `[Rubric §28, Front-End Testing]` assesses whether E2E tests are maintainable; the Page Object is the canonical pattern for that. `[Rubric §21, Accessibility]` applies indirectly: locating by role and label only works if the component renders proper accessible names, so the test style pressures accessible markup. - **Walkthrough**: a private `IPage` field set in the constructor (`LoginPage.cs:8-10`); locator properties for `EmailField`, `PasswordField`, `LoginButton`, the `ErrorAlert` (MudBlazor's `.mud-alert-text-error` class), and the `CreateAccountLink`, which the inline comment explains is a MudButton with `Href` and therefore renders as an `` located by link role (`:12-18`). `GotoAsync` navigates through `GotoAndWaitForBlazorAsync("/login")` (`:20-21`); `LoginAsync` fills both fields through the shared `FillFieldAsync` and then clicks (`:23-28`). The private `FillFieldAsync` delegates to [PageExtensions](#pageextensions)`.FillAndVerifyAsync` (`:31-32`), guarding the Blazor re-hydration race without a fixed delay. -- **Where it's used**: instantiated by [UserLoginTestsBase](#userlogintestsbase) for the invalid-password, create-account-link, and accessibility facts. +- **Where it's used**: instantiated by [UserLoginTestsBase](#userlogintestsbase) for the invalid-password, create-account-link, and accessibility facts, and by [PasswordResetTestsBase](#passwordresettestsbase) to reach the login screen before probing the recovery entry point (`MMCA.Common.Testing.E2E/Workflows/Identity/PasswordResetTestsBase.cs:28-29`). +- **Caveats / not-in-source**: the Page Object exposes no locator for the "Forgot your password?" link; the one test that asserts it locates it directly off the page (`PasswordResetTestsBase.cs:31`). ### ProfilePage > MMCA.Common.Testing.E2E · `MMCA.Common.Testing.E2E.PageObjects` · `MMCA.Common.Testing.E2E/PageObjects/ProfilePage.cs:6` · Level 0 · sealed class @@ -2425,8 +2823,8 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - **What it is**: the Page Object for the authenticated `/profile` screen, exposing the name, address, and password sections' fields and buttons as named locators. - **Depends on**: `Microsoft.Playwright` and [PageExtensions](#pageextensions)`.BlazorNavigateAsync` (`MMCA.Common.Testing.E2E/PageObjects/ProfilePage.cs:1-2`). - **Concept**: the Page Object Model taught in [LoginPage](#loginpage). One difference is load-bearing: `GotoAsync` uses `BlazorNavigateAsync("/profile")`, client-side routing (`ProfilePage.cs:34-35`), not a full page load, because `/profile` is `[Authorize]` and server-side rendering cannot read the JWT from browser storage, so a full load would bounce to `/login`. `[Rubric §28, Front-End Testing]` and `[Rubric §11, Security]` both apply: exercising the authenticated page correctly requires respecting the client-token boundary. -- **Walkthrough**: three grouped sets of locators. Name (`FirstNameField`, `LastNameField`, `SaveNameButton`, `:13-15`), address (`AddressLine1Field` through `CountryField` plus `SaveAddressButton`, `:18-24`), and password (`CurrentPasswordField`, `NewPasswordField` with `Exact = true` so it does not also match "Confirm New Password", `ConfirmNewPasswordField`, `ChangePasswordButton`, `:27-30`), plus a generic `ErrorAlert` located by the alert role (`:32`). This Page Object has no bulk action method: each fact drives the individual locators. -- **Where it's used**: instantiated throughout [ProfileManagementTestsBase](#profilemanagementtestsbase), and directly by ADC's own `ProfileManagementTests`, which drives the same Page Object off [E2ETestBase](#e2etestbase) instead of the shared base (`MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/Identity/ProfileManagementTests.cs:15`). +- **Walkthrough**: three commented locator groups. Name (`FirstNameField`, `LastNameField`, `SaveNameButton`, `:12-15`); address (six fields plus `SaveAddressButton`, `:17-24`); password (`CurrentPasswordField`, `NewPasswordField` located with `Exact = true` so it does not also match "Confirm New Password", `ConfirmNewPasswordField`, `ChangePasswordButton`, `:26-30`). `ErrorAlert` is located by ARIA alert role rather than a MudBlazor class (`:32`). `GotoAsync` is the client-side navigation described above (`:34-35`). +- **Where it's used**: by [ProfileManagementTestsBase](#profilemanagementtestsbase) for all six of its facts, and by ADC's own `ProfileManagementTests`, which drives the same Page Object without deriving from the shared base (`MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/Identity/ProfileManagementTests.cs:30-40`). ### RegisterPage > MMCA.Common.Testing.E2E · `MMCA.Common.Testing.E2E.PageObjects` · `MMCA.Common.Testing.E2E/PageObjects/RegisterPage.cs:6` · Level 0 · sealed class @@ -2437,6 +2835,16 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - **Walkthrough**: locator properties for the five required fields plus `RegisterButton` and `ErrorAlert` (`:12-18`), the `AlreadyHaveAccountLink` sign-in link (`:21`), and the optional address panel and fields (`:24-29`). `GotoAsync` full-loads `/register` (`:31-32`); `RegisterAsync` fills the five required fields through the shared helper, reusing the same password for the confirm field, then clicks (`:34-42`); the private `FillFieldAsync` delegates to [PageExtensions](#pageextensions)`.FillAndVerifyAsync` (`:48-49`). - **Where it's used**: instantiated by [UserRegistrationTestsBase](#userregistrationtestsbase) for all four of its facts. +### ResetPasswordPage +> MMCA.Common.Testing.E2E · `MMCA.Common.Testing.E2E.PageObjects` · `MMCA.Common.Testing.E2E/PageObjects/ResetPasswordPage.cs:6` · Level 0 · sealed class + +- **What it is**: the Page Object for the shared `/reset-password` screen, the redemption half of the recovery flow. It exposes the address, token, and new-password fields, both outcome alerts, the return-to-login link, and two navigation entry points: the bare page and the prefilled emailed-link form. +- **Depends on**: `Microsoft.Playwright` and the [PageExtensions](#pageextensions) helpers `GotoAndWaitForBlazorAsync` and `FillAndVerifyAsync` (`MMCA.Common.Testing.E2E/PageObjects/ResetPasswordPage.cs:1-2`). +- **Concept**: the Page Object Model taught in [LoginPage](#loginpage). What is worth teaching here is `GotoWithLinkAsync`, which reproduces the way a real user arrives: the emailed link carries the address and the token as query parameters, so both fields land prefilled and the test exercises the same route the mail does (`ResetPasswordPage.cs:30-36`). The fields stay editable, which the doc comment records is deliberate, so the raw token from the same email can also be typed by hand. `[Rubric §24, Forms/Validation/UX Safety]` assesses whether the recovery form's real arrival paths are covered; modelling both the bare and the linked entry is how this Page Object does it. +- **Walkthrough**: a private `IPage` field set in the constructor (`:8-10`); `EmailField` and `TokenField` located by label (`:12-13`); `NewPasswordField` located with `Exact = true`, with the comment spelling out that "New Password" is a substring of "Confirm New Password" so the default substring match would resolve to both fields (`:15-17`), and `ConfirmPasswordField` beside it (`:18`). `SubmitButton` is located by the accessible name "Reset your password" (`:20`); `ErrorAlert` and `SuccessAlert` are the two MudBlazor alert classes (`:21-22`); `GoToLoginLink` is again a link-role locator over a MudButton with `Href` (`:24-25`). `GotoAsync` loads the bare page (`:27-28`); `GotoWithLinkAsync` builds `/reset-password?email=...&token=...` with `Uri.EscapeDataString` on both values (`:34-36`); `ResetAsync` fills all four fields through `FillAndVerifyAsync` and clicks submit (`:38-45`). +- **Where it's used**: by [PasswordResetTestsBase](#passwordresettestsbase) for the empty-form validation fact and the a11y fact (`MMCA.Common.Testing.E2E/Workflows/Identity/PasswordResetTestsBase.cs:64-68`, `:95-99`), and by the framework's gallery suite, which additionally asserts the query-string prefill round-trip (`MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/ResetPasswordPageE2ETests.cs:19-27`, `:35-39`, `:45-48`). +- **Caveats / not-in-source**: no test in this package submits a genuine token. [PasswordResetTestsBase](#passwordresettestsbase)'s doc states why (the token only reaches the user by email, so consuming one is an app-side integration-test concern, `PasswordResetTestsBase.cs:10-15`), so `ResetAsync` and `SuccessAlert` are shipped affordances that the framework's own suites do not currently drive end to end. + ### UserCredentials > MMCA.Common.Testing.E2E · `MMCA.Common.Testing.E2E.Infrastructure` · `MMCA.Common.Testing.E2E/Infrastructure/E2ETestConfiguration.cs:78` · Level 0 · nested static class @@ -2466,7 +2874,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - `extension(ILocator locator)` (`:185`). `FillAndVerifyAsync` fills, then auto-waits `ToHaveValueAsync`, and if the value was wiped by re-hydration it clears the field, re-types character by character with a 20 ms delay, and re-asserts (`:197-216`). This is the single shared fill helper the base and the Page Objects all call. `ClickAndVerifyAsync` waits for interactivity, then clicks and waits a third of the timeout for the expected effect, up to three clicks in total, so a genuinely applied click is never re-issued and only a no-op click is retried (`:230-261`). `ClickAndWaitForUrlAsync` clicks a navigating link and re-clicks until the URL matches the supplied regular expression, for grid rows whose cells wrap content in `MudLink` so a row-center click lands on padding (`:273-296`). - The private `CompactHtml` collapses a violating node's markup to one trimmed line, truncated at 220 characters, so the failure message points at the exact offending element (`:305-314`). - **Why it's built this way**: the fill and click helpers exist because InteractiveAuto's prerender-then-hydrate model makes a bare fill or click a race on a fast host; auto-waiting assertions with a bounded re-type or re-click are strictly safer than fixed delays, since they succeed as soon as the value or effect appears. Two `[SuppressMessage]` attributes document analyzer false positives across the `extension(T)` boundary: CA1708 on the class, where the compiler-generated grouping members read as case-colliding (`:15-18`), and IDE0051 on `CompactHtml`, which the SDK 10.0.201+ analyzer cannot see being called from inside the extension block (`:301-304`). -- **Where it's used**: throughout the Page Objects ([LoginPage](#loginpage), [ProfilePage](#profilepage), [RegisterPage](#registerpage)), inside [E2ETestBase](#e2etestbase) (`FillFieldAsync`, `ScanAsync`, `ScanGridAsync`, the navigation helpers), and directly by every workflow base in this group. +- **Where it's used**: throughout the Page Objects ([ForgotPasswordPage](#forgotpasswordpage), [LoginPage](#loginpage), [ProfilePage](#profilepage), [RegisterPage](#registerpage), [ResetPasswordPage](#resetpasswordpage)), inside [E2ETestBase](#e2etestbase) (`FillFieldAsync`, `ScanAsync`, `ScanGridAsync`, the navigation helpers), and directly by every workflow base in this group. ### PlaywrightFixture > MMCA.Common.Testing.E2E · `MMCA.Common.Testing.E2E.Infrastructure` · `MMCA.Common.Testing.E2E/Infrastructure/PlaywrightFixture.cs:6` · Level 1 · sealed class @@ -2495,8 +2903,8 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - **Walkthrough**: a positional record with five defaulted parameters, `Lcp = 2500`, `Fcp = 1800`, `Ttfb = 800`, `Cls = 0.1`, `Inp = 500` (`WebVitalsCollector.cs:103-108`), all milliseconds except the unitless CLS. Two members. - The static `Describe(label, path, sample)` (`:118`) renders one invariant-culture line, `[web-vitals:{label}] path=... LCP=...ms FCP=...ms CLS=... TTFB=...ms INP-sample=...ms`, with CLS at three decimals and the rest at zero (`:122-124`), which is the record a reviewer greps for next to the uploaded JSON artifact. - `AssertWithinBudget(sample, label, path, writeLine = null)` (`:137`) invokes the optional sink with that line (normally `ITestOutputHelper.WriteLine`, `:141`), then asserts LCP, FCP, TTFB, and CLS against their ceilings (`:143-146`). INP is asserted **only when `sample.Inp > 0`** (`:148-151`), because no interaction clearing the collector's 16 ms event threshold leaves the sample at 0, and 0 must read as neither a pass-by-absence nor a failure. Failure text comes from the private `Message` helper, which names the metric, the measured value, the ceiling, and the page path (`:154-157`). -- **Why it's built this way**: keeping the numbers consumer-side while shipping the assert body is what lets ADC and Store hold different calibrated budgets without either repo re-deriving the INP-zero rule or the message format. The 0-INP carve-out is the subtle one, and it is pinned by its own unit test rather than left to a comment (`MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/WebVitalsBudgetTests.cs:58-64`). -- **Where it's used**: ADC holds one static default instance and takes the framework numbers as-is (`MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/WebVitalsTests.cs:27`, asserted at `:79`); Store constructs one per measurement (`MMCA.Store/Tests/E2E/MMCA.Store.E2E.Tests/Workflows/WebVitalsTests.cs:68`, `:94`). The framework's own gallery suite instead asserts against local constants tuned for the backend-less host (`MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/WebVitalsE2ETests.cs:18-20,43-45`), and `WebVitalsBudgetTests` covers the record's mechanics without starting a browser (`WebVitalsBudgetTests.cs:12`). +- **Why it's built this way**: keeping the numbers consumer-side while shipping the assert body is what lets ADC and Store hold different calibrated budgets without either repo re-deriving the INP-zero rule or the message format. The 0-INP carve-out is the subtle one, and it is pinned by its own unit test rather than left to a comment (`MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/WebVitalsBudgetTests.cs:59-64`). +- **Where it's used**: ADC holds one static default instance and takes the framework numbers as-is (`MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/WebVitalsTests.cs:27`, asserted at `:86`); Store constructs one per measurement (`MMCA.Store/Tests/E2E/MMCA.Store.E2E.Tests/Workflows/WebVitalsTests.cs:68`, `:94`). The framework's own gallery suite instead asserts against local constants tuned for the backend-less host (`MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/WebVitalsE2ETests.cs:18-20,43-45`), and `WebVitalsBudgetTests` covers the record's mechanics without starting a browser (`WebVitalsBudgetTests.cs:12`). ### E2ETestCollection > MMCA.Common.Testing.E2E · `MMCA.Common.Testing.E2E.Infrastructure` · `MMCA.Common.Testing.E2E/Infrastructure/PlaywrightFixture.cs:48` · Level 2 · sealed class @@ -2506,7 +2914,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - **Concept introduced, the xUnit collection fixture binding.** A collection fixture is instantiated once and shared by every test class that opts into the collection by name. This class carries a `public const string Name = "E2E"` (`:50`) used both in its own `[CollectionDefinition(Name)]` and in each test's `[Collection(E2ETestCollection.Name)]`, so the string is declared once and cannot drift. `[Rubric §14, Testability]` assesses fixture design; a single named constant binding is the robust way to share a fixture. - **Walkthrough**: an otherwise empty class body carrying the collection definition and the `Name` constant (`:47-51`). It exists purely as an xUnit marker, and it lives in the same file as the fixture it binds. - **Where it's used**: referenced by [E2ETestBase](#e2etestbase)'s `[Collection(E2ETestCollection.Name)]` attribute (`MMCA.Common.Testing.E2E/Infrastructure/E2ETestBase.cs:7`), so every workflow base and every consumer subclass inherits collection membership. -- **Caveats / not-in-source**: xUnit collection definitions do not cross assembly boundaries, so each consumer E2E assembly re-declares its own identically named definition over the same fixture type (`MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Infrastructure/E2ETestCollection.cs:7-11`). +- **Caveats / not-in-source**: xUnit collection definitions do not cross assembly boundaries, so each consumer E2E assembly re-declares its own identically named definition over the same fixture type, and says so in its doc comment (`MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Infrastructure/E2ETestCollection.cs:3-11`). ### WebVitalsCollector > MMCA.Common.Testing.E2E · `MMCA.Common.Testing.E2E.Infrastructure` · `MMCA.Common.Testing.E2E/Infrastructure/WebVitalsCollector.cs:20` · Level 2 · static class @@ -2516,7 +2924,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - **Concept introduced, in-browser performance measurement with no third-party JS.** Rather than shipping an analytics SDK, it injects a small init script that installs `PerformanceObserver`s for LCP, CLS, FCP, and INP, each wrapped in try/catch so an engine lacking an entry type leaves that metric at 0 instead of throwing, and accumulates into `window.__vitals` (`:26-35`). The type doc is explicit that this is the client-side analogue of a backend load test, not a cross-engine field measurement: LCP and CLS are Chromium-only, so on Firefox and WebKit those fields stay 0 and budget assertions pass (`:9-18`). `[Rubric §23, Front-End Performance]` and `[Rubric §12, Performance & Scalability]` assess whether user-centric performance is measured; observing the vitals APIs directly, with no network egress, is a self-contained way to do it. The same doc states the class is only the measurement infrastructure, that [WebVitalsBudget](#webvitalsbudget) is the shared assert mechanics, and that consumers own which pages carry a budget and what the numbers are (`:16-18`). - **Walkthrough**: `InstallAsync` registers the observers through `AddInitScriptAsync` so they are active on the next navigation (`:40-44`). `CollectAsync` evaluates a script that stamps TTFB from Navigation Timing and returns `window.__vitals` as JSON, deserialized into a [WebVitalsSample](#webvitalssample) (`:47-57`). `WriteArtifactAsync` resolves the output directory from `WEB_VITALS_OUTPUT_DIR` or falls back to `artifacts/` under the current directory, creates it, wraps the sample in a [WebVitalsArtifact](#webvitalsartifact), and writes `web-vitals-{label}.json` indented (`:63-72`). - **Why it's built this way**: the observers install before the document's own scripts (through `AddInitScript`) so early metrics such as FCP are not missed, and the per-observer try/catch is what makes the same code run green on all three engines despite the Chromium-only metrics. The init script is kept as one concatenated string rather than a raw literal to stay clear of the MA0136 analyzer (`:22-25`). -- **Where it's used**: by the budget-asserting tests each repo owns, which install, navigate, collect, assert, and write the artifact for CI upload: the framework's own gallery suite (`MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/WebVitalsE2ETests.cs:37-41`), ADC (`MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/WebVitalsTests.cs:58`, `:76-77`), and Store (`MMCA.Store/Tests/E2E/MMCA.Store.E2E.Tests/Workflows/WebVitalsTests.cs:73`, `:91-92`). +- **Where it's used**: by the budget-asserting tests each repo owns, which install, navigate, collect, assert, and write the artifact for CI upload: the framework's own gallery suite (`MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/WebVitalsE2ETests.cs:37-41`), ADC (`MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/WebVitalsTests.cs:65`, `:83-84`), and Store (`MMCA.Store/Tests/E2E/MMCA.Store.E2E.Tests/Workflows/WebVitalsTests.cs:73`, `:91-92`). ### E2ETestBase > MMCA.Common.Testing.E2E · `MMCA.Common.Testing.E2E.Infrastructure` · `MMCA.Common.Testing.E2E/Infrastructure/E2ETestBase.cs:8` · Level 3 · abstract class @@ -2530,17 +2938,17 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - Post-auth robustness. `WaitForInteractiveOrReloadAsync` (`:192-203`) waits for interactivity and, on either a `PlaywrightException` or a `TimeoutException`, reloads once and re-waits rather than watching the same stuck boot; the comment records why both exception types are caught (Playwright's `TimeoutException` derives from `System.TimeoutException`, not `PlaywrightException`, so an earlier single catch skipped the retry entirely) and why a reload beats a re-wait (the framework assets are now HTTP-cached, `:181-191`). `WaitForAuthResultAsync` (`:213-236`) races three signals through `Task.WhenAny`, leaving the auth page, the logout button appearing, or an error alert appearing, so success detection does not depend on the interactive button having hydrated; only an error alert still visible on the auth page after the grace window is a real failure, raised as an `InvalidOperationException` carrying the alert text. `AuthSucceededWithinGraceAsync` (`:241-259`) implements that grace window, falling back to the logout-button signal when no navigation occurs. - Helpers. `NavigateAndWaitAsync` (`:261-262`), the shared static `FillFieldAsync` delegating to [PageExtensions](#pageextensions)`.FillAndVerifyAsync` (`:269-270`), `UniqueId` (`:272`), and the two scan helpers. `ScanGridAsync` (`:283-289`) waits for a visible data row and for zero `[role='progressbar']` elements, then scans with [AxeOptions](#axeoptions)`.Wcag21AaExceptMudPagerCombobox`; `ScanAsync` (`:293-297`) applies the progressbar guard only and scans strictly with `Wcag21Aa`. - **Why it's built this way**: the auth helpers encode hard-won timing knowledge once (the `forceLoad` reload, the Server-versus-WASM hydration lag, the cookie-and-localStorage dual session store), so every consumer workflow inherits a deterministic sign-in instead of re-deriving the races. Clearing both token stores is essential: the Blazor Server host is cookie-only, so a localStorage clear alone would leave the next login authenticated as the wrong user (`:99-104`). The scan split lets grid pages accept the documented pager-combobox exception while every other page stays strict, and the grid wait keys off a data row rather than the loading bar hiding, which would resolve instantly before the transient unnamed progressbar even appears (`:274-282`). -- **Where it's used**: the base class of all six workflow bases in this unit ([AuthorizationTestsBase](#authorizationtestsbase), [LogoutTestsBase](#logouttestsbase), [ProfileManagementTestsBase](#profilemanagementtestsbase), [UserLoginTestsBase](#userlogintestsbase), [UserPreferencesTestsBase](#userpreferencestestsbase), [UserRegistrationTestsBase](#userregistrationtestsbase)) and, through them and directly, every E2E test class in the ADC and Store suites (for example ADC's own `ProfileManagementTests`, which derives from this base rather than the shared profile workflow, `MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/Identity/ProfileManagementTests.cs:8`). +- **Where it's used**: the base class of all seven workflow bases in this unit ([AuthorizationTestsBase](#authorizationtestsbase), [LogoutTestsBase](#logouttestsbase), [PasswordResetTestsBase](#passwordresettestsbase), [ProfileManagementTestsBase](#profilemanagementtestsbase), [UserLoginTestsBase](#userlogintestsbase), [UserPreferencesTestsBase](#userpreferencestestsbase), [UserRegistrationTestsBase](#userregistrationtestsbase)) and, through them and directly, every E2E test class in the ADC and Store suites (for example ADC's own `ProfileManagementTests`, which derives from this base rather than the shared profile workflow, `MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/Identity/ProfileManagementTests.cs:8`). ### AuthorizationTestsBase > MMCA.Common.Testing.E2E · `MMCA.Common.Testing.E2E.Workflows.Identity` · `MMCA.Common.Testing.E2E/Workflows/Identity/AuthorizationTestsBase.cs:18` · Level 4 · abstract class - **What it is**: the reusable authorization workflow fitness base, authored once and re-run as a thin subclass per repo. It asserts that anonymous users are redirected off protected paths, that public paths stay reachable, that a registered non-admin can reach an authenticated page, and that a non-admin probing admin routes gets the Forbidden page. - **Depends on**: [E2ETestBase](#e2etestbase), [PageExtensions](#pageextensions) (`GotoAndWaitForBlazorAsync`, `GotoProtectedAsync`), AwesomeAssertions, and `Microsoft.Playwright` (`MMCA.Common.Testing.E2E/Workflows/Identity/AuthorizationTestsBase.cs:1-6`). -- **Concept introduced, the authored-once workflow fitness base.** This is the pattern shared by all six bases in this unit: the framework owns the assertions and the SSR-versus-client-navigation mechanics, and each consumer supplies only its own route lists through abstract or virtual members, so identical security behavior is verified across repos without copying test bodies (`:10-17`). `[Rubric §11, Security]` assesses whether authorization is actually exercised; this base machine-checks both the anonymous-redirect and the authenticated-non-admin-escalation directions. `[Rubric §25, Navigation & IA]` applies because it pins which routes are public and which are gated. +- **Concept introduced, the authored-once workflow fitness base.** This is the pattern shared by all seven bases in this unit: the framework owns the assertions and the SSR-versus-client-navigation mechanics, and each consumer supplies only its own route lists through abstract or virtual members, so identical security behavior is verified across repos without copying test bodies (`:10-17`). `[Rubric §11, Security]` assesses whether authorization is actually exercised; this base machine-checks both the anonymous-redirect and the authenticated-non-admin-escalation directions. `[Rubric §25, Navigation & IA]` applies because it pins which routes are public and which are gated. - **Walkthrough**: the subclass supplies `ProtectedPaths` and `PublicPaths` (abstract, `:26`, `:29`) and optionally `AuthenticatedUserPath` and `AdminPaths` (virtual, defaulting to null and an empty list, `:35`, `:44`). Four facts follow. `AnonymousUser_ProtectedPages_ShouldRedirectToLogin` asserts each protected path bounces to `/login` (`:46-58`). `AnonymousUser_PublicPages_ShouldBeAccessible` asserts each public path stays put (`:60-72`). `RegisteredUser_AuthenticatedPage_ShouldBeAccessible` registers a non-admin, then client-navigates through `GotoProtectedAsync` because SSR cannot read the JWT, passing vacuously when no path is declared (`:74-93`). `RegisteredUser_AdminPages_ShouldBeForbidden` registers a non-admin, then asserts each admin path renders the shared Forbidden page, matching `h1[role='alert']` containing "Access Denied", with the comment noting that role denial is not a redirect so the page content is the only reliable signal (`:95-120`). - **Why it's built this way**: the two optional members use a no-dynamic-skip convention (an app with no such page simply passes) because the shipped library deliberately does not reference `xunit.v3.assert` for a declared skip (`:77-78`, `:98-99`). The non-empty assertions on `ProtectedPaths` and `PublicPaths` (`:49-50`, `:63-64`) are non-vacuity guards: a repo that declares no paths fails rather than passing silently. -- **Where it's used**: subclassed in both consumer E2E suites with that app's route lists (`MMCA.Store/Tests/E2E/MMCA.Store.E2E.Tests/Workflows/Identity/AuthorizationTests.cs:11-21`, `MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/Identity/AuthorizationTests.cs:11-22`); Store's subclass also adds one app-specific fact of its own, an anonymous order-detail deep link that must leak no order content (`AuthorizationTests.cs:23-37`). +- **Where it's used**: subclassed in both consumer E2E suites with that app's route lists (`MMCA.Store/Tests/E2E/MMCA.Store.E2E.Tests/Workflows/Identity/AuthorizationTests.cs:11-21`, `MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/Identity/AuthorizationTests.cs:11-39`, whose twelve-entry `AdminPaths` carries a comment explaining which authenticated-but-not-Organizer routes are deliberately excluded, `:21-24`); Store's subclass also adds one app-specific fact of its own, an anonymous order-detail deep link that must leak no order content (`AuthorizationTests.cs:23-37`). ### LogoutTestsBase > MMCA.Common.Testing.E2E · `MMCA.Common.Testing.E2E.Workflows.Identity` · `MMCA.Common.Testing.E2E/Workflows/Identity/LogoutTestsBase.cs:9` · Level 4 · abstract class @@ -2552,6 +2960,20 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - **Why it's built this way**: waiting for the cookie-clear response is the fix for a real full-speed race. At speed the test otherwise reaches `/profile` before the DELETE finishes, so the HttpOnly cookie is still present and SSR re-authenticates. The bounded re-request loop converges deterministically where any slowdown (slow-mo, or even trace capture) would have hidden the race entirely (`:42-46`, `:57-63`). - **Where it's used**: subclassed in both consumer E2E suites (`MMCA.Store/Tests/E2E/MMCA.Store.E2E.Tests/Workflows/Identity/LogoutTests.cs:5`, `MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/Identity/LogoutTests.cs:5`). +### PasswordResetTestsBase +> MMCA.Common.Testing.E2E · `MMCA.Common.Testing.E2E.Workflows.Identity` · `MMCA.Common.Testing.E2E/Workflows/Identity/PasswordResetTestsBase.cs:17` · Level 4 · abstract class + +- **What it is**: the reusable password-recovery workflow base covering the shared `/forgot-password` and `/reset-password` pages: the entry point is reachable from the login screen, an unknown address gets the same confirmation as a known one, the reset form's client-side validation blocks an empty submit, and both pages are accessibility-clean. +- **Depends on**: [E2ETestBase](#e2etestbase), [LoginPage](#loginpage), [ForgotPasswordPage](#forgotpasswordpage), [ResetPasswordPage](#resetpasswordpage), [PageExtensions](#pageextensions), [AxeOptions](#axeoptions), and `Microsoft.Playwright` (`MMCA.Common.Testing.E2E/Workflows/Identity/PasswordResetTestsBase.cs:1-6`). +- **Concept introduced, drawing the E2E boundary around what a browser can honestly observe.** The type doc states outright that the real token round-trip is deliberately not exercised here: the token only reaches the user by email, so redeeming one is an app-side integration-test concern, and what E2E owns is the reachability of the flow, the anti-enumeration confirmation, client-side validation, and WCAG 2.1 AA conformance of both pages (`:10-16`). That is a scope decision worth internalizing, a browser test that cannot reach the mailbox should assert the contract it *can* see rather than fake the one it cannot. `[Rubric §11, Security]` assesses whether the recovery flow leaks account existence; the unknown-address fact is the machine check on [ADR-091](https://ivanball.github.io/docs/adr/091-cache-backed-password-reset.html)'s anti-enumeration rule. `[Rubric §24, Forms/Validation/UX Safety]` covers the empty-submit validation, `[Rubric §21, Accessibility]` the two scans, and `[Rubric §25, Navigation & IA]` the entry-point fact, since a locked-out user has no other way in. +- **Walkthrough**: five facts, no abstract members, so a consumer subclass is a single line. + - `LoginPage_ForgotPasswordLink_NavigatesToForgotPasswordPage` (`:25-41`) opens the [LoginPage](#loginpage), asserts the "Forgot your password?" link is visible at all (the comment notes a user locked out of their account has no other entry point, `:33-34`), clicks it, and asserts the URL ends at `/forgot-password` (`:40`). + - `ForgotPassword_WithUnknownEmail_ShowsTheSameConfirmation` (`:44-58`) submits a `unknown-{UniqueId()}@test.com` address that certainly has no account, then asserts the positive path exactly: the success confirmation appears (`:55`), the URL stays on `/forgot-password` (`:56`), and the back-to-login link is visible (`:57`). There is no error alert and no navigation to distinguish it from a real address, which is the whole point. + - `ResetPassword_WithEmptyForm_ShowsClientValidationErrors` (`:61-76`) clicks submit on an untouched form; DataAnnotations block `OnValidSubmit` so nothing is sent, and the fact asserts the field-level texts "Email is required" and "Reset token is required" plus staying on `/reset-password` (`:73-75`). + - `ForgotPasswordPage_ShouldHaveNoAccessibilityViolations` (`:79-89`) and `ResetPasswordPage_ShouldHaveNoAccessibilityViolations` (`:92-100`) each load their page and scan with [AxeOptions](#axeoptions)`.Wcag21Aa`. +- **Why it's built this way**: the validation fact asserts the field-level **text** rather than a page-level alert, and the source gives the reason, those messages are present in both render modes (Server prerender and WebAssembly) while a page-level alert is not, the same reasoning as the mismatched-password registration test (`:70-72`, and [UserRegistrationTestsBase](#userregistrationtestsbase)`.Register_WithMismatchedPasswords_ShouldShowError`). The a11y scans use the strict `Wcag21Aa` preset explicitly rather than an unscoped `RunAxe`, keeping this workflow on the same documented target as the rest of Identity (`:85-88`). +- **Where it's used**: subclassed with no additions in both consumer E2E suites (`MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/Identity/PasswordResetTests.cs:5`, `MMCA.Store/Tests/E2E/MMCA.Store.E2E.Tests/Workflows/Identity/PasswordResetTests.cs:5`). The two Page Objects it drives are additionally exercised against the framework's own backend-less gallery host by `ForgotPasswordPageE2ETests` and `ResetPasswordPageE2ETests` (`MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/ForgotPasswordPageE2ETests.cs:9`, `MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/ResetPasswordPageE2ETests.cs:9`). + ### ProfileManagementTestsBase > MMCA.Common.Testing.E2E · `MMCA.Common.Testing.E2E.Workflows.Identity` · `MMCA.Common.Testing.E2E/Workflows/Identity/ProfileManagementTestsBase.cs:11` · Level 4 · abstract class @@ -2560,7 +2982,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - **Concept**: the authored-once workflow base taught in [AuthorizationTestsBase](#authorizationtestsbase), here driving a [ProfilePage](#profilepage). `[Rubric §24, Forms/Validation/UX Safety]` assesses whether edit-and-persist journeys work end to end; `[Rubric §21, Accessibility]` applies through the a11y fact. - **Walkthrough**: one virtual switch, `ProfileSupportsEmailChange`, off by default (`:24`). Six facts follow. `ChangeName_ShouldUpdateProfileName` clears and fills both name fields, saves, re-navigates, and asserts the values persisted (`:26-53`); `ChangeAddress_ShouldUpdateProfileAddress` does the same for the five address fields and asserts on line 1 (`:55-78`). Both use Playwright's plain `FillAsync` rather than the re-hydration-safe helper, since the profile page is reached by client-side navigation on an already interactive runtime. `ChangePassword_WithValidCurrentPassword_ShouldSucceed` fills the three password fields through the shared `FillFieldAsync`, waits for the "Password changed successfully." snackbar, then signs out and logs back in with the new password, waiting for the logout `forceLoad`'s `/login` URL rather than `LoadState.Load` so it does not race the in-flight navigation (`:80-110`). `ChangeEmail_ShouldUpdateEmail` is opt-in and returns immediately unless `ProfileSupportsEmailChange` is overridden true (`:112-146`). `ProfilePage_ShouldLoadWithUserData` asserts the form is pre-filled from registration (`:148-166`). `ProfilePage_ShouldHaveNoAccessibilityViolations` scans with [AxeOptions](#axeoptions)`.Wcag21Aa` (`:168-181`). - **Why it's built this way**: the email-change fact is a declared opt-in rather than a DOM probe because the previous probing version passed vacuously when the field was absent, reporting coverage for a journey the app does not offer; overriding the flag makes a missing field fail loud (`:18-23`, `:129-131`). The logout-then-login URL wait is called out in the source as the one remaining sign-out-then-login site still on the racy pattern, fixed to match [UserLoginTestsBase](#userlogintestsbase) (`:100-105`). -- **Where it's used**: subclassed only by Store, with no additions and no override of `ProfileSupportsEmailChange` (`MMCA.Store/Tests/E2E/MMCA.Store.E2E.Tests/Workflows/Identity/ProfileManagementTests.cs:5`), so the email-change fact passes without exercising a journey Store offers. ADC does **not** subclass this base: its profile page supports only password change and account deletion, so `MMCA.ADC.E2E.Tests` writes its own `ProfileManagementTests` directly on [E2ETestBase](#e2etestbase) with a password-change fact and a `/profile/claims` fact (`MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/Identity/ProfileManagementTests.cs:8`, `:10-38`, `:40-52`). +- **Where it's used**: subclassed only by Store, with no additions and no override of `ProfileSupportsEmailChange` (`MMCA.Store/Tests/E2E/MMCA.Store.E2E.Tests/Workflows/Identity/ProfileManagementTests.cs:5`), so the email-change fact passes without exercising a journey Store offers. ADC does **not** subclass this base: its profile page supports the avatar photo, password change, and account deletion but no name or address editing, so `MMCA.ADC.E2E.Tests` writes its own `ProfileManagementTests` directly on [E2ETestBase](#e2etestbase) with a password-change fact mirroring this one, a `/profile/claims` fact, and an avatar upload-replace-remove round trip that builds its two PNGs from base64 constants so the test needs no fixture file on disk (`MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/Identity/ProfileManagementTests.cs:8`, `:26`, `:56`, `:70`, `:13-17`). ### UserLoginTestsBase > MMCA.Common.Testing.E2E · `MMCA.Common.Testing.E2E.Workflows.Identity` · `MMCA.Common.Testing.E2E/Workflows/Identity/UserLoginTestsBase.cs:10` · Level 4 · abstract class @@ -2906,7 +3328,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - **What it is** - the ADC end of the brand-token drift guard. It is a five-line subclass of the shared [BrandColorTokenTestsBase](#brandcolortokentestsbase) that names one embedded stylesheet, `ADCHome.Shared.razor.css` (`MMCA.ADC.Architecture.Tests/BrandColorTokenTests.cs:14`-`:17`); the rule body itself lives in MMCA.Common. - **Depends on** - [BrandColorTokenTestsBase](#brandcolortokentestsbase) from the `MMCA.Common.Testing.Architecture` package (referenced at `MMCA.ADC.Architecture.Tests/MMCA.ADC.Architecture.Tests.csproj:41`), plus the `EmbeddedResource` item that maps the conference landing page's scoped stylesheet into this assembly under that logical name (`MMCA.ADC.Architecture.Tests/MMCA.ADC.Architecture.Tests.csproj:11`-`:13`). Externals: xUnit v3, AwesomeAssertions, and NetArchTest (`MMCA.ADC.Architecture.Tests.csproj:25`-`:27`), the last two reachable everywhere in the assembly through the global usings (`MMCA.ADC.Architecture.Tests/GlobalUsings.cs:1`-`:5`). - **Concept introduced, the thin-subclass fitness function.** Every type in this unit follows one shape, so learn it once here. A *fitness function* is an executable test that asserts an architectural property instead of a behavior. MMCA keeps the property's logic in exactly one place, an abstract `*TestsBase` in the shared `MMCA.Common.Testing.Architecture` package, and each repo derives a sealed subclass that supplies only its own identity: which assemblies to scan, which floors and allowlists apply, which files to read. xUnit discovers `[Fact]`s on inherited members, so the subclass needs no test method of its own; deriving the class is what makes the rule run in this repo ([ADR-015](https://ivanball.github.io/docs/adr/015-architecture-fitness-functions.html)). `[Rubric §34 - Architecture Governance & Documentation]` assesses whether architectural decisions are recorded and enforced rather than trusted to reviewers; here the decision is enforced by a build that goes red. `[Rubric §20 - Design System & Theming]` assesses whether a design system has one source of truth for its tokens; this rule is what stops a host copy of the landing page from re-hardcoding the brand hex. -- **Walkthrough** - one member. `EmbeddedCssLogicalNames` (`BrandColorTokenTests.cs:14`-`:17`) is a collection expression with a single entry, `"ADCHome.Shared.razor.css"`. That string is not a file path: it is the `LogicalName` the csproj assigns when it embeds `Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.css` as a manifest resource (`MMCA.ADC.Architecture.Tests.csproj:11`-`:13`), which is how a test assembly reads a file from a project it does not reference. The inherited fact `LandingPageCss_SourcesBrandColorFromToken_NotHardcodedHex` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/BrandColorTokenTestsBase.cs:25`) then loads each named resource (`:56`-`:63`, throwing a clear error if the embed is missing), requires the text to contain `var(--mmca-primary)` (`:41`-`:44`), and requires it not to contain the literal `#1565C0` in any casing (`:46`-`:49`). Both constants are declared once in the base (`:15`-`:16`). +- **Walkthrough** - one member. `EmbeddedCssLogicalNames` (`BrandColorTokenTests.cs:14`-`:17`) is a collection expression with a single entry, `"ADCHome.Shared.razor.css"`. That string is not a file path: it is the `LogicalName` the csproj assigns when it embeds `Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.css` as a manifest resource (`MMCA.ADC.Architecture.Tests.csproj:11`-`:13`), which is how a test assembly reads a file from a project it does not reference. The inherited fact `LandingPageCss_SourcesBrandColorFromToken_NotHardcodedHex` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/BrandColorTokenTestsBase.cs:25`) then loads each named resource, requires the text to contain `var(--mmca-primary)`, and requires it not to contain the literal `#1565C0` in any casing. Both constants are declared once in the base (`:15`-`:16`), and the abstract hook this class implements is declared at `:22`. - **Why it's built this way** - the class comment records the split (`BrandColorTokenTests.cs:3`-`:11`): MMCA.Common's own `BrandColorTokenTests` guards the C#-to-CSS token *definition*, and this one guards the ADC *consumer* of it. Embedding the stylesheet rather than reading it off disk means the guard travels with the compiled test assembly and cannot be defeated by a runner whose working directory differs. - **Where it's used** - the whole project is inside ADC's CI solution filter (`MMCA.ADC/MMCA.ADC.CI.slnf:58`), which the `build-and-test` job restores, builds, and tests on every PR and every push to `main` (`MMCA.ADC/.github/workflows/deploy.yml:124`, `:199`, `:205`, `:219`). - **Caveats / not-in-source** - the guard only covers stylesheets that are both embedded and listed. ADC lists exactly one, so a second landing-page stylesheet added later is invisible to the rule until someone adds it to both places. @@ -2918,24 +3340,68 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - **What it is** - the SLO alert-to-runbook pairing gate for ADC, and the shortest type in this unit: a bodyless class declaration, `public sealed class ObservabilityConventionTests : ObservabilityConventionTestsBase;` (`MMCA.ADC.Architecture.Tests/ObservabilityConventionTests.cs:7`). It overrides nothing at all. - **Depends on** - [ObservabilityConventionTestsBase](#observabilityconventiontestsbase), plus two `EmbeddedResource` entries in the csproj that supply the files the base reads: `infra/main.bicep` under the logical name `infra.main.bicep` and `infra/OPERATIONS.md` under `infra.OPERATIONS.md` (`MMCA.ADC.Architecture.Tests/MMCA.ADC.Architecture.Tests.csproj:17`-`:22`). - **Concept introduced, identity by inheritance alone.** This is the thin-subclass pattern from [BrandColorTokenTests](#brandcolortokentests) reduced to its limit. The base defaults `ResourceAssembly` to `GetType().Assembly` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/ObservabilityConventionTestsBase.cs:51`), so the derived type *is* the configuration: deriving in this assembly is what points the rule at ADC's embedded bicep and runbook. The file comment states exactly that ("this repo supplies only its identity", `ObservabilityConventionTests.cs:3`-`:6`). `[Rubric §13 - Observability & Operability]` assesses whether the system can be operated under failure, which means alerts that lead somewhere; this pairs each provisioned alert with a runbook section at build time instead of at 3am. -- **Walkthrough** - no members. Everything runs from the base's three inherited facts: `SloAlertSpecs_AreDiscovered_GateIsNotVacuous` (`ObservabilityConventionTestsBase.cs:54`) enforces the non-vacuity floor of `MinimumAlertSpecs`, defaulted to 3 and not overridden here (`:39`); `EveryProvisionedSloAlert_HasASeverityCorrectRunbookSection` (`:64`) walks the alerts declared in the embedded bicep and requires a matching, severity-correct section in the embedded runbook; and `EveryRunbookAlertSection_MapsToAProvisionedAlert` (`:92`) closes the other direction, failing on an orphan runbook section for an alert that no longer exists. The resource names the base reads default to `infra.main.bicep` and `infra.OPERATIONS.md` (`:42`, `:45`), which is why the csproj logical names must match exactly. +- **Walkthrough** - no members. Everything runs from the base's three inherited facts: `SloAlertSpecs_AreDiscovered_GateIsNotVacuous` (`ObservabilityConventionTestsBase.cs:54`) enforces the non-vacuity floor of `MinimumAlertSpecs`, defaulted to 3 and not overridden here (`:39`); `EveryProvisionedSloAlert_HasASeverityCorrectRunbookSection` (`:64`) walks the alerts declared in the embedded bicep and requires a matching, severity-correct section in the embedded runbook; and `EveryRunbookAlertSection_MapsToAProvisionedAlert` (`:92`) closes the other direction, failing on an orphan runbook section for an alert that no longer exists. Alerts are recognised by the `-alert-` infix in their resource name (`:32`). The resource names the base reads default to `infra.main.bicep` and `infra.OPERATIONS.md` (`:42`, `:45`), which is why the csproj logical names must match exactly. - **Why it's built this way** - alert definitions live in infrastructure-as-code and the response procedure lives in a Markdown runbook; nothing in either file references the other, so the pairing is exactly the kind of invariant that decays silently. Embedding both into the test assembly turns the pairing into a compile-and-run artifact. - **Where it's used** - runs with the rest of the suite in the `build-and-test` job (`MMCA.ADC/.github/workflows/deploy.yml:124`). +### AnonymousEndpointTests + +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/AnonymousEndpointTests.cs:21` · Level 6 · class (public, sealed) + +- **What it is** - the security gate that no endpoint loses its authorization unnoticed: every `[AllowAnonymous]` reachable in ADC's three module API assemblies and three module UI assemblies must appear as a reviewed line in this file (`MMCA.ADC.Architecture.Tests/AnonymousEndpointTests.cs:23`-`:31`, `:33`-`:103`). It is the largest subclass in the unit, 48 allowlist entries long. +- **Depends on** - [AnonymousEndpointTestsBase](#anonymousendpointtestsbase), `System.Reflection.Assembly` (global-used at `MMCA.ADC.Architecture.Tests/GlobalUsings.cs:1`), and, as type references pinning the three API assemblies, [IdentityModule](group-24-identity-module.md#identitymodule), [ConferenceModule](group-20-conference-api-grpc.md#conferencemodule), and [EngagementModule](group-22-engagement-module.md#engagementmodule) (`:25`-`:27`). The three UI assemblies are loaded by name (`:28`-`:30`). Note it does **not** take the map: it names its own assembly set. +- **Concept introduced, the reviewed-allowlist gate.** The rules elsewhere in this unit assert a structural property. This one asserts a *review* property: the set of anonymous endpoints is not wrong, it is simply not allowed to change silently. The base makes that stick in three directions at once (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/AnonymousEndpointTestsBase.cs:53`-`:90`): `AnonymousEndpoints_AreAllowListed` fails on an unlisted `[AllowAnonymous]` (`:54`), `ScannedEndpointSet_IsNotEmpty` fails when fewer than `MinimumScannedTypes` endpoint types were discovered at all (`:66`), and `AllowList_HasNoStaleEntries` fails on a listed entry that no longer matches anything (`:79`), which is what stops the list from silently accumulating names for endpoints that were renamed or re-gated. `[Rubric §11 - Security]` assesses whether the authorization posture is deliberate and verifiable; an allowlist that must be edited, in a file a reviewer reads, is the mechanism. `[Rubric §26 - Front-End Security]` applies too, because routable Blazor components are scanned alongside controllers. +- **Walkthrough** - three members. + - `TargetAssemblies` (`:23`-`:31`) names six assemblies: the Identity, Conference, and Engagement API assemblies by anchor type, and the matching three UI assemblies via `Assembly.Load`. The base scans each for the two shapes it understands, MVC controllers (any type whose base chain reaches `ControllerBase`, `AnonymousEndpointTestsBase.cs:101`-`:112`) and routable Blazor components (any type carrying a `RouteAttribute`, `:117`-`:118`), both matched by attribute full name so the rule library keeps no ASP.NET reference (`:26`-`:28`, `:32`-`:34`). + - `AllowedAnonymousEndpoints` (`:33`-`:103`) is the reviewed list, grouped by justification rather than alphabetically. Two Identity credential-exchange actions on [AuthController](group-24-identity-module.md#authcontroller), `LoginAsync` and `RegisterAsync`, because requiring a token to mint one would be circular; they are throttled by the auth-ip rate-limit policy instead (`:35`-`:39`). Then the Conference public-browse reads: the `GetAllAsync` / `GetAllForLookupAsync` / `GetByIdAsync` triple on thirteen agenda controllers ([ActivitiesController](group-20-conference-api-grpc.md#activitiescontroller), [EventsController](group-20-conference-api-grpc.md#eventscontroller), [SessionsController](group-20-conference-api-grpc.md#sessionscontroller), [SpeakersController](group-20-conference-api-grpc.md#speakerscontroller), [SponsorsController](group-20-conference-api-grpc.md#sponsorscontroller), and their category/lookup siblings, `:45`-`:83`), because the conference website is readable without an account and is output-cached per [ADR-040](https://ivanball.github.io/docs/adr/040-authenticated-output-caching-for-public-reads.html); the comment is careful to note that every create/update/delete on those same controllers stays behind the class-level `[HasPermission]` (`:43`-`:44`). Then three smaller families with their own reasons: the Now/Next wayfinding reads (`:85`-`:88`), the ICS calendar exports a calendar client fetches without a bearer token (`:90`-`:93`), and the aggregate bookmark counts behind the popularity badge, counts only and never a per-user list (`:95`-`:98`). Last, the type-level entry for [ServiceInfoController](group-20-conference-api-grpc.md#serviceinfocontroller), which must answer before a caller has a token to negotiate with (`:100`-`:102`). Type-level and method-level attributes use different identifier shapes, a bare `FullName` versus `FullName.MethodName` (`AnonymousEndpointTestsBase.cs:39`-`:44`), which is why that last entry has no method suffix. + - `MinimumScannedTypes => 79` (`:108`) raises the base floor of 1 (`AnonymousEndpointTestsBase.cs:51`) to the exact count of controller and routable-component types across the six assemblies today, so a renamed assembly or a dropped surface is a failure rather than a quietly smaller scan (`:105`-`:107`). +- **Why it's built this way** - the class comment explains what the *absences* mean, which is the part a reader cannot infer. The two password-recovery actions on [PasswordResetController](group-24-identity-module.md#passwordresetcontroller) are anonymous for the same circularity reason as login, but ADC does not override them, so the framework base owns their allowlist entries and none appear here (`:11`-`:15`); the same is true of refresh (`:37`). The base reads attributes with `DeclaredOnly` and `inherit: false` precisely so an inherited framework action is reported once at its declaration site rather than once per derived controller in every consumer (`AnonymousEndpointTestsBase.cs:140`-`:142`). Notification is absent because that module ships no controller and no routable component, hosting only the SignalR hub, so it contributes nothing to the scan (`:16`-`:19`). +- **Caveats / not-in-source** - the base states its own blind spot: minimal-API endpoints opt out through the `.AllowAnonymous()` builder call, which produces endpoint metadata at map time and is invisible to static reflection, so the framework's own small anonymous minimal-API surface (JWKS, OIDC discovery, app-association, session-cookie refresh, health) is not covered here (`AnonymousEndpointTestsBase.cs:18`-`:24`). Nothing recomputes the 79 floor, so it is a lower bound a human maintains. + +### ProtoContractTests + +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/ProtoContractTests.cs:3` · Level 6 · class (public, sealed) + +- **What it is** - the frozen wire contract for ADC's *synchronous* cross-service API. It names the seven `.proto` files the four `*.Contracts` projects compile and commits a 75-line snapshot of everything they declare, so a renumbered field or a renamed rpc fails the build (`MMCA.ADC.Architecture.Tests/ProtoContractTests.cs:9`-`:18`, `:20`-`:97`). +- **Depends on** - [ProtoContractTestsBase](#protocontracttestsbase). Nothing else: the rule reads `.proto` files off disk from the repo root, so this class takes no map and the csproj needs no reference to the `*.Contracts` projects. +- **Concept introduced, pinning a contract that no compiler checks.** A `.proto` file is a published contract between processes that are built separately, so nothing in a single repo's build notices when it changes incompatibly. The rule library rebuilds the live contract by parsing the files and diffs it against the committed list, reporting each side separately as "present but NOT frozen" and "frozen but NOT present" (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Protos.cs:67`-`:77`). What gets pinned is exactly the wire surface: the package, every rpc with its request/response types and streaming flags, every message field with its declared type, label, and **field number**, and every enum value with its number (`ArchitectureRules.Protos.cs:20`-`:25`). What is deliberately not pinned is `syntax`, `import`, and `option` lines including `csharp_namespace`, because none of them changes a byte on the wire and failing on them is the fastest way to teach a team to update a snapshot without reading it (`:26`-`:31`). `[Rubric §9 - API & Contract Design]` assesses contract governance across the whole surface, not just REST. `[Rubric §7 - Microservices Readiness]`: this list *is* the synchronous coupling between ADC's four services, in the same way [IntegrationEventContractTests](#integrationeventcontracttests) is the asynchronous one ([ADR-007](https://ivanball.github.io/docs/adr/007-grpc-extraction.html)). +- **Walkthrough** - three members, all implementing abstract hooks. + - `SolutionFileName => "MMCA.ADC.slnx"` (`:5`) implements `ProtoContractTestsBase.SolutionFileName` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/ProtoContractTestsBase.cs:22`). The rule resolves the repo root from it via `ArchitectureMapBase.FindRepoRoot` (`ArchitectureRules.Protos.cs:45`), so the files are read from the working tree regardless of the runner's working directory. + - `ProtoFiles` (`:9`-`:18`) lists seven repo-root-relative paths, and the comment states the scope rule: every `.proto` compiled by the four `*.Contracts` projects (`:7`-`:8`). Two from Conference (`event_live_validation`, `session_bookmark_validation`), two from Engagement (`bookmark_count`, `user_engagement_export`), one from Identity (`attendee_query`), and two from Notification (`live_channel`, `user_notification_export`). That is every `.proto` file in the repository today. + - `FrozenProtoContracts` (`:20`-`:97`) is the snapshot: 63 `message ...` lines, one per field, each ending in `= : `, and 12 `service ...` lines, one per rpc. The entries are sorted, which is what makes a regenerated snapshot diff line by line, and the base's `` explains how to regenerate it (print `ArchitectureRules.BuildProtoContract(...)` and paste, `ProtoContractTestsBase.cs:12`-`:17`). Reading the list is the fastest way to see what ADC's services actually say to each other: live-window validation and current-room lookup from Conference, bookmark counts and the GDPR engagement export from Engagement, the attendee user-id list from Identity, and channel push plus the notification export from Notification. + - The single inherited fact is `ProtoContracts_ShouldMatch_TheFrozenSnapshot` (`ProtoContractTestsBase.cs:33`). +- **Why it's built this way** - the base is explicit that this is consumer-facing only: MMCA.Common ships the gRPC plumbing but no `.proto` of its own, so the framework does not subclass it, and a repo with a `*.Contracts` project does (`ProtoContractTestsBase.cs:8`-`:11`). Note also that Notification's protos are pinned here even though the Notification module is absent from [AdcArchitectureMap](#adcarchitecturemap): this rule works from file paths, not from mapped assemblies, so the thin module is covered for free. +- **Caveats / not-in-source** - the file list is hand-maintained, so a brand-new `.proto` added to a `*.Contracts` project is not pinned until someone lists it here. Nothing asserts that `ProtoFiles` covers every `.proto` in the tree. + ### TranslationCompletenessTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/TranslationCompletenessTests.cs:12` · Level 5 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/TranslationCompletenessTests.cs:12` · Level 6 · class (public, sealed) - **What it is** - the internationalization completeness gate: every base `*.resx` under `Source/` must have a complete, non-empty Spanish `.es.resx` sibling, so adding an English key without its translation fails CI instead of shipping a half-translated UI (`MMCA.ADC.Architecture.Tests/TranslationCompletenessTests.cs:3`-`:11`). - **Depends on** - [LocalizationResourceTestsBase](#localizationresourcetestsbase). Note the deliberate name divergence: the ADC subclass is named for what it guarantees (translation completeness), not for the base it derives from. -- **Concept introduced, the non-vacuity floor.** A convention scan that discovers nothing passes trivially, which is the failure mode that makes fitness functions untrustworthy over time. The MMCA bases answer it with a minimum-count floor that the subclass raises to the repo's real magnitude, so a broken scan root (a moved directory, a renamed convention, a case-sensitivity slip on the Ubuntu runner) fails loudly instead of going green while checking zero files. You will see this floor again in [FormsConventionTests](#formsconventiontests) and [LocalizedTextConventionTests](#localizedtextconventiontests). `[Rubric §27 - i18n]` assesses whether localization is enforced rather than aspirational; the gate is the enforcement, and [ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html) (which supersedes the single-locale ADR-011) is the decision it executes. +- **Concept introduced, the non-vacuity floor.** A convention scan that discovers nothing passes trivially, which is the failure mode that makes fitness functions untrustworthy over time. The MMCA bases answer it with a minimum-count floor that the subclass raises to the repo's real magnitude, so a broken scan root (a moved directory, a renamed convention, a case-sensitivity slip on the Ubuntu runner) fails loudly instead of going green while checking zero files. You have already seen the floor in [AnonymousEndpointTests](#anonymousendpointtests), and you will see it again in [FormsConventionTests](#formsconventiontests) and [LocalizedTextConventionTests](#localizedtextconventiontests). `[Rubric §27 - i18n]` assesses whether localization is enforced rather than aspirational; the gate is the enforcement, and [ADR-027](https://ivanball.github.io/docs/adr/027-multi-locale-i18n.html) (which supersedes the single-locale ADR-011) is the decision it executes. - **Walkthrough** - two members. `RequiredCultures => ["es"]` (`TranslationCompletenessTests.cs:14`) implements the base's abstract culture list (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/LocalizationResourceTestsBase.cs:13`), so Spanish is the one culture ADC contractually completes. `MinimumBaseResources => 40` (`TranslationCompletenessTests.cs:16`) raises the base's default of 0 (`LocalizationResourceTestsBase.cs:21`), which would otherwise let an empty scan pass. The inherited fact is `Translations_AreComplete_ForEveryRequiredCulture` (`LocalizationResourceTestsBase.cs:24`). - **Why it's built this way** - the class comment justifies the floor from the repo's real shape: ADC has 40 or more localized resource sets across the three module UIs, the UI hosts' landing page, the nav-item module descriptors, and the API error-resource sets, so a near-zero discovery count means the scan path is wrong (`TranslationCompletenessTests.cs:8`-`:10`). - **Caveats / not-in-source** - the floor is a lower bound stated in the subclass, not a count computed from the tree, so it stays correct only as long as someone raises it when the resource set grows materially. +### DecoratorPipelineOrderTests + +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/DecoratorPipelineOrderTests.cs:27` · Level 9 · class (public, sealed) + +- **What it is** - the one type in this unit that builds a real DI container instead of reading metadata. It asserts that ADC's genuine registration sequence produces the [ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html) decorator nesting at runtime, exercised against a real Identity command/query pair (`MMCA.ADC.Architecture.Tests/DecoratorPipelineOrderTests.cs:17`-`:28`). +- **Depends on** - [DecoratorPipelineOrderTestsBase<TCommand, TCommandResult, TQuery, TQueryResult>](#decoratorpipelineordertestsbasetcommand-tcommandresult-tquery-tqueryresult) from the `MMCA.Common.Testing` package (`MMCA.ADC.Architecture.Tests.csproj:43`), closed over [ChangePreferencesCommand](group-24-identity-module.md#changepreferencescommand) / [Result](group-01-result-error-handling.md#result) and [GetUserPreferencesQuery](group-14-module-system-composition.md#getuserpreferencesquery) / `Result<`[UserPreferencesResponse](group-08-auth.md#userpreferencesresponse)`>` (`DecoratorPipelineOrderTests.cs:28`). Externals: `Microsoft.Extensions.DependencyInjection`, `Microsoft.FeatureManagement`, `NullLogger<>`, and Moq (`:1`-`:13`). +- **Concept introduced, an object-graph assertion.** Scrutor's `TryDecorate` applies decorators in reverse registration order, so the *last* decorator registered becomes the outermost wrapper. That makes an innocent-looking reorder of the `AddApplicationDecorators()` lines, or a module handler scan that runs after it instead of before, a silent change in runtime behavior: the code still compiles, the container still resolves, and the pipeline quietly runs validation after the transaction opens. The base turns that into a test failure by resolving the handler and walking the constructed graph via reflection over each decorator's private inner-handler field, so it verifies the objects that actually exist rather than the registration list (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/DecoratorPipelineOrderTestsBase.cs:29`-`:32`, `:99`-`:124`). `[Rubric §2 - Design Patterns]` assesses whether patterns are applied deliberately and correctly; the decorator chain is the framework's central pattern and this is the only test that proves its composition. `[Rubric §14 - Testability]`: the fact that a production registration sequence can be replayed in a bare `ServiceCollection` with seven mocked dependencies is itself the evidence that the composition root is not entangled with hosting. +- **Walkthrough** - one member, `ConfigureServices(IServiceCollection)` (`DecoratorPipelineOrderTests.cs:30`), which implements the base's single abstract hook (`DecoratorPipelineOrderTestsBase.cs:46`) and reads in two halves. + - **Test doubles for the decorator constructor dependencies** (`:33`-`:39`): `Mock.Of()`, `Mock.Of()`, `Mock.Of()`, `Mock.Of()`, and `Mock.Of()` as singletons, a scoped `IUnitOfWork` factory, and the open generic `ILogger<>` mapped to `NullLogger<>`. These exist only so the decorators can be constructed; the test never invokes a handler. The base names the same seven dependencies as the contract for a subclass (`DecoratorPipelineOrderTestsBase.cs:22`-`:25`). + - **The real registration sequence** (`:43`-`:45`): `AddApplication()`, then `ScanModuleApplicationServices()`, then `AddApplicationDecorators()` last. The comment states the load-bearing constraint plainly (`:41`-`:42`): TryDecorate can only wrap handlers already registered. + - The two inherited facts then assert the chains. `CommandPipeline_NestsDecorators_InAdr014Order` (`DecoratorPipelineOrderTestsBase.cs:71`) expects FeatureGate, Authorization, Logging, Caching, Validating, Timeout, Transactional, then the concrete handler (`:49`-`:58`); `QueryPipeline_NestsDecorators_InAdr014Order` (`:75`) expects FeatureGate, Authorization, Logging, Caching, Timeout, then the handler (`:61`-`:68`). Both are asserted by `AssertPipeline` (`:78`-`:97`), which compares every element except the last against the expected list (`:92`-`:93`) and then requires the innermost element *not* to end in "Decorator" (`:95`-`:96`), so a truncated chain cannot pass. ADC overrides neither expected list, so it accepts the framework default order as its contract. +- **Why it's built this way** - the pair was chosen for realism rather than convenience. [ChangePreferencesCommand](group-24-identity-module.md#changepreferencescommand) and its handler are shipped ADC Identity use cases, while [GetUserPreferencesQuery](group-14-module-system-composition.md#getuserpreferencesquery) is declared in `MMCA.Common.Application` and its concrete [GetUserPreferencesHandler](group-24-identity-module.md#getuserpreferenceshandler) lives in ADC on top of Common's [GetUserPreferencesHandlerBase<TUser>](group-14-module-system-composition.md#getuserpreferenceshandlerbasetuser). So the scan-then-decorate ordering is exercised across the framework and app boundary rather than against a fixture, which is exactly what the base asks a subclass to supply (`DecoratorPipelineOrderTestsBase.cs:26`-`:27`). +- **Where it's used** - an independent class in ADC's architecture suite; nothing consumes it. +- **Caveats / not-in-source** - the chain is unwrapped by reading compiler-generated private fields (`DecoratorPipelineOrderTestsBase.cs:104`-`:124`), so a future decorator that stores its inner handler somewhere other than a field (a property-only or captured-closure design) would be invisible to the walk. The base flags the reflection strategy explicitly (`:29`-`:32`). + ### AdcArchitectureMap -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/AdcArchitectureMap.cs:8` · Level 9 · class (internal, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/AdcArchitectureMap.cs:8` · Level 12 · class (internal, sealed) - **What it is** - the single declaration of what "the ADC architecture" *is*, in assembly terms: five MMCA.Common framework layers plus the Identity, Conference, and Engagement modules at six layers each, 23 entries in all (`MMCA.ADC.Architecture.Tests/AdcArchitectureMap.cs:12`-`:44`). Every map-driven rule in this unit scans exactly the assemblies listed here. - **Depends on** - [ArchitectureMapBase](#architecturemapbase) (which implements [IArchitectureMap](#iarchitecturemap)), the [Layer](#layer) enum and the [LayerRef](#layerref) record, and `System.Reflection.Assembly` (global-used at `MMCA.ADC.Architecture.Tests/GlobalUsings.cs:1`). Through its anchor types it also depends on [Result](group-01-result-error-handling.md#result), [BaseEntity<TIdentifierType>](group-02-domain-building-blocks.md#baseentitytidentifiertype), [EntityQueryService<TEntity, TEntityDTO, TIdentifierType>](group-03-querying-specifications.md#entityqueryservicetentity-tentitydto-tidentifiertype), [ApplicationDbContext](group-07-persistence-ef-core.md#applicationdbcontext), and [ApiControllerBase](group-12-api-hosting-mapping.md#apicontrollerbase) on the framework side, and on [User](group-24-identity-module.md#user), [Event](group-17-conference-domain.md#event), [UserSessionBookmark](group-22-engagement-module.md#usersessionbookmark), their DTOs, and the three [IdentityModule](group-24-identity-module.md#identitymodule) / [ConferenceModule](group-20-conference-api-grpc.md#conferencemodule) / [EngagementModule](group-22-engagement-module.md#engagementmodule) entry points on the app side. @@ -2946,37 +3412,36 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - **Module layers** (`:22`-`:43`) use the instance `Module(name, layer, assembly)` helper, three modules times six layers. Domain, Shared, and API are pinned by anchor type (`Identity.Domain.Users.User`, `Conference.Shared.Events.EventDTO`, `Engagement.API.EngagementModule`, and their siblings); Application, Infrastructure, and UI are loaded by name through `Assembly.Load` (for example `:23`, `:24`, `:27`), because, as the class comment explains, those assemblies have no convenient public anchor type (`:5`-`:6`). `Assembly.Load` succeeds here only because the csproj takes a `ProjectReference` on all eighteen module projects (`MMCA.ADC.Architecture.Tests.csproj:46`-`:65`), which is what puts the DLLs beside the test binary. - **Laziness** is inherited: `DefineLayers()` is materialized once through a `Lazy>` built in the base constructor (`ArchitectureMapBase.cs:13`-`:16`), so the twenty-odd subclasses that each construct their own map instance still pay the `Assembly.Load` cost only on first use. - **Why it's built this way** - centralizing every namespace and assembly string in one file also fixes Ubuntu CI case sensitivity in one place, which the base states as an explicit goal (`ArchitectureMapBase.cs:7`-`:9`). Compare [CommonArchitectureMap](#commonarchitecturemap), the same abstraction for a repo with no business modules. -- **Where it's used** - instantiated as a field initializer by every map-driven subclass in this unit (25 of the 30 types here, all of them at Level 10), for example `ConcurrencyConventionTests.cs:5`. It is `internal`, so it never leaves this assembly. -- **Caveats / not-in-source** - the thin Notification module (API plus Application only) is deliberately absent from the map, so the module-shaped rules do not cover it; [RawQueryableConventionTests](#rawqueryableconventiontests) is the one rule that re-adds Notification by hand, and it says why (`RawQueryableConventionTests.cs:16`-`:20`). Nothing in this repository asserts that the map lists every module that exists, so a fourth mapped module would have to be added here by a human. +- **Where it's used** - instantiated as a field initializer by every map-driven subclass in this unit (27 of the 35 types here, all of them at Level 13), for example `ConcurrencyConventionTests.cs:5`. It is `internal`, so it never leaves this assembly. The eight non-map types are the two embedded-resource guards, [AnonymousEndpointTests](#anonymousendpointtests), [ProtoContractTests](#protocontracttests), [TranslationCompletenessTests](#translationcompletenesstests), the two pipeline-order tests, and the map itself. +- **Caveats / not-in-source** - the thin Notification module (API plus Application only) is deliberately absent from the map, so the module-shaped rules do not cover it. Two rules re-add it by hand and each says why: [RawQueryableConventionTests](#rawqueryableconventiontests) appends its Application directory (`RawQueryableConventionTests.cs:16`-`:20`), and [ProtoContractTests](#protocontracttests) pins its protos by path. Nothing in this repository asserts that the map lists every module that exists, so a fourth mapped module would have to be added here by a human. -### DecoratorPipelineOrderTests +### MiddlewarePipelineOrderTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/DecoratorPipelineOrderTests.cs:26` · Level 9 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/MiddlewarePipelineOrderTests.cs:15` · Level 12 · class (public, sealed) -- **What it is** - the one type in this unit that builds a real DI container instead of reading metadata. It asserts that ADC's genuine registration sequence produces the [ADR-014](https://ivanball.github.io/docs/adr/014-cqrs-decorator-pipeline.html) decorator nesting at runtime, exercised against a real Identity command/query pair (`MMCA.ADC.Architecture.Tests/DecoratorPipelineOrderTests.cs:17`-`:27`). -- **Depends on** - [DecoratorPipelineOrderTestsBase<TCommand, TCommandResult, TQuery, TQueryResult>](#decoratorpipelineordertestsbasetcommand-tcommandresult-tquery-tqueryresult) from the `MMCA.Common.Testing` package (`MMCA.ADC.Architecture.Tests.csproj:43`), closed over [ChangePreferencesCommand](group-24-identity-module.md#changepreferencescommand) / [Result](group-01-result-error-handling.md#result) and [GetUserPreferencesQuery](group-14-module-system-composition.md#getuserpreferencesquery) / `Result<`[UserPreferencesResponse](group-08-auth.md#userpreferencesresponse)`>` (`DecoratorPipelineOrderTests.cs:27`). Externals: `Microsoft.Extensions.DependencyInjection`, `Microsoft.FeatureManagement`, `NullLogger<>`, and Moq (`:1`-`:13`). -- **Concept introduced, an object-graph assertion.** Scrutor's `TryDecorate` applies decorators in reverse registration order, so the *last* decorator registered becomes the outermost wrapper. That makes an innocent-looking reorder of the `AddApplicationDecorators()` lines, or a module handler scan that runs after it instead of before, a silent change in runtime behavior: the code still compiles, the container still resolves, and the pipeline quietly runs validation after the transaction opens. The base turns that into a test failure by resolving the handler and walking the constructed graph via reflection over each decorator's private inner-handler field, so it verifies the objects that actually exist rather than the registration list (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/DecoratorPipelineOrderTestsBase.cs:27`-`:30`, `:98`-`:118`). `[Rubric §2 - Design Patterns]` assesses whether patterns are applied deliberately and correctly; the decorator chain is the framework's central pattern and this is the only test that proves its composition. `[Rubric §14 - Testability]`: the fact that a production registration sequence can be replayed in a bare `ServiceCollection` with five mocked dependencies is itself the evidence that the composition root is not entangled with hosting. -- **Walkthrough** - one member, `ConfigureServices(IServiceCollection)` (`DecoratorPipelineOrderTests.cs:29`), which implements the base's single abstract hook (`DecoratorPipelineOrderTestsBase.cs:44`) and reads in two halves. - - **Test doubles for the decorator constructor dependencies** (`:32`-`:36`): `Mock.Of()`, `Mock.Of()`, and `Mock.Of()` as singletons, a scoped `IUnitOfWork` factory, and the open generic `ILogger<>` mapped to `NullLogger<>`. These exist only so the decorators can be constructed; the test never invokes a handler. - - **The real registration sequence** (`:40`-`:42`): `AddApplication()`, then `ScanModuleApplicationServices()`, then `AddApplicationDecorators()` last. The comment states the load-bearing constraint plainly (`:38`-`:39`): TryDecorate can only wrap handlers already registered. - - The two inherited facts then assert the chains. `CommandPipeline_NestsDecorators_InAdr014Order` (`DecoratorPipelineOrderTestsBase.cs:65`) expects FeatureGate, Logging, Caching, Validating, Transactional, then the concrete handler; `QueryPipeline_NestsDecorators_InAdr014Order` (`:69`) expects FeatureGate, Logging, Caching, then the handler (`:47`-`:62`). Both also assert the innermost element does *not* end in "Decorator" (`:89`-`:90`), so a truncated chain cannot pass. -- **Why it's built this way** - the pair was chosen for realism rather than convenience: `ChangePreferencesCommand` and `GetUserPreferencesQuery` are shipped Identity use cases, and the query's handler lives in MMCA.Common while the command's lives in ADC, so the scan-then-decorate ordering is exercised across the framework and app boundary rather than against a fixture. -- **Where it's used** - an independent class in ADC's architecture suite; nothing consumes it. -- **Caveats / not-in-source** - the chain is unwrapped by reading compiler-generated private fields, so a future decorator that stores its inner handler somewhere other than a field (a property-only or captured-closure design) would be invisible to the walk. The base flags the reflection strategy explicitly (`DecoratorPipelineOrderTestsBase.cs:93`-`:97`). +- **What it is** - the HTTP-edge counterpart of [DecoratorPipelineOrderTests](#decoratorpipelineordertests), and, like [ObservabilityConventionTests](#observabilityconventiontests), a bodyless declaration: `public sealed class MiddlewarePipelineOrderTests : MiddlewarePipelineOrderTestsBase;` (`MMCA.ADC.Architecture.Tests/MiddlewarePipelineOrderTests.cs:15`). Deriving it is the whole assertion. +- **Depends on** - [MiddlewarePipelineOrderTestsBase](#middlewarepipelineordertestsbase) from the `MMCA.Common.Testing` package (`using MMCA.Common.Testing;`, `:1`), and transitively on [MiddlewarePipelineBuilder](group-12-api-hosting-mapping.md#middlewarepipelinebuilder) and [MiddlewarePipelineStepNames](group-12-api-hosting-mapping.md#middlewarepipelinestepnames). +- **Concept introduced, an empty subclass as a conformance claim.** The class comment states what the emptiness *means* (`:5`-`:14`): every ADC REST and gRPC service host calls the zero-argument `UseCommonMiddlewarePipeline()`, so the framework's default step order is ADC's contract and the base needs no overrides. The two hooks that exist are the escape hatch a host would use if it customized the pipeline: `Configure`, which defaults to null (`MMCA.Common/Source/Hosting/MMCA.Common.Testing/MiddlewarePipelineOrderTestsBase.cs:35`), and `ExpectedStepNames` (`:38`-`:58`). Leaving both alone is a positive statement, not an omission. `[Rubric §10 - Cross-Cutting]` assesses whether cross-cutting behavior is applied uniformly rather than per host; `[Rubric §11 - Security]` is the sharp edge, because the ordering invariants below are authentication and rate-limiting invariants. +- **Walkthrough** - no members. Two inherited facts run against a builder seeded from `MiddlewarePipelineBuilder.CreateDefault()` (`MiddlewarePipelineOrderTestsBase.cs:79`-`:84`). + - `EdgePipeline_OrdersSteps_InDocumentedOrder` (`:61`) compares `builder.StepNames` against the 18-step default sequence, outermost first: exception handler, correlation id, request localization, pre-forwarded capture, forwarded headers, HTTPS redirection, response compression, routing, CORS, authentication, tenant resolution, rate limiting, soft-deleted-user filter, authorization, output cache, JWKS endpoint, OIDC discovery endpoint, controllers (`:40`-`:57`). + - `EdgePipeline_SatisfiesLoadBearingInvariants` (`:70`) calls `Build()` and requires it not to throw, because `Build()` re-checks the load-bearing adjacencies at startup, so a pipeline that failed here would have thrown while the host was starting (`:73`-`:76`). + - Four adjacencies are named as load-bearing in both the base and the ADC comment: the pre-forwarded capture immediately before the forwarded-headers rewrite (or `jwks_uri` stops being reachable), authentication immediately before tenant resolution (so the claim strategy sees `HttpContext.User`), authentication before the rate limiter per [ADR-019](https://ivanball.github.io/docs/adr/019-rate-limiting.html) (so the per-user cap engages), and forwarded headers before the HTTPS redirect (`MiddlewarePipelineOrderTests.cs:9`-`:13`, `MiddlewarePipelineOrderTestsBase.cs:66`). +- **Why it's built this way** - a reorder here fails at runtime in ways that look like configuration bugs: an unreachable `jwks_uri`, a tenant that never resolves, a per-user rate cap that never engages (`MiddlewarePipelineOrderTestsBase.cs:16`-`:18`). Making it a red test in the consumer repo is what turns a framework-side reorder into a build failure rather than a silent production behavior change ([ADR-079](https://ivanball.github.io/docs/adr/079-shared-http-middleware-pipeline.html)). No `WebApplication` is built: the steps are pure data until they are applied, so this runs in the fast unit tier with no database and no host (`:24`-`:27`). +- **Caveats / not-in-source** - the test asserts the *framework default*, seeded from `CreateDefault()`. It does not read any ADC `Program.cs`, so the claim that every ADC host calls the zero-argument overload is asserted by the comment, not by this test. A host that started passing a customization would silently fall outside this gate unless someone also overrode `Configure` here. ### ConcurrencyConventionTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/ConcurrencyConventionTests.cs:3` · Level 10 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/ConcurrencyConventionTests.cs:3` · Level 13 · class (public, sealed) -- **What it is** - the guard that every update request participates in optimistic concurrency. It is also the plainest example of the Level 10 shape in this unit: a sealed class whose entire body is one line supplying the map (`MMCA.ADC.Architecture.Tests/ConcurrencyConventionTests.cs:5`). +- **What it is** - the guard that every update request participates in optimistic concurrency. It is also the plainest example of the Level 13 shape in this unit: a sealed class whose entire body is one line supplying the map (`MMCA.ADC.Architecture.Tests/ConcurrencyConventionTests.cs:5`). - **Depends on** - [ConcurrencyConventionTestsBase](#concurrencyconventiontestsbase) and [AdcArchitectureMap](#adcarchitecturemap). -- **Concept introduced, the map-only subclass.** Seventeen types in this unit are exactly this: `protected override IArchitectureMap Map { get; } = new AdcArchitectureMap();` and nothing else. Note the property is an auto-property with an initializer, not an expression body, so each class constructs its map once per test-class instance rather than per fact. Everything else (the rule bodies, the `[Fact]` attributes, the failure messages) is inherited, which is precisely the point: MMCA.Common, MMCA.Store, and MMCA.ADC run byte-identical rule logic and differ only in what they point it at. The sections below for the other map-only subclasses do not repeat this explanation; they name the base and list what it asserts. `[Rubric §16 - Maintainability]` assesses duplication and change cost: a new rule ships to all three repos by adding a base and one derived line per repo. -- **Walkthrough** - one member, `Map` (`:5`). The inherited fact is `UpdateRequests_ShouldImplement_IConcurrencyAware` (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/ConcurrencyConventionTestsBase.cs:13`), which delegates to `ArchitectureRules.UpdateRequestsAreConcurrencyAware(Map)`. `[Rubric §8 - Data Architecture]`: an update request that does not carry a row version cannot detect a lost update, so this rule keeps [IConcurrencyAware](group-12-api-hosting-mapping.md#iconcurrencyaware) from being optional in practice. +- **Concept introduced, the map-only subclass.** Nineteen of the 27 map-driven types in this unit are exactly this: `protected override IArchitectureMap Map { get; } = new AdcArchitectureMap();` and nothing else. Note the property is an auto-property with an initializer, not an expression body, so each class constructs its map once per test-class instance rather than per fact. Everything else (the rule bodies, the `[Fact]` attributes, the failure messages) is inherited, which is precisely the point: MMCA.Common, MMCA.Store, and MMCA.ADC run byte-identical rule logic and differ only in what they point it at. The sections below for the other map-only subclasses do not repeat this explanation; they name the base and list what it asserts. `[Rubric §16 - Maintainability]` assesses duplication and change cost: a new rule ships to all three repos by adding a base and one derived line per repo. +- **Walkthrough** - one member, `Map` (`:5`), implementing the base's abstract property (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/ConcurrencyConventionTestsBase.cs:10`). The inherited fact is `UpdateRequests_ShouldImplement_IConcurrencyAware` (`:13`), which delegates to `ArchitectureRules.UpdateRequestsAreConcurrencyAware(Map)`. `[Rubric §8 - Data Architecture]`: an update request that does not carry a row version cannot detect a lost update, so this rule keeps [IConcurrencyAware](group-12-api-hosting-mapping.md#iconcurrencyaware) from being optional in practice. - **Where it's used** - runs with the whole suite in the `build-and-test` job (`MMCA.ADC/.github/workflows/deploy.yml:124`, `:219`). The same is true of every remaining type in this unit and is not repeated below. ### ConstructorDependencyCountTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/ConstructorDependencyCountTests.cs:17` · Level 10 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/ConstructorDependencyCountTests.cs:17` · Level 13 · class (public, sealed) - **What it is** - a single-responsibility ceiling: no service in a mapped module Application assembly may take more than seven constructor dependencies (`MMCA.ADC.Architecture.Tests/ConstructorDependencyCountTests.cs:19`-`:21`). - **Depends on** - [ConstructorDependencyCountTestsBase](#constructordependencycounttestsbase) and [AdcArchitectureMap](#adcarchitecturemap). @@ -2987,7 +3452,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc ### ControllerConventionTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/ControllerConventionTests.cs:3` · Level 10 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/ControllerConventionTests.cs:3` · Level 13 · class (public, sealed) - **What it is** - the API-layer convention guard, with a two-entry exemption list for the controllers that legitimately do not route through the framework's base controller (`MMCA.ADC.Architecture.Tests/ControllerConventionTests.cs:11`-`:15`). - **Depends on** - [ControllerConventionTestsBase](#controllerconventiontestsbase), [AdcArchitectureMap](#adcarchitecturemap), and, by name only (they are strings, not type references), [OAuthController](group-24-identity-module.md#oauthcontroller) and [ServiceInfoController](group-20-conference-api-grpc.md#serviceinfocontroller). @@ -2997,22 +3462,22 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc ### DataResidencyTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/DataResidencyTests.cs:12` · Level 10 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/DataResidencyTests.cs:12` · Level 13 · class (public, sealed) - **What it is** - a compliance drift guard: the data-residency statement published in ADC's `PRIVACY.md` must match the Azure region where personal data is actually provisioned, parsed out of the deployment workflow (`MMCA.ADC.Architecture.Tests/DataResidencyTests.cs:3`-`:11`). - **Depends on** - [DataResidencyTestsBase](#dataresidencytestsbase), [AdcArchitectureMap](#adcarchitecturemap), `System.IO.File`/`Path`, and AwesomeAssertions (used directly inside the override, `:26`). -- **Concept introduced, a test as the join between a document and an infrastructure fact.** Most of the rules in this unit compare code to code. This one compares prose to infrastructure: it reads the deployed region out of the source of truth (`SQL_LOCATION="${SQL_LOCATION_OVERRIDE:-westus2}"`, `MMCA.ADC/.github/workflows/deploy.yml:949`) and then requires `PRIVACY.md` to say the same thing, comparing whitespace-insensitively and case-insensitively so "West US 2" matches the `westus2` region token (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/DataResidencyTestsBase.cs:55`-`:58`). `[Rubric §30 - Compliance/Privacy/Data Governance]` assesses whether privacy claims are true and stay true; a policy that names a region the data never lived in is a compliance defect that no code review would catch, and this is the mechanism that closes it. +- **Concept introduced, a test as the join between a document and an infrastructure fact.** Most of the rules in this unit compare code to code. This one compares prose to infrastructure: it reads the deployed region out of the source of truth (`SQL_LOCATION="${SQL_LOCATION_OVERRIDE:-westus2}"`, `MMCA.ADC/.github/workflows/deploy.yml:949`) and then requires `PRIVACY.md` to say the same thing, comparing whitespace-insensitively and case-insensitively so "West US 2" matches the `westus2` region token. `[Rubric §30 - Compliance/Privacy/Data Governance]` assesses whether privacy claims are true and stay true; a policy that names a region the data never lived in is a compliance defect that no code review would catch, and this is the mechanism that closes it. - **Walkthrough** - three members. - - `Map` (`:14`) exists only so the base can resolve the repo root through `ArchitectureMapBase.FindRepoRoot($"{Map.RepoToken}.slnx")` (`DataResidencyTestsBase.cs:28`); no assembly scanning happens in this rule. - - `ForbiddenResidencyClaims => ["central United States"]` (`:16`) overrides the base's empty default (`DataResidencyTestsBase.cs:23`) and blocks a specific stale statement from returning, one that once contradicted the deployed region (`DataResidencyTests.cs:9`-`:10`). - - `ExtractDeployedRegion(string repoRoot)` (`:20`-`:31`) implements the base's abstract hook (`DataResidencyTestsBase.cs:53`). It reads `.github/workflows/deploy.yml` (`:22`), locates the literal marker `SQL_LOCATION_OVERRIDE:-` with an ordinal `IndexOf` (`:24`-`:25`), asserts the marker exists with a `because` explaining what the workflow must declare (`:26`-`:27`), then takes the alphanumeric run that follows as the region (`:29`-`:30`). Assert-then-parse rather than return-empty is exactly what the base asks implementations to do (`DataResidencyTestsBase.cs:47`-`:52`). - - The inherited fact `PrivacyPolicy_DataStorageRegion_MatchesDeployedRegion` (`DataResidencyTestsBase.cs:26`) then asserts the normalized policy contains the normalized region (`:37`) and contains none of the forbidden claims (`:40`-`:44`). + - `Map` (`:14`) exists only so the base can resolve the repo root through `ArchitectureMapBase.FindRepoRoot($"{Map.RepoToken}.slnx")`; no assembly scanning happens in this rule. + - `ForbiddenResidencyClaims => ["central United States"]` (`:16`) overrides the base's empty default (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/DataResidencyTestsBase.cs:23`) and blocks a specific stale statement from returning, one that once contradicted the deployed region (`DataResidencyTests.cs:9`-`:10`). + - `ExtractDeployedRegion(string repoRoot)` (`:20`-`:31`) implements the base's abstract hook (`DataResidencyTestsBase.cs:53`). It reads `.github/workflows/deploy.yml` (`:22`), locates the literal marker `SQL_LOCATION_OVERRIDE:-` with an ordinal `IndexOf` (`:24`-`:25`), asserts the marker exists with a `because` explaining what the workflow must declare (`:26`-`:27`), then takes the alphanumeric run that follows as the region (`:29`-`:30`). Assert-then-parse rather than return-empty is what the base asks implementations to do. + - The inherited fact `PrivacyPolicy_DataStorageRegion_MatchesDeployedRegion` (`DataResidencyTestsBase.cs:26`) then asserts the normalized policy contains the normalized region and contains none of the forbidden claims. - **Why it's built this way** - the account data and session bookmarks live in the Azure SQL database, and the QiMata Sponsorship subscription forces that SQL server into a different region from the Container Apps (`DataResidencyTests.cs:5`-`:9`), so "where the app runs" is genuinely not "where the personal data sits". Parsing the SQL region default rather than the app region encodes that distinction. - **Caveats / not-in-source** - the parse is positional: it takes the first occurrence of the marker in `deploy.yml`. The workflow contains both a job-level `SQL_LOCATION_OVERRIDE` env binding (`deploy.yml:914`) and the shell default (`:949`), so the rule depends on the marker string `SQL_LOCATION_OVERRIDE:-` appearing only in the latter form. ### DomainPurityTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/DomainPurityTests.cs:3` · Level 10 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/DomainPurityTests.cs:3` · Level 13 · class (public, sealed) - **What it is** - the Clean Architecture purity guard, plus one repo-specific addition: RabbitMQ is added to the forbidden-dependency list for Domain and Shared (`MMCA.ADC.Architecture.Tests/DomainPurityTests.cs:9`). - **Depends on** - [DomainPurityTestsBase](#domainpuritytestsbase) and [AdcArchitectureMap](#adcarchitecturemap). @@ -3021,7 +3486,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc ### EntityConventionTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/EntityConventionTests.cs:3` · Level 10 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/EntityConventionTests.cs:3` · Level 13 · class (public, sealed) - **What it is** - the DDD entity-shape guard for ADC's three module domains: aggregate roots exist, each has a `Result`-returning static factory and no public constructor, domain entities are sealed and live in the Domain layer, and DTOs or requests do not. - **Depends on** - [EntityConventionTestsBase](#entityconventiontestsbase) and [AdcArchitectureMap](#adcarchitecturemap). Map-only shape ([ConcurrencyConventionTests](#concurrencyconventiontests)). @@ -3029,29 +3494,29 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc ### EventConventionTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/EventConventionTests.cs:3` · Level 10 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/EventConventionTests.cs:3` · Level 13 · class (public, sealed) -- **What it is** - the integration-event shape guard: every integration event declares a schema version, inherits the framework's base integration event, and lives in an `*.IntegrationEvents` namespace under Shared. +- **What it is** - the integration-event shape guard: every integration event declares a schema version, inherits the framework's base integration event, and lives in an `*.IntegrationEvents` namespace under Shared; and every event upcaster is unique and moves the version forward. - **Depends on** - [EventConventionTestsBase](#eventconventiontestsbase) and [AdcArchitectureMap](#adcarchitecturemap). Map-only shape ([ConcurrencyConventionTests](#concurrencyconventiontests)). -- **Walkthrough** - one member, `Map` (`:5`). Three inherited facts (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/EventConventionTestsBase.cs:13`, `:16`, `:19`) enforce `SchemaVersion`, base-type inheritance, and namespace placement ([ADR-010](https://ivanball.github.io/docs/adr/010-integration-event-schema-versioning.html)). `[Rubric §6 - CQRS & Event-Driven]` assesses the discipline around asynchronous contracts; this rule handles the *shape*, while [IntegrationEventContractTests](#integrationeventcontracttests) freezes the *content*. +- **Walkthrough** - one member, `Map` (`:5`). Five inherited facts (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/EventConventionTestsBase.cs:14`, `:17`, `:20`, `:23`, `:26`). The first three enforce `SchemaVersion`, base-type inheritance, and namespace placement ([ADR-010](https://ivanball.github.io/docs/adr/010-integration-event-schema-versioning.html)). The last two guard the upcaster machinery that ADR-010 depends on: `EventUpcasters_ShouldHave_UniqueSourceTypes` (`:23`), so two upcasters cannot claim the same source shape, and `EventUpcasters_ShouldIncrease_SchemaVersion` (`:26`), so an upcaster cannot map an event onto the same or an earlier version. `[Rubric §6 - CQRS & Event-Driven]` assesses the discipline around asynchronous contracts; this rule handles the *shape*, while [IntegrationEventContractTests](#integrationeventcontracttests) freezes the *content*. ### FormsConventionTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/FormsConventionTests.cs:14` · Level 10 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/FormsConventionTests.cs:14` · Level 13 · class (public, sealed) - **What it is** - the UX-safety guard over ADC's admin forms. It configures the shared rule for the six Conference create forms and adds a hand-written fact for the Identity Profile form, which by design does not match the shared rule's glob (`MMCA.ADC.Architecture.Tests/FormsConventionTests.cs:3`-`:13`). - **Depends on** - [FormsConventionTestsBase](#formsconventiontestsbase), [AdcArchitectureMap](#adcarchitecturemap), [ArchitectureMapBase](#architecturemapbase) (called statically for the repo root, `:34`), `System.IO`, and AwesomeAssertions. -- **Concept introduced, extending a rule instead of replacing it, and covering what it cannot reach.** Two mechanisms appear here for the first time in this unit. First, `RequiredMarkers` is overridden by *spreading the base list* and appending to it (`.. base.RequiredMarkers`, `:26`), so ADC inherits the six framework markers (`UnsavedChangesGuard`, `IsDirtyAccessor`, `_isDirty`, ` 6` (`:18`), raising the base floor of 1 (`FormsConventionTestsBase.cs:24`) to ADC's real count: Event, Session, Room, Question, Speaker, and ConferenceCategory (`FormsConventionTests.cs:5`-`:6`). - - `RequiredMarkers` (`:24`-`:29`) appends two literals to the inherited set: the per-form ` 0` (the summary rendering from the live MudForm error list), and the `ValidateNewPassword` / `ValidateConfirmPassword` client-side wiring; it reports every missing marker at once rather than failing on the first (`:51`-`:56`). Finally it counts occurrences of `Required="true"` and `RequiredError`, requiring at least three of each so all three password fields stay required and keep a user-facing message (`:58`-`:61`), using the local `CountOccurrences` helper (`:64`-`:75`). - **Why it's built this way** - the Profile form is a single-section password and delete form with no navigate-away step, so it carries no unsaved-changes guard by design and does not match the base's `*Create.razor` glob (`:9`-`:12`); the base documents exactly that exclusion (`FormsConventionTestsBase.cs:11`-`:13`). Rather than weakening the shared rule to accommodate it, ADC asserts the markers that *do* apply. - **Caveats / not-in-source** - the hand-written fact hardcodes one file path, so moving `Profile.razor` fails the test (again, the safe direction) but adding a second self-service form gains no coverage automatically. ### FrameworkVersionConsistencyTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/FrameworkVersionConsistencyTests.cs:9` · Level 10 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/FrameworkVersionConsistencyTests.cs:9` · Level 13 · class (public, sealed) - **What it is** - the lockstep-versioning gate: every `MMCA.Common.*` package pinned in ADC's `Directory.Packages.props` must carry one and the same version, so a partial sweep fails CI instead of producing a subtly mismatched framework surface at runtime (`MMCA.ADC.Architecture.Tests/FrameworkVersionConsistencyTests.cs:3`-`:8`). - **Depends on** - [FrameworkVersionConsistencyTestsBase](#frameworkversionconsistencytestsbase) and [AdcArchitectureMap](#adcarchitecturemap). Map-only shape ([ConcurrencyConventionTests](#concurrencyconventiontests)); the map is used for its repo token, to find the props file from the repo root. @@ -3060,7 +3525,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc ### HandlerConventionTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/HandlerConventionTests.cs:3` · Level 10 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/HandlerConventionTests.cs:3` · Level 13 · class (public, sealed) - **What it is** - the CQRS handler placement and composition guard: handlers and validators live in the Application layer, handlers do not inject other handlers, application services do not inject handlers, domain event handlers are sealed and live in Application, and application services respect a constructor-arity limit. - **Depends on** - [HandlerConventionTestsBase](#handlerconventiontestsbase) and [AdcArchitectureMap](#adcarchitecturemap). Map-only shape ([ConcurrencyConventionTests](#concurrencyconventiontests)). @@ -3068,16 +3533,27 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc ### HandlerResultConventionTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/HandlerResultConventionTests.cs:8` · Level 10 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/HandlerResultConventionTests.cs:8` · Level 13 · class (public, sealed) - **What it is** - the gate that turns a runtime constraint into a build-time one: every ADC command and query handler's `TResult` must be [Result](group-01-result-error-handling.md#result) or `Result` (`MMCA.ADC.Architecture.Tests/HandlerResultConventionTests.cs:3`-`:7`). - **Depends on** - [HandlerResultConventionTestsBase](#handlerresultconventiontestsbase) and [AdcArchitectureMap](#adcarchitecturemap). Map-only shape ([ConcurrencyConventionTests](#concurrencyconventiontests)). -- **Concept introduced** - shifting a failure left. The decorator pipeline can short-circuit (a feature flag off, a validation failure, a cache hit), and to do that it must manufacture a failed result of the handler's `TResult`; the comment names the mechanism, `ResultFailureFactory` (`:5`-`:6`). A handler returning a bare DTO therefore compiles and registers cleanly and only explodes the first time a short-circuit fires in production. `[Rubric §6 - CQRS & Event-Driven]` and `[Rubric §14 - Testability]`: an invariant the type system cannot express is exactly what a fitness function is for. +- **Concept introduced** - shifting a failure left. The decorator pipeline can short-circuit (a feature flag off, an authorization denial, a validation failure, a cache hit), and to do that it must manufacture a failed result of the handler's `TResult`; the comment names the mechanism, `ResultFailureFactory` (`:5`-`:6`). A handler returning a bare DTO therefore compiles and registers cleanly and only explodes the first time a short-circuit fires in production. `[Rubric §6 - CQRS & Event-Driven]` and `[Rubric §14 - Testability]`: an invariant the type system cannot express is exactly what a fitness function is for. - **Walkthrough** - one member, `Map` (`:10`). Three inherited facts (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/HandlerResultConventionTestsBase.cs:21`, `:24`, `:27`): the Application layers declare at least one handler (a non-vacuity check), command handlers return result types, and query handlers do too. Opt-in from v1.120.0 (`HandlerResultConventionTests.cs:3`). +### IdempotencyConventionTests + +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/IdempotencyConventionTests.cs:3` · Level 13 · class (public, sealed) + +- **What it is** - the rule that every POST action in ADC's API layer states, in code, whether a retried request replays the original response or deliberately does not. Map-only shape ([ConcurrencyConventionTests](#concurrencyconventiontests)): the body is one line (`MMCA.ADC.Architecture.Tests/IdempotencyConventionTests.cs:5`). +- **Depends on** - [IdempotencyConventionTestsBase](#idempotencyconventiontestsbase) and [AdcArchitectureMap](#adcarchitecturemap), and, by attribute name, [IdempotentAttribute](group-12-api-hosting-mapping.md#idempotentattribute) and [NonIdempotentAttribute](group-12-api-hosting-mapping.md#nonidempotentattribute). +- **Concept introduced, forcing a decision rather than a default.** POST is the one verb HTTP does not define as idempotent, so a client retrying a timed-out POST cannot know whether the first attempt landed. The framework's answer is the `Idempotency-Key` filter, and it costs an existing client nothing, because the filter no-ops for a request that carries no key header. That makes the only failure mode worth gating an *omission nobody noticed*: an action that should replay but silently does not. The rule therefore does not demand `[Idempotent]`; it demands that the author write down which of the two applies, with `[NonIdempotent("why")]` carrying its own justification string (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Idempotency.cs:14`-`:31`, `:57`-`:60`). `[Rubric §9 - API & Contract Design]` assesses retry semantics as part of the contract; `[Rubric §29 - Resilience & Business Continuity]`: a retry that double-writes is precisely the failure a resilience policy creates when the endpoint has not thought about it. +- **Walkthrough** - one member, `Map` (`:5`), implementing the base's abstract property (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/IdempotencyConventionTestsBase.cs:12`). One inherited fact, `PostActions_ShouldDeclare_IdempotencyIntent` (`:15`), delegating to `ArchitectureRules.PostActionsDeclareIdempotencyIntent(Map)` (`ArchitectureRules.Idempotency.cs:43`-`:60`), which walks the concrete controllers in every `Layer.Api` assembly the map declares (`:47`-`:52`). Attributes are read with `inherit: true` and abstract controller types are skipped, so an ADC controller that inherits a POST action from `AuthControllerBase` or `AggregateRootEntityControllerBase` already satisfies the rule through that base rather than needing its own attribute (`:32`-`:37`). +- **Why it's built this way** - the base states the subclassing rule: derive it in a repo whose map declares an `Api` layer, and a repo with no API layer simply does not subclass (`IdempotencyConventionTestsBase.cs:6`-`:8`). ADC declares an Api layer for all three mapped modules, so the gate is live across the whole REST surface. +- **Caveats / not-in-source** - detection is by attribute type *name*, keeping the rule library free of an ASP.NET reference, and only `[HttpPost]` is recognised: an action routed through `[AcceptVerbs("POST")]` or a conventional route is out of scope (`ArchitectureRules.Idempotency.cs:38`-`:42`). The Notification module's API assembly is not in the map, so its POST actions, if any, are outside this gate. + ### ImmutabilityTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/ImmutabilityTests.cs:3` · Level 10 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/ImmutabilityTests.cs:3` · Level 13 · class (public, sealed) - **What it is** - the immutability guard across five categories of type: DTOs, commands and queries, domain events, integration events, and value objects (the last also required to be sealed and to live in Shared). - **Depends on** - [ImmutabilityTestsBase](#immutabilitytestsbase) and [AdcArchitectureMap](#adcarchitecturemap). Map-only shape ([ConcurrencyConventionTests](#concurrencyconventiontests)). @@ -3085,18 +3561,18 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc ### IntegrationEventContractTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/IntegrationEventContractTests.cs:3` · Level 10 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/IntegrationEventContractTests.cs:3` · Level 13 · class (public, sealed) -- **What it is** - the frozen wire contract for ADC's cross-service asynchronous API. It commits a seven-line snapshot of every integration event's full name and property shape, and the build fails if the live contract differs (`MMCA.ADC.Architecture.Tests/IntegrationEventContractTests.cs:9`-`:20`). +- **What it is** - the frozen wire contract for ADC's cross-service *asynchronous* API. It commits a seven-line snapshot of every integration event's full name and property shape, and the build fails if the live contract differs (`MMCA.ADC.Architecture.Tests/IntegrationEventContractTests.cs:9`-`:20`). - **Depends on** - [IntegrationEventContractTestsBase](#integrationeventcontracttestsbase) and [AdcArchitectureMap](#adcarchitecturemap). -- **Concept introduced, the approval snapshot.** The rules above check *shape rules*; this one checks *identity*. A consumer in another service deserializes by shape, so a renamed, removed, or retyped property (or a brand-new event shipped without a consumer) breaks the contract at runtime with no compile error anywhere. The base rebuilds the live contract from the map and asserts sequence equality against the committed list (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/IntegrationEventContractTestsBase.cs:21`-`:28`), so any change surfaces as a diff in this file that a reviewer must consciously accept. `[Rubric §9 - API & Contract Design]` assesses contract governance, and the asynchronous contract is as much an API as the REST surface; `[Rubric §7 - Microservices Readiness]`: with all four ADC modules already running as separate services, this list is the actual coupling between them. -- **Walkthrough** - two members. `Map` (`:5`), and `ExpectedContract` (`:9`-`:20`), a collection expression of seven strings in `FullName { Prop:Type, ... }` form: `EventFeedbackSubmitted` and `SessionFeedbackSubmitted` from Conference, `SpeakerLinkedToUser` and `SpeakerUnlinkedFromUser` from Conference (the pair Identity consumes to set and clear `User.LinkedSpeakerId`), [AttendeeCheckedIn](group-22-engagement-module.md#attendeecheckedin) from Engagement, and `UserDeleted` plus [UserRegistered](group-24-identity-module.md#userregistered) from Identity. The properties are listed in sorted order, which is how a rebuilt contract stays comparable line by line. +- **Concept introduced, the approval snapshot.** The rules above check *shape rules*; this one checks *identity*. A consumer in another service deserializes by shape, so a renamed, removed, or retyped property (or a brand-new event shipped without a consumer) breaks the contract at runtime with no compile error anywhere. The base rebuilds the live contract from the map and asserts sequence equality against the committed list (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/IntegrationEventContractTestsBase.cs:19`-`:29`), so any change surfaces as a diff in this file that a reviewer must consciously accept. `[Rubric §9 - API & Contract Design]` assesses contract governance, and the asynchronous contract is as much an API as the REST surface; `[Rubric §7 - Microservices Readiness]`: with all four ADC modules already running as separate services, this list is the actual coupling between them. Its synchronous twin is [ProtoContractTests](#protocontracttests). +- **Walkthrough** - two members. `Map` (`:5`), and `ExpectedContract` (`:9`-`:20`), a collection expression of seven strings in `FullName { Prop:Type, ... }` form: [EventFeedbackSubmitted](group-17-conference-domain.md#eventfeedbacksubmitted) and [SessionFeedbackSubmitted](group-17-conference-domain.md#sessionfeedbacksubmitted) from Conference, [SpeakerLinkedToUser](group-17-conference-domain.md#speakerlinkedtouser) and [SpeakerUnlinkedFromUser](group-17-conference-domain.md#speakerunlinkedfromuser) from Conference (the pair Identity consumes to set and clear the linked-speaker reference on the user), [AttendeeCheckedIn](group-22-engagement-module.md#attendeecheckedin) from Engagement, and [UserDeleted](group-24-identity-module.md#userdeleted) plus [UserRegistered](group-24-identity-module.md#userregistered) from Identity. The properties are listed in sorted order, which is how a rebuilt contract stays comparable line by line. - **Why it's built this way** - the comment states the rule of engagement (`:7`-`:8`): update the snapshot deliberately, and version the event or coordinate the consumer rollout in the same commit. The `AttendeeCheckedIn` entry carries its own inline justification (`:15`-`:16`): `SponsorId` is additive, optional, defaults to null, and is declared last precisely so a payload written before the sponsor scope existed still deserializes (confirmed in the event itself, `MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/IntegrationEvents/AttendeeCheckedIn.cs:21`, `:29`). -- **Caveats / not-in-source** - the snapshot proves that the shape has not changed, not that any consumer actually handles it. Consumer-side behavior is exercised by the cross-service Testcontainers tier, not here. +- **Caveats / not-in-source** - the snapshot proves that the shape has not changed, not that any consumer actually handles it. Consumer-side behavior is exercised by the cross-service Testcontainers tier, not here. The contract is rebuilt from mapped assemblies, so an integration event declared in the unmapped Notification module would not appear. ### LayerDependencyTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/LayerDependencyTests.cs:3` · Level 10 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/LayerDependencyTests.cs:3` · Level 13 · class (public, sealed) - **What it is** - the Clean Architecture layer-flow guard, and the highest-fact-count rule in the unit: fifteen inherited facts covering which layer may reference which. - **Depends on** - [LayerDependencyTestsBase](#layerdependencytestsbase) and [AdcArchitectureMap](#adcarchitecturemap). Map-only shape ([ConcurrencyConventionTests](#concurrencyconventiontests)). @@ -3104,7 +3580,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc ### LocalizedTextConventionTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/LocalizedTextConventionTests.cs:14` · Level 10 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/LocalizedTextConventionTests.cs:14` · Level 13 · class (public, sealed) - **What it is** - the companion to [TranslationCompletenessTests](#translationcompletenesstests). Where that one asks "is every key translated", this one asks "does every user-visible string go through a key at all": no hard-coded literals in `.razor` or `.razor.cs` under `Source/` (`MMCA.ADC.Architecture.Tests/LocalizedTextConventionTests.cs:3`-`:13`). - **Depends on** - [LocalizedTextConventionTestsBase](#localizedtextconventiontestsbase) and [AdcArchitectureMap](#adcarchitecturemap). @@ -3113,7 +3589,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc ### MicroserviceExtractionTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/MicroserviceExtractionTests.cs:3` · Level 10 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/MicroserviceExtractionTests.cs:3` · Level 13 · class (public, sealed) - **What it is** - the single-fact guard that transport never leaks into the core layers, so a module behaves identically in-process or extracted. - **Depends on** - [MicroserviceExtractionTestsBase](#microserviceextractiontestsbase) and [AdcArchitectureMap](#adcarchitecturemap). Map-only shape ([ConcurrencyConventionTests](#concurrencyconventiontests)). @@ -3121,7 +3597,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc ### ModuleIsolationTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/ModuleIsolationTests.cs:3` · Level 10 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/ModuleIsolationTests.cs:3` · Level 13 · class (public, sealed) - **What it is** - the guard that Identity, Conference, and Engagement do not reach into each other: module Domains, Applications, Infrastructures, and APIs are each isolated from their siblings, and neither Domain nor Application may reach another module's Infrastructure. - **Depends on** - [ModuleIsolationTestsBase](#moduleisolationtestsbase) and [AdcArchitectureMap](#adcarchitecturemap). Map-only shape ([ConcurrencyConventionTests](#concurrencyconventiontests)). @@ -3129,7 +3605,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc ### NamingConventionTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/NamingConventionTests.cs:3` · Level 10 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/NamingConventionTests.cs:3` · Level 13 · class (public, sealed) - **What it is** - the ten-fact naming and sealing guard: handler, command, query, validator, DTO, specification, repository, and EF configuration suffixes, plus domain events sealed in a `*.DomainEvents` namespace and invariant classes static. - **Depends on** - [NamingConventionTestsBase](#namingconventiontestsbase) and [AdcArchitectureMap](#adcarchitecturemap). Map-only shape ([ConcurrencyConventionTests](#concurrencyconventiontests)). @@ -3137,7 +3613,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc ### PiiConventionTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/PiiConventionTests.cs:3` · Level 10 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/PiiConventionTests.cs:3` · Level 13 · class (public, sealed) - **What it is** - the privacy structural guard: every domain entity declaring a [PiiAttribute](group-02-domain-building-blocks.md#piiattribute)-marked property must implement [IAnonymizable](group-02-domain-building-blocks.md#ianonymizable), so an entity that holds personal data always has an erasure path. - **Depends on** - [PiiConventionTestsBase](#piiconventiontestsbase) and [AdcArchitectureMap](#adcarchitecturemap). Map-only shape ([ConcurrencyConventionTests](#concurrencyconventiontests)). @@ -3145,7 +3621,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc ### RawQueryableConventionTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/RawQueryableConventionTests.cs:11` · Level 10 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/RawQueryableConventionTests.cs:11` · Level 13 · class (public, sealed) - **What it is** - the rule that Application-layer code must not use the repository's raw `IQueryable` surfaces (`Table` / `TableNoTracking*`), carrying an eight-file allowlist that pins ADC's existing deliberate uses (`MMCA.ADC.Architecture.Tests/RawQueryableConventionTests.cs:3`-`:9`, `:33`-`:52`). - **Depends on** - [RawQueryableConventionTestsBase](#rawqueryableconventiontestsbase), [AdcArchitectureMap](#adcarchitecturemap), [ArchitectureMapBase](#architecturemapbase) (statically, for the repo root at `:28`), and `System.IO.Path`. @@ -3157,9 +3633,19 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc - The inherited fact is `ApplicationLayer_DoesNotUseRawQueryableSurfaces` (`RawQueryableConventionTestsBase.cs:61`), a textual scan rather than an assembly scan, which is why it needs directories rather than the map's assemblies. - **Caveats / not-in-source** - `AllowedFiles` matches by file name, not by path, so two files with the same name in different modules would both be exempted. Nothing here enforces the "shrink it over time" discipline the comment asks for. +### ServiceContractPurityTests + +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/ServiceContractPurityTests.cs:9` · Level 13 · class (public, sealed) + +- **What it is** - the purity rule for the published gRPC wire surface: a type marked [ServiceContractAttribute](group-13-grpc-contracts.md#servicecontractattribute) must not depend on the producing service's Domain, Application, or Infrastructure (`MMCA.ADC.Architecture.Tests/ServiceContractPurityTests.cs:3`-`:8`). Map-only shape ([ConcurrencyConventionTests](#concurrencyconventiontests)). +- **Depends on** - [ServiceContractPurityTestsBase](#servicecontractpuritytestsbase) and [AdcArchitectureMap](#adcarchitecturemap). +- **Concept introduced, the attribute-driven ratchet, and the honest vacuous pass.** The other purity rules in this unit iterate *layers*. This one cannot: no repo registers `Layer.Contracts` in its map today, so a layer-iterating rule would pass vacuously forever without anyone noticing (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/ServiceContractPurityTestsBase.cs:8`-`:11`). Instead it scans every assembly the map registers for types carrying the marker, wherever they live, and enforces the invariant from the first marked type onward. The base is explicit that a repo which marks no type yet passes without asserting anything, and that this is the deliberate trade: the value is the ratchet, an invariant already wired up with no test left to remember to write (`:12`-`:18`). `[Rubric §7 - Microservices Readiness]` assesses whether a service can be consumed without its internals: a contract that leaks a domain entity or a persistence type forces every consumer to take the producer as a package dependency, which is what makes an extraction irreversible (`MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Contracts.cs:14`-`:18`). `[Rubric §9 - API & Contract Design]` covers the same boundary from the contract side. +- **Walkthrough** - one member, `Map` (`:11`), implementing the base's abstract property (`ServiceContractPurityTestsBase.cs:22`). One inherited fact, `ServiceContracts_ShouldNotDependOn_ServiceInternals` (`:25`), delegating to `ArchitectureRules.ServiceContractsDoNotDependOnServiceInternals(Map)` (`ArchitectureRules.Contracts.cs:32`-`:54`). The rule computes the forbidden internal namespaces from the map, returns immediately if that set is empty (`:34`-`:38`), and otherwise runs a NetArchTest query per mapped assembly against the types carrying the marker (`:40`-`:53`). The marker is matched by full name, `MMCA.Common.Shared.Abstractions.ServiceContractAttribute` (`:10`-`:11`), keeping the rule library free of a framework reference. +- **Caveats / not-in-source** - ADC declares no `[ServiceContract]` type in `Source/` today, and the four `*.Contracts` projects are not in [AdcArchitectureMap](#adcarchitecturemap), so this rule currently passes without inspecting anything in this repo. That is the base's documented vacuous case, not a defect, but it does mean the enforcement here is latent: it starts biting the day someone marks a type in a mapped assembly. ADC's actual gRPC contract governance today runs through [ProtoContractTests](#protocontracttests), which is not vacuous. + ### SharedLayerTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/SharedLayerTests.cs:3` · Level 10 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/SharedLayerTests.cs:3` · Level 13 · class (public, sealed) - **What it is** - the guard on the Shared layer, the one layer other modules are allowed to reference: a module's Shared project must not depend on that module's own internal layers, must not reach sibling modules, and must stay free of EF Core. - **Depends on** - [SharedLayerTestsBase](#sharedlayertestsbase) and [AdcArchitectureMap](#adcarchitecturemap). Map-only shape ([ConcurrencyConventionTests](#concurrencyconventiontests)). @@ -3167,7 +3653,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc ### SliceCohesionTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/SliceCohesionTests.cs:8` · Level 10 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/SliceCohesionTests.cs:8` · Level 13 · class (public, sealed) - **What it is** - the vertical-slice cohesion rule: every module's `Application/{Aggregate}/UseCases/{Operation}/` slice keeps its command or query, its handler, and its validator in one namespace, and the build fails if a handler is stranded from its contract (`MMCA.ADC.Architecture.Tests/SliceCohesionTests.cs:3`-`:7`). - **Depends on** - [SliceCohesionTestsBase](#slicecohesiontestsbase) and [AdcArchitectureMap](#adcarchitecturemap). Map-only shape ([ConcurrencyConventionTests](#concurrencyconventiontests)). @@ -3175,7 +3661,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc ### SpecificationConventionTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/SpecificationConventionTests.cs:8` · Level 10 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/SpecificationConventionTests.cs:8` · Level 13 · class (public, sealed) - **What it is** - the cross-source specification guard: no specification may filter by navigating to another entity, because such a filter would not translate if that entity later moved to a different data source. The stated alternative is [CrossSourceSpecification](group-03-querying-specifications.md#crosssourcespecification) (`MMCA.ADC.Architecture.Tests/SpecificationConventionTests.cs:3`-`:7`). - **Depends on** - [SpecificationConventionTestsBase](#specificationconventiontestsbase) and [AdcArchitectureMap](#adcarchitecturemap). Map-only shape ([ConcurrencyConventionTests](#concurrencyconventiontests)). @@ -3184,7 +3670,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc ### StateManagementConventionTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/StateManagementConventionTests.cs:9` · Level 10 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/StateManagementConventionTests.cs:9` · Level 13 · class (public, sealed) - **What it is** - the Blazor state-ownership guard: the Identity, Conference, and Engagement UI assemblies carry no mutable static state, and stateful UI services stay scoped (`MMCA.ADC.Architecture.Tests/StateManagementConventionTests.cs:3`-`:8`). - **Depends on** - [StateManagementConventionTestsBase](#statemanagementconventiontestsbase) and [AdcArchitectureMap](#adcarchitecturemap). Map-only shape ([ConcurrencyConventionTests](#concurrencyconventiontests)). @@ -3193,7 +3679,7 @@ Where a subclass carries an override beyond `Map`, that override is itself a doc ### UIArchitectureConventionTests -> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/UIArchitectureConventionTests.cs:10` · Level 10 · class (public, sealed) +> MMCA.ADC.Architecture.Tests · `MMCA.ADC.Architecture.Tests` · `MMCA.ADC.Architecture.Tests/UIArchitectureConventionTests.cs:10` · Level 13 · class (public, sealed) - **What it is** - the container/presentational split enforced mechanically: every code-behind under `Source/` (module UI and UI hosts alike) stays within the 400-line convention cap, and inline `@code` blocks stay small (`MMCA.ADC.Architecture.Tests/UIArchitectureConventionTests.cs:3`-`:9`). - **Depends on** - [UIArchitectureConventionTestsBase](#uiarchitectureconventiontestsbase) and [AdcArchitectureMap](#adcarchitecturemap). Map-only shape ([ConcurrencyConventionTests](#concurrencyconventiontests)). diff --git a/docs/onboarding/00-dependency-manifest.html b/docs/onboarding/00-dependency-manifest.html index f4e6e65..4379b55 100644 --- a/docs/onboarding/00-dependency-manifest.html +++ b/docs/onboarding/00-dependency-manifest.html @@ -158,10 +158,10 @@

Edge resolution & accuracy

is still linked (only one possible target). Names that are neither visible nor unique are dropped as unresolvable without full semantic binding.

    -
  • Edges resolved by namespace visibility: 12293 (~96%)
  • -
  • Edges resolved by globally-unique name (fallback): 493
  • +
  • Edges resolved by namespace visibility: 13146 (~96%)
  • +
  • Edges resolved by globally-unique name (fallback): 540
  • References dropped as ambiguous (matched >1 type, none visible): 29
  • -
  • Sensitivity: 518 / 3465 type levels would change if the globally-unique fallback +
  • Sensitivity: 753 / 3668 type levels would change if the globally-unique fallback were excluded; the fallback is retained because a globally-unique first-party name is unambiguous, so excluding it would under-count real dependencies.
@@ -179,86 +179,86 @@

Level distribution

0 - 708 + 729 1 - 422 + 430 2 - 268 + 273 3 - 241 + 265 4 - 296 + 322 5 - 224 + 231 6 - 125 + 120 7 - 138 + 107 8 - 267 + 213 9 - 251 + 245 10 - 275 + 205 11 - 101 + 73 12 - 32 + 57 13 - 17 + 108 14 - 11 + 55 15 - 7 + 137 16 - 6 + 14 17 - 15 + 20 18 - 60 + 63 19 1 -

Cycles (SCC size > 1): 30

+

Cycles (SCC size > 1): 34

@@ -305,10 +305,20 @@

Cycles (SCC size > 1): 30

+ + + + + + + + + + @@ -334,11 +344,6 @@

Cycles (SCC size > 1): 30

- - - - - @@ -368,55 +373,70 @@

Cycles (SCC size > 1): 30

- + + + + + + + + + + + + + + + + + + + + + - + - + - + - - - + + + - + - + - + - - - + + + - + - - - - -
3 2API:InsecureJwtMetadataWarningStartupFilter, API:WebApplicationBuilderExtensions
32 Shared:Address, Shared:AddressInvariants
44Tests:AnonymousEndpointTestsBaseTests, Tests:DriftedTests, Tests:StaleAllowListTests, Tests:ConformantTests
4 2 Tests:Priority, Tests:Priority
68Infrastructure:AuditTrailSaveChangesInterceptor, Infrastructure:ApplicationDbContext, Infrastructure:DataSourceModelCacheKeyFactory, Infrastructure:AuditSaveChangesInterceptor, Infrastructure:DomainEventSaveChangesInterceptor, Infrastructure:DeferredDispatch, Infrastructure:TenantSaveChangesInterceptor, Infrastructure:OutboxFinalizer
6 3 Domain:Category, Domain:CategoryInvariants, Domain:CategoryItem
Tests:SpecificationFitnessTests, Tests:SpecTestMap
784Domain:Session, Domain:SessionCategoryItem, Domain:SessionQuestionAnswer, Domain:SessionSpeaker
82Domain:LivePoll, Domain:LivePollOption
102API:MiddlewarePipelineBuilder, API:WebApplicationExtensions
118Infrastructure:AuditTrailSaveChangesInterceptor, Infrastructure:ApplicationDbContext, Infrastructure:DataSourceModelCacheKeyFactory, Infrastructure:AuditSaveChangesInterceptor, Infrastructure:DomainEventSaveChangesInterceptor, Infrastructure:DeferredDispatch, Infrastructure:TenantSaveChangesInterceptor, Infrastructure:OutboxFinalizer
12 2 Tests:MidSaveContextCreatingDbContext, Tests:ReentrantSaveInterceptor
712 2 Tests:CommitFailingDbContext, Tests:FailingDatabaseFacade
712 2 Tests:FailingSaveInterceptor, Tests:OutboxRoutingTestDbContext
712 2 Tests:GateTestContext, Tests:GateTestContext
84Domain:Session, Domain:SessionCategoryItem, Domain:SessionQuestionAnswer, Domain:SessionSpeaker132Tests:EventScopeFitnessTests, Tests:FakeConsumerMap
813 2Tests:EventScopeFitnessTests, Tests:FakeConsumerMapTests:EventUpcasterFitnessTests, Tests:UpcasterTestMap
813 2 Tests:AuditTrailTestContext, Tests:FailingSaveInterceptor
82Domain:LivePoll, Domain:LivePollOption143Tests:CosmosConfigurationPortabilityTests, Tests:FixedAssemblyProvider, Tests:MultiSourceSqliteIntegrationTests
1014 2 Tests:DatabaseInitializationExtensionsTests, Tests:FixedAssemblyProvider
113Tests:CosmosConfigurationPortabilityTests, Tests:FixedAssemblyProvider, Tests:MultiSourceSqliteIntegrationTests

Manifest (by level, then assembly)

@@ -536,6 +556,27 @@

Manifest (by level, then assembly)

+ + + + + + + + + + + + + + + + + + + + + @@ -592,6 +633,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -599,6 +647,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -1152,6 +1207,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -1166,6 +1228,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -2426,13 +2495,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -2559,6 +2621,20 @@

Manifest (by level, then assembly)

+ + + + + + + + + + + + + + @@ -2699,6 +2775,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -2706,6 +2789,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -3091,6 +3181,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -3413,6 +3510,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -3420,6 +3524,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -3490,35 +3601,35 @@

Manifest (by level, then assembly)

- + - - + + - + - + - + @@ -3574,13 +3685,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -3665,6 +3769,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -4190,6 +4301,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -4456,6 +4574,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -4568,6 +4693,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -4701,6 +4833,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -4792,6 +4931,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -4813,6 +4959,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -4960,6 +5113,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -5275,6 +5435,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -5415,6 +5582,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -5534,6 +5708,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -6017,6 +6198,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -6136,13 +6324,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -6283,6 +6464,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -6290,6 +6478,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -6353,6 +6548,20 @@

Manifest (by level, then assembly)

+ + + + + + + + + + + + + + @@ -6430,6 +6639,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -6542,6 +6758,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -6815,6 +7038,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -6822,6 +7052,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -6864,6 +7101,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -6885,13 +7129,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -6948,10 +7185,10 @@

Manifest (by level, then assembly)

- + - - + + @@ -6962,13 +7199,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -7207,13 +7437,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -8999,17 +9222,17 @@

Manifest (by level, then assembly)

- + - - + + - + - + @@ -9027,17 +9250,17 @@

Manifest (by level, then assembly)

- + - - + + - + - + @@ -9104,6 +9327,20 @@

Manifest (by level, then assembly)

+ + + + + + + + + + + + + + @@ -9195,6 +9432,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -9230,6 +9474,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -9279,10 +9530,10 @@

Manifest (by level, then assembly)

- - - - + + + + @@ -9643,6 +9894,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -10266,6 +10524,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -10434,6 +10699,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -10511,6 +10783,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -10763,13 +11042,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -10812,6 +11084,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -10835,15 +11114,8 @@

Manifest (by level, then assembly)

- - - - - - - - - + + @@ -10884,8 +11156,15 @@

Manifest (by level, then assembly)

- - + + + + + + + + + @@ -10917,6 +11196,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -10994,6 +11280,27 @@

Manifest (by level, then assembly)

+ + + + + + + + + + + + + + + + + + + + + @@ -11078,6 +11385,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -11085,6 +11399,20 @@

Manifest (by level, then assembly)

+ + + + + + + + + + + + + + @@ -11113,6 +11441,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -11127,6 +11462,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -11141,45 +11483,80 @@

Manifest (by level, then assembly)

- - - - + + + + - - - - + + + + - - + + - + - - - - + + + + - - - - + + + + - - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -11337,13 +11714,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -11358,6 +11728,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -11393,6 +11770,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -11428,17 +11812,17 @@

Manifest (by level, then assembly)

- + - + - - + + @@ -11456,6 +11840,20 @@

Manifest (by level, then assembly)

+ + + + + + + + + + + + + + @@ -11477,6 +11875,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -11491,6 +11896,27 @@

Manifest (by level, then assembly)

+ + + + + + + + + + + + + + + + + + + + + @@ -11675,8 +12101,8 @@

Manifest (by level, then assembly)

- - + + @@ -11988,6 +12414,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -12158,8 +12591,8 @@

Manifest (by level, then assembly)

- - + + @@ -12214,15 +12647,15 @@

Manifest (by level, then assembly)

- - + + - - + + @@ -12352,6 +12785,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -12366,6 +12806,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -12548,13 +12995,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -12625,6 +13065,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -12926,6 +13373,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -13066,6 +13520,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -13108,6 +13569,20 @@

Manifest (by level, then assembly)

+ + + + + + + + + + + + + + @@ -13115,6 +13590,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -13178,10 +13660,52 @@

Manifest (by level, then assembly)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + @@ -13206,6 +13730,48 @@

Manifest (by level, then assembly)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -13411,8 +13977,8 @@

Manifest (by level, then assembly)

- - + + @@ -13563,6 +14129,20 @@

Manifest (by level, then assembly)

+ + + + + + + + + + + + + + @@ -13577,6 +14157,27 @@

Manifest (by level, then assembly)

+ + + + + + + + + + + + + + + + + + + + + @@ -13850,6 +14451,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -13934,10 +14542,10 @@

Manifest (by level, then assembly)

- + - - + + @@ -13950,8 +14558,8 @@

Manifest (by level, then assembly)

- - + + @@ -14069,8 +14677,8 @@

Manifest (by level, then assembly)

- - + + @@ -14144,6 +14752,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -14475,8 +15090,8 @@

Manifest (by level, then assembly)

- - + + @@ -14494,6 +15109,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -14928,6 +15550,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -15012,6 +15641,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -15173,6 +15809,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -15383,6 +16026,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -15502,6 +16152,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -15544,6 +16201,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -15565,6 +16229,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -16062,48 +16733,6 @@

Manifest (by level, then assembly)

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -16125,20 +16754,6 @@

Manifest (by level, then assembly)

- - - - - - - - - - - - - - @@ -16258,6 +16873,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -16358,8 +16980,8 @@

Manifest (by level, then assembly)

- - + + @@ -16419,6 +17041,41 @@

Manifest (by level, then assembly)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -16475,6 +17132,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -16713,6 +17377,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -16783,13 +17454,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -17007,20 +17671,6 @@

Manifest (by level, then assembly)

- - - - - - - - - - - - - - @@ -17049,13 +17699,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -17084,20 +17727,6 @@

Manifest (by level, then assembly)

- - - - - - - - - - - - - - @@ -17105,283 +17734,59 @@

Manifest (by level, then assembly)

- - - - - - - - - - - + + + + - + - - + + - + - - + + - + - - + + - + - - + + - + - - + + - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + @@ -17399,6 +17804,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -17492,8 +17904,8 @@

Manifest (by level, then assembly)

- - + + @@ -17710,7 +18122,7 @@

Manifest (by level, then assembly)

- + @@ -17777,6 +18189,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -17982,8 +18401,8 @@

Manifest (by level, then assembly)

- - + + @@ -18078,13 +18497,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -18101,8 +18513,8 @@

Manifest (by level, then assembly)

- - + + @@ -18365,6 +18777,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -18442,13 +18861,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -18491,13 +18903,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -18603,7 +19008,14 @@

Manifest (by level, then assembly)

- + + + + + + + + @@ -18666,6 +19078,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -18696,8 +19115,8 @@

Manifest (by level, then assembly)

- - + + @@ -18708,1837 +19127,1536 @@

Manifest (by level, then assembly)

- - - - - - - - - + + - + - - + + - + - - - - + + + + - - - - + + + + - - - - + + + + - - + + - + - - + + - + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - - - - - - - - + + + + + - - - - + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + + + + - - - - + - - - - - + + + + + - - - - - + + + + + - - - + + + - + - - - + + + - + - - - - - + + + + + - - - - + + + + + + + - - - - + - - - - - + + + + + + + + - - - - + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - + + + - + - - - - - + + + + + - - - - - + + + + + - - - + + + - + - - - - - + + + + + - - - - - - - - - - - - + + + + + - - - - - + + + + + + + + - - - - + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - + + + - + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - + + + - + - - - + + + - + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - + + + - + - - - - - + + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - + - - + + - + - - + + - + - - + + - + - + - + - - + + - + - - + + - + - - + + - + - - - - - - - - + - + - - + + - + - - + + - + - - + + - + - - + + - + - - + + - + - + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - + + - + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - + + - + - - - - + + + + - - + + - + - - - - + + + + - - - - + + + + - - + + - + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - + + - + - - + + - + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - + + - + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - + + - + - - + + - + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - - - + + - - - - + - - - - + + + + - - - - - - + + - - - - + - - - - + + + + - - - - + + + + - - - - + + + + - - + + - + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - + + - - - - - - - - + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -20780,6 +20898,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -20787,6 +20912,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -20794,6 +20926,20 @@

Manifest (by level, then assembly)

+ + + + + + + + + + + + + + @@ -20801,66 +20947,10 @@

Manifest (by level, then assembly)

- + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + @@ -20871,69 +20961,6 @@

Manifest (by level, then assembly)

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -20941,20 +20968,6 @@

Manifest (by level, then assembly)

- - - - - - - - - - - - - - @@ -20969,20 +20982,6 @@

Manifest (by level, then assembly)

- - - - - - - - - - - - - - @@ -20990,20 +20989,6 @@

Manifest (by level, then assembly)

- - - - - - - - - - - - - - @@ -21011,276 +20996,122 @@

Manifest (by level, then assembly)

- - - - + + + + - - + + - + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - + - + - - + + - - - - + + + + - - - - + + + + - - - - + + + + @@ -21326,6 +21157,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -21340,6 +21178,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -21363,8 +21208,8 @@

Manifest (by level, then assembly)

- - + + @@ -21382,6 +21227,20 @@

Manifest (by level, then assembly)

+ + + + + + + + + + + + + + @@ -21413,7 +21272,7 @@

Manifest (by level, then assembly)

- + @@ -21445,10 +21304,24 @@

Manifest (by level, then assembly)

+ + + + + + + - - + + + + + + + + + @@ -21475,8 +21348,22 @@

Manifest (by level, then assembly)

- - + + + + + + + + + + + + + + + + @@ -21487,6 +21374,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -21501,6 +21395,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -21522,6 +21423,20 @@

Manifest (by level, then assembly)

+ + + + + + + + + + + + + + @@ -21550,10 +21465,10 @@

Manifest (by level, then assembly)

- + - - + + @@ -21564,13 +21479,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -21578,27 +21486,13 @@

Manifest (by level, then assembly)

- - - - - - - - + - - - - - - - @@ -21606,13 +21500,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -21620,27 +21507,6 @@

Manifest (by level, then assembly)

- - - - - - - - - - - - - - - - - - - - - @@ -21655,20 +21521,6 @@

Manifest (by level, then assembly)

- - - - - - - - - - - - - - @@ -21697,48 +21549,6 @@

Manifest (by level, then assembly)

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -21746,55 +21556,6 @@

Manifest (by level, then assembly)

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -21844,48 +21605,6 @@

Manifest (by level, then assembly)

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -21893,13 +21612,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -21916,8 +21628,15 @@

Manifest (by level, then assembly)

- - + + + + + + + + + @@ -21993,8 +21712,8 @@

Manifest (by level, then assembly)

- - + + @@ -22005,6 +21724,48 @@

Manifest (by level, then assembly)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -22026,6 +21787,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -22127,7 +21895,7 @@

Manifest (by level, then assembly)

- + @@ -22173,6 +21941,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -22187,94 +21962,17 @@

Manifest (by level, then assembly)

- + - - + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + @@ -22285,55 +21983,6 @@

Manifest (by level, then assembly)

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -22404,55 +22053,6 @@

Manifest (by level, then assembly)

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -22460,20 +22060,6 @@

Manifest (by level, then assembly)

- - - - - - - - - - - - - - @@ -22495,13 +22081,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -22509,13 +22088,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -22544,10 +22116,10 @@

Manifest (by level, then assembly)

- - - - + + + + @@ -22565,6 +22137,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -22574,15 +22153,15 @@

Manifest (by level, then assembly)

- - + + - + - - + + @@ -22593,13 +22172,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -22630,8 +22202,8 @@

Manifest (by level, then assembly)

- - + + @@ -22719,6 +22291,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -22747,6 +22326,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -22754,24 +22340,38 @@

Manifest (by level, then assembly)

- - + + + + + + - + + + + - - - - + + + + - - - - + + + + + + + + + + + @@ -22789,76 +22389,6 @@

Manifest (by level, then assembly)

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -22866,17 +22396,10 @@

Manifest (by level, then assembly)

- - - - - - - - + - - + + @@ -22894,27 +22417,6 @@

Manifest (by level, then assembly)

- - - - - - - - - - - - - - - - - - - - - @@ -22922,13 +22424,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -22936,6 +22431,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -22945,8 +22447,8 @@

Manifest (by level, then assembly)

- - + + @@ -23001,8 +22503,15 @@

Manifest (by level, then assembly)

- - + + + + + + + + + @@ -23013,6 +22522,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -23048,13 +22564,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -23062,17 +22571,17 @@

Manifest (by level, then assembly)

- + - - + + - + - - + + @@ -23083,13 +22592,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -23097,248 +22599,87 @@

Manifest (by level, then assembly)

- + - - + + - + - - + + - + - - + + - + - - + + - + - - + + - + - - + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -23366,7 +22707,7 @@

Manifest (by level, then assembly)

- + @@ -23398,48 +22739,6 @@

Manifest (by level, then assembly)

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -23454,13 +22753,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -23475,13 +22767,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -23524,20 +22809,6 @@

Manifest (by level, then assembly)

- - - - - - - - - - - - - - @@ -23552,13 +22823,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -23566,10 +22830,10 @@

Manifest (by level, then assembly)

- + - - + + @@ -23580,13 +22844,6 @@

Manifest (by level, then assembly)

- - - - - - - @@ -23594,521 +22851,2593 @@

Manifest (by level, then assembly)

- - - - + + + + - - - - + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - + + - - + + - - + + - - + + - - + + - + - - + + - - + + - - + + - + - - + + - - + + - - + + - - + + - - + + + + + + + + + - + - + + + + + + + + + + + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - - - - + + + + + + + + - - - - + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - + + + + + + + + + + - + - - - - - + + + + + - - - - - + + + + + - - - - + + + + - - - + + + - + - - - - - + + + + + - - + + - - + + - + - - - - - + + + + + - - - + + + + + + + + + + + + + + + + + - + - - - - - + + + + + - - - - - + + + + + + + + - - - - + - - - - + + + + - - - - - + + + + + - - - + + + + + + + + + + + + + + + + + - + - - - - - + + + + + - - - + + + - + + + + - - - - + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + + + + + + + + + + + - - - - + - - - - - + + + + + + + + + + + + + + + + + + + - - - - - + + + + + + + + - - - - + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + @@ -24133,6 +25462,27 @@

Manifest (by level, then assembly)

+ + + + + + + + + + + + + + + + + + + + + @@ -24153,6 +25503,41 @@

Manifest (by level, then assembly)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -24210,6 +25595,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -24258,6 +25650,34 @@

Manifest (by level, then assembly)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -24553,6 +25973,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -24658,6 +26085,13 @@

Manifest (by level, then assembly)

+ + + + + + + @@ -24678,6 +26112,13 @@

Manifest (by level, then assembly)

+ + + + + + + diff --git a/docs/onboarding/00-group-taxonomy.html b/docs/onboarding/00-group-taxonomy.html index cf1926d..56c7ad7 100644 --- a/docs/onboarding/00-group-taxonomy.html +++ b/docs/onboarding/00-group-taxonomy.html @@ -6,21 +6,21 @@ Phase 1b - Functional Group Taxonomy · MMCA · Ivan Ball-llovera - + - + - + @@ -144,7 +144,7 @@

Onboarding guide

Phase 1b - Functional Group Taxonomy

-

This is the primary axis of the guide. Every one of the 3,465 distinct first-party type +

This is the primary axis of the guide. Every one of the 3,668 distinct first-party type nodes from 00-inventory.md is assigned to exactly one functional group - its primary home: the capability or cross-cutting concern it most exists to serve. A type used across many groups (e.g. Result<T>, the entity base) lives in the one foundational group that @@ -218,7 +218,7 @@

The groups (ordered)

- + @@ -226,13 +226,13 @@

The groups (ordered)

- + - + @@ -246,14 +246,14 @@

The groups (ordered)

- - + + - + @@ -281,8 +281,8 @@

The groups (ordered)

- - + + @@ -295,14 +295,14 @@

The groups (ordered)

- - + + - + @@ -310,41 +310,41 @@

The groups (ordered)

- + - + - - + + - - + + - + - + @@ -358,15 +358,15 @@

The groups (ordered)

- + - - + + @@ -386,12 +386,12 @@

The groups (ordered)

- +
0ActivityEventIdRules<T>MMCA.ADC.Conference.Application0(none)
0ActivitySortOrderRules<T>MMCA.ADC.Conference.Application0(none)
0ActivityTimeRangeRules<T>MMCA.ADC.Conference.Application0(none)
0 AssemblyReference MMCA.ADC.Conference.Application 0
0GetPublicActivityFilterQueryMMCA.ADC.Conference.Application0(none)
0 GetPublicEventSpeakerFilterQuery MMCA.ADC.Conference.Application 0
0GetPublicRoomFilterQueryMMCA.ADC.Conference.Application0(none)
0 GetPublicSessionCategoryItemFilterQuery MMCA.ADC.Conference.Application 0
0InfiniteScrollSentinelMMCA.ADC.Conference.UI0(none)
0 IPublicLinkBuilder MMCA.ADC.Conference.UI 0
0PreConferenceWorkshopInfoMMCA.ADC.Conference.UI0(none)
0 ScorePollSignal MMCA.ADC.Conference.UI 0
0WebAuthenticatorCallbackActivityMMCA.ADC.UI0(none)
0 AllowMissingOwnerAttribute MMCA.Common.API 0
0MiddlewarePipelineStepMMCA.Common.API0(none)
0MiddlewarePipelineStepNamesMMCA.Common.API0(none)
0 NonIdempotentAttribute MMCA.Common.API 0
0ProbeControllerFeatureProviderMMCA.Common.API.Tests0(none)
0 SingleServiceProvider MMCA.Common.API.Tests 0
0StubHostEnvironmentMMCA.Common.API.Tests0(none)
0 StubHttpClientFactory MMCA.Common.API.Tests 0
0PasswordResetSettingsMMCA.Common.Application0(none)
0 PasswordRules<T> MMCA.Common.Application 0
0AbstractAnonymousFixtureControllerBaseMMCA.Common.Architecture.Tests0(none)
0 AbstractFitnessControllerBase MMCA.Common.Architecture.Tests 0
0AnonymousFixtureControllerMMCA.Common.Architecture.Tests0(none)
0 CompliantFixtureService MMCA.Common.Architecture.Tests 0
0UndeclaredFitnessControllerTypeLevelAnonymousFixtureController MMCA.Common.Architecture.Tests 0 (none)
0CspPolicyMMCA.Common.AspireUndeclaredFitnessControllerMMCA.Common.Architecture.Tests 0 (none)
0DataProtectionExtensionsCspPolicy MMCA.Common.Aspire 0 (none)
0DownstreamServiceHealthCheckDataProtectionExtensions MMCA.Common.Aspire 0 (none)
0GatewayCorrelationMiddlewareDownstreamServiceHealthCheck MMCA.Common.Aspire 0 (none)
0OutboxPollFilterProcessorMMCA.Common.Aspire0(none)
0 SecurityHeadersSettings MMCA.Common.Aspire 0
0StubHostEnvironmentMMCA.Common.Aspire.Tests0(none)
0 StubHttpClientFactory MMCA.Common.Aspire.Tests 0
0PasswordResetEntryMMCA.Common.Infrastructure0(none)
0 PeriodicBackgroundService MMCA.Common.Infrastructure 0
0ForgotPasswordRequestMMCA.Common.Shared0(none)
0 HttpResilienceDefaults MMCA.Common.Shared 0
0ResetPasswordRequestMMCA.Common.Shared0(none)
0 RoleNames MMCA.Common.Shared 0
0AnonymousEndpointTestsBaseMMCA.Common.Testing.Architecture0(none)
0 ArchitectureAssert MMCA.Common.Testing.Architecture 0
0ForgotPasswordPageMMCA.Common.Testing.E2E0(none)
0 LoginPage MMCA.Common.Testing.E2E 0
0ResetPasswordPageMMCA.Common.Testing.E2E0(none)
0 UserCredentials MMCA.Common.Testing.E2E 0
0ForgotPasswordModelMMCA.Common.UI0(none)
0 GeoPoint MMCA.Common.UI 0
0ResetPasswordModelMMCA.Common.UI0(none)
0 ReturnUrlProtector MMCA.Common.UI 0
1ActivityUpdateRequestMMCA.ADC.Conference.Application1IConcurrencyAware
1 ConferenceCategoryUpdateRequest MMCA.ADC.Conference.Application 1
1ActivityDTOMMCA.ADC.Conference.Shared2IBaseDTO<TIdentifierType>, IConcurrencyAware
1 CategoryGroupDistribution MMCA.ADC.Conference.Shared 1
1ForgotPasswordCommandMMCA.ADC.Identity.Application2ForgotPasswordRequest, ICommandWithRequest<out TRequest>
1 PiiCaptureLogger MMCA.ADC.Identity.IntegrationTests 1
1CorrelationIdMiddlewareMMCA.Common.API1ICorrelationContext
1 DomainExceptionHandler MMCA.Common.API 1
1OpenApiProbeHostMMCA.Common.API.Tests1ProbeControllerFeatureProvider
1 PlainDTO MMCA.Common.API.Tests 1
1ProblemDetailsProbeControllerMMCA.Common.API.Tests1Route
1 PublicEndpointOutputCachePolicyTests MMCA.Common.API.Tests 1
1TestForgotPasswordCommandMMCA.Common.API.Tests2ForgotPasswordRequest, ICommandWithRequest<out TRequest>
1TestResetPasswordCommandMMCA.Common.API.Tests2ICommandWithRequest<out TRequest>, ResetPasswordRequest
1 UnboundRouteTokenProbeController MMCA.Common.API.Tests 1
1ForgotPasswordRequestValidatorMMCA.Common.Application1ForgotPasswordRequest
1 GetUserPreferencesQuery MMCA.Common.Application 1
1ResetPasswordRequestValidatorMMCA.Common.Application2ResetPasswordRequest, StrongPasswordRules<T>
1 SendPushNotificationCommand MMCA.Common.Application 2
1TestForgotPasswordCommandMMCA.Common.Application.Tests2ForgotPasswordRequest, ICommandWithRequest<out TRequest>
1 TestRequestValidator MMCA.Common.Application.Tests 1
1TestResetPasswordCommandMMCA.Common.Application.Tests2ICommandWithRequest<out TRequest>, ResetPasswordRequest
1 TestStrategy MMCA.Common.Application.Tests 1
1InheritingFixtureControllerMMCA.Common.Architecture.Tests1AbstractAnonymousFixtureControllerBase
1 NavigationContractTests MMCA.Common.Architecture.Tests 1
1GatewayCorrelationExtensionsMMCA.Common.Aspire1GatewayCorrelationMiddleware
1 GatewayHealthCheckExtensions MMCA.Common.Aspire 3
1GatewayCorrelationMiddlewareTestsGatewayCorsExtensionsTests MMCA.Common.Aspire.Tests2GatewayCorrelationMiddleware, RecordingHttpResponseFeature1StubHostEnvironment
1
1OutboxPollFilterProcessorTestsMMCA.Common.Aspire.Tests1OutboxPollFilterProcessor
1 RecordingTask MMCA.Common.Aspire.Tests 1
1OutboxMessageMMCA.Common.Infrastructure1IDomainEvent
1 OutboxSignal MMCA.Common.Infrastructure 1
2AppAssociationEndpointTestsApiParameterDescriptorBackfillProviderTests MMCA.Common.API.Tests2AppAssociationEndpointExtensions, AppAssociationOptions4ApiParameterDescriptorBackfillProvider, OpenApiProbeHost, SegmentVersionedProbeController, UnboundRouteTokenProbeController
2CorrelationIdMiddlewareTestsAppAssociationEndpointTests MMCA.Common.API.Tests 2CorrelationIdMiddleware, ICorrelationContextAppAssociationEndpointExtensions, AppAssociationOptions
2
2PermissionPolicyProviderTestsOpenApiBaselineTests MMCA.Common.API.Tests2PermissionPolicyProvider, PermissionRequirement4OpenApiProbeHost, ProblemDetailsProbeController, SegmentVersionedProbeController, UnboundRouteTokenProbeController
2ProbeControllerFeatureProviderPermissionPolicyProviderTests MMCA.Common.API.Tests 2SegmentVersionedProbeController, UnboundRouteTokenProbeControllerPermissionPolicyProvider, PermissionRequirement
2
2IEventUpcasterMMCA.Common.Application1IIntegrationEvent
2IEventUpcasterRegistryMMCA.Common.Application1IIntegrationEvent
2 IIntegrationEventHandler<in TIntegrationEvent> MMCA.Common.Application 1
2ForgotPasswordRequestValidatorTestsMMCA.Common.Application.Tests2ForgotPasswordRequest, ForgotPasswordRequestValidator
2 LoginRequestValidatorTests MMCA.Common.Application.Tests 2
2ResetPasswordRequestValidatorTestsMMCA.Common.Application.Tests2ResetPasswordRequest, ResetPasswordRequestValidator
2 TestChangePasswordCommand MMCA.Common.Application.Tests 2
2ExtensionsMMCA.Common.Aspire8HealthCheckTags, HttpResilienceDefaults, IWarmupTask, OpenIdConnectMetadataWarmupTask, OutboxPollFilterProcessor, WarmupHostedService, WarmupReadinessGate, WarmupReadinessHealthCheckPasswordHashingFitnessTestsMMCA.Common.Architecture.Tests2ArchitectureAssert, PasswordHasher
2
2PasswordHasherSecurityTestsMMCA.Common.Infrastructure.Tests1PasswordHasher
2 PasswordHasherTests MMCA.Common.Infrastructure.Tests 1
3ActivityChangedMMCA.ADC.Conference.Domain2DomainEntityState, EntityChangedEvent<TIdentifierType>
3 CategoryChanged MMCA.ADC.Conference.Domain 2
3IActivityUIServiceMMCA.ADC.Conference.UI2ActivityDTO, IEntityService<TEntityDTO, TIdentifierType>
3 ICategoryItemUIService MMCA.ADC.Conference.UI 2
3PublicScheduleRoomOptionsMMCA.ADC.Conference.UI2EventDTO, RoomDTO
3 SpeakerLookupService MMCA.ADC.Conference.UI 4
3MainActivityMMCA.ADC.UI1IDeepLinkDispatcher
3 MainPage MMCA.ADC.UI 1
3InsecureJwtMetadataWarningStartupFilterMMCA.Common.API1WebApplicationBuilderExtensions
3 SignalRExtensions MMCA.Common.API 2 3 WebApplicationBuilderExtensions MMCA.Common.API6ApiParameterDescriptorBackfillProvider, JwtSettings, JwtSigningAlgorithm, RateLimitAlgorithm, RateLimitingSettings, RedisFixedWindowRateLimiter
3ApiParameterDescriptorBackfillProviderTestsMMCA.Common.API.Tests2ApiParameterDescriptorBackfillProvider, ProbeControllerFeatureProvider7ApiParameterDescriptorBackfillProvider, InsecureJwtMetadataWarningStartupFilter, JwtSettings, JwtSigningAlgorithm, RateLimitAlgorithm, RateLimitingSettings, RedisFixedWindowRateLimiter
3 3 DomainEventDispatcher MMCA.Common.Application5IDomainEvent, IDomainEventDispatcher, IDomainEventHandler<in TDomainEvent>, IIntegrationEvent, IIntegrationEventHandler<in TIntegrationEvent>6IDomainEvent, IDomainEventDispatcher, IDomainEventHandler<in TDomainEvent>, IEventUpcasterRegistry, IIntegrationEvent, IIntegrationEventHandler<in TIntegrationEvent>
3EventUpcasterRegistryMMCA.Common.Application4IDomainEvent, IEventUpcaster, IEventUpcasterRegistry, IIntegrationEvent
3
3IPasswordResetTokenServiceMMCA.Common.Application1Result
3 IPushDeviceRegistrar MMCA.Common.Application 2
3CustomerRenamedV1MMCA.Common.Application.Tests1BaseIntegrationEvent
3CustomerRenamedV2MMCA.Common.Application.Tests1BaseIntegrationEvent
3CustomerRenamedV3MMCA.Common.Application.Tests1BaseIntegrationEvent
3 FakeConsumerModule MMCA.Common.Application.Tests 3
3RecordingIntegrationHandler<TEvent>MMCA.Common.Application.Tests2IIntegrationEvent, IIntegrationEventHandler<in TIntegrationEvent>
3 RecordingSection MMCA.Common.Application.Tests 2
3RetiredEventMMCA.Common.Application.Tests1BaseIntegrationEvent
3SuccessorEventMMCA.Common.Application.Tests1BaseIntegrationEvent
3 TestEventHandler MMCA.Common.Application.Tests 2
3UnrelatedEventMMCA.Common.Application.Tests1BaseIntegrationEvent
3 UserOwnershipRuleTests MMCA.Common.Application.Tests 4
3EmptyScanTestsMMCA.Common.Architecture.Tests2AnonymousEndpointTestsBase, Result
3 FakeDependentModule MMCA.Common.Architecture.Tests 4
3SecurityHeadersExtensionsMMCA.Common.Aspire4ICspPolicyProvider, SecurityHeadersMiddleware, SecurityHeadersSettings, StaticCspPolicyProviderFixtureBackwardsV1MMCA.Common.Architecture.Tests1BaseIntegrationEvent
3InfrastructureHealthChecksTestsMMCA.Common.Aspire.Tests2Extensions, HealthCheckTagsFixtureBackwardsV2MMCA.Common.Architecture.Tests1BaseIntegrationEvent
3MetricsInstrumentationToggleTestsMMCA.Common.Aspire.TestsFixtureCompliantV1MMCA.Common.Architecture.Tests 1ExtensionsBaseIntegrationEvent
3SecurityHeadersMiddlewareTestsMMCA.Common.Aspire.Tests6CspPolicy, ICspPolicyProvider, SecurityHeadersMiddleware, SecurityHeadersSettings, StubCspProvider, StubWebHostEnvironmentFixtureCompliantV2MMCA.Common.Architecture.Tests1BaseIntegrationEvent
3SelfHttpWarmupTaskBaseTestsMMCA.Common.Aspire.Tests7CapturingLogger, ConfigurableWarmupTask, FakeEnvironment, FakeLifetime, FakeServer, SelfHttpWarmupTaskBase, TestServerHostFixtureCompliantV3MMCA.Common.Architecture.Tests1BaseIntegrationEvent
3TracesSampleRatioTestsMMCA.Common.Aspire.TestsFixtureContestedV1MMCA.Common.Architecture.Tests 1ExtensionsBaseIntegrationEvent
3FixtureContestedV2MMCA.Common.Architecture.Tests1BaseIntegrationEvent
3FixtureContestedV3MMCA.Common.Architecture.Tests1BaseIntegrationEvent
3SecurityHeadersExtensionsMMCA.Common.Aspire4ICspPolicyProvider, SecurityHeadersMiddleware, SecurityHeadersSettings, StaticCspPolicyProvider
3SecurityHeadersMiddlewareTestsMMCA.Common.Aspire.Tests6CspPolicy, ICspPolicyProvider, SecurityHeadersMiddleware, SecurityHeadersSettings, StubCspProvider, StubWebHostEnvironment
3SelfHttpWarmupTaskBaseTestsMMCA.Common.Aspire.Tests7CapturingLogger, ConfigurableWarmupTask, FakeEnvironment, FakeLifetime, FakeServer, SelfHttpWarmupTaskBase, TestServerHost
3
3CapturedStateMMCA.Common.Infrastructure3AggregateCapture, IDomainEvent, OutboxMessage
3 CrossDataSourceDegradeConvention MMCA.Common.Infrastructure 3
3EventUpcasterStartupValidatorMMCA.Common.Infrastructure2IEventUpcasterRegistry, IIntegrationEvent
3 IDataSourceResolver MMCA.Common.Infrastructure 3
3UpcastingIntegrationEventConsumer<TEvent>MMCA.Common.Infrastructure4IEventUpcasterRegistry, IInboxStore, IIntegrationEvent, IIntegrationEventHandler<in TIntegrationEvent>
3 DbSeederTests MMCA.Common.Infrastructure.Tests 1
3OtherIntegrationEventOrderPlacedV2 MMCA.Common.Infrastructure.Tests 1 BaseIntegrationEvent
3OutboxMessageTestsOtherIntegrationEvent MMCA.Common.Infrastructure.Tests3OutboxMessage, TestDomainEvent, TestDomainEventWithData1BaseIntegrationEvent
3
3RetiredOrderPlacedMMCA.Common.Infrastructure.Tests1BaseIntegrationEvent
3RetiredTestIntegrationEventMMCA.Common.Infrastructure.Tests1BaseIntegrationEvent
3 TestDataSourceService MMCA.Common.Infrastructure.Tests 3
3TestIntegrationEventV2MMCA.Common.Infrastructure.Tests1BaseIntegrationEvent
3 TestPhysicalDataSources MMCA.Common.Infrastructure.Tests 3
3ValidatorSampleV1MMCA.Common.Infrastructure.Tests1BaseIntegrationEvent
3ValidatorSampleV2MMCA.Common.Infrastructure.Tests1BaseIntegrationEvent
3ValidatorSampleV3MMCA.Common.Infrastructure.Tests1BaseIntegrationEvent
3 Address MMCA.Common.Shared 3 3 NotificationInboxService MMCA.Common.UI7AuthenticatedServiceBase, INotificationInboxUIService, INotificationScopeProvider, ITokenStorageService, PagedCollectionResult<T>, ServiceExceptionHelper, UserNotificationDTO8AuthenticatedServiceBase, INotificationInboxUIService, INotificationScopeProvider, ITokenRefresher, ITokenStorageService, PagedCollectionResult<T>, ServiceExceptionHelper, UserNotificationDTO
3
4ActivityServiceMMCA.ADC.Conference.UI4ActivityDTO, EntityServiceBase<TEntityDTO, TIdentifierType>, IActivityUIService, ITokenStorageService
4 CategoryItemService MMCA.ADC.Conference.UI 4 4 AttendeeFeedbackTests MMCA.ADC.E2E.Tests13E2ETestBase, E2ETestCollection, EventCreatePage, EventDetailPage, EventFeedbackPage, PlaywrightFixture, PublicEventDetailPage, PublicEventListPage, PublicSessionDetailPage, PublicSessionListPage, QuestionCreatePage, SessionCreatePage, SessionFeedbackPage12E2ETestBase, E2ETestCollection, EventCreatePage, EventDetailPage, EventFeedbackPage, PlaywrightFixture, PublicEventDetailPage, PublicSessionDetailPage, PublicSessionListPage, QuestionCreatePage, SessionCreatePage, SessionFeedbackPage
4 4 OrganizerEventManagementTests MMCA.ADC.E2E.Tests10E2ETestBase, E2ETestCollection, EventCreatePage, EventDetailPage, EventListPage, PlaywrightFixture, PublicEventDetailPage, PublicEventListPage, SessionCreatePage, SessionDetailPage9E2ETestBase, E2ETestCollection, EventCreatePage, EventDetailPage, EventListPage, PlaywrightFixture, PublicEventDetailPage, SessionCreatePage, SessionDetailPage
4 OrganizerFeedbackAnalyticsTests MMCA.ADC.E2E.Tests12E2ETestBase, E2ETestCollection, EventCreatePage, EventDetailPage, EventFeedbackPage, OrganizerEventFeedbackPage, OrganizerSessionFeedbackPage, PlaywrightFixture, PublicEventDetailPage, PublicEventListPage, SessionCreatePage, SessionDetailPage11E2ETestBase, E2ETestCollection, EventCreatePage, EventDetailPage, EventFeedbackPage, OrganizerEventFeedbackPage, OrganizerSessionFeedbackPage, PlaywrightFixture, PublicEventDetailPage, SessionCreatePage, SessionDetailPage
4
4SessionQuestionSubmittedPointsHandlerMMCA.ADC.Engagement.Application6DomainEntityState, IDomainEventHandler<in TDomainEvent>, IPointsAwarder, PointsActivityType, PointsSubjectKeys, SessionQuestionChanged
4 UserSessionBookmarkCacheEvictionHandler MMCA.ADC.Engagement.Application 5
4ThrowingPointsAwarderMMCA.ADC.Engagement.Application.Tests3IPointsAwarder, PointsActivityType, Result
4 DependencyInjection MMCA.ADC.Engagement.Infrastructure 1
4NowNextWidgetProviderMMCA.ADC.UI3MainActivity, NowNextSession, NowNextSnapshot
4 CookieSessionRefresher MMCA.Common.API 8
4ForwardedJwtBearerSecurityTestsMMCA.Common.API.Tests2StubHostEnvironment, WebApplicationBuilderExtensions
4 PlainEntity MMCA.Common.API.Tests 1
4EnvelopeCopyingV1ToV2UpcasterMMCA.Common.Application.Tests3CustomerRenamedV1, CustomerRenamedV2, IEventUpcaster
4 FakeEntity MMCA.Common.Application.Tests 1
4RecordingDomainHandlerForRetiredMMCA.Common.Application.Tests2IDomainEventHandler<in TDomainEvent>, RetiredEvent
4 RelatedA MMCA.Common.Application.Tests 1
4RetiredToSuccessorUpcasterMMCA.Common.Application.Tests3IEventUpcaster, RetiredEvent, SuccessorEvent
4RivalV1ToV3UpcasterMMCA.Common.Application.Tests3CustomerRenamedV1, CustomerRenamedV3, IEventUpcaster
4 SafeDomainEventHandlerTests MMCA.Common.Application.Tests 3
4SelfMappingUpcasterMMCA.Common.Application.Tests2CustomerRenamedV1, IEventUpcaster
4 StringFilterStrategyTests MMCA.Common.Application.Tests 2
4V1ToV2UpcasterMMCA.Common.Application.Tests3CustomerRenamedV1, CustomerRenamedV2, IEventUpcaster
4V2ToV1UpcasterMMCA.Common.Application.Tests3CustomerRenamedV1, CustomerRenamedV2, IEventUpcaster
4V2ToV3UpcasterMMCA.Common.Application.Tests3CustomerRenamedV2, CustomerRenamedV3, IEventUpcaster
4AnonymousEndpointTestsMMCA.Common.Architecture.Tests3AnonymousEndpointTestsBase, ApiControllerBase, UISharedAssemblyReference
4AnonymousEndpointTestsBaseTestsMMCA.Common.Architecture.Tests7AbstractAnonymousFixtureControllerBase, AnonymousFixtureController, ConformantTests, DriftedTests, EmptyScanTests, InheritingFixtureController, StaleAllowListTests
4ConformantTestsMMCA.Common.Architecture.Tests5AbstractAnonymousFixtureControllerBase, AnonymousEndpointTestsBase, AnonymousEndpointTestsBaseTests, AnonymousFixtureController, TypeLevelAnonymousFixtureController
4 DriftedTests MMCA.Common.Architecture.Tests2FakeDependentModule, ModuleConformanceTestsBase<TModule>4AnonymousEndpointTestsBase, AnonymousEndpointTestsBaseTests, FakeDependentModule, ModuleConformanceTestsBase<TModule>
4
4FixtureBackwardsVersionUpcasterMMCA.Common.Architecture.Tests3FixtureBackwardsV1, FixtureBackwardsV2, IEventUpcaster
4FixtureCompliantV1ToV2UpcasterMMCA.Common.Architecture.Tests3FixtureCompliantV1, FixtureCompliantV2, IEventUpcaster
4FixtureCompliantV2ToV3UpcasterMMCA.Common.Architecture.Tests3FixtureCompliantV2, FixtureCompliantV3, IEventUpcaster
4FixtureContestedClaimUpcasterMMCA.Common.Architecture.Tests3FixtureContestedV1, FixtureContestedV2, IEventUpcaster
4FixtureRivalClaimUpcasterMMCA.Common.Architecture.Tests3FixtureContestedV1, FixtureContestedV3, IEventUpcaster
4StaleAllowListTestsMMCA.Common.Architecture.Tests2AnonymousEndpointTestsBase, AnonymousEndpointTestsBaseTests
4 QueryPipelineBenchmarks MMCA.Common.Benchmarks 3 4 IntegrationEventConsumerExtensions MMCA.Common.Infrastructure4FaultIntegrationEventConsumer<TEvent>, IIntegrationEvent, IntegrationEventConsumer<TEvent>, OutputCacheEvictionRequested5FaultIntegrationEventConsumer<TEvent>, IIntegrationEvent, IntegrationEventConsumer<TEvent>, OutputCacheEvictionRequested, UpcastingIntegrationEventConsumer<TEvent>
4
4RecordingOriginalHandlerMMCA.Common.Infrastructure.Tests2IIntegrationEventHandler<in TIntegrationEvent>, TestIntegrationEvent
4RecordingSuccessorHandlerMMCA.Common.Infrastructure.Tests2IIntegrationEventHandler<in TIntegrationEvent>, TestIntegrationEventV2
4 RegistryUnattributed MMCA.Common.Infrastructure.Tests 1
4RetiredToV2UpcasterMMCA.Common.Infrastructure.Tests5IEventUpcaster, OrderPlacedV2, RetiredOrderPlaced, RetiredTestIntegrationEvent, TestIntegrationEventV2
4RivalV1ToV3UpcasterMMCA.Common.Infrastructure.Tests3IEventUpcaster, ValidatorSampleV1, ValidatorSampleV3
4SampleV1ToV2UpcasterMMCA.Common.Infrastructure.Tests3IEventUpcaster, ValidatorSampleV1, ValidatorSampleV2
4 SignalRLiveChannelPublisherTests MMCA.Common.Infrastructure.Tests 2
4PasswordResetTestsBaseMMCA.Common.Testing.E2E6AxeOptions, E2ETestBase, ForgotPasswordPage, LoginPage, PlaywrightFixture, ResetPasswordPage
4 ProfileManagementTestsBase MMCA.Common.Testing.E2E 4
4NotificationBellTestsNotificationBellHost MMCA.Common.UI.Tests4BunitTestBase, INotificationInboxUIService, NotificationBell, NotificationState1NotificationBell
4 4 NotificationInboxServiceTests MMCA.Common.UI.Tests11DomainInvariantViolationException, ITokenStorageService, Mocks, Mocks, NotificationInboxService, PagedCollectionResult<T>, PaginationMetadata, StubHttpClientFactory, StubHttpMessageHandler, StubScopeProvider, UserNotificationDTO12DomainInvariantViolationException, ITokenRefresher, ITokenStorageService, Mocks, Mocks, NotificationInboxService, PagedCollectionResult<T>, PaginationMetadata, StubHttpClientFactory, StubHttpMessageHandler, StubScopeProvider, UserNotificationDTO
4 5 PublicSessionListFilterBar MMCA.ADC.Conference.UI4EventDTO, IScreenshotService, IShareService, Severity5EventDTO, IScreenshotService, IShareService, RoomDTO, Severity
5
5PasswordResetTestsMMCA.ADC.E2E.Tests2PasswordResetTestsBase, PlaywrightFixture
5 UserLoginTests MMCA.ADC.E2E.Tests 2 5 DomainEventDispatcherAdditionalTests MMCA.Common.Application.Tests9DomainEventDispatcher, IDomainEventHandler<in TDomainEvent>, IIntegrationEventHandler<in TIntegrationEvent>, MultiHandlerEvent, MultiHandlerEventHandler1, MultiHandlerEventHandler2, TestDomainEventHandlerForIntegration, TestIntegrationEvent, TestIntegrationEventHandler16DomainEventDispatcher, EventUpcasterRegistry, IDomainEventHandler<in TDomainEvent>, IEventUpcasterRegistry, IIntegrationEventHandler<in TIntegrationEvent>, MultiHandlerEvent, MultiHandlerEventHandler1, MultiHandlerEventHandler2, RecordingDomainHandlerForRetired, RecordingIntegrationHandler<TEvent>, RetiredEvent, RetiredToSuccessorUpcaster, SuccessorEvent, TestDomainEventHandlerForIntegration, TestIntegrationEvent, TestIntegrationEventHandler
5
5EventUpcasterRegistryTestsMMCA.Common.Application.Tests13CustomerRenamedV1, CustomerRenamedV2, CustomerRenamedV3, EnvelopeCopyingV1ToV2Upcaster, EventUpcasterRegistry, IEventUpcaster, IIntegrationEvent, RivalV1ToV3Upcaster, SelfMappingUpcaster, UnrelatedEvent, V1ToV2Upcaster, V2ToV1Upcaster, V2ToV3Upcaster
5 FakeEntityDTOMapper MMCA.Common.Application.Tests 3
5PasswordResetTokenServiceMMCA.Common.Infrastructure7Email, Error, ICacheService, IPasswordResetTokenService, PasswordResetEntry, PasswordResetSettings, Result
5 PhoneNumberValueConverter MMCA.Common.Infrastructure 1
5EventUpcasterStartupValidatorTestsMMCA.Common.Infrastructure.Tests8EventUpcasterRegistry, EventUpcasterStartupValidator, IEventUpcaster, IEventUpcasterRegistry, RivalV1ToV3Upcaster, SampleV1ToV2Upcaster, ValidatorSampleV1, ValidatorSampleV2
5 ExclusionAggregate MMCA.Common.Infrastructure.Tests 1
5UpcastingIntegrationEventConsumerTestsMMCA.Common.Infrastructure.Tests8EventUpcasterRegistry, IEventUpcaster, IInboxStore, IIntegrationEventHandler<in TIntegrationEvent>, OrderPlacedV2, RetiredOrderPlaced, RetiredToV2Upcaster, UpcastingIntegrationEventConsumer<TEvent>
5 WarningCountingLogger MMCA.Common.Infrastructure.Tests 1
5ServiceContractPurityTestsBaseMMCA.Common.Testing.Architecture2ArchitectureRules, IArchitectureMap
5 SharedLayerTestsBase MMCA.Common.Testing.Architecture 2
5NotificationBellTestsMMCA.Common.UI.Tests5BunitTestBase, INotificationInboxUIService, NotificationBell, NotificationBellHost, NotificationState
5 NotificationListenerTests MMCA.Common.UI.Tests 7
6AnonymousEndpointTestsMMCA.ADC.Architecture.Tests4AnonymousEndpointTestsBase, ConferenceModule, EngagementModule, IdentityModule
6 ProtoContractTests MMCA.ADC.Architecture.Tests 1
6ActivityInvariantsMMCA.ADC.Conference.Domain3CommonInvariants, Error, Result
6 Category MMCA.ADC.Conference.Domain 7
6ApplicationDbContextMMCA.Common.Infrastructure26AuditableBaseEntity<TIdentifierType>, AuditSaveChangesInterceptor, AuditTrailEntry, AuditTrailSaveChangesInterceptor, AuditTrailSettings, CrossDataSourceDegradeConvention, DataSource, DataSourceKey, DataSourceModelCacheKeyFactory, DetectChangesScope, DomainEventSaveChangesInterceptor, IAuditableEntity, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, IEntityTypeConfigurationCosmos<TEntity, TIdentifierType>, IEntityTypeConfigurationSqlite<TEntity, TIdentifierType>, IEntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, InboxMessage, ITenantEntity, OutboxMessage …(+6)
6AuditSaveChangesInterceptorMMCA.Common.Infrastructure2ApplicationDbContext, IAuditableEntity
6AuditTrailSaveChangesInterceptorMMCA.Common.Infrastructure10ApplicationDbContext, AuditTrailEntry, CaptureContext, IAuditedEntity, InboxMessage, OutboxMessage, PendingEntityKey, PiiAttribute, PiiRedactor, ScheduledJobEntry
6DataSourceModelCacheKeyFactoryMMCA.Common.Infrastructure1ApplicationDbContext
6DeferredDispatchMMCA.Common.Infrastructure2CapturedState, DomainEventSaveChangesInterceptor
6DomainEventSaveChangesInterceptorMMCA.Common.Infrastructure11AggregateCapture, ApplicationDbContext, CapturedState, DeferredDispatch, IAggregateRoot, IDomainEvent, IDomainEventDispatcher, IIntegrationEvent, IOutboxSignal, OutboxFinalizer, OutboxMessage
6 EFReadRepository<TEntity, TIdentifierType> MMCA.Common.Infrastructure 12
6OutboxFinalizerMMCA.Common.Infrastructure2ApplicationDbContext, OutboxMessage
6TenantSaveChangesInterceptorMMCA.Common.Infrastructure3ApplicationDbContext, CrossTenantWriteException, ITenantEntity
6 AllSpecification MMCA.Common.Infrastructure.Tests 2
6PasswordResetTokenServiceTestsMMCA.Common.Infrastructure.Tests6ErrorType, FakeCacheService, PasswordResetEntry, PasswordResetSettings, PasswordResetTokenService, Result
6 PhoneNumberValueConverterTests MMCA.Common.Infrastructure.Tests 3 6 AuthUIService MMCA.Common.UI10AuthenticationResponse, ChangePasswordRequest, IAuthUIService, IPushRegistrationService, ITokenRefresher, ITokenStorageService, JwtAuthenticationStateProvider, LoginRequest, OAuthCodeExchangeRequest, RegisterRequest12AuthenticationResponse, ChangePasswordRequest, ForgotPasswordRequest, IAuthUIService, IPushRegistrationService, ITokenRefresher, ITokenStorageService, JwtAuthenticationStateProvider, LoginRequest, OAuthCodeExchangeRequest, RegisterRequest, ResetPasswordRequest
6
7ActivityDescriptionRules<T>MMCA.ADC.Conference.Application2ActivityInvariants, OptionalStringRules<T>
7ActivityNameRules<T>MMCA.ADC.Conference.Application2ActivityInvariants, RequiredStringRules<T>
7ActivityVenueAddressRules<T>MMCA.ADC.Conference.Application2ActivityInvariants, OptionalStringRules<T>
7ActivityVenueNameRules<T>MMCA.ADC.Conference.Application2ActivityInvariants, OptionalStringRules<T>
7ActivityVenueUrlRules<T>MMCA.ADC.Conference.Application2ActivityInvariants, OptionalStringRules<T>
7 AddCategoryItemCommand MMCA.ADC.Conference.Application 2
7EventTicketingUrlRules<T>MMCA.ADC.Conference.Application2EventInvariants, OptionalStringRules<T>
7 EventTimeZoneRules<T> MMCA.ADC.Conference.Application 1
7ActivityInvariantsTestsMMCA.ADC.Conference.Domain.Tests1ActivityInvariants
7 CategoryInvariantsTests MMCA.ADC.Conference.Domain.Tests 2
7PublicEventListMMCA.ADC.Conference.UI7ConferenceRoutePaths, DataGridListPageBase<TDto>, EventDTO, EventService, IEventUIService, ListPageActions, MobileInfiniteScrollList<TItem>
7 PublicSessionListView MMCA.ADC.Conference.UI 9
7CommonArchitectureMapMMCA.Common.Architecture.Tests10ApiControllerBase, ApplicationDbContext, ArchitectureMapBase, BaseEntity<TIdentifierType>, DomainEventDispatcher, Layer, LayerRef, Result, ResultGrpcExtensions, UISharedAssemblyReference
7FrameworkSanityTestsMMCA.Common.Architecture.Tests7ApplicationDbContext, ArchitectureAssert, DomainEventDispatcher, IJwksProvider, ILiveChannelPublisher, IMessageBus, ResultGrpcExtensions
7 SpecificationFitnessTests MMCA.Common.Architecture.Tests 6
7CosmosDbContextMMCA.Common.Infrastructure5ApplicationDbContext, DataSource, IEntityConfigurationAssemblyProvider, OutboxMessage, PhysicalDataSource
7 EFRepositoryDecorator<TEntity, TIdentifierType> MMCA.Common.Infrastructure 6
7IDbContextFactoryMMCA.Common.Infrastructure3ApplicationDbContext, DataSource, DataSourceKey
7IPhysicalDbContextFactoryMMCA.Common.Infrastructure3ApplicationDbContext, DataSourceKey, PhysicalDataSource
7 IRepositoryFactory MMCA.Common.Infrastructure 4
7SqliteDbContextMMCA.Common.Infrastructure4ApplicationDbContext, DataSource, IEntityConfigurationAssemblyProvider, PhysicalDataSource
7SQLServerDbContextMMCA.Common.Infrastructure5ApplicationDbContext, DataSource, IEntityConfigurationAssemblyProvider, PersistenceSettings, PhysicalDataSourceEFReadRepositoryDecoratorAdditionalTestsMMCA.Common.Infrastructure.Tests3EFReadRepositoryDecorator<TEntity, TIdentifierType>, FakeEntity, IReadRepository<TEntity, TIdentifierType>
7AddMultiTenancyTestsEFReadRepositoryDecoratorTests MMCA.Common.Infrastructure.Tests11ConnectionStringSettings, DataSourceResolver, DataSourcesSettings, ITenantContext, TenancySettings, TenancySettingsValidator, TenantContext, TenantDataSourceOverrideSettings, TenantEntrySettings, TenantResolutionStrategy, TenantSaveChangesInterceptor4BaseLookup<TIdentifierType>, EFReadRepositoryDecorator<TEntity, TIdentifierType>, FakeEntity, IReadRepository<TEntity, TIdentifierType>
7CleanupTestContextOwnsMoneyTests MMCA.Common.Infrastructure.Tests11ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, InboxMessage, IOutboxSignal, NullAssemblyProvider, OutboxMessage, TestPhysicalDataSources6Currency, HandRolledOwner, HelperOwner, Money, MoneyTestDbContext, PropertyFacets
7CommitFailingDbContextPortableThingConfiguration MMCA.Common.Infrastructure.Tests12ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, FailingDatabaseFacade, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, OutboxMessage, TestAggregate, TestPhysicalDataSources3DataSource, EntityTypeConfiguration<TEntity, TIdentifierType>, PortableThing
7DegradeTestContextTestConfigDbContext MMCA.Common.Infrastructure.Tests8ApplicationDbContext, DataSourceKey, DegradeCustomer, DegradeOrder, EmptyAssemblyProvider, EmptyAssemblyProvider, IEntityDataSourceRegistry, PhysicalDataSource2TestAggregateEntity, TestAggregateEntityConfiguration
7DetectionTestDbContextTestNonAggregateConfigDbContext MMCA.Common.Infrastructure.Tests10ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, TestPhysicalDataSources, Widget2TestNonAggregateEntity, TestNonAggregateEntityConfiguration
7EFReadRepositoryDecoratorAdditionalTestsMMCA.Common.Infrastructure.Tests3EFReadRepositoryDecorator<TEntity, TIdentifierType>, FakeEntity, IReadRepository<TEntity, TIdentifierType>DependencyInjectionMMCA.Common.UI27ApiSettings, ApiUserPreferenceReader, ApiUserPreferenceWriter, AuthDelegatingHandler, AuthUIService, CultureDelegatingHandler, DefaultOAuthUISettings, EndpointCultureApplier, HttpResilienceDefaults, IAuthUIService, ICultureApplier, IEntityService<TEntityDTO, TIdentifierType>, IFormFactor, IOAuthUISettings, ISessionCookieSync, IUIModule, IUserPreferenceReader, IUserPreferenceWriter, JsFetchSessionCookieSync, LayoutSettings …(+7)
7EFReadRepositoryDecoratorTestsMMCA.Common.Infrastructure.Tests4BaseLookup<TIdentifierType>, EFReadRepositoryDecorator<TEntity, TIdentifierType>, FakeEntity, IReadRepository<TEntity, TIdentifierType>
7ExclusionTestDbContextMMCA.Common.Infrastructure.Tests8ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, ExclusionAggregate, IEntityDataSourceRegistry, NullAssemblyProvider, TestPhysicalDataSources
7FailingDatabaseFacadeMMCA.Common.Infrastructure.Tests2AlwaysRetryExecutionStrategy, CommitFailingDbContext
7FailingSaveInterceptorMMCA.Common.Infrastructure.Tests1OutboxRoutingTestDbContext
7GateTestContextMMCA.Common.Infrastructure.Tests13ApplicationDbContext, AuditSaveChangesInterceptor, DataSource, DataSourceKey, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, GateTestContext, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, PhysicalDataSource, SchedulerSettings
7GateTestContextMMCA.Common.Infrastructure.Tests14ApplicationDbContext, AuditSaveChangesInterceptor, AuditTrailSettings, DataSource, DataSourceKey, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, GateTestContext, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, NullAssemblyProvider, PhysicalDataSource
7InboxTestDbContextMMCA.Common.Infrastructure.Tests4ApplicationDbContext, IEntityConfigurationAssemblyProvider, InboxMessage, TestPhysicalDataSources
7IntegrityTestDbContextMMCA.Common.Infrastructure.Tests11ApplicationDbContext, AuditSaveChangesInterceptor, DataSourceKey, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IntegrityAggregate, IOutboxSignal, NullAssemblyProvider, PhysicalDataSource
7MidSaveContextCreatingDbContextMMCA.Common.Infrastructure.Tests10ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, IOutboxSignal, ReentrantSaveInterceptor, TestPhysicalDataSources
7NamedSoftDeleteTestDbContextMMCA.Common.Infrastructure.Tests2ApplicationDbContext, ProjectedTestEntity
7OutboxRoutingTestDbContextMMCA.Common.Infrastructure.Tests10ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, FailingSaveInterceptor, IEntityDataSourceRegistry, NullAssemblyProvider, OutboxMessage, TestAggregate, TestPhysicalDataSources
7OutboxTestDbContextMMCA.Common.Infrastructure.Tests4ApplicationDbContext, IEntityConfigurationAssemblyProvider, OutboxMessage, TestPhysicalDataSources
7OwnsMoneyTestsMMCA.Common.Infrastructure.Tests6Currency, HandRolledOwner, HelperOwner, Money, MoneyTestDbContext, PropertyFacets
7PortableThingConfigurationMMCA.Common.Infrastructure.Tests3DataSource, EntityTypeConfiguration<TEntity, TIdentifierType>, PortableThing
7QueryShapeTestDbContextMMCA.Common.Infrastructure.Tests10ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, Product, TestPhysicalDataSources
7ReentrantSaveInterceptorMMCA.Common.Infrastructure.Tests1MidSaveContextCreatingDbContext
7SchedulerTestContextMMCA.Common.Infrastructure.Tests10ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, ScheduledJobEntry, TestPhysicalDataSources
7SoftDeleteTestDbContextMMCA.Common.Infrastructure.Tests11ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, SoftDeletableEntity, SoftDeletableTestEntity, TestPhysicalDataSources
7SpecificationTestDbContextMMCA.Common.Infrastructure.Tests3ApplicationDbContext, SpecTestChild, SpecTestEntity
7StampTestDbContextMMCA.Common.Infrastructure.Tests10ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, StampedEntity, TestPhysicalDataSources
7TenantTestContextMMCA.Common.Infrastructure.Tests16ApplicationDbContext, AuditSaveChangesInterceptor, AuditTrailEntry, AuditTrailSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, NullAssemblyProvider, PlainThing, TenantSaveChangesInterceptor, TenantThing, TestPhysicalDataSources, TrailedTenantThing
7TestApplicationDbContextMMCA.Common.Infrastructure.Tests10ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, IOutboxSignal, TestEntity, TestPhysicalDataSources
7TestAuditDbContextMMCA.Common.Infrastructure.Tests10ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, TestAuditEntity, TestPhysicalDataSources
7TestConfigDbContextMMCA.Common.Infrastructure.Tests2TestAggregateEntity, TestAggregateEntityConfiguration
7TestDomainEventDbContextMMCA.Common.Infrastructure.Tests8ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IEntityDataSourceRegistry, NullAssemblyProvider, TestAggregate, TestPhysicalDataSources
7TestNonAggregateConfigDbContextMMCA.Common.Infrastructure.Tests2TestNonAggregateEntity, TestNonAggregateEntityConfiguration
7TestNonOutboxContextMMCA.Common.Infrastructure.Tests9ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, TestPhysicalDataSources
7TestOutboxContextMMCA.Common.Infrastructure.Tests10ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, OutboxMessage, TestPhysicalDataSources
7TransactionTestDbContextMMCA.Common.Infrastructure.Tests11ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, OutboxMessage, TestAggregate, TestPhysicalDataSources
7UniqueIndexTestDbContextMMCA.Common.Infrastructure.Tests12ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, FilteredIndexEntity, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, NullAssemblyProvider, TestPhysicalDataSources, UniqueNamedEntity
7DependencyInjectionMMCA.Common.UI27ApiSettings, ApiUserPreferenceReader, ApiUserPreferenceWriter, AuthDelegatingHandler, AuthUIService, CultureDelegatingHandler, DefaultOAuthUISettings, EndpointCultureApplier, HttpResilienceDefaults, IAuthUIService, ICultureApplier, IEntityService<TEntityDTO, TIdentifierType>, IFormFactor, IOAuthUISettings, ISessionCookieSync, IUIModule, IUserPreferenceReader, IUserPreferenceWriter, JsFetchSessionCookieSync, LayoutSettings …(+7)
7DataGridListPageBaseTestsMMCA.Common.UI.Tests6BunitTestBase, ListPageQueryStateService, ListPageStateService, Severity, TestGridPage, WidgetRowDataGridListPageBaseTestsMMCA.Common.UI.Tests6BunitTestBase, ListPageQueryStateService, ListPageStateService, Severity, TestGridPage, WidgetRow
8
8ActivityUpdateRequestValidatorMMCA.ADC.Conference.Application8ActivityDescriptionRules<T>, ActivityNameRules<T>, ActivitySortOrderRules<T>, ActivityTimeRangeRules<T>, ActivityUpdateRequest, ActivityVenueAddressRules<T>, ActivityVenueNameRules<T>, ActivityVenueUrlRules<T>
8 AddCategoryItemCommandValidator MMCA.ADC.Conference.Application 3 8 EventUpdateRequestValidator MMCA.ADC.Conference.Application6EventDateRangeRules<T>, EventNameRules<T>, EventOrganizerContactEmailRules<T>, EventSponsorshipPacketUrlRules<T>, EventTimeZoneRules<T>, EventUpdateRequest7EventDateRangeRules<T>, EventNameRules<T>, EventOrganizerContactEmailRules<T>, EventSponsorshipPacketUrlRules<T>, EventTicketingUrlRules<T>, EventTimeZoneRules<T>, EventUpdateRequest
8 UserRegisteredHandler MMCA.ADC.Conference.Application 8Email, IEventBus, IIntegrationEventHandler<in TIntegrationEvent>, IRepository<TEntity, TIdentifierType>, IUnitOfWork, Speaker, SpeakerLinkedToUser, UserRegisteredEmail, IEntityQuerier<TEntity, TIdentifierType>, IEventBus, IIntegrationEventHandler<in TIntegrationEvent>, IUnitOfWork, Speaker, SpeakerLinkedToUser, UserRegistered
8
8ActivityMMCA.ADC.Conference.Domain6ActivityChanged, ActivityInvariants, AuditableAggregateRootEntity<TIdentifierType>, DomainEntityState, Event, Result
8 Session MMCA.ADC.Conference.Domain 15 8 PublicEventDetail MMCA.ADC.Conference.UI10ConferenceRoutePaths, Event, EventDTO, EventService, IClipboardService, IEventUIService, IGeocodingService, IGeolocationService, IMapNavigationService, Severity11ConferenceReadAudience, ConferenceRoutePaths, Event, EventDTO, EventService, IClipboardService, IEventUIService, IGeocodingService, IGeolocationService, IMapNavigationService, Severity
8
8SessionQuestionSubmittedPointsHandlerMMCA.ADC.Engagement.Application8DomainEntityState, IDomainEventHandler<in TDomainEvent>, IPointsAwarder, IUnitOfWork, PointsActivityType, PointsSubjectKeys, SessionQuestion, SessionQuestionChanged
8 SessionQuestionUpvoteChangedHandler MMCA.ADC.Engagement.Application 11 8 ToggleUpvoteHandler MMCA.ADC.Engagement.Application8Error, ICommandHandler<in TCommand, TResult>, IRepository<TEntity, TIdentifierType>, IUnitOfWork, Result, SessionQuestion, SessionQuestionUpvote, ToggleUpvoteCommand9Error, ICommandHandler<in TCommand, TResult>, IEntityReader<TEntity, TIdentifierType>, IRepository<TEntity, TIdentifierType>, IUnitOfWork, Result, SessionQuestion, SessionQuestionUpvote, ToggleUpvoteCommand
8
8ResetPasswordCommandMMCA.ADC.Identity.Application4ICacheInvalidating, ICommandWithRequest<out TRequest>, ResetPasswordRequest, User
8 SetUserAvatarHandler MMCA.ADC.Identity.Application 10
8ModuleApplicationDbContextMMCA.ADC.Identity.Infrastructure4ApplicationDbContext, IEntityConfigurationAssemblyProvider, PhysicalDataSource, User
8 UserConfiguration MMCA.ADC.Identity.Infrastructure 4
8DatabaseInitializationExtensionsMMCA.Common.API11ApplicationSettings, DataSource, DataSourceKey, IDataSourceResolver, IDbContextFactory, IEntityDataSourceRegistry, ITenantContext, ModuleLoader, TenancySettings, TenantDataSourceTarget, TenantDataSourceTargets
8 EntityControllerBaseETagTests MMCA.Common.API.Tests 12
8GetMyNotificationsHandlerForgotPasswordHandlerBase<TUser, TCommand>MMCA.Common.Application11AuditableAggregateRootEntity<TIdentifierType>, Email, ForgotPasswordRequest, ICommandHandler<in TCommand, TResult>, ICommandWithRequest<out TRequest>, IEmailSender, IPasswordResetTokenService, IUnitOfWork, PasswordResetSettings, Result, UserUseCaseLog
8GetMyNotificationsHandler MMCA.Common.Application 11 GetMyNotificationsQuery, IQueryableExecutor, IQueryHandler<in TQuery, TResult>, IUnitOfWork, PagedCollectionResult<T>, PaginationMetadata, PagingMath, PushNotification, Result, UserNotification, UserNotificationDTO
8ResetPasswordHandlerBase<TUser, TCommand>MMCA.Common.Application12AuditableAggregateRootEntity<TIdentifierType>, Error, ICommandHandler<in TCommand, TResult>, ICommandWithRequest<out TRequest>, ILoginProtectionService, IPasswordChangeableUser, IPasswordHasher, IPasswordResetTokenService, IUnitOfWork, ResetPasswordRequest, Result, UserUseCaseLog
8 SendPushNotificationRequestValidator MMCA.Common.Application 3 8 HandlerMocks MMCA.Common.Application.Tests6IPasswordHasher, IReadRepository<TEntity, TIdentifierType>, IRepository<TEntity, TIdentifierType>, IUnitOfWork, TestHidingDeleteUser, TestIdentityUser9IEmailSender, ILoginProtectionService, IPasswordHasher, IPasswordResetTokenService, IReadRepository<TEntity, TIdentifierType>, IRepository<TEntity, TIdentifierType>, IUnitOfWork, TestHidingDeleteUser, TestIdentityUser
8
8AggregateConventionTestsMMCA.Common.Architecture.Tests3AggregateConventionTestsBase, CommonArchitectureMap, IArchitectureMap
8CancellationTokenConventionTestsMMCA.Common.Architecture.TestsPushNotificationTestsMMCA.Common.Domain.Tests 3CancellationTokenConventionTestsBase, CommonArchitectureMap, IArchitectureMapPushNotification, PushNotificationCreated, PushNotificationStatus
8DomainPurityTestsMMCA.Common.Architecture.TestsPushNotificationConfigurationMMCA.Common.Infrastructure 3CommonArchitectureMap, DomainPurityTestsBase, IArchitectureMapEntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, PushNotification, PushNotificationInvariants
8EventScopeFitnessTestsMMCA.Common.Architecture.Tests3ArchitectureRules, CommonArchitectureMap, FakeConsumerMapUserNotificationConfigurationMMCA.Common.Infrastructure2EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, UserNotification
8EventVersioningConventionTestsMMCA.Common.Architecture.Tests3CommonArchitectureMap, EventConventionTestsBase, IArchitectureMapDesignAlphaEntityConfigurationMMCA.Common.Infrastructure.Tests2DesignAlphaEntity, EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>
8FakeConsumerMapMMCA.Common.Architecture.Tests5ArchitectureMapBase, BaseIntegrationEvent, EventScopeFitnessTests, Layer, LayerRefDesignBetaEntityConfigurationMMCA.Common.Infrastructure.Tests2DesignBetaEntity, EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>
8HandlerResultConventionTestsMMCA.Common.Architecture.TestsEFRepositoryDecoratorAdditionalTestsMMCA.Common.Infrastructure.Tests 3CommonArchitectureMap, HandlerResultConventionTestsBase, IArchitectureMapEFRepositoryDecorator<TEntity, TIdentifierType>, FakeAggregateEntity, IRepository<TEntity, TIdentifierType>
8IdempotencyConventionTestsMMCA.Common.Architecture.TestsEFRepositoryDecoratorTestsMMCA.Common.Infrastructure.Tests 3CommonArchitectureMap, IArchitectureMap, IdempotencyConventionTestsBaseEFRepositoryDecorator<TEntity, TIdentifierType>, FakeAggregateEntity, IRepository<TEntity, TIdentifierType>
8LayerDependencyTestsMMCA.Common.Architecture.Tests3CommonArchitectureMap, IArchitectureMap, LayerDependencyTestsBaseEntityTypeConfigurationBaseTestsMMCA.Common.Infrastructure.Tests6TestAggregateEntity, TestAggregateEntityConfiguration, TestConfigDbContext, TestNonAggregateConfigDbContext, TestNonAggregateEntity, TestNonAggregateEntityConfiguration
8LocalizedTextConventionTestsMMCA.Common.Architecture.Tests3CommonArchitectureMap, IArchitectureMap, LocalizedTextConventionTestsBaseMultiSourceCustomerConfigurationMMCA.Common.Infrastructure.Tests2EntityTypeConfigurationSqlite<TEntity, TIdentifierType>, MultiSourceCustomer
8MicroserviceExtractionTestsMMCA.Common.Architecture.Tests3CommonArchitectureMap, IArchitectureMap, MicroserviceExtractionTestsBaseMultiSourceOrderConfigurationMMCA.Common.Infrastructure.Tests2EntityTypeConfigurationSqlite<TEntity, TIdentifierType>, MultiSourceOrder
8NamespaceCycleTestsMMCA.Common.Architecture.Tests3CommonArchitectureMap, IArchitectureMap, NamespaceCycleTestsBaseNotificationTestDbContextMMCA.Common.Infrastructure.Tests2PushNotification, UserNotification
8PiiConventionTestsMMCA.Common.Architecture.Tests3CommonArchitectureMap, IArchitectureMap, PiiConventionTestsBasePortablePrincipalConfigurationMMCA.Common.Infrastructure.Tests2EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, PortablePrincipal
8RawQueryableConventionTestsMMCA.Common.Architecture.Tests4ArchitectureMapBase, CommonArchitectureMap, IArchitectureMap, RawQueryableConventionTestsBaseProjectionTestDbContextMMCA.Common.Infrastructure.Tests1PushNotification
8SliceCohesionTestsMMCA.Common.Architecture.Tests3CommonArchitectureMap, IArchitectureMap, SliceCohesionTestsBaseRegistryDuplicateConfigurationAMMCA.Common.Infrastructure.Tests2EntityTypeConfigurationSqlite<TEntity, TIdentifierType>, RegistryDuplicate
8StateManagementConventionTestsMMCA.Common.Architecture.Tests3CommonArchitectureMap, IArchitectureMap, StateManagementConventionTestsBaseRegistryDuplicateConfigurationBMMCA.Common.Infrastructure.Tests2EntityTypeConfigurationSqlite<TEntity, TIdentifierType>, RegistryDuplicate
8UIArchitectureConventionTestsMMCA.Common.Architecture.Tests3CommonArchitectureMap, IArchitectureMap, UIArchitectureConventionTestsBaseRegistryInvoiceConfigurationMMCA.Common.Infrastructure.Tests2EntityTypeConfigurationSqlite<TEntity, TIdentifierType>, RegistryInvoice
8PushNotificationTestsMMCA.Common.Domain.Tests3PushNotification, PushNotificationCreated, PushNotificationStatusRegistryOrderConfigurationMMCA.Common.Infrastructure.Tests2EntityTypeConfigurationSqlite<TEntity, TIdentifierType>, RegistryOrder
8ApplicationDbContextEFFactoryMMCA.Common.Infrastructure6ApplicationDbContext, CosmosDbContext, DataSource, IDbContextFactory, SqliteDbContext, SQLServerDbContextRegistrySqlServerEntityConfigurationMMCA.Common.Infrastructure.Tests2EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, RegistrySqlServerEntity
8AuditTrailCleanupJobMMCA.Common.Infrastructure10AuditTrailEntry, AuditTrailSettings, DataSource, IDbContextFactory, IEntityDataSourceRegistry, IScheduledJob, ITenantContext, TenancySettings, TenantDataSourceTarget, TenantDataSourceTargetsSeederMocksMMCA.Common.Infrastructure.Tests4IPasswordHasher, IRepository<TEntity, TIdentifierType>, IUnitOfWork, TestSeedUser
8AuditTrailReaderMMCA.Common.Infrastructure7AuditTrailEntry, AuditTrailEntryDTO, AuditTrailSettings, DataSourceKey, IAuditTrailReader, IDataSourceResolver, IDbContextFactorySqliteTestEntityConfigMMCA.Common.Infrastructure.Tests2EntityTypeConfigurationSqlite<TEntity, TIdentifierType>, SqliteTestEntity
8BrokerEventBusMMCA.Common.Infrastructure7IDataSourceResolver, IDbContextFactory, IEventBus, IIntegrationEvent, IOutboxSignal, OutboxMessage, OutboxSettingsTestConnectionContextMMCA.Common.Infrastructure.Tests2TestDuplexPipe, User
8DefaultCosmosDbContextFactoryMMCA.Common.Infrastructure5CosmosDbContext, DataSource, DataSourceKey, IDbContextFactory, IPhysicalDbContextFactoryGalleryAuthenticationStateProviderMMCA.Common.UI.Gallery1User
8DefaultSqliteDbContextFactoryMMCA.Common.Infrastructure5DataSource, DataSourceKey, IDbContextFactory, IPhysicalDbContextFactory, SqliteDbContext9DecoratorPipelineOrderTestsMMCA.ADC.Architecture.Tests11ChangePreferencesCommand, ClassReference, DecoratorPipelineOrderTestsBase<TCommand, TCommandResult, TQuery, TQueryResult>, GetUserPreferencesQuery, ICacheService, ICorrelationContext, ICurrentUserService, IPermissionRegistry, IUnitOfWork, Result, UserPreferencesResponse
8DefaultSqlServerDbContextFactoryMMCA.Common.Infrastructure5DataSource, DataSourceKey, IDbContextFactory, IPhysicalDbContextFactory, SQLServerDbContext9CurrentUserServiceExtensionsMMCA.ADC.Conference.API2ConferenceReadAudience, ICurrentUserService
8DesignTimeDbContextHelperMMCA.Common.Infrastructure22AuditSaveChangesInterceptor, AuditTrailSaveChangesInterceptor, AuditTrailSettings, DataSource, DataSourceKey, DataSourceResolver, DataSourcesSettings, DesignTimeDbContextOptions, DomainEventSaveChangesInterceptor, EntityDataSourceRegistry, ExplicitAssemblyProvider, IDataSourceResolver, IDomainEventDispatcher, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, IOutboxSignal, NullDomainEventDispatcher, OutboxSignal, SchedulerSettings, SQLServerDbContext …(+2)9EventQuestionAnswersControllerMMCA.ADC.Conference.API20AddEventQuestionAnswerCommand, AddEventQuestionAnswerRequest, AuthorizationPolicies, BaseLookup<TIdentifierType>, CollectionResult<T>, EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>, EventQuestionAnswer, EventQuestionAnswerDTO, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, OwnedByUserSpecification<TEntity, TIdentifierType>, PagedCollectionResult<T>, QueryFilterModelBinder, RemoveEventQuestionAnswerCommand, Result, RoleNames, Route, UpdateEventQuestionAnswerCommand, UpdateEventQuestionAnswerRequest
8EfInboxStoreMMCA.Common.Infrastructure6ApplicationDbContext, IDataSourceResolver, IDbContextFactory, IInboxStore, InboxMessage, OutboxSettings9EventSpeakersControllerMMCA.ADC.Conference.API19AddEventSpeakerCommand, AddEventSpeakerRequest, BaseLookup<TIdentifierType>, CollectionResult<T>, ConferencePermissions, EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>, EventSpeaker, EventSpeakerDTO, GetPublicEventSpeakerFilterQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, IQueryHandler<in TQuery, TResult>, PagedCollectionResult<T>, QueryFilterModelBinder, RemoveEventSpeakerCommand, Result, Route, Specification<TEntity, TIdentifierType>
8InProcessEventBusMMCA.Common.Infrastructure8IDataSourceResolver, IDbContextFactory, IDomainEventDispatcher, IEventBus, IIntegrationEvent, OutboxFinalizer, OutboxMessage, OutboxSettings9QuestionsControllerMMCA.ADC.Conference.API16AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest>, BaseLookup<TIdentifierType>, CollectionResult<T>, ConferencePermissions, DeleteEntityCommand<TEntity, TIdentifierType>, ICommandHandler<in TCommand, TResult>, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, PagedCollectionResult<T>, QueryFilterModelBinder, Question, QuestionCreateRequest, QuestionDTO, QuestionUpdateRequest, Result, Route, UpdateQuestionCommand
8OutboxCleanupServiceMMCA.Common.Infrastructure13DataSource, DataSourceKey, IDataSourceResolver, IDbContextFactory, IEntityDataSourceRegistry, InboxMessage, ITenantContext, MessageBusSettings, OutboxMessage, OutboxSettings, TenancySettings, TenantDataSourceTarget, TenantDataSourceTargets9RoomsControllerMMCA.ADC.Conference.API21AddRoomCommand, AddRoomRequest, BaseLookup<TIdentifierType>, CollectionResult<T>, ConferencePermissions, EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>, GetPublicRoomFilterQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, IQueryHandler<in TQuery, TResult>, PagedCollectionResult<T>, QueryFilterModelBinder, RemoveRoomCommand, Result, Room, RoomDTO, Route, Specification<TEntity, TIdentifierType>, UpdateRoomCommand …(+1)
8OutboxProcessorMMCA.Common.Infrastructure21ApplicationDbContext, BrokerMetrics, BrokerResilienceDefaults, DataSource, DataSourceKey, Event, IDataSourceResolver, IDbContextFactory, IDomainEventDispatcher, IEntityDataSourceRegistry, IIntegrationEvent, IMessageBus, IOutboxSignal, ITenantContext, OutboxCycleResult, OutboxMessage, OutboxMetrics, OutboxSettings, TenancySettings, TenantDataSourceTarget …(+1)9SpeakerCategoryItemsControllerMMCA.ADC.Conference.API19AddSpeakerCategoryItemCommand, AddSpeakerCategoryItemRequest, BaseLookup<TIdentifierType>, CollectionResult<T>, ConferencePermissions, EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>, GetPublicSpeakerCategoryItemFilterQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, IQueryHandler<in TQuery, TResult>, PagedCollectionResult<T>, QueryFilterModelBinder, RemoveSpeakerCategoryItemCommand, Result, Route, SpeakerCategoryItem, SpeakerCategoryItemDTO, Specification<TEntity, TIdentifierType>
8PhysicalDbContextFactoryMMCA.Common.Infrastructure10ApplicationDbContext, CosmosDbContext, DataSource, DataSourceKey, IDataSourceResolver, IEntityConfigurationAssemblyProvider, IPhysicalDbContextFactory, PhysicalDataSource, SqliteDbContext, SQLServerDbContext
8PushNotificationConfigurationMMCA.Common.Infrastructure3EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, PushNotification, PushNotificationInvariants9SpeakersControllerMMCA.ADC.Conference.API30AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest>, BaseLookup<TIdentifierType>, CollectionResult<T>, ConferencePermissions, DeleteEntityCommand<TEntity, TIdentifierType>, Error, GetPublicSpeakerFilterQuery, GetSessionBookmarkCountQuery, GetSessionBookmarkCountsQuery, GetSessionFeedbackQuery, GetSpeakersByEventFilterQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, IQueryHandler<in TQuery, TResult>, LinkUserRequest, LinkUserToSpeakerCommand, PagedCollectionResult<T>, QueryFilterModelBinder, Result …(+10)
8ScheduledJobRunnerMMCA.Common.Infrastructure 9ApplicationDbContext, DataSourceKey, IDataSourceResolver, IDbContextFactory, IScheduledJob, JobClaim, ScheduledJobEntry, SchedulerMetrics, SchedulerSettingsCategoryItemsControllerTestsMMCA.ADC.Conference.API.Tests12AddCategoryItemCommand, AddCategoryItemRequest, CategoryItem, CategoryItemDTO, CategoryItemsController, Error, ICommandHandler<in TCommand, TResult>, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, RemoveCategoryItemCommand, Result, UpdateCategoryItemCommand, UpdateCategoryItemRequest
8UnitOfWorkMMCA.Common.Infrastructure8AuditableAggregateRootEntity<TIdentifierType>, AuditableBaseEntity<TIdentifierType>, IDataSourceService, IDbContextFactory, IReadRepository<TEntity, TIdentifierType>, IRepository<TEntity, TIdentifierType>, IRepositoryFactory, IUnitOfWork9ConferenceCategoriesControllerTestsMMCA.ADC.Conference.API.Tests11Category, ConferenceCategoriesController, ConferenceCategoryCreateRequest, ConferenceCategoryDTO, ConferenceCategoryUpdateRequest, DeleteEntityCommand<TEntity, TIdentifierType>, Error, ICommandHandler<in TCommand, TResult>, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, Result, UpdateConferenceCategoryCommand
8UserNotificationConfigurationMMCA.Common.Infrastructure2EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, UserNotification9ActivityCreateRequestMMCA.ADC.Conference.Application3Activity, ICacheInvalidating, ICreateRequest
8ApplicationDbContextTenantFilterTestsMMCA.Common.Infrastructure.Tests6ApplicationDbContext, EFReadRepository<TEntity, TIdentifierType>, PlainThing, TenantDetail, TenantTestContext, TenantThing9ActivityDTOMapperMMCA.ADC.Conference.Application3Activity, ActivityDTO, IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>
8ApplicationDbContextTestsMMCA.Common.Infrastructure.Tests4ApplicationDbContext, DataSource, TestApplicationDbContext, TestEntity9AddEventQuestionAnswerCommandValidatorMMCA.ADC.Conference.Application1AddEventQuestionAnswerCommand
8AuditSaveChangesInterceptorTestsMMCA.Common.Infrastructure.Tests4AuditSaveChangesInterceptor, FakeTimeProvider, TestAuditDbContext, TestAuditEntity9AddEventQuestionAnswerHandlerMMCA.ADC.Conference.Application14AddEventQuestionAnswerCommand, Error, Event, EventFeedbackSubmitted, EventInvariants, EventQuestionAnswer, EventQuestionAnswerDTO, EventQuestionAnswerDTOMapper, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IUnitOfWork, Question, QuestionInvariants, Result
8AuditTrailModelGateTestsMMCA.Common.Infrastructure.Tests3AuditTrailEntry, DataSourceKey, GateTestContext9AddEventSpeakerCommandValidatorMMCA.ADC.Conference.Application1AddEventSpeakerCommand
9AddEventSpeakerHandlerMMCA.ADC.Conference.Application 8AuditTrailTestContextMMCA.Common.Infrastructure.Tests17ApplicationDbContext, AuditedThing, AuditSaveChangesInterceptor, AuditTrailEntry, AuditTrailSaveChangesInterceptor, CompositeKeyThing, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, FailingSaveInterceptor, FailingSaveInterceptor, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, NullAssemblyProvider, PlainThing, TestPhysicalDataSourcesAddEventSpeakerCommand, Error, Event, EventSpeakerDTO, EventSpeakerDTOMapper, ICommandHandler<in TCommand, TResult>, IUnitOfWork, Result
8CrossDataSourceDegradeConventionTestsMMCA.Common.Infrastructure.Tests14AuditSaveChangesInterceptor, DataSource, DataSourceKey, DataSourceModelCacheKeyFactory, DegradeCustomer, DegradeOrder, DegradeTestContext, DomainEventSaveChangesInterceptor, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, MapRegistry, OutboxSignal, PhysicalDataSource9AddRoomCommandValidatorMMCA.ADC.Conference.Application7AddRoomCommand, RoomAccessibilityInfoRules<T>, RoomCapacityRules<T>, RoomFloorRules<T>, RoomLocationRules<T>, RoomNameRules<T>, RoomSortRules<T>
8DependencyInjectionAdditionalTestsMMCA.Common.Infrastructure.Tests6EntityConfigurationOptions, IDataSourceService, IDbContextFactory, IQueryableExecutor, IRepositoryFactory, IUnitOfWork9AddRoomHandlerMMCA.ADC.Conference.Application10AddRoomCommand, Error, Event, EventInvariants, ICommandHandler<in TCommand, TResult>, IUnitOfWork, Result, Room, RoomDTO, RoomDTOMapper
8DesignAlphaEntityConfigurationMMCA.Common.Infrastructure.Tests9AddSessionCategoryItemCommandMMCA.ADC.Conference.Application 2DesignAlphaEntity, EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>ICacheInvalidating, Session
8DesignBetaEntityConfigurationMMCA.Common.Infrastructure.Tests9AddSessionQuestionAnswerCommandMMCA.ADC.Conference.Application 2DesignBetaEntity, EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>ICacheInvalidating, Session
8DomainEventCaptureExclusionTestsMMCA.Common.Infrastructure.Tests7DomainEventSaveChangesInterceptor, ExclusionAggregate, ExclusionEvent, ExclusionTestDbContext, IDomainEvent, IDomainEventDispatcher, IOutboxSignal9AddSessionSpeakerCommandMMCA.ADC.Conference.Application2ICacheInvalidating, Session
8DomainEventSaveChangesInterceptorOutboxRoutingTestsMMCA.Common.Infrastructure.Tests 9DomainEventSaveChangesInterceptor, IDomainEvent, IDomainEventDispatcher, IOutboxSignal, OutboxMessage, OutboxRoutingTestDbContext, TestAggregate, TestIntegrationEvent, TestLocalEventAddSpeakerCategoryItemCommandValidatorMMCA.ADC.Conference.Application1AddSpeakerCategoryItemCommand
9AddSpeakerCategoryItemHandlerMMCA.ADC.Conference.Application 8DomainEventSaveChangesInterceptorTestsMMCA.Common.Infrastructure.Tests7DomainEventSaveChangesInterceptor, IDomainEvent, IDomainEventDispatcher, IOutboxSignal, TestAggregate, TestDomainEvent, TestDomainEventDbContextAddSpeakerCategoryItemCommand, Error, ICommandHandler<in TCommand, TResult>, IUnitOfWork, Result, Speaker, SpeakerCategoryItemDTO, SpeakerCategoryItemDTOMapper
8EFReadRepositoryGetByIdFilterTestsMMCA.Common.Infrastructure.Tests3EFReadRepository<TEntity, TIdentifierType>, SoftDeletableTestEntity, SoftDeleteTestDbContext9CalendarExportMapperMMCA.ADC.Conference.Application4Event, IcsEvent, Session, SessionStatuses
9CreateConferenceCategoryHandlerMMCA.ADC.Conference.Application 8EFReadRepositoryKeysetPagingTestsMMCA.Common.Infrastructure.Tests8BbbSpecification, Category, EFReadRepository<TEntity, TIdentifierType>, ErrorType, KeysetCursor, KeysetPageRequest, SpecificationTestDbContext, SpecTestEntityCategory, ConferenceCategoryCreateRequest, ConferenceCategoryDTO, ConferenceCategoryDTOMapper, ICommandHandler<in TCommand, TResult>, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, IUnitOfWork, Result
8EFReadRepositoryProjectedFilterTestsMMCA.Common.Infrastructure.Tests3EFReadRepository<TEntity, TIdentifierType>, NamedSoftDeleteTestDbContext, ProjectedTestEntity9CreateQuestionHandlerMMCA.ADC.Conference.Application10Error, ICommandHandler<in TCommand, TResult>, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, IUnitOfWork, Question, QuestionCreateRequest, QuestionDTO, QuestionDTOMapper, QuestionInvariants, Result
8EFReadRepositorySpecificationTestsMMCA.Common.Infrastructure.Tests15AllSpecification, BetaSpecification, Category, DeletedByNameSpecification, EFReadRepository<TEntity, TIdentifierType>, HighRankSpecification, IncludingSoftDeletedSpecification, IncludingSpecification, ISpecification<TEntity, TIdentifierType>, NoMatchSpecification, SpecificationTestDbContext, SpecTestChild, SpecTestEntity, TopTwoByRankSpecification, TrackedSpecification9DeleteSessionHandlerMMCA.ADC.Conference.Application6DeleteEntityCommand<TEntity, TIdentifierType>, Error, ICommandHandler<in TCommand, TResult>, IUnitOfWork, Result, Session
8EFRepositoryDecoratorAdditionalTestsMMCA.Common.Infrastructure.Tests3EFRepositoryDecorator<TEntity, TIdentifierType>, FakeAggregateEntity, IRepository<TEntity, TIdentifierType>9EventCreateRequestMapperMMCA.ADC.Conference.Application4Event, EventCreateRequest, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, Result
8EFRepositoryDecoratorTestsMMCA.Common.Infrastructure.Tests3EFRepositoryDecorator<TEntity, TIdentifierType>, FakeAggregateEntity, IRepository<TEntity, TIdentifierType>9EventCreateRequestValidatorMMCA.ADC.Conference.Application7EventCreateRequest, EventDateRangeRules<T>, EventNameRules<T>, EventOrganizerContactEmailRules<T>, EventSponsorshipPacketUrlRules<T>, EventTicketingUrlRules<T>, EventTimeZoneRules<T>
8EntityTypeConfigurationBaseTestsMMCA.Common.Infrastructure.Tests9EventDTOMapperMMCA.ADC.Conference.Application 6TestAggregateEntity, TestAggregateEntityConfiguration, TestConfigDbContext, TestNonAggregateConfigDbContext, TestNonAggregateEntity, TestNonAggregateEntityConfigurationEvent, EventDTO, EventQuestionAnswerDTOMapper, EventSpeakerDTOMapper, IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>, RoomDTOMapper
8FailingSaveInterceptorMMCA.Common.Infrastructure.Tests1AuditTrailTestContext9GetCategoryDistributionHandlerMMCA.ADC.Conference.Application11Category, CategoryDistributionDTO, CategoryGroupDistribution, CategoryItemDistribution, GetCategoryDistributionQuery, IQueryHandler<in TQuery, TResult>, IUnitOfWork, Result, Session, SessionStatuses, StatusBucket
8MocksMMCA.Common.Infrastructure.Tests4IDataSourceResolver, IDbContextFactory, IDomainEventDispatcher, IOutboxSignal9GetContentSimilarityHandlerMMCA.ADC.Conference.Application10Category, ContentSimilarityDTO, GetContentSimilarityQuery, IQueryHandler<in TQuery, TResult>, IUnitOfWork, Result, Session, SessionSimilarityCalculator, SessionStatuses, SimilarSessionPair
8MultiSourceCustomerConfigurationMMCA.Common.Infrastructure.Tests9GetNowNextQueryMMCA.ADC.Conference.Application 2EntityTypeConfigurationSqlite<TEntity, TIdentifierType>, MultiSourceCustomerIQueryCacheable, Session
8MultiSourceOrderConfigurationMMCA.Common.Infrastructure.Tests2EntityTypeConfigurationSqlite<TEntity, TIdentifierType>, MultiSourceOrder9GetSessionBookmarkCountHandlerMMCA.ADC.Conference.Application7Error, GetSessionBookmarkCountQuery, IBookmarkCountService, IQueryHandler<in TQuery, TResult>, IUnitOfWork, Result, Session
8NotificationTestDbContextMMCA.Common.Infrastructure.Tests2PushNotification, UserNotification
8PortablePrincipalConfigurationMMCA.Common.Infrastructure.Tests2EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, PortablePrincipal9GetSessionBookmarkCountsHandlerMMCA.ADC.Conference.Application6GetSessionBookmarkCountsQuery, IBookmarkCountService, IQueryHandler<in TQuery, TResult>, IUnitOfWork, Result, Session
8ProjectionTestDbContextMMCA.Common.Infrastructure.Tests1PushNotification9GetSessionFeedbackHandlerMMCA.ADC.Conference.Application10Error, GetSessionFeedbackQuery, IQueryHandler<in TQuery, TResult>, IUnitOfWork, Question, RatingQuestionSummary, Result, Session, SessionFeedbackDTO, TextQuestionResponses
9GetSessionsBySpeakerFilterHandlerMMCA.ADC.Conference.Application 8QueryParameterizationTestsMMCA.Common.Infrastructure.Tests3QueryFieldService, QueryFilterService, QueryShapeTestDbContextGetSessionsBySpeakerFilterQuery, InlineSpecification<TEntity, TIdentifierType>, IQueryHandler<in TQuery, TResult>, IUnitOfWork, Result, Session, SessionSpeaker, Specification<TEntity, TIdentifierType>
8RegistryDuplicateConfigurationAMMCA.Common.Infrastructure.Tests2EntityTypeConfigurationSqlite<TEntity, TIdentifierType>, RegistryDuplicate9GetSessionSelectionDashboardHandlerMMCA.ADC.Conference.Application23Category, CategoryDistributionDTO, CategoryGroupDistribution, CategoryItemDistribution, Error, Event, GetSessionSelectionDashboardQuery, IQueryHandler<in TQuery, TResult>, IUnitOfWork, LocalityLookupEntry, MultiSessionSpeaker, Result, Session, SessionAiScore, SessionAiScoreDTO, SessionSelectionDashboardDTO, SessionStatuses, Speaker, SpeakerLocalityHelper, SpeakerLocalitySummary …(+3)
8RegistryDuplicateConfigurationBMMCA.Common.Infrastructure.Tests2EntityTypeConfigurationSqlite<TEntity, TIdentifierType>, RegistryDuplicate9GetSpeakersByEventFilterHandlerMMCA.ADC.Conference.Application10EventSpeaker, GetSpeakersByEventFilterQuery, InlineSpecification<TEntity, TIdentifierType>, IQueryHandler<in TQuery, TResult>, IUnitOfWork, Result, Session, SessionSpeaker, Speaker, Specification<TEntity, TIdentifierType>
8RegistryInvoiceConfigurationMMCA.Common.Infrastructure.Tests2EntityTypeConfigurationSqlite<TEntity, TIdentifierType>, RegistryInvoice9GetSpeakerSessionOverlapHandlerMMCA.ADC.Conference.Application13Category, GetSpeakerSessionOverlapQuery, IQueryHandler<in TQuery, TResult>, IUnitOfWork, LocalityLookupEntry, MultiSessionSpeaker, Result, Session, SessionStatuses, Speaker, SpeakerLocalityHelper, SpeakerSessionOverlapDTO, SpeakerSessionSummary
8RegistryOrderConfigurationMMCA.Common.Infrastructure.Tests9ISessionizeSyncStrategyMMCA.ADC.Conference.Application 2EntityTypeConfigurationSqlite<TEntity, TIdentifierType>, RegistryOrderSessionizeSyncContext, SessionizeSyncResult
8RegistrySqlServerEntityConfigurationMMCA.Common.Infrastructure.Tests2EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, RegistrySqlServerEntity9LinkUserToSpeakerHandlerMMCA.ADC.Conference.Application7Error, ICommandHandler<in TCommand, TResult>, IUnitOfWork, LinkUserToSpeakerCommand, Result, Speaker, SpeakerLinkedToUser
8SaveChangeDetectionTestsMMCA.Common.Infrastructure.Tests2DetectionTestDbContext, Widget9PublicSessionStatusSpecificationMMCA.ADC.Conference.Application3Session, SessionStatuses, Specification<TEntity, TIdentifierType>
8SchedulerModelGateTestsMMCA.Common.Infrastructure.Tests3DataSourceKey, GateTestContext, ScheduledJobEntry9PublishEventHandlerMMCA.ADC.Conference.Application6Error, Event, ICommandHandler<in TCommand, TResult>, IUnitOfWork, PublishEventCommand, Result
8SeederMocksMMCA.Common.Infrastructure.Tests9QuestionCreateRequestMapperMMCA.ADC.Conference.Application 4IPasswordHasher, IRepository<TEntity, TIdentifierType>, IUnitOfWork, TestSeedUserIEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, Question, QuestionCreateRequest, Result
8SoftDeleteQueryFilterTestsMMCA.Common.Infrastructure.Tests9QuestionCreateRequestValidatorMMCA.ADC.Conference.Application 2SoftDeletableEntity, SoftDeleteTestDbContextQuestionCreateRequest, QuestionTextRules<T>
8SoftDeleteUniqueIndexConventionTestsMMCA.Common.Infrastructure.Tests3FilteredIndexEntity, UniqueIndexTestDbContext, UniqueNamedEntity9RemoveEventQuestionAnswerHandlerMMCA.ADC.Conference.Application9Error, Event, EventQuestionAnswer, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IUnitOfWork, RemoveEventQuestionAnswerCommand, Result, RoleNames
8SpecificationEvaluatorTestsMMCA.Common.Infrastructure.Tests11BetaSpecification, Category, IncludingSpecification, OrderedSpecification, PagedSpecification, RankDescendingSpecification, SpecificationEvaluator, SpecificationTestDbContext, SpecTestChild, SpecTestEntity, UnorderedQuerySpecification9RemoveEventSpeakerHandlerMMCA.ADC.Conference.Application6Error, Event, ICommandHandler<in TCommand, TResult>, IUnitOfWork, RemoveEventSpeakerCommand, Result
8SqliteTestEntityConfigMMCA.Common.Infrastructure.Tests2EntityTypeConfigurationSqlite<TEntity, TIdentifierType>, SqliteTestEntity9RemoveRoomHandlerMMCA.ADC.Conference.Application6Error, Event, ICommandHandler<in TCommand, TResult>, IUnitOfWork, RemoveRoomCommand, Result
8SQLServerDbContextTestsMMCA.Common.Infrastructure.Tests11AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyAssemblyProvider, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, OutboxSignal, PersistenceSettings, SQLServerDbContext, TestPhysicalDataSources9RemoveSessionCategoryItemCommandMMCA.ADC.Conference.Application2ICacheInvalidating, Session
8TenantSaveChangesInterceptorTestsMMCA.Common.Infrastructure.Tests5CrossTenantWriteException, PlainThing, TenantTestContext, TenantThing, TrailedTenantThing9RemoveSessionQuestionAnswerCommandMMCA.ADC.Conference.Application2ICacheInvalidating, Session
8TestConnectionContextMMCA.Common.Infrastructure.Tests9RemoveSessionSpeakerCommandMMCA.ADC.Conference.Application 2TestDuplexPipe, UserICacheInvalidating, Session
8GalleryAuthenticationStateProviderMMCA.Common.UI.Gallery1User9RemoveSpeakerCategoryItemHandlerMMCA.ADC.Conference.Application6Error, ICommandHandler<in TCommand, TResult>, IUnitOfWork, RemoveSpeakerCategoryItemCommand, Result, Speaker
9AdcArchitectureMapMMCA.ADC.Architecture.Tests17ApiControllerBase, ApplicationDbContext, ArchitectureMapBase, BaseEntity<TIdentifierType>, ConferenceModule, EngagementModule, EntityQueryService<TEntity, TEntityDTO, TIdentifierType>, Event, EventDTO, IdentityModule, Layer, LayerRef, Result, User, UserDTO, UserSessionBookmark, UserSessionBookmarkDTOScoreEventSessionsHandlerMMCA.ADC.Conference.Application12Error, IAiScoringService, ICommandHandler<in TCommand, TResult>, IUnitOfWork, Result, ScoreEventSessionsCommand, ScoreEventSessionsResultDTO, Session, SessionAiScore, SessionScoringInput, Speaker, SpeakerInfo
9DecoratorPipelineOrderTestsMMCA.ADC.Architecture.Tests11ChangePreferencesCommand, ClassReference, DecoratorPipelineOrderTestsBase<TCommand, TCommandResult, TQuery, TQueryResult>, GetUserPreferencesQuery, ICacheService, ICorrelationContext, ICurrentUserService, IPermissionRegistry, IUnitOfWork, Result, UserPreferencesResponseSessionBookmarkValidationServiceMMCA.ADC.Conference.Application6Error, ISessionBookmarkValidationService, IUnitOfWork, Result, Session, SessionInvariants
9CurrentUserServiceExtensionsMMCA.ADC.Conference.API2ConferenceReadAudience, ICurrentUserServiceSessionCategoryItemDTOMapperMMCA.ADC.Conference.Application3IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>, SessionCategoryItem, SessionCategoryItemDTO
9EventQuestionAnswersControllerMMCA.ADC.Conference.API20AddEventQuestionAnswerCommand, AddEventQuestionAnswerRequest, AuthorizationPolicies, BaseLookup<TIdentifierType>, CollectionResult<T>, EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>, EventQuestionAnswer, EventQuestionAnswerDTO, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, OwnedByUserSpecification<TEntity, TIdentifierType>, PagedCollectionResult<T>, QueryFilterModelBinder, RemoveEventQuestionAnswerCommand, Result, RoleNames, Route, UpdateEventQuestionAnswerCommand, UpdateEventQuestionAnswerRequestSessionCreateRequestMMCA.ADC.Conference.Application3ICacheInvalidating, ICreateRequest, Session
9EventSpeakersControllerMMCA.ADC.Conference.API19AddEventSpeakerCommand, AddEventSpeakerRequest, BaseLookup<TIdentifierType>, CollectionResult<T>, ConferencePermissions, EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>, EventSpeaker, EventSpeakerDTO, GetPublicEventSpeakerFilterQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, IQueryHandler<in TQuery, TResult>, PagedCollectionResult<T>, QueryFilterModelBinder, RemoveEventSpeakerCommand, Result, Route, Specification<TEntity, TIdentifierType>SessionQuestionAnswerDTOMapperMMCA.ADC.Conference.Application3IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>, SessionQuestionAnswer, SessionQuestionAnswerDTO
9QuestionsControllerMMCA.ADC.Conference.API16AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest>, BaseLookup<TIdentifierType>, CollectionResult<T>, ConferencePermissions, DeleteEntityCommand<TEntity, TIdentifierType>, ICommandHandler<in TCommand, TResult>, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, PagedCollectionResult<T>, QueryFilterModelBinder, Question, QuestionCreateRequest, QuestionDTO, QuestionUpdateRequest, Result, Route, UpdateQuestionCommandSessionRoomSchedulingMMCA.ADC.Conference.Application5Error, Event, IEntityReader<TEntity, TIdentifierType>, Result, Session
9RoomsControllerMMCA.ADC.Conference.API17AddRoomCommand, AddRoomRequest, BaseLookup<TIdentifierType>, CollectionResult<T>, ConferencePermissions, EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>, ICommandHandler<in TCommand, TResult>, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, PagedCollectionResult<T>, QueryFilterModelBinder, RemoveRoomCommand, Result, Room, RoomDTO, Route, UpdateRoomCommand, UpdateRoomRequestSessionSpeakerDTOMapperMMCA.ADC.Conference.Application3IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>, SessionSpeaker, SessionSpeakerDTO
9SpeakerCategoryItemsControllerMMCA.ADC.Conference.API19AddSpeakerCategoryItemCommand, AddSpeakerCategoryItemRequest, BaseLookup<TIdentifierType>, CollectionResult<T>, ConferencePermissions, EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>, GetPublicSpeakerCategoryItemFilterQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, IQueryHandler<in TQuery, TResult>, PagedCollectionResult<T>, QueryFilterModelBinder, RemoveSpeakerCategoryItemCommand, Result, Route, SpeakerCategoryItem, SpeakerCategoryItemDTO, Specification<TEntity, TIdentifierType>SpeakerCreateRequestMapperMMCA.ADC.Conference.Application4IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, Result, Speaker, SpeakerCreateRequest
9SpeakersControllerMMCA.ADC.Conference.API31AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest>, AndSpecification<TEntity, TIdentifierType>, BaseLookup<TIdentifierType>, CollectionResult<T>, ConferencePermissions, DeleteEntityCommand<TEntity, TIdentifierType>, Error, GetPublicSpeakerFilterQuery, GetSessionBookmarkCountQuery, GetSessionBookmarkCountsQuery, GetSessionFeedbackQuery, GetSpeakersByEventFilterQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, IQueryHandler<in TQuery, TResult>, LinkUserRequest, LinkUserToSpeakerCommand, PagedCollectionResult<T>, QueryFilterModelBinder …(+11)SpeakerCreateRequestValidatorMMCA.ADC.Conference.Application3SpeakerCreateRequest, SpeakerFirstNameRules<T>, SpeakerLastNameRules<T>
9CategoryItemsControllerTestsMMCA.ADC.Conference.API.Tests12AddCategoryItemCommand, AddCategoryItemRequest, CategoryItem, CategoryItemDTO, CategoryItemsController, Error, ICommandHandler<in TCommand, TResult>, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, RemoveCategoryItemCommand, Result, UpdateCategoryItemCommand, UpdateCategoryItemRequest
9ConferenceCategoriesControllerTestsMMCA.ADC.Conference.API.Tests11Category, ConferenceCategoriesController, ConferenceCategoryCreateRequest, ConferenceCategoryDTO, ConferenceCategoryUpdateRequest, DeleteEntityCommand<TEntity, TIdentifierType>, Error, ICommandHandler<in TCommand, TResult>, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, Result, UpdateConferenceCategoryCommand
9AddEventQuestionAnswerCommandValidatorMMCA.ADC.Conference.Application1AddEventQuestionAnswerCommand
9AddEventQuestionAnswerHandlerSpeakerDTOMapper MMCA.ADC.Conference.Application14AddEventQuestionAnswerCommand, Error, Event, EventFeedbackSubmitted, EventInvariants, EventQuestionAnswer, EventQuestionAnswerDTO, EventQuestionAnswerDTOMapper, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IUnitOfWork, Question, QuestionInvariants, Result8Email, ICurrentUserService, IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>, RoleNames, Speaker, SpeakerCategoryItemDTOMapper, SpeakerDTO, SpeakerQuestionAnswerDTOMapper
9AddEventSpeakerCommandValidatorSponsorCreateRequest MMCA.ADC.Conference.Application1AddEventSpeakerCommand4ICacheInvalidating, ICreateRequest, Sponsor, SponsorTier
9AddEventSpeakerHandlerSponsorDTOMapper MMCA.ADC.Conference.Application8AddEventSpeakerCommand, Error, Event, EventSpeakerDTO, EventSpeakerDTOMapper, ICommandHandler<in TCommand, TResult>, IUnitOfWork, Result3IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>, Sponsor, SponsorDTO
9AddRoomCommandValidatorUnlinkUserFromSpeakerHandler MMCA.ADC.Conference.Application 7AddRoomCommand, RoomAccessibilityInfoRules<T>, RoomCapacityRules<T>, RoomFloorRules<T>, RoomLocationRules<T>, RoomNameRules<T>, RoomSortRules<T>Error, ICommandHandler<in TCommand, TResult>, IUnitOfWork, Result, Speaker, SpeakerUnlinkedFromUser, UnlinkUserFromSpeakerCommand
9AddRoomHandlerUnpublishEventHandler MMCA.ADC.Conference.Application10AddRoomCommand, Error, Event, EventInvariants, ICommandHandler<in TCommand, TResult>, IUnitOfWork, Result, Room, RoomDTO, RoomDTOMapper6Error, Event, ICommandHandler<in TCommand, TResult>, IUnitOfWork, Result, UnpublishEventCommand
9AddSessionCategoryItemCommandUpdateActivityCommand MMCA.ADC.Conference.Application2ICacheInvalidating, Session4Activity, ActivityUpdateRequest, ICacheInvalidating, ICommandWithRequest<out TRequest>
9AddSessionQuestionAnswerCommandUpdateConferenceCategoryHandler MMCA.ADC.Conference.Application2ICacheInvalidating, Session8Category, ConferenceCategoryDTO, ConferenceCategoryDTOMapper, Error, ICommandHandler<in TCommand, TResult>, IUnitOfWork, Result, UpdateConferenceCategoryCommand
9AddSessionSpeakerCommandUpdateEventQuestionAnswerHandler MMCA.ADC.Conference.Application2ICacheInvalidating, Session
9AddSpeakerCategoryItemCommandValidatorMMCA.ADC.Conference.Application1AddSpeakerCategoryItemCommandError, Event, EventQuestionAnswer, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IUnitOfWork, Result, RoleNames, UpdateEventQuestionAnswerCommand
9AddSpeakerCategoryItemHandlerUpdateQuestionHandler MMCA.ADC.Conference.Application8AddSpeakerCategoryItemCommand, Error, ICommandHandler<in TCommand, TResult>, IUnitOfWork, Result, Speaker, SpeakerCategoryItemDTO, SpeakerCategoryItemDTOMapper11Error, EventQuestionAnswer, ICommandHandler<in TCommand, TResult>, IUnitOfWork, Question, QuestionDTO, QuestionDTOMapper, Result, SessionQuestionAnswer, SpeakerQuestionAnswer, UpdateQuestionCommand
9CalendarExportMapperUpdateRoomCommandValidator MMCA.ADC.Conference.Application4Event, IcsEvent, Session, SessionStatuses7RoomAccessibilityInfoRules<T>, RoomCapacityRules<T>, RoomFloorRules<T>, RoomLocationRules<T>, RoomNameRules<T>, RoomSortRules<T>, UpdateRoomCommand
9CreateConferenceCategoryHandlerUpdateRoomHandler MMCA.ADC.Conference.Application8Category, ConferenceCategoryCreateRequest, ConferenceCategoryDTO, ConferenceCategoryDTOMapper, ICommandHandler<in TCommand, TResult>, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, IUnitOfWork, Result6Error, Event, ICommandHandler<in TCommand, TResult>, IUnitOfWork, Result, UpdateRoomCommand
9CreateQuestionHandlerUpdateSessionCommand MMCA.ADC.Conference.Application10Error, ICommandHandler<in TCommand, TResult>, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, IUnitOfWork, Question, QuestionCreateRequest, QuestionDTO, QuestionDTOMapper, QuestionInvariants, Result4ICacheInvalidating, ICommandWithRequest<out TRequest>, Session, SessionUpdateRequest
9DeleteSessionHandlerUpdateSessionQuestionAnswerCommand MMCA.ADC.Conference.Application6DeleteEntityCommand<TEntity, TIdentifierType>, Error, ICommandHandler<in TCommand, TResult>, IUnitOfWork, Result, Session2ICacheInvalidating, Session
9EventCreateRequestMapperUpdateSponsorCommand MMCA.ADC.Conference.Application 4Event, EventCreateRequest, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, ResultICacheInvalidating, ICommandWithRequest<out TRequest>, Sponsor, SponsorUpdateRequest
9EventCreateRequestValidatorMMCA.ADC.Conference.Application6EventCreateRequest, EventDateRangeRules<T>, EventNameRules<T>, EventOrganizerContactEmailRules<T>, EventSponsorshipPacketUrlRules<T>, EventTimeZoneRules<T>ActivityUpdateRequestValidatorTestsMMCA.ADC.Conference.Application.Tests3ActivityInvariants, ActivityUpdateRequest, ActivityUpdateRequestValidator
9EventDTOMapperMMCA.ADC.Conference.Application6Event, EventDTO, EventQuestionAnswerDTOMapper, EventSpeakerDTOMapper, IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>, RoomDTOMapperAddCategoryItemCommandValidatorTestsMMCA.ADC.Conference.Application.Tests3AddCategoryItemCommand, AddCategoryItemCommandValidator, CategoryInvariants
9GetCategoryDistributionHandlerMMCA.ADC.Conference.Application11Category, CategoryDistributionDTO, CategoryGroupDistribution, CategoryItemDistribution, GetCategoryDistributionQuery, IQueryHandler<in TQuery, TResult>, IUnitOfWork, Result, Session, SessionStatuses, StatusBucketConferenceCategoryCreateRequestValidatorTestsMMCA.ADC.Conference.Application.Tests3CategoryInvariants, ConferenceCategoryCreateRequest, ConferenceCategoryCreateRequestValidator
9GetContentSimilarityHandlerMMCA.ADC.Conference.Application10Category, ContentSimilarityDTO, GetContentSimilarityQuery, IQueryHandler<in TQuery, TResult>, IUnitOfWork, Result, Session, SessionSimilarityCalculator, SessionStatuses, SimilarSessionPairConferenceCategoryDTOMapperTestsMMCA.ADC.Conference.Application.Tests3Category, CategoryItemDTOMapper, ConferenceCategoryDTOMapper
9GetNowNextQueryMMCA.ADC.Conference.ApplicationConferenceCategoryUpdateRequestValidatorTestsMMCA.ADC.Conference.Application.Tests 2IQueryCacheable, SessionConferenceCategoryUpdateRequest, ConferenceCategoryUpdateRequestValidator
9GetSessionBookmarkCountHandlerMMCA.ADC.Conference.Application7Error, GetSessionBookmarkCountQuery, IBookmarkCountService, IQueryHandler<in TQuery, TResult>, IUnitOfWork, Result, SessionConferenceCategoryValidationRulesTestsMMCA.ADC.Conference.Application.Tests5CategoryInvariants, TestCategoryItemModel, TestCategoryItemValidator, TestCategoryModel, TestCategoryTitleValidator
9GetSessionBookmarkCountsHandlerMMCA.ADC.Conference.Application6GetSessionBookmarkCountsQuery, IBookmarkCountService, IQueryHandler<in TQuery, TResult>, IUnitOfWork, Result, SessionEventQuestionAnswerDTOMapperTestsMMCA.ADC.Conference.Application.Tests3Event, EventQuestionAnswer, EventQuestionAnswerDTOMapper
9GetSessionFeedbackHandlerMMCA.ADC.Conference.Application10Error, GetSessionFeedbackQuery, IQueryHandler<in TQuery, TResult>, IUnitOfWork, Question, RatingQuestionSummary, Result, Session, SessionFeedbackDTO, TextQuestionResponsesEventSpeakerDTOMapperTestsMMCA.ADC.Conference.Application.Tests3Event, EventSpeaker, EventSpeakerDTOMapper
9GetSessionsBySpeakerFilterHandlerMMCA.ADC.Conference.Application8GetSessionsBySpeakerFilterQuery, InlineSpecification<TEntity, TIdentifierType>, IQueryHandler<in TQuery, TResult>, IUnitOfWork, Result, Session, SessionSpeaker, Specification<TEntity, TIdentifierType>EventUpdateRequestValidatorTestsMMCA.ADC.Conference.Application.Tests2EventUpdateRequest, EventUpdateRequestValidator
9GetSessionSelectionDashboardHandlerMMCA.ADC.Conference.Application23Category, CategoryDistributionDTO, CategoryGroupDistribution, CategoryItemDistribution, Error, Event, GetSessionSelectionDashboardQuery, IQueryHandler<in TQuery, TResult>, IUnitOfWork, LocalityLookupEntry, MultiSessionSpeaker, Result, Session, SessionAiScore, SessionAiScoreDTO, SessionSelectionDashboardDTO, SessionStatuses, Speaker, SpeakerLocalityHelper, SpeakerLocalitySummary …(+3)EventValidationRulesTestsMMCA.ADC.Conference.Application.Tests3EventInvariants, TestEventModel, TestEventValidator
9GetSpeakersByEventFilterHandlerMMCA.ADC.Conference.Application10EventSpeaker, GetSpeakersByEventFilterQuery, InlineSpecification<TEntity, TIdentifierType>, IQueryHandler<in TQuery, TResult>, IUnitOfWork, Result, Session, SessionSpeaker, Speaker, Specification<TEntity, TIdentifierType>FakesMMCA.ADC.Conference.Application.Tests4InMemoryRepository<TEntity, TIdentifierType>, RecordingEventBus, RecordingUnitOfWork, Speaker
9GetSpeakerSessionOverlapHandlerMMCA.ADC.Conference.Application13Category, GetSpeakerSessionOverlapQuery, IQueryHandler<in TQuery, TResult>, IUnitOfWork, LocalityLookupEntry, MultiSessionSpeaker, Result, Session, SessionStatuses, Speaker, SpeakerLocalityHelper, SpeakerSessionOverlapDTO, SpeakerSessionSummaryPublishedEventSpecificationTestsMMCA.ADC.Conference.Application.Tests2Event, PublishedEventSpecification
9ISessionizeSyncStrategyMMCA.ADC.Conference.ApplicationQuestionDTOMapperTestsMMCA.ADC.Conference.Application.Tests 2SessionizeSyncContext, SessionizeSyncResultQuestion, QuestionDTOMapper
9LinkUserToSpeakerHandlerMMCA.ADC.Conference.Application7Error, ICommandHandler<in TCommand, TResult>, IUnitOfWork, LinkUserToSpeakerCommand, Result, Speaker, SpeakerLinkedToUserQuestionUpdateRequestValidatorTestsMMCA.ADC.Conference.Application.Tests2QuestionUpdateRequest, QuestionUpdateRequestValidator
9PublicSessionStatusSpecificationMMCA.ADC.Conference.ApplicationQuestionValidationRulesTestsMMCA.ADC.Conference.Application.Tests 3Session, SessionStatuses, Specification<TEntity, TIdentifierType>QuestionInvariants, TestQuestionModel, TestQuestionTextValidator
9PublishEventHandlerMMCA.ADC.Conference.Application6Error, Event, ICommandHandler<in TCommand, TResult>, IUnitOfWork, PublishEventCommand, ResultRoomDTOMapperTestsMMCA.ADC.Conference.Application.Tests3Event, Room, RoomDTOMapper
9QuestionCreateRequestMapperMMCA.ADC.Conference.Application4IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, Question, QuestionCreateRequest, ResultRoomValidationRulesTestsMMCA.ADC.Conference.Application.Tests3EventInvariants, TestRoomModel, TestRoomValidator
9QuestionCreateRequestValidatorMMCA.ADC.Conference.ApplicationSessionRoomFilterTestsMMCA.ADC.Conference.Application.Tests 2QuestionCreateRequest, QuestionTextRules<T>QueryFilterService, Session
9RemoveEventQuestionAnswerHandlerMMCA.ADC.Conference.Application9Error, Event, EventQuestionAnswer, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IUnitOfWork, RemoveEventQuestionAnswerCommand, Result, RoleNamesSessionUpdateRequestValidatorTestsMMCA.ADC.Conference.Application.Tests3SessionInvariants, SessionUpdateRequest, SessionUpdateRequestValidator
9RemoveEventSpeakerHandlerMMCA.ADC.Conference.Application6Error, Event, ICommandHandler<in TCommand, TResult>, IUnitOfWork, RemoveEventSpeakerCommand, ResultSessionValidationRulesTestsMMCA.ADC.Conference.Application.Tests3SessionInvariants, TestSessionModel, TestSessionValidator
9RemoveRoomHandlerMMCA.ADC.Conference.Application6Error, Event, ICommandHandler<in TCommand, TResult>, IUnitOfWork, RemoveRoomCommand, ResultSpeakerCategoryItemDTOMapperTestsMMCA.ADC.Conference.Application.Tests3Speaker, SpeakerCategoryItem, SpeakerCategoryItemDTOMapper
9RemoveSessionCategoryItemCommandMMCA.ADC.Conference.Application2ICacheInvalidating, SessionSpeakerLocalityHelperTestsMMCA.ADC.Conference.Application.Tests4Category, LocalityLookupEntry, Speaker, SpeakerLocalityHelper
9RemoveSessionQuestionAnswerCommandMMCA.ADC.Conference.Application2ICacheInvalidating, SessionSpeakerQuestionAnswerDTOMapperTestsMMCA.ADC.Conference.Application.Tests3Speaker, SpeakerQuestionAnswer, SpeakerQuestionAnswerDTOMapper
9RemoveSessionSpeakerCommandMMCA.ADC.Conference.Application2ICacheInvalidating, SessionSpeakerUpdateRequestValidatorTestsMMCA.ADC.Conference.Application.Tests3Email, SpeakerUpdateRequest, SpeakerUpdateRequestValidator
9RemoveSpeakerCategoryItemHandlerMMCA.ADC.Conference.Application6Error, ICommandHandler<in TCommand, TResult>, IUnitOfWork, RemoveSpeakerCategoryItemCommand, Result, SpeakerSpeakerValidationRulesTestsMMCA.ADC.Conference.Application.Tests3SpeakerInvariants, TestSpeakerModel, TestSpeakerValidator
9ScoreEventSessionsHandlerMMCA.ADC.Conference.Application12Error, IAiScoringService, ICommandHandler<in TCommand, TResult>, IUnitOfWork, Result, ScoreEventSessionsCommand, ScoreEventSessionsResultDTO, Session, SessionAiScore, SessionScoringInput, Speaker, SpeakerInfoSponsorUpdateRequestValidatorTestsMMCA.ADC.Conference.Application.Tests4SponsorInvariants, SponsorTier, SponsorUpdateRequest, SponsorUpdateRequestValidator
9SessionBookmarkValidationServiceMMCA.ADC.Conference.Application6Error, ISessionBookmarkValidationService, IUnitOfWork, Result, Session, SessionInvariantsUpdateCategoryItemCommandValidatorTestsMMCA.ADC.Conference.Application.Tests3CategoryInvariants, UpdateCategoryItemCommand, UpdateCategoryItemCommandValidator
9SessionCategoryItemDTOMapperMMCA.ADC.Conference.Application3IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>, SessionCategoryItem, SessionCategoryItemDTOIEventCascadeDeletionDomainServiceMMCA.ADC.Conference.Domain5Activity, Event, Result, Session, Sponsor
9SessionCreateRequestMMCA.ADC.Conference.Application3ICacheInvalidating, ICreateRequest, SessionActivityBuilderMMCA.ADC.Conference.Domain.Tests2Activity, EntityBuilderBase<TBuilder, TEntity>
9SessionQuestionAnswerDTOMapperMMCA.ADC.Conference.Application3IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>, SessionQuestionAnswer, SessionQuestionAnswerDTOSessionBuilderMMCA.ADC.Conference.Domain.Tests2EntityBuilderBase<TBuilder, TEntity>, Session
9SessionRoomSchedulingMMCA.ADC.Conference.ApplicationSessionTestsMMCA.ADC.Conference.Domain.Tests 5Error, Event, IRepository<TEntity, TIdentifierType>, Result, SessionDomainEntityState, Session, SessionCategoryItemChanged, SessionChanged, SessionSpeakerChanged
9SessionSpeakerDTOMapperMMCA.ADC.Conference.ApplicationSponsorBuilderMMCA.ADC.Conference.Domain.Tests 3IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>, SessionSpeaker, SessionSpeakerDTOEntityBuilderBase<TBuilder, TEntity>, Sponsor, SponsorTier
9SpeakerCreateRequestMapperMMCA.ADC.Conference.Application4IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, Result, Speaker, SpeakerCreateRequestActivityConfigurationMMCA.ADC.Conference.Infrastructure3Activity, ActivityInvariants, EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>
9SpeakerCreateRequestValidatorMMCA.ADC.Conference.Application3SpeakerCreateRequest, SpeakerFirstNameRules<T>, SpeakerLastNameRules<T>ConferenceModuleDbSeederMMCA.ADC.Conference.Infrastructure12Activity, DbSeeder, Event, IRepository<TEntity, TIdentifierType>, IUnitOfWork, Question, QuestionInvariants, Session, SessionInvariants, Speaker, Sponsor, SponsorTier
9SpeakerDTOMapperMMCA.ADC.Conference.Application8Email, ICurrentUserService, IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>, RoleNames, Speaker, SpeakerCategoryItemDTOMapper, SpeakerDTO, SpeakerQuestionAnswerDTOMapperSessionCategoryItemConfigurationMMCA.ADC.Conference.Infrastructure2EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, SessionCategoryItem
9SponsorCreateRequestMMCA.ADC.Conference.Application4ICacheInvalidating, ICreateRequest, Sponsor, SponsorTierSessionConfigurationMMCA.ADC.Conference.Infrastructure3EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, Session, SessionInvariants
9SponsorDTOMapperMMCA.ADC.Conference.ApplicationSessionQuestionAnswerConfigurationMMCA.ADC.Conference.Infrastructure 3IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>, Sponsor, SponsorDTOEntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, SessionInvariants, SessionQuestionAnswer
9UnlinkUserFromSpeakerHandlerMMCA.ADC.Conference.Application7Error, ICommandHandler<in TCommand, TResult>, IUnitOfWork, Result, Speaker, SpeakerUnlinkedFromUser, UnlinkUserFromSpeakerCommandSessionScoringSweepJobMMCA.ADC.Conference.Infrastructure8IScheduledJob, ISessionScoringQueue, IUnitOfWork, Session, SessionAiScore, SessionScoreStamp, SessionScoringCandidate, SessionScoringEnqueueResult
9UnpublishEventHandlerMMCA.ADC.Conference.Application6Error, Event, ICommandHandler<in TCommand, TResult>, IUnitOfWork, Result, UnpublishEventCommandSessionSpeakerConfigurationMMCA.ADC.Conference.Infrastructure2EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, SessionSpeaker
9UpdateConferenceCategoryHandlerMMCA.ADC.Conference.Application8Category, ConferenceCategoryDTO, ConferenceCategoryDTOMapper, Error, ICommandHandler<in TCommand, TResult>, IUnitOfWork, Result, UpdateConferenceCategoryCommandSponsorConfigurationMMCA.ADC.Conference.Infrastructure3EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, Sponsor, SponsorInvariants
9UpdateEventQuestionAnswerHandlerMMCA.ADC.Conference.Application9Error, Event, EventQuestionAnswer, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IUnitOfWork, Result, RoleNames, UpdateEventQuestionAnswerCommandCurrentEventDefaultsMMCA.ADC.Conference.Shared2CurrentEventSelector, EventDTO
9UpdateQuestionHandlerMMCA.ADC.Conference.Application11Error, EventQuestionAnswer, ICommandHandler<in TCommand, TResult>, IUnitOfWork, Question, QuestionDTO, QuestionDTOMapper, Result, SessionQuestionAnswer, SpeakerQuestionAnswer, UpdateQuestionCommandCurrentEventSelectorTestsMMCA.ADC.Conference.Shared.Tests2CurrentEventSelector, TestEvent
9UpdateRoomCommandValidatorMMCA.ADC.Conference.Application7RoomAccessibilityInfoRules<T>, RoomCapacityRules<T>, RoomFloorRules<T>, RoomLocationRules<T>, RoomNameRules<T>, RoomSortRules<T>, UpdateRoomCommandActivityCreateMMCA.ADC.Conference.UI10ActivityDTO, ActivityService, ConferenceRoutePaths, CurrentEventSelector, ErrorMessages, EventInfo, EventLookupService, IActivityUIService, IEventLookupService, Severity
9UpdateRoomHandlerMMCA.ADC.Conference.Application6Error, Event, ICommandHandler<in TCommand, TResult>, IUnitOfWork, Result, UpdateRoomCommandActivityDetailMMCA.ADC.Conference.UI10Activity, ActivityDTO, ActivityService, ConferenceRoutePaths, ErrorMessages, EventInfo, EventLookupService, IActivityUIService, IEventLookupService, Severity
9UpdateSessionCommandMMCA.ADC.Conference.Application4ICacheInvalidating, ICommandWithRequest<out TRequest>, Session, SessionUpdateRequestActivityListMMCA.ADC.Conference.UI12ActivityDTO, ActivityService, ConferenceRoutePaths, CurrentEventSelector, DataGridListPageBase<TDto>, ErrorMessages, EventInfo, EventLookupService, IActivityUIService, IEventLookupService, ListPageActions, MobileInfiniteScrollList<TItem>
9UpdateSessionQuestionAnswerCommandMMCA.ADC.Conference.Application2ICacheInvalidating, SessionADCHomeMMCA.ADC.Conference.UI10ADCCollectionResult, ADCEventInfo, ADCSponsorCollectionResult, ADCSponsorInfo, ConferenceTrackInfo, CurrentEventSelector, EventPhase, KeynoteSpeakerInfo, PreConferenceWorkshopInfo, SponsorTier
9UpdateSponsorCommandMMCA.ADC.Conference.Application4ICacheInvalidating, ICommandWithRequest<out TRequest>, Sponsor, SponsorUpdateRequestPublicActivityListMMCA.ADC.Conference.UI8ActivityDTO, ActivityService, CurrentEventSelector, EventLookupService, IActivityUIService, IEventLookupService, IMapNavigationService, Severity
9AddCategoryItemCommandValidatorTestsMMCA.ADC.Conference.Application.Tests3AddCategoryItemCommand, AddCategoryItemCommandValidator, CategoryInvariantsPublicEventListMMCA.ADC.Conference.UI12ConferenceReadAudience, ConferenceRoutePaths, CurrentEventSelector, DataGridListPageBase<TDto>, EventDTO, EventInfo, EventLookupService, EventService, IEventLookupService, IEventUIService, ListPageActions, MobileInfiniteScrollList<TItem>
9ConferenceCategoryCreateRequestValidatorTestsMMCA.ADC.Conference.Application.Tests3CategoryInvariants, ConferenceCategoryCreateRequest, ConferenceCategoryCreateRequestValidatorPublicSessionDetailMMCA.ADC.Conference.UI17BookmarkService, ConferenceRoutePaths, ICategoryItemLookupService, IHapticFeedbackService, IRoomUIService, ISessionBookmarkUIService, ISessionLiveUIService, ISessionUIService, ISpeakerLookupService, ITextToSpeechService, RoomDTO, RoomService, Session, SessionDTO, SessionLive, SessionService, Severity
9ConferenceCategoryDTOMapperTestsMMCA.ADC.Conference.Application.Tests3Category, CategoryItemDTOMapper, ConferenceCategoryDTOMapperPublicSpeakerListMMCA.ADC.Conference.UI9ConferenceReadAudience, CurrentEventSelector, DataGridListPageBase<TDto>, EventInfo, EventLookupService, IEventLookupService, ISpeakerUIService, SpeakerDTO, SpeakerService
9ConferenceCategoryUpdateRequestValidatorTestsMMCA.ADC.Conference.Application.Tests2ConferenceCategoryUpdateRequest, ConferenceCategoryUpdateRequestValidatorPublicSponsorListMMCA.ADC.Conference.UI7CurrentEventSelector, EventLookupService, IEventLookupService, ISponsorUIService, SponsorDTO, SponsorService, SponsorTier
9ConferenceCategoryValidationRulesTestsMMCA.ADC.Conference.Application.Tests5CategoryInvariants, TestCategoryItemModel, TestCategoryItemValidator, TestCategoryModel, TestCategoryTitleValidatorRoomListMMCA.ADC.Conference.UI12ConferenceRoutePaths, CurrentEventSelector, DataGridListPageBase<TDto>, ErrorMessages, EventInfo, EventLookupService, IEventLookupService, IRoomUIService, ListPageActions, MobileInfiniteScrollList<TItem>, RoomDTO, RoomService
9EventQuestionAnswerDTOMapperTestsMMCA.ADC.Conference.Application.Tests3Event, EventQuestionAnswer, EventQuestionAnswerDTOMapperSessionDetailMMCA.ADC.Conference.UI23CategoryItemInfo, CategoryItemLookupService, ConferenceRoutePaths, ErrorMessages, EventInfo, EventLookupService, ICategoryItemLookupService, IEventLookupService, IRoomUIService, ISessionCategoryItemUIService, ISessionSpeakerUIService, ISessionUIService, ISpeakerLookupService, RoomDTO, RoomService, Session, SessionCategoryItemService, SessionDTO, SessionService, SessionSpeakerService …(+3)
9EventSpeakerDTOMapperTestsMMCA.ADC.Conference.Application.Tests3Event, EventSpeaker, EventSpeakerDTOMapperSessionSelectionDashboardMMCA.ADC.Conference.UI11ConferenceRoutePaths, CurrentEventSelector, EventInfo, EventLookupService, IEventLookupService, ISessionSelectionUIService, ScorePollSignal, ScorePollTracker, SessionSelectionDashboardDTO, SessionSelectionFilterOptions, Severity
9EventUpdateRequestValidatorTestsMMCA.ADC.Conference.Application.Tests2EventUpdateRequest, EventUpdateRequestValidatorSpeakerDashboardMMCA.ADC.Conference.UI13CurrentEventSelector, Email, EventInfo, EventLookupService, IEventLookupService, ISpeakerDashboardUIService, ISpeakerUIService, SessionDTO, SessionFeedbackDTO, Severity, Speaker, SpeakerDTO, SpeakerService
9EventValidationRulesTestsMMCA.ADC.Conference.Application.Tests3EventInvariants, TestEventModel, TestEventValidatorSpeakerListMMCA.ADC.Conference.UI12ConferenceRoutePaths, CurrentEventSelector, DataGridListPageBase<TDto>, ErrorMessages, EventInfo, EventLookupService, IEventLookupService, ISpeakerUIService, ListPageActions, MobileInfiniteScrollList<TItem>, SpeakerDTO, SpeakerService
9FakesMMCA.ADC.Conference.Application.Tests4InMemoryRepository<TEntity, TIdentifierType>, RecordingEventBus, RecordingUnitOfWork, SpeakerSponsorCreateMMCA.ADC.Conference.UI11ConferenceRoutePaths, CurrentEventSelector, ErrorMessages, EventInfo, EventLookupService, IEventLookupService, ISponsorUIService, Severity, SponsorDTO, SponsorService, SponsorTier
9PublishedEventSpecificationTestsMMCA.ADC.Conference.Application.Tests2Event, PublishedEventSpecificationSponsorDetailMMCA.ADC.Conference.UI11ConferenceRoutePaths, ErrorMessages, EventInfo, EventLookupService, IEventLookupService, ISponsorUIService, Severity, Sponsor, SponsorDTO, SponsorService, SponsorTier
9QuestionDTOMapperTestsMMCA.ADC.Conference.Application.Tests2Question, QuestionDTOMapperSponsorListMMCA.ADC.Conference.UI13ConferenceRoutePaths, CurrentEventSelector, DataGridListPageBase<TDto>, ErrorMessages, EventInfo, EventLookupService, IEventLookupService, ISponsorUIService, ListPageActions, MobileInfiniteScrollList<TItem>, SponsorDTO, SponsorService, SponsorTier
9QuestionUpdateRequestValidatorTestsMMCA.ADC.Conference.Application.Tests2QuestionUpdateRequest, QuestionUpdateRequestValidatorEventDetailTestsMMCA.ADC.Conference.UI.Tests5BunitTestBase, EventDetail, EventDTO, IEventUIService, QuestionModerationDefault
9QuestionValidationRulesTestsMMCA.ADC.Conference.Application.Tests3QuestionInvariants, TestQuestionModel, TestQuestionTextValidatorManagementRouteAuthorizationTestsMMCA.ADC.Conference.UI.Tests2PublicEventDetail, RouteAuthorizationTestsBase
9RoomDTOMapperTestsMMCA.ADC.Conference.Application.Tests3Event, Room, RoomDTOMapperPublicEventDetailTestsMMCA.ADC.Conference.UI.Tests6BunitTestBase, EventDTO, IClipboardService, IEventUIService, IMapNavigationService, PublicEventDetail
9RoomValidationRulesTestsMMCA.ADC.Conference.Application.Tests3EventInvariants, TestRoomModel, TestRoomValidatorPublicSessionListViewBookmarkTestsMMCA.ADC.Conference.UI.Tests7BunitTestBase, ISessionBookmarkUIService, PublicSessionListView, Session, SessionDTO, Severity, UserSessionBookmarkDTO
9SessionUpdateRequestValidatorTestsMMCA.ADC.Conference.Application.Tests3SessionInvariants, SessionUpdateRequest, SessionUpdateRequestValidatorPublicSpeakerDetailTestsMMCA.ADC.Conference.UI.Tests5BunitTestBase, ISessionUIService, ISpeakerUIService, PublicSpeakerDetail, SpeakerDTO
9SessionValidationRulesTestsMMCA.ADC.Conference.Application.Tests3SessionInvariants, TestSessionModel, TestSessionValidatorQuestionDetailTestsMMCA.ADC.Conference.UI.Tests5BunitTestBase, IQuestionUIService, Question, QuestionDetail, QuestionDTO
9SpeakerCategoryItemDTOMapperTestsMMCA.ADC.Conference.Application.Tests3Speaker, SpeakerCategoryItem, SpeakerCategoryItemDTOMapperRoomDetailTestsMMCA.ADC.Conference.UI.Tests6BunitTestBase, EventInfo, IEventLookupService, IRoomUIService, RoomDetail, RoomDTO
9SpeakerLocalityHelperTestsMMCA.ADC.Conference.Application.Tests4Category, LocalityLookupEntry, Speaker, SpeakerLocalityHelperSpeakerDashboardServiceTestsMMCA.ADC.Conference.UI.Tests11CapturingHttpMessageHandler, DomainInvariantViolationException, HttpTestDoubles, PagedCollectionResult<T>, PaginationMetadata, RatingQuestionSummary, Session, SessionDTO, SessionFeedbackDTO, SessionSpeakerDTO, SpeakerDashboardService
9SpeakerQuestionAnswerDTOMapperTestsMMCA.ADC.Conference.Application.Tests3Speaker, SpeakerQuestionAnswer, SpeakerQuestionAnswerDTOMapperLivePollsControllerMMCA.ADC.Engagement.API25ApiControllerBase, AuthorizationPolicies, CastVoteCommand, CastVoteRequest, CloseLivePollCommand, CreateLivePollCommand, CreateLivePollRequest, DeleteEntityCommand<TEntity, TIdentifierType>, EngagementFeatures, EngagementPermissions, Error, GetEventPollsQuery, GetOpenPollsQuery, GetPollResultsQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IQueryHandler<in TQuery, TResult>, LifecycleTransitionRequest, LivePoll, LivePollDTO …(+5)
9SpeakerUpdateRequestValidatorTestsMMCA.ADC.Conference.Application.Tests3Email, SpeakerUpdateRequest, SpeakerUpdateRequestValidatorSessionQuestionsControllerMMCA.ADC.Engagement.API19ApiControllerBase, AuthorizationPolicies, EngagementFeatures, Error, GetModerationQueueQuery, GetSessionQuestionsQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IQueryHandler<in TQuery, TResult>, LifecycleTransitionRequest, ModerateQuestionCommand, ModerationAction, Result, RoleNames, Route, SessionQuestionDTO, SubmitQuestionCommand, SubmitQuestionRequest, ToggleUpvoteCommand
9SpeakerValidationRulesTestsMMCA.ADC.Conference.Application.Tests3SpeakerInvariants, TestSpeakerModel, TestSpeakerValidatorControllerMocksMMCA.ADC.Engagement.API.Tests46AttendanceStatsDTO, CastVoteCommand, CheckInAttendeeRequest, CheckInResultDTO, CloseLivePollCommand, CreateBookmarkRequest, CreateLivePollCommand, DeleteEntityCommand<TEntity, TIdentifierType>, GetAttendanceStatsQuery, GetBookmarkedSessionIdsQuery, GetEventPollsQuery, GetLeaderboardQuery, GetModerationQueueQuery, GetMyPointsQuery, GetOpenPollsQuery, GetOrCreateMyBadgeCommand, GetPointsOverviewQuery, GetPollResultsQuery, GetSessionQuestionsQuery, GetUserBookmarksQuery …(+26)
9SponsorUpdateRequestValidatorTestsMMCA.ADC.Conference.Application.Tests4SponsorInvariants, SponsorTier, SponsorUpdateRequest, SponsorUpdateRequestValidatorCloseLivePollHandlerMMCA.ADC.Engagement.Application12CloseLivePollCommand, Error, ICommandHandler<in TCommand, TResult>, IEventLiveValidationService, ILiveChannelPublishQueue, IUnitOfWork, LiveChannelPublishWorkItem, LivePoll, LivePollAuthorization, LivePollChannel, LivePollClosedPayload, Result
9UpdateCategoryItemCommandValidatorTestsMMCA.ADC.Conference.Application.Tests3CategoryInvariants, UpdateCategoryItemCommand, UpdateCategoryItemCommandValidatorCreateBookmarkHandlerMMCA.ADC.Engagement.Application11CreateBookmarkRequest, DuplicateKeyDetection, Error, IBookmarkManagementDomainService, ICommandHandler<in TCommand, TResult>, ISessionBookmarkValidationService, IUnitOfWork, Result, UserSessionBookmark, UserSessionBookmarkDTO, UserSessionBookmarkDTOMapper
9IEventCascadeDeletionDomainServiceMMCA.ADC.Conference.Domain4Event, Result, Session, SponsorGetModerationQueueHandlerMMCA.ADC.Engagement.Application10GetModerationQueueQuery, IEventLiveValidationService, IQueryableExecutor, IQueryHandler<in TQuery, TResult>, IUnitOfWork, LivePollAuthorization, Result, SessionQuestion, SessionQuestionDTO, SessionQuestionViewBuilder
9SessionBuilderMMCA.ADC.Conference.Domain.Tests2EntityBuilderBase<TBuilder, TEntity>, SessionGetMyPointsHandlerMMCA.ADC.Engagement.Application11Error, GetMyPointsQuery, ICurrentUserService, IQueryHandler<in TQuery, TResult>, IUnitOfWork, LeaderboardOptIn, MyPointsDTO, PagingMath, PointsEntry, PointsEntryDTO, Result
9SessionTestsMMCA.ADC.Conference.Domain.Tests5DomainEntityState, Session, SessionCategoryItemChanged, SessionChanged, SessionSpeakerChangedGetOrCreateMyBadgeHandlerMMCA.ADC.Engagement.Application10AttendeeBadge, DuplicateKeyDetection, Error, GetOrCreateMyBadgeCommand, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQuerier<TEntity, TIdentifierType>, IUnitOfWork, MyBadgeDTO, Result
9SponsorBuilderMMCA.ADC.Conference.Domain.Tests3EntityBuilderBase<TBuilder, TEntity>, Sponsor, SponsorTierGetSessionQuestionsHandlerMMCA.ADC.Engagement.Application10GetSessionQuestionsQuery, IQueryableExecutor, IQueryHandler<in TQuery, TResult>, IUnitOfWork, QuestionStatus, Result, SessionQuestion, SessionQuestionDTO, SessionQuestionUpvote, SessionQuestionViewBuilder
9ConferenceModuleDbSeederMMCA.ADC.Conference.Infrastructure11DbSeeder, Event, IRepository<TEntity, TIdentifierType>, IUnitOfWork, Question, QuestionInvariants, Session, SessionInvariants, Speaker, Sponsor, SponsorTierGetUserBookmarksHandlerMMCA.ADC.Engagement.Application12GetUserBookmarksQuery, IQueryableExecutor, IQueryHandler<in TQuery, TResult>, ISessionBookmarkValidationService, IUnitOfWork, PagedCollectionResult<T>, PaginationMetadata, PagingMath, Result, UserSessionBookmark, UserSessionBookmarkDTO, UserSessionBookmarkDTOMapper
9ModuleApplicationDbContextMMCA.ADC.Conference.Infrastructure17ApplicationDbContext, Category, CategoryItem, Event, EventQuestionAnswer, EventSpeaker, IEntityConfigurationAssemblyProvider, PhysicalDataSource, Question, Room, Session, SessionCategoryItem, SessionQuestionAnswer, SessionSpeaker, Speaker, SpeakerCategoryItem, SponsorLivePollDTOMapperMMCA.ADC.Engagement.Application3IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>, LivePoll, LivePollDTO
9SessionCategoryItemConfigurationMMCA.ADC.Conference.Infrastructure2EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, SessionCategoryItemLivePollResultsBuilderMMCA.ADC.Engagement.Application7IQueryableExecutor, IUnitOfWork, LivePoll, LivePollOptionResultDTO, LivePollResultsDTO, LivePollVote, Question
9SessionConfigurationMMCA.ADC.Conference.Infrastructure3EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, Session, SessionInvariantsOpenLivePollHandlerMMCA.ADC.Engagement.Application12Error, ICommandHandler<in TCommand, TResult>, IEventLiveValidationService, ILiveChannelPublishQueue, IUnitOfWork, LiveChannelPublishWorkItem, LivePoll, LivePollAuthorization, LivePollChannel, LivePollOpenedPayload, OpenLivePollCommand, Result
9SessionQuestionAnswerConfigurationMMCA.ADC.Conference.Infrastructure3EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, SessionInvariants, SessionQuestionAnswerSetLeaderboardParticipationHandlerMMCA.ADC.Engagement.Application10DuplicateKeyDetection, Error, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQuerier<TEntity, TIdentifierType>, IRepository<TEntity, TIdentifierType>, IUnitOfWork, LeaderboardOptIn, Result, SetLeaderboardParticipationRequest
9SessionScoringSweepJobMMCA.ADC.Conference.Infrastructure8IScheduledJob, ISessionScoringQueue, IUnitOfWork, Session, SessionAiScore, SessionScoreStamp, SessionScoringCandidate, SessionScoringEnqueueResultSubmitQuestionHandlerMMCA.ADC.Engagement.Application19BestEffort, Error, ICommandHandler<in TCommand, TResult>, IEventLiveValidationService, ILiveChannelPublishQueue, IUnitOfWork, LiveChannelPublishWorkItem, LivePollChannel, QuestionModerationDefault, QuestionStatus, Result, SessionQuestion, SessionQuestionApprovedPayload, SessionQuestionChannel, SessionQuestionDTO, SessionQuestionInvariants, SessionQuestionPendingCountChangedPayload, SessionQuestionViewBuilder, SubmitQuestionCommand
9SessionSpeakerConfigurationMMCA.ADC.Conference.Infrastructure2EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, SessionSpeakerCreateLivePollCommandValidatorTestsMMCA.ADC.Engagement.Application.Tests5CreateLivePollCommand, CreateLivePollCommandValidator, CreateLivePollRequest, LivePollInvariants, Question
9SponsorConfigurationMMCA.ADC.Conference.Infrastructure3EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, Sponsor, SponsorInvariantsHandlerMocksMMCA.ADC.Engagement.Application.Tests6IEventLiveValidationService, ILiveChannelPublishQueue, IRepository<TEntity, TIdentifierType>, IUnitOfWork, LivePoll, LivePollVote
9CurrentEventDefaultsMMCA.ADC.Conference.SharedUserSessionBookmarkDTOMapperTestsMMCA.ADC.Engagement.Application.Tests 2CurrentEventSelector, EventDTOUserSessionBookmark, UserSessionBookmarkDTOMapper
9CurrentEventSelectorTestsMMCA.ADC.Conference.Shared.TestsBookmarkCountServiceGrpcAdapterMMCA.ADC.Engagement.Contracts 2CurrentEventSelector, TestEventBookmarkCountService, IBookmarkCountService
9ADCHomeMMCA.ADC.Conference.UI9ADCCollectionResult, ADCEventInfo, ADCSponsorCollectionResult, ADCSponsorInfo, ConferenceTrackInfo, CurrentEventSelector, EventPhase, KeynoteSpeakerInfo, SponsorTierBookmarkManagementDomainServiceMMCA.ADC.Engagement.Domain3IBookmarkManagementDomainService, Result, UserSessionBookmark
9PublicSessionDetailMMCA.ADC.Conference.UI17BookmarkService, ConferenceRoutePaths, ICategoryItemLookupService, IHapticFeedbackService, IRoomUIService, ISessionBookmarkUIService, ISessionLiveUIService, ISessionUIService, ISpeakerLookupService, ITextToSpeechService, RoomDTO, RoomService, Session, SessionDTO, SessionLive, SessionService, SeverityLivePollTestsMMCA.ADC.Engagement.Domain.Tests6DomainEntityState, LivePoll, LivePollChanged, LivePollInvariants, LivePollOption, LivePollStatus
9PublicSpeakerListMMCA.ADC.Conference.UI12ConferenceReadAudience, ConferenceRoutePaths, CurrentEventSelector, DataGridListPageBase<TDto>, EventInfo, EventLookupService, IEventLookupService, ISpeakerUIService, ListPageActions, MobileInfiniteScrollList<TItem>, SpeakerDTO, SpeakerServiceLivePollConfigurationMMCA.ADC.Engagement.Infrastructure3EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, LivePoll, LivePollInvariants
9PublicSponsorListMMCA.ADC.Conference.UI7CurrentEventSelector, EventLookupService, IEventLookupService, ISponsorUIService, SponsorDTO, SponsorService, SponsorTierLivePollOptionConfigurationMMCA.ADC.Engagement.Infrastructure3EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, LivePollInvariants, LivePollOption
9RoomListMMCA.ADC.Conference.UI12ConferenceRoutePaths, CurrentEventSelector, DataGridListPageBase<TDto>, ErrorMessages, EventInfo, EventLookupService, IEventLookupService, IRoomUIService, ListPageActions, MobileInfiniteScrollList<TItem>, RoomDTO, RoomServiceBookmarkCountsGrpcServiceMMCA.ADC.Engagement.Service2BookmarkCountService, IBookmarkCountService
9SessionDetailMMCA.ADC.Conference.UI23CategoryItemInfo, CategoryItemLookupService, ConferenceRoutePaths, ErrorMessages, EventInfo, EventLookupService, ICategoryItemLookupService, IEventLookupService, IRoomUIService, ISessionCategoryItemUIService, ISessionSpeakerUIService, ISessionUIService, ISpeakerLookupService, RoomDTO, RoomService, Session, SessionCategoryItemService, SessionDTO, SessionService, SessionSpeakerService …(+3)CheckInScopeNamesMMCA.ADC.Engagement.Shared4CheckInScope, Event, Session, Sponsor
9SessionSelectionDashboardMMCA.ADC.Conference.UI11ConferenceRoutePaths, CurrentEventSelector, EventInfo, EventLookupService, IEventLookupService, ISessionSelectionUIService, ScorePollSignal, ScorePollTracker, SessionSelectionDashboardDTO, SessionSelectionFilterOptions, SeverityPointsSettingsMMCA.ADC.Engagement.Shared4EventFeedback, PointsActivityType, SessionFeedback, SponsorVisit
9SpeakerDashboardMMCA.ADC.Conference.UI13CurrentEventSelector, Email, EventInfo, EventLookupService, IEventLookupService, ISpeakerDashboardUIService, ISpeakerUIService, SessionDTO, SessionFeedbackDTO, Severity, Speaker, SpeakerDTO, SpeakerServiceLiveEventServiceMMCA.ADC.Engagement.UI5CurrentEventSelector, EventDTO, ILiveEventUIService, LiveEventContext, PagedCollectionResult<T>
9SpeakerListMMCA.ADC.Conference.UI12ConferenceRoutePaths, CurrentEventSelector, DataGridListPageBase<TDto>, ErrorMessages, EventInfo, EventLookupService, IEventLookupService, ISpeakerUIService, ListPageActions, MobileInfiniteScrollList<TItem>, SpeakerDTO, SpeakerService
EventFeedbackTestsMMCA.ADC.Engagement.UI.Tests 9SponsorCreateMMCA.ADC.Conference.UI11ConferenceRoutePaths, CurrentEventSelector, ErrorMessages, EventInfo, EventLookupService, IEventLookupService, ISponsorUIService, Severity, SponsorDTO, SponsorService, SponsorTierBunitComponentTestBase, EventFeedback, EventInfo, EventQuestionAnswerDTO, IEventFeedbackUIService, IEventLookupService, IQuestionLookupService, Question, QuestionDTO
9SponsorDetailMMCA.ADC.Conference.UI11ConferenceRoutePaths, ErrorMessages, EventInfo, EventLookupService, IEventLookupService, ISponsorUIService, Severity, Sponsor, SponsorDTO, SponsorService, SponsorTierSessionFeedbackPartialSubmitTestsMMCA.ADC.Engagement.UI.Tests10BunitComponentTestBase, IEntityService<TEntityDTO, TIdentifierType>, IQuestionLookupService, ISessionFeedbackUIService, Question, QuestionDTO, SessionDTO, SessionFeedback, SessionQuestionAnswerDTO, Severity
9SponsorListMMCA.ADC.Conference.UI13ConferenceRoutePaths, CurrentEventSelector, DataGridListPageBase<TDto>, ErrorMessages, EventInfo, EventLookupService, IEventLookupService, ISponsorUIService, ListPageActions, MobileInfiniteScrollList<TItem>, SponsorDTO, SponsorService, SponsorTier
SessionFeedbackTestsMMCA.ADC.Engagement.UI.Tests 9EventDetailTestsMMCA.ADC.Conference.UI.Tests5BunitTestBase, EventDetail, EventDTO, IEventUIService, QuestionModerationDefaultBunitComponentTestBase, IEntityService<TEntityDTO, TIdentifierType>, IQuestionLookupService, ISessionFeedbackUIService, Question, QuestionDTO, SessionDTO, SessionFeedback, SessionQuestionAnswerDTO
9ManagementRouteAuthorizationTestsMMCA.ADC.Conference.UI.Tests2PublicEventDetail, RouteAuthorizationTestsBaseSessionLiveModerationPanelTestsMMCA.ADC.Engagement.UI.Tests11BunitComponentTestBase, CreateLivePollRequest, ILivePollUIService, ISessionQuestionUIService, LivePollDTO, LivePollResultsDTO, LivePollStatus, Question, QuestionStatus, SessionLiveModerationPanel, SessionQuestionDTO
9PublicEventDetailTestsMMCA.ADC.Conference.UI.Tests6BunitTestBase, EventDTO, IClipboardService, IEventUIService, IMapNavigationService, PublicEventDetailSessionReminderPlannerTestsMMCA.ADC.Engagement.UI.Tests3Session, SessionInfo, SessionReminderPlanner
9PublicSessionListViewBookmarkTestsMMCA.ADC.Conference.UI.Tests7BunitTestBase, ISessionBookmarkUIService, PublicSessionListView, Session, SessionDTO, Severity, UserSessionBookmarkDTOUsersControllerMMCA.ADC.Identity.API18ApiControllerBase, DeleteUserCommand, Error, ExportUserDataQuery, GetUserAvatarQuery, GetUsersQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IdentityPermissions, IQueryHandler<in TQuery, TResult>, PagedCollectionResult<T>, RemoveUserAvatarCommand, Result, Route, SetUserAvatarCommand, UserAvatarDTO, UserDataExportDTO, UserListDTO
9PublicSpeakerDetailTestsMMCA.ADC.Conference.UI.TestsChangePasswordHandlerMMCA.ADC.Identity.Application 5BunitTestBase, ISessionUIService, ISpeakerUIService, PublicSpeakerDetail, SpeakerDTOChangePasswordCommand, ChangePasswordHandlerBase<TUser, TCommand>, IPasswordHasher, IUnitOfWork, User
9QuestionDetailTestsMMCA.ADC.Conference.UI.Tests5BunitTestBase, IQuestionUIService, Question, QuestionDetail, QuestionDTOChangePreferencesHandlerMMCA.ADC.Identity.Application4ChangePreferencesCommand, ChangePreferencesHandlerBase<TUser, TCommand>, IUnitOfWork, User
9RoomDetailTestsMMCA.ADC.Conference.UI.Tests6BunitTestBase, EventInfo, IEventLookupService, IRoomUIService, RoomDetail, RoomDTODeleteUserHandlerMMCA.ADC.Identity.Application10DeleteUserCommand, DeleteUserHandlerBase<TUser, TCommand>, ICacheService, IFileStorageService, IUnitOfWork, Result, SoftDeletedUserCache, User, UserDeleted, UserRole
9SpeakerDashboardServiceTestsMMCA.ADC.Conference.UI.Tests11CapturingHttpMessageHandler, DomainInvariantViolationException, HttpTestDoubles, PagedCollectionResult<T>, PaginationMetadata, RatingQuestionSummary, Session, SessionDTO, SessionFeedbackDTO, SessionSpeakerDTO, SpeakerDashboardServiceExportUserDataHandlerMMCA.ADC.Identity.Application8Email, ExportUserDataHandlerBase<TUser, TQuery>, ExportUserDataQuery, IUnitOfWork, IUserDataExportSection, User, UserDataExportSubjectDTO, UserRole
9LivePollsControllerMMCA.ADC.Engagement.API25ApiControllerBase, AuthorizationPolicies, CastVoteCommand, CastVoteRequest, CloseLivePollCommand, CreateLivePollCommand, CreateLivePollRequest, DeleteEntityCommand<TEntity, TIdentifierType>, EngagementFeatures, EngagementPermissions, Error, GetEventPollsQuery, GetOpenPollsQuery, GetPollResultsQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IQueryHandler<in TQuery, TResult>, LifecycleTransitionRequest, LivePoll, LivePollDTO …(+5)GetUserPreferencesHandlerMMCA.ADC.Identity.Application3GetUserPreferencesHandlerBase<TUser>, IUnitOfWork, User
9SessionQuestionsControllerMMCA.ADC.Engagement.API19ApiControllerBase, AuthorizationPolicies, EngagementFeatures, Error, GetModerationQueueQuery, GetSessionQuestionsQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IQueryHandler<in TQuery, TResult>, LifecycleTransitionRequest, ModerateQuestionCommand, ModerationAction, Result, RoleNames, Route, SessionQuestionDTO, SubmitQuestionCommand, SubmitQuestionRequest, ToggleUpvoteCommandRemoveUserAvatarHandlerMMCA.ADC.Identity.Application8Error, ICommandHandler<in TCommand, TResult>, IFileStorageService, IUnitOfWork, RemoveUserAvatarCommand, Result, SetUserAvatarHandler, User
9ControllerMocksMMCA.ADC.Engagement.API.Tests46AttendanceStatsDTO, CastVoteCommand, CheckInAttendeeRequest, CheckInResultDTO, CloseLivePollCommand, CreateBookmarkRequest, CreateLivePollCommand, DeleteEntityCommand<TEntity, TIdentifierType>, GetAttendanceStatsQuery, GetBookmarkedSessionIdsQuery, GetEventPollsQuery, GetLeaderboardQuery, GetModerationQueueQuery, GetMyPointsQuery, GetOpenPollsQuery, GetOrCreateMyBadgeCommand, GetPointsOverviewQuery, GetPollResultsQuery, GetSessionQuestionsQuery, GetUserBookmarksQuery …(+26)ResetPasswordHandlerMMCA.ADC.Identity.Application7ILoginProtectionService, IPasswordHasher, IPasswordResetTokenService, IUnitOfWork, ResetPasswordCommand, ResetPasswordHandlerBase<TUser, TCommand>, User
9CloseLivePollHandlerMMCA.ADC.Engagement.Application12CloseLivePollCommand, Error, ICommandHandler<in TCommand, TResult>, IEventLiveValidationService, ILiveChannelPublishQueue, IUnitOfWork, LiveChannelPublishWorkItem, LivePoll, LivePollAuthorization, LivePollChannel, LivePollClosedPayload, ResultFakesMMCA.ADC.Identity.Application.Tests3InMemoryRepository<TEntity, TIdentifierType>, RecordingUnitOfWork, User
9CreateBookmarkHandlerMMCA.ADC.Engagement.ApplicationSetUserAvatarHandlerTestsMMCA.ADC.Identity.Application.Tests 11CreateBookmarkRequest, DuplicateKeyDetection, Error, IBookmarkManagementDomainService, ICommandHandler<in TCommand, TResult>, ISessionBookmarkValidationService, IUnitOfWork, Result, UserSessionBookmark, UserSessionBookmarkDTO, UserSessionBookmarkDTOMapper
9GetModerationQueueHandlerMMCA.ADC.Engagement.Application10GetModerationQueueQuery, IEventLiveValidationService, IQueryableExecutor, IQueryHandler<in TQuery, TResult>, IUnitOfWork, LivePollAuthorization, Result, SessionQuestion, SessionQuestionDTO, SessionQuestionViewBuilderError, IFileStorageService, IImageProcessor, ImageContentSniffer, IRepository<TEntity, TIdentifierType>, IUnitOfWork, Result, SetUserAvatarCommand, SetUserAvatarHandler, User, UserRole
9GetMyPointsHandlerMMCA.ADC.Engagement.Application11Error, GetMyPointsQuery, ICurrentUserService, IQueryHandler<in TQuery, TResult>, IUnitOfWork, LeaderboardOptIn, MyPointsDTO, PagingMath, PointsEntry, PointsEntryDTO, ResultSoftDeletedUserValidatorTestsMMCA.ADC.Identity.Application.Tests4IRepository<TEntity, TIdentifierType>, IUnitOfWork, SoftDeletedUserValidator<TUser>, User
9GetOrCreateMyBadgeHandlerMMCA.ADC.Engagement.Application10AttendeeBadge, DuplicateKeyDetection, Error, GetOrCreateMyBadgeCommand, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IRepository<TEntity, TIdentifierType>, IUnitOfWork, MyBadgeDTO, ResultUserDTOMapperTestsMMCA.ADC.Identity.Application.Tests3User, UserDTOMapper, UserRole
9GetSessionQuestionsHandlerMMCA.ADC.Engagement.Application10GetSessionQuestionsQuery, IQueryableExecutor, IQueryHandler<in TQuery, TResult>, IUnitOfWork, QuestionStatus, Result, SessionQuestion, SessionQuestionDTO, SessionQuestionUpvote, SessionQuestionViewBuilderAttendeeQueryServiceGrpcAdapterMMCA.ADC.Identity.Contracts2AttendeeQueryService, IAttendeeQueryService
9GetUserBookmarksHandlerMMCA.ADC.Engagement.Application12GetUserBookmarksQuery, IQueryableExecutor, IQueryHandler<in TQuery, TResult>, ISessionBookmarkValidationService, IUnitOfWork, PagedCollectionResult<T>, PaginationMetadata, PagingMath, Result, UserSessionBookmark, UserSessionBookmarkDTO, UserSessionBookmarkDTOMapperIdentityTestDbContextMMCA.ADC.Identity.Infrastructure.Tests2User, UserConfiguration
9LivePollDTOMapperMMCA.ADC.Engagement.Application3IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>, LivePoll, LivePollDTOAttendeesGrpcServiceMMCA.ADC.Identity.Service2AttendeeQueryService, IAttendeeQueryService
9LivePollResultsBuilderMMCA.ADC.Engagement.Application7IQueryableExecutor, IUnitOfWork, LivePoll, LivePollOptionResultDTO, LivePollResultsDTO, LivePollVote, QuestionDependencyInjectionMMCA.ADC.Notification.Application5ApplicationSettings, AttendeeNotificationRecipientProvider, INotificationRecipientProvider, IUserNotificationExportService, UserNotificationExportService
9OpenLivePollHandlerMMCA.ADC.Engagement.Application12Error, ICommandHandler<in TCommand, TResult>, IEventLiveValidationService, ILiveChannelPublishQueue, IUnitOfWork, LiveChannelPublishWorkItem, LivePoll, LivePollAuthorization, LivePollChannel, LivePollOpenedPayload, OpenLivePollCommand, ResultDependencyInjectionTestsMMCA.ADC.Notification.Application.Tests6ApplicationSettings, AttendeeNotificationRecipientProvider, DependencyInjectionAssert, INotificationRecipientProvider, IUserNotificationExportService, UserNotificationExportService
9SetLeaderboardParticipationHandlerMMCA.ADC.Engagement.Application9DuplicateKeyDetection, Error, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IRepository<TEntity, TIdentifierType>, IUnitOfWork, LeaderboardOptIn, Result, SetLeaderboardParticipationRequestUserNotificationExportServiceGrpcAdapterMMCA.ADC.Notification.Contracts3IUserNotificationExportService, UserNotificationExportItemDTO, UserNotificationExportService
9SubmitQuestionHandlerMMCA.ADC.Engagement.Application19BestEffort, Error, ICommandHandler<in TCommand, TResult>, IEventLiveValidationService, ILiveChannelPublishQueue, IUnitOfWork, LiveChannelPublishWorkItem, LivePollChannel, QuestionModerationDefault, QuestionStatus, Result, SessionQuestion, SessionQuestionApprovedPayload, SessionQuestionChannel, SessionQuestionDTO, SessionQuestionInvariants, SessionQuestionPendingCountChangedPayload, SessionQuestionViewBuilder, SubmitQuestionCommandUserNotificationExportGrpcServiceMMCA.ADC.Notification.Service2IUserNotificationExportService, UserNotificationExportService
9CreateLivePollCommandValidatorTestsMMCA.ADC.Engagement.Application.Tests5CreateLivePollCommand, CreateLivePollCommandValidator, CreateLivePollRequest, LivePollInvariants, QuestionMainActivityMMCA.ADC.UI2Activity, IDeepLinkDispatcher
9HandlerMocksMMCA.ADC.Engagement.Application.Tests6IEventLiveValidationService, ILiveChannelPublishQueue, IRepository<TEntity, TIdentifierType>, IUnitOfWork, LivePoll, LivePollVoteWebAuthenticatorCallbackActivityMMCA.ADC.UI1Activity
9UserSessionBookmarkDTOMapperTestsMMCA.ADC.Engagement.Application.TestsCorrelationIdMiddlewareMMCA.Common.API 2UserSessionBookmark, UserSessionBookmarkDTOMapper
9BookmarkCountServiceGrpcAdapterMMCA.ADC.Engagement.Contracts2BookmarkCountService, IBookmarkCountService
9BookmarkManagementDomainServiceMMCA.ADC.Engagement.Domain3IBookmarkManagementDomainService, Result, UserSessionBookmark
9LivePollTestsMMCA.ADC.Engagement.Domain.Tests6DomainEntityState, LivePoll, LivePollChanged, LivePollInvariants, LivePollOption, LivePollStatus
9LivePollConfigurationMMCA.ADC.Engagement.Infrastructure3EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, LivePoll, LivePollInvariants
9LivePollOptionConfigurationMMCA.ADC.Engagement.Infrastructure3EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, LivePollInvariants, LivePollOption
9BookmarkCountsGrpcServiceMMCA.ADC.Engagement.Service2BookmarkCountService, IBookmarkCountService
9CheckInScopeNamesMMCA.ADC.Engagement.Shared4CheckInScope, Event, Session, Sponsor
9PointsSettingsMMCA.ADC.Engagement.Shared4EventFeedback, PointsActivityType, SessionFeedback, SponsorVisit
9LiveEventServiceMMCA.ADC.Engagement.UI5CurrentEventSelector, EventDTO, ILiveEventUIService, LiveEventContext, PagedCollectionResult<T>
9EventFeedbackTestsMMCA.ADC.Engagement.UI.Tests9BunitComponentTestBase, EventFeedback, EventInfo, EventQuestionAnswerDTO, IEventFeedbackUIService, IEventLookupService, IQuestionLookupService, Question, QuestionDTO
9SessionFeedbackPartialSubmitTestsMMCA.ADC.Engagement.UI.Tests10BunitComponentTestBase, IEntityService<TEntityDTO, TIdentifierType>, IQuestionLookupService, ISessionFeedbackUIService, Question, QuestionDTO, SessionDTO, SessionFeedback, SessionQuestionAnswerDTO, Severity
9SessionFeedbackTestsMMCA.ADC.Engagement.UI.Tests9BunitComponentTestBase, IEntityService<TEntityDTO, TIdentifierType>, IQuestionLookupService, ISessionFeedbackUIService, Question, QuestionDTO, SessionDTO, SessionFeedback, SessionQuestionAnswerDTO
9SessionLiveModerationPanelTestsMMCA.ADC.Engagement.UI.Tests11BunitComponentTestBase, CreateLivePollRequest, ILivePollUIService, ISessionQuestionUIService, LivePollDTO, LivePollResultsDTO, LivePollStatus, Question, QuestionStatus, SessionLiveModerationPanel, SessionQuestionDTO
9SessionReminderPlannerTestsMMCA.ADC.Engagement.UI.Tests3Session, SessionInfo, SessionReminderPlanner
9UsersControllerMMCA.ADC.Identity.API18ApiControllerBase, DeleteUserCommand, Error, ExportUserDataQuery, GetUserAvatarQuery, GetUsersQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IdentityPermissions, IQueryHandler<in TQuery, TResult>, PagedCollectionResult<T>, RemoveUserAvatarCommand, Result, Route, SetUserAvatarCommand, UserAvatarDTO, UserDataExportDTO, UserListDTO
9AuthenticationServiceMMCA.ADC.Identity.Application18AuthenticationResponse, AuthenticationServiceBase<TUser>, AuthenticationValidators, Email, Error, IAuthenticationService, IExternalLoginEmailVerifier, ILoginProtectionService, IPasswordHasher, ITokenService, IUnitOfWork, RegisterRequest, Result, TokenService, UnitOfWork, User, UserRegistered, UserRole
9ChangePasswordHandlerMMCA.ADC.Identity.Application5ChangePasswordCommand, ChangePasswordHandlerBase<TUser, TCommand>, IPasswordHasher, IUnitOfWork, User
9ChangePreferencesHandlerMMCA.ADC.Identity.Application4ChangePreferencesCommand, ChangePreferencesHandlerBase<TUser, TCommand>, IUnitOfWork, User
9DeleteUserHandlerMMCA.ADC.Identity.Application10DeleteUserCommand, DeleteUserHandlerBase<TUser, TCommand>, ICacheService, IFileStorageService, IUnitOfWork, Result, SoftDeletedUserCache, User, UserDeleted, UserRole
9ExportUserDataHandlerMMCA.ADC.Identity.Application8Email, ExportUserDataHandlerBase<TUser, TQuery>, ExportUserDataQuery, IUnitOfWork, IUserDataExportSection, User, UserDataExportSubjectDTO, UserRole
9GetUserPreferencesHandlerMMCA.ADC.Identity.Application3GetUserPreferencesHandlerBase<TUser>, IUnitOfWork, User
9RemoveUserAvatarHandlerMMCA.ADC.Identity.Application8Error, ICommandHandler<in TCommand, TResult>, IFileStorageService, IUnitOfWork, RemoveUserAvatarCommand, Result, SetUserAvatarHandler, User
9FakesMMCA.ADC.Identity.Application.Tests3InMemoryRepository<TEntity, TIdentifierType>, RecordingUnitOfWork, User
9SetUserAvatarHandlerTestsMMCA.ADC.Identity.Application.Tests11Error, IFileStorageService, IImageProcessor, ImageContentSniffer, IRepository<TEntity, TIdentifierType>, IUnitOfWork, Result, SetUserAvatarCommand, SetUserAvatarHandler, User, UserRole
9SoftDeletedUserValidatorTestsMMCA.ADC.Identity.Application.Tests4IRepository<TEntity, TIdentifierType>, IUnitOfWork, SoftDeletedUserValidator<TUser>, User
9UserDTOMapperTestsMMCA.ADC.Identity.Application.Tests3User, UserDTOMapper, UserRole
9AttendeeQueryServiceGrpcAdapterMMCA.ADC.Identity.Contracts2AttendeeQueryService, IAttendeeQueryService
9IdentityTestDbContextMMCA.ADC.Identity.Infrastructure.Tests2User, UserConfiguration
9AttendeesGrpcServiceMMCA.ADC.Identity.Service2AttendeeQueryService, IAttendeeQueryService
9DependencyInjectionMMCA.ADC.Notification.Application5ApplicationSettings, AttendeeNotificationRecipientProvider, INotificationRecipientProvider, IUserNotificationExportService, UserNotificationExportService
9DependencyInjectionTestsMMCA.ADC.Notification.Application.Tests6ApplicationSettings, AttendeeNotificationRecipientProvider, DependencyInjectionAssert, INotificationRecipientProvider, IUserNotificationExportService, UserNotificationExportService
9UserNotificationExportServiceGrpcAdapterMMCA.ADC.Notification.Contracts3IUserNotificationExportService, UserNotificationExportItemDTO, UserNotificationExportService
9UserNotificationExportGrpcServiceMMCA.ADC.Notification.Service2IUserNotificationExportService, UserNotificationExportServiceActivity, ICorrelationContext
9
9TestForgotPasswordHandlerMMCA.Common.Application.Tests8Email, ForgotPasswordHandlerBase<TUser, TCommand>, IEmailSender, IPasswordResetTokenService, IUnitOfWork, PasswordResetSettings, TestForgotPasswordCommand, TestIdentityUser
9 TestGetUserPreferencesHandler MMCA.Common.Application.Tests 3
9TestResetPasswordHandlerMMCA.Common.Application.Tests7ILoginProtectionService, IPasswordHasher, IPasswordResetTokenService, IUnitOfWork, ResetPasswordHandlerBase<TUser, TCommand>, TestIdentityUser, TestResetPasswordCommand
9 TransactionalCommandDecoratorTests MMCA.Common.Application.Tests 6
9GatewayCorrelationMiddlewareMMCA.Common.Aspire1Activity
9OutboxPollFilterProcessorMMCA.Common.Aspire1Activity
9 CurrentUserService MMCA.Common.Infrastructure 2
9DbContextFactoryOutboxMessage MMCA.Common.Infrastructure18ApplicationDbContext, CosmosDbContext, DataSource, DataSourceKey, DomainEventSaveChangesInterceptor, ICurrentUserService, IDataSourceResolver, IDbContextFactory, IdentityInsertGroup, IEntityDataSourceRegistry, IPhysicalDbContextFactory, ITenantContext, PhysicalDataSource, Result, SQLServerDbContext, TenancySettings, TenancySettingsValidator, TransactionCommitAmbiguousException
9EFRepository<TEntity, TIdentifierType>MMCA.Common.Infrastructure9ApplicationDbContext, AuditableBaseEntity<TIdentifierType>, EFReadRepository<TEntity, TIdentifierType>, IAuditableEntity, ICurrentUserService, IRepository<TEntity, TIdentifierType>, IRowVersioned, IUpdatePropertySetter<TEntity>, UpdatePropertySetterBuilder<TEntity>
9IdentityModuleDbSeederBase<TUser>MMCA.Common.Infrastructure9AuditableAggregateRootEntity<TIdentifierType>, DbSeeder, Email, IPasswordHasher, IUnitOfWork, PasswordHasher, Result, SeedAccount, UnitOfWork
9AddAuditTrailTestsMMCA.Common.Infrastructure.Tests6AuditTrailCleanupJob, AuditTrailReader, AuditTrailSaveChangesInterceptor, AuditTrailSettings, IAuditTrailReader, IScheduledJob
9AddScheduledJobsTestsMMCA.Common.Infrastructure.Tests5FirstJob, IScheduledJob, ScheduledJobRunner, SchedulerSettings, SecondJob
9ApplicationDbContextEFFactoryTestsMMCA.Common.Infrastructure.Tests5ApplicationDbContextEFFactory, CosmosDbContext, IDbContextFactory, SqliteDbContext, SQLServerDbContext
9AuditTrailSaveChangesInterceptorTestsMMCA.Common.Infrastructure.Tests13AuditedThing, AuditTrailEntry, AuditTrailSaveChangesInterceptor, AuditTrailTestContext, AuditTrailTestHarness, CompositeKeyThing, Email, FakeTimeProvider, InboxMessage, OutboxMessage, PiiRedactor, PlainThing, ScheduledJobEntry
9BrokerEventBusTestsMMCA.Common.Infrastructure.Tests19ApplicationDbContext, AuditSaveChangesInterceptor, BrokerEventBus, DataSource, DataSourceKey, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDataSourceResolver, IDbContextFactory, IDomainEventDispatcher, IEntityDataSourceRegistry, IIntegrationEvent, IOutboxSignal, Mocks, OutboxMessage, OutboxSettings, TestIntegrationEvent, TestNonOutboxContext, TestOutboxContext
9BrokerMessageBusTestsMMCA.Common.Infrastructure.Tests5BrokerMessageBus, IIntegrationEvent, Mocks, OtherIntegrationEvent, TestIntegrationEvent2Activity, IDomainEvent
9
9CronosNextOccurrenceTestsMMCA.Common.Infrastructure.Tests1ScheduledJobRunner
9DependencyInjectionBrokerMessagingTestsMMCA.Common.Infrastructure.Tests4EfInboxStore, IInboxStore, InboxDisabledWarningService, NoOpInboxStore
9DependencyInjectionInfrastructureTestsMMCA.Common.Infrastructure.Tests16AuditSaveChangesInterceptor, ConnectionStringSettings, DomainEventSaveChangesInterceptor, EntityConfigurationOptions, IConnectionStringSettings, IDataSourceService, IEntityConfigurationAssemblyProvider, IJwtSettings, IQueryableExecutor, IRepository<TEntity, TIdentifierType>, IRepositoryFactory, ISmtpSettings, IUnitOfWork, OutboxProcessor, OutboxSettings, SmtpSettings
9DesignTimeDbContextHelperTestsMMCA.Common.Infrastructure.Tests8ConnectionStringSettings, DataSource, DataSourceEntrySettings, DataSourceKey, DesignAlphaEntity, DesignBetaEntity, DesignTimeDbContextHelper, DesignTimeDbContextOptions
9EfInboxStoreTestsMMCA.Common.Infrastructure.Tests16ApplicationDbContext, AuditSaveChangesInterceptor, DataSource, DataSourceKey, DomainEventSaveChangesInterceptor, EfInboxStore, EmptyEntityDataSourceRegistry, IDataSourceResolver, IDbContextFactory, IDomainEventDispatcher, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, InboxMessage, InboxTestDbContext, IOutboxSignal, OutboxSettings
9InProcessEventBusOutboxTestsMMCA.Common.Infrastructure.Tests11DataSource, DataSourceKey, IDataSourceResolver, IDbContextFactory, IDomainEvent, IDomainEventDispatcher, InProcessEventBus, OutboxMessage, OutboxSettings, TestIntegrationEvent, TestOutboxContext
9InProcessEventBusTestsMMCA.Common.Infrastructure.Tests10DataSource, DataSourceKey, IDataSourceResolver, IDbContextFactory, IDomainEvent, IDomainEventDispatcher, IIntegrationEvent, InProcessEventBus, OutboxSettings, TestNonOutboxContext
9InProcessMessageBusTestsMMCA.Common.Infrastructure.Tests11DomainEventDispatcher, IDomainEvent, IDomainEventDispatcher, IDomainEventHandler<in TDomainEvent>, IIntegrationEvent, IIntegrationEventHandler<in TIntegrationEvent>, InProcessMessageBus, Mocks, RecordingDomainHandler, RecordingIntegrationHandler, TestIntegrationEvent
9MocksMMCA.Common.Infrastructure.Tests6IDataSourceResolver, IDataSourceService, IDbContextFactory, IEntityDataSourceRegistry, IRepositoryFactory, OutboxCleanupService
9 NullUserService MMCA.Common.Infrastructure.Tests 1
9OutboxProcessorTestsMMCA.Common.Infrastructure.Tests23AuditSaveChangesInterceptor, BrokerResilienceDefaults, DataSource, DataSourceKey, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, FakeTimeProvider, IDataSourceResolver, IDbContextFactory, IDomainEvent, IDomainEventDispatcher, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, IIntegrationEvent, IMessageBus, IOutboxSignal, OutboxCycleResult, OutboxMessage, OutboxProcessor, OutboxSettings …(+3)
9OutboxProcessorWaitTestsMMCA.Common.Infrastructure.Tests1OutboxProcessor
9 PushNotificationTestDbContext MMCA.Common.Infrastructure.Tests 1
9ScheduledJobRunnerTestsMMCA.Common.Infrastructure.Tests10ApplicationDbContext, DataSource, DelegateScheduledJob, FakeTimeProvider, IDataSourceResolver, ScheduledJobEntry, ScheduledJobOverrideSettings, ScheduledJobRunner, SchedulerSettings, SchedulerTestContext
9SchedulerTestHarnessMMCA.Common.Infrastructure.Tests9ApplicationDbContext, DataSource, DataSourceKey, FakeTimeProvider, IDataSourceResolver, IDbContextFactory, IScheduledJob, ScheduledJobRunner, SchedulerSettings
9 SqliteTestDbContext MMCA.Common.Infrastructure.Tests 2
9TenantDataSourceTargetTestsMMCA.Common.Infrastructure.Tests14DataSource, DataSourceKey, IDataSourceResolver, IEntityDataSourceRegistry, IOutboxSignal, MessageBusSettings, OutboxCleanupService, OutboxProcessor, OutboxSettings, TenancySettings, TenantDataSourceOverrideSettings, TenantDataSourceTarget, TenantDataSourceTargets, TenantEntrySettings
9HandlerTestBase<THandler>MMCA.Common.Testing6AuditableAggregateRootEntity<TIdentifierType>, AuditableBaseEntity<TIdentifierType>, IReadRepository<TEntity, TIdentifierType>, IRepository<TEntity, TIdentifierType>, IUnitOfWork, UnitOfWork
9 GalleryHost MMCA.Common.UI.Gallery 16
10ConcurrencyConventionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, ConcurrencyConventionTestsBase, IArchitectureMapActivitiesControllerMMCA.ADC.Conference.API20Activity, ActivityCreateRequest, ActivityDTO, ActivityUpdateRequest, AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest>, BaseLookup<TIdentifierType>, CollectionResult<T>, ConferencePermissions, DeleteEntityCommand<TEntity, TIdentifierType>, GetPublicActivityFilterQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, IQueryHandler<in TQuery, TResult>, PagedCollectionResult<T>, QueryFilterModelBinder, Result, Route, Specification<TEntity, TIdentifierType>, UpdateActivityCommand
10ConstructorDependencyCountTestsMMCA.ADC.Architecture.TestsConferenceModuleSeederMMCA.ADC.Conference.API 3AdcArchitectureMap, ConstructorDependencyCountTestsBase, IArchitectureMapConferenceModuleDbSeeder, IModuleSeeder, IUnitOfWork
10ControllerConventionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, ControllerConventionTestsBase, IArchitectureMapEventsControllerMMCA.ADC.Conference.API28AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest>, BaseLookup<TIdentifierType>, CollectionResult<T>, ConferencePermissions, DeleteEntityCommand<TEntity, TIdentifierType>, Event, EventCreateRequest, EventDTO, EventTransitionRequest, EventUpdateRequest, ExportEventCalendarQuery, GetNowNextQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, IQueryHandler<in TQuery, TResult>, NowNextDTO, PagedCollectionResult<T>, PublishedEventSpecification, PublishEventCommand …(+8)
10DataResidencyTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, DataResidencyTestsBase, IArchitectureMapSessionCategoryItemsControllerMMCA.ADC.Conference.API19AddSessionCategoryItemCommand, AddSessionCategoryItemRequest, BaseLookup<TIdentifierType>, CollectionResult<T>, ConferencePermissions, EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>, GetPublicSessionCategoryItemFilterQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, IQueryHandler<in TQuery, TResult>, PagedCollectionResult<T>, QueryFilterModelBinder, RemoveSessionCategoryItemCommand, Result, Route, SessionCategoryItem, SessionCategoryItemDTO, Specification<TEntity, TIdentifierType>
10DomainPurityTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, DomainPurityTestsBase, IArchitectureMapSessionQuestionAnswersControllerMMCA.ADC.Conference.API20AddSessionQuestionAnswerCommand, AddSessionQuestionAnswerRequest, AuthorizationPolicies, BaseLookup<TIdentifierType>, CollectionResult<T>, EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, OwnedByUserSpecification<TEntity, TIdentifierType>, PagedCollectionResult<T>, QueryFilterModelBinder, RemoveSessionQuestionAnswerCommand, Result, RoleNames, Route, SessionQuestionAnswer, SessionQuestionAnswerDTO, UpdateSessionQuestionAnswerCommand, UpdateSessionQuestionAnswerRequest
10EntityConventionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, EntityConventionTestsBase, IArchitectureMapSessionsControllerMMCA.ADC.Conference.API25AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest>, BaseLookup<TIdentifierType>, CollectionResult<T>, ConferencePermissions, DeleteEntityCommand<TEntity, TIdentifierType>, Event, EventDTO, ExportSessionCalendarQuery, GetPublicSessionFilterQuery, GetSessionsBySpeakerFilterQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, IQueryHandler<in TQuery, TResult>, PagedCollectionResult<T>, QueryFilterModelBinder, Result, Route, Session, SessionCreateRequest …(+5)
10EventConventionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, EventConventionTestsBase, IArchitectureMapSessionSpeakersControllerMMCA.ADC.Conference.API19AddSessionSpeakerCommand, AddSessionSpeakerRequest, BaseLookup<TIdentifierType>, CollectionResult<T>, ConferencePermissions, EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>, GetPublicSessionSpeakerFilterQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, IQueryHandler<in TQuery, TResult>, PagedCollectionResult<T>, QueryFilterModelBinder, RemoveSessionSpeakerCommand, Result, Route, SessionSpeaker, SessionSpeakerDTO, Specification<TEntity, TIdentifierType>
10FormsConventionTestsMMCA.ADC.Architecture.Tests4AdcArchitectureMap, ArchitectureMapBase, FormsConventionTestsBase, IArchitectureMapSponsorsControllerMMCA.ADC.Conference.API20AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest>, BaseLookup<TIdentifierType>, CollectionResult<T>, ConferencePermissions, DeleteEntityCommand<TEntity, TIdentifierType>, GetPublicSponsorFilterQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, IQueryHandler<in TQuery, TResult>, PagedCollectionResult<T>, QueryFilterModelBinder, Result, Route, Specification<TEntity, TIdentifierType>, Sponsor, SponsorCreateRequest, SponsorDTO, SponsorUpdateRequest, UpdateSponsorCommand
10FrameworkVersionConsistencyTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, FrameworkVersionConsistencyTestsBase, IArchitectureMapEventQuestionAnswersControllerTestsMMCA.ADC.Conference.API.Tests18AddEventQuestionAnswerCommand, AddEventQuestionAnswerRequest, CollectionResult<T>, Error, EventQuestionAnswer, EventQuestionAnswerDTO, EventQuestionAnswersController, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, PagedCollectionResult<T>, PaginationMetadata, RemoveEventQuestionAnswerCommand, Result, RoleNames, Specification<TEntity, TIdentifierType>, UpdateEventQuestionAnswerCommand, UpdateEventQuestionAnswerRequest
10HandlerConventionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, HandlerConventionTestsBase, IArchitectureMapEventSpeakersControllerTestsMMCA.ADC.Conference.API.Tests19AddEventSpeakerCommand, AddEventSpeakerRequest, BaseLookup<TIdentifierType>, Error, EventSpeaker, EventSpeakerDTO, EventSpeakersController, GetPublicEventSpeakerFilterQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, InlineSpecification<TEntity, TIdentifierType>, IQueryHandler<in TQuery, TResult>, ISpecification<TEntity, TIdentifierType>, PagedCollectionResult<T>, RemoveEventSpeakerCommand, Result, RoleNames, Specification<TEntity, TIdentifierType>
10HandlerResultConventionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, HandlerResultConventionTestsBase, IArchitectureMapQuestionsControllerTestsMMCA.ADC.Conference.API.Tests11DeleteEntityCommand<TEntity, TIdentifierType>, Error, ICommandHandler<in TCommand, TResult>, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, Question, QuestionCreateRequest, QuestionDTO, QuestionsController, QuestionUpdateRequest, Result, UpdateQuestionCommand
10IdempotencyConventionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, IdempotencyConventionTestsBaseRoomsControllerTestsMMCA.ADC.Conference.API.Tests22AddRoomCommand, AddRoomRequest, BaseLookup<TIdentifierType>, CollectionResult<T>, Error, GetPublicRoomFilterQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, InlineSpecification<TEntity, TIdentifierType>, IQueryHandler<in TQuery, TResult>, ISpecification<TEntity, TIdentifierType>, PagedCollectionResult<T>, RemoveRoomCommand, Result, RoleNames, Room, RoomDTO, RoomsController, Specification<TEntity, TIdentifierType> …(+2)
10ImmutabilityTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, ImmutabilityTestsBase
10IntegrationEventContractTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, IntegrationEventContractTestsBase
10LayerDependencyTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, LayerDependencyTestsBase
10LocalizedTextConventionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, LocalizedTextConventionTestsBase
10MicroserviceExtractionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, MicroserviceExtractionTestsBase
10ModuleIsolationTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, ModuleIsolationTestsBase
10NamingConventionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, NamingConventionTestsBase
10PiiConventionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, PiiConventionTestsBase
10RawQueryableConventionTestsMMCA.ADC.Architecture.Tests4AdcArchitectureMap, ArchitectureMapBase, IArchitectureMap, RawQueryableConventionTestsBase
10SharedLayerTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, SharedLayerTestsBase
10SliceCohesionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, SliceCohesionTestsBase
10SpecificationConventionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, SpecificationConventionTestsBase
10StateManagementConventionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, StateManagementConventionTestsBase
10UIArchitectureConventionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, UIArchitectureConventionTestsBase
10ConferenceModuleSeederMMCA.ADC.Conference.API3ConferenceModuleDbSeeder, IModuleSeeder, IUnitOfWork
10EventsControllerMMCA.ADC.Conference.API28AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest>, BaseLookup<TIdentifierType>, CollectionResult<T>, ConferencePermissions, DeleteEntityCommand<TEntity, TIdentifierType>, Event, EventCreateRequest, EventDTO, EventTransitionRequest, EventUpdateRequest, ExportEventCalendarQuery, GetNowNextQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, IQueryHandler<in TQuery, TResult>, NowNextDTO, PagedCollectionResult<T>, PublishedEventSpecification, PublishEventCommand …(+8)
10SessionCategoryItemsControllerMMCA.ADC.Conference.API19AddSessionCategoryItemCommand, AddSessionCategoryItemRequest, BaseLookup<TIdentifierType>, CollectionResult<T>, ConferencePermissions, EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>, GetPublicSessionCategoryItemFilterQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, IQueryHandler<in TQuery, TResult>, PagedCollectionResult<T>, QueryFilterModelBinder, RemoveSessionCategoryItemCommand, Result, Route, SessionCategoryItem, SessionCategoryItemDTO, Specification<TEntity, TIdentifierType>
10SessionQuestionAnswersControllerMMCA.ADC.Conference.API20AddSessionQuestionAnswerCommand, AddSessionQuestionAnswerRequest, AuthorizationPolicies, BaseLookup<TIdentifierType>, CollectionResult<T>, EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, OwnedByUserSpecification<TEntity, TIdentifierType>, PagedCollectionResult<T>, QueryFilterModelBinder, RemoveSessionQuestionAnswerCommand, Result, RoleNames, Route, SessionQuestionAnswer, SessionQuestionAnswerDTO, UpdateSessionQuestionAnswerCommand, UpdateSessionQuestionAnswerRequest
10SessionsControllerMMCA.ADC.Conference.API26AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest>, AndSpecification<TEntity, TIdentifierType>, BaseLookup<TIdentifierType>, CollectionResult<T>, ConferencePermissions, DeleteEntityCommand<TEntity, TIdentifierType>, Event, EventDTO, ExportSessionCalendarQuery, GetPublicSessionFilterQuery, GetSessionsBySpeakerFilterQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, IQueryHandler<in TQuery, TResult>, PagedCollectionResult<T>, QueryFilterModelBinder, Result, Route, Session …(+6)
10SessionSpeakersControllerMMCA.ADC.Conference.API19AddSessionSpeakerCommand, AddSessionSpeakerRequest, BaseLookup<TIdentifierType>, CollectionResult<T>, ConferencePermissions, EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>, GetPublicSessionSpeakerFilterQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, IQueryHandler<in TQuery, TResult>, PagedCollectionResult<T>, QueryFilterModelBinder, RemoveSessionSpeakerCommand, Result, Route, SessionSpeaker, SessionSpeakerDTO, Specification<TEntity, TIdentifierType>
10SponsorsControllerMMCA.ADC.Conference.API20AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest>, BaseLookup<TIdentifierType>, CollectionResult<T>, ConferencePermissions, DeleteEntityCommand<TEntity, TIdentifierType>, GetPublicSponsorFilterQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, IQueryHandler<in TQuery, TResult>, PagedCollectionResult<T>, QueryFilterModelBinder, Result, Route, Specification<TEntity, TIdentifierType>, Sponsor, SponsorCreateRequest, SponsorDTO, SponsorUpdateRequest, UpdateSponsorCommand
10EventQuestionAnswersControllerTestsMMCA.ADC.Conference.API.Tests18AddEventQuestionAnswerCommand, AddEventQuestionAnswerRequest, CollectionResult<T>, Error, EventQuestionAnswer, EventQuestionAnswerDTO, EventQuestionAnswersController, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, PagedCollectionResult<T>, PaginationMetadata, RemoveEventQuestionAnswerCommand, Result, RoleNames, Specification<TEntity, TIdentifierType>, UpdateEventQuestionAnswerCommand, UpdateEventQuestionAnswerRequest
10EventSpeakersControllerTestsSpeakerCategoryItemsControllerTests MMCA.ADC.Conference.API.Tests 19AddEventSpeakerCommand, AddEventSpeakerRequest, BaseLookup<TIdentifierType>, Error, EventSpeaker, EventSpeakerDTO, EventSpeakersController, GetPublicEventSpeakerFilterQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, InlineSpecification<TEntity, TIdentifierType>, IQueryHandler<in TQuery, TResult>, ISpecification<TEntity, TIdentifierType>, PagedCollectionResult<T>, RemoveEventSpeakerCommand, Result, RoleNames, Specification<TEntity, TIdentifierType>AddSpeakerCategoryItemCommand, AddSpeakerCategoryItemRequest, BaseLookup<TIdentifierType>, Error, GetPublicSpeakerCategoryItemFilterQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, InlineSpecification<TEntity, TIdentifierType>, IQueryHandler<in TQuery, TResult>, ISpecification<TEntity, TIdentifierType>, PagedCollectionResult<T>, RemoveSpeakerCategoryItemCommand, Result, RoleNames, SpeakerCategoryItem, SpeakerCategoryItemDTO, SpeakerCategoryItemsController, Specification<TEntity, TIdentifierType>
10QuestionsControllerTestsSpeakersControllerTests MMCA.ADC.Conference.API.Tests11DeleteEntityCommand<TEntity, TIdentifierType>, Error, ICommandHandler<in TCommand, TResult>, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, Question, QuestionCreateRequest, QuestionDTO, QuestionsController, QuestionUpdateRequest, Result, UpdateQuestionCommand29AndSpecification<TEntity, TIdentifierType>, BaseLookup<TIdentifierType>, DeleteEntityCommand<TEntity, TIdentifierType>, Error, GetPublicSpeakerFilterQuery, GetSessionBookmarkCountQuery, GetSessionBookmarkCountsQuery, GetSessionFeedbackQuery, GetSpeakersByEventFilterQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, InlineSpecification<TEntity, TIdentifierType>, IQueryHandler<in TQuery, TResult>, ISpecification<TEntity, TIdentifierType>, LinkUserRequest, LinkUserToSpeakerCommand, PagedCollectionResult<T>, Result, RoleNames …(+9)
10RoomsControllerTestsMMCA.ADC.Conference.API.Tests12AddRoomCommand, AddRoomRequest, Error, ICommandHandler<in TCommand, TResult>, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, RemoveRoomCommand, Result, Room, RoomDTO, RoomsController, UpdateRoomCommand, UpdateRoomRequestActivityCreateRequestMapperMMCA.ADC.Conference.Application4Activity, ActivityCreateRequest, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, Result
10SpeakerCategoryItemsControllerTestsMMCA.ADC.Conference.API.Tests19AddSpeakerCategoryItemCommand, AddSpeakerCategoryItemRequest, BaseLookup<TIdentifierType>, Error, GetPublicSpeakerCategoryItemFilterQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, InlineSpecification<TEntity, TIdentifierType>, IQueryHandler<in TQuery, TResult>, ISpecification<TEntity, TIdentifierType>, PagedCollectionResult<T>, RemoveSpeakerCategoryItemCommand, Result, RoleNames, SpeakerCategoryItem, SpeakerCategoryItemDTO, SpeakerCategoryItemsController, Specification<TEntity, TIdentifierType>ActivityCreateRequestValidatorMMCA.ADC.Conference.Application9ActivityCreateRequest, ActivityDescriptionRules<T>, ActivityEventIdRules<T>, ActivityNameRules<T>, ActivitySortOrderRules<T>, ActivityTimeRangeRules<T>, ActivityVenueAddressRules<T>, ActivityVenueNameRules<T>, ActivityVenueUrlRules<T>
10SpeakersControllerTestsMMCA.ADC.Conference.API.Tests29AndSpecification<TEntity, TIdentifierType>, BaseLookup<TIdentifierType>, DeleteEntityCommand<TEntity, TIdentifierType>, Error, GetPublicSpeakerFilterQuery, GetSessionBookmarkCountQuery, GetSessionBookmarkCountsQuery, GetSessionFeedbackQuery, GetSpeakersByEventFilterQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, InlineSpecification<TEntity, TIdentifierType>, IQueryHandler<in TQuery, TResult>, ISpecification<TEntity, TIdentifierType>, LinkUserRequest, LinkUserToSpeakerCommand, PagedCollectionResult<T>, Result, RoleNames …(+9)ActivityNavigationPopulatorMMCA.ADC.Conference.Application5Activity, DeclarativeNavigationPopulator<TEntity>, Event, FKNavigationDescriptor<TEntity, TChild, TChildId>, IUnitOfWork
10
10CategoryItemNavigationPopulatorMMCA.ADC.Conference.Application5Category, CategoryItem, DeclarativeNavigationPopulator<TEntity>, FKNavigationDescriptor<TEntity, TChild, TChildId>, IUnitOfWork
10 CategorySyncStrategy MMCA.ADC.Conference.Application 7
10CreateActivityHandlerMMCA.ADC.Conference.Application8Activity, ActivityCreateRequest, ActivityDTO, ActivityDTOMapper, ICommandHandler<in TCommand, TResult>, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, IUnitOfWork, Result
10 CreateEventHandler MMCA.ADC.Conference.Application 8 10 DeleteEventHandler MMCA.ADC.Conference.Application9DeleteEntityCommand<TEntity, TIdentifierType>, Error, Event, ICommandHandler<in TCommand, TResult>, IEventCascadeDeletionDomainService, IUnitOfWork, Result, Session, Sponsor10Activity, DeleteEntityCommand<TEntity, TIdentifierType>, Error, Event, ICommandHandler<in TCommand, TResult>, IEventCascadeDeletionDomainService, IUnitOfWork, Result, Session, Sponsor
10
10EventQuestionAnswerNavigationPopulatorMMCA.ADC.Conference.Application5DeclarativeNavigationPopulator<TEntity>, Event, EventQuestionAnswer, FKNavigationDescriptor<TEntity, TChild, TChildId>, IUnitOfWork
10EventSpeakerNavigationPopulatorMMCA.ADC.Conference.Application5DeclarativeNavigationPopulator<TEntity>, Event, EventSpeaker, FKNavigationDescriptor<TEntity, TChild, TChildId>, IUnitOfWork
10 ExportEventCalendarHandler MMCA.ADC.Conference.Application 9 PublicConferenceVisibility MMCA.ADC.Conference.Application 8AndSpecification<TEntity, TIdentifierType>, CrossSourceSpecification, Event, InlineSpecification<TEntity, TIdentifierType>, IUnitOfWork, PublicSessionStatusSpecification, Session, SessionSpeakerCrossSourceSpecification, Event, IEntityQuerier<TEntity, TIdentifierType>, InlineSpecification<TEntity, TIdentifierType>, IUnitOfWork, PublicSessionStatusSpecification, Session, SessionSpeaker
10
10RoomNavigationPopulatorMMCA.ADC.Conference.Application5DeclarativeNavigationPopulator<TEntity>, Event, FKNavigationDescriptor<TEntity, TChild, TChildId>, IUnitOfWork, Room
10 RoomSyncStrategy MMCA.ADC.Conference.Application4ISessionizeSyncStrategy, Room, SessionizeSyncContext, SessionizeSyncResult9Event, EventInvariants, ISessionizeSyncStrategy, Result, Room, SessionizeRoom, SessionizeSyncContext, SessionizeSyncResult, SessionizeSyncWarnings
10SessionCategoryItemNavigationPopulatorMMCA.ADC.Conference.Application5DeclarativeNavigationPopulator<TEntity>, FKNavigationDescriptor<TEntity, TChild, TChildId>, IUnitOfWork, Session, SessionCategoryItem
10 10 SessionNavigationPopulator MMCA.ADC.Conference.Application7ChildNavigationDescriptor<TEntity, TParentId, TChild, TChildId>, DeclarativeNavigationPopulator<TEntity>, IUnitOfWork, Session, SessionCategoryItem, SessionQuestionAnswer, SessionSpeaker10ChildNavigationDescriptor<TEntity, TParentId, TChild, TChildId>, DeclarativeNavigationPopulator<TEntity>, Event, FKNavigationDescriptor<TEntity, TChild, TChildId>, IUnitOfWork, Room, Session, SessionCategoryItem, SessionQuestionAnswer, SessionSpeaker
10SessionQuestionAnswerNavigationPopulatorMMCA.ADC.Conference.Application5DeclarativeNavigationPopulator<TEntity>, FKNavigationDescriptor<TEntity, TChild, TChildId>, IUnitOfWork, Session, SessionQuestionAnswer
10SessionSpeakerNavigationPopulatorMMCA.ADC.Conference.Application5DeclarativeNavigationPopulator<TEntity>, FKNavigationDescriptor<TEntity, TChild, TChildId>, IUnitOfWork, Session, SessionSpeaker
10
10SpeakerCategoryItemNavigationPopulatorMMCA.ADC.Conference.Application5DeclarativeNavigationPopulator<TEntity>, FKNavigationDescriptor<TEntity, TChild, TChildId>, IUnitOfWork, Speaker, SpeakerCategoryItem
10 SpeakerEntityQueryService MMCA.ADC.Conference.Application 8
10SpeakerQuestionAnswerNavigationPopulatorMMCA.ADC.Conference.Application5DeclarativeNavigationPopulator<TEntity>, FKNavigationDescriptor<TEntity, TChild, TChildId>, IUnitOfWork, Speaker, SpeakerQuestionAnswer
10 SpeakerSyncStrategy MMCA.ADC.Conference.Application 9
10SponsorNavigationPopulatorMMCA.ADC.Conference.Application5DeclarativeNavigationPopulator<TEntity>, Event, FKNavigationDescriptor<TEntity, TChild, TChildId>, IUnitOfWork, Sponsor
10UpdateActivityHandlerMMCA.ADC.Conference.Application8Activity, ActivityDTO, ActivityDTOMapper, Error, ICommandHandler<in TCommand, TResult>, IUnitOfWork, Result, UpdateActivityCommand
10 UpdateEventHandler MMCA.ADC.Conference.Application 9
10AddCategoryItemHandlerTestsActivityDTOMapperTests MMCA.ADC.Conference.Application.Tests8AddCategoryItemCommand, AddCategoryItemHandler, Category, CategoryItemDTOMapper, ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, UnitOfWork2Activity, ActivityDTOMapper
10
10AddEventQuestionAnswerHandlerTestsMMCA.ADC.Conference.Application.Tests10AddEventQuestionAnswerCommand, AddEventQuestionAnswerHandler, ErrorType, Event, EventQuestionAnswerDTOMapper, HandlerTestBase<THandler>, ICurrentUserService, IRepository<TEntity, TIdentifierType>, Question, UnitOfWork
10 AddEventSpeakerCommandValidatorTests MMCA.ADC.Conference.Application.Tests 2
10AddEventSpeakerHandlerTestsMMCA.ADC.Conference.Application.Tests8AddEventSpeakerCommand, AddEventSpeakerHandler, ErrorType, Event, EventSpeakerDTOMapper, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, UnitOfWork
10AddRoomCommandValidatorTestsAddRoomCommandValidatorTests MMCA.ADC.Conference.Application.Tests 3 AddRoomCommand, AddRoomCommandValidator, EventInvariants
10AddRoomHandlerTestsMMCA.ADC.Conference.Application.Tests12AddRoomCommand, AddRoomHandler, ErrorType, Event, EventInvariants, HandlerTestBase<THandler>, IReadRepository<TEntity, TIdentifierType>, IRepository<TEntity, TIdentifierType>, IUnitOfWork, Room, RoomDTOMapper, UnitOfWork
10 AddSpeakerCategoryItemCommandValidatorTests MMCA.ADC.Conference.Application.Tests 2
10AddSpeakerCategoryItemHandlerTestsMMCA.ADC.Conference.Application.Tests8AddSpeakerCategoryItemCommand, AddSpeakerCategoryItemHandler, ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, Speaker, SpeakerCategoryItemDTOMapper, UnitOfWork
10 CalendarExportMapperTests MMCA.ADC.Conference.Application.Tests 4
10CreateConferenceCategoryHandlerTestsMMCA.ADC.Conference.Application.Tests11Category, CategoryItemDTOMapper, ConferenceCategoryCreateRequest, ConferenceCategoryDTOMapper, CreateConferenceCategoryHandler, Error, HandlerTestBase<THandler>, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, IRepository<TEntity, TIdentifierType>, Result, UnitOfWork
10CreateQuestionHandlerTestsMMCA.ADC.Conference.Application.Tests11CreateQuestionHandler, HandlerTestBase<THandler>, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, IRepository<TEntity, TIdentifierType>, IUnitOfWork, Question, QuestionCreateRequest, QuestionDTOMapper, QuestionInvariants, Result, UnitOfWork
10DeleteSessionHandlerTestsMMCA.ADC.Conference.Application.Tests7DeleteEntityCommand<TEntity, TIdentifierType>, DeleteSessionHandler, ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, Session, UnitOfWork
10 EventCreateRequestValidatorTests MMCA.ADC.Conference.Application.Tests 2
10GetCategoryDistributionHandlerTestsMMCA.ADC.Conference.Application.Tests8Category, GetCategoryDistributionHandler, GetCategoryDistributionQuery, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, Session, SessionStatuses, UnitOfWork
10GetContentSimilarityHandlerTestsMMCA.ADC.Conference.Application.Tests8Category, GetContentSimilarityHandler, GetContentSimilarityQuery, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, Session, SessionStatuses, UnitOfWork
10 GetNowNextQueryCacheTests MMCA.ADC.Conference.Application.Tests 3
10GetSessionsBySpeakerFilterHandlerTestsMMCA.ADC.Conference.Application.Tests7GetSessionsBySpeakerFilterHandler, GetSessionsBySpeakerFilterQuery, HandlerTestBase<THandler>, IReadRepository<TEntity, TIdentifierType>, Session, SessionSpeaker, UnitOfWork
10GetSessionSelectionDashboardHandlerTestsMMCA.ADC.Conference.Application.Tests12Category, ErrorType, Event, GetSessionSelectionDashboardHandler, GetSessionSelectionDashboardQuery, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, Session, SessionAiScore, SessionStatuses, Speaker, UnitOfWork
10GetSpeakersByEventFilterHandlerTestsMMCA.ADC.Conference.Application.Tests9EventSpeaker, GetSpeakersByEventFilterHandler, GetSpeakersByEventFilterQuery, HandlerTestBase<THandler>, IReadRepository<TEntity, TIdentifierType>, Session, SessionSpeaker, Speaker, UnitOfWork
10GetSpeakerSessionOverlapHandlerTestsMMCA.ADC.Conference.Application.Tests9Category, GetSpeakerSessionOverlapHandler, GetSpeakerSessionOverlapQuery, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, Session, SessionStatuses, Speaker, UnitOfWork
10LinkUserToSpeakerHandlerTestsMMCA.ADC.Conference.Application.Tests8ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, LinkUserToSpeakerCommand, LinkUserToSpeakerHandler, Speaker, SpeakerLinkedToUser, UnitOfWork
10PublishEventHandlerTestsMMCA.ADC.Conference.Application.Tests7ErrorType, Event, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, PublishEventCommand, PublishEventHandler, UnitOfWork
10 QuestionCreateRequestValidatorTests MMCA.ADC.Conference.Application.Tests 3
10RemoveCategoryItemHandlerTestsMMCA.ADC.Conference.Application.Tests7Category, ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, RemoveCategoryItemCommand, RemoveCategoryItemHandler, UnitOfWork
10RemoveEventQuestionAnswerHandlerTestsMMCA.ADC.Conference.Application.Tests9ErrorType, Event, HandlerTestBase<THandler>, ICurrentUserService, IRepository<TEntity, TIdentifierType>, RemoveEventQuestionAnswerCommand, RemoveEventQuestionAnswerHandler, RoleNames, UnitOfWork
10RemoveEventSpeakerHandlerTestsMMCA.ADC.Conference.Application.Tests7ErrorType, Event, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, RemoveEventSpeakerCommand, RemoveEventSpeakerHandler, UnitOfWork
10RemoveRoomHandlerTestsMMCA.ADC.Conference.Application.Tests7ErrorType, Event, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, RemoveRoomCommand, RemoveRoomHandler, UnitOfWork
10RemoveSpeakerCategoryItemHandlerTestsMMCA.ADC.Conference.Application.Tests7ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, RemoveSpeakerCategoryItemCommand, RemoveSpeakerCategoryItemHandler, Speaker, UnitOfWork
10ScoreEventSessionsHandlerTestsMMCA.ADC.Conference.Application.Tests12HandlerTestBase<THandler>, IAiScoringService, IRepository<TEntity, TIdentifierType>, ScoreEventSessionsCommand, ScoreEventSessionsHandler, Session, SessionAiScore, SessionScoringInput, SessionScoringResult, SessionStatuses, Speaker, UnitOfWork
10SessionBookmarkValidationServiceTestsMMCA.ADC.Conference.Application.Tests6ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, Session, SessionBookmarkValidationService, UnitOfWork
10 SessionCategoryItemDTOMapperTests MMCA.ADC.Conference.Application.Tests 3
10UnlinkUserFromSpeakerHandlerTestsMMCA.ADC.Conference.Application.Tests8ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, Speaker, SpeakerUnlinkedFromUser, UnitOfWork, UnlinkUserFromSpeakerCommand, UnlinkUserFromSpeakerHandler
10UnpublishEventHandlerTestsMMCA.ADC.Conference.Application.Tests7ErrorType, Event, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, UnitOfWork, UnpublishEventCommand, UnpublishEventHandler
10UpdateCategoryItemHandlerTestsMMCA.ADC.Conference.Application.Tests7Category, ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, UnitOfWork, UpdateCategoryItemCommand, UpdateCategoryItemHandler
10UpdateConferenceCategoryHandlerTestsMMCA.ADC.Conference.Application.Tests10Category, CategoryItemDTOMapper, ConferenceCategoryDTOMapper, ConferenceCategoryUpdateRequest, ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, UnitOfWork, UpdateConferenceCategoryCommand, UpdateConferenceCategoryHandler
10UpdateEventQuestionAnswerHandlerTestsMMCA.ADC.Conference.Application.Tests9ErrorType, Event, HandlerTestBase<THandler>, ICurrentUserService, IRepository<TEntity, TIdentifierType>, RoleNames, UnitOfWork, UpdateEventQuestionAnswerCommand, UpdateEventQuestionAnswerHandler
10UpdateQuestionHandlerTestsMMCA.ADC.Conference.Application.Tests13ErrorType, EventQuestionAnswer, HandlerTestBase<THandler>, IReadRepository<TEntity, TIdentifierType>, IRepository<TEntity, TIdentifierType>, Question, QuestionDTOMapper, QuestionUpdateRequest, SessionQuestionAnswer, SpeakerQuestionAnswer, UnitOfWork, UpdateQuestionCommand, UpdateQuestionHandler
10 UpdateRoomCommandValidatorTests MMCA.ADC.Conference.Application.Tests 3
10UpdateRoomHandlerTestsMMCA.ADC.Conference.Application.Tests7ErrorType, Event, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, UnitOfWork, UpdateRoomCommand, UpdateRoomHandler
10 UserRegisteredHandlerTests MMCA.ADC.Conference.Application.Tests 10 10 EventCascadeDeletionDomainService MMCA.ADC.Conference.Domain5Event, IEventCascadeDeletionDomainService, Result, Session, Sponsor6Activity, Event, IEventCascadeDeletionDomainService, Result, Session, Sponsor
10ActivityTestsMMCA.ADC.Conference.Domain.Tests6Activity, ActivityBuilder, ActivityChanged, ActivityInvariants, DomainEntityState, Result
10 10 PublicSessionList MMCA.ADC.Conference.UI18BookmarkService, CachedSessionPage, ConferenceReadAudience, CurrentEventDefaults, DataGridListPageBase<TDto>, EventDTO, EventService, IConnectivityStatusService, IEventUIService, ILocalCacheStore, ISessionBookmarkUIService, ISessionUIService, ISpeakerLookupService, PublicSessionListView, SessionDTO, SessionService, Severity, SpeakerInfo20BookmarkService, CachedSessionPage, ConferenceReadAudience, CurrentEventDefaults, DataGridListPageBase<TDto>, EventDTO, EventService, IConnectivityStatusService, IEventUIService, ILocalCacheStore, ISessionBookmarkUIService, ISessionUIService, ISpeakerLookupService, PublicScheduleRoomOptions, PublicSessionListView, RoomDTO, SessionDTO, SessionService, Severity, SpeakerInfo
10
10ActivityCreateTestsMMCA.ADC.Conference.UI.Tests6ActivityCreate, ActivityDTO, BunitTestBase, EventInfo, IActivityUIService, IEventLookupService
10ActivityDetailTestsMMCA.ADC.Conference.UI.Tests7Activity, ActivityDetail, ActivityDTO, BunitTestBase, EventInfo, IActivityUIService, IEventLookupService
10ADCHomeTestsMMCA.ADC.Conference.UI.Tests2ADCHome, BunitTestBase
10ADCHomeTicketingTestsMMCA.ADC.Conference.UI.Tests4ADCHome, BunitTestBase, CapturingHttpMessageHandler, HttpTestDoubles
10PublicActivityListTestsMMCA.ADC.Conference.UI.Tests6ActivityDTO, BunitTestBase, EventInfo, IActivityUIService, IEventLookupService, PublicActivityList
10PublicEventListRedirectTestsMMCA.ADC.Conference.UI.Tests11BunitTestBase, EventDTO, EventInfo, IEventLookupService, IEventUIService, ListPageQueryStateService, ListPageStateService, MobileInfiniteScrollList<TItem>, PublicEventList, RoleNames, TestPrincipal
10 PublicSessionDetailBookmarkTests MMCA.ADC.Conference.UI.Tests 13
10PublicSpeakerListCardGridTestsMMCA.ADC.Conference.UI.Tests10BunitTestBase, EventInfo, IEventLookupService, InfiniteScrollSentinel, ISpeakerUIService, ListPageQueryStateService, ListPageStateService, PublicSpeakerList, Speaker, SpeakerDTO
10 PublicSpeakerListEventFilterTests MMCA.ADC.Conference.UI.Tests 9 CastVoteHandler MMCA.ADC.Engagement.Application 10CastVoteCommand, Error, ICommandHandler<in TCommand, TResult>, IRepository<TEntity, TIdentifierType>, IUnitOfWork, LivePoll, LivePollResultsBuilder, LivePollResultsDTO, LivePollVote, ResultCastVoteCommand, Error, ICommandHandler<in TCommand, TResult>, IEntityReader<TEntity, TIdentifierType>, IUnitOfWork, LivePoll, LivePollResultsBuilder, LivePollResultsDTO, LivePollVote, Result
10
10LivePollOptionNavigationPopulatorMMCA.ADC.Engagement.Application5DeclarativeNavigationPopulator<TEntity>, FKNavigationDescriptor<TEntity, TChild, TChildId>, IUnitOfWork, LivePoll, LivePollOption
10 LivePollVoteChangedHandler MMCA.ADC.Engagement.Application 9
10BookmarkCountServiceTestsLivePollDTOMapperTests MMCA.ADC.Engagement.Application.Tests5BookmarkCountService, HandlerTestBase<THandler>, InMemoryQueryableExecutor, UnitOfWork, UserSessionBookmark3LivePoll, LivePollDTOMapper, LivePollStatus
10CloseLivePollHandlerTestsLivePollResultsBuilderTests MMCA.ADC.Engagement.Application.Tests16CloseLivePollCommand, CloseLivePollHandler, Error, ErrorType, HandlerMocks, HandlerTestBase<THandler>, IEventLiveValidationService, ILiveChannelPublishQueue, LiveChannelPublishWorkItem, LivePoll, LivePollChannel, LivePollStatus, QuestionModerationDefault, Result, SessionLiveInfo, UnitOfWork
10CreateBookmarkHandlerTestsMMCA.ADC.Engagement.Application.Tests12BookmarkManagementDomainService, CreateBookmarkHandler, CreateBookmarkRequest, Error, ErrorType, HandlerMocks, HandlerTestBase<THandler>, ISessionBookmarkValidationService, Result, UnitOfWork, UserSessionBookmark, UserSessionBookmarkDTOMapper
10EventFeedbackSubmittedPointsHandlerTestsMMCA.ADC.Engagement.Application.Tests9Error, EventFeedbackSubmitted, EventFeedbackSubmittedPointsHandler, HandlerTestBase<THandler>, IPointsAwarder, PointsActivityType, RecordingPointsAwarder, Result, TestSupport
10GetBookmarkedSessionIdsHandlerTestsMMCA.ADC.Engagement.Application.Tests5GetBookmarkedSessionIdsHandler, GetBookmarkedSessionIdsQuery, HandlerTestBase<THandler>, UnitOfWork, UserSessionBookmark
10GetModerationQueueHandlerTestsMMCA.ADC.Engagement.Application.Tests16Error, ErrorType, GetModerationQueueHandler, GetModerationQueueQuery, HandlerMocks, HandlerTestBase<THandler>, IEventLiveValidationService, InMemoryQueryableExecutor, QuestionModerationDefault, QuestionStatus, Result, SessionLiveInfo, SessionQuestion, SessionQuestionUpvote, SessionQuestionViewBuilder, UnitOfWork
10GetMyPointsHandlerTestsMMCA.ADC.Engagement.Application.Tests9ErrorType, GetMyPointsHandler, GetMyPointsQuery, HandlerTestBase<THandler>, ICurrentUserService, LeaderboardOptIn, PointsActivityType, PointsEntry, UnitOfWork
10GetPointsOverviewHandlerTestsMMCA.ADC.Engagement.Application.Tests7GetPointsOverviewHandler, GetPointsOverviewQuery, HandlerTestBase<THandler>, PointsActivityType, PointsEntry, PointsEntryDTO, UnitOfWork
10GetSessionQuestionsHandlerTestsMMCA.ADC.Engagement.Application.Tests9GetSessionQuestionsHandler, GetSessionQuestionsQuery, HandlerTestBase<THandler>, InMemoryQueryableExecutor, QuestionStatus, SessionQuestion, SessionQuestionUpvote, SessionQuestionViewBuilder, UnitOfWork
10GetUserBookmarksHandlerTestsMMCA.ADC.Engagement.Application.Tests11Error, GetUserBookmarksHandler, GetUserBookmarksQuery, HandlerMocks, HandlerTestBase<THandler>, IQueryableExecutor, ISessionBookmarkValidationService, Result, UnitOfWork, UserSessionBookmark, UserSessionBookmarkDTOMapper
10LivePollDTOMapperTestsMMCA.ADC.Engagement.Application.Tests3LivePoll, LivePollDTOMapper, LivePollStatus
10LivePollResultsBuilderTestsMMCA.ADC.Engagement.Application.Tests7AuditableBaseEntity<TIdentifierType>, InMemoryQueryableExecutor, IReadRepository<TEntity, TIdentifierType>, IUnitOfWork, LivePoll, LivePollResultsBuilder, LivePollVote
10ModerateQuestionHandlerTestsMMCA.ADC.Engagement.Application.Tests17Error, ErrorType, HandlerMocks, HandlerTestBase<THandler>, IEventLiveValidationService, ILiveChannelPublishQueue, LiveChannelPublishWorkItem, ModerateQuestionCommand, ModerateQuestionHandler, ModerationAction, QuestionModerationDefault, QuestionStatus, Result, SessionLiveInfo, SessionQuestion, SessionQuestionChannel, UnitOfWork7AuditableBaseEntity<TIdentifierType>, InMemoryQueryableExecutor, IReadRepository<TEntity, TIdentifierType>, IUnitOfWork, LivePoll, LivePollResultsBuilder, LivePollVote
10
10OpenLivePollHandlerTestsMMCA.ADC.Engagement.Application.Tests18Error, ErrorType, EventLiveInfo, FixedTimeProvider, HandlerMocks, HandlerTestBase<THandler>, IEventLiveValidationService, ILiveChannelPublishQueue, LiveChannelPublishWorkItem, LivePoll, LivePollChannel, LivePollStatus, OpenLivePollCommand, OpenLivePollHandler, QuestionModerationDefault, Result, SessionLiveInfo, UnitOfWork
10SessionFeedbackSubmittedPointsHandlerTestsMMCA.ADC.Engagement.Application.Tests9Error, HandlerTestBase<THandler>, IPointsAwarder, PointsActivityType, RecordingPointsAwarder, Result, SessionFeedbackSubmitted, SessionFeedbackSubmittedPointsHandler, TestSupport
10SessionQuestionSubmittedPointsHandlerTestsMMCA.ADC.Engagement.Application.Tests14DomainEntityState, Error, HandlerTestBase<THandler>, IPointsAwarder, PointsActivityType, Question, QuestionStatus, RecordingPointsAwarder, Result, SessionQuestion, SessionQuestionChanged, SessionQuestionSubmittedPointsHandler, TestSupport, UnitOfWork
10SetLeaderboardParticipationHandlerTestsMMCA.ADC.Engagement.Application.Tests8ErrorType, HandlerMocks, HandlerTestBase<THandler>, ICurrentUserService, LeaderboardOptIn, SetLeaderboardParticipationHandler, SetLeaderboardParticipationRequest, UnitOfWork
10SubmitQuestionHandlerTestsMMCA.ADC.Engagement.Application.Tests23Error, FixedTimeProvider, HandlerMocks, HandlerTestBase<THandler>, IEventLiveValidationService, ILiveChannelPublishQueue, InMemoryQueryableExecutor, IReadRepository<TEntity, TIdentifierType>, LiveChannelPublishWorkItem, QuestionModerationDefault, QuestionStatus, Result, SessionLiveInfo, SessionQuestion, SessionQuestionApprovedPayload, SessionQuestionChannel, SessionQuestionInvariants, SessionQuestionPendingCountChangedPayload, SessionQuestionUpvote, SessionQuestionViewBuilder …(+3)
10ToggleUpvoteHandlerTestsMMCA.ADC.Engagement.Application.Tests11ErrorType, FixedTimeProvider, HandlerMocks, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, QuestionStatus, SessionQuestion, SessionQuestionUpvote, ToggleUpvoteCommand, ToggleUpvoteHandler, UnitOfWork
10UserDeletedPointsHandlerTestsMMCA.ADC.Engagement.Application.Tests7HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, LeaderboardOptIn, TestSupport, UnitOfWork, UserDeleted, UserDeletedPointsHandler
10 CheckIn MMCA.ADC.Engagement.Domain 7
10DependencyInjectionMMCA.ADC.Identity.Application13ApplicationSettings, AttendeeQueryService, AuthenticationService, AuthenticationValidators, ClassReference, ClassReference, EngagementUserDataExportSection, IAttendeeQueryService, IAuthenticationService, ISoftDeletedUserValidator, NotificationUserDataExportSection, SoftDeletedUserValidator<TUser>, User
10AttendeeQueryServiceTestsMMCA.ADC.Identity.Application.Tests5AttendeeQueryService, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, UnitOfWork, User
10AuthenticationServiceTestsMMCA.ADC.Identity.Application.Tests19AuthenticationResponse, AuthenticationService, AuthenticationValidators, Error, ErrorType, IExternalLoginEmailVerifier, ILoginProtectionService, IPasswordHasher, IRepository<TEntity, TIdentifierType>, ITokenService, IUnitOfWork, LoginRequest, RefreshTokenRequest, RegisterRequest, Result, ServiceMocks, User, UserRegistered, UserRole
10ChangePasswordHandlerTestsMMCA.ADC.Identity.Application.Tests10ChangePasswordCommand, ChangePasswordHandler, ChangePasswordRequest, ErrorType, HandlerTestBase<THandler>, IPasswordHasher, IRepository<TEntity, TIdentifierType>, UnitOfWork, User, UserRole
10ChangePreferencesHandlerTestsMMCA.ADC.Identity.Application.Tests9ChangePreferencesCommand, ChangePreferencesHandler, ChangePreferencesRequest, ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, UnitOfWork, User, UserRole
10DeleteUserHandlerTestsMMCA.ADC.Identity.Application.Tests13DeleteUserCommand, DeleteUserHandler, ErrorType, FixedTimeProvider, HandlerTestBase<THandler>, ICacheService, IFileStorageService, IRepository<TEntity, TIdentifierType>, Result, SoftDeletedUserCache, UnitOfWork, User, UserRole
10ExportUserDataHandlerTestsMMCA.ADC.Identity.Application.Tests26EngagementUserDataExportSection, ErrorType, ExportUserDataHandler, ExportUserDataHandlerBase<TUser, TQuery>, ExportUserDataQuery, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, IUserDataExportSection, IUserEngagementExportService, IUserNotificationExportService, NotificationUserDataExportSection, Subject, ThrowingExportSection, UnitOfWork, User, UserDataExportDTO, UserDataExportEngagementSectionDTO, UserDataExportNotificationSectionDTO, UserDataExportSectionDefaults, UserDataExportSectionDTO …(+6)
10 ExportUserDataRegistrationTests MMCA.ADC.Identity.Application.Tests 10
10GetUserPreferencesHandlerTestsMMCA.ADC.Identity.Application.Tests12ChangePreferencesCommand, ChangePreferencesHandler, ChangePreferencesRequest, ErrorType, GetUserPreferencesHandler, GetUserPreferencesQuery, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, UnitOfWork, User, UserPreferencesResponse, UserRole
10GetUsersHandlerTestsMMCA.ADC.Identity.Application.Tests10Email, GetUsersHandler, GetUsersQuery, HandlerTestBase<THandler>, IQueryableExecutor, IRepository<TEntity, TIdentifierType>, UnitOfWork, User, UserListDTO, UserRole
10 SpeakerLinkedToUserHandlerTests MMCA.ADC.Identity.Application.Tests 8
10IdentityModuleDbSeederMMCA.ADC.Identity.Infrastructure9Email, IdentityModuleDbSeederBase<TUser>, IPasswordHasher, IUnitOfWork, Result, SeedAccount, UnitOfWork, User, UserRole
10 IdentityEntityConfigurationTests MMCA.ADC.Identity.Infrastructure.Tests 3
10UserNotificationExportServiceTestsMMCA.ADC.Notification.Application.Tests7HandlerTestBase<THandler>, InMemoryQueryableExecutor, IRepository<TEntity, TIdentifierType>, PushNotification, UnitOfWork, UserNotification, UserNotificationExportService
10 DependencyInjection MMCA.ADC.Notification.Contracts 5
10AuthControllerBaseMMCA.Common.API10ApiControllerBase, AuthenticationResponse, AuthenticationService, CurrentUserService, IAuthenticationService, ICurrentUserService, LoginRequest, RefreshTokenRequest, RegisterRequest, WebApplicationBuilderExtensionsNowNextWidgetProviderMMCA.ADC.UI3MainActivity, NowNextSession, NowNextSnapshot
10
10MiddlewarePipelineBuilderMMCA.Common.API7CorrelationIdMiddleware, MiddlewarePipelineStep, MiddlewarePipelineStepNames, SoftDeletedUserMiddleware, TenantResolutionMiddleware, WebApplicationBuilderExtensions, WebApplicationExtensions
10 OwnerOrAdminFilter MMCA.Common.API 4 10 WebApplicationExtensions MMCA.Common.API5CorrelationIdMiddleware, SoftDeletedUserMiddleware, SupportedCultures, TenantResolutionMiddleware, WebApplicationBuilderExtensions2MiddlewarePipelineBuilder, SupportedCultures
10DatabaseInitializationExtensionsTestsCorrelationIdMiddlewareTests MMCA.Common.API.Tests23ApplicationSettings, AuditSaveChangesInterceptor, ConnectionStringSettings, DataSource, DataSourceEntrySettings, DataSourceResolver, DataSourcesSettings, DbContextFactory, DomainEventSaveChangesInterceptor, EntityDataSourceRegistry, FixedAssemblyProvider, ICurrentUserService, IDataSourceResolver, IDbContextFactory, IDomainEventDispatcher, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, InitTestWidget, IOutboxSignal, IPhysicalDbContextFactory …(+3)2CorrelationIdMiddleware, ICorrelationContext
10
10FixedAssemblyProviderMMCA.Common.API.Tests2DatabaseInitializationExtensionsTests, IEntityConfigurationAssemblyProvider
10 NotificationInboxControllerTests MMCA.Common.API.Tests 13 10 DependencyInjection MMCA.Common.Application33ApplicationSettings, AuthorizationCommandDecorator<TCommand, TResult>, AuthorizationQueryDecorator<TQuery, TResult>, CachingCommandDecorator<TCommand, TResult>, CachingQueryDecorator<TQuery, TResult>, ClassReference, CommandRequestValidator<TCommand, TRequest>, DomainEventDispatcher, EntityQueryPipeline, FeatureGateCommandDecorator<TCommand, TResult>, FeatureGateQueryDecorator<TQuery, TResult>, IApplicationSettings, ICommandHandler<in TCommand, TResult>, ICommandWithRequest<out TRequest>, IDomainEventDispatcher, IDomainEventHandler<in TDomainEvent>, IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>, IEntityDTOProjector<TEntity, TEntityDTO, TIdentifierType>, IEntityQueryPipeline, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> …(+13)37ApplicationSettings, AuthorizationCommandDecorator<TCommand, TResult>, AuthorizationQueryDecorator<TQuery, TResult>, CachingCommandDecorator<TCommand, TResult>, CachingQueryDecorator<TQuery, TResult>, ClassReference, CommandRequestValidator<TCommand, TRequest>, DomainEventDispatcher, EntityQueryPipeline, EventUpcasterRegistry, FeatureGateCommandDecorator<TCommand, TResult>, FeatureGateQueryDecorator<TQuery, TResult>, IApplicationSettings, ICommandHandler<in TCommand, TResult>, ICommandWithRequest<out TRequest>, IDomainEventDispatcher, IDomainEventHandler<in TDomainEvent>, IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>, IEntityDTOProjector<TEntity, TEntityDTO, TIdentifierType>, IEntityQueryPipeline …(+17)
10
10ForgotPasswordHandlerBaseTestsMMCA.Common.Application.Tests8Error, ForgotPasswordRequest, HandlerMocks, PasswordResetSettings, Result, TestForgotPasswordCommand, TestForgotPasswordHandler, TestIdentityUser
10 GetNotificationHistoryHandlerTests MMCA.Common.Application.Tests 10
10ResetPasswordHandlerBaseTestsMMCA.Common.Application.Tests14Email, Error, ErrorType, HandlerMocks, ILoginProtectionService, IPasswordHasher, IPasswordResetTokenService, IRepository<TEntity, TIdentifierType>, IUnitOfWork, ResetPasswordRequest, Result, TestIdentityUser, TestResetPasswordCommand, TestResetPasswordHandler
10 SendPushNotificationHandlerTests MMCA.Common.Application.Tests 16
10RepositoryFactoryMMCA.Common.InfrastructureExtensionsMMCA.Common.Aspire8HealthCheckTags, HttpResilienceDefaults, IWarmupTask, OpenIdConnectMetadataWarmupTask, OutboxPollFilterProcessor, WarmupHostedService, WarmupReadinessGate, WarmupReadinessHealthCheck
10AuditableAggregateRootEntity<TIdentifierType>, AuditableBaseEntity<TIdentifierType>, EFReadRepository<TEntity, TIdentifierType>, EFReadRepositoryDecorator<TEntity, TIdentifierType>, EFRepository<TEntity, TIdentifierType>, EFRepositoryDecorator<TEntity, TIdentifierType>, IApplicationSettings, IReadRepository<TEntity, TIdentifierType>, IRepository<TEntity, TIdentifierType>, IRepositoryFactoryGatewayCorrelationExtensionsMMCA.Common.Aspire1GatewayCorrelationMiddleware
10AuditTrailCleanupJobTestsMMCA.Common.Infrastructure.Tests13ApplicationDbContext, AuditedThing, AuditTrailCleanupJob, AuditTrailEntry, AuditTrailSettings, AuditTrailTestContext, AuditTrailTestHarness, DataSource, DataSourceKey, FakeTimeProvider, IDbContextFactory, IEntityDataSourceRegistry, SchedulerTestHarnessGatewayCorrelationMiddlewareTestsMMCA.Common.Aspire.Tests3Activity, GatewayCorrelationMiddleware, RecordingHttpResponseFeature
10AuditTrailReaderTestsMMCA.Common.Infrastructure.Tests12ApplicationDbContext, AuditTrailEntry, AuditTrailReader, AuditTrailSettings, AuditTrailTestContext, AuditTrailTestHarness, DataSource, DataSourceKey, FakeTimeProvider, IDataSourceResolver, IDbContextFactory, SchedulerTestHarnessOutboxPollFilterProcessorTestsMMCA.Common.Aspire.Tests2Activity, OutboxPollFilterProcessor
10CapturedStateMMCA.Common.Infrastructure3AggregateCapture, IDomainEvent, OutboxMessage
10
10DbContextFactoryAdditionalTestsMMCA.Common.Infrastructure.Tests8DataSource, DataSourceKey, DbContextFactory, ICurrentUserService, IDataSourceResolver, IEntityDataSourceRegistry, IPhysicalDbContextFactory, MidSaveContextCreatingDbContext
10DbContextFactoryCommitAmbiguityTestsMMCA.Common.Infrastructure.Tests14CommitFailingDbContext, DataSource, DataSourceKey, DbContextFactory, ICurrentUserService, IDataSourceResolver, IDomainEvent, IDomainEventDispatcher, IEntityDataSourceRegistry, IPhysicalDbContextFactory, Result, TestAggregate, TestLocalEvent, TransactionCommitAmbiguousException
10DbContextFactorySaveIntegrityTestsMMCA.Common.Infrastructure.Tests12DataSource, DataSourceKey, DbContextFactory, ICurrentUserService, IDataSourceResolver, IDomainEvent, IDomainEventDispatcher, IEntityDataSourceRegistry, IntegrityAggregate, IntegrityEvent, IntegrityTestDbContext, IPhysicalDbContextFactory
10DbContextFactoryTenantTestsMMCA.Common.Infrastructure.Tests15ApplicationDbContext, DataSource, DataSourceKey, DbContextFactory, ICurrentUserService, IDataSourceResolver, IEntityDataSourceRegistry, IPhysicalDbContextFactory, ITenantContext, MutableTenantContext, PhysicalDataSource, TenancySettings, TenantDataSourceOverrideSettings, TenantEntrySettings, TenantTestContext
10DbContextFactoryTestsMMCA.Common.Infrastructure.Tests8ApplicationDbContext, DataSource, DataSourceKey, DbContextFactory, ICurrentUserService, IDataSourceResolver, IEntityDataSourceRegistry, IPhysicalDbContextFactory
10DbContextFactoryTransactionTestsMMCA.Common.Infrastructure.Tests15DataSource, DataSourceKey, DbContextFactory, Error, ICurrentUserService, IDataSourceResolver, IDomainEvent, IDomainEventDispatcher, IEntityDataSourceRegistry, IPhysicalDbContextFactory, OutboxMessage, Result, TestAggregate, TestLocalEvent, TransactionTestDbContext
10DependencyInjectionTestsMMCA.Common.Infrastructure.Tests23CorrelationContext, CurrentUserService, DistributedCacheService, EntityConfigurationOptions, ICacheService, ICorrelationContext, ICurrentUserService, IDistributedLock, IEmailSender, IEventBus, ILiveChannelPublisher, InProcessDistributedLock, InProcessEventBus, IPasswordHasher, IPushNotificationSender, ITokenService, MemoryCacheService, NullLiveChannelPublisher, NullPushNotificationSender, PasswordHasher …(+3)
10EFRepositoryAdditionalTestsMMCA.Common.Infrastructure.Tests3EFRepository<TEntity, TIdentifierType>, TestDbContext, TestEntity
10EFRepositoryAuditStampTestsMMCA.Common.Infrastructure.Tests5EFRepository<TEntity, TIdentifierType>, ICurrentUserService, PlainDbContext, StampedEntity, StampTestDbContext
10EFRepositoryIntegrationTestsMMCA.Common.Infrastructure.Tests7EFReadRepository<TEntity, TIdentifierType>, EFRepository<TEntity, TIdentifierType>, FakeTimeProvider, ICurrentUserService, TestChildEntity, TestDbContext, TestEntity
10 EntityTypeConfigurationTests MMCA.Common.Infrastructure.Tests 2
10MarkAllNotificationsReadHandlerTrackingTestsMMCA.Common.Infrastructure.Tests10EFQueryableExecutor, EFRepository<TEntity, TIdentifierType>, IUnitOfWork, MarkAllNotificationsReadCommand, MarkAllNotificationsReadHandler, NotificationTestDbContext, PushNotification, Result, SeededIds, UserNotification
10OutboxCleanupServiceTestsOutboxMessageTests MMCA.Common.Infrastructure.Tests14ApplicationDbContext, CleanupTestContext, DataSource, DataSourceKey, FakeTimeProvider, IDataSourceResolver, IDbContextFactory, IEntityDataSourceRegistry, InboxMessage, MessageBusSettings, Mocks, OutboxCleanupService, OutboxMessage, OutboxSettings3OutboxMessage, TestDomainEvent, TestDomainEventWithData
10
10TestIdentityModuleDbSeederMMCA.Common.Infrastructure.Tests8Email, Error, IdentityModuleDbSeederBase<TUser>, IPasswordHasher, IUnitOfWork, Result, SeedAccount, TestSeedUser
10UnitOfWorkAdditionalTestsMMCA.Common.Infrastructure.Tests12ApplicationDbContext, DataSource, DataSourceKey, FakeAggregate, FakeEntity, IDataSourceService, IDbContextFactory, IReadRepository<TEntity, TIdentifierType>, IRepository<TEntity, TIdentifierType>, IRepositoryFactory, Mocks, UnitOfWork
10UnitOfWorkTestsMMCA.Common.Infrastructure.Tests12ApplicationDbContext, DataSource, DataSourceKey, FakeAggregate, FakeEntity, IDataSourceService, IDbContextFactory, IReadRepository<TEntity, TIdentifierType>, IRepository<TEntity, TIdentifierType>, IRepositoryFactory, Mocks, UnitOfWork
10 DecoratorPipelineOrderTests MMCA.Common.Testing.Tests 11
10HandlerTestBaseTestsMMCA.Common.Testing.Tests5FakeHandler, HandlerTestBase<THandler>, TestAggregate, TestChildEntity, UnitOfWork
10 GalleryHostFixture MMCA.Common.UI.E2E.Tests 2
11ActivitiesControllerTestsMMCA.ADC.Conference.API.Tests20ActivitiesController, Activity, ActivityCreateRequest, ActivityDTO, ActivityUpdateRequest, ConferencePermissions, DeleteEntityCommand<TEntity, TIdentifierType>, Error, GetPublicActivityFilterQuery, HasPermissionAttribute, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, InlineSpecification<TEntity, TIdentifierType>, IQueryHandler<in TQuery, TResult>, PagedCollectionResult<T>, Result, RoleNames, Specification<TEntity, TIdentifierType>, UpdateActivityCommand
11 ConditionalWriteConventionTests MMCA.ADC.Conference.API.Tests 4 11 EntityExportAuthorizationTests MMCA.ADC.Conference.API.Tests12ConferencePermissions, EventQuestionAnswersController, EventsController, EventSpeakersController, HasPermissionAttribute, SessionCategoryItemsController, SessionQuestionAnswersController, SessionsController, SessionSpeakersController, SpeakerCategoryItemsController, SpeakersController, SponsorsController13ActivitiesController, ConferencePermissions, EventQuestionAnswersController, EventsController, EventSpeakersController, HasPermissionAttribute, SessionCategoryItemsController, SessionQuestionAnswersController, SessionsController, SessionSpeakersController, SpeakerCategoryItemsController, SpeakersController, SponsorsController
11 11 DependencyInjection MMCA.ADC.Conference.Application54ApplicationSettings, Category, CategoryItem, CategoryItemDTO, ClassReference, ClassReference, ConferenceCategoryDTO, ConferenceCategoryNavigationPopulator, DeleteEntityCommand<TEntity, TIdentifierType>, DeleteEntityHandler<TEntity, TIdentifierType>, DeleteEventHandler, DeleteSessionHandler, EntityQueryService<TEntity, TEntityDTO, TIdentifierType>, Event, EventCascadeDeletionDomainService, EventDTO, EventLiveValidationService, EventNavigationPopulator, EventQuestionAnswer, EventQuestionAnswerDTO …(+34)68Activity, ActivityDTO, ActivityNavigationPopulator, ApplicationSettings, Category, CategoryItem, CategoryItemDTO, CategoryItemNavigationPopulator, ClassReference, ClassReference, ConferenceCategoryDTO, ConferenceCategoryNavigationPopulator, DeleteEntityCommand<TEntity, TIdentifierType>, DeleteEntityHandler<TEntity, TIdentifierType>, DeleteEventHandler, DeleteSessionHandler, EntityQueryService<TEntity, TEntityDTO, TIdentifierType>, Event, EventCascadeDeletionDomainService, EventDTO …(+48)
11GetPublicActivityFilterHandlerMMCA.ADC.Conference.Application8Activity, GetPublicActivityFilterQuery, InlineSpecification<TEntity, TIdentifierType>, IQueryHandler<in TQuery, TResult>, IUnitOfWork, PublicConferenceVisibility, Result, Specification<TEntity, TIdentifierType>
11
11GetPublicRoomFilterHandlerMMCA.ADC.Conference.Application8GetPublicRoomFilterQuery, InlineSpecification<TEntity, TIdentifierType>, IQueryHandler<in TQuery, TResult>, IUnitOfWork, PublicConferenceVisibility, Result, Room, Specification<TEntity, TIdentifierType>
11 GetPublicSessionCategoryItemFilterHandler MMCA.ADC.Conference.Application 8
11RefreshFromSessionizeHandlerMMCA.ADC.Conference.Application19CategorySyncStrategy, Error, Event, ICommandHandler<in TCommand, TResult>, ICurrentUserService, ISessionizeService, ISessionizeSyncStrategy, IUnitOfWork, QuestionSyncStrategy, RefreshFromSessionizeCommand, RefreshFromSessionizeResultDTO, Result, RoomSyncStrategy, SessionizeResponse, SessionizeSyncContext, SessionizeSyncResult, SessionSyncStrategy, SpeakerSyncStrategy, UnitOfWork
11 UpdateSessionHandler MMCA.ADC.Conference.Application 10
11AddSessionCategoryItemCommandValidatorTestsActivityCreateRequestValidatorTests MMCA.ADC.Conference.Application.Tests2AddSessionCategoryItemCommand, AddSessionCategoryItemCommandValidator3ActivityCreateRequest, ActivityCreateRequestValidator, ActivityInvariants
11AddSessionCategoryItemHandlerTestsAddSessionCategoryItemCommandValidatorTests MMCA.ADC.Conference.Application.Tests8AddSessionCategoryItemCommand, AddSessionCategoryItemHandler, ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, Session, SessionCategoryItemDTOMapper, UnitOfWork2AddSessionCategoryItemCommand, AddSessionCategoryItemCommandValidator
11
11AddSessionQuestionAnswerHandlerTestsMMCA.ADC.Conference.Application.Tests11AddSessionQuestionAnswerCommand, AddSessionQuestionAnswerHandler, ErrorType, Event, HandlerTestBase<THandler>, ICurrentUserService, IRepository<TEntity, TIdentifierType>, Question, Session, SessionQuestionAnswerDTOMapper, UnitOfWork
11 AddSessionSpeakerCommandValidatorTests MMCA.ADC.Conference.Application.Tests 2
11AddSessionSpeakerHandlerTestsExportEventCalendarHandlerTests MMCA.ADC.Conference.Application.Tests8AddSessionSpeakerCommand, AddSessionSpeakerHandler, ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, Session, SessionSpeakerDTOMapper, UnitOfWork7ErrorType, Event, ExportEventCalendarHandler, ExportEventCalendarQuery, IRepository<TEntity, TIdentifierType>, IUnitOfWork, Session
11CategorySyncStrategyTestsExportSessionCalendarHandlerTests MMCA.ADC.Conference.Application.Tests10Category, CategorySyncStrategy, Event, IRepository<TEntity, TIdentifierType>, IUnitOfWork, SessionizeCategory, SessionizeCategoryItem, SessionizeResponse, SessionizeSyncContext, UnitOfWork7ErrorType, Event, ExportSessionCalendarHandler, ExportSessionCalendarQuery, IRepository<TEntity, TIdentifierType>, IUnitOfWork, Session
11ConferenceCategoryNavigationPopulatorTestsGetNowNextHandlerTests MMCA.ADC.Conference.Application.Tests6Category, ConferenceCategoryNavigationPopulator, HandlerTestBase<THandler>, INavigationPopulator<in TEntity>, NavigationMetadata, UnitOfWork9ErrorType, Event, FixedTimeProvider, GetNowNextHandler, GetNowNextQuery, IRepository<TEntity, TIdentifierType>, IUnitOfWork, Session, SessionStatuses
11CreateEventHandlerTestsSessionCreateRequestValidatorTests MMCA.ADC.Conference.Application.Tests13CreateEventHandler, Error, Event, EventCreateRequest, EventDTOMapper, EventQuestionAnswerDTOMapper, EventSpeakerDTOMapper, HandlerTestBase<THandler>, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, IRepository<TEntity, TIdentifierType>, Result, RoomDTOMapper, UnitOfWork3SessionCreateRequest, SessionCreateRequestValidator, SessionInvariants
11CreateSpeakerHandlerTestsSessionDTOMapperTests MMCA.ADC.Conference.Application.Tests14CreateSpeakerHandler, Email, Error, HandlerTestBase<THandler>, ICurrentUserService, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, IRepository<TEntity, TIdentifierType>, Result, Speaker, SpeakerCategoryItemDTOMapper, SpeakerCreateRequest, SpeakerDTOMapper, SpeakerQuestionAnswerDTOMapper, UnitOfWork6Event, Session, SessionCategoryItemDTOMapper, SessionDTOMapper, SessionQuestionAnswerDTOMapper, SessionSpeakerDTOMapper
11CreateSponsorHandlerTestsSponsorCreateRequestValidatorTests MMCA.ADC.Conference.Application.Tests11CreateSponsorHandler, Error, HandlerTestBase<THandler>, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, IRepository<TEntity, TIdentifierType>, Result, Sponsor, SponsorCreateRequest, SponsorDTOMapper, SponsorTier, UnitOfWork4SponsorCreateRequest, SponsorCreateRequestValidator, SponsorInvariants, SponsorTier
11DeleteEventHandlerTestsMMCA.ADC.Conference.Application.Tests11DeleteEntityCommand<TEntity, TIdentifierType>, DeleteEventHandler, ErrorType, Event, EventCascadeDeletionDomainService, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, Session, Sponsor, SponsorTier, UnitOfWorkEventLiveValidationServiceGrpcAdapterMMCA.ADC.Conference.Contracts10Error, EventLiveInfo, EventLiveValidationService, GrpcErrorTrailerParser, IEventLiveValidationService, QuestionModerationDefault, Result, RoomSessionInfo, SessionLiveInfo, SponsorLiveInfo
11EventLiveValidationServiceTestsMMCA.ADC.Conference.Application.Tests11ErrorType, Event, EventLiveValidationService, FixedTimeProvider, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, QuestionModerationDefault, Session, Sponsor, SponsorTier, UnitOfWorkEventCascadeDeletionDomainServiceTestsMMCA.ADC.Conference.Domain.Tests5ActivityBuilder, Event, EventCascadeDeletionDomainService, Session, SponsorBuilder
11EventNavigationPopulatorTestsMMCA.ADC.Conference.Application.Tests6Event, EventNavigationPopulator, HandlerTestBase<THandler>, INavigationPopulator<in TEntity>, NavigationMetadata, UnitOfWorkConferenceEntityConfigurationTestsMMCA.ADC.Conference.Infrastructure.Tests20Category, CategoryInvariants, CategoryItem, ConferenceTestDbContext, Event, EventInvariants, EventQuestionAnswer, EventSpeaker, Question, QuestionInvariants, Room, Session, SessionCategoryItem, SessionInvariants, SessionQuestionAnswer, SessionSpeaker, Speaker, SpeakerCategoryItem, SpeakerInvariants, SpeakerQuestionAnswer
11ExportEventCalendarHandlerTestsMMCA.ADC.Conference.Application.Tests7ErrorType, Event, ExportEventCalendarHandler, ExportEventCalendarQuery, IRepository<TEntity, TIdentifierType>, IUnitOfWork, SessionEventLiveValidationGrpcServiceMMCA.ADC.Conference.Service3EventLiveValidationService, IEventLiveValidationService, QuestionModerationDefault
11ExportSessionCalendarHandlerTestsMMCA.ADC.Conference.Application.Tests7ErrorType, Event, ExportSessionCalendarHandler, ExportSessionCalendarQuery, IRepository<TEntity, TIdentifierType>, IUnitOfWork, SessionPublicSessionListEventFilterTestsMMCA.ADC.Conference.UI.Tests12BunitTestBase, Event, EventDTO, IEventUIService, ISessionUIService, ISpeakerLookupService, ListPageQueryStateService, ListPageStateService, PublicSessionList, RoleNames, SpeakerInfo, TestPrincipal
11GetNowNextHandlerTestsMMCA.ADC.Conference.Application.Tests9ErrorType, Event, FixedTimeProvider, GetNowNextHandler, GetNowNextQuery, IRepository<TEntity, TIdentifierType>, IUnitOfWork, Session, SessionStatuses
PublicSessionListRoomFilterTestsMMCA.ADC.Conference.UI.Tests 11GetPublicSessionFilterHandlerTestsMMCA.ADC.Conference.Application.Tests8Event, GetPublicSessionFilterHandler, GetPublicSessionFilterQuery, HandlerTestBase<THandler>, IReadRepository<TEntity, TIdentifierType>, Session, SessionStatuses, UnitOfWork
11QuestionSyncStrategyTestsMMCA.ADC.Conference.Application.Tests11Event, IRepository<TEntity, TIdentifierType>, IUnitOfWork, Question, QuestionSyncStrategy, SessionizeQuestion, SessionizeQuestionAnswer, SessionizeResponse, SessionizeSpeaker, SessionizeSyncContext, UnitOfWork
11RemoveSessionCategoryItemHandlerTestsMMCA.ADC.Conference.Application.Tests7ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, RemoveSessionCategoryItemCommand, RemoveSessionCategoryItemHandler, Session, UnitOfWork
11RemoveSessionQuestionAnswerHandlerTestsMMCA.ADC.Conference.Application.Tests9ErrorType, HandlerTestBase<THandler>, ICurrentUserService, IRepository<TEntity, TIdentifierType>, RemoveSessionQuestionAnswerCommand, RemoveSessionQuestionAnswerHandler, RoleNames, Session, UnitOfWork
11RemoveSessionSpeakerHandlerTestsMMCA.ADC.Conference.Application.Tests7ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, RemoveSessionSpeakerCommand, RemoveSessionSpeakerHandler, Session, UnitOfWork
11RoomSyncStrategyTestsMMCA.ADC.Conference.Application.Tests9Event, IReadRepository<TEntity, TIdentifierType>, IUnitOfWork, Room, RoomSyncStrategy, SessionizeResponse, SessionizeRoom, SessionizeSyncContext, UnitOfWork
11SessionCreateRequestValidatorTestsMMCA.ADC.Conference.Application.Tests3SessionCreateRequest, SessionCreateRequestValidator, SessionInvariants
11SessionDTOMapperTestsMMCA.ADC.Conference.Application.Tests6Event, Session, SessionCategoryItemDTOMapper, SessionDTOMapper, SessionQuestionAnswerDTOMapper, SessionSpeakerDTOMapper
11SessionNavigationPopulatorTestsMMCA.ADC.Conference.Application.Tests6HandlerTestBase<THandler>, INavigationPopulator<in TEntity>, NavigationMetadata, Session, SessionNavigationPopulator, UnitOfWork
11SessionSyncStrategyTestsMMCA.ADC.Conference.Application.Tests9Event, IRepository<TEntity, TIdentifierType>, IUnitOfWork, Session, SessionizeResponse, SessionizeSession, SessionizeSyncContext, SessionSyncStrategy, UnitOfWork
11SpeakerEntityQueryServiceTestsMMCA.ADC.Conference.Application.Tests16EntityQueryParameters<TEntity>, ErrorType, HandlerTestBase<THandler>, ICurrentUserService, IEntityQueryPipeline, INavigationMetadataProvider, INavigationPopulator<in TEntity>, InlineSpecification<TEntity, TIdentifierType>, IReadRepository<TEntity, TIdentifierType>, NavigationMetadata, Speaker, SpeakerCategoryItemDTOMapper, SpeakerDTOMapper, SpeakerEntityQueryService, SpeakerQuestionAnswerDTOMapper, UnitOfWork
11SpeakerNavigationPopulatorTestsMMCA.ADC.Conference.Application.Tests6HandlerTestBase<THandler>, INavigationPopulator<in TEntity>, NavigationMetadata, Speaker, SpeakerNavigationPopulator, UnitOfWork
11SpeakerSyncStrategyTestsMMCA.ADC.Conference.Application.Tests11Event, EventSpeaker, IReadRepository<TEntity, TIdentifierType>, IRepository<TEntity, TIdentifierType>, IUnitOfWork, SessionizeResponse, SessionizeSpeaker, SessionizeSyncContext, Speaker, SpeakerSyncStrategy, UnitOfWork
11SponsorCreateRequestValidatorTestsMMCA.ADC.Conference.Application.Tests4SponsorCreateRequest, SponsorCreateRequestValidator, SponsorInvariants, SponsorTier
11UpdateEventHandlerTestsMMCA.ADC.Conference.Application.Tests13ErrorType, Event, EventDTOMapper, EventQuestionAnswerDTOMapper, EventSpeakerDTOMapper, EventUpdateRequest, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, RoomDTOMapper, Session, UnitOfWork, UpdateEventCommand, UpdateEventHandler
11UpdateSessionQuestionAnswerHandlerTestsMMCA.ADC.Conference.Application.Tests10ErrorType, Event, HandlerTestBase<THandler>, ICurrentUserService, IRepository<TEntity, TIdentifierType>, RoleNames, Session, UnitOfWork, UpdateSessionQuestionAnswerCommand, UpdateSessionQuestionAnswerHandler
11UpdateSpeakerHandlerTestsMMCA.ADC.Conference.Application.Tests13Email, ErrorType, HandlerTestBase<THandler>, ICurrentUserService, IRepository<TEntity, TIdentifierType>, Speaker, SpeakerCategoryItemDTOMapper, SpeakerDTOMapper, SpeakerQuestionAnswerDTOMapper, SpeakerUpdateRequest, UnitOfWork, UpdateSpeakerCommand, UpdateSpeakerHandler
11UpdateSponsorHandlerTestsMMCA.ADC.Conference.Application.Tests10ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, Sponsor, SponsorDTOMapper, SponsorTier, SponsorUpdateRequest, UnitOfWork, UpdateSponsorCommand, UpdateSponsorHandler
11EventLiveValidationServiceGrpcAdapterMMCA.ADC.Conference.Contracts10Error, EventLiveInfo, EventLiveValidationService, GrpcErrorTrailerParser, IEventLiveValidationService, QuestionModerationDefault, Result, RoomSessionInfo, SessionLiveInfo, SponsorLiveInfo
11EventCascadeDeletionDomainServiceTestsMMCA.ADC.Conference.Domain.Tests4Event, EventCascadeDeletionDomainService, Session, SponsorBuilder
11ConferenceEntityConfigurationTestsMMCA.ADC.Conference.Infrastructure.Tests20Category, CategoryInvariants, CategoryItem, ConferenceTestDbContext, Event, EventInvariants, EventQuestionAnswer, EventSpeaker, Question, QuestionInvariants, Room, Session, SessionCategoryItem, SessionInvariants, SessionQuestionAnswer, SessionSpeaker, Speaker, SpeakerCategoryItem, SpeakerInvariants, SpeakerQuestionAnswer
11EventLiveValidationGrpcServiceMMCA.ADC.Conference.Service3EventLiveValidationService, IEventLiveValidationService, QuestionModerationDefault
11PublicSessionListEventFilterTestsMMCA.ADC.Conference.UI.Tests12BunitTestBase, Event, EventDTO, IEventUIService, ISessionUIService, ISpeakerLookupService, ListPageQueryStateService, ListPageStateService, PublicSessionList, RoleNames, SpeakerInfo, TestPrincipalBunitTestBase, EventDTO, IEventUIService, ISessionUIService, ISpeakerLookupService, ListPageQueryStateService, ListPageStateService, PublicSessionList, Room, RoomDTO, SpeakerInfo
11 CheckInProcessor MMCA.ADC.Engagement.Application 8CheckIn, CheckInResultDTO, CheckInScope, Error, IEventLiveValidationService, IRepository<TEntity, TIdentifierType>, IUnitOfWork, ResultCheckIn, CheckInResultDTO, CheckInScope, Error, IEntityQuerier<TEntity, TIdentifierType>, IEventLiveValidationService, IUnitOfWork, Result
11
11AttendeeCheckedInPointsHandlerTestsMMCA.ADC.Engagement.Application.Tests11AttendeeCheckedIn, AttendeeCheckedInPointsHandler, CheckInScopeNames, Error, HandlerTestBase<THandler>, IPointsAwarder, PointsActivityType, RecordingPointsAwarder, Result, SponsorVisit, TestSupport
11CastVoteHandlerTestsMMCA.ADC.Engagement.Application.Tests12CastVoteCommand, CastVoteHandler, ErrorType, FixedTimeProvider, HandlerMocks, HandlerTestBase<THandler>, InMemoryQueryableExecutor, IReadRepository<TEntity, TIdentifierType>, LivePoll, LivePollResultsBuilder, LivePollVote, UnitOfWork
11CreateLivePollHandlerTestsMMCA.ADC.Engagement.Application.Tests17CreateLivePollCommand, CreateLivePollHandler, CreateLivePollRequest, Error, ErrorType, EventLiveInfo, HandlerMocks, HandlerTestBase<THandler>, IEventLiveValidationService, LivePoll, LivePollDTOMapper, LivePollStatus, Question, QuestionModerationDefault, Result, SessionLiveInfo, UnitOfWork
11GetEventPollsHandlerTestsMMCA.ADC.Engagement.Application.Tests7GetEventPollsHandler, GetEventPollsQuery, HandlerTestBase<THandler>, LivePoll, LivePollDTOMapper, LivePollStatus, UnitOfWork
11GetLeaderboardHandlerTestsMMCA.ADC.Engagement.Application.Tests8GetLeaderboardHandler, GetLeaderboardQuery, HandlerTestBase<THandler>, LeaderboardOptIn, PointsActivityType, PointsEntry, PointsSettings, UnitOfWork
11GetOpenPollsHandlerTestsMMCA.ADC.Engagement.Application.Tests9ErrorType, GetOpenPollsHandler, GetOpenPollsQuery, HandlerTestBase<THandler>, InMemoryQueryableExecutor, LivePoll, LivePollResultsBuilder, LivePollVote, UnitOfWork
11 HandlerMocks MMCA.ADC.Engagement.Application.Tests 4
11PointsAwarderTestsMMCA.ADC.Engagement.Application.Tests11AwarderMocks, EventFeedback, HandlerTestBase<THandler>, MutableOptions, PointsActivityType, PointsAwarder, PointsEntry, PointsSettings, PointsSubjectKeys, SessionFeedback, UnitOfWork
11 CheckInTests MMCA.ADC.Engagement.Domain.Tests 4
11ModuleApplicationDbContextMMCA.ADC.Engagement.Infrastructure13ApplicationDbContext, AttendeeBadge, CheckIn, IEntityConfigurationAssemblyProvider, LeaderboardOptIn, LivePoll, LivePollOption, LivePollVote, PhysicalDataSource, PointsEntry, SessionQuestion, SessionQuestionUpvote, UserSessionBookmark
11 EngagementEntityConfigurationTests MMCA.ADC.Engagement.Infrastructure.Tests 9
11IdentityModuleSeederMMCA.ADC.Identity.API4IdentityModuleDbSeeder, IModuleSeeder, IPasswordHasher, IUnitOfWork
11IdentityModuleDbSeederTestsMMCA.ADC.Identity.Infrastructure.Tests6IdentityModuleDbSeeder, IPasswordHasher, IRepository<TEntity, TIdentifierType>, IUnitOfWork, SeederMocks, User
11 MauiProgram MMCA.ADC.UI 15
11UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand>MMCA.Common.API15AuthControllerBase, ChangePasswordHandler, ChangePasswordRequest, ChangePreferencesHandler, ChangePreferencesRequest, CurrentUserService, GetUserPreferencesHandler, GetUserPreferencesQuery, IAuthenticationService, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IQueryHandler<in TQuery, TResult>, IUserScopedCommand<out TRequest>, Result, UserPreferencesResponse
11 DependencyInjectionTests MMCA.Common.API.Tests 11
11OverridingAuthControllerMiddlewarePipelineBuilderTests MMCA.Common.API.Tests5AuthControllerBase, AuthenticationResponse, IAuthenticationService, ICurrentUserService, RegisterRequest3MiddlewarePipelineBuilder, MiddlewarePipelineStep, MiddlewarePipelineStepNames
11
11TestAuthControllerMMCA.Common.API.Tests3AuthControllerBase, IAuthenticationService, ICurrentUserService
11 TestDataExportController MMCA.Common.API.Tests 6
11DependencyInjectionMMCA.Common.Infrastructure123ApplicationDbContext, ApplicationDbContextEFFactory, AuditSaveChangesInterceptor, AuditTrailCleanupJob, AuditTrailReader, AuditTrailSaveChangesInterceptor, AuditTrailSettings, AzureBlobFileStorageService, AzureNotificationHubDeviceRegistrar, AzureNotificationHubNativePushSender, BrokerEventBus, BrokerMessageBus, CacheKeyNamespace, CacheKeyPrefixOptions, CacheOptions, ClaimBasedUserIdProvider, ClassReference, ConnectionStringSettings, CorrelationContext, CosmosDbContext …(+103)InfrastructureHealthChecksTestsMMCA.Common.Aspire.Tests2Extensions, HealthCheckTags
11CosmosConfigurationPortabilityTestsMMCA.Common.Infrastructure.Tests17AuditSaveChangesInterceptor, ConnectionStringSettings, DataSource, DataSourceEntrySettings, DataSourceResolver, DataSourcesSettings, DomainEventSaveChangesInterceptor, EntityDataSourceRegistry, FixedAssemblyProvider, IDataSourceResolver, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, OutboxSignal, PhysicalDbContextFactory, PortablePrincipal, PortableThingMetricsInstrumentationToggleTestsMMCA.Common.Aspire.Tests1Extensions
11FixedAssemblyProviderMMCA.Common.Infrastructure.TestsTracesSampleRatioTestsMMCA.Common.Aspire.Tests1Extensions
11ApplicationDbContextMMCA.Common.Infrastructure26AuditableBaseEntity<TIdentifierType>, AuditSaveChangesInterceptor, AuditTrailEntry, AuditTrailSaveChangesInterceptor, AuditTrailSettings, CrossDataSourceDegradeConvention, DataSource, DataSourceKey, DataSourceModelCacheKeyFactory, DetectChangesScope, DomainEventSaveChangesInterceptor, IAuditableEntity, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, IEntityTypeConfigurationCosmos<TEntity, TIdentifierType>, IEntityTypeConfigurationSqlite<TEntity, TIdentifierType>, IEntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, InboxMessage, ITenantEntity, OutboxMessage …(+6)
11AuditSaveChangesInterceptorMMCA.Common.Infrastructure2ApplicationDbContext, IAuditableEntity
11AuditTrailSaveChangesInterceptorMMCA.Common.Infrastructure11Activity, ApplicationDbContext, AuditTrailEntry, CaptureContext, IAuditedEntity, InboxMessage, OutboxMessage, PendingEntityKey, PiiAttribute, PiiRedactor, ScheduledJobEntry
11DataSourceModelCacheKeyFactoryMMCA.Common.Infrastructure1ApplicationDbContext
11DeferredDispatchMMCA.Common.Infrastructure2CapturedState, DomainEventSaveChangesInterceptor
11DomainEventSaveChangesInterceptorMMCA.Common.Infrastructure11AggregateCapture, ApplicationDbContext, CapturedState, DeferredDispatch, IAggregateRoot, IDomainEvent, IDomainEventDispatcher, IIntegrationEvent, IOutboxSignal, OutboxFinalizer, OutboxMessage
11OutboxFinalizerMMCA.Common.Infrastructure2ApplicationDbContext, OutboxMessage
11TenantSaveChangesInterceptorMMCA.Common.Infrastructure 3CosmosConfigurationPortabilityTests, IEntityConfigurationAssemblyProvider, MultiSourceSqliteIntegrationTestsApplicationDbContext, CrossTenantWriteException, ITenantEntity
11IdentityModuleDbSeederBaseTestsMMCA.Common.Infrastructure.TestsMiddlewarePipelineOrderTestsBaseMMCA.Common.Testing2MiddlewarePipelineBuilder, MiddlewarePipelineStepNames
11GalleryE2ECollectionMMCA.Common.UI.E2E.Tests2GalleryHostFixture, PlaywrightFixture
12AdcArchitectureMapMMCA.ADC.Architecture.Tests17ApiControllerBase, ApplicationDbContext, ArchitectureMapBase, BaseEntity<TIdentifierType>, ConferenceModule, EngagementModule, EntityQueryService<TEntity, TEntityDTO, TIdentifierType>, Event, EventDTO, IdentityModule, Layer, LayerRef, Result, User, UserDTO, UserSessionBookmark, UserSessionBookmarkDTO
12MiddlewarePipelineOrderTestsMMCA.ADC.Architecture.Tests1MiddlewarePipelineOrderTestsBase
12DependencyInjectionMMCA.ADC.Conference.Contracts6EventLiveValidationService, EventLiveValidationServiceGrpcAdapter, IEventLiveValidationService, ISessionBookmarkValidationService, SessionBookmarkValidationService, SessionBookmarkValidationServiceGrpcAdapter
12ModuleApplicationDbContextMMCA.ADC.Conference.Infrastructure18Activity, ApplicationDbContext, Category, CategoryItem, Event, EventQuestionAnswer, EventSpeaker, IEntityConfigurationAssemblyProvider, PhysicalDataSource, Question, Room, Session, SessionCategoryItem, SessionQuestionAnswer, SessionSpeaker, Speaker, SpeakerCategoryItem, Sponsor
12BookmarksControllerTestsMMCA.ADC.Engagement.API.Tests17BookmarksController, ControllerMocks, CreateBookmarkRequest, DeleteEntityCommand<TEntity, TIdentifierType>, Error, GetBookmarkedSessionIdsQuery, GetUserBookmarksQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, IQueryHandler<in TQuery, TResult>, OwnerOrAdminFilter, PagedCollectionResult<T>, PaginationMetadata, Result, UserSessionBookmark, UserSessionBookmarkDTO
12CheckInAttendeeHandlerMMCA.ADC.Engagement.Application11AttendeeBadge, BadgePayload, CheckInAttendeeRequest, CheckInProcessor, CheckInResultDTO, Error, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEventLiveValidationService, IUnitOfWork, Result
12DependencyInjectionMMCA.ADC.Engagement.Application33ApplicationSettings, BookmarkCountService, BookmarkManagementDomainService, ClassReference, ClassReference, DeleteEntityCommand<TEntity, TIdentifierType>, DeleteEntityHandler<TEntity, TIdentifierType>, EntityQueryService<TEntity, TEntityDTO, TIdentifierType>, IBookmarkCountService, IBookmarkManagementDomainService, ICommandHandler<in TCommand, TResult>, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, ILiveChannelPublishQueue, INavigationPopulator<in TEntity>, IPointsAwarder, IUserEngagementExportService, LiveChannelPublishQueue, LivePoll, LivePollDTO, LivePollNavigationPopulator …(+13)
12ManualCheckInHandlerMMCA.ADC.Engagement.Application9CheckInProcessor, CheckInResultDTO, Error, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEventLiveValidationService, IUnitOfWork, ManualCheckInRequest, Result
12UserEngagementExportServiceGrpcAdapterMMCA.ADC.Engagement.Contracts9CheckInScope, IUserEngagementExportService, PointsActivityType, UserEngagementBookmarkExportDTO, UserEngagementCheckInExportDTO, UserEngagementExportDTO, UserEngagementExportService, UserEngagementPointsEntryExportDTO, UserEngagementSubmittedQuestionExportDTO
12ModuleApplicationDbContextMMCA.ADC.Engagement.Infrastructure13ApplicationDbContext, AttendeeBadge, CheckIn, IEntityConfigurationAssemblyProvider, LeaderboardOptIn, LivePoll, LivePollOption, LivePollVote, PhysicalDataSource, PointsEntry, SessionQuestion, SessionQuestionUpvote, UserSessionBookmark
12UserEngagementExportGrpcServiceMMCA.ADC.Engagement.Service3IUserEngagementExportService, LeaderboardOptIn, UserEngagementExportService
12DependencyInjectionMMCA.ADC.Engagement.UI33AttendeeLookupService, BookmarkService, CheckInService, CurrentEventNotificationScopeProvider, EngagementUIModule, EventFeedbackService, IAttendeeLookupService, IBookmarkUIService, ICheckInUIService, IEventFeedbackUIService, ILiveEventUIService, ILivePollUIService, INotificationScopeProvider, INowNextService, IPointsUIService, IQuestionLookupService, ISessionBookmarkUIService, ISessionFeedbackUIService, ISessionLiveUIService, ISessionLookupService …(+13)
12ModuleApplicationDbContextMMCA.ADC.Identity.Infrastructure4ApplicationDbContext, IEntityConfigurationAssemblyProvider, PhysicalDataSource, User
12AppMMCA.ADC.UI1MauiProgram
12AppDelegateMMCA.ADC.UI2IDeepLinkDispatcher, MauiProgram
12MainApplicationMMCA.ADC.UI1MauiProgram
12DataExportControllerBaseTestsMMCA.Common.API.Tests14AuthorizationPolicies, DataExportControllerBase<TQuery>, Error, ICurrentUserService, IQueryHandler<in TQuery, TResult>, PrivacyFeatures, Result, StubFeatureManager, Subject, SubjectSnapshot, TestDataExportController, TestExportQuery, UserDataExportDTO, UserDataExportSectionDTO
12CommonArchitectureMapMMCA.Common.Architecture.Tests10ApiControllerBase, ApplicationDbContext, ArchitectureMapBase, BaseEntity<TIdentifierType>, DomainEventDispatcher, Layer, LayerRef, Result, ResultGrpcExtensions, UISharedAssemblyReference
12FrameworkSanityTestsMMCA.Common.Architecture.Tests 7IPasswordHasher, IRepository<TEntity, TIdentifierType>, IUnitOfWork, SeedAccount, SeederMocks, TestIdentityModuleDbSeeder, TestSeedUserApplicationDbContext, ArchitectureAssert, DomainEventDispatcher, IJwksProvider, ILiveChannelPublisher, IMessageBus, ResultGrpcExtensions
12CosmosDbContextMMCA.Common.Infrastructure5ApplicationDbContext, DataSource, IEntityConfigurationAssemblyProvider, OutboxMessage, PhysicalDataSource
12EFRepository<TEntity, TIdentifierType>MMCA.Common.Infrastructure9ApplicationDbContext, AuditableBaseEntity<TIdentifierType>, EFReadRepository<TEntity, TIdentifierType>, IAuditableEntity, ICurrentUserService, IRepository<TEntity, TIdentifierType>, IRowVersioned, IUpdatePropertySetter<TEntity>, UpdatePropertySetterBuilder<TEntity>
12IDbContextFactoryMMCA.Common.Infrastructure3ApplicationDbContext, DataSource, DataSourceKey
12IPhysicalDbContextFactoryMMCA.Common.Infrastructure3ApplicationDbContext, DataSourceKey, PhysicalDataSource
12SqliteDbContextMMCA.Common.Infrastructure4ApplicationDbContext, DataSource, IEntityConfigurationAssemblyProvider, PhysicalDataSource
12SQLServerDbContextMMCA.Common.Infrastructure5ApplicationDbContext, DataSource, IEntityConfigurationAssemblyProvider, PersistenceSettings, PhysicalDataSource
12AddMultiTenancyTestsMMCA.Common.Infrastructure.Tests11ConnectionStringSettings, DataSourceResolver, DataSourcesSettings, ITenantContext, TenancySettings, TenancySettingsValidator, TenantContext, TenantDataSourceOverrideSettings, TenantEntrySettings, TenantResolutionStrategy, TenantSaveChangesInterceptor
12CleanupTestContextMMCA.Common.Infrastructure.Tests11ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, InboxMessage, IOutboxSignal, NullAssemblyProvider, OutboxMessage, TestPhysicalDataSources
12CommitFailingDbContextMMCA.Common.Infrastructure.Tests12ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, FailingDatabaseFacade, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, OutboxMessage, TestAggregate, TestPhysicalDataSources
12DegradeTestContextMMCA.Common.Infrastructure.Tests8ApplicationDbContext, DataSourceKey, DegradeCustomer, DegradeOrder, EmptyAssemblyProvider, EmptyAssemblyProvider, IEntityDataSourceRegistry, PhysicalDataSource
12DetectionTestDbContextMMCA.Common.Infrastructure.Tests10ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, TestPhysicalDataSources, Widget
12ExclusionTestDbContextMMCA.Common.Infrastructure.Tests8ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, ExclusionAggregate, IEntityDataSourceRegistry, NullAssemblyProvider, TestPhysicalDataSources
12FailingDatabaseFacadeMMCA.Common.Infrastructure.Tests2AlwaysRetryExecutionStrategy, CommitFailingDbContext
12FailingSaveInterceptorMMCA.Common.Infrastructure.Tests1OutboxRoutingTestDbContext
12GateTestContextMMCA.Common.Infrastructure.Tests13ApplicationDbContext, AuditSaveChangesInterceptor, DataSource, DataSourceKey, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, GateTestContext, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, PhysicalDataSource, SchedulerSettings
12GateTestContextMMCA.Common.Infrastructure.Tests14ApplicationDbContext, AuditSaveChangesInterceptor, AuditTrailSettings, DataSource, DataSourceKey, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, GateTestContext, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, NullAssemblyProvider, PhysicalDataSource
12InboxTestDbContextMMCA.Common.Infrastructure.Tests4ApplicationDbContext, IEntityConfigurationAssemblyProvider, InboxMessage, TestPhysicalDataSources
12IntegrityTestDbContextMMCA.Common.Infrastructure.Tests11ApplicationDbContext, AuditSaveChangesInterceptor, DataSourceKey, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IntegrityAggregate, IOutboxSignal, NullAssemblyProvider, PhysicalDataSource
12MidSaveContextCreatingDbContextMMCA.Common.Infrastructure.Tests10ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, IOutboxSignal, ReentrantSaveInterceptor, TestPhysicalDataSources
12NamedSoftDeleteTestDbContextMMCA.Common.Infrastructure.Tests2ApplicationDbContext, ProjectedTestEntity
12OutboxRoutingTestDbContextMMCA.Common.Infrastructure.Tests10ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, FailingSaveInterceptor, IEntityDataSourceRegistry, NullAssemblyProvider, OutboxMessage, TestAggregate, TestPhysicalDataSources
12OutboxTestDbContextMMCA.Common.Infrastructure.Tests4ApplicationDbContext, IEntityConfigurationAssemblyProvider, OutboxMessage, TestPhysicalDataSources
12QueryShapeTestDbContextMMCA.Common.Infrastructure.Tests10ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, Product, TestPhysicalDataSources
12ReentrantSaveInterceptorMMCA.Common.Infrastructure.Tests1MidSaveContextCreatingDbContext
12SchedulerTestContextMMCA.Common.Infrastructure.Tests10ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, ScheduledJobEntry, TestPhysicalDataSources
12SoftDeleteTestDbContextMMCA.Common.Infrastructure.Tests11ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, SoftDeletableEntity, SoftDeletableTestEntity, TestPhysicalDataSources
12SpecificationTestDbContextMMCA.Common.Infrastructure.Tests3ApplicationDbContext, SpecTestChild, SpecTestEntity
12StampTestDbContextMMCA.Common.Infrastructure.Tests10ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, StampedEntity, TestPhysicalDataSources
12TenantTestContextMMCA.Common.Infrastructure.Tests16ApplicationDbContext, AuditSaveChangesInterceptor, AuditTrailEntry, AuditTrailSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, NullAssemblyProvider, PlainThing, TenantSaveChangesInterceptor, TenantThing, TestPhysicalDataSources, TrailedTenantThing
12TestApplicationDbContextMMCA.Common.Infrastructure.Tests10ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, IOutboxSignal, TestEntity, TestPhysicalDataSources
12TestAuditDbContextMMCA.Common.Infrastructure.Tests10ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, TestAuditEntity, TestPhysicalDataSources
12TestDomainEventDbContextMMCA.Common.Infrastructure.Tests8ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IEntityDataSourceRegistry, NullAssemblyProvider, TestAggregate, TestPhysicalDataSources
12TestNonOutboxContextMMCA.Common.Infrastructure.Tests9ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, TestPhysicalDataSources
12TestOutboxContextMMCA.Common.Infrastructure.Tests10ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, OutboxMessage, TestPhysicalDataSources
12TransactionTestDbContextMMCA.Common.Infrastructure.Tests11ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, OutboxMessage, TestAggregate, TestPhysicalDataSources
12UniqueIndexTestDbContextMMCA.Common.Infrastructure.Tests12ApplicationDbContext, AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, FilteredIndexEntity, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, NullAssemblyProvider, TestPhysicalDataSources, UniqueNamedEntity
12MiddlewarePipelineOrderTestsMMCA.Common.Testing.Tests1MiddlewarePipelineOrderTestsBase
12GalleryAxeTestBaseMMCA.Common.UI.E2E.Tests4E2ETestConfiguration, GalleryE2ECollection, GalleryHostFixture, PlaywrightFixture
13ConcurrencyConventionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, ConcurrencyConventionTestsBase, IArchitectureMap
13ConstructorDependencyCountTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, ConstructorDependencyCountTestsBase, IArchitectureMap
13ControllerConventionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, ControllerConventionTestsBase, IArchitectureMap
13DataResidencyTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, DataResidencyTestsBase, IArchitectureMap
13DomainPurityTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, DomainPurityTestsBase, IArchitectureMap
13EntityConventionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, EntityConventionTestsBase, IArchitectureMap
13EventConventionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, EventConventionTestsBase, IArchitectureMap
13FormsConventionTestsMMCA.ADC.Architecture.Tests4AdcArchitectureMap, ArchitectureMapBase, FormsConventionTestsBase, IArchitectureMap
13FrameworkVersionConsistencyTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, FrameworkVersionConsistencyTestsBase, IArchitectureMap
13HandlerConventionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, HandlerConventionTestsBase, IArchitectureMap
13HandlerResultConventionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, HandlerResultConventionTestsBase, IArchitectureMap
13IdempotencyConventionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, IdempotencyConventionTestsBase
13ImmutabilityTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, ImmutabilityTestsBase
13IntegrationEventContractTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, IntegrationEventContractTestsBase
13LayerDependencyTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, LayerDependencyTestsBase
13LocalizedTextConventionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, LocalizedTextConventionTestsBase
13MicroserviceExtractionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, MicroserviceExtractionTestsBase
13ModuleIsolationTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, ModuleIsolationTestsBase
13NamingConventionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, NamingConventionTestsBase
13PiiConventionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, PiiConventionTestsBase
13RawQueryableConventionTestsMMCA.ADC.Architecture.Tests4AdcArchitectureMap, ArchitectureMapBase, IArchitectureMap, RawQueryableConventionTestsBase
13ServiceContractPurityTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, ServiceContractPurityTestsBase
13SharedLayerTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, SharedLayerTestsBase
13SliceCohesionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, SliceCohesionTestsBase
13SpecificationConventionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, SpecificationConventionTestsBase
13StateManagementConventionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, StateManagementConventionTestsBase
13UIArchitectureConventionTestsMMCA.ADC.Architecture.Tests3AdcArchitectureMap, IArchitectureMap, UIArchitectureConventionTestsBase
13DependencyInjectionMMCA.ADC.Engagement.Contracts6BookmarkCountService, BookmarkCountServiceGrpcAdapter, IBookmarkCountService, IUserEngagementExportService, UserEngagementExportService, UserEngagementExportServiceGrpcAdapter
13UserEngagementExportGrpcServiceTestsMMCA.ADC.Services.Tests8CheckInScope, FakeServerCallContext, IUserEngagementExportService, UserEngagementBookmarkExportDTO, UserEngagementCheckInExportDTO, UserEngagementExportDTO, UserEngagementExportGrpcService, UserEngagementSubmittedQuestionExportDTO
13UserEngagementExportServiceGrpcAdapterTestsMMCA.ADC.Services.Tests4CheckInScope, UserEngagementExportDTO, UserEngagementExportService, UserEngagementExportServiceGrpcAdapter
13ProgramMMCA.ADC.UI1AppDelegate
13DatabaseInitializationExtensionsMMCA.Common.API11ApplicationSettings, DataSource, DataSourceKey, IDataSourceResolver, IDbContextFactory, IEntityDataSourceRegistry, ITenantContext, ModuleLoader, TenancySettings, TenantDataSourceTarget, TenantDataSourceTargets
13AggregateConventionTestsMMCA.Common.Architecture.Tests3AggregateConventionTestsBase, CommonArchitectureMap, IArchitectureMap
13CancellationTokenConventionTestsMMCA.Common.Architecture.Tests3CancellationTokenConventionTestsBase, CommonArchitectureMap, IArchitectureMap
13DomainPurityTestsMMCA.Common.Architecture.Tests3CommonArchitectureMap, DomainPurityTestsBase, IArchitectureMap
13EventScopeFitnessTestsMMCA.Common.Architecture.Tests3ArchitectureRules, CommonArchitectureMap, FakeConsumerMap
13EventUpcasterFitnessTestsMMCA.Common.Architecture.Tests9ArchitectureRules, CommonArchitectureMap, FixtureBackwardsVersionUpcaster, FixtureCompliantV1ToV2Upcaster, FixtureCompliantV2ToV3Upcaster, FixtureContestedClaimUpcaster, FixtureContestedV1, FixtureRivalClaimUpcaster, UpcasterTestMap
13EventVersioningConventionTestsMMCA.Common.Architecture.Tests3CommonArchitectureMap, EventConventionTestsBase, IArchitectureMap
13FakeConsumerMapMMCA.Common.Architecture.Tests5ArchitectureMapBase, BaseIntegrationEvent, EventScopeFitnessTests, Layer, LayerRef
13HandlerResultConventionTestsMMCA.Common.Architecture.Tests3CommonArchitectureMap, HandlerResultConventionTestsBase, IArchitectureMap
13IdempotencyConventionTestsMMCA.Common.Architecture.Tests3CommonArchitectureMap, IArchitectureMap, IdempotencyConventionTestsBase
13LayerDependencyTestsMMCA.Common.Architecture.Tests3CommonArchitectureMap, IArchitectureMap, LayerDependencyTestsBase
13LocalizedTextConventionTestsMMCA.Common.Architecture.Tests3CommonArchitectureMap, IArchitectureMap, LocalizedTextConventionTestsBase
13MicroserviceExtractionTestsMMCA.Common.Architecture.Tests3CommonArchitectureMap, IArchitectureMap, MicroserviceExtractionTestsBase
13NamespaceCycleTestsMMCA.Common.Architecture.Tests3CommonArchitectureMap, IArchitectureMap, NamespaceCycleTestsBase
13PiiConventionTestsMMCA.Common.Architecture.Tests3CommonArchitectureMap, IArchitectureMap, PiiConventionTestsBase
13RawQueryableConventionTestsMMCA.Common.Architecture.Tests4ArchitectureMapBase, CommonArchitectureMap, IArchitectureMap, RawQueryableConventionTestsBase
13ServiceContractPurityTestsMMCA.Common.Architecture.Tests3CommonArchitectureMap, IArchitectureMap, ServiceContractPurityTestsBase
13SliceCohesionTestsMMCA.Common.Architecture.Tests3CommonArchitectureMap, IArchitectureMap, SliceCohesionTestsBase
13StateManagementConventionTestsMMCA.Common.Architecture.Tests3CommonArchitectureMap, IArchitectureMap, StateManagementConventionTestsBase
13UIArchitectureConventionTestsMMCA.Common.Architecture.Tests3CommonArchitectureMap, IArchitectureMap, UIArchitectureConventionTestsBase
13UpcasterTestMapMMCA.Common.Architecture.Tests4ArchitectureMapBase, EventUpcasterFitnessTests, Layer, LayerRef
13ApplicationDbContextEFFactoryMMCA.Common.Infrastructure6ApplicationDbContext, CosmosDbContext, DataSource, IDbContextFactory, SqliteDbContext, SQLServerDbContext
13AuditTrailCleanupJobMMCA.Common.Infrastructure10AuditTrailEntry, AuditTrailSettings, DataSource, IDbContextFactory, IEntityDataSourceRegistry, IScheduledJob, ITenantContext, TenancySettings, TenantDataSourceTarget, TenantDataSourceTargets
13AuditTrailReaderMMCA.Common.Infrastructure7AuditTrailEntry, AuditTrailEntryDTO, AuditTrailSettings, DataSourceKey, IAuditTrailReader, IDataSourceResolver, IDbContextFactory
13BrokerEventBusMMCA.Common.Infrastructure7IDataSourceResolver, IDbContextFactory, IEventBus, IIntegrationEvent, IOutboxSignal, OutboxMessage, OutboxSettings
13DbContextFactoryMMCA.Common.Infrastructure18ApplicationDbContext, CosmosDbContext, DataSource, DataSourceKey, DomainEventSaveChangesInterceptor, ICurrentUserService, IDataSourceResolver, IDbContextFactory, IdentityInsertGroup, IEntityDataSourceRegistry, IPhysicalDbContextFactory, ITenantContext, PhysicalDataSource, Result, SQLServerDbContext, TenancySettings, TenancySettingsValidator, TransactionCommitAmbiguousException
13DefaultCosmosDbContextFactoryMMCA.Common.Infrastructure5CosmosDbContext, DataSource, DataSourceKey, IDbContextFactory, IPhysicalDbContextFactory
13DefaultSqliteDbContextFactoryMMCA.Common.Infrastructure5DataSource, DataSourceKey, IDbContextFactory, IPhysicalDbContextFactory, SqliteDbContext
13DefaultSqlServerDbContextFactoryMMCA.Common.Infrastructure5DataSource, DataSourceKey, IDbContextFactory, IPhysicalDbContextFactory, SQLServerDbContext
13DesignTimeDbContextHelperMMCA.Common.Infrastructure22AuditSaveChangesInterceptor, AuditTrailSaveChangesInterceptor, AuditTrailSettings, DataSource, DataSourceKey, DataSourceResolver, DataSourcesSettings, DesignTimeDbContextOptions, DomainEventSaveChangesInterceptor, EntityDataSourceRegistry, ExplicitAssemblyProvider, IDataSourceResolver, IDomainEventDispatcher, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, IOutboxSignal, NullDomainEventDispatcher, OutboxSignal, SchedulerSettings, SQLServerDbContext …(+2)
13EfInboxStoreMMCA.Common.Infrastructure6ApplicationDbContext, IDataSourceResolver, IDbContextFactory, IInboxStore, InboxMessage, OutboxSettings
13InProcessEventBusMMCA.Common.Infrastructure8IDataSourceResolver, IDbContextFactory, IDomainEventDispatcher, IEventBus, IIntegrationEvent, OutboxFinalizer, OutboxMessage, OutboxSettings
13OutboxCleanupServiceMMCA.Common.Infrastructure13DataSource, DataSourceKey, IDataSourceResolver, IDbContextFactory, IEntityDataSourceRegistry, InboxMessage, ITenantContext, MessageBusSettings, OutboxMessage, OutboxSettings, TenancySettings, TenantDataSourceTarget, TenantDataSourceTargets
13OutboxProcessorMMCA.Common.Infrastructure22Activity, ApplicationDbContext, BrokerMetrics, BrokerResilienceDefaults, DataSource, DataSourceKey, Event, IDataSourceResolver, IDbContextFactory, IDomainEventDispatcher, IEntityDataSourceRegistry, IIntegrationEvent, IMessageBus, IOutboxSignal, ITenantContext, OutboxCycleResult, OutboxMessage, OutboxMetrics, OutboxSettings, TenancySettings …(+2)
13PhysicalDbContextFactoryMMCA.Common.Infrastructure10ApplicationDbContext, CosmosDbContext, DataSource, DataSourceKey, IDataSourceResolver, IEntityConfigurationAssemblyProvider, IPhysicalDbContextFactory, PhysicalDataSource, SqliteDbContext, SQLServerDbContext
13RepositoryFactoryMMCA.Common.Infrastructure10AuditableAggregateRootEntity<TIdentifierType>, AuditableBaseEntity<TIdentifierType>, EFReadRepository<TEntity, TIdentifierType>, EFReadRepositoryDecorator<TEntity, TIdentifierType>, EFRepository<TEntity, TIdentifierType>, EFRepositoryDecorator<TEntity, TIdentifierType>, IApplicationSettings, IReadRepository<TEntity, TIdentifierType>, IRepository<TEntity, TIdentifierType>, IRepositoryFactory
13ScheduledJobRunnerMMCA.Common.Infrastructure9ApplicationDbContext, DataSourceKey, IDataSourceResolver, IDbContextFactory, IScheduledJob, JobClaim, ScheduledJobEntry, SchedulerMetrics, SchedulerSettings
13UnitOfWorkMMCA.Common.Infrastructure8AuditableAggregateRootEntity<TIdentifierType>, AuditableBaseEntity<TIdentifierType>, IDataSourceService, IDbContextFactory, IReadRepository<TEntity, TIdentifierType>, IRepository<TEntity, TIdentifierType>, IRepositoryFactory, IUnitOfWork
13ApplicationDbContextTenantFilterTestsMMCA.Common.Infrastructure.Tests6ApplicationDbContext, EFReadRepository<TEntity, TIdentifierType>, PlainThing, TenantDetail, TenantTestContext, TenantThing
13ApplicationDbContextTestsMMCA.Common.Infrastructure.Tests4ApplicationDbContext, DataSource, TestApplicationDbContext, TestEntity
13AuditSaveChangesInterceptorTestsMMCA.Common.Infrastructure.Tests4AuditSaveChangesInterceptor, FakeTimeProvider, TestAuditDbContext, TestAuditEntity
13AuditTrailModelGateTestsMMCA.Common.Infrastructure.Tests3AuditTrailEntry, DataSourceKey, GateTestContext
13AuditTrailTestContextMMCA.Common.Infrastructure.Tests17ApplicationDbContext, AuditedThing, AuditSaveChangesInterceptor, AuditTrailEntry, AuditTrailSaveChangesInterceptor, CompositeKeyThing, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, FailingSaveInterceptor, FailingSaveInterceptor, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, NullAssemblyProvider, NullAssemblyProvider, PlainThing, TestPhysicalDataSources
13CrossDataSourceDegradeConventionTestsMMCA.Common.Infrastructure.Tests14AuditSaveChangesInterceptor, DataSource, DataSourceKey, DataSourceModelCacheKeyFactory, DegradeCustomer, DegradeOrder, DegradeTestContext, DomainEventSaveChangesInterceptor, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, MapRegistry, OutboxSignal, PhysicalDataSource
13DependencyInjectionAdditionalTestsMMCA.Common.Infrastructure.Tests6EntityConfigurationOptions, IDataSourceService, IDbContextFactory, IQueryableExecutor, IRepositoryFactory, IUnitOfWork
13DomainEventCaptureExclusionTestsMMCA.Common.Infrastructure.Tests7DomainEventSaveChangesInterceptor, ExclusionAggregate, ExclusionEvent, ExclusionTestDbContext, IDomainEvent, IDomainEventDispatcher, IOutboxSignal
13DomainEventSaveChangesInterceptorOutboxRoutingTestsMMCA.Common.Infrastructure.Tests9DomainEventSaveChangesInterceptor, IDomainEvent, IDomainEventDispatcher, IOutboxSignal, OutboxMessage, OutboxRoutingTestDbContext, TestAggregate, TestIntegrationEvent, TestLocalEvent
13DomainEventSaveChangesInterceptorTestsMMCA.Common.Infrastructure.Tests7DomainEventSaveChangesInterceptor, IDomainEvent, IDomainEventDispatcher, IOutboxSignal, TestAggregate, TestDomainEvent, TestDomainEventDbContext
13EFReadRepositoryGetByIdFilterTestsMMCA.Common.Infrastructure.Tests3EFReadRepository<TEntity, TIdentifierType>, SoftDeletableTestEntity, SoftDeleteTestDbContext
13EFReadRepositoryKeysetPagingTestsMMCA.Common.Infrastructure.Tests8BbbSpecification, Category, EFReadRepository<TEntity, TIdentifierType>, ErrorType, KeysetCursor, KeysetPageRequest, SpecificationTestDbContext, SpecTestEntity
13EFReadRepositoryProjectedFilterTestsMMCA.Common.Infrastructure.Tests3EFReadRepository<TEntity, TIdentifierType>, NamedSoftDeleteTestDbContext, ProjectedTestEntity
13EFReadRepositorySpecificationTestsMMCA.Common.Infrastructure.Tests15AllSpecification, BetaSpecification, Category, DeletedByNameSpecification, EFReadRepository<TEntity, TIdentifierType>, HighRankSpecification, IncludingSoftDeletedSpecification, IncludingSpecification, ISpecification<TEntity, TIdentifierType>, NoMatchSpecification, SpecificationTestDbContext, SpecTestChild, SpecTestEntity, TopTwoByRankSpecification, TrackedSpecification
13EFRepositoryAdditionalTestsMMCA.Common.Infrastructure.Tests3EFRepository<TEntity, TIdentifierType>, TestDbContext, TestEntity
13EFRepositoryAuditStampTestsMMCA.Common.Infrastructure.Tests5EFRepository<TEntity, TIdentifierType>, ICurrentUserService, PlainDbContext, StampedEntity, StampTestDbContext
13EFRepositoryIntegrationTestsMMCA.Common.Infrastructure.Tests7EFReadRepository<TEntity, TIdentifierType>, EFRepository<TEntity, TIdentifierType>, FakeTimeProvider, ICurrentUserService, TestChildEntity, TestDbContext, TestEntity
13FailingSaveInterceptorMMCA.Common.Infrastructure.Tests1AuditTrailTestContext
13MarkAllNotificationsReadHandlerTrackingTestsMMCA.Common.Infrastructure.Tests10EFQueryableExecutor, EFRepository<TEntity, TIdentifierType>, IUnitOfWork, MarkAllNotificationsReadCommand, MarkAllNotificationsReadHandler, NotificationTestDbContext, PushNotification, Result, SeededIds, UserNotification
13MocksMMCA.Common.Infrastructure.Tests4IDataSourceResolver, IDbContextFactory, IDomainEventDispatcher, IOutboxSignal
13QueryParameterizationTestsMMCA.Common.Infrastructure.Tests3QueryFieldService, QueryFilterService, QueryShapeTestDbContext
13SaveChangeDetectionTestsMMCA.Common.Infrastructure.Tests2DetectionTestDbContext, Widget
13SchedulerModelGateTestsMMCA.Common.Infrastructure.Tests3DataSourceKey, GateTestContext, ScheduledJobEntry
13SoftDeleteQueryFilterTestsMMCA.Common.Infrastructure.Tests2SoftDeletableEntity, SoftDeleteTestDbContext
13SoftDeleteUniqueIndexConventionTestsMMCA.Common.Infrastructure.Tests3FilteredIndexEntity, UniqueIndexTestDbContext, UniqueNamedEntity
13SpecificationEvaluatorTestsMMCA.Common.Infrastructure.Tests11BetaSpecification, Category, IncludingSpecification, OrderedSpecification, PagedSpecification, RankDescendingSpecification, SpecificationEvaluator, SpecificationTestDbContext, SpecTestChild, SpecTestEntity, UnorderedQuerySpecification
13SQLServerDbContextTestsMMCA.Common.Infrastructure.Tests11AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, EmptyAssemblyProvider, EmptyEntityDataSourceRegistry, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, OutboxSignal, PersistenceSettings, SQLServerDbContext, TestPhysicalDataSources
13TenantSaveChangesInterceptorTestsMMCA.Common.Infrastructure.Tests5CrossTenantWriteException, PlainThing, TenantTestContext, TenantThing, TrailedTenantThing
13ComponentsPageE2ETestsMMCA.Common.UI.E2E.Tests4AxeOptions, GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixture
13DarkModeE2ETestsMMCA.Common.UI.E2E.Tests4AxeOptions, GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixture
13ForgotPasswordPageE2ETestsMMCA.Common.UI.E2E.Tests5AxeOptions, ForgotPasswordPage, GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixture
13LoginPageE2ETestsMMCA.Common.UI.E2E.Tests5AxeOptions, GalleryAxeTestBase, GalleryHostFixture, LoginPage, PlaywrightFixture
13MobileTopRowE2ETestsMMCA.Common.UI.E2E.Tests3GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixture
13NotificationPagesE2ETestsMMCA.Common.UI.E2E.Tests4AxeOptions, GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixture
13PseudoLocalizationE2ETestsMMCA.Common.UI.E2E.Tests4GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixture, SupportedCultures
13RegisterPageE2ETestsMMCA.Common.UI.E2E.Tests5AxeOptions, GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixture, RegisterPage
13ResetPasswordPageE2ETestsMMCA.Common.UI.E2E.Tests5AxeOptions, GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixture, ResetPasswordPage
13StickySidebarE2ETestsMMCA.Common.UI.E2E.Tests3GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixture
13WebVitalsE2ETestsMMCA.Common.UI.E2E.Tests4GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixture, WebVitalsCollector
14RefreshFromSessionizeHandlerMMCA.ADC.Conference.Application19CategorySyncStrategy, Error, Event, ICommandHandler<in TCommand, TResult>, ICurrentUserService, ISessionizeService, ISessionizeSyncStrategy, IUnitOfWork, QuestionSyncStrategy, RefreshFromSessionizeCommand, RefreshFromSessionizeResultDTO, Result, RoomSyncStrategy, SessionizeResponse, SessionizeSyncContext, SessionizeSyncResult, SessionSyncStrategy, SpeakerSyncStrategy, UnitOfWork
14CategorySyncStrategyTestsMMCA.ADC.Conference.Application.Tests10Category, CategorySyncStrategy, Event, IRepository<TEntity, TIdentifierType>, IUnitOfWork, SessionizeCategory, SessionizeCategoryItem, SessionizeResponse, SessionizeSyncContext, UnitOfWork
14QuestionSyncStrategyTestsMMCA.ADC.Conference.Application.Tests11Event, IRepository<TEntity, TIdentifierType>, IUnitOfWork, Question, QuestionSyncStrategy, SessionizeQuestion, SessionizeQuestionAnswer, SessionizeResponse, SessionizeSpeaker, SessionizeSyncContext, UnitOfWork
14RoomSyncStrategyTestsMMCA.ADC.Conference.Application.Tests10Event, EventInvariants, IReadRepository<TEntity, TIdentifierType>, IUnitOfWork, Room, RoomSyncStrategy, SessionizeResponse, SessionizeRoom, SessionizeSyncContext, UnitOfWork
14SessionSyncStrategyTestsMMCA.ADC.Conference.Application.Tests9Event, IRepository<TEntity, TIdentifierType>, IUnitOfWork, Session, SessionizeResponse, SessionizeSession, SessionizeSyncContext, SessionSyncStrategy, UnitOfWork
14SpeakerSyncStrategyTestsMMCA.ADC.Conference.Application.Tests11Event, EventSpeaker, IReadRepository<TEntity, TIdentifierType>, IRepository<TEntity, TIdentifierType>, IUnitOfWork, SessionizeResponse, SessionizeSpeaker, SessionizeSyncContext, Speaker, SpeakerSyncStrategy, UnitOfWork
14ConferenceTestWebApplicationFactoryMMCA.ADC.Conference.IntegrationTests9FakeAiScoringService, FakeBookmarkCountService, FakeSessionizeService, IAiScoringService, IBookmarkCountService, ISessionizeService, JwtTokenGenerator, Program, WebApplicationBuilderExtensions
14ConferenceCrossServiceFactoryMMCA.ADC.CrossService.IntegrationTests4JwtTokenGenerator, Program, RateLimiterNeutralizer, WebApplicationBuilderExtensions
14EngagementCrossServiceFactoryMMCA.ADC.CrossService.IntegrationTests4JwtTokenGenerator, Program, RateLimiterNeutralizer, WebApplicationBuilderExtensions
14IdentityCrossServiceFactoryMMCA.ADC.CrossService.IntegrationTests2Program, RateLimiterNeutralizer
14EngagementTestWebApplicationFactoryMMCA.ADC.Engagement.IntegrationTests9FakeEventLiveValidationService, FakeSessionBookmarkValidationService, IEventLiveValidationService, ILiveChannelPublisher, ISessionBookmarkValidationService, JwtTokenGenerator, NullLiveChannelPublisher, Program, WebApplicationBuilderExtensions
14GatewayApplicationFactoryMMCA.ADC.Gateway.Tests2Program, RecordingHttpForwarder
14GracefulShutdownTestsMMCA.ADC.Gateway.Tests2GracefulShutdownTestsBase<TEntryPoint>, Program
14RouteMapApplicationFactoryMMCA.ADC.Gateway.Tests2Program, RecordingHttpForwarder
14SecurityHeadersTestsMMCA.ADC.Gateway.Tests3ProductionHostApplicationFactory<TEntryPoint>, Program, SecurityHeadersTestsBase
14AuthenticationServiceMMCA.ADC.Identity.Application18AuthenticationResponse, AuthenticationServiceBase<TUser>, AuthenticationValidators, Email, Error, IAuthenticationService, IExternalLoginEmailVerifier, ILoginProtectionService, IPasswordHasher, ITokenService, IUnitOfWork, RegisterRequest, Result, TokenService, UnitOfWork, User, UserRegistered, UserRole
14ForgotPasswordHandlerMMCA.ADC.Identity.Application9Email, ForgotPasswordCommand, ForgotPasswordHandlerBase<TUser, TCommand>, IEmailSender, IPasswordResetTokenService, IUnitOfWork, PasswordResetSettings, UnitOfWork, User
14IdentityTestWebApplicationFactoryMMCA.ADC.Identity.IntegrationTests6FakeUserEngagementExportService, FakeUserNotificationExportService, IUserEngagementExportService, IUserNotificationExportService, PiiCaptureLoggerProvider, Program
14NotificationTestWebApplicationFactoryMMCA.ADC.Notification.IntegrationTests5FakeAttendeeQueryService, IAttendeeQueryService, JwtTokenGenerator, Program, WebApplicationBuilderExtensions
14DatabaseInitializationExtensionsTestsMMCA.Common.API.Tests23ApplicationSettings, AuditSaveChangesInterceptor, ConnectionStringSettings, DataSource, DataSourceEntrySettings, DataSourceResolver, DataSourcesSettings, DbContextFactory, DomainEventSaveChangesInterceptor, EntityDataSourceRegistry, FixedAssemblyProvider, ICurrentUserService, IDataSourceResolver, IDbContextFactory, IDomainEventDispatcher, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, InitTestWidget, IOutboxSignal, IPhysicalDbContextFactory …(+3)
14FixedAssemblyProviderMMCA.Common.API.Tests2DatabaseInitializationExtensionsTests, IEntityConfigurationAssemblyProvider
14DependencyInjectionMMCA.Common.Infrastructure127ApplicationDbContext, ApplicationDbContextEFFactory, AuditSaveChangesInterceptor, AuditTrailCleanupJob, AuditTrailReader, AuditTrailSaveChangesInterceptor, AuditTrailSettings, AzureBlobFileStorageService, AzureNotificationHubDeviceRegistrar, AzureNotificationHubNativePushSender, BrokerEventBus, BrokerMessageBus, CacheKeyNamespace, CacheKeyPrefixOptions, CacheOptions, ClaimBasedUserIdProvider, ClassReference, ConnectionStringSettings, CorrelationContext, CosmosDbContext …(+107)
14IdentityModuleDbSeederBase<TUser>MMCA.Common.Infrastructure9AuditableAggregateRootEntity<TIdentifierType>, DbSeeder, Email, IPasswordHasher, IUnitOfWork, PasswordHasher, Result, SeedAccount, UnitOfWork
14AddAuditTrailTestsMMCA.Common.Infrastructure.Tests6AuditTrailCleanupJob, AuditTrailReader, AuditTrailSaveChangesInterceptor, AuditTrailSettings, IAuditTrailReader, IScheduledJob
14AddScheduledJobsTestsMMCA.Common.Infrastructure.Tests5FirstJob, IScheduledJob, ScheduledJobRunner, SchedulerSettings, SecondJob
14ApplicationDbContextEFFactoryTestsMMCA.Common.Infrastructure.Tests5ApplicationDbContextEFFactory, CosmosDbContext, IDbContextFactory, SqliteDbContext, SQLServerDbContext
14AuditTrailSaveChangesInterceptorTestsMMCA.Common.Infrastructure.Tests13AuditedThing, AuditTrailEntry, AuditTrailSaveChangesInterceptor, AuditTrailTestContext, AuditTrailTestHarness, CompositeKeyThing, Email, FakeTimeProvider, InboxMessage, OutboxMessage, PiiRedactor, PlainThing, ScheduledJobEntry
14BrokerEventBusTestsMMCA.Common.Infrastructure.Tests19ApplicationDbContext, AuditSaveChangesInterceptor, BrokerEventBus, DataSource, DataSourceKey, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, IDataSourceResolver, IDbContextFactory, IDomainEventDispatcher, IEntityDataSourceRegistry, IIntegrationEvent, IOutboxSignal, Mocks, OutboxMessage, OutboxSettings, TestIntegrationEvent, TestNonOutboxContext, TestOutboxContext
14BrokerMessageBusTestsMMCA.Common.Infrastructure.Tests5BrokerMessageBus, IIntegrationEvent, Mocks, OtherIntegrationEvent, TestIntegrationEvent
14CosmosConfigurationPortabilityTestsMMCA.Common.Infrastructure.Tests17AuditSaveChangesInterceptor, ConnectionStringSettings, DataSource, DataSourceEntrySettings, DataSourceResolver, DataSourcesSettings, DomainEventSaveChangesInterceptor, EntityDataSourceRegistry, FixedAssemblyProvider, IDataSourceResolver, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, OutboxSignal, PhysicalDbContextFactory, PortablePrincipal, PortableThing
14CronosNextOccurrenceTestsMMCA.Common.Infrastructure.Tests1ScheduledJobRunner
14DbContextFactoryAdditionalTestsMMCA.Common.Infrastructure.Tests8DataSource, DataSourceKey, DbContextFactory, ICurrentUserService, IDataSourceResolver, IEntityDataSourceRegistry, IPhysicalDbContextFactory, MidSaveContextCreatingDbContext
14DbContextFactoryCommitAmbiguityTestsMMCA.Common.Infrastructure.Tests14CommitFailingDbContext, DataSource, DataSourceKey, DbContextFactory, ICurrentUserService, IDataSourceResolver, IDomainEvent, IDomainEventDispatcher, IEntityDataSourceRegistry, IPhysicalDbContextFactory, Result, TestAggregate, TestLocalEvent, TransactionCommitAmbiguousException
14DbContextFactorySaveIntegrityTestsMMCA.Common.Infrastructure.Tests12DataSource, DataSourceKey, DbContextFactory, ICurrentUserService, IDataSourceResolver, IDomainEvent, IDomainEventDispatcher, IEntityDataSourceRegistry, IntegrityAggregate, IntegrityEvent, IntegrityTestDbContext, IPhysicalDbContextFactory
14DbContextFactoryTenantTestsMMCA.Common.Infrastructure.Tests15ApplicationDbContext, DataSource, DataSourceKey, DbContextFactory, ICurrentUserService, IDataSourceResolver, IEntityDataSourceRegistry, IPhysicalDbContextFactory, ITenantContext, MutableTenantContext, PhysicalDataSource, TenancySettings, TenantDataSourceOverrideSettings, TenantEntrySettings, TenantTestContext
14DbContextFactoryTestsMMCA.Common.Infrastructure.Tests8ApplicationDbContext, DataSource, DataSourceKey, DbContextFactory, ICurrentUserService, IDataSourceResolver, IEntityDataSourceRegistry, IPhysicalDbContextFactory
14DbContextFactoryTransactionTestsMMCA.Common.Infrastructure.Tests15DataSource, DataSourceKey, DbContextFactory, Error, ICurrentUserService, IDataSourceResolver, IDomainEvent, IDomainEventDispatcher, IEntityDataSourceRegistry, IPhysicalDbContextFactory, OutboxMessage, Result, TestAggregate, TestLocalEvent, TransactionTestDbContext
14DependencyInjectionBrokerMessagingTestsMMCA.Common.Infrastructure.Tests4EfInboxStore, IInboxStore, InboxDisabledWarningService, NoOpInboxStore
14DependencyInjectionInfrastructureTestsMMCA.Common.Infrastructure.Tests16AuditSaveChangesInterceptor, ConnectionStringSettings, DomainEventSaveChangesInterceptor, EntityConfigurationOptions, IConnectionStringSettings, IDataSourceService, IEntityConfigurationAssemblyProvider, IJwtSettings, IQueryableExecutor, IRepository<TEntity, TIdentifierType>, IRepositoryFactory, ISmtpSettings, IUnitOfWork, OutboxProcessor, OutboxSettings, SmtpSettings
14DependencyInjectionTestsMMCA.Common.Infrastructure.Tests23CorrelationContext, CurrentUserService, DistributedCacheService, EntityConfigurationOptions, ICacheService, ICorrelationContext, ICurrentUserService, IDistributedLock, IEmailSender, IEventBus, ILiveChannelPublisher, InProcessDistributedLock, InProcessEventBus, IPasswordHasher, IPushNotificationSender, ITokenService, MemoryCacheService, NullLiveChannelPublisher, NullPushNotificationSender, PasswordHasher …(+3)
14DesignTimeDbContextHelperTestsMMCA.Common.Infrastructure.Tests8ConnectionStringSettings, DataSource, DataSourceEntrySettings, DataSourceKey, DesignAlphaEntity, DesignBetaEntity, DesignTimeDbContextHelper, DesignTimeDbContextOptions
14EfInboxStoreTestsMMCA.Common.Infrastructure.Tests16ApplicationDbContext, AuditSaveChangesInterceptor, DataSource, DataSourceKey, DomainEventSaveChangesInterceptor, EfInboxStore, EmptyEntityDataSourceRegistry, IDataSourceResolver, IDbContextFactory, IDomainEventDispatcher, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, InboxMessage, InboxTestDbContext, IOutboxSignal, OutboxSettings
14FixedAssemblyProviderMMCA.Common.Infrastructure.Tests3CosmosConfigurationPortabilityTests, IEntityConfigurationAssemblyProvider, MultiSourceSqliteIntegrationTests
14InProcessEventBusOutboxTestsMMCA.Common.Infrastructure.Tests11DataSource, DataSourceKey, IDataSourceResolver, IDbContextFactory, IDomainEvent, IDomainEventDispatcher, InProcessEventBus, OutboxMessage, OutboxSettings, TestIntegrationEvent, TestOutboxContext
14InProcessEventBusTestsMMCA.Common.Infrastructure.Tests10DataSource, DataSourceKey, IDataSourceResolver, IDbContextFactory, IDomainEvent, IDomainEventDispatcher, IIntegrationEvent, InProcessEventBus, OutboxSettings, TestNonOutboxContext
14InProcessMessageBusTestsMMCA.Common.Infrastructure.Tests16DomainEventDispatcher, IDomainEvent, IDomainEventDispatcher, IDomainEventHandler<in TDomainEvent>, IIntegrationEvent, IIntegrationEventHandler<in TIntegrationEvent>, InProcessMessageBus, Mocks, RecordingDomainHandler, RecordingIntegrationHandler, RecordingOriginalHandler, RecordingSuccessorHandler, RetiredTestIntegrationEvent, RetiredToV2Upcaster, TestIntegrationEvent, TestIntegrationEventV2
14MocksMMCA.Common.Infrastructure.Tests6IDataSourceResolver, IDataSourceService, IDbContextFactory, IEntityDataSourceRegistry, IRepositoryFactory, OutboxCleanupService
14MultiSourceSqliteIntegrationTestsMMCA.Common.Infrastructure.Tests26AuditSaveChangesInterceptor, ConnectionStringSettings, DataSource, DataSourceEntrySettings, DataSourceResolver, DataSourceService, DataSourcesSettings, DbContextFactory, DomainEventSaveChangesInterceptor, EntityDataSourceRegistry, FixedAssemblyProvider, IApplicationSettings, ICurrentUserService, IDataSourceResolver, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, MultiSourceCustomer, MultiSourceOrder, MultiSourceTestEvent …(+6)
14OutboxProcessorTestsMMCA.Common.Infrastructure.Tests23AuditSaveChangesInterceptor, BrokerResilienceDefaults, DataSource, DataSourceKey, DomainEventSaveChangesInterceptor, EmptyEntityDataSourceRegistry, FakeTimeProvider, IDataSourceResolver, IDbContextFactory, IDomainEvent, IDomainEventDispatcher, IEntityConfigurationAssemblyProvider, IEntityDataSourceRegistry, IIntegrationEvent, IMessageBus, IOutboxSignal, OutboxCycleResult, OutboxMessage, OutboxProcessor, OutboxSettings …(+3)
14OutboxProcessorWaitTestsMMCA.Common.Infrastructure.Tests1OutboxProcessor
14RepositoryFactoryTestsMMCA.Common.Infrastructure.Tests11EFReadRepository<TEntity, TIdentifierType>, EFReadRepositoryDecorator<TEntity, TIdentifierType>, EFRepository<TEntity, TIdentifierType>, EFRepositoryDecorator<TEntity, TIdentifierType>, FakeAggregate, FakeEntity, IApplicationSettings, IReadRepository<TEntity, TIdentifierType>, IRepository<TEntity, TIdentifierType>, RepositoryFactory, TestDbContext
14ScheduledJobRunnerTestsMMCA.Common.Infrastructure.Tests10ApplicationDbContext, DataSource, DelegateScheduledJob, FakeTimeProvider, IDataSourceResolver, ScheduledJobEntry, ScheduledJobOverrideSettings, ScheduledJobRunner, SchedulerSettings, SchedulerTestContext
14SchedulerTestHarnessMMCA.Common.Infrastructure.Tests9ApplicationDbContext, DataSource, DataSourceKey, FakeTimeProvider, IDataSourceResolver, IDbContextFactory, IScheduledJob, ScheduledJobRunner, SchedulerSettings
14TenantDataSourceTargetTestsMMCA.Common.Infrastructure.Tests14DataSource, DataSourceKey, IDataSourceResolver, IEntityDataSourceRegistry, IOutboxSignal, MessageBusSettings, OutboxCleanupService, OutboxProcessor, OutboxSettings, TenancySettings, TenantDataSourceOverrideSettings, TenantDataSourceTarget, TenantDataSourceTargets, TenantEntrySettings
14HandlerTestBase<THandler>MMCA.Common.Testing6AuditableAggregateRootEntity<TIdentifierType>, AuditableBaseEntity<TIdentifierType>, IReadRepository<TEntity, TIdentifierType>, IRepository<TEntity, TIdentifierType>, IUnitOfWork, UnitOfWork
15ActivityNavigationPopulatorTestsMMCA.ADC.Conference.Application.Tests6Activity, ActivityNavigationPopulator, HandlerTestBase<THandler>, INavigationPopulator<in TEntity>, NavigationMetadata, UnitOfWork
15AddCategoryItemHandlerTestsMMCA.ADC.Conference.Application.Tests8AddCategoryItemCommand, AddCategoryItemHandler, Category, CategoryItemDTOMapper, ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, UnitOfWork
15AddEventQuestionAnswerHandlerTestsMMCA.ADC.Conference.Application.Tests10AddEventQuestionAnswerCommand, AddEventQuestionAnswerHandler, ErrorType, Event, EventQuestionAnswerDTOMapper, HandlerTestBase<THandler>, ICurrentUserService, IRepository<TEntity, TIdentifierType>, Question, UnitOfWork
15AddEventSpeakerHandlerTestsMMCA.ADC.Conference.Application.Tests8AddEventSpeakerCommand, AddEventSpeakerHandler, ErrorType, Event, EventSpeakerDTOMapper, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, UnitOfWork
15AddRoomHandlerTestsMMCA.ADC.Conference.Application.Tests12AddRoomCommand, AddRoomHandler, ErrorType, Event, EventInvariants, HandlerTestBase<THandler>, IReadRepository<TEntity, TIdentifierType>, IRepository<TEntity, TIdentifierType>, IUnitOfWork, Room, RoomDTOMapper, UnitOfWork
15AddSessionCategoryItemHandlerTestsMMCA.ADC.Conference.Application.Tests8AddSessionCategoryItemCommand, AddSessionCategoryItemHandler, ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, Session, SessionCategoryItemDTOMapper, UnitOfWork
15AddSessionQuestionAnswerHandlerTestsMMCA.ADC.Conference.Application.Tests11AddSessionQuestionAnswerCommand, AddSessionQuestionAnswerHandler, ErrorType, Event, HandlerTestBase<THandler>, ICurrentUserService, IRepository<TEntity, TIdentifierType>, Question, Session, SessionQuestionAnswerDTOMapper, UnitOfWork
15AddSessionSpeakerHandlerTestsMMCA.ADC.Conference.Application.Tests8AddSessionSpeakerCommand, AddSessionSpeakerHandler, ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, Session, SessionSpeakerDTOMapper, UnitOfWork
15AddSpeakerCategoryItemHandlerTestsMMCA.ADC.Conference.Application.Tests8AddSpeakerCategoryItemCommand, AddSpeakerCategoryItemHandler, ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, Speaker, SpeakerCategoryItemDTOMapper, UnitOfWork
15CategoryItemNavigationPopulatorTestsMMCA.ADC.Conference.Application.Tests6CategoryItem, CategoryItemNavigationPopulator, HandlerTestBase<THandler>, INavigationPopulator<in TEntity>, NavigationMetadata, UnitOfWork
15ConferenceCategoryNavigationPopulatorTestsMMCA.ADC.Conference.Application.Tests6Category, ConferenceCategoryNavigationPopulator, HandlerTestBase<THandler>, INavigationPopulator<in TEntity>, NavigationMetadata, UnitOfWork
15CreateActivityHandlerTestsMMCA.ADC.Conference.Application.Tests10Activity, ActivityCreateRequest, ActivityDTOMapper, CreateActivityHandler, Error, HandlerTestBase<THandler>, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, IRepository<TEntity, TIdentifierType>, Result, UnitOfWork
15CreateConferenceCategoryHandlerTestsMMCA.ADC.Conference.Application.Tests11Category, CategoryItemDTOMapper, ConferenceCategoryCreateRequest, ConferenceCategoryDTOMapper, CreateConferenceCategoryHandler, Error, HandlerTestBase<THandler>, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, IRepository<TEntity, TIdentifierType>, Result, UnitOfWork
15CreateEventHandlerTestsMMCA.ADC.Conference.Application.Tests13CreateEventHandler, Error, Event, EventCreateRequest, EventDTOMapper, EventQuestionAnswerDTOMapper, EventSpeakerDTOMapper, HandlerTestBase<THandler>, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, IRepository<TEntity, TIdentifierType>, Result, RoomDTOMapper, UnitOfWork
15CreateQuestionHandlerTestsMMCA.ADC.Conference.Application.Tests11CreateQuestionHandler, HandlerTestBase<THandler>, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, IRepository<TEntity, TIdentifierType>, IUnitOfWork, Question, QuestionCreateRequest, QuestionDTOMapper, QuestionInvariants, Result, UnitOfWork
15CreateSessionHandlerTestsMMCA.ADC.Conference.Application.Tests17CreateSessionHandler, Error, ErrorType, Event, HandlerTestBase<THandler>, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, IRepository<TEntity, TIdentifierType>, IUnitOfWork, Result, Session, SessionCategoryItemDTOMapper, SessionCreateRequest, SessionDTOMapper, SessionInvariants, SessionQuestionAnswerDTOMapper, SessionSpeakerDTOMapper, UnitOfWork
15CreateSpeakerHandlerTestsMMCA.ADC.Conference.Application.Tests14CreateSpeakerHandler, Email, Error, HandlerTestBase<THandler>, ICurrentUserService, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, IRepository<TEntity, TIdentifierType>, Result, Speaker, SpeakerCategoryItemDTOMapper, SpeakerCreateRequest, SpeakerDTOMapper, SpeakerQuestionAnswerDTOMapper, UnitOfWork
15CreateSponsorHandlerTestsMMCA.ADC.Conference.Application.Tests11CreateSponsorHandler, Error, HandlerTestBase<THandler>, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, IRepository<TEntity, TIdentifierType>, Result, Sponsor, SponsorCreateRequest, SponsorDTOMapper, SponsorTier, UnitOfWork
15DeleteEventHandlerTestsMMCA.ADC.Conference.Application.Tests12Activity, DeleteEntityCommand<TEntity, TIdentifierType>, DeleteEventHandler, ErrorType, Event, EventCascadeDeletionDomainService, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, Session, Sponsor, SponsorTier, UnitOfWork
15DeleteSessionHandlerTestsMMCA.ADC.Conference.Application.Tests7DeleteEntityCommand<TEntity, TIdentifierType>, DeleteSessionHandler, ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, Session, UnitOfWork
15EventLiveValidationServiceTestsMMCA.ADC.Conference.Application.Tests11ErrorType, Event, EventLiveValidationService, FixedTimeProvider, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, QuestionModerationDefault, Session, Sponsor, SponsorTier, UnitOfWork
15EventNavigationPopulatorTestsMMCA.ADC.Conference.Application.Tests6Event, EventNavigationPopulator, HandlerTestBase<THandler>, INavigationPopulator<in TEntity>, NavigationMetadata, UnitOfWork
15EventQuestionAnswerNavigationPopulatorTestsMMCA.ADC.Conference.Application.Tests6EventQuestionAnswer, EventQuestionAnswerNavigationPopulator, HandlerTestBase<THandler>, INavigationPopulator<in TEntity>, NavigationMetadata, UnitOfWork
15EventSpeakerNavigationPopulatorTestsMMCA.ADC.Conference.Application.Tests6EventSpeaker, EventSpeakerNavigationPopulator, HandlerTestBase<THandler>, INavigationPopulator<in TEntity>, NavigationMetadata, UnitOfWork
15GetCategoryDistributionHandlerTestsMMCA.ADC.Conference.Application.Tests8Category, GetCategoryDistributionHandler, GetCategoryDistributionQuery, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, Session, SessionStatuses, UnitOfWork
15GetContentSimilarityHandlerTestsMMCA.ADC.Conference.Application.Tests8Category, GetContentSimilarityHandler, GetContentSimilarityQuery, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, Session, SessionStatuses, UnitOfWork
15GetPublicActivityFilterHandlerTestsMMCA.ADC.Conference.Application.Tests7Activity, Event, GetPublicActivityFilterHandler, GetPublicActivityFilterQuery, HandlerTestBase<THandler>, IReadRepository<TEntity, TIdentifierType>, UnitOfWork
15GetPublicEventSpeakerFilterHandlerTestsMMCA.ADC.Conference.Application.Tests10Event, EventSpeaker, GetPublicEventSpeakerFilterHandler, GetPublicEventSpeakerFilterQuery, HandlerTestBase<THandler>, IReadRepository<TEntity, TIdentifierType>, ISpecification<TEntity, TIdentifierType>, Session, SessionSpeaker, UnitOfWork
15GetPublicSessionCategoryItemFilterHandlerTestsMMCA.ADC.Conference.Application.Tests9Event, GetPublicSessionCategoryItemFilterHandler, GetPublicSessionCategoryItemFilterQuery, HandlerTestBase<THandler>, IReadRepository<TEntity, TIdentifierType>, ISpecification<TEntity, TIdentifierType>, Session, SessionCategoryItem, UnitOfWork
15GetPublicSessionFilterHandlerTestsMMCA.ADC.Conference.Application.Tests8Event, GetPublicSessionFilterHandler, GetPublicSessionFilterQuery, HandlerTestBase<THandler>, IReadRepository<TEntity, TIdentifierType>, Session, SessionStatuses, UnitOfWork
15GetPublicSessionSpeakerFilterHandlerTestsMMCA.ADC.Conference.Application.Tests9Event, GetPublicSessionSpeakerFilterHandler, GetPublicSessionSpeakerFilterQuery, HandlerTestBase<THandler>, IReadRepository<TEntity, TIdentifierType>, ISpecification<TEntity, TIdentifierType>, Session, SessionSpeaker, UnitOfWork
15GetPublicSpeakerCategoryItemFilterHandlerTestsMMCA.ADC.Conference.Application.Tests10Event, GetPublicSpeakerCategoryItemFilterHandler, GetPublicSpeakerCategoryItemFilterQuery, HandlerTestBase<THandler>, IReadRepository<TEntity, TIdentifierType>, ISpecification<TEntity, TIdentifierType>, Session, SessionSpeaker, SpeakerCategoryItem, UnitOfWork
15GetPublicSpeakerFilterHandlerTestsMMCA.ADC.Conference.Application.Tests12Event, EventSpeaker, GetPublicSpeakerFilterHandler, GetPublicSpeakerFilterQuery, HandlerTestBase<THandler>, IReadRepository<TEntity, TIdentifierType>, ISpecification<TEntity, TIdentifierType>, Session, SessionSpeaker, SessionStatuses, Speaker, UnitOfWork
15GetPublicSponsorFilterHandlerTestsMMCA.ADC.Conference.Application.Tests8Event, GetPublicSponsorFilterHandler, GetPublicSponsorFilterQuery, HandlerTestBase<THandler>, IReadRepository<TEntity, TIdentifierType>, Sponsor, SponsorTier, UnitOfWork
15GetSessionsBySpeakerFilterHandlerTestsMMCA.ADC.Conference.Application.Tests7GetSessionsBySpeakerFilterHandler, GetSessionsBySpeakerFilterQuery, HandlerTestBase<THandler>, IReadRepository<TEntity, TIdentifierType>, Session, SessionSpeaker, UnitOfWork
15GetSessionSelectionDashboardHandlerTestsMMCA.ADC.Conference.Application.Tests12Category, ErrorType, Event, GetSessionSelectionDashboardHandler, GetSessionSelectionDashboardQuery, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, Session, SessionAiScore, SessionStatuses, Speaker, UnitOfWork
15GetSpeakersByEventFilterHandlerTestsMMCA.ADC.Conference.Application.Tests9EventSpeaker, GetSpeakersByEventFilterHandler, GetSpeakersByEventFilterQuery, HandlerTestBase<THandler>, IReadRepository<TEntity, TIdentifierType>, Session, SessionSpeaker, Speaker, UnitOfWork
15GetSpeakerSessionOverlapHandlerTestsMMCA.ADC.Conference.Application.Tests9Category, GetSpeakerSessionOverlapHandler, GetSpeakerSessionOverlapQuery, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, Session, SessionStatuses, Speaker, UnitOfWork
15LinkUserToSpeakerHandlerTestsMMCA.ADC.Conference.Application.Tests8ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, LinkUserToSpeakerCommand, LinkUserToSpeakerHandler, Speaker, SpeakerLinkedToUser, UnitOfWork
15PublishEventHandlerTestsMMCA.ADC.Conference.Application.Tests7ErrorType, Event, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, PublishEventCommand, PublishEventHandler, UnitOfWork
15RefreshFromSessionizeHandlerTestsMMCA.ADC.Conference.Application.Tests9ErrorType, Event, ICurrentUserService, IRepository<TEntity, TIdentifierType>, ISessionizeService, IUnitOfWork, RefreshFromSessionizeCommand, RefreshFromSessionizeHandler, SessionizeResponse
15RemoveCategoryItemHandlerTestsMMCA.ADC.Conference.Application.Tests7Category, ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, RemoveCategoryItemCommand, RemoveCategoryItemHandler, UnitOfWork
15RemoveEventQuestionAnswerHandlerTestsMMCA.ADC.Conference.Application.Tests9ErrorType, Event, HandlerTestBase<THandler>, ICurrentUserService, IRepository<TEntity, TIdentifierType>, RemoveEventQuestionAnswerCommand, RemoveEventQuestionAnswerHandler, RoleNames, UnitOfWork
15RemoveEventSpeakerHandlerTestsMMCA.ADC.Conference.Application.Tests7ErrorType, Event, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, RemoveEventSpeakerCommand, RemoveEventSpeakerHandler, UnitOfWork
15RemoveRoomHandlerTestsMMCA.ADC.Conference.Application.Tests7ErrorType, Event, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, RemoveRoomCommand, RemoveRoomHandler, UnitOfWork
15RemoveSessionCategoryItemHandlerTestsMMCA.ADC.Conference.Application.Tests7ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, RemoveSessionCategoryItemCommand, RemoveSessionCategoryItemHandler, Session, UnitOfWork
15RemoveSessionQuestionAnswerHandlerTestsMMCA.ADC.Conference.Application.Tests9ErrorType, HandlerTestBase<THandler>, ICurrentUserService, IRepository<TEntity, TIdentifierType>, RemoveSessionQuestionAnswerCommand, RemoveSessionQuestionAnswerHandler, RoleNames, Session, UnitOfWork
15RemoveSessionSpeakerHandlerTestsMMCA.ADC.Conference.Application.Tests7ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, RemoveSessionSpeakerCommand, RemoveSessionSpeakerHandler, Session, UnitOfWork
15RemoveSpeakerCategoryItemHandlerTestsMMCA.ADC.Conference.Application.Tests7ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, RemoveSpeakerCategoryItemCommand, RemoveSpeakerCategoryItemHandler, Speaker, UnitOfWork
15RoomNavigationPopulatorTestsMMCA.ADC.Conference.Application.Tests6HandlerTestBase<THandler>, INavigationPopulator<in TEntity>, NavigationMetadata, Room, RoomNavigationPopulator, UnitOfWork
15ScoreEventSessionsHandlerTestsMMCA.ADC.Conference.Application.Tests12HandlerTestBase<THandler>, IAiScoringService, IRepository<TEntity, TIdentifierType>, ScoreEventSessionsCommand, ScoreEventSessionsHandler, Session, SessionAiScore, SessionScoringInput, SessionScoringResult, SessionStatuses, Speaker, UnitOfWork
15SessionBookmarkValidationServiceTestsMMCA.ADC.Conference.Application.Tests6ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, Session, SessionBookmarkValidationService, UnitOfWork
15SessionCategoryItemNavigationPopulatorTestsMMCA.ADC.Conference.Application.Tests6HandlerTestBase<THandler>, INavigationPopulator<in TEntity>, NavigationMetadata, SessionCategoryItem, SessionCategoryItemNavigationPopulator, UnitOfWork
15SessionNavigationPopulatorTestsMMCA.ADC.Conference.Application.Tests6HandlerTestBase<THandler>, INavigationPopulator<in TEntity>, NavigationMetadata, Session, SessionNavigationPopulator, UnitOfWork
15SessionQuestionAnswerNavigationPopulatorTestsMMCA.ADC.Conference.Application.Tests6HandlerTestBase<THandler>, INavigationPopulator<in TEntity>, NavigationMetadata, SessionQuestionAnswer, SessionQuestionAnswerNavigationPopulator, UnitOfWork
15SessionSpeakerNavigationPopulatorTestsMMCA.ADC.Conference.Application.Tests6HandlerTestBase<THandler>, INavigationPopulator<in TEntity>, NavigationMetadata, SessionSpeaker, SessionSpeakerNavigationPopulator, UnitOfWork
11MultiSourceSqliteIntegrationTestsMMCA.Common.Infrastructure.Tests26AuditSaveChangesInterceptor, ConnectionStringSettings, DataSource, DataSourceEntrySettings, DataSourceResolver, DataSourceService, DataSourcesSettings, DbContextFactory, DomainEventSaveChangesInterceptor, EntityDataSourceRegistry, FixedAssemblyProvider, IApplicationSettings, ICurrentUserService, IDataSourceResolver, IDomainEventDispatcher, IEntityDataSourceRegistry, IOutboxSignal, MultiSourceCustomer, MultiSourceOrder, MultiSourceTestEvent …(+6)15SpeakerCategoryItemNavigationPopulatorTestsMMCA.ADC.Conference.Application.Tests6HandlerTestBase<THandler>, INavigationPopulator<in TEntity>, NavigationMetadata, SpeakerCategoryItem, SpeakerCategoryItemNavigationPopulator, UnitOfWork
11RepositoryFactoryTestsMMCA.Common.Infrastructure.Tests11EFReadRepository<TEntity, TIdentifierType>, EFReadRepositoryDecorator<TEntity, TIdentifierType>, EFRepository<TEntity, TIdentifierType>, EFRepositoryDecorator<TEntity, TIdentifierType>, FakeAggregate, FakeEntity, IApplicationSettings, IReadRepository<TEntity, TIdentifierType>, IRepository<TEntity, TIdentifierType>, RepositoryFactory, TestDbContext15SpeakerEntityQueryServiceTestsMMCA.ADC.Conference.Application.Tests16EntityQueryParameters<TEntity>, ErrorType, HandlerTestBase<THandler>, ICurrentUserService, IEntityQueryPipeline, INavigationMetadataProvider, INavigationPopulator<in TEntity>, InlineSpecification<TEntity, TIdentifierType>, IReadRepository<TEntity, TIdentifierType>, NavigationMetadata, Speaker, SpeakerCategoryItemDTOMapper, SpeakerDTOMapper, SpeakerEntityQueryService, SpeakerQuestionAnswerDTOMapper, UnitOfWork
11GalleryE2ECollectionMMCA.Common.UI.E2E.Tests2GalleryHostFixture, PlaywrightFixture15SpeakerNavigationPopulatorTestsMMCA.ADC.Conference.Application.Tests6HandlerTestBase<THandler>, INavigationPopulator<in TEntity>, NavigationMetadata, Speaker, SpeakerNavigationPopulator, UnitOfWork
12CreateSessionHandlerTests15SpeakerQuestionAnswerNavigationPopulatorTests MMCA.ADC.Conference.Application.Tests17CreateSessionHandler, Error, ErrorType, Event, HandlerTestBase<THandler>, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, IRepository<TEntity, TIdentifierType>, IUnitOfWork, Result, Session, SessionCategoryItemDTOMapper, SessionCreateRequest, SessionDTOMapper, SessionInvariants, SessionQuestionAnswerDTOMapper, SessionSpeakerDTOMapper, UnitOfWork6HandlerTestBase<THandler>, INavigationPopulator<in TEntity>, NavigationMetadata, SpeakerQuestionAnswer, SpeakerQuestionAnswerNavigationPopulator, UnitOfWork
12GetPublicEventSpeakerFilterHandlerTests15SponsorNavigationPopulatorTests MMCA.ADC.Conference.Application.Tests9Event, EventSpeaker, GetPublicEventSpeakerFilterHandler, GetPublicEventSpeakerFilterQuery, HandlerTestBase<THandler>, IReadRepository<TEntity, TIdentifierType>, Session, SessionSpeaker, UnitOfWork6HandlerTestBase<THandler>, INavigationPopulator<in TEntity>, NavigationMetadata, Sponsor, SponsorNavigationPopulator, UnitOfWork
12GetPublicSessionCategoryItemFilterHandlerTests15UnlinkUserFromSpeakerHandlerTests MMCA.ADC.Conference.Application.Tests 8Event, GetPublicSessionCategoryItemFilterHandler, GetPublicSessionCategoryItemFilterQuery, HandlerTestBase<THandler>, IReadRepository<TEntity, TIdentifierType>, Session, SessionCategoryItem, UnitOfWorkErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, Speaker, SpeakerUnlinkedFromUser, UnitOfWork, UnlinkUserFromSpeakerCommand, UnlinkUserFromSpeakerHandler
12GetPublicSessionSpeakerFilterHandlerTests15UnpublishEventHandlerTests MMCA.ADC.Conference.Application.Tests8Event, GetPublicSessionSpeakerFilterHandler, GetPublicSessionSpeakerFilterQuery, HandlerTestBase<THandler>, IReadRepository<TEntity, TIdentifierType>, Session, SessionSpeaker, UnitOfWork7ErrorType, Event, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, UnitOfWork, UnpublishEventCommand, UnpublishEventHandler
12GetPublicSpeakerCategoryItemFilterHandlerTests15UpdateActivityHandlerTests MMCA.ADC.Conference.Application.Tests 9Event, GetPublicSpeakerCategoryItemFilterHandler, GetPublicSpeakerCategoryItemFilterQuery, HandlerTestBase<THandler>, IReadRepository<TEntity, TIdentifierType>, Session, SessionSpeaker, SpeakerCategoryItem, UnitOfWorkActivity, ActivityDTOMapper, ActivityUpdateRequest, ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, UnitOfWork, UpdateActivityCommand, UpdateActivityHandler
12GetPublicSpeakerFilterHandlerTests15UpdateCategoryItemHandlerTests MMCA.ADC.Conference.Application.Tests11Event, EventSpeaker, GetPublicSpeakerFilterHandler, GetPublicSpeakerFilterQuery, HandlerTestBase<THandler>, IReadRepository<TEntity, TIdentifierType>, Session, SessionSpeaker, SessionStatuses, Speaker, UnitOfWork7Category, ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, UnitOfWork, UpdateCategoryItemCommand, UpdateCategoryItemHandler
12GetPublicSponsorFilterHandlerTests15UpdateConferenceCategoryHandlerTests MMCA.ADC.Conference.Application.Tests8Event, GetPublicSponsorFilterHandler, GetPublicSponsorFilterQuery, HandlerTestBase<THandler>, IReadRepository<TEntity, TIdentifierType>, Sponsor, SponsorTier, UnitOfWork10Category, CategoryItemDTOMapper, ConferenceCategoryDTOMapper, ConferenceCategoryUpdateRequest, ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, UnitOfWork, UpdateConferenceCategoryCommand, UpdateConferenceCategoryHandler
12RefreshFromSessionizeHandlerTests15UpdateEventHandlerTestsMMCA.ADC.Conference.Application.Tests13ErrorType, Event, EventDTOMapper, EventQuestionAnswerDTOMapper, EventSpeakerDTOMapper, EventUpdateRequest, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, RoomDTOMapper, Session, UnitOfWork, UpdateEventCommand, UpdateEventHandler
15UpdateEventQuestionAnswerHandlerTests MMCA.ADC.Conference.Application.Tests 9ErrorType, Event, ICurrentUserService, IRepository<TEntity, TIdentifierType>, ISessionizeService, IUnitOfWork, RefreshFromSessionizeCommand, RefreshFromSessionizeHandler, SessionizeResponseErrorType, Event, HandlerTestBase<THandler>, ICurrentUserService, IRepository<TEntity, TIdentifierType>, RoleNames, UnitOfWork, UpdateEventQuestionAnswerCommand, UpdateEventQuestionAnswerHandler
1215UpdateQuestionHandlerTestsMMCA.ADC.Conference.Application.Tests13ErrorType, EventQuestionAnswer, HandlerTestBase<THandler>, IReadRepository<TEntity, TIdentifierType>, IRepository<TEntity, TIdentifierType>, Question, QuestionDTOMapper, QuestionUpdateRequest, SessionQuestionAnswer, SpeakerQuestionAnswer, UnitOfWork, UpdateQuestionCommand, UpdateQuestionHandler
15UpdateRoomHandlerTestsMMCA.ADC.Conference.Application.Tests7ErrorType, Event, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, UnitOfWork, UpdateRoomCommand, UpdateRoomHandler
15 UpdateSessionHandlerTests MMCA.ADC.Conference.Application.Tests 13 ErrorType, Event, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, Session, SessionCategoryItemDTOMapper, SessionDTOMapper, SessionQuestionAnswerDTOMapper, SessionSpeakerDTOMapper, SessionUpdateRequest, UnitOfWork, UpdateSessionCommand, UpdateSessionHandler
12DependencyInjectionMMCA.ADC.Conference.Contracts6EventLiveValidationService, EventLiveValidationServiceGrpcAdapter, IEventLiveValidationService, ISessionBookmarkValidationService, SessionBookmarkValidationService, SessionBookmarkValidationServiceGrpcAdapter15UpdateSessionQuestionAnswerHandlerTestsMMCA.ADC.Conference.Application.Tests10ErrorType, Event, HandlerTestBase<THandler>, ICurrentUserService, IRepository<TEntity, TIdentifierType>, RoleNames, Session, UnitOfWork, UpdateSessionQuestionAnswerCommand, UpdateSessionQuestionAnswerHandler
12BookmarksControllerTestsMMCA.ADC.Engagement.API.Tests17BookmarksController, ControllerMocks, CreateBookmarkRequest, DeleteEntityCommand<TEntity, TIdentifierType>, Error, GetBookmarkedSessionIdsQuery, GetUserBookmarksQuery, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, IQueryHandler<in TQuery, TResult>, OwnerOrAdminFilter, PagedCollectionResult<T>, PaginationMetadata, Result, UserSessionBookmark, UserSessionBookmarkDTO15UpdateSpeakerHandlerTestsMMCA.ADC.Conference.Application.Tests13Email, ErrorType, HandlerTestBase<THandler>, ICurrentUserService, IRepository<TEntity, TIdentifierType>, Speaker, SpeakerCategoryItemDTOMapper, SpeakerDTOMapper, SpeakerQuestionAnswerDTOMapper, SpeakerUpdateRequest, UnitOfWork, UpdateSpeakerCommand, UpdateSpeakerHandler
12CheckInAttendeeHandlerMMCA.ADC.Engagement.Application11AttendeeBadge, BadgePayload, CheckInAttendeeRequest, CheckInProcessor, CheckInResultDTO, Error, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEventLiveValidationService, IUnitOfWork, Result15UpdateSponsorHandlerTestsMMCA.ADC.Conference.Application.Tests10ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, Sponsor, SponsorDTOMapper, SponsorTier, SponsorUpdateRequest, UnitOfWork, UpdateSponsorCommand, UpdateSponsorHandler
12DependencyInjectionMMCA.ADC.Engagement.Application31ApplicationSettings, BookmarkCountService, BookmarkManagementDomainService, ClassReference, ClassReference, DeleteEntityCommand<TEntity, TIdentifierType>, DeleteEntityHandler<TEntity, TIdentifierType>, EntityQueryService<TEntity, TEntityDTO, TIdentifierType>, IBookmarkCountService, IBookmarkManagementDomainService, ICommandHandler<in TCommand, TResult>, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, ILiveChannelPublishQueue, INavigationPopulator<in TEntity>, IPointsAwarder, IUserEngagementExportService, LiveChannelPublishQueue, LivePoll, LivePollDTO, LivePollNavigationPopulator …(+11)15ConferenceIntegrationTestFixtureMMCA.ADC.Conference.IntegrationTests4ConferenceTestWebApplicationFactory, JwtTokenGenerator, Program, SqlServerIntegrationTestFixtureBase<TEntryPoint>
12ManualCheckInHandlerMMCA.ADC.Engagement.Application9CheckInProcessor, CheckInResultDTO, Error, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IEventLiveValidationService, IUnitOfWork, ManualCheckInRequest, Result15CrossServiceFixtureMMCA.ADC.CrossService.IntegrationTests6ConferenceCrossServiceFactory, CrossServiceDataSource, CrossServiceFixtureBase, EngagementCrossServiceFactory, IdentityCrossServiceFactory, JwtTokenGenerator
12GetAttendanceStatsHandlerTests15AttendeeCheckedInPointsHandlerTests MMCA.ADC.Engagement.Application.Tests6CheckIn, CheckInScope, GetAttendanceStatsHandler, GetAttendanceStatsQuery, HandlerTestBase<THandler>, UnitOfWork11AttendeeCheckedIn, AttendeeCheckedInPointsHandler, CheckInScopeNames, Error, HandlerTestBase<THandler>, IPointsAwarder, PointsActivityType, RecordingPointsAwarder, Result, SponsorVisit, TestSupport
12GetOrCreateMyBadgeHandlerTests15BookmarkCountServiceTests MMCA.ADC.Engagement.Application.Tests8AttendeeBadge, ErrorType, GetOrCreateMyBadgeCommand, GetOrCreateMyBadgeHandler, HandlerMocks, HandlerTestBase<THandler>, ICurrentUserService, UnitOfWork5BookmarkCountService, HandlerTestBase<THandler>, InMemoryQueryableExecutor, UnitOfWork, UserSessionBookmark
12RecordRoomCheckInHandlerTests15CastVoteHandlerTests MMCA.ADC.Engagement.Application.Tests17AttendeeCheckedIn, CheckIn, CheckInScope, CheckInScopeNames, CheckInSettings, Error, ErrorType, FixedTimeProvider, HandlerMocks, HandlerTestBase<THandler>, ICurrentUserService, IEventLiveValidationService, RecordRoomCheckInHandler, Result, RoomCheckInRequest, RoomSessionInfo, UnitOfWork12CastVoteCommand, CastVoteHandler, ErrorType, FixedTimeProvider, HandlerMocks, HandlerTestBase<THandler>, InMemoryQueryableExecutor, IReadRepository<TEntity, TIdentifierType>, LivePoll, LivePollResultsBuilder, LivePollVote, UnitOfWork
12RecordSponsorVisitHandlerTests15CheckInAttendeeHandlerTests MMCA.ADC.Engagement.Application.Tests16AttendeeCheckedIn, CheckIn, CheckInScope, CheckInScopeNames, Error, ErrorType, FixedTimeProvider, HandlerMocks, HandlerTestBase<THandler>, ICurrentUserService, IEventLiveValidationService, RecordSponsorVisitHandler, Result, SponsorLiveInfo, SponsorVisitRequest, UnitOfWork20AttendeeBadge, AttendeeCheckedIn, BadgePayload, CheckIn, CheckInAttendeeHandler, CheckInAttendeeRequest, CheckInScope, CheckInScopeNames, Error, ErrorType, EventLiveInfo, FixedTimeProvider, HandlerMocks, HandlerTestBase<THandler>, ICurrentUserService, IEventLiveValidationService, QuestionModerationDefault, Result, SessionLiveInfo, UnitOfWork
12UserEngagementExportServiceGrpcAdapterMMCA.ADC.Engagement.Contracts9CheckInScope, IUserEngagementExportService, PointsActivityType, UserEngagementBookmarkExportDTO, UserEngagementCheckInExportDTO, UserEngagementExportDTO, UserEngagementExportService, UserEngagementPointsEntryExportDTO, UserEngagementSubmittedQuestionExportDTO15CloseLivePollHandlerTestsMMCA.ADC.Engagement.Application.Tests16CloseLivePollCommand, CloseLivePollHandler, Error, ErrorType, HandlerMocks, HandlerTestBase<THandler>, IEventLiveValidationService, ILiveChannelPublishQueue, LiveChannelPublishWorkItem, LivePoll, LivePollChannel, LivePollStatus, QuestionModerationDefault, Result, SessionLiveInfo, UnitOfWork
15CreateBookmarkHandlerTestsMMCA.ADC.Engagement.Application.Tests 12UserEngagementExportGrpcServiceMMCA.ADC.Engagement.Service3IUserEngagementExportService, LeaderboardOptIn, UserEngagementExportServiceBookmarkManagementDomainService, CreateBookmarkHandler, CreateBookmarkRequest, Error, ErrorType, HandlerMocks, HandlerTestBase<THandler>, ISessionBookmarkValidationService, Result, UnitOfWork, UserSessionBookmark, UserSessionBookmarkDTOMapper
12DependencyInjectionMMCA.ADC.Engagement.UI33AttendeeLookupService, BookmarkService, CheckInService, CurrentEventNotificationScopeProvider, EngagementUIModule, EventFeedbackService, IAttendeeLookupService, IBookmarkUIService, ICheckInUIService, IEventFeedbackUIService, ILiveEventUIService, ILivePollUIService, INotificationScopeProvider, INowNextService, IPointsUIService, IQuestionLookupService, ISessionBookmarkUIService, ISessionFeedbackUIService, ISessionLiveUIService, ISessionLookupService …(+13)15CreateLivePollHandlerTestsMMCA.ADC.Engagement.Application.Tests17CreateLivePollCommand, CreateLivePollHandler, CreateLivePollRequest, Error, ErrorType, EventLiveInfo, HandlerMocks, HandlerTestBase<THandler>, IEventLiveValidationService, LivePoll, LivePollDTOMapper, LivePollStatus, Question, QuestionModerationDefault, Result, SessionLiveInfo, UnitOfWork
12AuthControllerMMCA.ADC.Identity.API18AuthenticationResponse, AuthenticationService, ChangePasswordCommand, ChangePasswordRequest, ChangePreferencesCommand, ChangePreferencesRequest, GetUserPreferencesQuery, IAuthenticationService, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IQueryHandler<in TQuery, TResult>, LoginRequest, RegisterRequest, Result, Route, UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand>, UserPreferencesResponse, WebApplicationBuilderExtensions15EventFeedbackSubmittedPointsHandlerTestsMMCA.ADC.Engagement.Application.Tests9Error, EventFeedbackSubmitted, EventFeedbackSubmittedPointsHandler, HandlerTestBase<THandler>, IPointsAwarder, PointsActivityType, RecordingPointsAwarder, Result, TestSupport
12AppMMCA.ADC.UI1MauiProgram15GetAttendanceStatsHandlerTestsMMCA.ADC.Engagement.Application.Tests6CheckIn, CheckInScope, GetAttendanceStatsHandler, GetAttendanceStatsQuery, HandlerTestBase<THandler>, UnitOfWork
12AppDelegateMMCA.ADC.UI2IDeepLinkDispatcher, MauiProgram15GetBookmarkedSessionIdsHandlerTestsMMCA.ADC.Engagement.Application.Tests5GetBookmarkedSessionIdsHandler, GetBookmarkedSessionIdsQuery, HandlerTestBase<THandler>, UnitOfWork, UserSessionBookmark
12MainApplicationMMCA.ADC.UI1MauiProgram15GetEventPollsHandlerTestsMMCA.ADC.Engagement.Application.Tests7GetEventPollsHandler, GetEventPollsQuery, HandlerTestBase<THandler>, LivePoll, LivePollDTOMapper, LivePollStatus, UnitOfWork
12AuthControllerBaseRateLimitTestsMMCA.Common.API.Tests3AuthControllerBase, OverridingAuthController, WebApplicationBuilderExtensions15GetLeaderboardHandlerTestsMMCA.ADC.Engagement.Application.Tests8GetLeaderboardHandler, GetLeaderboardQuery, HandlerTestBase<THandler>, LeaderboardOptIn, PointsActivityType, PointsEntry, PointsSettings, UnitOfWork
12AuthControllerBaseTestsMMCA.Common.API.Tests15GetModerationQueueHandlerTestsMMCA.ADC.Engagement.Application.Tests16Error, ErrorType, GetModerationQueueHandler, GetModerationQueueQuery, HandlerMocks, HandlerTestBase<THandler>, IEventLiveValidationService, InMemoryQueryableExecutor, QuestionModerationDefault, QuestionStatus, Result, SessionLiveInfo, SessionQuestion, SessionQuestionUpvote, SessionQuestionViewBuilder, UnitOfWork
15GetMyPointsHandlerTestsMMCA.ADC.Engagement.Application.Tests 9AuthenticationResponse, Error, IAuthenticationService, ICurrentUserService, LoginRequest, RefreshTokenRequest, RegisterRequest, Result, TestAuthControllerErrorType, GetMyPointsHandler, GetMyPointsQuery, HandlerTestBase<THandler>, ICurrentUserService, LeaderboardOptIn, PointsActivityType, PointsEntry, UnitOfWork
12DataExportControllerBaseTestsMMCA.Common.API.Tests14AuthorizationPolicies, DataExportControllerBase<TQuery>, Error, ICurrentUserService, IQueryHandler<in TQuery, TResult>, PrivacyFeatures, Result, StubFeatureManager, Subject, SubjectSnapshot, TestDataExportController, TestExportQuery, UserDataExportDTO, UserDataExportSectionDTO15GetOpenPollsHandlerTestsMMCA.ADC.Engagement.Application.Tests9ErrorType, GetOpenPollsHandler, GetOpenPollsQuery, HandlerTestBase<THandler>, InMemoryQueryableExecutor, LivePoll, LivePollResultsBuilder, LivePollVote, UnitOfWork
12TestUserAccountAuthControllerMMCA.Common.API.Tests12ChangePasswordRequest, ChangePreferencesRequest, GetUserPreferencesQuery, IAuthenticationService, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IQueryHandler<in TQuery, TResult>, Result, TestChangePasswordCommand, TestChangePreferencesCommand, UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand>, UserPreferencesResponse15GetOrCreateMyBadgeHandlerTestsMMCA.ADC.Engagement.Application.Tests8AttendeeBadge, ErrorType, GetOrCreateMyBadgeCommand, GetOrCreateMyBadgeHandler, HandlerMocks, HandlerTestBase<THandler>, ICurrentUserService, UnitOfWork
12EntityDataSourceRegistryTestsMMCA.Common.Infrastructure.Tests 15ConnectionStringSettings, DataSource, DataSourceEntrySettings, DataSourceKey, DataSourceResolver, DataSourcesSettings, EntityDataSourceRegistry, FixedAssemblyProvider, NamespaceConventions, PushNotification, RegistryDuplicate, RegistryInvoice, RegistryOrder, RegistrySqlServerEntity, RegistryUnattributedGetPointsOverviewHandlerTestsMMCA.ADC.Engagement.Application.Tests7GetPointsOverviewHandler, GetPointsOverviewQuery, HandlerTestBase<THandler>, PointsActivityType, PointsEntry, PointsEntryDTO, UnitOfWork
12OutboxProcessorExecuteAsyncTestsMMCA.Common.Infrastructure.Tests15GetSessionQuestionsHandlerTestsMMCA.ADC.Engagement.Application.Tests 9DataSource, DataSourceKey, DependencyInjection, FakeTimeProvider, IDataSourceResolver, IEntityDataSourceRegistry, IOutboxSignal, OutboxProcessor, OutboxSettingsGetSessionQuestionsHandler, GetSessionQuestionsQuery, HandlerTestBase<THandler>, InMemoryQueryableExecutor, QuestionStatus, SessionQuestion, SessionQuestionUpvote, SessionQuestionViewBuilder, UnitOfWork
12GalleryAxeTestBaseMMCA.Common.UI.E2E.Tests4E2ETestConfiguration, GalleryE2ECollection, GalleryHostFixture, PlaywrightFixture15GetUserBookmarksHandlerTestsMMCA.ADC.Engagement.Application.Tests11Error, GetUserBookmarksHandler, GetUserBookmarksQuery, HandlerMocks, HandlerTestBase<THandler>, IQueryableExecutor, ISessionBookmarkValidationService, Result, UnitOfWork, UserSessionBookmark, UserSessionBookmarkDTOMapper
13CheckInAttendeeHandlerTests15LivePollOptionNavigationPopulatorTests MMCA.ADC.Engagement.Application.Tests20AttendeeBadge, AttendeeCheckedIn, BadgePayload, CheckIn, CheckInAttendeeHandler, CheckInAttendeeRequest, CheckInScope, CheckInScopeNames, Error, ErrorType, EventLiveInfo, FixedTimeProvider, HandlerMocks, HandlerTestBase<THandler>, ICurrentUserService, IEventLiveValidationService, QuestionModerationDefault, Result, SessionLiveInfo, UnitOfWork6HandlerTestBase<THandler>, INavigationPopulator<in TEntity>, LivePollOption, LivePollOptionNavigationPopulator, NavigationMetadata, UnitOfWork
1315 ManualCheckInHandlerTests MMCA.ADC.Engagement.Application.Tests 17 AttendeeCheckedIn, CheckIn, CheckInScope, CheckInScopeNames, ErrorType, EventLiveInfo, FixedTimeProvider, HandlerMocks, HandlerTestBase<THandler>, ICurrentUserService, IEventLiveValidationService, ManualCheckInHandler, ManualCheckInRequest, QuestionModerationDefault, Result, SessionLiveInfo, UnitOfWork
13DependencyInjectionMMCA.ADC.Engagement.Contracts6BookmarkCountService, BookmarkCountServiceGrpcAdapter, IBookmarkCountService, IUserEngagementExportService, UserEngagementExportService, UserEngagementExportServiceGrpcAdapter15ModerateQuestionHandlerTestsMMCA.ADC.Engagement.Application.Tests17Error, ErrorType, HandlerMocks, HandlerTestBase<THandler>, IEventLiveValidationService, ILiveChannelPublishQueue, LiveChannelPublishWorkItem, ModerateQuestionCommand, ModerateQuestionHandler, ModerationAction, QuestionModerationDefault, QuestionStatus, Result, SessionLiveInfo, SessionQuestion, SessionQuestionChannel, UnitOfWork
13AuthControllerTestsMMCA.ADC.Identity.API.Tests15OpenLivePollHandlerTestsMMCA.ADC.Engagement.Application.Tests18Error, ErrorType, EventLiveInfo, FixedTimeProvider, HandlerMocks, HandlerTestBase<THandler>, IEventLiveValidationService, ILiveChannelPublishQueue, LiveChannelPublishWorkItem, LivePoll, LivePollChannel, LivePollStatus, OpenLivePollCommand, OpenLivePollHandler, QuestionModerationDefault, Result, SessionLiveInfo, UnitOfWork
15PointsAwarderTestsMMCA.ADC.Engagement.Application.Tests11AwarderMocks, EventFeedback, HandlerTestBase<THandler>, MutableOptions, PointsActivityType, PointsAwarder, PointsEntry, PointsSettings, PointsSubjectKeys, SessionFeedback, UnitOfWork
15RecordRoomCheckInHandlerTestsMMCA.ADC.Engagement.Application.Tests 17AuthController, AuthenticationResponse, ChangePasswordCommand, ChangePasswordRequest, ChangePreferencesCommand, Error, ErrorType, GetUserPreferencesQuery, IAuthenticationService, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IQueryHandler<in TQuery, TResult>, LoginRequest, RefreshTokenRequest, RegisterRequest, Result, UserPreferencesResponseAttendeeCheckedIn, CheckIn, CheckInScope, CheckInScopeNames, CheckInSettings, Error, ErrorType, FixedTimeProvider, HandlerMocks, HandlerTestBase<THandler>, ICurrentUserService, IEventLiveValidationService, RecordRoomCheckInHandler, Result, RoomCheckInRequest, RoomSessionInfo, UnitOfWork
13UserEngagementExportGrpcServiceTestsMMCA.ADC.Services.Tests8CheckInScope, FakeServerCallContext, IUserEngagementExportService, UserEngagementBookmarkExportDTO, UserEngagementCheckInExportDTO, UserEngagementExportDTO, UserEngagementExportGrpcService, UserEngagementSubmittedQuestionExportDTO15RecordSponsorVisitHandlerTestsMMCA.ADC.Engagement.Application.Tests16AttendeeCheckedIn, CheckIn, CheckInScope, CheckInScopeNames, Error, ErrorType, FixedTimeProvider, HandlerMocks, HandlerTestBase<THandler>, ICurrentUserService, IEventLiveValidationService, RecordSponsorVisitHandler, Result, SponsorLiveInfo, SponsorVisitRequest, UnitOfWork
13UserEngagementExportServiceGrpcAdapterTestsMMCA.ADC.Services.Tests4CheckInScope, UserEngagementExportDTO, UserEngagementExportService, UserEngagementExportServiceGrpcAdapter15SessionFeedbackSubmittedPointsHandlerTestsMMCA.ADC.Engagement.Application.Tests9Error, HandlerTestBase<THandler>, IPointsAwarder, PointsActivityType, RecordingPointsAwarder, Result, SessionFeedbackSubmitted, SessionFeedbackSubmittedPointsHandler, TestSupport
15SessionQuestionSubmittedPointsHandlerTestsMMCA.ADC.Engagement.Application.Tests 13ProgramMMCA.ADC.UI1AppDelegateDomainEntityState, Error, HandlerTestBase<THandler>, IPointsAwarder, PointsActivityType, QuestionStatus, RecordingPointsAwarder, Result, SessionQuestion, SessionQuestionChanged, SessionQuestionSubmittedPointsHandler, TestSupport, ThrowingPointsAwarder
13UserAccountAuthControllerBaseTestsMMCA.Common.API.Tests 15AuthenticationResponse, ChangePasswordRequest, ChangePreferencesRequest, Error, GetUserPreferencesQuery, IAuthenticationService, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IQueryHandler<in TQuery, TResult>, LoginRequest, Result, TestChangePasswordCommand, TestChangePreferencesCommand, TestUserAccountAuthController, UserPreferencesResponseSetLeaderboardParticipationHandlerTestsMMCA.ADC.Engagement.Application.Tests8ErrorType, HandlerMocks, HandlerTestBase<THandler>, ICurrentUserService, LeaderboardOptIn, SetLeaderboardParticipationHandler, SetLeaderboardParticipationRequest, UnitOfWork
13ComponentsPageE2ETestsMMCA.Common.UI.E2E.Tests4AxeOptions, GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixture15SubmitQuestionHandlerTestsMMCA.ADC.Engagement.Application.Tests23Error, FixedTimeProvider, HandlerMocks, HandlerTestBase<THandler>, IEventLiveValidationService, ILiveChannelPublishQueue, InMemoryQueryableExecutor, IReadRepository<TEntity, TIdentifierType>, LiveChannelPublishWorkItem, QuestionModerationDefault, QuestionStatus, Result, SessionLiveInfo, SessionQuestion, SessionQuestionApprovedPayload, SessionQuestionChannel, SessionQuestionInvariants, SessionQuestionPendingCountChangedPayload, SessionQuestionUpvote, SessionQuestionViewBuilder …(+3)
13DarkModeE2ETestsMMCA.Common.UI.E2E.Tests15ToggleUpvoteHandlerTestsMMCA.ADC.Engagement.Application.Tests11ErrorType, FixedTimeProvider, HandlerMocks, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, QuestionStatus, SessionQuestion, SessionQuestionUpvote, ToggleUpvoteCommand, ToggleUpvoteHandler, UnitOfWork
15UserDeletedPointsHandlerTestsMMCA.ADC.Engagement.Application.Tests7HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, LeaderboardOptIn, TestSupport, UnitOfWork, UserDeleted, UserDeletedPointsHandler
15EngagementIntegrationTestFixtureMMCA.ADC.Engagement.IntegrationTests 4AxeOptions, GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixtureEngagementTestWebApplicationFactory, JwtTokenGenerator, Program, SqlServerIntegrationTestFixtureBase<TEntryPoint>
13LoginPageE2ETestsMMCA.Common.UI.E2E.Tests5AxeOptions, GalleryAxeTestBase, GalleryHostFixture, LoginPage, PlaywrightFixture15GatewayHardeningTestsMMCA.ADC.Gateway.Tests1GatewayApplicationFactory
13MobileTopRowE2ETestsMMCA.Common.UI.E2E.Tests15RouteMapTestsMMCA.ADC.Gateway.Tests 3GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixtureClusterProfile, RecordingHttpForwarder, RouteMapApplicationFactory
15DependencyInjectionMMCA.ADC.Identity.Application 13NotificationPagesE2ETestsMMCA.Common.UI.E2E.Tests4AxeOptions, GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixtureApplicationSettings, AttendeeQueryService, AuthenticationService, AuthenticationValidators, ClassReference, ClassReference, EngagementUserDataExportSection, IAttendeeQueryService, IAuthenticationService, ISoftDeletedUserValidator, NotificationUserDataExportSection, SoftDeletedUserValidator<TUser>, User
13PseudoLocalizationE2ETestsMMCA.Common.UI.E2E.Tests4GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixture, SupportedCultures15AttendeeQueryServiceTestsMMCA.ADC.Identity.Application.Tests5AttendeeQueryService, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, UnitOfWork, User
13RegisterPageE2ETestsMMCA.Common.UI.E2E.Tests5AxeOptions, GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixture, RegisterPage15AuthenticationServiceTestsMMCA.ADC.Identity.Application.Tests19AuthenticationResponse, AuthenticationService, AuthenticationValidators, Error, ErrorType, IExternalLoginEmailVerifier, ILoginProtectionService, IPasswordHasher, IRepository<TEntity, TIdentifierType>, ITokenService, IUnitOfWork, LoginRequest, RefreshTokenRequest, RegisterRequest, Result, ServiceMocks, User, UserRegistered, UserRole
13StickySidebarE2ETestsMMCA.Common.UI.E2E.Tests3GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixture15ChangePasswordHandlerTestsMMCA.ADC.Identity.Application.Tests10ChangePasswordCommand, ChangePasswordHandler, ChangePasswordRequest, ErrorType, HandlerTestBase<THandler>, IPasswordHasher, IRepository<TEntity, TIdentifierType>, UnitOfWork, User, UserRole
15ChangePreferencesHandlerTestsMMCA.ADC.Identity.Application.Tests9ChangePreferencesCommand, ChangePreferencesHandler, ChangePreferencesRequest, ErrorType, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, UnitOfWork, User, UserRole
15DeleteUserHandlerTestsMMCA.ADC.Identity.Application.Tests 13WebVitalsE2ETestsMMCA.Common.UI.E2E.Tests4GalleryAxeTestBase, GalleryHostFixture, PlaywrightFixture, WebVitalsCollectorDeleteUserCommand, DeleteUserHandler, ErrorType, FixedTimeProvider, HandlerTestBase<THandler>, ICacheService, IFileStorageService, IRepository<TEntity, TIdentifierType>, Result, SoftDeletedUserCache, UnitOfWork, User, UserRole
14ConferenceTestWebApplicationFactoryMMCA.ADC.Conference.IntegrationTests8FakeAiScoringService, FakeBookmarkCountService, FakeSessionizeService, IAiScoringService, IBookmarkCountService, ISessionizeService, JwtTokenGenerator, Program15ExportUserDataHandlerTestsMMCA.ADC.Identity.Application.Tests26EngagementUserDataExportSection, ErrorType, ExportUserDataHandler, ExportUserDataHandlerBase<TUser, TQuery>, ExportUserDataQuery, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, IUserDataExportSection, IUserEngagementExportService, IUserNotificationExportService, NotificationUserDataExportSection, Subject, ThrowingExportSection, UnitOfWork, User, UserDataExportDTO, UserDataExportEngagementSectionDTO, UserDataExportNotificationSectionDTO, UserDataExportSectionDefaults, UserDataExportSectionDTO …(+6)
15ForgotPasswordHandlerTestsMMCA.ADC.Identity.Application.Tests12ForgotPasswordCommand, ForgotPasswordHandler, ForgotPasswordRequest, HandlerTestBase<THandler>, IEmailSender, IPasswordResetTokenService, IRepository<TEntity, TIdentifierType>, PasswordResetSettings, Result, UnitOfWork, User, UserRole
15GetUserPreferencesHandlerTestsMMCA.ADC.Identity.Application.Tests12ChangePreferencesCommand, ChangePreferencesHandler, ChangePreferencesRequest, ErrorType, GetUserPreferencesHandler, GetUserPreferencesQuery, HandlerTestBase<THandler>, IRepository<TEntity, TIdentifierType>, UnitOfWork, User, UserPreferencesResponse, UserRole
14ConferenceCrossServiceFactoryMMCA.ADC.CrossService.IntegrationTests3JwtTokenGenerator, Program, RateLimiterNeutralizer15GetUsersHandlerTestsMMCA.ADC.Identity.Application.Tests10Email, GetUsersHandler, GetUsersQuery, HandlerTestBase<THandler>, IQueryableExecutor, IRepository<TEntity, TIdentifierType>, UnitOfWork, User, UserListDTO, UserRole
15ResetPasswordHandlerTestsMMCA.ADC.Identity.Application.Tests 14EngagementCrossServiceFactoryMMCA.ADC.CrossService.IntegrationTests3JwtTokenGenerator, Program, RateLimiterNeutralizerEmail, Error, HandlerTestBase<THandler>, ILoginProtectionService, IPasswordHasher, IPasswordResetTokenService, IRepository<TEntity, TIdentifierType>, ResetPasswordCommand, ResetPasswordHandler, ResetPasswordRequest, Result, UnitOfWork, User, UserRole
14IdentityCrossServiceFactoryMMCA.ADC.CrossService.IntegrationTests2Program, RateLimiterNeutralizer15IdentityModuleDbSeederMMCA.ADC.Identity.Infrastructure9Email, IdentityModuleDbSeederBase<TUser>, IPasswordHasher, IUnitOfWork, Result, SeedAccount, UnitOfWork, User, UserRole
14EngagementTestWebApplicationFactoryMMCA.ADC.Engagement.IntegrationTests8FakeEventLiveValidationService, FakeSessionBookmarkValidationService, IEventLiveValidationService, ILiveChannelPublisher, ISessionBookmarkValidationService, JwtTokenGenerator, NullLiveChannelPublisher, Program15IdentityIntegrationTestFixtureMMCA.ADC.Identity.IntegrationTests4IdentityTestWebApplicationFactory, JwtTokenGenerator, Program, SqlServerIntegrationTestFixtureBase<TEntryPoint>
14GatewayApplicationFactoryMMCA.ADC.Gateway.Tests2Program, RecordingHttpForwarder15UserNotificationExportServiceTestsMMCA.ADC.Notification.Application.Tests7HandlerTestBase<THandler>, InMemoryQueryableExecutor, IRepository<TEntity, TIdentifierType>, PushNotification, UnitOfWork, UserNotification, UserNotificationExportService
14GracefulShutdownTestsMMCA.ADC.Gateway.Tests2GracefulShutdownTestsBase<TEntryPoint>, Program15NotificationIntegrationTestFixtureMMCA.ADC.Notification.IntegrationTests4JwtTokenGenerator, NotificationTestWebApplicationFactory, Program, SqlServerIntegrationTestFixtureBase<TEntryPoint>
14RouteMapApplicationFactoryMMCA.ADC.Gateway.Tests2Program, RecordingHttpForwarder15AuthControllerBaseMMCA.Common.API10ApiControllerBase, AuthenticationResponse, AuthenticationService, CurrentUserService, IAuthenticationService, ICurrentUserService, LoginRequest, RefreshTokenRequest, RegisterRequest, WebApplicationBuilderExtensions
14SecurityHeadersTestsMMCA.ADC.Gateway.Tests3ProductionHostApplicationFactory<TEntryPoint>, Program, SecurityHeadersTestsBase15PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>MMCA.Common.API9ApiControllerBase, ForgotPasswordHandler, ForgotPasswordRequest, ICommandHandler<in TCommand, TResult>, ICommandWithRequest<out TRequest>, ResetPasswordHandler, ResetPasswordRequest, Result, WebApplicationBuilderExtensions
14IdentityTestWebApplicationFactoryMMCA.ADC.Identity.IntegrationTests6FakeUserEngagementExportService, FakeUserNotificationExportService, IUserEngagementExportService, IUserNotificationExportService, PiiCaptureLoggerProvider, Program15AuditTrailCleanupJobTestsMMCA.Common.Infrastructure.Tests13ApplicationDbContext, AuditedThing, AuditTrailCleanupJob, AuditTrailEntry, AuditTrailSettings, AuditTrailTestContext, AuditTrailTestHarness, DataSource, DataSourceKey, FakeTimeProvider, IDbContextFactory, IEntityDataSourceRegistry, SchedulerTestHarness
14NotificationTestWebApplicationFactoryMMCA.ADC.Notification.IntegrationTests4FakeAttendeeQueryService, IAttendeeQueryService, JwtTokenGenerator, Program15AuditTrailReaderTestsMMCA.Common.Infrastructure.Tests12ApplicationDbContext, AuditTrailEntry, AuditTrailReader, AuditTrailSettings, AuditTrailTestContext, AuditTrailTestHarness, DataSource, DataSourceKey, FakeTimeProvider, IDataSourceResolver, IDbContextFactory, SchedulerTestHarness
15ConferenceIntegrationTestFixtureMMCA.ADC.Conference.IntegrationTests4ConferenceTestWebApplicationFactory, JwtTokenGenerator, Program, SqlServerIntegrationTestFixtureBase<TEntryPoint>EntityDataSourceRegistryTestsMMCA.Common.Infrastructure.Tests15ConnectionStringSettings, DataSource, DataSourceEntrySettings, DataSourceKey, DataSourceResolver, DataSourcesSettings, EntityDataSourceRegistry, FixedAssemblyProvider, NamespaceConventions, PushNotification, RegistryDuplicate, RegistryInvoice, RegistryOrder, RegistrySqlServerEntity, RegistryUnattributed
15CrossServiceFixtureMMCA.ADC.CrossService.IntegrationTests6ConferenceCrossServiceFactory, CrossServiceDataSource, CrossServiceFixtureBase, EngagementCrossServiceFactory, IdentityCrossServiceFactory, JwtTokenGeneratorOutboxCleanupServiceTestsMMCA.Common.Infrastructure.Tests14ApplicationDbContext, CleanupTestContext, DataSource, DataSourceKey, FakeTimeProvider, IDataSourceResolver, IDbContextFactory, IEntityDataSourceRegistry, InboxMessage, MessageBusSettings, Mocks, OutboxCleanupService, OutboxMessage, OutboxSettings
15EngagementIntegrationTestFixtureMMCA.ADC.Engagement.IntegrationTests4EngagementTestWebApplicationFactory, JwtTokenGenerator, Program, SqlServerIntegrationTestFixtureBase<TEntryPoint>OutboxProcessorExecuteAsyncTestsMMCA.Common.Infrastructure.Tests9DataSource, DataSourceKey, DependencyInjection, FakeTimeProvider, IDataSourceResolver, IEntityDataSourceRegistry, IOutboxSignal, OutboxProcessor, OutboxSettings
15GatewayHardeningTestsMMCA.ADC.Gateway.Tests1GatewayApplicationFactoryTestIdentityModuleDbSeederMMCA.Common.Infrastructure.Tests8Email, Error, IdentityModuleDbSeederBase<TUser>, IPasswordHasher, IUnitOfWork, Result, SeedAccount, TestSeedUser
15RouteMapTestsMMCA.ADC.Gateway.Tests3ClusterProfile, RecordingHttpForwarder, RouteMapApplicationFactoryUnitOfWorkAdditionalTestsMMCA.Common.Infrastructure.Tests12ApplicationDbContext, DataSource, DataSourceKey, FakeAggregate, FakeEntity, IDataSourceService, IDbContextFactory, IReadRepository<TEntity, TIdentifierType>, IRepository<TEntity, TIdentifierType>, IRepositoryFactory, Mocks, UnitOfWork
15IdentityIntegrationTestFixtureMMCA.ADC.Identity.IntegrationTests4IdentityTestWebApplicationFactory, JwtTokenGenerator, Program, SqlServerIntegrationTestFixtureBase<TEntryPoint>UnitOfWorkTestsMMCA.Common.Infrastructure.Tests12ApplicationDbContext, DataSource, DataSourceKey, FakeAggregate, FakeEntity, IDataSourceService, IDbContextFactory, IReadRepository<TEntity, TIdentifierType>, IRepository<TEntity, TIdentifierType>, IRepositoryFactory, Mocks, UnitOfWork
15NotificationIntegrationTestFixtureMMCA.ADC.Notification.IntegrationTests4JwtTokenGenerator, NotificationTestWebApplicationFactory, Program, SqlServerIntegrationTestFixtureBase<TEntryPoint>HandlerTestBaseTestsMMCA.Common.Testing.Tests5FakeHandler, HandlerTestBase<THandler>, TestAggregate, TestChildEntity, UnitOfWork
16
16IdentityModuleSeederMMCA.ADC.Identity.API4IdentityModuleDbSeeder, IModuleSeeder, IPasswordHasher, IUnitOfWork
16PasswordResetControllerMMCA.ADC.Identity.API8ForgotPasswordCommand, ForgotPasswordRequest, ICommandHandler<in TCommand, TResult>, PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>, ResetPasswordCommand, ResetPasswordRequest, Result, Route
16IdentityModuleDbSeederTestsMMCA.ADC.Identity.Infrastructure.Tests6IdentityModuleDbSeeder, IPasswordHasher, IRepository<TEntity, TIdentifierType>, IUnitOfWork, SeederMocks, User
16 IdentityIntegrationTestCollection MMCA.ADC.Identity.IntegrationTests 1 NotificationIntegrationTestFixture
16UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand>MMCA.Common.API15AuthControllerBase, ChangePasswordHandler, ChangePasswordRequest, ChangePreferencesHandler, ChangePreferencesRequest, CurrentUserService, GetUserPreferencesHandler, GetUserPreferencesQuery, IAuthenticationService, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IQueryHandler<in TQuery, TResult>, IUserScopedCommand<out TRequest>, Result, UserPreferencesResponse
16OverridingAuthControllerMMCA.Common.API.Tests5AuthControllerBase, AuthenticationResponse, IAuthenticationService, ICurrentUserService, RegisterRequest
16TestAuthControllerMMCA.Common.API.Tests3AuthControllerBase, IAuthenticationService, ICurrentUserService
16TestPasswordResetControllerMMCA.Common.API.Tests7ForgotPasswordRequest, ICommandHandler<in TCommand, TResult>, PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>, ResetPasswordRequest, Result, TestForgotPasswordCommand, TestResetPasswordCommand
16IdentityModuleDbSeederBaseTestsMMCA.Common.Infrastructure.Tests7IPasswordHasher, IRepository<TEntity, TIdentifierType>, IUnitOfWork, SeedAccount, SeederMocks, TestIdentityModuleDbSeeder, TestSeedUser
17 ApiVersioningTests MMCA.ADC.Conference.IntegrationTests
17AuthControllerMMCA.ADC.Identity.API18AuthenticationResponse, AuthenticationService, ChangePasswordCommand, ChangePasswordRequest, ChangePreferencesCommand, ChangePreferencesRequest, GetUserPreferencesQuery, IAuthenticationService, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IQueryHandler<in TQuery, TResult>, LoginRequest, RegisterRequest, Result, Route, UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand>, UserPreferencesResponse, WebApplicationBuilderExtensions
17 IdentityIntegrationTestBase MMCA.ADC.Identity.IntegrationTests 4 JwtTokenGenerator, NotificationIntegrationTestCollection, NotificationIntegrationTestFixture, ProblemDetailsContractTestsBase<TFixture>
17AuthControllerBaseRateLimitTestsMMCA.Common.API.Tests3AuthControllerBase, OverridingAuthController, WebApplicationBuilderExtensions
17AuthControllerBaseTestsMMCA.Common.API.Tests9AuthenticationResponse, Error, IAuthenticationService, ICurrentUserService, LoginRequest, RefreshTokenRequest, RegisterRequest, Result, TestAuthController
17PasswordResetAuthControllerBaseTestsMMCA.Common.API.Tests11Error, ForgotPasswordRequest, ICommandHandler<in TCommand, TResult>, IdempotentAttribute, PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>, ResetPasswordRequest, Result, TestForgotPasswordCommand, TestPasswordResetController, TestResetPasswordCommand, WebApplicationBuilderExtensions
17TestUserAccountAuthControllerMMCA.Common.API.Tests12ChangePasswordRequest, ChangePreferencesRequest, GetUserPreferencesQuery, IAuthenticationService, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IQueryHandler<in TQuery, TResult>, Result, TestChangePasswordCommand, TestChangePreferencesCommand, UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand>, UserPreferencesResponse
18 AnonymousAccessDeniedTests MMCA.ADC.Conference.IntegrationTests
18AuthControllerTestsMMCA.ADC.Identity.API.Tests17AuthController, AuthenticationResponse, ChangePasswordCommand, ChangePasswordRequest, ChangePreferencesCommand, Error, ErrorType, GetUserPreferencesQuery, IAuthenticationService, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IQueryHandler<in TQuery, TResult>, LoginRequest, RefreshTokenRequest, RegisterRequest, Result, UserPreferencesResponse
18 AnonymousAccessDeniedTests MMCA.ADC.Identity.IntegrationTests 2
18PasswordResetFlowTestsMMCA.ADC.Identity.IntegrationTests4Email, IdentityIntegrationTestBase, IdentityIntegrationTestFixture, IPasswordResetTokenService
18 UserExportTests MMCA.ADC.Identity.IntegrationTests 3 FakeAttendeeQueryService, NotificationHub, NotificationIntegrationTestBase, NotificationIntegrationTestFixture
18UserAccountAuthControllerBaseTestsMMCA.Common.API.Tests15AuthenticationResponse, ChangePasswordRequest, ChangePreferencesRequest, Error, GetUserPreferencesQuery, IAuthenticationService, ICommandHandler<in TCommand, TResult>, ICurrentUserService, IQueryHandler<in TQuery, TResult>, LoginRequest, Result, TestChangePasswordCommand, TestChangePreferencesCommand, TestUserAccountAuthController, UserPreferencesResponse
19 JwksDiscoveryTests MMCA.ADC.Identity.IntegrationTests
G03 Querying: Specifications, Filtering & the Entity Query Service
group-03-querying-specifications.md
3738 L0-L8 Composable read-side: the Specification pattern, dynamic filtering/sorting/paging, and the generic entity query pipeline.
G04 Domain & Integration Events + Outbox Dual-Dispatch
group-04-events-outbox.md
32L0-L8L0-L13 Event contracts, the domain-event dispatcher, the transactional outbox/inbox, and the in-process + broker message buses.
G05 CQRS: Commands, Queries & the Decorator Pipeline
group-05-cqrs-pipeline.md
3638 L0-L9 The command/query handler abstraction and the cross-cutting decorator pipeline (logging, transaction, caching, feature-gate, idempotency) wrapping it.
G07 Persistence & EF Core
group-07-persistence-ef-core.md
116L0-L10118L0-L14 The single SQLServerDbContext over the abstract ApplicationDbContext, interceptors, repositories, specifications evaluation, data-source routing (database-per-service), conventions, value generators, encryption, factories and design-time.
G08 Authentication & Authorization
group-08-auth.md
6977 L0-L10 JWT/JWKS dual-fetch token validation, current-user/claims, password hashing, cookie sessions, and policy/authorization plumbing.
G12 API Hosting, Middleware, Idempotency & DTO/Contract Mapping
group-12-api-hosting-mapping.md
74L0-L1179L0-L16 The ASP.NET Core edge: controller bases, middleware, startup, model binders, JSON converters, feature management, idempotency, correlation, and manual DTO/request mapping.
G14 Module System, Composition & Configuration
group-14-module-system-composition.md
68L0-L1170L0-L14 IModule discovery + Kahn-ordered ModuleLoader, the DI composition roots, assembly markers, data-source/database attributes, and options/settings binding.
G15 Common UI Framework (MudBlazor components, theme, base pages)
group-15-common-ui-framework.md
8991 L0-L7 Reusable Blazor building blocks: the data-grid list page base, theme, common pages/services, and UI extensions shared by every consumer app.
G16 Aspire Orchestration & Service Defaults
group-16-aspire-orchestration.md
31L0-L3L0-L10 The Aspire AppHost wiring, ServiceDefaults, warmup, telemetry and security helpers that compose and run the distributed app locally and in Azure.
G17 ADC Conference - Domain Model & Module Contracts
group-17-conference-domain.md
96100 L0-L10 The Conference bounded context: Event/Session/Speaker/Category/Question aggregates, their domain events and invariants, plus the Shared identifiers/DTOs/integration-event contracts.
G18 ADC Conference - Application & Use Cases
group-18-conference-application.md
252L0-L11285L0-L14 Conference CQRS handlers, validators, DTOs, specifications, the Sessionize import, and the session-selection decision-support analytics.
G19 ADC Conference - Infrastructure & Persistence
group-19-conference-infrastructure.md
32L0-L1033L0-L12 The Conference module DbContext registration, EF entity configurations, database seeding, and infrastructure services.
G20 ADC Conference - API, gRPC Contracts & Service Host
group-20-conference-api-grpc.md
4243 L0-L12 Conference REST controllers, the .Contracts gRPC surface, the extractable service host, and the gRPC adapter.
G21 ADC Conference - UI
group-21-conference-ui.md
97106 L0-L10 The Conference Blazor pages (events, sessions, speakers, categories, questions, rooms, feedback, public, session-selection) and their UI services.
G26 ADC Engagement Live Layer (Real-Time Polls & Session Q&A)
group-23-engagement-live-layer.md
9495 L0-L10 Real-time audience interaction in the Engagement bounded context: event-wide live polls with voting and moderated per-session Q&A with upvoting, over the SignalR hub-channel transport (ADR-039) and the cross-service gRPC live-channel adapter.
G23 ADC Identity Module (Users, Profiles, GDPR Export/Erasure)
group-24-identity-module.md
83L0-L1288L0-L17 The Identity bounded context end-to-end: the User aggregate, change-password/delete/export use cases, persistence, API/contracts/service, and profile/user UI.
G25 Testing & Quality Infrastructure
group-27-testing-infrastructure.md
17801907 L0-L19 All test projects + the reusable Testing/Testing.E2E/Testing.UI bases, architecture-fitness tests, and the component Gallery harness; individual [Fact]s are rolled up by project (logged exception).
-

Reconciliation: 1685 production types across 26 groups + 1780 test/testing types in G25 = 3465 (matches the inventory's distinct-node count). No type appears twice; none dropped.

+

Reconciliation: 1761 production types across 26 groups + 1907 test/testing types in G25 = 3668 (matches the inventory's distinct-node count). No type appears twice; none dropped.


Group membership

G01 - Result & Error Handling

@@ -706,7 +706,7 @@

G02 - Do

G03 - Querying: Specifications, Filtering & the Entity Query Service

-

group-03-querying-specifications.md | 37 types | Composable read-side: the Specification pattern, dynamic filtering/sorting/paging, and the generic entity query pipeline.

+

group-03-querying-specifications.md | 38 types | Composable read-side: the Specification pattern, dynamic filtering/sorting/paging, and the generic entity query pipeline.

@@ -851,6 +851,12 @@

G03 -

+ + + + + + @@ -1027,12 +1033,6 @@

G04 - Domain &am

- - - - - - @@ -1110,37 +1110,43 @@

G04 - Domain &am

- + + + + + + + - + - + - + - + - + @@ -1148,7 +1154,7 @@

G04 - Domain &am

3EventUpcasterRegistryclassMMCA.Common.Application.Services
3 InlineSpecification<TEntity, TIdentifierType> class MMCA.Common.Domain.Specifications
1OutboxMessageclassMMCA.Common.Infrastructure.Persistence.Outbox
1 OutboxSignal class MMCA.Common.Infrastructure.Persistence.OutboxMMCA.Common.Infrastructure.Services
69OutboxMessageclassMMCA.Common.Infrastructure.Persistence.Outbox
11 OutboxFinalizer class MMCA.Common.Infrastructure.Persistence.Outbox
813 BrokerEventBus class MMCA.Common.Infrastructure.Services
813 EfInboxStore class MMCA.Common.Infrastructure.Persistence.Inbox
813 InProcessEventBus class MMCA.Common.Infrastructure.Services
813 OutboxCleanupService class MMCA.Common.Infrastructure.Persistence.Outbox
813 OutboxProcessor class MMCA.Common.Infrastructure.Persistence.Outbox

G05 - CQRS: Commands, Queries & the Decorator Pipeline

-

group-05-cqrs-pipeline.md | 36 types | The command/query handler abstraction and the cross-cutting decorator pipeline (logging, transaction, caching, feature-gate, idempotency) wrapping it.

+

group-05-cqrs-pipeline.md | 38 types | The command/query handler abstraction and the cross-cutting decorator pipeline (logging, transaction, caching, feature-gate, idempotency) wrapping it.

@@ -1281,6 +1287,18 @@

G05 - CQRS: Command

+ + + + + + + + + + + + @@ -1494,7 +1512,7 @@

G06 - Validation

2IEventUpcasterinterfaceMMCA.Common.Application.Interfaces
2IEventUpcasterRegistryinterfaceMMCA.Common.Application.Interfaces
2 QueryCacheKeyLocks class MMCA.Common.Application.UseCases.Decorators

G07 - Persistence & EF Core

-

group-07-persistence-ef-core.md | 116 types | The single SQLServerDbContext over the abstract ApplicationDbContext, interceptors, repositories, specifications evaluation, data-source routing (database-per-service), conventions, value generators, encryption, factories and design-time.

+

group-07-persistence-ef-core.md | 118 types | The single SQLServerDbContext over the abstract ApplicationDbContext, interceptors, repositories, specifications evaluation, data-source routing (database-per-service), conventions, value generators, encryption, factories and design-time.

@@ -1777,12 +1795,6 @@

G07 - Persistence & EF Core

- - - - - - @@ -1795,6 +1807,12 @@

G07 - Persistence & EF Core

+ + + + + + @@ -1818,6 +1836,12 @@

G07 - Persistence & EF Core

+ + + + + + @@ -1975,42 +1999,6 @@

G07 - Persistence & EF Core

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -2040,18 +2028,6 @@

G07 - Persistence & EF Core

- - - - - - - - - - - - @@ -2077,134 +2053,188 @@

G07 - Persistence & EF Core

- + - + - + - + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - + + - + - + - + - + - + - + - + + + + + + + - + - + - + - + - - + + - + - + - - - - - - - - - - - - - - - - - - - + - - - - - -
3CapturedStaterecordMMCA.Common.Infrastructure.Persistence.Interceptors
3 CrossDataSourceDegradeConvention class MMCA.Common.Infrastructure.Persistence.Conventions
3EventUpcasterStartupValidatorclassMMCA.Common.Infrastructure.Services
3 IDataSourceResolver interface MMCA.Common.Infrastructure.Persistence.DataSources MMCA.Common.Application.Interfaces.Infrastructure
3UpcastingIntegrationEventConsumer<TEvent>classMMCA.Common.Infrastructure.Services
4 AzureBlobFileStorageService class
6ApplicationDbContextclassMMCA.Common.Infrastructure.Persistence.DbContexts
6AuditSaveChangesInterceptorclassMMCA.Common.Infrastructure.Persistence.Interceptors
6AuditTrailSaveChangesInterceptorclassMMCA.Common.Infrastructure.Persistence.AuditTrail
6DataSourceModelCacheKeyFactoryclassMMCA.Common.Infrastructure.Persistence.DbContexts
6DeferredDispatchrecordMMCA.Common.Infrastructure.Persistence.Interceptors
6DomainEventSaveChangesInterceptorclassMMCA.Common.Infrastructure.Persistence.Interceptors
6 EFReadRepository<TEntity, TIdentifierType> class MMCA.Common.Infrastructure.Persistence.Repositories MMCA.Common.Application.Extensions
6TenantSaveChangesInterceptorclassMMCA.Common.Infrastructure.Persistence.Interceptors
7CosmosDbContextclassMMCA.Common.Infrastructure.Persistence.DbContexts
7 EFRepositoryDecorator<TEntity, TIdentifierType> class
7IDbContextFactoryIRepositoryFactory interfaceMMCA.Common.Infrastructure.Persistence.DbContexts.FactoryMMCA.Common.Infrastructure.Persistence.Repositories.Factory
7IPhysicalDbContextFactoryIUnitOfWork interfaceMMCA.Common.Infrastructure.Persistence.DbContexts.FactoryMMCA.Common.Application.Interfaces.Infrastructure
7IRepositoryFactory8PushNotificationConfigurationclassMMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration.Notifications
8UserNotificationConfigurationclassMMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration.Notifications
10CapturedStaterecordMMCA.Common.Infrastructure.Persistence.Interceptors
11ApplicationDbContextclassMMCA.Common.Infrastructure.Persistence.DbContexts
11AuditSaveChangesInterceptorclassMMCA.Common.Infrastructure.Persistence.Interceptors
11AuditTrailSaveChangesInterceptorclassMMCA.Common.Infrastructure.Persistence.AuditTrail
11DataSourceModelCacheKeyFactoryclassMMCA.Common.Infrastructure.Persistence.DbContexts
11DeferredDispatchrecordMMCA.Common.Infrastructure.Persistence.Interceptors
11DomainEventSaveChangesInterceptorclassMMCA.Common.Infrastructure.Persistence.Interceptors
11TenantSaveChangesInterceptorclassMMCA.Common.Infrastructure.Persistence.Interceptors
12CosmosDbContextclassMMCA.Common.Infrastructure.Persistence.DbContexts
12EFRepository<TEntity, TIdentifierType>classMMCA.Common.Infrastructure.Persistence.Repositories
12IDbContextFactory interfaceMMCA.Common.Infrastructure.Persistence.Repositories.FactoryMMCA.Common.Infrastructure.Persistence.DbContexts.Factory
7IUnitOfWork12IPhysicalDbContextFactory interfaceMMCA.Common.Application.Interfaces.InfrastructureMMCA.Common.Infrastructure.Persistence.DbContexts.Factory
712 SqliteDbContext class MMCA.Common.Infrastructure.Persistence.DbContexts
712 SQLServerDbContext class MMCA.Common.Infrastructure.Persistence.DbContexts
813 ApplicationDbContextEFFactory class MMCA.Common.Infrastructure.Persistence.DbContexts.Factory
813 AuditTrailCleanupJob class MMCA.Common.Infrastructure.Persistence.AuditTrail
813 AuditTrailReader class MMCA.Common.Infrastructure.Persistence.AuditTrail
813DbContextFactoryclassMMCA.Common.Infrastructure.Persistence.DbContexts.Factory
13 DefaultCosmosDbContextFactory class MMCA.Common.Infrastructure.Persistence.DbContexts.Factory
813 DefaultSqliteDbContextFactory class MMCA.Common.Infrastructure.Persistence.DbContexts.Factory
813 DefaultSqlServerDbContextFactory class MMCA.Common.Infrastructure.Persistence.DbContexts.Factory
813 DesignTimeDbContextHelper class MMCA.Common.Infrastructure.Persistence.DbContexts.Design
813 PhysicalDbContextFactory class MMCA.Common.Infrastructure.Persistence.DbContexts.Factory
8PushNotificationConfiguration13RepositoryFactory classMMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration.NotificationsMMCA.Common.Infrastructure.Persistence.Repositories.Factory
813 UnitOfWork class MMCA.Common.Infrastructure.Persistence
8UserNotificationConfigurationclassMMCA.Common.Infrastructure.Persistence.Configuration.EntityTypeConfiguration.Notifications
9DbContextFactoryclassMMCA.Common.Infrastructure.Persistence.DbContexts.Factory
9EFRepository<TEntity, TIdentifierType>classMMCA.Common.Infrastructure.Persistence.Repositories
914 IdentityModuleDbSeederBase<TUser> class MMCA.Common.Infrastructure.Persistence.DbContexts.Seeding
10RepositoryFactoryclassMMCA.Common.Infrastructure.Persistence.Repositories.Factory

G08 - Authentication & Authorization

-

group-08-auth.md | 69 types | JWT/JWKS dual-fetch token validation, current-user/claims, password hashing, cookie sessions, and policy/authorization plumbing.

+

group-08-auth.md | 77 types | JWT/JWKS dual-fetch token validation, current-user/claims, password hashing, cookie sessions, and policy/authorization plumbing.

@@ -2265,6 +2295,12 @@

G08 - Authentication & Authoriz

+ + + + + + @@ -2337,6 +2373,18 @@

G08 - Authentication & Authoriz

+ + + + + + + + + + + + @@ -2367,6 +2415,12 @@

G08 - Authentication & Authoriz

+ + + + + + @@ -2403,6 +2457,12 @@

G08 - Authentication & Authoriz

+ + + + + + @@ -2463,6 +2523,12 @@

G08 - Authentication & Authoriz

+ + + + + + @@ -2535,6 +2601,12 @@

G08 - Authentication & Authoriz

+ + + + + + @@ -2595,6 +2667,12 @@

G08 - Authentication & Authoriz

+ + + + + + @@ -3124,7 +3202,7 @@

G11 -

0ForgotPasswordRequestrecord structMMCA.Common.Shared.Auth
0 IAuthUser interface MMCA.Common.Domain.Auth
0PasswordResetEntryrecordMMCA.Common.Infrastructure.Auth
0PasswordResetSettingsclassMMCA.Common.Application.Auth
0 PermissionPolicy class MMCA.Common.API.Authorization
0ResetPasswordRequestrecord structMMCA.Common.Shared.Auth
0 RoleNames class MMCA.Common.Shared.Auth
1ForgotPasswordRequestValidatorclassMMCA.Common.Application.Auth.Validation
1 HasPermissionAttribute class MMCA.Common.API.Authorization
1ResetPasswordRequestValidatorclassMMCA.Common.Application.Auth.Validation
1 RsaJwksProvider class MMCA.Common.Infrastructure.Auth
3IPasswordResetTokenServiceinterfaceMMCA.Common.Application.Auth
3 IUserPreferences interface MMCA.Common.Domain.Auth
5PasswordResetTokenServiceclassMMCA.Common.Infrastructure.Auth
5 SessionCookieAuthenticationExtensions class MMCA.Common.API.SessionCookies

G12 - API Hosting, Middleware, Idempotency & DTO/Contract Mapping

-

group-12-api-hosting-mapping.md | 74 types | The ASP.NET Core edge: controller bases, middleware, startup, model binders, JSON converters, feature management, idempotency, correlation, and manual DTO/request mapping.

+

group-12-api-hosting-mapping.md | 79 types | The ASP.NET Core edge: controller bases, middleware, startup, model binders, JSON converters, feature management, idempotency, correlation, and manual DTO/request mapping.

@@ -3257,6 +3335,18 @@

G12 - API

+ + + + + + + + + + + + @@ -3353,12 +3443,6 @@

G12 - API

- - - - - - @@ -3449,6 +3533,12 @@

G12 - API

+ + + + + + @@ -3538,10 +3628,10 @@

G12 - API

- - + + - + @@ -3551,15 +3641,15 @@

G12 - API

- + - + - + - + @@ -3574,7 +3664,25 @@

G12 - API

- + + + + + + + + + + + + + + + + + + + @@ -3632,7 +3740,7 @@

G13 - gRPC & Inter-Service Cont

0MiddlewarePipelineSteprecordMMCA.Common.API.Startup
0MiddlewarePipelineStepNamesclassMMCA.Common.API.Startup
0 NonIdempotentAttribute class MMCA.Common.API.Idempotency
1CorrelationIdMiddlewareclassMMCA.Common.API.Middleware
1 DomainExceptionHandler class MMCA.Common.API.Middleware
3InsecureJwtMetadataWarningStartupFilterclassMMCA.Common.API.Startup
3 SignalRExtensions class MMCA.Common.API.StartupMMCA.Common.API.FeatureManagement
8DatabaseInitializationExtensions9CorrelationIdMiddleware classMMCA.Common.API.StartupMMCA.Common.API.Middleware
9
10AuthControllerBaseDataExportControllerBase<TQuery> classMMCA.Common.API.ControllersMMCA.Common.API.Controllers.Privacy
10DataExportControllerBase<TQuery>MiddlewarePipelineBuilder classMMCA.Common.API.Controllers.PrivacyMMCA.Common.API.Startup
10MMCA.Common.API
1113DatabaseInitializationExtensionsclassMMCA.Common.API.Startup
15AuthControllerBaseclassMMCA.Common.API.Controllers
15PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>classMMCA.Common.API.Controllers
16 UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand> class MMCA.Common.API.Controllers

G14 - Module System, Composition & Configuration

-

group-14-module-system-composition.md | 68 types | IModule discovery + Kahn-ordered ModuleLoader, the DI composition roots, assembly markers, data-source/database attributes, and options/settings binding.

+

group-14-module-system-composition.md | 70 types | IModule discovery + Kahn-ordered ModuleLoader, the DI composition roots, assembly markers, data-source/database attributes, and options/settings binding.

@@ -4023,15 +4131,21 @@

G14 - Module System, Com

+ + + + + + - + - + @@ -4046,7 +4160,13 @@

G14 - Module System, Com

- + + + + + + + @@ -4054,7 +4174,7 @@

G14 - Module System, Com

8ForgotPasswordHandlerBase<TUser, TCommand>classMMCA.Common.Application.Users.UseCases.ForgotPassword
8 GetUserPreferencesHandlerBase<TUser> class MMCA.Common.Application.Users.UseCases.GetPreferences
8ScheduledJobRunnerResetPasswordHandlerBase<TUser, TCommand> classMMCA.Common.Infrastructure.SchedulingMMCA.Common.Application.Users.UseCases.ResetPassword
8MMCA.Common.Application
1113ScheduledJobRunnerclassMMCA.Common.Infrastructure.Scheduling
14 DependencyInjection class MMCA.Common.Infrastructure

G15 - Common UI Framework (MudBlazor components, theme, base pages)

-

group-15-common-ui-framework.md | 89 types | Reusable Blazor building blocks: the data-grid list page base, theme, common pages/services, and UI extensions shared by every consumer app.

+

group-15-common-ui-framework.md | 91 types | Reusable Blazor building blocks: the data-grid list page base, theme, common pages/services, and UI extensions shared by every consumer app.

@@ -4097,6 +4217,12 @@

G15 - C

+ + + + + + @@ -4235,6 +4361,12 @@

G15 - C

+ + + + + + @@ -4645,12 +4777,6 @@

G16 - Aspire Orchestration

- - - - - - @@ -4705,12 +4831,6 @@

G16 - Aspire Orchestration

- - - - - - @@ -4723,12 +4843,6 @@

G16 - Aspire Orchestration

- - - - - - @@ -4777,12 +4891,6 @@

G16 - Aspire Orchestration

- - - - - - @@ -4799,10 +4907,34 @@

G16 - Aspire Orchestration

+ + + + + + + + + + + + + + + + + + + + + + + +
0ForgotPasswordModelclassMMCA.Common.UI.Pages.Auth
0 IApiSettings interface MMCA.Common.UI.Common.Settings
0ResetPasswordModelclassMMCA.Common.UI.Pages.Auth
0 ReturnUrlProtector class MMCA.Common.UI.Services.Navigation
0GatewayCorrelationMiddlewareclassMMCA.Common.Aspire.Gateway
0 GatewayCorsExtensions class MMCA.Common.Aspire
0OutboxPollFilterProcessorclassMMCA.Common.Aspire.Telemetry
0 SecurityHeadersSettings class MMCA.Common.Aspire.Security
1GatewayCorrelationExtensionsclassMMCA.Common.Aspire.Gateway
1 GatewayHealthCheckExtensions class MMCA.Common.Aspire.Gateway
2ExtensionsclassMMCA.Common.Aspire
2 SecurityHeadersMiddleware class MMCA.Common.Aspire.Securityclass MMCA.Common.Aspire.Security
9GatewayCorrelationMiddlewareclassMMCA.Common.Aspire.Gateway
9OutboxPollFilterProcessorclassMMCA.Common.Aspire.Telemetry
10ExtensionsclassMMCA.Common.Aspire
10GatewayCorrelationExtensionsclassMMCA.Common.Aspire.Gateway

G17 - ADC Conference - Domain Model & Module Contracts

-

group-17-conference-domain.md | 96 types | The Conference bounded context: Event/Session/Speaker/Category/Question aggregates, their domain events and invariants, plus the Shared identifiers/DTOs/integration-event contracts.

+

group-17-conference-domain.md | 100 types | The Conference bounded context: Event/Session/Speaker/Category/Question aggregates, their domain events and invariants, plus the Shared identifiers/DTOs/integration-event contracts.

@@ -4941,6 +5073,12 @@

G17 - ADC Confere

+ + + + + + @@ -5145,6 +5283,12 @@

G17 - ADC Confere

+ + + + + + @@ -5241,6 +5385,12 @@

G17 - ADC Confere

+ + + + + + @@ -5337,6 +5487,12 @@

G17 - ADC Confere

+ + + + + + @@ -5392,7 +5548,7 @@

G17 - ADC Confere

1ActivityDTOrecordMMCA.ADC.Conference.Shared.Activities
1 CategoryGroupDistribution record MMCA.ADC.Conference.Shared.Sessions.DecisionSupport
3ActivityChangedrecordMMCA.ADC.Conference.Domain.Activities.DomainEvents
3 CategoryChanged record MMCA.ADC.Conference.Domain.Categories.DomainEvents
6ActivityInvariantsclassMMCA.ADC.Conference.Domain.Activities
6 Category class MMCA.ADC.Conference.Domain.Categories
8ActivityclassMMCA.ADC.Conference.Domain.Activities
8 CurrentEventSelector class MMCA.ADC.Conference.Shared.Events

G18 - ADC Conference - Application & Use Cases

-

group-18-conference-application.md | 252 types | Conference CQRS handlers, validators, DTOs, specifications, the Sessionize import, and the session-selection decision-support analytics.

+

group-18-conference-application.md | 285 types | Conference CQRS handlers, validators, DTOs, specifications, the Sessionize import, and the session-selection decision-support analytics.

@@ -5405,6 +5561,24 @@

G18 - ADC Conference - Ap

+ + + + + + + + + + + + + + + + + + @@ -5453,12 +5627,24 @@

G18 - ADC Conference - Ap

+ + + + + + + + + + + + @@ -5647,13 +5833,19 @@

G18 - ADC Conference - Ap

- + - + + + + + + + @@ -5783,6 +5975,36 @@

G18 - ADC Conference - Ap

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -5831,6 +6053,12 @@

G18 - ADC Conference - Ap

+ + + + + + @@ -5981,6 +6209,12 @@

G18 - ADC Conference - Ap

+ + + + + + @@ -6251,6 +6485,18 @@

G18 - ADC Conference - Ap

+ + + + + + + + + + + + @@ -6581,6 +6827,12 @@

G18 - ADC Conference - Ap

+ + + + + + @@ -6629,6 +6881,24 @@

G18 - ADC Conference - Ap

+ + + + + + + + + + + + + + + + + + @@ -6665,6 +6935,12 @@

G18 - ADC Conference - Ap

+ + + + + + @@ -6677,6 +6953,12 @@

G18 - ADC Conference - Ap

+ + + + + + @@ -6713,6 +6995,18 @@

G18 - ADC Conference - Ap

+ + + + + + + + + + + + @@ -6767,12 +7061,24 @@

G18 - ADC Conference - Ap

+ + + + + + + + + + + + @@ -6797,12 +7103,30 @@

G18 - ADC Conference - Ap

+ + + + + + + + + + + + + + + + + + @@ -6815,6 +7139,12 @@

G18 - ADC Conference - Ap

+ + + + + + @@ -6827,9 +7157,21 @@

G18 - ADC Conference - Ap

- + + + + + + + + + + + + + - + @@ -6869,12 +7211,24 @@

G18 - ADC Conference - Ap

+ + + + + + + + + + + + @@ -6905,20 +7259,20 @@

G18 - ADC Conference - Ap

- + - + - - + + - +
0ActivityEventIdRules<T>classMMCA.ADC.Conference.Application.Activities.Validation
0ActivitySortOrderRules<T>classMMCA.ADC.Conference.Application.Activities.Validation
0ActivityTimeRangeRules<T>classMMCA.ADC.Conference.Application.Activities.Validation
0 AssemblyReference class MMCA.ADC.Conference.Application
0GetPublicActivityFilterQueryrecordMMCA.ADC.Conference.Application.Activities.UseCases.GetPublicActivityFilter
0 GetPublicEventSpeakerFilterQuery record MMCA.ADC.Conference.Application.Events.UseCases.GetPublicEventSpeakerFilter
0GetPublicRoomFilterQueryrecordMMCA.ADC.Conference.Application.Events.UseCases.GetPublicRoomFilter
0 GetPublicSessionCategoryItemFilterQuery record MMCA.ADC.Conference.Application.Sessions.UseCases.GetPublicSessionCategoryItemFilter0 StatusBucket enumMMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetCategoryDistributionMMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetSessionSelectionDashboard
0 StatusBucket enumMMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetSessionSelectionDashboardMMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetCategoryDistribution
1ActivityUpdateRequestrecordMMCA.ADC.Conference.Application.Activities.UseCases.Update
1
7ActivityDescriptionRules<T>classMMCA.ADC.Conference.Application.Activities.Validation
7ActivityNameRules<T>classMMCA.ADC.Conference.Application.Activities.Validation
7ActivityVenueAddressRules<T>classMMCA.ADC.Conference.Application.Activities.Validation
7ActivityVenueNameRules<T>classMMCA.ADC.Conference.Application.Activities.Validation
7ActivityVenueUrlRules<T>classMMCA.ADC.Conference.Application.Activities.Validation
7 AddCategoryItemCommand record MMCA.ADC.Conference.Application.Categories.UseCases.AddCategoryItem
7EventTicketingUrlRules<T>classMMCA.ADC.Conference.Application.Events.Validation
7 EventTimeZoneRules<T> class MMCA.ADC.Conference.Application.Events.Validation
8ActivityUpdateRequestValidatorclassMMCA.ADC.Conference.Application.Activities.UseCases.Update
8 AddCategoryItemCommandValidator class MMCA.ADC.Conference.Application.Categories.UseCases.AddCategoryItem
9ActivityCreateRequestrecordMMCA.ADC.Conference.Application.Activities.UseCases.Create
9ActivityDTOMapperclassMMCA.ADC.Conference.Application.Activities.DTOs
9 AddEventQuestionAnswerCommandValidator class MMCA.ADC.Conference.Application.Events.UseCases.AddEventQuestionAnswer
9UpdateActivityCommandrecordMMCA.ADC.Conference.Application.Activities.UseCases.Update
9 UpdateConferenceCategoryHandler class MMCA.ADC.Conference.Application.Categories.UseCases.Update
10ActivityCreateRequestMapperclassMMCA.ADC.Conference.Application.Activities.UseCases.Create
10ActivityCreateRequestValidatorclassMMCA.ADC.Conference.Application.Activities.UseCases.Create
10ActivityNavigationPopulatorclassMMCA.ADC.Conference.Application.Activities
10 AddSessionCategoryItemCommandValidator class MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionCategoryItem
10CategoryItemNavigationPopulatorclassMMCA.ADC.Conference.Application.Categories
10 CategorySyncStrategy class MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize
10CreateActivityHandlerclassMMCA.ADC.Conference.Application.Activities.UseCases.Create
10 CreateEventHandler class MMCA.ADC.Conference.Application.Events.UseCases.Create
10EventQuestionAnswerNavigationPopulatorclassMMCA.ADC.Conference.Application.Events
10EventSpeakerNavigationPopulatorclassMMCA.ADC.Conference.Application.Events
10 ExportEventCalendarHandler class MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar
10RoomNavigationPopulatorclassMMCA.ADC.Conference.Application.Events
10 RoomSyncStrategy class MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize
10SessionCategoryItemNavigationPopulatorclassMMCA.ADC.Conference.Application.Sessions
10 SessionCreateRequestMapper class MMCA.ADC.Conference.Application.Sessions.UseCases.Create
10SessionQuestionAnswerNavigationPopulatorclassMMCA.ADC.Conference.Application.Sessions
10SessionSpeakerNavigationPopulatorclassMMCA.ADC.Conference.Application.Sessions
10 SessionSyncStrategy class MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize
10SpeakerCategoryItemNavigationPopulatorclassMMCA.ADC.Conference.Application.Speakers
10 SpeakerEntityQueryService class MMCA.ADC.Conference.Application.Speakers
10SpeakerQuestionAnswerNavigationPopulatorclassMMCA.ADC.Conference.Application.Speakers
10 SpeakerSyncStrategy class MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize
10SponsorCreateRequestValidatorSponsorCreateRequestValidatorclassMMCA.ADC.Conference.Application.Sponsors.UseCases.Create
10SponsorNavigationPopulatorclassMMCA.ADC.Conference.Application.Sponsors
10UpdateActivityHandler classMMCA.ADC.Conference.Application.Sponsors.UseCases.CreateMMCA.ADC.Conference.Application.Activities.UseCases.Update
10
11GetPublicActivityFilterHandlerclassMMCA.ADC.Conference.Application.Activities.UseCases.GetPublicActivityFilter
11 GetPublicEventSpeakerFilterHandler class MMCA.ADC.Conference.Application.Events.UseCases.GetPublicEventSpeakerFilter
11GetPublicRoomFilterHandlerclassMMCA.ADC.Conference.Application.Events.UseCases.GetPublicRoomFilter
11 GetPublicSessionCategoryItemFilterHandler class MMCA.ADC.Conference.Application.Sessions.UseCases.GetPublicSessionCategoryItemFilter
11RefreshFromSessionizeHandlerUpdateSessionHandler classMMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionizeMMCA.ADC.Conference.Application.Sessions.UseCases.Update
11UpdateSessionHandler14RefreshFromSessionizeHandler classMMCA.ADC.Conference.Application.Sessions.UseCases.UpdateMMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize

G19 - ADC Conference - Infrastructure & Persistence

-

group-19-conference-infrastructure.md | 32 types | The Conference module DbContext registration, EF entity configurations, database seeding, and infrastructure services.

+

group-19-conference-infrastructure.md | 33 types | The Conference module DbContext registration, EF entity configurations, database seeding, and infrastructure services.

@@ -7069,15 +7423,15 @@

G19 - ADC Conference

- + - + - + - + @@ -7121,10 +7475,16 @@

G19 - ADC Conference

+ + + + + +
9ConferenceModuleDbSeederActivityConfiguration classMMCA.ADC.Conference.Infrastructure.Persistence.DbContexts.SeedingMMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration
9ModuleApplicationDbContextConferenceModuleDbSeeder classMMCA.ADC.Conference.Infrastructure.Persistence.DbContextsMMCA.ADC.Conference.Infrastructure.Persistence.DbContexts.Seeding
9class MMCA.ADC.Conference.Infrastructure
12ModuleApplicationDbContextclassMMCA.ADC.Conference.Infrastructure.Persistence.DbContexts

G20 - ADC Conference - API, gRPC Contracts & Service Host

-

group-20-conference-api-grpc.md | 42 types | Conference REST controllers, the .Contracts gRPC surface, the extractable service host, and the gRPC adapter.

+

group-20-conference-api-grpc.md | 43 types | Conference REST controllers, the .Contracts gRPC surface, the extractable service host, and the gRPC adapter.

@@ -7317,6 +7677,12 @@

G20 - ADC Confe

+ + + + + + @@ -7390,7 +7756,7 @@

G20 - ADC Confe

10ActivitiesControllerclassMMCA.ADC.Conference.API.Controllers
10 ConferenceModuleSeeder class MMCA.ADC.Conference.API

G21 - ADC Conference - UI

-

group-21-conference-ui.md | 97 types | The Conference Blazor pages (events, sessions, speakers, categories, questions, rooms, feedback, public, session-selection) and their UI services.

+

group-21-conference-ui.md | 106 types | The Conference Blazor pages (events, sessions, speakers, categories, questions, rooms, feedback, public, session-selection) and their UI services.

@@ -7439,6 +7805,12 @@

G21 - ADC Conference - UI

+ + + + + + @@ -7451,6 +7823,12 @@

G21 - ADC Conference - UI

+ + + + + + @@ -7589,6 +7967,12 @@

G21 - ADC Conference - UI

+ + + + + + @@ -7655,12 +8039,24 @@

G21 - ADC Conference - UI

+ + + + + + + + + + + + @@ -7841,12 +8237,6 @@

G21 - ADC Conference - UI

- - - - - - @@ -7901,12 +8291,42 @@

G21 - ADC Conference - UI

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -8665,6 +9085,12 @@

G22 - ADC Engagement Modu

+ + + + + + @@ -8839,12 +9265,6 @@

G22 - ADC Engagement Modu

- - - - - - @@ -9007,12 +9427,6 @@

G22 - ADC Engagement Modu

- - - - - - @@ -9055,6 +9469,12 @@

G22 - ADC Engagement Modu

+ + + + + + @@ -9074,7 +9494,7 @@

G22 - ADC Engagement Modu

0InfiniteScrollSentinelclassMMCA.ADC.Conference.UI.Components
0 IPublicLinkBuilder interface MMCA.ADC.Conference.UI.Services
0PreConferenceWorkshopInforecordMMCA.ADC.Conference.UI.Pages.Home
0 ScorePollSignal enum MMCA.ADC.Conference.UI.Pages.SessionSelection
3IActivityUIServiceinterfaceMMCA.ADC.Conference.UI.Services
3 ICategoryItemUIService interface MMCA.ADC.Conference.UI.Services
3PublicScheduleRoomOptionsclassMMCA.ADC.Conference.UI.Pages.Public
3 SpeakerLookupService class MMCA.ADC.Conference.UI.Services
4ActivityServiceclassMMCA.ADC.Conference.UI.Services
4 CategoryItemService class MMCA.ADC.Conference.UI.Services
7PublicEventListclassMMCA.ADC.Conference.UI.Pages.Public
7 PublicSessionListView class MMCA.ADC.Conference.UI.Pages.Public
9ActivityCreateclassMMCA.ADC.Conference.UI.Pages.Activity
9ActivityDetailclassMMCA.ADC.Conference.UI.Pages.Activity
9ActivityListclassMMCA.ADC.Conference.UI.Pages.Activity
9 ADCHome class MMCA.ADC.Conference.UI.Pages.Home
9PublicActivityListclassMMCA.ADC.Conference.UI.Pages.Public
9PublicEventListclassMMCA.ADC.Conference.UI.Pages.Public
9 PublicSessionDetail class MMCA.ADC.Conference.UI.Pages.Public
4SessionQuestionSubmittedPointsHandlerclassMMCA.ADC.Engagement.Application.Points.DomainEventHandlers
4 UserSessionBookmarkCacheEvictionHandler class MMCA.ADC.Engagement.Application.UserSessionBookmarks.DomainEventHandlers
8SessionQuestionSubmittedPointsHandlerclassMMCA.ADC.Engagement.Application.Points.DomainEventHandlers
8 SessionQuestionUpvoteConfiguration class MMCA.ADC.Engagement.Infrastructure.Persistence.EntityConfiguration
11ModuleApplicationDbContextclassMMCA.ADC.Engagement.Infrastructure.Persistence.DbContexts
11 RecordRoomCheckInHandler class MMCA.ADC.Engagement.Application.CheckIns.UseCases.RecordRoomCheckIn
12ModuleApplicationDbContextclassMMCA.ADC.Engagement.Infrastructure.Persistence.DbContexts
12 UserEngagementExportGrpcService class MMCA.ADC.Engagement.Service.Grpc

G26 - ADC Engagement Live Layer (Real-Time Polls & Session Q&A)

-

group-23-engagement-live-layer.md | 94 types | Real-time audience interaction in the Engagement bounded context: event-wide live polls with voting and moderated per-session Q&A with upvoting, over the SignalR hub-channel transport (ADR-039) and the cross-service gRPC live-channel adapter.

+

group-23-engagement-live-layer.md | 95 types | Real-time audience interaction in the Engagement bounded context: event-wide live polls with voting and moderated per-session Q&A with upvoting, over the SignalR hub-channel transport (ADR-039) and the cross-service gRPC live-channel adapter.

@@ -9203,13 +9623,13 @@

G26 - ADC E

- + - + @@ -9645,6 +10065,12 @@

G26 - ADC E

+ + + + + + @@ -9652,7 +10078,7 @@

G26 - ADC E

0 OptionState classMMCA.ADC.Engagement.UI.Pages.HappeningNowMMCA.ADC.Engagement.UI.Pages.SessionLive
0 OptionState classMMCA.ADC.Engagement.UI.Pages.SessionLiveMMCA.ADC.Engagement.UI.Pages.HappeningNow
0
10LivePollOptionNavigationPopulatorclassMMCA.ADC.Engagement.Application.LivePolls.Services
10 LivePollVoteChangedHandler class MMCA.ADC.Engagement.Application.LivePolls.DomainEventHandlers

G23 - ADC Identity Module (Users, Profiles, GDPR Export/Erasure)

-

group-24-identity-module.md | 83 types | The Identity bounded context end-to-end: the User aggregate, change-password/delete/export use cases, persistence, API/contracts/service, and profile/user UI.

+

group-24-identity-module.md | 88 types | The Identity bounded context end-to-end: the User aggregate, change-password/delete/export use cases, persistence, API/contracts/service, and profile/user UI.

@@ -9667,49 +10093,49 @@

G23 - ADC I

- + - + - + - + - + - + - + - + @@ -9839,6 +10265,12 @@

G23 - ADC I

+ + + + + + @@ -10037,9 +10469,9 @@

G23 - ADC I

- - - + + + @@ -10085,12 +10517,6 @@

G23 - ADC I

- - - - - - @@ -10127,6 +10553,12 @@

G23 - ADC I

+ + + + + + @@ -10135,28 +10567,52 @@

G23 - ADC I

- + - + + + + + + + + + + + + + + + + + + + - + - + - + - + + + + + + + @@ -10182,12 +10638,6 @@

G24 - ADC

- - - - - - @@ -10219,25 +10669,25 @@

G24 - ADC

- + - - + + - - + + - - + + @@ -10248,6 +10698,12 @@

G24 - ADC

+ + + + + + @@ -10870,7 +11326,7 @@

G25 - Testing & Quality Infrastructure

-

group-27-testing-infrastructure.md | 1780 types | All test projects + the reusable Testing/Testing.E2E/Testing.UI bases, architecture-fitness tests, and the component Gallery harness; individual [Fact]s are rolled up by project (logged exception).

+

group-27-testing-infrastructure.md | 1907 types | All test projects + the reusable Testing/Testing.E2E/Testing.UI bases, architecture-fitness tests, and the component Gallery harness; individual [Fact]s are rolled up by project (logged exception).

Rolled up by project (individual [Fact]s not sectioned - logged exception). Reusable test infrastructure assemblies (sectioned in full in the chapter) are marked (infra).

@@ -10885,25 +11341,25 @@

G25 - Testing & Quality Infra

- - + + - + - - + + - + @@ -10927,7 +11383,7 @@

G25 - Testing & Quality Infra

- + @@ -10939,7 +11395,7 @@

G25 - Testing & Quality Infra

- + @@ -10951,8 +11407,8 @@

G25 - Testing & Quality Infra

- - + + @@ -10994,13 +11450,13 @@

G25 - Testing & Quality Infra

- + - - + + @@ -11012,12 +11468,12 @@

G25 - Testing & Quality Infra

- + - + @@ -11042,7 +11498,7 @@

G25 - Testing & Quality Infra

- + @@ -11065,26 +11521,26 @@

G25 - Testing & Quality Infra

- - + + - + - - + + - - + + @@ -11113,8 +11569,8 @@

G25 - Testing & Quality Infra

- - + + @@ -11125,26 +11581,26 @@

G25 - Testing & Quality Infra

- - + + - + - + - - + + @@ -11155,7 +11611,7 @@

G25 - Testing & Quality Infra

- + @@ -11167,7 +11623,7 @@

G25 - Testing & Quality Infra

- + diff --git a/docs/onboarding/00-inventory.html b/docs/onboarding/00-inventory.html index b7ddbe6..425ce81 100644 --- a/docs/onboarding/00-inventory.html +++ b/docs/onboarding/00-inventory.html @@ -148,9 +148,9 @@

Phase 0: Type Inventory

Generated mechanically by a Roslyn syntactic parse of every in-scope .cs file under MMCA.Common/Source, MMCA.Common/Tests, MMCA.ADC/Source, MMCA.ADC/Tests.

    -
  • Files scanned: 2810 (in-scope 2692, generated/excluded 118)
  • -
  • Type declaration rows (including partial-class fragments): 3586
  • -
  • Distinct type nodes (partials collapsed): 3465
  • +
  • Files scanned: 2950 (in-scope 2828, generated/excluded 122)
  • +
  • Type declaration rows (including partial-class fragments): 3797
  • +
  • Distinct type nodes (partials collapsed): 3668
  • extension(T) blocks: 78

Counts by kind

@@ -163,15 +163,15 @@

Counts by kind

- + - + - + @@ -179,7 +179,7 @@

Counts by kind

- + @@ -200,23 +200,23 @@

Counts by assembly (distinct nodes)

- + - + - + - + - + @@ -224,15 +224,15 @@

Counts by assembly (distinct nodes)

- + - + - + @@ -248,7 +248,7 @@

Counts by assembly (distinct nodes)

- + @@ -256,11 +256,11 @@

Counts by assembly (distinct nodes)

- + - + @@ -268,7 +268,7 @@

Counts by assembly (distinct nodes)

- + @@ -280,11 +280,11 @@

Counts by assembly (distinct nodes)

- + - + @@ -340,7 +340,7 @@

Counts by assembly (distinct nodes)

- + @@ -348,11 +348,11 @@

Counts by assembly (distinct nodes)

- + - + @@ -376,7 +376,7 @@

Counts by assembly (distinct nodes)

- + @@ -448,23 +448,23 @@

Counts by assembly (distinct nodes)

- + - + - + - + - + @@ -476,7 +476,7 @@

Counts by assembly (distinct nodes)

- + @@ -500,7 +500,7 @@

Counts by assembly (distinct nodes)

- + @@ -508,11 +508,11 @@

Counts by assembly (distinct nodes)

- + - + @@ -520,19 +520,19 @@

Counts by assembly (distinct nodes)

- + - + - + - + @@ -540,11 +540,11 @@

Counts by assembly (distinct nodes)

- + - + @@ -556,7 +556,7 @@

Counts by assembly (distinct nodes)

- + @@ -586,6 +586,13 @@

Full inventory

+ + + + + + + @@ -719,6 +726,13 @@

Full inventory

+ + + + + + + @@ -761,6 +775,13 @@

Full inventory

+ + + + + + + @@ -845,6 +866,13 @@

Full inventory

+ + + + + + + @@ -870,7 +898,7 @@

Full inventory

- + @@ -947,7 +975,7 @@

Full inventory

- + @@ -1031,7 +1059,7 @@

Full inventory

- + @@ -1055,6 +1083,13 @@

Full inventory

+ + + + + + + @@ -1115,7 +1150,7 @@

Full inventory

- + @@ -1199,7 +1234,154 @@

Full inventory

- + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1377,6 +1559,27 @@

Full inventory

+ + + + + + + + + + + + + + + + + + + + + @@ -1584,7 +1787,7 @@

Full inventory

- + @@ -1601,6 +1804,20 @@

Full inventory

+ + + + + + + + + + + + + + @@ -1654,7 +1871,7 @@

Full inventory

- + @@ -1822,7 +2039,7 @@

Full inventory

- + @@ -1846,6 +2063,13 @@

Full inventory

+ + + + + + + @@ -1972,11 +2196,32 @@

Full inventory

+ + + + + + + - + + + + + + + + + + + + + + + @@ -2539,6 +2784,13 @@

Full inventory

+ + + + + + + @@ -2553,6 +2805,13 @@

Full inventory

+ + + + + + + @@ -2798,6 +3057,13 @@

Full inventory

+ + + + + + + @@ -2945,6 +3211,62 @@

Full inventory

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3092,6 +3414,20 @@

Full inventory

+ + + + + + + + + + + + + + @@ -3099,6 +3435,13 @@

Full inventory

+ + + + + + + @@ -3173,7 +3516,7 @@

Full inventory

- + @@ -3243,7 +3586,7 @@

Full inventory

- + @@ -3393,6 +3736,13 @@

Full inventory

+ + + + + + + @@ -3400,28 +3750,49 @@

Full inventory

- + - - + + - + - - + + - + - - + + - + + + + + + + + + + + + + + + + + + + + + + @@ -3586,7 +3957,7 @@

Full inventory

- + @@ -3600,7 +3971,7 @@

Full inventory

- + @@ -3694,6 +4065,13 @@

Full inventory

+ + + + + + + @@ -3708,6 +4086,13 @@

Full inventory

+ + + + + + + @@ -3810,14 +4195,14 @@

Full inventory

- + - + @@ -3869,6 +4254,13 @@

Full inventory

+ + + + + + + @@ -3950,7 +4342,7 @@

Full inventory

- + @@ -4023,6 +4415,27 @@

Full inventory

+ + + + + + + + + + + + + + + + + + + + + @@ -4146,14 +4559,14 @@

Full inventory

- + - + @@ -4303,6 +4716,20 @@

Full inventory

+ + + + + + + + + + + + + + @@ -4356,7 +4783,14 @@

Full inventory

- + + + + + + + + @@ -4503,14 +4937,21 @@

Full inventory

- + - + + + + + + + + @@ -4902,7 +5343,7 @@

Full inventory

- + @@ -5094,6 +5535,13 @@

Full inventory

+ + + + + + + @@ -5605,6 +6053,34 @@

Full inventory

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -5665,77 +6141,98 @@

Full inventory

- + - + - + - + - + - + - + - + + + + + + + + - + + + + + + + + - + - + + + + + + + + @@ -5763,7 +6260,7 @@

Full inventory

- + @@ -5777,7 +6274,7 @@

Full inventory

- + @@ -5962,6 +6459,13 @@

Full inventory

+ + + + + + + @@ -6018,6 +6522,13 @@

Full inventory

+ + + + + + + @@ -6298,6 +6809,20 @@

Full inventory

+ + + + + + + + + + + + + + @@ -6326,6 +6851,27 @@

Full inventory

+ + + + + + + + + + + + + + + + + + + + + @@ -6333,6 +6879,13 @@

Full inventory

+ + + + + + + @@ -6361,6 +6914,13 @@

Full inventory

+ + + + + + + @@ -6375,6 +6935,13 @@

Full inventory

+ + + + + + + @@ -6554,7 +7121,7 @@

Full inventory

- + @@ -6582,7 +7149,7 @@

Full inventory

- + @@ -6771,7 +7338,7 @@

Full inventory

- + @@ -6960,7 +7527,7 @@

Full inventory

- + @@ -7131,6 +7698,13 @@

Full inventory

+ + + + + + + @@ -7516,6 +8090,13 @@

Full inventory

+ + + + + + + @@ -7646,7 +8227,7 @@

Full inventory

- + @@ -8055,6 +8636,13 @@

Full inventory

+ + + + + + + @@ -8150,7 +8738,14 @@

Full inventory

- + + + + + + + + @@ -8507,7 +9102,7 @@

Full inventory

- + @@ -8598,14 +9193,14 @@

Full inventory

- + - + @@ -8626,7 +9221,7 @@

Full inventory

- + @@ -8934,7 +9529,7 @@

Full inventory

- + @@ -8969,14 +9564,14 @@

Full inventory

- + - + @@ -10215,7 +10810,7 @@

Full inventory

- + @@ -10250,7 +10845,7 @@

Full inventory

- + @@ -10323,6 +10918,13 @@

Full inventory

+ + + + + + + @@ -10526,6 +11128,20 @@

Full inventory

+ + + + + + + + + + + + + + @@ -10575,6 +11191,20 @@

Full inventory

+ + + + + + + + + + + + + + @@ -10729,6 +11359,13 @@

Full inventory

+ + + + + + + @@ -10750,6 +11387,13 @@

Full inventory

+ + + + + + + @@ -11058,6 +11702,13 @@

Full inventory

+ + + + + + + @@ -11580,7 +12231,7 @@

Full inventory

- + @@ -12031,6 +12682,13 @@

Full inventory

+ + + + + + + @@ -12409,6 +13067,13 @@

Full inventory

+ + + + + + + @@ -12416,6 +13081,27 @@

Full inventory

+ + + + + + + + + + + + + + + + + + + + + @@ -12434,7 +13120,7 @@

Full inventory

- + @@ -12448,14 +13134,14 @@

Full inventory

- + - + @@ -12689,6 +13375,13 @@

Full inventory

+ + + + + + + @@ -12808,6 +13501,13 @@

Full inventory

+ + + + + + + @@ -12815,6 +13515,20 @@

Full inventory

+ + + + + + + + + + + + + + @@ -13050,34 +13764,55 @@

Full inventory

- + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + @@ -13186,6 +13921,13 @@

Full inventory

+ + + + + + + @@ -13207,6 +13949,13 @@

Full inventory

+ + + + + + + @@ -13228,6 +13977,13 @@

Full inventory

+ + + + + + + @@ -13253,7 +14009,7 @@

Full inventory

- + @@ -13291,6 +14047,20 @@

Full inventory

+ + + + + + + + + + + + + + @@ -13298,6 +14068,13 @@

Full inventory

+ + + + + + + @@ -13312,6 +14089,13 @@

Full inventory

+ + + + + + + @@ -13424,6 +14208,27 @@

Full inventory

+ + + + + + + + + + + + + + + + + + + + + @@ -13834,7 +14639,7 @@

Full inventory

- + @@ -13844,6 +14649,13 @@

Full inventory

+ + + + + + + @@ -14348,6 +15160,13 @@

Full inventory

+ + + + + + + @@ -14362,6 +15181,13 @@

Full inventory

+ + + + + + + @@ -14485,7 +15311,7 @@

Full inventory

- + @@ -14506,21 +15332,21 @@

Full inventory

- + - + - + @@ -14537,11 +15363,46 @@

Full inventory

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -14562,7 +15423,7 @@

Full inventory

- + @@ -14583,7 +15444,7 @@

Full inventory

- + @@ -14649,6 +15510,13 @@

Full inventory

+ + + + + + + @@ -14663,6 +15531,13 @@

Full inventory

+ + + + + + + @@ -15461,6 +16336,27 @@

Full inventory

+ + + + + + + + + + + + + + + + + + + + + @@ -15510,6 +16406,20 @@

Full inventory

+ + + + + + + + + + + + + + @@ -15748,6 +16658,20 @@

Full inventory

+ + + + + + + + + + + + + + @@ -15825,6 +16749,13 @@

Full inventory

+ + + + + + + @@ -15839,6 +16770,27 @@

Full inventory

+ + + + + + + + + + + + + + + + + + + + + @@ -16077,6 +17029,13 @@

Full inventory

+ + + + + + + @@ -16113,12 +17072,26 @@

Full inventory

+ + + + + + + + + + + + + + @@ -16126,6 +17099,13 @@

Full inventory

+ + + + + + + @@ -16189,6 +17169,20 @@

Full inventory

+ + + + + + + + + + + + + + @@ -16210,6 +17204,20 @@

Full inventory

+ + + + + + + + + + + + + + @@ -16308,6 +17316,13 @@

Full inventory

+ + + + + + + @@ -16322,6 +17337,27 @@

Full inventory

+ + + + + + + + + + + + + + + + + + + + + @@ -16350,6 +17386,13 @@

Full inventory

+ + + + + + + @@ -16389,9 +17432,23 @@

Full inventory

+ + + + + + + + + + + + + + @@ -16399,6 +17456,13 @@

Full inventory

+ + + + + + + @@ -16511,6 +17575,13 @@

Full inventory

+ + + + + + + @@ -16595,6 +17666,13 @@

Full inventory

+ + + + + + + @@ -16637,6 +17715,13 @@

Full inventory

+ + + + + + + @@ -16658,6 +17743,13 @@

Full inventory

+ + + + + + + @@ -16665,6 +17757,13 @@

Full inventory

+ + + + + + + @@ -16675,78 +17774,176 @@

Full inventory

- - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - - + + - + - - + + - - + + - - + + - + - - + + - - + + - - + + - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + - - + + @@ -17001,6 +18198,13 @@

Full inventory

+ + + + + + + @@ -17029,6 +18233,13 @@

Full inventory

+ + + + + + + @@ -17348,7 +18559,7 @@

Full inventory

- + @@ -18170,6 +19381,20 @@

Full inventory

+ + + + + + + + + + + + + + @@ -19059,6 +20284,13 @@

Full inventory

+ + + + + + + @@ -19192,6 +20424,13 @@

Full inventory

+ + + + + + + @@ -19437,6 +20676,13 @@

Full inventory

+ + + + + + + @@ -19444,6 +20690,13 @@

Full inventory

+ + + + + + + @@ -21523,6 +22776,13 @@

Full inventory

+ + + + + + + @@ -21569,7 +22829,7 @@

Full inventory

- + @@ -21597,7 +22857,7 @@

Full inventory

- + @@ -21649,6 +22909,13 @@

Full inventory

+ + + + + + + @@ -21656,6 +22923,13 @@

Full inventory

+ + + + + + + @@ -21674,14 +22948,63 @@

Full inventory

- + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -21691,6 +23014,13 @@

Full inventory

+ + + + + + + @@ -21772,7 +23102,7 @@

Full inventory

- + @@ -21782,6 +23112,13 @@

Full inventory

+ + + + + + + @@ -21824,6 +23161,34 @@

Full inventory

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -21968,7 +23333,7 @@

Full inventory

- + @@ -21999,6 +23364,13 @@

Full inventory

+ + + + + + + @@ -22048,6 +23420,13 @@

Full inventory

+ + + + + + + @@ -22671,6 +24050,13 @@

Full inventory

+ + + + + + + @@ -22734,6 +24120,13 @@

Full inventory

+ + + + + + + @@ -22759,6 +24152,13 @@

Full inventory

+ + + + + + + @@ -22888,6 +24288,13 @@

Full inventory

+ + + + + + + @@ -22962,7 +24369,7 @@

Full inventory

- + @@ -23147,6 +24554,13 @@

Full inventory

+ + + + + + + @@ -23273,6 +24687,13 @@

Full inventory

+ + + + + + + @@ -23294,6 +24715,13 @@

Full inventory

+ + + + + + + @@ -23308,6 +24736,13 @@

Full inventory

+ + + + + + + @@ -23392,6 +24827,13 @@

Full inventory

+ + + + + + + @@ -23641,7 +25083,7 @@

Full inventory

- + @@ -23669,7 +25111,7 @@

Full inventory

- + @@ -23728,6 +25170,13 @@

Full inventory

+ + + + + + + @@ -23749,6 +25198,13 @@

Full inventory

+ + + + + + + @@ -24565,7 +26021,7 @@

Full inventory

- + @@ -24617,6 +26073,13 @@

Full inventory

+ + + + + + + @@ -24652,6 +26115,13 @@

Full inventory

+ + + + + + + @@ -25030,11 +26500,18 @@

Full inventory

+ + + + + + + - + @@ -25258,7 +26735,7 @@

Full inventory

- + @@ -25566,7 +27043,7 @@

Full inventory

- + @@ -25587,7 +27064,7 @@

Full inventory

- + @@ -25608,7 +27085,7 @@

Full inventory

- + @@ -25703,7 +27180,7 @@

extension(T) blocks

- + @@ -25858,7 +27335,7 @@

extension(T) blocks

- + @@ -25868,17 +27345,17 @@

extension(T) blocks

- + - + - + @@ -26082,7 +27559,7 @@

extension(T) blocks

0 AssemblyReference classMMCA.ADC.Identity.APIMMCA.ADC.Identity.Domain
0 AssemblyReference classMMCA.ADC.Identity.InfrastructureMMCA.ADC.Identity.API
0 AssemblyReference classMMCA.ADC.Identity.ApplicationMMCA.ADC.Identity.Infrastructure
0 AssemblyReference classMMCA.ADC.Identity.DomainMMCA.ADC.Identity.Application
0 ClassReference classMMCA.ADC.Identity.APIMMCA.ADC.Identity.Infrastructure
0 ClassReference classMMCA.ADC.Identity.InfrastructureMMCA.ADC.Identity.API
0 ClassReference classMMCA.ADC.Identity.ApplicationMMCA.ADC.Identity.Domain
0 ClassReference classMMCA.ADC.Identity.DomainMMCA.ADC.Identity.Application
0
1ForgotPasswordCommandrecordMMCA.ADC.Identity.Application.Users.UseCases.ForgotPassword
1 HttpContextExternalLoginEmailVerifier class MMCA.ADC.Identity.API.Authentication
8ModuleApplicationDbContextclassMMCA.ADC.Identity.Infrastructure.Persistence.DbContextsResetPasswordCommandrecordMMCA.ADC.Identity.Application.Users.UseCases.ResetPassword
8
9AuthenticationServiceclassMMCA.ADC.Identity.Application.Users
9 ChangePasswordHandler class MMCA.ADC.Identity.Application.Users.UseCases.ChangePassword
9ResetPasswordHandlerclassMMCA.ADC.Identity.Application.Users.UseCases.ResetPassword
9 UsersController class MMCA.ADC.Identity.API.Controllers10 DependencyInjection classMMCA.ADC.Identity.ApplicationMMCA.ADC.Identity.Contracts
1012ModuleApplicationDbContextclassMMCA.ADC.Identity.Infrastructure.Persistence.DbContexts
14AuthenticationServiceclassMMCA.ADC.Identity.Application.Users
14ForgotPasswordHandlerclassMMCA.ADC.Identity.Application.Users.UseCases.ForgotPassword
15 DependencyInjection classMMCA.ADC.Identity.ContractsMMCA.ADC.Identity.Application
1015 IdentityModuleDbSeeder class MMCA.ADC.Identity.Infrastructure.Persistence.DbContexts.Seeding
1116 IdentityModuleSeeder class MMCA.ADC.Identity.API
1216PasswordResetControllerclassMMCA.ADC.Identity.API.Controllers
17 AuthController class MMCA.ADC.Identity.API.ControllersMMCA.ADC.UI
0WebAuthenticatorCallbackActivityclassMMCA.ADC.UI
1 ADCHomePageContent class
3MainActivityMainPage class MMCA.ADC.UI
3MainPage4App class MMCA.ADC.UI
4App9MainActivity class MMCA.ADC.UI
4NowNextWidgetProvider9WebAuthenticatorCallbackActivity class MMCA.ADC.UI
MMCA.ADC.UI.Pages
10NowNextWidgetProviderclassMMCA.ADC.UI
11 MauiProgram class
MMCA.ADC.Architecture.Tests (infra)32L1-L1035L1-L13
MMCA.ADC.Conference.API.Tests1920 L1-L11
MMCA.ADC.Conference.Application.Tests148L0-L12166L0-L15
MMCA.ADC.Conference.Domain.Tests2528 L6-L11
MMCA.ADC.Conference.UI.Tests3745 L1-L11
MMCA.ADC.E2E.Tests8283 L0-L8
MMCA.ADC.Engagement.Application.Tests57L0-L1359L0-L15
MMCA.ADC.Identity.API.Tests 7L1-L13L1-L18
MMCA.ADC.Identity.Application.Tests26L0-L1028L0-L15
MMCA.ADC.Identity.Infrastructure.Tests 4L8-L11L8-L16
MMCA.ADC.Identity.IntegrationTests3334 L0-L19
MMCA.ADC.Notification.Application.Tests 5L1-L10L1-L15
MMCA.Common.API.Tests111L0-L13121L0-L18
MMCA.Common.Application.Tests241265 L0-L10
MMCA.Common.Architecture.Tests (infra)63L0-L889L0-L13
MMCA.Common.Aspire.Tests34L0-L336L0-L11
MMCA.Common.Infrastructure.Tests307L0-L12323L0-L16
MMCA.Common.Testing (infra)18L0-L919L0-L14
MMCA.Common.Testing.Architecture (infra)4648 L0-L5
MMCA.Common.Testing.E2E (infra)2225 L0-L4
MMCA.Common.Testing.Tests16L0-L1017L0-L15
MMCA.Common.UI.E2E.Tests1315 L2-L13
MMCA.Common.UI.Tests8788 L0-L7
class28122978
record531569
interface196201
enum
record struct1618
delegate
MMCA.ADC.Architecture.Tests3235
MMCA.ADC.Conference.API3536
MMCA.ADC.Conference.API.Tests1920
MMCA.ADC.Conference.Application252285
MMCA.ADC.Conference.Application.Tests148166
MMCA.ADC.Conference.Contracts
MMCA.ADC.Conference.Domain4245
MMCA.ADC.Conference.Domain.Tests2528
MMCA.ADC.Conference.Infrastructure3233
MMCA.ADC.Conference.Infrastructure.Tests
MMCA.ADC.Conference.Shared5455
MMCA.ADC.Conference.Shared.Tests
MMCA.ADC.Conference.UI97106
MMCA.ADC.Conference.UI.Tests3745
MMCA.ADC.CrossService.IntegrationTests
MMCA.ADC.E2E.Tests8283
MMCA.ADC.Engagement.API
MMCA.ADC.Engagement.Application8485
MMCA.ADC.Engagement.Application.Tests5759
MMCA.ADC.Engagement.Contracts
MMCA.ADC.Identity.API1112
MMCA.ADC.Identity.API.Tests
MMCA.ADC.Identity.Application3034
MMCA.ADC.Identity.Application.Tests2628
MMCA.ADC.Identity.Contracts
MMCA.ADC.Identity.IntegrationTests3334
MMCA.ADC.Identity.Service
MMCA.Common.API9196
MMCA.Common.API.Tests111121
MMCA.Common.Application177186
MMCA.Common.Application.Tests241265
MMCA.Common.Architecture.Tests6389
MMCA.Common.Aspire
MMCA.Common.Aspire.Tests3436
MMCA.Common.Benchmarks
MMCA.Common.Infrastructure180184
MMCA.Common.Infrastructure.Redis.Tests
MMCA.Common.Infrastructure.Tests307323
MMCA.Common.Shared6668
MMCA.Common.Shared.Tests
MMCA.Common.Testing1819
MMCA.Common.Testing.Architecture4648
MMCA.Common.Testing.E2E2225
MMCA.Common.Testing.Tests1617
MMCA.Common.Testing.UI
MMCA.Common.UI150152
MMCA.Common.UI.E2E.Tests1315
MMCA.Common.UI.Gallery
MMCA.Common.UI.Tests8788
MMCA.Common.UI.Web MMCA.ADC.Architecture.Tests/AdcArchitectureMap.cs:8
AnonymousEndpointTestsclassMMCA.ADC.Architecture.TestsMMCA.ADC.Architecture.TestsMMCA.ADC.Architecture.Tests/AnonymousEndpointTests.cs:21
BrandColorTokenTests class MMCA.ADC.Architecture.Tests MMCA.ADC.Architecture.Tests/MicroserviceExtractionTests.cs:3
MiddlewarePipelineOrderTestsclassMMCA.ADC.Architecture.TestsMMCA.ADC.Architecture.TestsMMCA.ADC.Architecture.Tests/MiddlewarePipelineOrderTests.cs:15
ModuleIsolationTests class MMCA.ADC.Architecture.Tests MMCA.ADC.Architecture.Tests/RawQueryableConventionTests.cs:11
ServiceContractPurityTestsclassMMCA.ADC.Architecture.TestsMMCA.ADC.Architecture.TestsMMCA.ADC.Architecture.Tests/ServiceContractPurityTests.cs:9
SharedLayerTests class MMCA.ADC.Architecture.Tests MMCA.ADC.Conference.API/Authorization/CurrentUserServiceExtensions.cs:10
ActivitiesControllerclassMMCA.ADC.Conference.APIMMCA.ADC.Conference.API.ControllersMMCA.ADC.Conference.API/Controllers/ActivitiesController.cs:37
AddCategoryItemRequest record MMCA.ADC.Conference.API record MMCA.ADC.Conference.API MMCA.ADC.Conference.API.ControllersMMCA.ADC.Conference.API/Controllers/RoomsController.cs:25MMCA.ADC.Conference.API/Controllers/RoomsController.cs:30
AddSessionCategoryItemRequest class MMCA.ADC.Conference.API MMCA.ADC.Conference.API.ControllersMMCA.ADC.Conference.API/Controllers/RoomsController.cs:86MMCA.ADC.Conference.API/Controllers/RoomsController.cs:92
ServiceInfoController record MMCA.ADC.Conference.API MMCA.ADC.Conference.API.ControllersMMCA.ADC.Conference.API/Controllers/RoomsController.cs:53MMCA.ADC.Conference.API/Controllers/RoomsController.cs:58
UpdateSessionQuestionAnswerRequest MMCA.ADC.Conference.API.Tests/Authorization/ConferencePermissionGrantsTests.cs:14
ActivitiesControllerTestsclassMMCA.ADC.Conference.API.TestsMMCA.ADC.Conference.API.Tests.ControllersMMCA.ADC.Conference.API.Tests/Controllers/ActivitiesControllerTests.cs:26
CategoryItemsControllerTests class MMCA.ADC.Conference.API.Tests class MMCA.ADC.Conference.API.Tests MMCA.ADC.Conference.API.Tests.ControllersMMCA.ADC.Conference.API.Tests/Controllers/RoomsControllerTests.cs:19MMCA.ADC.Conference.API.Tests/Controllers/RoomsControllerTests.cs:26
SessionCategoryItemsControllerTests class MMCA.ADC.Conference.Application MMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application/DependencyInjection.cs:35MMCA.ADC.Conference.Application/DependencyInjection.cs:39
ActivityNavigationPopulatorclassMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.ActivitiesMMCA.ADC.Conference.Application/Activities/ActivityNavigationPopulator.cs:12
ActivityDTOMapperclassMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.Activities.DTOsMMCA.ADC.Conference.Application/Activities/DTOs/ActivityDTOMapper.cs:13
ActivityCreateRequestrecordMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.Activities.UseCases.CreateMMCA.ADC.Conference.Application/Activities/UseCases/Create/ActivityCreateRequest.cs:10
ActivityCreateRequestMapperclassMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.Activities.UseCases.CreateMMCA.ADC.Conference.Application/Activities/UseCases/Create/ActivityCreateRequestMapper.cs:11
ActivityCreateRequestValidatorclassMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.Activities.UseCases.CreateMMCA.ADC.Conference.Application/Activities/UseCases/Create/ActivityCreateRequestValidator.cs:7
CreateActivityHandlerclassMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.Activities.UseCases.CreateMMCA.ADC.Conference.Application/Activities/UseCases/Create/CreateActivityHandler.cs:16
GetPublicActivityFilterHandlerclassMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.Activities.UseCases.GetPublicActivityFilterMMCA.ADC.Conference.Application/Activities/UseCases/GetPublicActivityFilter/GetPublicActivityFilterHandler.cs:16
GetPublicActivityFilterQueryrecordMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.Activities.UseCases.GetPublicActivityFilterMMCA.ADC.Conference.Application/Activities/UseCases/GetPublicActivityFilter/GetPublicActivityFilterQuery.cs:13
ActivityUpdateRequestrecordMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.Activities.UseCases.UpdateMMCA.ADC.Conference.Application/Activities/UseCases/Update/ActivityUpdateRequest.cs:10
ActivityUpdateRequestValidatorclassMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.Activities.UseCases.UpdateMMCA.ADC.Conference.Application/Activities/UseCases/Update/ActivityUpdateRequestValidator.cs:7
UpdateActivityCommandrecordMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.Activities.UseCases.UpdateMMCA.ADC.Conference.Application/Activities/UseCases/Update/UpdateActivityCommand.cs:9
UpdateActivityHandlerclassMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.Activities.UseCases.UpdateMMCA.ADC.Conference.Application/Activities/UseCases/Update/UpdateActivityHandler.cs:15
ActivityDescriptionRules<T>classMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.Activities.ValidationMMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:25
ActivityEventIdRules<T>classMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.Activities.ValidationMMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:74
ActivityNameRules<T>classMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.Activities.ValidationMMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:13
ActivitySortOrderRules<T>classMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.Activities.ValidationMMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:111
ActivityTimeRangeRules<T>classMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.Activities.ValidationMMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:87
ActivityVenueAddressRules<T>classMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.Activities.ValidationMMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:49
ActivityVenueNameRules<T>classMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.Activities.ValidationMMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:37
ActivityVenueUrlRules<T>classMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.Activities.ValidationMMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:62
CategoryItemNavigationPopulatorclassMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.CategoriesMMCA.ADC.Conference.Application/Categories/CategoryItemNavigationPopulator.cs:11
ConferenceCategoryNavigationPopulator MMCA.ADC.Conference.Application/Events/EventNavigationPopulator.cs:11
EventQuestionAnswerNavigationPopulatorclassMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.EventsMMCA.ADC.Conference.Application/Events/EventQuestionAnswerNavigationPopulator.cs:11
EventSpeakerNavigationPopulatorclassMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.EventsMMCA.ADC.Conference.Application/Events/EventSpeakerNavigationPopulator.cs:11
RoomNavigationPopulatorclassMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.EventsMMCA.ADC.Conference.Application/Events/RoomNavigationPopulator.cs:11
RoomChangedHandler class MMCA.ADC.Conference.Application class MMCA.ADC.Conference.Application MMCA.ADC.Conference.Application.Events.UseCases.DeleteMMCA.ADC.Conference.Application/Events/UseCases/Delete/DeleteEventHandler.cs:17MMCA.ADC.Conference.Application/Events/UseCases/Delete/DeleteEventHandler.cs:18
GetPublicEventSpeakerFilterHandler MMCA.ADC.Conference.Application/Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterQuery.cs:10
GetPublicRoomFilterHandlerclassMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.Events.UseCases.GetPublicRoomFilterMMCA.ADC.Conference.Application/Events/UseCases/GetPublicRoomFilter/GetPublicRoomFilterHandler.cs:16
GetPublicRoomFilterQueryrecordMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.Events.UseCases.GetPublicRoomFilterMMCA.ADC.Conference.Application/Events/UseCases/GetPublicRoomFilter/GetPublicRoomFilterQuery.cs:14
PublishEventCommand record MMCA.ADC.Conference.Application class MMCA.ADC.Conference.Application MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionizeMMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RoomSyncStrategy.cs:10MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RoomSyncStrategy.cs:20
SessionizeSyncContext class MMCA.ADC.Conference.Application MMCA.ADC.Conference.Application.Events.ValidationMMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:91MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:109
EventNameRules<T> MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:75
EventTicketingUrlRules<T>classMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.Events.ValidationMMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:93
EventTimeZoneRules<T> class MMCA.ADC.Conference.Application MMCA.ADC.Conference.Application/Sessions/SessionBookmarkValidationService.cs:12
SessionCategoryItemNavigationPopulatorclassMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.SessionsMMCA.ADC.Conference.Application/Sessions/SessionCategoryItemNavigationPopulator.cs:11
SessionNavigationPopulator class MMCA.ADC.Conference.Application MMCA.ADC.Conference.Application.SessionsMMCA.ADC.Conference.Application/Sessions/SessionNavigationPopulator.cs:12MMCA.ADC.Conference.Application/Sessions/SessionNavigationPopulator.cs:13
SessionQuestionAnswerNavigationPopulatorclassMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.SessionsMMCA.ADC.Conference.Application/Sessions/SessionQuestionAnswerNavigationPopulator.cs:11
SessionSpeakerNavigationPopulatorclassMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.SessionsMMCA.ADC.Conference.Application/Sessions/SessionSpeakerNavigationPopulator.cs:11
SessionCreatedHandler MMCA.ADC.Conference.Application/Sessions/Validation/SessionValidationRules.cs:13
SpeakerCategoryItemNavigationPopulatorclassMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.SpeakersMMCA.ADC.Conference.Application/Speakers/SpeakerCategoryItemNavigationPopulator.cs:11
SpeakerEntityQueryService class MMCA.ADC.Conference.Application MMCA.ADC.Conference.Application/Speakers/SpeakerNavigationPopulator.cs:11
SpeakerQuestionAnswerNavigationPopulatorclassMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.SpeakersMMCA.ADC.Conference.Application/Speakers/SpeakerQuestionAnswerNavigationPopulator.cs:11
SpeakerDeletedHandler class MMCA.ADC.Conference.Application MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:22
SponsorNavigationPopulatorclassMMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application.SponsorsMMCA.ADC.Conference.Application/Sponsors/SponsorNavigationPopulator.cs:12
SponsorDTOMapper class MMCA.ADC.Conference.Application MMCA.ADC.Conference.Application/Users/IntegrationEventHandlers/UserRegisteredHandler.cs:40
ActivityNavigationPopulatorTestsclassMMCA.ADC.Conference.Application.TestsMMCA.ADC.Conference.Application.Tests.ActivitiesMMCA.ADC.Conference.Application.Tests/Activities/ActivityNavigationPopulatorTests.cs:9
ActivityDTOMapperTestsclassMMCA.ADC.Conference.Application.TestsMMCA.ADC.Conference.Application.Tests.Activities.DTOsMMCA.ADC.Conference.Application.Tests/Activities/DTOs/ActivityDTOMapperTests.cs:7
CreateActivityHandlerTestsclassMMCA.ADC.Conference.Application.TestsMMCA.ADC.Conference.Application.Tests.Activities.UseCasesMMCA.ADC.Conference.Application.Tests/Activities/UseCases/CreateActivityHandlerTests.cs:13
UpdateActivityHandlerTestsclassMMCA.ADC.Conference.Application.TestsMMCA.ADC.Conference.Application.Tests.Activities.UseCasesMMCA.ADC.Conference.Application.Tests/Activities/UseCases/UpdateActivityHandlerTests.cs:12
GetPublicActivityFilterHandlerTestsclassMMCA.ADC.Conference.Application.TestsMMCA.ADC.Conference.Application.Tests.Activities.UseCases.GetPublicActivityFilterMMCA.ADC.Conference.Application.Tests/Activities/UseCases/GetPublicActivityFilter/GetPublicActivityFilterHandlerTests.cs:18
ActivityCreateRequestValidatorTestsclassMMCA.ADC.Conference.Application.TestsMMCA.ADC.Conference.Application.Tests.Activities.ValidationMMCA.ADC.Conference.Application.Tests/Activities/Validation/ActivityCreateRequestValidatorTests.cs:7
ActivityUpdateRequestValidatorTestsclassMMCA.ADC.Conference.Application.TestsMMCA.ADC.Conference.Application.Tests.Activities.ValidationMMCA.ADC.Conference.Application.Tests/Activities/Validation/ActivityUpdateRequestValidatorTests.cs:7
CategoryItemNavigationPopulatorTestsclassMMCA.ADC.Conference.Application.TestsMMCA.ADC.Conference.Application.Tests.CategoriesMMCA.ADC.Conference.Application.Tests/Categories/CategoryItemNavigationPopulatorTests.cs:9
ConferenceCategoryNavigationPopulatorTests class MMCA.ADC.Conference.Application.Tests MMCA.ADC.Conference.Application.Tests/Events/EventNavigationPopulatorTests.cs:9
EventQuestionAnswerNavigationPopulatorTestsclassMMCA.ADC.Conference.Application.TestsMMCA.ADC.Conference.Application.Tests.EventsMMCA.ADC.Conference.Application.Tests/Events/EventQuestionAnswerNavigationPopulatorTests.cs:9
EventSpeakerNavigationPopulatorTestsclassMMCA.ADC.Conference.Application.TestsMMCA.ADC.Conference.Application.Tests.EventsMMCA.ADC.Conference.Application.Tests/Events/EventSpeakerNavigationPopulatorTests.cs:9
FixedTimeProvider class MMCA.ADC.Conference.Application.Tests MMCA.ADC.Conference.Application.Tests/Events/EventLiveValidationServiceTests.cs:388
RoomNavigationPopulatorTestsclassMMCA.ADC.Conference.Application.TestsMMCA.ADC.Conference.Application.Tests.EventsMMCA.ADC.Conference.Application.Tests/Events/RoomNavigationPopulatorTests.cs:9
RoomChangedHandlerTests class MMCA.ADC.Conference.Application.Tests class MMCA.ADC.Conference.Application.Tests MMCA.ADC.Conference.Application.Tests.Events.UseCasesMMCA.ADC.Conference.Application.Tests/Events/UseCases/DeleteEventHandlerTests.cs:17MMCA.ADC.Conference.Application.Tests/Events/UseCases/DeleteEventHandlerTests.cs:18
PublishEventHandlerTests class MMCA.ADC.Conference.Application.Tests MMCA.ADC.Conference.Application.Tests.Events.UseCases.GetPublicEventSpeakerFilterMMCA.ADC.Conference.Application.Tests/Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterHandlerTests.cs:18MMCA.ADC.Conference.Application.Tests/Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterHandlerTests.cs:19
AddEventQuestionAnswerCommandValidatorTests MMCA.ADC.Conference.Application.Tests/Sessions/SessionBookmarkValidationServiceTests.cs:12
SessionCategoryItemNavigationPopulatorTestsclassMMCA.ADC.Conference.Application.TestsMMCA.ADC.Conference.Application.Tests.SessionsMMCA.ADC.Conference.Application.Tests/Sessions/SessionCategoryItemNavigationPopulatorTests.cs:9
SessionNavigationPopulatorTests class MMCA.ADC.Conference.Application.Tests MMCA.ADC.Conference.Application.Tests/Sessions/SessionNavigationPopulatorTests.cs:9
SessionScoringQueueTestsSessionQuestionAnswerNavigationPopulatorTests class MMCA.ADC.Conference.Application.TestsMMCA.ADC.Conference.Application.Tests.Sessions.DecisionSupportMMCA.ADC.Conference.Application.Tests/Sessions/DecisionSupport/SessionScoringQueueTests.cs:11MMCA.ADC.Conference.Application.Tests.SessionsMMCA.ADC.Conference.Application.Tests/Sessions/SessionQuestionAnswerNavigationPopulatorTests.cs:9
SessionCreatedHandlerTestsSessionRoomFilterTests class MMCA.ADC.Conference.Application.TestsMMCA.ADC.Conference.Application.Tests.Sessions.DomainEventHandlersMMCA.ADC.Conference.Application.Tests/Sessions/DomainEventHandlers/SessionCreatedHandlerTests.cs:10MMCA.ADC.Conference.Application.Tests.SessionsMMCA.ADC.Conference.Application.Tests/Sessions/SessionRoomFilterTests.cs:15
SessionCategoryItemDTOMapperTestsSessionSpeakerNavigationPopulatorTests class MMCA.ADC.Conference.Application.TestsMMCA.ADC.Conference.Application.Tests.Sessions.DTOsMMCA.ADC.Conference.Application.Tests/Sessions/DTOs/SessionCategoryItemDTOMapperTests.cs:7MMCA.ADC.Conference.Application.Tests.SessionsMMCA.ADC.Conference.Application.Tests/Sessions/SessionSpeakerNavigationPopulatorTests.cs:9
SessionDTOMapperTestsSessionScoringQueueTestsclassMMCA.ADC.Conference.Application.TestsMMCA.ADC.Conference.Application.Tests.Sessions.DecisionSupportMMCA.ADC.Conference.Application.Tests/Sessions/DecisionSupport/SessionScoringQueueTests.cs:11
SessionCreatedHandlerTestsclassMMCA.ADC.Conference.Application.TestsMMCA.ADC.Conference.Application.Tests.Sessions.DomainEventHandlersMMCA.ADC.Conference.Application.Tests/Sessions/DomainEventHandlers/SessionCreatedHandlerTests.cs:10
SessionCategoryItemDTOMapperTestsclassMMCA.ADC.Conference.Application.TestsMMCA.ADC.Conference.Application.Tests.Sessions.DTOsMMCA.ADC.Conference.Application.Tests/Sessions/DTOs/SessionCategoryItemDTOMapperTests.cs:7
SessionDTOMapperTests class MMCA.ADC.Conference.Application.Tests MMCA.ADC.Conference.Application.Tests.Sessions.DTOs class MMCA.ADC.Conference.Application.Tests MMCA.ADC.Conference.Application.Tests.Sessions.UseCases.GetPublicSessionCategoryItemFilterMMCA.ADC.Conference.Application.Tests/Sessions/UseCases/GetPublicSessionCategoryItemFilter/GetPublicSessionCategoryItemFilterHandlerTests.cs:17MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/GetPublicSessionCategoryItemFilter/GetPublicSessionCategoryItemFilterHandlerTests.cs:18
GetPublicSessionFilterHandlerTests class MMCA.ADC.Conference.Application.Tests MMCA.ADC.Conference.Application.Tests.Sessions.UseCases.GetPublicSessionSpeakerFilterMMCA.ADC.Conference.Application.Tests/Sessions/UseCases/GetPublicSessionSpeakerFilter/GetPublicSessionSpeakerFilterHandlerTests.cs:17MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/GetPublicSessionSpeakerFilter/GetPublicSessionSpeakerFilterHandlerTests.cs:18
GetSessionsBySpeakerFilterHandlerTests MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionValidationRulesTests.cs:12
SpeakerCategoryItemNavigationPopulatorTestsclassMMCA.ADC.Conference.Application.TestsMMCA.ADC.Conference.Application.Tests.SpeakersMMCA.ADC.Conference.Application.Tests/Speakers/SpeakerCategoryItemNavigationPopulatorTests.cs:9
SpeakerEntityQueryServiceTests class MMCA.ADC.Conference.Application.Tests MMCA.ADC.Conference.Application.Tests/Speakers/SpeakerNavigationPopulatorTests.cs:9
SpeakerQuestionAnswerNavigationPopulatorTestsclassMMCA.ADC.Conference.Application.TestsMMCA.ADC.Conference.Application.Tests.SpeakersMMCA.ADC.Conference.Application.Tests/Speakers/SpeakerQuestionAnswerNavigationPopulatorTests.cs:9
Mocks record MMCA.ADC.Conference.Application.Tests class MMCA.ADC.Conference.Application.Tests MMCA.ADC.Conference.Application.Tests.Speakers.UseCases.GetPublicSpeakerCategoryItemFilterMMCA.ADC.Conference.Application.Tests/Speakers/UseCases/GetPublicSpeakerCategoryItemFilter/GetPublicSpeakerCategoryItemFilterHandlerTests.cs:18MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/GetPublicSpeakerCategoryItemFilter/GetPublicSpeakerCategoryItemFilterHandlerTests.cs:19
GetPublicSpeakerFilterHandlerTests class MMCA.ADC.Conference.Application.Tests MMCA.ADC.Conference.Application.Tests.Speakers.UseCases.GetPublicSpeakerFilterMMCA.ADC.Conference.Application.Tests/Speakers/UseCases/GetPublicSpeakerFilter/GetPublicSpeakerFilterHandlerTests.cs:21MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/GetPublicSpeakerFilter/GetPublicSpeakerFilterHandlerTests.cs:22
GetSpeakersByEventFilterHandlerTests MMCA.ADC.Conference.Application.Tests/Speakers/Validation/SpeakerValidationRulesTests.cs:12
SponsorNavigationPopulatorTestsclassMMCA.ADC.Conference.Application.TestsMMCA.ADC.Conference.Application.Tests.SponsorsMMCA.ADC.Conference.Application.Tests/Sponsors/SponsorNavigationPopulatorTests.cs:9
SponsorDTOMapperTests class MMCA.ADC.Conference.Application.Tests class MMCA.ADC.Conference.Application.Tests MMCA.ADC.Conference.Application.Tests.SyncMMCA.ADC.Conference.Application.Tests/Sync/RoomSyncStrategyTests.cs:10MMCA.ADC.Conference.Application.Tests/Sync/RoomSyncStrategyTests.cs:11
SessionSyncStrategyTests MMCA.ADC.Conference.Domain/AssemblyReference.cs:11
ActivityclassMMCA.ADC.Conference.DomainMMCA.ADC.Conference.Domain.ActivitiesMMCA.ADC.Conference.Domain/Activities/Activity.cs:20
ActivityInvariantsclassMMCA.ADC.Conference.DomainMMCA.ADC.Conference.Domain.ActivitiesMMCA.ADC.Conference.Domain/Activities/ActivityInvariants.cs:10
ActivityChangedrecordMMCA.ADC.Conference.DomainMMCA.ADC.Conference.Domain.Activities.DomainEventsMMCA.ADC.Conference.Domain/Activities/DomainEvents/ActivityChanged.cs:12
Category class MMCA.ADC.Conference.Domain class MMCA.ADC.Conference.Domain MMCA.ADC.Conference.Domain.ServicesMMCA.ADC.Conference.Domain/Services/EventCascadeDeletionDomainService.cs:14MMCA.ADC.Conference.Domain/Services/EventCascadeDeletionDomainService.cs:16
IEventCascadeDeletionDomainService interface MMCA.ADC.Conference.Domain MMCA.ADC.Conference.Domain.ServicesMMCA.ADC.Conference.Domain/Services/IEventCascadeDeletionDomainService.cs:13MMCA.ADC.Conference.Domain/Services/IEventCascadeDeletionDomainService.cs:15
Session MMCA.ADC.Conference.Domain/Sponsors/DomainEvents/SponsorChanged.cs:12
ActivityTestsclassMMCA.ADC.Conference.Domain.TestsMMCA.ADC.Conference.Domain.Tests.ActivitiesMMCA.ADC.Conference.Domain.Tests/Activities/ActivityTests.cs:10
ActivityBuilderclassMMCA.ADC.Conference.Domain.TestsMMCA.ADC.Conference.Domain.Tests.BuildersMMCA.ADC.Conference.Domain.Tests/Builders/ActivityBuilder.cs:10
EventBuilder class MMCA.ADC.Conference.Domain.Tests class MMCA.ADC.Conference.Domain.Tests MMCA.ADC.Conference.Domain.Tests.EventsMMCA.ADC.Conference.Domain.Tests/Events/EventTests.cs:9MMCA.ADC.Conference.Domain.Tests/Events/EventTests.cs:10
ActivityInvariantsTestsclassMMCA.ADC.Conference.Domain.TestsMMCA.ADC.Conference.Domain.Tests.InvariantsMMCA.ADC.Conference.Domain.Tests/Invariants/ActivityInvariantsTests.cs:6
CategoryInvariantsTests class MMCA.ADC.Conference.Infrastructure MMCA.ADC.Conference.Infrastructure.Persistence.DbContextsMMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:19MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:20
ConferenceModuleDbSeeder class MMCA.ADC.Conference.Infrastructure MMCA.ADC.Conference.Infrastructure.Persistence.DbContexts.SeedingMMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/Seeding/ConferenceModuleDbSeeder.cs:24MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/Seeding/ConferenceModuleDbSeeder.cs:25
ActivityConfigurationclassMMCA.ADC.Conference.InfrastructureMMCA.ADC.Conference.Infrastructure.Persistence.EntityConfigurationMMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/ActivityConfiguration.cs:11
CategoryItemConfiguration class MMCA.ADC.Conference.IntegrationTests MMCA.ADC.Conference.IntegrationTests.InfrastructureMMCA.ADC.Conference.IntegrationTests/Infrastructure/ConferenceTestWebApplicationFactory.cs:32MMCA.ADC.Conference.IntegrationTests/Infrastructure/ConferenceTestWebApplicationFactory.cs:33
FakeAiScoringService MMCA.ADC.Conference.Shared/ConferenceFeatures.cs:8
ActivityDTOrecordMMCA.ADC.Conference.SharedMMCA.ADC.Conference.Shared.ActivitiesMMCA.ADC.Conference.Shared/Activities/ActivityDTO.cs:10
ConferencePermissions class MMCA.ADC.Conference.Shared MMCA.ADC.Conference.UI/DependencyInjection.cs:11
InfiniteScrollSentinelclassMMCA.ADC.Conference.UIMMCA.ADC.Conference.UI.ComponentsMMCA.ADC.Conference.UI/Components/InfiniteScrollSentinel.razor.cs:21
ActivityCreateclassMMCA.ADC.Conference.UIMMCA.ADC.Conference.UI.Pages.ActivityMMCA.ADC.Conference.UI/Pages/Activity/ActivityCreate.razor.cs:16
ActivityDetailclassMMCA.ADC.Conference.UIMMCA.ADC.Conference.UI.Pages.ActivityMMCA.ADC.Conference.UI/Pages/Activity/ActivityDetail.razor.cs:16
ActivityListclassMMCA.ADC.Conference.UIMMCA.ADC.Conference.UI.Pages.ActivityMMCA.ADC.Conference.UI/Pages/Activity/ActivityList.razor.cs:19
ConferenceCategoryCreate class MMCA.ADC.Conference.UI record MMCA.ADC.Conference.UI MMCA.ADC.Conference.UI.Pages.HomeMMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:282MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:297
ADCEventInfo record MMCA.ADC.Conference.UI MMCA.ADC.Conference.UI.Pages.HomeMMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:284MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:299
ADCHome class MMCA.ADC.Conference.UI MMCA.ADC.Conference.UI.Pages.HomeMMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:17MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:18
ADCSponsorCollectionResult record MMCA.ADC.Conference.UI MMCA.ADC.Conference.UI.Pages.HomeMMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:295MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:311
ADCSponsorInfo record MMCA.ADC.Conference.UI MMCA.ADC.Conference.UI.Pages.HomeMMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:297MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:313
ConferenceTrackInfo record MMCA.ADC.Conference.UI MMCA.ADC.Conference.UI.Pages.HomeMMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:341MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:372
EventPhase enum MMCA.ADC.Conference.UI MMCA.ADC.Conference.UI.Pages.HomeMMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:57MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:72
KeynoteSpeakerInfo record MMCA.ADC.Conference.UI MMCA.ADC.Conference.UI.Pages.HomeMMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:340MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:371
PreConferenceWorkshopInforecordMMCA.ADC.Conference.UIMMCA.ADC.Conference.UI.Pages.HomeMMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:379
CachedSessionPage record MMCA.ADC.Conference.UI MMCA.ADC.Conference.UI.Pages.PublicMMCA.ADC.Conference.UI/Pages/Public/PublicSessionList.razor.cs:342MMCA.ADC.Conference.UI/Pages/Public/PublicSessionList.razor.cs:366
PublicActivityListclassMMCA.ADC.Conference.UIMMCA.ADC.Conference.UI.Pages.PublicMMCA.ADC.Conference.UI/Pages/Public/PublicActivityList.razor.cs:19
PublicEventDetail class MMCA.ADC.Conference.UI MMCA.ADC.Conference.UI.Pages.PublicMMCA.ADC.Conference.UI/Pages/Public/PublicEventDetail.razor.cs:14MMCA.ADC.Conference.UI/Pages/Public/PublicEventDetail.razor.cs:16
PublicEventList class MMCA.ADC.Conference.UI MMCA.ADC.Conference.UI.Pages.PublicMMCA.ADC.Conference.UI/Pages/Public/PublicEventList.razor.cs:17MMCA.ADC.Conference.UI/Pages/Public/PublicEventList.razor.cs:30
PublicScheduleRoomOptionsclassMMCA.ADC.Conference.UIMMCA.ADC.Conference.UI.Pages.PublicMMCA.ADC.Conference.UI/Pages/Public/PublicScheduleRoomOptions.cs:11
PublicSessionDetail class MMCA.ADC.Conference.UI MMCA.ADC.Conference.UI.Pages.PublicMMCA.ADC.Conference.UI/Pages/Public/PublicSessionListView.razor.cs:21MMCA.ADC.Conference.UI/Pages/Public/PublicSessionListView.razor.cs:23
PublicSpeakerDetail class MMCA.ADC.Conference.UI MMCA.ADC.Conference.UI.Pages.PublicMMCA.ADC.Conference.UI/Pages/Public/PublicSpeakerList.razor.cs:27MMCA.ADC.Conference.UI/Pages/Public/PublicSpeakerList.razor.cs:35
PublicSponsorList MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorList.razor.cs:19
ActivityServiceclassMMCA.ADC.Conference.UIMMCA.ADC.Conference.UI.ServicesMMCA.ADC.Conference.UI/Services/ActivityService.cs:10
CategoryItemInfo record MMCA.ADC.Conference.UI MMCA.ADC.Conference.UI/Services/ChildEntityServices.cs:14
IActivityUIServiceinterfaceMMCA.ADC.Conference.UIMMCA.ADC.Conference.UI.ServicesMMCA.ADC.Conference.UI/Services/IActivityUIService.cs:9
ICategoryItemLookupService interface MMCA.ADC.Conference.UI MMCA.ADC.Conference.UI.Tests/Components/SharePageButtonTests.cs:17
ActivityCreateTestsclassMMCA.ADC.Conference.UI.TestsMMCA.ADC.Conference.UI.Tests.Pages.ActivityMMCA.ADC.Conference.UI.Tests/Pages/Activity/ActivityCreateTests.cs:19
ActivityDetailTestsclassMMCA.ADC.Conference.UI.TestsMMCA.ADC.Conference.UI.Tests.Pages.ActivityMMCA.ADC.Conference.UI.Tests/Pages/Activity/ActivityDetailTests.cs:18
EventCreateTests class MMCA.ADC.Conference.UI.Tests MMCA.ADC.Conference.UI.Tests/Pages/Feedback/OrganizerSessionFeedbackTests.cs:19
ADCHomeTestsclassMMCA.ADC.Conference.UI.TestsMMCA.ADC.Conference.UI.Tests.Pages.HomeMMCA.ADC.Conference.UI.Tests/Pages/Home/ADCHomeTests.cs:20
ADCHomeTicketingTestsclassMMCA.ADC.Conference.UI.TestsMMCA.ADC.Conference.UI.Tests.Pages.HomeMMCA.ADC.Conference.UI.Tests/Pages/Home/ADCHomeTests.cs:124
PublicActivityListTestsclassMMCA.ADC.Conference.UI.TestsMMCA.ADC.Conference.UI.Tests.Pages.PublicMMCA.ADC.Conference.UI.Tests/Pages/Public/PublicActivityListTests.cs:16
PublicEventDetailTests class MMCA.ADC.Conference.UI.Tests MMCA.ADC.Conference.UI.Tests/Pages/Public/PublicEventDetailTests.cs:15
PublicEventListRedirectTestsclassMMCA.ADC.Conference.UI.TestsMMCA.ADC.Conference.UI.Tests.Pages.PublicMMCA.ADC.Conference.UI.Tests/Pages/Public/PublicEventListRedirectTests.cs:34
PublicSessionDetailBookmarkTests class MMCA.ADC.Conference.UI.Tests MMCA.ADC.Conference.UI.Tests/Pages/Public/PublicSessionListEventFilterTests.cs:22
PublicSessionListRoomFilterTestsclassMMCA.ADC.Conference.UI.TestsMMCA.ADC.Conference.UI.Tests.Pages.PublicMMCA.ADC.Conference.UI.Tests/Pages/Public/PublicSessionListRoomFilterTests.cs:20
PublicSessionListViewBookmarkTests class MMCA.ADC.Conference.UI.Tests MMCA.ADC.Conference.UI.Tests/Pages/Public/PublicSpeakerDetailTests.cs:13
PublicSpeakerListCardGridTestsclassMMCA.ADC.Conference.UI.TestsMMCA.ADC.Conference.UI.Tests.Pages.PublicMMCA.ADC.Conference.UI.Tests/Pages/Public/PublicSpeakerListCardGridTests.cs:24
PublicSpeakerListEventFilterTests class MMCA.ADC.Conference.UI.Tests class MMCA.ADC.CrossService.IntegrationTests MMCA.ADC.CrossService.IntegrationTests.InfrastructureMMCA.ADC.CrossService.IntegrationTests/Infrastructure/ConferenceCrossServiceFactory.cs:28MMCA.ADC.CrossService.IntegrationTests/Infrastructure/ConferenceCrossServiceFactory.cs:29
CrossServiceCollection class MMCA.ADC.CrossService.IntegrationTests MMCA.ADC.CrossService.IntegrationTests.InfrastructureMMCA.ADC.CrossService.IntegrationTests/Infrastructure/EngagementCrossServiceFactory.cs:32MMCA.ADC.CrossService.IntegrationTests/Infrastructure/EngagementCrossServiceFactory.cs:33
IdentityCrossServiceFactory class MMCA.ADC.E2E.Tests MMCA.ADC.E2E.Tests.PageObjectsMMCA.ADC.E2E.Tests/PageObjects/PublicSpeakerListPage.cs:3MMCA.ADC.E2E.Tests/PageObjects/PublicSpeakerListPage.cs:10
PublicSponsorListPage record MMCA.ADC.E2E.Tests MMCA.ADC.E2E.Tests.Workflows.ConferenceMMCA.ADC.E2E.Tests/Workflows/Conference/PublicBrowseTests.cs:450MMCA.ADC.E2E.Tests/Workflows/Conference/PublicBrowseTests.cs:462
OrganizerCategoryManagementTests MMCA.ADC.E2E.Tests/Workflows/Identity/LogoutTests.cs:5
PasswordResetTestsclassMMCA.ADC.E2E.TestsMMCA.ADC.E2E.Tests.Workflows.IdentityMMCA.ADC.E2E.Tests/Workflows/Identity/PasswordResetTests.cs:5
ProfileManagementTests class MMCA.ADC.E2E.Tests MMCA.ADC.Engagement.Application/LivePolls/Services/LivePollNavigationPopulator.cs:11
LivePollOptionNavigationPopulatorclassMMCA.ADC.Engagement.ApplicationMMCA.ADC.Engagement.Application.LivePolls.ServicesMMCA.ADC.Engagement.Application/LivePolls/Services/LivePollOptionNavigationPopulator.cs:11
LivePollResultsBuilder class MMCA.ADC.Engagement.Application class MMCA.ADC.Engagement.Application MMCA.ADC.Engagement.Application.Points.DomainEventHandlersMMCA.ADC.Engagement.Application/Points/DomainEventHandlers/SessionQuestionSubmittedPointsHandler.cs:39MMCA.ADC.Engagement.Application/Points/DomainEventHandlers/SessionQuestionSubmittedPointsHandler.cs:51
AttendeeCheckedInPointsHandler MMCA.ADC.Engagement.Application.Tests/LivePolls/DTOs/LivePollDTOMapperTests.cs:9
LivePollOptionNavigationPopulatorTestsclassMMCA.ADC.Engagement.Application.TestsMMCA.ADC.Engagement.Application.Tests.LivePolls.ServicesMMCA.ADC.Engagement.Application.Tests/LivePolls/Services/LivePollOptionNavigationPopulatorTests.cs:9
LivePollResultsBuilderTests class MMCA.ADC.Engagement.Application.Tests class MMCA.ADC.Engagement.Application.Tests MMCA.ADC.Engagement.Application.Tests.Points.DomainEventHandlersMMCA.ADC.Engagement.Application.Tests/Points/DomainEventHandlers/SessionQuestionSubmittedPointsHandlerTests.cs:18MMCA.ADC.Engagement.Application.Tests/Points/DomainEventHandlers/SessionQuestionSubmittedPointsHandlerTests.cs:29
ThrowingPointsAwarderclassMMCA.ADC.Engagement.Application.TestsMMCA.ADC.Engagement.Application.Tests.Points.DomainEventHandlersMMCA.ADC.Engagement.Application.Tests/Points/DomainEventHandlers/SessionQuestionSubmittedPointsHandlerTests.cs:236
AttendeeCheckedInPointsHandlerTests record MMCA.ADC.Engagement.Domain MMCA.ADC.Engagement.Domain.LivePolls.DomainEventsMMCA.ADC.Engagement.Domain/LivePolls/DomainEvents/LivePollVoteChanged.cs:15MMCA.ADC.Engagement.Domain/LivePolls/DomainEvents/LivePollVoteChanged.cs:21
LeaderboardOptIn record MMCA.ADC.Engagement.Domain MMCA.ADC.Engagement.Domain.SessionQuestions.DomainEventsMMCA.ADC.Engagement.Domain/SessionQuestions/DomainEvents/SessionQuestionChanged.cs:17MMCA.ADC.Engagement.Domain/SessionQuestions/DomainEvents/SessionQuestionChanged.cs:30
SessionQuestionUpvoteChanged record MMCA.ADC.Engagement.Domain MMCA.ADC.Engagement.Domain.SessionQuestions.DomainEventsMMCA.ADC.Engagement.Domain/SessionQuestions/DomainEvents/SessionQuestionUpvoteChanged.cs:14MMCA.ADC.Engagement.Domain/SessionQuestions/DomainEvents/SessionQuestionUpvoteChanged.cs:20
UserSessionBookmark record MMCA.ADC.Engagement.Domain MMCA.ADC.Engagement.Domain.UserSessionBookmarks.DomainEventsMMCA.ADC.Engagement.Domain/UserSessionBookmarks/DomainEvents/UserSessionBookmarkChanged.cs:15MMCA.ADC.Engagement.Domain/UserSessionBookmarks/DomainEvents/UserSessionBookmarkChanged.cs:21
AttendeeBadgeTests class MMCA.ADC.Engagement.IntegrationTests MMCA.ADC.Engagement.IntegrationTests.InfrastructureMMCA.ADC.Engagement.IntegrationTests/Infrastructure/EngagementTestWebApplicationFactory.cs:39MMCA.ADC.Engagement.IntegrationTests/Infrastructure/EngagementTestWebApplicationFactory.cs:40
FakeEventLiveValidationService record MMCA.ADC.Engagement.IntegrationTests MMCA.ADC.Engagement.IntegrationTests.PointsMMCA.ADC.Engagement.IntegrationTests/Points/PointsAwardRoundTripTests.cs:278MMCA.ADC.Engagement.IntegrationTests/Points/PointsAwardRoundTripTests.cs:332
PointsAwardRoundTripTests class MMCA.ADC.Engagement.IntegrationTests MMCA.ADC.Engagement.IntegrationTests.PointsMMCA.ADC.Engagement.IntegrationTests/Points/PointsAwardRoundTripTests.cs:32MMCA.ADC.Engagement.IntegrationTests/Points/PointsAwardRoundTripTests.cs:41
PointsEndpointTests record MMCA.ADC.Gateway.Tests MMCA.ADC.Gateway.TestsMMCA.ADC.Gateway.Tests/RouteMapTests.cs:257MMCA.ADC.Gateway.Tests/RouteMapTests.cs:258
GatewayApplicationFactory class MMCA.ADC.Gateway.Tests MMCA.ADC.Gateway.TestsMMCA.ADC.Gateway.Tests/RouteMapTests.cs:268MMCA.ADC.Gateway.Tests/RouteMapTests.cs:269
RouteMapTests MMCA.ADC.Identity.API/Controllers/OAuthController.cs:20
PasswordResetControllerclassMMCA.ADC.Identity.APIMMCA.ADC.Identity.API.ControllersMMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:28
UserClaimsController class MMCA.ADC.Identity.API MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/NotificationUserDataExportSection.cs:18
ForgotPasswordCommandrecordMMCA.ADC.Identity.ApplicationMMCA.ADC.Identity.Application.Users.UseCases.ForgotPasswordMMCA.ADC.Identity.Application/Users/UseCases/ForgotPassword/ForgotPasswordCommand.cs:12
ForgotPasswordHandlerclassMMCA.ADC.Identity.ApplicationMMCA.ADC.Identity.Application.Users.UseCases.ForgotPasswordMMCA.ADC.Identity.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandler.cs:20
GetUserPreferencesHandler class MMCA.ADC.Identity.Application MMCA.ADC.Identity.Application/Users/UseCases/RemoveUserAvatar/RemoveUserAvatarHandler.cs:14
ResetPasswordCommandrecordMMCA.ADC.Identity.ApplicationMMCA.ADC.Identity.Application.Users.UseCases.ResetPasswordMMCA.ADC.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordCommand.cs:14
ResetPasswordHandlerclassMMCA.ADC.Identity.ApplicationMMCA.ADC.Identity.Application.Users.UseCases.ResetPasswordMMCA.ADC.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordHandler.cs:18
SetUserAvatarCommand record MMCA.ADC.Identity.Application MMCA.ADC.Identity.Application.Tests/Users/UseCases/DeleteUserHandlerTests.cs:328
ForgotPasswordHandlerTestsclassMMCA.ADC.Identity.Application.TestsMMCA.ADC.Identity.Application.Tests.Users.UseCasesMMCA.ADC.Identity.Application.Tests/Users/UseCases/ForgotPasswordHandlerTests.cs:22
GetUserPreferencesHandlerTests class MMCA.ADC.Identity.Application.Tests MMCA.ADC.Identity.Application.Tests/Users/UseCases/NotificationUserDataExportSectionTests.cs:9
ResetPasswordHandlerTestsclassMMCA.ADC.Identity.Application.TestsMMCA.ADC.Identity.Application.Tests.Users.UseCasesMMCA.ADC.Identity.Application.Tests/Users/UseCases/ResetPasswordHandlerTests.cs:19
SetUserAvatarHandlerTests class MMCA.ADC.Identity.Application.Tests MMCA.ADC.Identity.IntegrationTests/Auth/OAuthExchangeTests.cs:18
PasswordResetFlowTestsclassMMCA.ADC.Identity.IntegrationTestsMMCA.ADC.Identity.IntegrationTests.AuthMMCA.ADC.Identity.IntegrationTests/Auth/PasswordResetFlowTests.cs:18
ErasureAndPiiLoggingTests class MMCA.ADC.Identity.IntegrationTests class MMCA.ADC.Notification.IntegrationTests MMCA.ADC.Notification.IntegrationTests.InfrastructureMMCA.ADC.Notification.IntegrationTests/Infrastructure/NotificationTestWebApplicationFactory.cs:33MMCA.ADC.Notification.IntegrationTests/Infrastructure/NotificationTestWebApplicationFactory.cs:34
NotificationControllerTests MMCA.Common.API/Controllers/OAuthControllerBase.cs:33
PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>classMMCA.Common.APIMMCA.Common.API.ControllersMMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:43
ServiceInfoControllerBase class MMCA.Common.API MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:21
InsecureJwtMetadataWarningStartupFilterclassMMCA.Common.APIMMCA.Common.API.StartupMMCA.Common.API/Startup/InsecureJwtMetadataWarningStartupFilter.cs:15
JwksEndpointExtensions class MMCA.Common.API MMCA.Common.API/Startup/JwksEndpointExtensions.cs:15
MiddlewarePipelineBuilderclassMMCA.Common.APIMMCA.Common.API.StartupMMCA.Common.API/Startup/MiddlewarePipelineBuilder.cs:15
MiddlewarePipelineSteprecordMMCA.Common.APIMMCA.Common.API.StartupMMCA.Common.API/Startup/MiddlewarePipelineStep.cs:21
MiddlewarePipelineStepNamesclassMMCA.Common.APIMMCA.Common.API.StartupMMCA.Common.API/Startup/MiddlewarePipelineStepNames.cs:14
MiniProfilerExtensions class MMCA.Common.API class MMCA.Common.API MMCA.Common.API.StartupMMCA.Common.API/Startup/OpenApiEndpointExtensions.cs:18MMCA.Common.API/Startup/OpenApiEndpointExtensions.cs:22
SignalRExtensions class MMCA.Common.API MMCA.Common.API.StartupMMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:29MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:31
WebApplicationExtensions class MMCA.Common.API MMCA.Common.API.StartupMMCA.Common.API/Startup/WebApplicationExtensions.cs:16MMCA.Common.API/Startup/WebApplicationExtensions.cs:14
FakeCategoriesController MMCA.Common.API.Tests/Controllers/AuthControllerBaseRateLimitTests.cs:87
PasswordResetAuthControllerBaseTestsclassMMCA.Common.API.TestsMMCA.Common.API.Tests.ControllersMMCA.Common.API.Tests/Controllers/PasswordResetAuthControllerBaseTests.cs:23
PlainDTO record MMCA.Common.API.Tests MMCA.Common.API.Tests/Controllers/EntityControllerBaseTests.cs:332
TestForgotPasswordCommandrecordMMCA.Common.API.TestsMMCA.Common.API.Tests.ControllersMMCA.Common.API.Tests/Controllers/PasswordResetAuthControllerBaseTests.cs:155
TestOAuthController class MMCA.Common.API.Tests MMCA.Common.API.Tests/Controllers/OAuthControllerBaseTests.cs:638
TestPasswordResetControllerclassMMCA.Common.API.TestsMMCA.Common.API.Tests.ControllersMMCA.Common.API.Tests/Controllers/PasswordResetAuthControllerBaseTests.cs:165
TestResetPasswordCommandrecordMMCA.Common.API.TestsMMCA.Common.API.Tests.ControllersMMCA.Common.API.Tests/Controllers/PasswordResetAuthControllerBaseTests.cs:162
TestUserAccountAuthController class MMCA.Common.API.Tests class MMCA.Common.API.Tests MMCA.Common.API.Tests.OpenApiMMCA.Common.API.Tests/OpenApi/ApiParameterDescriptorBackfillProviderTests.cs:36MMCA.Common.API.Tests/OpenApi/ApiParameterDescriptorBackfillProviderTests.cs:31
ProbeControllerFeatureProviderOpenApiBaselineTests class MMCA.Common.API.Tests MMCA.Common.API.Tests.OpenApiMMCA.Common.API.Tests/OpenApi/ApiParameterDescriptorBackfillProviderTests.cs:182MMCA.Common.API.Tests/OpenApi/OpenApiBaselineTests.cs:35
SegmentVersionedProbeControllerOpenApiProbeHost class MMCA.Common.API.Tests MMCA.Common.API.Tests.OpenApiMMCA.Common.API.Tests/OpenApi/ApiParameterDescriptorBackfillProviderTests.cs:200MMCA.Common.API.Tests/OpenApi/OpenApiProbeHost.cs:20
UnboundRouteTokenProbeControllerProbeControllerFeatureProvider class MMCA.Common.API.Tests MMCA.Common.API.Tests.OpenApiMMCA.Common.API.Tests/OpenApi/ApiParameterDescriptorBackfillProviderTests.cs:215MMCA.Common.API.Tests/OpenApi/OpenApiProbeHost.cs:65
RateLimitingSettingsTestsProblemDetailsProbeController class MMCA.Common.API.TestsMMCA.Common.API.Tests.RateLimitingMMCA.Common.API.Tests.OpenApiMMCA.Common.API.Tests/OpenApi/OpenApiBaselineTests.cs:172
SegmentVersionedProbeControllerclassMMCA.Common.API.TestsMMCA.Common.API.Tests.OpenApiMMCA.Common.API.Tests/OpenApi/ApiParameterDescriptorBackfillProviderTests.cs:157
UnboundRouteTokenProbeControllerclassMMCA.Common.API.TestsMMCA.Common.API.Tests.OpenApiMMCA.Common.API.Tests/OpenApi/ApiParameterDescriptorBackfillProviderTests.cs:172
RateLimitingSettingsTestsclassMMCA.Common.API.TestsMMCA.Common.API.Tests.RateLimiting MMCA.Common.API.Tests/RateLimiting/RateLimitingSettingsTests.cs:13
MMCA.Common.API.Tests/Startup/DatabaseInitializationExtensionsTests.cs:94
ForwardedJwtBearerSecurityTestsclassMMCA.Common.API.TestsMMCA.Common.API.Tests.StartupMMCA.Common.API.Tests/Startup/ForwardedJwtBearerSecurityTests.cs:22
InitTestWidget class MMCA.Common.API.Tests MMCA.Common.API.Tests/Startup/JwksEndpointTests.cs:26
MiddlewarePipelineBuilderTestsclassMMCA.Common.API.TestsMMCA.Common.API.Tests.StartupMMCA.Common.API.Tests/Startup/MiddlewarePipelineBuilderTests.cs:12
OidcDiscoveryEndpointTests class MMCA.Common.API.Tests MMCA.Common.API.Tests/Startup/RateLimitPartitionTests.cs:16
StubHostEnvironmentclassMMCA.Common.API.TestsMMCA.Common.API.Tests.StartupMMCA.Common.API.Tests/Startup/ForwardedJwtBearerSecurityTests.cs:146
WebApplicationBuilderExtensionsTests class MMCA.Common.API.Tests class MMCA.Common.Application MMCA.Common.ApplicationMMCA.Common.Application/DependencyInjection.cs:21MMCA.Common.Application/DependencyInjection.cs:22
AuditTrailEntryDTO MMCA.Common.Application/Auth/ILoginProtectionService.cs:10
IPasswordResetTokenServiceinterfaceMMCA.Common.ApplicationMMCA.Common.Application.AuthMMCA.Common.Application/Auth/IPasswordResetTokenService.cs:10
PasswordResetSettingsclassMMCA.Common.ApplicationMMCA.Common.Application.AuthMMCA.Common.Application/Auth/PasswordResetSettings.cs:10
SoftDeletedUserCache class MMCA.Common.Application MMCA.Common.Application/Auth/SoftDeletedUserCache.cs:17
ForgotPasswordRequestValidatorclassMMCA.Common.ApplicationMMCA.Common.Application.Auth.ValidationMMCA.Common.Application/Auth/Validation/ForgotPasswordRequestValidator.cs:11
LoginRequestValidator class MMCA.Common.Application MMCA.Common.Application/Auth/Validation/RefreshTokenRequestValidator.cs:10
ResetPasswordRequestValidatorclassMMCA.Common.ApplicationMMCA.Common.Application.Auth.ValidationMMCA.Common.Application/Auth/Validation/ResetPasswordRequestValidator.cs:12
SafeDomainEventHandler<TDomainEvent> class MMCA.Common.Application MMCA.Common.Application/Interfaces/IEventBus.cs:11
IEventUpcasterinterfaceMMCA.Common.ApplicationMMCA.Common.Application.InterfacesMMCA.Common.Application/Interfaces/IEventUpcaster.cs:28
IEventUpcaster<in TSource, out TTarget>interfaceMMCA.Common.ApplicationMMCA.Common.Application.InterfacesMMCA.Common.Application/Interfaces/IEventUpcaster.cs:67
IEventUpcasterRegistryinterfaceMMCA.Common.ApplicationMMCA.Common.Application.InterfacesMMCA.Common.Application/Interfaces/IEventUpcasterRegistry.cs:24
IIntegrationEventHandler<in TIntegrationEvent> interface MMCA.Common.Application class MMCA.Common.Application MMCA.Common.Application.ServicesMMCA.Common.Application/Services/DomainEventDispatcher.cs:16MMCA.Common.Application/Services/DomainEventDispatcher.cs:23
EntityQueryService<TEntity, TEntityDTO, TIdentifierType> MMCA.Common.Application/Services/EntityQueryService.cs:31
EventUpcasterRegistryclassMMCA.Common.ApplicationMMCA.Common.Application.ServicesMMCA.Common.Application/Services/EventUpcasterRegistry.cs:30
NavigationLoader class MMCA.Common.Application MMCA.Common.Application/Users/UseCases/ExportUserData/IUserDataExportSection.cs:47
ForgotPasswordHandlerBase<TUser, TCommand>classMMCA.Common.ApplicationMMCA.Common.Application.Users.UseCases.ForgotPasswordMMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:35
GetUserPreferencesHandlerBase<TUser> class MMCA.Common.Application MMCA.Common.Application/Users/UseCases/GetPreferences/GetUserPreferencesQuery.cs:5
ResetPasswordHandlerBase<TUser, TCommand>classMMCA.Common.ApplicationMMCA.Common.Application.Users.UseCases.ResetPasswordMMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:30
AddressLine1Rules<T> class MMCA.Common.Application class MMCA.Common.Application.Tests MMCA.Common.Application.TestsMMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:10MMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:11
DomainEventDispatcherTests record MMCA.Common.Application.Tests MMCA.Common.Application.TestsMMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:75MMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:76
MultiHandlerEventHandler1 class MMCA.Common.Application.Tests MMCA.Common.Application.TestsMMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:77MMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:78
MultiHandlerEventHandler2 class MMCA.Common.Application.Tests MMCA.Common.Application.TestsMMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:88MMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:89
NavigationMetadataTests MMCA.Common.Application.Tests/NullNotificationRecipientProviderTests.cs:9
RecordingDomainHandlerForRetiredclassMMCA.Common.Application.TestsMMCA.Common.Application.TestsMMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:145
RecordingIntegrationHandler<TEvent>classMMCA.Common.Application.TestsMMCA.Common.Application.TestsMMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:133
RetiredEventrecordMMCA.Common.Application.TestsMMCA.Common.Application.TestsMMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:121
RetiredToSuccessorUpcasterclassMMCA.Common.Application.TestsMMCA.Common.Application.TestsMMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:128
SuccessorEventrecordMMCA.Common.Application.TestsMMCA.Common.Application.TestsMMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:123
TestDomainEventHandlerForIntegration class MMCA.Common.Application.Tests MMCA.Common.Application.TestsMMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:26MMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:27
TestEvent record MMCA.Common.Application.Tests MMCA.Common.Application.TestsMMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:13MMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:14
TestIntegrationEvent class MMCA.Common.Application.Tests MMCA.Common.Application.TestsMMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:15MMCA.Common.Application.Tests/DomainEventDispatcherAdditionalTests.cs:16
TestIntegrationEventHandler MMCA.Common.Application.Tests/Auth/AuthenticationServiceBaseTests.cs:598
ForgotPasswordRequestValidatorTestsclassMMCA.Common.Application.TestsMMCA.Common.Application.Tests.Auth.ValidationMMCA.Common.Application.Tests/Auth/Validation/ForgotPasswordRequestValidatorTests.cs:7
LoginRequestValidatorTests class MMCA.Common.Application.Tests MMCA.Common.Application.Tests/Auth/Validation/RefreshTokenRequestValidatorTests.cs:7
ResetPasswordRequestValidatorTestsclassMMCA.Common.Application.TestsMMCA.Common.Application.Tests.Auth.ValidationMMCA.Common.Application.Tests/Auth/Validation/ResetPasswordRequestValidatorTests.cs:7
AuthorizationCommandDecoratorTests class MMCA.Common.Application.Tests MMCA.Common.Application.Tests/Services/ChildNavigationDescriptorTests.cs:9
CustomerRenamedV1recordMMCA.Common.Application.TestsMMCA.Common.Application.Tests.ServicesMMCA.Common.Application.Tests/Services/EventUpcasterRegistryTests.cs:23
CustomerRenamedV2recordMMCA.Common.Application.TestsMMCA.Common.Application.Tests.ServicesMMCA.Common.Application.Tests/Services/EventUpcasterRegistryTests.cs:25
CustomerRenamedV3recordMMCA.Common.Application.TestsMMCA.Common.Application.Tests.ServicesMMCA.Common.Application.Tests/Services/EventUpcasterRegistryTests.cs:30
DeclarativeNavigationPopulatorTests class MMCA.Common.Application.Tests MMCA.Common.Application.Tests/Services/EntityQueryServiceTests.cs:13
EnvelopeCopyingV1ToV2UpcasterclassMMCA.Common.Application.TestsMMCA.Common.Application.Tests.ServicesMMCA.Common.Application.Tests/Services/EventUpcasterRegistryTests.cs:54
EventUpcasterRegistryTestsclassMMCA.Common.Application.TestsMMCA.Common.Application.Tests.ServicesMMCA.Common.Application.Tests/Services/EventUpcasterRegistryTests.cs:19
FakeEntity class MMCA.Common.Application.Tests MMCA.Common.Application.Tests/Services/EntityQueryServiceResolutionTests.cs:94
RivalV1ToV3UpcasterclassMMCA.Common.Application.TestsMMCA.Common.Application.Tests.ServicesMMCA.Common.Application.Tests/Services/EventUpcasterRegistryTests.cs:65
SelfMappingUpcasterclassMMCA.Common.Application.TestsMMCA.Common.Application.Tests.ServicesMMCA.Common.Application.Tests/Services/EventUpcasterRegistryTests.cs:79
SortTestEntity class MMCA.Common.Application.Tests MMCA.Common.Application.Tests/Services/EntityQueryServiceProjectionTests.cs:56
UnrelatedEventrecordMMCA.Common.Application.TestsMMCA.Common.Application.Tests.ServicesMMCA.Common.Application.Tests/Services/EventUpcasterRegistryTests.cs:35
UnsupportedChild class MMCA.Common.Application.Tests MMCA.Common.Application.Tests/Services/NavigationMetadataProviderTests.cs:20
V1ToV2UpcasterclassMMCA.Common.Application.TestsMMCA.Common.Application.Tests.ServicesMMCA.Common.Application.Tests/Services/EventUpcasterRegistryTests.cs:38
V2ToV1UpcasterclassMMCA.Common.Application.TestsMMCA.Common.Application.Tests.ServicesMMCA.Common.Application.Tests/Services/EventUpcasterRegistryTests.cs:72
V2ToV3UpcasterclassMMCA.Common.Application.TestsMMCA.Common.Application.Tests.ServicesMMCA.Common.Application.Tests/Services/EventUpcasterRegistryTests.cs:44
BoolFilterStrategyTests class MMCA.Common.Application.Tests MMCA.Common.Application.Tests/Users/ExportUserDataHandlerBaseTests.cs:17
ForgotPasswordHandlerBaseTestsclassMMCA.Common.Application.TestsMMCA.Common.Application.Tests.UsersMMCA.Common.Application.Tests/Users/ForgotPasswordHandlerBaseTests.cs:20
GetUserPreferencesHandlerBaseTests class MMCA.Common.Application.Tests
HandlerMocksclassMMCA.Common.Application.TestsMMCA.Common.Application.Tests.UsersMMCA.Common.Application.Tests/Users/ForgotPasswordHandlerBaseTests.cs:136
HandlerMocks record MMCA.Common.Application.Tests MMCA.Common.Application.Tests.Users MMCA.Common.Application.Tests/Users/GetUserPreferencesHandlerBaseTests.cs:70
HandlerMocksrecordMMCA.Common.Application.TestsMMCA.Common.Application.Tests.UsersMMCA.Common.Application.Tests/Users/ResetPasswordHandlerBaseTests.cs:137
RecordingSection class MMCA.Common.Application.Tests MMCA.Common.Application.Tests/Users/ExportUserDataHandlerBaseTests.cs:289
ResetPasswordHandlerBaseTestsclassMMCA.Common.Application.TestsMMCA.Common.Application.Tests.UsersMMCA.Common.Application.Tests/Users/ResetPasswordHandlerBaseTests.cs:18
SoftDeletedUserValidatorTests class MMCA.Common.Application.Tests MMCA.Common.Application.Tests/Users/UserUseCaseTestDoubles.cs:123
TestForgotPasswordCommandrecordMMCA.Common.Application.TestsMMCA.Common.Application.Tests.UsersMMCA.Common.Application.Tests/Users/ForgotPasswordHandlerBaseTests.cs:186
TestForgotPasswordHandlerclassMMCA.Common.Application.TestsMMCA.Common.Application.Tests.UsersMMCA.Common.Application.Tests/Users/ForgotPasswordHandlerBaseTests.cs:190
TestGetUserPreferencesHandler class MMCA.Common.Application.Tests MMCA.Common.Application.Tests/Users/UserUseCaseTestDoubles.cs:13
TestResetPasswordCommandrecordMMCA.Common.Application.TestsMMCA.Common.Application.Tests.UsersMMCA.Common.Application.Tests/Users/ResetPasswordHandlerBaseTests.cs:172
TestResetPasswordHandlerclassMMCA.Common.Application.TestsMMCA.Common.Application.Tests.UsersMMCA.Common.Application.Tests/Users/ResetPasswordHandlerBaseTests.cs:176
ThrowingSection class MMCA.Common.Application.Tests MMCA.Common.Application.Tests/Validation/CommonValidationRulesTests.cs:320
AbstractAnonymousFixtureControllerBaseclassMMCA.Common.Architecture.TestsMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:84
AbstractFitnessControllerBase class MMCA.Common.Architecture.Tests MMCA.Common.Architecture.Tests/AggregateConventionTests.cs:9
AnonymousEndpointTestsclassMMCA.Common.Architecture.TestsMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests/AnonymousEndpointTests.cs:14
AnonymousEndpointTestsBaseTestsclassMMCA.Common.Architecture.TestsMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:13
AnonymousFixtureControllerclassMMCA.Common.Architecture.TestsMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:74
CancellationTestMap class MMCA.Common.Architecture.Tests MMCA.Common.Architecture.Tests/CommonArchitectureMap.cs:15
ConformantTestsclassMMCA.Common.Architecture.TestsMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:126
CycleTestMap class MMCA.Common.Architecture.Tests class MMCA.Common.Architecture.Tests MMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:100
DriftedTestsclassMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests MMCA.Common.Architecture.Tests/ModuleConformanceTestsBaseTests.cs:131
EmptyScanTestsclassMMCA.Common.Architecture.TestsMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:117
EventScopeFitnessTests class MMCA.Common.Architecture.Tests MMCA.Common.Architecture.Tests/EventScopeFitnessTests.cs:13
EventUpcasterFitnessTestsclassMMCA.Common.Architecture.TestsMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests/EventUpcasterFitnessTests.cs:12
EventVersioningConventionTests class MMCA.Common.Architecture.Tests MMCA.Common.Architecture.Tests/IdempotencyFitnessTests.cs:81
InheritingFixtureControllerclassMMCA.Common.Architecture.TestsMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:94
LayerDependencyTests class MMCA.Common.Architecture.Tests MMCA.Common.Architecture.Tests/ObservabilityConventionTestsBaseTests.cs:14
PasswordHashingFitnessTestsclassMMCA.Common.Architecture.TestsMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests/PasswordHashingFitnessTests.cs:15
PiiConventionTests class MMCA.Common.Architecture.Tests MMCA.Common.Architecture.Tests/SpecificationFitnessTests.cs:69
ServiceContractPurityTestsclassMMCA.Common.Architecture.TestsMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests/ServiceContractPurityTests.cs:11
SliceCohesionTests class MMCA.Common.Architecture.Tests MMCA.Common.Architecture.Tests/SpecificationFitnessTests.cs:40
StaleAllowListTestsclassMMCA.Common.Architecture.TestsMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:108
StateManagementConventionTests class MMCA.Common.Architecture.Tests MMCA.Common.Architecture.Tests/StateManagementConventionTests.cs:11
TypeLevelAnonymousFixtureControllerclassMMCA.Common.Architecture.TestsMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:98
UIArchitectureConventionTests class MMCA.Common.Architecture.Tests UndeclaredFitnessController class MMCA.Common.Architecture.TestsMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests/IdempotencyFitnessTests.cs:94MMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests/IdempotencyFitnessTests.cs:94
UpcasterTestMapclassMMCA.Common.Architecture.TestsMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests/EventUpcasterFitnessTests.cs:74
CompliantFixtureServiceclassMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests.CancellationFixturesMMCA.Common.Architecture.Tests/CancellationFixtures/CancellationTokenFixtures.cs:6
ExemptableFixtureServiceclassMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests.CancellationFixturesMMCA.Common.Architecture.Tests/CancellationFixtures/CancellationTokenFixtures.cs:57
ExternalContractFixtureServiceclassMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests.CancellationFixturesMMCA.Common.Architecture.Tests/CancellationFixtures/CancellationTokenFixtures.cs:67
MisnamedTokenFixtureServiceclassMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests.CancellationFixturesMMCA.Common.Architecture.Tests/CancellationFixtures/CancellationTokenFixtures.cs:49
MisplacedTokenFixtureServiceclassMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests.CancellationFixturesMMCA.Common.Architecture.Tests/CancellationFixtures/CancellationTokenFixtures.cs:37
MissingTokenFixtureServiceclassMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests.CancellationFixturesMMCA.Common.Architecture.Tests/CancellationFixtures/CancellationTokenFixtures.cs:27
AcyclicConsumerclassMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests.CycleFixtures.AcyclicMMCA.Common.Architecture.Tests/CycleFixtures/Acyclic/AcyclicFixtures.cs:6
LeftModelBaseclassMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests.CycleFixtures.LeftMMCA.Common.Architecture.Tests/CycleFixtures/Left/LeftFixtures.cs:13
LeftServiceclassMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests.CycleFixtures.LeftMMCA.Common.Architecture.Tests/CycleFixtures/Left/LeftFixtures.cs:6
RightModelclassMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests.CycleFixtures.RightMMCA.Common.Architecture.Tests/CycleFixtures/Right/RightFixtures.cs:6
FixtureBackwardsV1recordMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEventsMMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:45
FixtureBackwardsV2recordMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEventsMMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:48
FixtureBackwardsVersionUpcasterclassMMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEventsMMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:78
CompliantFixtureServiceclassFixtureCompliantV1record MMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests.CancellationFixturesMMCA.Common.Architecture.Tests/CancellationFixtures/CancellationTokenFixtures.cs:6MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEventsMMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:15
ExemptableFixtureServiceFixtureCompliantV1ToV2Upcaster class MMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests.CancellationFixturesMMCA.Common.Architecture.Tests/CancellationFixtures/CancellationTokenFixtures.cs:57MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEventsMMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:54
ExternalContractFixtureServiceclassFixtureCompliantV2record MMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests.CancellationFixturesMMCA.Common.Architecture.Tests/CancellationFixtures/CancellationTokenFixtures.cs:67MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEventsMMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:18
MisnamedTokenFixtureServiceFixtureCompliantV2ToV3Upcaster class MMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests.CancellationFixturesMMCA.Common.Architecture.Tests/CancellationFixtures/CancellationTokenFixtures.cs:49MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEventsMMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:60
MisplacedTokenFixtureServiceclassFixtureCompliantV3record MMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests.CancellationFixturesMMCA.Common.Architecture.Tests/CancellationFixtures/CancellationTokenFixtures.cs:37MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEventsMMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:24
MissingTokenFixtureServiceFixtureContestedClaimUpcaster class MMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests.CancellationFixturesMMCA.Common.Architecture.Tests/CancellationFixtures/CancellationTokenFixtures.cs:27MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEventsMMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:66
AcyclicConsumerclassFixtureContestedV1record MMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests.CycleFixtures.AcyclicMMCA.Common.Architecture.Tests/CycleFixtures/Acyclic/AcyclicFixtures.cs:6MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEventsMMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:30
LeftModelBaseclassFixtureContestedV2record MMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests.CycleFixtures.LeftMMCA.Common.Architecture.Tests/CycleFixtures/Left/LeftFixtures.cs:13MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEventsMMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:33
LeftServiceclassFixtureContestedV3record MMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests.CycleFixtures.LeftMMCA.Common.Architecture.Tests/CycleFixtures/Left/LeftFixtures.cs:6MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEventsMMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:39
RightModelFixtureRivalClaimUpcaster class MMCA.Common.Architecture.TestsMMCA.Common.Architecture.Tests.CycleFixtures.RightMMCA.Common.Architecture.Tests/CycleFixtures/Right/RightFixtures.cs:6MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEventsMMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:72
DataProtectionExtensions MMCA.Common.Aspire.Tests/Gateway/GatewayCorrelationMiddlewareTests.cs:15
GatewayCorsExtensionsTestsclassMMCA.Common.Aspire.TestsMMCA.Common.Aspire.Tests.GatewayMMCA.Common.Aspire.Tests/Gateway/GatewayCorsExtensionsTests.cs:19
GatewayDownstreamHealthChecksTests class MMCA.Common.Aspire.Tests MMCA.Common.Aspire.Tests/Gateway/GatewayDownstreamHealthChecksTests.cs:175
StubHostEnvironmentclassMMCA.Common.Aspire.TestsMMCA.Common.Aspire.Tests.GatewayMMCA.Common.Aspire.Tests/Gateway/GatewayCorsExtensionsTests.cs:78
StubHttpClientFactory class MMCA.Common.Aspire.Tests record MMCA.Common.Domain MMCA.Common.Domain.IntegrationEventsMMCA.Common.Domain/IntegrationEvents/OutputCacheEvictionRequested.cs:23MMCA.Common.Domain/IntegrationEvents/OutputCacheEvictionRequested.cs:27
IAggregateRoot MMCA.Common.Infrastructure/Auth/LoginProtectionSettings.cs:9
PasswordResetEntryrecordMMCA.Common.InfrastructureMMCA.Common.Infrastructure.AuthMMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:171
PasswordResetTokenServiceclassMMCA.Common.InfrastructureMMCA.Common.Infrastructure.AuthMMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:26
RsaJwksProvider class MMCA.Common.Infrastructure MMCA.Common.Infrastructure/Services/DataSourceService.cs:12
EventUpcasterStartupValidatorclassMMCA.Common.InfrastructureMMCA.Common.Infrastructure.ServicesMMCA.Common.Infrastructure/Services/EventUpcasterStartupValidator.cs:20
FaultIntegrationEventConsumer<TEvent> class MMCA.Common.Infrastructure MMCA.Common.Infrastructure/Services/TokenService.cs:23
UpcastingIntegrationEventConsumer<TEvent>classMMCA.Common.InfrastructureMMCA.Common.Infrastructure.ServicesMMCA.Common.Infrastructure/Services/UpcastingIntegrationEventConsumer.cs:31
AuditTrailSettings class MMCA.Common.Infrastructure MMCA.Common.Infrastructure.Tests/Auth/LoginProtectionServiceTests.cs:291
FakeCacheServiceclassMMCA.Common.Infrastructure.TestsMMCA.Common.Infrastructure.Tests.AuthMMCA.Common.Infrastructure.Tests/Auth/PasswordResetTokenServiceTests.cs:222
LoginProtectionServiceTests class MMCA.Common.Infrastructure.Tests MMCA.Common.Infrastructure.Tests/Auth/LoginProtectionServiceTests.cs:14
PasswordResetTokenServiceTestsclassMMCA.Common.Infrastructure.TestsMMCA.Common.Infrastructure.Tests.AuthMMCA.Common.Infrastructure.Tests/Auth/PasswordResetTokenServiceTests.cs:18
RsaJwksProviderTests class MMCA.Common.Infrastructure.Tests MMCA.Common.Infrastructure.Tests/Services/DataSourceServiceTests.cs:13
EventUpcasterStartupValidatorTestsclassMMCA.Common.Infrastructure.TestsMMCA.Common.Infrastructure.Tests.ServicesMMCA.Common.Infrastructure.Tests/Services/EventUpcasterStartupValidatorTests.cs:20
FakeEntity class MMCA.Common.Infrastructure.Tests class MMCA.Common.Infrastructure.Tests MMCA.Common.Infrastructure.Tests.ServicesMMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:19MMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:20
IntegrationEventConsumerTests record MMCA.Common.Infrastructure.Tests MMCA.Common.Infrastructure.Tests.ServicesMMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:24MMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:25
NativePushPayloadsTests MMCA.Common.Infrastructure.Tests/Services/CurrentUserServiceTests.cs:277
OrderPlacedV2recordMMCA.Common.Infrastructure.TestsMMCA.Common.Infrastructure.Tests.ServicesMMCA.Common.Infrastructure.Tests/Services/UpcastingIntegrationEventConsumerTests.cs:31
OtherIntegrationEvent record MMCA.Common.Infrastructure.Tests MMCA.Common.Infrastructure.Tests/Services/BrokerMessageBusTests.cs:23
PasswordHasherSecurityTestsclassMMCA.Common.Infrastructure.TestsMMCA.Common.Infrastructure.Tests.ServicesMMCA.Common.Infrastructure.Tests/Services/PasswordHasherSecurityTests.cs:18
PasswordHasherTests class MMCA.Common.Infrastructure.Tests class MMCA.Common.Infrastructure.Tests MMCA.Common.Infrastructure.Tests.ServicesMMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:152MMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:231
RecordingIntegrationHandler class MMCA.Common.Infrastructure.Tests MMCA.Common.Infrastructure.Tests.ServicesMMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:161MMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:240
RecordingOriginalHandlerclassMMCA.Common.Infrastructure.TestsMMCA.Common.Infrastructure.Tests.ServicesMMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:219
RecordingSuccessorHandlerclassMMCA.Common.Infrastructure.TestsMMCA.Common.Infrastructure.Tests.ServicesMMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:208
RetiredOrderPlacedrecordMMCA.Common.Infrastructure.TestsMMCA.Common.Infrastructure.Tests.ServicesMMCA.Common.Infrastructure.Tests/Services/UpcastingIntegrationEventConsumerTests.cs:29
RetiredTestIntegrationEventrecordMMCA.Common.Infrastructure.TestsMMCA.Common.Infrastructure.Tests.ServicesMMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:195
RetiredToV2UpcasterclassMMCA.Common.Infrastructure.TestsMMCA.Common.Infrastructure.Tests.ServicesMMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:202
RetiredToV2UpcasterclassMMCA.Common.Infrastructure.TestsMMCA.Common.Infrastructure.Tests.ServicesMMCA.Common.Infrastructure.Tests/Services/UpcastingIntegrationEventConsumerTests.cs:36
RivalV1ToV3UpcasterclassMMCA.Common.Infrastructure.TestsMMCA.Common.Infrastructure.Tests.ServicesMMCA.Common.Infrastructure.Tests/Services/EventUpcasterStartupValidatorTests.cs:40
RoleOnlyService MMCA.Common.Infrastructure.Tests/Services/CurrentUserServiceTests.cs:290
SampleV1ToV2UpcasterclassMMCA.Common.Infrastructure.TestsMMCA.Common.Infrastructure.Tests.ServicesMMCA.Common.Infrastructure.Tests/Services/EventUpcasterStartupValidatorTests.cs:35
SignalRLiveChannelPublisherTests class MMCA.Common.Infrastructure.Tests record MMCA.Common.Infrastructure.Tests MMCA.Common.Infrastructure.Tests.ServicesMMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:21MMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:22
TestIntegrationEvent MMCA.Common.Infrastructure.Tests/Services/IntegrationEventConsumerTests.cs:13
TestIntegrationEventV2recordMMCA.Common.Infrastructure.TestsMMCA.Common.Infrastructure.Tests.ServicesMMCA.Common.Infrastructure.Tests/Services/InProcessMessageBusTests.cs:197
TestNonOutboxContext class MMCA.Common.Infrastructure.Tests MMCA.Common.Infrastructure.Tests/Services/DataSourceServiceAdditionalTests.cs:86
UpcastingIntegrationEventConsumerTestsclassMMCA.Common.Infrastructure.TestsMMCA.Common.Infrastructure.Tests.ServicesMMCA.Common.Infrastructure.Tests/Services/UpcastingIntegrationEventConsumerTests.cs:26
ValidatorSampleV1recordMMCA.Common.Infrastructure.TestsMMCA.Common.Infrastructure.Tests.ServicesMMCA.Common.Infrastructure.Tests/Services/EventUpcasterStartupValidatorTests.cs:23
ValidatorSampleV2recordMMCA.Common.Infrastructure.TestsMMCA.Common.Infrastructure.Tests.ServicesMMCA.Common.Infrastructure.Tests/Services/EventUpcasterStartupValidatorTests.cs:25
ValidatorSampleV3recordMMCA.Common.Infrastructure.TestsMMCA.Common.Infrastructure.Tests.ServicesMMCA.Common.Infrastructure.Tests/Services/EventUpcasterStartupValidatorTests.cs:30
ConnectionStringSettingsTests class MMCA.Common.Infrastructure.Tests class MMCA.Common.Shared MMCA.Common.Shared.AbstractionsMMCA.Common.Shared/Abstractions/ServiceContractAttribute.cs:19MMCA.Common.Shared/Abstractions/ServiceContractAttribute.cs:21
AuthClaimTypes MMCA.Common.Shared/Auth/ChangePreferencesRequest.cs:10
ForgotPasswordRequestrecord structMMCA.Common.SharedMMCA.Common.Shared.AuthMMCA.Common.Shared/Auth/ForgotPasswordRequest.cs:8
IPermissionRegistry interface MMCA.Common.Shared MMCA.Common.Shared/Auth/RegisterRequest.cs:13
ResetPasswordRequestrecord structMMCA.Common.SharedMMCA.Common.Shared.AuthMMCA.Common.Shared/Auth/ResetPasswordRequest.cs:9
RoleNames class MMCA.Common.Shared MMCA.Common.Testing/JwtTokenGenerator.cs:30
MiddlewarePipelineOrderTestsBaseclassMMCA.Common.TestingMMCA.Common.TestingMMCA.Common.Testing/MiddlewarePipelineOrderTestsBase.cs:29
OpenApiContractTestsBase<TFixture> class MMCA.Common.Testing MMCA.Common.Testing.Architecture/Bases/AggregateConventionTestsBase.cs:10
AnonymousEndpointTestsBaseclassMMCA.Common.Testing.ArchitectureMMCA.Common.Testing.ArchitectureMMCA.Common.Testing.Architecture/Bases/AnonymousEndpointTestsBase.cs:30
ArchitectureAssert class MMCA.Common.Testing.Architecture class MMCA.Common.Testing.Architecture MMCA.Common.Testing.ArchitectureMMCA.Common.Testing.Architecture/ArchitectureRules.Contracts.cs:3
ArchitectureRulesclassMMCA.Common.Testing.ArchitectureMMCA.Common.Testing.Architecture MMCA.Common.Testing.Architecture/ArchitectureRules.Controllers.cs:3
MMCA.Common.Testing.Architecture/ArchitectureRules.Transport.cs:3
ArchitectureRulesclassMMCA.Common.Testing.ArchitectureMMCA.Common.Testing.ArchitectureMMCA.Common.Testing.Architecture/ArchitectureRules.Upcasters.cs:5
BrandColorTokenTestsBase class MMCA.Common.Testing.Architecture class MMCA.Common.Testing.Architecture MMCA.Common.Testing.ArchitectureMMCA.Common.Testing.Architecture/Bases/EventConventionTestsBase.cs:8MMCA.Common.Testing.Architecture/Bases/EventConventionTestsBase.cs:9
FormsConventionTestsBase MMCA.Common.Testing.Architecture/RuleHelpers.cs:14
ServiceContractPurityTestsBaseclassMMCA.Common.Testing.ArchitectureMMCA.Common.Testing.ArchitectureMMCA.Common.Testing.Architecture/Bases/ServiceContractPurityTestsBase.cs:20
SharedLayerTestsBase class MMCA.Common.Testing.Architecture MMCA.Common.Testing.E2E/Infrastructure/WebVitalsCollector.cs:76
ForgotPasswordPageclassMMCA.Common.Testing.E2EMMCA.Common.Testing.E2E.PageObjectsMMCA.Common.Testing.E2E/PageObjects/ForgotPasswordPage.cs:6
LoginPage class MMCA.Common.Testing.E2E MMCA.Common.Testing.E2E/PageObjects/RegisterPage.cs:6
ResetPasswordPageclassMMCA.Common.Testing.E2EMMCA.Common.Testing.E2E.PageObjectsMMCA.Common.Testing.E2E/PageObjects/ResetPasswordPage.cs:6
AuthorizationTestsBase class MMCA.Common.Testing.E2E MMCA.Common.Testing.E2E/Workflows/Identity/LogoutTestsBase.cs:9
PasswordResetTestsBaseclassMMCA.Common.Testing.E2EMMCA.Common.Testing.E2E.Workflows.IdentityMMCA.Common.Testing.E2E/Workflows/Identity/PasswordResetTestsBase.cs:17
ProfileManagementTestsBase class MMCA.Common.Testing.E2E MMCA.Common.Testing.Tests/JwtTokenGeneratorTests.cs:18
MiddlewarePipelineOrderTestsclassMMCA.Common.Testing.TestsMMCA.Common.Testing.TestsMMCA.Common.Testing.Tests/MiddlewarePipelineOrderTests.cs:10
PingCommand record MMCA.Common.Testing.Tests class MMCA.Common.UI MMCA.Common.UI.Common.SettingsMMCA.Common.UI/Common/Settings/LayoutSettings.cs:7MMCA.Common.UI/Common/Settings/LayoutSettings.cs:9
UIModuleConfiguration class MMCA.Common.UI MMCA.Common.UI.Components.NotificationsMMCA.Common.UI/Components/Notifications/NotificationBell.razor.cs:14MMCA.Common.UI/Components/Notifications/NotificationBell.razor.cs:22
MoneyExtensions MMCA.Common.UI/Notifications/NotificationUIModule.cs:14
ForgotPasswordModelclassMMCA.Common.UIMMCA.Common.UI.Pages.AuthMMCA.Common.UI/Pages/Auth/ForgotPasswordModel.cs:9
LoginModel class MMCA.Common.UI MMCA.Common.UI/Pages/Auth/RegisterModel.cs:9
ResetPasswordModelclassMMCA.Common.UIMMCA.Common.UI.Pages.AuthMMCA.Common.UI/Pages/Auth/ResetPasswordModel.cs:10
DataGridListPageBase<TDto> class MMCA.Common.UI class MMCA.Common.UI MMCA.Common.UI.Services.NotificationsMMCA.Common.UI/Services/Notifications/NotificationInboxService.cs:15MMCA.Common.UI/Services/Notifications/NotificationInboxService.cs:28
NotificationState MMCA.Common.UI.E2E.Tests/DarkModeE2ETests.cs:16
ForgotPasswordPageE2ETestsclassMMCA.Common.UI.E2E.TestsMMCA.Common.UI.E2E.TestsMMCA.Common.UI.E2E.Tests/ForgotPasswordPageE2ETests.cs:9
LoginPageE2ETests class MMCA.Common.UI.E2E.Tests MMCA.Common.UI.E2E.Tests/RegisterPageE2ETests.cs:9
ResetPasswordPageE2ETestsclassMMCA.Common.UI.E2E.TestsMMCA.Common.UI.E2E.TestsMMCA.Common.UI.E2E.Tests/ResetPasswordPageE2ETests.cs:9
StickySidebarE2ETests class MMCA.Common.UI.E2E.Tests MMCA.Common.UI.Tests/Components/MobileInfiniteScrollListTests.cs:11
NotificationBellHostclassMMCA.Common.UI.TestsMMCA.Common.UI.Tests.ComponentsMMCA.Common.UI.Tests/Components/NotificationBellTests.cs:17
NotificationBellTests class MMCA.Common.UI.Tests MMCA.Common.UI.Tests.ComponentsMMCA.Common.UI.Tests/Components/NotificationBellTests.cs:15MMCA.Common.UI.Tests/Components/NotificationBellTests.cs:48
NotificationListenerTests class MMCA.Common.UI.Tests MMCA.Common.UI.Tests.LayoutMMCA.Common.UI.Tests/Layout/NavMenuTests.cs:108MMCA.Common.UI.Tests/Layout/NavMenuTests.cs:136
ForbiddenTests record MMCA.Common.UI.Tests MMCA.Common.UI.Tests.Services.NotificationsMMCA.Common.UI.Tests/Services/Notifications/NotificationInboxServiceTests.cs:23MMCA.Common.UI.Tests/Services/Notifications/NotificationInboxServiceTests.cs:24
Mocks class MMCA.Common.UI.Tests MMCA.Common.UI.Tests.Services.NotificationsMMCA.Common.UI.Tests/Services/Notifications/NotificationInboxServiceTests.cs:21MMCA.Common.UI.Tests/Services/Notifications/NotificationInboxServiceTests.cs:22
NotificationStateTests class MMCA.Common.UI.Tests MMCA.Common.UI.Tests.Services.NotificationsMMCA.Common.UI.Tests/Services/Notifications/NotificationInboxServiceTests.cs:26MMCA.Common.UI.Tests/Services/Notifications/NotificationInboxServiceTests.cs:27
StubScopeProvider
IServiceCollection services MMCA.ADC.Conference.ApplicationMMCA.ADC.Conference.Application/DependencyInjection.cs:37MMCA.ADC.Conference.Application/DependencyInjection.cs:41
IServiceCollection services
WebApplication app MMCA.Common.APIMMCA.Common.API/Startup/OpenApiEndpointExtensions.cs:20MMCA.Common.API/Startup/OpenApiEndpointExtensions.cs:24
WebApplication app
IServiceCollection services MMCA.Common.APIMMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:226MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:236
WebApplication app MMCA.Common.APIMMCA.Common.API/Startup/WebApplicationExtensions.cs:37MMCA.Common.API/Startup/WebApplicationExtensions.cs:35
IServiceCollection services MMCA.Common.ApplicationMMCA.Common.Application/DependencyInjection.cs:23MMCA.Common.Application/DependencyInjection.cs:24
ValidationResult result

Generated / excluded artifacts (no type sections written)

-

118 files excluded as generated (EF migrations, snapshots, *.g.cs, AssemblyInfo).

+

122 files excluded as generated (EF migrations, snapshots, *.g.cs, AssemblyInfo).

@@ -26165,6 +27642,18 @@

Generated / excl

+ + + + + + + + + + + + diff --git a/docs/onboarding/00-primer.html b/docs/onboarding/00-primer.html index 26bd15e..5e68c32 100644 --- a/docs/onboarding/00-primer.html +++ b/docs/onboarding/00-primer.html @@ -807,6 +807,41 @@

The decision records (ADRs) t

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MMCA.ADC.Migrations.SqlServer.Conference/Migrations/20260814214554_AddEventSponsorshipPacketUrl.Designer.cs
MMCA.ADC.Migrations.SqlServer.Conference/Migrations/20260819153953_AddEventTicketingUrl.cs
MMCA.ADC.Migrations.SqlServer.Conference/Migrations/20260819153953_AddEventTicketingUrl.Designer.cs
MMCA.ADC.Migrations.SqlServer.Conference/Migrations/20260819160828_AddActivity.cs
MMCA.ADC.Migrations.SqlServer.Conference/Migrations/20260819160828_AddActivity.Designer.cs
MMCA.ADC.Migrations.SqlServer.Conference/Migrations/SQLServerDbContextModelSnapshot.cs
Gateway topology owned by configuration (amends 008): the route table moves out of MapForwarder code into YARP ReverseProxy configuration as the single route source, with RouteMapTests as a drift gate in both consumers and the per-destination HTTP version policy (ADR-012 profiles) in cluster config; the AppHost/bicep keep address books, not route tables g16
090Event upcaster registration extension point (completes 010): IEventUpcaster<TSource, TTarget> in the Application layer plus an EventUpcasterRegistry that chains V1 to V2 to V3 to the terminal contract and re-stamps MessageId/DateOccurred after every hop, so inbox dedup survives upcasting; both delivery paths consult it, and a duplicate/self-map/cycle throws at host startg04/g05
091Cache-backed password reset (extends 029/032): the reset credential is one ICacheService record (256-bit token, only its SHA-256 stored, 30-minute TTL, single live token per address, 5 validation attempts, 3 requests per 60 minutes) rather than three columns on the user row, and ForgotPasswordHandlerBase returns success on every path so the endpoint is not an account-enumeration oracleg08/g14
092Core Web Vitals budget as a shipped test contract and deploy gate: WebVitalsCollector installs PerformanceObserver hooks as a Playwright init script, WebVitalsBudget defaults to the good band (LCP 2500, FCP 1800, TTFB 800 ms, CLS 0.1, INP 500), a breach throws naming the page, and both apps' assertions ride the chromium e2e-gateg27/devops-cicd
093Container image build posture: eleven four-stage Dockerfiles where the GitHub Packages token is a BuildKit secret (never an ARG/ENV), there is deliberately no separate dotnet build stage (publish re-restores; the RID split made every image compile twice, about 75 s), and PublishReadyToRun=true on the nine service/gateway images only; floating base tag and running as root are recorded as undecideddevops-aspire/devops-cicd
094Client-side entity data-access contract (the calling half of 034): hand-written typed bases in MMCA.Common.UI (AuthenticatedServiceBase + EntityServiceBase<TEntityDTO, TIdentifierType>), no generated client; the user-facing Polly retry lives in the client base rather than in ADR-009's resilience handler, and ADR-017's Idempotency-Key is minted client-side for creates only and held constant across the retry burstg15/g12
095Uniqueness under soft delete: SoftDeleteUniqueIndexConvention, registered once in ApplicationDbContext.ConfigureConventions, filters every unique index on a non-owned IAuditableEntity to live rows, so a deleted record stops occupying its unique slot forever; a hand-authored filter wins, HasSoftDeleteFilter is the manual extension point, and Cosmos is a no-opg07
096Best-effort side-effect contract: one BestEffort.ExecuteAsync(operation, logger, action, ct) helper awaits the side effect and turns any failure into exactly one Warning plus one besteffort.dispatch.failed increment on its own MMCA.Common.BestEffort meter; caller cancellation is rethrown rather than swallowed, and the operation name stays a low-cardinality constantg03/g22

The canonical index for the full set can be found at https://ivanball.github.io/docs/adr/.


diff --git a/docs/onboarding/99-coverage-audit.html b/docs/onboarding/99-coverage-audit.html index 90a0681..c701a26 100644 --- a/docs/onboarding/99-coverage-audit.html +++ b/docs/onboarding/99-coverage-audit.html @@ -161,48 +161,48 @@

1. Coverage reconciliation

.cs files scanned - 2,810 + 2,950 00-inventory.md , in-scope - 2,692 + 2,828 , generated/excluded - 118 + 122 logged exception §2.1 Type declaration rows (incl. partial-class fragments) - 3,586 + 3,797 00-inventory.md Distinct type nodes (partials collapsed) - 3,465 + 3,668 the master checklist → mapped to a functional group - 3,465 + 3,668 classify.ps1 (0 unmapped) → individually sectioned (named in a chapter) - 1,890 + 2,001 verify.ps1 → rolled up by project (G25 test classes) - 1,575 + 1,667 logged exception §2.2 Distinct ### sections written across 27 chapters - 1,834 - covering the 1,890 (sibling families share a section, §2.3) + 1,910 + covering the 2,001 (sibling families share a section, §2.3) Chapter overviews written @@ -210,9 +210,9 @@

1. Coverage reconciliation

one per group -

Cross-check result: verify.ps1 confirms 0 of the 1,890 individually-sectioned types are +

Cross-check result: verify.ps1 confirms 0 of the 2,001 individually-sectioned types are missing from their group chapter, every one appears as a ### heading or in a sibling-family - File:Line table. 3,465 = 1,890 individually-sectioned + 1,575 rolled-up. Nothing dropped, nothing + File:Line table. 3,668 = 2,001 individually-sectioned + 1,667 rolled-up. Nothing dropped, nothing double-counted (each type maps to exactly one group).

Caveat on what verify.ps1 proves. Its check is name presence: a type counts as covered when @@ -1141,20 +1141,65 @@

1. Coverage reconciliation

bodies remains open outside the parts re-authored here.
+
+

Regeneration note (re-verified against current source, 2026-08-23 full drift sweep). Regenerated + at MMCA.Common 0110aee + MMCA.ADC 96f0919a (both clean; prior pass 0b19b56 / 018ccc50). + Net change: +203 distinct nodes (3,465 to 3,668), 0 removed, 0 regrouped; classify.ps1 + reports 0 unmapped and the per-group counts sum to 3,668. Individually-sectioned types 1,890 to + 2,001, roll-ups 1,575 to 1,667, ### sections 1,834 to 1,910, cycles 30 to 34.

+
    +
  • Password-reset vertical (G08 +8, G12 +5, G14 +2, ADR-091): the cache-backed + forgot/reset-password flow: ForgotPasswordHandlerBase<TUser, TCommand> + (MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:35) and its + Reset sibling, PasswordResetTokenService (MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:26), + PasswordResetSettings (MMCA.Common.Application/Auth/PasswordResetSettings.cs:10), and + PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand> + (MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:43).
  • +
  • Event upcasting (G03 +1, G05 +2, ADR-090): IEventUpcaster<in TSource, out TTarget> + and EventUpcasterRegistry (MMCA.Common.Application/Services/EventUpcasterRegistry.cs:30).
  • +
  • ADC Conference application (G18 +33): the session-selection decision-support vertical + (GetContentSimilarity, GetSessionSelectionDashboard, GetSpeakerSessionOverlap, + GetCategoryDistribution under MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/, + incl. IAiScoringService at .../ScoreEventSessions/IAiScoringService.cs:40), calendar export + (ExportEventCalendarQuery/ExportSessionCalendarQuery), and new sponsor/category/question + update use-cases; G17 +4, G19 +1, G20 +1, G21 +9, G23/Identity +5, G26/Live +1, G15 +2, G07 +2 + ride the same waves.
  • +
  • Testing growth (G25 +127): per-[Fact] classes rolled up per the standing exception; the + individually-sectioned reusable base set in group-27 now counts 240 types.
  • +
  • Level-repack fallout (the reason 17 extra parts were re-authored): plan.ps1's repack moved + 174 existing sections across unit boundaries beyond the delta-touched units (G08, G15, G18 p03/p06-p16, + G19, G21, G22). A deterministic heading-vs-membership scan over parts/ confirms 0 stale sections + remain; the residual duplicate headings in group-23/group-24 are distinct same-name types (two + OptionState component states; UserDeleted domain event vs integration event), not leftovers.
  • +
  • Cycles 30 to 34: four new SCCs, each wholly inside one group: two in G12 + (InsecureJwtMetadataWarningStartupFilter / WebApplicationBuilderExtensions and + MiddlewarePipelineBuilder / WebApplicationExtensions) and two test-only in G25 + (AnonymousEndpointTestsBaseTests / DriftedTests / StaleAllowListTests / ConformantTests, + and EventUpcasterFitnessTests / UpcasterTestMap).
  • +
  • Outside the type pipeline: devops-iac was re-authored against the 2026-08-22 FinOps + second-stage Bicep changes (MMCA.ADC/infra/main.bicep, foundation.bicep; ADC PRs #135/#136); + CONCEPT-MAPS.md needed no change (27 groups, 15 packages unchanged); the 00-primer.md ADR + table gained rows 090-096 from the canonical index.
  • +
  • Authoring pass and verification: 73 parts re-authored across 16 group chapters (52 approved + units + the 17 repack-fallout units + 3 G18 units + devops-iac), authored against real source + with path:line citations. verify.ps1: 0 missing, rubric 34/34. All adversarial + spot-checks (overviews of G03/G05/G07/G08/G12, G08-p02, G15-p04) returned CONFIRMED.
  • +
+

2. Exceptions log (every deliberate omission, with reason)

-

2.1 Generated / scaffolded code, not sectioned (118 files)

+

2.1 Generated / scaffolded code, not sectioned (122 files)

EF Core migrations (/Migrations/, .Migrations.SqlServer), ModelSnapshot, *.Designer.cs, *.g.cs, GlobalUsings.g.cs, and AssemblyInfo.cs are excluded by rule (Tools/invtool IsGenerated). The mechanisms that produce them are taught instead: the DbContext, the migration workflow, and the .proto/gRPC contracts (see group-07, group-13, and devops-testing). The full file list is in 00-inventory.md.

-

2.2 Per-[Fact] test classes, rolled up by project (1,460 types)

+

2.2 Per-[Fact] test classes, rolled up by project (1,667 types)

Per the guide's TESTS note, individual test classes are not given per-type sections. The Testing chapter (group-27) instead:

    -
  • sections the reusable test infrastructure in full (the 168 types in MMCA.Common.Testing, +
  • sections the reusable test infrastructure in full (the 240 types in MMCA.Common.Testing, .Testing.E2E, .Testing.UI, the shared .Testing.Architecture rule library + bases, now including the six convention/fitness bases added since v1.93.0, the web-vitals collector, the localization resx-parity base, the slice-cohesion base, the markup-snapshot helper, the new @@ -1166,7 +1211,7 @@

    2.2 Per- DependencyInjectionAssert, TestPolling, ModuleConformanceTestsBase<TModule> and the WebVitalsBudget added at the v1.142.0 pass, and the per-repo architecture-fitness test classes plus the Gallery harness), and

  • -
  • rolls the remaining 1,460 per-suite test classes (including the MMCA.Common.Benchmarks +
  • rolls the remaining 1,667 per-suite test classes (including the MMCA.Common.Benchmarks perf-smoke project) into a per-project table (purpose + style: unit / integration / fitness / E2E / component / performance-smoke). Every one of the 1,460 remains individually listed with file:line in @@ -1177,14 +1222,14 @@

    2

    Near-identical families (per-entity Add*/Remove*/Update* commands, *DTOMapper, *CreateRequest, *Validator, per-type filter strategies, etc.) are taught in one ### A, B, C section that explains the shared shape once. Every grouped type is still named and cited individually via the section's - File:Line table, so citation coverage is complete (this is what verify.ps1 checks). The 1,804 - individually-sectioned types are covered by 1,740 ### sections; the 64-type difference is family grouping.

    + File:Line table, so citation coverage is complete (this is what verify.ps1 checks). The 2,001 + individually-sectioned types are covered by 1,910 ### sections; the 91-type difference is family grouping.


    3. Grouping & ordering verification

      -
    • Every type in exactly one group. classify.ps1 assigns all 3,465 nodes via name-level overrides +
    • Every type in exactly one group. classify.ps1 assigns all 3,668 nodes via name-level overrides (for the grab-bag MMCA.Common.*Interfaces*/Services namespaces) + ordered namespace-prefix rules; - it reports 0 unmapped and the per-group counts sum to 3,465. See + it reports 0 unmapped and the per-group counts sum to 3,668. See 00-group-taxonomy.md.
    • Within-group ascending Level. Each chapter's sections were authored from a pre-sorted, Level- ascending unit table, so no section precedes a same-group type it depends on (ties broken by name).
    • diff --git a/docs/onboarding/devops-iac.html b/docs/onboarding/devops-iac.html index f87b266..28adf88 100644 --- a/docs/onboarding/devops-iac.html +++ b/docs/onboarding/devops-iac.html @@ -315,13 +315,13 @@

      Log Analytics Workspace (<

      PerGB2018 is the pay-as-you-go tier. The 30-day minimum is Azure's floor for this SKU, shorter retention is rejected (and the memory note reference_log_analytics_sku_limits.md records this hard constraint). All six container apps ship their logs here via the Container Apps environment's - appLogsConfiguration (main.bicep:905-911), and main.bicep's Application Insights component + appLogsConfiguration (main.bicep:916-922), and main.bicep's Application Insights component uses it as its workspace backing store, meaning traces and metrics land in the same workspace.

      workspaceCapping.dailyQuotaGb: 1 (foundation.bicep:41-43) is a FinOps circuit breaker, not a sizing decision. Normal ingestion is around 0.4 GB/day, so the ceiling never bites in steady state; it exists to bound a runaway telemetry storm (a metrics or log loop) instead of leaving a - pay-per-GB workspace uncapped. The comment records the escape hatch: raise it, or set - dailyQuotaGb: -1, if a legitimate busy period approaches the cap.

      + pay-per-GB workspace uncapped. The comment records the escape hatch (foundation.bicep:37-40): + raise it, or set dailyQuotaGb: -1, if a legitimate busy period approaches the cap.

      [Rubric §13, Observability & Operability] assesses whether the system exposes structured logs, distributed traces, and metrics in a queryable store. The single workspace is the convergence point: container-app stdout/stderr, ASP.NET Core structured logs, and OpenTelemetry traces all @@ -333,31 +333,30 @@

      Azure Container Registry decision for image pull. Without it, every container app would need a stored registry admin password. With it disabled, images are pulled exclusively via the shared UAMI's AcrPull role assignment (bootstrapped out-of-band, see the UAMI section below). The deploy push likewise uses - the GitHub deploy identity's AcrPush role, not the admin credential.

      + the GitHub deploy identity's AcrPush role, not the admin credential (foundation.bicep:58-59).

      [Rubric §11, Security] assesses elimination of long-lived credentials. Disabling the admin user removes the one static credential that would otherwise be needed for every pull, a concrete, verifiable hardening choice recorded directly in the Bicep.

      -

      ACR scheduled purge task (foundation.bicep:64-108)

      +

      ACR scheduled purge task (foundation.bicep:64-110)

      The registry has no garbage collection of its own at Basic tier: the retention policy feature is Premium-only (foundation.bicep:67). Every deploy pushes a sha tag plus :latest for six images - along with buildx cache layers, and nothing ever deleted any of them, so the ACR Data Stored meter - only ratcheted upward. The comment records the measured shape of that ratchet: $0.49/day climbing to - $0.69/day within nine days in 2026-08 (foundation.bicep:69-70).

      + along with buildx cache layers, and nothing deletes any of them, so the ACR Data Stored meter only + ratchets upward. The comment records the measured shape of that ratchet: $0.49/day climbing to + $0.69/day within nine days in 2026-08 (foundation.bicep:68-70).

      The answer is an ACR task rather than a workflow step:

      var acrPurgeTaskYaml = '''
       version: v1.1.0
       steps:
      -  - cmd: acr purge --filter '.*:.*' --ago 30d --keep 10 --untagged
      +  - cmd: acr purge --filter '.*:.*' --ago 3d --keep 3 --untagged
           disableWorkingDirectoryOverride: true
           timeout: 3600
       '''
      -

      acrPurgeTask (foundation.bicep:83-108) is a Microsoft.ContainerRegistry/registries/tasks +

      acrPurgeTask (foundation.bicep:85-110) is a Microsoft.ContainerRegistry/registries/tasks child of the registry, status: 'Enabled', running the YAML above as a base64 EncodedTask - (foundation.bicep:95-98) on a Linux/amd64 agent with a 3600-second timeout. Its single + (foundation.bicep:97-100) on a Linux/amd64 agent with a 3600-second timeout. Its single timerTriggers entry, daily-0500-utc, carries the cron expression 0 5 * * * - (foundation.bicep:99-105), so it fires once a day at 05:00 UTC.

      -

      Three details in that one command line carry the whole retention policy - (foundation.bicep:78):

      + (foundation.bicep:101-108), so it fires once a day at 05:00 UTC.

      +

      Three flags on that one command line carry the whole retention policy (foundation.bicep:80):

      @@ -372,44 +371,50 @@

      ACR scheduled purge task

      - - - + + + - - - + + +
      buildx cache layers and superseded :latest targets, pure waste the moment they are orphaned
      --ago 30ddeletes tags not updated in 30 daysone month of deployed history is the retention window--ago 3ddeletes tags not updated in 3 daysthree days of deployed history is the retention window
      --keep 10keeps the 10 most recent tags per repository regardless of agethe rollback window survives a quiet month; a repo that has not been deployed to in 30 days still keeps ten images to roll back to--keep 3keeps the 3 most recent tags per repository regardless of agerollback only ever reaches the previous revision, so three kept tags cover it even for a repository nobody has deployed to in a week
      +

      The window is that tight for a reason the template states as a measurement + (foundation.bicep:71-74): a wider 30-day / keep-10 window let the registry grow to about 300 GiB + against the 10 GiB the Basic tier includes (measured 2026-08-22), and every GiB above the included + allowance is billed as storage overage. Six images times two tags per deploy, plus a mode=max + buildx cache export per image, is a lot of manifest per merge.

      Two things make this credential-free, which is why it is a task and not another OIDC job in deploy.yml. acr in the step command is the registry's built-in task alias for mcr.microsoft.com/acr/acr-cli, and a scheduled task authenticates to its own home registry automatically, so no credential is configured anywhere in the resource - (foundation.bicep:71-74).

      + (foundation.bicep:74-76).

      [Rubric §31, Cost Efficiency / FinOps] assesses whether infrastructure cost is actively monitored, bounded, and governed. This is the storage end of that: the purge task bounds a monotonically growing meter that no alert would have caught (registry storage never fails, it just costs more every day), and it does so declaratively, in the same template that created the registry, with the retention window expressed as reviewable flags rather than as a habit somebody has to remember.

      -

      Outputs (foundation.bicep:113-115)

      +

      Outputs (foundation.bicep:115-117)

      acrName, acrLoginServer, and logAnalyticsName are the three values threaded from Phase 1 into Phase 2 (docker push target) and then into Phase 3 (main.bicep parameters). Because Phases 1 - to 3 are now separate jobs, they cross the job boundary as job outputs (deploy.yml:759-762) and + to 3 are separate jobs, they cross the job boundary as job outputs (deploy.yml:759-762) and are read as needs.foundation.outputs.*: see deploy.yml:829 (az acr login --name ${{ needs.foundation.outputs.acrName }}), deploy.yml:845-846 (the two image tags), and deploy.yml:955-956 (the acrName/logAnalyticsName parameter assembly).


      Deployment parameters, assembled at deploy time, not committed

      -

      There is no infra/main.parameters.json file in the repository, the infra/ directory holds only - foundation.bicep, main.bicep, DISASTER-RECOVERY.md, OPERATIONS.md, SQL-MANAGED-IDENTITY.md, - POST-CUTOVER-atldevcon-downgrade.md, and a workbooks/ folder. The parameters fed to main.bicep - are built from scratch at deploy time by deploy.yml's "Build deployment parameters file" step - (deploy.yml:911-1068), which writes /tmp/deploy-params.json with jq.

      +

      There is no infra/main.parameters.json file in the repository, the tracked infra/ directory + holds only foundation.bicep, main.bicep, DISASTER-RECOVERY.md, OPERATIONS.md, + SQL-MANAGED-IDENTITY.md, POST-CUTOVER-atldevcon-downgrade.md, and a workbooks/ folder. The + parameters fed to main.bicep are built from scratch at deploy time by deploy.yml's "Build + deployment parameters file" step (deploy.yml:911-1068), which writes /tmp/deploy-params.json + with jq.

      How it works:

      • The step fails fast when the ALERT_EMAIL repository variable is empty (deploy.yml:937-940), - because alertEmailAddress is now a required main.bicep parameter with no default + because alertEmailAddress is a required main.bicep parameter with no default (main.bicep:102-104). An alert rule wired to no notification channel is a silent failure, so the deploy refuses to proceed with an actionable error rather than letting Bicep validation report it.
      • A base jq -n invocation (deploy.yml:952-980) emits the always-present parameters, environmentName, @@ -437,7 +442,7 @@

        infra/main.bic

        File: MMCA.ADC/infra/main.bicep

        main.bicep declares every application-layer Azure resource: Application Insights, the SLO scheduled query rules and their action group, two operational log alerts, a Gateway availability - web test and its alert, a saved SLO workbook (main.bicep:530-542), the monthly cost budget, SQL + web test and its alert, a saved SLO workbook (main.bicep:541-553), the monthly cost budget, SQL Server with five databases (the AtlDevCon archive plus the four per-service databases), Service Bus, an inert-by-default Notification Hub, the blob storage account with its two containers (public avatars and the private DataProtection key ring), an Azure Managed Redis @@ -501,7 +506,7 @@

        Computed variables (main.bice post-login redirect target, so it must be injected whenever any external provider is on rather than behind one of them.

      -

      Per-service SQL connection strings (main.bicep:152-159) are composed from a shared base: the SQL +

      Per-service SQL connection strings (main.bicep:156-159) are composed from a shared base: the SQL server FQDN plus one of two auth segments selected by useManagedIdentitySql (main.bicep:152-154). Each is a distinct string pointing at its own database (ADC_Identity, ADC_Conference, ADC_Engagement, ADC_Notification), making the database-per-service boundary explicit in the value @@ -509,7 +514,7 @@

      Computed variables (main.bice

      The Service Bus connection string (main.bicep:164) is resolved via listKeys() against the app-clients SAS authorization rule (not RootManageSharedAccessKey) so a future migration to managed identity can revoke only the app rule without touching the namespace root. The Redis - connection string (main.bicep:895) is assembled the same way, from the instance hostname plus a + connection string (main.bicep:906) is assembled the same way, from the instance hostname plus a listKeys() primary key.

      Application Insights (main.bicep:185-195)

      A workspace-based App Insights component backed by the foundation Log Analytics workspace:

      @@ -531,7 +536,7 @@

      Application Insights (main. APPLICATIONINSIGHTS_CONNECTION_STRING is present (main.bicep:180-184 comment), so the Common framework automatically routes OpenTelemetry spans, logs, and metrics to Azure Monitor in production with no service-level code change.

      -

      Four more shared env entries ride along with the connection string on every app, and all of them +

      Five more shared env entries ride along with the connection string on every app, and all of them are cost controls on a pay-per-GB workspace:

      • Telemetry__TracesSampleRatio: '0.25' (main.bicep:209-212), head-based trace sampling that keeps @@ -541,8 +546,8 @@

        Application Insights (main. OpenTelemetry logging provider ships to Azure Monitor. Serilog still writes Information to stdout (container logs), but only Warning and above bills against the workspace. The value is set explicitly because OpenTelemetry is the ProviderAlias of OpenTelemetryLoggerProvider, so the - key gates that provider only, and because the service hosts now register Serilog as one provider - alongside OpenTelemetry instead of calling UseSerilog(), which used to replace the + key gates that provider only, and because the service hosts register Serilog as one provider + alongside OpenTelemetry instead of calling UseSerilog(), which would replace the ILoggerFactory and drop every application log line before it could reach App Insights.

      • Telemetry__DisableHttpClientMetrics: 'true' (main.bicep:231-234) and Telemetry__DisableRuntimeMetrics: 'true' (main.bicep:235-238), which drop the two @@ -553,19 +558,31 @@

        Application Insights (main. http.server.request.duration and the MMCA.Common meters carry the operational signal. Both keys are read by MMCA.Common.Aspire's ConfigureOpenTelemetry, and the outbound-dependency latency the client metrics would have shown is still captured as (sampled) AppDependencies - traces, so this trims volume rather than visibility. Every one of the six apps gets the pair: - Identity (main.bicep:1070-1071), Conference (:1263-1264), Engagement (:1383-1384), - Notification (:1522-1523), Gateway (:1662-1663), UI (:1768-1769).

      • + traces, so this trims volume rather than visibility. +
      • OTEL_METRIC_EXPORT_INTERVAL: '300000' (main.bicep:246-249) is the second stage of the same + cost control, and it works on cadence rather than on instrument selection. AppMetrics remained + about 63% of workspace ingestion after the two instrument groups above were dropped (measured + 2026-08-01 to 2026-08-22, main.bicep:240-245). The exporter ships cumulative aggregates, so + stretching the export interval from the SDK default of 60s to 300s drops roughly 80% of the + remaining datapoints without losing the signal: every alert rule in this template evaluates over a + 5-minute or 15-minute window, so a 5-minute export cadence still lands a datapoint per window. + This is the standard OpenTelemetry SDK env var, read by the periodic exporting metric reader + rather than by any MMCA.Common code.
      +

      Every one of the six apps gets all five: Identity (main.bicep:1079-1083), Conference + (:1278-1282), Engagement (:1403-1407), Notification (:1547-1551), Gateway (:1692-1696), + UI (:1799-1803). They are declared once as Bicep variables and spliced into each env array by + name, which is what keeps a cost decision from being applied to five apps and forgotten on the + sixth.

      [Rubric §13, Observability & Operability] assesses whether the system ships distributed traces, structured logs, and metrics to a queryable backend. The workspace-based App Insights with per-service Cloud Role Names gives full Application Map visibility, end-to-end distributed traces across all six services, and Kusto-queryable logs, covering this category end-to-end.

      -

      SLO alerts as code (main.bicep:240-348), ADR-062

      +

      SLO alerts as code (main.bicep:251-359), ADR-062

      The three SLOs are declared as data: an array of records named sloAlertSpecs - (main.bicep:276-304) carrying key, description, query, timeAggregation, + (main.bicep:287-315) carrying key, description, query, timeAggregation, metricMeasureColumn, threshold and severity. A Bicep for loop materializes one Log Analytics - Microsoft.Insights/scheduledQueryRules per spec (main.bicep:306-348):

      + Microsoft.Insights/scheduledQueryRules per spec (main.bicep:317-359):

      @@ -601,67 +618,69 @@

      SLO alerts as code (m

      The KQL predicate is the whole point of the migration. These rules replaced metric alerts on requests/failed, requests/duration and dependencies/failed, which paged on routine traffic because a metric alert cannot express a status-code or URL predicate. The template records the two - real incidents (main.bicep:262-275): one window held 8x401 plus 2x499 plus a single readiness 503 + real incidents (main.bicep:273-286): one window held 8x401 plus 2x499 plus a single readiness 503 and zero other failures, all from one browser session retrying with an expired token, and five long-lived SignalR hub connections averaging 11.3s dragged the fleet-wide average to 5539ms against a 3000ms threshold while every real request was fast. A hub connection reports its connection lifetime as request duration. The thresholds and severities are unchanged, so this is a precision fix, not a sensitivity cut: a genuine 400 or 500 burst still pages at the same numbers.

      -

      The union(...) in the criteria (main.bicep:328-340) supplies metricMeasureColumn only for the +

      The union(...) in the criteria (main.bicep:339-351) supplies metricMeasureColumn only for the aggregate rule. Omitting it (the empty-string case) makes a rule count returned rows, which is what the two failure-count SLOs want. evaluationFrequency: 'PT5M' over windowSize: 'PT15M' with - autoMitigate: true (main.bicep:320-322) means each rule re-evaluates every five minutes against + autoMitigate: true (main.bicep:331-333) means each rule re-evaluates every five minutes against a 15-minute rolling window and auto-resolves when the signal returns below threshold.

      -

      The superseded metric alerts are still declared, and disabled in place (main.bicep:350-392). - legacySloMetricAlertSpecs (main.bicep:357-361) still materializes the three metricAlerts under - their original, unsuffixed names (main.bicep:365) with enabled: false and an empty actions - array (main.bicep:370, :389). This is the incremental-ARM consequence made explicit: a resource +

      The superseded metric alerts are still declared, and disabled in place (main.bicep:361-403). + legacySloMetricAlertSpecs (main.bicep:368-372) still materializes the three metricAlerts under + their original, unsuffixed names (main.bicep:376) with enabled: false and an empty actions + array (main.bicep:381, :400). This is the incremental-ARM consequence made explicit: a resource that simply leaves the template is never deleted from the resource group, so dropping them would have left three live rules firing alongside the new ones. Disabling them declaratively needs no portal step and rolls back in one line. It is also why the replacements carry a -v2 suffix - (main.bicep:308-311): reusing the name would have renamed the live originals instead of disabling + (main.bicep:319-322): reusing the name would have renamed the live originals instead of disabling them.

      -

      The action group (main.bicep:246-260) has an unconditional email receiver, which is the direct +

      The action group (main.bicep:257-271) has an unconditional email receiver, which is the direct consequence of alertEmailAddress being a required parameter. Every scheduled query rule routes to - it (main.bicep:344) and so does the cost budget (main.bicep:567, :575). One group, one + it (main.bicep:355) and so does the cost budget (main.bicep:577, :585). One group, one receiver, no severity routing: severity is triage metadata, not a delivery decision.

      Each SLO alert is paired with a same-severity triage section in MMCA.ADC/infra/OPERATIONS.md (OPERATIONS.md:15, :29, :42), and that pairing is enforced by a framework fitness test rather than by discipline: ObservabilityConventionTestsBase parses this template between the literal - anchors var sloAlertSpecs and resource sloAlerts and fails the build in both directions. That - gate is covered in group 27; - it is not duplicated here. Note the coverage boundary: only alerts inside that parse window are - gated, so the two operational rules and the availability alert below are provisioned but ungated.

      -

      Operational and availability alerts (main.bicep:394-520)

      + anchors var sloAlertSpecs and resource sloAlerts + (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/ObservabilityConventionTestsBase.cs:109-110) + and fails the build in both directions. That gate is covered in + group 27; it is not + duplicated here. Note the coverage boundary: only alerts inside that parse window are gated, so the + two operational rules and the availability alert below are provisioned but ungated.

      +

      Operational and availability alerts (main.bicep:405-531)

      Beyond the three SLOs, main.bicep provisions two more scheduled query rules from - scheduledQueryAlertSpecs (main.bicep:407-420, materialized at :422-454), both severity 2 on a + scheduledQueryAlertSpecs (main.bicep:418-431, materialized at :433-465), both severity 2 on a 15-minute evaluation over a 15-minute window:

        -
      • outbox-dead-letter (main.bicep:408-413) fires on any hit (threshold: 0) of an AppTraces +
      • outbox-dead-letter (main.bicep:419-424) fires on any hit (threshold: 0) of an AppTraces row at Error or above whose message contains dead-lettered. An outbox message that exhausted its retries means an integration event was permanently lost. The row-age signal is DB-side and not queryable from Log Analytics, so this Error line is the backlog alarm.
      • -
      • sql-dependency-failures (main.bicep:414-419) fires above 10 failed SQL dependency calls. Every +
      • sql-dependency-failures (main.bicep:425-430) fires above 10 failed SQL dependency calls. Every service owns exactly one database, so a burst here means a service cannot reach its own DB, which also stalls its outbox drain.

      An outside-in availability signal sits alongside them: a standard URL-ping web test - (main.bicep:463-494) probes the public Gateway /health every 300 seconds from three Azure + (main.bicep:474-505) probes the public Gateway /health every 300 seconds from three Azure locations (East US, North Central US, South Central US), bound to the App Insights component via a - hidden-link tag. Its severity 1 alert (main.bicep:496-520) fires on a failedLocationCount + hidden-link tag. Its severity 1 alert (main.bicep:507-531) fires on a failedLocationCount of 2, so a single-location blip does not page.

      [Rubric §29, Resilience, Reliability & Business Continuity] assesses whether the system can detect degradation automatically and notify operators. The three SLO rules, the two operational rules, and the sev-1 availability alert all route to the same action group as the cost budget, giving the on-call operator an automated signal for error rate, latency, dependency failures, permanent event loss, database reachability, and total entry-point outage.

      -

      SLO workbook (main.bicep:522-542)

      +

      SLO workbook (main.bicep:533-553)

      A saved Azure Monitor workbook renders the same three SLO signals plus exceptions, grouped per service by AppRoleName (which is the OTEL_SERVICE_NAME value). It is bound to the Log Analytics workspace and embeds workbooks/adc-slo-workbook.json at compile time via loadTextContent - (main.bicep:539), so the visualization cannot diverge from the alerts by being maintained + (main.bicep:550), so the visualization cannot diverge from the alerts by being maintained somewhere else, and the JSON stays independently validatable as a file.

      -

      Cost budget (main.bicep:544-579)

      +

      Cost budget (main.bicep:555-590)

      resource costBudget 'Microsoft.Consumption/budgets@2023-11-01' = if (enableBudget) {
         properties: {
           amount: monthlyBudgetAmount      // default: $200 USD
      @@ -689,38 +708,38 @@ 

      Cost budget (main.bicep:544-579[Rubric §31, Cost Efficiency / FinOps] assesses whether infrastructure cost is actively monitored, bounded, and governed. The budget resource, the enableBudget escape hatch, the workspace daily ingestion cap, the 25% trace sampling, the Warning log floor, the two - metric-group disables, the daily ACR purge task, and the commonTags - applied to every billable resource (main.bicep:138-144) together satisfy this category: tags - enable cost attribution; the caps bound runaway spend at the telemetry, storage and compute ends; - and the budget threshold notifications make the cap actionable.

      -

      SQL Server and databases (main.bicep:581-694)

      -

      SQL Server (main.bicep:584-595):

      + metric-group disables, the 300-second metric export interval, the daily ACR purge task, and the + commonTags applied to every billable resource (main.bicep:138-144) together satisfy this + category: tags enable cost attribution; the caps bound runaway spend at the telemetry, storage and + compute ends; and the budget threshold notifications make the cap actionable.

      +

      SQL Server and databases (main.bicep:592-705)

      +

      SQL Server (main.bicep:595-606):

      name: '${prefix}-sql-${resourceToken}'
       version: '12.0'
       minimalTlsVersion: '1.2'
       publicNetworkAccess: 'Enabled'
      -

      publicNetworkAccess: 'Enabled' (main.bicep:593) combined with the firewall rule - AllowAzureServices (main.bicep:597-604, startIpAddress/endIpAddress both 0.0.0.0) is the +

      publicNetworkAccess: 'Enabled' (main.bicep:604) combined with the firewall rule + AllowAzureServices (main.bicep:608-615, startIpAddress/endIpAddress both 0.0.0.0) is the Azure-standard pattern for allowing Container Apps to reach SQL without a VNet/private endpoint. The 0.0.0.0-0.0.0.0 rule does not allow traffic from arbitrary internet IPs; it enables the - special "allow Azure services" flag. minimalTlsVersion: '1.2' (main.bicep:592) ensures all + special "allow Azure services" flag. minimalTlsVersion: '1.2' (main.bicep:603) ensures all connections are encrypted at TLS 1.2 minimum.

      -

      Entra (Azure AD) admin (main.bicep:612-621), provisioned only when sqlAadAdminObjectId is +

      Entra (Azure AD) admin (main.bicep:623-632), provisioned only when sqlAadAdminObjectId is supplied. It is deliberately additive: it enables Entra auth alongside the SQL admin login and does not set azureADOnlyAuthentication, so password auth keeps working throughout the - transition. Its purpose is to let an operator run the per-database + transition (main.bicep:617-622). Its purpose is to let an operator run the per-database CREATE USER [adc-prod-apps-identity] FROM EXTERNAL PROVIDER grants that managed-identity app auth depends on. Full sequencing lives in infra/SQL-MANAGED-IDENTITY.md; the staged model is described in the Key Vault section below.

      -

      Legacy AtlDevCon database (main.bicep:629-643): +

      Legacy AtlDevCon database (main.bicep:640-654): Retained at Basic tier (5 DTU, 2 GB cap) as a read-only archive and rollback source after the database-per-service cutover, downgraded from S0 to minimise cost on an idle archive. Its Bicep resource declaration prevents out-of-band drift, even though Incremental mode would not delete it anyway, having it declared makes the "never touch this" intent explicit and prevents ARM complaining - about an undeclared resource. The comment at main.bicep:623-628 is the canonical explanation: the + about an undeclared resource. The comment at main.bicep:634-639 is the canonical explanation: the data (~34 MB) was fully copied into the per-service databases; this is the archive, not the live store.

      -

      Per-service databases (main.bicep:654-677), [Rubric §8, Data Architecture]:

      +

      Per-service databases (main.bicep:665-688), [Rubric §8, Data Architecture]:

      var serviceDatabaseNames = [
         'ADC_Identity'
         'ADC_Conference'
      @@ -745,7 +764,7 @@ 

      SQL Server and databases ( -

      Long-term backup retention (LTR) (main.bicep:683-694):

      +

      Long-term backup retention (LTR) (main.bicep:694-705):

      resource serviceDatabaseLtr '…/backupLongTermRetentionPolicies@…' = [
         for (dbName, i) in serviceDatabaseNames: {
           properties: {
      @@ -757,31 +776,31 @@ 

      SQL Server and databases (

      Basic tier already provides 7-day PITR (point-in-time recovery) with geo-redundant backups; LTR - adds weekly (4-week), monthly (12-month), and yearly (1-year) archival on top. The practical - value: a corrupted migration or a data-loss bug discovered three weeks after the fact is still - recoverable. The AtlDevCon archive is intentionally excluded from LTR, it is a static archive, - not a live store.

      + adds weekly (4-week), monthly (12-month), and yearly (1-year) archival on top (main.bicep:690-693). + The practical value: a corrupted migration or a data-loss bug discovered three weeks after the fact + is still recoverable. The AtlDevCon archive is intentionally excluded from LTR, it is a static + archive, not a live store.

      [Rubric §29, Resilience, Reliability & Business Continuity] extends to data recovery. LTR on the live per-service databases means every production restore scenario, bad migration, silent corruption, regulatory request for historical data, has a recovery path beyond the 7-day PITR window. The disaster-recovery runbook at MMCA.ADC/infra/DISASTER-RECOVERY.md documents the drilled restore procedure (ADR-009).

      -

      Azure Service Bus (main.bicep:696-738)

      +

      Azure Service Bus (main.bicep:707-749)

      sku: Standard   // Basic rejected: MassTransit requires topics, Basic supports queues only
       minimumTlsVersion: '1.2'
      -

      The Standard tier comment at main.bicep:704-708 is the explanation of a constraint that has - bitten the project before (it was absent in early production and is now documented in the memory - note project_adc_no_broker_in_azure.md): MassTransit's UsingAzureServiceBus auto-provisions +

      The Standard tier comment at main.bicep:715-719 is the explanation of a constraint that has + bitten the project before: MassTransit's UsingAzureServiceBus auto-provisions one topic per message type and one subscription per consumer, Basic tier has no topics, only queues, so it silently fails at MassTransit startup. Standard tier costs a flat ~$10/month base for the namespace plus per-million-operations, and the link/unlink flows are far below 1k messages a month even at conference scale.

      -

      The app-clients authorization rule (main.bicep:728-738) grants Send + Listen + Manage rights. +

      The app-clients authorization rule (main.bicep:739-749) grants Send + Listen + Manage rights. The Manage right is required so MassTransit can ConfigureEndpoints, auto-provision topics - and subscriptions at startup. The alternative (declaring every topic in Bicep) would be brittle + and subscriptions at startup, and without it the first publish fails with an Unauthorized topology + error (main.bicep:733-738). The alternative (declaring every topic in Bicep) would be brittle as new integration events are added, because it would require a Bicep change for every new event type.

      -

      Current integration event flows wired over Service Bus (documented at main.bicep:699-702):

      +

      Current integration event flows wired over Service Bus (documented at main.bicep:710-713):

      • Identity publishes UserRegistered → Conference UserRegisteredHandler auto-links a speaker by email match (BR-207).
      • @@ -792,9 +811,9 @@

        Azure Service Bus (main.bicep: namespace is the transport that carries them in production (RabbitMQ fills the same role locally). All four services receive MessageBus__Provider and MessageBus__ConnectionString, but only Identity and Conference call AddBrokerMessaging today: the Engagement and Notification entries are - pre-provisioned forward-compatible wiring, and the template says so (main.bicep:1414-1418, - :1551-1554), so adding a consumer later is a Program.cs change with no infra redeploy.

        -

        Azure Notification Hub (main.bicep:740-778), inert by default

        + pre-provisioned forward-compatible wiring, and the template says so (main.bicep:1441-1445, + :1583-1586), so adding a consumer later is a Program.cs change with no infra redeploy.

        +

        Azure Notification Hub (main.bicep:751-789), inert by default

        The ADR-044 native-push fan-out (FCM v1 and APNs) is declared but not deployed: the namespace, the adc-push hub, and its app-backend authorization rule are all behind @@ -804,34 +823,34 @@

        Azure Notifica blocking every application deploy for an inert-by-design resource.

        Even with the namespace deployed, delivery stays off: nativePushEnabled (main.bicep:107) is a separate default-false parameter, and the Notification app's NativePush__Enabled env var is only - injected at all when the hub exists (main.bicep:1571-1575). Turning it on is a two-step runbook + injected at all when the hub exists (main.bicep:1600-1607). Turning it on is a two-step runbook operation, upload the platform credentials in the portal, then redeploy with the flags flipped. The hub's Free tier covers 500 devices and 1M pushes per month, far above conference volumes.

        -

        Blob storage: avatars and the DataProtection key ring (main.bicep:780-848), ADR-045

        -

        One Standard_LRS StorageV2 account (main.bicep:788-801) carries two containers on the same +

        Blob storage: avatars and the DataProtection key ring (main.bicep:791-859), ADR-045

        +

        One Standard_LRS StorageV2 account (main.bicep:799-812) carries two containers on the same default blob service. The first is the public-read avatars container - (main.bicep:808-814). Public read is deliberate: avatar URLs render in <img> tags on + (main.bicep:819-825). Public read is deliberate: avatar URLs render in <img> tags on anonymous-visible surfaces with no SAS plumbing, and blob names carry a random suffix so they are not enumerable. The account sets minimumTlsVersion: 'TLS1_2' and supportsHttpsTrafficOnly: true.

        -

        The second is dataProtectionKeysContainer (main.bicep:821-827), named dataprotection-keys and +

        The second is dataProtectionKeysContainer (main.bicep:832-838), named dataprotection-keys and explicitly publicAccess: 'None'. It holds the shared ASP.NET Core DataProtection key ring for the two apps that mint cookies (Identity and UI), and its privacy is the whole point of declaring it separately rather than reusing avatars: a key ring readable anonymously would hand out the keys that protect every auth cookie and antiforgery token in the system. The comment above it - (main.bicep:816-820) states the failure it prevents: both apps run at maxReplicas: 2, and the + (main.bicep:827-831) states the failure it prevents: both apps run at maxReplicas: 2, and the default in-memory key ring is per replica, so a token minted by one replica is undecryptable by the other. The per-app wiring is in the Identity and UI subsections below.

        The Identity service authenticates to it with DefaultAzureCredential resolving the shared apps identity, so there is no connection-string secret. Control-plane ownership of the account does not grant blob writes, though: the Storage Blob Data Contributor data-plane assignment - (main.bicep:840-848) is what does, and it is guarded by grantAvatarStorageRole, default false, + (main.bicep:851-859) is what does, and it is guarded by grantAvatarStorageRole, default false, for exactly the same reason as the Key Vault grants. Until an operator applies it once by hand, avatar uploads fail cleanly with FileStorage.UploadFailed and everything else deploys. That one assignment is scoped to the storage account, not to a container, so it also covers the key-ring container: the shared key ring needs no second role assignment, and the template says so - (main.bicep:834-835).

        + (main.bicep:845-846).

        [Rubric §11, Security] assesses credential and key handling. One follow-up is recorded in the - template as not implemented (main.bicep:836-839): encrypting the key ring at rest with a Key + template as not implemented (main.bicep:847-850): encrypting the key ring at rest with a Key Vault key (DataProtection__KeyVaultKeyUri) would need a separate Key Vault Crypto User grant on the apps identity, and neither the env var nor the grant exists today. The comment states the reason blob persistence deliberately works without it: a missing or delayed crypto grant would @@ -840,14 +859,14 @@

        Azure Managed Redis (main.bicep:850-895)

        +

        Azure Managed Redis (main.bicep:861-906)

        One shared Microsoft.Cache/redisEnterprise instance at the Balanced_B0 SKU (1 GB, HA disabled, around $13/month) with a single default database on port 10000, encrypted client protocol, OSSCluster clustering, VolatileLRU eviction and both persistence modes off - (main.bicep:864-892). Volatile-only eviction is deliberate: cache entries and idempotency records - carry TTLs, and a key without a TTL must never be silently evicted.

        + (main.bicep:875-903). Volatile-only eviction is deliberate: cache entries and idempotency records + carry TTLs, and a key without a TTL must never be silently evicted (main.bicep:895-896).

        Every service gets ConnectionStrings__redis from the vault, and three consumers activate on that - key alone with no application change:

        + key alone with no application change (main.bicep:864-872):

        1. ICacheService upgrades from a per-replica MemoryCache to DistributedCacheService, which makes the IdempotencyFilter's 24h replay records cross-replica (with maxReplicas: 2 a @@ -857,7 +876,7 @@

          Azure Managed Redis (main.bi
        2. The Notification SignalR backplane auto-wires when the key appears, via MMCA.Common.Infrastructure's AddPushNotifications.
        -

        Container Apps environment (main.bicep:897-913)

        +

        Container Apps environment (main.bicep:908-924)

        resource containerAppEnv '…/managedEnvironments@2024-03-01' = {
           properties: {
             appLogsConfiguration: {
        @@ -874,7 +893,7 @@ 

        Container Apps environment internal DNS resolution. An app can reach another by its Container App name (e.g. http://adc-prod-identity) because the ACA environment's internal DNS resolves Container App names as hostnames within the environment.

        -

        UAMI and ACR credential model (main.bicep:915-928)

        +

        UAMI and ACR credential model (main.bicep:926-939)

        [Rubric §11, Security] assesses credential handling as one of its primary axes.

        resource appsIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@…' existing = {
           name: 'adc-prod-apps-identity'
        @@ -886,9 +905,10 @@ 

        UAMI and ACR credential }

        appsIdentity is a User-Assigned Managed Identity (UAMI) bootstrapped out-of-band (one-time admin operation) with AcrPull on the registry and Key Vault Secrets User on the vault. The Bicep - template only references it (existing keyword), not creates it, because the deploy identity - (also a UAMI, used by GitHub Actions via OIDC) has Contributor but not Microsoft.Authorization/ roleAssignments/write, creating role assignments requires elevated permissions deliberately - withheld from the CI identity.

        + template only references it (existing keyword, main.bicep:931-933), not creates it, because + the deploy identity (also a UAMI, used by GitHub Actions via OIDC) has Contributor but not + Microsoft.Authorization/roleAssignments/write (main.bicep:926-930), creating role assignments + requires elevated permissions deliberately withheld from the CI identity.

        Every container app resource declares the same identity:

        identity: {
           type: 'UserAssigned'
        @@ -915,15 +935,16 @@ 

        UAMI and ACR credential itself, not just for approval gates. The federated identity credential's subject is repo:ivanball/ADC:environment:production, so a job without it presents repo:ivanball/ADC:ref:refs/heads/main instead and azure/login fails with AADSTS700213 - (deploy.yml:752-757). Every job that runs azure/login therefore declares it.

        -

        Key Vault and runtime secrets (main.bicep:930-1013), ADR-061

        + (deploy.yml:752-757). Every job that runs azure/login therefore declares it, including + cost-guard.yml's read-only surge check (cost-guard.yml:31-32).

        +

        Key Vault and runtime secrets (main.bicep:941-1024), ADR-061

        resource keyVault '…/vaults@…' existing = {
           name: 'adckv${resourceToken}'
         }

        Every production secret lives in Key Vault and reaches a Container App as a reference, never as a value. Key Vault is bootstrapped out-of-band like the identity: the template declares it existing - (main.bicep:940-942) and then writes fourteen secret child resources into it - (main.bicep:944-1013). Each Container App references them by Key Vault URI through the shared UAMI:

        + (main.bicep:951-953) and then writes fourteen secret child resources into it + (main.bicep:955-1024). Each Container App references them by Key Vault URI through the shared UAMI:

        secrets: [
           {
             name: 'sql-connection-string'
        @@ -935,17 +956,17 @@ 

        Key Vault and r

        This is the keyVaultUrl + identity pattern in ACA (Container Apps Secrets backed by Key Vault): the secret value never appears in the Container App definition, the ARM deployment history, or deployment logs. Not one secrets entry in this template carries an inline value. Containers then - consume them only through secretRef (for example main.bicep:1077, :1099, :1125, :1268, - :1574). At runtime ACA fetches the current secret version via the UAMI's Key Vault Secrets User + consume them only through secretRef (for example main.bicep:1089, :1111, :1137, :1286, + :1573). At runtime ACA fetches the current secret version via the UAMI's Key Vault Secrets User role, meaning a secret rotation only requires updating the Key Vault secret, no Bicep re-deployment, no app restart.

        -

        Secrets stored in Key Vault (main.bicep:944-1013):

        +

        Secrets stored in Key Vault (main.bicep:955-1024):

        • Per-service SQL connection strings (4): identity-sql-connection-string, conference-sql-connection-string, engagement-sql-connection-string, notification-sql-connection-string
        • service-bus-connection-string, redis-connection-string
        • -
        • notification-hub-connection-string (only when deployNotificationHub is true, main.bicep:969)
        • +
        • notification-hub-connection-string (only when deployNotificationHub is true, main.bicep:980)
        • rsa-private-key-pem, rsa-public-key-pem (or 'unused' placeholder when not supplied)
        • jwt-secret-key (HS256 fallback, or 'unused')
        • smtp-password, github-oauth-client-secret, google-oauth-client-secret, anthropic-api-key
        • @@ -960,32 +981,32 @@

          Key Vault and r code. The cost is that the vault is a poor inventory: an unused secret is indistinguishable from a configured one, and only an app's secrets list says which credentials are actually live.

          The two apps that need no credential say so explicitly. Gateway and UI declare secrets: [] - (main.bicep:1649, :1754) rather than omitting the property: a pure YARP proxy and a Blazor host + (main.bicep:1681, :1787) rather than omitting the property: a pure YARP proxy and a Blazor host that talks only to the Gateway hold nothing worth stealing, and stating it makes that a reviewable fact rather than an omission.

          Both role assignments are bootstrapped out of band, deliberately. The deploy identity holds Key Vault Secrets Officer to write the values; the apps hold Key Vault Secrets User to read them; the vault and both grants are created outside the template because the deploy principal has Contributor - without Microsoft.Authorization/roleAssignments/write (main.bicep:933-936). A template that + without Microsoft.Authorization/roleAssignments/write (main.bicep:944-947). A template that created its own role assignments would need exactly the permission the deployment deliberately does not have. The trade-off is stated in the ADR: one shared identity means any app carrying it can read every secret in the vault, not only the ones its own secrets list names, and the template cannot report that a grant is missing.

          The same grant also backs a second, different consumption path. Alongside the platform-resolved keyVaultUrl secret references above, five of the six apps receive KeyVault__Uri - (main.bicep:1144 Identity, :1299 Conference, :1425 Engagement, :1566 Notification, :1785 + (main.bicep:1156 Identity, :1321 Conference, :1452 Engagement, :1598 Notification, :1819 UI), which turns the vault into an ASP.NET Core configuration source: MMCA.Common's AddCommonKeyVaultConfiguration is a no-op without the key, and with it the host reads the vault synchronously at startup through DefaultAzureCredential. The Gateway is deliberately not in that - list (main.bicep:937-939): it holds no secret at all, so there is nothing for it to read. The two + list (main.bicep:948-950): it holds no secret at all, so there is nothing for it to read. The two paths differ in who resolves the value: the platform does it for secretRef entries, the host process does it for the configuration source, and both authenticate as the same appsIdentity that already holds Key Vault Secrets User. Secret names use a double dash for the configuration separator, so the existing single-dash secrets arrive as flat keys and shadow nothing the container - already sets.

          -

          That startup read is why AZURE_CLIENT_ID is now on Conference, Engagement, Notification and the - UI (main.bicep:1298, :1424, :1565, :1782) and no longer only on Identity, where it was - introduced for avatar blob access (:1136). Each app carries only the user-assigned identity, and + already sets (main.bicep:1149-1155).

          +

          That startup read is why AZURE_CLIENT_ID is on Conference, Engagement, Notification and the + UI (main.bicep:1320, :1451, :1597, :1816) and not only on Identity, where it was + introduced for avatar blob access (:1148). Each app carries only the user-assigned identity, and the ACA identity endpoint needs that identity named, so without the pin DefaultAzureCredential fails the startup vault read rather than falling back.

          The staged SQL auth completed its migration, but only the staging is visible in source. @@ -999,67 +1020,70 @@

          Key Vault and r deploy.yml:1065-1068 rewrites the parameter to true when it is set. In ivanball/ADC that variable is true (set 2026-06-28, alongside SQL_AAD_ADMIN_LOGIN and SQL_AAD_ADMIN_OID), so the running apps authenticate passwordlessly and the shared SQL password is no longer on the app path. - The ADC scorecard records the same activation on that date as the change that lifted §17 DevOps - Implementation from 8 to 9. The - migration ran in three stages, all driven by repository variables that are absent by default: + The runbook states the same as an operational fact (OPERATIONS.md:46). The + migration runs in three stages, all driven by repository variables that are absent by default: supply the Entra admin (deploy.yml:1054-1061), run the per-database external-provider grants by hand, then set USE_MANAGED_IDENTITY_SQL=true (deploy.yml:1065-1068). Because the Entra admin is additive and the flag defaults off, stage 1 changes nothing observable and a bad flip rolls back by the same one parameter. Whether a given deployment has already set that variable is not determinable from source.

          Where the other repos stand. MMCA.Store implements the identical Key Vault model with its own - identity (mmca-prod-apps-identity) and eleven vault secrets. MMCA.Common ships the shape as a - compile-only reference sample under samples/deployment/, not a deployment: it creates an - RBAC-authorized vault and attaches the identity for both ACR pull and secret reads, but declares no - secrets entry for the secretRef it uses and writes no secret into the vault it creates, and CI - only type-checks it. MMCA.Helpdesk has no infra/ directory and no deploy workflow at all (its - .github/workflows/ holds ci.yml plus the two Claude workflows), so there is nothing there to - adopt.

          + identity (mmca-prod-apps-identity, MMCA.Store/infra/main.bicep:25) and eleven vault secrets. + MMCA.Common ships the shape as a compile-only reference sample under samples/deployment/, not a + deployment: it creates an RBAC-authorized vault (MMCA.Common/samples/deployment/main.bicep:67) and + attaches the identity for both ACR pull and secret reads, but declares no secrets entry for the + secretRef it uses (:143) and writes no secret into the vault it creates, and CI only + type-checks it. MMCA.Helpdesk has no infra/ directory and no deploy workflow at all (its + .github/workflows/ holds ci.yml, release-templates.yml, and the two Claude workflows), so + there is nothing there to adopt.

          Container Apps, the six deployables

          Six Microsoft.App/containerApps resources are declared in main.bicep. They share structural patterns but differ in ingress transport, probe style, and environment variables.

          Common structural patterns

          -

          All six apps (main.bicep:1015-1839) share:

          +

          All six apps (main.bicep:1029-1874) share:

          • identity: { type: 'UserAssigned', userAssignedIdentities: { '${appsIdentity.id}': {} } }, the - same shared UAMI on every app (main.bicep:1022-1027, :1223, :1347, :1471, :1632, :1734).
          • -
          • activeRevisionsMode: 'Single', one active revision at a time; new deploys create a new - revision and traffic flips atomically rather than gradually. This matches deploy.yml's post- - deploy smoke-test gate, which checks the new revision before marking the deploy green.
          • + same shared UAMI on every app (main.bicep:1033-1038, :1240, :1369, :1498, :1664, :1767). +
          • activeRevisionsMode: 'Single' (main.bicep:1042, :1249, :1378, :1507, :1673, :1776), + one active revision at a time; new deploys create a new revision and traffic flips atomically + rather than gradually. This matches deploy.yml's post-deploy smoke-test gate, which checks the + new revision before marking the deploy green.
          • scale: { minReplicas: 1, maxReplicas: 2, rules: [{ name: 'http-scale', http: { metadata: { concurrentRequests: '50' } } }] }, minReplicas: 1 prevents scale-to-zero (which would destroy Blazor Server circuits and outbox in-flight messages); HTTP scale-out at 50 concurrent requests gives the headroom needed for a conference-day load (historically ~67 peak concurrent). Notification is the exception: its - maxReplicas is 1 (main.bicep:1616), a deliberate right-sizing at that measured peak. The - Redis backplane that would make a second replica safe for hub fan-out is now wired, so the cap - is a cost choice rather than a correctness one (main.bicep:1611-1615); raising it wants a + maxReplicas is 1 (main.bicep:1648), a deliberate right-sizing at that measured peak. The + Redis backplane that would make a second replica safe for hub fan-out is wired, so the cap + is a cost choice rather than a correctness one (main.bicep:1643-1647); raising it wants a verified two-replica fan-out test first.
          • ASPNETCORE_ENVIRONMENT: 'Production', switches ASP.NET Core to the production configuration, which among other things disables the OpenAPI endpoint (it is only mapped outside Production per the ADC CLAUDE.md).
          • -
          • ApplicationSettings__DatabaseInitStrategy: 'Migrate', each service auto-applies its own - database's pending migrations at startup as the sole migrator. deploy.yml deliberately has no +
          • ApplicationSettings__DatabaseInitStrategy: 'Migrate' on the four database-owning services + (main.bicep:1118, :1307, :1431, :1579), each service auto-applies its own database's + pending migrations at startup as the sole migrator. deploy.yml deliberately has no separate sqlcmd migration step (a backstop would race the container's startup Migrate()); with minReplicas: 1 exactly one replica migrates before the revision serves (deploy.yml:1078-1088). - The build-time EF model-drift gate (deploy.yml:262-276) still guarantees a migration exists for + The build-time EF model-drift gate (deploy.yml:262-277) still guarantees a migration exists for every model change, across all four migrations projects.
          • -
          • Outbox__PollingIntervalSeconds: '300', the outbox signal + smart wait in MMCA.Common ≥ 1.50.0 - delivers real messages in ~5 seconds regardless of the poll interval; the 300-second poll only - governs idle polling. This cuts App Insights SQL dependency telemetry that would otherwise flood - the workspace around the clock (the OutboxPollFilterProcessor suppresses the poll spans from - App Insights per the memory note project_outbox_cost_optimization.md).
          • -
          • Outbox__DeadLetterRetentionDays: '30' on the four database-owning services (main.bicep:1086, - :1274, :1394, :1539; Gateway and UI own no database and therefore no outbox). A +
          • Outbox__PollingIntervalSeconds: '300' (main.bicep:1102, :1294, :1419, :1569), the outbox + signal + smart wait in MMCA.Common ≥ 1.50.0 delivers real messages in ~5 seconds regardless of the + poll interval; the 300-second poll only governs idle polling. This cuts App Insights SQL dependency + telemetry that would otherwise flood the workspace around the clock (the + OutboxPollFilterProcessor suppresses the poll spans from App Insights per the memory note + project_outbox_cost_optimization.md).
          • +
          • Outbox__DeadLetterRetentionDays: '30' on the four database-owning services (main.bicep:1098, + :1292, :1417, :1567; Gateway and UI own no database and therefore no outbox). A dead-lettered row (retries exhausted, never delivered) keeps ProcessedOn null forever, so the processed-row sweep never reaches it and it stays in the pending index that every poll re-scans. OutboxCleanupService purges those rows on their own window, falling back to RetentionDays (default 7) when the key is 0 - (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/OutboxCleanupService.cs:116-136, + (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/OutboxCleanupService.cs:116-127, Settings/OutboxSettings.cs:101-108). Setting 30 in production deliberately keeps a failed payload longer than a delivered one: four weeks to diagnose or replay it by hand before the row is abandoned.
          • Scheduler__PollingIntervalSeconds: '300' on Identity, Conference and Engagement only - (main.bicep:1095, :1278, :1398), the same reasoning as the outbox interval applied to the + (main.bicep:1107, :1296, :1421), the same reasoning as the outbox interval applied to the scheduled-job runner: it smart-waits until the earliest due job, so the interval only bounds an idle sleep, and the 30-second default woke every runner twice a minute per database for nothing. Notification does not get the key because it runs no scheduler: Scheduler:Enabled is true in @@ -1068,16 +1092,26 @@

            Common structural patterns

            MMCA.ADC.Conference.Service/appsettings.json:33-34, MMCA.ADC.Engagement.Service/appsettings.json:54-55), and the Notification service declares no Scheduler section at all. The template's own note says the same - (main.bicep:1091-1094): the audit-trail cleanup job runs daily, which is what the interval + (main.bicep:1103-1106): the audit-trail cleanup job runs daily, which is what the interval paces.
          • -
          • ConnectionStrings__redis from Key Vault on all four services, which is the single key that turns - on the distributed cache, cross-replica idempotency, and the SignalR backplane.
          • +
          • ConnectionStrings__redis from Key Vault on all four services (main.bicep:1111, :1300, + :1425, :1573), which is the single key that turns on the distributed cache, cross-replica + idempotency, and the SignalR backplane.
          • MessageBus__Provider: 'AzureServiceBus' + MessageBus__ConnectionString from Key Vault, selects MassTransit's Azure Service Bus transport at startup (locally the AppHost injects WithBroker(rabbit) for RabbitMQ instead).
          • HealthProbe__Port, a dedicated HTTP/1.1 listener that Program.cs adds when the key is set, and the target of all three probes (see below).
          +

          Three of the four services also carry + Authentication__JwtBearer__RequireHttpsMetadata: 'false' (main.bicep:1306 Conference, :1430 + Engagement, :1578 Notification), and the template explains why in the comment directly above each + one (main.bicep:1303-1305, :1427-1429, :1575-1577). Their JWKS Authority is the ACA + internal-ingress h2c URL for Identity, http://adc-prod-identity: TLS terminates at the platform + edge, so traffic inside the environment is cleartext, and the framework's secure-by-default HTTPS + metadata requirement would otherwise reject that discovery fetch outright. Identity itself does not + carry the key because it issues the tokens rather than validating them against a remote authority, + and the Gateway does no JWT validation at all.

          [Rubric §17, DevOps & Deployment] specifically calls out environment parity. The same six services that run under Aspire locally also run as Container Apps in production, with the transport switch (RabbitMQ → AzureServiceBus), the SQL location switch (localhost SQL container → Azure SQL), and the secret management switch (environment variable → Key Vault URI) all being @@ -1085,7 +1119,7 @@

          Common structural patterns

          Ingress transport choices

          Two distinct transport configurations appear across the six apps:

          HTTP/2 cleartext (transport: 'http2', allowInsecure: true): used by Identity, Conference, - and Engagement (main.bicep:1032-1039, :1233-1240, :1357-1364). These three + and Engagement (main.bicep:1043-1050, :1250-1257, :1379-1386). These three services run Kestrel in Http2-only on cleartext (h2c prior knowledge), which is required for cross-service gRPC: Kestrel cannot negotiate HTTP/2 via ALPN without TLS, and internal ACA service-to-service traffic does not pass through the TLS terminator. allowInsecure: true is @@ -1093,11 +1127,11 @@

          Ingress transport choices

          architectural sense (traffic stays within the ACA virtual network) but the field name is misleading.

          HTTP/1.1 (transport: 'http'): used by Notification, Gateway, and UI. Notification runs Kestrel in Http1AndHttp2 because SignalR's WebSocket transport begins with an HTTP/1.1 Upgrade - handshake (main.bicep:1484 comment). Gateway and UI use HTTP/1.1 because they are the external - entry points (Blazor Server also uses WebSocket upgrade from HTTP/1.1, main.bicep:1794-1795 - comment).

          + handshake (main.bicep:1511 comment). Gateway and UI use HTTP/1.1 because they are the external + entry points (main.bicep:1674-1679, :1777-1785; Blazor Server also uses WebSocket upgrade from + HTTP/1.1, main.bicep:1828-1829 comment).

          Notification carries a third shape on top: additionalPortMappings exposes an internal-only TCP - port 8081 (main.bicep:1491-1497) for the cleartext h2c gRPC ingress (LiveChannelPush). TCP + port 8081 (main.bicep:1518-1524) for the cleartext h2c gRPC ingress (LiveChannelPush). TCP passthrough is what sidesteps the envoy HTTP/1.1-versus-HTTP/2 conflict, because the main ingress must stay http for WebSockets while gRPC needs end-to-end HTTP/2 (the ADR-012 mixed-transport @@ -1105,12 +1139,13 @@

          Ingress transport choices

          Probes on a dedicated HTTP/1.1 listener

          Kestrel in HTTP/2 prior-knowledge mode rejects the platform's HTTP/1.1 httpGet probe with GOAWAY HTTP_1_1_REQUIRED, which would fail the liveness check and cause a reboot loop. Rather than - degrading the three h2c services to port-only tcpSocket probes, each service now opens a + degrading the three h2c services to port-only tcpSocket probes, each service opens a dedicated HTTP/1.1 probe listener that is not exposed via ingress: HealthProbe__Port: '8081' - on Identity, Conference and Engagement (main.bicep:1076, :1267, :1387) and '8082' on - Notification (main.bicep:1530, because 8080 and 8081 are already the ADR-012 pair). ACA probes may + on Identity, Conference and Engagement (main.bicep:1088, :1285, :1410) and '8082' on + Notification (main.bicep:1558, because 8080 and 8081 are already the ADR-012 pair). ACA probes may target a port that ingress does not publish, so all six apps use httpGet probes and all six carry - the same three:

          + the same three (main.bicep:1200-1225 Identity, :1329-1354 Conference, :1458-1483 Engagement, + :1615-1640 Notification, :1711-1736 Gateway, :1830-1855 UI):

      @@ -1135,75 +1170,90 @@

      Probes on a dedicated HTTP/1.1 li

      warmup gate plus the DB-aware AddSqlServer check
      -

      The liveness/readiness split is the load-bearing part (main.bicep:1176-1182): /alive checks the +

      The liveness/readiness split is the load-bearing part (main.bicep:1193-1199): /alive checks the process only, so a database outage does not trigger a restart loop, while /health/ready fails when a replica cannot reach its database, pulling it out of rotation instead of letting it serve 500s. Readiness is also gated on WarmupHostedService completing (OIDC discovery fetched), so ACA holds - back user traffic until the replica is warm. Gateway and UI probe their own 8080 (main.bicep:1678-1703, - :1796-1821) because their Kestrel accepts HTTP/1.1 directly.

      + back user traffic until the replica is warm. Gateway and UI probe their own 8080 (main.bicep:1709-1710, + :1828-1829) because their Kestrel accepts HTTP/1.1 directly.

      Service Discovery (services__<name>__http__0)

      Aspire's service discovery convention uses env vars of the form services__<service-name>__http__0 to resolve service endpoints. In production these point at internal ACA hostnames:

        -
      • Gateway → all four services: conference (main.bicep:1671), identity (:1672), - engagement (:1673), notification (:1674), each as http://${<app>.name}
      • -
      • Conference → services__engagement__http__0 = http://${prefix}-engagement (main.bicep:1289) +
      • Gateway → all four services: conference (main.bicep:1704), identity (:1705), + engagement (:1706), notification (:1707), each as http://${<app>.name}
      • +
      • Conference → services__engagement__http__0 = http://${prefix}-engagement (main.bicep:1311) (using the literal ${prefix}-engagement rather than ${engagementApp.name} to avoid a Bicep symbolic cycle, Conference and Engagement both reference each other)
      • -
      • Engagement → services__conference__http__0 = http://${prefix}-conference (main.bicep:1408)
      • -
      • Notification → services__identity__http__0 = http://${identityApp.name} (main.bicep:1550)
      • -
      • Identity → services__engagement__http__0 (main.bicep:1113), for the PRIVACY.md data-subject +
      • Engagement → services__conference__http__0 = http://${prefix}-conference (main.bicep:1435)
      • +
      • Notification → services__identity__http__0 = http://${identityApp.name} (main.bicep:1582), + for the IAttendeeQueryService email-recipient lookup
      • +
      • Identity → services__engagement__http__0 (main.bicep:1125), for the PRIVACY.md data-subject export's Engagement section

      Two edges use a named endpoint rather than the default http one, because they target Notification's dedicated h2c gRPC port: services__notification__grpc__0 = http://${prefix}-notification:8081 - from Identity (main.bicep:1120) and from Engagement (main.bicep:1413). Both use the literal + from Identity (main.bicep:1132, the Notifications section of the same data-subject export) and + from Engagement (main.bicep:1440, the best-effort live-channel push). Both use the literal ${prefix}-notification name so deployment ordering stays unconstrained, since Notification itself references identityApp for its JWKS authority.

      The same service names work locally because the AppHost's WithReference injects them as services__engagement__http__0 = http://localhost:<assigned-port>. The application code calls AddHttpForwarderWithServiceDiscovery() or AddTypedGrpcClient<T>(serviceName) in both environments and resolves the endpoint from that env var key.

      -

      Identity Service specifics (main.bicep:1015-1214)

      -

      Identity is the JWT issuer and JWKS endpoint. Its JWT configuration (main.bicep:1101-1105):

      +

      Identity Service specifics (main.bicep:1026-1231)

      +

      Identity is the JWT issuer and JWKS endpoint. Its JWT configuration (main.bicep:1113-1117):

      { name: 'Jwt__SigningAlgorithm',   value: useRs256 ? 'RS256' : 'HS256' }
       { name: 'Jwt__Issuer',            value: 'https://${prefix}-gateway.${...defaultDomain}' }
       { name: 'Jwt__Audience',          value: 'AtlDevConapi' }
       { name: 'Jwt__AccessTokenExpirationMinutes', value: '15' }
       { name: 'Jwt__RefreshTokenExpirationDays',   value: '7' }

      When useRs256 = true, the RSA private key (from Key Vault) signs tokens and the public key is - published at /.well-known/jwks.json (main.bicep:1152-1157). Otherwise the HS256 branch injects - Jwt__SecretForKey and sets Jwks__Enabled: 'false' (main.bicep:1158-1162). Other services fetch + published at /.well-known/jwks.json (main.bicep:1169-1174). Otherwise the HS256 branch injects + Jwt__SecretForKey and sets Jwks__Enabled: 'false' (main.bicep:1175-1179). Other services fetch the JWKS document through the Gateway (Authentication__JwtBearer__Authority = 'http://${identityApp.name}') to validate tokens without a shared secret (ADR-004 "authentication dual-fetch"). The 15-minute access token lifetime limits the blast radius of a leaked token.

      -

      Identity is also the app that carries the avatar-storage wiring (main.bicep:1129-1130): +

      Identity is also the app that carries the avatar-storage wiring (main.bicep:1141-1142): FileStorage__ServiceUri and FileStorage__ContainerName, pointed at the storage account's blob - endpoint and the avatars container. AZURE_CLIENT_ID (main.bicep:1136) sits beside them and + endpoint and the avatars container. AZURE_CLIENT_ID (main.bicep:1148) sits beside them and pins the apps identity's client id so DefaultAzureCredential resolves the intended identity explicitly rather than relying on discovery order. That pin started here for blob access, but it is - no longer avatar-specific: four other apps now carry it for the Key Vault configuration source (see + no longer avatar-specific: four other apps carry it for the Key Vault configuration source (see the Key Vault section).

      Identity is one of the two apps that persist the DataProtection key ring - (main.bicep:1134-1135): DataProtection__BlobStorageUri points at + (main.bicep:1146-1147): DataProtection__BlobStorageUri points at <blob endpoint>dataprotection-keys/keys.xml in the private container described above, and DataProtection__ApplicationName: 'MMCA.ADC' is the isolation name the ring is scoped by (the same value on the UI, which is what makes the two apps share one ring rather than two). The comment - above them (main.bicep:1131-1133) states the failure mode: Identity does OAuth cookie + above them (main.bicep:1143-1145) states the failure mode: Identity does OAuth cookie cryptography at maxReplicas: 2 with no session affinity, so with the default per-replica in-memory ring a login started on one replica fails on the other. MMCA.Common's AddCommonDataProtection reads both keys, and DataProtection:BlobStorageUri is the gate: absent, the method does nothing and the host keeps the in-memory default, which is what local development and the tests want - (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/DataProtection/DataProtectionExtensions.cs:54-72).

      -

      Identity is sized at 0.25 CPU / 0.5 Gi (main.bicep:1063), the smallest Container Apps allocation. + (MMCA.Common/Source/Hosting/MMCA.Common.Aspire/DataProtection/DataProtectionExtensions.cs:54-62).

      +

      Identity is also the app that sends the account emails, so it receives the SMTP block + (main.bicep:1157-1161: Smtp__Host, Smtp__Port, Smtp__Username, Smtp__EnableSsl: 'true', + Smtp__From) with the password arriving separately as a secretRef only when one is configured + (main.bicep:1180, gated on hasSmtpPassword). Sitting with them is + PasswordReset__ResetUrl (main.bicep:1166), the absolute URL of the UI reset page the + forgot-password email links to + (ADR-091). It points at + the same UI origin OAuth__UIBaseUrl uses but is injected unconditionally, and the comment + above it says why (main.bicep:1162-1165): password recovery is a local-credential feature and has + to work whether or not an external OAuth provider is configured, so gating it behind hasAnyOAuth + would silently degrade the reset mail to a token-only message on any deployment without social + login.

      +

      Identity is sized at 0.25 CPU / 0.5 Gi (main.bicep:1074), the smallest Container Apps allocation. JWT operations are CPU-cheap once the key is loaded; the bottleneck is typically network I/O to SQL.

      -

      Conference Service specifics (main.bicep:1216-1338)

      -

      Conference is one of the two largest apps (0.5 CPU / 1 Gi, main.bicep:1256), reflecting its 14 REST - controllers, its AI scoring path (Anthropic API), and its role as the read-heavy entry point for - the event/session catalog. The Anthropic API key is injected only when hasAnthropic = true - (main.bicep:1242-1249, :1301):

      +

      Conference Service specifics (main.bicep:1233-1360)

      +

      Conference is one of the two largest apps (0.5 CPU / 1 Gi, main.bicep:1273), reflecting its + seventeen API controllers + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/), its AI scoring path + (Anthropic API), and its role as the read-heavy entry point for the event/session catalog. The + Anthropic API key is injected only when hasAnthropic = true (main.bicep:1259-1266, :1323):

      secrets: union(
         [ ... sql, redis and service bus ... ],
         hasAnthropic ? [{ name: 'anthropic-api-key', keyVaultUrl: ..., identity: appsIdentity.id }] : []
      @@ -1211,59 +1261,71 @@ 

      Conference Service spec

      This is the union() + conditional array pattern used throughout main.bicep to keep optional secrets and env vars out of the resource definition when not configured, rather than passing empty strings to the container.

      -

      Notification Service specifics (main.bicep:1464-1619)

      -

      Notification differs from the other three back-end services in four ways:

      +

      Notification Service specifics (main.bicep:1491-1651)

      +

      Notification differs from the other three back-end services in five ways:

      1. transport: 'http' instead of 'http2', SignalR WebSocket requires an HTTP/1.1 Upgrade - handshake (main.bicep:1484), plus the extra internal-only h2c port 8081 for gRPC - (main.bicep:1491-1497).
      2. -
      3. Its probe listener is on 8082 (main.bicep:1530), because the ADR-012 mixed profile already - owns 8080 and 8081 and those two endpoints are load-bearing.
      4. -
      5. maxReplicas: 1 (main.bicep:1616) rather than 2.
      6. + handshake (main.bicep:1511), plus the extra internal-only h2c port 8081 for gRPC + (main.bicep:1518-1524). +
      7. Its probe listener is on 8082 (main.bicep:1558), because the ADR-012 mixed profile already + owns 8080 and 8081 and those two endpoints are load-bearing (main.bicep:1553-1557).
      8. +
      9. maxReplicas: 1 (main.bicep:1648) rather than 2.
      10. It is the only app that can receive the native-push env block, and only when the hub exists - (main.bicep:1571-1575).
      11. + (main.bicep:1600-1607). +
      12. It is the second app with an SMTP block (main.bicep:1589-1593 plus the conditional + Smtp__Password secretRef at :1608 and its vault-backed secret at :1534), because the + notification service is the one that fans a notification out to email as well as to the hub.
      -

      Its readiness probe (main.bicep:1599-1607) is what holds ACA ingress until the - WarmupHostedService has fetched the JWKS document from Identity. Without it, SignalR connections - made during warmup would fail because the JWT validator is not yet initialized.

      -

      Gateway specifics (main.bicep:1621-1722)

      +

      It runs no scheduler, so unlike the other three it gets no Scheduler__PollingIntervalSeconds. + Its readiness probe (main.bicep:1631-1639) is what holds ACA ingress until the + WarmupHostedService has fetched the JWKS document from Identity (main.bicep:1610-1614). Without + it, SignalR connections made during warmup would fail because the JWT validator is not yet + initialized.

      +

      Gateway specifics (main.bicep:1653-1755)

      Gateway is the sole externally-reachable back-end entry point (external: true, - allowInsecure: false, main.bicep:1642-1647). It is a pure YARP reverse proxy: no DbContext, no - JWT issuing, no module, and secrets: [] (main.bicep:1649). Its env configuration is entirely + allowInsecure: false, main.bicep:1674-1679). It is a pure YARP reverse proxy: no DbContext, no + JWT issuing, no module, and secrets: [] (main.bicep:1681). Its env configuration is entirely service-discovery entries and CORS:

      { name: 'Cors__AllowedOrigins__0', value: 'https://${prefix}-ui.${...defaultDomain}' }
      -

      CORS is scoped to exactly the UI's FQDN (main.bicep:1665), not a wildcard. Gateway is sized at - 0.5 CPU / 1 Gi (main.bicep:1656) and uses the readiness gate at main.bicep:1694-1702 because its +

      CORS is scoped to exactly the UI's FQDN (main.bicep:1698), not a wildcard. Gateway is sized at + 0.5 CPU / 1 Gi (main.bicep:1688) and uses the readiness gate at main.bicep:1728-1735 because its warmup involves establishing connections to all back-end services. It is also the target of the - availability web test described above. It is also the one app with no KeyVault__Uri: holding no + availability web test described above, and the only app with no KeyVault__Uri: holding no secret, it has no vault to read.

      -

      UI specifics (main.bicep:1724-1839)

      -

      UI is the other externally-reachable app (external: true, main.bicep:1745), also with - secrets: [] (main.bicep:1754) and sized at 0.25 CPU / 0.5 Gi (main.bicep:1761). Three +

      The template also records the transport contract the Gateway holds up (main.bicep:1699-1702): + ForwardHttp2 defaults to true in the gateway code and YARP uses VersionPolicy=RequestVersionExact, + so it sends the HTTP/2 preface to the three h2c-prior-knowledge backends whose ACA ingress is + transport: http2. That pairing is why the ingress choice on those three services and the forwarder + policy here cannot be changed independently.

      +

      UI specifics (main.bicep:1757-1874)

      +

      UI is the other externally-reachable app (external: true, main.bicep:1778), also with + secrets: [] (main.bicep:1787) and sized at 0.25 CPU / 0.5 Gi (main.bicep:1794). Three non-obvious configuration points:

      -

      Sticky sessions (main.bicep:1749-1751):

      +

      Sticky sessions (main.bicep:1782-1784):

      stickySessions: { affinity: 'sticky' }

      Blazor Server runs the component model as a stateful SignalR circuit on the server. If a request from a browser is load-balanced to a different replica than the one holding the circuit, the - circuit drops. Sticky session affinity pins each browser session to one replica.

      -

      Dual API endpoints (main.bicep:1771-1774):

      + circuit drops. Sticky session affinity pins each browser session to one replica. The header comment + on the resource (main.bicep:1760-1762) states both Blazor Server requirements together: sticky + sessions and minReplicas >= 1.

      +

      Dual API endpoints (main.bicep:1806, :1808):

      { name: 'Api__ApiEndpoint',     value: 'http://${gatewayApp.name}' }
       { name: 'Api__WasmApiEndpoint', value: 'https://${gatewayApp.properties.configuration.ingress.fqdn}' }

      Server-side Blazor rendering uses the internal Gateway URL (skipping public DNS, TLS termination, and the Envoy round-trip). WebAssembly code running in the browser must use the external FQDN, it has no access to the internal ACA DNS. The UI serves the WASM endpoint URL via a /client-config endpoint so the WASM app can discover the gateway without the URL being baked into the WASM build.

      -

      Shared DataProtection key ring (main.bicep:1780-1781): the UI carries the same +

      Shared DataProtection key ring (main.bicep:1814-1815): the UI carries the same DataProtection__BlobStorageUri and DataProtection__ApplicationName: 'MMCA.ADC' pair as Identity, pointed at the same dataprotection-keys/keys.xml blob. The reason is the one above with the consequence reversed: sticky sessions pin a circuit to a replica, but the UI also mints the SSR session cookie and antiforgery tokens, and those travel with the browser rather than with the circuit, so at maxReplicas: 2 a per-replica in-memory ring makes them undecryptable on the other - replica (main.bicep:1775-1777). AZURE_CLIENT_ID (main.bicep:1782) pins the identity that + replica (main.bicep:1809-1813). AZURE_CLIENT_ID (main.bicep:1816) pins the identity that DefaultAzureCredential uses for both the blob write and the vault read.

      -

      The UI receives only the OAuth client ids when a provider is configured (main.bicep:1787-1792); +

      The UI receives only the OAuth client ids when a provider is configured (main.bicep:1821-1826); the client secrets stay on Identity, which is the app that completes the exchange.

      -

      Outputs (main.bicep:1842-1850)

      +

      Outputs (main.bicep:1876-1884)

      output acrLoginServer     string = acr.properties.loginServer
       output gatewayFqdn        string = gatewayApp.properties.configuration.ingress.fqdn
       output uiFqdn             string = uiApp.properties.configuration.ingress.fqdn
      @@ -1274,11 +1336,13 @@ 

      Outputs (main.bicep:1842-1850)< the deployed revision. That step probes every service through the Gateway, and for the two auth-gated endpoints the asserted status is exactly 401, not 2xx: an anonymous request must be rejected by the service, which only happens when the service is up and serving - (deploy.yml:1130-1133). On failure it rolls every app back to its previous revision and still - fails the job, and it reports separately when a rollback itself failed, so a partially rolled-back - fleet never looks like a clean auto-revert (deploy.yml:1151-1178).

      + (deploy.yml:1130-1133). A security-headers check rides along but is explicitly informational + (deploy.yml:1137-1144): a missing hardening header is not a "revision not serving" failure and + must not trip the fleet-wide rollback. On a real failure it rolls every app back to its previous + revision and still fails the job, and it reports separately when a rollback itself failed, so a + partially rolled-back fleet never looks like a clean auto-revert (deploy.yml:1151-1178).

      sqlServerFqdn is an output of main.bicep (each service connects to its own - database via the per-service connection strings written into Key Vault; deploy.yml itself no longer runs + database via the per-service connection strings written into Key Vault; deploy.yml itself does not run sqlcmd against the server, migrations are applied by the services at startup). The cutover-per-service-dbs.yml workflow discovers the SQL FQDN independently for the one-time data migration.


      @@ -1322,7 +1386,7 @@

      Rubric category cross-reference

      §11 Security - UAMI/OIDC model; Key Vault-backed secrets (ADR-061) plus the KeyVault__Uri configuration source on five of six apps; secrets: [] on Gateway and UI; adminUserEnabled: false; @secure() parameters; staged useManagedIdentitySql; private dataprotection-keys container for the shared key ring (at-rest key-vault encryption of that ring is an explicit not-yet-implemented follow-up); no static credentials + UAMI/OIDC model; Key Vault-backed secrets (ADR-061) plus the KeyVault__Uri configuration source on five of six apps; secrets: [] on Gateway and UI; adminUserEnabled: false; @secure() parameters; staged useManagedIdentitySql; private dataprotection-keys container for the shared key ring (at-rest key-vault encryption of that ring is an explicit not-yet-implemented follow-up); the scoped RequireHttpsMetadata: false on the three internal JWKS consumers; no static credentials §13 Observability @@ -1338,14 +1402,14 @@

      Rubric category cross-reference

      §31 Cost Efficiency / FinOps - commonTags on every resource; monthly budget with 80%/100% thresholds; cost-guard.yml surge-drift gate; workspace dailyQuotaGb: 1; 25% trace sampling; Warning OTel log floor; Basic-tier DB sizing; 300s outbox and scheduler polls; the two disabled metric groups; the daily ACR image-purge task + commonTags on every resource; monthly budget with 80%/100% thresholds; cost-guard.yml surge-drift gate; workspace dailyQuotaGb: 1; 25% trace sampling; Warning OTel log floor; Basic-tier DB sizing; 300s outbox and scheduler polls; the two disabled metric groups plus the 300s metric export interval; the daily 3-day/keep-3 ACR image-purge task

      Not determinable from source

      • The exact AcrPull and Key Vault Secrets User role-assignment commands used in the out-of- - band bootstrap are referenced in comments (main.bicep:915-919, main.bicep:933-936) but the + band bootstrap are referenced in comments (main.bicep:926-930, main.bicep:944-947) but the commands themselves live in infra/DISASTER-RECOVERY.md, which is private to the ADC repo and out of scope for this chapter. A distilled version is published in the framework's reference runbook, MMCA.Common/samples/deployment/DEPLOYMENT.md.
      • @@ -1356,7 +1420,9 @@

        Not determinable from source

        even though the template default says otherwise. Treat the template as the shape and the repository variables as the state; neither alone tells you what production is doing. The same split applies to AZURE_RESOURCE_GROUP and AZURE_SQL_LOCATION, whose fallbacks (acc-rg, - westus2) appear only in workflow comments and defaults. + westus2) appear only in workflow comments and defaults, and to the whole SMTP_* set + (deploy.yml:923-927), which decides whether the SMTP env block on Identity and Notification + carries a real relay or empty strings.
      • The azure/arm-deploy@v2 action's deploymentMode is not set explicitly in deploy.yml (deploy.yml:773-779 for foundation, deploy.yml:1070-1076 for main), the action defaults to Incremental, but this is not stated in the workflow file; it is inferred from the Incremental intent diff --git a/docs/onboarding/group-03-querying-specifications.html b/docs/onboarding/group-03-querying-specifications.html index 8e7e4de..32253a9 100644 --- a/docs/onboarding/group-03-querying-specifications.html +++ b/docs/onboarding/group-03-querying-specifications.html @@ -155,35 +155,36 @@

        3. Queryi

        The split matters: specifications are trusted and live with the domain, dynamic filters are untrusted and are validated, capped, and reflection-cached at the application boundary.

        The Specification pattern, the trusted predicate

        ISpecification<TEntity, TIdentifierType> (MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/ISpecification.cs:12) exposes two faces of one rule: a Criteria expression tree that EF Core translates to SQL, so the filter runs in the database rather than in memory after a full-table load (ISpecification.cs:17), and IsSatisfiedBy(entity) for in-memory evaluation (ISpecification.cs:22). The abstract base Specification<TEntity, TIdentifierType> (MMCA.Common/Source/Core/MMCA.Common.Domain/Specifications/Specification.cs:15) leaves Criteria abstract (Specification.cs:23), compiles it lazily on first use, and caches the delegate in a private field (Specification.cs:27, Specification.cs:32), so repeated in-memory checks do not recompile the tree.

        -

        The three combinators, AndSpecification<TEntity, TIdentifierType> (Specification.cs:81), OrSpecification<TEntity, TIdentifierType> (Specification.cs:105), and NotSpecification<TEntity, TIdentifierType> (Specification.cs:128), each delegate to the internal SpecificationComposer (Specification.cs:146) and cache the composed expression in a per-instance field rather than rebuilding it on every Criteria read (Specification.cs:88, Specification.cs:112, Specification.cs:134), because the pipeline reads Criteria at least once per request. Combine (Specification.cs:155) takes the left lambda's own parameter (Specification.cs:167), rebinds the right-hand body onto it, and joins the two with Expression.AndAlso or Expression.OrElse before closing the lambda (Specification.cs:169-173); Negate (Specification.cs:181) wraps the body in Expression.Not while keeping the inner lambda's parameter (Specification.cs:189-191). The rebinding is done by ParameterReplacer (MMCA.Common/Source/Core/MMCA.Common.Domain/Specifications/ParameterReplacer.cs:24), an ExpressionVisitor whose static Replace short-circuits when the two parameters are already the same instance (ParameterReplacer.cs:34, ParameterReplacer.cs:40) and whose VisitParameter swaps the rest (ParameterReplacer.cs:44). Composing by substitution rather than Expression.Invoke is a deliberate portability decision: an InvocationExpression survives into the query tree and several providers (Cosmos among them) refuse to translate one, so an ANDed specification used to fail on exactly the engines the framework is meant to be portable across (Specification.cs:66-69, ParameterReplacer.cs:12-15). The visitor is internal and reaches the Application layer through InternalsVisibleTo so the cross-source builder shares one copy rather than carrying its own (ParameterReplacer.cs:18-23). SpecificationExtensions (MMCA.Common/Source/Core/MMCA.Common.Domain/Specifications/SpecificationExtensions.cs:30) puts a fluent face on those three, as extension<TEntity, TIdentifierType> members (SpecificationExtensions.cs:32) exposing And (SpecificationExtensions.cs:48), Or (SpecificationExtensions.cs:68), and Not (SpecificationExtensions.cs:85), so a composed predicate reads left to right instead of inside out.

        +

        The three combinators, AndSpecification<TEntity, TIdentifierType> (Specification.cs:81), OrSpecification<TEntity, TIdentifierType> (Specification.cs:105), and NotSpecification<TEntity, TIdentifierType> (Specification.cs:128), each delegate to the internal SpecificationComposer (Specification.cs:146) and cache the composed expression in a per-instance _criteria field rather than rebuilding it on every Criteria read (Specification.cs:88-93, Specification.cs:112-117, Specification.cs:134-135), because the pipeline reads Criteria at least once per request. Combine (Specification.cs:155) takes the left lambda's own parameter (Specification.cs:167), rebinds the right-hand body onto it, and joins the two with Expression.AndAlso or Expression.OrElse before closing the lambda (Specification.cs:169-173); Negate (Specification.cs:181) wraps the body in Expression.Not while keeping the inner lambda's parameter (Specification.cs:189-191). The rebinding is done by ParameterReplacer (MMCA.Common/Source/Core/MMCA.Common.Domain/Specifications/ParameterReplacer.cs:24), an ExpressionVisitor whose static Replace short-circuits when the two parameters are already the same instance (ParameterReplacer.cs:34, ParameterReplacer.cs:40) and whose VisitParameter swaps the rest (ParameterReplacer.cs:44). Composing by substitution rather than Expression.Invoke is a deliberate portability decision: an InvocationExpression survives into the query tree and several providers (Cosmos among them) refuse to translate one, so an ANDed specification failed on exactly the engines the framework is meant to be portable across (Specification.cs:66-69, ParameterReplacer.cs:12-16). The visitor is internal and reaches the Application layer through InternalsVisibleTo so the cross-source builder shares one copy rather than carrying its own (ParameterReplacer.cs:19-23). SpecificationExtensions (MMCA.Common/Source/Core/MMCA.Common.Domain/Specifications/SpecificationExtensions.cs:30) puts a fluent face on those three, as extension<TEntity, TIdentifierType> members (SpecificationExtensions.cs:32) exposing And (SpecificationExtensions.cs:48), Or (SpecificationExtensions.cs:68), and Not (SpecificationExtensions.cs:85), so a composed predicate reads left to right instead of inside out.

        Concrete specifications are how a controller scopes a query to allowed data without trusting the request to do it. ADC has two, both one-liners: PublishedEventSpecification is e => e.IsPublished (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Specifications/PublishedEventSpecification.cs:11, criteria at PublishedEventSpecification.cs:14), and PublicSessionStatusSpecification allows the public session-status list (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/Specifications/PublicSessionStatusSpecification.cs:20), exposing its predicate as a public static readonly expression (PublicSessionStatusSpecification.cs:23) so the cross-source filter and the visible-session id resolver share one definition rather than each re-deriving BR-49 (Criteria simply returns it at PublicSessionStatusSpecification.cs:27). The framework also ships one ready-made scope: OwnedByUserSpecification<TEntity, TIdentifierType> (MMCA.Common/Source/Core/MMCA.Common.Domain/Specifications/OwnedByUserSpecification.cs:20) filters on the audit field CreatedBy as the ownership marker (OwnedByUserSpecification.cs:29-30), and its constraint is deliberately the concrete AuditableBaseEntity<TIdentifierType> rather than an IAuditableEntity interface, because a member access declared on an interface is not guaranteed to map to the entity's audit column and the criteria must stay EF-translatable (OwnedByUserSpecification.cs:12-16). ADC's question-answer controllers are its callers, and they show the intended shape: an organizer gets null (no scoping at all), everyone else gets the specification bound to their own user id (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:67-68, and the same pair at SessionQuestionAnswersController.cs:67-68). This is [Rubric §4, Domain-Driven Design] (the rule is a first-class, reusable domain object) and [Rubric §2, Design Patterns] (a textbook Specification), with a [Rubric §11, Security] overtone: an authorization predicate is server-supplied criteria the client cannot tamper with.

        -

        Two members round the family out for polyglot persistence (ADR-018). InlineSpecification<TEntity, TIdentifierType> (Specification.cs:45) wraps an already-composed Criteria expression as a first-class specification (Specification.cs:51-52), for predicates built at runtime where no hand-written class exists. The static CrossSourceSpecification (MMCA.Common/Source/Core/MMCA.Common.Application/Specifications/CrossSourceSpecification.cs:22) builds the cross-source filter: when a dependent entity references a principal that lives in a different physical data source (database-per-service, ADR-006), a navigating predicate like s => s.Event.IsPublished cannot be translated, so BuildAsync (CrossSourceSpecification.cs:39) first projects the matching principal keys from the principal's own source through the read repository's GetProjectedAsync (CrossSourceSpecification.cs:55-56), materializes them once (CrossSourceSpecification.cs:60), and returns an InlineSpecification (CrossSourceSpecification.cs:62) whose body is an Enumerable.Contains(keys, dependent.ForeignKey) call that translates to IN or ARRAY_CONTAINS (CrossSourceSpecification.cs:74-79). An optional local predicate on the dependent's own columns is rebound onto the foreign-key selector's parameter by the shared ParameterReplacer (CrossSourceSpecification.cs:86) and ANDed in (CrossSourceSpecification.cs:87), again without Expression.Invoke so the combined predicate stays translatable on every provider (CrossSourceSpecification.cs:83-85). The doc comment is explicit about the limit: the keys are materialized and embedded in the predicate, so the shape fits bounded principal sets (CrossSourceSpecification.cs:17-20). ADC uses it in production on both of its Session reads, each passing PublicSessionStatusSpecification.StatusCriteria as the local predicate: GetPublicSessionFilterHandler returns the specification as a query result (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/GetPublicSessionFilter/GetPublicSessionFilterHandler.cs:29-36), and PublicConferenceVisibility uses the same criteria to resolve the visible session ids so a session hidden from the session list cannot stay reachable through a speaker or junction read (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:62-74). The convention this exists to serve is guarded by an opt-in fitness rule, ArchitectureRules.SpecificationsDoNotNavigateToOtherEntities (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Specifications.cs:24), which analyzes only the parameterless specifications it can instantiate (ArchitectureRules.Specifications.cs:38-41) and is exposed to repos as the single-fact base SpecificationConventionTestsBase (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/SpecificationConventionTestsBase.cs:10, the fact at SpecificationConventionTestsBase.cs:16), which is [Rubric §14, Testability] applied to an architectural rule.

        +

        Two members round the family out for polyglot persistence (ADR-018). InlineSpecification<TEntity, TIdentifierType> (Specification.cs:45) wraps an already-composed Criteria expression as a first-class specification (Specification.cs:51-52), for predicates built at runtime where no hand-written class exists. The static CrossSourceSpecification (MMCA.Common/Source/Core/MMCA.Common.Application/Specifications/CrossSourceSpecification.cs:22) builds the cross-source filter: when a dependent entity references a principal that lives in a different physical data source (database-per-service, ADR-006), a navigating predicate like s => s.Event.IsPublished cannot be translated, so BuildAsync (CrossSourceSpecification.cs:39) first projects the matching principal keys from the principal's own source through the read repository's GetProjectedAsync (CrossSourceSpecification.cs:55-56), materializes them once (CrossSourceSpecification.cs:60), and returns an InlineSpecification (CrossSourceSpecification.cs:62) whose body is an Enumerable.Contains(keys, dependent.ForeignKey) call that translates to IN or ARRAY_CONTAINS (CrossSourceSpecification.cs:74-79). An optional local predicate on the dependent's own columns is rebound onto the foreign-key selector's parameter by the shared ParameterReplacer (CrossSourceSpecification.cs:86) and ANDed in (CrossSourceSpecification.cs:87), again without Expression.Invoke so the combined predicate stays translatable on every provider (CrossSourceSpecification.cs:83-85). The doc comment is explicit about the limit: the keys are materialized and embedded in the predicate, so the shape fits bounded principal sets (CrossSourceSpecification.cs:17-20). ADC uses it in production on both of its Session reads, each passing PublicSessionStatusSpecification.StatusCriteria as the local predicate: GetPublicSessionFilterHandler returns the specification as a query result (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/GetPublicSessionFilter/GetPublicSessionFilterHandler.cs:29-36), and PublicConferenceVisibility uses the same criteria to resolve the visible session ids so a session hidden from the session list cannot stay reachable through a speaker or junction read (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:63-69). The convention this exists to serve is guarded by an opt-in fitness rule, ArchitectureRules.SpecificationsDoNotNavigateToOtherEntities (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Specifications.cs:24), which analyzes only the parameterless specifications it can instantiate (ArchitectureRules.Specifications.cs:38-41) and is exposed to repos as the single-fact base SpecificationConventionTestsBase (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/SpecificationConventionTestsBase.cs:10, the fact at SpecificationConventionTestsBase.cs:15-17), which is [Rubric §14, Testability] applied to an architectural rule.

        QuerySpecification, a whole read in one object

        A plain specification is only a predicate, which leaves includes, ordering, paging, and tracking to be threaded through every layer as loose arguments. QuerySpecification<TEntity, TIdentifierType> (MMCA.Common/Source/Core/MMCA.Common.Domain/Specifications/QuerySpecification.cs:38) carries them instead. State is exposed read-only (OrderBy at QuerySpecification.cs:54, IncludePaths at QuerySpecification.cs:60, Skip and Take at QuerySpecification.cs:63 and QuerySpecification.cs:66, AsTracking at QuerySpecification.cs:72, IgnoreQueryFilters at QuerySpecification.cs:82) and assembled through protected builder methods a derived specification calls from its constructor: AddOrderBy (QuerySpecification.cs:90), AddInclude (QuerySpecification.cs:102, which ignores blanks and duplicates), ApplyPaging (QuerySpecification.cs:117, both values floored at zero at QuerySpecification.cs:119-120), WithTracking (QuerySpecification.cs:127), and WithSoftDeleted (QuerySpecification.cs:133). Two design notes are worth carrying forward. The base chain stays QuerySpecification over Specification on purpose, because the fitness rule above keys on that base-type prefix and on a property literally named Criteria (QuerySpecification.cs:29-34). And WithSoftDeleted drops the named SoftDelete global query filter and only that one, so a specification asking for deleted rows can never reach another tenant's data (QuerySpecification.cs:74-81). Each ordering key is an OrderExpression (QuerySpecification.cs:150), a record of an untyped LambdaExpression plus a descending flag, declared top-level rather than nested inside the generic class because a nested type is a different type per closed generic, which would stop the repository evaluator from handling an ordering list generically (QuerySpecification.cs:140-144).

        That object is consumed on the persistence side, not by the pipeline in this chapter. The repository's ListAsync(specification) (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:151) takes any ISpecification, and SpecificationEvaluator (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/SpecificationEvaluator.cs:20) applies the Criteria always (SpecificationEvaluator.cs:46) and the rest only when the instance is a QuerySpecification (SpecificationEvaluator.cs:48-58). Tracking and soft-delete scope are deliberately not applied there: those choose the base queryable, which only the repository can do, in EFReadRepository<TEntity, TIdentifierType>'s BaseQueryFor (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:312-321), with the concrete reads at EFReadRepository.cs:324 and EFReadRepository.cs:337. Aggregate reads (count, exists) pass applyShape: false so counting does not join in includes or count "page 3 of the matches" (SpecificationEvaluator.cs:29-34). Includes go through one shared helper that also opts the query into split-query mode whenever any include targets a collection navigation (SpecificationEvaluator.cs:77, the decision at SpecificationEvaluator.cs:93), so the string-include path and the specification path cannot drift apart (SpecificationEvaluator.cs:69-72).

        Dynamic filtering, one Strategy per CLR type

        -

        User filters arrive as a Dictionary<string, (string Operator, string Value)>, property name to operator key plus raw string value, parsed from the query string by QueryFilterModelBinder at the API edge, which caps a single request at MaxFilters = 50 distinct properties (MMCA.Common/Source/Presentation/MMCA.Common.API/ModelBinders/QueryFilterModelBinder.cs:34, enforced at QueryFilterModelBinder.cs:61, where surplus entries are dropped rather than rejected). Turning ("Name", "CONTAINS", "blazor") into a .Where() clause depends entirely on the property's CLR type, so instead of one large switch each type gets an IFilterStrategy (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Filtering/IFilterStrategy.cs:6) declaring an Apply method (IFilterStrategy.cs:17), the operator set it supports (IFilterStrategy.cs:24, where the default SupportedOperators is null, meaning operator validation is skipped for custom strategies), and a CanParseValue predicate that defaults to true (IFilterStrategy.cs:44). That last member exists because Apply fails open: a strategy that cannot parse a value silently returns the query unfiltered, so ?filter=id:equals:abc used to return the whole result set instead of no matches (IFilterStrategy.cs:32-38). Validating the value up front turns that into a 400, and the default of true keeps a custom strategy behaving exactly as before until it opts in.

        -

        The seven built-ins each override SupportedOperators with a FrozenSet: StringFilterStrategy (StringFilterStrategy.cs:12, operators at StringFilterStrategy.cs:14-18: CONTAINS, NOT CONTAINS, EQUALS, NOT EQUALS, STARTS WITH, ENDS WITH, IS EMPTY, IS NOT EMPTY, IN), IntFilterStrategy (IntFilterStrategy.cs:15), LongFilterStrategy (LongFilterStrategy.cs:14), and DecimalFilterStrategy (DecimalFilterStrategy.cs:14), which share one numeric set (equality, the four comparisons, IN, an inclusive BETWEEN range, and the two presence checks, at IntFilterStrategy.cs:17-22 and its two siblings, all parsing invariant-culture), DateTimeFilterStrategy (DateTimeFilterStrategy.cs:13: IS, IS NOT, IS AFTER, IS ON OR AFTER, IS BEFORE, IS ON OR BEFORE, the two presence checks, IN, and BETWEEN at DateTimeFilterStrategy.cs:17-22, all parsed with CultureInfo.InvariantCulture at DateTimeFilterStrategy.cs:15), BoolFilterStrategy (BoolFilterStrategy.cs:12: IS plus the two presence checks, BoolFilterStrategy.cs:14-17), and GuidFilterStrategy (GuidFilterStrategy.cs:13: EQUALS, NOT EQUALS, IN, and the two presence checks at GuidFilterStrategy.cs:15-18; GUIDs have no ordering, so no comparisons). Every value-typed strategy implements CanParseValue by delegating to one shared rule (IntFilterStrategy.cs:25, LongFilterStrategy.cs:24, DecimalFilterStrategy.cs:24, DateTimeFilterStrategy.cs:25, BoolFilterStrategy.cs:20, GuidFilterStrategy.cs:21); StringFilterStrategy declares none, because any string parses. That shared rule lives in the internal FilterValueParser (FilterValueParser.cs:8): CanParse (FilterValueParser.cs:53) says presence checks ignore the value, IN needs at least one parseable item, BETWEEN needs exactly two bounds, and every other operator needs the single scalar to parse (FilterValueParser.cs:58-64). BETWEEN gets its own stricter check (FilterValueParser.cs:76) that keeps empty and unparseable segments in play, because dropping them let "5,abc,10" and "5,,10" validate as a two-bound range and the strategies then applied a pair the caller never asked for (FilterValueParser.cs:70-75). The same class decodes the lists at apply time: ParseList<T> skips unparseable entries rather than failing the request (FilterValueParser.cs:17, the if (parse(part) is { } parsed) guard at FilterValueParser.cs:26), and ParseStringList splits on comma, trimming and dropping empty entries (FilterValueParser.cs:34).

        -

        Every clause is built through System.Linq.Dynamic.Core string predicates with parameter placeholders (@0), never string-concatenated values, and every call site passes the one shared DynamicQueryConfig.Parameterized parsing config (DynamicQueryConfig.cs:18, the instance at DynamicQueryConfig.cs:21-24). That flag is not cosmetic: Dynamic LINQ defaults UseParameterizedNamesInDynamicQuery to false, which turns each @0 into a ConstantExpression that EF inlines, so one filter value produced one distinct SQL string, one SQL Server plan-cache entry per value, and an EF compiled-query cache miss on every request (DynamicQueryConfig.cs:8-15). With the flag on the value is reached through a member access and EF parameterizes it, and QueryParameterizationTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/QueryParameterizationTests.cs:26) is the regression guard, the only test in the suite that inspects the emitted SQL. This is [Rubric §12, Performance & Scalability] hiding inside a one-property config object.

        -

        The static QueryFilterService (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Filtering/QueryFilterService.cs:19) is the registry and dispatcher. It seeds a ConcurrentDictionary<Type, IFilterStrategy> with the built-ins, registering both the value type and its Nullable<> form (QueryFilterService.cs:29-45), keeps a dedicated string instance for string properties and for dotted paths whose leaf type cannot be resolved (QueryFilterService.cs:52, fallback at QueryFilterService.cs:280-283), and exposes RegisterStrategy so a module can add a custom type without touching framework code (QueryFilterService.cs:60, the open/closed principle made literal, [Rubric §1, SOLID]). Reflection is memoized per (entity type, property name) but hits only (QueryFilterService.cs:27, LookupProperty at QueryFilterService.cs:235-246): the probed names come from the client's query string, so caching misses would let any caller grow a never-evicted static dictionary simply by filtering on names that do not exist, while the request still gets a clean 400 (QueryFilterService.cs:214-221). One shared resolver, ResolvePropertyInfo (QueryFilterService.cs:223), backs both phases so they cannot disagree about what resolves; they used to, and a plain rename entry passed validation and was then silently dropped, returning an unfiltered 200 (QueryFilterService.cs:208-213). A dotted path like "Category.Name" is walked segment by segment to its leaf type by ResolveFilterValueType (QueryFilterService.cs:259), so the leaf's own strategy validates the operator instead of every nested path defaulting to the string strategy (QueryFilterService.cs:153-157).

        -

        The two phases and their ordering are the security story. ValidateFilters (QueryFilterService.cs:111) runs before the query and returns a Result carrying every Error it found: Filter.Property.NotFound (QueryFilterService.cs:143-144), Filter.Type.NotSupported (QueryFilterService.cs:163-164), Filter.Operator.NotSupported (QueryFilterService.cs:295-296), and Filter.Value.Invalid (QueryFilterService.cs:196-197), the last suppressed when the operator itself was already rejected so one mistake does not produce two errors (QueryFilterService.cs:191). A bad filter is therefore a validation failure, not a SQL exception and not a silently widened result set. ApplyFilters (QueryFilterService.cs:76) then builds the actual .Where() chain, resolving the DTO name through the property map first (QueryFilterService.cs:84-86) and skipping any property it cannot resolve (QueryFilterService.cs:90-91). Strategy dispatch plus allow-listing untrusted input against real entity metadata is [Rubric §2, Design Patterns] and [Rubric §11, Security] together.

        +

        User filters arrive as a Dictionary<string, (string Operator, string Value)>, property name to operator key plus raw string value, parsed from the query string by QueryFilterModelBinder at the API edge, which caps a single request at MaxFilters = 50 distinct properties (MMCA.Common/Source/Presentation/MMCA.Common.API/ModelBinders/QueryFilterModelBinder.cs:34, enforced at QueryFilterModelBinder.cs:61, where surplus entries are dropped rather than rejected). Turning ("Name", "CONTAINS", "blazor") into a .Where() clause depends entirely on the property's CLR type, so instead of one large switch each type gets an IFilterStrategy (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Filtering/IFilterStrategy.cs:6) declaring an Apply method (IFilterStrategy.cs:17), the operator set it supports (IFilterStrategy.cs:24, where the default SupportedOperators is null, meaning operator validation is skipped for custom strategies), and a CanParseValue predicate that defaults to true (IFilterStrategy.cs:44). That last member exists because Apply fails open: a strategy that cannot parse a value silently returns the query unfiltered, so ?filter=id:equals:abc returned the whole result set instead of no matches (IFilterStrategy.cs:32-38). Validating the value up front turns that into a 400, and the default of true keeps a custom strategy behaving exactly as before until it opts in.

        +

        The seven built-ins each override SupportedOperators with a FrozenSet: StringFilterStrategy (StringFilterStrategy.cs:12, operators at StringFilterStrategy.cs:14-18: CONTAINS, NOT CONTAINS, EQUALS, NOT EQUALS, STARTS WITH, ENDS WITH, IS EMPTY, IS NOT EMPTY, IN), the numeric trio IntFilterStrategy (IntFilterStrategy.cs:15), LongFilterStrategy (LongFilterStrategy.cs:14), and DecimalFilterStrategy (DecimalFilterStrategy.cs:14), which share one operator set (equality, the four comparisons, IN, an inclusive BETWEEN range, and the two presence checks, at IntFilterStrategy.cs:17-22 and its two siblings, all parsing invariant-culture), DateTimeFilterStrategy (DateTimeFilterStrategy.cs:13: IS, IS NOT, IS AFTER, IS ON OR AFTER, IS BEFORE, IS ON OR BEFORE, the two presence checks, IN, and BETWEEN at DateTimeFilterStrategy.cs:17-22, parsed with CultureInfo.InvariantCulture at DateTimeFilterStrategy.cs:15), BoolFilterStrategy (BoolFilterStrategy.cs:12: IS plus the two presence checks, BoolFilterStrategy.cs:14-17), and GuidFilterStrategy (GuidFilterStrategy.cs:13: EQUALS, NOT EQUALS, IN, and the two presence checks at GuidFilterStrategy.cs:15-18; GUIDs have no ordering, so no comparisons). Every value-typed strategy implements CanParseValue by delegating to one shared rule (IntFilterStrategy.cs:25, LongFilterStrategy.cs:24, DecimalFilterStrategy.cs:24, DateTimeFilterStrategy.cs:25, BoolFilterStrategy.cs:20, GuidFilterStrategy.cs:21); StringFilterStrategy declares none, because any string parses. That shared rule lives in the internal FilterValueParser (FilterValueParser.cs:8): CanParse (FilterValueParser.cs:53) says presence checks ignore the value, IN needs at least one parseable item, BETWEEN needs exactly two bounds, and every other operator needs the single scalar to parse (FilterValueParser.cs:58-64). BETWEEN gets its own stricter check (FilterValueParser.cs:76) that keeps empty and unparseable segments in play, because dropping them let "5,abc,10" and "5,,10" validate as a two-bound range and the strategies then applied a pair the caller never asked for (FilterValueParser.cs:70-75). The same class decodes the lists at apply time: ParseList<T> skips unparseable entries rather than failing the request (FilterValueParser.cs:17, the if (parse(part) is { } parsed) guard at FilterValueParser.cs:26), and ParseStringList splits on comma, trimming and dropping empty entries (FilterValueParser.cs:34).

        +

        Every clause is built through System.Linq.Dynamic.Core string predicates with parameter placeholders (@0), never string-concatenated values, and every call site passes the one shared DynamicQueryConfig.Parameterized parsing config (DynamicQueryConfig.cs:18, the instance at DynamicQueryConfig.cs:21-24). That flag is not cosmetic: Dynamic LINQ defaults UseParameterizedNamesInDynamicQuery to false, which turns each @0 into a ConstantExpression that EF inlines, so one filter value produces one distinct SQL string, one SQL Server plan-cache entry per value, and an EF compiled-query cache miss on every request (DynamicQueryConfig.cs:8-16). With the flag on the value is reached through a member access and EF parameterizes it, and QueryParameterizationTests (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/QueryParameterizationTests.cs:26) is the regression guard, the only test in the suite that inspects the emitted SQL. This is [Rubric §12, Performance & Scalability] hiding inside a one-property config object.

        +

        The static QueryFilterService (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Filtering/QueryFilterService.cs:19) is the registry and dispatcher. It seeds a ConcurrentDictionary<Type, IFilterStrategy> with the built-ins, registering both the value type and its Nullable<> form (QueryFilterService.cs:29-45), keeps a dedicated string instance for string properties and for dotted paths whose leaf type cannot be resolved (QueryFilterService.cs:52, fallback at QueryFilterService.cs:280-283), and exposes RegisterStrategy so a module can add a custom type without touching framework code (QueryFilterService.cs:60, the open/closed principle made literal, [Rubric §1, SOLID]). Reflection is memoized per (entity type, property name) but hits only (QueryFilterService.cs:27, LookupProperty at QueryFilterService.cs:235): the probed names come from the client's query string, so caching misses would let any caller grow a never-evicted static dictionary simply by filtering on names that do not exist, while the request still gets a clean 400 (QueryFilterService.cs:214-221). One shared resolver, ResolvePropertyInfo (QueryFilterService.cs:223), backs both phases so they cannot disagree about what resolves; when they did, a plain rename entry passed validation and was then silently dropped, returning an unfiltered 200 (QueryFilterService.cs:208-213). A dotted path like "Category.Name" is walked segment by segment to its leaf type by ResolveFilterValueType (QueryFilterService.cs:259), so the leaf's own strategy validates the operator instead of every nested path defaulting to the string strategy (QueryFilterService.cs:153-157).

        +

        The two phases and their ordering are the security story. ValidateFilters (QueryFilterService.cs:111) runs before the query and returns a Result carrying every Error it found: Filter.Property.NotFound (QueryFilterService.cs:143-147), Filter.Type.NotSupported (QueryFilterService.cs:163-167), Filter.Operator.NotSupported (QueryFilterService.cs:295-299), and Filter.Value.Invalid (QueryFilterService.cs:196-200), the last suppressed when the operator itself was already rejected so one mistake does not produce two errors (QueryFilterService.cs:191-192). A bad filter is therefore a validation failure, not a SQL exception and not a silently widened result set. ApplyFilters (QueryFilterService.cs:76) then builds the actual .Where() chain, resolving the DTO name through the property map first (QueryFilterService.cs:84-86) and skipping any property it cannot resolve (QueryFilterService.cs:90-91). Strategy dispatch plus allow-listing untrusted input against real entity metadata is [Rubric §2, Design Patterns] and [Rubric §11, Security] together.

        Sorting, sparse fieldsets, and paging arithmetic

        QueryFieldService (MMCA.Common/Source/Core/MMCA.Common.Application/Services/QueryFieldService.cs:16) owns the rest of read shaping. ApplySorting (QueryFieldService.cs:155) resolves a DTO sort name through the server-authored map, and otherwise accepts the column only when it names a real public property of the entity (QueryFieldService.cs:190-202), falling back to the optional default sort when it does not (QueryFieldService.cs:172-178). That guard is deliberate and documented in the summary (QueryFieldService.cs:120-127): a client-supplied string can never reach Dynamic LINQ to order by nested paths or expressions the DTO does not expose. Map entries, being server-authored, may be expressions: ADC sorts speakers by FullName through an entry that concatenates first and last name (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/SpeakerEntityQueryService.cs:28-31, wired in by overriding the map at SpeakerEntityQueryService.cs:34). The method also takes an optional tieBreakProperty appended as a final ascending key (QueryFieldService.cs:161, applied by BuildOrdering at QueryFieldService.cs:209, and used alone when no valid sort column was given at QueryFieldService.cs:180-182). It exists because Skip/Take over a non-total ORDER BY is undefined: rows sharing a sort value can come back in a different order per statement, so the same row appears on two consecutive pages while another appears on neither, from data that never changed (QueryFieldService.cs:139-153). The append is repeated-key aware, skipped when the caller already sorted by that very column (QueryFieldService.cs:214-217).

        ApplyFieldSelection (QueryFieldService.cs:229) builds a MemberInit Select expression so a fields=name,bio request pulls only those columns from the database ([Rubric §12, Performance & Scalability]), restricted to writable properties because the projection needs setters (QueryFieldService.cs:282-291, the CanWrite filter at QueryFieldService.cs:287). The compiled lambda is cached per (entity type, normalized field set) (QueryFieldService.cs:237, cache at QueryFieldService.cs:280), and a null is cached on purpose to record "this field set projects nothing writable" so the miss is not recomputed per request (QueryFieldService.cs:269-271). ShapeData and ShapeCollectionData (QueryFieldService.cs:75, QueryFieldService.cs:96) produce the wire shape: an ExpandoObject (or a list of them) holding only the requested fields under camelCase keys. To make that cheap on large result sets the service caches a per-type array of PropertyAccessor (QueryFieldService.cs:42), a private readonly record struct bundling each property's name, its precomputed camelCase key, and a compiled Func<object, object?> getter built with Expression.Lambda(...).Compile() rather than PropertyInfo.GetValue (QueryFieldService.cs:46, built at QueryFieldService.cs:48-65); the field-filtered subset is cached again per field set (QueryFieldService.cs:465, QueryFieldService.cs:471), under an order- and case-insensitive key so name,id and Id, Name share one entry (QueryFieldService.cs:502-503).

        -

        Both field-set caches are bounded, and the reason is the same one that shapes the filter cache. Their key is half client-supplied, so an entity with N properties admits up to 2^N distinct subsets and a caller could grow either dictionary by permuting the list (QueryFieldService.cs:18-38). The cap is a const int MaxCacheEntries = 512 per cache (QueryFieldService.cs:39), with deliberately no LRU: past the cap ApplyFieldSelection skips server-side projection rather than admitting another key (QueryFieldService.cs:248-249, and the response is unchanged because shaping still trims the payload), and GetShapedAccessors filters per request instead (QueryFieldService.cs:489-490), which is a scan over an already-compiled accessor array rather than an expression rebuild. Validation mirrors the filter side: Validate<TEntity> rejects unknown field names and (when shaping) read-only properties (QueryFieldService.cs:317 and the map-aware overload at QueryFieldService.cs:352, shared body at QueryFieldService.cs:362), and ValidateSortDirection accepts only asc or desc (QueryFieldService.cs:415). Paging arithmetic is small enough to look trivial and is not, which is why it has its own type: PagingMath.Clamp (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/PagingMath.cs:32) clamps the page size into [1, maxPageSize] and the page number to at least 1 (PagingMath.cs:37-38), computes the offset in 64-bit (PagingMath.cs:40), and returns (0, 0) for a page beyond the reachable offset range, materializing the empty page that page genuinely holds (PagingMath.cs:42). A 32-bit (pageNumber - 1) * pageSize overflows and wraps negative near int.MaxValue, and SQL Server rejects a negative OFFSET outright, so the request surfaced as a 500 instead of an empty page (PagingMath.cs:7-12). Every paginating caller routes through here rather than open-coding the multiply, because the arithmetic previously lived only inside the pipeline and the handlers that paginate their own queryable each re-derived it in 32-bit (PagingMath.cs:14-17).

        +

        Both field-set caches are bounded, and the reason is the same one that shapes the filter cache. Their key is half client-supplied, so an entity with N properties admits up to 2^N distinct subsets and a caller could grow either dictionary by permuting the list (QueryFieldService.cs:18-38). The cap is a const int MaxCacheEntries = 512 per cache (QueryFieldService.cs:39), with deliberately no LRU: past the cap ApplyFieldSelection skips server-side projection rather than admitting another key (QueryFieldService.cs:248-249, and the response is unchanged because shaping still trims the payload), and GetShapedAccessors filters per request instead (QueryFieldService.cs:489-490), which is a scan over an already-compiled accessor array rather than an expression rebuild. Validation mirrors the filter side: Validate<TEntity> rejects unknown field names and (when shaping) read-only properties (QueryFieldService.cs:317 and the map-aware overload at QueryFieldService.cs:352, shared body at QueryFieldService.cs:362), and ValidateSortDirection accepts only asc or desc (QueryFieldService.cs:415). Paging arithmetic is small enough to look trivial and is not, which is why it has its own type: PagingMath.Clamp (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/PagingMath.cs:32) clamps the page size into [1, maxPageSize] and the page number to at least 1 (PagingMath.cs:37-38), computes the offset in 64-bit (PagingMath.cs:40), and returns (0, 0) for a page beyond the reachable offset range, materializing the empty page that page genuinely holds (PagingMath.cs:42). A 32-bit (pageNumber - 1) * pageSize overflows and wraps negative near int.MaxValue, and SQL Server rejects a negative OFFSET outright, so the request surfaced as a 500 instead of an empty page (PagingMath.cs:8-12). Every paginating caller routes through here rather than open-coding the multiply, because the arithmetic previously lived only inside the pipeline and the handlers that paginate their own queryable each re-derived it in 32-bit (PagingMath.cs:14-17).

        The pipeline: two entity paths plus projection pushdown

        IEntityQueryPipeline (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/IEntityQueryPipeline.cs:10) is the execution contract, implemented by the sealed EntityQueryPipeline (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/EntityQueryPipeline.cs:13), which talks to the database through the IQueryableExecutor abstraction rather than referencing EF Core from the Application layer ([Rubric §3, Clean Architecture]). Its inputs are bundled into EntityQueryParameters<TEntity> (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/EntityQueryParameters.cs:11), an immutable record carrying the specification Criteria, the dynamic Filters, sort column and direction, Fields, page number and size, the two include flags, and the DTO-to-entity property map (defaulting to an empty FrozenDictionary, EntityQueryParameters.cs:44).

        ExecuteAsync (EntityQueryPipeline.cs:39) runs a shared front half, ApplyIncludesCriteriaAndFilters (EntityQueryPipeline.cs:119): add every supported navigation as an .Include() (EntityQueryPipeline.cs:133-134), force AsSplitQuery() when a child collection is among them (EntityQueryPipeline.cs:140-141), then apply the specification criteria and the dynamic filters before materializing anything (EntityQueryPipeline.cs:147-151) so the data source does as much of the work as possible. The comment above the split-query switch records the hard-won reason, annotated R24/§8: paginating a single-query collection include truncates child rows because EF applies Skip/Take to the JOIN-expanded set, so list reads returned empty child collections while by-id reads worked (EntityQueryPipeline.cs:136-139). It then branches on whether any requested navigation is unsupported (EntityQueryPipeline.cs:53). Path 1, server-side includes (EntityQueryPipeline.cs:216): sort (EntityQueryPipeline.cs:227), count before paging (EntityQueryPipeline.cs:240), Skip/Take (EntityQueryPipeline.cs:241), field-selection Select (EntityQueryPipeline.cs:251), materialize (EntityQueryPipeline.cs:252). Path 2, manual navigation (EntityQueryPipeline.cs:161), taken when a requested navigation crosses a physical data source and cannot be joined: sort and page at the database first (EntityQueryPipeline.cs:175, EntityQueryPipeline.cs:187), materialize the page (EntityQueryPipeline.cs:196), then invoke the navigationPopulator callback to batch-load those navigations in a second query (EntityQueryPipeline.cs:199), the INavigationPopulator<in TEntity> extension point of ADR-002, and apply field selection in memory afterwards (EntityQueryPipeline.cs:208). Both paths pass the entity key as the sort tie-break, and only when the read is paginated (EntityQueryPipeline.cs:36, passed at EntityQueryPipeline.cs:180 and EntityQueryPipeline.cs:232): an unpaginated read materializes one capped set in one statement, so it cannot suffer the split-across-pages incoherence, and adding an ORDER BY there would charge every unsorted list read for a sort nobody asked for (EntityQueryPipeline.cs:30-35).

        -

        ExecuteProjectedAsync (EntityQueryPipeline.cs:60) is the third path and the newest: for a read whose result type has a registered IEntityDTOProjector<TEntity, TEntityDTO, TIdentifierType>, criteria, filters, sorting, and paging all run over entity rows (EntityQueryPipeline.cs:73-95) and the projection is applied last (EntityQueryPipeline.cs:105), so the provider pages exactly the rows it means to and selects only that page's columns. Nothing is materialized as an entity and no mapper runs. It handles server-side navigations only: there is no populator hook, because a projection cannot be post-processed row by row, so a query with cross-source includes must use ExecuteAsync instead, and navigation includes are not applied here at all because the projection itself decides what the provider joins and selects (IEntityQueryPipeline.cs:37-49, restated at EntityQueryPipeline.cs:101-104).

        +

        ExecuteProjectedAsync (EntityQueryPipeline.cs:60) is the third path: for a read whose result type has a registered IEntityDTOProjector<TEntity, TEntityDTO, TIdentifierType>, criteria, filters, sorting, and paging all run over entity rows (EntityQueryPipeline.cs:73-94) and the projection is applied last (EntityQueryPipeline.cs:105), so the provider pages exactly the rows it means to and selects only that page's columns. Nothing is materialized as an entity and no mapper runs. It handles server-side navigations only: there is no populator hook, because a projection cannot be post-processed row by row, so a query with cross-source includes must use ExecuteAsync instead, and navigation includes are not applied here at all because the projection itself decides what the provider joins and selects (IEntityQueryPipeline.cs:43-48, restated at EntityQueryPipeline.cs:101-104).

        All three paths share one [Rubric §12, Performance & Scalability] safety ceiling: an unpaginated query is capped at MaxUnboundedResultLimit, a public const int of 1000 (EntityQueryPipeline.cs:23, applied at EntityQueryPipeline.cs:98, EntityQueryPipeline.cs:193, and EntityQueryPipeline.cs:247), and a paginated call has its page size clamped to that same ceiling inside ApplyPaging, which delegates the offset arithmetic to PagingMath.Clamp (EntityQueryPipeline.cs:271-280). A direct service caller who forgets or oversizes paging therefore can never trigger an unbounded full-table load. The reported total for an unpaginated read is not simply the materialized count: CountUnpaginatedAsync (EntityQueryPipeline.cs:288) returns the materialized count only while it stays under the ceiling and issues a real COUNT otherwise (EntityQueryPipeline.cs:292-294), because at the cap the materialized number is the cap itself and reporting it told callers the set was exactly 1000 rows (EntityQueryPipeline.cs:283-287). Which navigations are eligible, and which path each takes, is decided by NavigationMetadataProvider (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/NavigationMetadataProvider.cs:20) behind INavigationMetadataProvider (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/INavigationMetadataProvider.cs:9). BuildIncludes asks separately for FK references and child collections (NavigationMetadataProvider.cs:31), and the classifier reflects over the entity's public properties looking for NavigationAttribute (NavigationMetadataProvider.cs:74), unwraps ICollection<T> / IReadOnlyCollection<T> to find the target entity (NavigationMetadataProvider.cs:106), and asks IDataSourceService whether the two ends share a JOIN-capable source, sorting each NavigationPropertyInfo into the supported or unsupported bucket of NavigationMetadata (NavigationMetadataProvider.cs:96-99). Results are cached per (entity type, NavigationType) in an instance-level dictionary, not a static one, precisely so that a process hosting more than one data-source configuration (integration tests, for example) cannot share classifications across hosts (NavigationMetadataProvider.cs:28, rationale at NavigationMetadataProvider.cs:22-27).

        The query service, the public face

        IEntityQueryService<TEntity, TEntityDTO, TIdentifierType> (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEntityQueryService.cs:19) and its concrete EntityQueryService<TEntity, TEntityDTO, TIdentifierType> (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:31) are what controllers and handlers inject. The service is constructed from IUnitOfWork, the metadata provider, the pipeline, an IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> (ADR-001), and an INavigationPopulator<TEntity> (EntityQueryService.cs:31-36), and it resolves its IReadRepository<TEntity, TIdentifierType> from the unit of work through a virtual property (EntityQueryService.cs:87). A second, six-argument constructor adds the optional IEntityDTOProjector (EntityQueryService.cs:69-77): it is a second constructor rather than an optional parameter because the Microsoft DI container has no notion of an optional dependency, so with two overloads it picks the longer one when a projector is registered and the shorter one when it is not (EntityQueryService.cs:51-61).

        -

        GetAllAsync (EntityQueryService.cs:248, with a six-parameter convenience overload at EntityQueryService.cs:227) is the four-step orchestration. (1) Validate every parameter up front with Result.Combine over the fields, sort-column, sort-direction, and filter validators, so a bad fields fails before any database hit (EntityQueryService.cs:262-267), re-stamping each error with the operation and entity name (EntityQueryService.cs:270-276). (2) Build the query: ask the metadata provider which includes are supported (EntityQueryService.cs:282), pack everything into EntityQueryParameters (EntityQueryService.cs:284-296), and pick Repository.Table or TableNoTracking from the asTracking flag (EntityQueryService.cs:298). (3) Execute on one of two branches, chosen by CanProject (EntityQueryService.cs:489-492): a registered projector, no tracking request, and no cross-source includes routes to ExecuteProjectedAsync and the mapper is never involved (EntityQueryService.cs:303-312); otherwise ExecuteAsync runs and DTOMapper.MapToDTOs converts the materialized entities (EntityQueryService.cs:316-324). Field shaping deliberately does not disqualify projection, because shaping runs after materialization over whatever object the pipeline produced (EntityQueryService.cs:484-487). (4) Shape and wrap: shape only when a field subset was requested, otherwise return the typed DTOs as-is to avoid a per-row ExpandoObject allocation and boxing (EntityQueryService.cs:334-336); both forms serialize to the same camelCase JSON, which is why the return type is PagedCollectionResult<object> rather than a typed collection (PagedCollectionResult<T>, and the contract note at IEntityQueryService.cs:12-14). The PaginationMetadata comes from BuildPaginationMetadata (EntityQueryService.cs:332, method at EntityQueryService.cs:555), whose job is to describe what the pipeline actually did rather than what the caller asked for: an unpaginated call reports the true total with the page size floored to the rows actually returnable, Math.Min(total, MaxUnboundedResultLimit), on page 1 (EntityQueryService.cs:569-572), and a paginated call reports Math.Clamp(pageSize, 1, MaxUnboundedResultLimit) on Math.Max(pageNumber, 1) (EntityQueryService.cs:579-582), mirroring exactly the floor and ceiling PagingMath applied. The clamp is recomputed here rather than read back from PagingMath, because that helper's (0, 0) sentinel for an unreachable page would otherwise advertise PageSize = 0 for a perfectly valid page size (EntityQueryService.cs:548-553).

        -

        The by-id path has a fast lane worth knowing. GetEntityByIdAsync (EntityQueryService.cs:376) validates the fields (EntityQueryService.cs:386), then tries TryGetByIdFastPathAsync (EntityQueryService.cs:119). TryGetFastPathIncludes (EntityQueryService.cs:161) decides eligibility: a field projection, a specification, or a non-default idField disqualifies the request (EntityQueryService.cs:171-176), and so do unsupported (cross-source) navigations, since only the pipeline's populator can batch-load those (EntityQueryService.cs:184-187, rationale at EntityQueryService.cs:155-159). Requested includes do not disqualify it: the repository's include overload applies the same Include calls and auto-applies AsSplitQuery for a child collection (EFReadRepository.cs:185-198, delegating the split decision to SpecificationEvaluator.cs:93), and disqualifying on includes left the fast path unreachable for every entity that declares a navigation, because the REST by-id action defaults includeFKs to true (EntityQueryService.cs:146-153). The string id is converted with a TypeConverter cached per identifier type (EntityQueryService.cs:198, cache at EntityQueryService.cs:107), and the read runs on the filtered TableNoTracking (EFReadRepository.cs:194), so soft-delete query filters still apply, unlike FindAsync (EntityQueryService.cs:113-117, and the same trap documented at EFReadRepository.cs:176-180). Anything else falls through to the pipeline with a synthetic Id EQUALS <value> filter (EntityQueryService.cs:408-411) and returns Error.NotFound when the page is empty (EntityQueryService.cs:427-430). GetByIdAsync (EntityQueryService.cs:437) layers DTO mapping and the same shape-only-if-fields rule on top (EntityQueryService.cs:461-463); GetAllForLookupAsync (EntityQueryService.cs:348) returns lightweight BaseLookup<TIdentifierType> id/name pairs for dropdowns; ExistsAsync (EntityQueryService.cs:467) delegates straight to the repository. The class is built for extension over modification ([Rubric §1, SOLID]): Repository (EntityQueryService.cs:87), DTOToEntityPropertyMap (EntityQueryService.cs:100), and every query method are virtual, so a module subclass such as SpeakerEntityQueryService (SpeakerEntityQueryService.cs:15) overrides one behavior (SpeakerEntityQueryService.cs:34) without reimplementing the engine.

        +

        GetAllAsync (EntityQueryService.cs:248, with a six-parameter convenience overload at EntityQueryService.cs:227) is the four-step orchestration. (1) Validate every parameter up front with Result.Combine over the fields, sort-column, sort-direction, and filter validators, so a bad fields fails before any database hit (EntityQueryService.cs:262-267), re-stamping each error with the operation and entity name (EntityQueryService.cs:270-276). (2) Build the query: ask the metadata provider which includes are supported (EntityQueryService.cs:282), pack everything into EntityQueryParameters (EntityQueryService.cs:284-296), and pick Repository.Table or TableNoTracking from the asTracking flag (EntityQueryService.cs:298). (3) Execute on one of two branches, chosen by CanProject (EntityQueryService.cs:489-492): a registered projector, no tracking request, and no cross-source includes routes to ExecuteProjectedAsync and the mapper is never involved (EntityQueryService.cs:303-313); otherwise ExecuteAsync runs and DTOMapper.MapToDTOs converts the materialized entities (EntityQueryService.cs:316-324). Field shaping deliberately does not disqualify projection, because shaping runs after materialization over whatever object the pipeline produced (EntityQueryService.cs:484-487). (4) Shape and wrap: shape only when a field subset was requested, otherwise return the typed DTOs as-is to avoid a per-row ExpandoObject allocation and boxing (EntityQueryService.cs:334-336); both forms serialize to the same camelCase JSON, which is why the return type is PagedCollectionResult<object> rather than a typed collection (PagedCollectionResult<T>, and the contract note at IEntityQueryService.cs:12-14). The PaginationMetadata comes from BuildPaginationMetadata (EntityQueryService.cs:332, method at EntityQueryService.cs:555), whose job is to describe what the pipeline actually did rather than what the caller asked for: an unpaginated call reports the true total with the page size floored to the rows actually returnable, Math.Min(total, MaxUnboundedResultLimit), on page 1 (EntityQueryService.cs:569-572), and a paginated call reports Math.Clamp(pageSize, 1, MaxUnboundedResultLimit) on Math.Max(pageNumber, 1) (EntityQueryService.cs:579-582), mirroring exactly the floor and ceiling PagingMath applied. The clamp is recomputed here rather than read back from PagingMath, because that helper's (0, 0) sentinel for an unreachable page would otherwise advertise PageSize = 0 for a perfectly valid page size (EntityQueryService.cs:548-553).

        +

        The by-id path has a fast lane worth knowing. GetEntityByIdAsync (EntityQueryService.cs:376) validates the fields, then tries TryGetByIdFastPathAsync (EntityQueryService.cs:119), which issues a single keyed TOP 1 WHERE Id = @id through the repository's include overload (EntityQueryService.cs:135). TryGetFastPathIncludes (EntityQueryService.cs:161) decides eligibility: a field projection, a specification, or a non-default idField disqualifies the request (EntityQueryService.cs:171-176), and so do unsupported (cross-source) navigations, since only the pipeline's populator can batch-load those (EntityQueryService.cs:184-187, rationale at EntityQueryService.cs:155-159). Requested includes do not disqualify it: the repository's include overload applies the same Include calls and auto-applies AsSplitQuery for a child collection (EFReadRepository.cs:185-198, delegating the split decision to SpecificationEvaluator.cs:93), and disqualifying on includes left the fast path unreachable for every entity that declares a navigation, because the REST by-id action defaults includeFKs to true (EntityQueryService.cs:147-153). The string id is converted with a TypeConverter cached per identifier type (EntityQueryService.cs:198, cache at EntityQueryService.cs:107), and the read runs on the filtered TableNoTracking (EFReadRepository.cs:194), so soft-delete query filters still apply, unlike FindAsync (EntityQueryService.cs:113-117, and the same trap documented at EFReadRepository.cs:176-180). Anything else falls through to the pipeline with a synthetic Id EQUALS <value> filter and returns Error.NotFound when the page comes back empty. GetByIdAsync (EntityQueryService.cs:437) layers DTO mapping and the same shape-only-if-fields rule on top; GetAllForLookupAsync (EntityQueryService.cs:348) returns lightweight BaseLookup<TIdentifierType> id/name pairs for dropdowns; ExistsAsync (EntityQueryService.cs:467) delegates straight to the repository. The class is built for extension over modification ([Rubric §1, SOLID]): Repository (EntityQueryService.cs:87), DTOToEntityPropertyMap (EntityQueryService.cs:100), and every query method are virtual, so a module subclass such as SpeakerEntityQueryService (SpeakerEntityQueryService.cs:15) overrides one behavior (SpeakerEntityQueryService.cs:34) without reimplementing the engine.

        End to end, one list request

        -

        The request reaches a read controller, EntityControllerBase<TEntity, TEntityDTO, TIdentifierType> (Group 12), which resolves MaxPageSize per request from IApplicationSettings, falling back to 500 when unset (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:54-62), clamps the requested page size to it (EntityControllerBase.cs:155), binds ?filter= through QueryFilterModelBinder (EntityControllerBase.cs:152), and may supply a server-authored specification for authorization scope. It calls IEntityQueryService.GetAllAsync. The service validates fields, sort, and filters (an early failure short-circuits to an error result), classifies the requested includes, and packages an EntityQueryParameters. EntityQueryPipeline then takes the projection path when a projector is registered and the read qualifies, or one of the two entity paths otherwise, applying the specification criteria plus the dynamic filters as translated, parameterized WHERE clauses, sorting with the key tie-break when paginating, counting, paging through PagingMath, projecting the requested columns, materializing, and batch-loading any cross-source navigations. The service maps to DTOs (or skips mapping entirely on the projected path), shapes only if a field subset was asked for, and returns a Result<PagedCollectionResult<object>> that the controller unwraps into the HTTP body plus an X-Pagination header carrying the serialized metadata (EntityControllerBase.cs:172). One pipeline, every entity, validated input, server-side execution, and a clean extension point for navigations that cross a service boundary ([Rubric §6, CQRS & Event-Driven] on the read side, [Rubric §9, API & Contract Design] for the uniform query contract).

        -

        Also filed here: the best-effort side-effect helper

        -

        Three types in this group are not part of the read path at all; they are co-located in MMCA.Common.Application/Services and are grouped by that folder. BestEffort (MMCA.Common/Source/Core/MMCA.Common.Application/Services/BestEffort.cs:25) runs a side effect that must never fail its caller (cache eviction after a committed command, a fire-and-forget notification, an eviction broadcast onto the bus): ExecuteAsync awaits the action and turns any non-cancellation failure into exactly one Warning plus one metric increment instead of an exception that would roll back or 500 an operation whose real work already succeeded (BestEffort.cs:45, the swallow at BestEffort.cs:65-71). Cancellation is explicitly not swallowed: when the caller's own token is the reason the action stopped, the OperationCanceledException is rethrown so a host shutdown unwinds promptly (BestEffort.cs:59-64, rationale at BestEffort.cs:11-17). The two companions carry the telemetry: BestEffortLog (BestEffort.cs:79) is the source-generated Warning message, kept separate so the public helper need not be partial (BestEffort.cs:81-84), and BestEffortMetrics (BestEffort.cs:99) owns the MMCA.Common.BestEffort meter and its besteffort.dispatch.failed counter, tagged by a low-cardinality operation name (BestEffort.cs:102-115). It is its own meter rather than a counter folded into the CQRS metrics so an operator can drop or keep it independently of the RED metrics (BestEffort.cs:92-97). Callers span both apps: Store's output-cache eviction (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/OutputCacheEvictionExtensions.cs:36) and ADC's live broadcasts and cache-eviction handlers (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/DomainEventHandlers/UserSessionBookmarkCacheEvictionHandler.cs:68, MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Live/LiveChannelPublishProcessor.cs:45). This is [Rubric §13, Observability & Operability] (a quietly broken side effect becomes a metric, not a line in a log nobody reads) and [Rubric §29, Resilience & Business Continuity] (a non-essential failure degrades instead of propagating).

        +

        The request reaches a read controller, EntityControllerBase<TEntity, TEntityDTO, TIdentifierType> (Group 12), which resolves MaxPageSize per request from IApplicationSettings, falling back to 500 when unset (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:57-62), clamps the requested page size to it (EntityControllerBase.cs:155), binds ?filter= through QueryFilterModelBinder (EntityControllerBase.cs:152), and may supply a server-authored specification for authorization scope. It calls IEntityQueryService.GetAllAsync. The service validates fields, sort, and filters (an early failure short-circuits to an error result), classifies the requested includes, and packages an EntityQueryParameters. EntityQueryPipeline then takes the projection path when a projector is registered and the read qualifies, or one of the two entity paths otherwise, applying the specification criteria plus the dynamic filters as translated, parameterized WHERE clauses, sorting with the key tie-break when paginating, counting, paging through PagingMath, projecting the requested columns, materializing, and batch-loading any cross-source navigations. The service maps to DTOs (or skips mapping entirely on the projected path), shapes only if a field subset was asked for, and returns a Result<PagedCollectionResult<object>> that the controller unwraps into the HTTP body plus an X-Pagination header carrying the serialized metadata (EntityControllerBase.cs:172). One pipeline, every entity, validated input, server-side execution, and a clean extension point for navigations that cross a service boundary ([Rubric §6, CQRS & Event-Driven] on the read side, [Rubric §9, API & Contract Design] for the uniform query contract).

        +

        Also filed here: best-effort dispatch and the upcaster registry

        +

        Four types in this group are not part of the read path at all; they are co-located in MMCA.Common.Application/Services and are grouped by that folder. BestEffort (MMCA.Common/Source/Core/MMCA.Common.Application/Services/BestEffort.cs:25) runs a side effect that must never fail its caller (cache eviction after a committed command, a fire-and-forget notification, an eviction broadcast onto the bus): ExecuteAsync awaits the action and turns any non-cancellation failure into exactly one Warning plus one metric increment instead of an exception that would roll back or 500 an operation whose real work already succeeded (BestEffort.cs:45, the swallow at BestEffort.cs:65-71). Cancellation is explicitly not swallowed: when the caller's own token is the reason the action stopped, the OperationCanceledException is rethrown so a host shutdown unwinds promptly (BestEffort.cs:59-64, rationale at BestEffort.cs:11-17). The two companions carry the telemetry: BestEffortLog (BestEffort.cs:79) is the source-generated Warning message, kept separate so the public helper need not be partial (BestEffort.cs:81-84), and BestEffortMetrics (BestEffort.cs:99) owns the MMCA.Common.BestEffort meter and its besteffort.dispatch.failed counter, tagged by a low-cardinality operation name (BestEffort.cs:102-115). It is its own meter rather than a counter folded into the CQRS metrics so an operator can drop or keep it independently of the RED metrics (BestEffort.cs:92-97). Callers span both apps: Store's output-cache eviction (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/OutputCacheEvictionExtensions.cs:36) and ADC's live broadcasts and cache-eviction handlers (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/DomainEventHandlers/UserSessionBookmarkCacheEvictionHandler.cs:68, MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Live/LiveChannelPublishProcessor.cs:45). This is [Rubric §13, Observability & Operability] (a quietly broken side effect becomes a metric, not a line in a log nobody reads) and [Rubric §29, Resilience & Business Continuity] (a non-essential failure degrades instead of propagating).

        +

        The fourth co-located type is EventUpcasterRegistry (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EventUpcasterRegistry.cs:30), the default IEventUpcasterRegistry, which belongs to the integration-event story rather than to querying. It indexes every registered IEventUpcaster by its source contract and rejects a duplicate source, a self-mapping upcaster, or a chain cycle at construction time, throwing an InvalidOperationException that names the offenders (EventUpcasterRegistry.cs:50-79); it precomputes each chain's terminal type once, because the graph is static after DI is built (EventUpcasterRegistry.cs:133); and it preserves the event envelope across every hop by stamping MessageId and DateOccurred from the pre-hop instance onto the upcasted one through cached PropertyInfo handles (EventUpcasterRegistry.cs:36, EventUpcasterRegistry.cs:169), so consumer-side inbox deduplication stays keyed on the id the producer published.

        BestEffortLog

        MMCA.Common.Application · MMCA.Common.Application.Services · MMCA.Common/Source/Core/MMCA.Common.Application/Services/BestEffort.cs:79 · Level 0 · class (internal, static, partial)

        @@ -199,7 +200,7 @@

        BestEffortLog

        message template on every call; the [LoggerMessage] attribute instead makes the compiler emit a strongly-typed, allocation-free DispatchFailed method with the template pre-parsed and the event wired up once. The type has to be partial for the generator to add the body, which is exactly why - it is a separate companion class: the doc comment (BestEffort.cs:76-78) records that the reason is + it is a separate companion class: the doc comment (BestEffort.cs:76-77) records that the reason is to keep the public BestEffort helper from having to be partial itself.
      • Walkthrough: one member. [LoggerMessage(Level = LogLevel.Warning, Message = "Best-effort operation '{Operation}' failed and was swallowed; the caller's outcome is unaffected")] over @@ -345,7 +346,7 @@

        BestEffort

        passed CancellationToken.None, not the request token. Store's output-cache eviction does exactly that, with the reason in a comment: the write has committed, so a client that disconnected mid-response must not abandon the cache cleanup - (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/OutputCacheEvictionExtensions.cs:33-41). + (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/OutputCacheEvictionExtensions.cs:34-40). Keeping operation a low-cardinality constant is the other obligation, because it becomes a metric tag (BestEffort.cs:20-22).
      • Where it's used: ADC Engagement's real-time and cache-eviction paths, all of which broadcast or @@ -361,6 +362,120 @@

        BestEffort

        anything that must eventually happen belongs in the outbox (ADR-003) rather than here.

      +

      EventUpcasterRegistry

      +
      +

      MMCA.Common.Application · MMCA.Common.Application.Services · MMCA.Common/Source/Core/MMCA.Common.Application/Services/EventUpcasterRegistry.cs:30 · Level 3 · class (public, sealed)

      +
      +
        +
      • What it is: the default + IEventUpcasterRegistry. It indexes every + registered IEventUpcaster by the contract it consumes, + precomputes where each upcast chain ends, and walks an incoming integration event forward to that + terminal contract while keeping the envelope the producer stamped.
      • +
      • Depends on: IEventUpcasterRegistry (the + interface it implements), IEventUpcaster, + IIntegrationEvent, + IDomainEvent (only for the nameof of the two envelope + properties); System.Collections.Concurrent (ConcurrentDictionary), System.Reflection + (PropertyInfo) from the BCL.
      • +
      • Concept introduced, upcasting a retired event contract. [Rubric §6, CQRS & Event-Driven] + assesses how well the event model handles change over time, and [Rubric §7, Microservices Readiness] assesses whether producers and consumers can deploy independently. Both meet here. Once + an integration event has been published, its shape is a contract: a producer that has moved to + FooV2 cannot assume every consumer redeployed at the same moment, and a queue can still hold Foo + messages written before the change. Upcasting resolves that without a flag day. An author registers + a small mapper that converts Foo into FooV2 + (services.AddEventUpcaster<Foo, FooV2, FooUpcaster>(), + MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:283-290), and this registry + converts on the way in so that exactly one handler shape exists in the codebase, the newest one. + Registrations compose: V1 to V2 plus V2 to V3 delivers a V1 message to the V3 handler + (DependencyInjection.cs:271). + [Rubric §15, Best Practices & Code Quality] covers the failure model: a bad registration graph is + a programming error, so it throws at construction naming the offenders instead of returning a + Result; the class remarks (:14-20) call that the + permission-registry precedent, and note that because the graph is static once DI is built, terminal + types are resolved once here rather than per message.
      • +
      • Walkthrough
          +
        • State. EnvelopeProperties + (static ConcurrentDictionary<Type, (PropertyInfo? MessageId, PropertyInfo? DateOccurred)>, + :36) caches the two writable envelope handles per upcast target type, so a chain pays the + reflection lookup once per contract for the life of the process. _bySourceType + (Dictionary<Type, IEventUpcaster>, :38) is the index the walk follows, and _terminalTypes + (Dictionary<Type, Type>, :39) is the precomputed answer to "where does this chain end".
        • +
        • Constructor (:50-82). After ArgumentNullException.ThrowIfNull(upcasters) (:52) it makes + one pass over the registrations, collecting all offenders instead of failing on the first: an + upcaster whose SourceType == TargetType is rejected as mapping a type onto itself (:59-63), a + second upcaster claiming an already-claimed source is rejected as a duplicate naming both + contenders (:65-69), and anything else is indexed (:71). If the offender list is non-empty it + throws one InvalidOperationException joining every message and restating the rule, "exactly one + upcaster may claim a source contract, and it must produce a different one" (:74-79). Only then + is BuildTerminalTypes run (:81), so cycle detection sees an already-validated graph.
        • +
        • BuildTerminalTypes (:133-161) walks each source forward, carrying a visited set (:139) and + a chain list for the error message (:140). Each hop advances by the upcaster's TargetType + (:145); a type that fails to enter visited means the ladder came back on itself, and the throw + renders the whole chain as A -> B -> C -> A (:148-154). The doc (:125-129) explains why a + repeat is unambiguously a cycle rather than a diamond: the constructor already rejected duplicate + sources, so the graph is functional (one outgoing edge per node). The final type reached is stored + as that source's terminal (:157).
        • +
        • HasUpcasterFor(Type) (:85-90) is a plain containment check on _bySourceType, and + ResolveTerminalType(Type) (:93-98) is a dictionary read that falls back to the type itself + (:97), so an unregistered contract is its own terminal. That identity behavior is what lets the + rest of the framework depend on the registry unconditionally.
        • +
        • UpcastToTerminal(IIntegrationEvent) (:101-123) is the hot path. It walks while a source has an + upcaster (:110), rejects a null return with an InvalidOperationException naming the offending + upcaster (:112-114), stamps the envelope (:116), and then advances currentType by the + upcaster's declared TargetType, not the runtime type of what was returned (:119). The + comment (:108-109) says why that matters: the constructor's acyclicity check was computed over + declared types, so following declared types is what bounds this loop. With no registration at all, + the loop never runs and the input instance is returned unchanged (:122).
        • +
        • PreserveEnvelope (:169-179) is the correctness detail. It pulls the cached MessageId and + DateOccurred handles for the produced type (:171-175, keyed on target.GetType()), then copies + both values from the pre-hop instance onto the new one (:177-178). Writable (:181-182) is the + filter that caches a handle only when CanWrite is true, so a target that does not expose the + property simply gets nothing written rather than throwing. Both properties are init-only on + BaseDomainEvent + (MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/BaseDomainEvent.cs:28 and :35), which + reflection can still set.
        • +
        • Describe (:184, :186) renders a type or an upcaster instance as its FullName, which is what + makes every one of the exception messages above name real types.
        • +
        +
      • +
      • Why it's built this way: ADR-090 + records the registration model. Envelope preservation is deliberately the registry's job rather than + the upcaster author's (remarks, :21-28): an upcaster maps payload fields only, and consumer-side + inbox deduplication is keyed on the MessageId the producer published + (ADR-021), so leaving the + copy to each author would make dedup depend on every author remembering. Making it automatic also + makes it idempotent: an author who does copy the envelope just gets the same values written twice, + which the test at + MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Services/EventUpcasterRegistryTests.cs:170 + pins.
      • +
      • Where it's used: registered unconditionally as a singleton by AddApplication + (services.TryAddSingleton<IEventUpcasterRegistry, EventUpcasterRegistry>(), + DependencyInjection.cs:40), and populated by each AddEventUpcaster<TSource, TTarget, TUpcaster>() + call, which appends the upcaster through TryAddEnumerable + (DependencyInjection.cs:283-290, the descriptor at :288). Both delivery paths consume it: the + in-process branch of DomainEventDispatcher + (MMCA.Common/Source/Core/MMCA.Common.Application/Services/DomainEventDispatcher.cs:62, resolved + through a Lazy<IEventUpcasterRegistry?> at :32-33 so a host without one still works) and the + broker-side + UpcastingIntegrationEventConsumer<TEvent> + (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/UpcastingIntegrationEventConsumer.cs:65 + and :72). + EventUpcasterStartupValidator + exists purely to force construction at host start + (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/EventUpcasterStartupValidator.cs:27 + calls ResolveTerminalType for that side effect), so a broken graph fails the host rather than the + first message. Behavior is pinned by + EventUpcasterRegistryTests + (identity at :88 and :187, chain walking at :116 and :130, envelope preservation at :153, + and one test per constructor rejection at :199, :211, :223).
      • +
      • Caveats / not-in-source: the walk trusts declared types. An upcaster that returns an instance + whose runtime type is not its declared TargetType still advances the walk by the declared type, and + the envelope stamp is looked up by the runtime type, so the two can disagree; nothing in this class + verifies the returned instance's type. Envelope stamping is also silently a no-op for a target that + exposes no writable MessageId/DateOccurred (Writable, :181-182).
      • +
      +

      QueryFieldService

      MMCA.Common.Application · MMCA.Common.Application.Services · MMCA.Common/Source/Core/MMCA.Common.Application/Services/QueryFieldService.cs:16 · Level 3 · class (sealed, all members static)

      @@ -2276,7 +2391,7 @@

      CrossSourceSpecification

    • The pipeline: two entity paths plus projection pushdown
    • The query service, the public face
    • End to end, one list request
    • -
    • Also filed here: the best-effort side-effect helper
    • +
    • Also filed here: best-effort dispatch and the upcaster registry
    diff --git a/docs/onboarding/group-05-cqrs-pipeline.html b/docs/onboarding/group-05-cqrs-pipeline.html index 85ad276..d42d7f8 100644 --- a/docs/onboarding/group-05-cqrs-pipeline.html +++ b/docs/onboarding/group-05-cqrs-pipeline.html @@ -182,9 +182,10 @@

    5. CQRS: Commands, Quer consumed by use cases: ITenantContext (which tenant this scope runs as), IDistributedLock (mutual exclusion across replicas), IScheduledJob (recurring work on a cron schedule), - IAuditTrailReader (the recorded change history of one entity), and + IAuditTrailReader (the recorded change history of one entity), IEntityDTOProjector<TEntity, TEntityDTO, TIdentifierType> - (opt-in projection pushdown on list reads).

  • + (opt-in projection pushdown on list reads), and the event-versioning pair + IEventUpcaster / IEventUpcasterRegistry.
  • One reusable use case shipped by the framework itself, DeleteEntityCommand<TEntity, TIdentifierType> and DeleteEntityHandler<TEntity, TIdentifierType>, @@ -194,9 +195,11 @@

    5. CQRS: Commands, Quer intent-revealing use cases) and [Rubric §10, Cross-Cutting Concerns] (the place those concerns are implemented once, uniformly, instead of scattered through handlers). The governing decision is ADR-014, revised 2026-07-19 - for the transactional semantics and again 2026-08-18 for the pipeline order, which now inserts an + for the transactional semantics and again 2026-08-18 for the pipeline order, which inserts an Authorization decorator between FeatureGate and Logging and a Timeout decorator between Validating - and Transactional.

    + and Transactional on both chains. The ADR's own Status block warns that the order printed in its + Decision section is the pre-2026-08-18 one and points at the later revision, so read the revision, not + the decision, when you need the current chain.

    The shape: thin handlers, fat pipeline

    A handler is deliberately tiny. ICommandHandler<in TCommand, TResult> (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/ICommandHandler.cs:9) and @@ -223,28 +226,31 @@

    The shape: thin handlers, fat pipe call arrives over REST, gRPC, or an integration-event consumer.

    How the pipeline is assembled (Scrutor, registration versus execution order)

    The wiring lives in DependencyInjection.cs - (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:21), exposed as + (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:22), exposed as extension(IServiceCollection services) members (the C# extension(T) syntax, primer §4). The sequence a host must follow is strict and ordered:

      -
    1. AddApplication() registers the core singletons (settings facade, event dispatcher, navigation - metadata, the EntityQueryPipeline) and - Common's own validators (DependencyInjection.cs:29-42).
    2. -
    3. ScanModuleApplicationServices<TAssemblyMarker>() runs once per module and uses Scrutor - assembly scanning to register domain and integration event handlers (singleton), DTO mappers, DTO - projectors and request mappers (scoped), and every concrete ICommandHandler<,>/IQueryHandler<,> - (scoped), plus FluentValidation validators (DependencyInjection.cs:132-204).
    4. -
    5. AddApplicationDecorators() is called last (DependencyInjection.cs:102-122). It uses - Scrutor's TryDecorate to wrap the already-registered handlers. This ordering is load-bearing: +
    6. AddApplication() (DependencyInjection.cs:30) registers the core singletons: the settings + facade, the domain event dispatcher, the upcaster registry, the navigation metadata provider and + the EntityQueryPipeline + (DependencyInjection.cs:32-43), then Common's own validators (DependencyInjection.cs:48).
    7. +
    8. ScanModuleApplicationServices<TAssemblyMarker>() (DependencyInjection.cs:140) runs once per + module and uses Scrutor assembly scanning to register domain and integration event handlers + (singleton, DependencyInjection.cs:144-155), DTO mappers, DTO projectors and request mappers + (scoped, DependencyInjection.cs:157-176), and every concrete + ICommandHandler<,>/IQueryHandler<,> (scoped, DependencyInjection.cs:178-188), plus + FluentValidation validators (DependencyInjection.cs:190).
    9. +
    10. AddApplicationDecorators() (DependencyInjection.cs:110) is called last. It uses Scrutor's + TryDecorate to wrap the already-registered handlers. This ordering is load-bearing: TryDecorate can only wrap registrations that already exist, which is why decorators must come - after every module's handler scan.
    11. + after every module's handler scan (DependencyInjection.cs:54-55).

    The subtle rule is registration order versus execution order. TryDecorate applies decorators in reverse registration order, so the last one registered becomes the outermost wrapper - (DependencyInjection.cs:49-51). The command registrations (DependencyInjection.cs:107-113), read + (DependencyInjection.cs:57-58). The command registrations (DependencyInjection.cs:115-121), read top to bottom, therefore list innermost-first, and the XML doc above them draws the resulting nesting - (DependencyInjection.cs:53-63):

    + (DependencyInjection.cs:63-70):

    FeatureGateCommandDecorator                    outermost (registered last)
       -> AuthorizationCommandDecorator
         -> LoggingCommandDecorator
    @@ -253,7 +259,7 @@ 

    FeatureGateQueryDecorator -> AuthorizationQueryDecorator @@ -265,81 +271,86 @@

    ProfilingCommandDecorator<TCommand, TResult> (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/ProfilingCommandDecorator.cs:11, one MiniProfiler.Current?.Step(...) around the inner call at ProfilingCommandDecorator.cs:17) and its read twin ProfilingQueryDecorator<TQuery, TResult> (.../Decorators/ProfilingQueryDecorator.cs:11, :17). No host in this workspace calls it today: the only call sites are the framework's own DependencyInjectionTests - (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/DependencyInjectionTests.cs:148, - :158), which matches - ADR-014's note that the - profiling pair is opt-in and unwired.

    + (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/DependencyInjectionTests.cs:148, :158), + which matches ADR-014's note + that the profiling pair is opt-in and unwired.

    Why this exact order, and what each layer guards

    The nesting order is a deliberate cost-and-correctness argument, spelled out in the registration - XML-doc (DependencyInjection.cs:76-98):

    + XML-doc (DependencyInjection.cs:87-105):

    • Feature-gating is outermost so a disabled feature is rejected with zero downstream work: no - permission check, no log scope, no cache touch, no validation, no budget, no transaction. + permission check, no log scope, no cache touch, no validation, no budget, no transaction + (DependencyInjection.cs:87-90). FeatureGateCommandDecorator<TCommand, TResult> (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/FeatureGateCommandDecorator.cs:18) and its read twin FeatureGateQueryDecorator<TQuery, TResult> (.../Decorators/FeatureGateQueryDecorator.cs:18) call IFeatureManager.IsEnabledAsync only when the use case opts in via IFeatureGated (FeatureGateCommandDecorator.cs:48-51, - FeatureGateQueryDecorator.cs:51) and short-circuit with a NotFound failure carrying the code - Feature.Disabled (FeatureGateCommandDecorator.cs:55-56, FeatureGateQueryDecorator.cs:56). A + FeatureGateQueryDecorator.cs:48-51) and short-circuit with a NotFound failure carrying the code + Feature.Disabled (FeatureGateCommandDecorator.cs:55-56, FeatureGateQueryDecorator.cs:55-56). A disabled feature reads as "this does not exist" rather than "you may not", which is the deliberate posture of ADR-031, and it is also why the gate stays outside authorization: an off feature must answer identically for every - caller instead of leaking which permission guards it (DependencyInjection.cs:79-82).
    • + caller instead of leaking which permission guards it (DependencyInjection.cs:88-90).
    • Authorization sits directly inside the gate and outside caching, so a denied request neither - reads nor populates the cache (DependencyInjection.cs:83-85). + reads nor populates the cache (DependencyInjection.cs:91-93). AuthorizationCommandDecorator<TCommand, TResult> (.../Decorators/AuthorizationCommandDecorator.cs:26) and AuthorizationQueryDecorator<TQuery, TResult> (.../Decorators/AuthorizationQueryDecorator.cs:21) take ICurrentUserService and - IPermissionRegistry, pass straight through when the use - case does not implement IRequiresPermission - (AuthorizationCommandDecorator.cs:58-59, AuthorizationQueryDecorator.cs:53-54), and otherwise - ask the registry whether any of the caller's roles grants the named permission - (AuthorizationCommandDecorator.cs:61, AuthorizationQueryDecorator.cs:56). When none does, the - decorator returns a Forbidden Error with the code - Authorization.PermissionDenied without invoking the handler - (AuthorizationCommandDecorator.cs:68-71, AuthorizationQueryDecorator.cs:63-66) and counts the - denial on CqrsMetrics (AuthorizationCommandDecorator.cs:65). This is defense in + IPermissionRegistry + (AuthorizationCommandDecorator.cs:27-29), pass straight through when the use case does not + implement IRequiresPermission (AuthorizationCommandDecorator.cs:58-59, + AuthorizationQueryDecorator.cs:53-54), and otherwise ask the registry whether any of the caller's + roles grants the named permission (AuthorizationCommandDecorator.cs:61, + AuthorizationQueryDecorator.cs:56). When none does, the decorator returns a Forbidden + Error with the code Authorization.PermissionDenied + without invoking the handler (AuthorizationCommandDecorator.cs:68-71, + AuthorizationQueryDecorator.cs:63-66) and counts the denial on CqrsMetrics + (AuthorizationCommandDecorator.cs:65, AuthorizationQueryDecorator.cs:60). This is defense in depth beside the endpoint's [Authorize] policy rather than a replacement for it - (AuthorizationCommandDecorator.cs:19-22): the capability check now travels with the use case, so a + (AuthorizationCommandDecorator.cs:19-22): the capability check travels with the use case, so a command reached over gRPC, from a scheduled job, or from another module is checked the same way it is over HTTP. That is [Rubric §11, Security] moving inward, and it is the pipeline-side surface of ADR-020.
    • -
    • Logging sits just inside authorization so it measures only enabled, permitted executions. +
    • Logging sits just inside authorization so it measures only enabled, permitted executions + (DependencyInjection.cs:94). LoggingCommandDecorator<TCommand, TResult> (.../Decorators/LoggingCommandDecorator.cs:14) opens a source-generated structured-logging scope carrying the command name and the CorrelationId from ICorrelationContext (LoggingCommandDecorator.cs:23, :25, :66-67), times the whole inner pipeline with Stopwatch.GetTimestamp()/Stopwatch.GetElapsedTime rather than a Stopwatch instance (one fewer - allocation per command, LoggingCommandDecorator.cs:29-36), and separates three outcomes: - completed, failed (a Result in a failure state, logged at Warning with an error summary) and - exception (logged at Error, then rethrown), at LoggingCommandDecorator.cs:38-58. Each outcome is - also recorded to the CqrsMetrics duration histogram tagged command and outcome + allocation per command, LoggingCommandDecorator.cs:32-36), and separates three outcomes: + completed (Information), failed (a Result in a failure state, Warning with an error summary), + and exception (Error, then rethrown), at LoggingCommandDecorator.cs:38-58 with the levels + declared at LoggingCommandDecorator.cs:77-87. Each outcome is also recorded to the + CqrsMetrics duration histogram tagged command and outcome (LoggingCommandDecorator.cs:69-73). This is the RED (Rate, Errors, Duration) anchor of [Rubric §13, Observability & Operability] (ADR-041). The read side LoggingQueryDecorator<TQuery, TResult> - (.../Decorators/LoggingQueryDecorator.cs:13) is the same shape against - CqrsMetrics.QueryDuration (LoggingQueryDecorator.cs:68).
    • + (.../Decorators/LoggingQueryDecorator.cs:13) is the same shape against CqrsMetrics.QueryDuration + (LoggingQueryDecorator.cs:67-71), with one calibration difference: a completed query logs at Debug + rather than Information (LoggingQueryDecorator.cs:73), because reads are the high-volume half.
    • Cache invalidation sits outside validation and outside the transaction, so the cache is only - cleared after a valid, committed mutation (DependencyInjection.cs:89-90). + cleared after a valid, committed mutation (DependencyInjection.cs:97-98). CachingCommandDecorator<TCommand, TResult> (.../Decorators/CachingCommandDecorator.cs:32) calls ICacheService.RemoveByPrefixAsync only when the command opts in via ICacheInvalidating, its prefix is non-blank, and @@ -348,21 +359,23 @@

      Why this exact order, a RemoveByPrefixAsync("") would evict the entire cache (CachingCommandDecorator.cs:73-75); the eviction runs with CancellationToken.None and swallows every fault into a warning, because the command has already committed and a cache outage must not turn a committed write into a failure - (CachingCommandDecorator.cs:84-104); and a second, delayed eviction fires after - ReInvalidationDelay (5 seconds by default, CachingCommandDecorator.cs:60) to remove an entry - that an in-flight read repopulated with pre-write state (CachingCommandDecorator.cs:91-96, - CachingCommandDecorator.cs:113-126). On the read side, + (CachingCommandDecorator.cs:86-89, CachingCommandDecorator.cs:98-103); and a second, delayed + eviction fires after ReInvalidationDelay (5 seconds by default, CachingCommandDecorator.cs:60) + to remove an entry that an in-flight read repopulated with pre-write state + (CachingCommandDecorator.cs:91-96, CachingCommandDecorator.cs:113-126). That follow-up task is + held on an internal property rather than dropped, so it is observed and a test can await it + deterministically (CachingCommandDecorator.cs:62-66). On the read side, CachingQueryDecorator<TQuery, TResult> (.../Decorators/CachingQueryDecorator.cs:34) serves hits without touching the handler (CachingQueryDecorator.cs:79-84), stores only non-failure results (CachingQueryDecorator.cs:109-114), and is fail-open throughout: a failed read is logged and treated as a miss, a failed populate returns the answer uncached, and only - OperationCanceledException escapes the guard (CachingQueryDecorator.cs:116, - CachingQueryDecorator.cs:162-165). Both halves are the pipeline's + OperationCanceledException escapes either guard (CachingQueryDecorator.cs:116-122, + CachingQueryDecorator.cs:165-169). Both halves are the pipeline's [Rubric §12, Performance & Scalability] story (ADR-026).

    • Validation sits outside the budget and the transaction so a malformed command never spends its - timeout allowance or opens a database transaction (DependencyInjection.cs:87-88). + timeout allowance or opens a database transaction (DependencyInjection.cs:95-96). ValidatingCommandDecorator<TCommand, TResult> (.../Decorators/ValidatingCommandDecorator.cs:24) takes IEnumerable<IValidator<TCommand>> and keeps the first (ValidatingCommandDecorator.cs:29), passes straight through when there is none @@ -372,12 +385,12 @@

      Why this exact order, a ICommandWithRequest<out TRequest> get a validator wired automatically: the module scan reflects over the assembly and TryAdds a CommandRequestValidator<TCommand, TRequest> - for each (DependencyInjection.cs:186-201), with TryAdd semantics so an explicit - IValidator<TCommand> always wins. That whole story belongs to + for each (DependencyInjection.cs:192-205), with TryAdd semantics so an explicit + IValidator<TCommand> always wins (DependencyInjection.cs:192-193). That whole story belongs to G06, Validation ([Rubric §24, Forms, Validation & UX Safety]).

    • The timeout budget sits inside validation and outside the transaction, so it covers the database work that actually hangs, does not charge the caller for validation, and cancels the transaction - rather than leaving it open (DependencyInjection.cs:91-94). + rather than leaving it open (DependencyInjection.cs:99-102). TimeoutCommandDecorator<TCommand, TResult> (.../Decorators/TimeoutCommandDecorator.cs:33) passes through unless the command implements IHasTimeout with a positive budget (TimeoutCommandDecorator.cs:63-64), otherwise @@ -389,13 +402,13 @@

      Why this exact order, a Error.Failure("Request.TimedOut", ...) (TimeoutCommandDecorator.cs:79-84) because the framework's ErrorType taxonomy maps to HTTP status codes and has no member for 408 or 504, so the machine-readable code, not the type, is what callers branch on - (TimeoutCommandDecorator.cs:12-17); the expiry is also counted on - CqrsMetrics (TimeoutCommandDecorator.cs:76). The read twin + (TimeoutCommandDecorator.cs:12-17); the expiry is also counted on CqrsMetrics + (TimeoutCommandDecorator.cs:76). The read twin TimeoutQueryDecorator<TQuery, TResult> (.../Decorators/TimeoutQueryDecorator.cs:33) is line-for-line identical - (TimeoutQueryDecorator.cs:63-84) but sits innermost on the query side, so a cache hit is served - without starting a budget at all. This is [Rubric §29, Resilience & Business Continuity] expressed - per use case rather than per host.

    • + (TimeoutQueryDecorator.cs:63-67, :73, :76, :80) but sits innermost on the query side, so + a cache hit is served without starting a budget at all. This is + [Rubric §29, Resilience & Business Continuity] expressed per use case rather than per host.
    • Transaction is innermost (closest to the handler) so the unit-of-work boundary is as tight as possible. TransactionalCommandDecorator<TCommand, TResult> (.../Decorators/TransactionalCommandDecorator.cs:18) is sixteen lines (18-33): pass through unless @@ -406,13 +419,13 @@

      Why this exact order, a call, in DbContextFactory ([Rubric §8, Data Architecture]), and it is worth reading: a returned failed Result rolls the transaction back, exactly like an exception - (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:565-570); + (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:565-571); the call is re-entrant, so a nested transaction joins the ambient one and only the outermost call - begins, commits, or rolls back (DbContextFactory.cs:515-516); in-process domain event dispatch is + begins, commits, or rolls back (DbContextFactory.cs:508-516); in-process domain event dispatch is deferred until after a successful commit and dropped on rollback (DbContextFactory.cs:472-475, - DbContextFactory.cs:581-585); and a failure of the commit itself is never retried, surfacing as - TransactionCommitAmbiguousException instead (DbContextFactory.cs:485-490, - DbContextFactory.cs:542-543).

    • + DbContextFactory.cs:578-580); and a failure of the commit itself is never retried, surfacing as + TransactionCommitAmbiguousException instead (DbContextFactory.cs:488-496, + DbContextFactory.cs:574-576).

    Opt-in by marker interface, pay only for what you use

    The pipeline is registered for every handler, but most decorators are dormant unless the use case @@ -448,19 +461,20 @@

    Opt-in by marker i exactly one production query implements it today, ADC's GetNowNextQuery (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/NowNext/GetNowNextQuery.cs:23, a 30-second TTL at :38), plus the reference apps (Helpdesk's GetTicketByIdQuery, - MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Application/Tickets/UseCases/GetById/GetTicketByIdQuery.cs:23, - and the ECommerce sample's GetProductByIdQuery, + MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Application/Tickets/UseCases/GetById/GetTicketByIdQuery.cs:23 + with a 5-minute TTL at :29, and the ECommerce sample's GetProductByIdQuery, MMCA.ECommerce/Source/Modules/Products/MMCA.ECommerce.Products.Application/Products/UseCases/GetById/GetProductByIdQuery.cs:23, and GetOrderByIdQuery, MMCA.ECommerce/Source/Modules/Orders/MMCA.ECommerce.Orders.Application/Orders/UseCases/GetById/GetOrderByIdQuery.cs:23). - MMCA.Store has no - IQueryCacheable query at all; its public reads cache at the HTTP OutputCache layer instead + MMCA.Store has no IQueryCacheable query at all; its public reads cache at the HTTP OutputCache + layer instead (ADR-040). The two newest markers are further back still: no use case in MMCA.ADC, MMCA.Store or MMCA.Helpdesk implements IRequiresPermission or IHasTimeout yet, so both decorators are exercised only by the - framework's own tests (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Decorators/AuthorizationCommandDecoratorTests.cs, - .../Decorators/TimeoutCommandDecoratorTests.cs). The capability shipped; the adoption has not - started.

    + framework's own tests + (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Decorators/AuthorizationCommandDecoratorTests.cs, + .../Decorators/AuthorizationQueryDecoratorTests.cs, .../Decorators/TimeoutCommandDecoratorTests.cs, + .../Decorators/TimeoutQueryDecoratorTests.cs). The capability shipped; the adoption has not started.

    Tenant scoping and the two lock tables

    Both caching decorators are multi-tenant aware, and the reason is a nice illustration of where a cross-cutting concern has to live. ICacheService is a singleton and therefore cannot see the @@ -469,22 +483,27 @@

    Tenant scoping and the two lock (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/TenantCacheKey.cs:25) turns a key or prefix into t:{tenantId}:{key} when ITenantContext (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ITenantContext.cs:22) reports a resolved - tenant, and returns it untouched when it does not (TenantCacheKey.cs:37-40). The scoped form is a - prefix, not a suffix, precisely so prefix eviction keeps working: a command's invalidation can - only reach its own tenant's entries (TenantCacheKey.cs:16-18). Because the query decorator uses the - same helper for its reads (CachingQueryDecorator.cs:64) and the command decorator for its evictions + tenant, and returns it untouched when it does not (TenantCacheKey.cs:37-40, marker constant at + TenantCacheKey.cs:28). The scoped form is a prefix, not a suffix, precisely so prefix eviction + keeps working: a command's invalidation can only reach its own tenant's entries + (TenantCacheKey.cs:15-19). Because the query decorator uses the same helper for its reads + (CachingQueryDecorator.cs:63-64) and the command decorator for its evictions (CachingCommandDecorator.cs:82), reads and invalidations stay symmetric by construction. ITenantContext is injected as an optional constructor parameter defaulting to null (CachingQueryDecorator.cs:38, CachingCommandDecorator.cs:36), so a single-tenant host keeps byte-identical cache keys to the pre-tenancy framework (ADR-073); the interface itself - exposes TenantId and IsResolved (ITenantContext.cs:28, :31) and refuses to change tenant - mid-scope, accepting the value it already holds and throwing on a different one + exposes TenantId and IsResolved (ITenantContext.cs:28, :31), treats an unresolved tenant as a + meaningful state rather than inventing a fallback value (ITenantContext.cs:10-15), and refuses to + change tenant mid-scope, accepting the value it already holds and throwing on a different one (ITenantContext.cs:33-41).

    The read path also guards against cache stampede. On a miss, CachingQueryDecorator<TQuery, TResult> takes a per-key lock and re-checks the cache inside it, so on expiry of a hot key exactly one caller runs the handler and - the rest are served the fresh entry (CachingQueryDecorator.cs:86-96). The lock table is + the rest are served the fresh entry (CachingQueryDecorator.cs:89-96). The miss counter is + incremented once, at the point where execution actually falls through to the handler rather than at + either cache read, so a request that misses the fast path and the double-check is not counted twice + (CachingQueryDecorator.cs:98-104). The lock table is QueryCacheKeyLocks (.../Decorators/CachingQueryDecorator.cs:194), a non-generic holder around a KeyedSemaphoreStripe so that every closed generic decorator shares one table rather than one per closed type @@ -492,7 +511,7 @@

    Tenant scoping and the two lock (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICacheService.cs:142) does the same job for the default ICacheService.GetOrCreateAsync implementation, and is deliberately a separate table: different call sites over different keys, where sharing stripes would only widen the - unrelated-key collisions striping already tolerates (ICacheService.cs:135-141). Both are striped + unrelated-key collisions striping already tolerates (ICacheService.cs:134-141). Both are striped rather than one semaphore per key, and both are honest about the limit: the lock is per process, so across replicas stampede protection is at most one handler execution per instance, not one cluster-wide (CachingQueryDecorator.cs:186-192).

    @@ -501,15 +520,15 @@

    Two supporting pieces, and a ResultFailureFactory (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/ResultFailureFactory.cs:11) builds a delegate that manufactures a TResult failure from an error list, taking a direct cast for - non-generic Result (ResultFailureFactory.cs:22-25) and compiling an expression tree once per - closed Result<T> (ResultFailureFactory.cs:27-41), and throwing InvalidOperationException for - anything else (ResultFailureFactory.cs:43-45). All four short-circuiting decorator families (feature - gate, authorization, validation, timeout) cache that delegate in a static field but build it - lazily, on the first short-circuit, not in a static constructor: since Scrutor's TryDecorate is + non-generic Result (ResultFailureFactory.cs:22-25), compiling an expression tree once per closed + Result<T> (ResultFailureFactory.cs:27-41), and throwing InvalidOperationException for anything + else (ResultFailureFactory.cs:43-45). All four short-circuiting decorator families (feature gate, + authorization, validation, timeout) cache that delegate in a static field but build it lazily, on + the first short-circuit, not in a static constructor: since Scrutor's TryDecorate is unconditional, an eager initializer turned an unsupported TResult into a TypeInitializationException at resolve time for a handler that never short-circuits - (FeatureGateCommandDecorator.cs:36-43, AuthorizationCommandDecorator.cs:36-53, - ValidatingCommandDecorator.cs:45-52, TimeoutCommandDecorator.cs:41-58). That repeated remark is a + (FeatureGateCommandDecorator.cs:36-43, AuthorizationCommandDecorator.cs:46-52, + ValidatingCommandDecorator.cs:45-52, TimeoutCommandDecorator.cs:51-58). That repeated remark is a good example of the guide's general rule: read the remarks, they usually record a bug that was paid for once.

    CqrsMetrics (.../Decorators/CqrsMetrics.cs:21) is the internal static holder of @@ -532,12 +551,12 @@

    Two supporting pieces, and a AuditableAggregateRootEntity<TIdentifierType> (DeleteEntityHandler.cs:17) rather than forcing every module to author DeleteSessionCommand, DeleteSpeakerCommand, and so on. The handler resolves the repository from - IUnitOfWork, returns a NotFound - Error stamped with its source and the entity type name - when the row is missing (DeleteEntityHandler.cs:25-28), calls the aggregate's own Delete() (which - enforces invariants and may raise domain events), and saves only when that succeeded - (DeleteEntityHandler.cs:30-32). The command itself is a one-property record that implements - ICacheInvalidating with a defaulted CachePrefix of + IUnitOfWork (DeleteEntityHandler.cs:25), returns a + NotFound Error stamped with its source and the entity + type name when the row is missing (DeleteEntityHandler.cs:27-28), calls the aggregate's own + Delete() (which enforces invariants and may raise domain events), and saves only when that + succeeded (DeleteEntityHandler.cs:30-32). The command itself is a one-property record that + implements ICacheInvalidating with a defaulted CachePrefix of typeof(TEntity).FullName + ":" (DeleteEntityCommand.cs:20), because the generic controller constructs the command itself and cannot supply one; setting it to an empty string is the documented opt-out (DeleteEntityCommand.cs:14-18), and matches the blank-prefix guard in the caching decorator. @@ -545,7 +564,7 @@

    Two supporting pieces, and a DeleteEntityCommand<Session, int> from DeleteEntityCommand<Speaker, int> so DI routes each to its own handler, and it supplies that default cache prefix (DeleteEntityCommand.cs:4-6).

    The other Application-layer contracts in this group

    -

    Five contracts sit beside the pipeline rather than inside it. They are declared here, in the +

    Seven contracts sit beside the pipeline rather than inside it. They are declared here, in the Application layer, and implemented in Infrastructure or the composition root, which is what keeps a use case that depends on one extractable into its own service.

    IDistributedLock @@ -554,34 +573,38 @@

    The other Applicati TryAcquireAsync(key, ttl, wait, cancellationToken) returns an IAsyncDisposable handle or null when the key was still held after wait elapsed (IDistributedLock.cs:59-63). The XML doc is explicit about the three things that make it safe to use: it is not reentrant - (IDistributedLock.cs:20-22), the TTL is a crash guard rather than a lease you may rely on, so a + (IDistributedLock.cs:19-22), the TTL is a crash guard rather than a lease you may rely on, so a paused holder can lose the lock without knowing (IDistributedLock.cs:37-42), and release is owner-scoped and idempotent (IDistributedLock.cs:54-57). No decorator takes it; its in-framework caller is the API idempotency filter, which needs its execute-then-store window to be exclusive across replicas (MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:150, + acquisition at IdempotencyFilter.cs:246-252, ADR-017), and the implementation - (RedisDistributedLock or the warn-once - InProcessDistributedLock) is - chosen at the composition root (G14).

    + (RedisDistributedLock or the + InProcessDistributedLock fallback) + is chosen at the composition root (G14).

    IScheduledJob (.../Interfaces/IScheduledJob.cs:36) is recurring work driven by a five-field cron expression parsed by Cronos, with three members: a stable Name that doubles as the primary key of the persisted job row (IScheduledJob.cs:44), a default CronExpression a host may - override per job through Scheduler:Jobs:{Name}:Cron (IScheduledJob.cs:65, :68), and - ExecuteAsync (IScheduledJob.cs:78). Four behaviors documented on the interface shape how you + override per job through Scheduler:Jobs:{Name}:Cron (IScheduledJob.cs:68, :63-66), and + ExecuteAsync (IScheduledJob.cs:78). Occurrences are computed against the UTC clock, never a + local or configured time zone, so a schedule never shifts, doubles, or vanishes across a daylight + saving transition (IScheduledJob.cs:57-62). Four behaviors documented on the interface shape how you write one: jobs resolve scoped, in a fresh DI scope per execution, so they may take a unit of work and must hold no state between runs (IScheduledJob.cs:9-14); a claim lease in the job store makes an - occurrence run exactly once across replicas (IScheduledJob.cs:16-21); missed occurrences do not + occurrence run exactly once across replicas (IScheduledJob.cs:15-21); missed occurrences do not pile up, so work that must not be skipped has to be idempotent and range-driven rather than - one-run-per-tick (IScheduledJob.cs:23-29); and a thrown exception is caught, logged and stamped as a - failed outcome without retry inside the occurrence (IScheduledJob.cs:31-34). The runner lives in + one-run-per-tick (IScheduledJob.cs:22-29); and a thrown exception is caught, logged, and stamped as + a failed outcome without retry inside the occurrence (IScheduledJob.cs:30-34). The runner lives in ScheduledJobRunner (ADR-074).

    IAuditTrailReader (.../Interfaces/IAuditTrailReader.cs:20) reads the recorded change history of one entity, keyed by the entity's full CLR type name and the invariant string form of its primary key (composite keys joined with | in model key order), paged and newest - first (IAuditTrailReader.cs:22-42). It is registered only by AddAuditTrail, so a host that never - opted in has nothing to resolve (IAuditTrailReader.cs:6-7), and the framework deliberately ships the - read without an endpoint or page, because who may see an entity's history is an application decision + first (IAuditTrailReader.cs:37-42, ordering note at IAuditTrailReader.cs:16-18). It is registered + only by AddAuditTrail, so a host that never opted in has nothing to resolve + (IAuditTrailReader.cs:5-8), and the framework deliberately ships the read without an endpoint or + page, because who may see an entity's history is an application decision (IAuditTrailReader.cs:10-15). The implementation is AuditTrailReader over the rows written by AuditTrailSaveChangesInterceptor @@ -592,30 +615,57 @@

    The other Applicati IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>: a mapper maps rows after they materialize, so the query must select whole entities, while a projector rewrites the queryable so the provider selects the DTO's columns directly - (IEntityDTOProjector.cs:6-16). Its one method is + (IEntityDTOProjector.cs:9-16). Its one method is IQueryable<TEntityDTO> ProjectTo(IQueryable<TEntity> source) (IEntityDTOProjector.cs:62), and the - implementation must stay translatable: no materializing inside it - (IEntityDTOProjector.cs:56-59), and no instance sub-mappers, custom mapping methods, or after-map - hooks, because a projection is an expression tree the database provider has to translate - (IEntityDTOProjector.cs:36-41). Registering one is the whole opt-in: the module scan picks it up - scoped beside the mappers (DependencyInjection.cs:155-162), and + implementation must stay translatable: no materializing inside it (IEntityDTOProjector.cs:56-58), + and no instance sub-mappers, custom mapping methods, or after-map hooks, because a projection is an + expression tree the database provider has to translate (IEntityDTOProjector.cs:36-41). Registering + one is the whole opt-in: the module scan picks it up scoped beside the mappers + (DependencyInjection.cs:166-170), and EntityQueryService<TEntity, TEntityDTO, TIdentifierType> declares a second, longer constructor purely so the container selects the projected path when one is registered and the plain path when it is not - (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:69-77, - EntityQueryService.cs:84, gate at EntityQueryService.cs:489-491). Because the two paths are chosen - by registration, a projector that disagrees with its mapper would make a response depend on which one - happened to be wired, which is why the contract says to pin the equivalence with a test - (IEntityDTOProjector.cs:42-46). That is [Rubric §12, Performance & Scalability] again, this time - on the read path (ADR-034).

    + (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:69-75, + EntityQueryService.cs:84, gate at EntityQueryService.cs:489-492, which also disqualifies tracked + reads and unsupported includes). Because the two paths are chosen by registration, a projector that + disagrees with its mapper would make a response depend on which one happened to be wired, which is + why the contract says to pin the equivalence with a test (IEntityDTOProjector.cs:42-46). That is + [Rubric §12, Performance & Scalability] again, this time on the read path + (ADR-034).

    +

    IEventUpcaster (.../Interfaces/IEventUpcaster.cs:28) and + IEventUpcasterRegistry (.../Interfaces/IEventUpcasterRegistry.cs:24) + are the versioning contracts for integration events, declared in this layer because both delivery + paths consume them. A breaking event-shape change is a new event type plus a consumer-side + upcaster, never a silent reshape of the existing type + (ADR-010), and the + typed IEventUpcaster<in TSource, out TTarget> (IEventUpcaster.cs:67) is the one an application + writes: it supplies SourceType, TargetType, and the non-generic Upcast as default interface + implementations (IEventUpcaster.cs:72-86), so the class body is the single typed conversion method + (IEventUpcaster.cs:82). Registration is one call, + services.AddEventUpcaster<TSource, TTarget, TUpcaster>() (DependencyInjection.cs:283-290), which + appends a singleton to an enumerable so several upcasters compose into a chain. The registry + (IEventUpcasterRegistry.cs:24) is the composed view: it probes whether a type has an upcaster + (IEventUpcasterRegistry.cs:31), resolves the terminal (newest) contract by walking the whole chain + (IEventUpcasterRegistry.cs:39), and upcasts an instance hop by hop + (IEventUpcasterRegistry.cs:48). Two properties make it safe to depend on unconditionally: it is + always registered, and with no upcasters its operations are identity + (DependencyInjection.cs:36-40, IEventUpcasterRegistry.cs:11-15); and every hop preserves the + envelope, restamping MessageId and DateOccurred from the pre-hop instance, so consumer-side inbox + deduplication keeps working on the id the producer published (IEventUpcaster.cs:21-26). A bad + registration graph (duplicate source, a source mapped onto itself, or a cycle) throws from the + implementation's constructor and is resolved at host start, so a misconfiguration fails the host + rather than the first message (IEventUpcasterRegistry.cs:17-22). The in-process consumer is the + DomainEventDispatcher, the broker-side one is + UpcastingIntegrationEventConsumer<TEvent> + (ADR-090).

    ICreateRequest (.../Interfaces/ICreateRequest.cs:8) is the smallest type in the group: an empty marker used purely as a generic constraint by IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> - so request-to-entity mapping is type-safe (ICreateRequest.cs:3-6). It pairs with + so request-to-entity mapping is type-safe (ICreateRequest.cs:3-7). It pairs with ICommandWithRequest<out TRequest> (.../UseCases/ICommandWithRequest.cs:14), whose single covariant Request property (ICommandWithRequest.cs:17) is what the module scan looks for when it auto-registers the delegating - validator described above.

    + validator described above (ICommandWithRequest.cs:4-11).

    Where this fits, and the failure-mode contract

    These contracts sit in the Application layer of Clean Architecture (primer §1), above Domain and below Infrastructure and the API. The @@ -641,11 +691,11 @@

    Where this fits, and the

    The contract to memorize, because the rest of the system relies on it, has four clauses. On a business failure (a Result with IsFailure, no exception thrown) the transaction is rolled back, atomicity over partial persistence, and cache invalidation is skipped - (DependencyInjection.cs:95-96, enforced at DbContextFactory.cs:565-570 and + (DependencyInjection.cs:103-104, enforced at DbContextFactory.cs:565-571 and CachingCommandDecorator.cs:76-78). On an exception the transaction also rolls back and the exception propagates outward through every decorator, which logs it and tags the metric exception - (DependencyInjection.cs:97, LoggingCommandDecorator.cs:52-58). On a short circuit (feature off, - permission denied, validation failed, budget expired) the handler is never called at all and the + (DependencyInjection.cs:105, LoggingCommandDecorator.cs:52-58). On a short circuit (feature + off, permission denied, validation failed, budget expired) the handler is never called at all and the caller gets a typed failure whose ErrorType is the decorator's own: NotFound, Forbidden, Validation, Failure respectively. And on the read side only non-failure results are ever cached (CachingQueryDecorator.cs:109). Note the revision history here: rollback-on-business-failure is the @@ -660,12 +710,13 @@

    ICreateRequest

    MMCA.Common.Application · MMCA.Common.Application.Interfaces · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICreateRequest.cs:8 · Level 0 · interface (marker, empty)

      -
    • What it is: an empty marker interface for "create" request DTOs, used as a generic type constraint by IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> to distinguish create-mapping from update-mapping at the type-system level.
    • +
    • What it is: an empty marker interface for "create" request DTOs, used as a generic type constraint by IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> to distinguish create-mapping from every other mapping path at the type-system level.
    • Depends on: nothing. Same presence-as-signal marker pattern as ITransactional.
    • -
    • Concept introduced, type-system constraints as documentation and enforcement. [Rubric §9, API & Contract Design] assesses how request contracts are modelled and kept unambiguous; tagging a DTO as "this is a create" lets generic mapper infrastructure (G12) refuse anything that is not a create request on the create-mapping path, catching a wiring mistake at compile time rather than at runtime.
    • -
    • Walkthrough: the body is empty ({ }, lines 9-10); the XML doc on lines 3-7 names the single consumer. All of the type's value is in the hierarchy.
    • -
    • Why it's built this way: a mapper constrained to where TCreateRequest : ICreateRequest makes it impossible to pass an update-request DTO into the create-mapping path, with no runtime check needed.
    • -
    • Where it's used: implemented by create-request DTOs in every module. Source search finds 6 in MMCA.ADC/Source (EventCreateRequest, SessionCreateRequest, SpeakerCreateRequest, SponsorCreateRequest, QuestionCreateRequest, ConferenceCategoryCreateRequest) and 8 in MMCA.Store/Source (ProductCreateRequest, ProductVariantCreateRequest, CategoryCreateRequest, OrderCreateRequest, CustomerCreateRequest, ShoppingCartCreateRequest, ShoppingCartItemCreateRequest, InventoryItemCreateRequest). Consumed as a generic constraint by IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> (G12).
    • +
    • Concept introduced, type-system constraints as documentation and enforcement. [Rubric §9, API & Contract Design] assesses how request contracts are modelled and kept unambiguous; tagging a DTO as "this is a create" lets generic mapper and controller infrastructure (G12) refuse anything that is not a create request on the create path, catching a wiring mistake at compile time rather than at runtime.
    • +
    • Walkthrough: the body is empty ({ }, ICreateRequest.cs:9-10); the XML doc (ICreateRequest.cs:3-7) names the constraint site. All of the type's value is in the hierarchy: there is no member to implement, so opting in costs a base-list entry and nothing else.
    • +
    • Why it's built this way: a mapper constrained to where TCreateRequest : ICreateRequest makes it impossible to pass a non-create DTO into the create-mapping path, with no runtime check needed and no reflection.
    • +
    • Where it's used: as a generic constraint in three framework places, all of them declaring where TCreateRequest : ICreateRequest: IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> (declared at MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEntityDTOMapper.cs:42-44, which is the file that hosts both mapper contracts), and the API controller pair IAggregateRootEntityControllerBase (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/IAggregateRootEntityControllerBase.cs:22) and AggregateRootEntityControllerBase (.../Controllers/AggregateRootEntityControllerBase.cs:43). Implemented by create-request DTOs in every module: 7 in MMCA.ADC/Source (EventCreateRequest, SessionCreateRequest, SpeakerCreateRequest, SponsorCreateRequest, QuestionCreateRequest, ActivityCreateRequest, ConferenceCategoryCreateRequest) and 8 in MMCA.Store/Source (ProductCreateRequest, ProductVariantCreateRequest, CategoryCreateRequest, OrderCreateRequest, CustomerCreateRequest, ShoppingCartCreateRequest, ShoppingCartItemCreateRequest, InventoryItemCreateRequest).
    • +
    • Caveats / not-in-source: the marker says "create", not "valid". Nothing in the type system checks that a ICreateRequest omits an identifier or carries the fields the aggregate factory needs; that is FluentValidation's and the factory's job.

    IDistributedLock

    @@ -676,10 +727,14 @@

    IDistributedLock

  • What it is: a one-method contract for mutual exclusion on a logical string key across every replica of a service. TryAcquireAsync hands back an IAsyncDisposable handle whose disposal releases the lock, or null when the key was still held elsewhere after the caller's wait elapsed.
  • Depends on: BCL only (Task, TimeSpan, IAsyncDisposable, CancellationToken), no first-party types. Its two implementations live in Infrastructure: InProcessDistributedLock and RedisDistributedLock. Contrast the per-process KeyedSemaphoreStripe, which is exactly what this interface exists to outgrow.
  • Concept introduced, cross-replica mutual exclusion as an Application-layer abstraction. [Rubric §12, Performance & Scalability] assesses whether a design still holds once the service scales out horizontally, and the XML doc opens with precisely that failure (IDistributedLock.cs:6-13): a SemaphoreSlim (or a striped one) serializes callers inside one process, so a service running more than one replica executes an "only one of these at a time" section once per replica. [Rubric §29, Resilience, Reliability & Business Continuity] assesses behavior under partial failure; this contract is documented as best-effort, not a consensus protocol (IDistributedLock.cs:23-28): a holder paused past its time-to-live loses the lock without being told, so the guarded section must stay correct (merely slower, or duplicated) when exclusion is lost. The doc states the usage rule bluntly: take the lock to collapse duplicate work, never as the only guard on a correctness invariant that persistence can enforce. [Rubric §3, Clean Architecture] assesses whether the core depends on abstractions while technology choices sit at the edge; the contract carries no transport type at all, so the StackExchange.Redis dependency stays in Infrastructure and callers here never see it.
  • -
  • Walkthrough: line 30 declares the interface; lines 59-63 declare its single member, Task<IAsyncDisposable?> TryAcquireAsync(string key, TimeSpan ttl, TimeSpan wait, CancellationToken cancellationToken = default). Every parameter carries a contract the implementations must honour. key is the logical name that callers sharing one backing store have to agree on (line 36). ttl is the crash guard: how long the lock survives with no explicit release, so a holder that dies mid-section cannot wedge the key; it must sit comfortably above the guarded section's expected duration, because work that outlives the TTL is no longer protected (lines 37-42). wait is how long to block for a current holder, and TimeSpan.Zero makes the call a single non-blocking attempt (lines 43-46). The token cancels the wait, not the work that follows it (line 47). The return contract matters as much as the parameters: null means "still held elsewhere after wait elapsed", and the handle is meant to be disposed inside an await using so release happens even when the guarded work throws (lines 48-53). Release is owner-scoped and idempotent (lines 54-58): disposing a handle whose TTL already lapsed is a no-op, not a release of whatever holder now owns the key. Two remarks bound usage further: implementations are singletons and must be safe to call concurrently (line 17), and the lock is not reentrant, so a caller that already holds key and asks for it again waits for itself and then fails to acquire (lines 20-21).
  • -
  • Why it's built this way: ADR-017 records the change that introduced it. The idempotency filter's execute-then-store window used to be guarded only by a process-local striped semaphore, which stops serializing anything the moment a service runs more than one replica, and both deployed apps do. Putting the contract in MMCA.Common.Application.Interfaces rather than Infrastructure is what lets the API filter depend on "a lock" while the Redis-versus-process-local decision stays a composition-root concern.
  • -
  • Where it's used: the Infrastructure composition root registers exactly one implementation inside AddCaching (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:191-204), choosing RedisDistributedLock when an IConnectionMultiplexer is resolvable and the warn-once InProcessDistributedLock otherwise. The one in-framework consumer is IdempotencyFilter, which resolves it from request services (MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:150) and spans the double-check plus action plus cache-store window with a 30-second ttl and a 5-second wait, answering a 409 in-flight-duplicate result instead of executing a second time when that wait expires with nothing cached.
  • -
  • Caveats / not-in-source: verified by source search across the workspace, no ADC or Store type takes an IDistributedLock today; the framework's own idempotency filter is the only production caller. The filter also resolves it with GetService<IDistributedLock>() and falls back to the striped-semaphore path when the result is null, even though AddCaching registers an implementation unconditionally, so that fallback is reachable only in a host that never calls AddCaching (or a test building its own provider).
  • +
  • Walkthrough: line 30 declares the interface; lines 59-63 declare its single member, Task<IAsyncDisposable?> TryAcquireAsync(string key, TimeSpan ttl, TimeSpan wait, CancellationToken cancellationToken = default). Every parameter carries a contract the implementations must honour. key is the logical name that callers sharing one backing store have to agree on (IDistributedLock.cs:36). ttl is the crash guard: how long the lock survives with no explicit release, so a holder that dies mid-section cannot wedge the key; it must sit comfortably above the guarded section's expected duration, because work that outlives the TTL is no longer protected (:37-42). wait is how long to block for a current holder, and TimeSpan.Zero makes the call a single non-blocking attempt (:43-46). The token cancels the wait, not the work that follows it (:47). The return contract matters as much as the parameters: null means "still held elsewhere after wait elapsed", and the handle is meant to be disposed inside an await using so release happens even when the guarded work throws (:48-53). Release is owner-scoped and idempotent (:54-58): disposing a handle whose TTL already lapsed is a no-op, not a release of whatever holder now owns the key. Two remarks bound usage further: implementations are singletons and must be safe to call concurrently (:17), and the lock is not reentrant, so a caller that already holds key and asks for it again waits for itself and then fails to acquire (:20-21).
  • +
  • Why it's built this way: ADR-017 records the change that introduced it. The idempotency filter's execute-then-store window was previously guarded only by a process-local striped semaphore, which stops serializing anything the moment a service runs more than one replica, and both deployed apps do. Putting the contract in MMCA.Common.Application.Interfaces rather than Infrastructure is what lets the API filter depend on "a lock" while the Redis-versus-process-local decision stays a composition-root concern.
  • +
  • Where it's used: the Infrastructure composition root registers exactly one implementation inside AddCaching (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:208-222), choosing RedisDistributedLock when an IConnectionMultiplexer is resolvable (:210-217) and the warn-once InProcessDistributedLock otherwise (:219-221); the comment above the registration explains the pairing with the cache (:204-207). Two production consumers exist today.
      +
    • The framework's IdempotencyFilter resolves it from request services (MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:150) and spans the double-check plus action plus cache-store window with a 30-second ttl (LockTimeToLive, :99) and a 5-second wait (LockWait, :106), calling TryAcquireAsync at :252. When that wait expires with nothing cached it answers a 409 in-flight-duplicate result instead of executing a second time (:263-273); when the lock call itself throws, it records a degraded metric and executes anyway rather than failing the request (:255-261).
    • +
    • MMCA.ADC's SessionScoringProcessor takes the lock per event around an AI-scoring pass (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Services/SessionScoringProcessor.cs:175-179) with a 15-minute ClaimTimeToLive (:85) and a ClaimWait of TimeSpan.Zero (:92), so a second replica that cannot claim the event simply skips its pass (:182-189). The inline rationale (:162-174) is a good worked example of both halves of the contract: the handle's disposal releases on every exit path, and the TTL releases for a replica that never reaches an exit path at all.
    • +
    +
  • +
  • Caveats / not-in-source: MMCA.Store/Source has no IDistributedLock consumer today. The idempotency filter also resolves it with GetService<IDistributedLock>() and falls back to the striped-semaphore path when the result is null (IdempotencyFilter.cs:150-155), even though AddCaching registers an implementation unconditionally, so that fallback is reachable only in a host that never calls AddCaching (or a test building its own provider). The ADC comment states the other honest limit: a host with no Redis gets the in-process implementation, where "cross-replica" exclusion degrades back to per-replica (SessionScoringProcessor.cs:172-174).

IScheduledJob

@@ -690,16 +745,20 @@

IScheduledJob

  • What it is: the contract for a unit of recurring work driven by a cron schedule: a stable Name, a default CronExpression, and an ExecuteAsync that runs one occurrence.
  • Depends on: BCL only (Task, CancellationToken). Executed by ScheduledJobRunner, persisted as ScheduledJobEntry rows, configured through SchedulerSettings and ScheduledJobOverrideSettings, and cron-parsed by Cronos (NuGet).
  • Concept introduced, recurring work as a first-class Application abstraction. [Rubric §13, Observability & Operability] assesses whether operators can see and steer background work; a job here is a named row with a schedule, an outcome and a last error, not an anonymous Timer. [Rubric §7, Microservices Readiness] and [Rubric §29, Resilience] assess behavior under scale-out and partial failure, and the interface's own doc is where the hard rules are written down (IScheduledJob.cs:8-35), so read them as contract, not commentary:
      -
    • Lifetime: jobs are resolved scoped, in a fresh DI scope per execution, exactly like a request. A job may take scoped dependencies (a unit of work, a repository, a command handler) and must hold no state between runs, because the previous instance is already disposed (lines 9-14).
    • -
    • Single runner across replicas: every replica runs a scheduler, but an occurrence executes once, because the persistent job store hands out a claim lease per row and only the claim winner runs (lines 16-21). A replica that dies mid-execution releases its claim implicitly when the lease expires.
    • -
    • Missed occurrences do not pile up: after an outage the job runs once and its next run is computed from the current instant, not from the backlog (lines 23-29). Work that must not be skipped therefore has to be idempotent and range-driven, processing everything since the last successful run rather than relying on one run per tick.
    • -
    • Failures are recorded, not fatal: an exception from ExecuteAsync is caught, logged and stamped on the row as a failed outcome while the schedule advances and the loop survives; there is no retry inside an occurrence (lines 31-33).
    • +
    • Lifetime: jobs are resolved scoped, in a fresh DI scope per execution, exactly like a request. A job may take scoped dependencies (a unit of work, a repository, a command handler) and must hold no state between runs, because the previous instance is already disposed (:9-14).
    • +
    • Single runner across replicas: every replica runs a scheduler, but an occurrence executes once, because the persistent job store hands out a claim lease per row (the outbox processor's claim idiom) and only the claim winner runs (:16-21). A replica that dies mid-execution releases its claim implicitly when the lease expires.
    • +
    • Missed occurrences do not pile up: after an outage the job runs once and its next run is computed from the current instant, not from the backlog (:23-29). Work that must not be skipped therefore has to be idempotent and range-driven, processing everything since the last successful run rather than relying on one run per tick.
    • +
    • Failures are recorded, not fatal: an exception from ExecuteAsync is caught, logged and stamped on the row as a failed outcome while the schedule advances and the loop survives; there is no retry inside an occurrence (:31-33).
  • -
  • Walkthrough: line 44 declares string Name { get; }, the stable identity that is also the primary key of the persisted row, so renaming it strands the old row and starts a new schedule, and two registered jobs must never share it (lines 38-43). Line 68 declares string CronExpression { get; }, a five-field expression (minute hour day-of-month month day-of-week) parsed by Cronos, with worked examples on lines 51-56. Two properties of that field are load-bearing: all times are UTC, never a local or configured zone, so a schedule never shifts, doubles or vanishes across a daylight-saving transition (lines 57-62), and the value is only the default, overridden per job by Scheduler:Jobs:{Name}:Cron in configuration whenever that key is present (lines 63-66). Line 78 declares Task ExecuteAsync(CancellationToken cancellationToken), whose token is cancelled on host shutdown; work that ignores it delays shutdown and can outlive its claim lease (lines 73-76).
  • -
  • Why it's built this way: ADR-074 records the design. Keeping the interface in Application (with no EF, no IHostedService, no cron library type in its signature) is what lets a module declare recurring work without taking an Infrastructure dependency, and lets the runner, the persistence of job state and the claim protocol all stay replaceable. Registration is deliberately split in two: AddScheduledJobs(configuration) enables the runner once per host, while AddScheduledJob<TJob>() adds one job. The two are order-free, both use TryAddEnumerable so a double call cannot produce two runners racing the same rows, and registering the scheduler is not the same as turning it on: everything stays inert until Scheduler:Enabled is true.
  • -
  • Where it's used: the framework ships exactly one implementation, AuditTrailCleanupJob (see AuditTrailCleanupJob), named "audit-trail-cleanup" and scheduled "0 3 * * *" (daily at 03:00 UTC). It is registered by AddAuditTrail rather than by AddScheduledJobs, which keeps the trail and the scheduler independent features.
  • -
  • Caveats / not-in-source: verified by source search, neither MMCA.ADC/Source nor MMCA.Store/Source implements IScheduledJob today; the retention job is the only job in the workspace. Retention therefore only happens in a host that enables both features: registering the trail without the scheduler records every change and purges nothing, leaving AuditTrail:RetentionDays inert.
  • +
  • Walkthrough: line 44 declares string Name { get; }, the stable identity that is also the primary key of the persisted row, so renaming it strands the old row and starts a new schedule, and two registered jobs must never share it (:38-43). Line 68 declares string CronExpression { get; }, a five-field expression (minute hour day-of-month month day-of-week) parsed by Cronos, with worked examples on :51-56. Two properties of that field are load-bearing: all times are UTC, never a local or configured zone, so a schedule never shifts, doubles or vanishes across a daylight-saving transition (:57-62), and the value is only the default, overridden per job by Scheduler:Jobs:{Name}:Cron in configuration whenever that key is present (:63-66). Line 78 declares Task ExecuteAsync(CancellationToken cancellationToken), whose token is cancelled on host shutdown; work that ignores it delays shutdown and can outlive its claim lease (:73-76).
  • +
  • Why it's built this way: ADR-074 records the design. Keeping the interface in Application (with no EF, no IHostedService, no cron library type in its signature) is what lets a module declare recurring work without taking an Infrastructure dependency, and lets the runner, the persistence of job state and the claim protocol all stay replaceable. Registration is deliberately split in two: AddScheduledJobs(configuration) enables the runner once per host (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:317, registering ScheduledJobRunner through TryAddEnumerable at :328), while AddScheduledJob<TJob>() adds one job (:352). Registering the scheduler is not the same as turning it on: everything stays inert until Scheduler:Enabled is true (:313).
  • +
  • Where it's used: two implementations exist in the workspace.
      +
    • The framework ships AuditTrailCleanupJob (see AuditTrailCleanupJob), named "audit-trail-cleanup" and scheduled "0 3 * * *", daily at 03:00 UTC (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailCleanupJob.cs:63,67). It is registered by AddAuditTrail rather than by AddScheduledJobs (.../Infrastructure/DependencyInjection.cs:404), which keeps the trail and the scheduler independent features (:377-379).
    • +
    • MMCA.ADC's SessionScoringSweepJob (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Services/SessionScoringSweepJob.cs:54) is named "conference-session-scoring-sweep" and runs "*/5 * * * *", every five minutes (:69,77), recovering AI-scoring passes interrupted inside a RecoveryWindow of 24 hours (:66). It is registered by the Conference module (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:54) and the scheduler itself is turned on in each ADC service host (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:313, and likewise in the Engagement and Identity service hosts).
    • +
    +
  • +
  • Caveats / not-in-source: MMCA.Store/Source implements no IScheduledJob today. Retention only happens in a host that enables both features: registering the trail without the scheduler records every change and purges nothing, leaving AuditTrail:RetentionDays inert, which is exactly what the AddAuditTrail doc warns about (.../Infrastructure/DependencyInjection.cs:377-380).

  • ITenantContext

    @@ -710,10 +769,10 @@

    ITenantContext

  • What it is: the scoped ambient contract for "which tenant is this scope running as": a nullable TenantId, an IsResolved flag, and a SetTenant that may be called once per scope.
  • Depends on: BCL only. Implemented by TenantContext in Infrastructure, populated at the edge by TenantResolutionMiddleware, and consumed by the caching decorators in this group plus the persistence layer (G07). Configured through TenancySettings. Deliberately mirrors ICorrelationContext.
  • Concept introduced, ambient scope state with an honest "unset" value. [Rubric §11, Security] assesses whether data isolation is enforced structurally rather than remembered per query; every tenant-aware read filter, save interceptor and cache key reads this one object, so a handler cannot forget to scope itself. [Rubric §10, Cross-Cutting Concerns]: like the correlation id, the value is captured once at the edge and flows implicitly for the rest of the scope. The interesting design decision is the one the doc calls out (ITenantContext.cs:10-15): unlike the correlation id there is no generated fallback. An unresolved tenant is a meaningful state (a background service, a seeder, an admin flow) and reads as "see everything", so inventing a value would silently scope a system operation to a tenant that does not exist.
  • -
  • Walkthrough: line 28 declares string? TenantId { get; }, null until resolved. Line 31 declares bool IsResolved { get; }. Line 41 declares void SetTenant(string tenantId), and its contract is the strict part: it throws ArgumentException on a null, empty or whitespace id (line 37) and InvalidOperationException when a different tenant was already resolved for this scope (lines 38-40), while accepting the value it already holds. The rationale is on lines 16-20: one scope, one tenant, because a scope whose tenant changed mid-flight has already read rows under the previous tenant and there is no honest way to reconcile that afterwards. The implementation matches exactly (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/TenantContext.cs:20-44), and its InvalidOperationException message tells the caller to start a new scope.
  • -
  • Why it's built this way: ADR-073 records the multi-tenancy model. Putting the contract in Application, not Infrastructure, is what lets the Application-layer caching decorators scope their keys without referencing EF Core: CachingCommandDecorator<TCommand, TResult> and CachingQueryDecorator<TQuery, TResult> both take it as an optional constructor parameter defaulting to null (.../Decorators/CachingCommandDecorator.cs:36, .../Decorators/CachingQueryDecorator.cs:38), so a single-tenant host that never calls AddMultiTenancy resolves them unchanged and pays nothing.
  • -
  • Where it's used: registered scoped in AddMultiTenancy in the Infrastructure composition root. Written at the edge by TenantResolutionMiddleware from a claim or a header, and re-asserted onto a fresh scope by every background path that fans out per tenant (the outbox processor and its cleanup service, AuditTrailCleanupJob, and startup database initialization). Read by DbContextFactory for per-tenant routing (also optional) and by both caching decorators through TenantCacheKey.Scope (.../Decorators/TenantCacheKey.cs:37-38).
  • -
  • Caveats / not-in-source: verified by source search, neither ADC nor Store resolves or sets ITenantContext today. Multi-tenancy is a shipped, tested framework capability that no deployed app has opted into, so every scope in production runs unresolved and the tenant-scoped cache keys and query filters are no-ops there.
  • +
  • Walkthrough: line 28 declares string? TenantId { get; }, null until resolved. Line 31 declares bool IsResolved { get; }. Line 41 declares void SetTenant(string tenantId), and its contract is the strict part: it throws ArgumentException on a null, empty or whitespace id (:37) and InvalidOperationException when a different tenant was already resolved for this scope (:38-40), while accepting the value it already holds (:34). The rationale is on :16-20: one scope, one tenant, because a scope whose tenant changed mid-flight has already read rows under the previous tenant and there is no honest way to reconcile that afterwards. The implementation matches exactly (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/TenantContext.cs:11-44), where IsResolved is simply TenantId is not null (:17), the first SetTenant assigns (:24-26), a repeat of the same value returns quietly (:32), and anything else throws.
  • +
  • Why it's built this way: ADR-073 records the multi-tenancy model. Putting the contract in Application, not Infrastructure, is what lets the Application-layer caching decorators scope their keys without referencing EF Core: CachingCommandDecorator<TCommand, TResult> and CachingQueryDecorator<TQuery, TResult> both take it as an optional primary-constructor parameter defaulting to null (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingCommandDecorator.cs:36 and .../CachingQueryDecorator.cs:38), so a single-tenant host that never calls AddMultiTenancy resolves them unchanged and pays nothing.
  • +
  • Where it's used: registered scoped in AddMultiTenancy (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:465). Written at the edge by TenantResolutionMiddleware from a claim or a header, and re-asserted onto a fresh scope by every background path that fans out per tenant (the outbox processor and its cleanup service, AuditTrailCleanupJob, and startup database initialization). Read by DbContextFactory for per-tenant routing (also optional) and by both caching decorators through TenantCacheKey.Scope, which prefixes the key only when a tenant is actually resolved (.../Decorators/TenantCacheKey.cs:37-40).
  • +
  • Caveats / not-in-source: verified by source search, neither MMCA.ADC/Source nor MMCA.Store/Source resolves or sets ITenantContext today. Multi-tenancy is a shipped, tested framework capability that no deployed app has opted into, so every scope in production runs unresolved and the tenant-scoped cache keys and query filters are no-ops there.

  • IAuditTrailReader

    @@ -722,12 +781,12 @@

    IAuditTrailReader

    • What it is: the one-method read surface over the recorded change history of a single entity: one page of changes, newest first.
    • -
    • Depends on: AuditTrailEntryDTO (its return payload, using at line 1). Implemented by AuditTrailReader over the AuditTrailEntry rows written by the audit-trail save interceptor.
    • +
    • Depends on: AuditTrailEntryDTO (its return payload, using at IAuditTrailReader.cs:1). Implemented by AuditTrailReader over the AuditTrailEntry rows written by the audit-trail save interceptor.
    • Concept introduced, shipping the read without shipping the exposure. [Rubric §30, Compliance, Privacy & Data Governance] assesses whether a system can answer "who changed this, and when"; the trail is that answer, and this is how an application asks. [Rubric §11, Security] assesses authorization placement, and the doc is explicit about the boundary it draws (IAuditTrailReader.cs:10-15): there is deliberately no shipped endpoint or page in v1, because who may see an entity's history is an application decision (an admin screen, a support tool, a data-subject request) rather than a framework one. Consumers wrap this in whatever query and authorization their domain calls for. [Rubric §3, Clean Architecture]: the contract speaks in strings and DTOs with no EF type in its signature, so the Application layer can offer history without knowing where rows live.
    • -
    • Walkthrough: lines 37-42 declare the single member, Task<IReadOnlyList<AuditTrailEntryDTO>> GetForEntityAsync(string entityType, string entityKey, int page = 1, int pageSize = 50, CancellationToken cancellationToken = default). The two identity parameters are string-typed on purpose, because they must match what the interceptor recorded: entityType is the full CLR type name (lines 25-28, for example typeof(Order).FullName) and entityKey is the invariant string form of the primary key, with composite parts joined by | in the model's key order (lines 29-32). Paging is forgiving rather than validating: values below 1 are treated as 1 for both page and pageSize (lines 33-34). Ordering is part of the contract, not an implementation detail (lines 17-18, 23): newest change first, so the first page is the most recent activity, and the implementation makes that stable by ordering ChangedOn descending with the row id descending as the tie-break.
    • -
    • Why it's built this way: ADR-075 records the trail. The interface exists at all so the read is testable and swappable, and it returns a DTO rather than the entity so the Application layer never handles a tracked row. The implementation is honest about a v1 limitation worth knowing before you build on it: trail rows are written to whichever database holds the entity that changed, which is what makes the write atomic, but this reader queries exactly one of them, the Default database of the engine named by AuditTrail:DataSource. For a monolith that is the whole trail; for a database-per-module host it is only the modules living in the default database.
    • -
    • Where it's used: registered scoped by AddAuditTrail, which is opt-in per host: a host that never calls it has no implementation to resolve (IAuditTrailReader.cs:6-7). The reader returns an empty list rather than throwing when the trail table is absent from the model, so registering the feature before flipping AuditTrail:Enabled is safe.
    • -
    • Caveats / not-in-source: verified by source search, no ADC or Store type consumes IAuditTrailReader today. Framework capability, no application consumer yet.
    • +
    • Walkthrough: IAuditTrailReader.cs:37-42 declare the single member, Task<IReadOnlyList<AuditTrailEntryDTO>> GetForEntityAsync(string entityType, string entityKey, int page = 1, int pageSize = 50, CancellationToken cancellationToken = default). The two identity parameters are string-typed on purpose, because they must match what the interceptor recorded: entityType is the full CLR type name (:25-28, for example typeof(Order).FullName) and entityKey is the invariant string form of the primary key, with composite parts joined by | in the model's key order (:29-32). Paging is forgiving rather than validating: values below 1 are treated as 1 for both page and pageSize (:33-34). Ordering is part of the contract, not an implementation detail (:17-18,23): newest change first, so the first page is the most recent activity, and the implementation makes that stable by ordering ChangedOn descending (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailReader.cs:63) with the row id descending as the tie-break.
    • +
    • Why it's built this way: ADR-075 records the trail. The interface exists at all so the read is testable and swappable, and it returns a DTO rather than the entity so the Application layer never handles a tracked row. The implementation is honest about a v1 limitation worth knowing before you build on it (AuditTrailReader.cs:17-22): trail rows are written to whichever database holds the entity that changed, which is what makes the write atomic, but this reader queries exactly one of them, the Default database of the engine named by AuditTrail:DataSource (resolved at AuditTrailReader.cs:54). For a monolith, where every source collapses onto Default, that is the whole trail; for a database-per-module host it is only the modules living in the default database.
    • +
    • Where it's used: registered scoped by AddAuditTrail (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:399), which is opt-in per host: a host that never calls it has no implementation to resolve (IAuditTrailReader.cs:6-7). The reader returns an empty list rather than throwing when the trail table is absent from the model (AuditTrailReader.cs:27-28), so registering the feature before flipping AuditTrail:Enabled is safe.
    • +
    • Caveats / not-in-source: verified by source search, no MMCA.ADC or MMCA.Store type consumes IAuditTrailReader today. It is a framework capability with no application consumer yet, which also means no shipped authorization decision to inherit: the first consumer owns that entirely.

    CacheKeyLocks

    @@ -737,13 +796,59 @@

    CacheKeyLocks

    • What it is: a two-line internal holder for the process-wide stripe table that the default ICacheService.GetOrCreateAsync<T> implementation uses to keep concurrent misses on one key from all running the factory.
    • Depends on: KeyedSemaphoreStripe (MMCA.Common.Shared.Concurrency, using at ICacheService.cs:1). Used only by ICacheService's default interface method.
    • -
    • Concept introduced, cache stampede protection and why the lock table is striped. [Rubric §12, Performance & Scalability] assesses behavior under load, and this is the classic thundering-herd guard: on a cold key, N concurrent readers would otherwise all miss, all call the expensive factory, and all write the same value. The interesting part is the shape of the guard. A per-key semaphore table forces a bad choice, spelled out on ICacheService.cs:135-140: drop the entry on release and two callers can run concurrently, or never drop it and a parameterized cache key grows the table without bound. Striping sidesteps both by hashing keys onto a fixed number of semaphores (KeyedSemaphoreStripe defaults to 256 stripes) and accepting that two unrelated keys occasionally share one. That is a fixed, bounded cost, and the stripes are never disposed because the table outlives every caller.
    • -
    • Walkthrough: line 142 declares internal static class CacheKeyLocks; line 145 declares its only member, internal static readonly KeyedSemaphoreStripe Locks = new(). The consuming sequence is the double-checked idiom at ICacheService.cs:104-124: a lock-free GetAsync fast path returns immediately on a hit (lines 107-110), then the stripe is taken (line 112), then the key is re-read inside the stripe (lines 116-118) so the waiters see what the winner just wrote, and only a still-missing key runs the factory and stores it (lines 120-122). The class doc (lines 127-133) explains the non-generic holder: statics on a generic method's declaring type would already be shared, but a holder keeps the table addressable and matches the sibling QueryCacheKeyLocks in the caching decorator (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingQueryDecorator.cs:194-197).
    • -
    • Why it's built this way: the two tables are separate on purpose (ICacheService.cs:137-140): these are different call sites over different keys, so sharing stripes would only widen the unrelated-key collisions striping already tolerates. Two limits of the mechanism are documented on the member it guards (ICacheService.cs:80-97) and matter more than the class itself. First, caching there is unconditional: whatever the factory returns is stored, including a failed Result, which is exactly why the caching decorators do NOT route through GetOrCreateAsync and keep their own read/execute/write sequence. Second, stampede protection is per process: the stripe table is process-wide, so with several replicas over one shared cache the factory can still run once per replica; a cluster-wide guarantee would need an IDistributedLock and is deliberately not attempted here.
    • -
    • Where it's used: only by the default implementation of ICacheService.GetOrCreateAsync<T> (ICacheService.cs:112). Backing stores with a native two-level primitive (see HybridCacheService) override the method and never touch this table (ICacheService.cs:92-97).
    • +
    • Concept introduced, cache stampede protection and why the lock table is striped. [Rubric §12, Performance & Scalability] assesses behavior under load, and this is the classic thundering-herd guard: on a cold key, N concurrent readers would otherwise all miss, all call the expensive factory, and all write the same value. The interesting part is the shape of the guard. A per-key semaphore table forces a bad choice, spelled out on ICacheService.cs:135-140: drop the entry on release and two callers can run concurrently, or never drop it and a parameterized cache key grows the table without bound. Striping sidesteps both by hashing keys onto a fixed number of semaphores (KeyedSemaphoreStripe) and accepting that two unrelated keys occasionally share one. That is a fixed, bounded cost, and the stripes are never disposed because the table outlives every caller.
    • +
    • Walkthrough: line 142 declares internal static class CacheKeyLocks; line 145 declares its only member, internal static readonly KeyedSemaphoreStripe Locks = new(). The consuming sequence is the double-checked idiom at ICacheService.cs:99-124: a null-check on the factory (:105), a lock-free GetAsync fast path that returns immediately on a hit (:107-110), then the stripe is taken (:112), then the key is re-read inside the stripe (:116-118) so the waiters see what the winner just wrote, and only a still-missing key runs the factory and stores it (:120-122). The class doc (:127-133) explains the non-generic holder: statics on a generic method's declaring type would already be shared, but a holder keeps the table addressable and matches the sibling QueryCacheKeyLocks in the caching decorator (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingQueryDecorator.cs:194, used at :89).
    • +
    • Why it's built this way: the two tables are separate on purpose (ICacheService.cs:137-140): these are different call sites over different keys, so sharing stripes would only widen the unrelated-key collisions striping already tolerates. Two limits of the mechanism are documented on the member it guards (ICacheService.cs:80-97) and matter more than the class itself. First, caching there is unconditional: whatever the factory returns is stored, including a failed Result or a null-equivalent value, which is exactly why the caching decorators do NOT route through GetOrCreateAsync and keep their own read/execute/write sequence (:82-86). Second, stampede protection is per process: the stripe table is process-wide, so with several replicas over one shared cache the factory can still run once per replica; a cluster-wide guarantee would need an IDistributedLock and is deliberately not attempted here (:88-91).
    • +
    • Where it's used: only by the default implementation of ICacheService.GetOrCreateAsync<T> (ICacheService.cs:112). Backing stores with a native two-level primitive (see HybridCacheService) override the method and never touch this table (:92-97).
    • Caveats / not-in-source: the type is internal, so it is not part of the public package surface and cannot be referenced or replaced from a consumer app; it is documented here because the behavior it produces is visible to anyone calling GetOrCreateAsync.

    +

    IEventUpcaster

    +
    +

    MMCA.Common.Application · MMCA.Common.Application.Interfaces · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEventUpcaster.cs:28 · Level 2 · interface (plus its typed generic sibling in the same file)

    +
    +
      +
    • What it is: the contract for converting one retired integration-event contract into its successor, so handlers are written once against the newest shape while older messages (queued at the broker, or sitting unprocessed in an outbox written before the upgrade) keep being delivered. The file declares two interfaces: the non-generic IEventUpcaster the framework resolves and indexes by (:28), and the typed IEventUpcaster<in TSource, out TTarget> application code actually implements (:67).
    • +
    • Depends on: IIntegrationEvent (using at :2) as the type of both ends of the conversion, and System.Diagnostics.CodeAnalysis.SuppressMessage (BCL). Composed by IEventUpcasterRegistry and registered through AddEventUpcaster<TSource, TTarget, TUpcaster>().
    • +
    • Concept introduced, event-schema evolution by additive versioning instead of in-place reshaping. [Rubric §6, CQRS & Event-Driven] assesses whether published event contracts can change without breaking subscribers, and [Rubric §9, API & Contract Design] assesses versioning of the contracts a system publishes. The policy behind this type is that a breaking event-shape change (a renamed, removed or retyped field) is a new event type plus a consumer-side upcaster, never a silent edit of an existing type (ADR-010); this interface plus its registration extension point is how that policy is actually expressed in code (ADR-090, cited at :15-19). The teaching point for a reader new to the pattern: upcasting is a read-side concern. Producers are never asked to publish two shapes, and handlers are never asked to accept two shapes. The conversion happens once, at the boundary between "what arrived" and "what the handlers are written for". [Rubric §7, Microservices Readiness] also applies, because a broker in front of independently-deployed services is exactly the environment where producer and consumer versions diverge for a while. [Rubric §16, Maintainability]: a chain of small pure functions is deletable in the order it was added, which is why the registration docs describe the retirement path (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:275-281).
    • +
    • Walkthrough, taking the two interfaces in the order the framework sees them.
        +
      • The non-generic IEventUpcaster (:28) declares three members: Type SourceType { get; } (:31), the retired contract it reads; Type TargetType { get; } (:34), the successor it produces; and IIntegrationEvent Upcast(IIntegrationEvent integrationEvent) (:41). This is the shape the registry indexes by and walks chains with, so nothing in the framework needs to know the closed generic types.
      • +
      • The typed IEventUpcaster<in TSource, out TTarget> : IEventUpcaster (:67) is what an application implements. Both parameters are constrained class, IIntegrationEvent (:68-69), and the variance annotations (in/out) are the natural ones for a converter. Its whole trick is default interface implementations: SourceType => typeof(TSource) (:72), TargetType => typeof(TTarget) (:75), and an explicit IIntegrationEvent IEventUpcaster.Upcast(...) that downcasts and forwards to the typed overload (:85-86). An implementer therefore writes exactly one method, TTarget Upcast(TSource integrationEvent) (:82), and gets the non-generic surface for free.
      • +
      • The [SuppressMessage] on CA1033 (:63-66) documents why: a default interface implementation of an inherited member can only be written as an explicit implementation, so there is no non-explicit form to offer child types.
      • +
      • Map payload fields only (:21-26 and :58-61). The framework preserves the envelope: after every hop the registry stamps MessageId and DateOccurred from the pre-hop instance onto the upcasted one, so consumer-side inbox deduplication keeps working on the id the producer published. An upcaster that copies them itself is harmless (the stamp is idempotent) and one that forgets is still correct.
      • +
      +
    • +
    • Why it's built this way: splitting the non-generic index surface from the typed authoring surface is what lets one registry hold heterogeneous upcasters in a single Dictionary<Type, IEventUpcaster> while implementers still write strongly typed code with no casts. Registration names both contracts explicitly, services.AddEventUpcaster<TOld, TNew, TUpcaster>() (.../DependencyInjection.cs:283-289), so the compiler checks the shape at the registration site rather than leaving a mismatch to fail on the first message (:265-270); implementations are registered singleton through TryAddEnumerable because they are pure functions (:288). Chains compose: registering V1 to V2 and V2 to V3 delivers a V1 message to the V3 handler (IEventUpcaster.cs:56).
    • +
    • Where it's used: composed by IEventUpcasterRegistry and thereby reached from both delivery paths, the in-process DomainEventDispatcher and the broker-side UpcastingIntegrationEventConsumer<TEvent>. Two architecture fitness rules police the shape across every repo, living once in the shared rules package: EventUpcastersHaveUniqueSourceTypes (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Upcasters.cs:12), because with two upcasters reading one type the contract a handler receives would depend on DI registration order, and EventUpcastersIncreaseSchemaVersion (:28), which reads the SchemaVersion off both contracts and fails when the target is not strictly higher (:44-48). The rules match the interface by name and arity (an ordinal comparison against the runtime interface name for IEventUpcaster with generic arity 2, :81-83) so the rule library keeps its no-compile-dependency idiom. SchemaVersion itself is the virtual int on BaseIntegrationEvent that defaults to 1 (MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/BaseIntegrationEvent.cs:32).
    • +
    • Caveats / not-in-source: verified by source search, no Source/ tree in the workspace contains an IEventUpcaster implementation today, in the framework or in MMCA.ADC, MMCA.Store or MMCA.Helpdesk. Every implementation is a test fixture (for example MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Services/EventUpcasterRegistryTests.cs:38,44 and the deliberately non-compliant fixtures the fitness rules assert against, MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:54-78). The mechanism is fully built and tested and has not yet had to be used on a real contract; the doc comment on the framework's own OutputCacheEvictionRequested records this as the shape a future V2 would take (MMCA.Common/Source/Core/MMCA.Common.Domain/IntegrationEvents/OutputCacheEvictionRequested.cs:21-23).
    • +
    +
    +

    IEventUpcasterRegistry

    +
    +

    MMCA.Common.Application · MMCA.Common.Application.Interfaces · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEventUpcasterRegistry.cs:24 · Level 2 · interface

    +
    +
      +
    • What it is: the composed view of every registered IEventUpcaster. It answers three questions: does anything upcast this type, what is the newest contract this type ends up as, and give me this instance converted to that contract, walking the whole chain.
    • +
    • Depends on: IIntegrationEvent (using at :1) and IEventUpcaster. Implemented by EventUpcasterRegistry (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EventUpcasterRegistry.cs:30).
    • +
    • Concept introduced, the empty-registry identity default. [Rubric §10, Cross-Cutting Concerns] assesses how an optional capability is threaded through a pipeline without every call site having to test for its presence. The registry is registered unconditionally by AddApplication() (.../Application/DependencyInjection.cs:40, comment at :36-39): with no upcasters registered it is an empty registry whose operations are the identity function, so both delivery paths can depend on it without a null check or a feature flag (ADR-090). [Rubric §15, Best Practices & Code Quality] and [Rubric §13, Observability & Operability] both apply to the failure model: a misregistration is a programming error, so it throws at composition time naming the offenders rather than returning a Result (EventUpcasterRegistry.cs:14-20), and a dedicated hosted service makes that happen at host start rather than on the first message.
    • +
    • Walkthrough: three members on the interface, and the implementation behind each is worth reading.
        +
      • bool HasUpcasterFor(Type eventType) (:31) is a dictionary probe (EventUpcasterRegistry.cs:85-90).
      • +
      • Type ResolveTerminalType(Type eventType) (:39) returns the newest contract the type upcasts to, or the type itself when nothing claims it (EventUpcasterRegistry.cs:93-98). It is a precomputed lookup, not a walk: BuildTerminalTypes resolves every chain once in the constructor (:133-161), because the chain graph is static once DI is built.
      • +
      • IIntegrationEvent UpcastToTerminal(IIntegrationEvent integrationEvent) (:48) applies every hop and preserves the envelope at each one (EventUpcasterRegistry.cs:101-123). Two details in the loop repay attention: it advances by the upcaster's declared TargetType rather than the runtime type of what was returned (:119, with the comment at :108-109 noting that the constructor's acyclicity check is what bounds the loop), and a null return from an upcaster throws with the offender named (:112-114).
      • +
      • Validation is constructor-time (EventUpcasterRegistry.cs:50-82). A self-mapping upcaster (:59-63) and two upcasters claiming one source (:65-69) are collected into an offenders list, so a misconfigured host sees all the problems at once rather than one per restart, then a single InvalidOperationException is thrown (:76-79). Cycles are caught separately in BuildTerminalTypes by walking each chain with a visited set and reporting the chain in order (:143-155); the comment explains why a repeated type is definitely a cycle and not a diamond (:126-128): the duplicate-source check already made the graph functional.
      • +
      • Envelope preservation (EventUpcasterRegistry.cs:169-179) reads MessageId and DateOccurred off the pre-hop instance and writes them onto the upcasted one through cached PropertyInfo handles, held in a static ConcurrentDictionary keyed by the produced type (:36) so a chain pays the reflection lookup once per contract. Both properties are init-only on BaseDomainEvent, which reflection can still set (:24-25), and a non-writable property is simply skipped (Writable, :181-182).
      • +
      +
    • +
    • Why it's built this way: ADR-090. Making envelope preservation the registry's job rather than the author's is the load-bearing choice: it means consumer-side inbox deduplication stays keyed on the id the producer published by construction, so no upcaster author can break deduplication by forgetting to copy a field they were never asked to think about (EventUpcasterRegistry.cs:22-27). Precomputing terminal types and caching envelope reflection keeps the per-message cost to dictionary lookups and delegate calls.
    • +
    • Where it's used: registered singleton by AddApplication() (.../Application/DependencyInjection.cs:40) and consumed by both delivery paths.
        +
      • In-process: DomainEventDispatcher holds it as a Lazy<IEventUpcasterRegistry?> resolved with GetService (MMCA.Common/Source/Core/MMCA.Common.Application/Services/DomainEventDispatcher.cs:32-33) and runs the integration branch through it before resolving handlers, so the handlers invoked are the ones written against the newest type (:62-64).
      • +
      • Broker-side: UpcastingIntegrationEventConsumer<TEvent> takes it as a constructor dependency (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/UpcastingIntegrationEventConsumer.cs:32) and dedups on the original message id before any upcasting (:46-48), registered per retired type with RegisterUpcastedIntegrationEventConsumer<TEvent>() (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/IntegrationEventConsumerExtensions.cs:78-90). That doc is explicit that you must not also register the plain IntegrationEventConsumer<TEvent> for the same type: two consumers on one event compete for the same queue and run the handlers twice (:61-64).
      • +
      • Startup: EventUpcasterStartupValidator is an IHostedService whose entire job is to resolve the registry and touch one member, turning the constructor validation into a host-start failure (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/EventUpcasterStartupValidator.cs:20,23-30). It is registered by AddInfrastructure through TryAddEnumerable (.../Infrastructure/DependencyInjection.cs:161) so several modules calling it do not run the validation several times.
      • +
      +
    • +
    • Caveats / not-in-source: because no host registers an upcaster today (see IEventUpcaster), every production resolution of this type is the empty-registry identity path; UpcastToTerminal returns its argument and ResolveTerminalType returns its argument. The chain-walking, cycle detection and envelope preservation described above are exercised only by tests at present.
    • +
    +

    IEntityDTOProjector<TEntity, TEntityDTO, TIdentifierType>

    MMCA.Common.Application · MMCA.Common.Application.Interfaces · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEntityDTOProjector.cs:51 · Level 4 · interface

    @@ -751,11 +856,11 @@

    IEntityDTOProject
    • What it is: an opt-in, one-method contract that rewrites an entity IQueryable into a DTO IQueryable, so the database returns only the columns the DTO actually has instead of whole entity rows that are mapped afterwards.
    • Depends on: AuditableBaseEntity<TIdentifierType> and IBaseDTO<TIdentifierType> as generic constraints (IEntityDTOProjector.cs:52-54, usings at :1-2). It is the pushdown counterpart of IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>, consumed by EntityQueryService<TEntity, TEntityDTO, TIdentifierType> and executed through IEntityQueryPipeline. Implementations are typically Mapperly-generated (Riok.Mapperly, NuGet).
    • -
    • Concept introduced, projection pushdown as an optional, additive read path. [Rubric §12, Performance & Scalability] assesses whether reads pay for data they do not return; the doc states the two costs the entity path incurs (IEntityDTOProjector.cs:9-16): the query must select whole entities (every column, plus a join per include the DTO happens to flatten), and every materialized row is mapped in .NET afterwards. A projector removes both by making the provider select the DTO's columns directly. [Rubric §8, Data Architecture] assesses how much shaping is pushed to the database. The design point worth internalising is that this is additive, never required: registering one for an entity is what switches that entity's list reads onto the projected path, and nothing breaks when none is registered because the query service falls back to materialize-then-map. The remarks then bound what a projection can express (:36-41): it is an expression tree the provider must translate, so no instance sub-mappers, no custom mapping methods (Use = nameof(...)), no after-map hooks, nothing that would have to run in .NET on a materialized object. A DTO whose shape needs any of those simply does not get a projector.
    • -
    • Walkthrough: line 51 declares public interface IEntityDTOProjector<TEntity, TEntityDTO, TIdentifierType>, constrained to an auditable entity, an IBaseDTO, and a notnull key (lines 52-54). Line 62 declares the single member, IQueryable<TEntityDTO> ProjectTo(IQueryable<TEntity> source), whose contract is stated in two halves: the input is an entity queryable already filtered, sorted, and paged (line 60), and the output must still be a translatable queryable, so an implementation must not materialize inside ProjectTo (lines 56-58). The doc carries a worked example (lines 23-35) of the idiomatic shape: a Mapperly [Mapper] static partial class exposing ProjectToDTO, wrapped by a small sealed class implementing this interface. The last remark is the correctness obligation (:42-46): a projector MUST produce the same values as the entity's mapper for the same row, because the two paths are chosen by registration, so a divergence would make a response depend on whether a projector happened to be registered. The doc says to pin the equivalence with a test, and the framework's own projector does exactly that.
    • +
    • Concept introduced, projection pushdown as an optional, additive read path. [Rubric §12, Performance & Scalability] assesses whether reads pay for data they do not return; the doc states the two costs the entity path incurs (IEntityDTOProjector.cs:9-16): the query must select whole entities (every column, plus a JOIN per include the DTO happens to flatten), and every materialized row is mapped in .NET afterwards. A projector removes both by making the provider select the DTO's columns directly. [Rubric §8, Data Architecture] assesses how much shaping is pushed to the database. The design point worth internalising is that this is additive, never required: registering one for an entity is what switches that entity's list reads onto the projected path, and nothing breaks when none is registered because the query service falls back to materialize-then-map. The remarks then bound what a projection can express (:36-41): it is an expression tree the provider must translate, so no instance sub-mappers, no custom mapping methods (Use = nameof(...)), no after-map hooks, nothing that would have to run in .NET on a materialized object. A DTO whose shape needs any of those simply does not get a projector, and its reads keep using the mapper.
    • +
    • Walkthrough: line 51 declares public interface IEntityDTOProjector<TEntity, TEntityDTO, TIdentifierType>, constrained to an auditable entity, an IBaseDTO, and a notnull key (:52-54). Line 62 declares the single member, IQueryable<TEntityDTO> ProjectTo(IQueryable<TEntity> source), whose contract is stated in two halves: the input is an entity queryable already filtered, sorted, and paged (:60), and the output must still be a translatable queryable, so an implementation must not materialize inside ProjectTo (:56-58). The doc carries a worked example (:23-35) of the idiomatic shape: a Mapperly [Mapper] static partial class exposing ProjectToDTO, wrapped by a small sealed class implementing this interface. The last remark is the correctness obligation (:42-46): a projector MUST produce the same values as the entity's mapper for the same row, because the two paths are chosen by registration, so a divergence would make a response depend on whether a projector happened to be registered. The doc says to pin the equivalence with a test, and the framework's own projector does exactly that.
    • Why it's built this way: ADR-055 records the optional projector on the read contract. The interesting mechanical detail is how "optional" is expressed in DI, because Microsoft.Extensions.DependencyInjection has no notion of an optional dependency: a single constructor naming an unregistered service fails to resolve, default value or not. EntityQueryService<TEntity, TEntityDTO, TIdentifierType> therefore declares a second, longer constructor that takes the projector (EntityQueryService.cs:69-77, rationale at :51-61); the container picks the longer one when a projector is registered and the shorter one when it is not, with no ambiguity because one parameter set is a strict superset of the other, and existing subclasses keep compiling untouched.
    • -
    • Where it's used: discovered by convention. ScanModuleApplicationServices<TAssemblyMarker>() scans a module assembly for IEntityDTOProjector<,,> and registers each as itself plus its interfaces, scoped, beside the DTO mappers (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:158-162), so a module only has to write the projector class. At read time EntityQueryService<TEntity, TEntityDTO, TIdentifierType> holds it as a nullable DTOProjector property (EntityQueryService.cs:84) and takes the projected branch only when CanProject is true (:303-313, predicate at :489-492). That predicate is three conditions: a projector is registered, the caller did not ask for tracking, and the query has no unsupported (cross-source) includes, because those are loaded row by row after materialization by the navigation populator and a projection has no rows to hand it (:476-483). Field shaping deliberately does not disqualify (:484-487). The framework ships one worked implementation, PushNotificationDTOProjector (MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/DTOs/PushNotificationDTOProjector.cs:35-45, see PushNotificationDTOProjector), registered explicitly by the notification module (.../Notifications/DependencyInjection.cs:50-52).
    • -
    • Caveats / not-in-source: verified by source search, neither MMCA.ADC nor MMCA.Store registers a projector today; outside the framework's own notification projector the only implementation in the workspace is MMCA.Helpdesk's TicketDTOProjector (MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Application/Tickets/DTOs/TicketDTOProjector.cs:38-47), which exists as the reference-app demonstration. Every list read in both production apps therefore still runs the materialize-then-map path. The equivalence obligation is also a convention, not a compiler rule: the framework's projector documents an enum-to-string divergence it had to inline by hand (PushNotificationDTOProjector.cs:13-20) and pins it with a test, but nothing stops a new projector from quietly disagreeing with its mapper.
    • +
    • Where it's used: discovered by convention. ScanModuleApplicationServices<TAssemblyMarker>() scans a module assembly for IEntityDTOProjector<,,> and registers each as itself plus its interfaces, scoped, beside the DTO mappers (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:166-170, with the opt-in rationale in the comment at :163-165), so a module only has to write the projector class. At read time EntityQueryService<TEntity, TEntityDTO, TIdentifierType> holds it as a nullable DTOProjector property (EntityQueryService.cs:84) and takes the projected branch only when CanProject is true (:303-313, predicate at :489-492). That predicate is three conditions: a projector is registered, the caller did not ask for tracking, and the query has no unsupported (cross-source) includes, because those are loaded row by row after materialization by the navigation populator and a projection has no rows to hand it (:476-482). Field shaping deliberately does not disqualify, because shaping runs after materialization over whatever object the pipeline produced (:484-487). The framework ships one worked implementation, PushNotificationDTOProjector (MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/DTOs/PushNotificationDTOProjector.cs:35-45), registered explicitly by the notification module (.../Notifications/DependencyInjection.cs:50-52).
    • +
    • Caveats / not-in-source: verified by source search, neither MMCA.ADC nor MMCA.Store registers a projector today; outside the framework's own notification projector the only implementation in the workspace is MMCA.Helpdesk's TicketDTOProjector (MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Application/Tickets/DTOs/TicketDTOProjector.cs:38-47), which exists as the reference-app demonstration. Every list read in both production apps therefore still runs the materialize-then-map path. The equivalence obligation is also a convention, not a compiler rule: the framework's projector documents an enum-to-string divergence it had to inline by hand (the entity's Status is an enum, the DTO's is a string, and the instance mapper's Use = nameof(MapStatusToString) is not expressible in an expression tree, so the projection inlines a conditional that the provider renders as a SQL CASE, PushNotificationDTOProjector.cs:13-19) and pins it with a test, but nothing stops a new projector from quietly disagreeing with its mapper.

    ICacheInvalidating

    diff --git a/docs/onboarding/group-07-persistence-ef-core.html b/docs/onboarding/group-07-persistence-ef-core.html index 4a357da..6eaac20 100644 --- a/docs/onboarding/group-07-persistence-ef-core.html +++ b/docs/onboarding/group-07-persistence-ef-core.html @@ -155,15 +155,17 @@

    7. Persistence & EF Core

    (IReadRepository<TEntity, TIdentifierType>, IWriteRepository<TEntity, TIdentifierType>, IRepository<TEntity, TIdentifierType>) coordinated by a - UnitOfWork; a data-source routing layer that lets every entity resolve to its own - physical database ("database per service") and every tenant optionally to its own copy of it; two - model-finalizing conventions that keep that routing honest; an engine-portable entity-configuration - hierarchy; and a supporting cast of value converters, value generators, an encryption converter, - seeders, and design-time factories. The group also hosts the framework's non-EF storage-adjacent - services: blob storage, image normalization, native push registration and delivery, and the shared - periodic-sweep base class. The whole thing is the [Rubric §8, Data Architecture] chapter of the - codebase, and it leans hard on [Rubric §7, Microservices Readiness] and - [Rubric §3, Clean Architecture].

    + UnitOfWork, with the query-shaping helpers + (SpecificationEvaluator, KeysetQueryBuilder) + that turn a specification or a cursor into SQL; a data-source routing layer that lets every entity + resolve to its own physical database ("database per service") and every tenant optionally to its own + copy of it; two model-finalizing conventions that keep that routing honest; an engine-portable + entity-configuration hierarchy; and a supporting cast of value converters, value generators, an + encryption converter, seeders, and design-time factories. The group also hosts the framework's + non-EF storage-adjacent services: blob storage, image normalization, native push registration and + delivery, and the shared periodic-sweep base class. The whole thing is the + [Rubric §8, Data Architecture] chapter of the codebase, and it leans hard on + [Rubric §7, Microservices Readiness] and [Rubric §3, Clean Architecture].

    One base context, one class per engine, one instance per database

    ApplicationDbContext (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:39) @@ -181,9 +183,10 @@

    One bas ScheduledJobEntry at :538-563, AuditTrailEntry at :572-601), each with the filtered indexes its poll path and its retention sweep need (IX_OutboxMessages_Pending at :496-499, IX_OutboxMessages_Processed at - :504-506, IX_InboxMessages_MessageId at :520-522, IX_ScheduledJobs_NextRunOn at :559-561, - IX_AuditTrailEntries_Entity at :592-593). Two of those four tables are gated: the job table is - mapped only when Scheduler:Enabled is set AND this context targets the Default source (jobs are + :504-506, IX_InboxMessages_MessageId at :520-522, IX_InboxMessages_ProcessedOn at :526-527, + IX_ScheduledJobs_NextRunOn at :559-561, IX_AuditTrailEntries_Entity at :592-593, + IX_AuditTrailEntries_ChangedOn at :598-599). Two of those four tables are gated: the job table + is mapped only when Scheduler:Enabled is set AND this context targets the Default source (jobs are host-scoped, :266-268), the trail table only when AuditTrail:Enabled is set, on every relational source (a trail row must commit with the change it describes, and a transaction does not span databases, :271). A host that opted into neither keeps the model it had before those features @@ -200,7 +203,7 @@

    One bas otherwise trigger a full DetectChanges, so a save paid three snapshot comparisons where one suffices, and the previous auto-detect setting is restored on the way out (:225).

    The design decision that shapes this whole group is stated in the base's own doc comment: one - context class per engine, one instance per physical data source (ApplicationDbContext.cs:28-33). + context class per engine, one instance per physical data source (ApplicationDbContext.cs:29-33). The same SQLServerDbContext class (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/SQLServerDbContext.cs:16) is instantiated once per SQL Server database, each instance carrying a different @@ -208,9 +211,9 @@

    One bas name). To keep EF from silently reusing the first-built model for every database, DataSourceModelCacheKeyFactory (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/DataSourceModelCacheKeyFactory.cs:16) - keys EF's model cache by context type plus physical source name plus the design-time flag, and is - installed by the base in OnConfiguring (ApplicationDbContext.cs:276). This is deliberately not a - per-module context split: one sealed context per engine over the abstract base is + keys EF's model cache by context type plus physical source name plus the design-time flag (:19-22), + and is installed by the base in OnConfiguring (ApplicationDbContext.cs:276). This is deliberately + not a per-module context split: one sealed context per engine over the abstract base is ADR-006's ruling. SQLServerDbContext adds the provider-specific touches: a per-environment command timeout read from PersistenceSettings rather than @@ -269,27 +272,29 @@

    SaveChanges as an interceptor pi justifies it. The exclusion is by instance rather than by entity state on purpose: a state-based filter would also drop events raised on an already-saved aggregate, which is how the identity module publishes its registration events (:155-159).

    -

    After the save, SavedChangesAsync does one of two things (DomainEventSaveChangesInterceptor.cs:278-295). - With no ambient transaction it flushes immediately: dispatch local events through +

    After the save, the post-save path DispatchAndFinalizeAsync + (DomainEventSaveChangesInterceptor.cs:278-295, reached from SavedChangesAsync at :89-98) does one + of two things. With no ambient transaction it flushes immediately: dispatch local events through IDomainEventDispatcher, remove exactly the - captured events from their aggregates, mark the local outbox rows processed, and signal the outbox for - integration events (:301-329). With an active transaction it removes the captured events (so a second - save inside the same transaction cannot re-capture them) and parks a - DeferredDispatch (:365) in a second weak table (:55); - DbContextFactory then calls the static FlushDeferredAsync only after a - successful commit (:128-137) and DropDeferred on rollback (:145). That is what keeps handler side - effects from acting on state that could still roll back, and what keeps a retrying execution strategy - from dispatching the same events once per attempt. Note the precision of the clearing: the interceptor - calls RemoveDomainEvents(capture.Events) rather than clearing the aggregate wholesale (:337-341), so - an event a handler raises on the same aggregate during in-process dispatch survives to a later capture - instead of being wiped. If in-process dispatch throws, the interceptor logs a warning and signals the - outbox to retry from the persisted rows rather than losing the event (:315-323). The synchronous - SavedChanges path cannot await a dispatcher at all, so it removes the captured events, signals the - outbox, and leaves delivery entirely to it (:108-121). Cosmos DB has no relational outbox table, so - the base exposes a SupportsOutbox flag (ApplicationDbContext.cs:116) that - CosmosDbContext overrides to false (CosmosDbContext.cs:69) and the interceptor - honors by dispatching everything in-process instead (:239-244). This split, atomic persistence plus - best-effort immediate dispatch with a durable fallback, is the at-least-once contract of + captured events from their aggregates, mark the local outbox rows processed through + OutboxFinalizer, and signal the outbox for integration + events (:301-329). With an active transaction it removes the captured events (so a second save inside + the same transaction cannot re-capture them) and parks a DeferredDispatch + (:365) in a second weak table (:55); DbContextFactory then calls the static + FlushDeferredAsync only after a successful commit (:128-137) and DropDeferred on rollback + (:145). That is what keeps handler side effects from acting on state that could still roll back, and + what keeps a retrying execution strategy from dispatching the same events once per attempt. Note the + precision of the clearing: the interceptor calls RemoveDomainEvents(capture.Events) rather than + clearing the aggregate wholesale (:337-341), so an event a handler raises on the same aggregate + during in-process dispatch survives to a later capture instead of being wiped. If in-process dispatch + throws, the interceptor logs a warning and signals the outbox to retry from the persisted rows rather + than losing the event (:315-323). The synchronous SavedChanges path cannot await a dispatcher at + all, so it removes the captured events, signals the outbox, and leaves delivery entirely to it + (:108-121). Cosmos DB has no relational outbox table, so the base exposes a SupportsOutbox flag + (ApplicationDbContext.cs:116) that CosmosDbContext overrides to false + (CosmosDbContext.cs:69) and the interceptor honors by dispatching everything in-process instead + (:239-244). This split, atomic persistence plus best-effort immediate dispatch with a durable + fallback, is the at-least-once contract of ADR-003; the consumer end lives in Group 04.

    The tenant boundary, read filter plus write guard

    @@ -303,25 +308,28 @@

    The tenant boundary, r so a scope with no tenant (the outbox processor, the seeders, the retention jobs) sees every tenant's rows (:435-441); and the column itself is declared required, 64 characters, non-Unicode, and indexed on relational engines, because every tenant-scoped read carries it as the leading predicate - (:411-422, width constant at :366). Because the two filters are named, EF composes them with AND, - and a caller asking for soft-deleted rows drops exactly the SoftDelete filter while the tenant filter - stays in force: the repository contract says so in as many words - (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:16-20, - :41-45).

    + (:411-422, width constant at :366). The filter reads the value through EF.Property rather than a + CLR member access, so an explicitly implemented interface member or a shadow property translates + identically (:428-433). Because the two filters are named, EF composes them with AND, and a caller + asking for soft-deleted rows drops exactly the SoftDelete filter while the tenant filter stays in + force: the repository contract says so in as many words + (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:16-19, + :75-79).

    The write half is TenantSaveChangesInterceptor (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/TenantSaveChangesInterceptor.cs:36). It stamps the scope's tenant onto an insert that declares none (:116-120), refuses an insert that names a different one (:122-123), and on update or delete checks both the original and the current value, so touching another tenant's row and reassigning a row to another tenant are both rejected - (:131-153). An untenanted insert from an untenanted scope is refused too, because silently writing a - row no tenant can ever read is worse than failing the save (:107-110). Owned types are skipped on both - sides: an owned value has no independent existence and its owner's tenant is already the row's tenant - (:70-75). Failures surface as CrossTenantWriteException + (:131-153, with the original reported in preference to the current one at :155-167). An untenanted + insert from an untenanted scope is refused too, because silently writing a row no tenant can ever read + is worse than failing the save (:107-110). Owned types are skipped on both sides: an owned value has + no independent existence and its owner's tenant is already the row's tenant (:70-75). Failures + surface as CrossTenantWriteException (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/CrossTenantWriteException.cs:24), which derives from InvalidOperationException so existing catch sites treat it like any other save-time invariant failure (:19-22). The deliberate asymmetry is documented in the interceptor's own remarks: a caller who bypasses the read filter with EF's parameterless IgnoreQueryFilters() can read across - tenants, but still cannot write across them (:30-34). That is [Rubric §11, Security] and + tenants, but still cannot write across them (:29-34). That is [Rubric §11, Security] and [Rubric §30, Compliance and Data Governance] in one type. The scope's tenant itself lives in TenantContext (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/TenantContext.cs:11), which is @@ -331,7 +339,7 @@

    The tenant boundary, r (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DataSources/TenantDataSourceTargets.cs:49-79), which emits the shared target for every source plus one extra TenantDataSourceTarget (:13) per tenant that overrides a source, because - a tenant with its own database is invisible to the shared sweep (:23-39).

    + a tenant with its own database is invisible to the shared sweep (:33-38).

    Recording what changed, the audit trail

    AuditTrailSaveChangesInterceptor (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailSaveChangesInterceptor.cs:62) @@ -342,11 +350,11 @@

    Recording what changed, the audi outbox precedent that a trail committable without its data is worse than no trail (:18-23). A Modified save produces one row per property whose value actually changed; Added and Deleted produce a single summary row with a null PropertyName - (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailEntry.cs:15-21). - Four things are worth knowing about it. It is opt-in twice over, once through AddAuditTrail (the - interceptor is resolved with GetService) and once through AuditTrail:Enabled (which maps the table), - and both are checked cheaply per save by asking the model whether the entity type exists at all - (:182-185). Personal data never reaches the table: a property carrying + (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailEntry.cs:15-21, + class at :23). Four things are worth knowing about it. It is opt-in twice over, once through + AddAuditTrail (the interceptor is resolved with GetService) and once through AuditTrail:Enabled + (which maps the table), and both are checked cheaply per save by asking the model whether the entity + type exists at all (:182-185). Personal data never reaches the table: a property carrying PiiAttribute records PiiRedactor.RedactedToken on both sides, and the redaction happens at capture, not at read, so the trail cannot become a second copy of a data @@ -357,15 +365,21 @@

    Recording what changed, the audi records is the ambient Activity trace id rather than a scoped correlation service, because a singleton interceptor holding a context built by the singleton physical factory cannot reach a scoped service without a lifetime bug; the doc comment says exactly that and names the accessor pattern - tenancy introduced as the way to change it later (:44-54). Two more types close the feature: - AuditTrailReader (.../AuditTrail/AuditTrailReader.cs:35) serves paged history - for one entity and states its own v1 limitation, that it reads only the Default source's trail table - (:16-25), and AuditTrailCleanupJob (.../AuditTrail/AuditTrailCleanupJob.cs:48) - is the framework's own recurring job, purging rows past RetentionDays from every relational source - nightly at 03:00 UTC in 1000-row ExecuteDelete batches (:58, :67, :70-80). It only runs if the - host also runs the scheduler, and a host that records the trail without one is fully supported: pruning - is then the operator's job (:23-28).

    -

    Repositories and the unit of work

    + tenancy introduced as the way to change it later (:44-54). The values every row of one save shares + (user, instant, trace id, tenant) are gathered once into a CaptureContext record + struct (:189-193, declared at :541), and a row describing an insert whose key the database has not + assigned yet is parked as a PendingEntityKey (:550) until the store-generated + key exists. Two more types close the feature: AuditTrailReader + (.../AuditTrail/AuditTrailReader.cs:35) serves paged history for one entity and states its own v1 + limitation, that it reads only the Default source's trail table (:17-24), and + AuditTrailCleanupJob (.../AuditTrail/AuditTrailCleanupJob.cs:48) is the + framework's own recurring IScheduledJob, purging rows past + RetentionDays from every relational source nightly at 03:00 UTC in 1000-row ExecuteDelete batches + (:58, :63, :67, :77-85), expanding that source list through + TenantDataSourceTargets so a tenant with its own database gets swept too + (:83). It only runs if the host also runs the scheduler, and a host that records the trail without one + is fully supported: pruning is then the operator's job (:23-28).

    +

    Repositories, specifications, and the unit of work

    Handlers do not touch a DbContext directly. They ask a UnitOfWork (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/UnitOfWork.cs:13) for a repository. The repository contract is deliberately interface-segregated @@ -375,7 +389,7 @@

    Repositories and the unit of work

    IEntityReader<TEntity, TIdentifierType> (IRepository.cs:21) or IEntityQuerier<TEntity, TIdentifierType> (:80); IReadRepository<TEntity, TIdentifierType> (:221) combines - both plus four IQueryable surfaces (tracking, no-tracking, single-query, split-query, :226-236), + both plus four IQueryable surfaces (tracking, no-tracking, single-query, split-query, :227-236), IWriteRepository<TEntity, TIdentifierType> (:244) adds mutation, and IRepository<TEntity, TIdentifierType> (:349) is the union. That layering is the group's clearest [Rubric §1, SOLID] (interface-segregation) statement, @@ -402,6 +416,27 @@

    Repositories and the unit of work

    .../Repositories/UpdatePropertySetterBuilder.cs:14
    ), which is what keeps EF Core out of the Application layer, and because ExecuteUpdate bypasses the interceptor pipeline the repository stamps LastModifiedOn/By itself unless the caller assigned them (EFRepository.cs:121-132).

    +

    The read repository does not compose queries by hand. SpecificationEvaluator + (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/SpecificationEvaluator.cs:20) + turns an ISpecification<TEntity, TIdentifierType> + into an IQueryable: criteria always, then the includes, the + OrderExpression chain, and the paging a + QuerySpecification<TEntity, TIdentifierType> + carries (:36-61), with the shape deliberately skipped for aggregate reads because joining includes to + count rows costs a join per navigation (:29-34). Tracking and soft-delete scope are not its + business: those choose the base queryable, which only the repository can do (:14-18). It also owns the + one split-query heuristic in the framework, opting into AsSplitQuery as soon as any include targets a + collection navigation (:85-93), and EFReadRepository.ApplyIncludes delegates to it so the + string-include path and the specification path cannot drift (:69-72, + EFReadRepository.cs:302). Cursor paging is the sibling helper: + KeysetQueryBuilder (.../Repositories/KeysetQueryBuilder.cs:22) resolves the + requested sort property or fails validation (:35-47), orders by (sortKey, Id) with the identifier + tie-break that makes the order total (:59-75), and builds the composite seek predicate against the + last row of the previous page (:102), so GetPageByCursorAsync + (IRepository.cs:207, implemented at EFReadRepository.cs:369) seeks straight to the boundary instead + of counting past every skipped row. Exactly one sort key is supported, by design + (KeysetQueryBuilder.cs:17-20). That is [Rubric §12, Performance and Scalability] expressed as a + contract rather than as advice.

    Two factories keep the wiring honest. RepositoryFactory (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/Factory/RepositoryFactory.cs:14) builds a repository over a given context and conditionally wraps it in a MiniProfiler decorator @@ -409,35 +444,36 @@

    Repositories and the unit of work

    EFReadRepositoryDecorator<TEntity, TIdentifierType>) when UseMiniProfiler is on (:33-38, :57-62), adding timing without the base repository knowing, and it activates both through a cached compiled ObjectFactory rather than reflecting on every call - (:69-84). DbContextFactory + (:67-84). DbContextFactory (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:39) is the scoped coordinator: it caches one ApplicationDbContext per - DataSourceKey so every repository in a scope shares one change tracker, gives each - new context a live tenant accessor rather than a copied value (:134-140), and enlists a late-created - context into an already-open transaction (:108-109). It is also the database-per-tenant routing point: - when the scope's tenant overrides a source, the context is created against that tenant's connection - string while keeping the original DataSourceKey, which is what lets one compiled model serve every - tenant's database (:148-173), and a cached routed context is refused to a second tenant rather than - silently serving the first tenant's rows (:181-198). Its save loop runs up to MaxSavePasses (3, - :53) passes over the cached contexts, because dispatching events in-process can materialize a context - for a source nobody had touched yet (:242-256), and it closes with a hard assertion: any context still - reporting ChangeTracker.HasChanges() when the unit of work returns throws rather than silently - discarding those changes (:264-275). Because there can be more than one physical source in play, - ExecuteInTransactionAsync runs the operation under the first transactional context's execution - strategy, opens a transaction per source, and commits them sequentially with no two-phase commit - (:501-543); cross-source consistency is the outbox's job, and the doc comment is explicit that a - commit failure on the second source leaves the first one committed (:492-499). The method is - re-entrant: a nested call joins the ambient transaction instead of opening a second one, so only the - outermost call may begin, commit, roll back, or flush (:505-513). A returned failed - Result rolls back exactly like an exception (:562-569), + DataSourceKey so every repository in a scope shares one change tracker (:88-118), + gives each new context a live tenant accessor rather than a copied value (:134-140), and enlists a + late-created context into an already-open transaction (:108-109). It is also the database-per-tenant + routing point: when the scope's tenant overrides a source, the context is created against that tenant's + connection string while keeping the original DataSourceKey, which is what lets one compiled model + serve every tenant's database (:148-173), and a cached routed context is refused to a second tenant + rather than silently serving the first tenant's rows (:181-198). Its save loop runs up to + MaxSavePasses (3, :53) passes over the cached contexts, because dispatching events in-process can + materialize a context for a source nobody had touched yet (:242-256), and it closes with a hard + assertion: any context still reporting ChangeTracker.HasChanges() when the unit of work returns throws + rather than silently discarding those changes (:264-275).

    +

    Because there can be more than one physical source in play, ExecuteInTransactionAsync (:504-546) + runs the operation under the first transactional context's execution strategy, opens a transaction per + source, and commits them sequentially with no two-phase commit (TryCommit at :623-655); + cross-source consistency is the outbox's job, and the doc comment is explicit that a commit failure on + the second source leaves the first one committed (:492-502). The method is re-entrant: a nested call + joins the ambient transaction instead of opening a second one, so only the outermost call may begin, + commit, roll back, or flush (:508-516). A returned failed + Result rolls back exactly like an exception (:565-572), which is what makes ADR-013's Result-over-exceptions rule safe for partial persistence; rollback also drops the deferred event dispatch (:448-452), and a retry resets the change tracker first so the aborted attempt's Added - entities are not inserted twice (ResetForRetry at :682-689). DbContextFactory further carries the + entities are not inserted twice (ResetForRetry at :711-718). DbContextFactory further carries the SET IDENTITY_INSERT machinery (IdentityInsertGroup at :410, the per-table save split at :289-361) for importing entities with explicit database-generated ids one table at a time, and the MigrateAsync / HasPendingMigrationsAsync sweeps over every SQL Server source in use - (:659-675).

    + (:688-704).

    UnitOfWork sits on top, resolving an entity's physical source through IDataSourceService, handing the matching context to the factory, and caching the resulting repository per closed generic interface type (UnitOfWork.cs:33-66). The physical creation @@ -459,11 +495,12 @@

    Repositories and the unit of work

    IdentityInsertGroup, the per-table batch the SET IDENTITY_INSERT loop saves one at a time, and TransactionCommitAmbiguousException - (.../Factory/TransactionCommitAmbiguousException.cs:22), which ExecuteInTransactionAsync throws when - the commit itself fails with an outcome nobody can vouch for. That last one is raised outside the - execution strategy on purpose (DbContextFactory.cs:535-540), because the strategy walks an - exception's whole inner chain to decide retriability and would otherwise re-run the operation on top of - a possibly-durable commit.

    + (.../Factory/TransactionCommitAmbiguousException.cs:22), which the commit path raises when the commit + itself fails with an outcome nobody can vouch for, naming each physical source's outcome (committed, + ambiguous, or rolled back) so the partial state is observable rather than inferred (:57-69, + DbContextFactory.cs:644-649). That exception is thrown outside the execution strategy on purpose + (DbContextFactory.cs:538-543), because the strategy walks an exception's whole inner chain to decide + retriability and would otherwise re-run the operation on top of a possibly-durable commit.

    Routing an entity to its database

    The heart of ADR-006 is that every entity resolves to a DataSourceKey @@ -497,7 +534,7 @@

    Routing an entity to its database

    :75-82
    , :157-160). Because the registry reads the same attributes the model configuration reads, routing and model contents agree by construction, and configurations that implement a provider interface directly without the attributed - base classes are deliberately skipped as legacy (:168-178). DataSourceService + base classes are deliberately skipped as legacy (:163-178). DataSourceService (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/DataSourceService.cs:12) is the thin application-facing facade over IEntityDataSourceRegistry, and it answers the one question navigation loading needs: two entities support EF .Include() only when their physical @@ -530,7 +567,7 @@

    Two model-finalizing conventions

    a smaller but sharper hole. Soft-delete hides a row from queries, but a plain unique index still enforces uniqueness against it, so "deleting" a speaker would permanently block re-creating one with the same email. The convention appends an IsDeleted = 0 filter to every unique index on a soft-deletable entity, - leaves hand-authored filters untouched, and no-ops for Cosmos (SoftDeleteUniqueIndexConvention.cs:33-56). + leaves hand-authored filters untouched, and no-ops for Cosmos (SoftDeleteUniqueIndexConvention.cs:27-55). The predicate text itself is not built inline: both this convention and the opt-in HasSoftDeleteFilter extension go through SoftDeleteFilterSql.Build (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/SoftDeleteFilterSql.cs:27-38), which @@ -578,7 +615,7 @@

    Entity configuration and en (.../Configuration/EntityTypeBuilderExtensions.cs:12) flattens a Money into an amount plus an ISO 4217 code column with a read-leg fallback to the zero-Money sentinel Currency - (:19, :24-50); the four converters in Persistence/Conversions map + (:19, mapping at :62-76, fallback at :71); the four converters in Persistence/Conversions map Email and PhoneNumber to plain strings in required (EmailValueConverter at .../Conversions/EmailValueConverter.cs:33, @@ -590,7 +627,7 @@

    Entity configuration and en (.../Conversions/EnumerationValueConverter.cs:33) plus its nullable sibling NullableEnumerationValueConverter<TEnumeration> (:62) store a smart enumeration as its plain int value, so replacing a CLR enum property with an enumeration - is not a schema change (:6-11).

    + is not a schema change.

    Discovery runs through ModelBuilderExtensions.ApplyAllConfigurations (.../DbContexts/ModelBuilderExtensions.cs:10, an extension(ModelBuilder) block at :12), which the base calls with an entity filter so each database's model receives only its own entities @@ -608,8 +645,9 @@

    Entity configuration and en the Notification schema because namespace derivation would otherwise resolve them to Common (PushNotificationConfiguration.cs:8-15, :25). This engine-portability design is ADR-018 (polyglot persistence); note - the current-reality caveat: the SQLite and Cosmos plumbing is shipped and tested, but SQL Server is the - only engine backing production entities today.

    + the current-reality caveat: the SQLite and Cosmos plumbing is shipped and tested, but every concrete + subclass of the SQLite and Cosmos configuration bases lives in Common's own test projects, so SQL Server + is the only engine backing production entities today.

    Encryption, seeding, design time, and the shared helpers

    A handful of supporting pieces round out the EF side. EncryptedStringConverter (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Encryption/EncryptedStringConverter.cs:72) @@ -645,15 +683,18 @@

    Encryption, seedi (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Infrastructure/Services/PaymentReconciliationService.cs:39), and ADR-052 covers in-process background work generally.

    -

    Seeding and design time close the loop. IDbSeeder and the DbSeeder base +

    Seeding and design time close the loop. IDbSeeder + (.../Seeding/IDbSeeder.cs:7) and the DbSeeder base (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Seeding/DbSeeder.cs:7) give module seeders a GetId<TIdentifier> helper that maps integer seed ids to either int or a deterministic Guid so seed data reproduces across key strategies (:20-39), and IdentityModuleDbSeederBase<TUser> - (.../Seeding/IdentityModuleDbSeederBase.cs:38) hoists the five-times-repeated account-seeding idiom out - of the two app identity modules, leaving only two app-specific hooks and a ShouldSeed opt-in gate that - defaults to true (:50, :57, :60-71), each account described by a SeedAccount record - (.../Seeding/SeedAccount.cs:17). For migrations, DesignTimeDbContextHelper + (.../Seeding/IdentityModuleDbSeederBase.cs:38) hoists the repeated account-seeding idiom out of the two + app identity modules, leaving only app-specific hooks and a ShouldSeed opt-in gate that defaults to true + (:50, :57, :60-71), each account described by a SeedAccount record + (.../Seeding/SeedAccount.cs:17) whose own remarks warn that seed credentials are plaintext by + construction and therefore development-only data (:6-11). For migrations, + DesignTimeDbContextHelper (.../DbContexts/Design/DesignTimeDbContextHelper.cs:36) builds a SQLServerDbContext for dotnet ef without the app's DI container: a downstream migrations project writes a few-line IDesignTimeDbContextFactory (:18-35), and @@ -661,8 +702,9 @@

    Encryption, seedi (:106-124), so each database gets its own migrations project. It composes minimal stand-ins (ExplicitAssemblyProvider at :126, NullDomainEventDispatcher at :131) and a - DesignTimeDbContextOptions carrying the connection settings, then wires - the same DataSourceResolver and + DesignTimeDbContextOptions + (.../Design/DesignTimeDbContextOptions.cs:11) carrying the connection settings, then wires the same + DataSourceResolver and EntityDataSourceRegistry the runtime uses so the design-time model matches the runtime one (:57-101). It registers the tenant interceptor, the scheduler options and the audit-trail options unconditionally, defaulted to disabled, precisely so dotnet ef scaffolds the same migration for @@ -678,10 +720,11 @@

    Blobs, images, and native push

    is the Azure implementation over a single pre-provisioned container, and NullFileStorageService (.../Services/NullFileStorageService.cs:11) fails uploads with a named error while letting deletes succeed (:17-25). IImageProcessor - and ImageSharpImageProcessor (.../Services/ImageSharpImageProcessor.cs:14) + (.../Interfaces/Infrastructure/IImageProcessor.cs:11) and + ImageSharpImageProcessor (.../Services/ImageSharpImageProcessor.cs:14) normalize untrusted uploads by decoding, baking in the EXIF orientation, center-cropping to a square, stripping the EXIF, XMP, and IPTC profiles, and re-encoding as JPEG at quality 85, so only pixels survive - (:21-42); the dependency-free ImageContentSniffer + (:17-42); the dependency-free ImageContentSniffer (.../Interfaces/Infrastructure/ImageContentSniffer.cs:10) is its upload-side companion, deciding the accepted formats (JPEG, PNG, WebP) from magic bytes rather than the client-declared content type (:15-36). Both are ADR-045, and both @@ -700,6 +743,26 @@

    Blobs, images, and native push

    :44-49) and chunks user tags at the hub's 20-tag expression cap (:13, :59-63), which is what makes those rules unit-testable without a hub. This channel sits beside the persisted notification record and the SignalR path in Group 10.

    +

    Three broker-side types share the same Infrastructure/Services folder without being storage at all, and + they belong to the events story in Group 04 and + Group 05 rather than to persistence. + FaultIntegrationEventConsumer<TEvent> + (.../Services/FaultIntegrationEventConsumer.cs:27) consumes MassTransit's Fault<TEvent> when a consumer + exhausts its retry policy, turning a silent row in the broker's error queue into one structured Error log + plus a broker.fault.count metric tagged by event type (:32-56); it never throws, because a fault + consumer that faults would publish Fault<Fault<TEvent>> and could re-enter itself (:18-23). + UpcastingIntegrationEventConsumer<TEvent> + (.../Services/UpcastingIntegrationEventConsumer.cs:31) is the draining consumer for a retired contract: + it binds the old queue, upcasts each message to its terminal contract through + IEventUpcasterRegistry, and dispatches the handlers + registered for that newer type, deduplicating on the ORIGINAL message id through + IInboxStore so a redelivery is recognized whatever contract the + handlers ultimately see (:49-63). EventUpcasterStartupValidator + (.../Services/EventUpcasterStartupValidator.cs:20) is the hosted service that forces that registry to be + constructed at host start, so a duplicate source, a self-mapping, or a cycle fails the host rather than + dead-lettering events hours later (:23-30). All three are + ADR-090 and + [Rubric §13, Observability and Operability] material.

    Where this group sits

    Persistence is the concrete floor the abstract domain stands on. The entity bases and audit contracts from Group 02 are what the interceptors stamp and the query filters hide; @@ -707,13 +770,13 @@

    Where this group sits

    DomainEventSaveChangesInterceptor drains into the outbox that Group 04 delivers; the transactional decorator in Group 05 is what opens the transaction whose commit releases the deferred - dispatch; the specifications and query service in Group 03 run - through this group's repositories and IQueryable surfaces; the navigation populators in - Group 11 fill the cross-source gaps the degrade convention opens; the - scheduler and settings types this group's gated tables answer to live in - Group 14; and the entity-source registry answers the .Include() - questions the populators ask. The design axes here are now three orthogonal ones collapsed behind a single - DataSourceKey plus a scoped tenant: + dispatch; the specifications and query service in Group 03 are + evaluated by this group's SpecificationEvaluator against its repositories and + IQueryable surfaces; the navigation populators in Group 11 fill the + cross-source gaps the degrade convention opens; the scheduler and settings types this group's gated tables + answer to live in Group 14; and the entity-source registry answers + the .Include() questions the populators ask. The design axes here are three orthogonal ones collapsed + behind a single DataSourceKey plus a scoped tenant: ADR-006's Name axis (which database), ADR-018's Engine axis (which storage technology), and ADR-073's tenant axis @@ -3536,12 +3599,12 @@

    NativePushPayloads

    MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Services · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/NativePushPayloads.cs:10 · Level 0 · class (internal static)

      -
    • What it is: a pure helper that builds the platform-native JSON bodies (FCM v1 for Android, APNs for Apple) and the user:{id} OR-tag expressions that an Azure Notification Hubs send needs. It holds no state and touches no hub, so the payload shapes and the tag-chunking rule are unit-testable in isolation (NativePushPayloads.cs:5-10).
    • +
    • What it is: a pure helper that builds the platform-native JSON bodies (FCM v1 for Android, APNs for Apple) and the user:{id} OR-tag expressions an Azure Notification Hubs send needs. It holds no state and touches no hub, so the payload shapes and the tag-chunking rule are unit-testable in isolation (NativePushPayloads.cs:5-10).
    • Depends on: the BCL only: System.Text.Json.JsonSerializer for the payload strings, Enumerable.Chunk for the OR-expression batching, and the UserIdentifierType alias (see primer §2) for the user-tag input.
    • Concept introduced, native push payload construction and the 20-tag chunk rule. [Rubric §7, Microservices Readiness] assesses whether cross-cutting delivery mechanics live behind a reusable, transport-specific boundary rather than smeared through handlers; here the exact wire shapes of two third-party push protocols are pinned in one place. Azure Notification Hubs caps a single tag expression at 20 tags (MaxTagsPerExpression, NativePushPayloads.cs:13), so a user-targeted broadcast to a large audience is split into Chunk(20) groups, each rendered as a user:a || user:b || ... OR-expression (NativePushPayloads.cs:59-63). That cap is a real hub limit, not an arbitrary batch size, which is why it is a named constant the sender and the registrar both reuse rather than a literal.
    • Walkthrough: BuildFcmV1Payload (NativePushPayloads.cs:16-28) nests a notification block of title/body under a message envelope, adding a data map only when metadata is non-empty (the { Count: > 0 } pattern, NativePushPayloads.cs:22). BuildApnsPayload (NativePushPayloads.cs:31-53) builds the APNs aps.alert block, then copies each metadata pair up to the top level as a custom key while explicitly refusing to overwrite the reserved aps key (NativePushPayloads.cs:44-49). BuildUserTagExpressions (NativePushPayloads.cs:59-63) maps each id through UserTag, chunks, and joins. UserTag (NativePushPayloads.cs:66-67) formats user:{userId} under InvariantCulture via string.Create, so a numeric id never picks up a locale-specific separator.
    • -
    • Why it's built this way: keeping the payload shapes and the hub's tag cap in a stateless helper (ADR-044) means the AzureNotificationHubNativePushSender stays a thin adapter and the fiddly JSON/tag rules can be proven correct without a live hub or credentials.
    • -
    • Where it's used: consumed by AzureNotificationHubNativePushSender (payloads and tag expressions, AzureNotificationHubNativePushSender.cs:21-24) and AzureNotificationHubDeviceRegistrar twice: the UserTag stamped on each installation (AzureNotificationHubDeviceRegistrar.cs:41) and the same tag re-read to verify ownership before a delete (AzureNotificationHubDeviceRegistrar.cs:112).
    • +
    • Why it's built this way: keeping the payload shapes and the hub's tag cap in a stateless helper (ADR-044) means AzureNotificationHubNativePushSender stays a thin adapter and the fiddly JSON/tag rules can be proven correct without a live hub or credentials (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/NativePushPayloadsTests.cs).
    • +
    • Where it's used: consumed by AzureNotificationHubNativePushSender (payloads and tag expressions, AzureNotificationHubNativePushSender.cs:21-24) and by AzureNotificationHubDeviceRegistrar twice: the UserTag stamped on each installation (AzureNotificationHubDeviceRegistrar.cs:41) and the same tag re-read to verify ownership before a delete (AzureNotificationHubDeviceRegistrar.cs:112).
    • Caveats / not-in-source: internal, so it is reachable only inside MMCA.Common.Infrastructure and its InternalsVisibleTo test project.

    PeriodicBackgroundService

    @@ -3549,7 +3612,7 @@

    PeriodicBackgroundService

    MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Services · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/PeriodicBackgroundService.cs:20 · Level 0 · class (public abstract partial)

      -
    • What it is: the framework's base class for fixed-interval background sweeps. A subclass supplies an interval and one cycle body; the base supplies the enablement gate, the startup delay, the loop, the never-die error handling, and a clock that tests can drive (PeriodicBackgroundService.cs:6-22).
    • +
    • What it is: the framework's base class for fixed-interval background sweeps. A subclass supplies an interval and one cycle body; the base supplies the enablement gate, the startup delay, the loop, the never-die error handling, and a clock tests can drive (PeriodicBackgroundService.cs:6-22).
    • Depends on: no first-party types at all. It extends Microsoft.Extensions.Hosting.BackgroundService and takes TimeProvider plus a non-generic ILogger through its primary constructor (PeriodicBackgroundService.cs:20-22).
    • Concept introduced, the clock-injected periodic hosted service. [Rubric §14, Testability] assesses whether time-dependent behavior can be exercised without waiting for real time: every wait here goes through the injected TimeProvider (PeriodicBackgroundService.cs:55 and PeriodicBackgroundService.cs:80), so a FakeTimeProvider can advance an hour-scale loop instantly, which is exactly what the unit tests do (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/PeriodicBackgroundServiceTests.cs:90-100). [Rubric §29, Resilience & Business Continuity] applies to the failure contract: a throwing cycle is logged and the loop continues to the next interval (PeriodicBackgroundService.cs:73-76), so one bad sweep cannot silently take a reconciliation job offline for the life of the process. [Rubric §13, Observability & Operability] shows in the two source-generated [LoggerMessage] methods (PeriodicBackgroundService.cs:89-93), which is why the class is partial: a disabled service says so at Information level, a failed cycle logs at Error with the exception.
    • Walkthrough
        @@ -3560,7 +3623,7 @@

        PeriodicBackgroundService

    • Why it's built this way: the class doc states the boundary explicitly (PeriodicBackgroundService.cs:12-16): this shape fits periodic reconciliation and cleanup work, and is deliberately not used by the outbox processor, whose signal-driven smart wait does not fit a fixed interval. ADR-054 records the same loop shape (gate, startup delay, per-cycle try/catch, TimeProvider waits) as the framework's answer for reconciliation sweeps.
    • -
    • Where it's used: its one production subclass is MMCA.Store's PaymentReconciliationService, the saga-timeout backstop for the Stripe payment flow (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Infrastructure/Services/PaymentReconciliationService.cs:39). That subclass is a good read for how little a derived sweep has to write: it overrides Interval from configuration (PaymentReconciliationService.cs:46), IsEnabled to log the specific reason it is off rather than the base's generic line (PaymentReconciliationService.cs:54-72), and ExecuteCycleAsync (PaymentReconciliationService.cs:75). The other subclass in the workspace is the CountingSweep test double (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/PeriodicBackgroundServiceTests.cs:103-104). MMCA.Common's own hosted services predate the base class and hand-roll their loops directly on BackgroundService: OutboxCleanupService is one (declared at MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/OutboxCleanupService.cs:40, deriving from BackgroundService at :48).
    • +
    • Where it's used: its one production subclass is MMCA.Store's PaymentReconciliationService, the saga-timeout backstop for the Stripe payment flow (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Infrastructure/Services/PaymentReconciliationService.cs:33, deriving at :39). That subclass is a good read for how little a derived sweep has to write: it overrides Interval from configuration (PaymentReconciliationService.cs:46), IsEnabled to log the specific reason it is off rather than the base's generic line (PaymentReconciliationService.cs:54), and ExecuteCycleAsync (PaymentReconciliationService.cs:75). The other subclass in the workspace is the CountingSweep test double (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/PeriodicBackgroundServiceTests.cs:103-104). MMCA.Common's own hosted services predate the base class and hand-roll their loops directly on BackgroundService: OutboxCleanupService is one.
    • Caveats / not-in-source: ADR-054 records that PaymentReconciliationService is the base class's only subclass in any of the applications, so adoption is real but narrow; treat the class as an available base rather than as a description of how every sweep in the workspace is built.

    AzureNotificationHubNativePushSender

    @@ -3568,24 +3631,12 @@

    AzureNotificationHubNativePushSend

    MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Services · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/AzureNotificationHubNativePushSender.cs:14 · Level 1 · class (sealed partial)

      -
    • What it is: the Azure Notification Hubs implementation of INativePushSender: the real, mobile-facing native notification channel that pushes FCM v1 and APNs payloads through a hub client (AzureNotificationHubNativePushSender.cs:7-16).
    • +
    • What it is: the Azure Notification Hubs implementation of INativePushSender, the mobile-facing native notification channel that pushes FCM v1 and APNs payloads through a hub client (AzureNotificationHubNativePushSender.cs:7-16).
    • Depends on: INativePushSender (the contract it fulfills), NativePushPayloads (payload and tag construction), and two externals: Microsoft.Azure.NotificationHubs.INotificationHubClient (the hub SDK) and ILogger<T>.
    • -
    • Concept introduced, the native (mobile) push channel and its best-effort contract. [Rubric §13, Observability & Operability] covers whether side-effecting integrations log their outcomes and fail without taking the request down; this sender emits a structured log per send (LogNativePushSent, AzureNotificationHubNativePushSender.cs:42-43) and its class comment records that callers treat the channel as best-effort (AzureNotificationHubNativePushSender.cs:11-12). That is literally true at the call site: SendPushNotificationHandler wraps the native send in a catch (Exception) annotated "native delivery is best-effort" (MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/Send/SendPushNotificationHandler.cs:147-148). This is the device-facing counterpart to the in-app SignalR channel: NullPushNotificationSender and its SignalR sibling deliver to connected web clients, whereas this one reaches devices via APNs and FCM.
    • +
    • Concept introduced, the native (mobile) push channel and its best-effort contract. [Rubric §13, Observability & Operability] covers whether side-effecting integrations log their outcomes and fail without taking the request down; this sender emits a structured log per send (LogNativePushSent, AzureNotificationHubNativePushSender.cs:42-43) and its class comment records that callers treat the channel as best-effort (AzureNotificationHubNativePushSender.cs:11-12). That is literally true at the call site: SendPushNotificationHandler wraps the native send in a catch annotated "native delivery is best-effort" (MMCA.Common/Source/Core/MMCA.Common.Application/Notifications/PushNotifications/UseCases/Send/SendPushNotificationHandler.cs:141-148). This is the device-facing counterpart to the in-app SignalR channel: NullPushNotificationSender and its SignalR sibling deliver to connected web clients, whereas this one reaches devices via APNs and FCM.
    • Walkthrough: the primary constructor takes the hub client and logger (AzureNotificationHubNativePushSender.cs:14-16). SendToUsersAsync (AzureNotificationHubNativePushSender.cs:19-31) builds both payloads once (AzureNotificationHubNativePushSender.cs:21-22), then for each 20-tag OR-expression sends an FcmV1Notification and an AppleNotification targeted at that expression (AzureNotificationHubNativePushSender.cs:24-28), so one call fans out to both platforms per audience chunk. BroadcastAsync (AzureNotificationHubNativePushSender.cs:34-40) sends the same two payloads with no tag filter, reaching every registered installation. Both ConfigureAwait(false) on every await (library code, no sync context needed, ADR-049) and log the title on completion.
    • Why it's built this way: the partial class exists so the [LoggerMessage] source generator can emit LogNativePushSent (AzureNotificationHubNativePushSender.cs:42-43), the high-performance logging pattern used across the framework. Splitting payload construction into NativePushPayloads (ADR-044) keeps this type a pure transport adapter.
    • -
    • Where it's used: registered as a transient INativePushSender by AddNativePushNotifications(configuration) in place of NullNativePushSender (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:579); resolved by SendPushNotificationHandler (SendPushNotificationHandler.cs:21).
    • -
    -

    ExplicitAssemblyProvider

    -
    -

    MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DbContexts.Design · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Design/DesignTimeDbContextHelper.cs:126 · Level 1 · class (sealed, private nested)

    -
    -
      -
    • What it is: a tiny private nested provider inside DesignTimeDbContextHelper that returns a fixed, caller-supplied list of entity-configuration assemblies (DesignTimeDbContextHelper.cs:126-129).
    • -
    • Depends on: IEntityConfigurationAssemblyProvider (the contract) and System.Reflection.Assembly.
    • -
    • Concept reinforced, explicit assembly enumeration in place of runtime scanning. [Rubric §8, Data Architecture] looks at whether the model's entity set is deterministic per database; at runtime the framework discovers configuration assemblies by scanning the AppDomain through DefaultEntityConfigurationAssemblyProvider, but dotnet ef design-time commands see none of that. GetConfigurationAssemblies (DesignTimeDbContextHelper.cs:128) simply hands back the assemblies the migrations project listed via DesignTimeDbContextOptions.AddConfigurationAssembly, so the design-time model contains exactly the intended entities and nothing else.
    • -
    • Why it's built this way: it is the design-time substitute for the AppDomain-scanning provider; keeping it private and trivial means the migrations authoring surface stays DesignTimeDbContextOptions, not this class.
    • -
    • Where it's used: instantiated once inside DesignTimeDbContextHelper.CreateSqlServer (DesignTimeDbContextHelper.cs:57), passed straight to the EntityDataSourceRegistry it builds (DesignTimeDbContextHelper.cs:62), registered as the IEntityConfigurationAssemblyProvider for the design-time container (DesignTimeDbContextHelper.cs:90), and handed to the context constructor (DesignTimeDbContextHelper.cs:99).
    • -
    • Caveats / not-in-source: private nested type; it surfaces in the inventory only because the tool includes private nested classes. Not reachable from outside the helper.
    • +
    • Where it's used: registered as a transient INativePushSender by AddNativePushNotifications(configuration) in place of NullNativePushSender (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:592), and only after that method has confirmed the NativePush section is enabled and carries both a connection string and a hub name (DependencyInjection.cs:581-591). Resolved by SendPushNotificationHandler (SendPushNotificationHandler.cs:21).

    NullNativePushSender

    @@ -3597,7 +3648,7 @@

    NullNativePushSender

  • Concept reinforced, the Null Object pattern as the safe default channel. [Rubric §2, Design Patterns] values a harmless default that satisfies a contract without a live dependency; registering this type by default means DI resolution and the Devices/send endpoints work everywhere, even in a host with no notification hub. The real AzureNotificationHubNativePushSender is swapped in only when AddNativePushNotifications(configuration) runs against an enabled, fully-configured hub (NullNativePushSender.cs:6-9).
  • Walkthrough: SendToUsersAsync and BroadcastAsync (NullNativePushSender.cs:13-18) each match the interface signature and return a completed task; there is no logging and no failure, by design.
  • Why it's built this way: ADR-044 gives the framework three notification channels; a no-op default keeps the native channel optional, so a host that never configures a hub still composes and runs.
  • -
  • Where it's used: registered with TryAddTransient as the default INativePushSender in AddInfrastructure (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:478), paired with NullPushDeviceRegistrar on the next line for the same disabled-hub scenario (DependencyInjection.cs:479).
  • +
  • Where it's used: registered with TryAddTransient as the default INativePushSender in AddServices (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:491), paired with NullPushDeviceRegistrar on the next line for the same disabled-hub scenario (DependencyInjection.cs:492, under the comment at :489-490).
  • TenantContext

    @@ -3606,32 +3657,14 @@

    TenantContext

    • What it is: the scoped holder of "which tenant is this scope running as". One instance per DI scope, unresolved until something calls SetTenant, which is the state every background service, seeder, and design-time tool stays in (TenantContext.cs:6-11).
    • Depends on: ITenantContext (the Application-layer contract it implements) and System.Globalization.CultureInfo for the exception message.
    • -
    • Concept introduced, the ambient tenant as a scoped value with a one-way latch. [Rubric §11, Security] assesses whether isolation boundaries are enforced rather than trusted, and [Rubric §8, Data Architecture] covers how a shared database keeps tenants apart. Multi-tenancy here (ADR-073) is row-level by default: the model gives every non-owned ITenantEntity a global query filter whose predicate lifts ApplicationDbContext.CurrentTenantId into a SQL parameter (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:99, applied by ApplyTenantFilters at :394-441), and TenantSaveChangesInterceptor stamps or verifies TenantId on every write (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/TenantSaveChangesInterceptor.cs:64-90). This class is the single value both of those read. Two design decisions are worth internalizing before you write anything tenant-aware:
        -
      • An unresolved tenant means "see everything", not "see nothing". There is deliberately no generated fallback the way ICorrelationContext has one, because a background worker or a seeder legitimately runs outside any tenant, and inventing an id would silently scope a system operation to a tenant that does not exist (ITenantContext.cs:9-15). The filter's CurrentTenantId == null disjunct is what implements that (ApplicationDbContext.cs:388).
      • +
      • Concept introduced, the ambient tenant as a scoped value with a one-way latch. [Rubric §11, Security] assesses whether isolation boundaries are enforced rather than trusted, and [Rubric §8, Data Architecture] covers how a shared database keeps tenants apart. Multi-tenancy here (ADR-073) is row-level by default: the model gives every non-owned ITenantEntity a global query filter whose predicate lifts ApplicationDbContext.CurrentTenantId into a SQL parameter (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:99, applied by ApplyTenantFilters at :394 and attached as the named Tenant filter at :441, the name constant at :360), and TenantSaveChangesInterceptor stamps or verifies TenantId on every write (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/TenantSaveChangesInterceptor.cs:64-93). This class is the single value both of those read. Two design decisions are worth internalizing before you write anything tenant-aware:
          +
        • An unresolved tenant means "see everything", not "see nothing". There is deliberately no generated fallback the way ICorrelationContext has one, because a background worker or a seeder legitimately runs outside any tenant, and inventing an id would silently scope a system operation to a tenant that does not exist (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ITenantContext.cs:9-15). The filter's CurrentTenantId == null disjunct is what implements that (ApplicationDbContext.cs:388).
        • One scope, one tenant. Changing the tenant mid-scope is refused, because rows already read or tracked in the scope were read under the first tenant and there is no honest way to reconcile that afterwards (ITenantContext.cs:16-20).
      • Walkthrough: TenantId is a private set auto-property (TenantContext.cs:14) and IsResolved is simply TenantId is not null (TenantContext.cs:17). SetTenant (TenantContext.cs:20-44) does three things in order: it rejects null/empty/whitespace up front with ArgumentException.ThrowIfNullOrWhiteSpace (TenantContext.cs:22); it latches the value when none is held yet (TenantContext.cs:24-28); and when a value is already held it compares ordinally, returning quietly for the same tenant (idempotent, so the resolution middleware and a worker re-asserting the tenant on the same scope do not fight, TenantContext.cs:30-35) and throwing an InvalidOperationException for a different one. That exception message names both tenants and tells the caller what to do instead: start a new scope (TenantContext.cs:37-43).
      • -
      • Why it's built this way: the registration is unconditional. AddServices registers it with TryAddScoped whether or not the host called AddMultiTenancy, and the comment says why: everything that reads it treats an unresolved tenant as "no tenancy", so always-on registration costs one object per scope and removes a whole class of "works until someone forgets the opt-in" bug (DependencyInjection.cs:448-452). AddMultiTenancy binds and validates the Tenancy settings and switches on resolution at the edge; it does not install the isolation, which is always present and always inert (DependencyInjection.cs:402-409).
      • -
      • Where it's used: written at the API edge by TenantResolutionMiddleware from the configured claim or header (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/TenantResolutionMiddleware.cs:70), and re-asserted on a fresh scope by every background path that must run as a tenant: OutboxProcessor (OutboxProcessor.cs:264), OutboxCleanupService (OutboxCleanupService.cs:100), AuditTrailCleanupJob (AuditTrailCleanupJob.cs:106), and the per-tenant database initializer (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:128). It is read by DbContextFactory for per-tenant database routing (DbContextFactory.cs:44, used at :102, :139 and :150) and by the caching decorators, which scope cache keys through TenantCacheKey.Scope (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/TenantCacheKey.cs:37, called from CachingQueryDecorator.cs:64 and CachingCommandDecorator.cs:82 with the context injected at CachingQueryDecorator.cs:38 and CachingCommandDecorator.cs:36).
      • -
      -

      DesignTimeDbContextOptions

      -
      -

      MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DbContexts.Design · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Design/DesignTimeDbContextOptions.cs:11 · Level 2 · class (sealed)

      -
      -
        -
      • What it is: the configuration carrier a migrations project fills in to tell DesignTimeDbContextHelper how to build a context for dotnet ef ... -- --datasource <Name>. It holds the connection settings, the named data-source entries, two model-shape flags, and the explicit list of entity-configuration assemblies (DesignTimeDbContextOptions.cs:11-61).
      • -
      • Depends on: ConnectionStringSettings, DataSourceEntrySettings, and System.Reflection.Assembly.
      • -
      • Concept introduced, design-time context construction for database-per-service. [Rubric §8, Data Architecture] assesses whether each database's migrations are built in isolation; in the database-per-service model (ADR-006) each module's migrations project must scaffold a context for only its own database. At design time there is no DI container and no AppDomain scan, so this options object captures everything dotnet ef cannot discover on its own: the top-level connection strings including SQLServerMigrationsAssembly (DesignTimeDbContextOptions.cs:20-24), the named DataSources entries (DesignTimeDbContextOptions.cs:26-27), and the explicit configuration assemblies (DesignTimeDbContextOptions.cs:57-61, whose comment notes the runtime scan sees nothing here).
      • -
      • Walkthrough
          -
        • DataSourceName (DesignTimeDbContextOptions.cs:18) is optional; when null the helper parses --datasource and falls back to Default.
        • -
        • EnableScheduler (DesignTimeDbContextOptions.cs:41) mirrors Scheduler:Enabled and decides whether the ScheduledJobs table is part of the design-time model. It defaults to false so dotnet ef keeps producing exactly the migrations it produced before the scheduler shipped (ADR-074). The remarks are the operational rule: set it in the migrations project of the Default data source of a host that calls AddScheduledJobs, and only there, because the table is host-scoped and a second migrations project that also enabled it would create a second copy (DesignTimeDbContextOptions.cs:35-40).
        • -
        • EnableAuditTrail (DesignTimeDbContextOptions.cs:55) mirrors AuditTrail:Enabled for the AuditTrailEntries change-history table (ADR-075), and the rule is the inverse of the scheduler's: set it in every data source whose entities are audited, because a trail row is written to the database holding the entity that changed (DesignTimeDbContextOptions.cs:49-54). Both flags carry the same warning: the flag must match the host's configuration or the scaffolded migrations and the running model disagree.
        • -
        • AddConfigurationAssembly (DesignTimeDbContextOptions.cs:66-75) is a chainable builder method that null-guards and skips duplicates before adding.
        • -
        -
      • -
      • Why it's built this way: a single options object plus a builder method keeps each per-module migrations factory to a handful of lines while still pinning the model to one database (ADR-006). The two boolean flags exist because the model is configuration-shaped: opt-in tables would otherwise be invisible to dotnet ef, which has no configuration to read.
      • -
      • Where it's used: passed to DesignTimeDbContextHelper.CreateSqlServer(args, options => ...) from each per-database migrations factory; the helper's class doc shows the exact shape (DesignTimeDbContextHelper.cs:20-32), and a real one is MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Conference/DesignTimeSQLServerDbContextFactory.cs:15-51, which sets DataSourceName = "Conference" (:32) and both flags to true (:37-38).
      • +
      • Why it's built this way: the registration is unconditional. AddServices registers it with TryAddScoped whether or not the host called AddMultiTenancy, and the comment says why: everything that reads it treats an unresolved tenant as "no tenancy", so always-on registration costs one object per scope and removes a whole class of "works until someone forgets the opt-in" bug (DependencyInjection.cs:461-465). AddMultiTenancy binds and validates the Tenancy settings and switches on resolution at the edge; it does not install the isolation, which is always present and always inert (DependencyInjection.cs:415-449).
      • +
      • Where it's used: written at the API edge by TenantResolutionMiddleware from the configured claim or header (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/TenantResolutionMiddleware.cs:70), and re-asserted on a fresh scope by every background path that must run as a tenant: OutboxProcessor (OutboxProcessor.cs:264), OutboxCleanupService (OutboxCleanupService.cs:100), AuditTrailCleanupJob (AuditTrailCleanupJob.cs:106), and the per-tenant database initializer (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:128). It is read by DbContextFactory for per-tenant database routing (injected optionally at MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:44, used at :102, :139, :150 and :186) and by the caching decorators, which scope cache keys through TenantCacheKey.Scope (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/TenantCacheKey.cs:37, called from CachingQueryDecorator.cs:64 and CachingCommandDecorator.cs:82 with the context injected at CachingQueryDecorator.cs:38 and CachingCommandDecorator.cs:36). Covered directly by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/TenantContextTests.cs.

      FaultIntegrationEventConsumer<TEvent>

      @@ -3648,19 +3681,7 @@

      FaultIntegrationEventConsumer<TE

  • Why it's built this way: ADR-087 is the poison-message decision this implements. The consumer deliberately observes and stops there: it does not replay, because the original message is already in the error queue and re-publishing from an observability path would double-deliver (FaultIntegrationEventConsumer.cs:21-22). The class is partial for the [LoggerMessage] generator, and generic so one implementation covers every event type while the event_type tag keeps the metric decomposable.
  • -
  • Where it's used: registered automatically alongside every consumer wired through RegisterIntegrationEventConsumer<TEvent> (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/IntegrationEventConsumerExtensions.cs:38-50): that method adds the IntegrationEventConsumer<TEvent> at :42 and, gated on the registerFaultConsumer parameter defaulting to true, this consumer at :46. A host passes false only for an event whose faults it routes itself, so two consumers do not compete for the same fault topic (:31-37). Covered directly by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/FaultIntegrationEventConsumerTests.cs:15.
  • - -

    NullDomainEventDispatcher

    -
    -

    MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DbContexts.Design · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Design/DesignTimeDbContextHelper.cs:131 · Level 2 · class (sealed, private nested)

    -
    -
      -
    • What it is: a no-op IDomainEventDispatcher used only inside the design-time context helper, never in production. DispatchAsync returns Task.CompletedTask (DesignTimeDbContextHelper.cs:131-135).
    • -
    • Depends on: IDomainEvent and IDomainEventDispatcher.
    • -
    • Concept reinforced, the Null Object pattern for a design-time DI gap. [Rubric §2, Design Patterns] values satisfying an interface with a harmless no-op when the real implementation would need the full application container. During dotnet ef migrations add the design-time factory builds a context but never saves through it, so a real dispatcher (which would try to hand events to handlers that are not registered here) would be both unnecessary and wrong. Registering this null dispatcher (DesignTimeDbContextHelper.cs:68) closes that dependency without pulling in application services, because DomainEventSaveChangesInterceptor is itself registered in that minimal container (DesignTimeDbContextHelper.cs:71) and demands one.
    • -
    • Why it's built this way: the design-time service graph is deliberately minimal (null loggers, null dispatcher, a hand-built ServiceCollection) so scaffolding a migration never spins up the app; this type is one leaf of that minimal graph.
    • -
    • Where it's used: registered as the IDomainEventDispatcher inside DesignTimeDbContextHelper.CreateSqlServer (DesignTimeDbContextHelper.cs:68).
    • -
    • Caveats / not-in-source: private nested type inside DesignTimeDbContextHelper; not accessible from outside.
    • +
    • Where it's used: registered automatically alongside every consumer wired through IntegrationEventConsumerExtensions (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/IntegrationEventConsumerExtensions.cs:38-50): RegisterIntegrationEventConsumer<TEvent> adds the IntegrationEventConsumer<TEvent> at :42 and, gated on the registerFaultConsumer parameter defaulting to true, this consumer at :46. The retired-contract sibling RegisterUpcastedIntegrationEventConsumer<TEvent> registers it the same way at :86, so a draining queue gets the same fault visibility. A host passes false only for an event whose faults it routes itself, so two consumers do not compete for the same fault topic (:31-37). Covered directly by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/FaultIntegrationEventConsumerTests.cs:15.

    DataSourceService

    @@ -3672,7 +3693,50 @@

    DataSourceService

  • Concept reinforced, entity-to-database routing as a query surface. [Rubric §8, Data Architecture] assesses whether database-per-service routing is a first-class, queryable concept; the registry aggregates every [UseDataSource] and [UseDatabase] declaration at startup, and this facade is the thin runtime interface over it. Because the registry is built eagerly from configuration assemblies (DataSourceService.cs:8-11), resolution no longer waits for an EF model to be built, which matters for the navigation classification that runs before any query.
  • Walkthrough: the four GetDataSource* overloads (DataSourceService.cs:15-24) forward straight to the registry, returning either the full DataSourceKey or just its Engine (DataSource). HaveIncludeSupport(DataSourceKey, DataSourceKey) (DataSourceService.cs:31-32) encodes the eager-loading rule: an EF Include is valid only when both entities resolve to the same key and that engine is not Cosmos (first == second && first.Engine != DataSource.CosmosDB), because Cosmos has no cross-document joins (DataSourceService.cs:27-30). The string overload (DataSourceService.cs:35-38) resolves both names through TryGetDataSourceKey and defers to the key overload, returning false if either name is unknown.
  • Why it's built this way: keeping the include-support rule in one predicate lets the navigation metadata and cross-source degrade logic ask a single authority whether a relationship can be loaded in-database versus batch-loaded across sources (ADR-006). Facading the registry keeps callers off its lower-level API.
  • -
  • Where it's used: registered with TryAddSingleton as the IDataSourceService in AddInfrastructure (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:53); injected into NavigationMetadataProvider (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/NavigationMetadataProvider.cs:20), which classifies navigations per process, and into UnitOfWork (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/UnitOfWork.cs:13), which uses it to pick the context for an entity (UnitOfWork.cs:29).
  • +
  • Where it's used: registered with TryAddSingleton as the IDataSourceService in AddInfrastructure (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:53); injected into NavigationMetadataProvider (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/NavigationMetadataProvider.cs:20), which classifies navigations per process, and into UnitOfWork (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/UnitOfWork.cs:13), which calls GetDataSourceKey(typeof(TEntity)) to pick the context an entity's repository binds to (UnitOfWork.cs:40 and :60). Covered by DataSourceServiceTests.cs and DataSourceServiceAdditionalTests.cs in MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/.
  • + +

    EventUpcasterStartupValidator

    +
    +

    MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Services · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/EventUpcasterStartupValidator.cs:20 · Level 3 · class (internal sealed)

    +
    +
      +
    • What it is: a two-line IHostedService whose only job is to resolve IEventUpcasterRegistry at host start, so a broken event-upcaster registration graph fails the host immediately instead of surfacing as a dead-lettered message hours later (EventUpcasterStartupValidator.cs:7-20).
    • +
    • Depends on: IEventUpcasterRegistry (injected, and the whole point), IIntegrationEvent (the harmless type it probes with), and Microsoft.Extensions.Hosting.IHostedService.
    • +
    • Concept introduced, fail-fast startup validation by construction. [Rubric §13, Observability & Operability] and [Rubric §33, Developer Experience] both ask whether a misconfiguration is discovered at the earliest honest moment with an actionable message. The validation itself is not here: EventUpcasterRegistry does its checking in its constructor, rejecting an upcaster that maps a type onto itself (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EventUpcasterRegistry.cs:59-63), two upcasters claiming the same source contract (:65-69), and, in BuildTerminalTypes, a chain that forms a cycle (:81, :133, with the throw at :151). The exception message names the offenders (EventUpcasterRegistry.cs:74-79). Because the registry is a singleton registered with TryAddSingleton in AddApplication (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:40), nothing constructs it until something asks for it, and on the broker path the first asker would be the first arriving message. This hosted service is the "something" that asks at startup. That is the whole pattern: when validation lives in a constructor, a hosted service that merely resolves the type converts lazy validation into eager validation without duplicating a single rule.
    • +
    • Walkthrough
        +
      • StartAsync (EventUpcasterStartupValidator.cs:23-30) calls upcasters.ResolveTerminalType(typeof(IIntegrationEvent)) and discards the result with _ =. The comment explains why the call exists at all (:25-26): resolving the constructor parameter is what runs the validation, and reading one member is what makes the dependency impossible for a later refactor to elide. IIntegrationEvent is a safe probe argument because no upcaster claims the interface itself, so ResolveTerminalType returns it unchanged (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEventUpcasterRegistry.cs:33-39) and the probe has no side effect.
      • +
      • StopAsync (EventUpcasterStartupValidator.cs:33) returns Task.CompletedTask. There is nothing to unwind.
      • +
      +
    • +
    • Why it's built this way: ADR-090 ships the upcaster extension point, and this is its startup half. The registration detail is load-bearing and spelled out in the class doc (EventUpcasterStartupValidator.cs:13-17): it is registered through TryAddEnumerable(ServiceDescriptor.Singleton<IHostedService, EventUpcasterStartupValidator>()) (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:160-161, under the comment at :156-159) rather than AddHostedService, because AddHostedService appends unconditionally and several modules each calling AddInfrastructure would then run the same validation several times. The cost when a host registers no upcasters at all is one resolve of an empty registry and one no-op call, which is why the registration is unconditional.
    • +
    • Where it's used: registered by AddInfrastructure (DependencyInjection.cs:160-161) and run by the generic host at start. It is internal, so no application code references it. Covered directly by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/EventUpcasterStartupValidatorTests.cs:20.
    • +
    • Caveats / not-in-source: the validator proves the graph is well-formed (no duplicate source, no self-map, no cycle). It does not execute any upcaster, so a mapping that compiles and registers cleanly but produces a wrong payload is not caught here; that is what an upcaster's own unit test is for.
    • +
    +

    UpcastingIntegrationEventConsumer<TEvent>

    +
    +

    MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Services · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/UpcastingIntegrationEventConsumer.cs:31 · Level 3 · class (sealed partial, generic)

    +
    +
      +
    • What it is: the draining consumer for a retired integration-event contract. It binds a broker queue to the old type TEvent, upcasts each arriving message to its terminal (newest) contract, and then invokes the handlers registered for that contract, so handlers only ever have to exist for the newest shape (UpcastingIntegrationEventConsumer.cs:12-31).
    • +
    • Depends on: IEventUpcasterRegistry (the chain walker), IServiceProvider (non-generic handler resolution), IInboxStore (idempotent delivery), IIntegrationEvent (the generic constraint) and IIntegrationEventHandler<in TIntegrationEvent> (the handler contract it closes at runtime), plus MassTransit's IConsumer<T> / ConsumeContext<T>, System.Linq.Expressions, ConcurrentDictionary, and ILogger<T>.
    • +
    • Concept introduced, event upcasting on the broker path. [Rubric §6, CQRS & Event-Driven] assesses how event contracts evolve without a lockstep deploy, and [Rubric §7, Microservices Readiness] assesses whether producers and consumers can be released independently. MassTransit binds consumers by .NET message type, so a retired contract keeps arriving as its old type until every producer has moved and every queue has drained. Without an upcasting path a consumer must either keep two sets of handlers or break. ADR-090 resolves that with a registry of IEventUpcaster mappings and two consumers: the ordinary IntegrationEventConsumer<TEvent> for the current contract, and this one for each retired contract still in flight. Three properties of the design are worth reading closely.
        +
      • Deduplication stays keyed on the ORIGINAL message id. The envelope (MessageId, DateOccurred) is preserved across every upcast hop by the registry (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEventUpcasterRegistry.cs:41-48), and this consumer reads integrationEvent.MessageId before any upcasting (UpcastingIntegrationEventConsumer.cs:55-57). A redelivery of the same broker message is therefore recognised whatever contract the handlers ultimately see, exactly as a plain consumer would have recorded it.
      • +
      • It degrades to plain dispatch. With no upcaster registered for TEvent, HasUpcasterFor is false, the class logs an Information line saying so (:65-70), and UpcastToTerminal returns the instance untouched, so the handlers for the original type run as usual. That is what makes the registration safe to add before the upcaster exists and safe to leave in place for one release after it is deleted (IntegrationEventConsumerExtensions.cs:65-70).
      • +
      • Handler resolution is non-generic, so it is cached. The terminal type is only known at runtime, so the closed handler interface must be built with MakeGenericType and the handler invoked without a compile-time generic argument. [Rubric §12, Performance & Scalability] is the reason for the static DispatchCache (:44-46): the closed interface type and a compiled expression-tree invoker are computed once per terminal type and reused for every subsequent message, keeping reflection off the per-message path exactly as the in-process DomainEventDispatcher does (:38-43).
      • +
      +
    • +
    • Walkthrough
        +
      • Consume (UpcastingIntegrationEventConsumer.cs:49-121) null-guards the context, reads the message and its MessageId, and short-circuits on a duplicate: inbox.AlreadyProcessedAsync returning true logs at Debug and returns without touching a handler (:59-63).
      • +
      • The upcast is one call, upcasters.UpcastToTerminal(integrationEvent), followed by reading the runtime type of the result (:72-73). When the terminal type differs from TEvent the hop is logged at Debug with both type names and the message id (:75-78), which is what makes an in-flight migration visible in the logs.
      • +
      • Dispatch pulls (closedHandlerType, invoker) from DispatchCache.GetOrAdd (:80-84), then enumerates serviceProvider.GetServices(closedHandlerType) and awaits the compiled invoker per handler, counting them (:88-109). A handler exception that is not an OperationCanceledException is logged at Error naming the failing handler type and then rethrown (:101-108), deliberately, so MassTransit applies the UseMessageRetry policy configured in ConfigureBrokerTransport before the message is dead-lettered.
      • +
      • Zero handlers is a normal outcome, not an error (:111-116): the process simply does not handle this contract, so an Information line is logged and the method returns normally, which lets MassTransit ack the message rather than retry it forever.
      • +
      • The inbox record is written last (:120), after every handler succeeded, keyed on the original message id and tagged with typeof(TEvent).Name. The comment states the invariant (:118-119): a handler failure rethrows above, leaves the message un-recorded, and keeps it eligible for redelivery.
      • +
      • BuildInvoker (:131-151) constructs the delegate. It resolves HandleAsync on the closed handler interface (throwing an InvalidOperationException if it is somehow missing, :134-135), builds three object/CancellationToken parameters, emits Expression.Convert casts to the concrete handler and event types, and compiles a Func<object, object, CancellationToken, Task> (:139-150). After the first message per terminal type, dispatch is a delegate call.
      • +
      +
    • +
    • Why it's built this way: ADR-090. The class doc records the one rule that will bite you if you miss it (:19-21): do not register both this consumer and the plain IntegrationEventConsumer<TEvent> for the same type, because two consumers compete for one queue and the handlers would run twice. The intended migration shape is a RegisterUpcastedIntegrationEventConsumer<TOld>() for the retired contract, a plain RegisterIntegrationEventConsumer<TNew>() for the current one, and an AddEventUpcaster<TOld, TNew, TUpcaster>() supplying the conversion (IntegrationEventConsumerExtensions.cs:58-64); once the queues have drained you remove all three in turn (:65-70).
    • +
    • Where it's used: registered per retired type through RegisterUpcastedIntegrationEventConsumer<TEvent> inside the configureConsumers callback a host passes to AddBrokerMessaging (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/IntegrationEventConsumerExtensions.cs:78-90), which also adds a FaultIntegrationEventConsumer<TEvent> by default (:86). Covered directly by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/UpcastingIntegrationEventConsumerTests.cs:26.
    • +
    • Caveats / not-in-source: DispatchCache is static, so it is shared by every closed generic instantiation of the class within a process and is never evicted. That is unremarkable for a fixed set of event contracts; nothing in source bounds it, so a host that generated event types dynamically would grow it without limit.

    AzureBlobFileStorageService

    @@ -3682,9 +3746,9 @@

    AzureBlobFileStorageService

  • What it is: the Azure Blob Storage implementation of IFileStorageService: uploads and deletes blobs in the single configured container, returning Result instead of throwing (AzureBlobFileStorageService.cs:10-17).
  • Depends on: IFileStorageService, the Result and Error types, and Azure externals BlobContainerClient / BlobUploadOptions / RequestFailedException plus ILogger<T>.
  • Concept introduced, the file-storage boundary and Result-wrapped I/O. [Rubric §10, Cross-Cutting Concerns] covers pushing infrastructure integrations behind an application-owned contract; here blob I/O is hidden behind IFileStorageService and every SDK failure is caught and mapped to a domain Error rather than bubbling as an exception. IsConfigured => true (AzureBlobFileStorageService.cs:20) is the flag that distinguishes this live implementation from the NullFileStorageService fallback.
  • -
  • Walkthrough: the constructor takes an already-resolved BlobContainerClient and a logger (AzureBlobFileStorageService.cs:15-17); the class comment notes the container and its public-access level are provisioned by infrastructure, not created here (AzureBlobFileStorageService.cs:12-13). UploadAsync (AzureBlobFileStorageService.cs:23-43) gets a blob client, uploads with an explicit ContentType header (AzureBlobFileStorageService.cs:30), and returns Result.Success(blobClient.Uri); a RequestFailedException is logged and mapped to Error.Failure("FileStorage.UploadFailed", ...) (AzureBlobFileStorageService.cs:35-42). DeleteAsync (AzureBlobFileStorageService.cs:46-62) calls DeleteBlobIfExistsAsync (idempotent) and maps failures to FileStorage.DeleteFailed.
  • +
  • Walkthrough: the primary constructor takes an already-resolved BlobContainerClient and a logger (AzureBlobFileStorageService.cs:15-17); the class comment notes the container and its public-access level are provisioned by infrastructure, not created here (AzureBlobFileStorageService.cs:12-13). UploadAsync (AzureBlobFileStorageService.cs:23-43) gets a blob client, uploads with an explicit ContentType header (AzureBlobFileStorageService.cs:30), and returns Result.Success(blobClient.Uri); a RequestFailedException is logged and mapped to Error.Failure("FileStorage.UploadFailed", ...) (AzureBlobFileStorageService.cs:35-42). DeleteAsync (AzureBlobFileStorageService.cs:46-62) calls DeleteBlobIfExistsAsync (idempotent) and maps failures to FileStorage.DeleteFailed (:54-61).
  • Why it's built this way: ADR-045 introduces the file-storage and image pipeline; returning Result keeps storage failures on the same error-handling rail as the rest of the stack, and catching only RequestFailedException means genuinely unexpected errors still surface.
  • -
  • Where it's used: registered as a transient IFileStorageService by AddAzureBlobFileStorage(configuration) in place of NullFileStorageService (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:620). The BlobContainerClient it receives is built one registration earlier (DependencyInjection.cs:613-619) and picks its auth mode from configuration: an absolute FileStorage:ServiceUri means DefaultAzureCredential (managed identity, the production path), otherwise a connection string (local Azurite); an incomplete section makes the whole call a no-op so hosts can register it unconditionally (DependencyInjection.cs:600-611). Consumed by the ADC Identity avatar handlers, for example SetUserAvatarHandler (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarHandler.cs:19), RemoveUserAvatarHandler (RemoveUserAvatarHandler.cs:16), and DeleteUserHandler (DeleteUserHandler.cs:30), typically after ImageSharpImageProcessor has normalized the bytes.
  • +
  • Where it's used: registered as a transient IFileStorageService by AddAzureBlobFileStorage(configuration) in place of NullFileStorageService (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:633). The BlobContainerClient it receives is built one registration earlier (DependencyInjection.cs:626-632) and picks its auth mode from configuration: an absolute FileStorage:ServiceUri means DefaultAzureCredential (managed identity, the production path), otherwise a connection string (local Azurite). Two guards make the whole call a no-op on an incomplete section, one for a missing container name (:613-617) and one for neither an absolute service URI nor a connection string (:619-624, with the comment noting that an empty-string ServiceUri binds to a relative Uri and so does not count), so hosts can register it unconditionally. Consumed by the ADC Identity avatar handlers, for example SetUserAvatarHandler (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarHandler.cs:19), RemoveUserAvatarHandler (RemoveUserAvatarHandler.cs:16), and DeleteUserHandler (DeleteUserHandler.cs:30), typically after ImageSharpImageProcessor has normalized the bytes.
  • AzureNotificationHubDeviceRegistrar

    @@ -3701,7 +3765,7 @@

    AzureNotificationHubDeviceRegistrar
  • Why it's built this way: ADR-044's native channel needs a way to associate devices with users; the tag-per-installation approach lets sends target user:{id} OR-expressions without the app keeping its own device table, and it doubles as the ownership record the scoped delete verifies. Idempotent delete keeps client retries safe. The default interface implementation of the scoped overload delegates to the unscoped one (IPushDeviceRegistrar.cs:54-55) so out-of-framework implementations keep compiling; this class overrides it because it can actually verify ownership.
  • -
  • Where it's used: registered as a transient IPushDeviceRegistrar by AddNativePushNotifications(configuration) in place of NullPushDeviceRegistrar (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:580); called by DevicesController, which passes the authenticated user id into both operations (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/DevicesController.cs:43 and :67), and paired with AzureNotificationHubNativePushSender for the send side.
  • +
  • Where it's used: registered as a transient IPushDeviceRegistrar by AddNativePushNotifications(configuration) in place of NullPushDeviceRegistrar (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:593); called by DevicesController, which passes the authenticated user id into both operations (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/DevicesController.cs:43 and :67), and paired with AzureNotificationHubNativePushSender for the send side. Covered directly by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/AzureNotificationHubDeviceRegistrarTests.cs.
  • Caveats / not-in-source: the ownership check is a read followed by a delete, not an atomic operation. The source says why that is acceptable: a concurrent re-registration of the same id between the two calls is the owner's own doing, so no lock is warranted (AzureNotificationHubDeviceRegistrar.cs:86-87). An installation registered before ownership tagging existed has no tag, so it is treated as someone else's and is not deleted (:91-92).
  • ImageSharpImageProcessor

    @@ -3714,7 +3778,7 @@

    ImageSharpImageProcessor

  • Concept introduced, full re-encode as a security control. [Rubric §11, Security] and [Rubric §30, Compliance/Privacy/Data Governance] both apply: decoding to pixels and re-encoding is deliberate so that EXIF metadata (including GPS coordinates, which are PII) and any polyglot payload smuggled into the original file are discarded, since only pixels survive the round trip (ImageSharpImageProcessor.cs:9-13). This is a defense against both privacy leaks and image-parser exploits, not merely a resize. Its upload-side companion is ImageContentSniffer, which decides the accepted formats (jpeg, png, webp) from the magic bytes rather than the client-declared content type or extension before the stream reaches this processor (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ImageContentSniffer.cs:3-9, with the predicate itself at :15-16).
  • Walkthrough: NormalizeToSquareJpegAsync (ImageSharpImageProcessor.cs:17-51) loads the stream, then Mutates with AutoOrient() before stripping metadata so a portrait phone photo is not left rotated (ImageSharpImageProcessor.cs:23-31), and resizes to size x size with ResizeMode.Crop. It then nulls out the EXIF, XMP, and IPTC profiles (ImageSharpImageProcessor.cs:33-35) and saves to a MemoryStream with JpegEncoder { Quality = 85 } (ImageSharpImageProcessor.cs:40), returning Result.Success(output.ToArray()). An UnknownImageFormatException or InvalidImageContentException is caught by an exception filter and mapped to Error.Validation("Image.Undecodable", ...) (ImageSharpImageProcessor.cs:44-50), so a garbage upload becomes a clean validation failure rather than a 500.
  • Why it's built this way: ADR-045 pairs storage with sanitization; ordering AutoOrient before metadata removal is the subtle correctness detail, and quality 85 is the standard size/quality trade-off. Catching only the two ImageSharp decode exceptions keeps unexpected faults visible.
  • -
  • Where it's used: registered with TryAddSingleton as the IImageProcessor in AddInfrastructure (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:484, whose comment notes it is dependency-free and therefore always the real implementation, DependencyInjection.cs:481-482); invoked by SetUserAvatarHandler (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarHandler.cs:18) before the bytes are handed to AzureBlobFileStorageService. There is no Null variant because processing needs no external resource.
  • +
  • Where it's used: registered with TryAddSingleton as the IImageProcessor in AddServices (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:497, whose comment notes it is dependency-free and therefore always the real implementation, DependencyInjection.cs:494-495); invoked by SetUserAvatarHandler (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarHandler.cs:18) before the bytes are handed to AzureBlobFileStorageService. There is no Null variant because processing needs no external resource. Covered directly by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/ImageSharpImageProcessorTests.cs.
  • NullFileStorageService

    @@ -3725,7 +3789,7 @@

    NullFileStorageService

  • Depends on: IFileStorageService and Result / Error.
  • Concept reinforced, an asymmetric Null Object (fail-closed write, no-op delete). [Rubric §2, Design Patterns] and [Rubric §10, Cross-Cutting Concerns]: unlike a pure no-op, this fallback distinguishes its two operations by intent. IsConfigured => false (NullFileStorageService.cs:14) lets callers detect the disabled channel; UploadAsync returns Error.Failure("FileStorage.NotConfigured", ...) (NullFileStorageService.cs:17-21) so a write fails loudly and predictably, while DeleteAsync returns Result.Success() (NullFileStorageService.cs:24-25) because there is nothing to delete and a delete of a non-existent file is already the desired state.
  • Why it's built this way: ADR-045 makes storage optional; failing uploads with a typed error (rather than a null-reference crash) keeps a host with no storage configured running and honest about what it cannot do.
  • -
  • Where it's used: registered with TryAddTransient as the default IFileStorageService in AddInfrastructure (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:483), swapped for AzureBlobFileStorageService by AddAzureBlobFileStorage(configuration) (DependencyInjection.cs:595-623).
  • +
  • Where it's used: registered with TryAddTransient as the default IFileStorageService in AddServices (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:496), swapped for AzureBlobFileStorageService by AddAzureBlobFileStorage(configuration) (DependencyInjection.cs:608-636).
  • NullPushDeviceRegistrar

    @@ -3736,27 +3800,7 @@

    NullPushDeviceRegistrar

  • Depends on: IPushDeviceRegistrar, DeviceInstallationRequest, and Result.
  • Concept reinforced, the Null Object pattern for the disabled native channel. [Rubric §2, Design Patterns]: UpsertAsync and both DeleteAsync overloads return Result.Success() (NullPushDeviceRegistrar.cs:15-24), so the Devices API is always callable and simply does nothing when no notification hub is wired up. Note that it implements the owner-scoped delete explicitly rather than inheriting the interface's default (which would delegate to the unscoped overload): the outcome is identical, and being explicit keeps the no-op honest about supporting the full contract. It is the device-registration twin of NullNativePushSender, which no-ops the send side of the same disabled channel (ADR-044).
  • Why it's built this way: keeping registration a success (rather than an error) means a client that always registers on launch is not blocked by a host that has not enabled native push; the channel becomes real only when AzureNotificationHubDeviceRegistrar is registered.
  • -
  • Where it's used: registered with TryAddTransient as the default IPushDeviceRegistrar in AddInfrastructure (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:479), replaced by AzureNotificationHubDeviceRegistrar when AddNativePushNotifications(configuration) finds an enabled hub (DependencyInjection.cs:580).
  • - -

    DesignTimeDbContextHelper

    -
    -

    MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Persistence.DbContexts.Design · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Design/DesignTimeDbContextHelper.cs:36 · Level 8 · class (static)

    -
    -
      -
    • What it is: a static helper that builds a SQLServerDbContext for dotnet ef design-time commands without the application's DI container, so each per-database migrations project reduces to a few lines (DesignTimeDbContextHelper.cs:18-36).
    • -
    • Depends on: EF Core (DbContextOptionsBuilder, the caller-implemented IDesignTimeDbContextFactory), the data-source resolution stack (DataSourceResolver, EntityDataSourceRegistry, DataSourcesSettings), the four save interceptors (AuditSaveChangesInterceptor, DomainEventSaveChangesInterceptor, TenantSaveChangesInterceptor, AuditTrailSaveChangesInterceptor), the options types TenancySettings / SchedulerSettings / AuditTrailSettings, IOutboxSignal / OutboxSignal, and its own two private nested leaves ExplicitAssemblyProvider and NullDomainEventDispatcher.
    • -
    • Concept introduced, design-time context construction for migrations-per-database. [Rubric §17, DevOps] and [Rubric §33, Developer Experience]: database-per-service (ADR-006) needs one migrations project per database, and scaffolding a migration must not require standing up the whole app. CreateSqlServer(args, configure) (DesignTimeDbContextHelper.cs:45-101) lets a migrations project implement EF's IDesignTimeDbContextFactory<SQLServerDbContext> in a callback that supplies connection settings, model-shape flags, and configuration assemblies (the pattern is shown verbatim in the class doc, DesignTimeDbContextHelper.cs:20-32).
    • -
    • Walkthrough
        -
      • Argument handling and source selection (DesignTimeDbContextHelper.cs:45-55): both parameters are null-guarded, the caller's configure runs over a fresh DesignTimeDbContextOptions, and the logical source name is resolved in priority order: explicit DataSourceName, else --datasource from args, else DataSourceKey.DefaultName.
      • -
      • The routing stack (:57-62): an ExplicitAssemblyProvider over the listed assemblies, a DataSourceResolver built from the supplied connection settings and DataSources entries with a NullLogger, and an EntityDataSourceRegistry over the two.
      • -
      • The minimal container (:64-92) is hand-built as a plain ServiceCollection: TimeProvider.System, null logger factory and null generic loggers, the NullDomainEventDispatcher, an OutboxSignal, and the interceptors. The tenant interceptor and a default TenancySettings are registered unconditionally, and the comment explains the reasoning: design time never resolves a tenant, so the interceptor is inert and the Tenant query filter short-circuits, which means the scaffolded migration is identical with or without tenancy apart from the TenantId column and index the model declares (:72-78). SchedulerSettings and AuditTrailSettings are created from the two DesignTimeDbContextOptions flags (:82-88), which is how an opt-in table becomes part of the design-time model; the audit-trail interceptor is registered even though the context resolves it with GetService, purely to keep the design-time pipeline identical to the runtime one (:84-89).
      • -
      • Construction (:94-100): the logical name is collapsed to a physical one through resolver.GetPhysical(resolver.ResolveLogical(DataSource.SQLServer, logicalName)), then the SQLServerDbContext is built with an empty options builder, the built service provider, the assembly provider, and that physical key, so the model contains only the selected source's entities.
      • -
      • ParseDataSourceName (:106-124) reads --datasource <Name> or --datasource=Name, throwing an actionable InvalidOperationException if the flag is present with no value (:112-114).
      • -
      -
    • -
    • Why it's built this way: ADR-006 requires per-database migrations; a shared design-time helper keeps each migrations project trivial and avoids booting the full application DI graph just to scaffold a migration. The pattern in the registrations above is "register everything the runtime registers, defaulted to inert", because the failure mode this guards against is a scaffolded migration that quietly differs from the running model.
    • -
    • Where it's used: called from each per-database migrations factory, for example MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Conference/DesignTimeSQLServerDbContextFactory.cs:15 and its Identity, Engagement, and Notification siblings, MMCA.Store's Catalog/Sales/Identity factories, and MMCA.Helpdesk's single Tickets factory (MMCA.Helpdesk/Source/Hosting/MMCA.Helpdesk.Migrations.SqlServer.Tickets/DesignTimeSQLServerDbContextFactory.cs:25). It is invoked as dotnet ef migrations add X --project ... -- --datasource <Name> (DesignTimeDbContextHelper.cs:33-34), and covered directly by MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/DataSources/DesignTimeDbContextHelperTests.cs:37.
    • -
    • Caveats / not-in-source: the ADC Conference factory is worth reading beside this helper for the one non-obvious trap. It deliberately gives the top-level connection string and the named Conference entry the same value so the design-time source collapses onto Default exactly as the running host's does; without the collapse the physical key would be the named Conference key and the host-scoped ScheduledJobs table would be missing from the scaffolded model while present in the running one (MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Conference/DesignTimeSQLServerDbContextFactory.cs:17-27).
    • +
    • Where it's used: registered with TryAddTransient as the default IPushDeviceRegistrar in AddServices (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:492), replaced by AzureNotificationHubDeviceRegistrar when AddNativePushNotifications(configuration) finds an enabled hub (DependencyInjection.cs:593).

    UpdatePropertySetterBuilder<TEntity>

    @@ -4481,7 +4525,7 @@

    RepositoryFactory

  • SaveChanges as an interceptor pipeline
  • The tenant boundary, read filter plus write guard
  • Recording what changed, the audit trail
  • -
  • Repositories and the unit of work
  • +
  • Repositories, specifications, and the unit of work
  • Routing an entity to its database
  • Two model-finalizing conventions
  • Entity configuration and engine portability
  • diff --git a/docs/onboarding/group-08-auth.html b/docs/onboarding/group-08-auth.html index 3fa69ab..6199d32 100644 --- a/docs/onboarding/group-08-auth.html +++ b/docs/onboarding/group-08-auth.html @@ -148,7 +148,7 @@

    8. Authentication & AuthorizationWhat this group covers. This is the security spine of the framework: how a caller proves who they are (authentication), how the system decides what they may do (authorization), and how both survive the jump from a single-process monolith to a fleet of extracted services. Almost every type here - serves one of eight moving parts: minting and validating JWTs + serves one of nine moving parts: minting and validating JWTs (TokenService / ITokenService, RsaJwksProvider / IJwksProvider); the shared login / register / refresh workflow (AuthenticationServiceBase<TUser>, @@ -160,8 +160,12 @@

    8. Authentication & AuthorizationPasswordHasher / IPasswordHasher); brute-force and rate-limit protection (LoginProtectionService / ILoginProtectionService, - LoginProtectionSettings); reading the current caller's identity from - claims (CurrentUserService / ICurrentUserService, + LoginProtectionSettings); the forgot-password token lifecycle + (PasswordResetTokenService / + IPasswordResetTokenService, + PasswordResetEntry, PasswordResetSettings); + reading the current caller's identity from claims + (CurrentUserService / ICurrentUserService, ClaimBasedUserIdProvider, AuthClaimTypes); the authorization model (roles, permissions, and resource ownership under AuthorizationExtensions, @@ -178,6 +182,8 @@

    8. Authentication & AuthorizationADR-029 (brute-force protection), ADR-032 (password hashing), + ADR-091 (the cache-backed + forgot-password token), ADR-020 (permission-based authorization), ADR-033 @@ -187,7 +193,7 @@

    8. Authentication & AuthorizationADR-051 (how each render head holds and reacquires a token). The rubric lenses are dominated by [Rubric §11, Security], with supporting [Rubric §7, Microservices Readiness] and [Rubric §10, Cross-Cutting]. Auth surfaces all of - its expected failures (bad password, lockout, expired session) as + its expected failures (bad password, lockout, expired session, rejected reset token) as Result failures, never exceptions, so reading the Result pattern first pays off here.

    Tokens: one signing switch, two validation worlds

    @@ -227,7 +233,7 @@

    Tokens: one signing swi (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/RsaJwksProvider.cs:15), which lazily builds a JsonWebKeySet from a PEM key (inline or read from a path) configured through JwksSettings (RsaJwksProvider.cs:15, - RsaJwksProvider.cs:58-74), behind the IJwksProvider port + RsaJwksProvider.cs:58-73), behind the IJwksProvider port (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/IJwksProvider.cs:11). Publishing is off by default, and when disabled or unconfigured the provider returns an empty key set (RsaJwksProvider.cs:30-33, RsaJwksProvider.cs:36-39) so the endpoint stays queryable but a @@ -303,13 +309,15 @@

    The shared authentication workflowRegisterRequest, RefreshTokenRequest, AuthenticationResponse, ChangePasswordRequest, OAuthCodeExchangeRequest, and the device-aware - AuthenticationRequest used by MAUI clients) are compact readonly record structs in MMCA.Common.Shared. Two of them mark boundaries worth noting: password change is + AuthenticationRequest used by MAUI clients) are compact readonly record structs in MMCA.Common.Shared. Several of them mark boundaries worth noting: password change is dispatched straight through its command handler at the controller layer rather than brokered by IAuthenticationService - (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IAuthenticationService.cs:8-9), and - ExternalLoginAsync has a default interface implementation that rejects the call - (IAuthenticationService.cs:66-74) because OAuth account linking stays coupled to the app's own - User factory. OAuthCodeExchangeRequest carries only an opaque single-use code + (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IAuthenticationService.cs:11), the same is + true of the forgot/reset pair below (ForgotPasswordRequest, + ResetPasswordRequest), and ExternalLoginAsync has a default interface + implementation that rejects the call (IAuthenticationService.cs:66-74) because OAuth account + linking stays coupled to the app's own User factory. OAuthCodeExchangeRequest carries only an + opaque single-use code (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/OAuthCodeExchangeRequest.cs:11) precisely so the token pair never appears in the address bar, browser history, a Referer header, or an access log. The FluentValidation rules that guard the requests are bundled into one parameter object, @@ -333,12 +341,16 @@

    What the app's User aggreg aggregates stay app-specific and are reached only through the per-app hooks. IPasswordChangeableUser (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IPasswordChangeableUser.cs:11) extends it with - ChangePassword, because the rotation workflow must verify the current credential before writing the - new one. IUserPreferences + ChangePassword (IPasswordChangeableUser.cs:19), because both the rotation workflow and the reset + workflow have to write a new credential through the aggregate rather than around it. + IUserPreferences (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IUserPreferences.cs:10) carries the stored culture - and theme plus a single UpdatePreferences mutator that always writes both fields, so persisting one - preference never clears the other (IUserPreferences.cs:18-25), matching the null-means-unchanged - semantics of ChangePreferencesRequest and + and theme plus a single UpdatePreferences mutator that always replaces both fields + (IUserPreferences.cs:13-25); the shared workflow is what preserves the other preference, passing the + stored value for any field the request left null + (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:53-55), + which is the null-means-unchanged contract stated on + ChangePreferencesRequest and mirrored by UserPreferencesResponse (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ChangePreferencesRequest.cs:10, MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/UserPreferencesResponse.cs:9).

    @@ -359,6 +371,8 @@

    What the app's User aggreg (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:23), GetUserPreferencesHandlerBase<TUser> (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandlerBase.cs:21), + ResetPasswordHandlerBase<TUser, TCommand> + (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:30), and DeleteUserHandlerBase<TUser, TCommand> (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:38), @@ -401,6 +415,84 @@

    Passwords and brute-force protecti read-modify-write rather than an atomic counter, because the native Redis INCR path wrote a key shape IDistributedCache could not read back (LoginProtectionService.cs:66-74). Sequential guessing, which is what a credential-stuffing run looks like, still trips the lockout.

    +

    Forgot password: a cache-backed single-use token

    +

    A user who has lost the password cannot present one, so this flow is anonymous by necessity, which + makes every one of its responses a potential account-enumeration oracle. It is also built without a + schema change: the token lives in the cache, hashed, and expires by TTL rather than being reaped by a + sweeper (ADR-091). The + port is IPasswordResetTokenService + (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IPasswordResetTokenService.cs:10), two methods + wide: IssueAsync mints a token for an address (IPasswordResetTokenService.cs:23) and + ValidateAndConsumeAsync redeems it exactly once (IPasswordResetTokenService.cs:36). The + implementation, PasswordResetTokenService + (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:26), rides on + ICacheService and buys four properties in a few lines each:

    +
      +
    • One active token per email. Issuing writes the same per-address key + (PasswordResetTokenService.cs:51, PasswordResetTokenService.cs:88), so requesting a new link + retires the previous one.
    • +
    • Hashed at rest. Only the Base64 of the token's SHA-256 is stored + (PasswordResetTokenService.cs:55-56, PasswordResetTokenService.cs:82-88), so a cache dump hands + out no working reset links, and the comparison on redemption is constant time through + CryptographicOperations.FixedTimeEquals (PasswordResetTokenService.cs:118).
    • +
    • An attempt cap. A wrong token increments a counter on the record, and the record is discarded at + MaxValidationAttempts (PasswordResetTokenService.cs:132-153, default 5, + MMCA.Common/Source/Core/MMCA.Common.Application/Auth/PasswordResetSettings.cs:36). The rewrite + after a wrong guess uses the record's remaining lifetime rather than a fresh one + (PasswordResetTokenService.cs:148-152), so guessing cannot extend the redeemable window.
    • +
    • A per-email request throttle. A counter carrying the window's TTL caps how often one address can + trigger an email (PasswordResetTokenService.cs:66-77, default 3 per 60 minutes, + PasswordResetSettings.cs:40, PasswordResetSettings.cs:44), and a successful redemption deletes + the token and that counter (PasswordResetTokenService.cs:126-127) so a legitimate reset does not + leave the user throttled out of a later one.
    • +
    +

    Keys are built from an Email-normalized identity for the same reason + LoginProtectionService does it + (PasswordResetTokenService.cs:40-53). The cached record, PasswordResetEntry + (PasswordResetTokenService.cs:171), is deliberately all JSON primitives: cache values round-trip + through System.Text.Json, so a value object or a byte[] member would not survive a distributed + backing store. Token material is 32 random bytes, Base64Url-encoded + (PasswordResetTokenService.cs:30, PasswordResetTokenService.cs:79), redeemable for + TokenLifetimeMinutes (default 30, PasswordResetSettings.cs:29), and every rejection (unknown, + expired, mismatched, attempt-capped) collapses into one generic failure + (PasswordResetTokenService.cs:155-159). The settings bind from the PasswordReset configuration + section and the service is registered scoped in Infrastructure DI (PasswordResetSettings.cs:13, + MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:139-143).

    +

    The workflow around the port lives in the group-14 handler bases, and it is where the + anti-enumeration rule is enforced. + ForgotPasswordHandlerBase<TUser, TCommand> + (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:35) + resolves the account through its one abstract lookup, issues a token, and mails it through + IEmailSender (ForgotPasswordHandlerBase.cs:83-88), but a + malformed address, an address with no account, a throttled request, and a failed send all log and + return success alike (ForgotPasswordHandlerBase.cs:57-62, ForgotPasswordHandlerBase.cs:65-69, + ForgotPasswordHandlerBase.cs:72-76, ForgotPasswordHandlerBase.cs:90-95). The only 400 comes from + ForgotPasswordRequestValidator + (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ForgotPasswordRequestValidator.cs:11), + which inspects the shape of the address and nothing else. The email carries both a prefilled link + (composed from PasswordResetSettings.ResetUrl, deliberately not required so a host that has not + configured a UI base still boots, PasswordResetSettings.cs:25) and the raw token, because a client + without deep linking (the MAUI head) needs it typed into the reset page by hand + (ForgotPasswordHandlerBase.cs:123-133). + ResetPasswordHandlerBase<TUser, TCommand> + (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:30) + consumes the token before the save on a stated trade-off (leaving it live until the write succeeds + opens a replay window; a token burned by a later invariant failure costs the user one more reset + request, ResetPasswordHandlerBase.cs:61-67), hashes through IPasswordHasher + and writes the credential through the aggregate's ChangePassword + (ResetPasswordHandlerBase.cs:79-80), then clears the login-protection counters so a user who reset + because of a lockout is not left locked out (ResetPasswordHandlerBase.cs:89). + ResetPasswordRequestValidator + (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ResetPasswordRequestValidator.cs:12) + includes the same StrongPasswordRules<T> that + registration and change-password use (ResetPasswordRequestValidator.cs:40), so a reset is not a way + around the complexity policy. The endpoints are + PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand> + (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:43): + both actions are [AllowAnonymous] and rate-limited per IP exactly as login and register are, + forgot-password answers 202 for any well-formed request + (PasswordResetAuthControllerBase.cs:75-92), and reset-password collapses every rejection into a + single 401 (PasswordResetAuthControllerBase.cs:99-117).

    Reading identity from claims

    Once a request is authenticated, downstream code needs the caller's identity without re-parsing the JWT. CurrentUserService @@ -421,7 +513,8 @@

    Reading identity from claims

    ClaimBasedUserIdProvider (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/ClaimBasedUserIdProvider.cs:9), plugs the same user_id claim into SignalR's IUserIdProvider so Clients.User(userId) routes hub messages to - the right connections. AuthClaimTypes + the right connections (ClaimBasedUserIdProvider.cs:11, ClaimBasedUserIdProvider.cs:14). + AuthClaimTypes (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/AuthClaimTypes.cs:7) names the one framework-custom claim beyond the BCL set, "permission" (AuthClaimTypes.cs:15), used by the authorization model below.

    @@ -451,8 +544,8 @@

    Authorization: roles, permiss AuthorizeAttribute whose policy name is perm:sessions:manage (PermissionPolicy.NameFor, MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionPolicy.cs:12, - PermissionPolicy.cs:17). Rather than pre-registering a named policy per permission, - PermissionPolicyProvider + PermissionPolicy.cs:17, applied at HasPermissionAttribute.cs:18). Rather than pre-registering a + named policy per permission, PermissionPolicyProvider (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionPolicyProvider.cs:13) materializes those policies on demand for any perm: name and falls through to the default provider for everything else (PermissionPolicyProvider.cs:31-47). The requirement it attaches, @@ -490,8 +583,9 @@

    Authorization: roles, permiss AllowMissingOwnerAttribute (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AllowMissingOwnerAttribute.cs:21), honored from either the action or its declaring controller via endpoint metadata - (OwnerOrAdminFilter.cs:83-84). The filter's vocabulary (claim type, bypass role, route parameter) is - configurable through OwnerOrAdminFilterOptions + (OwnerOrAdminFilter.cs:83-84, AllowMissingOwnerAttribute.cs:20). The filter's vocabulary (claim + type, bypass role, route parameter) is configurable through + OwnerOrAdminFilterOptions (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilterOptions.cs:11) whose defaults preserve the original customer_id / Admin / id behavior (OwnerOrAdminFilterOptions.cs:14-24, @@ -540,7 +634,7 @@

    Session cookies: keeping SSR exchanges the refresh cookie at the API's auth/refresh endpoint server-to-server (CookieSessionRefresher.cs:127-130), so the refresh token never reaches browser JS. It then writes the rotated pair back as cookies and stashes the fresh access token on HttpContext.Items - (CookieSessionRefresher.cs:87-91) so the current request's authentication reads the new token: + (CookieSessionRefresher.cs:86-91) so the current request's authentication reads the new token: CookieTokenReader checks that item before falling back to the request cookie (CookieTokenReader.cs:17, CookieTokenReader.cs:27-33). Concurrent refreshes are collapsed into a single flight by a KeyedSemaphoreStripe keyed on the refresh token plus a @@ -552,11 +646,11 @@

    Session cookies: keeping SSR was in flight (CookieSessionRefresher.cs:44-49); two unrelated tokens sharing a stripe is harmless because the grace cache is re-checked per token after acquiring (CookieSessionRefresher.cs:104-108). A transport failure is not cached and renders the request - anonymously rather than throwing a 500 out of SSR (CookieSessionRefresher.cs:117-122, + anonymously rather than throwing a 500 out of SSR (CookieSessionRefresher.cs:113-122, CookieSessionRefresher.cs:147-151). The same refresher backs the same-origin POST /auth/session/token endpoint the browser polls to hydrate its in-memory token (SessionCookieEndpoints.cs:45-60), guarded by SameSite=Lax plus a Sec-Fetch-Site cross-site - rejection (SessionCookieEndpoints.cs:48, SessionCookieEndpoints.cs:68-70) and returning + rejection (SessionCookieEndpoints.cs:44, SessionCookieEndpoints.cs:66-70) and returning SessionTokenResponse (CookieSessionRefresher.cs:20), the browser-safe projection of the internal SessionTokenResult (CookieSessionRefresher.cs:14) that deliberately omits the refresh token. This whole cluster is @@ -595,11 +689,13 @@

    Shared primitives and adjacent m KeyedSemaphoreStripe (MMCA.Common/Source/Core/MMCA.Common.Shared/Concurrency/KeyedSemaphoreStripe.cs:22) and its Releaser handle (KeyedSemaphoreStripe.cs:78) serialize work per logical key across a - fixed set of 256 semaphores (KeyedSemaphoreStripe.cs:25, KeyedSemaphoreStripe.cs:60-75). That is - the bounded alternative to a semaphore-per-key dictionary, which forces a choice between two defects: - removing the entry on release opens a window where one caller waits on a semaphore no longer in the - table while another creates a fresh one, and never removing it lets caller-supplied keys grow the table - without bound (KeyedSemaphoreStripe.cs:7-16). Its consumers today are + fixed set of semaphores (256 by default, KeyedSemaphoreStripe.cs:25, with an explicit-width + constructor at KeyedSemaphoreStripe.cs:37; acquisition maps the key onto one stripe at + KeyedSemaphoreStripe.cs:60-75). That is the bounded alternative to a semaphore-per-key dictionary, + which forces a choice between two defects: removing the entry on release opens a window where one + caller waits on a semaphore no longer in the table while another creates a fresh one, and never + removing it lets caller-supplied keys grow the table without bound + (KeyedSemaphoreStripe.cs:7-16). Its consumers today are CookieSessionRefresher (above, CookieSessionRefresher.cs:62), the IdempotencyFilter (MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:92), @@ -634,14 +730,15 @@

    Shared primitives and adjacent m each Identity module so Common never takes a cross-module domain reference. Its fast path is SoftDeletedUserCache (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/SoftDeletedUserCache.cs:17), which owns both the - key shape and the 30-second marker lifetime (SoftDeletedUserCache.cs:29, SoftDeletedUserCache.cs:42) + key shape and the 30-second marker lifetime (SoftDeletedUserCache.cs:29, SoftDeletedUserCache.cs:43) so the module that deletes an account writes exactly the key the middleware reads; the marker only has to outlive the window between the delete committing and the next validator query, and the 15-minute access-token lifetime bounds the rest of the exposure. The key is formatted invariantly on purpose, because a culture-sensitive identifier would be written under one request's culture and missed under - another (SoftDeletedUserCache.cs:42-43). The controller surface that drives everything above + another (SoftDeletedUserCache.cs:37-43). The controller surface that drives everything above (AuthControllerBase, OAuthControllerBase, + PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>, ExternalAuthExtensions) and the gRPC token forwarding (JwtForwardingClientInterceptor) live in later groups; this chapter is the engine those endpoints call into.

    @@ -1168,118 +1265,6 @@

    OwnerOrAdminFilter

    (ADR-033 lists orders as that case, handled with a specification or an explicit per-id check instead).

    -

    IPasswordHasher

    -
    -

    MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IPasswordHasher.cs:6 · Level 0 · interface

    -
    -
      -
    • What it is: the password-security port. Two methods: hash a plaintext password into a separated - (byte[] Hash, byte[] Salt) pair, and verify a plaintext against a stored hash plus salt.
    • -
    • Depends on: nothing first-party, BCL only (byte[]). Its Infrastructure adapter is - PasswordHasher.
    • -
    • Concept introduced, hash and salt kept apart. [Rubric §11, Security] assesses credential - handling. Returning the hash and the salt as two distinct byte[] members - (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IPasswordHasher.cs:11) - rather than one concatenated blob keeps the storage contract explicit: the caller persists two - columns, and VerifyPassword (:18) is unambiguous about what it re-derives and compares. Because - the algorithm and its parameters live entirely behind this interface, they can be strengthened - without touching a single Application handler - (ADR-032 sets the current hashing - policy, applied inside PasswordHasher).
    • -
    • Walkthrough: (byte[] Hash, byte[] Salt) HashPassword(string password) (:11) returns a named - value tuple the caller stores as two fields. bool VerifyPassword(string password, byte[] hash, byte[] salt) (:18) re-derives from the supplied salt and compares. The interface declares no - iteration count, algorithm identifier, or format version: every one of those is the concrete's - business.
    • -
    • Why it's built this way: a two-method port is the [Rubric §1, SOLID] dependency-inversion story - in miniature. Swapping the KDF or raising the iteration count is an Infrastructure change, invisible - to the Register/Login/ChangePassword use cases that only ever see this contract.
    • -
    • Where it's used: constructor-injected into the shared AuthenticationServiceBase<TUser> - (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:37), which calls - VerifyPassword on the login path (:112) and HashPassword on registration (:159), and into the - per-app Identity services that derive from it, for example ADC's AuthenticationService - (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:38) - and its ChangePasswordHandler - (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordHandler.cs:19).
    • -
    -
    -

    ISoftDeletedUserValidator

    -
    -

    MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ISoftDeletedUserValidator.cs:7 · Level 0 · interface

    -
    -
      -
    • What it is: a single-method port that answers "has this account been soft-deleted?", called after - JWT authentication to reject a soft-deleted user who still holds a valid, unexpired token (BR-133, - named in the type comment at - MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ISoftDeletedUserValidator.cs:4).
    • -
    • Depends on: BCL plus the solution-wide UserIdentifierType alias (:15). See - primer §2 for the alias convention - and ADR-005 for soft-delete - versus erasure. The generic implementation is - SoftDeletedUserValidator<TUser>.
    • -
    • Concept introduced, closing the stateless-token window. [Rubric §11, Security] assesses whether - revocation is timely. A JWT is stateless: once signed it stays valid until exp, even if the account - behind it was deleted a minute later. This port lets middleware re-ask the question on every - authenticated request and fail the request when the answer is yes, with no per-handler code. The - comment at :5 states the second motive: the interface is declared in Application and implemented - against the app's own User aggregate precisely so the middleware never takes a cross-module domain - reference. That is the same dependency inversion as the other ports in this group, applied to a - cross-module read.
    • -
    • Walkthrough: Task<bool> IsUserSoftDeletedAsync(UserIdentifierType userId, CancellationToken cancellationToken = default) (:15). One question, one answer, cancellable.
    • -
    • Where it's used: - SoftDeletedUserMiddleware resolves it - lazily from the request scope - (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/SoftDeletedUserMiddleware.cs:75 uses - context.RequestServices.GetService<ISoftDeletedUserValidator>(), so a host that registers no - implementation simply skips the check; the reason is stated at :43). Both apps register the shared - generic against their own user type: - MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:35 and - MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/DependencyInjection.cs:41, both - as TryAddScoped<ISoftDeletedUserValidator, SoftDeletedUserValidator<User>>().
    • -
    -
    -

    ITokenService

    -
    -

    MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ITokenService.cs:8 · Level 0 · interface

    -
    -
      -
    • What it is: the token-minting port called by the login and refresh use cases. It builds a signed - JWT access token from explicit identity facts, generates an opaque refresh token, publishes the two - token lifetimes, and recovers the ClaimsPrincipal from an expired-but-validly-signed access token.
    • -
    • Depends on: System.Security.Claims (BCL) and the UserIdentifierType alias. Its Infrastructure - adapter is TokenService, which signs with the RSA key surfaced by - IJwksProvider.
    • -
    • Concept introduced, token creation as an Infrastructure detail. [Rubric §3, Clean Architecture] - assesses whether library-specific types stay out of the inner layers: the handlers call this contract - and never see System.IdentityModel.Tokens.Jwt. GetPrincipalFromExpiredToken - (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ITokenService.cs:48) is - the linchpin of the refresh flow: it validates the signature while deliberately ignoring lifetime, so - an expired access token can still identify the user whose tokens are being rotated, returning null - when the token is invalid (:47).
    • -
    • Walkthrough: GenerateAccessToken(UserIdentifierType userId, string email, string role, string fullName, IEnumerable<Claim>? additionalClaims = null) (:17-22) takes the minimum claim set as - typed parameters rather than a ready-made principal, with an escape hatch for module-specific claims. - GenerateRefreshToken() (:26) returns a cryptographically random base64 string. Two default - interface members publish the lifetimes: AccessTokenLifetime (:33, defaulting to 15 minutes) - and RefreshTokenLifetime (:40, defaulting to 7 days), both documented as the BR-205 baseline. The - comments at :28-32 and :35-39 explain the split: the real implementation derives both from the - bound JWT settings, so the expiry reported to a client matches the token's actual exp, while the - defaults keep hand-written test doubles on the baseline instead of forcing every double to implement - two more members. GetPrincipalFromExpiredToken(string token) (:48) closes the set.
    • -
    • Why it's built this way: the explicit-parameter overload is a [Rubric §11, Security] guardrail. - The token's contents are a deliberate list, not whatever claims happened to ride in on an inbound - principal. Surfacing the lifetimes through the same port removes the older duplication where the - caller hard-coded an expiry that could silently drift from the signed exp. Note the consumer still - guards: AuthenticationServiceBase falls back to the same 15-minute and 7-day baselines when an - implementation reports a non-positive lifetime - (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:61-70).
    • -
    • Where it's used: the shared AuthenticationServiceBase login/refresh/register paths - (AuthenticationServiceBase.cs:168 and :214 stamp the refresh-token and access-token expiries from - those lifetimes, and :298/:305 do the same on the refresh path) and, through it, each app's - Identity authentication service, for example - MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:100 - (access token with speaker claims) and :225 (refresh token). The rotated pair produced here is what - CookieSessionRefresher later exchanges on the browser's behalf.
    • -
    -

    SessionCookieRequest

    MMCA.Common.API · MMCA.Common.API.SessionCookies · MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieEndpoints.cs:72 · Level 0 · record

    @@ -1302,7 +1287,6 @@

    SessionCookieRequest

  • Where it's used: bound by the POST handler at SessionCookieEndpoints.cs:29, which passes both strings straight to SessionCookieJar (:31).
  • -

    SessionTokenResponse

    MMCA.Common.API · MMCA.Common.API.SessionCookies · MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:20 · Level 0 · record

    @@ -1324,7 +1308,6 @@

    SessionTokenResponse

  • Where it's used: constructed and returned by the /auth/session/token handler (MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieEndpoints.cs:56).
  • -

    SessionTokenResult

    MMCA.Common.API · MMCA.Common.API.SessionCookies · MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:14 · Level 0 · record struct

    @@ -1348,7 +1331,6 @@

    SessionTokenResult

    rotation); unwrapped by SessionCookieEndpoints at SessionCookieEndpoints.cs:56. -

    ICookieSessionRefresher

    MMCA.Common.API · MMCA.Common.API.SessionCookies · MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:29 · Level 1 · interface

    @@ -1379,9 +1361,8 @@

    ICookieSessionRefresher

    authentication on navigations) and resolved by the /auth/session/token handler (MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieEndpoints.cs:46). Registered as a singleton at - MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:163. + MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:172. -

    CookieSessionRefreshMiddleware

    MMCA.Common.API · MMCA.Common.API.SessionCookies · MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefreshMiddleware.cs:13 · Level 2 · class

    @@ -1414,12 +1395,16 @@

    CookieSessionRefreshMiddleware

    (ADR-022).
  • Where it's used: registered on both Blazor Server hosts immediately before UseAuthentication(), MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:138 (with UseAuthentication() on the very next - statement at :140) and MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:178 (:180).
  • + statement at :140) and MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:178 (:180). Its + gating rules are pinned one test per branch in + MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/CookieSessionRefreshMiddlewareTests.cs: + an HTML navigation refreshes (:19), a browser-style multi-value Accept list still matches (:31), + a non-HTML Accept (:45), a missing Accept (:60) and a POST (:74) all skip, and a null + refresh result still calls next (:90).
  • Caveats / not-in-source: the ordering rule (before UseAuthentication) is enforced by the host that calls the extension, not by this class. Getting it wrong silently disables the SSR refresh rather than failing loudly.
  • -

    SessionCookieEndpoints

    MMCA.Common.API · MMCA.Common.API.SessionCookies · MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieEndpoints.cs:15 · Level 2 · class

    @@ -1468,7 +1453,6 @@

    SessionCookieEndpoints

    cross-site 403 (:60), the no-session 401 (:91), and the assertion that a valid session returns the access token but never the refresh token (:104). -

    SessionCookieJar

    MMCA.Common.API · MMCA.Common.API.SessionCookies · MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieJar.cs:11 · Level 2 · class

    @@ -1500,9 +1484,10 @@

    SessionCookieJar

    (ADR-022).
  • Where it's used: SessionCookieEndpoints (seed at SessionCookieEndpoints.cs:31, clear at :37) and - CookieSessionRefresher (rewrite after rotation, CookieSessionRefresher.cs:87).
  • + CookieSessionRefresher (rewrite after rotation, + CookieSessionRefresher.cs:87). The attributes it emits are asserted directly by + MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/SessionCookieJarTests.cs. -

    CookieSessionRefreshMiddlewareExtensions

    MMCA.Common.API · MMCA.Common.API.SessionCookies · MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefreshMiddleware.cs:35 · Level 3 · class

    @@ -1528,7 +1513,6 @@

    CookieSessionRefreshMiddleware MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/CookieSessionRefreshMiddlewareTests.cs:115 and :123. -

    CookieTokenReader

    MMCA.Common.API · MMCA.Common.API.SessionCookies · MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieTokenReader.cs:10 · Level 3 · class

    @@ -1564,9 +1548,9 @@

    CookieTokenReader

    (SessionCookieAuthenticationHandler.cs:28) and into the UI host's server-side token store (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Services/ServerTokenStorageService.cs:19). Registered scoped by AddServerAuthSessionCookie - (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:157). + (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:166), and covered by + MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/CookieTokenReaderTests.cs. -

    CookieSessionRefresher

    MMCA.Common.API · MMCA.Common.API.SessionCookies · MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:51 · Level 4 · class

    @@ -1589,15 +1573,16 @@

    CookieSessionRefresher

  • Concept introduced, single-flight refresh under a thundering herd. [Rubric §12, Performance & Scalability] assesses behavior under concurrent load. When an access token expires, many queued navigations can arrive at once; rotating for each would burn the refresh token repeatedly and log the - user out. The type comment (:39-50) states the design and, notably, why it changed: the lock is a - striped KeyedSemaphoreStripe keyed by refresh token rather than one - process-wide semaphore, because the lock is held across an outbound HTTP call and a single semaphore - serialized every unrelated user's cold navigation behind whichever refresh happened to be in flight. - Two unrelated tokens can still land on one stripe, which the comment calls out as harmless precisely - because the rotation-grace cache is re-checked per token after acquiring. Alongside the lock, a - 10-second RotationGrace (:60) caches the rotated pair keyed by the OLD refresh token (:144), so - a slightly-late sibling carrying the same expired pair gets the same result instead of rotating - again.
  • + user out. The type comment (:39-50) states the design: the lock is a striped + KeyedSemaphoreStripe keyed by refresh token rather than one process-wide + semaphore, because the lock is held across an outbound HTTP call and a single semaphore would + serialize every unrelated user's cold navigation behind whichever refresh happened to be in flight. + Two unrelated tokens can still land on the same one of the stripe's 256 lanes + (MMCA.Common/Source/Core/MMCA.Common.Shared/Concurrency/KeyedSemaphoreStripe.cs:25), which the + comment calls out as harmless precisely because the rotation-grace cache is re-checked per token + after acquiring. Alongside the lock, a 10-second RotationGrace (:60) caches the rotated pair + keyed by the OLD refresh token (:144), so a slightly-late sibling carrying the same expired pair + gets the same result instead of rotating again.
  • Walkthrough: GetOrRefreshAsync (:64-93) reads the access cookie (:68) and, if TryReadValidExpiry passes, returns it untouched (:69-72). Otherwise it reads the refresh cookie and returns null when there is none (:74-78). It calls RefreshAsync (:80), treats a missing or @@ -1621,16 +1606,17 @@

    CookieSessionRefresher

    comment at :185-188 explains it is internal rather than private so a concurrency test can pick two refresh tokens that do not collide on a stripe, which is a nice example of a testability affordance that costs nothing at runtime. [Rubric §14, Testability].
  • -
  • Concept, an SSR-safe failure mode. [Rubric §29, Resilience] and [Rubric §13, Observability] - apply to the outbound call. CallRefreshAsync wraps the POST in a try whose filter narrows to - HttpRequestException, OperationCanceledException, JsonException and NotSupportedException - (:147), logs one warning through the source-generated LogRefreshCallFailed (:149, declared with - [LoggerMessage] at :191-192, which is why the class is partial at :51), and returns null. - The comment at :117-122 gives the reasoning: this code runs during SSR, so an escaping exception - would turn a signed-in user's navigation into a 500 instead of an anonymous render. The failure is - deliberately not cached (only a successful rotation reaches cache.Set at :144), so the next - navigation retries, and a missing BaseAddress raises InvalidOperationException and is left to - propagate because that is a host misconfiguration rather than a runtime condition.
  • +
  • Concept, an SSR-safe failure mode. [Rubric §29, Resilience & Business Continuity] and [Rubric + §13, Observability & Operability] apply to the outbound call. CallRefreshAsync wraps the POST in a + try whose filter narrows to HttpRequestException, OperationCanceledException, JsonException + and NotSupportedException (:147), logs one warning through the source-generated + LogRefreshCallFailed (:149, declared with [LoggerMessage] at :191-192, which is why the class + is partial at :51), and returns null. The comment at :117-122 gives the reasoning: this code + runs during SSR, so an escaping exception would turn a signed-in user's navigation into a 500 + instead of an anonymous render. The failure is deliberately not cached (only a successful rotation + reaches cache.Set at :144), so the next navigation retries, and a missing BaseAddress raises + InvalidOperationException and is left to propagate because that is a host misconfiguration rather + than a runtime condition.
  • Why it's built this way: keying the grace cache by the OLD token is what lets a slightly-late sibling find the already-rotated pair, and striping the lock keeps one user's slow refresh from blocking everyone else's cold navigation. The server-to-server call is what keeps the refresh token @@ -1639,11 +1625,13 @@

    CookieSessionRefresher

  • Where it's used: resolved as ICookieSessionRefresher by CookieSessionRefreshMiddleware and by the /auth/session/token endpoint. Its named HttpClient, RefreshClientName = "SessionCookieRefreshClient" (:57), is configured with the API base address in - AddServerAuthSessionCookie (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:159-160), - which also registers the refresher as a singleton (:162-163) with an inline note that a shared - instance across requests is what makes single-flight work at all.
  • + AddServerAuthSessionCookie + (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:168-169), which also + registers the refresher as a singleton (:171-172) with an inline note that a shared instance across + requests is what makes single-flight work at all. The validate, rotate, grace-cache and failure paths + are covered by + MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/CookieSessionRefresherTests.cs. -

    SessionCookieAuthenticationHandler

    MMCA.Common.API · MMCA.Common.API.SessionCookies · MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieAuthenticationHandler.cs:24 · Level 4 · class

    @@ -1692,9 +1680,9 @@

    SessionCookieAuthenticationHandlerMMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:111-112. Covered directly by MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/SessionCookieAuthenticationHandlerTests.cs, including the fresh-token-from-Items path (:95, which stashes the token under - CookieTokenReader.FreshAccessTokenItemKey at :102 and asserts it wins over an expired cookie). + CookieTokenReader.FreshAccessTokenItemKey at :102 and asserts it wins over an expired cookie) and + the proof that expiry is judged by the handler's TimeProvider rather than the system clock (:112). -

    SessionCookieAuthenticationExtensions

    MMCA.Common.API · MMCA.Common.API.SessionCookies · MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieAuthenticationHandler.cs:90 · Level 5 · class

    @@ -1720,60 +1708,259 @@

    SessionCookieAuthenticationExtens MMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:112, chained onto the host's AddAuthentication(SessionCookieAuthenticationHandler.SchemeName) call on the preceding line. -
    +

    IAuthUser

    +
    +

    MMCA.Common.Domain · MMCA.Common.Domain.Auth · MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:10 · Level 0 · interface

    +
    +
      +
    • What it is: the deliberately minimal credential and refresh-token surface an Identity module's User aggregate exposes to the shared AuthenticationServiceBase<TUser> workflow. It is the contract that lets the framework's authentication plumbing read password material and rotate refresh tokens without knowing anything app-specific about the user (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:3-9).
    • +
    • Depends on: nothing first-party; the BCL only (byte[], DateTime). Implemented by each app's User aggregate (see User).
    • +
    • Concept introduced: the inverted user contract. Rather than the shared auth workflow depending on a concrete User class, User implements a small interface the framework owns. Profile fields, roles, linked aggregates, and claim sources stay app-specific: the shared workflow reaches those only through per-app hooks (CreateAccessToken, CreateUser), never through this contract (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:5-8). [Rubric §1, SOLID] assesses interface segregation and dependency inversion, and this is a textbook case: the interface is exactly the credential surface and nothing more. [Rubric §11, Security] assesses credential and session handling, and here the password hash, its salt, and the refresh-token lifecycle are the entire contract, which makes the security-relevant surface of a User aggregate readable in one screen.
    • +
    • Walkthrough: read the six members in two groups.
        +
      • Password material: byte[] PasswordHash (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:14) and byte[] PasswordSalt (:17), where the salt length is what selects the verify algorithm (see PasswordHasher). The scoped #pragma warning disable CA1819 (:12, restored on :18) knowingly returns arrays, to mirror IPasswordHasher's byte[] shape and the EF-mapped varbinary columns rather than force a defensive copy on every read.
      • +
      • Refresh-token state: nullable string? RefreshToken (:21) and DateTime? RefreshTokenExpiry (:24), both null when the token was never issued or has been revoked. Two mutators carry the rotation and revocation rules: UpdateRefreshToken(string refreshToken, DateTime expiry) (:27, BR-205) and RevokeRefreshToken() (:30, BR-206/216). Note that the state is read-only through properties and changed only through the two methods: the aggregate keeps control of the transition.
      • +
      +
    • +
    • Why it's built this way: keeping the contract in Domain and keeping it small is what makes the shared auth workflow reusable across Store and ADC (both User aggregates implement it) while each aggregate stays free to model everything else its own way. See ADR-004 for the dual-fetch auth model this contract feeds and ADR-032 for the password-material policy.
    • +
    • Where it's used: it is half the generic constraint on the shared login and refresh workflow, where TUser : AuditableAggregateRootEntity<UserIdentifierType>, IAuthUser (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:41), which calls UpdateRefreshToken on issue and rotation (:168, :298) and RevokeRefreshToken on reuse detection and revocation (:261, :282). It is also the base of IPasswordChangeableUser.
    • +
    +

    IPasswordHasher

    +
    +

    MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IPasswordHasher.cs:6 · Level 0 · interface

    +
    +
      +
    • What it is: the password-security port. Two methods: hash a plaintext password into a separated (byte[] Hash, byte[] Salt) pair, and verify a plaintext against a stored hash plus salt.
    • +
    • Depends on: nothing first-party, BCL only (byte[]). Its Infrastructure adapter is PasswordHasher.
    • +
    • Concept introduced: hash and salt kept apart. [Rubric §11, Security] assesses credential handling. Returning the hash and the salt as two distinct byte[] members (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IPasswordHasher.cs:11) rather than one concatenated blob keeps the storage contract explicit: the caller persists two columns, and VerifyPassword (:18) is unambiguous about what it re-derives and compares. Because the algorithm and its parameters live entirely behind this interface, they can be strengthened without touching a single Application handler (ADR-032 sets the current hashing policy, applied inside PasswordHasher).
    • +
    • Walkthrough: (byte[] Hash, byte[] Salt) HashPassword(string password) (:11) returns a named value tuple the caller stores as two fields. bool VerifyPassword(string password, byte[] hash, byte[] salt) (:18) re-derives from the supplied salt and compares. The interface declares no iteration count, algorithm identifier, or format version: every one of those is the concrete's business.
    • +
    • Why it's built this way: a two-method port is the [Rubric §1, SOLID] dependency-inversion story in miniature. Swapping the key-derivation function or raising the iteration count is an Infrastructure change, invisible to the register, login, and change-password use cases that only ever see this contract.
    • +
    • Where it's used: constructor-injected into AuthenticationServiceBase<TUser> (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:37), which calls VerifyPassword on the login path (:112) and HashPassword on registration (:159); into the shared ChangePasswordHandlerBase<TUser, TCommand>, which verifies the current password (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:55) before hashing the new one (:61); and into the per-app Identity services and handlers that derive from those, for example ADC's AuthenticationService (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:38) and its ChangePasswordHandler (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordHandler.cs:19).
    • +
    +

    ISoftDeletedUserValidator

    +
    +

    MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ISoftDeletedUserValidator.cs:7 · Level 0 · interface

    +
    +
      +
    • What it is: a single-method port that answers "has this account been soft-deleted?", called after JWT authentication to reject a soft-deleted user who still holds a valid, unexpired token (BR-133, named in the type comment at MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ISoftDeletedUserValidator.cs:4).
    • +
    • Depends on: BCL plus the solution-wide UserIdentifierType alias (:15). See primer §2 for the alias convention and ADR-005 for soft-delete versus erasure. The generic implementation is SoftDeletedUserValidator<TUser>.
    • +
    • Concept introduced: closing the stateless-token window. [Rubric §11, Security] assesses whether revocation is timely. A JWT is stateless: once signed it stays valid until exp, even if the account behind it was deleted a minute later. This port lets middleware re-ask the question on every authenticated request and fail the request when the answer is yes, with no per-handler code. The comment at :5 states the second motive: the interface is declared in Application and implemented against the app's own User aggregate precisely so the middleware never takes a cross-module domain reference. That is the same dependency inversion as the other ports in this group, applied to a cross-module read.
    • +
    • Walkthrough: one member, Task<bool> IsUserSoftDeletedAsync(UserIdentifierType userId, CancellationToken cancellationToken = default) (:15). One question, one answer, cancellable.
    • +
    • Where it's used: SoftDeletedUserMiddleware resolves it lazily from the request scope (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/SoftDeletedUserMiddleware.cs:75 calls context.RequestServices.GetService<ISoftDeletedUserValidator>(), so a host that registers no implementation simply skips the check; the reason is stated at :43-44). Both apps register the shared generic against their own user type: MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:35 and MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/DependencyInjection.cs:41, both as TryAddScoped<ISoftDeletedUserValidator, SoftDeletedUserValidator<User>>().
    • +
    +

    ITokenService

    +
    +

    MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ITokenService.cs:8 · Level 0 · interface

    +
    +
      +
    • What it is: the token-minting port called by the login and refresh use cases. It builds a signed JWT access token from explicit identity facts, generates an opaque refresh token, publishes the two token lifetimes, and recovers the ClaimsPrincipal from an expired-but-validly-signed access token.
    • +
    • Depends on: System.Security.Claims (BCL, :1) and the UserIdentifierType alias. Its Infrastructure adapter is TokenService, which signs with the RSA key surfaced by IJwksProvider.
    • +
    • Concept introduced: token creation as an Infrastructure detail. [Rubric §3, Clean Architecture] assesses whether library-specific types stay out of the inner layers: the handlers call this contract and never see System.IdentityModel.Tokens.Jwt. GetPrincipalFromExpiredToken (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ITokenService.cs:48) is the linchpin of the refresh flow: it validates the signature while deliberately ignoring lifetime, so an expired access token can still identify the user whose tokens are being rotated, returning null when the token is invalid (:47).
    • +
    • Walkthrough: GenerateAccessToken(UserIdentifierType userId, string email, string role, string fullName, IEnumerable<Claim>? additionalClaims = null) (:17-22) takes the minimum claim set as typed parameters rather than a ready-made principal, with an escape hatch for module-specific claims. GenerateRefreshToken() (:26) returns a cryptographically random base64 string. Two default interface members publish the lifetimes: AccessTokenLifetime (:33, defaulting to 15 minutes) and RefreshTokenLifetime (:40, defaulting to 7 days), both documented as the BR-205 baseline. The comments at :28-32 and :35-39 explain the split: the real implementation derives both from the bound JWT settings, so the expiry reported to a client matches the token's actual exp, while the defaults keep hand-written test doubles on the baseline instead of forcing every double to implement two more members. GetPrincipalFromExpiredToken(string token) (:48) closes the set.
    • +
    • Why it's built this way: the explicit-parameter overload is a [Rubric §11, Security] guardrail. The token's contents are a deliberate list, not whatever claims happened to ride in on an inbound principal. Surfacing the lifetimes through the same port removes the duplication where a caller would hard-code an expiry that could drift from the signed exp. Note the consumer still guards: AuthenticationServiceBase<TUser> falls back to the same 15-minute and 7-day baselines when an implementation reports a non-positive lifetime (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:61-70).
    • +
    • Where it's used: the shared login, register, and refresh paths (AuthenticationServiceBase.cs:168 and :214 stamp the refresh-token and access-token expiries from those lifetimes, :230 reads the expired principal, and :297/:305 do the same on the rotation path) and, through them, each app's Identity authentication service, for example MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:100 (access token with the speaker_id claim) and :225 (refresh token). The rotated pair produced here is what CookieSessionRefresher later exchanges on the browser's behalf.
    • +
    +

    PasswordResetSettings

    +
    +

    MMCA.Common.Application · MMCA.Common.Application.Auth · MMCA.Common/Source/Core/MMCA.Common.Application/Auth/PasswordResetSettings.cs:10 · Level 0 · class (sealed)

    +
    +
      +
    • What it is: the bound options object for the forgot-password workflow: where the reset page lives, how long a token stays redeemable, how many wrong guesses a token tolerates, and how often one address may ask for a reset (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/PasswordResetSettings.cs:6-9).
    • +
    • Depends on: System.ComponentModel.DataAnnotations for the range attributes and System.Diagnostics.CodeAnalysis for one scoped suppression (BCL, :1-2). Nothing first-party. Read by the implementation behind IPasswordResetTokenService and by the shared ForgotPasswordHandlerBase<TUser, TCommand>.
    • +
    • Concept: validated options whose defaults keep an unconfigured host bootable. [Rubric §10, Cross-Cutting Concerns] assesses whether policy knobs are configuration rather than constants buried in a handler, and [Rubric §11, Security] assesses whether the security-relevant knobs (token lifetime, attempt cap, request throttle) are bounded rather than free-form. Every numeric member carries a [Range] attribute, and the host binds the section with ValidateDataAnnotations().ValidateOnStart() (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:139-142), so a typo such as TokenLifetimeMinutes: 0 fails the host at startup instead of silently issuing tokens that are already expired.
    • +
    • Walkthrough: const string SectionName = "PasswordReset" (:13) names the configuration section the host binds. ResetUrl (:25) defaults to string.Empty and is deliberately not [Required]: the doc comment (:15-20) records that a host which has not configured a UI base must still boot, and an empty value degrades to a token-only email the user pastes into the reset page by hand. That degradation is visible in the caller, which emits the bare token when the URL is blank and otherwise appends ?email=...&token=... (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:145-147). The property carries a scoped CA1056 suppression (:21-24) explaining why it is a string and not a System.Uri: it is bound from PasswordReset__ResetUrl, concatenated with a query string, and the empty default is not a valid Uri. The four numeric knobs follow: TokenLifetimeMinutes (:29, [Range(1, 1440)], default 30), MaxValidationAttempts (:36, [Range(1, 100)], default 5), MaxRequestsPerEmail (:40, [Range(1, 100)], default 3), and RequestWindowMinutes (:44, [Range(1, 1440)], default 60). All five members are init-only, so the bound instance is immutable afterwards.
    • +
    • Why it's built this way: the defaults are a working policy on their own, so adopting the feature costs a registration call and no configuration at all, while the [Range] bounds plus ValidateOnStart make the one genuinely dangerous class of misconfiguration (a zero or negative lifetime, an unbounded attempt cap) unreachable. The decision to keep the whole reset credential in configuration and cache rather than in schema is ADR-091.
    • +
    • Where it's used: bound in the framework's Infrastructure registration (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:139-142); consumed by PasswordResetTokenService as a snapshot field (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:32) for the request window, the throttle ceiling, the token lifetime, and the attempt cap; and exposed to the shared forgot-password handler as a protected Settings property (ForgotPasswordHandlerBase.cs:48) that states the expiry in the email body (:125) and renders the link (:145-147).
    • +
    +

    ILoginProtectionService

    +
    +

    MMCA.Common.Application · MMCA.Common.Application.Auth · MMCA.Common/Source/Core/MMCA.Common.Application/Auth/ILoginProtectionService.cs:10 · Level 3 · interface

    +
    +
      +
    • What it is: the application-layer contract for brute-force and rate-limit protection on authentication endpoints: lockout checks, failed-attempt increments, successful-login resets, and registration rate-limiting per IP address.

      +
    • +
    • Depends on: Result from MMCA.Common.Shared.Abstractions (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/ILoginProtectionService.cs:1).

      +
    • +
    • Concept introduced: rate limiting as a first-class application concern. [Rubric §11, Security] assesses brute-force protection on auth flows, and [Rubric §10, Cross-Cutting Concerns] assesses whether such a policy is extracted to a port so the application layer can reason about it without coupling to a specific store (the doc comment at :7-8 names both a distributed and an in-memory cache as valid backers). Returning Result from CheckLockoutAsync (:18) and CheckRegistrationRateLimitAsync (:42) makes "account is locked out" a normal control-flow branch rather than a thrown exception.

      +
    • +
    • Walkthrough: five async methods in two scopes.

      +
        +
      • Email-scoped (failed-login lockout): CheckLockoutAsync (:18) returns a failure result when the email is currently locked; IncrementFailedAttemptsAsync (:26) records a failure and, per the doc comment (:20-22), applies exponential-backoff lockout once the maximum is exceeded; ResetFailedAttemptsAsync (:33) clears the counter after a successful login.
      • +
      • IP-scoped (registration flood): CheckRegistrationRateLimitAsync (:42) and IncrementRegistrationCountAsync (:49) throttle account creation per client IP. Both accept a nullable ipAddress and skip the check when it is null, so a host that cannot resolve the caller IP degrades to no limit rather than blocking everyone; CheckRegistrationRateLimitAsync returns Result.Success() in that case (doc comment, :36-37).
      • +
      +

      All five take a CancellationToken with a default argument, per convention.

      +
    • +
    • Why it's built this way: keeping the protection policy behind an interface lets the shared authentication workflow compose it in while the concrete cache mechanics stay in the implementation; the null-IP skip keeps the limiter from becoming an availability hazard (ADR-029).

      +
    • +
    • Where it's used: injected into AuthenticationServiceBase<TUser> (constructor parameter at MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:38), which calls all five across its login and registration flows (:84, :99, :114, :128, :146, :207). The concrete, cache-backed LoginProtectionService (tuned by LoginProtectionSettings) implements it, and the framework registers that pairing at MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:137.

      +
    • +
    +

    IPasswordChangeableUser

    +
    +

    MMCA.Common.Domain · MMCA.Common.Domain.Auth · MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IPasswordChangeableUser.cs:11 · Level 3 · interface

    +
    +
      +
    • What it is: the password-rotation surface an Identity module's User aggregate exposes to the shared ChangePasswordHandlerBase<TUser, TCommand> workflow. It is one method on top of IAuthUser (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IPasswordChangeableUser.cs:5-10).
    • +
    • Depends on: IAuthUser (its base interface, :11) and Result from MMCA.Common.Shared.Abstractions (:1).
    • +
    • Concept: capability interfaces layered by workflow. [Rubric §1, SOLID] assesses interface segregation, and this is the pattern applied twice over: a User that only ever authenticates implements IAuthUser; a User whose app offers self-service password change implements this one and gets PasswordHash and PasswordSalt along with it, because the workflow must verify the current credential before writing the new one (the XML comment states exactly this reason, :8-9). Inheritance here encodes a real dependency between capabilities rather than a taxonomy. [Rubric §4, DDD] also applies: the method returns Result, so the aggregate can refuse the change (an invariant failure) instead of the handler assuming success.
    • +
    • Walkthrough: one member, Result ChangePassword(byte[] newPasswordHash, byte[] newPasswordSalt) (:19). The aggregate receives already-hashed material, never a plaintext password: hashing is the handler's job via IPasswordHasher, so no plaintext ever reaches the Domain layer or an EF change tracker.
    • +
    • Why it's built this way: keeping the hash-and-salt pair as the parameter shape mirrors IAuthUser's two properties and IPasswordHasher's tuple return, so the whole chain from handler to aggregate speaks one vocabulary. See ADR-032.
    • +
    • Where it's used: as the generic constraint where TUser : AuditableAggregateRootEntity<UserIdentifierType>, IPasswordChangeableUser on the shared change-password workflow (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:28), which verifies the current password (:55), hashes the new one (:61), and calls ChangePassword with the result (:62). The forgot-password sibling workflow, ResetPasswordHandlerBase<TUser, TCommand>, writes the new material on the redeem path after IPasswordResetTokenService has identified the account.
    • +
    +

    IPasswordResetTokenService

    +
    +

    MMCA.Common.Application · MMCA.Common.Application.Auth · MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IPasswordResetTokenService.cs:10 · Level 3 · interface

    +
    +
      +
    • What it is: the two-method port behind the forgot-password workflow: issue a single-use reset token for an email address, and validate-then-consume a token presented back by the user. Implementations keep the token material outside the database, hashed at rest, and enforce both the per-email request throttle and the per-token validation-attempt cap (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IPasswordResetTokenService.cs:5-9).

      +
    • +
    • Depends on: Result and its generic form from MMCA.Common.Shared.Abstractions (:1), plus the UserIdentifierType alias. Its Infrastructure adapter is PasswordResetTokenService, backed by ICacheService and tuned by PasswordResetSettings.

      +
    • +
    • Concept introduced: a single-use credential without a schema change. [Rubric §11, Security] assesses how a secondary credential is minted, stored, and retired; [Rubric §8, Data Architecture] assesses whether short-lived state earns a place in the durable store. A reset token is not durable data: it is valid for minutes and must stop working the instant it is redeemed. Putting it in columns on the user row costs a migration in every consumer and needs a sweeper to reap expired rows, because expiry is not something a table enforces; a self-contained signed payload needs no store but then cannot be single-use, since a signed token that has not expired stays valid however many times it is presented. This port takes the third path and hides the choice: the handlers see two Result-returning methods, and the cache substrate is entirely the implementation's business (ADR-091).

      +

      The second teaching point is in the return shapes. ValidateAndConsumeAsync is documented to collapse unknown, expired, mismatched, and attempt-capped into one generic failure (:32-35), so the redeem endpoint cannot be used to distinguish a wrong token from an expired one from an address that was never issued a token. The issue path is throttled rather than refused loudly, for the same anti-enumeration reason the forgot-password handler answers success to every input.

      +
    • +
    • Walkthrough: two members.

      +
        +
      • Task<Result<string>> IssueAsync(string email, UserIdentifierType userId, CancellationToken cancellationToken = default) (:23) returns the raw token to email, or a failure when the per-email request throttle has been exceeded. The doc comment (:12-15) states the replace semantics: issuing overwrites any token already outstanding for that address, so requesting a new link immediately stops the older one from working. The userId parameter is what the token resolves back to at redeem time, which is why the redeem call never has to trust an identifier supplied by the caller.
      • +
      • Task<Result<UserIdentifierType>> ValidateAndConsumeAsync(string email, string token, CancellationToken cancellationToken = default) (:36) validates the presented token against the outstanding record and consumes it on success, so a token never redeems twice (:25-28), returning the account the token belongs to.
      • +
      +
    • +
    • Why it's built this way: taking email on both methods, rather than treating the token as self-describing, is what lets the implementation key its records by address and enforce the per-address throttle and the one-active-token rule at the same key. Returning Result<UserIdentifierType> rather than a boolean means the redeem handler gets the account identity from the token store itself. See PasswordResetTokenService for the mechanics the port hides: a 32-byte random token, only its SHA-256 stored, a fixed-time comparison, an attempt counter rewritten with the remaining lifetime so a wrong guess cannot extend the window, and removal of both the token record and the request counter on success.

      +
    • +
    • Where it's used: injected into the shared ForgotPasswordHandlerBase<TUser, TCommand> (constructor parameter at MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:37, called at :72, where a throttled issue is logged and still answered as success) and into ResetPasswordHandlerBase<TUser, TCommand> (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:33, redeemed at :62). Both apps' sealed subclasses take the same dependency, for example ADC's ForgotPasswordHandler (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandler.cs:22) and ResetPasswordHandler (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordHandler.cs:21). The framework registers the concrete as scoped at MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:143.

      +
    • +
    +

    IUserPreferences

    +
    +

    MMCA.Common.Domain · MMCA.Common.Domain.Auth · MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IUserPreferences.cs:10 · Level 3 · interface

    +
    +
      +
    • What it is: the stored UI-preference surface an Identity module's User aggregate exposes to the shared preference read and write workflows: preferred culture, preferred theme, and a single method that replaces both (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IUserPreferences.cs:5-9).
    • +
    • Depends on: Result from MMCA.Common.Shared.Abstractions (:1). Nothing else; it is deliberately not tied to IAuthUser, because preferences are orthogonal to credentials.
    • +
    • Concept: null as "not chosen". [Rubric §27, i18n] assesses whether locale is a first-class, persisted user choice rather than a per-session guess, and [Rubric §19, State Management] assesses where such UI state lives. Both properties are nullable, and the contract states that null means the user has not chosen that preference (:7-8), which is what lets the UI fall back to a browser or host default without needing a separate "is set" flag. See ADR-027 for the culture model and ADR-028 for the theme model.
    • +
    • Walkthrough: string? PreferredCulture (for example "es", :13) and string? PreferredTheme ("light" or "dark", :16) are read-only. Result UpdatePreferences(string? preferredCulture, string? preferredTheme) (:25) replaces both at once. The subtlety is documented at :18-21: because the method is a whole-object replace, the shared workflow always passes the currently stored value for any field the request left null, so writing one preference never silently clears the other. That read-then-merge is visible in the caller (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:53).
    • +
    • Why it's built this way: one replace method keeps the aggregate's invariant check in a single place, and pushing the merge into the workflow keeps the null-means-unchanged policy out of every app's User. Returning Result lets the aggregate reject an unsupported culture or theme value.
    • +
    • Where it's used: the read workflow constrains where TUser : AuditableBaseEntity<UserIdentifierType>, IUserPreferences and projects both properties into a response (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandlerBase.cs:23, :44); the write workflow constrains where TUser : AuditableAggregateRootEntity<UserIdentifierType>, IUserPreferences (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:26). Both are cross-linked as GetUserPreferencesHandlerBase<TUser> and ChangePreferencesHandlerBase<TUser, TCommand>.
    • +
    +

    IErasableUser

    +
    +

    MMCA.Common.Domain · MMCA.Common.Domain.Auth · MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IErasableUser.cs:30 · Level 4 · interface

    +
    +
      +
    • What it is: the erasure surface an Identity module's User aggregate exposes to the shared DeleteUserHandlerBase<TUser, TCommand> workflow: soft-delete the row, then irreversibly anonymize the personal data it still holds (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IErasableUser.cs:6-10).
    • +
    • Depends on: IAnonymizable (its base, contributing Result Anonymize(), :1 and :30) and Result (:2).
    • +
    • Concept introduced: why a Delete() that already exists on the base entity is redeclared here. This is the most instructive comment in the file and it is worth reading in full (:11-29). AuditableBaseEntity<TIdentifierType> already has a Delete(). But an app's User may hide it (public new Result Delete()) to couple account-specific behavior to deletion, typically revoking the refresh token so outstanding sessions die immediately. A hidden method is not an override. C# member lookup on a generic type parameter prefers the members of its class constraint, so a shared workflow writing user.Delete() would bind to the base implementation and silently skip the app's version. Redeclaring Delete() on this interface and invoking it through the interface forces interface dispatch, which resolves to the most derived member the app type maps onto IErasableUser. [Rubric §1, SOLID] (Liskov: the hidden method is exactly the substitutability hazard this closes) and [Rubric §15, Best Practices & Code Quality] both apply, and this is a case where the language rule, not a style preference, dictates the design. The second paragraph (:25-28) adds the compile-time guarantee: the base entity deliberately does not implement this interface, so a consumer that forgets to declare it fails the generic constraint at compile time rather than losing behavior at run time.
    • +
    • Walkthrough: one declared member, Result Delete() (:37), documented as soft-delete plus whatever the app couples to deletion (:32-35), returning a failure when the account is already deleted (:36). Inherited from IAnonymizable is Result Anonymize(), which must be idempotent. The two-step order is visible in the caller: cast once to the interface (IErasableUser erasable = user;, MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:88, with the reason spelled out at :83-87), erasable.Delete() first (:89), the app's own tail hook next (OnAfterSoftDeleteAsync, :95), then erasable.Anonymize() (:103), each short-circuiting on failure.
    • +
    • Why it's built this way: soft-delete alone hides a row but retains its personal data, so it does not satisfy an erasure request; anonymize-in-place overwrites the personal fields while keeping the row so foreign keys and the audit trail survive (ADR-005). Splitting the two into separate members lets the workflow run app-specific work between them. [Rubric §30, Compliance, Privacy & Data Governance] assesses exactly this: an erasure path that does not destroy referential integrity.
    • +
    • Where it's used: the generic constraint where TUser : AuditableAggregateRootEntity<UserIdentifierType>, IErasableUser on the shared delete-user workflow (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:41), implemented by each app's User aggregate.
    • +
    +

    SoftDeletedUserCache

    +
    +

    MMCA.Common.Application · MMCA.Common.Application.Auth · MMCA.Common/Source/Core/MMCA.Common.Application/Auth/SoftDeletedUserCache.cs:17 · Level 4 · class (static)

    +
    +
      +
    • What it is: the shared cache contract for the soft-deleted user marker (BR-133): the key shape, the marker lifetime, and a one-call helper that writes it. The API middleware reads the marker on every authenticated request; the module that soft-deletes a user writes it (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/SoftDeletedUserCache.cs:6-10).

      +
    • +
    • Depends on: ICacheService (:2), the UserIdentifierType alias, and System.Globalization.CultureInfo (BCL, :1).

      +
    • +
    • Concept introduced: revoking a stateless credential without a per-request lookup. [Rubric §11, Security] assesses whether a revoked principal actually loses access, and [Rubric §10, Cross-Cutting Concerns] assesses whether such a concern is factored so both ends share one definition. A JWT is a bearer credential: signature validation never asks "is this account still active?", so soft-deleting a user leaves an already-issued access token passing validation until it expires (ADR-047). The textbook fixes (a deny-list, or an account-status query on every request) reintroduce exactly the per-request state that stateless JWT was chosen to avoid. This type is the middle path: a short-lived cache marker written at deletion time and read cheaply on the hot path.

      +

      The remarks (:11-16) explain why the constants live in the Application layer rather than next to the middleware that reads them: a downstream application deleting an account has to write the exact same key the middleware reads, and a private constant in the presentation layer is unreachable from an application-layer command handler. Same reasoning as IdempotencyHeaders, applied one layer up.

      +
    • +
    • Walkthrough: three static members, no state.

      +
        +
      • MarkerDuration => TimeSpan.FromSeconds(30) (:29). The remarks (:22-28) justify the number rather than leaving it magic: the marker only has to cover the window between the delete committing and the next token validation, because once it expires the validator query is the source of truth again and gives the same answer. Short-lived access tokens (15 minutes, the BR-205 default on ITokenService) bound the rest of the exposure, so a longer marker would buy nothing and would keep stale entries alive for users who were never deleted.
      • +
      • KeyFor(UserIdentifierType userId) (:42-43) builds user:deleted:{userId} through string.Create(CultureInfo.InvariantCulture, ...). The remarks (:36-41) name the bug this prevents: an identifier renders differently under some cultures (digit shapes, group separators), so a culture-sensitive key would be written under one request's culture and missed under another, silently letting a deleted user keep making requests. This is a case where the analyzer rule about culture-invariant formatting is guarding a security property, not just a formatting nicety.
      • +
      • MarkDeletedAsync(ICacheService cache, UserIdentifierType userId, CancellationToken cancellationToken = default) (:53-61) null-guards the cache (:58) and writes true under KeyFor(userId) for MarkerDuration (:60). It returns the task without awaiting, so there is no extra async state machine for a one-call passthrough.
      • +
      +
    • +
    • Why it's built this way: publishing the key shape and the TTL as framework API is what keeps the writer and the reader honest, and it is a precondition for the module boundary in ADR-047: Identity owns the delete, every service hosts the middleware, and the only thing they share is a cache entry rather than a database. [Rubric §7, Microservices Readiness] applies directly: an extracted service can enforce the revocation without a reference to the Identity database.

      +
    • +
    • Where it's used: read by SoftDeletedUserMiddleware, which builds the key (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/SoftDeletedUserMiddleware.cs:85), short-circuits with 401 when the marker is true (:104), and on a miss falls back to the validator query and caches that answer, deleted or not, for the same MarkerDuration (:132). Written by the Identity delete path: ADC's DeleteUserHandler queues it as an after-commit action (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs:68-73, inside the OnAfterSoftDeleteAsync override at :46) and swallows a cache fault so a failed marker cannot turn a successful erasure into an error the caller would retry.

      +
    • +
    • Caveats / not-in-source: the marker is best effort on both ends by design. The middleware fails open on a cache outage, falling through to the validator query and proceeding if that is also unavailable, and the writer logs and continues on a cache fault. The exposure that leaves is bounded by the access-token lifetime, which is the trade-off ADR-047 accepts explicitly. ADC's handler is the only writer in the source tree today; MMCA.Store soft-deletes users without writing the marker, so there the middleware's own validator-query fallback is what enforces BR-133.

      +
    • +
    +

    AuthenticationValidators

    +
    +

    MMCA.Common.Application · MMCA.Common.Application.Auth · MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationValidators.cs:16 · Level 5 · class (sealed)

    +
    +
      +
    • What it is: a tiny parameter object that bundles the three FluentValidation validators the authentication workflow needs (login, registration, refresh) into one injectable dependency.
    • +
    • Depends on: FluentValidation's IValidator<T> (NuGet, :1) over the request DTOs LoginRequest, RegisterRequest, and RefreshTokenRequest (all in MMCA.Common.Shared.Auth, :2).
    • +
    • Concept introduced: the parameter object as a constructor-arity guardrail. [Rubric §1, SOLID] assesses whether a class stays a single, cohesive responsibility rather than sprawling into a god class, and [Rubric §16, Maintainability & Evolvability] assesses whether cross-cutting dependencies are grouped so a class can grow without exploding its constructor. The doc comment (:6-12) states the exact motive: collapsing three closely-related dependencies into one keeps the app's AuthenticationService below the application-service constructor-arity ceiling (a god-class analyzer guardrail) without giving up per-request validation. Because the request DTOs already live in MMCA.Common.Shared.Auth, the bundle is app-agnostic, which is why it could be hoisted out of the apps into the framework.
    • +
    • Walkthrough: a primary constructor takes the three IValidator<T> instances (:16-19), and three get-only properties surface them by name: Login (:22), Register (:25), and Refresh (:28), each assigned from its matching constructor parameter. There is no logic here; the type exists purely to shrink the dependency footprint of its consumer.
    • +
    • Why it's built this way: a sealed grouping type with get-only properties is the cheapest way to fold three cohesive dependencies into one constructor slot, so the workflow base can validate each request shape without pushing its constructor over the arity limit; DI resolves the three underlying validators and composes them into this one object. Two of the three (LoginRequestValidator, RefreshTokenRequestValidator) come from the framework assembly, while IValidator<RegisterRequest> is satisfied by the app's own RegisterRequestValidator, so the bundle is the point where framework and app validation meet.
    • +
    • Where it's used: injected into AuthenticationServiceBase<TUser> (constructor parameter at MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:40), whose LoginAsync, RegisterAsync, and RefreshTokenAsync call validators.Login (:77), validators.Register (:139), and validators.Refresh (:222) respectively before doing any work.
    • +
    +

    IAuthenticationService

    +
    +

    MMCA.Common.Application · MMCA.Common.Application.Auth · MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IAuthenticationService.cs:11 · Level 5 · interface

    +
    +
      +
    • What it is: the application-layer contract for the Identity module's authentication workflows: login, registration, token refresh, token revocation, and external (OAuth) login.

      +
    • +
    • Depends on: LoginRequest, RefreshTokenRequest, RegisterRequest, AuthenticationResponse, Result, Error, and the UserIdentifierType alias (:1-2).

      +
    • +
    • Concept introduced: default interface methods for optional capabilities. [Rubric §1, SOLID] (interface segregation and dependency inversion): ExternalLoginAsync (:66-74) ships a default implementation in the interface itself that returns a not-supported Error ("Auth.ExternalLoginNotSupported", :74). An implementation that does not offer OAuth (a stub host, or a deployment with social login disabled) inherits that failure for free and need not override anything, so the interface stays one piece while the capability is opt-in (ADR-036). [Rubric §11, Security]: login, registration, and refresh all return Result<AuthenticationResponse>, so auth outcomes flow as values and no exception leaks credential detail to the caller.

      +
    • +
    • Walkthrough: five methods, all async, all taking a CancellationToken.

      +
        +
      • LoginAsync(LoginRequest) returns Result<AuthenticationResponse> (:19).
      • +
      • RegisterAsync(RegisterRequest, string? ipAddress = null) (:30); the optional ipAddress feeds ILoginProtectionService's registration rate limit.
      • +
      • RefreshTokenAsync(RefreshTokenRequest) (:41) rotates the token pair.
      • +
      • RevokeTokenAsync(UserIdentifierType userId) returns Result (:51) and revokes a user's refresh token, returning a not-found error when there is none.
      • +
      • ExternalLoginAsync(loginProvider, providerKey, email, firstName, lastName) (:66), the default-implemented OAuth path; finds an account by provider and key or creates one from claims.
      • +
      +

      The doc comment (:6-9) also records a scope decision: password change is not on this interface. It is dispatched directly through its own command handler at the controller layer.

      +
    • +
    • Why it's built this way: concentrating the token-issuing workflows behind one port keeps the Identity controllers thin and lets the protection and rate-limit policy (ILoginProtectionService) compose in; the default OAuth method keeps the contract stable across hosts that do and do not enable social login.

      +
    • +
    • Where it's used: implemented by AuthenticationServiceBase<TUser> (which realises every member except the default ExternalLoginAsync) and, through it, by each app's sealed AuthenticationService; consumed by the Identity API controllers.

      +
    • +
    +

    AuthenticationServiceBase<TUser>

    +
    +

    MMCA.Common.Application · MMCA.Common.Application.Auth · MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:34 · Level 8 · class (abstract)

    +
    +
      +
    • What it is: the shared authentication workflow (login, registration, token refresh and rotation, revocation) hoisted once into the framework, generic over the app's User aggregate. It realises IAuthenticationService and leaves the genuinely app-specific decisions to a small set of abstract and virtual hooks a sealed subclass overrides.
    • +
    • Depends on: IUnitOfWork and IRepository<TEntity, TIdentifierType> (persistence, G07), ITokenService, IPasswordHasher, ILoginProtectionService, AuthenticationValidators (this group), the IAuthUser credential contract plus AuditableAggregateRootEntity<TIdentifierType> as the TUser constraint (:41), Email (normalizing the login and register address), Result and Error, the request and response DTOs (LoginRequest, RegisterRequest, RefreshTokenRequest, AuthenticationResponse), and the BCL TimeProvider (injected at :39, never DateTime.UtcNow, so the clock is testable).
    • +
    • Concept introduced: the Template Method that de-duplicates a whole vertical slice. [Rubric §2, Design Patterns] assesses idiomatic pattern use: this is a textbook Template Method, the invariant sequence of an operation living in the base while the variable steps are deferred to subclass hooks. [Rubric §16, Maintainability & Evolvability] (DRY across services) and [Rubric §1, SOLID] also apply: the doc comment (:11-32) records that the app Identity modules previously duplicated this workflow at roughly 70 to 95 percent line-identity, so folding it here means a fix to the lockout order or the rotation logic is written once. [Rubric §11, Security]: the base encodes the security posture directly, validate first, an ILoginProtectionService lockout and rate-limit gate (ADR-029), an untracked-then-tracked dual fetch (ADR-004), and refresh-token rotation with reuse detection (ADR-050, BR-205/206). [Rubric §7, Microservices Readiness]: the workflow depends only on ports, so it runs unchanged whether the Identity module is in-monolith or its own service.
    • +
    • Walkthrough (members in teaching order):
        +
      • Constructor and protected accessors (:34-54): a primary constructor takes the six collaborators; protected read-only properties re-expose UnitOfWork (:44), TokenService (:47), TimeProvider (:50) and a Repository (:53-54) resolved lazily as unitOfWork.GetRepository<TUser, UserIdentifierType>(), so subclass hooks and app-level flows (external login) reuse them without re-injecting.

        +
      • +
      • Token lifetimes (:61-70): virtual AccessTokenLifetime and RefreshTokenLifetime read through to ITokenService (which derives them from Jwt:AccessTokenExpirationMinutes and Jwt:RefreshTokenExpirationDays), so the expiry reported to the client matches the JWT's actual exp. A non-positive value, meaning a hand-written test double or a misconfigured host, falls back to the BR-205 defaults of 15 minutes and 7 days (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ITokenService.cs:33 and :40 carry the same defaults on the port).

        +
      • +
      • LoginAsync (:73-131): validate the request (:77-81), check lockout (:84, ADR-029 and BR-212), normalize the raw email into an Email value object (:92) so the EF predicate compares same-typed converted values (an invalid address yields a null value object that simply matches no user, which is the invalid-credentials answer anyway). Step 1 is an untracked fetch via the FindUntrackedByEmailAsync hook (:96) to verify credentials without change-tracker overhead; a null result increments failed attempts and returns a generic 401 (:97-102). An app gate runs before password verification (:106, with no failed-attempt increment so the pre-hoist behavior is preserved), then passwordHasher.VerifyPassword (:112). Step 2 is a tracked re-fetch by id (:120) so the new refresh token can be persisted, followed by ResetFailedAttemptsAsync (:128) and IssueTokensAsync (:130).

        +
      • +
      • RegisterAsync (:134-215): validate (:139-143), IP rate-limit (:146, ADR-029 and BR-213), reject a duplicate email through the EmailExistsAsync hook (:153-157), hash the password (:159), build the user through the CreateUser hook (:160), mint and store a refresh token (:167-168), AddAsync (:170) and SaveChangesAsync (:174), then run the OnUserRegisteredAsync post-commit hook (:204) to pick up the instance the first access token is minted from, increment the IP registration count (:207), and return the token pair (:211-214).

        +

        The save is wrapped in a deliberately broad catch (Exception) (:172-200, with a scoped CA1031 suppression at :176-178) whose comment is the teaching material. The email lookup above is a check-then-act: two concurrent registrations for the same address both pass it, and the loser only fails on the insert, against the unique index every consumer puts on Email (ADC unfiltered, Store filtered on IsDeleted). Without the catch, that race surfaces as a generic 500 instead of the 409 a serialized pair would have produced. The catch cannot name DbUpdateException, because Application has no EF Core dependency by layer rule, so the re-check is what narrows it (:194): if the address exists now, the concurrent registration is the cause and the caller gets the same conflict the serial path returns through the shared EmailAlreadyExistsFailure() helper; anything else rethrows untouched (:199) and still reaches the exception middleware. The re-check passes CancellationToken.None on purpose (:192-194): it has to run even when the caller's token is what aborted the save, or a cancelled save could never be classified.

        +
      • +
      • RefreshTokenAsync (:218-269): validate (:222-226), pull claims from the expired JWT via tokenService.GetPrincipalFromExpiredToken (:230, signature still checked, only lifetime skipped), read the user_id claim (:237-242), load the tracked user (:244), run the refresh app gate (:251), then the security-critical check (:259): if the stored RefreshToken does not match or has expired, this is treated as token reuse (potential theft), so user.RevokeRefreshToken() is called and saved (:261-262, BR-206) before returning a 401. A clean match issues a rotated pair through IssueTokensAsync (:268).

        +
      • +
      • RevokeTokenAsync (:272-286): load by id, RevokeRefreshToken(), save; a missing user yields Error.NotFound targeted at typeof(TUser).Name (:279).

        +
      • +
      • IssueTokensAsync (:292-306): the shared rotation used by login and refresh, and reusable by an app-level external-login flow. It mints an access token via the CreateAccessToken hook (:296), generates a new refresh token (:297), stamps its expiry off TimeProvider (:298), saves (:300), and returns the response (:302-305).

        +
      • +
      • The hooks: four are abstract, so a subclass must supply them. FindUntrackedByEmailAsync (:313) and EmailExistsAsync (:319) are deliberately written against the app's concrete User so EF translates the predicate byte-for-byte as before, and the second explicitly leaves the app to decide whether soft-deleted accounts count (ignoreQueryFilters: true blocks re-registration of an erased address, :315-318); CreateUser (:322) runs the app's domain factory; CreateAccessToken (:325) mints the app's claim set (for example speaker_id versus customer_id). Four virtual hooks default to a no-op: ValidateLoginCandidateAsync (:328) and ValidateRefreshCandidateAsync (:332) add extra gates such as a deactivated-account check; OnUserRegisteredAsync (:339) runs the post-commit side-effect (publish an integration event, or re-fetch a linked id); and CreateRefreshUserMissingError (:347) defaults the vanished-user case to 401 (a token for a missing user is indistinguishable from an invalid one) while letting an app return 404 where its public contract already promises it. One private static helper, EmailAlreadyExistsFailure() (:355-357), returns the Auth.EmailAlreadyExists conflict so the up-front check and the race recovery are indistinguishable to the caller.

        +
      • +
      +
    • +
    • Why it's built this way: the untracked-then-tracked dual fetch keeps the common credential-verification path off the change tracker (cheaper, and soft-deleted accounts fall out via EF query filters returning the generic 401) while still giving a tracked instance to persist the new token (ADR-004). Refresh-token reuse detection (revoke on mismatch) is the BR-206 defence against a stolen token being replayed (ADR-050). Password material flows through IAuthUser's PasswordHash and PasswordSalt (ADR-032), and the whole workflow depends only on abstractions, so it is identical whether the module runs in-process or as an extracted service.
    • +
    • Where it's used: subclassed by each app's sealed AuthenticationService, for example MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:35, which binds TUser = User, adds the Attendee default role (BR-45) and the speaker_id claim (BR-209, built at :249-252), and re-lists IAuthenticationService so it can re-implement RegisterAsync and ExternalLoginAsync outright: ADC raises its registration side-effects inside one transactional unit rather than through the OnUserRegisteredAsync hook, because the identity column means the id does not exist until the first save (AuthenticationService.cs:16-32). MMCA.Store supplies its own subclass with a customer_id claim. Consumed by the Identity API controllers via the IAuthenticationService port.
    • +
    • Caveats / not-in-source: the user_id claim is parsed with int.TryParse (:238), so the refresh flow assumes UserIdentifierType is int (the framework alias today, per ADR-048); an app that redefined the alias would need to override the refresh handling. ExternalLoginAsync is intentionally not overridden here: the base inherits the interface's default not-supported failure, and OAuth account linking stays in the app subclass because it is coupled to the app's User factory surface (doc comment, :30-31).
    • +

    ICurrentUserService

    MMCA.Common.Application · MMCA.Common.Application.Interfaces.Infrastructure · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ICurrentUserService.cs:9 · Level 8 · interface

      -
    • What it is: the Application layer's read-only window onto the authenticated caller: the raw - ClaimsPrincipal, a strongly-typed UserId, the caller's first role, the full role set, a generic - typed-claim reader, and a role-membership helper. It answers "who is calling?" without any handler - ever touching HttpContext.
    • -
    • Depends on: System.Security.Claims and IParsable<T> (BCL) plus the solution-wide - UserIdentifierType alias - (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ICurrentUserService.cs:15); - see primer §2. It references - RoleNames in documentation only (:80). Its adapter is - CurrentUserService in Infrastructure.
    • -
    • Concept introduced, the caller-identity port with behavior on the interface. [Rubric §3, Clean - Architecture] assesses whether inner layers stay free of transport types, and [Rubric §1, SOLID] - (interface segregation) whether a contract exposes only what its clients need. A handler must know - the caller to run ownership checks and to stamp audit fields, but it must not depend on - IHttpContextAccessor, which would drag ASP.NET Core into the Application project. This interface is - that inversion. What makes it worth studying is the use of default interface members: Roles - (:45-64) and IsInRole (:88-89) ship real implementations on the contract, so every implementer - and every hand-written test double inherits correct multi-role behavior instead of re-deriving it.
    • -
    • Walkthrough: ClaimsPrincipal User (:12) exposes the full principal for advanced inspection. - UserIdentifierType? UserId (:15) is the typed identifier, nullable because an unauthenticated - request has no user. string? Role (:22) is documented as the first role claim only, with the - remarks at :18-21 steering callers to Roles or IsInRole for membership checks. Roles - (:45-64) is the interesting member: it reads every role claim, accepting each claim type the JWT - middleware may produce (ClaimTypes.Role when inbound claim mapping is on, or the raw role / - roles claim when it is off, :50-53), falls back to a single-element list built from Role when - the principal yields nothing (:62), and null-guards User even though the property is declared - non-nullable (:49). The long remarks at :27-44 justify both accommodations from the nature of a - default interface member: it runs against every implementation, including a hand-written double or - a mock that stubs only Role, where reading claims alone would have reported no roles and silently - turned an authorization check into a denial, and dereferencing a null principal would have turned it - into a NullReferenceException. Claims win when present, so a genuine multi-role principal is still - read in full. T? GetClaimValue<T>(string claimType) where T : struct, IParsable<T> (:73-74) - parses a named claim into any parsable value type and returns null when the claim is missing or - unparseable, which is how a module reads its own claim (the doc names speaker_id, :68) without - Common ever knowing that claim exists. IsInRole(string roleName) (:88-89) is - Roles.Any(role => string.Equals(role, roleName, StringComparison.OrdinalIgnoreCase)).
    • -
    • Why it's built this way: the remarks at :82-87 record the reasoning behind IsInRole checking - every claim rather than comparing against Role. Comparing against the first role alone matched only - whichever role happened to be listed first, which is latent today because tokens carry a single role, - and would have surfaced silently as an authorization denial the moment a second role was added. - Typing UserId as the per-app alias instead of a generic parameter keeps the interface concrete and - easy to mock while staying correct for each app. [Rubric §11, Security] and [Rubric §15, Best - Practices & Code Quality].
    • -
    • Where it's used: injected into command handlers for ownership checks, into - ApplicationDbContext for CreatedBy and - LastModifiedBy stamping, and into this group's authorization filters and permission handlers.
    • -
    • Caveats / not-in-source: Role deliberately reports only the first role claim; treat it as a - display value and use Roles or IsInRole for any decision.
    • +
    • What it is: the Application layer's read-only window onto the authenticated caller: the raw ClaimsPrincipal, a strongly-typed UserId, the caller's first role, the full role set, a generic typed-claim reader, and a role-membership helper. It answers "who is calling?" without any handler ever touching HttpContext.
    • +
    • Depends on: System.Security.Claims and IParsable<T> (BCL, :1) plus the solution-wide UserIdentifierType alias (:15); see primer §2. It references RoleNames in documentation only (:80). Its adapter is CurrentUserService in Infrastructure.
    • +
    • Concept introduced: the caller-identity port with behavior on the interface. [Rubric §3, Clean Architecture] assesses whether inner layers stay free of transport types, and [Rubric §1, SOLID] (interface segregation) whether a contract exposes only what its clients need. A handler must know the caller to run ownership checks and to stamp audit fields, but it must not depend on IHttpContextAccessor, which would drag ASP.NET Core into the Application project. This interface is that inversion. What makes it worth studying is the use of default interface members: Roles (:45-64) and IsInRole (:88-89) ship real implementations on the contract, so every implementer and every hand-written test double inherits correct multi-role behavior instead of re-deriving it.
    • +
    • Walkthrough: ClaimsPrincipal User (:12) exposes the full principal for advanced inspection. UserIdentifierType? UserId (:15) is the typed identifier, nullable because an unauthenticated request has no user. string? Role (:22) is documented as the first role claim only, with the remarks at :18-21 steering callers to Roles or IsInRole for membership checks. Roles (:45-64) is the interesting member: it reads every role claim, accepting each claim type the JWT middleware may produce (ClaimTypes.Role when inbound claim mapping is on, or the raw role / roles claim when it is off, :50-53), falls back to a single-element list built from Role when the principal yields nothing (:62), and null-guards User even though the property is declared non-nullable (:49). The long remarks at :27-44 justify both accommodations from the nature of a default interface member: it runs against every implementation, including a hand-written double or a mock that stubs only Role, where reading claims alone would have reported no roles and silently turned an authorization check into a denial, and dereferencing a null principal would have turned it into a NullReferenceException. Claims win when present, so a genuine multi-role principal is still read in full. T? GetClaimValue<T>(string claimType) where T : struct, IParsable<T> (:73-74) parses a named claim into any parsable value type and returns null when the claim is missing or unparseable, which is how a module reads its own claim (the doc names speaker_id, :68) without Common ever knowing that claim exists. IsInRole(string roleName) (:88-89) is Roles.Any(role => string.Equals(role, roleName, StringComparison.OrdinalIgnoreCase)).
    • +
    • Why it's built this way: the remarks at :82-87 record the reasoning behind IsInRole checking every claim rather than comparing against Role. Comparing against the first role alone matched only whichever role happened to be listed first, which is latent today because tokens carry a single role, and would have surfaced silently as an authorization denial the moment a second role was added. Typing UserId as the per-app alias instead of a generic parameter keeps the interface concrete and easy to mock while staying correct for each app. [Rubric §11, Security] and [Rubric §15, Best Practices & Code Quality] both apply.
    • +
    • Where it's used: injected into command handlers for ownership checks, into ApplicationDbContext for CreatedBy and LastModifiedBy stamping, and into this group's authorization filters and permission handlers.
    • +
    • Caveats / not-in-source: Role deliberately reports only the first role claim; treat it as a display value and use Roles or IsInRole for any decision.

    AuthenticationRequest

    @@ -1798,24 +1985,7 @@

    ClaimBasedUserIdProvider

  • Concept: [Rubric §11, Security] assesses that identity is derived from the token and not from client-supplied input, and [Rubric §10, Cross-Cutting] assesses whether such plumbing is centralized once. SignalR's default IUserIdProvider keys connections by the NameIdentifier claim. This codebase instead stamps a custom user_id claim on every JWT (see TokenService, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/TokenService.cs:81), so without a matching provider Clients.User(userId) would resolve zero connections and every targeted push would silently vanish.
  • Walkthrough: const string UserIdClaimType = "user_id" (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/ClaimBasedUserIdProvider.cs:11) keeps the claim name identical to the issuer's. GetUserId(HubConnectionContext connection) (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/ClaimBasedUserIdProvider.cs:14-15) returns connection.User?.FindFirst(UserIdClaimType)?.Value. The null-conditional chain means an unauthenticated connection (no principal, or no claim) yields null, and SignalR then treats the connection as having no user rather than throwing during connection setup.
  • Why it's built this way: sealed, one claim in and one nullable string out. Naming the claim in a const on the reader side, matching the literal on the writer side, is what keeps the issuer and the connection router from drifting apart.
  • -
  • Where it's used: registered as services.TryAddSingleton<IUserIdProvider, ClaimBasedUserIdProvider>() in Infrastructure DI (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:310); called by SignalR's connection manager on every server-initiated Clients.User(...).
  • - -
    -

    IAuthUser

    -
    -

    MMCA.Common.Domain · MMCA.Common.Domain.Auth · MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:10 · Level 0 · interface

    -
    -
      -
    • What it is: the deliberately minimal credential and refresh-token surface an Identity module's User aggregate exposes to the shared AuthenticationServiceBase<TUser> workflow. It is the contract that lets the framework's authentication plumbing read password material and rotate refresh tokens without knowing anything app-specific about the user (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:3-9).
    • -
    • Depends on: nothing first-party; the BCL only (byte[], DateTime). Implemented by each app's User aggregate (see User).
    • -
    • Concept introduced: the inverted user contract. Rather than the shared auth workflow depending on a concrete User class, User implements a small interface the framework owns. Profile fields, roles, linked aggregates, and claim sources stay app-specific: the shared workflow reaches those only through per-app hooks (CreateAccessToken, CreateUser), never through this contract (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:5-8). [Rubric §1, SOLID] assesses interface segregation and dependency inversion, and this is a textbook case: the interface is exactly the credential surface and nothing more. [Rubric §11, Security] assesses credential and session handling, and here the password hash, its salt, and the refresh-token lifecycle are the entire contract, which makes the security-relevant surface of a User aggregate readable in one screen.
    • -
    • Walkthrough: read the six members in two groups.
        -
      • Password material: byte[] PasswordHash (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:14) and byte[] PasswordSalt (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:17), where the salt length is what selects the verify algorithm (see PasswordHasher). The scoped #pragma warning disable CA1819 (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:12, restored on :18) knowingly returns arrays, to mirror IPasswordHasher's byte[] shape and the EF-mapped varbinary columns rather than force a defensive copy on every read.
      • -
      • Refresh-token state: nullable string? RefreshToken (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:21) and DateTime? RefreshTokenExpiry (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:24), both null when the token was never issued or has been revoked. Two mutators carry the rotation and revocation rules: UpdateRefreshToken(string refreshToken, DateTime expiry) (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:27, BR-205) and RevokeRefreshToken() (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IAuthUser.cs:30, BR-206/216). Note that the state is read-only through properties and changed only through the two methods: the aggregate keeps control of the transition.
      • -
      -
    • -
    • Why it's built this way: keeping the contract in Domain and keeping it small is what makes the shared auth workflow reusable across Store and ADC (both User aggregates implement it) while each aggregate stays free to model everything else its own way. See ADR-004 for the dual-fetch/JWKS auth model this contract feeds and ADR-032 for the password-material policy.
    • -
    • Where it's used: it is the generic constraint on the shared login/refresh workflow, where TUser : AuditableAggregateRootEntity<UserIdentifierType>, IAuthUser (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:41), which calls UpdateRefreshToken on issue and rotation (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:168, :298) and RevokeRefreshToken on logout and revocation (:261, :282). It is also the base of IPasswordChangeableUser.
    • +
    • Where it's used: registered as services.TryAddSingleton<IUserIdProvider, ClaimBasedUserIdProvider>() in Infrastructure DI (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:561); called by SignalR's connection manager on every server-initiated Clients.User(...).

    IJwksProvider

    @@ -1828,7 +1998,7 @@

    IJwksProvider

  • Concept introduced: publishing a public key instead of sharing a secret. [Rubric §11, Security] assesses key management and blast radius, and [Rubric §7, Microservices Readiness] assesses whether a module can be lifted out without a rewrite. In an extracted-service topology, symmetric HS256 would require every service to hold the same secret, so any one compromised service can mint tokens for all of them. The asymmetric alternative (ADR-004) keeps the RSA private key inside the Identity service and publishes only the public key at a well-known URL; peers fetch it and validate signatures without ever being able to sign. IJwksProvider is how the Identity API obtains that public key set to serve.
  • Walkthrough: a single synchronous member, JsonWebKeySet GetJsonWebKeySet() (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/IJwksProvider.cs:19). Synchronous is the deliberate shape because key material is resolved once and cached in-process by the implementation. The doc comment sets a contract that the implementation must honor: return an empty key set rather than throwing when no signing key is configured (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/IJwksProvider.cs:13-18), so /.well-known/jwks.json stays a valid, pollable URL even in a host where JWKS publishing is off.
  • Why it's built this way: an interface here lets tests inject a pre-built key set with no file I/O, and the empty-set contract makes the endpoint safe to map unconditionally instead of behind a feature check.
  • -
  • Where it's used: registered as services.TryAddSingleton<IJwksProvider, RsaJwksProvider>() (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:141) next to the JwksSettings options binding (:137-140); the JWKS minimal-API endpoint calls it, and consuming services fetch the resulting document through AddForwardedJwtBearer at startup.
  • +
  • Where it's used: registered as services.TryAddSingleton<IJwksProvider, RsaJwksProvider>() (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:154) immediately after the JwksSettings options binding (:150-153); the JWKS minimal-API endpoint calls it, and consuming services fetch the resulting document through AddForwardedJwtBearer at startup.

  • LoginProtectionSettings

    @@ -1844,8 +2014,27 @@

    LoginProtectionSettings

  • Registration rate limiting: MaxRegistrationsPerIpPerHour (default 10, [Range(1, 10000)], MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionSettings.cs:36-37) and RegistrationRateLimitWindowMinutes (default 60, [Range(1, 1440)], :42-43).
  • -
  • Why it's built this way: sealed with init-only properties gives an immutable options object. Every property carries a [Range], and the registration wires .ValidateDataAnnotations().ValidateOnStart() (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:126-129), so an obviously unsafe value such as MaxFailedAttempts = 0 fails the host at startup instead of quietly disabling lockout until someone notices in production. The MaxLockoutSeconds upper bound of 3600 is also what lets LoginProtectionService reason about its shift-clamp safely.
  • -
  • Where it's used: bound and validated in AddInfrastructure (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:126-129) immediately before LoginProtectionService is registered (:130).
  • +
  • Why it's built this way: sealed with init-only properties gives an immutable options object. Every property carries a [Range], and the registration wires .ValidateDataAnnotations().ValidateOnStart() (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:133-136), so an obviously unsafe value such as MaxFailedAttempts = 0 fails the host at startup instead of quietly disabling lockout until someone notices in production. The MaxLockoutSeconds upper bound of 3600 is also what lets LoginProtectionService reason about its shift-clamp safely.
  • +
  • Where it's used: bound and validated in AddInfrastructure (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:133-136) immediately before LoginProtectionService is registered (:137). Its sibling PasswordResetSettings is bound in exactly the same shape three lines later (:139-142).
  • + +
    +

    PasswordResetEntry

    +
    +

    MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Auth · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:171 · Level 0 · record

    +
    +
      +
    • What it is: the cached reset record behind the forgot-password flow: what PasswordResetTokenService writes into the cache when a reset token is issued, and reads back when one is redeemed. It is internal sealed, declared as a second type at the bottom of its service's file (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:171-175).
    • +
    • Depends on: nothing first-party except the UserIdentifierType alias (the per-module global using identifier alias taught in the primer, ADR-048). Four positional parameters, all BCL primitives.
    • +
    • Concept introduced: a cache DTO is constrained by its serializer, not by your domain. [Rubric §8, Data Architecture] assesses whether each store is given a shape it can actually round-trip, and this four-line record is a compact lesson in that. The XML comment states the rule directly (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:162-166): the cache round-trips values through System.Text.Json, so every member is a JSON primitive. A value object such as Email or a raw byte[] here would not survive a distributed backing store, which is why the token digest is carried as Base64 text (:172) and the expiry as Unix seconds (:175) rather than as byte[] and DateTimeOffset. [Rubric §11, Security] also applies through one member name: TokenHashBase64, not Token. The record is structurally incapable of holding the secret it guards.
    • +
    • Walkthrough: four members, in the order they matter.
        +
      • string TokenHashBase64 (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:172): Base64 of the SHA-256 of the issued token, never the token itself (:167). Validation re-hashes the presented token and compares digests, so the cache never holds redeemable material.
      • +
      • UserIdentifierType UserId (:173): the account the token redeems to (:168). Storing the id in the record is what lets redemption resolve the user without a second lookup by email.
      • +
      • int FailedAttempts (:174): wrong tokens presented against this record so far (:169), the counter the attempt cap is enforced against.
      • +
      • long ExpiresAtUnixSeconds (:175): when the record expires (:170). This one exists for a specific reason explained at the rewrite site: when a failed attempt bumps the counter, the record is re-cached with the remaining lifetime computed from this field, so a wrong guess cannot extend how long the token stays redeemable (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:138, :146-152).
      • +
      +
    • +
    • Why it's built this way: being a record gives the non-destructive with expression that the attempt counter update relies on (entry with { FailedAttempts = attempts }, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:150), so the rewrite is a copy rather than a mutation. Being internal keeps a cache-layout detail out of the package's public API: nothing outside the Infrastructure assembly should be able to construct or read one. See ADR-091 for why the reset lifecycle lives in the cache at all.
    • +
    • Where it's used: written by IssueAsync (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:82-88), read by ValidateAndConsumeAsync (:100), and rewritten by RecordFailedAttemptAsync (:148-152). It appears nowhere else.

    PasswordHasher

    @@ -1864,7 +2053,7 @@

    PasswordHasher

  • Why it's built this way: verification stays backward-compatible with pre-existing HMAC hashes so a deployment can migrate lazily, while every write is PBKDF2, so the stored population converges on the strong format as users log in and change passwords, with no data migration and no downtime. FixedTimeEquals and the 600k iteration count are the concrete OWASP-aligned defenses; ADR-032 records the policy.
  • -
  • Where it's used: registered services.TryAddSingleton<IPasswordHasher, PasswordHasher>() (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:224); called by the shared change-password workflow (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:55 verify, :61 re-hash) and by each Identity module's register and login handlers, against the PasswordHash/PasswordSalt exposed by IAuthUser.
  • +
  • Where it's used: registered services.TryAddSingleton<IPasswordHasher, PasswordHasher>() (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:475); called by the shared change-password workflow (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:55 verify, :61 re-hash), by the forgot-password reset workflow (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:79, which hashes but never verifies: possession of the reset token replaces knowledge of the old password), and by each Identity module's register and login handlers, against the PasswordHash/PasswordSalt exposed by IAuthUser.

  • RsaJwksProvider

    @@ -1882,7 +2071,7 @@

    RsaJwksProvider

  • ResolvePem(JwksSettings settings) (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/RsaJwksProvider.cs:58) prefers the inline RsaPublicKeyPem (:60-63) and otherwise reads RsaPublicKeyPath from disk with a synchronous File.ReadAllText (:70), justified in the comment because the read happens on the first request and its success is cached, while a failure is deliberately not cached (:67-69).
  • -
  • Why it's built this way: exporting only the public parameters guarantees the private key can never reach the JWKS document even by accident. The inline-PEM-or-path pair supports both secrets-manager injection (env var or config) and a volume-mounted key file, which are the two deployment shapes the framework's samples use. sealed, and registered singleton (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:141) so the cache is process-wide.
  • +
  • Why it's built this way: exporting only the public parameters guarantees the private key can never reach the JWKS document even by accident. The inline-PEM-or-path pair supports both secrets-manager injection (env var or config) and a volume-mounted key file, which are the two deployment shapes the framework's samples use. sealed, and registered singleton (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:154) so the cache is process-wide.
  • Where it's used: the JWKS minimal-API endpoint calls GetJsonWebKeySet() per request; see JwksEndpointExtensions.

  • @@ -1905,49 +2094,10 @@

    TokenService

  • BuildHmacCredentials (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/TokenService.cs:166) throws InvalidOperationException when SecretForKey is missing (:168-172) and Base64-decodes it into a SymmetricSecurityKey (:175). BuildRsaCredentials (:180) throws when RsaPrivateKeyPem is missing (:183-187), imports the private key, and then resolves a validation key: the configured RsaPublicKeyPem when present, otherwise the public parameters derived from the private key (:196-210), so an issuer configured with only a private key can still self-validate its own tokens during refresh. Both nested try/catch blocks dispose the partially-created RSA before rethrowing (:215-225), so a bad PEM does not leak a native key handle. Missing key material therefore fails at construction, meaning at host startup, not on the first login.
  • -
  • Why it's built this way: see ADR-004 for the RS256 rationale. The DI lifetime is worth reading in full at the registration site (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:217-223): the service is TryAddSingleton because a scoped lifetime disposed the underlying RSA at end-of-request while Microsoft.IdentityModel.Tokens' static CryptoProviderCache still held the cached AsymmetricSignatureProvider wrapping it, throwing ObjectDisposedException on the next RS256 sign. Singleton is safe because the constructor depends only on the singleton IJwtSettings and the service is stateless afterwards.
  • +
  • Why it's built this way: see ADR-004 for the RS256 rationale. The DI lifetime is worth reading in full at the registration site (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:468-474): the service is TryAddSingleton because a scoped lifetime disposed the underlying RSA at end-of-request while Microsoft.IdentityModel.Tokens' static CryptoProviderCache still held the cached AsymmetricSignatureProvider wrapping it, throwing ObjectDisposedException on the next RS256 sign. Singleton is safe because the constructor depends only on the singleton IJwtSettings and the service is stateless afterwards.
  • Where it's used: the shared AuthenticationServiceBase<TUser> login/refresh flow and each Identity module's auth handlers. The user_id claim it emits is exactly what CurrentUserService and ClaimBasedUserIdProvider read back.

  • -

    IPasswordChangeableUser

    -
    -

    MMCA.Common.Domain · MMCA.Common.Domain.Auth · MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IPasswordChangeableUser.cs:11 · Level 3 · interface

    -
    -
      -
    • What it is: the password-rotation surface an Identity module's User aggregate exposes to the shared ChangePasswordHandlerBase<TUser, TCommand> workflow. It is one method on top of IAuthUser (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IPasswordChangeableUser.cs:5-10).
    • -
    • Depends on: IAuthUser (its base interface) and Result from MMCA.Common.Shared.Abstractions (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IPasswordChangeableUser.cs:1).
    • -
    • Concept: capability interfaces layered by workflow. [Rubric §1, SOLID] assesses interface segregation, and this is the pattern applied twice over: a User that only ever authenticates implements IAuthUser; a User whose app offers self-service password change implements this one and gets PasswordHash/PasswordSalt along with it, because the workflow must verify the current credential before writing the new one (the XML comment states exactly this reason, MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IPasswordChangeableUser.cs:8-9). Inheritance here encodes a real dependency between capabilities rather than a taxonomy. [Rubric §4, DDD] also applies: the method returns Result, so the aggregate can refuse the change (an invariant failure) instead of the handler assuming success.
    • -
    • Walkthrough: one member, Result ChangePassword(byte[] newPasswordHash, byte[] newPasswordSalt) (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IPasswordChangeableUser.cs:19). The aggregate receives already-hashed material, never a plaintext password: hashing is the handler's job via IPasswordHasher, so no plaintext ever reaches the Domain layer or an EF change tracker.
    • -
    • Why it's built this way: keeping the hash-and-salt pair as the parameter shape mirrors IAuthUser's two properties and IPasswordHasher's tuple return, so the whole chain from handler to aggregate speaks one vocabulary. See ADR-032.
    • -
    • Where it's used: as the generic constraint where TUser : AuditableAggregateRootEntity<UserIdentifierType>, IPasswordChangeableUser on the shared change-password workflow (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:28), which verifies the current password (:55), hashes the new one (:61), and calls ChangePassword with the result (:62).
    • -
    -
    -

    IUserPreferences

    -
    -

    MMCA.Common.Domain · MMCA.Common.Domain.Auth · MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IUserPreferences.cs:10 · Level 3 · interface

    -
    -
      -
    • What it is: the stored UI-preference surface an Identity module's User aggregate exposes to the shared preference read and write workflows: preferred culture, preferred theme, and a single method that replaces both (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IUserPreferences.cs:5-9).
    • -
    • Depends on: Result from MMCA.Common.Shared.Abstractions (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IUserPreferences.cs:1). Nothing else; it is deliberately not tied to IAuthUser, because preferences are orthogonal to credentials.
    • -
    • Concept: null as "not chosen". [Rubric §27, i18n] assesses whether locale is a first-class, persisted user choice rather than a per-session guess, and [Rubric §19, State Management] assesses where such UI state lives. Both properties are nullable, and the contract states that null means the user has not chosen that preference (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IUserPreferences.cs:7-8), which is what lets the UI fall back to a browser or host default without needing a separate "is set" flag. See ADR-027 for the culture model and ADR-028 for the theme model.
    • -
    • Walkthrough: string? PreferredCulture (for example "es", MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IUserPreferences.cs:13) and string? PreferredTheme ("light"/"dark", :16) are read-only. Result UpdatePreferences(string? preferredCulture, string? preferredTheme) (:25) replaces both at once. The subtlety is documented on :18-21: because the method is a whole-object replace, the shared workflow always passes the currently stored value for any field the request left null, so writing one preference never silently clears the other. You can see that read-then-merge in the caller: user.UpdatePreferences(command.Request.Culture ?? user.PreferredCulture, command.Request.Theme ?? user.PreferredTheme) (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:53-55).
    • -
    • Why it's built this way: one replace method keeps the aggregate's invariant check in a single place, and pushing the merge into the workflow keeps the null-means-unchanged policy out of every app's User. Returning Result lets the aggregate reject an unsupported culture or theme value.
    • -
    • Where it's used: the read workflow constrains where TUser : AuditableBaseEntity<UserIdentifierType>, IUserPreferences and projects both properties into a response (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandlerBase.cs:23, :44); the write workflow constrains where TUser : AuditableAggregateRootEntity<UserIdentifierType>, IUserPreferences (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:26). Both are cross-linked as GetUserPreferencesHandlerBase<TUser> and ChangePreferencesHandlerBase<TUser, TCommand>.
    • -
    -
    -

    IErasableUser

    -
    -

    MMCA.Common.Domain · MMCA.Common.Domain.Auth · MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IErasableUser.cs:30 · Level 4 · interface

    -
    -
      -
    • What it is: the erasure surface an Identity module's User aggregate exposes to the shared DeleteUserHandlerBase<TUser, TCommand> workflow: soft-delete the row, then irreversibly anonymize the personal data it still holds (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IErasableUser.cs:6-10).
    • -
    • Depends on: IAnonymizable (its base, contributing Result Anonymize()) and Result (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IErasableUser.cs:1-2).
    • -
    • Concept introduced: why a Delete() that already exists on the base entity is redeclared here. This is the most instructive comment in the file and it is worth reading in full (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IErasableUser.cs:11-29). AuditableBaseEntity<TIdentifierType> already has a Delete(). But an app's User may hide it (public new Result Delete()) to couple account-specific behavior to deletion, typically revoking the refresh token so outstanding sessions die immediately. A hidden method is not an override. C# member lookup on a generic type parameter prefers the members of its class constraint, so a shared workflow writing user.Delete() would bind to the base implementation and silently skip the app's version. Redeclaring Delete() on this interface and invoking it through the interface forces interface dispatch, which resolves to the most derived member the app type maps onto IErasableUser. [Rubric §1, SOLID] (Liskov: the hidden method is exactly the substitutability hazard this closes) and [Rubric §15, Best Practices] both apply, and this is a case where the language rule, not a style preference, dictates the design. The second paragraph (:25-28) adds the compile-time guarantee: the base entity deliberately does not implement this interface, so a consumer that forgets to declare it fails the generic constraint at compile time rather than losing behavior at run time.
    • -
    • Walkthrough: one declared member, Result Delete() (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IErasableUser.cs:37), documented as soft-delete plus whatever the app couples to deletion (:32-35), returning a failure when the account is already deleted (:36). Inherited from IAnonymizable is Result Anonymize() (MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IAnonymizable.cs:30), which must be idempotent (:26-27). The two-step order is visible in the caller: cast once to the interface (IErasableUser erasable = user;, MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:88), erasable.Delete() first (:89), the app's own tail hook next (:96), then erasable.Anonymize() (:103), each short-circuiting on failure.
    • -
    • Why it's built this way: soft-delete alone hides a row but retains its personal data, so it does not satisfy an erasure request; anonymize-in-place overwrites the personal fields while keeping the row so foreign keys and the audit trail survive (ADR-005, and MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IAnonymizable.cs:10-20). Splitting the two into separate members lets the workflow run app-specific work between them. [Rubric §30, Compliance and Data Governance] assesses exactly this: a GDPR/CCPA erasure path that does not destroy referential integrity.
    • -
    • Where it's used: the generic constraint where TUser : AuditableAggregateRootEntity<UserIdentifierType>, IErasableUser on the shared delete-user workflow (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:41), implemented by each app's User aggregate.
    • -
    -

    LoginProtectionService

    MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Auth · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:19 · Level 5 · class

    @@ -1956,12 +2106,12 @@

    LoginProtectionService

  • What it is: the cache-backed brute-force and rate-limiting service: exponential-backoff account lockout after repeated login failures, plus a per-IP registration rate limit (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:9-18).
  • Depends on: ILoginProtectionService (the Application port); LoginProtectionSettings via IOptions<>; ICacheService; Result and Error; and the Email value object, used purely as a normalizer.
  • Concept introduced: counter keys must be normalized the same way the lookup is. [Rubric §11, Security] assesses brute-force protection and rate limiting; [Rubric §10, Cross-Cutting] assesses whether it is one shared service rather than logic copied per endpoint. Two mechanisms in this file deserve close reading.
      -
    • Key normalization (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:25-43): NormalizeIdentity runs the supplied address through Email.Create and uses the normalized value (:39-41). Without it, the counter keys are built from raw request input while the user lookup runs against the normalized value object, so User@x.com, user@x.com and " user@x.com " resolve to one account but get independent attempt counters, and an attacker defeats the ADR-029 backoff just by varying capitalization. A malformed address (which never matches a user but still increments a counter) falls back to the same trim-and-lowercase shape (:41) so its attempts collapse onto one key too. The #pragma warning disable CA1308 (:40) is scoped and justified: lowercase is the RFC 5321 normalization Email itself performs.
    • +
    • Key normalization (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:25-43): NormalizeIdentity runs the supplied address through Email.Create and uses the normalized value (:39-41). Without it, the counter keys are built from raw request input while the user lookup runs against the normalized value object, so User@x.com, user@x.com and " user@x.com " resolve to one account but get independent attempt counters, and an attacker defeats the ADR-029 backoff just by varying capitalization. A malformed address (which never matches a user but still increments a counter) falls back to the same trim-and-lowercase shape (:41) so its attempts collapse onto one key too. The #pragma warning disable CA1308 (:40) is scoped and justified: lowercase is the RFC 5321 normalization Email itself performs. PasswordResetTokenService copies this helper verbatim and cites this type as the reason (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:34-38).
    • The lockout curve (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:80-89): excessAttempts = newCount - MaxFailedAttempts (:82) drives lockoutSeconds = Math.Min(1 << Math.Min(excessAttempts, 30), MaxLockoutSeconds) (:88), doubling the lockout per excess failure (1s, 2s, 4s, and so on) up to the configured cap. The inner Math.Min(excessAttempts, 30) clamps the shift exponent, and the comment explains why (:84-87): C# masks an int shift count to five bits, so 1 << 31 is negative and 1 << 32 wraps back to 1, which would silently shrink the lockout for a sufficiently persistent attacker. Since 1 << 30 already exceeds the [Range(1, 3600)] cap on LoginProtectionSettings.MaxLockoutSeconds, deep excess always lands on the cap.
  • Walkthrough
      -
    • Key builders: LockoutKey -> login:lockout:{normalized} (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:45), AttemptsKey -> login:attempts:{normalized} (:47), RegistrationKey -> registration:ip:{ipAddress} (:136).
    • +
    • Key builders: LockoutKey produces login:lockout:{normalized} (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:45), AttemptsKey produces login:attempts:{normalized} (:47), RegistrationKey produces registration:ip:{ipAddress} (:136).
    • CheckLockoutAsync(string email, CancellationToken) (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:50): reads the boolean lockout key (:53) and returns Error.Unauthorized("Auth.TooManyAttempts", ...) when set, otherwise Result.Success() (:55-60). A cache miss is treated as not locked out (?? false), so a cache outage fails open on lockout rather than locking everyone out.
    • IncrementFailedAttemptsAsync (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:64): increments the attempts key with the FailedAttemptWindowMinutes TTL (:75-78), and once the count reaches MaxFailedAttempts writes the lockout key with the exponential TTL (:80-90).
    • ResetFailedAttemptsAsync (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:94): removes both keys on a successful login (:96-97).
    • @@ -1969,10 +2119,38 @@

      LoginProtectionService

    • IncrementRegistrationCountAsync (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:120): no-ops on a missing IP (:122-125) and otherwise increments the per-IP counter with the RegistrationRateLimitWindowMinutes TTL (:130-133). The comment (:127-129) notes the TTL is refreshed on every write, so the window slides rather than staying anchored to the first registration, which only ever tightens the limit.
  • -
  • Why it's built this way: reusing ICacheService (Redis in production, in-memory fallback) instead of a bespoke store keeps the service thin and lets counters expire naturally by TTL rather than needing a sweep job; IOptions<> keeps every threshold configurable per environment. Registered scoped (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:130).
  • +
  • Why it's built this way: reusing ICacheService (Redis in production, in-memory fallback) instead of a bespoke store keeps the service thin and lets counters expire naturally by TTL rather than needing a sweep job; IOptions<> keeps every threshold configurable per environment. Registered scoped (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:137).
  • Caveats / not-in-source: the increment is documented in source as not atomic on the distributed cache today (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:66-74). DistributedCacheService.IncrementAsync is a read-modify-write, because the Redis INCR it used to issue wrote a plain string key while IDistributedCache reads entries back as hashes, and the mismatch made the counter unreadable (WRONGTYPE). The accepted cost: genuinely parallel attempts can overwrite each other's increments, so a concurrent burst can stay under MaxFailedAttempts. Sequential guessing, which is what a credential-stuffing run against one account looks like, still trips the lockout. The comment names the two ways to close the gap (a Lua script that increments within the hash layout, or moving counters off IDistributedCache); neither is implemented today.

  • +

    PasswordResetTokenService

    +
    +

    MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Auth · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:26 · Level 5 · class

    +
    +
      +
    • What it is: the IPasswordResetTokenService implementation, and the whole forgot-password token lifecycle in one file: issue a single-use token for an address, throttle how often one address can ask, hash the token at rest, cap wrong guesses, and consume the token on a successful redeem (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:12-25).
    • +
    • Depends on: IPasswordResetTokenService (the Application port); PasswordResetSettings via IOptions<>; ICacheService; PasswordResetEntry (its cached record); Result and Error; the Email value object as a normalizer; and from the BCL SHA256, RandomNumberGenerator, CryptographicOperations, and System.Buffers.Text.Base64Url.
    • +
    • Concept introduced: a reset token is a bearer credential, so treat it like a password. [Rubric §11, Security] assesses credential issuance and redemption; [Rubric §8, Data Architecture] assesses picking the right store for the right lifetime. Four properties are designed in, and the class doc lists all four up front (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:15-24).
        +
      • Hashed at rest. Only SHA256.HashData(...) of the token is stored (:55-56, :83), so a cache dump does not hand out working reset links. Unlike a password, a reset token is high-entropy (32 random bytes, :30, :79) and short-lived, which is why a plain digest is sufficient here where PasswordHasher needs 600,000 PBKDF2 iterations: there is no dictionary to run against a 256-bit random value.
      • +
      • One active token per email. The key is derived purely from the address (:51), so SetAsync overwrites (:88) and an older link stops working the moment a newer one is requested.
      • +
      • Attempt cap. Wrong tokens are counted on the record and the record is discarded at MaxValidationAttempts (:140-144), which turns the token into a credential you cannot grind at.
      • +
      • No schema change, no sweeper. The whole lifecycle rides ICacheService, so expiry is the cache TTL rather than a background job over a table (ADR-091). Compare LoginProtectionService, which reaches the same conclusion for lockout counters.
      • +
      +
    • +
    • Walkthrough
        +
      • Primary constructor takes ICacheService cacheService and IOptions<PasswordResetSettings> settings (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:26-28), snapshotting settings.Value into _settings (:32). TokenByteLength = 32 (:30) is the only other constant.
      • +
      • NormalizeIdentity (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:40-49) is the same Email.Create-then-fallback shape as LoginProtectionService, and its doc comment cites that type as the reason (:34-38): keys built from raw request input would give User@x.com and user@x.com independent tokens and independent request counters while resolving to one account. Two key builders follow: TokenKey produces pwdreset:token:{normalized} (:51) and RequestKey produces pwdreset:req:{normalized} (:53). HashToken is the shared SHA-256 helper (:55-56).
      • +
      • IssueAsync(string email, UserIdentifierType userId, CancellationToken) (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:59): throttle first. It increments the per-email request counter with the RequestWindowMinutes TTL (:66-69) and fails with Error.Unauthorized("Auth.ResetThrottled", ...) once the count exceeds MaxRequestsPerEmail (:71-77). Only then does it mint the token: 32 CSPRNG bytes rendered with Base64Url.EncodeToString (:79, URL-safe because the token travels in a query string), builds a PasswordResetEntry holding the Base64 digest, the user id, a zero attempt count and the absolute expiry as Unix seconds (:82-86), caches it under the token key with the configured lifetime (:88), and returns the raw token to the caller to email (:90). The raw token exists only in that return value: it is never written anywhere.
      • +
      • ValidateAndConsumeAsync(string email, string token, CancellationToken) (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:94): loads the entry (:100) and returns InvalidToken() when there is none (:101-104). A FormatException decoding the stored Base64 removes the unreadable record rather than leaving it to expire (:107-116). The comparison is CryptographicOperations.FixedTimeEquals over the two digests (:118), the same timing-side-channel defense PasswordHasher uses, with token ?? string.Empty so a null token hashes rather than throwing. A mismatch records a failed attempt and returns the same generic failure (:120-121). On a match it removes both the token key and the address's request counter (:126-127), so a successful reset does not leave the user throttled out of a later legitimate request (:124-125), and returns the entry's UserId (:129).
      • +
      • RecordFailedAttemptAsync (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:132): computes attempts = entry.FailedAttempts + 1 and the remaining lifetime from ExpiresAtUnixSeconds (:137-138). At MaxValidationAttempts, or once the remaining lifetime is non-positive, it deletes the record (:140-144). Otherwise it rewrites the entry with entry with { FailedAttempts = attempts } and a TTL of the remaining seconds, not a fresh lifetime (:146-152), because a wrong guess must not be able to extend how long the token stays redeemable.
      • +
      • InvalidToken() (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:155-159) is the single failure factory: unknown, expired, mismatched and attempt-capped all collapse to one Auth.InvalidResetToken error with one message. That uniformity is deliberate: distinct errors would make the endpoint an oracle for which addresses have an outstanding reset.
      • +
      +
    • +
    • Why it's built this way: ADR-091 records the decision. It extends ADR-029 (the cache-backed protection idiom reused here) and sits beside ADR-032, which decided how a password is stored but not how a user who has lost one gets a new one. Keeping the token out of the database is what makes the feature additive: no migration, no new table, and nothing to reap.
    • +
    • Where it's used: registered services.TryAddScoped<IPasswordResetTokenService, PasswordResetTokenService>() (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:143), directly after the PasswordResetSettings binding (:139-142). ForgotPasswordHandlerBase<TUser, TCommand> calls IssueAsync and emails the resulting link (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:72); ResetPasswordHandlerBase<TUser, TCommand> calls ValidateAndConsumeAsync (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:61-63) before the save, because leaving the token live until the write succeeds would open a replay window (:58-60). It is unit-tested by PasswordResetTokenServiceTests.
    • +
    • Caveats / not-in-source: the per-email request throttle inherits LoginProtectionService's non-atomic increment, and the source says so where it matters (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:64-65): concurrent requests can undercount, which loosens the throttle but never tightens it. The failed-attempt rewrite is a read-modify-write too, so a burst of simultaneous wrong guesses can lose increments against the attempt cap; sequential guessing still trips it.
    • +
    +

    CurrentUserService

    MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Services · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/CurrentUserService.cs:13 · Level 9 · class

    @@ -1980,7 +2158,7 @@

    CurrentUserService

    • What it is: the scoped, per-request implementation of ICurrentUserService. It extracts the current user's id, role, principal, and arbitrary typed claims from the JWT in the HTTP context (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/CurrentUserService.cs:8-12).
    • Depends on: ICurrentUserService; Microsoft.AspNetCore.Http.IHttpContextAccessor, System.Security.Claims, and System.Globalization (BCL). The user_id claim it reads is emitted by TokenService.
    • -
    • Concept introduced: scoped claim extraction with lazy per-request caching, parsed invariantly. [Rubric §11, Security] assesses correct claim extraction, [Rubric §12, Performance] the cost of doing it repeatedly, and [Rubric §27, i18n] the culture trap. The service is registered scoped (one instance per request, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:216) and wraps _userId and _role in Lazy<T> (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/CurrentUserService.cs:18, :26), so HttpContext.User is walked at most once per request no matter how many handlers, filters, and SaveChangesAsync calls ask. The i18n point is the one most codebases get wrong: claims are machine-written by TokenService under CultureInfo.InvariantCulture, so they must be read invariantly too. Both int.TryParse for the user id (:23) and T.TryParse for generic claims (:45) pass CultureInfo.InvariantCulture explicitly, with comments explaining that parsing under the ambient request culture misreads separators for decimal, double and DateTime claim types (:21-22, :43-44). Reading the custom user_id claim type (:16) rather than the standard sub keeps the claim contract with TokenService explicit.
    • +
    • Concept introduced: scoped claim extraction with lazy per-request caching, parsed invariantly. [Rubric §11, Security] assesses correct claim extraction, [Rubric §12, Performance] the cost of doing it repeatedly, and [Rubric §27, i18n] the culture trap. The service is registered scoped (one instance per request, MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:467) and wraps _userId and _role in Lazy<T> (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/CurrentUserService.cs:18, :26), so HttpContext.User is walked at most once per request no matter how many handlers, filters, and SaveChangesAsync calls ask. The i18n point is the one most codebases get wrong: claims are machine-written by TokenService under CultureInfo.InvariantCulture, so they must be read invariantly too. Both int.TryParse for the user id (:23) and T.TryParse for generic claims (:45) pass CultureInfo.InvariantCulture explicitly, with comments explaining that parsing under the ambient request culture misreads separators for decimal, double and DateTime claim types (:21-22, :43-44). Reading the custom user_id claim type (:16) rather than the standard sub keeps the claim contract with TokenService explicit.
    • Walkthrough
      • Primary constructor takes IHttpContextAccessor httpContextAccessor (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/CurrentUserService.cs:13), captured directly by the lazy initializers.
      • User (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/CurrentUserService.cs:30): returns the ClaimsPrincipal, or a fresh empty one when there is no HTTP context, so background jobs and hosted services resolving the same interface get an anonymous principal instead of a NullReferenceException.
      • @@ -2029,11 +2207,12 @@

        AuthClaimTypes

        (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionAuthorizationHandler.cs:29-30), and described (without being named) in the HasPermissionAttribute doc comment as the "explicit permission claim" alternative to the role-derived path - (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/HasPermissionAttribute.cs:6-10). + (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/HasPermissionAttribute.cs:5-11).
      • Caveats / not-in-source: no shipped token issuer in this repo writes a permission claim. Across - both applications and the framework there are exactly four references to the constant - (its declaration, the handler's doc comment, the handler's check, and one test), and the only - writer in the tree is a test that hands the claim to a principal directly + both applications and the framework there are exactly four references to the constant (its + declaration, the handler's doc comment at PermissionAuthorizationHandler.cs:9, the handler's + check at :29, and one test), and the only writer in the tree is a test that hands the claim to a + principal directly (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Authorization/PermissionAuthorizationHandlerTests.cs:30). The claim path is real and covered, but every deployed grant today flows through roles.
      @@ -2061,15 +2240,14 @@

      AuthenticationResponse

      OAuthControllerBase therefore detects a missing exchange entry by testing string.IsNullOrEmpty(response.AccessToken), with the reason written down at the call site - (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:149-155).
    • + (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:151-154).
    • Where it's used: produced by AuthenticationServiceBase<TUser> at the end of registration - (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:211-214) and - from the shared IssueTokensAsync helper that login, refresh, and app-level external-login flows - all funnel through (AuthenticationServiceBase.cs:292,302-305); declared as the 200/201 response - type on the three AuthControllerBase - endpoints - (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:56,77,97); + (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:211) and from + the shared IssueTokensAsync helper that login, refresh, and app-level external-login flows all + funnel through (AuthenticationServiceBase.cs:292,302); declared as the 200/201 response type on + the three AuthControllerBase endpoints + (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:58,80,101); consumed by AuthUIService, DirectApiTokenRefresher, and CookieSessionRefresher.
    • @@ -2090,15 +2268,15 @@

      ChangePasswordRequest

      ChangePasswordRequestValidator), which is what lets Store and ADC differ on policy while sharing the contract.
    • Walkthrough: two positional parameters (ChangePasswordRequest.cs:8-10); no body.
    • -
    • Where it's used: bound as the body of the shared PUT change-password endpoint on +
    • Where it's used: bound as the body of the shared PUT password endpoint on UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand> - (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:100), + (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:86,92), carried by each app's ChangePasswordCommand - (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordCommand.cs + (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordCommand.cs:14 and its Store twin), and validated by ADC's MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/Validation/ChangePasswordRequestValidator.cs:11, - which requires a non-empty CurrentPassword (:15-16) and applies the shared - StrongPasswordRules to NewPassword (:18).
    • + which requires a non-empty CurrentPassword (:15-16) and includes the shared + StrongPasswordRules<T> for NewPassword (:18).
    • Caveats / not-in-source: nothing in this type prevents the password strings from reaching a log. That is an operational convention (PII masking plus the "never log the body" habit), not a compile-time or runtime guarantee.
    • @@ -2119,11 +2297,11 @@

      ChangePreferencesRequest

      has a real bug hiding in it: the app-bar language switcher knows only the culture and the theme toggle knows only the theme, so whichever fires last would send null for the other field and silently erase the user's other choice. The doc comment states the rule that removes the bug - (ChangePreferencesRequest.cs:3-6): a null field leaves that preference unchanged, so each + (ChangePreferencesRequest.cs:3-7): a null field leaves that preference unchanged, so each control can persist its own field in isolation. The rule is honored in exactly one place, the shared handler's command.Request.Culture ?? user.PreferredCulture / command.Request.Theme ?? user.PreferredTheme coalesce - (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:53-55), + (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:54-55), which is why the contract can afford to be this terse. The two preferences themselves come from ADR-027 (culture) and ADR-028 (theme). @@ -2135,13 +2313,13 @@

      ChangePreferencesRequest

    • Why it's built this way: the payload record was byte-identical in both applications' Identity modules and was hoisted here, while the command record stayed app-side because ADC marks it ICacheInvalidating and Store does not. That split is spelled out in the handler base's remarks - (ChangePreferencesHandlerBase.cs:16-20), and it is a good illustration of the framework's hoisting + (ChangePreferencesHandlerBase.cs:16-22), and it is a good illustration of the framework's hoisting rule: share the shape, leave the per-app policy behind.
    • -
    • Where it's used: the body of the shared PUT auth/preferences endpoint - (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:112-119), +
    • Where it's used: the body of the shared PUT preferences endpoint + (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:112-118), which hands it to the app's command through the abstract CreateChangePreferencesCommand factory - (UserAccountAuthControllerBase.cs:77-79,125-127); the generic constraint that ties the two together - is where TChangePreferencesCommand : IUserScopedCommand<ChangePreferencesRequest> + (UserAccountAuthControllerBase.cs:77,126); the generic constraint that ties the two together is + where TChangePreferencesCommand : IUserScopedCommand<ChangePreferencesRequest> (UserAccountAuthControllerBase.cs:48). It is consumed by ChangePreferencesHandlerBase<TUser, TCommand> and carried by each app's @@ -2154,38 +2332,66 @@

      ChangePreferencesRequest

      Result the handler propagates. Note also that the Blazor UI does not send this exact type: ApiUserPreferenceWriter declares its own private UserPreferencesRequest(string? Culture, string? Theme) wire record - (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ApiUserPreferenceWriter.cs:29,64-65), so + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ApiUserPreferenceWriter.cs:29,65), so the two shapes agree by convention rather than by a shared reference.
    -

    IcsEvent

    -
    -

    MMCA.Common.Shared · MMCA.Common.Shared.Calendars · MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsEvent.cs:15 · Level 0 · record (sealed)

    -
    -
      -
    • What it is: one calendar entry consumed by IcsCalendarBuilder: a - positional sealed record carrying a stable UID, a title, start and end instants, and optional - description and location (MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsEvent.cs:15-21).
    • -
    • Depends on: nothing first-party; System.DateTimeOffset (BCL).
    • -
    • Concept introduced, UTC by contract. [Rubric §9, API & Contract Design] assesses whether a - contract is unambiguous about what the caller must supply. Unlike the auth siblings this is a - record (reference type), not a record struct, because it carries optional members and travels as - a collection. The load-bearing rule is in the doc comment (IcsEvent.cs:3-8): StartsAtUtc and - EndsAtUtc are UTC by contract, so converting a wall-clock time in the event's IANA time zone to - UTC is the caller's job. That single rule lets the builder emit Z-suffixed timestamps and skip - RFC 5545's error-prone VTIMEZONE machinery entirely, and it pushes the one genuinely hard problem - (daylight-saving transitions) to the one layer that knows the event's zone.
    • -
    • Walkthrough: six positional parameters (IcsEvent.cs:15-21): Uid (globally unique and stable, - which is how calendar apps de-duplicate a reimport instead of creating a second entry, documented at - IcsEvent.cs:9), Summary, StartsAtUtc, EndsAtUtc, and the two nullable optionals - Description = null and Location = null (IcsEvent.cs:20-21).
    • -
    • Where it's used: passed as an IReadOnlyCollection<IcsEvent> to - IcsCalendarBuilder's Build. In MMCA.ADC, - CalendarExportMapper converts a session - plus its event into one entry - (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/CalendarExportMapper.cs:31,37-43), - performing exactly the wall-clock-to-UTC conversion the contract demands in its own ToUtc helper, - with the DST discipline written out (invalid spring-forward times shift ahead one hour, ambiguous - fall-back times take the standard offset, CalendarExportMapper.cs:47-56).
    • +

      ForgotPasswordRequest

      +
      +

      MMCA.Common.Shared · MMCA.Common.Shared.Auth · MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ForgotPasswordRequest.cs:8 · Level 0 · record struct (readonly)

      +
      +
        +
      • What it is: a single-field request (string Email) that starts a password reset + (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ForgotPasswordRequest.cs:3-9).
      • +
      • Depends on: nothing first-party. It pairs with + ResetPasswordRequest, which completes the flow this one starts.
      • +
      • Concept introduced, the anti-enumeration contract. [Rubric §11, Security] assesses whether an + endpoint leaks facts an attacker can harvest, and [Rubric §9, API & Contract Design] assesses + whether a contract's shape matches the answer it is allowed to give. A password-reset entry point is + the classic account-enumeration oracle: if "no such user" answers differently from "email sent", an + attacker can test an address list against your user base for free. The doc comment on this one-field + record records the countermeasure as part of the contract (ForgotPasswordRequest.cs:3-6): the + response is always accepted, so the payload carries no signal about whether the address belongs to + an account. The rule is not aspirational, it is implemented in three coordinated places:
          +
        • the request validator checks only the shape of the address, and its doc comment says exactly + why it stops there, because a 400 on an unknown address would be the oracle the always-accepted + response exists to close + (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ForgotPasswordRequestValidator.cs:6-16);
        • +
        • the handler returns Result.Success() for a malformed address, an address with no account, a + throttled request, and a failed send alike, logging the real reason instead of returning it + (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:57-99);
        • +
        • the endpoint answers 202 Accepted on every well-formed request + (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:79,92).
        • +
        +
      • +
      • Walkthrough: one positional string Email (ForgotPasswordRequest.cs:8-9); no body, no + validation attributes, no normalization. Normalizing the address is the handler's job, through + Email.Create(command.Request.Email) (ForgotPasswordHandlerBase.cs:57), which is what lets the + DTO stay a raw wire shape while the value object owns the parsing rules.
      • +
      • Why it's built this way: + ADR-091 records the + cache-backed reset design this request opens. Keeping the payload to a single field means there is + nothing else for an attacker to probe, and keeping the "always accepted" promise in the type's doc + comment puts it where a reader meets it before the handler.
      • +
      • Where it's used: bound as the body of the anonymous, rate-limited POST forgot-password action + on + PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand> + (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:75-84), + which turns it into the app's command through an abstract factory (:61) constrained to + ICommandWithRequest<out TRequest> + (:46); shape-validated by + ForgotPasswordRequestValidator; handled by + ForgotPasswordHandlerBase<TUser, TCommand>; + posted by AuthUIService's + RequestPasswordResetAsync, deliberately over a bearer-free client so a signed-in caller does not + bind the reset to the current session + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthUIService.cs:289-293).
      • +
      • Caveats / not-in-source: only MMCA.ADC wires this vertical today. ADC has a + ForgotPasswordCommand + (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ForgotPassword/ForgotPasswordCommand.cs:12-13) + and a derived controller + (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:36); + MMCA.Store has no forgot-password command or controller in the tree, so the framework half ships + unused there.

      IPermissionRegistry

      @@ -2242,7 +2448,7 @@

      LoginRequest

      (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:73), which is reached through AuthControllerBase.LoginAsync - (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:59-60). + (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:61-65).

    OAuthCodeExchangeRequest

    @@ -2267,14 +2473,16 @@

    OAuthCodeExchangeRequest

    putting it in a URL acceptable. OAuthControllerBase.ExchangeAsync rejects a blank code - (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:142-145), + (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:144-147), looks the code up in ICacheService - (OAuthControllerBase.cs:151), and then removes it so a replayed code cannot mint a second token - pair (OAuthControllerBase.cs:157-158); an unknown, burned, or expired code all return the same - HTTP 400 with a deliberately non-specific message (OAuthControllerBase.cs:163-169). + (OAuthControllerBase.cs:149-153), and then removes it so a replayed code cannot mint a second + token pair (OAuthControllerBase.cs:159-160); an unknown, burned, or expired code all return the + same HTTP 400 with a deliberately non-specific message (OAuthControllerBase.cs:165-170). The + action is also marked [NonIdempotent] with the reason inline: replaying a stored response would + defeat the burn and let a leaked code mint the same tokens again (OAuthControllerBase.cs:137).
  • Where it's used: the body of the OAuth exchange endpoint - (OAuthControllerBase.cs:135-140), called by the UI's /auth/oauth-complete page after the - provider redirect lands (OAuthControllerBase.cs:125,128-131).
  • + (OAuthControllerBase.cs:136-142), called by the UI's /auth/oauth-complete page after the + provider redirect lands (OAuthControllerBase.cs:126,130-131).

    RefreshTokenRequest

    @@ -2297,9 +2505,60 @@

    RefreshTokenRequest

    AuthenticationServiceBase<TUser>.RefreshTokenAsync (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:218), which rejects an unreadable token or missing claims with an Auth.InvalidToken failure before it ever - looks at the refresh token (AuthenticationServiceBase.cs:233-241); exposed by + looks at the refresh token (AuthenticationServiceBase.cs:234,241); exposed by AuthControllerBase.RefreshAsync - (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:99-100). + (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:103-104). + +

    ResetPasswordRequest

    +
    +

    MMCA.Common.Shared · MMCA.Common.Shared.Auth · MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ResetPasswordRequest.cs:9 · Level 0 · record struct (readonly)

    +
    +
      +
    • What it is: (string Email, string Token, string NewPassword), the payload that completes a + password reset by redeeming the single-use token that + ForgotPasswordRequest caused to be mailed + (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ResetPasswordRequest.cs:3-12).
    • +
    • Depends on: nothing first-party; the readonly record struct shape from + AuthenticationResponse.
    • +
    • Concept, the three-field redemption payload and the single collapsed failure. [Rubric §11, Security]: the address is carried alongside the token so the server can verify that the token was + issued for that address rather than trusting the token in isolation, which is what the handler's + ValidateAndConsumeAsync(request.Email, request.Token, ...) call checks + (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:61-63). + The anti-enumeration discipline that governs the forgot half continues here in a different form: + an unknown, expired, mismatched or attempt-capped token and a vanished account all collapse to one + Auth.InvalidResetToken 401 with the same message, so the response distinguishes none of them + (ResetPasswordHandlerBase.cs:17-22,95-99). One ordering decision is worth internalizing: the token + is consumed before the save, and the comment says why (ResetPasswordHandlerBase.cs:58-60), + because leaving it live until the write succeeds would open a replay window in which the same token + is redeemed twice; a token burned by a later invariant failure costs the user one more reset + request, which is the cheaper failure.
    • +
    • Walkthrough: three positional parameters (ResetPasswordRequest.cs:9-12); no body. The doc + comment repeats the never-logged rule for NewPassword (ResetPasswordRequest.cs:8), the same + convention LoginRequest states.
    • +
    • Why it's built this way: the new password goes through the same + StrongPasswordRules<T> that registration and + change-password use, so a reset cannot become a way around the complexity policy + (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ResetPasswordRequestValidator.cs:7-23). + Reusing one rule set rather than restating it per endpoint is the reason the policy cannot drift. + ADR-091 covers the + token side.
    • +
    • Where it's used: bound as the body of the anonymous, rate-limited POST reset-password action + on + PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>, + which answers 204 on success (PasswordResetAuthControllerBase.cs:99-117); shape-validated by + ResetPasswordRequestValidator; handled by + ResetPasswordHandlerBase<TUser, TCommand>, + which hashes the new password, lets the aggregate apply its own invariants, saves, and then clears + the account's lockout so a user who reset because of a lockout is not still locked out + (ResetPasswordHandlerBase.cs:79-89); posted by + AuthUIService's ResetPasswordAsync + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthUIService.cs:316-319). ADC + carries it in a ResetPasswordCommand marked + ICacheInvalidating + (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordCommand.cs:14-15).
    • +
    • Caveats / not-in-source: as with the forgot half, MMCA.Store has no reset-password command or + controller in the tree; ADC is the only app that wires this vertical + (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:39).

    RoleNames

    @@ -2370,76 +2629,25 @@

    UserPreferencesResponse

    field-by-field (MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/GetUserPreferencesHandlerTests.cs:46,60).
  • Why it's built this way: like its request twin, the response record was byte-identical in both - applications' Identity modules and was hoisted into Shared, which is what let the read handler become - a shared base generic only in the User aggregate rather than in the query and the response too - (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandlerBase.cs:10-14).
  • + applications' Identity modules and was hoisted into Shared, which is what let the read side become a + shared base generic only in the User aggregate rather than in the query and the response too + (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandlerBase.cs:21).
  • Where it's used: produced by GetUserPreferencesHandlerBase<TUser> - from the aggregate's PreferredCulture/PreferredTheme - (GetUserPreferencesHandlerBase.cs:44), against a + from the aggregate's PreferredCulture/PreferredTheme (GetUserPreferencesHandlerBase.cs:44), + against a GetUserPreferencesQuery; declared - as the 200 response of the shared GET auth/preferences endpoint on + as the 200 response of the shared GET preferences endpoint on UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand> - (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:140-142).
  • + (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:138-140).
  • Caveats / not-in-source: the handler reads through GetReadRepository, not the write repository, and the remarks note this was a deliberate correction of a disagreement between the two app copies (ADC read, Store write), so Store gained a no-tracking read on adoption - (GetUserPreferencesHandlerBase.cs:15-19,39). As with the request twin, the Blazor client does not + (GetUserPreferencesHandlerBase.cs:16-20,39). As with the request twin, the Blazor client does not deserialize into this type: ApiUserPreferenceReader reads auth/preferences into its own UI-side UserPreferences record and falls back to an empty one for anonymous users or any error (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ApiUserPreferenceReader.cs:18,39-42).
  • -

    IcsCalendarBuilder

    -
    -

    MMCA.Common.Shared · MMCA.Common.Shared.Calendars · MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsCalendarBuilder.cs:12 · Level 1 · class (static)

    -
    -
      -
    • What it is: a dependency-free RFC 5545 iCalendar writer for "add to calendar" exports, turning a - product id and a collection of IcsEvents into a complete VCALENDAR string - (MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsCalendarBuilder.cs:6-12).
    • -
    • Depends on: IcsEvent; System.Text.StringBuilder and - System.Globalization.CultureInfo (BCL).
    • -
    • Concept introduced, the deliberately minimal, deterministic protocol writer. [Rubric §15, Best Practices & Code Quality] assesses focused, standards-correct implementations, and [Rubric §32, Dependency & Supply-Chain] assesses whether a dependency is worth its cost. Rather than pull in a - full iCalendar package, the builder implements exactly the RFC 5545 subset every calendar app - imports reliably: UTC-only timestamps (no VTIMEZONE), TEXT escaping, CRLF line endings, and 75-octet - line folding (IcsCalendarBuilder.cs:7-10). It is also deterministic: the caller supplies - dtStamp (IcsCalendarBuilder.cs:21-22), so identical inputs produce byte-identical output, which - is what makes the export cacheable and lets a test assert on the exact document.
    • -
    • Walkthrough: one public entry point and four private helpers, plus the MaxLineOctets = 75 - constant (IcsCalendarBuilder.cs:14). - Build(productId, events, dtStamp) (IcsCalendarBuilder.cs:22) guards its inputs - (IcsCalendarBuilder.cs:24-25), writes the calendar header (BEGIN:VCALENDAR, VERSION:2.0, - PRODID, CALSCALE:GREGORIAN, METHOD:PUBLISH, IcsCalendarBuilder.cs:28-32), appends each entry - in the collection's own order, and closes the document (IcsCalendarBuilder.cs:34-40). - AppendEvent (IcsCalendarBuilder.cs:43) writes a VEVENT block with UID, DTSTAMP, DTSTART, - DTEND, and SUMMARY (:45-50), then DESCRIPTION and LOCATION only when non-blank - (IcsCalendarBuilder.cs:52-60), so an absent optional produces no property line at all rather than - an empty one. FormatUtc (IcsCalendarBuilder.cs:65-66) renders an instant through - instant.UtcDateTime with the invariant culture, which is what turns the IcsEvent - UTC-by-contract rule into a literal Z-suffixed timestamp. EscapeText - (IcsCalendarBuilder.cs:69-76) applies RFC 5545 section 3.3.11 TEXT escaping, and the order - matters: backslash is escaped first (IcsCalendarBuilder.cs:71) so the escapes it later introduces - are not double-escaped, and all three newline forms collapse to a literal \n (:74-76). The - subtlest helper is AppendLine (IcsCalendarBuilder.cs:83): it folds content lines at 75 octets of - UTF-8, counting octets per character and treating a surrogate pair as one unit - (IcsCalendarBuilder.cs:89-90) so a fold can never split a multi-byte character, and it charges the - leading fold space against the continuation line's budget (IcsCalendarBuilder.cs:94-95).
    • -
    • Why it's built this way: a static, allocation-light writer with no external dependency keeps - MMCA.Common.Shared pure and therefore usable from Blazor WebAssembly, and pushing dtStamp to the - caller is the single choice that makes the output deterministic and testable.
    • -
    • Where it's used: MMCA.ADC's calendar exports: - ExportSessionCalendarHandler - for one session - (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportSessionCalendarHandler.cs:59-62) - and ExportEventCalendarHandler - for a whole event - (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportEventCalendarHandler.cs:54,61). - Both pass ADC's single PRODID constant - (.../ExportCalendar/CalendarExportMapper.cs:17).
    • -
    • Caveats / not-in-source: both ADC call sites pass DateTimeOffset.UtcNow as dtStamp - (ExportSessionCalendarHandler.cs:62, ExportEventCalendarHandler.cs:61), so the determinism the - builder offers is exercised by the tests rather than by production output.
    • -

    PermissionRegistry

    MMCA.Common.Shared · MMCA.Common.Shared.Auth · MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/PermissionRegistry.cs:10 · Level 1 · class (sealed)

    @@ -2514,7 +2722,7 @@

    PermissionRegistryBuilder

    first resolve, after every module has contributed (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:65-81); modules reach it through AddPermissions(...), which is deliberately safe to call once per module - (AuthorizationExtensions.cs:47-62), as MMCA.ADC's Conference, Engagement, and Identity modules + (AuthorizationExtensions.cs:48-62), as MMCA.ADC's Conference, Engagement, and Identity modules each do (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/DependencyInjection.cs:41-51, MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/DependencyInjection.cs:51-54, @@ -2543,25 +2751,30 @@

    RoleValue

    deliberately does not implement IEquatable<T> (RoleValue.cs:17-23): the remarks cite Sonar S4035, an unsealed IEquatable<T> breaks the equality contract for subclasses. Instead equality is the object.Equals override, type-guarded so two roles are equal only when they are the same - concrete type with the same case-insensitive value (RoleValue.cs:78-81), and a sealed derived type + concrete type with the same case-insensitive value (RoleValue.cs:90-93), and a sealed derived type may safely add a strongly-typed IEquatable<TSelf> plus ==/!= on top, which ADC's UserRole - does (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/UserRole.cs:17,78-84). It - lives in MMCA.Common.Shared so it stays dependency-free and usable from Blazor WebAssembly as well - as Domain, with each app deriving a concrete role type that fixes its own role set + does (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/UserRole.cs:17,78). It lives + in MMCA.Common.Shared so it stays dependency-free and usable from Blazor WebAssembly as well as + Domain, with each app deriving a concrete role type that fixes its own role set (RoleValue.cs:11-16).
  • Walkthrough: a get-only Value (RoleValue.cs:28) set by the protected constructor (RoleValue.cs:32). The static Validate(role, knownRoles, source) (RoleValue.cs:42) returns Result.Success() when the role is in the app's known set, otherwise a Result failure carrying an Error of kind Invariant coded User.Role.Invalid - (RoleValue.cs:46-52); note the role ?? string.Empty coalesce (RoleValue.cs:46), which turns a - null role into a clean failure rather than a NullReferenceException. The protected generic - BuildLookup<TRole>(params roles) (RoleValue.cs:63) freezes the supplied singletons into a - case-insensitive FrozenDictionary keyed by Value (RoleValue.cs:68-71), so a derived type can - back its FromString/IsValid members with interned instances instead of re-allocating. ToString - returns the value (RoleValue.cs:75), and GetHashCode uses the ordinal-ignore-case hash - (RoleValue.cs:84) so it stays consistent with Equals, which is the contract a dictionary key - depends on.
  • + (RoleValue.cs:46-52). The membership test itself is the private IsKnown + (RoleValue.cs:63-65), and it is more careful than it first looks: the fast path is the supplied + set's own Contains (correct and O(1) for the intended OrdinalIgnoreCase sets, with a + role ?? string.Empty coalesce so a null role becomes a clean failure rather than a + NullReferenceException), and a miss falls back to an explicit case-insensitive scan so that a set + built with the default ordinal comparer still validates case-insensitively as the contract + promises (RoleValue.cs:55-62). Role sets hold a handful of entries, so the fallback is negligible + and only runs on a miss. The protected generic BuildLookup<TRole>(params roles) + (RoleValue.cs:75) freezes the supplied singletons into a case-insensitive FrozenDictionary keyed + by Value (RoleValue.cs:80-83), so a derived type can back its FromString/IsValid members + with interned instances instead of re-allocating. ToString returns the value (RoleValue.cs:87), + and GetHashCode uses the ordinal-ignore-case hash (RoleValue.cs:96) so it stays consistent with + Equals, which is the contract a dictionary key depends on.
  • Why it's built this way: the abstract-class-plus-type-guard shape is how you share equality behavior across an open hierarchy of value objects without violating the equality contract, and the S4035 rationale is documented inline so a future reader does not "helpfully" add IEquatable<T> to @@ -2573,8 +2786,8 @@

    RoleValue

    (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/UserRole.cs:20-33), and exposes FromString/IsValid over that frozen lookup (UserRole.cs:51-65) plus a case-insensitive IsOrganizer for raw claim strings (UserRole.cs:76). Store's UserRole is a static class - rather than a subclass: it fixes Admin and Customer as string constants and calls the shared - RoleValue.Validate helper for its IsValid + rather than a subclass: it fixes Admin and Customer as string constants over an + OrdinalIgnoreCase set and calls the shared RoleValue.Validate helper for its IsValid (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Domain/Users/UserRole.cs:14,26-30,37), so it inherits the rule set (case-insensitive membership, the User.Role.Invalid code) without inheriting the type. Both key their known-role sets off the RoleNames constants.
  • @@ -2609,334 +2822,438 @@

    RegisterRequest

    (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:134) and the register endpoint on AuthControllerBase - (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:81-82), plus + (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:84-85), plus each app's register form. +

    IcsEvent

    +
    +

    MMCA.Common.Shared · MMCA.Common.Shared.Calendars · MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsEvent.cs:15 · Level 0 · record

    +
    +
      +
    • What it is: one calendar entry handed to + IcsCalendarBuilder: a stable Uid, a Summary, a UTC start and end, and + two optional strings for description and location + (MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsEvent.cs:15-21).
    • +
    • Depends on: nothing first-party; System.DateTimeOffset (BCL).
    • +
    • Concept introduced, the UTC-only calendar contract. [Rubric §9, API & Contract Design] + assesses whether a contract states its own invariants rather than leaving them to convention. The + invariant here is written into the type's own doc comment: "Times are UTC by contract" + (IcsEvent.cs:4). RFC 5545 lets a calendar carry local times paired with a VTIMEZONE block that + restates the zone's DST rules inside the document; getting that block right (and keeping it right + as tzdata moves) is a well-known source of bugs. By declaring the two timestamps + DateTimeOffset and requiring them to already be UTC instants, this record pushes the wall-clock + to UTC conversion onto the caller, which is where the zone knowledge actually lives, and lets the + builder emit plain Z-suffixed timestamps with no VTIMEZONE machinery at all (IcsEvent.cs:5-7). + The Uid carries a second contract: calendar clients de-duplicate re-imports by it, so it must be + globally unique and stable across exports of the same thing (IcsEvent.cs:9).
    • +
    • Walkthrough: a positional sealed record with six parameters and no body. Uid, Summary, + StartsAtUtc, EndsAtUtc are required by position; Description and Location default to null + (IcsEvent.cs:16-21), which is how the builder decides to omit the corresponding lines entirely + rather than emit an empty one. A record (reference type) rather than the readonly record struct + that the auth DTOs in this group use: entries are built into a collection and enumerated once, so + there is no per-call allocation to avoid.
    • +
    • Why it's built this way: the framework ships no calendar NuGet dependency, so the shape of an + entry is the framework's to define. Keeping it to the six fields every calendar client honors is + the same minimal-subset judgement the builder documents at + MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsCalendarBuilder.cs:7-10. No ADR governs + calendar export; the decision lives in these two files' doc comments.
    • +
    • Where it's used: ADC's Conference module builds entries from sessions in + CalendarExportMapper, which does the + event-zone to UTC conversion the contract demands and composes the Uid as + session-{id}@atldevcon + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/CalendarExportMapper.cs:31-44). + The mapped entries reach + ExportSessionCalendarHandler + (ExportSessionCalendarHandler.cs:59-61) and + ExportEventCalendarHandler + (ExportEventCalendarHandler.cs:54,61).
    • +
    • Caveats / not-in-source: nothing in the type enforces that StartsAtUtc and EndsAtUtc really + carry a zero offset, that the end follows the start, or that the Uid is unique. All three are + contract-by-documentation; the only enforcement is the mapper that produces them.
    • +

    IdempotencyHeaders

    MMCA.Common.Shared · MMCA.Common.Shared.Http · MMCA.Common/Source/Core/MMCA.Common.Shared/Http/IdempotencyHeaders.cs:13 · Level 0 · class (static)

      -
    • What it is: two const string header names for the idempotency protocol, the request header a - client sends to make a write repeatable and the response header a server sets when it replayed a - stored answer instead of executing again - (MMCA.Common/Source/Core/MMCA.Common.Shared/Http/IdempotencyHeaders.cs:13-26).

      -
    • -
    • Depends on: nothing. No usings, no first-party types, no externals.

      -
    • -
    • Concept introduced, the shared-literal constant as a contract between two packages that cannot - see each other. [Rubric §9, API & Contract Design] assesses whether the wire contract is - expressed once and consistently; [Rubric §16, Maintainability & Evolvability] assesses whether a - change lands in one place. The remarks - (MMCA.Common/Source/Core/MMCA.Common.Shared/Http/IdempotencyHeaders.cs:7-12) state the exact - reason this type sits in Shared rather than next to the filter that consumes it: the server-side - reader lives in MMCA.Common.API and the client-side writer lives in MMCA.Common.UI, and by the - layering rules those two packages have no reference to one another (UI depends on Shared only, - for Blazor WebAssembly compatibility). Shared is the one assembly both can see, so it is the only - place a single literal can serve both ends. Hard-coding "Idempotency-Key" twice would compile - perfectly and break silently the day one side is edited: a typo on the client means the server never - sees a key and every retry executes again.

      -
    • -
    • Walkthrough: two members and no behavior.

      -
        -
      • IdempotencyKey = "Idempotency-Key" (line 19), the client-provided key. The doc comment (lines - 15-18) records the protocol contract: a server that has already seen the key replays the original - response rather than executing the action a second time.
      • -
      • IdempotentReplay = "X-Idempotent-Replay" (line 25), appended by the server when the body it - returned came from the idempotency cache rather than a fresh execution, so a client can tell a - deduplicated answer from an original one.
      • -
      -

      Both are const, not static readonly, so they are usable in attribute arguments and in switch - patterns that require compile-time constants, the same choice - AuthClaimTypes and RoleNames make.

      -
    • -
    • Why it's built this way: ADR-017 - defines idempotency as a client-supplied-key protocol at the inbound HTTP edge, which only works if - both ends agree on the header spelling. Putting the literal in Shared makes that agreement a - compile-time fact instead of a convention.

      -
    • -
    • Where it's used: on the server, - IdempotencyFilter re-exposes it as a public - IdempotencyKeyHeader property - (MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:72), reads the - incoming header (IdempotencyFilter.cs:167), and appends X-Idempotent-Replay: true on a replay - (IdempotencyFilter.cs:387). On the client, - EntityServiceBase<TEntityDTO, TIdentifierType> - sets it as a default request header on the HttpClient that serves every retry attempt of one - logical operation - (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/EntityServiceBase.cs:193-199), which is - what makes the retries deduplicate instead of creating extra records. ADC's live-layer UI services - do the same per call - (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/LivePollUIService.cs:96 and - :143, - MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionQuestionUIService.cs:75).

      -
    • +
    • What it is: the two HTTP header names of the idempotency protocol, as const strings: + Idempotency-Key (the request header a client sends) and X-Idempotent-Replay (the response + header a server appends when it served a cached body) + (MMCA.Common/Source/Core/MMCA.Common.Shared/Http/IdempotencyHeaders.cs:19,25).
    • +
    • Depends on: nothing.
    • +
    • Concept introduced, the shared wire-literal. [Rubric §16, Maintainability] assesses whether a + fact that two components must agree on has exactly one home. [Rubric §9, API & Contract Design] + assesses whether the protocol between client and server is expressed explicitly. Both ends of this + protocol are first-party but live in packages that do not reference each other: the filter that + reads the key ships in MMCA.Common.API, the service bases that write it ship in MMCA.Common.UI. + The doc comment states the consequence plainly: "Hard-coding the string in both places is exactly + the drift this constant exists to prevent" (IdempotencyHeaders.cs:8-12). Putting the literal in + MMCA.Common.Shared, the one assembly both sides already depend on, is the standard placement rule + for cross-layer constants in this framework and is the same reasoning that puts the auth request + DTOs there.
    • +
    • Walkthrough: a static class with two const string fields and nothing else + (IdempotencyHeaders.cs:13-26). const rather than static readonly so the values can appear in + attribute arguments and constant patterns, matching + AuthClaimTypes and RoleNames in this group.
    • +
    • Why it's built this way: + ADR-017 defines the protocol: + the client supplies the key, and a server that replays a cached response adds + X-Idempotent-Replay: true so the caller can tell a replay from a fresh execution + (Website/docs-src/adr/017-request-idempotency.md:31,46).
    • +
    • Where it's used: server side, IdempotencyFilter + re-exports the request header name as a public property + (MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:72), reads it + in the one helper both filter stages share (IdempotencyFilter.cs:167), and appends the replay + header when it serves a cached response (IdempotencyFilter.cs:387); + NotificationsController reads the same + request header directly + (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Notifications/NotificationsController.cs:62). + Client side, EntityServiceBase<TEntityDTO, TIdentifierType> + attaches a generated key on retried writes + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/EntityServiceBase.cs:199), as do ADC's + SessionQuestionUIService + (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionQuestionUIService.cs:75) + and LivePollUIService + (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/LivePollUIService.cs:96,143).

    PrivacyFeatures

    MMCA.Common.Shared · MMCA.Common.Shared.Privacy · MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/PrivacyFeatures.cs:6 · Level 0 · class (static)

      -
    • What it is: the feature-flag name space for the privacy (data-subject rights) surface. One - member today: DataExport = "Privacy.DataExport", the flag that turns the data-subject export - endpoint on - (MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/PrivacyFeatures.cs:6-10).
    • -
    • Depends on: nothing. No usings, no first-party types, no externals. Same reason as - IdempotencyHeaders: a flag name has to be nameable from the layer that - gates on it and from the configuration a host writes, so it lives at the bottom of the stack.
    • -
    • Concept introduced, the flag name as a compile-time symbol rather than a magic string. - [Rubric §10, Cross-Cutting Concerns] assesses whether a concern like feature gating is expressed - once instead of restated per call site; - [Rubric §30, Compliance / Privacy / Data Governance] assesses whether privacy-affecting surfaces - are deliberately controlled rather than always-on. Microsoft.FeatureManagement matches flags by - string, both in the FeatureManagement configuration section and in the [FeatureGate("...")] - attribute, so nothing in the compiler stops a host from enabling "Privacy.DataExport" while the - controller gates on "PrivacyDataExport": the endpoint would simply stay 404 with no error - anywhere. Publishing the literal as a const makes the attribute side of that pair a symbol the - compiler checks, and it is const (not static readonly) precisely so it can be used as an - attribute argument, which static readonly cannot - (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Privacy/DataExportControllerBase.cs:59). - The configuration side stays a string a host types, and no source in this tree types it.
    • -
    • Walkthrough: one member.
        -
      • DataExport = "Privacy.DataExport" (line 9), documented as the flag controlling the data-subject - export (DSAR) endpoint (line 8). The value is dotted, matching the flag-name convention the - feature-management configuration section uses.
      • -
      -
    • -
    • Why it's built this way: ADR-031 - settles on Microsoft.FeatureManagement and enforces one flag name on two surfaces (a - [FeatureGate] on controllers, IFeatureGated on CQRS handlers), with a disabled feature - answering 404, not 403, so a turned-off capability is indistinguishable from one that was never - deployed. ADR-076 then chooses - to ship the whole export endpoint behind that gate, so adopting the framework does not silently - publish a route that returns a complete dossier on a person.
    • -
    • Where it's used: as the argument to the class-level - [FeatureGate(PrivacyFeatures.DataExport)] on +
    • What it is: one const string naming the feature flag that gates the data-subject export + surface, Privacy.DataExport + (MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/PrivacyFeatures.cs:9).
    • +
    • Depends on: nothing first-party.
    • +
    • Concept introduced, the feature flag as a shared constant. [Rubric §30, Compliance / Privacy / Data Governance] assesses how the codebase handles data-subject rights and how deliberately those + surfaces are turned on. [Rubric §10, Cross-Cutting Concerns] assesses whether concerns like + feature gating are applied uniformly rather than ad hoc. A data-subject access endpoint returns a + complete dossier of one person's personal data, so it is the last endpoint that should default to + reachable. Naming the flag once, in the assembly every layer can see, lets the attribute that + gates the controller and any host configuration that enables it refer to the same string. The + flag's own evaluation is the Microsoft.FeatureManagement [FeatureGate] attribute, whose + behavior is not this type's concern; see + ADR-031.
    • +
    • Walkthrough: a static class containing a single public const string DataExport = "Privacy.DataExport"; (PrivacyFeatures.cs:6-10). The dotted name is a namespace convention for + the flag key, not C# syntax: it is one opaque string as far as the feature manager is concerned.
    • +
    • Why it's built this way: + ADR-076 makes the whole export + capability opt-in, and records the gate explicitly: a host that has not turned the feature on gets + a 404 from the endpoint rather than an unauthorized-looking 403 + (Website/docs-src/adr/076-data-subject-export.md:116).
    • +
    • Where it's used: DataExportControllerBase<TQuery> - (DataExportControllerBase.cs:59, with the rationale in its remarks at :50-55), and asserted by - MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/Privacy/DataExportControllerBaseTests.cs:149, - which reflects over the attribute so the gate cannot be dropped in a refactor.
    • -
    • Caveats / not-in-source: no appsettings*.json in this workspace declares a - Privacy.DataExport entry, and no production controller derives from - DataExportControllerBase<TQuery> (ADC and Store keep their own earlier export endpoints, see - UserDataExportDTO). So the flag is defined and gated on, but not currently - enabled by any host in this tree.
    • + carries [FeatureGate(PrivacyFeatures.DataExport)] on the class + (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Privacy/DataExportControllerBase.cs:59), + with the rationale in the same file's remarks (DataExportControllerBase.cs:50-55). +
    • Caveats / not-in-source: no appsettings*.json anywhere in the workspace declares a + Privacy.DataExport flag, and neither ADC's nor Store's UsersController derives from + DataExportControllerBase (both declare their own ExportAsync action: + MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:158-161, + MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/UsersController.cs:36-39). + The gate is therefore framework behavior that no deployed endpoint currently exhibits, which + ADR-076 itself records (Website/docs-src/adr/076-data-subject-export.md:185).

    Releaser

    MMCA.Common.Shared · MMCA.Common.Shared.Concurrency · MMCA.Common/Source/Core/MMCA.Common.Shared/Concurrency/KeyedSemaphoreStripe.cs:78 · Level 0 · record struct (readonly, nested)

      -
    • What it is: the disposable handle KeyedSemaphoreStripe hands back - from AcquireAsync; disposing it releases the stripe that was taken +
    • What it is: the handle KeyedSemaphoreStripe.AcquireAsync returns. + Disposing it releases the stripe that was taken (MMCA.Common/Source/Core/MMCA.Common.Shared/Concurrency/KeyedSemaphoreStripe.cs:78-86).
    • -
    • Depends on: System.IDisposable and SemaphoreSlim (both BCL). It is nested inside - KeyedSemaphoreStripe and only that type can construct it.
    • -
    • Concept introduced, the scope-bound lock handle. [Rubric §15, Best Practices & Code Quality] - assesses whether resource lifetimes are expressed so the compiler enforces them. The alternative - shape, WaitAsync(...) followed by a try / finally Release() at every call site, puts the - release on the caller and fails open the first time someone forgets or returns early. Returning a - handle instead makes using the natural spelling, so the release rides on the scope and survives an - exception in the guarded work (the AcquireAsync doc comment says exactly this, - KeyedSemaphoreStripe.cs:52-56). readonly record struct keeps the handle allocation-free on a - path that runs per cache write and per idempotent POST, which matters because the whole point of the - striped design is to be cheap. [Rubric §12, Performance & Scalability] covers that allocation - choice.
    • -
    • Walkthrough: three members.
        -
      • private readonly SemaphoreSlim? _stripe (line 80): the semaphore to release, deliberately - nullable.
      • -
      • internal Releaser(SemaphoreSlim stripe) (line 82): internal, so only the enclosing stripe set - can mint one; there is no public way to fabricate a handle for a semaphore you never took.
      • -
      • Dispose() (line 85): _stripe?.Release(). The null-conditional is what makes a - default(Releaser) (which a struct always permits, since a struct has no null) a safe no-op - rather than a NullReferenceException; the doc comment on line 84 calls that out.
      • -
      -
    • -
    • Why it's built this way: a struct handle with an internal constructor gives the ergonomics of - using with none of the per-acquisition garbage, and the null-tolerant Dispose closes the one - hole a value type opens (nobody can construct a broken handle, but the language always allows a - zeroed one).
    • -
    • Where it's used: returned by - KeyedSemaphoreStripe.AcquireAsync - (KeyedSemaphoreStripe.cs:64) and consumed as a using at every call site: - IdempotencyFilter - (MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:208), - CookieSessionRefresher - (MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:102), - MemoryCacheService - (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/MemoryCacheService.cs:101, :112, - :132), the default GetOrCreateAsync implementation on - ICacheService - (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICacheService.cs:112), and +
    • Depends on: nested inside KeyedSemaphoreStripe; implements + System.IDisposable; wraps a System.Threading.SemaphoreSlim (BCL).
    • +
    • Concept introduced, the disposable-scope handle over a manual acquire/release pair. + [Rubric §15, Best Practices & Code Quality] assesses whether resource lifetimes are expressed so + the compiler enforces them. A raw SemaphoreSlim requires WaitAsync and Release to be paired + by hand, and the pairing has to survive an exception in between; forgetting the finally deadlocks + every later caller on that semaphore permanently. Returning a handle turns the pairing into a + using statement, which the compiler expands to a try/finally for you. The caller's whole + contract becomes one line, and the doc comment says so: "Await the call inside a using statement + so the release happens even when the guarded work throws" + (KeyedSemaphoreStripe.cs:53-55). [Rubric §12, Performance & Scalability]: making it a + readonly record struct means the handle costs one machine word on the stack rather than a heap + allocation on the hot path of every cache read.
    • +
    • Walkthrough: one private field, SemaphoreSlim? _stripe (KeyedSemaphoreStripe.cs:80), set by + an internal constructor so only the enclosing stripe set can hand out a live handle + (KeyedSemaphoreStripe.cs:82). Dispose is _stripe?.Release() (KeyedSemaphoreStripe.cs:85). + The null-conditional is load-bearing rather than defensive noise: a struct always has a + parameterless default form that no constructor ever ran for, so default(Releaser).Dispose() is + reachable C# and must be a no-op instead of a NullReferenceException. The doc comment states that + guarantee (KeyedSemaphoreStripe.cs:84).
    • +
    • Why it's built this way: synchronous IDisposable rather than IAsyncDisposable because + SemaphoreSlim.Release does not block. Contrast the distributed path, where + InProcessDistributedLock + returns an IAsyncDisposable? + (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Concurrency/InProcessDistributedLock.cs:42), + because releasing a lock held in a remote store is I/O.
    • +
    • Where it's used: every caller of AcquireAsync, always inside a using: + MemoryCacheService at + MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/MemoryCacheService.cs:101,112,132, + CookieSessionRefresher at + MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:102, + IdempotencyFilter at + MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:208, CachingQueryDecorator<TQuery, TResult> - (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingQueryDecorator.cs:89).
    • + at MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingQueryDecorator.cs:89, + and the ICacheService GetOrCreateAsync default + implementation at MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICacheService.cs:112.

    UserDataExportSectionDTO

    -

    MMCA.Common.Shared · MMCA.Common.Shared.Privacy · MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:61 · Level 0 · record (sealed)

    -
    -
      -
    • What it is: one section of a data-subject export package: the data a single contributor holds - about the subject, plus whether that contributor could be reached at all - (MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:51-89).

      -
    • -
    • Depends on: System.Runtime.Serialization's DataContract / DataMember (BCL, line 1). No - first-party types. It is the element type of UserDataExportDTO.Sections.

      -
    • -
    • Concept introduced, the degradation envelope: reporting "not retrieved" as data rather than as an - error. [Rubric §29, Resilience & Business Continuity] assesses whether a partial failure - degrades a response instead of failing it, and [Rubric §30, Compliance / Privacy / Data Governance] assesses whether a legal obligation is actually met under fault. A data-subject access - request is a deadline with a statutory obligation attached, and this document is assembled by - fanning out over contributors that in an extracted topology are other services. If any one of - them being unreachable failed the whole export, one peer outage would deny the subject their entire - package. So the shape carries the outcome instead: an unreachable contributor still produces an - envelope, with Available = false and a caller-safe reason. The doc comment states the invariant - the reader depends on (lines 67-70): Available = false means "incomplete, retry later", not - "the subject has no data here". Without that flag those two cases are the same empty payload, and a - subject could be told their record is empty when a service was simply down.

      -

      The second half of the concept is the caller-safe reason string (lines 82-86): it explicitly - never carries exception messages, stack traces, connection strings or peer addresses, because this - string is handed to the data subject. [Rubric §11, Security] applies: an error surface that leaks - infrastructure detail to an unauthenticated-adjacent audience is a disclosure bug, and the diagnostic - detail belongs in the log instead. The producer honours that split, logging the exception and - substituting a fixed generic reason - (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/ExportUserDataHandlerBase.cs:187-197).

      -
    • -
    • Walkthrough: four init-only properties, no behavior, each with an explicit [DataMember(Order = n)] so the wire order is declared rather than inherited from declaration order.

      -
        -
      • SectionName (required, Order 1, line 65): the stable identifier for the section, for example - "Engagement" or "Sales".
      • -
      • Available (required, Order 2, line 72): whether the section was produced successfully. Both - of these are required, so a section envelope cannot be constructed without answering the two - questions the reader must have.
      • -
      • Data (Order 3, line 80): the contributor's own payload, or null when the section is - unavailable. Typed object for the same reason UserDataExportDTO.Subject - is (taught there): the payload shape is owned by the contributor, not by the framework.
      • -
      • UnavailableReason (Order 4, line 88): the short caller-safe explanation, null when the section - is available.
      • -
      -
    • -
    • Why it's built this way: ADR-076 - makes per-section degradation the rule rather than an implementation detail, and the two required - members are what stop a producer from emitting an ambiguous envelope. sealed record gives value - equality and init-only immutability for free, so an assembled package cannot be mutated on its way - out.

      -
    • -
    • Where it's used: produced by - ExportUserDataHandlerBase<TUser, TQuery>.RunSectionAsync - on both the success path (ExportUserDataHandlerBase.cs:177-183, copying the fields off the - contributor's UserDataExportSectionResult) - and the degraded path (:192-197, stamping - UserDataExportSectionDefaults.UnavailableReason); - collected into Sections at :104-107 and :116.

      -
    • +

      MMCA.Common.Shared · MMCA.Common.Shared.Privacy · MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:61 · Level 0 · record

      +
    +
      +
    • What it is: one section of a data-subject export package: a SectionName, an Available flag, + an opaque Data payload, and an UnavailableReason + (MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:61-89). It is the + envelope around whatever one contributor holds about the subject.
    • +
    • Depends on: nothing first-party; + System.Runtime.Serialization.DataContractAttribute/DataMemberAttribute (BCL). It is the element + type of UserDataExportDTO.Sections.
    • +
    • Concept introduced, "no data" is not the same fact as "not retrieved". [Rubric §29, Resilience & Business Continuity] assesses how a composite operation behaves when one contributor is down. + [Rubric §30, Compliance / Privacy / Data Governance] assesses whether a data-subject right can be + honored under partial failure. A naive export either fails whole when any peer is unreachable + (denying the subject the data that is available) or silently omits the failed section (telling + the subject, falsely, that nothing is held there). This envelope refuses both: a section that could + not be produced is still present in the document, reporting Available = false, and the doc + comment records the distinction the reader must draw: false "means the section is incomplete and + the export can be retried later; it does not mean the subject has no data here" + (UserDataExportDTO.cs:68-69). This is the shape + ADR-096 calls best-effort, + applied to a read.
    • +
    • Walkthrough: four init-only properties, ordered explicitly with [DataMember(Order = n)] + (UserDataExportDTO.cs:64,71,79,87) so the serialized field order is a stated part of the + contract rather than a reflection accident. SectionName and Available are required + (UserDataExportDTO.cs:65,72), so a section envelope cannot be constructed without answering both + questions. Data is typed object? for the same reason UserDataExportDTO.Subject is: the + framework owns the envelope, the contributor owns the payload shape, and System.Text.Json + serializes an object-typed property by its runtime type (UserDataExportDTO.cs:74-78). The + fourth property carries the section's most security-sensitive rule: + UnavailableReason is "a short, caller-safe explanation" that "never carries exception messages, + stack traces, connection strings, or peer addresses: this string is handed to the data subject" + (UserDataExportDTO.cs:82-86).
    • +
    • Why it's built this way: + ADR-076 settled the three + questions neither app had answered, one of which was exactly "what an export does when one + contributing source is unavailable" + (Website/docs-src/adr/076-data-subject-export.md:44-46). Degrading one section preserves the + legal deadline on the rest of the document.
    • +
    • Where it's used: produced by + ExportUserDataHandlerBase<TUser, TQuery> + on both paths: from a successful + UserDataExportSectionResult + (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/ExportUserDataHandlerBase.cs:177-183) + and from the catch that degrades a throwing contributor, where the reason is the fixed string on + UserDataExportSectionDefaults + rather than anything derived from the exception (ExportUserDataHandlerBase.cs:185-197). Collected + into UserDataExportDTO.Sections at ExportUserDataHandlerBase.cs:104-116.
    • +
    • Caveats / not-in-source: nothing prevents an envelope from setting Available = true and a + non-null UnavailableReason at the same time, or Available = false with a payload. The + consistency is a convention the producing handler upholds, not a type invariant.
    • +
    +

    ForgotPasswordRequestValidator

    +
    +

    MMCA.Common.Application · MMCA.Common.Application.Auth.Validation · MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ForgotPasswordRequestValidator.cs:11 · Level 1 · class

    +
    +
      +
    • What it is: the FluentValidation validator for + ForgotPasswordRequest. It checks one field, Email, for non-empty and + address shape + (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ForgotPasswordRequestValidator.cs:13-16).
    • +
    • Depends on: ForgotPasswordRequest; FluentValidation's + AbstractValidator<T> (NuGet).
    • +
    • Concept introduced, validation that deliberately stops short. [Rubric §11, Security] assesses + whether the system leaks facts an attacker can use, and account enumeration is the classic leak: + if "forgot password" answers differently for a registered and an unregistered address, the endpoint + becomes a membership oracle. The forgot-password endpoint answers 202 Accepted unconditionally + (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:79,92), + and this validator is the place that could quietly undo it: a rule that checked whether the address + belongs to an account would turn a miss into a 400, which is the same oracle by a different + status code. The class doc comment names that trap and refuses it: a 400 there "would be the + enumeration oracle the always-accepted response exists to close" + (ForgotPasswordRequestValidator.cs:7-9). [Rubric §24, Forms / Validation / UX Safety]: shape + validation still runs, so a genuinely malformed address gets a useful client-side message without + costing an email send.
    • +
    • Walkthrough: an expression-bodied constructor with a single chained rule, + RuleFor(x => x.Email).NotEmpty().EmailAddress(), each stage carrying an explicit + WithMessage (ForgotPasswordRequestValidator.cs:13-16). The messages are literal English strings + rather than resource lookups, which is how every validator in this assembly is written.
    • +
    • Why it's built this way: the reset flow itself is + ADR-091; the + uniform-response posture it depends on is only as strong as its weakest responder, and a validator + runs before the handler does.
    • +
    • Where it's used: registered by assembly scan. + services.AddValidatorsFromAssemblyContaining<ClassReference>() + (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:48) picks up every + validator in MMCA.Common.Application, with the comment explaining why it must happen here rather + than in the per-module scan (DependencyInjection.cs:45-47). The resolved IValidator<ForgotPasswordRequest> + is then consumed indirectly: an app's forgot-password command implements ICommandWithRequest<ForgotPasswordRequest> + (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:42), + and CommandRequestValidator<TCommand, TRequest> + bridges the command's Request property to this validator + (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommandRequestValidator.cs:22-26), + auto-registered for every such command at DependencyInjection.cs:196-210.
    -

    KeyedSemaphoreStripe

    +

    IcsCalendarBuilder

    -

    MMCA.Common.Shared · MMCA.Common.Shared.Concurrency · MMCA.Common/Source/Core/MMCA.Common.Shared/Concurrency/KeyedSemaphoreStripe.cs:22 · Level 1 · class (sealed)

    +

    MMCA.Common.Shared · MMCA.Common.Shared.Calendars · MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsCalendarBuilder.cs:12 · Level 1 · class (static)

      -
    • What it is: an in-process mutual-exclusion primitive that serializes work per logical key - across a fixed array of SemaphoreSlim instances. A key hashes to one stripe, so the table size is - bounded by Width no matter how many distinct keys the process ever sees - (MMCA.Common/Source/Core/MMCA.Common.Shared/Concurrency/KeyedSemaphoreStripe.cs:3-6).

      -
    • -
    • Depends on: SemaphoreSlim, ArgumentOutOfRangeException, ArgumentNullException and - string.GetHashCode(ReadOnlySpan<char>, StringComparison) (all BCL); it returns the nested - Releaser. No first-party dependencies at all, which is why it can live in Shared - and be used from Application, Infrastructure and API alike.

      -
    • -
    • Concept introduced, lock striping (and the two defects it exists to avoid). [Rubric §12, Performance & Scalability] assesses whether concurrency control is bounded and does not become a - memory or contention hazard; [Rubric §11, Security] applies because the keys here are frequently - caller-supplied (an idempotency key, a parameterized cache key), which makes an unbounded - per-key table a remote memory-exhaustion vector. The class doc (lines 7-16) is worth reading in - full, because it argues against the shape most codebases reach for first, one SemaphoreSlim per - key in a ConcurrentDictionary. That shape forces a choice between two real defects:

      -
        -
      1. Remove the entry when the last holder releases, and you open a window where one caller is - waiting on a semaphore that is no longer in the table while a second caller creates a fresh one; - both then run concurrently, which is precisely what the lock existed to prevent.
      2. -
      3. Never remove it, and a caller-supplied key grows the table without bound.
      4. -
      -

      Striping has neither problem: the array is allocated once at construction and never mutated. The - price is false sharing of a stripe: two unrelated keys can hash to the same slot and briefly - serialize against each other. The doc explains why that is acceptable here (lines 13-15): every - caller is doing double-check locking and re-checks its own key's state after acquiring, so a - spurious wait costs latency, never correctness.

      -
    • -
    • Walkthrough (fields, constructors, then the one public method):

      -
        -
      • DefaultWidth = 256 (line 25): the default stripe count, documented as "ample concurrency without - a meaningful memory cost" (line 24). It is public const, which is what lets a test compute a - deliberate collision (see Caveats below).
      • -
      • private readonly SemaphoreSlim[] _stripes (line 27): the fixed table.
      • -
      • Parameterless constructor (lines 30-33): chains to the width overload with DefaultWidth.
      • -
      • KeyedSemaphoreStripe(int width) (lines 37-47): guards with - ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(width, 0) (line 39), stores Width (line 41), - then eagerly allocates every stripe as new SemaphoreSlim(1, 1) (lines 42-46). Initial count 1 and - maximum count 1 is a mutex: exactly one holder at a time. Allocating all of them up front is what - removes every race from the acquire path, there is no lazy creation to synchronize.
      • -
      • Width { get; } (line 50): the table size, get-only.
      • -
      • AcquireAsync(string key, CancellationToken) (lines 60-65): map the key to its stripe, await stripe.WaitAsync(cancellationToken) with ConfigureAwait(false) (line 63, the library - ConfigureAwait policy of - ADR-049), and return - a Releaser wrapping it (line 64). The doc comment (line 58) is precise about the - token's scope: it cancels the wait, not the work that follows it.
      • -
      • private SemaphoreSlim GetStripe(string key) (lines 67-75): null-checks the key (line 69) and then - folds an ordinal hash into a non-negative index: - (uint)string.GetHashCode(key, StringComparison.Ordinal) % (uint)Width (line 73). Two details - matter. Ordinal (not the default culture-sensitive comparison) keeps the mapping stable regardless - of the ambient culture. The uint cast rather than Math.Abs is deliberate and commented (lines - 71-72): int.MinValue has no positive counterpart, so Math.Abs on it throws, while masking the - sign bit by reinterpreting as unsigned cannot.
      • -
      -
    • -
    • Why it's built this way: the remarks (lines 18-21) fix the intended lifetime: instances are - thread-safe and meant to be held in a static field for the process lifetime, and the stripes are - never disposed because the instance outlives every caller. That is why you will find it as a - static readonly or an instance field on a singleton, never as a scoped dependency. Note the scope - limit that follows from being in-process: it serializes callers inside one process only, so - under more than one replica it is not sufficient on its own. That is exactly why - ADR-017 was revised to make the - idempotency guard an IDistributedLock resolved from - DI, keeping the stripe only as the fallback for a host that registers none - (MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:36 and - :197-199). [Rubric §7, Microservices Readiness] is the lens here: a primitive that is correct on - one node and insufficient on several is exactly the kind of assumption an extraction has to - re-examine.

      -
    • -
    • Where it's used: five call sites, all double-check-locking a cache. - IdempotencyFilter holds a static instance - (IdempotencyFilter.cs:92) and runs the guarded section under it when no distributed lock is - registered (IdempotencyFilter.cs:208-215). - CookieSessionRefresher holds a per-instance one - (MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:62) so - concurrent SSR requests carrying the same expired cookie do not each burn the refresh token - (:102-105). MemoryCacheService uses one to make its - read-modify-write paths atomic per key - (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/MemoryCacheService.cs:38, :101, - :112, :132). The default GetOrCreateAsync on - ICacheService collapses a factory stampede through an - internal non-generic holder, CacheKeyLocks.Locks - (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICacheService.cs:112, holder at - :142-146), and +

    • What it is: a dependency-free RFC 5545 writer. Given a product id, a collection of + IcsEvent, and a timestamp, it returns a complete VCALENDAR document as a string + (MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsCalendarBuilder.cs:22-41).
    • +
    • Depends on: IcsEvent; System.Text.StringBuilder, System.Text.Encoding, and + System.Globalization.CultureInfo (BCL). No NuGet package.
    • +
    • Concept introduced, the deterministic pure builder. [Rubric §14, Testability] assesses + whether behavior can be asserted without a harness. This type takes dtStamp as a parameter rather + than reading a clock, and the doc comment states the consequence: "Deterministic by design: the + caller supplies dtStamp, so identical inputs produce identical output" + (IcsCalendarBuilder.cs:10-11). That makes the whole document byte-assertable, which is exactly + what the suite in + MMCA.Common/Tests/Core/MMCA.Common.Shared.Tests/Calendars/IcsCalendarBuilderTests.cs does, + including a determinism test that builds twice and compares (IcsCalendarBuilderTests.cs:140-141). + [Rubric §32, Dependency & Supply-Chain] assesses what the framework takes on as a dependency. + Emitting an ICS file is a few hundred lines of string handling; taking a calendar library for it + would add a transitive surface to MMCA.Common.Shared, the assembly every other package depends + on. The type instead states its scope as "the subset every calendar app imports reliably" + (IcsCalendarBuilder.cs:7-10).
    • +
    • Walkthrough:
        +
      • MaxLineOctets = 75 (IcsCalendarBuilder.cs:14) is RFC 5545's content-line limit, counted in + octets rather than characters.
      • +
      • Build (IcsCalendarBuilder.cs:22) guards both inputs (ThrowIfNullOrWhiteSpace on the product + id, ThrowIfNull on the events, :24-25), then writes the fixed calendar preamble + VERSION:2.0, an escaped PRODID, CALSCALE:GREGORIAN, and METHOD:PUBLISH + (:28-32), loops the entries in the order given (:34-37), and closes the document (:39). + Note that an empty collection is legal: it produces a valid, entry-less calendar, which + IcsCalendarBuilderTests.cs:149 pins.
      • +
      • AppendEvent (:43) writes the five mandatory VEVENT lines: UID, DTSTAMP, DTSTART, + DTEND, SUMMARY (:46-50). DESCRIPTION and LOCATION are emitted only when the optional + field is not null or whitespace (:52-60), so an all-blank location does not leave a stray + empty property in the document.
      • +
      • FormatUtc (:65) is where the UTC-only contract shows up on the wire: it converts through + UtcDateTime and formats yyyyMMdd'T'HHmmss'Z' under InvariantCulture. The invariant culture + is not optional decoration; a non-Gregorian or non-ASCII-digit current culture would otherwise + corrupt the timestamp.
      • +
      • EscapeText (:69) implements RFC 5545 section 3.3.11 TEXT escaping. Order matters and is + correct here: backslash is escaped first (:71), so the backslashes introduced by the later + replacements are not double-escaped. Semicolon and comma follow (:72-73), then all three + newline forms collapse to the literal \n sequence (:74-76), CRLF before its parts so a + Windows line break does not become two escapes.
      • +
      • AppendLine (:83) is the subtlest method: RFC 5545 folding. It walks the string counting UTF-8 + octets per character, treating a surrogate pair as one unit (:89-90), and when the next + character would push the line past 75 octets it emits CRLF plus a single space and resets the + counter to 1 (:92-96). Two details are easy to get wrong and are handled: a fold never splits + a multi-byte character (because the decision is made per character, before appending), and the + continuation line's leading space counts against its own budget, which the inline comment states + (:95). Every line, folded or not, ends in CRLF (:103).
      • +
      +
    • +
    • Why it's built this way: no ADR covers calendar export; the rationale is entirely in the doc + comments cited above. The minimal-subset choice is the same instinct as the + IcsEvent UTC contract: avoid the parts of the specification whose correctness would + need continuous maintenance.
    • +
    • Where it's used: ADC's Conference module only, from + ExportSessionCalendarHandler + for a single session + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportSessionCalendarHandler.cs:59-61) + and ExportEventCalendarHandler + for a whole event + (.../ExportEventCalendarHandler.cs:61), both passing + CalendarExportMapper's + ProductId constant -//MMCA//AtlDevCon//EN (CalendarExportMapper.cs:17).
    • +
    • Caveats / not-in-source: both ADC handlers pass DateTimeOffset.UtcNow for dtStamp + (ExportEventCalendarHandler.cs:61) rather than an injected TimeProvider, so the determinism the + builder guarantees is available to its own tests but not exercised through the handlers.
    • +
    +

    KeyedSemaphoreStripe

    +
    +

    MMCA.Common.Shared · MMCA.Common.Shared.Concurrency · MMCA.Common/Source/Core/MMCA.Common.Shared/Concurrency/KeyedSemaphoreStripe.cs:22 · Level 1 · class

    +
    +
      +
    • What it is: an in-process, per-key mutual-exclusion primitive. Callers ask to serialize on a + string key; the key is hashed onto one of a fixed number of SemaphoreSlim stripes, and the caller + gets back a Releaser to dispose + (MMCA.Common/Source/Core/MMCA.Common.Shared/Concurrency/KeyedSemaphoreStripe.cs:22-86).

      +
    • +
    • Depends on: its own nested Releaser; System.Threading.SemaphoreSlim (BCL).

      +
    • +
    • Concept introduced, lock striping. [Rubric §12, Performance & Scalability] assesses how + shared state is guarded under concurrency and what that guard costs. The naive way to lock per key + is a ConcurrentDictionary<string, SemaphoreSlim>, and the class doc comment lays out why that + shape is a trap, in the code rather than in tribal memory (KeyedSemaphoreStripe.cs:8-15):

      +
        +
      • If you remove the entry when the last holder releases, you open a race. Caller A looks the + semaphore up, then B releases and removes it, then A waits on an object no longer in the table + while C creates a fresh one and takes that. A and C now both run the guarded section, which is + precisely what the lock existed to prevent.
      • +
      • If you never remove it, the table grows without bound, and the keys here are + caller-supplied (an idempotency key, a parameterized cache key), so that is an + attacker-influenced memory leak.
      • +
      +

      Striping sidesteps both by never creating or destroying anything: the table is allocated once at + the declared width and every key maps into it forever. The price is stated honestly in the same + comment: two unrelated keys can collide on a stripe and briefly serialize against each other. That + is harmless for the double-check-locking callers this exists for, because each one re-checks its + own key's state after acquiring (KeyedSemaphoreStripe.cs:13-15).

      +
    • +
    • Walkthrough:

      +
        +
      • DefaultWidth = 256 (:25), described as "ample concurrency without a meaningful memory cost"; + 256 SemaphoreSlim instances is a fixed, small, one-time allocation.
      • +
      • The parameterless constructor chains to the width-taking one (:30-33). The real constructor + validates with ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(width, 0) (:39), then + eagerly fills the array with binary semaphores, new SemaphoreSlim(1, 1) (:42-46). Eager fill + is what removes every later allocation and every later race: after the constructor there is no + mutation of the table at all, which is why the type is safe to share without any lock of its own.
      • +
      • Width is a get-only property (:50), exposed so tests can reason about collisions; one test + computes the exact stripe index a key lands on + (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/CookieSessionRefresherTests.cs:288-291).
      • +
      • AcquireAsync (:60) resolves the stripe, awaits WaitAsync(cancellationToken) with + ConfigureAwait(false) per + ADR-049 (:63), + and wraps the semaphore in a Releaser (:64). The parameter doc draws a line worth + remembering: the token "Cancels the wait, not the work that follows it" (:58).
      • +
      • GetStripe (:67) does the hashing: (uint)string.GetHashCode(key, StringComparison.Ordinal) % (uint)Width (:73). Two deliberate choices, both commented (:71-72). StringComparison.Ordinal + is passed explicitly rather than relying on the default, which keeps the mapping culture-independent. + And the sign is folded by casting to uint rather than calling Math.Abs, because + int.MinValue has no positive counterpart and Math.Abs would throw on it.
      • +
      +
    • +
    • Why it's built this way: the class is a hoisted shared primitive rather than a private helper + because five separate call sites needed the same guard. + ADR-017 records its role in the + idempotency filter explicitly: the striped semaphore is the fallback for a host that registers no + IDistributedLock, and the ADR reproduces the same two-defects argument + (Website/docs-src/adr/017-request-idempotency.md:59-65). The scaling limit is stated there too: + a process-local lock only serializes duplicates that land on the same replica + (017-request-idempotency.md:93), which is why + IDistributedLock is preferred when present + (MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:33-37).

      +
    • +
    • Where it's used: five holders, all static or instance fields that live for the lifetime of + their owner, matching the remark that instances are "intended to be held in a static field for the + process lifetime" and that stripes are never disposed (KeyedSemaphoreStripe.cs:18-21): + IdempotencyFilter (IdempotencyFilter.cs:92), + CookieSessionRefresher + (MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:62), + MemoryCacheService + (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/MemoryCacheService.cs:38), the + CacheKeyLocks holder behind ICacheService's + GetOrCreateAsync default implementation + (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICacheService.cs:145), and the + QueryCacheKeyLocks holder behind CachingQueryDecorator<TQuery, TResult> - does the same for a query key through its own holder QueryCacheKeyLocks - (CachingQueryDecorator.cs:89, holder field at :197). The two holders are deliberately separate - tables, not one shared set: the remarks on CacheKeyLocks (ICacheService.cs:134-141) note that - sharing stripes across unrelated call sites would only widen the unrelated-key collisions striping - already tolerates.

      -
    • -
    • Caveats / not-in-source: [Rubric §14, Testability] shows up in an unusual way here. Because - DefaultWidth is public, CookieSessionRefresherTests computes a key that provably lands on a - different stripe rather than hoping, so the test cannot flake on the one-in-256 collision - (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/CookieSessionRefresherTests.cs:274 - and :289-291). The primitive's own behavior is covered by - MMCA.Common/Tests/Core/MMCA.Common.Shared.Tests/Concurrency/KeyedSemaphoreStripeTests.cs, which - drives it at width: 1, 2 and 4 (:40, :78, :98) to force collisions deterministically. - Note also that string.GetHashCode is randomized per process by default in .NET, so which key lands - on which stripe is stable within a run and not across runs; nothing in the design depends on it being - stable across runs.

      + (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingQueryDecorator.cs:197). + InProcessDistributedLock cites + the same reasoning in its own doc comment (InProcessDistributedLock.cs:20).

      +
    • +
    • Caveats / not-in-source: .NET randomizes string hash codes per process, so the stripe a given + key lands on differs between runs. That is invisible to correctness (any key consistently maps to + one stripe within a process) but it means collision behavior cannot be reproduced across + processes, which the caching tests call out + (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Interfaces/CacheServiceGetOrCreateTests.cs:178-179).

    LoginRequestValidator

    @@ -2944,512 +3261,193 @@

    LoginRequestValidator

    MMCA.Common.Application · MMCA.Common.Application.Auth.Validation · MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/LoginRequestValidator.cs:11 · Level 1 · class

      -
    • What it is: the FluentValidation rule set for LoginRequest: the email must be - present and well-formed, the password must be present. Nothing else - (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/LoginRequestValidator.cs:11-22).
    • -
    • Depends on: FluentValidation's AbstractValidator<T> (NuGet, line 1) and - LoginRequest from MMCA.Common.Shared.Auth (line 2).
    • -
    • Concept introduced, validation that deliberately stops short. [Rubric §11, Security] assesses - whether authentication avoids leaking information to an unauthenticated caller. The doc comment - (lines 6-10) is explicit that the minimalism is the design: detailed credential verification happens - in the authentication service to avoid leaking information about which field was wrong. A - validator that answered "no account with that email" would turn the login endpoint into an account - enumeration oracle. Instead the shape check happens here, and every credential outcome collapses - into the single Auth.InvalidCredentials / "Invalid email or password." failure that - AuthenticationServiceBase<TUser> returns for both a missing - user and a wrong password - (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:100-101 and - :115-116). [Rubric §9, API & Contract Design] also applies: shape validation belongs at the edge - of the request, semantic validation belongs in the workflow.
    • -
    • Walkthrough: one constructor (line 13) with two rules.
        -
      • RuleFor(x => x.Email).NotEmpty().EmailAddress() (lines 15-17), with the messages "Email is - required." and "A valid email address is required."
      • -
      • RuleFor(x => x.Password).NotEmpty() (lines 19-20), message "Password is required." There is no - length, complexity or character rule here; a password policy on login would only reject - credentials that a legacy account might legitimately still hold.
      • -
      -
    • -
    • Why it's built this way: FluentValidation keeps the rules declarative and out of the workflow, - and the framework owns this particular validator because the request DTO it validates is itself - framework-owned. Contrast RegisterRequestValidator, which stays in each app - (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/Validation/RegisterRequestValidator.cs:12, - MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/Validation/RegisterRequestValidator.cs:13) - because password policy and required profile fields are an application decision.
    • -
    • Where it's used: registered by AddApplication() via - services.AddValidatorsFromAssemblyContaining<ClassReference>() - (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:40); the comment there - (:37-39) records why it cannot ride on the module scan, ScanModuleApplicationServices only scans - a module's own assembly. DI then injects it into - AuthenticationValidators as the IValidator<LoginRequest>, and - AuthenticationServiceBase<TUser>.LoginAsync runs it first - (AuthenticationServiceBase.cs:77-81).
    • +
    • What it is: the validator for LoginRequest: Email must be non-empty and a + valid address, Password must be non-empty + (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/LoginRequestValidator.cs:15-20).
    • +
    • Depends on: LoginRequest; FluentValidation's AbstractValidator<T>.
    • +
    • Concept: the same "validation that deliberately stops short" posture introduced by + ForgotPasswordRequestValidator. [Rubric §11, Security]: the + doc comment is explicit that the minimalism is a security property, not laziness. Credential + verification "happens in the authentication service to avoid leaking information about which field + was wrong" (LoginRequestValidator.cs:7-9). Notice what is absent: no PasswordRules<T> or + StrongPasswordRules<T> include. Applying the complexity policy at login would tell an attacker + that a candidate password could not possibly be the stored one, and would lock out any account + whose password predates the current policy. Complexity belongs on the writing paths only, which + is why ResetPasswordRequestValidator includes it and this one + does not.
    • +
    • Walkthrough: a block-bodied constructor with two independent RuleFor chains + (LoginRequestValidator.cs:15-20), each stage given an explicit WithMessage. FluentValidation + runs both rule sets and reports every failure, so a request missing both fields returns two errors + rather than one.
    • +
    • Why it's built this way: uniform failure responses for authentication are the same discipline + as the forgot-password 202, applied to a different endpoint. The complementary defence against + guessing at scale is the per-IP rate-limit policy the login action carries + (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:54-57), which + is ADR-029.
    • +
    • Where it's used: registered by the assembly scan at + MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:48 (which names this class + in its comment, DependencyInjection.cs:45), then injected as IValidator<LoginRequest> into + AuthenticationValidators, the parameter object that bundles the three + auth validators + (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationValidators.cs:17,22), which is + in turn what AuthenticationServiceBase<TUser> consumes.
    • +
    • Caveats / not-in-source: AuthenticationValidators also requires an + IValidator<RegisterRequest> (AuthenticationValidators.cs:18), but MMCA.Common.Application + ships no RegisterRequestValidator: the only one in the tree is app-level + (RegisterRequestValidator). The bundle + therefore only resolves in a host whose own Application assembly has been scanned as well.

    RefreshTokenRequestValidator

    MMCA.Common.Application · MMCA.Common.Application.Auth.Validation · MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/RefreshTokenRequestValidator.cs:10 · Level 1 · class

      -
    • What it is: the sibling rule set for RefreshTokenRequest: both tokens - must be non-empty - (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/RefreshTokenRequestValidator.cs:10-20).
    • -
    • Depends on: FluentValidation's AbstractValidator<T> (line 1) and - RefreshTokenRequest (line 2). Structurally identical to - LoginRequestValidator; the shared shape and the "stop short on purpose" - rationale are taught there.
    • -
    • Walkthrough: one constructor (line 12), two NotEmpty rules: AccessToken (lines 14-15, - "Access token is required.") and RefreshToken (lines 17-18, "Refresh token is required."). The doc - comment (lines 6-9) explains why both are mandatory even though only one is the credential: the - expired access token is what the workflow parses claims out of, and the refresh token is what it - compares for rotation. Neither is optional because the refresh flow needs both halves - (AuthenticationServiceBase.cs:230 reads the principal out of the access token, - :259 compares the refresh token).
    • -
    • Why it's built this way: same reasoning as its login sibling. It also validates no token - format, which is correct: an unparsable or tampered access token is rejected by signature - validation inside GetPrincipalFromExpiredToken - (ITokenService), not by a string rule that would only tell an attacker which of - their guesses were shaped right.
    • -
    • Where it's used: registered by the same - AddValidatorsFromAssemblyContaining<ClassReference>() call - (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:40), bundled into - AuthenticationValidators as the IValidator<RefreshTokenRequest>, and - run first by - AuthenticationServiceBase<TUser>.RefreshTokenAsync - (AuthenticationServiceBase.cs:222-226).
    • +
    • What it is: the validator for RefreshTokenRequest. Both fields are + required and nothing more is checked + (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/RefreshTokenRequestValidator.cs:14-18).
    • +
    • Depends on: RefreshTokenRequest; FluentValidation's + AbstractValidator<T>.
    • +
    • Concept: the shape is the one + ForgotPasswordRequestValidator introduced. What this validator + teaches is why both fields are mandatory, which the doc comment states: the expired access token + is needed "for claim extraction" and the refresh token "for rotation verification" + (RefreshTokenRequestValidator.cs:7-8). [Rubric §11, Security]: refresh-token rotation + (ADR-050) verifies the + presented refresh token against the one stored for the identity carried by the access token, so + a request missing either half cannot be evaluated at all. Deliberately absent: any JWT + well-formedness or signature check. Parsing a token is the token service's job, and doing it here + would duplicate the trust boundary in a layer that has no key material.
    • +
    • Walkthrough: two single-stage RuleFor(...).NotEmpty() chains with explicit messages + (RefreshTokenRequestValidator.cs:14-18).
    • +
    • Why it's built this way: keeping the validator to presence checks leaves exactly one place + where a token's authenticity is decided, which is what makes the refresh endpoint's failure + responses uniform.
    • +
    • Where it's used: picked up by the same assembly scan + (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:48, named in the comment + at :46) and injected as IValidator<RefreshTokenRequest> into + AuthenticationValidators + (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationValidators.cs:19,28).
    • +
    +

    ResetPasswordRequestValidator

    +
    +

    MMCA.Common.Application · MMCA.Common.Application.Auth.Validation · MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ResetPasswordRequestValidator.cs:12 · Level 1 · class

    +
    +
      +
    • What it is: the validator for ResetPasswordRequest: address shape on + Email, presence on Token, and the shared strong-password policy on NewPassword + (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ResetPasswordRequestValidator.cs:16-23).
    • +
    • Depends on: ResetPasswordRequest; + StrongPasswordRules<T>; FluentValidation's + AbstractValidator<T> and its Include composition.
    • +
    • Concept introduced, composing a rule set with Include. [Rubric §11, Security] assesses + whether a policy holds on every path that can change the guarded value, and [Rubric §1, SOLID] + the single-responsibility split that makes that possible. A password-complexity policy is only a + policy if every write path enforces it; if registration demands an uppercase letter and reset + does not, reset is a documented downgrade route. FluentValidation's Include merges another + validator's rules for the same model type into this one, so the policy can live in exactly one + class and be pulled into each writer. The doc comment states the intent: the new password goes + through "the same StrongPasswordRules<T> the registration and change-password requests use, so a + reset cannot be a way around the complexity policy" (ResetPasswordRequestValidator.cs:8-10). + StrongPasswordRules<T> is generic over the containing model and takes a selector expression, which + is what lets one rule set attach to a differently-shaped request each time + (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:97-108): it + enforces non-empty, 8 to 128 characters, and one each of uppercase, lowercase, digit, and + non-alphanumeric.
    • +
    • Walkthrough: three statements in a block-bodied constructor + (ResetPasswordRequestValidator.cs:13-24). Email gets NotEmpty().EmailAddress() (:16-18), + matching the forgot-password half so the two steps agree on what an address is. Token gets + NotEmpty() with a reset-specific message (:20-21); no format check, because the token's + validity is a lookup, not a shape. Then Include(new StrongPasswordRules<ResetPasswordRequest>(x => x.NewPassword)) (:23) grafts the seven policy rules onto the NewPassword field. Note the + contrast with the weaker sibling + PasswordRules<T> + (CommonValidationRules.cs:83-90), which enforces length only; reset deliberately takes the strong + one.
    • +
    • Why it's built this way: the reset flow is + ADR-091, and the + hashing the accepted password ends up under is + ADR-032. Neither is this + validator's concern, which is the point: it only decides whether the candidate is policy-compliant.
    • +
    • Where it's used: registered by the assembly scan + (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:48) and reached through + CommandRequestValidator<TCommand, TRequest> + for any command implementing ICommandWithRequest<ResetPasswordRequest>, the constraint + ResetPasswordHandlerBase<TUser, TCommand> + declares + (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:37). + The request arrives at + PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand> + (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:108), + which is why a policy failure surfaces as the documented 400 + (PasswordResetAuthControllerBase.cs:104) while a bad token collapses to 401 + (PasswordResetAuthControllerBase.cs:96-97,105).

    UserDataExportDTO

    -

    MMCA.Common.Shared · MMCA.Common.Shared.Privacy · MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:15 · Level 1 · record (sealed)

    -
    -
      -
    • What it is: the portable data-subject export package (GDPR/CCPA access and portability): a - snapshot of the account itself plus one UserDataExportSectionDTO - envelope per registered section of the user's data - (MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:5-49).

      -
    • -
    • Depends on: System.Runtime.Serialization's DataContract / DataMember (BCL, line 1), the - UserIdentifierType alias, and UserDataExportSectionDTO (line 48). - Nothing else: it is a pure contract type, which is why it sits in Shared where the Application - handler, the API controller and any client can all see it.

      -
    • -
    • Concept introduced, the versioned envelope with app-owned payloads. [Rubric §9, API & Contract Design] assesses whether a contract can evolve without breaking readers, and [Rubric §30, Compliance / Privacy / Data Governance] assesses whether personal data is handled with an explicit, - documented shape. Two design choices carry the whole idea:

      -
        -
      1. FormatVersion is read before parsing (lines 17-22). The framework owns the envelope, so - when the envelope changes a consumer can detect it rather than guess. Crucially the version - covers the envelope only: an app changing its own subject or section payloads does not move it - (ExportUserDataHandlerBase.cs:57-61, where the constant CurrentFormatVersion = "1.0" lives).
      2. -
      3. Subject and each section's Data are typed object (lines 32-40). This looks like a lost - type, and the doc comment explains why it is the point: the framework owns the envelope, each app - owns which of its fields are portable personal data, and a property typed object serializes - by its runtime type under System.Text.Json. So ADC can put its - UserDataExportSubjectDTO in that slot and - Store can put a different one, with no generic parameter threaded through the controller, the - handler and the response type. The cost is that a reader deserializing back into this record - gets a JsonElement rather than the app's type, which is exactly what the round-trip test - asserts - (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/Privacy/DataExportControllerBaseTests.cs:99).
      4. -
      -

      The third thing to internalise is the header comment (lines 8-12): this document is PII by - design. It exists to hand a data subject everything an app holds about them, so it must only ever - be produced for the account owner (or a privileged role) and must never be logged, cached, or - persisted by the pipeline that serves it. The producer honours that literally: the export query - implements no IQueryCacheable, so the caching decorator does not apply to it - (ExportUserDataHandlerBase.cs:42-45). [Rubric §11, Security] and [Rubric §13, Observability & Operability] pull in opposite directions here, and privacy wins: this is one payload you do not log.

      -
    • -
    • Walkthrough: five init-only properties, explicit [DataMember(Order = n)] on each so the wire - order is declared rather than incidental.

      -
        -
      • FormatVersion (required, Order 1, line 22): the envelope version, described above.
      • -
      • GeneratedOn (required, Order 2, line 26): the UTC instant the export was produced, sourced - from the injected TimeProvider (ExportUserDataHandlerBase.cs:113), never DateTime.UtcNow.
      • -
      • UserId (required, Order 3, line 30): the subject the export describes, in the - UserIdentifierType alias.
      • -
      • Subject (Order 4, line 40): the app's account snapshot, object?, null when the app publishes - no subject fields.
      • -
      • Sections (Order 5, line 48): the section envelopes, defaulting to [] so an export with no - registered contributors is an empty list rather than a null a reader has to guard. The doc comment - (lines 42-46) pins the two guarantees a consumer relies on: the order is the registration order, - and a section that could not be produced is still present reporting Available = false, so - "no data" is distinguishable from "not retrieved".
      • -
      -

      The three required members mean the compiler refuses a package missing its version, timestamp or - subject id: the three facts that make the document self-describing.

      -
    • -
    • Why it's built this way: ADR-076 - hoists the export idiom ADC and Store each wrote by hand into one framework contract, mirroring the - delete-handler shape, and makes per-section degradation the rule. sealed record with init-only - members gives an immutable, structurally-equal document, which is what lets a test compare an - assembled package by value. Erasure (the other half of the data-subject story) is a separate - decision, ADR-005.

      -
    • -
    • Where it's used: assembled by - ExportUserDataHandlerBase<TUser, TQuery>, - whose whole contract is IQueryHandler<TQuery, Result<UserDataExportDTO>> - (ExportUserDataHandlerBase.cs:53, assembly at :110-117). Served by - DataExportControllerBase<TQuery>, - which deliberately serializes it to UTF-8 bytes and returns a File(...) download rather than - Ok(export), because the document exists to be saved by the person it describes - (DataExportControllerBase.cs:104-110), naming the file from the package's own GeneratedOn so the - name and the document always agree (:134-135).

      -
    • -
    • Caveats / not-in-source: the shipped controller base has no production subclass in this - workspace today. Both apps keep their earlier standalone export endpoints, which return the same - UserDataExportDTO inline via Ok(result.Value) with no feature gate and no file download - (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:153-168, - MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/UsersController.cs:39-54). - ADR-076 records that non-adoption explicitly; the type itself is shared by both paths.

      -
    • +

      MMCA.Common.Shared · MMCA.Common.Shared.Privacy · MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:15 · Level 1 · record

      +
    +
      +
    • What it is: the whole data-subject export package: a format version, a generation timestamp, + the subject's id, an app-owned snapshot of the account itself, and a list of + UserDataExportSectionDTO envelopes + (MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/UserDataExportDTO.cs:15-49).
    • +
    • Depends on: UserDataExportSectionDTO; the + UserIdentifierType alias (ADR-085); + System.Runtime.Serialization attributes (BCL).
    • +
    • Concept introduced, the versioned, PII-by-design document. [Rubric §30, Compliance / Privacy / Data Governance] assesses how personal data is classified and handled. Most DTOs in this codebase + carry incidental personal data; this one is personal data end to end, and the type says so in + bold in its own summary: "This document is PII by design. It exists to hand a data subject + everything an app holds about them, so it must only ever be produced for the account owner (or a + privileged role) and must never be logged, cached, or persisted by the pipeline that serves it" + (UserDataExportDTO.cs:9-12). That single comment is what makes three otherwise-invisible + decisions legible: the query is not IQueryCacheable, so the caching decorator never sees it + (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/ExportUserDataHandlerBase.cs:42-45); + the degradation path logs the exception but hands the subject a generic reason + (ExportUserDataHandlerBase.cs:187-190); and the controller serializes to bytes and returns a + file rather than an ObjectResult + (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Privacy/DataExportControllerBase.cs:104-110). + [Rubric §9, API & Contract Design]: FormatVersion versions "the export document shape itself + (not the app's data)" (UserDataExportDTO.cs:18-19), so a consumer parsing an old file can detect + an envelope change rather than guess at it.
    • +
    • Walkthrough: five init-only properties under [DataContract], each with an explicit + [DataMember(Order = n)] (UserDataExportDTO.cs:21,25,29,39,47) pinning field order into the + contract.
        +
      • FormatVersion, GeneratedOn, and UserId are required (:22,26,30), so the envelope cannot + be constructed without them.
      • +
      • Subject is object? (:40), and the doc comment gives the full reasoning: the framework owns + the envelope, each app owns which of its own fields are portable personal data, and an + object-typed property serializes by its runtime type under System.Text.Json (:32-38). + That last clause is the mechanism that makes the erasure of the static type harmless. null is + legal and means the app publishes no subject fields.
      • +
      • Sections defaults to an empty collection expression, = [] (:48), so an export with no + registered contributors is a well-formed document rather than a null-bearing one. Order is the + section registration order, which the comment makes part of the contract (:42-45).
      -

      ILoginProtectionService

      -
      -

      MMCA.Common.Application · MMCA.Common.Application.Auth · MMCA.Common/Source/Core/MMCA.Common.Application/Auth/ILoginProtectionService.cs:10 · Level 3 · interface

      -
      -
        -
      • What it is: the application-layer contract for brute-force and rate-limit protection on - authentication endpoints: lockout checks, failed-attempt increments, successful-login resets, and - registration rate-limiting per IP address.

        -
      • -
      • Depends on: Result (MMCA.Common.Shared.Abstractions, - line 1).

        -
      • -
      • Concept introduced, rate-limiting as a first-class application concern. [Rubric §11, Security] (assesses brute-force protection on auth flows) and [Rubric §10, Cross-Cutting Concerns] (rate-limiting extracted to a port so the application layer reasons about it without - coupling to a specific store; the doc comment, lines 7-8, names both a distributed and an in-memory - cache as valid backers). Returning Result from - CheckLockoutAsync (line 18) and CheckRegistrationRateLimitAsync (line 42) makes "account is - locked out" a normal control-flow branch rather than a thrown exception.

        -
      • -
      • Walkthrough: five async methods, split into two scopes.

        -
          -
        • Email-scoped (failed-login lockout): CheckLockoutAsync (line 18) returns a failure result - when the email is currently locked; IncrementFailedAttemptsAsync (line 26) records a failure and, - per the doc comment (lines 20-22), applies exponential-backoff lockout once the max is - exceeded; ResetFailedAttemptsAsync (line 33) clears the counter after a successful login.
        • -
        • IP-scoped (registration flood): CheckRegistrationRateLimitAsync (line 42) and - IncrementRegistrationCountAsync (line 49) throttle account creation per client IP. Both accept a - nullable ipAddress and skip the check when it is null (so a host that cannot resolve the - caller IP degrades to no limit rather than blocking everyone); CheckRegistrationRateLimitAsync - returns Result.Success() in that case (doc comment, lines 36-37).
        • -
        -

        All five methods take a CancellationToken with a default argument, per convention.

        -
      • -
      • Why it's built this way: keeping the protection policy behind an interface lets the shared - authentication workflow compose it in while the concrete cache mechanics stay in the implementation; - the null-IP "skip" keeps the limiter from becoming an availability hazard - (ADR-029).

        -
      • -
      • Where it's used: injected into AuthenticationServiceBase<TUser> - (constructor, AuthenticationServiceBase.cs:38), which calls all five methods across its login and - registration flows (:84, :99, :114, :128, :146, :207); the concrete, cache-backed - LoginProtectionService (tuned by - LoginProtectionSettings) implements it.

      • -
      -

      SoftDeletedUserCache

      -
      -

      MMCA.Common.Application · MMCA.Common.Application.Auth · MMCA.Common/Source/Core/MMCA.Common.Application/Auth/SoftDeletedUserCache.cs:17 · Level 4 · class (static)

      -
      -
        -
      • What it is: the shared cache contract for the soft-deleted user marker (BR-133): the key - shape, the marker lifetime, and a one-call helper that writes it. The API middleware reads the - marker on every authenticated request; the module that soft-deletes a user writes it - (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/SoftDeletedUserCache.cs:6-10).

        -
      • -
      • Depends on: ICacheService (line 2), the - UserIdentifierType alias, and System.Globalization.CultureInfo (BCL, line 1).

        -
      • -
      • Concept introduced, revoking a stateless credential without a per-request lookup. [Rubric §11, Security] assesses whether a revoked principal actually loses access, and [Rubric §10, Cross-Cutting Concerns] assesses whether such a concern is factored so both ends share one - definition. A JWT is a bearer credential: signature validation never asks "is this account still - active?", so soft-deleting a user leaves their already-issued access token passing validation until - it expires - (ADR-047). The - textbook fixes (a deny-list, or an account-status query on every request) reintroduce exactly the - per-request state that stateless JWT was chosen to avoid. This type is the middle path: a short-lived - cache marker written at deletion time and read cheaply on the hot path.

        -

        The remarks (lines 11-16) explain why the constants live in the Application layer rather than - next to the middleware that reads them: a downstream application deleting an account has to write - the exact same key the middleware reads, and a private constant in the presentation layer is - unreachable from an application-layer command handler. Same reasoning as - IdempotencyHeaders, applied one layer up.

        -
      • -
      • Walkthrough: three static members, no state.

        -
          -
        • MarkerDuration => TimeSpan.FromSeconds(30) (line 29). The remarks (lines 22-28) justify the - number rather than leaving it magic: the marker only has to cover the window between the delete - committing and the next token validation, because once it expires the validator query is the - source of truth again and gives the same answer. Short-lived access tokens (15 minutes, the BR-205 - default on ITokenService) bound the rest of the exposure, so a longer marker - would buy nothing and would keep stale entries alive for users who were never deleted.
        • -
        • KeyFor(UserIdentifierType userId) (lines 42-43): builds user:deleted:{userId} through - string.Create(CultureInfo.InvariantCulture, ...). The remarks (lines 36-41) name the bug this - prevents: an identifier renders differently under some cultures (digit shapes, group separators), - so a culture-sensitive key would be written under one request's culture and missed under another, - silently letting a deleted user keep making requests. This is a case where the analyzer rule about - culture-invariant formatting is guarding a security property, not just a formatting nicety.
        • -
        • MarkDeletedAsync(ICacheService cache, UserIdentifierType userId, CancellationToken) (lines - 53-61): null-guards the cache (line 58) and writes true under KeyFor(userId) for - MarkerDuration (line 60). It returns the task without awaiting, so there is no extra async state - machine for a one-call passthrough.
        • -
        -
      • -
      • Why it's built this way: publishing the key shape and the TTL as framework API is what keeps the - writer and the reader honest, and it is a precondition for the module boundary in - ADR-047: - Identity owns the delete, every service hosts the middleware, and the only thing they share is a - cache entry rather than a database. [Rubric §7, Microservices Readiness] applies directly, an - extracted service can enforce the revocation without a reference to the Identity database.

        -
      • -
      • Where it's used: read by - SoftDeletedUserMiddleware, which - builds the key (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/SoftDeletedUserMiddleware.cs:85), - short-circuits with 401 when the marker is true (:102-106), and on a miss falls back to the - validator query and caches that answer, deleted or not, for the same MarkerDuration - (:131-133). Written by the Identity delete path: ADC's - DeleteUserHandler queues it as an after-commit - action and swallows a cache fault so a failed marker cannot turn a successful erasure into an error - the caller would retry - (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs:68-80, - inside the OnAfterSoftDeleteAsync override at :46).

        -
      • -
      • Caveats / not-in-source: the marker is best effort on both ends by design. The middleware fails - open on a cache outage (falling through to the validator query, and proceeding if that is also - unavailable, SoftDeletedUserMiddleware.cs:93-100 and :118-125), and the writer logs and - continues on a cache fault. The exposure that leaves is bounded by the access-token lifetime, which - is the trade-off ADR-047 accepts explicitly. ADC's handler is the only writer in the source tree - today; MMCA.Store soft-deletes users without writing the marker.

        -
      • -
      -

      AuthenticationValidators

      -
      -

      MMCA.Common.Application · MMCA.Common.Application.Auth · MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationValidators.cs:16 · Level 5 · class (sealed)

      -
      -
        -
      • What it is: a tiny parameter object that bundles the three FluentValidation validators the - authentication workflow needs (login, registration, refresh) into one injectable dependency.
      • -
      • Depends on: FluentValidation's IValidator<T> (NuGet, line 1) over the request DTOs - LoginRequest, RegisterRequest, and - RefreshTokenRequest (all in MMCA.Common.Shared.Auth, line 2).
      • -
      • Concept introduced, the parameter object as a constructor-arity guardrail. [Rubric §1, SOLID] - (assesses whether a class stays a single, cohesive responsibility rather than sprawling into a - god-class) and [Rubric §16, Maintainability & Evolvability] (assesses whether cross-cutting - dependencies are grouped so a class can grow without exploding its constructor). The doc comment - (lines 6-12) states the exact motive: collapsing three closely-related dependencies into one keeps - the app's AuthenticationService below the application-service constructor-arity ceiling (a - god-class analyzer guardrail) without giving up per-request validation. Because the request DTOs - already live in MMCA.Common.Shared.Auth, the bundle is app-agnostic, which is why it could be - hoisted out of the apps into the framework.
      • -
      • Walkthrough: a primary constructor takes the three IValidator<T> instances (lines 16-19), and - three get-only properties surface them by name: Login (line 22), Register (line 25), and - Refresh (line 28), each assigned from its matching constructor parameter. There is no logic here; - the type exists purely to shrink the dependency footprint of its consumer.
      • -
      • Why it's built this way: a sealed grouping type with get-only properties is the cheapest way to - fold three cohesive dependencies into one constructor slot, so the workflow base can validate each - request shape without pushing its constructor over the arity limit; DI resolves the three underlying - validators and composes them into this one object. Two of the three - (LoginRequestValidator, - RefreshTokenRequestValidator) come from the framework assembly, - while IValidator<RegisterRequest> is satisfied by the app's own RegisterRequestValidator, so the - bundle is the point where framework and app validation meet.
      • -
      • Where it's used: injected into AuthenticationServiceBase<TUser> - (constructor, AuthenticationServiceBase.cs:40), whose LoginAsync/RegisterAsync/RefreshTokenAsync - call validators.Login (:77), validators.Register (:139), and validators.Refresh (:222) - respectively before doing any work.
      • -
      -

      IAuthenticationService

      -
      -

      MMCA.Common.Application · MMCA.Common.Application.Auth · MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IAuthenticationService.cs:11 · Level 5 · interface

      -
      -
        -
      • What it is: the application-layer contract for the Identity module's authentication workflows: - login, registration, token refresh, token revocation, and external (OAuth) login.

        -
      • -
      • Depends on: LoginRequest, RefreshTokenRequest, - RegisterRequest, AuthenticationResponse, - Result, - Error, and the UserIdentifierType alias.

        -
      • -
      • Concept introduced, default interface methods for optional capabilities. [Rubric §1, SOLID] - (Interface Segregation and Dependency Inversion): ExternalLoginAsync (lines 66-74) ships a - default implementation in the interface itself that returns a "not supported" - Error.Failure ("Auth.ExternalLoginNotSupported"). An - implementation that does not offer OAuth (a stub host, or a deployment with social login disabled) - inherits that failure for free and need not override anything, so the interface stays one piece while - the capability is opt-in (ADR-036). - [Rubric §11, Security]: login, registration, and refresh all return - Result<AuthenticationResponse>, so auth outcomes flow as values and no exception leaks credential - detail to the caller.

        -
      • -
      • Walkthrough: five methods, all async, all taking a CancellationToken.

        -
          -
        • LoginAsync(LoginRequest) returns Result<AuthenticationResponse> (line 19).
        • -
        • RegisterAsync(RegisterRequest, string? ipAddress = null) (line 30); the optional ipAddress - feeds ILoginProtectionService's registration rate limit.
        • -
        • RefreshTokenAsync(RefreshTokenRequest) (line 41) rotates the token pair.
        • -
        • RevokeTokenAsync(UserIdentifierType userId) returns Result (line 51) and revokes a user's - refresh token, returning a not-found error when there is none.
        • -
        • ExternalLoginAsync(loginProvider, providerKey, email, firstName, lastName) (line 66), the - default-implemented OAuth path; finds an account by provider and key or creates one from claims.
        • -
        -

        The doc comment (lines 6-9) also records a scope decision: password change is not on this - interface. It is dispatched directly through its own command handler at the controller layer.

        -
      • -
      • Why it's built this way: concentrating the token-issuing workflows behind one port keeps the - Identity controllers thin and lets the protection/rate-limit policy - (ILoginProtectionService) compose in; the default OAuth method keeps - the contract stable across hosts that do and do not enable social login.

        -
      • -
      • Where it's used: implemented by AuthenticationServiceBase<TUser> - (which realises every member except the default ExternalLoginAsync) and, through it, by each app's - sealed AuthenticationService; consumed by the - Identity API controllers.

        -
      • -
      -

      AuthenticationServiceBase<TUser>

      -
      -

      MMCA.Common.Application · MMCA.Common.Application.Auth · MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:34 · Level 8 · class (abstract)

      -
      -
        -
      • What it is: the shared authentication workflow (login, registration, token refresh and - rotation, revocation) hoisted once into the framework, generic over the app's User aggregate. It - realises IAuthenticationService and leaves the genuinely app-specific - decisions to a small set of abstract/virtual hooks a sealed subclass overrides.
      • -
      • Depends on: IUnitOfWork and - IRepository<TEntity, TIdentifierType> - (persistence, G07), ITokenService, IPasswordHasher, - ILoginProtectionService, AuthenticationValidators - (this group), the IAuthUser credential contract plus - AuditableAggregateRootEntity<TIdentifierType> - as the TUser constraint (line 41), Email (normalising the - login/register email), Result / - Error, the request/response DTOs - (LoginRequest, RegisterRequest, - RefreshTokenRequest, AuthenticationResponse), - and the BCL TimeProvider (injected, never DateTime.UtcNow, so the clock is testable).
      • -
      • Concept introduced, the Template Method that de-duplicates a whole vertical slice. [Rubric §2, Design Patterns] (assesses idiomatic pattern use): this is a textbook Template Method, the - invariant sequence of an operation lives in the base while the variable steps are deferred to - subclass hooks. [Rubric §16, Maintainability & Evolvability] (DRY across services) and [Rubric §1, SOLID]: the doc comment (lines 11-32) records that the app Identity modules previously duplicated - this workflow at roughly 70-95% line-identity; folding it here means a fix to the lockout order or - the refresh-rotation logic is written once. [Rubric §11, Security]: the base encodes the security - posture directly, validate-first, an ILoginProtectionService - lockout/rate-limit gate - (ADR-029), an - untracked-then-tracked dual fetch - (ADR-004), and - refresh-token rotation with reuse detection - (ADR-050, BR-205/206). - [Rubric §7, Microservices Readiness]: the workflow depends only on ports (IUnitOfWork, - ITokenService, ...) so it runs unchanged whether the Identity module is in-monolith or its own - service.
      • -
      • Walkthrough (members in teaching order):
          -
        • Constructor + protected accessors (lines 34-54): a primary constructor takes the six - collaborators; protected read-only properties re-expose UnitOfWork (line 44), TokenService - (line 47), TimeProvider (line 50) and a Repository (lines 53-54) resolved lazily as - unitOfWork.GetRepository<TUser, UserIdentifierType>(), so subclass hooks and app-level flows - (external login) reuse them without re-injecting.

          -
        • -
        • Token lifetimes (lines 61-70): virtual AccessTokenLifetime and RefreshTokenLifetime read - through to ITokenService (which derives them from Jwt:AccessTokenExpirationMinutes - and Jwt:RefreshTokenExpirationDays), so the expiry reported to the client matches the JWT's - actual exp. A non-positive value, meaning a hand-written test double or a misconfigured host, - falls back to the BR-205 defaults of 15 minutes and 7 days - (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/ITokenService.cs:33 - and :40 carry the same defaults on the port).

          -
        • -
        • LoginAsync (lines 73-131): validate the request (lines 77-81), check lockout (line 84, - ADR-029 / - BR-212), normalise the raw email into an Email value - object (line 92) so the EF predicate compares same-typed converted values (an invalid email yields - a null value object that simply matches no user, which is the invalid-credentials answer anyway). - Step 1 is an untracked fetch via the FindUntrackedByEmailAsync hook (line 96) to verify - credentials without change-tracker overhead; a null result increments failed attempts and returns - a generic 401 (lines 97-102). An app gate runs before password verification (line 106, no - failed-attempt increment so the pre-hoist behaviour is preserved), then - passwordHasher.VerifyPassword (line 112). Step 2 is a tracked re-fetch by id (line 120) so - the new refresh token can be persisted, followed by ResetFailedAttemptsAsync (line 128) and - IssueTokensAsync (line 130).

          -
        • -
        • RegisterAsync (lines 134-215): validate (lines 139-143), IP rate-limit (line 146, - ADR-029 / - BR-213), reject a duplicate email through the EmailExistsAsync hook (lines 153-157), hash the - password (line 159), build the user through the CreateUser hook (line 160), mint and store a - refresh token (lines 167-168), AddAsync (line 170) and SaveChangesAsync (line 174), then run - the OnUserRegisteredAsync post-commit hook (line 204) to pick up the instance the first access - token is minted from, increment the IP registration count (line 207), and return the token pair - (lines 209-214).

          -

          The save is wrapped in a deliberately broad catch (Exception) (lines 172-200, with a scoped - CA1031 suppression) whose comment is the teaching material. The email lookup above is a - check-then-act: two concurrent registrations for the same address both pass it, and the loser only - fails on the insert, against the unique index every consumer puts on Email (ADC unfiltered, Store - filtered on IsDeleted). Without the catch, that race surfaces as a generic 500 instead of the 409 - a serialized pair would have produced. The catch cannot name DbUpdateException, because - Application has no EF Core dependency by layer rule, so the re-check is what narrows it (line - 194): if the address exists now, the concurrent registration is the cause and the caller gets the - same conflict the serial path returns through the shared EmailAlreadyExistsFailure() helper; - anything else rethrows untouched (line 199) and still reaches the exception middleware. The - re-check passes CancellationToken.None on purpose (lines 192-194): it has to run even when the - caller's token is what aborted the save, or a cancelled save could never be classified.

          -
        • -
        • RefreshTokenAsync (lines 218-269): validate (lines 222-226), pull claims from the expired - JWT via tokenService.GetPrincipalFromExpiredToken (line 230, signature still checked, only - lifetime skipped), read the user_id claim (lines 237-242), load the tracked user (line 244), run - the refresh app gate (line 251), then the security-critical check (line 259): if the stored - RefreshToken does not match or has expired, this is treated as token reuse (potential theft), - so user.RevokeRefreshToken() is called and saved (lines 261-262, BR-206) before returning a 401. - A clean match issues a rotated pair through IssueTokensAsync (line 268).

          -
        • -
        • RevokeTokenAsync (lines 272-286): load by id, RevokeRefreshToken(), save; a missing user - yields Error.NotFound targeted at typeof(TUser).Name (line 279).

          -
        • -
        • IssueTokensAsync (lines 292-306): the shared rotation used by login and refresh (and reusable - by app-level external login), mints an access token via the CreateAccessToken hook, generates a - new refresh token, stamps its expiry off TimeProvider, saves, and returns the response.

          -
        • -
        • The hooks: four abstract (a subclass must supply them). FindUntrackedByEmailAsync (line

          -
            -
          1. and EmailExistsAsync (line 319) are deliberately written against the app's concrete User - so EF translates the predicate byte-for-byte as before, and the second explicitly leaves the app to - decide whether soft-deleted accounts count (ignoreQueryFilters: true blocks re-registration of an - erased email, lines 315-318); CreateUser (line 322) runs the app's domain factory; - CreateAccessToken (line 325) mints the app's claim set (for example speaker_id vs - customer_id). Four virtual hooks default to a no-op: ValidateLoginCandidateAsync (line 328) - and ValidateRefreshCandidateAsync (line 332) add extra gates such as a deactivated-account check; - OnUserRegisteredAsync (line 339) runs the post-commit side-effect (publish an integration event - or re-fetch a linked id); and CreateRefreshUserMissingError (line 347) defaults the vanished-user - case to 401 (a token for a missing user is indistinguishable from an invalid one) while letting an - app return 404 where its public contract already promises it. One private static helper, - EmailAlreadyExistsFailure() (lines 355-357), returns the Auth.EmailAlreadyExists conflict so - the up-front check and the race recovery are indistinguishable to the caller.
          2. -
          -
        • -
        -
      • -
      • Why it's built this way: the untracked-then-tracked dual fetch keeps the common - credential-verification path off the change tracker (cheaper, and soft-deleted accounts fall out via - EF query filters returning the generic 401) while still giving a tracked instance to persist the new - token (ADR-004). - Refresh-token reuse detection (revoke-on-mismatch) is the BR-206 defence against a stolen token being - replayed (ADR-050). - Password material flows through IAuthUser's PasswordHash/PasswordSalt - (ADR-032), and the whole workflow - depends only on abstractions, so it is identical whether the module runs in-process or as an - extracted service.
      • -
      • Where it's used: subclassed by each app's sealed - AuthenticationService (for example - MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:35, - which binds TUser = User, adds the Attendee default role (BR-45) and the speaker_id claim - (BR-209, built at :249-252), and re-lists IAuthenticationService so it can re-implement - RegisterAsync (:57-62) and ExternalLoginAsync (:130-137) outright: ADC raises its - registration side-effects inside one ExecuteInTransactionAsync unit rather than through the - OnUserRegisteredAsync hook, because the identity column means the id does not exist until the first - save (AuthenticationService.cs:16-32, :44). MMCA.Store supplies its own subclass with a - customer_id claim. Consumed by the Identity API controllers via the - IAuthenticationService port.
      • -
      • Caveats / not-in-source: the user_id claim is parsed with int.TryParse (line 238), so the - refresh flow assumes UserIdentifierType is int (the framework alias today, per - ADR-048); an app - that redefined the alias would need to override the refresh handling. ExternalLoginAsync is - intentionally not overridden here: the base inherits the interface's default "not supported" - failure, and OAuth account linking stays in the app subclass because it is coupled to the app's - User factory surface (doc comment, lines 30-31).
      • +
      • Why it's built this way: + ADR-076 hoisted this shape out + of two near-identical app implementations. It is the export half of the data-subject obligation + whose erasure half was settled by + ADR-005, which explicitly + scoped export out and left it to consumers + (Website/docs-src/adr/076-data-subject-export.md:16-19).
      • +
      • Where it's used: it is the result type of the export query all the way through the stack. + ExportUserDataHandlerBase<TUser, TQuery> + implements IQueryHandler<TQuery, Result<UserDataExportDTO>> + (ExportUserDataHandlerBase.cs:53), stamps CurrentFormatVersion = "1.0" into it + (ExportUserDataHandlerBase.cs:61,112), and takes GeneratedOn from an injected TimeProvider + rather than a static clock (ExportUserDataHandlerBase.cs:113). + DataExportControllerBase<TQuery> + declares it as the 200 response type (DataExportControllerBase.cs:79) and derives the download + file name from the package's own GeneratedOn so the file name and the document can never disagree + (DataExportControllerBase.cs:126-135). Both apps subclass the handler + (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/ExportUserDataHandler.cs:35, + MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ExportUserData/ExportUserDataHandler.cs:39) + and expose it from their own UsersController.ExportAsync + (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:158-161, + MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/UsersController.cs:36-39).
      • +
      • Caveats / not-in-source: the "never logged, cached, or persisted" rule is a documented + discipline, not something the type or an analyzer enforces. Nothing stops a future handler from + marking its export query cacheable; the only guard today is that no shipped query does.

      ⬅ Persistence & EF CoreIndexCaching ➡

      @@ -3466,6 +3464,7 @@

      AuthenticationServiceBase<TUser>The shared authentication workflow

    • What the app's User aggregate must expose
    • Passwords and brute-force protection
    • +
    • Forgot password: a cache-backed single-use token
    • Reading identity from claims
    • Authorization: roles, permissions, ownership
    • Session cookies: keeping SSR authenticated
    • diff --git a/docs/onboarding/group-12-api-hosting-mapping.html b/docs/onboarding/group-12-api-hosting-mapping.html index aaf110c..2c34427 100644 --- a/docs/onboarding/group-12-api-hosting-mapping.html +++ b/docs/onboarding/group-12-api-hosting-mapping.html @@ -155,17 +155,18 @@

      12. API Host JwtForwardingDelegatingHandler), and MMCA.Common.Shared (the DTO vocabulary and SupportedCultures). The group has seven interlocking concerns: the composition root that registers the whole edge; the middleware pipeline every - request flows through in a fixed order; the error translation that keeps every failure shaped like - RFC 9457 Problem Details; the controller hierarchy that hands a module ready-made CRUD, export, - auth, and service-discovery endpoints; the write-safety controls (idempotency keys and conditional - writes) that make a retried or racing write predictable; the contract surface (DTO/request mapping, - JSON conversion, model binding, correlation, tenancy, feature gating, output caching); and the - well-known endpoints that make an extracted service self-describing. Read the group as the - reusable ASP.NET host a downstream service (Store, ADC, Helpdesk, or an extracted microservice) drops - into place so its own code is nothing but modules. Its central rubric column is [Rubric §9, API & - Contract Design] (consistent, versioned, standardized contracts and error shapes), with heavy - supporting roles for [Rubric §10, Cross-Cutting Concerns], [Rubric §11, Security], [Rubric §13, - Observability & Operability], [Rubric §7, Microservices Readiness], and (since + request flows through, itself expressed as ordered data rather than a hard-coded call sequence; the + error translation that keeps every failure shaped like RFC 9457 Problem Details; the controller + hierarchy that hands a module ready-made CRUD, export, auth, recovery, and service-discovery + endpoints; the write-safety controls (idempotency keys and conditional writes) that make a retried + or racing write predictable; the contract surface (DTO/request mapping, JSON conversion, model + binding, correlation, tenancy, feature gating, output caching); and the well-known endpoints that + make an extracted service self-describing. Read the group as the reusable ASP.NET host a downstream + service (Store, ADC, Helpdesk, or an extracted microservice) drops into place so its own code is + nothing but modules. Its central rubric column is [Rubric §9, API & Contract Design] (consistent, + versioned, standardized contracts and error shapes), with heavy supporting roles for [Rubric §10, + Cross-Cutting Concerns], [Rubric §11, Security], [Rubric §13, Observability & Operability], [Rubric §7, + Microservices Readiness], and (since ADR-027) [Rubric §27, Internationalization].

      The composition root: AddAPI plus the builder extensions. A host wires the edge through two @@ -201,19 +202,19 @@

      12. API Host module-{Name} health checks, tagged module so /health?tag=module filters them (Healthy for enabled modules at :192-198, Degraded for disabled ones at :200-207). WebApplicationBuilderExtensions - (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:29) + (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:31) carries the identical builder-side setup every service shares: header-based API versioning through the - api-version header (AddCommonApiVersioning, line 233, reader at line 242, v1.0 assumed when the - header is absent at line 240, + api-version header (AddCommonApiVersioning, line 243, reader at line 252, v1.0 assumed when the + header is absent at line 250, ADR-046), rate limiting - (AddCommonRateLimiting, three overloads at lines 285, 303, and 321), Brotli and Gzip compression at - CompressionLevel.Fastest (AddCommonResponseCompression, line 363, both providers pinned to - Fastest at lines 371-372 and 376-377 because these are dynamic per-request payloads on fractional - vCPUs), OpenAPI (line 392), CORS (line 543, + (AddCommonRateLimiting, three overloads at lines 295, 313, and 331), Brotli and Gzip compression at + CompressionLevel.Fastest (AddCommonResponseCompression, line 373, both providers pinned to + Fastest at lines 381-382 and 386-387 because these are dynamic per-request payloads on fractional + vCPUs), OpenAPI (line 402), CORS (line 579, ADR-082, with the two policy - names as constants at lines 32 and 35 and the allow-any-origin policy reachable only in Development, - lines 563-568), and the two JWT bearer registrations: in-process AddCommonAuthentication (line 500) - for the Identity host and AddForwardedJwtBearer (line 430) for extracted services that validate + names as constants at lines 34 and 37 and the allow-any-origin policy reachable only in Development, + lines 592-596), and the two JWT bearer registrations: in-process AddCommonAuthentication (line 536) + for the Identity host and AddForwardedJwtBearer (line 444) for extracted services that validate against a remote JWKS. Only one DI ordering rule is load-bearing in the whole host, and it belongs to the CQRS pipeline group, not here: AddApplicationDecorators (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:89) must run last so Scrutor @@ -221,64 +222,93 @@

      12. API Host order-independent. This is the [Rubric §9, API & Contract Design] and [Rubric §10, Cross-Cutting] story: versioning, compression, rate limiting, and CORS are configured once and inherited by every service instead of copy-pasted per host.

      -

      The request pipeline, in a fixed order. +

      The request pipeline is data, not prose. Middleware order is behavior in ASP.NET Core, so the + framework does not leave it to each host's Program.cs, and it no longer even leaves it as a fixed + sequence of Use... calls. WebApplicationExtensions's UseCommonMiddlewarePipeline - (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:45) is the - single place the middleware order is decided - (ADR-079), and the - order is deliberate: exception handling (line 47), then - CorrelationIdMiddleware (48), request localization (53), forwarded - headers (79), conditional HTTPS redirect (87-89), response compression (91), routing (92), CORS - (93-95), authentication (96), TenantResolutionMiddleware (102), the - rate limiter (108), SoftDeletedUserMiddleware (109), authorization - (110), output cache (111), the JWKS and OIDC discovery endpoints (118-119), and finally - MapControllers (121). Three of those positions are worth internalizing. The rate limiter runs - after authentication on purpose - (ADR-019, comment at - WebApplicationExtensions.cs:104-107): GlobalRateLimitPartition - (WebApplicationBuilderExtensions.cs:68) partitions by the authenticated principal and routes - anonymous traffic down a no-limiter branch (lines 75-78), so HttpContext.User must already be - populated or every request would look anonymous and the per-user cap would never engage; health, - liveness, /.well-known/*, and application/grpc traffic bypass the limiter outright - (IsRateLimitBypassed, lines 50-54). Tenant resolution sits immediately after authentication for the - mirror-image reason: its claim strategy reads HttpContext.User - (TenantResolutionMiddleware.cs:111), so running it any earlier would silently demote every request - to the header strategy. And the HTTPS redirect is skipped for any request whose content type starts - with application/grpc (WebApplicationExtensions.cs:87-89) because extracted gRPC services speak - HTTP/2 cleartext (h2c) and a 307 redirect would break the call. Forwarded-headers handling clears the - known-proxy allowlists so cloud reverse proxies are trusted regardless of their internal IPs (lines - 63-64) and stashes the pre-forward scheme and host in HttpContext.Items under PreForwardedSchemeKey - and PreForwardedHostKey (lines 24, 35, 72-77). UseCommonRequestLocalization (line 133) builds the - culture options from SupportedCultures + (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:46) and its + Action<MiddlewarePipelineBuilder> overload (WebApplicationExtensions.cs:58) both route through one + private ApplyPipeline that seeds the defaults, lets the host adjust them, validates the result, and + only then applies each step (WebApplicationExtensions.cs:138-148). The steps themselves are + MiddlewarePipelineStep records + (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/MiddlewarePipelineStep.cs:21), each a stable + Name plus an Action<WebApplication> Configure delegate, both null-validated on construction + (MiddlewarePipelineStep.cs:27-30); because a step is inert data until someone runs it, the whole + order is assertable without building a host. MiddlewarePipelineBuilder + (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/MiddlewarePipelineBuilder.cs:15) owns the + list: CreateDefault (MiddlewarePipelineBuilder.cs:31) seeds the eighteen framework steps in order + (:34-156) and InsertBefore, InsertAfter, Replace, and Remove (:166, :183, :203, :224) + let a host address any of them by name, which is why + MiddlewarePipelineStepNames + (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/MiddlewarePipelineStepNames.cs:14) is public + contract rather than an implementation detail: renaming a constant there is a breaking change, and the + declaration order of those constants (:17-74) is the runtime order. Read it top to bottom and you + have the edge: exception handler, correlation id, request localization, pre-forwarded capture, + forwarded headers, HTTPS redirection, response compression, routing, CORS, authentication, tenant + resolution, rate limiting, the soft-deleted-user filter, authorization, output cache, the JWKS and OIDC + discovery endpoints, and finally the controllers. Four of those adjacencies are load-bearing, and + Build (MiddlewarePipelineBuilder.cs:257) re-checks them before a single step is applied + (:259-277): the pre-forwarded capture must run immediately before UseForwardedHeaders or the + captured scheme and host are no longer the ones the connection saw; authentication must run immediately + before tenant resolution because the claim strategy reads HttpContext.User; authentication must + precede the rate limiter (ADR-019) + because GlobalRateLimitPartition keys on the authenticated principal and an unauthenticated pipeline + would see every request as anonymous; and forwarded headers must precede the HTTPS redirect so the + redirect decision reads the proxy-reported scheme. A violation throws InvalidOperationException at + startup with the offending order printed (:325-326 and :340-341), and an invariant binds only when + both of its steps are still present (:320 and :335), so dropping a whole capability stays + legal while reordering a pair does not. Two more decisions are worth internalizing from the default + step list: the HTTPS redirect is wrapped in a UseWhen that skips any request whose content type + starts with application/grpc (MiddlewarePipelineBuilder.cs:90-92), because extracted gRPC services + speak HTTP/2 cleartext (h2c) and a 307 would break the call; and the forwarded-headers step clears the + known-proxy allowlists so cloud reverse proxies are trusted regardless of their internal IPs + (:76-77), which is safe only because the pre-forward scheme and host were already stashed in + HttpContext.Items under the two keys declared at WebApplicationExtensions.cs:22 and :33. Hosts + freeze their own resulting order with the opt-in fitness function MiddlewarePipelineOrderTestsBase + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/MiddlewarePipelineOrderTestsBase.cs:29), whose + default expectation is the framework list verbatim (:38-58), so a reorder fails a fast unit test + instead of surfacing as an unreachable jwks_uri or a rate cap that never engages + (ADR-079). Alongside + the pipeline, UseCommonRequestLocalization (WebApplicationExtensions.cs:71) builds the culture + options from SupportedCultures (MMCA.Common/Source/Core/MMCA.Common.Shared/Globalization/SupportedCultures.cs:9: en-US as the default at line 12 and the full en-US plus es list at line 18, with the qps-Ploc pseudo locale at - line 28 added in Development only, WebApplicationExtensions.cs:140-143) so edge error localization - runs under the caller's culture, and the companion MapCultureEndpoint (line 162) serves the - GET /culture/set switch that Blazor UI hosts map - (ADR-027).

      + line 28 added in Development only, WebApplicationExtensions.cs:78-81) so edge error localization runs + under the caller's culture, and the companion MapCultureEndpoint (WebApplicationExtensions.cs:100) + serves the GET /culture/set switch that Blazor UI hosts map + (ADR-027). Turning order into + inspectable, validated data is [Rubric §10, Cross-Cutting], [Rubric §14, Testability] and + [Rubric §34, Architecture Governance] in one move.

      Rate limiting: one always-on partition, one named policy, and an optional shared counter. The global limiter is active on every request and rejects with 429 above RateLimitingSettings.GlobalPermitLimit (default 300 requests per minute, MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RateLimitingSettings.cs:40) per - authenticated user. Because it deliberately no-ops for anonymous callers and account lockout is - per-email, a password spray (one password, many email addresses) from a single source would otherwise - be unthrottled. The framework closes that gap with the named auth-ip policy (RateLimitPolicyAuthIp, - WebApplicationBuilderExtensions.cs:44), whose partition selector AuthIpRateLimitPartition (lines - 210-224) is a per-client-IP window defaulting to 30 requests per minute (RateLimitingSettings.cs:47) - and fails open on an unattributable IP (lines 214-215) rather than collapsing every such request - into one shared bucket, which would throttle the in-process test server to a standstill. Unlike the - other named limiters, this one is not left for each app to attach: + authenticated user: GlobalRateLimitPartition (WebApplicationBuilderExtensions.cs:78) routes + anonymous traffic down a no-limiter branch (lines 85-88), and health, liveness, /.well-known/*, and + application/grpc traffic bypass the limiter outright (IsRateLimitBypassed, lines 60-64). Because it + deliberately no-ops for anonymous callers and account lockout is per-email, a password spray (one + password, many email addresses) from a single source would otherwise be unthrottled. The framework + closes that gap with the named auth-ip policy (RateLimitPolicyAuthIp, + WebApplicationBuilderExtensions.cs:46), whose partition selector AuthIpRateLimitPartition (line + 220) is a per-client-IP window defaulting to 30 requests per minute (RateLimitingSettings.cs:47) and + fails open on an unattributable IP (lines 224-225) rather than collapsing every such request into + one shared bucket, which would throttle the in-process test server to a standstill. Unlike the other + named limiters, this one is not left for each app to attach: AuthControllerBase carries [EnableRateLimiting(WebApplicationBuilderExtensions.RateLimitPolicyAuthIp)] on both LoginAsync (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:57) and - RegisterAsync (AuthControllerBase.cs:79), so every consumer inherits spray protection by - construction, while RefreshAsync (AuthControllerBase.cs:98-103) is deliberately left unthrottled - because refresh is periodic and automatic and Blazor Server circuits issue it server-side from one - shared host IP. A consumer that inherits the base without calling AddCommonRateLimiting fails at - startup on an unregistered policy, which is the loud failure rather than the silent one. Two knobs sit - on top of that baseline, both reachable only through the IConfiguration overload - (WebApplicationBuilderExtensions.cs:303) that binds the RateLimiting section. Algorithm - (RateLimitingSettings.cs:53) selects the RateLimitAlgorithm enum + RegisterAsync (AuthControllerBase.cs:79), and + PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand> + carries it on both recovery endpoints + (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:78 + and :102), so every consumer inherits spray protection by construction, while RefreshAsync + (AuthControllerBase.cs:98-103) is deliberately left unthrottled because refresh is periodic and + automatic and Blazor Server circuits issue it server-side from one shared host IP. A consumer that + inherits the base without calling AddCommonRateLimiting fails at startup on an unregistered policy, + which is the loud failure rather than the silent one. Two knobs sit on top of that baseline, both + reachable only through the IConfiguration overload (WebApplicationBuilderExtensions.cs:313) that + binds the RateLimiting section. Algorithm (RateLimitingSettings.cs:53) selects the + RateLimitAlgorithm enum (MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RateLimitAlgorithm.cs:8): FixedWindow is the default and cheapest but lets a caller spend an allowance twice across a boundary, while SlidingWindow divides the same one-minute window into SegmentsPerWindow segments (default 4, @@ -287,9 +317,9 @@

      12. API Host RedisFixedWindowRateLimiter (MMCA.Common/Source/Presentation/MMCA.Common.API/RateLimiting/RedisFixedWindowRateLimiter.cs:37), so a limit means the same thing behind a load balancer as it does on one node. All three choices funnel - through one private factory, CreateLimitedPartition (WebApplicationBuilderExtensions.cs:137), which - takes the Redis path only when a IConnectionMultiplexer is actually registered and otherwise falls - through to the in-memory limiters rather than failing startup (lines 146-167). The Redis limiter is + through one private factory, CreateLimitedPartition (WebApplicationBuilderExtensions.cs:147), which + takes the Redis path only when an IConnectionMultiplexer is actually registered and otherwise falls + through to the in-memory limiters rather than failing startup (lines 156-177). The Redis limiter is worth reading for its three deliberate compromises: it stores one INCR counter per partition per window under rl:{partitionKey}:{unixMinute} (line 130) and gives it a 65 second TTL on the increment that creates it (line 142), so keys expire themselves and clock skew between instances cannot hand a @@ -301,7 +331,7 @@

      12. API Host (RedisFixedWindowRateLimiter.cs:169) is the two-instance lease type it hands out, Acquired and Rejected as shared statics (lines 172 and 175) so a permitted request allocates nothing. The auth-ip policy stays per-instance whatever Distributed says (allowDistributed: false, - WebApplicationBuilderExtensions.cs:223): per-account lockout already backs it, and a login throttle + WebApplicationBuilderExtensions.cs:233): per-account lockout already backs it, and a login throttle that fails open on a Redis outage is a worse trade than one that stays local (ADR-019 for the layering, and ADR-029 for the @@ -336,9 +366,10 @@

      12. API Host Enabled false in a host that never called AddMultiTenancy (line 62). And it fails closed: with Tenancy:RequireTenant on, a request that resolves no tenant is rejected at line 83 and answered 400 with an RFC 9457 body naming the claim and header it looked at (lines 133-146), because an unscoped - request would read across every tenant, which is the exact outcome tenancy exists to prevent; health, - liveness, and discovery paths are excluded so probes still answer before any tenant exists (lines - 89-94). SoftDeletedUserMiddleware + request would read across every tenant, which is the exact outcome tenancy exists to prevent; an + explicit RequireTenant opt-out lets the request run as a system caller instead (lines 75-81), and + health, liveness, and discovery paths are excluded so probes still answer before any tenant exists + (lines 89-94). SoftDeletedUserMiddleware (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/SoftDeletedUserMiddleware.cs:31) enforces business rule BR-133: an authenticated caller whose account was soft-deleted is rejected with a bare 401 (lines 104 and 145), checked first against a marker cached for 30 seconds @@ -350,7 +381,7 @@

      12. API Host RequestServices (line 75) instead of as an InvokeAsync parameter, so a service that does not host Identity passes the request through rather than 500-ing on every call: an explicit nod to the [Rubric §7, Microservices Readiness] extraction path. And unlike tenancy it fails open: a cache - read that throws falls back to the validator query (lines 93-100), and a validator query that throws + read that throws falls back to the validator query (lines 93-99), and a validator query that throws lets the request continue (lines 118-126), because failing closed would turn any cache or database blip into a total outage for every authenticated request, while the exposure it buys back is bounded by the access-token lifetime. All three middlewares are [Rubric §13, Observability & Operability] @@ -374,7 +405,7 @@

      12. API Host UnhandledResultFailureFilter (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/UnhandledResultFailureFilter.cs:21, an IAlwaysRunResultFilter) catches any action that accidentally returned a failed Result as a 200 - body, logs a warning, and rewrites it as the correct error (lines 27-49). All of those paths converge + body, logs a warning, and rewrites it as the correct error (lines 25-49). All of those paths converge on ErrorHttpMapping (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/ErrorHttpMapping.cs:14), whose FrozenDictionary<ErrorType, int> (lines 20-30) is the single source of truth mapping each @@ -428,17 +459,26 @@

      12. API Host for writes, TEntityDTO implements IBaseDTO<TIdentifierType>, and TCreateRequest implements ICreateRequest (IEntityControllerBase.cs:17-18, IAggregateRootEntityControllerBase.cs:20-22). Alongside the CRUD - tower sit five special-purpose bases: AuthControllerBase + tower sit six special-purpose bases: AuthControllerBase (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:41, anonymous login, register, and refresh over IAuthenticationService, lines 54-103, plus an [Authorize] revoke at lines 117-122); + PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand> + (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:43), + a sibling of that base rather than an addition to it because each app's own AuthController + already occupies the single-inheritance chain (PasswordResetAuthControllerBase.cs:13-18), serving + POST forgot-password (line 82) and POST reset-password (line 107); both are anonymous by necessity + since the caller has lost the credential, forgot-password always answers 202 whether or not the address + exists (line 92) so the response never reveals which addresses hold accounts, and each app supplies its + own command record through a one-line factory + (ADR-091); UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand> (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:40), - which subclasses it purely additively (line 46) and adds the self-service account endpoints - PUT password (line 86), PUT preferences (line 112), and GET preferences (line 138), taking the - two mutation commands as type parameters because each app owns its own command record while the - preferences query is shared (UserAccountAuthControllerBase.cs:47-48); + which subclasses AuthControllerBase purely additively (line 46) and adds the self-service account + endpoints PUT password (line 86), PUT preferences (line 112), and GET preferences (line 138), + taking the two mutation commands as type parameters because each app owns its own command record while + the preferences query is shared (UserAccountAuthControllerBase.cs:47-48); OAuthControllerBase (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:33, the Google and GitHub external-provider flow whose single-use exchange code, cached for two minutes at @@ -460,10 +500,10 @@

      12. API Host (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/ServiceInfoControllerBase.cs:30), whose dual-version /ServiceInfo returns ServiceInfoResponse for the deprecated v1.0 (line 51) and ServiceInfoV2Response for v2.0 (line 54, a superset - adding the supported and deprecated version lists at lines 32-33), proving the versioning machinery - works across versions ([MapToApiVersion] at lines 40 and 46). All of these carry the same note: - class-level routing and versioning attributes are not reliably inherited, so the per-service sealed - subclass supplies them. This is the clearest [Rubric §5, Vertical Slice] and [Rubric §16, + adding the supported and deprecated version lists held at lines 32-33), proving the versioning + machinery works across versions ([MapToApiVersion] at lines 40 and 46). All of these carry the same + note: class-level routing and versioning attributes are not reliably inherited, so the per-service + sealed subclass supplies them. This is the clearest [Rubric §5, Vertical Slice] and [Rubric §16, Maintainability] payoff in the presentation layer: a module writes a DTO, a mapper, and a short sealed subclass, and inherits a fully paged, filterable, exportable, error-mapped REST resource, with [Rubric §30, Compliance & Data Governance] covered by the DSAR base.

      @@ -484,14 +524,22 @@

      12. API Host the same bytes on every machine (FormatCell, lines 128-143: lowercase booleans to match the sibling JSON, ISO 8601 round-trip "O" for timestamps), and writes a UTF-8 BOM explicitly (lines 45 and 69, paired with the preamble-free encoding at line 55) because Excel reads a BOM-less UTF-8 CSV in the - machine's ANSI code page. The controller opens a StreamWriter over Response.Body without committing - the response (line 261), which is what keeps the "a failure on page one still returns Problem Details" - path honest, and the row ceiling is announced up front through the X-Export-Row-Limit header - (constant at line 493, default 100,000 at line 484, overridable per host through MaxExportRows at - lines 77-85) with the truncation notice written as a final body line, because headers are frozen the - moment the first byte flushes. Row scoping is a hook, not a default: GetExportSpecification returns - null (line 542), so a controller whose list endpoints row-scope reads must override it. That is a - [Rubric §12, Performance & Scalability] and [Rubric §11, Security] pairing worth reading closely.

      + machine's ANSI code page. Two guards run before any byte is written. Columns a CSV cannot represent + faithfully, binary concurrency tokens and every non-string collection property, are computed once per + closed controller type and dropped (UnexportablePropertyNames at EntityControllerBase.cs:555, + the type test at :583); value objects and other class-typed properties are deliberately kept, since + their invariant ToString is exactly the cell a reader expects. And a caller who names one of those + dropped properties in fields= gets an Error.InvalidEntityField validation failure rather than a + quietly missing column (ValidateExportFields, :601, called at :243). Then the controller opens a + StreamWriter over Response.Body without committing the response (line 261), which is what keeps the + "a failure on page one still returns Problem Details" path honest, and the row ceiling is announced up + front through the X-Export-Row-Limit header (constant at line 493, default 100,000 at line 484, + overridable per host through MaxExportRows at lines 77-85, written alongside the + Content-Disposition attachment name at lines 629-630) with the truncation notice written as a final + body line (line 333), because headers are frozen the moment the first byte flushes. Row scoping is a + hook, not a default: GetExportSpecification returns null (line 542), so a controller whose list + endpoints row-scope reads must override it. That is a [Rubric §12, Performance & Scalability] and + [Rubric §11, Security] pairing worth reading closely.

      Idempotency for safe retries. Write endpoints are made replay-safe by IdempotentAttribute (MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotentAttribute.cs:16), a one-line @@ -520,8 +568,8 @@

      12. API Host memorizing. A hit replays the stored IdempotencyRecord (IdempotencyRecord.cs:17, a status code plus JSON body plus the request-body hash) with an X-Idempotent-Replay: true header (line 387), as a bare StatusCodeResult when the stored body is - empty so a replayed 204 does not acquire a content type (lines 386-393). A key reused with a - different payload is answered 422 Unprocessable Entity rather than replayed (lines 373-380 and + empty so a replayed 204 does not acquire a content type (lines 388-395). A key reused with a + different payload is answered 422 Unprocessable Entity rather than replayed (lines 375-382 and BodyMismatchResult at 324-333), because replaying would tell the client a genuinely new write succeeded when nothing ran. A duplicate that cannot take the lock within the wait and finds nothing cached gets 409 Conflict (lines 263-275 and InFlightDuplicateResult at 303-312), which is @@ -538,13 +586,14 @@

      12. API Host IdempotencyMetrics (MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyMetrics.cs:16) publishes idempotency.replayed (line 37), idempotency.conflict tagged kind=body_mismatch or in_flight - (lines 24-32 and 42), and idempotency.degraded (line 47) on the MMCA.Common.Idempotency meter (line - 19), so a sustained degraded rate says out loud that deduplication is effectively off. The one thing - the filter cannot do is notice an endpoint that forgot to opt in, which is what + (lines 24, 29, and 42), and idempotency.degraded (line 47) on the MMCA.Common.Idempotency meter + (line 19), so a sustained degraded rate says out loud that deduplication is effectively off. The one + thing the filter cannot do is notice an endpoint that forgot to opt in, which is what NonIdempotentAttribute (MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/NonIdempotentAttribute.cs:23) exists for: it attaches no pipeline stage and changes no behavior, it only records a required Justification - string (line 28), and its sole consumer is the PostActionsDeclareIdempotencyIntent fitness function, + string (line 28), and its sole consumer is the PostActionsDeclareIdempotencyIntent fitness function + (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Idempotency.cs:44), which fails the build unless every POST action carries either [Idempotent] or this attribute. That is why AuthControllerBase reads the way it does: RegisterAsync is [Idempotent] (AuthControllerBase.cs:77) while login, refresh, and revoke each carry a written reason for staying @@ -575,14 +624,14 @@

      12. API Host IAsyncActionFilter directly and needs no DI registration (lines 41-44). Before the action it decodes the header and writes the token into every bound argument that implements IConcurrencyAware and does not already carry one (lines 120-135, through a - cached RowVersion setter so init-only record properties stay init-only, lines 61 and 165-172); + cached RowVersion setter so init-only record properties stay init-only, lines 61 and 159-172); after the action it rewrites a conflict outcome to 412 Precondition Failed (lines 178-207), covering both the 409 result and the raw DbUpdateConcurrencyException. Three decisions make the two mechanisms coexist. Body precedence: an argument that already carries a RowVersion is left alone and keeps its existing 409 semantics untouched, so an older client posting the token in the body sees no change at all (lines 151-154). Only the header path is rewritten, which is what keeps 412 meaning "the precondition you stated failed" and 409 meaning "a conflict you did not condition on". - And a malformed If-Match short-circuits with a 400 rather than being ignored (lines 114-118 and + And a malformed If-Match short-circuits with a 400 rather than being ignored (lines 70-74 and 210-219), because silently dropping a precondition the client believed it had set is the one outcome worse than rejecting it. Alongside it, ConcurrencyTokenRequest (MMCA.Common/Source/Core/MMCA.Common.Shared/DTOs/ConcurrencyTokenRequest.cs:12) remains the @@ -595,9 +644,9 @@

      12. API Host (MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/PublicEndpointOutputCachePolicy.cs:35), registered by name through OutputCacheOptionsExtensions (MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/OutputCacheOptionsExtensions.cs:6, both - overloads at lines 20-21 and 34-35), drops that identity bail-out (line 69), varies by every - query-string key (line 81) so search, paging, filtering and field projection each get their own entry, - refuses to store responses that set cookies or are not plain 200s (lines 100-103), and offers a + overloads at lines 20-21 and 34-35), drops that identity bail-out (lines 68-70), varies by every + query-string key (lines 79-81) so search, paging, filtering and field projection each get their own + entry, refuses to store responses that set cookies or are not plain 200s (lines 100-103), and offers a bypassRoles escape hatch so a privileged caller who receives an elevated payload always reads fresh, skipping both lookup and storage (line 113, ADR-040). @@ -606,7 +655,7 @@

      12. API Host (MMCA.Common/Source/Presentation/MMCA.Common.API/Caching/OutputCacheEvictionHandler.cs:32) closes that gap by consuming the OutputCacheEvictionRequested integration - event and calling EvictByTagAsync for each tag against this host's store (lines 44-63); no + event and calling EvictByTagAsync for each tag against this host's store (lines 38-63); no MassTransit type appears in it, so the same handler is reachable from the in-process dispatcher and from the broker (ADR-026). Its two behaviors are both deliberate: eviction is per-tag best effort, so a store that throws on one tag is logged and counted rather than rethrown (lines 56-62), because @@ -704,7 +753,19 @@

      12. API Host authority: it returns 404 when Jwt:Issuer is not configured (lines 63-66), derives jwks_uri from that configured issuer rather than from the inbound request (line 76) so issuer and JWKS URI stay origin-aligned, and disables the camelCase naming policy (line 45) because RFC 8414 field names are - snake_case and jwksUri would not be recognized. + snake_case and jwksUri would not be recognized. AddForwardedJwtBearer itself resolves + RequireHttpsMetadata in three steps, explicit argument, then the + Authentication:JwtBearer:RequireHttpsMetadata key (constant at + WebApplicationBuilderExtensions.cs:54), then "true outside Development" + (WebApplicationBuilderExtensions.cs:456-458); a resolved false outside Development is honored, + because an internal-ingress h2c authority genuinely has no HTTPS metadata, but it is never silent: + InsecureJwtMetadataWarningStartupFilter + (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/InsecureJwtMetadataWarningStartupFilter.cs:15) + is registered exactly then (WebApplicationBuilderExtensions.cs:460-464) and logs one startup warning + naming the key (InsecureJwtMetadataWarningStartupFilter.cs:26-29). It is an IStartupFilter rather + than a log line at registration time for a reason worth remembering: while the service collection is + being built the logging providers are not configured yet, so a warning written there is dropped + (InsecureJwtMetadataWarningStartupFilter.cs:7-13). JwtForwardingDelegatingHandler (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Http/JwtForwardingDelegatingHandler.cs:17) copies the caller's inbound Authorization header onto outgoing HTTP calls, unless one was already set (lines @@ -723,19 +784,20 @@

      12. API Host ADR-030, ADR-006), repeats the same strategy per tenant that keeps its own copy of a source, each in a fresh scope with its tenant set - (line 91 into InitializeTenantDatabasesAsync at line 112, scope and tenant at lines 127-128, + (line 91 into InitializeTenantDatabasesAsync at line 112, ADR-073), and finishes by running the enabled modules' seeders on the default scope only (line 98). Five smaller startup helpers round out the host: OpenApiEndpointExtensions - (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/OpenApiEndpointExtensions.cs:18) maps the - per-version OpenAPI document (lines 32-34) and the optional Scalar reference UI (lines 48-50) outside - Production only, ApiParameterDescriptorBackfillProvider + (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/OpenApiEndpointExtensions.cs:22) maps the + per-version OpenAPI document (MapCommonOpenApi, lines 34-38) and the optional Scalar reference UI + (MapCommonScalarUi, lines 52-56), both outside Production only, + ApiParameterDescriptorBackfillProvider (MMCA.Common/Source/Presentation/MMCA.Common.API/OpenApi/ApiParameterDescriptorBackfillProvider.cs:43) fills in the placeholder descriptor MVC leaves null on an unbound route token (lines 65-71) so a URL-segment-versioned or {tenant}-templated route cannot turn document generation into a 500, running last by ordering itself at int.MinValue (line 46) and registered exactly once through TryAddEnumerable however many helpers a host calls - (WebApplicationBuilderExtensions.cs:250, :395, and the helper itself at :407-409), + (WebApplicationBuilderExtensions.cs:260, :405, and the helper itself at :417-419), SignalRExtensions (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/SignalRExtensions.cs:12) maps NotificationHub at its configured path when push @@ -1014,7 +1076,7 @@

      ServiceInfoControllerBase

      • What it is: an anonymous, read-only discovery controller that proves the API-versioning machinery works across more than one version. The same /ServiceInfo route is served by v1.0 (deprecated) and v2.0, selected via the api-version header.
      • Depends on: Asp.Versioning (MapToApiVersion) and ASP.NET Core MVC (ControllerBase); returns ServiceInfoResponse and ServiceInfoV2Response, both nested in this file.
      • -
      • Concept introduced: header-based API versioning as a first-class contract. [Rubric §9, API & Contract Design] assesses whether an API can carry multiple versions concurrently and signal deprecation; this controller demonstrates the whole loop: two versions on one route, one marked deprecated, and ReportApiVersions = true (set in AddCommonApiVersioning on WebApplicationBuilderExtensions, WebApplicationBuilderExtensions.cs:241) so responses carry api-supported-versions / api-deprecated-versions headers (class doc, ServiceInfoControllerBase.cs:6-14). ADR-046 makes the point that a versioning claim which only ever ships v1.0 is untestable; this endpoint is what makes it testable.
      • +
      • Concept introduced: header-based API versioning as a first-class contract. [Rubric §9, API & Contract Design] assesses whether an API can carry multiple versions concurrently and signal deprecation; this controller demonstrates the whole loop: two versions on one route, one marked deprecated, and ReportApiVersions = true (set in AddCommonApiVersioning on WebApplicationBuilderExtensions, WebApplicationBuilderExtensions.cs:251, inside the extension member declared at WebApplicationBuilderExtensions.cs:243) so responses carry api-supported-versions / api-deprecated-versions headers (class doc, ServiceInfoControllerBase.cs:6-14). ADR-046 makes the point that a versioning claim which only ever ships v1.0 is untestable; this endpoint is what makes it testable.
      • Walkthrough
        • Supported = ["1.0", "2.0"] and Deprecated = ["1.0"] (ServiceInfoControllerBase.cs:32-33) are the static version lists the v2 payload echoes.
        • ServiceName (ServiceInfoControllerBase.cs:36) is an abstract property the sealed per-service subclass supplies, because class-level routing/versioning attributes are not reliably inherited (remarks, ServiceInfoControllerBase.cs:15-29): the subclass carries [ApiController], [Route("[controller]")], [AllowAnonymous], and the two [ApiVersion] attributes.
        • @@ -1023,7 +1085,7 @@

          ServiceInfoControllerBase

      • Why it's built this way: the type is abstract with an abstract ServiceName so each extracted service reuses the identical versioning surface while stamping its own identity, keeping the "build the monolith now, extract a service later" path uniform ([Rubric §7, Microservices Readiness]). The endpoint is anonymous and reached on the service host directly; gateways do not route it (class doc, ServiceInfoControllerBase.cs:12-13).
      • -
      • Where it's used: subclassed by each service's sealed ServiceInfoController, for example ADC's Conference service (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/ServiceInfoController.cs:20) and Store's Catalog service (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/Controllers/ServiceInfoController.cs:20). Because the controller ships in the framework, the fitness contract that exercises it is shared too: ServiceInfoVersioningContractTestsBase<TFixture> (MMCA.Common/Source/Hosting/MMCA.Common.Testing/ServiceInfoVersioningContractTestsBase.cs:19), and a repo subclasses it supplying only its fixture.
      • +
      • Where it's used: subclassed by each service's sealed ServiceInfoController, for example ADC's Conference service (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/ServiceInfoController.cs:20) and Store's Catalog service (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/Controllers/ServiceInfoController.cs:20). Because the controller ships in the framework, the fitness contract that exercises it is shared too: ServiceInfoVersioningContractTestsBase<TFixture> (MMCA.Common/Source/Hosting/MMCA.Common.Testing/ServiceInfoVersioningContractTestsBase.cs:19), and a repo subclasses it supplying only its fixture.

      IEntityControllerBase<TEntityDTO, TIdentifierType>

      @@ -1057,8 +1119,8 @@

      ApiControllerBase

    • Builds a ProblemDetails with that status and a fixed title/detail (ApiControllerBase.cs:40-45), attaches Extensions["errors"] via ErrorHttpMapping.BuildErrorsExtension (ApiControllerBase.cs:48), optionally localized through an IErrorLocalizer resolved with GetService (ApiControllerBase.cs:47, so a host without localization simply passes null), then returns StatusCode(statusCode, problemDetails) (ApiControllerBase.cs:50).
    -
  • Why it's built this way: one virtual method instead of a switch in every action removes duplication and makes the response shape uniform (ADR-013 for why failures are values rather than exceptions in the first place); keeping it virtual lets a subclass (EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>) wrap it with logging without reimplementing the mapping. The two ErrorHttpMapping members are internal static (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/ErrorHttpMapping.cs:36 and ErrorHttpMapping.cs:47), which is what lets UnhandledResultFailureFilter reuse the same status-code mapping and the same errors extension array for a failed Result that an action returned without calling HandleFailure (UnhandledResultFailureFilter.cs:36 and :47). The two bodies are deliberately not identical: the filter labels its ProblemDetails Title/Detail "Unhandled result failure" / "The action returned a Result.Failure that was not mapped to an HTTP error response." (UnhandledResultFailureFilter.cs:42-43) against the base's "Operation failed" / "One or more errors occurred." (ApiControllerBase.cs:43-44), so a response that fell through the filter is distinguishable from one the controller mapped on purpose. Localization is the ADR-027 extension point, keyed by Error.Code and leaving Code/Type/Source/Target verbatim so clients can still branch on them (ErrorHttpMapping.cs:47-55).
  • -
  • Where it's used: the root of the controller hierarchy. EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>, AuthControllerBase, DataExportControllerBase<TQuery>, and every module controller derive from it directly or transitively.
  • +
  • Why it's built this way: one virtual method instead of a switch in every action removes duplication and makes the response shape uniform (ADR-013 for why failures are values rather than exceptions in the first place); keeping it virtual lets a subclass (EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>) wrap it with logging without reimplementing the mapping. The two ErrorHttpMapping members are internal static (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/ErrorHttpMapping.cs:36 and ErrorHttpMapping.cs:47), which is what lets UnhandledResultFailureFilter reuse the same status-code mapping and the same errors extension array for a failed Result that an action returned without calling HandleFailure (UnhandledResultFailureFilter.cs:36 and :47). The two bodies are deliberately not identical: the filter labels its ProblemDetails Title/Detail "Unhandled result failure" / "The action returned a Result.Failure that was not mapped to an HTTP error response." (UnhandledResultFailureFilter.cs:42-43) against the base's "Operation failed" / "One or more errors occurred." (ApiControllerBase.cs:43-44), so a response that fell through the filter is distinguishable from one the controller mapped on purpose. Localization is the ADR-027 extension point, keyed by Error.Code and leaving Code/Type/Source/Target verbatim so clients can still branch on them (ErrorHttpMapping.cs:42-45 for the contract, ErrorHttpMapping.cs:48-55 for the projection that honours it).
  • +
  • Where it's used: the root of the controller hierarchy. EntityControllerBase<TEntity, TEntityDTO, TIdentifierType>, AuthControllerBase, PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>, DataExportControllerBase<TQuery>, and every module controller derive from it directly or transitively.
  • IAggregateRootEntityControllerBase<TEntityDTO, TIdentifierType, TCreateRequest>

    @@ -1109,7 +1171,7 @@

    EntityController
  • Why it's built this way: the controller stays thin. All filtering/sorting/paging lives in IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, and manual DTO mapping (ADR-001) keeps entities off the wire. The controller only translates HTTP concerns: query strings, headers, status codes. The export reuses the paged read wholesale precisely so it cannot disagree with the grid it was launched from, and the row ceiling (DefaultMaxExportRows = 100_000, :484, matching ApplicationSettings.MaxExportRows's own default) is a number an operator can reason about rather than an unbounded connection hold.
  • Caveats / not-in-source: ADR-078 describes truncation as signalled by an X-Export-Truncated: true response header; the shipped code sends no such header. It always sends X-Export-Row-Limit instead and carries the truncation fact in the trailing body record, with the remarks stating why a header cannot work (:197-205). Trust the code. The export is also not output-cached and inherits only whatever [Authorize]/[FeatureGate] the derived controller declares (:190-196).
  • -
  • Where it's used: the base for every read-only module controller, for example ADC's child-collection controllers SessionSpeakersController and CategoryItemsController. Extended by AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest> for entities that also create and delete. The GetExportSpecification hook is overridden today by Store's owner-scoped OrdersController and ShoppingCartsController. Framework coverage lives in EntityControllerBaseTests, EntityControllerBaseExportTests, and EntityControllerBaseETagTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/).
  • +
  • Where it's used: the base for every read-only module controller, for example ADC's child-collection controllers SessionSpeakersController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:56) and CategoryItemsController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:69). Extended by AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest> for entities that also create and delete. The GetExportSpecification hook is overridden today by Store's owner-scoped OrdersController (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.API/Controllers/OrdersController.cs:270) and ShoppingCartsController (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.API/Controllers/ShoppingCartsController.cs:224); Store's CustomersController deliberately does not, and records why (its list endpoints are Admin-only rather than row-scoped, so there is no ownership specification to reproduce, MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/CustomersController.cs:84-98). Framework coverage lives in EntityControllerBaseTests, EntityControllerBaseExportTests, and EntityControllerBaseETagTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/).
  • OAuthControllerBase

    @@ -1125,12 +1187,12 @@

    OAuthControllerBase

  • CompleteAsync (:76): after the middleware handles the provider callback, this reads the external cookie (:79), redirects to /login?error=oauth_failed when the ticket did not survive (:81-86), reads the stashed returnUrl with a GetString fallback to "/" rather than the throwing Items indexer (:88-91), extracts provider claims (ExtractClaims, :173), calls ExternalLoginAsync to find or create the local user and mint tokens (:101-102), signs out the temporary external cookie (:112), then mints a 32-byte hex exchangeCode (:117), stashes the token pair in the cache under it (:118-119), and redirects with only the code (:121, URL built at :124-127).
  • Name handling is defensive: ExtractName (:183) prefers GivenName/Surname claims and otherwise splits the Name claim, falling back to ("User", "") when there is no usable space-separated name (:197-211), so a provider that returns only a display name still yields a creatable local account.
  • Native heads (ADR-043): GetAllowedMobileReturnUrl (:235) returns the stashed returnUrl as the redirect target only when it is an absolute URI whose custom scheme is listed in OAuth:AllowedReturnUrlSchemes; http/https never match (:237-242), so the allowlist cannot become an open redirect, and a missing or empty section (or a test double returning null from GetSection) means "no allowlist", the exact pre-ADR-043 behavior (:244-248).
  • -
  • ExchangeAsync (:140): [HttpPost("exchange")] with [NonIdempotent(...)] and [AllowAnonymous] (:136-139); the UI swaps the code for the real AuthenticationResponse out-of-band. Because that response is a readonly record struct, a cache miss yields a default value rather than null, so the miss is detected via an empty AccessToken (:151-157). The code is then removed (:160), making it single-use so a leaked or replayed code cannot mint a second token pair. Both failure paths return the same opaque 400 "Invalid sign-in code" (:165-171).
  • +
  • ExchangeAsync (:140): [HttpPost("exchange")] with [NonIdempotent(...)] and [AllowAnonymous] (:136-139); the UI swaps the code for the real AuthenticationResponse out-of-band. Because that response is a struct, a cache miss yields a default value rather than null, so the miss is detected via an empty AccessToken (:151-157). The code is then removed (:160), making it single-use so a leaked or replayed code cannot mint a second token pair. Both failure paths return the same opaque 400 "Invalid sign-in code" (:165-171).
  • Why it's built this way: carrying tokens in a redirect is the classic OAuth token-leak vector; the single-use code plus a short-lived server-side stash closes it while keeping the client flow a plain redirect and one POST. The [NonIdempotent] justification on ExchangeAsync (:137) records why this one endpoint must stay outside the replay contract: replaying the stored response would defeat the burn, letting a leaked code mint the same tokens again for the whole retention window. The AppendQuery helper (:251) deliberately uses OriginalString rather than ToString(), because Uri normalization appends a trailing slash to authority-only URIs (atldevcon://oauth-complete) and native authenticator callback matching can be exact (:253-255).
  • Caveats / not-in-source: the provider scheme registration and the concrete ExternalLoginAsync implementation live outside this base (ExternalAuthExtensions and the app's IAuthenticationService); this file assumes both are wired.
  • -
  • Where it's used: subclassed by each app's sealed OAuth controller, for example ADC's OAuthController (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/OAuthController.cs:20), which adds only the class-level routing and versioning attributes.
  • +
  • Where it's used: subclassed by each app's sealed OAuth controller, for example ADC's OAuthController (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/OAuthController.cs:20), which adds only the class-level routing and versioning attributes. ExchangeAsync is one of the endpoints the framework's anonymous-endpoint architecture gate lists by name (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTests.cs:31), so its [AllowAnonymous] is an approved exception rather than an oversight.
  • AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest>

    @@ -1139,7 +1201,7 @@

    EntityControllerBase<TEntity, TEntityDTO, TIdentifierType> (the read endpoints) by adding a CreateAsync (POST) and a DeleteAsync (DELETE) for aggregate-root entities.
  • Depends on: EntityControllerBase<TEntity, TEntityDTO, TIdentifierType> (base), IAggregateRootEntityControllerBase<TEntityDTO, TIdentifierType, TCreateRequest> (implements), ICommandHandler<in TCommand, TResult> (create and delete handlers), DeleteEntityCommand<TEntity, TIdentifierType>, AuditableAggregateRootEntity<TIdentifierType> (constraint), ICreateRequest (constraint), IdempotentAttribute; ASP.NET Core MVC and Asp.Versioning.
  • -
  • Concept introduced: idempotent creation guarded at the endpoint. [Rubric §9, API & Contract Design] assesses safe mutation; CreateAsync carries [Idempotent] (AggregateRootEntityControllerBase.cs:59), which wires IdempotencyFilter so a retried POST carrying the same Idempotency-Key replays the original 201 (flagged X-Idempotent-Replay: true) instead of creating a duplicate aggregate, exactly what mobile and flaky-network clients need. A duplicate that arrives while the first request is still running and cannot take the lock within the 5-second LockWait (IdempotencyFilter.cs:106) is answered with 409 Conflict rather than a replay (IdempotencyFilter.cs:263-275). Because the fitness rule reads attributes with inherit: true, every concrete controller that inherits this action satisfies PostActionsDeclareIdempotencyIntent through this base (see NonIdempotentAttribute). [Rubric §1, SOLID]: the four constraints (AggregateRootEntityControllerBase.cs:40-43, notably TEntity : AuditableAggregateRootEntity<TIdentifierType>) enforce at compile time that only aggregate roots reach this create/delete surface.
  • +
  • Concept introduced: idempotent creation guarded at the endpoint. [Rubric §9, API & Contract Design] assesses safe mutation; CreateAsync carries [Idempotent] (AggregateRootEntityControllerBase.cs:59), which wires IdempotencyFilter so a retried POST carrying the same Idempotency-Key replays the original 201 (flagged X-Idempotent-Replay: true, IdempotencyFilter.cs:39) instead of creating a duplicate aggregate, exactly what mobile and flaky-network clients need. A duplicate that arrives while the first request is still running and cannot take the lock within the 5-second LockWait (IdempotencyFilter.cs:106, awaited at IdempotencyFilter.cs:252) is answered with 409 Conflict rather than a replay (IdempotencyFilter.cs:306-311). Because the fitness rule reads attributes with inherit: true, every concrete controller that inherits this action satisfies PostActionsDeclareIdempotencyIntent through this base (see NonIdempotentAttribute). [Rubric §1, SOLID]: the four constraints (AggregateRootEntityControllerBase.cs:40-43, notably TEntity : AuditableAggregateRootEntity<TIdentifierType>) enforce at compile time that only aggregate roots reach this create/delete surface.
  • Walkthrough
    • Primary constructor (AggregateRootEntityControllerBase.cs:27-38): four parameters, where queryService and logger are forwarded to the EntityControllerBase<TEntity, TEntityDTO, TIdentifierType> base (:38), plus createHandler and deleteHandler. The logger is typed ILogger<EntityControllerBase<...>>, not of this class, because ILogger<T> is not covariant and the base ctor requires that exact type; the #pragma warning disable S6672 (:35-37) is a justified, narrowly-scoped suppression documenting exactly that ([Rubric §15, Best Practices]).
    • CreateHandler property (:48): protected, so a derived controller that overrides CreateAsync to build a more specific command can still reach the handler. deleteHandler stays a captured constructor parameter, used directly at :93, because nothing overrides delete today.
    • @@ -1148,7 +1210,7 @@

      ADR-014). -
    • Where it's used: concrete aggregate controllers in the modules extend this, for example ADC's EventsController, SessionsController, and SpeakersController; child-only controllers deliberately extend the read-only base instead.
    • +
    • Where it's used: concrete aggregate controllers in the modules extend this, for example ADC's EventsController, SessionsController, and SpeakersController; child-only controllers deliberately extend the read-only base instead. Framework coverage lives in AggregateRootEntityControllerBaseTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/AggregateRootEntityControllerBaseTests.cs).

    CurrentUserTargetingContextAccessor

    @@ -1158,14 +1220,14 @@

    CurrentUserTargetingContextAccessor
  • What it is: the ITargetingContextAccessor that supplies the audience (a user id plus that user's groups) which the feature-management Targeting filter evaluates, read from the current HTTP request's principal.
  • Depends on: Microsoft.FeatureManagement.FeatureFilters.ITargetingContextAccessor and TargetingContext, Microsoft.AspNetCore.Http.IHttpContextAccessor, and System.Security.Claims (CurrentUserTargetingContextAccessor.cs:1-3). Registered by DependencyInjection.AddAPI; a sibling of DisabledFeatureHandler in the same folder.
  • Concept introduced (a percentage rollout that is sticky per user rather than random per request). A Percentage feature filter with no targeting rolls a die on every evaluation, so the same user sees the feature on one request and off the next: unusable for a UI. The Targeting filter fixes that by hashing the audience's user id, which makes the answer deterministic for a given user across requests and across instances, and this accessor is what supplies that id (:7-13). [Rubric §10, Cross-Cutting] assesses whether a cross-cutting toggle is applied uniformly; [Rubric §11, Security] and [Rubric §17, DevOps] both bear on the rollout being an operational lever rather than a deploy. See ADR-031. - Two source decisions are worth carrying. First, the user id is the user_id claim TokenService emits, the same claim IdempotencyFilter keys its cache on, with the principal's name as a fallback for a token that predates it. Second, the accessor is a singleton (that is the lifetime WithTargeting gives it), so it cannot take the scoped ICurrentUserService; it reads IHttpContextAccessor instead and re-derives the roles itself, which the doc calls out explicitly (:14-21).
  • + Two source decisions are worth carrying. First, the user id is the user_id claim TokenService emits (UserIdClaimType, :55), the same claim IdempotencyFilter keys its cache on, with the principal's name as a fallback for a token that predates it. Second, the accessor is a singleton (that is the lifetime WithTargeting gives it), so it cannot take the scoped ICurrentUserService; it reads IHttpContextAccessor instead and re-derives the roles itself, which the doc calls out explicitly (:14-21).

  • Walkthrough: GetContextAsync() (:63) reads httpContextAccessor.HttpContext?.User (:65). An unauthenticated or absent principal yields an empty context, UserId = null and Groups = [] (:67-74), so a targeted feature is simply off for anonymous callers unless the audience opts everyone in (:23-26); the method never returns null, because a feature filter must not be able to fail a request (:57-61). For an authenticated caller it collects the role claims, accepting each claim type the JWT middleware may produce: the standard ClaimTypes.Role URI when inbound claim mapping is on, or the raw role / roles claim when it is off (:76-82). Finally it builds the TargetingContext with user.FindFirst("user_id")?.Value ?? user.Identity.Name (:86) and those groups (:87).
  • Why it's built this way: accepting three role claim types is not defensive padding, it is the concrete consequence of ASP.NET Core's inbound claim mapping being configurable; matching only ClaimTypes.Role would silently drop every group when a host turns mapping off, and a group-targeted rollout would then behave as an ungrouped one. The class doc carries a worked FeatureManagement configuration example (:27-48) showing a rollout that always includes the Organizer role, includes 25 percent of everyone else, and pins two named users, which is the fastest way to see what the context is actually feeding.
  • -
  • Where it's used: registered inside AddAPI as services.AddFeatureManagement().WithTargeting<CurrentUserTargetingContextAccessor>() (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:91-92), immediately after AddHttpContextAccessor() (:90, which is TryAdd-based and therefore safe to call here as well as in AddServerAuthSessionCookie). From there it is consumed only by the Targeting feature filter, whose verdicts reach the HTTP edge through [FeatureGate] and DisabledFeatureHandler, and the CQRS layer through FeatureGateCommandDecorator<TCommand, TResult>.
  • +
  • Where it's used: registered inside AddAPI as services.AddFeatureManagement().WithTargeting<CurrentUserTargetingContextAccessor>() (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:91-92), immediately after AddHttpContextAccessor() (:90, which is TryAdd-based and therefore safe to call here as well as in AddServerAuthSessionCookie, :86-89). From there it is consumed only by the Targeting feature filter, whose verdicts reach the HTTP edge through [FeatureGate] and DisabledFeatureHandler, and the CQRS layer through FeatureGateCommandDecorator<TCommand, TResult>.
  • AuthControllerBase

    -

    MMCA.Common.API · MMCA.Common.API.Controllers · MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:41 · Level 10 · class (abstract)

    +

    MMCA.Common.API · MMCA.Common.API.Controllers · MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:41 · Level 15 · class (abstract)

    • What it is: the abstract base for password-based authentication endpoints: login, register, refresh, and revoke. A downstream module (Identity) inherits it and adds the route prefix, version attribute, and any module-specific endpoints.
    • @@ -1180,30 +1242,54 @@

      AuthControllerBase

    • RevokeAsync (:122): [Authorize] (:119); reads CurrentUserService.UserId, returns Unauthorized() if null (:124-126) as a defensive guard even though [Authorize] should already prevent a null id, then revokes and returns NoContent() (:128-132).
    -
  • Why it's built this way: [Rubric §16, Maintainability]: adding a new token flow means changing one base, not N module controllers; keeping the four methods virtual (rather than the class open-ended) keeps the override surface intentional. The rate-limit default is deliberately a loud dependency: a consumer that inherits this base without calling AddCommonRateLimiting() (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:285, which registers the "auth-ip" policy at :354-356, constant defined at :44, with an authIpPermitLimit default of 30 requests per minute per IP at :285 over the one-minute window at :183) fails at startup on an unregistered policy rather than silently serving unthrottled logins (AuthControllerBase.cs:35-39).
  • -
  • Caveats / not-in-source: the per-IP policy partitions on Connection.RemoteIpAddress and deliberately does not limit when that address is null (in-process TestServer, integration tests): AuthIpRateLimitPartition returns RateLimitPartition.GetNoLimiter("__unknown-ip") in that case (WebApplicationBuilderExtensions.cs:214-215), a fail-open posture matching the global limiter and documented at WebApplicationBuilderExtensions.cs:194-200.
  • -
  • Where it's used: the base of every app's Identity AuthController, reached today through UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand>, which both ADC (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AuthController.cs:29) and Store (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/AuthController.cs:27) extend. The framework's own coverage drives the base through a minimal test double, TestAuthController (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/AuthControllerBaseTests.cs).
  • +
  • Why it's built this way: [Rubric §16, Maintainability]: adding a new token flow means changing one base, not N module controllers; keeping the four methods virtual (rather than the class open-ended) keeps the override surface intentional. The rate-limit default is deliberately a loud dependency: a consumer that inherits this base without calling AddCommonRateLimiting() (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:295, which registers the "auth-ip" policy at :364-366, constant defined at :46, with an authIpPermitLimit default of 30 requests per minute per IP at :295 over the one-minute window at :183 and :193) fails at startup on an unregistered policy rather than silently serving unthrottled logins (AuthControllerBase.cs:35-39).
  • +
  • Caveats / not-in-source: the per-IP policy partitions on Connection.RemoteIpAddress (WebApplicationBuilderExtensions.cs:222) and deliberately does not limit when that address is null (in-process TestServer, integration tests): AuthIpRateLimitPartition returns RateLimitPartition.GetNoLimiter("__unknown-ip") in that case (WebApplicationBuilderExtensions.cs:224-225), a fail-open posture matching the global limiter and documented at WebApplicationBuilderExtensions.cs:204-209.
  • +
  • Where it's used: the base of every app's Identity AuthController, reached today through UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand>, which both ADC (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AuthController.cs:29) and Store (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/AuthController.cs:27) extend. Password recovery is deliberately NOT on this chain: it ships as the sibling PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>. The framework's own coverage drives the base through a minimal test double, TestAuthController (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/AuthControllerBaseTests.cs:18), with the throttling asserted separately in AuthControllerBaseRateLimitTests.
  • + +

    PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>

    +
    +

    MMCA.Common.API · MMCA.Common.API.Controllers · MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:43 · Level 15 · class (abstract)

    +
    +
      +
    • What it is: the two anonymous password-recovery endpoints, POST forgot-password and POST reset-password, for a user who cannot sign in at all. It is a sibling of AuthControllerBase, not an addition to it.
    • +
    • Depends on: ApiControllerBase (base), two ICommandHandler<in TCommand, TResult> instances returning Result (PasswordResetAuthControllerBase.cs:44-45), ICommandWithRequest<out TRequest> as the constraint on both type parameters (:46-47), ForgotPasswordRequest and ResetPasswordRequest, IdempotentAttribute, and the RateLimitPolicyAuthIp constant on WebApplicationBuilderExtensions; ASP.NET Core MVC, Microsoft.AspNetCore.Authorization, and Microsoft.AspNetCore.RateLimiting.
    • +
    • Concept introduced: a recovery surface that leaks nothing, on a chain single inheritance already owns. Two separate ideas meet in this one type.
        +
      • Why a sibling controller and not three more actions on the auth base. C# gives a class one base, and each app's AuthController already spends it on UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand>. Recovery therefore ships as its own base that the app routes to the same Auth prefix ([Route("Auth")] on the concrete controller), so POST /Auth/forgot-password rides the gateway's existing /Auth route with no gateway change (class doc, PasswordResetAuthControllerBase.cs:13-18; the ADC subclass records the same reasoning at MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:19-24). [Rubric §16, Maintainability] assesses whether a capability can be added without disturbing what already works: nothing on the authentication chain changed to make room for this.
      • +
      • Response shapes chosen so the endpoint is not an account oracle. [Rubric §11, Security] assesses what an unauthenticated caller can learn by probing. Forgot-password always answers 202 on a well-formed request: an unknown address, a throttled request and a failed send are all treated as success by the handler, so the response never reveals which addresses hold accounts, and only a malformed payload reaches 400 through the request validator (remarks, :27-31). Reset-password collapses every rejection to one 401 for the same reason (:95-98). Both actions must be anonymous by necessity, because the caller has lost the credential that authentication would demand, so requiring one would be circular (:20-26); the framework's anonymous-endpoint architecture gate therefore lists both by name rather than letting them pass silently (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTests.cs:37-38). See ADR-091, which chose a cache-backed single-use token over user-row columns or a self-contained signed payload.
      • +
      +
    • +
    • Walkthrough
        +
      • Type parameters and constraints (:43-47): TForgotPasswordCommand : ICommandWithRequest<ForgotPasswordRequest> and TResetPasswordCommand : ICommandWithRequest<ResetPasswordRequest>. That is the entire contract the base needs, a command that carries a request payload. Note the difference from the account base next door, whose commands are IUserScopedCommand<TRequest>: recovery has no authenticated user to scope to.
      • +
      • The two handlers become protected properties (:50 and :53), the same convention the other auth bases follow, so a derived controller can dispatch them itself for an extra endpoint.
      • +
      • CreateForgotPasswordCommand(request) (:61) and CreateResetPasswordCommand(request) (:69) are the two abstract factories. The doc comments state the expected implementation verbatim, => new(request);, and both consumers do exactly that (MMCA.ADC/.../PasswordResetController.cs:36 and :39).
      • +
      • ForgotPasswordAsync (:82): [HttpPost("forgot-password")], [Idempotent], [AllowAnonymous], [EnableRateLimiting(RateLimitPolicyAuthIp)] (:75-78), with the 202/400/429 contract declared for OpenAPI (:79-81). The body dispatches the app command and returns Accepted() on success or HandleFailure (:86-92).
      • +
      • ResetPasswordAsync (:107) mirrors it at [HttpPost("reset-password")] (:99-102), declaring 204/400/401/429 (:103-106) and returning NoContent() on success (:111-117).
      • +
      +
    • +
    • Why it's built this way: the commands stay app-side for the same reason they do on the account base (remarks, :32-39): ADC marks its ResetPasswordCommand ICacheInvalidating (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordCommand.cs:15) while Store's implements only ICommandWithRequest<ResetPasswordRequest> (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordCommand.cs:12-13), so one shared record could not preserve both behaviors. [Rubric §2, Design Patterns]: this is Template Method again, the base owning the HTTP shape and deferring construction to two primitive operations. Both actions carry [Idempotent] rather than a [NonIdempotent] justification, which fits their contract: a retried forgot-password should replay the same 202 instead of mailing a second token, and a retried reset should replay the same 204 rather than fail against a token the first call already burned. [Rubric §3, Clean Architecture]: no token generation, hashing, or mail send appears here at all; the controller dispatches and maps, and everything else lives behind the CQRS decorator pipeline (ADR-014).
    • +
    • Where it's used: subclassed by each app's sealed PasswordResetController: ADC's (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:28-33) and Store's (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/PasswordResetController.cs:25), each supplying only the two one-line factory overrides plus [ApiController], [Route("Auth")], and [ApiVersion("1.0")]. Framework coverage is PasswordResetAuthControllerBaseTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/PasswordResetAuthControllerBaseTests.cs:23), which drives a TestPasswordResetController double (:168) and additionally asserts, action by action, that the anonymous, rate-limited and idempotent attributes are still attached (:116-148), so the security posture cannot be removed silently.
    • +
    • Caveats / not-in-source: the "always 202" and "every rejection collapses to one 401" guarantees are properties of the app's command handlers, stated in this base's remarks (:27-31, :95-98) but enforced one layer down; nothing in this file forces them.

    UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand>

    -

    MMCA.Common.API · MMCA.Common.API.Controllers · MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:40 · Level 11 · class (abstract)

    +

    MMCA.Common.API · MMCA.Common.API.Controllers · MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:40 · Level 16 · class (abstract)

    • What it is: AuthControllerBase plus the three self-service account endpoints every app needs once a user is signed in: PUT password, PUT preferences, and GET preferences. The app Identity modules previously carried line-identical copies of all three actions, and the only real difference between them was the command record each one constructed (class doc, UserAccountAuthControllerBase.cs:14-19).
    • Depends on: AuthControllerBase (base, constructed with the same IAuthenticationService and ICurrentUserService it forwards, UserAccountAuthControllerBase.cs:46), two ICommandHandler<in TCommand, TResult> instances and one IQueryHandler<in TQuery, TResult> (:43-45), IUserScopedCommand<out TRequest> as the constraint on both command type parameters (:47-48), ChangePasswordRequest, ChangePreferencesRequest, GetUserPreferencesQuery, UserPreferencesResponse, and Result; ASP.NET Core MVC and Microsoft.AspNetCore.Authorization.
    • -
    • Concept introduced: generic-over-the-command deduplication. Two apps wanted the same HTTP surface but not the same command record: ADC's ChangePasswordCommand also implements ICacheInvalidating with a cache prefix built from its own User type, while Store's does not, so one shared record could not preserve both behaviors (remarks, UserAccountAuthControllerBase.cs:21-30). The resolution is the classic Template Method: the base owns the HTTP shape and the dispatch, and defers construction of the app command to two abstract factory methods. [Rubric §16, Maintainability] assesses whether a change lands in one place; [Rubric §1, SOLID] covers both the Open/Closed extension point and the Dependency Inversion angle, since the base depends only on the IUserScopedCommand<TRequest> abstraction and never on either app's concrete record. [Rubric §2, Design Patterns]: the two Create*Command overrides are the pattern's primitive operations, and their implementations really are one line each.
    • +
    • Concept introduced: generic-over-the-command deduplication. Two apps wanted the same HTTP surface but not the same command record: ADC's ChangePasswordCommand also implements ICacheInvalidating with a cache prefix built from its own User type (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordCommand.cs:15), while Store's does not, so one shared record could not preserve both behaviors (remarks, UserAccountAuthControllerBase.cs:21-30). The resolution is the classic Template Method: the base owns the HTTP shape and the dispatch, and defers construction of the app command to two abstract factory methods. [Rubric §16, Maintainability] assesses whether a change lands in one place; [Rubric §1, SOLID] covers both the Open/Closed extension point and the Dependency Inversion angle, since the base depends only on the IUserScopedCommand<TRequest> abstraction and never on either app's concrete record. [Rubric §2, Design Patterns]: the two Create*Command overrides are the pattern's primitive operations, and their implementations really are one line each.
    • Walkthrough
      • Type parameters and constraints (:40-48): TChangePasswordCommand : IUserScopedCommand<ChangePasswordRequest> and TChangePreferencesCommand : IUserScopedCommand<ChangePreferencesRequest>. That constraint is the whole contract the base needs: a command that carries a user id and a request payload.
      • The three handlers become protected properties (:51, :54, :57), matching the base's convention so a derived controller can dispatch them itself for an extra endpoint.
      • CreateChangePasswordCommand(userId, request) (:66-68) and CreateChangePreferencesCommand(userId, request) (:77-79) are the two abstract factories. Both take a UserIdentifierType, the solution-wide identifier alias, so the base never has to know whether an app's user key is an int or a Guid.
      • -
      • ChangePasswordAsync (:91): [HttpPut("password")] plus [Authorize] (:86-87). It reads CurrentUserService.UserId, returns Unauthorized() when null (:95-97), then dispatches CreateChangePasswordCommand(userId.Value, request) through the handler (:99-101) and returns NoContent() or HandleFailure. Note what is absent: no password verification, no hashing, no user lookup. Those live in the app's command handler, behind the CQRS decorator pipeline, so validation and the transaction wrap them (ADR-014). The doc comment is explicit that this dispatches the handler directly rather than brokering through the authentication service (:82-85).
      • +
      • ChangePasswordAsync (:91): [HttpPut("password")] plus [Authorize] (:86-87). It reads CurrentUserService.UserId, returns Unauthorized() when null (:95-97), then dispatches CreateChangePasswordCommand(userId.Value, request) through the handler (:99-101) and returns NoContent() or HandleFailure. Note what is absent: no password verification, no hashing, no user lookup. Those live in the app's command handler, behind the CQRS decorator pipeline, so validation and the transaction wrap them (ADR-014). The doc comment is explicit that this dispatches the handler directly rather than brokering through the authentication service (:81-85).
      • ChangePreferencesAsync (:117) mirrors it at [HttpPut("preferences")] (:112): the stored UI culture and theme (ADR-027, ADR-028) follow the user across devices, and a null field leaves that preference unchanged (:108-111).
      • GetPreferencesAsync (:142): [HttpGet("preferences")] (:138). This one constructs its query inline, new GetUserPreferencesQuery(userId.Value) (:150), because the read side has no per-app detail to preserve; the remarks call that asymmetry out deliberately (:28-29).
      • All three actions repeat the same "UserId is null yields Unauthorized()" guard rather than trusting [Authorize] alone, the same defensive posture AuthControllerBase.RevokeAsync takes.
    • Why it's built this way: inheriting this base instead of AuthControllerBase is purely additive (remarks, :31-36): every inherited login/register/refresh/revoke action, including the default per-IP throttling, the idempotency attributes, and the ability to override RegisterAsync or attach another [EnableRateLimiting] policy app-side, behaves exactly as before. That is what made the consolidation safe to do at all. The alternative (pushing the command records into the framework) would have forced ADC's cache-invalidation behavior onto Store or dropped it from ADC. [Rubric §14, Testability]: because the extension point is two abstract methods rather than a service lookup, the framework can exercise the whole base with a test double supplying trivial commands.
    • -
    • Where it's used: extended by each app's Identity AuthController: ADC's (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AuthController.cs:29) and Store's (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/AuthController.cs:27), each supplying the two one-line factory overrides. Covered in the framework by UserAccountAuthControllerBaseTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/UserAccountAuthControllerBaseTests.cs), which drives the base through a TestUserAccountAuthController double.
    • +
    • Where it's used: extended by each app's Identity AuthController: ADC's (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AuthController.cs:29) and Store's (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/AuthController.cs:27), each supplying the two one-line factory overrides. Covered in the framework by UserAccountAuthControllerBaseTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/UserAccountAuthControllerBaseTests.cs:16), which drives the base through a TestUserAccountAuthController double (:258).

    ErrorResourceSource

    @@ -1636,7 +1722,7 @@

    AppAssociationOptions

  • Why it's built this way: required init gives compile-checked construction plus immutability once bound (see the primer on required/init immutability), which matches the lifetime: a host builds one instance at startup and the endpoint reads it for the process lifetime. Defaulting the two collections to [] means a host that ships only one platform still constructs a valid document for the other. See ADR-043 for the deep-link decision this serves.
  • -
  • Where it's used: constructed inline by the ADC Blazor web host and passed straight to the mapper, with the Android/Apple identifiers read from the AppAssociation configuration section and the applinks patterns hard-coded to the app's routes (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:168-179).
  • +
  • Where it's used: constructed inline by the ADC Blazor web host and passed straight to the mapper, with the Android and Apple identifiers read from the AppAssociation configuration section (with in-code fallbacks) and the applinks patterns hard-coded to the app's routes (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:168-179). The comment there (:171-174) records a trap worth reading: the Release Android head overrides ApplicationId to ivanball.AtlDevCon, so that is the package Digital Asset Links must name, not the Debug-only id.
  • Caveats / not-in-source: the type performs no validation. Whether a fingerprint or bundle id is the correct one for the shipped app is only observable at install time on the device.
  • ErrorResources

    @@ -1649,54 +1735,64 @@

    ErrorResources

  • Concept: the resource anchor type. .NET's IStringLocalizerFactory.Create(Type) locates a satellite resource set by the type's assembly and namespace-relative name, so a resx file needs a co-located type to point at even when that type has no behavior. Keeping the anchor a real, public, empty class makes the resx discoverable by convention and gives modules a pattern to copy. [Rubric §27, i18n] assesses whether user-facing text is externalized rather than hard-coded: here the resx entries are keyed by the stable domain error Code (for example "PhoneNumber.Empty", ErrorResources.cs:5-6), so a translation never depends on the English message string.
  • Walkthrough: there is nothing to walk. The type is a body-less sealed class declared with the semicolon form (ErrorResources.cs:9); all of its meaning is in the doc comment and its resx siblings.
  • Why it's built this way: see ADR-027. Localizing at the HTTP edge (rather than inside the domain) keeps Error.Code culture-free all the way through the Application layer, and one anchor per resource set lets modules add their own translations additively instead of editing a framework file.
  • -
  • Where it's used: AddErrorLocalization() registers it as the framework's own source via services.AddErrorResources<ErrorResources>() (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:111); each module calls the same generic AddErrorResources<TResource>() (DependencyInjection.cs:122-127) with its own anchor type.
  • +
  • Where it's used: AddErrorLocalization() registers it as the framework's own source via services.AddErrorResources<ErrorResources>() (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:111); each module calls the same generic AddErrorResources<TResource>() (DependencyInjection.cs:122) with its own anchor type.
  • -

    ICorrelationContext

    +

    MiddlewarePipelineStep

    -

    MMCA.Common.Application · MMCA.Common.Application.Interfaces · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICorrelationContext.cs:8 · Level 0 · interface

    +

    MMCA.Common.API · MMCA.Common.API.Startup · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/MiddlewarePipelineStep.cs:21 · Level 0 · record (sealed, positional)

      -
    • What it is: the scoped abstraction that holds the correlation ID for the current request. Middleware sets it from the inbound X-Correlation-ID header (or a generated value) and everything downstream reads it through structured-logging scopes.
    • -
    • Depends on: nothing first-party, nothing external. Its holder implementation is CorrelationContext (Infrastructure) and its writer is CorrelationIdMiddleware (API).
    • -
    • Concept introduced (distributed trace correlation). [Rubric §13, Observability & Operability] assesses whether one logical operation can be reconstructed end to end from disjoint logs; a correlation ID is the single value stamped on every log line for one request that makes that possible, and it survives a service extraction because the ID travels on the wire rather than in process memory. [Rubric §10, Cross-Cutting] also applies: handlers and decorators read the ID through this interface and never touch HttpContext, so the concern is factored out of business code entirely.
    • -
    • Walkthrough: two members. CorrelationId { get; } (ICorrelationContext.cs:11) is what every downstream reader uses, and SetCorrelationId(string) (ICorrelationContext.cs:15) is what the middleware calls once at the start of a request. Keeping the setter on the same interface rather than splitting a second write-only abstraction is a deliberate simplification: exactly one type in the stack calls it.
    • -
    • Why it's built this way: the interface lives in Application, not Infrastructure, so the CQRS logging decorators can enrich their log scope without taking an ASP.NET dependency, which keeps the dependency arrow pointing inward [Rubric §3, Clean Architecture]. The same shape is deliberately mirrored by the tenancy abstraction: ITenantContext names ICorrelationContext as its model (one scoped instance per request, populated once at the edge).
    • -
    • Where it's used: registered as services.TryAddScoped<ICorrelationContext, CorrelationContext>() in the Infrastructure DI extensions, so one instance lives per request and a host may substitute its own implementation by registering first. It is written by CorrelationIdMiddleware, which resolves it as a method parameter of InvokeAsync (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/CorrelationIdMiddleware.cs:27) and sets it from the header, the current Activity trace ID, or HttpContext.TraceIdentifier in that order (CorrelationIdMiddleware.cs:32-36). It is read by LoggingCommandDecorator<TCommand, TResult> and LoggingQueryDecorator<TQuery, TResult>.
    • -
    • Caveats / not-in-source: nothing in the framework source publishes the correlation ID onto outbox messages or broker headers, so cross-process correlation today rests on the HTTP header echo plus OpenTelemetry's own trace context, not on this interface.
    • +
    • What it is: one named step of the shared HTTP edge pipeline: a stable string identifier plus the delegate that registers that step's middleware on a WebApplication. It is the atom that makes the edge order data rather than a hand-written sequence of app.UseX() calls.
    • +
    • Depends on: ASP.NET Core's WebApplication (through the Action<WebApplication> payload) and nothing else first-party. Its names normally come from MiddlewarePipelineStepNames; its container is MiddlewarePipelineBuilder.
    • +
    • Concept introduced (the pipeline as an inspectable list, not an imperative script). A conventional ASP.NET composition root is its own ordering: the order exists only as the sequence of statements in Program.cs, so nothing can read it, assert on it, or edit it. Modelling each step as a value with a name means the whole order can be enumerated (StepNames on the builder), rewritten by name, validated, and frozen by a unit test with no host running at all. The doc comment states the payoff directly: steps are pure data until UseCommonMiddlewarePipeline runs them in order, which is what makes the pipeline order testable without a running host (MiddlewarePipelineStep.cs:5-10). [Rubric §2, Design Patterns] assesses whether a recognizable pattern is applied where it earns its keep; this is the classic "reify the plan, then execute it" split, and it is what unlocks the fitness function. [Rubric §14, Testability] assesses whether behavior can be asserted cheaply: because a step never touches a host until Configure is invoked, the entire order runs in the fast unit tier.
    • +
    • Walkthrough: a positional record with two components, each re-declared as a validated property.
        +
      • Name (MiddlewarePipelineStep.cs:27) shadows the positional parameter with Validated(Name), which calls ArgumentException.ThrowIfNullOrWhiteSpace (:32-36). Null, empty, and whitespace names are rejected at construction, so an anchor lookup can never match a meaningless key.
      • +
      • Configure (:30) is validated the same way through the second Validated overload, which null-guards the delegate (:38-42).
      • +
      • The init accessors keep both immutable after construction, so a step handed to the builder cannot be mutated behind the builder's back.
      • +
      • The parameter doc records the runtime contract: Configure is invoked exactly once, in pipeline order, at the point UseCommonMiddlewarePipeline is called, so anything the delegate reads from the host (configuration, environment) is evaluated at configure time and not per request (:16-20).
      • +
      +
    • +
    • Why it's built this way: the validation-in-the-property-initializer idiom is how a positional record enforces invariants without giving up the concise declaration or the value semantics. Value equality also matters here: two steps with the same name and delegate compare equal, which keeps assertions in MiddlewarePipelineBuilder tests simple. See ADR-079, which records the move from an inline sequence to named steps.
    • +
    • Where it's used: MiddlewarePipelineBuilder.CreateDefault() constructs eighteen of them (MiddlewarePipelineBuilder.cs:31-156), and a host customizing the pipeline constructs its own to pass to InsertBefore / InsertAfter / Replace.
    • +
    • Caveats / not-in-source: name uniqueness is not enforced here; it is enforced by the builder's RequireUniqueName at insertion time (MiddlewarePipelineBuilder.cs:304-312). Constructing two steps with the same name in isolation is legal.
    -

    JwtForwardingDelegatingHandler

    +

    MiddlewarePipelineStepNames

    -

    MMCA.Common.Infrastructure · MMCA.Common.Infrastructure.Http · MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Http/JwtForwardingDelegatingHandler.cs:17 · Level 0 · class (sealed)

    +

    MMCA.Common.API · MMCA.Common.API.Startup · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/MiddlewarePipelineStepNames.cs:14 · Level 0 · class (static, constants)

      -
    • What it is: an HTTP DelegatingHandler that copies the inbound Authorization header from the current HttpContext onto every outgoing request, so a typed service client forwards the caller's bearer token to a downstream service without any handler threading the token by hand.
    • -
    • Depends on: Microsoft.AspNetCore.Http.IHttpContextAccessor (primary-constructor parameter, JwtForwardingDelegatingHandler.cs:17) and BCL DelegatingHandler/AuthenticationHeaderValue. It is the HTTP twin of the gRPC JwtForwardingClientInterceptor, a relationship the doc comment states outright (:11-15).
    • -
    • Concept introduced (token propagation for distributed authorization). [Rubric §7, Microservices Readiness] assesses whether a module can be lifted into its own process without rewriting application code, and [Rubric §11, Security] assesses how identity is carried across a trust boundary. When an extracted service calls another service on behalf of a user, the downstream needs that user's JWT to authorize the call. Doing that per call site would be both repetitive and easy to forget; putting it in the HttpClient message pipeline makes it a property of the client registration, so no application code participates [Rubric §10, Cross-Cutting].
    • -
    • Walkthrough: one override, SendAsync (JwtForwardingDelegatingHandler.cs:22).
        -
      • Null-guards the request (:24).
      • -
      • If request.Headers.Authorization is already set it forwards untouched (:27-30), so an explicit token or a prior handler in the chain is never overwritten.
      • -
      • Reads the inbound header through IHttpContextAccessor (:32); when there is no context or no header (background processors, outbox dispatch, tests) it is a plain no-op and calls base.SendAsync (:33-36).
      • -
      • Normalizes the scheme: a value starting with Bearer (case-insensitive) has the prefix stripped, otherwise the whole string is treated as the token, and it is re-attached as a fresh AuthenticationHeaderValue("Bearer", token) (:40-45). The BearerScheme constant (:19) is the one place the scheme name is spelled.
      • +
      • What it is: the eighteen well-known step names of the default edge pipeline, as const string fields declared in runtime order. A host customizing the pipeline addresses steps by these constants.
      • +
      • Depends on: nothing. It is referenced by MiddlewarePipelineBuilder (which seeds the defaults with these names and re-checks adjacencies by them) and by MiddlewarePipelineOrderTestsBase.
      • +
      • Concept (names as a published contract). Because a host inserts, replaces, and removes steps by name, the names are part of the framework's public API surface: the doc comment says so outright and adds that renaming one is a breaking change (MiddlewarePipelineStepNames.cs:3-7). [Rubric §9, API & Contract Design] assesses whether the surface a consumer binds to is explicit and stable; hoisting each name into a const is what turns a magic string into that surface, and what lets the compiler find every caller when the list changes. [Rubric §34, Architecture Governance & Documentation] also applies: the declaration order below the summary is the documented runtime order, so the code and the documentation cannot drift apart.
      • +
      • Walkthrough: the constants in declaration order, which is application order (outermost first).
          +
        • ExceptionHandler (:17), CorrelationId (:20), RequestLocalization (:23).
        • +
        • PreForwardedCapture (:29) and ForwardedHeaders (:32). The comment on the first (:25-28) states the adjacency: it must run immediately before ForwardedHeaders, because it captures the transport scheme and host as the connection saw them, before the forwarded headers rewrite them.
        • +
        • HttpsRedirection (:35), ResponseCompression (:38), Routing (:41), Cors (:44), Authentication (:47).
        • +
        • TenantResolution (:53), whose comment (:49-52) records that it must run immediately after Authentication because the claim strategy reads HttpContext.User.
        • +
        • RateLimiting (:56), documented as needing to run after Authentication per ADR-019.
        • +
        • SoftDeletedUserFilter (:59), Authorization (:62), OutputCache (:65).
        • +
        • JwksEndpoint (:68), OidcDiscoveryEndpoint (:71), and Controllers (:74), the innermost step.
        • +
        • The class summary (:8-12) flags that several of these adjacencies are load-bearing and are re-checked by MiddlewarePipelineBuilder.Build.
      • -
      • Why it's built this way: the no-op-without-context branch is what lets the handler be registered unconditionally on a typed client. Background services run with their own credentials rather than an ambient user's token, and without that branch every non-HTTP invocation path would need conditional wiring at the call site.
      • -
      • Where it's used: AddTypedServiceClient<TInterface, TImplementation>(serviceName) in the Infrastructure DI extensions registers it transiently and attaches it to the client pipeline alongside AddHttpContextAccessor() and the standard resilience handler. That helper is the HTTP counterpart to AddTypedGrpcClient<T>; its doc comment says to prefer gRPC for service-to-service contracts and to use this for webhook receivers, public REST endpoints, and third-party API wrappers.
      • +
      • Why it's built this way: const rather than static readonly so the values are usable in attribute arguments and switch patterns, and one file rather than a nested enum so the XML doc on each field can carry the ordering rationale next to the name it explains. See ADR-079.
      • +
      • Where it's used: MiddlewarePipelineBuilder.CreateDefault() names every seeded step with these constants; Build() names them again in its four invariant checks; and MiddlewarePipelineOrderTestsBase.ExpectedStepNames (MMCA.Common/Source/Hosting/MMCA.Common.Testing/MiddlewarePipelineOrderTestsBase.cs:38-58) lists all eighteen as the frozen expected order, which each app's MiddlewarePipelineOrderTests subclasses.

      OpenApiEndpointExtensions

      -

      MMCA.Common.API · MMCA.Common.API.Startup · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/OpenApiEndpointExtensions.cs:18 · Level 0 · class (static, extension block)

      +

      MMCA.Common.API · MMCA.Common.API.Startup · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/OpenApiEndpointExtensions.cs:22 · Level 0 · class (static, extension block)

      • What it is: two extension(WebApplication app) mapping helpers that expose the generated OpenAPI document and an optional interactive reference UI, both outside Production only.
      • Depends on: Scalar.AspNetCore (NuGet) for the reference UI and Asp.Versioning's WithDocumentPerVersion() convention. It pairs with AddCommonOpenApi() on WebApplicationBuilderExtensions, which registers the generator.
      • -
      • Concept introduced (the OpenAPI document as a dev/CI artifact, not a public surface). [Rubric §9, API & Contract Design] assesses whether an API has a machine-readable contract and whether that contract is guarded against silent drift. The doc comment (OpenApiEndpointExtensions.cs:7-17) is explicit on both halves: the document is the source of truth for the API surface and is meant to be guarded by a contract-snapshot test in the consumer integration tiers, which the framework deliberately does not duplicate because the surface lives in the consumer hosts. Mapping outside Production is the security posture [Rubric §11, Security]: these are internal services reached through the Gateway, which does not route the endpoint.
      • +
      • Concept introduced (the OpenAPI document as a dev/CI artifact, not a public surface). [Rubric §9, API & Contract Design] assesses whether an API has a machine-readable contract and whether that contract is guarded against silent drift. The doc comment (OpenApiEndpointExtensions.cs:7-21) is explicit that the guarding happens at two levels: the framework-owned part of the generated document (the versioned naming convention, the unbound-route-token backfill, the generated ProblemDetails error schema) is diffed against a committed baseline in-repo by OpenApiBaselineTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/OpenApi/OpenApiBaselineTests.cs), which fails on any change until the baseline is regenerated deliberately in the same pull request, while each consumer's concrete API surface stays the concern of the contract-snapshot tests in that host's integration tier. Mapping outside Production is the security posture [Rubric §11, Security]: these are internal services reached through the Gateway, which does not route the endpoint.
      • Walkthrough
          -
        • MapCommonOpenApi() (OpenApiEndpointExtensions.cs:30) calls the built-in MapOpenApi() only when !app.Environment.IsProduction() (:32-35) and chains .WithDocumentPerVersion(), which applies the API-versioning convention so the route resolves one document per discovered API version (/openapi/v1.json for v1.0, doc comment :22-29). It is a no-op in Production and returns app for chaining (:37).
        • -
        • MapCommonScalarUi() (OpenApiEndpointExtensions.cs:48) is the opt-in developer convenience: it calls MapScalarApiReference() outside Production (:50-53), rendering /scalar/{documentName}. Assets ship inside the Scalar.AspNetCore package rather than a CDN (:45-46), so it works offline and in CI.
        • +
        • MapCommonOpenApi() (OpenApiEndpointExtensions.cs:34) calls the built-in MapOpenApi() only when !app.Environment.IsProduction() (:36-39) and chains .WithDocumentPerVersion(), which applies the API-versioning convention so the route resolves one document per discovered API version (/openapi/v1.json for v1.0, doc comment :26-33). It is a no-op in Production and returns app for chaining (:41).
        • +
        • MapCommonScalarUi() (OpenApiEndpointExtensions.cs:52) is the opt-in developer convenience: it calls MapScalarApiReference() outside Production (:54-57), rendering /scalar/{documentName}. Assets ship inside the Scalar.AspNetCore package rather than a CDN (:49-50), so it works offline and in CI.
      • Why it's built this way: one shared pair of helpers keeps every service's OpenAPI story identical and enforces the "internal spec, not public surface" convention in one place instead of per host. The version-aware document mapping is what keeps the route stable as versions accumulate (ADR-046).
      • -
      • Where it's used: inside this workspace the only caller is the framework's own test host for ApiParameterDescriptorBackfillProvider. The ADC service hosts today call the stock ASP.NET pair directly instead (services.AddOpenApi() and a MapOpenApi() guarded by their own environment check). This is the framework offering a convention ahead of the consumers adopting it.
      • +
      • Where it's used: inside this workspace the only caller is the framework's own probe host, OpenApiProbeHost (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/OpenApi/OpenApiProbeHost.cs:39 and :59), which is what OpenApiBaselineTests and the ApiParameterDescriptorBackfillProvider tests boot. The ADC and Store service hosts today call the stock ASP.NET pair directly instead. This is the framework offering a convention ahead of the consumers adopting it; OpenApiContractTestsBase (MMCA.Common/Source/Hosting/MMCA.Common.Testing/OpenApiContractTestsBase.cs:11) is the base a consumer subclasses once it does.

      AppAssociationEndpointExtensions

      @@ -1705,16 +1801,16 @@

      AppAssociationEndpointExtensions

      • What it is: a mapping helper that serves the two well-known app-association documents from an AppAssociationOptions: Android Digital Asset Links at /.well-known/assetlinks.json and the Apple App Site Association at /.well-known/apple-app-site-association.
      • Depends on: AppAssociationOptions (Level 0) for every value; ASP.NET IEndpointRouteBuilder and Results.Json.
      • -
      • Concept (anonymous, machine-verified association documents). Both endpoints are anonymous by design because the OS and Apple's CDN fetch them without credentials, which the doc comment states (AppAssociationEndpointExtensions.cs:11-13). [Rubric §9, API & Contract Design]: the exact JSON shape is a contract a third party parses, so the code builds it structurally out of dictionaries rather than formatting strings by hand.
      • +
      • Concept (anonymous, machine-verified association documents). Both endpoints are anonymous by design because the OS and Apple's CDN fetch them without credentials, which the doc comment states (AppAssociationEndpointExtensions.cs:12-13). [Rubric §9, API & Contract Design]: the exact JSON shape is a contract a third party parses, so the code builds it structurally out of dictionaries rather than formatting strings by hand.
      • Walkthrough
        • Two path constants: AssetLinksPath (AppAssociationEndpointExtensions.cs:18) and AppleAppSiteAssociationPath (:24). The comment on the Apple constant (:20-23) records that the path deliberately has no file extension because Apple requires that exact path, while the content type must still be JSON.
        • MapAppAssociationEndpoints(AppAssociationOptions options) (:35) null-guards the options (:37), builds both documents once at map time because they are static for the process lifetime (:39-40), then maps two GETs that each return Results.Json(...), are .AllowAnonymous() and are .ExcludeFromDescription() so they never leak into the OpenAPI document (:42-48).
        • -
        • BuildAssetLinks (:54) emits the delegate_permission/common.handle_all_urls relation with the Android package name and the fingerprint list (:58-64).
        • +
        • BuildAssetLinks (:54) emits the delegate_permission/common.handle_all_urls relation with the Android package name and the fingerprint list (:56-65).
        • BuildAppleAppSiteAssociation (:68) emits the applinks details block, projecting each configured URL pattern into a { "/": pattern } component (:78-80), plus the webcredentials apps list naming the same app id (:84-87).
      • Why it's built this way: building the payload once at map time avoids a per-request allocation for a document that never changes [Rubric §12, Performance & Scalability], and holding the RFC 8615 well-known paths as public constants keeps them from drifting between hosts or between the endpoint and any gateway forwarding rule.
      • -
      • Where it's used: the ADC Blazor web host maps them once at startup (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:169), which is the host that ships a companion MAUI Hybrid app.
      • +
      • Where it's used: the ADC Blazor web host maps them once at startup (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:169), which is the host that ships a companion MAUI Hybrid app. Both documents' exact shapes are asserted by AppAssociationEndpointTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Startup/AppAssociationEndpointTests.cs).

      JwksEndpointExtensions

      @@ -1724,9 +1820,9 @@

      JwksEndpointExtensions

    • What it is: maps /.well-known/jwks.json, serializing the active JsonWebKeySet of the Identity service so other services can validate its RS256 tokens.
    • Depends on: IJwksProvider, resolved from DI per request, whose implementation is RsaJwksProvider; plus Microsoft.IdentityModel.Tokens.JsonWebKeySet and System.Text.Json.
    • Concept (the public-key distribution endpoint of cross-service auth). [Rubric §11, Security] and [Rubric §7, Microservices Readiness]: with RS256 only the Identity service holds the private key, and every other service fetches the public keys from here, so no shared secret ever crosses a service boundary (ADR-004). The endpoint is .AllowAnonymous() (JwksEndpointExtensions.cs:39) because clients fetch it before they have a token, which is what JWKS means (RFC 7517; the doc comment says so at :27-28).
    • -
    • Walkthrough: the DefaultJwksPath constant (JwksEndpointExtensions.cs:20) pins the RFC 8615 path. MapJwksEndpoint() (:31) maps a single GET whose handler takes HttpContext and IJwksProvider as parameters (:33), calls GetJsonWebKeySet() (:35), serializes with JsonSerializer (:36), sets application/json; charset=utf-8 explicitly (:37), and writes the body (:38). The whole endpoint is nine lines because the key material and its rotation live behind the provider.
    • -
    • Why it's built this way: non-Identity hosts still map it (see WebApplicationExtensions, which calls it unconditionally); their provider returns an empty key set rather than erroring, so the wiring is uniform across every host and a single gateway forwarder rule for /.well-known/* covers JWKS discovery for the whole platform. That same prefix is one of the paths the global rate limiter bypasses (WebApplicationBuilderExtensions.cs:53).
    • -
    • Where it's used: mapped inside UseCommonMiddlewarePipeline() (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:118), so every host that adopts the shared pipeline serves it; the path it owns is the jwks_uri value that OidcDiscoveryEndpointExtensions advertises, which is in turn what AddForwardedJwtBearer (on WebApplicationBuilderExtensions) reaches through OIDC discovery.
    • +
    • Walkthrough: the DefaultJwksPath constant (JwksEndpointExtensions.cs:20) pins the RFC 8615 path. MapJwksEndpoint() (:31) maps a single GET whose handler takes HttpContext and IJwksProvider as parameters (:33), calls GetJsonWebKeySet() (:35), serializes with JsonSerializer (:36), sets application/json; charset=utf-8 explicitly (:37), and writes the body (:38). The whole endpoint is under ten lines because the key material and its rotation live behind the provider.
    • +
    • Why it's built this way: non-Identity hosts still map it (the JwksEndpoint step is unconditional in the default pipeline, MiddlewarePipelineBuilder.cs:140-147); their provider returns an empty key set rather than erroring, so the wiring is uniform across every host and a single gateway forwarder rule for /.well-known/* covers JWKS discovery for the whole platform. That same prefix is one of the paths the global rate limiter bypasses (WebApplicationBuilderExtensions.cs:63).
    • +
    • Where it's used: applied as the JwksEndpoint step of the default pipeline seeded by MiddlewarePipelineBuilder (MiddlewarePipelineBuilder.cs:147), so every host that adopts the shared pipeline serves it; the path it owns is the jwks_uri value that OidcDiscoveryEndpointExtensions advertises, which is in turn what AddForwardedJwtBearer (on WebApplicationBuilderExtensions) reaches through OIDC discovery.

    MiniProfilerExtensions

    @@ -1736,9 +1832,10 @@

    MiniProfilerExtensions

  • What it is: a conditional MiniProfiler registration helper. When ApplicationSettings.UseMiniProfiler is true it registers MiniProfiler plus its Entity Framework integration; otherwise it does nothing.
  • Depends on: ApplicationSettings and StackExchange.Profiling (NuGet).
  • Concept (opt-in, settings-gated profiling). [Rubric §13, Observability & Operability] assesses whether diagnostics exist and whether they cost anything when switched off. One configuration flag turns a cross-cutting profiler on or off with no application code involved, and when off the MiniProfiler services are never registered at all, so there is no middleware and no per-request work.
  • -
  • Walkthrough: one member, AddMiniProfilerIfEnabled(ApplicationSettings) (MiniProfilerExtensions.cs:16). It tests applicationSettings.UseMiniProfiler (:18) and only then calls AddMiniProfiler(...) with a /profiler route base, PopupShowTimeWithChildren, the dark color scheme (:20-25), and .AddEntityFramework() so EF/SQL timings appear inline. It returns services either way (:28) so the call chains.
  • -
  • Why it's built this way: gating on a settings flag rather than #if DEBUG lets one specific environment (a staging slot, say) enable profiling without a rebuild, while production leaves it off and pays nothing. The default is off: ApplicationSettings.UseMiniProfiler is a plain bool with no initializer.
  • -
  • Where it's used: no host in this workspace calls it today. AddAPI(...) (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:44) does not invoke it, and no ADC or Store Program.cs does either; the helper is available for a host that opts in.
  • +
  • Walkthrough: one member, AddMiniProfilerIfEnabled(ApplicationSettings) (MiniProfilerExtensions.cs:16). It tests applicationSettings.UseMiniProfiler (:18) and only then calls AddMiniProfiler(...) with a /profiler route base, PopupShowTimeWithChildren, the dark color scheme (:20-25), and .AddEntityFramework() so EF and SQL timings appear inline. It returns services either way (:28) so the call chains.
  • +
  • Why it's built this way: gating on a settings flag rather than #if DEBUG lets one specific environment (a staging slot, say) enable profiling without a rebuild, while production leaves it off and pays nothing.
  • +
  • Where it's used: no host in this workspace calls it today. AddAPI(...) in MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs does not invoke it, and no ADC, Store, or Helpdesk Program.cs does either; the helper is available for a host that opts in.
  • +
  • Caveats / not-in-source: the helper registers services only. Nothing in this file maps the profiler's own middleware, so a host opting in must also call UseMiniProfiler() itself.
  • OidcDiscoveryEndpointExtensions

    @@ -1755,7 +1852,25 @@

    OidcDiscoveryEndpointExtensions

  • Why it's built this way: the comment at :68-75 documents the subtle reason jwks_uri is built from the configured issuer and not from the request. Aspire/DCP fronts the Identity service on per-launchSettings ports and rewrites Host via X-Forwarded-Host to canonical ports that internal callers cannot always reach, so reusing the issuer keeps issuer and jwks_uri origin-aligned (a common OIDC client requirement) and routes both through the same gateway that fronts /Auth, which means one forwarder rule for /.well-known/* covers everything.
  • -
  • Where it's used: mapped unconditionally by WebApplicationExtensions in the shared pipeline (WebApplicationExtensions.cs:119); consumed by the bearer middleware that AddForwardedJwtBearer configures on WebApplicationBuilderExtensions.
  • +
  • Where it's used: applied unconditionally as the OidcDiscoveryEndpoint step of the default pipeline (MiddlewarePipelineBuilder.cs:149-151); consumed by the bearer middleware that AddForwardedJwtBearer configures on WebApplicationBuilderExtensions, which deliberately leaves ValidIssuer unset so the issuer comes from this document (WebApplicationBuilderExtensions.cs:487-492).
  • +
  • Caveats / not-in-source: the pre-forwarded scheme and host captured into HttpContext.Items by the PreForwardedCapture step exist for exactly this endpoint's benefit (WebApplicationExtensions.cs:16-33), but the current handler composes jwks_uri from the configured issuer only, so those items are not read on this path today.
  • + +

    InsecureJwtMetadataWarningStartupFilter

    +
    +

    MMCA.Common.API · MMCA.Common.API.Startup · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/InsecureJwtMetadataWarningStartupFilter.cs:15 · Level 3 · class (internal, sealed, partial)

    +
    +
      +
    • What it is: a one-purpose IStartupFilter that writes a single warning at host startup when AddForwardedJwtBearer resolved RequireHttpsMetadata to false outside Development. It changes no behavior; it only makes a deliberate weakening visible in the logs.
    • +
    • Depends on: Microsoft.AspNetCore.Hosting.IStartupFilter, ILogger<T>, the source-generated [LoggerMessage] attribute, and WebApplicationBuilderExtensions.RequireHttpsMetadataConfigKey for the key name it echoes.
    • +
    • Concept introduced (IStartupFilter as a "log after the logging providers exist" hook). Registration-time code runs while the IServiceCollection is still being built, so the logging providers are not configured yet and anything written there is dropped. IStartupFilter.Configure runs later, once the provider is built and the application pipeline is being assembled, which is the first moment a warning is guaranteed to reach a sink. The doc comment states exactly that reasoning (InsecureJwtMetadataWarningStartupFilter.cs:7-13). [Rubric §11, Security] assesses whether a security-relevant deviation is deliberate, narrow, and visible; the code permits the deviation (an internal-ingress cleartext authority is a real deployment) but refuses to let it be silent. [Rubric §13, Observability & Operability] assesses whether an operator can see the posture a deployment is actually running: this warning is the only place the resolved value surfaces at runtime.
    • +
    • Walkthrough: two members.
        +
      • Configure(Action<IApplicationBuilder> next) (:19) logs once (:21) and returns next unchanged (:23). It inserts nothing into the request pipeline, so the filter costs nothing per request; the whole type is a startup-time side effect wearing a pipeline interface.
      • +
      • LogInsecureJwtMetadata (:29) is a [LoggerMessage] source-generated partial at LogLevel.Warning (:26-28). The message names the config key that produced the value and tells the operator what makes it safe (an internal-ingress cleartext h2c authority) and what to do (record that justification beside the setting in the deployment template).
      • +
      +
    • +
    • Why it's built this way: the filter is registered through TryAddEnumerable(ServiceDescriptor.Singleton<IStartupFilter, ...>) (WebApplicationBuilderExtensions.cs:462-463), which de-duplicates on implementation type, so a host that calls AddForwardedJwtBearer more than once still gets exactly one warning. Registration is itself conditional (:460): the filter is only added when the resolved value is false and the environment is not Development, so a developer's normal loop stays quiet. Source-generated logging avoids boxing and string formatting on a path that runs once, which is the framework's convention rather than a hot-path optimization here.
    • +
    • Where it's used: registered only from AddForwardedJwtBearer (WebApplicationBuilderExtensions.cs:460-464). Its registration conditions are asserted by ForwardedJwtBearerSecurityTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Startup/ForwardedJwtBearerSecurityTests.cs). In the deployed apps the ADC service hosts document the override explicitly: Azure sets Authentication:JwtBearer:RequireHttpsMetadata to false because the authority is the internal-ingress h2c URL (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:290-293), which is precisely the case this filter exists to annotate.
    • +
    • Caveats / not-in-source: nothing in the framework fails a build or a deployment on this warning. Whether an operator acts on it is a process concern, and there is no ADR for this decision in Website/docs-src/adr/ at the time of writing.

    SignalRExtensions

    @@ -1766,68 +1881,87 @@

    SignalRExtensions

  • Depends on: NotificationHub (Infrastructure), PushNotificationSettings, and IOptions<T>.
  • Concept (conditional real-time endpoint mapping). [Rubric §6, CQRS & Event-Driven]: the SignalR hub is the real-time delivery arm of the notification pipeline, so mapping it behind a settings gate means a host that does not push notifications simply never opens the endpoint, and the same Program.cs line is safe in every host.
  • Walkthrough: MapNotificationHub() (SignalRExtensions.cs:22) resolves IOptions<PushNotificationSettings> through GetService<T>(), which returns null when nothing registered it, and takes ?.Value (:24). Only when settings is { Enabled: true } does it call MapHub<NotificationHub>(settings.HubPath) (:25-28). The doc comment (:16-21) notes it must run after UseCommonMiddlewarePipeline() so authentication and routing are already in place.
  • -
  • Why it's built this way: GetService rather than GetRequiredService, plus the property-pattern guard, is what makes the call unconditionally safe; it matches the same "always call, no-op if not applicable" convention as the JWKS and OIDC mappers. The hub path is also why the JWT bearer options carry an access_token query-string fallback for /hubs (WebApplicationBuilderExtensions.cs:470-481 and :520-531): a WebSocket cannot send an Authorization header.
  • -
  • Where it's used: the ADC Notification service maps it after the shared pipeline (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:275, with the pipeline itself at :258); that host reads the hub path from configuration rather than hard-coding it.
  • +
  • Why it's built this way: GetService rather than GetRequiredService, plus the property-pattern guard, is what makes the call unconditionally safe; it matches the same "always call, no-op if not applicable" convention as the JWKS and OIDC mappers. The hub path is also why the JWT bearer options carry an access_token query-string fallback for /hubs on both authentication paths (WebApplicationBuilderExtensions.cs:504-517 and :554-567): a WebSocket cannot send an Authorization header.
  • +
  • Where it's used: the ADC Notification service maps it after the shared pipeline (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:281, with the pipeline itself at :264); that host reads the hub path from configuration rather than hard-coding it (:277).
  • WebApplicationBuilderExtensions

    -

    MMCA.Common.API · MMCA.Common.API.Startup · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:29 · Level 3 · class (static, extension block)

    +

    MMCA.Common.API · MMCA.Common.API.Startup · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:31 · Level 3 · class (static, extension block)

      -
    • What it is: the consolidated builder-side registration surface shared by every MMCA host: API versioning, rate limiting, response compression, OpenAPI, CORS, and the two JWT authentication modes (in-process validation and JWKS-forwarded validation). It is the sibling of WebApplicationExtensions, which owns middleware order; this one owns what goes into the DI container.
    • -
    • Depends on: JwtSettings and its JwtSigningAlgorithm; AddAuthorizationPolicies from MMCA.Common.API.Authorization; ApiParameterDescriptorBackfillProvider; RateLimitingSettings, RateLimitAlgorithm and RedisFixedWindowRateLimiter; ASP.NET rate-limiting, compression, CORS and Asp.Versioning primitives; Microsoft.IdentityModel.Tokens and StackExchange.Redis.
    • -
    • Concept introduced (per-user global rate limiting, a pluggable counter location, and algorithm-pinned JWT validation). [Rubric §12, Performance & Scalability] (a global limiter protects finite capacity, and a distributed counter makes the configured number mean the same thing behind a load balancer), [Rubric §11, Security] (algorithm pinning, HTTPS metadata, per-IP anonymous auth throttling) and [Rubric §9, API & Contract Design] (versioning, OpenAPI and compression handled identically across hosts rather than per host).
    • +
    • What it is: the consolidated builder-side registration surface shared by every MMCA host: API versioning, rate limiting, response compression, OpenAPI, CORS, and the two JWT authentication modes (in-process validation and JWKS-forwarded validation). It is the sibling of WebApplicationExtensions, which owns the runtime pipeline; this one owns what goes into the DI container.
    • +
    • Depends on: JwtSettings and its JwtSigningAlgorithm; AddAuthorizationPolicies from MMCA.Common.API.Authorization; ApiParameterDescriptorBackfillProvider; RateLimitingSettings, RateLimitAlgorithm and RedisFixedWindowRateLimiter; InsecureJwtMetadataWarningStartupFilter; ASP.NET rate-limiting, compression, CORS and Asp.Versioning primitives; Microsoft.IdentityModel.Tokens and StackExchange.Redis.
    • +
    • Concept introduced (per-user global rate limiting, a pluggable counter location, and algorithm-pinned JWT validation). [Rubric §12, Performance & Scalability] (a global limiter protects finite capacity, and a distributed counter makes the configured number mean the same thing behind a load balancer), [Rubric §11, Security] (algorithm pinning, HTTPS metadata resolution, per-IP anonymous auth throttling) and [Rubric §9, API & Contract Design] (versioning, OpenAPI and compression handled identically across hosts rather than per host).
    • Walkthrough: the load-bearing members, in file order.
        -
      • CorsPolicyAllowSpecificOrigins / CorsPolicyAllowAll (WebApplicationBuilderExtensions.cs:32, :35): the two policy names the pipeline chooses between by environment.
      • -
      • RateLimitPolicyAuthIp (:44): the named "auth-ip" policy for anonymous authentication attempts. Its comment (:37-43) states why it exists: the global limiter deliberately no-ops for anonymous traffic and per-account lockout is per-email, which would leave a password spray (one password, many emails) from a single source unthrottled.
      • -
      • IsRateLimitBypassed(HttpContext) (:50) exempts /health, /alive, /.well-known/* and application/grpc content types (:51-54), all legitimately high-frequency. It is internal rather than private specifically so the exemption logic is unit-testable through InternalsVisibleTo instead of only under a request flood (:48-49).
      • -
      • GlobalRateLimitPartition has two overloads. The permit-count one (:60-61) simply wraps its argument in a RateLimitingSettings and delegates; the settings one (:68) holds the logic: a NoLimiter for bypassed infrastructure (:70-73) and for unauthenticated callers (:75-78), otherwise a partition keyed by Identity.Name, then the user_id claim, then the remote IP, then the literal "authenticated" (:80-83), built through CreateLimitedPartition with redisScope: "global", queueLimit: 0 and allowDistributed: true (:85-92).
      • -
      • UserPolicyRateLimitPartition (:103) is the same shape for the opt-in "UserPolicy" limiter: one bucket per authenticated user, falling back to the client IP and then a shared anonymous bucket (:105-107), with redisScope: "user" and the configured queue limit (:109-116). It was extracted from the inline lambda it used to be so the key selection is unit-testable (:99-102).
      • -
      • CreateLimitedPartition (:137) is the single place a partition is built, and reading it is the fastest way to understand the whole limiter. When allowDistributed && settings.Distributed it resolves IConnectionMultiplexer through a nullable local, because HttpContext.RequestServices is declared non-nullable but is genuinely null outside a request pipeline such as a bare DefaultHttpContext in a unit test (:146-152). With a connection it returns a partition whose factory builds a RedisFixedWindowRateLimiter over $"{redisScope}:{key}" (:159-161), falling back to a null logger when none is registered (:156-157). Without a connection it deliberately falls through to the in-memory limiters rather than failing startup, so a host that turns the flag on before wiring Redis degrades to per-instance limits instead of losing rate limiting altogether (:164-166). The in-memory branch honours RateLimitAlgorithm: a sliding window with SegmentsPerWindow segments (:169-179) or the default fixed window (:181-187), both over TimeSpan.FromMinutes(1) with QueueProcessingOrder.OldestFirst.
      • -
      • AuthIpRateLimitPartition (:201 permit-count overload, :210 settings overload) partitions the "auth-ip" policy on the client IP and returns no limiter at all when the IP is unattributable (:212-215). The remark (:194-200) explains the choice: failing open on a null IP beats collapsing every such request into one shared bucket, which would throttle the in-process TestServer and the integration tier to a standstill. It passes allowDistributed: false (:223), so login throttling stays per-instance whatever Distributed says, because per-account login protection already backs it and a login throttle that fails open on a Redis outage is a worse trade than one that stays local (:132-135).
      • -
      • AddCommonApiVersioning() (:233): header-based versioning through the api-version reader with AssumeDefaultVersionWhenUnspecified and ReportApiVersions (:238-243), the API explorer group format 'v'VVV and SubstituteApiVersionInUrl (:244-248), then the backfill guard (:250). The comment (:235-237) records that DefaultApiVersion is deliberately not set because 1.0 is already the framework default and restating it trips AV0011/AV0024. See ADR-046.
      • -
      • AddCommonRateLimiting now has three overloads. The permit-count one (:285) keeps the original defaults permitLimit: 100, queueLimit: 2, perUserPermitLimit: 30, globalPermitLimit: 300, authIpPermitLimit: 30 and simply builds a RateLimitingSettings from them (:285-293). The IConfiguration one (:303) binds the RateLimiting section, falling back to a default instance when the section is absent (:307-308). The settings one (:321) does the work: rejection status 429 (:327), the always-on GlobalLimiter (:329-330), the opt-in "FixedPolicy" (:335-342, allowDistributed: false), "UserPolicy" (:344), and auth-ip (:354-356). Two comments carry the reasoning: "FixedPolicy" keeps its name whichever algorithm is configured, because it is referenced by name from [EnableRateLimiting] attributes in three repos and renaming it on a settings change would silently unlimit every endpoint using it (:332-334); and the auth-ip policy takes the client IP from Connection.RemoteIpAddress, which the shared pipeline has already resolved from X-Forwarded-For because UseForwardedHeaders runs before UseRateLimiter (:346-353). The long doc comment on the permit-count overload (:255-284) explains why anonymous traffic is deliberately unlimited and why authIpPermitLimit is 30 rather than a tighter 10: Blazor Server circuits issue the login call server-side, so every Server-circuit user shares the UI host's IP. See ADR-019.
      • -
      • AddCommonResponseCompression() (:363): Brotli plus Gzip, enabled for HTTPS, both at CompressionLevel.Fastest (:365-377); the comment (:373-375) justifies Fastest for gzip too on fractional-vCPU hosts serving dynamic payloads.
      • -
      • AddCommonOpenApi() (:392): services.AddApiVersioning().AddOpenApi() (:394) plus the backfill guard (:395). The comment (:382-391) notes the parameterless AddApiVersioning() only returns the builder, so options configured by AddCommonApiVersioning accumulate independently of call order. Pair it with MapCommonOpenApi() on OpenApiEndpointExtensions.
      • -
      • AddApiParameterDescriptorBackfill() (:407, private) registers ApiParameterDescriptorBackfillProvider via TryAddEnumerable (:408-409), which de-duplicates on implementation type so calling both AddCommonApiVersioning and AddCommonOpenApi installs the guard exactly once.
      • -
      • AddForwardedJwtBearer(authority, audience, requireHttpsMetadata = false) (:430) is the extracted-service mode: it validates its two string arguments (:435-436), sets Authority, Audience and RequireHttpsMetadata (:441-443), deliberately leaves ValidIssuer unset so the middleware takes the issuer from the discovery document (:451-456), and pins ValidAlgorithms = [RsaSha256] as defense against an algorithm-confusion swap (:458-464). It also installs the SignalR access_token query-string fallback for /hubs (:467-481) and then calls AddAuthorizationPolicies() (:484).
      • -
      • AddCommonAuthentication(IConfiguration) (:500) is the in-process mode: it binds JwtSettings with data-annotation validation on start (:502-505), builds validation parameters through BuildValidationParameters (:513), wires the same /hubs access-token fallback (:515-531), and adds the authorization policies (:534).
      • -
      • AddCommonCors(IConfiguration) (:543): the restrictive production policy takes its origins from Cors:AllowedOrigins and allowlists the SignalR headers and five methods with AllowCredentials (:547-554); the development any-origin policy sits under a justified #pragma warning disable S5122 explaining it is only ever selected when the environment is Development (:555-560).
      • -
      • GetValidatedSigningKey(string) (:571) decodes the Base64 HMAC key and throws when it is under 256 bits (:574-578), so a too-short secret fails at startup rather than weakening every token.
      • -
      • BuildValidationParameters(JwtSettings) (:590) branches on JwtSettings.SigningAlgorithm: RS256 requires RsaPublicKeyPem and throws a message that points at AddForwardedJwtBearer when it is missing (:592-598), imports the PEM into an RSA held for the app lifetime (:600-603, with a justified CA2000 suppression) and pins RS256 (:613); the default HS256 path builds a SymmetricSecurityKey from the validated secret and pins HmacSha256 (:617-630).
      • +
      • CorsPolicyAllowSpecificOrigins / CorsPolicyAllowAll (WebApplicationBuilderExtensions.cs:34, :37): the two policy names the pipeline chooses between by environment.
      • +
      • RateLimitPolicyAuthIp (:46): the named "auth-ip" policy for anonymous authentication attempts. Its comment (:39-45) states why it exists: the global limiter deliberately no-ops for anonymous traffic and per-account lockout is per-email, which would leave a password spray (one password, many emails) from a single source unthrottled.
      • +
      • RequireHttpsMetadataConfigKey (:54, value "Authentication:JwtBearer:RequireHttpsMetadata"): the one configuration key that can override the secure-by-default metadata posture. Its comment (:48-53) narrows the legitimate use to an authority that is genuinely plain HTTP (an internal-ingress h2c service URL) and asks for the justification to be recorded beside the setting.
      • +
      • IsRateLimitBypassed(HttpContext) (:60) exempts /health, /alive, /.well-known/* and application/grpc content types (:61-64), all legitimately high-frequency. It is internal rather than private specifically so the exemption logic is unit-testable through InternalsVisibleTo instead of only under a request flood (:58-59).
      • +
      • GlobalRateLimitPartition has two overloads. The permit-count one (:70-71) simply wraps its argument in a RateLimitingSettings and delegates; the settings one (:78) holds the logic: a NoLimiter for bypassed infrastructure (:80-83) and for unauthenticated callers (:85-88), otherwise a partition keyed by Identity.Name, then the user_id claim, then the remote IP, then the literal "authenticated" (:90-93), built through CreateLimitedPartition with redisScope: "global", queueLimit: 0 and allowDistributed: true (:95-102).
      • +
      • UserPolicyRateLimitPartition (:113) is the same shape for the opt-in "UserPolicy" limiter: one bucket per authenticated user, falling back to the client IP and then a shared anonymous bucket (:115-117), with redisScope: "user" and the configured queue limit (:119-126). It was extracted from the inline lambda it used to be so the key selection is unit-testable (:109-112).
      • +
      • CreateLimitedPartition (:147) is the single place a partition is built, and reading it is the fastest way to understand the whole limiter. When allowDistributed && settings.Distributed it resolves IConnectionMultiplexer through a nullable local, because HttpContext.RequestServices is declared non-nullable but is genuinely null outside a request pipeline such as a bare DefaultHttpContext in a unit test (:156-162). With a connection it returns a partition whose factory builds a RedisFixedWindowRateLimiter over $"{redisScope}:{key}" (:169-171), falling back to a null logger when none is registered (:166-167). Without a connection it deliberately falls through to the in-memory limiters rather than failing startup, so a host that turns the flag on before wiring Redis degrades to per-instance limits instead of losing rate limiting altogether (:174-176). The in-memory branch honours RateLimitAlgorithm: a sliding window with SegmentsPerWindow segments (:179-189) or the default fixed window (:191-197), both over TimeSpan.FromMinutes(1) with QueueProcessingOrder.OldestFirst.
      • +
      • AuthIpRateLimitPartition (:211 permit-count overload, :220 settings overload) partitions the "auth-ip" policy on the client IP and returns no limiter at all when the IP is unattributable (:222-225). The remark (:204-210) explains the choice: failing open on a null IP beats collapsing every such request into one shared bucket, which would throttle the in-process TestServer and the integration tier to a standstill. It passes allowDistributed: false (:233), so login throttling stays per-instance whatever Distributed says, because per-account login protection already backs it and a login throttle that fails open on a Redis outage is a worse trade than one that stays local (:142-146).
      • +
      • AddCommonApiVersioning() (:243): header-based versioning through the api-version reader with AssumeDefaultVersionWhenUnspecified and ReportApiVersions (:248-253), the API explorer group format 'v'VVV and SubstituteApiVersionInUrl (:254-258), then the backfill guard (:260). The comment (:245-247) records that DefaultApiVersion is deliberately not set because 1.0 is already the framework default and restating it trips AV0011/AV0024. See ADR-046.
      • +
      • AddCommonRateLimiting has three overloads. The permit-count one (:295) keeps the defaults permitLimit: 100, queueLimit: 2, perUserPermitLimit: 30, globalPermitLimit: 300, authIpPermitLimit: 30 and simply builds a RateLimitingSettings from them (:295-303). The IConfiguration one (:313) binds the RateLimiting section, falling back to a default instance when the section is absent (:317-318). The settings one (:331) does the work: rejection status 429 (:337), the always-on GlobalLimiter (:339-340), the opt-in "FixedPolicy" (:345-352, allowDistributed: false), "UserPolicy" (:354), and auth-ip (:364-366). Two comments carry the reasoning: "FixedPolicy" keeps its name whichever algorithm is configured, because it is referenced by name from [EnableRateLimiting] attributes in three repos and renaming it on a settings change would silently unlimit every endpoint using it (:342-344); and the auth-ip policy takes the client IP from Connection.RemoteIpAddress, which the shared pipeline has already resolved from X-Forwarded-For because UseForwardedHeaders runs before UseRateLimiter (:356-363). The long doc comment on the permit-count overload (:265-294) explains why anonymous traffic is deliberately unlimited and why authIpPermitLimit is 30 rather than a tighter 10: Blazor Server circuits issue the login call server-side, so every Server-circuit user shares the UI host's IP. See ADR-019.
      • +
      • AddCommonResponseCompression() (:373): Brotli plus Gzip, enabled for HTTPS, both at CompressionLevel.Fastest (:375-387); the comment (:383-385) justifies Fastest for gzip too on fractional-vCPU hosts serving dynamic payloads.
      • +
      • AddCommonOpenApi() (:402): services.AddApiVersioning().AddOpenApi() (:404) plus the backfill guard (:405). The comment (:392-401) notes the parameterless AddApiVersioning() only returns the builder, so options configured by AddCommonApiVersioning accumulate independently of call order. Pair it with MapCommonOpenApi() on OpenApiEndpointExtensions.
      • +
      • AddApiParameterDescriptorBackfill() (:417, private) registers ApiParameterDescriptorBackfillProvider via TryAddEnumerable (:418-419), which de-duplicates on implementation type, so calling both AddCommonApiVersioning and AddCommonOpenApi installs the guard exactly once.
      • +
      • AddForwardedJwtBearer(authority, audience, configuration, environment, requireHttpsMetadata = null) (:444) is the extracted-service mode. It validates all four required arguments (:451-454), then resolves the metadata posture in three steps: the explicit argument when it is not null, then RequireHttpsMetadataConfigKey, then true everywhere except Development (:456-458). When the resolved value is false outside Development it registers InsecureJwtMetadataWarningStartupFilter so the deviation is logged once at startup (:460-464), and finally delegates to the private AddForwardedJwtBearerCore (:466).
      • +
      • AddForwardedJwtBearerCore (:469, private) does the JWT wiring: Authority, Audience and RequireHttpsMetadata (:477-479), validation parameters that deliberately leave ValidIssuer unset so the middleware takes the issuer from the discovery document (:487-492), and ValidAlgorithms = [RsaSha256] as defense against an algorithm-confusion swap (:494-500). It also installs the SignalR access_token query-string fallback for /hubs (:503-517) and then calls AddAuthorizationPolicies() (:520).
      • +
      • AddCommonAuthentication(IConfiguration) (:536) is the in-process mode: it binds JwtSettings with data-annotation validation on start (:538-541), builds validation parameters through BuildValidationParameters (:549), wires the same /hubs access-token fallback (:551-567), and adds the authorization policies (:570).
      • +
      • AddCommonCors(IConfiguration) (:579): the restrictive production policy takes its origins from Cors:AllowedOrigins and allowlists four headers and five methods with AllowCredentials (:583-590); the development any-origin policy sits under a justified #pragma warning disable S5122 explaining it is only ever selected when the environment is Development (:591-596). See ADR-082.
      • +
      • GetValidatedSigningKey(string) (:607, internal static, outside the extension block) decodes the Base64 HMAC key and throws when it is under 256 bits (:610-614), so a too-short secret fails at startup rather than weakening every token.
      • +
      • BuildValidationParameters(JwtSettings) (:626, also internal static) branches on JwtSigningAlgorithm: RS256 requires RsaPublicKeyPem and throws a message that points at AddForwardedJwtBearer when it is missing (:630-634), imports the PEM into an RSA held for the app lifetime (:636-639, with a justified CA2000 suppression) and pins RS256 (:649); the default HS256 path builds a SymmetricSecurityKey from the validated secret and pins HmacSha256 (:653-666).
    • -
    • Why it's built this way: two authentication entry points are the framework's monolith-to-microservice hinge (ADR-004): the monolith or the issuing Identity service validates in process against a local key, while an extracted service validates against the issuer's published JWKS with no shared secret. The explicit ValidAlgorithms pin on both paths is deliberate defense in depth rather than trust in the token header. On the limiter side, factoring every partition through CreateLimitedPartition is what let the Redis counter and the sliding window arrive without touching a single partition-key rule: the policy names, the bypass list and every key are identical whatever the settings say, and only the permit counts, the algorithm and the counter's location change (:313-317).
    • -
    • Where it's used: every ADC service host calls the builder-side quartet in one block (AddCommonCors, AddCommonApiVersioning, AddCommonRateLimiting, AddCommonResponseCompression). Identity hosts take the in-process mode while the other services take the forwarded mode. The "auth-ip" policy is applied by attribute on the login and register actions of AuthControllerBase (AuthControllerBase.cs:57 and :79), so every consumer inheriting that base gets it without opting in. The internal partition helpers are exercised directly by WebApplicationBuilderExtensionsTests.
    • +
    • Why it's built this way: two authentication entry points are the framework's monolith-to-microservice hinge (ADR-004): the monolith or the issuing Identity service validates in process against a local key, while an extracted service validates against the issuer's published JWKS with no shared secret. The explicit ValidAlgorithms pin on both paths is deliberate defense in depth rather than trust in the token header. Taking IConfiguration and IHostEnvironment as required arguments on AddForwardedJwtBearer is what makes "HTTPS metadata unless you say otherwise" the default a host cannot forget: the insecure value stays reachable for the deployments that need it, but only through a named key and never silently. On the limiter side, factoring every partition through CreateLimitedPartition is what let the Redis counter and the sliding window arrive without touching a single partition-key rule: the policy names, the bypass list and every key are identical whatever the settings say, and only the permit counts, the algorithm and the counter's location change (:323-327).
    • +
    • Where it's used: every ADC and Store service host calls the builder-side quartet in one block (AddCommonCors, AddCommonApiVersioning, AddCommonRateLimiting, AddCommonResponseCompression). Identity hosts take the in-process mode while the other services take the forwarded mode, passing builder.Configuration and builder.Environment so the framework resolves the metadata posture (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:298-302). The "auth-ip" policy is applied by attribute on the login and register actions of AuthControllerBase (AuthControllerBase.cs:57 and :79), so every consumer inheriting that base gets it without opting in. The internal partition helpers are exercised directly by WebApplicationBuilderExtensionsTests, RateLimitPartitionTests and RateLimitAlgorithmSelectionTests; the metadata resolution by ForwardedJwtBearerSecurityTests.
    • Caveats / not-in-source: the five permit-limit defaults are framework defaults only. What a given deployment actually enforces is whatever the host passes or configures under RateLimiting, and that configuration value is not determinable from this file. Likewise Distributed only takes effect when the host also registers an IConnectionMultiplexer; whether it does is host composition, not this file.
    -

    IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>

    +

    MiddlewarePipelineBuilder

    -

    MMCA.Common.Application · MMCA.Common.Application.Interfaces · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEntityDTOMapper.cs:14 · Level 4 · interface

    +

    MMCA.Common.API · MMCA.Common.API.Startup · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/MiddlewarePipelineBuilder.cs:15 · Level 10 · class (sealed)

      -
    • What it is: the contract for mapping a domain entity to its DTO. It declares MapToDTO(entity) and ships a default MapToDTOs(collection) that fans MapToDTO across a collection.
    • -
    • Depends on: AuditableBaseEntity<TIdentifierType> and IBaseDTO<TIdentifierType>, both as generic constraints.
    • -
    • Concept introduced (manual DTO mapping). [Rubric §16, Maintainability]: the framework maps by hand in classes implementing this interface rather than through a reflective mapper, so a missing or mistyped mapping is a compile error and not a runtime surprise (ADR-001). [Rubric §1, SOLID]: the interface has exactly one required member (interface segregation), and the default MapToDTOs (IEntityDTOMapper.cs:27-32) is a C# default interface method, so every concrete mapper inherits batch mapping for free and overrides it only when a bulk-lookup optimization is worth writing [Rubric §2, Design Patterns].
    • -
    • Walkthrough: the three constraints (IEntityDTOMapper.cs:15-17) force the entity and the DTO to agree on the identifier type and require it to be notnull, so a structurally unsound mapper does not compile. MapToDTO(TEntity) (:22) is the single required member. MapToDTOs(...) (:27) null-guards its argument (:29) and projects with Select into a read-only collection through a collection expression (:31).
    • -
    • Why it's built this way: ADR-001 chose compile-time discoverability over reflective convenience. Implementations are auto-registered by the Scrutor scan, which picks up everything assignable to the open generic, so adding a mapper needs no DI edit.
    • -
    • Where it's used: it is a constructor dependency and a public property of EntityQueryService<TEntity, TEntityDTO, TIdentifierType> and is surfaced on the IEntityQueryService<TEntity, TEntityDTO, TIdentifierType> contract. The framework ships one implementation itself, PushNotificationDTOMapper, and every module in the apps supplies one per entity.
    • +
    • What it is: the mutable, ordered list of MiddlewarePipelineStep values behind UseCommonMiddlewarePipeline. CreateDefault() seeds the framework's eighteen-step edge pipeline; a host may then insert, replace, or remove steps by name; and Build() re-checks four load-bearing adjacencies before anything is applied.
    • +
    • Depends on: MiddlewarePipelineStep and MiddlewarePipelineStepNames; CorrelationIdMiddleware, TenantResolutionMiddleware and SoftDeletedUserMiddleware (the three custom middlewares it wires); WebApplicationBuilderExtensions for the two CORS policy names; JwksEndpointExtensions and OidcDiscoveryEndpointExtensions for the two always-mapped well-known endpoints; WebApplicationExtensions for UseCommonRequestLocalization and the two pre-forwarded HttpContext.Items keys; ASP.NET forwarded-headers primitives.
    • +
    • Concept introduced (a customization point that validates itself, not a free-for-all). The tension a shared pipeline has to resolve: one fixed order is safe but blocks any host with a legitimate extra step, while an open Action<WebApplication> hook gives the order back to every host and re-opens exactly the bugs the shared pipeline closed. The resolution here is a scoped escape hatch with startup-enforced invariants: the host may edit the list, but four adjacencies are re-asserted afterwards and a violation throws while the host is starting, naming the invariant it broke and printing the current order. [Rubric §16, Maintainability] assesses whether a change can be made locally without breaking distant behavior; encoding the rationale as an executable check rather than a comment is what makes that true here. [Rubric §13, Observability & Operability] also applies: the failure modes these invariants prevent (an unreachable jwks_uri, a tenant that never resolves, a per-user rate cap that never engages) all look like configuration bugs at runtime, so converting them into a startup exception with a named cause is a large operability win. See ADR-079.
    • +
    • Walkthrough
        +
      • One field, _steps (MiddlewarePipelineBuilder.cs:17), and a private constructor (:19), so the only way in is CreateDefault(). StepNames (:24) projects the current names in application order, which is what the fitness function asserts on and what error messages print.
      • +
      • CreateDefault() (:31-156) seeds the eighteen steps. Reading it top to bottom is the fastest way to learn the edge: exception handler (:34-36), correlation id (:38-40), request localization (:42-47, with the ADR-027 note that this runs early so edge error localization uses the caller's culture), the pre-forwarded scheme and host capture (:49-62), forwarded headers with KnownProxies and KnownIPNetworks cleared for cloud reverse proxies (:64-80), an HTTPS redirect wrapped in UseWhen that skips application/grpc so h2c gRPC calls are not 307-redirected (:82-92), response compression (:94-96), routing (:98-100), CORS choosing the development or production policy by environment (:102-106), authentication (:108-110), tenant resolution (:112-118), the rate limiter (:120-126), the soft-deleted-user filter (:128-130), authorization (:132-134), output cache (:136-138), the always-mapped JWKS (:140-147) and OIDC discovery (:149-151) endpoints, and finally MapControllers() (:153-155). Each Configure delegate is static, so no closure is allocated per step.
      • +
      • Four mutators, all returning this for chaining and all validating first: InsertBefore (:166), InsertAfter (:183), Replace (:203) and Remove (:224). Replace keeps the replaced step's position and permits a different name, but rejects a name another step already carries (:208-214).
      • +
      • Build() (:257) runs the four checks and returns a defensive copy (:279). Two adjacency checks: PreForwardedCapture immediately before ForwardedHeaders (:259-262) and Authentication immediately before TenantResolution (:264-267). Two precedence checks: Authentication before RateLimiting (:269-272, ADR-019) and ForwardedHeaders before HttpsRedirection (:274-277). Every check carries its rationale string, which is what the exception message prints.
      • +
      • The private helpers hold the guard semantics. RequireIndexOf (:285) rejects a blank name and, for an unknown one, throws listing every known step (:296-299), which turns a typo into a self-answering error. RequireUniqueName (:304) enforces name uniqueness across the list. RequireImmediatelyBefore (:314) and RequirePrecedes (:329) share one subtle rule: an invariant binds only when both of its steps are still present (:320, :335), so a host that removes a whole capability (both members of a pair) stays legal, while a host that removes only one half is not constrained by a rule that no longer has anything to say.
      • +
      +
    • +
    • Why it's built this way: the "both present or the rule is silent" clause is the design decision worth internalizing. Without it, Remove(MiddlewarePipelineStepNames.TenantResolution) on a single-tenant host would fail the authentication adjacency check for no reason, and the escape hatch would be unusable. With it, the invariants constrain reordering rather than composition, which is what they were written to protect. Constructing the defaults as data rather than as calls also means the whole order can be asserted with no WebApplication built at all, which is what puts the fitness function in the fast unit tier.
    • +
    • Where it's used: only through WebApplicationExtensions.ApplyPipeline (WebApplicationExtensions.cs:138-149), which both UseCommonMiddlewarePipeline overloads route through, so the zero-argument path is exactly the validated default pipeline. MiddlewarePipelineOrderTestsBase (MMCA.Common/Source/Hosting/MMCA.Common.Testing/MiddlewarePipelineOrderTestsBase.cs:29) subclasses into each app's architecture tier (ADC, Store, Helpdesk, and Common's own testing tier) and freezes the eighteen-name order; MiddlewarePipelineBuilderTests covers the mutators and the invariants directly.
    • +
    • Caveats / not-in-source: the builder validates order, not semantics. A Replace that keeps a step's name but swaps in an unrelated middleware passes every check, because nothing inspects the Configure delegate.
    -

    IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>

    +

    WebApplicationExtensions

    -

    MMCA.Common.Application · MMCA.Common.Application.Interfaces · MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEntityDTOMapper.cs:42 · Level 4 · interface

    +

    MMCA.Common.API · MMCA.Common.API.Startup · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:14 · Level 10 · class (static, extension block)

      -
    • What it is: the create-side counterpart to IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>. It maps an incoming create request to a domain entity through that entity's factory method, returning Task<Result<TEntity>> so asynchronous validation can run before the entity exists. It is declared in the same file as the read mapper, so one file owns both mapping directions.
    • -
    • Depends on: AuditableBaseEntity<TIdentifierType> and ICreateRequest as constraints (IEntityDTOMapper.cs:43-45), and Result<T> as the return payload.
    • -
    • Concept (request-to-entity mapping with async validation). [Rubric §1, SOLID]: separating create-mapping from read-mapping keeps each interface to one reason to change. [Rubric §9, API & Contract Design]: the ICreateRequest constraint tags a DTO as a create payload, so a read DTO cannot be passed down this path by accident. The Task<Result<TEntity>> signature is the load-bearing detail: creation frequently needs a database round trip (a uniqueness check) before the factory runs, and any failure surfaces as a Result error rather than an exception, exactly as the doc comment describes (IEntityDTOMapper.cs:35-38, :47-50).
    • -
    • Walkthrough: one member, CreateEntityAsync(TCreateRequest request, CancellationToken cancellationToken = default) (IEntityDTOMapper.cs:54). Implementations call the entity's Create(...) factory and return its Result unchanged, so validation errors thread through without translation.
    • -
    • Why it's built this way: the same ADR-001 rationale (explicit, compile-checked mapping), and co-locating it with the read mapper documents the expectation that a module supplies both directions per entity.
    • -
    • Where it's used: implemented by the per-entity *CreateRequestMapper classes in each module and injected into the matching create handlers (MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Application/Tickets/UseCases/Create/CreateTicketHandler.cs:21 is the smallest worked example in the workspace).
    • +
    • What it is: the extension(WebApplication app) type every downstream host calls to wire its HTTP edge: the two UseCommonMiddlewarePipeline overloads, the request-localization member, and the culture-switch endpoint. It is the runtime-side sibling of WebApplicationBuilderExtensions.
    • +
    • Depends on: MiddlewarePipelineBuilder (which now owns the step list) and SupportedCultures; ASP.NET localization and cookie primitives.
    • +
    • Concept (one canonical, ordered pipeline, applied through one private helper). [Rubric §10, Cross-Cutting] and [Rubric §13, Observability & Operability]: middleware order is behavior, not taste. Correlation must be established before anything downstream logs, and authentication must run before the rate limiter so the per-user partition sees a principal at all. Centralizing the order means a host cannot get it wrong (ADR-079). [Rubric §27, i18n] applies through the localization wiring (ADR-027).
    • +
    • Walkthrough
        +
      • Two internal constants, PreForwardedSchemeKey (WebApplicationExtensions.cs:22) and PreForwardedHostKey (:33), name the HttpContext.Items slots that the pipeline's PreForwardedCapture step writes before UseForwardedHeaders rewrites scheme and host. The comment on the host key (:24-32) records why: Aspire/DCP injects an X-Forwarded-Host pointing at the canonical launchSettings URL, which internal callers cannot reach.
      • +
      • UseCommonMiddlewarePipeline() (:46) is now a one-liner: ApplyPipeline(app, configure: null). Its doc comment (:37-45) states the contract in one sentence worth keeping: the order is data, not prose, named by MiddlewarePipelineStepNames and frozen by the MiddlewarePipelineOrderTestsBase fitness function.
      • +
      • UseCommonMiddlewarePipeline(Action<MiddlewarePipelineBuilder> configure) (:58) is the scoped escape hatch: it null-guards the delegate (:60) and routes through the same helper (:61). The XML doc declares both failure modes, ArgumentNullException and the InvalidOperationException an invariant violation raises (:56-57).
      • +
      • UseCommonRequestLocalization() (:71) builds the supported list from SupportedCultures.All (:73), appends the pseudo-locale in Development only (:78-81), and sets the default plus both supported and supported-UI culture lists (:84-87). It is itself the RequestLocalization step of the default pipeline, and Blazor UI hosts call it explicitly before MapRazorComponents so SSR prerender runs under the right culture (:64-70).
      • +
      • MapCultureEndpoint() (:100) maps the anonymous GET /culture/set?culture=&redirectUri= that the culture switcher calls. It honors only allowlisted cultures, and the pseudo-locale only in Development (:104, :107), writes the standard ASP.NET culture cookie as non-HttpOnly so the WASM client can read it (:110-121, with Secure conditional on the environment and both deviations justified inline at :109), then local-redirects (:125-126) to force a full reload.
      • +
      • ApplyPipeline (:138, private) is the whole application step: seed the defaults (:140), let the host's delegate adjust them if there is one (:141), then foreach over builder.Build() invoking each step's Configure in order (:143-146). Because both public overloads route through here, the zero-argument path is exactly the validated default pipeline (:133-137).
      • +
      +
    • +
    • Why it's built this way: pushing the step list out into MiddlewarePipelineBuilder and keeping only ApplyPipeline here is what lets the order be inspected and asserted while the entry point stays a single line in a host's Program.cs. The configure-then-Build sequence is deliberate: the host mutates first and the invariants are checked last, so a customization is judged on its result rather than on the order the host happened to make its edits.
    • +
    • Where it's used: called once per service host after app.Build(), for example MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:399, MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:277, and MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:117. The Blazor UI hosts instead call the localization and culture-endpoint members directly (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:160).
    • +
    • Caveats / not-in-source: a host that maps additional endpoints (SignalR hubs, minimal-API endpoints, app-association documents) does so after this call; the framework cannot enforce that ordering, it only documents it on the members that require it (for example SignalRExtensions.MapNotificationHub, MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/SignalRExtensions.cs:16-21). Note also that MMCA.Common.UI ships a different WebApplicationExtensions (see WebApplicationExtensions in the UI framework chapter); the two share a name and nothing else.

    DatabaseInitializationExtensions

    -

    MMCA.Common.API · MMCA.Common.API.Startup · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:21 · Level 8 · class (static, extension block)

    +

    MMCA.Common.API · MMCA.Common.API.Startup · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:21 · Level 13 · class (static, extension block)

    • What it is: the shared startup routine that, per physical data source and then per tenant database, creates or migrates the schema and finally runs each enabled module's seeder.
    • @@ -1844,28 +1978,9 @@

      DatabaseInitializationExtensions

  • Why it's built this way: one shared init path keeps every downstream service consistent, and the "None" strategy is the deploy-time guarantee that an app never serves traffic against an un-migrated database when migrations are applied by the pipeline rather than the app. The tenant pass exists because nothing else ever opens a per-tenant database (ADR-073), so without it such a database is never created and never migrated (:102-105).
  • -
  • Where it's used: called from each service host's Program.cs after app.Build() and before the middleware pipeline is wired. Its branches are covered by DatabaseInitializationExtensionsTests.
  • +
  • Where it's used: called from each service host's Program.cs after app.Build() and before the middleware pipeline is wired. Its branches are covered by DatabaseInitializationExtensionsTests (MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Startup/DatabaseInitializationExtensionsTests.cs).
  • Caveats / not-in-source: which strategy a deployment runs is configuration, not code. ADC sets Migrate in production so each service migrates its own database at startup, which is a deployment decision recorded in MMCA.ADC/CLAUDE.md, not something this file can show.
  • -

    WebApplicationExtensions

    -
    -

    MMCA.Common.API · MMCA.Common.API.Startup · MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:16 · Level 10 · class (static, extension block)

    -
    -
      -
    • What it is: the extension(WebApplication app) type that defines the canonical middleware pipeline (UseCommonMiddlewarePipeline) plus the request-localization and culture-switch endpoints, so every downstream host wires middleware in exactly one order. It is the runtime-side sibling of WebApplicationBuilderExtensions.
    • -
    • Depends on: CorrelationIdMiddleware, TenantResolutionMiddleware, SoftDeletedUserMiddleware, WebApplicationBuilderExtensions for the CORS policy names, JwksEndpointExtensions, OidcDiscoveryEndpointExtensions and SupportedCultures; ASP.NET forwarded-headers and localization primitives.
    • -
    • Concept (one canonical, ordered pipeline). [Rubric §10, Cross-Cutting] and [Rubric §13, Observability & Operability]: middleware order is behavior, not taste. Correlation must be established before anything downstream logs, and authentication must run before the rate limiter so the per-user partition sees a principal at all. Centralizing the order means a host cannot get it wrong (ADR-079). [Rubric §27, i18n] applies through the localization wiring (ADR-027).
    • -
    • Walkthrough
        -
      • Two internal constants, PreForwardedSchemeKey (WebApplicationExtensions.cs:24) and PreForwardedHostKey (:35), name the HttpContext.Items slots that capture the transport scheme and host before UseForwardedHeaders rewrites them. The comment on the host key (:26-34) records why: Aspire/DCP injects an X-Forwarded-Host pointing at the canonical launchSettings URL, which internal callers cannot reach.
      • -
      • UseCommonMiddlewarePipeline() (:45) wires, in order: exception handler (:47), correlation-id middleware (:48), request localization (:53, so edge error localization runs under the caller's culture), forwarded-headers options with KnownProxies/KnownIPNetworks cleared for cloud reverse proxies (:55-64), the capture step storing the pre-forwarded scheme and host (:72-77), UseForwardedHeaders (:79), an HTTPS redirect wrapped in UseWhen that skips application/grpc so h2c gRPC calls are not 307-redirected (:87-89), response compression (:91), routing (:92), CORS choosing the development or production policy by environment (:93-95), authentication (:96), tenant resolution (:102), the rate limiter (:108), the soft-deleted-user middleware (:109), authorization (:110), output cache (:111), the always-mapped MapJwksEndpoint() / MapOidcDiscoveryEndpoint() pair (:118-119), and finally MapControllers() (:121). Two comments carry the ordering rationale: tenant resolution sits immediately after authentication because its claim strategy reads HttpContext.User (:98-101), and the rate limiter sits after authentication per ADR-019 because otherwise every request looks anonymous and the per-user cap never engages (:104-107).
      • -
      • UseCommonRequestLocalization() (:133) builds the supported list from SupportedCultures.All (:135), appends the pseudo-locale in Development only (:140-143), and sets the default plus both supported and supported-UI culture lists (:146-151). Blazor UI hosts call it explicitly before MapRazorComponents so SSR prerender runs under the right culture (:126-132).
      • -
      • MapCultureEndpoint() (:162) maps the anonymous GET /culture/set?culture=&redirectUri= that the culture switcher calls. It honors only allowlisted cultures, and the pseudo-locale only in Development (:166, :169), writes the standard ASP.NET culture cookie as non-HttpOnly so the WASM client can read it (:172-183, with Secure conditional on the environment and both deviations justified inline at :171), then local-redirects (:187-188) to force a full reload.
      • -
      -
    • -
    • Why it's built this way: centralizing the order means a host cannot accidentally place rate limiting before authentication or forget forwarded-headers handling behind a cloud proxy. That last point is load-bearing for the limiter: UseForwardedHeaders running before UseRateLimiter is what makes Connection.RemoteIpAddress the real client IP for the auth-ip partition (WebApplicationBuilderExtensions.cs:346-349). The JWKS and OIDC endpoints are mapped unconditionally so a non-Identity host degrades to an empty key set or a 404 rather than diverging in wiring (:113-117).
    • -
    • Where it's used: called once per service host after app.Build(). The Blazor web host instead calls the two localization members directly.
    • -
    • Caveats / not-in-source: a host that maps additional endpoints (SignalR hubs, minimal-API endpoints, app-association documents) does so after this call; the framework cannot enforce that ordering, it only documents it on the members that require it (for example SignalRExtensions.MapNotificationHub, MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/SignalRExtensions.cs:16-21).
    • -

    IBaseDTO<TIdentifierType>

    MMCA.Common.Shared · MMCA.Common.Shared.DTOs · MMCA.Common/Source/Core/MMCA.Common.Shared/DTOs/IBaseDTO.cs:9 · Level 0 · interface

    diff --git a/docs/onboarding/group-14-module-system-composition.html b/docs/onboarding/group-14-module-system-composition.html index d7926e2..915b801 100644 --- a/docs/onboarding/group-14-module-system-composition.html +++ b/docs/onboarding/group-14-module-system-composition.html @@ -162,7 +162,7 @@

    14. Module System, Composit MessageBusSettings, OutboxSettings, PersistenceSettings, the JWT/JWKS group, SmtpSettings, PushNotificationSettings, NativePushSettings, - FileStorageSettings) and the newer opt-in feature sections + FileStorageSettings) and the opt-in feature sections (SchedulerSettings, AuditTrailSettings, TenancySettings); the cross-replica locking pair (RedisDistributedLock, InProcessDistributedLock); @@ -200,7 +200,7 @@

    The module contract and GetSessionBookmarkCountHandler still needs Engagement's IBookmarkCountService, so the disabled Engagement module contributes a stub and the host then replaces that stub with a typed gRPC client pointed at the real Engagement process - (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:321-329). Application code never + (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:342-350). Application code never learns which path it got; the transport choice lives entirely at the composition edge (ADR-008). [Rubric §2, Design Patterns] applies here: this is a clean strategy / null-object pairing (real service, disabled stub, remote client) rather than scattered if (moduleEnabled) checks.

    @@ -256,7 +256,7 @@

    Discovery and Kahn-ordered regi

    A subtlety worth stating against the source: the loader is not called from inside AddApplication(). Each host's Program.cs constructs a ModuleLoader, hands it a logger, calls DiscoverAndRegister directly, then registers the loader instance itself as a singleton - (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:313-319). After discovery the + (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:335-340). After discovery the loader also drives startup data through SeedAllAsync (ModuleLoader.cs:270-276), which invokes each collected IModuleSeeder.SeedAsync in registration order. IModuleSeeder @@ -270,94 +270,111 @@

    The two composi each using a C# extension(IServiceCollection services) block (see primer §4 for the extension(T) syntax). The Application root - (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:21) exposes AddApplication() - (DependencyInjection.cs:29), which fronts ApplicationSettings with its - IApplicationSettings abstraction (DependencyInjection.cs:31), registers - the three core singletons (IDomainEventDispatcher, - INavigationMetadataProvider, - IEntityQueryPipeline, - DependencyInjection.cs:33-35), and pulls in the framework's own FluentValidation validators by - assembly (DependencyInjection.cs:40). It also owns ScanModuleApplicationServices<TAssemblyMarker>() - (DependencyInjection.cs:115-179), the Scrutor convention scan every module's AddXModule calls: - domain-event and integration-event handlers as singletons (DependencyInjection.cs:119-130), DTO and - request mappers scoped (DependencyInjection.cs:132-142), command and query handlers scoped - (DependencyInjection.cs:144-154), validators from the module assembly (DependencyInjection.cs:156), - and finally a reflection pass that TryAdds a CommandRequestValidator<,> for every command - implementing ICommandWithRequest<T> (DependencyInjection.cs:160-176) so an explicit validator still - wins. The Infrastructure root + (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:22) exposes AddApplication() + (DependencyInjection.cs:30), which fronts ApplicationSettings with its + IApplicationSettings abstraction (DependencyInjection.cs:32), registers + the core singletons (IDomainEventDispatcher at + :34, IEventUpcasterRegistry at :40, + INavigationMetadataProvider at + :42, IEntityQueryPipeline at :43), + and pulls in the framework's own FluentValidation validators by assembly (DependencyInjection.cs:48). + The upcaster registry is registered unconditionally on purpose: with no upcasters it is an empty + registry whose operations are the identity, so both delivery paths can depend on it without a null + check (DependencyInjection.cs:36-40, + ADR-090), and individual + upcasters accumulate through AddEventUpcaster<TSource, TTarget, TUpcaster>() + (DependencyInjection.cs:283-290).

    +

    The Application root also owns ScanModuleApplicationServices<TAssemblyMarker>() + (DependencyInjection.cs:140-213), the Scrutor convention scan every module's AddXModule calls: + domain-event and integration-event handlers as singletons (DependencyInjection.cs:144-155), DTO + mappers, the opt-in + IEntityDTOProjector<TEntity, TEntityDTO, TIdentifierType> + projectors and request mappers scoped (DependencyInjection.cs:157-176), command and query handlers + scoped (DependencyInjection.cs:178-188), validators from the module assembly + (DependencyInjection.cs:190), and finally a reflection pass that TryAdds a + CommandRequestValidator<,> for every command implementing ICommandWithRequest<T> + (DependencyInjection.cs:194-210) so an explicit validator still wins.

    +

    The Infrastructure root (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:40) exposes AddInfrastructure(configuration) (DependencyInjection.cs:50), which binds most of the settings types in this chapter, registers the three save interceptors as singletons (DependencyInjection.cs:57-63), the persistence stack (data-source service and resolver, entity registry, the scoped and singleton context factories, repositories, unit of work, DependencyInjection.cs:52-109), Scrutor-scans the framework's own EF entity configurations - (DependencyInjection.cs:113-117), adds caching (DependencyInjection.cs:119), and enrolls the two - outbox hosted services (DependencyInjection.cs:151-152). Optional add-ons sit alongside it: - AddPushNotifications (DependencyInjection.cs:528), AddNativePushNotifications - (DependencyInjection.cs:563), AddAzureBlobFileStorage (DependencyInjection.cs:595), - AddBrokerMessaging (DependencyInjection.cs:647), and the typed-client helper - AddTypedServiceClient<TInterface, TImplementation>(serviceName) (DependencyInjection.cs:716) that - swaps an in-process abstraction for an HTTP transport with JWT forwarding and the standard Polly - pipeline.

    -

    AddCaching (MMCA.Common.Infrastructure/DependencyInjection.cs:164) also registers this chapter's + (DependencyInjection.cs:113-117), adds caching (DependencyInjection.cs:119), enrolls a startup + validator that fails the host on a bad upcaster graph (DependencyInjection.cs:160-161), and adds the + two outbox hosted services (DependencyInjection.cs:164-165). Optional add-ons sit alongside it: + AddPushNotifications (DependencyInjection.cs:541), AddNativePushNotifications + (DependencyInjection.cs:576), AddAzureBlobFileStorage (DependencyInjection.cs:608), + AddBrokerMessaging (DependencyInjection.cs:660), and the typed-client helper + AddTypedServiceClient<TInterface, TImplementation>(serviceName) (DependencyInjection.cs:734) that + swaps an in-process abstraction for an HTTP transport.

    +

    AddCaching (MMCA.Common.Infrastructure/DependencyInjection.cs:177) also registers this chapter's one cross-replica primitive: an IDistributedLock that resolves to RedisDistributedLock when the host has an IConnectionMultiplexer registered, and to the warn-once InProcessDistributedLock otherwise - (MMCA.Common.Infrastructure/DependencyInjection.cs:195-209). The Redis implementation is the - standard SET key token NX PX ttl lock with a compare-and-delete release script - (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Concurrency/RedisDistributedLock.cs:36-37, - RedisDistributedLock.cs:66-72), handing back a RedisLockHandle that releases - exactly its own acquisition, once (RedisDistributedLock.cs:88); the fallback is exclusive only inside - one process, which is exactly what its warning says out loud + (MMCA.Common.Infrastructure/DependencyInjection.cs:208-222). The Redis implementation is the + standard SET key token NX PX ttl acquire + (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Concurrency/RedisDistributedLock.cs:66-68) with a + compare-and-delete release script (RedisDistributedLock.cs:36-37), handing back a + RedisLockHandle that releases exactly its own acquisition, once + (RedisDistributedLock.cs:88-98); the fallback is exclusive only inside one process, which is exactly + what its warning says out loud (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Concurrency/InProcessDistributedLock.cs:75), and its InProcessLockHandle simply removes the key from a - ConcurrentDictionary (InProcessDistributedLock.cs:79-88). A multi-replica host that registers no + ConcurrentDictionary (InProcessDistributedLock.cs:79-91). A multi-replica host that registers no Redis client therefore gets one execution of the guarded section per replica. [Rubric §29, Resilience] and [Rubric §12, Performance & Scalability] both touch this pair: the degradation is deliberate, announced, and never silent.

    The order of these calls is a hard contract in exactly one respect, and it is the reason - AddApplicationDecorators() (MMCA.Common.Application/DependencyInjection.cs:89) must come last. + AddApplicationDecorators() (MMCA.Common.Application/DependencyInjection.cs:110) must come last. Decorators are registered with Scrutor's TryDecorate, which wraps existing registrations, so every module's concrete handlers must already be in the container or there is nothing to wrap. Beyond that, the relative position of AddInfrastructure and AddAPI is not load-bearing. [Rubric §6, CQRS & Event-Driven] and [Rubric §1, SOLID] (open/closed) live here: cross-cutting behavior is added by wrapping, not by editing handlers. AddApplicationDecorators also encodes the execution order via TryDecorate's reverse-registration rule (registered innermost first, - MMCA.Common.Application/DependencyInjection.cs:94-103), so the command pipeline ends up - FeatureGate -> Logging -> Caching -> Validating -> Transactional -> handler and the query pipeline - FeatureGate -> Logging -> Caching -> handler - (ADR-014). The decorator types - themselves (for example - FeatureGateCommandDecorator<TCommand, TResult> - and LoggingCommandDecorator<TCommand, TResult>) + MMCA.Common.Application/DependencyInjection.cs:115-128), so the command pipeline ends up + FeatureGate -> Authorization -> Logging -> Caching -> Validating -> Timeout -> Transactional -> handler + and the query pipeline FeatureGate -> Authorization -> Logging -> Caching -> Timeout -> handler + (ADR-014). The rationale for + each position is written out in the method's own doc comment + (MMCA.Common.Application/DependencyInjection.cs:84-107): authorization sits outside caching so a + denied request neither reads nor populates the cache, validation sits outside the transaction so an + invalid command never opens one, and the timeout budget sits inside validation and outside the + transaction so it covers the database work and cancels it rather than leaving it open. The decorator + types themselves (for example + FeatureGateCommandDecorator<TCommand, TResult>, + AuthorizationCommandDecorator<TCommand, TResult> + and TimeoutCommandDecorator<TCommand, TResult>) are documented in the CQRS-pipeline chapter; this chapter owns only the wiring of them. An optional MiniProfiler pair is registered separately by an opt-in AddApplicationProfiling() - (MMCA.Common.Application/DependencyInjection.cs:219-225), never by AddApplicationDecorators().

    + (MMCA.Common.Application/DependencyInjection.cs:297-303), never by AddApplicationDecorators().

    Opt-in platform features are composed the same way

    -

    Four newer capabilities are registered beside the roots rather than inside them, and they share one +

    Four capabilities are registered beside the roots rather than inside them, and they share one discipline: registering a feature is not the same as turning it on. AddScheduledJobs(configuration) - (MMCA.Common.Infrastructure/DependencyInjection.cs:304) binds + (MMCA.Common.Infrastructure/DependencyInjection.cs:317) binds SchedulerSettings and enrolls ScheduledJobRunner through TryAddEnumerable rather than AddHostedService, precisely so two modules calling it cannot - start two runners racing for the same rows (DependencyInjection.cs:311-315); individual jobs arrive - through AddScheduledJob<TJob>() (DependencyInjection.cs:339-345), each registered scoped so the + start two runners racing for the same rows (DependencyInjection.cs:324-328); individual jobs arrive + through AddScheduledJob<TJob>() (DependencyInjection.cs:352-357), each registered scoped so the runner can resolve it in a fresh scope per execution. AddAuditTrail(configuration) - (DependencyInjection.cs:375) binds AuditTrailSettings, adds the + (DependencyInjection.cs:388) binds AuditTrailSettings, adds the AuditTrailSaveChangesInterceptor and the AuditTrailReader that projects AuditTrailEntryDTO rows, and contributes its own retention job - (DependencyInjection.cs:377-391), which only actually runs when the host also enabled the scheduler. - AddMultiTenancy(configuration) (DependencyInjection.cs:424) binds + (DependencyInjection.cs:390-404), which only actually runs when the host also enabled the scheduler. + AddMultiTenancy(configuration) (DependencyInjection.cs:437) binds TenancySettings and registers TenancySettingsValidator as an IValidateOptions<TenancySettings> - (DependencyInjection.cs:426-433); note what it does not do, because that is the design: + (DependencyInjection.cs:439-446); note what it does not do, because that is the design: TenantSaveChangesInterceptor and ITenantContext are registered unconditionally by - AddInfrastructure and AddServices (DependencyInjection.cs:63, :452) and stay inert until a + AddInfrastructure and AddServices (DependencyInjection.cs:63, :465) and stay inert until a tenant is resolved, so the framework can never sit in the half-wired state where entities carry ITenantEntity but the write-side guard is off (ADR-073). Finally - AddUserDataExportSection<TSection>() (MMCA.Common.Application/DependencyInjection.cs:206-212) + AddUserDataExportSection<TSection>() (MMCA.Common.Application/DependencyInjection.cs:240-246) accumulates IUserDataExportSection contributors into the one IEnumerable the export handler fans out over; ADC's Identity module registers two of them (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:42-43). The @@ -372,16 +389,21 @@

    Opt-in platform feat (ScheduledJobRunner.cs:69, :87), then loops: reconcile the ScheduledJobs rows against the registered jobs, claim due rows with a lease, execute, stamp the outcome, and smart-wait until the earliest upcoming occurrence capped at Scheduler:PollingIntervalSeconds - (ScheduledJobRunner.cs:94-126). A claim attempt returns a JobClaim carrying either - this replica's lock token or null when another replica won the row - (ScheduledJobRunner.cs:439-447), which is what makes an occurrence run exactly once across a scaled - host. The persisted row is ScheduledJobEntry + (ScheduledJobRunner.cs:94-126). A claim attempt is a single filtered ExecuteUpdateAsync against the + still-unleased predicate, so two racing replicas both issue it and exactly one matches + (ScheduledJobRunner.cs:429-439); it returns a JobClaim carrying either this replica's + lock token or null when another replica won the row (ScheduledJobRunner.cs:447), which is what + makes an occurrence run exactly once across a scaled host. The persisted row is + ScheduledJobEntry (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Scheduling/ScheduledJobEntry.cs:20), deliberately not an auditable entity: it is framework bookkeeping with an explicit claim lease instead of a concurrency token (ScheduledJobEntry.cs:10-13). SchedulerMetrics (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Scheduling/SchedulerMetrics.cs:16) publishes the MMCA.Common.Scheduler meter with a run counter tagged by job and outcome - (SchedulerMetrics.cs:28-31) and a duration histogram (SchedulerMetrics.cs:39-40), so + (SchedulerMetrics.cs:28-31) and a duration histogram (SchedulerMetrics.cs:39-42), the same shape + BrokerMetrics + (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/BrokerMetrics.cs:18) uses for the + MMCA.Common.Broker meter's fault and circuit-open counters (BrokerMetrics.cs:30, :42). So [Rubric §13, Observability & Operability] is covered by instruments rather than by log scraping.

    Assembly anchors

    Several pieces of machinery need a Type whose Assembly identifies a layer: Scrutor's @@ -391,7 +413,7 @@

    Assembly anchors

    each a trivial static class AssemblyReference holding Assembly / AssemblyName statics (MMCA.Common/Source/Core/MMCA.Common.Domain/AssemblyReference.cs:8-12) beside a non-static class ClassReference (AssemblyReference.cs:18) for the places a generic constraint forbids a static type. AddApplication uses the Application pair for the common validators - (MMCA.Common.Application/DependencyInjection.cs:40) and AddInfrastructure uses the Infrastructure + (MMCA.Common.Application/DependencyInjection.cs:48) and AddInfrastructure uses the Infrastructure pair to scan entity configurations (MMCA.Common.Infrastructure/DependencyInjection.cs:113-117). They are deliberately behavior-free; their whole job is to name an assembly for the scanning and governance tooling.

    @@ -408,7 +430,7 @@

    Configuration binding, the Se abstraction (IConnectionStringSettings at DependencyInjection.cs:71, ISmtpSettings at DependencyInjection.cs:85, IJwtSettings at DependencyInjection.cs:65, - IPushNotificationSettings at DependencyInjection.cs:534). + IPushNotificationSettings at DependencyInjection.cs:547). [Rubric §13, Observability & Operability] and [Rubric §15, Best Practices] apply: ValidateOnStart plus DataAnnotations ranges (for example OutboxSettings BatchSize is [Range(1, 1000)] with a default of 50, @@ -441,12 +463,12 @@

    Configuration binding, the Se (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Settings/DataSourcesSettings.cs:34-39); MessageBusSettings and its MessageBusProvider enum (InProcess / RabbitMq / AzureServiceBus, - MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Settings/MessageBusSettings.cs:68-84) that + MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Settings/MessageBusSettings.cs:116-132) that AddBrokerMessaging switches on, short-circuiting entirely for InProcess - (MMCA.Common.Infrastructure/DependencyInjection.cs:656-659) and otherwise Replace-ing both + (MMCA.Common.Infrastructure/DependencyInjection.cs:669-672) and otherwise Replace-ing both IMessageBus and IEventBus with their broker-backed counterparts - (DependencyInjection.cs:676-682); OutboxSettings (batch size, retries, polling + (DependencyInjection.cs:689-695); OutboxSettings (batch size, retries, polling and processing intervals, lease, retention) consumed by the OutboxProcessor; PersistenceSettings, whose single CommandTimeoutSeconds defaults to the 30 @@ -467,11 +489,11 @@

    Configuration binding, the Se FileStorageSettings (ADR-045). The last two follow a different discipline on purpose: their Add* methods bind the section and then no-op - when it is disabled or incomplete (MMCA.Common.Infrastructure/DependencyInjection.cs:568-574 and - :600-611), so a host registers them unconditionally and a deployment switches the channel on by + when it is disabled or incomplete (MMCA.Common.Infrastructure/DependencyInjection.cs:581-587 and + :613-624), so a host registers them unconditionally and a deployment switches the channel on by configuration alone. One binding is deliberately elsewhere: JwtSettings is bound by the API layer's AddCommonAuthentication - (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:344-349), + (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:536-541), while Infrastructure only registers the IJwtSettings facade over the resulting options (MMCA.Common.Infrastructure/DependencyInjection.cs:65), so a host that skips authentication never pays for a JWT section it does not have.

    @@ -486,7 +508,7 @@

    Configuration binding, the Se (PollingIntervalSeconds default 30, LeaseSeconds default 300, SchedulerSettings.cs:33-43; RetentionDays default 90, AuditTrailSettings.cs:37-38), and per-job retiming lives in ScheduledJobOverrideSettings bound from Scheduler:Jobs:{Name} - (SchedulerSettings.cs:60-75). TenancySettings + (SchedulerSettings.cs:60, :66-75). TenancySettings (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Settings/TenancySettings.cs:50) adds the collection-binding subtlety: ResolutionOrder and ExcludedPathPrefixes bind as empty lists and the framework reads EffectiveResolutionOrder / EffectiveExcludedPathPrefixes instead @@ -528,8 +550,9 @@

    The two routing attributes

    markers that feed them live here because they are part of how a module declares its composition.

    Shared user use-case bases: composition in the other direction

    The chapter's last family is composition at the handler level rather than the container level. ADC - and Store each own an Identity module, and five of their account use cases had drifted into - line-identical copies, so the workflow was hoisted into abstract bases that each app subclasses: + and Store each own an Identity module, and seven of their account use cases had drifted into + line-identical copies (or would have), so the workflow was hoisted into abstract bases that each app + subclasses: ChangePasswordHandlerBase<TUser, TCommand> (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:24, ADR-032), @@ -540,19 +563,32 @@

    Shared us DeleteUserHandlerBase<TUser, TCommand> (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:38), the erasure workflow behind - ADR-005, and + ADR-005, ExportUserDataHandlerBase<TUser, TQuery> (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ExportUserData/ExportUserDataHandlerBase.cs:49), - the data-subject access workflow. Each base is generic in the app's User aggregate and in the app's - own command or query record, and reads that record only through the small contracts in this group: + the data-subject access workflow, and the password-recovery pair + ForgotPasswordHandlerBase<TUser, TCommand> + (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:35) + and ResetPasswordHandlerBase<TUser, TCommand> + (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:30), + which run the token issue-and-redeem flow described in + ADR-091 over + IPasswordResetTokenService and answer identically whether or not the address holds an account + (ResetPasswordHandlerBase.cs:20-21).

    +

    Each base is generic in the app's User aggregate and in the app's own command or query record, and + reads that record only through the small contracts in this group: IUserScopedRequest (UserId, MMCA.Common/Source/Core/MMCA.Common.Application/Users/IUserScopedRequest.cs:8), IUserScopedCommand<out TRequest> (adds the embedded payload, IUserScopedCommand.cs:13), and IUserOwnedRequest (adds CurrentUserId and CurrentUserRole, IUserOwnedRequest.cs:8). The commands stay app-side precisely because the two apps disagree on their pipeline attributes: ADC marks the password-change command ICacheInvalidating and - Store does not (ChangePasswordHandlerBase.cs:16-21).

    -

    The export base is the most instructive of the five, because it is where the container-level and + Store does not (ChangePasswordHandlerBase.cs:17-20). Note that + IUserScopedCommand<out TRequest> is deliberately not + ICommandWithRequest<TRequest>: implementing the latter also opts a command into automatic + CommandRequestValidator registration, which is a per-app decision, so implementing this one alone + changes no pipeline behavior (IUserScopedCommand.cs:6-11).

    +

    The export base is the most instructive of the seven, because it is where the container-level and handler-level composition meet. It authorizes through UserOwnershipRule.CheckOwnership (ExportUserDataHandlerBase.cs:81-90), reads the account through GetReadRepository @@ -568,7 +604,7 @@

    Shared us with the caller-safe default text in UserDataExportSectionDefaults (IUserDataExportSection.cs:105-113). The result is a UserDataExportDTO that is PII by design and is therefore never - logged or cached (ExportUserDataHandlerBase.cs:42-45).

    + logged or cached (ExportUserDataHandlerBase.cs:43-45).

    Around those bases sit the small shared pieces: UserOwnershipRule (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UserOwnershipRule.cs:21), the owner-or-privileged-role decision returning a Forbidden @@ -577,11 +613,11 @@

    Shared us UserUseCaseLog (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UserUseCaseLog.cs:11), a non-generic [LoggerMessage] holder so every subclass emits identical text while the log category still comes from - the subclass's own ILogger<T> (UserUseCaseLog.cs:13-23); + the subclass's own ILogger<T> (UserUseCaseLog.cs:13-29); SoftDeletedUserValidator<TUser> (MMCA.Common/Source/Core/MMCA.Common.Application/Users/SoftDeletedUserValidator.cs:19), which answers ISoftDeletedUserValidator with one - query-filter-bypassing existence check; and + query-filter-bypassing existence check (SoftDeletedUserValidator.cs:30-33); and GetUserPreferencesQuery (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/GetPreferences/GetUserPreferencesQuery.cs:5), the one request record that was byte-identical in both apps and so became shared. [Rubric §16, Maintainability] and [Rubric §1, SOLID] are the categories here: the variation points are explicit @@ -591,21 +627,21 @@

    End-to-end: one host's boot

    Reading MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs top to bottom shows the whole chapter cooperating. The host binds and validates ApplicationSettings and also reads the section eagerly for the value it must pass around (Program.cs:170-176), calls - AddApplication() then AddInfrastructure(builder.Configuration) (Program.cs:287-288), opts into the - scheduler and the audit trail (Program.cs:292, :296), binds - ModulesSettings and calls AddAPI(modulesSettings) (Program.cs:299-308), then + AddApplication() then AddInfrastructure(builder.Configuration) (Program.cs:308-309), opts into the + scheduler and the audit trail (Program.cs:313, :317), binds + ModulesSettings and calls AddAPI(modulesSettings) (Program.cs:320-329), then constructs a ModuleLoader with a Serilog-backed logger and calls DiscoverAndRegister(services, configuration, applicationSettings, modulesSettings, environmentName) - before registering the loader as a singleton (Program.cs:313-319). Because this is the Conference + before registering the loader as a singleton (Program.cs:335-340). Because this is the Conference service, only the Conference module is Enabled in its configuration; every other discovered module takes the RegisterDisabledStubs path. The host then patches the cross-process edges: it replaces the disabled Engagement stub with a real gRPC client (AddEngagementBookmarkCountClient(), - Program.cs:329) and calls AddBrokerMessaging(builder.Configuration, ...) (Program.cs:346-347) so + Program.cs:350) and calls AddBrokerMessaging(builder.Configuration, ...) (Program.cs:371) so MessageBusSettings Provider decides whether IMessageBus stays in-process or becomes the - MassTransit-backed broker. Only then comes AddApplicationDecorators() (Program.cs:349), last, so + MassTransit-backed broker. Only then comes AddApplicationDecorators() (Program.cs:375), last, so the decorators wrap the now-registered Conference handlers. Finally - app.Services.InitializeDatabaseAsync(applicationSettings, moduleLoader) (Program.cs:370) applies + app.Services.InitializeDatabaseAsync(applicationSettings, moduleLoader) (Program.cs:396) applies migrations and runs the module seeders the loader collected. The exact same module assemblies, dropped into a monolith host with every module Enabled, would Kahn-sort into one in-process graph with no gRPC clients, which is precisely the reversibility @@ -2544,21 +2580,24 @@

    GetUserPreferencesQuery

    primer §4 for the alias convention). No externals.

    -
  • Concept introduced: the one request record in this family that could be shared. Everything else - in the shared Users use cases keeps its command record app-side, because ADC and Store disagree on - the pipeline markers those records carry: both DeleteUserCommand records, for instance, implement - ICacheInvalidating with a CachePrefix built from their own User type - (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserCommand.cs:14-18, - MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/DeleteUser/DeleteUserCommand.cs:14-21), - which is a value no shared record could produce. This query carries no markers at all: it is not - ICacheInvalidating, not IQueryCacheable, not - ICommandWithRequest. That absence is precisely what made it hoistable, and it is the rule worth - taking away: a type moves into the framework when it has no app-specific policy attached to it.

    +
  • Concept introduced: the one request record in this family that could be shared. Almost + everything else in the shared Users use cases keeps its command record app-side, because ADC and + Store disagree on the pipeline markers those records carry: both DeleteUserCommand records, for + instance, implement ICacheInvalidating with a CachePrefix built from their own User type + (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserCommand.cs:14, + :17; + MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/DeleteUser/DeleteUserCommand.cs:17, + :20), which is a value no shared record could produce. This query carries no markers at all: + it is not ICacheInvalidating, not + IQueryCacheable, not + ICommandWithRequest<out TRequest>. + That absence is precisely what made it hoistable, and it is the rule worth taking away: a type + moves into the framework when it has no app-specific policy attached to it.

    [Rubric §9: API & Contract Design] assesses whether the contract between layers is explicit and minimal. The query is the entire input contract for the read: one identifier, supplied by the controller from the authenticated principal rather than by the caller, so there is no way to ask for another account's preferences through this shape - (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:145-151).

    + (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:145-150).

    [Rubric §6: CQRS & Event-Driven] assesses the separation of reads from writes. This is the read half of the culture/theme pair; its write counterpart is the app-side ChangePreferencesCommand handled by ChangePreferencesHandlerBase<TUser, TCommand>. @@ -2577,8 +2616,8 @@

    GetUserPreferencesQuery

  • Why it's built this way: the query and its UserPreferencesResponse reply were byte-identical in both app Identity modules, so the handler base could be made generic in the User aggregate alone - rather than also in the query type - (GetUserPreferencesHandlerBase.cs:10-14). Preferences themselves are the persistence side of + rather than also in the query type (GetUserPreferencesHandlerBase.cs:10-14). Preferences + themselves are the persistence side of ADR-027 (culture) and ADR-028 (theme).

  • @@ -2591,7 +2630,7 @@

    GetUserPreferencesQuery

    (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AuthController.cs:34, MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/AuthController.cs:32), and both architecture suites use it as the query specimen when asserting decorator ordering - (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/DecoratorPipelineOrderTests.cs:27, + (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/DecoratorPipelineOrderTests.cs:28, MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/DecoratorPipelineOrderTests.cs:27).

    @@ -2601,9 +2640,10 @@

    ChangePasswordHandlerBase<TU

    MMCA.Common.Application · MMCA.Common.Application.Users.UseCases.ChangePassword · MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:24 · Level 8 · class (abstract)

      -
    • What it is: the shared password-rotation workflow. Load the account, verify the current password - against the stored hash, hash the new one, let the aggregate apply its own invariants, and persist - only if the aggregate accepted the change (ChangePasswordHandlerBase.cs:24, :42-70).

      +
    • What it is: the shared password-rotation workflow for an authenticated user. Load the account, + verify the current password against the stored hash, hash the new one, let the aggregate apply its + own invariants, and persist only if the aggregate accepted the change + (ChangePasswordHandlerBase.cs:24, :42-70).

    • Depends on: IUnitOfWork, IPasswordHasher and an ILogger as primary-constructor @@ -2620,20 +2660,22 @@

      ChangePasswordHandlerBase<TU UserUseCaseLog. Externals: Microsoft.Extensions.Logging (:1).

    • Concept introduced: the generic template-method handler, and the two axes it is generic over. - The prior state was two line-identical handlers, one per app, differing only in log text - (ChangePasswordHandlerBase.cs:11-15). Hoisting them needed two variation points, and each is a - separate generic parameter for a separate reason. TUser varies because each app owns its own + The two app Identity modules carried line-identical copies of this handler, differing only in log + text (ChangePasswordHandlerBase.cs:11-15). Hoisting them needed two variation points, and each is + a separate generic parameter for a separate reason. TUser varies because each app owns its own User aggregate and the framework must never reference either; the capability it needs is named - by an interface constraint instead, so the base can call ChangePassword without knowing the type. - TCommand varies because the command record carries app-specific pipeline policy: ADC's - ChangePasswordCommand is + by an interface constraint instead, so the base can call ChangePassword without knowing the type + (IPasswordChangeableUser.cs:19). TCommand varies because the command record carries + app-specific pipeline policy: ADC's ChangePasswordCommand is ICacheInvalidating - (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordCommand.cs:14-15) - and Store's is not - (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordCommand.cs:12-13), + (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordCommand.cs:15, + :18) and Store's is not + (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordCommand.cs:13), so a single shared record would have had to pick one behavior. The base reads the command only through IUserScopedCommand<out TRequest>, which is deliberately - not ICommandWithRequest<TRequest>: that marker also opts the command into automatic + not + ICommandWithRequest<out TRequest>: + that marker also opts the command into automatic CommandRequestValidator<TCommand, TRequest> registration, which is a per-app decision (IUserScopedCommand.cs:6-11).

      [Rubric §1: SOLID] assesses open/closed and dependency inversion. The workflow is closed for @@ -2644,10 +2686,9 @@

      ChangePasswordHandlerBase<TU The current password is verified before anything is written (:55), the failure is an Unauthorized error with a stable code rather than a message that distinguishes "no such user" from "wrong password" at this layer (:57-58), and nothing in the handler ever logs the plaintext, the - hash or the salt: the success log carries only the user id - (UserUseCaseLog.cs:13-14). Hashing itself is delegated to - IPasswordHasher, whose contract returns a hash and a fresh - salt as a tuple (IPasswordHasher.cs:11), the shape + hash or the salt: the success log carries only the user id (UserUseCaseLog.cs:13-14). Hashing + itself is delegated to IPasswordHasher, whose contract returns + a hash and a fresh salt as a tuple (IPasswordHasher.cs:11), the shape ADR-032 fixes.

      [Rubric §4: DDD] assesses whether business rules live in the domain. The handler never mutates the user's fields: it calls user.ChangePassword(newHash, newSalt) (:62) and returns whatever @@ -2659,7 +2700,7 @@

      ChangePasswordHandlerBase<TU
      • Primary constructor (:24-27): unitOfWork, passwordHasher, logger. The logger is typed as the non-generic ILogger so a subclass can pass its own ILogger<TAppHandler> and keep the log - category app-specific while the message text stays shared.
      • + category app-specific while the message text stays shared (UserUseCaseLog.cs:5-10).
      • UnitOfWork (:32): a protected pass-through over the captured parameter, exposed so an app subclass can enlist further aggregates in the same unit of work.
      • HandlerName (:39): protected virtual, defaulting to GetType().Name. This is the detail @@ -2684,20 +2725,21 @@

        ChangePasswordHandlerBase<TU (ChangePasswordHandlerBase.cs:16-21).

      • Where it's used: subclassed once per app, each subclass empty apart from the constructor - forwarding (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordHandler.cs:17-23, - MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordHandler.cs:20). - Both subclasses are picked up as scoped command handlers by - ScanModuleApplicationServices<TAssemblyMarker>() (see - DependencyInjection) and are then wrapped by the decorator pipeline. The - workflow is pinned directly by + forwarding + (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordHandler.cs:17, + :21; + MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordHandler.cs:16, + :20). Both subclasses are picked up as scoped command handlers by + ScanModuleApplicationServices<TAssemblyMarker>() (see DependencyInjection) + and are then wrapped by the decorator pipeline. The workflow is pinned directly by MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/ChangePasswordHandlerBaseTests.cs:15, - which drives it through a test double subclass (:123).

        + which drives it through a test double subclass (:122-123).

      • Caveats: new-password strength is not checked here. Both apps' commands additionally - implement ICommandWithRequest<ChangePasswordRequest> (ChangePasswordCommand.cs:15 in ADC, - :13 in Store), which routes the payload through the Validating decorator before the handler runs, - so the base can assume a syntactically valid request. Neither app's command implements - ITransactional, so the single SaveChangesAsync at :65 is the whole atomic unit.

        + implement ICommandWithRequest<ChangePasswordRequest> (ChangePasswordCommand.cs:15 in ADC, :13 + in Store), which routes the payload through the Validating decorator before the handler runs, so + the base can assume a syntactically valid request. Neither app's command implements ITransactional, + so the single SaveChangesAsync at :65 is the whole atomic unit.


      @@ -2729,8 +2771,7 @@

      ChangePreferencesHandlerBase merge therefore happens in exactly one place, at the call into the aggregate: command.Request.Culture ?? user.PreferredCulture and the matching line for the theme (:53-55). The domain interface documents the same contract from its side, so an aggregate author knows that - UpdatePreferences always receives both values fully resolved - (IUserPreferences.cs:18-25).

      + UpdatePreferences always receives both values fully resolved (IUserPreferences.cs:18-25).

      [Rubric §16: Maintainability] assesses whether a rule has one home. Before the hoist this merge existed twice; a change to it (say, adding a third preference) had to be made in two repositories in lockstep or the apps would drift. It now has one home and one test suite.

      @@ -2740,7 +2781,7 @@

      ChangePreferencesHandlerBase otherwise produce (ADR-027 culture, ADR-028 theme).

    • -
    • Walkthrough: same shape as the password base, one method shorter.

      +
    • Walkthrough: same shape as the password base, one collaborator shorter.

      • Primary constructor (:23-25): unitOfWork and logger; no hasher, since nothing here is credential material.
      • @@ -2759,24 +2800,24 @@

        ChangePreferencesHandlerBase ChangePasswordHandlerBase<TUser, TCommand>, and with the same asymmetry on the command record: ADC's ChangePreferencesCommand is ICacheInvalidating - (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesCommand.cs:14-15) - while Store's is not + (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesCommand.cs:15, + :18) while Store's is not (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesCommand.cs:11-12), so the record stays app-side and only the payload record (ChangePreferencesRequest) is shared (ChangePreferencesHandlerBase.cs:16-20).

      • Where it's used: subclassed by - MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandler.cs:17-22 - and - MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandler.cs:19, - both empty subclasses that exist only to fix the generic arguments and preserve the class name. - Invoked from + MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandler.cs:17, + :20 and + MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandler.cs:16, + :19, both empty subclasses that exist only to fix the generic arguments and preserve the class + name. Invoked from UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand>, which builds the app's command through a factory hook and returns 204 No Content on success (UserAccountAuthControllerBase.cs:125-131). Covered by MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/ChangePreferencesHandlerBaseTests.cs:16 - through a test subclass (:109).

        + through a test subclass (:108-109).


      @@ -2788,7 +2829,7 @@

      DeleteUserHandlerBase<TUser, TCo
    • What it is: the shared account-erasure workflow: authorize owner-or-privileged-role, soft-delete the account, run the app's tail hook, irreversibly anonymize the personal data in place, save, then drain a post-commit queue (DeleteUserHandlerBase.cs:38, :55-119). It is the most extensible of - the five bases: one abstract member and one virtual hook.

      + the seven Users bases: one abstract member and one virtual hook.

    • Depends on: IUnitOfWork and an ILogger (:38-40); implements @@ -2833,13 +2874,11 @@

      DeleteUserHandlerBase<TUser, TCo handlers are scoped and a field would be a shared mutable across the whole request.

      [Rubric §30: Compliance, Privacy & Data Governance] assesses whether a data-subject erasure request is actually satisfiable. The sequence here is the mechanism behind both apps' published - erasure promise: soft-delete, then irreversible anonymization, in one transaction - (:10-15).

      + erasure promise: soft-delete, then irreversible anonymization, in one transaction (:10-15).

      [Rubric §11: Security] assesses authorization placement. The very first thing the method does, before it touches the repository, is the ownership check (:62-71), so an unauthorized caller cannot even confirm that an account id exists. The privileged-role test is passed in already - evaluated because each app owns its own role vocabulary - (UserOwnershipRule.cs:15-19).

      + evaluated because each app owns its own role vocabulary (UserOwnershipRule.cs:15-19).

      [Rubric §1: SOLID] assesses the template-method shape. The invariant order (authorize, load, delete, tail, anonymize, save, post-commit) is fixed by the base; only the two hooks vary.

    • @@ -2851,8 +2890,8 @@

      DeleteUserHandlerBase<TUser, TCo source.

    • HandleAsync(TCommand, CancellationToken) (:55-119):
      • Authorization first (:62-67) through - UserOwnershipRule.CheckOwnership, with the code - "User.DeleteForbidden" and a message the caller sees; a non-null return is the failure + UserOwnershipRule.CheckOwnership (UserOwnershipRule.cs:38), with the + code "User.DeleteForbidden" and a message the caller sees; a non-null return is the failure (:68-71).
      • Load through the write repository (:73-74); Error.NotFound when absent (:77).
      • IErasableUser erasable = user; erasable.Delete() (:88-89) with the dispatch rationale above; @@ -2875,42 +2914,157 @@

        DeleteUserHandlerBase<TUser, TCo

    • Why it's built this way: the hook contract was derived from what the two apps actually needed, - and both uses are visible in their overrides. ADC captures the avatar blob name before - anonymization clears the URL, raises the cross-service UserDeleted domain event on the aggregate - so its outbox row is written by the very save that commits the erasure, and queues the - soft-deleted-user cache marker and the blob deletion as post-commit actions - (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs:46-88). + and both uses are visible in their overrides. ADC's override + (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs:46-88) + captures the avatar blob name before anonymization clears the URL (DeleteUserHandler.cs:54), + raises the cross-service UserDeleted domain event on the aggregate so its outbox row is written by + the very save that commits the erasure (:62), and queues the soft-deleted-user cache marker and + the blob deletion as post-commit actions (:68-84). Store instead cascades in the same unit of work, erasing the linked Customer that holds its name/email/address PII and returning the Customer's own failure untouched so nothing is persisted (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs:35-60). One hook covers both because it can do work inline and schedule work for after the commit.

    • Where it's used: subclassed once per app - (MMCA.ADC/.../DeleteUser/DeleteUserHandler.cs:28-34 with HasDeletePrivilege returning - UserRole.IsOrganizer(...) at :42-43; - MMCA.Store/.../DeleteUser/DeleteUserHandler.cs:20-23 with UserRole.IsAdmin(...) at :26-27). - Covered directly by - MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/DeleteUserHandlerBaseTests.cs:14, - whose fixture user type is named TestHidingDeleteUser (:196) precisely so the hidden-Delete() - dispatch rule above is a regression test rather than a comment.

      + (MMCA.ADC/.../DeleteUser/DeleteUserHandler.cs:28, :34, with HasDeletePrivilege returning + UserRole.IsOrganizer(...) at :42-43; MMCA.Store/.../DeleteUser/DeleteUserHandler.cs:20, :23, + with UserRole.IsAdmin(...) at :26-27). Covered directly by + MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/DeleteUserHandlerBaseTests.cs:14, whose + fixture user type is TestHidingDeleteUser + (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/UserUseCaseTestDoubles.cs:96, closed + over at DeleteUserHandlerBaseTests.cs:195-196) precisely so the hidden-Delete() dispatch rule + above is a regression test rather than a comment.

    • Caveats: post-commit actions run after the erasure has already succeeded, so each one owns its own failure handling; the base does not wrap them (:111-114, and see the documented expectation at :135-139). ADC's override wraps its cache-marker action in a try/catch for exactly that reason - (MMCA.ADC/.../DeleteUser/DeleteUserHandler.cs:68-80). Both apps' DeleteUserCommand records are + (MMCA.ADC/.../DeleteUser/DeleteUserHandler.cs:70-79). Both apps' DeleteUserCommand records are ICacheInvalidating, so the cache prefix they carry is invalidated by the decorator after the handler returns success, outside this class.


    +

    ForgotPasswordHandlerBase<TUser, TCommand>

    +
    +

    MMCA.Common.Application · MMCA.Common.Application.Users.UseCases.ForgotPassword · MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:35 · Level 8 · class (abstract)

    +
    +
      +
    • What it is: the shared start-a-password-reset workflow: parse the submitted address, resolve the + account behind it, mint a single-use token, and email it. Every outcome returns + Result.Success() (ForgotPasswordHandlerBase.cs:35, :51-100).

      +
    • +
    • Depends on: IUnitOfWork, + IPasswordResetTokenService, + IEmailSender, + IOptions<PasswordResetSettings> and an ILogger + (:35-40); implements + ICommandHandler<in TCommand, TResult> + over Result (:40). Constraints: TUser is only an + AuditableAggregateRootEntity<TIdentifierType> + keyed by UserIdentifierType (:41) with no capability interface at all, because the workflow + reads nothing off the aggregate except Id (:72), and TCommand is an + ICommandWithRequest<out TRequest> + carrying a ForgotPasswordRequest (:42). It also uses + the Email value object (:57) and + UserUseCaseLog. Externals: Microsoft.Extensions.Options, + Microsoft.Extensions.Logging, System.Globalization and System.Net.WebUtility (:1-4).

      +
    • +
    • Concept introduced: the success-always handler, and anti-enumeration as a return-type decision. + Every other command base in this family reports its failures. This one cannot. A response that + differs between "we sent you a reset link" and "no such account" is an account-enumeration oracle: + anyone can walk an address list and learn which addresses are registered. So the four ways this + workflow can fail to send anything all return Result.Success() and differ only in a log line: a + malformed address (:58-62), an address with no account (:66-70), a request the token service + throttled (:73-77), and an email send that threw (:90-96). The class remarks state the rule + outright and name the one exception: only the request validator can produce a 400, and it inspects + the shape of the address alone (:20-25, + MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ForgotPasswordRequestValidator.cs:6-16).

      +

      [Rubric §11: Security] assesses whether a public endpoint leaks facts about who holds an account. + The leak surface is wider than the HTTP response, and the code closes it in three places. The result + is uniform (:62, :70, :77, :95). The controller turns every one of them into the same + 202 Accepted + (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:82-93). + And the rejection log deliberately carries a reason string but no address and no account id, so the + log does not become the oracle the response is not + (UserUseCaseLog.cs:34-37). Only the paths that already proved an account exists log a user id + (UserUseCaseLog.cs:25-32).

      +

      [Rubric §29: Resilience & Business Continuity] assesses what happens when a dependency fails + mid-workflow. A send failure is caught, logged with the exception, and swallowed (:90-96); the + token has already been issued and is still valid, so the user can retry or use the link from a later + request. The catch filter excludes OperationCanceledException (:90) so a cancelled request is + not misreported as a delivered reset.

      +

      [Rubric §3: Clean Architecture] assesses dependency direction. The workflow lives in the + Application layer and reaches the SMTP relay, the token cache and the database only through + interfaces; the one thing it genuinely cannot express in the framework, an address-to-account lookup + over an app-owned User aggregate, is the single abstract member (:109).

      +
    • +
    • Walkthrough: two protected properties, the handler method, and four hooks.

      +
        +
      • Primary constructor (:35-40): unitOfWork, tokenService, emailSender, settings, logger.
      • +
      • UnitOfWork (:45): exposed so the lookup override can reach a read repository. Both apps use it + for exactly that.
      • +
      • Settings (:48): settings.Value, unwrapped once so the body reads + Settings.TokenLifetimeMinutes rather than settings.Value....
      • +
      • HandleAsync(TCommand, CancellationToken) (:51-100): null-guard (:55); Email.Create on the + raw string, so a malformed address never reaches the lookup (:57-62); FindUntrackedByEmailAsync + (:65); tokenService.IssueAsync(email.Value, user.Id, ...) (:72), whose failure means the + per-email throttle fired; then the send, composed from the three Compose* hooks and sent as HTML + (:83-88); and finally the PasswordResetRequested log and success (:98-99).
      • +
      • FindUntrackedByEmailAsync(Email, CancellationToken) (:109): protected abstract. The only + app-specific step, because each app's User stores the address differently.
      • +
      • ComposeSubject() (:113): protected virtual, "Reset your password". Override to localize or + rebrand.
      • +
      • ComposeBody(string? resetLink, string token) (:123-134): protected virtual. It carries the + link and the raw token, because clients without deep linking (the MAUI head) need the token + typed into the reset page by hand (:115-119). Both the link and the token go through + WebUtility.HtmlEncode before interpolation into the HTML (:128, :132), and the expiry is + rendered with CultureInfo.InvariantCulture (:125).
      • +
      • ComposeResetLink(string email, string token) (:144-147): protected virtual. Returns null + when PasswordResetSettings.ResetUrl is blank, so an unconfigured host degrades to a token-only + email rather than emailing a broken link; otherwise it appends ?email=...&token=... with both + values Uri.EscapeDataString-encoded.
      • +
      +
    • +
    • Why it's built this way: the reset token is deliberately not a database row. It lives in the + distributed cache, hashed at rest, with the per-email request throttle and the per-token attempt cap + enforced by the token service rather than by this handler + (ADR-091; + MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:56, :71, + :79-80). That split is why the handler's only reaction to a throttled request is a log line: it + never learns which limit fired. The command record stays app-side for the same reason it does in the + ChangePassword hoist, and the base reads it only through ICommandWithRequest<ForgotPasswordRequest> + (:27-31).

      +
    • +
    • Where it's used: subclassed once per app, each override implementing the address lookup as an + untracked GetAllAsync filtered on the Email value object + (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandler.cs:20, + :26, :29-37; + MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandler.cs:21, + :27, :34-46). Reached over HTTP through + PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand>, + whose POST forgot-password action is [AllowAnonymous], rate-limited by the auth-IP policy and + [Idempotent] (PasswordResetAuthControllerBase.cs:75-93). Pinned by six tests in + MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/ForgotPasswordHandlerBaseTests.cs:20 + through a test subclass (:190-195), one per rejection path plus the unconfigured-ResetUrl + degradation (:27, :42, :57, :79, :92, :108).

      +
    • +
    • Caveats: the anonymous command carries no user identifier, which is why it implements + ICommandWithRequest<out TRequest> + rather than IUserScopedCommand<out TRequest> + (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ForgotPassword/ForgotPasswordCommand.cs:12-13). + Nothing in this workflow writes to the database, so it never calls SaveChangesAsync; the unit of + work is present only to hand the subclass a read repository.

      +
    • +
    +

    GetUserPreferencesHandlerBase<TUser>

    MMCA.Common.Application · MMCA.Common.Application.Users.UseCases.GetPreferences · MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandlerBase.cs:21 · Level 8 · class (abstract)

      -
    • What it is: the shared preference-read workflow, and the only query handler among the five - Users bases. Load the account through the read repository and project its two preference fields into - a UserPreferencesResponse +

    • What it is: the shared preference-read workflow, and the only query handler among the Users + bases. Load the account through the read repository and project its two preference fields into a + UserPreferencesResponse (GetUserPreferencesHandlerBase.cs:21, :33-45).

    • Depends on: IUnitOfWork as its single @@ -2923,7 +3077,7 @@

      GetUserPreferencesHandlerBase<TUs Error. No logger, and no externals beyond the BCL.

    • Concept introduced: one generic parameter is enough when nothing app-specific rides on the - request. This is the contrast case for the three command bases above. Because + request. This is the contrast case for the command bases above. Because GetUserPreferencesQuery carries no pipeline markers, it could be shared outright, so the base is generic in the User aggregate only (:10-14). Note also the weaker entity constraint: AuditableBaseEntity<UserIdentifierType> rather than @@ -2943,8 +3097,8 @@

      GetUserPreferencesHandlerBase<TUs using GetRepository instead.

      [Rubric §15: Best Practices & Code Quality] assesses consistency of error shape. The not-found path produces the identical Error.NotFound.WithSource(HandlerName).WithTarget(typeof(TUser).Name) - construction the three command bases use (:42-43), so every account use case in both apps reports - a missing user the same way.

      + construction the command bases use (:42-43), so every account use case in both apps reports a + missing user the same way.

    • Walkthrough: one protected member and one method.

        @@ -2959,22 +3113,22 @@

        GetUserPreferencesHandlerBase<TUs

    • Why it's built this way: the query, the response and the workflow were all identical across the - two apps, so this is the cleanest of the five hoists; the only decision it had to make was which - repository is correct for a read, and it resolved that in favor of the no-tracking one - (:15-19). Preferences are read at login to reapply a returning user's culture and theme across - devices (ADR-027, + two apps, so this is the cleanest of the Users hoists; the only decision it had to make was which + repository is correct for a read, and it resolved that in favor of the no-tracking one (:15-19). + Preferences are read at login to reapply a returning user's culture and theme across devices + (ADR-027, ADR-028), which is why the read path is worth keeping cheap.

    • Where it's used: subclassed as an empty, name-preserving class in both apps - (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandler.cs:13-16, - MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandler.cs:13). + (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandler.cs:13-14, + MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/GetPreferences/GetUserPreferencesHandler.cs:12-13). Consumed through the closed IQueryHandler<GetUserPreferencesQuery, Result<UserPreferencesResponse>> interface by UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand> - (UserAccountAuthControllerBase.cs:45, :57, :149-151). Pinned by + (UserAccountAuthControllerBase.cs:45, :57, :149-150). Pinned by MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/GetUserPreferencesHandlerBaseTests.cs:14 - through a test subclass (:88), and by each app's own handler tests.

      + through a test subclass (:87-88), and by each app's own handler tests.

    • Caveats: the soft-delete global query filter applies to this read like any other, so a soft-deleted account resolves to null and returns NotFound rather than its stored preferences. @@ -2982,6 +3136,115 @@

      GetUserPreferencesHandlerBase<TUs


    +

    ResetPasswordHandlerBase<TUser, TCommand>

    +
    +

    MMCA.Common.Application · MMCA.Common.Application.Users.UseCases.ResetPassword · MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:30 · Level 8 · class (abstract)

    +
    +
      +
    • What it is: the shared complete-a-password-reset workflow: redeem the single-use token, hash the + new password, let the aggregate apply its invariants, persist, then clear the account's lockout so + the user can sign in immediately with the new credential (ResetPasswordHandlerBase.cs:30, + :50-93).

      +
    • +
    • Depends on: IUnitOfWork, + IPasswordHasher, + IPasswordResetTokenService, + ILoginProtectionService and an ILogger (:30-35); + implements + ICommandHandler<in TCommand, TResult> + over Result (:35). Constraints: TUser is an + AuditableAggregateRootEntity<TIdentifierType> + implementing IPasswordChangeableUser (:36), the same + capability ChangePasswordHandlerBase<TUser, TCommand> + requires, and TCommand is an + ICommandWithRequest<out TRequest> + carrying a ResetPasswordRequest (:37). Also uses + Error and UserUseCaseLog. + Externals: Microsoft.Extensions.Logging (:1).

      +
    • +
    • Concept introduced: burn the token before the write, not after. The token is consumed at the top + of the method, before anything is saved (:61-63), and the comment explains the trade: leaving it + live until the write succeeds opens a replay window in which the same token redeems twice, while + burning it early costs a user whose aggregate then rejects the change one extra reset request + (:58-60). Choosing the second cost is the security-over-convenience call, and it is pinned by its + own test, HandleAsync_ConsumesTheTokenBeforeSaving + (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/ResetPasswordHandlerBaseTests.cs:111).

      +

      Concept introduced: one error for every rejection. Unlike the authenticated change-password + path, which can afford a specific Auth.InvalidCurrentPassword, this anonymous endpoint collapses + an unknown token, an expired token, a mismatched token, an attempt-capped token and a vanished + account into a single Auth.InvalidResetToken (:95-99, produced at :67 and :77). The private + InvalidToken() factory exists so there is exactly one construction site and no way for a future + edit to make two branches distinguishable by accident.

      +

      [Rubric §11: Security] assesses whether an anonymous endpoint leaks account state. Two mechanisms + do the work here: the uniform error above, and a rejection log that names only a reason string, + never an address or an account id (:66, :75, and UserUseCaseLog.cs:34-37). The + matching controller action turns every failure into the same 401 + (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/PasswordResetAuthControllerBase.cs:99-118).

      +

      [Rubric §29: Resilience & Business Continuity] assesses whether a user can recover unaided. The + final ResetFailedAttemptsAsync call (:89) is the part that makes a reset actually usable: a user + who reset the password because the brute-force lockout locked them out would otherwise still be + locked out with a brand-new credential + (ADR-029; + ILoginProtectionService.cs:33).

      +

      [Rubric §4: DDD] assesses whether the rules live in the domain. As with the change-password base, + the handler hashes and then calls user.ChangePassword(newHash, newSalt) (:80), returning the + aggregate's own result on failure without saving or clearing the lockout (:81-84).

      +
    • +
    • Walkthrough: two protected members, the handler method, and one private helper.

      +
        +
      • Primary constructor (:30-35): unitOfWork, passwordHasher, tokenService, loginProtection, + logger. Five collaborators, the widest of the Users bases, because a reset touches the token + store, the hasher, the database and the lockout store in one pass.
      • +
      • UnitOfWork (:40) and HandlerName (:47): the same two protected members as the other bases, + with the same rationale (an app subclass named ResetPasswordHandler reports that name as the + error source, :42-46).
      • +
      • HandleAsync(TCommand, CancellationToken) (:50-93): null-guard (:54); redeem the token via + ValidateAndConsumeAsync(request.Email, request.Token, ...) (:61-63) and fail generically on + rejection (:64-68); take the account id the token resolved to (:70) and load it through the + write repository (:71-72), failing with the same generic error if it is gone (:73-77); + hash the new password (:79) and call the aggregate (:80); SaveChangesAsync (:86); clear the + lockout (:89); log completion and return the aggregate's success result (:91-92).
      • +
      • InvalidToken() (:95-99): the single Error.Unauthorized("Auth.InvalidResetToken", ...) + construction, stamped with HandlerName.
      • +
      +
    • +
    • Why it's built this way: the reset half of the recovery vertical had to share the + change-password hoist's shape (generic in the aggregate, generic in the command, one virtual + HandlerName) so that both credential-write paths report errors identically and neither app has to + restate the workflow + (ADR-091). The one + ordering decision it owns, consuming before saving, is documented in the code rather than left to be + rediscovered (:58-60). New-password strength is not re-checked here because + ResetPasswordRequestValidator includes the same + StrongPasswordRules<T> set the registration and change-password requests use, so a reset cannot be + a way around the complexity policy + (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ResetPasswordRequestValidator.cs:7-23).

      +
    • +
    • Where it's used: subclassed once per app as an empty, name-preserving class + (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordHandler.cs:18, + :24-29; + MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordHandler.cs:20, + :26-31). Reached over HTTP through the POST reset-password action on + PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand> + (PasswordResetAuthControllerBase.cs:99-118), which answers 204 No Content on success. Pinned by + five tests in + MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/ResetPasswordHandlerBaseTests.cs:18 + through a test subclass (:176-181), covering both generic-error paths, the happy path, the + aggregate rejection and the consume-before-save ordering (:28, :48, :64, :85, :111).

      +
    • +
    • Caveats: the two apps differ on cache policy exactly as they do for change-password: ADC's + ResetPasswordCommand is + ICacheInvalidating with a prefix built from its + own User type + (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordCommand.cs:15, + :18) and Store's is not, which is the reason the command record stays app-side. The lockout clear + at :89 runs after the save and is not part of the transaction: if it throws, the password has + already changed. Not determinable from source: whether any deployment configures a + ResetFailedAttemptsAsync implementation that can fail in a way the caller would notice, since the + contract returns a bare Task with no result (ILoginProtectionService.cs:33).

      +
    • +
    +

    ⬅ gRPC & Inter-Service ContractsIndexCommon UI Framework (MudBlazor components, theme, base pages) ➡

    diff --git a/docs/onboarding/group-15-common-ui-framework.html b/docs/onboarding/group-15-common-ui-framework.html index bebd270..6e34cdf 100644 --- a/docs/onboarding/group-15-common-ui-framework.html +++ b/docs/onboarding/group-15-common-ui-framework.html @@ -145,48 +145,67 @@

    Onboarding guide

    15. Common UI Framework (MudBlazor components, theme, base pages)

    What this chapter covers. MMCA.Common.UI is the Blazor presentation package, and it is one of the - two layers (with Grpc) allowed to reference Shared only (see - primer §1). It touches no Application, Domain, or Infrastructure type, - which is exactly what lets it compile into a Blazor WebAssembly bundle and into a .NET MAUI hybrid head. - What it ships is the set of reusable parts every consumer UI assembles pages from: a server-paged - data-grid list-page base class, the brand MudBlazor theme, a typed HTTP service base for - talking to the WebAPI, the client-side authentication and token-refresh boundary, list-page state - preservation across navigation, a pluggable UI-module contract, an end-to-end localization - pipeline, and a turnkey notification inbox / push / live-channel feature. A second, thinner package - MMCA.Common.UI.Web sits above it and holds the pieces that need an ASP.NET pipeline (server-side token - storage, the Blazor Content-Security-Policy provider). The per-app and per-module Razor pages in the - consumer apps (group 21) derive from and consume these primitives, and the same components render across - Blazor Server, WebAssembly, and MAUI with no per-platform reimplementation. + two layers (with Grpc) allowed to reference Shared only: its single ProjectReference is + MMCA.Common.Shared (MMCA.Common/Source/Presentation/MMCA.Common.UI/MMCA.Common.UI.csproj:42), and + every other dependency is a NuGet package (MudBlazor, Polly, SignalR client, Scrutor, QRCoder, + System.IdentityModel.Tokens.Jwt, MMCA.Common.UI.csproj:19-37). It touches no Application, Domain, or + Infrastructure type, which is exactly what lets it compile into a Blazor WebAssembly bundle and into a + .NET MAUI hybrid head (see primer §1). What it ships is the set of + reusable parts every consumer UI assembles pages from: a server-paged data-grid list-page base class, + the brand MudBlazor theme, a typed HTTP service base for talking to the WebAPI, the client-side + authentication and token-refresh boundary, list-page state preservation across navigation, a + pluggable UI-module contract, an end-to-end localization pipeline, and a turnkey notification + inbox / push / live-channel feature. A second, thinner package MMCA.Common.UI.Web sits above it and + holds the pieces that need an ASP.NET pipeline (server-side token storage, the Blazor + Content-Security-Policy provider). The per-app and per-module Razor pages in the consumer apps + (chapter 21) derive from and consume these primitives, and the same + components render across Blazor Server, WebAssembly, and MAUI with no per-platform reimplementation. [Rubric §18, UI Architecture & Component Design] assesses component reuse, separation of presentation from data access, and whether there is a coherent composition model; nearly every type in this group exists so a consumer page is composed rather than hand-rolled.

    The data-access boundary: IEntityService over one named HttpClient. A page never touches HttpClient. It depends on - IEntityService<TEntityDTO, TIdentifierType>, the CRUD + IEntityService<TEntityDTO, TIdentifierType> + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IEntityService.cs:12), the CRUD contract, and gets its behavior from the abstract EntityServiceBase<TEntityDTO, TIdentifierType> (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/EntityServiceBase.cs:25), which derives in turn from AuthenticatedServiceBase (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/AuthenticatedServiceBase.cs:15). That base - owns the two cross-cutting concerns of an outbound API call. First, a Polly retry policy: 3 retries - with exponential backoff (2s, 4s, 8s) plus up to one second of random jitter so a fleet of clients - does not re-converge on the same instant (AuthenticatedServiceBase.cs:26-32), and the retryable set is + owns the cross-cutting concerns of an outbound API call. First, a Polly retry policy: 3 retries with + exponential backoff (2s, 4s, 8s) plus up to one second of random jitter so a fleet of clients does + not re-converge on the same instant (AuthenticatedServiceBase.cs:26-32), and the retryable set is deliberate rather than "any 5xx", 501 and 505 are permanent verdicts and are excluded while 408 and 429 - are explicit invitations to come back (AuthenticatedServiceBase.cs:89-98). Second, a helper that + are explicit invitations to come back (AuthenticatedServiceBase.cs:108-117). Second, a helper that creates a "APIClient" HttpClient from IHttpClientFactory and stamps the JWT Bearer token onto it from ITokenStorageService, swallowing the InvalidOperationException that JS - interop throws during SSR prerender (AuthenticatedServiceBase.cs:57-76). Retry and idempotency are - coupled on purpose: NewIdempotencyKey() (AuthenticatedServiceBase.cs:49) is generated once per + interop throws during SSR prerender (AuthenticatedServiceBase.cs:59-78); a sibling + CreateClientWithToken builds a client around an explicitly supplied token so a request the API answered + 401 can be replayed with one acquired straight from ITokenRefresher rather than + resending the token the server just rejected (AuthenticatedServiceBase.cs:88-95). Retry and idempotency + are coupled on purpose: NewIdempotencyKey() (AuthenticatedServiceBase.cs:51) is generated once per logical write and set as a default header on the single client that serves every attempt - (EntityServiceBase.cs:193-200), so a retried create dedupes on the server instead of producing a - duplicate row (the server half is IdempotencyHeaders and + (EntityServiceBase.cs:135, EntityServiceBase.cs:193-200), so a retried create dedupes on the server + instead of producing a duplicate row (the server half is + IdempotencyHeaders and IdempotentAttribute, - ADR-017). Responses come back in the - same PagedCollectionResult<T> / + ADR-017). Creates are the only verb + that carries a key: updates are full PUTs and deletes are naturally idempotent + (EntityServiceBase.cs:128-130). Responses come back in the same + PagedCollectionResult<T> / CollectionResult<T> envelopes the API returns, and SendRequestAsync runs ServiceExceptionHelper over a failed response before - EnsureSuccessStatusCode can throw a contextless exception (EntityServiceBase.cs:209-213), so a - backend Result.Failure reaches the page as a typed, displayable error. + EnsureSuccessStatusCode can throw a contextless exception (EntityServiceBase.cs:210-211): the helper + matches the ProblemDetails title the API emits ("Domain Exception", "Validation Exception", "Operation + failed") and rethrows it as a + DomainInvariantViolationException + carrying the original message + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ServiceExceptionHelper.cs:49-56), so a backend + Result.Failure reaches the page as a typed, displayable error. Many-to-many join endpoints, which have + POST and DELETE but no standalone reads, get their own thinner base, + ChildEntityServiceBase + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ChildEntityServiceBase.cs:17), whose + DeleteByIdAsync maps a 404 to false instead of an exception (ChildEntityServiceBase.cs:45-48). [Rubric §3, Clean Architecture] and [Rubric §9, API & Contract Design]: the UI binds to a DTO contract and an interface, never to server internals, and the wire envelope is uniform across every entity. [Rubric §29, Resilience] is the retry/jitter/idempotency triad.

    @@ -198,110 +217,162 @@

    15. Common against MudDataGrid<T>, CancellationTokenSource lifecycle, loading state, filter and sort extraction from MudBlazor's GridState<T>, error surfacing through ISnackbar, a LoadFailed flag so a failed fetch renders an inline retry instead of a misleading "no records" empty state - (DataGridListPageBase.cs:40), viewport-driven mobile versus desktop rendering (it implements - IBrowserViewportObserver and flips IsMobile through + (DataGridListPageBase.cs:40, set at :507 and :565), viewport-driven mobile versus desktop + rendering (it implements IBrowserViewportObserver and flips IsMobile through BreakpointConstants at the 960 px sidebar-collapse boundary, - DataGridListPageBase.cs:263-276), a persisted dense-density toggle (DataGridListPageBase.cs:76), and - a careful IAsyncDisposable/IDisposable teardown. It also solves a Blazor render-mode problem: grid - data captured during SSR prerender is persisted through PersistentComponentState as a - PersistedGridState record (DataGridListPageBase.cs:805), restored on - OnInitialized (DataGridListPageBase.cs:136-140) and re-registered for persisting with an explicit - RenderMode.InteractiveAuto, because a page that inherits its render mode from <Routes> gives the - framework nothing to associate the callback with (DataGridListPageBase.cs:149-159). A - PrerenderFetchTimeoutMs of 5000 caps how long prerender may block on a cold backend before falling back - to an empty grid the first interactive fetch refills (DataGridListPageBase.cs:82). + DataGridListPageBase.cs:44,267 and + MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/BreakpointConstants.cs:16-17), a persisted + dense-density toggle (DataGridListPageBase.cs:76), and a careful IAsyncDisposable/IDisposable + teardown. It also solves a Blazor render-mode problem: grid data captured during SSR prerender is + persisted through PersistentComponentState as a PersistedGridState record + (DataGridListPageBase.cs:805), restored on OnInitialized (DataGridListPageBase.cs:130-140) and + re-registered for persisting with an explicit RenderMode.InteractiveAuto, because a page that + inherits its render mode from <Routes> gives the framework nothing to associate the callback with + (DataGridListPageBase.cs:146-159). A PrerenderFetchTimeoutMs of 5000 caps how long prerender may + block on a cold backend before falling back to an empty grid the first interactive fetch refills + (DataGridListPageBase.cs:82, applied at :528). [Rubric §23, Front-End Performance & Rendering] assesses render efficiency and avoided round-trips; this persist-and-restore dance is that concern made concrete. The inline comments also record the MudDataGrid v9 pager quirks the class works around, notably that RowsPerPage cannot be restored by - parameter without resetting CurrentPage (DataGridListPageBase.cs:59-67, :278-283).

    + parameter without resetting CurrentPage (DataGridListPageBase.cs:59-67, :359-411).

    State preservation across navigation. Paging, sort, filters, and density live in the URL query - string as the source of truth, so deep links and browser back/forward replay correctly; the noisier - scroll offset lives in ListPageStateService + string as the source of truth, encoded and decoded by + ListPageQueryStateService under deliberately short reserved keys (p, + ps, mp, s, sd, d, q, f:<name>) with defaults omitted so a pristine list page has a clean + URL (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ListPageQueryStateService.cs:15-28), so + deep links and browser back/forward replay correctly. The noisier scroll offset lives in + ListPageStateService (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ListPageStateService.cs:58), a per-circuit - scoped service whose synchronous dictionary is the fast path and whose HydrateFromSessionAsync / - PersistToSessionAsync mirror entries through sessionStorage via a nav-interop.js module - (ListPageStateService.cs:98-162) so state survives circuit teardown, forceLoad navigation, and the - SSR to WASM transition. Every JS path there is defensively caught (prerender, disconnected circuit, - Safari private mode) so storage can never break the page. The immutable - ListPageState record (ListPageStateService.cs:9) carries page, page size, mobile - page, scroll, sort, density, and a page-specific filter dictionary, and is updated with with - expressions. ListPageQueryStateService owns the URL half and - NavigationHistoryService tracks an in-app history stack for back - affordances. [Rubric §19, State Management & Data Flow] assesses a deliberate, scoped state model - rather than ambient globals: these are registered Scoped, so each circuit gets its own instance + scoped service whose synchronous dictionary is the fast path and whose HydrateFromSessionAsync + (ListPageStateService.cs:98) / PersistToSessionAsync (ListPageStateService.cs:133) mirror entries + through sessionStorage via a nav-interop.js module (ListPageStateService.cs:60) so state survives + circuit teardown, forceLoad navigation, and the SSR to WASM transition. Every JS path there is + defensively caught (prerender, disconnected circuit, Safari private mode) so storage can never break the + page. The immutable ListPageState record (ListPageStateService.cs:9) carries page, + page size, mobile page, scroll, sort, density, and a page-specific filter dictionary, and is updated with + with expressions. NavigationHistoryService + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/NavigationHistoryService.cs:12) + bridges Blazor's NavigationManager to the browser history API so a detail page can perform a real + history.back() when a previous entry exists and fall back to a fixed path otherwise. + [Rubric §19, State Management & Data Flow] assesses a deliberate, scoped state model rather than + ambient globals: these are registered Scoped, so each circuit gets its own instance (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:86-88). [Rubric §25, Navigation & Information Architecture] covers the route catalogue - (RoutePaths, NavItem, NavSection) and the open-redirect guard + (RoutePaths, NavItem with its role, claim, section and group facets, and the + NavSection enum whose declaration order is the sidebar order, + MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/NavSection.cs:7-17) and the open-redirect guard ReturnUrlProtector, which accepts only same-origin relative paths beginning with - a single forward slash and replaces anything else with a fallback - (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/ReturnUrlProtector.cs:18-30).

    + a single forward slash and rejects protocol-relative forms, backslashes, control characters, and + anything that does not parse as a relative URI, replacing each with a fallback + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/ReturnUrlProtector.cs:18-59).

    Authentication and the host-polymorphic token refresh. Client-side auth is contracted by - IAuthUIService and implemented by AuthUIService + IAuthUIService + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/IAuthUIService.cs:9) and implemented by + AuthUIService (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthUIService.cs:15), which calls the WebAPI auth/* endpoints, persists tokens through ITokenStorageService, pushes auth-state changes through JwtAuthenticationStateProvider so AuthorizeView reacts immediately, and coordinates push-registration through the device-capability - contract IPushRegistrationService (AuthUIService.cs:15-20). The interesting part is the refresh: - one ITokenRefresher abstraction + contract IPushRegistrationService + (AuthUIService.cs:16-20). Alongside login, register, OAuth code exchange, logout, refresh and change + password, it carries the self-service reset pair: RequestPasswordResetAsync POSTs to the anonymous + auth/forgot-password endpoint, which answers 202 for every well-formed address, so a true result + means "accepted" and never "an account exists" (IAuthUIService.cs:36-41, AuthUIService.cs:285-305), + and ResetPasswordAsync completes the reset against auth/reset-password, returning false with the + server's generic message in LastError for an invalid, expired, or already-consumed token + (IAuthUIService.cs:43-48, AuthUIService.cs:307-328, + ADR-091). The refresh is the + interesting part: one ITokenRefresher abstraction (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/ITokenRefresher.cs:13) has two implementations picked per host, SameOriginProxyTokenRefresher for the browser (the refresh token lives in an HttpOnly cookie and rotation happens server-side behind a same-origin /auth/session/token proxy, so JS never sees it) and DirectApiTokenRefresher for MAUI (the refresh token sits in OS SecureStorage - and is exchanged directly against auth/refresh), documented at ITokenRefresher.cs:3-11. The + and is exchanged directly against auth/refresh), documented at ITokenRefresher.cs:3-11. Storage is + host-polymorphic in the same way: WasmTokenStorageService holds the access + token in memory only and single-flights its re-acquisition behind a lock + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/WasmTokenStorageService.cs:11-30), while + ServerTokenStorageService reads the HttpOnly cookie whenever a live + HttpContext exists (SSR prerender) and switches to the in-memory token on the interactive circuit + (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Services/ServerTokenStorageService.cs:17,30-40, + ADR-022). The ISessionCookieSync / JsFetchSessionCookieSync pair - mirrors the in-memory access token into the HttpOnly cookie the SSR prerender reads, and on a Blazor - Server head ServerTokenStorageService reads that cookie during prerender - and an in-memory token on the interactive circuit - (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs:18-30, - ADR-022). - [Rubric §26, Front-End Security] assesses token handling, XSS exposure, and secret storage, and this - group answers it in three places: keeping the refresh token out of JS-reachable storage, - BlazorCspPolicyProvider, which pins connect-src to 'self' plus the - configured API/Gateway origin and degrades to a permissive Report-Only policy rather than hard-breaking - on a misconfiguration - (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Security/BlazorCspPolicyProvider.cs:21-40), and + mirrors the in-memory access token into that cookie by firing the fetch from the browser, so the + Set-Cookie lands in the user's own jar under both render modes and falls silent when interop is + unavailable + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/JsFetchSessionCookieSync.cs:11-26). Both + storage implementations and both preference services agree on one 30-second expiry skew read through + JwtTokenInfo.IsFresh + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/JwtTokenInfo.cs:17-36), which parses the + token client-side without validating its signature because the API validates every request. Every + outbound call also passes AuthDelegatingHandler, which attaches the stored + bearer token to requests that do not go through CreateAuthenticatedClientAsync + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthDelegatingHandler.cs:9-24). The + cross-service JWKS validation these tokens flow into is + ADR-004.

    +

    Front-end security beyond tokens. [Rubric §26, Front-End Security] assesses token handling, XSS + exposure, and secret storage, and this group answers it in four places: keeping the refresh token out of + JS-reachable storage (above); BlazorCspPolicyProvider, which pins + connect-src to 'self' plus the configured API/Gateway origin (plus its wss form for the SignalR + hub) and degrades to a permissive Report-Only policy rather than hard-breaking on a misconfiguration + (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/Security/BlazorCspPolicyProvider.cs:21,38-56), + feeding the shared + SecurityHeadersMiddleware through + ICspPolicyProvider; WebApplicationExtensions.UseAuthenticatedNoStore, which emits Cache-Control: no-store on authenticated HTML so a logged-out user pressing Back never sees the previous user's page out of the bfcache while anonymous pages stay bfcache-eligible - (MMCA.Common/Source/Presentation/MMCA.Common.UI/Extensions/WebApplicationExtensions.cs:24-44). - The cross-service JWKS validation these tokens flow into is - ADR-004.

    + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Extensions/WebApplicationExtensions.cs:24-44); and the + returnUrl sanitizer already covered. The shared auth forms sit on the same fence: + LoginModel, RegisterModel, ForgotPasswordModel + and ResetPasswordModel are plain data-annotation EditForm models, with + PasswordComplexityAttribute mirroring the server's rule (at least 8 + characters with upper, lower, digit, and a non-alphanumeric character) so the form gives the verdict the + API would, and deferring empty input to [Required] so a blank field shows one message rather than two + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/PasswordComplexityAttribute.cs:12,20-30). + That client-side parity is the point of [Rubric §24, Forms, Validation & UX Safety]: the client + predicts, the server decides.

    Design system and theming. Visual consistency is centralized in one static MMCATheme MudTheme instance (MMCA.Common/Source/Presentation/MMCA.Common.UI/Theme/MMCATheme.cs:11) holding a light palette - (:13-47), a full dark palette (:48-84), an Inter-first typography scale (:85-137), and a 6 px - default border radius (:138-141). It is applied through the shared MmcaThemeProviders component, - which renders the four Mud providers every root layout needs exactly once - (MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/MmcaThemeProviders.razor:11-14). The + (:13-47), a full dark palette (:48-84), an Inter-first typography scale (:85-163), and a 6 px + default border radius (:164-167). It is applied through the shared MmcaThemeProviders component, + which renders the four Mud providers every root layout needs exactly once and takes the theme as a + parameter defaulting to MMCATheme.Instance, so an app with its own brand passes a derived MudTheme + instead of duplicating the provider block + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/MmcaThemeProviders.razor:11-14,22). The palette itself comes from a single C# source of truth, BrandColors (MMCA.Common/Source/Presentation/MMCA.Common.UI/Theme/BrandColors.cs:10), whose doc comment states the duplication contract plainly: the CSS custom properties in wwwroot/app.css must mirror these constants because C# cannot read CSS at build time, and BrandColorTokenTests asserts the two stay in sync - (BrandColors.cs:3-9). Color choices carry explicit WCAG reasoning: Secondary was moved to Teal 700 - #00796B for about 5.3:1 on light surfaces because the Teal 600 it replaced sat at about 4.0:1, under - the AA 4.5:1 floor (BrandColors.cs:21-26), and WarningContrastText is overridden to #212121 - because MudBlazor's default white on #F57F17 measures about 2.65:1 and failed an axe scan on a - "Pending Payment" chip (MMCATheme.cs:29-33). - [Rubric §20, Design System, Theming & Consistency] is the home category (one token source, dark mode, - consistent typography) and [Rubric §21, Accessibility] is woven into the palette itself. + (BrandColors.cs:3-9). Color choices carry explicit WCAG reasoning: Secondary is Teal 700 #00796B for + about 5.3:1 on light surfaces because the Teal 600 it replaced sat at about 4.0:1, under the AA 4.5:1 + floor (BrandColors.cs:21-26), and WarningContrastText is overridden to #212121 because MudBlazor's + default white on #F57F17 measures about 2.65:1 and failed an axe scan on a "Pending Payment" chip + (MMCATheme.cs:29-33). [Rubric §20, Design System, Theming & Consistency] is the home category (one + token source, dark mode, consistent typography) and [Rubric §21, Accessibility] is woven into the + palette itself and into the chrome, down to the skip-to-content link the shared layout renders first + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Layout/MainLayout.razor:17). [Rubric §22, Responsive & Cross-Browser] is named by BreakpointConstants and exercised by MobileInfiniteScrollList<TItem>, the mobile card list - whose IntersectionObserver sentinel, rendered-item cap, and generation-guarded supersession of in-flight - fetches keep a long list bounded - (MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/MobileInfiniteScrollList.razor.cs:17).

    + whose IntersectionObserver sentinel, 500-item rendered cap, and generation-guarded supersession of + in-flight fetches keep a long list bounded + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/MobileInfiniteScrollList.razor.cs:17,38-43).

    Dark mode is a service, not a flag. ThemeService (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ThemeService.cs:16, registered Scoped at DependencyInjection.cs:91) owns the preference: InitializeAsync reads the stored value through a theme.js module and falls back to the OS prefers-color-scheme only when nothing is stored (ThemeService.cs:34-49), SetDarkModeAsync persists through the same module and raises OnChange - (ThemeService.cs:53-59), and the JS module handle is held by LazyJsModule so the - import happens once and disposes cleanly. MmcaThemeProviders subscribes to OnChange and re-renders - defensively, guarding the race where the event fires between disposal and render dispatch - (MmcaThemeProviders.razor:33-61). Honest caveat: unlike locale, the no-flash SSR bootstrap is not - wired for theme. InitializeAsync is called from OnAfterRenderAsync(firstRender) because JS interop is - unavailable during prerender (MmcaThemeProviders.razor:22-31), so the bound mode is corrected just - after hydration and a brief wrong-theme first paint is possible + (ThemeService.cs:53-59), and the JS module handle is held by LazyJsModule + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/LazyJsModule.cs:20), a single-flight importer + that caches the in-flight import under a lock so two concurrent callers cannot leak a second module + reference, and that drops a failed task so an import attempted during prerender does not poison the + module for the rest of the circuit (LazyJsModule.cs:5-19). MmcaThemeProviders subscribes to + OnChange and re-renders defensively, guarding the race where the event fires between disposal and + render dispatch (MmcaThemeProviders.razor:40-68). Honest caveat: unlike locale, the no-flash SSR + bootstrap is not wired for theme. InitializeAsync is called from OnAfterRenderAsync(firstRender) + because JS interop is unavailable during prerender (MmcaThemeProviders.razor:29-38), so the bound mode + is corrected just after hydration and a brief wrong-theme first paint is possible (ADR-028).

    Internationalization: one culture decision, carried everywhere. The framework serves en-US and Spanish (es) plus a development-only pseudo locale, and the hard part is not the translations, it is @@ -325,35 +396,46 @@

    15. Common SharedResource for cross-cutting chrome (MMCA.Common/Source/Presentation/MMCA.Common.UI/Resources/SharedResource.cs:9, injected by the shared layout at MMCA.Common/Source/Presentation/MMCA.Common.UI/Layout/MainLayout.razor:12) and - MudTranslations for MudBlazor's own component text (pager, filter menus, pickers), - served through ResxMudLocalizer, which AddUIShared TryAdds because - AddMudServices registers no MudLocalizer of its own (DependencyInjection.cs:51-55). Applying a + MudTranslations for MudBlazor's own component text (pager, filter menus, pickers, + MMCA.Common/Source/Presentation/MMCA.Common.UI/Resources/MudTranslations.cs:10), served through + ResxMudLocalizer, which AddUIShared TryAdds because AddMudServices registers + no MudLocalizer of its own (DependencyInjection.cs:51-55) and whose values degrade to MudBlazor's + built-in English when a key is missing + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Globalization/ResxMudLocalizer.cs:7-17). Applying a switch is host-specific and sits behind ICultureApplier: the web default EndpointCultureApplier force-loads the server /culture/set endpoint so the server re-renders SSR under the new cookie and the WASM runtime re-reads it on startup (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/EndpointCultureApplier.cs:18-32), while a MAUI hybrid head, having no ASP.NET pipeline, replaces it after AddUIShared with an in-process applier - (MauiCultureApplier, group 26). The + (MauiCultureApplier, chapter 26). The development-only pseudo locale is the group's own i18n test harness: PseudoStringLocalizerFactory decorates IStringLocalizerFactory - unconditionally (DependencyInjection.cs:49) and PseudoLocalizer accents every - letter, pads for the roughly 40% expansion real translations need, and wraps the result in a bracket - sentinel while leaving {0} placeholders byte-identical + unconditionally (DependencyInjection.cs:49, + MMCA.Common/Source/Presentation/MMCA.Common.UI/Globalization/PseudoStringLocalizerFactory.cs:11-19) so + every IStringLocalizer in the host is wrapped in a PseudoStringLocalizer at + once, and PseudoLocalizer accents every letter, pads for the roughly 40% expansion + real translations need, and wraps the result in a bracket sentinel while leaving {0} placeholders + byte-identical (MMCA.Common/Source/Presentation/MMCA.Common.UI/Globalization/PseudoLocalizer.cs:20-30), which makes hard-coded strings, fixed-width layouts, and concatenated fragments all visible in one pass - (PseudoLocalizer.cs:12-19). [Rubric §27, Internationalization] is the home category here, and adding - a locale is a .es.resx sibling plus one allowlist entry, not new infrastructure.

    + (PseudoLocalizer.cs:12-19). Even the snackbar text is localized: ErrorMessages keeps + its static call sites but resolves each message from SharedResource once the root layout hands it a + localizer, falling back to the English format string until then + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/ErrorMessages.cs:17,26). + [Rubric §27, Internationalization] is the home category here, and adding a locale is a .es.resx + sibling plus one allowlist entry, not new infrastructure.

    Per-user preference persistence. A signed-in user's culture and theme follow them across devices via the Identity profile. IUserPreferenceWriter / ApiUserPreferenceWriter PUT to auth/preferences over the shared "APIClient" - (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ApiUserPreferenceWriter.cs:63-66) using the + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ApiUserPreferenceWriter.cs:62-66) using the private UserPreferencesRequest record (ApiUserPreferenceWriter.cs:29), and IUserPreferenceReader / ApiUserPreferenceReader GET the same endpoint at login and return the immutable UserPreferences record, whose null fields mean "leave unchanged" - (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ApiUserPreferenceReader.cs:24-52). The write - is strictly best-effort: the cookie is the device-local runtime channel and a failed persist never breaks + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/UserPreferences.cs:9, + MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ApiUserPreferenceReader.cs:24-52). The write is + strictly best-effort: the cookie is the device-local runtime channel and a failed persist never breaks the in-page switch. Best-effort has a cost, though, and both sides guard it, first by refusing to send when the token is missing, unreadable, or within 30 seconds of expiry via JwtTokenInfo.IsFresh (ApiUserPreferenceWriter.cs:27,47, @@ -363,7 +445,7 @@

    15. Common detail as much as a [Rubric §19, State Management] one: at low traffic, one 401 per theme toggle is enough on its own to trip a failed-request alert rule.

    Pluggable UI modules. The module system that organizes the back end - (IModule, group 14) has a front-end counterpart in + (IModule, chapter 14) has a front-end counterpart in IUIModule (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IUIModule.cs:10). A module descriptor exposes its navigation entries as NavItem values, the Assembly holding its Razor pages so @@ -372,41 +454,51 @@

    15. Common prologue is shared too: AddUIModule<TModule>() runs one Scrutor scan that picks up every IEntityService<,> implementation in the module's assembly as scoped, then registers the descriptor as a singleton (DependencyInjection.cs:152-162), so a module's own Add{Module}UI() no longer carries its - own copy of that scan and can still register services that must win afterwards. Adding a feature module - therefore wires its pages, its services, and its menu entries into the shell with no edit to the shell. - [Rubric §18, UI Architecture] and [Rubric §1, SOLID] (open/closed).

    + own copy of that scan and can still register services that must win afterwards. + UIModuleConfiguration lets a host switch a module off through + Modules:{name}:Enabled, defaulting to enabled when the section is absent + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/UIModuleConfiguration.cs:19-22), and + IHomePageContent is the per-app landing-page hook behind the shared / route + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IHomePageContent.cs:8). Adding a + feature module therefore wires its pages, its services, and its menu entries into the shell with no edit + to the shell. [Rubric §18, UI Architecture] and [Rubric §1, SOLID] (open/closed).

    A complete vertical slice shipped inside the framework: notifications. Unlike the rest of the package, which is base classes consumers extend, the Notifications area is a finished feature an app switches on with one call. NotificationUIModule (MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/NotificationUIModule.cs:14) contributes a user-facing inbox nav entry plus an Organizer-gated push-notification entry (NotificationUIModule.cs:16-20), the app-bar NotificationBell - (NotificationUIModule.cs:22), and a root-layout listener component (NotificationUIModule.cs:24); NotificationInbox, NotificationList, and - NotificationSend render it; - NotificationInboxService and - PushNotificationService (behind + (NotificationUIModule.cs:22), and a root-layout listener component (NotificationUIModule.cs:24); + NotificationInbox, NotificationList, and + NotificationSend render it; NotificationInboxService + and PushNotificationService (behind INotificationInboxUIService and IPushNotificationUIService) call the API; and NotificationHubService (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationHubService.cs:26) - holds the SignalR connection to the API's NotificationHub, - retrying an initial connect up to 3 times with doubling backoff and discarding a connection that never - started so a later join is not blocked forever (NotificationHubService.cs:145-179). The same connection - carries ephemeral live channel events: components join through JoinChannelAsync - (NotificationHubService.cs:192), membership is reference-counted per key by - ChannelReferenceCounter so one subscriber leaving does not cut the channel - off for the others, handlers are multicast through disposable ChannelSubscription handles - (NotificationHubService.cs:412-419), - and every held channel is re-joined on Reconnected because SignalR group membership does not survive a - new connection (NotificationHubService.cs:16-24, :141-143). Which notifications a user sees can be - narrowed by INotificationScopeProvider, an app-supplied scope key such as - "event:2" that both HTTP services consume so a send and the reads that follow agree, defaulting to the - unscoped NullNotificationScopeProvider and contractually forbidden from + holds the SignalR connection to the API's + NotificationHub, retrying an initial connect up to 3 times + with doubling backoff and discarding a connection that never started so a later join is not blocked + forever (NotificationHubService.cs:28,145-180). The same connection carries ephemeral live channel + events: components join through JoinChannelAsync (NotificationHubService.cs:192), membership is + reference-counted per key by ChannelReferenceCounter so one subscriber + leaving does not cut the channel off for the others + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/ChannelReferenceCounter.cs:16), + handlers are multicast through disposable ChannelSubscription handles + (NotificationHubService.cs:412), and every held channel is re-joined on Reconnected because SignalR + group membership does not survive a new connection (NotificationHubService.cs:16-24, :143). Which + notifications a user sees can be narrowed by INotificationScopeProvider, + an app-supplied scope key such as "event:2" that both HTTP services consume so a send and the reads + that follow agree, defaulting to the unscoped + NullNotificationScopeProvider and contractually forbidden from throwing, since a scope is a view filter and not a security boundary (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/INotificationScopeProvider.cs:9-21). - Shared unread state lives in NotificationState, and the whole feature is wired by - its own DependencyInjection.AddNotificationUI() in the Notifications - namespace + Shared unread state lives in NotificationState, which also arbitrates a single + active-poller slot by owner reference rather than a counter, so a teardown that never unregisters cannot + strand the slot for the life of the circuit + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/NotificationState.cs:8,12-19), + and the whole feature is wired by its own + DependencyInjection.AddNotificationUI() in the Notifications namespace (MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/DependencyInjection.cs:20-42), kept separate so an app that does not want real-time notifications never pays for the SignalR plumbing.

    How it wires up at startup. A host's Program.cs calls AddUIShared(configuration) once, a C# @@ -414,42 +506,55 @@

    15. Common on DependencyInjection (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:29-112). In order it binds and validates on start ApiSettings, so a missing endpoint fails the host rather than the - first request (DependencyInjection.cs:32-35); binds LayoutSettings without - validation, deliberately optional so a host with no Layout section still renders - (DependencyInjection.cs:38-39); sets up localization and the pseudo/Mud localizer decorators - (:42-55); registers the auth and culture delegating handlers and the named "APIClient" whose base - address comes from ApiSettings and whose timeout is pinned to + first request (DependencyInjection.cs:32-35; the read-only face of those options is + IApiSettings, whose WasmApiEndpoint lets the server call an internal URL while the + browser is handed an external one, + MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Settings/IApiSettings.cs:11-17); binds + LayoutSettings without validation, deliberately optional so a host with no Layout + section still renders (DependencyInjection.cs:38-39); sets up localization and the pseudo/Mud localizer + decorators (:42-55); registers the auth and culture delegating handlers and the named "APIClient" + whose base address comes from ApiSettings and whose timeout is pinned to HttpResilienceDefaults.TotalRequestTimeout rather than the BCL's arbitrary 100s, so the transport never pre-empts the resilience budget (:59-82); then TryAdds AuthUIService, the list-page state services, NavigationHistoryService, ThemeService, EndpointCultureApplier, the preference reader/writer, and a default IOAuthUISettings (DefaultOAuthUISettings) that downstream - apps override with ConfigurationOAuthUISettings (:85-105); and finally - calls AddDeviceCapabilityDefaults() so every capability contract resolves on every head - (ADR-042, group 26). The - TryAdd* discipline is what lets a consumer pre-register its own implementation and win. Browser hosts - add AddClientAuthSessionCookieSync() (:119-123) and AddWasmFormFactor() (:131-132); a Blazor - Server head adds AddCommonServerTokenStorage(), AddCommonBlazorCsp(), and AddCommonWebFormFactor() - from MMCA.Common.UI.Web (MMCA.Common.UI.Web/DependencyInjection.cs:26-48) plus the + apps override with ConfigurationOAuthUISettings, which reads provider + availability from the OAuth section for a server host and from pre-computed Enabled flags for a WASM + client (:85-105, + MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/ConfigurationOAuthUISettings.cs:13-28); + and finally calls AddDeviceCapabilityDefaults() so every capability contract resolves on every head + (:109, ADR-042, + chapter 26). The TryAdd* discipline is what lets a consumer pre-register its own implementation and + win. Browser hosts add AddClientAuthSessionCookieSync() (:119-123) and AddWasmFormFactor() + (:131-132); a Blazor Server head adds AddCommonServerTokenStorage(), AddCommonBlazorCsp() (before + AddCommonSecurityHeaders, so it beats the TryAdded static provider), and AddCommonWebFormFactor() + from MMCA.Common.UI.Web + (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/DependencyInjection.cs:26-48) plus the UseAuthenticatedNoStore() middleware. UISharedAssemblyReference (DependencyInjection.cs:167) is the marker other assemblies scan against.

    -

    The small Level-0 supporting cast fills in the rest: ErrorMessages, - NotificationRoutePaths, UIModuleConfiguration, - IHomePageContent (the per-app landing-page hook behind the shared / route), - LoginModel / RegisterModel / - PasswordComplexityAttribute for the shared auth forms - ([Rubric §24, Forms, Validation & UX Safety]), QrErrorCorrectionLevel, and +

    The small Level-0 supporting cast fills in the rest: NotificationRoutePaths + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/NotificationRoutePaths.cs:6), + QrErrorCorrectionLevel, the framework's own enum for QrCodeImage so the + component's public API does not pin consumers to QRCoder's ECCLevel + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/QrErrorCorrectionLevel.cs:9), and MauiBackNavigationBridge with its - BackNavigationResult for MAUI hardware-back handling. Form-factor detection has - since graduated into its own device-capability layer - (IFormFactor and friends, group 26). The presentational - helper MoneyExtensions formats Money for - display, keeping a display concern out of the domain value object, exactly where Clean Architecture wants - it.

    + BackNavigationResult for MAUI hardware-back handling, which reports both whether + history.back() fired and whether the WebView is at the root of its stack so a host can decide to exit + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Navigation/MauiBackNavigationBridge.cs:19,28). + Form-factor detection has graduated into its own device-capability layer + (IFormFactor and friends, chapter 26). The + presentational helper MoneyExtensions formats + Money for display, grouping a mixed collection by currency so + unrelated amounts never collapse under whichever symbol came first + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Extensions/MoneyExtensions.cs:14,23-30), keeping a + display concern out of the domain value object, exactly where Clean Architecture wants it.

    Read the per-type sections that follow for the mechanics. The consumer-side module UIs live in the ADC - module-UI chapters (group 21), and the bUnit component tests plus the Playwright/axe-core E2E suite that - exercise this package are covered in the testing chapter (group 25).

    + module-UI chapter (chapter 21), and the bUnit component tests plus the + Playwright/axe-core E2E suite that exercise this package are covered in the testing chapter + (chapter 27), which is where [Rubric §28, Front-End Testing] + lives.

    BreakpointConstants

    MMCA.Common.UI · MMCA.Common.UI.Common · MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/BreakpointConstants.cs:9 · Level 0 · class (static)

    @@ -685,6 +790,19 @@

    DependencyInjection

  • Why it's built this way: TryAdd throughout makes these methods safe to call from several composing hosts, and it is also the override mechanism, since a host that registers its own implementation before AddUIShared wins. Two ordering choices are called out in comments and are load-bearing in the opposite direction: ICultureApplier's default round-trips a server /culture/set endpoint that a MAUI hybrid head does not have, so hybrids override it after AddUIShared (:93-96), and device-capability defaults register first precisely so MAUI and browser heads can override them afterwards under last-registration-wins (:107-108).
  • Where it's used: Called once at startup by every consuming UI host (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs, .../MMCA.ADC.UI.Web.Client/Program.cs, .../MMCA.ADC.UI/MauiProgram.cs, and the three Store equivalents), immediately followed by the per-module Add{Module}UI() calls that UIModuleConfiguration.IsModuleEnabled guards. The "APIClient" it configures is the client every EntityServiceBase<TEntityDTO, TIdentifierType>-derived service resolves.
  • +

    ForgotPasswordModel

    +
    +

    MMCA.Common.UI · MMCA.Common.UI.Pages.Auth · MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/ForgotPasswordModel.cs:9 · Level 0 · class (sealed)

    +
    +
      +
    • What it is: the EditForm backing model for the Forgot Password page, a single Email string carrying DataAnnotations for shape validation. Nothing else is collected, because nothing else is needed to start a reset.
    • +
    • Depends on: System.ComponentModel.DataAnnotations (BCL): [Required], [EmailAddress]. Nothing first-party.
    • +
    • Concept introduced, validation deliberately capped at "shape" because of an anti-enumeration contract. [Rubric §24, Forms, Validation & UX Safety] (assesses whether a form gives a clear per-field verdict before submit) and [Rubric §26, Front-End Security] (assesses whether the front end avoids leaking information the back end withholds). Every other form in this group validates as much as it can client-side. This one deliberately stops at "is this a syntactically valid address", because the interesting question, does an account exist for it, is one the server refuses to answer: ADR-091 Decision 3 has ForgotPasswordHandlerBase return success on every path (malformed address, no account, throttled, failed send), so a distinguishable client-side outcome would reintroduce exactly the account-enumeration oracle the endpoint is built to avoid. The doc comment (ForgotPasswordModel.cs:5-8) states that trade-off directly.
    • +
    • Walkthrough: one get; set; property. Email (line 13) carries [Required(ErrorMessage = "Email is required")] and [EmailAddress(ErrorMessage = "Enter a valid email address")] (lines 11-12) and defaults to string.Empty.
    • +
    • Why it's built this way: sealed and mutable (set, not init) because EditForm two-way-binds the input to the model; keeping the model to one field is what makes the page's anti-enumeration behavior easy to reason about, there is no second field whose validation could betray a lookup.
    • +
    • Where it's used: instantiated as _model by ForgotPassword.razor (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/ForgotPassword.razor:66) and bound by its <EditForm Model="_model" OnValidSubmit="HandleRequestAsync"> + <DataAnnotationsValidator /> (lines 34-35), with the field wired For="@(() => _model.Email)" at line 37 so the message attaches to that input. On valid submit HandleRequestAsync (lines 73-90) calls IAuthUIService.RequestPasswordResetAsync(_model.Email) (line 79, contract at MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/IAuthUIService.cs:41) inside a try whose catch is empty on purpose (lines 81-84) and whose finally sets _isSubmitted = true unconditionally (line 88), so the success alert (line 23) renders for every submitted address whether the call succeeded, failed, or threw. The page is reached from the "forgot password" link on the Login page (Login.razor:64).
    • +
    • Caveats / not-in-source: RequestPasswordResetAsync returns bool, and the call site ignores it (line 79); that is the anti-enumeration rule, not an oversight, and the gallery E2E test pins it by asserting the confirmation appears against a stub service that always answers "not accepted" (MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/ForgotPasswordPageE2ETests.cs:28).
    • +

    LoginModel

    MMCA.Common.UI · MMCA.Common.UI.Pages.Auth · MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/LoginModel.cs:9 · Level 0 · class (sealed)

    @@ -699,7 +817,7 @@

    LoginModel

  • Why it's built this way: sealed and mutable (set, not init) because EditForm two-way-binds each input to the model; the messages are authored inline so each field shows one clear verdict.
  • -
  • Where it's used: instantiated as _model and bound by Login.razor (<EditForm Model="_model" OnValidSubmit="HandleLoginAsync"> + <DataAnnotationsValidator />, MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Login.razor:32-33, field at line 130, inputs bound with For="@(() => _model.Email)" at lines 39 and 45 so each MudTextField shows its own message); on valid submit the page hands the credentials to the injected IAuthUIService as a LoginRequest (Login.razor:173). Sibling of RegisterModel.
  • +
  • Where it's used: instantiated as _model and bound by Login.razor (<EditForm Model="_model" OnValidSubmit="HandleLoginAsync"> + <DataAnnotationsValidator />, MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Login.razor:32-33, field at line 134, inputs bound with For="@(() => _model.Email)" at lines 39 and 45 so each MudTextField shows its own message); on valid submit the page hands the credentials to the injected IAuthUIService as a LoginRequest (Login.razor:177). The same page carries the escape hatch for a user who cannot supply a password at all, a link to /forgot-password (Login.razor:64, backed by ForgotPasswordModel). Sibling of RegisterModel.
  • MudTranslations

    @@ -719,7 +837,7 @@

    PasswordComplexityAttribute

    MMCA.Common.UI · MMCA.Common.UI.Pages.Auth · MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/PasswordComplexityAttribute.cs:12 · Level 0 · class (sealed attribute)

      -
    • What it is: a custom ValidationAttribute that enforces the Register form's password-strength rule, at least 8 characters including an uppercase, a lowercase, a digit, and a special (non-alphanumeric) character.
    • +
    • What it is: a custom ValidationAttribute that enforces the framework's password-strength rule on any form that sets a new password, at least 8 characters including an uppercase, a lowercase, a digit, and a special (non-alphanumeric) character.
    • Depends on: System.ComponentModel.DataAnnotations (ValidationAttribute, ValidationResult, ValidationContext) and char.IsUpper/IsLower/IsDigit/IsLetterOrDigit (BCL). Nothing first-party.
    • Concept introduced, extending DataAnnotations with a domain rule. [Rubric §24, Forms, Validation & UX Safety] (assesses client-side validation parity with the server). Beyond the built-in [Required]/[EmailAddress], a bespoke rule subclasses ValidationAttribute and overrides IsValid. The doc comment (PasswordComplexityAttribute.cs:5-10) states the intent: mirror the server's rule so the EditForm gives the same verdict the API would. The downstream server-side story, how an accepted password is then hashed, is ADR-032 (PBKDF2-HMAC-SHA512 with legacy-hash backward compatibility); this attribute is only the client-side gate, never the security boundary.
    • Walkthrough:
        @@ -728,9 +846,9 @@

        PasswordComplexityAttribute

      • IsValid(object?, ValidationContext) (lines 19-39): returns ValidationResult.Success for a non-string or null/empty input (lines 21-24), deliberately deferring the "missing" message to RequiredAttribute so the field shows one message, not two; otherwise evaluates five predicates (Length >= 8, Any(char.IsUpper), Any(char.IsLower), Any(char.IsDigit), Any(c => !char.IsLetterOrDigit(c)), lines 26-30) and, on failure, returns a ValidationResult scoped to the member name (lines 37-38) so the message attaches to the right field.
    • -
    • Why it's built this way: a ValidationAttribute plugs straight into the same DataAnnotationsValidator that drives the rest of the form, so the complexity rule participates in the standard EditForm lifecycle with no extra wiring; emptiness is delegated to [Required] to avoid duplicate messages on one field.
    • -
    • Where it's used: applied to RegisterModel.Password (RegisterModel, RegisterModel.cs:22); evaluated by the <DataAnnotationsValidator /> in Register.razor (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Register.razor:27).
    • -
    • Caveats / not-in-source: the doc comment claims parity with the server's rule; this file only encodes the client check, so whether the server rule is byte-identical is not verifiable from this source.
    • +
    • Why it's built this way: a ValidationAttribute plugs straight into the same DataAnnotationsValidator that drives the rest of the form, so the complexity rule participates in the standard EditForm lifecycle with no extra wiring; emptiness is delegated to [Required] to avoid duplicate messages on one field. Because the rule is an attribute rather than a method, a second form that sets a password gets identical behavior by adding one line, which is exactly how the reset vertical picked it up.
    • +
    • Where it's used: applied to RegisterModel.Password (RegisterModel, RegisterModel.cs:22) and to ResetPasswordModel.NewPassword (ResetPasswordModel, ResetPasswordModel.cs:20); evaluated by the <DataAnnotationsValidator /> in Register.razor (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Register.razor:27) and ResetPassword.razor (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/ResetPassword.razor:35).
    • +
    • Caveats / not-in-source: the doc comment (line 6) still describes the attribute as the rule "for the Register form" although the reset form now carries it too; the code is the wider truth. The comment also claims parity with the server's rule, but this file only encodes the client check, so whether the server rule is byte-identical is not verifiable from this source.

    PersistedGridState

    @@ -778,7 +896,26 @@

    RegisterModel

  • Why it's built this way: the address fields stay attribute-free so a user can register without supplying one; the model is a flat view-model that the page projects onto the wire DTO at submit time rather than reusing the domain type directly.
  • -
  • Where it's used: instantiated as _model and bound by Register.razor (<EditForm Model="_model" OnValidSubmit="HandleRegisterAsync"> + <DataAnnotationsValidator />, MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Register.razor:26-27, field at line 122); on valid submit the page projects it into a RegisterRequest (Register.razor:161), with the address fields folded into an Address by BuildAddressResult() (Register.razor:129), which returns null when all six address fields are blank (lines 131-137) and otherwise Address.Create(...) (line 139). The accepted password is hashed server-side per ADR-032.
  • +
  • Where it's used: instantiated as _model and bound by Register.razor (<EditForm Model="_model" OnValidSubmit="HandleRegisterAsync"> + <DataAnnotationsValidator />, MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/Register.razor:26-27, field at line 122); on valid submit the page projects it into a RegisterRequest (Register.razor:161), with the address fields folded into an Address by BuildAddressResult() (Register.razor:129), which returns null when all six address fields are blank (lines 131-137) and otherwise Address.Create(...) (line 139). The accepted password is hashed server-side per ADR-032. Its password block is mirrored by ResetPasswordModel.
  • + +

    ResetPasswordModel

    +
    +

    MMCA.Common.UI · MMCA.Common.UI.Pages.Auth · MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/ResetPasswordModel.cs:10 · Level 0 · class (sealed)

    +
    +
      +
    • What it is: the EditForm backing model for the Reset Password page: the address and the emailed reset token that identify the request, plus the new password and its confirmation.
    • +
    • Depends on: System.ComponentModel.DataAnnotations ([Required], [EmailAddress], [Compare]) and the sibling first-party PasswordComplexityAttribute.
    • +
    • Concept reinforced, the same password block as registration, on a credential-carrying form. [Rubric §24, Forms, Validation & UX Safety] and [Rubric §26, Front-End Security]. The password half is byte-for-byte the shape RegisterModel introduced ([Required] + [PasswordComplexity] on the new value, [Required] + [Compare] on the confirmation), which is the payoff of expressing the complexity rule as an attribute rather than page code. What is new is the top half: Email and Token are not things the user chooses, they are the credential minted by the server and mailed as a link. The client validates only that both are present and that the address is well-formed; every substantive rejection (unknown, expired, mismatched, or attempt-capped token) collapses into one server-side Auth.InvalidResetToken error by design, per ADR-091 Decision 3, so the form must not try to pre-judge a token it cannot verify.
    • +
    • Walkthrough: four get; set; properties, each defaulting to string.Empty:
        +
      • Email (line 14), [Required(ErrorMessage = "Email is required")] + [EmailAddress(ErrorMessage = "Enter a valid email address")] (lines 12-13).
      • +
      • Token (line 17), [Required(ErrorMessage = "Reset token is required")] (line 16), and nothing more: length, encoding, and freshness are all server-side properties of the cache record.
      • +
      • NewPassword (line 21), [Required] + [PasswordComplexity] (lines 19-20).
      • +
      • ConfirmPassword (line 25), [Required] + [Compare(nameof(NewPassword), ErrorMessage = "Passwords do not match")] (lines 23-24), the cross-field check retargeted at NewPassword.
      • +
      +
    • +
    • Why it's built this way: the doc comment (lines 5-9) records the load-bearing choice, that Email and Token arrive prefilled from the reset link but stay editable, so a user who only has the raw token text from the email (no working deep link, which is the situation on the native heads) can paste it in by hand. Making those two ordinary bound fields rather than read-only parameters is what buys that fallback for free.
    • +
    • Where it's used: instantiated as _model by ResetPassword.razor (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/ResetPassword.razor:94) and bound by its <EditForm Model="_model" OnValidSubmit="HandleResetAsync"> + <DataAnnotationsValidator /> (lines 34-35), with the four inputs at lines 41, 50, 56, and 61. The page declares [SupplyParameterFromQuery] Email and Token properties (lines 88-92) and copies them into the model in OnParametersSet (lines 101-112), which fills a field only when it is still blank (lines 103, 108) so a value the user corrected by hand is not overwritten when parameters are set again. HandleResetAsync (lines 114-138) calls IAuthUIService.ResetPasswordAsync(_model.Email, _model.Token, _model.NewPassword) (line 121, contract at MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/IAuthUIService.cs:48), flips _isCompleted on true, and on false shows AuthService.LastError or the generic Auth.Reset.GenericError string (line 127). The prefill path is pinned by a gallery E2E test (MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/ResetPasswordPageE2ETests.cs:31), with a WCAG 2.1 AA scan alongside it (:43).
    • +
    • Caveats / not-in-source: the model has no rule tying Token to the address; that pairing is enforced by the server's cache record (pwdreset:token:{email}, ADR-091 Decision 1), not by anything visible here.

    SharedResource

    @@ -790,7 +927,7 @@

    SharedResource

  • Concept introduced, the resource-anchor type. [Rubric §27, Internationalization] (assesses whether user-facing copy is externalized to per-culture resources keyed stably, not hard-coded). ASP.NET Core's IStringLocalizer<T> convention resolves keys against the resource file whose base name matches the type T. So a dedicated empty class becomes the name that ties many components to one shared string table: injecting IStringLocalizer<SharedResource> anywhere reads the same dotted, stable keys (e.g. Common.Error.Load, Grid.Snackbar.LoadCancelled). The doc comment (SharedResource.cs:3-8) enumerates the chrome it covers: buttons, layout labels, snackbar/error templates, and the culture- and theme-switcher text. Its counterpart for library chrome is MudTranslations.
  • Walkthrough: there are no members. The whole contract is "be a public sealed type named SharedResource in this namespace, with sibling .resx files." The work lives in the .resx key/value pairs and the localization middleware that resolves them by culture.
  • Why it's built this way: a marker type is the idiomatic ASP.NET Core way to scope a shared resource table without inventing a real class; one anchor keeps the chrome strings in a single table every component shares (ADR-027 supersedes the prior single-locale stance of ADR-011).
  • -
  • Where it's used: injected as IStringLocalizer<SharedResource> by DataGridListPageBase<TDto> (DataGridListPageBase.cs:23) for its cancellation snackbar, and handed to ErrorMessages.Configure from the root layout (MMCA.Common/Source/Presentation/MMCA.Common.UI/Layout/MainLayout.razor:103) so the static helper resolves the same table; broadly consumed by the layout, the culture switcher, and the theme toggle components.
  • +
  • Where it's used: injected as IStringLocalizer<SharedResource> by DataGridListPageBase<TDto> (DataGridListPageBase.cs:23) for its cancellation snackbar, by the auth pages for their field labels and messages (ForgotPassword.razor:5, ResetPassword.razor:5), and handed to ErrorMessages.Configure from the root layout (MMCA.Common/Source/Presentation/MMCA.Common.UI/Layout/MainLayout.razor:103) so the static helper resolves the same table; broadly consumed by the layout, the culture switcher, and the theme toggle components.
  • Caveats / not-in-source: the .resx files (SharedResource.resx, SharedResource.es.resx) are resources, not .cs; their per-key contents are not enumerated here.
  • WebApplicationExtensions

    @@ -849,7 +986,7 @@

    ErrorMessages

    • What it is: a centralized factory of user-facing snackbar message strings (load/save/delete/not-found/validation/action), so every page code-behind reports an outcome with identical phrasing, resolved through a shared localizer when one is configured (ADR-027).
    • Depends on: IStringLocalizer/LocalizedString (Microsoft.Extensions.Localization, NuGet), string.Format with CultureInfo.CurrentCulture (BCL), and the first-party DomainInvariantViolationException (the one exception whose message is shown). The localizer it is handed is an IStringLocalizer<SharedResource> (per the doc comment, ErrorMessages.cs:25), so it shares the SharedResource .resx keys.
    • -
    • Concept introduced, the static-helper-with-injected-localizer bridge plus a safe-exception carve-out. [Rubric §27, Internationalization] (assesses whether user-facing copy resolves per UI culture from resources rather than hard-coded English), [Rubric §16, Maintainability] (assesses whether a wording change is localized to one place), and [Rubric §24, Forms, Validation & UX Safety] (assesses that raw error text is not leaked to the user). This type is the boundary where a static helper (callable from any page without DI) is back-filled with a culture-aware localizer: each method calls a private Localize(key, fallbackFormat, args) that returns the localized value when the localizer is set and the key resolves, else the inline English fallback, so the static call sites never change yet the output follows the current culture. The load-bearing subtlety is the exception carve-out: a DomainInvariantViolationException has its Message shown verbatim (because ServiceExceptionHelper rethrows the API's Problem Details errors as that type and their text is curated, server-localized domain wording, ADR-027 Decisions 3 and 5), while every other exception's Message is deliberately not surfaced (raw exception text is neither localizable nor safe to show, ADR-027 Decision 9). The rationale is spelled out in the LoadError doc comment (lines 42-51).
    • +
    • Concept introduced, the static-helper-with-injected-localizer bridge plus a safe-exception carve-out. [Rubric §27, Internationalization] (assesses whether user-facing copy resolves per UI culture from resources rather than hard-coded English), [Rubric §16, Maintainability] (assesses whether a wording change is localized to one place), and [Rubric §24, Forms, Validation & UX Safety] (assesses that raw error text is not leaked to the user). This type is the boundary where a static helper (callable from any page without DI) is back-filled with a culture-aware localizer: each method calls a private Localize(key, fallbackFormat, args) that returns the localized value when the localizer is set and the key resolves, else the inline English fallback, so the static call sites never change yet the output follows the current culture. The load-bearing subtlety is the exception carve-out: a DomainInvariantViolationException has its Message shown verbatim (because ServiceExceptionHelper rethrows the API's Problem Details errors as that type and their text is curated, server-localized domain wording, ADR-027 Decisions 3 and 5), while every other exception's Message is deliberately not surfaced (raw exception text is neither localizable nor safe to show, ADR-027 Decision 9). The rationale is spelled out in the LoadError doc comment (lines 42-51).
    • Walkthrough: a static class holding one mutable localizer field plus pure builders:
      • _localizer (line 19), a nullable IStringLocalizer?, null until configured.
      • Configure(IStringLocalizer localizer) (line 26), the one-time wiring point: assigns _localizer; idempotent; called from the root layout (see Where it's used).
      • @@ -889,7 +1026,7 @@

        DataGridListPageBase<TDto>

        • What it is: the abstract Blazor base for every server-paged MudDataGrid<TDto> list page. It folds the otherwise-copy-pasted concerns, cancellation lifecycle, loading and failure flags, mobile/desktop viewport detection, filter/sort extraction, error reporting, scroll restore, density toggle, URL + session + prerender state plumbing, and disposal, into one reusable component (class DataGridListPageBase<TDto> : ComponentBase, IBrowserViewportObserver, IAsyncDisposable, IDisposable, line 20).
        • Depends on: ErrorMessages (Level 2), SharedResource (Level 0, injected as IStringLocalizer<SharedResource>), ListPageState (Level 0), PersistedGridState (Level 0, nested), ListPageQueryStateService (Level 1), ListPageStateService (Level 1), BreakpointConstants (Level 0); MudBlazor's MudDataGrid<T>, GridState<T>, GridData<T>, IBrowserViewportObserver/IBrowserViewportService (NuGet); Blazor's PersistentComponentState, NavigationManager, IJSRuntime (framework).
        • -
        • Concept introduced, a behavior-rich Blazor base component. [Rubric §18, UI Architecture & Component Design] (assesses reuse; every list page inherits this behavior with zero copy-paste) and [Rubric §23, Front-End Performance & Rendering] (assesses server-side paging, only the requested page is fetched, never the whole table, plus the prerender cache that skips a redundant fetch). It also embodies several hard-won quality notes, each documented inline: the MudDataGrid v9 RowsPerPage bug (the v9 parameter setter always uses resetPage: true and clobbers CurrentPage, comment at lines 407-410), the disposed-CTS race (a debounced reload firing after disposal threw ObjectDisposedException and stuck the blazor-error-ui banner, lines 578-582), and the stale-write race (a late grid-state save landing after navigation stamped grid params onto the next page's URL and disposed it, lines 161-165), all worked around here, touching [Rubric §22, Responsive & Cross-Browser] and [Rubric §28, Front-End Testing] (these were E2E-discovered regressions). Its cancellation snackbar reads a localized string from SharedResource, the [Rubric §27, Internationalization] angle, and the LoadFailed flag is a [Rubric §24, Forms, Validation & UX Safety] detail: a failed fetch renders zero rows, which looks exactly like an empty list once the error snackbar expires, so derived pages branch on the flag to show an inline error-with-retry instead of the "no records" empty state (lines 33-40).
        • +
        • Concept introduced, a behavior-rich Blazor base component. [Rubric §18, UI Architecture & Component Design] (assesses reuse; every list page inherits this behavior with zero copy-paste) and [Rubric §23, Front-End Performance & Rendering] (assesses server-side paging, only the requested page is fetched, never the whole table, plus the prerender cache that skips a redundant fetch). It also embodies several hard-won quality notes, each documented inline: the MudDataGrid v9 RowsPerPage bug (the v9 parameter setter always uses resetPage: true and clobbers CurrentPage, comment at lines 407-410), the disposed-CTS race (a debounced reload firing after disposal threw ObjectDisposedException and stuck the blazor-error-ui banner, lines 578-582), and the stale-write race (a late grid-state save landing after navigation stamped grid params onto the next page's URL and disposed it, lines 161-165), all worked around here, touching [Rubric §22, Responsive & Cross-Browser] and [Rubric §28, Front-End Testing] (these were E2E-discovered regressions). Its cancellation snackbar reads a localized string from SharedResource, the [Rubric §27, Internationalization] angle, and the LoadFailed flag is a [Rubric §24, Forms, Validation & UX Safety] detail: a failed fetch renders zero rows, which looks exactly like an empty list once the error snackbar expires, so derived pages branch on the flag to show an inline error-with-retry instead of the "no records" empty state (documented at lines 32-40).
        • Walkthrough: in teaching order:
          • Injected services and abstract surface (lines 22-29): ISnackbar (line 22), IStringLocalizer<SharedResource> (line 23, the localized cancel message), IBrowserViewportService (line 24), the two state services (lines 25-26), NavigationManager (line 27), IJSRuntime (line 28), PersistentComponentState (line 29). Derived pages supply the abstract Title (line 41) and may override GridRef (line 121), SaveFilters/RestoreFilters (lines 108, 111), and OnMobileDataRequestedAsync (line 720).
          • Public/protected state (lines 31-76): IsLoading (line 31), LoadFailed (line 40), IsMobile (line 44), the mobile card-view block MobileItems/MobileTotalItems/MobileCurrentPage/MobilePageSize (lines 47-50), the bindable CurrentPageState (line 57, 0-indexed), RowsPerPageState (line 67, defaulting to 10 to match MudDataGrid v9's own default), and DenseGrid (line 76). PrerenderFetchTimeoutMs = 5000 (line 82) bounds the SSR fetch.
          • @@ -932,7 +1069,7 @@

            MoneyExtensions

        • Why it's built this way: presentational formatting belongs above the domain, so Money stays display-agnostic and the same value can be rendered differently by a different head. InvariantCulture is a deliberate choice over CurrentCulture: prices are shown with an explicit ISO code (USD), so a locale-dependent decimal separator would produce $12,50 USD and read as an error. The empty-symbol fallback and the per-currency grouping are both "render the truth" decisions: never imply a currency the data does not carry.
        • -
        • Where it's used: Store's Sales and Catalog UIs. ToDisplayString() renders order totals and line amounts (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.UI/Pages/Order/OrderLinesPanel.razor:34, :39, :51; Pages/Order/OrderSummaryPanel.razor:54; Pages/Order/OrderList.razor:36, :102) and the cart's order-created snackbar (Pages/ShoppingCart/ShoppingCartDetail.razor.cs:265); ToDisplayRange() renders the price span across a product's variants in catalog browse (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.UI/Pages/Catalog/CatalogBrowse.razor.cs:303).
        • +
        • Where it's used: Store's Sales and Catalog UIs. ToDisplayString() renders order totals and line amounts (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.UI/Pages/Order/OrderLinesPanel.razor:34, :39, :51; Pages/Order/OrderSummaryPanel.razor:54; Pages/Order/OrderList.razor:36, :102) and the cart's order-created snackbar (Pages/ShoppingCart/ShoppingCartDetail.razor.cs:265); ToDisplayRange() renders the price span across a product's variants in catalog browse (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.UI/Pages/Catalog/CatalogBrowse.razor.cs:303, with the single-price helper alongside it at :306).
        • Caveats / not-in-source: only USD and EUR have symbols; adding a currency means editing Symbol, there is no configuration-driven table. The "N2" format assumes a two-minor-unit currency, so a zero-decimal currency (JPY) would render two spurious decimals; no code guards that today.

        CultureDelegatingHandler

        @@ -1146,471 +1283,276 @@

        ApiUserPreferenceReader

      • Where it's used: Registered TryAddScoped (DependencyInjection.cs:101); injected into the login page (Login.razor:13) and read once per login in ApplyStoredPreferencesAndNavigateAsync (Login.razor:198).
      • Caveats / not-in-source: Unlike the writer it keeps no rejected-token memory, which is a reasonable asymmetry given it runs once per login rather than once per toggle, but it is a difference between the two classes rather than a shared pattern.
      -

      IOAuthUISettings

      -
      -

      MMCA.Common.UI · MMCA.Common.UI.Services.Auth · MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/IOAuthUISettings.cs:9 · Level 0 · interface

      -
      -
        -
      • What it is: the UI-layer contract that declares which external OAuth providers are available so - the shared login page can conditionally render social-login buttons.
      • -
      • Depends on: nothing first-party.
      • -
      • Concept introduced, safe-by-default via default interface members. [Rubric §18, UI Architecture] (assesses how presentation configuration is surfaced without leaking backend - concerns) and [Rubric §26, Front-End Security] (assesses that optional auth surfaces are opt-in). - Both members are default interface members: bool GoogleEnabled => false - (IOAuthUISettings.cs:12) and bool GitHubEnabled => false (IOAuthUISettings.cs:15). An app that - registers no implementation, or the no-op DefaultOAuthUISettings, gets - "no social login": the buttons stay hidden. Turning a provider on is additive, an implementation - returns true for the property it enables, with no change to the shared login component.
      • -
      • Walkthrough: two boolean getter members, both defaulting to false. The login Razor component - reads IOAuthUISettings from DI to decide whether to render each provider's button.
      • -
      • Why it's built this way: default interface members remove the need for a separate no-op class - while still shipping a usable, secure default (social login off until deliberately enabled).
      • -
      • Where it's used: implemented by the no-op DefaultOAuthUISettings - and the config-driven ConfigurationOAuthUISettings; consumed by - the login page.
      • -
      -

      ISessionCookieSync

      -
      -

      MMCA.Common.UI · MMCA.Common.UI.Services.Auth · MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/ISessionCookieSync.cs:8 · Level 0 · interface

      -
      -
        -
      • What it is: the contract for keeping the browser's HttpOnly auth cookie in step with the - client's in-memory tokens, so a server-side prerender can recognize an already-authenticated user.
      • -
      • Depends on: nothing first-party.
      • -
      • Concept introduced, the prerender/interactive cookie boundary. [Rubric §18, UI Architecture] - (assesses how the SSR prerender pass and the interactive circuit share auth state) and [Rubric §26, Front-End Security] (assesses that the refresh secret stays in an HttpOnly cookie, not JS). The doc - comment (ISessionCookieSync.cs:3-7) states the exact failure this prevents: the interactive - circuit's in-memory access token is unreachable from the server, so without a synced cookie a - right-click "Open in new tab" on an [Authorize] page (which prerenders on the server) redirects to - /login. This is the client half of the dual-fetch auth model (ADR-004).
      • -
      • Walkthrough: two methods, SyncAsync(string accessToken, string refreshToken) - (ISessionCookieSync.cs:10), called after login and each refresh to write the cookie, and - ClearAsync() (ISessionCookieSync.cs:12), called on logout to delete it.
      • -
      • Why it's built this way: keeping this an interface lets each host supply the right mechanism, a - browser fetch on the web heads (JsFetchSessionCookieSync) and a no-op - on MAUI (no SSR, no cookie).
      • -
      • Where it's used: implemented by JsFetchSessionCookieSync; driven - by WasmTokenStorageService at login and logout.
      • -
      -

      ITokenRefresher

      -
      -

      MMCA.Common.UI · MMCA.Common.UI.Services.Auth · MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/ITokenRefresher.cs:13 · Level 0 · interface

      -
      -
        -
      • What it is: the contract that acquires a fresh JWT access token, abstracting over where the - refresh credential lives per host.
      • -
      • Depends on: nothing first-party.
      • -
      • Concept introduced, host-agnostic token refresh. [Rubric §11, Security] and [Rubric §26, Front-End Security] (both assess that the refresh token, the high-value secret, is handled by the - safest mechanism per platform). The doc comment (ITokenRefresher.cs:3-12) names the two concrete - paths this single method hides: on the browser hosts (Server + WASM), - SameOriginProxyTokenRefresher calls the same-origin - /auth/session/token endpoint where the refresh token sits in an HttpOnly cookie and rotates - server-side (never exposed to JS); on MAUI, DirectApiTokenRefresher - exchanges the refresh token held in OS SecureStorage directly against auth/refresh.
      • -
      • Walkthrough: one method, Task<string?> AcquireAccessTokenAsync(CancellationToken = default) - (ITokenRefresher.cs:20). It returns a fresh access token, or null when no valid session exists - (missing, expired, or revoked credential), a clean null convention so callers redirect to login - rather than catch exceptions.
      • -
      • Why it's built this way: a one-method contract with a null-means-reauthenticate convention lets - the storage layer stay identical across hosts while the refresh-token persistence differs at the - edges (ADR-004).
      • -
      • Where it's used: implemented by SameOriginProxyTokenRefresher - and DirectApiTokenRefresher; consumed by - WasmTokenStorageService and AuthUIService.
      • -
      -

      ITokenStorageService

      -
      -

      MMCA.Common.UI · MMCA.Common.UI.Services.Auth · MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/ITokenStorageService.cs:8 · Level 0 · interface

      -
      -
        -
      • What it is: the platform-agnostic contract for persisting the JWT access/refresh pair, letting - each host use the safe storage mechanism for its platform.
      • -
      • Depends on: nothing first-party.
      • -
      • Concept introduced, platform-abstracted token persistence. [Rubric §26, Front-End Security] - and [Rubric §11, Security] (assess that tokens are held in the safest store per platform). The doc - comment (ITokenStorageService.cs:3-7) fixes the policy the implementations honor: browser hosts - keep the access token in memory and mirror the refresh token to an HttpOnly cookie, never - localStorage; MAUI uses OS SecureStorage. Managing both tokens through one abstraction means no - page component ever touches a raw storage API.
      • -
      • Walkthrough: four methods, GetAccessTokenAsync() (ITokenStorageService.cs:11) and - GetRefreshTokenAsync() (ITokenStorageService.cs:14) each returning Task<string?> (async - because SecureStorage is async on MAUI); SetTokensAsync(accessToken, refreshToken) - (ITokenStorageService.cs:17), an atomic write of both after login or refresh; and - ClearTokensAsync() (ITokenStorageService.cs:20) on logout.
      • -
      • Why it's built this way: an interface (not a base class) keeps the platform-specific - implementation in its own host with no shared code dependency; writing both tokens together avoids - partial-update bugs (a fresh access token paired with a stale refresh token).
      • -
      • Where it's used: implemented by WasmTokenStorageService (and a - Blazor Server sibling ServerTokenStorageService noted in the WASM doc comment); read by - AuthDelegatingHandler, - JwtAuthenticationStateProvider, and - AuthUIService.
      • -
      -

      JwtTokenInfo

      -
      -

      MMCA.Common.UI · MMCA.Common.UI.Services.Auth · MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/JwtTokenInfo.cs:9 · Level 0 · class (static)

      -
      -
        -
      • What it is: a static helper that inspects a JWT client-side (expiry only, no signature check) so - token storage can decide when to re-acquire an access token.
      • -
      • Depends on: BCL only (System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler).
      • -
      • Concept introduced, deliberate signature-free client inspection. [Rubric §26, Front-End Security] (assesses that trust decisions stay server-side) and [Rubric §12, Performance & Scalability] (assesses avoiding a doomed round-trip). The doc comment (JwtTokenInfo.cs:5-7) is - explicit: there is no signature validation here, the API validates every request. The only job - is to read expiry locally and refresh proactively, avoiding an API call that would come back 401.
      • -
      • Walkthrough: IsFresh(string? token, TimeSpan skew) (JwtTokenInfo.cs:16): returns false - immediately for null/blank (JwtTokenInfo.cs:18-21); returns false if CanReadToken says the - string is not a readable JWT (JwtTokenInfo.cs:24-27); otherwise returns whether - ReadJwtToken(token).ValidTo > DateTime.UtcNow + skew (JwtTokenInfo.cs:31), so a token within - skew of expiry is already treated as stale. A narrow catch of ArgumentException/FormatException - (JwtTokenInfo.cs:33-36) yields false on a malformed token rather than throwing.
      • -
      • Why it's built this way: a pure static method with no dependencies is trivially unit-testable by - passing token strings and needs no DI. The skew argument makes proactive refresh a caller policy, - not a hard-coded constant.
      • -
      • Where it's used: WasmTokenStorageService.GetAccessTokenAsync gates - its in-memory access token on JwtTokenInfo.IsFresh(_accessToken, ExpirySkew) before returning it - (WasmTokenStorageService.cs:22).
      • -
      -

      AuthDelegatingHandler

      -
      -

      MMCA.Common.UI · MMCA.Common.UI.Services.Auth · MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthDelegatingHandler.cs:9 · Level 1 · class (sealed)

      -
      -
        -
      • What it is: an HttpClient message handler that attaches the stored JWT Bearer token to every - outgoing API request.
      • -
      • Depends on: ITokenStorageService (Level 0); BCL - (System.Net.Http.Headers).
      • -
      • Concept introduced, the delegating-handler auth interceptor. [Rubric §11, Security] and - [Rubric §18, UI Architecture] (assess where the outbound auth header is centralized). A - DelegatingHandler is the HttpClient analogue of ASP.NET middleware: it wraps a request before it - goes on the wire. This one reads the access token from - ITokenStorageService and sets Authorization: Bearer {token}, so no call - site has to remember to authenticate.
      • -
      • Walkthrough: SendAsync (AuthDelegatingHandler.cs:13): awaits GetAccessTokenAsync - (AuthDelegatingHandler.cs:17); if the token is non-blank, sets - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token) - (AuthDelegatingHandler.cs:18-21); then delegates to base.SendAsync - (AuthDelegatingHandler.cs:23). With no token the request goes unauthenticated (the API answers 401 - where auth is required).
      • -
      • Why it's built this way: centralizing the header on the handler keeps every service call - uniformly authenticated without per-call code. The class is sealed and constructor-injects its - one dependency.
      • -
      • Where it's used: registered in the "APIClient" named-client pipeline via - AddHttpMessageHandler (per its doc comment, AuthDelegatingHandler.cs:5-7). Note that - AuthUIService sets the header manually on some calls because of a Blazor Server - DI scope issue (AuthUIService.cs:263).
      • -
      -

      ConfigurationOAuthUISettings

      -
      -

      MMCA.Common.UI · MMCA.Common.UI.Services.Auth · MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/ConfigurationOAuthUISettings.cs:13 · Level 1 · class (sealed)

      -
      -
        -
      • What it is: an IOAuthUISettings implementation that reads provider - availability from the OAuth configuration section, covering both host shapes (server and WASM) - with one class.
      • -
      • Depends on: IOAuthUISettings (Level 0); NuGet - (Microsoft.Extensions.Configuration.IConfiguration).
      • -
      • Concept introduced, config-driven provider gating that never ships the client id to the browser. - [Rubric §18, UI Architecture] (assesses configuration-driven UI without backend leakage) and - [Rubric §26, Front-End Security] (assesses that a secret-bearing key stays server-side). The doc - comment (ConfigurationOAuthUISettings.cs:5-12) explains the dual shape: a server host declares a - provider enabled when its OAuth:{Provider}:ClientId is configured; a WASM client instead receives - a pre-computed OAuth:{Provider}Enabled flag through its runtime config (/client-config), which - never carries the client id itself.
      • -
      • Walkthrough: the constructor (ConfigurationOAuthUISettings.cs:21) null-guards configuration, - reads the OAuth section, and computes GoogleEnabled/GitHubEnabled once - (ConfigurationOAuthUISettings.cs:25-27) into get-only properties - (ConfigurationOAuthUISettings.cs:16,19). IsProviderEnabled - (ConfigurationOAuthUISettings.cs:30) returns true when either the {Provider}Enabled flag parses - to true or a non-empty {Provider}:ClientId is present - (ConfigurationOAuthUISettings.cs:32-33), so the flag path (WASM) and the client-id path (server) - both light up the button.
      • -
      • Why it's built this way: folding both host shapes into one predicate avoids two near-identical - settings classes and keeps the "browser never sees the client id" rule in one place; computing the - flags in the constructor makes the instance immutable and cheap to read.
      • -
      • Where it's used: registered as a singleton (per its doc comment, - ConfigurationOAuthUISettings.cs:7) to replace the no-op - DefaultOAuthUISettings; consumed by the login page.
      • -
      -

      DefaultOAuthUISettings

      -
      -

      MMCA.Common.UI · MMCA.Common.UI.Services.Auth · MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/DefaultOAuthUISettings.cs:7 · Level 1 · class (internal sealed)

      -
      -
        -
      • What it is: the no-op IOAuthUISettings implementation that disables all - OAuth providers, a single-line type: internal sealed class DefaultOAuthUISettings : IOAuthUISettings; (DefaultOAuthUISettings.cs:7).
      • -
      • Depends on: IOAuthUISettings (Level 0).
      • -
      • Concept, the Null Object / default-registration pattern. [Rubric §2, Design Patterns] - (assesses using a benign default rather than a nullable dependency). Because - IOAuthUISettings supplies default members returning false, this class needs - no body: it inherits "all providers off". Registering it guarantees the interface is always - resolvable, so the login page can inject it unconditionally; a downstream app overrides the - registration with ConfigurationOAuthUISettings to enable - providers.
      • -
      • Walkthrough: no members. All behavior comes from the interface's default members.
      • -
      • Where it's used: the framework's fallback registration; superseded by - ConfigurationOAuthUISettings when an app configures OAuth.
      • -
      -

      DirectApiTokenRefresher

      -
      -

      MMCA.Common.UI · MMCA.Common.UI.Services.Auth · MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/DirectApiTokenRefresher.cs:11 · Level 1 · class (sealed)

      -
      -
        -
      • What it is: the MAUI ITokenRefresher: it exchanges the refresh token held - in OS SecureStorage directly against the API's auth/refresh endpoint and persists the rotated pair - back to storage.
      • -
      • Depends on: ITokenRefresher (Level 0), - ITokenStorageService (Level 0), - RefreshTokenRequest, - AuthenticationResponse; BCL/NuGet - (IHttpClientFactory, System.Net.Http.Json).
      • -
      • Concept, per-host refresh strategy. [Rubric §11, Security] (assesses matching the refresh - mechanism to the platform's threat surface). The doc comment (DirectApiTokenRefresher.cs:6-9) - justifies handling the refresh token directly: MAUI has no browser DOM and therefore no XSS surface, - so exchanging a SecureStorage-held token straight against the cross-origin API is acceptable. The - browser hosts use SameOriginProxyTokenRefresher instead.
      • -
      • Walkthrough: AcquireAccessTokenAsync (DirectApiTokenRefresher.cs:17): reads both tokens - (DirectApiTokenRefresher.cs:19-20); returns null if either is missing - (DirectApiTokenRefresher.cs:22-25); POSTs a - RefreshTokenRequest to the relative auth/refresh - (DirectApiTokenRefresher.cs:27-29); on a non-success status returns null - (DirectApiTokenRefresher.cs:31-34); otherwise reads - AuthenticationResponse, returns null on a blank access - token, then persists the rotated pair via SetTokensAsync and returns the new access token - (DirectApiTokenRefresher.cs:36-43).
      • -
      • Why it's built this way: constructor-injecting the storage service and HTTP factory keeps the - refresher stateless; the null-on-failure convention matches ITokenRefresher so - a caller treats null as "re-login".
      • -
      • Where it's used: registered as the ITokenRefresher on the MAUI host; - reached through AuthUIService.TryRefreshTokenAsync.
      • -
      -

      JsFetchSessionCookieSync

      -
      -

      MMCA.Common.UI · MMCA.Common.UI.Services.Auth · MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/JsFetchSessionCookieSync.cs:11 · Level 1 · class (sealed)

      -
      -
        -
      • What it is: the ISessionCookieSync implementation that syncs the - HttpOnly auth cookie by firing a browser fetch through JS interop.
      • -
      • Depends on: ISessionCookieSync (Level 0); NuGet - (Microsoft.JSInterop.IJSRuntime).
      • -
      • Concept, browser-issued cookie writes. [Rubric §26, Front-End Security] and [Rubric §18, UI Architecture] (assess crossing the Server/WASM prerender boundary safely). The doc comment - (JsFetchSessionCookieSync.cs:5-9) explains why the fetch is issued from the browser and not the - server: only then does the resulting Set-Cookie land in the user's cookie jar, and it works in - both Blazor Server interactive mode and WebAssembly. When JS interop is unavailable (SSR prerender, - a render-mode transition), the calls fall silent rather than throw.
      • -
      • Walkthrough: SyncAsync (JsFetchSessionCookieSync.cs:16) invokes mmcaAuthCookie.set with - both tokens; ClearAsync (JsFetchSessionCookieSync.cs:28) invokes mmcaAuthCookie.clear. Both - wrap the interop call and swallow the interop-unavailable exception family via the shared - IsInteropUnavailable predicate (JsFetchSessionCookieSync.cs:13-14), which matches - InvalidOperationException, JSDisconnectedException, JSException, and - OperationCanceledException. The catch comments note the cookie will be re-synced on the next write.
      • -
      • Why it's built this way: keeping the JS mechanics behind the interface lets MAUI drop in a - no-op; swallowing interop failures during prerender keeps a login flow from crashing when the circuit - is not yet interactive.
      • -
      • Where it's used: registered on the web heads as ISessionCookieSync; - driven by WasmTokenStorageService at login and logout.
      • -
      -

      JwtAuthenticationStateProvider

      -
      -

      MMCA.Common.UI · MMCA.Common.UI.Services.Auth · MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/JwtAuthenticationStateProvider.cs:12 · Level 1 · class (sealed)

      -
      -
        -
      • What it is: a custom Blazor AuthenticationStateProvider that derives auth state from the JWT - held by ITokenStorageService, reading claims client-side for - responsiveness while the API validates fully on every request.
      • -
      • Depends on: ITokenStorageService (Level 0); NuGet/BCL - (Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider, System.Security.Claims, - JwtSecurityTokenHandler).
      • -
      • Concept introduced, client-side auth-state projection. [Rubric §18, UI Architecture] (assesses - how Blazor's AuthorizeView/CascadingAuthenticationState learns who is signed in), [Rubric §11, Security] (assesses that client-side claims drive only rendering, not trust), and [Rubric §19, State Management] (assesses pushing state changes without a page reload). The doc comment - (JwtAuthenticationStateProvider.cs:7-11) states the split: claims are extracted client-side without - server validation to keep the UI responsive; the WebAPI does the real validation.
      • +

        ServiceExceptionHelper

        +
        +

        MMCA.Common.UI · MMCA.Common.UI.Services · MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ServiceExceptionHelper.cs:11 · Level 2 · class (static)

        +
        +
          +
        • What it is: the client-side half of the API error contract. It inspects a non-success HTTP + response body for the Problem Details payloads the WebAPI emits and re-throws them as a + DomainInvariantViolationException + carrying the server's own message, so a page can show "Session already has that speaker" instead of + "Response status code does not indicate success".
        • +
        • Depends on: + DomainInvariantViolationException + (ServiceExceptionHelper.cs:2); System.Text.Json (BCL) for the parse. Nothing else: it is a static + class with no state and no DI surface, which is why every UI service base can call it for free.
        • +
        • Concept introduced, reading the Problem Details contract from the client side. + [Rubric §9, API & Contract Design] assesses whether errors travel as a structured, versionable + payload rather than a status code plus prose. The server side of that contract has three producers + and this helper branches on the title each one writes: "Domain Exception" from + DomainExceptionHandler + (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/DomainExceptionHandler.cs:40), + "Validation Exception" from + ValidationExceptionHandler + (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/ValidationExceptionHandler.cs:41), and + "Operation failed" from + ApiControllerBase.HandleFailure when an + Error list comes back from a handler + (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/ApiControllerBase.cs:43). The title + string is the discriminator, matched with StringComparison.Ordinal + (ServiceExceptionHelper.cs:49,52,55), so the three producers and this consumer are coupled by an + exact literal on both ends. [Rubric §10, Cross-Cutting Concerns] also applies: error translation + lives in one place instead of in each page's catch.
        • Walkthrough
            -
          • A shared AnonymousState (JwtAuthenticationStateProvider.cs:14-15) is an empty - ClaimsPrincipal, the fallback for every unauthenticated path.
          • -
          • GetAuthenticationStateAsync (JwtAuthenticationStateProvider.cs:22): reads the token; returns - anonymous on blank (:27-30), on an unreadable token (CanReadToken, :33-36), or on an expired - token (ValidTo < DateTime.UtcNow, :39-42). Otherwise it builds a ClaimsIdentity with the - "jwt" authentication type (:45), which is what makes IsAuthenticated == true, and returns the - principal. A bare catch (:49-52) falls back to anonymous on any failure (corrupt data, interop - unavailable).
          • -
          • NotifyUserAuthentication(string token) (:59) builds a principal from the token and calls - NotifyAuthenticationStateChanged so CascadingAuthenticationState consumers update immediately - after login/refresh, with no page reload; NotifyUserLogout() (:71) pushes AnonymousState.
          • +
          • ThrowIfDomainExceptionAsync(HttpResponseMessage, CancellationToken) (ServiceExceptionHelper.cs:17) + is the only public member. It null-guards the response (line 19), returns immediately when there is + no content or the body is blank (lines 21-26), and reads the body as a string (line 24).
          • +
          • The parse is defensive: JsonDocument.Parse is wrapped in a try that swallows JsonException + and returns (lines 29-38). The comment names the cases that reach it, a bare 401 challenge or an + HTML error page, and states the contract with the caller: a non-JSON failure falls through to the + caller's own EnsureSuccessStatusCode(). Nothing is thrown here that the caller was not already + going to throw.
          • +
          • using (document) (line 40) disposes the parsed document on every exit path, including the throw + paths below, because the exception is constructed from strings already extracted.
          • +
          • No title property means "not one of ours": return and let the caller decide (lines 44-45).
          • +
          • "Domain Exception" takes the simple path, ExtractDetailMessage(root, "A domain error occurred.") + (line 50), which reads detail or falls back (lines 60-63).
          • +
          • "Validation Exception" goes through ExtractValidationMessage (lines 65-83). The server writes + errors as an object keyed by property name whose values are arrays of messages, so the helper + walks EnumerateObject() then EnumerateArray() (lines 72-76) and joins every message with a + single space (line 79). The joined string replaces the detail fallback only when at least one + message was found (line 78).
          • +
          • "Operation failed" goes through ExtractOperationFailedMessage (lines 85-98), which expects a + different shape: errors is a JSON array of error objects, not an object of arrays (line 89). + CollectErrorMessages (lines 100-114) pulls the message property off each element, skipping + blanks. That shape is what ErrorHttpMapping.BuildErrorsExtension projects from an Error list + (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/ErrorHttpMapping.cs:47-55), which is + why the two errors branches cannot share code.
        • -
        • Why it's built this way: deriving state from the stored token (rather than a server round-trip) - keeps the UI instant, and the explicit notify methods let AuthUIService drive - state transitions on login, refresh, and logout. The "jwt" auth-type string is load-bearing: an - identity built with no auth type reports IsAuthenticated == false.
        • -
        • Where it's used: registered as the Blazor AuthenticationStateProvider; - AuthUIService pattern-matches it to call - NotifyUserAuthentication/NotifyUserLogout.
        • -
        -

        SameOriginProxyTokenRefresher

        -
        -

        MMCA.Common.UI · MMCA.Common.UI.Services.Auth · MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/SameOriginProxyTokenRefresher.cs:11 · Level 1 · class (sealed)

        -
        -
          -
        • What it is: the browser (Blazor Server + WebAssembly) ITokenRefresher: it - calls the same-origin POST /auth/session/token endpoint via JS fetch so the browser sends its - HttpOnly cookies and the UI host refreshes server-side, returning only the access token.
        • -
        • Depends on: ITokenRefresher (Level 0); NuGet - (Microsoft.JSInterop.IJSRuntime).
        • -
        • Concept, refresh-token isolation from JS. [Rubric §11, Security] and [Rubric §26, Front-End Security] (assess that the refresh token never enters JS-reachable memory). The doc comment - (SameOriginProxyTokenRefresher.cs:5-10) explains the mechanism: the JS fetch uses - credentials:'same-origin', which sends the HttpOnly auth cookie to the same-origin UI host; the - host validates-or-refreshes server-side and hands back only the access token. This is the browser - half of the dual-fetch model (ADR-004).
        • -
        • Walkthrough: AcquireAccessTokenAsync (SameOriginProxyTokenRefresher.cs:13) invokes - mmcaAuthSession.getToken (:17), returning null for a blank result (:18). It catches the JS - interop exception family (InvalidOperationException, JSDisconnectedException, JSException, - OperationCanceledException, :20-25) and returns null, the comment noting the server-side cookie - path covers SSR-prerender and disconnected-circuit phases.
        • -
        • Why it's built this way: routing the refresh through a same-origin JS fetch keeps the - high-value refresh token in the HttpOnly cookie and out of JS memory, exactly the isolation §26 - rewards.
        • -
        • Where it's used: registered as the ITokenRefresher on the web server and - WASM hosts; consumed by WasmTokenStorageService and - AuthUIService.
        • -
        -

        WasmTokenStorageService

        -
        -

        MMCA.Common.UI · MMCA.Common.UI.Services.Auth · MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/WasmTokenStorageService.cs:11 · Level 1 · class (sealed)

        -
        -
          -
        • What it is: the WebAssembly ITokenStorageService: it holds the access - token in memory only (never localStorage) and hydrates or refreshes it on demand from the - HttpOnly cookies through an ITokenRefresher.
        • -
        • Depends on: ITokenStorageService (Level 0), - ISessionCookieSync (Level 0), ITokenRefresher - (Level 0), JwtTokenInfo (Level 0).
        • -
        • Concept introduced, in-memory-plus-cookie token custody with single-flight refresh. [Rubric §26, Front-End Security] (assesses keeping the access token out of persistent, JS-readable storage), - [Rubric §11, Security] (assesses that the refresh token is never client-readable), [Rubric §12, Performance & Scalability], and [Rubric §19, State Management] (assess deduplicating concurrent - token acquisition). The doc comment (WasmTokenStorageService.cs:3-10) states the model: cookie-only, - the access token lives in memory and is rehydrated from the HttpOnly cookies via the same-origin - /auth/session/token endpoint; the refresh token is never readable by JS; and the class is hoisted - from the app WASM clients because it carries no app-specific state (its Blazor Server sibling is - ServerTokenStorageService).
        • +
        • Why it's built this way: the alternative is a shared error DTO deserialized with a strongly typed + model, but the three payloads differ in the shape of errors and the helper must stay tolerant of + bodies that are not Problem Details at all (proxies, gateways, auth challenges). Reading the document + loosely and returning quietly on anything unrecognized means the helper can be called + unconditionally before EnsureSuccessStatusCode() without ever changing behavior for responses it + does not understand. Collapsing all three onto one exception type is deliberate too: pages catch one + thing and display ex.Message.
        • +
        • Where it's used: called on every non-success response by both service bases in this group, + EntityServiceBase<TEntityDTO, TIdentifierType> + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/EntityServiceBase.cs:211) and + ChildEntityServiceBase + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ChildEntityServiceBase.cs:31,52), plus the + hand-written services that use neither base: + NotificationInboxService in this package, and the module UI services in + ADC (Engagement check-in, points, feedback, bookmarks, live polls) and Store (cart state). Its + behavior is pinned by + ServiceExceptionHelperTests.
        • +
        • Caveats: the whole body is buffered into a string before parsing (line 24), so a very large error + payload is fully materialized; error bodies are small in practice, but there is no size guard in + source. The title match is exact and case-sensitive, so a producer that renames a title silently + degrades every client to the generic HttpRequestException path.
        • +
        +

        ChildEntityServiceBase

        +
        +

        MMCA.Common.UI · MMCA.Common.UI.Services · MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/ChildEntityServiceBase.cs:17 · Level 3 · class (abstract)

        +
        +
          +
        • What it is: the two-verb service base for join entities, the many-to-many rows a UI can create and + delete but never lists or edits on their own. It offers exactly PostAsync and DeleteByIdAsync over + the named "APIClient", and nothing else.
        • +
        • Depends on: AuthenticatedServiceBase (base class, supplying the + authenticated client factory and the retry policy), ITokenStorageService + (constructor parameter, passed straight through), ServiceExceptionHelper; + IHttpClientFactory and System.Net.Http.Json (BCL).
        • +
        • Concept introduced, a base class shaped by the resource rather than by convention. + [Rubric §18, UI Architecture & Component Design] assesses whether the presentation layer talks to the + backend through typed services rather than raw HttpClient calls in components. The interesting design + choice here is what is absent: a join row like SessionSpeaker has no list page, no edit form and + no lookup, so this base deliberately does not implement + IEntityService<TEntityDTO, TIdentifierType>. Giving join + services the full CRUD surface would hand pages six operations of which four have no endpoint behind + them. [Rubric §1, SOLID] reads this as interface segregation applied at the service-base level: the + smaller base cannot promise what the API does not serve.
        • Walkthrough
            -
          • Fields: a static ExpirySkew of 30 seconds (WasmTokenStorageService.cs:15), the in-memory - _accessToken (:17), and an _hydrateInFlight task handle (:18) that backs the single-flight - guard.
          • -
          • GetAccessTokenAsync (:20): returns the cached token immediately if - JwtTokenInfo.IsFresh(_accessToken, ExpirySkew) (:22-25); otherwise it starts (or joins) one - HydrateAsync via _hydrateInFlight ??= HydrateAsync() so concurrent callers (the delegating - handler, auth-state provider, SignalR) share a single acquisition (:27-36), clearing the handle - in a finally.
          • -
          • GetRefreshTokenAsync (:40): always returns null, the refresh token lives only in the HttpOnly - cookie.
          • -
          • SetTokensAsync (:42): stores the access token in memory and seeds the HttpOnly cookies via - ISessionCookieSync.SyncAsync; the comment notes the refresh token transits - JS only for that one same-origin POST and is never persisted (:44-47).
          • -
          • ClearTokensAsync (:50): nulls the in-memory token and clears the cookies.
          • -
          • HydrateAsync (:56): calls ITokenRefresher.AcquireAccessTokenAsync, caches - the result in _accessToken, and returns it.
          • +
          • The primary constructor takes IHttpClientFactory, ITokenStorageService and a string endpoint, + forwarding the first two to AuthenticatedServiceBase (ChildEntityServiceBase.cs:17-20). The + endpoint is captured as a primary-constructor parameter rather than exposed as a property, so + subclasses cannot rewrite it after construction; contrast + EntityServiceBase<TEntityDTO, TIdentifierType>, + which surfaces protected string Endpoint { get; } because its own methods build sub-paths from it.
          • +
          • PostAsync<TRequest>(TRequest request, CancellationToken) (line 24) creates an authenticated client + with using var (line 26), POSTs the payload as JSON to the relative endpoint URI (line 27), calls + ServiceExceptionHelper.ThrowIfDomainExceptionAsync on a non-success + status (lines 29-32), then EnsureSuccessStatusCode() (line 34) and returns the raw + HttpResponseMessage. Returning the response rather than a DTO is what lets each subclass decide how + to read the body. TRequest is generic precisely because join payloads are usually anonymous objects + (the doc comment says so at line 23).
          • +
          • DeleteByIdAsync(string id, CancellationToken) (line 39) builds "{endpoint}/{id}" (line 42) and + treats 404 NotFound as false rather than an exception (lines 45-48), so "already gone" is a + result, not a failure. Other non-success statuses go through the same domain-error extraction and + EnsureSuccessStatusCode() (lines 50-55) before returning true.
          • +
          • The id parameter is a string, not a generic identifier type: subclasses format their own typed id + before calling, for example id.ToString(CultureInfo.InvariantCulture) in + EventSpeakerService.DeleteAsync + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/ChildEntityServices.cs:24).
        • -
        • Why it's built this way: holding the access token in process memory (not localStorage) shrinks - the XSS blast radius, and the single-flight _hydrateInFlight guard prevents a thundering herd of - parallel refreshes when several components ask for a token at once (ADR-004).
        • -
        • Where it's used: registered as the ITokenStorageService on the WASM - host; read by AuthDelegatingHandler, - JwtAuthenticationStateProvider, and - AuthUIService.
        • -
        • Caveats / not-in-source: the finally clears _hydrateInFlight after the first awaiter - completes, so single-flight coalesces callers that overlap the acquisition window, not every call - across the token's lifetime; a caller arriving after the window starts a fresh hydration.
        • -
        -

        IAuthUIService

        -
        -

        MMCA.Common.UI · MMCA.Common.UI.Services.Auth · MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/IAuthUIService.cs:9 · Level 5 · interface

        -
        -
          -
        • What it is: the client-side authentication contract that ties together token storage, HTTP calls - to the auth/* WebAPI endpoints, and Blazor auth-state notifications.
        • -
        • Depends on: AuthenticationResponse, - LoginRequest, - RegisterRequest (via MMCA.Common.Shared.Auth).
        • -
        • Concept, the UI-layer auth boundary. [Rubric §3, Clean Architecture] (assesses that the UI - auth surface depends only on Shared DTOs, never Application/Domain) and [Rubric §11, Security] - (assesses token handling behind a service abstraction, not in page components). This interface lives - in MMCA.Common.UI and references only MMCA.Common.Shared.Auth request/response records, so page - components talk to it without pulling in any backend layer.
        • -
        • Walkthrough: a LastError string property (IAuthUIService.cs:12, the last failure message, or - null), plus LoginAsync (:15), RegisterAsync (:18), ExchangeOAuthCodeAsync (:25, which - swaps a single-use OAuth completion code for the token pair via auth/oauth/exchange, keeping tokens - out of the address bar), LogoutAsync (:28), TryRefreshTokenAsync (:31), and - ChangePasswordAsync (:34). The LoginAsync/RegisterAsync/ExchangeOAuthCodeAsync methods - return a nullable AuthenticationResponse (null on - failure).
        • -
        • Why it's built this way: exposing auth as a UI-layer contract keeps components free of HTTP and - token mechanics and preserves the layered dependency rule (UI depends on Shared only).
        • -
        • Where it's used: implemented by AuthUIService; injected into the login, - register, profile, and session-refresh Blazor components.
        • -
        -

        AuthUIService

        -
        -

        MMCA.Common.UI · MMCA.Common.UI.Services.Auth · MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthUIService.cs:15 · Level 6 · class (sealed)

        -
        -
          -
        • What it is: the concrete IAuthUIService: it drives the full client auth - lifecycle (login, register, OAuth exchange, logout, refresh, password change) by calling the - auth/* endpoints, persisting tokens via ITokenStorageService, and - pushing state through JwtAuthenticationStateProvider.
        • -
        • Depends on: IAuthUIService (Level 5), - ITokenStorageService (Level 0), - ITokenRefresher (Level 0), - JwtAuthenticationStateProvider (Level 1), - IPushRegistrationService, - AuthenticationResponse, - LoginRequest, - RegisterRequest, - OAuthCodeExchangeRequest, - ChangePasswordRequest; NuGet/BCL - (IHttpClientFactory, System.Net.Http.Json, ProblemDetails, Blazor - AuthenticationStateProvider).
        • -
        • Concept, centralized UI auth orchestration. [Rubric §11, Security] and [Rubric §26, Front-End Security] (assess that token storage/refresh flow through service abstractions, never raw storage in - page code), [Rubric §19, State Management] (assesses coordinating auth-state notifications), and - [Rubric §29, Resilience & Business Continuity] (assesses best-effort side effects that never block - the primary flow). The doc comment (AuthUIService.cs:9-13) notes it also guards - InvalidOperationException around JS interop during SSR prerender.
        • +
        • Why it's built this way: join endpoints sit behind [Authorize] exactly like their parent CRUD + endpoints, so they need the same Bearer-token plumbing and the same domain-error translation, but none + of the paging, filtering or lookup machinery. Deriving from + AuthenticatedServiceBase rather than from + EntityServiceBase<TEntityDTO, TIdentifierType> + reuses the auth path while keeping the surface honest. Note the deliberate asymmetry with its sibling: + PostAsync sends no Idempotency-Key, so a duplicate join is stopped by the domain invariant and + the unique index behind it rather than by request deduplication (the opt-in server-side model is + ADR-017, Website/docs-src/adr/017-request-idempotency.md).
        • +
        • Where it's used: four ADC Conference join services derive from it, + EventSpeakerService on eventspeakers, + SessionSpeakerService on sessionspeakers, + SessionCategoryItemService on + sessioncategoryitems, and + SpeakerCategoryItemService on + speakercategoryitems, all four declared in + MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/ChildEntityServices.cs:15,31,47,63. + Each adds a typed AddAsync/DeleteAsync pair over the two protected methods and implements its own + module interface. The base is pinned by + ChildEntityServiceBaseTests through + a minimal MembershipService subclass.
        • +
        • Caveats: PostAsync returns the HttpResponseMessage after the client it was created from has + been disposed by the enclosing using var (lines 26, 35). Reading the body afterwards works in every + current subclass because the content is already buffered by the time the call returns, but the + disposal ordering is a sharp edge a new subclass could cut itself on. Neither method routes through + RetryPolicy: the policy is inherited from AuthenticatedServiceBase but + never invoked here, so joins are single-attempt.
        • +
        +

        EntityServiceBase<TEntityDTO, TIdentifierType>

        +
        +

        MMCA.Common.UI · MMCA.Common.UI.Services · MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/EntityServiceBase.cs:25 · Level 3 · class (abstract)

        +
        +
          +
        • What it is: the CRUD workhorse of the UI layer. It implements + IEntityService<TEntityDTO, TIdentifierType> against a + REST endpoint by turning each operation into a URL plus a one-line HTTP lambda, and funnels every one + of them through a single dispatch method that owns retry, idempotency, error translation and + deserialization.
        • +
        • Depends on: AuthenticatedServiceBase (base class), + IEntityService<TEntityDTO, TIdentifierType> + (implemented interface), + IBaseDTO<TIdentifierType> (the + TEntityDTO constraint, EntityServiceBase.cs:29), + BaseLookup<TIdentifierType>, + CollectionResult<T>, + PagedCollectionResult<T> and its + PaginationMetadata, + IdempotencyHeaders, + ITokenStorageService, + ServiceExceptionHelper; Polly (through the inherited RetryPolicy) and + System.Net.Http.Json (BCL).
        • +
        • Concept introduced, one dispatch point for every cross-cutting HTTP concern. + [Rubric §10, Cross-Cutting Concerns] assesses whether retry, auth and error handling are applied in + one place instead of repeated per call: here the six public methods contain only URL construction, and + SendRequestAsync<T> (line 183) contains all of the policy. [Rubric §19, State Management & Data Flow] applies because components never touch HttpClient: they inject the typed interface and receive + DTOs. [Rubric §29, Resilience & Business Continuity] applies through the inherited three-retry + exponential-backoff-with-jitter policy + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/AuthenticatedServiceBase.cs:26-32), whose + predicate retries 5xx plus 408 and 429 but not 501 or 505 (AuthenticatedServiceBase.cs:108-117).
        • +
        • Concept introduced, retry safety for a non-idempotent verb. [Rubric §9, API & Contract Design] + assesses whether the client and server share an explicit protocol for duplicate writes. A retry policy + that re-issues a POST is a correctness hazard: if the first attempt reached the server and only the + response was lost, the retry creates a second record. AddAsync is the one method that passes an + idempotency key (EntityServiceBase.cs:135), generated once per logical operation by + AuthenticatedServiceBase.NewIdempotencyKey() as a compact GUID (AuthenticatedServiceBase.cs:51). + The key is set as a default request header on the client (line 199) rather than on an individual + request, and that one client instance serves every retry attempt, so all attempts carry the identical + value (the comment at lines 195-198 spells out that this is the point). The server side of the protocol + is the opt-in IdempotencyFilter, and both ends + read the header name from the shared + IdempotencyHeaders constant rather than hard-coding the literal + twice (MMCA.Common/Source/Core/MMCA.Common.Shared/Http/IdempotencyHeaders.cs:19). Reads, full-PUT + updates and deletes send no key because they are naturally idempotent (comment at + EntityServiceBase.cs:128-130).
        • Walkthrough
            -
          • LoginAsync (AuthUIService.cs:26) and RegisterAsync (:72) follow one shape: POST the request - to auth/login/auth/register; on a non-success status, read a ProblemDetails body into - LastError (falling back to a generic message) and return null (:32-46, :78-92); on success, - read AuthenticationResponse, bail on a blank access - token, persist the pair via SetTokensAsync inside a try/catch (InvalidOperationException) for - prerender, then call NotifyUserAuthentication when the provider is a - JwtAuthenticationStateProvider (:48-69, :94-114).
          • -
          • ExchangeOAuthCodeAsync (:117): rejects a blank code up front (:121-125), then POSTs an - OAuthCodeExchangeRequest to auth/oauth/exchange and - follows the same success/failure handling, keeping tokens out of the URL.
          • -
          • LogoutAsync (:172): first best-effort pushRegistration.UnregisterAsync() while the token is - still valid (native-push cleanup, ADR-044), wrapped in a CA1031-suppressed catch so a failure - never blocks sign-out (:177-186); then a best-effort authenticated auth/revoke POST - (:188-205); then ClearTokensAsync and NotifyUserLogout (:207-219).
          • -
          • TryRefreshTokenAsync (:222): delegates to - ITokenRefresher.AcquireAccessTokenAsync; a null result means the session - cannot be refreshed, so it clears tokens, notifies logout, and returns false; a token notifies - authentication and returns true (:227-253).
          • -
          • ChangePasswordAsync (:256): manually attaches the Bearer token from circuit-scoped storage - (the comment at :263 notes AuthDelegatingHandler has Blazor Server scope issues), then PUTs a - ChangePasswordRequest to auth/password and returns - the success flag.
          • +
          • The primary constructor takes endpoint, IHttpClientFactory and ITokenStorageService + (lines 25-28); note the parameter order differs from + ChildEntityServiceBase. The endpoint is republished as + protected string Endpoint { get; } (line 32) because the read methods append sub-paths to it. Both + type parameters are constrained: TEntityDTO : IBaseDTO<TIdentifierType> and + TIdentifierType : notnull (lines 29-30).
          • +
          • GetAllAsync(includeFKs, includeChildren, ct) (line 34) builds a two-parameter query string and + deserializes into PagedCollectionResult<TEntityDTO>, returning Items or an empty list + (lines 46-50). The "all" endpoint returns the paged envelope, not a bare array.
          • +
          • GetPagedAsync(filters, pageNumber, pageSize, sortColumn, sortDirection, includeChildren, ct) + (line 53) is the one with real work. Page numbers are formatted with + string.Create(CultureInfo.InvariantCulture, ...) (lines 64-65) so a comma-decimal locale cannot + corrupt the query, and every filter property, operator and value is passed through + Uri.EscapeDataString (lines 77-79). Filters serialize as filters[Property].operator= plus an + optional filters[Property].value=, and a filter whose operator is blank is skipped entirely + (line 75), which is how a grid clears a column filter. It targets {Endpoint}/paged (line 84) and + returns a tuple of items plus PaginationMetadata.TotalItemCount (line 89), the two things a + server-side data grid needs.
          • +
          • GetAllForLookupAsync(nameProperty, ct) (line 92) hits {Endpoint}/lookup and deserializes + CollectionResult<BaseLookup<TIdentifierType>> (line 97), the lightweight id-plus-name shape that + feeds dropdowns and autocompletes.
          • +
          • GetByIdAsync(id, includeChildren, ct) (line 104) is the only read that passes + treatNotFoundAsDefault: true (line 118), so a 404 becomes null instead of an exception.
          • +
          • AddAsync(entity, ct) (line 122) POSTs with throwIfNull: true and the idempotency key + (lines 134-135), and throws again at the call site if the dispatch still returned null (line 136).
          • +
          • UpdateAsync(entity, ct) (line 139) PUTs to {Endpoint}/{GetEntityId(entity)} with + expectContent: false (line 147) and always returns true; DeleteAsync(id, ct) (line 152) does the + same for DELETE (lines 157-162). Both rely on the dispatch to throw on failure, so true means "no + exception", not "the server reported a change".
          • +
          • GetEntityId(entity) (line 165) is protected virtual and simply returns entity.Id, the hook a + subclass overrides when the route key is not the DTO's own id.
          • +
          • SendRequestAsync<T>(httpAction, ct, treatNotFoundAsDefault, throwIfNull, expectContent, idempotencyKey) + (line 183) is the center of the class. It creates the authenticated client (line 191), attaches the + idempotency header when one was supplied (lines 193-200), executes the caller's lambda through + RetryPolicy with the cancellation token threaded in so a cancelled operation does not sleep out its + backoff (lines 202-204), short-circuits 404 to default when asked (lines 206-207), calls + ServiceExceptionHelper.ThrowIfDomainExceptionAsync before + EnsureSuccessStatusCode() so a domain failure surfaces as a readable message (lines 209-213), + returns default when no body is expected (lines 215-216), and finally deserializes and optionally + null-checks the payload (lines 218-221).
        • -
        • Why it's built this way: routing every auth operation through one service keeps components free - of HTTP and token mechanics; the pervasive InvalidOperationException guards keep an operation from - crashing when JS interop is unavailable during prerender; and the best-effort push/revoke steps - ensure sign-out always completes locally even when a remote call fails (ADR-044).
        • -
        • Where it's used: registered as the IAuthUIService implementation on the web - and MAUI heads; injected into login, register, profile, and session-refresh components. The - NoOpAuthUIService in the component gallery is the backend-less stand-in for gallery rendering.
        • -
        • Caveats / not-in-source: several catch blocks around ProblemDetails parsing and auth/revoke - swallow all exceptions deliberately (a failed error-detail read or revoke must not derail the flow); - the concrete AuthenticationStateProvider is injected by its base type and pattern-matched to - JwtAuthenticationStateProvider at each notification site, so a - different provider registration would silently skip the notify calls.
        • +
        • Why it's built this way: passing the HTTP call as a Func<HttpClient, Task<HttpResponseMessage>> + lets each verb stay a two-line method while every policy decision lives once. The ordering inside the + dispatch is the load-bearing part: domain-error extraction has to run before EnsureSuccessStatusCode() + or the readable message is lost inside a generic HttpRequestException, and the idempotency header has + to be set on the client rather than per request or each retry would carry a different key. All six + public methods are virtual, so a module service overrides only the one that needs domain-specific + behavior and inherits the rest.
        • +
        • Where it's used: it is the base of essentially every module CRUD service. In this package, + PushNotificationService + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Notifications/PushNotificationService.cs:19). + In ADC Conference, EventService, + SessionService, + SpeakerService and + SponsorService among others; in ADC Identity, + UserService. In Store, ProductService, CategoryService, + OrderService, ShoppingCartService, InventoryItemService and CustomerService. Behavior is pinned + by EntityServiceBaseTests and, for the + write-safety half, by + EntityServiceBaseIdempotencyRetryTests, + which asserts the key is emitted on creates only and stays identical across attempts.
        • +
        • Caveats: UpdateAsync and DeleteAsync return a hard-coded true with no path that returns + false, so a caller cannot distinguish "updated" from "server accepted a no-op". GetAllAsync has no + page-size bound in source: it asks the "all" endpoint for everything and materializes the result, which + is why grids use GetPagedAsync instead. The bearer token is applied by + AuthenticatedServiceBase.CreateAuthenticatedClientAsync rather than by + the AuthDelegatingHandler, because handlers created by IHttpClientFactory + live in a different DI scope than the Blazor circuit that holds the token + (AuthenticatedServiceBase.cs:53-58).

        BackNavigationResult

        diff --git a/docs/onboarding/group-17-conference-domain.html b/docs/onboarding/group-17-conference-domain.html index cc29701..bf279fb 100644 --- a/docs/onboarding/group-17-conference-domain.html +++ b/docs/onboarding/group-17-conference-domain.html @@ -149,9 +149,10 @@

        17. ADC Conference - Conference bounded context, the largest and richest domain in MMCA.ADC. It models everything an organizer curates and an attendee browses: the Event (the conference itself, with its rooms, speaker roster, and venue details), the Session (a talk on the schedule), the Speaker, the - Sponsor (the sold sponsorship and expo-booth record), the Category/CategoryItem taxonomy + Sponsor (the sold sponsorship and expo-booth record), the Activity (the party, coffee connect, + or closing ceremony that is deliberately not a session), the Category/CategoryItem taxonomy (tracks, levels, session formats), and the Question/answer machinery that captures structured - metadata about events, sessions, and speakers. Seven aggregate roots (one of them an AI scorecard), a + metadata about events, sessions, and speakers. Eight aggregate roots (one of them an AI scorecard), a dozen child entities, the static invariant classes that guard their business rules, the domain events every mutation raises, a pure domain service that coordinates the cross-aggregate cascade delete, and, across the package boundary in MMCA.ADC.Conference.Shared, the DTO contracts, the @@ -195,7 +196,7 @@

        Two packages, one bounded context

        (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/AssemblyReference.cs:5,11) is the trivial type that assembly scanning and the architecture-fitness tests pin to when they need to name the Conference domain assembly.

        -

        Seven aggregates and their ownership boundaries

        +

        Eight aggregates and their ownership boundaries

        An aggregate is a root entity plus the children it exclusively owns; invariants are enforced inside the boundary, and references across aggregates are by ID, never by object graph. Every root here derives from @@ -207,10 +208,10 @@

        Seven aggregates and th Room (the physical rooms), EventSpeaker (the speaker roster, a join to Speaker by ID), and EventQuestionAnswer (event-level structured answers). Its Id is database-generated (marked [IdValueGenerated], Event.cs:22), and it also - carries the per-event live-layer moderation default (Event.cs:71), the published flag - (Event.cs:65), the organizer contact email and sponsorship packet URL that drive the public pages - (Event.cs:56,62), and the Sessionize refresh stamp written by RecordSessionizeRefresh - (Event.cs:74,77,302). + carries the per-event live-layer moderation default (Event.cs:77), the published flag + (Event.cs:71), the organizer contact email, sponsorship packet URL, and ticketing URL that drive + the public pages (Event.cs:56,62,68, each of which the pages hide entirely when absent), and the + Sessionize refresh stamp written by RecordSessionizeRefresh (Event.cs:80,83,316).
      • Session (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:22) owns SessionSpeaker, SessionCategoryItem, and @@ -227,15 +228,29 @@

        Seven aggregates and th holds an optional Email value object (Speaker.cs:31), and carries the cross-module LinkedUserId FK to an Identity User (Speaker.cs:58). Speaker Ids are Sessionize-assigned GUIDs, with a fallback to Guid.NewGuid() for organizer-created and seeded - speakers (see the in-code note at Speaker.cs:148-153).

      • + speakers (see the in-code note at Speaker.cs:156-161).
      • Sponsor (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:18) is a flat root belonging to exactly one event by scalar EventId (Sponsor.cs:45, with a read-only - [Navigation] Event for public visibility filtering at Sponsor.cs:49). It carries a + [Navigation] Event for public visibility filtering at Sponsor.cs:48-49). It carries a SponsorTier that drives public placement, branding links, and the optional expo booth (IsExhibitor/BoothNumber, Sponsor.cs:52,58); its Id is database-generated (Sponsor.cs:17) because sponsors are sold, not imported from Sessionize. Moving a sponsor between - events is deliberately not an update: Update omits the event entirely (Sponsor.cs:153).
      • + events is deliberately not an update: Update omits the event entirely (Sponsor.cs:153-163). +
      • Activity + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:20) is the + social and networking programme: the pre-conference party, the morning coffee connect, the + after-party, the closing ceremony. It is deliberately not a session, and the type's own doc comment + says why (Activity.cs:11-18): an activity has no room and no speakers, and it frequently happens at + an external venue, so the venue travels on the activity itself (VenueName, VenueAddress, + VenueUrl at Activity.cs:42,45,48) instead of being inherited from the event. Its Id is + database-generated (Activity.cs:19) because activities are planned, not imported; it belongs to one + event by scalar EventId with a read-only [Navigation] for visibility filtering + (Activity.cs:54,57-58); StartTime/EndTime are plain wall-clock DateTimes in the owning event's + IANA zone, exactly like Session.StartsAt, with the zone kept on the event and never repeated per row + (Activity.cs:29-36); and SortOrder breaks ties between activities starting at the same minute + (Activity.cs:51). Like Sponsor, moving it between events is a create plus a delete rather than an + update (Activity.cs:134,145).
      • Category (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/Category.cs:16) owns CategoryItem: the taxonomy roots ("Level", "Track", "Session format") and their @@ -266,7 +281,8 @@

        Seven aggregates and th Session (Session.cs:22), and Speaker (Speaker.cs:22). The in-code rationale is worth reading (Event.cs:16-20, Session.cs:16-20, Speaker.cs:15-20): these three are written by organizers and overwritten by the Sessionize sync, and "which edit moved this, and was it a person or the importer" is - a question that only a history answers. Sponsors, categories, and questions do not carry that cost.

        + a question that only a history answers. Sponsors, activities, categories, and questions do not carry + that cost.

        The aggregate shape, taught once

        Open any of the roots and you will see the same skeleton; this repetition is the point, and it is what makes the per-type sections that follow read quickly. The shape, using Event as the @@ -275,45 +291,54 @@

        The aggregate shape, taught once

      • Private-setter properties (Name { get; private set; }, Event.cs:26): state can only change through the aggregate's own methods, never by an outside caller assigning a property. This is encapsulation as a compile-time guarantee ([Rubric §4, Domain-Driven Design], [Rubric §1, SOLID]).
      • -
      • Backing-field collections exposed as IReadOnlyCollection<T> (_rooms at Event.cs:79 becomes - Rooms => _rooms.AsReadOnly() at Event.cs:83). Children can only be added, updated, or removed +
      • Backing-field collections exposed as IReadOnlyCollection<T> (_rooms at Event.cs:85 becomes + Rooms => _rooms.AsReadOnly() at Event.cs:89). Children can only be added, updated, or removed through AddRoom/UpdateRoom/RemoveRoom-style methods that enforce invariants (for example the - duplicate-name rejection at Event.cs:687-704). Most collections are decorated + duplicate-name rejection at Event.cs:716-733). Most collections are decorated [Navigation(IsCollection = true)] so the navigation-populator machinery (G11) eager-loads them, - but two deliberately are not: Event.EventQuestionAnswers (Event.cs:93-103) and - Session.SessionQuestionAnswers (Session.cs:92-104) opt out because those collections grow with + but two deliberately are not: Event.EventQuestionAnswers (Event.cs:97-109) and + Session.SessionQuestionAnswers (Session.cs:90-104) opt out because those collections grow with attendance rather than with the schedule and were riding along on hot anonymous public reads that - never render them. Handlers that genuinely need them pass an explicit includes: list. That is a - [Rubric §12, Performance & Scalability] decision expressed as a deliberately absent attribute.
      • -
      • A private EF Core constructor (Event.cs:106, for materialization) plus a private state - constructor (Event.cs:112) used only by the factory.
      • + never render them; the session answers are also the one child collection here that is not public + data (Session.cs:99-102). Handlers that genuinely need them pass an explicit includes: list. + That is a [Rubric §12, Performance & Scalability] decision expressed as a deliberately absent + attribute. +
      • A private EF Core constructor (Event.cs:112, for materialization) plus a private state + constructor (Event.cs:118) used only by the factory.
      • A static Create(...) factory returning Result<T> - (Event.cs:155): it validates invariants via Result.Combine(...) before constructing anything - (Event.cs:170-173), so an invalid aggregate is unrepresentable, then raises an Added domain - event (Event.cs:196). The isIdValueGenerated ? default : id!.Value dance (Event.cs:177,192) + (Event.cs:164): it validates invariants via Result.Combine(...) before constructing anything + (Event.cs:180-183), so an invalid aggregate is unrepresentable, then raises an Added domain + event (Event.cs:207). The isIdValueGenerated ? default : id!.Value dance (Event.cs:187,203) reconciles database-generated IDs with explicitly supplied ones. Each root spells that reconciliation slightly differently: Speaker generates a GUID when no id is supplied - (Speaker.cs:153), and Category throws for a missing id when identity is not - database-generated (Category.cs:69).
      • -
      • Mutator methods (Update at Event.cs:217, Publish/Unpublish at Event.cs:258,278, - LinkUser/UnlinkUser on Speaker at Speaker.cs:260,278) that re-validate, mutate, and raise an + (Speaker.cs:161), Category throws for a missing id when identity is not + database-generated (Category.cs:69), and Activity uses the plain Event form + (Activity.cs:120-124).
      • +
      • Mutator methods (Update at Event.cs:229, Publish/Unpublish at Event.cs:272,292, + LinkUser/UnlinkUser on Speaker at Speaker.cs:272,290) that re-validate, mutate, and raise an Updated event. Lifecycle guards return failures rather than throwing: publishing an already - published event yields the "Event.AlreadyPublished" invariant error (Event.cs:262-266).
      • -
      • An overridden Delete() (Event.cs:314) that calls base.Delete() (the soft-delete from G02), - then cascade-soft-deletes each owned child (rooms, event speakers, and event answers at - Event.cs:320-339) and raises a Deleted event (Event.cs:341). Session does the - same for its three child collections (Session.cs:283-304), Category for its items - (Category.cs:109-116), Sponsor has nothing to cascade to and simply raises its - Deleted event (Sponsor.cs:190-197), and Speaker uses its override for a different - job: clearing the cross-context link (Speaker.cs:239-253). Soft-delete is the default everywhere - ([Rubric §8, Data Architecture]: the IsDeleted flag plus EF Core global query filters, never a - hard DELETE; ADR-005).
      • -
      • Restore methods for the Sessionize round-trip (RestoreRoom at Event.cs:439, - RestoreEventSpeaker at Event.cs:548, and the equivalents on Session and Speaker): a re-imported + published event yields the "Event.AlreadyPublished" invariant error (Event.cs:274-281).
      • +
      • An overridden Delete() (Event.cs:328) that calls base.Delete() (the soft-delete from G02, + Event.cs:330), then cascade-soft-deletes each owned child (rooms, event speakers, and event + answers at Event.cs:334-353) and raises a Deleted event (Event.cs:355). + Session does the same for its three child collections (Session.cs:283-304), + Category for its items (Category.cs:109-116), Sponsor and + Activity have nothing to cascade to and simply raise their Deleted events + (Sponsor.cs:190-197, Activity.cs:180-188), and Speaker uses its override for a + different job: clearing the cross-context link while deliberately leaving its junction children + alive so the Sessionize import can reactivate them in place (Speaker.cs:244-267). Soft-delete is + the default everywhere ([Rubric §8, Data Architecture]: the IsDeleted flag plus EF Core global + query filters, never a hard DELETE; + ADR-005).
      • +
      • Restore methods for the Sessionize round-trip (RestoreRoom at Event.cs:454, + RestoreEventSpeaker at Event.cs:577, and the equivalents on Session and Speaker): a re-imported child that was previously soft-deleted is reactivated in place rather than re-inserted. A restore has to clear the same uniqueness bar as an add, which is why RestoreRoom re-runs the duplicate-name - check before reactivating (Event.cs:455).
      • -
      • internal SetX(...) methods (Event.cs:500,596,673) delegating to the framework's SetItems + check before reactivating (Event.cs:484), and it first refuses any room owned by a different event + (Event.cs:463-470): room ids come from a global Sessionize sequence, Room.EventId has no setter, + and adding a foreign room to this collection would let EF relationship fixup silently move the row + (Event.cs:458-462).
      • +
      • internal SetX(...) methods (Event.cs:529,625,702) delegating to the framework's SetItems helper: the hooks the navigation populators call to hydrate the read-only collections after a batch load.
      • @@ -324,35 +349,38 @@

        Invariants, business rules

        Each aggregate has a co-located static invariant class, EventInvariants (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:10), SessionInvariants, SpeakerInvariants, - SponsorInvariants, CategoryInvariants, and - QuestionInvariants, whose methods each return a - Result and are combined with Result.Combine(...) in the - factory and mutators. They build on + SponsorInvariants, ActivityInvariants, + CategoryInvariants, and QuestionInvariants, whose + methods each return a Result and are combined with + Result.Combine(...) in the factory and mutators. They build on CommonInvariants (G02) for the generic string-not-empty and max-length checks and add domain-specific rules. They also carry the length constants shared with the EF Core configuration, so the domain rule and the column constraint can - never drift (EventInvariants.cs:13-49, + never drift (EventInvariants.cs:13-55, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:13-34, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/SpeakerInvariants.cs:13-40, - MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/SponsorInvariants.cs:13-31).

        + MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/SponsorInvariants.cs:13-31, + MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/ActivityInvariants.cs:13-25).

        The domain-specific rules are where the ubiquitous language shows up. SessionInvariants holds the BR-91 service-session guard (SessionInvariants.cs:91), the BR-49 status-eligibility check (SessionInvariants.cs:107), the BR-122 zero-duration guard whose failure code is "Session.Duration.Invalid" (SessionInvariants.cs:124-136), and the reserved manual id range 999_999_000 through 999_999_999 for sessions that did not come from Sessionize - (SessionInvariants.cs:41-44, mirrored for questions at + (SessionInvariants.cs:41-44, mirrored for rooms at EventInvariants.cs:62-65 and for questions at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:37-40). QuestionInvariants validates the free-text enum-like fields against allow-lists (QuestionInvariants.cs:28,31,34, checked at :68,83,98) and, for answers, checks each value against - its question type: Rating must parse as an integer 1 through 5 (QuestionInvariants.cs:130), Text is - capped at 2000 characters (QuestionInvariants.cs:25), and Email must parse as a - System.Net.Mail.MailAddress (QuestionInvariants.cs:160). CategoryInvariants enforces - case-insensitive uniqueness of an item name within its category (BR-138, + its question type: Rating must parse as an invariant-culture integer 1 through 5 + (QuestionInvariants.cs:130), Text is capped at 2000 characters (QuestionInvariants.cs:25), and + Email must parse as a System.Net.Mail.MailAddress (QuestionInvariants.cs:160). + ActivityInvariants is the compact newcomer: name, venue name, venue address, and venue URL length + checks plus a start-before-end time-range rule (ActivityInvariants.cs:33,45,57,69,82). + CategoryInvariants enforces case-insensitive uniqueness of an item name within its category (BR-138, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:37), and its in-code note explains why the exclusion parameter is nullable rather than defaulted: a database-generated CategoryItem id is 0 until the save, so a default exclusion would silently exempt every unsaved sibling (CategoryInvariants.cs:43-45). Centralizing each rule as a named, - side-effect-free method is what makes the domain exhaustively unit-testable ([Rubric §14, Testability]), and the error codes ("Event.AlreadyPublished" at Event.cs:263, + side-effect-free method is what makes the domain exhaustively unit-testable ([Rubric §14, Testability]), and the error codes ("Event.AlreadyPublished" at Event.cs:277, "Session.StatusIneligible" at SessionInvariants.cs:110) are the business vocabulary. The recurring // BR-NN comments are traceability links back to the business-requirements catalogue.

        A nuance worth flagging: the Status field on Session is free text, imported verbatim from Sessionize @@ -374,8 +402,8 @@

        Domain events and the outbox spineEvery state-changing method raises a domain event through the inherited AddDomainEvent(...). The events come in two shapes. The aggregate-level ones, EventChanged, SessionChanged, SpeakerChanged, - CategoryChanged, QuestionChanged, and - SponsorChanged, derive from + CategoryChanged, QuestionChanged, + SponsorChanged, and ActivityChanged, derive from EntityChangedEvent<TIdentifierType> and carry the DomainEntityState (Added/Updated/Deleted) plus a friendly label, and sometimes one extra correlating field: @@ -386,7 +414,7 @@

        Domain events and the outbox spineSessionCategoryItemChanged, SpeakerCategoryItemChanged, CategoryItemChanged, and the *QuestionAnswerChanged set, carry both the parent and child IDs (for example - RoomChanged(state, Id, room.Id, room.Name) at Event.cs:381) so a consumer can target the precise + RoomChanged(state, Id, room.Id, room.Name) at Event.cs:433) so a consumer can target the precise change and the module can invalidate the right output-cache tag. These are intra-module domain events: they ride the outbox (ADR-003) but are consumed inside Conference. They do not cross the wire to other services; that is the job of @@ -397,40 +425,44 @@

        Domain events and the outbox spinedeclare what happened, which is the Clean Architecture division of labor ([Rubric §6, CQRS & Event-Driven]). One domain detail matters for the cross-context link: Speaker.Delete() captures the previous LinkedUserId before clearing it and passes it into the Deleted - SpeakerChanged event (Speaker.cs:242,249,251), whose optional + SpeakerChanged event (Speaker.cs:254,261,263), whose optional PreviousLinkedUserId payload field (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/DomainEvents/SpeakerChanged.cs:20) exists precisely so the cross-context cleanup handler has what it needs even though the field is already nulled within Conference (BR-70).

        The cross-aggregate cascade: a pure domain service

        One business rule cannot live inside a single aggregate: deleting an Event must also delete every - Session belonging to it (BR-127) and every Sponsor sold against it, but sessions and sponsors are - separate aggregates (referenced by EventId, not owned). Putting a List<Session> inside Event - would violate the aggregate boundary. The answer is a domain service, - IEventCascadeDeletionDomainService - (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Services/IEventCascadeDeletionDomainService.cs:13) + Session belonging to it (BR-127), every Sponsor sold against it, and every Activity planned for + it, but sessions, sponsors, and activities are separate aggregates (referenced by EventId, not + owned). Putting a List<Session> inside Event would violate the aggregate boundary. The answer is a + domain service, IEventCascadeDeletionDomainService + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Services/IEventCascadeDeletionDomainService.cs:15) and its implementation EventCascadeDeletionDomainService - (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Services/EventCascadeDeletionDomainService.cs:14), + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Services/EventCascadeDeletionDomainService.cs:16), a pure, infrastructure-free coordinator that takes the pre-fetched Event plus its already-loaded - Session and Sponsor collections and orchestrates the deletes: soft-delete each session first (BR-55 - cascades to its children), then each sponsor, then the event itself (BR-72 cascades to rooms, event - speakers, and event answers) (EventCascadeDeletionDomainService.cs:17-39). The ordering is what makes - the failure path safe: the first child that refuses to delete short-circuits the cascade and returns its - own failure unchanged, so the event is never deleted and the caller (which saves only on success) - discards the aborted in-memory mutations rather than persisting a half-deleted graph - (EventCascadeDeletionDomainService.cs:19-36). This is [Rubric §4, Domain-Driven Design]'s textbook - "domain service for behavior that spans aggregates and belongs to no single one," and [Rubric §3, Clean Architecture]'s purity discipline: the service does no I/O; the application layer fetches the - aggregates and saves them. It is the highest-level type in the chapter precisely because it depends on - three aggregates at once.

        + Session, Sponsor, and Activity collections (IEventCascadeDeletionDomainService.cs:28-32) and + orchestrates the deletes: soft-delete each session first (BR-55 cascades to its children), then each + sponsor, then each activity, then the event itself (BR-72 cascades to rooms, event speakers, and event + answers) (EventCascadeDeletionDomainService.cs:28-55). The ordering is what makes the failure path + safe: the first child that refuses to delete short-circuits the cascade and returns its own failure + unchanged, so the event is never deleted and the caller (which saves only on success) discards the + aborted in-memory mutations rather than persisting a half-deleted graph + (EventCascadeDeletionDomainService.cs:25-52). Activities were folded into the same cascade for the + reason recorded beside the loop: leaving them behind would orphan rows the public activities page still + reads (EventCascadeDeletionDomainService.cs:44-46). This is [Rubric §4, Domain-Driven Design]'s + textbook "domain service for behavior that spans aggregates and belongs to no single one," and [Rubric §3, Clean Architecture]'s purity discipline: the service does no I/O; the application layer fetches + the aggregates and saves them. It is the highest-level type in the chapter precisely because it depends + on four aggregates at once.

        Read models and the AI decision-support feature

        The largest cluster in Conference.Shared is the DTO layer, the wire contracts that decouple the API from the domain entities ([Rubric §9, API & Contract Design]; ADR-001 chose manual/Mapperly mapping over reflection-based AutoMapper). Most are straightforward projections: EventDTO, SessionDTO, SpeakerDTO, - SponsorDTO, ConferenceCategoryDTO, - CategoryItemDTO, QuestionDTO, RoomDTO, and the - per-child join DTOs (EventSpeakerDTO, SessionSpeakerDTO, + SponsorDTO, ActivityDTO, + ConferenceCategoryDTO, CategoryItemDTO, + QuestionDTO, RoomDTO, and the per-child join DTOs + (EventSpeakerDTO, SessionSpeakerDTO, SessionCategoryItemDTO, SpeakerCategoryItemDTO, and the three *QuestionAnswerDTO records: EventQuestionAnswerDTO, @@ -441,22 +473,26 @@

        Read models and the AI MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/SessionFeedbackDTO.cs:6,22,38). They carry the entity's Id via the framework's IBaseDTO<TIdentifierType> contract and - required init-only properties: read contracts, immutable after construction. Some also implement + required init-only properties: read contracts, immutable after construction. Many also implement IConcurrencyAware and round-trip the RowVersion token (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/EventDTO.cs:9,15, and the - same pair on SponsorDTO.cs:9,15), which is what - EventTransitionRequest echoes back on publish and unpublish so a - transition decided against a stale view surfaces as 409 Conflict instead of applying silently + same pair on Sessions/SessionDTO.cs:9,15, Sponsors/SponsorDTO.cs:9,15, and + Activities/ActivityDTO.cs:10,16), which is what EventTransitionRequest + echoes back on publish and unpublish so a transition decided against a stale view surfaces as 409 + Conflict instead of applying silently (ADR-035, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/EventTransitionRequest.cs:14,17; its own doc comment records that MMCA.Common's ConcurrencyTokenRequest supersedes it at the next framework sweep, EventTransitionRequest.cs:12). Alongside those sit the small task-shaped contracts: - LinkUserRequest (the manual speaker-to-user link body, BR-209), + LinkUserRequest (the manual speaker-to-user link body, BR-209, + MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/LinkUserRequest.cs:6), RefreshFromSessionizeResultDTO (per-entity synced counts, the - BR-136 skipped-soft-deleted count, and non-fatal warnings), and the glanceable - NowNextDTO/NowNextSessionDTO snapshot behind the public - now-next endpoint (the Android home-screen widget payload, carrying both event-local wall clock and UTC - instants, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/NowNextDTO.cs:14,29).

        + BR-136 skipped-soft-deleted count, and non-fatal warnings, + MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/RefreshFromSessionizeResultDTO.cs:7,28,31), + and the glanceable NowNextDTO/NowNextSessionDTO snapshot behind + the public now-next endpoint (the Android home-screen widget payload, carrying both event-local wall + clock and UTC instants, + MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/NowNextDTO.cs:14,29).

        A distinct and more interesting subgroup is the DecisionSupport namespace: read models built purely to help an organizer curate a conference. SessionSelectionDashboardDTO @@ -479,7 +515,7 @@

        Read models and the AI rows (near-duplicate talks, scored 0.0 to 1.0 with the shared category items and keywords that drove the score, ContentSimilarityDTO.cs:34-41) are not members of the composite record: they are served by their own endpoint on the same controller - (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:81). + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:82). The AI scores are produced by an Anthropic-backed scoring service in Conference.Infrastructure (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Services/AnthropicScoringService.cs:16, outside this chapter) and persisted as the SessionAiScore aggregate; @@ -489,7 +525,7 @@

        Read models and the AI

        The whole organizer workflow is guarded by the conference:session-selection:manage capability permission catalogued in ConferencePermissions (ConferencePermissions.cs:30), applied once at the controller level - (SessionSelectionController.cs:28), not by a feature flag. The one flag the module does carry, + (SessionSelectionController.cs:29), not by a feature flag. The one flag the module does carry, ConferenceFeatures.SessionizeIntegration (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/ConferenceFeatures.cs:15), gates only the Sessionize external sync that seeds the raw session data this dashboard then analyzes: the @@ -502,14 +538,14 @@

        Authorization voca

        Two more Shared helpers deserve a mention because they encode policy the whole module relies on. ConferencePermissions (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Authorization/ConferencePermissions.cs:9) - is the catalogue of the module's eight capability permissions (conference:events:manage, - conference:sessions:manage, conference:sponsors:manage, and so on, - ConferencePermissions.cs:12-33), the stable string identifiers endpoints require via + is the catalogue of the module's nine capability permissions (conference:events:manage, + conference:sessions:manage, conference:sponsors:manage, conference:activities:manage, and so on, + ConferencePermissions.cs:12-36), the stable string identifiers endpoints require via [HasPermission(...)] rather than by role name. The All and ContentManagement subsets - (ConferencePermissions.cs:36,53) let a role grant an entire capability set or the narrower - catalog-curation slice (sessions, speakers, sponsors, and the category taxonomy) at once, a distinction - capability checks express centrally and role checks cannot. This is the permission-based authorization - story ([Rubric §11, Security], + (ConferencePermissions.cs:39,57) let a role grant an entire capability set or the narrower + catalog-curation slice (sessions, speakers, sponsors, activities, and the category taxonomy) at once, a + distinction capability checks express centrally and role checks cannot. This is the permission-based + authorization story ([Rubric §11, Security], ADR-020), decided by the role-to-permission grants declared in the module's registration rather than scattered across controllers. Beside it sits ConferenceReadAudience @@ -574,8 +610,8 @@

        Cro moderation default is the QuestionModerationDefault enum (Pending = 0/Approved = 1, BR-233, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/QuestionModerationDefault.cs:7-13) - carried on the Event (Event.cs:71, defaulted to Pending in both Create and Update, - Event.cs:166,227). The disabled stub, + carried on the Event (Event.cs:77, defaulted to Pending in both Create and Update, + Event.cs:175,239). The disabled stub, DisabledEventLiveValidationService (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/DisabledEventLiveValidationService.cs:22), deliberately fails open on all four: an always-open window and a published flag for events and @@ -603,7 +639,7 @@

        Cro answer (SessionFeedbackSubmitted.cs:8-13). They are also added to the aggregate pre-save with AddDomainEvent, so the outbox captures them atomically with the answer in the same SaveChangesAsync - (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerHandler.cs:131-136). + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerHandler.cs:133-136). All four are the eventually consistent replacement for what would otherwise be direct cross-module service calls: the links and the points ledger survive the service split because they travel as events over the broker (ADR-006/ADR-008).

        @@ -612,19 +648,19 @@

        Cro

        End-to-end: one organizer action

        To see the chapter cooperate, follow an organizer renaming a room on an event. The application handler loads the Event aggregate (with its Rooms hydrated by the navigation populator), calls - event.UpdateRoom(...) (Event.cs:397), which routes through the private GetRoomOrNotFound helper - (Event.cs:706-709, delegating to the framework's GetChildOrNotFound, so a missing or soft-deleted + event.UpdateRoom(...) (Event.cs:411), which routes through the private GetRoomOrNotFound helper + (Event.cs:735-738, delegating to the framework's GetChildOrNotFound, so a missing or soft-deleted room comes back as a NotFound Result rather than an exception), re-checks the case-insensitive room-name uniqueness rule that mirrors the database index - (Event.cs:411, implemented at Event.cs:687-704), delegates to the child's own Room.Update(...) - (which validates its invariants), and on success raises a RoomChanged Updated - event (Event.cs:419). The handler calls SaveChangesAsync; the interceptor writes the RoomChanged - to the outbox in the same transaction; in-process dispatch busts the relevant output-cache tags so the - next read is fresh. No exception was thrown on the expected not-found path, no child was mutated from - outside its aggregate, no event was hand-dispatched, and the same code path would behave identically - whether Conference runs in the monolith or as its own service, which is exactly the property the - framework groups (G01 through G14) exist to provide, here made concrete in a domain you can reason - about. For the why behind each design choice, + (Event.cs:425, implemented at Event.cs:716-733), delegates to the child's own Room.Update(...) + (which validates its invariants, Event.cs:429), and on success raises a + RoomChanged Updated event (Event.cs:433). The handler calls SaveChangesAsync; + the interceptor writes the RoomChanged to the outbox in the same transaction; in-process dispatch + busts the relevant output-cache tags so the next read is fresh. No exception was thrown on the expected + not-found path, no child was mutated from outside its aggregate, no event was hand-dispatched, and the + same code path would behave identically whether Conference runs in the monolith or as its own service, + which is exactly the property the framework groups (G01 through G14) exist to provide, here made + concrete in a domain you can reason about. For the why behind each design choice, ADR-001 (manual mapping), ADR-002 (navigation populators), ADR-003 (outbox), @@ -1533,7 +1569,7 @@

        LinkUserRequest

        contract without a Domain reference.
      • Where it's used: bound by SpeakersController.LinkUserAsync, a PUT /Speakers/{id}/link gated by [HasPermission(ConferencePermissions.SpeakersManage)] - (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:363-372), + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:363-380), which forwards request.UserId into the LinkUserToSpeakerCommand. That command's handler raises SpeakerLinkedToUser on the aggregate before the @@ -1559,13 +1595,17 @@

        RatingQuestionSummary

      • Walkthrough: four required init members (SessionFeedbackDTO.cs:25-34), QuestionId, QuestionText (so the client renders a label without a second lookup), AverageRating (a double, the computed mean), and ResponseCount (the sample size behind that mean). The mean is computed in - memory over the answers that parse as integers + memory over the answers that parse as integers under CultureInfo.InvariantCulture (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/UseCases/GetSessionFeedback/GetSessionFeedbackHandler.cs:75-88), so ResponseCount counts parseable ratings, not raw answer rows.
      • Why it's built this way: carrying QuestionText and ResponseCount alongside the average makes the record self-describing, so a UI can show "4.6 (from 32 responses)" straight from the payload.
      • Where it's used: nested in SessionFeedbackDTO.Ratings; built by GetSessionFeedbackHandler.
      • +
      • Caveats / not-in-source: a rating question whose answers are all unparseable produces no + summary row at all rather than a zero-count one, because the handler only adds the record when at + least one value parsed (GetSessionFeedbackHandler.cs:81-90). A consumer therefore cannot tell + "nobody rated it" from "the question was never asked" out of this payload alone.

      SponsorTier

      @@ -1595,8 +1635,9 @@

      SponsorTier

      The type's remarks call this out rather than leaving it as an accident.
    • Contrast this with the loose status strings elsewhere in the Conference contract (for example - SessionDTO.Status, which carries Sessionize's vocabulary as free text): tiers are sold by ADC itself, - so the set is closed and can be an enum.

      + SessionDTO.Status, a nullable string carrying Sessionize's vocabulary as free text, + MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/SessionDTO.cs:30): tiers are + sold by ADC itself, so the set is closed and can be an enum.

    • Walkthrough: four members with explicit values (SponsorTier.cs:15-24). There is no None, Unknown, or [Flags] member: a sponsor always has exactly one package.

      @@ -1641,6 +1682,65 @@

      TextQuestionResponses

      GetSessionFeedbackHandler.

    +

    ActivityDTO

    +
    +

    MMCA.ADC.Conference.Shared · MMCA.ADC.Conference.Shared.Activities · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Activities/ActivityDTO.cs:10 · Level 1 · record (class)

    +
    +
      +
    • What it is: the read-model shape of an Activity, a conference social or networking + slot (a party, a coffee connect, an after-party, the closing ceremony). It carries the name and blurb, + the event-local start and end times, an optional off-site venue, a display tie-breaker, and the FK to + its owning event.
    • +
    • Depends on: IBaseDTO<TIdentifierType>, + IConcurrencyAware (both from + MMCA.Common.Shared.DTOs, ActivityDTO.cs:1,10); the aliases ActivityIdentifierType and + EventIdentifierType (both int, + MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:5,8).
    • +
    • Concept, the event-local wall-clock DTO. [Rubric §8, Data Architecture] (assesses how time and + ownership are modelled at the storage boundary) and [Rubric §9, API & Contract Design]. Structurally + this is the concurrency-aware entity DTO already introduced by QuestionDTO, but its + time fields are worth stopping on. StartTime and EndTime are plain DateTime, not + DateTimeOffset, because the entity stores them as wall-clock values in the owning event's IANA time + zone and the zone lives once on the event rather than repeated per row + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:28-36). The DTO + faithfully carries that decision instead of quietly converting: a consumer that needs an absolute + instant has to combine the value with the event's zone, and the public page simply formats it as-is + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicActivityList.razor.cs:39-42).
    • +
    • Walkthrough: eleven members (ActivityDTO.cs:13-43). Id + RowVersion are the two framework + contracts. Name is the only required content field (line 19); Description (line 22) is optional. + StartTime and EndTime (lines 25-28) are the event-local programme window. The three venue fields + VenueName, VenueAddress, and VenueUrl (lines 31-37) are all optional and model the off-site + case only: an empty VenueName means the activity happens at the main conference venue, so the public + page falls back to the event venue rather than rendering a gap (Activity.cs:38-42), and + VenueAddress is what the "directions" affordance hands to a maps URL + (PublicActivityList.razor.cs:101-111). SortOrder (line 40) breaks ties between activities that + start at the same minute. EventId (line 43) scopes the activity to exactly one event. Note what is + absent: the entity's [Navigation] Event? reference (Activity.cs:56-58) is not projected, so an + activity response never drags an event graph along with it; and there is no room and no speaker + collection, because an activity is deliberately neither a session nor a talk.
    • +
    • Why it's built this way: activities are ADC's own content rather than a Sessionize import, so the + contract is small and mostly optional: an organizer can publish "After party" the moment it is + scheduled and fill in the venue later. Naming the tie-breaker SortOrder (where + SponsorDTO uses Sort) mirrors the underlying entity property in each case rather + than imposing a synthetic house name on the wire.
    • +
    • Where it's used: produced by + ActivityDTOMapper, a [Mapper] partial class + whose doc comment records that nothing is redacted because activity data is published to attendees by + design + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/DTOs/ActivityDTOMapper.cs:10-17); + projected by the + IEntityQueryService<TEntity, TEntityDTO, TIdentifierType> + injected into ActivitiesController + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/ActivitiesController.cs:37-47), + whose anonymous GET narrows non-privileged callers to activities of published events via a + specification (ActivitiesController.cs:49-70); rendered by + PublicActivityList, which orders by StartTime then + SortOrder (PublicActivityList.razor.cs:76-83), and by the organizer-facing + ActivityList; written through + ActivityCreateRequest and + ActivityUpdateRequest.
    • +
    +

    CategoryItemDTO

    MMCA.ADC.Conference.Shared · MMCA.ADC.Conference.Shared.Categories · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Categories/CategoryItemDTO.cs:8 · Level 1 · record (class)

    @@ -1663,16 +1763,21 @@

    CategoryItemDTO

    IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> implementation (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/DTOs/CategoryItemDTOMapper.cs:11-13) - is a [Mapper] partial class, so the source generator writes the field-by-field copy at compile - time (ADR-001): no reflection - cost, and a shape mismatch is a build error rather than a runtime surprise. + is a [Mapper] partial class declaring public partial CategoryItemDTO MapToDTO(CategoryItem entity); + with no body (CategoryItemDTOMapper.cs:16), so the source generator writes the field-by-field copy + at compile time (ADR-001): no + reflection cost, and a shape mismatch is a build error rather than a runtime surprise. The + collection overload is the one hand-written member, a null-guarded Select over the single map + (CategoryItemDTOMapper.cs:19-23).
  • Walkthrough: four members (CategoryItemDTO.cs:11-20), Id (the IBaseDTO contract), the required Name, a plain Sort (int, display order), and the required CategoryId FK back to the parent category. Sort is not required, so it defaults to 0 and an item without an explicit - order sorts first. Note what is absent: no RowVersion, because a category item is edited through its - parent Category aggregate, which is where the concurrency token lives.
  • + order sorts first. Note what is absent: no RowVersion, because a category item is a child entity + (AuditableBaseEntity<CategoryItemIdentifierType>, + MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/CategoryItem.cs:14) edited + through its parent Category aggregate root, which is where the concurrency token lives.
  • Why it's built this way: keeping the DTO a flat record with init-only members makes it an immutable snapshot the query pipeline can project, serialize, and cache without defensive copying; Mapperly keeps the entity to DTO copy allocation-light and drift-proof.
  • @@ -1701,9 +1806,11 @@

    QuestionDTO

    client so a later update can be rejected if the row changed underneath it. The interface itself documents the failure mode it prevents: without the round-trip an update reloads the row and saves it, so two concurrent editors silently overwrite each other and the mapped 409 Conflict never fires - (MMCA.Common/Source/Core/MMCA.Common.Shared/DTOs/IConcurrencyAware.cs:9-12). The CA1819 suppression - that lets a property return byte[] is declared once on the interface member - (IConcurrencyAware.cs:19), not repeated on each DTO, so implementing types stay clean. + (MMCA.Common/Source/Core/MMCA.Common.Shared/DTOs/IConcurrencyAware.cs:9-12). A null or empty token is + not an error either: the conflict check is simply skipped, which is what lets a create call and a + legacy client through (IConcurrencyAware.cs:15-17). The CA1819 suppression that lets a property + return byte[] is declared once on the interface member (IConcurrencyAware.cs:19), not repeated on + each DTO, so implementing types stay clean.
  • Walkthrough: eight members (QuestionDTO.cs:12-33), Id + RowVersion (the two contracts), the required QuestionText, then the optional descriptors QuestionEntity ("session" or "speaker"), QuestionType ("text" or "select"), Sort, IsRequired, and QuestionSource ("Sessionize" or @@ -1715,8 +1822,9 @@

    QuestionDTO

  • Where it's used: mapped by QuestionDTOMapper (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Questions/DTOs/QuestionDTOMapper.cs:11-13); - returned by QuestionsController and consumed - by the answer-collection UI. QuestionType is also the switch + returned by QuestionsController + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/QuestionsController.cs:31-32) + and consumed by the answer-collection UI. QuestionType is also the switch GetSessionFeedbackHandler reads when it splits feedback into ratings and text (GetSessionFeedbackHandler.cs:73).
  • @@ -1729,8 +1837,8 @@

    SessionFeedbackDTO

  • What it is: the aggregated feedback report for a single session (BR-210, SessionFeedbackDTO.cs:4): the session identity plus two grouped result sets, numeric ratings and free-text responses.
  • Depends on: RatingQuestionSummary, - TextQuestionResponses; the aliases SessionIdentifierType (a Guid) and - QuestionIdentifierType.
  • + TextQuestionResponses; the aliases SessionIdentifierType and + QuestionIdentifierType (both int).
  • Concept, the composed query-projection report. [Rubric §6, CQRS & Event-Driven] (a read model purpose-built for one query rather than a mapped entity) and [Rubric §12, Performance & Scalability]. This is the parent that composes the two Level-0 records above. It does not implement @@ -1741,21 +1849,30 @@

    SessionFeedbackDTO

    rating question) and TextResponses (an IReadOnlyList<TextQuestionResponses>, one entry per non-rating question). Splitting ratings from text mirrors the two answer kinds a session collects. The producing handler shows how the shape is filled and where it refuses: it loads the session with its - SessionSpeakers and SessionQuestionAnswers, returns Forbidden if the requested speaker is not - assigned to that session, returns an empty-but-valid report when there are no answers, then groups the - answers by question and routes each group to Ratings or TextResponses - (GetSessionFeedbackHandler.cs:24-53,68-101).
  • + SessionSpeakers and SessionQuestionAnswers untracked, returns NotFound when the session is gone, + returns a Forbidden error coded Speaker.NotAssigned if the requested speaker is not assigned to + that session, returns an empty-but-valid report when there are no answers, then loads only the + questions that actually have answers and routes each answer group to Ratings or TextResponses + (GetSessionFeedbackHandler.cs:23-53,56-101).
  • Why it's built this way: pre-aggregating on the server (averages and groupings) keeps the speaker UI a thin renderer and avoids shipping every raw answer row to the client; returning an empty report - rather than a 404 when nobody answered keeps the dashboard's happy path free of special cases.
  • -
  • Where it's used: returned by - GET /Speakers/{speakerId}/sessions/{sessionId}/feedback, which is [AllowAnonymous] under the - ConferencePublicCache output-cache policy (SpeakersController.cs:402-412), via - GetSessionFeedbackHandler; rendered - by SpeakerDashboardService and the speaker - dashboard page.
  • + rather than a 404 when nobody answered keeps the dashboard's happy path free of special cases; and + loading only the questions referenced by an answer (GetSessionFeedbackHandler.cs:56-63) keeps the + second query proportional to the feedback actually received. +
  • Where it's used: returned by GET /Speakers/{speakerId}/sessions/{sessionId}/feedback + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:406-425) + via GetSessionFeedbackHandler; fetched + by SpeakerDashboardService + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/SpeakerDashboardService.cs:69-76) + and rendered on the speaker dashboard. [Rubric §11, Security] is worth reading off that endpoint + directly: it is [Authorize] and applies a self-or-organizer gate in the action body, requiring either + the Organizer role or a speaker_id claim matching the route speaker before it calls the handler + (SpeakersController.cs:407,413-416). Its own doc comment records why it carries no output cache: free + text comments are the speaker's own read, and every response is authorization-dependent, so a shared + public cache entry would be a leak (SpeakersController.cs:400-405).
  • Caveats / not-in-source: the answers this report aggregates are the Conference module's - SessionQuestionAnswers, not the Engagement module's feedback aggregates; nothing in this DTO or its + SessionQuestionAnswers, not the Engagement module's + SessionFeedback aggregate; nothing in this DTO or its handler reads across that module boundary.

  • @@ -1768,7 +1885,7 @@

    SpeakerCategoryItemDTO

    many-to-many link that attaches a CategoryItem (a topic or a locality tier) to a Speaker.
  • Depends on: IBaseDTO<TIdentifierType>; - the aliases SpeakerCategoryItemIdentifierType (int), SpeakerIdentifierType (Guid), and + the aliases SpeakerCategoryItemIdentifierType (int), SpeakerIdentifierType (System.Guid), and CategoryItemIdentifierType (int).
  • Concept: the entity read DTO (see CategoryItemDTO), here for a join entity: a flat record of foreign keys with no editable content of its own.
  • @@ -1837,7 +1954,7 @@

    SponsorDTO

    entered. Sort (line 39) is the tie-breaker within a tier. EventId (line 42) scopes the sponsor to exactly one event. IsExhibitor + BoothNumber (lines 45-48) model the expo floor; the domain keeps a stored booth number even when the flag is false, because the flag drives display and does not reject - stored data (Sponsor.cs:54-57). + stored data (Sponsor.cs:54-58).
  • Why it's built this way: sponsors are sold rather than imported, so unlike the Sessionize-sourced entities this contract is fully ADC's own: a closed enum for tier, a required name, everything else optional so an organizer can create a sponsor the moment a deal closes and fill in the logo later.
  • @@ -1877,11 +1994,14 @@

    CategoryItemChanged

    change needs two identifiers (the parent aggregate and the child) plus a descriptor, so it does not fit that one-id shape. The aggregate-root lifecycle events, CategoryChanged and its siblings, do use EntityChangedEvent<T>. -
  • It is a sealed record class with no behavior. Structural equality plus the inherited - DateOccurred and event id come from BaseDomainEvent - (MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/BaseDomainEvent.cs:26-32); the type +
  • It is a sealed record class with no behavior. The inherited DateOccurred and MessageId + come from BaseDomainEvent, each defaulted at construction + (MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/BaseDomainEvent.cs:26-35); the type exists purely so IDomainEventHandler<CategoryItemChanged> can be registered and dispatched - independently of every other event type.
  • + independently of every other event type. Being a record gives it structural equality, but the + base's own remarks warn that this is not a deduplication mechanism: two logically identical + events raised separately are never equal because both defaults are fresh per instance, and + consumer-side dedup is the inbox's job keyed on MessageId (BaseDomainEvent.cs:10-16, ADR-021).
  • Walkthrough: four positional members (CategoryItemChanged.cs:13-17), State (the @@ -1918,7 +2038,10 @@

    ConferenceCategoryDTO

    DTO (RowVersion, as in QuestionDTO) and it nests a child collection of CategoryItemDTO, so the whole aggregate (category plus its options) serializes in one response. The concurrency token sits here and not on the child, which is the aggregate boundary - showing through the read model: you version the root, not each option.
  • + showing through the read model: you version the root, not each option. The entity declarations line up + with that split, Category is an AuditableAggregateRootEntity<ConferenceCategoryIdentifierType> + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/Category.cs:16) while + CategoryItem is a plain AuditableBaseEntity<CategoryItemIdentifierType> (CategoryItem.cs:14).
  • Walkthrough: six members (ConferenceCategoryDTO.cs:12-27), Id + RowVersion (the contracts), the required Title, an optional Sort and Type ("session" or "speaker"), and the CategoryItems collection, an IReadOnlyCollection<CategoryItemDTO> initialized to [] @@ -1929,9 +2052,10 @@

    ConferenceCategoryDTO

  • Where it's used: mapped by ConferenceCategoryDTOMapper, which takes CategoryItemDTOMapper as a - constructor dependency to project the children - (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/DTOs/ConferenceCategoryDTOMapper.cs:12-15); + constructor dependency and marks it [UseMapper] so the generator uses it for the children + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/DTOs/ConferenceCategoryDTOMapper.cs:12-18); returned by ConferenceCategoriesController + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/ConferenceCategoriesController.cs:32-33) and consumed by the category-management UI.

  • @@ -1946,9 +2070,10 @@

    SpeakerDTO

  • Depends on: IBaseDTO<TIdentifierType>, IConcurrencyAware, SpeakerCategoryItemDTO, - SpeakerQuestionAnswerDTO; the aliases SpeakerIdentifierType (a Guid, - because speakers are imported with Sessionize-side identity) and UserIdentifierType (an int, owned - by Identity).
  • + SpeakerQuestionAnswerDTO; the aliases SpeakerIdentifierType (a + System.Guid, because speakers are imported with Sessionize-assigned identity per BR-61, + MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:3,19) + and UserIdentifierType (an int, owned by Identity).
  • Concept, the cross-context read DTO and the redacting mapper. [Rubric §7, Microservices Readiness], [Rubric §8, Data Architecture], [Rubric §11, Security]. Two things make this DTO worth studying beyond its size:
    1. LinkedUserId is a bare nullable scalar (SpeakerDTO.cs:54), not a nested user object and not @@ -1957,12 +2082,14 @@

      SpeakerDTO

      reconciled by events (SpeakerLinkedToUser and SpeakerUnlinkedFromUser), never by a cross-database join.
    2. Email is nullable on the DTO although the entity holds an Email value object, because - SpeakerDTOMapper redacts it: after the - generated copy runs it returns dto with { Email = null } unless the caller is in the Organizer - role (BR-66, - MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/DTOs/SpeakerDTOMapper.cs:13,35-36). - The redaction is in the mapper rather than the controller, so every read path inherits it. This is - the DTO layer doing real work, not just shape translation.
    3. + SpeakerDTOMapper redacts it: the public + MapToDTO calls the generated MapToDTOGenerated and then returns dto with { Email = null } + unless the caller is in the Organizer role (BR-66, + MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/DTOs/SpeakerDTOMapper.cs:13,30-37,46). + A small private converter, NullableEmailToString (SpeakerDTOMapper.cs:49), is what lets the + generator flatten the value object to a string in the first place. The redaction is in the mapper + rather than the controller, so every read path inherits it. This is the DTO layer doing real work, + not just shape translation.
  • Walkthrough: seventeen members (SpeakerDTO.cs:12-60). Id + RowVersion are the contracts. The @@ -1983,7 +2110,7 @@

    SpeakerDTO

  • Where it's used: projected by the IEntityQueryService<TEntity, TEntityDTO, TIdentifierType> injected into SpeakersController - (SpeakersController.cs:44-47), and returned by its create and update commands; rendered by the public + (SpeakersController.cs:44-45), and returned by its create and update commands; rendered by the public speaker pages and by SpeakerDashboardService.

  • @@ -2003,15 +2130,18 @@

    CategoryChanged

    Created/Updated/Deleted trio). Where the Level-2 events above derive from BaseDomainEvent directly, the root-level events derive from EntityChangedEvent<TIdentifierType>, which consolidates the CRUD-lifecycle pattern: it holds - State plus a single generic EntityId, and each concrete record passes its own id up to that base + State plus a single generic EntityId (constrained notnull, + MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/EntityChangedEvent.cs:24-27), and each + concrete record passes its own id up to that base (CategoryChanged.cs:16: : EntityChangedEvent<ConferenceCategoryIdentifierType>(State, CategoryId)). A subtle but real consequence: the derived record re-exposes the id under a domain-meaningful name (CategoryId) while the same value is also reachable as the inherited generic EntityId, one identity under two property names, so handlers written against EntityChangedEvent<T> and handlers written against the concrete type both work. The base's own doc comment draws the dividing line, generic CRUD lifecycle belongs here while a business transition such as OrderPaid keeps inheriting - BaseDomainEvent directly - (MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/EntityChangedEvent.cs:16-18). + BaseDomainEvent directly (EntityChangedEvent.cs:15-19), and it also fixes the raise convention: + Added from factory methods, Updated from mutators, Deleted from Delete() + (EntityChangedEvent.cs:9-14).
  • Walkthrough: three positional members (CategoryChanged.cs:13-15), State, CategoryId, and Title; State and CategoryId are forwarded to the base constructor (line 16), and Title is the record's own added property, the human-readable descriptor a handler or log line can use without @@ -2036,8 +2166,9 @@

    EventQuestionAnswerChanged

  • Depends on: BaseDomainEvent (the base record) and the DomainEntityState enum, both from MMCA.Common.Domain; the module identifier aliases EventIdentifierType, - EventQuestionAnswerIdentifierType, and QuestionIdentifierType (BCL scalars behind a global using - alias, see the primer). No NuGet dependency.
  • + EventQuestionAnswerIdentifierType, and QuestionIdentifierType, all int behind a global using + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8-11, + see the primer). No NuGet dependency.
  • Concept introduced, the child-change domain event. [Rubric §6, CQRS & Event-Driven] (assesses whether state transitions are published as typed, first-class events that typed handlers can subscribe to, instead of leaking out as ad-hoc side effects) and [Rubric §4, DDD] (assesses whether the @@ -2073,9 +2204,11 @@

    EventQuestionAnswerChanged

    exactly what moved.
  • Where it's used: raised by Event's AddEventQuestionAnswer / UpdateEventQuestionAnswer / RemoveEventQuestionAnswer - (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:621, :645, :666); - collected on the aggregate and dispatched in-process by - DomainEventDispatcher after SaveChangesAsync.
  • + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:650, :674, :695, + declared at :637, :661, :684); collected on the aggregate, written to an outbox row by the save-changes + interceptor and dispatched in-process by + DomainEventDispatcher + (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:215-238).
  • Caveats / not-in-source: no dedicated IDomainEventHandler subscribes to it today (the Conference Application layer has handlers only for RoomChanged, SessionChanged, and SpeakerChanged); the event is raised and recorded regardless.
  • @@ -2090,7 +2223,9 @@

    EventSpeakerChanged

    entity is added or removed, that is, when a Speaker is attached to or detached from the event.
  • Depends on: BaseDomainEvent, DomainEntityState; aliases EventIdentifierType, - EventSpeakerIdentifierType, SpeakerIdentifierType.
  • + EventSpeakerIdentifierType, SpeakerIdentifierType (the last is System.Guid, not int, because + speakers carry Sessionize-assigned GUIDs, + MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:19).
  • Concept: the child-change domain event introduced by EventQuestionAnswerChanged, here for a join entity. [Rubric §6, CQRS & Event-Driven]. The XML doc says "added or removed" with no update case @@ -2099,9 +2234,12 @@

    EventSpeakerChanged

    sites use only Added and Deleted.
  • Walkthrough: State, EventId (parent), EventSpeakerId (the join row), SpeakerId (the linked speaker), lines 14-17.
  • -
  • Where it's used: raised by Event's AddEventSpeaker / RemoveEventSpeaker - (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:532, :568, :589); - dispatched in-process. No dedicated handler subscribes today.
  • +
  • Where it's used: raised by Event's AddEventSpeaker (:540), RestoreEventSpeaker + (:577), and RemoveEventSpeaker (:607) + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:561, :597, :618). + Note the restore path: un-deleting a soft-deleted join row raises Added again (:597), so a subscriber + sees the same transition it saw the first time and needs no separate "restored" case. No dedicated handler + subscribes today.

  • RoomChanged

    @@ -2119,11 +2257,15 @@

    RoomChanged

    subscriber, so it is the concrete sighting of the IDomainEventHandler<T> extension point: RoomChangedHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/DomainEventHandlers/RoomChangedHandler.cs:11-12) - implements IDomainEventHandler<RoomChanged> and branches on the State transition. Note the descriptor - choice: RoomName is a display label rather than an FK, so a log line or projection reads without a reload. + implements IDomainEventHandler<RoomChanged> and logs the transition with a source-generated + [LoggerMessage] that takes State, EventId, RoomId, and RoomName as structured fields + (RoomChangedHandler.cs:17-22). That is why the descriptor choice matters: RoomName is a display label + rather than an FK, so the log line (or a projection) reads without a reload. [Rubric §13, Observability & Operability] applies here too: the event payload is shaped so the handler can emit structured telemetry + without touching the database.
  • Walkthrough: State, EventId (parent), RoomId (child), RoomName (display label), lines 14-17.
  • -
  • Where it's used: raised by Event's AddRoom / UpdateRoom / RemoveRoom - (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:381, :419, :472, :493); +
  • Where it's used: raised by Event's AddRoom (:374), UpdateRoom (:411), RestoreRoom + (:454), and RemoveRoom (:511) + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:395, :433, :501, :522); consumed by RoomChangedHandler.

  • @@ -2145,7 +2287,8 @@

    SessionCategoryItemChanged

  • Walkthrough: sealed record class with State, SessionId, SessionCategoryItemId, CategoryItemId (SessionCategoryItemChanged.cs:13-17). Being a record, immutability and structural equality come for free; the primary-constructor parameters are the only state.
  • -
  • Where it's used: raised by Session's category-item add/remove methods +
  • Where it's used: raised by Session's AddSessionCategoryItem (:414), + RestoreSessionCategoryItem (:452), and RemoveSessionCategoryItem (:482) (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:435, :472, :493); captured by the outbox in SaveChangesAsync and dispatched in-process. No dedicated handler today.
  • @@ -2163,10 +2306,12 @@

    SessionQuestionAnswerChanged

    SessionQuestionAnswerIdentifierType, QuestionIdentifierType.
  • Concept: the child-change domain event (EventQuestionAnswerChanged). [Rubric §6, CQRS & Event-Driven]. The behavioral difference against a join event is the Updated state: - an answer's text can change in place (a join row cannot), and the raise sites use all three transitions.
  • + an answer's value can change in place (a join row cannot), so UpdateSessionQuestionAnswer exists + (Session.cs:536) and the raise sites use all three transitions.
  • Walkthrough: sealed record class with State, SessionId, SessionQuestionAnswerId, QuestionId (SessionQuestionAnswerChanged.cs:13-17).
  • -
  • Where it's used: raised by Session's question-answer methods +
  • Where it's used: raised by Session's AddSessionQuestionAnswer (:512), + UpdateSessionQuestionAnswer (:536), and RemoveSessionQuestionAnswer (:559) (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:525, :549, :570); captured by the outbox. Do not confuse it with SessionFeedbackSubmitted, the cross-module event the application layer @@ -2189,7 +2334,8 @@

    SessionSpeakerChanged

    and Deleted.
  • Walkthrough: sealed record class with State, SessionId, SessionSpeakerId, SpeakerId (SessionSpeakerChanged.cs:13-17).
  • -
  • Where it's used: raised by Session's speaker-association methods +
  • Where it's used: raised by Session's AddSessionSpeaker (:318), RestoreSessionSpeaker + (:355), and RemoveSessionSpeaker (:385) (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:339, :375, :396); captured by the outbox.
  • @@ -2212,8 +2358,9 @@

    SpeakerCategoryItemChanged

    relationship so handlers stay narrow.
  • Walkthrough: sealed record class with State, SpeakerId, SpeakerCategoryItemId, CategoryItemId (SpeakerCategoryItemChanged.cs:13-17).
  • -
  • Where it's used: raised by Speaker's category-item methods - (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:323, :360, :381); +
  • Where it's used: raised by Speaker's AddSpeakerCategoryItem (:314), + RestoreSpeakerCategoryItem (:352), and RemoveSpeakerCategoryItem (:382) + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:335, :372, :393); captured by the outbox.

  • @@ -2229,15 +2376,46 @@

    SpeakerQuestionAnswerChanged

    DomainEntityState; aliases SpeakerIdentifierType, SpeakerQuestionAnswerIdentifierType, QuestionIdentifierType.
  • Concept: the child-change domain event (EventQuestionAnswerChanged). - [Rubric §6, CQRS & Event-Driven]. As with the session answer, the answer text is mutable, so the raise + [Rubric §6, CQRS & Event-Driven]. As with the session answer, the answer value is mutable, so the raise sites span Added, Updated, and Deleted.
  • Walkthrough: sealed record class with State, SpeakerId, SpeakerQuestionAnswerId, QuestionId (SpeakerQuestionAnswerChanged.cs:13-17).
  • -
  • Where it's used: raised by Speaker's question-answer methods - (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:413, :437, :458); +
  • Where it's used: raised by Speaker's AddSpeakerQuestionAnswer (:412), + UpdateSpeakerQuestionAnswer (:436), and RemoveSpeakerQuestionAnswer (:459) + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:425, :449, :470); captured by the outbox.

  • +

    ActivityChanged

    +
    +

    MMCA.ADC.Conference.Domain · MMCA.ADC.Conference.Domain.Activities.DomainEvents · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/DomainEvents/ActivityChanged.cs:12 · Level 3 · record (sealed)

    +
    +
      +
    • What it is: the aggregate-root lifecycle event for an Activity, the non-session agenda item + (a keynote reception, a lunch break, a hallway track slot): raised when one is created, updated, or + soft-deleted. It carries the activity id and its display name.
    • +
    • Depends on: + EntityChangedEvent<TIdentifierType> (the + base record), DomainEntityState; alias + ActivityIdentifierType.
    • +
    • Concept: the aggregate-root lifecycle event, taught in detail under EventChanged + below. [Rubric §6, CQRS & Event-Driven] and [Rubric §16, Maintainability] (assesses whether a recurring + shape is factored once instead of copied). The instructive detail is a contrast: Activity + owns an EventId property + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:54), yet + ActivityChanged does not carry it, where the structurally similar SessionChanged + does. A subscriber that needs the parent event for an activity therefore has to reload it, which is a real + (if small) asymmetry in the event contracts of this bounded context rather than a rule you can infer.
    • +
    • Walkthrough: three positional members (ActivityChanged.cs:12-16): State, ActivityId, and Name, + with (State, ActivityId) forwarded to EntityChangedEvent<ActivityIdentifierType> on line 16.
    • +
    • Where it's used: raised from Activity's Create factory + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:127), its Update + method (:173), and its Delete override, which calls the base soft-delete first and raises the event only + when that base call returned success (:180-188); dispatched in-process.
    • +
    • Caveats / not-in-source: no IDomainEventHandler<ActivityChanged> is implemented today; the event is + raised and persisted to the outbox regardless.
    • +
    +

    EventChanged

    MMCA.ADC.Conference.Domain · MMCA.ADC.Conference.Domain.Events.DomainEvents · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/DomainEvents/EventChanged.cs:12 · Level 3 · record (sealed)

    @@ -2274,10 +2452,14 @@

    EventChanged

    serialized through the outbox, keeping the base shared also keeps their contract shape stable (ADR-010 governs the versioning rules for anything that crosses a boundary). -
  • Where it's used: raised from Event's Create / Update / Publish / Unpublish / Delete - (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:196, :251, :271, :291, - :341); dispatched in-process by - DomainEventDispatcher after SaveChangesAsync.
  • +
  • Where it's used: raised from Event's Create (:164), Update (:229), Publish + (:272), Unpublish (:292), and Delete (:328) + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:207, :265, :285, + :305, :355); dispatched in-process by + DomainEventDispatcher after SaveChangesAsync. Note + that publish and unpublish reuse the Updated transition rather than introducing dedicated event types, + which is the CRUD-lifecycle base doing its job: a subscriber that cares specifically about publication has + to compare the Event's own state, not the event type.

  • EventFeedbackSubmitted

    @@ -2296,11 +2478,15 @@

    EventFeedbackSubmitted

    things separate it from every event above. First, it derives from BaseIntegrationEvent, which adds a virtual SchemaVersion defaulting to 1 - (MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/BaseIntegrationEvent.cs:22) and implements - IIntegrationEvent, the marker that makes SaveChangesAsync leave its outbox row unprocessed so the - OutboxProcessor publishes it over the message bus instead of - dispatching it in process. Second, it lives in the .Shared project, not .Domain, precisely so a - subscribing module can reference the contract without pulling in Conference's domain model. + (MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/BaseIntegrationEvent.cs:32) and implements + IIntegrationEvent, the marker the save-changes interceptor branches on: an integration event still gets an + outbox row, but it is deliberately not dispatched in process, so its row stays unprocessed and the + OutboxProcessor publishes it over IMessageBus instead + (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:215-235 + and MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/OutboxProcessor.cs:510-521). + The registered transport then decides delivery: in-process for the monolith, MassTransit broker for the + extracted services. Second, it lives in the .Shared project, not .Domain, precisely so a subscribing + module can reference the contract without pulling in Conference's domain model. ADR-010 is the rule for evolving it: additive changes keep the version, a breaking change means a new type plus a consumer-side upcaster. @@ -2312,11 +2498,10 @@

    EventFeedbackSubmitted

  • Why it's built this way: the delivery semantics are the interesting part, and the XML doc states them (EventFeedbackSubmitted.cs:8-13). Event feedback is an upsert writing one row per form question (BR-107), so one submitted form raises this event once per newly created answer, and only on the create path: the - update branch of the same handler raises nothing - (AddEventQuestionAnswerHandler.cs:87-93 versus :102-116). Because at-least-once outbox delivery and a - multi-question form both mean the consumer can see the message more than once, the consumer is idempotent on - its own side: it collapses everything onto one subject key and lets the awarder's uniqueness rule reject the - duplicates + update branch of the same handler raises nothing (AddEventQuestionAnswerHandler.cs:81-94 for the update + path versus :96-117 for the create path). Because at-least-once outbox delivery and a multi-question form + both mean the consumer can see the message more than once, the consumer is idempotent on its own side: it + collapses everything onto one subject key and lets the awarder's uniqueness rule reject the duplicates (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/EventFeedbackSubmittedPointsHandler.cs:38-45). That is the standard posture for ADR-003: the producer guarantees the fact was recorded atomically with the data, the consumer guarantees the effect @@ -2326,7 +2511,7 @@

    EventFeedbackSubmitted

    (:112), so the outbox captures it in the same SaveChangesAsync; consumed by EventFeedbackSubmittedPointsHandler, registered as a broker consumer in the Engagement service host - (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:301).
  • + (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:307).

    QuestionChanged

    @@ -2344,7 +2529,8 @@

    QuestionChanged

  • Walkthrough: sealed record class QuestionChanged(DomainEntityState State, QuestionIdentifierType QuestionId, string QuestionText) forwarding (State, QuestionId) to EntityChangedEvent<QuestionIdentifierType> (QuestionChanged.cs:12-16).
  • -
  • Where it's used: raised from Question's create, update, and delete paths +
  • Where it's used: raised from Question's Create (:70), Update (:108), and Delete + (:135) paths (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/Question.cs:94, :128, :140); dispatched in-process. No dedicated handler subscribes today.
  • @@ -2362,14 +2548,16 @@

    SessionChanged

    EventIdentifierType.
  • Concept: the aggregate-root lifecycle event (EventChanged). [Rubric §6, CQRS & Event-Driven]. It is the one root event that carries a second identifier, the parent - EventId, in addition to Title, so a subscriber knows which event's schedule moved (useful for + EventId (line 17), in addition to Title, so a subscriber knows which event's schedule moved (useful for invalidating that event's session list rather than the whole cache). This is also where the State filter earns its keep: SessionCreatedHandler subscribes to the single type and returns early unless State is Added - (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/DomainEventHandlers/SessionCreatedHandler.cs:17-20).
  • + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/DomainEventHandlers/SessionCreatedHandler.cs:17-20), + then logs SessionId, Title, and EventId as structured fields (:24-25).
  • Walkthrough: sealed record class SessionChanged(DomainEntityState State, SessionIdentifierType SessionId, string Title, EventIdentifierType EventId) chaining (State, SessionId) to the base (SessionChanged.cs:13-18).
  • -
  • Where it's used: raised by Session's lifecycle methods +
  • Where it's used: raised by Session's Create (:163), Update (:229), and Delete + (:277) (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:206, :267, :304); consumed by SessionCreatedHandler.
  • @@ -2399,8 +2587,10 @@

    SessionFeedbackSubmitted

    (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerHandler.cs:134), with the timestamp taken from the injected TimeProvider; consumed by SessionFeedbackSubmittedPointsHandler, - registered as a broker consumer in the Engagement service host - (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:300). + which resolves it onto a session subject key + (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/SessionFeedbackSubmittedPointsHandler.cs:37-40) + and is registered as a broker consumer in the Engagement service host + (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:306).

    SpeakerChanged

    @@ -2423,10 +2613,12 @@

    SpeakerChanged

    can perform the BR-70 cross-context cleanup after the entity's own link field has been nulled.
  • Walkthrough: sealed record class SpeakerChanged(DomainEntityState State, SpeakerIdentifierType SpeakerId, string FullName, UserIdentifierType? PreviousLinkedUserId = null) chaining (State, SpeakerId) to the base (SpeakerChanged.cs:16-21). The default null on the fourth - parameter is what keeps the non-delete raise sites a three-argument call - (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:156, :223, :272, - :290), while the delete path passes the captured value - (Speaker.cs:251).
  • + parameter is what keeps the non-delete raise sites a three-argument call: Create (:168), Update + (:235), LinkUser (:284), and UnlinkUser (:302) + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs), while the delete path + is the only four-argument call and passes the captured value (Speaker.cs:263). Note that LinkUser and + UnlinkUser both emit Updated, not a bespoke link event, so a subscriber cannot tell a link change from a + name edit by event type alone.
  • Why it's built this way: an event is an immutable record of what already happened, so snapshotting the prior link onto the event avoids a lost-update race in which the cleanup handler would read an already-cleared field. It also decouples the delete transaction from the downstream unlink, which crosses a @@ -2434,7 +2626,8 @@

    SpeakerChanged

  • Where it's used: consumed by SpeakerDeletedHandler, which ignores every transition except Deleted, then publishes - SpeakerUnlinkedFromUser through IEventBus when PreviousLinkedUserId has a + SpeakerUnlinkedFromUser through + IEventBus when PreviousLinkedUserId has a value, from a fresh DI scope because the handler is a singleton (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/DomainEventHandlers/SpeakerDeletedHandler.cs:29-45). Identity then clears User.LinkedSpeakerId.
  • @@ -2451,13 +2644,14 @@

    SponsorChanged

    EntityChangedEvent<TIdentifierType>, DomainEntityState; alias SponsorIdentifierType.
  • Concept: the aggregate-root lifecycle event (EventChanged). - [Rubric §6, CQRS & Event-Driven] and [Rubric §16, Maintainability]. Sponsors are the newest aggregate in - this bounded context, and the fact that its event is a three-line record derived from the same base is the - payoff of the shared shape: a new aggregate gets the full lifecycle-event story without inventing anything.
  • + [Rubric §6, CQRS & Event-Driven] and [Rubric §16, Maintainability]. Sponsors are among the newest + aggregates in this bounded context, and the fact that its event is a three-line record derived from the same + base is the payoff of the shared shape: a new aggregate gets the full lifecycle-event story without + inventing anything.
  • Walkthrough: three positional members (SponsorChanged.cs:12-16): State, SponsorId, and Name, with (State, SponsorId) forwarded to EntityChangedEvent<SponsorIdentifierType> on line 16.
  • Where it's used: raised from Sponsor's Create factory - (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:133), its update path + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:133), its Update path (:183), and its Delete override, which calls the base soft-delete first and raises the event only when that base call returned success (:190-198); dispatched in-process. No dedicated handler subscribes today.
  • @@ -2492,9 +2686,11 @@

    SpeakerLinkedToUser

    ADR-003; it lets Identity and Conference run as separate services with no shared database and no cross-database FK (ADR-006). -
  • Where it's used: published by the Conference link-command handler (Application tier, Group 18) and - by UserRegisteredHandler's auto-link path; - consumed on the Identity side.
  • +
  • Where it's used: raised by + LinkUserToSpeakerHandler + (LinkUserToSpeakerHandler.cs:54) and, on the auto-link path, by + UserRegisteredHandler + (UserRegisteredHandler.cs:77 and :96); consumed on the Identity side.
  • SpeakerUnlinkedFromUser

    @@ -2515,11 +2711,63 @@

    SpeakerUnlinkedFromUser

    (SpeakerUnlinkedFromUser.cs:9-13).
  • Why it's built this way: it closes the loop on the eventually-consistent link, and it is the downstream half of a SpeakerChanged delete. That is exactly why - Speaker.Delete snapshots the previous link id onto the domain event before clearing the - field (Speaker.cs:242): the handler that publishes this integration event would otherwise have nothing - left to read.
  • -
  • Where it's used: published by the Conference unlink-command handler and by the speaker-delete - cleanup path; consumed on the Identity side.
  • + Speaker.Delete snapshots the previous link id into a local before base.Delete() + runs (Speaker.cs:253-254): the handler that publishes this integration event would otherwise have + nothing left to read. +
  • Where it's used: raised by + UnlinkUserFromSpeakerHandler + (UnlinkUserFromSpeakerHandler.cs:42) and by the speaker-delete cleanup path in + SpeakerDeletedHandler + (SpeakerDeletedHandler.cs:43); consumed on the Identity side.
  • + +

    ActivityInvariants

    +
    +

    MMCA.ADC.Conference.Domain · MMCA.ADC.Conference.Domain.Activities · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/ActivityInvariants.cs:10 · Level 6 · class (static)

    +
    +
      +
    • What it is: the domain rules for the Activity aggregate: a required name, three + optional venue fields that are length-checked only, and a start-before-end time range check. The + length constants declared here are read by both domain validation and the EF configuration, so a + column width and a domain rule cannot silently diverge (ActivityInvariants.cs:6-9).
    • +
    • Depends on: CommonInvariants + (ActivityInvariants.cs:1), Result and + Error (:2); BCL DateTime.
    • +
    • Concept: the static-invariants-class pattern introduced for the framework at + CommonInvariants and shown for a Conference + aggregate at SessionInvariants. [Rubric §4, Domain-Driven Design]: the rules + live in the domain rather than in a handler or a validator. What this particular class teaches, which + its siblings do not, is the optional field as a first-class domain concept: three of its five rule + methods short-circuit to Result.Success() when the value is null or empty rather than failing. The + doc on EnsureVenueNameIsValid states the reason plainly (:38-41): an empty venue name means the + activity happens at the main conference venue, so absence is a meaningful value, not missing data.
    • +
    • Walkthrough
        +
      • Length constants (ActivityInvariants.cs:13-25), all public const int: NameMaxLength (200), + DescriptionMaxLength (2000), VenueNameMaxLength (200), VenueAddressMaxLength (500, chosen to + match the event venue address per the doc at :21), and VenueUrlMaxLength (2000).
      • +
      • EnsureNameIsValid (:33-36): the standard Result.Combine of + CommonInvariants.EnsureStringIsNotEmpty plus CommonInvariants.EnsureStringMaxLength, tagged with + the stable codes Activity.Name.Empty and Activity.Name.TooLong.
      • +
      • EnsureVenueNameIsValid (:45-48), EnsureVenueAddressIsValid (:57-60), and + EnsureVenueUrlIsValid (:69-72): each is a single expression, string.IsNullOrEmpty(x) ? Result.Success() : CommonInvariants.EnsureStringMaxLength(...). Note what is deliberately absent for + the URL: no scheme parse, no reachability check. The doc (:62-65) records that the value is stored + as an opaque string with no fetch or upload pipeline behind it, matching the sponsor website-URL + precedent, so only the storage constraint is enforced.
      • +
      • EnsureTimeRangeIsValid (:82-89): fails with Error.Invariant("Activity.TimeRange.Invalid") + when endTime < startTime. The doc (:74-77) explains why the comparison is a plain one: both + values are event-local wall times, and the IANA zone lives on the owning Event, never + repeated per row. A zero-length activity is allowed; only an inverted range is rejected.
      • +
      +
    • +
    • Why it's built this way: pushing the "absent is legal" decision into the invariant, rather than + into every caller, means a handler cannot accidentally require a venue name and the EF column cannot + accidentally be narrower than the rule. Comparing naive wall times instead of instants keeps the domain + free of time-zone conversion, which belongs where the zone is known.
    • +
    • Where it's used: Activity.Create and Activity.Update + (Activity.cs:111-116 and :155-160); the length constants feed + ActivityConfiguration + (ActivityConfiguration.cs:20, :24, :36, :40, :44) and the application-layer rule types + ActivityNameRules<T> and its siblings + (ActivityValidationRules.cs:17-66).

    Category

    @@ -2598,7 +2846,9 @@

    Category

  • Where it's used: loaded through IReadRepository<TEntity, TIdentifierType>, - mutated by the Conference category command handlers (Group 18), and projected for the category UI.

    + mutated by the Conference category command handlers (Group 18), persisted through + ConferenceCategoryConfiguration, + and projected for the category UI.

  • CategoryInvariants

    @@ -2613,25 +2863,28 @@

    CategoryInvariants

    lower layer it delegates to), Result, Error, CategoryItem (it takes the child collection as a parameter); BCL CultureInfo. -
  • Concept: the module invariants class taught at EventInvariants. The - distinctive method here, which the simpler invariant classes lack, is the collection-aware uniqueness - guard. [Rubric §4, Domain-Driven Design]: the ubiquitous-language rule "an item name is unique - within its category" is expressed directly in the domain rather than deferred to a database index or a - UI check.
  • +
  • Concept: the module invariants class (see SessionInvariants and + EventInvariants). The distinctive method here, which the simpler invariant classes + lack, is the collection-aware uniqueness guard. [Rubric §4, Domain-Driven Design]: the + ubiquitous-language rule "an item name is unique within its category" is expressed directly in the + domain rather than deferred to a database index or a UI check.
  • Walkthrough
    • TitleMaxLength (255) and CategoryItemNameMaxLength (500) at CategoryInvariants.cs:14 and :17. - Note these are public static readonly int here rather than the const int used by the other + Note these are public static readonly int here rather than the public const int used by the other invariant classes in this chapter; both still feed the EF column widths.
    • -
    • EnsureTitleIsValid (:19) and EnsureCategoryItemNameIsValid (:24): each a Result.Combine of - CommonInvariants.EnsureStringIsNotEmpty plus CommonInvariants.EnsureStringMaxLength, with the +
    • EnsureTitleIsValid (:19-22) and EnsureCategoryItemNameIsValid (:24-27): each a Result.Combine + of CommonInvariants.EnsureStringIsNotEmpty plus CommonInvariants.EnsureStringMaxLength, with the message built through string.Create(CultureInfo.InvariantCulture, ...) so the text does not vary by - ambient culture.
    • + ambient culture. That call is needed here and not in the sibling classes precisely because the length + is a static readonly int rather than a compile-time constant, so the interpolation is evaluated at + run time.
    • EnsureCategoryItemNameIsUnique (:37-58): takes the existing item collection plus an optional excludeItemId (so renaming an item to its own name during an update does not self-conflict). It - skips IsDeleted items and compares with StringComparison.OrdinalIgnoreCase (:47-49), returning - Error.Conflict on a duplicate (:52). The inline comment at :43-45 records why the exclusion is - modeled as a nullable rather than defaulted: defaulting to default(id) would silently exclude every - unsaved sibling, since a database-generated CategoryItem id is 0 until the save.
    • + skips IsDeleted items and compares with StringComparison.OrdinalIgnoreCase (:46-49), returning + Error.Conflict("CategoryItem.Name.Duplicate") on a duplicate (:52-56). The inline comment at + :43-45 records why the exclusion is modeled as a nullable rather than defaulted: defaulting to + default(id) would silently exclude every unsaved sibling, since a database-generated CategoryItem + id is 0 until the save.
  • Why it's built this way: co-locating the rules per aggregate keeps the entity itself readable, and @@ -2640,7 +2893,9 @@

    CategoryInvariants

    lets the aggregate call it in memory.
  • Where it's used: called from Category's Create, Update, AddCategoryItem, and UpdateCategoryItem, and from CategoryItem's Create and Update; the length - constants are read by the Categories EF configuration.
  • + constants are read by the Categories EF configurations + (ConferenceCategoryConfiguration, + CategoryItemConfiguration).

    CategoryItem

    @@ -2681,7 +2936,8 @@

    CategoryItem

    caller cannot bypass the parent's uniqueness and cascade rules by reaching in and calling categoryItem.Update(...) directly. The parent method is the only path that also runs BR-138.
  • Where it's used: loaded through Category (EF Include or the navigation populator); - referenced by SpeakerCategoryItem as the target of that many-to-many bridge.
  • + referenced by SpeakerCategoryItem and + SessionCategoryItem as the target of those many-to-many bridges.

    EventInvariants

    @@ -2696,36 +2952,41 @@

    EventInvariants

    Result, Error; BCL TimeZoneInfo and DateOnly. Alias RoomIdentifierType. -
  • Concept introduced, the module invariants class. [Rubric §4, Domain-Driven Design] (invariants - live in the domain, expressed as reusable named rules rather than inline if blocks) and [Rubric §8, Data Architecture] (the MaxLength constants are the single source of truth shared by the EF column - configuration and by validation, keeping schema and rule in sync). This is the same static-invariants - idiom taught for value objects in - Group 02, applied to an aggregate: each +
  • Concept: the module invariants class, the same idiom taught for the framework at + CommonInvariants and for a Conference aggregate + at SessionInvariants, here in its widest form: fifteen length constants, a + reserved id range, and six rule methods covering a root plus two children. [Rubric §4, Domain-Driven Design] (invariants live in the domain, expressed as reusable named rules rather than inline if + blocks) and [Rubric §8, Data Architecture] (the MaxLength constants are the single source of truth + shared by the EF column configuration and by validation, keeping schema and rule in sync). Each Ensure... returns a Result rather than throwing, and callers combine several through Result.Combine.
  • Walkthrough, in teaching order:
      -
    • Length constants (EventInvariants.cs:13-52), all public const int: NameMaxLength (500), +
    • Length constants (EventInvariants.cs:13-55), all public const int: NameMaxLength (500), DescriptionMaxLength (4000), TimeZoneMaxLength (100), SessionizeCodeMaxLength (100), VenueAddressMaxLength (500), VenueMapUrlMaxLength (2000), WiFiInfoMaxLength (500), - OrganizerContactEmailMaxLength (255, :34), SponsorshipPacketUrlMaxLength (2000, :37), the four - room limits (RoomNameMaxLength 255, RoomFloorMaxLength 100, RoomLocationMaxLength 255, - RoomAccessibilityInfoMaxLength 500), and AnswerValueMaxLength (4000).
    • -
    • Reserved id range (EventInvariants.cs:54-62): RoomManualIdRangeStart (999_999_000) and - RoomManualIdRangeEnd (999_999_999). Room ids are app-assigned, the int PK is the Sessionize id, - so organizer-created rooms draw from this reserved high range and never collide with a real Sessionize - id. The comment notes it mirrors SessionInvariants.ManualIdRangeStart.
    • -
    • EnsureNameIsValid (:64): a Result.Combine of a not-empty and a max-length check delegated to - CommonInvariants.
    • -
    • EnsureTimeZoneIsValid (:75-104): not-empty, then max-length, then + OrganizerContactEmailMaxLength (255, :34), SponsorshipPacketUrlMaxLength (2000, :37), + TicketingUrlMaxLength (2000, :40), the four room limits (RoomNameMaxLength 255, + RoomFloorMaxLength 100, RoomLocationMaxLength 255, RoomAccessibilityInfoMaxLength 500), and + AnswerValueMaxLength (4000, :55).
    • +
    • Reserved id range (EventInvariants.cs:57-65): RoomManualIdRangeStart (999_999_000) and + RoomManualIdRangeEnd (999_999_999), both static readonly RoomIdentifierType. Room ids are + app-assigned, the int PK is the Sessionize id, so organizer-created rooms draw from this reserved + high range and never collide with a real Sessionize id. The comment notes it mirrors + SessionInvariants.ManualIdRangeStart.
    • +
    • EnsureNameIsValid (:67-70): a Result.Combine of a not-empty and a max-length check delegated + to CommonInvariants.
    • +
    • EnsureTimeZoneIsValid (:78-107): an explicit IsNullOrWhiteSpace guard, then max-length, then TimeZoneInfo.FindSystemTimeZoneById inside a try/catch that maps TimeZoneNotFoundException to an Event.TimeZone.Invalid invariant error (BR-87). The BCL is the authority on what counts as a - valid IANA identifier.
    • -
    • EnsureDateRangeIsValid (:113): fails with Event.DateRange.Invalid when endDate < startDate.
    • -
    • EnsureRoomCapacityIsValid (:128): rejects a non-positive capacity when one is supplied, written - as the pattern capacity is <= 0 so a null capacity passes (BR-93).
    • -
    • EnsureRoomNameIsValid (:137) and EnsureAnswerValueIsValid (:142): not-empty plus - max-length pairs for the two children.
    • -
    • EnsureEventIsPublished (:153): guards actions that require a published event (BR-108).
    • + valid IANA identifier; the domain carries no zone table of its own. +
    • EnsureDateRangeIsValid (:116-123): fails with Event.DateRange.Invalid when + endDate < startDate, so a single-day event (equal dates) is legal.
    • +
    • EnsureRoomCapacityIsValid (:131-138): rejects a non-positive capacity when one is supplied, + written as the pattern capacity is <= 0 so a null capacity passes (BR-93).
    • +
    • EnsureRoomNameIsValid (:140-143) and EnsureAnswerValueIsValid (:145-148): not-empty + plus max-length pairs for the two children.
    • +
    • EnsureEventIsPublished (:156-163): guards actions that require a published event (BR-108), + failing with Event.NotPublished.
  • Why it's built this way: keeping the length limits as constants on the invariants class, and having @@ -2734,10 +2995,15 @@

    EventInvariants

    of throwing keeps validation composable at the factory, where several checks are combined into one error list.
  • Where it's used: the Event, Room, and - EventQuestionAnswer factories and updaters call these; the Events EF - configuration reads the length constants.
  • -
  • Caveats / not-in-source: the reserved room-id range is defined here but nothing in this file assigns - from it. Which caller draws the next manual room id is not determinable from this source file.
  • + EventQuestionAnswer factories and updaters call these; the length constants are + read by EventConfiguration and + RoomConfiguration and by the + application-layer event and room validation rules. The reserved room-id range is consumed in two places: + AddRoomHandler allocates the next free id from it + and refuses once it is exhausted (AddRoomHandler.cs:96-104), and + RoomSyncStrategy skips any Sessionize room whose + id falls inside it, recording a warning rather than importing a colliding row + (RoomSyncStrategy.cs:95-97).

    QuestionInvariants

    @@ -2755,30 +3021,32 @@

    QuestionInvariants

    richer. [Rubric §4, Domain-Driven Design]: the closed value sets and the answer rules are expressed as domain logic, not as API or UI validation. The permitted values are held as data rather than as long switch statements: ValidQuestionEntities, ValidQuestionTypes, and ValidQuestionSources are - private static readonly string[] (QuestionInvariants.cs:28-34) checked with + private static readonly string[] (QuestionInvariants.cs:28, :31, :34) checked with StringComparer.OrdinalIgnoreCase.
  • Walkthrough
    • Length constants (QuestionInvariants.cs:13-25): QuestionTextMaxLength (1000), the three 20-char - discriminator limits, and TextAnswerMaxLength (2000).
    • -
    • The user-created id range ManualIdRangeStart / ManualIdRangeEnd (:37, :40, both 999_999_000 to - 999_999_999), distinguishing Sessionize ids from user-created ones.
    • -
    • EnsureQuestionTextIsValid (:48): an explicit IsNullOrWhiteSpace guard first, then max-length via - CommonInvariants.EnsureStringMaxLength.
    • -
    • EnsureQuestionEntityIsValid (:68), EnsureQuestionTypeIsValid (:83), and - EnsureQuestionSourceIsValid (:98): membership tests against the closed arrays, each returning a + discriminator limits (QuestionEntityMaxLength, QuestionTypeMaxLength, QuestionSourceMaxLength), + and TextAnswerMaxLength (2000).
    • +
    • The user-created id range ManualIdRangeStart / ManualIdRangeEnd (:37, :40, 999_999_000 to + 999_999_999), distinguishing Sessionize ids from user-created ones, the same device + SessionInvariants and EventInvariants use.
    • +
    • EnsureQuestionTextIsValid (:48-60): an explicit IsNullOrWhiteSpace guard first, then max-length + via CommonInvariants.EnsureStringMaxLength.
    • +
    • EnsureQuestionEntityIsValid (:68-75), EnsureQuestionTypeIsValid (:83-90), and + EnsureQuestionSourceIsValid (:98-105): membership tests against the closed arrays, each returning a specific Error.Invariant code.
    • EnsureAnswerValueMatchesQuestionType (:115-126): a switch expression on questionType dispatching to three private validators, because what counts as a valid answer depends on the question's type:
        -
      • ValidateRatingAnswer (:128): int.TryParse with NumberStyles.Integer and +
      • ValidateRatingAnswer (:128-140): int.TryParse with NumberStyles.Integer and CultureInfo.InvariantCulture, requiring 1 to 5, otherwise Error.Validation. The invariant culture is deliberate: a rating must parse identically wherever the request originates.
      • -
      • ValidateTextAnswer (:142): length must not exceed TextAnswerMaxLength (2000).
      • -
      • ValidateEmailAnswer (:156): constructs a System.Net.Mail.MailAddress and treats a +
      • ValidateTextAnswer (:142-154): length must not exceed TextAnswerMaxLength (2000).
      • +
      • ValidateEmailAnswer (:156-171): constructs a System.Net.Mail.MailAddress and treats a FormatException as invalid, again letting the BCL be the format authority.
      • -
      • An unrecognized type falls through to Error.Invariant("Question.QuestionType.Unknown") (:121). - Note the dispatch is an ordinal switch on the literal strings, so it is case-sensitive here even - though EnsureQuestionTypeIsValid accepts any casing.
      • +
      • An unrecognized type falls through to Error.Invariant("Question.QuestionType.Unknown") + (:121-125). Note the dispatch is an ordinal switch on the literal strings, so it is + case-sensitive here even though EnsureQuestionTypeIsValid accepts any casing.
    @@ -2787,38 +3055,8 @@

    QuestionInvariants

    malformed rating or email before it can reach a handler or the database, and expressing the allowed sets as arrays keeps adding a new question type a one-line data change rather than a code restructure.
  • Where it's used: called from Question's Create and Update; the answer-matching - rule is used by the answer-recording handlers in the Application tier; the length constants feed the - Questions EF configuration.
  • - -

    SpeakerInvariants

    -
    -

    MMCA.ADC.Conference.Domain · MMCA.ADC.Conference.Domain.Speakers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/SpeakerInvariants.cs:10 · Level 6 · class (static)

    -
    -
      -
    • What it is: the domain rules for the Speaker aggregate: first-name, last-name, and - answer-value non-empty and length constraints, plus the length constants for every profile field.
    • -
    • Depends on: CommonInvariants, - Result.
    • -
    • Concept: cross-reference EventInvariants for the pattern. This is the simplest - sibling in the family: no cross-field checks, no type dispatch. The bulk of the class is length - constants for the rich speaker profile (SpeakerInvariants.cs:13-40): FirstNameMaxLength and - LastNameMaxLength (200), EmailMaxLength (255), TagLineMaxLength (500), - TwitterHandleMaxLength (100), the four URL fields at 2000 (ProfilePictureMaxLength, - LinkedInUrlMaxLength, GitHubUrlMaxLength, WebsiteUrlMaxLength), and AnswerValueMaxLength (4000). - All are public const int and are read by the Speakers EF configuration so column widths stay in sync.
    • -
    • Walkthrough: three Ensure... methods (SpeakerInvariants.cs:42-55), each a Result.Combine of - CommonInvariants.EnsureStringIsNotEmpty plus CommonInvariants.EnsureStringMaxLength: - EnsureFirstNameIsValid (:42), EnsureLastNameIsValid (:47), and EnsureAnswerValueIsValid - (:52, using AnswerValueMaxLength). Note what is absent: email is not validated here even though - EmailMaxLength is declared. The Speaker factory parses it through the - Email value object instead (Speaker.cs:125), so format - correctness is the value object's responsibility and the constant exists only to size the column.
    • -
    • Why it's built this way: the split is a deliberate division of labor. Rules that are genuinely - speaker-specific live here; anything that is a reusable concept in its own right (a well-formed email) - becomes a value object that any module can hold.
    • -
    • Where it's used: Speaker's Create and Update, and - SpeakerQuestionAnswer's Create and UpdateAnswer; the length constants - feed the Speakers EF configuration.
    • + rule is used by the answer-recording handlers in the Application tier; the length constants feed + QuestionConfiguration.

    Event

    @@ -2853,70 +3091,85 @@

    Event

  • Selective auditing. Event implements IAuditedEntity (Event.cs:23). The class doc (Event.cs:16-20) states the reason as a cost-benefit judgment rather than a blanket policy: the - event record is the schedule everything else hangs off, several organizers edit it, and a wrong date - or venue is felt by every attendee, so one trail row per change is worth it.
  • + event record is the schedule everything else hangs off, several organizers edit it, and a wrong date, + venue or live window is felt by every attendee, so one trail row per change is worth it.
  • Selective navigation. Rooms and EventSpeakers are marked [Navigation(IsCollection = true)] - (Event.cs:82, :88) but EventQuestionAnswers deliberately is not (Event.cs:93-103). + (Event.cs:88, :94) but EventQuestionAnswers deliberately is not (Event.cs:99-109). [Rubric §12, Performance & Scalability]: the remarks record that the collection grows with - attendance rather than with the schedule, and that it was riding along on public reads that never - render it. Handlers that genuinely need it pass an explicit includes: list instead.
  • + attendance rather than with the schedule, that it rode along on public reads that never render it, + and that it is per-attendee feedback behind an anonymous endpoint. Handlers that genuinely need it + pass an explicit includes: list instead.
  • Walkthrough, in teaching order:
      -
    • [IdValueGenerated] on the class (Event.cs:22): the factory reads this at runtime through - typeof(Event).IsIdValueGenerated (Event.cs:177).
    • -
    • Scalar state (Event.cs:25-77): Name, Description?, StartDate/EndDate (DateOnly), +
    • [IdValueGenerated] on the class (Event.cs:22): the factory reads this at run time through + typeof(Event).IsIdValueGenerated (Event.cs:187).
    • +
    • Scalar state (Event.cs:25-83): Name, Description?, StartDate/EndDate (DateOnly), TimeZone, SessionizeCode?, VenueAddress?, VenueMapUrl?, WiFiInfo?, OrganizerContactEmail? (:56, falling back to the host-configured support address when absent), SponsorshipPacketUrl? (:62, whose absence hides the sponsorship call to action entirely), - IsPublished, QuestionModerationDefault (:71, the BR-233 initial status a newly submitted - live-layer question receives), and the nullable LastSessionizeRefreshOn/LastSessionizeRefreshBy - refresh-audit pair. All have private setters.
    • -
    • Child collections (Event.cs:79-103): three private List<T> backing fields exposed as + TicketingUrl? (:68, whose absence likewise hides the ticketing call to action on the landing and + public event pages), IsPublished, QuestionModerationDefault (:77, the BR-233 initial status a + newly submitted live-layer question receives), and the nullable + LastSessionizeRefreshOn/LastSessionizeRefreshBy refresh-audit pair. All have private setters.
    • +
    • Child collections (Event.cs:85-109): three private List<T> backing fields exposed as IReadOnlyCollection<T> projections.
    • -
    • Constructors (Event.cs:106-136): a private parameterless EF constructor that seeds the - non-nullable strings, plus a private field constructor used by the factory.
    • -
    • Create (Event.cs:155-199): combines EnsureNameIsValid, EnsureTimeZoneIsValid, and - EnsureDateRangeIsValid (:170-173); on success builds the instance with - Id = isIdValueGenerated ? default : id!.Value (:192), sets QuestionModerationDefault, and raises - EventChanged(Added) (:196).
    • -
    • Update (Event.cs:217): re-validates the same three invariants, writes the scalars including the - moderation default, raises EventChanged(Updated).
    • -
    • Publish and Unpublish (Event.cs:258, :278): flip IsPublished, refusing a no-op +
    • Constructors (Event.cs:112-144): a private parameterless EF constructor that seeds the + non-nullable strings, plus a private twelve-parameter field constructor used by the factory.
    • +
    • Create (Event.cs:164-210): combines EnsureNameIsValid, EnsureTimeZoneIsValid, and + EnsureDateRangeIsValid (:180-183); on success builds the instance with + Id = isIdValueGenerated ? default : id!.Value (:203) and sets QuestionModerationDefault (:204, + defaulted to QuestionModerationDefault.Pending at the parameter, :175), then raises + EventChanged(Added) (:207).
    • +
    • Update (Event.cs:229-268): re-validates the same three invariants (:244-247), writes the + scalars including the moderation default and the optional email and two URLs, raises + EventChanged(Updated) (:265).
    • +
    • Publish and Unpublish (Event.cs:272, :292): flip IsPublished, refusing a no-op transition with Event.AlreadyPublished / Event.AlreadyUnpublished, and raise EventChanged(Updated).
    • -
    • RecordSessionizeRefresh (Event.cs:302): stamps LastSessionizeRefreshOn/By from a - caller-supplied UTC instant. The parameter doc (:298-301) is explicit that the value comes from an +
    • RecordSessionizeRefresh (Event.cs:316-320): stamps LastSessionizeRefreshOn/By from a + caller-supplied UTC instant. The parameter doc (:312-315) is explicit that the value comes from an injected TimeProvider so the domain never reads an ambient clock. [Rubric §14, Testability]. Note this method returns void and raises no event.
    • -
    • Delete (Event.cs:314-345): overrides the base soft-delete, then cascade soft-deletes every - non-deleted room, event-speaker, and answer (BR-72), and raises EventChanged(Deleted). Session - cascade is deliberately not here: it is handled a layer up (BR-127) because sessions are separate - aggregates, which is what +
    • Delete (Event.cs:328-359): overrides the base soft-delete, then cascade soft-deletes every + non-deleted room, event-speaker, and answer (BR-72, :334-353), and raises EventChanged(Deleted). + Session cascade is deliberately not here: it is handled a layer up (BR-127) because sessions are + separate aggregates, which is what IEventCascadeDeletionDomainService exists for.
    • -
    • Room management (Event.cs:360-501): AddRoom (:360) checks name uniqueness first, delegates to - Room.Create, adds, and raises RoomChanged(Added); UpdateRoom (:397) resolves the child, - re-checks uniqueness excluding itself, and delegates; RestoreRoom (:439) is the BR-135 reactivation - path, taking the room instance rather than an id because a soft-deleted row is excluded by the - global query filter and so is not reachable through the loaded collection (:430-434). It re-runs the - uniqueness bar, calls room.Update before room.Reactivate() so a rejected name leaves the room - untouched and still deleted rather than half-restored (:459-467), and raises RoomChanged(Added) - because the room re-enters the visible set. RemoveRoom (:482) soft-deletes and raises +
    • Room management (Event.cs:374-529): AddRoom (:374) checks name uniqueness first, delegates to + Room.Create, adds, and raises RoomChanged(Added); UpdateRoom (:411) resolves the child, + re-checks uniqueness excluding itself, and delegates. RestoreRoom (:454) is the BR-135 + reactivation path and the most defensive method on the type. It takes the room instance rather + than an id because a soft-deleted row is excluded by the global query filter and so is not reachable + through the loaded collection (:442-447), and it then runs its guards in order: the room must belong + to this event (:463, Event.Room.WrongEvent), whose comment at :456-460 explains the stakes + precisely (Room.EventId has no setter and is populated purely by EF relationship fixup off this + Rooms navigation, so adding a foreign room here would silently rewrite its EventId on save and + move the row out of its real event); the room must actually be soft-deleted (:472, + Event.Room.NotDeleted); the incoming name must clear the same uniqueness bar as an add (:484, + with the comment at :481-483 noting that otherwise a Sessionize refresh restoring a room whose name + an organizer has since reused would fail on the database index and abort the whole refresh); and only + then room.Update runs before room.Reactivate() (:490, :494) so a rejected name leaves the + room untouched and still deleted rather than half-restored. It raises RoomChanged(Added) (:501) + because the room re-enters the visible set. RemoveRoom (:511) soft-deletes and raises RoomChanged(Deleted).
    • -
    • Event-speaker management (Event.cs:511-597): AddEventSpeaker (:511) guards duplicates in - memory (:515) with Event.Speaker.Duplicate; RestoreEventSpeaker (:548) is the join-entity - counterpart to RestoreRoom and needs no field re-apply because the join carries no organizer-entered - data (:541-545); RemoveEventSpeaker (:578) soft-deletes.
    • -
    • Answer management (Event.cs:608-674): AddEventQuestionAnswer, UpdateEventQuestionAnswer, - RemoveEventQuestionAnswer. Unlike the two collections above, the add has no duplicate guard: an - event answering the same question twice is not blocked in the domain.
    • -
    • Populator hooks (Event.cs:500, :596, :673): SetRooms, SetEventSpeakers, and +
    • Event-speaker management (Event.cs:540-626): AddEventSpeaker (:540) guards duplicates in + memory (:544) with Event.Speaker.Duplicate; RestoreEventSpeaker (:577) is the join-entity + counterpart to RestoreRoom, keeping the not-deleted guard (:581, Event.Speaker.NotDeleted) but + needing no field re-apply and no uniqueness re-check because the join carries no organizer-entered + data (:570-574); RemoveEventSpeaker (:607) soft-deletes.
    • +
    • Answer management (Event.cs:637-698): AddEventQuestionAnswer (:637), + UpdateEventQuestionAnswer (:661), RemoveEventQuestionAnswer (:684). Unlike the two collections + above, the add has no duplicate guard: an event answering the same question twice is not blocked + in the domain.
    • +
    • Populator hooks (Event.cs:529, :625, :702): SetRooms, SetEventSpeakers, and SetEventQuestionAnswers are internal and call the base SetItems, raising no events (ADR-002).
    • -
    • Private helpers (Event.cs:687-719): EnsureRoomNameIsUnique (:687), whose doc comment notes +
    • Private helpers (Event.cs:716-748): EnsureRoomNameIsUnique (:716), whose doc comment notes the ordinal-ignore-case comparison is chosen to match the database uniqueness index under the server's - default case-insensitive collation; and the three Get...OrNotFound wrappers over the base - GetChildOrNotFound so a missing child returns an + default case-insensitive collation, and which uses the same nullable-exclusion shape as + CategoryInvariants (:724); and the three Get...OrNotFound wrappers + (:735, :740, :745) over the base GetChildOrNotFound so a missing child returns an Error rather than a null.
  • @@ -2927,7 +3180,9 @@

    Event

    reinstate a room or a speaker, and reactivating a soft-deleted row preserves its id and history where re-creating it would not (BR-135).
  • Where it's used: loaded and mutated by the Conference application-layer command handlers (Group 18); - persisted through the Events EF configuration; projected to DTOs for the read endpoints.
  • + persisted through EventConfiguration; + projected to DTOs for the read endpoints; and referenced by FK from Activity, + Room, EventSpeaker, and EventQuestionAnswer.

    EventQuestionAnswer

    @@ -2950,7 +3205,7 @@

    EventQuestionAnswer

  • Walkthrough: [IdValueGenerated] (:12); QuestionId (the FK to the answered question) and AnswerValue, both with private setters (:15-19); the [Navigation] Event? back-navigation and the get-only EventId FK (:21-26); a private EF constructor that seeds AnswerValue = string.Empty and a - private field constructor (:29-37); Create (:46-64), which validates through + private field constructor (:28-37); Create (:46-64), which validates through EventInvariants.EnsureAnswerValueIsValid and assigns Id = isIdValueGenerated ? default : id!.Value (:60); UpdateAnswer (:71-80), which re-validates and then writes AnswerValue.
  • @@ -2958,8 +3213,14 @@

    EventQuestionAnswer

    means it shares the event's transaction and cascade delete, and its lifecycle notifications flow through the root's ordered event stream.
  • Where it's used: created and mutated only through Event's AddEventQuestionAnswer, - UpdateEventQuestionAnswer, and RemoveEventQuestionAnswer. Because the collection is not marked - [Navigation], handlers that need it request it explicitly rather than getting it from the populator.
  • + UpdateEventQuestionAnswer, and RemoveEventQuestionAnswer; mapped by + EventQuestionAnswerConfiguration. + Because the collection is not marked [Navigation], handlers that need it request it explicitly rather + than getting it from the populator. +
  • Caveats / not-in-source: nothing in this file checks that AnswerValue matches the referenced + question's type. QuestionInvariants.EnsureAnswerValueMatchesQuestionType + (QuestionInvariants.cs:115) exists for that but is not called from here, so the BR-124 check has to be + applied by a caller in the Application tier.
  • EventSpeaker

    @@ -2981,8 +3242,8 @@

    EventSpeaker

    There is no Update, because a join either exists or it does not.
  • Walkthrough: [IdValueGenerated] (:12); SpeakerId (:16); [Navigation] Event? and the get-only EventId (:18-23); an empty private EF constructor and a one-line private field constructor - (:26-28); Create (:36); and Reactivate() (:56), a one-line delegation to the base Undelete(). - The Reactivate doc (:50-54) explains its reason for existing: the join row carries the + (:26, :28); Create (:36-48); and Reactivate() (:56), a one-line delegation to the base + Undelete(). The Reactivate doc (:50-55) explains its reason for existing: the join row carries the Sessionize-assigned speaker id, so an association that reappears in the feed is reactivated rather than duplicated by a second row (BR-135).
  • Why it's built this way: an explicit join entity is what lets Event raise @@ -2990,7 +3251,8 @@

    EventSpeaker

    it is what makes the soft-delete-then-reactivate cycle possible under a repeatedly re-run import.
  • Where it's used: created, restored, and removed only through Event's AddEventSpeaker, RestoreEventSpeaker, and RemoveEventSpeaker; the duplicate-speaker guard lives in the root - (Event.cs:515), not here.
  • + (Event.cs:544), not here. Mapped by + EventSpeakerConfiguration.

    Question

    @@ -3002,7 +3264,8 @@

    Question

    (QuestionEntity), has an input type (QuestionType), a sort order, an IsRequired flag, and a QuestionSource. Unlike the other roots in this part it owns no children: answers live on the answering entity (EventQuestionAnswer, - SpeakerQuestionAnswer). + SpeakerQuestionAnswer, + SessionQuestionAnswer).
  • Depends on: AuditableAggregateRootEntity<TIdentifierType> (Question.cs:14), QuestionInvariants, @@ -3014,7 +3277,7 @@

    Question

    (Question.cs:14), so question ids are explicitly assigned, typically by Sessionize. Create still runs the same typeof(Question).IsIdValueGenerated check (:87), which here evaluates to false, so the id!.Value branch is always taken (:91). [Rubric §8, Data Architecture]: the id-origin decision - is expressed once, as an attribute on the type, and every factory reads it uniformly.
  • + is expressed once, as an attribute on the type (or its absence), and every factory reads it uniformly.
  • Walkthrough
    • Scalars (Question.cs:16-32): QuestionText, QuestionEntity, QuestionType, Sort, IsRequired, QuestionSource, all with private setters. The three discriminators are plain strings @@ -3036,11 +3299,13 @@

      Question

      user-created one, which is what the reserved manual id range in QuestionInvariants also protects.
    • Where it's used: referenced by scalar FK (QuestionId) from - EventQuestionAnswer and SpeakerQuestionAnswer; - fed into the feedback and custom-form features in the Application and UI tiers.
    • + EventQuestionAnswer, SpeakerQuestionAnswer, and + SessionQuestionAnswer; mapped by + QuestionConfiguration; fed into the + feedback and custom-form features in the Application and UI tiers.
    • Caveats / not-in-source: QuestionEntity accepts "Speaker" (QuestionInvariants.cs:28) while the - property's own XML doc still says "Session" or "Event" (Question.cs:19). The array is the operative - rule; the doc comment is stale.
    • + property's own XML doc still says "Session" or "Event" (Question.cs:19), as do the Create parameter + docs (:64). The array is the operative rule; the doc comments are stale.

    Room

    @@ -3065,178 +3330,91 @@

    Room

  • Walkthrough: scalars Name, Sort, Capacity?, Floor?, Location?, AccessibilityInfo? (Room.cs:14-30); [Navigation] Event? and the get-only EventId (:32-37); the EF constructor and the private field constructor (:40-56); Create (:69-98) validating EnsureRoomNameIsValid plus - EnsureRoomCapacityIsValid; Update (:110-132) re-validating the same pair and writing all six - scalars; Reactivate() (:140), a one-line delegation to the base Undelete() whose doc (:134-139) - explains that a room reappearing in the Sessionize feed has to be reactivated rather than re-created - precisely because its id is externally owned (BR-135). As a child it raises no events itself.
  • + EnsureRoomCapacityIsValid (:78-80); Update (:110-132) re-validating the same pair and writing all + six scalars; Reactivate() (:140), a one-line delegation to the base Undelete() whose doc + (:134-139) explains that a room reappearing in the Sessionize feed has to be reactivated rather than + re-created precisely because its id is externally owned (BR-135). As a child it raises no events itself.
  • Why it's built this way: preserving the Sessionize id as the PK keeps imported rooms stable across refreshes, so a re-import updates in place instead of creating duplicates, and the reserved manual range lets organizers add rooms without an id clash. Note the room-name uniqueness rule is not here: it - lives in Event (Event.cs:687), because uniqueness is a statement about the collection, - which only the root can see.
  • + lives in Event (Event.cs:716), because uniqueness is a statement about the collection, + which only the root can see. The same reasoning puts the "does this room belong to this event" check in + the root as well (Event.cs:463): EventId is get-only here (Room.cs:37), so only EF relationship + fixup ever sets it.
  • Where it's used: created, updated, restored, and removed through Event's AddRoom, UpdateRoom, RestoreRoom, and RemoveRoom, each of which raises RoomChanged; - referenced by Session scheduling.
  • + mapped by RoomConfiguration; referenced by + Session scheduling. -

    Speaker

    +

    Activity

    -

    MMCA.ADC.Conference.Domain · MMCA.ADC.Conference.Domain.Speakers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:22 · Level 7 · class (sealed, aggregate root)

    +

    MMCA.ADC.Conference.Domain · MMCA.ADC.Conference.Domain.Activities · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:20 · Level 8 · class (sealed, aggregate root)

      -
    • What it is: the aggregate root for a conference speaker. It carries rich profile data (names, an - optional Email value object, bio, tag line, social and URL - links, IsTopSpeaker), owns SpeakerCategoryItem join entities and - SpeakerQuestionAnswer children, and holds the cross-module link - LinkedUserId. Speaker ids are Sessionize-assigned GUIDs (Speaker.cs:12-14).
    • +
    • What it is: the aggregate root for a social or networking activity attached to a conference event: a + pre-conference party, a morning coffee connect, an after-party, a closing ceremony (Activity.cs:11-18). + It carries a name, an optional description, a start and end time, three optional venue fields, a sort + order, and the FK to its owning Event. Activity ids are database-generated.
    • Depends on: AuditableAggregateRootEntity<TIdentifierType> - and IAuditedEntity (Speaker.cs:22), - SpeakerInvariants, Email, - SpeakerCategoryItem, SpeakerQuestionAnswer, - Result and - Error, + (the base, Activity.cs:20), ActivityInvariants, Event (the + navigation target, :58), Result, DomainEntityState, + IdValueGeneratedAttribute, NavigationAttribute, and the - SpeakerChanged / SpeakerCategoryItemChanged / - SpeakerQuestionAnswerChanged domain events. Aliases - SpeakerIdentifierType (a Guid), UserIdentifierType, CategoryItemIdentifierType, - SpeakerCategoryItemIdentifierType, SpeakerQuestionAnswerIdentifierType, QuestionIdentifierType.
    • -
    • Concept: the aggregate root pattern (see Category) plus a cross-module link field - and value-object composition. [Rubric §7, Microservices Readiness] and [Rubric §8, Data Architecture]: LinkedUserId (Speaker.cs:58) is a nullable scalar FK to User in the Identity - database. It cannot be an EF navigation, because the two entities live in different databases - (ADR-006); the bidirectional link - is instead maintained through the integration events - SpeakerLinkedToUser and - SpeakerUnlinkedFromUser. [Rubric §11, Security] and [Rubric §30, Compliance and Privacy]: the class implements - IAuditedEntity, and the doc (Speaker.cs:15-20) - gives the reason: the record carries personal data and the link that grants a person speaker rights over - their sessions, so "who linked this account to this speaker" has to be answerable.
    • + ActivityChanged domain event. Aliases ActivityIdentifierType, + EventIdentifierType. +
    • Concept: the aggregate root taught at Category and Event, in its + childless form (compare Question). What Activity teaches that the others do not is a + modeling decision stated outright in the class doc (Activity.cs:11-18): an activity is deliberately + not a Session. It has no room and no speakers, and it frequently happens at an + external venue, so the venue is carried on the activity itself instead of being inherited from the + event. [Rubric §4, Domain-Driven Design]: rather than overload Session with nullable + room/speaker/venue fields and a "kind" discriminator, the ubiquitous language gets a second, smaller + aggregate whose invariants are genuinely different. [Rubric §16, Maintainability]: the cost of that + choice is a parallel command, query, and UI slice, and the benefit is that neither type carries the + other's optionality.
    • Walkthrough
        -
      • Profile scalars (Speaker.cs:24-55) and the computed FullName => $"{FirstName} {LastName}" - (:61), which is a projection, not a stored column.
      • -
      • Child collections (Speaker.cs:63-73): both SpeakerCategoryItems and SpeakerQuestionAnswers - are private lists exposed read-only and marked [Navigation(IsCollection = true)]. (Contrast - Event, where the answers collection is deliberately unmarked.)
      • -
      • Create (Speaker.cs:112-159): parses email into an - Email value object first (:122-129), so a supplied - but malformed email fails before the name checks run and the caller is not handed a partial error - list; then Result.Combines the two name invariants. The id assignment (:153) is the one that - differs from every sibling in this chapter: Id = id ?? (isIdValueGenerated ? default : Guid.NewGuid()). The inline comment (:148-152) records why: SpeakerIdentifierType is a - client-assigned Guid, and organizer-created speakers and the sample-data seeder both pass null, so - the factory generates one rather than dereferencing a null Nullable. The old id!.Value threw - "Nullable object must have a value" and killed both Conference's startup seeding and every organizer - "create speaker" call.
      • -
      • Update (Speaker.cs:183-226): the same email-first shape, then the name invariants, then eleven - scalar writes and SpeakerChanged(Updated). Read the remarks (:164-170): the method deliberately - does not touch LinkedUserId, because LinkUser/UnlinkUser are the only paths that carry the - BR-208 uniqueness check and raise the link and unlink events that keep Identity's User.LinkedSpeakerId - in sync. Writing the link here would silently desynchronize the two sides.
      • -
      • Delete (Speaker.cs:239-255): the BR-70 cross-context cleanup. It captures LinkedUserId into a - local before base.Delete() (:242), clears the field within the Conference context (:249), - then raises SpeakerChanged(Deleted, ..., previousLinkedUserId) (:251) so the downstream handler can - clear User.LinkedSpeakerId in Identity without a synchronous call back - (ADR-003). The doc (:228-237) - also records a deliberate non-cascade: the child associations survive the soft-delete and are not - cascaded (BR-70, BR-71), because the Sessionize import reactivates them in place when the speaker - returns (BR-135) and no cascade-restore counterpart exists; junction reads follow the parent's - visibility (BR-132), so the surviving children are not observable meanwhile. This is the opposite - choice from Category and Event, and it is worth understanding why: cascade - is right when children have no independent upstream lifecycle, and wrong when they do.
      • -
      • LinkUser / UnlinkUser (Speaker.cs:260, :278): guard against already-linked and - not-linked with Speaker.AlreadyLinked / Speaker.NotLinked (BR-209), set or clear LinkedUserId, - and raise SpeakerChanged(Updated).
      • -
      • Category-item management (Speaker.cs:302-389): AddSpeakerCategoryItem (:302) runs an - in-memory duplicate guard (:306) returning Speaker.CategoryItem.Duplicate before delegating to the - child factory, the same shape as Event.AddEventSpeaker; RestoreSpeakerCategoryItem (:340) is the - BR-135 reactivation counterpart; RemoveSpeakerCategoryItem (:370) soft-deletes.
      • -
      • Answer management (Speaker.cs:400-466): AddSpeakerQuestionAnswer (:400), - UpdateSpeakerQuestionAnswer (:424), RemoveSpeakerQuestionAnswer (:447). As with - Event's answers, the add carries no duplicate guard: a speaker answering the same - question twice is not blocked in the domain.
      • -
      • Populator hooks and helpers (Speaker.cs:388, :465, :469-477): SetSpeakerCategoryItems and - SetSpeakerQuestionAnswers are internal and event-free; the two Get...OrNotFound wrappers turn a - missing child into an Error.
      • -
      -
    • -
    • Why it's built this way: LinkedUserId as a nullable scalar rather than a navigation is the direct - consequence of database-per-service - (ADR-006), and the link is kept - consistent through integration events - (ADR-003) rather than a - cross-database FK. Note also what is not a field here: speaker locality is modeled as a - CategoryItem attached through - SpeakerCategoryItem, not as a Speaker.Location property, which is why that - collection exists rather than a scalar.
    • -
    • Where it's used: read and projected by the Conference query handlers, mutated by the speaker command - handlers (Group 18), and referenced by FK from EventSpeaker, the Engagement bookmark - entities, and Identity's User.
    • -
    -

    SpeakerCategoryItem

    -
    -

    MMCA.ADC.Conference.Domain · MMCA.ADC.Conference.Domain.Speakers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/SpeakerCategoryItem.cs:13 · Level 7 · class (sealed, join entity)

    -
    -
      -
    • What it is: the join entity linking a Speaker to a CategoryItem - (SpeakerCategoryItem.cs:8-10). It holds CategoryItemId, the back-navigation Speaker?, and the FK - SpeakerId. Database-generated id.
    • -
    • Depends on: - AuditableBaseEntity<TIdentifierType> - (SpeakerCategoryItem.cs:13), Speaker, - Result, - IdValueGeneratedAttribute, - NavigationAttribute.
    • -
    • Concept: the explicit join entity taught at EventSpeaker, structurally identical - apart from which two entities it bridges. [Rubric §4, Domain-Driven Design]. This is also the physical - representation of how a speaker's topics and locality are tracked: rather than a Speaker.Location - field, locality is a CategoryItem attached through this join.
    • -
    • Walkthrough: [IdValueGenerated] (:12); CategoryItemId (:16); [Navigation] Speaker? and the - get-only SpeakerId (:18-23); an empty private EF constructor and a one-line field constructor - (:26-28); Create (:36-48), a pure FK assignment with no content validation and no domain event - (Speaker raises SpeakerCategoryItemChanged); and - Reactivate() (:56), the same Undelete() delegation as EventSpeaker, for the same - BR-135 reason (:50-55).
    • -
    • Why it's built this way: modeling locality and topic as category items rather than as scalar speaker - columns means the vocabulary is organizer-editable data (Category rows) instead of a code - change, and the explicit join gives each association its own soft-delete and reactivation path.
    • -
    • Where it's used: loaded through Speaker.SpeakerCategoryItems; created, restored, and removed only - through Speaker's AddSpeakerCategoryItem, RestoreSpeakerCategoryItem, and - RemoveSpeakerCategoryItem; consumed by the speaker-detail and locality features.
    • -
    -

    SpeakerQuestionAnswer

    -
    -

    MMCA.ADC.Conference.Domain · MMCA.ADC.Conference.Domain.Speakers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/SpeakerQuestionAnswer.cs:13 · Level 7 · class (sealed, child entity)

    -
    -
      -
    • What it is: the child entity of Speaker holding that speaker's answer to one - Question, for example a T-shirt size (SpeakerQuestionAnswer.cs:8-10). It holds - QuestionId, AnswerValue, the back-navigation Speaker?, and the FK SpeakerId. Database-generated - id.
    • -
    • Depends on: - AuditableBaseEntity<TIdentifierType> - (SpeakerQuestionAnswer.cs:13), Speaker, SpeakerInvariants, - Result, - IdValueGeneratedAttribute, - NavigationAttribute.
    • -
    • Concept: the child-entity discipline of EventQuestionAnswer, differing only - in its parent and in which invariants class it calls. [Rubric §4, Domain-Driven Design]. The two types - are worth reading side by side: same [IdValueGenerated] marker, same QuestionId plus AnswerValue - pair, same back-navigation shape, same event-free factory and updater.
    • -
    • Walkthrough: [IdValueGenerated] (:12); QuestionId and AnswerValue (:15-19); - [Navigation] Speaker? and the get-only SpeakerId (:21-26); the EF constructor seeding - AnswerValue = string.Empty and the private field constructor (:29-37); Create (:46-64) and - UpdateAnswer (:71-80), both validating through SpeakerInvariants.EnsureAnswerValueIsValid and - neither raising a domain event (Speaker raises - SpeakerQuestionAnswerChanged).
    • -
    • Why it's built this way: the same reasoning as its Event twin. Keeping the answer a child of the - answering entity puts it inside that aggregate's transaction and gives the root a single place to - announce the change.
    • -
    • Caveats / not-in-source: neither this type nor Speaker checks that AnswerValue - matches the referenced question's type. QuestionInvariants.EnsureAnswerValueMatchesQuestionType - (QuestionInvariants.cs:115) exists for that, but it is not called from either file, so the BR-124 check - must be applied by a caller in the Application tier.
    • -
    • Where it's used: loaded through Speaker.SpeakerQuestionAnswers; created, updated, and removed only - through Speaker's AddSpeakerQuestionAnswer, UpdateSpeakerQuestionAnswer, and - RemoveSpeakerQuestionAnswer.
    • +
    • [IdValueGenerated] on the class (Activity.cs:19): activities are planned, not imported from + Sessionize, so the database owns the id. Create reads it through + typeof(Activity).IsIdValueGenerated (:120).
    • +
    • Scalars (Activity.cs:22-54): Name, Description?, StartTime/EndTime, VenueName?, + VenueAddress?, VenueUrl?, SortOrder, and EventId, all with private setters. Read the two time + docs carefully (:28-32, :35): both are plain wall-clock DateTime values in the owning event's + IANA time zone, exactly as Session.StartsAt does, and the zone lives on the event, never repeated + per row. SortOrder (:50) exists only to break ties between activities starting at the same time.
    • +
    • [Navigation] public Event? Event (Activity.cs:57-58): a single-reference navigation (not a + collection), described in its doc as read-only and used for public visibility filtering, so a public + read can honor the parent event's published state + (ADR-002).
    • +
    • Constructors (Activity.cs:61-83): the private parameterless EF constructor seeds + Name = string.Empty; the private nine-parameter field constructor is what the factory calls.
    • +
    • Create (Activity.cs:99-130): a five-way Result.Combine over + ActivityInvariants (:111-116) so a caller sees every problem at once, then + Id = isIdValueGenerated ? default : id!.Value (:124), then + AddDomainEvent(new ActivityChanged(DomainEntityState.Added, activity.Id, activity.Name)) (:127).
    • +
    • Update (Activity.cs:145-176): the same five checks (:155-160), then eight scalar writes, then + ActivityChanged(Updated) (:173). Note the parameter list has no eventId: the doc (:132-135) + records that the owning event is not updatable, and that moving an activity between events is a create + plus a delete.
    • +
    • Delete (Activity.cs:180-188): overrides the base soft-delete and, on success, raises + ActivityChanged(Deleted). There is no cascade loop, because the aggregate owns no children.
    • +
    + +
  • Why it's built this way: storing event-local wall times rather than instants means an organizer + edits the time they see printed on the schedule, and the single authoritative zone on Event + is applied once at render. Keeping venue on the activity is what lets an off-site after-party carry its + own address and map link while an on-site coffee connect simply leaves the fields null and the reader + falls back to the event venue.
  • +
  • Where it's used: mutated by the Conference activity command handlers and mapped to + ActivityDTO by + ActivityDTOMapper; hydrated by + ActivityNavigationPopulator; + persisted through + ActivityConfiguration; rendered by the + ActivityList, + ActivityDetail, and + ActivityCreate pages.
  • SponsorInvariants

    @@ -3523,7 +3701,7 @@

    EventCascadeDeletionDomainService

    On this page

    • Two packages, one bounded context
    • -
    • Seven aggregates and their ownership boundaries
    • +
    • Eight aggregates and their ownership boundaries
    • The aggregate shape, taught once
    • Invariants, business rules as testable units
    • Domain events and the outbox spine
    • diff --git a/docs/onboarding/group-18-conference-application.html b/docs/onboarding/group-18-conference-application.html index b1a754f..0eb9cae 100644 --- a/docs/onboarding/group-18-conference-application.html +++ b/docs/onboarding/group-18-conference-application.html @@ -146,32 +146,34 @@

      Onboarding guide

      18. ADC Conference - Application & Use Cases

      What this chapter covers. This is the application layer of the Conference module, the largest - single application assembly in the codebase (this group covers 251 distinct types). It sits between - the REST/gRPC edge (G20, Conference API & gRPC) and the domain - aggregates (G17, Conference Domain), and it is where the conference's - use cases actually live: create an event, publish it, add a room or a speaker, import the whole - agenda from Sessionize.com, export the schedule as an .ics calendar, answer "what is happening right - now", and run the AI-assisted analytics that help organizers decide which session proposals to accept. - One slice cuts across several of those reads: the public-visibility projection that keeps - non-published events, non-accepted sessions, their speakers, and their sponsors out of an unprivileged - reader's results (BR-108 / BR-49 / BR-239). It is resolved in one place by + single application assembly in the codebase (this group covers 285 entries in the type map, 284 + distinct names, since two private StatusBucket enums share a name in different decision-support + slices). It sits between the REST/gRPC edge (G20, Conference API & gRPC) + and the domain aggregates (G17, Conference Domain), and it is where + the conference's use cases actually live: create an event, publish it, add a room, a speaker, a + sponsor or a social activity, import the whole agenda from Sessionize.com, export the schedule as an + .ics calendar, answer "what is happening right now", and run the AI-assisted analytics that help + organizers decide which session proposals to accept. One slice cuts across several of those reads: the + public-visibility projection that keeps non-published events, non-accepted sessions, their + speakers, their rooms, their sponsors and their activities out of an unprivileged reader's results + (BR-108 / BR-49 / BR-239). It is resolved in one place by PublicConferenceVisibility over the shared - PublicSessionStatusSpecification allow-list, and seven - GetPublic*Filter handlers (events' speakers, sessions, session category items, session speakers, - speakers, speaker category items, and sponsors) turn its id lists into specifications the Conference - controllers apply. Everything here is engine-agnostic and framework-light: it depends on the - abstractions introduced by MMCA.Common.Application (handlers, mappers, validators, query services, - navigation populators) and on the Conference domain, but never on EF Core, ASP.NET, or a broker SDK - directly. Read the primer's tour of + PublicSessionStatusSpecification allow-list, and nine + GetPublic*Filter handlers (activities, rooms, event speakers, sessions, session category items, + session speakers, speakers, speaker category items, and sponsors) turn its id lists into + specifications the Conference controllers apply. Everything here is engine-agnostic and + framework-light: it depends on the abstractions introduced by MMCA.Common.Application (handlers, + mappers, validators, query services, navigation populators) and on the Conference domain, but never on + EF Core, ASP.NET, or a broker SDK directly. Read the primer's tour of CQRS and Vertical Slice first; this chapter shows those styles at full scale in one module.

      The vertical-slice anatomy of a use case

      Open any feature folder under Sessions/UseCases/, Events/UseCases/, Speakers/UseCases/, - Sponsors/UseCases/, Categories/UseCases/, or Questions/UseCases/ and you will find the same - cohesive slice: a command or query record, its handler, its FluentValidation validator, and (for - creates) a request record plus a request mapper, all co-located. Adding a feature means adding a - folder, not threading an edit through horizontal Services/, Validators/, and Repositories/ - directories. This is the + Sponsors/UseCases/, Activities/UseCases/, Categories/UseCases/, or Questions/UseCases/ and you + will find the same cohesive slice: a command or query record, its handler, its FluentValidation + validator, and (for creates) a request record plus a request mapper, all co-located. Adding a feature + means adding a folder, not threading an edit through horizontal Services/, Validators/, and + Repositories/ directories. This is the Vertical Slice discipline made physical. [Rubric §5, Vertical Slice] assesses whether a feature is one navigable unit rather than scattered horizontally, and the folder layout is the evidence.

      @@ -181,19 +183,26 @@

      The vertical-slice anatomy of and IQueryHandler<in TQuery, TResult>, so every handler in this assembly flows through the same decorator pipeline (Logging, Caching, Transactional, then the handler) without knowing it exists. Commands that change cached read data - implement ICacheInvalidating; commands that must be - atomic implement ITransactional; the one read that opts - into caching, GetNowNextQuery, implements + implement ICacheInvalidating and publish the aggregate + prefix the pipeline evicts (MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionCommand.cs:13, + MMCA.ADC.Conference.Application/Events/UseCases/Update/UpdateEventCommand.cs:13); the three commands + that must be atomic across several aggregates also implement + ITransactional (the Sessionize refresh at + MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeCommand.cs:13, + which additionally carries IFeatureGated with the + SessionizeIntegration flag at :19, plus the two speaker link commands). Exactly one read opts into + caching: GetNowNextQuery implements IQueryCacheable with a 30-second TTL (MMCA.ADC.Conference.Application/Sessions/UseCases/NowNext/GetNowNextQuery.cs:38) and a cache key - built under the Session aggregate prefix, so session writes evict it - (GetNowNextQuery.cs:26-35). The controller injects the handler interface and calls HandleAsync; - the concrete type is invisible to it. [Rubric §6, CQRS & Event-Driven] assesses a clean command/query - split through well-defined handler boundaries: this module is the canonical demonstration, dozens of - single-responsibility handlers, each one slice wide, all dispatched uniformly.

      + built under the Session aggregate prefix, so session writes evict it (GetNowNextQuery.cs:26-35). + The controller injects the handler interface and calls HandleAsync; the concrete type is invisible to + it. [Rubric §6, CQRS & Event-Driven] assesses a clean command/query split through well-defined handler + boundaries: this module is the canonical demonstration, dozens of single-responsibility handlers, each + one slice wide, all dispatched uniformly.

      The CRUD-shaped handlers (CreateEventHandler, CreateSessionHandler, CreateSpeakerHandler, - CreateSponsorHandler, CreateQuestionHandler, + CreateSponsorHandler, CreateActivityHandler, + CreateQuestionHandler, CreateConferenceCategoryHandler, and the matching Update*/Delete*/Add*/Remove* families) share one shape: they delegate object construction to an IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> @@ -201,27 +210,40 @@

      The vertical-slice anatomy of persist through IUnitOfWork and map the saved entity back to a DTO with an IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>. - The handler owns orchestration only (load, validate, persist, map, log) while the business rule ("an + CreateActivityHandler is the smallest complete example of the shape, four + statements between the mapper and the DTO + (MMCA.ADC.Conference.Application/Activities/UseCases/Create/CreateActivityHandler.cs:27-40). The + handler owns orchestration only (load, validate, persist, map, log) while the business rule ("an event's end date cannot precede its start date") lives in the domain factory and the invariant classes. - DeleteEventHandler is the one delete that is not the generic framework handler: + PublishEventHandler shows the same economy on a state transition: load, stamp + the client's rowversion so a decision taken against a stale view fails with 409 rather than silently + winning (MMCA.ADC.Conference.Application/Events/UseCases/Publish/PublishEventHandler.cs:27-29, + ADR-035), then delegate to + Event.Publish() (:31).

      +

      DeleteEventHandler is the one delete that is not the generic framework handler: it eagerly loads the event's owned children (Rooms, EventSpeakers, EventQuestionAnswers, - MMCA.ADC.Conference.Application/Events/UseCases/Delete/DeleteEventHandler.cs:28-32), then the event's - separate Session aggregates (BR-127, DeleteEventHandler.cs:37-42) and its Sponsor aggregates - (DeleteEventHandler.cs:46-51, otherwise the public sponsor strip keeps reading orphaned rows), and - hands all three to the domain's + MMCA.ADC.Conference.Application/Events/UseCases/Delete/DeleteEventHandler.cs:28-33), then the event's + separate Session aggregates (BR-127, DeleteEventHandler.cs:37-43), its Sponsor aggregates + (DeleteEventHandler.cs:45-52, otherwise the public sponsor strip keeps reading orphaned rows) and its + Activity aggregates (DeleteEventHandler.cs:54-61, same reasoning for the public activities page), + and hands all four collections to the domain's EventCascadeDeletionDomainService - (DeleteEventHandler.cs:54, BR-72/BR-55), because a cross-aggregate cascade is an application-layer - decision.

      + (DeleteEventHandler.cs:64, BR-72/BR-55), because a cross-aggregate cascade is an application-layer + decision. Every other aggregate delete is the framework's + DeleteEntityHandler<TEntity, TIdentifierType>, + bound closed in the composition root + (MMCA.ADC.Conference.Application/DependencyInjection.cs:68, :72, :77, :82, :86).

      The richer handlers add what genuinely needs orchestration context. UpdateSessionHandler and UpdateEventHandler stamp the client's concurrency token before mutating, so a concurrent edit surfaces as a 409 instead of silent last-write-wins (MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionHandler.cs:34, - MMCA.ADC.Conference.Application/Events/UseCases/Update/UpdateEventHandler.cs:33, ADR-035), and each - returns a two-part result record (UpdateSessionResult, + MMCA.ADC.Conference.Application/Events/UseCases/Update/UpdateEventHandler.cs:33, + ADR-035), and each returns a + two-part result record (UpdateSessionResult, UpdateEventResult) carrying the DTO plus a non-blocking warning flag: sessions - scheduled outside the event's date range (BR-86, UpdateSessionHandler.cs:89-91) and a time-zone - change on an event that already has sessions (BR-131, UpdateEventHandler.cs:35-46). The session - variant also rejects immutable-field edits with + scheduled outside the event's date range (BR-86, UpdateSessionHandler.cs:88-91) and a time-zone change + on an event that already has sessions (BR-131, UpdateEventHandler.cs:35-46). The session variant also + rejects immutable-field edits with Error.UnprocessableEntity (UpdateSessionHandler.cs:36-44, BR-140).

      Both the create and update session paths run the shared room checks through @@ -234,15 +256,15 @@

      The vertical-slice anatomy of SQL-translatable half-open interval comparison (s.StartsAt < endsAt && s.EndsAt > startsAt, SessionRoomScheduling.cs:103-106) so back-to-back sessions in one room do not collide, with int.MinValue as an "exclude nothing" sentinel that keeps the predicate a single shape - (SessionRoomScheduling.cs:101). The class documents the overlap half as a deliberate soft guard - (SessionRoomScheduling.cs:16-25): the existence probe and the write that follows are separate + (SessionRoomScheduling.cs:99-101). The class documents the overlap half as a deliberate soft + guard (SessionRoomScheduling.cs:16-25): the existence probe and the write that follows are separate statements, so two concurrent organizer writes can both observe a free window; SQL Server has no range-exclusion constraint to express the rule as an index, and the trade-off is accepted because the endpoints are organizer-only and the outcome is repairable. Writing that reasoning down beside the code is [Rubric §34, Architecture Governance & Documentation] in practice.

      CreateSessionHandler carries one more piece of orchestration: session ids are - app-assigned (the integer primary key is the Sessionize id), so an organizer create computes the - next id in the reserved manual range bounded by SessionInvariants.ManualIdRangeStart/End + app-assigned (the integer primary key is the Sessionize id), so an organizer create computes the next + id in the reserved manual range bounded by SessionInvariants.ManualIdRangeStart/End (CreateSessionHandler.cs:79-95), and because two concurrent creates can compute the same id, the handler retries a bounded three times (CreateSessionHandler.cs:30) on a unique-key violation, each retry in a fresh DI scope because the ambient DbContext still tracks the failed insert @@ -256,41 +278,46 @@

      SessionDTOMapper, EventDTOMapper, SpeakerDTOMapper, SponsorDTOMapper, - RoomDTOMapper, CategoryItemDTOMapper, and the - question-answer / category-item link mappers) implement the Common mapper contract and assign each - field by hand, the deliberate choice of ADR-001 - (manual/Mapperly mapping over reflection-based AutoMapper) so a renamed property is a compile error, - not a silent null. [Rubric §9, API & Contract Design] assesses explicit, traceable contracts: the - mapping is code you can read and test, not convention magic.

      + ActivityDTOMapper, RoomDTOMapper, + CategoryItemDTOMapper, and the question-answer / category-item link mappers) + implement the Common mapper contract and assign each field by hand, the deliberate choice of + ADR-001 (manual/Mapperly mapping over + reflection-based AutoMapper) so a renamed property is a compile error, not a silent null. + [Rubric §9, API & Contract Design] assesses explicit, traceable contracts: the mapping is code you can + read and test, not convention magic.

      Validation is composed, not inherited. Small generic rule fragments (EventDateRangeRules<T>, EventNameRules<T>, RoomCapacityRules<T>, SessionTitleRules<T>, SpeakerFirstNameRules<T>, SponsorNameRules<T>, - CategoryItemNameRules<T>, and two dozen siblings) each encapsulate one + ActivityTimeRangeRules<T>, + CategoryItemNameRules<T>, and three dozen siblings) each encapsulate one validated concern behind a property selector: the plain string ones subclass the framework's RequiredStringRules<T> and pass the domain's max-length invariant through (MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:13-18), while the ones with real logic derive from AbstractValidator<T> directly, such as EventTimeZoneRules<T>, which additionally proves the value is a resolvable IANA identifier via TimeZoneInfo.FindSystemTimeZoneById (EventValidationRules.cs:25-48, BR-87). The - optional fields wrap a shared fragment in a When(...) guard so an empty value is simply absent rather - than invalid (EventOrganizerContactEmailRules<T> at - EventValidationRules.cs:60-66, - EventSponsorshipPacketUrlRules<T> at - EventValidationRules.cs:78-84). The per-use-case validators - (EventUpdateRequestValidator, + optional fields compile the selector and wrap a shared fragment in a When(...) guard so an empty value + is simply absent rather than invalid (EventOrganizerContactEmailRules<T> + wrapping EmailRules<T> at EventValidationRules.cs:60-66, + EventSponsorshipPacketUrlRules<T> and + EventTicketingUrlRules<T> wrapping + OptionalStringRules<T> at EventValidationRules.cs:78-84 + and :96-102). The per-use-case validators (EventUpdateRequestValidator, SessionCreateRequestValidator, and the rest) pull the fragments together with FluentValidation's Include(...) and add only what is local to the request - (MMCA.ADC.Conference.Application/Events/UseCases/Update/EventUpdateRequestValidator.cs:11-15, with - the request-local enum check at :17-20). + (MMCA.ADC.Conference.Application/Events/UseCases/Update/EventUpdateRequestValidator.cs:11-16, with + the request-local enum check at :18-21). EventDateRangeRules<T> is the richest, compiling the StartDate selector into - a delegate (EventValidationRules.cs:104) and reading it inside a cross-property Must on EndDate - (EventValidationRules.cs:105-107). Every rule carries a stable error code alongside its message - (EventValidationRules.cs:30-32), so clients and tests key off the failure without string-matching - prose. The pattern mirrors the framework's rule-fragment families in - G06, Validation. [Rubric §24, Forms, Validation & UX - Safety] and [Rubric §1, SOLID] both apply: fragments compose without an inheritance chain, and a new - constraint is a new fragment that touches no existing validator.

      + a delegate (EventValidationRules.cs:122) and reading it inside a cross-property Must on EndDate + (EventValidationRules.cs:123-125); ActivityTimeRangeRules<T> is the same + shape one aggregate over + (MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:100-103). Every rule + carries a stable error code alongside its message (EventValidationRules.cs:30-32), so clients and + tests key off the failure without string-matching prose. The pattern mirrors the framework's + rule-fragment families in G06, Validation. + [Rubric §24, Forms, Validation & UX Safety] and [Rubric §1, SOLID] both apply: fragments compose without + an inheritance chain, and a new constraint is a new fragment that touches no existing validator.

      Authorization and scoping specifications are the read-side half of the same story, and the module keeps exactly two of them. PublishedEventSpecification filters events to e => e.IsPublished (BR-108, @@ -298,7 +325,7 @@

      PublicSessionStatusSpecification holds the BR-49 status allow-list as a static readonly Expression so the predicate can be composed into other expressions rather than only applied as a specification - (MMCA.ADC.Conference.Application/Sessions/Specifications/PublicSessionStatusSpecification.cs:23-24). + (MMCA.ADC.Conference.Application/Sessions/Specifications/PublicSessionStatusSpecification.cs:22-24). The allow-list is deliberately positive ("Status is null, or Status is Accepted") rather than a list of exclusions, and it compares against the constant instead of calling SessionStatuses.IsEligible, because a compiled @@ -309,62 +336,76 @@

      PublicConferenceVisibility (MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:28), a static resolver with - three public methods and one rule each: published event ids (BR-108, :36-47), visible session ids - (the BR-49 allow-list ANDed with the published-event scoping, :56-77), and visible speaker ids - (BR-239: the speakers of at least one eligible session inside the scoped event set, :99-127, over a - private helper that re-applies the allow-list inside an already narrowed scope, :134-151). - Everything is expressed as scalar id projections rather than navigation joins, so the criteria stay - translatable on any engine (ADR-018) and each aggregate keeps its by-id boundary to the others - (PublicConferenceVisibility.cs:22-26). The speaker rule's remarks record a real leak that shaped it: - the EventSpeaker join is deliberately not a visibility grant, because the Sessionize import writes - a row for every speaker in the response, so reading it as one published the whole imported roster and - made the filter vacuous (PublicConferenceVisibility.cs:92-98).

      + three public methods and one rule each: published event ids (BR-108, :36-48), visible session ids + (the BR-49 allow-list ANDed with the published-event scoping, built through the framework's + CrossSourceSpecification helper, + :57-82), and visible speaker ids (BR-239: the speakers of at least one eligible session inside the + scoped event set, :104-134, over a private helper that re-applies the allow-list inside an already + narrowed scope, :141-158). Everything is expressed as scalar id projections rather than navigation + joins, so the criteria stay translatable on any engine + (ADR-018) and each aggregate keeps + its by-id boundary to the others (PublicConferenceVisibility.cs:22-26). The speaker rule's remarks + record a real leak that shaped it: the EventSpeaker join is deliberately not a visibility grant, + because the Sessionize import writes a row for every speaker in the response, so reading it as one + published the whole imported roster and made the filter vacuous + (PublicConferenceVisibility.cs:97-103).

      Several query handlers build a specification instead of returning data, and they exist because a navigating predicate (s => s.Event.IsPublished) is not translatable once the two entities can live in - different data sources (ADR-006, ADR-018). GetPublicSessionFilterHandler - uses the framework's CrossSourceSpecification - helper to resolve the published Event ids and return a translatable Session.EventId IN (...) filter, - ANDed with the shared status allow-list - (MMCA.ADC.Conference.Application/Sessions/UseCases/GetPublicSessionFilter/GetPublicSessionFilterHandler.cs:29-36); - GetPublicSponsorFilterHandler does the simplest version of the same - move, wrapping the published-event id list in an + different data sources (ADR-006, + ADR-018). + GetPublicSessionFilterHandler uses the same + CrossSourceSpecification helper to + resolve the published Event ids and return a translatable Session.EventId IN (...) filter, ANDed + with the shared status allow-list + (MMCA.ADC.Conference.Application/Sessions/UseCases/GetPublicSessionFilter/GetPublicSessionFilterHandler.cs:29-36). + GetPublicSponsorFilterHandler, + GetPublicActivityFilterHandler and + GetPublicRoomFilterHandler do the simplest version of the same move, + wrapping the published-event id list in an InlineSpecification<TEntity, TIdentifierType> - (MMCA.ADC.Conference.Application/Sponsors/UseCases/GetPublicSponsorFilter/GetPublicSponsorFilterHandler.cs:25-30). + (MMCA.ADC.Conference.Application/Sponsors/UseCases/GetPublicSponsorFilter/GetPublicSponsorFilterHandler.cs:25-30, + MMCA.ADC.Conference.Application/Activities/UseCases/GetPublicActivityFilter/GetPublicActivityFilterHandler.cs:25-31, + MMCA.ADC.Conference.Application/Events/UseCases/GetPublicRoomFilter/GetPublicRoomFilterHandler.cs:25-31). GetSessionsBySpeakerFilterHandler and GetSpeakersByEventFilterHandler hand-roll the same id-list shape against the link tables, projecting ids through GetReadRepository(...).GetProjectedAsync and materializing them once so the predicate embeds a stable collection EF can translate to IN (MMCA.ADC.Conference.Application/Sessions/UseCases/GetSessionsBySpeakerFilter/GetSessionsBySpeakerFilterHandler.cs:30-43, - MMCA.ADC.Conference.Application/Speakers/UseCases/GetSpeakersByEventFilter/GetSpeakersByEventFilterHandler.cs:28-50, + MMCA.ADC.Conference.Application/Speakers/UseCases/GetSpeakersByEventFilter/GetSpeakersByEventFilterHandler.cs:28-53, which unions the direct EventSpeaker links with the transitive SessionSpeaker ones). An empty id list correctly matches nothing, which is why the caller must still apply the specification rather than skip it (GetSessionsBySpeakerFilterHandler.cs:15-19).

      Query services, navigation populators, and the composition root

      Read paths do not get bespoke handlers for the common cases; they go through the framework's generic IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, - which supplies filtering, sorting, paging, and field projection (ADR-034). The one local specialization - is SpeakerEntityQueryService, a thin subclass that overrides only the - DTO-to-entity property map so API consumers can sort and filter on the computed FullName while the - pipeline translates it to (FirstName + " " + LastName) + which supplies filtering, sorting, paging, and field projection + (ADR-034). The one local + specialization is SpeakerEntityQueryService, a thin subclass of + EntityQueryService<TEntity, TEntityDTO, TIdentifierType> + that overrides only the DTO-to-entity property map so API consumers can sort and filter on the computed + FullName while the pipeline translates it to (FirstName + " " + LastName) (MMCA.ADC.Conference.Application/Speakers/SpeakerEntityQueryService.cs:28-34). Eager-loading of child graphs is delegated to per-aggregate INavigationPopulator<in TEntity> implementations (EventNavigationPopulator, SessionNavigationPopulator, SpeakerNavigationPopulator, + ActivityNavigationPopulator, + SponsorNavigationPopulator, ConferenceCategoryNavigationPopulator), which encapsulate - which navigations to include and how to batch-load cross-source relationships (ADR-002); child entities - and the childless Question and Sponsor aggregates use the framework's + which navigations to include and how to batch-load cross-source relationships + (ADR-002); the childless + Question aggregate uses the framework's NullNavigationPopulator<TEntity> - because they are never the root of a full graph load - (MMCA.ADC.Conference.Application/DependencyInjection.cs:71-102).

      + because it is never the root of a full graph load + (MMCA.ADC.Conference.Application/DependencyInjection.cs:75).

      All of this is wired by DependencyInjection, the module's composition root - (MMCA.ADC.Conference.Application/DependencyInjection.cs:35, with the registration surface exposed as a - C# extension(IServiceCollection) member at DependencyInjection.cs:37-39). It explicitly binds the - closed generics Scrutor cannot infer (the cascade-deletion domain service at :44, the AI scoring - queue at :50-51, then each aggregate's navigation populator, query service, and delete handler at - :54-102, plus the two cross-module validation services at :105 and :108) and then calls - ScanModuleApplicationServices<ClassReference>() (DependencyInjection.cs:112) to discover the "many + (MMCA.ADC.Conference.Application/DependencyInjection.cs:39, with the registration surface exposed as a + C# extension(IServiceCollection) member at DependencyInjection.cs:41-43). It explicitly binds the + closed generics Scrutor cannot infer (the cascade-deletion domain service at :48, the AI scoring + queue at :54-55, then each aggregate's navigation populator, query service, and delete handler at + :58-115, plus the two cross-module validation services at :118 and :121) and then calls + ScanModuleApplicationServices<ClassReference>() (DependencyInjection.cs:125) to discover the "many small things" (every handler, mapper, validator, and event handler) by convention. AssemblyReference and ClassReference are the marker types that anchor that scan (MMCA.ADC.Conference.Application/AssemblyReference.cs:5 and :11). @@ -375,25 +416,34 @@

      Query ser

      Event-driven reactions: domain and integration handlers

      The application layer is also where the module reacts to events. Domain event handlers implement IDomainEventHandler<in TDomainEvent> and - run in-process after the aggregate's SaveChangesAsync. Two of the three are deliberately - observability-only: SessionCreatedHandler filters SessionChanged down to the - Added state and writes one structured log line + run in-process after the aggregate's SaveChangesAsync. Each subscribes to one entity's single + lifecycle event and switches on its state discriminator, the taxonomy + ADR-083 settles. Two of the + three are deliberately observability-only: SessionCreatedHandler filters + SessionChanged down to the Added state and writes one structured log line (MMCA.ADC.Conference.Application/Sessions/DomainEventHandlers/SessionCreatedHandler.cs:17-21), and RoomChangedHandler logs every room add/update/delete with the state on the - message (MMCA.ADC.Conference.Application/Events/DomainEventHandlers/RoomChangedHandler.cs:17-22), + message (MMCA.ADC.Conference.Application/Events/DomainEventHandlers/RoomChangedHandler.cs:17-18), which is the [Rubric §13, Observability & Operability] story: the event stream is where lifecycle telemetry is emitted, not the entity. SpeakerDeletedHandler is the one with a real side effect: on a Deleted state with a previously linked user it opens its own DI scope (the handler is a singleton), resolves IEventBus, and publishes SpeakerUnlinkedFromUser so Identity can clear User.LinkedSpeakerId - (MMCA.ADC.Conference.Application/Speakers/DomainEventHandlers/SpeakerDeletedHandler.cs:38-45, BR-70).

      + (MMCA.ADC.Conference.Application/Speakers/DomainEventHandlers/SpeakerDeletedHandler.cs:38-45, BR-70). + The write side of the same link works in the other direction and inside the transaction: + LinkUserToSpeakerHandler enforces the BR-208 one-speaker-per-user guard + (MMCA.ADC.Conference.Application/Speakers/UseCases/LinkUser/LinkUserToSpeakerHandler.cs:34-46) and + raises SpeakerLinkedToUser on the aggregate before the save, so the outbox row is captured in the + same SaveChangesAsync as the link (:51-56, + ADR-003).

      The integration event handler UserRegisteredHandler implements IIntegrationEventHandler<in TIntegrationEvent> and is the cross-module boundary in the other direction: when Identity publishes UserRegistered over the broker, Conference auto-links a speaker to that user (BR-207). The only auto-link signal is an - email match. The handler resolves the address through the Email value object (normalized to - lowercase, so the comparison is effectively case-insensitive) and orders the candidates so an unlinked - speaker wins and the choice stays deterministic when an address is shared + email match. The handler resolves the address through the + Email value object (normalized to lowercase, so the + comparison is effectively case-insensitive) and orders the candidates so an unlinked speaker wins and + the choice stays deterministic when an address is shared (MMCA.ADC.Conference.Application/Users/IntegrationEventHandlers/UserRegisteredHandler.cs:130-158). On a miss it runs a read-only name-match probe that counts unlinked speakers with the same first and last name and logs the count (UserRegisteredHandler.cs:167-193): the matched rows are never returned, @@ -411,9 +461,10 @@

      Event-driven rea the delivery mechanism, which is built for it (the outbox retries then dead-letters, MassTransit redelivers then moves the message to the error queue). Retrying is safe, because the "already linked to a different user" guard (UserRegisteredHandler.cs:66-70) makes the second attempt a no-op. Publishing - flows through the IEventBus abstraction and the outbox (ADR-003), so the application code never - references MassTransit. [Rubric §6, CQRS & Event-Driven] and [Rubric §7, Microservices Readiness]: the - module collaborates through events and interfaces, never direct cross-module type references.

      + flows through the IEventBus abstraction and the outbox + (ADR-003), so the application code + never references MassTransit. [Rubric §6, CQRS & Event-Driven] and [Rubric §7, Microservices Readiness]: + the module collaborates through events and interfaces, never direct cross-module type references.

      Two in-process services close the loop with the Engagement module. SessionBookmarkValidationService (MMCA.ADC.Conference.Application/Sessions/SessionBookmarkValidationService.cs:12) and @@ -422,7 +473,8 @@

      Event-driven rea Engagement-facing contracts ISessionBookmarkValidationService and IEventLiveValidationService, which - Engagement calls via gRPC when the modules run as separate services (ADR-007). The former gates session + Engagement calls via gRPC when the modules run as separate services + (ADR-007). The former gates session bookmarking through the domain's SessionInvariants (EnsureNotServiceSession for BR-91, EnsureStatusIsEligible for BR-49, SessionBookmarkValidationService.cs:33-38), so the eligibility rule is the domain's, not a copy, and @@ -432,7 +484,7 @@

      Event-driven rea :104, and which session a room is hosting right now at :143, so a check-in never has to trust a client-supplied session id) and deliberately does not compute the window itself: it delegates to the domain's CurrentEventSelector.GetLiveWindowUtc - (EventLiveValidationService.cs:242-243) so the midnight-to-midnight rule, the unknown-time-zone + (EventLiveValidationService.cs:242-246) so the midnight-to-midnight rule, the unknown-time-zone degradation, and the spring-forward-gap guard stay identical to the ones the home surfaces and the now/next snapshot use. Its session variant adds the assigned speaker ids (BR-236), the plenum flag, and the event's question-moderation default (BR-233, EventLiveValidationService.cs:90-100) after @@ -453,8 +505,11 @@

      Attendee-facing with Error.NotFound, and turns every exportable session into one VEVENT with the room as its location (MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportEventCalendarHandler.cs:24-59), with CalendarExportMapper doing the entity-to-entry shaping and owning the - IsExportable eligibility rule, and the framework's - IcsCalendarBuilder assembling the document + IsExportable eligibility rule, which itself defers the status half to + SessionStatuses.IsEligible so no second copy of the + allow-list can drift + (MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/CalendarExportMapper.cs:26-28), and + the framework's IcsCalendarBuilder assembling the document (ExportEventCalendarHandler.cs:61). An unresolvable IANA zone degrades to UTC rather than failing the export, defensively, for legacy rows (ExportEventCalendarHandler.cs:39-49). GetNowNextHandler builds the conference-day "happening now plus next up" snapshot @@ -468,7 +523,7 @@

      Attendee-facing rather than reading the clock directly (GetNowNextHandler.cs:20-22), which is what makes its "now" unit-testable at a fixed instant; the two export handlers instead stamp the .ics DTSTAMP from DateTimeOffset.UtcNow directly (ExportEventCalendarHandler.cs:61, - MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportSessionCalendarHandler.cs:59), + MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportSessionCalendarHandler.cs:59-62), so their timestamp is not injectable. [Rubric §14, Testability].

      The Sessionize import: Strategy-pattern orchestration

      The single most involved use case is importing a conference agenda from Sessionize.com. Sessionize @@ -503,7 +558,7 @@

      The Sessionize imp carrying its work via a shared SessionizeSyncContext and returning a SessionizeSyncResult with a primary and an optional secondary count (ISessionizeSyncStrategy.cs:21-28). An empty response is treated as success, not an error, and still - stamps the refresh (:96-111). Each strategy bulk-loads its entity family in one call (no N+1, + stamps the refresh (:95-111). Each strategy bulk-loads its entity family in one call (no N+1, MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionSyncStrategy.cs:21-29), upserts via the domain's Create/Update methods (SessionSyncStrategy.cs:79-114), skips soft-deleted rows (BR-136, SessionSyncStrategy.cs:79-85, warned once at RefreshFromSessionizeHandler.cs:128-131), @@ -513,8 +568,8 @@

      The Sessionize imp SQL Server accept Sessionize's own integer IDs before one batched SaveChangesAsync (:139). [Rubric §2, Design Patterns] (Strategy solving a real Open/Closed problem: a new entity family is a new strategy, not an edit to the orchestrator), [Rubric §12, Performance & Scalability] (bulk loads plus a - single save round-trip), and [Rubric §17, DevOps & Deployment] (the throttle and graceful per-entity - degradation make a re-import safe to run repeatedly).

      + single save round-trip), and [Rubric §17, DevOps & Deployment] (the throttle, the feature gate, and + graceful per-entity degradation make a re-import safe to run repeatedly).

      Decision support: AI scoring and content analytics

      The last cluster is session-selection decision support, analytics that help organizers triage proposals. GetSessionSelectionDashboardHandler is a composite @@ -553,7 +608,9 @@

      Decision support: AI a conflict (MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/ISessionScoringQueue.cs:4-14, consumed by SessionSelectionController). - SessionScoringQueue is a bounded Channel of capacity 16 + That bounded-queue-plus-hosted-drain shape is the pattern + ADR-052 settles for the whole + codebase. SessionScoringQueue is a bounded Channel of capacity 16 (MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/SessionScoringQueue.cs:38) with SingleReader set and FullMode = Wait paired with a non-blocking TryWrite (:43-49), so a full queue refuses the new request outright instead of dropping an earlier one, which matters because each run @@ -570,7 +627,7 @@

      Decision support: AI drain worker lives in infrastructure (SessionScoringProcessor), which is why DependencyInjection registers the concrete queue and the interface as the same - singleton instance (DependencyInjection.cs:50-51): two instances would mean producers writing to a + singleton instance (DependencyInjection.cs:54-55): two instances would mean producers writing to a queue nobody drains. [Rubric §12, Performance & Scalability] and [Rubric §29, Resilience & Business Continuity].

      The run itself is ScoreEventSessionsCommand, handled by @@ -594,7 +651,7 @@

      Decision support: AI Success flag and seven 1.0-10.0 sub-scores (overall, topic relevance, description quality, novelty, actionable takeaways, depth/insight quality, credibility/experience) in all cases (MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/IAiScoringService.cs:9, - IAiScoringService.cs:40-71), so one bad session never aborts the scoring loop. The input shapes are + IAiScoringService.cs:40-70), so one bad session never aborts the scoring loop. The input shapes are SessionScoringInput and SpeakerInfo (IAiScoringService.cs:23-37); the result is mapped onto the SessionAiScore aggregate for persistence @@ -607,27 +664,69 @@

      Decision support: AI populate), and bespoke handlers reserved for the genuinely complex 20% (the Sessionize import, the attendee-facing read models, and the decision-support analytics), each isolated behind a port or a strategy so it can evolve, be tested, and ultimately be extracted without disturbing the rest.

      +

      ActivityEventIdRules<T>

      +
      +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:74 · Level 0 · class (sealed)

      +
      +
        +
      • What it is: a reusable FluentValidation rule fragment that enforces "an activity must name the event it belongs to", applied to whichever EventIdentifierType property a caller points it at.
      • +
      • Depends on: FluentValidation's AbstractValidator<T> (NuGet, primer §3) and System.Linq.Expressions.Expression<Func<T, ...>> (BCL). The EventIdentifierType in the selector signature (ActivityValidationRules.cs:77) is the module's identifier alias, declared once as global using EventIdentifierType = int; in the Shared project (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8) and linked solution-wide, which is why no import for it appears in this file.
      • +
      • Concept, the module-local rule fragment. The fragment idiom itself (a tiny generic AbstractValidator<T> that a real validator folds in with FluentValidation's Include(...)) is taught in group-06 on RequiredStringRules<T> and its siblings. What the Conference module adds on top are two conventions: (1) the numeric or length bound is read from the domain's invariant class instead of a literal, so the constraint cannot drift between the domain factory, the EF configuration, and the request validator; and (2) every rule chain written by hand in this module attaches a stable dotted error code with WithErrorCode(...) next to its human-readable message, so an API client or a test can key off Activity.EventId.Required without string-matching English prose (the fragments that instead subclass a framework rule, such as ActivityNameRules<T>, inherit the base message and get no code). [Rubric §24, Forms, Validation & UX Safety] assesses whether validation is reused rather than copy-pasted across create and update paths and whether failures are machine-addressable: the fragment plus the error code is this module's answer to both. [Rubric §1, SOLID]: each fragment carries exactly one field contract, so changing that contract is a one-line edit in one place.
      • +
      • Walkthrough: one expression-bodied constructor, ActivityEventIdRules(Expression<Func<T, EventIdentifierType>> selector) (ActivityValidationRules.cs:77), whose entire body is RuleFor(selector).NotEmpty() with the message "You must specify an Event for the Activity" and the error code Activity.EventId.Required (ActivityValidationRules.cs:78-79). Because EventIdentifierType aliases int, NotEmpty() here rejects 0 (the default of an unset id) rather than a null.
      • +
      • Why it's built this way: the XML doc above the class (ActivityValidationRules.cs:69-73) states the rule's reason in domain terms: activities are scheduled per event, so an unscoped activity has nowhere to appear. Encoding that as a request-level rule means the caller gets a field-addressed validation failure before any handler or aggregate is touched.
      • +
      • Where it's used: Included by ActivityCreateRequestValidator (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/UseCases/Create/ActivityCreateRequestValidator.cs:12) and by that validator only. ActivityUpdateRequestValidator does not include it, because ActivityUpdateRequest carries no EventId at all: its doc comment (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/UseCases/Update/ActivityUpdateRequest.cs:6-9) records the choice, moving an activity between events is a create plus a delete, so a mistyped id cannot silently relocate a published social event.
      • +
      +

      ActivitySortOrderRules<T>

      +
      +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:111 · Level 0 · class (sealed)

      +
      +
        +
      • What it is: a reusable rule fragment enforcing that an activity's display sort order is non-negative.
      • +
      • Depends on: FluentValidation (AbstractValidator<T>), System.Linq.Expressions (BCL). No first-party dependency: unlike the string fragments in the same file it reads no domain constant.
      • +
      • Concept: the module-local rule fragment taught on ActivityEventIdRules<T>. [Rubric §16, Maintainability] assesses whether a constraint lives in one place; a single fragment shared by the create and update paths is that.
      • +
      • Walkthrough: one expression-bodied constructor, ActivitySortOrderRules(Expression<Func<T, int>> selector) (ActivityValidationRules.cs:114), body RuleFor(selector).GreaterThanOrEqualTo(0) with the message "Sort Order must be greater than or equal to 0" and the error code Activity.SortOrder.Negative (ActivityValidationRules.cs:115-116).
      • +
      • Why it's built this way: GreaterThanOrEqualTo(0) rather than GreaterThan(0) because zero is a legitimate "first in the list" position; the framework's shared PositiveIntRules<T> would have been the wrong fragment to reuse here, which is why this one exists locally.
      • +
      • Where it's used: Included by ActivityCreateRequestValidator (.../Activities/UseCases/Create/ActivityCreateRequestValidator.cs:14) and ActivityUpdateRequestValidator (.../Activities/UseCases/Update/ActivityUpdateRequestValidator.cs:13), each bound to its own SortOrder property. Its structural twin on the Categories side is CategoryItemSortRules<T>.
      • +
      +

      ActivityTimeRangeRules<T>

      +
      +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:87 · Level 0 · class (sealed)

      +
      +
        +
      • What it is: the one fragment in the activity family that validates a pair of properties rather than a single field: both ends of the activity's time range must be present, and the end must be on or after the start.
      • +
      • Depends on: FluentValidation (AbstractValidator<T>), System.Linq.Expressions (BCL). No domain constant.
      • +
      • Concept, the cross-field rule fragment. Single-field fragments can stay expression-bodied, but a rule that compares two properties needs the instance, not just the selected value. [Rubric §24, Forms, Validation & UX Safety] covers exactly this class of check (the one users hit most often and the one most likely to be duplicated inconsistently). The mechanism is worth learning once because every cross-field rule in the codebase uses it: FluentValidation's Must has an overload taking (instance, value), so the fragment compiles the other selector into a delegate at construction time and calls it against the instance inside the predicate.
      • +
      • Walkthrough: a two-selector constructor with a statement body, ActivityTimeRangeRules(Expression<Func<T, DateTime>> startTimeSelector, Expression<Func<T, DateTime>> endTimeSelector) (ActivityValidationRules.cs:90-92), building three rules:
          +
        • RuleFor(startTimeSelector).NotEmpty(), message "You must enter a Start Time", code Activity.StartTime.Required (:94-95).
        • +
        • RuleFor(endTimeSelector).NotEmpty(), message "You must enter an End Time", code Activity.EndTime.Required (:97-98).
        • +
        • var startTimeFunc = startTimeSelector.Compile(); (:100) turns the start-time expression into an executable Func<T, DateTime> once, at fragment construction, not per validation call. The third rule then hangs off the end-time selector and uses the two-argument Must((instance, endTime) => endTime >= startTimeFunc(instance)) (:101-102), reporting "End Time must be on or after the Start Time" with code Activity.EndTime.BeforeStart (:103). Attaching the comparison to the end selector is what makes the error surface on the end-time field in the UI.
        • +
        +
      • +
      • Why it's built this way: the class doc (ActivityValidationRules.cs:82-86) says the shape mirrors EventDateRangeRules, so the two schedule-bearing aggregates fail the same way for the same reason. Note that >= is deliberate, a zero-length activity passes; the rule bans only an end before its start.
      • +
      • Caveats / not in source: NotEmpty() on a non-nullable DateTime rejects default(DateTime), so a genuinely unset value is caught, but a caller that posts a real-but-implausible date (for example far outside the event window) is not: no range-versus-event check exists in this fragment.
      • +
      • Where it's used: Included by ActivityCreateRequestValidator (.../Create/ActivityCreateRequestValidator.cs:13) and ActivityUpdateRequestValidator (.../Update/ActivityUpdateRequestValidator.cs:12), each passing its own StartTime and EndTime selectors.
      • +

      AssemblyReference

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

        -
      • What it is: a tiny static class exposing the Conference Application assembly and its short name as two static readonly fields, so scanners and registrars have a strongly-typed handle on this assembly.
      • +
      • What it is: a tiny static class exposing the Conference Application assembly and its short name as two static readonly fields, so any reflection-driven tooling has a strongly-typed handle on this assembly.
      • Depends on: System.Reflection (BCL) only.
      • -
      • Concept, the assembly-anchor type. [Rubric §5, Vertical Slice] assesses whether a module is a self-contained, discoverable unit; a per-assembly anchor type is how the framework's reflection-based wiring finds "everything in the Conference Application layer" without hard-coding a namespace string. The two fields (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/AssemblyReference.cs:7-8) are Assembly = typeof(AssemblyReference).Assembly and AssemblyName = Assembly.GetName().Name ?? string.Empty, both computed once at type load. There is a sibling anchor of the same name in every layer (Conference Domain, Infrastructure, API), so a caller can name the exact assembly it means.
      • -
      • Walkthrough: two fields, no methods. Assembly (line 7) resolves the containing assembly via typeof(AssemblyReference).Assembly; AssemblyName (line 8) reads Assembly.GetName().Name, falling back to string.Empty when the runtime reports a null simple name.
      • -
      • Why it's built this way: taking typeof(AssemblyReference).Assembly is refactor-safe (renaming the assembly or moving the file changes nothing), which is why the framework prefers an anchor type over a hard-coded Assembly.Load("...").
      • -
      • Where it's used: assembly-scoped tooling that needs the Conference Application assembly by reference. The convention scan performed inside DependencyInjection instead anchors on ClassReference, because that API is generic over a type argument rather than over an Assembly value.
      • +
      • Concept, the assembly-anchor type. [Rubric §5, Vertical Slice] assesses whether a module is a self-contained, discoverable unit; a per-assembly anchor is how reflection-based wiring names "everything in the Conference Application layer" without hard-coding a namespace or assembly string. There is a sibling anchor of the same name in every layer of the module (Conference Domain, Infrastructure, API) and in every other module, so a caller can always name the exact assembly it means.
      • +
      • Walkthrough: two fields, no methods. Assembly = typeof(AssemblyReference).Assembly (AssemblyReference.cs:7) resolves the containing assembly from the type itself; AssemblyName = Assembly.GetName().Name ?? string.Empty (AssemblyReference.cs:8) reads the simple name and falls back to an empty string when the runtime reports null. Both are computed once at type load.
      • +
      • Why it's built this way: typeof(X).Assembly is refactor-safe (renaming the assembly, moving the file, or restructuring the namespace changes nothing), which is why the codebase prefers an anchor type over Assembly.Load("...") with a literal.
      • +
      • Where it's used: nothing in MMCA.ADC references MMCA.ADC.Conference.Application.AssemblyReference today (a repo-wide search over Source/ and Tests/ returns no consumer). The convention scan performed inside DependencyInjection anchors on ClassReference instead, because that framework API is generic over a type argument rather than over an Assembly value. The class is kept for symmetry with the other layers' anchors and for tooling that wants the Assembly object directly.

      CategoryItemSortRules<T>

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:40 · Level 0 · class (sealed)

        -
      • What it is: a reusable FluentValidation rule fragment that enforces a non-negative sort order (>= 0) on whichever int property a caller points it at.
      • -
      • Depends on: FluentValidation (AbstractValidator<T>, NuGet), System.Linq.Expressions (BCL). No first-party dependency: unlike its two siblings in the same file it reads no domain constant.
      • -
      • Concept introduced, the generic reusable validator rule. [Rubric §1, SOLID] (single responsibility, do not repeat) and [Rubric §16, Maintainability] (one place to change a constraint) both apply. Rather than re-declare "sort must be non-negative" inside every create/update validator, the rule is written once as a generic AbstractValidator<T> whose constructor takes an Expression<Func<T, int>> selector (ConferenceCategoryValidationRules.cs:43) naming the property that carries the sort value. A concrete command validator then folds the fragment into itself with FluentValidation's Include(...), which merges the fragment's rules into the including validator's rule set instead of nesting a child validator. This is the composition idiom every reusable rule class in the Conference module follows.
      • -
      • Walkthrough: one constructor, CategoryItemSortRules(Expression<Func<T, int>> selector) (ConferenceCategoryValidationRules.cs:43), written as an expression-bodied member whose whole body is a single chained call: RuleFor(selector).GreaterThanOrEqualTo(0) with the message "Sort order must be greater than or equal to 0" and the error code CategoryItem.Sort.Negative (ConferenceCategoryValidationRules.cs:44-45). Attaching a stable error code (not just prose) lets clients and tests key off the failure without string-matching the message.
      • +
      • What it is: a reusable rule fragment enforcing a non-negative sort order (>= 0) on whichever int property a caller points it at, for category items.
      • +
      • Depends on: FluentValidation (AbstractValidator<T>), System.Linq.Expressions (BCL). No first-party dependency: unlike its two siblings in the same file it reads no domain constant.
      • +
      • Concept: the module-local rule fragment taught on ActivityEventIdRules<T>. [Rubric §1, SOLID] (one responsibility per fragment) and [Rubric §16, Maintainability] (one place to change the constraint) both apply.
      • +
      • Walkthrough: one expression-bodied constructor, CategoryItemSortRules(Expression<Func<T, int>> selector) (ConferenceCategoryValidationRules.cs:43), whose whole body is RuleFor(selector).GreaterThanOrEqualTo(0) with the message "Sort order must be greater than or equal to 0" and the error code CategoryItem.Sort.Negative (ConferenceCategoryValidationRules.cs:44-45).
      • Why it's built this way: generic over T so the same fragment serves both the add-item and update-item command shapes; sealed because it is a leaf composition unit with no intended subclassing.
      • Where it's used: Included by AddCategoryItemCommandValidator (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/UseCases/AddCategoryItem/AddCategoryItemCommandValidator.cs:12) and UpdateCategoryItemCommandValidator (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/UseCases/UpdateCategoryItem/UpdateCategoryItemCommandValidator.cs:12), each binding the selector to its own Sort property. Sibling fragments in the same file are CategoryItemNameRules<T> and ConferenceCategoryTitleRules<T>.
      @@ -638,11 +737,173 @@

      ClassReference

      • What it is: an empty marker class used purely as a typeof anchor for assembly scanning; it declares no members.
      • Depends on: nothing.
      • -
      • Concept, the scan-anchor marker. [Rubric §2, Design Patterns] assesses idiomatic registration wiring. The framework's scanning API is generic over an anchor type, so ScanModuleApplicationServices<ClassReference>() reads as "scan the assembly that contains ClassReference", that is, this Application layer. The class body is empty (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/AssemblyReference.cs:11); its only job is to be a compile-time-checked stand-in for the assembly.
      • -
      • Walkthrough: public class ClassReference { } on one line. No fields, no methods, not sealed or static (it has to be usable as a generic type argument).
      • -
      • Where it's used: passed as the type argument to services.ScanModuleApplicationServices<ClassReference>() in DependencyInjection (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112). The generic parameter it satisfies is declared as TAssemblyMarker on ScanModuleApplicationServices in MMCA.Common (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:115).
      • +
      • Concept, the scan-anchor marker. [Rubric §2, Design Patterns] assesses idiomatic registration wiring. The framework's scanning API is generic over an anchor type, so ScanModuleApplicationServices<ClassReference>() reads as "scan the assembly that contains ClassReference", that is, this Application layer. The class body is empty (AssemblyReference.cs:11); its only job is to be a compile-time-checked stand-in for the assembly.
      • +
      • Walkthrough: public class ClassReference { } on one line. No fields, no methods, and deliberately neither sealed nor static, because a static class cannot be used as a generic type argument.
      • Why it's built this way: a dedicated marker keeps the scan call site refactor-safe and avoids anchoring the scan on a real domain or handler type that might later move to another assembly.
      • +
      • Where it's used: passed as the type argument to services.ScanModuleApplicationServices<ClassReference>() in DependencyInjection (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125). The generic parameter it satisfies is TAssemblyMarker on ScanModuleApplicationServices, declared in MMCA.Common (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:140-141) with a where TAssemblyMarker : class constraint.
      • +
      +

      GetPublicActivityFilterQuery

      +
      +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.UseCases.GetPublicActivityFilter · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/UseCases/GetPublicActivityFilter/GetPublicActivityFilterQuery.cs:13 · Level 0 · record (sealed)

      +
      +
        +
      • What it is: a parameterless query record asking one question: "which activities may an anonymous or non-privileged caller see?". It carries no data at all; the answer it triggers is a specification, not a page of rows.
      • +
      • Depends on: nothing. public sealed record GetPublicActivityFilterQuery(); is the entire type, a positional record with an empty parameter list.
      • +
      • Concept, the specification-returning query. Most CQRS queries return data. This family returns a filter: a Specification<TEntity, TIdentifierType> the caller then hands to the generic read pipeline, which ANDs it with whatever paging, sorting and field-selection the request already asked for. [Rubric §11, Security] assesses whether authorization is enforced at the data boundary rather than trusted to the UI: expressing "public visibility" as a server-built specification means a non-privileged caller's query is narrowed before it reaches the database, no matter which endpoint or filter string they sent. [Rubric §6, CQRS & Event-Driven]: the visibility rule is a first-class query use case with its own handler, so it is unit-testable and reusable instead of being an if buried in a controller. The empty record is the CQRS convention taken to its logical end, the query has no inputs because the answer depends only on server state (which events are published) and never on the caller's arguments.
      • +
      • Walkthrough: no members. The teaching is in the two doc comments. The summary (GetPublicActivityFilterQuery.cs:3-7) states the business rule: an activity is publicly visible when the event it belongs to is published (BR-108), so an event still being assembled does not leak its social programme before announcement. The remarks (:8-12) state the shape choice: Activity carries a real EventId column, so the rule resolves to a published-event id list and comes back as an Activity.EventId IN (...) criteria; no navigation join is involved, so the criteria stays engine-portable and Activity keeps its by-id boundary to Event.
      • +
      • Why it's built this way: keeping the criteria to scalar id comparisons rather than a navigation join is the polyglot-persistence safeguard of ADR-018, a filter written this way translates on any supported engine, not just SQL Server. It also preserves the DDD rule that one aggregate references another by id (ADR-006 draws the same boundary at the storage level).
      • +
      • Where it's used: constructed by ActivitiesController in BuildPublicActivitySpecificationAsync (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/ActivitiesController.cs:66) and handled by GetPublicActivityFilterHandler. It is one of the nine public-filter queries in this module, alongside GetPublicSessionFilterQuery, GetPublicSpeakerFilterQuery, GetPublicSponsorFilterQuery and the rest.
      • +
      +

      ActivityDescriptionRules<T>

      +
      +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:25 · Level 7 · class (sealed)

      +
      +
        +
      • What it is: a length-only rule fragment for the optional activity description.
      • +
      • Depends on: OptionalStringRules<T> (its base class, MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:25), ActivityInvariants (its DescriptionMaxLength constant), System.Linq.Expressions (BCL).
      • +
      • Concept: the module-local rule fragment (ActivityEventIdRules<T>), here in its inheriting form. The four optional string rules in this file and ActivityNameRules<T> do not write a RuleFor chain at all: they subclass a framework fragment and pass it a field label plus the domain's max-length constant. That is the module's whole contribution, binding a generic rule to a domain invariant. [Rubric §16, Maintainability].
      • +
      • Walkthrough: one constructor, ActivityDescriptionRules(Expression<Func<T, string?>> selector) (ActivityValidationRules.cs:28), whose body is only a base call: : base(selector, "Activity Description", ActivityInvariants.DescriptionMaxLength) (:29). The base contributes MaximumLength(maxLength) and nothing else, so null and empty are both accepted. DescriptionMaxLength is 2000 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/ActivityInvariants.cs:16).
      • +
      • Why it's built this way: reading the ceiling from ActivityInvariants means the number is declared once and shared by the domain factory, the EF column configuration, and this validator, so a schema change cannot leave a stale validator behind.
      • +
      • Caveats: the inherited fragments emit no WithErrorCode, so their failures carry a message but no stable code, unlike the hand-written fragments in the same file. And unlike the name and venue fields, which have domain-side guards emitting coded errors (ActivityInvariants.cs:36, :48, :60, :72), the description has only the constant at ActivityInvariants.cs:16 and no Ensure... guard beside it, so this fragment is the enforcement point on the request path.
      • +
      • Where it's used: Included by ActivityCreateRequestValidator (.../Create/ActivityCreateRequestValidator.cs:15) and ActivityUpdateRequestValidator (.../Update/ActivityUpdateRequestValidator.cs:14).
      • +
      +

      ActivityNameRules<T>

      +
      +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:13 · Level 7 · class (sealed)

      +
      +
        +
      • What it is: the required-string rule fragment for an activity's display name: non-empty and within the domain's maximum length.
      • +
      • Depends on: RequiredStringRules<T> (its base class, MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:13), ActivityInvariants (NameMaxLength), System.Linq.Expressions (BCL).
      • +
      • Concept: the inheriting form of the module-local rule fragment, as on ActivityDescriptionRules<T>. This one subclasses the required base rather than the optional one, which is the only structural difference between the two. [Rubric §1, SOLID], [Rubric §24, Forms, Validation & UX Safety].
      • +
      • Walkthrough: one constructor, ActivityNameRules(Expression<Func<T, string>> selector) (ActivityValidationRules.cs:16), body : base(selector, "Activity Name", ActivityInvariants.NameMaxLength) (:17). Note the non-nullable string selector, versus the string? of the optional siblings: the compiler enforces at the call site that only a required property can be passed here. The base contributes NotEmpty() plus MaximumLength(200) (ActivityInvariants.NameMaxLength is 200, .../Domain/Activities/ActivityInvariants.cs:13), with messages built from the "Activity Name" label.
      • +
      • Where it's used: Included by ActivityCreateRequestValidator (.../Create/ActivityCreateRequestValidator.cs:11) and ActivityUpdateRequestValidator (.../Update/ActivityUpdateRequestValidator.cs:11), pointed at each request's Name.
      • +
      +

      ActivityVenueAddressRules<T>

      +
      +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:49 · Level 7 · class (sealed)

      +
      +
        +
      • What it is: a length-only rule fragment for the optional street address of an activity's venue.
      • +
      • Depends on: OptionalStringRules<T>, ActivityInvariants (VenueAddressMaxLength), System.Linq.Expressions (BCL).
      • +
      • Concept: the inheriting rule fragment taught on ActivityDescriptionRules<T>. [Rubric §16, Maintainability].
      • +
      • Walkthrough: one constructor (ActivityValidationRules.cs:52) delegating to : base(selector, "Venue Address", ActivityInvariants.VenueAddressMaxLength) (:53). The ceiling is 500 (.../Domain/Activities/ActivityInvariants.cs:22), the tightest of the four optional activity strings.
      • +
      • Where it's used: Included by ActivityCreateRequestValidator (.../Create/ActivityCreateRequestValidator.cs:17) and ActivityUpdateRequestValidator (.../Update/ActivityUpdateRequestValidator.cs:16).
      • +
      +

      ActivityVenueNameRules<T>

      +
      +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:37 · Level 7 · class (sealed)

      +
      +
        +
      • What it is: a length-only rule fragment for the optional name of the venue an activity happens at.
      • +
      • Depends on: OptionalStringRules<T>, ActivityInvariants (VenueNameMaxLength), System.Linq.Expressions (BCL).
      • +
      • Concept: the inheriting rule fragment taught on ActivityDescriptionRules<T>.
      • +
      • Walkthrough: one constructor (ActivityValidationRules.cs:40) delegating to : base(selector, "Venue Name", ActivityInvariants.VenueNameMaxLength) (:41), ceiling 200 (.../Domain/Activities/ActivityInvariants.cs:19). The class doc (ActivityValidationRules.cs:32-35) records the semantics the emptiness carries: an empty venue name means the main conference venue, which is precisely why this rule is the optional base and not the required one. Absence is meaningful data here, not a missing field.
      • +
      • Where it's used: Included by ActivityCreateRequestValidator (.../Create/ActivityCreateRequestValidator.cs:16) and ActivityUpdateRequestValidator (.../Update/ActivityUpdateRequestValidator.cs:15).
      • +
      +

      ActivityVenueUrlRules<T>

      +
      +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:62 · Level 7 · class (sealed)

      +
      +
        +
      • What it is: a length-only rule fragment for the optional external website URL of an activity's venue.
      • +
      • Depends on: OptionalStringRules<T>, ActivityInvariants (VenueUrlMaxLength), System.Linq.Expressions (BCL).
      • +
      • Concept: the inheriting rule fragment taught on ActivityDescriptionRules<T>. [Rubric §9, API & Contract Design] is worth a note here: the module chooses not to constrain the URL's format at the contract boundary.
      • +
      • Walkthrough: one constructor (ActivityValidationRules.cs:65) delegating to : base(selector, "Venue URL", ActivityInvariants.VenueUrlMaxLength) (:66), ceiling 2000 (.../Domain/Activities/ActivityInvariants.cs:25).
      • +
      • Why it's built this way: the class doc (ActivityValidationRules.cs:56-61) is explicit that the check is length-only, the value is stored as an opaque string, matching the sponsor website-URL precedent. Keeping it a plain string with no Uri parse and no regex means an organizer pasting a slightly non-canonical link is not blocked at the boundary, and the 2000-character ceiling is the practical URL limit rather than a domain rule.
      • +
      • Caveats: no scheme check exists in this fragment, so javascript: or a relative value passes validation. Anything rendering this value is responsible for its own output handling; [Rubric §26, Front-End Security] lands on the consumer, not here.
      • +
      • Where it's used: Included by ActivityCreateRequestValidator (.../Create/ActivityCreateRequestValidator.cs:18) and ActivityUpdateRequestValidator (.../Update/ActivityUpdateRequestValidator.cs:17).
      • +
      +

      CategoryItemNameRules<T>

      +
      +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:27 · Level 7 · class (sealed)

      +
      +
        +
      • What it is: a rule fragment enforcing that a category-item name is non-empty and no longer than the maximum the domain defines.
      • +
      • Depends on: FluentValidation (AbstractValidator<T>), CategoryInvariants (its CategoryItemNameMaxLength field), System.Linq.Expressions and System.Globalization (BCL).
      • +
      • Concept: the module-local rule fragment taught on ActivityEventIdRules<T>. Unlike the activity string rules, the two Categories string fragments write their own chain instead of subclassing the framework's, which is what lets them attach error codes. [Rubric §1, SOLID], [Rubric §16, Maintainability].
      • +
      • Walkthrough: one expression-bodied constructor, CategoryItemNameRules(Expression<Func<T, string>> selector) (ConferenceCategoryValidationRules.cs:30), body a single chained RuleFor(selector): .NotEmpty() with message "You must enter a Category Item Name" and code CategoryItem.Name.Required (:32), then .MaximumLength(CategoryInvariants.CategoryItemNameMaxLength) with code CategoryItem.Name.MaxLength (:33). The bound is 500 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:17), declared static readonly int rather than const, so the value is read at runtime and consumers are not compile-time-baked to it. The max-length message interpolates that same value through string.Create(CultureInfo.InvariantCulture, $"...") rather than plain interpolation, which is what keeps the analyzers-as-errors build satisfied about culture-sensitive formatting.
      • +
      • Why it's built this way: pulling the bound from the domain invariants instead of a literal is the "one place to change a constraint" discipline; validating at the Application boundary as well as in the domain factory means the caller gets a field-level failure with a code instead of a generic domain error. The two layers deliberately do not share a code: this validator emits CategoryItem.Name.MaxLength while the domain path emits CategoryItem.Name.TooLong (CategoryInvariants.cs:27), so a failure tells you which layer rejected the value.
      • +
      • Where it's used: Included by AddCategoryItemCommandValidator (.../Categories/UseCases/AddCategoryItem/AddCategoryItemCommandValidator.cs:11) and UpdateCategoryItemCommandValidator (.../Categories/UseCases/UpdateCategoryItem/UpdateCategoryItemCommandValidator.cs:11), each pointed at its own Name. Siblings: CategoryItemSortRules<T>, ConferenceCategoryTitleRules<T>.
      • +
      +

      ConferenceCategoryTitleRules<T>

      +
      +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:13 · Level 7 · class (sealed)

      +
      +
        +
      • What it is: a rule fragment enforcing that a conference-category title is non-empty and within the domain-defined maximum length.
      • +
      • Depends on: FluentValidation (AbstractValidator<T>), CategoryInvariants (its TitleMaxLength field), System.Linq.Expressions and System.Globalization (BCL).
      • +
      • Concept: the module-local rule fragment taught on ActivityEventIdRules<T>, in the same hand-written-chain form as CategoryItemNameRules<T>.
      • +
      • Walkthrough: one expression-bodied constructor, ConferenceCategoryTitleRules(Expression<Func<T, string>> selector) (ConferenceCategoryValidationRules.cs:16), with a single chained RuleFor(selector): .NotEmpty(), message "You must enter a Category Title", code Category.Title.Required (:18), then .MaximumLength(CategoryInvariants.TitleMaxLength) with code Category.Title.MaxLength (:19). TitleMaxLength is 255 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:14). Structurally identical to CategoryItemNameRules<T>, differing only in the target property, the constant, and the error-code prefix (Category. versus CategoryItem., which is how a client tells a parent failure from a child one).
      • +
      • Where it's used: Included by ConferenceCategoryCreateRequestValidator (.../Categories/UseCases/Create/ConferenceCategoryCreateRequestValidator.cs:10) and ConferenceCategoryUpdateRequestValidator (.../Categories/UseCases/Update/ConferenceCategoryUpdateRequestValidator.cs:10). Note that these two validate inbound request records while the two item validators validate commands: the fragment is generic over T, so it does not care which.
      • +
      +

      DependencyInjection

      +
      +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:39 · Level 11 · class (static, extension block)

      +
      +
        +
      • What it is: the Conference module's application-layer composition root: a static class exposing AddModuleConferenceApplication(ApplicationSettings), which registers every application service this module needs into the DI container.
      • +
      • Depends on: ApplicationSettings; the Conference domain aggregates and children (Event, Session, Speaker, Category, CategoryItem, Question, Activity, Sponsor, Room, EventSpeaker, EventQuestionAnswer, SessionSpeaker, SessionCategoryItem, SessionQuestionAnswer, SpeakerCategoryItem, SpeakerQuestionAnswer); the framework generics EntityQueryService<TEntity, TEntityDTO, TIdentifierType>, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, INavigationPopulator<in TEntity>, NullNavigationPopulator<TEntity>, DeleteEntityCommand<TEntity, TIdentifierType> and DeleteEntityHandler<TEntity, TIdentifierType>; the cross-module ports ISessionBookmarkValidationService and IEventLiveValidationService; ClassReference; plus Microsoft.Extensions.DependencyInjection and its Extensions namespace (the TryAdd* helpers).
      • +
      • Concept, the module composition root written as an extension(IServiceCollection) block. [Rubric §5, Vertical Slice] assesses whether each module wires its own slice rather than a central registry knowing about every type; [Rubric §2, Design Patterns] assesses idiomatic registration. The registration method lives inside a C# extension(IServiceCollection services) block (DependencyInjection.cs:41), so callers write services.AddModuleConferenceApplication(settings): the same extension(T) member style used for DI across the codebase, explained once in the primer. The class comment (DependencyInjection.cs:34-38) names the deliberate split this file embodies: explicit registrations for the generic per-entity services (which cannot be discovered by convention, because the closed generic has to be spelled out) and Scrutor assembly scanning for everything hand-written (handlers, mappers, validators), so adding a use case needs no edit here.
      • +
      • Walkthrough (in body order):
          +
        • _ = applicationSettings (DependencyInjection.cs:45): the settings object is part of the module registration contract but this module does not branch on it today; the discard plus the inline comment "Reserved for future use (e.g., profiler decorators)" is what keeps the unused-parameter analyzer quiet without dropping the parameter from the signature.
        • +
        • Domain service (:48): IEventCascadeDeletionDomainService to EventCascadeDeletionDomainService as a singleton. It is stateless, which is why singleton is safe.
        • +
        • Session scoring queue (:50-55): SessionScoringQueue is registered concretely (:54) and behind ISessionScoringQueue, with the interface registration written as a factory that resolves the concrete singleton (sp => sp.GetRequiredService<SessionScoringQueue>(), :55) rather than as a second TryAddSingleton<ISessionScoringQueue, SessionScoringQueue>(). The comment above it (:50-53) states why: long-running AI scoring runs off the request path, the hosted drain in Infrastructure needs the reader side and the completion callback, and both registrations must resolve to the ONE instance, or producers would enqueue work into a queue nobody drains. This is the classic two-registrations-one-instance trap, and the factory form is the fix.
        • +
        • Aggregate roots with custom navigation populators (:57-72): Event (:58-60), Session (:62-64), Speaker (:66-68) and Category (:70-72) each get three scoped registrations, an INavigationPopulator<T> (their bespoke populators EventNavigationPopulator, SessionNavigationPopulator, SpeakerNavigationPopulator, ConferenceCategoryNavigationPopulator), an IEntityQueryService<T, TDTO, TId>, and a delete-command handler. Three of them deviate from the generic default, and the deviations are the interesting part: Event binds its delete to the bespoke DeleteEventHandler (:60) because deleting an event has to cascade, Session binds its delete to the bespoke DeleteSessionHandler (:64), and Speaker binds its query service to the bespoke SpeakerEntityQueryService (:67). Everything else uses the framework generics unchanged.
        • +
        • Aggregate roots with no navigation properties at all (:74-77): Question is the only member of this bucket, and it is the only entity in the whole file registered with NullNavigationPopulator<Question> (:75), the do-nothing populator that satisfies the contract when there is nothing to eager-load, plus the generic EntityQueryService and DeleteEntityHandler.
        • +
        • Aggregate roots whose only navigation is the parent Event FK reference (:79-86): Activity (:80-82) and Sponsor (:84-86) each get a bespoke populator (ActivityNavigationPopulator, SponsorNavigationPopulator) that resolves just that back-reference, the generic query service, and the generic DeleteEntityHandler.
        • +
        • Child entities (:88-115): Room, CategoryItem, EventSpeaker, EventQuestionAnswer, SessionSpeaker, SessionCategoryItem, SessionQuestionAnswer and SpeakerCategoryItem each get their own FK populator (RoomNavigationPopulator, CategoryItemNavigationPopulator, EventSpeakerNavigationPopulator, EventQuestionAnswerNavigationPopulator, SessionSpeakerNavigationPopulator, SessionCategoryItemNavigationPopulator, SessionQuestionAnswerNavigationPopulator, SpeakerCategoryItemNavigationPopulator) plus the base EntityQueryService, and deliberately no delete handler: children are removed through their aggregate root, never addressed directly by a delete command. SpeakerQuestionAnswer is the one asymmetry, it gets SpeakerQuestionAnswerNavigationPopulator (:115) but no query service, and the comment above it (:113-114) says so outright: it has no query service today, and registering the populator future-proofs the one that would be added alongside it.
        • +
        • Cross-module ports (:117-121): ISessionBookmarkValidationService to SessionBookmarkValidationService (:118) and IEventLiveValidationService to EventLiveValidationService (:121), the in-process interfaces the Engagement module consumes (the file's comments name Engagement and its live layer as the consumers).
        • +
        • Convention scan (:125): services.ScanModuleApplicationServices<ClassReference>() sweeps this assembly for, per the comment on :123-124, domain event handlers, DTO/request mappers, command/query handlers, and validators. The framework method behind it (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:140) runs a series of Scrutor services.Scan(...) passes, singleton lifetimes for domain and integration event handlers, scoped for mappers and projectors. The method then returns services (:127) for fluent chaining.
        • +
        +
      • +
      • Why it's built this way: every registration uses TryAdd* rather than Add*, so a host (or a test) can register its own implementation first and this method will not clobber it or produce a duplicate registration. Splitting explicit generics from convention scanning keeps a file that wires roughly twenty entities under 130 lines while still registering the module's dozens of hand-written handlers. Registering the cross-module validation services here as ordinary in-process interfaces is exactly what lets the same module code run co-located or split behind gRPC without a rewrite (ADR-007, ADR-008); the per-entity INavigationPopulator registrations are the populator pattern of ADR-002 being bound one entity at a time.
      • +
      • Where it's used: called by the Conference module's API-layer registration (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/DependencyInjection.cs:25), which is itself invoked through the module's IModule implementation during host startup; modules are discovered and registered in topological order by the ModuleLoader.
      • +
      • Caveats / not in source: applicationSettings is accepted and immediately discarded; the "profiler decorators" the comment reserves it for do not exist in this layer today. ISessionizeService is absent from this file on purpose: the Application layer owns that port, but its typed-client registration lives in Conference Infrastructure.
      • +
      +

      GetPublicActivityFilterHandler

      +
      +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.UseCases.GetPublicActivityFilter · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/UseCases/GetPublicActivityFilter/GetPublicActivityFilterHandler.cs:16 · Level 11 · class (sealed)

      +
      +
        +
      • What it is: the query handler that answers GetPublicActivityFilterQuery. It resolves the ids of the published events and returns an Activity.EventId IN (...) specification the read pipeline can AND into any activity query.
      • +
      • Depends on: IUnitOfWork (constructor-injected, GetPublicActivityFilterHandler.cs:17), PublicConferenceVisibility, Specification<TEntity, TIdentifierType> and InlineSpecification<TEntity, TIdentifierType>, IQueryHandler<in TQuery, TResult>, Result, and the Activity aggregate it filters.
      • +
      • Concept, the handler that returns a specification instead of rows. [Rubric §6, CQRS & Event-Driven] assesses whether read intent is modelled as first-class, individually testable use cases; [Rubric §11, Security] assesses whether the visibility rule is applied server-side at the data boundary. Combining the two produces the shape here: the handler's TResult is not a DTO or a page but Result<Specification<Activity, ActivityIdentifierType>> (:18). The caller receives a composable predicate and hands it to the generic entity-query layer, which applies it before paging and sorting, so the rule cannot be defeated by a crafted query string. [Rubric §12, Performance & Scalability] is served by the same choice: the filter arrives as one translated IN clause on a column, not as an in-memory post-filter over a full result set.
      • +
      • Walkthrough: a primary-constructor class taking IUnitOfWork unitOfWork (:16-17), with one method.
          +
        • HandleAsync(GetPublicActivityFilterQuery query, CancellationToken cancellationToken = default) (:21-23). The query parameter is unused by design, the query record has no fields.
        • +
        • await PublicConferenceVisibility.GetPublishedEventIdsAsync(unitOfWork, cancellationToken) (:25-27) does the work. That shared helper resolves the read repository for Event (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:40), projects e => e.Id under the predicate e => e.IsPublished with asTracking: false (PublicConferenceVisibility.cs:42-44), and materializes the sequence once so the caller embeds a stable collection EF can translate into IN (PublicConferenceVisibility.cs:46-47). Every await in this path is .ConfigureAwait(false), the codebase convention for library code.
        • +
        • The return (:29-30) wraps a new InlineSpecification<Activity, ActivityIdentifierType>(a => publishedEventIds.Contains(a.EventId)) in Result.Success<...>. InlineSpecification is the lambda-carrying specification, so no bespoke specification class is needed for a one-line criteria. The handler has no failure path: an empty published-event list is a valid answer that yields a specification matching nothing.
        • +
        +
      • +
      • Why it's built this way: the class doc (:10-15) states the alignment: the id-list shape mirrors the sponsor, speaker and session public filters, so no navigation join is required and the criteria stays translatable on any engine (ADR-018). Centralizing the id resolution in PublicConferenceVisibility rather than repeating the IsPublished projection in nine handlers means the definition of "published" changes in one place.
      • +
      • Where it's used: injected into ActivitiesController as IQueryHandler<GetPublicActivityFilterQuery, Result<Specification<Activity, ActivityIdentifierType>>> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/ActivitiesController.cs:42) and called from BuildPublicActivitySpecificationAsync (ActivitiesController.cs:60-69), which short-circuits to null for privileged readers (ActivitiesController.cs:63-64, guarded by IsPrivileged at :50) so Organizers and ContentEditors keep seeing activities of events still being assembled. It is registered by convention, not explicitly: the scan in DependencyInjection (DependencyInjection.cs:125) picks up every IQueryHandler<,> in the assembly.
      • +
      • Caveats / not in source: the two-query shape (published event ids, then activities) is two round trips by construction. Whether the id list is cached anywhere is not determinable from this file; nothing in the handler or in PublicConferenceVisibility memoizes it, so each call re-reads the published-event ids.
      • +
      +

      GetPublicEventSpeakerFilterQuery

      +
      +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.GetPublicEventSpeakerFilter · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterQuery.cs:10 · Level 0 · record

      +
      +
        +
      • What it is: a parameterless marker query asking for the filter that limits EventSpeaker junction rows to the ones a non-privileged caller may read. The whole type is one line: public sealed record GetPublicEventSpeakerFilterQuery; (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterQuery.cs:10).
      • +
      • Depends on: nothing first-party, nothing external. It is an empty record with no positional parameters.
      • +
      • Concept: none new. The marker-query shape is taught under GetPublicSessionFilterQuery, and the visibility rules themselves are defined once in PublicConferenceVisibility. What this query adds is a junction with two parents. The doc comment (GetPublicEventSpeakerFilterQuery.cs:3-9) states both legs and the leak each one closes: a row is readable only when its parent event is published (BR-108), because otherwise the join endpoints would list the speakers of an unannounced event and reveal that it exists, AND when its parent speaker is publicly visible (BR-239), because otherwise the association endpoint would hand back the whole Sessionize-imported roster that the speaker list itself hides. [Rubric §11, Security] assesses whether an anonymous surface can be used to infer the existence of content the caller may not read; a join row with two parents can leak through either of them.
      • +
      • Walkthrough: no members. Every line of behavior lives in GetPublicEventSpeakerFilterHandler.
      • +
      • Why it's built this way: the rules belong to the two parents, not to the join row, so the query carries no arguments and the handler derives its answer from the shared resolver instead of restating either rule.
      • +
      • Where it's used: handled by GetPublicEventSpeakerFilterHandler; injected into EventSpeakersController as an IQueryHandler<in TQuery, TResult> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:51) and constructed in its private BuildPublicSpecificationAsync helper (EventSpeakersController.cs:71), which returns null for privileged readers (:68-69) and the specification for everyone else. That helper feeds all four [AllowAnonymous] reads: the unpaged list (:90), the paged list (:120), the lookup (:148), and the by-id read (:178).
      • +
      +
      +

      GetPublicRoomFilterQuery

      +
      +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.GetPublicRoomFilter · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/GetPublicRoomFilter/GetPublicRoomFilterQuery.cs:14 · Level 0 · record

      +
      +
        +
      • What it is: a parameterless marker query asking for the filter that limits Room rows to the ones a non-privileged caller may read: a room is publicly visible when the event it belongs to is published (BR-108). The whole type is one line, public sealed record GetPublicRoomFilterQuery(); (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/GetPublicRoomFilter/GetPublicRoomFilterQuery.cs:14).
      • +
      • Depends on: nothing first-party, nothing external.
      • +
      • Concept: none new. The marker-query shape is taught under GetPublicSessionFilterQuery; the rule it names is resolved once in PublicConferenceVisibility. [Rubric §11, Security] assesses whether an anonymous surface leaks the existence or the detail of content the caller may not read. The doc comment names the concrete exposure this query closes (GetPublicRoomFilterQuery.cs:3-8): rooms of an unpublished event stay hidden, so an event still being assembled does not publish its floor plan, room names, or capacities before the agenda is announced.
      • +
      • Walkthrough: no members. Note the declaration-style difference from its sibling in this same unit: this one is written with an empty parameter list, GetPublicRoomFilterQuery(), which declares a primary constructor, while GetPublicEventSpeakerFilterQuery is written without one, GetPublicEventSpeakerFilterQuery;. Both are constructed identically at the call site as new X(), so the difference is cosmetic; it is worth knowing only so you do not read meaning into it.
      • +
      • Why it's built this way: the remarks (GetPublicRoomFilterQuery.cs:9-13) record the design choice behind the shape of the answer. Room carries a real EventId column (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Room.cs:37), so the rule resolves to a published-event id list and comes back as a Room.EventId IN (...) criteria. No navigation join is involved, which keeps the criteria engine-portable (ADR-018) and keeps Room's reference to Event a by-id boundary rather than a traversal.
      • +
      • Where it's used: handled by GetPublicRoomFilterHandler; injected into RoomsController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/RoomsController.cs:97) and constructed in its private BuildPublicRoomSpecificationAsync helper (RoomsController.cs:120).
      +

      SessionizeCategoryItem

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Sessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:60 · Level 0 · record (sealed)

      @@ -650,11 +911,12 @@

      SessionizeCategoryItem

      • What it is: the leaf DTO for one category value from the Sessionize "View All" API (for example "Beginner" under the "Level" category, or ".NET" under "Track"): an Id, a Name, and a Sort order.
      • Depends on: System.Text.Json.Serialization ([JsonPropertyName], BCL) only.
      • -
      • Concept introduced, the external-API contract DTO. [Rubric §9, API & Contract Design] assesses whether contracts crossing a boundary are explicit; [Rubric §32, Dependency & Supply-Chain] assesses controlling the shape of data arriving from a third party. The whole Sessionize* family in this one file models the JSON wire format of the external system the conference agenda is imported from. Every member of the family follows the same three rules: it is a sealed record, every property is init-only, and every property carries a [JsonPropertyName("...")] mapping the C# name onto the exact Sessionize field (SessionizeModels.cs:62-69). Reference-typed properties get a non-null default (Name { get; init; } = string.Empty at line 66, collections = []), so a payload missing a field deserializes to an empty value rather than a null the import code would have to guard. Modeling the external contract as its own dedicated immutable type, instead of binding straight onto domain entities, is the anti-corruption discipline: the outside shape is captured here and translated into the domain by the sync strategies. The remaining Sessionize* sections cross-reference back to this one rather than repeating the shape.
      • +
      • Concept introduced, the external-API contract DTO. [Rubric §9, API & Contract Design] assesses whether contracts crossing a boundary are explicit; [Rubric §32, Dependency & Supply-Chain] assesses controlling the shape of data arriving from a third party. The whole Sessionize* family in this one file models the JSON wire format of the external system the conference agenda is imported from. Every member of the family follows the same three rules: it is a sealed record, every property is init-only, and every property carries a [JsonPropertyName("...")] mapping the C# name onto the exact Sessionize field (SessionizeModels.cs:62-69). Reference-typed properties get a non-null default (Name { get; init; } = string.Empty at line 66, collections = []), so a payload missing a field deserializes to an empty value rather than a null the import code would have to guard. Modeling the external contract as its own dedicated immutable type, instead of binding straight onto domain entities, is the anti-corruption discipline: the outside shape is captured here and translated into the domain by the sync strategies. The remaining Sessionize* sections cross-reference back to this one rather than repeating the shape.
      • Walkthrough: three init properties, Id (int, line 63), Name (string, empty default, line 66), Sort (int, line 69), each JSON-mapped by the attribute on the line above it. No behavior at all; it is a pure data-transfer record.
      • -
      • Why it's built this way: record gives structural equality and a compact declaration (the same reasoning as the ValueObject discussion), and init plus non-null defaults means System.Text.Json can populate it while callers can never mutate it afterward.
      • -
      • Where it's used: nested inside SessionizeCategory's Items; consumed by the category import path, CategorySyncStrategy.
      • +
      • Why it's built this way: record gives structural equality and a compact declaration (the same reasoning as the ValueObject discussion), and init plus non-null defaults means System.Text.Json can populate it while callers can never mutate it afterward.
      • +
      • Where it's used: nested inside SessionizeCategory's Items; consumed by the category import path, CategorySyncStrategy.
      +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Sessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:126 · Level 0 · record (sealed)

      @@ -662,11 +924,12 @@
      • What it is: the DTO for one speaker social link from Sessionize: a Title, a Url, and a LinkType (for example "Twitter" or "LinkedIn").
      • Depends on: System.Text.Json.Serialization (BCL) only.
      • -
      • Concept: an external-API contract DTO, the pattern taught on SessionizeCategoryItem. [Rubric §9, API & Contract Design].
      • +
      • Concept: an external-API contract DTO, the pattern taught on SessionizeCategoryItem. [Rubric §9, API & Contract Design].
      • Walkthrough: three init string properties, all empty-defaulted and JSON-mapped: Title (line 129), Url (line 132), LinkType (line 135).
      • Why it's built this way: Url is a plain string, not a Uri. Keeping it a string means a non-canonical value from Sessionize cannot fail deserialization at the wire boundary; any parsing or validation happens later, in the import path, where a bad value can be reported as a warning instead of an exception.
      • -
      • Where it's used: nested inside SessionizeSpeaker's Links collection, read by SpeakerSyncStrategy.
      • +
      • Where it's used: nested inside SessionizeSpeaker's Links collection, read by SpeakerSyncStrategy.
      +

      SessionizeQuestion

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Sessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:25 · Level 0 · record (sealed)

      @@ -674,10 +937,11 @@

      SessionizeQuestion

      • What it is: the DTO for one custom-question definition from Sessionize (the question itself, not an answer to it): Id, Question text, QuestionType, and a Sort order.
      • Depends on: System.Text.Json.Serialization (BCL) only.
      • -
      • Concept: an external-API contract DTO (SessionizeCategoryItem). [Rubric §9, API & Contract Design].
      • +
      • Concept: an external-API contract DTO (SessionizeCategoryItem). [Rubric §9, API & Contract Design].
      • Walkthrough: four init properties, Id (int, line 28), Question (string, empty default, line 31), QuestionType (string, empty default, line 34), Sort (int, line 37). QuestionType arrives as a free-form string, so the import decides how to interpret it rather than the wire model constraining it to an enum.
      • -
      • Where it's used: nested inside SessionizeResponse's Questions; imported by QuestionSyncStrategy. Its answers are carried separately, by SessionizeQuestionAnswer.
      • +
      • Where it's used: nested inside SessionizeResponse's Questions (SessionizeModels.cs:21); imported by QuestionSyncStrategy. Its answers are carried separately, by SessionizeQuestionAnswer.
      +

      SessionizeQuestionAnswer

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Sessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:192 · Level 0 · record (sealed)

      @@ -685,10 +949,11 @@

      SessionizeQuestionAnswer

      • What it is: the DTO for one answer to a Sessionize custom question: the QuestionId it answers and its AnswerValue.
      • Depends on: System.Text.Json.Serialization (BCL) only.
      • -
      • Concept: an external-API contract DTO (SessionizeCategoryItem). [Rubric §9, API & Contract Design].
      • +
      • Concept: an external-API contract DTO (SessionizeCategoryItem). [Rubric §9, API & Contract Design].
      • Walkthrough: two init properties, QuestionId (int, line 195) and AnswerValue (string, empty default, line 198). The answer points back at its question by id rather than nesting the question definition, which is why the same answer record can hang off two different parents.
      • -
      • Where it's used: nested inside both SessionizeSpeaker's and SessionizeSession's QuestionAnswers collections.
      • +
      • Where it's used: nested inside both SessionizeSpeaker's (SessionizeModels.cs:122) and SessionizeSession's (SessionizeModels.cs:170) QuestionAnswers collections.
      +

      SessionizeRoom

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Sessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:73 · Level 0 · record (sealed)

      @@ -696,145 +961,134 @@

      SessionizeRoom

      • What it is: the DTO for one room from Sessionize: Id, Name, Sort.
      • Depends on: System.Text.Json.Serialization (BCL) only.
      • -
      • Concept: an external-API contract DTO (SessionizeCategoryItem). [Rubric §9, API & Contract Design].
      • -
      • Walkthrough: three init properties, Id (int, line 76), Name (string, empty default, line 79), Sort (int, line 82). Structurally identical to SessionizeCategoryItem; the two are kept as distinct types (rather than one shared "named thing" record) so a change on either side of the Sessionize contract cannot silently propagate to the other import path.
      • -
      • Where it's used: nested inside SessionizeResponse's Rooms; imported by RoomSyncStrategy. Sessions reference a room by RoomId, not by nesting this record.
      • +
      • Concept: an external-API contract DTO (SessionizeCategoryItem). [Rubric §9, API & Contract Design].
      • +
      • Walkthrough: three init properties, Id (int, line 76), Name (string, empty default, line 79), Sort (int, line 82). Structurally identical to SessionizeCategoryItem; the two are kept as distinct types (rather than one shared "named thing" record) so a change on either side of the Sessionize contract cannot silently propagate to the other import path.
      • +
      • Where it's used: nested inside SessionizeResponse's Rooms (SessionizeModels.cs:12); imported by RoomSyncStrategy. Sessions reference a room by RoomId (SessionizeModels.cs:173), not by nesting this record.
      +

      SessionizeCategory

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Sessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:41 · Level 1 · record (sealed)

        -
      • What it is: the DTO for one Sessionize category (for example "Level" or "Track"), owning the nested list of its SessionizeCategoryItem values.
      • -
      • Depends on: SessionizeCategoryItem (its Items collection); System.Text.Json.Serialization (BCL).
      • -
      • Concept: an external-API contract DTO (SessionizeCategoryItem). This is the first Sessionize* record that nests another, which is exactly why it sits one dependency level up. [Rubric §9, API & Contract Design].
      • +
      • What it is: the DTO for one Sessionize category (for example "Level" or "Track"), owning the nested list of its SessionizeCategoryItem values.
      • +
      • Depends on: SessionizeCategoryItem (its Items collection); System.Text.Json.Serialization (BCL).
      • +
      • Concept: an external-API contract DTO (SessionizeCategoryItem). This is the first Sessionize* record that nests another, which is exactly why it sits one dependency level up. [Rubric §9, API & Contract Design].
      • Walkthrough: five init properties. Id (int, line 44), Title (string, empty default, line 47), and Sort (int, line 50) are the flat fields; Type is string? (line 53), so an absent JSON field stays null rather than being flattened to an empty string; Items is IReadOnlyList<SessionizeCategoryItem> defaulted to the collection expression [] (line 56), so a category with no values deserializes to an empty list. Exposing the collection as IReadOnlyList<T> (not List<T>) keeps the record immutable in practice as well as by init.
      • -
      • Where it's used: nested inside SessionizeResponse's Categories; both the category and its items are reconciled against the domain by CategorySyncStrategy.
      • +
      • Where it's used: nested inside SessionizeResponse's Categories (SessionizeModels.cs:9); both the category and its items are reconciled against the domain by CategorySyncStrategy.
      +

      SessionizeSession

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Sessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:139 · Level 1 · record (sealed)

      • What it is: the richest Sessionize DTO: one conference session with its schedule, room, speaker references, category assignments, question answers, and live/recording metadata.
      • -
      • Depends on: SessionizeQuestionAnswer (its QuestionAnswers list, line 170); System.Text.Json.Serialization (BCL).
      • -
      • Concept: an external-API contract DTO (SessionizeCategoryItem), here at full width. [Rubric §9, API & Contract Design].
      • +
      • Depends on: SessionizeQuestionAnswer (its QuestionAnswers list, line 170); System.Text.Json.Serialization (BCL).
      • +
      • Concept: an external-API contract DTO (SessionizeCategoryItem), here at full width. [Rubric §9, API & Contract Design].
      • Walkthrough: sixteen init properties (SessionizeModels.cs:141-188). Five groups are worth knowing:
        • Id (int, line 143) is the only property in the whole file annotated [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] (line 142). Sessionize sometimes serializes a session id as a JSON string rather than a number, and that one attribute is what stops the whole import from failing on it.
        • -
        • StartsAt and EndsAt are DateTime? (lines 152 and 155), so an unscheduled session round-trips with nulls instead of a deserialization error. SessionSyncStrategy validates the pair rather than the wire model doing it: ValidateSessionTimes warns when a start date falls before the event's start date and when the duration is zero or negative, storing the value as-is per BR-122 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionSyncStrategy.cs:53-69).
        • +
        • StartsAt and EndsAt are DateTime? (lines 152 and 155), so an unscheduled session round-trips with nulls instead of a deserialization error. SessionSyncStrategy validates the pair rather than the wire model doing it: ValidateSessionTimes (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionSyncStrategy.cs:53) warns when a start falls before the event's start date and when an end falls after its end date (BR-86, :55-64), and warns on a zero or negative duration while storing the value as-is per BR-122 (:66-70).
        • Speakers is IReadOnlyList<Guid> (line 164), CategoryItems is IReadOnlyList<int> (line 167), and RoomId is int? (line 173): the session references other entities by their Sessionize ids instead of nesting the full objects, so those cross-references are resolved during import against the already-imported speakers, category items, and rooms.
        • -
        • Description, LiveUrl, RecordingUrl, and Status are nullable strings (lines 149, 176, 179, 182); LiveUrl/RecordingUrl stay string for the same reason as SessionizeLink's Url.
        • +
        • Description, LiveUrl, RecordingUrl, and Status are nullable strings (lines 149, 176, 179, 182); LiveUrl and RecordingUrl stay string for the same reason as SessionizeLink's Url.
        • Four booleans classify the session: IsServiceSession (line 158) and IsPlenumSession (line 161) mark non-talk and plenary slots, IsInformed (line 185) and IsConfirmed (line 188) carry the speaker-communication state Sessionize tracks.
      • Why it's built this way: the id-reference lists mirror how Sessionize normalizes its own payload; keeping the DTO faithful to that shape (rather than pre-joining it) means the wire model stays a mechanical translation and every judgement call lives in the sync strategies, where it can emit a warning.
      • -
      • Where it's used: nested inside SessionizeResponse's Sessions; imported by SessionSyncStrategy under RefreshFromSessionizeHandler.
      • +
      • Where it's used: nested inside SessionizeResponse's Sessions (SessionizeModels.cs:18); imported by SessionSyncStrategy under RefreshFromSessionizeHandler.
      +

      SessionizeSpeaker

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Sessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:86 · Level 1 · record (sealed)

        -
      • What it is: the DTO for one speaker from Sessionize, with profile fields, social SessionizeLinks, question answers, and id references to the speaker's sessions and category items.
      • -
      • Depends on: SessionizeLink (Links), SessionizeQuestionAnswer (QuestionAnswers); System.Text.Json.Serialization (BCL).
      • -
      • Concept: an external-API contract DTO (SessionizeCategoryItem). [Rubric §9, API & Contract Design].
      • +
      • What it is: the DTO for one speaker from Sessionize, with profile fields, social SessionizeLinks, question answers, and id references to the speaker's sessions and category items.
      • +
      • Depends on: SessionizeLink (Links), SessionizeQuestionAnswer (QuestionAnswers); System.Text.Json.Serialization (BCL).
      • +
      • Concept: an external-API contract DTO (SessionizeCategoryItem). [Rubric §9, API & Contract Design].
      • Walkthrough: twelve init properties (SessionizeModels.cs:88-122). Id is a Guid (line 89), unlike the int ids of every other Sessionize entity in this file. The optional profile fields Bio (line 98), TagLine (line 101), ProfilePicture (line 104), and FullName (line 116) are nullable strings, while FirstName (line 92) and LastName (line 95) are empty-defaulted non-nullable ones. IsTopSpeaker is a bool (line 107). Four collections, Links (line 110), Sessions (IReadOnlyList<int>, line 113), CategoryItems (IReadOnlyList<int>, line 119), and QuestionAnswers (line 122), are all IReadOnlyList<T> defaulted to [].
      • -
      • Why it's built this way: the Guid speaker id lines up with the Conference module's SpeakerIdentifierType = Guid alias, so the import can carry a Sessionize speaker id straight into a domain Speaker key without a conversion or a lookup table. That both FullName and the FirstName/LastName pair exist is Sessionize's redundancy, not the module's: the wire model keeps both and lets SpeakerSyncStrategy choose.
      • -
      • Where it's used: nested inside SessionizeResponse's Speakers; imported by SpeakerSyncStrategy, which takes the record directly as a parameter (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SpeakerSyncStrategy.cs:102).
      • +
      • Why it's built this way: the Guid speaker id lines up with the Conference module's SpeakerIdentifierType = Guid alias, so the import can carry a Sessionize speaker id straight into a domain Speaker key without a conversion or a lookup table. That both FullName and the FirstName/LastName pair exist is Sessionize's redundancy, not the module's: the wire model keeps both and lets SpeakerSyncStrategy choose.
      • +
      • Where it's used: nested inside SessionizeResponse's Speakers (SessionizeModels.cs:15); imported by SpeakerSyncStrategy, which takes the record directly as a parameter (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SpeakerSyncStrategy.cs:102). One consequence of that import is load-bearing elsewhere in this chapter: every synced speaker without an active link gets an EventSpeaker row (SpeakerSyncStrategy.cs:59), which is why GetPublicEventSpeakerFilterHandler cannot treat that junction as an acceptance signal.
      +

      SessionizeResponse

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Sessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:6 · Level 2 · record (sealed)

      • What it is: the top-level envelope for the Sessionize "View All" API response, holding the five parallel collections (Categories, Rooms, Speakers, Sessions, Questions) that make up an entire conference import payload.
      • -
      • Depends on: SessionizeCategory, SessionizeRoom, SessionizeSpeaker, SessionizeSession, SessionizeQuestion; System.Text.Json.Serialization (BCL).
      • -
      • Concept: the root of the external-API contract DTO tree that SessionizeCategoryItem taught. [Rubric §9, API & Contract Design]. This is the object ISessionizeService returns and the single input every Sessionize sync strategy reads from.
      • +
      • Depends on: SessionizeCategory, SessionizeRoom, SessionizeSpeaker, SessionizeSession, SessionizeQuestion; System.Text.Json.Serialization (BCL).
      • +
      • Concept: the root of the external-API contract DTO tree that SessionizeCategoryItem taught. [Rubric §9, API & Contract Design]. This is the object ISessionizeService returns and the single input every Sessionize sync strategy reads from.
      • Walkthrough: five init IReadOnlyList<...> properties, each defaulted to [] and JSON-mapped to the lower-cased Sessionize field name: Categories (line 9), Rooms (line 12), Speakers (line 15), Sessions (line 18), Questions (line 21). "View All" is Sessionize's denormalized endpoint: it returns every entity kind in one document, which is why this envelope has one collection per kind rather than a paged, per-type shape.
      • Why it's built this way: one immutable envelope makes the import easy to reason about, the strategies receive the whole snapshot at once and reconcile the domain against it; and the empty-list defaults mean a payload missing a section is still a valid, non-null response the strategies can iterate over without null checks.
      • -
      • Where it's used: returned (nullable) by ISessionizeService; handed to the sync strategies through SessionizeSyncContext by RefreshFromSessionizeHandler, under the RefreshFromSessionizeCommand use case.
      • +
      • Where it's used: returned (nullable) by ISessionizeService; carried to the sync strategies as the required Response property of SessionizeSyncContext (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionizeSyncContext.cs:13), which RefreshFromSessionizeHandler builds under the RefreshFromSessionizeCommand use case.
      +

      ISessionizeService

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Sessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/ISessionizeService.cs:6 · Level 3 · interface

        -
      • What it is: the one-method contract for fetching a whole conference from the Sessionize "View All" API, returning a SessionizeResponse or null when the response is empty.
      • -
      • Depends on: SessionizeResponse (its return type). Nothing else, no HttpClient, no options type.
      • -
      • Concept introduced, the outbound-port interface (dependency inversion at an external boundary). [Rubric §3, Clean Architecture] assesses whether the Application layer depends only on abstractions it owns, with concrete adapters living further out; [Rubric §7, Microservices Readiness] assesses isolating third-party calls behind a swappable boundary. Here the Application layer declares what it needs from Sessionize (this interface), while the HTTP client that actually calls the API lives in Conference Infrastructure and implements it: SessionizeService, registered as a typed client with the base address https://sessionize.com/api/v2/ via services.AddHttpClient<ISessionizeService, SessionizeService>(...) (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:21-23). That inversion is what keeps the import use case unit-testable: the test tier substitutes FakeSessionizeService and feeds a canned response with no network at all.
      • +
      • What it is: the one-method contract for fetching a whole conference from the Sessionize "View All" API, returning a SessionizeResponse or null when the response is empty.
      • +
      • Depends on: SessionizeResponse (its return type). Nothing else, no HttpClient, no options type.
      • +
      • Concept introduced, the outbound-port interface (dependency inversion at an external boundary). [Rubric §3, Clean Architecture] assesses whether the Application layer depends only on abstractions it owns, with concrete adapters living further out; [Rubric §7, Microservices Readiness] assesses isolating third-party calls behind a swappable boundary. Here the Application layer declares what it needs from Sessionize (this interface), while the HTTP client that actually calls the API lives in Conference Infrastructure and implements it: SessionizeService, registered as a typed client with the base address https://sessionize.com/api/v2/ via services.AddHttpClient<ISessionizeService, SessionizeService>(...) (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:22-24). That inversion is what keeps the import use case exercisable without a network: the integration tier substitutes FakeSessionizeService (MMCA.ADC/Tests/Integration/MMCA.ADC.Conference.IntegrationTests/Infrastructure/FakeSessionizeService.cs:12) and feeds a canned response.
      • Walkthrough: a single method, Task<SessionizeResponse?> GetAllAsync(string sessionizeCode, CancellationToken cancellationToken = default) (ISessionizeService.cs:12). sessionizeCode is the per-event Sessionize code (the XML doc gives the example "kqf8l42a", line 9); the nullable return signals an empty or absent response instead of throwing, so the caller decides whether an empty import is an error; and the defaulted trailing CancellationToken follows the codebase convention that every async boundary is cancelable.
      • -
      • Why it's built this way: a narrow, single-purpose port is the smallest surface the import needs, which makes both the real HTTP adapter and its test double trivial to write and keeps retry/timeout policy an Infrastructure concern.
      • -
      • Where it's used: constructor-injected into RefreshFromSessionizeHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeHandler.cs:21) and called there as GetAllAsync(@event.SessionizeCode, cancellationToken) (RefreshFromSessionizeHandler.cs:81). Note that it is not registered by DependencyInjection in this layer: the Application layer owns the interface, Infrastructure owns and registers the implementation.
      • -
      -

      CategoryItemNameRules<T>

      -
      -

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:27 · Level 7 · class (sealed)

      -
      -
        -
      • What it is: a reusable FluentValidation rule fragment enforcing that a category-item name is non-empty and no longer than the maximum the domain defines.
      • -
      • Depends on: FluentValidation (AbstractValidator<T>), CategoryInvariants (its CategoryItemNameMaxLength constant), System.Linq.Expressions and System.Globalization (BCL).
      • -
      • Concept: the generic reusable validator rule taught on CategoryItemSortRules<T>. [Rubric §1, SOLID] and [Rubric §16, Maintainability].
      • -
      • Walkthrough: one expression-bodied constructor, CategoryItemNameRules(Expression<Func<T, string>> selector) (ConferenceCategoryValidationRules.cs:30), whose body is a single chained RuleFor(selector): .NotEmpty() with message "You must enter a Category Item Name" and error code CategoryItem.Name.Required (line 32), then .MaximumLength(CategoryInvariants.CategoryItemNameMaxLength) with error code CategoryItem.Name.MaxLength (line 33). The bound is read from CategoryInvariants (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:17, currently 500), which the domain factory, the EF configuration, and this validator all share, so the length rule cannot drift between layers. The message interpolates that same constant through string.Create(CultureInfo.InvariantCulture, ...) rather than plain interpolation, which is what keeps the analyzer-as-error build happy about culture-sensitive formatting.
      • -
      • Why it's built this way: pulling the bound from the domain invariants instead of a literal here is the "one place to change a constraint" discipline; validating it at the Application boundary as well as in the domain factory means the caller gets a field-level validation failure (with an error code) instead of a generic domain error. Note the two do not duplicate the error code: the validator emits CategoryItem.Name.MaxLength while the domain path emits CategoryItem.Name.TooLong (CategoryInvariants.cs:27), so a failure tells you which layer rejected the value.
      • -
      • Where it's used: Included by AddCategoryItemCommandValidator (.../AddCategoryItem/AddCategoryItemCommandValidator.cs:11) and UpdateCategoryItemCommandValidator (.../UpdateCategoryItem/UpdateCategoryItemCommandValidator.cs:11), each pointed at its own Name property. Siblings: CategoryItemSortRules<T>, ConferenceCategoryTitleRules<T>.
      • +
      • Why it's built this way: a narrow, single-purpose port is the smallest surface the import needs, which makes both the real HTTP adapter and its test double trivial to write and keeps retry and timeout policy an Infrastructure concern.
      • +
      • Where it's used: constructor-injected into RefreshFromSessionizeHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeHandler.cs:21) and called there as GetAllAsync(@event.SessionizeCode, cancellationToken) (RefreshFromSessionizeHandler.cs:81). Note that it is not registered by this layer's DependencyInjection: the Application layer owns the interface, Infrastructure owns and registers the implementation.
      -

      ConferenceCategoryTitleRules<T>

      -
      -

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:13 · Level 7 · class (sealed)

      -
      -
        -
      • What it is: a reusable FluentValidation rule fragment enforcing that a conference-category title is non-empty and within the domain-defined maximum length.
      • -
      • Depends on: FluentValidation (AbstractValidator<T>), CategoryInvariants (its TitleMaxLength constant), System.Linq.Expressions and System.Globalization (BCL).
      • -
      • Concept: the generic reusable validator rule taught on CategoryItemSortRules<T>. [Rubric §1, SOLID] and [Rubric §16, Maintainability].
      • -
      • Walkthrough: one expression-bodied constructor, ConferenceCategoryTitleRules(Expression<Func<T, string>> selector) (ConferenceCategoryValidationRules.cs:16), with a single chained RuleFor(selector): .NotEmpty() with message "You must enter a Category Title" and error code Category.Title.Required (line 18), then .MaximumLength(CategoryInvariants.TitleMaxLength) with error code Category.Title.MaxLength (line 19). TitleMaxLength is 255 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:14). Structurally identical to CategoryItemNameRules<T>, differing only in the target property, the constant, and the error-code prefix.
      • -
      • Where it's used: Included by ConferenceCategoryCreateRequestValidator (.../Categories/UseCases/Create/ConferenceCategoryCreateRequestValidator.cs:10) and ConferenceCategoryUpdateRequestValidator (.../Categories/UseCases/Update/ConferenceCategoryUpdateRequestValidator.cs:10). Note that these two validate the inbound request records while the two item validators validate commands: the fragment is generic over T, so it does not care which.
      • -
      -

      DependencyInjection

      +
      +

      GetPublicEventSpeakerFilterHandler

      -

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:35 · Level 11 · class (static, extension block)

      +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.GetPublicEventSpeakerFilter · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterHandler.cs:22 · Level 11 · class (sealed)

        -
      • What it is: the Conference module's application-layer composition root: a static class exposing AddModuleConferenceApplication(ApplicationSettings), which registers every application service this module needs into the DI container.
      • -
      • Depends on: ApplicationSettings; the Conference domain aggregates and children (Event, Session, Speaker, Category, CategoryItem, Question, Sponsor, Room, SessionSpeaker, SpeakerCategoryItem and the rest); the framework generics EntityQueryService<TEntity, TEntityDTO, TIdentifierType>, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, INavigationPopulator<in TEntity>, NullNavigationPopulator<TEntity>, DeleteEntityCommand<TEntity, TIdentifierType> and DeleteEntityHandler<TEntity, TIdentifierType>; the cross-module ports ISessionBookmarkValidationService and IEventLiveValidationService; ClassReference; plus Microsoft.Extensions.DependencyInjection and its Extensions namespace (the TryAdd* helpers).
      • -
      • Concept introduced, the module composition root written as an extension(IServiceCollection) block. [Rubric §5, Vertical Slice] assesses whether each module wires its own slice rather than a central registry knowing about every type; [Rubric §2, Design Patterns] assesses idiomatic registration. The registration method lives inside a C# extension(IServiceCollection services) block (DependencyInjection.cs:37) so callers write services.AddModuleConferenceApplication(settings): the same extension(T) member style used for DI across the codebase, explained once in the primer. The class comment (DependencyInjection.cs:30-34) names the deliberate split this file embodies: explicit registrations for the generic per-entity services (which cannot be discovered by convention, because the closed generic has to be spelled out) and Scrutor assembly scanning for everything hand-written (handlers, mappers, validators), so adding a use case needs no edit here.
      • -
      • Walkthrough (in body order):
          -
        • _ = applicationSettings (DependencyInjection.cs:41): the settings object is part of the module registration contract but this module does not branch on it today; the discard plus the inline comment "Reserved for future use (e.g., profiler decorators)" is what keeps the unused-parameter analyzer quiet without dropping the parameter.
        • -
        • Domain service (line 44): IEventCascadeDeletionDomainService -> EventCascadeDeletionDomainService as a singleton. It is stateless, which is why singleton is safe.
        • -
        • Session scoring queue (lines 46-51): SessionScoringQueue is registered concretely (line 50) and behind ISessionScoringQueue, with the interface registration written as a factory that resolves the concrete singleton (sp => sp.GetRequiredService<SessionScoringQueue>(), line 51) rather than as a second TryAddSingleton<ISessionScoringQueue, SessionScoringQueue>(). The comment above it (lines 46-49) states why: long-running AI scoring runs off the request path, the hosted drain in Infrastructure needs the reader side and the completion callback, and both registrations must resolve to the ONE instance, or producers would enqueue work into a queue nobody drains. This is the classic two-registrations-one-instance trap, and the factory form is the fix.
        • -
        • Aggregate roots with custom navigation populators (lines 53-68): Event, Session, Speaker, and Category each get three scoped registrations, an INavigationPopulator<T> (their bespoke populators, EventNavigationPopulator, SessionNavigationPopulator, SpeakerNavigationPopulator, ConferenceCategoryNavigationPopulator), an IEntityQueryService<T, TDTO, TId>, and a delete-command handler. Three of those deviate from the generic default and the deviations are the interesting part: Event binds its delete to the bespoke DeleteEventHandler (line 56) because deleting an event has to cascade, Session binds its delete to a bespoke DeleteSessionHandler (line 60), and Speaker binds its query service to the bespoke SpeakerEntityQueryService (line 63); everything else uses the framework generics unchanged.
        • -
        • Aggregate roots with no child navigations (lines 70-77): Question and Sponsor each get the same trio but with NullNavigationPopulator<T>, the do-nothing populator that satisfies the contract when there is nothing to eager-load, plus the generic DeleteEntityHandler.
        • -
        • Child entities (lines 79-102): Room, CategoryItem, EventSpeaker, EventQuestionAnswer, SessionSpeaker, SessionCategoryItem, SessionQuestionAnswer, and SpeakerCategoryItem each get a NullNavigationPopulator plus the base EntityQueryService, and deliberately no delete handler: children are removed through their aggregate root, never addressed directly by a delete command.
        • -
        • Cross-module ports (lines 104-108): ISessionBookmarkValidationService -> SessionBookmarkValidationService and IEventLiveValidationService -> EventLiveValidationService, the in-process interfaces the Engagement service consumes (over gRPC once the modules run as separate processes; the file's comments name Engagement and its live layer as the consumers).
        • -
        • Convention scan (line 112): services.ScanModuleApplicationServices<ClassReference>() sweeps this assembly for, per the comment on lines 110-111, domain event handlers, DTO/request mappers, command/query handlers, and validators. Then the method returns services (line 114) for fluent chaining.
        • -
        +
      • What it is: the handler for GetPublicEventSpeakerFilterQuery. It asks PublicConferenceVisibility twice, once for the published event ids and once for the visible speaker ids, and returns an EventSpeaker.EventId IN (...) AND EventSpeaker.SpeakerId IN (...) specification.
      • +
      • Depends on: IUnitOfWork (:23, injected only to hand on to the resolver), PublicConferenceVisibility, InlineSpecification<TEntity, TIdentifierType> and its base Specification<TEntity, TIdentifierType>, EventSpeaker, and Result. It implements IQueryHandler<in TQuery, TResult> to Result<Specification<EventSpeaker, EventSpeakerIdentifierType>> (:24).
      • +
      • Concept introduced: the two-parent junction filter. [Rubric §11, Security]. The other public filters in this family each derive from a single parent: GetPublicRoomFilterHandler follows the event, GetPublicSpeakerFilterHandler follows the speaker's eligible sessions. This one ANDs two independent legs, and the remarks explain why the second is not redundant (:16-21): the Sessionize import writes an EventSpeaker row for every speaker in the response, which you can read in SpeakerSyncStrategy itself, where every synced speaker without an active link gets context.Event.AddEventSpeaker(null, ss.Id) (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SpeakerSyncStrategy.cs:59). An event-only filter would therefore republish the entire imported roster through the association endpoint, which is exactly what the public speaker list hides.
      • +
      • Concept: the duplicate scalar read, taken deliberately. [Rubric §12, Performance & Scalability]. The inline comment (:35-37) records a cost decision rather than an oversight. The junction read carries no event context, so the speaker rule spans every published event; both resolver calls read the Event table, described there as bounded at single-digit rows; and the duplicate scalar read was judged cheaper than threading the already-resolved ids through the shared resolver's signature. The trade-off is in the open: one extra projection query per request in exchange for keeping PublicConferenceVisibility's API narrow.
      • +
      • Walkthrough:
          +
        1. Resolve the published event ids: PublicConferenceVisibility.GetPublishedEventIdsAsync(unitOfWork, cancellationToken) (:31-33). Inside the resolver that is one scalar projection of Event.Id filtered by IsPublished, read untracked (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:42-44), then materialized once so the caller embeds a stable collection EF can translate to IN (PublicConferenceVisibility.cs:46-47).
        2. +
        3. Resolve the visible speaker ids: GetVisibleSpeakerIdsAsync(unitOfWork, cancellationToken: cancellationToken) (:38-40), with the optional event scope left at its default and spelled out with a named argument so it reads as a decision rather than an omission. Inside, that is the BR-239 chain: the published events (PublicConferenceVisibility.cs:109), the optional narrowing to one scoped event (:113-117), an empty answer when the scope is empty (:119-120), the eligible sessions inside that scope (:122-124), then the SessionSpeaker join projected down to distinct speaker ids (:126-133).
        4. +
        5. Wrap es => eventIds.Contains(es.EventId) && speakerIds.Contains(es.SpeakerId) in an InlineSpecification<TEntity, TIdentifierType> and return Result.Success (:42-44). There is no failure path: the handler cannot fail on its own terms.
        6. +
      • -
      • Why it's built this way: every registration uses TryAdd* rather than Add*, so a host (or a test) can register its own implementation first and this method will not clobber it or produce a duplicate-registration conflict. Splitting explicit generics from convention scanning keeps a file that wires roughly a dozen entities under 120 lines while still registering the module's dozens of hand-written handlers. Registering the cross-module validation services here as ordinary in-process interfaces is exactly what lets the same module code run co-located or split behind gRPC without a rewrite (ADR-007, ADR-008); the per-entity INavigationPopulator registrations are the ADR-002 populator pattern (ADR-002) being bound one entity at a time.
      • -
      • Where it's used: called by the Conference module's IModule registration during host startup; modules are discovered and registered in topological order by the ModuleLoader (G14, Module System & Composition).
      • -
      • Caveats / not-in-source: applicationSettings is accepted and immediately discarded; the "profiler decorators" the comment reserves it for do not exist in this layer today. ISessionizeService is absent from this file on purpose: its typed-client registration lives in Conference Infrastructure.
      • +
      • Why it's built this way: the summary states the shape (:10-15) and the remarks give the reason for the second leg (:16-21). Both legs are id lists turned into Contains, never navigation joins, so the criteria stays translatable on any provider (ADR-018), and deriving both from the shared resolver means the junction cannot drift away from the entities whose visibility it follows.
      • +
      • Where it's used: EventSpeakersController's BuildPublicSpecificationAsync (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:66-75) is the only consumer, and from there it reaches the unpaged list (:90), paged list (:120), lookup (:148), and by-id (:178) reads. Note that the class-level [HasPermission(ConferencePermissions.EventsManage)] (:46) is overridden per action by [AllowAnonymous] (:78, :101, :142, :164), which is exactly why the handler has to carry the visibility rules itself.
      • +
      • Testing: GetPublicEventSpeakerFilterHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterHandlerTests.cs:19), six tests on the shared HandlerTestBase<T>: the success shape (:74), a row whose event is published and whose speaker is visible (:83), a row on an unpublished event (:94), a row of a hidden speaker on a published event (:105), a world where no speaker is visible (:118), and one that captures the predicate the handler hands to the Event projection, compiles it, and asserts it accepts a published event and rejects an unpublished one (:128-147). One fixture detail is a property of the entity rather than of the test: EventSpeaker.EventId is get-only and written by EF (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventSpeaker.cs:23), so a row built through the factory in memory carries the default id, and the fixture uses default as its row event id (:21-22). [Rubric §14, Testability]: the speaker leg is the rule most likely to be dropped as redundant, and :105 is the test that would catch it.
      • +
      • Caveats / not-in-source: the controller maps a failed Result to null, meaning no filter: return result.IsSuccess ? result.Value : null; (EventSpeakersController.cs:74), which would widen the read rather than narrow it. Nothing in this handler can produce that failure today, so the exposure is latent rather than live, but it is the opposite of the fail-closed default the rest of the visibility code takes, and the same shape appears on the room controller. Both id lists are also materialized into the predicate, so the two IN lists grow with the number of published events and of publicly visible speakers; nothing in this file bounds either.
      -

      GetPublicEventSpeakerFilterQuery

      +
      +

      GetPublicRoomFilterHandler

      -

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.GetPublicEventSpeakerFilter · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterQuery.cs:10 · Level 0 · record

      +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.GetPublicRoomFilter · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/GetPublicRoomFilter/GetPublicRoomFilterHandler.cs:16 · Level 11 · class (sealed)

        -
      • What it is: a parameterless marker query asking for the filter that limits EventSpeaker junction rows to the ones a non-privileged caller may read. The whole type is one line: public sealed record GetPublicEventSpeakerFilterQuery; (GetPublicEventSpeakerFilterQuery.cs:10).
      • -
      • Depends on: nothing first-party, nothing external. It is an empty record with no positional parameters.
      • -
      • Concept: none new. The marker-query shape is taught under GetPublicSessionFilterQuery, the junction dimension under GetPublicSessionSpeakerFilterQuery, and the visibility rules themselves are defined once in PublicConferenceVisibility. What this query adds is a junction with two parents. The doc comment (GetPublicEventSpeakerFilterQuery.cs:3-9) states both legs and the leak each one closes: a row is readable only when its parent event is published (BR-108), because otherwise the join endpoints would list the speakers of an unannounced event and reveal that it exists, AND when its parent speaker is publicly visible (BR-239), because otherwise the association endpoint would hand back the whole Sessionize-imported roster that the speaker list itself hides. [Rubric §11, Security] assesses whether an anonymous surface can be used to infer the existence of content the caller may not read; a join row with two parents can leak through either of them.
      • -
      • Walkthrough: no members. Every line of behavior lives in GetPublicEventSpeakerFilterHandler.
      • -
      • Why it's built this way: the rules belong to the two parents, not to the join row, so the query carries no arguments and the handler derives its answer from the shared resolver instead of restating either rule.
      • -
      • Where it's used: handled by GetPublicEventSpeakerFilterHandler; injected into EventSpeakersController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:50) and constructed in its private BuildPublicSpecificationAsync helper (EventSpeakersController.cs:70), which returns null for privileged readers (:67-68) and the specification for everyone else. That helper feeds all four [AllowAnonymous] reads: the unpaged list (:89), the paged list (:119), the lookup (:147), and the by-id read (:177).
      • +
      • What it is: the handler for GetPublicRoomFilterQuery. It asks PublicConferenceVisibility for the published event ids and returns a Room.EventId IN (...) specification. It is the simplest member of the public-filter family: one resolver call, one predicate, no failure path.
      • +
      • Depends on: IUnitOfWork (:17, injected only to hand on to the resolver), PublicConferenceVisibility, InlineSpecification<TEntity, TIdentifierType> and its base Specification<TEntity, TIdentifierType>, Room, and Result. It implements IQueryHandler<in TQuery, TResult> to Result<Specification<Room, RoomIdentifierType>> (:18).
      • +
      • Concept: the query-that-returns-a-specification shape is taught under GetPublicSessionFilterHandler: a handler whose result is a reusable predicate rather than data, so the visibility rule is resolved once in the Application layer and applied by whichever read the controller is serving. [Rubric §6, CQRS & Event-Driven] assesses whether reads are expressed as explicit, single-purpose query objects; this is a query whose payload is the filter itself. [Rubric §11, Security] assesses the anonymous read surface: the rule here is one line of predicate, and it is the only thing standing between an unpublished event's venue layout and an anonymous caller.
      • +
      • Walkthrough:
          +
        1. HandleAsync (:21-23) takes the marker query and a CancellationToken.
        2. +
        3. Resolve the published event ids: PublicConferenceVisibility.GetPublishedEventIdsAsync(unitOfWork, cancellationToken) (:25-27). Inside the shared resolver that is a scalar, untracked projection of Event.Id where IsPublished, materialized once so the list embedded in the predicate is stable and EF-translatable to IN (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:36-47).
        4. +
        5. Wrap r => publishedEventIds.Contains(r.EventId) in an InlineSpecification<TEntity, TIdentifierType> and return Result.Success (:29-30). Nothing here can fail, so Result is used for pipeline uniformity rather than to carry an error.
        6. +
        +
      • +
      • Why it's built this way: the summary (:10-15) says the id-list shape mirrors the sponsor, speaker, and session public filters, so no navigation join is required and the criteria stays translatable on any engine (ADR-018). Room is the easy case for that rule because it carries a real EventId column (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Room.cs:37), so the parent's visibility is expressible directly on the child's own column, with no traversal and no join table. Sourcing the id list from the shared PublicConferenceVisibility rather than restating IsPublished here is what keeps one definition of "published" behind every public read.
      • +
      • Where it's used: RoomsController injects it as an IQueryHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/RoomsController.cs:97) and calls it from the private BuildPublicRoomSpecificationAsync helper (:114-124), which short-circuits to null when IsPrivileged (:117, backed by currentUserService.IsPrivilegedConferenceReader() at :104), so Organizer and ContentEditor readers see every room. The helper feeds the four [AllowAnonymous] reads (:131, :159, :201, :228): the unpaged list (:142), the paged list (:177), the lookup (:207), and the by-id read (:241). All four also sit behind [OutputCache(PolicyName = "RoomsCache")] (:132, :160, :202, :229), and the write actions evict that cache through EvictRoomsCacheAsync (:328).
      • +
      • Testing: there is no per-handler unit-test class for this filter; it is covered from the controller side by RoomsControllerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.API.Tests/Controllers/RoomsControllerTests.cs:26), which mocks the handler (:32) and asserts the paired behavior on all four reads: the specification is applied for an anonymous or Attendee caller and never resolved for an Organizer or ContentEditor one (:199 and :214 unpaged, :229 and :245 paged, :261 and :277 lookup, :292 and :312 by-id). The mock stands in a filter of its own, r => r.EventId == 1 (:329-333), and VerifyFilterNeverResolved (:335-338) is what proves the privileged path never even calls the handler. [Rubric §14, Testability]: the branch worth protecting is the privileged short-circuit, because a regression there is silent (privileged callers would simply see less), and these are the tests that would catch it.
      • +
      • Caveats / not-in-source: the controller maps a failed Result to null, that is, to no filter at all (RoomsController.cs:123), the same fail-open shape noted on GetPublicEventSpeakerFilterHandler. No path in this handler produces a failure today. The published-event id list is also materialized into the predicate, so the IN list grows with the number of published events; PublicConferenceVisibility describes that table as bounded at single-digit rows, but nothing in code enforces that bound.

      SessionizeSyncResult

      -

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/ISessionizeSyncStrategy.cs:21 · Level 0 · record

      +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/ISessionizeSyncStrategy.cs:21 · Level 0 · record (sealed)

        -
      • What it is: the small immutable value returned by every Sessionize sync strategy: a pair of counters reporting how many entities that strategy touched. It is co-located in the same file as ISessionizeSyncStrategy because it is that interface's return type.
      • -
      • Depends on: nothing first-party; it is a plain sealed record with two int init properties.
      • -
      • Walkthrough: two members. PrimarySynced (ISessionizeSyncStrategy.cs:24) is the count of the strategy's main entity (categories, rooms, questions, speakers, or sessions). SecondarySynced (ISessionizeSyncStrategy.cs:27) is an optional count of a nested child entity synced in the same pass; today only CategorySyncStrategy sets it, to report category items synced alongside categories (CategorySyncStrategy.cs:59). Both default to 0, so a strategy with no secondary entity simply leaves it unset (see the four one-counter returns at RoomSyncStrategy.cs:59, QuestionSyncStrategy.cs:93, SpeakerSyncStrategy.cs:72, and SessionSyncStrategy.cs:50).
      • -
      • Why it's built this way: returning a record rather than a bare int leaves room to grow the result (more counters, per-entity metadata) without breaking the five implementors. The two-field shape is deliberately generic so one type serves all five strategies.
      • -
      • Where it's used: produced by each SyncAsync implementation and accumulated into a List<SessionizeSyncResult> by RefreshFromSessionizeHandler (RefreshFromSessionizeHandler.cs:122-126), which then reads the counters positionally to build its result DTO (:143-153).
      • +
      • What it is: the two-number return value of one Sessionize sync step. It carries how many rows of the step's primary entity were accepted and, where a step also touches a child collection, how many of those were accepted.
      • +
      • Depends on: nothing first-party, nothing external. Two int properties, both init-only.
      • +
      • Concept: none new. It is a deliberately anaemic result record co-located with the contract that returns it, ISessionizeSyncStrategy (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/ISessionizeSyncStrategy.cs:7), rather than living in its own file. [Rubric §9, API and Contract Design] assesses whether a contract can grow without breaking its implementors: returning a record instead of a bare int means a future step can report a third number by adding one init property, and the five existing strategies keep compiling untouched.
      • +
      • Walkthrough: PrimarySynced (ISessionizeSyncStrategy.cs:24) is the headline count, for example speakers synced. SecondarySynced (ISessionizeSyncStrategy.cs:27) is the optional child count, for example the category items synced alongside their categories. Neither is required, so a strategy that has nothing secondary to report constructs new SessionizeSyncResult { PrimarySynced = n } and leaves the other at its default of zero, which is what four of the five strategies do (for example RoomSyncStrategy.cs:79).
      • +
      • Why it's built this way: the counts are what the organizer sees after an import, so they must mean "the domain accepted this row", not "the feed listed this row". Every strategy increments only after the aggregate call succeeded, which is why the record is filled at the end of the loop rather than from the feed's own collection sizes.
      • +
      • Where it's used: returned by all five strategies; collected into a List<SessionizeSyncResult> by RefreshFromSessionizeHandler (RefreshFromSessionizeHandler.cs:122-126) and projected into RefreshFromSessionizeResultDTO (RefreshFromSessionizeHandler.cs:143-153).

      SessionizeSyncWarnings

      @@ -842,47 +1096,40 @@

      SessionizeSyncWarnings

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionizeSyncWarnings.cs:9 · Level 3 · class (internal static)

        -
      • What it is: a one-method helper the sync strategies share when they have to report an entity that the Sessionize feed listed but the domain refused to create. It turns a failed Result into a short human-readable reason suitable for the warnings list on SessionizeSyncContext (SessionizeSyncWarnings.cs:5-8).
      • -
      • Depends on: Result from MMCA.Common.Shared.Abstractions (SessionizeSyncWarnings.cs:1). Nothing external.
      • -
      • Concept introduced: the silently dropped import row, made visible. Each strategy calls a domain factory that returns Result<T>, and a failed create is skipped so the rest of the import can continue. Without a message, the organizer would see a synced count that quietly excludes the row and no way to tell why: the comment at CategorySyncStrategy.cs:101-103 states exactly that reasoning. [Rubric §13, Observability & Operability] assesses whether an operator can tell what a run actually did; the warning string is the operator-facing half of a partial-success import. [Rubric §16, Maintainability] applies too: the "first error, else fallback" phrasing lives in one place instead of being re-typed at each of the three call sites.
      • -
      • Walkthrough: a single internal static string FirstErrorMessage(Result result) (SessionizeSyncWarnings.cs:17). Its body is one expression using a C# list pattern: result.Errors is [var first, ..] ? first.Message : "Unknown error" (:18). The pattern matches when Errors has at least one element, binding the first and discarding the rest, so the method never indexes an empty collection and never needs a Count check. A failed result carrying no errors degrades to the literal "Unknown error" rather than throwing.
      • -
      • Why it's built this way: internal static keeps it out of the module's public surface while still being reachable from every strategy in the namespace, and the expression body means the helper compiles to little more than the pattern test. The doc comment (:12-14) notes it mirrors the first-error idiom already used elsewhere in the module, so the warning text stays consistent across use cases.
      • -
      • Where it's used: three strategies, each on the create-failure path: CategorySyncStrategy (CategorySyncStrategy.cs:104), SpeakerSyncStrategy (SpeakerSyncStrategy.cs:131), and SessionSyncStrategy (SessionSyncStrategy.cs:107). QuestionSyncStrategy deliberately does not use it: it joins all error messages instead of just the first (QuestionSyncStrategy.cs:80).
      • +
      • What it is: a one-method helper the sync strategies share when they have to explain, in one short sentence, why the domain refused a row that the Sessionize feed listed.
      • +
      • Depends on: Result (MMCA.Common.Shared.Abstractions, imported at SessionizeSyncWarnings.cs:1). Nothing external.
      • +
      • Concept introduced, the first-error idiom. A failed Result carries a collection of Error values, but a warning line has room for one reason. FirstErrorMessage (SessionizeSyncWarnings.cs:65-66) uses a C# list pattern, result.Errors is [var first, ..], to bind the head of the collection when one exists and fall back to the literal "Unknown error" when the failure carries none. [Rubric §15, Best Practices and Code Quality] assesses whether recurring micro-logic is expressed once: five strategies needed the same sentence, so the idiom lives in one internal static method instead of five near-copies.
      • +
      • Walkthrough: one member. internal static string FirstErrorMessage(Result result) (SessionizeSyncWarnings.cs:65), expression-bodied, reading the head of the collection through a pattern rather than materializing a LINQ query.
      • +
      • Why it's built this way: internal and static because this is an implementation detail of a single use case, not a service. There is nothing to inject and nothing to mock, so it is a static call rather than a dependency.
      • +
      • Where it's used: CategorySyncStrategy (CategorySyncStrategy.cs:104), RoomSyncStrategy (RoomSyncStrategy.cs:72), SessionSyncStrategy (SessionSyncStrategy.cs:107) and SpeakerSyncStrategy (SpeakerSyncStrategy.cs:131).
      • +
      • Caveats: QuestionSyncStrategy does not use it. Its create-failure warning joins every error message with "; " instead (QuestionSyncStrategy.cs:80), so the question path reports all reasons where the other four report the first one.

      RefreshFromSessionizeCommand

      -

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeCommand.cs:13 · Level 8 · record

      +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeCommand.cs:13 · Level 8 · record (sealed)

        -
      • What it is: the CQRS command that requests a full refresh of one event's data (categories, rooms, questions, speakers, sessions) from the external Sessionize API, use case UC-6. It carries a single positional field: the EventId to refresh (RefreshFromSessionizeCommand.cs:13).
      • -
      • Depends on: Event (only for the typeof(Event).FullName cache-prefix expression), ConferenceFeatures (the feature-flag constant), the EventIdentifierType alias from MMCA.ADC.Conference.Shared, and three marker interfaces from MMCA.Common.Application.UseCases: ICacheInvalidating, ITransactional, and IFeatureGated.
      • -
      • Concept introduced: marker interfaces that opt a command into pipeline behavior. The command itself has no logic; it is a request record whose interfaces tell the decorator pipeline how to treat it (the pipeline is taught in Group 05). Implementing three markers stacks three cross-cutting behaviors declaratively:
          -
        • IFeatureGated exposes FeatureName => ConferenceFeatures.SessionizeIntegration (RefreshFromSessionizeCommand.cs:19), which resolves to the string "Conference.SessionizeIntegration" (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/ConferenceFeatures.cs:15). The FeatureGate decorator short-circuits the command when that flag is off, a runtime kill switch for the whole Sessionize integration. [Rubric §10, Cross-Cutting Concerns] assesses whether concerns like feature flags are handled centrally rather than scattered; here the flag check is inherited from the pipeline, not coded in the handler.
        • -
        • ICacheInvalidating exposes CachePrefix => $"{typeof(Event).FullName}:" (RefreshFromSessionizeCommand.cs:16); on success the Caching decorator evicts every cache entry under the Event prefix, so freshly imported data is not masked by a stale read cache.
        • -
        • ITransactional makes the Transactional decorator wrap the handler in one database transaction, so the five per-entity syncs commit atomically or roll back together. [Rubric §6, CQRS & Event-Driven] assesses whether mutations flow through well-defined command boundaries; this command is the boundary, and its markers are how it configures the pipeline around itself.
        • -
        -
      • -
      • Walkthrough: a one-parameter positional record declaration with its three base interfaces on one line (RefreshFromSessionizeCommand.cs:13), plus two expression-bodied get properties satisfying the marker contracts (CachePrefix at :16, FeatureName at :19). No constructor body, no validation: an event id is the only input the use case needs.
      • -
      • Why it's built this way: keeping the behavior in interfaces, not in the command body, is what lets one small record participate in feature-gating, cache invalidation, and transactions without repeating that plumbing per use case (see ADR-014 for the decorator ordering).
      • -
      • Where it's used: handled by RefreshFromSessionizeHandler; dispatched from EventsController.RefreshAsync, the POST {id}/refresh endpoint (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventsController.cs:337-344), whose handler dependency is declared at :51.
      • +
      • What it is: the command that asks for one event's data to be re-pulled from Sessionize (UC-6). It is a single-parameter record carrying the event id.
      • +
      • Depends on: EventIdentifierType (the module's identifier alias, see the primer), Event (used only to build the cache prefix from its full type name), ConferenceFeatures, and three pipeline markers from MMCA.Common: ICacheInvalidating, ITransactional and IFeatureGated.
      • +
      • Concept: the marker-driven decorator pipeline is taught in group 5. What is worth studying here is that one small record opts into three cross-cutting behaviors at once by implementing three interfaces (RefreshFromSessionizeCommand.cs:13). [Rubric §10, Cross-Cutting Concerns] assesses whether transactions, caching and feature gating are applied declaratively rather than hand-coded per handler: the handler below contains no transaction call, no cache eviction and no feature-flag check, because all three are decided by the markers on this type. [Rubric §29, Resilience and Business Continuity] assesses whether a risky dependency can be switched off without a deploy: IFeatureGated makes the whole Sessionize integration a runtime toggle.
      • +
      • Walkthrough: the positional parameter EventId (RefreshFromSessionizeCommand.cs:13). CachePrefix returns $"{typeof(Event).FullName}:" (RefreshFromSessionizeCommand.cs:17), so a successful refresh evicts the whole Event cache region rather than a single key: the import touches events, rooms, sessions, speakers, categories and questions, so a narrower eviction would leave stale reads behind. FeatureName returns ConferenceFeatures.SessionizeIntegration (RefreshFromSessionizeCommand.cs:19), whose value is the string "Conference.SessionizeIntegration" (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/ConferenceFeatures.cs:15). ITransactional is what makes the five entity families commit or roll back together.
      • +
      • Why it's built this way: all five sync steps write through one IUnitOfWork and one save, so a half-applied import (rooms in, sessions out) is not reachable. Sessions reference rooms and speakers, so a partial commit would leave dangling references; the transactional marker is a correctness requirement here, not a convenience.
      • +
      • Where it's used: constructed by EventsController.RefreshAsync (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventsController.cs:372) and passed to the injected ICommandHandler<in TCommand, TResult> (EventsController.cs:52).

      SessionizeSyncContext

      -

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionizeSyncContext.cs:11 · Level 8 · record

      +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionizeSyncContext.cs:11 · Level 8 · record (sealed)

        -
      • What it is: the mutable "parameter object" passed to every sync strategy for one import run. It bundles the four things a strategy needs (the parsed API payload, the target event, the unit of work, and a shared warnings list) plus one running counter the strategies write back into.
      • -
      • Depends on: SessionizeResponse (the parsed API payload, same group), Event (the aggregate being refreshed), and IUnitOfWork (repository access for strategies that load their own entities).
      • -
      • Concept introduced: a shared context object for a multi-step pipeline. Rather than pass four or five arguments into every strategy method, the orchestrator builds one context and threads it through. Two of its members are deliberately mutable so the strategies can report side information back to the orchestrator without changing the SyncAsync return contract:
          -
        • Warnings (SessionizeSyncContext.cs:16) is a required List<string> any strategy can append non-fatal problems to (a session outside the event date range, a question in the reserved id range, an entity the domain refused to create). The handler folds these into the result DTO (RefreshFromSessionizeHandler.cs:152).
        • -
        • SkippedSoftDeleted (SessionizeSyncContext.cs:19) is a plain int { get; set; } that every strategy increments when it meets a soft-deleted local row matching an incoming Sessionize id, implementing BR-136: a soft-deleted entity is never resurrected by an import.
        • -
        -
      • -
      • Walkthrough: four required init members set once at construction: Response (:13), Event (:14), UnitOfWork (:15), Warnings (:16); then the single mutable counter SkippedSoftDeleted (:19). The required keyword forces the handler to supply all four when it constructs the context (RefreshFromSessionizeHandler.cs:114-120), so a strategy can never see a half-built context. Note the asymmetry that makes the type work: the four init members cannot be reassigned, but Warnings is a mutable List<string> whose contents strategies append to, and SkippedSoftDeleted is the one genuinely settable property.
      • -
      • Why it's built this way: a single context keeps the strategy signature stable (ISessionizeSyncStrategy.SyncAsync takes exactly (context, cancellationToken)), and the two mutable channels give strategies a back-channel for warnings and skip counts without a richer return type. Because a context instance is created per command and the strategies run strictly in sequence (RefreshFromSessionizeHandler.cs:123-126), the mutability costs nothing in concurrency terms.
      • -
      • Where it's used: created once per command in RefreshFromSessionizeHandler and passed to each of the five strategies in turn.
      • +
      • What it is: the single parameter object every sync step receives: the parsed feed, the target event, the unit of work the step opens repositories from, and the two accumulators (warnings, skipped count) the steps write into as they go.
      • +
      • Depends on: SessionizeResponse, Event, IUnitOfWork, and List<string> (BCL).
      • +
      • Concept introduced, the mutable run context behind an immutable-looking record. Four members are required ... { get; init; } (SessionizeSyncContext.cs:13-16), so the identity of the run (which feed, which event, which unit of work, which warnings list) cannot be swapped by a step. The accumulators are still mutable, in two different ways: Warnings is init-only yet holds a List<string> whose contents every step appends to, and SkippedSoftDeleted is a plain { get; set; } counter (SessionizeSyncContext.cs:19) each step increments when it meets a soft-deleted row the feed still lists (BR-136). [Rubric §1, SOLID] assesses interface and parameter-shape discipline: bundling the run state into one type is what lets ISessionizeSyncStrategy keep a two-parameter signature that never changes when one step needs an extra input.
      • +
      • Walkthrough: Response (:13) is the deserialized feed. Event (:14) is the tracked aggregate the handler loaded with its Rooms and EventSpeakers navigations, which is why RoomSyncStrategy and SpeakerSyncStrategy can consult those collections without a query. UnitOfWork (:15) is the shared unit of work: every strategy resolves its repositories from it, which is what keeps all five steps inside one transaction and one change tracker. Warnings (:16) is the running list that ends up on the response DTO. SkippedSoftDeleted (:19) is the running count of soft-deleted rows skipped.
      • +
      • Why it's built this way: shared mutable state is normally a smell. It is safe here for one reason visible in the orchestrator: the strategies run strictly sequentially in a foreach (RefreshFromSessionizeHandler.cs:123-126), never concurrently. That sequencing is also a hard dependency requirement (categories before speakers and sessions, rooms before sessions), so the context's design and the execution order reinforce each other.
      • +
      • Where it's used: created once per command (RefreshFromSessionizeHandler.cs:114-120) and passed to each strategy's SyncAsync.
      • +
      • Caveats: nothing in the type enforces the sequential assumption. List<string> and the int counter are not thread-safe, so a change that ran the independent steps in parallel would need a concurrent collection and an interlocked counter.

      ISessionizeSyncStrategy

      @@ -890,13 +1137,13 @@

      ISessionizeSyncStrategy

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/ISessionizeSyncStrategy.cs:7 · Level 9 · interface

        -
      • What it is: the Strategy interface for synchronizing one entity family from a parsed Sessionize response into the domain. Each implementation owns exactly one entity type: categories, rooms, questions, speakers, or sessions (ISessionizeSyncStrategy.cs:3-6).
      • -
      • Depends on: SessionizeSyncContext (the input for one run) and SessionizeSyncResult (the return, co-located in the same file at :21).
      • -
      • Concept introduced: the Strategy pattern for pluggable, ordered sync steps. [Rubric §2, Design Patterns] assesses whether a pattern solves a real structural problem rather than decorating a simple one. Sessionize returns one payload covering five interdependent entity types; splitting the sync into one strategy per type keeps each SyncAsync small and single-purpose, and lets a new entity type arrive as a new strategy without touching the ones that exist ([Rubric §1, SOLID], open for extension). [Rubric §16, Maintainability] applies too: a change to how rooms sync cannot break speaker sync because the code paths are physically separate files.
      • -
      • Walkthrough: one method, Task<SessionizeSyncResult> SyncAsync(SessionizeSyncContext context, CancellationToken cancellationToken) (ISessionizeSyncStrategy.cs:15). The strategy reads what it needs from the context, upserts its entity family through domain aggregates, and returns a SessionizeSyncResult with the counts it produced. Nothing on the interface saves changes: persistence is the orchestrator's job, once, at the end.
      • -
      • Why it's built this way: a record result (not a bare int) leaves room to add metadata without breaking implementors, and a single context parameter keeps every strategy's signature identical so the orchestrator can loop over them uniformly.
      • -
      • Where it's used: implemented by the five Level-10 strategy classes below; the implementations are held in a static array inside RefreshFromSessionizeHandler (RefreshFromSessionizeHandler.cs:28-35) and executed in dependency order.
      • -
      • Caveats / not-in-source: the strategies are not injected via DI. The handler instantiates them directly in a static readonly ISessionizeSyncStrategy[] field (RefreshFromSessionizeHandler.cs:28), which is possible because they are stateless: all per-run state lives in SessionizeSyncContext. The trade-off is that a test cannot substitute a strategy; the extension point for testing is the ISessionizeService the handler calls, not the strategy array.
      • +
      • What it is: the one-method contract for "synchronize one entity family from the Sessionize feed into the domain". Five implementations exist, one per family: categories, rooms, questions, speakers, sessions.
      • +
      • Depends on: SessionizeSyncContext and SessionizeSyncResult (the latter declared in the same file at ISessionizeSyncStrategy.cs:21).
      • +
      • Concept introduced, the Strategy pattern applied to an import pipeline. [Rubric §2, Design Patterns] assesses whether a pattern solves a real structural problem instead of adding ceremony. One Sessionize payload covers five entity families with different upsert rules, different reserved-id guards and different child collections. Written as one method that would run to several hundred lines at a cyclomatic complexity the analyzers reject at error severity. Split behind this interface, each family's rules sit in their own file and the orchestrator holds only the order. [Rubric §16, Maintainability] assesses whether independent concerns are isolated so that a change to room handling cannot break speaker handling: the five files share nothing but this signature and the context type. [Rubric §14, Testability] assesses whether a unit can be exercised without its collaborators: each strategy is a stateless object with a single method taking a context, so a test instantiates one directly and asserts on the returned counts and the context's warnings (for example MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sync/RoomSyncStrategyTests.cs:11).
      • +
      • Walkthrough: one member, Task<SessionizeSyncResult> SyncAsync(SessionizeSyncContext context, CancellationToken cancellationToken) (ISessionizeSyncStrategy.cs:15). There is no Order property and no entity-type discriminator: order is the orchestrator's business, declared once in its static array.
      • +
      • Why it's built this way: passing a context rather than four parameters means a step that later needs another input costs one added property on the context, not a signature change rippling through five implementations. Returning a record rather than an int gives the same freedom on the way out.
      • +
      • Where it's used: implemented by CategorySyncStrategy, RoomSyncStrategy, QuestionSyncStrategy, SpeakerSyncStrategy and SessionSyncStrategy; consumed only by RefreshFromSessionizeHandler.
      • +
      • Caveats: the implementations are not registered in DI. The handler holds five instances in a static readonly ISessionizeSyncStrategy[] it constructs itself (RefreshFromSessionizeHandler.cs:28-35), so "add a strategy" means editing that array, not adding a registration.

      CategorySyncStrategy

      @@ -904,26 +1151,24 @@

      CategorySyncStrategy

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/CategorySyncStrategy.cs:12 · Level 10 · class (internal sealed)

        -
      • What it is: the sync strategy for categories and their nested category items. It is the clearest of the five, so it also serves as the reference for the shared upsert shape the others reuse.

        +
      • What it is: the first step of the import. It upserts Category rows and their CategoryItem children from the feed, and it runs first because speakers and sessions reference category items.

      • -
      • Depends on: ISessionizeSyncStrategy (the interface it implements), SessionizeSyncContext, SessionizeSyncResult, SessionizeCategory and SessionizeCategoryItem (the payload shapes), Category (the aggregate it upserts through), SessionizeSyncWarnings, and IUnitOfWork reached via the context.

        +
      • Depends on: SessionizeSyncContext, SessionizeCategory, SessionizeCategoryItem, Category, IRepository<TEntity, TIdentifierType> resolved from the context's unit of work, and SessionizeSyncWarnings. Externals: System.Globalization for invariant-culture id formatting.

      • -
      • Concept introduced: the shared four-phase upsert every strategy follows.

        +
      • Concept introduced, the four-phase upsert shape the other four strategies reuse. Read it once here and the remaining strategies become variations on it.

          -
        1. Bulk pre-load. Rather than N GetByIdAsync calls, the strategy opens its repository via context.UnitOfWork.GetRepository<Category, ConferenceCategoryIdentifierType>() (CategorySyncStrategy.cs:16) and calls GetByIdsAsync once with the full set of incoming Sessionize ids (:21-27). It passes includes: [nameof(Category.CategoryItems)] to eager-load children (:24), asTracking: true so updates are tracked (:25), and ignoreQueryFilters: true so soft-deleted rows are visible for the BR-136 check (:26). The results become a dictionary keyed by id (:28). This is a deliberate [Rubric §12, Performance & Scalability] choice: a full re-import of hundreds of entities would suffer badly from N+1 queries.
        2. -
        3. Iterate and discriminate. For each incoming category (:32-52): if it matches a local row that IsDeleted, increment context.SkippedSoftDeleted and continue (:36-40, BR-136); if it matches an active row, call the aggregate's Update(sc.Title, sc.Sort, sc.Type) (:42); if there is no match at all, hand off to CreateNewCategory (:50).
        4. -
        5. Sync children. SyncCategoryItems (:62) walks each incoming category's items, skipping soft-deleted ones (:71-75) and routing the rest to existing.UpdateCategoryItem (:79) or existing.AddCategoryItem (:83), returning the count it applied (:89).
        6. -
        7. Batch-add new entities. New aggregates are collected in a local List<Category> (:30) and flushed with a single categoryRepo.AddRangeAsync(newCategories, ...) (:56), then a SessionizeSyncResult carrying both counters is returned (:59).
        8. +
        9. Bulk pre-load. The strategy resolves the repository once (CategorySyncStrategy.cs:16), projects the feed's ids (:21) and issues a single GetByIdsAsync with includes: [nameof(Category.CategoryItems)], asTracking: true and ignoreQueryFilters: true (:22-27), then indexes the result by id (:28). One query replaces N GetByIdAsync calls. [Rubric §12, Performance and Scalability] assesses whether repeated data access is batched: a full ADC re-import walks hundreds of feed rows, so an id-at-a-time lookup would be an N+1 against the same table.
        10. +
        11. Discriminate. For each feed row (:32): if a stored row exists and is soft-deleted, bump context.SkippedSoftDeleted and skip (:36-40, BR-136). If it exists and is live, call the aggregate's Update (:42). If it does not exist, go to the Create factory (:99).
        12. +
        13. Sync children. SyncCategoryItems (:62-90) compares feed items against the loaded CategoryItems collection by id, skips soft-deleted ones (:71-75), and routes the rest to UpdateCategoryItem or AddCategoryItem on the parent aggregate (:79-83), never to a child repository.
        14. +
        15. Batch add and report. New categories accumulate in a local list and are flushed with one AddRangeAsync (:54-57), then the counts are returned (:59).
        -

        [Rubric §4, Domain-Driven Design] applies throughout: every mutation goes through a Category factory or aggregate method, so the domain enforces its own invariants and the strategy never sets fields directly.

        -
      • -
      • Walkthrough: SyncAsync (:14) runs the four phases above. The private helper CreateNewCategory (:92) calls the Category.Create(sc.Id, sc.Title, sc.Sort, sc.Type) factory (:99); on failure it does not abort the import, it appends a warning naming the category, its Sessionize id, and the first domain error via SessionizeSyncWarnings.FirstErrorMessage (:104), then returns zero items synced. On success it seeds the new category's items in the same pass (:109-113) before adding it to the pending list (:115). Note that the Sessionize id becomes the domain primary key: Create takes sc.Id directly, which is what makes the identity-insert step in the orchestrator necessary.

        +

        [Rubric §4, DDD] assesses whether invariants stay inside aggregates: every mutation in this file is a call on Category, and category items are only ever reached through their parent, so the aggregate boundary holds even under a bulk import.

      • -
      • Why it's built this way: partial success beats all-or-nothing for a feed the application does not control, and a warning is what turns "silently fewer rows" into something an organizer can act on. [Rubric §15, Best Practices & Code Quality] shows in the small things too: the warning interpolates the id through ToString(CultureInfo.InvariantCulture) (:104) rather than relying on ambient culture.

        +
      • Walkthrough of the specifics: CreateNewCategory (:92-118) is where the failure policy shows. Category.Create returns a Result; on failure the strategy appends a warning naming the title and id and quoting SessionizeSyncWarnings.FirstErrorMessage (:104), then returns without counting the row. On success it adds every feed item to the fresh aggregate (:109-113), queues the category (:115) and increments the count through a ref int parameter (:116). Note the asymmetry between the two paths: for an existing category items are reconciled against what is stored, while for a new one they are simply added, because there is nothing to reconcile against.

      • -
      • Where it's used: first entry in the handler's SyncStrategies array (RefreshFromSessionizeHandler.cs:30); it runs first because speakers and sessions reference category items.

        +
      • Why it's built this way: a single bad row must not abort an import of hundreds, so the strategy degrades: warn, skip, keep going, and let the organizer read the warnings on the response. ignoreQueryFilters: true is required rather than optional here, because a feed id is the row's literal primary key: a soft-deleted category is invisible under the global filter, so without the flag the strategy would treat it as new and the insert would violate the primary key and roll the whole refresh back.

      • -
      • Caveats / not-in-source: CategorySyncStrategy is the only strategy that reports a secondary count, and the counter semantics differ by branch: for an existing category the item count is what SyncCategoryItems applied (:46), while for a new category it is every item seeded (:111-112). Nothing in this file reconciles the two, so CategoryItemsSynced in the result DTO is a touched-count, not a changed-count.

        +
      • Where it's used: instance zero of the handler's strategy array (RefreshFromSessionizeHandler.cs:30); its two counts become CategoriesSynced and CategoryItemsSynced on the response (RefreshFromSessionizeHandler.cs:145-146). Covered by MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sync/CategorySyncStrategyTests.cs:15.


      @@ -932,38 +1177,41 @@

      QuestionSyncStrategy

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/QuestionSyncStrategy.cs:12 · Level 10 · class (internal sealed)

        -
      • What it is: the sync strategy for questions. It follows the same shape as CategorySyncStrategy (bulk pre-load at QuestionSyncStrategy.cs:45-49, discriminate at :54, batch-add at :90) and adds three question-specific concerns.
      • -
      • Depends on: ISessionizeSyncStrategy, SessionizeSyncContext, SessionizeSyncResult, Question (upserted via Create / Update), and QuestionInvariants (the reserved-id constants). Externally, only System.Globalization for invariant-culture warning text (:1).
      • -
      • Concept introduced: cross-source invariants enforced at the application layer. Three of this strategy's rules cannot live inside the Question entity because they compare external Sessionize data against internal rules that the entity alone cannot see:
          -
        • Reserved-id guard (:30-41): an incoming id inside [QuestionInvariants.ManualIdRangeStart, QuestionInvariants.ManualIdRangeEnd] is reserved for manually created questions (:33), so a Sessionize question landing there would shadow a user-created one. It is dropped with a warning naming the range (:35) and the import continues. [Rubric §11, Security] and [Rubric §16, Maintainability] both touch this: a bad external row cannot overwrite user-owned data, and the rule lives in exactly one place. The filter runs before the bulk pre-load (the surviving ids are what :44 feeds to the repository), so a reserved id never reaches a query.
        • -
        • Entity-type detection (:19-27 plus DeriveQuestionEntity, :101-118): the strategy pre-computes two hash sets of question ids, one from the answers attached to speakers (:20-23) and one from the answers attached to sessions (:24-27), then classifies each question from that evidence: a session answer wins first (:107-109), a speaker answer second (:112-114). The fallback is the part worth reading twice (:117): a feed carrying no answers for the question offers no classification signal at all, so the value already stored on the existing Question wins, and the literal "Session" default applies only to a genuinely new question. The doc comment (:96-100) states that rule directly. Without it, one answer-less feed would silently retag every stored Speaker question as a Session question.
        • -
        • Type mapping (MapSessionizeQuestionType, :123-129): Sessionize's open type strings collapse to the three domain-valid values. "Rating" (:126) and "Email" (:127) pass through, and everything else (Short_Text, Long_Text, Url, YesNo, and so on) maps to "Text" (:128). The helper is internal static (:123), so it is unit-testable without constructing the strategy: [Rubric §14, Testability].
        • +
        • What it is: the step that upserts Question rows. It adds two concerns the category step does not have: deciding which entity a question belongs to, and refusing feed ids that would collide with organizer-created questions.
        • +
        • Depends on: SessionizeSyncContext, SessionizeQuestion, Question, QuestionInvariants, and the repository from the context's unit of work.
        • +
        • Concept: the four-phase shape is taught under CategorySyncStrategy. What is new here is the reserved identifier band. QuestionInvariants.ManualIdRangeStart and ManualIdRangeEnd are 999_999_000 and 999_999_999 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:37 and :40). Organizer-created questions take ids from that band; Sessionize allocates from far below it. A feed id landing inside the band would silently overwrite a question a human wrote, so the strategy filters those rows out with a warning before anything reaches the database (QuestionSyncStrategy.cs:30-41). [Rubric §8, Data Architecture] assesses how identity is allocated when rows arrive from two sources into one table: the split-range convention is what lets imported and hand-created questions share a key space without a mapping table.
        • +
        • Walkthrough:
            +
          • Answer-derived classification (:20-27): before touching the database, the strategy builds two hash sets, the question ids answered by speakers and the question ids answered by sessions, by flattening QuestionAnswers across the feed's speakers and sessions.
          • +
          • Reserved-id filter (:30-41): the guard above, producing validQuestions.
          • +
          • Bulk pre-load (:44-50): one GetByIdsAsync over the surviving ids with tracking on and query filters off, indexed by id.
          • +
          • Classification and mapping: DeriveQuestionEntity (:101-118) returns "Session" when a session answered the question, "Speaker" when a speaker did, and otherwise falls back to the stored value before defaulting to "Session" (:117). That ordering matters: a feed that happens to carry no answers this run offers no classification signal, so an existing question keeps its recorded entity instead of being reclassified. MapSessionizeQuestionType (:123-129) collapses the feed's open type vocabulary onto the three domain-valid values, passing "Rating" and "Email" through and mapping everything else (Short_Text, Long_Text, Url, YesNo) to "Text".
          • +
          • Update or create (:61-83): a soft-deleted match is skipped and counted (:63-67, BR-136); a live match is updated with existingQuestion.IsRequired passed back in (:69), so the organizer's required flag survives a re-sync. A new question is created with isRequired: false and questionSource: "Sessionize" (:73), which is how imported questions stay distinguishable from hand-created ones; a failed create warns and skips (:80-81).
          • +
          • Batch add and report (:88-93).
        • -
        • Walkthrough: SyncAsync (:14) opens the question repository (:16), builds the two answer-derived id sets (:19-27), filters the reserved range into validQuestions (:30-41), bulk-loads the survivors with asTracking: true and ignoreQueryFilters: true (:44-49), keys them by id (:50), and loops (:54). Per question it looks up any local row (:56), resolves the entity tag (:58) and the mapped type (:59), then branches: a soft-deleted local row is skipped and counted (:63-67, BR-136); an active one is updated via Question.Update(...) preserving the existing IsRequired (:69); no local row means Question.Create(...) tagged questionSource: "Sessionize" with isRequired: false (:73), and the new aggregate joins the pending list (:76). A failed create appends a warning joining all error messages and skips (:80-81); the counter only advances for a row that actually landed (:85). New questions are flushed in one AddRangeAsync (:88-91) and a single-counter result is returned (:93).
        • -
        • Why it's built this way: preserving existingQuestion.IsRequired on update (:69) is the same instinct as the room strategy's preserved organizer fields, and the stored-entity fallback in DeriveQuestionEntity (:117) extends it to a field the feed only implies: the feed is authoritative for what it owns and silent about the rest, so a re-sync must not flatten local state on either. Tagging created rows with questionSource: "Sessionize" (:73) keeps the provenance queryable after the fact.
        • -
        • Where it's used: third entry in the handler's SyncStrategies array (RefreshFromSessionizeHandler.cs:32); it runs after categories and before speakers and sessions, whose answers reference these questions.
        • -
        • Caveats / not-in-source: this is the one strategy that does not route its create-failure text through SessionizeSyncWarnings; it joins every error with "; " instead of taking the first (:80). The difference is cosmetic in the warnings list, but it means the four strategies do not produce identically shaped messages. Note also that a question the feed drops entirely is left untouched: like the other strategies, this one only adds and updates.
        • +
        • Why it's built this way: classification cannot come from the feed's question record itself, only from which side of the payload answered it, which is why the two hash sets are computed up front rather than per row. Preserving IsRequired and the stored entity value on update is the same principle the room step applies to organizer-entered fields: the import owns the fields Sessionize sends and nothing else.
        • +
        • Where it's used: instance two of the handler's array (RefreshFromSessionizeHandler.cs:32), reported as QuestionsSynced (RefreshFromSessionizeHandler.cs:148). MapSessionizeQuestionType is internal static so it can be exercised directly: the assembly grants InternalsVisibleTo to the application test project (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/MMCA.ADC.Conference.Application.csproj:3), and the tests live at MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sync/QuestionSyncStrategyTests.cs:16.

        RoomSyncStrategy

        -

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RoomSyncStrategy.cs:10 · Level 10 · class (internal sealed)

        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RoomSyncStrategy.cs:20 · Level 10 · class (internal sealed)

          -
        • What it is: the smallest strategy. It syncs rooms, which are children of the Event aggregate rather than aggregate roots of their own, and it is the strategy that shows the restore-rather-than-skip half of the import policy.
        • -
        • Depends on: ISessionizeSyncStrategy, SessionizeSyncContext, SessionizeSyncResult, Room, and the Event aggregate reached through context.Event.
        • -
        • Concept introduced: reading a child entity through the read repository, mutating it through its aggregate. The comment at :14-16 states the rule directly: Room is a child of Event, not an aggregate root, so it is reachable only through GetReadRepository<Room, RoomIdentifierType>() (:17), the same accessor AddRoomHandler uses. The rows that accessor returns are still tracked, and every mutation still goes through an Event method. [Rubric §4, Domain-Driven Design] assesses whether aggregate boundaries are respected on the write path; this is the pattern that lets a query reach inside an aggregate without a write leaking around it. See also the IReadRepository<TEntity, TIdentifierType> accessor rules in Group 07.
        • -
        • Concept: soft-deleted rows must be resolved with the filters off, or the import breaks on a primary key. The comment at :20-22 explains why this is not optional here: a room carries its Sessionize id as its literal primary key, so a room the organizer removed and Sessionize still lists cannot simply be re-added; that would be a duplicate key. The pre-load therefore passes ignoreQueryFilters: true (:27) so the removed row is visible, and the strategy restores it in place.
        • -
        • Walkthrough:
            -
          1. Open the read repository (:17) and bulk-load every incoming room id with tracking on and query filters off (:23-28), keyed into a dictionary (:29).
          2. -
          3. For each incoming room (:31), resolve the local row from the already-loaded context.Event.Rooms collection first, falling back to the filters-off dictionary (:33-34). That two-step lookup matters: the handler loaded the event with its Rooms navigation under the normal query filters, so an active room is already present, while a removed one is only in the dictionary.
          4. -
          5. Dispatch on what was found: no local row means context.Event.AddRoom(sr.Id, sr.Name, sr.Sort) (:38); a soft-deleted row means context.Event.RestoreRoom(existingRoom, sr.Name, sr.Sort) (:42); an active row means context.Event.UpdateRoom(...) (:46-53).
          6. -
          7. Count every incoming room as synced (:56) and return a single-counter result (:59).
          8. -
          +
        • What it is: the step that upserts Room rows. Rooms are children of the Event aggregate, so every mutation goes through the event, and this step carries the most defensive id handling of the five.
        • +
        • Depends on: SessionizeSyncContext, SessionizeRoom, Event, Room, EventInvariants, Result, SessionizeSyncWarnings, and IReadRepository<TEntity, TIdentifierType>.
        • +
        • Concept introduced, reading a child entity without granting it a write repository. Room is not an aggregate root, so the strategy resolves GetReadRepository<Room, RoomIdentifierType> (RoomSyncStrategy.cs:27) rather than a full repository: it may load and track the rows, but it cannot add or remove them directly. All writes route through context.Event (:109-122). [Rubric §4, DDD] assesses whether the aggregate root remains the only write entry point for its children, and that one line is the mechanical expression of the rule. [Rubric §11, Security] and [Rubric §8, Data Architecture] both bear on the id guards below: the strategy treats an external id as untrusted input that could point at another event's row or at an organizer-owned one.
        • +
        • Walkthrough:
            +
          • Reserved-band filter (:87-102): ExcludeReservedIds drops any feed room whose id falls between EventInvariants.RoomManualIdRangeStart and RoomManualIdRangeEnd, which are 999_999_000 and 999_999_999 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:62 and :65), warning for each. This mirrors the question-side guard exactly.
          • +
          • Unscoped id lookup (:43-49): one GetByIdsAsync with asTracking: true and ignoreQueryFilters: true, and deliberately not filtered by EventId. The reasoning is written into the file (:32-42): Sessionize allocates room ids from a global sequence, and a room id carries straight through as the row's primary key, so an id can already belong to another event's room. Filtering by event would hide that row, the strategy would treat the id as new, and the insert would hit the primary key and roll back the entire refresh across all five families. Loading it unscoped and skipping it keeps the damage at the single offending room.
          • +
          • Resolution order (:53-54): the aggregate's own Rooms collection is consulted first, and only if that misses does the strategy fall back to the unscoped dictionary.
          • +
          • Ownership guard (:59-63): if the row came only from the unscoped lookup and its EventId is not this event's, warn and skip. The comment records why the check is conditional: anything already on the aggregate belongs to this event by construction, and a room added earlier in the same run still carries EventId 0 until EF assigns it on save.
          • +
          • Apply (:109-122): a three-way switch expression. No stored room means AddRoom; a soft-deleted one means RestoreRoom; otherwise UpdateRoom, which reads Capacity, Floor, Location and AccessibilityInfo back off the stored room (:118-121) because Sessionize never sends those. That is how organizer-entered room detail survives a re-sync.
          • +
          • Count acceptances only (:70-76): the aggregate can legitimately refuse a room (a name the feed repeats, a blank name). A refusal produces a warning and no increment, so the reported count and the warnings list always add up.
          • +
        • -
        • Why it's built this way: the UpdateRoom call passes back the room's existing Capacity, Floor, Location, and AccessibilityInfo (:50-53) rather than anything from the feed, which is how the doc comment's promise that "organizer-entered fields survive a re-sync" (:8) is actually kept. Sessionize knows a room's name and sort order and nothing else, so the update deliberately re-supplies the locally owned fields unchanged. Rooms are the one entity family where a reappearing row is restored instead of skipped: unlike categories, questions, speakers, and sessions, a room has no independent lifecycle to protect, and its id collision would otherwise fail the whole transaction.
        • -
        • Where it's used: second entry in the handler's SyncStrategies array (RefreshFromSessionizeHandler.cs:31); it runs after categories and before sessions, which reference rooms by RoomId.
        • +
        • Why it's built this way: the whole refresh is one transaction (ITransactional on RefreshFromSessionizeCommand), which makes any uncaught constraint violation an all-or-nothing loss. Each guard here converts a would-be transaction abort into a single skipped row plus a warning line. [Rubric §29, Resilience and Business Continuity] assesses whether a partial upstream defect degrades gracefully rather than taking the operation down.
        • +
        • Where it's used: instance one of the handler's array (RefreshFromSessionizeHandler.cs:31), reported as RoomsSynced (RefreshFromSessionizeHandler.cs:147). Covered by MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sync/RoomSyncStrategyTests.cs:11.

        SessionSyncStrategy

        @@ -971,23 +1219,19 @@

        SessionSyncStrategy

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionSyncStrategy.cs:14 · Level 10 · class (internal sealed)

        -
      • What it is: the richest child-syncing strategy. It upserts sessions and, for each one, reconciles three child collections: session speakers, session category items, and session question answers.
      • -
      • Depends on: ISessionizeSyncStrategy, SessionizeSyncContext, SessionizeSyncResult, SessionizeSession and SessionizeQuestionAnswer (payload shapes), Session (upserted via Create / Update and its Add* / Restore* child methods), and SessionizeSyncWarnings.
      • -
      • Concept introduced: validate-then-upsert with non-fatal warnings. Before touching a session, ValidateSessionTimes (SessionSyncStrategy.cs:53) records warnings, never errors, and the row is stored either way:
          -
        • BR-86, a session starting before the event's StartDate (:56-59) or ending after its EndDate (:61-64). Both comparisons project the event's DateOnly bounds through ToDateTime(TimeOnly.MinValue) and ToDateTime(TimeOnly.MaxValue) so a same-day session is never flagged.
        • -
        • BR-122, zero or negative duration where EndsAt <= StartsAt (:67-70); the warning text itself says the row is "stored as-is per BR-122".
        • -
        -
      • -
      • Concept introduced: reactivate the association, do not duplicate it (BR-135). The comment at :118-120 states the failure mode precisely: the session is loaded with query filters off, so its child collections carry the removed associations too, and blindly re-adding one would leave the removed row behind and double the association. Each of the three child reconciliations therefore looks for a soft-deleted match first:
          -
        • Session speakers (:123-137): for every incoming speaker with no active link, restore a soft-deleted link if one exists (RestoreSessionSpeaker, :131) or add a fresh one (AddSessionSpeaker, :135).
        • -
        • Session category items (:140-154): the identical shape via RestoreSessionCategoryItem (:148) and AddSessionCategoryItem (:152).
        • -
        • Session question answers (SyncSessionQuestionAnswers, :160): here the update path is the interesting one; an existing non-deleted answer has its value overwritten (UpdateSessionQuestionAnswer, :168) rather than being replaced, and only a genuinely new question gets AddSessionQuestionAnswer (:172).
        • +
        • What it is: the last step of the import. It upserts Session rows and their three join collections (speakers, category items, question answers), and it runs last because a session references rooms, speakers and category items the earlier steps created.
        • +
        • Depends on: SessionizeSyncContext, SessionizeSession, SessionizeQuestionAnswer, Session, SessionizeSyncWarnings, and the repository from the context's unit of work.
        • +
        • Concept introduced, reactivate rather than re-add (BR-135). The session and its collections are loaded with ignoreQueryFilters: true (SessionSyncStrategy.cs:23-28), so the loaded SessionSpeakers, SessionCategoryItems and SessionQuestionAnswers collections carry the removed associations too. SyncSessionChildren (:116-158) uses that: for a feed association with no live match it first looks for a soft-deleted row with the same key and calls RestoreSessionSpeaker (:131) or RestoreSessionCategoryItem (:148), and only adds a new row when none exists. Adding instead would leave the removed row in place and double the association. [Rubric §8, Data Architecture] assesses whether soft-delete is handled consistently on the write path as well as the read path: here the filters are turned off precisely so the write path can see and revive what the read path hides.
        • +
        • Walkthrough:
            +
          • Bulk pre-load (:23-29) with all three child collections included.
          • +
          • Advisory time validation (:53-71): ValidateSessionTimes warns when a session starts before the event's StartDate or ends after its EndDate (BR-86, :56-64) and when EndsAt is at or before StartsAt (BR-122, :67-70). None of these reject the session: the row is imported as-is and the organizer decides. [Rubric §24, Forms, Validation and UX Safety] assesses whether a system distinguishes a hard invariant from an advisory: schedule anomalies in a live conference feed are usually real and in flight, so blocking the import would be worse than reporting it.
          • +
          • Resolve or create (:73-114): a soft-deleted match is skipped and counted (:81-85, BR-136). A live match is updated with the feed's fields, while AccessibilityInfo and ResourceLinks are read back off the stored session (:93) because Sessionize does not send them. A miss goes to Session.Create (:97-102), which receives null for those same two fields and the event id from the context; a failed create warns and returns null (:103-109).
          • +
          • Children (:116-175): speakers and category items follow the restore-or-add shape above; SyncSessionQuestionAnswers (:160-175) updates the live answer for a question id if one exists and adds one otherwise, keyed on QuestionId with an IsDeleted guard (:164-165).
          • +
          • Batch add and report (:45-50).
        • -
        • Walkthrough: SyncAsync (:16) opens the session repository (:18) and bulk-loads existing sessions with all three child navigations included and filters off (:23-28). Per incoming session it validates times (:35), resolves through ResolveOrCreateSession (:37), counts the row (:41), and reconciles children (:42). ResolveOrCreateSession (:73) applies the now-familiar three-way branch: soft-deleted local row means skip and count (:81-85, BR-136); an active row means Session.Update(...) (:87-93); no row means Session.Create(...) (:97-102), and a failed create appends a warning via SessionizeSyncWarnings and skips (:107-108). New sessions are flushed in one AddRangeAsync (:47).
        • -
        • Why it's built this way: treating out-of-range times as warnings rather than rejections keeps the import resilient to imperfect upstream data while still telling the organizer what looked wrong. Note the same preserve-local-edits instinct as the rooms strategy: Update passes back the session's existing AccessibilityInfo and ResourceLinks (:93), and Create seeds both as null (:101), because Sessionize does not own those fields. [Rubric §4, Domain-Driven Design]: all mutation, including every child add and restore, flows through Session aggregate methods.
        • -
        • Where it's used: final entry in the handler's SyncStrategies array (RefreshFromSessionizeHandler.cs:34); it runs last because sessions reference rooms, speakers, categories, and questions synced by the earlier strategies.
        • -
        • Caveats / not-in-source: the reconciliation is additive only. An association Sessionize stopped listing is left in place; nothing in this file removes a session speaker or category item that disappeared from the feed. Warnings are also emitted per validation, not per session, so one badly shaped session can contribute up to three entries to the shared list.
        • +
        • Why it's built this way: an import that runs repeatedly against a moving feed must be idempotent in the practical sense, that re-running it does not multiply rows. Keying every child comparison on the domain id plus an IsDeleted check, and preferring restore over insert, is what delivers that.
        • +
        • Where it's used: instance four of the handler's array (RefreshFromSessionizeHandler.cs:34), reported as SessionsSynced (RefreshFromSessionizeHandler.cs:150). Covered by MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sync/SessionSyncStrategyTests.cs:15.

        SpeakerSyncStrategy

        @@ -995,648 +1239,643 @@

        SpeakerSyncStrategy

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SpeakerSyncStrategy.cs:14 · Level 10 · class (internal sealed)

      -
    • What it is: the sync strategy for speakers. It follows the shape CategorySyncStrategy establishes and adds three things of its own: social-link parsing, the event-to-speaker link, and an extra query to reach soft-deleted links the aggregate cannot see.
    • -
    • Depends on: ISessionizeSyncStrategy, SessionizeSyncContext, SessionizeSyncResult, SessionizeSpeaker / SessionizeLink / SessionizeQuestionAnswer (payload shapes), Speaker and its SpeakerCategoryItem children, Event and EventSpeaker, and SessionizeSyncWarnings.
    • -
    • Concept introduced: the association a loaded aggregate cannot show you. LoadDeletedEventSpeakersAsync (:81) exists because of a specific interaction: EventSpeaker is a child of Event, and the orchestrator loaded the Event with query filters on (RefreshFromSessionizeHandler.cs:43-48), so a removed link is simply absent from context.Event.EventSpeakers. The strategy therefore reads those links back explicitly through GetReadRepository<EventSpeaker, EventSpeakerIdentifierType>() with ignoreQueryFilters: true and a where narrowing to this event's deleted rows (:85-92). The doc comment at :75-79 spells out that reasoning. A speaker that was added and removed repeatedly can carry more than one removed link, so the result is grouped and only the first per speaker is kept (:96-98, comment at :94-95); the rest stay deleted.
    • -
    • Concept introduced: parsing untyped external links into typed fields. ExtractSocialLinks (:183) iterates the speaker's Sessionize Links and dispatches on LinkType, all comparisons OrdinalIgnoreCase: "Twitter" routes through ExtractTwitterHandle (:197-199), "LinkedIn" fills the LinkedIn url (:201), "Blog" or "Company_Website" fill the website url (:205-206), and anything whose url merely contains "github" is treated as the GitHub link (:210). ExtractTwitterHandle (:217) strips the six known twitter.com / x.com prefixes, trims a leading @ and trailing slashes (:228-236), and returns null for an empty result (:239). The #pragma warning disable S5332 around the prefix list (:227-237) carries its own justification (:224-226): those http:// literals are input patterns being removed, not addresses this code connects to, and dropping them would leave legacy Sessionize handles unparsed. [Rubric §15, Best Practices & Code Quality] assesses whether an analyzer suppression is narrow and explained; this one is both.
    • -
    • Walkthrough: SyncAsync (:16) opens the speaker repository (:18), bulk-loads existing speakers with their category items and question answers included and filters off (:23-28), and pre-resolves the deleted event links (:31). Per incoming speaker it extracts the social links (:37), then CreateOrUpdateSpeaker (:101) applies the three-way branch: soft-deleted local row means skip and count (:113-117, BR-136); an active row means Speaker.Update(...) preserving the existing Email (:119-123); no row means Speaker.Create(...) (:126) followed immediately by an Update to set the social links, because Create does not accept them (:136-140). A failed create warns via SessionizeSyncWarnings and returns null so the caller skips the speaker (:131-132). After upsert the strategy ensures the event link (BR-135): if no active link exists (:48), it restores a soft-deleted one (context.Event.RestoreEventSpeaker, :55) or adds a new one (context.Event.AddEventSpeaker(null, ss.Id), :59). Finally it reconciles category items (:145, restore at :157 / add at :161) and question answers (:166, update at :174 / add at :178), and flushes new speakers with one AddRangeAsync (:69).
    • -
    • Why it's built this way: preserving existingSpeaker.Email?.Value on update (:120) and passing null for email on create (:126) keeps the feed away from the field that BR-207 speaker auto-linking depends on; Sessionize does not publish speaker emails, so the import must never blank a locally held one. Two independent restore paths (the event link and the category items) exist because the two collections are reached differently: the category items come back on the filters-off speaker read and are visible in memory (comment at :147-148), while the event links needed the extra query above.
    • -
    • Where it's used: fourth entry in the handler's SyncStrategies array (RefreshFromSessionizeHandler.cs:33); it runs after categories and questions (which speakers reference) and before sessions (which reference speakers). Its AddEventSpeaker behavior is also the reason GetPublicEventSpeakerFilterHandler needs a second visibility leg.
    • -
    • Caveats / not-in-source: ExtractSocialLinks has no else for an unrecognized LinkType, so a link type Sessionize adds later is silently dropped rather than warned about, and the "github" substring test (:210) will claim any url mentioning github that was not already matched by an earlier branch. Question answers are matched on QuestionId only (:171), so a speaker with two answers to the same question would have the first repeatedly overwritten.
    • +
    • What it is: the step that upserts Speaker rows, links each speaker to the event through EventSpeaker, and syncs each speaker's category items and question answers. It also turns the feed's loose link list into typed social fields.
    • +
    • Depends on: SessionizeSyncContext, SessionizeSpeaker, SessionizeLink, SessionizeQuestionAnswer, Speaker, Event, EventSpeaker, SessionizeSyncWarnings, plus both IRepository<TEntity, TIdentifierType> and IReadRepository<TEntity, TIdentifierType>.
    • +
    • Concept introduced, reviving an association the aggregate cannot see. The event was loaded by the handler with the global query filters on, so a removed EventSpeaker link is simply absent from context.Event.EventSpeakers. Re-adding it would create a second row alongside the removed one. LoadDeletedEventSpeakersAsync (:81-99) closes that hole: it opens a read repository for EventSpeaker, queries this event's deleted links with ignoreQueryFilters: true and asTracking: true (:87-92), and groups them by speaker id, taking the first of each group because a speaker added and removed repeatedly can carry more than one removed link (:96-98). The link decision then reads: skip if a live link exists, restore a deleted one if either the aggregate or that dictionary has it, otherwise add (:48-61, BR-135). [Rubric §8, Data Architecture] assesses whether the soft-delete convention is applied coherently across an aggregate boundary, which is exactly the trap this method sidesteps.
    • +
    • Walkthrough:
        +
      • Bulk pre-load (:23-29) with SpeakerCategoryItems and SpeakerQuestionAnswers included, filters off, tracking on.
      • +
      • Social-link extraction (:183-215): ExtractSocialLinks walks the feed's links and matches LinkType case-insensitively, sending "Twitter" through ExtractTwitterHandle, "LinkedIn" to the LinkedIn field, "Blog" and "Company_Website" to the website field, and falling back to a URL-content check for "github" (:210). ExtractTwitterHandle (:217-240) strips six known twitter.com and x.com prefixes, trims a leading @ and surrounding slashes, and returns null for an empty result. The #pragma warning disable S5332 around the replacement chain (:227-237) is deliberate and documented in place: the plain-http literals are input patterns being removed, not addresses this code connects to, and dropping them would leave legacy Sessionize profile links unparsed.
      • +
      • Create or update (:101-143): a soft-deleted speaker is skipped and counted (:113-117). A live one is updated with existingSpeaker.Email?.Value passed back in (:120) so the stored email survives, since the feed does not carry it. A new speaker goes through Speaker.Create with a null email (:126) and is then immediately updated (:137-140) because the factory does not accept the social fields; a failed create warns and returns null (:127-133).
      • +
      • Children (:145-181): SyncCategoryItems uses the restore-or-add shape; SyncQuestionAnswers updates a live answer by its id or adds a new one.
      • +
      • Batch add and report (:67-72).
      -
      -

      GetPublicEventSpeakerFilterHandler

      -
      -

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.GetPublicEventSpeakerFilter · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterHandler.cs:22 · Level 11 · class (sealed)

      -
      -
        -
      • What it is: the handler for GetPublicEventSpeakerFilterQuery. It asks PublicConferenceVisibility twice, once for the published event ids and once for the visible speaker ids, and returns an EventSpeaker.EventId IN (...) AND EventSpeaker.SpeakerId IN (...) specification.
      • -
      • Depends on: IUnitOfWork (:23, injected only to hand on to the resolver), PublicConferenceVisibility, InlineSpecification<TEntity, TIdentifierType> and its base Specification<TEntity, TIdentifierType>, EventSpeaker, and Result. It implements IQueryHandler<in TQuery, TResult> to Result<Specification<EventSpeaker, EventSpeakerIdentifierType>> (:24).
      • -
      • Concept introduced: the two-parent junction filter. [Rubric §11, Security]. The other junction filters in this family each derive from a single parent: GetPublicSessionSpeakerFilterHandler follows the session, GetPublicSpeakerCategoryItemFilterHandler follows the speaker. This one ANDs two independent legs, and the remarks explain why the second is not redundant (:16-21): the Sessionize import writes an EventSpeaker row for every speaker in the response, which you can read in SpeakerSyncStrategy itself, where every synced speaker without an active link gets context.Event.AddEventSpeaker(null, ss.Id) (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SpeakerSyncStrategy.cs:59). An event-only filter would therefore republish the entire imported roster through the association endpoint, which is exactly what the public speaker list hides.
      • -
      • Concept: the duplicate scalar read, taken deliberately. [Rubric §12, Performance & Scalability]. The inline comment (:35-37) records a cost decision rather than an oversight. The junction read carries no event context, so the speaker rule spans every published event; both resolver calls read the Event table, described there as bounded at single-digit rows; and the duplicate scalar read was judged cheaper than threading the already-resolved ids through the shared resolver's signature. The trade-off is in the open: one extra projection query per request in exchange for keeping PublicConferenceVisibility's API narrow.
      • -
      • Walkthrough:
          -
        1. Resolve the published event ids: PublicConferenceVisibility.GetPublishedEventIdsAsync(unitOfWork, cancellationToken) (:31-33). Inside the resolver that is one scalar projection of Event.Id filtered by IsPublished (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:40-43), materialized once so the caller embeds a stable collection EF can translate to IN (:45-46).
        2. -
        3. Resolve the visible speaker ids: GetVisibleSpeakerIdsAsync(unitOfWork, cancellationToken: cancellationToken) (:38-40), with the optional event scope left at its default and spelled out with a named argument so it reads as a decision rather than an omission. Inside, that is the BR-239 chain: the published events (PublicConferenceVisibility.cs:104), an empty answer when the scope is empty (:114-115), the eligible sessions inside that scope (:117-119), then the SessionSpeaker join projected down to distinct speaker ids (:121-126).
        4. -
        5. Wrap es => eventIds.Contains(es.EventId) && speakerIds.Contains(es.SpeakerId) in an InlineSpecification<TEntity, TIdentifierType> and return Result.Success (:42-44). There is no failure path: the handler cannot fail on its own terms.
        6. -
      • -
      • Why it's built this way: the summary states the shape (:10-15) and the remarks give the reason for the second leg (:16-21). Both legs are id lists turned into Contains, never navigation joins, so the criteria stays translatable on any provider (ADR-018), and deriving both from the shared resolver means the junction cannot drift away from the entities whose visibility it follows.
      • -
      • Where it's used: EventSpeakersController's BuildPublicSpecificationAsync (EventSpeakersController.cs:65-74) is the only consumer, and from there it reaches the unpaged list (:89), paged list (:119), lookup (:147), and by-id (:177) reads. Note that the class-level [HasPermission(ConferencePermissions.EventsManage)] (:45) is overridden per action by [AllowAnonymous] (:77, :100, :141, :163), which is exactly why the handler has to carry the visibility rules itself.
      • -
      • Testing: GetPublicEventSpeakerFilterHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterHandlerTests.cs:18), six tests on the shared HandlerTestBase<T>: the success shape (:75), a row whose event is published and whose speaker is visible (:84), a row on an unpublished event (:95), a row of a hidden speaker on a published event (:106), a world where no speaker is visible (:119), and one that captures the predicate the handler hands to the Event projection, compiles it, and asserts it accepts a published event and rejects an unpublished one (:129-147). One fixture detail is a property of the entity rather than of the test: EventSpeaker.EventId is get-only and written by EF (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventSpeaker.cs:23), so a row built through the factory in memory carries the default id, and the fixture uses default as its row event id (:20-21). [Rubric §14, Testability]: the speaker leg is the rule most likely to be dropped as redundant, and :106 is the test that would catch it.
      • -
      • Caveats / not-in-source: the controller maps a failed Result to null, meaning no filter: return result.IsSuccess ? result.Value : null; (EventSpeakersController.cs:73), which would widen the read rather than narrow it. Nothing in this handler can produce that failure today, so the exposure is latent rather than live, but it is the opposite of the fail-closed default the rest of the visibility code takes, and the same shape appears on the other junction controllers. Both id lists are also materialized into the predicate, so the two IN lists grow with the number of published events and of publicly visible speakers; nothing in this file bounds either.
      • +
      • Why it's built this way: the module treats Sessionize as the owner of the fields Sessionize sends and the organizer as the owner of everything else, so every update call in this file threads the locally held values (email here, room detail in the room step, IsRequired in the question step) back through the aggregate rather than blanking them. [Rubric §30, Compliance, Privacy and Data Governance] assesses whether personal data is written only from the source entitled to set it: the speaker email is never overwritten by an import.
      • +
      • Where it's used: instance three of the handler's array (RefreshFromSessionizeHandler.cs:33), reported as SpeakersSynced (RefreshFromSessionizeHandler.cs:149). Covered by MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sync/SpeakerSyncStrategyTests.cs:12.

      RefreshFromSessionizeHandler

      -

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeHandler.cs:19 · Level 11 · class (sealed partial, command handler)

      +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeHandler.cs:19 · Level 14 · class (sealed partial)

        -
      • What it is: the command handler for RefreshFromSessionizeCommand. It loads the event, checks two preconditions, calls the Sessionize API, runs the five per-entity strategies in dependency order, and returns a DTO of per-entity sync counts. It is the orchestration seat of the whole import.
      • -
      • Depends on: first-party: IUnitOfWork, ICurrentUserService, ISessionizeService (the HTTP client abstraction), Result and Error, SessionizeResponse, SessionizeSyncContext, SessionizeSyncResult, ISessionizeSyncStrategy and its five implementations (CategorySyncStrategy, RoomSyncStrategy, QuestionSyncStrategy, SpeakerSyncStrategy, SessionSyncStrategy), Event, RefreshFromSessionizeResultDTO, and the ICommandHandler<in TCommand, TResult> contract it satisfies. Notable externals: TimeProvider (BCL, injected for a testable clock), Microsoft.Extensions.Logging (ILogger<T> plus the [LoggerMessage] source generator), System.Text.Json for JsonException, and Polly.CircuitBreaker / Polly.Timeout for the two resilience-pipeline exception types.
      • -
      • Concept introduced: the orchestrator that owns sequencing but not sync logic. [Rubric §2, Design Patterns] and [Rubric §6, CQRS & Event-Driven] both apply. The handler holds a static readonly ISessionizeSyncStrategy[] SyncStrategies (RefreshFromSessionizeHandler.cs:28-35) with the five strategies in explicit dependency order, and the comment above it states that order's reason (:26-27): categories first because speakers and sessions reference them, then rooms because sessions reference them, then questions, speakers, and finally sessions. Making the array static avoids re-allocating it per request; the strategies are safe to share because they are stateless. The handler knows the order entities must be synced but nothing about how any entity is synced.
      • -
      • Concept introduced: classifying an upstream exception as an outage, not a defect. IsSessionizeUnavailable (:166-171) is the interesting piece of error handling here. Its doc comment (:156-164) explains that the Sessionize client runs behind the standard resilience pipeline from AddServiceDefaults, so an unreachable API arrives as a Polly TimeoutRejectedException or BrokenCircuitException at least as often as an HttpRequestException, and an HTML error page served with a success status surfaces from ReadFromJsonAsync as JsonException or NotSupportedException (unparseable content type). All five share the friendly failure instead of escaping as a 500. The guard immediately after the catch is what keeps that breadth honest: cancellationToken.ThrowIfCancellationRequested() (:86) so a caller cancellation is never reported as an upstream outage. [Rubric §29, Resilience & Business Continuity] assesses whether a dependency's failure degrades the caller gracefully; see ADR-009.
      • -
      • Walkthrough: the primary constructor (:19-24) takes five dependencies: IUnitOfWork, ISessionizeService, ICurrentUserService, TimeProvider, and ILogger<RefreshFromSessionizeHandler>; the sealed partial modifier pairs with the [LoggerMessage] generator at :173-174. HandleAsync (:38) runs six phases:
          -
        1. Event load (:43-48): fetches the Event with its Rooms and EventSpeakers navigations, asTracking: true; a missing event returns Error.NotFound (:50-54). The query filters stay on here, which is why SpeakerSyncStrategy has to re-read deleted links itself.
        2. -
        3. BR-6 precondition (:57-64): a missing or blank SessionizeCode returns Error.Invariant with code "Event.Sessionize.NoCode".
        4. -
        5. BR-63 throttle (:67-75): if LastSessionizeRefreshOn is within five minutes of timeProvider.GetUtcNow().UtcDateTime (:68), it returns Error.Invariant code "Event.Sessionize.Throttled" without calling the API. [Rubric §12, Performance & Scalability]: the throttle protects both the upstream rate limit and the local transaction cost.
        6. -
        7. External API call (:81): sessionizeService.GetAllAsync inside a try whose catch ... when (IsSessionizeUnavailable(ex)) filter (:83) converts the five recognized shapes into Error.Failure code "Event.Sessionize.Unavailable" (:88-92). Any other exception propagates: the filter deliberately does not swallow defects.
        8. -
        9. Empty-response short-circuit (:96-111): a null response is valid (the event may have no data yet). The refresh timestamp is stamped via @event.RecordSessionizeRefresh(...) (:98), changes are saved (:99), and a zero-count DTO is returned without running any strategy.
        10. -
        11. Strategy execution (:114-153): a fresh SessionizeSyncContext is built with an empty warnings list (:114-120), then each strategy is awaited in sequence (:123-126), never in parallel, because later entities reference earlier ones and all five share one change tracker. A non-zero SkippedSoftDeleted is folded into Warnings as a single BR-136 summary line (:128-131), the refresh is stamped with the current user and time (:134), unitOfWork.RequestIdentityInsert() is called (:138), SaveChangesAsync commits the whole batch (:139), and the success log is emitted (:141). The DTO is assembled by reading each result's counters positionally (:143-153).
        12. -
        +
      • What it is: the command handler behind UC-6. It checks the preconditions, calls the Sessionize API, runs the five strategies in dependency order against one shared context, stamps the refresh on the event, saves everything in one transaction, and returns the per-entity counts and warnings.

        +
      • +
      • Depends on: IUnitOfWork, ISessionizeService, ICurrentUserService, TimeProvider (BCL), ILogger<T> (Microsoft.Extensions.Logging), Event, Result and Error, RefreshFromSessionizeCommand, RefreshFromSessionizeResultDTO, SessionizeResponse, SessionizeSyncContext, SessionizeSyncResult, ISessionizeSyncStrategy and its five implementations. Externals: Polly.CircuitBreaker and Polly.Timeout (for the two rejection types it catches) and System.Text.Json.

        +
      • +
      • Concept introduced, classifying an upstream failure instead of letting it become a 500. IsSessionizeUnavailable (RefreshFromSessionizeHandler.cs:166-171) treats five exception types as "no usable Sessionize data right now": HttpRequestException, Polly's TimeoutRejectedException and BrokenCircuitException, and JsonException or NotSupportedException. The last two matter because an upstream that serves an HTML error page with a success status makes the JSON read fail on content rather than on transport. The Polly types appear because the client runs behind the standard resilience pipeline from AddServiceDefaults, so an unreachable API often reaches the handler as a pipeline rejection rather than a socket error. Critically, the catch block re-checks cancellation first (:86): a broadened catch must not convert a caller's cancellation into an "upstream is down" answer. [Rubric §13, Observability and Operability] assesses whether operators can tell a dependency outage from a defect: this classification is what lets the controller answer 502 instead of 500. [Rubric §29, Resilience and Business Continuity] assesses graceful degradation against a third-party dependency.

        +
      • +
      • Walkthrough:

        +
          +
        • Static strategy array (:28-35): five stateless instances in dependency order (categories, rooms, questions, speakers, sessions), with the ordering rationale in the comment above them (:26-27). static readonly because the strategies hold no state; all state lives in the per-command context.
        • +
        • Primary constructor (:19-24): five dependencies. sealed partial is what allows the [LoggerMessage] source-generated log method at the bottom of the file (:173-174).
        • +
        • Load the aggregate (:43-54): GetByIdAsync with Rooms and EventSpeakers included and asTracking: true, returning Error.NotFound when the event is missing.
        • +
        • Precondition, a configured code (:57-64, BR-6): a blank SessionizeCode fails with Event.Sessionize.NoCode.
        • +
        • Precondition, the throttle (:67-75, BR-63): if LastSessionizeRefreshOn is less than five minutes before timeProvider.GetUtcNow(), the handler fails with Event.Sessionize.Throttled and never calls the API. Time comes from an injected TimeProvider, which is what makes the window testable without waiting.
        • +
        • Call the API (:78-93), with the classification above.
        • +
        • An empty response is success (:96-111): a null response is not an error, since an event may have no data yet. The handler stamps the refresh, saves, and returns a DTO of zeros with no warnings.
        • +
        • Run the strategies (:114-126): one context is built and the five SyncAsync calls run sequentially, collecting a SessionizeSyncResult each.
        • +
        • Summarize skips (:128-131): a non-zero SkippedSoftDeleted becomes one final warning line naming the count and BR-136.
        • +
        • Stamp and save (:134-139): RecordSessionizeRefresh writes the current user id and timestamp onto the aggregate, then unitOfWork.RequestIdentityInsert() (:138) is called before the single SaveChangesAsync. This is the load-bearing detail of the whole use case: Sessionize rows keep their external ids as primary keys in tables whose key columns are IDENTITY, so the unit of work has to wrap the save in SET IDENTITY_INSERT ON/OFF per table. The request flag is declared on IUnitOfWork (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IUnitOfWork.cs:49), forwarded to the context factory (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/UnitOfWork.cs:76), and consumed on the next save, which splits the work into rounds per table (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:224-233).
        • +
        • Log and project (:141-153): the source-generated information-level log records the event id, then the five results are read positionally, results[0] through results[4], into the response DTO.
        • +
        +

        [Rubric §6, CQRS and Event-Driven] assesses whether writes flow through one explicit handler boundary: this type implements ICommandHandler<in TCommand, TResult> (:24), is wrapped by the decorator pipeline described in group 5, and the controller knows only the interface. [Rubric §3, Clean Architecture] assesses dependency direction: the handler names ISessionizeService, never an HTTP client, so the outbound call is an abstraction the infrastructure layer satisfies with SessionizeService.

        +
      • +
      • Why it's built this way: one transaction across five entity families is not an optimization, it is what keeps sessions from referencing rooms or speakers that did not commit. That constraint drives the rest of the design: strategies must not throw on a bad row (a throw would abort everything), they must not provoke primary key collisions (hence the unscoped lookups and the reserved-band guards), and the handler must not save between steps.

        +
      • +
      • Where it's used: injected into EventsController as ICommandHandler<RefreshFromSessionizeCommand, Result<RefreshFromSessionizeResultDTO>> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventsController.cs:52) and invoked from RefreshAsync (EventsController.cs:367-372), which is [HttpPost("{id}/refresh")] and [Idempotent] (EventsController.cs:365-366). The controller maps the two well-known error codes onto transport: Event.Sessionize.Throttled becomes a 429 with a Retry-After of 300 seconds (EventsController.cs:378-381) and Event.Sessionize.Unavailable becomes a 502 (EventsController.cs:385-386). Unit tests live at MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/UseCases/RefreshFromSessionizeHandlerTests.cs:15, with an integration tier at MMCA.ADC/Tests/Integration/MMCA.ADC.Conference.IntegrationTests/Organizer/SessionizeRefreshTests.cs.

        +
      • +
      • Caveats: the DTO projection is positional (:145-150), so the response mapping is coupled to the order of the static array; inserting a sixth strategy anywhere but the end would silently shift every count that follows it. Both RecordSessionizeRefresh calls dereference currentUserService.UserId!.Value (:98 and :134) with the null-forgiving operator, so the handler assumes an authenticated caller and would throw for an anonymous one; the endpoint's authorization is what upholds that assumption.

      • -
      • Why it's built this way: keeping only load, check, call, fan-out, and commit here (no per-entity merge logic) keeps the method readable despite spanning five entity types, and returning Result on every failure path lets the pipeline and controller handle errors uniformly rather than through exceptions. RequestIdentityInsert() (:138) is a deliberate infrastructure signal, explained in the comment above it (:136-137): Sessionize preserves its own integer ids and the strategies write them as primary keys, but SQL Server IDENTITY columns reject explicit values, so the unit of work must wrap the save in SET IDENTITY_INSERT ON/OFF per table. The [LoggerMessage] source generator (:173-174) emits an allocation-free structured log carrying EventId: [Rubric §13, Observability & Operability].
      • -
      • Where it's used: discovered by assembly scanning and wrapped by the decorator pipeline (FeatureGate, Logging, Caching, Transactional, given the command's markers, see Group 05); invoked by EventsController.RefreshAsync (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventsController.cs:342-344). That controller translates two of this handler's error codes into specific HTTP statuses: "Event.Sessionize.Throttled" becomes a 429 with a Retry-After: 300 header (:349-352) and "Event.Sessionize.Unavailable" becomes a 502 (:356-357); everything else falls through to the shared HandleFailure (:359). On success it evicts the events cache plus five entity-family output-cache tags (:363-368), which is the read-cache counterpart to the command's own ICacheInvalidating prefix.
      • -
      • Caveats / not-in-source: the result-DTO assembly reads results[0] through results[4] positionally (:145-150), so it silently depends on SyncStrategies staying in the declared order; reordering the array without updating the indices would swap the reported counts. currentUserService.UserId!.Value is dereferenced with ! on both stamp paths (:98, :134), so an unauthenticated invocation would throw rather than return a Result failure; in practice the endpoint sits behind the controller's permission attribute, but this file does not enforce it.
      +

      EventDateRangeRules<T>

      -

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:91 · Level 0 · class (sealed, generic)

      +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:109 · Level 0 · class (sealed, generic)

        -
      • What it is - a reusable FluentValidation rule fragment that enforces event date-range integrity: a start date is required, an end date is required, and the end date must fall on or after the start date.
      • -
      • Depends on - FluentValidation.AbstractValidator<T> (NuGet) and System.Linq.Expressions.Expression<> (BCL). No first-party types: the file's MMCA.ADC.Conference.Domain.Events and MMCA.Common.Application.Validation usings (EventValidationRules.cs:3-4) are consumed by its sibling fragments, not by this one.
      • -
      • Concept introduced - the reusable field-rule fragment. This is the first place in this chapter's per-type sections where the Conference module's validation-composition pattern appears, so it is worth teaching from first principles. Each *Rules<T> class is a tiny AbstractValidator<T> (or a RequiredStringRules<T> subclass) that validates exactly one concern: one field, or one cross-field relationship. The generic parameter T is the owning command or request type, and the constructor takes a property-selector Expression<Func<T, TProp>>. Because the rule is parameterized on both T and the selector, the same fragment composes into a create-request validator and an update-request validator through FluentValidation's Include(...), with zero copy-paste. [Rubric §1 - SOLID] assesses single-responsibility and open/closed adherence: each fragment owns one rule, and a new constraint is added by composing another fragment rather than by editing an existing one. [Rubric §24 - Forms/Validation/UX Safety] assesses whether validation is centralized and message-consistent: the fragment carries both the user-facing message and a stable WithErrorCode string that a client can key off.
      • -
      • Walkthrough - the constructor (EventValidationRules.cs:94-96) takes two selectors, startDateSelector and endDateSelector, both over DateOnly. It registers a NotEmpty rule on each (:98-99, :101-102) with distinct error codes (Event.StartDate.Required, Event.EndDate.Required). The cross-field check is the notable mechanism: the start-date selector is compiled into a delegate once, at construction (var startDateFunc = startDateSelector.Compile();, :104), and a second rule on the end date calls Must((instance, endDate) => endDate >= startDateFunc(instance)) with error code Event.EndDate.BeforeStart (:105-107). The two-argument Must overload hands the predicate both the whole instance under validation and the end-date value, so the compiled getter reads the sibling property off that same object.
      • -
      • Why it's built this way - compiling the selector once at construction, rather than invoking the expression tree on every validation, keeps the cross-property comparison allocation-light on a path that runs per request. Splitting each concern into its own fragment means an update validator can pull in exactly the rules it needs instead of inheriting a monolithic validator.
      • -
      • Where it's used - included by EventCreateRequestValidator (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequestValidator.cs:13) and EventUpdateRequestValidator (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/Update/EventUpdateRequestValidator.cs:13), each passing p => p.StartDate, p => p.EndDate.
      • -
      • Caveats / not-in-source - NotEmpty on a DateOnly rejects default(DateOnly) (January 1, year 1), so a caller that never sets a date fails the required rule. That is FluentValidation's default-value semantics, not something this fragment states.
      • -
      -

      GetCategoryDistributionQuery

      -
      -

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetCategoryDistribution · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetCategoryDistribution/GetCategoryDistributionQuery.cs:5 · Level 0 · record (sealed)

      -
      -
        -
      • What it is - the CQRS query contract that asks for the distribution of an event's sessions across its category items. A one-line record carrying nothing but the event to analyze.
      • -
      • Depends on - the EventIdentifierType alias, an int in this module (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7). No other first-party types, nothing external.
      • -
      • Concept introduced - this is a plain read-side CQRS request; the query/handler split is taught by IQueryHandler<in TQuery, TResult>, so it is cross-referenced rather than re-taught here. Note that the record implements no marker interface: the pairing to a handler is purely the generic argument on IQueryHandler<GetCategoryDistributionQuery, Result<CategoryDistributionDTO>> (GetCategoryDistributionHandler.cs:15). [Rubric §6 - CQRS & Event-Driven] assesses whether reads and writes travel separate paths with explicit contracts: this record is a read intent with no side effects, resolved by GetCategoryDistributionHandler.
      • -
      • Walkthrough - one positional parameter, EventId of type EventIdentifierType (:5). No body, no defaults.
      • -
      • Why it's built this way - keeping the query as a standalone record means it can be dispatched on its own (an organizer opening the category-distribution view) or read alongside the other decision-support dimensions without one endpoint over-fetching for another.
      • -
      • Where it's used - constructed by SessionSelectionController on GET SessionSelection/categories/{eventId} (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:53-61), which resolves the handler through the injected IQueryHandler<GetCategoryDistributionQuery, Result<CategoryDistributionDTO>> (:31).
      • +
      • What it is: a reusable FluentValidation rule fragment that enforces event date-range integrity: a start date is required, an end date is required, and the end date must fall on or after the start date. It is the only fragment in the Events validation folder that reasons about two properties at once.
      • +
      • Depends on: FluentValidation's AbstractValidator<T> (NuGet, primer §3) and System.Linq.Expressions.Expression<> (BCL). No first-party types: the file's MMCA.ADC.Conference.Domain.Events and MMCA.Common.Application.Validation imports (EventValidationRules.cs:3-4) serve its sibling fragments in the same file, not this one.
      • +
      • Concept, the cross-field rule fragment. The module-local fragment idiom itself is taught on ActivityEventIdRules<T>, and the framework fragments it composes over live in group-06. What this class adds is the two-property case. A single-field fragment closes over one Expression<Func<T, TProp>>; a cross-field fragment takes two selectors and must read one property while validating the other. FluentValidation's two-argument Must overload is the mechanism: the predicate receives both the instance under validation and the value of the property the rule is attached to, so the sibling property is reachable through a compiled getter. [Rubric §1, SOLID] assesses single responsibility and open/closed adherence: "end after start" is its own fragment rather than a clause bolted onto a name or time-zone rule, so adding a constraint means composing another Include(...) line, never editing an existing fragment. [Rubric §24, Forms, Validation & UX Safety] assesses whether validation is centralized and machine-addressable: one fragment serves both the create and the update path, and each of its three rules carries a stable dotted error code beside its human message.
      • +
      • Walkthrough: the constructor (EventValidationRules.cs:112-114) takes two Expression<Func<T, DateOnly>> selectors, startDateSelector and endDateSelector. It registers NotEmpty on each, with the distinct codes Event.StartDate.Required (:116-117) and Event.EndDate.Required (:119-120). The cross-field check is the part worth reading closely: the start-date selector is compiled to a delegate once, at construction (var startDateFunc = startDateSelector.Compile();, :122), and a second rule on the end date calls Must((instance, endDate) => endDate >= startDateFunc(instance)) with the message "End Date must be on or after the Start Date" and the code Event.EndDate.BeforeStart (:123-125). Because the delegate is captured in the constructor, the expression tree is compiled per validator instance, not per validated request.
      • +
      • Why it's built this way: the same rule exists on the domain side as EventInvariants.EnsureDateRangeIsValid (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:116-125, error code Event.DateRange.Invalid), so the fragment is not the only guard: it is the fast, field-attributed one that fires before a handler or an aggregate is touched, while the invariant is the backstop for any caller that bypasses the validator. Splitting the concern into its own fragment is what lets the update validator pull in exactly this rule instead of inheriting a monolithic event validator.
      • +
      • Where it's used: Included by EventCreateRequestValidator (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequestValidator.cs:13) and EventUpdateRequestValidator (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/Update/EventUpdateRequestValidator.cs:13), each passing p => p.StartDate, p => p.EndDate.
      • +
      • Caveats / not-in-source: NotEmpty on a DateOnly rejects default(DateOnly) (January 1, year 1), so a caller that never sets a date fails the required rule. That is FluentValidation's default-value semantics, not something this fragment states.

      LocalityLookupEntry

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/SpeakerLocalityHelper.cs:13 · Level 0 · record class (internal sealed)

        -
      • What it is - one entry of the merged speaker-locality lookup: a tier name plus the id of the locality category the item came from. It is declared in the same file as SpeakerLocalityHelper (SpeakerLocalityHelper.cs:13-15) because it is that helper's dictionary value type and nothing outside the helper constructs one.
      • -
      • Depends on - the ConferenceCategoryIdentifierType alias, an int in this module (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:6). No other first-party types, nothing external beyond the BCL.
      • -
      • Concept introduced - locality modelled as a category assignment that carries its import generation. A speaker's origin is not a column on Speaker: it is a SpeakerCategoryItem row pointing at a CategoryItem inside a "Where are you traveling from" Category (SpeakerLocalityHelper.cs:17-19, with the title match at :98-99). [Rubric §4 - DDD] assesses whether concepts are expressed through the aggregates the domain actually has rather than through bolted-on fields; here the answer is read out of the existing category machinery, which is also what the Sessionize import populates. [Rubric §8 - Data Architecture] explains the second field: Category is a global aggregate with no event scoping, so every yearly Sessionize refresh adds another locality category carrying fresh item ids (:82-84), and a returning speaker keeps the item from every year they answered the question (:51-52). A bare id -> name lookup cannot say which year an item belongs to; pairing the name with its owning category id can, and that is the whole reason this record exists.
      • -
      • Walkthrough - two positional members on a record class (:13-15): Name, the locality tier name, documented with the example "Atlanta and Suburbs" (:11), and CategoryId, the identifier of the locality category owning the item (:12). The type is internal sealed, so it never leaves the Application assembly. Values are produced in exactly one place, BuildLocalityLookup, which walks the locality categories in ascending id order and writes lookup[item.Id] = new LocalityLookupEntry(item.Name, category.Id) for every non-deleted item (:128-137); they are consumed in exactly one place, GetLocalityTier, which walks the speaker's non-deleted assignments, looks each one up, and keeps the entry whose CategoryId is the highest seen so far (:43-58, the comparison at :53). Because the winner is chosen by comparison rather than by position, the order of the speaker's own assignments does not affect the answer.
      • -
      • Why it's built this way - the "most recent import wins" rule needs a tiebreaker that survives merging several years of categories into one dictionary, and the owning category id is the only ordering signal available without a schema change (:7-9, :117-119). Declaring it a record gives value equality and immutability for free, which is what lets the helper treat entries as plain values while scanning.
      • -
      • Where it's used - only inside the DecisionSupport folder, as the value type of the IReadOnlyDictionary<CategoryItemIdentifierType, LocalityLookupEntry> that SpeakerLocalityHelper builds and reads (:38, :123). That dictionary is threaded through two decision-support handlers: GetSessionSelectionDashboardHandler builds it once and passes it into its overlap, locality, and AI-score passes (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboard/GetSessionSelectionDashboardHandler.cs:76, :92, :95, :106, and the parameter declarations at :190, :258, :343, :367, :397), and GetSpeakerSessionOverlapHandler does the same for its narrower view (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSpeakerSessionOverlap/GetSpeakerSessionOverlapHandler.cs:52-53, :56, :103, :119).
      • -
      • Caveats / not-in-source - the "highest category id is the most recent import" rule is an assumption about how Sessionize allocates category ids. The source states it twice as a comment (:51-52, :117-119) but nothing enforces or validates it, so an out-of-order id would silently resolve a returning speaker to an older tier. The behavior is pinned by SpeakerLocalityHelperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/SpeakerLocalityHelperTests.cs:110-123, :126-141, :144-158), which construct entries directly and assert the newest-import tier wins regardless of assignment order, but the id ordering itself is an upstream property.
      • +
      • What it is: one entry of the merged speaker-locality lookup: a tier name plus the id of the locality category the item came from. It is declared in the same file as SpeakerLocalityHelper (SpeakerLocalityHelper.cs:13-15) because it is that helper's dictionary value type and nothing outside the helper constructs one.
      • +
      • Depends on: the ConferenceCategoryIdentifierType alias, an int in this module (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7). No other first-party types, nothing external beyond the BCL.
      • +
      • Concept, locality modelled as a category assignment that carries its import generation. A speaker's origin is not a column on Speaker: it is a SpeakerCategoryItem row pointing at a CategoryItem inside a "Where are you traveling from" Category (SpeakerLocalityHelper.cs:17-19, with the title match at :98-99). [Rubric §4, DDD] assesses whether concepts are expressed through the aggregates the domain actually has rather than through bolted-on fields; here the answer is read out of the existing category machinery, which is also what the Sessionize import populates. [Rubric §8, Data Architecture] explains the second field: Category is a global aggregate with no event scoping, so every yearly Sessionize refresh adds another locality category carrying fresh item ids (:82-84), and a returning speaker keeps the item from every year they answered the question (:51-52). A bare id-to-name lookup cannot say which year an item belongs to; pairing the name with its owning category id can, and that is the whole reason this record exists.
      • +
      • Walkthrough: two positional members on a record class (:13-15). Name is the locality tier name, documented with the example "Atlanta and Suburbs" (:11); CategoryId is the identifier of the locality category owning the item (:12). The type is internal sealed, so it never leaves the Application assembly. Values are produced in exactly one place, BuildLocalityLookup, which walks the locality categories in ascending id order and writes lookup[item.Id] = new LocalityLookupEntry(item.Name, category.Id) for every non-deleted item (:128-137); they are consumed in exactly one place, GetLocalityTier, which walks the speaker's non-deleted assignments, looks each one up, and keeps the entry whose CategoryId is the highest seen so far (:43-58, the comparison at :53). Because the winner is chosen by comparison rather than by position, the order of the speaker's own assignments does not affect the answer.
      • +
      • Why it's built this way: the "most recent import wins" rule needs a tiebreaker that survives merging several years of categories into one dictionary, and the owning category id is the only ordering signal available without a schema change (:7-9, :117-119). Declaring it a record gives value equality and immutability for free, which is what lets the helper treat entries as plain values while scanning.
      • +
      • Where it's used: only inside the DecisionSupport folder, as the value type of the IReadOnlyDictionary<CategoryItemIdentifierType, LocalityLookupEntry> that SpeakerLocalityHelper builds and reads (:38, :123). That dictionary is threaded through two decision-support handlers: GetSessionSelectionDashboardHandler builds it once and passes it into its overlap, locality, and AI-score passes (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboard/GetSessionSelectionDashboardHandler.cs:76, :92, :95, :106, with the parameter declarations at :190, :258, :343, :367, :397), and GetSpeakerSessionOverlapHandler does the same for its narrower view (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSpeakerSessionOverlap/GetSpeakerSessionOverlapHandler.cs:52-53, :56, :103, :119).
      • +
      • Caveats / not-in-source: the "highest category id is the most recent import" rule is an assumption about how Sessionize allocates category ids. The source states it twice as a comment (:51-52, :117-119) but nothing enforces or validates it, so an out-of-order id would silently resolve a returning speaker to an older tier. The behavior is pinned by SpeakerLocalityHelperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/SpeakerLocalityHelperTests.cs:110-123, :126-141, :144-158), which build the entries directly and assert that the newest-import tier wins regardless of assignment order, but the id ordering itself is an upstream property.

      RoomCapacityRules<T>

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:37 · Level 0 · class (sealed, generic)

        -
      • What it is - a reusable rule fragment enforcing that a room's capacity, when supplied, is strictly positive. Capacity is optional (int?), so the rule only fires when a value is present.
      • -
      • Depends on - FluentValidation.AbstractValidator<T> and System.Linq.Expressions.Expression<>. No first-party types.
      • -
      • Concept introduced - the same reusable field-rule pattern taught in EventDateRangeRules<T>. The wrinkle worth noticing is the conditional: .When(x => selector.Compile()(x) is not null) (RoomValidationRules.cs:43) guards the GreaterThan(0) rule (:42) so a null capacity is silently accepted rather than reported as invalid. [Rubric §24 - Forms/Validation/UX Safety] assesses whether validation matches the field's real optionality: an absent optional numeric field should not raise an error.
      • -
      • Walkthrough - the constructor takes an Expression<Func<T, int?>> selector (:40), chains GreaterThan(0) with message "Capacity must be greater than 0" and error code Room.Capacity.NotPositive (:42), then applies the null-guard When clause (:43).
      • -
      • Why it's built this way - separating the presence check (When) from the value check keeps the "optional but bounded" semantics in one place: a room without a known capacity is valid, a room claiming a non-positive capacity is not.
      • -
      • Where it's used - included by AddRoomCommandValidator (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/AddRoom/AddRoomCommandValidator.cs:13) and UpdateRoomCommandValidator (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomCommandValidator.cs:13), both on p => p.Capacity.
      • -
      • Caveats / not-in-source - unlike EventDateRangeRules<T>, which compiles its selector once at construction, the When predicate here calls selector.Compile() inside the lambda (:43), so the expression is compiled on each evaluation rather than cached.
      • +
      • What it is: a reusable rule fragment enforcing that a room's capacity, when supplied, is strictly positive. Capacity is optional (int?), so the rule only fires when a value is present.
      • +
      • Depends on: FluentValidation's AbstractValidator<T> and System.Linq.Expressions.Expression<>. No first-party types: unlike the string fragments in the same file it reads no domain constant.
      • +
      • Concept: the module-local rule fragment taught on ActivityEventIdRules<T>. The wrinkle worth noticing here is the conditional: .When(x => selector.Compile()(x) is not null) (RoomValidationRules.cs:43) guards the GreaterThan(0) rule (:42) so a null capacity is silently accepted rather than reported as invalid. [Rubric §24, Forms, Validation & UX Safety] assesses whether validation matches a field's real optionality: an absent optional numeric field should not raise an error, while a present but nonsensical one should.
      • +
      • Walkthrough: the constructor takes an Expression<Func<T, int?>> selector (:40), chains GreaterThan(0) with the message "Capacity must be greater than 0" and the error code Room.Capacity.NotPositive (:41-42), then applies the null-guard When clause (:43).
      • +
      • Why it's built this way: separating the presence check (When) from the value check keeps the "optional but bounded" semantics in one place: a room without a known capacity is valid, a room claiming a non-positive capacity is not. The domain states the same rule as EventInvariants.EnsureRoomCapacityIsValid, whose pattern is capacity is <= 0 and whose code is Room.Capacity.Invalid (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:131-139), tagged BR-93 in its doc comment (:127), so the fragment and the invariant agree on treating null as acceptable.
      • +
      • Where it's used: Included by AddRoomCommandValidator (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/AddRoom/AddRoomCommandValidator.cs:13) and UpdateRoomCommandValidator (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomCommandValidator.cs:13), both on p => p.Capacity.
      • +
      • Caveats / not-in-source: unlike EventDateRangeRules<T>, which compiles its selector once at construction, the When predicate here calls selector.Compile() inside the lambda (:43), so the expression is recompiled on each evaluation rather than cached.

      RoomSortRules<T>

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:25 · Level 0 · class (sealed, generic)

        -
      • What it is - a reusable rule fragment enforcing that a room's sort-order value is non-negative.
      • -
      • Depends on - FluentValidation.AbstractValidator<T> and System.Linq.Expressions.Expression<>. No first-party types.
      • -
      • Concept introduced - the same pattern as EventDateRangeRules<T>, in its simplest possible form: one rule, no conditionals, no cross-field logic.
      • -
      • Walkthrough - the constructor takes an Expression<Func<T, int>> selector (:28) and registers GreaterThanOrEqualTo(0) with message "Sort must be greater than or equal to 0" and error code Room.Sort.Negative (:29-30).
      • -
      • Why it's built this way - sort order drives deterministic ordering of rooms in the UI; a negative value has no meaning, so the fragment rejects it at the application boundary before it reaches the domain factory.
      • -
      • Where it's used - included by AddRoomCommandValidator (AddRoomCommandValidator.cs:12) and UpdateRoomCommandValidator (UpdateRoomCommandValidator.cs:12), both on p => p.Sort, alongside RoomCapacityRules<T>.
      • -
      -

      StatusBucket

      -
      -

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetCategoryDistribution · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetCategoryDistribution/GetCategoryDistributionHandler.cs:94 · Level 0 · enum (private, nested)

      -
      -
        -
      • What it is - a private enum nested inside GetCategoryDistributionHandler that collapses a session's raw status string onto one of three counting buckets used to tally the category distribution.
      • -
      • Depends on - nothing structurally; it is produced by the handler's ClassifyStatus from the SessionStatuses string constants (GetCategoryDistributionHandler.cs:101-112).
      • -
      • Concept introduced - a handler-local aggregation vocabulary. The enum is an implementation detail of one handler and never reaches a caller, who receives DTO counts instead. [Rubric §16 - Maintainability] assesses local reasoning: keeping the bucket type private to its handler means the bucketing can evolve without coupling the sibling decision-support handlers. [Rubric §15 - Best Practices & Code Quality] assesses expressiveness: three named members read better at the tally site (:58-60) than three ad-hoc string comparisons would.
      • -
      • Walkthrough - three members: Accepted, AcceptQueue, Pending (:94-99). There is deliberately no Declined member: declined sessions are removed upstream by IsDeclined (:45, :114-115) before any bucketing happens, so the enum only spans the statuses that count toward a category's totals.
      • -
      • Why it's built this way - declined proposals do not contribute to the distribution an organizer is weighing, so filtering them out before the enum stage keeps the three live buckets clean.
      • -
      • Where it's used - inside GetCategoryDistributionHandler only, by CountSessionsPerCategoryItem (:58-60) and ClassifyStatus (:101-112).
      • -
      • Caveats / not-in-source - GetSessionSelectionDashboardHandler declares its own private enum of the same name and the same three members (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboard/GetSessionSelectionDashboardHandler.cs:314-319), with a matching ClassifyStatus (:321-332). They are two independent types that happen to agree today; nothing in source keeps them in step.
      • +
      • What it is: a reusable rule fragment enforcing that a room's sort-order value is non-negative.
      • +
      • Depends on: FluentValidation's AbstractValidator<T> and System.Linq.Expressions.Expression<>. No first-party types.
      • +
      • Concept: the module-local rule fragment taught on ActivityEventIdRules<T>, in its simplest possible form: one rule, no conditional, no cross-field logic. Its structural twin on the Activities side is ActivitySortOrderRules<T>, and on the Categories side CategoryItemSortRules<T>.
      • +
      • Walkthrough: the constructor takes an Expression<Func<T, int>> selector (:28) and registers GreaterThanOrEqualTo(0) with the message "Sort must be greater than or equal to 0" and the error code Room.Sort.Negative (:29-30).
      • +
      • Why it's built this way: GreaterThanOrEqualTo(0) rather than GreaterThan(0) because zero is a legitimate "first in the list" position, which is also why the framework's shared PositiveIntRules<T> (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:49-54) is the wrong fragment to reuse here. Sort order drives deterministic room ordering in the UI, so a negative value is rejected at the application boundary before it reaches the domain factory.
      • +
      • Where it's used: Included by AddRoomCommandValidator (AddRoomCommandValidator.cs:12) and UpdateRoomCommandValidator (UpdateRoomCommandValidator.cs:12), both on p => p.Sort, alongside RoomCapacityRules<T>.
      • +
      • Caveats / not-in-source: nothing in EventInvariants mirrors this rule, so unlike room name and room capacity the sort order has no domain-side backstop: this fragment is the only guard on the path.

      EventNameRules<T>

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:13 · Level 7 · class (sealed, generic)

        -
      • What it is - a reusable rule fragment for the event name: non-empty and bounded by EventInvariants.NameMaxLength, which is 500 characters (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:13).
      • -
      • Depends on - RequiredStringRules<T> (its base class, MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:13) and EventInvariants.
      • -
      • Concept introduced - the same reusable field-rule pattern as EventDateRangeRules<T>, but this fragment inherits the framework's shared RequiredStringRules<T> instead of AbstractValidator<T> directly, delegating the NotEmpty plus MaximumLength wiring to the base (CommonValidationRules.cs:15-18). [Rubric §4 - DDD] assesses ubiquitous language in code: the class is named EventNameRules after the domain field rather than a generic "NameValidator". [Rubric §16 - Maintainability] assesses reuse across repos: the two-line derived class is all the module writes, because the shared base owns the message shape.
      • -
      • Walkthrough - one constructor taking an Expression<Func<T, string>> selector, whose whole body is the base call base(selector, "Event Name", EventInvariants.NameMaxLength) (:16-17). The base produces the messages "You must enter a Event Name" and "Event Name cannot be longer than 500 characters" (CommonValidationRules.cs:17-18).
      • -
      • Why it's built this way - the length constant lives once in EventInvariants and is the same value the domain-side invariant enforces (EventInvariants.EnsureNameIsValid, EventInvariants.cs:64-67, error code Event.Name.TooLong), so the validation message and the domain invariant cannot drift apart.
      • -
      • Where it's used - included by EventCreateRequestValidator (EventCreateRequestValidator.cs:11) and EventUpdateRequestValidator (EventUpdateRequestValidator.cs:11), both on p => p.Name.
      • -
      • Caveats / not-in-source - unlike the room and time-zone fragments, this one emits no WithErrorCode, because the shared base sets none (CommonValidationRules.cs:16-18). A client keying off error codes gets them for the event time zone and date range but not for the event name.
      • +
      • What it is: a reusable rule fragment for the event name: non-empty and bounded by EventInvariants.NameMaxLength, which is 500 characters (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:13).
      • +
      • Depends on: RequiredStringRules<T>, its base class from the framework (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:13), and EventInvariants for the bound.
      • +
      • Concept: the same fragment idiom taught on ActivityEventIdRules<T>, but taken to its terse extreme: instead of writing a rule chain, this fragment subclasses the framework's shared RequiredStringRules<T> and passes it a field label and a bound, delegating the NotEmpty plus MaximumLength wiring to the base (CommonValidationRules.cs:15-18). [Rubric §4, DDD] assesses ubiquitous language in code: the class is named EventNameRules after the domain field, not a generic "NameValidator". [Rubric §16, Maintainability] assesses reuse across repos: the module writes two lines, because the shared base owns the message shape, which is exactly the trade this idiom makes (see the caveat).
      • +
      • Walkthrough: one constructor taking an Expression<Func<T, string>> selector (:16), whose entire body is the base call base(selector, "Event Name", EventInvariants.NameMaxLength) (:17). The base produces the two messages "You must enter a Event Name" and "Event Name cannot be longer than 500 characters" (CommonValidationRules.cs:17-18).
      • +
      • Why it's built this way: the length constant lives once in EventInvariants and is the same value the domain-side invariant enforces (EventInvariants.EnsureNameIsValid, EventInvariants.cs:67-70, error code Event.Name.TooLong), so the validation message, the aggregate guard, and the schema cannot drift apart.
      • +
      • Where it's used: Included by EventCreateRequestValidator (EventCreateRequestValidator.cs:11) and EventUpdateRequestValidator (EventUpdateRequestValidator.cs:11), both on p => p.Name.
      • +
      • Caveats / not-in-source: unlike the room and time-zone fragments, this one emits no WithErrorCode, because the shared base sets none (CommonValidationRules.cs:16-18). A client keying off error codes gets them for the event time zone and the date range but not for the event name. The base message also reads "You must enter a Event Name", an article-agreement artifact of building the message from the field label.

      EventOrganizerContactEmailRules<T>

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:57 · Level 7 · class (sealed, generic)

        -
      • What it is - a rule fragment for the event's optional organizer contact email. When the caller supplies a value it must be a well-formed email address no longer than EventInvariants.OrganizerContactEmailMaxLength, 255 characters (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:34); when the caller leaves it blank, no rule runs at all.
      • -
      • Depends on - EmailRules<T>, the shared framework fragment it wraps (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:36-43), and EventInvariants. Inherits AbstractValidator<T> directly and uses System.Linq.Expressions.Expression<>.
      • -
      • Concept introduced - conditional inclusion of a required-field fragment. The three preceding fragments either always apply (EventNameRules<T>) or are unconditionally length-only (RoomFloorRules<T>). This one is different: the shared EmailRules<T> it wants to reuse starts with NotEmpty (CommonValidationRules.cs:40), which is exactly wrong for an optional field. Rather than fork a near-copy of the shared fragment, the constructor compiles the selector once (var accessor = selector.Compile();, EventValidationRules.cs:62) and wraps the whole Include in FluentValidation's When(...) so the required-email rules are only registered against instances that actually carry a value (:64-65). [Rubric §1 - SOLID] assesses open/closed adherence: optionality is added around the shared rule, not by modifying it. [Rubric §24 - Forms/Validation/UX Safety] assesses whether validation matches the field's real optionality: an organizer who never fills the field sees no error, while a typo in a filled field is still rejected as a bad address.
      • -
      • Walkthrough - the constructor takes an Expression<Func<T, string>> selector (:60), compiles it to a delegate (:62), then calls When(x => !string.IsNullOrWhiteSpace(accessor(x)), () => Include(new EmailRules<T>(selector, "Organizer Contact Email", EventInvariants.OrganizerContactEmailMaxLength))) (:64-65). Inside the guard the shared base contributes three chained rules: NotEmpty, EmailAddress, and MaximumLength, with messages built from the "Organizer Contact Email" field name (CommonValidationRules.cs:39-42).
      • -
      • Why it's built this way - the field's doc comment states the product reason (:51-55): the value is optional, and an empty value means the public event page falls back to the configured support address rather than showing nothing. That fallback is real, PublicEventDetail picks Configuration["Support:Email"] when the event carries no organizer address (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicEventDetail.razor.cs:81-83), so rejecting a blank value at the boundary would break the intended default.
      • -
      • Where it's used - included by EventCreateRequestValidator (EventCreateRequestValidator.cs:14) and EventUpdateRequestValidator (EventUpdateRequestValidator.cs:14), both as p => p.OrganizerContactEmail!.
      • -
      • Caveats / not-in-source - the selector type is non-nullable string while the underlying request property is string? (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequest.cs:46), which is why both call sites pass the null-forgiving p => p.OrganizerContactEmail!. The ! only silences the compiler; the runtime null is handled by the When guard, which is what makes the combination safe. There is no matching domain-side invariant for this field: EventInvariants defines the length constant (:34) and the EF configuration applies it to the column (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/EventConfiguration.cs:56-57), but no Ensure... method validates the address, so this fragment is the only format check on the path.
      • +
      • What it is: a rule fragment for the event's optional organizer contact email. When the caller supplies a value it must be a well-formed email address no longer than EventInvariants.OrganizerContactEmailMaxLength, 255 characters (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:34); when the caller leaves it blank, no rule runs at all.
      • +
      • Depends on: EmailRules<T>, the shared framework fragment it wraps (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:36-43), and EventInvariants. It inherits AbstractValidator<T> directly and uses System.Linq.Expressions.Expression<>.
      • +
      • Concept, conditional inclusion of a required-field fragment. The fragments seen so far either always apply (EventNameRules<T>) or are unconditionally length-only (RoomFloorRules<T>). This one is different: the shared EmailRules<T> it wants to reuse starts with NotEmpty (CommonValidationRules.cs:40), which is exactly wrong for an optional field. Rather than fork a near-copy of the shared fragment, the constructor compiles the selector once (var accessor = selector.Compile();, EventValidationRules.cs:62) and wraps the whole Include in FluentValidation's When(...), so the required-email rules are only registered against instances that actually carry a value (:64-65). [Rubric §1, SOLID] assesses open/closed adherence: optionality is composed around the shared rule, never by modifying it. [Rubric §24, Forms, Validation & UX Safety] assesses whether validation matches the field's real optionality: an organizer who never fills the field sees no error, while a typo in a filled field is still rejected as a bad address.
      • +
      • Walkthrough: the constructor takes an Expression<Func<T, string>> selector (:60), compiles it to a delegate (:62), then calls When(x => !string.IsNullOrWhiteSpace(accessor(x)), () => Include(new EmailRules<T>(selector, "Organizer Contact Email", EventInvariants.OrganizerContactEmailMaxLength))) (:64-65). Inside the guard the shared base contributes three chained rules: NotEmpty, EmailAddress, and MaximumLength, all with messages built from the "Organizer Contact Email" field label (CommonValidationRules.cs:39-42).
      • +
      • Why it's built this way: the field's doc comment states the product reason (:51-55): the value is optional, and an empty value means the public page falls back to the configured support address rather than showing nothing. That fallback is real. PublicEventDetail seeds _supportEmail from Configuration["Support:Email"] (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicEventDetail.razor.cs:54) and, once the event loads, keeps the configured address when Event.OrganizerContactEmail is blank and uses the event's own address otherwise (:115-117). Rejecting a blank value at the boundary would break that intended default.
      • +
      • Where it's used: Included by EventCreateRequestValidator (EventCreateRequestValidator.cs:14) and EventUpdateRequestValidator (EventUpdateRequestValidator.cs:14), both as p => p.OrganizerContactEmail!.
      • +
      • Caveats / not-in-source: the selector type is non-nullable string while the underlying request property is string? on both requests (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequest.cs:46, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/Update/EventUpdateRequest.cs:43), which is why both call sites pass the null-forgiving p => p.OrganizerContactEmail!. The ! only silences the compiler; the runtime null is handled by the When guard, and that combination is what makes it safe. There is also no domain-side invariant for this field: EventInvariants defines the length constant (:34) and the EF configuration applies it to the column (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/EventConfiguration.cs:56-57), but no Ensure... method validates the address, so this fragment is the only format check on the path.

      EventSponsorshipPacketUrlRules<T>

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:75 · Level 7 · class (sealed, generic)

        -
      • What it is - a rule fragment bounding the event's optional sponsorship-packet URL to EventInvariants.SponsorshipPacketUrlMaxLength, 2000 characters (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:37), and only when a value is supplied.
      • -
      • Depends on - OptionalStringRules<T>, the shared framework fragment it wraps (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:25-30), and EventInvariants. Inherits AbstractValidator<T> directly.
      • -
      • Concept introduced - structurally the same conditional-inclusion shape as EventOrganizerContactEmailRules<T>: compile the selector once, then Include the shared fragment inside a When guard. Worth noticing is that here the guard is not strictly needed for correctness, because the wrapped OptionalStringRules<T> is length-only and already passes a null (CommonValidationRules.cs:28-29); the two fragments are written to the same shape so the file reads uniformly. [Rubric §16 - Maintainability] assesses consistency: two adjacent optional-field fragments that look identical are cheaper to read and to extend than two that solve the same problem differently.
      • -
      • Walkthrough - the constructor takes an Expression<Func<T, string?>> selector (:78), note the nullable string? here as against the non-nullable selector of its email sibling, compiles it (:80), and registers When(x => !string.IsNullOrWhiteSpace(accessor(x)), () => Include(new OptionalStringRules<T>(selector, "Sponsorship Packet URL", EventInvariants.SponsorshipPacketUrlMaxLength))) (:82-83). The shared base contributes a single MaximumLength rule with the message "Sponsorship Packet URL cannot be longer than 2000 characters" (CommonValidationRules.cs:28-29).
      • -
      • Why it's built this way - the doc comment gives the product reason (:69-73): the field is optional, and an empty value means the landing page and the public sponsor page hide the sponsorship call to action rather than rendering a dead link. That behavior is visible in the UI, PublicSponsorList renders the packet button only when the URL is non-blank (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSponsorList.razor:30, with the value loaded at PublicSponsorList.razor.cs:61).
      • -
      • Where it's used - included by EventCreateRequestValidator (EventCreateRequestValidator.cs:15) and EventUpdateRequestValidator (EventUpdateRequestValidator.cs:15), both on p => p.SponsorshipPacketUrl.
      • -
      • Caveats / not-in-source - despite the name, nothing here validates that the value is a URL: the only constraint is length. A caller can store arbitrary text, and the public page will render it as a link target. The shared base also sets no WithErrorCode, so this field produces a message without a machine-readable code.
      • +
      • What it is: a rule fragment bounding the event's optional sponsorship-packet URL to EventInvariants.SponsorshipPacketUrlMaxLength, 2000 characters (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:37), and only when a value is supplied.
      • +
      • Depends on: OptionalStringRules<T>, the shared framework fragment it wraps (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:25-30), and EventInvariants. It inherits AbstractValidator<T> directly.
      • +
      • Concept: structurally the same conditional-inclusion shape taught on EventOrganizerContactEmailRules<T>: compile the selector once, then Include the shared fragment inside a When guard. Worth noticing is that here the guard is not strictly needed for correctness, because the wrapped OptionalStringRules<T> is length-only and already passes a null (CommonValidationRules.cs:28-29); the two fragments are written to the same shape so the file reads uniformly. [Rubric §16, Maintainability] assesses consistency: two adjacent optional-field fragments that look identical are cheaper to read and to extend than two that reach the same outcome by different routes.
      • +
      • Walkthrough: the constructor takes an Expression<Func<T, string?>> selector (:78), note the nullable string? here as against the non-nullable selector of its email sibling, compiles it (:80), and registers When(x => !string.IsNullOrWhiteSpace(accessor(x)), () => Include(new OptionalStringRules<T>(selector, "Sponsorship Packet URL", EventInvariants.SponsorshipPacketUrlMaxLength))) (:82-83). The shared base contributes a single MaximumLength rule with the message "Sponsorship Packet URL cannot be longer than 2000 characters" (CommonValidationRules.cs:28-29).
      • +
      • Why it's built this way: the doc comment gives the product reason (:69-73): the field is optional, and an empty value means the landing page and the public sponsor page hide the sponsorship call to action rather than rendering a dead link. Both behaviors are visible in the UI. PublicSponsorList renders its "Download Sponsorship Packet" button only when the URL is non-blank (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSponsorList.razor:29-37, with the value loaded at PublicSponsorList.razor.cs:61), and the landing page guards its own sponsorship call to action the same way (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor:310, :322).
      • +
      • Where it's used: Included by EventCreateRequestValidator (EventCreateRequestValidator.cs:15) and EventUpdateRequestValidator (EventUpdateRequestValidator.cs:15), both on p => p.SponsorshipPacketUrl.
      • +
      • Caveats / not-in-source: despite the name, nothing here validates that the value is a URL: the only constraint is length. A caller can store arbitrary text and the public page will render it as a link target. The shared base also sets no WithErrorCode, so this field produces a message without a machine-readable code.
      • +
      +

      EventTicketingUrlRules<T>

      +
      +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:93 · Level 7 · class (sealed, generic)

      +
      +
        +
      • What it is: a rule fragment bounding the event's optional ticketing URL to EventInvariants.TicketingUrlMaxLength, 2000 characters (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:40), and only when a value is supplied. It is the exact twin of its sponsorship sibling, one field over.
      • +
      • Depends on: OptionalStringRules<T> (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:25-30) and EventInvariants. It inherits AbstractValidator<T> directly.
      • +
      • Concept: the conditional-inclusion shape taught on EventOrganizerContactEmailRules<T> and repeated verbatim by EventSponsorshipPacketUrlRules<T>. Reading the three optional event fragments in a row (:57, :75, :93) is the clearest illustration of the module's convention: an optional field gets a compiled accessor, a When presence guard, and an Include of a shared framework fragment, and the only things that vary between them are which framework fragment is wrapped and which invariant constant bounds it. [Rubric §2, Design Patterns] assesses whether a recurring shape is expressed as a reusable composition rather than duplicated logic: the wrapping is identical, only the parameters change.
      • +
      • Walkthrough: the constructor takes an Expression<Func<T, string?>> selector (:96), compiles it to a delegate (:98), and registers When(x => !string.IsNullOrWhiteSpace(accessor(x)), () => Include(new OptionalStringRules<T>(selector, "Ticketing URL", EventInvariants.TicketingUrlMaxLength))) (:100-101). The shared base contributes one MaximumLength rule whose message is "Ticketing URL cannot be longer than 2000 characters" (CommonValidationRules.cs:28-29).
      • +
      • Why it's built this way: the doc comment states the product reason (:87-91): the field is optional, and an empty value means the landing page and the public event page hide the ticketing call to action. Both sites guard on the value being non-blank before rendering a button, the landing page at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor:71-75 and the public event page at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicEventDetail.razor:114-118, so a blank value is a supported product state rather than a validation failure. The 2000-character bound is the same constant the EF configuration applies to the column (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/EventConfiguration.cs:64-65).
      • +
      • Where it's used: Included by EventCreateRequestValidator (EventCreateRequestValidator.cs:16) and EventUpdateRequestValidator (EventUpdateRequestValidator.cs:16), both on p => p.TicketingUrl.
      • +
      • Caveats / not-in-source: as with the sponsorship URL, nothing validates URL syntax and no WithErrorCode is attached, because the wrapped base sets none. Note also that the landing page's pre-conference ticketing button is a separate, hard-coded constant (ADCHome.razor.cs:30) and does not flow through this field or this rule.

      EventTimeZoneRules<T>

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:25 · Level 7 · class (sealed, generic)

        -
      • What it is - a rule fragment for an event's time zone: non-empty, bounded by EventInvariants.TimeZoneMaxLength (100 characters, EventInvariants.cs:19), and semantically checked to be a time-zone identifier the runtime actually recognizes. The doc comment ties the third rule to business requirement BR-87 (EventValidationRules.cs:21-22).
      • -
      • Depends on - EventInvariants and System.TimeZoneInfo (BCL). Inherits AbstractValidator<T> directly.
      • -
      • Concept introduced - the same fragment shape as EventNameRules<T>, but it needs a predicate beyond string length, so it extends AbstractValidator<T> and adds a Must(...) rule. [Rubric §24 - Forms/Validation/UX Safety] assesses whether the boundary rejects values the downstream code cannot use: proving the string resolves to a real time zone stops an unusable identifier from reaching scheduling logic. [Rubric §15 - Best Practices & Code Quality] assesses defensive detail: the predicate catches only TimeZoneNotFoundException (:44), so an unexpected failure is not swallowed as "invalid input".
      • -
      • Walkthrough - the constructor chains three rules on one selector: NotEmpty with code Event.TimeZone.Required (:30), MaximumLength(EventInvariants.TimeZoneMaxLength) with code Event.TimeZone.MaxLength (:31), and Must(BeAValidIanaTimeZone) with code Event.TimeZone.InvalidIana and the message naming 'America/New_York' as the example form (:32). BeAValidIanaTimeZone (:34-48) returns true immediately for null or whitespace (:36-37) with the comment that NotEmpty already covers that branch, then calls TimeZoneInfo.FindSystemTimeZoneById(timeZone) inside a try (:41) and returns false only on TimeZoneNotFoundException (:44-46).
      • -
      • Why it's built this way - returning true for the empty case avoids emitting two messages for one missing field. Delegating the identifier check to TimeZoneInfo reuses the platform's canonical time-zone database instead of hand-maintaining a list of identifiers. The domain repeats the same three checks in EventInvariants.EnsureTimeZoneIsValid (EventInvariants.cs:75-93), so a caller bypassing the validator still cannot persist an unknown zone.
      • -
      • Where it's used - included by EventCreateRequestValidator (EventCreateRequestValidator.cs:12) and EventUpdateRequestValidator (EventUpdateRequestValidator.cs:12), both on p => p.TimeZone.
      • -
      • Caveats / not-in-source - FindSystemTimeZoneById resolves against the host operating system's time-zone database, so which identifiers are accepted can differ between a Windows developer machine and the Linux containers the services run in. Nothing in the rule pins that behavior, and the message says "IANA" while the lookup is whatever the host supports.
      • +
      • What it is: a rule fragment for an event's time zone: non-empty, bounded by EventInvariants.TimeZoneMaxLength (100 characters, EventInvariants.cs:19), and semantically checked to be a time-zone identifier the runtime actually recognizes. The doc comment ties the third rule to business requirement BR-87 (EventValidationRules.cs:21-22).
      • +
      • Depends on: EventInvariants and System.TimeZoneInfo (BCL). It inherits AbstractValidator<T> directly.
      • +
      • Concept: the same fragment shape as EventNameRules<T>, but it needs a predicate beyond string length, so it extends AbstractValidator<T> and adds a Must(...) rule backed by a private static predicate method. [Rubric §24, Forms, Validation & UX Safety] assesses whether the boundary rejects values the downstream code cannot use: proving the string resolves to a real time zone stops an unusable identifier from reaching scheduling logic. [Rubric §15, Best Practices & Code Quality] assesses defensive detail: the predicate catches only TimeZoneNotFoundException (:44), so an unexpected failure surfaces as an exception rather than being silently reported as "invalid input".
      • +
      • Walkthrough: the constructor chains three rules on one selector (:28-32): NotEmpty with code Event.TimeZone.Required (:30), MaximumLength(EventInvariants.TimeZoneMaxLength) with code Event.TimeZone.MaxLength (:31), and Must(BeAValidIanaTimeZone) with code Event.TimeZone.InvalidIana and a message naming 'America/New_York' as the example form (:32). BeAValidIanaTimeZone (:34-48) returns true immediately for null or whitespace (:36-37), with an inline comment noting that NotEmpty already covers that branch, then calls TimeZoneInfo.FindSystemTimeZoneById(timeZone) inside a try (:39-43) and returns false only on TimeZoneNotFoundException (:44-47).
      • +
      • Why it's built this way: returning true for the empty case avoids emitting two messages for one missing field. Delegating the identifier check to TimeZoneInfo reuses the platform's canonical time-zone database instead of hand-maintaining a list of identifiers. The domain repeats all three checks in EventInvariants.EnsureTimeZoneIsValid (EventInvariants.cs:78-105, with the codes Event.TimeZone.Empty, Event.TimeZone.TooLong, and Event.TimeZone.Invalid), so a caller that bypasses the validator still cannot persist an unknown zone. Note that the application-layer codes and the domain-layer codes are deliberately different strings for the same three conditions.
      • +
      • Where it's used: Included by EventCreateRequestValidator (EventCreateRequestValidator.cs:12) and EventUpdateRequestValidator (EventUpdateRequestValidator.cs:12), both on p => p.TimeZone.
      • +
      • Caveats / not-in-source: FindSystemTimeZoneById resolves against the host operating system's time-zone database, so which identifiers are accepted can differ between a Windows developer machine and the Linux containers the services run in. Nothing in the rule pins that behavior, and the message says "IANA" while the lookup is whatever the host supports.

      RoomAccessibilityInfoRules<T>

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:77 · Level 7 · class (sealed, generic)

        -
      • What it is - a rule fragment bounding a room's optional accessibility-info text to EventInvariants.RoomAccessibilityInfoMaxLength, 500 characters (EventInvariants.cs:49).
      • -
      • Depends on - EventInvariants. Inherits AbstractValidator<T> directly.
      • -
      • Concept introduced - the same fragment pattern as EventNameRules<T>. The distinguishing detail is optionality: the selector is Expression<Func<T, string?>> (:80) and the fragment applies MaximumLength only, with no NotEmpty, so a null value passes. Note the contrast with EventSponsorshipPacketUrlRules<T>, which reaches the same outcome through a shared fragment behind a When guard: this one simply writes the single rule locally.
      • -
      • Walkthrough - one constructor, one rule: MaximumLength(EventInvariants.RoomAccessibilityInfoMaxLength) with the message "Accessibility Info cannot be longer than 500 characters" and error code Room.AccessibilityInfo.MaxLength (:80-82).
      • -
      • Why it's built this way - accessibility notes are free text an organizer may not have yet, so absence is valid; only the length is constrained, using the same constant the domain and the persistence configuration share.
      • -
      • Where it's used - included by AddRoomCommandValidator (AddRoomCommandValidator.cs:16) and UpdateRoomCommandValidator (UpdateRoomCommandValidator.cs:16), both on p => p.AccessibilityInfo.
      • +
      • What it is: a rule fragment bounding a room's optional accessibility-info text to EventInvariants.RoomAccessibilityInfoMaxLength, 500 characters (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:52).
      • +
      • Depends on: EventInvariants. It inherits AbstractValidator<T> directly.
      • +
      • Concept: the module-local fragment idiom taught on ActivityEventIdRules<T>. The distinguishing detail is optionality expressed in the selector type: Expression<Func<T, string?>> (:80), with MaximumLength only and no NotEmpty, so a null value passes without a guard. Contrast EventSponsorshipPacketUrlRules<T>, which reaches the same outcome by wrapping a shared framework fragment in a When guard: this one simply writes the single rule locally, which is also what buys it an error code.
      • +
      • Walkthrough: one constructor (:80), one rule: MaximumLength(EventInvariants.RoomAccessibilityInfoMaxLength) with the message "Accessibility Info cannot be longer than 500 characters" and the error code Room.AccessibilityInfo.MaxLength (:81-82).
      • +
      • Why it's built this way: accessibility notes are free text an organizer may not have yet, so absence is valid; only the length is constrained, using the same constant the domain and the persistence configuration share.
      • +
      • Where it's used: Included by AddRoomCommandValidator (AddRoomCommandValidator.cs:16) and UpdateRoomCommandValidator (UpdateRoomCommandValidator.cs:16), both on p => p.AccessibilityInfo.

      RoomFloorRules<T>

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:51 · Level 7 · class (sealed, generic)

        -
      • What it is - a rule fragment bounding a room's optional floor label to EventInvariants.RoomFloorMaxLength, 100 characters (EventInvariants.cs:43).
      • -
      • Depends on - EventInvariants. Inherits AbstractValidator<T> directly.
      • -
      • Concept introduced - structurally identical to RoomAccessibilityInfoRules<T>: a nullable string? selector, MaximumLength only, no NotEmpty.
      • -
      • Walkthrough - one MaximumLength(EventInvariants.RoomFloorMaxLength) rule with error code Room.Floor.MaxLength (:54-56).
      • -
      • Why it's built this way - a floor is a label ("2", "Mezzanine"), not a required attribute of a room, so the fragment constrains only its length.
      • -
      • Where it's used - included by AddRoomCommandValidator (AddRoomCommandValidator.cs:14) and UpdateRoomCommandValidator (UpdateRoomCommandValidator.cs:14), both on p => p.Floor.
      • +
      • What it is: a rule fragment bounding a room's optional floor label to EventInvariants.RoomFloorMaxLength, 100 characters (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:46).
      • +
      • Depends on: EventInvariants. It inherits AbstractValidator<T> directly.
      • +
      • Concept: structurally identical to RoomAccessibilityInfoRules<T>: a nullable string? selector, MaximumLength only, no NotEmpty.
      • +
      • Walkthrough: one constructor (:54) and one MaximumLength(EventInvariants.RoomFloorMaxLength) rule with the error code Room.Floor.MaxLength (:55-56).
      • +
      • Why it's built this way: a floor is a label ("2", "Mezzanine"), not a required attribute of a room, so the fragment constrains only its length.
      • +
      • Where it's used: Included by AddRoomCommandValidator (AddRoomCommandValidator.cs:14) and UpdateRoomCommandValidator (UpdateRoomCommandValidator.cs:14), both on p => p.Floor.

      RoomLocationRules<T>

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:64 · Level 7 · class (sealed, generic)

        -
      • What it is - a rule fragment bounding a room's optional location text to EventInvariants.RoomLocationMaxLength, 255 characters (EventInvariants.cs:46).
      • -
      • Depends on - EventInvariants. Inherits AbstractValidator<T> directly.
      • -
      • Concept introduced - structurally identical to RoomFloorRules<T>: a nullable selector and a single MaximumLength rule. Reading the four optional room fragments together shows why the family exists at all: each is three lines, and the only things that vary are the constant, the message noun, and the error code.
      • -
      • Walkthrough - one MaximumLength(EventInvariants.RoomLocationMaxLength) rule with error code Room.Location.MaxLength (:67-69).
      • -
      • Where it's used - included by AddRoomCommandValidator (AddRoomCommandValidator.cs:15) and UpdateRoomCommandValidator (UpdateRoomCommandValidator.cs:15), both on p => p.Location.
      • +
      • What it is: a rule fragment bounding a room's optional location text to EventInvariants.RoomLocationMaxLength, 255 characters (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:49).
      • +
      • Depends on: EventInvariants. It inherits AbstractValidator<T> directly.
      • +
      • Concept: structurally identical to RoomFloorRules<T>: a nullable selector and a single MaximumLength rule. Reading the three optional room fragments together (:51, :64, :77) shows why the family exists at all: each is three lines, and the only things that vary are the invariant constant, the message noun, and the error code. [Rubric §16, Maintainability] assesses whether a constraint lives in exactly one place; splitting per field is what lets a room validator compose the required-plus-optional mix it actually needs.
      • +
      • Walkthrough: one constructor (:67) and one MaximumLength(EventInvariants.RoomLocationMaxLength) rule with the error code Room.Location.MaxLength (:68-69).
      • +
      • Where it's used: Included by AddRoomCommandValidator (AddRoomCommandValidator.cs:15) and UpdateRoomCommandValidator (UpdateRoomCommandValidator.cs:15), both on p => p.Location.

      RoomNameRules<T>

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:12 · Level 7 · class (sealed, generic)

        -
      • What it is - a rule fragment for a room's name: non-empty and bounded by EventInvariants.RoomNameMaxLength, 255 characters (EventInvariants.cs:40). It is the one required field among the room fragments.
      • -
      • Depends on - EventInvariants. Inherits AbstractValidator<T> directly.
      • -
      • Concept introduced - the same fragment pattern as EventNameRules<T>, but written out on AbstractValidator<T> rather than derived from RequiredStringRules<T>, even though the shape matches. Writing it locally is what buys the two WithErrorCode values the shared base does not set. [Rubric §9 - API & Contract Design] assesses the stability of the error contract clients consume: Room.Name.Required and Room.Name.MaxLength are stable machine-readable codes alongside the human message.
      • -
      • Walkthrough - the constructor chains NotEmpty with message "You must enter a Room Name" and code Room.Name.Required (:17), then MaximumLength(EventInvariants.RoomNameMaxLength) with code Room.Name.MaxLength (:18), on the single Expression<Func<T, string>> selector (:15).
      • -
      • Why it's built this way - the required name distinguishes this fragment from the three optional room fields; keeping each as its own fragment lets a command validator compose exactly the required-plus-optional mix it needs, which is what the two room validators do line by line.
      • -
      • Where it's used - included by AddRoomCommandValidator (AddRoomCommandValidator.cs:11) and UpdateRoomCommandValidator (UpdateRoomCommandValidator.cs:11), both on p => p.Name.
      • -
      • Caveats / not-in-source - the length rule duplicates a domain-side check: EventInvariants.EnsureRoomNameIsValid enforces the same constant with error code Room.Name.TooLong (EventInvariants.cs:137-140). The application fragment gives a fast, field-attributed failure; the domain invariant is the backstop that also fires for callers that bypass the validator.
      • +
      • What it is: a rule fragment for a room's name: non-empty and bounded by EventInvariants.RoomNameMaxLength, 255 characters (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:43). It is the one required field among the room fragments.
      • +
      • Depends on: EventInvariants. It inherits AbstractValidator<T> directly.
      • +
      • Concept: the same shape as EventNameRules<T>, but written out on AbstractValidator<T> rather than derived from RequiredStringRules<T>, even though the base would fit. Writing the chain locally is precisely what buys the two WithErrorCode values the shared base does not set, so the two sibling "name" fragments in this module are a clean illustration of the trade-off between inheriting a framework fragment and writing three lines by hand. [Rubric §9, API & Contract Design] assesses the stability of the error contract clients consume: Room.Name.Required and Room.Name.MaxLength are stable machine-readable codes alongside the human message, whereas the event name yields a message only.
      • +
      • Walkthrough: the constructor takes a single Expression<Func<T, string>> selector (:15) and chains NotEmpty with the message "You must enter a Room Name" and the code Room.Name.Required (:16-17), then MaximumLength(EventInvariants.RoomNameMaxLength) with the code Room.Name.MaxLength (:18).
      • +
      • Why it's built this way: the required name is what distinguishes this fragment from the three optional room fields; keeping each field as its own fragment lets a command validator compose exactly the mix it needs, which is what the two room validators do line by line.
      • +
      • Where it's used: Included by AddRoomCommandValidator (AddRoomCommandValidator.cs:11) and UpdateRoomCommandValidator (UpdateRoomCommandValidator.cs:11), both on p => p.Name.
      • +
      • Caveats / not-in-source: the length rule duplicates a domain-side check: EventInvariants.EnsureRoomNameIsValid enforces the same constant with the codes Room.Name.Empty and Room.Name.TooLong (EventInvariants.cs:140-143). The application fragment gives a fast, field-attributed failure; the domain invariant is the backstop that also fires for callers that bypass the validator.

      SpeakerLocalityHelper

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/SpeakerLocalityHelper.cs:21 · Level 8 · class (internal static)

        -
      • What it is - the small pure helper that answers "where is this speaker traveling from?" by reading the speaker's category assignments. It finds the locality categories among all loaded categories, flattens their items into one lookup, and resolves a speaker to a single tier name such as "Atlanta and Suburbs" or "Not North America" (SpeakerLocalityHelper.cs:17-19).
      • -
      • Depends on - Category and CategoryItem (via Category.CategoryItems), Speaker and its SpeakerCategoryItem collection, LocalityLookupEntry as its dictionary value type, and the CategoryItemIdentifierType / ConferenceCategoryIdentifierType aliases. Nothing external beyond the BCL; no repository, no IUnitOfWork, no logging.
      • -
      • Concept introduced - the pure in-memory helper beside a handler. Both decision-support handlers already load Category and Speaker graphs for other reasons, so the locality question is answered from data already in memory rather than by another query. Making the helper static with no injected dependencies means it is exercised directly in unit tests with hand-built aggregates and no test double at all. [Rubric §14 - Testability] assesses whether logic can be tested without infrastructure: SpeakerLocalityHelperTests constructs speakers and categories in-process and asserts against the four public methods (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/SpeakerLocalityHelperTests.cs:60, :154-157, :174, :186). [Rubric §12 - Performance & Scalability] assesses repeated work: the expensive step (scanning every category's items) happens once per request in BuildLocalityLookup, and the per-speaker step is a dictionary probe. [Rubric §8 - Data Architecture] assesses how the model's shape drives the code: because Category has no event scoping, correctness here depends on merging categories across imports rather than picking one.
      • -
      • Walkthrough - members in teaching order:
          -
        • KnownLocalityCategoryId (:27), a private const holding 121854, the Sessionize identifier of the original "Where are you traveling from" category. It is the fallback used only when no category title matches the heuristic (:23-26).
        • -
        • FindLocalityCategories(IEnumerable<Category> categories) (:88) walks every category, skipping soft-deleted ones (:95-96), and collects those whose Title contains "traveling" or "Where are you based", case-insensitively (:98-101). A category that matches neither but carries the known id is remembered as fallback (:103-104). If nothing matched by title, it returns the fallback as a single-element list or an empty list (:109-110); otherwise it sorts the matches by ascending id and returns them (:112-113).
        • +
        • What it is: the small pure helper that answers "where is this speaker traveling from?" by reading the speaker's category assignments. It finds the locality categories among all loaded categories, flattens their items into one lookup, and resolves a speaker to a single tier name such as "Atlanta and Suburbs" or "Not North America" (SpeakerLocalityHelper.cs:17-19).
        • +
        • Depends on: Category and CategoryItem (through Category.CategoryItems), Speaker and its SpeakerCategoryItem collection, LocalityLookupEntry as its dictionary value type, and the CategoryItemIdentifierType / ConferenceCategoryIdentifierType aliases (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:6-7). Nothing external beyond the BCL: no repository, no IUnitOfWork, no logger.
        • +
        • Concept, the pure in-memory helper beside a handler. Both decision-support handlers already load Category and Speaker graphs for other reasons, so the locality question is answered from data already in memory rather than by another query. Making the helper static with no injected dependencies means it is exercised directly in unit tests with hand-built aggregates and no test double at all. [Rubric §14, Testability] assesses whether logic can be tested without infrastructure: SpeakerLocalityHelperTests constructs speakers and categories in-process and asserts against all four public methods (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/SpeakerLocalityHelperTests.cs:60, :154-157, :174, :186). [Rubric §12, Performance & Scalability] assesses repeated work: the expensive step (scanning every category's items) happens once per request in BuildLocalityLookup, and the per-speaker step is a dictionary probe. [Rubric §8, Data Architecture] assesses how the model's shape drives the code: because Category has no event scoping, correctness here depends on merging categories across imports rather than picking one.
        • +
        • Walkthrough, members in teaching order:
            +
          • KnownLocalityCategoryId (:27), a private const holding 121854, the Sessionize identifier of the original "Where are you traveling from" category. It is the fallback used only when no category title matches the heuristic (:23-26).
          • +
          • FindLocalityCategories(IEnumerable<Category> categories) (:88) walks every category, skipping soft-deleted ones (:95-96), and collects those whose Title contains "traveling" or "Where are you based", case-insensitively (:98-102). A category that matches neither but carries the known id is remembered as fallback (:103-106). If nothing matched by title it returns the fallback as a single-element list, or an empty list (:109-110); otherwise it sorts the matches by ascending id and returns them (:112-113).
          • BuildLocalityLookup(IEnumerable<Category> localityCategories) (:123) iterates those categories in ascending id order (:128) and writes lookup[item.Id] = new LocalityLookupEntry(item.Name, category.Id) for every non-deleted item (:130-135). Ascending order is what makes the last write win, so a colliding item id resolves to the newest import (:116-119).
          • -
          • GetLocalityTier(Speaker speaker, IReadOnlyDictionary<...> localityCategoryItems) (:36) scans the speaker's SpeakerCategoryItems, skipping soft-deleted assignments (:45-46) and assignments whose item is not in the lookup (:48-49), and keeps the entry with the highest CategoryId seen (:53-57). It returns null when the speaker has no locality assignment at all (:40, :60).
          • +
          • GetLocalityTier(Speaker speaker, IReadOnlyDictionary<CategoryItemIdentifierType, LocalityLookupEntry> localityCategoryItems) (:36-38) scans the speaker's SpeakerCategoryItems, skipping soft-deleted assignments (:45-46) and assignments whose item is not in the lookup (:48-49), and keeps the entry with the highest CategoryId seen (:53-57). It returns null when the speaker has no locality assignment at all (:40, :60).
          • IsLocalSpeaker(string? localityTier) (:69) returns false for null (:71-72) and otherwise reports whether the tier name contains "Atlanta", "Georgia", or "Surrounding", case-insensitively (:74-76).
        • -
        • Why it's built this way - the regression this shape exists to fix is spelled out in the doc comment at :82-84 and in the test names: returning only the first (oldest) matching category left every speaker new to the current event resolving to no tier, because each yearly Sessionize refresh creates a fresh locality category with fresh item ids (SpeakerLocalityHelperTests.cs:126-141). Merging every locality category into one lookup, and breaking ties by highest owning category id, makes both a newcomer and a returning speaker resolve to their current-year answer (:144-158). The title heuristic with an id fallback keeps the code working across two different question wordings without a configuration entry.
        • -
        • Where it's used - GetSessionSelectionDashboardHandler calls FindLocalityCategories and BuildLocalityLookup once (GetSessionSelectionDashboardHandler.cs:75-76) and GetLocalityTier in three projection passes (:224, :286, :405, the last two defaulting a null tier to "Unknown"); GetSpeakerSessionOverlapHandler does the same for its narrower view (GetSpeakerSessionOverlapHandler.cs:52-53, :119).
        • -
        • Caveats / not-in-source - two things are worth flagging. First, IsLocalSpeaker has no caller under MMCA.ADC/Source: the only references are its declaration (:69) and the theory in SpeakerLocalityHelperTests.cs:161-177, so the "local speaker" notion is defined and tested but not yet consumed by a handler. Second, the tier match is substring-based, so any future tier name containing "Georgia" or "Surrounding" would be classified local without a code change; nothing in source constrains the set of tier names that Sessionize can produce.
        • +
        • Why it's built this way: the regression this shape exists to fix is spelled out in the doc comment at :82-84 and in the test names: returning only the first (oldest) matching category left every speaker new to the current event resolving to no tier, because each yearly Sessionize refresh creates a fresh locality category with fresh item ids (SpeakerLocalityHelperTests.cs:126-141). Merging every locality category into one lookup, and breaking ties by highest owning category id, makes both a newcomer and a returning speaker resolve to their current-year answer (:144-158). The title heuristic with an id fallback keeps the code working across two different question wordings without a configuration entry.
        • +
        • Where it's used: GetSessionSelectionDashboardHandler calls FindLocalityCategories and BuildLocalityLookup once (GetSessionSelectionDashboardHandler.cs:75-76) and GetLocalityTier in three projection passes (:224, :286, :405, the last two defaulting a null tier to "Unknown"); GetSpeakerSessionOverlapHandler does the same for its narrower view (GetSpeakerSessionOverlapHandler.cs:52-53, :119).
        • +
        • Caveats / not-in-source: two things are worth flagging. First, IsLocalSpeaker has no caller anywhere under MMCA.ADC/Source: the only references are its declaration (:69) and the theory in SpeakerLocalityHelperTests.cs:161-177, so the "local speaker" notion is defined and tested but not yet consumed by a handler. Second, the tier match is substring-based, so any future tier name containing "Georgia" or "Surrounding" would be classified local without a code change; nothing in source constrains the set of tier names Sessionize can produce.
        -

        GetCategoryDistributionHandler

        +

        GetCategoryDistributionQuery

        -

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetCategoryDistribution · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetCategoryDistribution/GetCategoryDistributionHandler.cs:14 · Level 9 · class (sealed)

        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetCategoryDistribution · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetCategoryDistribution/GetCategoryDistributionQuery.cs:5 · Level 0 · record (sealed)

          -
        • What it is - the query handler that computes, per category item, how many of an event's sessions were submitted, accepted, put in the accept queue, or left pending. It backs the organizer's category-distribution view during session selection.
        • -
        • Depends on - IQueryHandler<in TQuery, TResult> (implemented) and IUnitOfWork (the only constructor dependency, :14-15); Session and Category as repository entities plus CategoryItem through their collections; SessionStatuses for the status constants; the nested StatusBucket enum; and the output contracts CategoryDistributionDTO, CategoryGroupDistribution, and CategoryItemDistribution from MMCA.ADC.Conference.Shared.Sessions.DecisionSupport (:3).
        • -
        • Concept introduced - the in-memory analytics read handler. It loads two aggregate sets untracked and then does all filtering, bucketing, and grouping in C#, rather than pushing aggregation down into SQL. [Rubric §6 - CQRS & Event-Driven] assesses the read path: this is a pure query returning Result<CategoryDistributionDTO> and mutating nothing, so it is wrapped only by the query-side decorators the framework registers (Caching, Logging, FeatureGate, plus Profiling when enabled), never by the command-side Validating or Transactional ones (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:94-103, :222). [Rubric §12 - Performance & Scalability] assesses read efficiency: both loads pass asTracking: false (:27, :32) so EF skips change tracking, and the tally is a single pass into a dictionary rather than a nested scan (:50-61). [Rubric §5 - Vertical Slice] assesses feature cohesion: query, handler, and private bucketing live in one GetCategoryDistribution folder, so the whole feature is readable in one place.
        • -
        • Walkthrough - HandleAsync (:17-39) resolves a Session repository and a Category repository from the unit of work (:21-22). It loads the event's sessions with SessionCategoryItems included, filtered to s.EventId == query.EventId && !s.IsServiceSession (:24-28), then loads every category with its CategoryItems (:30-33), with no event filter because categories are global. CountSessionsPerCategoryItem (:41-64) drops declined sessions via IsDeclined (:45, :114-115), flattens each remaining session into (CategoryItemId, StatusBucket) pairs while skipping soft-deleted links (:46-48), and folds those pairs into a (Total, Accepted, AcceptQueue, Pending) tuple per category item (:50-61). ClassifyStatus (:101-112) treats a null status or SessionStatuses.Accepted as Accepted, SessionStatuses.AcceptQueue as AcceptQueue, and anything else as Pending, all with OrdinalIgnoreCase comparison. BuildCategoryGroups (:66-92) keeps only non-deleted categories that have at least one counted item (:70), orders categories by Sort (:71) and items by Sort (:78), and projects each item into a CategoryItemDistribution with its four counts, using TryGetValue so an uncounted item yields zeros (:81-90). The handler returns Result.Success(new CategoryDistributionDTO { Categories = categoryGroups }) (:38): it has no failure path.
        • -
        • Why it's built this way - aggregating in memory keeps the handler engine-agnostic (the same code runs against whatever store backs IUnitOfWork, per the database-per-service model of ADR-006) and states the domain rules (service sessions excluded, declined excluded, soft-deletes excluded at both the session-link and category-item levels) as readable filters instead of burying them in SQL. The trade-off is that a full event's sessions and the full category set are materialized; that is bounded by one conference's proposal volume.
        • -
        • Where it's used - injected into SessionSelectionController as IQueryHandler<GetCategoryDistributionQuery, Result<CategoryDistributionDTO>> (SessionSelectionController.cs:31) and invoked from GET SessionSelection/categories/{eventId} (:53-64), an organizer-only endpoint (:28) whose response is output-cached under the ConferenceCache policy (:54).
        • -
        • Caveats / not-in-source - a null Session.Status is counted as Accepted (:103-107), so a session whose status was never set inflates the accepted column rather than the pending one. The branch is explicit in code, but the reason for choosing Accepted over Pending as the null default is not stated there.
        • +
        • What it is - the CQRS query contract that asks for the distribution of an event's sessions across its category items. A one-line record carrying nothing but the event to analyze.
        • +
        • Depends on - the EventIdentifierType alias, an int in this module (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8). No other first-party types, nothing external.
        • +
        • Concept introduced - this is a plain read-side CQRS request; the query/handler split is taught by IQueryHandler<in TQuery, TResult>, so it is cross-referenced rather than re-taught here. Note that the record implements no marker interface: the pairing to a handler is purely the generic argument on IQueryHandler<GetCategoryDistributionQuery, Result<CategoryDistributionDTO>> (GetCategoryDistributionHandler.cs:15). [Rubric §6 - CQRS & Event-Driven] assesses whether reads and writes travel separate paths with explicit contracts: this record is a read intent with no side effects, resolved by GetCategoryDistributionHandler.
        • +
        • Walkthrough - one positional parameter, EventId of type EventIdentifierType (:5), documented by the two-line summary above it (:3-4). No body, no defaults.
        • +
        • Why it's built this way - keeping the query as a standalone record means it can be dispatched on its own (an organizer opening the category-distribution view) or computed alongside the other decision-support dimensions by GetSessionSelectionDashboardHandler without one endpoint over-fetching for another.
        • +
        • Where it's used - constructed by SessionSelectionController on GET SessionSelection/categories/{eventId} (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:53-65), which resolves the handler through the injected IQueryHandler<GetCategoryDistributionQuery, Result<CategoryDistributionDTO>> (:32).

        GetContentSimilarityQuery

        -

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetContentSimilarity · MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetContentSimilarity/GetContentSimilarityQuery.cs:6 · Level 0 · record

        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetContentSimilarity · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetContentSimilarity/GetContentSimilarityQuery.cs:6 · Level 0 · record (sealed)

          -
        • What it is: the read request that asks, for one event, which pairs of submitted sessions cover similar content, so an organizer can spot proposals that would compete for the same audience. A two-parameter sealed record (GetContentSimilarityQuery.cs:6).
        • -
        • Depends on: the EventIdentifierType alias (see identifier aliases) and a BCL double. No first-party types.
        • -
        • Concept introduced: the request record as a CQRS message. Every decision-support use case in this folder is a positional sealed record naming exactly the inputs its handler needs and nothing else, matched to its handler by generic argument and dispatched through the CQRS decorator pipeline. The folder layout reinforces it: query, handler, and (here) the calculator that does the math all live in one GetContentSimilarity/ directory, so a use case is a folder, not a scattering of files across a service, a DTO namespace, and a helper class. [Rubric §5, Vertical Slice] assesses exactly that co-location, and [Rubric §6, CQRS and Event-Driven] assesses whether read intent is modeled as a named message rather than a method-parameter bag.
        • -
        • Walkthrough: two positional parameters (:6). EventId is the event to analyze (:4), and MinimumSimilarity is a double defaulting to 0.3 (:5), the floor a pair's score must clear to appear in the result. It is the only parameter default among the decision-support request records.
        • -
        • Why it's built this way: exposing the threshold as a defaulted parameter lets a caller loosen or tighten the floor per request while the common case (an organizer opening the view) needs no argument at all. Keep in mind that the 0.3 floor lives here, but the weights that produce the number it is compared against live in SessionSimilarityCalculator; the two have to be read together to reason about what actually surfaces.
        • -
        • Where it's used: injected as IQueryHandler<GetContentSimilarityQuery, Result<ContentSimilarityDTO>> into SessionSelectionController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:33) and constructed by its GET SessionSelection/content-similarity/{eventId} action (:81-93, construction at :89); handled by GetContentSimilarityHandler, returning a ContentSimilarityDTO.
        • -
        • Caveats / not-in-source: the 0.3 default is written twice, once on this record (:6) and once as the action's own optional parameter default (SessionSelectionController.cs:85). Nothing links them, so changing one alone would silently leave the other in force for callers that omit the argument.
        • +
        • What it is - the read request behind "which pairs of submitted sessions look like the same talk". It carries the event to analyze plus the score floor below which a pair is not worth showing.
        • +
        • Depends on - the EventIdentifierType alias (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8) and a BCL double. No first-party types.
        • +
        • Concept introduced - the query that carries a tuning knob. The three sibling decision-support queries are pure identity ("analyze this event"); this one also carries a policy value, MinimumSimilarity, with an in-contract default of 0.3 (:6). [Rubric §9 - API & Contract Design] assesses whether a contract's optional inputs are explicit and defaulted in one place: here the default is stated twice, once on the record (:6) and once on the controller action parameter (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:86), so an HTTP caller that omits minimumSimilarity never exercises the record's default at all. [Rubric §6 - CQRS & Event-Driven] applies as for the sibling queries: a named read message resolved by exactly one IQueryHandler<in TQuery, TResult>.
        • +
        • Walkthrough - two positional members (:6): EventId, and MinimumSimilarity defaulted to 0.3. The doc comment documents the intended range as 0.0 to 1.0 (:5). The value is used exactly once, as an inclusive lower bound in GetContentSimilarityHandler (GetContentSimilarityHandler.cs:74, score >= query.MinimumSimilarity).
        • +
        • Why it's built this way - similarity is a judgment call, not a fact: an organizer sweeping for near-duplicate submissions wants a different floor than one looking only at blatant overlaps. Putting the knob on the query rather than in configuration lets the caller choose per request without a redeploy.
        • +
        • Where it's used - constructed by SessionSelectionController on GET SessionSelection/content-similarity/{eventId} (SessionSelectionController.cs:81-94), binding minimumSimilarity from the query string (:86).
        • +
        • Caveats / not-in-source - the 0.0 to 1.0 range is documentation only. There is no validator for this query (the GetContentSimilarity folder holds only the query, the handler, and SessionSimilarityCalculator), and the query-side decorator chain registers no validating decorator: validation is command-side only (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:117 for commands versus :124-128 for queries). A caller passing 5.0 therefore gets an empty pair list, and a negative floor returns every pair up to the handler's cap, both silently.

        GetSessionSelectionDashboardQuery

        -

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetSessionSelectionDashboard · MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboard/GetSessionSelectionDashboardQuery.cs:5 · Level 0 · record

        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetSessionSelectionDashboard · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboard/GetSessionSelectionDashboardQuery.cs:5 · Level 0 · record (sealed)

          -
        • What it is: the read request for the composite session-selection dashboard: given one EventId, produce summary counts, category distribution, speaker overlap, speaker locality, and the AI-score table in a single result. A one-field sealed record (GetSessionSelectionDashboardQuery.cs:5).
        • -
        • Depends on: the EventIdentifierType alias. No externals beyond the BCL.
        • -
        • Concept introduced: none new; the same request-record shape taught under GetContentSimilarityQuery. [Rubric §6, CQRS and Event-Driven].
        • -
        • Walkthrough: a single EventId positional parameter (:5), documented as "the event to analyze" (:4); compiler-generated value equality and init immutability come free from record.
        • -
        • Why it's built this way: a composite query (one message, one round trip) is the decision-support answer to running four separate analytics queries against the same session set: see GetSessionSelectionDashboardHandler.
        • -
        • Where it's used: injected into SessionSelectionController (SessionSelectionController.cs:30) and constructed by its GET SessionSelection/dashboard/{eventId} action (:39-50, construction at :46); handled by GetSessionSelectionDashboardHandler, returning a SessionSelectionDashboardDTO.
        • +
        • What it is - the read request for the whole session-selection screen in one call: summary counts, category distribution, speaker overlap, speaker locality, and AI scores for one event.
        • +
        • Depends on - the EventIdentifierType alias (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8). Nothing else.
        • +
        • Concept introduced - the composite (screen-shaped) query. The other three decision-support queries each answer one question; this one is deliberately shaped like the UI page rather than like a single analytical question, and its handler computes all four dimensions from one set of loads (GetSessionSelectionDashboardHandler.cs:12-14). [Rubric §9 - API & Contract Design] assesses whether the contract fits its consumer: one round trip for a screen that would otherwise need four, at the cost of a response the narrower endpoints do not need. [Rubric §12 - Performance & Scalability] assesses request economy: the sessions, categories, and speakers are read once and reused across every computed block instead of four times.
        • +
        • Walkthrough - one positional parameter, EventId (:5), with the summary and parameter doc above it (:3-4). Identical in shape to GetCategoryDistributionQuery and GetSpeakerSessionOverlapQuery: the difference is entirely in the handler's breadth.
        • +
        • Why it's built this way - the Blazor page is the only consumer that needs all four dimensions, and it needs them consistent with each other. Answering them from one snapshot of loaded data means the counts on the page cannot disagree between panels.
        • +
        • Where it's used - constructed by SessionSelectionController on GET SessionSelection/dashboard/{eventId} (SessionSelectionController.cs:39-51). That endpoint is the one the UI actually calls: SessionSelectionService requests sessionselection/dashboard/{eventId} and deserializes SessionSelectionDashboardDTO (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/SessionSelectionService.cs:16-30), and calls none of the three narrow endpoints.

        GetSpeakerSessionOverlapQuery

        -

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetSpeakerSessionOverlap · MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSpeakerSessionOverlap/GetSpeakerSessionOverlapQuery.cs:5 · Level 0 · record

        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetSpeakerSessionOverlap · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSpeakerSessionOverlap/GetSpeakerSessionOverlapQuery.cs:5 · Level 0 · record (sealed)

          -
        • What it is: the read request that asks, for one event, which speakers submitted sessions and how many. A one-field sealed record carrying the EventId (GetSpeakerSessionOverlapQuery.cs:5).
        • -
        • Depends on: the EventIdentifierType alias. No externals.
        • -
        • Concept introduced: none new; the same request-record shape as GetContentSimilarityQuery. [Rubric §6, CQRS and Event-Driven].
        • -
        • Walkthrough: a single EventId positional parameter (:5).
        • -
        • Why it's built this way: speaker overlap is one focused slice of the dashboard, exposed on its own query so the UI can request just that view without paying for the composite load.
        • -
        • Where it's used: injected into SessionSelectionController (SessionSelectionController.cs:32) and constructed by its GET SessionSelection/speaker-overlap/{eventId} action (:67-78, construction at :74); handled by GetSpeakerSessionOverlapHandler, returning a SpeakerSessionOverlapDTO.
        • -
        • Caveats / not-in-source: the doc comment frames the query as "speakers with multiple submitted sessions" (:3) and the controller action repeats that (SessionSelectionController.cs:66), but the handler in fact returns every submitting speaker, sorted so multi-session ones surface first (GetSpeakerSessionOverlapHandler, doc comment :11-17). The comments are stale relative to the code.
        • +
        • What it is - the read request for the speaker-centric view of an event's submissions: who submitted what, with the multi-session speakers surfaced first.
        • +
        • Depends on - the EventIdentifierType alias (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8). Nothing else.
        • +
        • Concept introduced - none new; it is the same one-field read message as GetCategoryDistributionQuery, and the request-record concept is taught there. [Rubric §5 - Vertical Slice] assesses feature cohesion: this record sits in the same GetSpeakerSessionOverlap folder as its handler, so the whole capability is one directory.
        • +
        • Walkthrough - one positional parameter, EventId (:5); the summary above it states the intent as "find speakers with multiple submitted sessions" (:3). Note that the handler's own doc comment corrects that scope: it returns every speaker with at least one submitted session (GetSpeakerSessionOverlapHandler.cs:12-16).
        • +
        • Why it's built this way - speaker overlap is a distinct selection concern from topic balance (one speaker holding three accepted slots is a program problem even when the topic mix is fine), so it gets its own message and its own endpoint rather than being a filter over the category view.
        • +
        • Where it's used - constructed by SessionSelectionController on GET SessionSelection/speaker-overlap/{eventId} (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:67-79).

        SessionSimilarityCalculator

        -

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetContentSimilarity · MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetContentSimilarity/SessionSimilarityCalculator.cs:9 · Level 0 · class (internal static)

        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetContentSimilarity · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetContentSimilarity/SessionSimilarityCalculator.cs:9 · Level 0 · class (static, internal)

          -
        • What it is: the pure static engine behind content similarity. It scores how alike two sessions are by blending category-item overlap (weight 0.6) with keyword overlap drawn from titles and descriptions (weight 0.4), and it also supplies the intersection helper the handler uses to explain each match (SessionSimilarityCalculator.cs:9).
        • -
        • Depends on: System.Collections.Frozen.FrozenSet<string> (:1, :14) and HashSet<T> from the BCL, and the CategoryItemIdentifierType alias as a set element type on CalculateSimilarity (:98-99). No first-party types at all: this class never touches a repository, an entity, or a DTO.
        • -
        • Concept introduced: weighted Jaccard similarity with span-based tokenization. The Jaccard index is the size of a set intersection divided by the size of its union, a value in [0.0, 1.0]. Here two independent Jaccard scores are blended: one over the sessions' category-item ids, one over their keyword sets. Two sessions with identical category tags but no shared keywords score 0.6; identical keywords but no shared tags score 0.4. Two important properties fall out of the implementation rather than the formula. [Rubric §12, Performance and Scalability] assesses allocation and lookup discipline on compute paths, and this type is where the quadratic pair loop's per-comparison cost is decided: the stop-word list is a FrozenSet<string> (:14-34), which pays its build cost once at class initialization to buy the fastest possible read-only membership test, and TokenizeText walks a ReadOnlySpan<char> (:48-68) so it does not allocate a substring per candidate word. [Rubric §14, Testability] assesses whether logic can be exercised without infrastructure: because the class is static, side-effect free, and dependency free, its whole behavior is reachable from a plain unit test with no fixture (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/SessionSimilarityCalculatorTests.cs:6).
        • -
        • Walkthrough, members in teaching order:
            -
          1. CategoryWeight = 0.6 and KeywordWeight = 0.4 (:11-12), private consts. They sum to 1.0, which is what keeps the composite score inside [0.0, 1.0].
          2. -
          3. StopWords (:14-34), a FrozenSet<string> built with StringComparer.Ordinal. It holds two distinct groups, and the source separates them with comments: ordinary English function words (:16-29) and conference-generic words that would otherwise make every pair look alike, including "SESSION", "TALK", "PRESENTATION", "WORKSHOP", "DEEP", "DIVE", "OVERVIEW" (:30-33). That second group is the domain knowledge in this file: it is why "Deep Dive into X" and "Deep Dive into Y" do not register as similar.
          4. -
          5. TokenizeText(string? text) (:41): returns an empty set for null or whitespace input (:43-44), then scans the text as a span, tracking the start index of each run of letters or digits (:50-68). A run is emitted only when it is at least three characters long (:62), and AddTokenIfNotStopWord (:123-130) uppercases it with ToUpperInvariant and drops it if it is a stop word. Uppercase rather than lowercase is deliberate and the doc comment says why (:37): analyzer rule CA1308 prefers ToUpperInvariant for normalization, because lowercasing is not round-trip safe in every culture.
          6. -
          7. CalculateJaccardIndex<T>(HashSet<T>, HashSet<T>) (:80): returns 0.0 when both sets are empty (:82-83, so two untagged sessions are not treated as identical), then iterates the smaller set against the larger (:85-88) so the intersection scan is bounded by the smaller count, and divides by setA.Count + setB.Count - intersectionCount (:90-91), the inclusion-exclusion form of the union size.
          8. -
          9. CalculateSimilarity(...) (:97): the composite, CategoryWeight * categoryScore + KeywordWeight * keywordScore (:103-105).
          10. -
          11. GetIntersection<T>(...) (:115): the same smaller-against-larger trick (:117-120), returning the shared elements as a List<T> so the handler can name the shared tags and keywords on each result pair.
          12. -
          +
        • What it is - the pure-function core of the content-similarity feature: it turns two sessions into a single number between 0.0 and 1.0 by blending category-item overlap (weight 0.6) with keyword overlap from title and description (weight 0.4).
        • +
        • Depends on - System.Collections.Frozen.FrozenSet<string> and System.Linq from the BCL, plus the CategoryItemIdentifierType alias in one signature (:98-99). No first-party types at all: it never touches an entity, a repository, or a DTO.
        • +
        • Concept introduced - the Jaccard index, and why the scoring logic is a static class. The Jaccard index of two sets is the size of their intersection divided by the size of their union, so identical sets score 1.0 and disjoint sets score 0.0. This file applies it twice, once to the two sessions' category-item id sets and once to their keyword sets, then blends the two with fixed weights (:103-105). Two sessions tagged identically but sharing no vocabulary score 0.6; two sessions sharing vocabulary but no tags score 0.4. [Rubric §14 - Testability] assesses whether logic can be exercised without infrastructure: because every method is static and takes plain sets, SessionSimilarityCalculatorTests drives all four of them with literal inputs and no test double at all (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/SessionSimilarityCalculatorTests.cs:6). [Rubric §12 - Performance & Scalability] assesses hot-path cost: this runs once per session pair, so the code avoids per-character allocation and uses an O(1) frozen set for stop-word lookups. [Rubric §1 - SOLID] assesses separation of responsibility: the scoring rule lives apart from the handler that orchestrates loading and DTO building, so a weight change touches one file.
        • +
        • Walkthrough
            +
          • CategoryWeight = 0.6 and KeywordWeight = 0.4 (:11-12), the only two tuning constants, private const and therefore not configurable at runtime.
          • +
          • StopWords, a FrozenSet<string> built once at type initialization with StringComparer.Ordinal (:14-34). It holds ordinary English function words and, deliberately, conference-generic vocabulary such as "SESSION", "TALK", "PRESENTATION", "DEEP", "DIVE", and "WORKSHOP" (:31-33), which would otherwise make every abstract look like every other abstract.
          • +
          • TokenizeText(string? text) (:41-71) returns an empty set for null or whitespace (:43-44), then scans the text as a ReadOnlySpan<char> with a manual index loop rather than string.Split (:48-68). A run of letters or digits ends at any other character or at end of input (:52); runs shorter than three characters are dropped (:62); surviving runs go to AddTokenIfNotStopWord (:123-130), which uppercases with ToUpperInvariant (the summary at :37 notes this is upper rather than lower to satisfy analyzer rule CA1308) and adds the token only if it is not a stop word.
          • +
          • CalculateJaccardIndex<T>(HashSet<T>, HashSet<T>) (:80-92) returns 0.0 when both sets are empty (:82-83), which is the guard against dividing by a zero union. It iterates the smaller set against the larger one's Contains (:85-88), then divides the intersection count by setA.Count + setB.Count - intersectionCount (:90-91).
          • +
          • CalculateSimilarity(...) (:97-106) is the blend: CategoryWeight * categoryJaccard + KeywordWeight * keywordJaccard.
          • +
          • GetIntersection<T>(...) (:115-121) returns the shared elements as a List<T>, again scanning the smaller set, and exists so the handler can show a reader why a pair scored what it scored.
          • +
        • -
        • Why it's built this way: a Jaccard index on category items alone would flag unrelated sessions that merely share a broad track, so the keyword signal raises the bar; weighting categories higher reflects that curated tags are a stronger signal than free-text overlap. Keeping the math in a separate dependency-free type (rather than as private methods on the handler) is what makes the scoring rules directly testable and lets the handler read as pure orchestration.
        • -
        • Where it's used: GetContentSimilarityHandler only. It calls TokenizeText once per session while pre-computing (GetContentSimilarityHandler.cs:58), CalculateSimilarity once per pair inside the double loop (:68-72), and GetIntersection twice per surviving pair when building the result (:94-95).
        • -
        • Caveats / not-in-source: the stop-word list, the two weights, and the three-character minimum are all compile-time constants with no configuration hook, so tuning the similarity behavior for a different kind of event means editing and redeploying this file.
        • +
        • Why it's built this way - a single signal is too blunt for program selection. Category overlap alone flags every pair inside a broad track; keyword overlap alone flags any two talks that both say "Kubernetes". Weighting categories higher than keywords encodes that a shared explicit tag is stronger evidence than shared prose. Keeping all of it internal static with no dependencies means the rule is auditable and unit-testable in isolation, which is what the test class does.
        • +
        • Where it's used - only by GetContentSimilarityHandler: TokenizeText while pre-computing per-session keyword sets (GetContentSimilarityHandler.cs:58), CalculateSimilarity inside the pairwise loop (:68-72), and GetIntersection twice when building each result row (:94-95).
        • +
        • Caveats / not-in-source - two sessions with no category items at all score 0.0 on the category component, not 1.0, because the both-empty case returns zero by design (:82-83): "neither is tagged" is treated as no evidence rather than as agreement. Stop words are English only, and the token filter keeps digits, so a version number such as "2026" counts as a keyword. The weights and the three-character minimum are constants with no configuration path.
        • +
        +

        StatusBucket

        +
        +

        MMCA.ADC.Conference.Application · ...DecisionSupport.GetCategoryDistribution and ...DecisionSupport.GetSessionSelectionDashboard · see table · Level 0 · enum (private, nested, two declarations)

        +
        +
          +
        • What it is - two independent private enums, one nested in each of the two handlers that tally sessions by status, that collapse a session's free-text status string onto the three columns those tallies report.
        • +
        +
        + + + + + + + + + + + + + + + + + +
        TypeFile:LineNotes (what differs)
        StatusBucket (GetCategoryDistribution)MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetCategoryDistribution/GetCategoryDistributionHandler.cs:94Members Accepted, AcceptQueue, Pending (:94-99), classified by that handler's own ClassifyStatus (:101-112).
        StatusBucket (GetSessionSelectionDashboard)MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboard/GetSessionSelectionDashboardHandler.cs:314The same three members (:314-319) and a matching ClassifyStatus (:321-332). Nothing in source keeps the two copies in step.
        +
          +
        • Depends on - nothing structurally. Both are produced from the SessionStatuses string constants by their handler's ClassifyStatus.
        • +
        • Concept introduced - a handler-local aggregation vocabulary. Session.Status is free text imported from Sessionize, and SessionStatuses names six recognized values: Accepted, Waitlisted, AcceptQueue, Nominated, DeclineQueue, Declined (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionStatuses.cs:17-32). The distribution views do not want six columns, so each handler declares a private three-member enum and folds everything that is neither accepted nor accept-queue into Pending. [Rubric §16 - Maintainability] assesses local reasoning: the bucket type is an implementation detail no caller can see, so either handler can change its bucketing without touching the other. [Rubric §15 - Best Practices & Code Quality] assesses expressiveness: three named members read better at the tally site than three ad-hoc string comparisons.
        • +
        • Walkthrough - three members in each declaration: Accepted, AcceptQueue, Pending. There is deliberately no Declined member, because declined sessions are removed before any bucketing happens: IsDeclined filters them out in GetCategoryDistributionHandler (:45, :114-115) and in the dashboard handler's CountCategoryItems (GetSessionSelectionDashboardHandler.cs:137, :334-335). ClassifyStatus in both handlers maps a null status or SessionStatuses.Accepted to Accepted, SessionStatuses.AcceptQueue to AcceptQueue, and everything else to Pending, comparing with StringComparison.OrdinalIgnoreCase.
        • +
        • Why it's built this way - declined proposals do not compete for a slot, so they are dropped before the enum stage and the three live buckets stay meaningful. Keeping the enum private to each handler avoids a shared type that would couple two otherwise independent use cases.
        • +
        • Where it's used - inside its own handler only: GetCategoryDistributionHandler (:58-60, :101-112) and GetSessionSelectionDashboardHandler (:150-152, :321-332). Callers receive DTO counts, never a bucket value.
        • +
        • Caveats / not-in-source - a null Session.Status counts as Accepted in both copies (GetCategoryDistributionHandler.cs:103-107, GetSessionSelectionDashboardHandler.cs:323-327). That is consistent with the domain's public-visibility allow-list, where an unset status is eligible because organizer-created sessions never carry one (SessionStatuses.cs:47-51), but the code does not restate the reason at the bucketing site. Waitlisted, Nominated, and DeclineQueue all land in Pending with no way to tell them apart in the output.
        -

        StatusBucket

        +

        GetCategoryDistributionHandler

        -

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetSessionSelectionDashboard · MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboard/GetSessionSelectionDashboardHandler.cs:314 · Level 0 · enum (private)

        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetCategoryDistribution · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetCategoryDistribution/GetCategoryDistributionHandler.cs:14 · Level 9 · class (sealed)

          -
        • What it is: a private three-member enum (Accepted, AcceptQueue, Pending) that collapses a session's free-text status string into the buckets the dashboard's category-distribution tally counts (GetSessionSelectionDashboardHandler.cs:314-319).
        • -
        • Depends on: nothing at the type level; conceptually on SessionStatuses, the string constants the classifier compares against (:324, :329).
        • -
        • Concept introduced: a handler-private classification vocabulary. Session stores its status as a nullable string (it arrives that way from the Sessionize import), and three different places in this handler need to answer "which pile does this session go in". Naming the three piles as an enum turns repeated string.Equals(..., StringComparison.OrdinalIgnoreCase) comparisons into one classifier plus a switch on a closed set. The enum is private, so it is an implementation detail: callers only ever see the aggregated counts on the DTO. Note there is deliberately no Declined member, because declined sessions are filtered out by IsDeclined (:334-335) before bucketing ever runs (:137), so the enum spans only the statuses that count toward a category's totals. [Rubric §16, Maintainability] assesses local reasoning: because the type cannot escape the file, this handler's bucketing can change without any coordination with a sibling use case.
        • -
        • Walkthrough: three members (:316-318). ClassifyStatus(Session) (:321-332) is the only producer: a null status or SessionStatuses.Accepted maps to Accepted (:323-326), SessionStatuses.AcceptQueue maps to AcceptQueue, and everything else falls through to Pending (:329-331). The null-means-accepted rule is worth internalizing; it repeats throughout this handler (:81, :217-218, :293-294). CountCategoryItems (:133-156) is the only consumer: for each non-declined session it pairs every live SessionCategoryItem id with the session's bucket (:136-140) and folds the pairs into a (Total, Accepted, AcceptQueue, Pending) tuple per category item (:143-153).
        • -
        • Why it's built this way: keeping the bucket private to this handler means its bucketing can diverge from another dashboard's without coupling the two use cases, which is exactly what happened: see the caveat.
        • -
        • Where it's used: inside GetSessionSelectionDashboardHandler only.
        • -
        • Caveats / not-in-source: a same-named private sibling enum lives in GetCategoryDistributionHandler (MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetCategoryDistribution/GetCategoryDistributionHandler.cs:94, with its own ClassifyStatus at :101 and IsDeclined at :114). The duplication is deliberate: the two handlers share the name and the semantics but no type, so neither can break the other. The cost is that a change to the bucketing rule has to be made twice, and nothing in the source flags the pair.
        • +
        • What it is - the query handler that computes, per category item, how many of an event's sessions were submitted, accepted, put in the accept queue, or left pending. It backs the organizer's category-distribution view during session selection.
        • +
        • Depends on - IQueryHandler<in TQuery, TResult> (implemented) and IUnitOfWork (the only constructor dependency, :14-15); Session and Category as repository entities plus CategoryItem through their collections; SessionStatuses for the status constants; the nested StatusBucket enum; and the output contracts CategoryDistributionDTO, CategoryGroupDistribution, and CategoryItemDistribution from MMCA.ADC.Conference.Shared.Sessions.DecisionSupport (:3).
        • +
        • Concept introduced - the in-memory analytics read handler. It loads two aggregate sets untracked and then does all filtering, bucketing, and grouping in C#, rather than pushing aggregation down into SQL. [Rubric §6 - CQRS & Event-Driven] assesses the read path: this is a pure query returning Result<CategoryDistributionDTO> and mutating nothing, so it is wrapped only by the query-side decorators the framework registers (Timeout, Caching, Logging, Authorization, FeatureGate, plus Profiling when enabled), never by the command-side Validating or Transactional ones (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:124-128, :300). [Rubric §12 - Performance & Scalability] assesses read efficiency: both loads pass asTracking: false (:27, :32) so EF skips change tracking, and the tally is a single pass into a dictionary rather than a nested scan (:50-61). [Rubric §5 - Vertical Slice] assesses feature cohesion: query, handler, and private bucketing live in one GetCategoryDistribution folder, so the whole feature is readable in one place.
        • +
        • Walkthrough - HandleAsync (:17-39) resolves a Session repository and a Category repository from the unit of work (:21-22). It loads the event's sessions with SessionCategoryItems included, filtered to s.EventId == query.EventId && !s.IsServiceSession (:24-28), then loads every category with its CategoryItems (:30-33), with no event filter because categories are global. CountSessionsPerCategoryItem (:41-64) drops declined sessions via IsDeclined (:45, :114-115), flattens each remaining session into (CategoryItemId, StatusBucket) pairs while skipping soft-deleted links (:46-48), and folds those pairs into a (Total, Accepted, AcceptQueue, Pending) tuple per category item (:50-61). ClassifyStatus (:101-112) treats a null status or SessionStatuses.Accepted as Accepted, SessionStatuses.AcceptQueue as AcceptQueue, and anything else as Pending, all with OrdinalIgnoreCase comparison. BuildCategoryGroups (:66-92) keeps only non-deleted categories that have at least one counted item (:70), orders categories by Sort (:71) and items by Sort (:78), and projects each item into a CategoryItemDistribution with its four counts, using TryGetValue so an uncounted item yields zeros (:81-90). The handler returns Result.Success(new CategoryDistributionDTO { Categories = categoryGroups }) (:38): it has no failure path.
        • +
        • Why it's built this way - aggregating in memory keeps the handler engine-agnostic (the same code runs against whatever store backs IUnitOfWork, per the database-per-service model of ADR-006) and states the domain rules (service sessions excluded, declined excluded, soft-deletes excluded at both the session-link and category-item levels) as readable filters instead of burying them in SQL. The trade-off is that a full event's sessions and the full category set are materialized; that is bounded by one conference's proposal volume.
        • +
        • Where it's used - injected into SessionSelectionController as IQueryHandler<GetCategoryDistributionQuery, Result<CategoryDistributionDTO>> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:32) and invoked from GET SessionSelection/categories/{eventId} (:53-65), an organizer-only endpoint (:29) whose response is output-cached under the ConferenceCache policy (:55). Behavior is pinned by GetCategoryDistributionHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/GetCategoryDistributionHandlerTests.cs:13).
        • +
        • Caveats / not-in-source - a null Session.Status is counted as Accepted (:103-107), so a session whose status was never set inflates the accepted column rather than the pending one. The branch is explicit in code, but the reason for choosing Accepted over Pending as the null default is not stated there. The handler also resolves the read-write GetRepository (:21-22) although it only reads: IUnitOfWork exposes a narrower GetReadRepository alongside it (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IUnitOfWork.cs:19 versus :29).

        GetContentSimilarityHandler

        -

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetContentSimilarity · MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetContentSimilarity/GetContentSimilarityHandler.cs:14 · Level 9 · class (sealed)

        -
        -
          -
        • What it is: the query handler that finds pairs of similar-content sessions in an event: it loads the candidate sessions once, scores every pair, and returns the strongest matches above the caller's threshold, capped at 50 (GetContentSimilarityHandler.cs:14).
        • -
        • Depends on: IUnitOfWork (constructor-injected, :15) and, through it, the Session and Category repositories (:23-24); SessionStatuses (:31); SessionSimilarityCalculator; Result; and the DTOs ContentSimilarityDTO and SimilarSessionPair. It implements IQueryHandler<in TQuery, TResult> of GetContentSimilarityQuery to Result<ContentSimilarityDTO> (:15).
        • -
        • Concept introduced: a quadratic analytics handler with a hard result cap. Comparing every session against every other is O(n^2) in the session count, and nothing about the request bounds n: an event with 300 proposals produces about 45,000 comparisons. Three separate devices keep that affordable, and it is worth seeing them as a set. First, narrow the input: the where clause drops service sessions and declined ones at the database, not in memory (:29-31). Second, hoist the per-item work out of the loop: each session's category-item set and keyword set are computed once, before the loop begins (:51-59), so the inner comparison is pure set arithmetic rather than repeated tokenization. Third, bound the output: MaxPairs (:17) truncates the sorted list to 50, so the response size does not grow with n^2 even when the threshold is set to zero. Both reads are asTracking: false (:32, :38), so EF materializes without change-tracking overhead on a path that never writes. [Rubric §12, Performance and Scalability] is the category this section is really about; [Rubric §8, Data Architecture] applies to the read shape, two GetAllAsync calls with explicit includes rather than lazy navigation.
        • -
        • Concept introduced: a total order for a truncated result. Sorting by score alone is not enough when the list is then cut to a fixed size: ties would be ordered by whatever List<T>.Sort (an unstable introsort) happened to produce, so the same event could return different top-50 pairs on two identical requests. CompareByScoreThenIndex (:120-132) makes the comparison total by falling through from score to IndexA and then IndexB (:130-131), and its doc comment states that purpose explicitly (:116-119). The regression test is HandleAsync_WithTiedScores_TruncatesToADeterministicTopFifty (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/GetContentSimilarityHandlerTests.cs:319). [Rubric §9, API and Contract Design] assesses response predictability: a truncated collection endpoint owes the caller a deterministic ordering, otherwise the cut point is arbitrary.
        • -
        • Walkthrough of HandleAsync (:19):
            -
          1. Resolve the session and category repositories from the unit of work (:23-24).
          2. -
          3. Load the candidate sessions: SessionCategoryItems included, filtered to the event, excluding IsServiceSession rows and anything whose Status equals SessionStatuses.Declined, untracked (:27-33). Note the Status != SessionStatuses.Declined comparison runs in SQL here, unlike the case-insensitive in-memory comparisons the dashboard handler uses.
          4. -
          5. Load all categories with their CategoryItems, untracked (:36-39), and flatten them into a CategoryItemIdentifierType -> name dictionary (:41-48). This is only needed to turn shared ids into readable names at the end.
          6. -
          7. Pre-compute, per session, an anonymous record of the session plus a HashSet of its non-deleted category-item ids and a keyword set from TokenizeText(s.Title + " " + s.Description) (:51-59).
          8. -
          9. Double-loop with j = i + 1 so each unordered pair is visited exactly once (:64-67), call SessionSimilarityCalculator.CalculateSimilarity (:68-72), and keep the pair only when the score is at or above query.MinimumSimilarity (:74-77); the comparison is >=, so the threshold is an inclusive lower bound.
          10. -
          11. Sort with the CompareByScoreThenIndex comparator (:82), which is score descending with an index tie-break, then truncate with GetRange(0, MaxPairs) when there are more than 50 (:83-86).
          12. -
          13. Project each survivor into a SimilarSessionPair (:89-111): both session ids, titles, and statuses; the score rounded to three digits with explicit MidpointRounding.ToEven (:105); the shared category items resolved through the name lookup, silently dropping ids the lookup does not know (:94, :106-108); and at most ten shared keywords (:95, :109).
          14. -
          15. Return Result.Success(new ContentSimilarityDTO { Pairs = result }) (:113).
          16. -
          -
        • -
        • Why it's built this way: the handler has no failure path at all, because a similarity report over zero sessions is legitimately an empty list rather than an error, so it never constructs a Result.Failure. Delegating all scoring to the static calculator keeps this file readable as orchestration, and keeps the tuning constants in one place. The cap plus the threshold together bound compute and payload so a large proposal set cannot produce an unbounded response.
        • -
        • Where it's used: resolved through IQueryHandler<in TQuery, TResult> by SessionSelectionController's content-similarity action (SessionSelectionController.cs:33, :81-93), which is behind the class-level [HasPermission(ConferencePermissions.SessionSelectionManage)] (:28) and served through the ConferenceCache output-cache policy (:82).
        • -
        • Testing: GetContentSimilarityHandlerTests on the shared HandlerTestBase<T> (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/GetContentSimilarityHandlerTests.cs:12), 16 tests covering the threshold boundary (:173), the self-comparison exclusion (:224), the fifty-pair cap (:302), the deterministic tie-break (:319), and the three-decimal rounding (:363).
        • -
        • Caveats / not-in-source: the pairwise loop is unconditional, so the MaxPairs cap bounds the response but not the work; nothing in this file limits how many sessions get loaded and compared. There is also no cancellation check inside the loops, so cancellationToken only takes effect at the two awaited repository calls.
        • +

          MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetContentSimilarity · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetContentSimilarity/GetContentSimilarityHandler.cs:14 · Level 9 · class (sealed)

          +
    +
      +
    • What it is - the query handler that scores every pair of an event's live sessions for content overlap and returns the strongest pairs, each annotated with the tags and words the two proposals share.
    • +
    • Depends on - IQueryHandler<in TQuery, TResult> (implemented) and IUnitOfWork (the only constructor dependency, :14-15); SessionSimilarityCalculator for the scoring; Session, Category, and CategoryItem as loaded aggregates; SessionStatuses for the declined filter; and the output contracts ContentSimilarityDTO and SimilarSessionPair (:3).
    • +
    • Concept introduced - the bounded quadratic analytical query. Unlike its sibling handlers, which fold each session once, this one compares every session against every other one: a double loop with j = i + 1 (:64-79), so a 200-proposal event performs 19,900 comparisons. Three separate mechanisms keep that honest. First, the expensive per-session work (tokenizing title plus description, materializing the category-item id set) is hoisted out of the loop and done once per session (:51-59), so the loop body is only set arithmetic. Second, only sessions that can still be scheduled are loaded at all: the where predicate excludes service sessions and declined ones in the database (:29-31). Third, the result is capped at MaxPairs = 50 (:17, :83-86). [Rubric §12 - Performance & Scalability] assesses exactly this shape: quadratic work is acceptable here because n is one conference's proposal count and the constant factor is a hash-set intersection, but the cost is real and the cap bounds the response, not the computation. [Rubric §9 - API & Contract Design] assesses response determinism, which is why the sort is not a plain score sort (see the walkthrough). [Rubric §6 - CQRS & Event-Driven] applies as for the sibling handlers: a pure read wrapped only by the query-side decorators.
    • +
    • Walkthrough - HandleAsync (:19-114) resolves Session and Category repositories (:23-24), loads the event's non-service, non-declined sessions with SessionCategoryItems included and asTracking: false (:27-33), and loads all categories with their items (:36-39) purely to build an id-to-name lookup for the shared-tag labels (:41-48). It then projects each session into an anonymous value of (Session, CategoryItems, Keywords) (:51-59), where CategoryItems skips soft-deleted links (:56) and Keywords comes from SessionSimilarityCalculator.TokenizeText(s.Title + " " + s.Description) (:58). The pairwise loop scores each combination and keeps the pair when score >= query.MinimumSimilarity (:64-79): the bound is inclusive, which is what HandleAsync_TreatsMinimumSimilarityAsInclusiveLowerBound pins (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/GetContentSimilarityHandlerTests.cs:173). Sorting uses the explicit comparer CompareByScoreThenIndex (:82, :120-132): score descending, then index A, then index B. The tie-break is the load-bearing part, and the summary above the comparer says why (:116-119): a plain score comparison leaves equal-scoring pairs in unspecified relative order, so truncating to 50 could return a different 50 for the same input. Truncation is GetRange(0, MaxPairs) (:83-86). Each surviving pair becomes a SimilarSessionPair (:89-111) carrying both sessions' id, title, and status, the score rounded to three decimals with MidpointRounding.ToEven (:105), the shared category items resolved to names (:106-108), and at most ten shared keywords (:109). The method ends with Result.Success(new ContentSimilarityDTO { Pairs = result }) (:113) and has no failure path.
    • +
    • Why it's built this way - the point of the feature is a conversation between organizers, so the output has to be explainable: showing the shared tags and words next to the number is what makes a pair actionable rather than merely flagged. The deterministic comparer exists because the response is output-cached and read by humans comparing runs, so a stable list is worth the extra comparisons. Doing the whole computation in memory keeps the scoring rule in C# where it is unit-testable, rather than in SQL where it would not be.
    • +
    • Where it's used - injected into SessionSelectionController as IQueryHandler<GetContentSimilarityQuery, Result<ContentSimilarityDTO>> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:34) and invoked from GET SessionSelection/content-similarity/{eventId} (:81-94). The dashboard path does not use it: GetSessionSelectionDashboardHandler computes distribution, overlap, locality, and AI scores but no similarity block, so this handler is reachable only through its own endpoint.
    • +
    • Caveats / not-in-source - the 50-pair cap truncates silently: the response carries no flag saying more pairs cleared the threshold. Sessions with neither tags nor keywords score 0.0 against each other and are therefore returned when the caller passes a floor of 0.0 (GetContentSimilarityHandlerTests.cs:207). The declined filter is expressed as s.Status != SessionStatuses.Declined (:31), a comparison translated to SQL, whereas the sibling handlers compare statuses with OrdinalIgnoreCase in memory; whether the two agree on casing depends on the database collation, which this file does not state.

    GetSessionSelectionDashboardHandler

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetSessionSelectionDashboard · MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboard/GetSessionSelectionDashboardHandler.cs:16 · Level 9 · class (sealed)

    -
    -
      -
    • What it is: the composite decision-support handler. It loads an event's sessions, categories, speakers, and AI scores once, then derives summary counts, category distribution, speaker overlap, speaker locality, and the AI-score table in memory, returning them all in a single SessionSelectionDashboardDTO (GetSessionSelectionDashboardHandler.cs:16).
    • -
    • Depends on: IUnitOfWork (:17) and, through it, the Event, Session, Speaker, Category, and SessionAiScore repositories (:23-26, :98); SpeakerLocalityHelper and LocalityLookupEntry; SessionStatuses; the private StatusBucket enum; Result and Error; and the dashboard DTO family, CategoryDistributionDTO, CategoryGroupDistribution, CategoryItemDistribution, SpeakerSessionOverlapDTO, MultiSessionSpeaker, SpeakerSessionSummary, SpeakerLocalitySummary, and SessionAiScoreDTO. Implements IQueryHandler<in TQuery, TResult> of GetSessionSelectionDashboardQuery to Result<SessionSelectionDashboardDTO> (:17).
    • -
    • Concept introduced: load once, compute many. This handler backs a single screen that shows five analytics at once. The naive shape (one query per panel) would read the same sessions five times. Instead it issues a small fixed set of untracked reads (asTracking: false at :37, :42, :60, :103) and derives every panel from those materialized collections with private static methods. The comment at :33 records a constraint that shapes the code: the loads are sequential, not parallel, because a DbContext is not concurrency-safe, so Task.WhenAll over the same unit of work would fault. [Rubric §12, Performance and Scalability] assesses read-path efficiency, and this is a worked example of trading in-memory CPU for round trips. [Rubric §8, Data Architecture] applies to a subtler decision described next.
    • -
    • Concept introduced: reading past the soft-delete filter, on purpose. The speaker load passes ignoreQueryFilters: true (:57-62), which switches off the EF global query filter that normally hides soft-deleted rows (see soft-delete). The comment above it (:52-56) is the rationale and is worth reading in the source: a live SessionSpeaker link can point at a soft-deleted Speaker, because speaker deletion deliberately does not cascade (BR-70/BR-71), and dropping that speaker here would render the dashboard row as "Unknown" instead of the truth. The comment also states why this is safe rather than a resurrection bug: every downstream consumer re-filters the child collections in memory (.Where(ss => !ss.IsDeleted) at :48 and :403, the equivalent if (ss.IsDeleted) continue; guards at :197-198 and :266-267, and .Where(sci => !sci.IsDeleted) at :139, :234, :419), so ignoring the filter widens only the speaker lookup, never the join rows. [Rubric §8, Data Architecture] assesses whether the persistence rules (here soft-delete and non-cascading deletes) are understood at the point of query rather than assumed away.
    • -
    • Walkthrough of HandleAsync (:19):
        -
      1. Resolve the event, session, speaker, and category repositories (:23-26), then validate the event exists; a miss returns Error.NotFound sourced to this handler and targeted at Event (:29-31). This is the handler's only failure path.
      2. -
      3. Load non-service sessions for the event with SessionSpeakers and SessionCategoryItems included (:34-38), then all categories with CategoryItems (:40-43).
      4. -
      5. Collect the distinct speaker ids off the live session-speaker links (:46-50) and load exactly those speakers with SpeakerCategoryItems via GetByIdsAsync, filters off (:57-62), into an id-keyed dictionary (:63).
      6. -
      7. Flatten the categories into a category-item name lookup (:66-73), then build the locality lookup: SpeakerLocalityHelper.FindLocalityCategories(categories) followed by BuildLocalityLookup (:75-76).
      8. -
      9. Compute the summary counts (:79-86): total, accepted, accept-queue, and declined by SessionStatuses comparison, with pending derived as the remainder rather than counted (:86). A null Status counts as accepted (:81).
      10. -
      11. ComputeCategoryDistribution (:89, defined :124-131) tallies category items via CountCategoryItems (:133-156, using StatusBucket) and shapes them with BuildCategoryGroups (:158-184), which keeps only non-deleted categories that actually have a counted item, ordered by Sort (:161-163), with the items inside each group also ordered by Sort (:170).
      12. -
      13. ComputeSpeakerOverlap (:92, defined :186-253) groups sessions by live speaker link, projects each into a MultiSessionSpeaker with its locality tier and an accepted-session flag, and sorts by session count descending, then accepted presence, then name (:240-250).
      14. -
      15. ComputeSpeakerLocality (:95, defined :255-312) re-groups the same sessions by speaker and folds them into per-tier totals, falling back to the literal "Unknown" both when the speaker is missing from the lookup and when the helper returns no tier (:283-287), ordered by speaker count descending (:302-303).
      16. -
      17. Load the SessionAiScore rows for this event's sessions (:98-104) and map them through BuildAiScoreDtos (:337-360), ordered by descending OverallScore (:354). That helper first locates the "Level" category by title match (:347-351) so that ResolveCategoryInfo (:410-432) can split a session's tags into ordinary categories and its single level, while ResolveSpeakerLocalities (:394-408) lists each session's distinct speaker tiers. A score whose session is not in the loaded set still produces a row, with an empty title and a null status (:357, :376, :387).
      18. -
      19. Assemble and return the composite DTO (:108-121).
      20. -
      -
    • -
    • Why it's built this way: one screen, one request. Computing every panel from one shared load avoids re-reading the same sessions once per panel, and keeping the compute in private static methods (rather than in a shared service) means each panel's rules stay local to the use case that renders them.
    • -
    • Where it's used: SessionSelectionController's dashboard action (SessionSelectionController.cs:30, :39-50), organizer-only and output-cached under the ConferenceCache policy (:28, :40). Two of its panels are also available standalone: speaker overlap through GetSpeakerSessionOverlapHandler and category distribution through GetCategoryDistributionHandler. The AI-score panel is populated by ScoreEventSessionsHandler.
    • -
    • Testing: GetSessionSelectionDashboardHandlerTests on HandlerTestBase<T> (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboardHandlerTests.cs:15).
    • -
    • Caveats / not-in-source: the class doc comment (:12-15) still lists "content similarity" among the analytics computed here; it is not, and never appears in the returned DTO. That analysis lives in the separate GetContentSimilarityHandler. The comment also omits the AI-score panel, which the handler does compute. Separately, ComputeSpeakerOverlap and ComputeSpeakerLocality each rebuild the same speaker-to-sessions grouping independently (:192-208 and :261-277); the duplication is in the source as written.
    • +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetSessionSelectionDashboard · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboard/GetSessionSelectionDashboardHandler.cs:16 · Level 9 · class (sealed)

      +
    +
      +
    • What it is - the composite handler behind the session-selection screen. It validates the event, loads sessions, categories, speakers, and AI scores once, and computes five blocks from that one snapshot: summary counts, category distribution, speaker overlap, speaker locality, and per-session AI scores.
    • +
    • Depends on - IQueryHandler<in TQuery, TResult> (implemented) and IUnitOfWork (the only constructor dependency, :16-17); the aggregates Event, Session, Speaker, Category, and SessionAiScore; SpeakerLocalityHelper and its LocalityLookupEntry; SessionStatuses; Result and Error; and the output contracts SessionSelectionDashboardDTO, CategoryDistributionDTO, SpeakerSessionOverlapDTO, MultiSessionSpeaker, SpeakerSessionSummary, SpeakerLocalitySummary, and SessionAiScoreDTO (:5).
    • +
    • Concept introduced - one snapshot, many projections, and the deliberate query-filter escape. Two mechanisms are worth learning here. The first is the load-once discipline: the comment at :33 records that the loads stay sequential for EF single-context safety (a DbContext is not thread-safe, so "parallel-friendly" here means ordered and independent, not concurrent), and every later block is a pure function over the already-materialized collections. The second is the one place the handler steps outside the framework's defaults: the speaker load passes ignoreQueryFilters: true (:61), turning off the global soft-delete filter for that read only. The comment above it explains the rule (:52-56): speaker deletion deliberately does not cascade to SessionSpeaker links (BR-70/BR-71), so a live link can point at a soft-deleted speaker, and honoring the filter would render that row as "Unknown" instead of the truth. [Rubric §8 - Data Architecture] assesses whether soft-delete semantics are applied deliberately rather than by reflex: this is an explicit, commented, single-read opt-out, and the comment notes that every downstream consumer re-filters the child collections in memory, so the escape cannot resurrect deleted category items. [Rubric §12 - Performance & Scalability] assesses request economy: four loads serve five projections. [Rubric §16 - Maintainability] assesses duplication, and that is the honest weak point (see the caveats).
    • +
    • Walkthrough - HandleAsync (:19-122) resolves four repositories (:23-26), then fetches the Event and returns Error.NotFound decorated with source and target when it is missing (:29-31): this is the only decision-support handler with a failure path, pinned by HandleAsync_WhenEventNotFound_ReturnsNotFound (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboardHandlerTests.cs:197). It loads the event's non-service sessions with SessionSpeakers and SessionCategoryItems included, untracked (:34-38), all categories with their items (:40-43), the distinct speaker ids referenced by live session-speaker links (:46-50), and those speakers with SpeakerCategoryItems included and query filters off (:57-62), indexed into a dictionary (:63). Two lookups follow: category-item id to name (:66-73) and the locality lookup built by SpeakerLocalityHelper from the locality categories it finds (:75-76). The summary counts are four Count passes plus one subtraction: accepted counts null-or-Accepted statuses, accept-queue and declined count their constants, and pending is the remainder (:79-86). ComputeCategoryDistribution (:89, :124-131) reuses the same tally-then-group shape as GetCategoryDistributionHandler (:133-156, :158-184). ComputeSpeakerOverlap (:92, :186-253) groups sessions by live speaker link, skips ids missing from the lookup (:213-214), stamps each speaker's locality tier (:224), orders each speaker's sessions by title (:227), and sorts speakers by session count descending, then accepted-session presence, then name (:240-250). ComputeSpeakerLocality (:95, :255-312) re-groups the same sessions by speaker, resolves each speaker to a tier defaulting to "Unknown" (:283-287), accumulates speaker, session, accepted, and accept-queue counts per tier in a case-insensitive dictionary (:279, :289-299), and emits SpeakerLocalitySummary rows ordered by speaker count descending (:302-311). Finally the AI-score block loads every SessionAiScore whose SessionId is in the loaded set (:98-104), and BuildAiScoreDtos (:337-360) orders them by OverallScore descending and projects each one through BuildSingleAiScoreDto (:362-392). That projection is where the "Level" category is special-cased: the handler finds the first non-deleted category whose title contains "Level" (:347-348), collects its item ids (:349-351), and ResolveCategoryInfo (:410-432) then splits a session's tags into ordinary categories and the single level value. ResolveSpeakerLocalities (:394-408) produces the distinct tier names for a scored session's speakers, again defaulting to "Unknown". ScoredOn prefers LastModifiedOn and falls back to CreatedOn (:386), the audit fields the framework stamps on save.
    • +
    • Why it's built this way - the screen needs internally consistent numbers, and computing every block from one materialized snapshot is what guarantees the speaker panel and the category panel describe the same set of sessions. The AI scores are read here rather than computed here because scoring is background work: ScoreEventSessionsHandler queues it and the dashboard surfaces whatever rows exist, which is why the queue endpoint tells the caller to refresh after a few minutes (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:96-99).
    • +
    • Where it's used - injected into SessionSelectionController as IQueryHandler<GetSessionSelectionDashboardQuery, Result<SessionSelectionDashboardDTO>> (SessionSelectionController.cs:31) and invoked from GET SessionSelection/dashboard/{eventId} (:39-51), the one decision-support endpoint the Blazor UI calls, through SessionSelectionService (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/SessionSelectionService.cs:16-30) and onto the SessionSelectionDashboard page.
    • +
    • Caveats / not-in-source - the category-distribution and speaker-overlap logic is duplicated rather than shared: CountCategoryItems and BuildCategoryGroups here (:133-156, :158-184) mirror GetCategoryDistributionHandler's CountSessionsPerCategoryItem and BuildCategoryGroups (GetCategoryDistributionHandler.cs:41-64, :66-92), and ComputeSpeakerOverlap mirrors GetSpeakerSessionOverlapHandler. Nothing in source keeps the copies aligned, and they already differ in one visible way: this handler loads speakers with ignoreQueryFilters: true while the standalone overlap handler does not, so a soft-deleted speaker appears on the dashboard and is absent from the narrow endpoint. The "Level" category is matched by a substring of the category title (:347-348), so renaming that category upstream silently empties SessionLevel. A session carrying more than one level tag resolves to whichever ResolveCategoryInfo encounters first (:426-429), decided by the enumeration order of the session's links.

    GetSpeakerSessionOverlapHandler

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetSpeakerSessionOverlap · MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSpeakerSessionOverlap/GetSpeakerSessionOverlapHandler.cs:18 · Level 9 · class (sealed)

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetSpeakerSessionOverlap · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSpeakerSessionOverlap/GetSpeakerSessionOverlapHandler.cs:18 · Level 9 · class (sealed)

      -
    • What it is: the standalone speaker-overlap handler. It returns every speaker who submitted at least one session for an event, with their sessions, sorted so multi-session speakers surface first: session count descending, then accepted-session presence, then name (GetSpeakerSessionOverlapHandler.cs:18, doc comment :11-17).
    • -
    • Depends on: IUnitOfWork (:19) and the Session, Speaker, and Category repositories (:25-27); SpeakerLocalityHelper and LocalityLookupEntry; SessionStatuses; Result; and SpeakerSessionOverlapDTO / MultiSessionSpeaker / SpeakerSessionSummary. Implements IQueryHandler<in TQuery, TResult> of GetSpeakerSessionOverlapQuery to Result<SpeakerSessionOverlapDTO> (:19).
    • -
    • Concept introduced: none new. It repeats the load-once-compute shape of GetSessionSelectionDashboardHandler and emits the identical MultiSessionSpeaker projection with the same three-key sort, but for one panel instead of five. What it adds is an early exit: if the event has no submitting speakers it returns an empty result before issuing the speaker and category queries at all (:38-39), so the empty case costs one round trip rather than three. [Rubric §6, CQRS and Event-Driven] (one message per read intent, even when two intents overlap) and [Rubric §12, Performance and Scalability].
    • -
    • Walkthrough of HandleAsync (:21):
        -
      1. Resolve the session, speaker, and category repositories (:25-27).
      2. -
      3. Load non-service sessions for the event with SessionSpeakers and SessionCategoryItems included, untracked (:29-33).
      4. -
      5. Group them by speaker id with GroupSessionsBySpeaker (:35, defined :61-82), which skips soft-deleted SessionSpeaker rows (:66); short-circuit to an empty SpeakerSessionOverlapDTO when no speaker ids came back (:38-39).
      6. -
      7. Load exactly those speakers with SpeakerCategoryItems via GetByIdsAsync, untracked (:41-45), and all categories with CategoryItems (:47-50).
      8. -
      9. Build the locality lookup by piping FindLocalityCategories into BuildLocalityLookup (:52-53) and the category-item name lookup with BuildCategoryItemNameLookup (:54, defined :84-97).
      10. -
      11. BuildMultiSessionSpeakers (:56, defined :99-140) walks the loaded speakers, skips any with no grouped sessions (:108-109), stamps the locality tier via SpeakerLocalityHelper.GetLocalityTier (:119) and an accepted flag where a null Status counts as accepted (:111-113), projects each session through BuildSessionSummary (:123, defined :142-153) with its non-deleted category-item names, ordered by title case-insensitively (:122), and sorts the speakers by session count, then accepted presence, then name (:127-137).
      12. -
      13. Return Result.Success with the list (:58). Like the similarity handler, this one has no failure path: note it does not validate that the event exists, so an unknown event id yields an empty list rather than a 404.
      14. -
      -
    • -
    • Why it's built this way: organizers often want just the overlap view, so it is its own use case reusing the shared SpeakerLocalityHelper and the same DTOs the dashboard embeds. Because the response shape is identical, the UI can render one component against either endpoint.
    • -
    • Where it's used: SessionSelectionController's speaker-overlap action (SessionSelectionController.cs:32, :67-78), organizer-only and output-cached (:28, :68).
    • -
    • Testing: GetSpeakerSessionOverlapHandlerTests on HandlerTestBase<T> (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/GetSpeakerSessionOverlapHandlerTests.cs:13).
    • -
    • Caveats / not-in-source: this handler's speaker load does not pass ignoreQueryFilters: true (:41-45), while the dashboard's equivalent load does (GetSessionSelectionDashboardHandler.cs:57-62). A speaker who was soft-deleted but still linked to a live session therefore appears on the dashboard's overlap panel and is silently missing from this standalone endpoint, even though both return the same DTO type. Nothing in either file cross-references the other.
    • +
    • What it is - the query handler that returns every speaker who submitted at least one session for an event, each with their sessions and locality tier, sorted so speakers holding several proposals appear first.
    • +
    • Depends on - IQueryHandler<in TQuery, TResult> (implemented) and IUnitOfWork (the only constructor dependency, :18-19); the aggregates Session, Speaker, and Category plus the SessionSpeaker and SpeakerCategoryItem links; SpeakerLocalityHelper and LocalityLookupEntry; SessionStatuses; and the output contracts SpeakerSessionOverlapDTO, MultiSessionSpeaker, and SpeakerSessionSummary (:4).
    • +
    • Concept introduced - inverting an aggregate's direction in memory. The database is queried session-first (sessions for an event, with their speaker links included, :29-33), but the answer is speaker-first. GroupSessionsBySpeaker (:61-82) performs that inversion: it flattens sessions into (SpeakerId, Session) pairs while skipping soft-deleted links (:64-67) and folds them into a dictionary of speaker to session list. Only then does the handler know which speakers to fetch, which is why the Speaker load is a GetByIdsAsync over the collected keys (:41-45, the interface at MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:48) rather than a second broad query. [Rubric §12 - Performance & Scalability] assesses query shape: three loads, no per-speaker round trip, and an early return that skips the speaker and category loads entirely when the event has no sessions (:38-39), a path pinned by HandleAsync_WithNoSessions_ReturnsEmptyAndSkipsSpeakerLookup (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/GetSpeakerSessionOverlapHandlerTests.cs:131). [Rubric §4 - DDD] assesses whether a concept is read from the aggregates the domain actually has: a speaker's origin is not a column but a category assignment, resolved by SpeakerLocalityHelper (:52-53, :119). [Rubric §6 - CQRS & Event-Driven] applies as for the siblings: a pure read with no failure path.
    • +
    • Walkthrough - HandleAsync (:21-59) resolves session, speaker, and category repositories (:25-27), loads the event's non-service sessions with SessionSpeakers and SessionCategoryItems included and asTracking: false (:29-33), inverts them into the speaker-to-sessions dictionary (:35), and returns an empty SpeakerSessionOverlapDTO when no speaker was referenced (:38-39). Otherwise it loads exactly those speakers with SpeakerCategoryItems included (:41-45) and all categories with their items (:47-50), then builds two lookups: the locality lookup, via SpeakerLocalityHelper.BuildLocalityLookup(SpeakerLocalityHelper.FindLocalityCategories(categories)) (:52-53), and category-item id to name (:54, :84-97). BuildMultiSessionSpeakers (:99-140) walks the loaded speakers, skips any with no sessions in the dictionary (:108-109), computes HasAcceptedSession by treating a null status or SessionStatuses.Accepted as accepted with OrdinalIgnoreCase (:111-113), stamps the locality tier (:119), and orders each speaker's sessions by title case-insensitively (:122). Each session becomes a SpeakerSessionSummary through BuildSessionSummary (:142-153), which carries id, title, raw status, and the names of the session's non-deleted category items. The final sort (:127-137) is three-level: session count descending, then accepted-session presence, then speaker name with OrdinalIgnoreCase, which is what makes the list deterministic rather than dictionary-enumeration order.
    • +
    • Why it's built this way - the class summary states the scope decision explicitly (:12-16): the endpoint returns every speaker, not only multi-session ones, because the UI renders a session-count column and lets the organizer see the whole roster while the sort surfaces the overlap cases first. Filtering server-side to speakers with two or more sessions would have made that same screen impossible without a second call.
    • +
    • Where it's used - injected into SessionSelectionController as IQueryHandler<GetSpeakerSessionOverlapQuery, Result<SpeakerSessionOverlapDTO>> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:33) and invoked from GET SessionSelection/speaker-overlap/{eventId} (:67-79). The Blazor UI does not call this endpoint: it reads the equivalent block off the dashboard response instead.
    • +
    • Caveats / not-in-source - the type and method names are residue from the narrower original scope: the DTO element is still MultiSessionSpeaker and the builder is still BuildMultiSessionSpeakers (:99) even though single-session speakers are included (HandleAsync_IncludesSingleSessionSpeakers, GetSpeakerSessionOverlapHandlerTests.cs:174). Unlike GetSessionSelectionDashboardHandler, this handler's GetByIdsAsync call does not pass ignoreQueryFilters (:41-45, versus GetSessionSelectionDashboardHandler.cs:57-62), so the global soft-delete filter applies and a soft-deleted speaker is skipped along with every session only they submitted (HandleAsync_SkipsSpeakersMissingFromRepository, GetSpeakerSessionOverlapHandlerTests.cs:217). Whether that difference is intended is not stated in either file. LocalityCategory stays null when a speaker has no locality assignment; this handler does not substitute "Unknown" the way the dashboard's locality block does.

    ExportEventCalendarQuery

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportEventCalendarQuery.cs:5 · Level 0 · record

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportEventCalendarQuery.cs:5 · Level 0 · record (sealed)

      -
    • What it is: the read request that asks for one published event's whole schedule rendered as an RFC 5545 (.ics) calendar document. A one-field sealed record carrying the EventId to export (ExportEventCalendarQuery.cs:5).
    • -
    • Depends on: the EventIdentifierType alias (see identifier aliases). Nothing else first-party, nothing external beyond the BCL.
    • -
    • Concept introduced, the request record as a CQRS message. [Rubric §6, CQRS and Event-Driven] assesses whether every read is an explicitly named message routed to its own handler. The whole type is one line: public sealed record ExportEventCalendarQuery(EventIdentifierType EventId);. It names exactly the input its handler needs, carries no behavior, and is dispatched through the shared decorator pipeline to its IQueryHandler<in TQuery, TResult> implementation, ExportEventCalendarHandler. [Rubric §5, Vertical Slice]: query, handler, and the mapper they share sit together under one UseCases/ExportCalendar folder rather than in layer-wide "Queries" and "Handlers" buckets.
    • -
    • Walkthrough: a positional record with the single member EventId (ExportEventCalendarQuery.cs:5); the doc comment attributes the feature to ADR-042 Wave 5 and documents the parameter (ExportEventCalendarQuery.cs:3-4). record supplies value equality and immutability, so the query is safe as a cache or log key in the pipeline.
    • -
    • Why it's built this way: keeping the query minimal (an id and nothing else) leaves every publish and exportability rule in one place, the handler, instead of splitting it between the request and the code that serves it.
    • -
    • Where it's used: handled by ExportEventCalendarHandler; the handler is injected into EventsController as IQueryHandler<ExportEventCalendarQuery, Result<string>> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventsController.cs:52) and the query is constructed in ExportCalendarAsync on GET {id}/ics (EventsController.cs:213), an [AllowAnonymous] action output-cached under the EventsCache policy that returns the string UTF-8 encoded as a text/calendar file named event-{id}.ics (EventsController.cs:202-217).
    • +
    • What it is - the read request behind "add the whole conference to my calendar": one event id, answered with an RFC 5545 .ics document covering every exportable session on that event's schedule.
    • +
    • Depends on - the EventIdentifierType alias, an int in this module (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8). No other first-party types, nothing external.
    • +
    • Concept introduced - the query whose result is a document, not a DTO. Every other read in this group resolves to a shaped DTO; this one resolves to Result<string> where the string is a complete calendar file (ExportEventCalendarHandler.cs:17). The query/handler split itself is taught by IQueryHandler<in TQuery, TResult> and is cross-referenced rather than re-taught. [Rubric §9 - API & Contract Design] assesses whether a contract matches the representation its consumer needs: a calendar client wants text/calendar bytes, so the use case produces the serialized document and the controller only wraps it in a File(...) response (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventsController.cs:214-217). [Rubric §6 - CQRS & Event-Driven] applies as for the sibling reads: a named message with no side effects, bound to exactly one handler by the generic argument.
    • +
    • Walkthrough - one positional parameter, EventId (:5), documented by the summary and <param> above it (:3-4). No body, no defaults, no marker interface.
    • +
    • Why it's built this way - the event-wide and single-session exports are genuinely different reads (one loads the whole schedule plus the room map, the other loads one session and then checks its parent), so they get separate messages instead of one query with a nullable session id. See ExportSessionCalendarQuery for the narrow twin.
    • +
    • Where it's used - constructed by EventsController on GET Events/{id}/ics, an [AllowAnonymous] action under the EventsCache output-cache policy (EventsController.cs:207-218), resolved through the injected IQueryHandler<ExportEventCalendarQuery, Result<string>> (:53). The browser-side caller is the add-to-calendar button on PublicEventDetail (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicEventDetail.razor:27-28).
    • +
    • Caveats / not-in-source - the doc comment tags the feature "ADR-042 Wave 5" (:3). ADR-042 is the MAUI device-capability abstraction (Website/docs-src/adr/042-device-capability-abstraction.md:1) and says nothing about iCalendar, so read that tag as a delivery-wave label rather than as a pointer to a specification of this export.

    ExportSessionCalendarQuery

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportSessionCalendarQuery.cs:5 · Level 0 · record

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportSessionCalendarQuery.cs:5 · Level 0 · record (sealed)

      -
    • What it is: the single-session variant of the calendar export: it asks for one public session rendered as an .ics document, backing the add-to-calendar affordance. A one-field sealed record carrying the SessionId (ExportSessionCalendarQuery.cs:5).
    • -
    • Depends on: the SessionIdentifierType alias. Nothing else.
    • -
    • Concept introduced: none new; it is the sibling of ExportEventCalendarQuery and differs only in identifying one session rather than a whole event. [Rubric §6, CQRS and Event-Driven].
    • -
    • Walkthrough: a positional record with the single member SessionId (ExportSessionCalendarQuery.cs:5); same ADR-042 Wave 5 attribution in the doc comment (ExportSessionCalendarQuery.cs:3-4).
    • -
    • Why it's built this way: a separate query keeps the one-session public rules distinct from the whole-event export, so neither path has to branch on "did the caller want one or all".
    • -
    • Where it's used: handled by ExportSessionCalendarHandler; the handler is injected into SessionsController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionsController.cs:50) and the query is constructed on GET {id}/ics (SessionsController.cs:279), [AllowAnonymous] and output-cached under SessionsCache, returned as session-{id}.ics (SessionsController.cs:268-283).
    • +
    • What it is - the read request for a single-session .ics document, the one behind the "add to calendar" button on a session page.
    • +
    • Depends on - the SessionIdentifierType alias, an int in this module (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:15). Nothing else.
    • +
    • Concept - none new; it is the same document-producing read message as ExportEventCalendarQuery, which teaches the shape. [Rubric §5 - Vertical Slice] assesses feature cohesion: both queries, both handlers, and the mapper they share sit in one ExportCalendar folder, so the whole capability is one directory.
    • +
    • Walkthrough - one positional parameter, SessionId (:5), with the summary and <param> above it (:3-4).
    • +
    • Why it's built this way - the single-session export is what a public attendee actually uses while browsing the agenda, and it enforces a stricter rule than the event export does (the session itself must be exportable, not merely present in a published event). Keeping it a separate message keeps that rule in one handler rather than as a branch inside a combined one.
    • +
    • Where it's used - constructed by SessionsController on GET Sessions/{id}/ics, [AllowAnonymous] under the SessionsCache policy (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionsController.cs:272-283), through the injected IQueryHandler<ExportSessionCalendarQuery, Result<string>> (:50). The UI caller is the add-to-calendar button on PublicSessionDetail (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSessionDetail.razor:38-39).

    ScoreEventSessionsCommand

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/ScoreEventSessionsCommand.cs:5 · Level 0 · record

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/ScoreEventSessionsCommand.cs:5 · Level 0 · record (sealed)

      -
    • What it is: the write request that triggers AI scoring for every session in an event. A one-field sealed record carrying the EventId whose sessions to score (ScoreEventSessionsCommand.cs:3-5).
    • -
    • Depends on: the EventIdentifierType alias. No externals.
    • -
    • Concept introduced: this is the one command among the sibling request records in this unit. It is structurally identical to the query records, but it dispatches through the command side of the pipeline (ICommandHandler<in TCommand, TResult>), which carries the Validating and Transactional decorators the query side does not. Note the record implements no marker interface (:5), so the Transactional decorator opens no transaction for it: durability comes from the handler's own per-session SaveChangesAsync instead. [Rubric §6, CQRS and Event-Driven] is precisely the split modeled here: a read (the dashboard) and a write (the scoring run) that happen to share a shape are still separate message types on separate pipelines.
    • -
    • Walkthrough: a single EventId positional parameter (:5), documented at :4.
    • -
    • Why it's built this way: scoring mutates persistence (it deletes and rewrites SessionAiScore rows, ScoreEventSessionsHandler.cs:105-107), so it is a command, not a query, and keeping it a distinct message makes that read/write asymmetry explicit.
    • -
    • Where it's used: constructed by the hosted drain SessionScoringProcessor (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Services/SessionScoringProcessor.cs:193), never by the controller: the endpoint only enqueues. Handled by ScoreEventSessionsHandler, which resolves through DI as ICommandHandler<ScoreEventSessionsCommand, Result<ScoreEventSessionsResultDTO>> (SessionScoringProcessor.cs:190-191) and returns a ScoreEventSessionsResultDTO.
    • +
    • What it is - the write message that says "score every non-service session on this event with the AI model". One positional EventId and nothing else.
    • +
    • Depends on - the EventIdentifierType alias (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8). Nothing external.
    • +
    • Concept introduced - the command nobody sends from a request thread. Unlike the other commands in this group, no controller ever constructs this record. The HTTP surface enqueues an event id instead (see ISessionScoringQueue), and the only construction site is the hosted drain worker resolving the handler inside its own DI scope (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Services/SessionScoringProcessor.cs:190-194). [Rubric §6 - CQRS & Event-Driven] assesses whether writes travel as explicit messages: keeping the run a real ICommandHandler command rather than a plain service method means the drain worker goes through the same handler pipeline a controller dispatch would. [Rubric §12 - Performance & Scalability] assesses request economy: the expensive work is named here and executed elsewhere, so the request thread returns in milliseconds.
    • +
    • Walkthrough - one positional parameter, EventId (:5), with the one-line summary above it (:3-4).
    • +
    • Why it's built this way - a scoring run takes minutes and issues one paid Anthropic call per session (ISessionScoringQueue.cs:19-21). Modelling it as a command lets the queue carry only an id while the handler stays a normal, testable use case.
    • +
    • Where it's used - resolved and dispatched by SessionScoringProcessor as ICommandHandler<ScoreEventSessionsCommand, Result<ScoreEventSessionsResultDTO>> (SessionScoringProcessor.cs:190-194); handled by ScoreEventSessionsHandler.

    SessionScoringEnqueueResult

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/ISessionScoringQueue.cs:4 · Level 0 · enum

      -
    • What it is: the three-valued outcome of asking to score an event's sessions: Queued, AlreadyPending, QueueFull (ISessionScoringQueue.cs:4-14). It is the return type of ISessionScoringQueue's TryEnqueue.
    • -
    • Depends on: nothing; a plain public enum co-located with the interface that returns it.
    • -
    • Concept introduced, an explicit refusal vocabulary instead of a bool. A bool TryEnqueue could only say yes or no; the caller could not tell "your run is already going" from "we are saturated, come back later", and those need different HTTP answers. Naming all three outcomes lets the edge translate each one without inspecting queue internals. [Rubric §9, API and Contract Design] assesses whether a contract carries enough information for its caller to act: here the enum is the reason the endpoint can distinguish a 202 from two different 409s.
    • -
    • Walkthrough: Queued (:7), accepted and awaiting the drain worker; AlreadyPending (:10), an existing run for the same event is queued or executing and continues untouched; QueueFull (:13), the bounded channel is at capacity and the caller should retry later.
    • -
    • Why it's built this way: ADR-052 (background job execution) requires expensive work to refuse rather than silently coalesce or drop (052-background-job-execution.md:46-54), and a refusal is only useful if the caller learns which refusal it was.
    • -
    • Where it's used: returned by SessionScoringQueue's TryEnqueue (SessionScoringQueue.cs:64-77) and switched on by SessionSelectionController's ScoreSessions (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:110-128), which maps Queued to 202 Accepted (:114) and the other two to distinct 409 Conflict errors, SessionScoring.AlreadyRunning (:117-120) and SessionScoring.QueueFull (:124-127).
    • +
    • What it is - the three-valued answer to "did my scoring request get in": Queued, AlreadyPending, or QueueFull (:7, :10, :13).
    • +
    • Depends on - nothing. It is a bare enum with default integer backing and no attributes.
    • +
    • Concept introduced - the tri-state accept, and why it is not a bool. A boolean enqueue result would collapse two refusals that need different words at the API edge: "your run is already in flight, do nothing" versus "the queue is saturated, come back". The controller maps them to distinct problem codes on the same HTTP status, SessionScoring.AlreadyRunning and SessionScoring.QueueFull, both 409 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:112-130). [Rubric §9 - API & Contract Design] assesses whether a refusal is expressed with enough fidelity for a caller to act on it: an operator seeing "already running" waits, an operator seeing "queue full" retries. [Rubric §29 - Resilience & Business Continuity] assesses back-pressure: refusing outright is the deliberate alternative to blocking a request thread on a bounded channel.
    • +
    • Walkthrough - three members in acceptance order, each with a one-line doc comment stating the caller's next move (:6-13). There is no None or Unknown member: every path through TryEnqueue returns one of the three explicitly (SessionScoringQueue.cs:69, :72, :76).
    • +
    • Why it's built this way - the dedup decision and the capacity decision are made at different points inside TryEnqueue (a lost TryAdd versus a failed TryWrite), so the return type carries both outcomes rather than forcing the caller to re-inspect the queue.
    • +
    • Where it's used - returned by ISessionScoringQueue.TryEnqueue (ISessionScoringQueue.cs:36), produced by SessionScoringQueue, and switched on by SessionSelectionController.ScoreSessions (SessionSelectionController.cs:112).

    SessionScoringResult

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/IAiScoringService.cs:40 · Level 0 · record

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/IAiScoringService.cs:40 · Level 0 · record (sealed)

      -
    • What it is: the Application-layer result of scoring one session via IAiScoringService. It carries seven numeric sub-scores (each documented 1.0-10.0 decimal), a free-text Reasoning, the SessionId, and a Success flag. A failed AI call returns this record with Success = false rather than throwing (IAiScoringService.cs:40-71).
    • -
    • Depends on: the SessionIdentifierType alias (on SessionId, :43). No first-party types; every property is required init.
    • -
    • Concept introduced, never-throw service results. Rather than propagate exceptions from the AI call, ScoreSessionAsync returns this record in every case (the contract is stated at IAiScoringService.cs:9) and the handler branches on Success (ScoreEventSessionsHandler.cs:72). This is the Result philosophy (see Result) applied to an unreliable network dependency: the scoring loop cannot be aborted by one bad session. [Rubric §29, Resilience and Business Continuity] assesses how a dependency failure is contained; here it is demoted to a per-item flag rather than an exception that unwinds the whole batch.
    • -
    • Walkthrough: ten required init members (:43-70): SessionId, OverallScore, TopicRelevanceScore, DescriptionQualityScore, NoveltyScore, ActionableTakeawaysScore, DepthOrInsightQualityScore, CredibilityExperienceScore, Reasoning, Success. required on all of them means a scorer implementation cannot forget to populate one, and init means a result cannot be edited after the scorer hands it back.
    • -
    • Why it's built this way: separating this Application-layer result from the external SessionAiScoreDTO lets the scoring contract evolve (add or drop a sub-score) without immediately breaking the API surface.
    • -
    • Where it's used: returned by IAiScoringService.ScoreSessionAsync and consumed by ScoreEventSessionsHandler, which feeds a successful one plus the scorer's ModelId into SessionAiScore.Create (ScoreEventSessionsHandler.cs:79-83) to make a SessionAiScore domain row.
    • -
    • Caveats / not-in-source: the 1.0-10.0 range lives only in the doc comments here (:45-64); this record enforces no bound. The range check that does exist is in the domain factory SessionAiScore.Create, whose EnsureScoreInRange rejects anything outside >= 1.0m and <= 10.0m for all seven sub-scores (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionAiScore.cs:68-74, :134-135); the handler treats such a rejection as just another counted failure (ScoreEventSessionsHandler.cs:85-90).
    • +
    • What it is - what the AI scorer hands back for one session: seven numeric sub-scores, the model's free-text Reasoning, and a Success flag that says whether any of it means anything.
    • +
    • Depends on - the SessionIdentifierType alias (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:15) and BCL decimal, string, bool. No first-party types.
    • +
    • Concept introduced - the never-throw service result. IAiScoringService contracts that scoring never throws and reports failure in the result instead (IAiScoringService.cs:9), and this record is the vehicle. The adapter's failure path builds it with every score at 0m, Reasoning = "Scoring failed", and Success = false (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Services/AnthropicScoringService.cs:181-192), so one bad session never aborts a loop over hundreds. [Rubric §29 - Resilience & Business Continuity] assesses whether a flaky external dependency degrades one item or the whole run: here it degrades one. [Rubric §13 - Observability & Operability] assesses whether an outcome is legible after the fact: Reasoning is persisted onto SessionAiScore alongside the model id, so an organizer can see why a session scored what it scored and which model said so.
    • +
    • Walkthrough - ten required init members (:43-70): SessionId; the seven decimal scores OverallScore, TopicRelevanceScore, DescriptionQualityScore, NoveltyScore, ActionableTakeawaysScore, DepthOrInsightQualityScore, CredibilityExperienceScore, each documented as 1.0 to 10.0; Reasoning; and Success. required on all ten means no partially-populated instance can be constructed, which is what lets the handler read result.SessionId rather than the loop variable when building the entity (ScoreEventSessionsHandler.cs:80).
    • +
    • Why it's built this way - the 1.0 to 10.0 range in the doc comments is documentation on this record only. The invariant is enforced one layer in, by SessionAiScore.Create, which rejects anything outside >= 1.0m and <= 10.0m with a SessionAiScore.OutOfRange error (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionAiScore.cs:134-141). Keeping the transport record permissive and the entity strict means a model that returns nonsense produces a counted failure instead of a corrupt row.
    • +
    • Where it's used - returned by IAiScoringService.ScoreSessionAsync (IAiScoringService.cs:11-13), produced by AnthropicScoringService and by FakeAiScoringService in tests, consumed by ScoreEventSessionsHandler (:70-83). It is application-internal: the shape the API returns is SessionAiScoreDTO.
    • +
    • Caveats / not-in-source - a Success = false result carries all-zero scores, which SessionAiScore.Create would reject outright. Nothing in the type enforces that pairing; the handler simply never reaches Create on a failed result because it checks Success first (ScoreEventSessionsHandler.cs:72-77).

    SessionScoringWorkItem

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/SessionScoringQueue.cs:21 · Level 0 · record struct

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/SessionScoringQueue.cs:21 · Level 0 · readonly record struct

      -
    • What it is: one queued AI scoring run, as it travels through the channel: which event to score, and which attempt this is. A readonly record struct of (EventIdentifierType EventId, int Attempt) (SessionScoringQueue.cs:21).
    • -
    • Depends on: the EventIdentifierType alias, and the BCL StructLayoutAttribute / LayoutKind.Auto from System.Runtime.InteropServices (:20). Nothing else first-party.
    • -
    • Concept introduced, retry state that rides on the message. The attempt count travels with the item rather than living in a side table, so the drain worker can decide whether a failed run is worth retrying without keeping any per-event state of its own (:7-15). The doc comment is explicit about the cost of that choice: a crash between the failure and the requeue loses the retry, which is the intended floor, because the queue is in-process and best-effort and an organizer can always trigger the run again. [Rubric §29, Resilience and Business Continuity] assesses whether the failure model is stated and bounded rather than assumed; here the guarantee is deliberately weak and written down. [Rubric §12, Performance and Scalability]: a readonly record struct means each queued item is a stack-sized value with no allocation per enqueue, and [StructLayout(LayoutKind.Auto)] (:20) lets the runtime pack the two fields rather than forcing sequential layout.
    • -
    • Walkthrough: two positional members, EventId (documented at :16) and Attempt (:17-19), where 1 is the original request and each bounded retry the drain schedules increments it by one. readonly makes every member non-mutating, so an item cannot be edited in flight; record struct supplies value equality for free, which is what makes the queue's unit tests able to assert on a dequeued item directly (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/DecisionSupport/SessionScoringQueueTests.cs:79).
    • -
    • Why it's built this way: ADR-052 puts the retry policy in the drain worker, not in the queue; carrying the attempt on the item is what lets the worker stay stateless while still enforcing a cap.
    • -
    • Where it's used: it is the channel's element type inside SessionScoringQueue (:43-44), constructed on the enqueue path with FirstAttempt (:71) and on the requeue path with the caller's attempt number (:97); consumed by SessionScoringProcessor, which reads items off Reader.ReadAllAsync (SessionScoringProcessor.cs:107) and compares item.Attempt against its own MaxAttempts of 3 before re-queuing (SessionScoringProcessor.cs:74, :143).
    • +
    • What it is - one queued scoring run: which event to score, and which attempt this is. Two values in a readonly record struct marked [StructLayout(LayoutKind.Auto)] (:20-21).
    • +
    • Depends on - the EventIdentifierType alias (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8), plus System.Runtime.InteropServices.StructLayoutAttribute (:2). No first-party dependencies.
    • +
    • Concept introduced - retry state that travels with the message. The obvious alternative is a side table of "how many times have I tried event 7", owned by the drain worker. Carrying Attempt on the item instead means the worker keeps no per-event state at all: it reads an item, and if the run throws it re-queues the same item with Attempt + 1 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Services/SessionScoringProcessor.cs:143). The doc comment names the price honestly: a crash between the failure and the requeue loses the retry, which is the intended floor for an in-process, best-effort queue that an organizer can always trigger again (:11-14). [Rubric §29 - Resilience & Business Continuity] assesses whether the durability level of a mechanism is chosen and stated rather than assumed. [Rubric §12 - Performance & Scalability] applies in a small way: a struct element in a Channel<T> avoids a heap allocation per enqueue, and LayoutKind.Auto lets the runtime pack the fields.
    • +
    • Walkthrough - two positional members (:21): EventId and Attempt. Attempt is documented as 1 for the original request, incremented by one on each bounded retry the drain worker schedules (:17-19); the constant supplying that initial 1 lives on the queue as FirstAttempt (SessionScoringQueue.cs:41).
    • +
    • Why it's built this way - a readonly record struct gives value equality and immutability with no allocation, which suits a message that is written, read once, and discarded.
    • +
    • Where it's used - the element type of the queue's bounded Channel<SessionScoringWorkItem> (SessionScoringQueue.cs:43-49), written by TryEnqueue (:71) and TryRequeue (:97), read by SessionScoringProcessor through queue.Reader.ReadAllAsync (SessionScoringProcessor.cs:107).
    • +
    • Caveats / not-in-source - the retry ceiling is not on this type. MaxAttempts = 3 is a private constant on the drain worker (SessionScoringProcessor.cs:74), so nothing in the item itself stops a different consumer from re-queuing forever.

    SpeakerInfo

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/IAiScoringService.cs:23 · Level 0 · record

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/IAiScoringService.cs:23 · Level 0 · record (sealed)

      -
    • What it is: the minimal speaker payload the AI scorer needs: FullName, optional TagLine, optional Bio. A positional sealed record (IAiScoringService.cs:23-26).
    • -
    • Depends on: nothing first-party; three string / string? members.
    • -
    • Concept introduced, least-privilege data passing. The record deliberately carries only what the model reads (name, tagline, bio) and no ids, contact fields, or other PII. [Rubric §11, Security] and [Rubric §30, Compliance, Privacy and Data Governance] both assess how much data crosses a boundary to a third party: shipping a purpose-built projection to the AI vendor rather than a whole Speaker limits what leaves the trust boundary.
    • -
    • Walkthrough: three positional parameters (:23-26); the doc comments (:19-22) note the source-side maximum lengths (TagLine 500 characters, Bio 4000) that the domain enforces upstream.
    • -
    • Why it's built this way: a narrow record keeps the scoring prompt small and keeps speaker identity beyond the name out of the external model call.
    • -
    • Where it's used: nested inside SessionScoringInput; populated by ScoreEventSessionsHandler from each Speaker's FullName / TagLine / Bio (ScoreEventSessionsHandler.cs:64), with speakers that miss the lookup filtered out before the list is built (:63-67).
    • -
    • Caveats / not-in-source: a differently-shaped SpeakerInfo also exists in the Conference UI layer (SpeakerInfo); the two are unrelated types that share a name. The speaker's FullName and Bio do still leave the trust boundary on every scoring call, so "least privilege" here means a narrowed projection, not an anonymized one.
    • +
    • What it is - the slice of a speaker that the AI model is allowed to see: full name, optional tagline, optional biography (:23-26).
    • +
    • Depends on - nothing but BCL strings. Notably it does not carry the SpeakerIdentifierType (a Guid in this module, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:19), nor an email, a photo url, or any other Speaker field.
    • +
    • Concept introduced - the minimized projection at an external boundary. Everything in this record leaves the system: AnthropicScoringService concatenates the name, tagline, and bio straight into the prompt body it posts to Anthropic (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Services/AnthropicScoringService.cs:167-171). Building a purpose-shaped record instead of passing the entity means the set of fields that can reach a third party is a three-line declaration a reviewer can read at a glance. [Rubric §30 - Compliance/Privacy/Data Governance] assesses data minimization at a processor boundary: the identifier is deliberately absent, so what leaves is speaker-authored public bio text with no key to join it back. [Rubric §11 - Security] assesses whether such a boundary is explicit rather than incidental; here it is a type, not a convention.
    • +
    • Walkthrough - three positional members (:23-26), the last two nullable. The handler builds one per non-deleted SessionSpeaker it can resolve, from speaker.FullName, speaker.TagLine, speaker.Bio (ScoreEventSessionsHandler.cs:61-67).
    • +
    • Why it's built this way - a scoring prompt needs the speaker's credibility signals and nothing else. Widening the model would silently widen what is sent to a paid third-party API, which is the kind of change a dedicated record forces into review.
    • +
    • Where it's used - as the Speakers list on SessionScoringInput (IAiScoringService.cs:37); constructed only in ScoreEventSessionsHandler (:64).
    • +
    • Caveats / not-in-source - the doc comments state "max 500 chars" for TagLine and "max 4000 chars" for Bio (:21-22), but nothing in this record, the handler, or the Anthropic adapter truncates or validates either value: the adapter appends whatever it is given (AnthropicScoringService.cs:167-171). Treat those numbers as descriptive of the source fields, not as an enforced bound on the prompt. A different, unrelated SpeakerInfo exists in the Conference UI assembly (SpeakerInfo); the two only share a name.

    ISessionScoringQueue

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/ISessionScoringQueue.cs:31 · Level 1 · interface

      -
    • What it is: the producer-side port for requesting an AI scoring run. The API edge calls TryEnqueue and gets an immediate answer; the run itself happens later on a hosted worker (ISessionScoringQueue.cs:31-41).
    • -
    • Depends on: SessionScoringEnqueueResult (Level 0, same file :4) as its return type, and the EventIdentifierType alias as its key. No externals.
    • -
    • Concept introduced, queue-plus-drain instead of fire-and-forget. The interface doc (:16-30) records the history explicitly: scoring an event takes minutes and issues one paid Anthropic call per session, so it cannot run on the request thread, and it used to run as an untracked task started from the controller. That shape had three defects, all named in the comment: nothing tracked it, so a deploy or scale-in killed it mid-run with no record; nothing deduplicated it, so two clicks meant two concurrent passes over the same event, doubling the spend and racing each other's writes; and it ignored the host lifetime, so shutdown could neither wait for it nor cancel it. Declaring the port in the Application layer keeps the API edge unaware of channels and hosted services entirely. [Rubric §3, Clean Architecture] assesses dependency inversion at layer boundaries, and [Rubric §29, Resilience and Business Continuity] assesses whether long-running work survives (or fails cleanly across) a restart.
    • -
    • Walkthrough: TryEnqueue(EventIdentifierType) (:36) requests a run and returns which of the three outcomes happened; IsPending(EventIdentifierType) (:40) reports whether a run for that event is queued or currently executing. The doc at :27-29 states the dedup posture: a second request while one is in flight is refused, not coalesced, so the caller learns the run is already going. Note what is deliberately absent from the port: the reader side and the completion callback live on the concrete class, not here, so a producer cannot accidentally drain the queue.
    • -
    • Why it's built this way: ADR-052 makes the bounded queue plus single-reader hosted drain the standard shape for in-process background work (052-background-job-execution.md:34-54), and puts capacity, full-mode, and dedup policy inside the queue type so a caller cannot get them wrong.
    • -
    • Where it's used: injected into SessionSelectionController (SessionSelectionController.cs:34), whose ScoreSessions action is its only production caller (:110); implemented by SessionScoringQueue, registered as a singleton in the Conference application DI (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:50-51).
    • -
    • Caveats / not-in-source: IsPending has no production caller today. It is exercised only from tests: the queue's own unit tests (SessionScoringQueueTests.cs:19, :49, :62, :102, :121) and the drain's tests, which use it to observe that the claim was released (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Infrastructure.Tests/Services/SessionScoringProcessorTests.cs:43, :69, :94, :146).
    • +
    • What it is - the producer-side port for AI scoring runs: ask for an event to be scored, or ask whether one is already in flight. Two methods, neither async.
    • +
    • Depends on - SessionScoringEnqueueResult (same file, :4) and the EventIdentifierType alias (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8). Nothing external.
    • +
    • Concept introduced - the request-shedding work queue, and why fire-and-forget was not enough. The interface's own doc comment (:16-30) is the design record for the whole feature. Scoring an event takes minutes and issues one paid Anthropic call per session, so it cannot run on the request thread; it previously ran as a fire-and-forget task started from the controller, which had three named problems: nothing tracked it, so a deploy or scale-in killed it mid-run with no record; nothing deduplicated it, so two clicks meant two concurrent passes over the same event, doubling the spend and racing each other's writes; and it ignored the host lifetime, so shutdown could not wait for or cancel it (:20-24). The port answers all three by handing the work to a hosted drain. Note the deliberate dedup choice: a second request is refused rather than silently coalesced, so the caller learns the run is already in flight (:26-29). [Rubric §29 - Resilience & Business Continuity] assesses whether long work survives the request that started it. [Rubric §31 - Cost/FinOps] assesses spend control on a metered dependency: dedup here is a money guard as much as a correctness one. [Rubric §3 - Clean Architecture] assesses the direction of dependency: the port is declared in Application, while the channel implementation and the hosted worker that drains it are wired nearer the host, so the controller sees neither.
    • +
    • Walkthrough - TryEnqueue(EventIdentifierType) (:36) returns SessionScoringEnqueueResult rather than a bool, so the caller can distinguish "already pending" from "queue full". IsPending(EventIdentifierType) (:40) reports whether a run is queued or currently executing, not merely waiting: the implementation holds the claim until the run finishes (SessionScoringQueue.cs:51-55). Both methods are synchronous, which is what makes the enqueue safe to call from an MVC action with no awaits at all.
    • +
    • Why it's built this way - separating the producer port from the concrete SessionScoringQueue keeps the consumer side (Reader, TryRequeue, MarkCompleted) off the interface the API layer can reach. A controller can only ask; only the drain worker, which resolves the concrete class, can consume or complete.
    • +
    • Where it's used - injected into SessionSelectionController for POST SessionSelection/score/{eventId} (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:35, :106-131) and into SessionScoringSweepJob, the five-minute crash-recovery backstop that re-enqueues events whose pass started but never finished (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Services/SessionScoringSweepJob.cs:56, rationale at :13-20). Registered as a singleton that forwards to the one concrete instance (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:50-55).

    SessionScoringInput

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/IAiScoringService.cs:33 · Level 1 · record

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/IAiScoringService.cs:33 · Level 1 · record (sealed)

      -
    • What it is: the full input for scoring one session: SessionId, Title, optional Description, and the list of SpeakerInfo records for the session's speakers. A positional sealed record (IAiScoringService.cs:33-37).
    • -
    • Depends on: SpeakerInfo (Level 0, same file :23) via IReadOnlyList<SpeakerInfo>; the SessionIdentifierType alias. Referencing a Level-0 first-party type is what puts this record at Level 1.
    • -
    • Concept introduced: none new; it is the request DTO for IAiScoringService, assembling exactly what the model prompt needs (title, description, speaker bios) and nothing more, extending the least-privilege discipline SpeakerInfo sets. [Rubric §6, CQRS and Event-Driven] and [Rubric §30, Compliance, Privacy and Data Governance].
    • -
    • Walkthrough: four positional parameters (:33-37), documented at :28-32; Speakers may be empty (stated at :32), so a speaker-less session still scores.
    • -
    • Why it's built this way: a purpose-built input record keeps the port contract stable and lets the use case be tested against a fake scorer without constructing domain aggregates.
    • -
    • Where it's used: constructed per session by ScoreEventSessionsHandler (ScoreEventSessionsHandler.cs:69) and passed straight to IAiScoringService.ScoreSessionAsync (:70).
    • +
    • What it is - everything the AI scorer is given about one session: its id, title, optional description, and the SpeakerInfo projections for its speakers (:33-37).
    • +
    • Depends on - SpeakerInfo (same file, :23) and the SessionIdentifierType alias (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:15). External: IReadOnlyList<T> only.
    • +
    • Concept - none new; it is the request half of the never-throw port taught at IAiScoringService, and the minimization rationale is taught at SpeakerInfo. [Rubric §3 - Clean Architecture] assesses whether an external capability is described in the application's own language: this record names a session and its speakers, not a prompt, a token budget, or a JSON body, all of which stay inside the Infrastructure adapter.
    • +
    • Walkthrough - four positional members (:33-37). Speakers is documented as possibly empty (:32), and the handler does produce an empty list for a session whose speaker links resolve to nothing (ScoreEventSessionsHandler.cs:61-67). SessionId is carried through the call and echoed back on SessionScoringResult, which is what lets the handler pair a result with its session without holding a map.
    • +
    • Why it's built this way - passing a purpose-built input record rather than the Session entity keeps the domain aggregate out of the adapter and keeps the prompt's ingredients auditable in four lines.
    • +
    • Where it's used - the sole payload parameter of IAiScoringService.ScoreSessionAsync (:11-13); built once per session by ScoreEventSessionsHandler (:69), consumed by AnthropicScoringService.

    IAiScoringService

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/IAiScoringService.cs:6 · Level 2 · interface

      -
    • What it is: the application-layer port for scoring a single conference session with an AI model. Its contract guarantees it never throws: failure is reported through the returned SessionScoringResult's Success flag (IAiScoringService.cs:6-17).
    • -
    • Depends on: SessionScoringInput (Level 1, :33) as the argument and SessionScoringResult (Level 0, :40) as the return; BCL Task / CancellationToken otherwise.
    • -
    • Concept introduced, a port-and-adapter boundary for an external AI capability. The Application layer declares the interface; the Anthropic HTTP and JSON details live in an Infrastructure adapter, so the vendor protocol never reaches Application. [Rubric §3, Clean Architecture] assesses whether outward dependencies are inverted behind an abstraction, which this does exactly, and [Rubric §1, SOLID] (the Dependency Inversion Principle) is the same story: the handler depends on this port, not a concrete API client. The never-throws clause (documented at :9) also ties to [Rubric §29, Resilience and Business Continuity], and [Rubric §14, Testability] follows from both: ScoreEventSessionsHandler is unit-tested against a fake scorer (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/ScoreEventSessionsHandlerTests.cs:13).
    • -
    • Walkthrough: ScoreSessionAsync(SessionScoringInput, CancellationToken) (:11-13) returns the per-session result; ModelId (:15-16) exposes which model produced the score, so persisted rows can record the model used for auditability. Note that ModelId is a property on the port rather than a field of the result, which is what lets the handler stamp every row it writes without asking the scorer twice (ScoreEventSessionsHandler.cs:83).
    • -
    • Why it's built this way: defining the port in Application lets the scoring use case be unit-tested with a fake scorer and lets the AI vendor be swapped without touching ScoreEventSessionsHandler.
    • -
    • Where it's used: injected into ScoreEventSessionsHandler (ScoreEventSessionsHandler.cs:20); implemented by the Infrastructure adapter AnthropicScoringService, whose ModelId is the literal claude-haiku-4-5-20251001 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Services/AnthropicScoringService.cs:22) and which sends that same id as the request model (AnthropicScoringService.cs:42).
    • +
    • What it is - the application-layer port for scoring one conference session with an AI model, plus a property naming the model that did the scoring.
    • +
    • Depends on - SessionScoringInput and SessionScoringResult, both declared in this same file (:33, :40), which in turn use SpeakerInfo (:23). External: BCL only. There is no Anthropic client type anywhere in the Application assembly.
    • +
    • Concept introduced - port and adapter for an unreliable, paid, external capability. [Rubric §3 - Clean Architecture] assesses which layer owns the abstraction: the application declares the port, while the HTTP client, the prompt text, the JSON contract records, and the API key all live in Infrastructure's AnthropicScoringService, bound by AddHttpClient<IAiScoringService, AnthropicScoringService> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:29). [Rubric §1 - SOLID] assesses the dependency-inversion direction: ScoreEventSessionsHandler depends on this interface, so swapping vendors touches one registration. [Rubric §29 - Resilience & Business Continuity] assesses failure containment: the "never throws, failure is indicated in the result" clause (:9) is the Result philosophy applied to a network call, and it is what lets the handler's per-session loop keep going. [Rubric §14 - Testability] assesses whether the use case can run without the dependency: FakeAiScoringService implements this interface, so the handler is unit-tested with no HTTP and no API key.
    • +
    • Walkthrough
        +
      • ScoreSessionAsync(SessionScoringInput, CancellationToken = default) (:11-13) returns Task<SessionScoringResult>. There is no Result<T> here and no exception path: the success or failure signal is the Success flag on the returned record.
      • +
      • ModelId { get; } (:16) exposes which model produced a score. The handler stamps it onto the persisted entity as the modelUsed argument (ScoreEventSessionsHandler.cs:83), so a score row records both the number and its provenance.
      • +
      • Scope: one session per call. Nothing on this interface batches, so the fan-out policy (sequential, one at a time) is the handler's decision rather than the port's.
      • +
      +
    • +
    • Why it's built this way - defining the port in Application lets the scoring use case be exercised with a fake scorer, and lets the AI vendor change without touching the handler. Exposing ModelId on the port rather than hard-coding a string in the handler means the recorded provenance cannot drift from the client that actually made the call.
    • +
    • Where it's used - constructor-injected into ScoreEventSessionsHandler (:20); implemented by AnthropicScoringService in production and FakeAiScoringService in tests.

    SessionScoringQueue

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/SessionScoringQueue.cs:34 · Level 2 · class

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/SessionScoringQueue.cs:34 · Level 2 · class (sealed)

      -
    • What it is: the bounded in-process implementation of ISessionScoringQueue, built on a System.Threading.Channels channel of SessionScoringWorkItem values plus a concurrent set of in-flight events. Registered as a singleton and drained by exactly one hosted worker (SessionScoringQueue.cs:23-34).
    • -
    • Depends on: ISessionScoringQueue, SessionScoringEnqueueResult, and SessionScoringWorkItem; BCL Channel<T> / BoundedChannelOptions (System.Threading.Channels) and ConcurrentDictionary<,> (System.Collections.Concurrent).
    • -
    • Concept introduced, backpressure mode as a statement of what the work is worth. The channel is created with FullMode = BoundedChannelFullMode.Wait, SingleReader = true, SingleWriter = false (:43-49) and is written with a non-blocking TryWrite (:71). That combination means a full queue refuses the request instead of blocking the request thread or evicting an earlier item: the class comment contrasts it with an ephemeral live broadcast, where dropping is fine, and notes that a scoring run is expensive enough that the caller needs to know it was not accepted (:25-29). SingleReader matches the one drain worker, so runs execute one at a time and cannot contend for the same event's rows (:30-32). [Rubric §12, Performance and Scalability] assesses how load is shed at the edge; [Rubric §31, Cost and FinOps] applies too, because each queued run is metered spend against the AI vendor.
    • -
    • Concept introduced, claim-before-write deduplication. TryEnqueue (:64-77) first does _pending.TryAdd(eventId, 0) and returns AlreadyPending when it loses (:68-69), so a concurrent duplicate is refused and exactly one run per event is ever in flight. Only then does it try the channel write; if that fails it releases the claim before returning QueueFull (:74-76) so a later attempt is not blocked by a request that never queued. The claim is cleared by MarkCompleted, called by the drain after the run finishes, so the dedup window covers execution and not just the wait in the queue (:51-55, :105-110).
    • -
    • Walkthrough:
        -
      • Capacity (:36-38): the const 16. The comment is explicit that the bound exists to refuse a runaway caller, not to absorb load: organizers score a handful of events.
      • -
      • FirstAttempt (:40-41): the const 1, the attempt number stamped on an item queued from a caller's original request.
      • -
      • _channel (:43-49): the bounded channel of SessionScoringWorkItem described above.
      • -
      • _pending (:51-55): a ConcurrentDictionary<EventIdentifierType, byte> used as a concurrent set of queued-or-running events.
      • -
      • Reader (:57-58): the ChannelReader<SessionScoringWorkItem> the hosted drain consumes. It is on the concrete class, not on the interface, which is why DI registers both the concrete type and the interface pointing at the same instance.
      • -
      • IsPending (:61): a dictionary containment check.
      • -
      • TryEnqueue (:64-77): the claim-then-write sequence above, writing attempt 1.
      • -
      • TryRequeue(EventIdentifierType, int) (:79-103): the drain worker's retry path. Unlike TryEnqueue it does not refuse an already-claimed event (:83-89, :95): the caller is the drain itself, which has just finished the run that held the claim, so re-taking it is the point. A full channel is handled identically, by giving the claim back (:100-102), so an event is never left marked pending by a retry that never queued.
      • -
      • MarkCompleted (:105-110): removes the claim; called from the drain after every run, successful or not.
      • +
      • What it is - the bounded in-process implementation of ISessionScoringQueue: a 16-slot channel of SessionScoringWorkItem values plus a concurrent set of the events currently claimed, registered as a singleton and drained by one hosted worker.
      • +
      • Depends on - ISessionScoringQueue, SessionScoringWorkItem (same file, :21), and SessionScoringEnqueueResult. External: System.Threading.Channels.Channel<T> and System.Collections.Concurrent.ConcurrentDictionary<TKey, TValue> (:1-3).
      • +
      • Concept introduced - claim-then-write, and refuse rather than drop. Two mechanisms interlock here and both repay a close read.
          +
        • Refuse rather than drop. The channel is created with BoundedChannelFullMode.Wait (:46) but is only ever written through the non-blocking TryWrite (:71, :97). That combination means a full queue makes TryWrite return false immediately instead of blocking the caller or evicting an older item. The doc comment states why the alternative is wrong for this workload: unlike an ephemeral live broadcast, a scoring run is expensive and the caller needs to know it was not accepted (:26-29). [Rubric §29 - Resilience & Business Continuity] assesses back-pressure policy; [Rubric §31 - Cost/FinOps] assesses spend control, since every accepted run is real money.
        • +
        • Claim first, then write. TryEnqueue adds to _pending before touching the channel (:68), so of two concurrent duplicate requests exactly one wins the TryAdd and the other is refused (:66-69). If the subsequent TryWrite fails, the claim is released again (:75) so a request that never queued cannot lock the event out. Taking the claim after a successful write would leave a window in which a second caller sees no claim and enqueues a duplicate.
        • +
        • SingleReader = true (:47) encodes that exactly one drain worker consumes the channel, so runs execute one at a time and cannot contend for the same event's rows (:30-31). SingleWriter = false (:48) admits many concurrent producers.
        • +
        +
      • +
      • Walkthrough
          +
        • Capacity = 16 (:38), with the comment stating the intent: organizers score a handful of events, so the bound exists to refuse a runaway caller, not to absorb load (:36-37). FirstAttempt = 1 (:41) is the attempt number stamped on an item queued from an original request.
        • +
        • _channel (:43-49), the bounded channel described above; _pending (:55), a ConcurrentDictionary<EventIdentifierType, byte> used as a set. Its doc comment names the important subtlety: the drain removes an entry only after the run finishes, so the dedup window covers execution too, not just the wait in the queue (:51-54).
        • +
        • Reader (:58) exposes the ChannelReader<SessionScoringWorkItem> for the hosted drain. It is on the class, not on the interface, so only a consumer holding the concrete type can read.
        • +
        • IsPending(eventId) (:61) is a dictionary lookup.
        • +
        • TryEnqueue(eventId) (:64-77) returns AlreadyPending on a lost claim (:69), Queued on a successful write (:72), or QueueFull after releasing the claim (:75-76).
        • +
        • TryRequeue(eventId, attempt) (:93-103) is the retry path and deliberately does not refuse an already-claimed event: its caller is the drain worker itself, which has just finished the run that held the claim, so re-adding is the point rather than a duplicate (:84-88). A full channel is handled exactly as on the enqueue path, by giving the claim back (:100-101).
        • +
        • MarkCompleted(eventId) (:110) clears the claim once a run has finished, successfully or not.
      • -
      • Why it's built this way: ADR-052 (background job execution) is the governing decision. It requires a bounded Channel<T> per job kind registered so that the concrete type and its interface resolve to the one instance (052-background-job-execution.md:37-41; the registration is DependencyInjection.cs:50-51, whose comment at :46-49 spells out that registering them separately would give producers a queue nobody drains), Wait plus non-blocking TryWrite for expensive work, and dedup by natural key with the claim released only at run end (052-background-job-execution.md:46-54). Its stated trade-offs apply here: the queue is in-process, so it does not survive a restart and dedup is per replica.
      • -
      • Where it's used: produced into by SessionSelectionController through the interface; drained by SessionScoringProcessor, which iterates queue.Reader.ReadAllAsync(stoppingToken) (SessionScoringProcessor.cs:107), calls MarkCompleted in a finally (:130-136), and only then decides whether to TryRequeue with item.Attempt + 1 (:143). The ordering there is load-bearing and commented as such (:132-134): completing after a requeue would clear the very claim the requeue just re-took.
      • -
      • Caveats / not-in-source: per-process dedup is not per-deployment dedup. With more than one replica, two hosts each keep their own _pending, so the cross-replica guarantee comes from an IDistributedLock taken in the drain instead (SessionScoringProcessor.cs:162-188), not from this class. Covered directly by SessionScoringQueueTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/DecisionSupport/SessionScoringQueueTests.cs:11), including the attempt plumbing (:90) and the claim release on a full queue (:109).
      • +
      • Why it's built this way - the dedup guarantee is only as good as the ordering of the claim and the write, and the two release paths exist so that a refusal never leaves a permanent phantom claim behind. Note what the class does not try to be: durable. It is in-process memory, so a replica restart loses queued items, which is why SessionScoringSweepJob exists as a slower crash-recovery backstop (SessionScoringSweepJob.cs:13-20).
      • +
      • Where it's used - registered twice on purpose: TryAddSingleton<SessionScoringQueue>() and then TryAddSingleton<ISessionScoringQueue>(sp => sp.GetRequiredService<SessionScoringQueue>()), so both registrations resolve to the one instance (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:50-55; the comment there notes that producers would otherwise write to a queue nobody drains). Producers hold the interface; SessionScoringProcessor holds the concrete class and uses Reader, MarkCompleted, and TryRequeue (SessionScoringProcessor.cs:50, :107, :135, :143). Exercised directly by SessionScoringQueueTests.
      • +
      • Caveats / not-in-source - dedup is per process. Conference runs with more than one replica, so two triggers landing on different replicas both pass this class's _pending check; the cross-replica guard is an IDistributedLock taken by the drain worker before it invokes the handler, and a host with no Redis configured falls back to per-replica exclusion again (SessionScoringProcessor.cs:162-181). Nothing here bounds how long a claim may live: MarkCompleted is the only release, so a consumer that neither completes nor crashes would hold an event's claim indefinitely.

      CalendarExportMapper

      -

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/CalendarExportMapper.cs:14 · Level 9 · class (internal static)

      +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/CalendarExportMapper.cs:14 · Level 9 · class (static, internal)

        -
      • What it is: the shared helper that decides whether a session may appear in a public calendar and maps an exportable one to an IcsEvent, converting the event-zone wall-clock times to UTC with explicit DST discipline (CalendarExportMapper.cs:8-14).
      • -
      • Depends on: Session and Event (Conference domain), SessionStatuses, IcsEvent from MMCA.Common.Shared.Calendars, and the BCL TimeZoneInfo / DateTimeOffset / CultureInfo types.
      • -
      • Concept introduced, wall-clock to UTC conversion at the layer boundary. [Rubric §8, Data Architecture] and [Rubric §16, Maintainability]. IcsCalendarBuilder is UTC-only by contract: its entry type takes StartsAtUtc / EndsAtUtc as DateTimeOffset instants and says so in its own doc comment (MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsEvent.cs:4-19). Sessions, however, store StartsAt / EndsAt as nullable DateTime (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:31, :34) that this mapper's own comment identifies as wall-clock local to the event's IANA time zone (CalendarExportMapper.cs:9-10). Bridging those two representations is this helper's whole reason to exist, and it does so with a named, testable rule rather than an inline ToUniversalTime() at each call site.
      • -
      • Concept introduced, DST edge cases made deterministic. ToUtc (:47-56) re-kinds the value as Unspecified (:49), asks TimeZoneInfo.IsInvalidTime whether it falls in a spring-forward gap and shifts it ahead one hour if so (:50-53), then builds the DateTimeOffset from the zone's offset for that instant (:55). The class comment (:10-12) states the second half of the policy: ambiguous fall-back times resolve to the standard offset, which is what GetUtcOffset returns for an ambiguous local time. Both branches are decisions, not accidents, and the comment ties them to the same rules the Engagement reminder planner applies (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/SessionReminderPlanner.cs:96-102, including the identical spring-forward shift and the same explicit note about ambiguous times) so the two features cannot disagree.
      • -
      • Concept introduced, one allow-list, no second copy. [Rubric §11, Security] and [Rubric §16, Maintainability]. IsExportable (:26-28) delegates the status question entirely to SessionStatuses's IsEligible, which permits only Accepted (case-insensitively) or an unset status and rejects every other value, known or unknown (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionStatuses.cs:47-56). The doc comment records why (:21-22): this file used to carry a second, drifting copy of the allow-list. Because Status is free text imported from Sessionize, an allow-list is the safe default and a deny-list is not, which the domain type says in its own remarks (SessionStatuses.cs:8-13).
      • -
      • Walkthrough:
          -
        • ProductId (:16-17): the RFC 5545 PRODID constant -//MMCA//AtlDevCon//EN stamped on every ADC-produced calendar.
        • -
        • IsExportable(Session) (:19-28): the single source of truth for public exportability. True only when the session has both StartsAt and EndsAt, is not a service session, and passes SessionStatuses.IsEligible (BR-49). The doc notes it is role-independent by design (:23-24): the ICS document is a public-schedule artifact, so privileged callers get the same filtered export.
        • -
        • ToIcsEvent(Session, Event, TimeZoneInfo, string?) (:30-44): builds a stable UID session-{Id}@atldevcon with string.Create(CultureInfo.InvariantCulture, ...) (:38), joins the room name and the event's VenueAddress into a comma-separated location skipping blank parts (:33-35), converts both endpoints through ToUtc (:40-41), and passes null rather than an empty string when there is no location (:43).
        • -
        • ToUtc(DateTime, TimeZoneInfo) (:46-56): the DST-aware conversion described above.
        • +
        • What it is - the shared rules of the calendar export: which sessions may appear in one, how a session becomes an IcsEvent, and how an event-local wall-clock time becomes a UTC instant.
        • +
        • Depends on - Session, Event, and SessionStatuses from the Conference domain, and IcsEvent from MMCA.Common.Shared.Calendars (:1-4). External: System.Globalization and BCL TimeZoneInfo.
        • +
        • Concept introduced - the time-zone conversion contract, and one source of truth for a visibility allow-list.
            +
          • IcsCalendarBuilder is UTC-only by contract so it can emit Z-suffixed timestamps and skip RFC 5545's VTIMEZONE machinery entirely (MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsEvent.cs:3-8). Session times, however, are wall-clock local to the event's IANA zone. Somebody has to convert, and this mapper is where that happens, with the DST discipline stated in its own summary: invalid spring-forward times shift ahead one hour, ambiguous fall-back times resolve to the standard offset (:9-12). [Rubric §15 - Best Practices & Code Quality] assesses whether a known-hard problem is handled explicitly rather than by accident: the two DST edge cases are named in the doc and the first is coded.
          • +
          • IsExportable delegates the status question wholesale to SessionStatuses.IsEligible, and the comment records why: this file used to carry a second, drifting copy of the allow-list (:21-22). [Rubric §11 - Security] assesses whether a public-visibility rule has exactly one definition; a duplicated allow-list is how a status ends up publicly visible on one surface and not another.
          • +
          +
        • +
        • Walkthrough
            +
          • ProductId = "-//MMCA//AtlDevCon//EN" (:17), the RFC 5545 PRODID stamped on every ADC-produced calendar document.
          • +
          • IsExportable(Session) (:26-28): a property pattern requiring StartsAt and EndsAt to be non-null and IsServiceSession to be false, combined with SessionStatuses.IsEligible(session.Status) (BR-49: only Accepted or an unset status, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionStatuses.cs:54-56). The summary states the deliberate design point: this is role-independent, because the ICS document is a public-schedule artifact, so privileged callers get the same filtered export (:23-24).
          • +
          • ToIcsEvent(Session, Event, TimeZoneInfo, string? roomName) (:31-44): joins the room name and the event's VenueAddress with ", ", dropping blanks (:33-35), then builds the IcsEvent with a stable uid of the form session-{id}@atldevcon (:38), the title, both converted instants, the description, and the joined location or null when empty (:43). The stable uid matters: calendar apps use it to de-duplicate re-imports (MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsEvent.cs:9), so re-downloading the file updates the entry instead of creating a second one.
          • +
          • ToUtc(DateTime localWallClock, TimeZoneInfo) (:47-56): re-kinds the input as Unspecified (:49), and if the zone reports it as an invalid time (the hour that does not exist on a spring-forward day) adds one hour (:50-53), then constructs the DateTimeOffset with that zone's offset for the adjusted instant (:55).
        • -
        • Why it's built this way: centralizing exportability and the time conversion in one internal static helper keeps both calendar handlers thin and guarantees they agree on what "public" and "UTC" mean. internal is deliberate: the rule is an Application-layer implementation detail, not part of the module's public surface.
        • -
        • Where it's used: by ExportEventCalendarHandler (ExportEventCalendarHandler.cs:52-61) and ExportSessionCalendarHandler (ExportSessionCalendarHandler.cs:27, :60-61), and by GetNowNextHandler, which filters the happening-now surface with the same IsExportable predicate (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/NowNext/GetNowNextHandler.cs:54) and reuses ToUtc for its row conversions (GetNowNextHandler.cs:87-88). The resulting IcsEvent list is handed to IcsCalendarBuilder's Build(productId, events, dtStamp) (MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsCalendarBuilder.cs:22).
        • -
        • Testing: covered directly by CalendarExportMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/ExportCalendar/CalendarExportMapperTests.cs:14), whose cases are organized around the BR-49 allow-list (:37-68, the accepted and unset cases at :37-41, the eight rejected statuses at :54-63) and the wall-clock conversion (:71-72, with the spring-forward gap at :81-82); internal members are reachable from the test project through the usual InternalsVisibleTo arrangement. [Rubric §14, Testability]: pulling the two rules out of the handlers is what makes them unit-testable without a repository.
        • +
        • Why it's built this way - both export handlers need identical filtering and identical time conversion. Putting them in an internal static class with no infrastructure dependencies means the rules are stated once, are unit-testable in isolation, and cannot diverge between the whole-schedule and single-session paths.
        • +
        • Where it's used - by ExportEventCalendarHandler (:52, :54, :61) and ExportSessionCalendarHandler (:27, :60-61); covered directly by CalendarExportMapperTests.
        • +
        • Caveats / not-in-source - the summary says ambiguous fall-back times resolve to the standard offset, but no code branches on IsAmbiguousTime: that outcome comes from TimeZoneInfo.GetUtcOffset's own behavior for an ambiguous local time (:55), not from a decision in this file. IsExportable ignores the owning event's published state entirely; that check belongs to the callers, and both perform it.

        ScoreEventSessionsHandler

        -

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/ScoreEventSessionsHandler.cs:18 · Level 9 · class

        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/ScoreEventSessionsHandler.cs:18 · Level 9 · class (sealed, partial)

          -
        • What it is: the command handler that scores every session in an event via IAiScoringService, persisting each score immediately so the dashboard can show real-time progress (ScoreEventSessionsHandler.cs:12-21). A sealed partial class, partial because its log methods are source-generated.
        • -
        • Depends on: IUnitOfWork and, through it, the Session / SessionAiScore / Speaker repositories (:27-28, :40); IAiScoringService; ILogger<ScoreEventSessionsHandler>; SpeakerInfo / SessionScoringInput / SessionScoringResult; ScoreEventSessionsResultDTO; Result / Error. Implements ICommandHandler<in TCommand, TResult> of ScoreEventSessionsCommand to Result<ScoreEventSessionsResultDTO> (:21).
        • -
        • Concept introduced, per-item save with contained failure. The loop scores one session at a time and saves it individually (:92-117); a scorer failure, a domain-factory failure, or a save exception increments failed and continues rather than aborting the batch (:72-77, :85-90, :113-117), and only an all-failed run returns Error.Failure (:127-133). This is the never-throw contract of SessionScoringResult carried up into batch orchestration. Note the catch filter excludes OperationCanceledException (:113), so host shutdown propagates instead of being counted as a failure. [Rubric §29, Resilience and Business Continuity].
        • -
        • Concept introduced, replace-in-place instead of wipe-then-rebuild. The long comment at :94-103 documents a reversal worth reading in full. The handler used to delete every existing score for the event up front, which made the dashboard reset to zero and count up. It paid for that with every existing score: N sequential paid Anthropic calls follow, and the first one to fail on an expired key or a rate limit left the sessions it never reached with no score at all. Now each session's stale row is deleted inside the same step that writes its replacement (:105-107), so a run that dies partway through has moved only the sessions it actually reached, and a session whose call failed keeps the score it already had. The delete-then-add pair is safe because the unique filtered index on SessionId (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SessionAiScoreConfiguration.cs:58-61, IsUnique().HasSoftDeleteFilter()) allows at most one live row per session either way. [Rubric §8, Data Architecture] and [Rubric §29, Resilience and Business Continuity].
        • -
        • Concept introduced, source-generated logging. Every log call is a [LoggerMessage] static partial method (:138-154), the compiler-generated high-performance logging pattern that avoids boxing and template re-parsing, and each per-session line carries a {Progress}/{Total} pair so an operator can follow a run in the log (:144-151). [Rubric §13, Observability and Operability].
        • -
        • Walkthrough:
            -
          1. Resolve the session and score repositories (:27-28), then load the event's non-service sessions with SessionSpeakers included and no tracking (:30-34); short-circuit to a zero-count success when there are none (:36-37).
          2. -
          3. Batch-load the distinct non-deleted speakers for those sessions via GetByIdsAsync and build an id lookup (:40-51), then log the run start (:53).
          4. -
          5. For each session: project its non-deleted speakers into SpeakerInfo, dropping any the lookup misses (:61-67), build a SessionScoringInput and call ScoreSessionAsync (:69-70).
          6. -
          7. On !result.Success, count a failure and continue (:72-77). Otherwise build the domain row with SessionAiScore.Create, passing the seven sub-scores, the reasoning, and aiScoringService.ModelId (:79-83); a failed Create is also just a counted failure (:85-90).
          8. -
          9. Inside a try, ExecuteDeleteAsync this one session's existing scores (accumulating into replaced), AddAsync the new row, and SaveChangesAsync (:104-108); count the success and log progress (:110-111).
          10. -
          11. After the loop, log the replacement total when non-zero (:120-123) and the completion counts (:125), then return Error.Failure with code AiScoring.AllFailed when nothing scored and something failed (:127-133), else the count DTO (:135).
          12. -
          +
        • What it is - the use case that walks an event's sessions one at a time, asks the AI scorer about each, and persists each score the moment it arrives, returning how many were scored and how many failed.
        • +
        • Depends on - IUnitOfWork, IAiScoringService, and ILogger<ScoreEventSessionsHandler> by primary constructor (:18-21); Session, Speaker, and SessionAiScore from the domain; ScoreEventSessionsResultDTO as the payload; Result and Error. It implements ICommandHandler<in TCommand, TResult> (:21).
        • +
        • Concept introduced - incremental commit, and per-item replacement instead of an up-front wipe. This handler is the clearest example in the group of a long-running use case designed around the question "what does a run that dies halfway leave behind?".
            +
          • Commit per session, not per run. SaveChangesAsync is called inside the loop (:108), so the UI can show real-time progress and a run killed at session 40 of 200 leaves 40 durable scores. That is the opposite of the usual one-transaction-per-command shape, and the summary says so explicitly (:12-16).
          • +
          • Replace in the same step that writes. The comment at :94-103 records the failure this design fixed: an up-front bulk delete of the event's scores made the dashboard reset to zero and count up, but it paid for that with every existing score on the event, so the first Anthropic call to fail on an expired key or a rate limit left the sessions it never reached with no score at all. Per-session granularity means a run that dies partway through has replaced only what it re-scored, and a session whose call failed keeps the score it already had. The delete-then-add pair is safe because the unique filtered index on SessionId in SessionAiScoreConfiguration permits at most one live row per session either way.
          • +
          • [Rubric §8 - Data Architecture] assesses write granularity and the invariants the schema itself enforces; [Rubric §29 - Resilience & Business Continuity] assesses partial-failure behavior; [Rubric §31 - Cost/FinOps] assesses paid-call economy, since every failure that forces a full re-run costs money again; [Rubric §13 - Observability & Operability] assesses run legibility, which here is six source-generated [LoggerMessage] methods carrying progress counters (:138-154).
          • +
        • -
        • Why it's built this way: saving per session gives the organizer live progress and means a mid-run failure keeps the scores already computed; the all-failed guard surfaces a misconfiguration (a missing or expired Anthropic API key, named in the error message at :131) as one actionable error rather than a silent empty result.
        • -
        • Where it's used: resolved per run from a fresh DI scope by the hosted drain SessionScoringProcessor (SessionScoringProcessor.cs:160, :190-194), never called from the controller: the endpoint only enqueues. The resulting scores feed GetSessionSelectionDashboardHandler's AI-score panel, and the drain evicts the sessions output-cache tag on both sides of the run (SessionScoringProcessor.cs:158, :208).
        • -
        • Caveats / not-in-source: the actual AI call, prompt, and model are supplied by the Infrastructure adapter AnthropicScoringService; this handler only orchestrates the port. A Result failure returned from here is deliberately not retried by the drain (SessionScoringProcessor.cs:196-205): only a thrown exception reaches the retry path, because a Result failure is a business outcome and replaying it would pay for the same refusal twice more. Covered by ScoreEventSessionsHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/ScoreEventSessionsHandlerTests.cs:13), built on the shared HandlerTestBase<T>.
        • +
        • Walkthrough
            +
          • Repositories for Session and SessionAiScore off the unit of work (:27-28).
          • +
          • Load the event's sessions with SessionSpeakers included, excluding service sessions, asTracking: false (:30-34). An event with no sessions short-circuits to a success carrying zeroes (:36-37), not a failure.
          • +
          • Batch-load speakers: flatten SessionSpeakers, drop soft-deleted links, distinct the speaker ids, one GetByIdsAsync (:40-50), then a dictionary by id (:51). This is the N+1 avoidance step: one speaker query for the whole event rather than one per session.
          • +
          • The loop (:59-118). Per session: project the non-deleted speaker links into SpeakerInfo values, skipping ids the lookup does not resolve (:61-67); build a SessionScoringInput (:69); call ScoreSessionAsync (:70).
          • +
          • Two failure gates before any write. !result.Success counts a failure and continues (:72-77). SessionAiScore.Create returning a failure (an out-of-range score, SessionAiScore.cs:134-141) also counts a failure and continues (:85-90), so a model that answers with a 12 is rejected at the domain boundary rather than persisted.
          • +
          • The write step (:92-117): ExecuteDeleteAsync for this session's existing score rows, accumulating the count into replaced (:105); AddAsync for the new entity (:107); SaveChangesAsync (:108). It is wrapped in catch (Exception ex) when (ex is not OperationCanceledException) (:113), so a save failure counts as one failed session and the loop continues, while a cancellation still propagates and unwinds the run.
          • +
          • Outcome (:120-135): log the replaced count if any, log the totals, then one policy decision. If nothing scored and something failed, return a Result failure with code AiScoring.AllFailed naming the likely cause (:127-133). Any partial success returns Result.Success with the counts, which is what keeps the drain worker from retrying a business outcome.
          • +
          +
        • +
        • Why it's built this way - the run is long, paid, and externally fallible, so the design optimizes for "every session that was successfully scored stays scored" over transactional all-or-nothing. The AllFailed failure exists so that a total washout (an expired key, a wrong endpoint) is loud rather than a silent success reporting zero.
        • +
        • Where it's used - resolved by SessionScoringProcessor inside a per-run DI scope and invoked with a ScoreEventSessionsCommand (SessionScoringProcessor.cs:190-194). No controller calls it directly; the HTTP surface only enqueues.
        • +
        • Caveats / not-in-source - ExecuteDeleteAsync is a set-based database delete that bypasses change tracking, domain events, audit stamps, and soft-delete entirely (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:298-308), so a replaced AI score is physically gone rather than flagged IsDeleted. Scoring is also strictly sequential, one Anthropic call at a time with no concurrency knob, so wall-clock time grows linearly with session count. The replaced counter is logged but is not part of ScoreEventSessionsResultDTO, which carries only SessionsScored and SessionsFailed.

        ExportEventCalendarHandler

        -

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportEventCalendarHandler.cs:15 · Level 10 · class

        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportEventCalendarHandler.cs:15 · Level 10 · class (sealed)

          -
        • What it is: the query handler that produces a whole-schedule .ics string for a published event: every exportable session becomes one VEVENT with its room in the location. Unknown or unpublished events come back as NotFound (ExportEventCalendarHandler.cs:10-15).
        • -
        • Depends on: IUnitOfWork (for the Event and Session repositories), CalendarExportMapper, IcsCalendarBuilder, and Result / Error. Implements IQueryHandler<in TQuery, TResult> of ExportEventCalendarQuery to Result<string> (:17).
        • -
        • Concept introduced, public-read handlers leak nothing. [Rubric §11, Security] assesses whether an anonymous endpoint can be used to probe for the existence of content the caller may not see. This handler collapses "missing" and "unpublished" into one answer: either condition returns Error.NotFound tagged with the handler name and Event (:28-32), so a caller cannot distinguish an unpublished event from one that does not exist. The endpoint is [AllowAnonymous] (EventsController.cs:207), which is exactly why the distinction has to disappear here rather than at the controller.
        • -
        • Concept introduced, degrade rather than fail on bad reference data. The IANA zone lookup is wrapped in a try that catches TimeZoneNotFoundException and falls back to TimeZoneInfo.Utc (:39-49). The comment states the reasoning (:46-47): EventInvariants.EnsureTimeZoneIsValid guards writes, so this can only trip on legacy rows, and an export that silently shifts to UTC is a better answer for a public endpoint than a 500. [Rubric §29, Resilience and Business Continuity] and [Rubric §15, Best Practices and Code Quality]: the fallback is the same rule GetNowNextHandler applies, and the comment says so, which is what stops the two from drifting.
        • -
        • Walkthrough:
            -
          1. Load the event with its Rooms child collection eagerly included (:25-27). The inline comment (:24) explains why the include is necessary: rooms are children of the Event aggregate and have no repository of their own.
          2. -
          3. Guard: null or !IsPublished returns NotFound (:28-32).
          4. -
          5. Load the event's sessions with GetAllAsync([], s => s.EventId == query.EventId, ...) (:34-36), then build a room-id to room-name dictionary from the already-loaded aggregate (:37), which is what lets step 5 resolve room names without a second query.
          6. -
          7. Resolve the event's IANA zone, with the UTC fallback above (:39-49).
          8. -
          9. Filter with CalendarExportMapper.IsExportable, order by StartsAt, and map each survivor to an IcsEvent, looking each session's RoomId up in the dictionary and passing null when it misses (:51-59).
          10. -
          11. Hand the entries to IcsCalendarBuilder.Build with the shared ProductId and DateTimeOffset.UtcNow as the DTSTAMP, and return the document as Result.Success (:61-62).
          12. -
          +
        • What it is - the read use case that turns a published event into one .ics document: every exportable session becomes one VEVENT with its room in the location field.
        • +
        • Depends on - IUnitOfWork by primary constructor (:15-16), CalendarExportMapper, IcsCalendarBuilder, the Event and Session aggregates, and Result / Error. It implements IQueryHandler<ExportEventCalendarQuery, Result<string>> (:17).
        • +
        • Concept introduced - the defensive read against a legacy row. Time zones are validated on write by EventInvariants.EnsureTimeZoneIsValid (named at :46), yet this handler still wraps TimeZoneInfo.FindSystemTimeZoneById in a try / catch (TimeZoneNotFoundException) and degrades to UTC (:39-49). The comment gives the reasoning: stay defensive for legacy rows, and degrade rather than fail the export, the same rule GetNowNextHandler applies. [Rubric §29 - Resilience & Business Continuity] assesses graceful degradation on a public read path: an unrecognized zone yields a schedule shifted to UTC rather than a 500 during the conference. [Rubric §11 - Security] assesses information disclosure on an anonymous endpoint: an unpublished or unknown event returns Error.NotFound (:28-32), so the response cannot distinguish "does not exist" from "not published yet".
        • +
        • Walkthrough
            +
          • Load the Event by id with nameof(Event.Rooms) included (:25-27). The comment explains the include rather than a separate repository call: rooms are children of the Event aggregate and have no repository of their own (:24), which is aggregate-boundary discipline in practice.
          • +
          • Guard: null or not IsPublished returns a NotFound error tagged with source and target (:28-32).
          • +
          • Load every session for the event with no includes (:34-36), untracked by the repository default (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:85-92), then build a room-id to room-name dictionary from the loaded rooms (:37).
          • +
          • Resolve the time zone with the UTC fallback described above (:39-49).
          • +
          • Project: filter by CalendarExportMapper.IsExportable, order by StartsAt, map each session through ToIcsEvent, passing the room name only when the session has a RoomId the dictionary resolves (:51-59).
          • +
          • Build and return: IcsCalendarBuilder.Build(CalendarExportMapper.ProductId, entries, DateTimeOffset.UtcNow) wrapped in Result.Success (:61-62).
          • +
        • -
        • Why it's built this way: rendering the schedule server-side (ADR-042 Wave 5, cited at ExportEventCalendarQuery.cs:3 and at EventsController.cs:203) keeps the RFC 5545 formatting in one shared builder in MMCA.Common.Shared instead of in a client, and lets the read be output-cached like every other anonymous Conference read. [Rubric §12, Performance and Scalability]: the handler issues two reads, one for the event with its rooms and one for the sessions, and does all filtering, ordering, and room resolution in memory over those materialized collections.
        • -
        • Where it's used: invoked by EventsController's ExportCalendarAsync (EventsController.cs:209-217), which UTF-8 encodes the string and returns it as a text/calendar file (:216).
        • -
        • Testing: ExportEventCalendarHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/ExportCalendar/ExportEventCalendarHandlerTests.cs:17).
        • -
        • Caveats / not-in-source: the session read is unfiltered at the database (:34-36 passes only the event-id predicate), so every session on the event is materialized and then narrowed in memory by IsExportable. That is cheap at conference scale but is not a database-side filter.
        • +
        • Why it's built this way - the filtering and conversion rules live in CalendarExportMapper, so this handler is only orchestration: load, guard, project, serialize. Ordering by StartsAt before serializing means the document reads chronologically for any client that renders it as a list, since IcsCalendarBuilder emits the entries in the order the caller supplies them (MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsCalendarBuilder.cs:20).
        • +
        • Where it's used - injected into EventsController as IQueryHandler<ExportEventCalendarQuery, Result<string>> (EventsController.cs:53), invoked from GET Events/{id}/ics, which returns the string as a text/calendar file named event-{id}.ics (:214-217). Covered by ExportEventCalendarHandlerTests.
        • +
        • Caveats / not-in-source - the handler loads every session on the event with no paging or cap, so document size scales with the schedule. DateTimeOffset.UtcNow is read inline rather than through an injected clock (:61), so the emitted DTSTAMP differs per call even though IcsCalendarBuilder is otherwise deterministic for identical inputs. A session whose RoomId is not among the event's loaded rooms exports silently with no room in its location.

        ExportSessionCalendarHandler

        -

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportSessionCalendarHandler.cs:16 · Level 10 · class

        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportSessionCalendarHandler.cs:16 · Level 10 · class (sealed)

          -
        • What it is: the single-session sibling of ExportEventCalendarHandler. It produces a one-VEVENT .ics document for the public add-to-calendar affordance under the same public-read rules (ExportSessionCalendarHandler.cs:10-16).
        • -
        • Depends on: IUnitOfWork, CalendarExportMapper, IcsCalendarBuilder, Result / Error. Implements IQueryHandler<in TQuery, TResult> of ExportSessionCalendarQuery to Result<string> (:18).
        • -
        • Concept introduced: none new; it applies the existence-hiding discipline ExportEventCalendarHandler introduces, with two guards instead of one, and the same UTC fallback for an unrecognized zone id (:47-57). [Rubric §11, Security]. Worth noticing the ordering: the session guard and the event guard return NotFound targeting different entities (Session at :30, Event at :40), which keeps server-side diagnostics precise while the HTTP response stays a plain 404 either way.
        • -
        • Walkthrough:
            -
          1. Load the session by id (no includes) and reject it if null or if CalendarExportMapper.IsExportable says no, returning NotFound targeting Session (:25-31). Ineligible-status, unscheduled, and service sessions are therefore invisible here.
          2. -
          3. Load the owning event with Rooms included and reject null or unpublished with NotFound targeting Event (:34-41): a perfectly exportable session on an unpublished event stays hidden. The same "rooms are children of the aggregate" comment appears at :33.
          4. -
          5. Resolve the session's room name from the loaded event's Rooms with FirstOrDefault, or null when the session has no room (:43-45), then resolve the IANA zone with the UTC fallback (:47-57).
          6. -
          7. Build a one-entry calendar via IcsCalendarBuilder.Build with the shared ProductId and DateTimeOffset.UtcNow, and return it (:59-64).
          8. -
          +
        • What it is - the read use case behind the single-session add-to-calendar button: one session, one VEVENT, one .ics document.
        • +
        • Depends on - the same set as its event-wide twin: IUnitOfWork (:16-17), CalendarExportMapper, IcsCalendarBuilder, Session, Event, Result / Error. It implements IQueryHandler<ExportSessionCalendarQuery, Result<string>> (:18).
        • +
        • Concept introduced - the two-hop public-read guard. The interesting difference from ExportEventCalendarHandler is that a session id alone does not establish public visibility: the session must itself be exportable and its owning event must be published. This handler checks both, in that order, and answers NotFound for either failure (:27-31, :37-41). The summary states the intent plainly: everything else is NotFound so the endpoint leaks nothing about unpublished content (:13-14). [Rubric §11 - Security] assesses whether an anonymous endpoint can be used to probe for hidden content: a declined session and a session inside an unpublished event are indistinguishable from one that does not exist. [Rubric §1 - SOLID] assesses reuse over duplication: the filtering rule itself is not restated here, it is the same IsExportable predicate the event-wide export applies.
        • +
        • Walkthrough
            +
          • Load the session by id, no includes, untracked (:25-26); guard on null or !CalendarExportMapper.IsExportable(session) (:27-31).
          • +
          • Load the owning Event with Rooms included via session.EventId (:33-36), with the same aggregate-child note as the twin (:33); guard on null or unpublished (:37-41).
          • +
          • Resolve the room name by scanning the event's loaded rooms for session.RoomId, null when the session has no room (:43-45).
          • +
          • Resolve the time zone with the same TimeZoneNotFoundException to UTC degradation (:47-57).
          • +
          • Build a one-element calendar with a collection expression and return it as a success (:59-64).
          • +
        • -
        • Why it's built this way: a dedicated one-session path, rather than filtering the whole-event export down to one row, keeps the add-to-calendar button cheap (two by-id reads) and makes the two leak-prevention guards explicit and individually testable (ADR-042 Wave 5, cited at ExportSessionCalendarQuery.cs:3 and at SessionsController.cs:269-270).
        • -
        • Where it's used: invoked by SessionsController's ExportCalendarAsync (SessionsController.cs:275-283). The action is [AllowAnonymous] and output-cached under SessionsCache (SessionsController.cs:272-274), which is why the handler carries the visibility rules itself rather than leaning on the controller's class-level permission attribute.
        • -
        • Testing: ExportSessionCalendarHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/ExportCalendar/ExportSessionCalendarHandlerTests.cs:16).
        • +
        • Why it's built this way - it is a deliberate near-twin of the event-wide handler rather than a shared code path with a nullable session id, because the guard order differs (session first, then event) and the room lookup is a scan rather than a dictionary. Everything genuinely shared, the export predicate, the PRODID, the IcsEvent mapping, and the time conversion, already lives once in CalendarExportMapper.
        • +
        • Where it's used - injected into SessionsController as IQueryHandler<ExportSessionCalendarQuery, Result<string>> (SessionsController.cs:50), invoked from GET Sessions/{id}/ics, which returns text/calendar named session-{id}.ics (:279-282). Covered by ExportSessionCalendarHandlerTests.
        • +
        • Caveats / not-in-source - as with the twin, DateTimeOffset.UtcNow is read inline (:62) rather than injected. The two time-zone catch blocks in this folder are identical copies with nothing shared between them, so a change to the degradation policy has to be made in both handlers.

        GetPublicSessionCategoryItemFilterQuery

        @@ -1648,7 +1887,7 @@

        GetPublicSessionCategoryItemFil
      • Concept: none new. The marker-query shape is taught under GetPublicSessionFilterQuery, the junction dimension under GetPublicSessionSpeakerFilterQuery, and the visibility rule itself lives once in PublicConferenceVisibility. The doc comment (:3-7) names the leak the query closes: a junction row is readable only when its parent session is publicly visible (the BR-49 status allow-list, inside a BR-108 published event), because otherwise the join endpoints would list the categories of a hidden session and so reveal that the session exists. [Rubric §11, Security] assesses whether an anonymous surface can be used to infer the existence of content the caller may not read; a join table is exactly the surface that gets forgotten once the parent entity is locked down.
      • Walkthrough: no members. Note what is deliberately absent: unlike GetSessionsBySpeakerFilterQuery, which carries the speaker it filters by, this query takes no argument at all, because the junction reads carry no scope to narrow to. Every line of behavior lives in GetPublicSessionCategoryItemFilterHandler.
      • Why it's built this way: the rule belongs to the parent session, not to the join row, so the query carries no arguments and the handler derives its answer from the shared resolver instead of restating BR-49 a second time.
      • -
      • Where it's used: handled by GetPublicSessionCategoryItemFilterHandler; injected into SessionCategoryItemsController as an IQueryHandler<...> constructor parameter (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:51) and constructed inside that controller's private BuildPublicSpecificationAsync helper (SessionCategoryItemsController.cs:66-75), which returns null for privileged readers (:68-69) and the specification for everyone else (:71-74). From there it reaches all four anonymous reads: the unpaged list (:90), the paged list (:120), the lookup (:148), and the by-id read (:178), where a hidden parent session turns the row into a 404 rather than a redacted record (:173-183).
      • +
      • Where it's used: handled by GetPublicSessionCategoryItemFilterHandler; injected into SessionCategoryItemsController as an IQueryHandler<...> constructor parameter (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:52) and constructed inside that controller's private BuildPublicSpecificationAsync helper (SessionCategoryItemsController.cs:67-76), which returns null for privileged readers (:69-70) and the specification for everyone else (:72-75). From there it reaches all four anonymous reads: the unpaged list (:91), the paged list (:121), the lookup (:149), and the by-id read (:179), where a hidden parent session turns the row into a 404 rather than a redacted record.

      GetPublicSessionFilterQuery

      @@ -1662,9 +1901,9 @@

      GetPublicSessionFilterQuery

      [Rubric §11, Security] assesses whether an authorization rule is enforced once, server side, on every path that can reach the data. The doc comment (:3-10) spells out the rule and why it is an allow-list rather than a deny-list: Accepted-or-unset sessions whose parent event is published, so a session in any other state (waitlisted, nominated, queued, declined, or an unrecognized Sessionize value) is invisible by default. A deny-list would silently expose the next status Sessionize invents. [Rubric §2, Design Patterns] assesses whether a recognized pattern is used where it earns its keep. Specification-as-return-value keeps the predicate composable: SessionsController ANDs it with the speaker filter rather than choosing between them (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionsController.cs:116-118).
    • Walkthrough: no members, no methods, no validation. The doc comment (:6-9) also records the physical constraint that shapes the handler: the design treats Session and Event as potentially living in different data sources, so the published-event check cannot be a navigation join and the handler delegates to the framework's cross-source specification helper.
    • -
    • Why it's built this way: an empty record still buys a distinct type, and a distinct type is what the CQRS pipeline dispatches on. new GetPublicSessionFilterQuery() selects GetPublicSessionFilterHandler through the DI registration of IQueryHandler<in TQuery, TResult>, so the visibility rule is reached the same way every other read is, with the same decorators around it.
    • +
    • Why it's built this way: an empty record still buys a distinct type, and a distinct type is what the CQRS pipeline dispatches on. new GetPublicSessionFilterQuery() selects GetPublicSessionFilterHandler through the Scrutor assembly scan the module's registration runs (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125, documented at :37), which closes IQueryHandler<in TQuery, TResult> over this query type. The visibility rule is therefore reached the same way every other read is, with the same decorators around it.
    • Where it's used: handled by GetPublicSessionFilterHandler; injected into SessionsController (SessionsController.cs:48) and constructed in its private BuildPublicSessionSpecificationAsync helper (SessionsController.cs:67-76), which short-circuits to null for privileged readers (:69-70). That helper feeds the unpaged list (:138), the paged list through BuildPagedSessionSpecificationAsync (:96, applied at :177), the lookup (:207), and the by-id read (:237), each of which is [AllowAnonymous] (:126, :152, :201, :223) under the class-level [HasPermission(ConferencePermissions.SessionsManage)] (:41).
    • -
    • Caveats / not-in-source: the doc comment states that Session lives in Cosmos DB and Event in SQL Server (:7-8). In ADC as configured today both are SQL Server entities: SessionConfiguration derives from EntityTypeConfigurationSQLServer<Session, SessionIdentifierType> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SessionConfiguration.cs:12-13) and EventConfiguration from the same SQL Server base (.../EntityConfiguration/EventConfiguration.cs:12). The cross-source treatment is therefore prophylactic against the polyglot option (ADR-018) rather than a description of the deployed engine split.
    • +
    • Caveats / not-in-source: the doc comment states that Session lives in Cosmos DB and Event in SQL Server (:7-8), and the controller repeats it (SessionsController.cs:63). In ADC as configured today both are SQL Server entities: SessionConfiguration derives from EntityTypeConfigurationSQLServer<Session, SessionIdentifierType> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SessionConfiguration.cs:12-13) and EventConfiguration from the same SQL Server base (.../EntityConfiguration/EventConfiguration.cs:11-12). The cross-source treatment is therefore prophylactic against the polyglot option (ADR-018) rather than a description of the deployed engine split.

    GetPublicSessionSpeakerFilterQuery

    @@ -1677,7 +1916,7 @@

    GetPublicSessionSpeakerFilterQueryConcept introduced: visibility propagates down a junction. The marker shape itself comes from GetPublicSessionFilterQuery; what this query adds is the observation that hiding an entity is not finished until every table pointing at it is hidden too. The doc comment (:3-7) states the leak in one sentence: without this filter the join endpoints would list the speakers of a hidden session and thereby leak its existence. [Rubric §11, Security]: the junction is an independent read surface with its own controller and its own anonymous actions, so it needs its own enforcement rather than inheriting one.
  • Walkthrough: no members. The filter is derived, not parameterized, so GetPublicSessionSpeakerFilterHandler can compute the answer from the same visible-session id list the category-item filter uses.
  • Why it's built this way: giving the join its own query type (rather than reusing GetPublicSessionFilterQuery and translating the result) keeps each handler's return type bound to the entity being filtered: this one yields Specification<SessionSpeaker, SessionSpeakerIdentifierType>, which the join controller hands straight to the generic query service with no adaptation.
  • -
  • Where it's used: handled by GetPublicSessionSpeakerFilterHandler; injected into SessionSpeakersController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:51) and constructed in that controller's BuildPublicSpecificationAsync helper (SessionSpeakersController.cs:66-75, privileged short circuit at :68-69). The helper feeds the unpaged list (:90), the paged list (:120), the lookup (:148), and the by-id read (:178), all [AllowAnonymous] (:78, :101, :142, :164) beneath the class-level [HasPermission(ConferencePermissions.SessionsManage)] (:46).
  • +
  • Where it's used: handled by GetPublicSessionSpeakerFilterHandler; injected into SessionSpeakersController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:52) and constructed in that controller's BuildPublicSpecificationAsync helper (SessionSpeakersController.cs:67-76, privileged short circuit at :69-70). The helper feeds the unpaged list (:91), the paged list (:121), the lookup (:149), and the by-id read (:179), all [AllowAnonymous] (:79, :102, :143, :165) beneath the class-level [HasPermission(ConferencePermissions.SessionsManage)] (:47).

  • GetSessionsBySpeakerFilterQuery

    @@ -1686,8 +1925,8 @@

    GetSessionsBySpeakerFilterQuery

    • What it is: the one member of this filter family that carries an argument: public sealed record GetSessionsBySpeakerFilterQuery(SpeakerIdentifierType SpeakerId); (GetSessionsBySpeakerFilterQuery.cs:11). Its answer is the specification selecting the sessions a given speaker presents.
    • -
    • Depends on: the SpeakerIdentifierType alias (System.Guid in Conference, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:18). Nothing else.
    • -
    • Concept introduced: the virtual filter key. A client filtering the paged session list by speaker sends SpeakerId as an ordinary filter key, but Session has no SpeakerId column: the link lives in the SessionSpeaker join. The doc comment (:4-9) records the resolution: the link is resolved as an ID-list projection so the resulting criteria stays engine-portable and the Session aggregate keeps a by-id boundary to Speaker, following the GetSpeakersByEventFilterQuery precedent (BR-132). +
    • Depends on: the SpeakerIdentifierType alias (System.Guid in Conference, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:19). Nothing else.
    • +
    • Concept introduced: the virtual filter key. A client filtering the paged session list by speaker sends SpeakerId as an ordinary filter key, but Session has no SpeakerId column: the link lives in the SessionSpeaker join. The doc comment (:4-9) records the resolution: the link is resolved as an ID-list projection so the resulting criteria stays engine-portable and the Session aggregate keeps a by-id boundary to Speaker, following the GetSpeakersByEventFilterQuery precedent (BR-132). [Rubric §4, DDD] assesses whether aggregates reference each other by identifier instead of by object graph; this query exists precisely so a cross-aggregate question can be answered without giving Session a navigation to Speaker. [Rubric §9, API & Contract Design]: the key is intercepted in the controller and never forwarded to the generic filter pipeline, which rejects unknown properties, and an unparseable value ignores the key rather than failing the request (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionsController.cs:82-89, :102-107).
    • Walkthrough: a single positional member, SpeakerId (:11), documented as the speaker whose sessions should match (:10). No behavior; all of it is in GetSessionsBySpeakerFilterHandler.
    • @@ -1702,11 +1941,11 @@

      GetSessionsBySpeakerFilterHandler

    • What it is: the handler for GetSessionsBySpeakerFilterQuery. It projects the session ids linked to the speaker through the SessionSpeaker join and returns a Session.Id IN (...) filter (GetSessionsBySpeakerFilterHandler.cs:21-23).
    • Depends on: IUnitOfWork (primary-constructor parameter, :22); IQueryHandler<in TQuery, TResult> closed over Result<Specification<Session, SessionIdentifierType>> (:23); InlineSpecification<TEntity, TIdentifierType> and Specification<TEntity, TIdentifierType>; Session and SessionSpeaker; Result.
    • -
    • Concept introduced: ID-list projection instead of a navigation join. Rather than expressing the rule as one LINQ expression that walks Session -> SessionSpeaker -> Speaker, the handler runs a scalar projection query first and embeds its result in the predicate. GetProjectedAsync<TResult>(select, where, asTracking, ignoreQueryFilters, cancellationToken) returns only the selected column instead of whole entities; it is declared on the IEntityQuerier<TEntity, TIdentifierType> facet (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:103, facet declared at :78) that IReadRepository<TEntity, TIdentifierType> composes (IRepository.cs:134). - [Rubric §8, Data Architecture] assesses whether query shapes survive the storage topology the architecture allows. A navigation join is only translatable when both ends sit in the same physical source; an IN over materialized ids translates on every provider, which is what ADR-018 needs. The framework enforces the same constraint mechanically for declared specification classes through the SpecificationsDoNotNavigateToOtherEntities fitness rule, which instantiates parameterless specifications and inspects their Criteria (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Specifications.cs:24, evaluation at :53). +
    • Concept introduced: ID-list projection instead of a navigation join. Rather than expressing the rule as one LINQ expression that walks Session -> SessionSpeaker -> Speaker, the handler runs a scalar projection query first and embeds its result in the predicate. GetProjectedAsync<TResult>(select, where, asTracking, ignoreQueryFilters, cancellationToken) returns only the selected column instead of whole entities; it is declared on the IEntityQuerier<TEntity, TIdentifierType> facet (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:105-110, facet declared at :80) that IReadRepository<TEntity, TIdentifierType> composes (IRepository.cs:221-222). + [Rubric §8, Data Architecture] assesses whether query shapes survive the storage topology the architecture allows. A navigation join is only translatable when both ends sit in the same physical source; an IN over materialized ids translates on every provider, which is what ADR-018 needs. The framework enforces the same constraint mechanically for declared specification classes through the SpecificationsDoNotNavigateToOtherEntities fitness rule, which instantiates parameterless specifications and inspects their Criteria (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Specifications.cs:24, evaluation at :53, failure message at :74). [Rubric §4, DDD]: the class comment (:9-14) states the second property being bought, that the Session aggregate keeps its by-id boundary to the Speaker aggregate even while answering a cross-aggregate question.
    • Walkthrough
        -
      1. Project the linked session ids (:30-37): resolve the read repository for SessionSpeaker and call GetProjectedAsync(ss => ss.SessionId, ss => ss.SpeakerId == query.SpeakerId, asTracking: false, cancellationToken: cancellationToken). asTracking: false matters because nothing here will be mutated; a tracked read would pollute the change tracker for whatever else the request does. ignoreQueryFilters is left at its false default (IRepository.cs:107), so soft-deleted join rows are excluded by the EF global query filter rather than by a predicate term written here.
      2. +
      3. Project the linked session ids (:30-37): resolve the read repository for SessionSpeaker and call GetProjectedAsync(ss => ss.SessionId, ss => ss.SpeakerId == query.SpeakerId, asTracking: false, cancellationToken: cancellationToken). Nothing here will be mutated, so the untracked read is what is wanted; asTracking is passed explicitly even though false is already its default (IRepository.cs:108), and a tracked read would pollute the change tracker for whatever else the request does. ignoreQueryFilters is left at its false default (IRepository.cs:109), so soft-deleted join rows are excluded by the EF global query filter rather than by a predicate term written here.
      4. Materialize and de-duplicate (:39-40): IReadOnlyList<SessionIdentifierType> ids = [.. sessionIds.Distinct()];. The inline comment (:39) gives the reason for materializing: the predicate must embed a stable collection EF can translate to IN. A lazily-enumerated source would be captured unevaluated and re-enumerated every time the criteria is applied.
      5. Wrap and return (:42-43): ids.Contains(s.Id) becomes an InlineSpecification<TEntity, TIdentifierType> returned inside Result.Success. There is no failure path: the handler cannot fail on its own terms.
      @@ -1724,9 +1963,9 @@

      GetPublicSessionFilterHandler

      • What it is: the handler for GetPublicSessionFilterQuery and the definitive statement of BR-132 / BR-49 in code. It resolves the published Event ids and returns a Session.EventId IN (...) filter ANDed with the status allow-list (GetPublicSessionFilterHandler.cs:20-22).
      • Depends on: IUnitOfWork (:21); CrossSourceSpecification; PublicSessionStatusSpecification (for its static StatusCriteria); Session and Event; Specification<TEntity, TIdentifierType>; Result; IQueryHandler<in TQuery, TResult> (:22).
      • -
      • Concept introduced: composing a filter across two data sources. CrossSourceSpecification exists because a predicate like s => s.Event.IsPublished is not translatable when principal and dependent may live in different physical sources (MMCA.Common/Source/Core/MMCA.Common.Application/Specifications/CrossSourceSpecification.cs:9-21). BuildAsync runs a scalar projection against the principal's own source (:54-57), materializes the keys once (:59-60), then builds Enumerable.Contains(keys, dependent.ForeignKey) as an expression tree and ANDs the optional local predicate onto it after rebinding its parameter, deliberately avoiding Expression.Invoke so the combined predicate stays translatable on every provider (:66-91, the rebinding at :85-87). +
      • Concept introduced: composing a filter across two data sources. CrossSourceSpecification exists because a predicate like s => s.Event.IsPublished is not translatable when principal and dependent may live in different physical sources (MMCA.Common/Source/Core/MMCA.Common.Application/Specifications/CrossSourceSpecification.cs:9-21). BuildAsync runs a scalar projection against the principal's own source (:54-57), materializes the keys once (:59-60), then builds Enumerable.Contains(keys, dependent.ForeignKey) as an expression tree (:74-79) and ANDs the optional local predicate onto it after rebinding its parameter, deliberately avoiding Expression.Invoke so the combined predicate stays translatable on every provider (:66-91, the rebinding at :86-87). [Rubric §3, Clean Architecture] assesses whether infrastructure concerns stay out of the application layer. The handler expresses a business rule and hands the storage problem to a framework helper; it names no provider, no table, and no SQL. - [Rubric §16, Maintainability]: the status leg is not written here. It is PublicSessionStatusSpecification's StatusCriteria (:34), the same static expression the visible-session id resolver passes (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:67), so the session list and every derived read cannot drift apart. That expression is s => s.Status == null || s.Status == SessionStatuses.Accepted and compares against SessionStatuses.Accepted rather than calling SessionStatuses's IsEligible, because compiled code does not translate to SQL (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/Specifications/PublicSessionStatusSpecification.cs:12-19, :23-24).
      • + [Rubric §16, Maintainability]: the status leg is not written here. It is PublicSessionStatusSpecification's StatusCriteria (:34), the same static expression the visible-session id resolver passes (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:68), so the session list and every derived read cannot drift apart. That expression is s => s.Status == null || s.Status == SessionStatuses.Accepted and compares against SessionStatuses.Accepted rather than calling SessionStatuses's IsEligible, because compiled code does not translate to SQL (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/Specifications/PublicSessionStatusSpecification.cs:12-19, :23-24).
      • Walkthrough
        1. Build the cross-source specification (:29-36): one call to CrossSourceSpecification.BuildAsync<Session, SessionIdentifierType, Event, EventIdentifierType> with principalPredicate: e => e.IsPublished (BR-108), dependentForeignKey: s => s.EventId, and localPredicate: PublicSessionStatusSpecification.StatusCriteria (BR-49). The type arguments pin the direction of the relationship: Session is the dependent being filtered, Event the principal being resolved.
        2. Return (:38): Result.Success(specification). As with its siblings there is no failure branch.
        3. @@ -1734,7 +1973,7 @@

          GetPublicSessionFilterHandler

        4. Why it's built this way: the whole handler is two statements because the reusable mechanics were pushed into the framework. What stays local is the pair of business predicates, which is the part that can change. The rule is enforced at the application layer rather than in the controller so that every caller of the query gets it, including the ones added later.
        5. Where it's used: SessionsController via BuildPublicSessionSpecificationAsync (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionsController.cs:67-76), which reaches the unpaged list, the paged list, the lookup, and the by-id read. The lookup action is worth reading: it forwards specification.Criteria as the lookup filter (SessionsController.cs:211-215) precisely because a lookup endpoint would otherwise be a side channel listing the sessions the list and detail endpoints already hide (:194-199).
        6. -
        7. Testing: GetPublicSessionFilterHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/GetPublicSessionFilter/GetPublicSessionFilterHandlerTests.cs:12), seven test methods asserted against the produced criteria rather than against the handler's internals: the success shape (:72), the public statuses matching for a published event (:84, a theory over Accepted and null), the non-public statuses excluded (:101, a theory over Waitlisted, AcceptQueue, Nominated, DeclineQueue, Declined, and two unrecognized values), a session of an unpublished event excluded (:111), no published events matching nothing (:121), the principal predicate selecting only published events (:139), and the cancellation token reaching the event query (:162).
        8. +
        9. Testing: GetPublicSessionFilterHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/GetPublicSessionFilter/GetPublicSessionFilterHandlerTests.cs:12), seven test methods asserted against the produced criteria rather than against the handler's internals: the success shape (:72), the public statuses matching for a published event (:84, a theory over Accepted and null), the non-public statuses excluded (:101, a theory over the non-eligible Sessionize values), a session of an unpublished event excluded (:111), no published events matching nothing (:121), the principal predicate selecting only published events (:139), and the cancellation token reaching the event query (:162).
        10. Caveats / not-in-source: CrossSourceSpecification materializes the matching principal keys into the predicate, and its own note (CrossSourceSpecification.cs:17-20) scopes the technique to small or bounded principal sets, the "published events" shape. Nothing in this handler bounds that set; it is bounded in practice by how many events a conference publishes. Note also that the controller maps a failed Result to null, meaning no filter (SessionsController.cs:75), which would widen the read rather than narrow it; nothing in this handler can produce that failure today, so the exposure is latent rather than live.

      @@ -1747,14 +1986,14 @@

      GetPublicSessionCategoryItemF
    • Depends on: IUnitOfWork (:17, injected only to hand on to the resolver); PublicConferenceVisibility; InlineSpecification<TEntity, TIdentifierType>; SessionCategoryItem; Result. Implements IQueryHandler<in TQuery, TResult> to Result<Specification<SessionCategoryItem, SessionCategoryItemIdentifierType>> (:18).
    • Concept: none new; the derived junction filter is taught under GetPublicSessionSpeakerFilterHandler, and this is the category-assignment instance of the same shape. It calls the identical resolver method its speaker-side twin does and restates nothing of the rule. [Rubric §16, Maintainability]: the junction cannot drift away from the entity whose visibility it follows, because it holds no copy of that entity's rule. [Rubric §8, Data Architecture]: the answer arrives as an id list turned into Contains, not a navigation join, so the criteria stays translatable on any provider (ADR-018).
    • Walkthrough
        -
      1. Resolve the visible session ids (:25-27): PublicConferenceVisibility.GetVisibleSessionIdsAsync(unitOfWork, cancellationToken). Inside the resolver that is the same CrossSourceSpecification.BuildAsync call GetPublicSessionFilterHandler makes, over Session and Event with principalPredicate: e => e.IsPublished, dependentForeignKey: s => s.EventId, and localPredicate: PublicSessionStatusSpecification.StatusCriteria (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:62-69), followed by a scalar projection of the session ids that combined criteria matches (PublicConferenceVisibility.cs:71-74). The comment there (:60-61) states the property being bought: the same helper and the same criteria the public-session read filter uses, so a session hidden from the session list can never stay reachable through a junction read.
      2. +
      3. Resolve the visible session ids (:25-27): PublicConferenceVisibility.GetVisibleSessionIdsAsync(unitOfWork, cancellationToken). Inside the resolver that is the same CrossSourceSpecification.BuildAsync call GetPublicSessionFilterHandler makes, over Session and Event with principalPredicate: e => e.IsPublished, dependentForeignKey: s => s.EventId, and localPredicate: PublicSessionStatusSpecification.StatusCriteria (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:63-70). The resolver then hands that specification straight to the repository's spec-taking projection overload, ListAsync(specification, s => s.Id, cancellationToken) (PublicConferenceVisibility.cs:77-79; the overload is declared at MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:170-173 and projects server side, after the specification's ordering and paging, :155-164). The comment above the call explains why passing a specification is enough (PublicConferenceVisibility.cs:72-74): a plain specification contributes its Criteria and nothing else, so this is the untracked, soft-delete-filtered read the explicit GetProjectedAsync arguments would otherwise have to spell out. The comment at :61-62 states the property being bought: the same helper and the same criteria the public-session read filter uses, so a session hidden from the session list can never stay reachable through a junction read.
      4. Wrap and return (:29-31): sci => sessionIds.Contains(sci.SessionId) inside an InlineSpecification<TEntity, TIdentifierType>, returned as Result.Success. No failure path.
    • Why it's built this way: the doc comment states the intent directly (:10-15): a junction row follows the visibility of its parent session (BR-49). Deriving that answer instead of copying the session rule keeps one definition of "publicly visible session" behind the session list, the session-speaker join, and this category-assignment join.
    • -
    • Where it's used: SessionCategoryItemsController's BuildPublicSpecificationAsync (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:66-75) is the only consumer, and from there it reaches the unpaged list (:90), paged list (:120), lookup (:148), and by-id (:178) reads. Note that the class-level [HasPermission(ConferencePermissions.SessionsManage)] (:46) is overridden per action by [AllowAnonymous] (:78, :101, :142, :164), which is exactly why the handler has to carry the visibility rule itself.
    • -
    • Testing: GetPublicSessionCategoryItemFilterHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/GetPublicSessionCategoryItemFilter/GetPublicSessionCategoryItemFilterHandlerTests.cs:17), four tests on the shared HandlerTestBase<T>: the success shape (:55), a row whose parent session is visible (:64), a row whose parent session is hidden (:74), and a world with no visible sessions at all (:84). One fixture detail is a property of the entity rather than of the test: SessionCategoryItem.SessionId is get-only and written by EF (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionCategoryItem.cs:23), so a row built in memory carries the default id, and the fixture uses default as its row session id (:19-21).
    • -
    • Caveats / not-in-source: as on the other junction reads, the controller maps a failed Result to null, meaning no filter (SessionCategoryItemsController.cs:74), which would widen the read rather than narrow it; nothing in this handler can produce that failure today, so the exposure is latent rather than live. The resolved id list is also materialized into the predicate, so the IN list grows with the number of publicly visible sessions across every published event, and nothing in this file bounds it.
    • +
    • Where it's used: SessionCategoryItemsController's BuildPublicSpecificationAsync (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:67-76) is the only consumer, and from there it reaches the unpaged list (:91), paged list (:121), lookup (:149), and by-id (:179) reads. Note that the class-level [HasPermission(ConferencePermissions.SessionsManage)] (:47) is overridden per action by [AllowAnonymous] (:79, :102, :143, :165), which is exactly why the handler has to carry the visibility rule itself.
    • +
    • Testing: GetPublicSessionCategoryItemFilterHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/GetPublicSessionCategoryItemFilter/GetPublicSessionCategoryItemFilterHandlerTests.cs:18), four tests on the shared HandlerTestBase<T>: the success shape (:56), a row whose parent session is visible (:65), a row whose parent session is hidden (:75), and a world with no visible sessions at all (:85). The fixture mocks the two reads the resolver actually performs: GetProjectedAsync on the Event repository (:29-36) and the spec-taking ListAsync on the Session repository (:46-51), whose comment notes that the resolver hands the session read a specification rather than an unwrapped predicate. One further detail is a property of the entity rather than of the test: SessionCategoryItem.SessionId is get-only and written by EF (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionCategoryItem.cs:23), so a row built in memory carries the default id, and the fixture uses default as its row session id (:20-21).
    • +
    • Caveats / not-in-source: as on the other junction reads, the controller maps a failed Result to null, meaning no filter (SessionCategoryItemsController.cs:75), which would widen the read rather than narrow it; nothing in this handler can produce that failure today, so the exposure is latent rather than live. The resolved id list is also materialized into the predicate, so the IN list grows with the number of publicly visible sessions across every published event, and nothing in this file bounds it.

    GetPublicSessionSpeakerFilterHandler

    @@ -1764,18 +2003,18 @@

    GetPublicSessionSpeakerFilterHandl
    • What it is: the handler for GetPublicSessionSpeakerFilterQuery. It resolves the visible session ids and returns a SessionSpeaker.SessionId IN (...) specification, so a join row is readable exactly when its parent session is (GetPublicSessionSpeakerFilterHandler.cs:15-17).
    • Depends on: IUnitOfWork (:16); PublicConferenceVisibility; InlineSpecification<TEntity, TIdentifierType>; SessionSpeaker; Result; IQueryHandler<in TQuery, TResult> (:17).
    • -
    • Concept introduced: the derived junction filter. The rule this handler enforces is not its own. It is one call to a shared resolver, PublicConferenceVisibility.GetVisibleSessionIdsAsync (:24-26), followed by a Contains over the returned ids. Nothing about "Accepted or unset status, inside a published event" appears in this file, which is the point: the definition lives once (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:56-77) and every read that must respect it derives from that one definition. - [Rubric §11, Security] assesses whether a visibility rule holds on every surface that can reach the protected data. The remarks on the resolver (PublicConferenceVisibility.cs:10-27) state the invariant: one definition of "publicly visible" backs the session, speaker, and junction read filters, so closing a leak in one place closes it everywhere. +
    • Concept introduced: the derived junction filter. The rule this handler enforces is not its own. It is one call to a shared resolver, PublicConferenceVisibility.GetVisibleSessionIdsAsync (:24-26), followed by a Contains over the returned ids. Nothing about "Accepted or unset status, inside a published event" appears in this file, which is the point: the definition lives once (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:57-82) and every read that must respect it derives from that one definition. + [Rubric §11, Security] assesses whether a visibility rule holds on every surface that can reach the protected data. The summary and remarks on the resolver (PublicConferenceVisibility.cs:10-27) state the invariant: one definition of "publicly visible" backs the session, speaker, and junction read filters, so closing a leak in one place closes it everywhere, and everything is expressed as scalar id projections rather than navigation joins so the criteria stay translatable on any engine. [Rubric §1, SOLID]: this is the single-responsibility split in miniature. The resolver decides who is visible; the handler decides how that answer is shaped for one entity.
    • Walkthrough
        -
      1. Resolve (:24-26): GetVisibleSessionIdsAsync(unitOfWork, cancellationToken), which internally builds the cross-source session specification and projects the ids matching it (PublicConferenceVisibility.cs:62-76).
      2. +
      3. Resolve (:24-26): GetVisibleSessionIdsAsync(unitOfWork, cancellationToken), which internally builds the cross-source session specification (PublicConferenceVisibility.cs:63-70) and projects the ids matching it through the spec-taking ListAsync overload (:75-79).
      4. Wrap and return (:28-30): ss => sessionIds.Contains(ss.SessionId) inside an InlineSpecification<TEntity, TIdentifierType>, returned as Result.Success. The handler has no failure branch and no conditional logic at all.
    • Why it's built this way: the alternative, restating the status and published-event rules against navigation properties, would both duplicate the rule and produce criteria a non-relational provider could not translate. Two round trips (ids, then the filtered read) buy one rule and portable criteria.
    • -
    • Where it's used: SessionSpeakersController's BuildPublicSpecificationAsync (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:66-75) is the only consumer, feeding the unpaged list (:90), paged list (:120), lookup (:148), and by-id (:178) reads, each [AllowAnonymous] (:78, :101, :142, :164) under the class-level permission requirement (:46).
    • -
    • Testing: GetPublicSessionSpeakerFilterHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/GetPublicSessionSpeakerFilter/GetPublicSessionSpeakerFilterHandlerTests.cs:17), four tests mirroring the category-item twin one for one: the success shape (:55), a row of a visible session matching (:64), a row of a hidden session excluded (:74), and no visible sessions matching nothing (:84).
    • -
    • Caveats / not-in-source: identical to the category-item handler. A failed Result becomes null in the controller, meaning no filter (SessionSpeakersController.cs:74), and the materialized id list is unbounded in this file.
    • +
    • Where it's used: SessionSpeakersController's BuildPublicSpecificationAsync (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:67-76) is the only consumer, feeding the unpaged list (:91), paged list (:121), lookup (:149), and by-id (:179) reads, each [AllowAnonymous] (:79, :102, :143, :165) under the class-level permission requirement (:47).
    • +
    • Testing: GetPublicSessionSpeakerFilterHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/GetPublicSessionSpeakerFilter/GetPublicSessionSpeakerFilterHandlerTests.cs:18), four tests mirroring the category-item twin one for one: the success shape (:56), a row of a visible session matching (:65), a row of a hidden session excluded (:75), and no visible sessions matching nothing (:85).
    • +
    • Caveats / not-in-source: identical to the category-item handler. A failed Result becomes null in the controller, meaning no filter (SessionSpeakersController.cs:75), and the materialized id list is unbounded in this file.

    GetPublicSpeakerCategoryItemFilterQuery

    @@ -1785,9 +2024,9 @@

    GetPublicSpeakerCategoryItemFil
  • What it is: the parameterless query that asks for the read filter applied to the speaker-to-category-item junction. It carries no data at all; it exists purely as the typed key that resolves the matching handler out of DI.
  • Depends on: nothing first-party. It is a bare public sealed record with no positional parameters and no members (GetPublicSpeakerCategoryItemFilterQuery.cs:8).
  • Concept introduced: the filter query as a DI lookup token. Most CQRS messages in this module carry a payload. This one carries none, because the answer depends only on ambient data (which events are published, which sessions are on the BR-49 allow-list) and not on anything the caller can supply. Declaring it as a type anyway is what lets a controller inject IQueryHandler<GetPublicSpeakerCategoryItemFilterQuery, ...> and get the visibility rule through the same pipeline as every other read, rather than calling a static helper directly from the API layer. [Rubric §6, CQRS & Event-Driven] assesses whether reads are expressed as explicit, individually resolvable messages: even a zero-argument rule gets its own query type here. [Rubric §11, Security] assesses where authorization data is decided: the rule is derived server-side from published state, so there is no request field an anonymous caller could tamper with.
  • -
  • Walkthrough: one line of code. The XML doc above it (:3-7) is the load-bearing part: it records why the junction needs its own filter at all, namely that without it the join endpoints would list the categories of a hidden speaker (including the BR-66 locality assignments) and leak that speaker's existence even though the speaker row itself is filtered out.
  • +
  • Walkthrough: one line of code (:8). The XML doc above it (:3-7) is the load-bearing part: it records why the junction needs its own filter at all, namely that without it the join endpoints would list the categories of a hidden speaker (including the BR-66 locality assignments) and leak that speaker's existence even though the speaker row itself is filtered out.
  • Why it's built this way: a record with no parameters still gets value equality and a compiler-generated ToString, and costs nothing to allocate per request. Keeping it distinct from GetPublicSpeakerFilterQuery means the two filters can diverge later (the junction read has no event context to scope by) without either handler growing a mode flag.
  • -
  • Where it's used: constructed by SpeakerCategoryItemsController in its private BuildPublicSpecificationAsync helper (MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:71) and answered by GetPublicSpeakerCategoryItemFilterHandler.
  • +
  • Where it's used: constructed by SpeakerCategoryItemsController in its private BuildPublicSpecificationAsync helper (MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:72) and answered by GetPublicSpeakerCategoryItemFilterHandler.
  • GetPublicSpeakerFilterQuery

    @@ -1795,9 +2034,9 @@

    GetPublicSpeakerFilterQuery

    • What it is: the query that asks for the public-speaker read filter (BR-239), optionally narrowed to one event. It is a single-parameter record whose only field is a nullable event id that defaults to null.
    • -
    • Depends on: the module alias EventIdentifierType (int for Conference, declared in MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7). No other first-party types.
    • -
    • Concept introduced: the optional scope parameter. The same rule has to answer two different questions: "which speakers are public anywhere" and "which speakers are public on this event". Rather than two query types, one nullable parameter distinguishes them, and the default = null (GetPublicSpeakerFilterQuery.cs:20) means the un-scoped call site reads as new GetPublicSpeakerFilterQuery(). [Rubric §9, API & Contract Design] assesses contract expressiveness: the nullable is documented per-parameter (:15-19) as "the paged list has one, everything else passes none", so the two modes are part of the published contract rather than folklore. [Rubric §11, Security] as with the junction query: the id only narrows the rule, it can never widen it, because an unpublished or unknown scoped event resolves to an empty visible set (MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:109-115).
    • -
    • Walkthrough: public sealed record GetPublicSpeakerFilterQuery(EventIdentifierType? EventId = null) (:20). The <remarks> block (:9-14) explains the shape the handler will return: Speaker carries no status or event column of its own, so the rule cannot be expressed as a property comparison and is instead resolved into an id list and returned as a Speaker.Id IN (...) criteria, following the BR-132 precedent. That keeps the criteria free of navigation joins, so it stays translatable on any engine and Speaker keeps its by-id boundary to the Session and Event aggregates.
    • +
    • Depends on: the module alias EventIdentifierType (int for Conference, declared in MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8). No other first-party types.
    • +
    • Concept introduced: the optional scope parameter. The same rule has to answer two different questions: "which speakers are public anywhere" and "which speakers are public on this event". Rather than two query types, one nullable parameter distinguishes them, and the default = null (GetPublicSpeakerFilterQuery.cs:20) means the un-scoped call site reads as new GetPublicSpeakerFilterQuery(). [Rubric §9, API & Contract Design] assesses contract expressiveness: the nullable is documented per-parameter (:15-19) as "the paged list has one, everything else passes none", so the two modes are part of the published contract rather than folklore. [Rubric §11, Security] as with the junction query: the id only narrows the rule, it can never widen it, because an unpublished or unknown scoped event resolves to an empty visible set (MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:114-120).
    • +
    • Walkthrough: public sealed record GetPublicSpeakerFilterQuery(EventIdentifierType? EventId = null) (:20). The <remarks> block (:9-14) explains the shape the handler will return: Speaker carries no status or event column of its own, so the rule cannot be expressed as a property comparison and is instead resolved into an id list and returned as a Speaker.Id IN (...) criteria, following the BR-132 precedent. That keeps the criteria free of navigation joins, so it stays translatable on any engine and Speaker keeps its by-id boundary to the Session and Event aggregates.
    • Why it's built this way: making the scope optional rather than required is what lets one handler serve the paged list, the lookup, GetById, and the junction reads. The alternative (a required id plus a sentinel) would have pushed the "no context" case into every caller.
    • Where it's used: constructed by SpeakersController in BuildPublicSpeakerSpecificationAsync (MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:89) and answered by GetPublicSpeakerFilterHandler.
    @@ -1807,10 +2046,10 @@

    GetSessionBookmarkCountQuery

    • What it is: the query behind the speaker dashboard's "how many people bookmarked my talk" number (BR-210). It names both the session being counted and the speaker asking, so the handler can authorize the read.
    • -
    • Depends on: the module aliases SpeakerIdentifierType (System.Guid) and SessionIdentifierType (int), declared in MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:18 and :14.
    • +
    • Depends on: the module aliases SpeakerIdentifierType (System.Guid) and SessionIdentifierType (int), declared in MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:19 and :15.
    • Concept introduced: carrying the subject alongside the object. The count itself only needs a session id. The speaker id is in the message because the authorization rule is "you may see the count for a session you are assigned to", and that rule is enforced by the handler rather than by a route filter. Putting the speaker in the query keeps the authorization input explicit and testable instead of hidden in ambient request state. [Rubric §11, Security] assesses whether object-level authorization is enforced next to the data access; here the pairing in the query is what makes that possible. [Rubric §6, CQRS & Event-Driven]: a read that spans two bounded contexts still travels as one ordinary query.
    • Walkthrough: public sealed record GetSessionBookmarkCountQuery(SpeakerIdentifierType SpeakerId, SessionIdentifierType SessionId) (:6), with per-parameter docs naming SpeakerId as "the speaker requesting the count" (:4).
    • -
    • Where it's used: constructed by SpeakersController at MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:430 for the GET /Speakers/{speakerId}/sessions/{sessionId}/bookmarks/count endpoint (:421), and handled by GetSessionBookmarkCountHandler. Its batch sibling is GetSessionBookmarkCountsQuery, which the dashboard uses to avoid a per-session fan-out (:441-449).
    • +
    • Where it's used: constructed by SpeakersController at MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:438 for the GET /Speakers/{speakerId}/sessions/{sessionId}/bookmarks/count endpoint (:429), and handled by GetSessionBookmarkCountHandler. Its batch sibling is GetSessionBookmarkCountsQuery, which the dashboard uses to avoid a per-session fan-out (:449-459).

    SessionEventIdRules<T>

    @@ -1844,7 +2083,7 @@

    SessionDescriptionRules<T>

  • What it is: the rule fragment bounding a session's optional description to 4000 characters, the widest text bound in the session family.
  • Depends on: OptionalStringRules<T> (:38) and SessionInvariants (:41).
  • Concept reinforced: identical shape to SessionAccessibilityInfoRules<T>: nullable field, MaximumLength only, no NotEmpty.
  • -
  • Walkthrough: the constructor (:40) forwards base(selector, "Session Description", SessionInvariants.DescriptionMaxLength) (:41); DescriptionMaxLength is 4000 (SessionInvariants.cs:16).
  • +
  • Walkthrough: the constructor (:40) forwards base(selector, "Session Description", SessionInvariants.DescriptionMaxLength) (:41); DescriptionMaxLength is 4000 (SessionInvariants.cs:16), the same constant the aggregate's own update guard cites (SessionInvariants.cs:73).
  • Where it's used: Included by SessionCreateRequestValidator (:13) and SessionUpdateRequestValidator (:12).
  • SessionLiveUrlRules<T>

    @@ -1901,7 +2140,7 @@

    SessionTitleRules<T>

  • Depends on: RequiredStringRules<T> (its base class, SessionValidationRules.cs:14) and SessionInvariants (:17).
  • Concept reinforced: the parameterized fragment from SessionEventIdRules<T>, specialized against the required framework base rather than the optional one. RequiredStringRules<T> chains NotEmpty() then MaximumLength(maxLength) with generated messages built from the field label (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:15-18), so choosing the base is how a fragment declares required-versus-optional. [Rubric §1, SOLID] and [Rubric §24, Forms/Validation/UX Safety].
  • Walkthrough: sealed class SessionTitleRules<T> : RequiredStringRules<T> (:13-14); the constructor (:16) forwards base(selector, "Session Title", SessionInvariants.TitleMaxLength) (:17). TitleMaxLength is 500 (SessionInvariants.cs:13), the same constant the aggregate's own title guard cites (SessionInvariants.cs:49).
  • -
  • Caveats / not-in-source: unlike SessionEventIdRules<T>, none of the seven string fragments in this file set WithErrorCode, because neither framework base does (CommonValidationRules.cs:16-18,28-29). Their failures therefore surface with generated messages and FluentValidation's default codes, not the stable Session.<Field>.<Reason> codes the domain invariants use.
  • +
  • Caveats / not-in-source: unlike SessionEventIdRules<T>, none of the seven string fragments in this file set WithErrorCode, because neither framework base does (CommonValidationRules.cs:16-18,28-29). Their failures therefore surface with generated messages and FluentValidation's default codes, not the stable Session.<Field>.<Reason> codes the domain invariants use (SessionInvariants.cs:49,73-78).
  • Where it's used: Included by SessionCreateRequestValidator (:11) and SessionUpdateRequestValidator (:11).
  • GetSessionBookmarkCountHandler

    @@ -1911,10 +2150,11 @@

    GetSessionBookmarkCountHandler

    • What it is: the handler for GetSessionBookmarkCountQuery. It verifies the asking speaker is actually assigned to the session, then asks the Engagement module for the count. Conference never reads Engagement's bookmark table itself.
    • Depends on: IQueryHandler<in TQuery, TResult>, IUnitOfWork, IBookmarkCountService (Engagement's shared contract), Session and its SessionSpeaker children, plus Result and Error.
    • -
    • Concept introduced: the cross-context read through an owned interface. Bookmarks belong to Engagement's bounded context and live in Engagement's own database (database-per-service, ADR-006), so there is no join available and no table Conference is allowed to touch. Instead Engagement publishes a one-method contract in its Shared project and Conference depends on that abstraction (GetSessionBookmarkCountHandler.cs:16). In the monolith the implementation is in-process; in the extracted topology the same interface is satisfied by a gRPC client the Conference service host registers with services.AddEngagementBookmarkCountClient() (MMCA.ADC.Conference.Service/Program.cs:329, which replaces any prior registration), and by DisabledBookmarkCountService when the Engagement module is switched off (MMCA.ADC.Engagement.API/EngagementModule.cs:30-32, its RegisterDisabledStubs hook). The handler is unchanged in all three cases. [Rubric §7, Microservices Readiness] assesses whether cross-module calls go through abstractions that can be re-pointed at a transport; this is the pattern in one file. [Rubric §3, Clean Architecture]: the Application layer names an interface and never a transport. [Rubric §11, Security]: the ownership check sits in the handler, immediately beside the data it guards.
    • -
    • Walkthrough: the primary constructor (:14-16) injects IUnitOfWork and IBookmarkCountService. HandleAsync (:19) resolves the session repository off the unit of work (:23, never by constructor-injecting IRepository<,> directly) and loads the session by id with its SessionSpeakers included and asTracking: false (:24-28), since this is a pure read. A missing session returns Error.NotFound stamped with the handler name and target (:30). The authorization step (:33) then requires at least one non-soft-deleted SessionSpeaker whose SpeakerId matches the caller, and otherwise returns Error.Forbidden coded Speaker.NotAssigned (:35-40). Only after both checks does it call bookmarkCountService.GetBookmarkCountForSessionAsync(query.SessionId, cancellationToken) (:43) and wrap the integer in Result.Success (:45).
    • -
    • Why it's built this way: ordering matters. The not-found check precedes the assignment check, and the assignment check precedes the cross-context call, so an unauthorized caller never causes a gRPC hop and never learns anything beyond "forbidden". Distinguishing NotFound from Forbidden is a deliberate choice here (the session's existence is public information on this module's read surface), which is the opposite of the Bookmarks delete endpoint, where Engagement returns 404 rather than 403 to avoid leaking existence.
    • -
    • Where it's used: injected into SpeakersController (MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:52) and invoked by the GET /Speakers/{speakerId}/sessions/{sessionId}/bookmarks/count endpoint (:421-436), which is [AllowAnonymous] and served through the BookmarkCountsCache output-cache policy (:422-423). The batch equivalent used by the speaker dashboard is GetSessionBookmarkCountsHandler (:441-449).
    • +
    • Concept introduced: the cross-context read through an owned interface. Bookmarks belong to Engagement's bounded context and live in Engagement's own database (database-per-service, ADR-006), so there is no join available and no table Conference is allowed to touch. Instead Engagement publishes a one-method contract in its Shared project and Conference depends on that abstraction (GetSessionBookmarkCountHandler.cs:16). In the monolith the implementation is in-process; in the extracted topology the same interface is satisfied by a gRPC client the Conference service host registers with services.AddEngagementBookmarkCountClient() (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:350, which replaces any prior registration, :347-349), and by DisabledBookmarkCountService when the Engagement module is switched off (MMCA.ADC.Engagement.API/EngagementModule.cs:30-32, its RegisterDisabledStubs hook). The handler is unchanged in all three cases. [Rubric §7, Microservices Readiness] assesses whether cross-module calls go through abstractions that can be re-pointed at a transport; this is that pattern in one file, and the gRPC path is ADR-007. [Rubric §3, Clean Architecture]: the Application layer names an interface and never a transport. [Rubric §11, Security]: the ownership check sits in the handler, immediately beside the data it guards.
    • +
    • Walkthrough: the primary constructor (:14-16) injects IUnitOfWork and IBookmarkCountService. HandleAsync (:19-21) resolves the session repository off the unit of work (:23, never by constructor-injecting IRepository<,> directly) and loads the session by id with its SessionSpeakers included and asTracking: false (:24-28), since this is a pure read. A missing session returns Error.NotFound stamped with the handler name and target (:30). The authorization step (:33) then requires at least one non-soft-deleted SessionSpeaker whose SpeakerId matches the caller, and otherwise returns Error.Forbidden coded Speaker.NotAssigned (:35-40). Only after both checks does it call bookmarkCountService.GetBookmarkCountForSessionAsync(query.SessionId, cancellationToken) (:43) and wrap the integer in Result.Success (:45).
    • +
    • Why it's built this way: ordering matters. The not-found check precedes the assignment check, and the assignment check precedes the cross-context call, so an unauthorized caller never causes a gRPC hop and never learns anything beyond "forbidden". Distinguishing NotFound from Forbidden is a deliberate choice here: the session's existence is public information on this module's read surface, which is the opposite of the Bookmarks delete endpoint in Engagement, where a 404 is returned rather than a 403 to avoid leaking existence.
    • +
    • Testing: GetSessionBookmarkCountHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/GetSessionBookmarkCountHandlerTests.cs:11) covers exactly the three outcomes above: a missing session (:25), an unassigned speaker (:45), and the assigned happy path returning the count (:66).
    • +
    • Where it's used: injected into SpeakersController (MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:52) and invoked by the GET /Speakers/{speakerId}/sessions/{sessionId}/bookmarks/count endpoint (:429-444), which is [AllowAnonymous] (:430) and served through the BookmarkCountsCache output-cache policy (:431). The batch equivalent used by the speaker dashboard is GetSessionBookmarkCountsHandler (:449-459).

    SessionRoomScheduling

    @@ -1922,15 +2162,16 @@

    SessionRoomScheduling

    • What it is: the shared room-assignment guard for the session create and update paths. One static class holding the cross-event room check (BR-130), the SQL-translatable overlap predicate that detects a double booking, and the conflict error both paths return.
    • -
    • Depends on: IRepository<TEntity, TIdentifierType> (for the existence probe), Session, Event and its Room children, Result and Error, plus System.Linq.Expressions (BCL) and the module aliases RoomIdentifierType / SessionIdentifierType (both int).
    • -
    • Concept introduced: the half-open interval and the honestly-documented soft guard. Two sessions conflict when they share a room and their [StartsAt, EndsAt) windows overlap; back-to-back sessions, where one ends exactly when the next starts, do not conflict. That is the whole business rule, and it falls out of the two strict comparisons in the predicate rather than needing any special case. The second, more important lesson is in the class doc (:16-25): this check is deliberately advisory. The existence probe and the insert or update that follows are separate statements, not one atomic step, so two concurrent organizer writes can both observe a free window and both commit, genuinely double-booking the room. The doc also explains why persistence cannot close the gap cheaply: the predicate spans an interval rather than a single value, and SQL Server has no range-exclusion constraint, so no unique index can express the rule. The trade-off is accepted because the endpoints are organizer-only (a narrow, low-concurrency audience) and the outcome is repairable at any time by editing either session. [Rubric §8, Data Architecture] assesses how consistency rules are enforced against the store; this is a read-then-write guard with its own limits written down instead of assumed away. [Rubric §15, Best Practices & Code Quality] and [Rubric §34, Architecture Governance & Documentation]: an accepted weakness documented at the point of use is worth more than a silent one. [Rubric §12, Performance & Scalability]: the check is one server-side existence probe, never a client-side scan of the room's schedule.
    • +
    • Depends on: IEntityReader<TEntity, TIdentifierType> for the existence probe (SessionRoomScheduling.cs:45), Session, Event and its Room children, Result and Error, plus System.Linq.Expressions (BCL) and the module aliases RoomIdentifierType and SessionIdentifierType (both int).
    • +
    • Concept introduced: the half-open interval and the honestly-documented soft guard. Two sessions conflict when they share a room and their [StartsAt, EndsAt) windows overlap; back-to-back sessions, where one ends exactly when the next starts, do not conflict. That is the whole business rule, and it falls out of the two strict comparisons in the predicate rather than needing any special case. The second, more important lesson is in the class doc (:16-25): this check is deliberately advisory. The existence probe and the insert or update that follows are separate statements, not one atomic step, so two concurrent organizer writes can both observe a free window and both commit, genuinely double-booking the room. The doc also explains why persistence cannot close the gap cheaply: the predicate spans an interval rather than a single value, and SQL Server has no range-exclusion constraint, so no unique index can express the rule. The trade-off is accepted because the create and update endpoints are organizer-only (a narrow, low-concurrency audience) and the outcome is repairable at any time by editing either session's room or slot. [Rubric §8, Data Architecture] assesses how consistency rules are enforced against the store; this is a read-then-write guard with its own limits written down instead of assumed away. [Rubric §15, Best Practices & Code Quality] and [Rubric §34, Architecture Governance & Documentation]: an accepted weakness documented at the point of use is worth more than a silent one. [Rubric §12, Performance & Scalability]: the check is one server-side existence probe, never a client-side scan of the room's schedule.
    • Walkthrough (three public members, in call order):
      • ValidateRoomAssignmentAsync (:44-81) is the entry point both handlers call. It null-guards parentEvent (:54), then short-circuits to success when no room was requested (:56-57), because an unassigned session cannot conflict with anything. It looks the room up inside the already-loaded parent event's Rooms collection, requiring it to be non-soft-deleted (:59); a room that belongs to some other event is not found there and returns Error.Validation coded Session.RoomId.CrossEvent targeting Session.RoomId (:62-67). That is BR-130, and it costs no extra query. It then short-circuits again if either end of the window is missing (:69-70): a room can be assigned without a scheduled slot. Only with a room and both times does it run the probe, repository.ExistsAsync(BuildOverlapPredicate(...)) (:74-76), returning the conflict error or success (:78-80).
      • -
      • BuildOverlapPredicate (:93-107) builds the Expression<Func<Session, bool>> the probe translates to SQL. excludeSessionId defaults to null and collapses to int.MinValue (:101), which the comment justifies: session ids are always positive (Sessionize-assigned or the reserved manual range), so the sentinel excludes nothing and keeps the predicate a single shape rather than two conditionally-composed ones. The predicate itself (:103-106) requires same RoomId, Id != exclusionId, both timestamps non-null, and then the two strict comparisons s.StartsAt < endsAt && s.EndsAt > startsAt that define the half-open overlap.
      • +
      • BuildOverlapPredicate (:93-107) builds the Expression<Func<Session, bool>> the probe translates to SQL. excludeSessionId defaults to null and collapses to int.MinValue (:101), which the comment justifies: session ids are always positive (Sessionize-assigned or the reserved manual range), so the sentinel excludes nothing and keeps the predicate a single shape rather than two conditionally-composed ones. The predicate itself (:103-106) requires the same RoomId, Id != exclusionId, both timestamps non-null, and then the two strict comparisons s.StartsAt < endsAt && s.EndsAt > startsAt that define the half-open overlap.
      • DoubleBookedError (:116-121) returns Error.Conflict coded Session.Room.DoubleBooked, the 409-style failure the API surfaces. Exposing it as a named member means the two handlers and their tests refer to one definition of the conflict.
    • -
    • Why it's built this way: factoring the rule into a static class (rather than duplicating it in each handler, or pushing it into Session) is a direct consequence of where the data lives. The rule spans two aggregates: it needs the parent Event's rooms and it needs every other session's schedule, so no single aggregate can enforce it and it belongs in the application layer beside the handlers that load both. Keeping the predicate a separate public member is what lets the update path pass excludeSessionId so a session can keep or shrink its own slot without colliding with itself.
    • +
    • Why it's built this way: factoring the rule into a static class (rather than duplicating it in each handler, or pushing it into Session) is a direct consequence of where the data lives. The rule spans two aggregates: it needs the parent Event's rooms and it needs every other session's schedule, so no single aggregate can enforce it, and it belongs in the application layer beside the handlers that load both. Keeping the predicate a separate public member is what lets the update path pass excludeSessionId so a session can keep or shrink its own slot without colliding with itself.
    • +
    • Testing: SessionRoomSchedulingTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionRoomSchedulingTests.cs:12) exercises the predicate as a pure function, which is what makes the half-open rule cheap to pin down: overlapping (:54) and fully contained (:62) slots match, while back-to-back (:70), same-room-different-day (:79), different-room (:87), self-excluded (:95), and unscheduled (:104) cases do not. A final test asserts DoubleBookedError is conflict-typed (:112).
    • Caveats / not-in-source: the class doc notes that deliberate co-location (lightning talks sharing one slot) would need this check relaxed from a rejection to a warning (:13-15). No such relaxation exists in the current code.
    • Where it's used: called by CreateSessionHandler with excludeSessionId: null (MMCA.ADC.Conference.Application/Sessions/UseCases/Create/CreateSessionHandler.cs:111-119, only when the command actually carries a room, :100) and by UpdateSessionHandler with excludeSessionId: command.Id (MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionHandler.cs:57-65), each passing its own handler name as the error source. Both load the parent event with includes: [nameof(Event.Rooms)] and asTracking: false first (CreateSessionHandler.cs:103-107, UpdateSessionHandler.cs:48-52), because that collection is what the cross-event check reads.
    @@ -1941,11 +2182,12 @@

    GetPublicSpeakerCategoryItemF
    • What it is: the handler that turns GetPublicSpeakerCategoryItemFilterQuery into a specification restricting the speaker-to-category-item junction to rows whose parent speaker is publicly visible (BR-239).
    • Depends on: IQueryHandler<in TQuery, TResult>, IUnitOfWork, PublicConferenceVisibility, Specification<TEntity, TIdentifierType> and InlineSpecification<TEntity, TIdentifierType>, SpeakerCategoryItem, and Result.
    • -
    • Concept introduced: a query handler whose result is a filter, not data. Every other read handler in this module returns rows or a DTO. This one returns a Specification<TEntity, TIdentifierType>, which the controller then hands to its generic query service so the framework's paging, sorting, and projection all run inside the restricted set. The visibility rule is therefore composed with the caller's own filters by the query pipeline rather than being applied afterwards in memory, which is what keeps page counts honest. [Rubric §6, CQRS & Event-Driven] assesses the read side's composability; [Rubric §11, Security] assesses that the restriction is applied at the data layer, so an attacker cannot page past it; [Rubric §12, Performance & Scalability] assesses that filtering happens server-side rather than after materialization.
    • -
    • Walkthrough: the primary constructor (:17-18) injects IUnitOfWork only. The declared result type (:19) is Result<Specification<SpeakerCategoryItem, SpeakerCategoryItemIdentifierType>>. HandleAsync (:22) calls PublicConferenceVisibility.GetVisibleSpeakerIdsAsync(unitOfWork, cancellationToken: cancellationToken) (:26-28), passing no event scope, so the rule spans every published event. It wraps the resulting id list in an InlineSpecification<TEntity, TIdentifierType> whose criteria is sci => speakerIds.Contains(sci.SpeakerId) (:31-32), a SpeakerId IN (...) predicate, and returns it as a success (:30).
    • -
    • Why it's built this way: the junction row follows the visibility of its parent, so the handler reuses the one visible-speaker computation instead of re-deriving a junction-specific rule. Filtering by an id list rather than a navigation join keeps the criteria engine-portable and preserves the by-id boundary between SpeakerCategoryItem and the Session and Event aggregates the rule actually reads. The query parameter is accepted and unused because the IQueryHandler<in TQuery, TResult> contract requires it, which is also why the cancellation token is passed by name (:27).
    • +
    • Concept introduced: a query handler whose result is a filter, not data. Every other read handler in this module returns rows or a DTO. This one returns a Specification<TEntity, TIdentifierType>, which the controller then hands to its generic query service so the framework's paging, sorting, and projection all run inside the restricted set. The visibility rule is therefore composed with the caller's own filters by the query pipeline rather than being applied afterwards in memory, which is what keeps page counts honest. [Rubric §6, CQRS & Event-Driven] assesses the read side's composability; [Rubric §11, Security] assesses that the restriction is applied at the data layer, so a caller cannot page past it; [Rubric §12, Performance & Scalability] assesses that filtering happens server-side rather than after materialization.
    • +
    • Walkthrough: the primary constructor (:17-18) injects IUnitOfWork only. The declared result type (:19) is Result<Specification<SpeakerCategoryItem, SpeakerCategoryItemIdentifierType>>. HandleAsync (:22-24) calls PublicConferenceVisibility.GetVisibleSpeakerIdsAsync(unitOfWork, cancellationToken: cancellationToken) (:26-28), passing no event scope, so the rule spans every published event. It wraps the resulting id list in an InlineSpecification<TEntity, TIdentifierType> whose criteria is sci => speakerIds.Contains(sci.SpeakerId) (:31-32), a SpeakerId IN (...) predicate, and returns it as a success (:30).
    • +
    • Why it's built this way: the junction row follows the visibility of its parent, so the handler reuses the one visible-speaker computation instead of re-deriving a junction-specific rule. Filtering by an id list rather than a navigation join keeps the criteria engine-portable (ADR-018) and preserves the by-id boundary between SpeakerCategoryItem and the Session and Event aggregates the rule actually reads. The query parameter is accepted and unused because the IQueryHandler<in TQuery, TResult> contract requires it, which is also why the cancellation token is passed by name (:27).
    • +
    • Testing: GetPublicSpeakerCategoryItemFilterHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/GetPublicSpeakerCategoryItemFilter/GetPublicSpeakerCategoryItemFilterHandlerTests.cs:19), four tests: the success shape (:66), a row of a visible speaker matching (:75), a row of a hidden speaker excluded (:85), and no visible speakers matching nothing (:95).
    • Caveats / not-in-source: the id list is materialized into the expression, so the generated SQL carries as many parameters as there are visible speakers. What that costs at conference scale is not determinable from this file.
    • -
    • Where it's used: injected into SpeakerCategoryItemsController (MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:51) and invoked from its BuildPublicSpecificationAsync helper (:66-75), which returns null for privileged readers (Organizer/ContentEditor) so they see every row. That helper feeds all four junction read endpoints: GetAll (:90), the paged list (:120), the lookup (:148, where only the specification's Criteria is forwarded), and GetById (:178). Registration is convention-based: the module's ScanModuleApplicationServices<ClassReference>() call picks up every handler in the assembly (MMCA.ADC.Conference.Application/DependencyInjection.cs:112).
    • +
    • Where it's used: injected into SpeakerCategoryItemsController (MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:52) and invoked from its BuildPublicSpecificationAsync helper (:67-76), which returns null for privileged readers (Organizer/ContentEditor, :59,69-70) so they see every row. That helper feeds all four junction read endpoints: GetAll (:91), the paged list (:121), the lookup (:149, where only the specification's Criteria is forwarded, :155), and GetById (:179), each [AllowAnonymous] (:79, :102, :143, :165) under the class-level [HasPermission(ConferencePermissions.SpeakersManage)] requirement (:47). Registration is convention-based: the module's ScanModuleApplicationServices<ClassReference>() call picks up every handler in the assembly (MMCA.ADC.Conference.Application/DependencyInjection.cs:125).

    GetPublicSpeakerFilterHandler

    @@ -1954,11 +2196,12 @@

    GetPublicSpeakerFilterHandler

    • What it is: the handler that turns GetPublicSpeakerFilterQuery into a Speaker.Id IN (...) specification implementing BR-239: a speaker is publicly visible when they have at least one publicly visible session in the scoped published-event set.
    • Depends on: IQueryHandler<in TQuery, TResult>, IUnitOfWork, PublicConferenceVisibility, Specification<TEntity, TIdentifierType> and InlineSpecification<TEntity, TIdentifierType>, Speaker, and Result.
    • -
    • Concept reinforced: the filter-returning query handler introduced by GetPublicSpeakerCategoryItemFilterHandler, here with the optional event scope threaded through. It is worth understanding why the rule has to be resolved into ids at all: Speaker carries no status column and no event column, so "is this speaker public" is not a property of the speaker row. It is a fact about the sessions the speaker is linked to, which live in another aggregate. Resolving it to an id list is the BR-132 precedent, and the shape is shared with GetSpeakersByEventFilterHandler. [Rubric §4, DDD] assesses aggregate boundaries: Speaker keeps a by-id relationship to Session and Event instead of growing a navigation that would merge three aggregates into one query. [Rubric §8, Data Architecture]: an id-list criteria has no join, so it stays translatable on every engine the framework supports.
    • -
    • Walkthrough: the primary constructor (:17-18) injects IUnitOfWork. HandleAsync (:22-24) passes query.EventId straight through to PublicConferenceVisibility.GetVisibleSpeakerIdsAsync(unitOfWork, query.EventId, cancellationToken) (:26-28), then returns Result.Success over an InlineSpecification<TEntity, TIdentifierType> with the criteria s => speakerIds.Contains(s.Id) (:30-31). All of the actual rule lives in PublicConferenceVisibility (MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:99-127), which resolves the published-event set (:104), narrows it to the scoped event when one is supplied (an unpublished or unknown scoped event yields an empty list, :108-115), collects the BR-49-eligible session ids (:117-119), and projects the distinct speaker ids off the SessionSpeaker junction (:121-126).
    • -
    • Why it's built this way: the handler is deliberately thin. Keeping the rule in PublicConferenceVisibility is what lets the speaker filter, the junction filter, and the session filters share one definition of "published" and one definition of the BR-49 allow-list (expressed once as PublicSessionStatusSpecification, PublicConferenceVisibility.cs:141-143), so they cannot drift apart into three subtly different notions of public. The scope is passed through rather than resolved here because narrowing is a caller concern: only the paged list has an event context.
    • -
    • Caveats / not-in-source: PublicConferenceVisibility's remarks (:92-98) record that the EventSpeaker join is deliberately not treated as a visibility grant, because the Sessionize import (SpeakerSyncStrategy) writes a row there for every speaker in the response, which once made this filter vacuous by publishing the entire imported roster. The session link is the only acceptance signal consulted.
    • -
    • Where it's used: injected into SpeakersController (MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:55) and invoked from BuildPublicSpeakerSpecificationAsync (:82-93), which returns null for privileged readers so Organizers and ContentEditors see every speaker (:62-63,86-87).
    • +
    • Concept reinforced: the filter-returning query handler introduced by GetPublicSpeakerCategoryItemFilterHandler, here with the optional event scope threaded through. It is worth understanding why the rule has to be resolved into ids at all: Speaker carries no status column and no event column, so "is this speaker public" is not a property of the speaker row. It is a fact about the sessions the speaker is linked to, which live in another aggregate. Resolving it to an id list is the BR-132 precedent, and the shape is shared with GetSpeakersByEventFilterHandler. [Rubric §4, DDD] assesses aggregate boundaries: Speaker keeps a by-id relationship to Session and Event instead of growing a navigation that would merge three aggregates into one query. [Rubric §8, Data Architecture]: an id-list criteria has no join, so it stays translatable on every engine the framework supports.
    • +
    • Walkthrough: the primary constructor (:17-19) injects IUnitOfWork. HandleAsync (:22-24) passes query.EventId straight through to PublicConferenceVisibility.GetVisibleSpeakerIdsAsync(unitOfWork, query.EventId, cancellationToken) (:26-28), then returns Result.Success over an InlineSpecification<TEntity, TIdentifierType> with the criteria s => speakerIds.Contains(s.Id) (:30-31). All of the actual rule lives in PublicConferenceVisibility (MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:104-134), which resolves the published-event set (:109), narrows it to the scoped event when one is supplied (an unpublished or unknown scoped event yields an empty list, :111-120), collects the BR-49-eligible session ids (:122-124), and projects the distinct speaker ids off the SessionSpeaker junction (:126-133).
    • +
    • Why it's built this way: the handler is deliberately thin. Keeping the rule in PublicConferenceVisibility is what lets the speaker filter, the junction filter, and the session filters share one definition of "published" and one definition of the BR-49 allow-list (expressed once as PublicSessionStatusSpecification and ANDed with the event-id scope, PublicConferenceVisibility.cs:148-149), so they cannot drift apart into three subtly different notions of public. The scope is passed through rather than resolved here because narrowing is a caller concern: only the paged list has an event context.
    • +
    • Testing: GetPublicSpeakerFilterHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/GetPublicSpeakerFilter/GetPublicSpeakerFilterHandlerTests.cs:22) is the largest of the filter suites, because the rule has the most edges: an accepted session publishes its speaker (:153), a null-status session does too (:164), while non-accepted-only (:193), EventSpeaker-only (:214), and orphan (:235) speakers stay hidden. The scope modes get their own tests: no scope spans every published event (:256), a scope narrows to that event (:267), an unpublished scope matches nothing (:279), and a scope with no eligible session matches nothing (:298).
    • +
    • Caveats / not-in-source: PublicConferenceVisibility's remarks (:97-103) record that the EventSpeaker join is deliberately not treated as a visibility grant, because the Sessionize import (SpeakerSyncStrategy) writes a row there for every speaker in the response, which once made this filter vacuous by publishing the entire imported roster. The session link is the only acceptance signal consulted.
    • +
    • Where it's used: injected into SpeakersController (MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:55) and invoked from BuildPublicSpeakerSpecificationAsync (:82-93), which returns null for privileged readers so Organizers and ContentEditors see every speaker (:62-63,86-87). That helper feeds the unpaged list (:109), the paged list (the only caller that supplies an event scope, :162), the lookup (:215), and GetById (:257).

    GetPublicSponsorFilterQuery

    @@ -1970,7 +2213,7 @@

    GetPublicSponsorFilterQuery

  • Concept introduced: none new; this is the filter-query-as-DI-lookup-token shape taught at GetPublicSpeakerCategoryItemFilterQuery. What is worth reading here is the <remarks> block (:8-12), which records the one structural difference from its speaker and session siblings: Sponsor carries a real EventId column (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:45), so the rule can be resolved as a published-event id list and returned as a Sponsor.EventId IN (...) criteria with no navigation join anywhere in the expression tree. [Rubric §11, Security] assesses where visibility is decided: the query has no field an anonymous caller could set, so the rule cannot be widened from the wire, and the doc comment states the leak being prevented, namely an event still being assembled exposing its sponsor roster before announcement (:5-6). [Rubric §8, Data Architecture] assesses query portability: keeping the criteria to a scalar IN is what makes it translatable on any engine (ADR-018, named in the remarks at :11).
  • Walkthrough: one line of code (:13). The ten lines above it (:3-12) are the contract: the summary states the rule and the leak it closes, the remarks state the shape the handler must return and why.
  • Why it's built this way: giving a zero-argument rule its own record type is what lets SponsorsController inject IQueryHandler<GetPublicSponsorFilterQuery, ...> and reach the rule through the same pipeline as every other read, instead of calling a static helper from the API layer.
  • -
  • Where it's used: constructed by SponsorsController in its private BuildPublicSponsorSpecificationAsync helper (MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:66) and answered by GetPublicSponsorFilterHandler.
  • +
  • Where it's used: constructed by SponsorsController inside its private BuildPublicSponsorSpecificationAsync helper (MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:66) and answered by GetPublicSponsorFilterHandler.
  • GetSessionBookmarkCountsQuery

    @@ -1978,11 +2221,11 @@

    GetSessionBookmarkCountsQuery

    • What it is: the read intent behind the Speaker Dashboard's bookmark widget. It asks, in one call, "how many people bookmarked each of these sessions?", carrying the requesting speaker plus the set of session ids to count (BR-210, GetSessionBookmarkCountsQuery.cs:3).
    • -
    • Depends on: nothing first-party beyond the module identifier aliases SpeakerIdentifierType (a Guid) and SessionIdentifierType (an int), declared in MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:18,14 and used at :7-8. The only external is BCL IReadOnlyCollection<T>.
    • +
    • Depends on: nothing first-party beyond the module identifier aliases SpeakerIdentifierType (a System.Guid) and SessionIdentifierType (an int), declared in MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:19,15 and used at :7-8. The only external is BCL IReadOnlyCollection<T>.
    • Concept introduced: the batched query, and why the caller's id list is an input rather than an authorization. The singular sibling GetSessionBookmarkCountQuery answers for one session; this record takes a whole collection (:8) so a dashboard listing N sessions makes one round trip instead of N. The important design point is what the record does not mean: SessionIds is a request, not a grant. The speaker id travels alongside (:7) precisely so GetSessionBookmarkCountsHandler can re-derive server-side which of those sessions the speaker is actually entitled to see. [Rubric §11, Security] assesses whether authorization decisions are made from server-held state rather than from client-supplied claims: the shape of this query is what makes that possible, because it forces the pairing of "who is asking" with "what they asked about". [Rubric §12, Performance & Scalability]: collapsing a per-row fan-out into one batched intent is the query-shape half of an N+1 fix.
    • Walkthrough: two positional parameters on a sealed record (:6-8), SpeakerId (:7) and SessionIds (:8). The declared parameter type is IReadOnlyCollection<SessionIdentifierType>, so the handler can cheaply test Count before doing any work without committing the caller to a particular collection implementation. There are no methods, no markers, and no cache-invalidation interface: this is a pure read intent.
    • Why it's built this way: queries in this codebase are plain records with no behavior so the IQueryHandler<in TQuery, TResult> implementation stays the single place where the read is described (see Group 05).
    • -
    • Where it's used: constructed by SpeakersController on GET {speakerId}/sessions/bookmarks/counts (MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:441-451), which binds sessionIds from the query string and null-coalesces a missing array to an empty one (:446,450).
    • +
    • Where it's used: constructed by SpeakersController on GET {speakerId}/sessions/bookmarks/counts (MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:449-459), which binds sessionIds from the query string with [FromQuery] and null-coalesces a missing array to an empty one (:454,458).

    GetSessionFeedbackQuery

    @@ -1993,7 +2236,7 @@

    GetSessionFeedbackQuery

  • Depends on: the module identifier aliases SpeakerIdentifierType and SessionIdentifierType (:6). Nothing else.
  • Concept introduced: none new; this is the same "who is asking plus what they asked about" pair taught at GetSessionBookmarkCountsQuery, in its single-target form. The speaker id is not decorative: GetSessionFeedbackHandler rejects the read with a Forbidden error when the speaker is not assigned to the session, so the query type carries exactly the two facts the authorization check needs. [Rubric §11, Security]: the read is scoped by a server-verified relationship, not by trusting the route.
  • Walkthrough: a one-line sealed record with two positional parameters, SpeakerId and SessionId (:6). The XML docs (:3-5) name the business rule and each parameter's role.
  • -
  • Where it's used: constructed by SpeakersController on GET {speakerId}/sessions/{sessionId}/feedback (MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:402-412), an [AllowAnonymous] endpoint served through the ConferencePublicCache output-cache policy (:403-404).
  • +
  • Where it's used: constructed by SpeakersController on GET {speakerId}/sessions/{sessionId}/feedback (MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:406-420). The endpoint is [Authorize] (:407) and applies a self-or-organizer gate before the handler ever runs: the caller must hold the Organizer role or carry the route speaker's speaker_id claim, otherwise it returns Forbid() (:413-416). It carries no [OutputCache] attribute, and the endpoint summary records why (:403-405): free-text comments are the speaker's own read, so every response is authorization-dependent and must not be publicly cached.
  • GetSpeakersByEventFilterQuery

    @@ -2001,11 +2244,12 @@

    GetSpeakersByEventFilterQuery

    • What it is: the intent "give me a filter that selects the speakers belonging to this event". It carries one field, the EventId (:12), and its handler returns a Specification<TEntity, TIdentifierType> rather than data.
    • -
    • Depends on: the EventIdentifierType alias (an int, MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7), used at :12. Nothing else.
    • +
    • Depends on: the EventIdentifierType alias (an int, MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8), used at :12. Nothing else.
    • Concept introduced: the query that returns a specification, not rows. Most queries in this module resolve to a DTO or a count. This one resolves to a predicate object that the caller then composes into a larger read. The reason is spelled out in the type's own doc comment (:3-10): a Speaker has no EventId column, and it can belong to an event by two independent link paths, the EventSpeaker join written by the Sessionize sync and the SessionSpeaker join written by organizer session management. Resolving that union has to happen in a handler with repository access, but the result still has to be a filter so it can be ANDed with the caller's other criteria and passed to the generic paged read. Returning a specification is how a multi-step lookup is turned back into a single composable clause. [Rubric §2, Design Patterns] assesses whether recognized patterns are applied where they earn their keep: this is Specification used as a first-class return value, not just as a parameter. [Rubric §8, Data Architecture]: the doc comment records the deliberate choice to resolve the joins as ID-list projections so the criteria stay engine-portable rather than depending on a navigation join.
    • -
    • Walkthrough: the whole type is one line (:12); the ten lines above it (:3-11) are the design rationale, which is unusually long for a record and is the load-bearing part to read. It names the two link paths, states that they are populated by different flows so the handler must union them, and notes that Speaker has no EventId column.
    • +
    • Walkthrough: the whole type is one line (:12); the nine lines above it (:3-11) are the design rationale, which is unusually long for a record and is the load-bearing part to read. It names the two link paths, states that they are populated by different flows so the handler must union them, and notes that Speaker has no EventId column.
    • Why it's built this way: keeping Speaker free of an EventId column preserves the aggregate boundary (a speaker exists independently of any event, and relates to events by id, not by containment). The cost of that DDD choice is this two-path lookup, and the specification return type is what keeps the cost contained in one handler. See ADR-055 for the repository-plus-specification data-access contract this leans on.
    • -
    • Where it's used: SpeakersController lifts an EventId out of the incoming filter dictionary (MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:154-160), and when present calls the handler and folds the returned specification into the public-visibility specification via AndSpecification<TEntity, TIdentifierType> before running the paged read (:165-189).
    • +
    • Where it's used: SpeakersController lifts an EventId out of the incoming filter dictionary and removes it unconditionally, because Speaker has no such column and the generic filter pipeline rejects unknown properties (MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:150-160). When an id parsed, it calls the handler and folds the returned specification into the public-visibility specification (:165-176) before running the paged read (:178-189).
    • +
    • Caveats / not-in-source: this filter is distinct from the BR-239 public-visibility rule, which is resolved separately by BuildPublicSpeakerSpecificationAsync (SpeakersController.cs:162). The two are ANDed, so an event-scoped listing shows the intersection, not the union.

    GetSessionBookmarkCountsHandler

    @@ -2014,10 +2258,11 @@

    GetSessionBookmarkCountsHandler

    • What it is: the handler for GetSessionBookmarkCountsQuery. It re-verifies which of the requested sessions actually belong to the asking speaker, then asks the Engagement module for the bookmark counts of just those sessions, in one batched call.
    • Depends on: IQueryHandler<in TQuery, TResult> (GetSessionBookmarkCountsHandler.cs:4,20), IUnitOfWork (:3,18), IBookmarkCountService from MMCA.ADC.Engagement.Shared.UserSessionBookmarks (:2,19), Session with its SessionSpeakers navigation (:1,35), and Result (:5).
    • -
    • Concept introduced: reading across a bounded-context boundary through an interface, and filtering-not-failing authorization. Conference displays bookmark counts but does not own them: the data lives in Engagement. Rather than referencing Engagement's domain or querying its database, the handler injects IBookmarkCountService, an interface published in Engagement's Shared layer. In the monolith topology DI binds it to the in-process BookmarkCountService; in ADC's extracted topology the same interface is satisfied by a generated gRPC client, so this handler compiles and behaves identically either way (ADR-007, ADR-006). [Rubric §7, Microservices Readiness] assesses whether a module could be extracted without rewriting its callers: this handler is the proof, the only Engagement-shaped thing it knows is one interface. The second idea is the authorization posture (:40-46): the handler keeps only sessions the speaker is assigned to and silently drops the rest rather than failing the whole batch, so one stale or foreign id in a dashboard's list never denies the speaker the counts they are entitled to. [Rubric §11, Security]: the client's id list is treated as a request, never as a grant, and the class doc comment states that intent explicitly (:11-12).
    • -
    • Walkthrough: the primary constructor takes the unit of work and the count service (:17-19); the class implements IQueryHandler<GetSessionBookmarkCountsQuery, Result<IReadOnlyDictionary<SessionIdentifierType, int>>> (:20). HandleAsync (:23) starts with an empty-input short circuit returning an empty dictionary without touching the database (:27-31). It then takes the read repository (GetReadRepository, :33) and loads the requested sessions with their SessionSpeakers eager-included, asTracking: false (:34-38), a read-only query with no change-tracker overhead. The authorization projection (:43-46) keeps sessions where any SessionSpeaker matches the query's SpeakerId and is not soft-deleted, and selects just the ids. A second short circuit returns an empty dictionary when nothing survived (:48-52). Finally it calls bookmarkCountService.GetBookmarkCountsForSessionsAsync(authorizedSessionIds, ...) (:54-56) and wraps the dictionary in Result.Success (:58).
    • -
    • Why it's built this way: the batched contract exists so the Speaker Dashboard makes one call instead of one per session; the class doc comment (:9-16) names that as the reason. Note the explicit !ss.IsDeleted test at :44: the eager-loaded child collection is filtered in memory here, so the check is written out rather than relying solely on the EF global soft-delete filter (ADR-005). [Rubric §12, Performance & Scalability]: two round trips total (one local read, one cross-module call) regardless of session count.
    • -
    • Where it's used: injected into SpeakersController as IQueryHandler<GetSessionBookmarkCountsQuery, ...> (MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:53) and invoked from the bookmarks/counts endpoint (:449-451), which serves through the BookmarkCountsCache output-cache policy (:443).
    • +
    • Concept introduced: reading across a bounded-context boundary through an interface, and filtering-not-failing authorization. Conference displays bookmark counts but does not own them: the data lives in Engagement. Rather than referencing Engagement's domain or querying its database, the handler injects IBookmarkCountService, an interface published in Engagement's Shared layer (MMCA.ADC.Engagement.Shared/UserSessionBookmarks/IBookmarkCountService.cs:8). In the monolith topology DI binds it to the in-process BookmarkCountService via TryAddScoped (MMCA.ADC.Engagement.Application/DependencyInjection.cs:45); in ADC's extracted topology that registration is swapped for BookmarkCountServiceGrpcAdapter, a hand-written adapter over the generated gRPC client, using services.Replace (MMCA.ADC.Engagement.Contracts/DependencyInjection.cs:49, MMCA.ADC.Engagement.Contracts/BookmarkCountServiceGrpcAdapter.cs:15). This handler compiles and behaves identically either way (ADR-007, ADR-006). [Rubric §7, Microservices Readiness] assesses whether a module could be extracted without rewriting its callers: this handler is the proof, the only Engagement-shaped thing it knows is one interface. The second idea is the authorization posture (:40-46): the handler keeps only sessions the speaker is assigned to and silently drops the rest rather than failing the whole batch, so one stale or foreign id in a dashboard's list never denies the speaker the counts they are entitled to. [Rubric §11, Security]: the client's id list is treated as a request, never as a grant, and the class doc comment states that intent explicitly (:11-12).
    • +
    • Walkthrough: the primary constructor takes the unit of work and the count service (:17-19); the class implements IQueryHandler<GetSessionBookmarkCountsQuery, Result<IReadOnlyDictionary<SessionIdentifierType, int>>> (:20). HandleAsync (:23) starts with an empty-input short circuit returning an empty dictionary without touching the database (:27-31). It then takes the read repository (GetReadRepository, :33) and loads the requested sessions with their SessionSpeakers eager-included, asTracking: false (:34-38), a read-only query with no change-tracker overhead. The authorization projection (:43-46) keeps sessions where any SessionSpeaker matches the query's SpeakerId and is not soft-deleted, and selects just the ids. A second short circuit returns an empty dictionary when nothing survived (:48-52). Finally it calls bookmarkCountService.GetBookmarkCountsForSessionsAsync(authorizedSessionIds, ...) (:54-56) and wraps the returned dictionary in Result.Success (:58).
    • +
    • Why it's built this way: the batched contract exists so the Speaker Dashboard makes one call instead of one per session; the class doc comment (:9-16) names that as the reason, and the interface contract guarantees every requested id is present in the result with zero-bookmark sessions mapping to 0 (MMCA.ADC.Engagement.Shared/UserSessionBookmarks/IBookmarkCountService.cs:19-20). Note the explicit !ss.IsDeleted test at :44: the eager-loaded child collection is filtered in memory here, so the check is written out rather than relying solely on the EF global soft-delete filter (ADR-005). [Rubric §12, Performance & Scalability]: two round trips total (one local read, one cross-module call) regardless of session count.
    • +
    • Where it's used: injected into SpeakersController as IQueryHandler<GetSessionBookmarkCountsQuery, ...> (MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:53) and invoked from the bookmarks/counts endpoint (:457-459), which is [AllowAnonymous] and served through the BookmarkCountsCache output-cache policy (:450-451).
    • +
    • Caveats / not-in-source: when the Engagement module is disabled in a host, the interface resolves to DisabledBookmarkCountService instead (MMCA.ADC.Engagement.API/EngagementModule.cs:32), so this handler's cross-module hop can be satisfied by a stub. The stub's return shape is not read here.

    GetSessionFeedbackHandler

    @@ -2025,12 +2270,12 @@

    GetSessionFeedbackHandler

    • What it is: the handler for GetSessionFeedbackQuery. It confirms the speaker is assigned to the session, then aggregates that session's answers into average ratings per rating question and raw text lists per open question.
    • -
    • Depends on: IQueryHandler<in TQuery, TResult> (GetSessionFeedbackHandler.cs:6,16), IUnitOfWork (:5,16), Session and Question (:2-3), SessionFeedbackDTO with RatingQuestionSummary and TextQuestionResponses (:4), Result and Error (:7), and BCL System.Globalization for culture-invariant parsing (:1).
    • -
    • Concept introduced: the in-context analytics handler, and defensive parsing of a stringly-typed answer store. Unlike its bookmark sibling, this handler needs no cross-module call: session questions and answers are Conference-owned, so everything is local. Two mechanisms are worth learning here. The first is the ownership gate (:33-40): if no live SessionSpeaker matches the query's speaker, the handler returns Error.Forbidden with the stable code Speaker.NotAssigned plus a message, source, and target, so a speaker cannot read another speaker's feedback by editing the URL. Note the deliberate distinction from GetSessionBookmarkCountsHandler: a single-target read fails on a mismatch, a batch read filters. The second is the answer model: AnswerValue is stored as a string regardless of question type, so a Rating answer must be parsed back to an integer. The handler uses int.TryParse with NumberStyles.Integer and CultureInfo.InvariantCulture (:76) and drops values that do not parse, rather than throwing. Culture-invariance is the load-bearing detail: parsing a stored value with the ambient culture makes the same database return different results on different servers. [Rubric §11, Security] (server-verified ownership), [Rubric §15, Best Practices & Code Quality] (invariant-culture parsing, no exceptions used for flow control), [Rubric §12, Performance & Scalability] (two queries maximum, with the second skipped entirely when there are no answers).
    • +
    • Depends on: IQueryHandler<in TQuery, TResult> (GetSessionFeedbackHandler.cs:6,16), IUnitOfWork (:5,16), Session and Question (:2-3), SessionFeedbackDTO with RatingQuestionSummary and TextQuestionResponses (:4; all three declared in MMCA.ADC.Conference.Shared/Speakers/SessionFeedbackDTO.cs:6,22,38), Result and Error (:7), and BCL System.Globalization for culture-invariant parsing (:1).
    • +
    • Concept introduced: the in-context analytics handler, and defensive parsing of a stringly-typed answer store. Unlike its bookmark sibling, this handler needs no cross-module call: session questions and answers are Conference-owned, so everything is local. Two mechanisms are worth learning here. The first is the ownership gate (:33-40): if no live SessionSpeaker matches the query's speaker, the handler returns Error.Forbidden with the stable code Speaker.NotAssigned plus a message, source, and target, so a speaker cannot read another speaker's feedback by editing the URL. Note the deliberate distinction from GetSessionBookmarkCountsHandler: a single-target read fails on a mismatch, a batch read filters. The second is the answer model: SessionQuestionAnswer stores AnswerValue as a string regardless of question type, so a Rating answer must be parsed back to an integer. The handler uses int.TryParse with NumberStyles.Integer and CultureInfo.InvariantCulture (:76) and drops values that do not parse, rather than throwing. Culture-invariance is the load-bearing detail: parsing a stored value with the ambient culture makes the same database return different results on different servers. [Rubric §11, Security] (server-verified ownership), [Rubric §15, Best Practices & Code Quality] (invariant-culture parsing, no exceptions used for flow control), [Rubric §12, Performance & Scalability] (two queries maximum, with the second skipped entirely when there are no answers).
    • Walkthrough: the primary constructor takes only the unit of work (:15-16). HandleAsync (:19) takes the Session repository (:23) and loads the session by id with SessionSpeakers and SessionQuestionAnswers eager-included and asTracking: false (:24-28), returning Error.NotFound sourced and targeted for diagnostics when it is missing (:29-30). The ownership gate follows (:33-40). With no answers it returns an empty SessionFeedbackDTO carrying just the session id and title (:44-53), avoiding the question query altogether. Otherwise it collects the distinct answered question ids into a HashSet (:56) and loads only those questions (:57-62), building a dictionary lookup (:63). The aggregation loop groups answers by question id (:68), skips a group whose question was not found (:70-71), and branches on question.QuestionType == "Rating" (:73): the rating branch parses each answer, keeps the parsed values (:75-79), and, only if at least one parsed (:81), emits a RatingQuestionSummary with AverageRating and ResponseCount (:83-89); every other question type emits a TextQuestionResponses with all raw answer strings via the collection expression [.. group.Select(a => a.AnswerValue)] (:94-99). The final DTO is assembled and wrapped in Result.Success (:103-109).
    • Why it's built this way: the comment at :42 records that soft-deleted answers are already excluded by the EF global query filter, so the aggregation does not re-filter them (contrast the explicit !ss.IsDeleted on the eager-loaded speaker links at :33). Loading questions by the answered-id set rather than by session avoids pulling the whole question bank. See ADR-005 for the soft-delete model these filters implement.
    • -
    • Where it's used: injected into SpeakersController (MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:51) and invoked from the session-feedback endpoint (:410-412).
    • -
    • Caveats / not-in-source: the rating branch is selected by the literal string "Rating" (:73). Whether QuestionType is constrained to a known set anywhere else is not determinable from this file. Both repository calls use GetRepository rather than GetReadRepository (:23,57), though both pass asTracking: false, so the reads are untracked either way.
    • +
    • Where it's used: injected into SpeakersController (MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:51) and invoked from the session-feedback endpoint after its self-or-organizer gate (:418-420).
    • +
    • Caveats / not-in-source: the rating branch is selected by the literal string "Rating" (:73); the domain does constrain the column to ["Rating", "Text", "Email"] (MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:31), but this handler shares no constant with it, so the two definitions are coupled only by convention. Both repository calls use GetRepository rather than GetReadRepository (:23,57), though both pass asTracking: false, so the reads are untracked either way.

    GetSpeakersByEventFilterHandler

    @@ -2042,8 +2287,8 @@

    GetSpeakersByEventFilterHandler

  • Concept introduced: projection queries and the ID-list filter. The handler never materializes an entity. It uses GetProjectedAsync on the read repository up to three times, each pulling a single scalar column with a where clause and asTracking: false: speaker ids from the event-speaker join (:28-31), session ids for the event (:33-36), and speaker ids from the session-speaker join for those sessions (:44-47). Projecting rather than loading is what keeps a potentially wide join cheap: the query returns ids, not aggregates. The result is then expressed as an InlineSpecification<TEntity, TIdentifierType> over speakerIds.Contains(s.Id) (:52-53), which EF translates to a SQL IN. That indirection is deliberate: because the predicate closes over an in-memory list rather than over a navigation property, the criteria stay translatable on any provider, which is the engine-portability point the class doc comment makes (:15-17; see ADR-055 and the multi-engine motivation in ADR-018). [Rubric §8, Data Architecture] assesses whether queries stay portable and index-friendly; [Rubric §4, Domain-Driven Design]: Speaker relates to events by id across an aggregate boundary, never by owning an EventId column.
  • Walkthrough: the primary constructor takes only the unit of work (:19-20); the handler's TResult is Result<Specification<Speaker, SpeakerIdentifierType>> (:21). HandleAsync (:24) runs the direct-link projection first (:28-31), then the event's session ids (:33-36). sessionSpeakerIds is initialized to an empty collection (:38) and the third query runs only when there is at least one session (:39), so an event with no sessions costs two queries, not three. Inside the branch, the session ids are materialized once into an IReadOnlyList<T> (:42) with an explanatory comment (:41): the predicate must close over a stable collection for EF to translate it into IN rather than re-enumerating a deferred sequence. The two id sets are then concatenated and de-duplicated into one list (:50), and the specification is constructed and returned as a success (:52-53). The handler never returns a failure.
  • Why it's built this way: the union is necessary because the two link paths are written by different flows (the Sessionize import writes EventSpeaker, organizer session management writes SessionSpeaker), a fact recorded in the query's own doc comment (MMCA.ADC.Conference.Application/Speakers/UseCases/GetSpeakersByEventFilter/GetSpeakersByEventFilterQuery.cs:5-8). Returning a specification instead of speaker rows lets the caller AND this filter with its own visibility rules and still use the shared paged read path. The class doc comment cites the BR-132 cross-source specification helper as the precedent for the shape (:15).
  • -
  • Where it's used: injected into SpeakersController (MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:54); the controller composes the returned specification with the public-visibility specification via AndSpecification<TEntity, TIdentifierType> and passes the combination to the paged query service (:165-189).
  • -
  • Caveats / not-in-source: the returned specification embeds a materialized id list, so its size grows with the event's speaker count. No cap is applied in this handler.
  • +
  • Where it's used: injected into SpeakersController (MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:54); on a successful result the controller composes the returned specification with the public-visibility specification through the And extension, which builds an AndSpecification<TEntity, TIdentifierType> (SpeakersController.cs:170-175; MMCA.Common/Source/Core/MMCA.Common.Domain/Specifications/SpecificationExtensions.cs:53), and passes the combination to the paged query service (:178-189).
  • +
  • Caveats / not-in-source: the returned specification embeds a materialized id list, so its size grows with the event's speaker count. No cap is applied in this handler. Note also that a failed result at the call site is swallowed: the controller only composes when filterResult.IsSuccess (SpeakersController.cs:170), so a failure would silently fall back to the public specification alone. This handler has no failure path today, so that branch is unreachable from that call site.
  • GetPublicSponsorFilterHandler

    @@ -2052,11 +2297,11 @@

    GetPublicSponsorFilterHandler

    • What it is: the handler for GetPublicSponsorFilterQuery. It asks the shared visibility helper for the published event ids and returns a Sponsor.EventId IN (...) specification built from them.
    • Depends on: PublicConferenceVisibility from MMCA.ADC.Conference.Application.Common (GetPublicSponsorFilterHandler.cs:1,25), IQueryHandler<in TQuery, TResult> (:4,18), IUnitOfWork (:3,17), Specification<TEntity, TIdentifierType> and InlineSpecification<TEntity, TIdentifierType> (:5,30), Sponsor (:2), and Result (:6).
    • -
    • Concept introduced: the shortest public-filter handler, and what a real foreign-key column buys you. Its speaker and session siblings have to translate a visibility rule into an id list of the entity they are filtering, because those aggregates carry no column the rule can be expressed against. Sponsor does carry EventId (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:45), so the rule collapses to one hop: fetch the published event ids and compare the sponsor's own column against them. The whole handler is six statements' worth of code. Two design points carry over from the siblings anyway. First, the id list comes from PublicConferenceVisibility, not from a local query, so "published" is defined once for sessions, speakers, junctions, and sponsors alike, and closing a leak in that one helper closes it everywhere (MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:10-14). Second, the returned criteria contain no navigation join, so they stay translatable on any provider. [Rubric §11, Security] assesses whether visibility is centrally defined and server-derived: a caller supplies nothing, and BR-108 lives in exactly one method. [Rubric §1, SOLID]: the handler's only job is to shape the helper's output into a specification. [Rubric §8, Data Architecture]: the IN predicate is engine-portable per ADR-018.
    • -
    • Walkthrough: the primary constructor takes only the unit of work (:16-17); the class implements IQueryHandler<GetPublicSponsorFilterQuery, Result<Specification<Sponsor, SponsorIdentifierType>>> (:18). HandleAsync (:21-23) awaits PublicConferenceVisibility.GetPublishedEventIdsAsync(unitOfWork, cancellationToken) (:25-27), which projects Event.Id where IsPublished with asTracking: false and materializes the result once so the predicate closes over a stable collection (PublicConferenceVisibility.cs:40-46). It then returns Result.Success wrapping an InlineSpecification<Sponsor, SponsorIdentifierType>(s => publishedEventIds.Contains(s.EventId)) (:29-30). There is no failure path and no branching: an empty published set simply yields a specification that matches nothing.
    • -
    • Why it's built this way: routing every public read filter through one helper rather than through per-entity queries is the deliberate anti-leak measure recorded in the helper's own summary (PublicConferenceVisibility.cs:10-14), and returning a specification (rather than sponsor rows) lets the controller hand the filter to the shared paged query service and let it AND the filter with the caller's own criteria.
    • -
    • Where it's used: injected into SponsorsController (MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:42) and called from its private BuildPublicSponsorSpecificationAsync helper (:60-70), which short-circuits to null for privileged readers (Organizer or ContentEditor, :50,63-64) and otherwise passes the specification into the list (:84), paged (:119), and lookup (:143) reads. Because the specification is ANDed by the query service rather than substituted, scoping a request to an unpublished event yields an empty page for a non-privileged caller instead of leaking the roster (:94-98).
    • -
    • Caveats / not-in-source: when the handler returns a failure the controller falls back to null, meaning no filter (:69). The handler has no failure path today, so that branch is unreachable from this call site; whether it is defensive by intent is not determinable from source.
    • +
    • Concept introduced: the shortest public-filter handler, and what a real foreign-key column buys you. Its speaker and session siblings have to translate a visibility rule into an id list of the entity they are filtering, because those aggregates carry no column the rule can be expressed against. Sponsor does carry EventId (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:45), so the rule collapses to one hop: fetch the published event ids and compare the sponsor's own column against them. The whole handler is two statements' worth of code. Two design points carry over from the siblings anyway. First, the id list comes from PublicConferenceVisibility, not from a local query, so "published" (BR-108) is defined in exactly one method for every public conference read, and closing a leak there closes it everywhere (MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:10-15,36-48). Second, the returned criteria contain no navigation join, so they stay translatable on any provider. [Rubric §11, Security] assesses whether visibility is centrally defined and server-derived: a caller supplies nothing, and the published-event rule lives in one place. [Rubric §1, SOLID]: the handler's only job is to shape the helper's output into a specification. [Rubric §8, Data Architecture]: the IN predicate is engine-portable per ADR-018.
    • +
    • Walkthrough: the primary constructor takes only the unit of work (:16-17); the class implements IQueryHandler<GetPublicSponsorFilterQuery, Result<Specification<Sponsor, SponsorIdentifierType>>> (:18). HandleAsync (:21-23) awaits PublicConferenceVisibility.GetPublishedEventIdsAsync(unitOfWork, cancellationToken) (:25-27), which projects Event.Id where IsPublished with asTracking: false (PublicConferenceVisibility.cs:42-44) and materializes the result once so the predicate closes over a stable collection (:46-47). It then returns Result.Success wrapping an InlineSpecification<Sponsor, SponsorIdentifierType>(s => publishedEventIds.Contains(s.EventId)) (:29-30). There is no failure path and no branching: an empty published set simply yields a specification that matches nothing.
    • +
    • Why it's built this way: routing every public read filter through one helper rather than through per-entity queries is the deliberate anti-leak measure recorded in the helper's own summary (PublicConferenceVisibility.cs:10-15), and returning a specification (rather than sponsor rows) lets the controller hand the filter to the shared paged query service and let it AND the filter with the caller's own criteria.
    • +
    • Where it's used: injected into SponsorsController (MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:42) and called from its private BuildPublicSponsorSpecificationAsync helper (:60-70), which short-circuits to null for privileged readers (Organizer or ContentEditor, :50,53-54,63-64) and otherwise supplies the specification to all four public reads: the list (:84), the paged list (:119), the lookup (:143), and get-by-id (:176). Because the specification is ANDed by the query service rather than substituted, scoping a request to an unpublished event yields an empty page for a non-privileged caller instead of leaking the roster (:94-98), and a single sponsor of an unpublished event is a 404 rather than a redacted record, so a guessed id cannot confirm that a sponsorship was sold (:158-160).
    • +
    • Caveats / not-in-source: when the handler returns a failure the controller falls back to null, meaning no filter (:69). The handler has no failure path today, so that branch is unreachable from this call site; whether it is defensive by intent is not determinable from source. The lookup path takes a different route from the other three: it passes specification.Criteria as a raw where predicate rather than the specification object (:149), so any non-criteria part of a future specification would be dropped there.

    SponsorEventIdRules<T>

    @@ -2064,11 +2309,11 @@

    SponsorEventIdRules<T>

    • What it is: a one-rule reusable validator that asserts a sponsor request actually names an event. It is generic over the request type, so the same rule object binds to any record that has an event id.
    • -
    • Depends on: AbstractValidator<T> and Expression<Func<T, TProperty>> from FluentValidation and the BCL (SponsorValidationRules.cs:1-2,99,101), plus the EventIdentifierType alias (:101, aliased to int in MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7, see primer). No first-party types.
    • +
    • Depends on: AbstractValidator<T> and Expression<Func<T, TProperty>> from FluentValidation and the BCL (MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:1-2,99,101), plus the EventIdentifierType alias (:101, aliased to int in MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8, see primer). No first-party types.
    • Concept introduced: the parameterized rule set. This file is the first place in the sponsors slice where validation is packaged rather than written inline, so learn the shape here. A rule set is a sealed class Foo<T> : AbstractValidator<T> whose constructor takes an Expression<Func<T, TProperty>> selector (:101) and does nothing but call RuleFor(selector) with the field's contract attached. It is generic because the contract belongs to the concept ("a sponsor's event id"), not to any one request record: SponsorCreateRequest is a different type from SponsorUpdateRequest, yet both can reuse the identical object by supplying their own property selector. Consumers fold it in with FluentValidation's Include(...), which merges the included validator's rules into the host validator as if they had been typed there. The payoff is that a constraint has exactly one definition and many bindings, and a change to it cannot land on the create path while missing the update path. [Rubric §24, Forms/Validation/UX Safety] assesses whether input constraints are single-sourced and applied consistently at every entry point: this whole file exists to make that true for sponsors. [Rubric §1, SOLID]: each rule set has one reason to change, the contract of one field.
    • -
    • Walkthrough: the class is sealed and derives directly from AbstractValidator<T> (:98-99), unlike the seven length rule sets below it, which derive from the shared MMCA.Common bases. The whole body is an expression-bodied constructor (:101-103): RuleFor(selector).NotEmpty().WithMessage("You must specify an Event for the Sponsor").WithErrorCode("Sponsor.EventId.Required"). Two details matter. First, because EventIdentifierType is int, FluentValidation's NotEmpty() rejects the type default, so an omitted or zeroed event id fails rather than binding silently to 0. Second, WithErrorCode is contract, not decoration: the string Sponsor.EventId.Required is what an API client or a test keys on, while the message is the human-facing half.
    • +
    • Walkthrough: the class is sealed and derives directly from AbstractValidator<T> (:98-99), unlike the seven length rule sets above it, which derive from the shared MMCA.Common bases. The whole body is an expression-bodied constructor (:101-103): RuleFor(selector).NotEmpty().WithMessage("You must specify an Event for the Sponsor").WithErrorCode("Sponsor.EventId.Required"). Two details matter. First, because EventIdentifierType is int, FluentValidation's NotEmpty() rejects the type default, so an omitted or zeroed event id fails rather than binding silently to 0. Second, WithErrorCode is contract, not decoration: the string Sponsor.EventId.Required is what an API client or a test keys on, while the message is the human-facing half.
    • Why it's built this way: the XML doc states the business reason directly (:94-96), that sponsors are sold per event, so an unscoped sponsor has nowhere to appear. This rule is also the only enforcement of that fact at the application boundary: the Sponsor aggregate's Create composes name, logo URL, and booth number invariants but does not re-check the event id (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:120-122), because a zero-valued foreign key would already fail at the database.
    • -
    • Where it's used: included once, by SponsorCreateRequestValidator (MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestValidator.cs:12). SponsorUpdateRequestValidator deliberately does not include it, because SponsorUpdateRequest carries no event id at all: its <remarks> records that moving a sponsor between events is a create plus a delete, so a mistyped id cannot silently relocate bought placement (MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/SponsorUpdateRequest.cs:7-10).
    • +
    • Where it's used: included once, by SponsorCreateRequestValidator (MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestValidator.cs:12). SponsorUpdateRequestValidator deliberately does not include it (MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/SponsorUpdateRequestValidator.cs:11-18 includes eight rule sets and not this one), because SponsorUpdateRequest carries no event id at all: its <remarks> records that moving a sponsor between events is a create plus a delete, so a mistyped id cannot silently relocate bought placement (MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/SponsorUpdateRequest.cs:7-10).

    SponsorSortRules<T>

    @@ -2081,17 +2326,18 @@

    SponsorSortRules<T>

  • Walkthrough: sealed class SponsorSortRules<T> : AbstractValidator<T> (:110-111) with an expression-bodied constructor (:113-115) calling RuleFor(selector).GreaterThanOrEqualTo(0) with the message "Sort must be greater than or equal to 0" and the stable error code Sponsor.Sort.Negative.
  • Where it's used: included by both sponsor request validators, SponsorCreateRequestValidator (SponsorCreateRequestValidator.cs:13) and SponsorUpdateRequestValidator (SponsorUpdateRequestValidator.cs:12), which is the reuse this file is built for.
  • -

    ConferenceCategoryUpdateRequest

    +

    ActivityUpdateRequest

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.Update · MMCA.ADC.Conference.Application/Categories/UseCases/Update/ConferenceCategoryUpdateRequest.cs:6 · Level 1 · record

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.UseCases.Update · MMCA.ADC.Conference.Application/Activities/UseCases/Update/ActivityUpdateRequest.cs:10 · Level 1 · record

      -
    • What it is: the request DTO a client PUTs to update an existing conference Category. It carries the three editable fields plus the concurrency token the client last saw.
    • -
    • Depends on: IConcurrencyAware from MMCA.Common.Shared.DTOs (ConferenceCategoryUpdateRequest.cs:1,6). Nothing else: it is a pure payload record with no domain types in its members.
    • -
    • Concept introduced: the concurrency-aware update request. Every update request in this module is a record class implementing IConcurrencyAware, which contributes exactly one member, a nullable byte[]? RowVersion (:9). That token is the client's proof of what it read. The handler stamps it as the entity's original row version before saving, so EF Core compares it against the stored value on UPDATE and raises a concurrency exception (surfaced as HTTP 409) when someone else has written the row in the meantime. Without the round trip, a stale form silently overwrites a newer edit. Because the property is nullable, omitting it opts out of the check rather than failing closed, which is the framework's deliberate trade-off (ADR-035). [Rubric §8, Data Architecture] assesses how concurrent writes to one row are reconciled: this codebase chooses optimistic concurrency with an explicit client token over pessimistic locking or last-write-wins. [Rubric §9, API & Contract Design]: the token is part of the wire contract, so the round trip is visible to clients rather than hidden server state.
    • -
    • Walkthrough: RowVersion (:9) is init-only and nullable. Title (:12) is required string, the one field the validator guards. Sort (:15) is the display order, defaulting to zero. Type (:18) is the optional discriminator string, documented as "session" or "speaker" (:17). All four members are init-only, so the request is immutable once bound.
    • -
    • Why it's built this way: required on Title means the request cannot be constructed without a title, pushing the most basic contract violation to the model binder instead of the validator. Making the PUT a full replacement (rather than a patch) is what lets the handler pass every field straight through to the aggregate without distinguishing "not supplied" from "cleared".
    • -
    • Where it's used: bound from the body by ConferenceCategoriesController on PUT {id} (MMCA.ADC.Conference.API/Controllers/ConferenceCategoriesController.cs:103-107), wrapped in UpdateConferenceCategoryCommand (:110), validated by ConferenceCategoryUpdateRequestValidator, and consumed field by field by UpdateConferenceCategoryHandler.
    • +
    • What it is: the body a client PUTs to update an existing conference Activity, the non-session items on the agenda (receptions, breaks, after-parties). It carries every editable field plus the concurrency token the client last read.
    • +
    • Depends on: IConcurrencyAware from MMCA.Common.Shared.DTOs (MMCA.ADC.Conference.Application/Activities/UseCases/Update/ActivityUpdateRequest.cs:1,10). Nothing else: DateTime, string, int, and byte[] are BCL. There is not a single domain type in its member list, which is the point of a request DTO.
    • +
    • Concept introduced: none new. The concurrency-aware update request is taught at ConferenceCategoryUpdateRequest: a record class implementing IConcurrencyAware, contributing one nullable byte[]? RowVersion (:13) that the handler stamps as the entity's original row version so a competing write surfaces as a 409 rather than last-write-wins (ADR-035). What is distinctive here is the field that is deliberately missing. The record has no EventId, and the <remarks> says why: moving an activity between events is a create plus a delete, so a mistyped EventId cannot silently relocate a published social event (:6-9). That is the same rule SponsorUpdateRequest applies to bought sponsor placement, so the module has one consistent answer to "can a PUT reparent an aggregate?". [Rubric §9, API & Contract Design] assesses whether a contract makes the safe thing the only expressible thing: an operation that is dangerous is not merely validated against, it is absent from the type. [Rubric §4, DDD]: the owning event is part of the activity's identity within the conference, not an ordinary attribute, so it is not editable through the attribute-editing endpoint.
    • +
    • Walkthrough: RowVersion (:13) is nullable and init-only, carrying <inheritdoc /> from the interface. Name (:16) is the one required member, so the model binder rejects a body without it before any validator runs. Description (:19) is optional. StartTime and EndTime (:22, :25) are plain DateTime values documented as event-local (:21, :24). VenueName (:28) is optional and documented such that empty means the main conference venue, with VenueAddress (:31) and VenueUrl (:34) alongside it for off-site items. SortOrder (:37) breaks ties between activities that start at the same time (:36). Every member is init-only, so the bound request is immutable for the rest of the pipeline.
    • +
    • Why it's built this way: making the PUT a full replacement rather than a patch means the handler can pass every field straight through to the aggregate without distinguishing "not supplied" from "cleared" (MMCA.ADC.Conference.Application/Activities/UseCases/Update/UpdateActivityHandler.cs:34-42). Marking only Name as required pushes the single non-negotiable field to bind time and leaves the graded constraints (lengths, the time ordering) to ActivityUpdateRequestValidator, which can produce a readable message per field.
    • +
    • Where it's used: bound from the body by ActivitiesController on PUT {id} (MMCA.ADC.Conference.API/Controllers/ActivitiesController.cs:223-227), wrapped into UpdateActivityCommand (:231), validated by ActivityUpdateRequestValidator, and consumed field by field by UpdateActivityHandler.
    • +
    • Caveats / not-in-source: unlike the create path, which has an ActivityCreateRequestMapper, there is no ActivityUpdateRequestMapper; the update handler reads the eight members positionally instead. Nothing in the record pins a DateTimeKind, so whether the wire value arrives as UTC or unspecified local time is not determinable from this file: the doc comments only say "event-local" (:21, :24).

    SponsorBoothNumberRules<T>

    @@ -2100,10 +2346,10 @@

    SponsorBoothNumberRules<T>

    • What it is: the reusable length rule for a sponsor's optional expo booth number.
    • Depends on: OptionalStringRules<T> from MMCA.Common.Application.Validation (SponsorValidationRules.cs:4,87) and SponsorInvariants for the constant BoothNumberMaxLength (:3,90, value 50 at MMCA.ADC.Conference.Domain/Sponsors/SponsorInvariants.cs:31).
    • -
    • Concept introduced: the three-argument subclass, and why a length constant lives in the domain. The seven length rule sets in this file (this one, description, LinkedIn URL, logo URL, name, Twitter handle, website URL) are each a two-line sealed class whose constructor forwards to a shared MMCA.Common base with three arguments: the property selector, a human-facing field label, and a max length. The base does the actual work, RuleFor(selector).MaximumLength(maxLength) with a generated message (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:25-29). Two design points are worth internalizing. First, the subclass exists purely to name the pairing of a field with its constant, so callers write new SponsorBoothNumberRules<T>(p => p.BoothNumber) and cannot accidentally bind the booth-number field to the description's length. Second, the constant is imported from the Domain layer, not declared here: SponsorInvariants's own doc comment states that its length constants are referenced by both domain validation and EF configuration to keep constraints in sync (SponsorInvariants.cs:6-9), and the EF entity configuration does exactly that (MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SponsorConfiguration.cs:56). One number therefore governs the validator's message, the domain guard where one exists, and the column width, so a 51-character booth number is rejected with a readable error instead of truncating or throwing at the database. That dependency on a Domain constant is also why these seven sit at Level 7 while the two inline rule sets above sit at Level 0. [Rubric §8, Data Architecture] assesses whether storage constraints and application constraints agree; [Rubric §16, Maintainability]: widening a column is a one-constant change that propagates to every layer that cares.
    • +
    • Concept introduced: the three-argument subclass, and why a length constant lives in the domain. The seven length rule sets in this file (this one, description, LinkedIn URL, logo URL, name, Twitter handle, website URL) are each a two-line sealed class whose constructor forwards to a shared MMCA.Common base with three arguments: the property selector, a human-facing field label, and a max length. The base does the actual work, RuleFor(selector).MaximumLength(maxLength) with a generated message (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:25-29). Two design points are worth internalizing. First, the subclass exists purely to name the pairing of a field with its constant, so callers write new SponsorBoothNumberRules<T>(p => p.BoothNumber) and cannot accidentally bind the booth-number field to the description's length. Second, the constant is imported from the Domain layer, not declared here: SponsorInvariants's own doc comment states that its length constants are referenced by both domain validation and EF configuration to keep constraints in sync (SponsorInvariants.cs:6-9), and the EF entity configuration does exactly that (MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SponsorConfiguration.cs:56). One number therefore governs the validator's message, the domain guard where one exists, and the column width, so a 51-character booth number is rejected with a readable error instead of truncating or throwing at the database. That dependency on a Domain constant is also why these seven sit at Level 7 while the two inline rule sets below them sit at Level 0. [Rubric §8, Data Architecture] assesses whether storage constraints and application constraints agree; [Rubric §16, Maintainability]: widening a column is a one-constant change that propagates to every layer that cares.
    • Walkthrough: sealed class SponsorBoothNumberRules<T> : OptionalStringRules<T> (:86-87); the constructor takes Expression<Func<T, string?>> selector (:89) and calls : base(selector, "Booth Number", SponsorInvariants.BoothNumberMaxLength) (:90). There is no body. The selector type is nullable, which is the whole difference between the optional base and the required one: a null booth number passes.
    • Why it's built this way: the field is optional in the domain too. SponsorInvariants.EnsureBoothNumberIsValid short-circuits to success on a null or empty value and otherwise applies the same constant (SponsorInvariants.cs:63-66), and its doc comment records the deliberate rule that a booth number is accepted even when the sponsor is not flagged as an exhibitor, because the flag drives display and does not reject stored data (:56-59).
    • -
    • Where it's used: included by SponsorCreateRequestValidator (SponsorCreateRequestValidator.cs:19) and SponsorUpdateRequestValidator (SponsorUpdateRequestValidator.cs:18).
    • +
    • Where it's used: included by SponsorCreateRequestValidator (SponsorCreateRequestValidator.cs:19) and SponsorUpdateRequestValidator (SponsorUpdateRequestValidator.cs:18); re-checked in the aggregate through SponsorInvariants.EnsureBoothNumberIsValid on both Create and Update (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:122,168).

    SponsorDescriptionRules<T>

    @@ -2126,7 +2372,7 @@

    SponsorLinkedInUrlRules<T>

  • Depends on: OptionalStringRules<T> (SponsorValidationRules.cs:4,63) and SponsorInvariants.LinkedInUrlMaxLength (:66, value 2000 at SponsorInvariants.cs:25).
  • Concept introduced: none new; see SponsorBoothNumberRules<T>. The label is "LinkedIn URL" (:66).
  • Walkthrough: sealed class SponsorLinkedInUrlRules<T> : OptionalStringRules<T> (:62-63), one forwarding constructor (:65-66).
  • -
  • Caveats / not-in-source: length only. Nothing in this rule set checks that the value is a well-formed URL or that it points at linkedin.com, and there is no matching domain invariant. Whether a client-side control constrains the input is not determinable from this file.
  • +
  • Caveats / not-in-source: length only. Nothing in this rule set checks that the value is a well-formed URL or that it points at linkedin.com, and there is no matching domain invariant (SponsorInvariants.cs:39-66). Whether a client-side control constrains the input is not determinable from this file.
  • Where it's used: included by SponsorCreateRequestValidator (SponsorCreateRequestValidator.cs:17) and SponsorUpdateRequestValidator (SponsorUpdateRequestValidator.cs:16).
  • SponsorLogoUrlRules<T>

    @@ -2147,7 +2393,7 @@

    SponsorNameRules<T>

    • What it is: the reusable rule set for the sponsor's display name: the one sponsor string that is mandatory as well as bounded.
    • Depends on: RequiredStringRules<T> from MMCA.Common.Application.Validation (SponsorValidationRules.cs:4,14) and SponsorInvariants.NameMaxLength (:17, value 200 at SponsorInvariants.cs:13).
    • -
    • Concept introduced: required versus optional, chosen by base class. This is the one rule set in the file that derives from RequiredStringRules<T> rather than OptionalStringRules<T>, and that single choice is the whole difference in behavior. The required base chains NotEmpty() ahead of MaximumLength(...) and takes a non-nullable Expression<Func<T, string>> selector (CommonValidationRules.cs:13-18); the optional base takes a nullable selector and declares only the length rule (:25-29). So "is this field mandatory?" is answered once, by which base you extend, and the compiler helps: binding a string? property to this rule set will not compile. [Rubric §1, SOLID]: two small bases, each with one responsibility, compose into every field contract in the module. [Rubric §24, Forms/Validation/UX Safety]: mandatory-ness is declared in one place per field rather than restated per request record.
    • +
    • Concept introduced: required versus optional, chosen by base class. This is the one rule set in the file that derives from RequiredStringRules<T> rather than OptionalStringRules<T>, and that single choice is the whole difference in behavior. The required base chains NotEmpty() ahead of MaximumLength(...) and takes a non-nullable Expression<Func<T, string>> selector (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:13-18); the optional base takes a nullable selector and declares only the length rule (:25-29). So "is this field mandatory?" is answered once, by which base you extend, and the compiler helps: binding a string? property to this rule set will not compile. [Rubric §1, SOLID]: two small bases, each with one responsibility, compose into every field contract in the module. [Rubric §24, Forms/Validation/UX Safety]: mandatory-ness is declared in one place per field rather than restated per request record.
    • Walkthrough: sealed class SponsorNameRules<T> : RequiredStringRules<T> (:13-14); the constructor takes Expression<Func<T, string>> selector (:16) and forwards (selector, "Sponsor Name", SponsorInvariants.NameMaxLength) (:17). The base produces two messages: "You must enter a Sponsor Name" and "Sponsor Name cannot be longer than 200 characters" (CommonValidationRules.cs:17-18).
    • Why it's built this way: validation here is the fast, message-friendly first pass, not the authority. The Sponsor aggregate re-checks the same rule through SponsorInvariants.EnsureNameIsValid in both Create and Update (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:120,166), which returns Result errors carrying the stable codes Sponsor.Name.Empty and Sponsor.Name.TooLong (SponsorInvariants.cs:41-42). A caller that bypasses the request pipeline still cannot create a nameless sponsor.
    • Where it's used: included by SponsorCreateRequestValidator (SponsorCreateRequestValidator.cs:11) and SponsorUpdateRequestValidator (SponsorUpdateRequestValidator.cs:11).
    • @@ -2176,405 +2422,428 @@

      SponsorWebsiteUrlRules<T>

    • Caveats / not-in-source: length only, with no domain counterpart, exactly as for the LinkedIn field. The rendered sponsor link is therefore whatever an organizer typed, so the escaping burden sits with the UI layer.
    • Where it's used: included by SponsorCreateRequestValidator (SponsorCreateRequestValidator.cs:16) and SponsorUpdateRequestValidator (SponsorUpdateRequestValidator.cs:15).
    -

    UpdateConferenceCategoryCommand

    +

    ActivityUpdateRequestValidator

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.Update · MMCA.ADC.Conference.Application/Categories/UseCases/Update/UpdateConferenceCategoryCommand.cs:9 · Level 7 · record

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.UseCases.Update · MMCA.ADC.Conference.Application/Activities/UseCases/Update/ActivityUpdateRequestValidator.cs:7 · Level 8 · class

      -
    • What it is: the write intent for updating a conference Category. It pairs the target Id with the ConferenceCategoryUpdateRequest payload and opts the operation into cache eviction.
    • -
    • Depends on: ICommandWithRequest<out TRequest> and ICacheInvalidating from MMCA.Common.Application.UseCases (UpdateConferenceCategoryCommand.cs:2,9), the ConferenceCategoryUpdateRequest it wraps (:9), and the Category type used only for its FullName in the cache prefix (:1,12). ConferenceCategoryIdentifierType is the module identifier alias.
    • -
    • Concept introduced: the id-plus-request command, and validation by delegation. A create path can let the request record double as the command, because the identity is inside the body. An update cannot: the target id arrives on the route and the payload arrives in the body, so the command exists to marry the two into one object the pipeline can dispatch. ICommandWithRequest<out TRequest> is the framework contract that makes this shape legible to the decorators: it exposes the wrapped request, so the ValidatingCommandDecorator<TCommand, TResult> can reach the validator registered against the request type rather than against the command. That is why ConferenceCategoryUpdateRequestValidator validates ConferenceCategoryUpdateRequest and no UpdateConferenceCategoryCommandValidator file exists to keep in sync. The second marker, ICacheInvalidating, opts the command into the caching decorator so a successful update evicts the category read cache. [Rubric §6, CQRS & Event-Driven] assesses whether writes are explicit intents flowing through a uniform pipeline: both cross-cutting behaviors attach declaratively through marker interfaces, with no wiring inside the handler (ADR-014).
    • -
    • Walkthrough: two positional parameters, Id and Request, with both interfaces implemented on the same declaration line (:9). CachePrefix (:12) is an expression-bodied property returning $"{typeof(Category).FullName}:", the key namespace the CachingCommandDecorator<TCommand, TResult> wipes after a successful handle. There is no other member; the positional Request parameter satisfies the interface property with no extra code.
    • -
    • Why it's built this way: deriving the cache prefix from typeof(Category).FullName rather than a literal string keeps the writer (this command) and the reader (the category query cache) agreed on one key namespace that a rename cannot desynchronize.
    • -
    • Where it's used: constructed by ConferenceCategoriesController from the route id and body (MMCA.ADC.Conference.API/Controllers/ConferenceCategoriesController.cs:109-111) and handled by UpdateConferenceCategoryHandler.
    • +
    • What it is: the FluentValidation validator for ActivityUpdateRequest. It owns no rules of its own; it is a seven-line list of Include(...) calls that assembles the activity field rule sets.
    • +
    • Depends on: AbstractValidator<T> from FluentValidation (MMCA.ADC.Conference.Application/Activities/UseCases/Update/ActivityUpdateRequestValidator.cs:1,7) and the reusable activity rule sets in MMCA.ADC.Conference.Application.Activities.Validation (:2): ActivityNameRules<T> (:11), ActivityTimeRangeRules<T> (:12), ActivitySortOrderRules<T> (:13), ActivityDescriptionRules<T> (:14), ActivityVenueNameRules<T> (:15), ActivityVenueAddressRules<T> (:16), and ActivityVenueUrlRules<T> (:17).
    • +
    • Concept introduced: the composed validator, and the create/update rule delta. The parameterized rule set taught at SponsorEventIdRules<T> only pays off if request validators are assembled from those parts rather than hand-written, and this class is the assembly step: Include(...) folds an included validator's rules into this one, and because each rule set is generic the same object serves the create and update records with different property selectors. The instructive part is what differs between the two lists. ActivityCreateRequestValidator includes eight rule sets, one of which is ActivityEventIdRules<ActivityCreateRequest> (MMCA.ADC.Conference.Application/Activities/UseCases/Create/ActivityCreateRequestValidator.cs:12); this validator includes seven and omits that one, because there is no EventId on the update record to validate. The delta between the two validators is therefore exactly the delta between the two contracts, which is what you want when auditing "does the update path enforce everything the create path does?". [Rubric §24, Forms/Validation/UX Safety] assesses whether every write entry point applies the same field constraints: here the shared set is literally shared objects, and the single difference is structural rather than an oversight. [Rubric §5, Vertical Slice]: the validator lives in the UseCases/Update folder beside the request, command, and handler it serves, not in a module-wide validators bucket.
    • +
    • Walkthrough: sealed class ActivityUpdateRequestValidator : AbstractValidator<ActivityUpdateRequest> (:7) with a parameterless constructor (:9-18) containing seven Include(new XRules<ActivityUpdateRequest>(p => p.Field)) statements. Two are not plain length rules. ActivityTimeRangeRules<T> (:12) takes two selectors, start and end, and registers three rules: NotEmpty on each with codes Activity.StartTime.Required and Activity.EndTime.Required, then a cross-field Must that compiles the start selector once and asserts endTime >= startTimeFunc(instance) with code Activity.EndTime.BeforeStart (MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:87,90-104). ActivitySortOrderRules<T> (:13) is the integer floor rule, GreaterThanOrEqualTo(0) with code Activity.SortOrder.Negative (ActivityValidationRules.cs:111,114-116). The remaining five are the required/optional string bases already taught in the sponsor rule sets.
    • +
    • Why it's built this way: cross-field ordering (end after start) cannot be expressed by a per-property rule set, so it is packaged as a two-selector rule set instead of being written inline here. That keeps this class purely declarative: nothing in it can drift from the create path except by adding or removing a line, which is visible in review.
    • +
    • Where it's used: never constructed by hand. ScanModuleApplicationServices<ClassReference>() calls FluentValidation's AddValidatorsFromAssemblyContaining<TAssemblyMarker>() (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:140,190, invoked at MMCA.ADC.Conference.Application/DependencyInjection.cs:125), so this validator is registered as IValidator<ActivityUpdateRequest> and picked up automatically. Because UpdateActivityCommand implements ICommandWithRequest<ActivityUpdateRequest>, the framework wires a CommandRequestValidator<TCommand, TRequest> that delegates to it (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/ICommandWithRequest.cs:5-11), and ValidatingCommandDecorator<TCommand, TResult> runs it before the handler.
    -

    ConferenceCategoryUpdateRequestValidator

    +

    UpdateActivityCommand

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.Update · MMCA.ADC.Conference.Application/Categories/UseCases/Update/ConferenceCategoryUpdateRequestValidator.cs:7 · Level 8 · class

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.UseCases.Update · MMCA.ADC.Conference.Application/Activities/UseCases/Update/UpdateActivityCommand.cs:9 · Level 9 · record

      -
    • What it is: the FluentValidation validator for ConferenceCategoryUpdateRequest, run by the pipeline before the update handler executes.
    • -
    • Depends on: AbstractValidator<T> (FluentValidation, ConferenceCategoryUpdateRequestValidator.cs:1,7), ConferenceCategoryUpdateRequest, and the shared ConferenceCategoryTitleRules<T> rule set from MMCA.ADC.Conference.Application.Categories.Validation (:2,10).
    • -
    • Concept introduced: none new; this is the Include composition taught for the sponsor rule sets above, in its smallest possible form. The entire class body is one expression-bodied constructor (:9-10) folding in a single parameterized rule set: Include(new ConferenceCategoryTitleRules<ConferenceCategoryUpdateRequest>(p => p.Title)). The same rule object is included by the create-side category validator against a different request type, which is the whole point: the title contract is declared once and every entry path inherits it, complete with the stable error code Category.Title.Required that the rule set attaches (MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:18). [Rubric §24, Forms/Validation/UX Safety] assesses whether input constraints are single-sourced and consistently applied; [Rubric §1, SOLID]: this validator's only job is composition, so a title-rule change never has to be found in two files.
    • -
    • Walkthrough: sealed class ConferenceCategoryUpdateRequestValidator : AbstractValidator<ConferenceCategoryUpdateRequest> (:7); the constructor (:9-10) is the single Include. RowVersion, Sort, and Type carry no rules here: a null concurrency token is a legitimate "skip the check" signal rather than an error, and the other two have no field-level business constraint.
    • -
    • Where it's used: discovered by assembly scanning and invoked by the ValidatingCommandDecorator<TCommand, TResult> ahead of UpdateConferenceCategoryHandler, reached through UpdateConferenceCategoryCommand's ICommandWithRequest<out TRequest> implementation.
    • +
    • What it is: the CQRS command that carries an activity id plus its update payload to the handler. It is a two-parameter positional record, and it also declares that a successful run should evict the activity query cache.
    • +
    • Depends on: ActivityUpdateRequest (MMCA.ADC.Conference.Application/Activities/UseCases/Update/UpdateActivityCommand.cs:9), the ActivityIdentifierType alias (:9, aliased to int at MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:5), the Activity entity type, used only as a typeof argument (:1,12), and two MMCA.Common marker interfaces, ICommandWithRequest<out TRequest> and ICacheInvalidating (:2,9).
    • +
    • Concept introduced: markers as pipeline configuration. This record has no logic, yet the two interfaces it implements change what the decorator pipeline does around it, which is the pattern to internalize. ICommandWithRequest<TRequest> says "my Request property is the thing to validate", and the framework's convention registration turns that into an IValidator<TCommand> that delegates to IValidator<TRequest> via FluentValidation's SetValidator, using TryAdd semantics so an explicit command validator would win (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/ICommandWithRequest.cs:5-11,14-17). ICacheInvalidating contributes a single CachePrefix string (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/ICacheInvalidating.cs:8-14), and CachingCommandDecorator<TCommand, TResult> evicts by that prefix only after the inner handler returns a non-failure result, scoping the prefix to the current tenant first (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingCommandDecorator.cs:76-88). Two details in that decorator are worth reading once: the empty-prefix guard is load-bearing because RemoveByPrefixAsync("") would evict the entire cache (:74-78), and a second delayed eviction fires afterwards to catch an in-flight read that repopulated a stale entry (:96). [Rubric §6, CQRS & Event-Driven] assesses whether write intent is modelled as an explicit message with cross-cutting behavior attached declaratively: it is, and the command is the only place that behavior is configured. [Rubric §10, Cross-Cutting]: caching, validation, logging, and transactions are decorators around the handler rather than calls inside it.
    • +
    • Walkthrough: the whole type is three lines of substance. sealed record UpdateActivityCommand(ActivityIdentifierType Id, ActivityUpdateRequest Request) (:9) gives the record its two members; Request satisfies the ICommandWithRequest<ActivityUpdateRequest> contract by name. CachePrefix (:12) is an expression-bodied property returning $"{typeof(Activity).FullName}:", so the prefix is the entity's fully qualified type name with a trailing colon. Deriving it from typeof rather than a literal means a rename of the entity moves the prefix with it, and it matches the key shape the query side writes.
    • +
    • Why it's built this way: an id-plus-request command keeps the route parameter and the body as separate, typed things all the way to the handler, so the handler never has to trust an id embedded in the payload. The cache prefix on the command rather than in the handler is what lets the eviction happen after the transaction decorator commits, which is the only ordering that cannot leave a freshly repopulated stale entry behind.
    • +
    • Where it's used: constructed by ActivitiesController in its PUT {id} action (MMCA.ADC.Conference.API/Controllers/ActivitiesController.cs:231) and handled by UpdateActivityHandler.
    -

    UpdateConferenceCategoryHandler

    +

    UpdateActivityHandler

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.Update · MMCA.ADC.Conference.Application/Categories/UseCases/Update/UpdateConferenceCategoryHandler.cs:15 · Level 9 · class

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.UseCases.Update · MMCA.ADC.Conference.Application/Activities/UseCases/Update/UpdateActivityHandler.cs:15 · Level 10 · class

      -
    • What it is: the handler for UpdateConferenceCategoryCommand: load the Category, stamp the client's concurrency token, delegate the field changes to the aggregate's Update, save, log, and return the updated DTO.
    • -
    • Depends on: ICommandHandler<in TCommand, TResult> (UpdateConferenceCategoryHandler.cs:6,18), IUnitOfWork (:5,16), ConferenceCategoryDTOMapper (:2,17), Result and Error (:7), the Category aggregate (:3), ConferenceCategoryDTO (:4), and ILogger<T> from Microsoft.Extensions.Logging (:1,18).
    • -
    • Concept introduced: the optimistic-concurrency round trip inside a handler. This is the canonical update shape in the module, and its one non-obvious line is repository.SetOriginalRowVersion(entity, command.Request.RowVersion) (:32). EF Core would otherwise use the row version it loaded a moment ago as the WHERE predicate on UPDATE, comparing the row against itself and always succeeding. Overwriting the original value with the token the client last saw changes the question to "has anyone written this row since the client read it?". If someone has, SaveChangesAsync raises DbUpdateConcurrencyException, which the shared exception middleware turns into HTTP 409 instead of a silent last-write-wins; the in-code comment states exactly this (:30-31). A null token skips the check, the documented opt-out from ADR-035. [Rubric §8, Data Architecture] assesses concurrent-write reconciliation. [Rubric §4, Domain-Driven Design]: the handler assigns no properties itself, it calls entity.Update(...) (:34-37) so the aggregate re-checks its own invariants and raises CategoryChanged (MMCA.ADC.Conference.Domain/Categories/Category.cs:84,95). [Rubric §13, Observability & Operability]: the [LoggerMessage] source-generated log (:49-50) is compile-time and allocation-free.
    • -
    • Walkthrough: the class is sealed partial with a primary constructor for DI (:15-18), partial because [LoggerMessage] generates the log method's body into the other half. HandleAsync (:21-23) gets the typed repository (:25), loads by id (:26), and returns Error.NotFound tagged with source and target when the category is absent (:27-28). It stamps the row version (:32), calls entity.Update(command.Request.Title, command.Request.Sort, command.Request.Type) (:34-37), and short-circuits with the aggregate's own errors on failure (:39-40). On success it awaits SaveChangesAsync with ConfigureAwait(false) (:42), the single save that also persists the domain event through the outbox, emits LogConferenceCategoryUpdated with the category id (:44), and returns Result.Success(dtoMapper.MapToDTO(entity)) (:46). The [LoggerMessage] declaration sits at :49-50 with level Information and the template "Conference category {CategoryId} updated".
    • -
    • Why it's built this way: the handler opens no transaction and evicts no cache. Those are the transactional and caching decorators' jobs, driven by UpdateConferenceCategoryCommand's ICacheInvalidating marker, which keeps every command's cross-cutting behavior uniform (ADR-014). Mapping the tracked entity after the save means the returned DTO reflects anything the domain normalized.
    • -
    • Where it's used: injected into ConferenceCategoriesController as ICommandHandler<UpdateConferenceCategoryCommand, Result<ConferenceCategoryDTO>> (MMCA.ADC.Conference.API/Controllers/ConferenceCategoriesController.cs:35) and invoked on PUT {id} (:109-111), after which the controller separately evicts the tagged HTTP output cache (:116).
    • +
    • What it is: the command handler that applies an ActivityUpdateRequest to a stored activity. It is the canonical shape of an update handler in this codebase: load, stamp the concurrency token, delegate to the aggregate, save, map, return.
    • +
    • Depends on: IUnitOfWork (MMCA.ADC.Conference.Application/Activities/UseCases/Update/UpdateActivityHandler.cs:5,16), ActivityDTOMapper (:2,17), ILogger<UpdateActivityHandler> from Microsoft.Extensions.Logging (:1,18), the Activity aggregate (:3,25), ActivityDTO (:4,18), and Result / Error from MMCA.Common.Shared.Abstractions (:7,28). It implements ICommandHandler<in TCommand, TResult> closed over UpdateActivityCommand and Result<ActivityDTO> (:18).
    • +
    • Concept introduced: the concurrency stamp, and why the handler never touches the DbContext. Everything about persistence goes through IUnitOfWork: the handler asks it for a repository (:25) rather than injecting IRepository<,> directly, which is the workspace rule, and it calls unitOfWork.SaveChangesAsync (:47) rather than a context. The step that is easy to miss is line 32, repository.SetOriginalRowVersion(entity, command.Request.RowVersion). EF Core has just loaded the row and recorded its current RowVersion as the original value, so an UPDATE would concur with whatever is in the database right now, which is last-write-wins. Overwriting the tracked original value with the token the client sent makes the generated WHERE clause compare against what the client actually read, so a row someone else changed in between produces zero affected rows and a DbUpdateConcurrencyException, surfaced as HTTP 409. The framework implementation is deliberately forgiving: a null or zero-length token returns early and skips the check entirely (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFRepository.cs:75-84), so a client that omits the token opts out rather than being rejected (ADR-035). [Rubric §8, Data Architecture] assesses how concurrent writes to one row are reconciled: optimistic concurrency with a client-carried token, not pessimistic locking. [Rubric §3, Clean Architecture]: the handler names only Application-layer abstractions and the Domain aggregate, so nothing EF-shaped leaks into the use case. [Rubric §13, Observability & Operability]: the success log is a source-generated [LoggerMessage], not string interpolation.
    • +
    • Walkthrough: the class is a sealed partial class with a primary constructor taking the three dependencies (:15-18); partial is required because the logging source generator emits the other half. HandleAsync (:21-52) runs six steps. (1) Get the typed repository from the unit of work, GetRepository<Activity, ActivityIdentifierType>() (:25). (2) GetByIdAsync(command.Id, ...) (:26); a null entity returns Result.Failure<ActivityDTO>(Error.NotFound.WithSource(nameof(UpdateActivityHandler)).WithTarget(nameof(Activity))) (:27-28), so the 404 carries which handler produced it and which aggregate was missing rather than a bare message. (3) Stamp the client's token (:32), with the comment above it recording the intent (:30-31). (4) Delegate to the aggregate: entity.Update(...) with the eight request fields in order (:34-42). The domain method, not the handler, is what validates: it composes name, time-range, venue-name, venue-address, and venue-URL invariants through Result.Combine and returns early on failure before mutating anything (MMCA.ADC.Conference.Domain/Activities/Activity.cs:145,155-162), then assigns the fields and raises ActivityChanged with DomainEntityState.Updated (:164-173). A failed result is propagated by errors, not exceptions (:44-45). (5) await unitOfWork.SaveChangesAsync(cancellationToken) (:47), which is where audit stamping, the domain-event dispatch, and the concurrency comparison all happen. (6) Log and map: LogActivityUpdated(logger, command.Id) (:49) then Result.Success(dtoMapper.MapToDTO(entity)) (:51). The log method itself is the generator-backed partial at :54-55, [LoggerMessage(Level = LogLevel.Information, Message = "Activity {ActivityId} updated")].
    • +
    • Why it's built this way: the handler is deliberately thin. Field-shape validation already ran in the decorator pipeline via ActivityUpdateRequestValidator, business invariants live in ActivityInvariants behind Activity.Update, transactions and cache eviction are decorators configured by UpdateActivityCommand, and mapping is a Mapperly-generated method on ActivityDTOMapper (ADR-001). What remains in the handler is only the orchestration that is genuinely specific to this use case, which is why it reads as a linear list of six steps.
    • +
    • Where it's used: resolved as ICommandHandler<UpdateActivityCommand, Result<ActivityDTO>> and injected into ActivitiesController's primary constructor (MMCA.ADC.Conference.API/Controllers/ActivitiesController.cs:40), invoked from the PUT {id} action guarded by [HasPermission(ConferencePermissions.ActivitiesManage)] (:223-232). Registration is convention-based: the ICommandHandler<,> assembly scan inside ScanModuleApplicationServices<TAssemblyMarker>() picks it up with scoped lifetime (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:178-182, called at MMCA.ADC.Conference.Application/DependencyInjection.cs:125), so the injected instance is the decorated pipeline, not the bare class.
    • +
    • Caveats / not-in-source: the controller evicts output-cache tags after a successful update (ActivitiesController.cs:238, evicting conference:activities and conference at :253-257). That is a second, distinct cache from the one UpdateActivityCommand.CachePrefix addresses: the response cache at the HTTP boundary versus the query-result cache inside the decorator pipeline. Both are evicted on this path; nothing in these files coordinates them beyond both being triggered by the same request.
    • +
    +

    ConferenceCategoryUpdateRequest

    +
    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.Update · MMCA.ADC.Conference.Application/Categories/UseCases/Update/ConferenceCategoryUpdateRequest.cs:6 · Level 1 · record

    +
    +
      +
    • What it is: the request DTO a client PUTs to update an existing conference Category. It carries the three editable fields plus the concurrency token the client last saw.
    • +
    • Depends on: IConcurrencyAware from MMCA.Common.Shared.DTOs (ConferenceCategoryUpdateRequest.cs:1,6). Nothing else: it is a pure payload record with no domain types in its members.
    • +
    • Concept introduced: the concurrency-aware update request. Every update request in this module is a record class implementing IConcurrencyAware, which contributes exactly one member, a nullable byte[]? RowVersion (:9). That token is the client's proof of what it read. The handler stamps it as the entity's original row version before saving, so EF Core compares it against the stored value on UPDATE and raises a concurrency exception (surfaced as HTTP 409) when someone else has written the row in the meantime. Without the round trip, a stale form silently overwrites a newer edit. Because the property is nullable, omitting it opts out of the check rather than failing closed, which is the framework's deliberate trade-off (ADR-035) and is stated on the contract itself (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:276-284). [Rubric §8, Data Architecture] assesses how concurrent writes to one row are reconciled: this codebase chooses optimistic concurrency with an explicit client token over pessimistic locking or last-write-wins. [Rubric §9, API & Contract Design]: the token is part of the wire contract, so the round trip is visible to clients rather than hidden server state.
    • +
    • Walkthrough: RowVersion (:9) is init-only and nullable. Title (:12) is required string, the one field the validator guards. Sort (:15) is the display order, defaulting to zero. Type (:18) is the optional discriminator string, documented as "session" or "speaker" (:17). All four members are init-only, so the request is immutable once bound.
    • +
    • Why it's built this way: required on Title means the request cannot be constructed without a title, pushing the most basic contract violation to the model binder instead of the validator. Making the PUT a full replacement (rather than a patch) is what lets the handler pass every field straight through to the aggregate without distinguishing "not supplied" from "cleared".
    • +
    • Where it's used: bound from the body by ConferenceCategoriesController on PUT {id} (MMCA.ADC.Conference.API/Controllers/ConferenceCategoriesController.cs:103-107), wrapped in UpdateConferenceCategoryCommand (:110), validated by ConferenceCategoryUpdateRequestValidator, and consumed field by field by UpdateConferenceCategoryHandler.

    EventUpdateRequest

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Update · MMCA.ADC.Conference.Application/Events/UseCases/Update/EventUpdateRequest.cs:7 · Level 1 · record

      -
    • What it is: the body a client PUTs to update an existing Event. It is a full replacement payload: every editable field of the event edition plus the concurrency token the client last read.
    • -
    • Depends on: IConcurrencyAware from MMCA.Common.Shared.DTOs (EventUpdateRequest.cs:2,7) and the QuestionModerationDefault enum from MMCA.ADC.Conference.Shared.Events (:1,40). Nothing else: DateOnly and byte[] are BCL.
    • -
    • Concept introduced: none new. The concurrency-aware update request is taught at ConferenceCategoryUpdateRequest; the same record class plus nullable byte[]? RowVersion shape repeats here. What this request adds is an enum-valued field on the wire. QuestionModerationDefault (:40) is the only non-string, non-date member with a domain meaning, and it is deliberately not required, so an omitted value binds to the enum's zero member Pending (MMCA.ADC.Conference.Shared/Events/QuestionModerationDefault.cs:10). [Rubric §9, API & Contract Design] assesses whether the contract is explicit about what a client must send: seven members here are required and the compiler enforces them at bind time, while the optional remainder is genuinely optional. [Rubric §24, Forms/Validation/UX Safety]: the enum is re-checked by EventUpdateRequestValidator with IsInEnum(), because model binding will happily deserialize an out-of-range integer into an enum-typed property.
    • -
    • Walkthrough: RowVersion (:10) is the init-only concurrency token. Name (:13), StartDate (:19), EndDate (:22), and TimeZone (:25) are required, so they cannot be omitted. Description (:16), SessionizeCode (:28), VenueAddress (:31), VenueMapUrl (:34), and WiFiInfo (:37) are nullable strings that make up the venue-and-logistics half of the payload. QuestionModerationDefault (:40) carries the live-layer moderation policy for the edition (BR-233). OrganizerContactEmail (:43) and SponsorshipPacketUrl (:46) are the two attendee-facing optional links. Every member is init-only, so the bound instance is immutable for the whole pipeline.
    • -
    • Why it's built this way: TimeZone is required rather than optional because it is the interpretation key for every session time under the event, and UpdateEventHandler compares it against the stored value to decide whether to raise the BR-131 warning (MMCA.ADC.Conference.Application/Events/UseCases/Update/UpdateEventHandler.cs:36). A nullable time zone would make "not supplied" indistinguishable from "cleared" on the one field where that ambiguity is most expensive.
    • -
    • Where it's used: bound from the body by EventsController on PUT {id} (MMCA.ADC.Conference.API/Controllers/EventsController.cs:266-269), wrapped into UpdateEventCommand (:272), validated by EventUpdateRequestValidator, and read field by field by UpdateEventHandler (UpdateEventHandler.cs:48-60).
    • -
    • Caveats / not-in-source: because the request is a full replacement and QuestionModerationDefault is not required, a client that omits the field sets the edition's moderation default to Pending, since both the request member (:40) and the domain method's parameter default (MMCA.ADC.Conference.Domain/Events/Event.cs:227) land on the zero member. Whether the admin UI always sends the current value is not determinable from this file.
    • +
    • What it is: the full-replacement payload for editing a conference Event: identity and schedule (name, description, dates, time zone), the Sessionize link code, the venue and logistics fields an attendee sees, the per-event question moderation default, and the outward-facing contact and URLs an edition publishes.
    • +
    • Depends on: IConcurrencyAware from MMCA.Common.Shared.DTOs (EventUpdateRequest.cs:2,7) and the QuestionModerationDefault enum from MMCA.ADC.Conference.Shared.Events (:1,40). Everything else is a BCL primitive, including DateOnly for the two dates (:19,22).
    • +
    • Concept introduced: date-only scheduling plus a named time zone, rather than an offset. The event's span is two DateOnly values (:19,22) and its TimeZone is a string documented as an IANA identifier (:24-25). Nothing here is a DateTimeOffset, so the record cannot silently bake in a UTC offset that is wrong half the year: the calendar day is the fact, and the zone id is how any consumer resolves a wall-clock session time to an instant. That choice is what makes the time zone load-bearing enough to earn its own business rule on the update path (BR-131, see UpdateEventHandler). [Rubric §8, Data Architecture] assesses whether temporal data is modeled so that it survives daylight-saving transitions and re-hosting; [Rubric §27, i18n]: an IANA id is the portable, culture-neutral way to express when this conference happens, and it is validated for real against the host's time zone database by EventTimeZoneRules<T> (MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:34-48).
    • +
    • Walkthrough: RowVersion (:10) is the IConcurrencyAware token. Four members are required and therefore cannot be omitted by a caller: Name (:13), StartDate (:19), EndDate (:22), and TimeZone (:25). The optional strings are Description (:16), SessionizeCode (:28, the code that ties this edition to a Sessionize event feed), VenueAddress (:31), VenueMapUrl (:34), WiFiInfo (:37), OrganizerContactEmail (:43), SponsorshipPacketUrl (:46), and TicketingUrl (:49). QuestionModerationDefault (:40) is the one enum member, documented as the BR-233 moderation default for live-layer session questions. Every member is init-only.
    • +
    • Why it's built this way: the update request carries one field the create request does not. EventCreateRequest has no QuestionModerationDefault member and EventCreateRequestMapper omits the argument entirely when it calls the factory (MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequestMapper.cs:19-32), so a new event takes the domain default Pending (MMCA.ADC.Conference.Domain/Events/Event.cs:175, enum values at MMCA.ADC.Conference.Shared/Events/QuestionModerationDefault.cs:10,13). Moderation posture is therefore something an organizer opts into after the event exists rather than a decision forced at creation time, and the cautious value is the one you get by default. [Rubric §11, Security] assesses whether defaults fail safe: unmoderated display of attendee-submitted text is the riskier state, and it is never the implicit one.
    • +
    • Where it's used: bound from the body by EventsController on PUT {id} (MMCA.ADC.Conference.API/Controllers/EventsController.cs:266-270), validated by EventUpdateRequestValidator, wrapped in UpdateEventCommand (:273), and passed field by field into Event.Update by UpdateEventHandler.

    QuestionUpdateRequest

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Questions.UseCases.Update · MMCA.ADC.Conference.Application/Questions/UseCases/Update/QuestionUpdateRequest.cs:6 · Level 1 · record

      -
    • What it is: the PUT body for an existing feedback Question: the prompt text, the two discriminators that say what the question is attached to and how it is answered, plus display order and a required flag.
    • -
    • Depends on: IConcurrencyAware only (QuestionUpdateRequest.cs:1,6). It is a pure payload record.
    • -
    • Concept introduced: none new; see ConferenceCategoryUpdateRequest for the shape. The point worth carrying forward from this record is that QuestionEntity and QuestionType (:15,18) are required and re-sent on every update even though UpdateQuestionHandler will refuse to change them once answers exist (BR-137). A full-replacement contract means the client always echoes them; the handler compares the echo against stored state and decides. [Rubric §9, API & Contract Design] assesses whether the payload shape matches the operation: a PUT that replaces the resource carries the whole resource, and conditional immutability is enforced server-side rather than by splitting the endpoint.
    • -
    • Walkthrough: RowVersion (:9) is the concurrency token. QuestionText (:12) is the required prompt, the one field this request's validator guards. QuestionEntity (:15) is the required target discriminator, documented as "Session" or "Event". QuestionType (:18) is the required input-kind discriminator, documented as "Rating", "Text", or "Email". Sort (:21) is the display order and IsRequired (:24) is whether an answer is mandatory; both are plain value types with implicit defaults.
    • -
    • Where it's used: bound by QuestionsController on PUT {id} (MMCA.ADC.Conference.API/Controllers/QuestionsController.cs:103-106), wrapped into UpdateQuestionCommand (:109), validated by QuestionUpdateRequestValidator, and consumed by UpdateQuestionHandler.
    • -
    • Caveats / not-in-source: the two discriminators are typed as string, not enums, and their allowed values live in the doc comments (:14,17) plus the domain invariants EnsureQuestionEntityIsValid and EnsureQuestionTypeIsValid invoked from Question.Update (MMCA.ADC.Conference.Domain/Questions/Question.cs:117-118). This record itself constrains neither.
    • +
    • What it is: the payload for editing an existing survey Question: its text, the entity it targets, its input type, its display order, and whether an answer is mandatory.
    • +
    • Depends on: IConcurrencyAware (QuestionUpdateRequest.cs:1,6). No other type; all five payload members are BCL primitives.
    • +
    • Concept introduced: a field that is in the contract but only conditionally editable. QuestionEntity (:15) and QuestionType (:18) are plain required string members here, so the wire contract accepts a new value for either. Whether that value is allowed is not a property of the record: UpdateQuestionHandler probes the three answer tables and rejects the change once any answer exists (BR-137, UpdateQuestionHandler.cs:38-73). Contrast this with a field removed from a request entirely, which is how the module expresses "never editable through this path". The distinction is worth internalizing because it tells you where to look for a rule: a shape constraint lives in the record, a state-dependent constraint cannot, because the record has no access to the database. [Rubric §4, Domain-Driven Design] assesses whether rules live where the knowledge to enforce them lives; [Rubric §9, API & Contract Design]: the contract stays uniform between create and update, and the difference surfaces as a validation error with a stable code rather than as a missing property.
    • +
    • Walkthrough: RowVersion (:9) is the concurrency token. QuestionText (:12), QuestionEntity (:15, documented as "Session" or "Event"), and QuestionType (:18, documented as "Rating", "Text", or "Email") are required. Sort (:21) and IsRequired (:24) are plain value members that default to 0 and false. Note the near-miss in naming: IsRequired is the survey question's own "an attendee must answer this" flag, not the C# required modifier that governs three of its siblings.
    • +
    • Why it's built this way: the two discriminator strings are free-form string, not enums, so adding a question type or a new target entity does not require a change to the contract type; the legal values are asserted in the domain instead (QuestionInvariants, called from MMCA.ADC.Conference.Domain/Questions/Question.cs:115-118).
    • +
    • Where it's used: bound by QuestionsController on PUT {id} (MMCA.ADC.Conference.API/Controllers/QuestionsController.cs:102-106), validated by QuestionUpdateRequestValidator, wrapped in UpdateQuestionCommand (:109), and consumed by UpdateQuestionHandler.
    -

    SessionUpdateRequest

    +

    UpdateEventResult

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.Update · MMCA.ADC.Conference.Application/Sessions/UseCases/Update/SessionUpdateRequest.cs:6 · Level 1 · record

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Update · MMCA.ADC.Conference.Application/Events/UseCases/Update/UpdateEventCommand.cs:19 · Level 3 · record

      -
    • What it is: the PUT body for an existing Session, and the largest update request in the module: fifteen members covering identity, schedule, workflow flags, links, and the room assignment.
    • -
    • Depends on: IConcurrencyAware (SessionUpdateRequest.cs:1,6) plus the module identifier aliases EventIdentifierType and RoomIdentifierType (:12,54), both aliased to int in MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7,11.
    • -
    • Concept introduced: the echoed immutable field. EventId (:12) is required on a request that is not allowed to change it: its own doc comment says "Must match the session's current EventId (BR-140: immutable after creation)". At first read that looks redundant, but it is the safety property of a full-replacement PUT. The client sends the whole resource as it believes it to be, and UpdateSessionHandler compares the echoed parent against the stored one, failing with Session.EventId.Immutable and HTTP 422 when they differ (MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionHandler.cs:37-44). Dropping the field would make a stale client's belief invisible; keeping it turns a wrong assumption into an explicit rejection instead of a silent accept. The same echoed value is then used as the lookup key for the parent event that the room and date-range checks need. [Rubric §9, API & Contract Design]: immutability is expressed as a rejected transition with a stable error code, not as an absent field. [Rubric §4, Domain-Driven Design]: a session belongs to exactly one event for life, which is an aggregate-composition fact rather than an editable attribute.
    • -
    • Walkthrough: RowVersion (:9) is the concurrency token. EventId (:12) and Title (:15) are the two required members. Description (:18) is the optional abstract. StartsAt and EndsAt (:21,24) are nullable DateTimes, so an unscheduled session is legal. Status (:27) is an optional free-text status. The four booleans IsInformed, IsConfirmed, IsServiceSession, IsPlenumSession (:30,33,36,39) carry speaker-workflow and session-kind state. LiveUrl, RecordingUrl, AccessibilityInfo, and ResourceLinks (:42,45,48,51) are the optional link and note fields. RoomId (:54) is a nullable room assignment, which is what makes the BR-130 cross-event and double-booking checks conditional rather than mandatory.
    • -
    • Why it's built this way: the nullable schedule fields are load-bearing for the import path as well as the admin UI. A session that arrives from Sessionize before the agenda is fixed has no times and no room, so making StartsAt, EndsAt, and RoomId optional keeps that state representable instead of forcing placeholder values that later read as real data.
    • -
    • Where it's used: bound by SessionsController on PUT {id} (MMCA.ADC.Conference.API/Controllers/SessionsController.cs:324-327), wrapped into UpdateSessionCommand (:330), validated by SessionUpdateRequestValidator, and consumed by UpdateSessionHandler.
    • +
    • What it is: the two-member envelope UpdateEventHandler returns: the updated EventDTO plus a boolean saying whether this particular update changed the time zone while sessions already existed.
    • +
    • Depends on: EventDTO (UpdateEventCommand.cs:19, imported at :1). Nothing else; the second member is a bool.
    • +
    • Concept introduced: the advisory result, distinct from success and from failure. The Result pattern gives a handler two outcomes, success with a value or failure with errors. BR-131 is neither: changing an event's time zone after sessions are scheduled does not violate an invariant (the write is legitimate and must be persisted), but it does change what every already-stored session time means. Rejecting it would be wrong, and silently accepting it would be worse. The codebase's answer is a third channel carried inside the success value, a flag the caller can act on. This is a small type with a large lesson, namely that "succeeded, with something you should know" deserves a first-class shape rather than a log line the operator never reads. [Rubric §9, API & Contract Design] assesses how non-fatal conditions are conveyed: EventsController translates the flag into an X-Warning response header and still returns 200 with the DTO (MMCA.ADC.Conference.API/Controllers/EventsController.cs:279-288), so the body stays exactly the EventDTO the API contract promises and the advisory rides beside it. [Rubric §13, Observability & Operability]: the condition is surfaced to the human who caused it, at the moment they caused it.
    • +
    • Walkthrough: one line. sealed record UpdateEventResult(EventDTO Event, bool HasTimeZoneWarning) (:19), with both members documented on the declaration (:16-18). It has no methods and no behavior; it exists to name a pair.
    • +
    • Why it's built this way: it lives in the same file as UpdateEventCommand (:10) because the two are one use case's input and output and are never referenced apart. Keeping the envelope in the Application layer rather than widening EventDTO with a HasTimeZoneWarning property matters: the flag is a fact about this write, not a property of the event, so it must not be persisted, cached, or returned by any read.
    • +
    • Where it's used: constructed by UpdateEventHandler (UpdateEventHandler.cs:70), declared as the handler's result type on both the handler interface (:19) and the controller's injected dependency (MMCA.ADC.Conference.API/Controllers/EventsController.cs:48), and unwrapped by the controller, which reads the flag (:280) and then returns only result.Value.Event (:288). No other layer sees the envelope.
    -

    UpdateEventResult

    +

    UpdateConferenceCategoryCommand

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Update · MMCA.ADC.Conference.Application/Events/UseCases/Update/UpdateEventCommand.cs:19 · Level 3 · record

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.Update · MMCA.ADC.Conference.Application/Categories/UseCases/Update/UpdateConferenceCategoryCommand.cs:9 · Level 7 · record

      -
    • What it is: the two-member return value of UpdateEventHandler: the updated EventDTO and a boolean saying whether the update tripped the BR-131 time-zone advisory.
    • -
    • Depends on: EventDTO (UpdateEventCommand.cs:19). It lives in the same file as UpdateEventCommand, which is why its File:Line points there.
    • -
    • Concept introduced: the advisory warning, separated from the failure channel. The codebase already has one way to say "no": a failed Result carrying Error values, which the controller turns into a 4xx. Some outcomes are neither success nor failure though: changing an event's time zone while sessions already exist does not violate an invariant, but it silently re-interprets every stored session time, so the organizer should be told. Encoding that as an error would block a legitimate edit; encoding it as a log line would hide it from the person who caused it. The pattern used here is a third channel: the handler still returns Result.Success(...), but the success payload is a wrapper carrying the DTO plus a flag. The transport decides how to surface it, and EventsController converts the flag into an X-Warning response header, then returns only result.Value.Event as the body (MMCA.ADC.Conference.API/Controllers/EventsController.cs:279-287). The wrapper therefore never reaches the wire: it is an application-to-API carrier, so the client's response schema stays exactly EventDTO. [Rubric §9, API & Contract Design] assesses how non-fatal conditions are communicated without polluting the success contract. [Rubric §6, CQRS & Event-Driven]: the handler stays the single owner of the business decision and the controller owns only its presentation.
    • -
    • Walkthrough: sealed record UpdateEventResult(EventDTO Event, bool HasTimeZoneWarning) (:19), two positional parameters and no body. The XML doc names the rule it serves, BR-131 (:16-18).
    • -
    • Where it's used: constructed once, at the end of UpdateEventHandler.HandleAsync (UpdateEventHandler.cs:69), and unwrapped by EventsController (EventsController.cs:279-287). It is also the TResult in the handler's ICommandHandler<UpdateEventCommand, Result<UpdateEventResult>> registration, which is how the controller injects it (EventsController.cs:47).
    • +
    • What it is: the write intent for updating a conference Category. It pairs the target Id with the ConferenceCategoryUpdateRequest payload and opts the operation into cache eviction.
    • +
    • Depends on: ICommandWithRequest<out TRequest> and ICacheInvalidating from MMCA.Common.Application.UseCases (UpdateConferenceCategoryCommand.cs:2,9), the ConferenceCategoryUpdateRequest it wraps (:9), and the Category type used only for its FullName in the cache prefix (:1,12). ConferenceCategoryIdentifierType is the module identifier alias.
    • +
    • Concept introduced: the id-plus-request command, and validation by delegation. A create path can let the request record double as the command, because the identity is inside the body. An update cannot: the target id arrives on the route and the payload arrives in the body, so the command exists to marry the two into one object the pipeline can dispatch. ICommandWithRequest<out TRequest> is the framework contract that makes this shape legible to the decorators. The bridge is concrete and worth tracing once: module registration reflects over the assembly, finds every type implementing ICommandWithRequest<>, and TryAdds an IValidator<TCommand> implemented by CommandRequestValidator<TCommand, TRequest> (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:196-210), whose whole body is RuleFor(c => c.Request).SetValidator(validator) against the registered request validator (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommandRequestValidator.cs:22-27). That is why ConferenceCategoryUpdateRequestValidator validates the request type and no UpdateConferenceCategoryCommandValidator file exists to keep in sync, and why TryAdd matters: a hand-written command validator still wins. The second marker, ICacheInvalidating, opts the command into the caching decorator so a successful update evicts the category read cache. [Rubric §6, CQRS & Event-Driven] assesses whether writes are explicit intents flowing through a uniform pipeline: both cross-cutting behaviors attach declaratively through marker interfaces, with no wiring inside the handler (ADR-014).
    • +
    • Walkthrough: two positional parameters, Id and Request, with both interfaces implemented on the same declaration line (:9). CachePrefix (:12) is an expression-bodied property returning $"{typeof(Category).FullName}:", the key namespace the CachingCommandDecorator<TCommand, TResult> wipes after a successful handle. There is no other member; the positional Request parameter satisfies the interface property with no extra code.
    • +
    • Why it's built this way: deriving the cache prefix from typeof(Category).FullName rather than a literal string keeps the writer (this command) and the reader (the category query cache) agreed on one key namespace that a rename cannot desynchronize.
    • +
    • Where it's used: constructed by ConferenceCategoriesController from the route id and body (MMCA.ADC.Conference.API/Controllers/ConferenceCategoriesController.cs:109-111) and handled by UpdateConferenceCategoryHandler.
    -

    UpdateSessionResult

    +

    ConferenceCategoryUpdateRequestValidator

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.Update · MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionCommand.cs:19 · Level 3 · record

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.Update · MMCA.ADC.Conference.Application/Categories/UseCases/Update/ConferenceCategoryUpdateRequestValidator.cs:7 · Level 8 · class

      -
    • What it is: the return value of UpdateSessionHandler: the updated SessionDTO plus a boolean saying whether the session's times fall outside the parent event's date range (BR-86).
    • -
    • Depends on: SessionDTO (UpdateSessionCommand.cs:2,19). Declared in the same file as UpdateSessionCommand.
    • -
    • Concept introduced: none new; this is the advisory-warning wrapper taught at UpdateEventResult, applied to a different rule. The difference worth noting is what the two warnings mean. The time-zone warning says an existing set of session times may now be misinterpreted; the date-range warning says the times just submitted sit outside the event's own days. Both are organizer errors that the system deliberately refuses to treat as invariants, because conferences do run pre-days and after-parties that legitimately fall outside a strictly recorded date range.
    • -
    • Walkthrough: sealed record UpdateSessionResult(SessionDTO Session, bool HasDateRangeWarning) (:19), with the XML doc naming BR-86 (:16-18).
    • -
    • Where it's used: constructed at the end of UpdateSessionHandler.HandleAsync (UpdateSessionHandler.cs:97) and unwrapped by SessionsController, which appends the X-Warning header and returns result.Value.Session (MMCA.ADC.Conference.API/Controllers/SessionsController.cs:337-343). The same controller emits the identical header on the create path by computing the comparison inline instead (SessionsController.cs:310-315), so the wrapper is the update path's way of moving that decision into the handler where the parent event is already loaded.
    • +
    • What it is: the FluentValidation validator for ConferenceCategoryUpdateRequest, run by the pipeline before the update handler executes.
    • +
    • Depends on: AbstractValidator<T> (FluentValidation, ConferenceCategoryUpdateRequestValidator.cs:1,7), ConferenceCategoryUpdateRequest, and the shared ConferenceCategoryTitleRules<T> rule set from MMCA.ADC.Conference.Application.Categories.Validation (:2,10).
    • +
    • Concept introduced: none new; this is Include composition (taught in group 06) in its smallest possible form. The entire class body is one expression-bodied constructor (:9-10) folding in a single parameterized rule set: Include(new ConferenceCategoryTitleRules<ConferenceCategoryUpdateRequest>(p => p.Title)). The same rule object is included by the create-side category validator against a different request type, which is the whole point: the title contract is declared once and every entry path inherits it, complete with the stable error code Category.Title.Required that the rule set attaches (MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:18) and the max-length bound it reads from the domain's CategoryInvariants (:19). [Rubric §24, Forms/Validation/UX Safety] assesses whether input constraints are single-sourced and consistently applied; [Rubric §1, SOLID]: this validator's only job is composition, so a title-rule change never has to be found in two files.
    • +
    • Walkthrough: sealed class ConferenceCategoryUpdateRequestValidator : AbstractValidator<ConferenceCategoryUpdateRequest> (:7); the constructor (:9-10) is the single Include. RowVersion, Sort, and Type carry no rules here: a null concurrency token is a legitimate "skip the check" signal rather than an error, and the other two have no field-level business constraint.
    • +
    • Where it's used: discovered by assembly scanning and invoked by the ValidatingCommandDecorator<TCommand, TResult> ahead of UpdateConferenceCategoryHandler, reached through UpdateConferenceCategoryCommand's ICommandWithRequest<out TRequest> implementation.

    EventUpdateRequestValidator

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Update · MMCA.ADC.Conference.Application/Events/UseCases/Update/EventUpdateRequestValidator.cs:7 · Level 8 · class

      -
    • What it is: the FluentValidation validator for EventUpdateRequest. It composes five reusable event rule sets and adds one inline rule for the moderation enum.
    • -
    • Depends on: AbstractValidator<T> from FluentValidation (EventUpdateRequestValidator.cs:1,7) and the rule sets in MMCA.ADC.Conference.Application.Events.Validation (:2): EventNameRules<T>, EventTimeZoneRules<T>, EventDateRangeRules<T>, EventOrganizerContactEmailRules<T>, and EventSponsorshipPacketUrlRules<T>.
    • -
    • Concept introduced: Include composition with a multi-field rule set, and the null-forgiving selector. The Include mechanism itself is taught at ConferenceCategoryUpdateRequestValidator; two wrinkles show up here for the first time. First, EventDateRangeRules<T> takes two selectors, p => p.StartDate and p => p.EndDate (:13), because the constraint it owns is a relationship rather than a field: it requires both dates and then asserts endDate >= startDateFunc(instance) by compiling the start-date selector and using it inside a cross-property Must (MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:104-107). A parameterized rule set does not have to be single-field, it just has to own one concept. Second, line 14 passes p => p.OrganizerContactEmail! with the null-forgiving operator, because that rule set's constructor takes a non-nullable Expression<Func<T, string>> (EventValidationRules.cs:60) while the request property is string?. That is safe here only because the rule set guards itself: it compiles the accessor and wraps the real email rules in When(x => !string.IsNullOrWhiteSpace(accessor(x)), ...) (EventValidationRules.cs:62-65), so a null value never reaches the inner EmailRules<T>. [Rubric §24, Forms/Validation/UX Safety] assesses whether constraints are single-sourced and applied at every entry point: the same five rule sets are included by the create-side validator, so a rule change cannot land on one path only. [Rubric §15, Best Practices & Code Quality]: the ! here is a real, if load-bearing, sharp edge, only correct because of the When guard one file away.
    • -
    • Walkthrough: sealed class EventUpdateRequestValidator : AbstractValidator<EventUpdateRequest> (:7). The constructor (:9-21) folds in name (:11), time zone (:12), the date-range pair (:13), organizer contact email (:14), and sponsorship packet URL (:15), then declares the one rule that has no reusable home: RuleFor(x => x.QuestionModerationDefault).IsInEnum() with the message "Question moderation default is not a valid value." and the stable error code Event.QuestionModerationDefault.Invalid (:17-20). IsInEnum() matters because a JSON body carrying "questionModerationDefault": 7 binds without complaint into the enum-typed property, and only this rule rejects it.
    • -
    • Why it's built this way: the time-zone rule is the clearest reason to keep these rule sets shared rather than inline. EventTimeZoneRules<T> is not a length check: it chains NotEmpty, MaximumLength, and a Must(BeAValidIanaTimeZone) predicate carrying the error code Event.TimeZone.InvalidIana (EventValidationRules.cs:29-32), which is the BR-87 enforcement point. Duplicating that logic per request record would be how the create and update paths eventually disagree.
    • -
    • Where it's used: discovered by assembly scanning and invoked by the ValidatingCommandDecorator<TCommand, TResult> ahead of UpdateEventHandler, reached through UpdateEventCommand's ICommandWithRequest<out TRequest> implementation.
    • -
    • Caveats / not-in-source: nothing here validates SessionizeCode, VenueAddress, VenueMapUrl, or WiFiInfo. For those fields the enforcement is the EF column width plus whatever the domain checks; Event.Update combines only name, time zone, and date-range invariants (MMCA.ADC.Conference.Domain/Events/Event.cs:231-234).
    • +
    • What it is: the validator for EventUpdateRequest. It composes six reusable event rule sets and adds one rule written inline.
    • +
    • Depends on: AbstractValidator<T> (FluentValidation, EventUpdateRequestValidator.cs:1,7), EventUpdateRequest, and the six rule sets from MMCA.ADC.Conference.Application.Events.Validation (:2,11-16): EventNameRules<T>, EventTimeZoneRules<T>, EventDateRangeRules<T>, EventOrganizerContactEmailRules<T>, EventSponsorshipPacketUrlRules<T>, and EventTicketingUrlRules<T>.
    • +
    • Concept introduced: when a rule belongs inline rather than in a shared rule set. Compare this constructor to EventCreateRequestValidator (MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequestValidator.cs:11-16): the same six Include calls appear in the same order, and only this file adds a seventh rule, RuleFor(x => x.QuestionModerationDefault).IsInEnum() (:18-21). The asymmetry is not an oversight. QuestionModerationDefault is a member of the update request only (see EventUpdateRequest), so there is exactly one binding site, and packaging a one-call rule as a generic rule set would buy nothing. The rule itself guards against the way enums arrive over JSON: an unmapped integer binds happily into an enum-typed property, so a value such as (QuestionModerationDefault)7 would otherwise reach the domain and be stored. IsInEnum rejects it before the handler runs, with a message and the stable code Event.QuestionModerationDefault.Invalid (:20-21). [Rubric §24, Forms/Validation/UX Safety] assesses whether every inbound field has a contract; [Rubric §11, Security]: enum members are a closed set only if something enforces the closure at the boundary.
    • +
    • Walkthrough: sealed class EventUpdateRequestValidator : AbstractValidator<EventUpdateRequest> (:7). The constructor (:9-22) includes the name rule (:11), the IANA time zone rule (:12), the date-range rule that also cross-checks EndDate >= StartDate (:13, cross-property comparison at EventValidationRules.cs:122-125), and three optional-field rules. Two details repay a second look. First, the organizer email is passed with a null-forgiving p => p.OrganizerContactEmail! (:14) because that rule set's selector is typed Expression<Func<T, string>> while the property is string? (EventValidationRules.cs:60); the rule set is nonetheless safe, because it wraps its inner EmailRules<T> in a When(...) that fires only on a non-blank value (:64-65). Second, the two URL rule sets use the same When guard over OptionalStringRules<T> (:82-83,100-101), so clearing a URL is always legal. Fields with no rule at all: RowVersion, Description, SessionizeCode, VenueAddress, VenueMapUrl, and WiFiInfo.
    • +
    • Why it's built this way: length bounds are not literals here. Each rule set reads its constant from EventInvariants in the Domain layer, so the validator's message, the aggregate's own guard, and the EF column width agree on one number (EventInvariants; usages at EventValidationRules.cs:17,31,65,83,101). [Rubric §16, Maintainability]: widening a field is a one-constant change.
    • +
    • Caveats / not-in-source: the time zone check calls TimeZoneInfo.FindSystemTimeZoneById and treats TimeZoneNotFoundException as invalid (EventValidationRules.cs:39-47), so the accepted set is whatever the host's time zone database contains. Which identifiers that is on a given container image is not determinable from source.
    • +
    • Where it's used: registered by assembly scanning, reached through UpdateEventCommand's ICommandWithRequest<out TRequest>, and executed by the ValidatingCommandDecorator<TCommand, TResult> before UpdateEventHandler.

    QuestionUpdateRequestValidator

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Questions.UseCases.Update · MMCA.ADC.Conference.Application/Questions/UseCases/Update/QuestionUpdateRequestValidator.cs:7 · Level 8 · class

      -
    • What it is: the validator for QuestionUpdateRequest, and the smallest one in this slice: a single expression-bodied constructor with one Include.
    • -
    • Depends on: AbstractValidator<T> (QuestionUpdateRequestValidator.cs:1,7) and QuestionTextRules<T> from MMCA.ADC.Conference.Application.Questions.Validation (:2,10).
    • -
    • Concept introduced: none new; see ConferenceCategoryUpdateRequestValidator for the composition pattern and EventUpdateRequestValidator for the multi-rule version. The teaching value of this class is the deliberate gap. Three of the six request members carry business meaning, yet only QuestionText gets a rule. The reason is that the other two constraints cannot be answered from the payload alone: whether QuestionEntity and QuestionType hold legal values is a domain invariant (QuestionInvariants.EnsureQuestionEntityIsValid and EnsureQuestionTypeIsValid, invoked from Question.Update at MMCA.ADC.Conference.Domain/Questions/Question.cs:117-118), and whether they may change at all depends on database state, which is BR-137 in UpdateQuestionHandler. A validator that only sees the request should not pretend to decide either. [Rubric §24, Forms/Validation/UX Safety]: each constraint is enforced at the layer that actually has the information, rather than being half-implemented in the cheapest one.
    • -
    • Walkthrough: sealed class QuestionUpdateRequestValidator : AbstractValidator<QuestionUpdateRequest> (:7); the whole body is => Include(new QuestionTextRules<QuestionUpdateRequest>(p => p.QuestionText)) (:9-10). The included rule set chains NotEmpty with error code Question.QuestionText.Required and a MaximumLength(QuestionInvariants.QuestionTextMaxLength) with code Question.QuestionText.MaxLength (MMCA.ADC.Conference.Application/Questions/Validation/QuestionValidationRules.cs:16-18).
    • -
    • Where it's used: run by the ValidatingCommandDecorator<TCommand, TResult> before UpdateQuestionHandler, through UpdateQuestionCommand.
    • -
    -

    SessionUpdateRequestValidator

    -
    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.Update · MMCA.ADC.Conference.Application/Sessions/UseCases/Update/SessionUpdateRequestValidator.cs:7 · Level 8 · class

    -
    -
      -
    • What it is: the validator for SessionUpdateRequest: seven Include calls, one per bounded text field.
    • -
    • Depends on: AbstractValidator<T> (SessionUpdateRequestValidator.cs:1,7) and seven rule sets from MMCA.ADC.Conference.Application.Sessions.Validation (:2): SessionTitleRules<T>, SessionDescriptionRules<T>, SessionStatusRules<T>, SessionLiveUrlRules<T>, SessionRecordingUrlRules<T>, SessionAccessibilityInfoRules<T>, and SessionResourceLinksRules<T>.
    • -
    • Concept introduced: none new; it is the same composition taught at ConferenceCategoryUpdateRequestValidator, at its widest in this module. What is worth studying is the shape of what is absent. Of the fifteen request members, the seven text fields get rules and eight do not: RowVersion (a null token is a legitimate opt-out, not an error), the four booleans (every value is legal), EventId, RoomId, and the StartsAt/EndsAt pair. The last four are exactly the fields whose constraints need other rows to evaluate: BR-140 needs the stored session, BR-130 needs the parent event's room collection, and the double-booking check needs every other session in that room. All three therefore live in UpdateSessionHandler and SessionRoomScheduling, not here. [Rubric §3, Clean Architecture] assesses whether each concern sits at the layer that owns its data: a stateless request validator stays stateless, and stateful rules stay in the handler that already has a unit of work.
    • -
    • Walkthrough: sealed class SessionUpdateRequestValidator : AbstractValidator<SessionUpdateRequest> (:7); the constructor (:9-18) includes title (:11), description (:12), status (:13), live URL (:14), recording URL (:15), accessibility info (:16), and resource links (:17). The six optional selectors bind nullable properties, the title selector binds the non-nullable Title, and that difference in the rule sets' base classes is what makes the title the only mandatory one.
    • -
    • Why it's built this way: the same six optional text constraints are re-checked in the aggregate by SessionInvariants.EnsureOptionalTextLengthsAreValid, which Session.Update combines with the title and time-order invariants (MMCA.ADC.Conference.Domain/Sessions/Session.cs:245-248). The validator is the fast path with readable per-field messages; the domain is the authority a non-HTTP caller still cannot bypass.
    • -
    • Where it's used: run by the ValidatingCommandDecorator<TCommand, TResult> before UpdateSessionHandler, through UpdateSessionCommand.
    • +
    • What it is: the validator for QuestionUpdateRequest. One Include, covering the question text.
    • +
    • Depends on: AbstractValidator<T> (FluentValidation, QuestionUpdateRequestValidator.cs:1,7), QuestionUpdateRequest, and QuestionTextRules<T> from MMCA.ADC.Conference.Application.Questions.Validation (:2,10).
    • +
    • Concept introduced: none new; it is the same one-line composition as ConferenceCategoryUpdateRequestValidator, and it is the create-side validator with a different type argument (MMCA.ADC.Conference.Application/Questions/UseCases/Create/QuestionCreateRequestValidator.cs:10, compare :9-10 here). What is worth noticing is everything it does not validate. QuestionEntity and QuestionType are required strings with no rule here, even though only certain values are legal. Their legality is asserted twice further in: by QuestionInvariants inside Question.Update (MMCA.ADC.Conference.Domain/Questions/Question.cs:115-118), and, for the change rather than the value, by UpdateQuestionHandler's BR-137 probe. [Rubric §3, Clean Architecture] assesses whether each layer holds the checks it is entitled to hold: the validator owns cheap shape checks at the boundary, the aggregate owns the value invariants, and the handler owns the checks that require a database read.
    • +
    • Walkthrough: sealed class QuestionUpdateRequestValidator : AbstractValidator<QuestionUpdateRequest> (:7) with an expression-bodied constructor (:9-10) that includes new QuestionTextRules<QuestionUpdateRequest>(p => p.QuestionText). That rule set is NotEmpty plus MaximumLength(QuestionInvariants.QuestionTextMaxLength), carrying the codes Question.QuestionText.Required and Question.QuestionText.MaxLength (MMCA.ADC.Conference.Application/Questions/Validation/QuestionValidationRules.cs:16-18).
    • +
    • Where it's used: executed by the ValidatingCommandDecorator<TCommand, TResult> through UpdateQuestionCommand, ahead of UpdateQuestionHandler.

    UpdateEventCommand

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Update · MMCA.ADC.Conference.Application/Events/UseCases/Update/UpdateEventCommand.cs:10 · Level 8 · record

      -
    • What it is: the write intent for updating an Event. It marries the route id with the EventUpdateRequest body and opts the operation into cache eviction.
    • -
    • Depends on: ICommandWithRequest<out TRequest> and ICacheInvalidating from MMCA.Common.Application.UseCases (UpdateEventCommand.cs:3,10), the EventUpdateRequest it wraps (:10), and the Event domain type used only for its FullName in the cache prefix (:1,13). EventIdentifierType is the module alias for int.
    • -
    • Concept introduced: none new; the id-plus-request command and validation by delegation are taught at UpdateConferenceCategoryCommand. This is that shape applied to events, and it is worth noting how little the command has to say: two positional parameters and one computed property, with both cross-cutting behaviors attached declaratively. [Rubric §6, CQRS & Event-Driven] assesses whether writes are explicit intents flowing through a uniform pipeline; the caching and validation behavior arrives from marker interfaces rather than from code inside UpdateEventHandler (ADR-014).
    • -
    • Walkthrough: sealed record UpdateEventCommand(EventIdentifierType Id, EventUpdateRequest Request) implementing both interfaces on the declaration line (:10). CachePrefix (:13) is expression-bodied, returning $"{typeof(Event).FullName}:", the key namespace the CachingCommandDecorator<TCommand, TResult> wipes after a successful handle. The positional Request parameter satisfies the ICommandWithRequest<out TRequest> property with no extra member, which is why EventUpdateRequestValidator can be registered against the request type and still be found for this command.
    • -
    • Why it's built this way: deriving the prefix from typeof(Event).FullName rather than a string literal keeps the writer and the read-side cache keys agreed on one namespace that a rename cannot desynchronize. Note this is the application cache; the HTTP output cache is a second, separate layer that EventsController evicts by tag right after the handler returns (MMCA.ADC.Conference.API/Controllers/EventsController.cs:286).
    • -
    • Where it's used: constructed by EventsController from the route id and body (EventsController.cs:271-273) and handled by UpdateEventHandler.
    • +
    • What it is: the write intent for updating an Event: the route id plus the EventUpdateRequest body, marked as cache-invalidating.
    • +
    • Depends on: ICommandWithRequest<out TRequest> and ICacheInvalidating (UpdateEventCommand.cs:3,10), EventUpdateRequest (:10), and the Event aggregate type, used only for its FullName (:1,13). EventIdentifierType is the module identifier alias.
    • +
    • Concept introduced: none new; it is the id-plus-request shape taught at UpdateConferenceCategoryCommand. The one thing to note is the asymmetry in its handler's result type: this command's handler returns Result<UpdateEventResult> rather than Result<EventDTO> (UpdateEventHandler.cs:19), which is the only place across these three update slices where the returned envelope differs from the DTO. [Rubric §6, CQRS & Event-Driven]: the command type is the pipeline's dispatch key, so the result type can vary per use case without any decorator caring.
    • +
    • Walkthrough: sealed record UpdateEventCommand(EventIdentifierType Id, EventUpdateRequest Request) implementing both marker interfaces on the declaration line (:10); CachePrefix => $"{typeof(Event).FullName}:" (:13). UpdateEventResult sits directly below it in the same file (:19).
    • +
    • Why it's built this way: the command does not implement ITransactional. The handler writes one aggregate and saves once, so the ambient SaveChangesAsync boundary suffices, and the transactional decorator is reserved for commands that coordinate multiple aggregates (ADR-014; see TransactionalCommandDecorator<TCommand, TResult>).
    • +
    • Where it's used: constructed by EventsController (MMCA.ADC.Conference.API/Controllers/EventsController.cs:273) against the injected ICommandHandler<UpdateEventCommand, Result<UpdateEventResult>> (:48), and handled by UpdateEventHandler.

    UpdateQuestionCommand

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Questions.UseCases.Update · MMCA.ADC.Conference.Application/Questions/UseCases/Update/UpdateQuestionCommand.cs:9 · Level 8 · record

      -
    • What it is: the write intent for updating a Question, pairing the route id with the QuestionUpdateRequest body.
    • -
    • Depends on: ICommandWithRequest<out TRequest> and ICacheInvalidating (UpdateQuestionCommand.cs:2,9), the wrapped QuestionUpdateRequest (:9), and the Question type for the cache prefix (:1,12).
    • -
    • Concept introduced: none new; identical in shape to UpdateEventCommand and taught at UpdateConferenceCategoryCommand. The uniformity is the point: every update in this module is one two-parameter record with the same two markers, so a reader who has understood one has understood all of them, and a new use case cannot forget cache invalidation without that omission being visible on a single line.
    • -
    • Walkthrough: sealed record UpdateQuestionCommand(QuestionIdentifierType Id, QuestionUpdateRequest Request) (:9), with CachePrefix => $"{typeof(Question).FullName}:" (:12). Unlike the event and session update files, this one declares no result wrapper: the question update has no advisory warning to carry, so UpdateQuestionHandler returns Result<QuestionDTO> directly.
    • -
    • Where it's used: constructed by QuestionsController on PUT {id} (MMCA.ADC.Conference.API/Controllers/QuestionsController.cs:108-110) and handled by UpdateQuestionHandler.
    • +
    • What it is: the write intent for updating a Question: route id plus the QuestionUpdateRequest body, marked as cache-invalidating.
    • +
    • Depends on: ICommandWithRequest<out TRequest> and ICacheInvalidating (UpdateQuestionCommand.cs:2,9), QuestionUpdateRequest (:9), and the Question type for its FullName (:1,12). QuestionIdentifierType is the module identifier alias.
    • +
    • Concept introduced: none new; identical in shape to UpdateConferenceCategoryCommand, including the derived cache prefix. Reading the three update commands side by side is the fastest way to internalize the module's uniformity: same two positional members, same two markers, same one computed property, three different aggregates. [Rubric §5, Vertical Slice] assesses whether a feature's types sit together and follow one recognizable shape: each UseCases/Update folder holds exactly the request, the validator, the command, and the handler for one entity, so a new slice is a copy of a known pattern rather than an act of invention.
    • +
    • Walkthrough: sealed record UpdateQuestionCommand(QuestionIdentifierType Id, QuestionUpdateRequest Request) with both interfaces on the declaration (:9), and CachePrefix => $"{typeof(Question).FullName}:" (:12).
    • +
    • Where it's used: constructed by QuestionsController (MMCA.ADC.Conference.API/Controllers/QuestionsController.cs:109) against the injected ICommandHandler<UpdateQuestionCommand, Result<QuestionDTO>> (:34), and handled by UpdateQuestionHandler.
    -

    UpdateQuestionHandler

    +

    UpdateConferenceCategoryHandler

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Questions.UseCases.Update · MMCA.ADC.Conference.Application/Questions/UseCases/Update/UpdateQuestionHandler.cs:19 · Level 9 · class

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.Update · MMCA.ADC.Conference.Application/Categories/UseCases/Update/UpdateConferenceCategoryHandler.cs:15 · Level 9 · class

      -
    • What it is: the handler for UpdateQuestionCommand. It loads the Question, stamps the concurrency token, refuses a shape change once answers exist (BR-137), delegates the field changes to the aggregate, saves, logs, and returns the DTO.
    • -
    • Depends on: ICommandHandler<in TCommand, TResult> (UpdateQuestionHandler.cs:9,22), IUnitOfWork (:8,20), QuestionDTOMapper (:2,21), Result and Error (:10), the Question aggregate (:4), the three answer entities EventQuestionAnswer (:3,42), SessionQuestionAnswer (:5,49), and SpeakerQuestionAnswer (:6,59), and ILogger<T> (:1,22).
    • -
    • Concept introduced: the conditional-immutability guard, and read repositories for existence probes. The optimistic-concurrency round trip itself is taught at UpdateConferenceCategoryHandler; what is new here is a rule that depends on data the request cannot see. BR-137 says a question's QuestionType and QuestionEntity become frozen the moment anyone has answered it, because changing a "Rating" question into a "Text" question would leave stored answers uninterpretable. Two properties of the implementation deserve attention. First, the whole probe is skipped unless one of the two fields actually differs (:39-40): renaming the prompt text of an answered question stays free, so the guard costs nothing on the common edit. Second, when the probe does run it hits three tables through IReadRepository<TEntity, TIdentifierType> rather than the tracking IRepository<TEntity, TIdentifierType>, obtained from unitOfWork.GetReadRepository<...>() (:42,49,59). That is the deliberate choice for a question the handler only asks and never mutates, and the repositories are resolved from the unit of work rather than constructor-injected, which is the module-wide convention. [Rubric §8, Data Architecture] assesses whether stored data stays interpretable across schema and metadata edits: this guard exists precisely so historical answers cannot be orphaned from their question's type. [Rubric §12, Performance & Scalability]: each probe is an ExistsAsync predicate, so the database answers with an existence check rather than materializing answer rows, and the second and third probes are short-circuited once the first says yes (:47,55).
    • -
    • Walkthrough: the class is sealed partial with a primary constructor for DI (:19-22), partial because [LoggerMessage] generates the log method body into the other half. HandleAsync (:25-27) resolves the tracking repository (:29), loads by id (:30), and returns Error.NotFound tagged with source and target when the question is absent (:31-32). It stamps the client's token with repository.SetOriginalRowVersion(entity, command.Request.RowVersion) (:36), the line whose comment records that a concurrent edit must surface as a 409 rather than a silent last-write-wins (:34-35). The BR-137 block (:38-73) compares the two discriminators (:39-40) and, only on a difference, probes event answers (:42-45), then session answers if none were found (:47-53), then speaker answers (:55-63, whose comment states that a speaker-profile answer counts the same as the other two). If any exist it returns Error.Validation with the stable code Question.ImmutableAfterAnswers (:65-72). Otherwise it calls entity.Update(...) with the five payload fields (:75-80), which re-runs the text, entity, and type invariants and raises QuestionChanged (MMCA.ADC.Conference.Domain/Questions/Question.cs:115-128), short-circuits on the aggregate's own errors (:82-83), awaits SaveChangesAsync with ConfigureAwait(false) (:85), emits LogQuestionUpdated with command.Id (:87), and returns Result.Success(dtoMapper.MapToDTO(entity)) (:89). The [LoggerMessage] declaration sits at :92-93 at Information level with the template "Question {QuestionId} updated".
    • -
    • Why it's built this way: the guard lives in the handler rather than in the aggregate because the aggregate cannot see the answers. A Question does not own its answers as children (they hang off events, sessions, and speakers), so "has anyone answered this?" is a cross-aggregate query, and the application layer is where cross-aggregate questions are allowed to be asked. The handler also opens no transaction and evicts no cache: those are the transactional and caching decorators' jobs, driven by UpdateQuestionCommand's markers (ADR-014).
    • -
    • Where it's used: injected into QuestionsController as ICommandHandler<UpdateQuestionCommand, Result<QuestionDTO>> (MMCA.ADC.Conference.API/Controllers/QuestionsController.cs:34) and invoked on PUT {id} (:108-110), after which the controller evicts the tagged HTTP output cache (:115).
    • -
    • Caveats / not-in-source: the three probes run sequentially rather than as one query, so a shape change on an unanswered question costs up to three round trips. That is the price of the answers living in three separate tables, and it is paid only on the rare edit that actually changes a discriminator.
    • +
    • What it is: the handler for UpdateConferenceCategoryCommand: load the Category, stamp the client's concurrency token, delegate the field changes to the aggregate's Update, save, log, and return the updated DTO.
    • +
    • Depends on: ICommandHandler<in TCommand, TResult> (UpdateConferenceCategoryHandler.cs:6,18), IUnitOfWork (:5,16), ConferenceCategoryDTOMapper (:2,17), Result and Error (:7), the Category aggregate (:3), ConferenceCategoryDTO (:4), and ILogger<T> from Microsoft.Extensions.Logging (:1,18).
    • +
    • Concept introduced: the optimistic-concurrency round trip inside a handler. This is the canonical update shape in the module, and its one non-obvious line is repository.SetOriginalRowVersion(entity, command.Request.RowVersion) (:32). EF Core would otherwise use the row version it loaded a moment ago as the WHERE predicate on UPDATE, comparing the row against itself and always succeeding. Overwriting the original value with the token the client last saw changes the question to "has anyone written this row since the client read it?". If someone has, SaveChangesAsync raises DbUpdateConcurrencyException, which the shared exception middleware turns into HTTP 409 instead of a silent last-write-wins; the in-code comment states exactly this (:30-31), and the contract's own documentation repeats it, including that a null or empty token is a no-op (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:276-284). That opt-out is the documented position of ADR-035. [Rubric §8, Data Architecture] assesses concurrent-write reconciliation. [Rubric §4, Domain-Driven Design]: the handler assigns no properties itself, it calls entity.Update(...) (:34-37) so the aggregate re-checks its own invariants and raises CategoryChanged (MMCA.ADC.Conference.Domain/Categories/Category.cs:84-95). [Rubric §13, Observability & Operability]: the [LoggerMessage] source-generated log (:49-50) is compile-time and allocation-free.
    • +
    • Walkthrough: the class is sealed partial with a primary constructor for DI (:15-18), partial because [LoggerMessage] generates the log method's body into the other half. HandleAsync (:21-23) gets the typed repository (:25), loads by id (:26), and returns Error.NotFound tagged with source and target when the category is absent (:27-28). It stamps the row version (:32), calls entity.Update(command.Request.Title, command.Request.Sort, command.Request.Type) (:34-37), and short-circuits with the aggregate's own errors on failure (:39-40). On success it awaits SaveChangesAsync with ConfigureAwait(false) (:42), the single save that also persists the domain event through the outbox, emits LogConferenceCategoryUpdated with the category id (:44), and returns Result.Success(dtoMapper.MapToDTO(entity)) (:46). The [LoggerMessage] declaration sits at :49-50 with level Information and the template "Conference category {CategoryId} updated".
    • +
    • Why it's built this way: the handler opens no transaction and evicts no cache. Those are the transactional and caching decorators' jobs, driven by UpdateConferenceCategoryCommand's ICacheInvalidating marker, which keeps every command's cross-cutting behavior uniform (ADR-014). Mapping the tracked entity after the save means the returned DTO reflects anything the domain normalized.
    • +
    • Where it's used: injected into ConferenceCategoriesController as ICommandHandler<UpdateConferenceCategoryCommand, Result<ConferenceCategoryDTO>> (MMCA.ADC.Conference.API/Controllers/ConferenceCategoriesController.cs:35) and invoked on PUT {id} (:109-111), after which the controller separately evicts the tagged HTTP output cache (:116).
    -

    UpdateSessionCommand

    +

    UpdateQuestionHandler

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.Update · MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionCommand.cs:10 · Level 9 · record

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Questions.UseCases.Update · MMCA.ADC.Conference.Application/Questions/UseCases/Update/UpdateQuestionHandler.cs:19 · Level 9 · class

      -
    • What it is: the write intent for updating a Session, pairing the route id with the SessionUpdateRequest body.
    • -
    • Depends on: ICommandWithRequest<out TRequest> and ICacheInvalidating (UpdateSessionCommand.cs:3,10), the wrapped SessionUpdateRequest (:10), and the Session domain type for the cache prefix (:1,13). It also pulls in MMCA.ADC.Conference.Shared.Sessions (:2) for the SessionDTO referenced by UpdateSessionResult further down the same file.
    • -
    • Concept introduced: none new; see UpdateConferenceCategoryCommand for the shape and UpdateEventCommand for the sibling. Its level is one higher than the other update commands only because SessionUpdateRequest sits deeper in the dependency graph, not because the command does more.
    • -
    • Walkthrough: sealed record UpdateSessionCommand(SessionIdentifierType Id, SessionUpdateRequest Request) implementing both markers (:10), with CachePrefix => $"{typeof(Session).FullName}:" (:13). The file then declares UpdateSessionResult (:19), so the intent and its return shape are read together.
    • -
    • Where it's used: constructed by SessionsController on PUT {id} (MMCA.ADC.Conference.API/Controllers/SessionsController.cs:329-331) and handled by UpdateSessionHandler.
    • +
    • What it is: the handler for UpdateQuestionCommand. It follows the canonical update shape and inserts one extra gate: a question's type and target entity may not change once anybody has answered it (BR-137).
    • +
    • Depends on: ICommandHandler<in TCommand, TResult> (UpdateQuestionHandler.cs:9,22), IUnitOfWork (:8,20), QuestionDTOMapper (:2,21), Result and Error (:10), the Question aggregate (:4), the three answer entities EventQuestionAnswer (:3), SessionQuestionAnswer (:5), and SpeakerQuestionAnswer (:6), plus ILogger<T> (:1,22).
    • +
    • Concept introduced: the state-dependent immutability check, and why it is the handler's job. Some rules cannot live in the request record (it has no data) or in the aggregate (a Question does not hold its answers; those live in separate tables reached through their own repositories). BR-137 is one of them: changing QuestionType from "Rating" to "Text" after answers exist would leave stored answers that no longer make sense under the new type. So the handler asks the question the domain cannot: does any answer reference this question? It guards the whole check behind a cheap comparison first (:39-40), so the common edit, fixing a typo in the text or reordering, costs zero extra queries. Only when a discriminator actually changes does it run up to three ExistsAsync probes, short-circuiting as soon as one returns true (:42-63). Each probe uses unitOfWork.GetReadRepository<...>(), the read-only face of the repository (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IUnitOfWork.cs:29; the predicate overload of ExistsAsync is declared at MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:62-65), which both states the intent and keeps those entities out of the change tracker. [Rubric §4, Domain-Driven Design] assesses where knowledge lives: this is a cross-aggregate rule, and the application layer is the only place that can see both sides of it. [Rubric §12, Performance & Scalability]: ExistsAsync compiles to an existence probe rather than a load, and the sequence is both conditional and short-circuiting. [Rubric §1, SOLID]: the aggregate stays ignorant of a collection it does not own.
    • +
    • Walkthrough: sealed partial class with primary-constructor DI (:19-22). HandleAsync (:25-27) takes the write repository for questions (:29), loads the entity (:30), and returns Error.NotFound when it is missing (:31-32). It stamps the client's row version (:36). The BR-137 block (:38-73) fires only when entity.QuestionType != command.Request.QuestionType || entity.QuestionEntity != command.Request.QuestionEntity (:39-40); it probes event answers (:42-45), then session answers if still clean (:49-53), then speaker answers (:59-62, with a comment recording that speaker answers count the same as the other two), and on a hit returns Error.Validation with code Question.ImmutableAfterAnswers and an explanatory message tagged with source and target (:67-72). Past the gate it calls entity.Update(questionText, questionEntity, questionType, sort, isRequired) (:75-80), which re-runs the value invariants and raises QuestionChanged (MMCA.ADC.Conference.Domain/Questions/Question.cs:115-128), propagates any domain errors (:82-83), saves (:85), logs "Question {QuestionId} updated" (:87, declared at :92-93), and returns the mapped QuestionDTO (:89).
    • +
    • Why it's built this way: the refusal is an Error.Validation with a stable code rather than a thrown exception, so it travels the same Result channel as a FluentValidation failure and the controller's shared HandleFailure turns it into a client-error response with no special case (MMCA.ADC.Conference.API/Controllers/QuestionsController.cs:112-113).
    • +
    • Caveats / not-in-source: the check is a read followed by a write with no lock between them, so an answer submitted in the window between the probe and the save is not caught. The row version protects the question row, not the answer tables. Whether that race has ever occurred in practice is not determinable from source.
    • +
    • Where it's used: injected into QuestionsController (MMCA.ADC.Conference.API/Controllers/QuestionsController.cs:34) and invoked on PUT {id} (:108-110), after which the controller evicts the questions output cache (:115).

    UpdateEventHandler

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Update · MMCA.ADC.Conference.Application/Events/UseCases/Update/UpdateEventHandler.cs:16 · Level 10 · class

      -
    • What it is: the handler for UpdateEventCommand. It loads the Event, stamps the concurrency token, detects a time-zone change that would re-interpret existing session times (BR-131), delegates the field changes to the aggregate, saves, logs, and returns the DTO wrapped with the warning flag.
    • -
    • Depends on: ICommandHandler<in TCommand, TResult> (UpdateEventHandler.cs:6,19), IUnitOfWork (:5,17), EventDTOMapper (:2,18), Result and Error (:7), the Event (:3) and Session (:4) aggregates, UpdateEventResult, and ILogger<T> (:1,19).
    • -
    • Concept introduced: detecting a semantic ripple, and reporting it without failing. The concurrency round trip is taught at UpdateConferenceCategoryHandler and the wrapper is taught at UpdateEventResult; what this handler adds is the reason both exist together. Session times are stored as absolute values, and the event's TimeZone is how the UI interprets them. Change the time zone and no row changes, yet every session on the agenda now means a different wall-clock time. That is not an invariant violation, so the handler must not fail the request, but it is also not nothing, so it must not be silent. The implementation is the cheapest possible detection: compare the incoming time zone to the stored one with StringComparison.Ordinal (:36), and only if it differs ask the session repository whether any session belongs to this event at all (:41-45). If the event has no sessions yet, changing the time zone is harmless and no warning is raised. [Rubric §8, Data Architecture] assesses whether the meaning of stored data is protected across edits: this is a case where the data is untouched and only its interpretation moves, which is exactly the class of change that silently corrupts a schedule. [Rubric §13, Observability & Operability]: the [LoggerMessage] source-generated log (:72-73) is compile-time and allocation-free.
    • -
    • Walkthrough: sealed partial class with a primary constructor for DI (:16-19). HandleAsync (:22-24) resolves the tracking repository for events (:26), loads by id (:27), and returns Error.NotFound tagged with source and target if the event is gone (:28-29). It stamps the client's token (:33) with the comment recording the 409 intent (:31-32). The BR-131 block computes timeZoneChanging (:36), defaults hasTimeZoneWarning to false (:37), and only inside the if resolves a session repository and runs ExistsAsync(s => s.EventId == command.Id, ...) (:39-46). It then calls entity.Update(...) with all twelve payload fields in order (:48-60), which re-runs the name, time-zone, and date-range invariants and raises EventChanged (MMCA.ADC.Conference.Domain/Events/Event.cs:231-251), short-circuits on failure (:62-63), awaits SaveChangesAsync with ConfigureAwait(false) (:65), logs with entity.Id (:67), and returns Result.Success(new UpdateEventResult(dtoMapper.MapToDTO(entity), hasTimeZoneWarning)) (:69).
    • -
    • Why it's built this way: the existence probe deliberately runs before the aggregate mutation, while entity.TimeZone still holds the stored value; running it after Update would compare the new value against itself and never warn. Mapping the tracked entity after the save means the returned DTO reflects anything the domain normalized. And as with every handler in this module, no transaction is opened and no cache is evicted here: those come from the decorators driven by UpdateEventCommand's markers (ADR-014), while the concurrency opt-out on a null token is the documented behavior of ADR-035.
    • -
    • Where it's used: injected into EventsController as ICommandHandler<UpdateEventCommand, Result<UpdateEventResult>> (MMCA.ADC.Conference.API/Controllers/EventsController.cs:47) and invoked on PUT {id} (:271-273); the controller turns HasTimeZoneWarning into an X-Warning header advising that existing session times may now be semantically incorrect and suggesting a Sessionize refresh (:279-284), then evicts the events output cache and returns the DTO (:286-287).
    • -
    • Caveats / not-in-source: the warning is advisory only. Nothing in this handler rewrites session times, and whether an organizer acts on the header is outside the code. The probe also stops at "does any session exist", so it does not distinguish an event with one unscheduled session from one with a full agenda.
    • +
    • What it is: the handler for UpdateEventCommand. It performs the canonical update and, when the time zone changes on an event that already has sessions, returns an advisory flag alongside the DTO (BR-131).
    • +
    • Depends on: ICommandHandler<in TCommand, TResult> (UpdateEventHandler.cs:6,19), IUnitOfWork (:5,17), EventDTOMapper (:2,18), Result and Error (:7), the Event aggregate (:3), the Session aggregate used only for the existence probe (:4), UpdateEventResult, and ILogger<T> (:1,19).
    • +
    • Concept introduced: detecting a change by comparing before you overwrite. The BR-131 test has to run before entity.Update(...), because afterwards the tracked entity already holds the new time zone and the old value is gone. Line 36 captures it while it is still available: var timeZoneChanging = !string.Equals(entity.TimeZone, command.Request.TimeZone, StringComparison.Ordinal). Only if that is true does the handler pay for a query, asking whether any session belongs to this event (:41-45). The answer is not an error and does not block the write; it is carried out through UpdateEventResult (:70) so the caller can warn the human. This is the practical face of the rule: session times are anchored to the event's zone, so re-pointing the zone changes what every stored session time means, and the system refuses to guess whether the organizer meant to move the conference or to fix a typo. [Rubric §9, API & Contract Design]: a non-fatal condition gets its own channel instead of overloading the failure path. [Rubric §12, Performance & Scalability]: the probe is conditional and uses ExistsAsync rather than loading sessions. [Rubric §13, Observability & Operability]: the operator receives the warning at the point of change, in the response, not in a log they would have to go looking for.
    • +
    • Walkthrough: sealed partial class with primary-constructor DI (:16-19). HandleAsync (:22-24) resolves the event repository (:26), loads by id (:27), and returns Error.NotFound when absent (:28-29). It stamps the client's concurrency token (:33, the mechanism taught at UpdateConferenceCategoryHandler). It computes timeZoneChanging (:36), initializes hasTimeZoneWarning to false (:37), and inside the if (:39-46) resolves a session repository and calls ExistsAsync(s => s.EventId == command.Id, ...) (:42-44). It then calls entity.Update(...) with all thirteen fields in declaration order (:48-61), which re-runs the name, time zone, and date-range invariants through EventInvariants and raises EventChanged (MMCA.ADC.Conference.Domain/Events/Event.cs:244-265). Domain failures short-circuit (:63-64); otherwise it saves (:66), logs "Event {EventId} updated" (:68, declared at :73-74), and returns Result.Success(new UpdateEventResult(dtoMapper.MapToDTO(entity), hasTimeZoneWarning)) (:70).
    • +
    • Why it's built this way: the ordering of the two guards is deliberate. The row version is stamped first (:33), so a stale client loses at SaveChangesAsync regardless of how the BR-131 branch went, and the warning is computed against the state actually loaded. Passing every field into one Update call rather than assigning properties keeps the aggregate the only writer of its own state and yields exactly one EventChanged domain event per update, which the outbox picks up on the same save (ADR-003).
    • +
    • Caveats / not-in-source: two small inconsistencies are worth knowing. The session probe uses unitOfWork.GetRepository<Session, SessionIdentifierType>() (:41), the full read-write repository, where the read-only GetReadRepository used by UpdateQuestionHandler would have expressed the intent better; nothing is written through it, so the difference is stylistic. And the advisory is one-way: the handler reports that session times may now be misaligned but performs no re-timing and schedules no follow-up, so acting on the warning (for example by re-running a Sessionize import) is left to the operator.
    • +
    • Where it's used: injected into EventsController as ICommandHandler<UpdateEventCommand, Result<UpdateEventResult>> (MMCA.ADC.Conference.API/Controllers/EventsController.cs:48) and invoked on PUT {id} (:272-274); the controller appends an X-Warning response header when the flag is set (:279-285), evicts the events output cache (:287), and returns only the DTO (:288).
    -

    UpdateSessionHandler

    +

    SessionUpdateRequest

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.Update · MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionHandler.cs:17 · Level 11 · class

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.Update · MMCA.ADC.Conference.Application/Sessions/UseCases/Update/SessionUpdateRequest.cs:6 · Level 1 · record

      -
    • What it is: the most guarded update handler in the module. It loads the Session, stamps the concurrency token, rejects a change of parent event (BR-140), loads the parent Event with its rooms, validates the room assignment and schedule slot (BR-130 plus the double-booking guard), delegates the field changes to the aggregate, computes the date-range advisory (BR-86), saves, logs, and returns the wrapped DTO.
    • -
    • Depends on: ICommandHandler<in TCommand, TResult> (UpdateSessionHandler.cs:7,20), IUnitOfWork (:6,18), SessionDTOMapper (:2,19), SessionRoomScheduling (:3,57), Result and Error (:8), the Event (:4) and Session (:5) aggregates, UpdateSessionResult, and ILogger<T> (:1,20).
    • -
    • Concept introduced: ordering the guards, and the difference between a rejection, a validation, and a warning. Read top to bottom, this handler is a lesson in sequencing. The cheapest, most absolute check runs first: BR-140 compares the echoed EventId against the stored one and, on a mismatch, returns Error.UnprocessableEntity with the code Session.EventId.Immutable and the message "Session cannot be moved between events." (:37-44). Note the error kind. This is not a NotFound (the session exists) and not a plain Validation (the payload is well formed); it is a semantically understood but forbidden transition, which is exactly what 422 means. Only after that does the handler pay for a database read, and even then it is a targeted one: the parent event is fetched with includes: [nameof(Event.Rooms)] and asTracking: false (:48-52), because the rooms are needed for the BR-130 check but nothing about the event is going to be modified. That non-tracking read is deliberate and consequential: the mutation path stays on the tracked repository for the session itself, and a tracked-versus-untracked mix is how "saved" changes silently vanish. The third guard delegates outward to SessionRoomScheduling.ValidateRoomAssignmentAsync (:57-65), passing the tracking session repository, the loaded parent, the requested room, the requested slot, and excludeSessionId: command.Id so the session cannot collide with its own current booking. Finally, after the aggregate has accepted the change, BR-86 computes a flag rather than an error (:89-91). Four checks, four different outcomes: 422, delegated failure, aggregate failure, advisory header. [Rubric §1, SOLID] assesses single responsibility: the overlap and cross-event logic lives in its own reusable class so the create path can run the identical check. [Rubric §12, Performance & Scalability]: the guards are ordered cheapest-first, so the common well-formed update pays for one extra read and nothing else. [Rubric §8, Data Architecture]: the tracking discipline (mutate through the tracked repository, read reference data untracked) is what keeps a composed query from silently dropping the save.
    • -
    • Walkthrough: sealed partial class with a primary constructor (:17-20). HandleAsync (:23-25) resolves the tracking session repository (:27), loads by id (:28), and returns Error.NotFound when absent (:29-30). It stamps the client's concurrency token (:34). BR-140 runs next (:37-44). The parent event load follows (:47-52) with its own NotFound guard targeting Event (:53-54), which is the "orphaned session" case. SessionRoomScheduling.ValidateRoomAssignmentAsync is awaited with ConfigureAwait(false) and its errors are propagated unchanged (:57-67). entity.Update(...) then takes fourteen fields (:69-83), re-running the title, time-order, and optional-length invariants and raising SessionChanged (MMCA.ADC.Conference.Domain/Sessions/Session.cs:245-267), with the usual short-circuit on failure (:85-86). BR-86 calls the private static IsOutsideEventDateRange (:89-91), which converts each supplied DateTime with DateOnly.FromDateTime and returns true if the start is before the event's StartDate or the end is after its EndDate, treating a missing time as "no complaint" (:104-113). Then SaveChangesAsync with ConfigureAwait(false) (:93), the LogSessionUpdated call with entity.Id (:95), and Result.Success(new UpdateSessionResult(dtoMapper.MapToDTO(entity), hasDateRangeWarning)) (:97). The [LoggerMessage] declaration is at :100-101.
    • -
    • Why it's built this way: the date-range check is computed against parentEvent.StartDate and parentEvent.EndDate, which the handler already has in memory from the BR-130 load, so the advisory costs nothing extra. Placing it after entity.Update also means it reflects the values the domain accepted rather than the raw request. As everywhere in this module, the transaction and cache eviction come from the decorators driven by UpdateSessionCommand's markers (ADR-014), and the null-token concurrency opt-out is ADR-035.
    • -
    • Where it's used: injected into SessionsController as ICommandHandler<UpdateSessionCommand, Result<UpdateSessionResult>> (MMCA.ADC.Conference.API/Controllers/SessionsController.cs:45) and invoked on PUT {id} (:329-331); the controller converts HasDateRangeWarning into the X-Warning header "Session time falls outside the event's date range." (:337-340), evicts the sessions output cache, and returns result.Value.Session (:342-343).
    • -
    • Caveats / not-in-source: IsOutsideEventDateRange compares dates only, not times, and it uses the raw DateTime values without applying the event's time zone, so a session scheduled late on the final day is judged by its stored date alone. Whether the stored session times are UTC or local is not determinable from this file.
    • +
    • What it is: the full-replacement payload a client PUTs to edit an existing Session. It carries the concurrency token, the parent event id, the title and description, the scheduled window, four booleans that describe what kind of slot this is, four optional strings (live URL, recording URL, accessibility info, resource links), and the optional room assignment.
    • +
    • Depends on: IConcurrencyAware from MMCA.Common.Shared.DTOs (SessionUpdateRequest.cs:1,6). Every other member is a BCL primitive or one of the module's identifier aliases (EventIdentifierType at :12, RoomIdentifierType at :54), so the record pulls in no domain type at all.
    • +
    • Concept introduced: carrying a field you are not allowed to change, so the server can detect that you tried. EventId is required here (:12) even though a session can never move between events, and the doc comment on the property says exactly that ("Must match the session's current EventId (BR-140: immutable after creation)", :11). The rule is enforced downstream by UpdateSessionHandler, which compares the request value against the loaded entity and returns a 422-shaped error when they differ (MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionHandler.cs:37-44). This is one of three strategies the module uses for a relationship field that must not move, and all three sit side by side in this unit: carry-and-verify here, omit-entirely in SponsorUpdateRequest (SponsorUpdateRequest.cs:7-10), and route-to-a-governed-endpoint in SpeakerUpdateRequest (SpeakerUpdateRequest.cs:6-7). Carry-and-verify is the right choice when the field is genuinely part of the resource's identity in the client's mental model: a session editor already knows which event it is working under, so sending it costs nothing and turns a client bug (posting session 42's body to session 43's route) into a loud rejection rather than a silent cross-event write. [Rubric §9, API and Contract Design] assesses whether a contract makes illegal states detectable rather than merely undocumented: the field's presence is what makes the mismatch checkable at all. [Rubric §11, Security]: a request body is caller-controlled, so an immutability rule that exists only in a UI is not a rule; the check that counts is the server-side comparison.
    • +
    • Walkthrough: RowVersion (:9) is the IConcurrencyAware token, nullable and therefore opt-out. Two members are required: EventId (:12) and Title (:15). Description (:18) is optional. The schedule is two nullable DateTime values, StartsAt (:21) and EndsAt (:24), so an unscheduled session is a legal state. Status (:27) is a free-form optional string. Four booleans describe the slot: IsInformed (:30) and IsConfirmed (:33) track the speaker-communication workflow, IsServiceSession (:36) marks lunch and break blocks, and IsPlenumSession (:39) marks whole-room slots. Four optional strings follow: LiveUrl (:42), RecordingUrl (:45), AccessibilityInfo (:48), and ResourceLinks (:51). RoomId (:54) is the nullable room assignment, and it is the one member that triggers cross-aggregate work in the handler. Every member is init-only.
    • +
    • Why it's built this way: nullable StartsAt/EndsAt are load-bearing rather than lazy. Sessions exist before the schedule is drawn, so "no time yet" must round-trip through the edit form without inventing a placeholder date; SessionRoomScheduling reads the same nullability and simply skips the double-booking probe when either bound is missing (MMCA.ADC.Conference.Application/Sessions/Validation/SessionRoomScheduling.cs:69-70). Because the PUT is a full replacement, the handler can pass all fourteen editable fields straight into Session.Update (MMCA.ADC.Conference.Domain/Sessions/Session.cs:229-243) without ever distinguishing "omitted" from "cleared".
    • +
    • Where it's used: bound from the body by SessionsController on PUT {id} (MMCA.ADC.Conference.API/Controllers/SessionsController.cs:323-327), validated by SessionUpdateRequestValidator, wrapped in UpdateSessionCommand (:330), and consumed field by field by UpdateSessionHandler.

    SpeakerUpdateRequest

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.Update · MMCA.ADC.Conference.Application/Speakers/UseCases/Update/SpeakerUpdateRequest.cs:8 · Level 1 · record

      -
    • What it is: the request DTO for changing an existing Speaker: the two name fields, the profile text and links, and the curation flag, plus the concurrency token.
    • -
    • Depends on: IConcurrencyAware from MMCA.Common.Shared.DTOs (SpeakerUpdateRequest.cs:1,8). Nothing else: every member is a BCL primitive, so the payload type carries no domain or framework reference.
    • -
    • Concept introduced: the field a request deliberately does not carry. The record's <remarks> (:6-7) states that it carries no linked-user field, because "the governed /link and /unlink endpoints (BR-208) are the only paths that change Speaker.LinkedUserId". This is a security decision expressed as an absence: because LinkedUserId is not bindable here, no crafted body can attach a speaker profile to someone else's account, and the uniqueness check plus the integration events that keep Identity's User.LinkedSpeakerId in sync stay on the one governed path. The complementary case is IsTopSpeaker (:32), which is present but is honored only for organizers: UpdateSpeakerHandler discards it on a self-edit. [Rubric §11, Security] assesses whether privilege boundaries are enforced server-side rather than assumed from the client: one field is removed from the contract entirely and the other is filtered in the handler, so neither protection depends on the UI behaving. [Rubric §9, API & Contract Design]: the shape of the request is itself the statement of what a caller may change.
    • -
    • Walkthrough: RowVersion (:11) is the nullable optimistic-concurrency token contributed by IConcurrencyAware, carried back so the handler can detect a lost update. FirstName (:14) and LastName (:17) are the two required string fields, so the compiler refuses a partially built request. Email (:20), Bio (:23), TagLine (:26), and ProfilePicture (:29) are the optional profile fields. IsTopSpeaker (:32) is the organizer-only featured flag. The four social links, TwitterHandle (:35), LinkedInUrl (:38), GitHubUrl (:41), and WebsiteUrl (:44), are all optional strings. Every member is init-only, so the request is immutable once bound.
    • -
    • Why it's built this way: required plus init is the codebase-wide immutability convention. Making both names required rather than nullable means the PUT is a full replacement, not a patch: the client always sends the complete state, so the handler never has to distinguish "not supplied" from "cleared".
    • -
    • Where it's used: validated by SpeakerUpdateRequestValidator, wrapped by UpdateSpeakerCommand, consumed by UpdateSpeakerHandler, and bound from the body by SpeakersController (MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:331).
    • +
    • What it is: the payload for editing an existing Speaker: the name, the contact email, the biography and tagline, the profile picture, the four social and web links, and the featured-speaker flag.
    • +
    • Depends on: IConcurrencyAware (SpeakerUpdateRequest.cs:1,8). Nothing else; all eleven payload members are string, string?, or bool.
    • +
    • Concept introduced: the field a request deliberately does not have. A Speaker row owns a LinkedUserId, the pointer to the Identity user allowed to edit that speaker's own profile, and this record has no member for it. The remark on the type says why: the governed /link and /unlink endpoints (BR-208) are the only paths that change it (:6-7). Removing the field from the wire contract is a stronger guarantee than validating it, because there is no value to validate and no code path to forget: a crafted body simply has nowhere to put the claim. That matters here because linking is not a property assignment, it is a two-context transaction. UnlinkUserFromSpeakerHandler raises SpeakerUnlinkedFromUser on the aggregate before the save so the outbox row commits with the unlink (MMCA.ADC.Conference.Application/Speakers/UseCases/UnlinkUser/UnlinkUserFromSpeakerHandler.cs:13-17), and the Identity module clears User.LinkedSpeakerId from that event. A generic PUT could not carry that coordination, so the shape of the contract encodes the constraint instead. [Rubric §11, Security] assesses whether privilege-bearing state can be reached from an unprivileged path: the answer is strongest when the path does not exist. [Rubric §9, API and Contract Design]: a narrower request is a clearer contract, and here the omission is documented on the type rather than left to be inferred.
    • +
    • Walkthrough: RowVersion (:11) is the concurrency token. FirstName (:14) and LastName (:17) are the two required members and the only two the validator guards. Email (:20), Bio (:23), TagLine (:26), and ProfilePicture (:29) are optional strings. IsTopSpeaker (:32) is the organizer curation flag: present in the contract, but only conditionally honored (see UpdateSpeakerCommand and UpdateSpeakerHandler). TwitterHandle (:35), LinkedInUrl (:38), GitHubUrl (:41), and WebsiteUrl (:44) close out the links. All members are init-only.
    • +
    • Why it's built this way: Email is a plain string? on the wire and becomes a validated value object inside the aggregate, not here. Speaker.Update calls Email.Create and returns its failure before touching any field (MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:208-215), so the format rule lives once in Email and applies to every path that sets a speaker address, including the Sessionize import, rather than only to callers who happen to arrive through this record (ADR-068).
    • +
    • Where it's used: bound by SpeakersController on PUT {id} (MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:327-332), validated by SpeakerUpdateRequestValidator, wrapped in UpdateSpeakerCommand together with the caller's role (:341), and consumed by UpdateSpeakerHandler.

    SponsorUpdateRequest

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.UseCases.Update · MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/SponsorUpdateRequest.cs:11 · Level 1 · record

      -
    • What it is: the request DTO for changing an existing conference Sponsor or exhibitor: the display name, the tier, the branding and link fields, the display order, and the two expo-booth fields, plus the concurrency token.
    • -
    • Depends on: IConcurrencyAware (SponsorUpdateRequest.cs:2,11) and SponsorTier from MMCA.ADC.Conference.Shared.Sponsors (:1,20). It is the one update request in this unit whose payload includes a domain enum rather than only primitives.
    • -
    • Concept: the same "field deliberately absent" idea SpeakerUpdateRequest teaches, applied to a commercial rather than a privacy concern. The record's <remarks> (:7-10) records that the owning event is missing on purpose: "moving a sponsor between events is a create plus a delete, so a mistyped EventId cannot silently relocate bought placement." The create-side request does carry an event id and validates it (MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestValidator.cs:12), and the domain's own Update doc comment repeats the rule (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:140), so all three layers agree that ownership is set once. [Rubric §9, API & Contract Design] assesses whether the contract expresses exactly what the server will do: an unchangeable relationship is simply not in the payload, so there is no field to ignore and no silent no-op to explain. [Rubric §4, Domain-Driven Design]: the sponsor-to-event relationship is treated as identity-bearing, not as an editable attribute.
    • -
    • Walkthrough: RowVersion (:14) is the concurrency token. Name (:17) is the single required string. Tier (:20) is the SponsorTier enum, whose numeric values double as the public display order (MMCA.ADC.Conference.Shared/Sponsors/SponsorTier.cs:12-25); because Platinum = 0 is the zero member, an omitted tier binds to the top package. LogoUrl (:23), Description (:26), WebsiteUrl (:29), LinkedInUrl (:32), and TwitterHandle (:35) are the optional branding and link fields. Sort (:38) is the order within the tier, so ranking is two-level (tier first, then sort). IsExhibitor (:41) marks a sponsor who also staffs an expo booth and BoothNumber (:44) is that booth's optional label. Every member is init-only.
    • -
    • Why it's built this way: one flat request covers both a pure sponsor and an exhibitor rather than splitting the two into separate contracts, because the domain models exhibiting as a flag on the same aggregate (Sponsor.cs:52) and keeps the booth number even when the flag is off (Sponsor.cs:55). Sending every editable field on each PUT keeps the update a full replacement, matching the other update requests in this module.
    • -
    • Where it's used: validated by SponsorUpdateRequestValidator, wrapped by UpdateSponsorCommand, consumed by UpdateSponsorHandler, and bound from the body by SponsorsController (MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:227).
    • -
    • Caveats / not-in-source: Tier carries no validator clause (see SponsorUpdateRequestValidator), so what happens to an out-of-range numeric tier is decided by the JSON binder rather than by anything in these files. Not determinable from source: whether an undefined SponsorTier value is rejected before it reaches the aggregate.
    • +
    • What it is: the payload for editing an existing Sponsor or exhibitor: display name, tier, logo, blurb, three outward links, the in-tier display order, and the two expo-booth fields.
    • +
    • Depends on: IConcurrencyAware from MMCA.Common.Shared.DTOs (SponsorUpdateRequest.cs:2,11) and the SponsorTier enum from MMCA.ADC.Conference.Shared.Sponsors (:1,20). It is the only one of the three update requests in this unit that carries a domain enum.
    • +
    • Concept introduced: omission as the immutability mechanism, and why the stakes decide which mechanism you pick. The remark on the type is explicit: the owning event is deliberately absent, because moving a sponsor between events is a create plus a delete, "so a mistyped EventId cannot silently relocate bought placement" (:7-10). Compare SessionUpdateRequest, which keeps EventId and has the handler reject a mismatch. Both prevent the move; they differ in what happens to a wrong value. Carry-and-verify turns a bad id into an error the client sees. Omission makes a bad id unrepresentable, at the cost of forcing a legitimate move through two operations. The module picks omission exactly where the record represents something sold: a sponsor's placement is inventory an organizer was paid for, and a create-plus-delete leaves an audit trail that a silent field edit does not. Note the follow-on effect on validation: SponsorCreateRequestValidator includes SponsorEventIdRules<T> (MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestValidator.cs:12, rule at MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:98-104) and SponsorUpdateRequestValidator cannot, because there is no property to select. [Rubric §9, API and Contract Design] assesses how a contract expresses what may change; [Rubric §30, Compliance and Data Governance]: for records with commercial consequences, an operation that leaves two auditable rows beats one that mutates a field in place.
    • +
    • Walkthrough: RowVersion (:14) is the concurrency token, and Name (:17) is the single required member. Tier (:20) is the SponsorTier enum. LogoUrl (:23), Description (:26), WebsiteUrl (:29), LinkedInUrl (:32), and TwitterHandle (:35) are the optional presentation fields. Sort (:38) is the display order within the tier, guarded to be non-negative by the validator. IsExhibitor (:41) and BoothNumber (:44) are the expo-floor pair: the flag says the sponsor staffs a booth, the string names it. All members are init-only.
    • +
    • Caveats / not-in-source: nothing in this record, or in SponsorUpdateRequestValidator, requires BoothNumber to be present when IsExhibitor is true or absent when it is false. Whether that pairing is enforced anywhere is not determinable from this type; the domain's own guard on the field is a length check only (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:168).
    • +
    • Where it's used: bound by SponsorsController on PUT {id} (MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:223-228), validated by SponsorUpdateRequestValidator, wrapped in UpdateSponsorCommand (:231), and consumed by UpdateSponsorHandler.

    RoomChangedHandler

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.DomainEventHandlers · MMCA.ADC.Conference.Application/Events/DomainEventHandlers/RoomChangedHandler.cs:11 · Level 3 · class

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.DomainEventHandlers · MMCA.ADC.Conference.Application/Events/DomainEventHandlers/RoomChangedHandler.cs:11 · Level 3 · class (sealed partial)

    +
    +
      +
    • What it is: the in-process handler for RoomChanged. It writes one structured log line per room lifecycle transition and does nothing else.
    • +
    • Depends on: IDomainEventHandler<in TDomainEvent> from MMCA.Common.Application.Interfaces (RoomChangedHandler.cs:3,12), the RoomChanged event it closes over (:2,12), DomainEntityState from MMCA.Common.Domain.Enums (:4,22), and ILogger<T> plus the [LoggerMessage] source generator from Microsoft.Extensions.Logging (:1,21).
    • +
    • Concept introduced: the unfiltered lifecycle handler, and the source-generated log message. This is the simplest possible subscriber under the one-event-per-entity taxonomy: rather than testing domainEvent.State, it passes the discriminator into the message template as a value (:17,21), so a single handler covers add, update, and remove and the log line names which one happened. ADR-083 records that choice explicitly, noting that a handler wanting every transition writes no filter and logs the discriminator instead. The second mechanism is [LoggerMessage] (:21): the attribute makes the compiler generate the body of the partial method LogRoomChanged (:22), which is why the class is declared sealed partial (:11). The generated code avoids boxing the arguments and skips formatting entirely when the Information level is disabled, so the call site is close to free when the log is off. It is also why the parameters keep their real types (DomainEntityState, the two identifier aliases, string) instead of being flattened into an interpolated string: each becomes a named field in the structured log, so an operator can query on RoomId rather than grep on text. [Rubric §13, Observability and Operability] assesses whether the system emits queryable, structured signals at meaningful transitions; [Rubric §12, Performance and Scalability]: the generator exists precisely so observability does not cost allocations on a path that runs on every write.
    • +
    • Walkthrough: the primary constructor takes only ILogger<RoomChangedHandler> (:12). HandleAsync (:15-19) is synchronous in substance: it calls LogRoomChanged with the event's four members (:17) and returns Task.CompletedTask (:18) rather than being marked async, which avoids allocating a state machine for a method that never awaits. LogRoomChanged (:21-22) declares the template "Room {State}: EventId={EventId}, RoomId={RoomId}, Name={RoomName}".
    • +
    • Why it's built this way: RoomChanged inherits BaseDomainEvent directly rather than EntityChangedEvent<TIdentifierType>, because a Room is a child and the interesting identity is the pair EventId plus RoomId (MMCA.ADC.Conference.Domain/Events/DomainEvents/RoomChanged.cs:13-18; ADR-083 names this exact case). The handler's template mirrors that pair, so a log line identifies the parent as well as the child.
    • +
    • Where it's used: registered as a singleton by the module's assembly scan, which finds every IDomainEventHandler<> implementation (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:144-148), and invoked after a successful save by the DomainEventSaveChangesInterceptor. The Event aggregate raises the event from four sites: adding a room (MMCA.ADC.Conference.Domain/Events/Event.cs:395), updating one (:433), restoring a soft-deleted one (:501), and removing one (:522).
    • +
    • Caveats / not-in-source: the restore path raises Added (Event.cs:501), so an Added line in the log does not prove a new row was inserted; it can equally mean a previously soft-deleted room was reactivated in place.
    • +
    +

    UpdateSessionResult

    +
    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.Update · MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionCommand.cs:19 · Level 3 · record

      -
    • What it is: the domain event handler for RoomChanged. It does exactly one thing: write a structured log line describing what happened to the room.
    • -
    • Depends on: IDomainEventHandler<in TDomainEvent> from MMCA.Common.Application.Interfaces (RoomChangedHandler.cs:3,12), RoomChanged (:2,12), DomainEntityState (:4,22), and ILogger<T> (Microsoft.Extensions.Logging, :1,12).
    • -
    • Concept introduced: the observation-only domain event handler. Domain event handlers are discovered by assembly scanning and registered as singletons during ScanModuleApplicationServices, then invoked by the framework's dispatcher after SaveChangesAsync (see Group 04). The aggregate that raised the event knows nothing about who listens. This particular handler is the simplest possible shape: no injected repository, no DI scope, no side effect beyond a log record, and a synchronous body returning Task.CompletedTask (:18) rather than an async method. That is worth learning as a baseline, because the next two handlers in this unit (SessionCreatedHandler and SpeakerDeletedHandler) each add exactly one thing on top of it. [Rubric §6, CQRS & Event-Driven] assesses whether behavior reacts to events instead of being wired into the writer: the room mutation path has no idea this log line exists. [Rubric §13, Observability & Operability]: the [LoggerMessage] source generator produces a compile-time log method with no boxing and no runtime format parsing.
    • -
    • Walkthrough: the class is sealed partial with a primary constructor taking only ILogger<RoomChangedHandler> (:11-12). HandleAsync (:15) calls LogRoomChanged with four fields off the event, State, EventId, RoomId, and RoomName (:17), then returns Task.CompletedTask (:18). The [LoggerMessage] declaration (:21-22) pins LogLevel.Information and the template "Room {State}: EventId={EventId}, RoomId={RoomId}, Name={RoomName}"; note that State is a message field rather than a branch, so one handler covers add, update, and delete without a state guard.
    • -
    • Why it's built this way: partial plus [LoggerMessage] is the codebase-wide high-performance logging convention. Keeping the state in the template (instead of three handlers or an if) is the right call when every state deserves the same treatment.
    • -
    • Where it's used: auto-discovered as a singleton by ScanModuleApplicationServices and invoked by the framework's domain event dispatcher after a Room change is saved.
    • +
    • What it is: the two-member envelope UpdateSessionHandler returns: the updated SessionDTO plus a boolean saying whether the new session times fall outside the parent event's date range.
    • +
    • Depends on: SessionDTO from MMCA.ADC.Conference.Shared.Sessions (UpdateSessionCommand.cs:2,19). The second member is a bool.
    • +
    • Concept introduced: none new. This is the advisory-result shape taught at UpdateEventResult, applied to a second rule: BR-86 rather than BR-131. What is worth carrying forward is that the shape recurs, which is what makes it a pattern rather than a one-off. Both cases share the same class of problem: the write is legal and must be persisted, but it leaves the data in a state a human should look at. Scheduling a session outside the conference dates is not an invariant violation (the organizer may be mid-edit, or the event dates may be about to move), so failing the request would be wrong, and swallowing it would leave a session nobody can attend. [Rubric §9, API and Contract Design] assesses how non-fatal conditions travel: SessionsController reads the flag, appends an X-Warning response header, and still returns 200 with only the DTO in the body (MMCA.ADC.Conference.API/Controllers/SessionsController.cs:336-343), so the envelope itself never reaches the wire. [Rubric §13, Observability and Operability]: the warning reaches the person who caused it, at the moment they caused it.
    • +
    • Walkthrough: one line, sealed record UpdateSessionResult(SessionDTO Session, bool HasDateRangeWarning) (:19), documented on the declaration (:16-18). No methods, no behavior; it exists to name a pair.
    • +
    • Why it's built this way: it shares a file with UpdateSessionCommand (:10) because the two are one use case's input and output and are never referenced apart. Keeping the flag out of SessionDTO is the load-bearing part: HasDateRangeWarning is a fact about this particular write, not a property of the session, so it must never be persisted, cached, or returned by a read endpoint.
    • +
    • Where it's used: constructed by UpdateSessionHandler (UpdateSessionHandler.cs:97), named in the handler's own interface (:20) and in the controller's injected handler type (MMCA.ADC.Conference.API/Controllers/SessionsController.cs:45), and unwrapped by the controller, which reads result.Value!.HasDateRangeWarning (:337) and then returns result.Value.Session (:343).
    • +
    • Caveats / not-in-source: the create path computes the same BR-86 warning in the controller instead, by re-reading the event through the query service (SessionsController.cs:302-316), so the two paths reach the same header by different routes. Only the update path routes it through a result envelope.

    SessionCreatedHandler

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.DomainEventHandlers · MMCA.ADC.Conference.Application/Sessions/DomainEventHandlers/SessionCreatedHandler.cs:11 · Level 4 · class

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.DomainEventHandlers · MMCA.ADC.Conference.Application/Sessions/DomainEventHandlers/SessionCreatedHandler.cs:11 · Level 4 · class (sealed partial)

      -
    • What it is: the domain event handler that logs session creation. It subscribes to the single SessionChanged event and acts only when the state is Added.
    • -
    • Depends on: IDomainEventHandler<in TDomainEvent> (SessionCreatedHandler.cs:3,12), SessionChanged (:2,12), DomainEntityState (:4,17), and ILogger<T> (:1,12).
    • -
    • Concept introduced: one event type, many handlers, routed by state. The Conference domain does not raise SessionCreated / SessionUpdated / SessionDeleted as three event types. It raises one SessionChanged carrying a DomainEntityState discriminator, and each handler opens with a guard that returns early for the states it does not care about (:17-18). The trade-off is deliberate: a single event type keeps the aggregate's AddDomainEvent calls uniform and lets a handler that genuinely wants every state (like RoomChangedHandler) skip the branch entirely, at the cost of every specialized handler paying for a guard and being invoked on states it ignores. [Rubric §6, CQRS & Event-Driven]: the aggregate publishes what changed, and each consumer decides what is interesting. [Rubric §13, Observability & Operability]: the [LoggerMessage] source generator gives a zero-allocation log path.
    • -
    • Walkthrough: sealed partial class with an ILogger<SessionCreatedHandler> primary constructor (:11-12). HandleAsync (:15) first checks domainEvent.State != DomainEntityState.Added and returns Task.CompletedTask when the state is anything else (:17-18). On an add it calls LogSessionCreated with SessionId, Title, and EventId (:20) and returns a completed task (:21). The [LoggerMessage] (:24-25) pins Information and the template "Session created: SessionId={SessionId}, Title={SessionTitle}, EventId={EventId}". Note the log-property name is SessionTitle even though it is fed from domainEvent.Title: the structured log key is chosen for searchability across the whole telemetry stream, not to mirror the source property.
    • -
    • Why it's built this way: the guard costs one enum comparison, which is cheaper than a per-state event hierarchy would cost in aggregate code. Because the handler is synchronous and stateless, registering it as a singleton is safe.
    • -
    • Where it's used: auto-discovered as a singleton by ScanModuleApplicationServices; fires on every SessionChanged dispatch and does work only on creations.
    • +
    • What it is: the handler that subscribes to SessionChanged and logs a line only when the transition was a creation. Every other state is ignored.
    • +
    • Depends on: IDomainEventHandler<in TDomainEvent> (SessionCreatedHandler.cs:3,12), SessionChanged (:2,12), DomainEntityState (:4,17), and ILogger<T> with [LoggerMessage] (:1,24).
    • +
    • Concept introduced: the state filter, and why the type name and the subscription differ. The class is called SessionCreatedHandler but it implements IDomainEventHandler<SessionChanged> (:12), because there is no SessionCreated event to subscribe to: under ADR-083 a Session raises one event type from all three lifecycle sites (MMCA.ADC.Conference.Domain/Sessions/Session.cs:206 for Added, :267 for Updated, :304 for Deleted), and a subscriber that cares about one transition narrows on the discriminator itself. The guard is the first statement of the method: if (domainEvent.State != DomainEntityState.Added) return Task.CompletedTask; (:17-18). The ADR cites these exact lines as the canonical example of the pattern. Two practical consequences are worth internalizing. First, the handler is invoked for every session write in the system, so the filter has to be cheap and has to come first, before any logging or I/O. Second, "handler ran" and "handler acted" are now different facts, which is why a test for this type has to assert the no-op case as well as the acted case. Compare RoomChangedHandler, which takes the other branch of the same fork and logs every transition. [Rubric §6, CQRS and Event-Driven] assesses whether write-side effects are expressed as subscriptions to explicit events rather than as inline calls; [Rubric §16, Maintainability]: adding a fourth transition later means adding a raise site, not editing three handler contracts.
    • +
    • Walkthrough: the primary constructor takes only ILogger<SessionCreatedHandler> (:12). HandleAsync (:15-22) filters on state (:17-18), then calls the generated LogSessionCreated with the session id, the title, and the parent event id (:20) and returns Task.CompletedTask (:21). LogSessionCreated (:24-25) carries the template "Session created: SessionId={SessionId}, Title={SessionTitle}, EventId={EventId}". Note the near-miss in naming: the template placeholder is SessionTitle while the event member is Title, and the generated structured field follows the method parameter, so the log records SessionTitle.
    • +
    • Why it's built this way: logging the parent EventId beside the session id is what makes the line useful in a deployment that hosts several conference editions, and it is available only because SessionChanged carries EventId as a member (MMCA.ADC.Conference.Domain/Sessions/DomainEvents/SessionChanged.cs:13-18) rather than forcing the handler to read the session back out of the database.
    • +
    • Where it's used: registered as a singleton by the module assembly scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:144-148) and dispatched after a successful save by the DomainEventSaveChangesInterceptor. In practice it fires for sessions created by CreateSessionHandler and for every session the Sessionize import inserts.

    SpeakerDeletedHandler

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.DomainEventHandlers · MMCA.ADC.Conference.Application/Speakers/DomainEventHandlers/SpeakerDeletedHandler.cs:20 · Level 4 · class

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.DomainEventHandlers · MMCA.ADC.Conference.Application/Speakers/DomainEventHandlers/SpeakerDeletedHandler.cs:20 · Level 4 · class (sealed partial)

      -
    • What it is: the handler for SpeakerChanged in the Deleted state (BR-70). It logs the soft-delete and, when the speaker had been linked to a user, publishes a SpeakerUnlinkedFromUser integration event so the Identity module can clear that user's LinkedSpeakerId.
    • -
    • Depends on: IDomainEventHandler<in TDomainEvent> (SpeakerDeletedHandler.cs:5,22), SpeakerChanged (:3,22), SpeakerUnlinkedFromUser (:4,43), IEventBus resolved from a child scope (:41), DomainEntityState (:6,29), IServiceScopeFactory (Microsoft.Extensions.DependencyInjection, :1,21), and ILogger<T> (:2,22).
    • -
    • Concept introduced: two things at once, and both matter. First, the domain event that raises an integration event. A domain event is in-process and Conference-local; an integration event crosses a module (and, in ADC's extracted topology, a process) boundary. This handler is the bridge: the Speaker aggregate raises a local SpeakerChanged, and the handler translates it into a durable cross-service fact. Its own doc comment (:14-18) records that this replaces a previous direct call into IUserSpeakerLinkService.ClearLinkedSpeakerAsync, so the cleanup is now eventually consistent: Identity processes the event asynchronously through the broker, or in-process via the outbox in monolith mode. Publishing through IEventBus means the event is persisted with the aggregate change and delivered by the outbox processor (ADR-003), so a broker outage delays but does not lose the unlink. [Rubric §7, Microservices Readiness] assesses whether cross-module coupling is asynchronous and transport-agnostic: replacing the direct service call with an event is precisely the change that lets Identity and Conference run as separate processes (ADR-008). [Rubric §29, Resilience & Business Continuity]: the outbox makes the cross-service cleanup retryable. - Second, the singleton-to-scoped bridge. Domain event handlers are registered as singletons, but IEventBus is scoped (it writes through the request's unit of work). Injecting a scoped service into a singleton constructor is a captive-dependency bug. The handler avoids it by taking IServiceScopeFactory instead and opening a fresh await using scope at the moment of use (:40), with the source comment stating the reason outright: "Uses a separate DI scope because the handler is a singleton" (:36-37). Learn this shape: it is the correct answer whenever singleton-lifetime code needs scoped work.
    • -
    • Walkthrough: the class is sealed partial with a two-argument primary constructor, IServiceScopeFactory and ILogger<SpeakerDeletedHandler> (:20-22). HandleAsync is genuinely async here (:25), unlike its two siblings above. It null-guards the event with ArgumentNullException.ThrowIfNull (:27), then returns early unless State == DomainEntityState.Deleted (:29-30). It logs SpeakerId, FullName, and PreviousLinkedUserId (:32). The publish is conditional on domainEvent.PreviousLinkedUserId.HasValue (:38): a speaker who was never linked to a user produces no integration event at all. When there is a link, it opens the scope (:40), resolves IEventBus (:41), and publishes new SpeakerUnlinkedFromUser(PreviousLinkedUserId.Value, SpeakerId) with ConfigureAwait(false) (:42-44). The [LoggerMessage] (:48-49) pins Information and the template "Speaker soft-deleted: SpeakerId={SpeakerId}, Name={SpeakerName}, PreviousLinkedUserId={PreviousLinkedUserId}".
    • -
    • Why it's built this way: the event carries PreviousLinkedUserId because, as the source comment records (:34-36), LinkedUserId was already cleared on the Speaker entity during Delete() in the Conference context. By the time the handler runs, the current entity no longer knows who it was linked to, so the domain event must have captured it. That is a general lesson about state-carrying events: capture what the consumer needs at raise time, because the aggregate has already moved on.
    • -
    • Where it's used: auto-discovered as a singleton; fires on every SpeakerChanged dispatch and acts only on soft-deletes. The published event is consumed on the Identity side (see Group 24).
    • -
    • Caveats / not-in-source: the word "soft-deleted" in the log template reflects the codebase-wide soft-delete convention; this file does not itself set IsDeleted, so the fact that the delete is soft is established in the Speaker aggregate, not here.
    • +
    • What it is: the handler for SpeakerChanged in the Deleted state (BR-70). It logs the soft delete and, when the speaker had a linked user, publishes SpeakerUnlinkedFromUser so the Identity module can clear that user's LinkedSpeakerId.
    • +
    • Depends on: IDomainEventHandler<in TDomainEvent> (SpeakerDeletedHandler.cs:5,22), SpeakerChanged (:3,22), SpeakerUnlinkedFromUser from MMCA.ADC.Conference.Shared.Speakers.IntegrationEvents (:4,43), IEventBus resolved at runtime rather than injected (:41), DomainEntityState (:6,29), and IServiceScopeFactory from Microsoft.Extensions.DependencyInjection (:1,21).
    • +
    • Concept introduced: crossing a module boundary with an event instead of a call, and the singleton-to-scoped lifetime bridge that makes it possible. Two mechanisms are stacked here, and both repay slowing down for.
        +
      • The boundary crossing. Deleting a Conference speaker leaves a dangling pointer in the Identity module's User.LinkedSpeakerId. The direct fix would be a call into an Identity service, which is exactly what this handler's doc comment says it replaces (:14-18). Instead the handler publishes an integration event and lets Identity react on its own schedule: SpeakerUnlinkedFromUserHandler subscribes and clears the field. The consequence is that the two sides are eventually consistent, not atomically consistent, and the doc states that plainly (:16-18). That trade is what lets Conference and Identity run as separate services without a code change: in the monolith the publish goes through InProcessEventBus and the outbox, and when the modules are split the same event travels over the broker (ADR-003, ADR-007). [Rubric §7, Microservices Readiness] assesses whether cross-module work is already expressed in a transport-agnostic way; [Rubric §6, CQRS and Event-Driven]: a domain event internal to one context is translated into an integration event at the boundary rather than leaking as-is.
      • +
      • The lifetime bridge. Domain event handlers are registered as singletons (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:143-148, whose comment says they create their own DI scopes internally), while IEventBus needs a scoped DbContext to write its outbox row. Injecting a scoped service into a singleton constructor is the classic captive-dependency bug: the first scope's context would be pinned for the lifetime of the process. The handler therefore takes IServiceScopeFactory (:21) and opens a scope per invocation with await using var scope = scopeFactory.CreateAsyncScope() (:40), resolving IEventBus from inside it (:41). await using matters because the scope owns async-disposable services. [Rubric §15, Best Practices and Code Quality] assesses lifetime correctness; [Rubric §1, SOLID]: the handler depends on the abstraction for creating a scope, not on a service locator it could misuse elsewhere.
      -

      AddCategoryItemCommand

      +
    • +
    • Walkthrough: the primary constructor takes IServiceScopeFactory and ILogger<SpeakerDeletedHandler> (:20-22). HandleAsync (:25-46) starts with ArgumentNullException.ThrowIfNull(domainEvent) (:27), then filters to Deleted and returns otherwise (:29-30). It logs first (:32), so the soft delete is recorded whether or not a link existed. The publish is guarded by if (domainEvent.PreviousLinkedUserId.HasValue) (:38): a speaker who was never linked produces no integration event and therefore no work for Identity. Inside the guard, the scope is created (:40), IEventBus is resolved (:41), and PublishAsync sends new SpeakerUnlinkedFromUser(domainEvent.PreviousLinkedUserId.Value, domainEvent.SpeakerId) (:42-44). LogSpeakerDeleted (:48-49) is the generated [LoggerMessage] method carrying all three fields.
    • +
    • Why it's built this way: the handler can only work because the event carries the previous value. Speaker.Delete captures LinkedUserId before clearing it and passes it into the event (MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:253-263), and the event's own doc says this exists so the cross-context cleanup can run after the field has already been cleared (MMCA.ADC.Conference.Domain/Speakers/DomainEvents/SpeakerChanged.cs:12-15). Without the captured value the handler would have to read a row whose link is already gone. Note also that the entity is only soft-deleted (ADR-005), so "deleted" here still leaves a speaker row behind.
    • +
    • Where it's used: registered as a singleton by the assembly scan and dispatched by the DomainEventSaveChangesInterceptor after the delete has been persisted. Its output is consumed by SpeakerUnlinkedFromUserHandler in the Identity module, registered as a broker consumer when the two run as separate services (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:300).
    • +
    • Caveats / not-in-source: the publish here is a second write, not part of the delete's transaction. Domain events are dispatched after the save completes, and under an ambient transaction after commit, by the interceptor (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:12-33), and InProcessEventBus then adds its own outbox row and calls SaveChangesAsync on its own (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/InProcessEventBus.cs:66-77). Contrast UnlinkUserFromSpeakerHandler, which raises the same integration event on the aggregate before the save so the outbox row commits with the unlink, and whose doc comment names the two-commit hazard it is avoiding (MMCA.ADC.Conference.Application/Speakers/UseCases/UnlinkUser/UnlinkUserFromSpeakerHandler.cs:13-17). The delete path's backstop is the outbox row for SpeakerChanged itself, which stays unprocessed and is retried by the OutboxProcessor when the dispatch fails (DomainEventSaveChangesInterceptor.cs:16-18). Whether that backstop closes the window in every failure mode is not determinable from these files alone.
    • +
    +

    SessionUpdateRequestValidator

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.AddCategoryItem · MMCA.ADC.Conference.Application/Categories/UseCases/AddCategoryItem/AddCategoryItemCommand.cs:14 · Level 7 · record

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.Update · MMCA.ADC.Conference.Application/Sessions/UseCases/Update/SessionUpdateRequestValidator.cs:7 · Level 8 · class (sealed)

      -
    • What it is: the write intent for adding one child item to an existing conference Category aggregate. It carries the owning category id, an optional item id, the display name, and the sort order.
    • -
    • Depends on: ICacheInvalidating from MMCA.Common.Application.UseCases (AddCategoryItemCommand.cs:2,18) and the Category domain type, referenced only to build the cache key prefix (:1,21). ConferenceCategoryIdentifierType and CategoryItemIdentifierType are the module identifier aliases (see primer).
    • -
    • Concept introduced: the child-add command shape. A positional record is the whole request: it holds no behavior, and the aggregate decides what the add means. Two details carry weight. First, CategoryItemId is nullable (CategoryItemIdentifierType?, :16), and the XML doc says why (:11): the Sessionize import supplies the source-assigned id, while a manual add leaves it null for database-generated identity. Second, implementing ICacheInvalidating opts the command into the caching decorator of the CQRS pipeline, so a successful add evicts the category read cache without the handler touching a cache API. [Rubric §6, CQRS & Event-Driven] assesses whether writes are explicit intents flowing through one uniform pipeline: this record is the intent, and the marker interface is how a cross-cutting concern attaches to it declaratively. [Rubric §10, Cross-Cutting]: cache eviction is declared on the contract and applied centrally, never hand-rolled per handler.
    • -
    • Walkthrough: the positional parameters are CategoryId (:15), the nullable CategoryItemId (:16), Name (:17), and Sort (:18); the record is sealed and implements ICacheInvalidating (:14,18). The single member, CachePrefix (:21), returns $"{typeof(Category).FullName}:", the key namespace the caching decorator wipes on success. Keying off typeof(Category) rather than typeof(CategoryItem) is deliberate: items are read as part of their parent, so the parent's cached reads are the stale ones.
    • -
    • Why it's built this way: a positional record gives value equality and immutability for free, and deriving the prefix from the entity's FullName keeps producer (this command) and consumer (the query cache) agreed on one string with no shared constant to drift.
    • -
    • Where it's used: constructed by CategoryItemsController on POST from the bound request (MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:126-130), validated by AddCategoryItemCommandValidator, and handled by AddCategoryItemHandler.
    • +
    • What it is: the FluentValidation validator for SessionUpdateRequest. It composes seven reusable session rule sets and adds nothing of its own.
    • +
    • Depends on: AbstractValidator<T> from FluentValidation (SessionUpdateRequestValidator.cs:1,7), SessionUpdateRequest, and seven rule sets from MMCA.ADC.Conference.Application.Sessions.Validation (:2,11-17): SessionTitleRules<T>, SessionDescriptionRules<T>, SessionStatusRules<T>, SessionLiveUrlRules<T>, SessionRecordingUrlRules<T>, SessionAccessibilityInfoRules<T>, and SessionResourceLinksRules<T>.
    • +
    • Concept introduced: none new; this is Include composition (taught in group 06) applied at its widest in this unit. What is worth reading here is the diff against the create side. SessionCreateRequestValidator makes eight Include calls to this validator's seven, and the extra one is SessionEventIdRules<T> (MMCA.ADC.Conference.Application/Sessions/UseCases/Create/SessionCreateRequestValidator.cs:12). The asymmetry is exactly the point made at SessionUpdateRequest: on create, EventId is the field that decides where the session lands, so "you must specify an Event for the Session" is a real field-level rule (MMCA.ADC.Conference.Application/Sessions/Validation/SessionValidationRules.cs:24-30). On update it is a value to be compared, not chosen, so a non-empty check would add nothing and the real guard is the handler's equality test. Every other rule is shared verbatim between the two paths, which is the payoff of packaging each field's contract as its own generic type rather than writing rules inline. [Rubric §24, Forms, Validation and UX Safety] assesses whether input constraints are single-sourced and consistently applied across entry paths; [Rubric §1, SOLID]: this class's only job is composition, so a title-rule change never has to be found in two files.
    • +
    • Walkthrough: sealed class SessionUpdateRequestValidator : AbstractValidator<SessionUpdateRequest> (:7); the constructor (:9-18) is seven Include calls and nothing else. Title gets SessionTitleRules<T> (:11), which derives from RequiredStringRules<T> and reads its bound from the domain's SessionInvariants.TitleMaxLength (SessionValidationRules.cs:13-18). The other six all derive from OptionalStringRules<T> and are pure length bounds pulled from the same invariants class: description (:12), status (:13), live URL (:14), recording URL (:15), accessibility info (:16), and resource links (:17). Fields with no rule at all: RowVersion, EventId, StartsAt, EndsAt, the four booleans, and RoomId.
    • +
    • Why it's built this way: the two URL fields are length-checked but not format-checked, and the rule sets say why on the type: the value is stored as an opaque string for Sessionize compatibility (SessionValidationRules.cs:56-60,69-73). An imported feed is the authority on what a live-stream link looks like, so rejecting a shape the upstream system accepts would break the import rather than protect anyone. Length bounds are read from SessionInvariants in the Domain layer, so the validator's message, the aggregate's guard, and the EF column width agree on one number. [Rubric §16, Maintainability]: widening a field is a one-constant change.
    • +
    • Caveats / not-in-source: the start/end ordering rule is not here. StartsAt and EndsAt carry no validator rule; the ordering invariant is enforced inside the aggregate by SessionInvariants.EnsureEndsAtIsAfterStartsAt, called from Session.Update (MMCA.ADC.Conference.Domain/Sessions/Session.cs:247). A reader looking for "why was my end-before-start rejected" must look at the domain, not at this file.
    • +
    • Where it's used: discovered by assembly scanning, reached through UpdateSessionCommand's ICommandWithRequest<out TRequest> implementation, and executed by the ValidatingCommandDecorator<TCommand, TResult> before UpdateSessionHandler runs.
    -

    CategoryItemDTOMapper

    +

    SpeakerUpdateRequestValidator

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.DTOs · MMCA.ADC.Conference.Application/Categories/DTOs/CategoryItemDTOMapper.cs:12 · Level 7 · class

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.Update · MMCA.ADC.Conference.Application/Speakers/UseCases/Update/SpeakerUpdateRequestValidator.cs:7 · Level 8 · class (sealed)

      -
    • What it is: the Mapperly-generated mapper that turns a CategoryItem domain entity into its wire-facing CategoryItemDTO.
    • -
    • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> from MMCA.Common.Application.Interfaces (CategoryItemDTOMapper.cs:3,13), CategoryItem and CategoryItemDTO (:1-2), and the Mapperly source generator (Riok.Mapperly.Abstractions, NuGet, :4).
    • -
    • Concept introduced: source-generated DTO mapping (ADR-001). The class is sealed partial and carries [Mapper] (:11-12); Mapperly writes the body of the partial CategoryItemDTO MapToDTO(...) declaration (:16) at compile time by name-matching properties, so there is no runtime reflection, no expression tree, and no hand-written field copy to fall behind the entity. A shape mismatch fails the build rather than a request. This is the split ADR-001 describes: property-name-parallel entity/DTO pairs get generated mappers, and only genuine mismatches need hand-written code. [Rubric §9, API & Contract Design] assesses whether the domain model is shielded from the wire contract: the entity and the DTO stay two separate shapes, so a domain rename cannot silently change the API payload. [Rubric §12, Performance & Scalability]: compile-time mapping costs no reflection at runtime.
    • -
    • Walkthrough: MapToDTO (:16) is the generated single-entity conversion. MapToDTOs (:19) is hand-written because the interface asks for a collection overload: it null-guards with ArgumentNullException.ThrowIfNull(entityCollection) (:21), then projects each element through MapToDTO into a materialized array with the collection expression [.. entityCollection.Select(MapToDTO)] (:22). Materializing rather than returning a lazy sequence matters, because the caller may dispose the DbContext before enumeration.
    • -
    • Why it's built this way: implementing the framework's IEntityDTOMapper contract is what lets the mapper be discovered by assembly scanning and injected, so no caller ever news it or hardcodes a conversion.
    • -
    • Where it's used: composed as the child mapper inside ConferenceCategoryDTOMapper (ConferenceCategoryDTOMapper.cs:13-14,17-18) and injected into AddCategoryItemHandler (AddCategoryItemHandler.cs:17) to shape the newly added item for the response.
    • +
    • What it is: the validator for SpeakerUpdateRequest. Two Include calls, covering the first and last name.
    • +
    • Depends on: AbstractValidator<T> (SpeakerUpdateRequestValidator.cs:1,7), SpeakerUpdateRequest, and two rule sets from MMCA.ADC.Conference.Application.Speakers.Validation (:2,11-12): SpeakerFirstNameRules<T> and SpeakerLastNameRules<T>.
    • +
    • Concept introduced: none new, but this is the clearest example in the unit of a validator that is deliberately thin because the rules live deeper. Nine of the eleven payload fields carry no rule here: Email, Bio, TagLine, ProfilePicture, IsTopSpeaker, and the four link fields. That is not an oversight to be filed as a gap. Email is validated where it becomes a value object, inside Speaker.Update via Email.Create (MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:208-215), so adding a duplicate rule here would create a second place to keep a format in agreement. IsTopSpeaker is not a shape question at all: whether the caller may set it is an authorization decision, made by UpdateSpeakerHandler from the command's CallerIsOrganizer flag (UpdateSpeakerHandler.cs:40), and a validator has no access to the caller. The lesson generalizes: when you find a field with no rule, ask which of the three layers owns it (shape at the validator, invariant at the aggregate, privilege at the handler) before concluding it is unguarded. [Rubric §24, Forms, Validation and UX Safety] assesses coverage of inbound fields; [Rubric §4, Domain-Driven Design]: rules belong where the knowledge to enforce them lives.
    • +
    • Walkthrough: sealed class SpeakerUpdateRequestValidator : AbstractValidator<SpeakerUpdateRequest> (:7); the constructor (:9-13) includes SpeakerFirstNameRules<T> against FirstName (:11) and SpeakerLastNameRules<T> against LastName (:12). Both derive from RequiredStringRules<T> and take their bounds from SpeakerInvariants (MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:11-16,22-27).
    • +
    • Why it's built this way: this constructor is byte-for-byte the same two rules as SpeakerCreateRequestValidator (MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequestValidator.cs:11-12), differing only in the generic argument. Unlike sessions and sponsors, a speaker has no owning-event field, so create and update have identical field contracts and there is no diff to explain.
    • +
    • Where it's used: registered by assembly scanning and run by the ValidatingCommandDecorator<TCommand, TResult> ahead of UpdateSpeakerHandler, reached through UpdateSpeakerCommand.
    -

    AddCategoryItemCommandValidator

    +

    SponsorUpdateRequestValidator

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.AddCategoryItem · MMCA.ADC.Conference.Application/Categories/UseCases/AddCategoryItem/AddCategoryItemCommandValidator.cs:7 · Level 8 · class

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.UseCases.Update · MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/SponsorUpdateRequestValidator.cs:7 · Level 8 · class (sealed)

      -
    • What it is: the FluentValidation validator for AddCategoryItemCommand, run by the pipeline before the add handler executes. It checks the new item's Name and Sort.
    • -
    • Depends on: AbstractValidator<AddCategoryItemCommand> (FluentValidation, NuGet, AddCategoryItemCommandValidator.cs:1,7) and two shared rule sets from the module's Categories.Validation namespace (:2), CategoryItemNameRules<T> and CategoryItemSortRules<T>.
    • -
    • Concept introduced: rule-set composition via Include. Rather than restating the item-field rules inline, the constructor folds two reusable, selector-parameterized rule objects into this validator: Include(new CategoryItemNameRules<AddCategoryItemCommand>(p => p.Name)) (:11) and Include(new CategoryItemSortRules<AddCategoryItemCommand>(p => p.Sort)) (:12). Each rule set is generic in the request type and takes a property selector, so one definition serves the add command, the update command, and the import path, each pointing the selector at its own property. The name rule enforces non-empty plus a bound sourced from CategoryInvariants.CategoryItemNameMaxLength (MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:30-33), and the sort rule enforces GreaterThanOrEqualTo(0) (ConferenceCategoryValidationRules.cs:43-45). [Rubric §24, Forms/Validation/UX Safety] assesses whether input constraints are declared once and applied consistently across entry paths. [Rubric §1, SOLID]: the item's field rules live in exactly one place, so a length or range change updates every command that edits those fields at once.
    • -
    • Walkthrough: the constructor body (:9-13) is two Include calls and nothing else. Neither id field is validated: the owning category's existence is a database question, answered by AddCategoryItemHandler with an Error.NotFound, and uniqueness of the name within the category is a domain question, answered by the aggregate (BR-138).
    • -
    • Why it's built this way: splitting the checks by who can answer them is the whole idea. Format rules that need no data run here, before the transaction opens; rules that need the aggregate run inside it.
    • -
    • Where it's used: discovered by assembly scanning and invoked by the pipeline's validation decorator ahead of AddCategoryItemHandler.
    • +
    • What it is: the validator for SponsorUpdateRequest. Eight Include calls covering the name, the sort order, and the six optional strings.
    • +
    • Depends on: AbstractValidator<T> (SponsorUpdateRequestValidator.cs:1,7), SponsorUpdateRequest, and eight rule sets from MMCA.ADC.Conference.Application.Sponsors.Validation (:2,11-18): SponsorNameRules<T>, SponsorSortRules<T>, SponsorLogoUrlRules<T>, SponsorDescriptionRules<T>, SponsorWebsiteUrlRules<T>, SponsorLinkedInUrlRules<T>, SponsorTwitterHandleRules<T>, and SponsorBoothNumberRules<T>.
    • +
    • Concept introduced: none new. The one thing to read is the non-string rule in the set: SponsorSortRules<T> (:12) is not a length bound, it is GreaterThanOrEqualTo(0) with the stable error code Sponsor.Sort.Negative (MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:110-116). Packaging a one-call numeric rule as a generic rule set pays off precisely because sponsors have two entry paths, create and update, and both include it. Note also what the enum member does not get: Tier has no rule, unlike the event module's update validator, which guards its enum with IsInEnum (compare EventUpdateRequestValidator). [Rubric §24, Forms, Validation and UX Safety] assesses whether every inbound field has a contract; on this record, an out-of-range integer cast to SponsorTier would bind and reach the aggregate, whose Update guards only the name, logo URL, and booth number (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:165-168).
    • +
    • Walkthrough: sealed class SponsorUpdateRequestValidator : AbstractValidator<SponsorUpdateRequest> (:7); the constructor (:9-19) is eight Include calls: name (:11), sort (:12), logo URL (:13), description (:14), website URL (:15), LinkedIn URL (:16), Twitter handle (:17), and booth number (:18). SponsorNameRules<T> derives from RequiredStringRules<T> (SponsorValidationRules.cs:13-18); the six optional-string rules all derive from OptionalStringRules<T> and read their bounds from SponsorInvariants. Fields with no rule: RowVersion, Tier, and IsExhibitor.
    • +
    • Why it's built this way: SponsorCreateRequestValidator makes nine Include calls in the same order (MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestValidator.cs:11-19), and the extra one is SponsorEventIdRules<T> (:12). That is the mechanical consequence of the design decision recorded on SponsorUpdateRequest: with no EventId property there is no selector to pass, so the rule cannot be included even by accident.
    • +
    • Where it's used: registered by assembly scanning, reached through UpdateSponsorCommand, and executed by the ValidatingCommandDecorator<TCommand, TResult> before UpdateSponsorHandler.
    -

    AddCategoryItemHandler

    +

    UpdateSpeakerCommand

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.AddCategoryItem · MMCA.ADC.Conference.Application/Categories/UseCases/AddCategoryItem/AddCategoryItemHandler.cs:15 · Level 8 · class

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.Update · MMCA.ADC.Conference.Application/Speakers/UseCases/Update/UpdateSpeakerCommand.cs:13 · Level 8 · record (sealed)

      -
    • What it is: the handler for AddCategoryItemCommand: load the owning Category, delegate the add to the root, persist, log, and return the new item's DTO.
    • -
    • Depends on: ICommandHandler<in TCommand, TResult> (AddCategoryItemHandler.cs:6,18), IUnitOfWork (:5,16), CategoryItemDTOMapper (:2,17), Result and Error (:7), and ILogger<AddCategoryItemHandler> (:1,18).
    • -
    • Concept introduced: routing a child mutation through the aggregate root. The handler never inserts a CategoryItem row; it loads the Category and calls category.AddCategoryItem(...) (:30), so the aggregate enforces its own consistency (the DDD boundary rule from Group 02). What lives behind that one call is substantial: the case-insensitive name-uniqueness rule BR-138, the CategoryItem factory, and a CategoryItemChanged domain event (MMCA.ADC.Conference.Domain/Categories/Category.cs:131-153). The handler is correspondingly thin, because validation, caching, and the transaction are applied by the decorator pipeline around it (see primer and Group 05). [Rubric §4, Domain-Driven Design]: mutations go through the root. [Rubric §13, Observability & Operability]: the [LoggerMessage] source-generated log (:41-42) is compile-time and allocation-free.
    • -
    • Walkthrough: HandleAsync (:21-23) gets the typed repository from the unit of work (:25) and loads the category with the plain GetByIdAsync (:26), with no eager include, because the aggregate root can add a child without materializing the existing collection. A null category returns Error.NotFound tagged with source and target for diagnostics (:27-28). It then calls category.AddCategoryItem(command.CategoryItemId, command.Name, command.Sort) (:30) and short-circuits with the aggregate's own errors on failure (:31-32). On success it awaits SaveChangesAsync with ConfigureAwait(false) (:34), emits LogCategoryItemAdded with the item name and category id (:36, :41-42), and returns Result.Success(dtoMapper.MapToDTO(result.Value!)) (:38), mapping the very item the aggregate just created rather than re-reading it.
    • -
    • Why it's built this way: the handler opens no transaction and evicts no cache itself; the transactional and caching decorators do that, driven by the marker interface on AddCategoryItemCommand. That keeps the handler focused and the cross-cutting behavior uniform across every command in the module.
    • -
    • Where it's used: injected into CategoryItemsController as ICommandHandler<AddCategoryItemCommand, Result<CategoryItemDTO>> (MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:63) and invoked on POST (CategoryItemsController.cs:125-131).
    • -
    • Caveats / not-in-source: AddCategoryItem returns the created entity, so the DTO is mapped from the in-memory instance. Any value the database assigns during SaveChangesAsync is reflected only because EF writes the generated key back onto that tracked instance; nothing in this handler re-queries.
    • +
    • What it is: the write intent for updating a Speaker. Unlike the other two update commands in this unit it has three members: the target id, the request payload, and a boolean saying whether the caller holds the Organizer role.
    • +
    • Depends on: ICommandWithRequest<out TRequest> and ICacheInvalidating from MMCA.Common.Application.UseCases (UpdateSpeakerCommand.cs:2,16), the SpeakerUpdateRequest it wraps (:15), and the Speaker type used only for its FullName in the cache prefix (:1,19).
    • +
    • Concept introduced: putting the caller's authority inside the command, bound at the edge. The id-plus-request shape and the validation-by-delegation wiring were taught at UpdateConferenceCategoryCommand; what is new here is the third parameter. CallerIsOrganizer (:16) is a fact about who is asking, not about what is being asked, and the doc comment states the invariant that makes it safe: it is "bound at the API edge, never from the request body" (:9-10). SpeakersController computes it from the authenticated principal via ICurrentUserService and passes it as a named argument (MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:335,341). Two design questions are settled by this shape. First, why not read the principal inside the handler? Because the Application layer would then depend on the ambient HTTP context, and the same handler has to work when invoked from a background job or a test. Passing authority as data keeps the handler a pure function of its command. Second, why a boolean rather than the whole principal? Because the handler needs exactly one bit, and narrowing at the boundary means the handler cannot accidentally start making other authorization decisions. The doc also records the consequence: when the flag is false (a BR-214 speaker self-edit) the handler ignores the organizer-only IsTopSpeaker field and keeps the stored value (:10-12). [Rubric §11, Security] assesses whether privilege decisions are made from trusted inputs: the role comes from the validated token, never from JSON. [Rubric §3, Clean Architecture]: the dependency on "who is calling" points inward as a value, not outward as an infrastructure reference.
    • +
    • Walkthrough: three positional parameters, Id (:14), Request (:15), and CallerIsOrganizer (:16), with both marker interfaces implemented on the same declaration line (:16). CachePrefix (:19) is an expression-bodied property returning $"{typeof(Speaker).FullName}:", the key namespace the CachingCommandDecorator<TCommand, TResult> wipes after a successful handle. The positional Request parameter satisfies ICommandWithRequest<out TRequest> with no extra code, which is what makes the auto-registered CommandRequestValidator<TCommand, TRequest> able to reach SpeakerUpdateRequestValidator (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:192-209).
    • +
    • Why it's built this way: because CallerIsOrganizer is a positional record member, it is also part of the command's equality and its ToString, and it is visible to every decorator in the pipeline. That is a deliberate trade: the flag is not a secret, and having it on the command is what lets an operator see, in a logged command, which authority level performed an edit. Note the field is not validated: SpeakerUpdateRequestValidator validates the Request property only, so nothing in the validation pipeline can contradict the edge's decision.
    • +
    • Where it's used: constructed by SpeakersController after its own BR-214 gate (MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:335-341) and handled by UpdateSpeakerHandler.
    -

    ConferenceCategoryDTOMapper

    +

    UpdateSessionCommand

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.DTOs · MMCA.ADC.Conference.Application/Categories/DTOs/ConferenceCategoryDTOMapper.cs:13 · Level 8 · class

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.Update · MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionCommand.cs:10 · Level 9 · record (sealed)

      -
    • What it is: the Mapperly mapper from the Category aggregate to its ConferenceCategoryDTO, including the child CategoryItems collection.
    • -
    • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> (ConferenceCategoryDTOMapper.cs:3,15), the Mapperly generator (:4), and one injected child mapper, CategoryItemDTOMapper (:13-14).
    • -
    • Concept introduced: mapper composition with [UseMapper]. This is the parent half of the two-mapper pair. The class is sealed partial with a primary constructor that takes the CategoryItemDTOMapper and stores it in a [UseMapper]-tagged private field (:17-18); that attribute tells Mapperly to call the child mapper for the nested collection instead of generating a second, parallel copy of the item mapping. Composition rather than regeneration is what keeps one entity/DTO pair mapped in exactly one place, however many parents embed it. [Rubric §9, API & Contract Design]: the aggregate's wire shape is assembled from composable per-child mappers. [Rubric §16, Maintainability]: adding a field to CategoryItemDTO is a one-file change that both mappers pick up.
    • -
    • Walkthrough: the primary constructor (:13-14) receives categoryItemDTOMapper; the [UseMapper] private readonly field (:17-18) exposes it to the generator. MapToDTO (:21) is the generated single-entity conversion, routing child items through the reused mapper. MapToDTOs (:24) is the same hand-written projection as its child mapper: null-guard (:26), then [.. entityCollection.Select(MapToDTO)] (:27).
    • -
    • Why it's built this way: taking the child mapper through DI rather than newing it keeps both mappers ordinary injectable services, which is also what makes them unit-testable in isolation ([Rubric §14, Testability]).
    • -
    • Where it's used: the primary DTO mapper for Conference category reads, resolved with its child auto-injected and consumed by the category create and read paths.
    • +
    • What it is: the write intent for updating a Session: the target id plus the SessionUpdateRequest payload, opted into cache eviction.
    • +
    • Depends on: ICommandWithRequest<out TRequest> and ICacheInvalidating (UpdateSessionCommand.cs:3,10), the SessionUpdateRequest it wraps (:10), and the Session type used only for its FullName in the cache prefix (:1,13). The file also declares UpdateSessionResult (:19), which is why it imports SessionDTO (:2).
    • +
    • Concept introduced: none new; this is the id-plus-request command taught at UpdateConferenceCategoryCommand. The detail worth noting is what the two marker interfaces buy and what they do not. ICacheInvalidating opts the command into the caching decorator so a successful update evicts the session read cache, and ICommandWithRequest<out TRequest> is what routes SessionUpdateRequestValidator into the pipeline. Neither is ITransactional, so this command runs without an explicit ambient transaction; the handler's single SaveChangesAsync is its own unit of work, which is sufficient because the write touches one aggregate. Contrast the link and unlink commands in this module, which do declare ITransactional because they coordinate a write with an integration event. [Rubric §6, CQRS and Event-Driven] assesses whether writes are explicit intents flowing through a uniform pipeline: both cross-cutting behaviors attach declaratively through markers, with no wiring inside the handler (ADR-014).
    • +
    • Walkthrough: two positional parameters, Id and Request, with both interfaces on the declaration line (:10). CachePrefix (:13) returns $"{typeof(Session).FullName}:". The result type UpdateSessionResult shares the file (:19).
    • +
    • Why it's built this way: deriving the cache prefix from typeof(Session).FullName rather than a literal keeps the writer (this command) and the reader (the session query cache) agreed on one key namespace that a rename cannot desynchronize.
    • +
    • Caveats / not-in-source: the decorator's eviction is not the only cache clearing on this path. SessionsController also calls its own EvictSessionsCacheAsync after a successful update (MMCA.ADC.Conference.API/Controllers/SessionsController.cs:342). Why both layers evict is not determinable from these files.
    • +
    • Where it's used: constructed by SessionsController from the route id and body (MMCA.ADC.Conference.API/Controllers/SessionsController.cs:329-331) and handled by UpdateSessionHandler.
    -

    SpeakerUpdateRequestValidator

    +

    UpdateSponsorCommand

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.Update · MMCA.ADC.Conference.Application/Speakers/UseCases/Update/SpeakerUpdateRequestValidator.cs:7 · Level 8 · class

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.UseCases.Update · MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/UpdateSponsorCommand.cs:9 · Level 9 · record (sealed)

      -
    • What it is: the FluentValidation validator for SpeakerUpdateRequest: the two name rule sets, and nothing else.
    • -
    • Depends on: AbstractValidator<T> (FluentValidation, SpeakerUpdateRequestValidator.cs:1,7), SpeakerUpdateRequest, and the Speakers.Validation rule sets SpeakerFirstNameRules<T> and SpeakerLastNameRules<T> (:2,11-12).
    • -
    • Concept: nothing new; the same Include composition AddCategoryItemCommandValidator teaches, at the smallest useful size. The constructor (:9-13) folds in the first-name rules (:11) and the last-name rules (:12), the same pair the create-side speaker validator uses, so the two write paths cannot drift on what a speaker name must look like. The nine optional fields (email, bio, tagline, profile picture, the four social links, and IsTopSpeaker) carry no declared constraints at this layer. [Rubric §24, Forms/Validation/UX Safety]: the two mandatory fields are single-sourced across every path that writes a speaker name, including the Sessionize import mapping.
    • -
    • Walkthrough: sealed class SpeakerUpdateRequestValidator : AbstractValidator<SpeakerUpdateRequest> (:7) with a two-statement constructor (:9-13). RowVersion is not validated: a null token is a legitimate "skip the conflict check" signal, not an error.
    • -
    • Where it's used: discovered by assembly scanning and reached through UpdateSpeakerCommand's ICommandWithRequest<out TRequest> auto-registration, ahead of UpdateSpeakerHandler.
    • -
    • Caveats / not-in-source: the optional URL fields are not format-checked here, only the two names are constrained. Whether the Speaker aggregate's own Update bounds them is not determinable from this file.
    • +
    • What it is: the write intent for updating a Sponsor: the target id plus the SponsorUpdateRequest payload, opted into cache eviction.
    • +
    • Depends on: ICommandWithRequest<out TRequest> and ICacheInvalidating (UpdateSponsorCommand.cs:2,9), the SponsorUpdateRequest it wraps (:9), and the Sponsor type used only for its FullName in the cache prefix (:1,12).
    • +
    • Concept introduced: none; this is the same three-line shape as UpdateSessionCommand and UpdateConferenceCategoryCommand, and it is the plainest instance of it in the module. Reading the three side by side is the point of the repetition: sponsor is id-plus-request, session is id-plus-request with a richer result, and speaker is id-plus-request plus caller authority. The uniformity is what makes the pipeline generic, and the deviations are where the interesting rules live.
    • +
    • Walkthrough: the whole type is four lines. Two positional parameters, Id and Request, with both interfaces on the declaration line (:9), and CachePrefix (:12) returning $"{typeof(Sponsor).FullName}:". No other member.
    • +
    • Where it's used: constructed by SponsorsController on PUT {id} (MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:230-232), validated through SponsorUpdateRequestValidator by way of the auto-registered CommandRequestValidator<TCommand, TRequest>, and handled by UpdateSponsorHandler.
    -

    SponsorUpdateRequestValidator

    +

    UpdateSpeakerHandler

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.UseCases.Update · MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/SponsorUpdateRequestValidator.cs:7 · Level 8 · class

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.Update · MMCA.ADC.Conference.Application/Speakers/UseCases/Update/UpdateSpeakerHandler.cs:15 · Level 10 · class (sealed partial)

      -
    • What it is: the FluentValidation validator for SponsorUpdateRequest, assembled from eight reusable per-field rule sets. It is the widest Include composition among the update validators in this unit.
    • -
    • Depends on: AbstractValidator<T> (FluentValidation, SponsorUpdateRequestValidator.cs:1,7), SponsorUpdateRequest, and the Sponsors.Validation rule sets SponsorNameRules<T>, SponsorSortRules<T>, SponsorLogoUrlRules<T>, SponsorDescriptionRules<T>, SponsorWebsiteUrlRules<T>, SponsorLinkedInUrlRules<T>, SponsorTwitterHandleRules<T>, and SponsorBoothNumberRules<T> (:2,11-18).
    • -
    • Concept: nothing new beyond AddCategoryItemCommandValidator, but this is the clearest demonstration in the sponsor slice of what composition buys. Two of the included rule sets derive from framework bases rather than hand-chaining clauses: SponsorNameRules<T> extends RequiredStringRules<T> (MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:13-17) and the six optional-string sets extend OptionalStringRules<T> (SponsorValidationRules.cs:26-91), each passing a human-facing label and a bound read from SponsorInvariants rather than a literal. Compare the create-side validator, which includes the same eight plus SponsorEventIdRules<T> for the owning event (MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestValidator.cs:11-19): the one-rule difference between the two validators is exactly the one field the update contract drops. [Rubric §24, Forms/Validation/UX Safety] assesses whether input constraints are declared once and applied consistently: each sponsor field has a single definition bound twice. [Rubric §16, Maintainability]: a bound change is a one-line edit in SponsorInvariants that reaches both write paths and the EF column definition (MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SponsorConfiguration.cs:20,30,34,38,42,46,56).
    • -
    • Walkthrough: the constructor (:9-19) is eight Include calls in payload order: name (:11), sort (:12), logo URL (:13), description (:14), website URL (:15), LinkedIn URL (:16), Twitter handle (:17), and booth number (:18). Three request members are deliberately unvalidated: RowVersion (a null token means "skip the conflict check"), Tier (an enum, constrained by its type), and IsExhibitor (a bool with no field-level contract to state).
    • -
    • Why it's built this way: sponsors are a paid placement, so their text fields are the ones most likely to be pasted in from a contract document; bounding every one of them at the edge means an over-long blurb fails as a 400 with a named field rather than as a database truncation error deep in SaveChangesAsync.
    • -
    • Where it's used: discovered by assembly scanning and reached through UpdateSponsorCommand's ICommandWithRequest<out TRequest> auto-registration, ahead of UpdateSponsorHandler.
    • +
    • What it is: the command handler for speaker updates. It loads the aggregate, stamps the concurrency token, reconciles the one organizer-only field against the caller's authority, delegates to Speaker.Update, saves, and returns the mapped SpeakerDTO.
    • +
    • Depends on: ICommandHandler<in TCommand, TResult> (UpdateSpeakerHandler.cs:6,18), IUnitOfWork (:5,16), SpeakerDTOMapper (:2,17), Result and Error (:7), the Speaker aggregate (:3,25), and SpeakerDTO (:4,18).
    • +
    • Concept introduced: field-level authorization, and where it has to live. Line 40 is the whole idea in one expression: var isTopSpeaker = command.CallerIsOrganizer ? command.Request.IsTopSpeaker : entity.IsTopSpeaker;. A self-editing speaker's request may contain any value for IsTopSpeaker, and it is simply discarded in favor of the stored one. Three properties of that line are worth naming. It is fail-closed: the privileged branch requires the flag to be true, so an unset or unrecognized authority keeps the stored value rather than accepting the request. It is silent by design: a self-edit that tries to set the flag does not fail, it just has no effect on that field, which keeps a shared edit form working for both audiences without branching the API. And it cannot be moved outward: a validator sees only the request, so it cannot compare against the stored value, and the controller does not have the entity loaded. The handler is the first place where caller authority (from UpdateSpeakerCommand) and current state (from the repository) are both in hand. The comment above the line spells out the threat it closes: a crafted request body must not be able to feature a speaker (:34-39). The same comment records the companion rule, that LinkedUserId is absent from the request entirely so the governed /link and /unlink endpoints stay the only paths that change it (:36-39). [Rubric §11, Security] assesses whether authorization is enforced at the level the data requires, not only at the endpoint: role-gating the whole PUT would have forced a separate self-service endpoint, and per-field reconciliation is the alternative this codebase chose (ADR-033 records the resource-ownership axis that sits beside role and permission checks). [Rubric §1, SOLID]: the handler still has one reason to change, because the authority arrives as data rather than as a second dependency.
    • +
    • Walkthrough: the primary constructor takes IUnitOfWork, SpeakerDTOMapper, and ILogger<UpdateSpeakerHandler> (:15-18). HandleAsync (:21-63) resolves the repository (:25), loads the aggregate tracked (:26), and returns Error.NotFound tagged with the handler and target names when it is missing (:27-28). It then stamps the client's token with repository.SetOriginalRowVersion(entity, command.Request.RowVersion) (:32) so a concurrent edit surfaces as a DbUpdateConcurrencyException and a 409 rather than last-write-wins (ADR-035); note the ordering, the stamp happens before any mutation. The privilege reconciliation follows (:40), then entity.Update(...) receives ten request fields plus the reconciled isTopSpeaker (:42-53). A domain failure returns its errors unchanged (:55-56), preserving the aggregate's own error codes rather than re-wrapping them. SaveChangesAsync persists (:58), the generated LogSpeakerUpdated records the id (:60, template at :65-66), and the result is Result.Success(dtoMapper.MapToDTO(entity)) (:62).
    • +
    • Why it's built this way: Speaker.Update raises SpeakerChanged with DomainEntityState.Updated as its last act (MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:235), and the handler never touches an event bus. That division is the whole point of the ADR-083 taxonomy: the aggregate announces the transition, the DomainEventSaveChangesInterceptor captures it into the outbox inside the same save, and subscribers such as SpeakerDeletedHandler filter for the state they care about. Mapping to the DTO after the save (:62) rather than before means the returned payload carries the audit fields and the fresh row version that SaveChangesAsync stamped.
    • +
    • Where it's used: registered by the module's handler scan and injected into SpeakersController as ICommandHandler<UpdateSpeakerCommand, Result<SpeakerDTO>> (MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:47), invoked from the PUT {id} action (:340-342). It runs behind the decorator pipeline, so validation and cache eviction happen around it rather than inside it.
    -

    UpdateSpeakerCommand

    +

    UpdateSponsorHandler

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.Update · MMCA.ADC.Conference.Application/Speakers/UseCases/Update/UpdateSpeakerCommand.cs:13 · Level 8 · record

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.UseCases.Update · MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/UpdateSponsorHandler.cs:15 · Level 10 · class (sealed partial)

      -
    • What it is: the write intent for updating a Speaker. Unlike the other update commands in this unit it carries a third parameter, CallerIsOrganizer, alongside the id and the request.
    • -
    • Depends on: ICommandWithRequest<out TRequest> and ICacheInvalidating (both MMCA.Common.Application.UseCases, UpdateSpeakerCommand.cs:2,16), SpeakerUpdateRequest (:15), and the Speaker entity for the cache prefix (:1,19).
    • -
    • Concept introduced: carrying an authorization fact into the command, bound at the edge. BR-214 lets a speaker edit their own profile, but IsTopSpeaker is organizer curation and must not be self-assignable. Rather than injecting an identity service into the handler (which would make the handler depend on HTTP identity and become awkward to unit test), the API resolves the role once and passes the answer in. The doc comment on the parameter (:9-12) states the rule precisely: it is "bound at the API edge, never from the request body", and when it is false the handler "ignores the organizer-only request field IsTopSpeaker and keeps the entity's current value". SpeakersController computes it from the role claim and refuses the request outright unless the caller is the organizer or the speaker themselves (MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:334-338). [Rubric §11, Security] assesses whether authorization decisions are made from server-side identity rather than client input: the flag is a named command parameter whose only writer is the controller, so the trust boundary is visible in the type. [Rubric §14, Testability]: because the fact is data on the command, both the organizer and self-edit branches are unit-testable with no HTTP context.
    • -
    • Walkthrough: the record spans four lines (:13-16): Id (:14), Request (:15), and bool CallerIsOrganizer (:16), with both marker interfaces on the closing line. CachePrefix (:19) returns $"{typeof(Speaker).FullName}:". Automatic request validation comes from ICommandWithRequest<out TRequest>: the framework registers a request-delegating command validator, so SpeakerUpdateRequestValidator is picked up through the Request property with no UpdateSpeakerCommandValidator file to keep in sync. CallerIsOrganizer is not validated because it is server-set.
    • -
    • Why it's built this way: the alternative, filtering IsTopSpeaker in the controller before handing the request to the handler, would put a domain rule in the presentation layer and leave it unenforced for any other caller of the command. Passing the fact and deciding in the handler keeps the rule with the behavior.
    • -
    • Where it's used: constructed by SpeakersController with the named argument CallerIsOrganizer: isOrganizer (SpeakersController.cs:341) and handled by UpdateSpeakerHandler.
    • +
    • What it is: the command handler for sponsor updates, and the reference shape for "an update handler with nothing unusual in it": load, stamp, delegate, save, map.
    • +
    • Depends on: ICommandHandler<in TCommand, TResult> (UpdateSponsorHandler.cs:6,18), IUnitOfWork (:5,16), SponsorDTOMapper (:2,17), the Sponsor aggregate (:3,25), SponsorDTO (:4,18), and Result with Error (:7).
    • +
    • Concept introduced: none new. Read this one to fix the canonical five-step update shape in mind, because the other two handlers in this unit are this shape plus something: UpdateSpeakerHandler adds a privilege reconciliation, UpdateSessionHandler adds two cross-aggregate checks and an advisory flag. The five steps are: resolve the repository from the unit of work (:25), load and null-check (:26-28), stamp the concurrency token (:32), delegate the whole state transition to one aggregate method (:34-44), and save then map (:49-53). Everything the pipeline can do generically (validate, evict cache, log the request, wrap in a transaction) is absent, because the decorators do it. [Rubric §5, Vertical Slice] assesses whether a feature's code sits together and stays thin: this file is the entire write side of "edit a sponsor", and it is 58 lines. [Rubric §3, Clean Architecture]: the handler names no persistence technology, only IUnitOfWork.
    • +
    • Walkthrough: the primary constructor takes IUnitOfWork, SponsorDTOMapper, and ILogger<UpdateSponsorHandler> (:15-18). HandleAsync (:21-54) resolves GetRepository<Sponsor, SponsorIdentifierType>() (:25), loads by id (:26), and returns Error.NotFound with source and target set when absent (:27-28). SetOriginalRowVersion stamps the client's token (:32, rationale in the comment at :30-31). entity.Update(...) passes all ten editable fields in one call (:34-44); the aggregate validates name, logo URL, and booth number through SponsorInvariants before assigning anything (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:165-170) and raises SponsorChanged with Updated at the end (:183). A domain failure returns its errors unchanged (:46-47). Then SaveChangesAsync (:49), LogSponsorUpdated (:51, template at :56-57), and Result.Success(dtoMapper.MapToDTO(entity)) (:53).
    • +
    • Why it's built this way: passing every field on every update, rather than diffing the request against the entity, is what makes the aggregate's Update a single validated transition. Sponsor.Update runs Result.Combine over its three invariant checks and returns before assigning a single property if any fails (Sponsor.cs:165-170), so a rejected update leaves the entity exactly as loaded. A field-by-field patch could not offer that guarantee without a rollback.
    • +
    • Where it's used: injected into SponsorsController as ICommandHandler<UpdateSponsorCommand, Result<SponsorDTO>> (MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:40) and invoked from the PUT {id} action, which is gated by the SponsorsManage permission (:224,230-232).
    -

    UpdateSponsorCommand

    +

    UpdateSessionHandler

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.UseCases.Update · MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/UpdateSponsorCommand.cs:9 · Level 9 · record

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.Update · MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionHandler.cs:17 · Level 11 · class (sealed partial)

      -
    • What it is: the write intent for updating a Sponsor: the target id plus the SponsorUpdateRequest payload, and nothing else.
    • -
    • Depends on: ICommandWithRequest<out TRequest> and ICacheInvalidating (UpdateSponsorCommand.cs:2,9), SponsorUpdateRequest (:9), and the Sponsor entity, referenced only for the cache prefix (:1,12).
    • -
    • Concept: nothing new; this is the plain id-plus-request shape, and it is worth reading directly against UpdateSpeakerCommand to see what the extra parameter there is buying. Sponsors have no self-service editor: the endpoint is permission-gated as a whole ([HasPermission(ConferencePermissions.SponsorsManage)], MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:224), so there is no per-field privilege to carry into the handler and the command stays two parameters wide. [Rubric §6, CQRS & Event-Driven]: intent in, typed result out, with cache eviction attached declaratively by marker rather than called by the handler. [Rubric §11, Security]: the authorization decision is made once at the edge because the whole use case is organizer-only, which is why the command needs no authorization payload.
    • -
    • Walkthrough: the whole type is one declaration line plus one member: sealed record UpdateSponsorCommand(SponsorIdentifierType Id, SponsorUpdateRequest Request) implementing both markers (:9), and CachePrefix returning $"{typeof(Sponsor).FullName}:" (:12). The doc comment (:6) summarizes it as "Command to update an existing sponsor. Invalidates the sponsor cache on success."
    • -
    • Why it's built this way: the update path needs a wrapper because the id comes from the route while the body carries the payload, and ICommandWithRequest<out TRequest> makes that wrapper cheap: the positional Request parameter satisfies the interface, and SponsorUpdateRequestValidator is reused with no command-level validator to write.
    • -
    • Where it's used: constructed by SponsorsController on PUT (SponsorsController.cs:231) and handled by UpdateSponsorHandler.
    • +
    • What it is: the most guarded write path in this unit. It validates an immutable field, loads a second aggregate to validate the room assignment, delegates the state transition, computes an advisory warning, saves, and returns an UpdateSessionResult rather than a bare DTO.
    • +
    • Depends on: ICommandHandler<in TCommand, TResult> (UpdateSessionHandler.cs:7,20), IUnitOfWork (:6,18), SessionDTOMapper (:2,19), SessionRoomScheduling (:3,57), the Session and Event aggregates (:4,5,27,47), and Result with Error (:8).
    • +
    • Concept introduced: the handler as the only place a cross-aggregate rule can live. Session and Event are separate aggregate roots, so neither may reach into the other to answer "does this room belong to my event" or "is my new time inside the conference dates". A session cannot load an event, and an event cannot validate a session it does not own. The handler is the first layer that can hold both, so it does: it loads the parent event with its rooms (:47-52) and then runs two different kinds of check against it.
        +
      • A hard rule (BR-130), delegated to shared code. SessionRoomScheduling.ValidateRoomAssignmentAsync (:57-65) takes the loaded event, the requested room and window, and excludeSessionId: command.Id (:63). It rejects a room that does not belong to the event with the stable code Session.RoomId.CrossEvent (MMCA.ADC.Conference.Application/Sessions/Validation/SessionRoomScheduling.cs:62-67) and then probes for an overlapping booking. The excludeSessionId argument is what makes this reusable between create and update: without it, a session re-saved with its own room and slot would collide with itself. The predicate implements the exclusion with int.MinValue as a sentinel so the expression keeps one shape for both callers (SessionRoomScheduling.cs:99-107), and the interval comparison s.StartsAt < endsAt && s.EndsAt > startsAt (:106) is why back-to-back sessions do not conflict.
      • +
      • A soft rule (BR-86), computed inline. IsOutsideEventDateRange (:104-113) compares DateOnly.FromDateTime(startsAt) against the event's StartDate and the end against EndDate, and its result is carried out in UpdateSessionResult instead of failing the request. + Holding both in one method is what makes the distinction legible: the same handler knows which violation blocks a write and which merely annotates it. [Rubric §4, Domain-Driven Design] assesses whether aggregate boundaries are respected: the cross-aggregate rule sits in the layer above rather than being smuggled into an entity. [Rubric §5, Vertical Slice]: the room rule is factored into a shared static so create and update cannot drift apart, and the drift risk is real (they are two files that must reject the same things).
      -

      UpdateSpeakerHandler

      +
    • +
    • Walkthrough: the primary constructor takes IUnitOfWork, SessionDTOMapper, and ILogger<UpdateSessionHandler> (:17-20), and the handler's result type is Result<UpdateSessionResult> (:20). HandleAsync (:23-98) runs in order: resolve the session repository (:27) and load tracked (:28), returning Error.NotFound if absent (:29-30); stamp the concurrency token (:34, comment at :32-33); enforce BR-140 by comparing command.Request.EventId with entity.EventId and returning Error.UnprocessableEntity with the code Session.EventId.Immutable on a mismatch (:37-44); load the parent event with includes: [nameof(Event.Rooms)] and asTracking: false (:47-52), returning Error.NotFound if it is gone (:53-54); run the room validation and propagate its errors verbatim (:57-67); call entity.Update(...) with the fourteen editable fields (:69-83) and propagate a domain failure unchanged (:85-86); compute hasDateRangeWarning (:89-91); SaveChangesAsync (:93); log (:95, template at :100-101); and return Result.Success(new UpdateSessionResult(dtoMapper.MapToDTO(entity), hasDateRangeWarning)) (:97).
    • +
    • Why it's built this way: the parent event is fetched with asTracking: false (:51) because it is read for validation only and must not be written; an untracked read also keeps the change tracker from carrying an aggregate the save has no business touching. The explicit includes: [nameof(Event.Rooms)] (:50) is what lets ValidateRoomAssignmentAsync scan parentEvent.Rooms in memory (SessionRoomScheduling.cs:59) instead of issuing another query, and using nameof rather than a string literal keeps the include from silently going stale on a rename. The BR-140 check lives in the handler rather than in Session.Update because the aggregate method is never passed an event id at all (MMCA.ADC.Conference.Domain/Sessions/Session.cs:229-243): the field is not among the things a session can change, so the guard belongs where the request and the stored entity are both visible.
    • +
    • Caveats / not-in-source: the double-booking half of BR-130 is a soft guard and the code says so at length. The existence probe and the update that follows are separate statements, so two concurrent organizer writes can both observe a free window and both commit; the type-level doc explains that SQL Server has no range-exclusion constraint able to express an interval predicate, and accepts the gap because the endpoint is organizer-only and the outcome is repairable (MMCA.ADC.Conference.Application/Sessions/Validation/SessionRoomScheduling.cs:16-25). Treat a "no conflict" result as advisory under concurrency, not as a guarantee.
    • +
    • Where it's used: injected into SessionsController as ICommandHandler<UpdateSessionCommand, Result<UpdateSessionResult>> (MMCA.ADC.Conference.API/Controllers/SessionsController.cs:45) and invoked from the PUT {id} action (:329-331), which is covered by the controller-level SessionsManage permission (:41) and which converts the warning flag into an X-Warning header before returning the DTO (:336-343).
    • +
    +

    AddCategoryItemCommand

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.Update · MMCA.ADC.Conference.Application/Speakers/UseCases/Update/UpdateSpeakerHandler.cs:15 · Level 10 · class

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.AddCategoryItem · MMCA.ADC.Conference.Application/Categories/UseCases/AddCategoryItem/AddCategoryItemCommand.cs:14 · Level 7 · record (sealed)

      -
    • What it is: the handler for UpdateSpeakerCommand. It loads the Speaker, stamps the concurrency token, filters the organizer-only field, delegates to the aggregate's Update, saves, and returns the DTO.
    • -
    • Depends on: ICommandHandler<in TCommand, TResult> (UpdateSpeakerHandler.cs:6,18), IUnitOfWork (:5,16), SpeakerDTOMapper (:2,17), Result and Error (:7), the Speaker aggregate (:3), the SpeakerDTO contract (:4), and ILogger<T> (:1,18).
    • -
    • Concept introduced: field-level authorization inside the handler. The single most instructive line is var isTopSpeaker = command.CallerIsOrganizer ? command.Request.IsTopSpeaker : entity.IsTopSpeaker; (:40). A non-organizer's submitted value is discarded and the stored value is passed through unchanged, so a BR-214 self-edit cannot feature its own speaker no matter what the body says. The comment block above it (:34-39) spells out both halves of the design: IsTopSpeaker is organizer curation, so "a crafted request body cannot feature a speaker", and LinkedUserId "is not part of this request at all" because the governed /link and /unlink endpoints carry the BR-208 uniqueness check and raise the events that keep Identity's User.LinkedSpeakerId in sync. Two different techniques for two different risks: remove the field from the contract when no caller should ever set it, filter it in the handler when some callers may. [Rubric §11, Security] assesses whether privileged fields are protected server-side: neither protection can be bypassed by a client. [Rubric §8, Data Architecture]: the row-version stamp at :32 is how a lost update is detected instead of silently accepted. [Rubric §1, SOLID]: the handler makes the decision, the controller only supplies the fact.
    • -
    • Walkthrough: HandleAsync (:21-23) gets the typed repository (:25), loads by id (:26), and returns Error.NotFound tagged with source and target when absent (:27-28). It stamps the client's last-seen token with repository.SetOriginalRowVersion(entity, command.Request.RowVersion) (:32), the comment above it recording that a concurrent edit then surfaces as a DbUpdateConcurrencyException mapped to 409 rather than last-write-wins (:30-31). It computes isTopSpeaker (:40), then calls entity.Update(...) with the ten request fields and the filtered flag as the eleventh argument (:42-53), propagating the aggregate's errors on failure (:55-56). On success it saves with ConfigureAwait(false) (:58), logs (:60), and returns Result.Success(dtoMapper.MapToDTO(entity)) (:62). The [LoggerMessage] (:65-66) declares "Speaker {SpeakerId} updated".
    • -
    • Why it's built this way: passing every field positionally to entity.Update keeps the aggregate the only place that can mutate speaker state, so invariants are checked in one place and the handler stays a pure orchestrator. The handler opens no transaction and evicts no cache: the decorators do, driven by UpdateSpeakerCommand's markers.
    • -
    • Where it's used: injected into SpeakersController as ICommandHandler<UpdateSpeakerCommand, Result<SpeakerDTO>> (MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:47) and invoked on PUT after the BR-214 organizer-or-self check (SpeakersController.cs:334-342).
    • +
    • What it is: the write intent for adding one CategoryItem (a selectable option such as "Beginner" inside the "Level" category) to an existing Category. It carries the owning category, an optional explicit item id, and the two fields the child actually holds.
    • +
    • Depends on: ICacheInvalidating from MMCA.Common.Application.UseCases (AddCategoryItemCommand.cs:2,18), the Category domain type used only to build the cache prefix (:1,21), and the module identifier aliases ConferenceCategoryIdentifierType and CategoryItemIdentifierType (both = int, MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:6-7; see the primer on identifier aliases).
    • +
    • Concept introduced, the optional-identity add command. Every other member of the category family takes its identifiers as plain non-nullable values. This one declares CategoryItemIdentifierType? CategoryItemId (AddCategoryItemCommand.cs:16), and the question mark is the whole point: it is the wire-level way to say "I do not have an id for this child, let the store assign one". The nullability lines up exactly with the domain factory it eventually reaches, CategoryItem.Create(CategoryItemIdentifierType? id, ...) (MMCA.ADC.Conference.Domain/Categories/CategoryItem.cs:47-48), which resolves the argument as id ?? (isIdValueGenerated ? default : throw ...) (CategoryItem.cs:61). Because CategoryItem carries [IdValueGenerated] (CategoryItem.cs:13), a null id becomes 0 and EF assigns the real key at save. The reason the parameter exists at all is the Sessionize import, which calls the same domain method with upstream-assigned ids (MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/CategorySyncStrategy.cs:83,111). Contrast this with ConferenceCategoryCreateRequest, whose Id is not nullable (ConferenceCategoryCreateRequest.cs:16): the same design decision was made two different ways in the same folder tree. [Rubric §9, API & Contract Design] assesses whether an inbound contract is explicit about optionality: here the nullable id is the contract's own documentation that identity is caller-optional. [Rubric §10, Cross-Cutting]: cache eviction is declared rather than coded, because the caching decorator reads CachePrefix off the command instead of the handler evicting by hand.
    • +
    • Walkthrough: a sealed record with four positional parameters, CategoryId, CategoryItemId, Name, Sort (AddCategoryItemCommand.cs:14-18), implementing ICacheInvalidating (:18). The single member in the body is CachePrefix => $"{typeof(Category).FullName}:" (:21), keyed on the aggregate root rather than the child because a cached category read carries its items inline (ConferenceCategoryDTO.CategoryItems, MMCA.ADC.Conference.Shared/Categories/ConferenceCategoryDTO.cs:27). Name and Sort are non-nullable (:17-18), so the command cannot express "leave the name alone", which is correct for an add.
    • +
    • Why it's built this way: the command names exactly the four values the aggregate's AddCategoryItem method needs (MMCA.ADC.Conference.Domain/Categories/Category.cs:131-134) and nothing else, so the HTTP shape, the validator target, and the domain call signature stay in one-to-one correspondence.
    • +
    • Where it's used: constructed by the category-items controller from an AddCategoryItemRequest body (MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:134-138, handler injected at :64), validated by AddCategoryItemCommandValidator, and handled by AddCategoryItemHandler. Its edit and delete counterparts are UpdateCategoryItemCommand and RemoveCategoryItemCommand.
    -

    UpdateSponsorHandler

    +

    CategoryItemDTOMapper

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.UseCases.Update · MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/UpdateSponsorHandler.cs:15 · Level 10 · class

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.DTOs · MMCA.ADC.Conference.Application/Categories/DTOs/CategoryItemDTOMapper.cs:12 · Level 7 · class (sealed partial)

      -
    • What it is: the handler for UpdateSponsorCommand: load the Sponsor, stamp the concurrency token, delegate all ten editable fields to the aggregate's Update, save, log, and return the DTO.
    • -
    • Depends on: ICommandHandler<in TCommand, TResult> (UpdateSponsorHandler.cs:6,18), IUnitOfWork (:5,16), SponsorDTOMapper (:2,17), Result and Error (:7), the Sponsor aggregate (:3), the SponsorDTO contract (:4), and ILogger<T> (:1,18).
    • -
    • Concept: this is the canonical update handler with nothing added, so read it as the baseline that UpdateSpeakerHandler decorates with one filtered field. Five moves and two failure exits: load, stamp, delegate, save, map. What is worth noticing is the pair of caches involved on this path. The command's ICacheInvalidating marker makes the pipeline decorator wipe the application query cache, and separately SponsorsController evicts the HTTP output cache by tag after a successful update (MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:237, SponsorsController.cs:253-257, evicting conference:sponsors and conference). Two caches sit in front of a sponsor read, and a write has to clear both. [Rubric §10, Cross-Cutting] assesses whether such concerns are applied uniformly: one of the two evictions is declarative and one is an explicit call, so the pairing is a thing to remember rather than something the type system enforces. [Rubric §8, Data Architecture]: the row-version stamp (:32) is the conflict-detection mechanism, identical to the speaker path.
    • -
    • Walkthrough: HandleAsync (:21-23) resolves unitOfWork.GetRepository<Sponsor, SponsorIdentifierType>() (:25), loads by id (:26), and returns Error.NotFound sourced to the handler and targeted at Sponsor when absent (:27-28). It stamps the client's row version (:32), with the same 409-not-last-write-wins comment as the speaker handler (:30-31). It then calls entity.Update(...) with all ten editable fields in payload order, Name, Tier, LogoUrl, Description, WebsiteUrl, LinkedInUrl, TwitterHandle, Sort, IsExhibitor, BoothNumber (:34-44), and propagates the aggregate's errors verbatim on failure (:46-47). On success it saves with ConfigureAwait(false) (:49), logs LogSponsorUpdated (:51, :56-57), and returns Result.Success(dtoMapper.MapToDTO(entity)) (:53).
    • -
    • Why it's built this way: the aggregate, not the handler, decides what a valid sponsor is: Sponsor.Update combines three invariant checks (name, logo URL, booth number) before assigning any field and raises SponsorChanged with DomainEntityState.Updated at the end (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:153-186, checks at :165-168, event at :183). Note that the owning event is absent from the parameter list on purpose, matching the request contract and the doc comment at Sponsor.cs:140: a sponsor cannot be moved between events by an update.
    • -
    • Where it's used: injected into SponsorsController as ICommandHandler<UpdateSponsorCommand, Result<SponsorDTO>> (MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:40) and invoked on PUT (SponsorsController.cs:230-232).
    • -
    • Caveats / not-in-source: only three of the ten fields are re-checked by the aggregate. The length bounds on the description and the three link fields are enforced by SponsorUpdateRequestValidator at the edge and by the EF column definitions (MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SponsorConfiguration.cs:34,38,42,46), so a caller that reached Sponsor.Update without passing through the validator would meet the constraint at the database rather than as a domain failure.
    • +
    • What it is: the read-side mapper that turns a CategoryItem domain entity into a CategoryItemDTO. The single-entity method has no body in this file: Mapperly generates it at compile time.
    • +
    • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> from MMCA.Common.Application.Interfaces (CategoryItemDTOMapper.cs:3,13), Riok.Mapperly.Abstractions (NuGet, :4,11), the CategoryItem entity (:1), the CategoryItemDTO contract from the Shared project (:2), and the CategoryItemIdentifierType alias (= int, MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:6).
    • +
    • Concept introduced, source-generated DTO mapping. [Mapper] on a partial class (CategoryItemDTOMapper.cs:11-12) tells the Mapperly generator to fill in the body of every partial method it finds, here MapToDTO (:16). The generated body is straight-line property assignment: no reflection, no expression trees, no runtime configuration, and a compile error rather than a silent null when a target member has no matching source member. That is the policy ADR-001 settled on: mapping is either hand-written or generated, never reflective. The four target members are Id, Name, Sort, and CategoryId (MMCA.ADC.Conference.Shared/Categories/CategoryItemDTO.cs:39-48); the last of these reads the entity's foreign key, which the domain exposes as a getter-only property with no setter at all (MMCA.ADC.Conference.Domain/Categories/CategoryItem.cs:27), so the wire contract can surface the parent link without the domain ever handing out a way to reassign it. [Rubric §12, Performance & Scalability] assesses whether hot paths avoid avoidable runtime work: every category read maps a full item list, so a generated assignment beats a reflective copy exactly where volume lands. [Rubric §9, API & Contract Design]: the DTO, not the entity, is what crosses the wire, so a domain refactor cannot silently reshape the JSON. [Rubric §14, Testability]: the mapper is a pure function of its input and is tested directly (CategoryItemDTOMapperTests, Group 27).
    • +
    • Walkthrough: two members. public partial CategoryItemDTO MapToDTO(CategoryItem entity) (CategoryItemDTOMapper.cs:16) is the declaration whose implementation the generator supplies. MapToDTOs(IReadOnlyCollection<CategoryItem>) (:19-23) is hand-written and deliberately so: it null-guards with ArgumentNullException.ThrowIfNull (:21) and then projects with a collection expression over a spread, [.. entityCollection.Select(MapToDTO)] (:22), which materializes a single array without an intermediate List<T> growth cycle. The collection method delegating to the generated single-item method is the shape every mapper in this module repeats.
    • +
    • Why it's built this way: generating the property copy keeps the mapping honest (add a DTO member with no source and the build breaks) while the hand-written collection method keeps the allocation profile under the author's control rather than the generator's.
    • +
    • Where it's used: injected by concrete type into AddCategoryItemHandler (MMCA.ADC.Conference.Application/Categories/UseCases/AddCategoryItem/AddCategoryItemHandler.cs:17), consumed as a child mapper by ConferenceCategoryDTOMapper through [UseMapper] (ConferenceCategoryDTOMapper.cs:17-18), and resolved as IEntityDTOMapper<CategoryItem, CategoryItemDTO, CategoryItemIdentifierType> by the generic query service registered for the entity (MMCA.ADC.Conference.Application/DependencyInjection.cs:93, constructor parameter at MMCA.Common.Application/Services/EntityQueryService.cs:35). Registration is by the convention scan, not an explicit AddScoped line (MMCA.ADC.Conference.Application/DependencyInjection.cs:125).

    ConferenceCategoryCreateRequest

    @@ -2582,11 +2851,11 @@

    ConferenceCategoryCreateRequest

    • What it is: the inbound contract for creating a conference Category, the aggregate behind vocabularies such as "Level", "Track", and "Session Format". As with the other create slices in this module it is both the HTTP request body and the command the CQRS pipeline dispatches: there is no separate CreateConferenceCategoryCommand.
    • -
    • Depends on: ICreateRequest and ICacheInvalidating from MMCA.Common.Application.UseCases / .Interfaces (ConferenceCategoryCreateRequest.cs:2-3,10), the Category domain type used only to build the cache prefix (ConferenceCategoryCreateRequest.cs:1,13), and the ConferenceCategoryIdentifierType alias (= int, MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:6).
    • -
    • Concept: the create-request-as-command shape introduced by EventCreateRequest, applied to the smallest aggregate in the module. Implementing the marker ICreateRequest (ConferenceCategoryCreateRequest.cs:10) is what lets the generic create machinery accept this record straight off the wire, hand it to an IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, and dispatch it as ICommandHandler<ConferenceCategoryCreateRequest, Result<ConferenceCategoryDTO>>. Note that it is declared public record class rather than sealed record (ConferenceCategoryCreateRequest.cs:10), the only shape difference from its command siblings in this unit. [Rubric §9, API & Contract Design] assesses whether an inbound contract is explicit about shape and optionality: exactly one member is required (Title, :19), and the other three are optional by declaration, which is the endpoint's optionality documentation. [Rubric §10, Cross-Cutting]: cache eviction is declared, not coded, because the caching decorator reads CachePrefix off the request.
    • -
    • Walkthrough: CachePrefix => $"{typeof(Category).FullName}:" (ConferenceCategoryCreateRequest.cs:13) is the eviction key the caching decorator purges on success, keyed on the aggregate root so every cached category read is invalidated together. Id (:16) is a non-nullable ConferenceCategoryIdentifierType, which matters downstream: Category.Create declares its id parameter as ConferenceCategoryIdentifierType? (MMCA.ADC.Conference.Domain/Categories/Category.cs:55) precisely so a caller can say "no id", but this request can never express that, so an omitted id binds to 0 and the factory's null branch (Category.cs:69) is unreachable from the HTTP path. The reason the factory accepts an id at all is the Sessionize import, which carries category ids assigned upstream (Category.cs:49). Title is required (:19), Sort is a plain int display order (:22), and Type is the optional discriminator whose documented examples are "session" and "speaker" (:25). Every member is init-only, so the request cannot be mutated after model binding.
    • +
    • Depends on: ICreateRequest and ICacheInvalidating from MMCA.Common.Application.Interfaces / .UseCases (ConferenceCategoryCreateRequest.cs:2-3,10), the Category domain type used only to build the cache prefix (:1,13), and the ConferenceCategoryIdentifierType alias (= int, MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7).
    • +
    • Concept: the create-request-as-command shape introduced by EventCreateRequest, applied to the smallest aggregate in the module. Implementing the marker ICreateRequest (ConferenceCategoryCreateRequest.cs:10) is what lets the generic create machinery accept this record straight off the wire, hand it to an IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, and dispatch it as ICommandHandler<ConferenceCategoryCreateRequest, Result<ConferenceCategoryDTO>>. Note that it is declared public record class rather than sealed record (:10), the only shape difference from its command siblings in this unit. [Rubric §9, API & Contract Design] assesses whether an inbound contract is explicit about shape and optionality: exactly one member is required (Title, :19), and the other three are optional by declaration, which is the endpoint's optionality documentation. [Rubric §10, Cross-Cutting]: cache eviction is declared, not coded, because the caching decorator reads CachePrefix off the request.
    • +
    • Walkthrough: CachePrefix => $"{typeof(Category).FullName}:" (ConferenceCategoryCreateRequest.cs:13) is the eviction key the caching decorator purges on success, keyed on the aggregate root so every cached category read is invalidated together. Id (:16) is a non-nullable ConferenceCategoryIdentifierType, which matters downstream: Category.Create declares its id parameter as ConferenceCategoryIdentifierType? (MMCA.ADC.Conference.Domain/Categories/Category.cs:54-55) precisely so a caller can say "no id", but this request can never express that, so an omitted id binds to 0 and the factory's throw branch (Category.cs:69) is unreachable from the HTTP path (unreachable for this aggregate in any case, since Category carries [IdValueGenerated], Category.cs:15). The reason the factory accepts an id at all is the Sessionize import, which carries category ids assigned upstream (MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/CategorySyncStrategy.cs:99). Title is required (:19), Sort is a plain int display order (:22), and Type is the optional discriminator whose documented examples are "session" and "speaker" (:25). Every member is init-only, so the request cannot be mutated after model binding.
    • Why it's built this way: collapsing request and command removes a mapping step with no behavior of its own, and keeping the id nullable on the domain factory while non-nullable on the wire contract lets one aggregate serve both the API (store-generated keys) and the importer (Sessionize-assigned keys).
    • -
    • Where it's used: bound by the categories controller (MMCA.ADC.Conference.API/Controllers/ConferenceCategoriesController.cs:94), which types its base class on it (ConferenceCategoriesController.cs:39) and injects the handler as ICommandHandler<ConferenceCategoryCreateRequest, Result<ConferenceCategoryDTO>> (:34); validated by ConferenceCategoryCreateRequestValidator, converted by ConferenceCategoryCreateRequestMapper, handled by CreateConferenceCategoryHandler. Its edit-side counterpart is ConferenceCategoryUpdateRequest.
    • +
    • Where it's used: bound by the categories controller (MMCA.ADC.Conference.API/Controllers/ConferenceCategoriesController.cs:94), which types its base class on it (ConferenceCategoriesController.cs:39-40) and injects the handler as ICommandHandler<ConferenceCategoryCreateRequest, Result<ConferenceCategoryDTO>> (:34); validated by ConferenceCategoryCreateRequestValidator, converted by ConferenceCategoryCreateRequestMapper, handled by CreateConferenceCategoryHandler. Its edit-side counterpart is ConferenceCategoryUpdateRequest.

    QuestionTextRules<T>

    @@ -2596,55 +2865,58 @@

    QuestionTextRules<T>

  • What it is: the one reusable FluentValidation rule set for the text of a conference Question. It says a question text must be present and no longer than the domain's limit, and it is written once for every request type that carries such a field.
  • Depends on: FluentValidation.AbstractValidator<T> (NuGet, QuestionValidationRules.cs:2,13), System.Linq.Expressions.Expression<TDelegate> (BCL, :1,15), and QuestionInvariants for the length constant (:3,18).
  • Concept introduced, the generic rule object with a property selector. A FluentValidation validator is generic over the type it validates, so a rule written for one request type cannot normally be reused by another. This codebase solves that by making the rule itself generic in T and taking an Expression<Func<T, string>> in its constructor (QuestionValidationRules.cs:12,15). new QuestionTextRules<QuestionCreateRequest>(p => p.QuestionText) then means "apply the question-text rules to this type's QuestionText property", and a consuming validator pulls the rules in with Include(...), which copies every rule from another AbstractValidator<T> over the same T. The payoff is that create and update cannot drift apart on what a valid question text is: both include this same object. [Rubric §16, Maintainability] assesses whether a change has one edit point: raising the limit is a single edit to QuestionInvariants.QuestionTextMaxLength, and both the message and the constraint follow. [Rubric §24, Forms, Validation & UX Safety] assesses whether invalid input is rejected at the boundary with actionable messages: each rule carries both human text and a stable machine code.
  • -
  • Walkthrough: the whole type is an expression-bodied constructor (QuestionValidationRules.cs:15-18). RuleFor(selector) opens the chain, .NotEmpty() attaches the message "You must enter a Question Text" with error code Question.QuestionText.Required (:17), and .MaximumLength(QuestionInvariants.QuestionTextMaxLength) attaches "Question Text cannot be longer than 1000 characters" with code Question.QuestionText.MaxLength (:18). The constant is 1000 (MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:13), the same value the EF configuration and the domain invariant read, so the API error and the column width cannot disagree. Two details are worth noticing. First, the error codes are the stable contract: the message wording can change without breaking a client that branches on Question.QuestionText.MaxLength. Second, this class writes its interpolated message with a plain $"..." (:18) where the category rules use string.Create(CultureInfo.InvariantCulture, $"...") (MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:19), so the number is formatted with the ambient culture here and invariantly there.
  • -
  • Why it's built this way: pulling length limits from the aggregate's invariants class rather than restating them in the validator is what keeps three layers (validation, domain guard, column constraint) on one number. Each aggregate owns its own invariants class, which is why this rule reads QuestionInvariants and not a shared constants bag.
  • -
  • Where it's used: included by QuestionCreateRequestValidator (MMCA.ADC.Conference.Application/Questions/UseCases/Create/QuestionCreateRequestValidator.cs:10) and QuestionUpdateRequestValidator (.../Update/QuestionUpdateRequestValidator.cs:10).
  • +
  • Walkthrough: the whole type is an expression-bodied constructor (QuestionValidationRules.cs:15-18). RuleFor(selector) opens the chain, .NotEmpty() attaches the message "You must enter a Question Text" with error code Question.QuestionText.Required (:17), and .MaximumLength(QuestionInvariants.QuestionTextMaxLength) attaches "Question Text cannot be longer than 1000 characters" with code Question.QuestionText.MaxLength (:18). The limit is 1000, declared as a public const int (MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:13), the same value the EF configuration and the domain invariant read (QuestionInvariants.cs:59), so the API error and the column width cannot disagree. Two details are worth noticing. First, the error codes are the stable contract: the message wording can change without breaking a client that branches on Question.QuestionText.MaxLength. Second, this class writes its interpolated message with a plain $"..." (:18) where the category rules use string.Create(CultureInfo.InvariantCulture, $"...") (MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:19), so the number is formatted with the ambient culture here and invariantly there.
  • +
  • Why it's built this way: pulling length limits from the aggregate's invariants class rather than restating them in the validator is what keeps three layers (validation, domain guard, column constraint) on one number. Each aggregate owns its own invariants class, which is why this rule reads QuestionInvariants and not a shared constants bag. Note the small inconsistency between the two invariants classes: question limits are const (QuestionInvariants.cs:13) while category limits are public static readonly int (MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:14,17), so the former are baked into each calling assembly at compile time and the latter are read at runtime.
  • +
  • Where it's used: included by QuestionCreateRequestValidator (MMCA.ADC.Conference.Application/Questions/UseCases/Create/QuestionCreateRequestValidator.cs:10) and QuestionUpdateRequestValidator (MMCA.ADC.Conference.Application/Questions/UseCases/Update/QuestionUpdateRequestValidator.cs:10).
  • RemoveCategoryItemCommand

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.RemoveCategoryItem · MMCA.ADC.Conference.Application/Categories/UseCases/RemoveCategoryItem/RemoveCategoryItemCommand.cs:12 · Level 7 · record (sealed)

      -
    • What it is: the write intent for removing one CategoryItem (a selectable option such as "Beginner" inside the "Level" category) from its owning Category. It names the category and the item, and nothing else.
    • -
    • Depends on: ICacheInvalidating (RemoveCategoryItemCommand.cs:2,14), the Category type for the cache prefix (:1,17), and the ConferenceCategoryIdentifierType / CategoryItemIdentifierType aliases (both = int, MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:5-6).
    • -
    • Concept: the remove-child command shape, the mirror of AddCategoryItemCommand and deliberately narrower than it. An add carries the child's payload; a remove carries only the two identifiers, because the child already exists and the only decision left is which one. The pair (CategoryId, CategoryItemId) is what makes the operation aggregate-scoped: the handler loads the category and asks it to remove the item, so no caller can delete an item by id alone and bypass the aggregate's rules. The cache prefix is keyed on Category, not on the child, because a cached category read carries its items inline. [Rubric §4, Domain-Driven Design] assesses whether children are mutated through their root: the command's shape makes any other access path impossible to express.
    • -
    • Walkthrough: a sealed record with two positional parameters, CategoryId and CategoryItemId (RemoveCategoryItemCommand.cs:12-14), plus the single computed CachePrefix => $"{typeof(Category).FullName}:" (:17). There is no validator class in the folder, because two required identifiers have nothing to check beyond model binding, and there is no RowVersion: unlike the event publish transition, a category-item removal carries no optimistic-concurrency token. Note what CategoryId is allowed to be: the DELETE endpoint takes it as an optional query argument (MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:173), so it legitimately arrives as 0, and RemoveCategoryItemHandler is the piece that copes with that.
    • -
    • Why it's built this way: keeping the payload to two identifiers means the command is fully described by the route plus one query argument, and it leaves the aggregate as the only place that knows what removing an item means (a soft delete plus a CategoryItemChanged domain event, MMCA.ADC.Conference.Domain/Categories/Category.cs:199,203).
    • -
    • Where it's used: constructed by the category-items controller as new RemoveCategoryItemCommand(categoryId, id) (MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:177, handler injected at :65) and handled by RemoveCategoryItemHandler. Its add and update counterparts are AddCategoryItemCommand and UpdateCategoryItemCommand.
    • +
    • What it is: the write intent for removing one CategoryItem from its owning Category. It names the category and the item, and nothing else.
    • +
    • Depends on: ICacheInvalidating (RemoveCategoryItemCommand.cs:2,14), the Category type for the cache prefix (:1,17), and the ConferenceCategoryIdentifierType / CategoryItemIdentifierType aliases (both = int, MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:6-7).
    • +
    • Concept: the remove-child command shape, the mirror of AddCategoryItemCommand and deliberately narrower than it. An add carries the child's payload; a remove carries only the two identifiers, because the child already exists and the only decision left is which one. Note that both identifiers here are non-nullable (RemoveCategoryItemCommand.cs:13-14), where the add's child id is optional: an add may invent identity, a remove may only reference it. The pair (CategoryId, CategoryItemId) is what makes the operation aggregate-scoped: the handler loads the category and asks it to remove the item, so no caller can delete an item by id alone and bypass the aggregate's rules. The cache prefix is keyed on Category, not on the child, because a cached category read carries its items inline. [Rubric §4, Domain-Driven Design] assesses whether children are mutated through their root: the command's shape makes any other access path impossible to express.
    • +
    • Walkthrough: a sealed record with two positional parameters, CategoryId and CategoryItemId (RemoveCategoryItemCommand.cs:12-14), plus the single computed CachePrefix => $"{typeof(Category).FullName}:" (:17). There is no validator class in the folder, because two required identifiers have nothing to check beyond model binding, and there is no RowVersion: unlike the event publish transition, a category-item removal carries no optimistic-concurrency token. Note what CategoryId is allowed to be: the DELETE endpoint takes it as a query argument with no [Required] and no default (MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:181), so it legitimately arrives as 0, and RemoveCategoryItemHandler is the piece that copes with that.
    • +
    • Why it's built this way: keeping the payload to two identifiers means the command is fully described by the route plus one query argument, and it leaves the aggregate as the only place that knows what removing an item means (a soft delete on the child plus a CategoryItemChanged domain event, MMCA.ADC.Conference.Domain/Categories/Category.cs:199,203).
    • +
    • Where it's used: constructed by the category-items controller as new RemoveCategoryItemCommand(categoryId, id) (MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:185, handler injected at :66) and handled by RemoveCategoryItemHandler. Its add and update counterparts are AddCategoryItemCommand and UpdateCategoryItemCommand.
    -

    SpeakerFirstNameRules<T>

    +

    UpdateCategoryItemCommand

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.Validation · MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:11 · Level 7 · class (sealed, generic)

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.UpdateCategoryItem · MMCA.ADC.Conference.Application/Categories/UseCases/UpdateCategoryItem/UpdateCategoryItemCommand.cs:14 · Level 7 · record (sealed)

      -
    • What it is: the reusable rule set for a Speaker's first name. It contributes no rule bodies of its own: it is a three-line subclass that binds the framework's generic "required string" rules to one field name and one length constant.
    • -
    • Depends on: RequiredStringRules<T> from MMCA.Common.Application.Validation (SpeakerValidationRules.cs:3,12), SpeakerInvariants (:2,15), and Expression<TDelegate> (BCL, :1,14).
    • -
    • Concept introduced, rule reuse by inheritance rather than by composition. QuestionTextRules<T> hand-writes its NotEmpty plus MaximumLength chain; this class instead inherits the identical chain from the framework and passes three arguments to it: the selector, the display name, and the limit (SpeakerValidationRules.cs:14-15). The base builds both messages from the display name, "You must enter a First Name" and "First Name cannot be longer than 200 characters" (MMCA.Common.Application/Validation/CommonValidationRules.cs:16-18). The trade is visible in the output: the framework base attaches no WithErrorCode, so a speaker name violation surfaces with FluentValidation's default codes while a question text violation surfaces with the explicit Question.QuestionText.* codes. Choose the base when the field is an ordinary required string, hand-write when the field needs a stable machine code. [Rubric §1, SOLID] assesses whether a type has one reason to change: this one changes only if the speaker's first name changes its name or its length. [Rubric §15, Best Practices & Code Quality]: the shared base is in MMCA.Common, so the same shape is available to every module in every app rather than copy-pasted per aggregate.
    • -
    • Walkthrough: sealed class SpeakerFirstNameRules<T> : RequiredStringRules<T> (SpeakerValidationRules.cs:11-12) with a single constructor forwarding base(selector, "First Name", SpeakerInvariants.FirstNameMaxLength) (:14-15). The constant is 200 (MMCA.ADC.Conference.Domain/Speakers/SpeakerInvariants.cs:13).
    • -
    • Where it's used: included by SpeakerCreateRequestValidator (MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequestValidator.cs:11) and SpeakerUpdateRequestValidator (.../Update/SpeakerUpdateRequestValidator.cs:11).
    • +
    • What it is: the write intent for editing one CategoryItem inside its owning Category: the two identifiers that locate it plus the two fields that may change.
    • +
    • Depends on: ICacheInvalidating (UpdateCategoryItemCommand.cs:2,18), the Category type for the cache prefix (:1,21), and the ConferenceCategoryIdentifierType / CategoryItemIdentifierType aliases.
    • +
    • Concept: the update-child shape, which sits between the add and remove shapes. It carries the aggregate id and the child id like a remove, plus exactly the fields that are editable and no others: Name is a non-nullable string and Sort a non-nullable int (UpdateCategoryItemCommand.cs:17-18), so this command cannot be used to blank a name by omission. Because both editable fields are here rather than spread across a partial-update document, the record is the complete answer to "what can this endpoint change", and it is also the exact target the validator binds to. [Rubric §9, API & Contract Design] assesses whether an edit contract states precisely what is mutable: the four positional parameters are that statement. [Rubric §6, CQRS & Event-Driven]: one command type, one handler, one write path, with the resulting CategoryItemChanged domain event raised inside the aggregate (MMCA.ADC.Conference.Domain/Categories/Category.cs:182).
    • +
    • Walkthrough: four positional parameters, CategoryId, CategoryItemId, Name, Sort (UpdateCategoryItemCommand.cs:14-18), with CachePrefix => $"{typeof(Category).FullName}:" (:21). Unlike RemoveCategoryItemCommand this command is always fully populated: the controller reads the item id from the route and the owning category id from a required member of the request body (MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:44,161-165), so the handler never has to hunt for the owner.
    • +
    • Why it's built this way: a narrow, explicitly-typed update command keeps the write surface auditable and gives the caching decorator a well-defined eviction boundary; a general "patch the item entity" contract would have neither property, and it could not be validated by a single AbstractValidator<T>.
    • +
    • Where it's used: constructed by the category-items controller (MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:161, handler injected at :65), validated by UpdateCategoryItemCommandValidator, and handled by UpdateCategoryItemHandler.
    -

    SpeakerLastNameRules<T>

    +

    AddCategoryItemCommandValidator

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.Validation · MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:22 · Level 7 · class (sealed, generic)

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.AddCategoryItem · MMCA.ADC.Conference.Application/Categories/UseCases/AddCategoryItem/AddCategoryItemCommandValidator.cs:7 · Level 8 · class (sealed)

      -
    • What it is: the last-name twin of SpeakerFirstNameRules<T>, declared in the same file.
    • -
    • Depends on: the same three: RequiredStringRules<T> (SpeakerValidationRules.cs:3,23), SpeakerInvariants (:2,26), and Expression<TDelegate> (:1,25).
    • -
    • Concept: nothing new; the inherit-the-shared-rules pattern taught by SpeakerFirstNameRules<T>. The two declarations differ only in the display name passed to the base, "Last Name" instead of "First Name", and in the constant, SpeakerInvariants.LastNameMaxLength (SpeakerValidationRules.cs:26), which is also 200 (MMCA.ADC.Conference.Domain/Speakers/SpeakerInvariants.cs:16). They stay two types rather than one parameterized rule because each is included by name against a specific property, which is what makes the call site at the validator read as documentation.
    • -
    • Walkthrough: sealed class SpeakerLastNameRules<T> : RequiredStringRules<T> (SpeakerValidationRules.cs:22-23) with the forwarding constructor at :25-26.
    • -
    • Where it's used: included by SpeakerCreateRequestValidator (MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequestValidator.cs:12) and SpeakerUpdateRequestValidator (.../Update/SpeakerUpdateRequestValidator.cs:12).
    • +
    • What it is: the FluentValidation validator the pipeline runs against an AddCategoryItemCommand before AddCategoryItemHandler sees it. It holds no rule bodies of its own: it is two Include calls.
    • +
    • Depends on: FluentValidation.AbstractValidator<T> (NuGet, AddCategoryItemCommandValidator.cs:1,7) and the two shared rule objects CategoryItemNameRules<T> and CategoryItemSortRules<T> from MMCA.ADC.Conference.Application.Categories.Validation (:2,11-12).
    • +
    • Concept: rule composition by Include with a property selector, the mechanism taught by QuestionTextRules<T>, applied to a command rather than a wire request. It is byte-for-byte the same body as UpdateCategoryItemCommandValidator with a different generic argument (AddCategoryItemCommandValidator.cs:11-12), which is the point: the add path and the edit path cannot disagree about what a valid item name or sort order is, because both include the same two rule objects. [Rubric §16, Maintainability] assesses whether a change has one edit point: changing the name limit is one edit in CategoryInvariants, and both validators follow. [Rubric §24, Forms, Validation & UX Safety] assesses boundary rejection with actionable messages: both included rules carry stable machine codes alongside their human text.
    • +
    • Walkthrough: a block-bodied constructor with two statements (AddCategoryItemCommandValidator.cs:9-13): Include(new CategoryItemNameRules<AddCategoryItemCommand>(p => p.Name)) (:11) and Include(new CategoryItemSortRules<AddCategoryItemCommand>(p => p.Sort)) (:12). The name rule enforces NotEmpty with code CategoryItem.Name.Required and MaximumLength(CategoryInvariants.CategoryItemNameMaxLength) with code CategoryItem.Name.MaxLength, the limit being 500 (MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:30-33; MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:17). The sort rule enforces GreaterThanOrEqualTo(0) with code CategoryItem.Sort.Negative (ConferenceCategoryValidationRules.cs:43-45). Two things are deliberately not checked here: the optional CategoryItemId (nothing to validate about an absent id) and name uniqueness within the category, which needs the sibling collection and therefore lives in the aggregate (BR-138, MMCA.ADC.Conference.Domain/Categories/Category.cs:136-140).
    • +
    • Why it's built this way: field-shape rules that need only the incoming values run at the boundary where they can be reported as one complete, field-addressed list; rules that need loaded state run in the domain. That split is why this validator stays a dependency-free composition that can be constructed in a unit test with new.
    • +
    • Where it's used: resolved by the validation decorator around AddCategoryItemHandler, registered by the convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:125); covered directly by AddCategoryItemCommandValidatorTests (Group 27), which lives alongside its update-side twin in MMCA.ADC.Conference.Application.Tests/Categories/Validation/CategoryCommandValidatorTests.cs:8.
    -

    UpdateCategoryItemCommand

    +

    AddCategoryItemHandler

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.UpdateCategoryItem · MMCA.ADC.Conference.Application/Categories/UseCases/UpdateCategoryItem/UpdateCategoryItemCommand.cs:14 · Level 7 · record (sealed)

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.AddCategoryItem · MMCA.ADC.Conference.Application/Categories/UseCases/AddCategoryItem/AddCategoryItemHandler.cs:15 · Level 8 · class (sealed partial)

      -
    • What it is: the write intent for editing one CategoryItem inside its owning Category: the two identifiers that locate it plus the two fields that may change.
    • -
    • Depends on: ICacheInvalidating (UpdateCategoryItemCommand.cs:2,18), the Category type for the cache prefix (:1,21), and the ConferenceCategoryIdentifierType / CategoryItemIdentifierType aliases.
    • -
    • Concept: the update-child shape, which sits between the add and remove shapes. It carries the aggregate id and the child id like a remove, plus exactly the fields that are editable and no others: Name is a non-nullable string and Sort a non-nullable int (UpdateCategoryItemCommand.cs:17-18), so this command cannot be used to blank a name by omission. Because both editable fields are here rather than spread across a partial-update document, the record is the complete answer to "what can this endpoint change", and it is also the exact target the validator binds to. [Rubric §9, API & Contract Design] assesses whether an edit contract states precisely what is mutable: the four positional parameters are that statement. [Rubric §6, CQRS & Event-Driven]: one command type, one handler, one write path, with the resulting CategoryItemChanged domain event raised inside the aggregate (MMCA.ADC.Conference.Domain/Categories/Category.cs:182).
    • -
    • Walkthrough: four positional parameters, CategoryId, CategoryItemId, Name, Sort (UpdateCategoryItemCommand.cs:14-18), with CachePrefix => $"{typeof(Category).FullName}:" (:21). Unlike RemoveCategoryItemCommand this command is always fully populated: the controller reads the item id from the route and the owning category id from the request body (MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:153-157), so the handler never has to hunt for the owner.
    • -
    • Why it's built this way: a narrow, explicitly-typed update command keeps the write surface auditable and gives the caching decorator a well-defined eviction boundary; a general "patch the item entity" contract would have neither property, and it could not be validated by a single AbstractValidator<T>.
    • -
    • Where it's used: constructed by the category-items controller (MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:153, handler injected at :64), validated by UpdateCategoryItemCommandValidator, and handled by UpdateCategoryItemHandler.
    • +
    • What it is: the handler for AddCategoryItemCommand. It loads the owning Category, delegates the creation of the child to the aggregate, saves, and returns the new child as a CategoryItemDTO.
    • +
    • Depends on: IUnitOfWork (AddCategoryItemHandler.cs:5,16), CategoryItemDTOMapper by concrete type (:2,17), ILogger<T> with a source-generated [LoggerMessage] (:1,18,41-42), ICommandHandler<in TCommand, TResult> (:6,18), the Category aggregate and its CategoryItem child (:3), CategoryItemDTO from the Shared project (:4), and Result / Error (:7).
    • +
    • Concept introduced, the child-mutation handler that returns the new child. The other two category-item handlers return a bare Result; this one returns Result<CategoryItemDTO> (AddCategoryItemHandler.cs:18,21) because a create has something the caller does not yet have: the store-assigned id. That single difference drives everything else in the class. It is why a DTO mapper is injected at all (:17), why the aggregate's AddCategoryItem returns Result<CategoryItem> rather than Result (MMCA.ADC.Conference.Domain/Categories/Category.cs:131), and why the controller can answer 201 Created with a route to the new row (MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:147-150). Note the ordering discipline: MapToDTO runs after SaveChangesAsync (:34,38), so the DTO carries the real key rather than the 0 that existed a moment earlier. [Rubric §4, Domain-Driven Design] assesses whether children are created through their root: the handler never calls CategoryItem.Create itself, it calls category.AddCategoryItem(...) (:30) and lets the aggregate run the BR-138 uniqueness rule first (Category.cs:136-140). [Rubric §6, CQRS & Event-Driven]: one command, one handler, one write path, with CategoryItemChanged queued inside the aggregate (Category.cs:150) and drained by the same save. [Rubric §13, Observability & Operability]: the source-generated log message records the item name and owning category as structured fields at zero allocation when the level is disabled (:36,41-42).
    • +
    • Walkthrough: primary-constructor injection of the three collaborators (AddCategoryItemHandler.cs:15-18), declaring ICommandHandler<AddCategoryItemCommand, Result<CategoryItemDTO>>. HandleAsync (:21-39) resolves the typed repository through the unit of work (:25), then loads the aggregate with the two-argument overload GetByIdAsync(command.CategoryId, cancellationToken) (:26). That overload is worth pausing on: it takes no includes and no asTracking flag, and its implementation queries the tracked Table on purpose so that generic load-mutate-save handlers are not silent no-ops (MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:176-181). A missing category becomes Result.Failure<CategoryItemDTO>(Error.NotFound.WithSource(nameof(AddCategoryItemHandler)).WithTarget(nameof(Category))) (:27-28). The domain call at :30 returns Result<CategoryItem>, whose errors are re-wrapped into the handler's own generic shape on failure (:31-32), which is how a duplicate-name conflict from CategoryInvariants.EnsureCategoryItemNameIsUnique (MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:37-57) reaches the caller as a value rather than an exception. On success it awaits the single unitOfWork.SaveChangesAsync(cancellationToken) with .ConfigureAwait(false) (:34), logs (:36), and returns Result.Success(dtoMapper.MapToDTO(result.Value!)) (:38).
    • +
    • Why it's built this way: the save-only-after-the-aggregate-agrees shape is the module's canonical command body, so a rejected add writes nothing at all, and the one SaveChangesAsync stays the single boundary that stamps audit fields, captures domain events, and writes the outbox row (ADR-003).
    • +
    • Where it's used: resolved by the category-items controller as ICommandHandler<AddCategoryItemCommand, Result<CategoryItemDTO>> (MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:64) and invoked from its hand-written, [Idempotent]-decorated create action (:127-139), after which the controller evicts the named output-cache policy explicitly (:146). Covered by AddCategoryItemHandlerTests (Group 27).
    • +
    • Caveats / not-in-source: the include-less load at :26 brings back the aggregate without materializing Category.CategoryItems, while the BR-138 uniqueness check inside the aggregate scans exactly that collection (Category.cs:137-138). Whether a duplicate name is caught therefore depends on which items EF has already tracked in the current scope, unlike UpdateCategoryItemHandler and RemoveCategoryItemHandler, which both pass includes: [nameof(Category.CategoryItems)] explicitly. Not determinable from source: whether the database also enforces the name uniqueness with an index, which would make the domain check a second line of defense rather than the only one.

    ConferenceCategoryCreateRequestMapper

    @@ -2653,10 +2925,10 @@

    ConferenceCategoryCreateRequestMa
    • What it is: the one adapter that turns a ConferenceCategoryCreateRequest into a Category domain entity, by calling the aggregate's Create factory and returning whatever Result<T> it produces.
    • Depends on: IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> from MMCA.Common.Application.Interfaces (ConferenceCategoryCreateRequestMapper.cs:2,11-12), the Category aggregate and its ConferenceCategoryIdentifierType alias (:1), and Result<T> from MMCA.Common.Shared.Abstractions (:3,15).
    • -
    • Concept: request-to-entity mapping as a separate injectable role, the same contract EventCreateRequestMapper implements for events. The generic create pipeline never constructs entities itself: it resolves an IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> and asks it for one, which is what lets one handler shape serve every aggregate while each aggregate keeps its own construction rules. Two properties matter. First, it is a plain sealed class with no [Mapper] attribute (ConferenceCategoryCreateRequestMapper.cs:11): unlike the read-side DTO mappers below, request-to-entity conversion is deliberately hand-written, because it must go through a factory that can fail, which a property-copy generator cannot express. Second, it returns Task<Result<Category>> rather than a Category, so an invalid request produces a failure value that flows back as a 400-class response instead of an exception. [Rubric §3, Clean Architecture] assesses whether the domain stays independent of the delivery mechanism: the controller knows a request type, the domain knows a factory, and this class is the only thing that knows both. [Rubric §4, Domain-Driven Design]: the factory stays the single construction path, so no invariant can be bypassed by new. See ADR-001 for the no-reflection mapping policy.
    • -
    • Walkthrough: one method. CreateEntityAsync(ConferenceCategoryCreateRequest request, CancellationToken) (ConferenceCategoryCreateRequestMapper.cs:15) null-guards with ArgumentNullException.ThrowIfNull(request) (:17), then returns Task.FromResult(Category.Create(request.Id, request.Title, request.Sort, request.Type)) (:19-23). The method is synchronous in substance: Task.FromResult satisfies the async contract without a state machine, because the factory does no I/O. Inside the factory (MMCA.ADC.Conference.Domain/Categories/Category.cs:54-75) the title is checked by CategoryInvariants.EnsureTitleIsValid through Result.Combine (:60-61), the id is resolved against the store-generated-key check (:65,69), and a CategoryChanged domain event with DomainEntityState.Added is queued on the new aggregate (:72) for the outbox to pick up at save time. Because the request's Id is non-nullable (ConferenceCategoryCreateRequest.cs:16), what actually reaches the factory is 0 when the client omits it.
    • -
    • Why it's built this way: pushing construction into Category.Create means the invariant check and the domain event run for every caller, not just HTTP ones. The mapper adds no rules of its own, which is exactly what makes it safe to have several entry points into the same aggregate.
    • -
    • Where it's used: injected into CreateConferenceCategoryHandler as IEntityRequestMapper<Category, ConferenceCategoryCreateRequest, ConferenceCategoryIdentifierType> (CreateConferenceCategoryHandler.cs:18), registered by the module's convention scan rather than an explicit AddScoped line (MMCA.ADC.Conference.Application/DependencyInjection.cs:112).
    • +
    • Concept: request-to-entity mapping as a separate injectable role. The generic create pipeline never constructs entities itself: it resolves an IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> and asks it for one, which is what lets one handler shape serve every aggregate while each aggregate keeps its own construction rules. Two properties matter. First, it is a plain sealed class with no [Mapper] attribute (ConferenceCategoryCreateRequestMapper.cs:11): unlike the read-side DTO mappers in this unit, request-to-entity conversion is deliberately hand-written, because it must go through a factory that can fail, which a property-copy generator cannot express. Second, it returns Task<Result<Category>> rather than a Category, so an invalid request produces a failure value that flows back as a 400-class response instead of an exception. [Rubric §3, Clean Architecture] assesses whether the domain stays independent of the delivery mechanism: the controller knows a request type, the domain knows a factory, and this class is the only thing that knows both. [Rubric §4, Domain-Driven Design]: the factory stays the single construction path, so no invariant can be bypassed by new. See ADR-001 for the no-reflection mapping policy.
    • +
    • Walkthrough: one method. CreateEntityAsync(ConferenceCategoryCreateRequest request, CancellationToken) (ConferenceCategoryCreateRequestMapper.cs:15) null-guards with ArgumentNullException.ThrowIfNull(request) (:17), then returns Task.FromResult(Category.Create(request.Id, request.Title, request.Sort, request.Type)) (:19-23). The method is synchronous in substance: Task.FromResult satisfies the async contract without a state machine, because the factory does no I/O. Inside the factory (MMCA.ADC.Conference.Domain/Categories/Category.cs:54-75) the title is checked by CategoryInvariants.EnsureTitleIsValid through Result.Combine (:60-61), the id is resolved against the [IdValueGenerated] check (:65,69), and a CategoryChanged domain event with DomainEntityState.Added is queued on the new aggregate (:72) for the outbox to pick up at save time. Because the request's Id is non-nullable (ConferenceCategoryCreateRequest.cs:16), what actually reaches the factory is 0 when the client omits it.
    • +
    • Why it's built this way: pushing construction into Category.Create means the invariant check and the domain event run for every caller, not just HTTP ones (the Sessionize importer calls the same factory at MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/CategorySyncStrategy.cs:99). The mapper adds no rules of its own, which is exactly what makes it safe to have several entry points into the same aggregate.
    • +
    • Where it's used: injected into CreateConferenceCategoryHandler as IEntityRequestMapper<Category, ConferenceCategoryCreateRequest, ConferenceCategoryIdentifierType> (CreateConferenceCategoryHandler.cs:18), registered by the module's convention scan rather than an explicit AddScoped line (MMCA.ADC.Conference.Application/DependencyInjection.cs:125).

    ConferenceCategoryCreateRequestValidator

    @@ -2665,32 +2937,22 @@

    ConferenceCategoryCreateReques
    • What it is: the FluentValidation validator the pipeline runs against a ConferenceCategoryCreateRequest before CreateConferenceCategoryHandler sees it. It contains no rules of its own: it is a single Include call.
    • Depends on: FluentValidation.AbstractValidator<T> (NuGet, ConferenceCategoryCreateRequestValidator.cs:1,7) and ConferenceCategoryTitleRules<T> from MMCA.ADC.Conference.Application.Categories.Validation (:2,10).
    • -
    • Concept: rule composition by Include with a property selector, the mechanism taught by QuestionTextRules<T>. Include(new ConferenceCategoryTitleRules<ConferenceCategoryCreateRequest>(p => p.Title)) (ConferenceCategoryCreateRequestValidator.cs:10) copies the shared title rules onto this request's Title property, and ConferenceCategoryUpdateRequestValidator includes the same rule object against the update request, so create and update cannot disagree about what a valid title is. Equally instructive is what is not validated: Sort and Type have no rules at all here, even though a sibling rule object for a sort value exists (CategoryItemSortRules<T>, applied only to category items by UpdateCategoryItemCommandValidator). A negative Sort on a category is therefore accepted. [Rubric §24, Forms, Validation & UX Safety] assesses whether invalid input is rejected at the boundary: the title path is covered, the sort path is not. [Rubric §16, Maintainability]: one shared rule object is one edit point instead of one per slice.
    • +
    • Concept: rule composition by Include with a property selector, the mechanism taught by QuestionTextRules<T>. Include(new ConferenceCategoryTitleRules<ConferenceCategoryCreateRequest>(p => p.Title)) (ConferenceCategoryCreateRequestValidator.cs:10) copies the shared title rules onto this request's Title property, and the update-side validator includes the same rule object against ConferenceCategoryUpdateRequest, so create and update cannot disagree about what a valid title is. Equally instructive is what is not validated: Sort and Type have no rules at all here, even though a sibling rule object for a sort value exists (CategoryItemSortRules<T>, applied only to category items by AddCategoryItemCommandValidator and UpdateCategoryItemCommandValidator). A negative Sort on a category is therefore accepted. [Rubric §24, Forms, Validation & UX Safety] assesses whether invalid input is rejected at the boundary: the title path is covered, the sort path is not. [Rubric §16, Maintainability]: one shared rule object is one edit point instead of one per slice.
    • Walkthrough: the whole type is an expression-bodied constructor (ConferenceCategoryCreateRequestValidator.cs:9-10). The rule bodies it pulls in live at MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:16-19: NotEmpty with code Category.Title.Required, then MaximumLength(CategoryInvariants.TitleMaxLength) with code Category.Title.MaxLength, where the limit is 255 (MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:14). Note the domain declares that limit as public static readonly int rather than const, so it is read at runtime rather than baked into each caller.
    • -
    • Why it's built this way: validating at the pipeline boundary gives the caller a complete, field-addressed error list in one round trip, while Category.Create keeps its own EnsureTitleIsValid check (MMCA.ADC.Conference.Domain/Categories/Category.cs:61) as the backstop for non-HTTP callers such as the Sessionize import. The duplication is intentional and cheap because both sides read the same constant.
    • -
    • Where it's used: resolved by the validation decorator around CreateConferenceCategoryHandler, registered by the convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:112); covered directly by ConferenceCategoryCreateRequestValidatorTests (Group 27).
    • -
    -

    EventQuestionAnswerDTOMapper

    -
    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.DTOs · MMCA.ADC.Conference.Application/Events/DTOs/EventQuestionAnswerDTOMapper.cs:12 · Level 8 · class (sealed partial)

    -
    -
      -
    • What it is: the read-side mapper that turns an EventQuestionAnswer domain entity into an EventQuestionAnswerDTO. The single-entity method has no body in this file: Mapperly generates it at compile time.
    • -
    • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> from MMCA.Common.Application.Interfaces (EventQuestionAnswerDTOMapper.cs:3,13), Riok.Mapperly.Abstractions (NuGet, :4,11), the EventQuestionAnswer entity (:1), the EventQuestionAnswerDTO contract from the Shared project (:2), and the EventQuestionAnswerIdentifierType alias (= int).
    • -
    • Concept introduced (for this unit), source-generated DTO mapping. [Mapper] on a partial class (EventQuestionAnswerDTOMapper.cs:11-12) tells the Mapperly generator to fill in the body of every partial method it finds, here MapToDTO (:16). The generated body is straight-line property assignment: no reflection, no expression trees, no runtime configuration, and a compile error rather than a silent null if a target member has no source. That is the whole point of ADR-001: mapping is either hand-written or generated, never reflective. [Rubric §12, Performance & Scalability] assesses whether hot paths avoid avoidable runtime work: every read endpoint maps its result set, so a generated assignment beats a reflective copy at the exact place volume lands. [Rubric §9, API & Contract Design]: the DTO, not the entity, is what crosses the wire, so a domain refactor cannot silently reshape the JSON. [Rubric §14, Testability]: the mapper is a pure function of its input and is tested directly (EventQuestionAnswerDTOMapperTests, Group 27).
    • -
    • Walkthrough: two members. public partial EventQuestionAnswerDTO MapToDTO(EventQuestionAnswer entity) (EventQuestionAnswerDTOMapper.cs:16) is the declaration whose implementation the generator supplies. MapToDTOs(IReadOnlyCollection<EventQuestionAnswer>) (:19-23) is hand-written and deliberately so: it null-guards with ArgumentNullException.ThrowIfNull (:21) and then projects with a collection expression over a spread, [.. entityCollection.Select(MapToDTO)] (:22), which materializes a single array without an intermediate List<T> growth cycle. The collection method delegating to the generated single-item method is the shape every mapper in this family repeats.
    • -
    • Where it's used: injected concretely into AddEventQuestionAnswerHandler (MMCA.ADC.Conference.Application/Events/UseCases/AddEventQuestionAnswer/AddEventQuestionAnswerHandler.cs:21), consumed as a child mapper by EventDTOMapper through [UseMapper] (EventDTOMapper.cs:26-27), and resolved as IEntityDTOMapper<EventQuestionAnswer, EventQuestionAnswerDTO, EventQuestionAnswerIdentifierType> by the generic query service registered for the entity (MMCA.ADC.Conference.Application/DependencyInjection.cs:90, constructor parameter at MMCA.Common.Application/Services/EntityQueryService.cs:35). Registration is by the convention scan (DependencyInjection.cs:112).
    • +
    • Why it's built this way: validating at the pipeline boundary gives the caller a complete, field-addressed error list in one round trip, while Category.Create keeps its own EnsureTitleIsValid check (MMCA.ADC.Conference.Domain/Categories/Category.cs:60-61) as the backstop for non-HTTP callers such as the Sessionize import. The duplication is intentional and cheap because both sides read the same constant.
    • +
    • Where it's used: resolved by the validation decorator around CreateConferenceCategoryHandler, registered by the convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:125); covered directly by ConferenceCategoryCreateRequestValidatorTests (Group 27).
    -

    EventSpeakerDTOMapper

    +

    ConferenceCategoryDTOMapper

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.DTOs · MMCA.ADC.Conference.Application/Events/DTOs/EventSpeakerDTOMapper.cs:12 · Level 8 · class (sealed partial)

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.DTOs · MMCA.ADC.Conference.Application/Categories/DTOs/ConferenceCategoryDTOMapper.cs:13 · Level 8 · class (sealed partial)

      -
    • What it is: the same generated mapper for the EventSpeaker association entity to EventSpeakerDTO.
    • -
    • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> (EventSpeakerDTOMapper.cs:3,13), Mapperly (:4,11), the entity and DTO (:1-2), and the EventSpeakerIdentifierType alias.
    • -
    • Concept: nothing new; the [Mapper]-plus-partial shape taught by EventQuestionAnswerDTOMapper. Reading the two side by side is the fastest way to see how little varies: the entity, the DTO, and the identifier alias in the interface arguments, and nothing else.
    • -
    • Walkthrough: public partial EventSpeakerDTO MapToDTO(EventSpeaker entity) (EventSpeakerDTOMapper.cs:16) generated by Mapperly, and the hand-written MapToDTOs with its null guard and spread projection (:19-23).
    • -
    • Where it's used: injected concretely into AddEventSpeakerHandler (MMCA.ADC.Conference.Application/Events/UseCases/AddEventSpeaker/AddEventSpeakerHandler.cs:17), used as a child mapper by EventDTOMapper (EventDTOMapper.cs:23-24), and resolved by the query service registered at MMCA.ADC.Conference.Application/DependencyInjection.cs:87. Tested by EventSpeakerDTOMapperTests (Group 27).
    • +
    • What it is: the read-side mapper for the Category aggregate. It maps the root and delegates its one child collection to CategoryItemDTOMapper, so a category and its options are produced by one call.
    • +
    • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> (ConferenceCategoryDTOMapper.cs:3,15), Riok.Mapperly.Abstractions (NuGet, :4,12,17), CategoryItemDTOMapper (:14,18), the Category aggregate (:1), ConferenceCategoryDTO from the Shared project (:2), and the ConferenceCategoryIdentifierType alias.
    • +
    • Concept introduced, composing generated mappers with [UseMapper]. The generator faced with IReadOnlyCollection<CategoryItem> on the source and IReadOnlyCollection<CategoryItemDTO> on the target could generate a second, private copy of the item mapping. [UseMapper] on a field (ConferenceCategoryDTOMapper.cs:17-18) tells it not to: "when you need to map a CategoryItem, call this instance". That is how an aggregate DTO gets its child collection filled without duplicating child-mapping logic, and it is why a category item rendered inside a category is byte-identical to one rendered from the items endpoint. The dependency arrives through a primary constructor (:13-14) and is stored in a readonly field, so the DI container composes the two mappers and neither knows about the container. [Rubric §2, Design Patterns] assesses whether composition is preferred to duplication: this is the composite mapper with an injected leaf. [Rubric §9, API & Contract Design]: the target contract exposes RowVersion (MMCA.ADC.Conference.Shared/Categories/ConferenceCategoryDTO.cs:15, via IConcurrencyAware at :9), so the optimistic-concurrency token travels to the client and back on edit. [Rubric §12, Performance & Scalability]: root and children are both straight-line generated assignment, no reflection anywhere on the read path.
    • +
    • Walkthrough: [Mapper] on the sealed partial class (ConferenceCategoryDTOMapper.cs:12-13) turns on generation. The primary constructor takes the child mapper (:13-14) and assigns it to the [UseMapper]-annotated field _categoryItemDTOMapper (:17-18). public partial ConferenceCategoryDTO MapToDTO(Category entity) (:21) is the declaration the generator implements: it copies Id, RowVersion, Title, Sort, Type and projects CategoryItems (MMCA.ADC.Conference.Shared/Categories/ConferenceCategoryDTO.cs:12-27) through the injected child mapper. MapToDTOs (:24-28) is the same hand-written null-guarded spread projection as its siblings (:26-27). Unlike EventDTOMapper, this class needs no [MapperIgnoreTarget] escape hatch, because every DTO member has a same-named, same-typed source member on the entity.
    • +
    • Why it's built this way: keeping the child mapper injected rather than generated inline means one CategoryItemDTO shape exists in the system regardless of how it was reached, and it keeps both mappers independently unit-testable.
    • +
    • Where it's used: injected by concrete type into CreateConferenceCategoryHandler (CreateConferenceCategoryHandler.cs:19) and UpdateConferenceCategoryHandler (MMCA.ADC.Conference.Application/Categories/UseCases/Update/UpdateConferenceCategoryHandler.cs:17), and resolved as IEntityDTOMapper<Category, ConferenceCategoryDTO, ConferenceCategoryIdentifierType> by the category query service (MMCA.ADC.Conference.Application/DependencyInjection.cs:71, constructor parameter at MMCA.Common.Application/Services/EntityQueryService.cs:35). Registration is by the convention scan (DependencyInjection.cs:125). Tested by ConferenceCategoryDTOMapperTests (Group 27).

    RemoveCategoryItemHandler

    @@ -2699,33 +2961,22 @@

    RemoveCategoryItemHandler

    • What it is: the handler for RemoveCategoryItemCommand. It loads the owning Category with its items, delegates the removal to the aggregate, and saves only if the aggregate agreed. It is the one handler in the category slice that can find its aggregate two different ways.
    • Depends on: IUnitOfWork (RemoveCategoryItemHandler.cs:3,14), ILogger<T> with a source-generated [LoggerMessage] (:1,15,60-61), ICommandHandler<in TCommand, TResult> (:4,15), the Category aggregate and its CategoryItem child (:2), and Result / Error (:5).
    • -
    • Concept introduced, resolving the aggregate from the child when the caller does not name it. The usual child-mutation handler loads by aggregate id and stops there. This one cannot assume it has an aggregate id, because the DELETE endpoint takes categoryId as an optional query argument (MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:173) and the UI's generic delete sends only the item id, so CategoryId arrives as default (0). The handler branches on that (RemoveCategoryItemHandler.cs:28): when the id is unset it runs GetAllAsync with a predicate that finds the owner through its children, where: c => c.CategoryItems.Any(ci => ci.Id == command.CategoryItemId) (:30-34), and takes the first match (:35); otherwise it loads by id directly (:39-43). Both branches pass includes: [nameof(Category.CategoryItems)] and asTracking: true, and both are load-bearing: without the include the child collection is empty and the domain method finds nothing to remove, and without tracking the change would be made on an untracked graph and silently lost at SaveChangesAsync (see the primer on the EF tracking rule). [Rubric §4, Domain-Driven Design] assesses whether children are reached through their root: even the id-less path resolves the root first and then asks it to remove the child. [Rubric §12, Performance & Scalability]: the fallback path is a scan-and-filter query rather than a keyed lookup, which is the cost of accepting a request that omits the owner. [Rubric §13, Observability & Operability]: the source-generated log message records both identifiers with structured fields at zero allocation when the level is disabled.
    • -
    • Walkthrough: primary-constructor injection of IUnitOfWork and ILogger<RemoveCategoryItemHandler> (RemoveCategoryItemHandler.cs:13-15), implementing ICommandHandler<RemoveCategoryItemCommand, Result>. HandleAsync (:18-58) resolves the typed repository (:22), runs the two-branch load described above (:27-44), and returns Error.NotFound.WithSource(nameof(RemoveCategoryItemHandler)).WithTarget(nameof(Category)) when nothing was found (:46-47); note that the fallback branch reports a missing category even when what the caller actually got wrong was the item id. It then calls entity.RemoveCategoryItem(command.CategoryItemId) (:49), and only on success awaits unitOfWork.SaveChangesAsync(cancellationToken) with .ConfigureAwait(false) and emits LogCategoryItemRemoved (:50-55, declaration :60-61). The aggregate's own Result is returned unchanged (:57), so a missing-child failure from GetCategoryItemOrNotFound (MMCA.ADC.Conference.Domain/Categories/Category.cs:194,217) reaches the caller with its domain error intact. Inside the aggregate the removal is a soft delete on the child (Category.cs:199) followed by a CategoryItemChanged event with DomainEntityState.Deleted (:203).
    • +
    • Concept introduced, resolving the aggregate from the child when the caller does not name it. The usual child-mutation handler loads by aggregate id and stops there. This one cannot assume it has an aggregate id, because the DELETE endpoint takes categoryId as a plain query argument (MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:181) and the UI's generic delete sends only the item id, so CategoryId arrives as default (0). The handler branches on that (RemoveCategoryItemHandler.cs:28): when the id is unset it runs GetAllAsync with a predicate that finds the owner through its children, where: c => c.CategoryItems.Any(ci => ci.Id == command.CategoryItemId) (:30-34), and takes the first match (:35); otherwise it loads by id directly (:39-43). Both branches pass includes: [nameof(Category.CategoryItems)] and asTracking: true, and both are load-bearing: without the include the child collection is empty and the domain method finds nothing to remove, and without tracking the change would be made on an untracked graph and silently lost at SaveChangesAsync (the include-carrying repository overload defaults asTracking to false, MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:31-35). [Rubric §4, Domain-Driven Design] assesses whether children are reached through their root: even the id-less path resolves the root first and then asks it to remove the child. [Rubric §12, Performance & Scalability]: the fallback path is a filter-over-children query rather than a keyed lookup, which is the cost of accepting a request that omits the owner. [Rubric §13, Observability & Operability]: the source-generated log message records both identifiers with structured fields at zero allocation when the level is disabled.
    • +
    • Walkthrough: primary-constructor injection of IUnitOfWork and ILogger<RemoveCategoryItemHandler> (RemoveCategoryItemHandler.cs:13-15), implementing ICommandHandler<RemoveCategoryItemCommand, Result>. HandleAsync (:18-58) resolves the typed repository (:22), runs the two-branch load described above (:27-44), and returns Error.NotFound.WithSource(nameof(RemoveCategoryItemHandler)).WithTarget(nameof(Category)) when nothing was found (:46-47); note that the fallback branch reports a missing category even when what the caller actually got wrong was the item id. It then calls entity.RemoveCategoryItem(command.CategoryItemId) (:49), and only on success awaits unitOfWork.SaveChangesAsync(cancellationToken) with .ConfigureAwait(false) and emits LogCategoryItemRemoved (:50-55, declaration :60-61). The aggregate's own Result is returned unchanged (:57), so a missing-child failure from GetCategoryItemOrNotFound (MMCA.ADC.Conference.Domain/Categories/Category.cs:194,214-217) reaches the caller with its domain error intact. Inside the aggregate the removal is a soft delete on the child (Category.cs:199) followed by a CategoryItemChanged event with DomainEntityState.Deleted (:203).
    • Why it's built this way: the save-only-on-success shape is the module's canonical command body, so a rejected removal writes nothing at all and the single SaveChangesAsync stays the one boundary that stamps audit fields, captures domain events, and writes the outbox row (ADR-003). The owner-resolution fallback exists because the UI reuses one generic delete action for every child grid, and the alternative would have been a bespoke client call per child type.
    • -
    • Where it's used: resolved by the category-items controller as ICommandHandler<RemoveCategoryItemCommand, Result> (MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:65) and invoked at :177, after which the controller also evicts the named output-cache policy explicitly (:185). Covered by RemoveCategoryItemHandlerTests (Group 27).
    • -
    -

    RoomDTOMapper

    -
    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.DTOs · MMCA.ADC.Conference.Application/Events/DTOs/RoomDTOMapper.cs:12 · Level 8 · class (sealed partial)

    -
    -
      -
    • What it is: the generated mapper from a Room child entity to a RoomDTO.
    • -
    • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> (RoomDTOMapper.cs:3,13), Mapperly (:4,11), the entity and DTO (:1-2), and the RoomIdentifierType alias.
    • -
    • Concept: nothing new; the [Mapper]-plus-partial shape taught by EventQuestionAnswerDTOMapper.
    • -
    • Walkthrough: public partial RoomDTO MapToDTO(Room entity) (RoomDTOMapper.cs:16) generated by Mapperly, plus the hand-written MapToDTOs with null guard and spread projection (:19-23).
    • -
    • Where it's used: injected concretely into AddRoomHandler (MMCA.ADC.Conference.Application/Events/UseCases/AddRoom/AddRoomHandler.cs:21), used as a child mapper by EventDTOMapper (EventDTOMapper.cs:20-21), and resolved by the query service registered at MMCA.ADC.Conference.Application/DependencyInjection.cs:81. Tested by RoomDTOMapperTests (Group 27).
    • +
    • Where it's used: resolved by the category-items controller as ICommandHandler<RemoveCategoryItemCommand, Result> (MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:66) and invoked at :184-186, after which the controller evicts the named output-cache policy explicitly and returns 204 No Content (:193-194). Covered by RemoveCategoryItemHandlerTests (Group 27).

    UpdateCategoryItemCommandValidator

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.UpdateCategoryItem · MMCA.ADC.Conference.Application/Categories/UseCases/UpdateCategoryItem/UpdateCategoryItemCommandValidator.cs:7 · Level 8 · class (sealed)

      -
    • What it is: the validator the pipeline runs against an UpdateCategoryItemCommand. Like its create-side cousin it holds no rule bodies: it is two Include calls.
    • +
    • What it is: the validator the pipeline runs against an UpdateCategoryItemCommand. Like its add-side twin it holds no rule bodies: it is two Include calls.
    • Depends on: FluentValidation.AbstractValidator<T> (NuGet, UpdateCategoryItemCommandValidator.cs:1,7) and the two shared rule objects CategoryItemNameRules<T> and CategoryItemSortRules<T> from MMCA.ADC.Conference.Application.Categories.Validation (:2,11-12).
    • -
    • Concept: validating a command rather than a request. ConferenceCategoryCreateRequestValidator targets a wire contract; this one targets the record the controller assembles from a route value plus a body (MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:153-157). The pipeline treats them identically because both are just T to FluentValidation, which is what makes it possible to validate at the same boundary whether or not a slice has a distinct request type. The two included rule objects also show the generic-rule pattern working over different selector types: CategoryItemNameRules<T> takes an Expression<Func<T, string>>, CategoryItemSortRules<T> takes an Expression<Func<T, int>> (MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:30,43). [Rubric §24, Forms, Validation & UX Safety] assesses boundary rejection with actionable messages: both rules carry stable codes, CategoryItem.Name.Required / CategoryItem.Name.MaxLength (ConferenceCategoryValidationRules.cs:32-33) and CategoryItem.Sort.Negative (:45). [Rubric §1, SOLID]: name rules and sort rules are separate objects with separate reasons to change, and the validator composes them instead of inheriting a fat base.
    • -
    • Walkthrough: a block-bodied constructor with two statements (UpdateCategoryItemCommandValidator.cs:9-13): Include(new CategoryItemNameRules<UpdateCategoryItemCommand>(p => p.Name)) (:11) and Include(new CategoryItemSortRules<UpdateCategoryItemCommand>(p => p.Sort)) (:12). The name rule enforces NotEmpty plus MaximumLength(CategoryInvariants.CategoryItemNameMaxLength), which is 500 (MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:17); the sort rule enforces GreaterThanOrEqualTo(0) (ConferenceCategoryValidationRules.cs:45). What this validator does not check is uniqueness of the name within the category: that rule (BR-138) needs the sibling collection and therefore lives in the aggregate (MMCA.ADC.Conference.Domain/Categories/Category.cs:172-176).
    • -
    • Why it's built this way: field-shape rules that need only the incoming values run at the boundary where they can be reported as a complete list; rules that need loaded state run in the domain. Splitting them that way is why the validator can stay a pure, allocation-free composition with no repository dependency.
    • -
    • Where it's used: resolved by the validation decorator around UpdateCategoryItemHandler, registered by the convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:112); covered by UpdateCategoryItemCommandValidatorTests (Group 27). Its add-side sibling is AddCategoryItemCommandValidator.
    • +
    • Concept: validating a command rather than a request. ConferenceCategoryCreateRequestValidator targets a wire contract; this one targets the record the controller assembles from a route value plus a body (MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:161-165). The pipeline treats them identically because both are just T to FluentValidation, which is what makes it possible to validate at the same boundary whether or not a slice has a distinct request type. The two included rule objects also show the generic-rule pattern working over different selector types: CategoryItemNameRules<T> takes an Expression<Func<T, string>>, CategoryItemSortRules<T> takes an Expression<Func<T, int>> (MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:30,43). [Rubric §24, Forms, Validation & UX Safety] assesses boundary rejection with actionable messages: both rules carry stable codes, CategoryItem.Name.Required / CategoryItem.Name.MaxLength (ConferenceCategoryValidationRules.cs:32-33) and CategoryItem.Sort.Negative (:45). [Rubric §1, SOLID]: name rules and sort rules are separate objects with separate reasons to change, and the validator composes them instead of inheriting a fat base.
    • +
    • Walkthrough: a block-bodied constructor with two statements (UpdateCategoryItemCommandValidator.cs:9-13): Include(new CategoryItemNameRules<UpdateCategoryItemCommand>(p => p.Name)) (:11) and Include(new CategoryItemSortRules<UpdateCategoryItemCommand>(p => p.Sort)) (:12). The name rule enforces NotEmpty plus MaximumLength(CategoryInvariants.CategoryItemNameMaxLength), which is 500 (MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:17); the sort rule enforces GreaterThanOrEqualTo(0) (ConferenceCategoryValidationRules.cs:44-45). What this validator does not check is uniqueness of the name within the category: that rule (BR-138) needs the sibling collection and therefore lives in the aggregate, where the update path passes the item being edited as the exclusion (MMCA.ADC.Conference.Domain/Categories/Category.cs:172-176).
    • +
    • Why it's built this way: field-shape rules that need only the incoming values run at the boundary where they can be reported as a complete list; rules that need loaded state run in the domain. Splitting them that way is why the validator can stay a pure, dependency-free composition with no repository dependency.
    • +
    • Where it's used: resolved by the validation decorator around UpdateCategoryItemHandler, registered by the convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:125); covered by UpdateCategoryItemCommandValidatorTests (Group 27), which shares a file with its add-side twin (MMCA.ADC.Conference.Application.Tests/Categories/Validation/CategoryCommandValidatorTests.cs:54). Its add-side sibling is AddCategoryItemCommandValidator.

    UpdateCategoryItemHandler

    @@ -2734,10 +2985,10 @@

    UpdateCategoryItemHandler

    • What it is: the handler for UpdateCategoryItemCommand. It loads the owning Category with its items, delegates the edit to the aggregate, and saves only if the aggregate agreed.
    • Depends on: IUnitOfWork (UpdateCategoryItemHandler.cs:3,14), ILogger<T> with a source-generated [LoggerMessage] (:1,15,42-43), ICommandHandler<in TCommand, TResult> (:4,15), the Category aggregate and its CategoryItem child (:2), and Result / Error (:5).
    • -
    • Concept: the canonical child-mutation handler body, and the cleanest example of it in this unit because it has no fallback lookup to distract from the shape. Compare it line by line with RemoveCategoryItemHandler: same primary constructor, same GetRepository call, same includes plus asTracking: true load, same decorated Error.NotFound, same save-only-on-success tail. The only differences are the domain method called and the log message, because the update always arrives with its owner id in the body and therefore needs only one load path. [Rubric §4, Domain-Driven Design] assesses whether the aggregate owns its invariants: the handler never touches the child collection itself, and the BR-138 case-insensitive uniqueness check that makes this operation interesting lives inside Category.UpdateCategoryItem (MMCA.ADC.Conference.Domain/Categories/Category.cs:173-176). [Rubric §14, Testability]: with only a unit of work and a logger injected, the handler is exercised in the unit tier against a faked repository (UpdateCategoryItemHandlerTests, Group 27).
    • -
    • Walkthrough: primary-constructor injection (UpdateCategoryItemHandler.cs:13-15), implementing ICommandHandler<UpdateCategoryItemCommand, Result>. HandleAsync (:18-40) resolves the typed repository (:22), loads with GetByIdAsync(command.CategoryId, includes: [nameof(Category.CategoryItems)], asTracking: true, ...) (:23-27), returns Error.NotFound.WithSource(nameof(UpdateCategoryItemHandler)).WithTarget(nameof(Category)) when the row is absent (:28-29), calls entity.UpdateCategoryItem(command.CategoryItemId, command.Name, command.Sort) (:31), and on success awaits SaveChangesAsync with .ConfigureAwait(false) and emits LogCategoryItemUpdated (:32-37, declaration :42-43). The domain result is returned unchanged (:39). Inside the aggregate the order of checks matters: the child is located first (Category.cs:167), then the uniqueness rule runs excluding the item being edited (:173-174), then the child's own Update applies the values (:178), and only then is CategoryItemChanged with DomainEntityState.Updated queued (:182).
    • +
    • Concept: the canonical child-mutation handler body, and the cleanest example of it in this unit because it has no fallback lookup to distract from the shape. Compare it line by line with RemoveCategoryItemHandler: same primary constructor, same GetRepository call, same includes plus asTracking: true load, same decorated Error.NotFound, same save-only-on-success tail. The only differences are the domain method called and the log message, because the update always arrives with its owner id in the body and therefore needs only one load path. [Rubric §4, Domain-Driven Design] assesses whether the aggregate owns its invariants: the handler never touches the child collection itself, and the BR-138 case-insensitive uniqueness check that makes this operation interesting lives inside Category.UpdateCategoryItem (MMCA.ADC.Conference.Domain/Categories/Category.cs:172-176). [Rubric §14, Testability]: with only a unit of work and a logger injected, the handler is exercised in the unit tier against a faked repository (UpdateCategoryItemHandlerTests, Group 27).
    • +
    • Walkthrough: primary-constructor injection (UpdateCategoryItemHandler.cs:13-15), implementing ICommandHandler<UpdateCategoryItemCommand, Result>. HandleAsync (:18-40) resolves the typed repository (:22), loads with GetByIdAsync(command.CategoryId, includes: [nameof(Category.CategoryItems)], asTracking: true, ...) (:23-27), returns Error.NotFound.WithSource(nameof(UpdateCategoryItemHandler)).WithTarget(nameof(Category)) when the row is absent (:28-29), calls entity.UpdateCategoryItem(command.CategoryItemId, command.Name, command.Sort) (:31), and on success awaits SaveChangesAsync with .ConfigureAwait(false) and emits LogCategoryItemUpdated (:32-37, declaration :42-43). The domain result is returned unchanged (:39). Inside the aggregate the order of checks matters: the child is located first (Category.cs:167), then the uniqueness rule runs excluding the item being edited (:173-174), then the child's own Update applies the values after re-checking the name invariant (:178; MMCA.ADC.Conference.Domain/Categories/CategoryItem.cs:73-85), and only then is CategoryItemChanged with DomainEntityState.Updated queued (:182).
    • Why it's built this way: keeping the handler this thin means every rule that could reject the edit is discoverable in one place, the aggregate, and the handler's entire contribution is orchestration: load with the right graph, delegate, persist once, log.
    • -
    • Where it's used: resolved by the category-items controller as ICommandHandler<UpdateCategoryItemCommand, Result> (MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:64) and invoked at :152, followed by an explicit output-cache eviction and a 204 No Content (:165-166).
    • +
    • Where it's used: resolved by the category-items controller as ICommandHandler<UpdateCategoryItemCommand, Result> (MMCA.ADC.Conference.API/Controllers/CategoryItemsController.cs:65) and invoked at :160-166, followed by an explicit output-cache eviction and a 204 No Content (:173-174).

    CreateConferenceCategoryHandler

    @@ -2749,19 +3000,30 @@

    CreateConferenceCategoryHandler

  • Concept: the generic create slice assembled end to end, the same composition CreateEventHandler uses for events. Read the four injected dependencies as a pipeline (CreateConferenceCategoryHandler.cs:16-20): the request mapper turns the wire contract into a validated entity, the unit of work supplies the typed repository and owns the transaction boundary, and the DTO mapper turns the persisted entity back into a wire contract. Notice the asymmetry in how the two mappers are injected: the request mapper arrives by interface (:18) because the generic create machinery is written against that abstraction, while the DTO mapper arrives by concrete type (:19) because this handler wants that specific mapper and its nested child mapper. Notice equally what is absent: no validator call (the validation decorator already ran ConferenceCategoryCreateRequestValidator), no cache eviction (the caching decorator reads CachePrefix off the request), and no try/catch (failures arrive as Result values). That absence is the point of the decorator pipeline taught in Group 05. [Rubric §5, Vertical Slice] assesses whether a use case is self-contained: the four Create types live in one folder and this handler is the slice's entry point. [Rubric §3, Clean Architecture]: the Application layer depends on abstractions and on the Domain, never on EF Core or ASP.NET. [Rubric §6, CQRS & Event-Driven]: one command type, one handler, one write path, and the CategoryChanged event raised inside the factory (MMCA.ADC.Conference.Domain/Categories/Category.cs:72) is captured by the same SaveChangesAsync.
  • Walkthrough: primary-constructor injection of the four collaborators (CreateConferenceCategoryHandler.cs:16-20), declaring ICommandHandler<ConferenceCategoryCreateRequest, Result<ConferenceCategoryDTO>>. HandleAsync (:23-40) awaits requestMapper.CreateEntityAsync(command, cancellationToken) (:27) and short-circuits on failure by re-wrapping the errors into the correct generic shape, Result.Failure<ConferenceCategoryDTO>(result.Errors) (:28-29), which is how a factory-level invariant failure becomes an API error without an exception. It then unwraps result.Value! (:31), gets unitOfWork.GetRepository<Category, ConferenceCategoryIdentifierType>() (:32), and awaits repository.AddAsync(entity, cancellationToken) followed by the single unitOfWork.SaveChangesAsync(cancellationToken) (:34-35), both with .ConfigureAwait(false). LogConferenceCategoryCreated(logger, entity.Id, entity.Title) (:37, declaration :42-43) is emitted after the save, so the logged id is the store-generated key rather than the 0 that arrived on the request. It returns Result.Success(dtoMapper.MapToDTO(entity)) (:39).
  • Why it's built this way: the single SaveChangesAsync is the one place audit fields are stamped, domain events are captured, and outbox rows are written, so the handler deliberately owns exactly one call to it. Returning a ConferenceCategoryDTO rather than the entity keeps the domain type from crossing the API boundary, per ADR-001.
  • -
  • Where it's used: resolved by the categories controller as ICommandHandler<ConferenceCategoryCreateRequest, Result<ConferenceCategoryDTO>> (MMCA.ADC.Conference.API/Controllers/ConferenceCategoriesController.cs:34) and reached through the base controller's create action, which the ADC controller overrides only to add an explicit cache eviction after the call (:93-100). Its update counterpart is UpdateConferenceCategoryHandler. Covered by CreateConferenceCategoryHandlerTests (Group 27).
  • +
  • Where it's used: resolved by the categories controller as ICommandHandler<ConferenceCategoryCreateRequest, Result<ConferenceCategoryDTO>> (MMCA.ADC.Conference.API/Controllers/ConferenceCategoriesController.cs:34) and reached through AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest> (:39-40), whose create action the ADC controller overrides only to add an explicit cache eviction after the call (:93-100). Its update counterpart is UpdateConferenceCategoryHandler. Covered by CreateConferenceCategoryHandlerTests (Group 27).
  • -

    EventDTOMapper

    +

    SpeakerFirstNameRules<T>

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.DTOs · MMCA.ADC.Conference.Application/Events/DTOs/EventDTOMapper.cs:14 · Level 9 · class (sealed partial)

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.Validation · MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:11 · Level 7 · class (sealed, generic)

    +
    +
      +
    • What it is: the reusable rule set for a Speaker's first name. It contributes no rule bodies of its own: it is a three-line subclass that binds the framework's generic "required string" rules to one field name and one length constant.
    • +
    • Depends on: RequiredStringRules<T> from MMCA.Common.Application.Validation (MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:3,12), SpeakerInvariants (SpeakerValidationRules.cs:2,15), and Expression<TDelegate> from System.Linq.Expressions (BCL, SpeakerValidationRules.cs:1,14).
    • +
    • Concept introduced, rule reuse by inheritance rather than by composition. QuestionTextRules<T> hand-writes its own NotEmpty plus MaximumLength chain (MMCA.ADC.Conference.Application/Questions/Validation/QuestionValidationRules.cs:16-18); this class instead inherits the identical chain from the framework and passes three arguments to it: the selector, the display name, and the limit (SpeakerValidationRules.cs:14-15). The base builds both messages from the display name, "You must enter a First Name" and "First Name cannot be longer than 200 characters" (MMCA.Common.Application/Validation/CommonValidationRules.cs:16-18). The trade is visible in the output: the framework base attaches no WithErrorCode, so a speaker name violation surfaces with FluentValidation's default code, while a question text violation surfaces with the explicit Question.QuestionText.Required / Question.QuestionText.MaxLength codes (QuestionValidationRules.cs:17-18). Choose the base when the field is an ordinary required string; hand-write when the field needs a stable machine-readable code. [Rubric §1, SOLID] assesses whether a type has one reason to change: this one changes only if the speaker's first name changes its display name or its length ceiling. [Rubric §15, Best Practices & Code Quality] assesses duplication: the shared base lives in MMCA.Common, so the same shape is available to every module in every app rather than copy-pasted per aggregate.
    • +
    • Walkthrough: public sealed class SpeakerFirstNameRules<T> : RequiredStringRules<T> (SpeakerValidationRules.cs:11-12) with a single constructor that forwards base(selector, "First Name", SpeakerInvariants.FirstNameMaxLength) (SpeakerValidationRules.cs:14-15). The constant is 200 (MMCA.ADC.Conference.Domain/Speakers/SpeakerInvariants.cs:13), the same value the domain guard applies when it produces the Speaker.FirstName.TooLong invariant error (SpeakerInvariants.cs:45), so the boundary rejection and the deep guard cannot drift apart.
    • +
    • Why it's built this way: keeping the constant in the domain and the message in the framework base means an ADC-specific validator is reduced to naming the field. The generic T is what makes one rule object serve several request shapes, since FluentValidation's Include requires both validators to be generic over the same type.
    • +
    • Where it's used: included by SpeakerCreateRequestValidator (MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequestValidator.cs:11) and by SpeakerUpdateRequestValidator (MMCA.ADC.Conference.Application/Speakers/UseCases/Update/SpeakerUpdateRequestValidator.cs:11).
    • +
    +

    SpeakerLastNameRules<T>

    +
    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.Validation · MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:22 · Level 7 · class (sealed, generic)

      -
    • What it is: the read-side mapper for the Event aggregate. It is the composite of the family: it maps the root and delegates its three child collections to the child mappers, then applies one hand-written fix-up the generator cannot express.
    • -
    • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> (EventDTOMapper.cs:3,18), Mapperly (:4,13,20,23,26,50), RoomDTOMapper, EventSpeakerDTOMapper, and EventQuestionAnswerDTOMapper (:15-17), the Event aggregate (:1), EventDTO from the Shared project (:2), and System.Globalization.CultureInfo (BCL, :39).
    • -
    • Concept introduced, composing generated mappers and escaping to hand-written code. Two Mapperly features carry this class. [UseMapper] on a field (EventDTOMapper.cs:20,23,26) tells the generator "when you need to map a Room, a EventSpeaker, or an EventQuestionAnswer, call this instance instead of generating a second copy", which is how an aggregate DTO gets its child collections filled without duplicating child mapping logic. [MapperIgnoreTarget] (:50) is the escape hatch: it tells the generator to leave one target member alone, so the class can set it itself. The member in question is LastSessionizeRefreshBy, a UserIdentifierType? on the entity (MMCA.ADC.Conference.Domain/Events/Event.cs:77) but a string? on the DTO (MMCA.ADC.Conference.Shared/Events/EventDTO.cs:60); a nullable-value-type-to-string conversion is not something the generator will invent, and the file's own comment says so (EventDTOMapper.cs:35). The general lesson is that a source generator is not all-or-nothing: you keep generation for the ninety percent of straight property copies and hand-write only the member that needs a decision. [Rubric §12, Performance & Scalability] assesses avoidable runtime work on hot paths: the whole event read path, including children, is generated assignment plus one with expression. [Rubric §9, API & Contract Design]: the DTO's own types are chosen for the wire (an id rendered as a string), and this class is where the two type systems meet. [Rubric §15, Best Practices & Code Quality]: the conversion is done with CultureInfo.InvariantCulture (:39) rather than the ambient culture, so the value is stable regardless of server locale.
    • -
    • Walkthrough: the primary constructor takes the three child mappers (EventDTOMapper.cs:14-17) and assigns each to a [UseMapper]-annotated readonly field (:20-27). The public MapToDTO(Event entity) (:30-41) is hand-written: it null-guards (:32), calls the private generated MapToDTOGenerated(entity) (:33), and then returns a with expression that sets the one ignored member, LastSessionizeRefreshBy = entity.LastSessionizeRefreshBy?.ToString(CultureInfo.InvariantCulture) (:36-40). Because EventDTO is a record, the with copy is a cheap shallow clone that leaves every generated assignment intact. MapToDTOs (:44-48) is the same null-guarded spread projection as its siblings. The generated method itself is declared last, private partial EventDTO MapToDTOGenerated(Event entity) carrying [MapperIgnoreTarget(nameof(EventDTO.LastSessionizeRefreshBy))] (:50-51).
    • -
    • Why it's built this way: making the public method the wrapper and the generated method private means no caller can accidentally bypass the fix-up and receive a DTO with a null LastSessionizeRefreshBy. Keeping the child mappers injected rather than generated inline means the same RoomDTO shape is produced whether a room is read directly or as part of an event.
    • -
    • Where it's used: injected into CreateEventHandler (MMCA.ADC.Conference.Application/Events/UseCases/Create/CreateEventHandler.cs:19) and UpdateEventHandler (.../Update/UpdateEventHandler.cs:18), and resolved as IEntityDTOMapper<Event, EventDTO, EventIdentifierType> by the event query service (MMCA.ADC.Conference.Application/DependencyInjection.cs:55, constructor parameter at MMCA.Common.Application/Services/EntityQueryService.cs:35). Registration is by the convention scan (DependencyInjection.cs:112).
    • +
    • What it is: the last-name twin of SpeakerFirstNameRules<T>, declared in the same file.
    • +
    • Depends on: the same three: RequiredStringRules<T> (MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:3,23), SpeakerInvariants (SpeakerValidationRules.cs:2,26), and Expression<TDelegate> (SpeakerValidationRules.cs:1,25).
    • +
    • Concept: nothing new; the inherit-the-shared-rules pattern taught by SpeakerFirstNameRules<T>. The two declarations differ only in the display name passed to the base, "Last Name" instead of "First Name", and in the constant, SpeakerInvariants.LastNameMaxLength (SpeakerValidationRules.cs:26), which is also 200 (MMCA.ADC.Conference.Domain/Speakers/SpeakerInvariants.cs:16). They stay two types rather than one parameterized rule because each is included by name against a specific property, which is what makes the call site at the validator read as documentation.
    • +
    • Walkthrough: public sealed class SpeakerLastNameRules<T> : RequiredStringRules<T> (SpeakerValidationRules.cs:22-23) with the forwarding constructor at SpeakerValidationRules.cs:25-26.
    • +
    • Where it's used: included by SpeakerCreateRequestValidator (MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequestValidator.cs:12) and by SpeakerUpdateRequestValidator (MMCA.ADC.Conference.Application/Speakers/UseCases/Update/SpeakerUpdateRequestValidator.cs:12).

    AddEventQuestionAnswerCommand

    @@ -2769,11 +3031,11 @@

    AddEventQuestionAnswerCommand

    • What it is: the write intent for recording an answer to a conference question against an Event: which event, which question, the answer text, and optionally an explicit id for the new answer row.
    • -
    • Depends on: ICacheInvalidating from MMCA.Common.Application.UseCases (AddEventQuestionAnswerCommand.cs:2,15), the Event domain type used only to build the cache prefix (AddEventQuestionAnswerCommand.cs:1,18), and the module identifier aliases EventIdentifierType, EventQuestionAnswerIdentifierType, and QuestionIdentifierType (EventIdentifierType = int, MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7; see the primer).
    • -
    • Concept introduced, the cache-invalidating child-add command. A mutation opts into cache eviction by implementing ICacheInvalidating and exposing a CachePrefix; the caching decorator in the CQRS pipeline (Group 05) purges every cached read under that prefix once the command succeeds. Two details generalize to every add-child command in this unit. First, the prefix is keyed on the aggregate root (typeof(Event).FullName, AddEventQuestionAnswerCommand.cs:18), not on the child type, because a cached event read carries its answers with it. Second, the child id is nullable (EventQuestionAnswerIdentifierType?, AddEventQuestionAnswerCommand.cs:13): a Sessionize import can supply the source-assigned id, while an interactive add leaves it null. [Rubric §6, CQRS & Event-Driven] assesses whether writes are explicit intents flowing through one pipeline: the record is the intent and the marker interface is how the cross-cutting cache concern attaches declaratively, so no handler touches the cache. [Rubric §10, Cross-Cutting]: caching is a pipeline concern, not hand-rolled per use case.
    • -
    • Walkthrough: a sealed record with four positional parameters, EventId, the nullable EventQuestionAnswerId, QuestionId, and the string AnswerValue (AddEventQuestionAnswerCommand.cs:11-15), plus the single computed CachePrefix => $"{typeof(Event).FullName}:" (AddEventQuestionAnswerCommand.cs:18). There is no behavior here; the upsert rule, the published-event check, the question-type check, and the cross-module points notification all live in AddEventQuestionAnswerHandler.
    • +
    • Depends on: ICacheInvalidating from MMCA.Common.Application.UseCases (MMCA.ADC.Conference.Application/Events/UseCases/AddEventQuestionAnswer/AddEventQuestionAnswerCommand.cs:2,15), the Event domain type used only to build the cache prefix (AddEventQuestionAnswerCommand.cs:1,18), and the module identifier aliases EventIdentifierType, EventQuestionAnswerIdentifierType, and QuestionIdentifierType (see the primer on identifier-type aliases).
    • +
    • Concept introduced, the cache-invalidating child-add command. A mutation opts into cache eviction by implementing ICacheInvalidating and exposing a CachePrefix; the caching decorator in the CQRS pipeline (Group 05) purges every cached read under that prefix once the command succeeds. Two details generalize to every add-child command in this unit. First, the prefix is keyed on the aggregate root, $"{typeof(Event).FullName}:" (AddEventQuestionAnswerCommand.cs:18), not on the child type, because a cached event read carries its answers with it. Second, the child id is nullable, EventQuestionAnswerIdentifierType? (AddEventQuestionAnswerCommand.cs:13): the aggregate's own contract reads "Explicit ID, or null for database-generated identity" (MMCA.ADC.Conference.Domain/Events/Event.cs:633), so a caller that already owns a stable id can supply it while an interactive add leaves it null. [Rubric §6, CQRS & Event-Driven] assesses whether writes are explicit intents flowing through one pipeline: the record is the intent, and the marker interface is how the cross-cutting cache concern attaches declaratively, so no handler touches the cache. [Rubric §10, Cross-Cutting] assesses whether such concerns live in one place: caching is a pipeline decorator, not hand-rolled per use case.
    • +
    • Walkthrough: a sealed record with four positional parameters, EventId, the nullable EventQuestionAnswerId, QuestionId, and the string AnswerValue (AddEventQuestionAnswerCommand.cs:11-15), plus the single computed CachePrefix (AddEventQuestionAnswerCommand.cs:18). There is no behavior here; the upsert rule, the published-event check, the question-type check, and the cross-module points notification all live in AddEventQuestionAnswerHandler.
    • Why it's built this way: a positional record gives immutability and value equality for free, and keeping the payload to plain identifiers plus the answer text means the caller cannot smuggle in an owner id: the handler derives the answering user from ICurrentUserService instead.
    • -
    • Where it's used: constructed by the answers controller with a null child id (MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:182, handler injected at :58), validated by AddEventQuestionAnswerCommandValidator, and handled by AddEventQuestionAnswerHandler.
    • +
    • Where it's used: constructed by the answers controller with a null child id (MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:190, handler injected at EventQuestionAnswersController.cs:59), validated by AddEventQuestionAnswerCommandValidator, and handled by AddEventQuestionAnswerHandler.

    AddEventSpeakerCommand

    @@ -2781,11 +3043,12 @@

    AddEventSpeakerCommand

    • What it is: the write intent for associating an existing speaker with an existing event. It creates the EventSpeaker association row, not the speaker.
    • -
    • Depends on: ICacheInvalidating (AddEventSpeakerCommand.cs:2,13), Event for the cache prefix (AddEventSpeakerCommand.cs:1,16), and the EventIdentifierType, EventSpeakerIdentifierType, and SpeakerIdentifierType aliases (SpeakerIdentifierType = System.Guid, MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:18).
    • +
    • Depends on: ICacheInvalidating (MMCA.ADC.Conference.Application/Events/UseCases/AddEventSpeaker/AddEventSpeakerCommand.cs:2,13), the Event type for the cache prefix (AddEventSpeakerCommand.cs:1,16), and the EventIdentifierType, EventSpeakerIdentifierType, and SpeakerIdentifierType aliases.
    • Concept: the same cache-invalidating add-child shape introduced by AddEventQuestionAnswerCommand, reduced to its minimum: aggregate id, optional child id, and the one foreign identifier being linked. [Rubric §4, Domain-Driven Design] assesses whether relationships are mutated through an aggregate root: the command names the Event first because the association is a child of the event aggregate, and the speaker aggregate is untouched by this write.
    • -
    • Walkthrough: sealed record AddEventSpeakerCommand(EventIdentifierType EventId, EventSpeakerIdentifierType? EventSpeakerId, SpeakerIdentifierType SpeakerId) : ICacheInvalidating (AddEventSpeakerCommand.cs:10-13), with CachePrefix => $"{typeof(Event).FullName}:" (AddEventSpeakerCommand.cs:16). The nullable EventSpeakerId serves the Sessionize import path exactly as in the sibling command (AddEventSpeakerCommand.cs:12).
    • -
    • Why it's built this way: linking rather than nesting keeps the speaker an independent aggregate that survives being removed from an event, and it keeps the duplicate-association rule (enforced in Event.AddEventSpeaker, MMCA.ADC.Conference.Domain/Events/Event.cs:515-522) inside the event boundary.
    • -
    • Where it's used: constructed by the event-speakers controller with a null child id (MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:215, handler injected at :48), validated by AddEventSpeakerCommandValidator, and handled by AddEventSpeakerHandler.
    • +
    • Walkthrough: public sealed record AddEventSpeakerCommand(EventIdentifierType EventId, EventSpeakerIdentifierType? EventSpeakerId, SpeakerIdentifierType SpeakerId) : ICacheInvalidating (AddEventSpeakerCommand.cs:10-13), with CachePrefix => $"{typeof(Event).FullName}:" (AddEventSpeakerCommand.cs:16). The nullable EventSpeakerId (AddEventSpeakerCommand.cs:12) carries straight through to the aggregate, where null means "let the database generate the identity" (MMCA.ADC.Conference.Domain/Events/Event.cs:537).
    • +
    • Why it's built this way: linking rather than nesting keeps the speaker an independent aggregate that survives being removed from an event, and it keeps the duplicate-association rule inside the event boundary, where Event.AddEventSpeaker scans its own loaded children and fails with Event.Speaker.Duplicate (MMCA.ADC.Conference.Domain/Events/Event.cs:544-551).
    • +
    • Where it's used: constructed by the event-speakers controller with a null child id (MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:223, handler injected at EventSpeakersController.cs:49), validated by AddEventSpeakerCommandValidator, and handled by AddEventSpeakerHandler.
    • +
    • Caveats / not-in-source: the Sessionize refresh does not go through this command. SpeakerSyncStrategy calls the aggregate method directly, also with a null association id (MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SpeakerSyncStrategy.cs:59), so nothing in source constructs this record with a non-null EventSpeakerId today.

    AddRoomCommand

    @@ -2793,48 +3056,58 @@

    AddRoomCommand

    • What it is: the write intent for adding a room to an existing event. It is the widest add-child command in this unit: eight positional parameters carrying the target event, an optional explicit room id, and the room's descriptive fields.
    • -
    • Depends on: ICacheInvalidating (AddRoomCommand.cs:2,23), the Event type for the cache prefix (AddRoomCommand.cs:1,26), and the EventIdentifierType and RoomIdentifierType aliases.
    • -
    • Concept: the same cache-invalidating add-child shape as AddEventQuestionAnswerCommand, but here the nullable child id is genuinely load-bearing rather than an import convenience. Room ids are application-assigned (the integer primary key is the Sessionize id), so a null RoomId tells AddRoomHandler to allocate one from a reserved manual range, while a non-null id is respected as an explicit Sessionize id (AddRoomCommand.cs:17, and see AddRoomHandler for the allocation). [Rubric §9, API & Contract Design] assesses whether inbound contracts are explicit about optionality: the four nullable trailing fields model genuinely optional room metadata rather than overloading empty strings.
    • +
    • Depends on: ICacheInvalidating (MMCA.ADC.Conference.Application/Events/UseCases/AddRoom/AddRoomCommand.cs:2,23), the Event type for the cache prefix (AddRoomCommand.cs:1,26), and the EventIdentifierType and RoomIdentifierType aliases.
    • +
    • Concept: the same cache-invalidating add-child shape as AddEventQuestionAnswerCommand, but here the nullable child id is genuinely load-bearing rather than an identity-generation detail. Room ids are application-assigned: the integer primary key is the Sessionize id, and the aggregate documents the parameter as "Sessionize-assigned room ID, or null when not available" (MMCA.ADC.Conference.Domain/Events/Event.cs:366). A null RoomId therefore tells AddRoomHandler to allocate one from a reserved manual range, while a non-null id is respected verbatim (AddRoomCommand.cs:17). [Rubric §9, API & Contract Design] assesses whether inbound contracts are explicit about optionality: the four nullable trailing fields model genuinely optional room metadata rather than overloading empty strings.
    • Walkthrough: the sealed record declares EventId and the nullable RoomId (AddRoomCommand.cs:16-17), the two mandatory room fields Name and Sort (AddRoomCommand.cs:18-19), then the four optional fields Capacity, Floor, Location, and AccessibilityInfo (AddRoomCommand.cs:20-23). CachePrefix => $"{typeof(Event).FullName}:" (AddRoomCommand.cs:26) evicts the event read cache, since a room is read as part of its event.
    • -
    • Why it's built this way: one command serves both organizer-created rooms (no id) and Sessionize-imported rooms (explicit id), so the import needs no parallel write path. Cache invalidation as a marker interface keeps the decorator pipeline in charge of the cross-cutting concern.
    • -
    • Where it's used: constructed by the rooms controller (MMCA.ADC.Conference.API/Controllers/RoomsController.cs:150, handler injected at :87), validated by AddRoomCommandValidator, and handled by AddRoomHandler. Its update counterpart is UpdateRoomCommand.
    • +
    • Why it's built this way: one command serves both organizer-created rooms (no id) and callers that already hold a Sessionize id, so the id-allocation decision is made once in the handler rather than at every call site. Cache invalidation as a marker interface keeps the decorator pipeline in charge of the cross-cutting concern.
    • +
    • Where it's used: constructed by the rooms controller, which forwards the request's RoomId rather than forcing null (MMCA.ADC.Conference.API/Controllers/RoomsController.cs:263-271, handler injected at RoomsController.cs:94), validated by AddRoomCommandValidator, and handled by AddRoomHandler. Its update counterpart is UpdateRoomCommand.
    • +
    • Caveats / not-in-source: the Sessionize room refresh bypasses this command too, calling @event.AddRoom(sr.Id, sr.Name, sr.Sort) on the aggregate directly with the source-assigned id (MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RoomSyncStrategy.cs:112).
    -

    EventCreateRequest

    +

    EventQuestionAnswerDTOMapper

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Create · MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequest.cs:10 · Level 8 · record

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.DTOs · MMCA.ADC.Conference.Application/Events/DTOs/EventQuestionAnswerDTOMapper.cs:12 · Level 8 · class (sealed partial)

      -
    • What it is: the inbound contract for creating a conference Event. It is both the HTTP request body and the command that the CQRS pipeline dispatches: there is no separate CreateEventCommand.
    • -
    • Depends on: ICreateRequest from MMCA.Common.Application.Interfaces and ICacheInvalidating from MMCA.Common.Application.UseCases (EventCreateRequest.cs:2-3,10), the Event domain type used only to build the cache prefix (EventCreateRequest.cs:1,13), the EventIdentifierType alias (= int, MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7), and DateOnly (BCL).
    • -
    • Concept introduced, the create request as the command. Every other write in this unit has a hand-written XxxCommand record. Create does not: implementing the marker ICreateRequest (EventCreateRequest.cs:10) is what lets the generic create machinery in Group 12 accept this record straight off the wire, hand it to an IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, and dispatch it as ICommandHandler<EventCreateRequest, Result<EventDTO>>. One record therefore carries three roles: the JSON contract, the validation target, and the command. required on Name, StartDate, EndDate, and TimeZone (EventCreateRequest.cs:19,25,28,31) makes the mandatory set a compile-time property of the type rather than a convention the deserializer has to be trusted with, and every member is init-only so the request cannot be mutated after model binding. [Rubric §9, API & Contract Design] assesses whether inbound contracts are explicit about shape and optionality: the split between four required members and eight optional ones is the API's optionality documentation. [Rubric §5, Vertical Slice] assesses whether a feature's request, validator, mapper, and handler live together: all four Create types sit in one folder.
    • -
    • Walkthrough: CachePrefix => $"{typeof(Event).FullName}:" (EventCreateRequest.cs:13) is the eviction key the caching decorator purges on success, keyed on the aggregate root exactly as the child commands above do. Id (EventCreateRequest.cs:16) is a non-nullable EventIdentifierType, so an omitted id binds to 0; the domain factory discards whatever arrives when Event's key is store-generated (MMCA.ADC.Conference.Domain/Events/Event.cs:177,192). Then the four required fields, Name, StartDate, EndDate, TimeZone (EventCreateRequest.cs:19,25,28,31), where TimeZone is an IANA identifier rather than a UTC offset. The remaining members are all nullable and optional: Description (:22), SessionizeCode (:34), VenueAddress (:37), VenueMapUrl (:40), WiFiInfo (:43), OrganizerContactEmail (:46), and SponsorshipPacketUrl (:49). SessionizeCode is the hook that later lets RefreshFromSessionizeCommand pull an agenda for this event; OrganizerContactEmail and SponsorshipPacketUrl are the two attendee- and sponsor-facing fields the public pages read, and both are validated only when supplied (see EventCreateRequestValidator).
    • -
    • Why it's built this way: collapsing request and command removes a mapping step that would have no behavior of its own, while the marker interfaces keep caching and pipeline participation declarative instead of hand-coded in the handler. Storing an IANA zone id rather than a fixed offset is what lets session times render correctly across a daylight-saving boundary.
    • -
    • Where it's used: injected into the events controller as ICommandHandler<EventCreateRequest, Result<EventDTO>> (MMCA.ADC.Conference.API/Controllers/EventsController.cs:46) and forwarded to the shared AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest>, which owns the create action (EventsController.cs:57-58); validated by EventCreateRequestValidator, converted by EventCreateRequestMapper, handled by CreateEventHandler. Its edit-side counterpart is EventUpdateRequest.
    • +
    • What it is: the read-side mapper that turns an EventQuestionAnswer domain entity into an EventQuestionAnswerDTO. The single-entity method has no body in this file: Mapperly generates it at compile time.
    • +
    • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> from MMCA.Common.Application.Interfaces (MMCA.ADC.Conference.Application/Events/DTOs/EventQuestionAnswerDTOMapper.cs:3,13), Riok.Mapperly.Abstractions (NuGet, EventQuestionAnswerDTOMapper.cs:4,11), the EventQuestionAnswer entity (EventQuestionAnswerDTOMapper.cs:1), the EventQuestionAnswerDTO contract from the Shared project (EventQuestionAnswerDTOMapper.cs:2), and the EventQuestionAnswerIdentifierType alias.
    • +
    • Concept introduced (for this unit), source-generated DTO mapping. [Mapper] on a partial class (EventQuestionAnswerDTOMapper.cs:11-12) tells the Mapperly generator to fill in the body of every partial method it finds, here MapToDTO (EventQuestionAnswerDTOMapper.cs:16). The generated body is straight-line property assignment: no reflection, no expression trees, no runtime configuration, and a compile error rather than a silent null if a target member has no source. That is the whole point of ADR-001: mapping is either hand-written or generated, never reflective. [Rubric §12, Performance & Scalability] assesses whether hot paths avoid avoidable runtime work: every read endpoint maps its result set, so a generated assignment beats a reflective copy at the exact place volume lands. [Rubric §9, API & Contract Design] assesses what crosses the wire: the DTO, not the entity, so a domain refactor cannot silently reshape the JSON. [Rubric §14, Testability] assesses whether logic can be exercised in isolation: the mapper is a pure function of its input and is tested directly (EventQuestionAnswerDTOMapperTests).
    • +
    • Walkthrough: two members. public partial EventQuestionAnswerDTO MapToDTO(EventQuestionAnswer entity) (EventQuestionAnswerDTOMapper.cs:16) is the declaration whose implementation the generator supplies. MapToDTOs(IReadOnlyCollection<EventQuestionAnswer>) (EventQuestionAnswerDTOMapper.cs:19-23) is hand-written and deliberately so: it null-guards with ArgumentNullException.ThrowIfNull (EventQuestionAnswerDTOMapper.cs:21) and then projects with a collection expression over a spread, [.. entityCollection.Select(MapToDTO)] (EventQuestionAnswerDTOMapper.cs:22), which materializes a single array without an intermediate List<T> growth cycle. The collection method delegating to the generated single-item method is the shape every mapper in this family repeats.
    • +
    • Where it's used: injected concretely into AddEventQuestionAnswerHandler (MMCA.ADC.Conference.Application/Events/UseCases/AddEventQuestionAnswer/AddEventQuestionAnswerHandler.cs:21), consumed as a child mapper by EventDTOMapper through [UseMapper] (MMCA.ADC.Conference.Application/Events/DTOs/EventDTOMapper.cs:26-27), and resolved as IEntityDTOMapper<EventQuestionAnswer, EventQuestionAnswerDTO, EventQuestionAnswerIdentifierType> by the generic query service registered for the entity (MMCA.ADC.Conference.Application/DependencyInjection.cs:99, constructor parameter at MMCA.Common.Application/Services/EntityQueryService.cs:35). Registration of the mapper itself is by the module's convention scan, services.ScanModuleApplicationServices<ClassReference>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:125).
    • +
    +

    EventSpeakerDTOMapper

    +
    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.DTOs · MMCA.ADC.Conference.Application/Events/DTOs/EventSpeakerDTOMapper.cs:12 · Level 8 · class (sealed partial)

    +
    +
      +
    • What it is: the same generated mapper for the EventSpeaker association entity to EventSpeakerDTO.
    • +
    • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> (MMCA.ADC.Conference.Application/Events/DTOs/EventSpeakerDTOMapper.cs:3,13), Mapperly (EventSpeakerDTOMapper.cs:4,11), the entity and DTO (EventSpeakerDTOMapper.cs:1-2), and the EventSpeakerIdentifierType alias.
    • +
    • Concept: nothing new; the [Mapper]-plus-partial shape taught by EventQuestionAnswerDTOMapper. Reading the two side by side is the fastest way to see how little varies: the entity, the DTO, and the identifier alias in the interface arguments, and nothing else.
    • +
    • Walkthrough: public partial EventSpeakerDTO MapToDTO(EventSpeaker entity) (EventSpeakerDTOMapper.cs:16) generated by Mapperly, and the hand-written MapToDTOs with its null guard and spread projection (EventSpeakerDTOMapper.cs:19-23).
    • +
    • Where it's used: injected concretely into AddEventSpeakerHandler (MMCA.ADC.Conference.Application/Events/UseCases/AddEventSpeaker/AddEventSpeakerHandler.cs:17), used as a child mapper by EventDTOMapper (MMCA.ADC.Conference.Application/Events/DTOs/EventDTOMapper.cs:23-24), and resolved by the query service registered at MMCA.ADC.Conference.Application/DependencyInjection.cs:96. Tested by EventSpeakerDTOMapperTests.

    PublishedEventSpecification

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Specifications · MMCA.ADC.Conference.Application/Events/Specifications/PublishedEventSpecification.cs:11 · Level 8 · class (sealed)

      -
    • What it is: a one-line query filter object that restricts an event query to published events only. It is how BR-108 ("non-privileged readers see only published events") is expressed as data rather than as an if inside every read endpoint.
    • +
    • What it is: a one-line query filter object that restricts an event query to published events only. It is how BR-108 ("non-privileged readers see only published events") is expressed as data rather than as an if inside every read endpoint (MMCA.ADC.Conference.Application/Events/Specifications/PublishedEventSpecification.cs:7-9).
    • Depends on: Specification<TEntity, TIdentifierType> from MMCA.Common.Domain.Specifications (PublishedEventSpecification.cs:3,11), the Event aggregate and its EventIdentifierType alias (PublishedEventSpecification.cs:2,11), and System.Linq.Expressions (PublishedEventSpecification.cs:1,14).
    • -
    • Concept introduced, authorization expressed as a specification. The specification pattern itself is taught in Group 03; what this type introduces is using it as a security filter. The base class exposes a Criteria expression that the repository composes into the EF query, so the restriction is applied in SQL rather than after materialization: an unpublished event is never loaded, never counted in a page total, and never reaches the serializer. Because the filter is a first-class object, the decision "does this caller get the filter" becomes a nullable value at the call site instead of branching query code. [Rubric §11, Security] assesses whether authorization is enforced at the data boundary rather than in the view: here a non-privileged reader's query cannot return an unpublished row at all. [Rubric §12, Performance & Scalability]: pushing the predicate into the expression tree keeps paging counts correct and avoids over-fetching.
    • -
    • Walkthrough: the whole type is a single expression-bodied override, public override Expression<Func<Event, bool>> Criteria => e => e.IsPublished (PublishedEventSpecification.cs:14). There is no constructor and no state, so an instance is free to allocate per request.
    • -
    • Why it's built this way: keeping BR-108 in one named type means the four event read endpoints share one definition of "visible event", and the class name makes the business rule greppable. Inheriting from the framework Specification<TEntity, TIdentifierType> base lets the same object flow through the generic query service and repository without an ADC-specific overload.
    • -
    • Where it's used: EventsController builds it through the private helper GetPublishedEventSpecification(), which returns null (no filter) when currentUserService.IsPrivilegedConferenceReader() and a new instance otherwise (MMCA.ADC.Conference.API/Controllers/EventsController.cs:66-67). The helper is passed as the specification: argument on four read paths (EventsController.cs:82, EventsController.cs:112, EventsController.cs:141, EventsController.cs:171).
    • -
    • Caveats / not-in-source: the gate is the read audience, not the Organizer role alone: IsPrivilegedConferenceReader() is the shared predicate, and the same call also guards a non-read action at EventsController.cs:194. The predicate here does not exclude soft-deleted rows; that is handled separately by the EF global query filter (see Group 07).
    • +
    • Concept introduced, authorization expressed as a specification. The specification pattern itself is taught in Group 03; what this type introduces is using it as a security filter. The base class exposes a Criteria expression that the repository composes into the EF query, so the restriction is applied in SQL rather than after materialization: an unpublished event is never loaded, never counted in a page total, and never reaches the serializer. Because the filter is a first-class object, the decision "does this caller get the filter" becomes a nullable value at the call site instead of branching query code. [Rubric §11, Security] assesses whether authorization is enforced at the data boundary rather than in the view: a non-privileged reader's query cannot return an unpublished row at all. [Rubric §12, Performance & Scalability] assesses over-fetching: pushing the predicate into the expression tree keeps paging counts correct and avoids materializing rows the caller may not see.
    • +
    • Walkthrough: the whole type is a single expression-bodied override, public override Expression<Func<Event, bool>> Criteria => e => e.IsPublished (PublishedEventSpecification.cs:14). There is no constructor and no state, so an instance is cheap to allocate per request.
    • +
    • Why it's built this way: keeping BR-108 in one named type means the event read endpoints share one definition of "visible event", and the class name makes the business rule greppable. Inheriting from the framework Specification<TEntity, TIdentifierType> base lets the same object flow through the generic query service and repository without an ADC-specific overload.
    • +
    • Where it's used: EventsController builds it through the private helper GetPublishedEventSpecification(), which returns null (no filter) when currentUserService.IsPrivilegedConferenceReader() and a new instance otherwise (MMCA.ADC.Conference.API/Controllers/EventsController.cs:67-68). The helper feeds the specification: argument on four read paths (EventsController.cs:83, EventsController.cs:113, EventsController.cs:142, EventsController.cs:172).
    • +
    • Caveats / not-in-source: the gate is the read audience, not the Organizer role alone: IsPrivilegedConferenceReader() is the shared predicate, and the same call also guards the export action outright with a Forbid() rather than with this filter (EventsController.cs:195-198). The criteria here do not exclude soft-deleted rows; that is handled separately by the EF global query filter (see Group 07).
    -

    PublishEventCommand

    +

    RoomDTOMapper

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Publish · MMCA.ADC.Conference.Application/Events/UseCases/Publish/PublishEventCommand.cs:12 · Level 8 · record (sealed)

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.DTOs · MMCA.ADC.Conference.Application/Events/DTOs/RoomDTOMapper.cs:12 · Level 8 · class (sealed partial)

      -
    • What it is: the write intent for flipping an Event to published, which is what makes it visible to attendees. It carries the event id and, optionally, the concurrency token the client last saw.
    • -
    • Depends on: ICacheInvalidating (PublishEventCommand.cs:2,12), the Event type for the cache prefix (PublishEventCommand.cs:1,15), and the EventIdentifierType alias.
    • -
    • Concept introduced, the optional optimistic-concurrency token on a state-transition command. byte[]? RowVersion = null (PublishEventCommand.cs:12) is a defaulted positional parameter, so the command compiles and dispatches with or without it. When present it is the SQL Server rowversion the client received on its last read; the handler stamps it back as the row's original value so the UPDATE matches zero rows if anyone else changed the event in the meantime, and the save surfaces a 409 Conflict. When it is null the check is skipped entirely (PublishEventCommand.cs:8-11). Making it opt-in rather than mandatory is the deliberate choice recorded in ADR-035: a state transition is the classic lost-update target (two organizers looking at the same stale draft), but internal callers such as the Sessionize import do not need to carry a token. [Rubric §8, Data Architecture] assesses how concurrent writes are reconciled: the token turns a silent last-writer-wins into an explicit conflict the caller must resolve. [Rubric §9, API & Contract Design] assesses contract explicitness: the token is part of the command, not an ambient header the handler has to go looking for.
    • -
    • Walkthrough: two positional parameters, EventIdentifierType Id and byte[]? RowVersion (PublishEventCommand.cs:12), plus the computed CachePrefix => $"{typeof(Event).FullName}:" (PublishEventCommand.cs:15). There is no validation and no behavior on the record; the "already published" rule lives on the aggregate (MMCA.ADC.Conference.Domain/Events/Event.cs:260-267).
    • -
    • Why it's built this way: publishing is a domain transition, not a field edit, so it gets its own command rather than riding on the update request. That keeps the authorization surface, the audit log line, and the cache eviction distinct from a generic edit, and it lets the API expose a purpose-named endpoint instead of a PATCH of a boolean.
    • -
    • Where it's used: constructed by the events controller from the route id plus the request body's token (MMCA.ADC.Conference.API/Controllers/EventsController.cs:302, handler injected at :48) and handled by PublishEventHandler. Its inverse is UnpublishEventCommand.
    • +
    • What it is: the generated mapper from a Room child entity to a RoomDTO.
    • +
    • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> (MMCA.ADC.Conference.Application/Events/DTOs/RoomDTOMapper.cs:3,13), Mapperly (RoomDTOMapper.cs:4,11), the entity and DTO (RoomDTOMapper.cs:1-2), and the RoomIdentifierType alias.
    • +
    • Concept: nothing new; the [Mapper]-plus-partial shape taught by EventQuestionAnswerDTOMapper.
    • +
    • Walkthrough: public partial RoomDTO MapToDTO(Room entity) (RoomDTOMapper.cs:16) generated by Mapperly, plus the hand-written MapToDTOs with null guard and spread projection (RoomDTOMapper.cs:19-23).
    • +
    • Where it's used: injected concretely into AddRoomHandler (MMCA.ADC.Conference.Application/Events/UseCases/AddRoom/AddRoomHandler.cs:21), used as a child mapper by EventDTOMapper (MMCA.ADC.Conference.Application/Events/DTOs/EventDTOMapper.cs:20-21), and resolved by the query service registered at MMCA.ADC.Conference.Application/DependencyInjection.cs:90. Tested by RoomDTOMapperTests.

    AddEventQuestionAnswerCommandValidator

    @@ -2842,10 +3115,10 @@

    AddEventQuestionAnswerCommandVal

    • What it is: the FluentValidation validator for AddEventQuestionAnswerCommand. It enforces exactly one thing: the answer text is not empty.
    • -
    • Depends on: AbstractValidator<AddEventQuestionAnswerCommand> from FluentValidation (NuGet, AddEventQuestionAnswerCommandValidator.cs:1,8). It includes no shared rule set.
    • -
    • Concept introduced, the inline validator with a stable error code. Unlike the Include-composed validators in this unit (AddRoomCommandValidator, EventCreateRequestValidator), this one writes its single rule inline because no other command shares an "event answer value" field. The load-bearing detail is WithErrorCode("EventQuestionAnswer.AnswerValue.Required") (AddEventQuestionAnswerCommandValidator.cs:14): that string is what the API error-mapping layer keys on to build the problem-details response, so it is part of the public contract, not just prose. [Rubric §24, Forms, Validation & UX Safety] assesses whether bad input is rejected before it reaches business logic with a message the UI can act on; [Rubric §9, API & Contract Design]: the stable dotted error code lets clients branch on the failure without parsing English.
    • +
    • Depends on: AbstractValidator<AddEventQuestionAnswerCommand> from FluentValidation (NuGet, MMCA.ADC.Conference.Application/Events/UseCases/AddEventQuestionAnswer/AddEventQuestionAnswerCommandValidator.cs:1,8). It includes no shared rule set.
    • +
    • Concept introduced, the inline validator with a stable error code. Unlike the Include-composed validators in this unit (AddRoomCommandValidator, EventCreateRequestValidator), this one writes its single rule inline because no other command shares an "event answer value" field. The load-bearing detail is WithErrorCode("EventQuestionAnswer.AnswerValue.Required") (AddEventQuestionAnswerCommandValidator.cs:14): that string is what a client can branch on, so it is part of the contract, not just prose. [Rubric §24, Forms, Validation & UX Safety] assesses whether bad input is rejected before it reaches business logic with a message the UI can act on. [Rubric §9, API & Contract Design] assesses contract stability: the dotted error code lets clients branch on the failure without parsing English.
    • Walkthrough: an expression-bodied constructor (AddEventQuestionAnswerCommandValidator.cs:10) chaining RuleFor(x => x.AnswerValue).NotEmpty() with the message "Answer value is required." and the error code above (AddEventQuestionAnswerCommandValidator.cs:11-14).
    • -
    • Why it's built this way: the validator deliberately checks shape only. The semantic rules stay deeper: the 4000-character bound lives on EventInvariants.AnswerValueMaxLength and its guard (MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:52, EventInvariants.cs:145) and is mirrored in the EF configuration (MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/EventQuestionAnswerConfiguration.cs:25-26), and the "does this text match the question's type" rule is a domain invariant the handler calls (see AddEventQuestionAnswerHandler). The validator cannot express that rule because it would need to load the question first.
    • +
    • Why it's built this way: the validator deliberately checks shape only. The semantic rules stay deeper: the 4000-character bound lives on EventInvariants.AnswerValueMaxLength and its guard (MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:55, EventInvariants.cs:148) and is mirrored in the EF configuration (MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/EventQuestionAnswerConfiguration.cs:25-26), and the "does this text match the question's type" rule is a domain invariant the handler calls (see AddEventQuestionAnswerHandler). The validator cannot express that rule because it would need to load the question first.
    • Where it's used: resolved and run by the validation decorator in the CQRS pipeline (Group 05) before AddEventQuestionAnswerHandler executes.

    AddEventQuestionAnswerHandler

    @@ -2854,18 +3127,18 @@

    AddEventQuestionAnswerHandler

    • What it is: the handler that records a user's answer to an event question. It is the most rule-dense handler in this unit: it gates on the event being published, cross-checks the question, performs an upsert rather than a blind insert, and raises a cross-module integration event on the insert path only.
    • -
    • Depends on: IUnitOfWork (AddEventQuestionAnswerHandler.cs:19), ICurrentUserService (:20), EventQuestionAnswerDTOMapper (:21), TimeProvider (BCL, :22), ILogger<AddEventQuestionAnswerHandler> (:23), the ICommandHandler<in TCommand, TResult> contract (:23), the Event aggregate with its EventQuestionAnswer children, EventInvariants (:40), Question plus QuestionInvariants (:65,77), EventFeedbackSubmitted (:6,112), and Result / Error.
    • -
    • Concept introduced, the add that is really an upsert (BR-107), and a domain event raised on one branch only. A user answering the same question twice must not accumulate rows; the second submission replaces the first. The handler encodes that as an identity lookup on the triple (current user, question, event): the event is already the aggregate being loaded, so the search reduces to scanning the loaded children for !a.IsDeleted && a.QuestionId == command.QuestionId && a.CreatedBy == userId (AddEventQuestionAnswerHandler.cs:51-52). The owner is read from ICurrentUserService (:50), never from the command, so a caller cannot answer on someone else's behalf. The second idea is the one to carry forward: the create path (and only the create path) calls entity.AddDomainEvent(new EventFeedbackSubmitted(userId, entity.Id, timeProvider.GetUtcNow().UtcDateTime)) (:112) so the Engagement module can award feedback points once per user per event. Because the event is added to the aggregate before the save, the outbox captures it in the same SaveChangesAsync transaction as the answer row, which is the whole point of ADR-003: there is no window where the answer is persisted but the notification is lost, and no window where points are awarded for an answer that rolled back. Editing an existing answer raises nothing, so points cannot be farmed by resubmitting. [Rubric §11, Security] assesses whether identity is derived server-side: the answering user is ambient, not a request field. [Rubric §4, DDD]: reconciliation happens against children already loaded in the aggregate, so no second round trip and no chance of mutating an answer outside its event. [Rubric §6, CQRS & Event-Driven]: one command, one handler, one transaction boundary at SaveChangesAsync, and cross-module effects travel as an event rather than as a direct call into Engagement. [Rubric §7, Microservices Readiness]: Conference does not reference Engagement at all here; it publishes a fact and lets the other service decide what it is worth.
    • +
    • Depends on: IUnitOfWork (MMCA.ADC.Conference.Application/Events/UseCases/AddEventQuestionAnswer/AddEventQuestionAnswerHandler.cs:19), ICurrentUserService (AddEventQuestionAnswerHandler.cs:20), EventQuestionAnswerDTOMapper (AddEventQuestionAnswerHandler.cs:21), TimeProvider (BCL, AddEventQuestionAnswerHandler.cs:22), ILogger<AddEventQuestionAnswerHandler> (AddEventQuestionAnswerHandler.cs:23), the ICommandHandler<in TCommand, TResult> contract (AddEventQuestionAnswerHandler.cs:23), the Event aggregate with its EventQuestionAnswer children, EventInvariants (AddEventQuestionAnswerHandler.cs:40), Question plus QuestionInvariants (AddEventQuestionAnswerHandler.cs:65,77), EventFeedbackSubmitted (AddEventQuestionAnswerHandler.cs:6,112), and Result / Error.
    • +
    • Concept introduced, the add that is really an upsert (BR-107), and a domain event raised on one branch only. A user answering the same question twice must not accumulate rows; the second submission replaces the first. The handler encodes that as an identity lookup on the triple (current user, question, event): the event is already the aggregate being loaded, so the search reduces to scanning the loaded children for !a.IsDeleted && a.QuestionId == command.QuestionId && a.CreatedBy == userId (AddEventQuestionAnswerHandler.cs:51-52). The owner is read from ICurrentUserService (AddEventQuestionAnswerHandler.cs:50), never from the command, so a caller cannot answer on someone else's behalf. The second idea is the one to carry forward: the create path (and only the create path) calls entity.AddDomainEvent(new EventFeedbackSubmitted(userId, entity.Id, timeProvider.GetUtcNow().UtcDateTime)) (AddEventQuestionAnswerHandler.cs:112) so the Engagement module can award feedback points once per user per event. Because the event is added to the aggregate before the save, the outbox captures it in the same SaveChangesAsync transaction as the answer row, which is the whole point of ADR-003: there is no window where the answer is persisted but the notification is lost, and none where points are awarded for an answer that rolled back. The file states that reasoning inline (AddEventQuestionAnswerHandler.cs:109-111). Editing an existing answer raises nothing, so points cannot be farmed by resubmitting. [Rubric §11, Security] assesses whether identity is derived server-side: the answering user is ambient, not a request field. [Rubric §4, DDD] assesses aggregate integrity: reconciliation happens against children already loaded in the aggregate, so there is no second round trip and no chance of mutating an answer outside its event. [Rubric §6, CQRS & Event-Driven] assesses the write path: one command, one handler, one transaction boundary at SaveChangesAsync, and cross-module effects travel as an event rather than as a direct call into Engagement. [Rubric §7, Microservices Readiness] assesses coupling: Conference does not reference Engagement at all here; it publishes a fact and lets the other service decide what it is worth.
    • Walkthrough
        -
      • HandleAsync (:26-58) resolves the event repository from unitOfWork.GetRepository<Event, EventIdentifierType>() (:30) and loads with includes: [nameof(Event.EventQuestionAnswers)] and asTracking: true (:31-35), because the upsert scan walks that child collection and EF must be tracking it for the update branch to persist. A missing event returns Error.NotFound sourced to the handler and targeted at Event (:36-37).
      • -
      • BR-108 is checked by delegating to EventInvariants.EnsureEventIsPublished(entity.IsPublished, ...) (:40-42), which fails with the invariant code Event.NotPublished (MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:153-160).
      • -
      • The private ValidateQuestionAsync (:60-79) loads the Question by id (:65-66) and fails with the validation code Question.NotFoundOrWrongEntity when it is missing or its QuestionEntity != "Event" (BR-128, :67-74), then returns QuestionInvariants.EnsureAnswerValueMatchesQuestionType(question.QuestionType, command.AnswerValue, ...) (BR-124, :77-78), which switches on "Rating", "Text", or "Email" and rejects an unknown type outright (MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:115-126).
      • -
      • Only then does the upsert branch run (:54-57). An existing answer routes to UpdateExistingAnswerAsync (:81-94), which calls entity.UpdateEventQuestionAnswer(existingAnswer.Id, command.AnswerValue) (:87), saves (:91), and returns the mapped existing row (:93). Otherwise CreateNewAnswerAsync (:96-117) calls entity.AddEventQuestionAnswer(...) (:102-105), adds the EventFeedbackSubmitted notification (:112, with the reason spelled out in the comment at :109-111), saves (:114), and returns the mapped new child (:116).
      • +
      • HandleAsync (AddEventQuestionAnswerHandler.cs:26-58) resolves the event repository from unitOfWork.GetRepository<Event, EventIdentifierType>() (:30) and loads with includes: [nameof(Event.EventQuestionAnswers)] and asTracking: true (:31-35), because the upsert scan walks that child collection and EF must be tracking it for the update branch to persist. A missing event returns Error.NotFound sourced to the handler and targeted at Event (:36-37).
      • +
      • BR-108 is checked by delegating to EventInvariants.EnsureEventIsPublished(entity.IsPublished, ...) (:40-42), which fails with the invariant code Event.NotPublished and the message "This action requires the event to be published." (MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:156-163).
      • +
      • The private ValidateQuestionAsync (:60-79) loads the Question by id (:65-66) and fails with the validation code Question.NotFoundOrWrongEntity when it is missing or its QuestionEntity != "Event" (BR-128, :67-74), then returns QuestionInvariants.EnsureAnswerValueMatchesQuestionType(question.QuestionType, command.AnswerValue, ...) (BR-124, :77-78), which switches on "Rating", "Text", or "Email" and rejects an unknown type outright with Question.QuestionType.Unknown (MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:115-126).
      • +
      • Only then does the upsert branch run (:54-57). An existing answer routes to UpdateExistingAnswerAsync (:81-94), which calls entity.UpdateEventQuestionAnswer(existingAnswer.Id, command.AnswerValue) (:87), saves (:91), and returns the mapped existing row (:93). Otherwise CreateNewAnswerAsync (:96-117) calls entity.AddEventQuestionAnswer(...) (:102-105), adds the EventFeedbackSubmitted notification (:112), saves (:114), and returns the mapped new child (:116).
      • Both branches log through the source-generated LogQuestionAnswerAddedToEvent (:92, :115, declared :119-120).
    • -
    • Why it's built this way: the three gates run in increasing cost order, cheapest first: the aggregate is already loaded, the published flag is in memory, and only the question check costs a second query. Splitting the two upsert outcomes into private methods keeps HandleAsync readable as a decision tree while both paths share one save. Taking the timestamp from an injected TimeProvider rather than DateTime.UtcNow keeps the points-awarding path deterministic under test. The [LoggerMessage] source generator gives allocation-free structured logging ([Rubric §13, Observability & Operability]), and ConfigureAwait(false) on the infrastructure awaits follows the library convention (ADR-049).
    • -
    • Where it's used: dispatched by the answers controller through the CQRS pipeline (MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:58, EventQuestionAnswersController.cs:182). Downstream, the published EventFeedbackSubmitted is consumed by Engagement's EventFeedbackSubmittedPointsHandler (MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/EventFeedbackSubmittedPointsHandler.cs:26), registered on the Engagement service host (MMCA.ADC.Engagement.Service/Program.cs:301).
    • +
    • Why it's built this way: the three gates run in increasing cost order, cheapest first: the aggregate is already loaded, the published flag is in memory, and only the question check costs a second query. Splitting the two upsert outcomes into private methods keeps HandleAsync readable as a decision tree while both paths share one save. Taking the timestamp from an injected TimeProvider rather than DateTime.UtcNow keeps the points-awarding path deterministic under test. The [LoggerMessage] source generator gives allocation-free structured logging ([Rubric §13, Observability & Operability]), and ConfigureAwait(false) on the infrastructure awaits (:91, :114) follows the library convention (ADR-049).
    • +
    • Where it's used: dispatched by the answers controller through the CQRS pipeline (MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:59, EventQuestionAnswersController.cs:190). Downstream, the published EventFeedbackSubmitted is consumed by Engagement's EventFeedbackSubmittedPointsHandler (MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/EventFeedbackSubmittedPointsHandler.cs:26), wired on the Engagement service host by x.RegisterIntegrationEventConsumer<EventFeedbackSubmitted>() (MMCA.ADC.Engagement.Service/Program.cs:307, with the full consumer map documented at Program.cs:284-287). Covered by AddEventQuestionAnswerHandlerTests.
    • Caveats / not-in-source: currentUserService.UserId!.Value (AddEventQuestionAnswerHandler.cs:50) is null-forgiven, so the handler assumes an authenticated caller; the enforcement of that assumption lives in the controller's authorization attributes, not here. The success log message is identical for the update and insert branches (:119), so the log alone does not distinguish an upsert from a new answer; only the presence of the outbox row does.

    AddEventSpeakerCommandValidator

    @@ -2874,10 +3147,10 @@

    AddEventSpeakerCommandValidator

    • What it is: the FluentValidation validator for AddEventSpeakerCommand. It rejects a default (empty) speaker id.
    • -
    • Depends on: AbstractValidator<AddEventSpeakerCommand> from FluentValidation (AddEventSpeakerCommandValidator.cs:1,8) and the SpeakerIdentifierType alias.
    • -
    • Concept: the same inline-validator shape as AddEventQuestionAnswerCommandValidator, applied to an identifier instead of a string. NotEqual(default(SpeakerIdentifierType)) (AddEventSpeakerCommandValidator.cs:12) is written against the alias rather than a concrete type, so the rule keeps working if the module's speaker key type changes: the alias is System.Guid today (MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:18), which makes this an "id is not Guid.Empty" check. [Rubric §24, Forms, Validation & UX Safety].
    • +
    • Depends on: AbstractValidator<AddEventSpeakerCommand> from FluentValidation (MMCA.ADC.Conference.Application/Events/UseCases/AddEventSpeaker/AddEventSpeakerCommandValidator.cs:1,8) and the SpeakerIdentifierType alias.
    • +
    • Concept: the same inline-validator shape as AddEventQuestionAnswerCommandValidator, applied to an identifier instead of a string. NotEqual(default(SpeakerIdentifierType)) (AddEventSpeakerCommandValidator.cs:12) is written against the alias rather than a concrete type, so the rule keeps working if the module's speaker key type changes. [Rubric §24, Forms, Validation & UX Safety] assesses boundary rejection: an unset identifier is caught before any database work.
    • Walkthrough: an expression-bodied constructor (AddEventSpeakerCommandValidator.cs:10) with one rule, RuleFor(x => x.SpeakerId).NotEqual(default(SpeakerIdentifierType)).WithMessage("Speaker ID is required.") (AddEventSpeakerCommandValidator.cs:11-13).
    • -
    • Why it's built this way: an empty identifier is a client bug worth catching before a database round trip; whether the speaker actually exists, and whether it is already linked to this event, are questions only the aggregate can answer, so those stay in Event.AddEventSpeaker (MMCA.ADC.Conference.Domain/Events/Event.cs:515-522).
    • +
    • Why it's built this way: an empty identifier is a client bug worth catching before a database round trip; whether the speaker actually exists, and whether it is already linked to this event, are questions only the aggregate can answer, so those stay in Event.AddEventSpeaker (MMCA.ADC.Conference.Domain/Events/Event.cs:544-551).
    • Where it's used: run by the pipeline's validation decorator ahead of AddEventSpeakerHandler.
    • Caveats / not-in-source: unlike its sibling in this unit, this rule sets no WithErrorCode, so the failure surfaces with FluentValidation's default code rather than a stable dotted code (AddEventSpeakerCommandValidator.cs:11-13). Whether that is deliberate is not stated in source.
    @@ -2887,11 +3160,11 @@

    AddEventSpeakerHandler

    • What it is: the handler that associates a speaker with an event. It is the reference "load aggregate, call a domain method, save, map" shape with nothing else layered on.
    • -
    • Depends on: IUnitOfWork (AddEventSpeakerHandler.cs:16), EventSpeakerDTOMapper (:17), ILogger<AddEventSpeakerHandler> (:18), the ICommandHandler<in TCommand, TResult> contract returning Result<EventSpeakerDTO> (:18), the Event aggregate, and Result / Error.
    • -
    • Concept: read this one first if you want the skeleton every other handler in the unit decorates. Four moves and no branching beyond the two failure exits: load, delegate, save, map. Note what is absent: no ownership check (that is UpdateEventQuestionAnswerHandler), no id allocation (that is AddRoomHandler), no upsert and no integration event (that is AddEventQuestionAnswerHandler). The one non-obvious move is the include list, and the file explains it in a comment (:27-28): the join collection has to be loaded or the aggregate's duplicate check runs against an empty in-memory list and a double submit surfaces as a raw unique-index 409 instead of a clean domain error. [Rubric §5, Vertical Slice] assesses whether a use case is self-contained: the command, validator, and handler live in one folder and share no base class. [Rubric §4, DDD]: the duplicate-association rule lives on the aggregate (MMCA.ADC.Conference.Domain/Events/Event.cs:515-522), so the handler never re-implements it, but the handler is responsible for loading enough state for that rule to be evaluable.
    • +
    • Depends on: IUnitOfWork (MMCA.ADC.Conference.Application/Events/UseCases/AddEventSpeaker/AddEventSpeakerHandler.cs:16), EventSpeakerDTOMapper (AddEventSpeakerHandler.cs:17), ILogger<AddEventSpeakerHandler> (AddEventSpeakerHandler.cs:18), the ICommandHandler<in TCommand, TResult> contract returning Result<EventSpeakerDTO> (AddEventSpeakerHandler.cs:18), the Event aggregate, and Result / Error.
    • +
    • Concept: read this one first if you want the skeleton every other handler in the unit decorates. Four moves and no branching beyond the two failure exits: load, delegate, save, map. Note what is absent: no ownership check (that is UpdateEventQuestionAnswerHandler), no id allocation (that is AddRoomHandler), no upsert and no integration event (that is AddEventQuestionAnswerHandler). The one non-obvious move is the include list, and the file explains it in a comment (AddEventSpeakerHandler.cs:27-28): the join collection has to be loaded, or the aggregate's duplicate check runs against an empty in-memory list and a double submit surfaces as a raw unique-index 409 instead of a clean domain error. [Rubric §5, Vertical Slice] assesses whether a use case is self-contained: the command, validator, and handler live in one folder and share no base class. [Rubric §4, DDD] assesses where rules live: the duplicate-association rule is on the aggregate (MMCA.ADC.Conference.Domain/Events/Event.cs:544-551), so the handler never re-implements it, but the handler is responsible for loading enough state for that rule to be evaluable.
    • Walkthrough: HandleAsync (AddEventSpeakerHandler.cs:21-44) resolves the event repository from the unit of work (:25) and loads by id with [nameof(Event.EventSpeakers)] and asTracking: true (:29), returning Error.NotFound sourced to the handler and targeted at Event when absent (:30-31). It calls entity.AddEventSpeaker(command.EventSpeakerId, command.SpeakerId) (:33-35) and propagates the aggregate's own errors verbatim on failure (:36-37). On success it persists with SaveChangesAsync(...).ConfigureAwait(false) (:39), logs the source-generated LogSpeakerAddedToEvent with both ids (:41, declared :46-47), and returns Result.Success(eventSpeakerDTOMapper.MapToDTO(result.Value!)) (:43).
    • -
    • Why it's built this way: returning the domain result's errors rather than a handler-authored message preserves the aggregate's error code (Event.Speaker.Duplicate, Event.cs:518) all the way to the API response, which is what turns a concurrent double submit into a predictable, client-parseable conflict rather than a database-shaped exception.
    • -
    • Where it's used: dispatched by the event-speakers controller through the CQRS pipeline (MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:48, EventSpeakersController.cs:215).
    • +
    • Why it's built this way: returning the domain result's errors rather than a handler-authored message preserves the aggregate's error code (Event.Speaker.Duplicate, MMCA.ADC.Conference.Domain/Events/Event.cs:547) all the way to the API response, which is what turns a concurrent double submit into a predictable, client-parseable conflict rather than a database-shaped exception.
    • +
    • Where it's used: dispatched by the event-speakers controller through the CQRS pipeline (MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:49, EventSpeakersController.cs:223). Covered by AddEventSpeakerHandlerTests.

    AddRoomCommandValidator

    @@ -2899,10 +3172,10 @@

    AddRoomCommandValidator

    • What it is: the FluentValidation validator for AddRoomCommand, assembled from six reusable per-field rule sets rather than written inline.
    • -
    • Depends on: AbstractValidator<AddRoomCommand> (FluentValidation, AddRoomCommandValidator.cs:1,7) and the module's room rule sets RoomNameRules<T>, RoomSortRules<T>, RoomCapacityRules<T>, RoomFloorRules<T>, RoomLocationRules<T>, and RoomAccessibilityInfoRules<T> from Events.Validation (AddRoomCommandValidator.cs:2,11-16).
    • -
    • Concept introduced, rule composition via Include. FluentValidation's Include(...) folds another validator's rules into this one, and requires both validators to be generic over the same T. That is why each rule set is generic in the request type and takes a property selector: the same RoomNameRules<T> object validates a room name on this command, on UpdateRoomCommand, and on the Sessionize import path, each pointing at its own property. The alternative, restating the clauses per command, is what lets a constraint drift between the add and update endpoints. [Rubric §15, Best Practices & Code Quality] assesses duplication: there is exactly one definition of each room-field constraint. [Rubric §16, Maintainability]: a constraint change is a one-file edit that propagates to every command that composed the rule.
    • -
    • Walkthrough: the constructor is six Include calls in field order, each constructing a rule set bound to the matching property: RoomNameRules on p => p.Name (AddRoomCommandValidator.cs:11), RoomSortRules on Sort (:12), RoomCapacityRules on Capacity (:13), RoomFloorRules on Floor (:14), RoomLocationRules on Location (:15), and RoomAccessibilityInfoRules on AccessibilityInfo (:16). No bespoke rule appears in this class; every constraint lives in the included types (MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:12, :25, :37, :51, :64, :77). Two of those bodies are worth reading: the name rule is required-plus-max-length against EventInvariants.RoomNameMaxLength with the stable codes Room.Name.Required and Room.Name.MaxLength (RoomValidationRules.cs:16-18), and the capacity rule is a positive-value check wrapped in a When(...) so it applies only when a capacity was supplied (RoomValidationRules.cs:40-43).
    • -
    • Why it's built this way: rooms are written from two directions (organizer create and Sessionize import), and shared rule sets are the mechanism that keeps both honest without a base validator class. The length ceilings come from EventInvariants (MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:40), the same constants the EF entity configuration applies to the columns, so validation and schema cannot drift. Its update counterpart UpdateRoomCommandValidator composes the same six sets, which is the clearest demonstration that the composition is the point.
    • +
    • Depends on: AbstractValidator<AddRoomCommand> (FluentValidation, MMCA.ADC.Conference.Application/Events/UseCases/AddRoom/AddRoomCommandValidator.cs:1,7) and the module's room rule sets RoomNameRules<T>, RoomSortRules<T>, RoomCapacityRules<T>, RoomFloorRules<T>, RoomLocationRules<T>, and RoomAccessibilityInfoRules<T> from Events.Validation (AddRoomCommandValidator.cs:2,11-16).
    • +
    • Concept introduced, rule composition via Include. FluentValidation's Include(...) folds another validator's rules into this one, and requires both validators to be generic over the same T. That is why each rule set is generic in the request type and takes a property selector: the same RoomNameRules<T> object validates a room name on this command and on UpdateRoomCommand, each pointing at its own property. The alternative, restating the clauses per command, is what lets a constraint drift between the add and update endpoints. [Rubric §15, Best Practices & Code Quality] assesses duplication: there is exactly one definition of each room-field constraint. [Rubric §16, Maintainability] assesses change cost: a constraint change is a one-file edit that propagates to every command that composed the rule.
    • +
    • Walkthrough: the constructor is six Include calls in field order, each constructing a rule set bound to the matching property: RoomNameRules on p => p.Name (AddRoomCommandValidator.cs:11), RoomSortRules on Sort (:12), RoomCapacityRules on Capacity (:13), RoomFloorRules on Floor (:14), RoomLocationRules on Location (:15), and RoomAccessibilityInfoRules on AccessibilityInfo (:16). No bespoke rule appears in this class; every constraint lives in the included types (MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:12, :25, :37, :51, :64, :77). Two of those bodies are worth reading: the name rule is required-plus-max-length against EventInvariants.RoomNameMaxLength with the stable codes Room.Name.Required and Room.Name.MaxLength (RoomValidationRules.cs:17-18), and the capacity rule is a GreaterThan(0) check wrapped in a When(...) so it applies only when a capacity was supplied (RoomValidationRules.cs:42-43).
    • +
    • Why it's built this way: shared rule sets are the mechanism that keeps the add and update endpoints honest without a base validator class. The length ceilings come from EventInvariants (MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:43, where RoomNameMaxLength is 255), the same constants the domain guard and the EF entity configuration apply, so validation, invariant, and schema cannot drift. Its update counterpart UpdateRoomCommandValidator composes the same six sets, which is the clearest demonstration that the composition is the point.
    • Where it's used: resolved and invoked by the pipeline's validation decorator (Group 05) for AddRoomCommand, before AddRoomHandler runs.

    AddRoomHandler

    @@ -2911,254 +3184,225 @@

    AddRoomHandler

    • What it is: the handler that adds a room to an event. It carries two concerns no other handler in this unit has: it allocates the room's primary key itself from a reserved range, and it retries in a fresh DI scope when a concurrent add wins the same key.
    • -
    • Depends on: IUnitOfWork (AddRoomHandler.cs:19), IServiceScopeFactory from Microsoft.Extensions.DependencyInjection (:1,20), RoomDTOMapper (:21), ILogger<AddRoomHandler> (:22), the ICommandHandler<in TCommand, TResult> contract (:22), the Event and Room types, EventInvariants for the reserved-range constants (:96,102,104), IReadRepository<TEntity, TIdentifierType> via GetReadRepository (:93), and Result / Error.
    • -
    • Concept introduced, reserved-range key allocation with a bounded, index-aware collision retry. Room ids are application-assigned: the integer primary key is the Sessionize id, so a database identity column cannot own the value or an import would overwrite an organizer's room. The codebase reserves a high block for manually created rooms, EventInvariants.RoomManualIdRangeStart = 999_999_000 through RoomManualIdRangeEnd = 999_999_999 (MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:59,62), and has the handler pick the next free id inside it. That read-then-write is inherently racy across concurrent requests, so the handler wraps the whole attempt in a bounded retry keyed on a duplicate-key failure. The refinement worth studying is that not every duplicate-key failure is retryable: rooms also carry a unique index on (EventId, Name), and recomputing an id would never clear a name conflict, so the handler names that index in a const and excludes it from the retry filter (AddRoomHandler.cs:31, :140-141). Without that carve-out a genuine duplicate-name request would burn all three attempts before the caller finally got the conflict. [Rubric §8, Data Architecture] assesses id strategy and ownership of key space: a reserved range keeps two writers (the app and Sessionize) in one integer column without a coordination service. [Rubric §29, Resilience & Business Continuity] assesses whether transient conflicts are absorbed rather than surfaced, and whether non-transient ones are correctly not retried: three attempts (:25), and only for the collision class that a retry can actually fix. [Rubric §13, Observability & Operability]: the collision path logs a warning naming the attempt number (:153-154), so the race is visible in telemetry instead of silent.
    • +
    • Depends on: IUnitOfWork (MMCA.ADC.Conference.Application/Events/UseCases/AddRoom/AddRoomHandler.cs:19), IServiceScopeFactory from Microsoft.Extensions.DependencyInjection (AddRoomHandler.cs:1,20), RoomDTOMapper (AddRoomHandler.cs:21), ILogger<AddRoomHandler> (AddRoomHandler.cs:22), the ICommandHandler<in TCommand, TResult> contract (AddRoomHandler.cs:22), the Event and Room types, EventInvariants for the reserved-range constants (AddRoomHandler.cs:96,102,104), IReadRepository<TEntity, TIdentifierType> via GetReadRepository (AddRoomHandler.cs:93), and Result / Error.
    • +
    • Concept introduced, reserved-range key allocation with a bounded, index-aware collision retry. Room ids are application-assigned: the integer primary key is the Sessionize id, so a database identity column cannot own the value or an import would overwrite an organizer's room (AddRoomHandler.cs:87-89). The codebase reserves a high block for manually created rooms, EventInvariants.RoomManualIdRangeStart = 999_999_000 through RoomManualIdRangeEnd = 999_999_999 (MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:62,65), and has the handler pick the next free id inside it. That read-then-write is inherently racy across concurrent requests, so the handler wraps the whole attempt in a bounded retry keyed on a duplicate-key failure. The refinement worth studying is that not every duplicate-key failure is retryable: rooms also carry a unique index on (EventId, Name), and recomputing an id would never clear a name conflict, so the handler names that index in a const and excludes it from the retry filter (AddRoomHandler.cs:31, AddRoomHandler.cs:140-141). Without that carve-out a genuine duplicate-name request would burn all three attempts before the caller finally got the conflict. [Rubric §8, Data Architecture] assesses id strategy and ownership of key space: a reserved range keeps two writers (the app and Sessionize) in one integer column without a coordination service. [Rubric §29, Resilience & Business Continuity] assesses whether transient conflicts are absorbed rather than surfaced, and whether non-transient ones are correctly not retried: three attempts (AddRoomHandler.cs:25), and only for the collision class a retry can actually fix. [Rubric §13, Observability & Operability] assesses whether the race is visible: the collision path logs a warning naming the attempt number and the budget (AddRoomHandler.cs:153-154).
    • Walkthrough: three parts.
        -
      • HandleAsync (:34-67) is pure retry policy. An explicit command.RoomId short-circuits to a single attempt with no recomputation, because a collision on a caller-supplied id (a Sessionize import, say) is a genuine caller error (:38-41). Otherwise it loops: attempt 1 runs against the ambient unit of work (:49-50); every later attempt opens scopeFactory.CreateAsyncScope() and resolves a fresh IUnitOfWork from it (:57-59), because the ambient DbContext still tracks the failed insert and the whole body (including the event load) must re-run against a clean context, as the comment at :52-56 explains. The catch filter is narrow, attempt < MaxManualIdAttempts && IsUniqueKeyViolation(ex) (:61), so anything else propagates.
      • -
      • AddRoomCoreAsync (:74-126) is one attempt: resolve the repository (:79), load the event with [nameof(Event.Rooms)] and asTracking: true (:83, and the comment at :81-82 says why: without the include, the aggregate's duplicate-name check runs against an empty list), fail NotFound if absent (:84-85); when command.RoomId is null, take a read repository for Room and GetAllAsync every room in the reserved range with ignoreQueryFilters: true so a soft-deleted room still reserves its id (:93-98), compute Max(r => r.Id) + 1 or the range start when empty (:100-102), and fail with Error.Failure(..., "Manual room ID range exhausted.") if that passes the range end (:104-105); then delegate to entity.AddRoom(roomId, command.Name, command.Sort, command.Capacity, command.Floor, command.Location, command.AccessibilityInfo) (:110-117), propagate domain errors (:118-119), SaveChangesAsync (:121), log LogRoomAdded (:123, declared :150-151), and return the mapped RoomDTO (:125).
      • +
      • HandleAsync (AddRoomHandler.cs:34-67) is pure retry policy. An explicit command.RoomId short-circuits to a single attempt with no recomputation, because a collision on a caller-supplied id is a genuine caller error (:38-41). Otherwise it loops: attempt 1 runs against the ambient unit of work (:49-50); every later attempt opens scopeFactory.CreateAsyncScope() and resolves a fresh IUnitOfWork from it (:57-59), because the ambient DbContext still tracks the failed insert and the whole body (including the event load) must re-run against a clean context, as the comment at :52-56 explains. The catch filter is narrow, attempt < MaxManualIdAttempts && IsUniqueKeyViolation(ex) (:61), so anything else propagates.
      • +
      • AddRoomCoreAsync (:74-126) is one attempt: resolve the repository (:79), load the event with [nameof(Event.Rooms)] and asTracking: true (:83, with the comment at :81-82 explaining that without the include the aggregate's duplicate-name check runs against an empty list), fail NotFound if absent (:84-85); when command.RoomId is null, take a read repository for Room and GetAllAsync every room in the reserved range with ignoreQueryFilters: true so a soft-deleted room still reserves its id (:93-98), compute Max(r => r.Id) + 1 or the range start when the set is empty (:100-102), and fail with Error.Failure(..., "Manual room ID range exhausted.") if that passes the range end (:104-105); then delegate to entity.AddRoom(roomId, command.Name, command.Sort, command.Capacity, command.Floor, command.Location, command.AccessibilityInfo) (:110-117), propagate domain errors (:118-119), SaveChangesAsync on the attempt's unit of work (:121), log LogRoomAdded (:123, declared :150-151), and return the mapped RoomDTO (:125).
      • IsUniqueKeyViolation (:136-148) walks the whole InnerException chain (:138) looking for the substring "duplicate key" with OrdinalIgnoreCase while excluding any message that also names RoomNameIndexName (:140-141).
    • -
    • Why it's built this way: the aggregate owns room creation invariants, while the handler owns the one concern no single event can decide, namely an id range that is global across all events (AddRoomHandler.cs:87-89). Detection is message-based rather than typed on a SQL exception because the Application layer is not allowed to reference EF Core or provider types (:128-135), which is Clean Architecture's dependency rule paying a small cost in precision; the comment records that both SQL Server errors 2601 and 2627 report "duplicate key". Retrying in a new scope instead of reusing the failed one is the load-bearing detail: reusing the attempt's already-mutated Event would append a second room and re-raise its domain event, and attaching that instance to another context is not permitted (:52-56).
    • -
    • Where it's used: dispatched by the rooms controller through the CQRS pipeline for AddRoomCommand (MMCA.ADC.Conference.API/Controllers/RoomsController.cs:87, RoomsController.cs:150).
    • +
    • Why it's built this way: the aggregate owns room creation invariants, while the handler owns the one concern no single event can decide, namely an id range that is global across all events (AddRoomHandler.cs:87-89). Detection is message-based rather than typed on a SQL exception because the Application layer is not allowed to reference EF Core or provider types (AddRoomHandler.cs:128-135), which is Clean Architecture's dependency rule paying a small cost in precision; the comment records that both SQL Server errors 2601 and 2627 report "duplicate key". Retrying in a new scope instead of reusing the failed one is the load-bearing detail: reusing the attempt's already-mutated Event would append a second room and re-raise its RoomChanged event, and attaching that instance to another context is not permitted (AddRoomHandler.cs:52-56).
    • +
    • Where it's used: dispatched by the rooms controller through the CQRS pipeline for AddRoomCommand (MMCA.ADC.Conference.API/Controllers/RoomsController.cs:94, RoomsController.cs:262-272). Covered by AddRoomHandlerTests.
    • Caveats / not-in-source: the RoomNameIndexName constant's doc comment says the index is "declared in the Infrastructure entity configuration" (AddRoomHandler.cs:27-31), but the configuration declares it without an explicit name, builder.HasIndex(p => new { p.EventId, p.Name }).IsUnique().HasSoftDeleteFilter() (MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/RoomConfiguration.cs:52-54); the literal IX_Room_EventId_Name is EF's conventional name and appears verbatim only in the migration that created it (MMCA.ADC.Migrations.SqlServer.Conference/Migrations/20260813223101_AddRoomNameUniqueIndex.cs:46). Nothing in source pins the two together, so renaming the index would silently re-arm the retry loop for name conflicts. Separately, an attempt that fails for a non-duplicate reason is not retried at all (the filter excludes it) and surfaces as a thrown exception to the pipeline's exception middleware, and the message-based match would not recognize a provider that words its duplicate-key error differently.
    -

    EventCreateRequestMapper

    +

    EventDTOMapper

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Create · MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequestMapper.cs:11 · Level 9 · class (sealed)

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.DTOs · MMCA.ADC.Conference.Application/Events/DTOs/EventDTOMapper.cs:14 · Level 9 · class (sealed partial)

      -
    • What it is: the one adapter that turns an EventCreateRequest into an Event domain entity, by calling the aggregate's Create factory and returning whatever Result it produces.
    • -
    • Depends on: IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> from MMCA.Common.Application.Interfaces (EventCreateRequestMapper.cs:2,11-12), the Event aggregate and its EventIdentifierType alias (EventCreateRequestMapper.cs:1), and Result from MMCA.Common.Shared.Abstractions (EventCreateRequestMapper.cs:3,15).
    • -
    • Concept introduced, request-to-entity mapping as a separate injectable role. The generic create pipeline never constructs entities itself; it resolves an IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> and asks it for one. That indirection is what lets one handler shape serve every aggregate while each aggregate keeps its own construction rules. Two properties of this mapper matter. First, it is a plain sealed class with no [Mapper] attribute (EventCreateRequestMapper.cs:11): unlike the read-side DTO mappers, request-to-entity conversion is deliberately hand-written, because it must go through a factory that can fail, which a property-copy generator cannot express. Second, it returns Task<Result<Event>> rather than an Event, so an invalid request produces a failure value that flows back through the handler as a 400-class response instead of an exception. [Rubric §3, Clean Architecture] assesses whether the domain stays independent of the delivery mechanism: the controller knows a request type, the domain knows a factory, and this class is the only thing that knows both. [Rubric §4, Domain-Driven Design]: the factory stays the single construction path, so no invariant can be bypassed by new. See ADR-001 for the no-reflection mapping policy.
    • -
    • Walkthrough: one method. CreateEntityAsync(EventCreateRequest request, CancellationToken) (EventCreateRequestMapper.cs:15) null-guards with ArgumentNullException.ThrowIfNull(request) (:17), then returns Task.FromResult(Event.Create(...)) (:19-31). The first ten arguments are forwarded positionally, Id, Name, Description, StartDate, EndDate, TimeZone, SessionizeCode, VenueAddress, VenueMapUrl, WiFiInfo (:20-29), and the last two are passed by name, organizerContactEmail: and sponsorshipPacketUrl: (:30-31). The named form is not decoration: the factory's eleventh parameter sits between them and the positional block, questionModerationDefault (MMCA.ADC.Conference.Domain/Events/Event.cs:166), and it is deliberately not forwarded, so a newly created event takes the declared default QuestionModerationDefault.Pending and starts with moderated live-layer questions (BR-233). The method is synchronous in substance: Task.FromResult satisfies the async contract without allocating a state machine, because the factory does no I/O. The forwarded Id is discarded by the factory when the aggregate's key is store-generated (Event.cs:177,192).
    • -
    • Why it's built this way: pushing construction into Event.Create means the invariant checks combined at Event.cs:170-173 (EnsureNameIsValid, EnsureTimeZoneIsValid, EnsureDateRangeIsValid) run even for callers that never touch the HTTP layer, such as the Sessionize import, and the EventChanged domain event is raised inside the factory (Event.cs:196) rather than by any caller. The mapper adds no rules of its own, which is exactly what makes it safe to have several entry points.
    • -
    • Where it's used: injected into CreateEventHandler as IEntityRequestMapper<Event, EventCreateRequest, EventIdentifierType> (CreateEventHandler.cs:18), registered by the module's assembly scanning rather than an explicit AddScoped line (MMCA.ADC.Conference.Application/DependencyInjection.cs).
    • +
    • What it is: the read-side mapper for the Event aggregate. It is the composite of the family: it maps the root, delegates its three child collections to the child mappers, then applies one hand-written fix-up the generator cannot express.
    • +
    • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> (MMCA.ADC.Conference.Application/Events/DTOs/EventDTOMapper.cs:3,18), Mapperly (EventDTOMapper.cs:4,13,20,23,26,50), RoomDTOMapper, EventSpeakerDTOMapper, and EventQuestionAnswerDTOMapper (EventDTOMapper.cs:15-17), the Event aggregate (EventDTOMapper.cs:1), EventDTO from the Shared project (EventDTOMapper.cs:2), and System.Globalization.CultureInfo (BCL, EventDTOMapper.cs:39).
    • +
    • Concept introduced, composing generated mappers and escaping to hand-written code. Two Mapperly features carry this class. [UseMapper] on a field (EventDTOMapper.cs:20,23,26) tells the generator "when you need to map a Room, an EventSpeaker, or an EventQuestionAnswer, call this instance instead of generating a second copy", which is how an aggregate DTO gets its child collections filled without duplicating child mapping logic. [MapperIgnoreTarget] (EventDTOMapper.cs:50) is the escape hatch: it tells the generator to leave one target member alone so the class can set it itself. The member in question is LastSessionizeRefreshBy, a UserIdentifierType? on the entity (MMCA.ADC.Conference.Domain/Events/Event.cs:83) but a string? on the DTO (MMCA.ADC.Conference.Shared/Events/EventDTO.cs:63); a nullable-value-type-to-string conversion is not something the generator will invent, and the file's own comment says so (EventDTOMapper.cs:35). The general lesson is that a source generator is not all-or-nothing: keep generation for the ninety percent of straight property copies and hand-write only the member that needs a decision. [Rubric §12, Performance & Scalability] assesses avoidable runtime work on hot paths: the whole event read path, including children, is generated assignment plus one with expression. [Rubric §9, API & Contract Design] assesses wire shape: the DTO's own types are chosen for the wire (an id rendered as a string), and this class is where the two type systems meet. [Rubric §15, Best Practices & Code Quality] assesses correctness details: the conversion uses CultureInfo.InvariantCulture (EventDTOMapper.cs:38-39) rather than the ambient culture, so the value is stable regardless of server locale.
    • +
    • Walkthrough: the primary constructor takes the three child mappers (EventDTOMapper.cs:14-17) and assigns each to a [UseMapper]-annotated readonly field (EventDTOMapper.cs:20-27). The public MapToDTO(Event entity) (:30-41) is hand-written: it null-guards (:32), calls the private generated MapToDTOGenerated(entity) (:33), and then returns a with expression that sets the one ignored member, LastSessionizeRefreshBy = entity.LastSessionizeRefreshBy?.ToString(System.Globalization.CultureInfo.InvariantCulture) (:36-40). Because EventDTO is a record, the with copy is a cheap shallow clone that leaves every generated assignment intact. MapToDTOs (:44-48) is the same null-guarded spread projection as its siblings. The generated method itself is declared last, private partial EventDTO MapToDTOGenerated(Event entity) carrying [MapperIgnoreTarget(nameof(EventDTO.LastSessionizeRefreshBy))] (:50-51).
    • +
    • Why it's built this way: making the public method the wrapper and the generated method private means no caller can accidentally bypass the fix-up and receive a DTO with a null LastSessionizeRefreshBy. Keeping the child mappers injected rather than generated inline means the same RoomDTO shape is produced whether a room is read directly or as part of an event, per ADR-001.
    • +
    • Where it's used: injected into CreateEventHandler (MMCA.ADC.Conference.Application/Events/UseCases/Create/CreateEventHandler.cs:19) and UpdateEventHandler (MMCA.ADC.Conference.Application/Events/UseCases/Update/UpdateEventHandler.cs:18), and resolved as IEntityDTOMapper<Event, EventDTO, EventIdentifierType> by the event query service (MMCA.ADC.Conference.Application/DependencyInjection.cs:59, constructor parameter at MMCA.Common.Application/Services/EntityQueryService.cs:35). Registration is by the module's convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:125). Tested by EventDTOMapperTests.
    -

    EventCreateRequestValidator

    +

    EventCreateRequest

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Create · MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequestValidator.cs:7 · Level 9 · class (sealed)

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Create · MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequest.cs:10 · Level 8 · record

      -
    • What it is: the FluentValidation validator the pipeline runs against an EventCreateRequest before CreateEventHandler sees it. It contains no rules of its own: it is five Include calls.
    • -
    • Depends on: FluentValidation.AbstractValidator<T> (NuGet, EventCreateRequestValidator.cs:1,7) and the five shared rule objects EventNameRules<T>, EventTimeZoneRules<T>, EventDateRangeRules<T>, EventOrganizerContactEmailRules<T>, and EventSponsorshipPacketUrlRules<T> from MMCA.ADC.Conference.Application.Events.Validation (EventCreateRequestValidator.cs:2,11-15).
    • -
    • Concept introduced, rule composition by Include with a property selector. FluentValidation's Include(otherValidator) copies every rule from another AbstractValidator<T> into this one, and it requires both validators to be generic over the same T. That is why the rule classes are generic in the containing type and take an expression selector in their constructor: new EventNameRules<EventCreateRequest>(p => p.Name) (EventCreateRequestValidator.cs:11) says "apply the event-name rules to this type's Name property". The same rule classes are re-included by EventUpdateRequestValidator against a different request type, so a length or format constraint is written once and both slices inherit it: there is no way for create and update to drift apart on what a valid event name is. [Rubric §24, Forms, Validation & UX Safety] assesses whether invalid input is rejected at the boundary with actionable messages: the request never reaches the aggregate when a rule fails. [Rubric §16, Maintainability]: a shared rule object is one edit point instead of N. [Rubric §1, SOLID]: each rule class has one reason to change, and validators compose them rather than inherit a fat base.
    • -
    • Walkthrough: the whole type is a constructor (EventCreateRequestValidator.cs:9-16) with five Include calls: EventNameRules on p => p.Name (:11), EventTimeZoneRules on p => p.TimeZone (:12), EventDateRangeRules on the pair p => p.StartDate, p => p.EndDate (:13), EventOrganizerContactEmailRules on p => p.OrganizerContactEmail! (:14), and EventSponsorshipPacketUrlRules on p => p.SponsorshipPacketUrl (:15). The date-range rule takes two selectors because it is a cross-field rule, which is the reason it cannot be expressed as a per-property attribute (MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:91-102). The two optional-field rule sets share a shape worth noticing: each compiles its selector once and wraps the shared rule set in When(x => !string.IsNullOrWhiteSpace(accessor(x)), ...) (EventValidationRules.cs:60-66 and :78-84), so an omitted contact email or packet URL is not an error while a supplied one is fully checked, the email against the framework EmailRules<T> and the URL against OptionalStringRules<T> with EventInvariants length ceilings. The time-zone rule is the one with real logic: required, length-capped, and Must(BeAValidIanaTimeZone) resolving the string through TimeZoneInfo.FindSystemTimeZoneById with the error code Event.TimeZone.InvalidIana (EventValidationRules.cs:28-42, BR-87).
    • -
    • Why it's built this way: validating at the pipeline boundary gives the caller a complete, field-addressed error list in one round trip, while the domain factory keeps its own invariant checks as the backstop for non-HTTP callers. The duplication is intentional and cheap because both sides read the same EventInvariants limits.
    • -
    • Where it's used: resolved by the validation decorator around CreateEventHandler; the update-side sibling is EventUpdateRequestValidator.
    • -
    • Caveats / not-in-source: the organizer-contact-email selector is null-forgiven (EventCreateRequestValidator.cs:14) so it can bind a string? property to a rule set typed on string. The null case is handled by the When guard inside the rule set (EventValidationRules.cs:64-65), not by anything visible in this file.
    • +
    • What it is: the inbound payload for creating a conference Event, and simultaneously the command that the CQRS pipeline dispatches. There is no separate CreateEventCommand: the request record is the command, and CreateEventHandler is registered as ICommandHandler<EventCreateRequest, Result<EventDTO>> (MMCA.ADC.Conference.Application/Events/UseCases/Create/CreateEventHandler.cs:20).
    • +
    • Depends on: ICreateRequest from MMCA.Common.Application.Interfaces (EventCreateRequest.cs:2,10), ICacheInvalidating from MMCA.Common.Application.UseCases (EventCreateRequest.cs:3,10), the Event domain type referenced only to build the cache prefix (EventCreateRequest.cs:1,13), the EventIdentifierType module alias (ADR-048, EventCreateRequest.cs:16), and DateOnly from the BCL (EventCreateRequest.cs:25,28).
    • +
    • Concept introduced, the create request as a dual-purpose contract. Two marker interfaces do all the wiring here, and neither adds a member the author has to implement by hand except one property. ICreateRequest is what allows the type to be the TCreateRequest argument of AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest> (MMCA.ADC.Conference.API/Controllers/EventsController.cs:58), so the base controller can accept it as a [FromBody] model and hand it straight to a command handler (MMCA.Common.API/Controllers/AggregateRootEntityControllerBase.cs:63-67). ICacheInvalidating requires one property, CachePrefix (MMCA.Common.Application/UseCases/ICacheInvalidating.cs:14), and the caching decorator evicts every cached entry under that prefix after the command succeeds (MMCA.Common.Application/UseCases/Decorators/CachingCommandDecorator.cs:76-89). The prefix here is keyed on the aggregate type, $"{typeof(Event).FullName}:" (EventCreateRequest.cs:13), which is the same convention the framework's own generic delete command uses (MMCA.Common.Application/UseCases/DeleteEntityCommand.cs:20). [Rubric §9, API & Contract Design] assesses whether the wire contract is explicit and stable: required on Name, StartDate, EndDate and TimeZone (EventCreateRequest.cs:19,25,28,31) makes the mandatory set a compile-time fact for every in-process caller and a model-binding fact for HTTP callers, and the remaining nine fields are explicitly nullable rather than empty-string-as-absent. [Rubric §10, Cross-Cutting] assesses whether concerns like caching live in one place: this record declares what to evict and never touches a cache API.
    • +
    • Walkthrough: public record class EventCreateRequest : ICreateRequest, ICacheInvalidating (EventCreateRequest.cs:10), then the computed CachePrefix (EventCreateRequest.cs:13). The payload is thirteen init-only properties: Id (:16), the required Name (:19), optional Description (:22), the required StartDate and EndDate (:25,28), the required IANA TimeZone (:31), then six optional strings, SessionizeCode (:34), VenueAddress (:37), VenueMapUrl (:40), WiFiInfo (:43), OrganizerContactEmail (:46), SponsorshipPacketUrl (:49), and TicketingUrl (:52). Every setter is init, so once the model binder has produced the instance no handler in the pipeline can mutate it.
    • +
    • Why it's built this way: collapsing "request DTO" and "command" into one type removes a translation step that would carry no information, and it is what lets a whole CRUD endpoint be inherited rather than written (see the base controller in Group 12). Declaring the cache prefix on the message instead of inside the handler means the framework decorator, not the module, owns eviction.
    • +
    • Where it's used: bound by EventsController.CreateAsync (MMCA.ADC.Conference.API/Controllers/EventsController.cs:257), which overrides the inherited action only to add the explicit [Idempotent] declaration and a follow-up output-cache eviction (EventsController.cs:254-262); validated by EventCreateRequestValidator; translated by EventCreateRequestMapper; handled by CreateEventHandler. Its update-side counterpart is EventUpdateRequest.
    • +
    • Caveats / not-in-source: the Id property (EventCreateRequest.cs:16) is accepted but never applied. Event carries [IdValueGenerated] (MMCA.ADC.Conference.Domain/Events/Event.cs:22), so the factory assigns Id = isIdValueGenerated ? default : id!.Value (Event.cs:187,203) and the caller-supplied value is discarded for this aggregate. The record is also record class, not sealed record, unlike every command in this unit; nothing in source derives from it and no comment explains the difference.
    -

    PublishEventHandler

    +

    PublishEventCommand

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Publish · MMCA.ADC.Conference.Application/Events/UseCases/Publish/PublishEventHandler.cs:13 · Level 9 · class (sealed partial)

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Publish · MMCA.ADC.Conference.Application/Events/UseCases/Publish/PublishEventCommand.cs:12 · Level 8 · record (sealed)

      -
    • What it is: the handler for PublishEventCommand. It loads the event, stamps the client's concurrency token, asks the aggregate to publish itself, and saves only if the aggregate agreed.
    • -
    • Depends on: IUnitOfWork (PublishEventHandler.cs:3,14), ILogger<T> with a source-generated [LoggerMessage] (PublishEventHandler.cs:1,15,41-42), ICommandHandler<in TCommand, TResult> (PublishEventHandler.cs:4,15), the Event aggregate (PublishEventHandler.cs:2), and Result / Error (PublishEventHandler.cs:5).
    • -
    • Concept introduced, applying an optimistic-concurrency token to a loaded entity. The interesting line is repository.SetOriginalRowVersion(entity, command.RowVersion) (PublishEventHandler.cs:29). EF Core decides whether an UPDATE succeeded by comparing the row's original tracked rowversion against the database. Normally the original value is whatever the row had when it was just loaded, which is by definition current, so a concurrency check would always pass. This call overwrites the tracked original with the token the client last saw (IRepository<TEntity, TIdentifierType> declares it at MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:197), so a decision made against a stale view fails the save and surfaces as 409 Conflict rather than silently overwriting a concurrent edit. Passing null is the documented way to skip the check (PublishEventHandler.cs:27-29). This is ADR-035 in three lines. [Rubric §8, Data Architecture] assesses concurrency and consistency handling: the check is in the database, not in an application-side read-then-compare that could itself race. [Rubric §13, Observability & Operability]: the source-generated log message means the transition is recorded with structured fields at zero allocation when the level is disabled.
    • -
    • Walkthrough: primary-constructor injection of IUnitOfWork and ILogger<PublishEventHandler> (PublishEventHandler.cs:13-15), and the class implements ICommandHandler<PublishEventCommand, Result>. HandleAsync (:18-39) gets the typed repository from the unit of work (:22), loads by id with the plain include-free overload (:23), and returns Error.NotFound.WithSource(nameof(PublishEventHandler)).WithTarget(nameof(Event)) when the row is absent (:24-25). Note there is no includes array and no asTracking: true here: a publish touches a scalar on the root only, unlike AddEventSpeakerHandler and AddRoomHandler, which must load a child collection for the aggregate's duplicate checks. It then stamps the row version (:29), calls entity.Publish() (:31), and only on success awaits unitOfWork.SaveChangesAsync(cancellationToken) and logs LogEventPublished (:32-37). The aggregate's own Result is returned unchanged (:38), so an "already published" invariant failure (MMCA.ADC.Conference.Domain/Events/Event.cs:260-267, code Event.AlreadyPublished) reaches the caller with its domain error code intact. LogEventPublished is declared partial with [LoggerMessage(Level = LogLevel.Information, ...)] (:41-42) and its body is generated at build time.
    • -
    • Why it's built this way: the save-only-on-success shape is the module's canonical command body. It means a rejected transition writes nothing at all, so the single SaveChangesAsync stays the one boundary that stamps audit fields, captures domain events, and writes the outbox row for EventChanged raised inside Event.Publish (Event.cs:271).
    • -
    • Where it's used: resolved by the events controller as ICommandHandler<PublishEventCommand, Result> (MMCA.ADC.Conference.API/Controllers/EventsController.cs:48) and invoked at :302. Its inverse is UnpublishEventHandler.
    • +
    • What it is: the intent to make an event visible to attendees. Two positional parameters: the event Id and an optional RowVersion, the concurrency token the client last saw (PublishEventCommand.cs:12).
    • +
    • Depends on: ICacheInvalidating (PublishEventCommand.cs:2,12), the Event type for the cache prefix (PublishEventCommand.cs:1,15), the EventIdentifierType alias, and byte[] from the BCL for the token.
    • +
    • Concept introduced, the optional concurrency token on a state-transition command. Publishing is a decision made against what the organizer had on screen. If someone else edited or unpublished the event in the meantime, the transition was decided against a stale view. Rather than re-read and compare, the command carries the client's last-seen RowVersion (PublishEventCommand.cs:8-11,12) and PublishEventHandler stamps it back as the tracked entity's original value, so SQL Server's own WHERE RowVersion = @original clause decides (ADR-035). The parameter defaults to null, and the framework treats null or empty as "skip the check" (MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:276-284), so the safety is opt-in per call rather than a breaking contract change. [Rubric §8, Data Architecture] assesses how concurrent writes are reconciled: this is optimistic concurrency pushed to the database predicate, with no read-compare window in application code. [Rubric §9, API & Contract Design] assesses evolution: a defaulted trailing parameter lets older callers keep compiling and calling while new ones opt into conditional writes.
    • +
    • Walkthrough: public sealed record PublishEventCommand(EventIdentifierType Id, byte[]? RowVersion = null) : ICacheInvalidating (PublishEventCommand.cs:12) plus the single computed CachePrefix => $"{typeof(Event).FullName}:" (PublishEventCommand.cs:15). No behavior lives here; the already-published rule is a domain invariant (MMCA.ADC.Conference.Domain/Events/Event.cs:274-281).
    • +
    • Why it's built this way: modelling publish as its own command rather than as a field on an update request keeps the transition auditable as a distinct intent, gives it its own endpoint, validator surface and idempotency contract, and keeps the update path free of a boolean that would otherwise be settable by accident.
    • +
    • Where it's used: constructed by EventsController.PublishAsync from the optional EventTransitionRequest body, new PublishEventCommand(id, request?.RowVersion) (MMCA.ADC.Conference.API/Controllers/EventsController.cs:316, handler injected at EventsController.cs:49). The endpoint is POST {id}/publish carrying IdempotentAttribute and SupportsIfMatchAttribute and declaring both 409 and 412 (EventsController.cs:305-309). Handled by PublishEventHandler.
    -

    CreateEventHandler

    +

    RemoveEventQuestionAnswerCommand

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Create · MMCA.ADC.Conference.Application/Events/UseCases/Create/CreateEventHandler.cs:16 · Level 10 · class (sealed partial)

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RemoveEventQuestionAnswer · MMCA.ADC.Conference.Application/Events/UseCases/RemoveEventQuestionAnswer/RemoveEventQuestionAnswerCommand.cs:9 · Level 8 · record (sealed)

      -
    • What it is: the handler that creates a conference Event. It is the highest-level type in this unit because it composes four of the others: the request, the request mapper, the repository, and the read-side DTO mapper.
    • -
    • Depends on: IUnitOfWork (CreateEventHandler.cs:6,17), IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> satisfied by EventCreateRequestMapper (CreateEventHandler.cs:5,18), EventDTOMapper (:2,19), ILogger<T> (:1,20,42-43), ICommandHandler<in TCommand, TResult> (:7,20), the Event aggregate (:3), EventDTO from the Shared project (:4), and Result (:8).
    • -
    • Concept introduced, the generic create slice assembled end to end. Read the four injected dependencies as a pipeline (CreateEventHandler.cs:16-20): the request mapper turns the wire contract into a validated entity, the unit of work supplies the typed repository and owns the transaction boundary, and the DTO mapper turns the persisted entity back into a wire contract. The handler itself contributes only sequencing and a log line, and there is no new Event(...) anywhere in it. Note also what is absent: no validator call (the pipeline's validation decorator already ran EventCreateRequestValidator), no cache eviction (the caching decorator reads CachePrefix off EventCreateRequest), and no try/catch (failures arrive as Result values). That absence is the point of the decorator pipeline taught in Group 05. [Rubric §5, Vertical Slice] assesses whether a use case is self-contained: the four Create types live in one folder and this handler is the slice's entry point. [Rubric §3, Clean Architecture]: the Application layer depends on abstractions (IUnitOfWork, IEntityRequestMapper) and on the Domain, never on EF Core or ASP.NET. [Rubric §6, CQRS & Event-Driven]: one command type, one handler, one write path, and the EventChanged domain event raised inside the factory (MMCA.ADC.Conference.Domain/Events/Event.cs:196) is captured by the same SaveChangesAsync.
    • -
    • Walkthrough: primary-constructor injection of the four collaborators (CreateEventHandler.cs:16-20), declaring ICommandHandler<EventCreateRequest, Result<EventDTO>>. HandleAsync (:23-40) awaits requestMapper.CreateEntityAsync(command, cancellationToken) (:27) and short-circuits on failure by re-wrapping the errors into the correct generic shape, Result.Failure<EventDTO>(result.Errors) (:28-29), which is how a factory-level invariant failure becomes an API error without an exception. It then unwraps result.Value! (:31), gets unitOfWork.GetRepository<Event, EventIdentifierType>() (:32), awaits repository.AddAsync(entity, cancellationToken) and then the single unitOfWork.SaveChangesAsync(cancellationToken) (:34-35), both with .ConfigureAwait(false). After the save it emits the generated LogEventCreated(logger, entity.Id, entity.Name) (:37, declaration :42-43), which is placed after the save so the logged id is the store-generated key rather than a placeholder. It returns Result.Success(dtoMapper.MapToDTO(entity)) (:39).
    • -
    • Why it's built this way: the single SaveChangesAsync is the one place audit fields are stamped, domain events are captured, and outbox rows are written, so the handler deliberately owns exactly one call to it. Returning an EventDTO rather than the entity keeps the domain type from crossing the API boundary, per ADR-001.
    • -
    • Where it's used: injected into the events controller as ICommandHandler<EventCreateRequest, Result<EventDTO>> (MMCA.ADC.Conference.API/Controllers/EventsController.cs:46) and forwarded to AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest>, which owns the inherited create action (EventsController.cs:57-58). Its update and delete counterparts are UpdateEventHandler and DeleteEventHandler.
    • +
    • What it is: the intent to remove one EventQuestionAnswer from an Event. Two positional parameters, the owning EventId and the EventQuestionAnswerId (RemoveEventQuestionAnswerCommand.cs:9-11).
    • +
    • Depends on: ICacheInvalidating (RemoveEventQuestionAnswerCommand.cs:2,11), the Event type for the cache prefix (RemoveEventQuestionAnswerCommand.cs:1,14), and the EventIdentifierType / EventQuestionAnswerIdentifierType aliases.
    • +
    • Concept introduced, the remove-child command shape. Every child removal in this module is the same two-identifier record: the aggregate root first, the child second, nothing else. Naming the root is not redundant. The write has to travel through the aggregate so the invariant checks and the EventQuestionAnswerChanged domain event fire, so the handler loads the Event and calls a method on it rather than deleting a row by id. The removal itself is a soft delete: the child's Delete() sets IsDeleted = true and fails with Error.AlreadyDeleted if it was already removed (MMCA.Common.Domain/Entities/AuditableBaseEntity.cs:47-59), which is ADR-005 applied to a child entity. [Rubric §4, Domain-Driven Design] assesses whether children are mutated through their root: the command's shape makes that structurally unavoidable. [Rubric §8, Data Architecture] assesses deletion policy: rows are retired, not destroyed, and the EF global query filter hides them from every later read.
    • +
    • Walkthrough: a sealed record with EventId and EventQuestionAnswerId (RemoveEventQuestionAnswerCommand.cs:9-11), plus CachePrefix => $"{typeof(Event).FullName}:" (RemoveEventQuestionAnswerCommand.cs:14), keyed on the root because a cached event read carries its answers with it.
    • +
    • Why it's built this way: keeping the delete as an aggregate operation rather than a repository-level ExecuteDelete preserves domain events, audit stamping and soft-delete semantics, all three of which a bulk delete would bypass (MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:298-300).
    • +
    • Where it's used: constructed by EventQuestionAnswersController.DeleteAsync as new RemoveEventQuestionAnswerCommand(eventId, id), with the event id taken from the query string (MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:218-225, handler injected at EventQuestionAnswersController.cs:61). Handled by RemoveEventQuestionAnswerHandler, which adds the ownership rule the record does not express.
    • +
    • Caveats / not-in-source: unlike PublishEventCommand, this command carries no RowVersion, so a child removal is not a conditional write; a concurrent edit of the same answer is last-write-wins.
    -

    QuestionDTOMapper

    +

    RemoveEventSpeakerCommand

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Questions.DTOs · MMCA.ADC.Conference.Application/Questions/DTOs/QuestionDTOMapper.cs:12 · Level 8 · class

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RemoveEventSpeaker · MMCA.ADC.Conference.Application/Events/UseCases/RemoveEventSpeaker/RemoveEventSpeakerCommand.cs:9 · Level 8 · record (sealed)

      -
    • What it is: the outbound mapper that turns a Question aggregate into the QuestionDTO the API returns. Its single-entity method has no body: Mapperly generates it at compile time from the [Mapper] attribute (:11).
    • -
    • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over Question, QuestionDTO, and the QuestionIdentifierType alias (:13); the Question entity and the QuestionDTO contract; Riok.Mapperly.Abstractions (NuGet, :4).
    • -
    • Concept reinforced, source-generated DTO mapping. [Rubric §2, Design Patterns] assesses whether repetitive translation code is factored out rather than hand-written per property: the partial declaration at :16 is the whole contribution, and the generator emits the property-by-property assignment into a companion .g.cs. [Rubric §12, Performance & Scalability] assesses the cost of that translation: because the body is generated, mapping is straight-line assignment with no reflection and no expression compilation on the hot read path. [Rubric §3, Clean Architecture] assesses direction: the domain type never leaves the Application layer, only the DTO does. The convention and its trade-offs are set out in ADR-001; contrast this with the inbound *RequestMapper classes such as QuestionCreateRequestMapper, which are hand-written because they must call a factory and are allowed to fail.
    • -
    • Walkthrough
        -
      • [Mapper] (:11) is the generator trigger; the class is sealed partial (:12) so the generated half can be merged in.
      • -
      • public partial QuestionDTO MapToDTO(Question entity) (:16) is the generated member. Mapping is by name, and the pairs line up one for one: QuestionText, QuestionEntity, QuestionType, Sort, IsRequired, and QuestionSource on the entity (MMCA.ADC.Conference.Domain/Questions/Question.cs:17-32) against the same names on the DTO (MMCA.ADC.Conference.Shared/Questions/QuestionDTO.cs:18-33), plus the Id and RowVersion members the DTO inherits from IBaseDTO<QuestionIdentifierType> and IConcurrencyAware (QuestionDTO.cs:12-15).
      • -
      • MapToDTOs (:19-23) is hand-written, not generated: it null-guards the input (:21) and projects with a collection expression over the generated single-item mapper, [.. entityCollection.Select(MapToDTO)] (:22).
      • -
      -
    • -
    • Why it's built this way: the three string members are non-nullable on the entity (Question.cs:20, :23, :32) and nullable on the DTO (QuestionDTO.cs:21, :24, :33), which is the usual direction for a read contract: the DTO tolerates more than the domain produces, so a contract change does not force a domain change.
    • -
    • Where it's used: injected as a concrete type by CreateQuestionHandler (MMCA.ADC.Conference.Application/Questions/UseCases/Create/CreateQuestionHandler.cs:23) and UpdateQuestionHandler (MMCA.ADC.Conference.Application/Questions/UseCases/Update/UpdateQuestionHandler.cs:21); resolved through its interface by EntityQueryService<TEntity, TEntityDTO, TIdentifierType> (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:35), which is registered for Question at MMCA.ADC.Conference.Application/DependencyInjection.cs:72 and drives every read on QuestionsController. The mapper itself is picked up by the module scan (DependencyInjection.cs:112).
    • -
    • Caveats / not-in-source: the generated assignments are not readable in this file, only in build output, so a member added to the DTO without a matching entity member surfaces as a generator diagnostic at build time rather than as anything visible here. Note also that this mapper redacts nothing: unlike SpeakerDTOMapper, which withholds an email from non-organizers, every question member is copied verbatim.
    • +
    • What it is: the intent to unlink a speaker from an event. It targets the EventSpeaker association row, not the speaker: the speaker aggregate is untouched.
    • +
    • Depends on: ICacheInvalidating (RemoveEventSpeakerCommand.cs:2,11), the Event type for the cache prefix (RemoveEventSpeakerCommand.cs:1,14), and the EventIdentifierType / EventSpeakerIdentifierType aliases.
    • +
    • Concept: nothing new; the remove-child shape taught by RemoveEventQuestionAnswerCommand. What it demonstrates is why "link" entities are worth having: removing a speaker from an event is a soft delete of one join row, so the speaker keeps existing, keeps its own identity, and can be linked to another edition of the conference. [Rubric §4, Domain-Driven Design] assesses aggregate boundaries: the association belongs to the event, the speaker is its own aggregate, and this command can only reach the former.
    • +
    • Walkthrough: public sealed record RemoveEventSpeakerCommand(EventIdentifierType EventId, EventSpeakerIdentifierType EventSpeakerId) : ICacheInvalidating (RemoveEventSpeakerCommand.cs:9-11) with CachePrefix => $"{typeof(Event).FullName}:" (RemoveEventSpeakerCommand.cs:14).
    • +
    • Where it's used: constructed by EventSpeakersController.DeleteAsync as new RemoveEventSpeakerCommand(eventId, id) (MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:239-246, handler injected at EventSpeakersController.cs:50). Handled by RemoveEventSpeakerHandler. Its add-side counterpart is the command handled by AddEventSpeakerHandler.
    -

    RemoveEventQuestionAnswerCommand

    +

    RemoveRoomCommand

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RemoveEventQuestionAnswer · MMCA.ADC.Conference.Application/Events/UseCases/RemoveEventQuestionAnswer/RemoveEventQuestionAnswerCommand.cs:9 · Level 8 · record

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RemoveRoom · MMCA.ADC.Conference.Application/Events/UseCases/RemoveRoom/RemoveRoomCommand.cs:9 · Level 8 · record (sealed)

      -
    • What it is: the write message that detaches an answer from an event's question set. Two ids and nothing else: the owning EventId and the EventQuestionAnswerId to remove (:9-11).
    • -
    • Depends on: ICacheInvalidating (:11); the Event aggregate, referenced only to build the cache prefix (:14); the EventIdentifierType and EventQuestionAnswerIdentifierType module aliases.
    • -
    • Concept reinforced, the child-mutation command that names its parent. [Rubric §6, CQRS & Event-Driven] assesses whether a write is an explicit single-purpose message: it is, and it deliberately carries the aggregate root's id rather than the child's alone, because the handler must load the Event and let the root perform the removal. [Rubric §10, Cross-Cutting] assesses whether such concerns are declared rather than coded: CachePrefix => $"{typeof(Event).FullName}:" (:14) is what makes CachingCommandDecorator<TCommand, TResult> drop every cached read keyed under the Event type after a successful removal (pipeline order in ADR-014). The join row has no cache namespace of its own, so the parent's whole cached surface is evicted, which is exactly what keeps a deleted answer out of an already-cached event read. The same prefix appears on every command in this family, including AddEventQuestionAnswerCommand.
    • -
    • Walkthrough: a sealed record with a two-parameter positional constructor (:9-11) and one expression-bodied member, CachePrefix (:13-14). There is no ITransactional marker: the write lands inside one aggregate and one SaveChangesAsync, with no cross-context event to keep atomic.
    • -
    • Why it's built this way: records give value equality and immutability for free, and the marker interface moves eviction into the pipeline so the handler stays free of cache code.
    • -
    • Where it's used: constructed by EventQuestionAnswersController's delete action (MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:217); handled by RemoveEventQuestionAnswerHandler.
    • +
    • What it is: the intent to remove a Room from an event: the owning EventId and the RoomId (RemoveRoomCommand.cs:9-11).
    • +
    • Depends on: ICacheInvalidating (RemoveRoomCommand.cs:2,11), the Event type for the cache prefix (RemoveRoomCommand.cs:1,14), and the EventIdentifierType / RoomIdentifierType aliases.
    • +
    • Concept: nothing new; the remove-child shape taught by RemoveEventQuestionAnswerCommand. Rooms are the one child family whose identifiers can be externally assigned (Sessionize ids), which makes the soft delete matter more than usual: retiring the row rather than deleting it keeps a later refresh from colliding with a reused key. [Rubric §16, Maintainability] assesses uniformity: the third identical remove command in the same module is a sign the shape is a convention, so a reader who has understood one has understood all of them.
    • +
    • Walkthrough: public sealed record RemoveRoomCommand(EventIdentifierType EventId, RoomIdentifierType RoomId) : ICacheInvalidating (RemoveRoomCommand.cs:9-11) with CachePrefix => $"{typeof(Event).FullName}:" (RemoveRoomCommand.cs:14).
    • +
    • Where it's used: constructed by RoomsController.DeleteAsync as new RemoveRoomCommand(eventId, id) (MMCA.ADC.Conference.API/Controllers/RoomsController.cs:311-318, handler injected at RoomsController.cs:96). Handled by RemoveRoomHandler. Its add-side counterpart is handled by AddRoomHandler.
    -

    RemoveEventSpeakerCommand

    +

    UnpublishEventCommand

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RemoveEventSpeaker · MMCA.ADC.Conference.Application/Events/UseCases/RemoveEventSpeaker/RemoveEventSpeakerCommand.cs:9 · Level 8 · record

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Unpublish · MMCA.ADC.Conference.Application/Events/UseCases/Unpublish/UnpublishEventCommand.cs:12 · Level 8 · record (sealed)

      -
    • What it is: the command that removes a speaker's association with an event. It names the association join row, not the speaker: EventSpeakerId (:11). The speaker profile itself is untouched.
    • -
    • Depends on: ICacheInvalidating; Event (cache prefix only); the EventIdentifierType and EventSpeakerIdentifierType aliases.
    • -
    • Concept reinforced: none new, see RemoveEventQuestionAnswerCommand. Same two-id shape, same parent-scoped CachePrefix (:14). [Rubric §6, CQRS & Event-Driven].
    • -
    • Walkthrough: a sealed record with two positional parameters (:9-11) and the single CachePrefix member (:13-14). Removing the association by its own id rather than by (EventId, SpeakerId) is what lets the domain treat the join row as a first-class child with its own soft-delete state (see EventSpeaker).
    • -
    • Where it's used: constructed by EventSpeakersController's delete action (MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:238); handled by RemoveEventSpeakerHandler.
    • +
    • What it is: the mirror of PublishEventCommand: the intent to hide an event from attendees again, with the same optional concurrency token (UnpublishEventCommand.cs:12).
    • +
    • Depends on: ICacheInvalidating (UnpublishEventCommand.cs:2,12), the Event type for the cache prefix (UnpublishEventCommand.cs:1,15), and the EventIdentifierType alias.
    • +
    • Concept: nothing new; the optional-RowVersion transition command taught by PublishEventCommand. The two records are byte-for-byte equivalent apart from their names, which is deliberate: each transition gets its own type so the pipeline decorators, the idempotency store and the logs can tell them apart without inspecting a payload field. [Rubric §6, CQRS & Event-Driven] assesses whether writes are modelled as named intents: "unpublish" is a type, not a IsPublished = false mutation.
    • +
    • Walkthrough: public sealed record UnpublishEventCommand(EventIdentifierType Id, byte[]? RowVersion = null) : ICacheInvalidating (UnpublishEventCommand.cs:12) with CachePrefix => $"{typeof(Event).FullName}:" (UnpublishEventCommand.cs:15).
    • +
    • Where it's used: constructed by EventsController.UnpublishAsync as new UnpublishEventCommand(id, request?.RowVersion) (MMCA.ADC.Conference.API/Controllers/EventsController.cs:347, handler injected at EventsController.cs:50), on the POST {id}/unpublish endpoint carrying the same [Idempotent] and [SupportsIfMatch] pair and the same 409/412 declarations (EventsController.cs:336-340). Handled by UnpublishEventHandler.
    -

    RemoveRoomCommand

    +

    UpdateEventQuestionAnswerCommand

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RemoveRoom · MMCA.ADC.Conference.Application/Events/UseCases/RemoveRoom/RemoveRoomCommand.cs:9 · Level 8 · record

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.UpdateEventQuestionAnswer · MMCA.ADC.Conference.Application/Events/UseCases/UpdateEventQuestionAnswer/UpdateEventQuestionAnswerCommand.cs:10 · Level 8 · record (sealed)

      -
    • What it is: the command that removes a room from an event. Rooms are children of Event, not standalone aggregates, so the message carries both ids (:9-11).
    • -
    • Depends on: ICacheInvalidating; Event (cache prefix only); the EventIdentifierType and RoomIdentifierType aliases.
    • -
    • Concept reinforced: none new, see RemoveEventQuestionAnswerCommand; the CachePrefix is identical (:14) and the record is the same two-id shape. Compare AddRoomCommand, which carries the full room payload for the same aggregate.
    • -
    • Walkthrough: a sealed record with two positional parameters (:9-11) and one member (:13-14).
    • -
    • Where it's used: constructed by RoomsController's delete action (MMCA.ADC.Conference.API/Controllers/RoomsController.cs:205); handled by RemoveRoomHandler.
    • -
    • Caveats / not-in-source: a removal is a soft delete, not a row deletion (the domain calls the child's Delete(), MMCA.ADC.Conference.Domain/Events/Event.cs:489). Nothing on the command says so, and a caller reading only this record would not know the room can come back through Event.RestoreRoom (Event.cs:439-475).
    • +
    • What it is: the intent to change the text of an existing answer: the owning EventId, the EventQuestionAnswerId, and the new AnswerValue (UpdateEventQuestionAnswerCommand.cs:10-13).
    • +
    • Depends on: ICacheInvalidating (UpdateEventQuestionAnswerCommand.cs:2,13), the Event type for the cache prefix (UpdateEventQuestionAnswerCommand.cs:1,16), and the EventIdentifierType / EventQuestionAnswerIdentifierType aliases.
    • +
    • Concept: the remove-child shape plus one payload field. The detail worth noticing is what the record does not carry: no author, no timestamp. Ownership is decided server-side by UpdateEventQuestionAnswerHandler from ICurrentUserService, so a caller cannot claim to be editing on someone else's behalf by shaping the payload. [Rubric §11, Security] assesses whether identity is ambient rather than client-supplied: the absence of an owner field is the enforcement.
    • +
    • Walkthrough: public sealed record UpdateEventQuestionAnswerCommand(EventIdentifierType EventId, EventQuestionAnswerIdentifierType EventQuestionAnswerId, string AnswerValue) : ICacheInvalidating (UpdateEventQuestionAnswerCommand.cs:10-13) with CachePrefix => $"{typeof(Event).FullName}:" (UpdateEventQuestionAnswerCommand.cs:16). The length and emptiness rules for AnswerValue are enforced deeper, by EventInvariants.EnsureAnswerValueIsValid inside EventQuestionAnswer.UpdateAnswer (MMCA.ADC.Conference.Domain/Events/EventQuestionAnswer.cs:71-80).
    • +
    • Where it's used: constructed by EventQuestionAnswersController.UpdateAsync as new UpdateEventQuestionAnswerCommand(request.EventId, id, request.AnswerValue), taking the event id from the body and the answer id from the route (MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:202-209, handler injected at EventQuestionAnswersController.cs:60). Handled by UpdateEventQuestionAnswerHandler.
    -

    UnpublishEventCommand

    +

    EventCreateRequestMapper

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Unpublish · MMCA.ADC.Conference.Application/Events/UseCases/Unpublish/UnpublishEventCommand.cs:12 · Level 8 · record

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Create · MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequestMapper.cs:11 · Level 9 · class (sealed)

      -
    • What it is: the state-transition command that hides a published event from attendees again. It is the inverse of PublishEventCommand and carries the same optional concurrency token.
    • -
    • Depends on: ICacheInvalidating; Event (cache prefix); the EventIdentifierType alias; byte[] for the rowversion.
    • -
    • Concept introduced, the client-supplied concurrency token on a transition command. [Rubric §8, Data Architecture] assesses whether concurrent writers can silently overwrite each other. A publish/unpublish toggle is the classic lost-update shape: two organizers both load the event, both see "published", and both press unpublish. RowVersion (:12) is the client's last-seen SQL Server rowversion, echoed back so the save can fail with a conflict instead of blindly applying a decision made against a stale view (ADR-035). The parameter is optional and defaults to null (:12), and the XML comment states what null means (:8-11): skip the stale-view check. That makes the check opt-in per caller rather than mandatory, which is a deliberate trade-off worth noticing: a client that never sends the token gets last-write-wins. [Rubric §9, API & Contract Design] reaches the same point from the wire side, where EventsController binds the body with EmptyBodyBehavior.Allow so an omitted body is legal (MMCA.ADC.Conference.API/Controllers/EventsController.cs:320).
    • -
    • Walkthrough: a one-line sealed record (:12) with two positional parameters, the second defaulted, and the single CachePrefix member (:14-15) that evicts the cached Event reads on success like every other command in this family.
    • -
    • Why it's built this way: keeping the token on the command rather than inferring it server-side means the check is about what the caller saw. The handler stamps it as the original value before the transition (see UnpublishEventHandler), so EF's own concurrency machinery does the enforcement and no bespoke compare-and-swap code is needed.
    • -
    • Where it's used: constructed by EventsController's POST /Events/{id}/unpublish action (EventsController.cs:324), which declares 409 Conflict as a documented response (:317) and evicts the events output cache afterwards (:330); handled by UnpublishEventHandler.
    • +
    • What it is: the one-method collaborator that turns an EventCreateRequest into an Event by calling the aggregate's factory method. It performs no validation and constructs nothing itself.
    • +
    • Depends on: IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> from MMCA.Common.Application.Interfaces (EventCreateRequestMapper.cs:2,12), the Event aggregate and its Create factory (EventCreateRequestMapper.cs:1,19), Result from MMCA.Common.Shared.Abstractions (EventCreateRequestMapper.cs:3,15), and the EventIdentifierType alias.
    • +
    • Concept introduced, request-to-entity translation as its own injectable step. The framework's create pipeline is deliberately split in three: a request record, a mapper that produces the entity, and a handler that persists it. The mapper is the only place that knows the factory's parameter order, so if Event.Create grows a parameter exactly one application-layer file changes. Because the interface returns Task<Result<TEntity>>, an implementation that needs a lookup (another repository, an external call) can be genuinely asynchronous; this one has nothing to await, so it wraps the synchronous factory in Task.FromResult rather than declaring async and forcing a state machine (EventCreateRequestMapper.cs:19). [Rubric §1, SOLID] assesses single responsibility and dependency inversion: CreateEventHandler depends on the interface, never on this class, so a module can swap translation without touching the handler. [Rubric §4, Domain-Driven Design] assesses whether entities can be created in an invalid state: the mapper cannot call a constructor, only the factory, and the factory returns Result rather than throwing. [Rubric §14, Testability] assesses isolation: the class has no dependencies at all, so a test constructs it directly.
    • +
    • Walkthrough: public sealed class EventCreateRequestMapper : IEntityRequestMapper<Event, EventCreateRequest, EventIdentifierType> (EventCreateRequestMapper.cs:11-12). The single method CreateEntityAsync (EventCreateRequestMapper.cs:15) null-guards with ArgumentNullException.ThrowIfNull(request) (:17), then returns Task.FromResult(Event.Create(...)) passing thirteen values positionally through WiFiInfo and the last three by name, organizerContactEmail:, sponsorshipPacketUrl: and ticketingUrl: (:19-32). Naming the trailing three is not cosmetic: it steps over the factory's questionModerationDefault parameter, which sits between them in the signature (MMCA.ADC.Conference.Domain/Events/Event.cs:175-178). Inside the factory, three invariants run through Result.Combine before any object exists (Event.cs:180-185), and a EventChanged(DomainEntityState.Added, ...) domain event is attached to the new aggregate (Event.cs:207) so the outbox picks it up in the same transaction as the insert (ADR-003).
    • +
    • Why it's built this way: the failure mode this design removes is an application layer that news up entities. Because the only path from a request to an Event runs through Event.Create, the name, time zone and date-range invariants cannot be skipped even by a caller that bypassed the validator.
    • +
    • Where it's used: injected into CreateEventHandler as IEntityRequestMapper<Event, EventCreateRequest, EventIdentifierType> (MMCA.ADC.Conference.Application/Events/UseCases/Create/CreateEventHandler.cs:18). Registration is by convention: the module calls services.ScanModuleApplicationServices<ClassReference>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:125), which registers every IEntityRequestMapper<,,> implementation as itself and as its interfaces with a scoped lifetime (MMCA.Common.Application/DependencyInjection.cs:172-176).
    • +
    • Caveats / not-in-source: the mapper never supplies questionModerationDefault, so every event created through this path takes the factory default QuestionModerationDefault.Pending (MMCA.ADC.Conference.Domain/Events/Event.cs:175). EventCreateRequest has no field for it, so the moderation default cannot be chosen at creation time through the API.
    -

    UpdateEventQuestionAnswerCommand

    +

    EventCreateRequestValidator

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.UpdateEventQuestionAnswer · MMCA.ADC.Conference.Application/Events/UseCases/UpdateEventQuestionAnswer/UpdateEventQuestionAnswerCommand.cs:10 · Level 8 · record

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Create · MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequestValidator.cs:7 · Level 9 · class (sealed)

      -
    • What it is: the command that edits the text of an existing answer on an event. Two ids to locate the child plus the new AnswerValue (:10-13).
    • -
    • Depends on: ICacheInvalidating; Event (cache prefix); the EventIdentifierType and EventQuestionAnswerIdentifierType aliases.
    • -
    • Concept reinforced: the parent-scoped CachePrefix (:16) introduced at RemoveEventQuestionAnswerCommand. [Rubric §6, CQRS & Event-Driven]: the update carries the whole new value rather than a patch document, so the message is idempotent by construction. Applying it twice leaves the same text.
    • -
    • Walkthrough: a sealed record with three positional parameters (:10-13) and one member (:15-16). AnswerValue is a plain non-nullable string with no length attribute on it: the ceiling lives in the domain, enforced when Event forwards to the child's UpdateAnswer (MMCA.ADC.Conference.Domain/Events/Event.cs:641).
    • -
    • Caveats / not-in-source: there is no UpdateEventQuestionAnswerCommandValidator in this use-case folder, so nothing rejects an empty answer before the transaction opens; the failure comes back from the domain instead (compare UpdateRoomCommandValidator, which does front-load its checks).
    • -
    • Where it's used: constructed by EventQuestionAnswersController's update action from an UpdateEventQuestionAnswerRequest body (MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:201); handled by UpdateEventQuestionAnswerHandler.
    • +
    • What it is: the FluentValidation validator for EventCreateRequest. It contains no rule of its own: six Include calls assemble it from reusable per-field rule sets.
    • +
    • Depends on: AbstractValidator<EventCreateRequest> from FluentValidation (NuGet, EventCreateRequestValidator.cs:1,7) and the module's own rule objects from Events.Validation (EventCreateRequestValidator.cs:2): EventNameRules<T>, EventTimeZoneRules<T>, EventDateRangeRules<T>, EventOrganizerContactEmailRules<T>, EventSponsorshipPacketUrlRules<T> and EventTicketingUrlRules<T>.
    • +
    • Concept introduced, the validator as pure composition. FluentValidation's Include merges another validator's rules into this one, and because each rule set is generic over the request type they can be reused verbatim by the update-side validator against a different record. That is why the rules live in Events/Validation rather than in the use-case folder: the create and update slices share one definition of "a valid event name". Two of the includes are worth reading closely. Include(new EventDateRangeRules<EventCreateRequest>(p => p.StartDate, p => p.EndDate)) (EventCreateRequestValidator.cs:13) takes two selectors because the rule is cross-field: it compiles the start-date selector and compares (MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:122-125), failing with the code Event.EndDate.BeforeStart. And Include(new EventOrganizerContactEmailRules<EventCreateRequest>(p => p.OrganizerContactEmail!)) (EventCreateRequestValidator.cs:14) carries a null-forgiving ! because the property is string? while the rule takes Expression<Func<T, string>> (EventValidationRules.cs:60); the ! is safe because the rule body only applies the shared EmailRules<T> inside a When(x => !string.IsNullOrWhiteSpace(accessor(x)), ...) guard (EventValidationRules.cs:64-65), so a null value is never routed into the inner rules. [Rubric §24, Forms, Validation & UX Safety] assesses whether bad input is rejected at the boundary with actionable messages: the included rules attach stable dotted error codes such as Event.TimeZone.InvalidIana (EventValidationRules.cs:32) that a client can branch on. [Rubric §15, Best Practices & Code Quality] assesses duplication: this validator is six lines because the rules are objects.
    • +
    • Walkthrough: public sealed class EventCreateRequestValidator : AbstractValidator<EventCreateRequest> (EventCreateRequestValidator.cs:7) with a parameterless constructor (:9) whose whole body is the six includes: name (:11), time zone (:12), date range (:13), organizer contact email (:14), sponsorship packet URL (:15) and ticketing URL (:16). Only two fields are unconditionally required here, name through RequiredStringRules<T> (EventValidationRules.cs:13-17) and time zone (EventValidationRules.cs:28-32); the last three includes each wrap their rules in a When guard so an omitted optional URL or email passes.
    • +
    • Why it's built this way: the time-zone rule is the clearest argument for this layout. It does not just check length, it calls TimeZoneInfo.FindSystemTimeZoneById and treats a TimeZoneNotFoundException as invalid (EventValidationRules.cs:34-48), which is real logic that no one wants written twice. Composing it means the create and update paths cannot drift.
    • +
    • Where it's used: resolved and executed by the validation step of the CQRS pipeline before CreateEventHandler runs. Registration is again by convention: ScanModuleApplicationServices ends with services.AddValidatorsFromAssemblyContaining<TAssemblyMarker>() (MMCA.Common.Application/DependencyInjection.cs:190), called for this module at MMCA.ADC.Conference.Application/DependencyInjection.cs:125. Covered by EventCreateRequestValidatorTests.
    • +
    • Caveats / not-in-source: five request fields have no rule at all: Description, SessionizeCode, VenueAddress, VenueMapUrl and WiFiInfo (EventCreateRequest.cs:22,34,37,40,43). Their length limits are enforced only by the EF column configuration and the domain invariants, not at the boundary, and nothing in source states whether that is deliberate.
    -

    UpdateRoomCommand

    +

    PublishEventHandler

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.UpdateRoom · MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomCommand.cs:15 · Level 8 · record

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Publish · MMCA.ADC.Conference.Application/Events/UseCases/Publish/PublishEventHandler.cs:13 · Level 9 · class (sealed partial)

      -
    • What it is: the full-replacement update for one room inside an event. It is the widest command in this unit: two ids plus the six room fields (:15-23).
    • -
    • Depends on: ICacheInvalidating; Event (cache prefix); the EventIdentifierType and RoomIdentifierType aliases.
    • -
    • Concept reinforced, the whole-object update command. [Rubric §9, API & Contract Design] assesses whether a mutation contract is unambiguous: every optional field is declared nullable (Capacity, Floor, Location, AccessibilityInfo, :20-23) and is passed through to the domain as sent, so omitting one clears it rather than leaving it alone. That is the defining property of a PUT-shaped command, and it is why RoomsController binds it from a complete UpdateRoomRequest body (MMCA.ADC.Conference.API/Controllers/RoomsController.cs:175). [Rubric §21, Accessibility] is touched, though only at the data level: AccessibilityInfo (:23) is the field that carries a room's accessibility notes to attendees, so the write path preserves that information as a first-class member rather than folding it into a free-text description.
    • -
    • Walkthrough: a sealed record with eight positional parameters (:15-23), each documented individually (:6-14), and the single CachePrefix member (:25-26). Note what is absent: no RowVersion, so unlike UnpublishEventCommand a room edit is last-write-wins.
    • -
    • Where it's used: constructed by RoomsController's PUT /Rooms/{id} action (RoomsController.cs:179-187), which evicts the rooms output cache on success (:193); validated by UpdateRoomCommandValidator; handled by UpdateRoomHandler.
    • +
    • What it is: the handler for PublishEventCommand. It loads the event, applies the caller's concurrency token, asks the aggregate to publish itself, and saves.
    • +
    • Depends on: IUnitOfWork (PublishEventHandler.cs:14), ILogger<PublishEventHandler> (PublishEventHandler.cs:15), the ICommandHandler<in TCommand, TResult> contract returning a non-generic Result (PublishEventHandler.cs:15), the Event aggregate, and Error.
    • +
    • Concept introduced, stamping a client token as the tracked entity's original value. This is the application-side half of ADR-035 and it is three lines long. After loading the tracked aggregate, the handler calls repository.SetOriginalRowVersion(entity, command.RowVersion) (PublishEventHandler.cs:29) with the explanation inline (:27-28). The repository writes that value into EF's change tracker as the RowVersion property's original value (MMCA.Common.Infrastructure/Persistence/Repositories/EFRepository.cs:75-83), so the UPDATE that EF emits carries WHERE RowVersion = @clientToken. If someone else touched the row, zero rows match, EF raises DbUpdateConcurrencyException, and the global handler maps it to 409 Conflict (MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:276-284); on an endpoint decorated with SupportsIfMatchAttribute the same outcome is rewritten to 412 Precondition Failed instead, because under an explicit If-Match header a conflict is a failed precondition (MMCA.Common.API/Concurrency/SupportsIfMatchAttribute.cs:178-186). The guard that makes the whole thing opt-in lives in the repository: a null or empty token returns immediately and the check is simply not applied (EFRepository.cs:78-79). [Rubric §8, Data Architecture] assesses concurrency control: the arbitration is a database predicate, not an application-level compare. [Rubric §12, Performance & Scalability] assesses contention: optimistic concurrency takes no locks, so simultaneous readers are never blocked and only the losing writer pays. [Rubric §13, Observability & Operability] assesses diagnostics: the success path logs through a [LoggerMessage] source-generated method (PublishEventHandler.cs:41-42), which is allocation-free and emits EventId as a structured field.
    • +
    • Walkthrough: HandleAsync (PublishEventHandler.cs:18-39) resolves the repository with unitOfWork.GetRepository<Event, EventIdentifierType>() (:22), then loads with the two-argument GetByIdAsync(command.Id, cancellationToken) (:23). That overload is deliberately tracked (MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:170-181), which matters twice here: the mutation must persist, and SetOriginalRowVersion can only reach an entity EF is tracking. A missing event returns Error.NotFound.WithSource(nameof(PublishEventHandler)).WithTarget(nameof(Event)) (:24-25). Then the token is stamped (:29) and the decision is delegated: entity.Publish() (:31) fails with the invariant Event.AlreadyPublished when the flag is already set and otherwise sets IsPublished = true and raises EventChanged(DomainEntityState.Updated, ...) (MMCA.ADC.Conference.Domain/Events/Event.cs:272-288). Only on success does the handler save and log (:33-36), and the aggregate's own Result is returned verbatim (:38).
    • +
    • Why it's built this way: no include list is requested because the transition touches only the root's own flag, so loading the children would be wasted I/O. Saving inside the IsSuccess branch means a rejected transition never opens a write, and returning the domain result unchanged preserves the Event.AlreadyPublished code all the way to the HTTP response instead of flattening it into a generic 400.
    • +
    • Where it's used: dispatched by EventsController.PublishAsync (MMCA.ADC.Conference.API/Controllers/EventsController.cs:49,315-317), which converts a failure through HandleFailure and otherwise evicts the events output cache and returns 204 (EventsController.cs:319-323). Covered by PublishEventHandlerTests.
    • +
    • Caveats / not-in-source: ConfigureAwait(false) appears on the save (:34) but not on the load (:23). That is not a defect here: ADR-049 scopes the CA2007 gate to packaged framework code and explicitly leaves it off in the application repos, ADC included, so both forms are permitted in this file.

    RemoveEventQuestionAnswerHandler

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RemoveEventQuestionAnswer · MMCA.ADC.Conference.Application/Events/UseCases/RemoveEventQuestionAnswer/RemoveEventQuestionAnswerHandler.cs:14 · Level 9 · class

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RemoveEventQuestionAnswer · MMCA.ADC.Conference.Application/Events/UseCases/RemoveEventQuestionAnswer/RemoveEventQuestionAnswerHandler.cs:14 · Level 9 · class (sealed partial)

      -
    • What it is: the handler for RemoveEventQuestionAnswerCommand. It is the load-delegate-save template with one addition that the other removal handlers do not have: an ownership check (BR-52/BR-53, stated in the class comment at :10-13).
    • -
    • Depends on: ICommandHandler<in TCommand, TResult>; IUnitOfWork; ICurrentUserService and RoleNames; the Event aggregate and its EventQuestionAnswer child; Result and Error; Microsoft.Extensions.Logging.
    • -
    • Concept introduced, per-row ownership enforced in the handler. [Rubric §11, Security] assesses whether authorization is checked at the granularity the rule actually needs. A policy attribute on the controller can only answer "is this caller authenticated", not "does this caller own row 4711", so the row-level rule lives here: the answer is located in the loaded collection (:34), and the request is refused when the caller is not an Organizer and the answer's CreatedBy is not the caller's id (:35). CreatedBy is the audit field the framework stamps automatically on insert (MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/AuditableBaseEntity.cs:27), so ownership costs no extra column. The refusal is Error.Forbidden with the stable code EventQuestionAnswer.NotOwner (:37-42), which is what lets the API return 403 with a machine-readable reason rather than a bare status.
    • -
    • Walkthrough
        -
      • Primary constructor (:14-17): IUnitOfWork, ICurrentUserService, and a typed logger. The declared result is the non-generic Result, so no DTO mapper is involved.
      • -
      • HandleAsync (:20) resolves the Event repository from the unit of work rather than injecting it (:24), then loads with the two load-bearing arguments: includes: [nameof(Event.EventQuestionAnswers)] puts the child in memory for the domain method to find, and asTracking: true is what makes the soft-delete flag survive SaveChangesAsync (:25-29). A missing event returns Error.NotFound decorated with source and target (:30-31).
      • -
      • The ownership guard (:34-42) filters !a.IsDeleted when locating the answer (:34), so an already-removed row is not treated as someone's property. Note the guard is written as answer is not null && ...: when the id matches nothing, the guard falls through and the domain returns the not-found error instead, which keeps one shape of error for one condition.
      • -
      • entity.RemoveEventQuestionAnswer(command.EventQuestionAnswerId) (:44) is the domain decision. The aggregate resolves the child or returns not-found, calls its Delete(), and raises EventQuestionAnswerChanged with DomainEntityState.Deleted (MMCA.ADC.Conference.Domain/Events/Event.cs:655-669). Reaching into the collection from the handler would skip that event.
      • -
      • Only on success does it save and log (:45-49), through the generated LogQuestionAnswerRemovedFromEvent (:54-55); the domain Result is returned unchanged either way (:51).
      • -
      -
    • -
    • Why it's built this way: guarding SaveChangesAsync behind IsSuccess matters more than it looks. That single call is the boundary that stamps audit fields, dispatches domain events, and writes outbox rows, so a rejected removal publishes nothing. [Rubric §13, Observability & Operability]: the [LoggerMessage] source-generated log keeps the successful path structured and allocation-free.
    • -
    • Where it's used: registered by the module scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:112) and invoked through the decorator pipeline by EventQuestionAnswersController (MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:216-218).
    • -
    • Caveats / not-in-source: currentUserService.UserId!.Value (:35) is dereferenced with !. The controller is [Authorize(Policy = AuthorizationPolicies.RequireAuthenticated)] (EventQuestionAnswersController.cs:55), so an anonymous caller cannot reach it through that route, but nothing in this file enforces the invariant. Also worth noting: that controller performs no output-cache eviction after a mutation (there is no Evict call in it), unlike RoomsController (RoomsController.cs:211); the command's CachePrefix covers the handler-level read cache only.
    • +
    • What it is: the handler for RemoveEventQuestionAnswerCommand. It is the remove-child skeleton plus one authorization rule: an attendee may delete only their own answer, an Organizer may delete any (BR-52/BR-53, RemoveEventQuestionAnswerHandler.cs:11-12).
    • +
    • Depends on: IUnitOfWork (RemoveEventQuestionAnswerHandler.cs:15), ICurrentUserService (:16), ILogger<RemoveEventQuestionAnswerHandler> (:17), the ICommandHandler<in TCommand, TResult> contract (:17), RoleNames from MMCA.Common.Shared.Auth (:6,35), the Event aggregate with its EventQuestionAnswer children, and Result / Error.
    • +
    • Concept introduced, row-level ownership enforced in the handler. Role-based authorization at the endpoint answers "may this kind of user delete answers"; it cannot answer "may this user delete this answer". That second question needs the row, so it is asked here, after the aggregate is loaded and before the domain method is called. The predicate is a three-way test (:35): the answer exists, the caller is not in the Organizer role, and answer.CreatedBy != currentUserService.UserId!.Value. CreatedBy is not a field the client sends: it is stamped automatically by the audit pipeline when the row was written, and the current user is read from the ambient claims principal, so both sides of the comparison are server-owned. A failure returns Error.Forbidden with the code EventQuestionAnswer.NotOwner (:37-42), which is a distinct outcome from not-found and maps to 403 rather than 404. [Rubric §11, Security] assesses whether authorization is enforced at the resource, not only at the route: this is object-level authorization, the check that role attributes structurally cannot perform. [Rubric §4, Domain-Driven Design] assesses rule placement: ownership is an application-policy question about the caller, not an invariant of the aggregate, which is why it lives here while the "does this child exist" rule stays in the domain.
    • +
    • Walkthrough: HandleAsync (:20-52) resolves the event repository (:24) and loads with includes: [nameof(Event.EventQuestionAnswers)] and asTracking: true (:25-29); both are load-bearing, since the ownership scan reads the child collection and the soft delete must be tracked to persist. A missing event returns Error.NotFound sourced to the handler (:30-31). The candidate answer is found in memory with FirstOrDefault(a => a.Id == command.EventQuestionAnswerId && !a.IsDeleted) (:34), the ownership gate runs (:35-42), and only then does the aggregate act: entity.RemoveEventQuestionAnswer(command.EventQuestionAnswerId) (:44) re-finds the child through the framework helper GetChildOrNotFound, which returns Error.NotFound targeted at the child type when it is absent or already soft-deleted (MMCA.Common.Domain/Entities/AuditableAggregateRootEntity.cs:103-120), calls Delete() on it (MMCA.Common.Domain/Entities/AuditableBaseEntity.cs:47-59), and raises EventQuestionAnswerChanged(DomainEntityState.Deleted, ...) (MMCA.ADC.Conference.Domain/Events/Event.cs:684-700). Success saves and logs both identifiers through the generated LogQuestionAnswerRemovedFromEvent (:47-48, declared :54-55).
    • +
    • Why it's built this way: doing the ownership check against the already-loaded aggregate costs nothing extra, since the answers were included for the removal anyway. Returning Error.Forbidden rather than silently no-oping keeps the API honest about why the delete did not happen, and keeping the not-found decision inside the aggregate means the two failure modes cannot get out of sync with the soft-delete filter.
    • +
    • Where it's used: dispatched by EventQuestionAnswersController.DeleteAsync (MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:61,218-225), on a controller gated by [Authorize(Policy = AuthorizationPolicies.RequireAuthenticated)] (EventQuestionAnswersController.cs:56). Covered by RemoveEventQuestionAnswerHandlerTests.
    • +
    • Caveats / not-in-source: currentUserService.UserId!.Value (:35) is null-forgiven, so the handler assumes an authenticated caller and relies on the controller policy for that guarantee. Note also the branch order: when no matching answer is found the ownership gate is skipped entirely (answer is not null short-circuits at :35) and the request falls through to the aggregate, which answers Error.NotFound. A non-owner therefore receives 403 for an answer that exists and 404 for one that does not.

    RemoveEventSpeakerHandler

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RemoveEventSpeaker · MMCA.ADC.Conference.Application/Events/UseCases/RemoveEventSpeaker/RemoveEventSpeakerHandler.cs:13 · Level 9 · class

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RemoveEventSpeaker · MMCA.ADC.Conference.Application/Events/UseCases/RemoveEventSpeaker/RemoveEventSpeakerHandler.cs:13 · Level 9 · class (sealed partial)

      -
    • What it is: the handler for RemoveEventSpeakerCommand. It is the minimal form of the child-removal template: no ownership check, no concurrency token, five statements.
    • -
    • Depends on: ICommandHandler<in TCommand, TResult>; IUnitOfWork; the Event aggregate and its EventSpeaker child; Result and Error; Microsoft.Extensions.Logging.
    • -
    • Concept reinforced, the include-plus-tracking pair a removal requires. [Rubric §4, DDD] assesses whether children are mutated through the root: the handler never touches an EventSpeaker, it calls entity.RemoveEventSpeaker(...) (:31), and the aggregate resolves the child, deletes it, and raises EventSpeakerChanged (MMCA.ADC.Conference.Domain/Events/Event.cs:578-592). [Rubric §8, Data Architecture] covers the load arguments at :23-27, both required for the same reasons spelled out at RemoveEventQuestionAnswerHandler.
    • -
    • Walkthrough: sealed partial class with a two-parameter primary constructor (:13-15). HandleAsync (:18) resolves the repository (:22), loads the event with includes: [nameof(Event.EventSpeakers)] and asTracking: true (:23-27), returns Error.NotFound when the event is missing (:28-29), delegates to the aggregate (:31), and on success saves with ConfigureAwait(false) and logs through the generated LogSpeakerRemovedFromEvent (:32-36, declared :41-42). The domain Result is returned unchanged (:38).
    • -
    • Why it's built this way: the class comment (:9-12) states the whole design in one sentence, "loads the event aggregate with its speakers and delegates". Validation, cache eviction, and transaction scope are the pipeline's job (ADR-014), declared by the markers on the command, which is why the handler can be this small.
    • -
    • Where it's used: registered by the module scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:112); invoked by EventSpeakersController's delete action, which evicts both parents' output-cache entries afterwards (MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:237-247).
    • +
    • What it is: the handler for RemoveEventSpeakerCommand. It is the remove-child skeleton with nothing added: load the aggregate with its speakers, delegate, save, log.
    • +
    • Depends on: IUnitOfWork (RemoveEventSpeakerHandler.cs:14), ILogger<RemoveEventSpeakerHandler> (:15), the ICommandHandler<in TCommand, TResult> contract (:15), the Event aggregate, and Result / Error.
    • +
    • Concept: read this one as the minimal form of what RemoveEventQuestionAnswerHandler decorates. There is no ownership rule because an event-speaker link has no per-user owner: the endpoint's role gate is the whole authorization story. What both share is the include-plus-tracking pair (:23-27): a child soft delete mutates an object inside the root's collection, so the collection has to be loaded and EF has to be tracking it, otherwise the aggregate scans an empty list and reports not-found for a child that exists. [Rubric §5, Vertical Slice] assesses self-containment: the command and its handler live in one folder and share no base class with any sibling slice. [Rubric §1, SOLID] assesses dependency direction: the handler depends on IUnitOfWork, an application abstraction, not on a DbContext.
    • +
    • Walkthrough: HandleAsync (:18-39) resolves the repository (:22), loads by id with includes: [nameof(Event.EventSpeakers)] and asTracking: true (:23-27), returns Error.NotFound sourced to the handler and targeted at Event when absent (:28-29), then calls entity.RemoveEventSpeaker(command.EventSpeakerId) (:31). The aggregate resolves the child through GetEventSpeakerOrNotFound (MMCA.ADC.Conference.Domain/Events/Event.cs:609, helper at Event.cs:740-743), soft-deletes it and raises EventSpeakerChanged(DomainEntityState.Deleted, ...) (Event.cs:607-623). Success saves (:34) and logs the speaker and event ids through the generated LogSpeakerRemovedFromEvent (:35, declared :41-42); the domain result is returned as is (:38).
    • +
    • Why it's built this way: the handler adds no error text of its own on the domain path, so the aggregate stays the single author of "why not". That is what makes the four remove handlers in this module readable as one pattern with local variations rather than four independent implementations.
    • +
    • Where it's used: dispatched by EventSpeakersController.DeleteAsync (MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:50,239-246). Covered by RemoveEventSpeakerHandlerTests.

    RemoveRoomHandler

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RemoveRoom · MMCA.ADC.Conference.Application/Events/UseCases/RemoveRoom/RemoveRoomHandler.cs:13 · Level 9 · class

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RemoveRoom · MMCA.ADC.Conference.Application/Events/UseCases/RemoveRoom/RemoveRoomHandler.cs:13 · Level 9 · class (sealed partial)

      -
    • What it is: the handler for RemoveRoomCommand. Structurally identical to RemoveEventSpeakerHandler, line for line, with Event.Rooms swapped in for Event.EventSpeakers.
    • -
    • Depends on: ICommandHandler<in TCommand, TResult>; IUnitOfWork; the Event aggregate and its Room child; Result and Error; Microsoft.Extensions.Logging.
    • -
    • Concept reinforced: see RemoveEventSpeakerHandler for the include-plus-tracking pair and the delegate-to-the-root rule. [Rubric §4, DDD], [Rubric §8, Data Architecture].
    • -
    • Walkthrough: primary constructor (:13-15); HandleAsync (:18) resolves the repository (:22), loads with includes: [nameof(Event.Rooms)] and asTracking: true (:23-27), fails with Error.NotFound when absent (:28-29), calls entity.RemoveRoom(command.RoomId) (:31), and saves plus logs only on success (:32-36, generated method at :41-42). The domain method soft-deletes the room and raises RoomChanged with DomainEntityState.Deleted (MMCA.ADC.Conference.Domain/Events/Event.cs:482-496).
    • -
    • Why it's built this way: the repetition across the three removal handlers is deliberate rather than factored away. Each one is a vertical slice ([Rubric §5, Vertical Slice]), so a rule that later applies to only one of them (as the ownership rule already does on the question-answer path) can be added without touching the others.
    • -
    • Where it's used: registered by the module scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:112); invoked by RoomsController's delete action (MMCA.ADC.Conference.API/Controllers/RoomsController.cs:204-212).
    • +
    • What it is: the handler for RemoveRoomCommand. Structurally identical to RemoveEventSpeakerHandler, differing only in the include name, the aggregate method and the log message.
    • +
    • Depends on: IUnitOfWork (RemoveRoomHandler.cs:14), ILogger<RemoveRoomHandler> (:15), the ICommandHandler<in TCommand, TResult> contract (:15), the Event aggregate with its Room children, and Result / Error.
    • +
    • Concept: nothing new; the remove-child handler taught by RemoveEventSpeakerHandler. The one thing worth carrying away is what a soft delete means for a room specifically: sessions scheduled into it keep referencing a row that still exists, so history stays readable rather than turning into dangling identifiers (ADR-005). [Rubric §8, Data Architecture] assesses referential integrity under deletion: retiring the row keeps every prior reference resolvable.
    • +
    • Walkthrough: HandleAsync (:18-39) resolves the repository (:22), loads with includes: [nameof(Event.Rooms)] and asTracking: true (:23-27), returns Error.NotFound for a missing event (:28-29), and calls entity.RemoveRoom(command.RoomId) (:31), which resolves the child through GetRoomOrNotFound (MMCA.ADC.Conference.Domain/Events/Event.cs:513, helper at Event.cs:735-738), calls Delete() and raises RoomChanged(DomainEntityState.Deleted, ...) (Event.cs:511-525). Success saves (:34) and logs both ids through LogRoomRemoved (:35, declared :41-42).
    • +
    • Where it's used: dispatched by RoomsController.DeleteAsync, which takes the room id from the route and the event id from the query string (MMCA.ADC.Conference.API/Controllers/RoomsController.cs:96,311-318) and evicts the rooms output cache before returning 204 (RoomsController.cs:325-326). Covered by RemoveRoomHandlerTests.
    • +
    • Caveats / not-in-source: the room's own name-uniqueness rule (Event.Room.Duplicate, MMCA.ADC.Conference.Domain/Events/Event.cs:721-732) excludes soft-deleted rooms from its scan, so a name freed by this handler becomes reusable immediately.

    UnpublishEventHandler

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Unpublish · MMCA.ADC.Conference.Application/Events/UseCases/Unpublish/UnpublishEventHandler.cs:13 · Level 9 · class

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Unpublish · MMCA.ADC.Conference.Application/Events/UseCases/Unpublish/UnpublishEventHandler.cs:13 · Level 9 · class (sealed partial)

      -
    • What it is: the handler for UnpublishEventCommand. It loads the event, arms the optimistic-concurrency check, and delegates the state transition to the aggregate.
    • -
    • Depends on: ICommandHandler<in TCommand, TResult>; IUnitOfWork and the repository's SetOriginalRowVersion (IRepository<TEntity, TIdentifierType>, declared at MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:197); the Event aggregate; Result and Error; Microsoft.Extensions.Logging.
    • -
    • Concept introduced, arming optimistic concurrency by stamping the original value. [Rubric §8, Data Architecture] assesses how concurrent writers are reconciled. EF Core decides a concurrency conflict by comparing the original value it holds for a rowversion property against the row in the database at save time. Freshly loading the entity makes the original value whatever is on disk right now, which detects nothing. repository.SetOriginalRowVersion(entity, command.RowVersion) (:29) overwrites that original with the token the client saw, so the UPDATE carries the client's version in its WHERE clause and affects zero rows if anyone changed the event in the meantime, which EF surfaces as a concurrency exception and the API turns into 409. The comment above the call states the rule and the null case (:27-28), and the implementation is a no-op for a null or empty token (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFRepository.cs:75-84). Rationale in ADR-035.
    • -
    • Walkthrough
        -
      • Primary constructor (:13-15): unit of work and a typed logger.
      • -
      • HandleAsync (:18) resolves the repository (:22) and loads with the include-free single-argument overload, GetByIdAsync(command.Id, cancellationToken) (:23). This is the one place in this unit where tracking is not requested explicitly, and it still works: that overload queries the tracked Table deliberately, documented at MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:172-176, because the generic update and delete handlers load through it, mutate, and save. A no-tracking load here would make both the rowversion stamp and the transition silent no-ops.
      • -
      • Error.NotFound with source and target when the event is missing (:24-25), then the rowversion stamp (:29).
      • -
      • entity.Unpublish() (:31) is the domain decision: the aggregate refuses an event that is already unpublished with the stable code Event.AlreadyUnpublished, flips IsPublished to false, and raises EventChanged with DomainEntityState.Updated (MMCA.ADC.Conference.Domain/Events/Event.cs:278-294).
      • -
      • Save and log only on success (:32-36), through the generated LogEventUnpublished (:41-42); the domain Result is returned unchanged (:38).
      • -
      -
    • -
    • Why it's built this way: the handler owns the plumbing of the stale-view check while the aggregate owns the legality of the transition. Neither knows about the other's rule, which is what keeps "can this event be unpublished" testable without a database.
    • -
    • Where it's used: registered by the module scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:112); invoked by EventsController's unpublish action (MMCA.ADC.Conference.API/Controllers/EventsController.cs:323-325).
    • -
    • Caveats / not-in-source: the translation from EF's concurrency exception to a 409 response is not in this file. The handler returns a domain Result; the conflict surfaces from SaveChangesAsync and is mapped by the shared API middleware.
    • +
    • What it is: the handler for UnpublishEventCommand, the exact mirror of PublishEventHandler with entity.Unpublish() in place of entity.Publish().
    • +
    • Depends on: IUnitOfWork (UnpublishEventHandler.cs:14), ILogger<UnpublishEventHandler> (:15), the ICommandHandler<in TCommand, TResult> contract (:15), the Event aggregate, and Result / Error.
    • +
    • Concept: nothing new; the stale-view guard taught by PublishEventHandler, including the same inline explanation of why the token is stamped before the transition (:27-28). Unpublishing is the direction where the guard earns its keep: hiding an event that a colleague has just re-published, based on a page rendered minutes ago, is precisely the mistake ADR-035 is meant to convert into a 409 or a 412 rather than a silent overwrite. [Rubric §6, CQRS & Event-Driven] assesses intent modelling: the reverse transition is its own command and its own handler, so both directions are separately auditable and separately loggable.
    • +
    • Walkthrough: HandleAsync (:18-39) resolves the repository (:22), loads the tracked aggregate with the two-argument GetByIdAsync (:23), returns Error.NotFound when absent (:24-25), stamps the client token (:29), and calls entity.Unpublish() (:31), which fails with the invariant Event.AlreadyUnpublished when the flag is already clear and otherwise clears IsPublished and raises EventChanged(DomainEntityState.Updated, ...) (MMCA.ADC.Conference.Domain/Events/Event.cs:292-308). Success saves and logs through LogEventUnpublished (:34-35, declared :41-42).
    • +
    • Where it's used: dispatched by EventsController.UnpublishAsync (MMCA.ADC.Conference.API/Controllers/EventsController.cs:50,346-348), which evicts the events output cache and returns 204 on success (EventsController.cs:350-354). Covered by UnpublishEventHandlerTests.
    • +
    • Caveats / not-in-source: unpublishing changes what non-privileged readers can see, since the read paths filter on IsPublished, but nothing in this handler evicts a cached read directly. That is the caching decorator's job, driven by the CachePrefix on UnpublishEventCommand (MMCA.Common.Application/UseCases/Decorators/CachingCommandDecorator.cs:76-89), with the separate output cache evicted by the controller.

    UpdateEventQuestionAnswerHandler

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.UpdateEventQuestionAnswer · MMCA.ADC.Conference.Application/Events/UseCases/UpdateEventQuestionAnswer/UpdateEventQuestionAnswerHandler.cs:14 · Level 9 · class

    -
    -
      -
    • What it is: the handler for UpdateEventQuestionAnswerCommand. It is the edit twin of RemoveEventQuestionAnswerHandler and carries the same BR-52/BR-53 ownership rule (:10-13).
    • -
    • Depends on: ICommandHandler<in TCommand, TResult>; IUnitOfWork; ICurrentUserService and RoleNames; the Event aggregate and its EventQuestionAnswer child; Result and Error; Microsoft.Extensions.Logging.
    • -
    • Concept reinforced, per-row ownership. The guard is the same expression as on the removal path, down to the error code: not an Organizer plus answer.CreatedBy != currentUserService.UserId!.Value yields Error.Forbidden with code EventQuestionAnswer.NotOwner and the message "You can only update your own answers." (:34-42). [Rubric §11, Security], [Rubric §16, Maintainability]: the duplication between the two handlers is real and visible; it is the price of keeping each slice independent, and it is worth knowing about when the rule changes, because it has to change in two files.
    • -
    • Walkthrough: primary constructor (:14-17); HandleAsync (:20) resolves the repository (:24), loads with includes: [nameof(Event.EventQuestionAnswers)] and asTracking: true (:25-29), fails not-found when the event is missing (:30-31), runs the ownership guard (:34-42), then calls entity.UpdateEventQuestionAnswer(id, answerValue) (:44-46). The aggregate resolves the child, forwards to its UpdateAnswer, and raises EventQuestionAnswerChanged with DomainEntityState.Updated (MMCA.ADC.Conference.Domain/Events/Event.cs:632-648), so an invalid answer value fails before anything is saved. Save and log run only on success (:47-51, generated method at :56-57), and the domain Result is returned unchanged (:53).
    • -
    • Where it's used: registered by the module scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:112); invoked by EventQuestionAnswersController's update action (MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:200-202).
    • -
    -

    UpdateRoomCommandValidator

    -
    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.UpdateRoom · MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomCommandValidator.cs:7 · Level 9 · class

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.UpdateEventQuestionAnswer · MMCA.ADC.Conference.Application/Events/UseCases/UpdateEventQuestionAnswer/UpdateEventQuestionAnswerHandler.cs:14 · Level 9 · class (sealed partial)

      -
    • What it is: the FluentValidation validator for UpdateRoomCommand. It declares no rule of its own; its entire body composes six shared rule sets (:11-16).
    • -
    • Depends on: FluentValidation's AbstractValidator<T> (NuGet); RoomNameRules<T>, RoomSortRules<T>, RoomCapacityRules<T>, RoomFloorRules<T>, RoomLocationRules<T>, and RoomAccessibilityInfoRules<T>.
    • -
    • Concept reinforced, rule composition with Include. [Rubric §16, Maintainability] assesses whether one constraint is written once: rather than restate the room constraints in the add validator and again here, both Include the same generic rule sets, parameterized by a property selector. Include merges the included validator's rules in as though they had been declared inline, so composition costs nothing at validation time. [Rubric §24, Forms, Validation & UX Safety] covers what those rules produce: each carries a human message and a stable error code, for example Room.Name.Required and Room.Name.MaxLength (MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:17-18), so a client can branch on the code instead of parsing English. The ceilings come from the domain, not from the validator: EventInvariants.RoomNameMaxLength is 255, RoomFloorMaxLength 100, RoomLocationMaxLength 255, and RoomAccessibilityInfoMaxLength 500 (MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:40, :43, :46, :49), which are the same constants the EF column configuration uses, so the form limit and the column width cannot drift apart.
    • -
    • Walkthrough: a sealed class whose whole body is a six-line constructor (:9-17). RoomNameRules<T> is required plus max length (RoomValidationRules.cs:12-19); RoomSortRules<T> demands a non-negative sort (:25-31); RoomCapacityRules<T> demands a positive capacity but only when one is supplied, via .When(x => selector.Compile()(x) is not null) (:37-44); the floor, location, and accessibility rule sets are max-length only, so a null value passes (:51-57, :64-70, :77-83).
    • -
    • Why it's built this way: extracting field rules into shared generic classes is the module-wide convention; add a constraint once and every command that includes the rule set picks it up. Running them ahead of the transaction is the pipeline's job: ValidatingCommandDecorator<TCommand, TResult> sits outside ITransactional (ADR-014), so a malformed room never opens a database transaction.
    • -
    • Where it's used: discovered by the module's validator scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:112) and run by ValidatingCommandDecorator<TCommand, TResult> ahead of UpdateRoomHandler. Compare AddRoomCommandValidator, which includes the same rule sets for the add path.
    • -
    • Caveats / not-in-source: name uniqueness within an event is not validated here. It cannot be: the rule needs the event's other rooms, so it lives in the aggregate as EnsureRoomNameIsUnique and comes back as the invariant error Event.Room.Duplicate (MMCA.ADC.Conference.Domain/Events/Event.cs:687-704).
    • +
    • What it is: the handler for UpdateEventQuestionAnswerCommand. It applies the same BR-52/BR-53 ownership gate as its remove twin and then asks the aggregate to change the answer text (UpdateEventQuestionAnswerHandler.cs:10-13).
    • +
    • Depends on: IUnitOfWork (:15), ICurrentUserService (:16), ILogger<UpdateEventQuestionAnswerHandler> (:17), the ICommandHandler<in TCommand, TResult> contract (:17), RoleNames (:6,35), the Event aggregate with its EventQuestionAnswer children, and Result / Error.
    • +
    • Concept: nothing new; the row-level ownership check taught by RemoveEventQuestionAnswerHandler, reproduced line for line with "You can only update your own answers." in place of the delete message (:37-42). Reading the two side by side is the fastest way to see that the rule is a policy about the caller and the row, not about the verb. The one difference downstream is that the update path re-validates the payload inside the domain: EventQuestionAnswer.UpdateAnswer runs EventInvariants.EnsureAnswerValueIsValid before assigning, so an empty or over-long answer is rejected by the entity even if it reached the handler (MMCA.ADC.Conference.Domain/Events/EventQuestionAnswer.cs:71-80). [Rubric §11, Security] assesses object-level authorization: identity comes from the ambient principal and the owner comes from the persisted audit field, never from the command. [Rubric §4, Domain-Driven Design] assesses invariant placement: the text rule stays on the entity, so no future caller can bypass it by writing a new handler.
    • +
    • Walkthrough: HandleAsync (:20-54) resolves the repository (:24), loads with includes: [nameof(Event.EventQuestionAnswers)] and asTracking: true (:25-29), returns Error.NotFound for a missing event (:30-31), finds the candidate answer in the loaded collection (:34), applies the ownership gate returning Error.Forbidden with the code EventQuestionAnswer.NotOwner (:35-42), then calls entity.UpdateEventQuestionAnswer(command.EventQuestionAnswerId, command.AnswerValue) (:44-46). The aggregate re-resolves the child through GetEventQuestionAnswerOrNotFound (MMCA.ADC.Conference.Domain/Events/Event.cs:665, helper at Event.cs:745-748), delegates to answer.UpdateAnswer(...) and raises EventQuestionAnswerChanged(DomainEntityState.Updated, ...) (Event.cs:661-680). Success saves (:49) and logs through LogEventQuestionAnswerUpdated (:50, declared :56-57).
    • +
    • Why it's built this way: the ownership predicate is duplicated between this handler and the remove handler rather than hoisted into a shared helper. With one condition and one message each, the two slices stay independently readable and independently changeable, which is the trade the vertical-slice layout is making on purpose.
    • +
    • Where it's used: dispatched by EventQuestionAnswersController.UpdateAsync on PUT {id} (MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:60,202-209), which returns 204 on success. Covered by UpdateEventQuestionAnswerHandlerTests.
    • +
    • Caveats / not-in-source: as in the remove twin, currentUserService.UserId!.Value (:35) assumes an authenticated caller, and the log line records only the answer id, not the editor (:56-57), so the audit trail for "who changed this text" lives in the row's LastModifiedBy stamp rather than in the log.
    -

    UpdateRoomHandler

    +

    CreateEventHandler

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.UpdateRoom · MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomHandler.cs:13 · Level 9 · class

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Create · MMCA.ADC.Conference.Application/Events/UseCases/Create/CreateEventHandler.cs:16 · Level 10 · class (sealed partial)

      -
    • What it is: the handler for UpdateRoomCommand. Same load-delegate-save template as RemoveRoomHandler, with all six room fields forwarded to the aggregate.
    • -
    • Depends on: ICommandHandler<in TCommand, TResult>; IUnitOfWork; the Event aggregate and its Room child; Result and Error; Microsoft.Extensions.Logging.
    • -
    • Concept reinforced, the handler as a pass-through to the root. [Rubric §4, DDD] assesses where the rules live, and this handler is the clearest example in the unit of them living elsewhere: it re-checks nothing that UpdateRoomCommandValidator already checked and decides nothing the aggregate decides. Event.UpdateRoom (MMCA.ADC.Conference.Domain/Events/Event.cs:397-422) resolves the room or returns not-found (:406-409), enforces name uniqueness excluding the room being edited (:411-413), forwards to room.Update(...) for the field-level invariants (:415-417), and raises RoomChanged with DomainEntityState.Updated only after all three pass (:419). [Rubric §3, Clean Architecture]: the handler touches abstractions only, with no EF type in sight.
    • -
    • Walkthrough: primary constructor (:13-15); HandleAsync (:18) resolves the repository (:22), loads with includes: [nameof(Event.Rooms)] and asTracking: true (:23-27) because both the uniqueness check and the mutation need the sibling rooms in memory and tracked, returns Error.NotFound when the event is missing (:28-29), forwards the seven command members positionally to entity.UpdateRoom(...) (:31-38), and saves plus logs only on success (:39-43, generated method at :48-49). The domain Result is returned unchanged (:45).
    • -
    • Why it's built this way: the uniqueness rule is the reason the whole Rooms collection is loaded for what looks like a single-row edit. It is an aggregate-scoped invariant, so it can only be answered with the aggregate in hand; pushing it to a database index alone would surface as an opaque constraint violation instead of the typed Event.Room.Duplicate error.
    • -
    • Where it's used: registered by the module scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:112); invoked through the decorator pipeline by RoomsController's PUT /Rooms/{id} action (MMCA.ADC.Conference.API/Controllers/RoomsController.cs:178-188), which returns 204 No Content and evicts the rooms output cache on success (:193-194).
    • +
    • What it is: the handler that creates an Event. It owns no construction logic and no mapping logic: it orchestrates three collaborators (request mapper, repository, DTO mapper) and returns the created EventDTO.
    • +
    • Depends on: IUnitOfWork (CreateEventHandler.cs:17), IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> closed over Event, EventCreateRequest and EventIdentifierType (:18), EventDTOMapper injected as a concrete class (:19), ILogger<CreateEventHandler> (:20), the ICommandHandler<in TCommand, TResult> contract returning Result<EventDTO> (:20), and Result.
    • +
    • Concept introduced, the create slice as pure composition. Three details make this handler the reference for every create in the module. First, the entity arrives already built and already validated: requestMapper.CreateEntityAsync(command, cancellationToken) returns a Result, and a failure is propagated with its errors intact rather than reworded (:27-29). Second, the repository is obtained per use case from the unit of work, unitOfWork.GetRepository<Event, EventIdentifierType>() (:32), never constructor-injected; injecting IRepository<TEntity, TIdentifierType> directly would bypass the unit of work that owns the transaction and the change tracker. Third, AddAsync stages and SaveChangesAsync commits (:34-35), and that single save is the transaction boundary for three separate effects: the row, the audit stamps applied by the DbContext, and the outbox record for the EventChanged domain event the factory attached (ADR-003, event raised at MMCA.ADC.Conference.Domain/Events/Event.cs:207). Nothing can be persisted without its notification, and nothing can be notified without being persisted. [Rubric §3, Clean Architecture] assesses dependency direction: every collaborator here is an application or domain abstraction, and the only concrete type is the module's own DTO mapper. [Rubric §6, CQRS & Event-Driven] assesses the write path: one command type in, one DTO out, one save. [Rubric §14, Testability] assesses substitutability: four constructor parameters, all interfaces or a pure mapper, so the handler is exercised without a database. [Rubric §13, Observability & Operability] assesses diagnostics: the success log is a [LoggerMessage] method carrying EventId and Name as structured fields (:42-43).
    • +
    • Walkthrough: the primary constructor declares the four dependencies (:16-20). HandleAsync (:23-40) awaits the request mapper (:27), returns Result.Failure<EventDTO>(result.Errors) on failure (:28-29), unwraps the entity (:31), resolves the repository (:32), stages the insert with AddAsync(entity, cancellationToken).ConfigureAwait(false) (:34), commits with SaveChangesAsync (:35), logs (:37), and returns Result.Success(dtoMapper.MapToDTO(entity)) (:39). Note that the DTO is produced from the entity after the save, so a database-generated Id is present in the response.
    • +
    • Why it's built this way: this handler is what makes the inherited CRUD endpoint work. AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest> takes an ICommandHandler<TCreateRequest, Result<TEntityDTO>> in its constructor (MMCA.Common.API/Controllers/AggregateRootEntityControllerBase.cs:33,48) and its CreateAsync does nothing but call it and translate the result into 201 Created with a CreatedAtRoute location (AggregateRootEntityControllerBase.cs:63-76). Because this handler satisfies that closed generic, the whole create endpoint for events is inherited rather than written. The pipeline decorators (logging, caching, transactional) wrap it without it knowing, which is why there is no try/catch and no cache call in the file.
    • +
    • Where it's used: injected into EventsController as ICommandHandler<EventCreateRequest, Result<EventDTO>> (MMCA.ADC.Conference.API/Controllers/EventsController.cs:47) and passed to the base controller (EventsController.cs:58); reached through the overridden CreateAsync, which calls base.CreateAsync(request, cancellationToken) and then evicts the events output cache (EventsController.cs:254-262). Registered by the convention scan, which binds every ICommandHandler<,> implementation to its interfaces with a scoped lifetime (MMCA.Common.Application/DependencyInjection.cs:178-182, invoked at MMCA.ADC.Conference.Application/DependencyInjection.cs:125). Covered by CreateEventHandlerTests. Its update-side counterpart is UpdateEventHandler and its delete-side counterpart is DeleteEventHandler.
    • +
    • Caveats / not-in-source: the handler never inspects command.Id, and neither does the aggregate for this type (see the caveat on EventCreateRequest), so a client-supplied event id is silently ignored rather than rejected. Idempotency for retried POSTs is handled entirely outside this file, by IdempotentAttribute on the endpoint (ADR-017); the handler itself would insert a second row if invoked twice.

    AddSpeakerCategoryItemCommand

    @@ -3167,22 +3411,10 @@

    AddSpeakerCategoryItemCommand

    • What it is: the command that tags a speaker with a category item. Category items are how a speaker's topics and locality are modeled, so this is the "tag this speaker" message. Three positional parameters: the owning SpeakerId, an optional SpeakerCategoryItemId for the join entity, and the CategoryItemId being associated (AddSpeakerCategoryItemCommand.cs:13-16).
    • Depends on: ICacheInvalidating, the pipeline marker it implements (AddSpeakerCategoryItemCommand.cs:16); the Speaker domain type, referenced only to build the cache prefix; and the SpeakerIdentifierType / SpeakerCategoryItemIdentifierType / CategoryItemIdentifierType module aliases (ADR-048).
    • -
    • Concept introduced, the nullable child id on an Add command: the second parameter is SpeakerCategoryItemIdentifierType?, documented as "Explicit ID for the new join entity, or null for database-generated identity" (AddSpeakerCategoryItemCommand.cs:11). The REST path always passes null and lets the database assign the key (SpeakerCategoryItemsController at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:216); the parameter exists so a caller that already knows the id can supply it, which is exactly the shape the aggregate's factory call takes (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:302-304). [Rubric §9, API and Contract Design] assesses whether a contract states precisely what a caller may decide: making the id nullable rather than defaulted keeps "let the database choose" distinct from "I chose zero".
    • +
    • Concept introduced, the nullable child id on an Add command: the second parameter is SpeakerCategoryItemIdentifierType?, documented as "Explicit ID for the new join entity, or null for database-generated identity" (AddSpeakerCategoryItemCommand.cs:11). The REST path always passes null and lets the database assign the key (SpeakerCategoryItemsController at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:224); the parameter exists so a caller that already knows the id can supply it, which is exactly the shape the aggregate's method takes (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:314-316). [Rubric §9, API and Contract Design] assesses whether a contract states precisely what a caller may decide: making the id nullable rather than defaulted keeps "let the database choose" distinct from "I chose zero".
    • Walkthrough: the record body holds one member, CachePrefix => $"{typeof(Speaker).FullName}:" (AddSpeakerCategoryItemCommand.cs:18-19). That satisfies ICacheInvalidating, so CachingCommandDecorator<TCommand, TResult> evicts every cache entry under the Speaker prefix after the command succeeds. The join row has no cache namespace of its own, which is the point: tagging a speaker flushes the whole speaker read surface in one stroke rather than requiring per-query bookkeeping, so a stale nested category item cannot survive inside an already-cached speaker.
    • -
    • Why it's built this way: caching and invalidation are declared by the message and applied uniformly by the pipeline (ADR-026, ADR-014), never hand-wired inside a handler. [Rubric §10, Cross-Cutting]: the command says what it invalidates; it does not know how. Note what is absent: no ITransactional, because the whole write lands in one aggregate and one SaveChangesAsync with no cross-context event to keep atomic (contrast LinkUserToSpeakerCommand).
    • -
    • Where it's used: validated by AddSpeakerCategoryItemCommandValidator, handled by AddSpeakerCategoryItemHandler, and constructed by the POST /SpeakerCategoryItems action from an AddSpeakerCategoryItemRequest body (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:212-217), on a controller gated by the SpeakersManage permission (SpeakerCategoryItemsController.cs:46, ADR-020). Its mirror image is RemoveSpeakerCategoryItemCommand.
    • -
    -

    LinkUserToSpeakerCommand

    -
    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.LinkUser · MMCA.ADC.Conference.Application/Speakers/UseCases/LinkUser/LinkUserToSpeakerCommand.cs:13 · Level 8 · record

    -
    -
      -
    • What it is: the write message an organizer sends to attach an application account to a speaker profile (BR-209). Two ids and nothing else: SpeakerId and UserId (LinkUserToSpeakerCommand.cs:13).
    • -
    • Depends on: ICacheInvalidating and ITransactional, both markers implemented at LinkUserToSpeakerCommand.cs:13; Speaker, referenced only to build the cache prefix; and the module identifier aliases SpeakerIdentifierType (a GUID here) and UserIdentifierType (an int owned by Identity).
    • -
    • Concept introduced, the command that declares its own transaction: [Rubric §6, CQRS and Event-Driven] assesses whether a write is modeled as an explicit single-purpose message: this record carries intent only, and the two marker interfaces tell the pipeline how to run it. [Rubric §10, Cross-Cutting] assesses whether such concerns are declared rather than hand-coded. Implementing ITransactional opts the message into TransactionalCommandDecorator<TCommand, TResult>, and the XML comment states the reason plainly (LinkUserToSpeakerCommand.cs:7-8): the Speaker link and the outbox row that carries the cross-context User update must commit together or not at all. Implementing ICacheInvalidating with CachePrefix => $"{typeof(Speaker).FullName}:" (LinkUserToSpeakerCommand.cs:16) is what makes CachingCommandDecorator<TCommand, TResult> drop every cached read keyed under the Speaker type after a successful link.
    • -
    • Walkthrough: a sealed record with a two-parameter positional constructor (LinkUserToSpeakerCommand.cs:13) and one member, the expression-bodied CachePrefix (LinkUserToSpeakerCommand.cs:15-16). The cross-module identifier pairing is the notable part: UserIdentifierType is Identity's alias, carried here as a plain scalar because the two modules own separate databases and there is no foreign key to point at (ADR-006).
    • -
    • Why it's built this way: the two markers move durability and cache eviction out of the handler and into the pipeline, so LinkUserToSpeakerHandler reads as pure domain orchestration. Records give value equality and immutability for free.
    • -
    • Where it's used: constructed by the PUT /Speakers/{id}/link action from a LinkUserRequest body (SpeakersController at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:372, handler injected at SpeakersController.cs:49, permission-gated at SpeakersController.cs:365); handled by LinkUserToSpeakerHandler. Its inverse is UnlinkUserFromSpeakerCommand.
    • +
    • Why it's built this way: caching and invalidation are declared by the message and applied uniformly by the pipeline (ADR-026, ADR-014), never hand-wired inside a handler. [Rubric §10, Cross-Cutting]: the command says what it invalidates; it does not know how. Note what is absent: no ITransactional, because the whole write lands in one aggregate and one SaveChangesAsync with no cross-context event to keep atomic.
    • +
    • Where it's used: validated by AddSpeakerCategoryItemCommandValidator, handled by AddSpeakerCategoryItemHandler, and constructed by the POST /SpeakerCategoryItems action from an AddSpeakerCategoryItemRequest body (SpeakerCategoryItemsController.cs:217-225, handler injected at SpeakerCategoryItemsController.cs:50), on a controller gated by the SpeakersManage permission (SpeakerCategoryItemsController.cs:47, ADR-020). That action also carries [Idempotent] (SpeakerCategoryItemsController.cs:218), so a retried request with the same Idempotency-Key replays the first response instead of adding a second row. Its mirror image is RemoveSpeakerCategoryItemCommand.

    QuestionCreateRequest

    @@ -3194,8 +3426,26 @@

    QuestionCreateRequest

  • Concept introduced, the request whose id field is accepted and then thrown away: [Rubric §9, API and Contract Design] assesses whether a contract is honest about what the caller controls. Id is a settable init member (QuestionCreateRequest.cs:16), but its own doc comment says "Auto-generated by the handler; caller-provided values are ignored". That is not laziness: the question id space is shared with Sessionize, so CreateQuestionHandler allocates from a reserved manual range and overwrites whatever arrived (CreateQuestionHandler.cs:87). Compare SpeakerCreateRequest, where the caller-supplied id IS honored because a speaker id is a Sessionize-assigned GUID. Two create requests in the same module, opposite id policies, and the only place either is stated is a one-line comment on the property.
  • Walkthrough: a record class (not sealed) with init-only members. CachePrefix => $"{typeof(Question).FullName}:" (QuestionCreateRequest.cs:13) is the invalidation tag. Exactly one member is required, QuestionText (QuestionCreateRequest.cs:19), so the record cannot be constructed without it. The rest are optional: QuestionEntity (QuestionCreateRequest.cs:22), QuestionType (QuestionCreateRequest.cs:25), Sort (QuestionCreateRequest.cs:28), and IsRequired (QuestionCreateRequest.cs:31).
  • Why it's built this way: required plus init gives compile-time enforcement of the minimum payload while leaving the rest optional, and collapsing request and command into one type keeps a simple create slice to a single message. [Rubric §5, Vertical Slice]: the request, its mapper, its validator, and its handler all sit in Questions/UseCases/Create.
  • -
  • Where it's used: bound from the body by QuestionsController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/QuestionsController.cs:93), and it is also the fourth generic argument of that controller's base AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest> (QuestionsController.cs:38-39); validated by QuestionCreateRequestValidator; translated to a domain entity by QuestionCreateRequestMapper; handled by CreateQuestionHandler.
  • -
  • Caveats / not-in-source: QuestionEntity and QuestionType are declared nullable here, but Question.Create takes them as non-nullable and validates both against closed value lists (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:28, QuestionInvariants.cs:31). QuestionCreateRequestMapper bridges the gap with ! (QuestionCreateRequestMapper.cs:22-23), so omitting either field is not a binding error but an invariant failure at create time. Nothing on this record says so.
  • +
  • Where it's used: bound from the body by QuestionsController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/QuestionsController.cs:91-93), and it is also the fourth generic argument of that controller's base AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest> (QuestionsController.cs:38-39); validated by QuestionCreateRequestValidator; translated to a domain entity by QuestionCreateRequestMapper; handled by CreateQuestionHandler.
  • +
  • Caveats / not-in-source: QuestionEntity and QuestionType are declared nullable here, but Question.Create takes them as non-nullable and validates both against closed value lists, ["Session", "Event", "Speaker"] and ["Rating", "Text", "Email"] (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:28, QuestionInvariants.cs:31). QuestionCreateRequestMapper bridges the gap with ! (QuestionCreateRequestMapper.cs:22-23), so omitting either field is not a binding error but an invariant failure at create time. Nothing on this record says so.
  • + +

    QuestionDTOMapper

    +
    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Questions.DTOs · MMCA.ADC.Conference.Application/Questions/DTOs/QuestionDTOMapper.cs:12 · Level 8 · class (sealed partial)

    +
    +
      +
    • What it is: the outbound mapper that turns a Question aggregate into the QuestionDTO the API returns. Its single-entity method has no body: Mapperly generates it at compile time from the [Mapper] attribute (QuestionDTOMapper.cs:11).
    • +
    • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over Question, QuestionDTO, and the QuestionIdentifierType alias (QuestionDTOMapper.cs:13); the Question entity and the QuestionDTO contract; Riok.Mapperly.Abstractions (NuGet, QuestionDTOMapper.cs:4).
    • +
    • Concept reinforced, source-generated DTO mapping: [Rubric §2, Design Patterns] assesses whether repetitive translation code is factored out rather than hand-written per property: the partial declaration at QuestionDTOMapper.cs:16 is the whole contribution, and the generator emits the property-by-property assignment into a companion generated file. [Rubric §12, Performance and Scalability] assesses the cost of that translation: because the body is generated, mapping is straight-line assignment with no reflection and no expression compilation on the hot read path. [Rubric §3, Clean Architecture] assesses direction: the domain type never leaves the Application layer, only the DTO does. The convention and its trade-offs are set out in ADR-001; contrast this with the inbound *RequestMapper classes such as QuestionCreateRequestMapper, which are hand-written because they must call a factory and are allowed to fail.
    • +
    • Walkthrough
        +
      • [Mapper] (QuestionDTOMapper.cs:11) is the generator trigger; the class is sealed partial (QuestionDTOMapper.cs:12) so the generated half can be merged in.
      • +
      • public partial QuestionDTO MapToDTO(Question entity) (QuestionDTOMapper.cs:16) is the generated member. Mapping is by name, and the pairs line up one for one: QuestionText, QuestionEntity, QuestionType, Sort, IsRequired, and QuestionSource on the entity (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/Question.cs:17-32) against the same names on the DTO (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Questions/QuestionDTO.cs:18-33), plus the Id and RowVersion members the DTO declares for IBaseDTO<QuestionIdentifierType> and IConcurrencyAware (QuestionDTO.cs:12-15).
      • +
      • MapToDTOs (QuestionDTOMapper.cs:19-23) is hand-written, not generated: it null-guards the input (QuestionDTOMapper.cs:21) and projects with a collection expression over the generated single-item mapper, [.. entityCollection.Select(MapToDTO)] (QuestionDTOMapper.cs:22).
      • +
      +
    • +
    • Why it's built this way: the three string members are non-nullable on the entity (Question.cs:17, Question.cs:20, Question.cs:32) and nullable on the DTO (QuestionDTO.cs:21, QuestionDTO.cs:24, QuestionDTO.cs:33), which is the usual direction for a read contract: the DTO tolerates more than the domain produces, so a contract change does not force a domain change.
    • +
    • Where it's used: injected as a concrete type by CreateQuestionHandler (CreateQuestionHandler.cs:23) and UpdateQuestionHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Questions/UseCases/Update/UpdateQuestionHandler.cs:21); resolved through its interface by EntityQueryService<TEntity, TEntityDTO, TIdentifierType> (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:35), which is registered for Question at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:76 and drives every read on QuestionsController. The mapper itself is picked up by the module scan (DependencyInjection.cs:125). Covered by QuestionDTOMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Questions/DTOs/QuestionDTOMapperTests.cs).
    • +
    • Caveats / not-in-source: the generated assignments are not readable in this file, only in build output, so a member added to the DTO without a matching entity member surfaces as a generator diagnostic at build time rather than as anything visible here. Note also that this mapper redacts nothing: unlike SpeakerDTOMapper, which withholds an email from non-organizers, every question member is copied verbatim.

    SpeakerCategoryItemDTOMapper

    @@ -3204,29 +3454,16 @@

    SpeakerCategoryItemDTOMapper

    • What it is: the entity-to-DTO mapper for the SpeakerCategoryItem join entity. It is a Mapperly source-generated mapper: the class declares the signature, the generator writes the body at compile time.
    • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over SpeakerCategoryItem / SpeakerCategoryItemDTO / SpeakerCategoryItemIdentifierType (SpeakerCategoryItemDTOMapper.cs:13), and the Riok.Mapperly.Abstractions package for the [Mapper] attribute (SpeakerCategoryItemDTOMapper.cs:4, SpeakerCategoryItemDTOMapper.cs:11).
    • -
    • Concept introduced, compile-time mapping instead of runtime reflection: [Rubric §12, Performance and Scalability] assesses whether hot per-row work avoids reflection, and [Rubric §15, Best Practices] assesses whether generated code is preferred to hand-maintained boilerplate that can silently drift. [Mapper] on a partial class makes Mapperly emit the body of public partial SpeakerCategoryItemDTO MapToDTO(SpeakerCategoryItem entity) (SpeakerCategoryItemDTOMapper.cs:16) as plain property assignments in a generated file. There is no runtime configuration step and no reflection at map time; a property that cannot be matched is a build diagnostic, not a null at runtime. This is the "manual mapping over reflective auto-mapping" position of ADR-001, taken one step further: the compiler writes the manual mapping.
    • +
    • Concept introduced, compile-time mapping instead of runtime reflection: [Rubric §12, Performance and Scalability] assesses whether hot per-row work avoids reflection, and [Rubric §15, Best Practices and Code Quality] assesses whether generated code is preferred to hand-maintained boilerplate that can silently drift. [Mapper] on a partial class makes Mapperly emit the body of public partial SpeakerCategoryItemDTO MapToDTO(SpeakerCategoryItem entity) (SpeakerCategoryItemDTOMapper.cs:16) as plain property assignments in a generated file. There is no runtime configuration step and no reflection at map time; a property that cannot be matched is a build diagnostic, not a null at runtime. This is the "manual mapping over reflective auto-mapping" position of ADR-001, taken one step further: the compiler writes the manual mapping.
    • Walkthrough: two members.
      • MapToDTO (SpeakerCategoryItemDTOMapper.cs:16) is partial with no body; the generator supplies it.
      • MapToDTOs (SpeakerCategoryItemDTOMapper.cs:19-23) is written by hand: a null guard, then a collection-expression spread over Select(MapToDTO). Re-declaring it on the class is what makes it callable through the concrete type, which matters because the handlers in this module inject the concrete mapper (AddSpeakerCategoryItemHandler.cs:17) rather than the interface.
    • Why it's built this way: DTO shaping stays a compile-checked, allocation-lean step owned by the Application layer, so the API contract cannot drift from the entity without a build error (ADR-001).
    • -
    • Where it's used: injected into AddSpeakerCategoryItemHandler (AddSpeakerCategoryItemHandler.cs:17), composed into SpeakerDTOMapper as a [UseMapper] field for the parent's child collection (SpeakerDTOMapper.cs:23-24), and resolved by the generic EntityQueryService<TEntity, TEntityDTO, TIdentifierType> registered for this entity (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:102). It is registered by the convention scan at DependencyInjection.cs:112, not by an explicit line. Covered by SpeakerCategoryItemDTOMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/DTOs/SpeakerCategoryItemDTOMapperTests.cs).
    • +
    • Where it's used: injected into AddSpeakerCategoryItemHandler (AddSpeakerCategoryItemHandler.cs:17), composed into SpeakerDTOMapper as a [UseMapper] field for the parent's child collection (SpeakerDTOMapper.cs:23-24), and resolved by the generic EntityQueryService<TEntity, TEntityDTO, TIdentifierType> registered for this entity (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:111). The mapper itself is registered by the convention scan at DependencyInjection.cs:125, not by an explicit line. Covered by SpeakerCategoryItemDTOMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/DTOs/SpeakerCategoryItemDTOMapperTests.cs).
    • Caveats / not-in-source: what the generated MapToDTO actually copies is decided by the property names on the entity and the DTO; the generated file is not in the repository, so the field list is only observable through the two type definitions and a build.
    -

    SpeakerCreateRequest

    -
    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.Create · MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequest.cs:10 · Level 8 · record

    -
    -
      -
    • What it is: the create-request DTO for a conference speaker. Like QuestionCreateRequest it doubles as the command: CreateSpeakerHandler implements ICommandHandler<SpeakerCreateRequest, Result<SpeakerDTO>> directly against this type, so there is no separate CreateSpeakerCommand.
    • -
    • Depends on: ICreateRequest; ICacheInvalidating; the Speaker type for the cache prefix; the SpeakerIdentifierType alias (SpeakerCreateRequest.cs:10, SpeakerCreateRequest.cs:13).
    • -
    • Concept reinforced, the request-as-command shape: [Rubric §9, API and Contract Design] assesses whether the wire contract is an explicit, versionable type rather than the domain entity leaking outward: the controller binds this record straight from the request body (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:311) and it is also the fourth generic argument of the base controller (SpeakersController.cs:59). [Rubric §5, Vertical Slice] applies to the folder: request, mapper, validator, and handler for Create all sit in Speakers/UseCases/Create. Marking it ICacheInvalidating means a successful create evicts the cached speaker reads exactly like the command records above.
    • -
    • Walkthrough: a record class (not sealed) with init-only members. CachePrefix (SpeakerCreateRequest.cs:13) is the invalidation tag. Id (SpeakerCreateRequest.cs:16) is a SpeakerIdentifierType, and its comment records that it is Sessionize-assigned: the caller supplies the key rather than the database generating it, which is what lets an import be idempotent. Three members are required, so the record cannot be constructed without them: FirstName (SpeakerCreateRequest.cs:19), LastName (SpeakerCreateRequest.cs:22), and FullName (SpeakerCreateRequest.cs:25). The rest are optional init members: Email (SpeakerCreateRequest.cs:28), Bio (SpeakerCreateRequest.cs:31), TagLine (SpeakerCreateRequest.cs:34), ProfilePicture (SpeakerCreateRequest.cs:37), the IsTopSpeaker flag (SpeakerCreateRequest.cs:40), and the four profile links TwitterHandle (SpeakerCreateRequest.cs:43), LinkedInUrl (SpeakerCreateRequest.cs:46), GitHubUrl (SpeakerCreateRequest.cs:49), and WebsiteUrl (SpeakerCreateRequest.cs:52).
    • -
    • Why it's built this way: required plus init gives compile-time enforcement of the minimum payload while leaving the rest optional, and collapsing request and command into one type keeps a simple create slice to a single message (contrast the child-mutation flows, where the controller builds a distinct command record such as AddSpeakerCategoryItemCommand).
    • -
    • Where it's used: bound by SpeakersController on the SpeakersManage-gated POST /Speakers (SpeakersController.cs:308-312); validated by SpeakerCreateRequestValidator; translated to a domain entity by SpeakerCreateRequestMapper; handled by CreateSpeakerHandler.
    • -
    • Caveats / not-in-source: FullName is required on the contract but never reaches the domain. Speaker computes FullName => $"{FirstName} {LastName}" (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:61) and SpeakerCreateRequestMapper does not pass it to the factory, so a caller-supplied value is accepted and discarded. The same is true of the four profile-link members, which the mapper also drops (Email, by contrast, IS forwarded). Nothing on this type says so.
    • -

    SpeakerQuestionAnswerDTOMapper

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.DTOs · MMCA.ADC.Conference.Application/Speakers/DTOs/SpeakerQuestionAnswerDTOMapper.cs:12 · Level 8 · class (sealed partial)

    @@ -3235,18 +3472,29 @@

    SpeakerQuestionAnswerDTOMapper

  • What it is: the Mapperly mapper for SpeakerQuestionAnswer to SpeakerQuestionAnswerDTO, the speaker's answers to the conference's profile questions. Structurally identical to SpeakerCategoryItemDTOMapper: the [Mapper] attribute (SpeakerQuestionAnswerDTOMapper.cs:11), the partial MapToDTO (SpeakerQuestionAnswerDTOMapper.cs:16), and the hand-written MapToDTOs (SpeakerQuestionAnswerDTOMapper.cs:19-23).
  • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over the answer entity, its DTO, and SpeakerQuestionAnswerIdentifierType (SpeakerQuestionAnswerDTOMapper.cs:13), plus Mapperly.
  • Concept introduced: none new; see SpeakerCategoryItemDTOMapper for the generated-mapper mechanism and why MapToDTOs is re-declared on the class.
  • -
  • Where it's used: this is the one mapper in the speaker family with no handler and no query service of its own. It reaches the wire only through composition: SpeakerDTOMapper holds it as a [UseMapper] field (SpeakerDTOMapper.cs:26-27) and the generator calls it while filling SpeakerDTO.SpeakerQuestionAnswers (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:60). Registration is by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112), which is why no explicit line exists for it while SpeakerCategoryItem gets one at DependencyInjection.cs:101-102. Covered by SpeakerQuestionAnswerDTOMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/DTOs/SpeakerQuestionAnswerDTOMapperTests.cs).
  • +
  • Where it's used: this is the one mapper in the speaker family with no handler and no query service of its own. It reaches the wire only through composition: SpeakerDTOMapper holds it as a [UseMapper] field (SpeakerDTOMapper.cs:26-27) and the generator calls it while filling SpeakerDTO.SpeakerQuestionAnswers (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:60). Registration is by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125), which is why no explicit line exists for it while SpeakerCategoryItem gets one at DependencyInjection.cs:110-111. The entity's navigation populator is registered on its own at DependencyInjection.cs:115, and the comment above it records why that pair is uneven: SpeakerQuestionAnswer has no query service today, and registering the populator future-proofs the one that would be added alongside it (DependencyInjection.cs:113-114). Covered by SpeakerQuestionAnswerDTOMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/DTOs/SpeakerQuestionAnswerDTOMapperTests.cs).
  • -

    AddSpeakerCategoryItemCommandValidator

    +

    UpdateRoomCommand

    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.AddSpeakerCategoryItem · MMCA.ADC.Conference.Application/Speakers/UseCases/AddSpeakerCategoryItem/AddSpeakerCategoryItemCommandValidator.cs:8 · Level 9 · class (sealed)

    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.UpdateRoom · MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomCommand.cs:15 · Level 8 · record

    +
    +
      +
    • What it is: the full-replacement update for one room inside an event. It is the widest command in the event slice: two ids plus the six room fields (UpdateRoomCommand.cs:15-23).
    • +
    • Depends on: ICacheInvalidating (UpdateRoomCommand.cs:23); Event, referenced only for the cache prefix; the EventIdentifierType and RoomIdentifierType aliases.
    • +
    • Concept reinforced, the whole-object update command: [Rubric §9, API and Contract Design] assesses whether a mutation contract is unambiguous. Every optional field is declared nullable (Capacity, Floor, Location, AccessibilityInfo, UpdateRoomCommand.cs:20-23) and is passed through to the domain as sent, so omitting one clears it rather than leaving it alone. That is the defining property of a PUT-shaped command, and it is why RoomsController binds it from a complete UpdateRoomRequest body (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/RoomsController.cs:288). [Rubric §21, Accessibility] is touched, though only at the data level: AccessibilityInfo (UpdateRoomCommand.cs:23) is the field that carries a room's accessibility notes to attendees, so the write path preserves that information as a first-class member rather than folding it into a free-text description.
    • +
    • Walkthrough: a sealed record with eight positional parameters (UpdateRoomCommand.cs:15-23), each documented individually (UpdateRoomCommand.cs:7-14), and the single CachePrefix member keyed on the Event type name (UpdateRoomCommand.cs:25-26). The prefix is the parent's, not the room's, because a room is a child of the event aggregate and every cached read that could contain it is keyed under Event. Note what is absent: no RowVersion, so unlike UnpublishEventCommand a room edit is last-write-wins.
    • +
    • Where it's used: constructed by RoomsController's PUT /Rooms/{id} action (RoomsController.cs:285-299, handler injected at RoomsController.cs:95), which evicts the conference:rooms output-cache tag and returns 204 No Content on success (RoomsController.cs:306-307, eviction helper at RoomsController.cs:328-329); validated by UpdateRoomCommandValidator; handled by UpdateRoomHandler.
    • +
    +

    AddSpeakerCategoryItemCommandValidator

    +
    +

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.AddSpeakerCategoryItem · MMCA.ADC.Conference.Application/Speakers/UseCases/AddSpeakerCategoryItem/AddSpeakerCategoryItemCommandValidator.cs:8 · Level 9 · class (sealed)

    • What it is: the FluentValidation validator for AddSpeakerCategoryItemCommand. It asserts one thing: the caller actually supplied a category item.
    • Depends on: FluentValidation's AbstractValidator<T> (AddSpeakerCategoryItemCommandValidator.cs:1, AddSpeakerCategoryItemCommandValidator.cs:8) and the CategoryItemIdentifierType alias.
    • -
    • Concept introduced, the Validating decorator stage: [Rubric §24, Forms, Validation and UX Safety] assesses whether bad input is rejected before it reaches business logic, and [Rubric §10, Cross-Cutting] assesses whether that happens uniformly. The handler never calls this class. ValidatingCommandDecorator<TCommand, TResult> runs every registered validator for the command type before the transaction opens (ADR-014), so a malformed command costs no database work. Registration is by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112): dropping a validator file next to the command is the entire wiring step, which is the vertical-slice payoff, [Rubric §5, Vertical Slice].
    • +
    • Concept introduced, the Validating decorator stage: [Rubric §24, Forms, Validation and UX Safety] assesses whether bad input is rejected before it reaches business logic, and [Rubric §10, Cross-Cutting] assesses whether that happens uniformly. The handler never calls this class. ValidatingCommandDecorator<TCommand, TResult> runs every registered validator for the command type before the transaction opens (ADR-014), so a malformed command costs no database work. Registration is by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125): dropping a validator file next to the command is the entire wiring step, which is the vertical-slice payoff, [Rubric §5, Vertical Slice].
    • Walkthrough: an expression-bodied constructor with a single rule (AddSpeakerCategoryItemCommandValidator.cs:10-13): RuleFor(x => x.CategoryItemId).NotEqual(default(CategoryItemIdentifierType)) with the message "Category item ID is required." Because the identifier alias is a value type, default is the "not supplied" sentinel that model binding produces for a missing JSON field, so this rule is what turns a silently omitted field into a 400 rather than a lookup miss deeper in.
    • -
    • Why it's built this way: the validator covers only what can be judged from the message itself. Whether the association is a duplicate is decided against loaded state in the aggregate (Speaker.AddSpeakerCategoryItem at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:306-313) rather than restated here. [Rubric §4, Domain-Driven Design]: the invariant stays in the aggregate; the validator only guards the shape.
    • +
    • Why it's built this way: the validator covers only what can be judged from the message itself. Whether the association is a duplicate is decided against loaded state in the aggregate (Speaker.AddSpeakerCategoryItem at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:318-325) rather than restated here. [Rubric §4, Domain-Driven Design]: the invariant stays in the aggregate; the validator only guards the shape.
    • Where it's used: resolved by the Validating decorator for AddSpeakerCategoryItemCommand; covered directly by AddSpeakerCategoryItemCommandValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/Validation/SpeakerCommandValidatorTests.cs:6, instantiating the validator at SpeakerCommandValidatorTests.cs:8).

    AddSpeakerCategoryItemHandler

    @@ -3256,16 +3504,16 @@

    AddSpeakerCategoryItemHandler

    • What it is: the handler for AddSpeakerCategoryItemCommand, and the canonical "add a child through the aggregate root" shape in the speaker slice: load the speaker with its children, delegate to a domain method, save, log, return the mapped DTO.
    • Depends on: ICommandHandler<in TCommand, TResult> closed over the command and Result<SpeakerCategoryItemDTO> (AddSpeakerCategoryItemHandler.cs:18), IUnitOfWork, SpeakerCategoryItemDTOMapper, the Speaker aggregate and its SpeakerCategoryItem child, Result / Error, and Microsoft.Extensions.Logging.
    • -
    • Concept introduced, loading the children the invariant needs: [Rubric §4, Domain-Driven Design] assesses whether application code mutates child entities directly. It does not here: speaker.AddSpeakerCategoryItem(command.SpeakerCategoryItemId, command.CategoryItemId) (AddSpeakerCategoryItemHandler.cs:33) is the only write, and the aggregate is where the duplicate rule, the child factory call, and the SpeakerCategoryItemChanged domain event live (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:302-326). The load arguments are chosen by what the aggregate must decide, and the code says so in a comment (AddSpeakerCategoryItemHandler.cs:27-28): the join collection has to be included, or the duplicate check at Speaker.cs:306 runs against an empty in-memory list and a double submit surfaces as a raw unique-index 409 instead of a worded invariant failure. asTracking: true is the other half, because an untracked aggregate would make the subsequent save a silent no-op. [Rubric §8, Data Architecture]: the in-memory check is still backed at the database level by a filtered unique index on (SpeakerId, CategoryItemId) (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SpeakerCategoryItemConfiguration.cs:30-32), so a race that beats the check still cannot write a duplicate row.
    • +
    • Concept introduced, loading the children the invariant needs: [Rubric §4, Domain-Driven Design] assesses whether application code mutates child entities directly. It does not here: speaker.AddSpeakerCategoryItem(command.SpeakerCategoryItemId, command.CategoryItemId) (AddSpeakerCategoryItemHandler.cs:33) is the only write, and the aggregate is where the duplicate rule, the child factory call, and the SpeakerCategoryItemChanged domain event live (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:314-338). The load arguments are chosen by what the aggregate must decide, and the code says so in a comment (AddSpeakerCategoryItemHandler.cs:27-28): the join collection has to be included, or the duplicate check at Speaker.cs:318 runs against an empty in-memory list and a double submit surfaces as a raw unique-index 409 instead of a worded invariant failure. asTracking: true is the other half, because an untracked aggregate would make the subsequent save a silent no-op. [Rubric §8, Data Architecture]: the in-memory check is still backed at the database level by a unique index on (SpeakerId, CategoryItemId) filtered to non-deleted rows (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SpeakerCategoryItemConfiguration.cs:30-32), so a race that beats the check still cannot write a duplicate row.
    • Walkthrough: a primary constructor taking unitOfWork, the concrete dtoMapper, and a typed ILogger (AddSpeakerCategoryItemHandler.cs:15-18).
      • HandleAsync (AddSpeakerCategoryItemHandler.cs:21-42) resolves the Speaker repository from the unit of work rather than injecting it (AddSpeakerCategoryItemHandler.cs:25), then loads with includes: [nameof(Speaker.SpeakerCategoryItems)] and asTracking: true (AddSpeakerCategoryItemHandler.cs:29).
      • A missing speaker returns Error.NotFound stamped with source and target (AddSpeakerCategoryItemHandler.cs:30-31), the standard failure shape of the Result pattern (ADR-013).
      • -
      • The domain call's failure is forwarded verbatim, errors and all (AddSpeakerCategoryItemHandler.cs:34-35), so a duplicate association surfaces to the client as the aggregate worded it (Speaker.CategoryItem.Duplicate, Speaker.cs:308-312).
      • -
      • Only on success does it SaveChangesAsync with ConfigureAwait(false) (ADR-049), log through the source-generated LogCategoryItemAdded (AddSpeakerCategoryItemHandler.cs:37-39, declared at AddSpeakerCategoryItemHandler.cs:44-45), and return Result.Success(dtoMapper.MapToDTO(result.Value!)) (AddSpeakerCategoryItemHandler.cs:41). The new child is mapped from the instance the aggregate returned, not re-queried.
      • +
      • The domain call's failure is forwarded verbatim, errors and all (AddSpeakerCategoryItemHandler.cs:34-35), so a duplicate association surfaces to the client as the aggregate worded it (Speaker.CategoryItem.Duplicate, Speaker.cs:320-325).
      • +
      • Only on success does it SaveChangesAsync with ConfigureAwait(false) (ADR-049), log through the source-generated LogCategoryItemAdded (AddSpeakerCategoryItemHandler.cs:37-39, declared at AddSpeakerCategoryItemHandler.cs:44-45), and return Result.Success(dtoMapper.MapToDTO(result.Value!)) (AddSpeakerCategoryItemHandler.cs:41). The new child is mapped from the instance the aggregate returned (Speaker.cs:337), not re-queried.
    • Why it's built this way: the handler is pure orchestration because the surrounding decorators already own the rest: validation before it, cache eviction after it (ADR-014). [Rubric §1, SOLID]: one reason to change, and it is the use case, not the plumbing. [Rubric §13, Observability and Operability]: logging goes through the [LoggerMessage] partial method, which is why the class is partial, and the log is emitted only on the success path.
    • -
    • Where it's used: registered by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112) and injected into SpeakerCategoryItemsController as ICommandHandler<AddSpeakerCategoryItemCommand, Result<SpeakerCategoryItemDTO>> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:49). Its counterpart is RemoveSpeakerCategoryItemHandler. Covered by AddSpeakerCategoryItemHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/AddSpeakerCategoryItemHandlerTests.cs).
    • +
    • Where it's used: registered by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125) and injected into SpeakerCategoryItemsController as ICommandHandler<AddSpeakerCategoryItemCommand, Result<SpeakerCategoryItemDTO>> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:50). Its counterpart is RemoveSpeakerCategoryItemHandler. Covered by AddSpeakerCategoryItemHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/AddSpeakerCategoryItemHandlerTests.cs).

    CreateQuestionHandler

    @@ -3274,7 +3522,7 @@

    CreateQuestionHandler

    • What it is: the handler that creates a question. It is the create template with server-controlled id allocation bolted on, and the only handler in this unit that retries itself.
    • Depends on: ICommandHandler<in TCommand, TResult> closed over QuestionCreateRequest and Result<QuestionDTO> (CreateQuestionHandler.cs:24); IUnitOfWork; IServiceScopeFactory (Microsoft.Extensions.DependencyInjection); IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, satisfied by QuestionCreateRequestMapper; QuestionDTOMapper; QuestionInvariants; Result / Error; logging (CreateQuestionHandler.cs:19-24).
    • -
    • Concept introduced, application-side key allocation in a reserved range, and the retry that makes it safe: [Rubric §8, Data Architecture] assesses whether a key strategy is deliberate and collision-proof. The question id space is shared with an external system: Sessionize assigns ids to imported questions, so the database's identity column cannot be trusted to stay out of the way. The module reserves 999_999_000 to 999_999_999 for user-created questions (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:37, QuestionInvariants.cs:40), and the handler allocates max + 1 inside it. Two details make that allocation honest. First, the range query passes ignoreQueryFilters: true (CreateQuestionHandler.cs:76), so a soft-deleted question still reserves its id and a re-created question never reuses a deleted key (ADR-005). Second, max + 1 computed outside a lock is a race by construction, so the handler expects to lose it occasionally and retries. [Rubric §29, Resilience]: the failure mode is anticipated in code rather than left to the caller.
    • +
    • Concept introduced, application-side key allocation in a reserved range, and the retry that makes it safe: [Rubric §8, Data Architecture] assesses whether a key strategy is deliberate and collision-proof. The question id space is shared with an external system: Sessionize assigns ids to imported questions, so the database's identity column cannot be trusted to stay out of the way. The module reserves 999_999_000 to 999_999_999 for user-created questions (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:37, QuestionInvariants.cs:40), and the handler allocates max + 1 inside it. Two details make that allocation honest. First, the range query passes ignoreQueryFilters: true (CreateQuestionHandler.cs:76), so a soft-deleted question still reserves its id and a re-created question never reuses a deleted key (ADR-005). Second, max + 1 computed outside a lock is a race by construction, so the handler expects to lose it occasionally and retries. [Rubric §29, Resilience and Business Continuity]: the failure mode is anticipated in code rather than left to the caller.
    • Walkthrough: the primary constructor adds IServiceScopeFactory and the concrete QuestionDTOMapper to the usual dependencies (CreateQuestionHandler.cs:19-24), and MaxManualIdAttempts is a const int of 3 (CreateQuestionHandler.cs:27).
      • HandleAsync (CreateQuestionHandler.cs:30-58) is a bounded retry loop. The first attempt runs against the ambient unit of work (CreateQuestionHandler.cs:43-44). Every later attempt creates an await using DI scope and resolves a fresh IUnitOfWork from it (CreateQuestionHandler.cs:48-50), and the comment explains why that is not optional: the ambient DbContext still tracks the failed insert, so a clean context is required for the recomputed id to persist.
      • The catch is an exception filter, not a blanket catch: it re-enters the loop only while attempt < MaxManualIdAttempts and only for a unique-key violation (CreateQuestionHandler.cs:52-56), logging a warning through the generated LogManualIdCollision. Anything else propagates.
      • @@ -3283,27 +3531,8 @@

        CreateQuestionHandler

    • Why it's built this way: the class comment contrasts this slice with the session create path (CreateQuestionHandler.cs:34-36): there is no explicit-id branch here, because this handler always overrides the caller id, which is precisely what makes every attempt retryable. A handler that sometimes honored a caller id could not blindly recompute on collision.
    • -
    • Where it's used: registered by the convention scan; injected into QuestionsController as ICommandHandler<QuestionCreateRequest, Result<QuestionDTO>> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/QuestionsController.cs:33) and dispatched by its POST /Questions override, which also evicts the output cache (QuestionsController.cs:91-99). The whole controller is gated on the QuestionsManage permission except the explicitly anonymous read actions (QuestionsController.cs:30, QuestionsController.cs:42). Covered by CreateQuestionHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Questions/UseCases/CreateQuestionHandlerTests.cs).
    • -
    • Caveats / not-in-source: the range read materializes every manual-range question on every create to compute one maximum. At the ADC's question volume that is negligible, but nothing in the file bounds it, and no comment records the trade-off.
    • -
    -

    LinkUserToSpeakerHandler

    -
    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.LinkUser · MMCA.ADC.Conference.Application/Speakers/UseCases/LinkUser/LinkUserToSpeakerHandler.cs:20 · Level 9 · class (sealed partial)

    -
    -
      -
    • What it is: the handler for LinkUserToSpeakerCommand. It updates the Conference side of a bidirectional link that spans two databases, and raises the event that updates the other side.
    • -
    • Depends on: ICommandHandler<in TCommand, TResult>; IUnitOfWork; Speaker; SpeakerLinkedToUser; Result and Error; Microsoft.Extensions.Logging. Note what is not injected: there is no publisher service, because the event is raised on the aggregate.
    • -
    • Concept introduced, cross-context coordination captured in the same transaction: [Rubric §6, CQRS and Event-Driven] and [Rubric §7, Microservices Readiness]. Conference and Identity own separate databases (ADR-006), so there is no foreign key between Speaker and User and consistency has to flow through events. The load-bearing detail is the ordering: speaker.AddDomainEvent(new SpeakerLinkedToUser(...)) runs BEFORE the single SaveChangesAsync (LinkUserToSpeakerHandler.cs:54, then LinkUserToSpeakerHandler.cs:56), so the outbox row is written inside the same transaction as the link (ADR-003). The class comment states the failure this removed (LinkUserToSpeakerHandler.cs:13-18): with a post-save publish, a crash could commit the Conference-side link and lose the event that sets User.LinkedSpeakerId. The OutboxProcessor later routes the row to the registered IMessageBus transport. This is also why the command carries ITransactional.
    • -
    • Walkthrough
        -
      • Primary constructor (LinkUserToSpeakerHandler.cs:20-22): IUnitOfWork and ILogger<LinkUserToSpeakerHandler>; the declared result is the non-generic Result, so no DTO mapper is needed.
      • -
      • HandleAsync (LinkUserToSpeakerHandler.cs:25) resolves the repository (LinkUserToSpeakerHandler.cs:29) and loads the speaker with the include-free, tracked overload (LinkUserToSpeakerHandler.cs:30), returning Error.NotFound with source and target set when it is missing (LinkUserToSpeakerHandler.cs:31-32).
      • -
      • The BR-208 uniqueness guard (LinkUserToSpeakerHandler.cs:34-46) is the interesting part: it queries every speaker whose LinkedUserId equals the target user (LinkUserToSpeakerHandler.cs:35-38) and fails with Error.Invariant(code: "Speaker.UserAlreadyLinked", ...) if any OTHER speaker already holds that link (LinkUserToSpeakerHandler.cs:39-46). The s.Id != command.SpeakerId test is what makes re-linking the same pair a no-op rather than an error.
      • -
      • speaker.LinkUser(command.UserId) (LinkUserToSpeakerHandler.cs:48) is the domain decision. The aggregate refuses a speaker that is already linked, returning Speaker.AlreadyLinked (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:260-274), so the handler owns only the cross-row rule and the entity owns its own.
      • -
      • On success it raises the integration event on the aggregate (LinkUserToSpeakerHandler.cs:54), saves (LinkUserToSpeakerHandler.cs:56), and logs through the generated LogUserLinkedToSpeaker (LinkUserToSpeakerHandler.cs:58, declared at LinkUserToSpeakerHandler.cs:64-65). The domain Result is returned either way (LinkUserToSpeakerHandler.cs:61), so a rejection reaches the API with its error codes intact.
      • -
      -
    • -
    • Why it's built this way: splitting the rules (uniqueness across speakers in the handler, "already linked?" inside the aggregate) keeps each check where the data for it lives, and the pre-save event raise is a deliberate durability fix rather than a style choice.
    • -
    • Where it's used: registered by the Conference application scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112); invoked through the decorator pipeline by the PUT /Speakers/{id}/link action (SpeakersController at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:366-379). The emitted event is consumed on the Identity side to set User.LinkedSpeakerId. This organizer-driven path is also the deliberate fallback for speakers the automatic email match in UserRegisteredHandler cannot claim. Covered by LinkUserToSpeakerHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/LinkUserToSpeakerHandlerTests.cs).
    • +
    • Where it's used: registered by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125); injected into QuestionsController as ICommandHandler<QuestionCreateRequest, Result<QuestionDTO>> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/QuestionsController.cs:33) and dispatched by its POST /Questions override, which also evicts the conference:questions output-cache tag (QuestionsController.cs:91-97, helper at QuestionsController.cs:130-131). The whole controller is gated on the QuestionsManage permission except the explicitly anonymous read actions (QuestionsController.cs:30, QuestionsController.cs:42). Covered by CreateQuestionHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Questions/UseCases/CreateQuestionHandlerTests.cs).
    • +
    • Caveats / not-in-source: the range read materializes every manual-range question on every create to compute one maximum. At the conference's question volume that is negligible, but nothing in the file bounds it, and no comment records the trade-off.

    QuestionCreateRequestMapper

    @@ -3312,10 +3541,10 @@

    QuestionCreateRequestMapper

    • What it is: the adapter that turns a validated QuestionCreateRequest into a Question entity by calling the aggregate's static factory.
    • Depends on: IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> closed over Question, QuestionCreateRequest, and QuestionIdentifierType (QuestionCreateRequestMapper.cs:11-12); Question; Result.
    • -
    • Concept introduced, the request mapper as the only door into a factory: [Rubric §4, Domain-Driven Design] assesses whether an entity can be constructed in an invalid state. It cannot: Question.Create (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/Question.cs:70-97) combines four invariant checks before it allocates anything (Question.cs:79-83) and raises QuestionChanged on success (Question.cs:94), returning a Result<Question> throughout. A bad request therefore becomes an error list, never a half-built object. Unlike a DTO mapper, which copies fields outward, a request mapper translates inward and is allowed to fail. Note the class carries no [Mapper] attribute: this is hand-written mapping, not Mapperly generation (contrast SpeakerCategoryItemDTOMapper). [Rubric §3, Clean Architecture]: keeping it in its own class is what lets CreateQuestionHandler depend on the generic interface instead of the factory signature.
    • -
    • Walkthrough: CreateEntityAsync (QuestionCreateRequestMapper.cs:15) null-guards the request (QuestionCreateRequestMapper.cs:17), then returns Task.FromResult(Question.Create(...)) (QuestionCreateRequestMapper.cs:19-26): the work is synchronous and the Task exists only to satisfy the async interface. Two things in that call are worth reading twice. The nullable QuestionEntity and QuestionType are forced with ! (QuestionCreateRequestMapper.cs:22-23), which does not make them non-null; it hands a possible null to invariants that reject it against a closed list (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:68-75, QuestionInvariants.cs:83-90), so an omitted field becomes a worded invariant error rather than a null-reference exception. And questionSource is hard-coded to "User" (QuestionCreateRequestMapper.cs:26), never taken from the request: an API-created question can never claim to have come from Sessionize.
    • +
    • Concept introduced, the request mapper as the only door into a factory: [Rubric §4, Domain-Driven Design] assesses whether an entity can be constructed in an invalid state. It cannot: Question.Create (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/Question.cs:70) combines four invariant checks before it allocates anything (Question.cs:80-83) and raises QuestionChanged on success (Question.cs:94), returning a Result<Question> throughout. A bad request therefore becomes an error list, never a half-built object. Unlike a DTO mapper, which copies fields outward, a request mapper translates inward and is allowed to fail. Note the class carries no [Mapper] attribute: this is hand-written mapping, not Mapperly generation (contrast SpeakerCategoryItemDTOMapper). [Rubric §3, Clean Architecture]: keeping it in its own class is what lets CreateQuestionHandler depend on the generic interface instead of the factory signature.
    • +
    • Walkthrough: CreateEntityAsync (QuestionCreateRequestMapper.cs:15) null-guards the request (QuestionCreateRequestMapper.cs:17), then returns Task.FromResult(Question.Create(...)) (QuestionCreateRequestMapper.cs:19-26): the work is synchronous and the Task exists only to satisfy the async interface. Two things in that call are worth reading twice. The nullable QuestionEntity and QuestionType are forced with ! (QuestionCreateRequestMapper.cs:22-23), which does not make them non-null; it hands a possible null to invariants that reject it against a closed list (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:68-69, QuestionInvariants.cs:83-84), so an omitted field becomes a worded invariant error rather than a null-reference exception. And questionSource is hard-coded to "User" (QuestionCreateRequestMapper.cs:26), never taken from the request: an API-created question can never claim to have come from Sessionize.
    • Why it's built this way: delegating every field check to the factory keeps validation in the domain instead of duplicated in the Application layer, and the generic create pipeline can drive any aggregate through the same contract (ADR-001). Pinning the source server-side is the same instinct as never taking an identity from a request body: provenance is not a caller's field to set.
    • -
    • Where it's used: resolved as IEntityRequestMapper<Question, QuestionCreateRequest, QuestionIdentifierType> by CreateQuestionHandler (CreateQuestionHandler.cs:22) and invoked at CreateQuestionHandler.cs:89; registered by the module's application scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112).
    • +
    • Where it's used: resolved as IEntityRequestMapper<Question, QuestionCreateRequest, QuestionIdentifierType> by CreateQuestionHandler (CreateQuestionHandler.cs:22) and invoked at CreateQuestionHandler.cs:89; registered by the module's application scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125).

    QuestionCreateRequestValidator

    @@ -3327,34 +3556,8 @@

    QuestionCreateRequestValidator

  • Concept reinforced, rule composition with Include: [Rubric §16, Maintainability] assesses whether the same constraint is written once. Rather than repeat a required-plus-max-length rule in the create validator and again in the update validator, both Include the same generic rule set, parameterized by a property selector (QuestionCreateRequestValidator.cs:10). Include merges the included validator's rules into this one as though they were declared inline, so composition costs nothing at validation time. [Rubric §24, Forms, Validation and UX Safety] covers what the rules produce: QuestionTextRules<T> attaches NotEmpty and MaximumLength(QuestionInvariants.QuestionTextMaxLength) with stable error codes Question.QuestionText.Required and Question.QuestionText.MaxLength (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Questions/Validation/QuestionValidationRules.cs:17-18). The ceiling is 1000 characters and it comes from the domain constant (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:13), the same constant the invariant enforces (QuestionInvariants.cs:59), so the form limit and the domain limit cannot drift apart.
  • Walkthrough: a sealed class whose whole body is an expression-bodied constructor (QuestionCreateRequestValidator.cs:9-10) calling Include(new QuestionTextRules<QuestionCreateRequest>(p => p.QuestionText)).
  • Why it's built this way: extracting field rules into shared generic classes is the module-wide convention; add a constraint once and every request type that includes the rule set picks it up.
  • -
  • Where it's used: discovered by the module's validator scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112) and run by ValidatingCommandDecorator<TCommand, TResult> ahead of CreateQuestionHandler. Covered by QuestionCreateRequestValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Questions/Validation/QuestionCreateRequestValidatorTests.cs).
  • -
  • Caveats / not-in-source: only QuestionText is validated here. QuestionEntity, QuestionType, and QuestionSource are left entirely to the domain invariants inside Question.Create, so an invalid or missing value arrives as an invariant failure rather than a field-level validation error.
  • - -

    SpeakerCreateRequestMapper

    -
    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.Create · MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequestMapper.cs:11 · Level 9 · class (sealed)

    -
    -
      -
    • What it is: the adapter that turns a validated SpeakerCreateRequest into a Speaker entity by calling the aggregate's static factory.
    • -
    • Depends on: IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> closed over Speaker, SpeakerCreateRequest, and SpeakerIdentifierType (SpeakerCreateRequestMapper.cs:11-12); Speaker; Result.
    • -
    • Concept reinforced, the request mapper as the only door into a factory: see QuestionCreateRequestMapper for the pattern. Speaker.Create (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:112-159) parses the optional email into an Email value object and bails on a malformed address (Speaker.cs:122-129), combines the first- and last-name invariants (Speaker.cs:131-135), assigns the client-supplied id or generates one (Speaker.cs:153), and raises SpeakerChanged (Speaker.cs:156). The id fallback carries its own scar: the comment records that the previous id!.Value threw "Nullable object must have a value" and killed both Conference's startup seeding and every organizer create (Speaker.cs:148-152). [Rubric §4, Domain-Driven Design], [Rubric §15, Best Practices].
    • -
    • Walkthrough: CreateEntityAsync (SpeakerCreateRequestMapper.cs:15) null-guards (SpeakerCreateRequestMapper.cs:17), then returns Task.FromResult(Speaker.Create(...)) (SpeakerCreateRequestMapper.cs:19-27). Eight of the request's members are forwarded (Id, FirstName, LastName, Email, Bio, TagLine, ProfilePicture, IsTopSpeaker); FullName and the four profile-link members are not, because the factory has no parameters for them.
    • -
    • Why it's built this way: delegating every field check to the factory keeps validation in the domain instead of duplicated in the Application layer, and the generic create pipeline can drive any aggregate through the same contract (ADR-001).
    • -
    • Where it's used: injected into CreateSpeakerHandler as IEntityRequestMapper<Speaker, SpeakerCreateRequest, SpeakerIdentifierType> (CreateSpeakerHandler.cs:18) and invoked at CreateSpeakerHandler.cs:27; registered by the module's application scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112).
    • -
    • Caveats / not-in-source: whether the four profile links (TwitterHandle, LinkedInUrl, GitHubUrl, WebsiteUrl) are meant to be persisted at create time is Not determinable from source. The mapper simply does not forward them and no later assignment is visible in this file, so a create-then-update through UpdateSpeakerCommand is the only path that sets them.
    • -
    -

    SpeakerCreateRequestValidator

    -
    -

    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.Create · MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequestValidator.cs:7 · Level 9 · class (sealed)

    -
    -
      -
    • What it is: the FluentValidation validator for SpeakerCreateRequest. It declares no rules of its own; it composes two shared rule sets.
    • -
    • Depends on: FluentValidation's AbstractValidator<T> (SpeakerCreateRequestValidator.cs:1, SpeakerCreateRequestValidator.cs:7); SpeakerFirstNameRules<T> and SpeakerLastNameRules<T> (SpeakerCreateRequestValidator.cs:2).
    • -
    • Concept reinforced, rule composition with Include: the same mechanism taught on QuestionCreateRequestValidator, with one extra layer. Both speaker rule sets extend the framework's RequiredStringRules<T> with a display label and a ceiling taken from the domain: "First Name" with SpeakerInvariants.FirstNameMaxLength and "Last Name" with LastNameMaxLength, both 200 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:14-15 and SpeakerValidationRules.cs:25-26; MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/SpeakerInvariants.cs:13 and SpeakerInvariants.cs:16), which is the same constant the domain invariant enforces (SpeakerInvariants.cs:45, SpeakerInvariants.cs:50). [Rubric §16, Maintainability], [Rubric §24, Forms, Validation and UX Safety].
    • -
    • Walkthrough: a sealed class whose whole body is a two-line constructor (SpeakerCreateRequestValidator.cs:9-13) calling Include(new SpeakerFirstNameRules<SpeakerCreateRequest>(p => p.FirstName)) and the last-name equivalent.
    • -
    • Why it's built this way: extracting field rules into shared generic classes is the module-wide convention; add a constraint once and every request type that includes the rule set picks it up (here, both the create and the update validators).
    • -
    • Where it's used: discovered by the module's validator scan and run by ValidatingCommandDecorator<TCommand, TResult> ahead of CreateSpeakerHandler. Covered by SpeakerCreateRequestValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/Validation/SpeakerCreateRequestValidatorTests.cs).
    • -
    • Caveats / not-in-source: only the two names are validated here. Email is left entirely to the Email value object inside Speaker.Create (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:122-129), so a malformed address arrives as an invariant failure rather than a field-level validation error.
    • +
    • Where it's used: discovered by the module's validator scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125) and run by ValidatingCommandDecorator<TCommand, TResult> ahead of CreateQuestionHandler. Covered by QuestionCreateRequestValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Questions/Validation/QuestionCreateRequestValidatorTests.cs).
    • +
    • Caveats / not-in-source: only QuestionText is validated here. QuestionEntity, QuestionType, and QuestionSource are left entirely to the domain invariants inside Question.Create (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/Question.cs:80-83), so an invalid or missing value arrives as an invariant failure rather than a field-level validation error.

    SpeakerDTOMapper

    @@ -3362,39 +3565,57 @@

    SpeakerDTOMapper

    • What it is: the mapper for the Speaker aggregate itself. It composes the two child mappers, and it is the single place where BR-66 redacts speaker email from anyone who is not an organizer.
    • -
    • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over Speaker / SpeakerDTO / SpeakerIdentifierType (SpeakerDTOMapper.cs:21); the two sibling mappers SpeakerCategoryItemDTOMapper and SpeakerQuestionAnswerDTOMapper; ICurrentUserService; the Email value object; RoleNames; and Mapperly (SpeakerDTOMapper.cs:1-20).
    • -
    • Concept introduced, a redacting mapper, and why the redaction lives here: [Rubric §11, Security] assesses whether a PII rule is enforced at one chokepoint rather than at each call site, and [Rubric §30, Compliance and Data Governance] assesses whether personal data has a stated handling rule. MapToDTO does not expose the generated mapping directly. It calls the private generated method and then decides: currentUserService.IsInRole(RoleNames.Organizer) ? dto : dto with { Email = null } (SpeakerDTOMapper.cs:33-36). Because every speaker read path in the module goes through this one mapper (SpeakerEntityQueryService.cs:19, CreateSpeakerHandler.cs:19, UpdateSpeakerHandler.cs:17), there is no endpoint that can accidentally return a speaker email to the public. That single-chokepoint property is also what makes the framework's inherited CSV export a hole worth patching separately: it streams past the DTO mapper entirely, which is why SpeakersController overrides the export action and denies non-privileged callers outright (BR-239, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:281-305). Reading the mapper alone would leave you believing the rule was airtight; reading the pair shows where it needed help.
    • +
    • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over Speaker / SpeakerDTO / SpeakerIdentifierType (SpeakerDTOMapper.cs:21); the two sibling mappers SpeakerCategoryItemDTOMapper and SpeakerQuestionAnswerDTOMapper; ICurrentUserService; the Email value object; RoleNames; and Mapperly (SpeakerDTOMapper.cs:1-20).
    • +
    • Concept introduced, a redacting mapper, and why the redaction lives here: [Rubric §11, Security] assesses whether a PII rule is enforced at one chokepoint rather than at each call site, and [Rubric §30, Compliance, Privacy and Data Governance] assesses whether personal data has a stated handling rule. MapToDTO does not expose the generated mapping directly. It calls the private generated method and then decides: currentUserService.IsInRole(RoleNames.Organizer) ? dto : dto with { Email = null } (SpeakerDTOMapper.cs:33-36). Because every speaker read path in the module goes through this one mapper (SpeakerEntityQueryService.cs:19, CreateSpeakerHandler.cs:19, UpdateSpeakerHandler.cs:17), there is no endpoint that can accidentally return a speaker email to the public. That single-chokepoint property is also what makes the framework's inherited CSV export a hole worth patching separately: it streams past the DTO mapper entirely, which is why SpeakersController overrides the export action, gates it on SpeakersManage, and denies non-privileged callers outright (BR-239, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:281-302). Reading the mapper alone would leave you believing the rule was airtight; reading the pair shows where it needed help.
    • Concept introduced, mapper composition with [UseMapper]: each injected child mapper is stored in a private field annotated [UseMapper] (SpeakerDTOMapper.cs:23-27). That attribute tells the Mapperly generator: when you need to map a SpeakerCategoryItem to a SpeakerCategoryItemDTO while filling this type, call that mapper instead of generating a second, private copy of the same mapping. The payoff is that SpeakerDTO's two child collections (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:57, SpeakerDTO.cs:60) are filled by exactly the same code the child endpoints use, so a speaker read and a GET /SpeakerCategoryItems read can never disagree about a child's shape. [Rubric §2, Design Patterns]: composition over duplication, expressed declaratively. [Rubric §16, Maintainability]: adding a field to a child DTO is one edit, not three.
    • -
    • Walkthrough: five members plus the two fields.
        +
      • Walkthrough: four methods plus the two fields.
        • The primary constructor takes the two child mappers and ICurrentUserService (SpeakerDTOMapper.cs:17-20), assigned to the [UseMapper] fields (SpeakerDTOMapper.cs:23-27).
        • MapToDTO (SpeakerDTOMapper.cs:30-37) is hand-written, not generated: null guard, call the generated mapping, apply the BR-66 redaction with a with expression on the record DTO.
        • MapToDTOGenerated (SpeakerDTOMapper.cs:46) is the private partial the generator fills. Making the generated method private and wrapping it is the mechanism that lets the redaction be unskippable; a caller cannot reach the unredacted projection.
        • MapToDTOs (SpeakerDTOMapper.cs:40-44) maps a collection through the same public MapToDTO, so the rule applies per row on list reads too.
        • -
        • NullableEmailToString (SpeakerDTOMapper.cs:49) is a private conversion helper Mapperly picks up to turn the Email value object into the DTO's string?. A value object on the entity and a plain string on the contract is exactly the kind of gap that would otherwise be a build error.
        • +
        • NullableEmailToString (SpeakerDTOMapper.cs:49) is a private conversion helper Mapperly picks up to turn the Email value object into the DTO's string? (SpeakerDTO.cs:27). A value object on the entity and a plain string on the contract is exactly the kind of gap that would otherwise be a build error.
      • Why it's built this way: children are mapped by their owners' mappers, so the DTO graph is assembled from single-purpose pieces the DI container already has (ADR-001). What the child collections actually contain at map time is decided earlier, by SpeakerNavigationPopulator (ADR-002): this mapper copies what was loaded and never triggers a query itself.
      • -
      • Where it's used: injected as a concrete type into CreateSpeakerHandler (CreateSpeakerHandler.cs:19) and UpdateSpeakerHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/UseCases/Update/UpdateSpeakerHandler.cs:17) for the write-path response DTO, and into SpeakerEntityQueryService (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/SpeakerEntityQueryService.cs:19), the speaker-specific query service registered at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:63, which every speaker read endpoint goes through (ADR-034). Covered by SpeakerDTOMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/DTOs/SpeakerDTOMapperTests.cs).
      • +
      • Where it's used: injected as a concrete type into CreateSpeakerHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/UseCases/Create/CreateSpeakerHandler.cs:19) and UpdateSpeakerHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/UseCases/Update/UpdateSpeakerHandler.cs:17) for the write-path response DTO, and into SpeakerEntityQueryService (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/SpeakerEntityQueryService.cs:19), the speaker-specific query service registered at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:67, which every speaker read endpoint goes through (ADR-034). Covered by SpeakerDTOMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/DTOs/SpeakerDTOMapperTests.cs).
      • Caveats / not-in-source: the redaction depends on ICurrentUserService resolving a real caller. In a background or system context with no principal, IsInRole returning false means the mapper redacts, which fails closed; that is the safe direction, but nothing in this file states it as an intended behavior.
      -

      CreateSpeakerHandler

      +

      UpdateRoomCommandValidator

      -

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.Create · MMCA.ADC.Conference.Application/Speakers/UseCases/Create/CreateSpeakerHandler.cs:16 · Level 10 · class (sealed partial)

      +

      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.UpdateRoom · MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomCommandValidator.cs:7 · Level 9 · class (sealed)

        -
      • What it is: the handler that creates a speaker. It is deliberately the thinnest handler in this unit: map, add, save, project. Compare it with CreateQuestionHandler, which needs a reserved id range and a collision retry; a speaker id is a client-assigned GUID, so none of that machinery is required here.
      • -
      • Depends on: ICommandHandler<in TCommand, TResult> closed over SpeakerCreateRequest and Result<SpeakerDTO> (CreateSpeakerHandler.cs:20); IUnitOfWork; IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, satisfied by SpeakerCreateRequestMapper; SpeakerDTOMapper; SpeakerDTO; Result; Microsoft.Extensions.Logging.
      • -
      • Concept reinforced, the generic create slice end to end: [Rubric §5, Vertical Slice]: the request IS the command, so the class implements ICommandHandler<SpeakerCreateRequest, Result<SpeakerDTO>> (CreateSpeakerHandler.cs:20) and the four types of the slice sit in one folder. [Rubric §3, Clean Architecture]: the handler orchestrates a request mapper, a repository, and a DTO mapper without embedding any construction logic of its own. [Rubric §11, Security] reaches it indirectly through the response: SpeakerDTOMapper redacts the speaker's email for non-organizers (BR-66, SpeakerDTOMapper.cs:36), so even the create response obeys the same rule as every read.
      • -
      • Walkthrough
          -
        • Primary constructor (CreateSpeakerHandler.cs:16-20): unit of work, the request mapper resolved by its generic interface, the DTO mapper as a concrete type, and ILogger<CreateSpeakerHandler>.
        • -
        • HandleAsync (CreateSpeakerHandler.cs:23-40) calls requestMapper.CreateEntityAsync(command, cancellationToken) first (CreateSpeakerHandler.cs:27) and returns the mapper's errors verbatim on failure (CreateSpeakerHandler.cs:28-29), so a domain invariant violation surfaces as a Result failure rather than an exception.
        • -
        • It takes result.Value! (CreateSpeakerHandler.cs:31), resolves IRepository<Speaker, SpeakerIdentifierType> from the unit of work rather than injecting it (CreateSpeakerHandler.cs:32), then awaits repository.AddAsync(...) and unitOfWork.SaveChangesAsync(...) with ConfigureAwait(false) (CreateSpeakerHandler.cs:34-35, ADR-049). Resolving the repository through IUnitOfWork rather than constructor-injecting IRepository<TEntity, TIdentifierType> is the framework rule that keeps one tracked context per scope.
        • -
        • It logs through the generated LogSpeakerCreated, which records the id and the computed full name (CreateSpeakerHandler.cs:37, declared at CreateSpeakerHandler.cs:42-43), and returns Result.Success(dtoMapper.MapToDTO(entity)) (CreateSpeakerHandler.cs:39).
        • +
        • What it is: the FluentValidation validator for UpdateRoomCommand. It declares no rule of its own; its entire body composes six shared rule sets (UpdateRoomCommandValidator.cs:11-16).
        • +
        • Depends on: FluentValidation's AbstractValidator<T> (NuGet); RoomNameRules<T>, RoomSortRules<T>, RoomCapacityRules<T>, RoomFloorRules<T>, RoomLocationRules<T>, and RoomAccessibilityInfoRules<T> (UpdateRoomCommandValidator.cs:2).
        • +
        • Concept reinforced, rule composition with Include: [Rubric §16, Maintainability] assesses whether one constraint is written once: rather than restate the room constraints in the add validator and again here, both Include the same generic rule sets, parameterized by a property selector. Include merges the included validator's rules in as though they had been declared inline, so composition costs nothing at validation time. [Rubric §24, Forms, Validation and UX Safety] covers what those rules produce: each carries a human message and a stable error code, for example Room.Name.Required and Room.Name.MaxLength (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:17-18), so a client can branch on the code instead of parsing English. The ceilings come from the domain, not from the validator: EventInvariants.RoomNameMaxLength is 255, RoomFloorMaxLength 100, RoomLocationMaxLength 255, and RoomAccessibilityInfoMaxLength 500 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:43, EventInvariants.cs:46, EventInvariants.cs:49, EventInvariants.cs:52), which are the same constants the domain invariant enforces (EventInvariants.cs:143), so the form limit and the domain limit cannot drift apart.
        • +
        • Walkthrough: a sealed class whose whole body is a six-line constructor (UpdateRoomCommandValidator.cs:9-17). RoomNameRules<T> is required plus max length (RoomValidationRules.cs:12-18); RoomSortRules<T> demands a sort greater than or equal to zero (RoomValidationRules.cs:25-30); RoomCapacityRules<T> demands a positive capacity but only when one is supplied, via .When(x => selector.Compile()(x) is not null) (RoomValidationRules.cs:37-43); the floor, location, and accessibility rule sets are max-length only, so a null value passes (RoomValidationRules.cs:51-56, RoomValidationRules.cs:64-69, RoomValidationRules.cs:77-82).
        • +
        • Why it's built this way: extracting field rules into shared generic classes is the module-wide convention; add a constraint once and every command that includes the rule set picks it up. Running them ahead of the transaction is the pipeline's job: ValidatingCommandDecorator<TCommand, TResult> sits outside ITransactional (ADR-014), so a malformed room never opens a database transaction.
        • +
        • Where it's used: discovered by the module's validator scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125) and run by ValidatingCommandDecorator<TCommand, TResult> ahead of UpdateRoomHandler. Compare AddRoomCommandValidator, which includes the same rule sets for the add path. Covered by UpdateRoomCommandValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/Validation/CommandValidatorTests.cs:116-118).
        • +
        • Caveats / not-in-source: name uniqueness within an event is not validated here. It cannot be: the rule needs the event's other rooms, so it lives in the aggregate as EnsureRoomNameIsUnique and comes back as the invariant error Event.Room.Duplicate (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:716-733, code at Event.cs:728).
        -
      • -
      • Why it's built this way: validation, cache invalidation, and transaction scope are all handled by the pipeline decorators wrapped around this handler (declared by the markers on SpeakerCreateRequest), which is exactly why the create logic can reduce to four statements (ADR-014). [Rubric §1, SOLID]: the handler's one reason to change is the use case.
      • -
      • Where it's used: registered by the Conference application scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112); injected into SpeakersController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:46) and dispatched by its POST /Speakers override, which delegates to the base controller and then evicts the speakers output cache (SpeakersController.cs:308-317). The action is gated on the SpeakersManage permission (SpeakersController.cs:309). Covered by CreateSpeakerHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/CreateSpeakerHandlerTests.cs).
      • -
      • Caveats / not-in-source: nothing here guards against a caller re-submitting an id that already exists; the insert simply fails on the primary key and surfaces through the shared exception handling. The Sessionize import path relies on that, since it supplies the Sessionize GUID as Id, but no comment in this file records the expectation.
      • +

        UpdateRoomHandler

        +
        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.UpdateRoom · MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomHandler.cs:13 · Level 9 · class (sealed partial)

        +
        +
          +
        • What it is: the handler for UpdateRoomCommand. Same load-delegate-save template as RemoveRoomHandler, with all six room fields forwarded to the aggregate.
        • +
        • Depends on: ICommandHandler<in TCommand, TResult> closed over the command and the non-generic Result (UpdateRoomHandler.cs:15); IUnitOfWork; the Event aggregate and its Room child; Result and Error; Microsoft.Extensions.Logging. No DTO mapper is injected, because the update returns no body.
        • +
        • Concept reinforced, the handler as a pass-through to the root: [Rubric §4, Domain-Driven Design] assesses where the rules live, and this handler is the clearest example in the unit of them living elsewhere: it re-checks nothing that UpdateRoomCommandValidator already checked and decides nothing the aggregate decides. Event.UpdateRoom (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:411-436) resolves the room or returns not-found (Event.cs:420-423), enforces name uniqueness excluding the room being edited (Event.cs:425-427), forwards to room.Update(...) for the field-level invariants (Event.cs:429-431), and raises RoomChanged with DomainEntityState.Updated only after all three pass (Event.cs:433). [Rubric §3, Clean Architecture]: the handler touches abstractions only, with no EF type in sight.
        • +
        • Walkthrough: primary constructor (UpdateRoomHandler.cs:13-15); HandleAsync (UpdateRoomHandler.cs:18) resolves the repository (UpdateRoomHandler.cs:22), loads with includes: [nameof(Event.Rooms)] and asTracking: true (UpdateRoomHandler.cs:23-27) because both the uniqueness check and the mutation need the sibling rooms in memory and tracked, returns Error.NotFound when the event is missing (UpdateRoomHandler.cs:28-29), forwards the seven remaining command members positionally to entity.UpdateRoom(...) (UpdateRoomHandler.cs:31-38), and saves plus logs only on success (UpdateRoomHandler.cs:39-43, generated log method at UpdateRoomHandler.cs:48-49). The domain Result is returned unchanged (UpdateRoomHandler.cs:45), so a rejection reaches the API with its error codes intact.
        • +
        • Why it's built this way: the uniqueness rule is the reason the whole Rooms collection is loaded for what looks like a single-row edit. It is an aggregate-scoped invariant, so it can only be answered with the aggregate in hand; pushing it to a database index alone would surface as an opaque constraint violation instead of the typed Event.Room.Duplicate error (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:728).
        • +
        • Where it's used: registered by the module scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125); invoked through the decorator pipeline by RoomsController's PUT /Rooms/{id} action (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/RoomsController.cs:285-299), which returns 204 No Content and evicts the rooms output cache on success (RoomsController.cs:306-307). Covered by UpdateRoomHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/UseCases/UpdateRoomHandlerTests.cs).
        • +
        +

        LinkUserToSpeakerCommand

        +
        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.LinkUser · MMCA.ADC.Conference.Application/Speakers/UseCases/LinkUser/LinkUserToSpeakerCommand.cs:13 · Level 8 · record

        +
        +
          +
        • What it is: the write message an organizer sends to attach an application account to a speaker profile (BR-209). Two ids and nothing else: SpeakerId and UserId (LinkUserToSpeakerCommand.cs:13).
        • +
        • Depends on: ICacheInvalidating and ITransactional, both markers implemented at LinkUserToSpeakerCommand.cs:13; Speaker, referenced only to build the cache prefix; and the module identifier aliases SpeakerIdentifierType (a System.Guid, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:19) and UserIdentifierType (owned by Identity).
        • +
        • Concept introduced, the command that declares its own transaction: [Rubric §6, CQRS and Event-Driven] assesses whether a write is modeled as an explicit single-purpose message: this record carries intent only, and the two marker interfaces tell the pipeline how to run it. [Rubric §10, Cross-Cutting] assesses whether such concerns are declared rather than hand-coded. Implementing ITransactional opts the message into TransactionalCommandDecorator<TCommand, TResult>, the innermost decorator in the registered chain (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:115-121), and the XML comment states the reason plainly (LinkUserToSpeakerCommand.cs:7-8): the Speaker link and the outbox row that carries the cross-context User update must commit together or not at all. Implementing ICacheInvalidating with CachePrefix => $"{typeof(Speaker).FullName}:" (LinkUserToSpeakerCommand.cs:16) is what makes CachingCommandDecorator<TCommand, TResult> drop every cached read keyed under the Speaker type after a successful link.
        • +
        • Walkthrough: a sealed record with a two-parameter positional constructor (LinkUserToSpeakerCommand.cs:13) and one member, the expression-bodied CachePrefix (LinkUserToSpeakerCommand.cs:15-16). The cross-module identifier pairing is the notable part: UserIdentifierType is Identity's alias, carried here as a plain scalar because the two modules own separate databases and there is no foreign key to point at (ADR-006, ADR-048).
        • +
        • Why it's built this way: the two markers move durability and cache eviction out of the handler and into the pipeline, so LinkUserToSpeakerHandler reads as pure domain orchestration (ADR-014). Records give value equality and immutability for free.
        • +
        • Where it's used: constructed by the PUT /Speakers/{id}/link action from a LinkUserRequest body (SpeakersController at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:372, handler injected at SpeakersController.cs:49, permission-gated at SpeakersController.cs:365); handled by LinkUserToSpeakerHandler. Its inverse is UnlinkUserFromSpeakerCommand.

        RemoveSpeakerCategoryItemCommand

        @@ -3403,10 +3624,23 @@

        RemoveSpeakerCategoryItemCommand

        • What it is: the mirror image of AddSpeakerCategoryItemCommand, the message that detaches one category-item tag from a speaker. Two positional parameters: the owning SpeakerId and the SpeakerCategoryItemId of the join entity to remove (RemoveSpeakerCategoryItemCommand.cs:12-14).
        • Depends on: ICacheInvalidating, the only interface it implements (RemoveSpeakerCategoryItemCommand.cs:14); the Speaker domain type, referenced solely to build the cache prefix; and the SpeakerIdentifierType (a GUID) and SpeakerCategoryItemIdentifierType (an int) module aliases (ADR-048).
        • -
        • Concept reinforced, the remove command addresses the join row, not the tag: the second parameter is the identity of the association (SpeakerCategoryItem), not of the CategoryItem being untagged. That asymmetry with the Add command (which takes the CategoryItemId it wants to attach) is deliberate: after the association exists, the REST resource the client holds is the junction row, so a delete names it directly (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:232-239). [Rubric §9, API and Contract Design] assesses whether the contract addresses the thing the caller actually has a handle on.
        • -
        • Walkthrough: a sealed record whose whole body is one expression-bodied member, CachePrefix => $"{typeof(Speaker).FullName}:" (RemoveSpeakerCategoryItemCommand.cs:16-17). That is the same prefix every other speaker write declares, so an untag flushes the whole cached speaker read surface rather than trying to surgically evict the one nested collection (ADR-026). Note the absence of ITransactional: the write touches a single aggregate in a single SaveChangesAsync, so there is nothing to keep atomic across contexts (contrast UnlinkUserFromSpeakerCommand, immediately below).
        • +
        • Concept reinforced, the remove command addresses the join row, not the tag: the second parameter is the identity of the association (SpeakerCategoryItem), not of the category item being untagged. That asymmetry with the Add command (which takes the CategoryItemId it wants to attach) is deliberate: once the association exists, the REST resource the client holds is the junction row, so a delete names it directly and takes the speaker id from the query string (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:240-247). [Rubric §9, API and Contract Design] assesses whether the contract addresses the thing the caller actually has a handle on.
        • +
        • Walkthrough: a sealed record whose whole body is one expression-bodied member, CachePrefix => $"{typeof(Speaker).FullName}:" (RemoveSpeakerCategoryItemCommand.cs:16-17). That is the same prefix every other speaker write declares, so an untag flushes the whole cached speaker read surface rather than trying to surgically evict the one nested collection (ADR-026). Note the absence of ITransactional: the write touches a single aggregate in a single SaveChangesAsync, so there is nothing to keep atomic across contexts (contrast UnlinkUserFromSpeakerCommand, below).
        • Why it's built this way: the message declares its cross-cutting effects and the pipeline applies them (ADR-014); the handler stays free of cache code. [Rubric §10, Cross-Cutting].
        • -
        • Where it's used: constructed by the DELETE /SpeakerCategoryItems/{id} action of SpeakerCategoryItemsController, which takes the join id from the route and the speaker id from the query string (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:232-240, handler injected at SpeakerCategoryItemsController.cs:50); handled by RemoveSpeakerCategoryItemHandler.
        • +
        • Where it's used: constructed by the DELETE /SpeakerCategoryItems/{id} action of SpeakerCategoryItemsController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:240-247, handler injected at SpeakerCategoryItemsController.cs:51), a controller gated on the SpeakersManage permission at the class level rather than per action (SpeakerCategoryItemsController.cs:47, ADR-020); handled by RemoveSpeakerCategoryItemHandler.
        • +
        +

        SpeakerCreateRequest

        +
        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.Create · MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequest.cs:10 · Level 8 · record

        +
        +
          +
        • What it is: the create-request DTO for a conference speaker. Like QuestionCreateRequest it doubles as the command: CreateSpeakerHandler implements ICommandHandler<SpeakerCreateRequest, Result<SpeakerDTO>> directly against this type, so there is no separate CreateSpeakerCommand.
        • +
        • Depends on: ICreateRequest, an empty marker used as the generic constraint on the request-mapper contract (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICreateRequest.cs:8-10); ICacheInvalidating; the Speaker type for the cache prefix; the SpeakerIdentifierType alias (SpeakerCreateRequest.cs:10, SpeakerCreateRequest.cs:13).
        • +
        • Concept reinforced, the request-as-command shape: [Rubric §9, API and Contract Design] assesses whether the wire contract is an explicit, versionable type rather than the domain entity leaking outward: the controller binds this record straight from the request body (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:311) and it is also the fourth generic argument of the base controller (SpeakersController.cs:59-60). [Rubric §5, Vertical Slice] applies to the folder: request, mapper, validator, and handler for Create all sit in Speakers/UseCases/Create. Marking it ICacheInvalidating means a successful create evicts the cached speaker reads exactly like the command records above.
        • +
        • Walkthrough: a record class (not sealed) with init-only members. CachePrefix (SpeakerCreateRequest.cs:12-13) is the invalidation tag. Id (SpeakerCreateRequest.cs:15-16) is a SpeakerIdentifierType, and its comment records that it is Sessionize-assigned: the caller supplies the key rather than the database generating it, which is what lets an import be idempotent. Three members are required, so the record cannot be constructed without them: FirstName (SpeakerCreateRequest.cs:19), LastName (SpeakerCreateRequest.cs:22), and FullName (SpeakerCreateRequest.cs:25). The rest are optional init members: Email (SpeakerCreateRequest.cs:28), Bio (SpeakerCreateRequest.cs:31), TagLine (SpeakerCreateRequest.cs:34), ProfilePicture (SpeakerCreateRequest.cs:37), the IsTopSpeaker flag (SpeakerCreateRequest.cs:40), and the four profile links TwitterHandle (SpeakerCreateRequest.cs:43), LinkedInUrl (SpeakerCreateRequest.cs:46), GitHubUrl (SpeakerCreateRequest.cs:49), and WebsiteUrl (SpeakerCreateRequest.cs:52).
        • +
        • Why it's built this way: required plus init gives compile-time enforcement of the minimum payload while leaving the rest optional, and collapsing request and command into one type keeps a simple create slice to a single message (contrast the child-mutation flows, where the controller builds a distinct command record such as AddSpeakerCategoryItemCommand).
        • +
        • Where it's used: bound by SpeakersController on the SpeakersManage-gated POST /Speakers (SpeakersController.cs:308-317); validated by SpeakerCreateRequestValidator; translated to a domain entity by SpeakerCreateRequestMapper; handled by CreateSpeakerHandler.
        • +
        • Caveats / not-in-source: FullName is required on the contract but never reaches the domain. Speaker computes FullName => $"{FirstName} {LastName}" (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:61) and SpeakerCreateRequestMapper does not pass it to the factory, so a caller-supplied value is accepted and discarded. It is the only member of this record the mapper drops; every other one, the four profile links included, is forwarded (SpeakerCreateRequestMapper.cs:19-31). Nothing on this type says so.

        UnlinkUserFromSpeakerCommand

        @@ -3426,82 +3660,246 @@

        UserRegisteredHandler

        • What it is: the Conference-side subscriber to Identity's UserRegistered integration event. When someone registers an account, this handler tries to find the speaker profile that belongs to them and links the two (BR-207), so a speaker who signs up sees their own sessions without an organizer lifting a finger.
        • -
        • Depends on: IIntegrationEventHandler<in TIntegrationEvent> closed over UserRegistered (UserRegisteredHandler.cs:42); IServiceScopeFactory (BCL DI); IUnitOfWork and IRepository<TEntity, TIdentifierType>, both resolved from the created scope; IEventBus; the Speaker aggregate; SpeakerLinkedToUser; the Email value object; Microsoft.Extensions.Logging.
        • -
        • Concept introduced, the integration-event consumer that opens its own scope: an IIntegrationEventHandler<in TIntegrationEvent> is registered as a singleton by the framework's convention scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:126-130), which means it cannot hold a scoped IUnitOfWork as a constructor dependency: a singleton capturing a scoped EF context is the classic captive-dependency bug. The handler therefore takes an IServiceScopeFactory and opens one scope per event (UserRegisteredHandler.cs:51-53), resolving the unit of work and the event bus inside it, then disposes the scope with the event. The class comment states the lifetime rule outright (UserRegisteredHandler.cs:35-38). [Rubric §6, CQRS and Event-Driven] assesses whether consumers are reliable and idempotent; [Rubric §7, Microservices Readiness]: Conference reacts to an Identity fact without referencing Identity's domain, only its published event contract.
        • -
        • Concept introduced, letting the delivery mechanism own retry: the whole body sits inside try with an exception filter rather than a catch block that swallows: catch (Exception ex) when (LogAndRethrow(ex, ...)) (UserRegisteredHandler.cs:101). LogAndRethrow logs and returns false (UserRegisteredHandler.cs:124-128), so the filter never matches and the exception keeps propagating; the throw inside the block is unreachable by construction (UserRegisteredHandler.cs:103-104). The remarks explain the fix this replaced (UserRegisteredHandler.cs:112-122): the handler used to swallow everything, so a single transient database fault lost the auto-link permanently, because delivery had already been acknowledged. Propagating instead hands the decision to the transport, which is built for it: the outbox retries to its limit and then dead-letters, and MassTransit redelivers and then moves the message to the error queue (ADR-003). Retry is safe because the operation is idempotent (see the walkthrough), which is the same reasoning ADR-021 formalizes for consumers. [Rubric §29, Resilience] assesses whether failures are recoverable rather than silently absorbed; this is the difference between an alertable dead letter and a lost link nobody notices.
        • +
        • Depends on: IIntegrationEventHandler<in TIntegrationEvent> closed over UserRegistered (UserRegisteredHandler.cs:42); IServiceScopeFactory (BCL DI); IUnitOfWork and IEntityQuerier<TEntity, TIdentifierType>, the read-side surface both private helpers take (UserRegisteredHandler.cs:131, UserRegisteredHandler.cs:168); IEventBus; the Speaker aggregate; SpeakerLinkedToUser; the Email value object; Microsoft.Extensions.Logging.
        • +
        • Concept introduced, the integration-event consumer that opens its own scope: an IIntegrationEventHandler<in TIntegrationEvent> is registered as a singleton by the framework's convention scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:151-155), which means it cannot hold a scoped IUnitOfWork as a constructor dependency: a singleton capturing a scoped EF context is the classic captive-dependency bug. The handler therefore takes an IServiceScopeFactory and opens one scope per event (UserRegisteredHandler.cs:51-53), resolving the unit of work and the event bus inside it, then disposes the scope with the event. The class comment states the lifetime rule outright (UserRegisteredHandler.cs:35-38). [Rubric §6, CQRS and Event-Driven] assesses whether consumers are reliable and idempotent; [Rubric §7, Microservices Readiness]: Conference reacts to an Identity fact without referencing Identity's domain, only its published event contract.
        • +
        • Concept introduced, letting the delivery mechanism own retry: the whole body sits inside try with an exception filter rather than a catch block that swallows: catch (Exception ex) when (LogAndRethrow(ex, ...)) (UserRegisteredHandler.cs:101). LogAndRethrow logs and returns false (UserRegisteredHandler.cs:124-128), so the filter never matches and the exception keeps propagating; the throw inside the block is unreachable by construction (UserRegisteredHandler.cs:103-104). The remarks explain the fix this replaced (UserRegisteredHandler.cs:111-123): the handler used to swallow everything, so a single transient database fault lost the auto-link permanently, because delivery had already been acknowledged. Propagating instead hands the decision to the transport, which is built for it: the outbox retries to its limit and then dead-letters, and MassTransit redelivers and then moves the message to the error queue (ADR-003). Retry is safe because the operation is idempotent (see the walkthrough), which is the same reasoning ADR-021 formalizes for consumers. [Rubric §29, Resilience] assesses whether failures are recoverable rather than silently absorbed; this is the difference between an alertable dead letter and a lost link nobody notices.
        • Concept introduced, an identity match must use a fact the registrant cannot forge: there is exactly ONE match strategy, and the reason the others were removed is the interesting part. TryMatchByEmailAsync (UserRegisteredHandler.cs:130-158) parses the registered address through the Email value object, bails with a warning if it is malformed (UserRegisteredHandler.cs:137-142), and queries speakers whose recorded Email equals it (UserRegisteredHandler.cs:145-149). A unique-name fallback used to run when the email missed, covering Sessionize-imported speakers whose Email is always null because the public view/All endpoint omits PII. It was deleted as a security fix (bug hunt C5): first and last name arrive straight from the attacker-controlled registration form and prove nothing, so anyone who knew a speaker's name could register under it and take over that profile (UserRegisteredHandler.cs:16-25). [Rubric §11, Security] assesses whether an authorization-relevant decision rests on a verified signal: an email is verified by the registration flow, a typed-in name is not.
        • Walkthrough:
          • HandleAsync (UserRegisteredHandler.cs:45) null-guards the event (UserRegisteredHandler.cs:47), opens the scope, and resolves the Speaker repository (UserRegisteredHandler.cs:51-55).
          • -
          • On an email miss it calls LogNameMatchCandidatesAsync and returns without linking (UserRegisteredHandler.cs:58-63). That helper is read-only by design (UserRegisteredHandler.cs:160-193): it counts unlinked speakers whose first and last name match (UserRegisteredHandler.cs:183-187) and logs the count, never returning the rows, so no code path can link on a name. The log line is the trail an organizer follows to link the speaker by hand through LinkUserToSpeakerHandler (BR-209).
          • +
          • On an email miss it calls LogNameMatchCandidatesAsync and returns without linking (UserRegisteredHandler.cs:58-63). That helper is read-only by design (UserRegisteredHandler.cs:167-193): it counts unlinked speakers whose first and last name match (UserRegisteredHandler.cs:183-187) and logs the count, never returning the rows, so no code path can link on a name. The log line is the trail an organizer follows to link the speaker by hand through LinkUserToSpeakerHandler (BR-209).
          • Three idempotency guards follow, in order: a speaker already linked to a different user is left alone (UserRegisteredHandler.cs:66-70); a speaker already linked to this user re-publishes SpeakerLinkedToUser so Identity can re-sync, then returns without re-linking (UserRegisteredHandler.cs:74-80); and a rejected speaker.LinkUser(...) logs and returns (UserRegisteredHandler.cs:82-87). Together they make a redelivery a no-op, which is what licenses the rethrow above.
          • The happy path saves, publishes SpeakerLinkedToUser through the IEventBus, and logs (UserRegisteredHandler.cs:92-99). Identity consumes that event to set User.LinkedSpeakerId.
          • One detail worth reading twice: the email query orders .OrderBy(s => s.LinkedUserId.HasValue).ThenBy(s => s.Id) before taking the first (UserRegisteredHandler.cs:154-157). When two speaker rows share an address, an arbitrary FirstOrDefault could pick an already-linked record, abandon the link, and then pick a different one on a retry; the explicit ordering makes the choice deterministic across attempts and prefers the unlinked candidate.
          • +
          • The name-count query leans on two ambient behaviors the comment spells out (UserRegisteredHandler.cs:180-182): SQL Server's case-insensitive default collation, and the global soft-delete query filter that keeps IsDeleted rows out of the count (ADR-005).
        • -
        • Why it's built this way: the auto-link is deliberately eventually consistent (UserRegisteredHandler.cs:28-34). A brand-new user's first token does not carry the speaker_id claim; it appears on the next token refresh after this handler completes. The class comment also records why the handler does not evict the 5-minute SpeakersCache output cache: doing so would require an ASP.NET Core dependency the Application layer must not take, so the cache simply expires. A unique-index race on Speaker.LinkedUserId (two registrations matching one speaker at once) surfaces as a DbUpdateException and resolves on the retry, since the loser then hits the "already linked to a different user" guard (UserRegisteredHandler.cs:89-91).
        • -
        • Where it's used: registered as a singleton by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112, scanning rule at MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:126-130) and fed by the Conference service host, which wires the generic IntegrationEventConsumer<T> adapter for this event with services.AddBrokerMessaging(builder.Configuration, x => x.RegisterIntegrationEventConsumer<UserRegistered>()) (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:346-347, explained at Program.cs:336-345). Its producer is Identity's AuthenticationService, which raises UserRegistered on the user aggregate and persists its outbox row (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:236-237). Covered by UserRegisteredHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Users/IntegrationEventHandlers/UserRegisteredHandlerTests.cs) and end to end over a real broker by UserRegisteredBrokerFlowTests (MMCA.ADC/Tests/Integration/MMCA.ADC.CrossService.IntegrationTests/CrossService/UserRegisteredBrokerFlowTests.cs).
        • -
        • Caveats / not-in-source: the email query loads every matching speaker with asTracking: true (UserRegisteredHandler.cs:145-149), which is needed for the link but means the shared-address case materializes all of them. Nothing in the file bounds that set, and nothing states a policy for what a duplicate speaker email is supposed to mean.
        • +
        • Why it's built this way: the auto-link is deliberately eventually consistent (UserRegisteredHandler.cs:28-34). A brand-new user's first token does not carry the speaker_id claim; it appears on the next token refresh after this handler completes. The class comment also records why the handler does not evict the 5-minute SpeakersCache output cache: doing so would require an ASP.NET Core dependency the Application layer must not take, so the cache simply expires.
        • +
        • Where it's used: registered as a singleton by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125, scanning rule at MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:151-155) and fed by the Conference service host, which wires the generic IntegrationEventConsumer<T> adapter for this event with services.AddBrokerMessaging(builder.Configuration, x => x.RegisterIntegrationEventConsumer<UserRegistered>()...) (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:371-373, explained at Program.cs:357-366). Its producer is Identity's AuthenticationService, which raises UserRegistered on the user aggregate and persists its outbox row on the standard registration path (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:112-116) and again for a brand-new external-login user (AuthenticationService.cs:236-237). Covered by UserRegisteredHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Users/IntegrationEventHandlers/UserRegisteredHandlerTests.cs) and end to end over a real broker by UserRegisteredBrokerFlowTests (MMCA.ADC/Tests/Integration/MMCA.ADC.CrossService.IntegrationTests/CrossService/UserRegisteredBrokerFlowTests.cs).
        • +
        • Caveats / not-in-source: two things in this file disagree with each other. The inline comment above the save still says a unique-index race on Speaker.LinkedUserId "is swallowed by the outer best-effort catch below" (UserRegisteredHandler.cs:89-91), but the catch filter no longer swallows anything (UserRegisteredHandler.cs:101-105): such a DbUpdateException propagates and is resolved by the redelivery, exactly as the LogAndRethrow remarks describe (UserRegisteredHandler.cs:118-122). Trust the code, not that comment. Separately, the email query loads every matching speaker with asTracking: true (UserRegisteredHandler.cs:145-149); nothing in the file bounds that set, and nothing states a policy for what a duplicate speaker email is supposed to mean.
        -

        AddSessionCategoryItemCommand

        +

        ActivityCreateRequest

        -

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionCategoryItem · MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemCommand.cs:10 · Level 9 · record

        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.UseCases.Create · MMCA.ADC.Conference.Application/Activities/UseCases/Create/ActivityCreateRequest.cs:10 · Level 9 · record

          -
        • What it is: the command that tags a session with a category item (the mechanism behind session topics and tracks). Three positional parameters: the owning SessionId, an optional SessionCategoryItemId for the join entity, and the CategoryItemId being associated (AddSessionCategoryItemCommand.cs:10-13).
        • -
        • Depends on: ICacheInvalidating (AddSessionCategoryItemCommand.cs:13); the Session type for the cache prefix; and the SessionIdentifierType, SessionCategoryItemIdentifierType, and CategoryItemIdentifierType aliases, all three int in this module (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:5, :13, :14).
        • -
        • Concept reinforced, the nullable child id: the second parameter is documented as "Explicit ID for the join entity, or null for database-generated identity" (AddSessionCategoryItemCommand.cs:8), the same shape taught on AddSpeakerCategoryItemCommand. The REST path always passes null (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:216) and lets the database assign the key; the parameter exists for callers that already know the id, and it is the exact signature the aggregate factory takes (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:414-416). [Rubric §9, API and Contract Design]: nullable rather than defaulted keeps "let the database choose" distinct from "I chose zero".
        • -
        • Walkthrough: the record body is one member, CachePrefix => $"{typeof(Session).FullName}:" (AddSessionCategoryItemCommand.cs:15-16), the same session-wide prefix that RemoveSessionCategoryItemCommand and the session create request declare, so one eviction covers every cached session projection (ADR-026).
        • -
        • Where it's used: constructed by the POST /SessionCategoryItems action of SessionCategoryItemsController from an AddSessionCategoryItemRequest body (SessionCategoryItemsController.cs:210-217), on a controller gated by the SessionsManage permission (SessionCategoryItemsController.cs:46); validated by AddSessionCategoryItemCommandValidator; handled by AddSessionCategoryItemHandler.
        • +
        • What it is: the create-request DTO for a conference social or networking activity (a pre-conference party, a morning coffee connect, an after-party, a closing ceremony). Like SpeakerCreateRequest it doubles as the command handled by CreateActivityHandler.
        • +
        • Depends on: ICreateRequest and ICacheInvalidating (ActivityCreateRequest.cs:10); the Activity type for the cache prefix; the ActivityIdentifierType and EventIdentifierType module aliases.
        • +
        • Concept reinforced, an id the caller cannot choose: the contrast with SpeakerCreateRequest is the lesson. A speaker id is client-assigned because Sessionize owns it; an activity is planned inside the app, so Activity carries [IdValueGenerated] (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:19) and its factory assigns default rather than the supplied value (Activity.cs:120-125). The request still exposes an Id member, and its comment is honest about the consequence: "Database-generated; caller-provided values are ignored" (ActivityCreateRequest.cs:15). [Rubric §9, API and Contract Design] assesses whether a contract tells the caller the truth about what it does with each field.
        • +
        • Walkthrough: a record class with init-only members. CachePrefix => $"{typeof(Activity).FullName}:" (ActivityCreateRequest.cs:12-13) is the invalidation tag. Id (ActivityCreateRequest.cs:16) is ignored as described above. Only Name is required (ActivityCreateRequest.cs:19). Description is optional (ActivityCreateRequest.cs:22); StartTime and EndTime (ActivityCreateRequest.cs:25, ActivityCreateRequest.cs:28) are plain DateTime values in the owning event's wall-clock time, matching how Activity stores them and where the IANA zone actually lives, on the event (Activity.cs:28-36). The venue trio VenueName, VenueAddress, and VenueUrl (ActivityCreateRequest.cs:31, ActivityCreateRequest.cs:34, ActivityCreateRequest.cs:37) is carried on the activity rather than inherited from the event, because an after-party is usually somewhere else; an empty VenueName means the main conference venue. SortOrder (ActivityCreateRequest.cs:40) breaks ties between activities that start at the same minute, and EventId (ActivityCreateRequest.cs:43) is the owning event.
        • +
        • Why it's built this way: keeping the venue on the activity instead of the event is the modeling decision the domain comment spells out (Activity.cs:11-18): an activity is deliberately not a session, has no room and no speakers, and frequently happens off site. [Rubric §4, Domain-Driven Design].
        • +
        • Where it's used: bound by ActivitiesController on the ActivitiesManage-gated POST /Activities (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/ActivitiesController.cs:211-220) and used as the fourth generic argument of its base controller (ActivitiesController.cs:46-47); validated by ActivityCreateRequestValidator; mapped by ActivityCreateRequestMapper; handled by CreateActivityHandler. The update side has its own pair, ActivityUpdateRequest and UpdateActivityCommand.
        -

        PublicSessionStatusSpecification

        +

        ActivityDTOMapper

        -

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.Specifications · MMCA.ADC.Conference.Application/Sessions/Specifications/PublicSessionStatusSpecification.cs:20 · Level 9 · class (sealed)

        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.DTOs · MMCA.ADC.Conference.Application/Activities/DTOs/ActivityDTOMapper.cs:13 · Level 9 · class (sealed partial)

          -
        • What it is: the single definition of which session statuses an anonymous or non-privileged caller may see (BR-49): Accepted, or no status at all, since organizer-created sessions never carry one. It is a nine-line class that every public session read path in the module goes through.
        • -
        • Depends on: Specification<TEntity, TIdentifierType> closed over Session and SessionIdentifierType (PublicSessionStatusSpecification.cs:20); SessionStatuses for the Accepted constant; and System.Linq.Expressions.
        • -
        • Concept introduced, one predicate exposed in two forms: the allow-list is a public static readonly Expression<Func<Session, bool>> StatusCriteria (PublicSessionStatusSpecification.cs:23-24), and the instance Criteria override simply returns it (PublicSessionStatusSpecification.cs:27). That duality is the whole design. Call sites that need to compose the predicate into a larger expression tree take the static field, and call sites that want specification algebra (AND, OR, paging, sorting) instantiate the class, and both share one definition so the rule cannot drift between them (PublicSessionStatusSpecification.cs:13-15). [Rubric §11, Security] assesses whether a visibility rule is centralized: a status that becomes public here becomes public everywhere at once, which is exactly what you want and also exactly why this file deserves care.
        • -
        • Concept introduced, writing predicates that a database can actually run: the remarks record a trap the code deliberately avoids (PublicSessionStatusSpecification.cs:16-18). The domain already has SessionStatuses.IsEligible(status), but calling it here would put compiled C# inside an expression tree, and EF Core cannot translate a method body to SQL; the predicate would either throw or silently evaluate client-side after loading every row. So the expression compares against the Accepted constant directly. The comment also notes that SQL Server's case-insensitive default collation gives the same case behavior the in-memory predicate has. [Rubric §12, Performance and Scalability]: a translatable predicate filters in the database instead of in the process; [Rubric §8, Data Architecture]: the query stays engine-agnostic enough to survive the polyglot posture of ADR-018.
        • -
        • Walkthrough: two members and no constructor. StatusCriteria (PublicSessionStatusSpecification.cs:23-24) is s => s.Status == null || s.Status == SessionStatuses.Accepted; the null branch is not an oversight but the organizer-created case. Criteria (PublicSessionStatusSpecification.cs:27) is the override the specification pipeline consumes (ADR-055).
        • -
        • Why it's built this way: BR-49 is a business rule that appears in at least three query shapes; stating it once as an expression is the only way those shapes cannot disagree. Keeping it in the Application layer rather than the Domain is a consequence of it being a read filter, not an entity invariant (the invariant form lives beside it as SessionInvariants.EnsureStatusIsEligible, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:107).
        • -
        • Where it's used: PublicConferenceVisibility uses both forms, the static expression as the localPredicate of a cross-source build (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:67) and an instance in specification algebra (PublicConferenceVisibility.cs:142); GetPublicSessionFilterHandler uses the static expression for the public session list (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/GetPublicSessionFilter/GetPublicSessionFilterHandler.cs:34).
        • -
        • Caveats / not-in-source: the collation argument in the remarks is a statement about the deployed SQL Server, not something the type can enforce. On a case-sensitive collation or a different engine, the expression and SessionStatuses.IsEligible could disagree, and nothing in the code would catch it.
        • +
        • What it is: the Mapperly-generated projector from the Activity entity to ActivityDTO. It is the simplest mapper in the Conference module: no nested collections, no redaction, no injected services.
        • +
        • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over Activity, ActivityDTO, and ActivityIdentifierType (ActivityDTOMapper.cs:13-14); Riok.Mapperly's [Mapper] source generator (ActivityDTOMapper.cs:4, ActivityDTOMapper.cs:12).
        • +
        • Concept reinforced, "nothing to redact" is a decision, not an omission: compare SpeakerDTOMapper, which injects ICurrentUserService and blanks the speaker email for non-organizers (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/DTOs/SpeakerDTOMapper.cs:35-36, BR-66). This mapper takes no services at all, and the class comment says why: activity data is published to attendees by design (ActivityDTOMapper.cs:8-11). [Rubric §11, Security] assesses whether PII exposure is a considered per-field decision; the signal worth noticing here is that the absence of a filter is documented rather than accidental.
        • +
        • Walkthrough: [Mapper] on a sealed partial class (ActivityDTOMapper.cs:12-14) lets Mapperly emit the property-by-property body of the partial ActivityDTO MapToDTO(Activity entity) declaration (ActivityDTOMapper.cs:17) at compile time, so there is no reflection at runtime and a renamed or unmapped property is a build error rather than a silent null. The collection overload is hand-written: MapToDTOs null-guards and projects with a collection expression, [.. entityCollection.Select(MapToDTO)] (ActivityDTOMapper.cs:20-24). ActivityDTO carries RowVersion through IConcurrencyAware (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Activities/ActivityDTO.cs:10-16), which is what makes an optimistic-concurrency update possible from a previously read DTO.
        • +
        • Why it's built this way: generated mapping keeps the manual-mapping guarantee (no runtime reflection, compile-time verification) without the hand-written drudgery (ADR-001). [Rubric §16, Maintainability].
        • +
        • Where it's used: injected as a concrete type into CreateActivityHandler (CreateActivityHandler.cs:19) and UpdateActivityHandler; resolved as IEntityDTOMapper<...> by the EntityQueryService<Activity, ActivityDTO, ActivityIdentifierType> registered for the module (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:81), which is what serves the read endpoints. Registered both as itself and by its interfaces by the framework scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:157-161). Covered by ActivityDTOMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Activities/DTOs/ActivityDTOMapperTests.cs).
        • +
        • Caveats / not-in-source: Activity has no IEntityDTOProjector sibling (the Activities/DTOs/ folder holds only this mapper), so list reads materialize entities and then map them rather than projecting server-side. Whether that is a deliberate choice for a table this small is Not determinable from source.
        • +
        +

        LinkUserToSpeakerHandler

        +
        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.LinkUser · MMCA.ADC.Conference.Application/Speakers/UseCases/LinkUser/LinkUserToSpeakerHandler.cs:20 · Level 9 · class (sealed partial)

        +
        +
          +
        • What it is: the handler for LinkUserToSpeakerCommand. It updates the Conference side of a bidirectional link that spans two databases, and raises the event that updates the other side.
        • +
        • Depends on: ICommandHandler<in TCommand, TResult>; IUnitOfWork; Speaker; SpeakerLinkedToUser; Result and Error; Microsoft.Extensions.Logging. Note what is not injected: there is no publisher service, because the event is raised on the aggregate.
        • +
        • Concept introduced, cross-context coordination captured in the same transaction: [Rubric §6, CQRS and Event-Driven] and [Rubric §7, Microservices Readiness]. Conference and Identity own separate databases (ADR-006), so there is no foreign key between Speaker and User and consistency has to flow through events. The load-bearing detail is the ordering: speaker.AddDomainEvent(new SpeakerLinkedToUser(...)) runs BEFORE the single SaveChangesAsync (LinkUserToSpeakerHandler.cs:54, then LinkUserToSpeakerHandler.cs:56), so the outbox row is written inside the same transaction as the link (ADR-003). The class comment states the failure this removed (LinkUserToSpeakerHandler.cs:13-18): with a post-save publish, a crash could commit the Conference-side link and lose the event that sets User.LinkedSpeakerId. The OutboxProcessor later routes the row to the registered IMessageBus transport. This is also why the command carries ITransactional.
        • +
        • Walkthrough:
            +
          • Primary constructor (LinkUserToSpeakerHandler.cs:20-22): IUnitOfWork and ILogger<LinkUserToSpeakerHandler>; the declared result is the non-generic Result, so no DTO mapper is needed.
          • +
          • HandleAsync (LinkUserToSpeakerHandler.cs:25) resolves the repository (LinkUserToSpeakerHandler.cs:29) and loads the speaker with the include-free overload (LinkUserToSpeakerHandler.cs:30), returning Error.NotFound with source and target set when it is missing (LinkUserToSpeakerHandler.cs:31-32).
          • +
          • The BR-208 uniqueness guard (LinkUserToSpeakerHandler.cs:34-46) is the interesting part: it queries every speaker whose LinkedUserId equals the target user (LinkUserToSpeakerHandler.cs:35-38) and fails with Error.Invariant(code: "Speaker.UserAlreadyLinked", ...) if any OTHER speaker already holds that link (LinkUserToSpeakerHandler.cs:39-46). The s.Id != command.SpeakerId test is what makes re-linking the same pair a no-op rather than an error.
          • +
          • speaker.LinkUser(command.UserId) (LinkUserToSpeakerHandler.cs:48) is the domain decision. The aggregate refuses a speaker that is already linked, returning Speaker.AlreadyLinked (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:272-281), so the handler owns only the cross-row rule and the entity owns its own.
          • +
          • On success it raises the integration event on the aggregate (LinkUserToSpeakerHandler.cs:54), saves (LinkUserToSpeakerHandler.cs:56), and logs through the generated LogUserLinkedToSpeaker (LinkUserToSpeakerHandler.cs:58, declared at LinkUserToSpeakerHandler.cs:64-65). The domain Result is returned either way (LinkUserToSpeakerHandler.cs:61), so a rejection reaches the API with its error codes intact.
          • +
          +
        • +
        • Why it's built this way: splitting the rules (uniqueness across speakers in the handler, "already linked?" inside the aggregate) keeps each check where the data for it lives, and the pre-save event raise is a deliberate durability fix rather than a style choice.
        • +
        • Where it's used: registered by the Conference application scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125); invoked through the decorator pipeline by the PUT /Speakers/{id}/link action (SpeakersController at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:364-380). The emitted event is consumed on the Identity side to set User.LinkedSpeakerId. This organizer-driven path is also the deliberate fallback for speakers the automatic email match in UserRegisteredHandler cannot claim. Covered by LinkUserToSpeakerHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/LinkUserToSpeakerHandlerTests.cs).
        • +
        • Caveats / not-in-source: the BR-208 guard is a read-then-write with no lock, so two concurrent links naming the same user could both pass it; nothing in this file says what settles that race.

        RemoveSpeakerCategoryItemHandler

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.RemoveSpeakerCategoryItem · MMCA.ADC.Conference.Application/Speakers/UseCases/RemoveSpeakerCategoryItem/RemoveSpeakerCategoryItemHandler.cs:13 · Level 9 · class (sealed partial)

          -
        • What it is: the handler for RemoveSpeakerCategoryItemCommand. It is the canonical "load the aggregate, call a domain method, save" shape, at its smallest: twenty-two lines of orchestration with no business logic of its own.
        • +
        • What it is: the handler for RemoveSpeakerCategoryItemCommand. It is the canonical "load the aggregate, call a domain method, save" shape at its smallest: about twenty lines of orchestration with no business logic of its own.
        • Depends on: ICommandHandler<in TCommand, TResult> closed over the command and the non-generic Result (RemoveSpeakerCategoryItemHandler.cs:15); IUnitOfWork; the Speaker aggregate and its SpeakerCategoryItem child; Error; Microsoft.Extensions.Logging.
        • -
        • Concept reinforced, mutate only through the aggregate root: the handler never touches the join entity. It loads the speaker with its SpeakerCategoryItems collection and hands the id to entity.RemoveSpeakerCategoryItem(...) (RemoveSpeakerCategoryItemHandler.cs:31), which resolves the child, soft-deletes it, and raises SpeakerCategoryItemChanged (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:370-375). [Rubric §4, Domain-Driven Design] assesses whether the aggregate boundary is respected on writes: a handler that deleted the join row through its own repository would bypass the domain event and the aggregate's own not-found error.
        • +
        • Concept reinforced, mutate only through the aggregate root: the handler never touches the join entity. It loads the speaker with its SpeakerCategoryItems collection and hands the id to entity.RemoveSpeakerCategoryItem(...) (RemoveSpeakerCategoryItemHandler.cs:31), which resolves the child, soft-deletes it, and raises SpeakerCategoryItemChanged (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:382-395). [Rubric §4, Domain-Driven Design] assesses whether the aggregate boundary is respected on writes: a handler that deleted the join row through its own repository would bypass the domain event and the aggregate's own not-found error.
        • Walkthrough:
          • Primary constructor (RemoveSpeakerCategoryItemHandler.cs:13-15): IUnitOfWork and a typed ILogger<RemoveSpeakerCategoryItemHandler>. No DTO mapper, because a delete returns the non-generic Result.
          • -
          • HandleAsync (RemoveSpeakerCategoryItemHandler.cs:18) resolves the repository (:22) and loads with two arguments that are both load-bearing: includes: [nameof(Speaker.SpeakerCategoryItems)] (:25), without which the aggregate would search an empty in-memory collection and report not-found, and asTracking: true (:26), without which the change tracker would observe nothing and the save would emit no SQL.
          • +
          • HandleAsync (RemoveSpeakerCategoryItemHandler.cs:18) resolves the repository (RemoveSpeakerCategoryItemHandler.cs:22) and loads with two arguments that are both load-bearing: includes: [nameof(Speaker.SpeakerCategoryItems)] (RemoveSpeakerCategoryItemHandler.cs:25), without which the aggregate would search an empty in-memory collection and report not-found, and asTracking: true (RemoveSpeakerCategoryItemHandler.cs:26), without which the change tracker would observe nothing and the save would emit no SQL.
          • A missing speaker returns Error.NotFound.WithSource(...).WithTarget(...) (RemoveSpeakerCategoryItemHandler.cs:28-29), stamping the handler and the entity type into the error so the API response says which lookup failed.
          • -
          • Only on success does it save and log (RemoveSpeakerCategoryItemHandler.cs:32-37); the domain Result is returned unchanged either way (:39), so a domain rejection reaches the caller with its own codes intact.
          • +
          • Only on success does it save and log (RemoveSpeakerCategoryItemHandler.cs:32-37); the domain Result is returned unchanged either way (RemoveSpeakerCategoryItemHandler.cs:39), so a domain rejection reaches the caller with its own codes intact.
          • Logging goes through the source-generated [LoggerMessage] partial LogCategoryItemRemoved (RemoveSpeakerCategoryItemHandler.cs:42-43), the allocation-free, compile-checked logging idiom used by every handler in this module. [Rubric §13, Observability and Operability].
        • Why it's built this way: leaving removal semantics in the aggregate and cache eviction on the command (ICacheInvalidating) leaves the handler as four steps in a fixed order, which is why every sibling remove handler in this chapter reads the same way.
        • -
        • Where it's used: registered by the module's application scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112); invoked through the decorator pipeline by SpeakerCategoryItemsController's delete action (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:238-240). Covered by RemoveSpeakerCategoryItemHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/RemoveSpeakerCategoryItemHandlerTests.cs).
        • +
        • Where it's used: registered by the module's application scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125); invoked through the decorator pipeline by SpeakerCategoryItemsController's delete action (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:246-247), which then evicts both parents' output-cache entries (SpeakerCategoryItemsController.cs:255). Covered by RemoveSpeakerCategoryItemHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/RemoveSpeakerCategoryItemHandlerTests.cs).
        • +
        +

        SpeakerCreateRequestMapper

        +
        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.Create · MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequestMapper.cs:11 · Level 9 · class (sealed)

        +
        +
          +
        • What it is: the adapter that turns a validated SpeakerCreateRequest into a Speaker entity by calling the aggregate's static factory.
        • +
        • Depends on: IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> closed over Speaker, SpeakerCreateRequest, and SpeakerIdentifierType (SpeakerCreateRequestMapper.cs:11-12); Speaker; Result.
        • +
        • Concept reinforced, the request mapper as the only door into a factory: see QuestionCreateRequestMapper for the pattern. Speaker.Create (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:116-171) parses the optional email into an Email value object and bails on a malformed address (Speaker.cs:130-137), combines the first- and last-name invariants with Result.Combine so both failures surface at once (Speaker.cs:139-142), assigns the client-supplied id or generates one (Speaker.cs:161), and raises SpeakerChanged (Speaker.cs:168). The id fallback carries its own scar: the comment records that the previous id!.Value threw "Nullable object must have a value" and killed both Conference's startup seeding and every organizer create (Speaker.cs:156-160). [Rubric §4, Domain-Driven Design], [Rubric §15, Best Practices].
        • +
        • Walkthrough: CreateEntityAsync (SpeakerCreateRequestMapper.cs:15) null-guards (SpeakerCreateRequestMapper.cs:17), then returns Task.FromResult(Speaker.Create(...)) (SpeakerCreateRequestMapper.cs:19-31). Every one of the factory's twelve parameters is filled from the request (Id, FirstName, LastName, Email, Bio, TagLine, ProfilePicture, IsTopSpeaker, TwitterHandle, LinkedInUrl, GitHubUrl, WebsiteUrl); only FullName is dropped, because the entity computes it (Speaker.cs:61). The method is synchronous work behind an async signature: Task.FromResult satisfies the interface without an allocation-heavy state machine.
        • +
        • Why it's built this way: delegating every field check to the factory keeps validation in the domain instead of duplicated in the Application layer, and the generic create pipeline can drive any aggregate through the same contract (ADR-001).
        • +
        • Where it's used: injected into CreateSpeakerHandler as IEntityRequestMapper<Speaker, SpeakerCreateRequest, SpeakerIdentifierType> (CreateSpeakerHandler.cs:18) and invoked at CreateSpeakerHandler.cs:27; registered by the framework's request-mapper scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:172-176), driven from the module at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125.
        • +
        • Caveats / not-in-source: the factory's id parameter is SpeakerIdentifierType?, but the request's Id is the non-nullable alias, so this path always passes a value and the Guid.NewGuid() fallback at Speaker.cs:161 is unreachable from it. A POST /Speakers body that omits Id therefore creates a speaker whose key is Guid.Empty rather than a fresh GUID. The null-id branch is reached only by callers that pass id: null explicitly, which is what the sample-data seeder does (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/Seeding/ConferenceModuleDbSeeder.cs:201-202); the Sessionize import supplies the Sessionize GUID instead (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SpeakerSyncStrategy.cs:126). Nothing in this file or the request records that expectation.
        • +
        +

        SpeakerCreateRequestValidator

        +
        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.Create · MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequestValidator.cs:7 · Level 9 · class (sealed)

        +
        +
          +
        • What it is: the FluentValidation validator for SpeakerCreateRequest. It declares no rules of its own; it composes two shared rule sets.
        • +
        • Depends on: FluentValidation's AbstractValidator<T> (SpeakerCreateRequestValidator.cs:1, SpeakerCreateRequestValidator.cs:7); SpeakerFirstNameRules<T> and SpeakerLastNameRules<T> (SpeakerCreateRequestValidator.cs:2).
        • +
        • Concept reinforced, rule composition with Include: the same mechanism taught on QuestionCreateRequestValidator, with one extra layer. Both speaker rule sets extend the framework's RequiredStringRules<T> with a display label and a ceiling taken from the domain: "First Name" with SpeakerInvariants.FirstNameMaxLength and "Last Name" with LastNameMaxLength, both 200 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:14-15 and SpeakerValidationRules.cs:25-26; SpeakerInvariants at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/SpeakerInvariants.cs:13 and SpeakerInvariants.cs:16), which is the same constant the domain invariant enforces (SpeakerInvariants.cs:45, SpeakerInvariants.cs:50). One constant, two enforcement points, no drift. [Rubric §16, Maintainability], [Rubric §24, Forms, Validation and UX Safety].
        • +
        • Walkthrough: a sealed class whose whole body is a two-line constructor (SpeakerCreateRequestValidator.cs:9-13) calling Include(new SpeakerFirstNameRules<SpeakerCreateRequest>(p => p.FirstName)) and the last-name equivalent.
        • +
        • Why it's built this way: extracting field rules into shared generic classes is the module-wide convention; add a constraint once and every request type that includes the rule set picks it up (here, both the create and the update validators).
        • +
        • Where it's used: discovered by AddValidatorsFromAssemblyContaining inside the module scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:190) and run by ValidatingCommandDecorator<TCommand, TResult> ahead of CreateSpeakerHandler. Covered by SpeakerCreateRequestValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/Validation/SpeakerCreateRequestValidatorTests.cs).
        • +
        • Caveats / not-in-source: only the two names are validated here. Email is left entirely to the Email value object inside Speaker.Create (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:130-137), so a malformed address arrives as an invariant failure rather than a field-level validation error, and the profile-link members are length-checked nowhere in this layer.
        • +
        +

        UnlinkUserFromSpeakerHandler

        +
        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.UnlinkUser · MMCA.ADC.Conference.Application/Speakers/UseCases/UnlinkUser/UnlinkUserFromSpeakerHandler.cs:19 · Level 9 · class (sealed partial)

        +
        +
          +
        • What it is: the handler for UnlinkUserFromSpeakerCommand. It clears the Conference side of the User-Speaker link and raises the event that clears the Identity side.
        • +
        • Depends on: ICommandHandler<in TCommand, TResult> (UnlinkUserFromSpeakerHandler.cs:21); IUnitOfWork; the Speaker aggregate; SpeakerUnlinkedFromUser; Result and Error; logging. As with LinkUserToSpeakerHandler, no publisher service is injected, because the event is raised on the aggregate.
        • +
        • Concept reinforced, raise the integration event before the save, not after: the class comment is unusually explicit about the bug this ordering removes (UnlinkUserFromSpeakerHandler.cs:13-17). Raising SpeakerUnlinkedFromUser on the aggregate first (UnlinkUserFromSpeakerHandler.cs:42) and saving after (UnlinkUserFromSpeakerHandler.cs:45) puts the outbox row and the unlink in one transaction, so a crash can no longer commit the Conference-side unlink while losing the event that clears User.LinkedSpeakerId on the Identity side. A post-save publish, which is what this code used to do, had exactly that hole. The OutboxProcessor then routes the row to the registered IMessageBus transport (ADR-003). [Rubric §6, CQRS and Event-Driven], [Rubric §29, Resilience].
        • +
        • Walkthrough:
            +
          • Loads the speaker with the include-free overload (UnlinkUserFromSpeakerHandler.cs:29) and returns a stamped Error.NotFound when it is missing (UnlinkUserFromSpeakerHandler.cs:30-31). No includes are needed: the link is a scalar column on the aggregate root.
          • +
          • Captures previousUserId BEFORE calling speaker.UnlinkUser() (UnlinkUserFromSpeakerHandler.cs:33-34). This is the load-bearing line: the domain method clears LinkedUserId (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:301), so reading it afterwards would yield null and the event could not name the user that was unlinked. The aggregate itself rejects an unlinked speaker with Speaker.NotLinked (Speaker.cs:290-299).
          • +
          • On success, and only when a previous user actually existed, it raises the event (UnlinkUserFromSpeakerHandler.cs:40-43), saves (UnlinkUserFromSpeakerHandler.cs:45), and logs through the generated LogUserUnlinkedFromSpeaker (UnlinkUserFromSpeakerHandler.cs:47, declared at UnlinkUserFromSpeakerHandler.cs:53-54). The domain Result is returned unchanged (UnlinkUserFromSpeakerHandler.cs:50).
          • +
          +
        • +
        • Why it's built this way: Conference and Identity own separate databases (ADR-006), so there is no foreign key to cascade and the back-link has to travel as an event; the ITransactional marker on the command plus the pre-save raise are together what make that event as durable as the write it describes.
        • +
        • Where it's used: registered by the module's application scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125); invoked by DELETE /Speakers/{id}/link on SpeakersController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:383-391). The emitted event is consumed on the Identity side to clear User.LinkedSpeakerId. Covered by UnlinkUserFromSpeakerHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/UnlinkUserFromSpeakerHandlerTests.cs) and over a real broker by SpeakerLinkBrokerFlowTests (MMCA.ADC/Tests/Integration/MMCA.ADC.CrossService.IntegrationTests/CrossService/SpeakerLinkBrokerFlowTests.cs).
        • +
        • Caveats / not-in-source: the previousUserId.HasValue test (UnlinkUserFromSpeakerHandler.cs:40) can never be false on the success path, because UnlinkUser() fails when nothing is linked (Speaker.cs:292-299). It is defensive, not a live branch.
        • +
        +

        ActivityCreateRequestMapper

        +
        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.UseCases.Create · MMCA.ADC.Conference.Application/Activities/UseCases/Create/ActivityCreateRequestMapper.cs:11 · Level 10 · class (sealed)

        +
        +
          +
        • What it is: the adapter that turns a validated ActivityCreateRequest into an Activity entity by calling the aggregate's static factory. Structurally identical to SpeakerCreateRequestMapper.
        • +
        • Depends on: IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> closed over Activity, ActivityCreateRequest, and ActivityIdentifierType (ActivityCreateRequestMapper.cs:11-12); Activity; Result.
        • +
        • Concept reinforced, the factory decides, the mapper only forwards: CreateEntityAsync never constructs an entity itself. Activity.Create (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:99-130) combines five invariants in one Result.Combine (name, time range, venue name, venue address, venue URL: Activity.cs:111-116), returns their aggregated errors on failure (Activity.cs:117-118), assigns the id (Activity.cs:124), and raises ActivityChanged with DomainEntityState.Added (Activity.cs:127). [Rubric §4, Domain-Driven Design]: an Activity that exists is an Activity that passed its invariants.
        • +
        • Walkthrough: CreateEntityAsync (ActivityCreateRequestMapper.cs:15) null-guards the request (ActivityCreateRequestMapper.cs:17) and returns Task.FromResult(Activity.Create(...)) with all ten arguments taken straight from the request (ActivityCreateRequestMapper.cs:19-29). Unlike the speaker mapper, nothing is dropped here: the request and the factory have the same shape.
        • +
        • Why it's built this way: one generic contract (IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>) lets the same create handler shape serve every aggregate, while each aggregate keeps its own construction rules (ADR-001). [Rubric §1, SOLID]: the mapper's single responsibility is translation.
        • +
        • Where it's used: injected into CreateActivityHandler by its interface (CreateActivityHandler.cs:18) and invoked at CreateActivityHandler.cs:27; registered by the framework's request-mapper scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:172-176).
        • +
        • Caveats / not-in-source: the request's Id is forwarded (ActivityCreateRequestMapper.cs:20) but discarded downstream, because Activity is [IdValueGenerated] and the factory assigns default in that case (Activity.cs:120-125). If that attribute were ever removed, the same line would evaluate id!.Value, which is the exact null-Nullable crash Speaker had to fix (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:156-161). Nothing here guards against that.
        • +
        +

        ActivityCreateRequestValidator

        +
        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.UseCases.Create · MMCA.ADC.Conference.Application/Activities/UseCases/Create/ActivityCreateRequestValidator.cs:7 · Level 10 · class (sealed)

        +
        +
          +
        • What it is: the FluentValidation validator for ActivityCreateRequest. Like SpeakerCreateRequestValidator it writes no rules of its own, but it composes eight rule sets instead of two, which makes it the clearest example in the module of how far the Include convention scales.
        • +
        • Depends on: FluentValidation's AbstractValidator<T> (ActivityCreateRequestValidator.cs:1, ActivityCreateRequestValidator.cs:7); the eight reusable rule classes in MMCA.ADC.Conference.Application.Activities.Validation (ActivityCreateRequestValidator.cs:2).
        • +
        • Concept reinforced, two kinds of reusable rule set: five of the eight extend a framework base (RequiredStringRules<T> for the name, OptionalStringRules<T> for description, venue name, venue address, and venue URL), each supplying a display label and a max length that comes from ActivityInvariants (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:13-67; constants at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/ActivityInvariants.cs:13-25). The other three are plain AbstractValidator<T> subclasses that encode rules the framework has no base for: ActivityEventIdRules<T> requires a non-empty event (ActivityValidationRules.cs:74-80), ActivitySortOrderRules<T> requires a non-negative order (ActivityValidationRules.cs:111-117), and ActivityTimeRangeRules<T> is the multi-field one, compiling the start-time selector so the end-time rule can compare against it: .Must((instance, endTime) => endTime >= startTimeFunc(instance)) (ActivityValidationRules.cs:100-103). Every rule carries an explicit WithErrorCode, so a client can branch on Activity.EndTime.BeforeStart instead of matching English prose. [Rubric §24, Forms, Validation and UX Safety], [Rubric §9, API and Contract Design].
        • +
        • Walkthrough: the whole class is an eight-line constructor (ActivityCreateRequestValidator.cs:9-19), one Include per rule set, each handed a property selector: name (ActivityCreateRequestValidator.cs:11), event id (ActivityCreateRequestValidator.cs:12), the start/end pair (ActivityCreateRequestValidator.cs:13), sort order (ActivityCreateRequestValidator.cs:14), description (ActivityCreateRequestValidator.cs:15), venue name (ActivityCreateRequestValidator.cs:16), venue address (ActivityCreateRequestValidator.cs:17), venue URL (ActivityCreateRequestValidator.cs:18).
        • +
        • Why it's built this way: composition beats inheritance here because the update request needs the same field rules with a different id shape; both validators include the same rule classes rather than sharing a base class. [Rubric §16, Maintainability].
        • +
        • Where it's used: discovered by AddValidatorsFromAssemblyContaining (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:190) and executed by ValidatingCommandDecorator<TCommand, TResult> before CreateActivityHandler ever runs (ADR-014). Covered by ActivityCreateRequestValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Activities/Validation/ActivityCreateRequestValidatorTests.cs).
        • +
        • Caveats / not-in-source: the validator and the factory do not check the same set. ActivityDescriptionRules<T>, ActivitySortOrderRules<T>, and ActivityEventIdRules<T> have no counterpart in Activity.Create, which combines only name, time range, and the three venue fields (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:111-116). Anything that builds an Activity without going through this validator (a seeder, a future importer) can therefore produce a negative SortOrder, an over-long description, or an activity with an empty EventId. Whether that gap is intentional is Not determinable from source.
        • +
        +

        CreateActivityHandler

        +
        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.UseCases.Create · MMCA.ADC.Conference.Application/Activities/UseCases/Create/CreateActivityHandler.cs:16 · Level 10 · class (sealed partial)

        +
        +
          +
        • What it is: the handler that creates an activity: map, add, save, project. It is the same four-statement shape as CreateSpeakerHandler, which is the point: once the decorators own validation, caching, and transactions, a create slice has almost nothing left to write.
        • +
        • Depends on: ICommandHandler<in TCommand, TResult> closed over ActivityCreateRequest and Result<ActivityDTO> (CreateActivityHandler.cs:20); IUnitOfWork; IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, satisfied by ActivityCreateRequestMapper; ActivityDTOMapper; ActivityDTO; Result; Microsoft.Extensions.Logging.
        • +
        • Concept reinforced, the generic create slice end to end: [Rubric §5, Vertical Slice]: request, mapper, validator, and handler live in one folder and the request IS the command. [Rubric §3, Clean Architecture]: the handler orchestrates a request mapper, a repository, and a DTO mapper without embedding construction logic of its own; the domain types it touches come from the Domain project and nothing from ASP.NET Core appears here.
        • +
        • Walkthrough:
            +
          • Primary constructor (CreateActivityHandler.cs:16-20): unit of work, the request mapper resolved by its generic interface, the DTO mapper as a concrete type, and ILogger<CreateActivityHandler>.
          • +
          • HandleAsync (CreateActivityHandler.cs:23-40) calls requestMapper.CreateEntityAsync(command, cancellationToken) first (CreateActivityHandler.cs:27) and returns the mapper's errors verbatim on failure (CreateActivityHandler.cs:28-29), so a domain invariant violation surfaces as a Result failure rather than an exception.
          • +
          • It takes result.Value! (CreateActivityHandler.cs:31), resolves IRepository<Activity, ActivityIdentifierType> from the unit of work rather than injecting it (CreateActivityHandler.cs:32), then awaits repository.AddAsync(...) and unitOfWork.SaveChangesAsync(...) with ConfigureAwait(false) (CreateActivityHandler.cs:34-35, ADR-049). Resolving the repository through IUnitOfWork rather than constructor-injecting IRepository<TEntity, TIdentifierType> is the framework rule that keeps one tracked context per scope.
          • +
          • It logs the new id and name through the generated LogActivityCreated (CreateActivityHandler.cs:37, declared at CreateActivityHandler.cs:42-43) and returns Result.Success(dtoMapper.MapToDTO(entity)) (CreateActivityHandler.cs:39). Reading entity.Id after the save is what makes the database-generated key observable in the response.
          • +
          +
        • +
        • Why it's built this way: the pipeline supplies everything this handler does not: ValidatingCommandDecorator<TCommand, TResult> runs ActivityCreateRequestValidator first, and CachingCommandDecorator<TCommand, TResult> acts on the CachePrefix the request declares (ADR-014). [Rubric §10, Cross-Cutting].
        • +
        • Where it's used: registered by the command-handler scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:178-182, driven from MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125); injected into ActivitiesController as ICommandHandler<ActivityCreateRequest, Result<ActivityDTO>> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/ActivitiesController.cs:39) and dispatched by its POST /Activities override, which delegates to the base controller and then evicts the activities output cache (ActivitiesController.cs:211-220). The action is gated on the ActivitiesManage permission (ActivitiesController.cs:212), held by Organizer and ContentEditor, while the read endpoints stay anonymous per BR-43 (ActivitiesController.cs:27-32). Covered by CreateActivityHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Activities/UseCases/CreateActivityHandlerTests.cs).
        • +
        +

        CreateSpeakerHandler

        +
        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.Create · MMCA.ADC.Conference.Application/Speakers/UseCases/Create/CreateSpeakerHandler.cs:16 · Level 10 · class (sealed partial)

        +
        +
          +
        • What it is: the handler that creates a speaker. It is deliberately one of the thinnest handlers in this chapter: map, add, save, project. Compare it with CreateQuestionHandler, which needs a reserved id range and a collision retry; a speaker id is a client-assigned GUID, so none of that machinery is required here.
        • +
        • Depends on: ICommandHandler<in TCommand, TResult> closed over SpeakerCreateRequest and Result<SpeakerDTO> (CreateSpeakerHandler.cs:20); IUnitOfWork; IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, satisfied by SpeakerCreateRequestMapper; SpeakerDTOMapper; SpeakerDTO; Result; Microsoft.Extensions.Logging.
        • +
        • Concept reinforced, the create response obeys the same rules as a read: [Rubric §5, Vertical Slice]: the request IS the command, so the class implements ICommandHandler<SpeakerCreateRequest, Result<SpeakerDTO>> (CreateSpeakerHandler.cs:20) and the four types of the slice sit in one folder. [Rubric §11, Security] reaches it through the projection: SpeakerDTOMapper blanks the speaker's email for non-organizers (BR-66, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/DTOs/SpeakerDTOMapper.cs:35-36), so even the create response is redacted by the same rule as every read, not only the list endpoints.
        • +
        • Walkthrough:
            +
          • Primary constructor (CreateSpeakerHandler.cs:16-20): unit of work, the request mapper resolved by its generic interface, the DTO mapper as a concrete type, and ILogger<CreateSpeakerHandler>.
          • +
          • HandleAsync (CreateSpeakerHandler.cs:23-40) calls requestMapper.CreateEntityAsync(command, cancellationToken) first (CreateSpeakerHandler.cs:27) and returns the mapper's errors verbatim on failure (CreateSpeakerHandler.cs:28-29), so a domain invariant violation surfaces as a Result failure rather than an exception.
          • +
          • It takes result.Value! (CreateSpeakerHandler.cs:31), resolves IRepository<Speaker, SpeakerIdentifierType> from the unit of work rather than injecting it (CreateSpeakerHandler.cs:32), then awaits repository.AddAsync(...) and unitOfWork.SaveChangesAsync(...) with ConfigureAwait(false) (CreateSpeakerHandler.cs:34-35, ADR-049).
          • +
          • It logs through the generated LogSpeakerCreated, which records the id and the computed full name (CreateSpeakerHandler.cs:37, declared at CreateSpeakerHandler.cs:42-43), and returns Result.Success(dtoMapper.MapToDTO(entity)) (CreateSpeakerHandler.cs:39).
          • +
          +
        • +
        • Why it's built this way: validation, cache invalidation, and transaction scope are all handled by the pipeline decorators wrapped around this handler (declared by the markers on SpeakerCreateRequest), which is exactly why the create logic can reduce to four statements (ADR-014). [Rubric §1, SOLID]: the handler's one reason to change is the use case.
        • +
        • Where it's used: registered by the command-handler scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:178-182, driven from MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125); injected into SpeakersController as ICommandHandler<SpeakerCreateRequest, Result<SpeakerDTO>> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:46) and dispatched by its POST /Speakers override, which delegates to the base controller and then evicts the speakers output cache (SpeakersController.cs:308-317). The action is gated on the SpeakersManage permission (SpeakersController.cs:309). Covered by CreateSpeakerHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/CreateSpeakerHandlerTests.cs).
        • +
        • Caveats / not-in-source: nothing here guards against a caller re-submitting an id that already exists; the insert simply fails on the primary key and surfaces through the shared exception handling. The Sessionize import path relies on that, since it supplies the Sessionize GUID as Id (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SpeakerSyncStrategy.cs:126), but no comment in this file records the expectation.
        • +
        +

        AddSessionCategoryItemCommand

        +
        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionCategoryItem · MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemCommand.cs:10 · Level 9 · record

        +
        +
          +
        • What it is: the command that tags a session with a category item (the mechanism behind session topics, levels, and localities). Three positional parameters: the owning SessionId, an optional SessionCategoryItemId for the join entity, and the CategoryItemId being associated (MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemCommand.cs:10-13).
        • +
        • Depends on: ICacheInvalidating (AddSessionCategoryItemCommand.cs:13); the Session type, used only for its FullName when building the cache prefix; and the SessionIdentifierType, SessionCategoryItemIdentifierType, and CategoryItemIdentifierType module aliases, all three int (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:6, :14, :15).
        • +
        • Concept introduced, the nullable child id on an Add command: the second parameter is SessionCategoryItemIdentifierType?, documented in the file as "Explicit ID for the join entity, or null for database-generated identity" (AddSessionCategoryItemCommand.cs:8). The REST path always passes null and lets the database assign the key (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:224); the parameter exists because that is the exact signature the aggregate factory takes (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:414-416), and a caller that already knows the id (the Sessionize import is the obvious one) can supply it. [Rubric §9, API and Contract Design] assesses whether a contract states exactly what a caller may decide: a nullable id rather than a defaulted one keeps "let the database choose" distinct from "I chose zero".
        • +
        • Walkthrough: the record body is one member, CachePrefix => $"{typeof(Session).FullName}:" (AddSessionCategoryItemCommand.cs:15-16). That is the same session-wide prefix AddSessionQuestionAnswerCommand and RemoveSessionCategoryItemCommand declare, so one eviction after a successful command covers every cached session projection rather than requiring per-query bookkeeping (ADR-026). The command itself never touches a cache: it only declares what it invalidates, and the caching decorator does the work (ADR-014). [Rubric §10, Cross-Cutting].
        • +
        • Where it's used: constructed by the POST /SessionCategoryItems action of SessionCategoryItemsController from an AddSessionCategoryItemRequest body (SessionCategoryItemsController.cs:219-224), on a controller gated by the SessionsManage permission (SessionCategoryItemsController.cs:47, ADR-020); validated by AddSessionCategoryItemCommandValidator; handled by AddSessionCategoryItemHandler.
        • +
        +

        AddSessionQuestionAnswerCommand

        +
        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionQuestionAnswer · MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerCommand.cs:11 · Level 9 · record

        +
        +
          +
        • What it is: the message an attendee's session feedback travels on. Four positional fields: the owning SessionId, an optional SessionQuestionAnswerId for the answer row, the QuestionId being answered, and the AnswerValue text (MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerCommand.cs:11-15).
        • +
        • Depends on: ICacheInvalidating (AddSessionQuestionAnswerCommand.cs:15), the Session type for the prefix, and the SessionIdentifierType / SessionQuestionAnswerIdentifierType / QuestionIdentifierType aliases (ADR-048).
        • +
        • Concept reinforced: none new. The nullable child id works exactly as on AddSessionCategoryItemCommand; the file states the same contract at AddSessionQuestionAnswerCommand.cs:8, the REST path passes null (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:190), and the aggregate method takes the same shape (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:512).
        • +
        • Walkthrough: one member, CachePrefix => $"{typeof(Session).FullName}:" (AddSessionQuestionAnswerCommand.cs:17-18). The interesting part of this record is what it does not carry: no author id. Ownership is resolved server side from ICurrentUserService inside AddSessionQuestionAnswerHandler (AddSessionQuestionAnswerHandler.cs:52), so a client cannot submit feedback as someone else. [Rubric §11, Security]: identity is never a request field.
        • +
        • Where it's used: validated by AddSessionQuestionAnswerCommandValidator, handled by AddSessionQuestionAnswerHandler, and built from an AddSessionQuestionAnswerRequest by the POST /SessionQuestionAnswers action of SessionQuestionAnswersController (SessionQuestionAnswersController.cs:185-190), on a controller whose whole surface requires an authenticated caller (SessionQuestionAnswersController.cs:56).
        • +
        +

        PublicSessionStatusSpecification

        +
        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.Specifications · MMCA.ADC.Conference.Application/Sessions/Specifications/PublicSessionStatusSpecification.cs:20 · Level 9 · class (sealed)

        +
        +
          +
        • What it is: the single definition of which session statuses an anonymous or non-privileged caller may see (BR-49): Accepted, or no status at all, since organizer-created sessions never carry one. It is an eight-line class that every public session read path in the module goes through, ANDed with the published-event scoping of BR-108 (MMCA.ADC.Conference.Application/Sessions/Specifications/PublicSessionStatusSpecification.cs:7-11).
        • +
        • Depends on: Specification<TEntity, TIdentifierType> closed over Session and SessionIdentifierType (PublicSessionStatusSpecification.cs:20); SessionStatuses for the Accepted constant; and System.Linq.Expressions (PublicSessionStatusSpecification.cs:1).
        • +
        • Concept introduced, one predicate exposed in two forms: the allow-list is a public static readonly Expression<Func<Session, bool>> StatusCriteria (PublicSessionStatusSpecification.cs:23-24), and the instance Criteria override simply returns it (PublicSessionStatusSpecification.cs:27). That duality is the whole design. Call sites that need to compose the predicate into a larger expression tree take the static field, and call sites that want specification algebra (AND, OR, paging, sorting) instantiate the class; both share one definition so the rule cannot drift between them (the file says so at :13-15). [Rubric §11, Security] assesses whether a visibility rule is centralized: a status that becomes public here becomes public everywhere at once, which is what you want and also exactly why this file deserves care.
        • +
        • Concept introduced, writing predicates a database can actually run: the remarks record a trap the code deliberately avoids (PublicSessionStatusSpecification.cs:15-18). The domain already has SessionStatuses.IsEligible(status), but calling it here would put compiled C# inside an expression tree, and EF Core cannot translate a method body to SQL; the predicate would either throw or silently evaluate client-side after loading every row. So the expression compares against the Accepted constant directly. The comment also notes that SQL Server's case-insensitive default collation gives the same case behavior the in-memory predicate has. [Rubric §12, Performance and Scalability]: a translatable predicate filters in the database instead of in the process. [Rubric §8, Data Architecture]: the query stays engine-agnostic enough to survive the polyglot posture of ADR-018.
        • +
        • Walkthrough: two members and no constructor. StatusCriteria (PublicSessionStatusSpecification.cs:23-24) is s => s.Status == null || s.Status == SessionStatuses.Accepted; the null branch is not an oversight but the organizer-created case. Criteria (PublicSessionStatusSpecification.cs:27) is the override the specification pipeline consumes (ADR-055).
        • +
        • Why it's built this way: BR-49 appears in at least three query shapes; stating it once as an expression is the only way those shapes cannot disagree. Keeping it in the Application layer rather than the Domain follows from it being a read filter, not an entity invariant. The invariant form lives beside it in the domain as SessionInvariants.EnsureStatusIsEligible (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:107), which is the form SessionBookmarkValidationService and AddSessionQuestionAnswerHandler call on an already-loaded row.
        • +
        • Where it's used: PublicConferenceVisibility uses both forms, the static expression as the localPredicate of a cross-source build (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:68) and an instance in specification algebra (PublicConferenceVisibility.cs:148); GetPublicSessionFilterHandler uses the static expression for the public session list (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/GetPublicSessionFilter/GetPublicSessionFilterHandler.cs:34, with the sharing intent stated at :17).
        • +
        • Caveats / not-in-source: the collation argument in the remarks is a statement about the deployed SQL Server, not something the type can enforce. On a case-sensitive collation or a different engine, the expression and SessionStatuses.IsEligible could disagree, and nothing in the code would catch it.

        SessionBookmarkValidationService

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions · MMCA.ADC.Conference.Application/Sessions/SessionBookmarkValidationService.cs:12 · Level 9 · class (sealed)

          -
        • What it is: Conference's implementation of a contract the Engagement module declares. Engagement owns bookmarks but not sessions, so before it stores a bookmark it asks Conference two questions through this type: may this session be bookmarked, and which sessions belong to this event.
        • -
        • Depends on: ISessionBookmarkValidationService (the cross-module interface it implements, SessionBookmarkValidationService.cs:12); IUnitOfWork; the Session aggregate and SessionInvariants; Result and Error.
        • -
        • Concept introduced, the cross-module provider behind an interface the consumer owns: the interface lives in MMCA.ADC.Conference.Shared, the implementation in Conference's Application layer, and Engagement's handlers depend only on the interface (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/UseCases/Create/CreateBookmarkHandler.cs:20). That indirection is what makes the module extractable: in the split topology Conference is disabled inside the Engagement process, and the Contracts project swaps the registration for a gRPC adapter with one line, services.Replace(ServiceDescriptor.Scoped<ISessionBookmarkValidationService, SessionBookmarkValidationServiceGrpcAdapter>()) (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/DependencyInjection.cs:49). Not one line of Engagement's application code changes. [Rubric §7, Microservices Readiness] assesses exactly this: whether a cross-module call is a transport decision made at the edge (ADR-007); [Rubric §3, Clean Architecture]: the dependency points at an abstraction, never at another module's domain.
        • +
        • What it is: Conference's implementation of a contract the Engagement module consumes. Engagement owns bookmarks but not sessions, so before it stores a bookmark it asks Conference two questions through this type: may this session be bookmarked (BR-49 and BR-91), and which sessions belong to this event (MMCA.ADC.Conference.Application/Sessions/SessionBookmarkValidationService.cs:8-11).
        • +
        • Depends on: ISessionBookmarkValidationService, the cross-module interface it implements (SessionBookmarkValidationService.cs:12); IUnitOfWork, taken by primary constructor; the Session aggregate and its SessionInvariants helpers; Result and Error.
        • +
        • Concept introduced, the cross-module provider behind an interface the consumer depends on: the interface lives in MMCA.ADC.Conference.Shared, the implementation here in Conference's Application layer, and Engagement's handlers depend only on the interface (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/UseCases/Create/CreateBookmarkHandler.cs:20). That indirection is what makes the module extractable: in the split topology Conference is disabled inside the Engagement process, and the Contracts project swaps the registration for a gRPC adapter with one line, services.Replace(ServiceDescriptor.Scoped<ISessionBookmarkValidationService, SessionBookmarkValidationServiceGrpcAdapter>()) (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/DependencyInjection.cs:49). Not one line of Engagement's application code changes. [Rubric §7, Microservices Readiness] assesses exactly this: whether a cross-module call is a transport decision made at the edge (ADR-007). [Rubric §3, Clean Architecture]: the dependency points at an abstraction, never at another module's domain.
        • Walkthrough: two methods, and the first is where the business rules live.
            -
          • ValidateSessionForBookmarkAsync (SessionBookmarkValidationService.cs:15-39) loads the session untracked with no includes (:20-24), returns Error.NotFound stamped with this service as source when it is missing (:26-30), then runs two domain invariants in order: SessionInvariants.EnsureNotServiceSession for BR-91, since a break or a lunch slot is not something an attendee bookmarks (:33, invariant at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:91), and SessionInvariants.EnsureStatusIsEligible for BR-49 (:38, invariant at SessionInvariants.cs:107). Both are static domain functions, so the rule stays in the Domain layer and this class only decides when to ask. Note the contrast with PublicSessionStatusSpecification: that one is an EF-translatable expression for filtering a query, this one is compiled code checking an already-loaded row, and the two are deliberately different expressions of the same BR-49.
          • -
          • GetSessionIdsByEventAsync (SessionBookmarkValidationService.cs:42-54) returns the id list for one event, read untracked and projected into a Result<IReadOnlyCollection<SessionIdentifierType>> (:47-53). Engagement uses it to scope a user's bookmark list to a single event without holding any session data of its own. Returning ids rather than session rows is the point: it crosses the module boundary with the smallest possible payload and no schema coupling, which is also what keeps the gRPC contract trivial (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/Protos/session_bookmark_validation.proto:27). [Rubric §8, Data Architecture].
          • +
          • ValidateSessionForBookmarkAsync (SessionBookmarkValidationService.cs:15-39) loads the session untracked with no includes (:19-24), returns Error.NotFound stamped with this service as source and Session as target when it is missing (:26-30), then runs two domain invariants in order: SessionInvariants.EnsureNotServiceSession for BR-91, since a break or a lunch slot is not something an attendee bookmarks (:33, invariant at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:91), and SessionInvariants.EnsureStatusIsEligible for BR-49 (:38, invariant at SessionInvariants.cs:107). Both are static domain functions, so the rule stays in the Domain layer and this class only decides when to ask. Note the contrast with PublicSessionStatusSpecification: that one is an EF-translatable expression for filtering a query, this one is compiled code checking an already-loaded row, and the two are deliberately different expressions of the same BR-49.
          • +
          • GetSessionIdsByEventAsync (SessionBookmarkValidationService.cs:42-54) returns the id list for one event, read untracked with a where: s => s.EventId == eventId predicate and projected into a Result<IReadOnlyCollection<SessionIdentifierType>> (:46-53). Engagement uses it to scope a user's bookmark list to a single event without holding any session data of its own. Returning ids rather than session rows is the point: it crosses the module boundary with the smallest possible payload and no schema coupling, which is also what keeps the gRPC contract trivial (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/Protos/session_bookmark_validation.proto:28). [Rubric §8, Data Architecture].
        • Why it's built this way: bookmarks and sessions live in different databases (ADR-006), so Engagement cannot join to a session table to check eligibility; the only correct move is to ask the owner. Keeping the answer in a narrow interface means the question survives the process split unchanged.
        • -
        • Where it's used: registered explicitly (not by the convention scan) as the in-process implementation at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:105; consumed by Engagement's CreateBookmarkHandler (CreateBookmarkHandler.cs:31) and GetUserBookmarksHandler (GetUserBookmarksHandler.cs:43); exposed over the wire by SessionBookmarksGrpcService (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Grpc/SessionBookmarksGrpcService.cs:23), which wraps this instance as its inner. Covered by SessionBookmarkValidationServiceTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/SessionBookmarkValidationServiceTests.cs).
        • -
        • Caveats / not-in-source: GetSessionIdsByEventAsync loads whole session entities and projects the ids in memory (SessionBookmarkValidationService.cs:47-53) rather than projecting in the query, so the cost scales with session size, not id count. It also applies no visibility filter: the ids of every non-deleted session in the event are returned, eligible or not, which is safe only because the caller uses them to narrow a bookmark list the user already owns.
        • +
        • Where it's used: registered explicitly, not by the convention scan, as the in-process implementation at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:118; consumed by Engagement's CreateBookmarkHandler (CreateBookmarkHandler.cs:31) and GetUserBookmarksHandler; exposed over the wire by SessionBookmarksGrpcService, which wraps this instance as its inner (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Grpc/SessionBookmarksGrpcService.cs:23). Covered by SessionBookmarkValidationServiceTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/SessionBookmarkValidationServiceTests.cs:12).
        • +
        • Caveats / not-in-source: GetSessionIdsByEventAsync loads whole session entities and projects the ids in memory (SessionBookmarkValidationService.cs:47-53) rather than projecting in the query, so the cost scales with session row size, not id count. It also applies no visibility filter: the ids of every non-deleted session in the event are returned, eligible or not, which is safe only because the caller uses them to narrow a bookmark list the user already owns.

        SessionCategoryItemDTOMapper

        @@ -3509,20 +3907,21 @@

        SessionCategoryItemDTOMapper

        • What it is: the entity-to-DTO mapper for the SessionCategoryItem join entity, source-generated by Mapperly.
        • -
        • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over SessionCategoryItem / SessionCategoryItemDTO / SessionCategoryItemIdentifierType (SessionCategoryItemDTOMapper.cs:13), and Riok.Mapperly.Abstractions for the [Mapper] attribute (SessionCategoryItemDTOMapper.cs:11).
        • -
        • Concept reinforced, compile-time mapping: the mechanism is taught on SpeakerCategoryItemDTOMapper; this is the session-side twin, byte-for-byte the same shape over different types. [Mapper] on a partial class makes the generator emit the body of MapToDTO as plain property assignments, so an unmatched property is a build diagnostic rather than a silent null (ADR-001). [Rubric §12, Performance and Scalability] and [Rubric §15, Best Practices and Code Quality].
        • -
        • Walkthrough: two members. MapToDTO (SessionCategoryItemDTOMapper.cs:16) is partial with no body. MapToDTOs (SessionCategoryItemDTOMapper.cs:19-23) is hand-written: a null guard, then a collection-expression spread over Select(MapToDTO). Declaring it on the class is what makes it reachable through the concrete type, which matters because the add handler injects the concrete mapper, not the interface.
        • -
        • Where it's used: injected into AddSessionCategoryItemHandler (AddSessionCategoryItemHandler.cs:18); composed into SessionDTOMapper as a [UseMapper] field (SessionDTOMapper.cs:26-27); resolved by the generic EntityQueryService<TEntity, TEntityDTO, TIdentifierType> registered for this entity (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:96). Registered by the convention scan, self and interfaces, scoped (DependencyInjection.cs:112, rule at MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:132-136). Covered by SessionCategoryItemDTOMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/DTOs/SessionCategoryItemDTOMapperTests.cs).
        • +
        • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over SessionCategoryItem / SessionCategoryItemDTO / SessionCategoryItemIdentifierType (MMCA.ADC.Conference.Application/Sessions/DTOs/SessionCategoryItemDTOMapper.cs:13), and Riok.Mapperly.Abstractions for the [Mapper] attribute (SessionCategoryItemDTOMapper.cs:4, :11).
        • +
        • Concept reinforced, compile-time mapping: the mechanism is taught on SpeakerCategoryItemDTOMapper; this is the session-side twin, the same shape over different types. [Mapper] on a partial class makes the generator emit the body of MapToDTO as plain property assignments, so an unmatched property is a build diagnostic rather than a silent null, and there is no runtime reflection or expression compilation on the read path (ADR-001). [Rubric §12, Performance and Scalability] and [Rubric §15, Best Practices and Code Quality].
        • +
        • Walkthrough: two members. MapToDTO (SessionCategoryItemDTOMapper.cs:16) is partial with no body, which is the generator's hook. MapToDTOs (SessionCategoryItemDTOMapper.cs:19-23) is hand-written: ArgumentNullException.ThrowIfNull then a collection-expression spread over Select(MapToDTO). Declaring the plural on the class is what makes it reachable through the concrete type, which matters because AddSessionCategoryItemHandler injects the concrete mapper, not the interface.
        • +
        • Where it's used: injected into AddSessionCategoryItemHandler (AddSessionCategoryItemHandler.cs:18); composed into SessionDTOMapper as a [UseMapper] field (SessionDTOMapper.cs:26-27); resolved by the generic EntityQueryService<TEntity, TEntityDTO, TIdentifierType> registered for this entity (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:105). Registered by the convention scan, self and interfaces, scoped (DependencyInjection.cs:125). Covered by SessionCategoryItemDTOMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/DTOs/SessionCategoryItemDTOMapperTests.cs:7).

        SessionQuestionAnswerDTOMapper

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.DTOs · MMCA.ADC.Conference.Application/Sessions/DTOs/SessionQuestionAnswerDTOMapper.cs:12 · Level 9 · class (sealed partial)

          -
        • What it is: the Mapperly mapper for SessionQuestionAnswer, the entity that stores a speaker's answer to a session question.
        • -
        • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over SessionQuestionAnswer / SessionQuestionAnswerDTO / SessionQuestionAnswerIdentifierType (SessionQuestionAnswerDTOMapper.cs:13), and Riok.Mapperly.Abstractions (SessionQuestionAnswerDTOMapper.cs:11).
        • +
        • What it is: the Mapperly mapper for SessionQuestionAnswer, the entity that stores one attendee's answer to one session question.
        • +
        • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over SessionQuestionAnswer / SessionQuestionAnswerDTO / SessionQuestionAnswerIdentifierType (MMCA.ADC.Conference.Application/Sessions/DTOs/SessionQuestionAnswerDTOMapper.cs:13), and Riok.Mapperly.Abstractions (SessionQuestionAnswerDTOMapper.cs:11).
        • Concept reinforced: none new. Identical in structure to SessionCategoryItemDTOMapper: a partial MapToDTO the generator fills in (SessionQuestionAnswerDTOMapper.cs:16) and a hand-written null-guarded MapToDTOs (SessionQuestionAnswerDTOMapper.cs:19-23).
        • -
        • Where it's used: injected into AddSessionQuestionAnswerHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerHandler.cs:23); composed into SessionDTOMapper (SessionDTOMapper.cs:23-24); resolved by the query service registered for this entity (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:99). Covered by SessionQuestionAnswerDTOMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/DTOs/SessionQuestionAnswerDTOMapperTests.cs).
        • +
        • Where it's used: injected into AddSessionQuestionAnswerHandler (MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerHandler.cs:23), where both the create and the update branch map through it; composed into SessionDTOMapper (SessionDTOMapper.cs:23-24); resolved by the query service registered for this entity (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:108). Covered by SessionQuestionAnswerDTOMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/DTOs/SessionQuestionAnswerDTOMapperTests.cs:7).
        • +
        • Caveats / not-in-source: the DTO shape is decided entirely by the two type definitions and the generated file, which is not in the repository. Whether an answer's author is exposed to a caller is a property question on SessionQuestionAnswerDTO, not something this file controls.

        SessionSpeakerDTOMapper

        @@ -3530,536 +3929,751 @@

        SessionSpeakerDTOMapper

        • What it is: the Mapperly mapper for SessionSpeaker, the join entity that assigns a speaker to a session.
        • -
        • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over SessionSpeaker / SessionSpeakerDTO / SessionSpeakerIdentifierType (SessionSpeakerDTOMapper.cs:13), and Riok.Mapperly.Abstractions (SessionSpeakerDTOMapper.cs:11).
        • +
        • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over SessionSpeaker / SessionSpeakerDTO / SessionSpeakerIdentifierType (MMCA.ADC.Conference.Application/Sessions/DTOs/SessionSpeakerDTOMapper.cs:13), and Riok.Mapperly.Abstractions (SessionSpeakerDTOMapper.cs:11).
        • Concept reinforced: none new; see SessionCategoryItemDTOMapper. Same two members, same split between the generated MapToDTO (SessionSpeakerDTOMapper.cs:16) and the hand-written MapToDTOs (SessionSpeakerDTOMapper.cs:19-23).
        • -
        • Where it's used: injected into AddSessionSpeakerHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionSpeaker/AddSessionSpeakerHandler.cs:18); composed into SessionDTOMapper (SessionDTOMapper.cs:20-21); resolved by the query service registered for this entity (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:93). Covered by SessionSpeakerDTOMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/DTOs/SessionSpeakerDTOMapperTests.cs).
        • +
        • Where it's used: injected into the add-speaker handler for the session slice; composed into SessionDTOMapper (SessionDTOMapper.cs:20-21); resolved by the query service registered for this entity (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:102). Covered by SessionSpeakerDTOMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/DTOs/SessionSpeakerDTOMapperTests.cs:7).
        • Caveats / not-in-source: this mapper projects the join row only. Whether a parent SessionDTO arrives carrying its SessionSpeakers at all depends on whether the read path ran SessionNavigationPopulator for that collection; nothing in this file influences it.
        -

        UnlinkUserFromSpeakerHandler

        +

        AddSessionCategoryItemCommandValidator

        -

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.UnlinkUser · MMCA.ADC.Conference.Application/Speakers/UseCases/UnlinkUser/UnlinkUserFromSpeakerHandler.cs:19 · Level 9 · class (sealed partial)

        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionCategoryItem · MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemCommandValidator.cs:8 · Level 10 · class (sealed)

          -
        • What it is: the handler for UnlinkUserFromSpeakerCommand. It clears the Conference side of the User-Speaker link and raises the event that clears the Identity side.
        • -
        • Depends on: ICommandHandler<in TCommand, TResult> (UnlinkUserFromSpeakerHandler.cs:21); IUnitOfWork; the Speaker aggregate; SpeakerUnlinkedFromUser; Result and Error; logging. As with LinkUserToSpeakerHandler, no publisher service is injected, because the event is raised on the aggregate.
        • -
        • Concept reinforced, raise the integration event before the save, not after: the class comment is unusually explicit about the bug this ordering removes (UnlinkUserFromSpeakerHandler.cs:13-17). Raising SpeakerUnlinkedFromUser on the aggregate first (UnlinkUserFromSpeakerHandler.cs:42) and saving after (:45) puts the outbox row and the unlink in one transaction, so a crash can no longer commit the Conference-side unlink while losing the event that clears User.LinkedSpeakerId on the Identity side. A post-save publish, which is what this code used to do, had exactly that hole. The OutboxProcessor then routes the row to the registered IMessageBus transport (ADR-003). [Rubric §6, CQRS and Event-Driven], [Rubric §29, Resilience].
        • +
        • What it is: the FluentValidation validator for AddSessionCategoryItemCommand. It is a single rule: the CategoryItemId must not be the default (MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemCommandValidator.cs:10-13).
        • +
        • Depends on: FluentValidation's AbstractValidator<T> and nothing else (AddSessionCategoryItemCommandValidator.cs:1, :8).
        • +
        • Concept introduced, shape checks at the pipeline's front door: the validating decorator runs every registered validator for the command type before AddSessionCategoryItemHandler sees the message and before the transaction opens (ADR-014), so a malformed command costs no database work and the handler can assume a well-formed message. The division of labor is worth naming: "is the request well formed?" lives here, "is the operation allowed?" lives in the handler and the aggregate. That is why the duplicate-tag rule is NOT in this file: detecting it needs the loaded session, so it lives in Session.AddSessionCategoryItem as the Session.CategoryItem.Duplicate invariant (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:418-425). [Rubric §24, Forms, Validation and UX Safety] assesses whether bad input is rejected before it reaches business logic; [Rubric §15, Best Practices and Code Quality]: cheap guards stay declarative.
        • +
        • Walkthrough: an expression-bodied constructor holding one RuleFor(x => x.CategoryItemId).NotEqual(default(CategoryItemIdentifierType)).WithMessage("Category item ID is required.") (AddSessionCategoryItemCommandValidator.cs:10-13). Because CategoryItemIdentifierType is int (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:6), this rejects a zero id, the value an unset JSON field binds to. Writing it as default(CategoryItemIdentifierType) rather than 0 means the rule survives an alias change to a GUID without editing.
        • +
        • Why it's built this way: the convention scan auto-registers every AbstractValidator in the assembly, services.ScanModuleApplicationServices<ClassReference>() (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125), so dropping a validator file next to its command is the entire wiring step. [Rubric §5, Vertical Slice]: no registration line to forget in a distant file.
        • +
        • Where it's used: resolved as IValidator<AddSessionCategoryItemCommand> by the validating decorator on every dispatch of that command. Covered by AddSessionCategoryItemCommandValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionCommandValidatorTests.cs:60), one of the validator test classes sharing that file.
        • +
        • Caveats / not-in-source: SessionId is not validated here. A zero session id therefore reaches the handler and comes back as the aggregate's not-found error rather than a validation failure. Nothing in the file says whether that is deliberate.
        • +
        +

        AddSessionCategoryItemHandler

        +
        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionCategoryItem · MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemHandler.cs:16 · Level 10 · class (sealed partial)

        +
        +
          +
        • What it is: the handler for AddSessionCategoryItemCommand. It loads the session, asks the aggregate to create the association, saves, and returns the new join row as a DTO (MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemHandler.cs:11-15).
        • +
        • Depends on: ICommandHandler<in TCommand, TResult> closed over the command and Result<SessionCategoryItemDTO> (AddSessionCategoryItemHandler.cs:19); IUnitOfWork; SessionCategoryItemDTOMapper, injected as the concrete type (AddSessionCategoryItemHandler.cs:18); the Session aggregate and its SessionCategoryItem child; SessionCategoryItemDTO; Result and Error; Microsoft.Extensions.Logging.
        • +
        • Concept introduced, why the include list is a correctness argument and not an optimization: the code carries a comment that says it outright (AddSessionCategoryItemHandler.cs:28-29): the join collection has to be loaded, or the aggregate's duplicate check runs against an empty in-memory list and a double submit surfaces as a raw unique-index 409 instead of a worded business error. Session.AddSessionCategoryItem guards with _sessionCategoryItems.Exists(sci => !sci.IsDeleted && sci.CategoryItemId == categoryItemId) (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:418), a purely in-memory test: it can only be as correct as the collection the handler hydrated. [Rubric §4, Domain-Driven Design] assesses whether invariants are enforced by the aggregate; this is the flip side, the application layer's obligation to give the aggregate the state its invariants need. [Rubric §9, API and Contract Design]: the difference between the two outcomes is a business error with a code the client can act on versus an opaque database conflict.
        • Walkthrough:
            -
          • Loads the speaker with the include-free, tracked overload (UnlinkUserFromSpeakerHandler.cs:29) and returns a stamped Error.NotFound when it is missing (:30-31). No includes are needed: the link is a scalar column on the aggregate root.
          • -
          • Captures previousUserId BEFORE calling speaker.UnlinkUser() (UnlinkUserFromSpeakerHandler.cs:33-34). This is the load-bearing line: the domain method clears LinkedUserId, so reading it afterwards would yield null and the event could not name the user that was unlinked. The aggregate itself rejects an unlinked speaker with Speaker.NotLinked (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:278-284).
          • -
          • On success, and only when a previous user actually existed, it raises the event (UnlinkUserFromSpeakerHandler.cs:40-43), saves (:45), and logs through the generated LogUserUnlinkedFromSpeaker (:47, declared at :53-54). The domain Result is returned unchanged (:50).
          • +
          • A primary constructor taking unitOfWork, the concrete dtoMapper, and a typed ILogger (AddSessionCategoryItemHandler.cs:16-19).
          • +
          • HandleAsync (:22-43) resolves the write repository through IUnitOfWork rather than injecting one (:26) and loads with [nameof(Session.SessionCategoryItems)] and asTracking: true (:30), returning a stamped Error.NotFound when the session is missing (:31-32). Tracking is as load-bearing as the include: an untracked graph would make the later save a silent no-op.
          • +
          • Delegates to session.AddSessionCategoryItem(command.SessionCategoryItemId, command.CategoryItemId) (:34). The aggregate rejects a duplicate with Session.CategoryItem.Duplicate, creates the child through its own factory, and raises SessionCategoryItemChanged (Session.cs:418-437). A failure short-circuits with the domain errors carried through unchanged (:35-36).
          • +
          • Saves, logs through the source-generated LogCategoryItemAddedToSession (:38-40, declared at :45-46), then maps the newly created child, Result.Success(dtoMapper.MapToDTO(result.Value!)) (:42). The null-forgiving ! is safe only because the failure branch already returned. [Rubric §13, Observability and Operability]: one structured line, emitted once, after the save.
          • +
          • Note the return shape: this add handler returns a DTO, unlike the remove handlers in this chapter which return the bare Result. The controller needs the database-assigned join id to answer 201 Created with a location header (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:233-236).
        • -
        • Why it's built this way: Conference and Identity own separate databases (ADR-006), so there is no foreign key to cascade and the back-link has to travel as an event; the ITransactional marker on the command plus the pre-save raise are together what make that event as durable as the write it describes.
        • -
        • Where it's used: registered by the module's application scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112); invoked by DELETE /Speakers/{id}/link on SpeakersController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:383-391). The emitted event is consumed on the Identity side to clear User.LinkedSpeakerId. Covered by UnlinkUserFromSpeakerHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/UnlinkUserFromSpeakerHandlerTests.cs) and over a real broker by SpeakerLinkBrokerFlowTests (MMCA.ADC/Tests/Integration/MMCA.ADC.CrossService.IntegrationTests/CrossService/SpeakerLinkBrokerFlowTests.cs).
        • +
        • Why it's built this way: mapping after the save rather than before is what lets the DTO carry the identity the database generated, which is the whole reason the command's join id is nullable. Atomicity is the one SaveChangesAsync covering the join row and the domain event the aggregate raised, both in the same ADC_Conference database (ADR-006, ADR-003).
        • +
        • Where it's used: registered by the module's application scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125); injected into SessionCategoryItemsController as ICommandHandler<AddSessionCategoryItemCommand, Result<SessionCategoryItemDTO>> (SessionCategoryItemsController.cs:50) and dispatched by its POST action, which is marked [Idempotent] so a retried request with the same Idempotency-Key replays the first response instead of adding a second row (SessionCategoryItemsController.cs:212-218), and which evicts the junction output cache before returning (:232). Covered by AddSessionCategoryItemHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/AddSessionCategoryItemHandlerTests.cs:12).
        -

        AddSessionCategoryItemCommandValidator

        +

        AddSessionQuestionAnswerCommandValidator

        -

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionCategoryItem · MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemCommandValidator.cs:8 · Level 10 · class (sealed)

        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionQuestionAnswer · MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerCommandValidator.cs:8 · Level 10 · class (sealed)

          -
        • What it is: the FluentValidation validator for AddSessionCategoryItemCommand. It is a single rule: the CategoryItemId must not be the default (AddSessionCategoryItemCommandValidator.cs:10-13).
        • -
        • Depends on: FluentValidation's AbstractValidator<T> only (AddSessionCategoryItemCommandValidator.cs:1, :8).
        • -
        • Concept reinforced, shape checks at the pipeline's front door: the validating decorator runs this before AddSessionCategoryItemHandler sees the command and before the transaction opens (ADR-014), so the handler can assume a well-formed message. The division of labor is worth naming: "is the request well formed?" lives here, "is the operation allowed?" lives in the handler and the aggregate. That is why the duplicate-tag rule is NOT in this file: detecting it needs the loaded session, so it lives in Session.AddSessionCategoryItem as the Session.CategoryItem.Duplicate invariant (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:418-425). [Rubric §24, Forms, Validation and UX Safety] assesses whether validation runs before business logic; [Rubric §15, Best Practices and Code Quality]: cheap guards stay declarative.
        • -
        • Walkthrough: an expression-bodied constructor holding one RuleFor(x => x.CategoryItemId).NotEqual(default(CategoryItemIdentifierType)).WithMessage("Category item ID is required.") (AddSessionCategoryItemCommandValidator.cs:10-13). Because CategoryItemIdentifierType is int (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:5), this rejects a zero id, the value an unset JSON field binds to. Writing it as default(CategoryItemIdentifierType) rather than 0 means the rule survives an alias change to a GUID without editing.
        • -
        • Why it's built this way: the convention scan auto-registers every AbstractValidator in the assembly (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:156), so adding a validator class is the entire wiring step; no registration line to forget.
        • -
        • Where it's used: resolved as IValidator<AddSessionCategoryItemCommand> by the validating decorator on every dispatch of that command.
        • -
        • Caveats / not-in-source: SessionId is not validated here. A zero session id therefore reaches the handler and comes back as the aggregate's not-found error rather than a validation failure. Nothing in the file says whether that is deliberate.
        • +
        • What it is: the FluentValidation validator for AddSessionQuestionAnswerCommand. One rule: RuleFor(x => x.AnswerValue).NotEmpty() with the message "Answer value is required." (MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerCommandValidator.cs:10-13).
        • +
        • Depends on: FluentValidation's AbstractValidator<T> and nothing else (AddSessionQuestionAnswerCommandValidator.cs:1, :8).
        • +
        • Concept reinforced: the validating decorator stage, taught on AddSessionCategoryItemCommandValidator. The handler never calls this class; registration is by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125).
        • +
        • Why it's built this way: NotEmpty covers null, empty, and whitespace-only text, which is all that can be judged without knowing the question. The semantic check, that the answer matches the question's declared type, needs the Question row and therefore lives in the handler as BR-124 (AddSessionQuestionAnswerHandler.cs:102-103). Shape rules here, data-dependent rules where the data is. [Rubric §24, Forms, Validation and UX Safety].
        • +
        • Where it's used: resolved by the validating decorator for AddSessionQuestionAnswerCommand; covered by AddSessionQuestionAnswerCommandValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionCommandValidatorTests.cs:8).
        -

        AddSessionCategoryItemHandler

        +

        AddSessionQuestionAnswerHandler

        -

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionCategoryItem · MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemHandler.cs:16 · Level 10 · class (sealed partial)

        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionQuestionAnswer · MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerHandler.cs:20 · Level 10 · class (sealed partial)

          -
        • What it is: the handler for AddSessionCategoryItemCommand. It loads the session, asks the aggregate to create the association, saves, and returns the new join row as a DTO.
        • -
        • Depends on: ICommandHandler<in TCommand, TResult> closed over the command and Result<SessionCategoryItemDTO> (AddSessionCategoryItemHandler.cs:19); IUnitOfWork; SessionCategoryItemDTOMapper, injected as the concrete type (AddSessionCategoryItemHandler.cs:18); the Session aggregate and its SessionCategoryItem child; SessionCategoryItemDTO; Result and Error; logging.
        • -
        • Concept introduced, why the include list is a correctness argument and not an optimization: the code carries a comment that is worth quoting in spirit (AddSessionCategoryItemHandler.cs:28-29): the join collection HAS to be loaded, or the aggregate's duplicate check runs against an empty in-memory list and a double submit surfaces as a raw unique-index 409 instead of a worded business error. Session.AddSessionCategoryItem guards with _sessionCategoryItems.Exists(sci => !sci.IsDeleted && sci.CategoryItemId == categoryItemId) (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:418), a purely in-memory test: it can only be as correct as the collection the handler hydrated. [Rubric §4, Domain-Driven Design] assesses whether invariants are enforced by the aggregate; this is the flip side, the application layer's obligation to give the aggregate the state its invariants need. [Rubric §9, API and Contract Design]: the difference between the two outcomes is a 400-class business error with a code the client can act on versus an opaque database conflict.
        • -
        • Walkthrough:
            -
          • Resolves the repository and loads with includes: [nameof(Session.SessionCategoryItems)] and asTracking: true (AddSessionCategoryItemHandler.cs:26-30), returning a stamped Error.NotFound when the session is missing (:31-32).
          • -
          • Delegates to session.AddSessionCategoryItem(command.SessionCategoryItemId, command.CategoryItemId) (AddSessionCategoryItemHandler.cs:34). The aggregate rejects a duplicate with Session.CategoryItem.Duplicate, creates the child through its own factory, and raises SessionCategoryItemChanged (Session.cs:418-437). A failure short-circuits with the domain errors converted to the generic failure type (AddSessionCategoryItemHandler.cs:35-36).
          • -
          • Saves, logs through the generated LogCategoryItemAddedToSession (AddSessionCategoryItemHandler.cs:38-40, declared at :45-46), then maps the newly created child, Result.Success(dtoMapper.MapToDTO(result.Value!)) (:42). The ! is safe here only because the failure branch already returned.
          • -
          • Note the return shape: this add handler returns a DTO, unlike the remove handlers in this chapter which return the bare Result. The controller needs the database-assigned join id to answer 201 Created with a location header (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:225-228).
          • +
          • What it is: the richest write path among the session child commands. Where the other Add handlers are three-step orchestrations, this one runs a chain of business rules before it decides between creating a new answer and updating the caller's existing one, and raises a cross-module integration event on the create branch only (MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerHandler.cs:14-19 names the rules: BR-91, BR-49, BR-108, BR-128, BR-124, BR-107).
          • +
          • Depends on: ICommandHandler<in TCommand, TResult> closed over the command and Result<SessionQuestionAnswerDTO> (AddSessionQuestionAnswerHandler.cs:25), IUnitOfWork, ICurrentUserService, SessionQuestionAnswerDTOMapper, the BCL TimeProvider, the Session, Event, and Question aggregates with their invariant helpers (SessionInvariants, EventInvariants, QuestionInvariants), SessionFeedbackSubmitted, Result / Error, and logging (AddSessionQuestionAnswerHandler.cs:1-10, :20-25).
          • +
          • Concept introduced, an application-level upsert over an aggregate, and where its race is caught: [Rubric §6, CQRS and Event-Driven] assesses whether a command slice owns its full decision, and [Rubric §8, Data Architecture] assesses whether an integrity rule has a database-level guarantee and not only an in-memory one. BR-107 says one live answer per (session, question, author), so the handler looks for an existing non-deleted answer by the current user for this question in the already loaded child collection (:53-54) and branches: found means update, not found means create (:56-59). That check is in-memory by construction, so two concurrent submissions can both take the create branch. The database is the backstop: a unique index on (SessionId, QuestionId, CreatedBy) stops the second write (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SessionQuestionAnswerConfiguration.cs:43-44). Reading the handler alone would leave you thinking the rule is best-effort; reading the pair shows the real guarantee.
          • +
          • Concept introduced, an integration event raised on the aggregate pre-save so the outbox captures it atomically: [Rubric §7, Microservices Readiness] assesses whether modules collaborate without reaching into each other's data. On the create branch only, the handler calls session.AddDomainEvent(new SessionFeedbackSubmitted(userId, session.Id, session.EventId, timeProvider.GetUtcNow().UtcDateTime)) (:134) before the save, so the event row and the answer row land in the same transaction (ADR-003); the comment above the call states exactly that (:131-133). Engagement consumes it to award feedback points (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/SessionFeedbackSubmittedPointsHandler.cs:28). This is also why the handler takes an injected TimeProvider (:24) rather than reading DateTime.UtcNow: the timestamp on the event is testable.
          • +
          • Walkthrough: five members, and the ordering between them is the rule hierarchy.
              +
            • HandleAsync (:28-60) loads the session with its SessionQuestionAnswers and asTracking: true (:33-37), because both the upsert lookup and the subsequent mutation need the children tracked. A missing session is a stamped Error.NotFound (:38-39).
            • +
            • ValidateSessionEligibilityAsync (:62-83) reuses domain invariants rather than restating them: SessionInvariants.EnsureNotServiceSession (BR-91, a break or a lunch slot takes no feedback, :67, defined at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:91), SessionInvariants.EnsureStatusIsEligible (BR-49, the same allow-list PublicSessionStatusSpecification expresses for reads, :72, defined at SessionInvariants.cs:107), then a load of the parent Event and EventInvariants.EnsureEventIsPublished (BR-108, :77-82, defined at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:156). Each check short-circuits on failure.
            • +
            • ValidateQuestionAsync (:85-104) loads the Question and rejects one that does not exist or whose QuestionEntity is not "Session" with a validation error coded Question.NotFoundOrWrongEntity (BR-128, :90-99), then hands the answer text to QuestionInvariants.EnsureAnswerValueMatchesQuestionType (BR-124, :102-103, defined at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:115).
            • +
            • UpdateExistingAnswerAsync (:106-119) calls session.UpdateSessionQuestionAnswer(existingAnswer.Id, command.AnswerValue), saves, and maps the same tracked instance back out, so the response carries the new value.
            • +
            • CreateNewAnswerAsync (:121-139) calls session.AddSessionQuestionAnswer(...) (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:512), raises the integration event, saves, and maps the child the aggregate returned. Both branches emit the same LogQuestionAnswerAddedToSession line (:141-142), so the log does not distinguish an insert from an update.
          • -
          • Why it's built this way: mapping after the save rather than before is what lets the DTO carry the identity the database generated, which is the whole reason the command's join id is nullable.
          • -
          • Where it's used: registered by the module's application scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112); injected into SessionCategoryItemsController as ICommandHandler<AddSessionCategoryItemCommand, Result<SessionCategoryItemDTO>> (SessionCategoryItemsController.cs:49) and dispatched by its POST action, which also evicts the junction output cache (SessionCategoryItemsController.cs:215-224). Covered by AddSessionCategoryItemHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/AddSessionCategoryItemHandlerTests.cs).
          • +
          • Why it's built this way: eligibility rules are shared with other callers, so the handler composes helpers instead of copying conditions, and every check returns a Result that folds into the same failure channel (ADR-013). [Rubric §3, Clean Architecture]: the two cross-aggregate reads go through IUnitOfWork repositories, never EF types.
          • +
          • Where it's used: injected into SessionQuestionAnswersController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:59), whose whole surface requires an authenticated caller (:56), from the [Idempotent] POST /SessionQuestionAnswers action (:183-190). Covered by AddSessionQuestionAnswerHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/AddSessionQuestionAnswerHandlerTests.cs:14).
          • +
          • Caveats / not-in-source: currentUserService.UserId!.Value (:52) is null-forgiving. Nothing inside this handler enforces that a user id is present; the guarantee comes from the controller policy, so a caller reaching this code with no id would fault rather than fail gracefully. The three validation steps each issue their own round-trip (session, event, question), which is three reads before any write on the create path.
          • +
          +

          SessionCategoryItemNavigationPopulator

          +
          +

          MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions · MMCA.ADC.Conference.Application/Sessions/SessionCategoryItemNavigationPopulator.cs:11 · Level 10 · class (sealed)

          +
          +
            +
          • What it is: the navigation populator for the SessionCategoryItem join entity when it is read as its own entity rather than as a child of a session. It hydrates one navigation: the parent Session back-reference (MMCA.ADC.Conference.Application/Sessions/SessionCategoryItemNavigationPopulator.cs:7-9).
          • +
          • Depends on: DeclarativeNavigationPopulator<TEntity> closed over SessionCategoryItem (SessionCategoryItemNavigationPopulator.cs:13); FKNavigationDescriptor<TEntity, TChild, TChildId> (:15); IUnitOfWork, passed straight through to the base (:12-13); and the Session aggregate as the FK target.
          • +
          • Concept introduced, the FK direction of a declarative populator: Group 11 teaches the populator pattern itself; what this file introduces for the session slice is the reference direction, as opposed to the collection direction SessionNavigationPopulator uses. A FKNavigationDescriptor reads the nullable foreign key off each parent, batches the distinct values into one WHERE FK IN (...) query against the target's read repository, groups the results, and assigns each parent its match (MMCA.Common/Source/Core/MMCA.Common.Application/Services/NavigationLoader.cs:54-90). The AssignAction here therefore ends in FirstOrDefault() (SessionCategoryItemNavigationPopulator.cs:20), because a reference navigation wants one row out of a list. The descriptor also declares RequiresChildren => false (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/FKNavigationDescriptor.cs:23), which is what lets a caller ask for FK references without paying for child collections: the base checks that flag against the includeFKs / includeChildren arguments before loading anything (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/DeclarativeNavigationPopulator.cs:36-40). [Rubric §12, Performance and Scalability]: one batched query for the whole page of rows, never one per row. [Rubric §2, Design Patterns]: Template Method configured by data rather than by virtual methods.
          • +
          • Walkthrough: the class body is empty (SessionCategoryItemNavigationPopulator.cs:23-24). Everything it says is said in the base-constructor argument list: one descriptor with PropertyName = nameof(SessionCategoryItem.Session) (:17), ParentKeySelector = e => e.SessionId (:18), ChildForeignKeySelector = child => child.Id (:19), and AssignAction = (e, sessions) => e.Session = sessions.FirstOrDefault() (:20). PropertyName is not decoration: the base loads a descriptor only when that exact property name appears in the query's UnsupportedIncludes metadata (DeclarativeNavigationPopulator.cs:30-38), so a name typo means a silently unpopulated navigation rather than a compile error.
          • +
          • Why it's built this way: this indirection exists because of the cross-source degradation rule. When a relationship can span physical data sources, EF's navigation is stripped and only the scalar foreign key survives, so hydration has to be a second batched query rather than an Include (ADR-002, ADR-006). [Rubric §3, Clean Architecture]: the Application layer describes hydration with repository abstractions and property selectors, with no EF Core namespace anywhere in the file.
          • +
          • Where it's used: registered as the INavigationPopulator<SessionCategoryItem> implementation, services.TryAddScoped<INavigationPopulator<SessionCategoryItem>, SessionCategoryItemNavigationPopulator>() (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:104), directly above the base EntityQueryService<TEntity, TEntityDTO, TIdentifierType> registration for the same entity (:105), which is the pairing that puts it on every direct read of a session category item (ADR-034). Covered by SessionCategoryItemNavigationPopulatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/SessionCategoryItemNavigationPopulatorTests.cs:9).

          SessionDTOMapper

          MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.DTOs · MMCA.ADC.Conference.Application/Sessions/DTOs/SessionDTOMapper.cs:14 · Level 10 · class (sealed partial)

            -
          • What it is: the mapper that turns a Session aggregate into a SessionDTO, including its three child collections. It is the composite of the three child mappers above, and the reason it sits a level higher than they do.
          • -
          • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over Session / SessionDTO / SessionIdentifierType (SessionDTOMapper.cs:18); SessionSpeakerDTOMapper, SessionQuestionAnswerDTOMapper, and SessionCategoryItemDTOMapper, all three taken by primary constructor (SessionDTOMapper.cs:14-17); Riok.Mapperly.Abstractions.
          • -
          • Concept introduced, composing generated mappers with [UseMapper]: the three injected mappers are stored in fields marked [UseMapper] (SessionDTOMapper.cs:20-27). That attribute tells Mapperly: when you need to map a SessionSpeaker while generating the body of MapToDTO(Session), do not invent a nested mapping, call this field. The result is one generated method per type, reused wherever the type appears, instead of a copy of the child mapping inlined into every parent. Change how a SessionSpeaker projects and every parent DTO that embeds one follows automatically. [Rubric §1, SOLID]: each mapper has one reason to change; [Rubric §16, Maintainability]: the composition is declared in three fields rather than maintained as duplicated assignment code.
          • -
          • Walkthrough: three [UseMapper] readonly fields assigned from the primary constructor parameters (SessionDTOMapper.cs:20-27), the partial MapToDTO the generator fills in (SessionDTOMapper.cs:30), and the hand-written MapToDTOs with its null guard and Select spread (SessionDTOMapper.cs:33-37). The class is sealed partial and carries [Mapper] (SessionDTOMapper.cs:13-14), which is what makes the generation happen at all.
          • +
          • What it is: the mapper that turns a Session aggregate into a SessionDTO, including its three child collections. It is the composite of the three child mappers in this unit, and that is why it sits a level above them.
          • +
          • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over Session / SessionDTO / SessionIdentifierType (MMCA.ADC.Conference.Application/Sessions/DTOs/SessionDTOMapper.cs:18); SessionSpeakerDTOMapper, SessionQuestionAnswerDTOMapper, and SessionCategoryItemDTOMapper, all three taken by primary constructor (SessionDTOMapper.cs:14-17); Riok.Mapperly.Abstractions.
          • +
          • Concept introduced, composing generated mappers with [UseMapper]: the three injected mappers are stored in fields marked [UseMapper] (SessionDTOMapper.cs:20-27). That attribute tells Mapperly: when you need to map a SessionSpeaker while generating the body of MapToDTO(Session), do not invent a nested mapping, call this field. The result is one generated method per type, reused wherever the type appears, instead of a copy of the child mapping inlined into every parent. Change how a SessionSpeaker projects and every parent DTO that embeds one follows automatically. [Rubric §1, SOLID]: each mapper has one reason to change. [Rubric §16, Maintainability]: the composition is declared in three fields rather than maintained as duplicated assignment code.
          • +
          • Walkthrough: three [UseMapper] readonly fields assigned from the primary-constructor parameters (SessionDTOMapper.cs:20-27), the partial MapToDTO the generator fills in (:30), and the hand-written MapToDTOs with its null guard and Select spread (:33-37). The class is sealed partial and carries [Mapper] (:13-14), which is what makes the generation happen at all.
          • Why it's built this way: generated composition keeps the DTO projection compile-checked end to end, so adding a property to SessionDTO that no entity property feeds is a build error rather than a null in a response (ADR-001).
          • -
          • Where it's used: injected as the concrete type into CreateSessionHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/Create/CreateSessionHandler.cs:26, mapped at CreateSessionHandler.cs:135) and UpdateSessionHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionHandler.cs:19, mapped at UpdateSessionHandler.cs:97) to shape the response of a write; resolved as IEntityDTOMapper<Session, SessionDTO, SessionIdentifierType> by the generic EntityQueryService<TEntity, TEntityDTO, TIdentifierType> registered for sessions (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:59), which is what puts it on every session read (ADR-034). Registered self-and-interfaces by the convention scan (DependencyInjection.cs:112). Covered by SessionDTOMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/DTOs/SessionDTOMapperTests.cs).
          • -
          • Caveats / not-in-source: the generated file is not in the repository, so which properties actually get copied is observable only from the two type definitions and a build. In particular, whether the child collections are populated at map time depends entirely on whether the read path ran SessionNavigationPopulator first; the mapper maps what it is given.
          • +
          • Where it's used: injected as the concrete type into CreateSessionHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/Create/CreateSessionHandler.cs:26, mapped at :135) and UpdateSessionHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionHandler.cs:19, mapped at :97) to shape the response of a write; resolved as IEntityDTOMapper<Session, SessionDTO, SessionIdentifierType> by the generic EntityQueryService<TEntity, TEntityDTO, TIdentifierType> registered for sessions (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:63), which is what puts it on every session read (ADR-034). Registered self-and-interfaces by the convention scan (DependencyInjection.cs:125). Covered by SessionDTOMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/DTOs/SessionDTOMapperTests.cs:8).
          • +
          • Caveats / not-in-source: the generated file is not in the repository, so which properties actually get copied is observable only from the two type definitions and a build. In particular, whether the child collections or the Event and Room references are populated at map time depends entirely on whether the read path ran SessionNavigationPopulator for them first; the mapper maps what it is given.

          SessionNavigationPopulator

          -

          MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions · MMCA.ADC.Conference.Application/Sessions/SessionNavigationPopulator.cs:12 · Level 10 · class (sealed)

          +

          MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions · MMCA.ADC.Conference.Application/Sessions/SessionNavigationPopulator.cs:13 · Level 10 · class (sealed)

            -
          • What it is: the navigation populator for the Session aggregate. It loads the three child collections that EF Core cannot materialize through .Include() on this model: SessionSpeakers, SessionQuestionAnswers, and SessionCategoryItems (SessionNavigationPopulator.cs:7-10). It is the richest populator in the Conference module and, notably, has an empty class body.
          • -
          • Depends on: DeclarativeNavigationPopulator<TEntity> closed over Session (SessionNavigationPopulator.cs:14); ChildNavigationDescriptor<TEntity, TParentId, TChild, TChildId>; IUnitOfWork, passed straight through to the base; and the SessionSpeaker, SessionQuestionAnswer, and SessionCategoryItem child entities.
          • -
          • Concept reinforced, declarative child loading instead of hand-written joins: the mechanism is taught in Group 11 (ADR-002); the job here is pure binding. The subclass supplies data, not overrides: the base constructor takes the unit of work plus a collection-expression array of descriptors (SessionNavigationPopulator.cs:12-37) and owns the bulk fetch-and-assign algorithm, so the class body is genuinely empty (SessionNavigationPopulator.cs:38-39). The reason this indirection exists at all is the cross-source degradation rule: when a relationship spans physical data sources, EF's navigation is stripped and only the scalar foreign key survives, so hydration has to be a second batched query rather than an Include (ADR-006). [Rubric §2, Design Patterns]: Template Method configured by data rather than by virtual methods. [Rubric §3, Clean Architecture]: the Application layer describes hydration with repository abstractions and property selectors, with no EF Core namespace in the file. [Rubric §12, Performance and Scalability]: the base batches one query per child collection across all parents, not one per parent.
          • -
          • Walkthrough: three descriptors, each supplying the same four settings.
              -
            • SessionSpeakers (SessionNavigationPopulator.cs:16-22): PropertyName = nameof(Session.SessionSpeakers) (:18), ParentKeySelector = e => e.Id (:19), ChildForeignKeySelector = child => child.SessionId (:20), AssignAction = (e, sessionSpeakers) => e.SetSessionSpeakers(sessionSpeakers) (:21).
            • -
            • SessionQuestionAnswers (SessionNavigationPopulator.cs:23-29) and SessionCategoryItems (:30-36) repeat the shape against their own child types and SetSessionQuestionAnswers / SetSessionCategoryItems mutators (:28, :35).
            • -
            • Every AssignAction goes through the aggregate's own setter rather than a back-door property assignment, so hydration passes through the same door a business operation would. PropertyName is not decoration: the base uses it to build the navigation metadata that marks the property as populated, which is how a caller knows the collection is a real empty list and not merely unloaded.
            • +
            • What it is: the navigation populator for the Session aggregate, and the richest one in the Conference module. It declares five navigations that EF Core cannot materialize through .Include() on this model: FK references to Event and Room, and the three child collections SessionSpeakers, SessionQuestionAnswers, and SessionCategoryItems (MMCA.ADC.Conference.Application/Sessions/SessionNavigationPopulator.cs:8-12). Notably, its class body is empty.
            • +
            • Depends on: DeclarativeNavigationPopulator<TEntity> closed over Session (SessionNavigationPopulator.cs:15); both descriptor kinds, FKNavigationDescriptor<TEntity, TChild, TChildId> (:17, :24) and ChildNavigationDescriptor<TEntity, TParentId, TChild, TChildId> (:31, :38, :45); IUnitOfWork, passed straight through to the base (:14-15); and the Event, Room, SessionSpeaker, SessionQuestionAnswer, and SessionCategoryItem types.
            • +
            • Concept introduced, the two descriptor kinds side by side: this is the one file in the session slice where both appear, so it is the clearest place to see the difference. An FKNavigationDescriptor walks forward along a foreign key the parent holds and assigns one row (AssignAction ends in FirstOrDefault(), :22, :29); a ChildNavigationDescriptor walks backward from a foreign key the children hold and assigns the whole list (:36, :43, :50). The base treats them differently at load time: RequiresChildren is false on the FK kind (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/FKNavigationDescriptor.cs:23) and true on the child kind (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/ChildNavigationDescriptor.cs:25), and it tests that flag against the caller's includeFKs and includeChildren arguments before loading (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/DeclarativeNavigationPopulator.cs:36-40). A session list can therefore fetch its room and event labels without dragging every answer row along with them. [Rubric §12, Performance and Scalability] assesses whether a read pays only for what it asked for; [Rubric §2, Design Patterns]: the subclass supplies data, not overrides, which is why the body is genuinely empty (SessionNavigationPopulator.cs:53-54).
            • +
            • Walkthrough: five descriptors, each supplying the same four settings.
                +
              • Event (SessionNavigationPopulator.cs:17-23): PropertyName = nameof(Session.Event) (:19), ParentKeySelector = e => e.EventId (:20), ChildForeignKeySelector = child => child.Id (:21), AssignAction = (e, events) => e.Event = events.FirstOrDefault() (:22).
              • +
              • Room (:24-30) repeats that shape over RoomId, which is nullable on the session because a session need not be scheduled into a room yet; LoadFKPropertyAsync drops null keys before it builds the IN clause and assigns an empty list to every parent when no key survives (MMCA.Common/Source/Core/MMCA.Common.Application/Services/NavigationLoader.cs:54-69).
              • +
              • SessionSpeakers (:31-37), SessionQuestionAnswers (:38-44), and SessionCategoryItems (:45-51) each invert the direction: ParentKeySelector = e => e.Id, ChildForeignKeySelector = child => child.SessionId, and an AssignAction that calls the aggregate's own SetSessionSpeakers / SetSessionQuestionAnswers / SetSessionCategoryItems mutator (:36, :43, :50, declared at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:403, :577, :500).
              • +
              • Every AssignAction goes through an aggregate member rather than a back-door property write, so hydration passes through the same door a business operation would. [Rubric §4, Domain-Driven Design].
            • -
            • Why it's built this way: one descriptor per collection makes adding a child navigation a data edit rather than a new query method, and every aggregate in the module hydrates through one code path (ADR-002).
            • -
            • Where it's used: registered as the INavigationPopulator<Session> implementation, services.TryAddScoped<INavigationPopulator<Session>, SessionNavigationPopulator>() (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:58), and resolved by the navigation-population step of the generic query layer whenever a Session is read with any of these three collections requested (ADR-034). Its output is what SessionDTOMapper projects. Covered by SessionNavigationPopulatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/SessionNavigationPopulatorTests.cs).
            • +
            • Why it's built this way: one descriptor per navigation makes adding a relationship a data edit rather than a new query method, and every aggregate in the module hydrates through one code path (ADR-002).
            • +
            • Where it's used: registered as the INavigationPopulator<Session> implementation, services.TryAddScoped<INavigationPopulator<Session>, SessionNavigationPopulator>() (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:62), immediately above the session query-service and custom-delete registrations that complete the aggregate's block (:63-64), and resolved by the navigation-population step of the generic query layer whenever a Session is read with any of these five navigations requested (ADR-034). Its output is what SessionDTOMapper projects. Covered by SessionNavigationPopulatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/SessionNavigationPopulatorTests.cs:9).
            • +
            • Caveats / not-in-source: the write handlers in this unit do not go through this populator at all. AddSessionCategoryItemHandler and AddSessionQuestionAnswerHandler pass an explicit includes array to the repository instead, because they need a tracked graph and this populator's loads are untracked (NavigationLoader.cs:80-84). Read paths and write paths hydrate the same collections by two different mechanisms, and nothing in either file cross-references the other.
            -

            AddSessionQuestionAnswerCommand

            +

            SessionQuestionAnswerNavigationPopulator

            -

            MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionQuestionAnswer · MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerCommand.cs:11 · Level 9 · record

            +

            MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions · MMCA.ADC.Conference.Application/Sessions/SessionQuestionAnswerNavigationPopulator.cs:11 · Level 10 · class (sealed)

              -
            • What it is: the message an attendee's session feedback travels on. Four positional fields: the owning SessionId, an optional SessionQuestionAnswerId for the answer row, the QuestionId being answered, and the AnswerValue text (AddSessionQuestionAnswerCommand.cs:11-15).
            • -
            • Depends on: ICacheInvalidating (the pipeline marker it implements, AddSessionQuestionAnswerCommand.cs:15), the Session domain type (used only for its FullName when building the cache prefix), and the SessionIdentifierType / SessionQuestionAnswerIdentifierType / QuestionIdentifierType module aliases (ADR-048).
            • -
            • Concept introduced, the nullable child id on an Add command: the second parameter is SessionQuestionAnswerIdentifierType?, documented as "explicit ID for the answer entity, or null for database-generated identity" (AddSessionQuestionAnswerCommand.cs:8). The REST path always passes null and lets the database assign the key (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:182); the parameter exists so a caller that already knows the id can supply it, which is the shape the aggregate's AddSessionQuestionAnswer takes (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:512). [Rubric §9, API and Contract Design] assesses whether a contract states exactly what a caller may decide: a nullable id rather than a defaulted one keeps "let the database choose" distinct from "I chose zero".
            • -
            • Walkthrough: the record body holds one member, CachePrefix => $"{typeof(Session).FullName}:" (AddSessionQuestionAnswerCommand.cs:17-18). That satisfies ICacheInvalidating, so the caching decorator evicts every entry under the Session prefix after the command succeeds. Every session read keys under that same prefix, including GetNowNextQuery (GetNowNextQuery.cs:33), so one answer submission flushes the session projections in one stroke instead of requiring per-query bookkeeping.
            • -
            • Why it's built this way: the command carries no author id. Ownership is resolved server side from ICurrentUserService inside AddSessionQuestionAnswerHandler (AddSessionQuestionAnswerHandler.cs:52), so a client cannot submit feedback as someone else. [Rubric §11, Security]: identity is never a request field. [Rubric §10, Cross-Cutting]: the command declares what it invalidates and knows nothing about how (ADR-014, ADR-026).
            • -
            • Where it's used: validated by AddSessionQuestionAnswerCommandValidator, handled by AddSessionQuestionAnswerHandler, and built from an AddSessionQuestionAnswerRequest by the POST /SessionQuestionAnswers endpoint (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:176-182), on a controller whose whole surface requires an authenticated caller (SessionQuestionAnswersController.cs:55).
            • +
            • What it is: the navigation populator for SessionQuestionAnswer read as its own entity. One navigation: the parent Session back-reference (MMCA.ADC.Conference.Application/Sessions/SessionQuestionAnswerNavigationPopulator.cs:7-9).
            • +
            • Depends on: DeclarativeNavigationPopulator<TEntity> closed over SessionQuestionAnswer (SessionQuestionAnswerNavigationPopulator.cs:13); FKNavigationDescriptor<TEntity, TChild, TChildId> (:15); IUnitOfWork (:12); the Session aggregate as the FK target.
            • +
            • Concept reinforced: none new. Structurally identical to SessionCategoryItemNavigationPopulator, which teaches the FK direction: PropertyName = nameof(SessionQuestionAnswer.Session) (:17), ParentKeySelector = e => e.SessionId (:18), ChildForeignKeySelector = child => child.Id (:19), AssignAction = (e, sessions) => e.Session = sessions.FirstOrDefault() (:20), and an empty class body (:23-24).
            • +
            • Where it's used: registered at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:107, paired with the base EntityQueryService<TEntity, TEntityDTO, TIdentifierType> for the same entity (:108). Covered by SessionQuestionAnswerNavigationPopulatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/SessionQuestionAnswerNavigationPopulatorTests.cs:9).
            • +
            • Caveats / not-in-source: this populator hydrates the parent session on an answer row, but it applies no visibility filter of its own; the eligibility rules that gate writing an answer (AddSessionQuestionAnswerHandler) have no counterpart here. Whether a direct answer read is scoped is decided by the query's specification, not by this file.
            • +
            +

            SessionSpeakerNavigationPopulator

            +
            +

            MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions · MMCA.ADC.Conference.Application/Sessions/SessionSpeakerNavigationPopulator.cs:11 · Level 10 · class (sealed)

            +
            +
              +
            • What it is: the navigation populator for SessionSpeaker read as its own entity. One navigation: the parent Session back-reference (MMCA.ADC.Conference.Application/Sessions/SessionSpeakerNavigationPopulator.cs:7-9).
            • +
            • Depends on: DeclarativeNavigationPopulator<TEntity> closed over SessionSpeaker (SessionSpeakerNavigationPopulator.cs:13); FKNavigationDescriptor<TEntity, TChild, TChildId> (:15); IUnitOfWork (:12); the Session aggregate as the FK target.
            • +
            • Concept reinforced: none new; see SessionCategoryItemNavigationPopulator. Same single descriptor over PropertyName = nameof(SessionSpeaker.Session) (:17), ParentKeySelector = e => e.SessionId (:18), ChildForeignKeySelector = child => child.Id (:19), AssignAction ending in FirstOrDefault() (:20), and an empty class body (:23-24). Worth noticing what is absent: the descriptor list does not include the Speaker side of the join, so a directly read SessionSpeaker gets its session hydrated but not its speaker.
            • +
            • Where it's used: registered at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:101, paired with the base EntityQueryService<TEntity, TEntityDTO, TIdentifierType> for the same entity (:102). Covered by SessionSpeakerNavigationPopulatorTests, which pins the type to INavigationPopulator<SessionSpeaker> and asserts the empty-collection short circuit (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/SessionSpeakerNavigationPopulatorTests.cs:9, :21, :24-30).
            • +
            • Caveats / not-in-source: no source comment explains why the Speaker navigation is left out of this descriptor list while Session is present. It is consistent with the module's other join populators, which all hydrate one side only, but the file itself does not say so.

            AddSessionSpeakerCommand

            MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionSpeaker · MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionSpeaker/AddSessionSpeakerCommand.cs:10 · Level 9 · record

              -
            • What it is: the command that associates a speaker with a session: SessionId, the optional join id SessionSpeakerId, and the SpeakerId (AddSessionSpeakerCommand.cs:10-13).
            • -
            • Depends on: ICacheInvalidating (AddSessionSpeakerCommand.cs:13), the Session type for the prefix, and the SessionIdentifierType / SessionSpeakerIdentifierType / SpeakerIdentifierType aliases. Note that SpeakerIdentifierType is a Guid in this module while the session ids are integers, which is exactly why the aliases exist rather than bare primitives (ADR-048).
            • -
            • Concept introduced: none new; the nullable child id and CachePrefix => $"{typeof(Session).FullName}:" (AddSessionSpeakerCommand.cs:15-16) work exactly as taught on AddSessionQuestionAnswerCommand. The one thing worth noticing is what this command does not carry: no ordering, no role, no display flag. Everything else about the association is the join entity's own business, decided inside the aggregate.
            • -
            • Where it's used: validated by AddSessionSpeakerCommandValidator, handled by AddSessionSpeakerHandler, and constructed by the POST /SessionSpeakers endpoint (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:210-216) on a controller gated by the SessionsManage permission (SessionSpeakersController.cs:46, ADR-020).
            • +
            • What it is: the command that attaches a speaker to an existing session. Three positional parameters: the owning SessionId, an optional SessionSpeakerId for the join row, and the SpeakerId being associated (MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionSpeaker/AddSessionSpeakerCommand.cs:10-13).
            • +
            • Depends on: ICacheInvalidating (AddSessionSpeakerCommand.cs:13); the Session type, referenced only for its FullName when building the cache prefix; and the SessionIdentifierType / SessionSpeakerIdentifierType / SpeakerIdentifierType module aliases (ADR-048).
            • +
            • Concept reinforced, the nullable child id on an Add command: the second parameter is SessionSpeakerIdentifierType?, documented in the file as "Explicit ID for the join entity, or null for database-generated identity" (AddSessionSpeakerCommand.cs:8). The REST path always passes null and lets the database assign the key (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:224); the parameter exists because that is the exact shape the aggregate method takes (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:318-320). The same shape is taught on AddSessionCategoryItemCommand.
            • +
            • Walkthrough: the record body is a single member, CachePrefix => $"{typeof(Session).FullName}:" (AddSessionSpeakerCommand.cs:15-16). That is the session-wide prefix every session write in this module declares, so one eviction after a successful command clears every cached session projection instead of requiring per-query bookkeeping (ADR-026). The command itself never touches a cache: it declares what it invalidates and the caching decorator does the work (ADR-014). [Rubric §10, Cross-Cutting] assesses whether concerns like caching live in one pipeline stage rather than being re-implemented per handler; here the handler has no cache code at all.
            • +
            • Where it's used: constructed by the hand-written POST /SessionSpeakers action of SessionSpeakersController from an AddSessionSpeakerRequest body (SessionSpeakersController.cs:217-225), on a controller gated by the SessionsManage permission (SessionSpeakersController.cs:47, ADR-020) and marked [Idempotent] so a retried request replays the first response rather than adding a second row (SessionSpeakersController.cs:218, ADR-017); validated by AddSessionSpeakerCommandValidator; handled by AddSessionSpeakerHandler.

            DeleteSessionHandler

            MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.Delete · MMCA.ADC.Conference.Application/Sessions/UseCases/Delete/DeleteSessionHandler.cs:16 · Level 9 · class (sealed partial)

              -
            • What it is: one of the two deletes in the Conference module that is not the framework's generic handler. Deleting a Session has to soft-delete the three owned join collections with it, and the aggregate can only walk children that were actually materialized, so this handler loads them explicitly before calling Delete() (DeleteSessionHandler.cs:9-15 states exactly that rationale, naming BR-55).
            • -
            • Depends on: ICommandHandler<in TCommand, TResult> closed over DeleteEntityCommand<TEntity, TIdentifierType><Session, SessionIdentifierType> and Result (DeleteSessionHandler.cs:18), IUnitOfWork, the Session aggregate, Error, and Microsoft.Extensions.Logging (DeleteSessionHandler.cs:1-5).
            • -
            • Concept introduced, replacing a generic framework handler by registering a more specific one: [Rubric §2, Design Patterns] assesses whether a general mechanism can be specialized without being forked, and [Rubric §5, Vertical Slice] assesses whether one use case can deviate without disturbing its neighbours. The framework ships DeleteEntityHandler<TEntity, TIdentifierType>, which loads one entity through the id-only overload, calls Delete(), and saves (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/DeleteEntityHandler.cs:21-35). Speaker, Category, Question, and Sponsor all take that generic registration (MMCA.ADC.Conference.Application/DependencyInjection.cs:64, :68, :73, :77); Session and Event alone are bound to hand-written classes (DependencyInjection.cs:60 and :56). Because both implement the same closed interface, the substitution is invisible above: SessionsController takes ICommandHandler<DeleteEntityCommand<Session, SessionIdentifierType>, Result> in its constructor (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionsController.cs:46) and never names this class. No route and no request shape changes.
            • -
            • Concept introduced, the include list is what makes an in-memory cascade work: [Rubric §4, Domain-Driven Design] assesses whether the aggregate stays the owner of its own invariants. Session.Delete() soft-deletes the session and then walks its three owned child lists in memory, returning the first child failure unchanged and only then raising SessionChanged with DomainEntityState.Deleted (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:277-308, the BR-55 contract stated at :272-275). A child that was never materialized is a child the loop cannot see. That is why the includes array on DeleteSessionHandler.cs:28 (SessionSpeakers, SessionQuestionAnswers, SessionCategoryItems) matches the three collections that override iterates, one for one. They are not there to shape a response: this handler returns a bare Result with no payload. asTracking: true (DeleteSessionHandler.cs:29) is equally load-bearing, since the include-aware repository overload defaults to no-tracking (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:181-184) and an untracked graph would make the subsequent save a silent no-op.
            • -
            • Walkthrough: a primary constructor taking unitOfWork and a typed ILogger (DeleteSessionHandler.cs:16-18).
                -
              • HandleAsync (DeleteSessionHandler.cs:21-42) resolves the write repository through IUnitOfWork rather than injecting one (:25) and loads the session with its three child collections (:26-30).
              • -
              • A missing session returns Error.NotFound stamped with source and target (:31-32), the standard failure shape of the Result pattern (ADR-013).
              • -
              • entity.Delete() (:34) is the only decision point: the handler never touches a child row itself.
              • -
              • Only on success does it SaveChangesAsync and log (:35-39); the failure path returns the domain Result unchanged without saving (:41), discarding whatever the aborted cascade already applied in memory.
              • -
              • LogSessionDeleted is a source-generated [LoggerMessage] partial method (:44-45), which is why the class is partial. [Rubric §13, Observability and Operability]: one structured line, emitted once, after the save.
              • +
              • What it is: the module's replacement for the generic delete handler on the Session aggregate. It exists for one reason: to load the owned join collections before calling Delete(), so the aggregate's cascade actually has children to cascade over.
              • +
              • Depends on: ICommandHandler<in TCommand, TResult> closed over DeleteEntityCommand<TEntity, TIdentifierType> and Result (DeleteSessionHandler.cs:18); IUnitOfWork and the session repository it hands out (DeleteSessionHandler.cs:17, :25); Error; Microsoft.Extensions.Logging for the source-generated log method.
              • +
              • Concept introduced, overriding a generic handler for one entity: DeleteEntityHandler<TEntity, TIdentifierType> is registered for most aggregates (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:68 for Speaker, :72 for Category), but the module's DI file substitutes this class for Session with a single TryAddScoped line (DependencyInjection.cs:64). Because the registration is keyed by the closed ICommandHandler<DeleteEntityCommand<Session, SessionIdentifierType>, Result> interface, nothing upstream changes: SessionsController still injects the same closed interface (SessionsController.cs:45) and the base controller's inherited delete action still calls it. [Rubric §1, SOLID] assesses substitutability at the abstraction, which is exactly what this swap uses. [Rubric §5, Vertical Slice]: the one entity that needs different delete behavior gets its own file rather than a conditional inside the shared handler.
              • +
              • Concept introduced, why a soft-delete cascade is load-order sensitive: Session.Delete() (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:277-308) walks _sessionSpeakers, _sessionQuestionAnswers, and _sessionCategoryItems and soft-deletes each non-deleted child (Session.cs:283-302). Those are in-memory backing lists, so they contain only what EF materialized. The generic delete handler loads no navigations, which would leave the join rows active under a soft-deleted session; the class summary states this trade-off directly (DeleteSessionHandler.cs:9-15). [Rubric §4, DDD] assesses whether the aggregate boundary is enforced at write time: the rule lives on the entity, and the handler's only job is to hydrate the boundary before invoking it. [Rubric §8, Data Architecture]: soft-delete is the workspace default (ADR-005), so an orphaned active child is a data-correctness bug, not a cosmetic one.
              • +
              • Walkthrough: one public method and one log method.
                  +
                • HandleAsync (DeleteSessionHandler.cs:21-42) resolves the session repository from the unit of work (:25), then loads by id with all three join collections included and asTracking: true (:26-30). Tracking is required because the cascade mutates loaded children and SaveChangesAsync must see them (the untracked-query trap is covered under IRepository<TEntity, TIdentifierType>). A missing row returns Error.NotFound stamped with this handler as source and Session as target (:31-32).
                • +
                • The cascade itself is one call, entity.Delete() (:34). On success the handler persists and logs (:37-38); on failure it returns the aggregate's errors untouched (:41), so a child that refuses deletion aborts the whole operation with no partial write.
                • +
                • LogSessionDeleted (:44-45) is a [LoggerMessage] source-generated partial: compile-time-checked template, strongly typed SessionIdentifierType parameter, no boxing. This is the logging shape on every handler in the module. [Rubric §13, Observability and Operability] (ADR-041).
              • -
              • Why it's built this way: what cascades is a business rule and lives in the aggregate; which rows to load is an application concern and lives here. Atomicity comes from the one SaveChangesAsync covering the parent and its children, which are all rows in the same ADC_Conference database (ADR-006). Soft-delete, not erasure, is the workspace default: Delete() sets IsDeleted and the context's global filter hides the rows from every later read (ADR-005). The SessionChanged domain event raised at Session.cs:304 is captured into the outbox by the same save (ADR-003).
              • -
              • Where it's used: registered as the Session delete handler at MMCA.ADC.Conference.Application/DependencyInjection.cs:60 and reached through DELETE /Sessions/{id}, which SessionsController overrides purely to evict output-cache tags after the base call (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionsController.cs:347-355 calling EvictSessionsCacheAsync, which drops the conference:sessions and conference tags at :357-361). The controller requires the SessionsManage permission (SessionsController.cs:41). Covered by DeleteSessionHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DeleteSessionHandlerTests.cs:12).
              • -
              • Caveats / not-in-source: DeleteEntityCommand<TEntity, TIdentifierType> implements ICacheInvalidating only, with a CachePrefix defaulting to the aggregate full-name convention (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/DeleteEntityCommand.cs:11, :20), so the invalidation is automatic but there is no ITransactional marker: the single SaveChangesAsync is the whole atomicity story (ADR-014). The child load is unpaged, so a session with an unusually large answer count materializes all of it in one batch, and no source comment records a ceiling for that.
              • +
              • Why it's built this way: the alternative, teaching the generic handler to include navigations, would push entity-specific knowledge into framework code that has no way to know which navigations are owned. The class summary notes the Event delete path has the same shape (DeleteSessionHandler.cs:14), and DependencyInjection.cs:60 registers DeleteEventHandler for the same reason.
              • +
              • Where it's used: registered at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:64; invoked through the deleteHandler constructor parameter of SessionsController (SessionsController.cs:45), which passes it into AggregateRootEntityControllerBase (SessionsController.cs:54-55). Covered by DeleteSessionHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DeleteSessionHandlerTests.cs:12).
              • +
              • Caveats / not-in-source: the handler declares no cache prefix of its own. DeleteEntityCommand<Session, SessionIdentifierType> is the message travelling the pipeline, so whether a session delete evicts the session cache is decided by that generic command's contract, not by anything in this file.

              GetNowNextQuery

              MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.NowNext · MMCA.ADC.Conference.Application/Sessions/UseCases/NowNext/GetNowNextQuery.cs:23 · Level 9 · record

                -
              • What it is: the query object for the "happening now / up next" snapshot behind the home-screen widget (ADR-042 Wave 8). It carries one nullable parameter, EventId (GetNowNextQuery.cs:23); a value targets a specific published event, and null tells the handler to auto-select the current-or-next published event, which is what the widget passes because it has no event id of its own (GetNowNextQuery.cs:7-12).
              • -
              • Depends on: IQueryCacheable (the marker it implements, GetNowNextQuery.cs:23), the Session domain type (used only for its FullName when building the cache key), the EventIdentifierType alias, and the BCL CultureInfo / TimeSpan.
              • -
              • Concept introduced, query-level read caching: a query implementing IQueryCacheable is wrapped by CachingQueryDecorator<TQuery, TResult> (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingQueryDecorator.cs:34), which returns a stored result on a hit without executing the inner handler, serializes concurrent misses behind a per-key lock so one request repopulates while the rest wait, and treats every cache fault as a miss rather than a 500 (CachingQueryDecorator.cs:8-23). [Rubric §12, Performance and Scalability] assesses whether hot reads avoid recomputation and database round-trips: a public, non-user-specific read that the home surface hits on every load is memoized instead of recomputed, and the stampede lock keeps a cold key from becoming a thundering herd. [Rubric §10, Cross-Cutting]: caching is a pipeline concern the query only declares, never implements.
              • -
              • Walkthrough: CacheKey (GetNowNextQuery.cs:26-35) composes "{Session.FullName}:NowNext:{scope}", where scope is the invariant-culture event id or the literal "current" when EventId is null (:30-33). Placing the key under the Session full-name prefix is deliberate: every session write command in this group invalidates on that same prefix (see AddSessionQuestionAnswerCommand and RemoveSessionCategoryItemCommand), so any session mutation evicts this snapshot. CacheDuration (GetNowNextQuery.cs:38) is a deliberately short 30 seconds; the remarks state why (:13-20): it bounds staleness both from event-level edits and from the continuous now/next time-bucket transitions, and it is the sole backstop when prefix eviction is unavailable.
              • -
              • Why it's built this way: keying under the aggregate prefix lets one prefix eviction cover every derived read of that aggregate, and the short TTL keeps a time-sensitive widget honest even when the distributed cache is absent (ADR-026).
              • -
              • Where it's used: handled by GetNowNextHandler and dispatched from two anonymous endpoints on EventsController: GET /Events/{id}/now-next (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventsController.cs:223-230) and the id-less GET /Events/now-next the home widget calls (EventsController.cs:238-244). Both also sit behind the NowNextCache output-cache policy (EventsController.cs:225, :240), so there are two independent cache layers over this read. Covered by GetNowNextQueryCacheTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/NowNext/GetNowNextQueryCacheTests.cs:14), which pins the key shape and the TTL.
              • +
              • What it is: the request for the "happening now / up next" snapshot. One parameter, EventIdentifierType? EventId: a value targets that published event, null asks the handler to feature the current-or-next published event (MMCA.ADC.Conference.Application/Sessions/UseCases/NowNext/GetNowNextQuery.cs:23).
              • +
              • Depends on: IQueryCacheable (GetNowNextQuery.cs:23); the Session type for the key prefix; System.Globalization for the invariant id formatting (GetNowNextQuery.cs:1).
              • +
              • Concept introduced, a query that declares its own cache key: commands implement ICacheInvalidating and name a prefix to evict; queries implement IQueryCacheable and name a key plus a TTL. The caching decorator reads both, so a cached read and the writes that invalidate it agree only because they agree on the prefix string (ADR-014, ADR-026). [Rubric §12, Performance and Scalability] assesses whether hot reads are cached at a layer that can be evicted correctly; the doc comment states the reasoning, a hot, public, non-user-specific read behind a home-screen widget (GetNowNextQuery.cs:13-20).
              • +
              • Walkthrough: two computed members and no methods.
                  +
                • CacheKey (GetNowNextQuery.cs:26-35) builds {Session full name}:NowNext:{scope} where scope is the event id formatted with CultureInfo.InvariantCulture, or the literal "current" when the id is null (:30-33). Two things matter here. The key sits under the same Session aggregate prefix the session commands declare as their CachePrefix, so any session write evicts this entry through prefix eviction. And the id-less form gets its own stable key rather than colliding with whichever event happens to be current.
                • +
                • CacheDuration (GetNowNextQuery.cs:38) is TimeSpan.FromSeconds(30). The file explains why the TTL is short and why it is not redundant with prefix eviction (:16-19): the payload changes with the wall clock as sessions roll over time buckets, event-level edits are not session writes, and the TTL is the sole backstop when prefix eviction is unavailable. [Rubric §29, Resilience and Business Continuity]: correctness degrades to "at most 30 seconds stale" rather than "wrong until someone writes a session".
                • +
                +
              • +
              • Why it's built this way: the widget has no event id of its own (GetNowNextQuery.cs:10-12), so an id-less form has to exist; making it a nullable parameter on one query rather than a second query type keeps one handler, one cache policy, and one payload shape (ADR-042 Wave 8, cited in the file at :8).
              • +
              • Where it's used: constructed twice by EventsController, with an id for GET Events/{id}/now-next (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventsController.cs:231) and with EventId: null for GET Events/now-next (EventsController.cs:245). Both actions are [AllowAnonymous] and carry [OutputCache(PolicyName = "NowNextCache")] (EventsController.cs:225-226, :240-241), a 60-second public policy tagged conference and conference:sessions (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:252). Handled by GetNowNextHandler. Covered by GetNowNextQueryCacheTests.
              • +
              • Caveats / not-in-source: there are two independent cache layers on this read, the 30-second query cache declared here and the 60-second HTTP output cache declared on the endpoint. Nothing in source ties the two durations together, so the staleness a widget actually sees is bounded by the output cache, not by CacheDuration.

              RemoveSessionCategoryItemCommand

              MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionCategoryItem · MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionCategoryItem/RemoveSessionCategoryItemCommand.cs:9 · Level 9 · record

                -
              • What it is: the command to detach a category-item association from a session. A two-field sealed record: the owning SessionId plus the join-entity id SessionCategoryItemId (RemoveSessionCategoryItemCommand.cs:9-11).
              • +
              • What it is: the command that detaches a category item (a topic, level, or locality tag) from a session. Two positional ids: the owning SessionId and the SessionCategoryItemId join row to remove (MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionCategoryItem/RemoveSessionCategoryItemCommand.cs:9-11).
              • Depends on: ICacheInvalidating (RemoveSessionCategoryItemCommand.cs:11), the Session type for the prefix, and the SessionIdentifierType / SessionCategoryItemIdentifierType aliases.
              • -
              • Concept introduced: none new. Note the contrast with the Add commands: a remove targets an existing row, so the join id is non-nullable here (:11) where the Add commands make it optional. CachePrefix => $"{typeof(Session).FullName}:" (:13-14) is the same session prefix, so a removal flushes the cached session projections, GetNowNextQuery included. [Rubric §9, API and Contract Design]: the nullability of one field carries the whole "create versus target" distinction, with no extra flag.
              • -
              • Where it's used: handled by RemoveSessionCategoryItemHandler; constructed by DELETE /SessionCategoryItems/{id} (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:232-239), which takes the owning session id as a separate argument and is gated by the SessionsManage permission (SessionCategoryItemsController.cs:46).
              • +
              • Concept reinforced: none new. Note the asymmetry with the matching Add command: AddSessionCategoryItemCommand takes a nullable child id (the database may assign it), while every Remove command takes a required one. There is nothing to generate on removal, so a null there would only be a way to express "remove nothing".
              • +
              • Walkthrough: one member, CachePrefix => $"{typeof(Session).FullName}:" (RemoveSessionCategoryItemCommand.cs:13-14), identical to the Add side, so adding and removing a tag evict the same set of cached session projections.
              • +
              • Where it's used: constructed by the DELETE /SessionCategoryItems/{id} action of SessionCategoryItemsController, which takes the join id from the route and the session id from an optional query string (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:240-248), on a controller gated by the SessionsManage permission (SessionCategoryItemsController.cs:47); handled by RemoveSessionCategoryItemHandler.
              -

              SessionCreateRequest

              +

              RemoveSessionQuestionAnswerCommand

              -

              MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.Create · MMCA.ADC.Conference.Application/Sessions/UseCases/Create/SessionCreateRequest.cs:10 · Level 9 · record

              +

              MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionQuestionAnswer · MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionQuestionAnswer/RemoveSessionQuestionAnswerCommand.cs:9 · Level 9 · record

                -
              • What it is: the create request DTO for a conference session. It doubles as the create command itself: CreateSessionHandler is declared as ICommandHandler<SessionCreateRequest, Result<SessionDTO>> (MMCA.ADC.Conference.Application/Sessions/UseCases/Create/CreateSessionHandler.cs:27), so the request travels the CQRS pipeline unchanged.
              • -
              • Depends on: ICreateRequest, so the generic create pipeline can process it, and ICacheInvalidating, so a successful create evicts the session cache (SessionCreateRequest.cs:10, :13); the Session type for the prefix; and the SessionIdentifierType / EventIdentifierType / RoomIdentifierType aliases.
              • -
              • Walkthrough: a record class (SessionCreateRequest.cs:10) carrying the session's writable fields as init-only properties. Title (:19) and EventId (:61) are required; everything else is optional, including the schedule (StartsAt / EndsAt, :25-28), the status and lifecycle flags (Status, IsInformed, IsConfirmed, IsServiceSession, IsPlenumSession, :31-43), the URL and info fields (LiveUrl, RecordingUrl, AccessibilityInfo, ResourceLinks, :46-55), Duration (:58), and the assigned RoomId (:64). Id (:16) is caller-supplied but auto-generated when left at its default (see CreateSessionHandler for the manual-id logic). CachePrefix returns the Session full-name prefix (:13), matching the read caches and every other session write message.
              • -
              • Why it's built this way: sharing one immutable request type for both the API contract and the internal command keeps the create slice thin. required marks the genuinely mandatory inputs at the type level so a malformed request cannot even be constructed, and init-only accessors mean the handler can only produce a modified copy with a with expression, which is exactly what the manual-id path does (CreateSessionHandler.cs:94). [Rubric §9, API and Contract Design]: a small, explicit, immutable shape. [Rubric §5, Vertical Slice]: request, validator, mapper, and handler all live in the one UseCases/Create folder.
              • -
              • Where it's used: validated by SessionCreateRequestValidator, mapped to a domain entity by SessionCreateRequestMapper, handled by CreateSessionHandler, and bound by POST /Sessions on SessionsController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionsController.cs:290-293), which also carries [Idempotent] so a retried request with the same Idempotency-Key does not create a second session (SessionsController.cs:291).
              • -
              • Caveats / not-in-source: three accepted fields never reach the domain factory. IsInformed, IsConfirmed, and Duration are on the request (:34, :37, :58) but are not among the fourteen arguments SessionCreateRequestMapper forwards, so the factory's defaults win for them on a create. Whether that is a deliberate "set them on update, not on create" policy is not stated anywhere in source.
              • +
              • What it is: the command that removes one attendee feedback answer from a session. Two positional ids: the owning SessionId and the SessionQuestionAnswerId (MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionQuestionAnswer/RemoveSessionQuestionAnswerCommand.cs:9-11).
              • +
              • Depends on: ICacheInvalidating (RemoveSessionQuestionAnswerCommand.cs:11), the Session type for the prefix, and the SessionIdentifierType / SessionQuestionAnswerIdentifierType aliases.
              • +
              • Concept reinforced: the same two-id removal shape as RemoveSessionCategoryItemCommand. What is worth noticing is what this record does not carry: no caller identity. Ownership for BR-52 and BR-53 is resolved server side inside RemoveSessionQuestionAnswerHandler, so a client cannot claim to be an answer's author by shaping the request. [Rubric §11, Security] assesses whether identity ever travels as request data; here it does not.
              • +
              • Walkthrough: one member, CachePrefix => $"{typeof(Session).FullName}:" (RemoveSessionQuestionAnswerCommand.cs:13-14).
              • +
              • Where it's used: constructed by the DELETE /SessionQuestionAnswers/{id} action of SessionQuestionAnswersController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:218-226), on a controller whose whole surface requires an authenticated caller (SessionQuestionAnswersController.cs:56) rather than the SessionsManage permission the other two junction controllers demand; handled by RemoveSessionQuestionAnswerHandler.
              -

              AddSessionQuestionAnswerCommandValidator

              +

              RemoveSessionSpeakerCommand

              -

              MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionQuestionAnswer · MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerCommandValidator.cs:8 · Level 10 · class (sealed)

              +

              MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionSpeaker · MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionSpeaker/RemoveSessionSpeakerCommand.cs:9 · Level 9 · record

                -
              • What it is: the FluentValidation validator for AddSessionQuestionAnswerCommand. One rule: RuleFor(x => x.AnswerValue).NotEmpty() with the message "Answer value is required." (AddSessionQuestionAnswerCommandValidator.cs:10-13).
              • -
              • Depends on: FluentValidation's AbstractValidator<T> and nothing else (AddSessionQuestionAnswerCommandValidator.cs:1, :8).
              • -
              • Concept introduced, the Validating decorator stage: [Rubric §24, Forms/Validation/UX Safety] assesses whether bad input is rejected before it reaches business logic, and [Rubric §10, Cross-Cutting] assesses whether that happens uniformly. The handler never calls this class. The validating decorator runs every registered validator for the command type before the transaction opens (ADR-014), so a malformed command costs no database work. Registration is by convention scan, services.ScanModuleApplicationServices<ClassReference>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:110-112): dropping a validator file next to its command is the entire wiring step, which is the vertical-slice payoff, [Rubric §5, Vertical Slice].
              • -
              • Why it's built this way: NotEmpty covers null, empty, and whitespace-only text, which is all that can be judged without knowing the question. The semantic check, that the answer matches the question's declared type, needs the Question row and therefore lives in the handler as BR-124 (AddSessionQuestionAnswerHandler.cs:102-103). Shape rules here, data-dependent rules where the data is.
              • -
              • Where it's used: resolved by the validating decorator for AddSessionQuestionAnswerCommand; covered by AddSessionQuestionAnswerCommandValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionCommandValidatorTests.cs:8), one of the three validator test classes that share that file.
              • +
              • What it is: the command that detaches a speaker from a session. Two positional ids: the owning SessionId and the SessionSpeakerId join row (MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionSpeaker/RemoveSessionSpeakerCommand.cs:9-11).
              • +
              • Depends on: ICacheInvalidating (RemoveSessionSpeakerCommand.cs:11), the Session type for the prefix, and the SessionIdentifierType / SessionSpeakerIdentifierType aliases.
              • +
              • Concept reinforced: structurally identical to RemoveSessionCategoryItemCommand. The behavioral difference is downstream, not here: RemoveSessionSpeakerHandler treats a SessionId of default as "not supplied" and resolves the owning session from the join id instead, which is only possible because SessionIdentifierType is a value type with a meaningless zero.
              • +
              • Walkthrough: one member, CachePrefix => $"{typeof(Session).FullName}:" (RemoveSessionSpeakerCommand.cs:13-14).
              • +
              • Where it's used: constructed by the DELETE /SessionSpeakers/{id} action of SessionSpeakersController, route id plus [FromQuery] sessionId (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:241-250), on a controller gated by the SessionsManage permission (SessionSpeakersController.cs:47); handled by RemoveSessionSpeakerHandler.
              -

              AddSessionQuestionAnswerHandler

              +

              SessionCreateRequest

              -

              MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionQuestionAnswer · MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerHandler.cs:20 · Level 10 · class (sealed partial)

              +

              MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.Create · MMCA.ADC.Conference.Application/Sessions/UseCases/Create/SessionCreateRequest.cs:10 · Level 9 · record class

                -
              • What it is: the richest write path among the session child commands. Where the other Add handlers are three-step orchestrations, this one runs a chain of business rules before it decides between creating a new answer and updating the caller's existing one, and raises a cross-module integration event on the create branch only (AddSessionQuestionAnswerHandler.cs:14-19 names the rules: BR-91, BR-49, BR-108, BR-128, BR-124, BR-107).
              • -
              • Depends on: ICommandHandler<in TCommand, TResult> closed over the command and Result<SessionQuestionAnswerDTO> (AddSessionQuestionAnswerHandler.cs:25), IUnitOfWork, ICurrentUserService, SessionQuestionAnswerDTOMapper, the BCL TimeProvider, the Session, Event, and Question aggregates with their invariant helpers (SessionInvariants, EventInvariants, QuestionInvariants), SessionFeedbackSubmitted, Result / Error, and logging (AddSessionQuestionAnswerHandler.cs:1-10, :20-25).
              • -
              • Concept introduced, an application-level upsert over an aggregate, and where its race is caught: [Rubric §6, CQRS and Event-Driven] assesses whether a command slice owns its full decision, and [Rubric §8, Data Architecture] assesses whether integrity rules have a database-level guarantee and not only an in-memory one. BR-107 says one live answer per (session, question, author), so the handler looks for an existing non-deleted answer by the current user for this question in the already loaded child collection (:53-54) and branches: found means update, not found means create (:56-59). That check is in-memory by construction, so two concurrent submissions can both take the create branch. The database is the backstop: a filtered unique index on (SessionId, QuestionId, CreatedBy) stops the second write (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SessionQuestionAnswerConfiguration.cs:43-44). Reading the handler alone would leave you thinking the rule is best-effort; reading the pair shows the real guarantee.
              • -
              • Concept introduced, an integration event raised on the aggregate pre-save so the outbox captures it atomically: [Rubric §7, Microservices Readiness] assesses whether modules collaborate without reaching into each other's data. On the create branch only, the handler calls session.AddDomainEvent(new SessionFeedbackSubmitted(userId, session.Id, session.EventId, timeProvider.GetUtcNow().UtcDateTime)) (:134) before the save, so the event row and the answer row land in the same transaction (ADR-003). Engagement consumes it to award feedback points (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/SessionFeedbackSubmittedPointsHandler.cs:28). The event type documents the exactly-once reasoning: a submitted form writes one row per question, so the event fires once per newly created answer and never on the update path, and the consumer is idempotent on its own side so a multi-question form still awards points once (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/IntegrationEvents/SessionFeedbackSubmitted.cs:8-13). This is also why the handler needs an injected TimeProvider (:24) rather than DateTime.UtcNow: the timestamp on the event is testable.
              • -
              • Walkthrough: five members, and the ordering between them is the rule hierarchy.
                  -
                • HandleAsync (:28-60) loads the session with its SessionQuestionAnswers and asTracking: true (:33-37), because both the upsert lookup and the subsequent mutation need the children tracked. A missing session is Error.NotFound (:38-39).
                • -
                • ValidateSessionEligibilityAsync (:62-83) reuses domain invariants rather than restating them: SessionInvariants.EnsureNotServiceSession (BR-91, a break or a lunch slot takes no feedback, :67, defined at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:91), SessionInvariants.EnsureStatusIsEligible (BR-49, the same allow-list PublicSessionStatusSpecification expresses for reads, :72, defined at SessionInvariants.cs:107), then a load of the parent Event and EventInvariants.EnsureEventIsPublished (BR-108, :77-82, defined at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:153). Each check short-circuits on failure.
                • -
                • ValidateQuestionAsync (:85-104) loads the Question and rejects one that does not exist or whose QuestionEntity is not "Session" with a validation error coded Question.NotFoundOrWrongEntity (BR-128, :90-99), then hands the answer text to QuestionInvariants.EnsureAnswerValueMatchesQuestionType (BR-124, :102-103, defined at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:115).
                • -
                • UpdateExistingAnswerAsync (:106-119) calls session.UpdateSessionQuestionAnswer(existingAnswer.Id, command.AnswerValue) (Session.cs:536), saves, and maps the same tracked instance back out, so the response carries the new value.
                • -
                • CreateNewAnswerAsync (:121-139) calls session.AddSessionQuestionAnswer(...) (Session.cs:512), raises the integration event, saves, and maps the child the aggregate returned. Both branches emit the same LogQuestionAnswerAddedToSession line (:141-142), so the log does not distinguish an insert from an update.
                • -
                -
              • -
              • Why it's built this way: eligibility rules are shared with other callers, so the handler composes helpers instead of copying conditions, and every check returns a Result that folds into the same failure channel (ADR-013). [Rubric §3, Clean Architecture]: the two cross-aggregate reads go through IUnitOfWork repositories, never EF types.
              • -
              • Where it's used: injected into SessionQuestionAnswersController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:58), whose whole surface requires an authenticated caller (:55), from the POST /SessionQuestionAnswers action (:176-182). Covered by AddSessionQuestionAnswerHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/AddSessionQuestionAnswerHandlerTests.cs:14).
              • -
              • Caveats / not-in-source: currentUserService.UserId!.Value (:52) is null-forgiving. Nothing inside this handler enforces that a user id is present; the guarantee comes from the controller policy, so a caller reaching this code with no id would fault rather than fail gracefully. The three validation steps each issue their own round-trip (session, event, question), which is three reads before any write on the create path.
              • +
              • What it is: the POST body for creating a session and, unusually, the command message itself. There is no separate CreateSessionCommand: CreateSessionHandler is declared as ICommandHandler<SessionCreateRequest, Result<SessionDTO>> (MMCA.ADC.Conference.Application/Sessions/UseCases/Create/CreateSessionHandler.cs:27), so the request record travels the whole decorator pipeline unchanged.
              • +
              • Depends on: ICreateRequest and ICacheInvalidating (SessionCreateRequest.cs:10); the Session type for the prefix; the SessionIdentifierType, EventIdentifierType, and RoomIdentifierType aliases.
              • +
              • Concept introduced, the request DTO as the command: ICreateRequest is a pure marker with no members (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICreateRequest.cs:8-10); its only job is to be a generic constraint on IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>. That constraint is what lets the generic create pipeline in AggregateRootEntityControllerBase bind a request body straight to a handler with no intermediate command type (SessionsController.cs:54-55). [Rubric §9, API and Contract Design] assesses whether the wire contract is explicit: it is, but the price is that the HTTP contract and the internal command contract are one type and cannot evolve independently. [Rubric §16, Maintainability]: one type instead of two, at the cost of that coupling.
              • +
              • Walkthrough: CachePrefix (SessionCreateRequest.cs:13) is the same session-wide prefix the child commands use, so a create evicts cached session reads. Seventeen init-only data properties follow. Only two are required: Title (:19) and EventId (:61). Id (:16) is a plain non-nullable SessionIdentifierType documented as "auto-generated if not provided", which in practice means a caller sends nothing and the property arrives as 0; CreateSessionHandler reads that 0 as its signal to allocate an id from the reserved manual range. LiveUrl, RecordingUrl, AccessibilityInfo, and ResourceLinks (:46, :49, :52, :55) are nullable strings rather than Uri, matching how Sessionize exports them. Status (:31) is a nullable string, not an enum, which is what makes a session with no status at all representable (the organizer-created case PublicSessionStatusSpecification has to allow for). [Rubric §15, Best Practices and Code Quality]: init-only accessors make the request immutable once bound, and CreateSessionHandler uses with rather than mutation when it fills in the id (CreateSessionHandler.cs:94).
              • +
              • Where it's used: bound from the body of POST /Sessions on SessionsController (SessionsController.cs:290-294), an action that overrides the base create purely to add the BR-86 date-range warning header and to make the [Idempotent] contract visible at the ADC endpoint (SessionsController.cs:285-291); validated by SessionCreateRequestValidator; turned into an entity by SessionCreateRequestMapper; handled by CreateSessionHandler.
              • +
              • Caveats / not-in-source: three properties on this record never reach the domain. IsInformed (:34), IsConfirmed (:37), and Duration (:58) are not among the fourteen arguments SessionCreateRequestMapper passes to Session.Create (SessionCreateRequestMapper.cs:19-33), and Session.Create has no parameters for them (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:163-177). For Duration that is by design, since the entity computes it from StartsAt and EndsAt (Session.cs:80); for the two booleans a caller can send a value that is silently discarded, and nothing in the contract says so.

              AddSessionSpeakerCommandValidator

              MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionSpeaker · MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionSpeaker/AddSessionSpeakerCommandValidator.cs:8 · Level 10 · class (sealed)

                -
              • What it is: the validator for AddSessionSpeakerCommand. One rule: RuleFor(x => x.SpeakerId).NotEqual(default(SpeakerIdentifierType)) with the message "Speaker ID is required." (AddSessionSpeakerCommandValidator.cs:10-13).
              • -
              • Depends on: FluentValidation's AbstractValidator<T> (AddSessionSpeakerCommandValidator.cs:8) and the SpeakerIdentifierType alias.
              • -
              • Concept introduced: none new; see AddSessionQuestionAnswerCommandValidator for the decorator stage. What differs is the sentinel. Because the identifier alias is a value type, default is what model binding produces for a missing or unparsable JSON field, so this rule turns a silently omitted speaker into a 400 rather than a lookup miss deeper in. Here the alias is a Guid, so the rule rejects Guid.Empty.
              • -
              • Why it's built this way: the validator covers only what can be judged from the message itself. Whether the speaker exists, and whether the association is a duplicate, are decided against loaded state in the aggregate (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:318-322) rather than restated here. [Rubric §4, Domain-Driven Design]: the invariant stays in the aggregate; the validator only guards the shape.
              • -
              • Where it's used: resolved by the validating decorator for AddSessionSpeakerCommand; covered by AddSessionSpeakerCommandValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionCommandValidatorTests.cs:38).
              • +
              • What it is: a one-rule FluentValidation validator for AddSessionSpeakerCommand, rejecting a missing speaker id before the handler touches the database.
              • +
              • Depends on: FluentValidation's AbstractValidator<T> closed over the command (AddSessionSpeakerCommandValidator.cs:1, :8), and the SpeakerIdentifierType alias for the default comparison.
              • +
              • Concept reinforced, validation at the command boundary: the validation decorator runs every registered AbstractValidator<TCommand> before the handler (ADR-014), which is why AddSessionSpeakerHandler contains no shape checks on its inputs. Discovery is by convention: the module's ScanModuleApplicationServices<ClassReference>() call registers validators alongside handlers and mappers in one line (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125). [Rubric §24, Forms, Validation and UX Safety] assesses whether validation is present and applied before business logic; it is, and the failure surfaces as a structured Result rather than an exception.
              • +
              • Walkthrough: an expression-bodied constructor (AddSessionSpeakerCommandValidator.cs:10-13): RuleFor(x => x.SpeakerId).NotEqual(default(SpeakerIdentifierType)).WithMessage("Speaker ID is required."). Because the identifier alias is int, default is 0, and this is the module's standard idiom for "a value-typed id was not supplied".
              • +
              • Why it's built this way: the interesting rule, that a speaker cannot be attached twice, cannot live here. Duplicate detection needs the session's existing speaker list, so it is an aggregate invariant instead (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:322-329, error code Session.Speaker.Duplicate). The split is deliberate: the validator checks shape, the aggregate checks state. [Rubric §4, DDD].
              • +
              • Where it's used: discovered by the convention scan (DependencyInjection.cs:125) and invoked by the validation decorator ahead of AddSessionSpeakerHandler. Covered by AddSessionSpeakerCommandValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionCommandValidatorTests.cs:38).
              • +
              • Caveats / not-in-source: SessionId is not validated. A command with SessionId == 0 passes validation and fails in the handler as Error.NotFound (AddSessionSpeakerHandler.cs:28-29), a correct outcome reached by a slower path than the speaker-id check gets.

              AddSessionSpeakerHandler

              MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionSpeaker · MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionSpeaker/AddSessionSpeakerHandler.cs:16 · Level 10 · class (sealed partial)

                -
              • What it is: the handler for AddSessionSpeakerCommand, and the plainest example of the "add a child through the aggregate root" shape: load the session, delegate to a domain method, save, log, return the mapped DTO (AddSessionSpeakerHandler.cs:11-15).
              • -
              • Depends on: ICommandHandler<in TCommand, TResult> closed over the command and Result<SessionSpeakerDTO> (AddSessionSpeakerHandler.cs:19), IUnitOfWork, SessionSpeakerDTOMapper, the Session aggregate and its SessionSpeaker child, Result / Error, and Microsoft.Extensions.Logging.
              • -
              • Concept introduced, loading the children the invariant needs: this handler calls the include-aware overload, GetByIdAsync(command.SessionId, [nameof(Session.SessionSpeakers)], asTracking: true, cancellationToken) (AddSessionSpeakerHandler.cs:27). That matters because the aggregate's duplicate rule is evaluated over the in-memory collection: _sessionSpeakers.Exists(ss => !ss.IsDeleted && ss.SpeakerId == speakerId) (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:322). Loading the collection is part of satisfying the invariant, not an optimization. asTracking: true is required because that overload defaults to no-tracking (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFReadRepository.cs:181-184), and an untracked aggregate would make the save a silent no-op. [Rubric §4, Domain-Driven Design] and [Rubric §8, Data Architecture]: the load shape is chosen by what the aggregate must decide, and the filtered unique index on (SessionId, SpeakerId) still backs it at the database level (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SessionSpeakerConfiguration.cs:30-31).
              • -
              • Walkthrough: a primary constructor with unitOfWork, dtoMapper, and a typed ILogger (AddSessionSpeakerHandler.cs:16-19).
                  -
                • HandleAsync (:22-40) loads as above and returns Error.NotFound stamped with source and target for a missing session (:28-29).
                • -
                • session.AddSessionSpeaker(command.SessionSpeakerId, command.SpeakerId) (:31) is the only write; a domain failure is forwarded verbatim, errors and all (:32-33), so a duplicate association surfaces to the client as the aggregate worded it.
                • -
                • Only on success does it SaveChangesAsync with ConfigureAwait(false) (ADR-049), log through the generated LogSpeakerAddedToSession (:35-37, :42-43), and return Result.Success(dtoMapper.MapToDTO(result.Value!)) (:39). The new join row is mapped from the value the aggregate returned, not re-queried.
                • +
                • What it is: the handler that loads a session, asks the aggregate to add a speaker association, saves, and returns the new join row as a DTO.
                • +
                • Depends on: ICommandHandler<in TCommand, TResult> closed over AddSessionSpeakerCommand and Result<SessionSpeakerDTO> (AddSessionSpeakerHandler.cs:19); IUnitOfWork (:17); the concrete SessionSpeakerDTOMapper (:18); the Session aggregate and its SessionSpeaker child; Result and Error.
                • +
                • Concept introduced, the load-delegate-save handler shape: this is the canonical child-mutation handler in the module and worth reading once carefully, because five siblings in this chapter repeat it. The handler owns orchestration and persistence; the aggregate owns the rule. [Rubric §3, Clean Architecture] assesses whether the application layer stays free of business rules: the only decision this class makes is what to do with a failed Result. [Rubric §6, CQRS and Event-Driven]: the domain event is raised inside the aggregate (Session.cs:339) and dispatched by the unit of work, not by the handler.
                • +
                • Walkthrough: a primary constructor and one method.
                    +
                  • Constructor parameters (AddSessionSpeakerHandler.cs:16-19): unit of work, mapper, logger. Note the mapper is injected as the concrete SessionSpeakerDTOMapper rather than through the mapper interface, which is what makes the hand-written plural MapToDTOs reachable at other call sites; here only the singular is used.
                  • +
                  • HandleAsync (:22-40) resolves the session repository (:26), then loads by id including only SessionSpeakers with asTracking: true (:27). Including exactly the one collection the aggregate method will touch is the pattern across all of these handlers: enough to enforce the invariant, no more. A missing session returns Error.NotFound stamped with source and target (:28-29).
                  • +
                  • session.AddSessionSpeaker(command.SessionSpeakerId, command.SpeakerId) (:31) is where the rules live: the aggregate rejects a duplicate non-deleted association with Session.Speaker.Duplicate (Session.cs:322-329), delegates row creation to SessionSpeaker.Create (Session.cs:331), and raises SessionSpeakerChanged with DomainEntityState.Added (Session.cs:339). A failure short-circuits with the aggregate's errors (:32-33), so no save happens.
                  • +
                  • On success the handler persists (:35), logs through the source-generated LogSpeakerAddedToSession (:37, :42-43), and returns Result.Success(dtoMapper.MapToDTO(result.Value!)) (:39). The ! is safe only because IsFailure was checked two lines earlier; that is the standard Result usage in this codebase.
                • -
                • Why it's built this way: the aggregate owns the duplicate rule and the SessionSpeakerChanged domain event; the handler owns only the loading strategy that lets the aggregate apply them, plus the DTO shaping. The surrounding decorators already own the rest: validation before it, cache eviction after it (ADR-014). [Rubric §1, SOLID]: one reason to change, and it is the use case, not the plumbing.
                • -
                • Where it's used: injected into SessionSpeakersController as ICommandHandler<AddSessionSpeakerCommand, Result<SessionSpeakerDTO>> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:49) and invoked from its POST action (:210-216), which evicts the sessions output cache afterwards because a speaker assignment changes the cached session reads. Registered by the convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:112). Covered by AddSessionSpeakerHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/AddSessionSpeakerHandlerTests.cs:12).
                • +
                • Why it's built this way: returning the created DTO rather than bare success lets the controller answer 201 Created with a location route pointing at the new join row (SessionSpeakersController.cs:235-238), which is what the generic create action does for aggregates and what a hand-written child create has to do for itself.
                • +
                • Where it's used: registered by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125); injected into SessionSpeakersController as addHandler (SessionSpeakersController.cs:50) and called at SessionSpeakersController.cs:223-225. Covered by AddSessionSpeakerHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/AddSessionSpeakerHandlerTests.cs:12).
                • +
                • Caveats / not-in-source: the handler never checks that SpeakerId refers to an existing speaker. A well-formed id for a speaker that does not exist reaches the database and fails there as a foreign-key violation, not as a Result.

                GetNowNextHandler

                MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.NowNext · MMCA.ADC.Conference.Application/Sessions/UseCases/NowNext/GetNowNextHandler.cs:20 · Level 10 · class (sealed)

                  -
                • What it is: the handler that builds the now-next snapshot: the sessions running at the query instant plus the next starting batch, for one published event or the auto-selected current-or-next event (GetNowNextHandler.cs:12-19).
                • -
                • Depends on: IQueryHandler<in TQuery, TResult> closed over GetNowNextQuery and Result<NowNextDTO> (GetNowNextHandler.cs:22), IUnitOfWork, the BCL TimeProvider, the Event and Session aggregates, CurrentEventSelector (event auto-selection and live-window math), CalendarExportMapper (eligibility plus wall-clock-to-UTC conversion), and the NowNextDTO / NowNextSessionDTO shapes.
                • -
                • Concept introduced, a TimeProvider-injected clock for a time-bucketed read: the handler takes TimeProvider timeProvider in its primary constructor (GetNowNextHandler.cs:20-22) and reads GetUtcNow() exactly once (:29), so "now" is a single injected instant rather than an ambient DateTime.UtcNow sampled repeatedly through the method. That matters twice over: a fake clock lets a test place the wall clock precisely inside or across a session boundary, [Rubric §14, Testability], and one sample means the "now" and "next" partitions cannot disagree about where the boundary is. [Rubric §12, Performance and Scalability]: pairing this handler with the 30-second cache on its query keeps a per-load widget cheap.
                • -
                • Walkthrough:
                    -
                  • SelectEventAsync (:90-113) resolves the target event: an explicit id loads that event with its Rooms (:96-101); a null id loads every published event and defers to CurrentEventSelector.SelectCurrentOrNext (:103-112, defined at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/CurrentEventSelector.cs:22). A missing or unpublished event returns Error.NotFound (:32-36), so an explicitly requested draft event is as invisible as a nonexistent one.
                  • -
                  • It then loads the event's sessions by scalar EventId predicate (:38-40), builds a room-id to name map from the already-included rooms (:41), and resolves the event's IANA time zone, falling back to TimeZoneInfo.Utc on TimeZoneNotFoundException rather than failing the read (:43-51).
                  • -
                  • Sessions are filtered through CalendarExportMapper.IsExportable, which requires a scheduled, non-service session whose status is on the BR-49 allow-list (:54, defined at MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/CalendarExportMapper.cs:26-28), and each survivor is projected by ToRow (:80-88) carrying both the local wall-clock times and their UTC instants via CalendarExportMapper.ToUtc (CalendarExportMapper.cs:47-56, which shifts spring-forward gap times ahead one hour).
                  • -
                  • "Now" is the rows whose [StartsAtUtc, EndsAtUtc) interval contains the instant, ordered by start then room name with StringComparer.OrdinalIgnoreCase (:58-62). "Next" is the batch sharing the earliest future start, not one arbitrary winner, so parallel tracks surface together (:64-72, with the intent stated in the comment at :64).
                  • -
                  • The event's live flag comes from CurrentEventSelector.GetLiveWindowUtc (:74-75, defined at CurrentEventSelector.cs:64), and the result is a NowNextDTO carrying the event id, name, live flag, and the two lists (:77).
                  • +
                  • What it is: the read handler behind the now-next snapshot. It picks an event, filters that event's sessions down to the publicly exportable ones, and splits them into "running at this instant" and "the next batch to start".
                  • +
                  • Depends on: IQueryHandler<in TQuery, TResult> closed over GetNowNextQuery and Result<NowNextDTO> (GetNowNextHandler.cs:22); IUnitOfWork (:21); TimeProvider from the BCL (:22); CalendarExportMapper for IsExportable and ToUtc (:1, :54, :87-88); CurrentEventSelector for the live window and the current-or-next rule (:74, :107); the Event and Session aggregates; NowNextSessionDTO.
                  • +
                  • Concept introduced, injecting the clock: the handler takes TimeProvider and reads timeProvider.GetUtcNow() once at the top (GetNowNextHandler.cs:29), then uses that single instant for every comparison in the method. Two consequences. First, the snapshot is internally consistent: a session cannot be classified as both running and upcoming because the clock moved between two comparisons. Second, the whole "is it 9:30 on conference morning" question becomes a test input, which is exactly how GetNowNextHandlerTests pins its scenarios (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/NowNext/GetNowNextHandlerTests.cs:29-30). [Rubric §14, Testability] assesses whether ambient state is injected rather than reached for; a DateTime.UtcNow in this method would make the behavior untestable.
                  • +
                  • Concept introduced, wall clock versus instant: a conference schedule is authored in local wall-clock time, but "is it running now" is a question about instants. The handler resolves the event's TimeZone string to a TimeZoneInfo (:46) and converts each session's stored wall-clock StartsAt and EndsAt to a DateTimeOffset through CalendarExportMapper.ToUtc (:87-88), which also shifts spring-forward gap times ahead one hour so an invalid local time still yields an instant (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/CalendarExportMapper.cs:47-55). NowNextSessionDTO then carries both forms, local for printing on a badge or widget and UTC for callers doing their own math (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/NowNextDTO.cs:29-36). [Rubric §27, Internationalization] in its time-zone sense: the displayed value is the event's zone, never the server's.
                  • +
                  • Walkthrough: one public method and two private helpers.
                      +
                    • HandleAsync (GetNowNextHandler.cs:25-78) starts with the clock (:29), then calls SelectEventAsync and refuses anything missing or unpublished with Error.NotFound targeting Event (:31-36). That guard is the access control for this endpoint: both actions are [AllowAnonymous], so "published" is the only thing standing between an anonymous caller and an unannounced event's schedule. [Rubric §11, Security].
                    • +
                    • It loads every session for the event with no includes and no visibility specification (:38-40), builds a room-id-to-name dictionary from the already-included Rooms (:41), then resolves the time zone with a TimeZoneNotFoundException fallback to UTC (:43-51). The fallback is a resilience choice: a bad zone id degrades the snapshot's times rather than failing the widget. [Rubric §29, Resilience and Business Continuity].
                    • +
                    • Eligibility reuses the calendar-export rule rather than restating it: rows = sessions.Where(CalendarExportMapper.IsExportable) (:53-56), which means scheduled at both ends, not a service session, and status-eligible per BR-49 (CalendarExportMapper.cs:26-28). One definition, two public surfaces.
                    • +
                    • now is every row whose UTC window contains the instant, ordered by start then room name case-insensitively (:58-62). next is deliberately not "the single next session": the handler takes the minimum future start (:65-66) and returns every row sharing it (:67-72), with the comment stating the intent, so parallel tracks show together (:64).
                    • +
                    • isLive compares the instant against the event's live window from CurrentEventSelector.GetLiveWindowUtc (:74-75), the same window the home surfaces use, and the payload is assembled at :77.
                    • +
                    • ToRow (:80-88) projects one session, resolving the room name through the dictionary and returning null when the session has no room (:84).
                    • +
                    • SelectEventAsync (:90-113) branches on the nullable id: an explicit id is a direct GetByIdAsync including Rooms (:98-100); otherwise it loads all published events with their rooms (:103-105) and hands them to CurrentEventSelector.SelectCurrentOrNext with accessor lambdas for start, end, and zone (:107-112). Passing accessors rather than an interface is what lets that selector serve both entities and DTOs across the module.
                  • -
                  • Why it's built this way: reusing the calendar-export eligibility and DST conversion keeps the now-next view consistent with the exported schedule (one rule, two readers), and reusing CurrentEventSelector keeps the id-less form agreeing with every other home surface about which event is "current" (ADR-042 Wave 8).
                  • -
                  • Where it's used: injected into EventsController as IQueryHandler<GetNowNextQuery, Result<NowNextDTO>> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventsController.cs:53) and called from both now-next endpoints (:230, :244). Covered by GetNowNextHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/NowNext/GetNowNextHandlerTests.cs:17), which drives it with a fake TimeProvider.
                  • -
                  • Caveats / not-in-source: the session load is unpaged and filtered in memory, so a very large event materializes all of its sessions per cache miss. ToRow dereferences session.StartsAt!.Value and session.EndsAt!.Value (:85-88); that is safe only because IsExportable already rejected unscheduled sessions, a coupling the compiler does not enforce.
                  • +
                  • Why it's built this way: the file states the contract at :12-19, that eligibility and DST discipline are shared with the calendar export deliberately. A widget and an .ics download that disagreed about which sessions are public would be a visible defect, and the only way to guarantee they agree is to call the same predicate.
                  • +
                  • Where it's used: registered by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125); injected into EventsController as nowNextHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventsController.cs:54) and called from both now-next actions (EventsController.cs:231, :245). The payload is fetched over HTTP by NowNextService (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/NowNextService.cs:23), rendered by HappeningNow, and read by the Android NowNextWidgetProvider. Covered by GetNowNextHandlerTests.
                  • +
                  • Caveats / not-in-source: the query at :38-40 loads every session row for the event, then filters, projects, sorts, and buckets in memory (:53-72). Nothing is pushed to the database beyond the EventId predicate, so the cost scales with the event's total session count rather than with the handful of rows the snapshot returns. The two cache layers on the endpoint make that acceptable in practice, not correct in principle. Separately, ToRow dereferences session.StartsAt! and session.EndsAt! (:85-88); that is safe only because IsExportable already rejected null-scheduled sessions, a coupling the compiler cannot check.

                  RemoveSessionCategoryItemHandler

                  MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionCategoryItem · MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionCategoryItem/RemoveSessionCategoryItemHandler.cs:13 · Level 10 · class (sealed partial)

                    -
                  • What it is: the handler for RemoveSessionCategoryItemCommand, and the canonical "remove a child from the session aggregate" shape that its sibling remove handlers vary from (RemoveSessionCategoryItemHandler.cs:9-12).
                  • -
                  • Depends on: ICommandHandler<in TCommand, TResult> closed over the command and a bare Result (RemoveSessionCategoryItemHandler.cs:15), IUnitOfWork, the Session aggregate and its SessionCategoryItem child, Error, and Microsoft.Extensions.Logging.
                  • -
                  • Concept introduced, load-tracked-then-mutate-through-the-aggregate: HandleAsync (:18-39) loads the session with its SessionCategoryItems and asTracking: true (:23-27), returns Error.NotFound stamped with the handler as source and Session as target when absent (:28-29), then delegates the actual removal to entity.RemoveSessionCategoryItem(command.SessionCategoryItemId) (:31, defined at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:482) so the aggregate enforces its own invariants and raises its own event. Only on success does it save and log; the domain Result is returned unchanged either way (:32-38). Note the return type: unlike the Add handlers there is no DTO, because a removal has nothing to hand back. [Rubric §4, Domain-Driven Design]: the handler never mutates child state directly, it asks the aggregate root. [Rubric §13, Observability and Operability]: logging goes through the source-generated [LoggerMessage] partial (:41-42), which records both the join id and the session id.
                  • -
                  • Walkthrough: the primary constructor takes IUnitOfWork and a typed ILogger<RemoveSessionCategoryItemHandler> (:13-15). asTracking: true is not incidental: the aggregate mutation has to be observed by the change tracker for the subsequent SaveChangesAsync (:34) to emit anything. Loading the collection is equally load-bearing, since the aggregate can only remove a child it can see.
                  • -
                  • Why it's built this way: keeping removal logic in the aggregate and cache eviction on the command (via ICacheInvalidating) leaves the handler as pure orchestration: load, delegate, save, log.
                  • -
                  • Where it's used: registered by the convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:112), injected into SessionCategoryItemsController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:50), and invoked from its DELETE /{id} action (:232-239). Covered by RemoveSessionCategoryItemHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/RemoveSessionCategoryItemHandlerTests.cs:11).
                  • +
                  • What it is: the plainest member of the load-delegate-save family: it loads a session with its category items and asks the aggregate to remove one.
                  • +
                  • Depends on: ICommandHandler<in TCommand, TResult> closed over RemoveSessionCategoryItemCommand and Result (RemoveSessionCategoryItemHandler.cs:15); IUnitOfWork (:14); the Session aggregate and its SessionCategoryItem child; Error.
                  • +
                  • Concept reinforced: the shape taught on AddSessionSpeakerHandler, minus the DTO. Removals return a bare Result because the controller answers 204 No Content (SessionCategoryItemsController.cs:256), so there is nothing to map and no mapper to inject.
                  • +
                  • Walkthrough: HandleAsync (RemoveSessionCategoryItemHandler.cs:18-39) resolves the repository (:22), loads by command.SessionId including only SessionCategoryItems with asTracking: true (:23-27), returns Error.NotFound when the session is missing (:28-29), and delegates to entity.RemoveSessionCategoryItem(command.SessionCategoryItemId) (:31, aggregate method at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:482). Only a successful result saves and logs (:32-36); a failure is returned as-is (:38). LogCategoryItemRemovedFromSession is source-generated (:41-42).
                  • +
                  • Where it's used: registered by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125); injected into SessionCategoryItemsController as removeHandler (SessionCategoryItemsController.cs:51) and called at SessionCategoryItemsController.cs:246-248, after which the controller evicts both parents' output-cache entries (SessionCategoryItemsController.cs:255). Covered by RemoveSessionCategoryItemHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/RemoveSessionCategoryItemHandlerTests.cs:11).
                  • +
                  • Caveats / not-in-source: the handler takes SessionId on faith. Passing a valid join id together with the wrong session id yields a not-found from the aggregate rather than a cross-session removal, but nothing here verifies the pairing before the load.
                  • +
                  +

                  RemoveSessionQuestionAnswerHandler

                  +
                  +

                  MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionQuestionAnswer · MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionQuestionAnswer/RemoveSessionQuestionAnswerHandler.cs:14 · Level 10 · class (sealed partial)

                  +
                  +
                    +
                  • What it is: the same load-delegate-save shape as its siblings, with one addition that earns it a careful read: an ownership check. BR-52 and BR-53 say an attendee may delete only their own answers, while an organizer may delete any (RemoveSessionQuestionAnswerHandler.cs:12).
                  • +
                  • Depends on: ICommandHandler<in TCommand, TResult> closed over RemoveSessionQuestionAnswerCommand and Result (RemoveSessionQuestionAnswerHandler.cs:17); IUnitOfWork (:15); ICurrentUserService (:16); RoleNames for the Organizer constant (:6, :35); the Session aggregate and its SessionQuestionAnswer child; Error.
                  • +
                  • Concept introduced, row-level authorization inside the handler: permission attributes on a controller answer "may this caller call this endpoint"; they cannot answer "may this caller touch this row". That second question needs the row, so it is asked here, after the load. The check is a single condition (:35): if the answer exists, the caller is not in the Organizer role, and the answer's CreatedBy differs from the current user id, the handler returns Error.Forbidden with the code SessionQuestionAnswer.NotOwner and a caller-safe message (:37-42). Ownership comes from the audit stamp the framework writes on insert, not from anything the client sent. [Rubric §11, Security] assesses whether authorization decisions are made where the data is, with identity taken from the token rather than the payload; both hold here.
                  • +
                  • Walkthrough: HandleAsync (RemoveSessionQuestionAnswerHandler.cs:20-52) loads the session including only SessionQuestionAnswers, tracked (:24-29), and returns Error.NotFound when the session is missing (:30-31). It then finds the target answer in the loaded collection, excluding soft-deleted rows (:34), runs the ownership condition (:35-42), and delegates to entity.RemoveSessionQuestionAnswer(...) (:44, aggregate method at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:559). Success saves and logs (:45-49).
                  • +
                  • Why it's built this way: putting the ownership rule in the aggregate would force the domain to know about the current user, which is an application-layer concern; putting it in the controller would require loading the row twice. The handler is the one place that already has both the identity service and the loaded answer. [Rubric §3, Clean Architecture].
                  • +
                  • Where it's used: registered by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125); injected into SessionQuestionAnswersController as removeHandler (SessionQuestionAnswersController.cs:61) and called at SessionQuestionAnswersController.cs:224-226. Covered by RemoveSessionQuestionAnswerHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/RemoveSessionQuestionAnswerHandlerTests.cs:12).
                  • +
                  • Caveats / not-in-source: the condition dereferences currentUserService.UserId!.Value (:35), so an unauthenticated caller reaching this handler would throw rather than be refused. That cannot happen through the REST surface, because the controller requires an authenticated principal for its entire surface (SessionQuestionAnswersController.cs:56), but the guarantee lives in the controller attribute, not in this file. Note also that the branch is skipped entirely when answer is null (:35): a non-existent or already-deleted id falls through to the aggregate, which returns its own not-found rather than a forbidden, so the endpoint does not leak whether an answer the caller cannot see exists.
                  • +
                  +

                  RemoveSessionSpeakerHandler

                  +
                  +

                  MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionSpeaker · MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionSpeaker/RemoveSessionSpeakerHandler.cs:13 · Level 10 · class (sealed partial)

                  +
                  +
                    +
                  • What it is: the speaker-detach handler, and the one member of the family that has to cope with a caller who does not know the parent id.
                  • +
                  • Depends on: ICommandHandler<in TCommand, TResult> closed over RemoveSessionSpeakerCommand and Result (RemoveSessionSpeakerHandler.cs:15); IUnitOfWork (:14); the Session aggregate and its SessionSpeaker child; Error.
                  • +
                  • Concept introduced, resolving an aggregate root from a child id: the DELETE endpoint takes the session id as an optional query parameter, but the UI's generic delete component sends only the join-entity id, so SessionId arrives as default (RemoveSessionSpeakerHandler.cs:24-26). The handler branches on that (:28): when the session id is unset it queries sessions by a predicate over the child collection, s => s.SessionSpeakers.Any(ss => ss.Id == command.SessionSpeakerId), including the collection and tracking it (:30-34), and takes the first match (:35); otherwise it loads directly by id (:39-43). Either way the rest of the method is identical, so the aggregate boundary is preserved: the removal is still performed by the root, never by reaching into a child repository. [Rubric §4, DDD] assesses exactly this, that children are mutated through their root. [Rubric §9, API and Contract Design]: the optional query parameter is what makes the two shapes one endpoint rather than two.
                  • +
                  • Walkthrough: HandleAsync (RemoveSessionSpeakerHandler.cs:18-57) resolves the repository (:22), runs the branch above into a nullable Session (:27-44), returns Error.NotFound when nothing resolved (:46-47), delegates to entity.RemoveSessionSpeaker(command.SessionSpeakerId) (:49, aggregate method at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:385-399, which soft-deletes the join row and raises SessionSpeakerChanged with DomainEntityState.Deleted), then saves and logs on success (:50-54).
                  • +
                  • Why it's built this way: the fallback exists because the UI reuses one generic delete affordance across every entity, and that component knows only the row's own id. Teaching the server to resolve the parent is cheaper than special-casing the client, and it keeps the endpoint usable by callers that do have the session id.
                  • +
                  • Where it's used: registered by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125); injected into SessionSpeakersController as removeHandler (SessionSpeakersController.cs:51) and called at SessionSpeakersController.cs:248-250. Covered by RemoveSessionSpeakerHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/RemoveSessionSpeakerHandlerTests.cs:11).
                  • +
                  • Caveats / not-in-source: the fallback query returns a collection and takes FirstOrDefault (:30-35), so it materializes every matching session rather than asking for one. In practice a join id belongs to exactly one session, but the query does not say so. The log statement also records command.SessionId (:53), which is 0 on the fallback path, so the emitted event names the join row correctly and the session as zero.

                  SessionCreateRequestMapper

                  MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.Create · MMCA.ADC.Conference.Application/Sessions/UseCases/Create/SessionCreateRequestMapper.cs:11 · Level 10 · class (sealed)

                    -
                  • What it is: the adapter that turns a validated SessionCreateRequest into a Session domain entity by calling the aggregate's Create factory (SessionCreateRequestMapper.cs:7-9).
                  • -
                  • Depends on: IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> closed over Session / SessionCreateRequest / SessionIdentifierType (SessionCreateRequestMapper.cs:11-12), the Session factory, and Result.
                  • -
                  • Concept introduced, request-to-entity mapping as its own step: the create pipeline separates "shape the input" (this mapper) from "orchestrate the use case" (the handler). CreateEntityAsync (:15-34) guards a null request with ArgumentNullException.ThrowIfNull (:17), then forwards the request fields positionally into Session.Create(...) (:19-33), returning that factory's Result<Session> wrapped in an already-completed Task. There is no async here at all: the mapping is synchronous, and the Task exists only to satisfy the interface, which other entities implement with genuinely asynchronous lookups. [Rubric §1, SOLID]: single responsibility, the mapper knows the factory's argument order and nothing else. [Rubric §2, Design Patterns]: the handler depends on the interface and never on this class. Manual mapping over reflection-based mapping follows ADR-001.
                  • -
                  • Walkthrough: fourteen positional arguments in factory order (:20-33): Id, Title, Description, StartsAt, EndsAt, Status, IsServiceSession, IsPlenumSession, LiveUrl, RecordingUrl, AccessibilityInfo, ResourceLinks, EventId, RoomId, matching Session.Create's parameter list one for one (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:163-177). Because the arguments are positional and same-typed neighbours exist (two bools, six string?s), a reordering here compiles: the compiler cannot catch it, only the domain-level tests can. Three request fields are deliberately absent from the list: IsInformed, IsConfirmed, and Duration never reach the factory.
                  • -
                  • Why it's built this way: the factory, not the mapper, is where the entity's creation invariants live: Session.Create combines the title, the start-before-end ordering, and the optional-text length checks before it constructs anything (Session.cs:179-183). Keeping the mapper argument-shuffling only means there is exactly one place a session can come into existence.
                  • -
                  • Where it's used: injected into CreateSessionHandler as IEntityRequestMapper<Session, SessionCreateRequest, SessionIdentifierType> (CreateSessionHandler.cs:25), so the handler is constructed against the interface and this class is named only at registration (the convention scan, MMCA.ADC.Conference.Application/DependencyInjection.cs:110-112).
                  • -
                  • Caveats / not-in-source: whether the three unmapped fields are an intentional "set them on update, not on create" policy or an oversight is not stated in this file or in the factory's documentation.
                  • +
                  • What it is: the adapter that turns a validated SessionCreateRequest into a Session entity by calling the domain factory. It is one method long and contains no logic of its own.
                  • +
                  • Depends on: IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> closed over Session / SessionCreateRequest / SessionIdentifierType (SessionCreateRequestMapper.cs:11-12); Result; Session.Create.
                  • +
                  • Concept introduced, request-to-entity mapping is not DTO mapping: entity-to-DTO mapping is source-generated by Mapperly (ADR-001), because it is a mechanical property copy with no rules. Going the other way is the opposite: constructing an entity is where invariants are enforced, so it cannot be generated. This class is therefore hand-written and does exactly one thing, forward the request's fields to the factory in positional order, so that Session.Create remains the only path into a valid Session. [Rubric §4, DDD] assesses whether entities can be constructed in an invalid state; here they cannot, because the mapper has no other constructor available to it. [Rubric §2, Design Patterns]: this is an adapter, and its value is precisely that it has no behavior of its own to disagree with the factory.
                  • +
                  • Walkthrough: CreateEntityAsync (SessionCreateRequestMapper.cs:15-34) guards its argument with ArgumentNullException.ThrowIfNull (:17), then returns Task.FromResult(Session.Create(...)) with fourteen positional arguments (:19-33). The method is async in signature only: it returns a completed task because nothing here awaits, which keeps the interface uniform for mappers that do need I/O without paying a state machine for the ones that do not. Validation happens inside the factory, which combines three invariant checks before allocating (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:179-184: title validity, end-after-start, optional text lengths) and returns Result.Failure<Session> with the combined errors when any fails.
                  • +
                  • Why it's built this way: the generic create pipeline needs a uniform way to get from some request type to some entity, and the only thing that can vary per entity is which factory to call with which fields. Isolating that in a one-method class means the pipeline never sees a domain constructor.
                  • +
                  • Where it's used: registered by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125); injected into CreateSessionHandler through the interface, not the concrete type (CreateSessionHandler.cs:25), and called at CreateSessionHandler.cs:124.
                  • +
                  • Caveats / not-in-source: the mapper passes request.Id into a SessionIdentifierType? parameter (SessionCreateRequestMapper.cs:20), so a request whose Id is still 0 would reach the factory as 0 rather than as null. That does not happen on the live path, because CreateSessionHandler replaces a default id with a computed one before calling the mapper (CreateSessionHandler.cs:79-95), but the mapper itself does not enforce it. See also the three request properties this method drops, noted under SessionCreateRequest.

                  SessionCreateRequestValidator

                  MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.Create · MMCA.ADC.Conference.Application/Sessions/UseCases/Create/SessionCreateRequestValidator.cs:7 · Level 10 · class (sealed)

                    -
                  • What it is: the FluentValidation validator for SessionCreateRequest. It composes reusable per-field rule sets rather than restating each rule inline (SessionCreateRequestValidator.cs:6).
                  • -
                  • Depends on: FluentValidation (AbstractValidator<T> and its Include) and the shared Session*Rules<T> classes from MMCA.ADC.Conference.Application.Sessions.Validation (SessionCreateRequestValidator.cs:1-2).
                  • -
                  • Concept introduced, composed validation via reusable rule includes: the constructor (:9-19) calls Include(...) once per field rule set, each rule class generic over the request type and constructed with a property selector, for example Include(new SessionTitleRules<SessionCreateRequest>(p => p.Title)) (:11). Include folds the other validator's rules into this one, so the composite reports a single flat error list. Because the rule classes are generic over the request type rather than tied to one DTO, the create and update requests share the identical rule definitions: SessionUpdateRequest's validator includes the same classes over its own properties. [Rubric §24, Forms/Validation/UX Safety] and [Rubric §16, Maintainability]: a rule such as title length or URL format is defined once, so it cannot drift between the create and update paths.
                  • -
                  • Walkthrough: eight includes (:11-18), covering Title (SessionTitleRules<T>), EventId (SessionEventIdRules<T>), Description (SessionDescriptionRules<T>), Status (SessionStatusRules<T>), LiveUrl (SessionLiveUrlRules<T>), RecordingUrl (SessionRecordingUrlRules<T>), AccessibilityInfo (SessionAccessibilityInfoRules<T>), and ResourceLinks (SessionResourceLinksRules<T>). Note what is not validated here: the StartsAt / EndsAt ordering is enforced by the domain factory (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:181), and the room assignment by CreateSessionHandler's BR-130 check, because both need data this validator does not have.
                  • -
                  • Why it's built this way: a validator judges the message; anything needing loaded state belongs further in. That division is what keeps the validating decorator able to run before any database work happens (ADR-014).
                  • -
                  • Where it's used: resolved by the validating decorator for SessionCreateRequest, which is also the create command. Covered by SessionCreateRequestValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionCreateRequestValidatorTests.cs:7).
                  • +
                  • What it is: the input validator for SessionCreateRequest. Its body is eight Include calls and nothing else (SessionCreateRequestValidator.cs:9-19).
                  • +
                  • Depends on: FluentValidation's AbstractValidator<T> (:1, :7); the eight reusable rule sets in MMCA.ADC.Conference.Application.Sessions.Validation (:2), namely SessionTitleRules<T>, SessionEventIdRules<T>, SessionDescriptionRules<T>, SessionStatusRules<T>, SessionLiveUrlRules<T>, SessionRecordingUrlRules<T>, SessionAccessibilityInfoRules<T>, and SessionResourceLinksRules<T>.
                  • +
                  • Concept reinforced, composing validators with Include: each rule set is generic in the containing request type and takes a property selector in its constructor, for example new SessionTitleRules<SessionCreateRequest>(p => p.Title) (:11). FluentValidation's Include folds another validator's rules into this one as though they had been written inline, so the create and update requests share one definition of "what a valid session title is" without sharing a base class or a request shape. [Rubric §16, Maintainability] assesses whether a rule has a single home: change the title constraint once and both request paths move together. [Rubric §1, SOLID]: each rule set is one reason to change.
                  • +
                  • Walkthrough: the constructor (SessionCreateRequestValidator.cs:9-19) includes the eight sets in a fixed order: title, event id, description, status, live URL, recording URL, accessibility info, resource links. No rule is declared locally, which is the point; the class is a manifest of which shared field rules apply to this request.
                  • +
                  • Why it's built this way: the parallel SessionUpdateRequestValidator includes the same generic rule sets closed over its own request type. Parameterizing by both the request type and the property selector is what makes that reuse possible across two records that share no interface.
                  • +
                  • Where it's used: discovered by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125) and run by the validation decorator ahead of CreateSessionHandler (ADR-014). Covered by SessionCreateRequestValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionCreateRequestValidatorTests.cs:7).
                  • +
                  • Caveats / not-in-source: the validator says nothing about StartsAt versus EndsAt. Ordering is a domain invariant, checked by SessionInvariants.EnsureEndsAtIsAfterStartsAt inside the factory (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:181), so an inverted range is rejected one layer later than a too-long title is. Room assignment is likewise absent here and enforced by the handler (CreateSessionHandler.cs:100-122).

                  CreateSessionHandler

                  MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.Create · MMCA.ADC.Conference.Application/Sessions/UseCases/Create/CreateSessionHandler.cs:22 · Level 11 · class (sealed partial)

                    -
                  • What it is: the command handler for creating a session, and the richest write path in this group. It assigns manual ids in a reserved range, validates the room assignment (BR-130 cross-event plus double-booking), delegates entity construction to the mapper, persists, maps the result to a DTO, and retries on a concurrent id collision (CreateSessionHandler.cs:15-21).
                  • -
                  • Depends on: ICommandHandler<in TCommand, TResult> closed over SessionCreateRequest and Result<SessionDTO> (CreateSessionHandler.cs:27), IUnitOfWork, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> (satisfied by SessionCreateRequestMapper), SessionDTOMapper, the Session and Event aggregates, SessionInvariants (the reserved manual-id range), SessionRoomScheduling (the BR-130 room rules), the SessionDTO result shape, plus IServiceScopeFactory and logging (CreateSessionHandler.cs:22-27).
                  • -
                  • Concept introduced, application-assigned ids with a bounded retry on collision: session ids are application-assigned because the int primary key IS the Sessionize id (:76-78). When the caller supplies no id (organizer create, where the request Id defaults to 0), CreateCoreAsync reads every existing row inside the reserved manual range with ignoreQueryFilters: true so soft-deleted rows still reserve their id, then takes Max(Id) + 1 or the range start when the range is empty (:79-89); an exhausted range returns a failure rather than wrapping around (:91-92). That range is SessionInvariants.ManualIdRangeStart = 999,999,000 through ManualIdRangeEnd = 999,999,999 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:41, :44), deliberately above any real Sessionize id. Because two concurrent creates can compute the same next id, HandleAsync wraps the attempt in a bounded loop of MaxManualIdAttempts = 3 (:30, :42-62); a duplicate-key failure recomputes the id in a fresh DI scope via scopeFactory.CreateAsyncScope() so a clean DbContext is used, since the ambient one still tracks the failed insert (:51-55). [Rubric §8, Data Architecture]: id allocation is an explicit, range-partitioned concern rather than a database identity column, which is what lets an imported id and a hand-created id share one table. [Rubric §12, Performance and Scalability]: the collision path is exceptional and capped at three attempts, not a lock on the hot path.
                  • -
                  • Walkthrough:
                      -
                    • An explicit caller id is respected as-is and gets a single attempt with no id recomputation, because a collision there is a genuine caller error (:37-40).
                    • -
                    • CreateCoreAsync (:69-136) takes the unit of work as a parameter rather than closing over the injected one, which is exactly what makes the fresh-scope retry possible. It resolves the manual id when unset and rewrites the request with a with expression, which is available because every request property is init-only (:94).
                    • -
                    • The room rules run only when RoomId has a value (:100): the parent event is loaded untracked with its Rooms (:102-107, Error.NotFound when missing at :108-109), then SessionRoomScheduling.ValidateRoomAssignmentAsync (:111-119, defined at MMCA.ADC.Conference.Application/Sessions/Validation/SessionRoomScheduling.cs:44) checks that the room belongs to this event (BR-130, error code Session.RoomId.CrossEvent at SessionRoomScheduling.cs:63) and that the [StartsAt, EndsAt) slot does not overlap another session in the same room, with excludeSessionId: null because nothing exists yet to exclude. The comment at :97-99 explains why the event load sits inside this branch: a room-less session has nothing to validate and create has no other use for the event.
                    • -
                    • It then maps via requestMapper.CreateEntityAsync with an early return on failure, adds, saves, logs, and returns Result.Success(dtoMapper.MapToDTO(entity)) (:124-135).
                    • -
                    • IsUniqueKeyViolation (:143-152) walks the whole exception chain looking for the text "duplicate key", case-insensitively. Detection is message-based because the Application layer cannot reference EF Core or SQL Server types, and both SQL Server errors 2601 and 2627 carry that wording (:138-142). [Rubric §3, Clean Architecture]: the layer boundary holds, at the cost of a string match.
                    • +
                    • What it is: the session create handler, and the most involved handler in this chapter. Beyond the usual map-persist-return, it allocates application-assigned ids out of a reserved range, guards room assignment, and retries a bounded number of times when a concurrent create takes the id it computed.
                    • +
                    • Depends on: ICommandHandler<in TCommand, TResult> closed over SessionCreateRequest and Result<SessionDTO> (CreateSessionHandler.cs:27); IUnitOfWork (:23); IServiceScopeFactory from Microsoft.Extensions.DependencyInjection (:24); IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> (:25, implemented by SessionCreateRequestMapper); the concrete SessionDTOMapper (:26); SessionInvariants for the reserved range; SessionRoomScheduling for BR-130; the Event aggregate.
                    • +
                    • Concept introduced, application-assigned ids in a reserved range: session primary keys are not database-generated, because the int PK is the Sessionize id (CreateSessionHandler.cs:76-78). An organizer-created session therefore needs an id that can never collide with one Sessionize will later import. The domain reserves the top of the range for that: SessionInvariants.ManualIdRangeStart is 999_999_000 and ManualIdRangeEnd is 999_999_999 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:41, :44), so a thousand manual ids sit above anything Sessionize issues. The handler queries the existing rows in that window with ignoreQueryFilters: true (:81-85), takes Max + 1 or the range start when empty (:87-89), and fails with a plain Error.Failure when the range is exhausted (:91-92). Ignoring the query filters is load-bearing: a soft-deleted session still occupies its id, so counting only visible rows would hand out an id the database already holds. [Rubric §8, Data Architecture] assesses whether the identity strategy matches the data's provenance; here an externally-owned key space forced the choice, and the reserved range is how the two writers coexist. The same pattern appears for questions and rooms (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:37, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:62).
                    • +
                    • Concept introduced, retrying a lost id race in a fresh DI scope: computing Max + 1 and inserting is a read-then-write race, so two concurrent organizer creates can compute the same id. The handler accepts that and recovers instead of locking. MaxManualIdAttempts is 3 (:30). HandleAsync (:33-63) loops, and the catch filter engages only when attempts remain and the exception chain looks like a duplicate key (:57). The subtle part is the retry, which does not reuse the ambient unit of work: scopeFactory.CreateAsyncScope() produces a fresh scope and a fresh IUnitOfWork (:53-55), because the ambient DbContext still tracks the insert that just failed and would replay it (:51-52). [Rubric §29, Resilience and Business Continuity] assesses whether transient contention is survived rather than surfaced; a bounded, condition-filtered retry is the shape that does not turn a real error into an infinite loop. [Rubric §12, Performance and Scalability]: the design trades a rare retry for never taking a table lock.
                    • +
                    • Concept introduced, detecting a database error without referencing the database: the Application layer cannot reference EF Core or the SQL client, so IsUniqueKeyViolation (:143-152) walks the InnerException chain and matches the message text "duplicate key" case-insensitively, which covers SQL Server errors 2601 and 2627 (:138-142). The file states both the constraint and the compromise in its own comment. [Rubric §3, Clean Architecture] assesses whether layer boundaries hold under pressure; they do here, at the cost of a string match.
                    • +
                    • Walkthrough: two paths and two helpers.
                        +
                      • HandleAsync (:33-63) first short-circuits: a caller-supplied id (a Sessionize import, for example) is respected as-is and gets a single attempt with no recomputation, because a collision there is a genuine caller error (:37-40). Otherwise the retry loop runs, calling CreateCoreAsync on the ambient unit of work for attempt one (:48-49) and on a scoped one thereafter (:53-55), logging a warning on each collision (:60).
                      • +
                      • CreateCoreAsync (:69-136) is one full attempt. It resolves the repository (:74), allocates the manual id when needed (:79-95, ending in command = command with { Id = nextId }, a copy rather than a mutation), then validates room assignment only when a room was requested (:100-122). That branch loads the parent Event with its Rooms untracked (:102-107), returns Error.NotFound targeting Event when it is missing (:108-109), and delegates to SessionRoomScheduling.ValidateRoomAssignmentAsync for BR-130 cross-event validation plus the double-booking guard (:111-119, with excludeSessionId: null because nothing exists yet to exclude). The comment at :97-99 explains why the event is not loaded unconditionally: a room-less session has nothing to validate, and unlike update's BR-86 warning, create has no other use for the event.
                      • +
                      • The tail is the ordinary create: map through the request mapper (:124-128), AddAsync then SaveChangesAsync (:130-131), log (:133), and return the mapped SessionDTO (:135).
                      • +
                      • Two [LoggerMessage] partials close the file: LogSessionCreated at information level (:154-155) and LogManualIdCollision at warning level with the attempt counters (:157-158). The warning is the operational signal that the id race is happening more often than expected. [Rubric §13, Observability and Operability].
                    • -
                    • Why it's built this way: keeping organizer-created ids in a reserved high range prevents them from colliding with future Sessionize-assigned ids, and the seeder starts its sample sessions at the range start for the same reason (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/Seeding/ConferenceModuleDbSeeder.cs:232-235). The fresh-scope retry is the only reliable way to recover from a duplicate-key race without leaking EF types upward. The double-booking half of the room check is documented as a deliberate SOFT guard: the probe and the insert are separate statements, so two concurrent organizer writes can both pass it, accepted because the endpoint is organizer-only and the outcome is repairable (SessionRoomScheduling.cs:17-25, :72). Structured logging uses the source-generated [LoggerMessage] partials LogSessionCreated and LogManualIdCollision (:154-158), the collision one at Warning level so a retry storm is visible in telemetry. [Rubric §13, Observability and Operability]: the exceptional path is the one that logs loudest.
                    • -
                    • Where it's used: injected into SessionsController as ICommandHandler<SessionCreateRequest, Result<SessionDTO>> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionsController.cs:44) and invoked from the POST /Sessions override, which then adds the BR-86 X-Warning header when the session times fall outside the event's date range and evicts the sessions output cache (SessionsController.cs:290-320). Covered by CreateSessionHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/CreateSessionHandlerTests.cs:16).
                    • -
                    • Caveats / not-in-source: this handler is not on the Sessionize import path. The importer calls the domain factory directly (MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionSyncStrategy.cs:97), so the manual-id logic and the room checks apply to organizer creates only. The manual-id scan loads every row in the reserved range to compute a maximum rather than projecting one scalar, so its cost grows with the number of hand-created sessions; nothing in the file bounds that. Message-based exception matching is also locale-sensitive by nature, and the code names only the SQL Server error numbers, not what a different provider would report.
                    • +
                    • Why it's built this way: every complication in this file traces to one fact, that the session key space is shared with an external system. ADR-006 gives the module its own database, but not its own id authority for sessions. Given that, the reserved range prevents collision by construction, and the retry handles the only race the range cannot prevent.
                    • +
                    • Where it's used: registered by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:125); injected into SessionsController as createHandler (SessionsController.cs:44), passed into AggregateRootEntityControllerBase (SessionsController.cs:54-55), and called from the overridden POST /Sessions action (SessionsController.cs:296), which is marked [Idempotent] so a retried request replays rather than creating twice (SessionsController.cs:291, ADR-017). Covered by CreateSessionHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/CreateSessionHandlerTests.cs:16), which mocks the scope factory and a second unit of work specifically to exercise the retry path (CreateSessionHandlerTests.cs:24-26).
                    • +
                    • Caveats / not-in-source: the manual-id query loads every session row in the reserved range as entities and computes Max in memory (:81-89) rather than asking the database for the maximum. The range caps at a thousand rows, so the cost is bounded, but it is not a scalar query. The retry loop is also while (true) with its bound expressed only in the catch filter (:44, :57): correct as written, since a non-matching exception or an exhausted budget propagates, but the termination condition is not local to the loop header. Finally, IsUniqueKeyViolation matches on message text, so a provider that phrases the error differently, or a localized server message, would not be recognized and the create would surface the raw exception.
                    -

                    RemoveSessionQuestionAnswerCommand

                    +

                    SponsorCreateRequest

                    -

                    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionQuestionAnswer · MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionQuestionAnswer/RemoveSessionQuestionAnswerCommand.cs:9 · Level 9 · record

                    +

                    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.UseCases.Create · MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequest.cs:11 · Level 9 · record class

                      -
                    • What it is: the command to detach a question answer from a session. It is a two-field sealed record: the owning SessionId plus the child SessionQuestionAnswerId (MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionQuestionAnswer/RemoveSessionQuestionAnswerCommand.cs:9-11).
                    • -
                    • Depends on: ICacheInvalidating (the only interface it implements), the Session domain type (used purely for its FullName when building the prefix), and the SessionIdentifierType / SessionQuestionAnswerIdentifierType module aliases (ADR-048; both resolve to int at MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:15).
                    • -
                    • Concept reinforced, write-side cache invalidation declared by the message: a command that implements ICacheInvalidating exposes a CachePrefix, and the caching decorator in the CQRS pipeline evicts every entry under that prefix once the command succeeds (ADR-014, ADR-026). Here the prefix is $"{typeof(Session).FullName}:" (RemoveSessionQuestionAnswerCommand.cs:14), the same namespace-qualified prefix every cached session read keys under, so removing one answer flushes all cached session projections rather than trying to reason about which ones embedded it. [Rubric §10, Cross-Cutting] assesses whether concerns like caching are applied uniformly by infrastructure instead of hand-wired per use case: this command declares an eviction and implements none. [Rubric §12, Performance and Scalability]: the coarse prefix trades some over-eviction for a correctness guarantee that no handler can forget.
                    • -
                    • Walkthrough: the record body is a single expression-bodied property, CachePrefix (RemoveSessionQuestionAnswerCommand.cs:13-14); both ids are positional parameters, so the message is immutable by construction.
                    • -
                    • Where it's used: constructed by the delete endpoint of SessionQuestionAnswersController (MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:217) and handled by RemoveSessionQuestionAnswerHandler, which adds the per-record ownership check this message deliberately does not carry.
                    • +
                    • What it is: the body a POST /Sponsors binds to, and, without any translation step, the command the CQRS pipeline dispatches. Twelve init-only members describe a sponsor or exhibitor; one extra member, CachePrefix, tells the pipeline what to evict when the write succeeds (MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequest.cs:14).
                    • +
                    • Depends on: two framework marker interfaces, ICreateRequest (an empty marker, MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICreateRequest.cs:8-10) and ICacheInvalidating (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/ICacheInvalidating.cs:8-15), plus the Sponsor entity type (referenced only through typeof for the cache prefix) and the SponsorTier enum. SponsorIdentifierType and EventIdentifierType are both module aliases for int (MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8, :21).
                    • +
                    • Concept introduced, the request that is also the command: most codebases carry a request DTO at the edge and translate it into an internal command. Here the two collapse. The controller declares ICommandHandler<SponsorCreateRequest, Result<SponsorDTO>> directly (MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:39) and passes the same type as the TCreateRequest argument of its generic base (:46-47), so a single declaration is the OpenAPI schema, the validation target, the mapper input, and the cache-invalidation carrier. The cost is that a wire concern and a use-case concern share one type; the benefit is that there is exactly one place to add a field. [Rubric §5, Vertical Slice] assesses whether a feature is expressible as one thin, self-contained slice: the sponsor create slice is this file plus a validator, a mapper, and a handler, all in the same folder. [Rubric §9, API and Contract Design] assesses whether the published contract is explicit: required string Name (:20) is the only member the binder will not default, and every other member is optional by construction.
                    • +
                    • Walkthrough: the members in the order they matter.
                        +
                      • CachePrefix => $"{typeof(Sponsor).FullName}:" (:14) is the entity's fully-qualified name plus a colon. That is the framework's own convention, not a local invention: the generic delete command builds its prefix the same way (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/DeleteEntityCommand.cs:20), and UpdateSponsorCommand repeats the identical expression (MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/UpdateSponsorCommand.cs:12). All three sponsor mutations therefore evict one shared namespace of keys. The eviction is performed by CachingCommandDecorator<TCommand, TResult> on success only, and it skips a blank prefix because RemoveByPrefixAsync("") would flush the entire cache (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingCommandDecorator.cs:73-78).
                      • +
                      • Id (:17) is documented as database-generated with caller-supplied values ignored, and the factory is what makes that true: it consults typeof(Sponsor).IsIdValueGenerated and substitutes default whenever the store owns the key (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:126-131). Nothing rejects a supplied id; it is simply discarded.
                      • +
                      • Name (:20) is required, so an omitted name fails model binding before any validator runs. Tier (:23) is the enum, Sort (:41) the within-tier display order, EventId (:44) the owning event, IsExhibitor (:47) and BoothNumber (:50) the expo-floor pair, and LogoUrl, Description, WebsiteUrl, LinkedInUrl, TwitterHandle (:26-38) the optional branding strings.
                      • +
                      • Every member is init, so once the binder has filled the instance the validator, the mapper, and the handler all see the same frozen values.
                      -

                      RemoveSessionSpeakerCommand

                      +
                    • +
                    • Why it's built this way: the shape mirrors SponsorDTO member for member (MMCA.ADC.Conference.Shared/Sponsors/SponsorDTO.cs:9-48) minus the concurrency token, which keeps the round trip (POST a request, receive a DTO) readable without a mapping table (ADR-001). Declaring cache invalidation as a property rather than calling a cache API keeps the Application layer free of cache infrastructure, which is what [Rubric §10, Cross-Cutting] looks for: the concern is declared once, and one decorator implements it for every command that declares it.
                    • +
                    • Where it's used: SponsorsController takes the handler for it in its constructor (MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:39) and overrides CreateAsync to add the capability check and the output-cache eviction (:211-220); SponsorCreateRequestValidator validates it, SponsorCreateRequestMapper turns it into an entity, and CreateSponsorHandler persists it.
                    • +
                    • Caveats / not-in-source: sponsors sit behind two independent caches, and this property addresses only one. CachePrefix drives the framework's ICacheService prefix eviction; the ASP.NET output cache in front of the public sponsor reads is a separate store the controller evicts by tag in the same action (MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:253-257). Removing either half leaves stale sponsor data visible somewhere.
                    • +
                    +

                    SponsorDTOMapper

                    -

                    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionSpeaker · MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionSpeaker/RemoveSessionSpeakerCommand.cs:9 · Level 9 · record

                    +

                    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.DTOs · MMCA.ADC.Conference.Application/Sponsors/DTOs/SponsorDTOMapper.cs:13 · Level 9 · class (sealed, partial)

                      -
                    • What it is: the command to remove a speaker association from a session: SessionId plus the join-entity id SessionSpeakerId (MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionSpeaker/RemoveSessionSpeakerCommand.cs:9-11).
                    • -
                    • Depends on: ICacheInvalidating, the Session type for the prefix, and the SessionIdentifierType / SessionSpeakerIdentifierType aliases.
                    • -
                    • Concept reinforced: none new; CachePrefix (RemoveSessionSpeakerCommand.cs:14) is the identical session prefix explained on RemoveSessionQuestionAnswerCommand.
                    • -
                    • Walkthrough: structurally interchangeable with its sibling, but read the handler before assuming they behave alike: RemoveSessionSpeakerHandler tolerates a defaulted SessionId and resolves the owning session from the join id, because the UI's generic delete does not send the parent id. The command itself declares no nullability for SessionId, so "absent" is expressed as the int default rather than as null.
                    • -
                    • Where it's used: constructed by the delete endpoint of SessionSpeakersController (MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:241, where the parent id arrives as [FromQuery] SessionIdentifierType sessionId at :237) and handled by RemoveSessionSpeakerHandler.
                    • +
                    • What it is: the entity-to-DTO mapper for sponsors, and the simplest one in the module: no redaction, no conditional projection, just the Mapperly-generated copy of Sponsor into SponsorDTO, because sponsor data is bought placement and is public by design (MMCA.ADC.Conference.Application/Sponsors/DTOs/SponsorDTOMapper.cs:8-11).
                    • +
                    • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over Sponsor / SponsorDTO / SponsorIdentifierType (:14), and the Mapperly source generator via [Mapper] from Riok.Mapperly.Abstractions (:4, :12).
                    • +
                    • Concept reinforced, source-generated mapping: the pattern is introduced in Group 12 and governed by ADR-001. MapToDTO is declared partial with no body (:17); Mapperly reads both types at compile time and emits the property-by-property assignment, so a member added to the entity but not to the DTO surfaces as a build diagnostic rather than as a silently missing field at runtime. [Rubric §15, Best Practices and Code Quality] assesses whether repetitive code is generated rather than hand-maintained: twelve assignments exist, and none of them are in this file.
                    • +
                    • Walkthrough: two members.
                        +
                      • public partial SponsorDTO MapToDTO(Sponsor entity); (:17) is the generated one. Because SponsorDTO also carries RowVersion (MMCA.ADC.Conference.Shared/Sponsors/SponsorDTO.cs:15), the concurrency token rides along with the projection and is what a later update has to echo back.
                      • +
                      • MapToDTOs (:20-24) is hand-written and, read side by side, is character-for-character what the interface already provides as a default implementation (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEntityDTOMapper.cs:27-32): a null guard plus [.. entityCollection.Select(MapToDTO)]. The duplication is not pointless. A C# default interface member is reachable only through the interface, and this mapper is injected by its concrete type in at least one place (CreateSponsorHandler, MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/CreateSponsorHandler.cs:19), which Scrutor supports by registering mappers AsSelfWithInterfaces (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:157-161). Re-declaring the method keeps both call shapes working.
                      -

                      SponsorCreateRequest

                      +
                    • +
                    • Why it's built this way: contrast it with its sibling. SpeakerDTOMapper injects the current-user service and blanks the speaker's email for anyone who is not an Organizer (MMCA.ADC.Conference.Application/Speakers/DTOs/SpeakerDTOMapper.cs:36). Sponsors have no such member, so this mapper needs no collaborators and stays a pure function. [Rubric §11, Security] assesses whether sensitive data is filtered at the boundary that owns it: here the boundary exists and has nothing to filter, which is a documented conclusion (:8-11) rather than an omission. [Rubric §30, Compliance and Data Governance] lands in the same place: nothing on the sponsor record is personal data.
                    • +
                    • Where it's used: registered by the module's convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:125, which reaches the IEntityDTOMapper<,,> sweep at MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:157-161), injected concretely into CreateSponsorHandler (CreateSponsorHandler.cs:19), and resolved through the interface by the closed-generic read service registered for sponsors, EntityQueryService<Sponsor, SponsorDTO, SponsorIdentifierType> (MMCA.ADC.Conference.Application/DependencyInjection.cs:85).
                    • +
                    • Caveats / not-in-source: the generated MapToDTO has no null guard, and the suite pins that: MapToDTO(null!) throws NullReferenceException (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sponsors/DTOs/SponsorDTOMapperTests.cs:73-81), while the hand-written collection overload throws the more conventional ArgumentNullException (SponsorDTOMapper.cs:22). Every caller in this codebase passes a materialized entity, so the asymmetry is documented behavior rather than a live failure mode.
                    • +
                    +

                    UpdateSessionQuestionAnswerCommand

                    -

                    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.UseCases.Create · MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequest.cs:11 · Level 9 · record

                    +

                    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.UpdateSessionQuestionAnswer · MMCA.ADC.Conference.Application/Sessions/UseCases/UpdateSessionQuestionAnswer/UpdateSessionQuestionAnswerCommand.cs:10 · Level 9 · record (sealed)

                      -
                    • What it is: the create contract for a conference sponsor or exhibitor. Like the other create requests in this module it doubles as the command itself: CreateSponsorHandler is declared as ICommandHandler<SponsorCreateRequest, Result<SponsorDTO>> (MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/CreateSponsorHandler.cs:20), so the request travels the whole pipeline unchanged.
                    • -
                    • Depends on: ICreateRequest (so the generic request-mapper pipeline can process it) and ICacheInvalidating (SponsorCreateRequest.cs:11); the Sponsor type for the cache prefix; the SponsorTier enum; the SponsorIdentifierType / EventIdentifierType aliases (SponsorIdentifierType is int, MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:20).
                    • -
                    • Concept reinforced, the create request as both wire contract and command: CachePrefix returns $"{typeof(Sponsor).FullName}:" (SponsorCreateRequest.cs:14), so a successful create evicts the cached sponsor reads through the pipeline decorator. That is a different cache from the ASP.NET output cache the controller evicts separately (MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:218), and both are needed: one holds handler results, the other holds rendered responses. [Rubric §9, API and Contract Design] assesses whether the public shape is explicit and minimal; [Rubric §5, Vertical Slice]: the request, its validator, its mapper, and its handler all live in the one UseCases/Create folder.
                    • -
                    • Walkthrough: a record class of init-only properties. Only Name is required (SponsorCreateRequest.cs:20), which is exactly the field the domain treats as mandatory. Tier (:23) and Sort (:41) carry their own defaults, EventId (:44) scopes the sponsor to an event, IsExhibitor (:47) and BoothNumber (:50) model the expo-floor half of the concept, and the four link fields (LogoUrl, WebsiteUrl, LinkedInUrl, TwitterHandle, :26, :32, :35, :38) plus Description (:29) are all optional. Id (:17) is present but its XML doc states the rule: it is database-generated and a caller-supplied value is ignored. That is not a comment-only claim; the factory decides it, Id = isIdValueGenerated ? default : id!.Value (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:126, :130), which is what makes sponsors differ from sessions, whose int key is the Sessionize id and therefore application-assigned.
                    • -
                    • Why it's built this way: one immutable type for the API body and the internal command removes a translation step and a class that could drift, and init-only accessors mean any handler adjustment has to be an explicit with copy rather than a hidden mutation.
                    • -
                    • Where it's used: bound by the [HttpPost] override on SponsorsController (MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:211-217, gated by [HasPermission(ConferencePermissions.SponsorsManage)] at :212, see ADR-020), validated by SponsorCreateRequestValidator, turned into an entity by SponsorCreateRequestMapper, and handled by CreateSponsorHandler.
                    • -
                    • Caveats / not-in-source: nothing on the request or in its validator checks that EventId refers to an existing event; the rule only requires that one be supplied (MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:101-103). Referential enforcement is left to the database.
                    • +
                    • What it is: a three-value command to change the text of one answer on one session's questionnaire. It carries the owning session id, the answer id, and the new text (MMCA.ADC.Conference.Application/Sessions/UseCases/UpdateSessionQuestionAnswer/UpdateSessionQuestionAnswerCommand.cs:10-13).
                    • +
                    • Depends on: ICacheInvalidating (:13) and the Session type, referenced only through typeof to build the cache prefix (:16). SessionIdentifierType and SessionQuestionAnswerIdentifierType are both int (MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:15-16).
                    • +
                    • Concept reinforced, commands address the aggregate root: the pattern is taught in Group 05. Note what the first parameter buys. The REST route already identifies the answer (PUT /SessionQuestionAnswers/{id}), yet the command still requires SessionId, supplied from the request body (MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:209). That is not redundancy: SessionQuestionAnswer is a child inside the Session aggregate, so the only legal way to mutate it is to load the root and go through it, and the root's id is what makes that load possible. [Rubric §4, DDD] assesses whether aggregate boundaries are respected in the write model: the command's shape enforces the boundary before the handler even runs.
                    • +
                    • Walkthrough: a positional record with one computed member.
                        +
                      • The three positional parameters SessionId, SessionQuestionAnswerId, and AnswerValue (:11-13) become init-only properties, so the command is immutable once constructed.
                      • +
                      • CachePrefix => $"{typeof(Session).FullName}:" (:16) names the Session, not the answer. Evicting the parent's namespace is what matters: nothing caches a bare answer, but a session read that includes its answers would otherwise keep serving the old text.
                      -

                      SponsorDTOMapper

                      +
                    • +
                    • Why it's built this way: the handler's response type is Result, not Result<T> (UpdateSessionQuestionAnswerHandler.cs:17), because a successful update returns nothing beyond a 204 (MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:214). Keeping the command a record also makes it structurally comparable, which is what the API tests lean on when they assert the handler was called with the values the route and body supplied (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.API.Tests/Controllers/SessionQuestionAnswersControllerTests.cs:82).
                    • +
                    • Where it's used: constructed by SessionQuestionAnswersController.UpdateAsync (MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:202-215) and handled by UpdateSessionQuestionAnswerHandler.
                    • +
                    • Caveats / not-in-source: nothing checks that the SessionId in the body actually owns the {id} in the route. It does not need to: the handler loads the session named in the command and looks the answer up inside that aggregate's own collection, so a mismatched pair simply finds no child and returns NotFound (UpdateSessionQuestionAnswerHandler.cs:44, MMCA.ADC.Conference.Domain/Sessions/Session.cs:540-542).
                    • +
                    +

                    ActivityNavigationPopulator

                    -

                    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.DTOs · MMCA.ADC.Conference.Application/Sponsors/DTOs/SponsorDTOMapper.cs:13 · Level 9 · class

                    +

                    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities · MMCA.ADC.Conference.Application/Activities/ActivityNavigationPopulator.cs:12 · Level 10 · class (sealed)

                      -
                    • What it is: the read-side mapper that projects a Sponsor entity into a SponsorDTO. Its class comment states the redaction policy in one line: sponsor data is public by design (it is bought placement), so nothing is withheld (MMCA.ADC.Conference.Application/Sponsors/DTOs/SponsorDTOMapper.cs:8-11).
                    • -
                    • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over Sponsor / SponsorDTO / SponsorIdentifierType (SponsorDTOMapper.cs:14), and the Mapperly source generator (Riok.Mapperly.Abstractions, :4).
                    • -
                    • Concept reinforced, compile-time generated mapping: the class is partial and carries [Mapper] (SponsorDTOMapper.cs:12-13), and MapToDTO is declared as a partial method with no body (:17). Mapperly writes the body at compile time by matching property names, so the mapping is ordinary generated C# with no reflection and no runtime configuration to get wrong; a property that cannot be matched is a build warning, which under this workspace's TreatWarningsAsErrors is a build failure. This is the ADR-001 position: mapping is explicit and checkable, never a runtime convention scan. [Rubric §15, Best Practices and Code Quality]: the compiler, not a test, is what proves the projection is total. [Rubric §12, Performance and Scalability]: generated assignment code allocates one object and does no member lookup.
                    • -
                    • Walkthrough: two members. MapToDTO (:17) is the generated single-entity projection. MapToDTOs (:20-24) is hand-written rather than generated: it guards its argument with ArgumentNullException.ThrowIfNull (:22) and then materializes with a collection expression over Select(MapToDTO) (:23), so the caller always receives a fully realized IReadOnlyCollection<SponsorDTO> instead of a deferred sequence that could be enumerated after the DbContext is gone.
                    • -
                    • Where it's used: injected concretely (not through the interface) into CreateSponsorHandler (MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/CreateSponsorHandler.cs:19) and UpdateSponsorHandler (MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/UpdateSponsorHandler.cs:17), and resolved by interface for the generic read path, since sponsors are served by the framework's EntityQueryService<Sponsor, SponsorDTO, SponsorIdentifierType> (MMCA.ADC.Conference.Application/DependencyInjection.cs:76, see ADR-034). Registration happens through the convention scan, services.ScanModuleApplicationServices<ClassReference>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:112).
                    • +
                    • What it is: the navigation populator for Activity. It declares one thing: how to hydrate an activity's parent Event reference when EF Core cannot reach it with .Include(). The class body is empty (MMCA.ADC.Conference.Application/Activities/ActivityNavigationPopulator.cs:24-25); the entire implementation is the descriptor list handed to the base constructor (:14-23).
                    • +
                    • Depends on: DeclarativeNavigationPopulator<TEntity> closed over Activity (:14), FKNavigationDescriptor<TEntity, TChild, TChildId> closed over Activity / Event / EventIdentifierType (:16), IUnitOfWork forwarded untouched to the base (:12-14), and the Activity and Event entities.
                    • +
                    • Concept introduced in this group, FK back-reference hydration: Group 11 teaches the populator machinery; what this class introduces here is the other direction of it. A child-collection descriptor answers "give me the rows that point at me"; an FK descriptor answers "give me the one row I point at". The framework separates the two with a single boolean: FKNavigationDescriptor.RequiresChildren is hard-coded false (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/FKNavigationDescriptor.cs:23), and the base uses it to pick which caller flag gates the load, includeFKs rather than includeChildren (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/DeclarativeNavigationPopulator.cs:36). [Rubric §2, Design Patterns] assesses whether behavior is factored into reusable shapes: this is Template Method configured by data, so a new navigation is a descriptor rather than a new query method. [Rubric §7, Microservices Readiness] assesses whether code survives a physical split: the whole reason this file exists is that a join is unavailable when parent and child live in different data sources (ADR-006, ADR-018).
                    • +
                    • Walkthrough: one descriptor, four settings, and a base algorithm worth following once.
                        +
                      • PropertyName = nameof(Activity.Event) (:17) is the match key. The base builds an ordinal HashSet of the property names the metadata provider flagged as unsupported and loads only descriptors whose name is in it (DeclarativeNavigationPopulator.cs:30-37). The name comes from the navigation property itself, public Event? Event { get; set; } (MMCA.ADC.Conference.Domain/Activities/Activity.cs:57-58), which carries a bare [Navigation] attribute; with IsCollection left at its default, metadata discovery files it in the foreign-key bucket (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/NavigationMetadataProvider.cs:74-78), which is exactly what makes RequiresChildren => false the right gate.
                      • +
                      • ParentKeySelector = e => e.EventId (:18). The descriptor types this as Func<TEntity, TChildId?> (FKNavigationDescriptor.cs:26), and Activity.EventId is a non-nullable int (MMCA.ADC.Conference.Domain/Activities/Activity.cs:54), so the compiler widens it to int?. The nullable signature exists for entities whose FK is genuinely optional; here it can never be null.
                      • +
                      • ChildForeignKeySelector = child => child.Id (:19). Read that carefully: for an FK reference the "child foreign key" is the target's own primary key, because the predicate being built matches Event.Id against the set of Activity.EventId values.
                      • +
                      • AssignAction = (e, events) => e.Event = events.FirstOrDefault() (:21). The navigation is a public settable property (Activity.cs:58), so no aggregate mutator is needed, unlike the child-collection populators which have to call an internal setter.
                      • +
                      • The load is NavigationLoader.LoadFKPropertyAsync (FKNavigationDescriptor.cs:39-45), and it is deliberately batched: collect the distinct non-null keys (MMCA.Common/Source/Core/MMCA.Common.Application/Services/NavigationLoader.cs:54-59), return early assigning empty lists when there are none (:61-69), build child => parentIds.Contains(child.Id) as an expression tree (:71-78), run one GetAllAsync with that predicate against the read repository (:80-84), group the results into a dictionary (:86-90), and assign per parent (:92-99). One query for a page of activities, not one per activity.
                      • +
                      • Two guards mean this usually costs nothing: PopulateAsync returns immediately when the entity list is empty or when the metadata reported no unsupported includes at all (DeclarativeNavigationPopulator.cs:27-28). On a topology where activities and events share a source, that second guard is always true and the populator never touches the database.
                      -

                      UpdateSessionQuestionAnswerCommand

                      +
                    • +
                    • Why it's built this way: ADR-002 makes hydration a declaration in the Application layer rather than an EF concern, and NavigationMetadataProvider decides per navigation whether .Include() is available (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/NavigationMetadataProvider.cs:37-46). Because that decision is configuration, this file is inert in the monolith and becomes the hydration path after a split, with no change to the controller, the query service, or the DTO. [Rubric §3, Clean Architecture]: there is no EF Core namespace anywhere in the file. [Rubric §12, Performance and Scalability]: the batched IN shape is what keeps a paged list from degrading into N+1.
                    • +
                    • Where it's used: registered as services.TryAddScoped<INavigationPopulator<Activity>, ActivityNavigationPopulator>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:80) and consumed by the closed-generic read service registered on the next line (:81), which invokes it as part of the read pipeline. Its tests assert the DI shape and both empty-input guards without touching the unit of work (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Activities/ActivityNavigationPopulatorTests.cs:15-43).
                    • +
                    • Caveats / not-in-source: AssignAction runs for every parent, including those whose lookup found nothing, in which case FirstOrDefault() assigns null (NavigationLoader.cs:92-99). Combined with the soft-delete query filter the read repository applies (ADR-005), an activity whose owning event has been soft-deleted comes back with Event == null rather than as an error, and a caller that renders the event name has to handle that null.
                    • +
                    +

                    CategoryItemNavigationPopulator

                    -

                    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.UpdateSessionQuestionAnswer · MMCA.ADC.Conference.Application/Sessions/UseCases/UpdateSessionQuestionAnswer/UpdateSessionQuestionAnswerCommand.cs:10 · Level 9 · record

                    +

                    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories · MMCA.ADC.Conference.Application/Categories/CategoryItemNavigationPopulator.cs:11 · Level 10 · class (sealed)

                      -
                    • What it is: the command to edit an existing question answer on a session. Same shape as the two Remove commands above plus one payload field: SessionId, SessionQuestionAnswerId, and the new AnswerValue text (MMCA.ADC.Conference.Application/Sessions/UseCases/UpdateSessionQuestionAnswer/UpdateSessionQuestionAnswerCommand.cs:10-13).
                    • -
                    • Depends on: ICacheInvalidating, the Session type, and the SessionIdentifierType / SessionQuestionAnswerIdentifierType aliases.
                    • -
                    • Concept reinforced: none new; CachePrefix (UpdateSessionQuestionAnswerCommand.cs:16) evicts the same session prefix described on RemoveSessionQuestionAnswerCommand. Worth noticing what the command does not carry: no author id and no role. Ownership is decided by the handler against the audit trail, never against a client-supplied field, which is why an attacker cannot forge authorship by editing the request body.
                    • -
                    • Where it's used: constructed by SessionQuestionAnswersController (MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:201) and handled by UpdateSessionQuestionAnswerHandler.
                    • +
                    • What it is: the FK populator for CategoryItem, hydrating each item's parent Category reference. It is the only populator in the Conference module whose FK target is not Event.
                    • +
                    • Depends on: DeclarativeNavigationPopulator<TEntity> over CategoryItem (MMCA.ADC.Conference.Application/Categories/CategoryItemNavigationPopulator.cs:13), one FKNavigationDescriptor<TEntity, TChild, TChildId> closed over CategoryItem / Category / ConferenceCategoryIdentifierType (:15), and IUnitOfWork (:12).
                    • +
                    • Concept reinforced: identical in mechanism to ActivityNavigationPopulator, which teaches the FK descriptor, the includeFKs gate, and the batched loader in full. Only the four settings differ.
                    • +
                    • Walkthrough: PropertyName = nameof(CategoryItem.Category) (:17), matching the [Navigation]-attributed settable property on the entity (MMCA.ADC.Conference.Domain/Categories/CategoryItem.cs:23-24); ParentKeySelector = e => e.CategoryId (:18), reading the get-only FK (CategoryItem.cs:27); ChildForeignKeySelector = child => child.Id (:19), the parent category's own primary key; and AssignAction = (e, categories) => e.Category = categories.FirstOrDefault() (:20). The generic argument ConferenceCategoryIdentifierType is the module alias for int (MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7), named with the Conference prefix because the entity type is the very generic Category.
                    • +
                    • Why it's built this way: same rationale as its siblings (ADR-002). [Rubric §16, Maintainability]: the difference between two entities that need parent hydration is four lines of configuration, not two query classes.
                    • +
                    • Where it's used: registered as INavigationPopulator<CategoryItem> (MMCA.ADC.Conference.Application/DependencyInjection.cs:92) alongside the closed-generic read service for category items (:93). Tests: MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Categories/CategoryItemNavigationPopulatorTests.cs:15-43.
                    • +
                    • Caveats / not-in-source: ConferenceCategoryNavigationPopulator is the mirror image of this file, loading items from the category side. The two are independent registrations, so a read that starts at either end hydrates the other without either populator knowing about its counterpart.

                    ConferenceCategoryNavigationPopulator

                    MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories · MMCA.ADC.Conference.Application/Categories/ConferenceCategoryNavigationPopulator.cs:11 · Level 10 · class (sealed)

                      -
                    • What it is: the navigation populator for the Category aggregate. It loads the one child collection (CategoryItem) that the read path cannot materialize through .Include() on this model (MMCA.ADC.Conference.Application/Categories/ConferenceCategoryNavigationPopulator.cs:7-9). It is the smallest populator in the module: one descriptor and an empty class body (:23-24).
                    • -
                    • Depends on: DeclarativeNavigationPopulator<TEntity> (the base, closed over Category), ChildNavigationDescriptor<TEntity, TParentId, TChild, TChildId>, IUnitOfWork (passed straight through to the base, :11-13), and the Category / CategoryItem entities.
                    • -
                    • Concept reinforced, declarative child loading over hand-written joins: the mechanism is taught in Group 11 (ADR-002); the job here is pure binding. The subclass supplies data, not an override: the base constructor takes the unit of work plus a collection-expression array of descriptors (:13-22) and owns the bulk fetch-and-assign algorithm. [Rubric §2, Design Patterns] assesses whether repetition is factored into a reusable abstraction: this is Template Method configured by data rather than by virtual methods. [Rubric §3, Clean Architecture]: the Application layer describes hydration with repository abstractions and property selectors, and no EF Core namespace appears in the file.
                    • -
                    • Walkthrough: one ChildNavigationDescriptor<Category, ConferenceCategoryIdentifierType, CategoryItem, CategoryItemIdentifierType> (:15) with four settings: PropertyName = nameof(Category.CategoryItems) (:17), ParentKeySelector = e => e.Id (:18), ChildForeignKeySelector = child => child.CategoryId (:19), and AssignAction = (e, categoryItems) => e.SetCategoryItems(categoryItems) (:20). The assign action goes through the aggregate's own mutator rather than a back-door property setter, and that mutator is internal (MMCA.ADC.Conference.Domain/Categories/Category.cs:210), reachable from here only because the Domain project grants <InternalsVisibleTo Include="MMCA.ADC.Conference.Application" /> (MMCA.ADC.Conference.Domain/MMCA.ADC.Conference.Domain.csproj:3). Note the parent-key type: the Conference module's category alias is ConferenceCategoryIdentifierType, not a bare CategoryIdentifierType, because the module also owns category items (ADR-048).
                    • -
                    • Why it's built this way: one descriptor per collection means adding a child navigation is a data edit, not a new query method, and every aggregate in the module hydrates through the same code path (ADR-002).
                    • -
                    • Where it's used: registered as the INavigationPopulator<Category> implementation, services.TryAddScoped<INavigationPopulator<Category>, ConferenceCategoryNavigationPopulator>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:66), and resolved by the read pipeline whenever a Category is loaded with CategoryItems requested.
                    • +
                    • What it is: the navigation populator for the Category aggregate, hydrating its one child collection, CategoryItems. Like every populator in this module the body is empty (MMCA.ADC.Conference.Application/Categories/ConferenceCategoryNavigationPopulator.cs:23-24) and the behavior is the descriptor passed to the base (:13-22).
                    • +
                    • Depends on: DeclarativeNavigationPopulator<TEntity> over Category (:13), one ChildNavigationDescriptor<TEntity, TParentId, TChild, TChildId> closed over Category / ConferenceCategoryIdentifierType / CategoryItem / CategoryItemIdentifierType (:15), IUnitOfWork (:12), and the Category and CategoryItem entities.
                    • +
                    • Concept introduced in this group, declarative child loading: the counterpart to the FK direction taught on ActivityNavigationPopulator. Three things change. First, ChildNavigationDescriptor.RequiresChildren is hard-coded true (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/ChildNavigationDescriptor.cs:25), so the load is gated on the caller's includeChildren flag (DeclarativeNavigationPopulator.cs:36), matching the [Navigation(IsCollection = true)] attribute that puts the property in the child bucket during discovery (MMCA.ADC.Conference.Domain/Categories/Category.cs:30-31). Second, the key pair inverts: the parent supplies its own primary key and the child supplies the FK that points back. Third, the assignment cannot be a property set, because the collection is exposed as IReadOnlyCollection<CategoryItem> over a private list (Category.cs:31).
                    • +
                    • Walkthrough: one descriptor.
                        +
                      • PropertyName = nameof(Category.CategoryItems) (:17), ParentKeySelector = e => e.Id (:18), ChildForeignKeySelector = child => child.CategoryId (:19, the FK declared at MMCA.ADC.Conference.Domain/Categories/CategoryItem.cs:27).
                      • +
                      • AssignAction = (e, categoryItems) => e.SetCategoryItems(categoryItems) (:20) calls an internal aggregate mutator (MMCA.ADC.Conference.Domain/Categories/Category.cs:210), reachable from this assembly only because the Domain project grants <InternalsVisibleTo Include="MMCA.ADC.Conference.Application" /> (MMCA.ADC.Conference.Domain/MMCA.ADC.Conference.Domain.csproj:3). Nothing outside the module can replace the collection.
                      • +
                      • The load routes through NavigationLoader.LoadChildrenPropertyAsync (ChildNavigationDescriptor.cs:41), which is one batched WHERE CategoryId IN (...parentIds) query per descriptor, not one per category.
                      -

                      CreateSponsorHandler

                      -
                      -

                      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.UseCases.Create · MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/CreateSponsorHandler.cs:16 · Level 10 · class (sealed partial)

                      -
                      -
                        -
                      • What it is: the command handler for creating a sponsor. It is the module's clearest example of the minimal create shape: map, add, save, log, project (MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/CreateSponsorHandler.cs:12-15). Compare it with CreateSessionHandler in this same group, which carries manual-id allocation, a room-scheduling check, and a duplicate-key retry; the difference between the two is a good measure of how much accidental complexity an application-assigned key buys.
                      • -
                      • Depends on: ICommandHandler<in TCommand, TResult> closed over SponsorCreateRequest and Result<SponsorDTO> (:20), IUnitOfWork (:17), IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> (:18, satisfied by SponsorCreateRequestMapper), SponsorDTOMapper (:19), the Sponsor aggregate, the SponsorDTO result shape, Result, and Microsoft.Extensions.Logging.
                      • -
                      • Concept reinforced, the handler as orchestration only: every decision this use case makes lives somewhere else. Field-level validation ran in the pipeline before the handler was reached (SponsorCreateRequestValidator); the invariants that survive a bad caller run inside Sponsor.Create; the cache eviction is declared on the request; the transaction and logging decorators wrap the call (ADR-014). What remains is nine statements. [Rubric §1, SOLID]: a single responsibility, and every collaborator is injected as an abstraction except the DTO mapper. [Rubric §6, CQRS and Event-Driven]: one message in, one result out, no query concerns mixed in.
                      • -
                      • Walkthrough: HandleAsync (:23-40) starts by asking the request mapper to build the entity, and returns early on failure, propagating the domain's error list into a typed failure with Result.Failure<SponsorDTO>(result.Errors) (:27-29), so a validation failure from Sponsor.Create reaches the API as the same error shape any other failure does (ADR-013). It then resolves the write repository from the unit of work rather than injecting IRepository<,> directly (:32), adds (:34), and saves once (:35); that single SaveChangesAsync is also where the audit fields are stamped and where any domain events raised by the factory are captured into the outbox. Success is logged through the source-generated LogSponsorCreated (:37, declared at :42-43 at Information level, recording id and name), and the entity is projected with dtoMapper.MapToDTO(entity) after the save (:39), so the returned DTO carries the database-generated Id. [Rubric §13, Observability and Operability]: [LoggerMessage] gives compile-checked, allocation-free structured logging instead of interpolated strings.
                      • -
                      • Why it's built this way: the entity is constructed through a mapper injected as an interface, so this handler never names the concrete mapper and never learns the argument order of the domain factory (ADR-001). Resolving the repository from IUnitOfWork instead of constructor-injecting a repository keeps one change-tracking scope per request, which is the pattern the whole module follows (ADR-055).
                      • -
                      • Where it's used: registered by the convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:112) and injected into SponsorsController as ICommandHandler<SponsorCreateRequest, Result<SponsorDTO>> (MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:39), behind the SponsorsManage permission (:212).
                      • -
                      • Caveats / not-in-source: nothing here checks that the target event exists or that the caller may write to it beyond the controller's permission attribute; a sponsor pointing at a missing EventId would fail at the database, not here.
                      • + +
                      • Why it's built this way: the naming deserves a note. The type is ConferenceCategoryNavigationPopulator while the entity is plain Category, and the identifier alias is ConferenceCategoryIdentifierType (MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7): Category is a word several modules would claim, so the module-qualified prefix appears on everything that is registered or aliased globally, while the entity keeps its natural name inside its own namespace. [Rubric §16, Maintainability] cares about exactly this kind of collision avoidance. The hydration rationale is ADR-002 and ADR-006, as for every populator here.
                      • +
                      • Where it's used: registered as services.TryAddScoped<INavigationPopulator<Category>, ConferenceCategoryNavigationPopulator>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:70), beside a closed-generic read service (:71) and the generic delete handler (:72). Tests: MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Categories/ConferenceCategoryNavigationPopulatorTests.cs:15-43.
                      • +
                      • Caveats / not-in-source: nothing in the descriptor filters soft-deleted items. That exclusion comes from the global query filter on the read repository the loader resolves (ADR-005), not from this file.
                      -

                      DeleteEventHandler

                      +

                      CreateSponsorHandler

                      -

                      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Delete · MMCA.ADC.Conference.Application/Events/UseCases/Delete/DeleteEventHandler.cs:17 · Level 10 · class (sealed partial)

                      +

                      MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.UseCases.Create · MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/CreateSponsorHandler.cs:16 · Level 10 · class (sealed, partial)

                        -
                      • What it is: the custom delete handler for Event. It replaces the framework's generic delete for this one entity because deleting an event has to cascade across aggregate boundaries: to sessions (BR-127) and to sponsors (MMCA.ADC.Conference.Application/Events/UseCases/Delete/DeleteEventHandler.cs:12-16).
                      • -
                      • Depends on: ICommandHandler<in TCommand, TResult> closed over DeleteEntityCommand<TEntity, TIdentifierType> (:20), IUnitOfWork (:18), IEventCascadeDeletionDomainService (:19), the Event, Session, and Sponsor aggregates, Result and Error, and logging.
                      • -
                      • Concept introduced, cascading a soft-delete across aggregates from the application layer: an aggregate may cascade to the children it owns, and Event.Delete() already does that for Rooms, EventSpeakers, and EventQuestionAnswers (BR-72). Sessions and sponsors are separate aggregate roots that merely reference the event, so nothing inside Event may reach them; the transactional script that spans them belongs one layer out, which is exactly what this handler is (:12-16). Note also what "delete" means here: nothing is removed. The soft-delete convention sets IsDeleted and the EF global query filter hides the rows afterwards (ADR-005). [Rubric §4, Domain-Driven Design] assesses whether aggregate boundaries are respected: the handler never mutates a session or a sponsor itself, it hands both collections to a domain service that calls each aggregate's own Delete(). [Rubric §8, Data Architecture]: the cascade is expressed in code rather than as a database ON DELETE CASCADE, which is what keeps it valid when these tables live in different databases.
                      • -
                      • Walkthrough:
                          -
                        • Loads the event tracked, with the three owned collections included so Event.Delete() can cascade to them (:27-32), and returns Error.NotFound stamped with source and target when it is missing (:33-34).
                        • -
                        • Loads the event's sessions tracked, each with its own children included, filtered to !s.IsDeleted so an already-deleted session is not re-processed (:37-42). The include list matters: Session.Delete() cascades to SessionSpeakers, SessionQuestionAnswers, and SessionCategoryItems (BR-55), and it can only cascade to collections that are actually loaded.
                        • -
                        • Loads the event's sponsors tracked with an empty include list, since a sponsor has no children (:46-51). The comment explains why they are in scope at all (:44-45): sponsors are their own aggregate rooted on the event, and leaving them behind would orphan rows the public sponsor strip still reads.
                        • -
                        • Delegates the whole cascade to eventCascadeDeletionDomainService.CascadeDelete(entity, sessions, sponsors) (:54), and only on success saves once and logs (:55-59). The domain service short-circuits on the first failure and the caller saves nothing, so the aborted in-memory mutations are simply discarded (MMCA.ADC.Conference.Domain/Services/EventCascadeDeletionDomainService.cs:19-33). One SaveChangesAsync for the entire cascade is what makes it atomic.
                        • -
                        • The Result from the domain service is returned unchanged (:61), so a business rule that blocks the delete surfaces with its own error, not a generic failure.
                        • +
                        • What it is: the command handler for SponsorCreateRequest. Eighteen lines of orchestration: turn the request into an entity, add it, save, log, and return the DTO (MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/CreateSponsorHandler.cs:23-40).
                        • +
                        • Depends on: IUnitOfWork (:17), IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> closed over Sponsor / SponsorCreateRequest / SponsorIdentifierType (:18, satisfied at runtime by SponsorCreateRequestMapper), the concrete SponsorDTOMapper (:19), and ILogger<CreateSponsorHandler> (:20). It implements ICommandHandler<in TCommand, TResult> with Result<SponsorDTO> as the result (:20).
                        • +
                        • Concept reinforced, the thin handler: what is absent is the lesson. There is no validation call, no transaction scope, no cache eviction, and no try/catch. Validation is applied by ValidatingCommandDecorator<TCommand, TResult> resolving SponsorCreateRequestValidator; invalidation is applied by CachingCommandDecorator<TCommand, TResult> reading CachePrefix off the request; the decorator ordering is taught in Group 05. [Rubric §1, SOLID] assesses single responsibility: this class does persistence orchestration and nothing else. [Rubric §10, Cross-Cutting]: every concern that would otherwise be copy-pasted into thirty handlers lives in a decorator.
                        • +
                        • Walkthrough: read it top to bottom.
                            +
                          • requestMapper.CreateEntityAsync(command, cancellationToken) (:27) returns a Result<Sponsor>, because entity construction can fail on a domain invariant. Failure short-circuits with the mapper's own errors and never touches the repository (:28-29).
                          • +
                          • unitOfWork.GetRepository<Sponsor, SponsorIdentifierType>() (:32) resolves the write repository from the unit of work, per call. Repositories are never constructor-injected in this codebase; asking the unit of work is what keeps the repository and the change tracker on the same scope.
                          • +
                          • AddAsync then SaveChangesAsync (:34-35), both with ConfigureAwait(false). The save is where audit stamping, the soft-delete convention, and outbox persistence happen (ADR-003 covers the outbox side).
                          • +
                          • LogSponsorCreated(logger, entity.Id, entity.Name) (:37) is a source-generated log method declared at :42-43 with [LoggerMessage(Level = LogLevel.Information, ...)]. That is why the class is partial: the generator supplies the body, and the template's {SponsorId} and {Name} become structured fields rather than a formatted string. [Rubric §13, Observability and Operability] assesses whether logs are queryable: the created id is a field, not text inside a message.
                          • +
                          • Result.Success(dtoMapper.MapToDTO(entity)) (:39) maps the just-saved entity, so the caller receives the store-assigned identity in the response body.
                        • -
                        • Why it's built this way: putting the multi-aggregate rule in EventCascadeDeletionDomainService rather than in the handler keeps the rule unit-testable without a database and keeps the handler a loader plus a saver. Registering a hand-written handler for this one command while every other entity keeps the generic DeleteEntityHandler<,> is the framework's escape hatch working as designed.
                        • -
                        • Where it's used: registered explicitly, overriding the generic delete for this entity: services.TryAddScoped<ICommandHandler<DeleteEntityCommand<Event, EventIdentifierType>, Result>, ...DeleteEventHandler>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:56). Compare the sponsor line four registrations later, which keeps the generic DeleteEntityHandler<Sponsor, SponsorIdentifierType> (:77). Invoked from the event delete endpoint on EventsController.
                        • -
                        • Caveats / not-in-source: the two GetAllAsync calls load whole entity graphs into memory to soft-delete them; for an event with a large schedule that is a substantial materialization, and nothing in this file bounds it.
                        • +
                        • Why it's built this way: the request mapper is injected as the interface while the DTO mapper is injected as the concrete class (:18-19). That asymmetry is a DI fact, not a style choice: Scrutor registers request mappers and DTO mappers AsSelfWithInterfaces (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:157-176), so either shape resolves, and taking the interface for the request mapper keeps the create slice swappable (an async uniqueness check would be a new implementation, not a handler edit).
                        • +
                        • Where it's used: registered by the ICommandHandler<,> assembly scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:178-182, invoked from MMCA.ADC.Conference.Application/DependencyInjection.cs:125), injected into SponsorsController (MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:39), and reached through the base CreateAsync that the controller's capability-gated override wraps (:211-220). Tests cover the success path, the mapper-failure short circuit, and the repository and save calls (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sponsors/UseCases/CreateSponsorHandlerTests.cs:61-133).
                        • +
                        • Caveats / not-in-source: nothing in the Application layer proves that EventId names a real event. SponsorCreateRequestValidator only requires it to be non-zero (MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:101-103), the mapper performs no lookup, and Sponsor.Create validates only name, logo URL, and booth number (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:119-122). The guarantee comes one layer down: the EF configuration declares a required FK to Event (MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SponsorConfiguration.cs:62-65), so a bogus id fails at SaveChangesAsync as a database error rather than as a validation Result.

                        EventLiveValidationService

                        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events · MMCA.ADC.Conference.Application/Events/EventLiveValidationService.cs:22 · Level 10 · class (sealed)

                          -
                        • What it is: the Conference-side implementation of IEventLiveValidationService, the narrow contract the Engagement module's conference-day live layer calls to learn whether an event, session, sponsor, or room is live and who may act on it (MMCA.ADC.Conference.Application/Events/EventLiveValidationService.cs:11-21).
                        • -
                        • Depends on: IEventLiveValidationService (the interface lives in Conference.Shared, so consumers need no reference to this assembly), IUnitOfWork and the injected TimeProvider (:22), the Event, Session, and Sponsor aggregates, SessionInvariants, CurrentEventSelector, CalendarExportMapper, the EventLiveInfo / SessionLiveInfo / SponsorLiveInfo / RoomSessionInfo payloads, and Result / Error.
                        • -
                        • Concept introduced, a cross-module read contract implemented in the owning module: Engagement owns the live experience but not the source of truth for events, sessions, sponsors, and rooms, so it asks Conference through this one interface. In process (tests, single-host runs) the container binds it here, services.TryAddScoped<IEventLiveValidationService, EventLiveValidationService>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:108); across processes the same interface is satisfied by EventLiveValidationServiceGrpcAdapter in front of EventLiveValidationGrpcService, and Engagement's code does not change (ADR-007). When the Conference module is disabled entirely, the module registration substitutes DisabledEventLiveValidationService (MMCA.ADC.Conference.API/ConferenceModule.cs:24). [Rubric §7, Microservices Readiness] assesses whether cross-module dependencies flow through interfaces a process boundary can later intercept; this type is the reason a check-in works identically in the modular monolith and in the four-service deployment. [Rubric §3, Clean Architecture]: everything runs over repository abstractions, with no EF or transport type in sight.
                        • -
                        • Walkthrough: four public lookups and two private helpers.
                            -
                          • GetEventLiveInfoAsync (:25-45) loads the event untracked with an empty include list (:30-34), returns a decorated Error.NotFound when it is missing (:36-40), and otherwise returns an EventLiveInfo carrying the published flag plus the computed UTC window (:42-44).
                          • -
                          • GetSessionLiveInfoAsync (:48-101) loads the session with includes: [nameof(Session.SessionSpeakers)] (:53-57), because the caller needs the speaker list to decide presenter rights (BR-236). It then enforces the bookmark eligibility rules by calling the domain's own invariants instead of restating them: SessionInvariants.EnsureNotServiceSession (BR-91, :67) and SessionInvariants.EnsureStatusIsEligible (BR-49, :71), converting either failure into a typed failure with the domain's error list intact (:68-73). The parent event is loaded next with the same not-found treatment (:75-86), the window is computed (:88), and the active speaker ids are projected with soft-deleted joins filtered out (:90-91). The returned SessionLiveInfo (:93-100) carries the event id, the published flag, the window, the speaker ids, IsPlenumSession, and the event's QuestionModerationDefault (BR-233), so one round trip answers every question the live layer has.
                          • -
                          • GetSponsorLiveInfoAsync (:104-140) answers the booth-visit lookup with the owning event and the sponsor's display name (:136-139). The comment on the read is the security-relevant part (:108-109): the repository applies the soft-delete query filter, so a removed sponsor answers exactly like one that never existed, which means a printed QR code for a pulled sponsor stops working with no extra check anywhere.
                          • -
                          • GetCurrentRoomSessionInfoAsync (:143-219) resolves which session a room is hosting right now, so a check-in never has to trust a client-supplied session id. It loads the room's sessions untracked (:148-153), drops any without a schedule (:157-159), and treats an empty set the same as an unknown room: NotFound (:161-165). It resolves the event and its IANA zone (:167-180), reads a single utcNow from the injected TimeProvider (:181), and clamps the caller's grace to a non-negative TimeSpan (:182). Wall-clock session times are converted with CalendarExportMapper.ToUtc (:192-193) rather than compared raw, and the comment says why (:184-186): session times are event-zone wall clock, so comparing them against a UTC instant directly would be wrong by the zone offset for the entire conference. Selection then has a deliberate priority (:197-206): an in-progress session (StartsAtUtc <= now < EndsAtUtc) always wins, and only if none is running does the upcoming-within-grace branch apply, because back-to-back sessions overlap inside the grace window and the attendee scanning the room QR is standing in the one that is actually running.
                          • -
                          • ResolveTimeZone (:223-237) degrades an unrecognized or invalid zone id to TimeZoneInfo.Utc rather than failing the lookup, matching what the now-next snapshot does. ComputeLiveWindowUtc (:242-246) is a pure delegation to CurrentEventSelector.GetLiveWindowUtc, and the comment above it states the rule (:239-241): the window (midnight to midnight in the event zone), the unknown-zone degradation, and the spring-forward-gap guard must stay identical to the ones the home surfaces and the now-next snapshot use.
                          • +
                          • What it is: Conference's answer to four questions the Engagement module's conference-day features have to ask before they will record anything: is this event published and when is it live, is this session eligible for the live layer and who speaks at it, does this sponsor exist and which event owns it, and which session is this room hosting right now. It is the implementation behind the cross-module contract IEventLiveValidationService (MMCA.ADC.Conference.Application/Events/EventLiveValidationService.cs:22).
                          • +
                          • Depends on: IUnitOfWork and the BCL TimeProvider (:22), the Event, Session, and Sponsor entities, SessionInvariants for the two eligibility rules (:67, :71), CurrentEventSelector for the live-window math (:243), CalendarExportMapper for wall-clock to UTC conversion (:192-193), and the four result records EventLiveInfo, SessionLiveInfo, SponsorLiveInfo, and RoomSessionInfo.
                          • +
                          • Concept introduced, the cross-module read contract: Engagement needs facts about conference data but must not reference Conference's entities, or the two modules could never be deployed apart. The pattern that solves it has three parts. The interface and its four record types live in MMCA.ADC.Conference.Shared (MMCA.ADC.Conference.Shared/Events/IEventLiveValidationService.cs:11), a project both sides may reference. The implementation lives here, in Conference.Application, where the entities are. And the binding is swappable: in the modular monolith this class is registered (MMCA.ADC.Conference.Application/DependencyInjection.cs:121); in a split topology the Contracts package replaces it with a gRPC adapter that implements the same interface over the wire (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/EventLiveValidationServiceGrpcAdapter.cs:25, swapped in at MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/DependencyInjection.cs:79); and in a host that does not load Conference at all, the module registers a disabled stub instead (MMCA.ADC.Conference.API/ConferenceModule.cs:21-25). Engagement's handlers see one interface in all three worlds. [Rubric §7, Microservices Readiness] assesses whether a module can be extracted without a rewrite: this is the extraction contract itself (ADR-007, ADR-008). [Rubric §9, API and Contract Design]: the DTO-like records carry only scalars and id lists, which is what keeps them serializable over gRPC unchanged.
                          • +
                          • Walkthrough: four public methods and two private helpers.
                              +
                            • GetEventLiveInfoAsync (:25-45) loads the event by id with no includes, returns Error.NotFound tagged with source and target when it is missing (:36-40), computes the window, and returns the published flag plus both boundaries (:44).
                            • +
                            • GetSessionLiveInfoAsync (:48-101) loads the session with its SessionSpeakers (:55), then applies the two bookmark-eligibility rules before anything else: EnsureNotServiceSession (BR-91, :67-69) and EnsureStatusIsEligible (BR-49, :71-73), both borrowed from the domain's own invariant helper so the live layer cannot drift from the bookmark rules. Only then does it fetch the owning event (:75-86), project the non-deleted speaker ids (:90-91), and return them with the plenum flag and the event's question-moderation default (:93-100).
                            • +
                            • GetSponsorLiveInfoAsync (:104-140) answers the printed-QR booth-visit lookup. The comment at :108-109 is the design note worth keeping: because the read repository applies the soft-delete filter, a pulled sponsor answers exactly like one that never existed, so a printed QR for a dropped sponsor simply stops working.
                            • +
                            • GetCurrentRoomSessionInfoAsync (:143-219) is the one with real logic. It loads every session in the room (:148-153), drops the ones with no schedule (:157-159), resolves the owning event from the first survivor (:167-172), converts each session's wall-clock start and end into UTC through CalendarExportMapper.ToUtc (:187-195), and then picks: an in-progress session (StartsAtUtc <= now < EndsAtUtc) wins, and only if there is none does it accept the earliest session starting inside the grace window (:199-206). The comment at :197-198 explains why the order matters: back-to-back sessions overlap inside the grace window, and the attendee scanning the room QR is standing in the one that is actually running.
                            • +
                            • ResolveTimeZone (:223-237) degrades an unrecognized or invalid IANA id to UTC instead of failing the lookup, catching both TimeZoneNotFoundException and InvalidTimeZoneException.
                            • +
                            • ComputeLiveWindowUtc (:242-246) delegates to CurrentEventSelector.GetLiveWindowUtc (MMCA.ADC.Conference.Shared/Events/CurrentEventSelector.cs:64-83) rather than repeating the rule. That shared helper defines the window as start date at 00:00 local through end date plus one day at 00:00 local (exclusive), degrades an unknown zone the same way, and handles the spring-forward gap where local midnight never existed. The comment at :239-241 states the reason plainly: the home-page countdown, the now-next snapshot, and this service must agree on when an event is live, or two surfaces disagree in front of an audience.
                            • +
                            +
                          • +
                          • Why it's built this way: TimeProvider is injected rather than DateTime.UtcNow being called, which is what makes the room-resolution rules testable at all: the suite drives them with a FixedTimeProvider (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/EventLiveValidationServiceTests.cs:388). [Rubric §14, Testability] assesses whether behavior can be exercised deterministically: the suite exercises all four methods, including back-to-back sessions, both grace-window boundaries, unknown rooms, unrecognized time zones, and unpublished events (:42-386). The grace window is a parameter, not a Conference setting, because it is check-in policy and Conference only answers the schedule question (MMCA.ADC.Conference.Shared/Events/IEventLiveValidationService.cs:50-53). [Rubric §29, Resilience]: the time-zone fallbacks mean one bad legacy row degrades a single event's window rather than throwing through every live endpoint.
                          • +
                          • Where it's used: registered as services.TryAddScoped<IEventLiveValidationService, EventLiveValidationService>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:121) and consumed exclusively by Engagement: the live-poll lifecycle handlers, the session-question submit and moderation handlers, and the check-in flows including CheckInProcessor (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/Services/CheckInProcessor.cs:34, :96), RecordRoomCheckInHandler (.../CheckIns/UseCases/RecordRoomCheckIn/RecordRoomCheckInHandler.cs:26), RecordSponsorVisitHandler (.../CheckIns/UseCases/RecordSponsorVisit/RecordSponsorVisitHandler.cs:34), SubmitQuestionHandler (.../SessionQuestions/UseCases/Submit/SubmitQuestionHandler.cs:27), and OpenLivePollHandler (.../LivePolls/UseCases/Open/OpenLivePollHandler.cs:22). In the split topology it is exposed over gRPC by EventLiveValidationGrpcService (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Grpc/EventLiveValidationGrpcService.cs:22).
                          • +
                          • Caveats / not-in-source: two edges.
                              +
                            • The first two methods resolve the write-capable repository through unitOfWork.GetRepository<...>() (:29, :52, :75) while the sponsor and room methods use GetReadRepository<...>() (:110, :123, :148, :167). Every call passes asTracking: false, so the reads are untracked either way; the inconsistency is in which repository is asked for, not in what the query does.
                            • +
                            • GetCurrentRoomSessionInfoAsync loads all sessions for the room and filters in memory (:149-159, :187-195), because the wall-clock to UTC conversion is compiled code that cannot be translated to SQL. Room-sized session counts make that fine today; nothing in the code bounds it.
                          • -
                          • Why it's built this way: centralizing "is this live, and who may act on it" behind one interface, and reusing the domain invariants and the shared window math instead of copying them, gives both modules one authoritative answer. Injecting TimeProvider rather than reading DateTime.UtcNow makes the room lookup testable at an exact instant. [Rubric §14, Testability]: a fake clock can place "now" precisely on a session boundary or inside the grace window.
                          • -
                          • Where it's used: by Engagement's live layer and check-in paths, all through the interface: CheckInProcessor (MMCA.ADC.Engagement.Application/CheckIns/Services/CheckInProcessor.cs:104, :114), RecordSponsorVisitHandler (MMCA.ADC.Engagement.Application/CheckIns/UseCases/RecordSponsorVisit/RecordSponsorVisitHandler.cs:58), RecordRoomCheckInHandler (MMCA.ADC.Engagement.Application/CheckIns/UseCases/RecordRoomCheckIn/RecordRoomCheckInHandler.cs:52), SubmitQuestionHandler (MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/Submit/SubmitQuestionHandler.cs:37), and OpenLivePollHandler (MMCA.ADC.Engagement.Application/LivePolls/UseCases/Open/OpenLivePollHandler.cs:53, :73), among the other live-poll and moderation handlers.
                          • -
                          • Caveats / not-in-source: GetCurrentRoomSessionInfoAsync loads every session assigned to the room, filters and converts them in memory, and only then narrows to the resolved event (:187-195); nothing bounds that set by date. The room-to-event resolution also takes the first scheduled session's EventId (:169), so a room reused across two events resolves against whichever row comes back first.

                          EventNavigationPopulator

                          MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events · MMCA.ADC.Conference.Application/Events/EventNavigationPopulator.cs:11 · Level 10 · class (sealed)

                            -
                          • What it is: the declarative navigation populator for the Event aggregate. It loads the three child collections the read path cannot materialize through .Include() on this model (MMCA.ADC.Conference.Application/Events/EventNavigationPopulator.cs:7-9).
                          • -
                          • Depends on: DeclarativeNavigationPopulator<TEntity>, ChildNavigationDescriptor<TEntity, TParentId, TChild, TChildId>, IUnitOfWork, and the Event, Room, EventSpeaker, and EventQuestionAnswer entities.
                          • -
                          • Concept reinforced: the same declarative loading taught on ConferenceCategoryNavigationPopulator and in Group 11; this class simply carries three descriptors instead of one, and its body is likewise empty (:37-38). [Rubric §2, Design Patterns]: adding a child collection is one descriptor, not a new query method.
                          • -
                          • Walkthrough: three descriptors, all keyed on Event.Id against the child's EventId. Rooms assigned through SetRooms (:15-21), EventSpeakers through SetEventSpeakers (:22-28), and EventQuestionAnswers through SetEventQuestionAnswers (:29-35). The AssignAction targets are worth a look: all three Set* methods are internal on the aggregate (MMCA.ADC.Conference.Domain/Events/Event.cs:500, :596, :673), reachable from here only because the Domain project grants <InternalsVisibleTo Include="MMCA.ADC.Conference.Application" /> (MMCA.ADC.Conference.Domain/MMCA.ADC.Conference.Domain.csproj:3). Hydration therefore goes through the domain's own guarded mutators while staying closed to every other assembly.
                          • -
                          • Why it's built this way: an aggregate that exposed public collection setters would be mutable by anyone; an aggregate with no setters at all could not be hydrated. The internal plus InternalsVisibleTo pairing grants the populator exactly the access it needs and nobody else any (ADR-002).
                          • -
                          • Where it's used: registered as INavigationPopulator<Event> (MMCA.ADC.Conference.Application/DependencyInjection.cs:54) and resolved by the read pipeline whenever an Event is loaded with these navigations requested. DeleteEventHandler bypasses it by naming the same three collections in an explicit includes list, because it needs them tracked.
                          • +
                          • What it is: the navigation populator for the Event aggregate, the largest one in the module: three child-collection descriptors for Rooms, EventSpeakers, and EventQuestionAnswers (MMCA.ADC.Conference.Application/Events/EventNavigationPopulator.cs:14-36). The body is empty (:37-38).

                            +
                          • +
                          • Depends on: DeclarativeNavigationPopulator<TEntity> over Event (:13), three ChildNavigationDescriptor<TEntity, TParentId, TChild, TChildId> instances (:15, :22, :29), IUnitOfWork (:12), and the Event, Room, EventSpeaker, and EventQuestionAnswer entities.

                            +
                          • +
                          • Concept reinforced: child-collection binding is taught on ConferenceCategoryNavigationPopulator. What this class adds is scale (three descriptors evaluated in declaration order, DeclarativeNavigationPopulator.cs:34-41) and one genuinely instructive mismatch, in the caveat below.

                            +
                          • +
                          • Walkthrough: all three descriptors key on Event.Id against the child's EventId, and each supplies the same four settings.

                            +
                            + + + + + + + + + + + + + + + + + + + + + + +
                            DescriptorFile:LineProperty, parent key, child FK, assign
                            ChildNavigationDescriptor<Event, EventIdentifierType, Room, RoomIdentifierType>MMCA.ADC.Conference.Application/Events/EventNavigationPopulator.cs:15-21nameof(Event.Rooms), e => e.Id, child => child.EventId, SetRooms
                            ChildNavigationDescriptor<Event, EventIdentifierType, EventSpeaker, EventSpeakerIdentifierType>MMCA.ADC.Conference.Application/Events/EventNavigationPopulator.cs:22-28nameof(Event.EventSpeakers), e => e.Id, child => child.EventId, SetEventSpeakers
                            ChildNavigationDescriptor<Event, EventIdentifierType, EventQuestionAnswer, EventQuestionAnswerIdentifierType>MMCA.ADC.Conference.Application/Events/EventNavigationPopulator.cs:29-35nameof(Event.EventQuestionAnswers), e => e.Id, child => child.EventId, SetEventQuestionAnswers
                            +
                              +
                            • All three assign through internal aggregate mutators, SetRooms (MMCA.ADC.Conference.Domain/Events/Event.cs:529), SetEventSpeakers (:625), and SetEventQuestionAnswers (:702), reachable only because the Domain project grants <InternalsVisibleTo Include="MMCA.ADC.Conference.Application" /> (MMCA.ADC.Conference.Domain/MMCA.ADC.Conference.Domain.csproj:3). The collections themselves are IReadOnlyCollection<> over private lists (Event.cs:89, :95, :109).
                            • +
                            • Each descriptor's load is one batched WHERE EventId IN (...) query (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/ChildNavigationDescriptor.cs:41), so a fully hydrated page of events costs three extra queries, not three per event.
                            • +
                            • All three are gated on includeChildren, because ChildNavigationDescriptor.RequiresChildren is true (ChildNavigationDescriptor.cs:25).
                            -

                            PublicConferenceVisibility

                            +
                          • +
                          • Why it's built this way: the entity type parameters are what make this survivable under extraction. Room and EventSpeaker may end up in a different physical source than Event, at which point .Include() stops being an option and only a batched key lookup can hydrate them (ADR-002, ADR-006). [Rubric §8, Data Architecture] assesses how relationships are expressed: a cross-source parent-child link degrades to a scalar FK plus an IN lookup, and that is precisely what these three declarations are.

                            +
                          • +
                          • Where it's used: registered as services.TryAddScoped<INavigationPopulator<Event>, EventNavigationPopulator>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:58), beside the closed-generic read service (:59) and the module's own delete handler (:60). Tests: MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/EventNavigationPopulatorTests.cs:15-43.

                            +
                          • +
                          • Caveats / not-in-source: the third descriptor cannot fire through the populator path as the code stands. Event.EventQuestionAnswers is deliberately not marked [Navigation] (MMCA.ADC.Conference.Domain/Events/Event.cs:100-109 documents why: the collection grows with attendance rather than with the schedule, and it rode along on public reads that never render it). Navigation discovery is attribute-driven (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/NavigationMetadataProvider.cs:74-78), so the property never appears in UnsupportedIncludes, and the base's unsupportedPropertyNames.Contains(descriptor.PropertyName) test never matches it (DeclarativeNavigationPopulator.cs:37). Handlers that need those answers pass an explicit includes: list instead, which is exactly what the entity's own remark instructs. The descriptor is harmless and would become live again the moment the attribute returned.

                            +
                          • +
                          +

                          EventQuestionAnswerNavigationPopulator

                          -

                          MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Common · MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:28 · Level 10 · class (static)

                          +

                          MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events · MMCA.ADC.Conference.Application/Events/EventQuestionAnswerNavigationPopulator.cs:11 · Level 10 · class (sealed)

                            -
                          • What it is: the one definition of what an anonymous or non-privileged caller may read from the conference catalog, expressed as id lists that callers turn into Id IN (...) specifications. Closing a visibility leak in this file closes it on every public read at once (MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:10-15).
                          • -
                          • Depends on: IUnitOfWork (read repositories only), the Event, Session, and SessionSpeaker entities, PublicSessionStatusSpecification (the BR-49 status allow-list), CrossSourceSpecification, AndSpecification<TEntity, TIdentifierType>, and InlineSpecification<TEntity, TIdentifierType>.
                          • -
                          • Concept introduced, authorization by id list instead of authorization by join: the three rules are stated in the remarks (:17-21): an event is visible when published (BR-108); a session is visible when its event is visible AND its status is on the BR-49 allow-list; a speaker is visible when they have at least one eligible session inside the scoped published-event set (BR-239). Every rule is resolved as a scalar id projection, never as a navigation join such as s.Event.IsPublished (:22-26). Three things follow: the criteria stay translatable on any engine (the polyglot safeguard of ADR-018), each aggregate keeps a by-id boundary to the others so a future extraction can answer the same question over a service call (ADR-007), and the resulting specifications pass the architecture fitness test that bans navigation-property filters. [Rubric §11, Security] assesses whether authorization is centralized and fail-closed: this class is a single choke point, and every "nothing visible" path returns an empty list, which callers render as an IN () matching no rows rather than as an unfiltered read. [Rubric §7, Microservices Readiness] and [Rubric §8, Data Architecture]: id lists cross an aggregate boundary; joins do not.
                          • -
                          • Walkthrough: three public resolvers plus one private helper, and the ordering between them is the rule hierarchy.
                              -
                            • GetPublishedEventIdsAsync (:36-47) projects e.Id where e.IsPublished, untracked (:42), then materializes once so callers embed a stable collection EF can translate into an IN (:45-46). That materialize-once comment is load-bearing: re-enumerating a lazy sequence inside an expression tree is what makes such a filter fail to translate.
                            • -
                            • GetVisibleSessionIdsAsync (:56-77) builds the event scoping through CrossSourceSpecification.BuildAsync, passing principalPredicate: e => e.IsPublished, dependentForeignKey: s => s.EventId, and localPredicate: PublicSessionStatusSpecification.StatusCriteria (:62-69), then projects the matching session ids (:71-74). It uses the same helper and the same criteria the public session read filter uses, so a session hidden from the session list can never stay reachable through a speaker or junction read (:60-61).
                            • -
                            • GetVisibleSpeakerIdsAsync (:99-127) takes an optional eventId scope. With a scope, the published-event set is narrowed to that one id, and an unpublished or unknown scoped event narrows it to empty rather than raising an error (:108-112), which is the fail-closed reading of BR-108. An empty scope, or an empty eligible-session set, short-circuits to [] (:114-119). Otherwise it projects the distinct SpeakerId values off the SessionSpeaker join for those sessions (:121-126). The remarks record a real leak this shape fixed (:92-98): the EventSpeaker join is deliberately NOT consulted, because the Sessionize import writes a row there for every speaker in the response, so reading it as a visibility grant published the whole imported roster and made the filter vacuous. The session link is the only acceptance signal a speaker carries.
                            • -
                            • GetEligibleSessionIdsAsync (:134-151) is the private variant the speaker rule uses when it needs to narrow an already resolved event scope. It ANDs a PublicSessionStatusSpecification instance with an InlineSpecification over the scoped id list (:141-143), keeping the criteria a translatable IN filter with no navigation join, and reuses the one status allow-list rather than restating it.
                            • +
                            • What it is: the FK populator for EventQuestionAnswer, hydrating each answer's parent Event reference.
                            • +
                            • Depends on: DeclarativeNavigationPopulator<TEntity> over EventQuestionAnswer (:13), one FKNavigationDescriptor<TEntity, TChild, TChildId> closed over EventQuestionAnswer / Event / EventIdentifierType (:15), and IUnitOfWork (:12).
                            • +
                            • Concept reinforced: mechanically identical to ActivityNavigationPopulator, which teaches the FK descriptor, the includeFKs gate, and the batched loader.
                            • +
                            • Walkthrough: PropertyName = nameof(EventQuestionAnswer.Event) (:17), ParentKeySelector = e => e.EventId (:18, the get-only FK at MMCA.ADC.Conference.Domain/Events/EventQuestionAnswer.cs:26), ChildForeignKeySelector = child => child.Id (:19), and AssignAction = (e, events) => e.Event = events.FirstOrDefault() (:20) writing the settable navigation (EventQuestionAnswer.cs:22-23).
                            • +
                            • Why it's built this way: note the asymmetry with the parent side. Event.EventQuestionAnswers carries no [Navigation] attribute, so the forward collection is not auto-hydrated (see the caveat on EventNavigationPopulator), while EventQuestionAnswer.Event is attributed, so a read that starts at the answer can still reach its event. The direction that is cheap and bounded is enabled; the direction that is unbounded is not. [Rubric §12, Performance and Scalability] is the reason the two directions are configured differently.
                            • +
                            • Where it's used: registered as INavigationPopulator<EventQuestionAnswer> (MMCA.ADC.Conference.Application/DependencyInjection.cs:98) alongside the closed-generic read service (:99). Tests: MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/EventQuestionAnswerNavigationPopulatorTests.cs:15-43.
                            -
                          • -
                          • Why it's built this way: a single shared definition is the only way six independent read handlers can agree on what "public" means. The two entry points into PublicSessionStatusSpecification (the raw StatusCriteria expression for composition, and an instance for specification algebra) exist precisely so this class can use whichever form each call site needs without a second copy of the predicate (ADR-055).
                          • -
                          • Where it's used: six public-read filter handlers call it, all under MMCA.ADC.Conference.Application/: GetPublicSessionSpeakerFilterHandler (Sessions/UseCases/GetPublicSessionSpeakerFilter/GetPublicSessionSpeakerFilterHandler.cs:24), GetPublicSessionCategoryItemFilterHandler (Sessions/UseCases/GetPublicSessionCategoryItemFilter/GetPublicSessionCategoryItemFilterHandler.cs:25), GetPublicSpeakerFilterHandler (Speakers/UseCases/GetPublicSpeakerFilter/GetPublicSpeakerFilterHandler.cs:26), GetPublicSpeakerCategoryItemFilterHandler (Speakers/UseCases/GetPublicSpeakerCategoryItemFilter/GetPublicSpeakerCategoryItemFilterHandler.cs:26), GetPublicSponsorFilterHandler (Sponsors/UseCases/GetPublicSponsorFilter/GetPublicSponsorFilterHandler.cs:25, which needs only the published-event rule and turns it into a Sponsor.EventId IN (...) filter), and GetPublicEventSpeakerFilterHandler, which calls two of the three resolvers (Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterHandler.cs:31, :38).
                          • -
                          • Caveats / not-in-source: the class is static and takes its IUnitOfWork per call, so it is not injectable and cannot be substituted in a test; callers are tested against it directly. Cost is the other thing the source does not hide: the speaker rule issues up to three sequential round trips (events, sessions, join rows), which is the price of avoiding a cross-aggregate join.
                          • +

                            EventSpeakerNavigationPopulator

                            +
                            +

                            MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events · MMCA.ADC.Conference.Application/Events/EventSpeakerNavigationPopulator.cs:11 · Level 10 · class (sealed)

                            +
                            +
                              +
                            • What it is: the FK populator for the EventSpeaker junction, hydrating its parent Event reference.
                            • +
                            • Depends on: DeclarativeNavigationPopulator<TEntity> over EventSpeaker (:13), one FKNavigationDescriptor<TEntity, TChild, TChildId> closed over EventSpeaker / Event / EventIdentifierType (:15), and IUnitOfWork (:12).
                            • +
                            • Concept reinforced: the FK mechanism is taught on ActivityNavigationPopulator; the four settings here are nameof(EventSpeaker.Event) (:17), e => e.EventId (:18, MMCA.ADC.Conference.Domain/Events/EventSpeaker.cs:23), child => child.Id (:19), and the FirstOrDefault assignment into the settable navigation (:20, EventSpeaker.cs:19-20).
                            • +
                            • Why it's built this way: the junction carries only the two parent references, so hydrating the event side is the difference between a usable association read and a row of bare integers. Note that the speaker side is not declared here: EventSpeaker has no Speaker navigation descriptor in this file, which is consistent with speakers being reachable through their own aggregate.
                            • +
                            • Where it's used: registered as INavigationPopulator<EventSpeaker> (MMCA.ADC.Conference.Application/DependencyInjection.cs:95) beside its read service (:96). Tests: MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/EventSpeakerNavigationPopulatorTests.cs:15-43.
                            • +
                            • Caveats / not-in-source: this junction is also the one the public-visibility rules refuse to trust as an acceptance signal, because the Sessionize import writes a row for every speaker in the response; see PublicConferenceVisibility (MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:97-103). Hydration and visibility are separate concerns here, and this file only does the former.
                            -

                            RemoveSessionQuestionAnswerHandler

                            +

                            PublicConferenceVisibility

                            -

                            MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionQuestionAnswer · MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionQuestionAnswer/RemoveSessionQuestionAnswerHandler.cs:14 · Level 10 · class (sealed partial)

                            +

                            MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Common · MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:28 · Level 10 · class (static)

                              -
                            • What it is: the handler for RemoveSessionQuestionAnswerCommand. It is the load-then-mutate-through-the-aggregate shape with a per-record ownership guard in front of it (MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionQuestionAnswer/RemoveSessionQuestionAnswerHandler.cs:10-13).
                            • -
                            • Depends on: ICommandHandler<in TCommand, TResult>, IUnitOfWork, ICurrentUserService and RoleNames (:14-17), the Session aggregate and its SessionQuestionAnswer child, Result / Error, and logging.
                            • -
                            • Concept introduced, per-record ownership authorization inside the command slice (BR-52 / BR-53): role-based attributes on a controller can say "an attendee may call this endpoint", but they cannot say "an attendee may delete this row". That second decision needs the record, so it lives here. After loading the session with its answers tracked (:24-29), the handler finds the target among the non-soft-deleted answers and, if the caller is not an Organizer and did not create it, returns Error.Forbidden with code SessionQuestionAnswer.NotOwner (:34-42). The ownership fact comes from answer.CreatedBy, the audit field stamped by the persistence layer, compared against currentUserService.UserId; the client never supplies it. [Rubric §11, Security] assesses whether authorization is enforced at the right granularity and fails closed: this is record-level, server-derived, and organizer-exempt by an explicit role check. [Rubric §6, CQRS and Event-Driven]: the decision lives in the slice that owns the operation rather than scattered across controller attributes.
                            • -
                            • Walkthrough: HandleAsync (:20-52) resolves the write repository (:24), loads with asTracking: true (:28, which is not incidental: the aggregate mutation has to be observed by the change tracker for the save to emit anything), and returns Error.NotFound stamped with handler and Session when absent (:30-31). The guard is written so that a missing answer fails answer is not null (:35) and falls straight through to the domain call, which is what produces the not-found style failure instead of a misleading 403. Removal itself is delegated to entity.RemoveSessionQuestionAnswer(...) (:44) so the aggregate enforces its own invariants, and only on success does it save and log through the generated LogQuestionAnswerRemovedFromSession (:45-49, declared :54-55). The domain Result is returned unchanged either way (:51).
                            • -
                            • Why it's built this way: keeping removal logic in the aggregate and cache eviction on the command leaves the handler as pure orchestration: load, authorize, delegate, save, log. [Rubric §13, Observability and Operability]: logging goes through a source-generated [LoggerMessage] partial, the compile-checked, allocation-free pattern used on every handler in this module.
                            • -
                            • Where it's used: registered by the convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:112), injected into SessionQuestionAnswersController (MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:60) and invoked from its delete endpoint (:217).
                            • -
                            • Caveats / not-in-source: the guard dereferences currentUserService.UserId!.Value (:35), so an unauthenticated caller reaching this handler would throw rather than be rejected; it is only safe because the endpoint sits behind authentication.
                            • +
                            • What it is: the single definition of what an anonymous or non-privileged caller may see in the conference catalog, expressed as three id-list resolvers that the public read filters turn into IN (...) specifications (MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:10-15). Three rules: an event is visible when published (BR-108), a session is visible when its event is visible and its status is on the BR-49 allow-list, and a speaker is visible when they have at least one eligible session inside the scoped published-event set (BR-239) (:18-21).
                            • +
                            • Depends on: IUnitOfWork and IEntityQuerier<TEntity, TIdentifierType> (:40, :75, :126-127, :151), CrossSourceSpecification (:63), InlineSpecification<TEntity, TIdentifierType> (:149), PublicSessionStatusSpecification (:68, :148), and the Event, Session, and SessionSpeaker entities.
                            • +
                            • Concept introduced, authorization as a translatable data filter: read authorization here is not a check that runs after the query, it is the query. Every rule is resolved into a materialized list of ids and embedded in a predicate, never expressed as a navigation join. The file states the three reasons (:22-26): the criteria stay translatable on any engine (ADR-018), each aggregate keeps its by-id boundary to the others, and the results pass the specification fitness test. [Rubric §11, Security] assesses whether authorization is enforced where the data is read rather than in a view: because one helper backs the session, speaker, sponsor, room, activity, and junction filters, closing a leak in one place closes it everywhere, which is the property that motivates the whole file. [Rubric §8, Data Architecture]: a cross-aggregate rule becomes a scalar projection plus an IN, the shape a split topology can still execute.
                            • +
                            • Walkthrough: three public resolvers and one private helper.
                                +
                              • GetPublishedEventIdsAsync (:36-48) resolves the read repository as an IEntityQuerier<TEntity, TIdentifierType> (:40) and projects ids with a predicate in one call, GetProjectedAsync(e => e.Id, e => e.IsPublished, asTracking: false, ...) (:42-44). The result is materialized once so callers embed a stable collection EF can translate (:46-47).
                              • +
                              • GetVisibleSessionIdsAsync (:57-82) delegates the two-source AND to CrossSourceSpecification.BuildAsync (:63-70), passing e => e.IsPublished as the principal predicate, s => s.EventId as the dependent FK, and PublicSessionStatusSpecification.StatusCriteria as the local predicate. That helper runs the principal projection first and returns an inline specification whose criteria is localPredicate AND principalKeys.Contains(fk), built as an expression tree with no Expression.Invoke so it stays translatable on every provider (MMCA.Common/Source/Core/MMCA.Common.Application/Specifications/CrossSourceSpecification.cs:55-88). The specification then reaches the repository as a specification: ListAsync(specification, s => s.Id, cancellationToken) (:77-79) is the untracked, soft-delete-filtered projection that the explicit argument list used to spell out (:72-74).
                              • +
                              • GetVisibleSpeakerIdsAsync (:104-134) takes an optional event scope. It resolves the published set first (:109), and when a scope is supplied it narrows to that single event only if the event is published, otherwise to the empty list (:113-117); an unpublished or unknown scoped event is not an error, it simply has no public speakers (:111-112). Empty scope and empty eligible-session set both short-circuit to [] (:119-124), then the join table is projected with eligibleSessionIds.Contains(ss.SessionId) and de-duplicated (:126-133).
                              • +
                              • GetEligibleSessionIdsAsync (:141-158) is the private narrowing variant: new PublicSessionStatusSpecification().And(new InlineSpecification<Session, SessionIdentifierType>(s => scopedEventIds.Contains(s.EventId))) (:148-149), which keeps the criteria a translatable IN filter with no navigation join (:146-147).
                              -

                              RemoveSessionSpeakerHandler

                              +
                            • +
                            • Why it's built this way: the remark at :97-103 is the most load-bearing comment in the file, and it records a real rule, not a preference. The EventSpeaker junction is deliberately not treated as a visibility grant, because the Sessionize import writes a row there for every speaker in the response, so reading it as acceptance would publish the entire imported roster and make the filter vacuous. The session link is the only acceptance signal a speaker carries, so it is the only path consulted: a speaker whose sessions are all waitlisted or declined, and one linked to nothing, both stay hidden. [Rubric §4, DDD]: the rule is stated in the vocabulary of the business (published, accepted, assigned) and lives beside the aggregates it constrains.
                            • +
                            • Where it's used: by the eight public-filter query handlers, one per publicly readable entity or junction: GetPublicSponsorFilterHandler (MMCA.ADC.Conference.Application/Sponsors/UseCases/GetPublicSponsorFilter/GetPublicSponsorFilterHandler.cs:25), GetPublicActivityFilterHandler (.../Activities/UseCases/GetPublicActivityFilter/GetPublicActivityFilterHandler.cs:25), GetPublicRoomFilterHandler (.../Events/UseCases/GetPublicRoomFilter/GetPublicRoomFilterHandler.cs:25), GetPublicSpeakerFilterHandler (.../Speakers/UseCases/GetPublicSpeakerFilter/GetPublicSpeakerFilterHandler.cs:26-31, which passes the query's optional event scope straight through), GetPublicSpeakerCategoryItemFilterHandler (.../Speakers/UseCases/GetPublicSpeakerCategoryItemFilter/GetPublicSpeakerCategoryItemFilterHandler.cs:26), GetPublicSessionSpeakerFilterHandler (.../Sessions/UseCases/GetPublicSessionSpeakerFilter/GetPublicSessionSpeakerFilterHandler.cs:24), GetPublicSessionCategoryItemFilterHandler (.../Sessions/UseCases/GetPublicSessionCategoryItemFilter/GetPublicSessionCategoryItemFilterHandler.cs:25), and GetPublicEventSpeakerFilterHandler (.../Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterHandler.cs:31-44), which calls two resolvers so a junction row follows the visibility of both its parents.
                            • +
                            • Caveats / not-in-source: the id lists are materialized and embedded, and the framework says so: the helper fits principal sets that are small and bounded, the "published events" shape (MMCA.Common/Source/Core/MMCA.Common.Application/Specifications/CrossSourceSpecification.cs:17-20). A single call to GetVisibleSpeakerIdsAsync issues three sequential round trips (events, eligible sessions, join rows), and the junction handler calls two resolvers, reading the bounded Event table twice; that trade is stated in the handler itself rather than optimized away (GetPublicEventSpeakerFilterHandler.cs:35-37). Nothing in this file caches any of it.
                            • +
                            +

                            RoomNavigationPopulator

                            -

                            MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionSpeaker · MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionSpeaker/RemoveSessionSpeakerHandler.cs:13 · Level 10 · class (sealed partial)

                            +

                            MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events · MMCA.ADC.Conference.Application/Events/RoomNavigationPopulator.cs:11 · Level 10 · class (sealed)

                              -
                            • What it is: the handler for RemoveSessionSpeakerCommand. The same load-delegate-save shape as its question-answer sibling, minus the ownership guard and plus a fallback for resolving the owning session when the command omits SessionId (MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionSpeaker/RemoveSessionSpeakerHandler.cs:9-12).
                            • -
                            • Depends on: ICommandHandler<in TCommand, TResult>, IUnitOfWork, the Session aggregate and its SessionSpeaker child, Result / Error, and logging.
                            • -
                            • Concept introduced, resolving the parent aggregate from a join id: the DELETE endpoint takes the session id as an optional query parameter, but the UI's generic delete sends only the join-entity id, so SessionId arrives as the default 0; the comment records exactly this (:24-26). When SessionId == default the handler queries for the session whose SessionSpeakers contains the join id and takes the first match (:28-36); otherwise it loads by id directly (:37-44). Both branches include SessionSpeakers and load asTracking: true, so the two paths hand the domain call an identically hydrated aggregate. [Rubric §9, API and Contract Design] assesses whether the contract absorbs real client behavior without weakening the model: the handler adapts, while the domain call still targets exactly one aggregate. [Rubric §12, Performance and Scalability]: the Any(...)-predicate scan runs only on the id-less path, not on the hot one.
                            • -
                            • Walkthrough: after resolving the session (or returning Error.NotFound at :46-47), it delegates to entity.RemoveSessionSpeaker(...) (:49), then saves and logs on success through the generated LogSpeakerRemovedFromSession (:50-54, declared :59-60). The log statement writes command.SessionSpeakerId and command.SessionId (:53), so on the fallback path it records the defaulted 0 rather than the resolved session id: a small telemetry gap worth knowing about when reading these logs.
                            • -
                            • Where it's used: registered by the convention scan; injected into SessionSpeakersController (MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:50) and invoked from its delete endpoint (:241), which also evicts the sessions output cache afterwards (:249).
                            • +
                            • What it is: the FK populator for Room, hydrating each room's parent Event reference.
                            • +
                            • Depends on: DeclarativeNavigationPopulator<TEntity> over Room (:13), one FKNavigationDescriptor<TEntity, TChild, TChildId> closed over Room / Event / EventIdentifierType (:15), and IUnitOfWork (:12).
                            • +
                            • Concept reinforced: identical in mechanism to ActivityNavigationPopulator. The settings are nameof(Room.Event) (:17), e => e.EventId (:18, the get-only FK at MMCA.ADC.Conference.Domain/Events/Room.cs:37), child => child.Id (:19), and the FirstOrDefault assignment into the settable, [Navigation]-attributed property (:20, Room.cs:33-34).
                            • +
                            • Why it's built this way: rooms are the one child of Event that is read from both ends in production. EventNavigationPopulator hydrates Event.Rooms for an event-first read, and this class hydrates Room.Event for a room-first read, both through the same base and both as batched key lookups (ADR-002). [Rubric §7, Microservices Readiness]: neither direction assumes the two entities share a database.
                            • +
                            • Where it's used: registered as INavigationPopulator<Room> (MMCA.ADC.Conference.Application/DependencyInjection.cs:89) beside the closed-generic read service for rooms (:90). Tests: MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/RoomNavigationPopulatorTests.cs:15-43.

                            SponsorCreateRequestMapper

                            MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.UseCases.Create · MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestMapper.cs:11 · Level 10 · class (sealed)

                              -
                            • What it is: the adapter that turns a validated SponsorCreateRequest into a Sponsor entity by calling the aggregate's Create factory (MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestMapper.cs:7-9).
                            • -
                            • Depends on: IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> closed over Sponsor / SponsorCreateRequest / SponsorIdentifierType (:11-12), the Sponsor factory, and Result.
                            • -
                            • Concept reinforced, request-to-entity mapping as its own step: the create pipeline separates shaping the input (this mapper) from orchestrating the use case (CreateSponsorHandler). CreateEntityAsync (:15-32) guards the null request with ArgumentNullException.ThrowIfNull (:17), then forwards the request fields positionally into Sponsor.Create(...) (:19-31), returning that factory's Result<Sponsor> wrapped in an already-completed Task. There is no async here at all: the mapping is synchronous, and the Task exists only to satisfy an interface other entities implement with genuinely asynchronous lookups. [Rubric §1, SOLID]: single responsibility, the mapper knows the factory's argument order and nothing else. Manual mapping over reflection-based mapping follows ADR-001.
                            • -
                            • Walkthrough: twelve positional arguments in factory order: Id, Name, Tier, LogoUrl, Description, WebsiteUrl, LinkedInUrl, TwitterHandle, Sort, EventId, IsExhibitor, BoothNumber (:20-31), matching Sponsor.Create exactly (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:105-117). Unlike the session mapper, no request field is dropped: every property on SponsorCreateRequest reaches the factory. The Id it passes is widened to the factory's nullable parameter and then discarded, because the factory checks typeof(Sponsor).IsIdValueGenerated and keeps the database default when the key is store-generated (Sponsor.cs:126, :130).
                            • -
                            • Why it's built this way: positional forwarding into a factory means the domain decides which combinations are legal, and the compiler catches an argument-order change at the one place that knows it. The factory validates name, logo URL, and booth number through SponsorInvariants before constructing anything (Sponsor.cs:119-124) and raises a SponsorChanged domain event on the new instance (:133), so a create is never a silent write.
                            • -
                            • Where it's used: injected into CreateSponsorHandler as IEntityRequestMapper<Sponsor, SponsorCreateRequest, SponsorIdentifierType> (MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/CreateSponsorHandler.cs:18), so the handler is constructed against the interface and this class is named only at registration (the convention scan, MMCA.ADC.Conference.Application/DependencyInjection.cs:112).
                            • +
                            • What it is: the one place that knows how to turn a SponsorCreateRequest into a Sponsor. It does not construct the entity itself; it calls the domain factory and hands back whatever Result<Sponsor> that factory returns (MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestMapper.cs:19-31).
                            • +
                            • Depends on: IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> closed over Sponsor / SponsorCreateRequest / SponsorIdentifierType (:12), the Sponsor aggregate and its Create factory, and Result (:3).
                            • +
                            • Concept reinforced, request mapping is not object mapping: SponsorDTOMapper can be source-generated because its target is a settable record. This mapper cannot, because its target is a factory that returns a Result<T>: the entity's constructor is private and the only way in runs the invariants first. So the direction out of the domain is generated and the direction into it is hand-written, which is exactly the split ADR-001 describes. [Rubric §4, DDD] assesses whether invariants are unavoidable: there is no path from a request to a Sponsor that skips Sponsor.Create.
                            • +
                            • Walkthrough: one method.
                                +
                              • ArgumentNullException.ThrowIfNull(request) (:17) guards the reference the interface does not declare as nullable.
                              • +
                              • Task.FromResult(Sponsor.Create(...)) (:19-31) forwards twelve values in the factory's parameter order. There is no await because there is no I/O: the method is Task-returning to satisfy the interface (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEntityDTOMapper.cs:54), not because anything is asynchronous, and wrapping a completed value avoids allocating a state machine.
                              • +
                              • The work then happens in the domain: Sponsor.Create (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:105-136) combines three invariant checks for name, logo URL, and booth number (:119-122), decides whether to honor or discard the supplied id based on IsIdValueGenerated (:126-131), and raises the SponsorChanged domain event with DomainEntityState.Added before returning success (:133).
                              • +
                              • request.Id is a non-nullable int widened to the factory's SponsorIdentifierType? parameter (Sponsor.cs:106), which is why the request can declare a plain value type and still reach a nullable factory slot.
                              • +
                              +
                            • +
                            • Why it's built this way: keeping the factory call behind an interface means the create slice can grow an asynchronous pre-check (a uniqueness lookup, for example) by changing this one class, with no edit to CreateSponsorHandler, which injects the interface (CreateSponsorHandler.cs:18). [Rubric §1, SOLID]: dependency inversion applied at the smallest useful granularity.
                            • +
                            • Where it's used: registered by the IEntityRequestMapper<,,> assembly scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:172-176, invoked from MMCA.ADC.Conference.Application/DependencyInjection.cs:125) and resolved into CreateSponsorHandler (CreateSponsorHandler.cs:18, called at :27).
                            • +
                            • Caveats / not-in-source: the async signature is currently unused, and no existence or uniqueness check happens here today; see the caveat on CreateSponsorHandler for what does and does not verify EventId.

                            SponsorCreateRequestValidator

                            MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.UseCases.Create · MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestValidator.cs:7 · Level 10 · class (sealed)

                              -
                            • What it is: the FluentValidation validator for SponsorCreateRequest. It composes reusable per-field rule sets rather than restating each rule inline (MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestValidator.cs:6).
                            • -
                            • Depends on: FluentValidation (AbstractValidator<T> and its Include, :1, :7) and the shared Sponsor*Rules<T> classes from MMCA.ADC.Conference.Application.Sponsors.Validation (:2).
                            • -
                            • Concept reinforced, composed validation via reusable rule includes: the constructor (:9-20) calls Include(...) once per field, each rule class generic over the request type and constructed with a property selector, for example Include(new SponsorNameRules<SponsorCreateRequest>(p => p.Name)) (:11). Include folds the other validator's rules into this one, so the composite reports a single flat error list. Because the rule classes are generic over the request type, the create and the update path share the identical rule shapes: SponsorUpdateRequestValidator includes the same eight of them closed over SponsorUpdateRequest (MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/SponsorUpdateRequestValidator.cs:11-18). [Rubric §24, Forms/Validation/UX Safety] and [Rubric §15, Best Practices and Code Quality]: a length or format rule is defined once, so it cannot drift between create and update. The validator runs in the CQRS pipeline before the handler executes (ADR-014).
                            • -
                            • Walkthrough: nine includes covering Name, EventId, Sort, LogoUrl, Description, WebsiteUrl, LinkedInUrl, TwitterHandle, and BoothNumber (:11-19). The rule classes themselves are thin bindings to shared bases and to the domain's own constants: SponsorNameRules<T> derives from RequiredStringRules<T> with SponsorInvariants.NameMaxLength (MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:13-18), the six optional string rules derive from OptionalStringRules<T> with their matching invariant constants (:26-91), SponsorEventIdRules<T> is a NotEmpty with error code Sponsor.EventId.Required (:98-104), and SponsorSortRules<T> is a GreaterThanOrEqualTo(0) with error code Sponsor.Sort.Negative (:110-116). Taking the length limits from SponsorInvariants rather than from literals is what keeps the API rejection and the domain rejection in agreement.
                            • -
                            • Why it's built this way: notice what is deliberately absent. Tier and IsExhibitor have no rules at all, because an enum and a bool are already total; and only three of these fields (Name, LogoUrl, BoothNumber) are re-checked by the domain factory (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:119-122). For the rest, this validator is the only gate, which is the practical reason it runs on every path into the use case rather than only at the controller.
                            • -
                            • Where it's used: resolved by the validation stage of the create pipeline for SponsorCreateRequest; registered by the convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:112).
                            • -
                            • Caveats / not-in-source: the logo, website, and LinkedIn rules are length-only. The source states the reason for the logo one (MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:21-23): the value is stored as an opaque string, matching the speaker profile-picture precedent. No URL well-formedness check exists for any of the three.
                            • +
                            • What it is: the FluentValidation validator for SponsorCreateRequest. Its constructor is nine Include calls and nothing else (MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestValidator.cs:9-20): it owns no rule of its own, it composes rule objects that the update request also composes.
                            • +
                            • Depends on: AbstractValidator<T> from FluentValidation (:7) and the nine reusable rule classes in MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs, seven of which derive from the framework's RequiredStringRules<T> or OptionalStringRules<T>.
                            • +
                            • Concept reinforced, composable rule objects: the technique is introduced in Group 06. Include merges another validator's rules into this one, so the same rule instance definition can be bound to a different property selector on a different request type. The payoff is visible in the sibling: SponsorUpdateRequestValidator includes eight of these same nine rule objects, bound to the update request's properties (MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/SponsorUpdateRequestValidator.cs:11-18), so the create and update contracts cannot drift on max lengths or error codes. Only the event-id rule is missing there, because the owning event is not updatable at all (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:140). [Rubric §24, Forms, Validation, and UX Safety] assesses whether validation is stated once and enforced consistently: the rule text and error codes a client sees are identical on both verbs.
                            • +
                            • Walkthrough: each Include binds a rule object to a property selector.
                                +
                              • SponsorNameRules<SponsorCreateRequest>(p => p.Name) (:11) derives from RequiredStringRules<T> with the label "Sponsor Name" and SponsorInvariants.NameMaxLength (MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:13-18). The max length comes from the domain's invariant constants, so the request rule and the entity invariant cannot disagree.
                              • +
                              • SponsorEventIdRules (:12) is a hand-written AbstractValidator<T> requiring NotEmpty with the error code Sponsor.EventId.Required (SponsorValidationRules.cs:98-104); SponsorSortRules (:13) requires GreaterThanOrEqualTo(0) with Sponsor.Sort.Negative (:110-116).
                              • +
                              • The six optional strings, LogoUrl, Description, WebsiteUrl, LinkedInUrl, TwitterHandle, and BoothNumber (:14-19), all derive from OptionalStringRules<T> with their own labels and SponsorInvariants max lengths (SponsorValidationRules.cs:26-91). The logo URL is length-checked only, not parsed as a URI, and the file says why: the value is stored as an opaque string, matching the speaker profile-picture precedent (:21-23).
                              • +
                              +
                            • +
                            • Why it's built this way: no handler calls this class. ValidatingCommandDecorator<TCommand, TResult> resolves it from the container and runs it before the handler, converting failures into a Result rather than an exception. [Rubric §10, Cross-Cutting] and [Rubric §6, CQRS] both point at the same design: validation is a pipeline stage, so a handler that forgets to validate cannot exist.
                            • +
                            • Where it's used: registered by AddValidatorsFromAssemblyContaining<TAssemblyMarker>() inside the module scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:190, invoked at MMCA.ADC.Conference.Application/DependencyInjection.cs:125). Its tests walk the boundaries directly, including the exact-max-length pass and the null-optional-strings pass (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sponsors/Validation/SponsorCreateRequestValidatorTests.cs:30-147).
                            • +
                            • Caveats / not-in-source: two gaps are worth knowing. NotEmpty on an int rejects only zero, so it proves an event was chosen, not that the event exists (the database FK does that, MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SponsorConfiguration.cs:62-65). And no rule here constrains Tier: an out-of-range enum value passes validation, and Sponsor.Create does not check it either (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:119-122).

                            UpdateSessionQuestionAnswerHandler

                            -

                            MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.UpdateSessionQuestionAnswer · MMCA.ADC.Conference.Application/Sessions/UseCases/UpdateSessionQuestionAnswer/UpdateSessionQuestionAnswerHandler.cs:14 · Level 10 · class (sealed partial)

                            +

                            MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.UpdateSessionQuestionAnswer · MMCA.ADC.Conference.Application/Sessions/UseCases/UpdateSessionQuestionAnswer/UpdateSessionQuestionAnswerHandler.cs:14 · Level 10 · class (sealed, partial)

                            +
                            +
                              +
                            • What it is: the handler for UpdateSessionQuestionAnswerCommand. It loads the owning Session aggregate with its answers, enforces the BR-52/BR-53 ownership rule, mutates through the root, and saves (MMCA.ADC.Conference.Application/Sessions/UseCases/UpdateSessionQuestionAnswer/UpdateSessionQuestionAnswerHandler.cs:20-52).
                            • +
                            • Depends on: IUnitOfWork (:15), ICurrentUserService (:16), ILogger<UpdateSessionQuestionAnswerHandler> (:17), and RoleNames (:35). It implements ICommandHandler<in TCommand, TResult> returning a bare Result (:17).
                            • +
                            • Concept introduced, ownership enforced in the handler: the controller has already established that the caller is authenticated ([Authorize(Policy = AuthorizationPolicies.RequireAuthenticated)], MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:56), but only the handler can establish whether this particular row is theirs, because that fact lives in the loaded entity's audit column. So the rule sits here, next to the data: an Organizer may edit any answer, everyone else may edit only rows whose CreatedBy matches their user id (:33-42). [Rubric §11, Security] assesses whether authorization decisions are made where the necessary facts exist: role membership comes from the token, row ownership from the aggregate, and both are compared in one expression. [Rubric §4, DDD]: the check reads the child through the root's collection, never through a separate repository.
                            • +
                            • Walkthrough: eight steps, in order.
                                +
                              • unitOfWork.GetRepository<Session, SessionIdentifierType>() (:24), then GetByIdAsync with includes: [nameof(Session.SessionQuestionAnswers)] and asTracking: true (:25-29). The tracking flag is load-bearing: the mutation happens on this graph and SaveChangesAsync persists it only because the change tracker is watching.
                              • +
                              • A missing session returns Error.NotFound tagged with source and target (:30-31), so the failure carries where it came from without a string message.
                              • +
                              • The ownership pre-check (:34-42) finds the active answer in the loaded collection with a.Id == command.SessionQuestionAnswerId && !a.IsDeleted, then fails with Error.Forbidden(code: "SessionQuestionAnswer.NotOwner", ...) when the answer exists, the caller is not in RoleNames.Organizer, and answer.CreatedBy differs from the current user id.
                              • +
                              • The answer is not null guard is the interesting part. When the id names nothing, or names a soft-deleted row, the check is skipped and the call falls through to the domain, which resolves the child with the same active-only predicate and returns NotFound (MMCA.ADC.Conference.Domain/Sessions/Session.cs:540-542 into MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/AuditableAggregateRootEntity.cs:103-119). The practical effect is that a non-owner probing for an id they cannot see receives NotFound rather than Forbidden, so the error does not confirm the row exists.
                              • +
                              • entity.UpdateSessionQuestionAnswer(...) (:44) does the real work: resolve the child, call answer.UpdateAnswer (MMCA.ADC.Conference.Domain/Sessions/SessionQuestionAnswer.cs:71-80, which validates the text before assigning it), and raise SessionQuestionAnswerChanged with DomainEntityState.Updated (Session.cs:549).
                              • +
                              • SaveChangesAsync runs only on success (:45-49). That is safe precisely because the domain validates before it mutates: a failed update leaves the tracked graph unchanged, so skipping the save cannot strand a half-applied edit.
                              • +
                              • The success log is source-generated (:54-55), which is why the class is partial.
                              • +
                              +
                            • +
                            • Why it's built this way: the handler mirrors its sibling UpdateEventQuestionAnswerHandler line for line (MMCA.ADC.Conference.Application/Events/UseCases/UpdateEventQuestionAnswer/UpdateEventQuestionAnswerHandler.cs:35), which is deliberate: session-level and event-level questionnaires answer to the same two business rules, and reading either one teaches both. The read side enforces the complementary rule with a specification instead of a branch: the controller scopes non-Organizer list reads with OwnedByUserSpecification (MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:67-68), so ownership shows up as a filter on reads and as a guard on writes.
                            • +
                            • Where it's used: registered by the ICommandHandler<,> assembly scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:178-182), injected into SessionQuestionAnswersController (MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:60), and invoked by its UpdateAsync action, which returns 204 NoContent on success (:202-215). Note that the PUT carries no [Idempotent] attribute, unlike the POST on the same controller (:183-184): a replayed update is naturally idempotent.
                            • +
                            • Caveats / not-in-source: two.
                                +
                              • The non-owner branch is not exercised by this module's unit tests. Every test in the class stubs IsInRole(RoleNames.Organizer) as true in its constructor (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/UpdateSessionQuestionAnswerHandlerTests.cs:27), so the five tests (:79-174) all take the privileged path through :35.
                              • +
                              • currentUserService.UserId!.Value is dereferenced without a null check (:35). It is only reached for an authenticated non-Organizer, and the controller policy makes that the only way in, but a caller with no user id reaching this line would throw rather than return a failure Result.
                              • +
                              +
                            • +
                            +

                            DeleteEventHandler

                            +
                            +

                            MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Delete · MMCA.ADC.Conference.Application/Events/UseCases/Delete/DeleteEventHandler.cs:18 · Level 10 · class (sealed partial)

                            +
                            +
                              +
                            • What it is: the module's replacement for the framework's generic delete handler on one entity, Event. Deleting an event has to reach three other aggregates (sessions, sponsors, activities) that the generic handler cannot see, so Conference registers this handler under the same contract and takes the delete slot over (MMCA.ADC.Conference.Application/Events/UseCases/Delete/DeleteEventHandler.cs:13-17).
                            • +
                            • Depends on: ICommandHandler<in TCommand, TResult> closed over DeleteEntityCommand<TEntity, TIdentifierType> for Event and Result (:21); IUnitOfWork (:19); IEventCascadeDeletionDomainService (:20); ILogger<DeleteEventHandler> from Microsoft.Extensions.Logging (:21); and the four aggregates Event, Session, Sponsor, and Activity.
                            • +
                            • Concept introduced, a cross-aggregate cascade split between the application and domain layers: an aggregate may delete everything it owns, and nothing else. Event.Delete() cascades to the children the event owns outright, its rooms, event speakers, and event question answers (BR-72, MMCA.ADC.Conference.Domain/Events/Event.cs:323-328), and stops there. Sessions, sponsors, and activities are separate aggregate roots that merely carry an EventId, so nothing inside Event can reach them. The pattern this file demonstrates splits the job in two: the application layer owns loading the other aggregates, because loading needs repositories, and the domain layer owns deciding and ordering the deletions, because that is a business rule. [Rubric §4, Domain-Driven Design] assesses whether aggregate boundaries are respected as consistency boundaries rather than smeared into one graph; here the boundary is respected literally, and the cross-boundary rule is stated once in a pure domain service (EventCascadeDeletionDomainService) with no infrastructure dependency (MMCA.ADC.Conference.Domain/Services/EventCascadeDeletionDomainService.cs:16). [Rubric §1, SOLID]: the generic handler stays closed for modification and this class is the extension, registered against the same interface. [Rubric §8, Data Architecture]: because everything is soft-delete, the whole cascade is a set of in-memory flag mutations followed by one write, not four delete statements.
                            • +
                            • Walkthrough: one method, and its shape is load-load-load-load, decide, save.
                                +
                              • The primary constructor takes three services and declares the contract in the base list (:18-21). There is no IRepository parameter: every repository is pulled off IUnitOfWork inside the method, which is the framework's rule for keeping one tracked context per operation.
                              • +
                              • HandleAsync loads the event through unitOfWork.GetRepository<Event, EventIdentifierType>() with an explicit includes array naming Rooms, EventSpeakers, and EventQuestionAnswers, and with asTracking: true (:28-33). Tracking is the load-bearing argument in all four reads: the cascade mutates entities in memory and relies on the change tracker to turn those mutations into an UPDATE. An untracked graph would produce a silently successful no-op.
                              • +
                              • A missing event short-circuits with Error.NotFound stamped with source and target (:34-35), which is the Result idiom rather than an exception (ADR-013).
                              • +
                              • Sessions load next, with their own three child collections included so that each session's own cascade has its children in memory, filtered by s.EventId.Equals(entity.Id) && !s.IsDeleted and tracked (:38-43). Sponsors (:47-52) and activities (:56-61) follow the same shape with an empty includes array, because neither has children to cascade to.
                              • +
                              • eventCascadeDeletionDomainService.CascadeDelete(entity, sessions, sponsors, activities) (:64) hands all four sets to the domain service, which deletes sessions first (each cascading to its own children, BR-55, MMCA.ADC.Conference.Domain/Sessions/Session.cs:273-277), then sponsors, then activities, and only then the event (EventCascadeDeletionDomainService.cs:28-54). The first failure in any loop returns that failure unchanged and leaves the event untouched.
                              • +
                              • The save is conditional (:65-69): unitOfWork.SaveChangesAsync runs only when the cascade succeeded, so an aborted cascade's partial in-memory mutations are discarded with the scope rather than persisted. That single SaveChangesAsync is what makes the whole cascade atomic.
                              • +
                              • LogEventDeleted is a source-generated [LoggerMessage] partial at Information level carrying the event id (:74-75). [Rubric §13, Observability and Operability] assesses whether operationally interesting transitions are recorded in a structured, queryable form; the generator emits an allocation-free, strongly typed log call instead of an interpolated string, and it fires only on the success path.
                              • +
                              +
                            • +
                            • Why it's built this way: the module is meant to be extractable as its own service (ADR-007, ADR-008), and the four aggregates here all live in the Conference database (ADR-006), so an in-process cascade over one unit of work is legitimate. The alternative, database-level ON DELETE CASCADE, is unavailable by construction: nothing is hard-deleted, rows are flagged (ADR-005), and a flag update is not something a foreign key can propagate. The comments name the consequence of getting this wrong: sponsors and activities left behind are rows the public sponsor strip and activities page keep reading (:45-46, :54-55).
                            • +
                            • Where it's used: registered as services.TryAddScoped<ICommandHandler<DeleteEntityCommand<Event, EventIdentifierType>, Result>, ...DeleteEventHandler>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:60), which is what makes it win the slot over the generic DeleteEntityHandler<TEntity, TIdentifierType> that Sponsor and the other entities register (:86). It is injected into EventsController as the delete handler (MMCA.ADC.Conference.API/Controllers/EventsController.cs:51) and invoked through the overridden DeleteAsync, which delegates to the base action and then evicts three output-cache tags, conference:events, conference:sessions, and conference:rooms, precisely because the cascade reached beyond the event (:404-414). Covered by DeleteEventHandlerTests, which asserts each of the three cascade legs separately (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/UseCases/DeleteEventHandlerTests.cs:200, :222, :244) plus the not-found path and the save (:184, :266).
                            • +
                            • Caveats / not-in-source: the handler issues four reads before it writes anything, and three of them are unbounded by page size: an event with many sessions materializes all of them, with their children, into memory. Nothing in the file caps that. The !s.IsDeleted predicates (:41, :50, :59) are belt-and-braces on top of the global soft-delete query filter, so an already-deleted child is skipped rather than re-deleted. ConfigureAwait(false) appears on the save (:67) but not on the four repository awaits, an inconsistency with no visible effect in an ASP.NET Core host. The cache eviction lives in the controller, not here, so a caller invoking this handler by any other route deletes correctly but leaves the output cache stale.
                            • +
                            +

                            SpeakerCategoryItemNavigationPopulator

                            +
                            +

                            MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers · MMCA.ADC.Conference.Application/Speakers/SpeakerCategoryItemNavigationPopulator.cs:11 · Level 10 · class (sealed)

                              -
                            • What it is: the handler for UpdateSessionQuestionAnswerCommand. It mirrors RemoveSessionQuestionAnswerHandler statement for statement, enforcing the same BR-52 / BR-53 ownership rule before applying an edit instead of a removal (MMCA.ADC.Conference.Application/Sessions/UseCases/UpdateSessionQuestionAnswer/UpdateSessionQuestionAnswerHandler.cs:10-13).
                            • -
                            • Depends on: ICommandHandler<in TCommand, TResult>, IUnitOfWork, ICurrentUserService, RoleNames, the Session aggregate, Result / Error, and logging (:14-17).
                            • -
                            • Concept reinforced: none new; the ownership guard is the one taught on RemoveSessionQuestionAnswerHandler. Load the session with its answers, tracked (:25-29); block a non-organizer editing another user's answer with Error.Forbidden, code SessionQuestionAnswer.NotOwner and message "You can only update your own answers." (:37-42); then delegate to entity.UpdateSessionQuestionAnswer(command.SessionQuestionAnswerId, command.AnswerValue) (:44). The pair is a small, deliberate duplication: two use-case folders, one authorization rule stated twice, rather than a shared base class that would couple the slices. [Rubric §5, Vertical Slice] assesses whether a feature is independently changeable; the cost of this choice is two places to edit if BR-52 changes, and the benefit is that neither slice can break the other.
                            • -
                            • Walkthrough: the success path saves once and logs through the generated LogSessionQuestionAnswerUpdated, which records only the answer id (:45-49, declared :54-55). The new text is never logged, which matters because an answer body is attendee-authored content.
                            • -
                            • Where it's used: injected into SessionQuestionAnswersController (MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:59) and invoked from its update endpoint (:201).
                            • +
                            • What it is: the navigation populator for the SpeakerCategoryItem join entity when it is read as its own entity rather than as a child of a speaker. It declares exactly one navigation, the parent Speaker back-reference (MMCA.ADC.Conference.Application/Speakers/SpeakerCategoryItemNavigationPopulator.cs:7-10). The class body is empty (:23-24): everything it does is data passed to the base constructor.
                            • +
                            • Depends on: DeclarativeNavigationPopulator<TEntity> closed over SpeakerCategoryItem (:13); FKNavigationDescriptor<TEntity, TChild, TChildId> (:15); IUnitOfWork, forwarded straight through (:12-13); and the Speaker aggregate as the reference target.
                            • +
                            • Concept introduced for the speaker slice, the FK direction of a declarative populator: Group 11 teaches the populator pattern itself; what this file shows is the reference direction, the mirror image of the collection direction SpeakerNavigationPopulator uses. An FKNavigationDescriptor reads the key off each parent row, drops nulls, distincts them, builds child => parentIds.Contains(child.Id) as an expression tree, runs one untracked query, groups the results, and assigns each row its match (MMCA.Common/Source/Core/MMCA.Common.Application/Services/NavigationLoader.cs:54-99). That is why the AssignAction ends in FirstOrDefault() (:20): the loader always hands back a List<TChild>, and a reference navigation wants one element out of it. The descriptor also declares RequiresChildren => false (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/FKNavigationDescriptor.cs:23), which is what lets a caller ask for FK references without paying for child collections: the base tests that flag against the caller's includeFKs / includeChildren arguments before loading anything (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/DeclarativeNavigationPopulator.cs:36-40). [Rubric §12, Performance and Scalability] assesses whether reads scale with page size rather than row count: one batched query per descriptor for the whole page, never one per row. [Rubric §2, Design Patterns]: Template Method configured by data instead of by overrides, which is why the body is genuinely empty.
                            • +
                            • Walkthrough: one descriptor, four settings, and a generic quartet worth reading closely.
                                +
                              • PropertyName = nameof(SpeakerCategoryItem.Speaker) (:17) is not decoration. The base builds a set of the query's UnsupportedIncludes property names with an ordinal comparer and loads a descriptor only when its PropertyName is in that set (DeclarativeNavigationPopulator.cs:27-37), so a typo here is a silently unpopulated navigation, not a compile error. nameof is what prevents that.
                              • +
                              • ParentKeySelector = e => e.SpeakerId (:18) reads the join row's FK (MMCA.ADC.Conference.Domain/Speakers/SpeakerCategoryItem.cs:23), and ChildForeignKeySelector = child => child.Id (:19) names the target's primary key, because on the FK direction the "child" of the descriptor is the referenced parent entity.
                              • +
                              • AssignAction = (e, speakers) => e.Speaker = speakers.FirstOrDefault() (:20) writes the settable navigation property, which the entity exposes as [Navigation] public Speaker? Speaker { get; set; } (MMCA.ADC.Conference.Domain/Speakers/SpeakerCategoryItem.cs:19-20). The attribute is what puts this property in the FK bucket during metadata discovery, and the public setter is what makes the assignment possible at all.
                              • +
                              • The closed generic is FKNavigationDescriptor<SpeakerCategoryItem, Speaker, SpeakerIdentifierType> (:15). Speaker is the one Conference aggregate whose key is not an int: SpeakerIdentifierType aliases System.Guid because speakers carry Sessionize-assigned GUIDs (BR-61, MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:3, :19). That satisfies the descriptor's where TChildId : struct constraint (FKNavigationDescriptor.cs:17), and the non-nullable SpeakerId widens to the TChildId? the ParentKeySelector declares (:26).
                              • +
                              +
                            • +
                            • Why it's built this way: the classification that triggers any of this is made per navigation by NavigationMetadataProvider, which asks whether the declaring and target entity types share include support and files the navigation as supported or unsupported accordingly (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/NavigationMetadataProvider.cs:96-99). Two entities in the same physical source stay on .Include(); two entities split across sources cannot be joined, and only a second batched query can hydrate the relationship (ADR-002, ADR-006, ADR-018). Since source assignment is configuration, this file is a no-op on a topology where the two entities live together and becomes the hydration path on one where they do not, with no change to the controller or the query service. [Rubric §3, Clean Architecture]: the Application layer states hydration as property names and key selectors, with no EF Core namespace anywhere in the file.
                            • +
                            • Where it's used: registered as services.TryAddScoped<INavigationPopulator<SpeakerCategoryItem>, SpeakerCategoryItemNavigationPopulator>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:110), directly above the closed generic EntityQueryService<TEntity, TEntityDTO, TIdentifierType> registration for the same entity (:111), which is the pairing that puts it on every direct read of a speaker category item (ADR-034); that query service is injected into SpeakerCategoryItemsController (MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:49). Covered by SpeakerCategoryItemNavigationPopulatorTests, which pins the type to INavigationPopulator<SpeakerCategoryItem> and asserts both empty-input short circuits (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/SpeakerCategoryItemNavigationPopulatorTests.cs:9, :20, :24, :35).
                            • +
                            • Caveats / not-in-source: SpeakerCategoryItemDTO carries only Id, SpeakerId, and CategoryItemId (MMCA.ADC.Conference.Shared/Speakers/SpeakerCategoryItemDTO.cs:8-18), so nothing this populator hydrates reaches the API response on the read path today: the descriptor exists for the shape of the entity, not for a field a caller currently sees. The descriptor list also covers one side of the join only, Speaker and not CategoryItem, and the file gives no reason for the asymmetry.

                            SpeakerEntityQueryService

                            MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers · MMCA.ADC.Conference.Application/Speakers/SpeakerEntityQueryService.cs:15 · Level 10 · class (sealed)

                              -
                            • What it is: the only subclass of the framework's generic query service in the whole Conference module. It adds exactly one thing to EntityQueryService<TEntity, TEntityDTO, TIdentifierType>: a one-entry map that teaches the read pipeline how to filter and sort by FullName, a name that exists on the DTO and on the entity but in no database column (MMCA.ADC.Conference.Application/Speakers/SpeakerEntityQueryService.cs:11-13).
                            • -
                            • Depends on: EntityQueryService<TEntity, TEntityDTO, TIdentifierType> closed over Speaker / SpeakerDTO / SpeakerIdentifierType (:21-22), and the five collaborators it forwards to the base unchanged (:15-20): IUnitOfWork, INavigationMetadataProvider, IEntityQueryPipeline, SpeakerDTOMapper, and INavigationPopulator<in TEntity> closed over Speaker (satisfied at runtime by SpeakerNavigationPopulator). SpeakerIdentifierType is the module alias for System.Guid (MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:18), because speakers carry Sessionize-assigned GUIDs (BR-61, :3).
                            • -
                            • Concept introduced, the DTO-to-entity property map: a REST client filters and sorts using the vocabulary of the DTO it receives, but the query runs against the entity. Most names line up; FullName does not. On the entity it is a computed, get-only property, public string FullName => $"{FirstName} {LastName}" (MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:61), and the EF configuration explicitly removes it from the model with builder.Ignore(p => p.FullName) (MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SpeakerConfiguration.cs:67-68), so there is nothing named FullName for SQL to order by or match against. The map closes that gap by pairing the DTO name with a Dynamic LINQ expression over the two real columns rather than with a property path. [Rubric §12, Performance and Scalability] assesses whether reads stay translatable and server-side: the alternative to this one dictionary entry is fetching every speaker and matching the search box in memory, which the paged endpoint could not do. [Rubric §9, API and Contract Design] assesses whether the contract a caller sees is coherent: callers filter by the field they were served, and the translation stays a server concern. [Rubric §11, Security] is relevant too, and the framework is explicit about why: map entries are accepted unconditionally during validation precisely because they are server-authored and never client-supplied (MMCA.Common/Source/Core/MMCA.Common.Application/Services/QueryFieldService.cs:268-274, :318-319); a name the server never mapped still has to survive reflection against the entity, so a client cannot inject an expression of its own.
                            • -
                            • Walkthrough: the whole class is twenty lines, and all of it is configuration.
                                -
                              • The primary constructor takes the five services and forwards them positionally to the base constructor (:15-22). Note that the mapper parameter is the concrete SpeakerDTOMapper, not the IEntityDTOMapper<,,> the base declares (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:35): the subclass names the implementation and lets the compiler widen it, which is how DI resolves the Mapperly-generated mapper by its own type.
                              • +
                              • What it is: the only subclass of the framework's generic query service in the whole Conference module. It adds exactly one thing to EntityQueryService<TEntity, TEntityDTO, TIdentifierType>: a one-entry map that teaches the read pipeline how to filter and sort by FullName, a name that exists on the DTO and on the entity but in no database column (MMCA.ADC.Conference.Application/Speakers/SpeakerEntityQueryService.cs:11-14).
                              • +
                              • Depends on: EntityQueryService<TEntity, TEntityDTO, TIdentifierType> closed over Speaker / SpeakerDTO / SpeakerIdentifierType (:21-22), and the five collaborators it forwards to the base unchanged (:15-20): IUnitOfWork, INavigationMetadataProvider, IEntityQueryPipeline, SpeakerDTOMapper, and INavigationPopulator<in TEntity> closed over Speaker (satisfied at runtime by SpeakerNavigationPopulator).
                              • +
                              • Concept introduced, the DTO-to-entity property map: a REST client filters and sorts using the vocabulary of the DTO it received, but the query runs against the entity. Most names line up; FullName does not. On the entity it is a computed, get-only property, public string FullName => $"{FirstName} {LastName}" (MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:61), and the EF configuration explicitly removes it from the model with builder.Ignore(p => p.FullName) (MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SpeakerConfiguration.cs:67-68), so there is nothing named FullName for SQL to order by or match against. The map closes that gap by pairing the DTO name with a Dynamic LINQ expression over the two real columns rather than with a property path. [Rubric §12, Performance and Scalability] assesses whether reads stay translatable and server-side: the alternative to this one dictionary entry is fetching every speaker and matching the search box in memory, which a paged endpoint cannot do correctly. [Rubric §9, API and Contract Design] assesses whether the contract a caller sees is coherent: callers filter by the field they were served, and the translation stays a server concern. [Rubric §11, Security] is relevant too, and the framework is explicit about why: map entries are accepted unconditionally during field validation precisely because they are server-authored, never client-supplied (MMCA.Common/Source/Core/MMCA.Common.Application/Services/QueryFieldService.cs:329, :377-379), while any name the server never mapped still has to survive reflection against the entity, so a client cannot inject an expression of its own.
                              • +
                              • Walkthrough: the whole class is twenty-one lines, and all of it is configuration.
                                  +
                                • The primary constructor takes the five services and forwards them positionally to the base (:15-22). Note that the mapper parameter is the concrete SpeakerDTOMapper, not the IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> the base declares (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:31-36): the subclass names the implementation and lets the compiler widen it, which is how DI resolves the Mapperly-generated mapper by its own type.
                                • PropertyMap is a private static readonly IReadOnlyDictionary<string, string> with one entry: [nameof(SpeakerDTO.FullName)] = "(FirstName + \" \" + LastName)" (:28-31). Using nameof keys the map to the DTO property (MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:24), so renaming it breaks the build instead of silently breaking a sort. The outer parentheses in the value are load-bearing, and the next bullet shows why.
                                • DTOToEntityPropertyMap overrides the base's virtual, empty default and returns that static instance (:34). One allocation for the process, not one per request.
                                • -
                                • What the base then does with it is the interesting half. The value flows into three places: validation of the sort column and of every filter (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:225, :227), and the EntityQueryParameters handed to the pipeline (:436). On the filter path, QueryFilterService resolves the incoming key FullName through the map to the expression (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Filtering/QueryFilterService.cs:84-86), resolves a PropertyInfo for the DTO-facing name so type resolution still works (:88, :223-229, which finds the computed Speaker.FullName and types the filter as a string), and hands the expression to StringFilterStrategy (:95-97). A CONTAINS there becomes query.Where("(FirstName + \" \" + LastName).Contains(@0)", value) (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Filtering/StringFilterStrategy.cs:23), which is a plain concatenation predicate EF Core translates to SQL over the two real columns. Drop the parentheses from the map value and the same template would read FirstName + " " + LastName.Contains(@0), a different expression entirely. On the sort path, QueryFieldService.ApplySorting substitutes the mapped expression before appending the direction and calls Dynamic LINQ OrderBy (MMCA.Common/Source/Core/MMCA.Common.Application/Services/QueryFieldService.cs:144-153).
                                • -
                                • Everything else this service can do is inherited untouched: parameter validation, the keyed by-id fast path that skips the dynamic-filter pipeline for a plain primary-key read (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:80-100, asserted for speakers in MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/SpeakerEntityQueryServiceTests.cs:180-199), pagination metadata, and field shaping.
                                • +
                                • What the base does with it is the interesting half. The value flows into three places: validation of the sort column and of every filter (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:264, :266), and the EntityQueryParameters<TEntity> handed to the pipeline (:295, and :527 on the by-id path). On the filter path, QueryFilterService resolves the incoming key FullName through the map to the expression, resolves a PropertyInfo for the DTO-facing name so type resolution still works, and hands the expression to the string strategy (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Filtering/QueryFilterService.cs:84-97). A CONTAINS there becomes query.Where("(FirstName + \" \" + LastName).Contains(@0)", value) (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Filtering/StringFilterStrategy.cs:23), a plain concatenation predicate EF Core translates to SQL over the two real columns. Drop the parentheses from the map value and the same template would read FirstName + " " + LastName.Contains(@0), a different expression entirely. On the sort path, QueryFieldService.ApplySorting resolves the column through the map before appending the direction and the server-supplied tie-break key, then calls Dynamic LINQ OrderBy (MMCA.Common/Source/Core/MMCA.Common.Application/Services/QueryFieldService.cs:163-169, :190-201).
                                • +
                                • Everything else this service can do is inherited untouched: parameter validation, the by-id fast path, pagination metadata, field shaping, and the navigation-population step that invokes NavigationPopulator.PopulateAsync as a delegate handed down to the pipeline (EntityQueryService.cs:320, :534).
                              • -
                              • Why it's built this way: the framework offers an override hook rather than a configuration file or an attribute, so the mapping lives in the module that owns the vocabulary and costs nothing for the entities that do not need one. That is visible in the registration block: Speaker gets this subclass while Event, Session, Category, Question, and Sponsor all register the closed generic base directly (MMCA.ADC.Conference.Application/DependencyInjection.cs:55, :59, :67, :72, :76). [Rubric §16, Maintainability]: the delta between "standard entity" and "entity with a computed sort field" is one dictionary entry.
                              • -
                              • Where it's used: registered as services.TryAddScoped<IEntityQueryService<Speaker, SpeakerDTO, SpeakerIdentifierType>, SpeakerEntityQueryService>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:63) and injected into SpeakersController as the interface (MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:45), whose paged action passes the caller's filters and sort straight through alongside the BR-239 public-visibility specification (:178-189). Both speaker grids drive it with exactly this vocabulary: the organizer list sends filters["FullName"] = ("contains", _searchString) and sorts by "FullName" (MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerList.razor.cs:145, :158), and the public list does the same (MMCA.ADC.Conference.UI/Pages/Public/PublicSpeakerList.razor.cs:179, :192). The unit tests pin the contract, asserting that the captured pipeline parameters carry the FullName key mapped to the exact entity expression (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/SpeakerEntityQueryServiceTests.cs:89-105) and that an unmapped, unknown filter property fails validation with Filter.Property.NotFound before the pipeline is touched (:109-128).
                              • +
                              • Why it's built this way: the framework offers an override hook rather than a configuration file or an attribute, so the mapping lives in the module that owns the vocabulary and costs nothing for entities that need none. That is visible in the registration block: Speaker gets this subclass (MMCA.ADC.Conference.Application/DependencyInjection.cs:67) while Sponsor and SpeakerCategoryItem register the closed generic base directly (:85, :111). [Rubric §16, Maintainability]: the delta between "standard entity" and "entity with a computed sort field" is one dictionary entry.
                              • +
                              • Where it's used: registered as services.TryAddScoped<IEntityQueryService<Speaker, SpeakerDTO, SpeakerIdentifierType>, SpeakerEntityQueryService>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:67) and injected into SpeakersController as the interface (MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:45). Both speaker grids drive it with exactly this vocabulary: the organizer list sends filters["FullName"] = ("contains", _searchString) and sorts by "FullName" (MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerList.razor.cs:145, :158), and the public list does the same (MMCA.ADC.Conference.UI/Pages/Public/PublicSpeakerList.razor.cs:275, :177). SpeakerEntityQueryServiceTests pins the contract, asserting that the captured pipeline parameters carry the FullName key mapped to the exact entity expression (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/SpeakerEntityQueryServiceTests.cs:17, :89-104) and that an unmapped, unknown filter property fails validation before the pipeline is touched (:109).
                              • Caveats / not-in-source: two edges are worth knowing.
                                  -
                                • FullName is filterable and sortable but not requestable as a shaped field. The fields parameter is validated through the overload that takes no map (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:224), so ?fields=FullName is rejected as read-only (MMCA.Common/Source/Core/MMCA.Common.Application/Services/QueryFieldService.cs:335-341), which is consistent with server-side projection being restricted to writable properties (:161-163).
                                • -
                                • PropertyMap is built with the default (ordinal, case-sensitive) comparer (:28), while the base's empty default uses StringComparer.OrdinalIgnoreCase (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:61). Only the exact key FullName hits the map. A lowercase filter key misses it and is then rejected cleanly, because filter property lookup is case-sensitive (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Filtering/QueryFilterService.cs:241). A lowercase sort column behaves differently: sort validation resolves names case-insensitively (MMCA.Common/Source/Core/MMCA.Common.Application/Services/QueryFieldService.cs:322) and so does ApplySorting's unmapped fallback (:146-148), so the request passes validation and Dynamic LINQ receives the bare, EF-ignored FullName instead of the mapped expression. What the database layer does with that is not exercised anywhere in this repository: not determinable from source. Every caller in the codebase sends the exact-case key.
                                • +
                                • FullName is filterable and sortable but not requestable as a shaped field. The fields parameter is validated through the overload that takes no map (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:263, resolving to QueryFieldService.cs:317-318), so ?fields=FullName never reaches the map and is rejected as a read-only property, which is consistent with server-side projection being restricted to writable properties (QueryFieldService.cs:306-311).
                                • +
                                • PropertyMap is built with the default (ordinal, case-sensitive) comparer (:28), while the base's empty default uses StringComparer.OrdinalIgnoreCase (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:100). Only the exact key FullName hits the map. A lowercase filter key misses it and is then rejected cleanly, because filter property lookup reflects case-sensitively (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Filtering/QueryFilterService.cs:241). A lowercase sort column behaves differently: sort validation matches property names case-insensitively (QueryFieldService.cs:381-382) and the unmapped fallback in ResolveSortExpression resolves with BindingFlags.IgnoreCase (:199-201), so the request passes validation and Dynamic LINQ receives the bare, EF-ignored FullName instead of the mapped expression. What the database layer does with that is not exercised anywhere in this repository: not determinable from source. Every caller in the codebase sends the exact-case key.
                              @@ -4068,11 +4682,11 @@

                              SpeakerNavigationPopulator

                              MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers · MMCA.ADC.Conference.Application/Speakers/SpeakerNavigationPopulator.cs:11 · Level 10 · class (sealed)

          -
        • What it is: the declarative navigation populator for the Speaker aggregate. It declares how to hydrate the two child collections the read path may not be able to materialize through .Include(), SpeakerCategoryItems and SpeakerQuestionAnswers (MMCA.ADC.Conference.Application/Speakers/SpeakerNavigationPopulator.cs:7-9). Like its siblings, the class body is empty (:30-31): everything it does is expressed as data passed to its base constructor.

          +
        • What it is: the declarative navigation populator for the Speaker aggregate. It declares how to hydrate the two child collections the read path may not be able to materialize through .Include(), SpeakerCategoryItems and SpeakerQuestionAnswers (MMCA.ADC.Conference.Application/Speakers/SpeakerNavigationPopulator.cs:7-10). Like its siblings, the class body is empty (:23-24).

        • -
        • Depends on: DeclarativeNavigationPopulator<TEntity> closed over Speaker (:13), ChildNavigationDescriptor<TEntity, TParentId, TChild, TChildId> (:15, :22), IUnitOfWork (forwarded straight to the base, :12-13), and the Speaker, SpeakerCategoryItem, and SpeakerQuestionAnswer entities.

          +
        • Depends on: DeclarativeNavigationPopulator<TEntity> closed over Speaker (:13); ChildNavigationDescriptor<TEntity, TParentId, TChild, TChildId> (:15, :22); IUnitOfWork, forwarded straight to the base (:12-13); and the Speaker, SpeakerCategoryItem, and SpeakerQuestionAnswer entities.

        • -
        • Concept reinforced, declarative child loading: the mechanism is taught in Group 11 and in this group on ConferenceCategoryNavigationPopulator and EventNavigationPopulator (ADR-002); this class is pure binding, with two descriptors instead of one or three. [Rubric §2, Design Patterns]: Template Method configured by data, so adding a child collection is a descriptor, not a new query method. [Rubric §3, Clean Architecture]: the Application layer states hydration as key selectors and property names, with no EF Core namespace anywhere in the file.

          +
        • Concept reinforced, the collection direction: the mechanism is taught in Group 11 and, for the reference direction, by SpeakerCategoryItemNavigationPopulator above (ADR-002). A ChildNavigationDescriptor inverts the FK direction: it reads the parent's primary key, matches it against a foreign key the children hold, and assigns the whole list rather than one element. It also declares RequiresChildren => true (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/ChildNavigationDescriptor.cs:25), so both descriptors here are gated on the caller's includeChildren argument and stay dormant on a read that only asked for FK references. [Rubric §2, Design Patterns]: Template Method configured by data, so adding a child collection is a descriptor, not a new query method. [Rubric §3, Clean Architecture]: the Application layer states hydration as key selectors and property names, with no EF Core namespace in the file.

        • Walkthrough: two descriptors, both keyed on Speaker.Id against the child's SpeakerId, each supplying the same four settings.

          @@ -4095,18 +4709,40 @@

          SpeakerNavigationPopulator

            -
          • The generic quartet is worth reading closely, because Speaker is the one Conference aggregate whose key is not an int: the parent key type is SpeakerIdentifierType (System.Guid) while both child keys are int (MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:17-19). The descriptor keeps the two apart as separate type parameters, so the batch load compares GUID to GUID (child => child.SpeakerId is typed SpeakerIdentifierType on both children: MMCA.ADC.Conference.Domain/Speakers/SpeakerCategoryItem.cs:23, MMCA.ADC.Conference.Domain/Speakers/SpeakerQuestionAnswer.cs:26) while still resolving each child's own read repository by its own key type.
          • -
          • Both AssignAction targets go through the aggregate's own mutators, SetSpeakerCategoryItems (MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:388-389) and SetSpeakerQuestionAnswers (:465-466), each a thin delegation to the framework's SetItems over the private backing list. Both are internal, reachable from here only because the Domain project grants <InternalsVisibleTo Include="MMCA.ADC.Conference.Application" /> (MMCA.ADC.Conference.Domain/MMCA.ADC.Conference.Domain.csproj:3). The collections themselves are exposed as IReadOnlyCollection<> over private lists (Speaker.cs:63-73), so no other assembly can replace them.
          • -
          • The base owns the algorithm, and its guards decide when any of this runs. PopulateAsync returns immediately when there are no entities or no unsupported includes, then loads only descriptors whose PropertyName appears in UnsupportedIncludes (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/DeclarativeNavigationPopulator.cs:27-41). Because ChildNavigationDescriptor.RequiresChildren is hard-coded true (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/ChildNavigationDescriptor.cs:25), both are gated on includeChildren, matching the [Navigation(IsCollection = true)] attributes that put them in the child-collection bucket during metadata discovery (MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:66, :72). The load itself is one batched WHERE childFK IN (...parentIds) query per descriptor via NavigationLoader.LoadChildrenPropertyAsync (ChildNavigationDescriptor.cs:41-47), not one query per speaker.
          • +
          • The generic quartet is worth reading closely, because the parent key and the child keys are different types here: TParentId is SpeakerIdentifierType (System.Guid) while both TChildId values are int (MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:18-20). The descriptor keeps the two apart as separate type parameters, so the batch load compares GUID to GUID (child => child.SpeakerId is typed SpeakerIdentifierType on both children: MMCA.ADC.Conference.Domain/Speakers/SpeakerCategoryItem.cs:23, MMCA.ADC.Conference.Domain/Speakers/SpeakerQuestionAnswer.cs:26) while each child's own read repository is still resolved by its own int key (ChildNavigationDescriptor.cs:41-47).
          • +
          • Both AssignAction targets go through the aggregate's own mutators, SetSpeakerCategoryItems (MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:400-401) and SetSpeakerQuestionAnswers (:477-478), each a thin delegation to the framework's SetItems over the private backing list. Both are internal, reachable from here only because the Domain project grants <InternalsVisibleTo Include="MMCA.ADC.Conference.Application" /> (MMCA.ADC.Conference.Domain/MMCA.ADC.Conference.Domain.csproj:3). The collections themselves are exposed as IReadOnlyCollection<> over private lists (Speaker.cs:63-73), so no other assembly can replace them. [Rubric §4, Domain-Driven Design]: hydration passes through the same door a business operation would, not a back-door property write.
          • +
          • The base owns the algorithm and its guards decide when any of this runs. PopulateAsync returns immediately when there are no entities or no unsupported includes, then loads only descriptors whose PropertyName appears in UnsupportedIncludes (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/DeclarativeNavigationPopulator.cs:27-41). Both names match [Navigation(IsCollection = true)] attributes on the aggregate (Speaker.cs:66, :72), which is what puts them in the child-collection bucket during metadata discovery. The load itself is one batched WHERE childFK IN (...parentIds) query per descriptor via NavigationLoader, not one query per speaker.
        • -
        • Why it's built this way: the classification that triggers this code is made per navigation by NavigationMetadataProvider, which asks whether the declaring and target entity types share include support and files the navigation as supported or unsupported accordingly (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/NavigationMetadataProvider.cs:96-99). Two entities in the same physical source are joinable and stay on .Include(); two entities split across sources are not, and only manual batch loading can hydrate them (ADR-002, ADR-006, ADR-018). Since data-source assignment is configuration, the same populator is a no-op on a topology where speakers and their children live together and becomes the hydration path on one where they do not, with no change to the controller, the query service, or the DTO mapper. [Rubric §7, Microservices Readiness] assesses whether the code survives a physical split: this file is the survival kit. [Rubric §8, Data Architecture]: the parent-child link is expressed as a scalar FK plus a batched key lookup, which is what a cross-source relationship degrades to.

          +
        • Why it's built this way: the same cross-source degradation rule as its siblings (ADR-002, ADR-006): when a relationship can span physical data sources, EF's navigation is stripped and only the scalar foreign key survives, so hydration has to be a second batched query rather than an Include (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/NavigationMetadataProvider.cs:96-99). [Rubric §7, Microservices Readiness] assesses whether the code survives a physical split: this file is the survival kit for the speaker aggregate. [Rubric §8, Data Architecture]: the parent-child link degrades to a scalar FK plus a batched key lookup.

        • -
        • Where it's used: registered as services.TryAddScoped<INavigationPopulator<Speaker>, SpeakerNavigationPopulator>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:62), injected into SpeakerEntityQueryService, and invoked by the read pipeline as the NavigationPopulator.PopulateAsync delegate the base query service passes down (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:443). Compare the entities that need none: Question, Sponsor, Room, and CategoryItem all register NullNavigationPopulator<TEntity> instead (MMCA.ADC.Conference.Application/DependencyInjection.cs:71, :75, :80, :83). Its unit tests assert the type binds to INavigationPopulator<Speaker> and that the empty-input guards complete without touching the unit of work (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/SpeakerNavigationPopulatorTests.cs:15-43).

          +
        • Where it's used: registered as services.TryAddScoped<INavigationPopulator<Speaker>, SpeakerNavigationPopulator>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:66), immediately above the SpeakerEntityQueryService registration it is injected into (:67), and invoked by the read pipeline as the NavigationPopulator.PopulateAsync delegate the base query service passes down (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:320, :534). Compare the entities that need no manual hydration at all: those register NullNavigationPopulator<TEntity> instead. SpeakerNavigationPopulatorTests asserts the type binds to INavigationPopulator<Speaker> and that the empty-input guards complete without touching the unit of work (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/SpeakerNavigationPopulatorTests.cs:9, :20, :24, :35).

        • -
        • Caveats / not-in-source: the descriptors cover child collections only. Speaker.LinkedUserId is a scalar cross-module reference to Identity's User (MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:58) and is deliberately not a navigation here, so nothing in this file hydrates it; the linked user is resolved by the Identity service, not by this populator. Nothing in the descriptors filters soft-deleted children either: that exclusion comes from the EF global query filter applied by the read repository the loader resolves (ADR-005).

          +
        • Caveats / not-in-source: the descriptors cover child collections only. Speaker.LinkedUserId is a scalar cross-module reference into the Identity module and is deliberately not a navigation here, so nothing in this file hydrates a linked user. Nothing in the descriptors filters soft-deleted children either: that exclusion comes from the EF global query filter applied by the read repository the loader resolves (ADR-005). The loader's queries are untracked (MMCA.Common/Source/Core/MMCA.Common.Application/Services/NavigationLoader.cs:80-84), which is why write handlers that need a tracked graph pass an explicit includes array to the repository instead of relying on this populator.

        +

        SpeakerQuestionAnswerNavigationPopulator

        +
        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers · MMCA.ADC.Conference.Application/Speakers/SpeakerQuestionAnswerNavigationPopulator.cs:11 · Level 10 · class (sealed)

        +
        +
          +
        • What it is: the navigation populator for SpeakerQuestionAnswer read as its own entity. One navigation: the parent Speaker back-reference (MMCA.ADC.Conference.Application/Speakers/SpeakerQuestionAnswerNavigationPopulator.cs:7-10).
        • +
        • Depends on: DeclarativeNavigationPopulator<TEntity> closed over SpeakerQuestionAnswer (:13); FKNavigationDescriptor<TEntity, TChild, TChildId> (:15); IUnitOfWork (:12); the Speaker aggregate as the reference target.
        • +
        • Concept reinforced: none new. Structurally identical to SpeakerCategoryItemNavigationPopulator, which teaches the FK direction: the same closed generic over Speaker and SpeakerIdentifierType (:15), PropertyName = nameof(SpeakerQuestionAnswer.Speaker) (:17), ParentKeySelector = e => e.SpeakerId (:18), ChildForeignKeySelector = child => child.Id (:19), AssignAction ending in FirstOrDefault() (:20), and an empty class body (:23-24). The target property is the same settable, attributed navigation shape (MMCA.ADC.Conference.Domain/Speakers/SpeakerQuestionAnswer.cs:22-23).
        • +
        • Where it's used: registered as services.TryAddScoped<INavigationPopulator<SpeakerQuestionAnswer>, SpeakerQuestionAnswerNavigationPopulator>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:115). This is the one populator in the module with no paired query service, and the registration says so in a comment: the entity has no query service today, and registering the populator future-proofs the one that would be added alongside it (:113-114). Covered by SpeakerQuestionAnswerNavigationPopulatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/SpeakerQuestionAnswerNavigationPopulatorTests.cs:9, :20, :24, :35).
        • +
        • Caveats / not-in-source: because nothing resolves INavigationPopulator<SpeakerQuestionAnswer> on a read path today, this class is registered but not exercised outside its unit tests. Answers still reach clients as part of a speaker read, through the child-collection descriptor on SpeakerNavigationPopulator, which is a different code path entirely.
        • +
        +

        SponsorNavigationPopulator

        +
        +

        MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors · MMCA.ADC.Conference.Application/Sponsors/SponsorNavigationPopulator.cs:12 · Level 10 · class (sealed)

        +
        +
          +
        • What it is: the navigation populator for the Sponsor aggregate. One navigation, and it points up: the Event a sponsor belongs to (MMCA.ADC.Conference.Application/Sponsors/SponsorNavigationPopulator.cs:7-11).
        • +
        • Depends on: DeclarativeNavigationPopulator<TEntity> closed over Sponsor (:14); FKNavigationDescriptor<TEntity, TChild, TChildId> (:16); IUnitOfWork (:13); the Event aggregate as the reference target.
        • +
        • Concept reinforced: none new; see SpeakerCategoryItemNavigationPopulator for the FK direction. What differs is only which key is read and which aggregate is fetched: PropertyName = nameof(Sponsor.Event) (:18), ParentKeySelector = e => e.EventId (:19, over the private-set FK at MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:45), ChildForeignKeySelector = child => child.Id (:20), AssignAction = (e, events) => e.Event = events.FirstOrDefault() (:21) writing the attributed navigation (Sponsor.cs:48-49), and an empty class body (:24-25). Worth noting the shape this reveals: Sponsor is an aggregate root that owns no children of its own, which is why it needs exactly one descriptor and why its delete is the generic DeleteEntityHandler<TEntity, TIdentifierType> rather than a cascade handler (MMCA.ADC.Conference.Application/DependencyInjection.cs:86), even though it is itself swept up by DeleteEventHandler when its event goes away.
        • +
        • Where it's used: registered as services.TryAddScoped<INavigationPopulator<Sponsor>, SponsorNavigationPopulator>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:84), immediately above the closed generic EntityQueryService<TEntity, TEntityDTO, TIdentifierType> registration for sponsors (:85), which is injected into SponsorsController (MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:38). Covered by SponsorNavigationPopulatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sponsors/SponsorNavigationPopulatorTests.cs:9, :20, :24, :35).
        • +
        • Caveats / not-in-source: SponsorDTO carries EventId but no Event (MMCA.ADC.Conference.Shared/Sponsors/SponsorDTO.cs:41-42), and the sponsor mapper projects no event data, so what this populator hydrates does not reach an API response on the read path today. The descriptor keeps the entity self-consistent when a sponsor is materialized with FK includes; it is not currently what any client sees.
        • +

        ⬅ ADC Conference - Domain Model & Module ContractsIndexADC Conference - Infrastructure & Persistence ➡

        diff --git a/docs/onboarding/group-19-conference-infrastructure.html b/docs/onboarding/group-19-conference-infrastructure.html index 3adb73d..b3e485a 100644 --- a/docs/onboarding/group-19-conference-infrastructure.html +++ b/docs/onboarding/group-19-conference-infrastructure.html @@ -147,18 +147,19 @@

        19. ADC Conference - Infrastructure & Persistence

        What this chapter covers. This is the adapter layer of the Conference module, the place where the engine-agnostic domain meets concrete technology. Three concerns live here: (1) persistence - mapping, the 16 EF Core entity configurations that turn plain domain classes into SQL Server tables, + mapping, the 17 EF Core entity configurations that turn plain domain classes into SQL Server tables, the abstract DbContext that declares the module's DbSets, and the seeder that puts the real conference events and feedback questions into a fresh database; (2) outbound integration and background work, the HTTP clients that talk to Sessionize (the conference's session-submission - platform) and to the Anthropic Claude API (the AI session scorer), plus the hosted worker that - drains the scoring queue off the request path; and (3) the DI wiring that registers those services - with the right resilience policy. It is the per-module realization of Clean Architecture's ports and - adapters idea: the Application layer declares the ports + platform) and to the Anthropic Claude API (the AI session scorer), the hosted worker that drains + the scoring queue off the request path, and the cron job that re-queues a scoring pass a crash cut in + half; and (3) the DI wiring that registers those services with the right resilience policy. It is + the per-module realization of Clean Architecture's ports and adapters idea: the + Application layer declares the ports (ISessionizeService, IAiScoringService, SessionScoringQueue), and this - Infrastructure layer supplies the adapters and the runner. [Rubric §3, Clean Architecture] assesses + Infrastructure layer supplies the adapters and the runners. [Rubric §3, Clean Architecture] assesses whether dependencies point inward and the domain stays framework-free; here every EF, HTTP, and Anthropic concern is quarantined in Infrastructure, so the domain entities in Group 17 carry no persistence or transport attribute at all.

        @@ -166,14 +167,16 @@

        Engine-

        The most important idea in this chapter is one the entities themselves never express: what storage engine each entity uses is decided here, not in the domain. A Conference domain entity, Session, Speaker, - Event, Sponsor, the - join entities, is a plain class. The only thing that binds it to SQL Server is which base class its - configuration inherits from. All 16 configs in this group - (SessionConfiguration, SpeakerConfiguration, - EventConfiguration, SponsorConfiguration, and the - rest) derive from + Event, Sponsor, + Activity, the join entities, is a plain class. The only + thing that binds it to SQL Server is which base class its configuration inherits from. All 17 configs in + this group (SessionConfiguration, + SpeakerConfiguration, EventConfiguration, + SponsorConfiguration, ActivityConfiguration, and + the rest) derive from EntityTypeConfigurationSQLServer<TEntity, TIdentifierType> - (for example MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SessionConfiguration.cs:12-13), + (for example MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SessionConfiguration.cs:12-13 + and MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/ActivityConfiguration.cs:11-12), which is a thin shim carrying [UseDataSource(DataSource.SQLServer)] (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/EntityTypeConfigurationSQLServer.cs:16-17) over the engine-neutral @@ -187,12 +190,13 @@

        Engine- the dominant lens for the whole persistence half of this chapter.

        Each config inherits the cross-cutting behavior, then adds entity specifics

        Every configuration's Configure method begins with base.Configure(builder) (for example - SessionConfiguration.cs:18) and then adds its own mappings. That one base call is where the - framework injects the conventions applied uniformly: the strongly-typed key, the table name and module - schema, and the concurrency token, none of which any individual config re-states. The per-entity bodies - then declare what is unique: column lengths sourced from the domain's invariant constants - (SessionInvariants.TitleMaxLength at SessionConfiguration.cs:20-22, EventInvariants.NameMaxLength - at EventConfiguration.cs:19-21, SponsorInvariants.NameMaxLength at SponsorConfiguration.cs:19-21), + SessionConfiguration.cs:18, ActivityConfiguration.cs:17) and then adds its own mappings. That one + base call is where the framework injects the conventions applied uniformly: the strongly-typed key, + the table name and module schema, and the concurrency token, none of which any individual config + re-states. The per-entity bodies then declare what is unique: column lengths sourced from the domain's + invariant constants (SessionInvariants.TitleMaxLength at SessionConfiguration.cs:20-22, + EventInvariants.NameMaxLength at EventConfiguration.cs:19-21, SponsorInvariants.NameMaxLength at + SponsorConfiguration.cs:19-21, ActivityInvariants.NameMaxLength at ActivityConfiguration.cs:19-21), required and optional flags, computed properties excluded with builder.Ignore(...) (Session.Duration at SessionConfiguration.cs:67, Speaker.FullName at SpeakerConfiguration.cs:68), value conversions (Speaker.Email round-trips through @@ -206,21 +210,24 @@

        SoftDeleteUniqueIndexConvention - (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/SoftDeleteUniqueIndexConvention.cs:43-51), + (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/SoftDeleteUniqueIndexConvention.cs:43-54), so a soft-deleted link never blocks a re-insert; CategoryItemConfiguration relies on exactly that and declares its unique (CategoryId, Name) index with no filter call at all (CategoryItemConfiguration.cs:30-31). A hand-authored non-unique index is deliberately left alone by the convention and opts in explicitly through IndexBuilderExtensions.HasSoftDeleteFilter() - (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/IndexBuilderExtensions.cs:19-30), + (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/IndexBuilderExtensions.cs:20-29), which replaces the old literal HasFilter("[IsDeleted] = 0") by reading the column name from the model - and the quoting from the engine. Three lookup indexes here take that opt-in: Session.EventId - (SessionConfiguration.cs:77-78), Sponsor.EventId (SponsorConfiguration.cs:67-68), and - EventQuestionAnswer.EventId (EventQuestionAnswerConfiguration.cs:35-36). Several unique indexes also - call it explicitly for readability even though the convention would supply it: - SessionSpeakerConfiguration's (SessionId, SpeakerId) pair - (SessionSpeakerConfiguration.cs:30-32), the one-score-per-session index on + and the quoting from the engine. Five lookup indexes here take that opt-in: Session.EventId + (SessionConfiguration.cs:77-78), Sponsor.EventId (SponsorConfiguration.cs:67-68), + EventQuestionAnswer.EventId (EventQuestionAnswerConfiguration.cs:35-36), and both of + ActivityConfiguration's, the plain EventId lookup + (ActivityConfiguration.cs:58-59) and the composite (EventId, StartTime, SortOrder) that serves the + public activities page's ordering directly instead of sorting an event slice in memory + (ActivityConfiguration.cs:61-64). Several unique indexes also call it explicitly for readability even + though the convention would supply it: SessionSpeakerConfiguration's + (SessionId, SpeakerId) pair (SessionSpeakerConfiguration.cs:30-32), the one-score-per-session index on SessionAiScoreConfiguration (SessionAiScoreConfiguration.cs:59-61), the equivalent pairs on EventSpeakerConfiguration (EventSpeakerConfiguration.cs:30-32), @@ -233,30 +240,37 @@

        SessionQuestionAnswerConfiguration (SessionQuestionAnswerConfiguration.cs:43-45) and EventQuestionAnswerConfiguration - (EventQuestionAnswerConfiguration.cs:42-44). Two configs declare no index at all and map columns only, - QuestionConfiguration (QuestionConfiguration.cs:10) and - SpeakerQuestionAnswerConfiguration - (SpeakerQuestionAnswerConfiguration.cs:10). Sparse filters are a different thing again and stay - literal, because they filter on a nullable business column rather than on soft-delete: - Speaker.LinkedUserId is unique only where it is set (SpeakerConfiguration.cs:63-65, the - User-to-Speaker link), and Event.SessionizeCode is indexed only where present - (EventConfiguration.cs:41-42). Two further quirks are worth knowing: + (EventQuestionAnswerConfiguration.cs:42-44).

        +

        Two indexes are deliberately unfiltered, and both carry a comment explaining why, because in each + case the filtered composite next to them is not a substitute. RoomConfiguration re-declares the + conventional foreign-key index on EventId (RoomConfiguration.cs:46-48) because EF drops it as + redundant once the composite (EventId, Name) index leads with the same column, while the foreign-key + lookups still want it. SessionQuestionAnswerConfiguration keeps its plain SessionId index + (SessionQuestionAnswerConfiguration.cs:34-37) because the Sessionize sync reads that table by + SessionId with the global query filters off, and a filtered index cannot serve a query that does + not carry the predicate. Sparse filters are a different thing again and stay literal, because they + filter on a nullable business column rather than on soft-delete: Speaker.LinkedUserId is unique only + where it is set (SpeakerConfiguration.cs:63-65, the User-to-Speaker link), and Event.SessionizeCode + is indexed only where present (EventConfiguration.cs:41-42). Two further quirks are worth knowing: ConferenceCategoryConfiguration calls - ToTable("Category", "Conference") explicitly (ConferenceCategoryConfiguration.cs:24) so the + ToTable("Category", "Conference") explicitly (ConferenceCategoryConfiguration.cs:22-24) so the Conference Category table cannot collide with another module's Category, and SessionConfiguration maps the Session-to-Room relationship with OnDelete(DeleteBehavior.Restrict) (SessionConfiguration.cs:83-87) so deleting a room can never - cascade sessions away.

        + cascade sessions away. Two configs declare no index at all and map columns only, + QuestionConfiguration (QuestionConfiguration.cs:10) and + SpeakerQuestionAnswerConfiguration + (SpeakerQuestionAnswerConfiguration.cs:10).

        DbSets, the context shape, and how the configurations are actually found

        ModuleApplicationDbContext - (MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:19) is the - Conference module's abstract DbContext. It does one job: declare 14 internal DbSet<T> properties + (MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:20) is the + Conference module's abstract DbContext. It does one job: declare 15 internal DbSet<T> properties (Events, Rooms, EventSpeakers, EventQuestionAnswers, Sessions, SessionSpeakers, SessionQuestionAnswers, SessionCategoryItems, Speakers, SpeakerCategoryItems, Categories, - CategoryItems, Questions, Sponsors, at ModuleApplicationDbContext.cs:27-66). It is abstract - and inherits from the Common + CategoryItems, Questions, Sponsors, Activities, at ModuleApplicationDbContext.cs:28-70). It is + abstract and inherits from the Common ApplicationDbContext through its primary - constructor (ModuleApplicationDbContext.cs:19-24), from which it gets the real machinery: the + constructor (ModuleApplicationDbContext.cs:20-25), from which it gets the real machinery: the SaveChangesAsync override that stamps audit fields and captures domain events into the outbox, and the global soft-delete query filters applied to every auditable entity. The concrete class EF actually instantiates is the single SQLServerDbContext in @@ -267,50 +281,54 @@

        context walks the registered configuration assemblies and applies every IEntityTypeConfigurationSQLServer<,> implementation whose entity resolves to this context's data source key - (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:610-636, - with the engine-to-interface switch at :612-618 and the registry filter at :625-635). That is why two + (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:610-637, + with the engine-to-interface switch at :612-618 and the registry filter at :625-636). That is why two entities with a configuration here, SessionAiScore and SpeakerQuestionAnswer, are mapped and queryable through the repository layer even though ModuleApplicationDbContext declares no DbSet for either: - 16 configurations, 14 DbSets, and the configurations win. [Rubric §7, Microservices Readiness] (can a + 17 configurations, 15 DbSets, and the configurations win. [Rubric §7, Microservices Readiness] (can a module become its own service without a rewrite?) is embodied here: the Conference module already runs as - MMCA.ADC.Conference.Service over its own ADC_Conference database with its own dbo.OutboxMessages, - and cross-module references (a speaker's linked user, a bookmark's session) are scalar columns resolved - via gRPC and integration events, never cross-database foreign keys.

        + MMCA.ADC.Conference.Service over its own ADC_Conference database + (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:33) with its own outbox, and cross-module + references (a speaker's linked user, a bookmark's session) are scalar columns resolved via gRPC and + integration events, never cross-database foreign keys.

        Seeding: two real events always, sample data only in dev and CI

        ConferenceModuleDbSeeder - (MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/Seeding/ConferenceModuleDbSeeder.cs:24) + (MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/Seeding/ConferenceModuleDbSeeder.cs:25) derives from the framework's DbSeeder and runs after schema initialization, constructed by ConferenceModuleSeeder in the API layer (MMCA.ADC.Conference.API/ConferenceModuleSeeder.cs:28). It is idempotent: every step first issues an ExistsAsync check through the repository and returns early if the row is present - (ConferenceModuleDbSeeder.cs:64-69, :98-103, :132-137), which is what makes it safe to run on every + (ConferenceModuleDbSeeder.cs:69-74, :103-108, :137-142), which is what makes it safe to run on every startup under the production Migrate init strategy (ADR-030). It always seeds three - things (ConferenceModuleDbSeeder.cs:46-48): the 2026 Atlanta Cloud + AI Conference (2026-05-30, - America/New_York, Sessionize code z1ecmzux, ConferenceModuleDbSeeder.cs:71-83), the 2026 Atlanta - Developers Conference (2026-10-17, Sessionize code sf1nopko, ConferenceModuleDbSeeder.cs:105-117), - both published immediately after creation (:88 and :122) and both carrying the shared venue address, - map URL and their own published sponsorship-packet URL (ConferenceModuleDbSeeder.cs:26-38), and the + things (ConferenceModuleDbSeeder.cs:50-52): the 2026 Atlanta Cloud + AI Conference (2026-05-30, + America/New_York, Sessionize code z1ecmzux, ConferenceModuleDbSeeder.cs:76-88), the 2026 Atlanta + Developers Conference (2026-10-17, Sessionize code sf1nopko, ConferenceModuleDbSeeder.cs:110-122), + both published immediately after creation (:93 and :127) and both carrying the shared venue address, + map URL and their own published sponsorship-packet URL (ConferenceModuleDbSeeder.cs:27-42), and the fixed set of 10 feedback questions (5 session ratings plus a session comment, 3 conference ratings - plus a conference comment, ConferenceModuleDbSeeder.cs:139-151) whose ids start at + plus a conference comment, ConferenceModuleDbSeeder.cs:144-156) whose ids start at QuestionInvariants.ManualIdRangeStart - (ConferenceModuleDbSeeder.cs:153) so they never collide with imported data.

        -

        It conditionally seeds four more things (ConferenceModuleDbSeeder.cs:50-56): two sample speakers - (Ada Lovelace and Alan Turing, :179-183), two sample sessions with app-assigned ids from + (ConferenceModuleDbSeeder.cs:158) so they never collide with imported data.

        +

        It conditionally seeds five more things (ConferenceModuleDbSeeder.cs:54-61): two sample speakers + (Ada Lovelace and Alan Turing, :184-188), two sample sessions with app-assigned ids from SessionInvariants.ManualIdRangeStart, one per - seeded event (:236-240, and the ids are explicit because a Session's int PK is its Sessionize id, so - the sample rows take a reserved range above any real one, :232-235), the EventSpeaker plus - SessionSpeaker links between them (:305-306), and four sample sponsors across the Platinum, Gold, Silver - and Community tiers, two of them exhibitors with booth numbers (:344-350). All of that runs only when - includeSampleData is set. The flag comes from Seeding:IncludeSampleConferenceData - (MMCA.ADC.Conference.API/ConferenceModuleSeeder.cs:26), which the local Aspire AppHost sets - (MMCA.ADC.AppHost/Program.cs:162) and production leaves unset. The reason is documented in the seeder's - own remarks (ConferenceModuleDbSeeder.cs:16-23): the public-browse E2E tests need at least one session - and one speaker row to exist deterministically, while production's real sessions and speakers arrive - through the Sessionize import. The links are created on both paths deliberately, so the direct - (EventSpeaker) and the transitive (SessionSpeaker) branches of the speakers-by-event filter are both - exercised in dev and CI (ConferenceModuleDbSeeder.cs:302-304).

        + seeded event (:241-245, and the ids are explicit because a Session's int PK is its Sessionize id, so + the sample rows take a reserved range above any real one, :237-240), the EventSpeaker plus + SessionSpeaker links between them (:310-311), four sample sponsors across the Platinum, Gold, Silver + and Community tiers, two of them exhibitors with booth numbers (:349-355), and three sample social + activities (a pre-conference party the evening before the Developers Conference, a morning coffee + connect, and an after-party) whose event-local wall-clock times are anchored on each event's own start + date (:409-425, :441). All of that runs only when includeSampleData is set. The flag comes from + Seeding:IncludeSampleConferenceData (MMCA.ADC.Conference.API/ConferenceModuleSeeder.cs:26), which the + local Aspire AppHost sets (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:162) and production + leaves unset. The reason is documented in the seeder's own remarks + (ConferenceModuleDbSeeder.cs:17-24): the public-browse E2E tests need at least one session and one + speaker row to exist deterministically, while production's real sessions and speakers arrive through the + Sessionize import. The links are created on both paths deliberately, so the direct (EventSpeaker) and + the transitive (SessionSpeaker) branches of the speakers-by-event filter are both exercised in dev and CI + (ConferenceModuleDbSeeder.cs:307-309).

        The Sessionize adapter

        SessionizeService (MMCA.ADC.Conference.Infrastructure/Services/SessionizeService.cs:10) is a deliberately thin HTTP @@ -322,7 +340,7 @@

        The Sessionize adapter

        status, because the import use-case that calls it is a foreground operation with a caller waiting on the result. It is registered as a typed HttpClient in DependencyInjection with the base address https://sessionize.com/api/v2/ baked in - (MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:21-23), so it inherits the standard Aspire + (MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:22-24), so it inherits the standard Aspire resilience handler (Polly retry, timeout, circuit breaker) unchanged: [Rubric §29, Resilience & Business Continuity], the ADR-009 policy that every outbound client gets resilience by default. The thinness is intentional: parsing, mapping, and the import workflow live in Application use-cases, and this adapter owns only the wire call.

        @@ -376,8 +394,8 @@

        Scoring runs on single-reader hosted drain), and it replaced an untracked fire-and-forget task the controller used to start.

        The queue's dedup lives in one process's memory, and Conference runs at maxReplicas: 2 - (the conferenceApp container app at MMCA.ADC/infra/main.bicep:1219, scale rule at - MMCA.ADC/infra/main.bicep:1335), so the queue alone never stopped two organizer triggers landing on + (the conferenceApp container app at MMCA.ADC/infra/main.bicep:1236, scale rule at + MMCA.ADC/infra/main.bicep:1357), so the queue alone never stopped two organizer triggers landing on different replicas from each running a full paid pass over the same sessions. The worker therefore takes a cross-replica lock before invoking the handler: it creates a per-item DI scope (CreateAsyncScope, SessionScoringProcessor.cs:160) because the drain itself is a singleton while the @@ -389,9 +407,11 @@

        Scoring runs on pass twice in a row. The handle is disposed by an await using around the whole run, so the lock comes back on success, on failure, and via its time-to-live even when the replica is killed mid-pass: the comment at :162-174 records that this replaced a cache counter released in a finally, which left a - killed replica's key stuck at 1 and locked the event out until an operator cleared it by hand. Note the - doc drift here: ADR-052 still describes dedup as per-replica and the distributed lock as a future step - (Website/docs-src/adr/052-background-job-execution.md:85-87), but the lock is in the code today.

        + killed replica's key stuck at 1 and locked the event out until an operator cleared it by hand, and it + records the honest limit that a host with no Redis configured falls back to the in-process + IDistributedLock, where exclusion is per replica again. Note the doc drift here: ADR-052 still + describes dedup as per-replica and a distributed lock as the point at which this would need a real job + system (Website/docs-src/adr/052-background-job-execution.md:92-95), but the lock is in the code today.

        Failure handling is decided once instead of per call site. A cancellation during shutdown logs and returns without requeuing (SessionScoringProcessor.cs:115-123); any other exception is caught under an explicit CA1031 suppression whose comment states the rule, one failed run must not kill the drain @@ -404,8 +424,8 @@

        Scoring runs on the handler answered, and a business refusal replayed twice more just costs money. When every attempt is exhausted the terminal path increments the scoring.run.failed.terminal counter tagged by event (:96-99, :150) on the MMCA.ADC.Conference.Scoring meter (:59), which the service host exports by - registering that meter name (MMCA.ADC.Conference.Service/Program.cs:134): that is - [Rubric §13, Observability & Operability] closing the loop on work that no user is waiting for.

        + registering that meter name (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:134): that + is [Rubric §13, Observability & Operability] closing the loop on work that no user is waiting for.

        The output cache is evicted twice per run, once up front so polling clients stop seeing stale scores and once after a successful pass (SessionScoringProcessor.cs:158 and :208), and it evicts the narrow conference:sessions tag rather than the root conference tag. The comment above that constant records @@ -414,23 +434,59 @@

        Scoring runs on surface onto the Basic-tier database while attendees were browsing. [Rubric §12, Performance & Scalability] and [Rubric §31, Cost/FinOps] both live in that one constant (ADR-026, ADR-040).

        +

        The sweep that finishes what a crash interrupted

        +

        The drain is fast but not durable: the channel lives in one replica's memory, so a deploy, a scale-in or + a crash between the organizer's click and the last session's score leaves an event half scored with + nothing anywhere that would pick it up again. + SessionScoringSweepJob + (MMCA.ADC.Conference.Infrastructure/Services/SessionScoringSweepJob.cs:54) is the backstop for exactly + that. It is an IScheduledJob named + conference-session-scoring-sweep with the cron expression */5 * * * * + (SessionScoringSweepJob.cs:69, :77), so the framework's recurring-job scheduler + (ADR-074) runs it every five + minutes, once across the whole service rather than once per replica, under the persistent claim lease the + outbox pattern established + (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IScheduledJob.cs:16-20). A host overrides + the cadence through Scheduler:Jobs:conference-session-scoring-sweep:Cron without touching code + (SessionScoringSweepJob.cs:72-76).

        +

        There is no scoring-state column on Event, so the job derives the condition from the rows the scoring + handler already writes. It projects every non-service session into + SessionScoringCandidate (SessionScoringSweepJob.cs:86-89, :208) and + every persisted score into SessionScoreStamp (:98-100, :213), collapses the + stamps to the newest per session (:118-132), then groups the candidates by event (:105-108) and + judges each one: an event is mid-pass exactly when some but not all of its scorable sessions carry a + score (:170-173). Two bounds keep a wrong guess from spending money. An event with zero scores is + never enqueued, because nobody asked for it and starting a pass the organizer did not request would bill + every event in the database on the first tick. A partially scored event is enqueued only while its newest + score is inside the 24-hour RecoveryWindow (:66, :103, :175-179), so a crash is recovered but a + session the model will never score cannot re-trigger paid passes forever; past that the job logs that it + is leaving the event alone and an organizer re-triggers by hand (:195-202). Beyond the enqueue the job + is read-only, and the enqueue itself is safe to repeat because the queue's pending set refuses an event + that is already queued or running, which the job records as the outcome on its log line (:181-182, + :185-193). [Rubric §29, Resilience & Business Continuity] is the lens: the fast path stays in memory, + and a slow, cheap, idempotent sweep notices what the fast path dropped.

        DI wiring and a deliberate resilience override

        DependencyInjection - (MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:11) is a single + (MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:12) is a single extension(IServiceCollection) block (the codebase's standard DI-registration idiom, taught in the - primer) exposing AddModuleConferenceInfrastructure() (DependencyInjection.cs:13-19). It registers - both adapters as typed HTTP clients and the drain as a hosted service (DependencyInjection.cs:45). The - Anthropic client gets a custom resilience policy: a 5-minute HttpClient.Timeout and the - anthropic-version: 2023-06-01 header (DependencyInjection.cs:30-32), then - RemoveAllResilienceHandlers() followed by a re-added StandardResilienceHandler with a 3-minute attempt - timeout, a 7-minute circuit-breaker sampling window, a 5-minute total request timeout, and only one - retry (DependencyInjection.cs:34-41). The inline comment explains why (DependencyInjection.cs:25-26): - AI scoring of a large batch can take minutes, which would blow through Aspire's default 30s attempt and - 90s total limits, and retrying an expensive LLM call aggressively is wasteful. This is a precise - illustration of ADR-009: - every outbound client is resilient by default, but a client with genuinely different latency - characteristics tunes the policy rather than disabling it. The Sessionize client takes the defaults - unchanged.

        + primer) exposing AddModuleConferenceInfrastructure() (DependencyInjection.cs:20-57). It registers + both adapters as typed HTTP clients, the drain as a hosted service (DependencyInjection.cs:46), and the + sweep as a scheduled job (DependencyInjection.cs:54). That last registration carries a nuance worth + reading: the job is registered by the module, the way the framework's own audit-trail retention job + is, and it only actually runs in a host that also calls AddScheduledJobs and turns the scheduler on, + which MMCA.ADC.Conference.Service does + (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:313); anywhere else the registration + is inert (DependencyInjection.cs:48-53). The Anthropic client gets a custom resilience policy: a + 5-minute HttpClient.Timeout and the anthropic-version: 2023-06-01 header + (DependencyInjection.cs:31-33), then RemoveAllResilienceHandlers() followed by a re-added + StandardResilienceHandler with a 3-minute attempt timeout, a 7-minute circuit-breaker sampling window, + a 5-minute total request timeout, and only one retry (DependencyInjection.cs:35-42). The inline + comment explains why (DependencyInjection.cs:26-27): AI scoring of a large batch can take minutes, + which would blow through Aspire's default 30s attempt and 90s total limits, and retrying an expensive LLM + call aggressively is wasteful. This is a precise illustration of + ADR-009: every + outbound client is resilient by default, but a client with genuinely different latency characteristics + tunes the policy rather than disabling it. The Sessionize client takes the defaults unchanged.

        How it fits together at runtime

        Three flows tie the chapter together. Persistence flow: a Conference command handler mutates an aggregate and the unit of work saves; that resolves the concrete SQLServerDbContext over the @@ -442,13 +498,15 @@

        How it fits together at runtime

        adapter makes the outbound call inside the default Polly pipeline, and the parsed SessionizeResponse flows back for mapping. Scoring flow: the organizer POSTs to the scoring endpoint, the controller only calls TryEnqueue and returns 202 Accepted or 409 Conflict - (MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:110-128), + (MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:110-131), SessionScoringProcessor picks the event up, evicts the sessions cache tag, claims the event's distributed lock, runs the scoped command handler which calls AnthropicScoringService once per session under the tuned resilience policy, - persists one SessionAiScore row per session behind the unique filtered index, and evicts the tag again. - The two marker types in this assembly, AssemblyReference and - ClassReference (MMCA.ADC.Conference.Infrastructure/AssemblyReference.cs:5 and + persists one SessionAiScore row per session behind the unique filtered index, and evicts the tag again; + if that run dies mid-pass, SessionScoringSweepJob notices the partial result + within five minutes and puts the event back on the queue. The two marker types in this assembly, + AssemblyReference and ClassReference + (MMCA.ADC.Conference.Infrastructure/AssemblyReference.cs:5 and MMCA.ADC.Conference.Infrastructure/AssemblyReference.cs:11), exist purely so the module loader and the configuration-assembly scan can reach this assembly by a stable typeof() handle instead of a hard-coded type list, the same extension point every module assembly provides.

        @@ -680,18 +738,18 @@

        CategoryItemConfiguration

        MMCA.ADC.Conference.Infrastructure · MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/CategoryItemConfiguration.cs:10 · Level 8 · class

      -
    • What it is: the EF Core persistence map for the CategoryItem entity: column facets, the parent relationship to Category, and a composite unique index. It is the smallest complete member of the sixteen-class configuration family in this folder, so it is the one this chapter uses to teach the shared shape.

      +
    • What it is: the EF Core persistence map for the CategoryItem entity: column facets, the parent relationship to Category, and a composite unique index. It is the smallest complete member of the seventeen-class configuration family in this folder, so it is the one this chapter uses to teach the shared shape.

    • Depends on: first-party: EntityTypeConfigurationSQLServer<TEntity, TIdentifierType> (base, :11), CategoryItem, Category, CategoryInvariants (:19). External: Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<T>.

    • Concept introduced, the per-entity configuration class and what the base already did. Every configuration in this folder is an internal sealed class deriving from EntityTypeConfigurationSQLServer<TEntity, TIdentifierType> and overriding one method, Configure(EntityTypeBuilder<TEntity> builder), whose first statement is always base.Configure(builder) (:16). Knowing exactly what that base call does is what stops you re-declaring things by hand:

        -
      • EntityTypeConfigurationSQLServer is a shim with no body (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/EntityTypeConfigurationSQLServer.cs:17). Its whole contribution is the [UseDataSource(DataSource.SQLServer)] attribute it carries (:16), an instance of UseDataSourceAttribute.
      • -
      • The real work is in EntityTypeConfiguration<TEntity, TIdentifierType>. Its Configure reads the attribute off GetType() and throws if it is missing (EntityTypeConfiguration.cs:43-46), then calls ApplyEngineConventions (:48). For DataSource.SQLServer that means ToTable(typeof(TEntity).Name, NamespaceConventions.GetModuleName(typeof(TEntity)) ?? "dbo"), so table name comes from the CLR type and schema comes from the module segment of the entity's namespace (:66), then HasKey(p => p.Id) (:67) and either ValueGeneratedOnAdd() or ValueGeneratedNever() depending on IsIdValueGenerated (:68-71).
      • +
      • EntityTypeConfigurationSQLServer is a shim with no body (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/EntityTypeConfigurationSQLServer.cs:17-20). Its whole contribution is the [UseDataSource(DataSource.SQLServer)] attribute it carries (:16), an instance of UseDataSourceAttribute.
      • +
      • The real work is in EntityTypeConfiguration<TEntity, TIdentifierType>. Its Configure (EntityTypeConfiguration.cs:37) reads the attribute off GetType() and throws if it is missing (:43-46), then calls ApplyEngineConventions (:48). For DataSource.SQLServer that means ToTable(typeof(TEntity).Name, NamespaceConventions.GetModuleName(typeof(TEntity)) ?? "dbo"), so the table name comes from the CLR type and the schema comes from the module segment of the entity's namespace (:66), then HasKey(p => p.Id) (:67) and either ValueGeneratedOnAdd() or ValueGeneratedNever() depending on IsIdValueGenerated (:68-71).
      • Below that, EntityTypeConfigurationBase<TEntity, TIdentifierType> does exactly one thing: builder.Ignore(nameof(AuditableAggregateRootEntity<>.DomainEvents)) for aggregate roots (EntityTypeConfigurationBase.cs:29-32), keeping the in-memory event list out of the schema.
      • What the base chain does not do is equally important. The soft-delete global query filter, the rowversion concurrency token and the soft-delete index convention are installed by the context, not by these classes: ApplicationDbContext adds the query filter at ApplicationDbContext.cs:348, marks the concurrency property at :469 and :473, and registers SoftDeleteUniqueIndexConvention at :296. So a configuration class in this folder is only ever about this entity's columns, relationships and indexes.
      -

      Because the engine is pinned entirely by the base type, re-pointing a Conference entity at SQLite or Cosmos is a base-class swap with no edit to the body of Configure: the domain entity, the handlers and everything above stay untouched. All sixteen Conference configurations use the SQL Server base, since ADC runs SQL Server only.

      +

      Because the engine is pinned entirely by the base type, re-pointing a Conference entity at SQLite or Cosmos is a base-class swap with no edit to the body of Configure: the domain entity, the handlers and everything above stay untouched. All seventeen Conference configurations use the SQL Server base, since ADC runs SQL Server only.

      [Rubric §8, Data Architecture] assesses whether persistence is designed deliberately (typed lengths, correct nullability, FK relationships, purposeful indexes) rather than left to convention defaults: this family is where all of that lives for the Conference database. [Rubric §3, Clean Architecture] assesses dependency direction: EF mapping is confined to Infrastructure, and the domain entities carry zero EF attributes, so the domain layer stays framework-free.

    • Concept introduced, length constants sourced from the domain invariants. Nearly every HasMaxLength call in this folder reads a constant from the entity's …Invariants class instead of a literal. Here it is CategoryInvariants.CategoryItemNameMaxLength (:19). The same constant is what the Application layer's FluentValidation rules use, so the column width and the request validator are a single source of truth: change the constant once and both move. [Rubric §16, Maintainability] assesses exactly this kind of single-definition-point discipline.

      @@ -720,14 +778,14 @@

      ConferenceCategoryConfiguration

    • Depends on: first-party: EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, Category, CategoryInvariants. External: Microsoft.EntityFrameworkCore (for ToTable).
    • Concept: the shared shape is taught under CategoryItemConfiguration; the only new idea here is the deliberate name/table split.
    • Walkthrough
        -
      • Class name (:13-14): the type is ConferenceCategoryConfiguration, not CategoryConfiguration. The XML doc (:8-12) gives the reason: the ADC codebase carries more than one Category concept, and a distinct configuration class name avoids ambiguity for a reader scanning the folder.
      • +
      • Class name (:13-14): the type is ConferenceCategoryConfiguration, not CategoryConfiguration. The XML doc (:8-12) gives the reason: more than one Category concept exists in the wider codebase vocabulary, and a distinct configuration class name avoids ambiguity for a reader scanning the folder.
      • Explicit table mapping (:24): builder.ToTable("Category", "Conference"). The comment (:21-23) is honest that this is redundant, the base would already derive Category from typeof(Category).Name and Conference from the namespace; it is written out for clarity given the class-name mismatch above.
      • Columns (:26-35): Title required at CategoryInvariants.TitleMaxLength; Sort required; Type optional with a literal HasMaxLength(100), one of the few places in the family that does not read a constant.
    • Why it's built this way: naming the configuration for the bounded context rather than for the CLR type is a small readability trade: the class is findable by module, and the explicit ToTable keeps the physical target visible at the call site rather than implied by a base-class convention two files away.
    • Where it's used: same discovery path as the rest of the family (see CategoryItemConfiguration).
    • -
    • Caveats / not-in-source: the doc comment cites a Catalog-module Category as the collision being avoided. Catalog is a MMCA.Store module, not an ADC one, so within this repo nothing would actually collide; treat the comment as historical rationale carried over from the shared framework vocabulary.
    • +
    • Caveats / not-in-source: the doc comment (:10-11) cites a Catalog-module Category as the collision being avoided. Catalog is a MMCA.Store module, not an ADC one, so within this repo nothing would actually collide; treat the comment as rationale carried over from the shared framework vocabulary.

    EventConfiguration

    @@ -737,16 +795,16 @@

    EventConfiguration

    • What it is: the persistence map for Event, the top aggregate of the Conference module (the conference itself: dates, venue, publication state, Sessionize linkage).
    • Depends on: first-party: EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, Event, EventInvariants, QuestionModerationDefault. External: Microsoft.EntityFrameworkCore.
    • -
    • Concept reinforced, the filtered non-unique index. HasIndex(p => p.SessionizeCode).HasFilter("[SessionizeCode] IS NOT NULL") (:41-42) is filtered but not unique. A filtered index only covers the rows matching its predicate, so this one indexes just the events that carry a Sessionize code, which is the population the import path looks up by. It deliberately does not forbid two events sharing a code, and it costs nothing for the (many) events with a null code. [Rubric §12, Performance and Scalability] assesses whether indexes are chosen for the actual query shape rather than sprayed across columns: this is a narrow index sized to one lookup.
    • +
    • Concept reinforced, the filtered non-unique index. HasIndex(p => p.SessionizeCode).HasFilter("[SessionizeCode] IS NOT NULL") (:41-42) is filtered but not unique. A filtered index only covers the rows matching its predicate, so this one indexes just the events that carry a Sessionize code, which is the population the import path looks up by. It deliberately does not forbid two events sharing a code, and it costs nothing for the events with a null code. [Rubric §12, Performance and Scalability] assesses whether indexes are chosen for the actual query shape rather than sprayed across columns: this is a narrow index sized to one lookup.
    • Walkthrough
        -
      • Required core (:19-35): Name (EventInvariants.NameMaxLength), StartDate, EndDate, and TimeZone (EventInvariants.TimeZoneMaxLength). Storing the IANA time-zone id as a column rather than baking a UTC offset into the dates is what lets the schedule render correctly across DST.
      • -
      • Optional descriptive and venue columns (:23-25, :44-62): Description, VenueAddress, VenueMapUrl, WiFiInfo, OrganizerContactEmail, SponsorshipPacketUrl, each IsRequired(false) with its own invariant-sourced max length.
      • -
      • Sessionize linkage (:37-42, :71-75): SessionizeCode optional plus the filtered index above; LastSessionizeRefreshOn / LastSessionizeRefreshBy are optional audit-style columns recording the last import run. [Rubric §13, Observability and Operability] assesses whether the system records the provenance of imported data: these two columns answer "when was this event last synced, and by whom" from the row itself.
      • -
      • State flags (:64-69): IsPublished required; QuestionModerationDefault required, with the comment (:67) noting it is stored as an int through EF's default enum conversion and that Pending (0) is the safe default per BR-233. There is no HasConversion call, EF's default enum-to-int mapping is used as-is, so the safe default is also the zero value in the database.
      • +
      • Required core (:19-21, :27-35): Name (EventInvariants.NameMaxLength), StartDate, EndDate, and TimeZone (EventInvariants.TimeZoneMaxLength). Storing the IANA time-zone id as a column rather than baking a UTC offset into the dates is what lets the schedule render correctly across DST.
      • +
      • Optional descriptive, venue and link columns (:23-25, :44-66): Description, VenueAddress, VenueMapUrl, WiFiInfo, OrganizerContactEmail, SponsorshipPacketUrl and TicketingUrl, each IsRequired(false) with its own invariant-sourced max length.
      • +
      • Sessionize linkage (:37-42, :75-79): SessionizeCode optional plus the filtered index above; LastSessionizeRefreshOn / LastSessionizeRefreshBy are optional audit-style columns recording the last import run. [Rubric §13, Observability and Operability] assesses whether the system records the provenance of imported data: these two columns answer "when was this event last synced, and by whom" from the row itself.
      • +
      • State flags (:68-73): IsPublished required; QuestionModerationDefault required, with the comment (:71) noting it is stored as an int through EF's default enum conversion and that Pending (0) is the safe default per BR-233. There is no HasConversion call, EF's default enum-to-int mapping is used as-is, and the enum really does declare Pending = 0 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/QuestionModerationDefault.cs:10), so the safe default is also the zero value in the database.
    • Why it's built this way: everything the organizer may not know at creation time is nullable, so an event can be created early and enriched later without a two-phase workflow; only the four facts that make an event an event are required.
    • -
    • Where it's used: Event is the FK target of RoomConfiguration, SessionConfiguration, EventSpeakerConfiguration, EventQuestionAnswerConfiguration and SponsorConfiguration.
    • +
    • Where it's used: Event is the FK target of RoomConfiguration, SessionConfiguration, EventSpeakerConfiguration, EventQuestionAnswerConfiguration, ActivityConfiguration and SponsorConfiguration.

    EventQuestionAnswerConfiguration

    @@ -758,7 +816,7 @@

    EventQuestionAnswerConfiguration

  • Depends on: first-party: EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, EventQuestionAnswer, Event, EventInvariants, IndexBuilderExtensions (HasSoftDeleteFilter). External: Microsoft.EntityFrameworkCore.Metadata.Builders.
  • Concept introduced, HasSoftDeleteFilter() and the database as the concurrency backstop.
    • HasSoftDeleteFilter() (IndexBuilderExtensions.cs:50-64) replaces a hand-typed HasFilter("[IsDeleted] = 0"). It builds the predicate through SoftDeleteFilterSql from the live model (:56), so a renamed soft-delete column follows automatically and the identifier quoting comes from the engine instead of a SQL-Server-shaped literal. Its engine parameter defaults to DataSource.SQLServer (:51), which is exactly what the …SQLServer base already implies. On a unique index the call is technically redundant with SoftDeleteUniqueIndexConvention, which would apply the same predicate at model finalizing; writing it explicitly keeps the intent readable at the call site, and because the convention skips any index that already declares a filter (SoftDeleteUniqueIndexConvention.cs:53) the two can never disagree. On a non-unique index like the EventId lookup here, the convention deliberately does nothing, so the explicit call is the only way to get the filter.
    • -
    • The (EventId, QuestionId, CreatedBy) unique index (:42-44) is a race backstop, and the comment (:39-41) is unusually candid about why: the application-level upsert only inspects the in-memory collection, so two concurrent submits can both take the create branch. The database refuses the second one, and the shared DbUpdateException handler turns the violation into a 409 for the client. [Rubric §8, Data Architecture] assesses whether invariants that matter are enforced where they cannot be raced, and [Rubric §15, Best Practices and Code Quality] assesses whether known limitations are documented at the point of the compensating control rather than left for the next reader to discover.
    • +
    • The (EventId, QuestionId, CreatedBy) unique index (:42-44) is a race backstop, and the comment (:38-41) is unusually candid about why: the application-level upsert only inspects the in-memory collection, so two concurrent submits can both take the create branch. The database refuses the second one, and the shared DbUpdateException handler turns the violation into a 409 for the client. [Rubric §8, Data Architecture] assesses whether invariants that matter are enforced where they cannot be raced, and [Rubric §15, Best Practices and Code Quality] assesses whether known limitations are documented at the point of the compensating control rather than left for the next reader to discover.
  • Walkthrough: required EventId and QuestionId scalars (:19-23); required AnswerValue at EventInvariants.AnswerValueMaxLength (:25-27); required parent relationship HasOne(p => p.Event).WithMany(p => p.EventQuestionAnswers).HasForeignKey(p => p.EventId) (:29-32); soft-delete-filtered lookup index on EventId (:35-36); the BR-123 filtered unique index (:42-44).
  • @@ -821,7 +879,7 @@

    QuestionConfiguration

  • What it is: the persistence map for Question, the definition of a feedback question (its text, what it attaches to, how it renders, and where it came from).
  • Depends on: first-party: EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, Question, QuestionInvariants. External: Microsoft.EntityFrameworkCore.Metadata.Builders.
  • Concept: the shared shape is taught under CategoryItemConfiguration. What is worth noticing here is that this is the flattest configuration in the folder: six required properties, no relationships and no indexes at all.
  • -
  • Walkthrough (:18-38): all six columns are IsRequired(). QuestionText, QuestionEntity, QuestionType and QuestionSource each take their length from QuestionInvariants; Sort and IsRequired (the boolean, not the fluent call) are plain required scalars. QuestionEntity and QuestionType are stored as strings, not enums, so adding a question type or a new attachable entity needs no migration and no enum-to-string conversion.
  • +
  • Walkthrough (:18-38): all six columns are IsRequired(). QuestionText, QuestionEntity, QuestionType and QuestionSource each take their length from QuestionInvariants; Sort and IsRequired (the boolean, not the fluent call) are plain required scalars. QuestionEntity and QuestionType are declared as string on the entity (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/Question.cs:20, :23), not enums, so adding a question type or a new attachable entity needs no migration and no enum-to-string conversion.
  • Why it's built this way: questions are attached to events, sessions and speakers by the three …QuestionAnswer entities, and those answers carry a plain QuestionId scalar rather than a navigation, so Question itself needs no relationship configuration. Modelling the discriminators as strings keeps the question catalogue extensible from data rather than from code.
  • Where it's used: referenced by QuestionId from EventQuestionAnswerConfiguration, SessionQuestionAnswerConfiguration and SpeakerQuestionAnswerConfiguration.
  • @@ -836,7 +894,7 @@

    RoomConfiguration

  • Concept introduced, re-declaring an index EF would otherwise drop. The explicit builder.HasIndex(p => p.EventId) (:48) looks redundant next to the (EventId, Name) composite below it, and the comment (:46-47) says exactly why it is not: EF removes the conventional foreign-key index as redundant once a composite index leads with the same column, but the composite is filtered, and the plain FK lookups still want an unfiltered index. This is a good example of a mapping decision that only makes sense once you know EF's own de-duplication rule; without the comment the line reads as a mistake. [Rubric §12, Performance and Scalability] assesses whether index choices survive framework conventions rather than being silently optimized away.
  • Walkthrough
    • Required (:19-24): Name at EventInvariants.RoomNameMaxLength, and Sort.
    • -
    • Optional (:26-39): Capacity (a nullable scalar with no length), plus Floor, Location and AccessibilityInfo, each with an invariant-sourced max length. AccessibilityInfo being a first-class room column, not a note bolted onto the description, is the schema-level half of ADC's WCAG commitment. [Rubric §21, Accessibility] assesses whether accessibility is designed into the data rather than added at the view.
    • +
    • Optional (:26-39): Capacity (a nullable scalar with no length), plus Floor, Location and AccessibilityInfo, each with an invariant-sourced max length. AccessibilityInfo being a first-class room column, not a note bolted onto the description, is the schema-level half of ADC's accessibility commitment. [Rubric §21, Accessibility] assesses whether accessibility is designed into the data rather than added at the view.
    • Parent relationship (:41-44): required HasOne(p => p.Event).WithMany(p => p.Rooms).HasForeignKey(p => p.EventId).
    • Indexes (:48, :52-54): the re-declared plain EventId index, then HasIndex(p => new { p.EventId, p.Name }).IsUnique().HasSoftDeleteFilter(). The comment (:50-51) states its purpose plainly: it backstops the aggregate's duplicate-room-name invariant, and the soft-delete filter means a deleted room never blocks reusing its name.
    @@ -853,11 +911,11 @@

    SessionAiScoreConfiguration

  • What it is: the persistence map for SessionAiScore, the row that stores a language model's rating of one session across seven dimensions plus its written reasoning.
  • Depends on: first-party: EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, SessionAiScore, IndexBuilderExtensions. External: Microsoft.EntityFrameworkCore.Metadata.Builders.
  • Concept introduced, sizing a decimal column to the value's actual range. Each of the seven score columns is declared HasPrecision(3, 1), that is decimal(3,1): three total digits, one after the point (:22-48). That is the smallest exact-decimal shape that holds a one-decimal rating without the rounding surprises a float/double column would introduce. Choosing exact decimal for a value that is compared and sorted, rather than binary floating point, is the point. [Rubric §8, Data Architecture] assesses type fidelity of stored values.
  • -
  • Concept reinforced, recording the provenance of derived data. ModelUsed (:54-56, max 100) and Reasoning (:50-52, max 4000) are both required. Persisting which model produced a score, and the sentence explaining it, alongside the numbers is what makes an AI judgement auditable: you can tell after the fact whether a given score came from a model you have since replaced. [Rubric §13, Observability and Operability] assesses whether derived values carry enough context to be explained later.
  • -
  • Walkthrough: required SessionId scalar (:19-20); seven decimal(3,1) required score columns, OverallScore, TopicRelevanceScore, DescriptionQualityScore, NoveltyScore, ActionableTakeawaysScore, DepthOrInsightQualityScore, CredibilityExperienceScore (:22-48); required Reasoning and ModelUsed (:50-56); and HasIndex(p => p.SessionId).IsUnique().HasSoftDeleteFilter() (:59-61), commented "One score per session (among non-deleted)". There is no HasOne relationship to Session: SessionId is a plain scalar, so the score row is not a child of the session aggregate.
  • +
  • Concept reinforced, recording the provenance of derived data. ModelUsed (:54-56, a literal max length of 100) and Reasoning (:50-52, a literal max length of 4000) are both required, and both are among the few columns in this folder whose lengths are written as literals rather than read from an invariants class. Persisting which model produced a score, and the sentence explaining it, alongside the numbers is what makes an AI judgement auditable: you can tell after the fact whether a given score came from a model you have since replaced. [Rubric §13, Observability and Operability] assesses whether derived values carry enough context to be explained later.
  • +
  • Walkthrough: required SessionId scalar (:19-20); seven decimal(3,1) required score columns, OverallScore, TopicRelevanceScore, DescriptionQualityScore, NoveltyScore, ActionableTakeawaysScore, DepthOrInsightQualityScore, CredibilityExperienceScore (:22-48); required Reasoning and ModelUsed (:50-56); and HasIndex(p => p.SessionId).IsUnique().HasSoftDeleteFilter() (:59-61), commented "One score per session (among non-deleted)" (:58). There is no HasOne relationship to Session: SessionId is a plain scalar, so the score row is not a child of the session aggregate.
  • Why it's built this way: keeping the score in its own table behind a unique-per-session index means re-scoring is a soft-delete plus insert (the filter frees the slot) rather than an in-place overwrite, and the previous scoring run stays on disk for comparison.
  • Where it's used: written by the Conference scoring pipeline, whose adapter and processor are covered earlier in this chapter under AnthropicScoringService and SessionScoringProcessor.
  • -
  • Caveats / not-in-source: this configuration only defines the table. Whether scoring runs in a given environment is a configuration and feature-gating question decided outside this file. Note also that ModuleApplicationDbContext declares no DbSet for SessionAiScore, and nothing breaks, because that manifest does not drive the model.
  • +
  • Caveats / not-in-source: this configuration only defines the table. Whether scoring runs in a given environment is a configuration and feature-gating question decided outside this file. Note also that ModuleApplicationDbContext declares no DbSet for SessionAiScore (its fifteen sets are listed at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:28-70), and nothing breaks, because that manifest does not drive the model.

  • SpeakerCategoryItemConfiguration

    @@ -881,19 +939,19 @@

    SpeakerConfiguration

  • Depends on: first-party: EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, Speaker, SpeakerInvariants, NullableEmailValueConverter, and transitively the Email value object. External: Microsoft.EntityFrameworkCore.

  • -
  • Concept introduced, mapping a value object with HasConversion instead of OwnsOne. builder.Property(p => p.Email).HasConversion(new NullableEmailValueConverter()) (:42-43) round-trips the Email? value object to a plain nullable string column. The converter (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conversions/EmailValueConverter.cs:60-70) passes null straight through on both legs, so "no email" stays a SQL NULL rather than becoming an empty string or a failed Email.Create call. Two design points worth carrying forward:

    +
  • Concept introduced, mapping a value object with HasConversion instead of OwnsOne. builder.Property(p => p.Email).HasConversion(new NullableEmailValueConverter()) (:42-43) round-trips the Email? value object to a plain nullable string column. The converter (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conversions/EmailValueConverter.cs:60-71) passes null straight through on both legs (:67-68), so "no email" stays a SQL NULL rather than becoming an empty string or a failed Email.Create call. Two design points worth carrying forward:

    • Why HasConversion and not OwnsOne: the backing column stays a plain string, so adopting the value object on a property that used to be a string is not a schema change (EmailValueConverter.cs:8-10).
    • Facets stay at the call site: the converter deliberately owns no length or requiredness, which is why HasMaxLength(SpeakerInvariants.EmailMaxLength) and IsRequired(false) are chained here (:44-45). Those differ per entity and are not the converter's business (EmailValueConverter.cs:20-22).

    [Rubric §4, DDD] assesses whether value objects survive the trip to storage instead of being flattened into primitives at the boundary. [Rubric §16, Maintainability] applies too: the conversion logic lives once in MMCA.Common, so every entity with an email gets identical semantics.

  • -
  • Concept reinforced, the partially filtered unique index. HasIndex(p => p.LinkedUserId).IsUnique().HasFilter("[LinkedUserId] IS NOT NULL") (:63-65) enforces the one-to-one User to Speaker link only among speakers that have one. Without the predicate, SQL Server would treat multiple NULLs as duplicates and allow at most one unlinked speaker, which would be nonsense. Note this one is a hand-written literal rather than HasSoftDeleteFilter(), because the predicate is about LinkedUserId, not about soft delete; the soft-delete clause is added on top automatically, since the index is unique and the convention only skips indexes that already have a filter (SoftDeleteUniqueIndexConvention.cs:53).

    +
  • Concept reinforced, the partially filtered unique index. HasIndex(p => p.LinkedUserId).IsUnique().HasFilter("[LinkedUserId] IS NOT NULL") (:63-65) enforces the one-to-one User to Speaker link only among speakers that have one. Without the predicate, SQL Server would treat multiple NULLs as duplicates and allow at most one unlinked speaker, which would be nonsense. Note this one is a hand-written literal rather than HasSoftDeleteFilter(), because the predicate is about LinkedUserId, not about soft delete; the soft-delete clause is not added on top, because SoftDeleteUniqueIndexConvention skips any index that already declares a filter (SoftDeleteUniqueIndexConvention.cs:53). A soft-deleted linked speaker therefore keeps holding its LinkedUserId slot.

  • Walkthrough

    • Required identity (:20-26, :39-40): FirstName, LastName, IsTopSpeaker.
    • -
    • Optional profile (:28-37, :47-61): Bio (no max length, so nvarchar(max)), TagLine, ProfilePicture, TwitterHandle, LinkedInUrl, GitHubUrl, WebsiteUrl, each length-capped from SpeakerInvariants.
    • +
    • Optional profile (:28-37, :47-61): Bio (no max length, so nvarchar(max)), TagLine, ProfilePicture, TwitterHandle, LinkedInUrl, GitHubUrl, WebsiteUrl, each length-capped from SpeakerInvariants except Bio.
    • Email (:42-45) and the LinkedUserId index (:63-65), described above.
    • Computed property excluded (:68): builder.Ignore(p => p.FullName) keeps the derived FullName out of the schema. Ignoring computed properties explicitly is how this codebase keeps derived state a domain concern and off the table.
    @@ -911,12 +969,32 @@

    SpeakerQuestionAnswerConfiguration
  • What it is: the persistence map for SpeakerQuestionAnswer, a speaker's answer to a speaker-scoped Question (the fields Sessionize collects on a submission form).
  • Depends on: first-party: EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, SpeakerQuestionAnswer, Speaker, SpeakerInvariants. External: Microsoft.EntityFrameworkCore.Metadata.Builders.
  • -
  • Concept: the answer-entity shape is taught under EventQuestionAnswerConfiguration. This is the stripped-down member of the three: it declares no indexes at all.
  • +
  • Concept: the answer-entity shape is taught under EventQuestionAnswerConfiguration. This is the stripped-down member of the three: it declares no indexes at all, and it is the only one of the three that does not import MMCA.Common.Infrastructure.Persistence.Configuration, because it never needs HasSoftDeleteFilter().
  • Walkthrough (:18-31): required SpeakerId, QuestionId and AnswerValue (at SpeakerInvariants.AnswerValueMaxLength), then HasOne(p => p.Speaker).WithMany(p => p.SpeakerQuestionAnswers).HasForeignKey(p => p.SpeakerId).IsRequired(). Only the conventional EF index on the SpeakerId foreign key exists.
  • Why it's built this way: these rows arrive from the Sessionize import as part of a speaker payload and are read back with the speaker, never queried independently or submitted concurrently by two authors, so neither the BR-123 anti-race unique index nor an extra lookup index earns its cost here. Contrast with the event and session answer configurations, where an attendee-facing form can be double-submitted.
  • Where it's used: populated by the Sessionize sync path and read as part of the speaker detail projection.

  • +

    ActivityConfiguration

    +
    +

    MMCA.ADC.Conference.Infrastructure · MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/ActivityConfiguration.cs:11 · Level 9 · class

    +
    +
      +
    • What it is: the persistence map for Activity, a social or networking item attached to an event (a pre-conference party, a coffee connect, an after-party) that is deliberately not a session: no room, no speakers, and often an external venue carried on the row itself.
    • +
    • Depends on: first-party: EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, Activity, ActivityInvariants, Event, IndexBuilderExtensions. External: Microsoft.EntityFrameworkCore.Metadata.Builders.
    • +
    • Concept introduced, indexing for the sort, not just for the filter. The second index (:63-64) is HasIndex(p => new { p.EventId, p.StartTime, p.SortOrder }).HasSoftDeleteFilter(): non-unique, filtered, and composed in exactly the order the public agenda page consumes. The comment (:61-62) states the intent: the page filters by one event and orders by start time then sort order, so the composite serves the browse query directly instead of the database pulling the event slice and sorting it afterwards. This is the one place in the folder where an index's column order is chosen for an ORDER BY rather than for a lookup predicate, and it is worth reading alongside the narrower lookup index above it (:58-59, plain EventId with the same soft-delete filter). Contrast RoomConfiguration, whose paired indexes exist because the composite is filtered and the FK lookup wanted an unfiltered one; here both carry the filter, because every read of this table goes through the global query filter. [Rubric §12, Performance and Scalability] assesses whether index shape follows the queries that actually run.
    • +
    • Concept reinforced, event-local wall-clock time. StartTime and EndTime are required plain date-times with no offset column (:29-33), and the comment (:27-28) is explicit that this mirrors Session.StartsAt/EndsAt: the IANA zone lives once on the owning Event (see EventConfiguration) and is never repeated per row. The domain entity says the same thing at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:28-33. Storing one zone for the whole programme is what keeps a schedule internally consistent when an activity is moved. [Rubric §16, Maintainability] assesses single-definition-point discipline, and this is the time-zone instance of it.
    • +
    • Walkthrough
        +
      • Required (:19-21, :29-33, :47-51): Name at ActivityInvariants.NameMaxLength (200, ActivityInvariants.cs:13), StartTime, EndTime, SortOrder, and the EventId scalar.
      • +
      • Optional (:23-25, :35-45): Description (ActivityInvariants.DescriptionMaxLength), VenueName, VenueAddress and VenueUrl, each IsRequired(false) with its own invariant-sourced max length. An absent VenueName is a meaningful value rather than missing data: the domain invariant says so (ActivityInvariants.cs:38-40), and the public page falls back to the event venue.
      • +
      • Event relationship (:53-56): required HasOne(p => p.Event).WithMany().HasForeignKey(p => p.EventId), with the parameterless WithMany(), so Event exposes no activities collection. The same one-sided-navigation choice is made in SessionConfiguration: activities are read by explicit event-scoped queries, not by walking the event aggregate.
      • +
      • Indexes (:58-59, :63-64): the filtered EventId lookup, then the filtered (EventId, StartTime, SortOrder) browse index described above. Neither is unique, so SoftDeleteUniqueIndexConvention would not have touched either one, which is why both spell out HasSoftDeleteFilter().
      • +
      +
    • +
    • Why it's built this way: an activity is a first-class row rather than a flavour of session because it has a different shape (its own venue, no room, no speakers), and separating it keeps the session table free of columns that only apply to parties. [Rubric §4, DDD] assesses whether the model names distinct concepts distinctly instead of overloading one entity with a type discriminator.
    • +
    • Where it's used: exposed as DbSet<Activity> Activities on ModuleApplicationDbContext (ModuleApplicationDbContext.cs:70); read by the public agenda queries and written by the organizer-facing activity commands.
    • +
    +

    SessionCategoryItemConfiguration

    MMCA.ADC.Conference.Infrastructure · MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SessionCategoryItemConfiguration.cs:11 · Level 9 · class

    @@ -941,8 +1019,8 @@

    SessionConfiguration

  • Walkthrough
    • Required (:20-22, :38-48, :69-70): Title at SessionInvariants.TitleMaxLength; four booleans, IsInformed, IsConfirmed, IsServiceSession, IsPlenumSession; and the EventId scalar.
    • Optional (:24-36, :50-64, :80-81): Description, StartsAt, EndsAt, Status, LiveUrl, RecordingUrl, AccessibilityInfo, ResourceLinks, RoomId. That StartsAt, EndsAt and RoomId are all nullable is the schema admitting that a session exists as an accepted talk long before it is scheduled.
    • -
    • Status is a plain string (:34-36) capped at SessionInvariants.StatusMaxLength, not an enum with a conversion, so adding a status value needs no migration.
    • -
    • Computed property excluded (:67): builder.Ignore(p => p.Duration), since Duration is derived from StartsAt and EndsAt.
    • +
    • Status is a plain string (:34-36) capped at SessionInvariants.StatusMaxLength, not an enum with a conversion (the entity declares it as string? at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:37), so adding a status value needs no migration.
    • +
    • Computed property excluded (:67): builder.Ignore(p => p.Duration), since Duration is derived from StartsAt and EndsAt (Session.cs:80).
    • Event relationship (:72-75) required, plus HasIndex(p => p.EventId).HasSoftDeleteFilter() (:77-78), a non-unique filtered lookup index for "all live sessions of this event", the single hottest read in the app.
    • Room relationship (:83-87) optional, with the Restrict behaviour described above.
    @@ -981,261 +1059,25 @@

    SponsorConfiguration

    MMCA.ADC.Conference.Infrastructure · MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SponsorConfiguration.cs:11 · Level 9 · class

    • -
    • What it is: the persistence map for Sponsor: a sponsoring organization's branding, tier, links, and optional expo-booth details, scoped to one Event.
    • -
    • Depends on: first-party: EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, Sponsor, SponsorTier, SponsorInvariants, Event, IndexBuilderExtensions. External: Microsoft.EntityFrameworkCore.Metadata.Builders.
    • -
    • Concept introduced, storing an enum as its underlying int on purpose. builder.Property(p => p.Tier).HasConversion<int>().IsRequired() (:25-27) makes the SponsorTier enum a plain int column. EF would map an enum to int by default anyway, so the value of writing it out is documentary: the comment (:23-24) states the two consequences the team wants pinned down, that tier ordering becomes a plain column sort (Platinum before Gold falls out of the numeric ordering, no lookup table and no CASE expression), and that adding a package later does not rewrite existing rows, which a string-backed enum with a renamed member would. [Rubric §8, Data Architecture] assesses whether a stored representation is chosen for the queries and the migrations it will have to survive. - Contrast this with Session.Status (a plain string, SessionConfiguration :34-36) and Question.QuestionType (also a string, QuestionConfiguration :26-28). The codebase does not apply one rule everywhere: values with a meaningful order are ints, open-ended vocabularies stay strings.
    • -
    • Walkthrough
        -
      • Required (:19-27, :49-53, :59-60): Name at SponsorInvariants.NameMaxLength; Tier as above; Sort; IsExhibitor; the EventId scalar.
      • -
      • Optional branding and links (:29-47): LogoUrl, Description, WebsiteUrl, LinkedInUrl, TwitterHandle, each length-capped from SponsorInvariants. A sponsor row is useful the moment it has a name and a tier; everything the sponsor sends over later is nullable.
      • -
      • Optional booth detail (:55-57): BoothNumber, paired with the required IsExhibitor flag. Sponsorship and exhibiting are separate facts: a sponsor can have a tier without a booth.
      • -
      • Event relationship (:62-65): required HasOne(p => p.Event).WithMany().HasForeignKey(p => p.EventId), with the parameterless WithMany(), so Event exposes no sponsors collection, the same one-sided-navigation choice made in SessionConfiguration.
      • -
      • Lookup index (:67-68): HasIndex(p => p.EventId).HasSoftDeleteFilter(), a non-unique filtered index for "all live sponsors of this event", which is exactly what the public sponsor wall queries.
      • -
      -
    • -
    • Why it's built this way: the sponsor wall is a public, cached read that always filters by event and orders by tier then Sort; making the tier an int and the event lookup a filtered index means that page is a single indexed range scan with an ordering the database can satisfy directly.
    • -
    • Where it's used: the Conference sponsor endpoints and the public sponsor wall UI; the schema is snapshotted by MMCA.ADC.Migrations.SqlServer.Conference like the rest of the family.
    • -
    -
    -

    ConferenceModuleDbSeeder

    -
    -

    MMCA.ADC.Conference.Infrastructure · MMCA.ADC.Conference.Infrastructure.Persistence.DbContexts.Seeding · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/Seeding/ConferenceModuleDbSeeder.cs:24 · Level 9 · class

    -
    -
      -
    • What it is: the Conference module's idempotent database seeder. It always seeds the two - conference events (Cloud + AI and Developers) and the standard feedback questions, and optionally - seeds sample browse data (speakers, sessions, event/session speaker links, and sponsors) when an - includeSampleData flag is set. It derives from the framework's - DbSeeder base - (ConferenceModuleDbSeeder.cs:24), which is the abstract implementation of - IDbSeeder.
    • -
    • Depends on: IUnitOfWork (the single constructor - dependency, :24), from which every seed method pulls a typed - IRepository<TEntity, TIdentifierType> - (:61, :130, :177, :230, :342); the domain factories for - Event, Question, - Speaker, Session - and Sponsor; the aggregate methods - Event.AddEventSpeaker and Session.AddSessionSpeaker that create the - EventSpeaker / - SessionSpeaker links; the - SponsorTier enum from the module's Shared project - (:6); and the reserved-id constants - QuestionInvariants.ManualIdRangeStart and - SessionInvariants.ManualIdRangeStart. Externals - are BCL only (DateOnly, TimeOnly, DateTime, tuple arrays, LINQ FirstOrDefault).
    • -
    • Concept reinforced, idempotent and environment-gated seeding through the domain factories. - [Rubric §17, DevOps & Deployment] assesses whether database initialization is repeatable and safe to - re-run on every start; [Rubric §14, Testability] assesses whether the system provides deterministic - fixtures a test tier can rely on. Every seed step asks the repository first - (ExistsAsync at :64, :98, :132, :189, :249, :359) and returns or continues when the row - is already there, so re-running against a seeded database writes nothing. [Rubric §4, DDD] shows up - in how the rows are written: seed data goes through the same Event.Create / Question.Create / - Speaker.Create / Session.Create / Sponsor.Create factories the command handlers use, each - returning a Result<T> that is checked for IsFailure before AddAsync (:85, :166, :206, - :275, :380), so seeded rows satisfy exactly the same invariants as user-created rows. There is no - raw-insert back door.
    • -
    • Walkthrough
        -
      • Constants and suppressions (:26-:38): the shared VenueAddress literal (:26), the venue - map embed URL (:29), the placeholder sample-sponsor website (:32), and the two published - sponsorship-packet URLs, one per event (:35, :38). Each URL constant carries its own narrowly - scoped S1075 (URIs should not be hardcoded) suppression with a written justification (:28, - :31, :34, :37). [Rubric §15, Best Practices & Code Quality] assesses exactly this: analyzer - suppressions are per-symbol and explained, never a blanket file- or project-level disable.
      • -
      • Constructor (:24): a primary constructor (IUnitOfWork unitOfWork, bool includeSampleData = false). - unitOfWork is null-guarded into a readonly field (:40) and the flag is copied (:41). The default - is false, so the production-safe path is the one you get by forgetting the argument.
      • -
      • SeedAsync (:44-:57): the ordered entry point. Three unconditional steps run first, the - Cloud + AI event, the Developers event, then the questions (:46-:48). Only when - _includeSampleData is true does it continue into SeedSpeakersAsync, SeedSessionsAsync, - SeedSampleEventLinksAsync, and SeedSponsorsAsync (:50-:56). That if is the entire - environment gate: real events and feedback questions always exist, sample browse rows exist only - where the caller asked for them.
      • -
      • SeedCloudAiConferenceEventAsync (:59-:92): the existence probe deliberately matches two - names, "2026 Atlanta Cloud + AI Conference" and the pre-rename "Atlanta Cloud + AI Conference" - (:64-:66, with the reason in the comment at :63), so a database seeded before the rename is not - given a duplicate. When absent it builds the event through Event.Create (:71-:83): single-day - 2026-05-30, America/New_York, Sessionize code z1ecmzux, the shared venue constants, organizer - contact atlcloudconf@gmail.com, and the Cloud + AI sponsorship packet URL. A failed Result simply - returns (:85-:86). It then calls eventResult.Value!.Publish() (:88) so the event is publicly - visible the moment it lands, and finishes with AddAsync + SaveChangesAsync (:90-:91).
      • -
      • SeedDevelopersConferenceEventAsync (:94-:126): structurally identical, one name only - ("2026 Atlanta Developers Conference", :99), 2026-10-17, Sessionize code sf1nopko, organizer - contact atldevcon@gmail.com, and the Developers-edition packet URL. Both events share the same - physical venue constants.
      • -
      • SeedQuestionsAsync (:128-:173): guarded by a single probe for "Rate the Session" with - QuestionSource == "User" (:132-:134), it then walks a literal tuple array of ten questions - (:139-:151): six Session-scoped (five Rating plus a free-text Comments) and four Event-scoped - (three Rating plus Comments). Ids are explicitly assigned, starting at - QuestionInvariants.ManualIdRangeStart and incrementing (:153, :158); that constant is - 999_999_000 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:37), - a reserved band sitting above any real Sessionize id so imported questions can never collide with - these. One SaveChangesAsync commits the whole set (:172).
      • -
      • SeedSpeakersAsync (:175-:215, sample-only): two sample speakers, Ada Lovelace and Alan - Turing (:179-:183), each existence-checked by first and last name individually (:189-:191) so - a partially seeded database is topped up rather than skipped wholesale. Both are created with - isTopSpeaker: true. An added flag means SaveChangesAsync is called only when something actually - changed (:213-:214), the same guard every sample step uses.
      • -
      • SeedSessionsAsync (:217-:284, sample-only): resolves both seeded events through the shared - GetSampleEventsAsync helper (:227-:228), then declares two sample sessions, one per event - (:236-:240): the keynote on the Cloud + AI event and the Azure talk on the Developers event. Ids - are assigned from SessionInvariants.ManualIdRangeStart and + 1 (:238-:239; the constant is - 999_999_000 at - MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:41), - because a Session's int primary key is its Sessionize id (comment at :232-:235), so app-created - sessions must take ids from a reserved high band. Start time is computed off the owning event's date, - sessionEvent.StartDate.ToDateTime(new TimeOnly(13, 0), DateTimeKind.Utc) (:257), one hour long - (:264), status "Accepted", no room.
      • -
      • SeedSampleEventLinksAsync (:286-:310, sample-only): loads the two sample speakers untracked - (:290-:295), bails if either is missing (:299-:300), then calls both link helpers and combines - their results with added |= (:305-:306), the non-short-circuiting operator, so the session-link - pass always runs even when the event-link pass reported nothing new.
      • -
      • LinkSampleEventSpeakersAsync (:312-:331): re-reads the events tracked and with the - EventSpeakers collection included (:316-:317, the include is required because the aggregate - checks that collection), then links Ada to Cloud + AI and Alan to Developers (:324, :327). - Idempotency here is delegated to the aggregate: Event.AddEventSpeaker returns a - Event.Speaker.Duplicate failure when a non-deleted link for that speaker already exists - (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:515-:522), so the - seeder only has to look at IsSuccess.
      • -
      • LinkSampleSessionSpeakersAsync (:410-:430): the same trick on the other side, sessions - loaded tracked with SessionSpeakers included (:414-:418), then each sample session gets its - matching speaker by title (:424-:425). Linking both paths is deliberate (comment at - :302-:304): the speakers-by-event filter has a direct EventSpeaker branch and a transitive - SessionSpeaker branch, and dev/CI data exercises both.
      • -
      • SeedSponsorsAsync (:333-:389, sample-only): four sample sponsors spread across the two - events (:344-:350), a Platinum and a Gold exhibitor with booth numbers on the Cloud + AI event, a - Silver and a Community non-exhibitor on the Developers event. Sponsors are per-event, so a null event - is skipped (:355-:356) and each name is existence-checked (:359-:361) before Sponsor.Create - (:366-:378).
      • -
      • GetSampleEventsAsync (:391-:408): a static helper that fetches both events in one tracked - GetAllAsync with a caller-supplied includes list (:397-:402), picks the Developers event by - exact name and treats "anything else in the result" as the Cloud + AI event (:404-:405), which is - how the pre-rename name keeps resolving. Passing includes in lets one helper serve both the - no-navigation callers (:228, :340) and the EventSpeakers-loading caller (:317).
      • -
      -
    • -
    • Why it's built this way: seeding through domain factories keeps seed rows valid by construction - rather than by hand-written SQL that drifts from the invariants; per-row existence probes make the - whole seeder safe to run on every startup, which is what the deployed hosts do (each service migrates - and seeds its own database, see ADR-006); - and the includeSampleData default of false keeps browse fixtures out of production while - guaranteeing dev and CI always have at least one session and one speaker. The class remarks - (:16-:23) name the two public-browse E2E tests (PublicBrowseTests.PublicSessionList_* / - PublicSpeakerList_*) that depend on exactly that guarantee.
    • -
    • Where it's used: constructed and driven by - ConferenceModuleSeeder - (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/ConferenceModuleSeeder.cs:28-:29), the - module's IModuleSeeder implementation, which - resolves IUnitOfWork from the service provider (ConferenceModuleSeeder.cs:21), reads - Seeding:IncludeSampleConferenceData from configuration (:26), and awaits SeedAsync. Module - seeders are invoked by ModuleLoader in module - registration order.
    • -
    • Caveats / not-in-source: (1) the seeder reads no configuration itself, the boolean is the caller's - decision, so which hosts set Seeding:IncludeSampleConferenceData=true is a configuration fact, not a - code fact (the class remarks at :17-:20 say the local AppHost and the E2E CI workflow do, and - production leaves it unset). (2) Idempotency is by name/title match, so renaming a seeded event, - question, speaker, session, or sponsor in the database causes the next run to insert a fresh copy; the - Cloud + AI probe carries an explicit second name (:65) precisely because that already happened once. - (3) In SeedQuestionsAsync a mid-loop factory failure returns before the single SaveChangesAsync - (:166-:167), so the already-added questions in that batch are never committed, deliberate - all-or-nothing behavior, but it means a partial question set is not possible and a silent no-op is. - (4) The comment at :223-:226 records that databases seeded before the sample sessions were split - across the two events keep the old both-on-one-event shape, because the skip-by-title check never moves - an existing row; the documented remedy is resetting the local SQL volume.
    • -
    -
    -

    ModuleApplicationDbContext

    -
    -

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

    -
    -
      -
    • What it is: the Conference module's abstract EF Core DbContext. It adds nothing but a typed - inventory: fourteen internal DbSet<T> properties naming the entities this module persists - (:27-:66), on top of the framework base - ApplicationDbContext.
    • -
    • Depends on: ApplicationDbContext (base, - :24) and its four constructor inputs, EF Core's DbContextOptions, IServiceProvider, - IEntityConfigurationAssemblyProvider, - and PhysicalDataSource (:20-:23); the - Conference domain entities Event, - Room, - EventSpeaker, - EventQuestionAnswer, - Session, - SessionSpeaker, - SessionQuestionAnswer, - SessionCategoryItem, - Speaker, - SpeakerCategoryItem, - Category, - CategoryItem, - Question, and - Sponsor (:27-:66). External: Microsoft.EntityFrameworkCore.
    • -
    • Concept reinforced, one context class per engine, not per module. [Rubric §8, Data Architecture] - assesses whether the persistence topology is a deliberate design rather than an accident of code - organization. The instinctive reading of this file, "each module has its own DbContext class", is - not how the runtime works. The context that actually executes queries is the framework's sealed - SQLServerDbContext - (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/SQLServerDbContext.cs:16), - one class per storage engine, instantiated once per physical database. Its OnModelCreating calls - ApplyConfigurationsForEntitiesInContext(DataSource.SQLServer, modelBuilder) - (SQLServerDbContext.cs:88), which scans every module assembly supplied by the - IEntityConfigurationAssemblyProvider for IEntityTypeConfigurationSQLServer<,> implementations and - applies only those whose entity maps to this instance's DataSourceKey - (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:610-:637, - filtering through EntityDataSourceRegistry). - In other words the EF model is built from the entity configurations, not from DbSet declarations, - and DataSourceModelCacheKeyFactory keys the model cache per data source so the same class can hold - different models per database. That is the design recorded in - ADR-006 and - ADR-018: splitting the context - per module is explicitly rejected, because engine choice, not module membership, is what a context - class encodes.
    • +
    • What it is: the EF Core persistence map for the Sponsor aggregate: eleven column facets, an enum-to-int conversion for the sponsorship tier, the required relationship to the owning Event, and one non-unique filtered lookup index. It is the newest member of the seventeen-class configuration family in this folder (seventeen *Configuration.cs files today).
    • +
    • Depends on: first-party: EntityTypeConfigurationSQLServer<TEntity, TIdentifierType> (base, :12), Sponsor, Event, SponsorInvariants (every HasMaxLength argument), SponsorTier (indirectly, through the Tier property it converts), and IndexBuilderExtensions for HasSoftDeleteFilter() (:68, imported at :3). External: Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<T> (:1).
    • +
    • Concept: the shared shape of this family, an internal sealed class over the SQL Server base whose Configure opens with base.Configure(builder) and therefore inherits table name, schema, key and value generation, is taught once under CategoryItemConfiguration. That section also explains why the length constants come from an ...Invariants class rather than from literals, and what HasSoftDeleteFilter() does. Only the two ideas below are new here.
    • +
    • Concept introduced, storing an enum as its underlying int on purpose. Tier is a SponsorTier, a four-member enum whose numeric values are deliberately the display order: Platinum = 0, Gold = 1, Silver = 2, Community = 3 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sponsors/SponsorTier.cs:15-24, with the ordering rationale in the doc comment at :4-6 and the CA1008 zero-member note at :9-10). The configuration spells the storage out, builder.Property(p => p.Tier).HasConversion<int>().IsRequired() (:25-27), and the comment above it (:23-24) gives both halves of the reason: the tier ordering stays a plain integer column sort, and adding a package later does not rewrite existing rows. The second half is the part worth internalizing. Appending a new member at the high end of the enum leaves every stored row valid, while re-numbering to slot a package into the middle would require a data migration. The shipped column is Tier int NOT NULL (MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Conference/Migrations/20260812202047_AddSponsors.cs:22). This is the only HasConversion<int>() in the Conference configuration folder; the one other converter in the family, SpeakerConfiguration's NullableEmailValueConverter (SpeakerConfiguration.cs:43), converts a value object, not an enum. [Rubric §8, Data Architecture] assesses whether column types are a deliberate choice rather than a convention default: writing the conversion at the call site pins the storage shape where a reader of the mapping will see it, instead of leaving it implied by provider convention two layers away.
    • +
    • Concept reinforced, a root that references another root by id. Sponsor is an aggregate root in its own right (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:18, sealed class Sponsor : AuditableAggregateRootEntity<SponsorIdentifierType>), not a child of the Event aggregate. The mapping shows that boundary directly: the relationship is declared HasOne(p => p.Event).WithMany().HasForeignKey(p => p.EventId).IsRequired() (:62-65), and WithMany() takes no navigation expression because Event exposes no Sponsors collection at all (Event.cs mentions sponsorship only as the scalar SponsorshipPacketUrl at :62). So a sponsor knows its event, an event does not enumerate its sponsors, and nothing can load a sponsor set by walking the event aggregate: reads go through a filter on EventId. [Rubric §4, DDD] assesses whether aggregate boundaries are drawn and then respected in the persistence layer; a one-way navigation is how that boundary gets enforced by the mapping rather than left to discipline. Contrast SessionAiScoreConfiguration, which goes one step further and maps no relationship at all, and SessionSpeakerConfiguration, whose WithMany(p => p.SessionSpeakers) names both ends because that row genuinely belongs to the session aggregate.
    • Walkthrough
        -
      • Primary constructor (:19-:24): four parameters, DbContextOptions options, - IServiceProvider serviceProvider, IEntityConfigurationAssemblyProvider assemblyProvider, and - PhysicalDataSource physicalDataSource, forwarded verbatim to the base (:24). No parameter is - stored, transformed, or validated here; the whole file is pass-through plus declarations.
      • -
      • The fourteen DbSet properties (:27-:66): aggregate roots (Events, Sessions, Speakers, - Categories, Questions, Sponsors), their children (Rooms, CategoryItems), and the join and - answer entities (EventSpeakers, EventQuestionAnswers, SessionSpeakers, - SessionQuestionAnswers, SessionCategoryItems, SpeakerCategoryItems). They are internal, not - public: nothing outside this assembly can reach a DbSet, which keeps application code on the - repository and unit-of-work abstractions instead of on EF directly.
      • -
      • Inherited behavior: the class body defines no overrides at all. Audit stamping, soft-delete and - tenant query filters, domain-event capture, and outbox persistence all come from the base and its - interceptors, AuditSaveChangesInterceptor - and DomainEventSaveChangesInterceptor, - which is how a Conference SaveChangesAsync writes an - OutboxMessage row in the same transaction as the - aggregate change (ADR-003).
      • +
      • Class declaration (:11-12): internal sealed class SponsorConfiguration : EntityTypeConfigurationSQLServer<Sponsor, SponsorIdentifierType>, the second type argument being the module's identifier alias.
      • +
      • base.Configure(builder) (:17): table Sponsor, schema Conference (both derived, and both visible in the shipped migration at 20260812202047_AddSponsors.cs:15-16), key on Id, identity value generation.
      • +
      • Required scalars (:19-21, :49-53, :59-60): Name at SponsorInvariants.NameMaxLength (200, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/SponsorInvariants.cs:13); Sort and IsExhibitor required with no configured default; EventId required.
      • +
      • The tier conversion (:25-27): described above.
      • +
      • Optional presentation columns (:29-47, :55-57): LogoUrl, Description, WebsiteUrl and LinkedInUrl each IsRequired(false) at 2000 characters (SponsorInvariants.cs:16, :19, :22, :25); TwitterHandle at 100 (:28); BoothNumber at 50 (:31). All seven widths read a constant, so this configuration contains no literal lengths.
      • +
      • Event relationship (:62-65): the one-way required HasOne/WithMany() pair described above. The shipped foreign key is FK_Sponsor_Event_EventId with ReferentialAction.Cascade (20260812202047_AddSponsors.cs:42-48).
      • +
      • Lookup index (:67-68): builder.HasIndex(p => p.EventId).HasSoftDeleteFilter(). It is not unique, so SoftDeleteUniqueIndexConvention would never have touched it and the explicit call is the only way the predicate gets applied; the migration confirms the shipped shape, IX_Sponsor_EventId with filter: "[IsDeleted] = 0" (20260812202047_AddSponsors.cs:51-56). It is aimed at exactly one query: the public sponsor strip fetches a page with filters["EventId"] = ("equals", ...) and sortColumn: "Sort" (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSponsorList.razor.cs:64-74), an equality predicate on EventId intersected with the global soft-delete filter, and the filtered index covers both halves. [Rubric §12, Performance and Scalability] assesses whether index shape follows the queries that actually run.
      • +
      • What is absent from this file and still ends up in the table: IsDeleted, CreatedOn/CreatedBy, LastModifiedOn/LastModifiedBy and the rowversion concurrency token are all in the shipped table (20260812202047_AddSponsors.cs:32-37) without appearing anywhere in Configure. They come from ApplicationDbContext and the entity base, which makes this the cleanest single illustration in the chapter of the division of labour taught under CategoryItemConfiguration: a configuration class owns only this entity's columns, relationships and indexes.
    • -
    • Why it's built this way: the per-module abstract context is the module's declared persistence - surface, one file you can read to learn exactly which tables the Conference module owns, without - fragmenting the runtime into per-module contexts (which would break cross-module transactions and - multiply model caches). Each sibling module declares its own identically named class in its own - namespace, Engagement at - MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:19 - and Identity at - MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:15, - so the three read as parallel inventories of three module-owned databases (ADC_Conference, - ADC_Engagement, ADC_Identity).
    • -
    • Where it's used: as a declaration, and only as one, today. A repository-wide search for the type - name across MMCA.ADC/Source and MMCA.ADC/Tests returns nothing but the three class declarations - themselves, so no concrete class in this repository derives from it and no code resolves it from - DI; the Conference tables are reached through - SQLServerDbContext instances created by the - framework's context factories, and the entity configurations covered in the sibling section of this - chapter are what put those tables in the model.
    • -
    • Caveats / not-in-source: (1) The XML doc comment (:14-:18) says the class "declares the DbSets - for all Conference entities". Two entities that do have SQL Server configurations in this module, - SessionAiScore (SessionAiScoreConfiguration.cs) and - SpeakerQuestionAnswer (SpeakerQuestionAnswerConfiguration.cs), have no DbSet here, and they are - still mapped and still persisted, which is the clearest available proof that the model comes from the - configurations rather than from this list. Treat the DbSet block as a helpful but non-authoritative - index. (2) Because nothing derives from this abstract class, whether a future engine-specific or - test-specific subclass is intended is not determinable from source.
    • +
    • Why it's built this way: sponsors are per-event data with a public, ordered presentation, so the mapping optimizes for the two things the public page does, filter by event and sort within a tier, and for schema stability as sponsorship packages change. Keeping every width on SponsorInvariants means the column, the domain guard (EnsureNameIsValid at SponsorInvariants.cs:39, EnsureLogoUrlIsValid at :51, EnsureBoothNumberIsValid at :63) and the Application-layer request validators cannot drift apart, which is what [Rubric §16, Maintainability] looks for. Confining all of it to one Infrastructure class keeps the Sponsor entity free of EF attributes, the Clean Architecture dependency rule this whole folder exists to serve.
    • +
    • Where it's used: applied when the concrete SQLServerDbContext for the ADC_Conference database builds its model by scanning the module assembly, exactly like its sixteen siblings; snapshotted by the Conference migrations project (MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Conference, table created by 20260812202047_AddSponsors.cs). Rows are written by ConferenceModuleDbSeeder's sample-data path and by the sponsor command handlers, and read by SponsorService for PublicSponsorList. ModuleApplicationDbContext does declare a Sponsors DbSet, but as that section explains, the DbSet list is an index, not the source of the model.
    • +
    • Caveats / not-in-source: (1) There is no unique index on (EventId, Name), or on Name at all. The seeder's idempotency check probes s => s.Name == name (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/Seeding/ConferenceModuleDbSeeder.cs:364-366), so uniqueness of sponsor names is an application-level convention with no database backstop, unlike the room-name and session-speaker cases elsewhere in this folder. (2) The Tier column carries no check constraint, so a value outside 0..3 would be storable by anything that bypasses the domain factory; the enum is enforced in the CLR type, not in SQL. (3) BoothNumber is nullable and independent of IsExhibitor: the domain deliberately accepts a booth number on a non-exhibitor (SponsorInvariants.cs:57-58, "the flag drives display, it does not reject stored data"), and the mapping adds no constraint tying the two together. (4) The Cascade delete on the event foreign key is not overridden here; because the codebase soft-deletes rather than hard-deletes, whether that cascade ever executes in a deployed database is not determinable from source. (5) The grouping by tier that the public page renders happens in memory after the fetch (PublicSponsorList.razor.cs:81-85), not as a SQL ORDER BY Tier, so the int conversion enables a cheap column sort that today's read path does not yet ask the database to perform.


    @@ -1256,6 +1098,7 @@

    ModuleApplicationDbContext

  • The Sessionize adapter
  • The Anthropic AI scoring adapter
  • Scoring runs on a hosted drain, guarded across replicas
  • +
  • The sweep that finishes what a crash interrupted
  • DI wiring and a deliberate resilience override
  • How it fits together at runtime
  • diff --git a/docs/onboarding/group-20-conference-api-grpc.html b/docs/onboarding/group-20-conference-api-grpc.html index 85b16d3..c15d950 100644 --- a/docs/onboarding/group-20-conference-api-grpc.html +++ b/docs/onboarding/group-20-conference-api-grpc.html @@ -6,21 +6,21 @@ 20. ADC Conference - API, gRPC Contracts & Service Host · MMCA · Ivan Ball-llovera - + - + - + @@ -145,40 +145,42 @@

    Onboarding guide

    20. ADC Conference - API, gRPC Contracts & Service Host

    -

    This chapter is the edge of the Conference bounded context, the layer that turns the rich Conference domain (G17) and its CQRS slices (G18) into a running HTTP + gRPC surface, plus the small amount of glue that lets that surface be hosted either inside the ADC monolith or as its own extracted microservice (MMCA.ADC.Conference.Service) with no change to the application code beneath. Almost nothing here is novel: the controllers are thin shells over the generic REST machinery taught in G12 (API Hosting, Middleware & DTO Mapping), the gRPC pieces are concrete instances of the transport boundary taught in G13 (gRPC & Inter-Service Contracts), and the module entry point is one implementation of the IModule contract from G14 (Module System & Composition). What this chapter teaches is how the Conference module wires those reusable pieces into a real, sixteen-controller, twice-gRPC-edged conference API, and the handful of places where it deviates from the generic shape for a genuine business reason. The headline rubric lenses are [Rubric §9, API & Contract Design] (a consistent, versioned REST + gRPC contract), [Rubric §5, Vertical Slice] and [Rubric §6, CQRS & Event-Driven] (each action dispatches to a single command/query handler), and [Rubric §7, Microservices Readiness] (the same code runs in-process or extracted). Everything lives in three projects: MMCA.ADC.Conference.API (the REST controllers, the ConferenceModule entry point, the ConferenceModuleSeeder), MMCA.ADC.Conference.Service (the host wiring plus the gRPC servers), and MMCA.ADC.Conference.Contracts (the client-side gRPC adapters and the contract-package DI).

    +

    What this chapter covers. This is the edge of the Conference bounded context, the layer that turns the rich Conference domain (G17) and its CQRS slices (G18) into a running HTTP + gRPC surface, plus the small amount of glue that lets that surface be hosted either inside a co-located host or as its own extracted microservice (MMCA.ADC.Conference.Service) with no change to the application code beneath. Almost nothing here is novel: the controllers are thin shells over the generic REST machinery taught in G12 (API Hosting, Middleware & DTO Mapping), the gRPC pieces are concrete instances of the transport boundary taught in G13 (gRPC & Inter-Service Contracts), and the module entry point is one implementation of the IModule contract from G14 (Module System & Composition). What this chapter teaches is how the Conference module wires those reusable pieces into a real, seventeen-controller, twice-gRPC-edged conference API, and the handful of places where it deviates from the generic shape for a genuine business reason. The headline rubric lenses are [Rubric §9, API & Contract Design] (a consistent, versioned REST + gRPC contract), [Rubric §5, Vertical Slice] and [Rubric §6, CQRS & Event-Driven] (each action dispatches to a single command/query handler), and [Rubric §7, Microservices Readiness] (the same code runs in-process or extracted). Everything lives in three projects: MMCA.ADC.Conference.API (the REST controllers, the ConferenceModule entry point, the ConferenceModuleSeeder), MMCA.ADC.Conference.Service (the host wiring plus the gRPC servers), and MMCA.ADC.Conference.Contracts (the client-side gRPC adapters and the contract-package DI).

    The controller hierarchy, almost everything is inherited

    -

    The Conference API exposes sixteen controllers, and the striking thing about them is how little code each carries. They split into three structural families, all built on the generic controller bases from G12. Aggregate-root controllers (six: SessionsController, SpeakersController, EventsController, QuestionsController, ConferenceCategoriesController, SponsorsController) derive from AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest> and inherit the full read + create + delete surface, often only override-ing actions to add [AllowAnonymous], an [OutputCache] policy, or a business rule (SessionsController derives from that base at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionsController.cs:54, EventsController at EventsController.cs:57, QuestionsController at QuestionsController.cs:38, ConferenceCategoriesController at ConferenceCategoriesController.cs:39, SpeakersController at SpeakersController.cs:59, SponsorsController at SponsorsController.cs:46). Child-and-join controllers (eight: RoomsController, CategoryItemsController, EventSpeakersController, SessionSpeakersController, SessionCategoryItemsController, SpeakerCategoryItemsController, EventQuestionAnswersController, SessionQuestionAnswersController) derive from the read-oriented EntityControllerBase<TEntity, TEntityDTO, TIdentifierType> (RoomsController.cs:92, CategoryItemsController.cs:68, EventSpeakersController.cs:54, SessionSpeakersController.cs:55, SessionCategoryItemsController.cs:55, SpeakerCategoryItemsController.cs:55, EventQuestionAnswersController.cs:63, SessionQuestionAnswersController.cs:63) and add their own POST/PUT/DELETE actions by hand, because they manipulate a child of an aggregate (a room belongs to an event, a category item to a category) and so their write commands carry a parent identifier the generic create/delete cannot supply. And bespoke controllers (two: ServiceInfoController and SessionSelectionController) sit apart: SessionSelectionController derives from Common's ApiControllerBase (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:36) and ServiceInfoController from the shared ServiceInfoControllerBase (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/ServiceInfoController.cs:20), because neither exposes a CRUD entity at all.

    -

    The reason a concrete controller can be short is that the generic bases already supply GET (capped, returning CollectionResult<T>), GET /paged (filtered/sorted/paged, returning PagedCollectionResult<T>), GET /lookup (id+name pairs as BaseLookup<TIdentifierType> for dropdowns), GET /{id}, GET /export (a streamed CSV), and, on the aggregate base, POST (to 201 Created) and DELETE (to 204). Each Conference controller's constructor simply injects the IEntityQueryService<TEntity, TEntityDTO, TIdentifierType> for reads and the specific ICommandHandler<in TCommand, TResult> / IQueryHandler<in TQuery, TResult> instances for its writes and bespoke reads (SessionsController.cs:42-53), then folds any Result.Failure back through the inherited HandleFailure (SessionsController.cs:242, SessionSelectionController.cs:49). That is the [Rubric §1, SOLID] / [Rubric §16, Maintainability & Evolvability] payoff the generic base exists for (the generic-controller + dynamic-query contract of ADR-034): the CRUD logic is written once in Common, and a per-entity controller has almost no reason to change.

    +

    The Conference API exposes seventeen controllers, and the striking thing about them is how little code each carries. They split into three structural families, all built on the generic controller bases from G12. Aggregate-root controllers (seven: SessionsController, SpeakersController, EventsController, QuestionsController, ConferenceCategoriesController, SponsorsController, ActivitiesController) derive from AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest> and inherit the full read + create + delete surface, often only override-ing actions to add [AllowAnonymous], an [OutputCache] policy, or a business rule (SessionsController derives from that base at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionsController.cs:54, EventsController at EventsController.cs:58, QuestionsController at QuestionsController.cs:38, ConferenceCategoriesController at ConferenceCategoriesController.cs:39, SpeakersController at SpeakersController.cs:59, SponsorsController at SponsorsController.cs:46, ActivitiesController at ActivitiesController.cs:46). Child-and-join controllers (eight: RoomsController, CategoryItemsController, EventSpeakersController, SessionSpeakersController, SessionCategoryItemsController, SpeakerCategoryItemsController, EventQuestionAnswersController, SessionQuestionAnswersController) derive from the read-oriented EntityControllerBase<TEntity, TEntityDTO, TIdentifierType> (RoomsController.cs:101, CategoryItemsController.cs:69, EventSpeakersController.cs:55, SessionSpeakersController.cs:56, SessionCategoryItemsController.cs:56, SpeakerCategoryItemsController.cs:56, EventQuestionAnswersController.cs:64, SessionQuestionAnswersController.cs:64) and add their own POST/PUT/DELETE actions by hand, because they manipulate a child of an aggregate (a room belongs to an event, a category item to a category) and so their write commands carry a parent identifier the generic create/delete cannot supply. And bespoke controllers (two: ServiceInfoController and SessionSelectionController) sit apart: SessionSelectionController derives from Common's ApiControllerBase (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSelectionController.cs:37) and ServiceInfoController from the shared ServiceInfoControllerBase (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/ServiceInfoController.cs:20), because neither exposes a CRUD entity at all.

    +

    The reason a concrete controller can be short is that the generic bases already supply GET (capped, returning CollectionResult<T>), GET /paged (filtered/sorted/paged, returning PagedCollectionResult<T>), GET /lookup (id+name pairs as BaseLookup<TIdentifierType> for dropdowns), GET /{id}, GET /export (a streamed CSV), and, on the aggregate base, POST (to 201 Created) and DELETE (to 204). Each Conference controller's constructor simply injects the IEntityQueryService<TEntity, TEntityDTO, TIdentifierType> for reads and the specific ICommandHandler<in TCommand, TResult> / IQueryHandler<in TQuery, TResult> instances for its writes and bespoke reads (SessionsController.cs:42-53), then folds any Result.Failure back through the inherited HandleFailure (SessionsController.cs:242, SessionSelectionController.cs:50). That is the [Rubric §1, SOLID] / [Rubric §16, Maintainability & Evolvability] payoff the generic base exists for (the generic-controller + dynamic-query contract of ADR-034): the CRUD logic is written once in Common, and a per-entity controller has almost no reason to change.

    Authorization at the edge, three shapes not one

    -

    Authorization is capability-based by default but not uniform, and the differences are the interesting part. Most write-bearing controllers carry a class-level HasPermissionAttribute gate naming one ConferencePermissions capability rather than a role policy: SessionsManage on SessionsController (SessionsController.cs:41) and on the two session-join controllers (SessionSpeakersController.cs:46, SessionCategoryItemsController.cs:46), EventsManage (EventsController.cs:43, EventSpeakersController.cs:45), RoomsManage (RoomsController.cs:84), CategoriesManage (ConferenceCategoriesController.cs:31, CategoryItemsController.cs:60), QuestionsManage (QuestionsController.cs:30), SpeakersManage (SpeakerCategoryItemsController.cs:46), and SessionSelectionManage (SessionSelectionController.cs:28). Reads are then re-opened action by action with [AllowAnonymous] (BR-43 public browse, for example SessionsController.cs:126, RoomsController.cs:95).

    -

    Three controllers deliberately break that pattern, and knowing why saves you from "fixing" them. SpeakersController carries only a plain [Authorize] at class level (SpeakersController.cs:43) and pushes [HasPermission(ConferencePermissions.SpeakersManage)] down onto the individual organizer write actions (SpeakersController.cs:290,309,353,365,384), because one of its writes is an authenticated self-service surface: the BR-214 profile update re-declares plain [Authorize] (SpeakersController.cs:327-328) and then decides inside the action whether the caller is an organizer or the speaker themselves, by comparing the speaker_id JWT claim to the route id and passing the answer down as CallerIsOrganizer so the handler can refuse a self-edit of the organizer-only IsTopSpeaker field (SpeakersController.cs:335-341). SponsorsController copies that shape for the same mechanical reason (SponsorsController.cs:36, per-action SponsorsManage at SponsorsController.cs:193,212,224,243): a bare [Authorize] is what the inherited export action needs to pick up, so the capability is declared per action instead. And EventQuestionAnswersController / SessionQuestionAnswersController gate on [Authorize(Policy = AuthorizationPolicies.RequireAuthenticated)] instead (EventQuestionAnswersController.cs:55, SessionQuestionAnswersController.cs:55), because any signed-in attendee may submit feedback answers, so no organizer capability applies. Which roles hold which capability is declared once in AddModuleConferenceAPI (see below), the permission-over-RBAC model of ADR-020; [Rubric §11, Security] is the lens, and these exceptions are the evidence that the model is applied per endpoint rather than pasted.

    -

    Orthogonal to all three shapes is the read audience, which no attribute can express because it changes the rows rather than the verdict. Eight controllers ask CurrentUserServiceExtensions.IsPrivilegedConferenceReader() (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Authorization/CurrentUserServiceExtensions.cs:24) and turn the answer into a specification or null: SessionsController.cs:58, SpeakersController.cs:63, EventsController.cs:67, SponsorsController.cs:50, EventSpeakersController.cs:57, SessionSpeakersController.cs:58, SessionCategoryItemsController.cs:58, and SpeakerCategoryItemsController.cs:58. The helper is one line over ICurrentUserService (CurrentUserServiceExtensions.cs:25) and answers against the ConferenceReadAudience.PrivilegedRoles list declared once in G17, with an explicit remark in its own doc comment that this is a read-visibility check and never a substitute for a [HasPermission(...)] gate (CurrentUserServiceExtensions.cs:20-23).

    -

    The same helper closes a specific hole worth naming, because it recurs in seven files and is easy to reintroduce. The framework's inherited CSV export streams with no specification, so a non-privileged caller would receive the unfiltered catalog in one file: declined sessions, draft events, hidden speakers, unannounced sponsorships. Every controller whose reads are row-filtered therefore overrides ExportAsync and returns Forbid() for a non-privileged caller rather than serving a scoped file (SessionsController.cs:251-266 BR-49/BR-132, SpeakersController.cs:289-306 BR-239, EventsController.cs:185-201 BR-108, SponsorsController.cs:192-208 BR-108, and the four join controllers at EventSpeakersController.cs:200, SessionSpeakersController.cs:201, SessionCategoryItemsController.cs:201, SpeakerCategoryItemsController.cs:201). Privileged readers already read everything and may export it. That is [Rubric §11, Security] again, applied to the one action a generic base cannot make safe on its own.

    +

    Authorization is capability-based by default but not uniform, and the differences are the interesting part. Most write-bearing controllers carry a class-level HasPermissionAttribute gate naming one ConferencePermissions capability rather than a role policy: SessionsManage on SessionsController (SessionsController.cs:41) and on the two session-join controllers (SessionSpeakersController.cs:47, SessionCategoryItemsController.cs:47), EventsManage (EventsController.cs:44, EventSpeakersController.cs:46), RoomsManage (RoomsController.cs:91), CategoriesManage (ConferenceCategoriesController.cs:31, CategoryItemsController.cs:61), QuestionsManage (QuestionsController.cs:30), SpeakersManage (SpeakerCategoryItemsController.cs:47), and SessionSelectionManage (SessionSelectionController.cs:29). Reads are then re-opened action by action with [AllowAnonymous] (BR-43 public browse, for example SessionsController.cs:126, RoomsController.cs:131). Nine capability constants exist in total, declared once in ConferencePermissions.All (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Authorization/ConferencePermissions.cs:39-50).

    +

    Three shapes break that pattern, and knowing why saves you from "fixing" them. SpeakersController carries only a plain [Authorize] at class level (SpeakersController.cs:43) and pushes [HasPermission(ConferencePermissions.SpeakersManage)] down onto the individual organizer write actions (SpeakersController.cs:290,309,353,365,384), because one of its writes is an authenticated self-service surface: the BR-214 profile update re-declares plain [Authorize] (SpeakersController.cs:327-328) and then decides inside the action whether the caller is an organizer or the speaker themselves, by comparing the speaker_id JWT claim to the route id and passing the answer down as CallerIsOrganizer so the handler can refuse a self-edit of the organizer-only IsTopSpeaker field (SpeakersController.cs:334-341). SponsorsController and ActivitiesController copy that shape for the same mechanical reason (SponsorsController.cs:36, ActivitiesController.cs:36, with per-action SponsorsManage at SponsorsController.cs:193,212,224,243 and ActivitiesManage at ActivitiesController.cs:193,212,224,243): a bare [Authorize] is what the inherited export action needs to pick up, so the capability is declared per action instead. And EventQuestionAnswersController / SessionQuestionAnswersController gate on [Authorize(Policy = AuthorizationPolicies.RequireAuthenticated)] instead (EventQuestionAnswersController.cs:56, SessionQuestionAnswersController.cs:56), because any signed-in attendee may submit feedback answers, so no organizer capability applies. Which roles hold which capability is declared once in AddModuleConferenceAPI (see below), the permission-over-RBAC model of ADR-020; [Rubric §11, Security] is the lens, and these exceptions are the evidence that the model is applied per endpoint rather than pasted.

    +

    Orthogonal to all three shapes is the read audience, which no attribute can express because it changes the rows rather than the verdict. Ten controllers ask CurrentUserServiceExtensions.IsPrivilegedConferenceReader() (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Authorization/CurrentUserServiceExtensions.cs:24) and turn the answer into a specification or null: SessionsController.cs:58, SpeakersController.cs:63, EventsController.cs:68, SponsorsController.cs:50, ActivitiesController.cs:50, RoomsController.cs:104, EventSpeakersController.cs:58, SessionSpeakersController.cs:59, SessionCategoryItemsController.cs:59, and SpeakerCategoryItemsController.cs:59. The helper is one line over ICurrentUserService (CurrentUserServiceExtensions.cs:25) and answers against the ConferenceReadAudience.PrivilegedRoles list declared once in G17 (Organizer and ContentEditor, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Authorization/ConferenceReadAudience.cs:91-95), with an explicit remark in its own doc comment that this is a read-visibility check and never a substitute for a [HasPermission(...)] gate (CurrentUserServiceExtensions.cs:20-23). The two feedback-answer controllers use a different scoping axis for the same purpose: BR-8 narrows an attendee to their own answers through an OwnedByUserSpecification<TEntity, TIdentifierType> built from the caller's user id, or null for an Organizer (EventQuestionAnswersController.cs:67-68, applied at :81,109,145).

    +

    The read audience closes a specific hole worth naming, because it recurs in eleven files and is easy to reintroduce. The framework's inherited CSV export streams with no specification, so a non-privileged caller would receive the unfiltered catalog in one file: declined sessions, draft events, hidden speakers, unannounced sponsorships and activities. Every controller whose reads are row-filtered therefore overrides ExportAsync and returns Forbid() for a non-privileged caller rather than serving a scoped file (SessionsController.cs:251-266 BR-49/BR-132, SpeakersController.cs:289-305 BR-239, EventsController.cs:186-201 BR-108, SponsorsController.cs:192-208 BR-108, ActivitiesController.cs:192-208 BR-108, and the four join controllers at EventSpeakersController.cs:193,203, SessionSpeakersController.cs:194,204, SessionCategoryItemsController.cs:194,204, SpeakerCategoryItemsController.cs:194,204); the two answer controllers do the same against the Organizer role, matching their BR-8 row scope (EventQuestionAnswersController.cs:159-174, SessionQuestionAnswersController.cs:159-174). Privileged readers already read everything and may export it. The controllers with a class-level capability gate and no anonymous export (RoomsController, CategoryItemsController, QuestionsController, ConferenceCategoriesController) need no such override, because their inherited export is still behind the class attribute. That is [Rubric §11, Security] again, applied to the one action a generic base cannot make safe on its own.

    The request records, the inbound write shapes

    -

    Several controllers declare small record class request types alongside themselves, co-located in the same file: AddRoomRequest/UpdateRoomRequest (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/RoomsController.cs:24,52), AddCategoryItemRequest/UpdateCategoryItemRequest (CategoryItemsController.cs:24,40), AddEventSpeakerRequest (EventSpeakersController.cs:28), AddSessionSpeakerRequest (SessionSpeakersController.cs:28), AddSpeakerCategoryItemRequest (SpeakerCategoryItemsController.cs:28), AddSessionCategoryItemRequest (SessionCategoryItemsController.cs:28), AddEventQuestionAnswerRequest/UpdateEventQuestionAnswerRequest (EventQuestionAnswersController.cs:26,39), and AddSessionQuestionAnswerRequest/UpdateSessionQuestionAnswerRequest (SessionQuestionAnswersController.cs:26,39). These are the wire shapes for the child-entity writes the generic base cannot model: each carries the parent identifier (EventId at RoomsController.cs:27) plus the child's own fields, all required/init for immutability (RoomsController.cs:26-48), and the controller action unpacks the record positionally into the matching Add*Command/Update*Command from G18 (RoomsController.cs:149-159). They are deliberately separate from the inbound application command types (and from the outbound DTOs), the §9 "DTOs decoupled from entities" discipline, so the HTTP contract can evolve independently of the command's parameter list. The aggregate-root controllers, by contrast, reuse the application layer's create-request command directly (for example SessionsController binds SessionCreateRequest as its TCreateRequest, SessionsController.cs:54), so they need no per-controller record.

    +

    Several controllers declare small record class request types alongside themselves, co-located in the same file: AddRoomRequest/UpdateRoomRequest (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/RoomsController.cs:30,58), AddCategoryItemRequest/UpdateCategoryItemRequest (CategoryItemsController.cs:25,41), AddEventSpeakerRequest (EventSpeakersController.cs:29), AddSessionSpeakerRequest (SessionSpeakersController.cs:29), AddSpeakerCategoryItemRequest (SpeakerCategoryItemsController.cs:29), AddSessionCategoryItemRequest (SessionCategoryItemsController.cs:29), AddEventQuestionAnswerRequest/UpdateEventQuestionAnswerRequest (EventQuestionAnswersController.cs:27,40), and AddSessionQuestionAnswerRequest/UpdateSessionQuestionAnswerRequest (SessionQuestionAnswersController.cs:27,40). These are the wire shapes for the child-entity writes the generic base cannot model: each carries the parent identifier (EventId at RoomsController.cs:33) plus the child's own fields, all required/init for immutability (RoomsController.cs:30-55), and the controller action unpacks the record positionally into the matching Add*Command/Update*Command from G18 (RoomsController.cs:262-272 on create, :291-301 on update, :317-319 on delete). They are deliberately separate from the inbound application command types (and from the outbound DTOs), the §9 "DTOs decoupled from entities" discipline, so the HTTP contract can evolve independently of the command's parameter list. The aggregate-root controllers, by contrast, reuse the application layer's create-request command directly (for example SessionsController binds SessionCreateRequest as its TCreateRequest, SessionsController.cs:54), so they need no per-controller record.

    Where the generic shape gives way: filtering, warnings, and calendars

    SessionsController is the best illustration of how a controller earns its overrides. Every read action is [AllowAnonymous] and [OutputCache(PolicyName = "SessionsCache")] (SessionsController.cs:125-127,151-153,200-202,222-224,272-274), and the reads thread a specification built by BuildPublicSessionSpecificationAsync (SessionsController.cs:67), which returns null for privileged readers and otherwise dispatches the GetPublicSessionFilterQuery handler so non-organizers never see declined sessions (BR-132/BR-49). The cross-source part matters: Session and Event can live in different data sources, so the published-event check is resolved by that handler through the framework's cross-source specification helper rather than by a join (SessionsController.cs:60-66; ADR-018). The paged read adds a second layer, BuildPagedSessionSpecificationAsync (SessionsController.cs:96): Session has no SpeakerId column, so that filter key is intercepted and Removed before the generic filter pipeline can reject it, resolved to an id list through GetSessionsBySpeakerFilterQuery, and ANDed with the public filter via AndSpecification<TEntity, TIdentifierType> rather than substituted for it (SessionsController.cs:102-118), because substituting would leak non-accepted sessions to anonymous callers; an unparseable value simply ignores the key (SessionsController.cs:104).

    -

    The same controller adds three things the base has no notion of. A PUT /{id} update that surfaces a BR-86 X-Warning header when the update handler reports HasDateRangeWarning (SessionsController.cs:323-344), with the matching check done inline on create by comparing the request times against the event's StartDate/EndDate (SessionsController.cs:302-316). A GET /{id}/ics action that streams one public session as a text/calendar document for the add-to-calendar affordance (SessionsController.cs:272-283) via ExportSessionCalendarQuery. And an explicit [Idempotent] declaration on the create override (SessionsController.cs:291) so the Idempotency-Key contract from IdempotentAttribute is visible at the ADC endpoint rather than only inherited (the attribute is single-use, so the declaration coincides with the inherited one instead of duplicating it, SessionsController.cs:285-289). Every mutating action finishes by calling EvictSessionsCacheAsync, which evicts both the conference:sessions and conference output-cache tags (SessionsController.cs:318,342,353,357-361), the write-side half of the caching contract.

    -

    EventsController follows the same recipe and adds its own GET /{id}/ics (EventsController.cs:206-217) plus per-event and global now-next snapshot actions under the short-lived NowNextCache policy (EventsController.cs:223-247), both dispatching GetNowNextQuery and returning a NowNextDTO; the id-less form exists because the home-screen widget has no event id to pass (EventsController.cs:244). Its read filter is the simplest of the group, a plain PublishedEventSpecification or null (EventsController.cs:67), because Event owns its own publish flag. It also carries the publish, unpublish, and Sessionize-refresh commands that have no generic equivalent (EventsController.cs:294,316,337), the first two optionally carrying the client's last-seen rowversion for the ADR-035 stale-view check (EventsController.cs:290-293), and the refresh mapping two domain error codes onto HTTP 429 (with a Retry-After: 300) and 502 (EventsController.cs:348-357). Cache eviction is proportional to blast radius: an ordinary event write evicts only conference:events (EventsController.cs:387-388), a delete also evicts sessions and rooms (EventsController.cs:382-383), and a Sessionize refresh evicts all six tags it can touch (EventsController.cs:363-368). [Rubric §12, Performance & Scalability] is the lens for the whole caching story here.

    -

    Two read-path carve-outs are worth internalizing before you touch either file. SpeakersController.GetByIdAsync normally applies the BR-239 public-speaker specification, but drops it when the caller's speaker_id claim equals the route id, because the self-edit form cannot load without reading the profile it edits; and because the output-cache key does not vary by caller, that same branch turns storage off for the response through IOutputCacheFeature so a private profile can never land in the shared entry (SpeakersController.cs:253-267). SponsorsController is the mirror image of the Sessions filter problem: Sponsor carries a real EventId column, so an event-scoped request goes through the generic filter pipeline unchanged and the published-event specification from GetPublicSponsorFilterQuery is ANDed on top of it rather than intercepted, which means scoping to an unpublished event yields an empty page instead of leaking the roster (SponsorsController.cs:60-70,94-134). Its PUT /{id} dispatches UpdateSponsorCommand and, like every other Sponsors mutation, evicts conference:sponsors plus conference (SponsorsController.cs:225-239,253-257).

    +

    The GET /lookup action is worth internalizing as a family, because five controllers override it for the same reason. A lookup returns id plus label pairs and is anonymous, so left inherited it becomes a side channel that names exactly the rows the list and detail endpoints hide. Each of SessionsController (SessionsController.cs:200-220), EventsController (EventsController.cs:135-155), RoomsController (RoomsController.cs:200-220), SponsorsController (SponsorsController.cs:136-156) and ActivitiesController (ActivitiesController.cs:136-156) therefore short-circuits to the base action when the specification is null (a privileged reader) and otherwise forwards specification.Criteria to the query service as the lookup filter. SpeakersController goes one step further and also constrains the label: only FirstName and LastName may be requested by a non-privileged caller (SpeakersController.cs:66,219-230), because nameProperty=Email would project the speaker email straight into the label and go around the DTO mapper that redacts it (BR-66).

    +

    The same controller adds three things the base has no notion of. A PUT /{id} update that surfaces a BR-86 X-Warning header when the update handler reports HasDateRangeWarning (SessionsController.cs:323-344), with the matching check done inline on create by comparing the request times against the event's StartDate/EndDate (SessionsController.cs:302-316). A GET /{id}/ics action that streams one public session as a text/calendar document for the add-to-calendar affordance (SessionsController.cs:272-283) via ExportSessionCalendarQuery. And an explicit Idempotent declaration on the create override (SessionsController.cs:291) so the Idempotency-Key contract is visible at the ADC endpoint rather than only inherited (the attribute is single-use, so the declaration coincides with the inherited one instead of duplicating it, SessionsController.cs:285-289). Every mutating action finishes by calling EvictSessionsCacheAsync, which evicts both the conference:sessions and conference output-cache tags (SessionsController.cs:318,342,353,357-361), the write-side half of the caching contract.

    +

    EventsController follows the same recipe and adds its own GET /{id}/ics (EventsController.cs:207-218) plus per-event and global now-next snapshot actions under the short-lived NowNextCache policy (EventsController.cs:224-247), both dispatching GetNowNextQuery and returning a NowNextDTO; the id-less form exists because the home-screen widget has no event id to pass (EventsController.cs:239-247). Its read filter is the simplest of the group, a plain PublishedEventSpecification or null (EventsController.cs:67-68), because Event owns its own publish flag. It also carries the publish, unpublish, and Sessionize-refresh commands that have no generic equivalent (EventsController.cs:310,341,367). Publish and unpublish state their precondition two ways: an optional body carrying the client's last-seen rowversion for the ADR-035 stale-view check, and a SupportsIfMatch declaration (EventsController.cs:307,338) that lets the same token arrive as an HTTP If-Match header, in which case a stale token answers 412 instead of 409 (EventsController.cs:291-309). The refresh maps two domain error codes onto HTTP 429 (with a Retry-After: 300) and 502 (EventsController.cs:377-386). Cache eviction is proportional to blast radius: an ordinary event write evicts only conference:events (EventsController.cs:416-417), a delete also evicts sessions and rooms (EventsController.cs:410-412), and a Sessionize refresh evicts all six tags it can touch (EventsController.cs:392-397). [Rubric §12, Performance & Scalability] is the lens for the whole caching story here.

    +

    Three read-path carve-outs are worth internalizing before you touch these files. SpeakersController.GetByIdAsync normally applies the BR-239 public-speaker specification, but drops it when the caller's speaker_id claim equals the route id, because the self-edit form cannot load without reading the profile it edits; and because the output-cache key does not vary by caller, that same branch turns storage off for the response through IOutputCacheFeature so a private profile can never land in the shared entry (SpeakersController.cs:253-267). The same controller's per-session feedback read is gated self-or-organizer in code and deliberately left uncached, since every response is authorization-dependent (SpeakersController.cs:406-425), while its two bookmark-count reads are anonymous under the short-TTL BookmarkCountsCache policy (SpeakersController.cs:429-431,449-451). And SponsorsController, with ActivitiesController as its twin, is the mirror image of the Sessions filter problem: Sponsor and Activity each carry a real EventId column, so an event-scoped request goes through the generic filter pipeline unchanged and the published-event specification from GetPublicSponsorFilterQuery / GetPublicActivityFilterQuery is ANDed on top of it rather than intercepted, which means scoping to an unpublished event yields an empty page instead of leaking the roster (SponsorsController.cs:60-70,94-99, ActivitiesController.cs:60-70,94-99). Their PUT /{id} dispatches UpdateSponsorCommand / UpdateActivityCommand and, like every other mutation on those two, evicts the entity tag plus conference (SponsorsController.cs:225-239,253-257, ActivitiesController.cs:225-239,253-257).

    Two more deviations, versioning and decision support

    -

    ServiceInfoController exists to prove the API-versioning machinery works beyond a single version ([Rubric §9, API & Contract Design]). It is a one-member shell over Common's ServiceInfoControllerBase: it overrides only ServiceName => "Conference" (ServiceInfoController.cs:23) and carries the class-level [AllowAnonymous], [ApiVersion("1.0", Deprecated = true)], and [ApiVersion("2.0")] attributes (ServiceInfoController.cs:17-19), which are placed here because they are not reliably inherited from the base (ServiceInfoController.cs:12-13). The shared base serves the same /ServiceInfo route at two API versions selected by the api-version header: 1.0 (deprecated) returns the minimal shape, 2.0 the evolved shape that also advertises the supported and deprecated version lists. Every other Conference controller declares a single [ApiVersion("1.0")]; this one demonstrates the deprecation story end to end.

    -

    SessionSelectionController is the most behaviour-rich controller in the group and the one furthest from the generic shape. It is organizer-only ([HasPermission(ConferencePermissions.SessionSelectionManage)], SessionSelectionController.cs:28) decision support over an event's session pool: a composite dashboard, category distribution, speaker overlap, and content similarity, each GET delegating to a dedicated IQueryHandler<in TQuery, TResult> and output-cached under the ConferenceCache policy (SessionSelectionController.cs:39-40,53-54,67-68,81-82; content similarity also takes a minimumSimilarity threshold defaulting to 0.3, SessionSelectionController.cs:85). Its POST score/{eventId} action is the notable one: AI scoring of every eligible session can take minutes, so the action does not run the work at all. It calls ISessionScoringQueue.TryEnqueue(eventId) and switches on the returned SessionScoringEnqueueResult (SessionSelectionController.cs:108-129): Queued logs through a [LoggerMessage]-sourced structured log and returns 202 Accepted (SessionSelectionController.cs:112-114,131-132, [Rubric §13, Observability & Operability]), while AlreadyPending and QueueFull both fold into a 409 Conflict through HandleFailure with distinct error codes (SessionSelectionController.cs:116-127). Refusing a second concurrent run is a cost decision stated in the source: each pass issues one paid Anthropic call per session, so two passes would double the spend while racing each other's writes (SessionSelectionController.cs:100-104, [Rubric §31, Cost/FinOps]). The actual work runs on the background SessionScoringProcessor in G19, which keeps the controller free of any scope-lifetime handling.

    +

    ServiceInfoController exists to prove the API-versioning machinery works beyond a single version ([Rubric §9, API & Contract Design]). It is a one-member shell over Common's ServiceInfoControllerBase: it overrides only ServiceName => "Conference" (ServiceInfoController.cs:23) and carries the class-level [AllowAnonymous], [ApiVersion("1.0", Deprecated = true)], and [ApiVersion("2.0")] attributes (ServiceInfoController.cs:17-19), which are placed here because they are not reliably inherited from the base (ServiceInfoController.cs:11-13). The shared base serves the same /ServiceInfo route at two API versions selected by the api-version header: 1.0 (deprecated) returns the minimal shape, 2.0 the evolved shape that also advertises the supported and deprecated version lists. Every other Conference controller declares a single [ApiVersion("1.0")]; this one demonstrates the deprecation story end to end.

    +

    SessionSelectionController is the most behaviour-rich controller in the group and the one furthest from the generic shape. It is organizer-only ([HasPermission(ConferencePermissions.SessionSelectionManage)], SessionSelectionController.cs:29) decision support over an event's session pool: a composite dashboard, category distribution, speaker overlap, and content similarity, each GET delegating to a dedicated IQueryHandler<in TQuery, TResult> and output-cached under the ConferenceCache policy (SessionSelectionController.cs:40-41,54-55,68-69,82-83; content similarity also takes a minimumSimilarity threshold defaulting to 0.3, SessionSelectionController.cs:86). Its POST score/{eventId} action is the notable one: AI scoring of every eligible session can take minutes, so the action does not run the work at all. It calls ISessionScoringQueue.TryEnqueue(eventId) and switches on the returned SessionScoringEnqueueResult (SessionSelectionController.cs:110-131): Queued logs through a [LoggerMessage]-sourced structured log and returns 202 Accepted (SessionSelectionController.cs:114-116,133-134, [Rubric §13, Observability & Operability]), while AlreadyPending and QueueFull both fold into a 409 Conflict through HandleFailure with distinct error codes (SessionSelectionController.cs:118-129). Refusing a second concurrent run is a cost decision stated in the source: each pass issues one paid Anthropic call per session, so two passes would double the spend while racing each other's writes (SessionSelectionController.cs:101-105, [Rubric §31, Cost/FinOps]). The same reasoning drives an explicit NonIdempotent declaration with a written justification (SessionSelectionController.cs:107): the queue already deduplicates, so replaying a cached 202 would report acceptance for a request the queue never saw and hide both the already-running refusal and a queue-full rejection the caller has to act on. The actual work runs on the background SessionScoringProcessor in G19, which keeps the controller free of any scope-lifetime handling.

    The module entry point and seeder, how Conference plugs in

    -

    ConferenceModule is the Conference implementation of IModule. It is tiny by design: Register(...) calls the DependencyInjection extension's AddConferenceModule(...) (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/ConferenceModule.cs:28-29), which chains the Application, Infrastructure, and API-layer registrations in dependency order into one call (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/DependencyInjection.cs:25-27). The API layer's AddModuleConferenceAPI is not a no-op: it calls AddPermissions to grant RoleNames.Organizer and .Admin every ConferencePermissions capability, and ContentEditor only the ContentManagement curation subset with no event structure, rooms, questions, or session selection (DependencyInjection.cs:41-51). Attendees are granted nothing here, so attendee-facing endpoints stay on the plain AuthorizationPolicies.RequireAuthenticated policy (DependencyInjection.cs:35-36). And RegisterDisabledStubs(...) registers both a DisabledSessionBookmarkValidationService and a DisabledEventLiveValidationService as singletons (ConferenceModule.cs:23-24) so that other hosts which depend on Conference's ISessionBookmarkValidationService or IEventLiveValidationService but do not host Conference still resolve those interfaces (they no-op, or are later Replaced by the gRPC adapters). The ModuleLoader (G14) discovers ConferenceModule by reflection and registers it in topological order, the same mechanism whether Conference runs in the monolith or alone in its service.

    -

    ConferenceModuleSeeder implements IModuleSeeder (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/ConferenceModuleSeeder.cs:13) and is the API layer's thin bridge to the real seeding logic: it resolves IUnitOfWork and IConfiguration from the passed service provider, reads Seeding:IncludeSampleConferenceData (defaulting to false when the key is absent, and set only on the local AppHost and in E2E CI), then constructs and runs ConferenceModuleDbSeeder from G19 with that flag (ConferenceModuleSeeder.cs:21-29). The two markers AssemblyReference / ClassReference (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/AssemblyReference.cs:5,11) are the per-package anchors the module scan and the architecture fitness tests pin against, and ConferenceErrorResources (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Resources/ConferenceErrorResources.cs:11) is a similarly empty sealed class acting as the anchor for the module's .resx error-code translations, keyed by domain error Code and deliberately omitting runtime-variable messages so they degrade to English with the interpolated value intact (ConferenceErrorResources.cs:3-10).

    +

    ConferenceModule is the Conference implementation of IModule. It is tiny by design: Register(...) calls the DependencyInjection extension's AddConferenceModule(...) (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/ConferenceModule.cs:28-29), which chains the Application, Infrastructure, and API-layer registrations in dependency order into one call (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/DependencyInjection.cs:25-27). The API layer's AddModuleConferenceAPI is not a no-op: it calls AddPermissions to grant RoleNames.Organizer and .Admin every ConferencePermissions capability, and ContentEditor only the five-capability ContentManagement curation subset with no event structure, rooms, questions, or session selection (DependencyInjection.cs:41-51; the subset itself is ConferencePermissions.cs:57-64). Attendees are granted nothing here, so attendee-facing endpoints stay on the plain AuthorizationPolicies.RequireAuthenticated policy (DependencyInjection.cs:35-36). And RegisterDisabledStubs(...) registers both a DisabledSessionBookmarkValidationService and a DisabledEventLiveValidationService as singletons (ConferenceModule.cs:23-24) so that other hosts which depend on Conference's ISessionBookmarkValidationService or IEventLiveValidationService but do not host Conference still resolve those interfaces (they no-op, or are later Replaced by the gRPC adapters). The ModuleLoader (G14) discovers ConferenceModule by reflection and registers it in topological order, the same mechanism whether Conference is co-hosted or runs alone in its service.

    +

    ConferenceModuleSeeder implements IModuleSeeder (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/ConferenceModuleSeeder.cs:13) and is the API layer's thin bridge to the real seeding logic: it resolves IUnitOfWork and IConfiguration from the passed service provider, reads Seeding:IncludeSampleConferenceData (defaulting to false when the key is absent, and set to true only by the local AppHost at MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:162), then constructs and runs ConferenceModuleDbSeeder from G19 with that flag (ConferenceModuleSeeder.cs:21-29). The two markers AssemblyReference / ClassReference (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/AssemblyReference.cs:5,11) are the per-package anchors the module scan and the architecture fitness tests pin against, and ConferenceErrorResources (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Resources/ConferenceErrorResources.cs:11) is a similarly empty sealed class acting as the anchor for the module's .resx error-code translations, keyed by domain error Code and deliberately omitting runtime-variable messages so they degrade to English with the interpolated value intact (ConferenceErrorResources.cs:3-10).

    The gRPC edge, Conference as both server and client

    -

    When Conference is extracted into its own process, two of its in-process collaborations must cross a network boundary, and both are handled by the G13 transport boundary (Result over the wire, transport at the edge, ADR-007). Conference is the server for two contracts. SessionBookmarksGrpcService (in MMCA.ADC.Conference.Service) exposes Conference's ISessionBookmarkValidationService to Engagement, answering "is this session valid to bookmark?" (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Grpc/SessionBookmarksGrpcService.cs:27) and "give me the session ids for this event" (SessionBookmarksGrpcService.cs:45). EventLiveValidationGrpcService exposes IEventLiveValidationService to Engagement's conference-day live layer across four methods, each projecting a domain record onto the wire shape: GetEventLiveInfo returns an EventLiveInfo as publish state plus live-window bounds converted to Unix seconds (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Grpc/EventLiveValidationGrpcService.cs:41-46), GetSessionLiveInfo adds a SessionLiveInfo's stringified speaker ids, plenum flag, and moderation default cast to an int (EventLiveValidationGrpcService.cs:65-75), GetSponsorLiveInfo returns a SponsorLiveInfo (EventLiveValidationGrpcService.cs:94-99), and GetCurrentRoomSessionInfo resolves the room's currently-running session within a caller-supplied grace window as a RoomSessionInfo (EventLiveValidationGrpcService.cs:110-124). Each server method is a constructor-injected wrapper over the inner C# service: it null-guards request and context, awaits the inner call, and on a failed Result calls result.ThrowIfFailure() (SessionBookmarksGrpcService.cs:39,57, EventLiveValidationGrpcService.cs:38,62,91,115) so the GrpcResultExceptionInterceptor (wired by AddGrpcServiceDefaults()) can translate the failure into an RpcException with structured error-{i}-* trailers.

    -

    On the client side, each contract has a hand-written adapter in MMCA.ADC.Conference.Contracts that Engagement uses. SessionBookmarkValidationServiceGrpcAdapter implements the identical ISessionBookmarkValidationService interface on top of the generated gRPC client (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/SessionBookmarkValidationServiceGrpcAdapter.cs:24-26), and EventLiveValidationServiceGrpcAdapter does the same for all four IEventLiveValidationService methods (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/EventLiveValidationServiceGrpcAdapter.cs:23-25), converting the Unix-second live-window fields back into UTC DateTimes and the speaker-id strings back into Guids (EventLiveValidationServiceGrpcAdapter.cs:84-91). Both pin a 5-second per-call deadline on every RPC (SessionBookmarkValidationServiceGrpcAdapter.cs:32,46,79, EventLiveValidationServiceGrpcAdapter.cs:30,44,81,122,161), much tighter than the shared resilience pipeline's 30s attempt / 90s total budget, precisely because these calls sit inline in user request paths (bookmark create and list, live-layer poll and question commands) and a hung (as opposed to refused) Conference peer must fail fast rather than hold the caller hostage. Both catch RpcException and reconstruct Result.Failure(errors) from the trailers, falling back to a generic Error.Failure coded Grpc.{StatusCode} for pure transport faults such as connection reset or deadline exceeded (SessionBookmarkValidationServiceGrpcAdapter.cs:50-64,84-100, EventLiveValidationServiceGrpcAdapter.cs:52-66,93-107,130-144,170-184). The trailer parsing lives once in GrpcErrorTrailerParser (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/GrpcErrorTrailerParser.cs:14), whose Parse walks error-{i}-* trailers by index until the first missing code and rebuilds each Error with the correct factory per ErrorType (GrpcErrorTrailerParser.cs:17,25-44,56-68), so the round-trip logic is shared by both adapters. Because both the in-process implementation and each adapter satisfy the same interface, swapping monolith for microservice is a registration change, not a rewrite (ADR-007; [Rubric §7, Microservices Readiness]).

    -

    Those registration swaps are performed by the contract package's DependencyInjection extension, one method per contract: AddConferenceSessionValidationClient(serviceName = "conference") (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/DependencyInjection.cs:43) and AddConferenceEventLiveValidationClient(...) (DependencyInjection.cs:73). Each does exactly two things: registers a typed gRPC client via Common's AddTypedGrpcClient<TClient>(serviceName) (DependencyInjection.cs:45,75, which resolves http://conference through Aspire service discovery and attaches the JWT-forwarding interceptor plus Polly resilience handler), then calls services.Replace(...) with a scoped descriptor rather than TryAdd (DependencyInjection.cs:49,79), to overwrite whatever implementation is already in the container (the real in-process service if Conference is co-hosted, or the Disabled... stub if not) with the gRPC adapter. The Replace is deliberate so the adapter wins in either case; it must be called from the consumer's Program.cs after ModuleLoader.DiscoverAndRegister(...) so the in-process or stub registration is already present for Replace to find (DependencyInjection.cs:36-39). Note the bidirectional Conference-to-Engagement gRPC relationship: Conference serves these two contracts and also consumes Engagement's IBookmarkCountService, so the Conference service host registers AddEngagementBookmarkCountClient() (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:329) and the AppHost deliberately omits a reciprocal startup WaitFor to avoid a deadlock; transient "peer not ready" errors self-heal through the resilience pipeline (ADR-007 / ADR-008; [Rubric §29, Resilience]).

    +

    When Conference runs in its own process, two of its in-process collaborations must cross a network boundary, and both are handled by the G13 transport boundary (Result over the wire, transport at the edge, ADR-007). Conference is the server for two contracts. SessionBookmarksGrpcService (in MMCA.ADC.Conference.Service) exposes Conference's ISessionBookmarkValidationService to Engagement, answering "is this session valid to bookmark?" (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Grpc/SessionBookmarksGrpcService.cs:27) and "give me the session ids for this event" (SessionBookmarksGrpcService.cs:45). EventLiveValidationGrpcService exposes IEventLiveValidationService to Engagement's conference-day live layer across four methods, each projecting a domain record onto the wire shape: GetEventLiveInfo returns an EventLiveInfo as publish state plus live-window bounds converted to Unix seconds (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Grpc/EventLiveValidationGrpcService.cs:41-46), GetSessionLiveInfo adds a SessionLiveInfo's stringified speaker ids, plenum flag, and moderation default cast to an int (EventLiveValidationGrpcService.cs:65-75), GetSponsorLiveInfo returns a SponsorLiveInfo (EventLiveValidationGrpcService.cs:94-99), and GetCurrentRoomSessionInfo resolves the room's currently-running session within a caller-supplied grace window as a RoomSessionInfo (EventLiveValidationGrpcService.cs:103,118-124). Each server method is a constructor-injected wrapper over the inner C# service: it null-guards request and context, awaits the inner call, and on a failed Result calls result.ThrowIfFailure() (SessionBookmarksGrpcService.cs:39,57, EventLiveValidationGrpcService.cs:38,62,91,115) so the GrpcResultExceptionInterceptor (wired by AddGrpcServiceDefaults()) can translate the failure into an RpcException with structured error-{i}-* trailers.

    +

    On the client side, each contract has a hand-written adapter in MMCA.ADC.Conference.Contracts that Engagement uses. SessionBookmarkValidationServiceGrpcAdapter implements the identical ISessionBookmarkValidationService interface on top of the generated gRPC client (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/SessionBookmarkValidationServiceGrpcAdapter.cs:24-26), and EventLiveValidationServiceGrpcAdapter does the same for all four IEventLiveValidationService methods (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/EventLiveValidationServiceGrpcAdapter.cs:23-25), converting the Unix-second live-window fields back into UTC DateTimes and the speaker-id strings back into Guids (EventLiveValidationServiceGrpcAdapter.cs:47-50,84-91). Both pin a 5-second per-call deadline on every RPC (SessionBookmarkValidationServiceGrpcAdapter.cs:32,46,79, EventLiveValidationServiceGrpcAdapter.cs:30,44,81,122,161), much tighter than the shared resilience pipeline's 30s attempt / 90s total budget, precisely because these calls sit inline in user request paths (bookmark create and list, live-layer poll and question commands) and a hung (as opposed to refused) Conference peer must fail fast rather than hold the caller hostage (EventLiveValidationServiceGrpcAdapter.cs:27-29). Both catch RpcException and reconstruct Result.Failure(errors) from the trailers, falling back to a generic Error.Failure coded Grpc.{StatusCode} for pure transport faults such as connection reset or deadline exceeded (SessionBookmarkValidationServiceGrpcAdapter.cs:50-64,84-100, EventLiveValidationServiceGrpcAdapter.cs:52-66,93-107,130-144,170-184). The trailer parsing lives once in GrpcErrorTrailerParser (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/GrpcErrorTrailerParser.cs:14), whose Parse walks error-{i}-* trailers by index until the first missing code and rebuilds each Error with the correct factory per ErrorType (GrpcErrorTrailerParser.cs:17,25-44,56-68), so the round-trip logic is shared by both adapters. Because both the in-process implementation and each adapter satisfy the same interface, swapping a co-located module for a remote service is a registration change, not a rewrite (ADR-007; [Rubric §7, Microservices Readiness]).

    +

    Those registration swaps are performed by the contract package's DependencyInjection extension, one method per contract: AddConferenceSessionValidationClient(serviceName = "conference") (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/DependencyInjection.cs:43) and AddConferenceEventLiveValidationClient(...) (DependencyInjection.cs:73). Each does exactly two things: registers a typed gRPC client via Common's AddTypedGrpcClient<TClient>(serviceName) (DependencyInjection.cs:45,75, which resolves http://conference through Aspire service discovery and attaches the JWT-forwarding interceptor plus Polly resilience handler), then calls services.Replace(...) with a scoped descriptor rather than TryAdd (DependencyInjection.cs:49,79), to overwrite whatever implementation is already in the container (the real in-process service if Conference is co-hosted, or the Disabled... stub if not) with the gRPC adapter. The Replace is deliberate so the adapter wins in either case; it must be called from the consumer's Program.cs after ModuleLoader.DiscoverAndRegister(...) so the in-process or stub registration is already present for Replace to find (DependencyInjection.cs:36-39). Note the bidirectional Conference-to-Engagement gRPC relationship: Conference serves these two contracts and also consumes Engagement's IBookmarkCountService, so the Conference service host registers AddEngagementBookmarkCountClient() (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:350) and the AppHost deliberately gives only the Engagement-to-Conference edge a startup WaitFor, leaving the reverse edge a plain WithReference so the pair cannot deadlock; transient "peer not ready" errors self-heal through the resilience pipeline (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:203-218; ADR-007 / ADR-008; [Rubric §29, Resilience]).

    The service host: Kestrel first, and why

    -

    The MMCA.ADC.Conference.Service Program.cs boots only the Conference module (Modules:Conference:Enabled=true). Kestrel is configured before anything else, and the whole of it is one line: builder.ConfigureEndpointsWithHealthProbe(HttpProtocols.Http2) (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:85), the shared extension from Common's KestrelEndpointExtensions (G16). Passing HttpProtocols.Http2 sets every endpoint default to HTTP/2-only on cleartext (h2c prior knowledge), so cross-service gRPC clients can negotiate HTTP/2 without TLS or ALPN; on a cleartext endpoint Http1AndHttp2 would effectively disable HTTP/2 and Kestrel would reject gRPC frames with GOAWAY HTTP_1_1_REQUIRED (Program.cs:75-81). That host-transport choice is ADR-012. The operational half lives in the shared helper: only when HealthProbe:Port is configured (injected by infra/main.bicep, deliberately absent locally so Aspire's dynamic ports keep working) does it add a dedicated HTTP/1.1-only listener for the ACA httpGet probes (Program.cs:82-84), because the h2c-only endpoint rejects the platform's HTTP/1.1 probe requests. MapDefaultEndpoints (Program.cs:372) maps the health endpoints on every listener, so the probe port serves the real health pipeline while staying off the ACA ingress. The rest of the host is the standard ADC REST composition: Serilog registered as one provider rather than through UseSerilog() so the OpenTelemetry-to-Azure-Monitor provider survives (Program.cs:108-115), an optional Key Vault configuration source layered in before anything binds settings (Program.cs:124), the Conference-owned MMCA.ADC.Conference.Scoring meter (Program.cs:133-134), health checks with SQL required (Program.cs:184), CORS, API versioning and rate limiting (Program.cs:187-189), response compression (Program.cs:265), OpenAPI outside Production (Program.cs:270,380-383), RS256 JWT validation via JWKS discovery forwarded through the Gateway (Program.cs:275-281), exception handlers (Program.cs:284), the scheduler and audit-trail extension points (Program.cs:292,296), and the shared middleware pipeline (Program.cs:373; ADR-004 / ADR-019).

    +

    The MMCA.ADC.Conference.Service Program.cs boots only the Conference module (Modules:Conference:Enabled=true). Kestrel is configured before anything else, and the whole of it is one line: builder.ConfigureEndpointsWithHealthProbe(HttpProtocols.Http2) (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:85), the shared extension from Common's KestrelEndpointExtensions (G16). Passing HttpProtocols.Http2 sets every endpoint default to HTTP/2-only on cleartext (h2c prior knowledge), so cross-service gRPC clients can negotiate HTTP/2 without TLS or ALPN; on a cleartext endpoint Http1AndHttp2 would effectively disable HTTP/2 and Kestrel would reject gRPC frames with GOAWAY HTTP_1_1_REQUIRED (Program.cs:75-81). That host-transport choice is ADR-012. The operational half lives in the shared helper: only when HealthProbe:Port is configured (injected by infra/main.bicep, deliberately absent locally so Aspire's dynamic ports keep working) does it add a dedicated HTTP/1.1-only listener for the ACA httpGet probes (Program.cs:82-84), because the h2c-only endpoint rejects the platform's HTTP/1.1 probe requests. MapDefaultEndpoints (Program.cs:398) maps the health endpoints on every listener, so the probe port serves the real health pipeline while staying off the ACA ingress. The rest of the host is the standard ADC REST composition: Serilog registered as one provider rather than through UseSerilog() so the OpenTelemetry-to-Azure-Monitor provider survives (Program.cs:108-115), an optional Key Vault configuration source layered in before anything binds settings (Program.cs:124), the Conference-owned MMCA.ADC.Conference.Scoring meter (Program.cs:133-134), health checks with SQL required (Program.cs:184), CORS, API versioning and rate limiting (Program.cs:187-189), response compression (Program.cs:280), OpenAPI outside Production (Program.cs:285,406-409), RS256 JWT validation via JWKS discovery forwarded through the Gateway (Program.cs:294-302), exception handlers (Program.cs:305), the scheduler and audit-trail extension points (Program.cs:313,317), and the shared middleware pipeline (Program.cs:399; ADR-004 / ADR-019 / ADR-079).

    Output caching and warm-up, the two performance extension points

    -

    Output caching is where this host carries the most bespoke configuration (Program.cs:191-255). The base policy is deny-by-default NoCache (Program.cs:198), so only explicitly decorated endpoints cache at all. ConferenceCache stays on the built-in default semantics because the permission-gated SessionSelectionController references it, and ADR-040's public policy must never back a permission-gated endpoint since a cached hit is served before MVC's filters run (Program.cs:200-206). Eight further policies (ConferencePublicCache, EventsCache, SessionsCache, SpeakersCache, RoomsCache, CategoriesCache, QuestionsCache, SponsorsCache) are registered through AddPublicEndpointPolicy at a 5-minute TTL with hierarchical tags (Program.cs:231-243), and each one bypasses the cache entirely for the privileged read audience (Program.cs:230, the bypass list built from ConferenceReadAudience.PrivilegedRoles so it can never diverge from the API-layer visibility checks), for two reasons spelled out in the source (Program.cs:214-229): privileged responses include unpublished rows that must never land in a shared public entry, and admin surfaces read back immediately after writing, where a stale cached row version would make the next save throw DbUpdateConcurrencyException. Two policies then sit at a 60-second TTL for different reasons: NowNextCache because its payload changes with the clock and is identical for every role, so it takes no bypass at all (Program.cs:246), and BookmarkCountsCache because bookmark counts are owned by Engagement in another process with no handle on this service's cache store, so no tag eviction can ever reach those entries and a short TTL is the only lever available (Program.cs:248-254). All of this is ADR-040: PublicEndpointOutputCachePolicy exists because the UI attaches a Bearer token to every request and the built-in default policy refuses to cache anything carrying Authorization, which on conference day meant the cache served none of the real traffic. Two more details are easy to miss and load-bearing at two replicas: when a Redis connection string is present the host backs the output cache with Redis as well as the distributed cache (Program.cs:156), because the default per-replica memory store meant an EvictByTagAsync reached only the replica that served the mutation while the other kept serving the pre-edit payload for the full TTL; and the same branch adds a two-level cache, an in-process L1 over the Redis L2 under a disjoint keyspace, so a repeat read inside one replica never leaves the process while invalidation still crosses replicas (Program.cs:164).

    -

    The host also contributes the module's error-code translations to the edge localizer by calling AddErrorResources<ConferenceErrorResources>() (Program.cs:311), so a Conference domain error like Event.Name.Empty is rendered in the caller's culture by the shared ErrorLocalizer (ADR-027). And one more startup extension point matters: SelfHttpOutputCacheWarmupTask, registered via AddWarmupTask<T>() (Program.cs:262) as an ADR-025 IWarmupTask. The task itself is almost empty: it derives from SelfHttpWarmupTaskBase (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/SelfHttpOutputCacheWarmupTask.cs:22-28) and contributes only a name (SelfHttpOutputCacheWarmupTask.cs:59) and a list of paths (SelfHttpOutputCacheWarmupTask.cs:62), while the base owns the request machinery (waiting for the server to start, resolving the actually-bound cleartext port, pinning HTTP/2 prior knowledge, and treating a failure as non-fatal). The paths are the interesting part, and there are eight of them in two families (SelfHttpOutputCacheWarmupTask.cs:42-56), because OutputCache keys on the full URL and a warmed entry is only ever hit by a byte-identical query string: family one mirrors the Blazor list pages, whose service base interpolates C# bools and so writes capital False/True; family two mirrors the hand-written lookup services, which write lowercase literals and pageSize=10000. Warming one family left the other paying a cold read on its first real caller. Every path is [AllowAnonymous], so the base's require-success loop sees 200 and skips nothing.

    +

    Output caching is where this host carries the most bespoke configuration (Program.cs:196-265). The base policy is deny-by-default NoCache (Program.cs:198), so only explicitly decorated endpoints cache at all. ConferenceCache stays on the built-in default semantics because the permission-gated SessionSelectionController references it, and ADR-040's public policy must never back a permission-gated endpoint since a cached hit is served before MVC's filters run (Program.cs:200-206). Nine further policies (ConferencePublicCache, EventsCache, SessionsCache, SpeakersCache, RoomsCache, CategoriesCache, QuestionsCache, SponsorsCache, ActivitiesCache) are registered through AddPublicEndpointPolicy at a 5-minute TTL with hierarchical tags (Program.cs:236-249), and each one bypasses the cache entirely for the privileged read audience (Program.cs:235, the bypass list built from ConferenceReadAudience.PrivilegedRoles so it can never diverge from the API-layer visibility checks), for two reasons spelled out in the source (Program.cs:214-234): privileged responses include unpublished rows that must never land in a shared public entry, and admin surfaces read back immediately after writing, where a stale cached row version would make the next save throw DbUpdateConcurrencyException. Two policies then sit at a 60-second TTL for different reasons: NowNextCache because its payload changes with the clock and is identical for every role, so it takes no bypass at all (Program.cs:252), and BookmarkCountsCache because bookmark counts are owned by Engagement in another process (Program.cs:255-264). All of this is ADR-040: PublicEndpointOutputCachePolicy exists because the UI attaches a Bearer token to every request and the built-in default policy refuses to cache anything carrying Authorization, which on conference day meant the cache served none of the real traffic.

    +

    Two mechanisms close the distance that TTLs alone cannot. First, at two replicas the store itself must be shared: when a Redis connection string is present the host backs the output cache with Redis as well as the distributed cache (Program.cs:156), because the default per-replica memory store meant an EvictByTagAsync reached only the replica that served the mutation while the other kept serving the pre-edit payload for the full TTL; the same branch adds a two-level cache, an in-process L1 over the Redis L2 under a disjoint keyspace, so a repeat read inside one replica never leaves the process while invalidation still crosses replicas (Program.cs:164). Second, a write that never touches a Conference controller still has to reach this cache: an Engagement bookmark or an application-layer speaker auto-link has no handle on IOutputCacheStore, so the writer publishes an OutputCacheEvictionRequested integration event, this host registers the consumer half with AddOutputCacheEvictionHandler() (Program.cs:270) and the broker half with RegisterOutputCacheEvictionConsumer() (Program.cs:373), and the tag is dropped on arrival. Registering only one of the two halves is a silent no-op (Program.cs:267-269); the 60-second BookmarkCountsCache TTL stays deliberately as the backstop for a message that never lands.

    +

    The host also contributes the module's error-code translations to the edge localizer by calling AddErrorResources<ConferenceErrorResources>() (Program.cs:332), so a Conference domain error like Event.Name.Empty is rendered in the caller's culture by the shared ErrorLocalizer (ADR-027). And one more startup extension point matters: SelfHttpOutputCacheWarmupTask, registered via AddWarmupTask<T>() (Program.cs:277) as an ADR-025 IWarmupTask. The task itself is almost empty: it derives from SelfHttpWarmupTaskBase (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/SelfHttpOutputCacheWarmupTask.cs:22-28) and contributes only a name (SelfHttpOutputCacheWarmupTask.cs:59) and a list of paths (SelfHttpOutputCacheWarmupTask.cs:62), while the base owns the request machinery (waiting for the server to start, resolving the actually-bound cleartext port, pinning HTTP/2 prior knowledge, and treating a failure as non-fatal). The paths are the interesting part, and there are eight of them in two families (SelfHttpOutputCacheWarmupTask.cs:42-56), because OutputCache keys on the full URL and a warmed entry is only ever hit by a byte-identical query string: family one mirrors the Blazor list pages, whose service base interpolates C# bools and so writes capital False/True; family two mirrors the hand-written lookup services, which write lowercase literals and pageSize=10000 (SelfHttpOutputCacheWarmupTask.cs:30-41). Warming one family left the other paying a cold read on its first real caller. Every path is [AllowAnonymous], so the base's require-success loop sees 200 and skips nothing.

    The runtime picture, one host, two transports

    -

    After module discovery (Program.cs:313-319) the host wires the Engagement gRPC client (Program.cs:329), the broker (AddBrokerMessaging registering the UserRegistered integration-event consumer that drives the BR-207 email-match speaker auto-link through UserRegisteredHandler, Program.cs:346-347, falling back to in-process mode when MessageBus:Provider is unset so integration tests are unaffected), the decorator pipeline (Program.cs:349), AddGrpcServiceDefaults() (Program.cs:358), and the per-module health checks (Program.cs:361). It initializes the database before serving traffic (Program.cs:370), then publishes both gRPC endpoints over the same Kestrel HTTP/2 channel the REST controllers serve: MapGrpcService<SessionBookmarksGrpcService>().RequireAuthorization() (Program.cs:393) and MapGrpcService<EventLiveValidationGrpcService>().RequireAuthorization() (Program.cs:394), adding gRPC reflection in Development only (Program.cs:396-399). The RequireAuthorization() is not decoration: both contracts answer conference-state questions raised on behalf of a specific end user, so internal-only ingress is not considered sufficient, and every caller is an Engagement handler sitting behind an authenticated controller whose bearer token the JWT-forwarding interceptor carries across (Program.cs:388-392, [Rubric §11, Security]).

    -

    A browser request to GET /Sessions enters the Gateway, is forwarded as HTTP/2 to this host, flows through the shared middleware pipeline, hits an output-cached SessionsController action that excludes declined sessions for non-privileged readers, runs the query handler's CQRS pipeline, and returns a CollectionResult<SessionDTO>. Meanwhile an Engagement service can simultaneously call ValidateSessionForBookmark or GetSessionLiveInfo over gRPC against the very same process, and a UserRegistered message from Identity can arrive over the broker and auto-link a speaker, all without any of the three paths knowing about the others. That one module, three ingress paths, identical whether monolith or extracted property is the whole point of this chapter, and the reason the Conference edge is mostly thin glue over reusable Common machinery: the version-header contract and the two-version ServiceInfo surface are the [Rubric §9, API & Contract Design] evidence, and the Replace-driven client swaps are the [Rubric §7, Microservices Readiness] extension point that keeps extraction reversible.

    +

    After module discovery (Program.cs:335-339) the host wires the Engagement gRPC client (Program.cs:350), the broker (AddBrokerMessaging registering the UserRegistered integration-event consumer that drives the BR-207 email-match speaker auto-link through UserRegisteredHandler, Program.cs:371-373, falling back to in-process mode when MessageBus:Provider is unset so integration tests are unaffected), the decorator pipeline (Program.cs:375), AddGrpcServiceDefaults() (Program.cs:384), and the per-module health checks (Program.cs:387). It initializes the database before serving traffic (Program.cs:396), then publishes both gRPC endpoints over the same Kestrel HTTP/2 channel the REST controllers serve: MapGrpcService<SessionBookmarksGrpcService>().RequireAuthorization() (Program.cs:419) and MapGrpcService<EventLiveValidationGrpcService>().RequireAuthorization() (Program.cs:420), adding gRPC reflection in Development only (Program.cs:422-425). The RequireAuthorization() is not decoration: both contracts answer conference-state questions raised on behalf of a specific end user, so internal-only ingress is not considered sufficient, and every caller is an Engagement handler sitting behind an authenticated controller whose bearer token the JWT-forwarding interceptor carries across (Program.cs:414-418, [Rubric §11, Security]).

    +

    A browser request to GET /Sessions enters the Gateway, is forwarded as HTTP/2 to this host, flows through the shared middleware pipeline, hits an output-cached SessionsController action that excludes declined sessions for non-privileged readers, runs the query handler's CQRS pipeline, and returns a CollectionResult<SessionDTO>. Meanwhile an Engagement service can simultaneously call ValidateSessionForBookmark or GetSessionLiveInfo over gRPC against the very same process, and a UserRegistered message from Identity can arrive over the broker and auto-link a speaker, all without any of the three paths knowing about the others. That one module, three ingress paths, identical whether co-hosted or standalone property is the whole point of this chapter, and the reason the Conference edge is mostly thin glue over reusable Common machinery: the version-header contract and the two-version ServiceInfo surface are the [Rubric §9, API & Contract Design] evidence, and the Replace-driven client swaps are the [Rubric §7, Microservices Readiness] extension point that keeps the topology reversible.

    AssemblyReference

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

    @@ -974,34 +976,35 @@

    ConferenceCategoriesController

    EventQuestionAnswersController

    -

    MMCA.ADC.Conference.API · MMCA.ADC.Conference.API.Controllers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:56 · Level 9 · class (sealed)

    +

    MMCA.ADC.Conference.API · MMCA.ADC.Conference.API.Controllers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventQuestionAnswersController.cs:57 · Level 9 · class (sealed)

    • What it is: the REST controller for event feedback answers (/EventQuestionAnswers). Unlike the public-catalog controllers in this group, both reads and writes require authentication ([Authorize(Policy = AuthorizationPolicies.RequireAuthenticated)], - EventQuestionAnswersController.cs:55), and the reads are owner-scoped by BR-8: organizers see every + EventQuestionAnswersController.cs:56), and the reads are owner-scoped by BR-8: organizers see every answer, everyone else sees only their own.
    • Depends on: EntityControllerBase<TEntity, TEntityDTO, TIdentifierType> - (the read-only base, EventQuestionAnswersController.cs:63); + (the read-only base, EventQuestionAnswersController.cs:64); IEntityQueryService<TEntity, TEntityDTO, TIdentifierType> for reads; three ICommandHandler<in TCommand, TResult> injections for AddEventQuestionAnswerCommand, UpdateEventQuestionAnswerCommand and RemoveEventQuestionAnswerCommand - (:58-60); ICurrentUserService and + (:59-61); ICurrentUserService and RoleNames for the scoping decision; OwnedByUserSpecification<TEntity, TIdentifierType> - as the filter it builds; AuthorizationPolicies; - the EventQuestionAnswerDTO; and its two request + as the filter it builds; AuthorizationPolicies; the + IdempotentAttribute on its create; the + EventQuestionAnswerDTO; and its two request records AddEventQuestionAnswerRequest / - UpdateEventQuestionAnswerRequest (:26-46). Externals: ASP.NET Core + UpdateEventQuestionAnswerRequest (:26-47). Externals: ASP.NET Core MVC ([ApiController], [HttpGet], [FromQuery]), Asp.Versioning, ILogger.
    • Concept introduced, per-user read scoping via a specification. [Rubric §11, Security] assesses whether authorization is enforced server-side and whether results are scoped per user rather than merely - hidden in the UI. The private GetUserScopingSpecification() (EventQuestionAnswersController.cs:66-67) + hidden in the UI. The private GetUserScopingSpecification() (EventQuestionAnswersController.cs:67-68) returns null when currentUserService.IsInRole(RoleNames.Organizer) (no filter, sees all), otherwise a new OwnedByUserSpecification<EventQuestionAnswer, EventQuestionAnswerIdentifierType>(currentUserService.UserId!.Value). That specification is threaded into QueryService.GetAllAsync / GetByIdAsync, so the database query @@ -1009,43 +1012,47 @@

      EventQuestionAnswersController

      filtered out of an already-fetched page. This is why the reads here fully override the base actions (threading the specification, asTracking: false, and a MaxPageSize cap) instead of delegating with => base.... the way the public controllers do. [Rubric §9, API & Contract Design]: the write records - carry no UserId at all (:26-46); identity comes from the authenticated principal and CreatedBy is + carry no UserId at all (:26-47); identity comes from the authenticated principal and CreatedBy is stamped by the audit pipeline, never trusted from the client. Note the absence of any [OutputCache] - attribute: a per-caller response must not land in a shared cache entry, and the controller simply never - opts in.
    • + attribute on any action in this file: a per-caller response must not land in a shared cache entry, and the + controller simply never opts in.
    • Concept introduced, closing the CSV export as a row-scoping bypass. The framework base ships a streaming CSV endpoint, ExportAsync - (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:234), and its own + (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:235), and its own remarks state the hazard plainly: the rows it streams are whatever GetExportSpecification() allows, and that hook returns null by default, so an export is unscoped unless the concrete controller says - otherwise (EntityControllerBase.cs:213-218, 466-488). A controller that row-scopes its list endpoints + otherwise (EntityControllerBase.cs:214-219, 520-542). A controller that row-scopes its list endpoints but inherits the default export therefore hands every caller the whole table in one request. This controller closes that with the role gate the base describes as the interim form of the mitigation - (EntityControllerBase.cs:478-482): ExportAsync is overridden to Forbid() unless the caller is an - organizer, then delegate to the base (EventQuestionAnswersController.cs:159-173). Every row-scoped + (EntityControllerBase.cs:533-537): ExportAsync is overridden to Forbid() unless the caller is an + organizer, then delegate to the base (EventQuestionAnswersController.cs:159-174). Every row-scoped controller in this unit repeats one of the two variants of this gate, and the rationale is written into - each override's doc comment (:152-157 here). [Rubric §11, Security] again, and [Rubric §30, Compliance/Privacy/Data Governance], which assesses whether personal data has a single governed exit + each override's doc comment (:153-158 here). [Rubric §11, Security] again, and [Rubric §30, Compliance/Privacy/Data Governance], which assesses whether personal data has a single governed exit path: feedback answers are attributable personal content, so a bulk download stays with the role that already reads every row.
    • Walkthrough
        -
      • Primary-constructor injection (EventQuestionAnswersController.cs:56-62): query service, three command - handlers, ICurrentUserService, logger. The base is constructed with (queryService, logger) (:63).
      • -
      • GetUserScopingSpecification() (:66-67): the organizer-or-own branch described above.
      • -
      • GetAllAsync (:69-88): fully overridden, calls QueryService.GetAllAsync(specification: GetUserScopingSpecification(), pageSize: MaxPageSize, asTracking: false, ...) (:77-85) and returns +
      • Primary-constructor injection (EventQuestionAnswersController.cs:57-63): query service, three command + handlers, ICurrentUserService, logger. The base is constructed with (queryService, logger) (:64).
      • +
      • GetUserScopingSpecification() (:67-68): the organizer-or-own branch described above.
      • +
      • GetAllAsync (:70-89): fully overridden, calls QueryService.GetAllAsync(specification: GetUserScopingSpecification(), pageSize: MaxPageSize, asTracking: false, ...) (:78-86) and returns Ok(result.Value) or HandleFailure(result.Errors).
      • -
      • The paged GetAllAsync (:90-123): clamps pageSize = Math.Min(pageSize, MaxPageSize) (:102), - threads the same specification (:108), binds filters through the - QueryFilterModelBinder (:99), and appends - the X-Pagination header carrying the result's pagination metadata (:121).
      • -
      • GetAllForLookupAsync (:125-129) delegates straight to the base; GetByIdAsync (:131-150) threads - the specification (:144) so an attendee cannot fetch another user's answer by id.
      • -
      • ExportAsync (:159-173): the organizer gate above, Forbid() at :169, otherwise - base.ExportAsync(...) at :172.
      • -
      • CreateAsync (:176-191): dispatches new AddEventQuestionAnswerCommand(request.EventId, null, request.QuestionId, request.AnswerValue) (:182), the null being the child id the domain mints, then +
      • The paged GetAllAsync (:91-124): clamps pageSize = Math.Min(pageSize, MaxPageSize) (:103), + threads the same specification (:109), binds filters through the + QueryFilterModelBinder (:100), and appends + the X-Pagination header carrying the result's + PaginationMetadata (:122).
      • +
      • GetAllForLookupAsync (:126-130) delegates straight to the base; GetByIdAsync (:132-151) threads + the specification (:145) so an attendee cannot fetch another user's answer by id.
      • +
      • ExportAsync (:159-174): the organizer gate above, Forbid() at :170, otherwise + base.ExportAsync(...) at :173.
      • +
      • CreateAsync (:185-199) carries [Idempotent] (:184), so a retried POST with the same + Idempotency-Key replays the stored response instead of writing a second answer row. The doc comment + records why the attribute is declared here rather than inherited: this create is hand-written, not the + base action (:176-182). It dispatches new AddEventQuestionAnswerCommand(request.EventId, null, request.QuestionId, request.AnswerValue) (:190), the null being the child id the domain mints, then CreatedAtRoute("GetEventQuestionAnswerById", ...).
      • -
      • UpdateAsync (:194-207): dispatches new UpdateEventQuestionAnswerCommand(request.EventId, id, request.AnswerValue) (:201) and returns NoContent().
      • -
      • DeleteAsync (:210-223): takes the parent eventId [FromQuery] (:213) because the route only - carries the child id, dispatches RemoveEventQuestionAnswerCommand(eventId, id) (:217), and returns +
      • UpdateAsync (:203-215): dispatches new UpdateEventQuestionAnswerCommand(request.EventId, id, request.AnswerValue) (:209) and returns NoContent().
      • +
      • DeleteAsync (:219-231): takes the parent eventId [FromQuery] (:221) because the route only + carries the child id, dispatches RemoveEventQuestionAnswerCommand(eventId, id) (:225), and returns NoContent().
    • @@ -1060,13 +1067,13 @@

      EventQuestionAnswersController

      exact session-scoped sibling (BR-9), built the same way.
    • Caveats / not-in-source: the controller gates the export by role instead of overriding GetExportSpecification(), so an attendee cannot export their own answers at all. The base's remarks - describe the specification override as the form that would restore that (EntityControllerBase.cs:478-482); + describe the specification override as the form that would restore that (EntityControllerBase.cs:533-537); whether that change is planned is not determinable from source.

    EventSpeakersController

    -

    MMCA.ADC.Conference.API · MMCA.ADC.Conference.API.Controllers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:46 · Level 9 · class (sealed)

    +

    MMCA.ADC.Conference.API · MMCA.ADC.Conference.API.Controllers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventSpeakersController.cs:47 · Level 9 · class (sealed)

    • What it is: the REST controller for the many-to-many link between an event and a speaker @@ -1074,66 +1081,72 @@

      EventSpeakersController

      an EventSpeaker is a child of the Event aggregate, this controller reads the child directly but mutates it only through the parent aggregate's commands. It is the reference implementation of the - junction controller shape that four controllers in this unit share.
    • + junction controller shape that three more controllers in this unit share.
    • Depends on: EntityControllerBase<TEntity, TEntityDTO, TIdentifierType> - (the read-only base, EventSpeakersController.cs:54), + (the read-only base, EventSpeakersController.cs:55), IEntityQueryService<TEntity, TEntityDTO, TIdentifierType> for reads, two ICommandHandler<in TCommand, TResult>s (AddEventSpeakerCommand / RemoveEventSpeakerCommand, - EventSpeakersController.cs:48-49), an + EventSpeakersController.cs:49-50), an IQueryHandler<in TQuery, TResult> for GetPublicEventSpeakerFilterQuery - (:50), ICurrentUserService plus the + (:51), ICurrentUserService plus the CurrentUserServiceExtensions read-audience helper, ASP.NET Core's - IOutputCacheStore (:52), the EventSpeakerDTO, the + IOutputCacheStore (:53), the EventSpeakerDTO, the HasPermissionAttribute and the - ConferencePermissions catalog, and its request - record AddEventSpeakerRequest (:28-35).
    • + ConferencePermissions catalog, the + IdempotentAttribute, and its request record + AddEventSpeakerRequest (:28-36).
    • Concept introduced, the junction controller and its inherited visibility. [Rubric §4, Domain-Driven Design] assesses whether aggregate boundaries are respected: you never POST straight at a child row. The controller derives from the read-only EntityControllerBase (which supplies only GetAll / GetById / GetAllForLookup / Export, no create or delete) and hand-rolls its two mutations, each dispatching a command that loads the parent aggregate. [Rubric §11, Security]: the class carries [HasPermission(ConferencePermissions.EventsManage)] - (EventSpeakersController.cs:45) so writes require the organizer capability (BR-41), while every read + (EventSpeakersController.cs:46) so writes require the organizer capability (BR-41), while every read overrides that with [AllowAnonymous] (BR-43). The subtle half is BR-108: a junction row must not leak the existence of an unpublished event, so the reads do not simply forward to the base. IsPrivileged - (:57) asks currentUserService.IsPrivilegedConferenceReader(), and BuildPublicSpecificationAsync - (:65-74) returns null for a privileged reader or, for everyone else, the + (:58) asks currentUserService.IsPrivilegedConferenceReader(), and BuildPublicSpecificationAsync + (:66-75) returns null for a privileged reader or, for everyone else, the Specification<TEntity, TIdentifierType> produced by the GetPublicEventSpeakerFilterQuery handler, which resolves the published-event id list in the Application layer. Every read threads that specification into the query service, so a hidden parent yields a 404 rather than a redacted row. [Rubric §12, Performance & Scalability]: the reads are cached under the EventsCache policy (5-minute TTL, tags conference and conference:events, registered at - MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:232), which is exactly why the writes - must evict.
    • + MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:237), which is exactly why the writes + must evict. That policy is a + PublicEndpointOutputCachePolicy + registration and it bypasses the cache entirely for the privileged read audience + (ADR-040), + which is what keeps an organizer's everything-inclusive payload out of the shared public entry.
    • Walkthrough
        -
      • GetAllAsync (EventSpeakersController.cs:79-97) and the paged overload (:102-134) are full - overrides: [AllowAnonymous] + [OutputCache(PolicyName = "EventsCache")] (:77-78, 100-101), the - public specification threaded in (:89, 119), the page size clamped to MaxPageSize (:113), and the - X-Pagination header appended (:132).
      • -
      • GetAllForLookupAsync (:143-160) is the anti-side-channel path: a privileged reader (null - specification) falls through to the framework base action (:148-149); everyone else gets - QueryService.GetAllForLookupAsync(nameProperty, where: specification.Criteria, ...) (:151-155), the +
      • GetAllAsync (EventSpeakersController.cs:80-98) and the paged overload (:103-135) are full + overrides: [AllowAnonymous] + [OutputCache(PolicyName = "EventsCache")] (:78-79, 101-102), the + public specification threaded in (:90, 120), the page size clamped to MaxPageSize (:114), and the + X-Pagination header appended (:133).
      • +
      • GetAllForLookupAsync (:144-161) is the anti-side-channel path: a privileged reader (null + specification) falls through to the framework base action (:149-150); everyone else gets + QueryService.GetAllForLookupAsync(nameProperty, where: specification.Criteria, ...) (:152-156), the where overload declared at - MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEntityQueryService.cs:87-91, and the rows + MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEntityQueryService.cs:87, and the rows are wrapped back into a CollectionResult<T> of - BaseLookup<TIdentifierType> (:159). + BaseLookup<TIdentifierType> (:160). Without this, a dropdown would enumerate the names the list endpoint hides.
      • -
      • GetByIdAsync (:165-183) threads the same specification (:177).
      • -
      • ExportAsync (:192-206) is the export gate taught at +
      • GetByIdAsync (:166-184) threads the same specification (:178).
      • +
      • ExportAsync (:193-207) is the export gate taught at EventQuestionAnswersController, in its privileged-reader form: - if (!IsPrivileged) return Forbid(); (:200-203), then base.ExportAsync(...) (:205). The doc + if (!IsPrivileged) return Forbid(); (:201-204), then base.ExportAsync(...) (:206). The doc comment states the leak it prevents: an unscoped CSV would carry the junction rows of unpublished - events, "leaking exactly the existence the reads above hide" (:186-190).
      • -
      • CreateAsync (:210-228) dispatches AddEventSpeakerCommand(request.EventId, null, request.SpeakerId) - (:215), returns HandleFailure(result.Errors) on failure (:218-221), then evicts and returns - CreatedAtRoute("GetEventSpeakerById", ...) (:223-227).
      • -
      • DeleteAsync (:232-248) reads the parent eventId [FromQuery] (:234), dispatches - RemoveEventSpeakerCommand(eventId, id) (:238), evicts (:246), and returns NoContent().
      • -
      • EvictJunctionCacheAsync (:255-260) clears both parents' tags plus the broad one: + events, "leaking exactly the existence the reads above hide" (:186-191).
      • +
      • CreateAsync (:218-236) is [Idempotent] (:217) and dispatches + AddEventSpeakerCommand(request.EventId, null, request.SpeakerId) (:223), returns + HandleFailure(result.Errors) on failure (:226-229), then evicts and returns + CreatedAtRoute("GetEventSpeakerById", ...) (:231-235).
      • +
      • DeleteAsync (:240-256) reads the parent eventId [FromQuery] (:242), dispatches + RemoveEventSpeakerCommand(eventId, id) (:246), evicts (:254), and returns NoContent().
      • +
      • EvictJunctionCacheAsync (:263-268) clears both parents' tags plus the broad one: conference:events, conference:speakers, conference. Note the ordering guard in both mutations: the failure return happens before the eviction, so a rejected command never disturbs the cache.
      • Error-to-HTTP translation is inherited from @@ -1141,7 +1154,7 @@

        EventSpeakersController

    • Why it's built this way: a child has no independent lifecycle, so it earns free read endpoints but - explicit, aggregate-routed mutations. The visibility filter lives at the controller edge as a + explicit, aggregate-routed mutations. The visibility filter lives at the controller boundary as a specification because that is the one place that knows the caller's role, while the rule (which parents are public) stays in an Application-layer query handler ([Rubric §3, Clean Architecture]). Evicting both parents' tags is deliberate: the association shows up @@ -1187,10 +1200,14 @@

      QuestionsController

      the sibling controllers' doc comments describe, for example MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:286-287).
    • Walkthrough
        -
      • The class is gated by [HasPermission(ConferencePermissions.QuestionsManage)] (QuestionsController.cs:30); - all four reads override that with [AllowAnonymous] and attach +
      • The class is gated by [HasPermission(ConferencePermissions.QuestionsManage)] (QuestionsController.cs:30), + a capability granted to Organizer and Admin only + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/DependencyInjection.cs:43-44; it is absent + from the ContentEditor subset, + MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Authorization/ConferencePermissions.cs:57-64). + All four reads override that with [AllowAnonymous] and attach [OutputCache(PolicyName = "QuestionsCache")] (5-minute TTL, tags conference and - conference:questions, MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:242).
      • + conference:questions, MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:247).
      • CreateAsync (:92-99) and DeleteAsync (:121-128) are thin overrides: call base.CreateAsync / base.DeleteAsync, then await EvictQuestionsCacheAsync(...), then return the base's result. Because the base hands back an ActionResult rather than a @@ -1208,71 +1225,104 @@

        QuestionsController

      • Why it's built this way: questions carry no per-role visibility rule, so the controller carries none. It is the reference case for how little an aggregate-root controller must write when the base does the work: policy, one update action, and eviction.
      • -
      • Where it's used: the Conference service host; the feedback-form builder UI is the main client, and the - answers flow through EventQuestionAnswersController and +
      • Where it's used: the Conference service host, reached through the Gateway route + /Questions/{**catch-all} (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:92); the + feedback-form builder UI is the main client, and the answers flow through + EventQuestionAnswersController and SessionQuestionAnswersController.

      RoomsController

      -

      MMCA.ADC.Conference.API · MMCA.ADC.Conference.API.Controllers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/RoomsController.cs:85 · Level 9 · class (sealed)

      +

      MMCA.ADC.Conference.API · MMCA.ADC.Conference.API.Controllers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/RoomsController.cs:92 · Level 9 · class (sealed)

      • What it is: the REST controller for conference Rooms (/Rooms). Rooms are child entities of an Event but are exposed - at a top-level route for convenient querying (RoomsController.cs:76-80). It is a child-collection - controller like EventSpeakersController, but with a fuller add / update / - remove surface, real editable content, and no inherited visibility filter.
      • + at a top-level route for convenient querying (RoomsController.cs:82-87). It is a child-collection + controller like EventSpeakersController, with the same BR-108 parent + visibility rule on its reads, but a fuller add / update / remove surface because a room has real editable + content rather than just an association.
      • Depends on: EntityControllerBase - (RoomsController.cs:92), + (RoomsController.cs:101), IEntityQueryService, three ICommandHandlers for AddRoomCommand / UpdateRoomCommand / - RemoveRoomCommand (:87-89), - IOutputCacheStore (:90), the RoomDTO, and its two request - records AddRoomRequest / UpdateRoomRequest (:24-74), which + RemoveRoomCommand (:94-96), an + IQueryHandler for + GetPublicRoomFilterQuery (:97), + ICurrentUserService with the + CurrentUserServiceExtensions read-audience helper (:98, 104), + IOutputCacheStore (:99), the RoomDTO, the + IdempotentAttribute, and its two request + records AddRoomRequest / UpdateRoomRequest (:30-80), which carry the room's name, sort order, and optional capacity / floor / location / accessibility fields.
      • +
      • Concept introduced, scoping by a parent's real foreign key. [Rubric §11, Security] and [Rubric §9, API & Contract Design]: BR-108 hides an unpublished event's venue layout, and Room carries a real + EventId column, so the controller does not have to intercept anything. BuildPublicRoomSpecificationAsync + (RoomsController.cs:114-124) returns null for a privileged reader (IsPrivileged, :104) and + otherwise the specification resolved by the GetPublicRoomFilterQuery handler; a failed handler result + degrades to null rather than failing the read (:123). Because the caller's own EventId filter goes + through the generic filter pipeline unchanged, the two predicates are composed by the query service + rather than substituted, so scoping to an unpublished event returns an empty page instead of that event's + rooms. The doc comment at :152-157 states exactly that contract. Compare + SpeakersController, where EventId is not a column and the paged action must + intercept the key by hand.
      • Concept introduced, output-cache eviction on mutation. [Rubric §12, Performance & Scalability] assesses caching strategy: every read here is decorated [OutputCache(PolicyName = "RoomsCache")] - (RoomsController.cs:96, 106, 121, 129), so anonymous room reads are served from a 5-minute entry tagged + (RoomsController.cs:132, 160, 202, 229), so anonymous room reads are served from a 5-minute entry tagged conference and conference:rooms - (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:240). The correctness half is - eviction: each mutation ends by calling EvictRoomsCacheAsync (RoomsController.cs:215-216), which does + (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:245). The correctness half is + eviction: each mutation ends by calling EvictRoomsCacheAsync (RoomsController.cs:328-329), which does outputCacheStore.EvictByTagAsync("conference:rooms", ...), invalidating exactly the room reads and nothing else. [Rubric §3, Clean Architecture]: the eviction lives in the controller, not the command - handler, because IOutputCacheStore is an ASP.NET concern the Application layer must not reference. Note - also what is absent: rooms carry no BR-108-style parent visibility filter, so the reads delegate - straight to the base with => base.... (:97-141), and, exactly as in - QuestionsController, there is no ExportAsync override either, because there is - no row scoping for an export to bypass. A room name is not considered a leak the way a draft event's title - is.
      • + handler, because IOutputCacheStore is an ASP.NET concern the Application layer must not reference.
      • Walkthrough
          -
        • The class gate is [HasPermission(ConferencePermissions.RoomsManage)] (RoomsController.cs:84), a +
        • The class gate is [HasPermission(ConferencePermissions.RoomsManage)] (RoomsController.cs:91), a room-specific capability rather than the event one, even though rooms hang off the event aggregate; each read re-opens with [AllowAnonymous].
        • -
        • CreateAsync (:145-169) maps AddRoomRequest to AddRoomCommand positionally (:150-158, note the - optional client-supplied RoomId in slot two), returns HandleFailure on failure (:161-162), evicts - (:164), and returns CreatedAtRoute("GetRoomById", ...).
        • -
        • UpdateAsync (:173-195) dispatches UpdateRoomCommand with the parent EventId from the body and - the child id from the route (:179-187), evicts (:193), and returns NoContent().
        • -
        • DeleteAsync (:199-213) reads the parent eventId [FromQuery] (:201), dispatches - RemoveRoomCommand(eventId, id) (:205), evicts (:211), and returns NoContent().
        • +
        • GetAllAsync (:133-150) and the paged overload (:161-192) thread the public specification + (:142, 177), clamp pageSize to MaxPageSize (:172), and append the X-Pagination header + (:190).
        • +
        • GetAllForLookupAsync (:203-220) is the anti-side-channel path: privileged readers fall through to + the base (:208-209), everyone else forwards specification.Criteria as the lookup where + (:211-215) and the rows are rewrapped into a + CollectionResult<T> of + BaseLookup<TIdentifierType> (:219).
        • +
        • GetByIdAsync (:230-247) threads the same specification (:241). Its doc comment states the rule + precisely: a room of an unpublished event is a 404, "not a redacted record, so a guessed id cannot + confirm that an unannounced event exists or that a venue has been booked for it" (:222-226).
        • +
        • CreateAsync (:258-282) is [Idempotent] (:257), maps AddRoomRequest to AddRoomCommand + positionally (:263-271, note the optional client-supplied RoomId in slot two), returns + HandleFailure on failure (:274-275), evicts (:277), and returns CreatedAtRoute("GetRoomById", ...).
        • +
        • UpdateAsync (:286-308) dispatches UpdateRoomCommand with the parent EventId from the body and + the child id from the route (:292-300), evicts (:306), and returns NoContent().
        • +
        • DeleteAsync (:312-326) reads the parent eventId [FromQuery] (:314), dispatches + RemoveRoomCommand(eventId, id) (:318), evicts (:324), and returns NoContent().
        • All three mutations return HandleFailure before they evict, so a failed command never disturbs the cache.
      • Why it's built this way: rooms are read far more than they are edited (venue maps, schedule grids), so - caching the public reads is worth the eviction bookkeeping on the rare write. The add / update / remove - trio (richer than the two-verb junction controllers) reflects that a room has real editable content, not - just an association.
      • -
      • Where it's used: the Conference service host; consumed by the room-management UI and by any schedule - view that resolves a session's room.
      • + caching the public reads is worth the eviction bookkeeping on the rare write. Scoping the reads through + the Application-layer filter query rather than a controller-side join keeps the persistence knowledge out + of the boundary, the same division EventSpeakersController uses. +
      • Where it's used: the Conference service host, behind the Gateway route /Rooms/{**catch-all} + (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:52); consumed by the room-management UI under + MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Room/ and by any schedule view that + resolves a session's room.
      • +
      • Caveats / not-in-source: this is the one row-scoped controller in the unit that does not override + ExportAsync, so /Rooms/export streams unscoped, protected only by the class-level RoomsManage + capability. That capability is granted to Organizer and Admin (DependencyInjection.cs:43-44) while the + privileged read audience is Organizer and ContentEditor + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Authorization/ConferenceReadAudience.cs:26-30), + so the two sets are not identical. Whether the missing override is deliberate is not determinable from + source: unlike its siblings, the file carries no doc comment on the subject.

      SpeakerCategoryItemsController

      -

      MMCA.ADC.Conference.API · MMCA.ADC.Conference.API.Controllers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:47 · Level 9 · class (sealed)

      +

      MMCA.ADC.Conference.API · MMCA.ADC.Conference.API.Controllers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakerCategoryItemsController.cs:48 · Level 9 · class (sealed)

      • What it is: the REST controller for the link between a @@ -1282,44 +1332,48 @@

        SpeakerCategoryItemsController

        EventSpeakersController: anonymous reads that inherit the parent's visibility, organizer add/remove, no update.
      • Depends on: EntityControllerBase - (SpeakerCategoryItemsController.cs:55), + (SpeakerCategoryItemsController.cs:56), IEntityQueryService, the AddSpeakerCategoryItemCommand / RemoveSpeakerCategoryItemCommand - ICommandHandlers (:49-50), an + ICommandHandlers (:50-51), an IQueryHandler for GetPublicSpeakerCategoryItemFilterQuery - (:51), ICurrentUserService, IOutputCacheStore (:53), the - SpeakerCategoryItemDTO, and the - AddSpeakerCategoryItemRequest record (:28-35).
      • + (:52), ICurrentUserService, IOutputCacheStore (:54), the + SpeakerCategoryItemDTO, the + IdempotentAttribute, and the + AddSpeakerCategoryItemRequest record (:29-36).
      • Concept introduced: none new; this is the junction controller pattern taught at EventSpeakersController. Two differences are worth noting. [Rubric §11, Security], first, the permission vocabulary tracks the owning aggregate: the class is guarded by [HasPermission(ConferencePermissions.SpeakersManage)] - (SpeakerCategoryItemsController.cs:46) rather than the EventsManage its event-side twin uses, so + (SpeakerCategoryItemsController.cs:47) rather than the EventsManage its event-side twin uses, so managing a speaker's tags requires speaker-management rights. Second, the inherited visibility rule is BR-239 (a junction row must not reveal a speaker the caller cannot read) rather than BR-108, resolved by - the GetPublicSpeakerCategoryItemFilterQuery handler through BuildPublicSpecificationAsync (:66-75), - with IsPrivileged (:58) short-circuiting for Organizer / ContentEditor.
      • + the GetPublicSpeakerCategoryItemFilterQuery handler through BuildPublicSpecificationAsync (:67-76), + with IsPrivileged (:59) short-circuiting for Organizer / ContentEditor.
      • Walkthrough: shape-for-shape the same as EventSpeakersController, with the SpeakersCache policy instead of - EventsCache (Program.cs:239). GetAllAsync (SpeakerCategoryItemsController.cs:80-98) and the paged - overload (:103-135) thread the public specification (:90, 120) and append X-Pagination (:133); - GetAllForLookupAsync (:144-161) delegates to the base for privileged readers and otherwise forwards - specification.Criteria as the lookup where (:152-156); GetByIdAsync (:166-184) threads the same - specification (:178). ExportAsync (:193-207) repeats the privileged-reader export gate - (Forbid() at :203, doc comment at :186-191). CreateAsync (:211-229) dispatches - AddSpeakerCategoryItemCommand(request.SpeakerId, null, request.CategoryItemId) (:216), evicts, and - returns CreatedAtRoute("GetSpeakerCategoryItemById", ...); DeleteAsync (:233-249) reads the parent - speakerId [FromQuery] (:235), dispatches RemoveSpeakerCategoryItemCommand(speakerId, id) (:239), - evicts, and returns NoContent(). EvictJunctionCacheAsync (:256-261) clears conference:speakers, + EventsCache (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:244). GetAllAsync + (SpeakerCategoryItemsController.cs:81-99) and the paged overload (:104-136) thread the public + specification (:91, 121) and append X-Pagination (:134); + GetAllForLookupAsync (:145-162) delegates to the base for privileged readers (:150-151) and + otherwise forwards specification.Criteria as the lookup where (:153-157); GetByIdAsync + (:167-185) threads the same specification (:179). ExportAsync (:194-208) repeats the + privileged-reader export gate (Forbid() at :204, doc comment at :187-192). CreateAsync + (:219-237) is [Idempotent] (:218), dispatches + AddSpeakerCategoryItemCommand(request.SpeakerId, null, request.CategoryItemId) (:224), evicts, and + returns CreatedAtRoute("GetSpeakerCategoryItemById", ...); DeleteAsync (:241-257) reads the parent + speakerId [FromQuery] (:243), dispatches RemoveSpeakerCategoryItemCommand(speakerId, id) (:247), + evicts, and returns NoContent(). EvictJunctionCacheAsync (:264-269) clears conference:speakers, conference:categories, and conference.
      • Why it's built this way: it shares the exact shape of the other junction controllers because the underlying rules (mutate the child only through its parent aggregate; never let a junction row out-live its parent's visibility) are identical. Only the aggregate, the DTO, the permission, and the pair of cache tags change, which is [Rubric §16, Maintainability] in practice: one shape learned once, repeated without variation.
      • -
      • Where it's used: the Conference service host; consumed by the speaker-profile editing UI.
      • +
      • Where it's used: the Conference service host; consumed by the speaker-profile editing UI under + MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/.

      SpeakersController

      @@ -1353,14 +1407,16 @@

      SpeakersController

      SpeakerUpdateRequest, LinkUserRequest, SessionFeedbackDTO, the - HasPermissionAttribute, + HasPermissionAttribute, the + SpecificationExtensions And composer + that yields an AndSpecification<TEntity, TIdentifierType>, and Error.
    • Concept introduced, per-action authorization plus row-level ownership checks. [Rubric §11, Security]: unlike the other aggregate-root controllers (which gate the whole class with one [HasPermission(...)]), SpeakersController carries a bare class-level [Authorize] - (SpeakersController.cs:43) and then varies authorization per action. Reads are [AllowAnonymous]; - export / create / delete / link / unlink each re-assert + (SpeakersController.cs:43) and then varies authorization per action. The catalog reads are + [AllowAnonymous]; export / create / delete / link / unlink each re-assert [HasPermission(ConferencePermissions.SpeakersManage)] (:290, 309, 353, 365, 384); and UpdateAsync performs a resource-ownership check in code, comparing the caller's speaker_id JWT claim with the route id and returning Forbid() when the caller is neither the speaker nor an organizer (:335-338, @@ -1371,9 +1427,11 @@

      SpeakersController

      demonstrates a virtual filter key. EventId is not a Speaker column, so the action removes it from the generic filter dictionary before the generic pipeline can reject it (:154-160), translates it into a specification via GetSpeakersByEventFilterQuery, and ANDs it with the public-speaker specification - rather than substituting it (:162-176, using AndSpecification at :174). Substituting would leak - hidden speakers to a non-privileged caller; an unparseable value simply drops the scope instead of failing - the request.
    • + rather than substituting it (:162-176, publicSpecification.And(...) at :174, the extension member + declared at + MMCA.Common/Source/Core/MMCA.Common.Domain/Specifications/SpecificationExtensions.cs:48). Substituting + would leak hidden speakers to a non-privileged caller; an unparseable value simply drops the scope instead + of failing the request.
    • Walkthrough
      • IsPrivileged (SpeakersController.cs:63) and BuildPublicSpeakerSpecificationAsync (:82-93): the BR-239 projection, parameterized by an optional eventId because a speaker accepted for one event is @@ -1388,7 +1446,7 @@

        SpeakersController

        mapper that redacts it. The check runs before the query service, so a rejected label is never queried.
      • GetByIdAsync (:246-279) carries the self-read carve-out: when the caller's speaker_id claim matches the route id, the specification is dropped so the speaker can always load their own profile - (:257-262), and because that response can contain data the public cannot see while the output-cache + (:257-267), and because that response can contain data the public cannot see while the output-cache key does not vary by caller, the action turns storage off for this response via HttpContext.Features.Get<IOutputCacheFeature>()?.Context.AllowCacheStorage = false (:266). The policy only ever turns storage off, never back on, so the opt-out sticks.
      • @@ -1405,14 +1463,19 @@

        SpeakersController

        UpdateAsync (:329-349) runs the BR-214 check, dispatches, and evicts. LinkUserAsync / UnlinkUserAsync (:366-398) dispatch the link/unlink commands, which drive the cross-module User-to-Speaker association over integration events, and evict. -
      • The three BR-210 projections are [AllowAnonymous]: GetSessionFeedbackAsync (:405-417) under the - broad ConferencePublicCache policy (Program.cs:231) because it spans speakers and sessions, and - GetSessionBookmarkCountAsync (:424-436) plus the batched - GetSessionBookmarkCountsAsync (:444-456) under BookmarkCountsCache, a 60-second policy - (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:254). [Rubric §7, Microservices Readiness]: the short TTL exists because bookmark counts are owned by the Engagement service, in - another process, whose writes have no handle on this service's cache store, so no tag eviction can ever - reach these entries and a short TTL is the only lever available from this side.
      • -
      • EvictSpeakersCacheAsync (:458-462) clears conference:speakers and the broad conference tag.
      • +
      • The three BR-210 projections split by sensitivity. GetSessionFeedbackAsync (:408-425) is + [Authorize] (:407) and repeats the self-or-organizer gate of the update path (:413-416), and it + carries no [OutputCache] at all: its doc comment records that every response is + authorization-dependent, so a shared public entry could serve one speaker's free-text feedback to + another caller (:400-405). The two count endpoints stay [AllowAnonymous]: + GetSessionBookmarkCountAsync (:432-444) and the batched GetSessionBookmarkCountsAsync + (:452-464) run under BookmarkCountsCache, a 60-second policy tagged conference and + conference:sessions (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:264). + [Rubric §7, Microservices Readiness]: bookmark counts are owned by the Engagement service, in another + process, whose writes have no handle on this host's cache store, so Engagement's bookmark handler + publishes an eviction request over the broker that this host turns into a tag drop, and the short TTL + stays as the backstop for a message that never lands (Program.cs:253-264).
      • +
      • EvictSpeakersCacheAsync (:466-470) clears conference:speakers and the broad conference tag.
    • Why it's built this way: speaker profiles are edited both by organizers and by the speakers @@ -1420,24 +1483,109 @@

      SpeakersController

      that check inline mirrors the per-mutation ownership pattern used across the codebase. The virtual EventId filter gives clients an event-scoped speaker list without adding a denormalized column to the aggregate, and the batched counts endpoint exists to replace the Speaker Dashboard's per-session fan-out - (:438-440), which is [Rubric §12, Performance & Scalability] applied at the contract level.
    • -
    • Where it's used: the Conference service host; consumed by the speaker directory, the speaker - self-service profile page, organizer linking tools, and the speaker dashboard's feedback and bookmark - tiles.
    • + (:446-448), which is [Rubric §12, Performance & Scalability] applied at the contract level. +
    • Where it's used: the Conference service host behind the Gateway route /Speakers/{**catch-all} + (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:48); consumed by the public speaker directory + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSpeakerList.razor), the + speaker self-service profile page, organizer linking tools, and the speaker dashboard's feedback and + bookmark tiles.
    • +
    +
    +

    ActivitiesController

    +
    +

    MMCA.ADC.Conference.API · MMCA.ADC.Conference.API.Controllers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/ActivitiesController.cs:37 · Level 10 · class (sealed)

    +
    +
      +
    • What it is: the REST controller for the Activity aggregate + root (/Activities), the conference's social and networking programme (parties, meetups, sponsor + receptions). Anonymous reads scoped to published events, and create / update / delete / export behind the + activities-manage capability (ActivitiesController.cs:27-31).
    • +
    • Depends on: AggregateRootEntityControllerBase + (ActivitiesController.cs:46-47), + IEntityQueryService + (:38), three ICommandHandlers + (ActivityCreateRequest, + UpdateActivityCommand, and a + DeleteEntityCommand<TEntity, TIdentifierType> + delete handler, :39-41), an + IQueryHandler for + GetPublicActivityFilterQuery (:42), + ICurrentUserService plus the + CurrentUserServiceExtensions read-audience helper (:43, 50), + IOutputCacheStore (:44), the ActivityDTO, + ActivityUpdateRequest as the PUT body + (:227), the HasPermissionAttribute and the + ConferencePermissions catalog, and the + QueryFilterModelBinder.
    • +
    • Concept introduced: none new. This controller is the exact structural twin of + SponsorsController: the same bare [Authorize] class gate with a per-mutation + capability, the same real-EventId-column scoping (no filter interception), the same + attribute-plus-imperative export gate. What differs is the vocabulary. [Rubric §11, Security]: the class + carries [Authorize] (ActivitiesController.cs:36) and each mutation re-asserts + [HasPermission(ConferencePermissions.ActivitiesManage)] (:193, 212, 224, 243), the capability declared + at + MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Authorization/ConferencePermissions.cs:36 + and included in the ContentManagement curation subset (ConferencePermissions.cs:57-64), so a content + editor can run the social programme without holding event, room, or question rights. + [Rubric §12, Performance & Scalability]: reads run under the ActivitiesCache policy (5-minute TTL, + tags conference and conference:activities, + MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:249) and every mutation evicts both + tags.
    • +
    • Walkthrough
        +
      • IsPrivileged (ActivitiesController.cs:50) is the shared + currentUserService.IsPrivilegedConferenceReader() read-audience check; + BuildPublicActivitySpecificationAsync (:60-70) returns null for a privileged reader and otherwise + the Specification<TEntity, TIdentifierType> + the GetPublicActivityFilterQuery handler resolves (:66-69); a failed handler result degrades to + null rather than failing the read.
      • +
      • GetAllAsync (:75-92) and the paged overload (:103-134) are [AllowAnonymous] + + [OutputCache(PolicyName = "ActivitiesCache")] full overrides that thread the specification + (:84, 119), clamp pageSize to MaxPageSize (:114), and append the X-Pagination header (:132). + The paged action's doc comment states the composition contract explicitly: EventId is a real column, + so the caller's event filter travels through the generic pipeline and the published-event rule is ANDed + on top of it rather than substituted (:94-99).
      • +
      • GetAllForLookupAsync (:139-156) is the anti-side-channel path: base action for a privileged reader + (:144-145), otherwise specification.Criteria forwarded as the lookup where (:147-151) and the + rows rewrapped into a CollectionResult<T> of + BaseLookup<TIdentifierType> (:155).
      • +
      • GetByIdAsync (:165-182) threads the same specification (:176); its doc comment states that an + activity of an unpublished event is a 404, "not a redacted record, so a guessed id cannot confirm that a + party has been scheduled" (:158-161).
      • +
      • ExportAsync (:194-208) pairs the declarative + [HasPermission(ConferencePermissions.ActivitiesManage)] (:193) with the imperative + if (!IsPrivileged) return Forbid(); (:202-205), then delegates to the base (:207). The doc comment + names the leak an unscoped CSV would be: a social programme that has not been announced (:184-191).
      • +
      • CreateAsync (:213-220) and DeleteAsync (:244-251) are thin overrides that call the base and then + evict; UpdateAsync (:225-239) is the hand-rolled action the base does not supply, wrapping the route + id and body in new UpdateActivityCommand(id, request) (:231), folding a failure through + HandleFailure (:234-235), evicting (:237), and returning Ok(result.Value).
      • +
      • EvictActivitiesCacheAsync (:253-257) clears conference:activities and the broad conference tag, + the latter because the activity strip renders alongside other conference reads.
      • +
      +
    • +
    • Why it's built this way: the social programme has the same publish-gated lifecycle as the rest of the + catalog, so it reuses the specification pattern rather than inventing an activity-specific visibility + flag, and because Activity owns a real EventId none of that scoping needs a virtual key. Repeating the + sponsor controller's shape verbatim is [Rubric §16, Maintainability] in practice: two aggregates with + identical rules get identical code, so the reader who has learned one has learned both.
    • +
    • Where it's used: the Conference service host behind the Gateway route /Activities/{**catch-all} + (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:100). Clients are the public activity page + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicActivityList.razor) and + the organizer list, create, and detail pages under + MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Activity/.

    EventsController

    -

    MMCA.ADC.Conference.API · MMCA.ADC.Conference.API.Controllers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventsController.cs:44 · Level 10 · class (sealed)

    +

    MMCA.ADC.Conference.API · MMCA.ADC.Conference.API.Controllers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/EventsController.cs:45 · Level 10 · class (sealed)

    • What it is: the REST controller for the Event aggregate root (/Events), and the richest controller in the group. On top of the standard aggregate-root CRUD it adds - visibility scoping, a scoped CSV export, publish / unpublish with optimistic-concurrency checks, a - Sessionize refresh with bespoke error mapping, iCalendar export, and the "happening now / up next" - snapshot.
    • + visibility scoping, a scoped CSV export, publish / unpublish with conditional-write support, a Sessionize + refresh with bespoke error mapping, iCalendar export, and the "happening now / up next" snapshot.
    • Depends on: AggregateRootEntityControllerBase - (EventsController.cs:57-58), + (EventsController.cs:58-59), IEntityQueryService, six ICommandHandlers (EventCreateRequest, @@ -1445,10 +1593,10 @@

      EventsController

      PublishEventCommand, UnpublishEventCommand, DeleteEntityCommand, - RefreshFromSessionizeCommand, :46-51), + RefreshFromSessionizeCommand, :47-52), two IQueryHandlers (ExportEventCalendarQuery, - GetNowNextQuery, :52-53), + GetNowNextQuery, :53-54), ICurrentUserService, IOutputCacheStore, the PublishedEventSpecification, the EventDTO, @@ -1456,47 +1604,57 @@

      EventsController

      EventUpdateRequest, EventTransitionRequest, NowNextDTO, - RefreshFromSessionizeResultDTO, and the - IdempotentAttribute.
    • + RefreshFromSessionizeResultDTO, the + IdempotentAttribute, and the + SupportsIfMatchAttribute.
    • Concept introduced, business-rule visibility scoping via a specification. [Rubric §11, Security] and [Rubric §3, Clean Architecture]: BR-108 says non-privileged readers see only published events. Rather - than branch inside each query, the controller builds a specification at the edge: - GetPublishedEventSpecification() (EventsController.cs:66-67) returns null for a privileged reader + than branch inside each query, the controller builds a specification at the boundary: + GetPublishedEventSpecification() (EventsController.cs:67-68) returns null for a privileged reader (currentUserService.IsPrivilegedConferenceReader(), so a ContentEditor who reads every session can also read the events those sessions belong to) and a PublishedEventSpecification for everyone else. Each read - passes it into QueryService.GetAllAsync / GetByIdAsync (:82, 112, 171), so the authorization + passes it into QueryService.GetAllAsync / GetByIdAsync (:83, 113, 172), so the authorization predicate is a data specification the query service composes into SQL, not imperative post-filtering. The - lookup endpoint (:137-154) applies the same predicate as the where argument, closing the side channel - that would otherwise list draft events by name, and ExportAsync (:186-200) closes the same channel on - the CSV path with the privileged-reader Forbid() gate (:194-197). [Rubric §29, Resilience & Business Continuity]: RefreshAsync (:338-370) maps upstream trouble to retryable HTTP, an - Event.Sessionize.Throttled error becoming 429 with a Retry-After: 300 header (:349-353, BR-63) and - Event.Sessionize.Unavailable becoming 502 (:356-357), so an upstream throttle reaches the client as - a signal rather than a 500.
    • + lookup endpoint (:138-155) applies the same predicate as the where argument, closing the side channel + that would otherwise list draft events by name, and ExportAsync (:187-201) closes the same channel on + the CSV path with the privileged-reader Forbid() gate (:195-198). +
    • Concept introduced, conditional writes on a state transition. [Rubric §9, API & Contract Design] + assesses whether a contract expresses concurrency honestly. PublishAsync and UnpublishAsync take an + optional body ([FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] EventTransitionRequest?, + :312, 343) carrying the client's last-seen row version, so omitting it skips the stale-view check + (ADR-035). Both also carry + [SupportsIfMatch] (:307, 338), which lets a caller state the same precondition as an HTTP If-Match + header; the difference is the failure code, 412 rather than 409, and both are declared as + [ProducesResponseType] on each action (:308-309, 339-340). The doc comment records the one constraint + that is easy to trip over: the header populates the bound body, so a caller using If-Match must still + send a body, and {} is enough (:291-304). Both transitions are also [Idempotent] (:306, 337), + because publishing is a state assertion and replaying the stored response for a retried key is exactly + what the caller meant. [Rubric §29, Resilience & Business Continuity]: RefreshAsync (:367-399) maps + upstream trouble to retryable HTTP, an Event.Sessionize.Throttled error becoming 429 with a + Retry-After: 300 header (:378-382, BR-63) and Event.Sessionize.Unavailable becoming 502 + (:385-386), so an upstream throttle reaches the client as a signal rather than a 500.
    • Walkthrough
        -
      • The reads (EventsController.cs:69-177) attach [AllowAnonymous] + +
      • The reads (EventsController.cs:70-178) attach [AllowAnonymous] + [OutputCache(PolicyName = "EventsCache")] and the published-event specification; the paged overload - serializes PaginationMetadata into the X-Pagination header (:125).
      • -
      • ExportCalendarAsync (:209-217) streams an .ics document via File(...) with the - text/calendar content type and an event-{id}.ics file name.
      • -
      • GetNowNextAsync (:226-232) and GetCurrentNowNextAsync (:241-246) serve the now / next snapshot - for a given event or, with GetNowNextQuery(EventId: null), for the current one; both use the + serializes PaginationMetadata into the + X-Pagination header (:126).
      • +
      • ExportCalendarAsync (:210-218) streams an .ics document via File(...) with the + text/calendar content type and an event-{id}.ics file name (:217).
      • +
      • GetNowNextAsync (:227-233) and GetCurrentNowNextAsync (:242-247) serve the now / next snapshot + for a given event or, with GetNowNextQuery(EventId: null) (:245), for the current one; both use the short-TTL NowNextCache policy (60 seconds, - MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:246) because the payload changes with - the clock.
      • -
      • CreateAsync (:255-262) is an override marked [Idempotent] (:254), so a retried POST carrying the + MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:252) because the payload changes with + the clock. That policy is registered without the privileged-reader bypass, because the snapshot is + identical for every role (Program.cs:250-252).
      • +
      • CreateAsync (:256-263) is an override marked [Idempotent] (:255), so a retried POST carrying the same Idempotency-Key is deduplicated; it calls base.CreateAsync then evicts. The attribute is - single-use, so declaring it here coincides with the inherited one instead of duplicating it (:248-252).
      • -
      • UpdateAsync (:266-288) appends a non-fatal X-Warning header when a timezone change leaves existing - sessions semantically stale (BR-131, :279-284) and returns Ok(result.Value.Event).
      • -
      • PublishAsync (:296-310) and UnpublishAsync (:318-332) take an optional body - ([FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] EventTransitionRequest?) carrying the - client's last-seen row version, so omitting it skips the stale-view check - (ADR-035); both declare - [ProducesResponseType(StatusCodes.Status409Conflict)] (:295, 317).
      • + single-use, so declaring it here coincides with the inherited one instead of duplicating it (:249-253). +
      • UpdateAsync (:267-289) appends a non-fatal X-Warning header when a timezone change leaves existing + sessions semantically stale (BR-131, :280-285) and returns Ok(result.Value.Event).
      • RefreshAsync triggers a Sessionize import and, because that import touches six entity types, evicts - six tags: events, sessions, speakers, categories, rooms, and questions (:363-368).
      • -
      • DeleteAsync (:376-385) additionally evicts conference:sessions and conference:rooms because - soft-deleting an event cascades to its children. EvictEventsCacheAsync (:387-388) is the single-tag + six tags: events, sessions, speakers, categories, rooms, and questions (:392-397).
      • +
      • DeleteAsync (:405-414) additionally evicts conference:sessions and conference:rooms because + soft-deleting an event cascades to its children. EvictEventsCacheAsync (:416-417) is the single-tag helper the other mutations share.
    • @@ -1505,13 +1663,16 @@

      EventsController

      a flat list of extra actions. Mapping Sessionize failures to distinct status codes here keeps that operational nuance at the boundary while the handler stays a pure Result producer, and the fan-out of eviction tags is written where the knowledge of "what this operation touched" actually lives. -
    • Where it's used: the Conference service host; the home-screen widget calls now-next, the schedule UI - calls the reads and the .ics export, and organizer tooling drives publish, unpublish, and refresh.
    • +
    • Where it's used: the Conference service host behind the Gateway route /Events/{**catch-all} + (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:40); the home-screen widget calls now-next, + the public schedule UI + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicEventList.razor) calls the + reads and the .ics export, and organizer tooling drives publish, unpublish, and refresh.

    SessionCategoryItemsController

    -

    MMCA.ADC.Conference.API · MMCA.ADC.Conference.API.Controllers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:47 · Level 10 · class (sealed)

    +

    MMCA.ADC.Conference.API · MMCA.ADC.Conference.API.Controllers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionCategoryItemsController.cs:48 · Level 10 · class (sealed)

    • What it is: the REST controller for the link between a @@ -1521,46 +1682,49 @@

      SessionCategoryItemsController

      EventSpeakersController: anonymous reads that inherit the parent's visibility, organizer add/remove, no update.
    • Depends on: EntityControllerBase - (SessionCategoryItemsController.cs:55), + (SessionCategoryItemsController.cs:56), IEntityQueryService, the AddSessionCategoryItemCommand / RemoveSessionCategoryItemCommand - ICommandHandlers (:49-50), an + ICommandHandlers (:50-51), an IQueryHandler for GetPublicSessionCategoryItemFilterQuery - (:51), ICurrentUserService, IOutputCacheStore (:53), the - SessionCategoryItemDTO, and the - AddSessionCategoryItemRequest record (:28-35).
    • + (:52), ICurrentUserService, IOutputCacheStore (:54), the + SessionCategoryItemDTO, the + IdempotentAttribute, and the + AddSessionCategoryItemRequest record (:29-36).
    • Concept introduced: none new; see the junction controller pattern at EventSpeakersController. The class is guarded by - [HasPermission(ConferencePermissions.SessionsManage)] (SessionCategoryItemsController.cs:46) because + [HasPermission(ConferencePermissions.SessionsManage)] (SessionCategoryItemsController.cs:47) because the association belongs to the session aggregate, and its inherited visibility rule is BR-49 (a junction row must not reveal a session the caller cannot read), resolved by BuildPublicSpecificationAsync - (:66-75) with IsPrivileged (:58) short-circuiting for Organizer / ContentEditor. + (:67-76) with IsPrivileged (:59) short-circuiting for Organizer / ContentEditor. [Rubric §11, Security]: as with every junction controller here, the write permission follows the owning aggregate while the read filter follows the parent's visibility, and the CSV export repeats the privileged-reader gate so the scoping cannot be bypassed by asking for the file instead of the page.
    • Walkthrough: four [AllowAnonymous] + [OutputCache(PolicyName = "SessionsCache")] reads - (SessionCategoryItemsController.cs:77-184) thread the public specification (:90, 120, 178), append - X-Pagination (:133), and forward specification.Criteria as the lookup where for non-privileged - callers (:148-156). ExportAsync (:193-207) returns Forbid() for a non-privileged caller (:203) - and otherwise delegates to the base (:206). CreateAsync (:211-229) dispatches - AddSessionCategoryItemCommand(request.SessionId, null, request.CategoryItemId) (:216), evicts, and - returns CreatedAtRoute("GetSessionCategoryItemById", ...); DeleteAsync (:233-249) reads the parent - sessionId [FromQuery] (:235), dispatches RemoveSessionCategoryItemCommand(sessionId, id) (:239), - evicts, and returns NoContent(). EvictJunctionCacheAsync (:256-261) clears conference:sessions, - conference:categories, and conference.
    • + (SessionCategoryItemsController.cs:78-185) thread the public specification (:91, 121, 179), append + X-Pagination (:134), and forward specification.Criteria as the lookup where for non-privileged + callers (:149-157). ExportAsync (:194-208) returns Forbid() for a non-privileged caller (:204) + and otherwise delegates to the base (:207). CreateAsync (:219-237) is [Idempotent] (:218), + dispatches AddSessionCategoryItemCommand(request.SessionId, null, request.CategoryItemId) (:224), + evicts, and returns CreatedAtRoute("GetSessionCategoryItemById", ...); DeleteAsync (:241-257) reads + the parent sessionId [FromQuery] (:243), dispatches + RemoveSessionCategoryItemCommand(sessionId, id) (:247), evicts, and returns NoContent(). + EvictJunctionCacheAsync (:264-269) clears conference:sessions, conference:categories, and + conference.
    • Why it's built this way: same rationale as the other junction controllers, the child mutates only through its parent aggregate, so it gets free reads and explicit, command-routed writes; and because a tag on a session is visible from both the session page and the category page, both parents' cache tags are evicted.
    • Where it's used: the Conference service host; consumed by the session-editing UI's tag picker and by - the public schedule filters.
    • + the public schedule filters + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSessionListFilterBar.razor).

    SessionQuestionAnswersController

    -

    MMCA.ADC.Conference.API · MMCA.ADC.Conference.API.Controllers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:56 · Level 10 · class (sealed)

    +

    MMCA.ADC.Conference.API · MMCA.ADC.Conference.API.Controllers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionQuestionAnswersController.cs:57 · Level 10 · class (sealed)

    • What it is: the REST controller for a session's answered feedback questions @@ -1568,49 +1732,51 @@

      SessionQuestionAnswersController

      EventQuestionAnswersController: reads require authentication and are owner-scoped, so an attendee sees only their own answers and an organizer sees all (BR-9).
    • Depends on: EntityControllerBase - (SessionQuestionAnswersController.cs:63), + (SessionQuestionAnswersController.cs:64), IEntityQueryService, the add / update / remove ICommandHandlers for AddSessionQuestionAnswerCommand, UpdateSessionQuestionAnswerCommand and RemoveSessionQuestionAnswerCommand - (:58-60), ICurrentUserService + + (:59-61), ICurrentUserService + RoleNames for the scoping decision, OwnedByUserSpecification<TEntity, TIdentifierType>, AuthorizationPolicies, the + IdempotentAttribute, the SessionQuestionAnswerDTO, and its two request records AddSessionQuestionAnswerRequest / - UpdateSessionQuestionAnswerRequest (:26-46).
    • + UpdateSessionQuestionAnswerRequest (:26-47).
    • Concept introduced: none new; owner-scoped reads and the organizer-only export gate are taught at EventQuestionAnswersController. [Rubric §11, Security]: the class is gated with [Authorize(Policy = AuthorizationPolicies.RequireAuthenticated)] - (SessionQuestionAnswersController.cs:55) so no endpoint here is anonymous, and - GetUserScopingSpecification() (:66-67) returns null for + (SessionQuestionAnswersController.cs:56) so no endpoint here is anonymous, and + GetUserScopingSpecification() (:67-68) returns null for currentUserService.IsInRole(RoleNames.Organizer) and an OwnedByUserSpecification<SessionQuestionAnswer, SessionQuestionAnswerIdentifierType>(currentUserService.UserId!.Value) otherwise. This is a distinct posture from the other session-scoped child controllers, whose reads are fully anonymous and filtered by the parent's visibility rather than by ownership. As with its event-side twin, no read carries an [OutputCache] attribute, because a per-caller payload must never enter a shared cache entry.
    • -
    • Walkthrough: the reads (SessionQuestionAnswersController.cs:69-150) forward to - QueryService.GetAllAsync / GetByIdAsync with the scoping specification (:80, 108, 144), clamp the - page size (:102), and append the X-Pagination header (:121); GetAllForLookupAsync (:125-129) - delegates straight to the base. ExportAsync (:159-173) returns Forbid() unless the caller is an - organizer (:167-170), the BR-9 form of the row-scoping bypass gate, with the reasoning in its doc - comment (:152-157). CreateAsync (:176-191) dispatches +
    • Walkthrough: the reads (SessionQuestionAnswersController.cs:70-151) forward to + QueryService.GetAllAsync / GetByIdAsync with the scoping specification (:81, 109, 145), clamp the + page size (:103), and append the X-Pagination header (:122); GetAllForLookupAsync (:126-130) + delegates straight to the base. ExportAsync (:159-174) returns Forbid() unless the caller is an + organizer (:168-171), the BR-9 form of the row-scoping bypass gate, with the reasoning in its doc + comment (:153-158). CreateAsync (:185-199) is [Idempotent] (:184), dispatches AddSessionQuestionAnswerCommand(request.SessionId, null, request.QuestionId, request.AnswerValue) - (:182) and returns CreatedAtRoute. UpdateAsync (:194-207) dispatches - UpdateSessionQuestionAnswerCommand(request.SessionId, id, request.AnswerValue) (:201) and returns - NoContent(). DeleteAsync (:210-223) reads the parent sessionId [FromQuery] (:213) and - dispatches RemoveSessionQuestionAnswerCommand(sessionId, id) (:217).
    • + (:190) and returns CreatedAtRoute. UpdateAsync (:203-215) dispatches + UpdateSessionQuestionAnswerCommand(request.SessionId, id, request.AnswerValue) (:209) and returns + NoContent(). DeleteAsync (:219-231) reads the parent sessionId [FromQuery] (:221) and + dispatches RemoveSessionQuestionAnswerCommand(sessionId, id) (:225).
    • Why it's built this way: answers are personal feedback, so the read surface cannot be public; scoping by specification keeps the authorization rule in one place and lets the query service compose it into the database query rather than filtering in memory. Mirroring the event-side controller line for line is - deliberate: two rules (BR-8 and BR-9) with the same shape get the same implementation, export gate - included.
    • -
    • Where it's used: the Conference service host; consumed by the attendee feedback UI and by organizer - reporting screens.
    • + deliberate: two rules (BR-8 and BR-9) with the same shape get the same implementation, export gate and + replay contract included. +
    • Where it's used: the Conference service host; consumed by the attendee feedback UI under + MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Feedback/ and by organizer reporting + screens.

    SessionsController

    @@ -1637,7 +1803,9 @@

    SessionsController

    ICurrentUserService, IOutputCacheStore, the SessionDTO, UpdateSessionResult, - SessionUpdateRequest, + SessionUpdateRequest, the + SpecificationExtensions And composer + yielding an AndSpecification, the EventDTO, and the IdempotentAttribute. @@ -1653,27 +1821,29 @@

    SessionsController

    Specification<Session, SessionIdentifierType> the query service can apply; privileged readers get null. [Rubric §12, Performance & Scalability]: reads are [OutputCache(PolicyName = "SessionsCache")] - (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:238) and the default sort is the + (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:243) and the default sort is the "StartsAt,RoomId" string (:123), which sorts the schedule chronologically then by room. The comment above it (:121-122) records the mechanism: the "ascending" suffix QueryFieldService.ApplySorting appends binds only to the last column in Dynamic LINQ, and the leading column defaults to ascending, so one string sorts both columns ascending.
  • Walkthrough
    • BuildPagedSessionSpecificationAsync (SessionsController.cs:96-119) is the paged read's specification - builder: it takes the public filter, then intercepts and removes the virtual SpeakerId filter key - (Session has no such column, :102-107), resolves it through GetSessionsBySpeakerFilterQuery, and - ANDs the two with AndSpecification (:116-118). As in + builder: it takes the public filter (:100), then intercepts and removes the virtual SpeakerId filter + key (Session has no such column, :102-107), resolves it through GetSessionsBySpeakerFilterQuery + (:109-111), and ANDs the two with publicSpecification.And(...) (:116-118). As in SpeakersController, substitution would leak non-accepted sessions, and an - unparseable value simply drops the scope.
    • -
    • GetAllAsync (:128-149) applies the public specification and the default sort; the paged overload - (:154-192) defaults the sort when none was supplied (:167-171), calls the builder above (:177), - and writes X-Pagination (:190). GetAllForLookupAsync (:203-220) delegates to the base for - privileged readers and otherwise forwards specification.Criteria as the lookup where. - GetByIdAsync (:225-243) threads the same specification, so a hidden session is a 404.
    • + unparseable value or a failed handler result simply drops the scope (:102-107, 113-114). +
    • GetAllAsync (:128-149) applies the public specification (:138) and the default sort (:140-141); + the paged overload (:154-192) defaults the sort when none was supplied (:167-171), calls the builder + above (:177), and writes X-Pagination (:190). GetAllForLookupAsync (:203-220) delegates to the + base for privileged readers (:208-209) and otherwise forwards specification.Criteria as the lookup + where (:211-215). GetByIdAsync (:225-243) threads the same specification (:237), so a hidden + session is a 404.
    • ExportAsync (:252-266) is the same bypass gate the other row-scoped controllers use: Forbid() for - a non-privileged caller (:260-263), otherwise the base. Its doc comment spells out what an unscoped - CSV would hand over: the whole catalog, "declined and draft-event sessions included" (:245-250).
    • -
    • ExportCalendarAsync (:275-283) streams a single session .ics via File(...).
    • + a non-privileged caller (:260-263), otherwise the base (:265). Its doc comment spells out what an + unscoped CSV would hand over: the whole catalog, "declined and draft-event sessions included" + (:245-250). +
    • ExportCalendarAsync (:275-283) streams a single session .ics via File(...) (:282).
    • CreateAsync (:292-320) is an override marked [Idempotent] (:291) that calls CreateHandler.HandleAsync directly (:296) rather than base.CreateAsync, because it needs the Result in order to run the BR-86 check: when the request set start or end times, it re-reads the @@ -1681,12 +1851,13 @@

      SessionsController

      range (:303-316). Note the parent re-read pattern-matches the widened query result with eventResult.Value is EventDTO evt (:310) rather than a dynamic member access, because IEntityQueryService widens its return to object for field projection, so the controller narrows it - back with a type pattern.
    • + back with a type pattern (the reason is written into the comment at :305-306).
    • UpdateAsync (:324-344) surfaces the same BR-86 warning from result.Value!.HasDateRangeWarning (:337) and returns Ok(result.Value.Session). DeleteAsync (:348-355) calls the base and evicts.
    • Every mutation ends at EvictSessionsCacheAsync (:357-361), which clears both the conference:sessions tag and the broad conference tag, the latter because cross-entity projections - (the speaker feedback and bookmark endpoints) are cached under the broad tag alone.
    • + (the speaker bookmark-count endpoints) are cached under conference:sessions and conference rather + than under a speakers tag.
  • Why it's built this way: pushing the cross-source published-event check into a query handler keeps the @@ -1694,14 +1865,16 @@

    SessionsController

    the API accept a slightly-off schedule while telling the client, rather than rejecting the write outright. Calling the create handler directly instead of the base is the deliberate cost of needing the typed result at the boundary.
  • -
  • Where it's used: the Conference service host; the schedule UI, the "add to calendar" affordance, the - speaker dashboard's SpeakerId-filtered list, and the k6 load test's read endpoints (/Sessions/paged) - all hit it.
  • +
  • Where it's used: the Conference service host behind the Gateway route /Sessions/{**catch-all} + (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:44); the public schedule UI + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSessionList.razor), the + "add to calendar" affordance, the speaker dashboard's SpeakerId-filtered list, and the k6 load test's + read endpoints (/Sessions/paged) all hit it.

  • SessionSpeakersController

    -

    MMCA.ADC.Conference.API · MMCA.ADC.Conference.API.Controllers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:47 · Level 10 · class (sealed)

    +

    MMCA.ADC.Conference.API · MMCA.ADC.Conference.API.Controllers · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SessionSpeakersController.cs:48 · Level 10 · class (sealed)

    • What it is: the REST controller for the link between a @@ -1709,34 +1882,37 @@

      SessionSpeakersController

      Speakers (/SessionSpeakers). A junction controller like EventSpeakersController, with one distinguishing detail in its eviction set.
    • Depends on: EntityControllerBase - (SessionSpeakersController.cs:55), + (SessionSpeakersController.cs:56), IEntityQueryService, the AddSessionSpeakerCommand / RemoveSessionSpeakerCommand - ICommandHandlers (:49-50), an + ICommandHandlers (:50-51), an IQueryHandler for GetPublicSessionSpeakerFilterQuery - (:51), ICurrentUserService, IOutputCacheStore (:53), the - SessionSpeakerDTO, and the - AddSessionSpeakerRequest record (:28-35).
    • + (:52), ICurrentUserService, IOutputCacheStore (:54), the + SessionSpeakerDTO, the + IdempotentAttribute, and the + AddSessionSpeakerRequest record (:29-36).
    • Concept introduced: none new; the junction controller pattern is taught at EventSpeakersController, and the BR-49 parent-visibility filter - (BuildPublicSpecificationAsync, SessionSpeakersController.cs:66-75) is the same one + (BuildPublicSpecificationAsync, SessionSpeakersController.cs:67-76) is the same one SessionCategoryItemsController uses, export gate included - (:192-207). The difference is the eviction set: [Rubric §12, Performance & Scalability], - EvictSessionsCacheAsync (:253-257) clears conference:sessions and the broad conference tag, and + (:194-208). The difference is the eviction set: [Rubric §12, Performance & Scalability], + EvictSessionsCacheAsync (:261-265) clears conference:sessions and the broad conference tag, and deliberately does not clear conference:speakers the way the other two-parent junction controllers - do. The comment at :224-225 gives the reason: what a speaker assignment changes is the cached session + do. The comment at :232-233 gives the reason: what a speaker assignment changes is the cached session detail and list reads (which the speaker dashboard relies on), so the sessions tag is the one that must go.
    • Walkthrough: four [AllowAnonymous] + [OutputCache(PolicyName = "SessionsCache")] reads - (SessionSpeakersController.cs:77-184) thread the public specification (:90, 120, 178), append - X-Pagination (:133), and forward specification.Criteria as the lookup where for non-privileged - callers (:148-156). ExportAsync (:193-207) returns Forbid() for a non-privileged caller (:203). - CreateAsync (:211-231) dispatches AddSessionSpeakerCommand(request.SessionId, null, request.SpeakerId) (:216), evicts on success only (:219-226), and returns - CreatedAtRoute("GetSessionSpeakerById", ...); DeleteAsync (:235-251) reads the parent sessionId - [FromQuery] (:237), dispatches RemoveSessionSpeakerCommand(sessionId, id) (:241), evicts, and - returns NoContent(). The class gate is [HasPermission(ConferencePermissions.SessionsManage)] (:46).
    • + (SessionSpeakersController.cs:78-185) thread the public specification (:91, 121, 179), append + X-Pagination (:134), and forward specification.Criteria as the lookup where for non-privileged + callers (:149-157). ExportAsync (:194-208) returns Forbid() for a non-privileged caller (:204). + CreateAsync (:219-239) is [Idempotent] (:218), dispatches + AddSessionSpeakerCommand(request.SessionId, null, request.SpeakerId) (:224), evicts on success only + (:227-234), and returns CreatedAtRoute("GetSessionSpeakerById", ...); DeleteAsync (:243-259) reads + the parent sessionId [FromQuery] (:245), dispatches RemoveSessionSpeakerCommand(sessionId, id) + (:249), evicts (:257), and returns NoContent(). The class gate is + [HasPermission(ConferencePermissions.SessionsManage)] (:47).
    • Why it's built this way: the eviction crosses aggregates deliberately, because the session's cached representation includes its speakers, so mutating the link must invalidate the session cache to keep reads correct. Everything else is the shared junction shape, which is the point: an engineer who has read @@ -1779,15 +1955,16 @@

      SponsorsController

      pipeline unchanged and BuildPublicSponsorSpecificationAsync (:60-70) only adds the published-event rule on top of it (:52-58, 94-99). The published rule and the caller's filter are composed by the query service rather than substituted, so scoping to an unpublished event returns an empty page to a - non-privileged caller instead of leaking the roster. That is why this controller, alone among the - scope-carrying aggregate roots in this unit, has no filter-interception block at all. + non-privileged caller instead of leaking the roster. That is why this controller, like its + ActivitiesController twin and unlike the speaker and session roots, has no + filter-interception block at all. [Rubric §11, Security]: the class carries a bare [Authorize] (:36) and each mutation re-asserts [HasPermission(ConferencePermissions.SponsorsManage)] (:193, 212, 224, 243), the capability declared at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Authorization/ConferencePermissions.cs:33 - and included in the ContentManagement curation subset (ConferencePermissions.cs:53-59), so a content + and included in the ContentManagement curation subset (ConferencePermissions.cs:57-64), so a content editor can manage the sponsor roster without holding event, room, or question rights. [Rubric §12, Performance & Scalability]: reads run under the SponsorsCache policy (5-minute TTL, tags conference - and conference:sponsors, MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:243) and + and conference:sponsors, MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:248) and every mutation evicts both tags.
    • Walkthrough
      • IsPrivileged (SponsorsController.cs:50) is the shared @@ -1827,7 +2004,7 @@

        SponsorsController

        the simplest of the scope-carrying aggregate-root controllers.
      • Where it's used: hosted by MMCA.ADC.Conference.Service and reached through the YARP Gateway, which forwards /Sponsors/{**catch-all} to the Conference service - (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:144). Clients are the public sponsor page + (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/appsettings.json:96). Clients are the public sponsor page (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSponsorList.razor) and the organizer sponsor list, create, and detail pages under MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sponsor/.
      • diff --git a/docs/onboarding/group-21-conference-ui.html b/docs/onboarding/group-21-conference-ui.html index d9b9424..d3b962c 100644 --- a/docs/onboarding/group-21-conference-ui.html +++ b/docs/onboarding/group-21-conference-ui.html @@ -145,43 +145,48 @@

        Onboarding guide

        21. ADC Conference - UI

        -

        What this chapter covers. This is the consumer half of the "write-once UI, render everywhere" story (primer §2): the Blazor pages and per-page HTTP services that turn the Conference REST surface (G20) into the screens an organizer, a speaker, a sponsor, or an anonymous attendee actually touches. Everything here lives in the per-module Razor Class Library MMCA.ADC.Conference.UI (under MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/, the path all File:line citations below are relative to), which, like every consumer UI, assembles the reusable primitives taught in G15 (Common UI Framework) into concrete pages. There is almost no new infrastructure here: the value is in seeing how a real, eleven-area feature surface (events, sessions, speakers, categories, questions, rooms, sponsors, feedback, public browsing, session selection, and the conference landing page) is composed from the framework's list-page base, typed HTTP service base, device-capability abstractions, and module system. The headline lens is [Rubric §18, UI Architecture & Component Design], which assesses component reuse, separation of presentation from data access, and a coherent composition model. Because the same Razor components compile into the Blazor Server, WebAssembly, and .NET MAUI hybrid heads, this one library renders the conference across web, Android, iOS, macOS, and Windows with no per-platform reimplementation. [Rubric §22, Responsive & Cross-Browser/Device].

        +

        What this chapter covers. This is the consumer half of the "write-once UI, render everywhere" story (primer §2): the Blazor pages and per-page HTTP services that turn the Conference REST surface (G20) into the screens an organizer, a speaker, a sponsor, or an anonymous attendee actually touches. Everything here lives in the per-module Razor Class Library MMCA.ADC.Conference.UI (under MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/, the path all File:line citations below are relative to), which, like every consumer UI, assembles the reusable primitives taught in G15 (Common UI Framework) into concrete pages. There is almost no new infrastructure here: the value is in seeing how a real, twelve-area feature surface (events, sessions, speakers, conference categories and their items, questions, rooms, sponsors, activities, feedback moderation, public browsing, session selection, and the conference landing page) is composed from the framework's list-page base, typed HTTP service base, device-capability abstractions, and module system. The headline lens is [Rubric §18, UI Architecture & Component Design], which assesses component reuse, separation of presentation from data access, and a coherent composition model. Because the same Razor components compile into the Blazor Server, WebAssembly, and .NET MAUI hybrid heads, this one library renders the conference across web, Android, iOS, macOS, and Windows with no per-platform reimplementation. [Rubric §22, Responsive & Cross-Browser/Device].

        The layering inside the UI: a page never touches HttpClient

        -

        Each page is a .razor + .razor.cs code-behind pair that depends only on a UI service interface, never on HttpClient and never on the API's internals. The eight CRUD-shaped entities (events, sessions, speakers, conference categories, category items, questions, rooms, sponsors) each get a service deriving from Common's EntityServiceBase<TEntityDTO, TIdentifierType> and exposing the IEntityService<TEntityDTO, TIdentifierType> contract: EventService, SessionService, SpeakerService, ConferenceCategoryService, CategoryItemService, QuestionService, RoomService, and SponsorService. They inherit GetAllAsync/GetPagedAsync/GetByIdAsync/AddAsync/UpdateAsync/DeleteAsync and only add the handful of bespoke verbs the conference needs. Most add nothing at all: SponsorService is a body-less class whose entire job is to bind the sponsors endpoint to SponsorDTO and SponsorIdentifierType (MMCA.ADC.Conference.UI/Services/SponsorService.cs:10 to :14), and ISponsorUIService is an equally empty extension of the generic contract (MMCA.ADC.Conference.UI/Services/ISponsorUIService.cs:9). EventService is the counter-example that shows where extension goes: it layers PublishAsync, UnpublishAsync, and RefreshFromSessionizeAsync onto the inherited CRUD (MMCA.ADC.Conference.UI/Services/EventService.cs:17, :32, :47), each routed through the inherited SendRequestAsync helper so a back-end Result.Failure is unwrapped into a typed, displayable error via ServiceExceptionHelper before EnsureSuccessStatusCode can throw something contextless. [Rubric §3, Clean Architecture] and [Rubric §9, API & Contract Design]: the page binds to a DTO contract (EventDTO, SessionDTO, SpeakerDTO, SponsorDTO) and an interface, and the wire envelope is the uniform PagedCollectionResult<T> / CollectionResult<T> the API returns for every entity. Each entity also gets its own per-feature interface, IEventUIService, ISessionUIService, ISpeakerUIService, IConferenceCategoryUIService, ICategoryItemUIService, IQuestionUIService, IRoomUIService, and ISponsorUIService, which extends the generic contract and declares only that entity's extra verbs.

        +

        Each page is a .razor + .razor.cs code-behind pair that depends only on a UI service interface, never on HttpClient and never on the API's internals. The nine CRUD-shaped entities (events, sessions, speakers, conference categories, category items, questions, rooms, sponsors, activities) each get a service deriving from Common's EntityServiceBase<TEntityDTO, TIdentifierType> and exposing the IEntityService<TEntityDTO, TIdentifierType> contract: EventService, SessionService, SpeakerService, ConferenceCategoryService, CategoryItemService, QuestionService, RoomService, SponsorService, and ActivityService (MMCA.ADC.Conference.UI/Services/EventService.cs:14, Services/SessionService.cs:11, Services/SpeakerService.cs:13, Services/ConferenceCategoryService.cs:11, Services/CategoryItemService.cs:11, Services/QuestionService.cs:11, Services/RoomService.cs:14, Services/SponsorService.cs:11, Services/ActivityService.cs:11). They inherit GetAllAsync/GetPagedAsync/GetByIdAsync/AddAsync/UpdateAsync/DeleteAsync and only add the handful of bespoke verbs the conference needs. Most add nothing at all: ActivityService is a body-less class whose entire job is to bind the activities endpoint to ActivityDTO and ActivityIdentifierType (MMCA.ADC.Conference.UI/Services/ActivityService.cs:10 to :14), and IActivityUIService is an equally empty extension of the generic contract (MMCA.ADC.Conference.UI/Services/IActivityUIService.cs:9); SponsorService and ISponsorUIService have exactly the same shape (Services/SponsorService.cs:10, Services/ISponsorUIService.cs:9).

        +

        Three services show where extension goes. EventService layers PublishAsync, UnpublishAsync, and RefreshFromSessionizeAsync onto the inherited CRUD (MMCA.ADC.Conference.UI/Services/EventService.cs:17, :32, :47), each routed through the inherited SendRequestAsync helper so a back-end Result.Failure is unwrapped into a typed, displayable error via ServiceExceptionHelper before EnsureSuccessStatusCode can throw something contextless; EventDetail is the page that calls all three (MMCA.ADC.Conference.UI/Pages/Event/EventDetail.razor.cs:221, :249, :304). RoomService overrides AddAsync to reshape the POST body, because the API's AddRoomRequest contract names the key RoomId while the DTO calls it Id (MMCA.ADC.Conference.UI/Services/RoomService.cs:17 to :33), and adds a two-argument DeleteAsync that passes the owning event on the query string (Services/RoomService.cs:35). SpeakerService adds LinkUserAsync/UnlinkUserAsync for binding a speaker record to an identity account (MMCA.ADC.Conference.UI/Services/SpeakerService.cs:16, :31). [Rubric §3, Clean Architecture] and [Rubric §9, API & Contract Design]: the page binds to a DTO contract (EventDTO, SessionDTO, SpeakerDTO, SponsorDTO, ActivityDTO) and an interface, and the wire envelope is the uniform PagedCollectionResult<T> / CollectionResult<T> the API returns for every entity. Each entity also gets its own per-feature interface, IEventUIService, ISessionUIService, ISpeakerUIService, IConferenceCategoryUIService, ICategoryItemUIService, IQuestionUIService, IRoomUIService, ISponsorUIService, and IActivityUIService, which extends the generic contract and declares only that entity's extra verbs.

        The list pages: derive from DataGridListPageBase<TDto>, get everything for free

        -

        Ten list screens, the organizer EventList, SessionList, SpeakerList, ConferenceCategoryList, QuestionList, RoomList, SponsorList, and the public PublicEventList, PublicSessionList, PublicSpeakerList, inherit DataGridListPageBase<TDto>. That base supplies server-side paging against MudDataGrid<T>, cancellation lifecycle, loading and load-failed state, filter/sort extraction from MudBlazor's GridState<T>, ISnackbar error surfacing, saved page/rows-per-page/scroll restoration, and viewport-driven mobile rendering that swaps the grid for a MobileInfiniteScrollList<TItem>. A concrete page therefore reduces to overriding Title, GridRef, SaveFilters/RestoreFilters, and a LoadServerData delegate that calls its service's GetPagedAsync and folds in page-specific filters. MMCA.ADC.Conference.UI/Pages/Event/EventList.razor.cs:48 is the roughly ten-line canonical example, with the mobile path reusing the same service call through FetchMobilePage (EventList.razor.cs:60) and delete-with-confirmation delegated to the shared ListPageActions helper (EventList.razor.cs:72). [Rubric §23, Front-End Performance & Rendering] (avoiding redundant fetches and round-trips) and [Rubric §19, State Management & Data Flow] (paging, sort, and filter state persisted across navigation). This is the "compose, do not repeat" thesis of G15 made concrete ten times over. The one public list page that does not use the base is PublicSponsorList: a sponsor roster is bounded (its MaxSponsors cap is 200, MMCA.ADC.Conference.UI/Pages/Public/PublicSponsorList.razor.cs:21) and is rendered as tier-grouped logo cards rather than a grid, so it fetches one page and groups it in memory instead (PublicSponsorList.razor.cs:68 to :86).

        +

        Eleven list screens, the organizer EventList, SessionList, SpeakerList, ConferenceCategoryList, QuestionList, RoomList, SponsorList, ActivityList, and the public PublicEventList, PublicSessionList, PublicSpeakerList, inherit DataGridListPageBase<TDto> (MMCA.ADC.Conference.UI/Pages/Event/EventList.razor.cs:16, Pages/Session/SessionList.razor.cs:18, Pages/Speaker/SpeakerList.razor.cs:19, Pages/ConferenceCategory/ConferenceCategoryList.razor.cs:11, Pages/Question/QuestionList.razor.cs:11, Pages/Room/RoomList.razor.cs:12, Pages/Sponsor/SponsorList.razor.cs:19, Pages/Activity/ActivityList.razor.cs:19, Pages/Public/PublicEventList.razor.cs:30, Pages/Public/PublicSessionList.razor.cs:25, Pages/Public/PublicSpeakerList.razor.cs:35). That base supplies server-side paging against MudDataGrid<T>, cancellation lifecycle, loading and load-failed state, filter and sort extraction from MudBlazor's GridState<T>, ISnackbar error surfacing, saved page/rows-per-page/scroll restoration, and viewport-driven mobile rendering that swaps the grid for a MobileInfiniteScrollList<TItem>. A concrete page therefore reduces to overriding Title, GridRef, SaveFilters/RestoreFilters, and a LoadServerData delegate that calls its service's GetPagedAsync and folds in page-specific filters. MMCA.ADC.Conference.UI/Pages/Event/EventList.razor.cs:48 is the roughly ten-line canonical example, with the mobile path reusing the same service call through FetchMobilePage (Pages/Event/EventList.razor.cs:60) and delete-with-confirmation delegated to the shared ListPageActions helper (Pages/Event/EventList.razor.cs:72). ActivityList shows the next increment of the same recipe: it resolves a default event filter before the grid's first fetch by starting the lookup in OnInitializedAsync and awaiting that task inside LoadServerData (Pages/Activity/ActivityList.razor.cs:77, awaited at :136), persists the choice with an explicit "all" sentinel so an intentional clear is distinguishable from no saved state (Pages/Activity/ActivityList.razor.cs:50), and drops a restored id that no longer exists back to the computed default (Pages/Activity/ActivityList.razor.cs:98 to :112). [Rubric §23, Front-End Performance & Rendering] (avoiding redundant fetches and round-trips) and [Rubric §19, State Management & Data Flow] (paging, sort, and filter state persisted across navigation). This is the "compose, do not repeat" thesis of G15 made concrete eleven times over. Each organizer entity pairs its list with a create page and a detail page in the same shape, a MudBlazor form over the entity's DTO with breadcrumbs, snackbar feedback, and an owned CancellationTokenSource disposed with the component: EventCreate / EventDetail (Pages/Event/EventCreate.razor.cs:13, Pages/Event/EventDetail.razor.cs:15), SessionCreate / SessionDetail (Pages/Session/SessionCreate.razor.cs:15, Pages/Session/SessionDetail.razor.cs:17), SpeakerCreate / SpeakerDetail (Pages/Speaker/SpeakerCreate.razor.cs:13, Pages/Speaker/SpeakerDetail.razor.cs:19), ConferenceCategoryCreate / ConferenceCategoryDetail (Pages/ConferenceCategory/ConferenceCategoryCreate.razor.cs:9, Pages/ConferenceCategory/ConferenceCategoryDetail.razor.cs:11), QuestionCreate / QuestionDetail (Pages/Question/QuestionCreate.razor.cs:9, Pages/Question/QuestionDetail.razor.cs:11), RoomCreate / RoomDetail (Pages/Room/RoomCreate.razor.cs:9, Pages/Room/RoomDetail.razor.cs:12), plus the sponsor and activity pairs covered below.

        +

        Two public lists deliberately opt out of the grid. PublicSponsorList and PublicActivityList are plain ComponentBase pages (MMCA.ADC.Conference.UI/Pages/Public/PublicSponsorList.razor.cs:18, Pages/Public/PublicActivityList.razor.cs:19), because a sponsor roster and an activity programme are bounded (both cap the fetch at 200 rows, Pages/Public/PublicSponsorList.razor.cs:21, Pages/Public/PublicActivityList.razor.cs:22) and render as tier-grouped logo cards and a chronological programme rather than a sortable table, so each fetches one page and orders it in memory (Pages/Public/PublicSponsorList.razor.cs:68 to :86, Pages/Public/PublicActivityList.razor.cs:68 to :85). A third page splits the difference: PublicSpeakerList keeps the base class's page-based mobile fetch path but appends each page to an accumulating list and hangs an InfiniteScrollSentinel below its card grid, twelve cards per chunk so a full chunk fills whole rows (Pages/Public/PublicSpeakerList.razor.cs:38, :40, next page at :213, sentinel rendered at Pages/Public/PublicSpeakerList.razor:144). The sentinel itself is the module's one piece of new UI infrastructure: it drives the same shared _content/MMCA.Common.UI/infinite-scroll.js IntersectionObserver module MobileInfiniteScrollList uses, but owns only the observer, so a page keeps its own card markup, empty state, and error state (MMCA.ADC.Conference.UI/Components/InfiniteScrollSentinel.razor.cs:21, observer attach at :59, JS-invoked callback at :46). Owning the observer in a child component is also what makes the lifecycle correct: a page deriving from DataGridListPageBase cannot hook async disposal, while the renderer disposes this component the moment the host stops rendering it, which is exactly when the last page has loaded (Components/InfiniteScrollSentinel.razor.cs:76 to :111).

        Container and presentational split

        -

        The behaviour-heavy screens do not keep everything in one code-behind: the page stays the container (data fetching, filter and paging state, service calls) and hands rendering to presentational children that receive parameters and raise callbacks. PublicSessionList is the fullest example, splitting into PublicSessionListFilterBar (organizer event picker or locked chip, debounced search, All Sessions / My Schedule toggle, share action, MMCA.ADC.Conference.UI/Pages/Public/PublicSessionListFilterBar.razor.cs:15) and PublicSessionListView (the mobile card list and the desktop grid plus the inline bookmark stars, MMCA.ADC.Conference.UI/Pages/Public/PublicSessionListView.razor.cs:21). The view exposes Grid and ReloadAsync back to the page (PublicSessionListView.razor.cs:85, :88) so the base class's grid plumbing keeps working unchanged, and it patches the container-owned bookmark dictionary in place when a star is toggled (PublicSessionListView.razor.cs:137, :152). The same split shows up on the speaker detail page via SpeakerCategoryItemsPanel, which raises Changed so the page reloads the speaker (MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerCategoryItemsPanel.razor.cs:31, invoked at :73 and :87), and on the selection dashboard via SessionSelectionSpeakerOverlap and SessionSelectionAiScores, each taking the five filter values as plain parameters (SessionSelectionSpeakerOverlap.razor.cs:15 to :19, SessionSelectionAiScores.razor.cs:17 to :21). The pure display and filter-matching rules those children share (locality-tier detection, status and score chip colors, score-tier and status predicates) live in the static SessionSelectionDisplay (MMCA.ADC.Conference.UI/Pages/SessionSelection/SessionSelectionDisplay.cs:11, helpers at :13 to :53), testable without rendering anything. [Rubric §18, UI Architecture] and [Rubric §28, Front-End Testing].

        +

        The behaviour-heavy screens do not keep everything in one code-behind: the page stays the container (data fetching, filter and paging state, service calls) and hands rendering to presentational children that receive parameters and raise callbacks. PublicSessionList is the fullest example, splitting into PublicSessionListFilterBar (organizer event picker or locked chip, debounced title search, room picker, All Sessions / My Schedule toggle, share action, MMCA.ADC.Conference.UI/Pages/Public/PublicSessionListFilterBar.razor.cs:15, parameters at :25 to :58) and PublicSessionListView (the mobile card list and the desktop grid plus the inline bookmark stars, Pages/Public/PublicSessionListView.razor.cs:23). The view exposes Grid and ReloadAsync back to the page (Pages/Public/PublicSessionListView.razor.cs:87, :90) so the base class's grid plumbing keeps working unchanged, and it patches the container-owned bookmark dictionary in place when a star is toggled (Pages/Public/PublicSessionListView.razor.cs:139, :154). The same split shows up on the speaker detail page via SpeakerCategoryItemsPanel, which raises Changed so the page reloads the speaker (MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerCategoryItemsPanel.razor.cs:31, invoked at :73 and :87), and on the selection dashboard via SessionSelectionSpeakerOverlap and SessionSelectionAiScores, each taking the five filter values as plain parameters (Pages/SessionSelection/SessionSelectionSpeakerOverlap.razor.cs:15 to :19, Pages/SessionSelection/SessionSelectionAiScores.razor.cs:17 to :21). The pure display and filter-matching rules those children share (locality-tier detection, status and score chip colors, score-tier and status predicates) live in the static SessionSelectionDisplay (MMCA.ADC.Conference.UI/Pages/SessionSelection/SessionSelectionDisplay.cs:11, helpers at :13 to :56), testable without rendering anything. [Rubric §18, UI Architecture] and [Rubric §28, Front-End Testing].

        Child-and-join entities: a thin POST/DELETE base

        -

        Sessions, speakers, and events own join relationships (a speaker added to a session, a category item to a speaker) that the generic CRUD base cannot model, because the write carries a parent id. These get four near-identical services (EventSpeakerService, SessionSpeakerService, SessionCategoryItemService, SpeakerCategoryItemService) over the shared, purpose-built ChildEntityServiceBase, which was hoisted out of this module into MMCA.Common.UI so every consumer module can reuse it (the note is left in place at MMCA.ADC.Conference.UI/Services/ChildEntityServices.cs:75). Each Conference join service reduces to supplying its endpoint and adding typed AddAsync/DeleteAsync wrappers over the base's two verbs (ChildEntityServices.cs:14, :30, :46, :62). Their interfaces (IEventSpeakerUIService, ISessionSpeakerUIService, ISessionCategoryItemUIService, ISpeakerCategoryItemUIService) live together in MMCA.ADC.Conference.UI/Services/IChildEntityUIService.cs. Note the hard-won detail: the add payload always names the parent explicitly (new { EventId = eventId, SpeakerId = speakerId }, ChildEntityServices.cs:19), because a controller that binds a parentId from the query string will 404 a remove that sends only the child id. [Rubric §24, Forms, Validation & UX Safety].

        +

        Sessions, speakers, and events own join relationships (a speaker added to a session, a category item to a speaker) that the generic CRUD base cannot model, because the write carries a parent id. These get four near-identical services (EventSpeakerService, SessionSpeakerService, SessionCategoryItemService, SpeakerCategoryItemService) over the shared, purpose-built ChildEntityServiceBase, which was hoisted out of this module into MMCA.Common.UI so every consumer module can reuse it (the note is left in place at MMCA.ADC.Conference.UI/Services/ChildEntityServices.cs:75). Each Conference join service reduces to supplying its endpoint and adding typed AddAsync/DeleteAsync wrappers over the base's two verbs (Services/ChildEntityServices.cs:14, :30, :46, :62). Their interfaces (IEventSpeakerUIService, ISessionSpeakerUIService, ISessionCategoryItemUIService, ISpeakerCategoryItemUIService) live together in one file (MMCA.ADC.Conference.UI/Services/IChildEntityUIService.cs:10, :19, :28, :37). Note the hard-won detail: the add payload always names the parent explicitly (new { EventId = eventId, SpeakerId = speakerId }, Services/ChildEntityServices.cs:19), because a controller that binds a parentId from the query string will 404 a remove that sends only the child id. [Rubric §24, Forms, Validation & UX Safety].

        Display-enrichment lookups: the GetAll-vs-GetById populator gap, worked around in the UI

        -

        Because the API's list endpoints do not always populate every cross-entity navigation, several pages need a cheap id-to-name map to render speaker names beside a session or an event name beside a room or a sponsor. Three lookup services fill that role, SpeakerLookupService, EventLookupService, and CategoryItemLookupService (behind ISpeakerLookupService, IEventLookupService, ICategoryItemLookupService). Each does one pageSize=10000 fetch with children and foreign keys suppressed, and folds the result into a Dictionary of lightweight projection records, SpeakerInfo, EventInfo, CategoryItemInfo (MMCA.ADC.Conference.UI/Services/SpeakerLookupService.cs:20 and :25, MMCA.ADC.Conference.UI/Services/EventLookupService.cs:20 and :25, MMCA.ADC.Conference.UI/Services/CategoryItemLookupService.cs:33 and :38); the category-item lookup makes a second, unpaged call first so each item can carry its owning category's title (CategoryItemLookupService.cs:19 to :26). EventInfo is the one projection that grew a feature-specific field: SponsorshipPacketUrl is an optional trailing parameter defaulting to null precisely so the many call sites that need only identity and dates stayed unchanged, and only the public sponsor page reads it (MMCA.ADC.Conference.UI/Services/IEventLookupService.cs:12 to :19). PublicSessionList fetches the speaker lookup once while resolving its event filter (MMCA.ADC.Conference.UI/Pages/Public/PublicSessionList.razor.cs:170) and the view joins each session's SessionSpeakers against it to display names (PublicSessionListView.razor.cs:163). This is a deliberate client-side join over the navigation-populator (ADR-002) gap between the API's list and by-id read shapes.

        +

        Because the API's list endpoints do not always populate every cross-entity navigation, several pages need a cheap id-to-name map to render speaker names beside a session or an event name beside a room, a sponsor, or an activity. Three lookup services fill that role, SpeakerLookupService, EventLookupService, and CategoryItemLookupService (behind ISpeakerLookupService, IEventLookupService, ICategoryItemLookupService). Each does one pageSize=10000 fetch with children and foreign keys suppressed, and folds the result into a Dictionary of lightweight projection records, SpeakerInfo, EventInfo, CategoryItemInfo (MMCA.ADC.Conference.UI/Services/SpeakerLookupService.cs:20 and :28, Services/EventLookupService.cs:20 and :28, Services/CategoryItemLookupService.cs:33 and :42); the category-item lookup makes a second, unpaged call first so each item can carry its owning category's title (Services/CategoryItemLookupService.cs:19 to :30). EventInfo is the one projection that grew a feature-specific field: SponsorshipPacketUrl is an optional trailing parameter defaulting to null precisely so the many call sites that need only identity and dates stayed unchanged, and only the public sponsor page reads it (MMCA.ADC.Conference.UI/Services/IEventLookupService.cs:12 to :19, read at Pages/Public/PublicSponsorList.razor.cs:61). PublicSessionList fetches the speaker lookup once while resolving its event filter (MMCA.ADC.Conference.UI/Pages/Public/PublicSessionList.razor.cs:182) and the view joins each session's SessionSpeakers against it to display names (Pages/Public/PublicSessionListView.razor.cs:170 to :172). This is a deliberate client-side join over the navigation-populator (ADR-002) gap between the API's list and by-id read shapes.

        Three feature areas that go beyond CRUD

        -

        First, the speaker self-service dashboard: SpeakerDashboard is gated on the speaker_id JWT claim (read from the cascaded authentication state and parsed as a Guid, MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerDashboard.razor.cs:74) and shows the linked speaker's sessions for the current or next event, per-session bookmark counts, and feedback, with inline profile editing (BR-214). It leans on SpeakerDashboardService (behind ISpeakerDashboardUIService), whose session read pushes the speaker filter server-side, caps the page at 100 rows, and appends a per-call cache-bust query parameter so it is a guaranteed miss against the shared sessions output cache and a just-made speaker assignment shows immediately (MMCA.ADC.Conference.UI/Services/SpeakerDashboardService.cs:20, :36, :39), and whose bookmark counts come back from one batched endpoint rather than one cross-service hop per session (SpeakerDashboardService.cs:55, consumed at SpeakerDashboard.razor.cs:117). It derives from Common's AuthenticatedServiceBase so its calls carry the bearer token and run through the shared retry policy (SpeakerDashboardService.cs:97). Second, organizer feedback moderation (BR-53): OrganizerEventFeedback / OrganizerSessionFeedback let organizers review and delete answers via OrganizerEventFeedbackService / OrganizerSessionFeedbackService (interfaces IOrganizerEventFeedbackUIService / IOrganizerSessionFeedbackUIService); organizers get the unscoped server-side view, and each delete passes the parent id explicitly on the query string to satisfy the controller's binding (MMCA.ADC.Conference.UI/Services/OrganizerFeedbackService.cs:48, :95), unwrapping domain failures through ServiceExceptionHelper before throwing (OrganizerFeedbackService.cs:53, :100). [Rubric §11, Security]: the scoping is server-side, not a client-side hide. Third, QR self-service: SpeakerQr renders a full-screen code a speaker can hold up at the podium, with no backend call at all, since the speaker comes from the speaker_id claim and the payload is built locally, so the page renders identically on the prerender and interactive passes (MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerQr.razor.cs:49 to :55). The payload is always the absolute public URL from IPublicLinkBuilder, never the WebView-internal origin, or a code scanned off the MAUI head would open for nobody else. The module's shared QrCodeButton component puts the same capability on four organizer and public print surfaces: sponsor detail (MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorDetail.razor:93), room detail (MMCA.ADC.Conference.UI/Pages/Room/RoomDetail.razor:56), public event detail (MMCA.ADC.Conference.UI/Pages/Public/PublicEventDetail.razor:30), and public session detail (MMCA.ADC.Conference.UI/Pages/Public/PublicSessionDetail.razor:41).

        +

        First, the speaker self-service dashboard: SpeakerDashboard is gated on the speaker_id JWT claim (read from the cascaded authentication state and parsed as a Guid, MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerDashboard.razor.cs:74, :76) behind a plain [Authorize] attribute (Pages/Speaker/SpeakerDashboard.razor:2), and shows the linked speaker's sessions for the current or next event, per-session bookmark counts, and feedback, with inline profile editing (BR-214). It leans on SpeakerDashboardService (behind ISpeakerDashboardUIService), whose session read pushes the speaker filter server-side, caps the page at 100 rows, and appends a per-call cache-bust query parameter so it is a guaranteed miss against the shared sessions output cache and a just-made speaker assignment shows immediately (MMCA.ADC.Conference.UI/Services/SpeakerDashboardService.cs:20, :36, :39), and whose bookmark counts come back from one batched endpoint rather than one cross-service hop per session (Services/SpeakerDashboardService.cs:55, consumed at Pages/Speaker/SpeakerDashboard.razor.cs:117). It derives from Common's AuthenticatedServiceBase so its calls carry the bearer token and run through the shared retry policy (Services/SpeakerDashboardService.cs:97), with a private dispatch helper that unwraps domain failures and treats 404 as "no feedback yet" rather than an error (Services/SpeakerDashboardService.cs:99 to :104). Second, organizer feedback moderation (BR-53): OrganizerEventFeedback / OrganizerSessionFeedback let organizers review and delete answers via OrganizerEventFeedbackService / OrganizerSessionFeedbackService (interfaces IOrganizerEventFeedbackUIService / IOrganizerSessionFeedbackUIService); organizers get the unscoped server-side view, and each delete passes the parent id explicitly on the query string to satisfy the controller's binding (MMCA.ADC.Conference.UI/Services/OrganizerFeedbackService.cs:48, :95), unwrapping domain failures through ServiceExceptionHelper before throwing (Services/OrganizerFeedbackService.cs:53, :100). [Rubric §11, Security]: the scoping is server-side and the pages carry [Authorize(Roles = "Organizer")] (Pages/Feedback/OrganizerEventFeedback.razor:2), not a client-side hide. Third, QR self-service: SpeakerQr renders a full-screen code a speaker can hold up at the podium, with no backend call at all, since the speaker comes from the speaker_id claim and the payload is built locally, so the page renders identically on the prerender and interactive passes (MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerQr.razor.cs:49 to :55). The payload is always the absolute public URL from IPublicLinkBuilder, never the WebView-internal origin, or a code scanned off the MAUI head would open for nobody else. The module's own QrCodeButton component (MMCA.ADC.Conference.UI/Components/QrCodeButton.razor) puts the same capability on five organizer and public print surfaces: sponsor detail (Pages/Sponsor/SponsorDetail.razor:93), room detail (Pages/Room/RoomDetail.razor:56), public event detail (Pages/Public/PublicEventDetail.razor:30), public session detail (Pages/Public/PublicSessionDetail.razor:41), and public speaker detail (Pages/Public/PublicSpeakerDetail.razor:45).

        Session-selection decision support, the asynchronous edge

        -

        The most behaviour-rich page is the organizer-only SessionSelectionDashboard, which renders category distribution, speaker overlap, locality breakdown, and AI content-similarity scoring over an event's session pool via SessionSelectionService (behind ISessionSelectionUIService). It defaults the event picker to the live-or-next event through the shared CurrentEventSelector (MMCA.ADC.Conference.UI/Pages/SessionSelection/SessionSelectionDashboard.razor.cs:80) and derives its four filter option lists from the returned SessionSelectionDashboardDTO itself, through the pure projection record SessionSelectionFilterOptions (SessionSelectionDashboard.razor.cs:191, projection at MMCA.ADC.Conference.UI/Pages/SessionSelection/SessionSelectionFilterOptions.cs:20). GetDashboardAsync reads that DTO through the inherited retry policy (MMCA.ADC.Conference.UI/Services/SessionSelectionService.cs:23); ScoreSessionsAsync POSTs to the scoring endpoint and handles 202 Accepted explicitly: because AI scoring of every eligible session can take minutes, the API runs the ScoreEventSessionsCommand in a background scope and returns 202 immediately, so the UI service maps that to a sentinel ScoreEventSessionsResultDTO with SessionsScored = -1 to signal "started in background" rather than a completed count (SessionSelectionService.cs:41 to :44). The page then starts a fire-and-forget poll loop on an 8-second cadence, held in an internal property so a bUnit test can shrink it (SessionSelectionDashboard.razor.cs:246, loop at :262 and :272), and the decision logic for that loop is factored out into the pure state machine ScorePollTracker, which turns each observation into a ScorePollSignal: keep polling, apply-and-continue, all sessions scored, counts stable long enough, or no scores at all within the zero-progress budget (MMCA.ADC.Conference.UI/Pages/SessionSelection/ScorePollTracker.cs:74, dispatched at SessionSelectionDashboard.razor.cs:317). Its budgets are explicit constants: 225 polls, a 30-minute cap (ScorePollTracker.cs:34), 5 consecutive fetch failures (ScorePollTracker.cs:41), 10 zero-progress polls (ScorePollTracker.cs:48), and 3 stable polls before completion (ScorePollTracker.cs:51). [Rubric §6, CQRS & Event-Driven] and [Rubric §29, Resilience]: the fire-and-forget contract is honoured on both sides, transient poll failures are absorbed rather than wedging the Score button, and the dashboard read goes through the retry policy so a blip self-heals.

        +

        The most behaviour-rich page is the organizer-only SessionSelectionDashboard (MMCA.ADC.Conference.UI/Pages/SessionSelection/SessionSelectionDashboard.razor:2 carries the Organizer role attribute), which renders category distribution, speaker overlap, locality breakdown, and AI content-similarity scoring over an event's session pool via SessionSelectionService (behind ISessionSelectionUIService). It defaults the event picker to the live-or-next event through the shared CurrentEventSelector (Pages/SessionSelection/SessionSelectionDashboard.razor.cs:80) and derives its four filter option lists from the returned SessionSelectionDashboardDTO itself, through the pure projection record SessionSelectionFilterOptions (Pages/SessionSelection/SessionSelectionDashboard.razor.cs:193, projection at Pages/SessionSelection/SessionSelectionFilterOptions.cs:20). Every load is stamped with a monotonic generation rather than an event id, so switching away from an event and back still discards the first response even though both carry the same id: the field is bumped on each selection (Pages/SessionSelection/SessionSelectionDashboard.razor.cs:36), snapshotted before the fetch (:131), and re-checked before the board is replaced (:143), before an error banner is painted (:165), and before the spinner is cleared (:172). [Rubric §19, State Management & Data Flow].

        +

        GetDashboardAsync reads that DTO through the inherited retry policy (MMCA.ADC.Conference.UI/Services/SessionSelectionService.cs:23); ScoreSessionsAsync POSTs to the scoring endpoint and handles 202 Accepted explicitly: because AI scoring of every eligible session can take minutes, the API runs the ScoreEventSessionsCommand in a background scope and returns 202 immediately, so the UI service maps that to a sentinel ScoreEventSessionsResultDTO with SessionsScored = -1 to signal "started in background" rather than a completed count (Services/SessionSelectionService.cs:42 to :45). The page then starts a fire-and-forget poll loop (Pages/SessionSelection/SessionSelectionDashboard.razor.cs:215) on an 8-second cadence held in an internal property so a bUnit test can shrink it (Pages/SessionSelection/SessionSelectionDashboard.razor.cs:246, loop at :262, delay at :276), and the decision logic for that loop is factored out into the pure state machine ScorePollTracker, which turns each observation into a ScorePollSignal: keep polling, apply-and-continue, all sessions scored, counts stable long enough, or no scores at all within the zero-progress budget (Pages/SessionSelection/ScorePollTracker.cs:74, observed at Pages/SessionSelection/SessionSelectionDashboard.razor.cs:285, failures at :299, dispatched at :319). Its budgets are explicit constants: 225 polls, a 30-minute cap (Pages/SessionSelection/ScorePollTracker.cs:34), 5 consecutive fetch failures (:41), 10 zero-progress polls (:48), and 3 stable polls before completion (:51). [Rubric §6, CQRS & Event-Driven] and [Rubric §29, Resilience]: the fire-and-forget contract is honoured on both sides, transient poll failures are absorbed rather than wedging the Score button, and the dashboard read goes through the retry policy so a blip self-heals.

        Public versus authenticated rendering, and the device-capability path

        -

        A recurring [Rubric §11, Security] pattern: the same conference entity is exposed through two page families. The public family (PublicEventList/PublicEventDetail, PublicSessionList/PublicSessionDetail, PublicSpeakerList/PublicSpeakerDetail, PublicSponsorList) is anonymous-readable and output-cached at the API; the organizer family exposes edit controls behind role gating. PublicSessionList shows the nuance well. It is read-only for anonymous users (BR-43), but an authenticated user gets inline bookmark stars and a My Schedule toggle wired through the optional ISessionBookmarkUIService; because Blazor's [Inject] has no optional mode (an unregistered service throws at render), the page declares that dependency as a nullable property and resolves it via IServiceProvider.GetService (PublicSessionList.razor.cs:38, resolved at :114), so it stays null when the Engagement module is disabled. [Rubric §7, Microservices Readiness]. Non-organizers are always locked server-side to the computed current or next event via CurrentEventDefaults and the privileged-reader list ConferenceReadAudience, so a shared organizer URL cannot pin an attendee to a different or unpublished event (PublicSessionList.razor.cs:149, :186, :193). My Schedule is a true server-side paged fetch, scoping the query with an Id IN (...) filter over the bookmarked ids rather than over-fetching and filtering in memory (PublicSessionList.razor.cs:296 to :304). The page also participates in the device-capability layer (G26, ADR-042): the last successful first page is written to ILocalCacheStore as a CachedSessionPage record and replayed when IConnectivityStatusService reports offline (PublicSessionList.razor.cs:316, :325, record at :342), the star toggle fires IHapticFeedbackService (PublicSessionListView.razor.cs:111), the filter bar shares a schedule screenshot through IScreenshotService and IShareService (PublicSessionListFilterBar.razor.cs:51 to :57), and /conference/sessions?mine=true is a deep link the MAUI head's home-screen quick action targets (PublicSessionList.razor.cs:65). Each of those is a no-op on the web heads, so one page serves both worlds. [Rubric §29, Resilience] and [Rubric §22, Responsive & Cross-Browser/Device].

        -

        Sponsors, a feature area in miniature

        -

        The sponsor surface is worth reading as a compact tour of every pattern above, because it is the newest and touches all of them. Organizers manage the roster through SponsorList / SponsorCreate / SponsorDetail: the list is a plain DataGridListPageBase<SponsorDTO> whose event filter defaults to the current or next event and persists across navigation (MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorList.razor.cs:19, :44, :77, :95), the create page offers the SponsorTier values in package order straight off Enum.GetValues and defaults the event the same way (MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorCreate.razor.cs:18, :56, :77), and the detail page edits every field except the owning event, on the stated rationale that moving a sponsorship between events is a create plus a delete (MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorDetail.razor.cs:10 to :15), resolving the event name through the shared EventLookupService (SponsorDetail.razor.cs:38 to :41). Attendees see the same data twice. PublicSponsorList resolves the featured event, filters the roster to it, and groups by tier ascending (package order) then by Sort and name, so the render order is deterministic rather than insertion-dependent (PublicSponsorList.razor.cs:49 to :86); when the roster is empty it falls back to the sponsorship call to action, and when the event publishes no packet URL that call to action is hidden entirely rather than offering a dead link (PublicSponsorList.razor.cs:36, :61). ADCHome renders the same roster as a logo strip using the same tier-then-sort-then-name rule (MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:210 to :219), filtering client-side to the featured event so a second published edition's sponsors cannot bleed onto the landing page (ADCHome.razor.cs:213), and any failure leaves the list empty and the call to action standing (ADCHome.razor.cs:221 to :228). Because that page reads the anonymous endpoint directly rather than through a typed service, its wire shapes are private records on the component itself: ADCSponsorCollectionResult and ADCSponsorInfo (ADCHome.razor.cs:295, :297).

        +

        A recurring [Rubric §11, Security] pattern: the same conference entity is exposed through two page families. The public family (PublicEventList/PublicEventDetail, PublicSessionList/PublicSessionDetail, PublicSpeakerList/PublicSpeakerDetail, PublicSponsorList, PublicActivityList) carries no @attribute [Authorize] at all and is output-cached at the API; the organizer family gates on the Organizer role in markup (MMCA.ADC.Conference.UI/Pages/Event/EventList.razor:2). PublicSessionList shows the nuance well. It is read-only for anonymous users (BR-43), but an authenticated user gets inline bookmark stars and a My Schedule toggle wired through the optional ISessionBookmarkUIService; because Blazor's [Inject] has no optional mode (an unregistered service throws at render), the page declares that dependency as a nullable property and resolves it via IServiceProvider.GetService (Pages/Public/PublicSessionList.razor.cs:38, resolved at :126), so it stays null when the Engagement module is disabled. [Rubric §7, Microservices Readiness]. Non-organizers are always locked server-side to the computed current or next event via CurrentEventDefaults and the privileged-reader list ConferenceReadAudience, so a shared organizer URL cannot pin an attendee to a different or unpublished event (Pages/Public/PublicSessionList.razor.cs:161, :203, :210). The room picker costs no extra fetch: PublicScheduleRoomOptions scopes the rooms the already-loaded events carry, de-duplicates and orders them, and clears a room filter the newly scoped list no longer offers rather than leaving it filtering out everything (Pages/Public/PublicScheduleRoomOptions.cs:21 to :41, called at Pages/Public/PublicSessionList.razor.cs:195). My Schedule is a true server-side paged fetch, scoping the query with an Id IN (...) filter over the bookmarked ids rather than over-fetching and filtering in memory (Pages/Public/PublicSessionList.razor.cs:317).

        +

        The page also participates in the device-capability layer (G26, ADR-042): the last successful first page is written to ILocalCacheStore as a CachedSessionPage record and replayed when IConnectivityStatusService reports offline (MMCA.ADC.Conference.UI/Pages/Public/PublicSessionList.razor.cs:342, :351, record at :366), the star toggle fires IHapticFeedbackService (Pages/Public/PublicSessionListView.razor.cs:113), the filter bar shares a schedule screenshot through IScreenshotService and IShareService (Pages/Public/PublicSessionListFilterBar.razor.cs:65 to :69), the public activity page opens directions through IMapNavigationService, which launches the platform maps app on native heads and a maps site in a browser tab otherwise (Pages/Public/PublicActivityList.razor.cs:109), and /conference/sessions?mine=true is a deep link the MAUI head's home-screen quick action targets (Pages/Public/PublicSessionList.razor.cs:67). Each of those is a no-op on the web heads, so one page serves both worlds. [Rubric §29, Resilience] and [Rubric §22, Responsive & Cross-Browser/Device].

        +

        Sponsors and activities, two feature areas in miniature

        +

        The sponsor surface is worth reading as a compact tour of every pattern above. Organizers manage the roster through SponsorList / SponsorCreate / SponsorDetail: the list is a plain DataGridListPageBase<SponsorDTO> whose event filter defaults to the current or next event and persists across navigation (MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorList.razor.cs:19, :44, :54, :106), the create page offers the SponsorTier values in package order straight off Enum.GetValues and defaults the event the same way (Pages/Sponsor/SponsorCreate.razor.cs:18, :56), and the detail page edits every field except the owning event, on the stated rationale that moving a sponsorship between events is a create plus a delete (Pages/Sponsor/SponsorDetail.razor.cs:10 to :13), resolving the event name through the shared EventLookupService (Pages/Sponsor/SponsorDetail.razor.cs:100). Attendees see the same data twice. PublicSponsorList resolves the featured event, filters the roster to it, and groups by tier ascending (package order) then by Sort and name, so the render order is deterministic rather than insertion-dependent (Pages/Public/PublicSponsorList.razor.cs:44 to :86); when the roster is empty it falls back to the sponsorship call to action, and when the event publishes no packet URL that call to action is hidden entirely rather than offering a dead link (Pages/Public/PublicSponsorList.razor.cs:36, :61). ADCHome renders the same roster as a logo strip using the same tier-then-sort-then-name rule (Pages/Home/ADCHome.razor.cs:225 to :234), filtering client-side to the featured event so a second published edition's sponsors cannot bleed onto the landing page (Pages/Home/ADCHome.razor.cs:228), and any failure leaves the list empty and the call to action standing (Pages/Home/ADCHome.razor.cs:236 to :243). Because that page reads the anonymous endpoint directly rather than through a typed service, its wire shapes are private records on the component itself: ADCSponsorCollectionResult and ADCSponsorInfo (Pages/Home/ADCHome.razor.cs:311, :313).

        +

        The activities area (the social programme: pre-conference party, coffee connect, after-party, closing ceremony) is the newest and repeats the shape with two twists. ActivityCreate, ActivityDetail, and ActivityList are the organizer trio, with the create page defaulting the owning event to the current or next one and deriving the start and end defaults from that event's dates (MMCA.ADC.Conference.UI/Pages/Activity/ActivityCreate.razor.cs:51, :58). Because EventId is a real Activity column, the list's event filter needs no virtual-key resolution and goes straight through the generic filter pipeline (Pages/Activity/ActivityList.razor.cs:150), unlike the speaker list, whose event filter travels as a virtual key resolved server-side through the join tables (Pages/Public/PublicSpeakerList.razor.cs:21 to :22). PublicActivityList renders the same programme chronologically for attendees, ordering by start time, then display order, then name so ties are deterministic (Pages/Public/PublicActivityList.razor.cs:79 to :85), and offers the directions affordance for an activity that carries its own off-site venue (Pages/Public/PublicActivityList.razor.cs:101).

        The landing page

        -

        ADCHome is the conference front door, shared by the web and MAUI heads; both serve the editorial images from their own site root today, so neither overrides the ImageBasePath parameter (MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:35). It fetches the events list through the named "APIClient" and features the live-or-next published event via CurrentEventSelector (ADCHome.razor.cs:160, :165), deserializing into two more private API models, ADCCollectionResult and ADCEventInfo (ADCHome.razor.cs:282, :284). Three rendering decisions are worth internalizing. First, during SSR prerender it skips the backend fetch and the timer entirely and renders the static fallback, because an untimed server-side call to a cold backend would block the prerender and therefore the post-login navigation (ADCHome.razor.cs:101). Second, the per-second countdown ticking lives in a child component behind a render fence, so this page arms only a single one-shot Timer for the Live-to-Ended flip (ADCHome.razor.cs:113, armed at :128), classifying the moment into the EventPhase enum Upcoming/Live/Ended from the event's own time zone (ADCHome.razor.cs:255). Third, the fallback date is a named constant with an explicit warning that it must track the published event date, since a stale value makes the hero date and the countdown visibly jump once the real event loads (ADCHome.razor.cs:25). [Rubric §23, Front-End Performance & Rendering]. The editorial content it renders (keynote and the eight-track catalog) is held as static records, KeynoteSpeakerInfo and ConferenceTrackInfo (ADCHome.razor.cs:340, :341, data at :309 and :320).

        -

        Routes and navigation

        -

        All paths are centralized in ConferenceRoutePaths, a static catalogue of literal routes and id-parameterized builder methods (EventDetails(id), PublicSessionDetails(id), SponsorDetails(id), EventFeedbackOrganizer(id), and so on) typed against the module's identifier aliases and formatted culture-invariantly; pages navigate with NavigationManager.NavigateTo(ConferenceRoutePaths.EventDetails(id)) rather than hand-building URL strings, so a route change happens in one file (MMCA.ADC.Conference.UI/ConferenceRoutePaths.cs:10 to :63). Two entries in that file are deliberate duplicates of routes owned by Engagement.UI, SponsorVisitLink and RoomCheckInLink (ConferenceRoutePaths.cs:55, :56), because Conference.UI must not reference Engagement.UI yet the organizer print surfaces need those links to encode into a QR; the reason is recorded inline at ConferenceRoutePaths.cs:51 to :54. [Rubric §25, Navigation, Routing & Information Architecture]. Public share links are built through the injectable IPublicLinkBuilder, whose default NavigationPublicLinkBuilder resolves against the browser origin (MMCA.ADC.Conference.UI/Services/NavigationPublicLinkBuilder.cs:19), with the MAUI head overriding the registration after module registration so shared links always point at the web app (MMCA.ADC.Conference.UI/DependencyInjection.cs:46 to :49). User-facing strings are not inline English: every page resolves its labels and snackbar messages through an injected IStringLocalizer (the L["..."] calls in each code-behind, for example the title in EventList.razor.cs:19 and the delete toast at EventList.razor.cs:77, or the breadcrumbs in SpeakerDashboard.razor.cs:56) over co-located .resx resources. Where a string is deliberately left untranslated (the conference brand name, a postal address, the English-only editorial content on the landing page) the code carries an explicit // i18n: allow marker with a reason (ADCHome.razor.cs:64, :68, :80, :307). [Rubric §27, Internationalization & Localization] assesses externalized strings and culture-aware formatting; this area embodies it under ADR-027, which superseded the single-locale ADR-011 (primer §6).

        +

        ADCHome is the conference front door, shared by the web and MAUI heads; both serve the editorial images from their own site root today, so neither overrides the ImageBasePath parameter (MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:51). It fetches the events list through the named "APIClient" and features the live-or-next published event via CurrentEventSelector (Pages/Home/ADCHome.razor.cs:176, :180), deserializing into two more private API models, ADCCollectionResult and ADCEventInfo (Pages/Home/ADCHome.razor.cs:297, :299). Three rendering decisions are worth internalizing. First, during SSR prerender it skips the backend fetch and the timer entirely and renders the static fallback, because an untimed server-side call to a cold backend would block the prerender and therefore the post-login navigation (Pages/Home/ADCHome.razor.cs:116). Second, the per-second countdown ticking lives in a child component behind a render fence, so this page arms only a single one-shot Timer for the Live-to-Ended flip (Pages/Home/ADCHome.razor.cs:126, armed at :128), classifying the moment into the EventPhase enum Upcoming/Live/Ended from the event's own time zone and going through CurrentEventSelector.ToUtc so the spring-forward gap at a midnight boundary cannot throw out of the render path (Pages/Home/ADCHome.razor.cs:246 to :275). Third, the fallback date is a named constant with an explicit warning that it must track the published event date, since a stale value makes the hero date and the countdown visibly jump once the real event loads (Pages/Home/ADCHome.razor.cs:40). [Rubric §23, Front-End Performance & Rendering]. The editorial content it renders (the keynote, the eight-track catalog, and the two pre-conference workshops) is held as static records, KeynoteSpeakerInfo, ConferenceTrackInfo, and PreConferenceWorkshopInfo (Pages/Home/ADCHome.razor.cs:371, :372, :379, data at :325, :336, and :359); the workshop record carries only proper nouns plus a resource-key stem, so its audience and description lines stay localized (Pages/Home/ADCHome.razor.cs:356 to :358).

        +

        Routes, navigation, and localized strings

        +

        All paths are centralized in ConferenceRoutePaths, a static catalogue of literal routes and id-parameterized builder methods (EventDetails(id), PublicSessionDetails(id), SponsorDetails(id), ActivityDetails(id), EventFeedbackOrganizer(id), and so on) typed against the module's identifier aliases and formatted culture-invariantly; pages navigate with NavigationManager.NavigateTo(ConferenceRoutePaths.EventDetails(id)) rather than hand-building URL strings, so a route change happens in one file (MMCA.ADC.Conference.UI/ConferenceRoutePaths.cs:10 to :68). Two entries in that file are deliberate duplicates of routes owned by Engagement.UI, SponsorVisitLink and RoomCheckInLink (ConferenceRoutePaths.cs:60, :61), because Conference.UI must not reference Engagement.UI yet the organizer print surfaces need those links to encode into a QR; the reason is recorded inline at ConferenceRoutePaths.cs:56 to :59. [Rubric §25, Navigation, Routing & Information Architecture]. Public share links are built through the injectable IPublicLinkBuilder, whose default NavigationPublicLinkBuilder resolves against the browser origin (MMCA.ADC.Conference.UI/Services/NavigationPublicLinkBuilder.cs:19), with the MAUI head overriding the registration after module registration so shared links always point at the web app (MMCA.ADC.Conference.UI/DependencyInjection.cs:46 to :49). User-facing strings are not inline English: every page injects an IStringLocalizer<TPage> in its markup (Pages/Event/EventList.razor:5) and resolves labels and snackbar messages through L["..."] over co-located .resx resources, including format patterns such as the hero date layout so month names follow the selected culture (Pages/Home/ADCHome.razor.cs:284). Where a string is deliberately left untranslated (the conference brand name, a postal address, a ticketing URL, the English-only editorial content) the code carries an explicit // i18n: allow marker with a reason (Pages/Home/ADCHome.razor.cs:31, :79, :83, :323). [Rubric §27, Internationalization & Localization] assesses externalized strings and culture-aware formatting; this area embodies it under ADR-027, which superseded the single-locale ADR-011 (primer §6).

        How it all plugs into the shell

        -

        Two registration types wire the area in. ConferenceUIModule implements Common's IUIModule (the front-end counterpart of the IModule back-end contract): it declares the module's fourteen NavItem entries, whose labels are ADR-027 resource keys (Nav.Events, Nav.Dashboard, and so on) each carrying a TitleResource so the shared NavMenu localizes them at render time against the co-located ConferenceUIModule.resx pair (MMCA.ADC.Conference.UI/ConferenceUIModule.cs:18 to :39). Those fourteen split three ways: four public entries for everyone including the sponsor page (ConferenceUIModule.cs:21 to :24), two speaker_id-claim-gated entries in the user section, the dashboard and the speaker's own QR (ConferenceUIModule.cs:27, :28), and an Organizer-role-gated admin group of eight, Events, Sessions, Speakers, Categories, Questions, Rooms, Sponsors, and Session Selection (ConferenceUIModule.cs:31 to :38); it then exposes its assembly so the host can discover the Razor routes (ConferenceUIModule.cs:41). The companion DependencyInjection extension AddConferenceUI() (a C# extension(IServiceCollection) member, primer §4) is the one call a host makes (MMCA.ADC.Conference.UI/DependencyInjection.cs:19): it delegates the two-step prologue to Common's AddUIModule<ConferenceUIModule>(), which Scrutor-scans the module assembly for every IEntityService<,> implementation as scoped and registers the descriptor as a singleton IUIModule (DependencyInjection.cs:23, implementation at MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:152 to :161), then explicitly registers the four child-entity services (DependencyInjection.cs:26 to :29), the speaker dashboard (:32), the two organizer feedback services (:35, :36), session selection (:39), the three lookup services (:42 to :44), and the public-link builder (:49). Because the scan covers the entity services, adding a ninth CRUD entity needs no edit here at all, and because the module contributes its own nav and assembly, the shell folds it in with no edit to the shell either. [Rubric §1, SOLID] (Open/Closed) and [Rubric §18, UI Architecture]. Read the per-type sections that follow for the mechanics of each page and service; the bUnit and Playwright tests that exercise this library live in the testing chapter (G27).

        +

        Two registration types wire the area in. ConferenceUIModule implements Common's IUIModule (the front-end counterpart of the IModule back-end contract): it declares the module's sixteen NavItem entries, whose labels are ADR-027 resource keys (Nav.Events, Nav.Dashboard, and so on) each carrying a TitleResource so the shared NavMenu localizes them at render time against the co-located ConferenceUIModule.resx pair (MMCA.ADC.Conference.UI/ConferenceUIModule.cs:18 to :41). Those sixteen split three ways: five public entries for everyone, Events, Sessions, Speakers, Sponsors, and Activities (ConferenceUIModule.cs:21 to :25), two speaker_id-claim-gated entries in the user section, the dashboard and the speaker's own QR (ConferenceUIModule.cs:28, :29), and an Organizer-role-gated admin group of nine, Events, Sessions, Speakers, Categories, Questions, Rooms, Sponsors, Activities, and Session Selection (ConferenceUIModule.cs:32 to :40); it then exposes its assembly so the host can discover the Razor routes (ConferenceUIModule.cs:43). The companion DependencyInjection extension AddConferenceUI() (a C# extension(IServiceCollection) member, primer §4) is the one call a host makes (MMCA.ADC.Conference.UI/DependencyInjection.cs:19): it delegates the two-step prologue to Common's AddUIModule<ConferenceUIModule>(), which Scrutor-scans the module assembly for every IEntityService<,> implementation as scoped and registers the descriptor as a singleton IUIModule (DependencyInjection.cs:23, implementation at MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:152 to :162), then explicitly registers the four child-entity services (DependencyInjection.cs:26 to :29), the speaker dashboard (:32), the two organizer feedback services (:35, :36), session selection (:39), the three lookup services (:42 to :44), and the public-link builder (:49). Because the scan covers the entity services, adding a tenth CRUD entity needs no edit here at all: the Activities area added ActivityService with no line in this file. And because the module contributes its own nav and assembly, the shell folds it in with no edit to the shell either. [Rubric §1, SOLID] (Open/Closed) and [Rubric §18, UI Architecture]. Read the per-type sections that follow for the mechanics of each page and service; the bUnit and Playwright tests that exercise this library live in the testing chapter (G27).

        ADCEventInfo

        -

        MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Home · MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:284 · Level 0 · record (sealed, private)

        +

        MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Home · MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:299 · Level 0 · record (sealed, private)

          -
        • What it is: the deserialization-only projection of one published event as the landing page needs it. It is declared private sealed record inside ADCHome (:284), so it is not a shared contract: it exists purely to give System.Text.Json a shape to bind the events response into.
        • +
        • What it is: the deserialization-only projection of one published event as the landing page needs it. It is declared private sealed record inside ADCHome (:299), so it is not a shared contract: it exists purely to give System.Text.Json a shape to bind the events response into.
        • Depends on: no first-party types. BCL only (DateOnly for the two dates).
        • -
        • Concept introduced: the page-local wire model. [Rubric §9, API and Contract Design] assesses whether consumers bind to explicit, minimal contracts rather than reaching for the server's internal types. The landing page needs nine fields (Id, Name, Description?, StartDate, EndDate, TimeZone, VenueAddress?, VenueMapUrl?, SponsorshipPacketUrl?, :284-293) out of the much larger event DTO the API serves, so it declares exactly those and lets the serializer ignore the rest. Because the record is private to the component, no other page can accidentally couple to it; a second consumer declares its own projection. Every optional field is nullable, which is what lets the page fall back to hard-coded defaults without null checks scattered through the markup.
        • -
        • Walkthrough: a positional record with no methods. Name feeds the EventName property and therefore HeroTitleParts() (:64, :78); Description feeds EventDescription, falling back to the localized Fallback.EventDescription resource (:66); StartDate/EndDate/TimeZone are the three inputs UpdateCountdown() converts into the UTC live window (:233-246); VenueAddress backs the venue block and the Google Maps search URL (:68-71); Id is the filter key that keeps a second published edition's sponsors off the page (:214); SponsorshipPacketUrl gates the whole sponsorship call to action block, heading and button included (ADCHome.razor:208-227).
        • -
        • Why it's built this way: the page must render before, during, and after the API call, so it stores a single nullable ADCEventInfo? _event (:50) and every derived property is written as _event?.X ?? <default>. One nullable field is the whole "loaded or not" state machine, with no extra flags.
        • -
        • Where it's used: the Items list of ADCCollectionResult (:282), selected by CurrentEventSelector.SelectCurrentOrNext in LoadEventAsync (:165-170), used as the sponsor filter key in LoadSponsorsAsync (:198, :214), and read by every derived display property on ADCHome.
        • -
        • Caveats / not-in-source: VenueMapUrl is bound from the wire but never read: the map button builds its own Google Maps search URL from VenueAddress instead (:70-71, ADCHome.razor:248-258). Whether the field is kept for a planned direct-map link is not determinable from source.
        • +
        • Concept introduced: the page-local wire model. [Rubric §9, API and Contract Design] assesses whether consumers bind to explicit, minimal contracts rather than reaching for the server's internal types. The landing page needs ten fields (Id, Name, Description?, StartDate, EndDate, TimeZone, VenueAddress?, VenueMapUrl?, SponsorshipPacketUrl?, TicketingUrl?, :299-309) out of the much larger event DTO the API serves, so it declares exactly those and lets the serializer ignore the rest. Because the record is private to the component, no other page can accidentally couple to it; a second consumer declares its own projection. Every optional field is nullable, which is what lets the page fall back to hard-coded defaults without null checks scattered through the markup.
        • +
        • Walkthrough: a positional record with no methods. Name feeds the EventName property and therefore HeroTitleParts() (:79, :93); Description feeds EventDescription, falling back to the localized Fallback.EventDescription resource (:81); StartDate/EndDate/TimeZone are the three inputs UpdateCountdown() converts into the UTC live window (:248-252); VenueAddress backs the venue block and the Google Maps search URL (:83-86); Id is the filter key that keeps a second published edition's sponsors off the page (:228); SponsorshipPacketUrl gates the whole sponsorship call to action block, heading and button included (ADCHome.razor:310-329); TicketingUrl gates the hero's "get tickets" button the same way (ADCHome.razor:71-79), so an event that has not opened sales renders no button rather than a dead link.
        • +
        • Why it's built this way: the page must render before, during, and after the API call, so it stores a single nullable ADCEventInfo? _event (:65) and every derived property is written as _event?.X ?? <default>. One nullable field is the whole "loaded or not" state machine, with no extra flags. The two ticketing surfaces sit on opposite sides of that line: the conference-day button reads the event field, while the pre-conference workshop button reads the fixed PreConferenceTicketingUrl constant (:30-31), because the workshop day sells through its own TicketLeap page.
        • +
        • Where it's used: the Items list of ADCCollectionResult (:297), selected by CurrentEventSelector.SelectCurrentOrNext in LoadEventAsync (:180-185), used as the sponsor filter key in LoadSponsorsAsync (:213, :228), and read by every derived display property on ADCHome.
        • +
        • Caveats / not-in-source: VenueMapUrl is bound from the wire (:307) but never read by this page: the map button builds its own Google Maps search URL from VenueAddress instead (:85-86, ADCHome.razor:353-363). The field is a real event column, edited and displayed on the organizer event page (MMCA.ADC.Conference.UI/Pages/Event/EventDetail.razor:68-69, :131); why the landing page projects it without using it is not determinable from source.

        ConferenceRoutePaths

        @@ -189,102 +194,136 @@

        ConferenceRoutePaths

        • What it is: one static class holding every Conference UI route, as public static readonly string constants for fixed paths and small factory methods for id-bearing paths. It covers the organizer management routes, the public attendee routes, the speaker surfaces, and the two QR landing links, so no @page directive or NavigateTo call has to hard-code a URL.
        • -
        • Depends on: no first-party types. It uses the module's identifier aliases (EventIdentifierType, SessionIdentifierType, SpeakerIdentifierType, ConferenceCategoryIdentifierType, QuestionIdentifierType, RoomIdentifierType, SponsorIdentifierType) that the Conference Shared project declares as global using (see the primer on identifier-type aliases), plus System.Globalization.CultureInfo (:1).
        • -
        • Concept introduced: a centralized navigation vocabulary. [Rubric §25, Navigation and Information Architecture] assesses whether routes form a coherent, role-aware information architecture instead of scattered magic strings; this class is that story in miniature. The paths split into two deliberate namespaces mirroring the module's two audiences: organizers work under bare prefixes (/events :10, /sessions :14, /speakers :18, /conferencecategories :22, /questions :26, /rooms :30, /sponsors :34) while attendees work under a /conference/... prefix (PublicSessions :39, PublicEvents :40, PublicSpeakers :43, PublicSponsors :45). Detail routes are methods rather than constants because they interpolate a typed id: EventDetails(EventIdentifierType id) (:12) builds /events/{id} with string.Create(CultureInfo.InvariantCulture, ...) so an integer id can never be formatted with a culture-specific group separator. [Rubric §27, Internationalization] shows up here as the negative case: URLs are the one place culture-aware formatting must be suppressed.
        • -
        • Walkthrough: the file is a flat list grouped by entity, each group contributing a list route, a create route, and a details factory: events (:10-12), sessions (:14-16), speakers (:18-20), conference categories (:22-24), questions (:26-28), rooms (:30-32), sponsors (:34-36). The public attendee block follows (:38-45), then the speaker surfaces SpeakerDashboard and SpeakerQr (:48-49), two QR self-service links, the organizer feedback factories, and the selection dashboard.
            -
          • The two QR links are deliberate duplicates (:55-56). SponsorVisitLink and RoomCheckInLink build /engage/sponsors/{id} and /engage/rooms/{id}, but those two pages are owned by Engagement.UI. The comment (:51-54) records the reason: Conference.UI must not reference Engagement.UI, yet the organizer print surfaces need the URL to encode into a QR code, and EngagementRoutePaths duplicates a Conference session route the same way. Both are consumed by a QrCodeButton on the sponsor and room detail pages (MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorDetail.razor:93, MMCA.ADC.Conference.UI/Pages/Room/RoomDetail.razor:56).
          • -
          • Feedback routes nest under their parent entity: EventFeedbackOrganizer gives /events/{id}/feedback and SessionFeedbackOrganizer gives /sessions/{id}/feedback (:59-60), so the URL itself expresses the ownership hierarchy. SessionSelectionDashboard closes the file (:63).
          • -
          • Two factories differ from the rest: SpeakerDetails (:20) and PublicSpeakerDetails (:44) use plain interpolation rather than string.Create(CultureInfo.InvariantCulture, ...), because SpeakerIdentifierType is a Guid whose ToString() is already culture-invariant.
          • +
          • Depends on: no first-party types. It uses the module's identifier aliases (EventIdentifierType, SessionIdentifierType, SpeakerIdentifierType, ConferenceCategoryIdentifierType, QuestionIdentifierType, RoomIdentifierType, SponsorIdentifierType, ActivityIdentifierType) that the Conference Shared project declares as global using (see the primer on identifier-type aliases), plus System.Globalization.CultureInfo (:1).
          • +
          • Concept introduced: a centralized navigation vocabulary. [Rubric §25, Navigation and Information Architecture] assesses whether routes form a coherent, role-aware information architecture instead of scattered magic strings; this class is that story in miniature. The paths split into two deliberate namespaces mirroring the module's two audiences: organizers work under bare prefixes (/events :10, /sessions :14, /speakers :18, /conferencecategories :22, /questions :26, /rooms :30, /sponsors :34, /activities :38) while attendees work under a /conference/... prefix (PublicSessions :43, PublicEvents :44, PublicSpeakers :47, PublicSponsors :49, PublicActivities :50). Detail routes are methods rather than constants because they interpolate a typed id: EventDetails(EventIdentifierType id) (:12) builds /events/{id} with string.Create(CultureInfo.InvariantCulture, ...) so an integer id can never be formatted with a culture-specific group separator. [Rubric §27, Internationalization] shows up here as the negative case: URLs are the one place culture-aware formatting must be suppressed.
          • +
          • Walkthrough: the file is a flat list grouped by entity, each group contributing a list route, a create route, and a details factory: events (:10-12), sessions (:14-16), speakers (:18-20), conference categories (:22-24), questions (:26-28), rooms (:30-32), sponsors (:34-36), activities (:38-40). The public attendee block follows (:42-50), then the speaker surfaces SpeakerDashboard and SpeakerQr (:52-54), two QR self-service links, the organizer feedback factories, and the selection dashboard.
              +
            • The two QR links are deliberate duplicates (:60-61). SponsorVisitLink and RoomCheckInLink build /engage/sponsors/{id} and /engage/rooms/{id}, but those two pages are owned by Engagement.UI. The comment (:56-59) records the reason: Conference.UI must not reference Engagement.UI, yet the organizer print surfaces need the URL to encode into a QR code, and EngagementRoutePaths duplicates a Conference session route the same way. Both are consumed by a QrCodeButton on the sponsor and room detail pages (MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorDetail.razor:93, MMCA.ADC.Conference.UI/Pages/Room/RoomDetail.razor:56).
            • +
            • Feedback routes nest under their parent entity: EventFeedbackOrganizer gives /events/{id}/feedback and SessionFeedbackOrganizer gives /sessions/{id}/feedback (:64-65), so the URL itself expresses the ownership hierarchy. SessionSelectionDashboard closes the file (:68).
            • +
            • Two factories differ from the rest: SpeakerDetails (:20) and PublicSpeakerDetails (:48) use plain interpolation rather than string.Create(CultureInfo.InvariantCulture, ...), because SpeakerIdentifierType is a Guid whose ToString() is already culture-invariant.
          • Why it's built this way: if the admin prefix ever moves (say /events becomes /admin/events), editing the one constant propagates the change to every navigation call, with no grep-and-replace and no risk of a stale link. Keeping the parameterized routes as methods typed against the identifier aliases means a wrong-entity id is a compile error, not a 404.
          • -
          • Where it's used: every Conference UI Blazor page's @page directive and NavigationManager.NavigateTo call (for example MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorDetail.razor.cs:226), the "see all sponsors" link on the landing page (ADCHome.razor:201), the NavItems collection in ConferenceUIModule (ConferenceUIModule.cs:21-38), and even one Engagement page, which links back to PublicSponsors (MMCA.ADC.Engagement.UI/Pages/Sponsors/SponsorVisit.razor:42).
          • +
          • Where it's used: every Conference UI Blazor page's @page directive and NavigationManager.NavigateTo call (for example MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorDetail.razor.cs:226), the "see all sponsors" link on the landing page (ADCHome.razor:303), the NavItems collection in ConferenceUIModule (ConferenceUIModule.cs:21-40), and even one Engagement page, which links back to PublicSponsors (MMCA.ADC.Engagement.UI/Pages/Sponsors/SponsorVisit.razor:42).

          ConferenceTrackInfo

          -

          MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Home · MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:341 · Level 0 · record (sealed, private)

          +

          MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Home · MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:372 · Level 0 · record (sealed, private)

          • What it is: one row of the landing page's track catalogue: a track Name, an Icon (a MudBlazor icon path constant), and a Topics string listing the track's subject areas.
          • Depends on: no first-party types. The Icon values are MudBlazor Icons.Material.Filled.* constants (external).
          • -
          • Concept introduced: this is the second of the two static-content records on the landing page; the pattern is introduced under KeynoteSpeakerInfo.
          • -
          • Walkthrough: a three-property positional record (:341). The whole catalogue is a private static readonly ConferenceTrackInfo[] Tracks (:320) initialized inline as a collection expression (:321-338) with eight entries (:322, :324, :326, :328, :330, :332, :334, :336), each spanning two lines: the track name and its icon constant on the first, the topics blurb on the second. They run from "Foundations (Beginner & Student)" (:322) to "Career, Leadership & Community" (:336), with the two AI tracks, languages, cross-platform, security, and game/XR in between. Every Icon is a MudBlazor Icons.Material.Filled.* constant picked per track (School, Psychology, AutoAwesome, Code, Devices, Security, SportsEsports, Groups). Storing the icon as a string (rather than a RenderFragment or an enum) is what keeps the record a plain data type: the markup passes it straight to <MudIcon Icon="@track.Icon"> (ADCHome.razor:138).
          • -
          • Why it's built this way: the track list changes once per conference cycle and is editorial rather than transactional, so it lives in the assembly instead of behind an API call or a CMS; the // i18n: allow marker above the block names "the track catalog" alongside the keynote bio as deliberately English-only editorial content (:307-308). The array is static readonly, so it is allocated once per process, not per render.
          • -
          • Where it's used: the Tracks array on ADCHome (:320), rendered as the track grid in ADCHome.razor (:130-147): one MudItem/MudCard per entry, keyed by track.Name (ADCHome.razor:133), showing the icon (:138), the name (:140), and the topics line (:142).
          • +
          • Concept introduced: this is one of three static-content records on the landing page; the pattern is introduced under KeynoteSpeakerInfo and reused by PreConferenceWorkshopInfo.
          • +
          • Walkthrough: a three-property positional record (:372). The whole catalogue is a private static readonly ConferenceTrackInfo[] Tracks (:336) initialized inline as a collection expression (:337-354) with eight entries (:338, :340, :342, :344, :346, :348, :350, :352), each spanning two lines: the track name and its icon constant on the first, the topics blurb on the second. They run from "Foundations (Beginner & Student)" (:338) to "Career, Leadership & Community" (:352), with the two AI tracks, languages, cross-platform, security, and game/XR in between. Every Icon is a MudBlazor Icons.Material.Filled.* constant picked per track (School, Psychology, AutoAwesome, Code, Devices, Security, SportsEsports, Groups). Storing the icon as a string (rather than a RenderFragment or an enum) is what keeps the record a plain data type: the markup passes it straight to <MudIcon Icon="@track.Icon"> (ADCHome.razor:237).
          • +
          • Why it's built this way: the track list changes once per conference cycle and is editorial rather than transactional, so it lives in the assembly instead of behind an API call or a CMS; the // i18n: allow marker above the block names "the track catalog" alongside the keynote bio as deliberately English-only editorial content (:323-324). The array is static readonly, so it is allocated once per process, not per render.
          • +
          • Where it's used: the Tracks array on ADCHome (:336), rendered as the track grid in ADCHome.razor (:229-246): one MudItem/MudCard per entry, keyed by track.Name (ADCHome.razor:232), showing the icon (:237), the name (:239), and the topics line (:241).

          EventPhase

          -

          MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Home · MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:57 · Level 0 · enum (private)

          +

          MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Home · MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:72 · Level 0 · enum (private)

            -
          • What it is: the three-state classification of the featured event relative to now: Upcoming, Live, Ended (:57-62). It is the single switch the landing page's hero renders from.
          • +
          • What it is: the three-state classification of the featured event relative to now: Upcoming, Live, Ended (:72-77). It is the single switch the landing page's hero renders from.
          • Depends on: nothing.
          • -
          • Concept introduced: deriving a render state from a clock instead of storing it. [Rubric §19, State Management and Data Flow] assesses whether UI state is derived from a single source of truth or duplicated into flags. There is no IsLive boolean anywhere on the page: UpdateCountdown() recomputes _phase from DateTime.UtcNow against the converted UTC window every time it runs (:254-260), and the markup branches on that one field. Recomputing rather than storing means a stale phase is impossible after a timer callback, a parameter change, or the interactive render pass that follows prerender.
          • -
          • Walkthrough: the assignment is a switch expression over now (:255-260): now < _startUtc gives Upcoming, now < _endUtc gives Live, anything later gives Ended. ArmPhaseTimerForEventEnd() reads it as its guard, returning immediately unless the phase is Live (:130-133), which is what makes the Live-to-Ended timer a single one-shot rather than a recurring tick. In the markup, Upcoming renders the HomeCountdown child, Live renders the "event live" chip plus a button to /happening-now, and Ended renders the post-event chip (ADCHome.razor:33-65).
          • +
          • Concept introduced: deriving a render state from a clock instead of storing it. [Rubric §19, State Management and Data Flow] assesses whether UI state is derived from a single source of truth or duplicated into flags. There is no IsLive boolean anywhere on the page: UpdateCountdown() recomputes _phase from DateTime.UtcNow against the converted UTC window every time it runs (:269-275), and the markup branches on that one field. Recomputing rather than storing means a stale phase is impossible after a timer callback, a parameter change, or the interactive render pass that follows prerender.
          • +
          • Walkthrough: the assignment is a switch expression over now (:270-275): now < _startUtc gives Upcoming, now < _endUtc gives Live, anything later gives Ended. ArmPhaseTimerForEventEnd() reads it as its guard, returning immediately unless the phase is Live (:145-148), which is what makes the Live-to-Ended timer a single one-shot rather than a recurring tick. In the markup, Upcoming renders the HomeCountdown child (ADCHome.razor:36-41), Live renders the "event live" chip plus a button to /happening-now (ADCHome.razor:42-56), and Ended renders the post-event chip (ADCHome.razor:57-65). The hero's ticketing button sits outside that branch (ADCHome.razor:67-80), so it shows in every phase the event publishes a URL.
          • Why it's built this way: three named states read far better at the call site than nested date comparisons, and keeping the enum private to the component signals it is a view concern, not a domain concept. The domain's own notion of a live window lives server-side and in CurrentEventSelector.
          • -
          • Where it's used: the _phase field on ADCHome (:49) and its Razor markup only.
          • +
          • Where it's used: the _phase field on ADCHome (:64) and its Razor markup only.
          • +
          +

          InfiniteScrollSentinel

          +
          +

          MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Components · MMCA.ADC.Conference.UI/Components/InfiniteScrollSentinel.razor.cs:21 · Level 0 · class (partial component)

          +
          +
            +
          • What it is: a one-div child component that a list page renders below its last item. When that div scrolls to within 200px of the viewport, the component raises an OnVisible callback so the page can fetch and append its next page. It owns the browser observer and nothing else: the item markup, the fetch, and the accumulated list all stay with the host page.
          • +
          • Depends on: IJSRuntime (:23), ElementReference, EventCallback, and DotNetObjectReference from Blazor, plus the shared JavaScript module _content/MMCA.Common.UI/infinite-scroll.js shipped by MMCA.Common.UI (MMCA.Common.UI/wwwroot/infinite-scroll.js). No first-party C# types.
          • +
          • Concept introduced: the JS-interop observer wrapped as a disposable child component. [Rubric §23, Front-End Performance and Rendering] assesses whether a UI loads work incrementally instead of rendering everything up front; an IntersectionObserver is the browser-native way to do that, and it costs no scroll-event handler and no polling. The interop is two-way: C# imports the module and calls observe (:63-66), and JavaScript calls back into C# by name through dotNetRef.invokeMethodAsync('OnSentinelVisible') (infinite-scroll.js:8), which is why the [JSInvokable] method's name is fixed and the code comment says so (:41-44). Each instance mints its own _observerId from a Guid (:34) so the module's observers map can detach exactly this one on teardown (infinite-scroll.js:1, :16-21). + The second idea is the reason it is a separate component at all, spelled out in the class doc (:6-19). The shared MobileInfiniteScrollList drives the same JS module (MMCA.Common.UI/Components/MobileInfiniteScrollList.razor.cs:86) but also owns the item markup, which a page with its own card grid cannot use. And a page deriving from DataGridListPageBase<TDto> cannot hook async disposal to detach an observer, because that base's DisposeAsync is not virtual (MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:725). Putting the observer in a child solves both: the renderer disposes the child the moment the host stops rendering it, which is exactly when the last page has loaded. [Rubric §1, SOLID] is the underlying move, single responsibility applied to a lifecycle rather than to data.
          • +
          • Walkthrough:
              +
            • Parameters (:26-32): OnVisible is the EventCallback the host binds its load-more method to, IsLoading renders the inline progress row while that fetch is in flight, and LoadingLabel is the accessible name for it, localized by the host per ADR-027.
            • +
            • State (:34-39): the per-instance _observerId, the ElementReference bound with @ref in the markup (InfiniteScrollSentinel.razor:4), the imported IJSObjectReference, the DotNetObjectReference<InfiniteScrollSentinel>, and two flags, _observing and _disposed.
            • +
            • OnSentinelVisible (:45-47): the [JSInvokable] entry point. It short-circuits to Task.CompletedTask when already disposed, otherwise marshals onto the renderer's synchronization context with InvokeAsync before raising OnVisible. Both halves matter: the call arrives from a JS callback on an arbitrary context, and a disposed component must not raise into a torn-down host.
            • +
            • OnAfterRenderAsync (:49-57): attaches on firstRender only, and only when not disposed. Attaching after render is required because _sentinelRef is not populated until the element exists.
            • +
            • AttachObserverAsync (:59-74): imports the module lazily with ??=, creates the DotNetObjectReference the same way, calls observe with the reference, the element, and the id, then sets _observing. It catches JSDisconnectedException and does nothing, with the comment recording the consequence (:71-72): during prerendering or circuit teardown there is no JS to talk to, so the list simply stops at the pages already loaded rather than failing the render.
            • +
            • DisposeAsync (:76-111): GC.SuppressFinalize first, then an idempotency guard on _disposed, then a best-effort detach: unobserve only if the observer was actually attached, then DisposeAsync on the module. Two catch arms swallow JSDisconnectedException (circuit already gone) and JSException (shutdown-time interop races), and the finally always disposes the DotNetObjectReference so the managed reference cannot leak even when interop fails.
            • +
            • Markup (InfiniteScrollSentinel.razor:1-14): the sentinel div, and inside it, only while IsLoading, a MudProgressCircular in a wrapper carrying role="status", aria-live="polite", and aria-busy="true" (:9). [Rubric §21, Accessibility]: the comment states the intent (:7-8), a screen reader hears that more items are loading without the announcement interrupting reading, matching PageLoadingState's politeness, and the spinner carries the host's localized aria-label (:11). The class names are the shared ones from MMCA.Common.UI's stylesheet, so the sentinel looks identical here and inside MobileInfiniteScrollList (InfiniteScrollSentinel.razor:1-3).
            • +
            +
          • +
          • Why it's built this way: the host must render the sentinel only while more pages exist, which the class doc states as a usage contract (:18-19). That is not just an optimization: absence of the sentinel is what stops the observer from firing at the end of the list, and a filter reset that refills the list gets a fresh instance and therefore a fresh observer. [Rubric §16, Maintainability]: the contract is one line of markup at the call site rather than a Reset() method on this component.
          • +
          • Where it's used: the public speaker card grid, PublicSpeakerList, which renders it under @if (HasMoreSpeakers) and binds OnVisible to its LoadMoreSpeakersAsync (MMCA.ADC.Conference.UI/Pages/Public/PublicSpeakerList.razor:133-146). The comment there records the same contract from the host's side (:131-132): the sentinel exists only while pages remain, so its absence is the "everything is loaded" signal for the reader and for the tests. When a load fails, the page swaps the sentinel for an error row with a retry button (:135-141), which also detaches the observer until the reader asks again.

          KeynoteSpeakerInfo

          -

          MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Home · MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:340 · Level 0 · record (sealed, private)

          +

          MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Home · MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:371 · Level 0 · record (sealed, private)

            -
          • What it is: the keynote block's content: the speaker's Name, Title (their role), the TalkTitle, an optional PhotoFileName, and BioParagraphs as a string[] (:340).
          • +
          • What it is: the keynote block's content: the speaker's Name, Title (their role), the TalkTitle, an optional PhotoFileName, and BioParagraphs as a string[] (:371).
          • Depends on: no first-party types. BCL only.
          • -
          • Concept introduced: the two-tier content model of a landing page. The page splits its content into dynamic data fetched from the API (dates, venue, name, sponsors, via ADCEventInfo and ADCSponsorInfo) and editorial data compiled into the assembly (keynote and tracks, via this record and its sibling ConferenceTrackInfo). [Rubric §23, Front-End Performance and Rendering] is the payoff: the keynote and the track grid render on the first frame with zero network dependency, so a cold or unreachable backend degrades only the countdown and the sponsor strip, never the page. [Rubric §27, Internationalization] is the deliberate exception: the block carries an explicit // i18n: allow marker with a written reason (:307-308) recording that this English-only editorial content is the same copy the API would serve, while the chrome around it is localized. That marker convention is how ADR-027 distinguishes "not yet translated" from "intentionally untranslated".
          • -
          • Walkthrough: a five-property positional record (:340). The single instance is a private static readonly KeynoteSpeakerInfo Keynote initialized inline (:309-318): Jared Rhodes, "Microsoft MVP and Principal Engineer", the talk "More Software, Different Work", PhotoFileName: "jared-rhodes.jpg" (:313), and a three-paragraph biography (:315-317). BioParagraphs is an array rather than one string so the template can emit each paragraph in its own element instead of relying on whitespace preservation (ADCHome.razor:103-106). The record carries only the portrait's file name, not a path: the usable src is composed from the head-specific ImageBasePath parameter through KeynoteImageSrc, which returns $"{ImageBasePath}/speakers/{fileName}" or null when no file name is supplied (:42-43). That split exists because a head could package its assets elsewhere, and the null case is still guarded in the markup so the card renders name and title without a portrait (ADCHome.razor:83-92).
          • +
          • Concept introduced: the two-tier content model of a landing page. The page splits its content into dynamic data fetched from the API (dates, venue, name, ticketing link, sponsors, via ADCEventInfo and ADCSponsorInfo) and editorial data compiled into the assembly (keynote, tracks, and workshops, via this record and its siblings ConferenceTrackInfo and PreConferenceWorkshopInfo). [Rubric §23, Front-End Performance and Rendering] is the payoff: the keynote, the workshop cards, and the track grid render on the first frame with zero network dependency, so a cold or unreachable backend degrades only the countdown, the hero ticketing button, and the sponsor strip, never the page. [Rubric §27, Internationalization] is the deliberate exception: the block carries an explicit // i18n: allow marker with a written reason (:323-324) recording that this English-only editorial content is the same copy the API would serve, while the chrome around it is localized. That marker convention is how ADR-027 distinguishes "not yet translated" from "intentionally untranslated".
          • +
          • Walkthrough: a five-property positional record (:371). The single instance is a private static readonly KeynoteSpeakerInfo Keynote initialized inline (:325-334): Jared Rhodes, "Microsoft MVP and Principal Engineer", the talk "More Software, Different Work", PhotoFileName: "jared-rhodes.jpg" (:329), and a three-paragraph biography (:331-333). BioParagraphs is an array rather than one string so the template can emit each paragraph in its own element instead of relying on whitespace preservation (ADCHome.razor:199-202). The record carries only the portrait's file name, not a path: the usable src is composed from the head-specific ImageBasePath parameter through KeynoteImageSrc, which returns $"{ImageBasePath}/speakers/{fileName}" or null when no file name is supplied (:57-58). That split exists because a head could package its assets elsewhere, and the null case is still guarded in the markup so the card renders name and title without a portrait (ADCHome.razor:179-192).
          • Why it's built this way: the keynote changes once per conference cycle, so a database round-trip and an admin screen would be pure overhead. Keeping it static readonly also means it is shared by every circuit on the server head rather than re-allocated per user.
          • -
          • Where it's used: the Keynote field on ADCHome (:309), read by KeynoteImageSrc (:42-43) and rendered in the keynote section of ADCHome.razor (:70-111).
          • +
          • Where it's used: the Keynote field on ADCHome (:325), read by KeynoteImageSrc (:57-58) and rendered in the keynote section of ADCHome.razor (:163-207).
          • +
          +

          PreConferenceWorkshopInfo

          +
          +

          MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Home · MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:379 · Level 0 · record (sealed, private)

          +
          +
            +
          • What it is: one pre-conference workshop card: a resource-key stem Key, the workshop Title, the Presenter name, and an Icon (:379). Two instances make up the whole workshops section.
          • +
          • Depends on: no first-party types. The Icon values are MudBlazor Icons.Material.Filled.* constants (external).
          • +
          • Concept introduced: the half-localized content record. [Rubric §27, Internationalization] assesses whether user-facing text resolves through resources rather than sitting in code, and this record is the interesting middle case that the other two content records do not show. Instead of choosing "all in code" or "all in resources", it splits by kind of string: proper nouns stay in code with // i18n: allow markers (workshop titles :362, :366; presenter names :363, :367) because translating a talk title or a person's name would be wrong, while the prose that describes each workshop lives in the .resx pair. The bridge is Key, documented on the record itself (:374-378): the markup composes Workshops.{Key}.Audience and Workshops.{Key}.Description at render time (ADCHome.razor:136, :139), and those keys exist in both locales (ADCHome.resx:26-29). One consequence worth noticing: adding a workshop means adding four resource entries per locale, and a typo in Key fails at runtime as a missing resource rather than at compile time.
          • +
          • Walkthrough: a four-property positional record (:379). The catalogue is a private static readonly PreConferenceWorkshopInfo[] Workshops (:359) with two entries: "ModularMonolith" presented by Ivan Ball-llovera with the Hub icon (:361-364), and "SoftwareFactory" presented by Tim Rayburn with the PrecisionManufacturing icon (:365-368). The comment above the array states the split in one sentence (:356-358). The markup renders the array as a two-column grid (ADCHome.razor:120-145), keyed by workshop.Key (:123), each card showing the icon in a circle (:128), the title (:130), the localized Workshops.PresenterLabel formatted with the presenter name (:132-134), the audience line (:135-137), and the description (:138-140).
          • +
          • Why it's built this way: the workshop day runs before the conference day, and the section is placed between the hero and the keynote so the page reads in the same order as the event (ADCHome.razor:86-88). Two facts the cards would otherwise repeat, the schedule and the venue, are hoisted into one shared logistics line above the grid (ADCHome.razor:107-118), which is why they are resources (Workshops.Schedule, Workshops.Venue) rather than record fields. Ticketing is the other deliberate asymmetry: the workshop day sells on its own TicketLeap page, so its call to action reads the PreConferenceTicketingUrl constant (:30-31) and always renders (ADCHome.razor:147-159), while the hero's conference-day button reads TicketingUrl off the featured event and hides itself when absent. The constant carries an S1075 suppression with the reason inline (:27-32): it is a published product page, not an environment-dependent path, so there is nothing to configure. [Rubric §26, Front-End Security]: the workshop ticketing button opens with Target="_blank" together with rel="noopener noreferrer" (ADCHome.razor:155).
          • +
          • Where it's used: the Workshops array on ADCHome (:359) and the workshops section of ADCHome.razor (:85-161) only.

          ADCCollectionResult

          -

          MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Home · MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:282 · Level 1 · record (sealed, private)

          +

          MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Home · MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:297 · Level 1 · record (sealed, private)

            -
          • What it is: the one-property envelope the landing page deserializes the events response into: List<ADCEventInfo>? Items (:282). It exists because the API returns a collection envelope, not a bare array.
          • +
          • What it is: the one-property envelope the landing page deserializes the events response into: List<ADCEventInfo>? Items (:297). It exists because the API returns a collection envelope, not a bare array.
          • Depends on: ADCEventInfo (its element type), which is what puts it one level above the plain records.
          • Concept introduced: mirroring only the slice of the envelope you consume. The API's uniform collection contract is CollectionResult<T>, which carries more than a list. Rather than referencing that type, the page declares a minimal structural twin containing just Items, keeping the landing page free of any dependency on the API's shared contract assembly. [Rubric §9, API and Contract Design]: the wire format is honoured, the coupling is not.
          • -
          • Walkthrough: consumed in exactly one place, LoadEventAsync (:161): await client.GetFromJsonAsync<ADCCollectionResult>("events", ApiJsonOptions, _cts!.Token). The ApiJsonOptions field is a JsonSerializerOptions(JsonSerializerDefaults.Web) allocated once as static readonly (:19), which is what makes the camelCase wire names bind to the PascalCase record properties. Items is nullable and immediately coalesced to an empty collection at the call site (result?.Items ?? [], :166), so a null body, a null Items, and an empty list all take the same path.
          • +
          • Walkthrough: consumed in exactly one place, LoadEventAsync (:176): await client.GetFromJsonAsync<ADCCollectionResult>("events", ApiJsonOptions, _cts!.Token). The ApiJsonOptions field is a JsonSerializerOptions(JsonSerializerDefaults.Web) allocated once as static readonly (:34), which is what makes the camelCase wire names bind to the PascalCase record properties. Items is nullable and immediately coalesced to an empty collection at the call site (result?.Items ?? [], :181), so a null body, a null Items, and an empty list all take the same path.
          • Why it's built this way: GetFromJsonAsync returns null for an empty response body, so the nullable property plus the coalesce covers both failure shapes without a branch.
          • -
          • Where it's used: ADCHome.LoadEventAsync only (:161).
          • +
          • Where it's used: ADCHome.LoadEventAsync only (:176).

          ADCSponsorInfo

          -

          MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Home · MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:297 · Level 1 · record (sealed, private)

          +

          MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Home · MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:313 · Level 1 · record (sealed, private)

            -
          • What it is: the landing page's projection of one sponsor: Id, Name, Tier, LogoUrl?, WebsiteUrl?, Sort, and EventId (:297-304). Seven fields, exactly what the sponsor logo strip renders and sorts by.
          • +
          • What it is: the landing page's projection of one sponsor: Id, Name, Tier, LogoUrl?, WebsiteUrl?, Sort, and EventId (:313-320). Seven fields, exactly what the sponsor logo strip renders and sorts by.
          • Depends on: SponsorTier from MMCA.ADC.Conference.Shared.Sponsors (imported at :5). That one shared enum is the single first-party type the landing page's wire models reference, and it is what raises this record above level 0.
          • -
          • Concept introduced: sharing the enum, not the DTO. The page could have referenced the API's SponsorDTO and taken everything with it. Instead it declares its own seven-field record and imports only SponsorTier, because the tier is a domain vocabulary term whose numeric ordering is load-bearing here: Platinum = 0, Gold = 1, Silver = 2 (MMCA.ADC.Conference.Shared/Sponsors/SponsorTier.cs:15-21), so an OrderBy(g => g.Key) on the enum value yields package order without a lookup table (:215). Re-declaring the enum locally would have duplicated that ordering contract in a place no test guards. [Rubric §9, API and Contract Design] is the balance being struck: copy the shape, share the vocabulary.
          • -
          • Walkthrough: a positional record with no methods, used only inside LoadSponsorsAsync and the markup. Tier is the grouping key (:214), Sort then Name are the intra-tier tie-breakers (:218), EventId is the filter that scopes the strip to the featured event (:214), and LogoUrl/WebsiteUrl drive a four-way render fallback in the markup (ADCHome.razor:171-194): linked logo, linked name, bare logo, or bare name, depending on which of the two optional URLs are present. [Rubric §26, Front-End Security] is visible in that block: the outbound sponsor link carries Target="_blank" together with rel="noopener noreferrer" (ADCHome.razor:174), so a sponsor site can never reach back through window.opener. [Rubric §21, Accessibility]: the link also carries a localized aria-label built from the sponsor name (ADCHome.razor:175), and the logo image its Alt (ADCHome.razor:178), so a logo-only card is still announced.
          • -
          • Why it's built this way: Sort exists so organizers can order sponsors inside a tier by hand, and the code comment states why the sort is explicit at all (:208-209): tier ascending is package order, and Sort then Name breaks ties so the strip is deterministic rather than dependent on insertion order. StringComparer.CurrentCulture on the name tie-break (:218) keeps that alphabetical fallback correct under the selected culture.
          • -
          • Where it's used: the Items list of ADCSponsorCollectionResult (:295), the grouped _sponsorTiers field (:53), and the sponsor section of ADCHome.razor (:151-229).
          • +
          • Concept introduced: sharing the enum, not the DTO. The page could have referenced the API's SponsorDTO and taken everything with it. Instead it declares its own seven-field record and imports only SponsorTier, because the tier is a domain vocabulary term whose numeric ordering is load-bearing here: Platinum = 0, Gold = 1, Silver = 2 (MMCA.ADC.Conference.Shared/Sponsors/SponsorTier.cs:15-21), so an OrderBy(g => g.Key) on the enum value yields package order without a lookup table (:230). Re-declaring the enum locally would have duplicated that ordering contract in a place no test guards. [Rubric §9, API and Contract Design] is the balance being struck: copy the shape, share the vocabulary.
          • +
          • Walkthrough: a positional record with no methods, used only inside LoadSponsorsAsync and the markup. Tier is the grouping key (:229), Sort then Name are the intra-tier tie-breakers (:233), EventId is the filter that scopes the strip to the featured event (:228), and LogoUrl/WebsiteUrl drive a four-way render fallback in the markup (ADCHome.razor:274-295): linked logo, linked name, bare logo, or bare name, depending on which of the two optional URLs are present. [Rubric §26, Front-End Security] is visible in that block: the outbound sponsor link carries Target="_blank" together with rel="noopener noreferrer" (ADCHome.razor:276), so a sponsor site can never reach back through window.opener. [Rubric §21, Accessibility]: the link also carries a localized aria-label built from the sponsor name (ADCHome.razor:277), and the logo image its Alt (ADCHome.razor:280), so a logo-only card is still announced.
          • +
          • Why it's built this way: Sort exists so organizers can order sponsors inside a tier by hand, and the code comment states why the sort is explicit at all (:223-224): tier ascending is package order, and Sort then Name breaks ties so the strip is deterministic rather than dependent on insertion order. StringComparer.CurrentCulture on the name tie-break (:233) keeps that alphabetical fallback correct under the selected culture.
          • +
          • Where it's used: the Items list of ADCSponsorCollectionResult (:311), the grouped _sponsorTiers field (:68), and the sponsor section of ADCHome.razor (:250-331).

          ADCSponsorCollectionResult

          -

          MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Home · MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:295 · Level 2 · record (sealed, private)

          +

          MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Home · MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:311 · Level 2 · record (sealed, private)

            -
          • What it is: the envelope for the sponsors response: List<ADCSponsorInfo>? Items (:295). It is the sponsor-side twin of ADCCollectionResult, declared separately because C# records are not structurally typed.
          • +
          • What it is: the envelope for the sponsors response: List<ADCSponsorInfo>? Items (:311). It is the sponsor-side twin of ADCCollectionResult, declared separately because C# records are not structurally typed.
          • Depends on: ADCSponsorInfo, and transitively SponsorTier.
          • Concept introduced: nothing new; the minimal-envelope idea is taught under ADCCollectionResult.
          • -
          • Walkthrough: deserialized in LoadSponsorsAsync with the same shared ApiJsonOptions and the same cancellation token (:206), then reduced in one collection expression (:210-219): result?.Items ?? [] for the null-safe start, .Where(s => s.EventId == _event.Id) to scope to the featured event, .GroupBy(s => s.Tier), .OrderBy(g => g.Key) for package order, and a Select that materializes each group as a KeyValuePair<SponsorTier, IReadOnlyList<ADCSponsorInfo>> with its members ordered by Sort then Name (:216-218). The result lands in _sponsorTiers (:53), whose doc comment states the intent in one line: sponsors grouped by tier in package order, each group ordered by Sort then Name (:52).
          • -
          • Why it's built this way: the method-level remarks (:190-195) record the two safety properties. The sponsors endpoint is the same anonymous read path as the events call and already scopes anonymous callers to sponsors of published events (MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:72-89, whose specification resolves published event ids in MMCA.ADC.Conference.Application/Sponsors/UseCases/GetPublicSponsorFilter/GetPublicSponsorFilterHandler.cs:25-30); the client-side EventId filter is the second half, so a second published edition's sponsors never bleed onto this page. And any failure leaves the list empty, which falls back to the sponsorship call to action rather than a blank strip.
          • -
          • Where it's used: ADCHome.LoadSponsorsAsync only (:206).
          • +
          • Walkthrough: deserialized in LoadSponsorsAsync with the same shared ApiJsonOptions and the same cancellation token (:221), then reduced in one collection expression (:225-234): result?.Items ?? [] for the null-safe start, .Where(s => s.EventId == _event.Id) to scope to the featured event (:228), .GroupBy(s => s.Tier) (:229), .OrderBy(g => g.Key) for package order (:230), and a Select that materializes each group as a KeyValuePair<SponsorTier, IReadOnlyList<ADCSponsorInfo>> with its members ordered by Sort then Name (:231-233). The result lands in _sponsorTiers (:68), whose doc comment states the intent in one line: sponsors grouped by tier in package order, each group ordered by Sort then Name (:67).
          • +
          • Why it's built this way: the method-level remarks (:205-210) record the two safety properties. The sponsors endpoint is the same anonymous read path as the events call and already scopes anonymous callers to sponsors of published events (MMCA.ADC.Conference.API/Controllers/SponsorsController.cs:72-92, whose specification is built at :60-70 and resolves published event ids in MMCA.ADC.Conference.Application/Sponsors/UseCases/GetPublicSponsorFilter/GetPublicSponsorFilterHandler.cs:25-30); the client-side EventId filter is the second half, so a second published edition's sponsors never bleed onto this page. And any failure leaves the list empty, which falls back to the sponsorship call to action rather than a blank strip.
          • +
          • Where it's used: ADCHome.LoadSponsorsAsync only (:221).

          ConferenceUIModule

          MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI/ConferenceUIModule.cs:14 · Level 3 · class (sealed)

            -
          • What it is: the Conference module's UI descriptor. It contributes the navigation items for the whole conference capability (public Events/Sessions/Speakers/Sponsors, the claim-gated speaker Dashboard and QR page, and an organizer admin group covering Events, Sessions, Speakers, Categories, Questions, Rooms, Sponsors, and Session Selection) and exposes its assembly so the host can discover the module's routable Blazor components.
          • +
          • What it is: the Conference module's UI descriptor. It contributes the navigation items for the whole conference capability (public Events/Sessions/Speakers/Sponsors/Activities, the claim-gated speaker Dashboard and QR page, and an organizer admin group covering Events, Sessions, Speakers, Categories, Questions, Rooms, Sponsors, Activities, and Session Selection) and exposes its assembly so the host can discover the module's routable Blazor components.
          • Depends on: IUIModule (the contract it implements), NavItem and NavSection (the nav vocabulary from MMCA.Common.UI.Common), RoleNames (the Organizer role string), ConferenceRoutePaths (the URLs), plus MudBlazor Icons and System.Reflection.Assembly (externals) and the co-located ConferenceUIModule.resx / ConferenceUIModule.es.resx pair.
          • Concept introduced: the modular-UI descriptor, the front-end analogue of IModule. [Rubric §18, UI Architecture and Component Design] assesses whether UI is composed from cohesive, self-describing modules rather than a hard-coded master shell; a module declaring its own menu is exactly that, and it is the Open/Closed half of [Rubric §1, SOLID]: enabling a module adds its navigation with no edit to the shell. [Rubric §25, Navigation and Information Architecture] is served because the items are role- and claim-aware and grouped into sections. [Rubric §11, Security] applies with an important caveat: hiding a nav item is UX only. The services still enforce authorization server-side, so the claim and role here are not the security boundary. Per ADR-027 the Title and Group strings are resource keys, not literals: TitleResource: typeof(ConferenceUIModule) on every item tells the shared NavMenu to resolve them against the co-located .resx at render time, which the file's own comment records (:16-17).
          • -
          • Walkthrough: NavItems (:18-39) is an IReadOnlyList<NavItem> initialized with a collection expression in three tiers, fourteen items in all.
              -
            • Public (:21-24): four items for everyone, anonymous included, pointing at the /conference/... routes: Events, Sessions, Speakers, Sponsors. They carry no RequiredRole and no Section, so they default to NavSection.General (MMCA.Common.UI/Common/NavItem.cs:17).
            • -
            • Speaker (:27-28): the Dashboard and the QR page, both carrying RequiredClaim: "speaker_id" and Section: NavSection.User, so they appear only for a user whose JWT links them to a speaker record and they render in the user menu rather than the main list.
            • -
            • Organizer (:31-38): eight items, each carrying RoleNames.Organizer, Section: NavSection.Admin, and Group: "Nav.Group.Conference" so they fold into one labelled admin group, ending with the Session Selection entry (:38).
            • -
            • Assembly (:41) returns typeof(ConferenceUIModule).Assembly so the host's Blazor router can discover this library's routable components. Note that "Events", "Sessions", "Speakers", and "Sponsors" each appear twice in the list, once public and once organizer, differing only in route and gating: the same label serves two audiences with two destinations.
            • +
            • Walkthrough: NavItems (:18-41) is an IReadOnlyList<NavItem> initialized with a collection expression in three tiers, sixteen items in all.
                +
              • Public (:21-25): five items for everyone, anonymous included, pointing at the /conference/... routes: Events, Sessions, Speakers, Sponsors, Activities. They carry no RequiredRole and no Section, so they default to NavSection.General (MMCA.Common.UI/Common/NavItem.cs:17).
              • +
              • Speaker (:28-29): the Dashboard and the QR page, both carrying RequiredClaim: "speaker_id" and Section: NavSection.User, so they appear only for a user whose JWT links them to a speaker record and they render in the user menu rather than the main list.
              • +
              • Organizer (:32-40): nine items, each carrying RoleNames.Organizer, Section: NavSection.Admin, and Group: "Nav.Group.Conference" so they fold into one labelled admin group, ending with the Session Selection entry (:40).
              • +
              • Assembly (:43) returns typeof(ConferenceUIModule).Assembly so the host's Blazor router can discover this library's routable components. Note that "Events", "Sessions", "Speakers", "Sponsors", and "Activities" each appear twice in the list, once public and once organizer, differing only in route and gating: the same label serves two audiences with two destinations.
            • Why it's built this way: mirroring the backend IModule pattern on the UI side keeps the app extensible. A host that boots without the Conference module simply has no conference nav and no conference routes, with no conditional code anywhere in the shell. The class also leaves AppBarComponentTypes and LayoutComponentTypes at their interface defaults (MMCA.Common.UI/Common/Interfaces/IUIModule.cs:19-22): Conference contributes no app-bar badge or root overlay.
            • @@ -299,38 +338,45 @@

              DependencyInjection

            • Depends on: ConferenceUIModule and, through AddUIModule<T> (MMCA.Common.UI/DependencyInjection.cs:152-162), Scrutor's assembly-scanning API and the open generic IEntityService<TEntityDTO, TIdentifierType>. Then this module's own service contracts: IEventSpeakerUIService, ISessionSpeakerUIService, ISessionCategoryItemUIService, ISpeakerCategoryItemUIService, ISpeakerDashboardUIService, IOrganizerEventFeedbackUIService, IOrganizerSessionFeedbackUIService, ISessionSelectionUIService, ISpeakerLookupService, IEventLookupService, ICategoryItemLookupService, and IPublicLinkBuilder with its NavigationPublicLinkBuilder implementation.
            • Concept introduced: the extension(IServiceCollection) registration block, half convention and half explicit. [Rubric §3, Clean Architecture] and [Rubric §16, Maintainability] both come down to keeping wiring at the edges; this file is the module's one wiring point. It uses the C# preview extension-type syntax extension(IServiceCollection services) (:13) to hang AddConferenceUI (:19) off IServiceCollection, the same idiom every module's DependencyInjection uses. The convention half is delegated to AddUIModule<ConferenceUIModule>() (:23), which does two things in one call (MMCA.Common.UI/DependencyInjection.cs:155-161): a Scrutor scan of this assembly registering every IEntityService<,> implementation AsImplementedInterfaces().WithScopedLifetime(), and the singleton registration of the descriptor itself. Registering AsImplementedInterfaces is what makes a page able to inject the narrow per-entity interface rather than the open generic, and it means adding a new entity service needs no edit here.
            • Walkthrough: the scan runs first (:23), then the method registers by hand exactly the services the scan cannot see, because they do not implement IEntityService<,>: four child-entity managers for the join relationships (:26-29), the speaker dashboard service (:32), the two BR-53 organizer-feedback moderation services (:35-36), the session-selection decision-support service (:39), and three cross-module lookup services (:42-44). It then registers IPublicLinkBuilder as NavigationPublicLinkBuilder (:49) and returns services for chaining (:51). Every explicit registration is AddScoped; only the descriptor is a singleton, which is correct because it is immutable data.
            • -
            • Why it's built this way: scanning the uniform bulk and spelling out the one-off collaborators keeps registration short without hiding the non-trivial wiring. One such subtlety is documented inline (:46-48): the public share-link builder resolves against the browser origin by default, but the MAUI head re-registers IPublicLinkBuilder after this call so last-registration-wins points shared links at the configured public web URL. That ordering dependency is exactly the kind of thing that belongs in a comment next to the registration.
            • +
            • Why it's built this way: scanning the uniform bulk and spelling out the one-off collaborators keeps registration short without hiding the non-trivial wiring. One such subtlety is documented inline (:46-48): the public share-link builder resolves against the browser origin by default, but the MAUI head re-registers IPublicLinkBuilder after this call so last-registration-wins points shared links at the configured public web URL (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/MauiProgram.cs:105-107, citing ADR-042). That ordering dependency is exactly the kind of thing that belongs in a comment next to the registration.
            • Where it's used: called once during startup by each of the three UI heads: the Blazor Server host (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:83), the WebAssembly client (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web.Client/Program.cs:63), and the MAUI host (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/MauiProgram.cs:97), alongside the other modules' AddXxxUI() extensions.

            ADCHome

            -

            MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Home · MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:17 · Level 9 · class (sealed partial component)

            +

            MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Home · MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:18 · Level 9 · class (sealed partial component)

              -
            • What it is: the conference landing page: hero with a live countdown, keynote, track catalogue, sponsor strip with a sponsorship call to action, and venue block. It fetches the published events list to find which event to feature, classifies that event as Upcoming/Live/Ended, loads that event's sponsors, and renders the rest from compiled-in editorial content. It is shared verbatim by the Web and MAUI heads, and its class doc records that both heads serve the static images from their own site root, so neither overrides ImageBasePath today (:10-16).
            • -
            • Depends on: ADCCollectionResult, ADCEventInfo, ADCSponsorCollectionResult, ADCSponsorInfo (the API models), EventPhase, KeynoteSpeakerInfo, ConferenceTrackInfo (the content records, all private inner types of this class), CurrentEventSelector from MMCA.ADC.Conference.Shared.Events (:4), SponsorTier (:5), and ConferenceRoutePaths for the "see all sponsors" link (ADCHome.razor:201). Externals: IHttpClientFactory and GetFromJsonAsync (:1, :27-28), IStringLocalizer<ADCHome> injected in the markup as L (ADCHome.razor:1), System.Threading.Timer, TimeZoneInfo, MudBlazor, and the Blazor RendererInfo API. It composes one first-party child component, HomeCountdown (ADCHome.razor:40), which lives in the same folder as a single .razor file with no code-behind.
            • -
            • Concept introduced: rendering correctly across the prerender and interactive passes. [Rubric §23, Front-End Performance and Rendering] assesses whether a page avoids wasted renders and blocking work; this component is the chapter's clearest case study, and both of its decisions were learned the hard way, as the code comments record.
                -
              • Skip the fetch during prerender. OnInitializedAsync checks RendererInfo.IsInteractive and, when false, sets _isLoading = false, computes the countdown from defaults, and returns without touching the network (:101-106). The comment (:96-100) states why: an untimed server-side call to a cold or unreachable backend would block the prerender, and therefore the page load and the post-login NavigateTo("/"), indefinitely. The static fallback renders immediately and the interactive pass loads the real event. [Rubric §29, Resilience] is the same point from the availability angle.
              • -
              • Fence the per-second re-render. The ticking digits live in the HomeCountdown child, which owns its own timer, so this page arms only a single one-shot Timer for the Live-to-Ended flip (:128-143). The comment at :111-112 records the prior behaviour: a 1-second timer that re-rendered the entire landing page, the largest static page in the app, for the whole event, per circuit, just to catch one transition. The child goes further still: it ticks once a minute while more than 65 minutes remain and switches to once a second only for the final hour (HomeCountdown.razor:32, :52-59, :70-74). - Three more rubric threads run through it. [Rubric §22, Responsive and Cross-Browser/Device]: one component compiles into the Blazor Server, WebAssembly, and MAUI heads, with the per-head difference reduced to the ImageBasePath parameter (:35-36). [Rubric §27, Internationalization]: user-facing chrome resolves through L[...], while three strings carry explicit // i18n: allow markers with reasons (the brand name :64, the postal address :68, the editorial content block :307-308). [Rubric §20, Design System and Theming]: the page's scoped stylesheet is a single shared copy rendered by both heads, and an architecture fitness test embeds it and fails the build if it re-hardcodes the brand hex instead of using var(--mmca-primary) (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/BrandColorTokenTests.cs:14-17, MMCA.ADC.Architecture.Tests.csproj:11-13).
              • +
              • What it is: the conference landing page: hero with a live countdown and a ticketing button, pre-conference workshops, keynote, track catalogue, sponsor strip with a sponsorship call to action, and venue block. It fetches the published events list to find which event to feature, classifies that event as Upcoming/Live/Ended, loads that event's sponsors, and renders the rest from compiled-in editorial content. It is shared verbatim by the Web and MAUI heads, and its class doc records that both heads serve the static images from their own site root, so neither overrides ImageBasePath today (:10-17).

                +
              • +
              • Depends on: ADCCollectionResult, ADCEventInfo, ADCSponsorCollectionResult, ADCSponsorInfo (the API models), EventPhase, KeynoteSpeakerInfo, ConferenceTrackInfo, PreConferenceWorkshopInfo (the content records, all private inner types of this class), CurrentEventSelector from MMCA.ADC.Conference.Shared.Events (:4), SponsorTier (:5), and ConferenceRoutePaths for the "see all sponsors" link (ADCHome.razor:303). Externals: IHttpClientFactory and GetFromJsonAsync (:1, :42-43), IStringLocalizer<ADCHome> injected in the markup as L (ADCHome.razor:1), System.Threading.Timer, TimeZoneInfo, MudBlazor, and the Blazor RendererInfo API. It composes one first-party child component, HomeCountdown (ADCHome.razor:40), which lives in the same folder as a single .razor file with no code-behind.

                +
              • +
              • Concept introduced: rendering correctly across the prerender and interactive passes. [Rubric §23, Front-End Performance and Rendering] assesses whether a page avoids wasted renders and blocking work; this component is the chapter's clearest case study, and both of its decisions are recorded in the code comments.

                +
                  +
                • Skip the fetch during prerender. OnInitializedAsync checks RendererInfo.IsInteractive and, when false, sets _isLoading = false, computes the countdown from defaults, and returns without touching the network (:116-121). The comment (:111-115) states why: an untimed server-side call to a cold or unreachable backend would block the prerender, and therefore the page load and the post-login NavigateTo("/"), indefinitely. The static fallback renders immediately and the interactive pass loads the real event. [Rubric §29, Resilience] is the same point from the availability angle.
                • +
                • Fence the per-second re-render. The ticking digits live in the HomeCountdown child, which owns its own timer, so this page arms only a single one-shot Timer for the Live-to-Ended flip (:143-158). The comment at :126-127 records the alternative: a 1-second timer would re-render the entire landing page, the largest static page in the app, for the whole event, per circuit, just to catch one transition. The child goes further still: it ticks once a minute while more than 65 minutes remain and switches to once a second only for the final hour (HomeCountdown.razor:32, :55, :59, :73).
                +

                Three more rubric threads run through it. [Rubric §22, Responsive and Cross-Browser/Device]: one component compiles into the Blazor Server, WebAssembly, and MAUI heads, with the per-head difference reduced to the ImageBasePath parameter (:50-51). [Rubric §27, Internationalization]: user-facing chrome resolves through L[...], while four strings carry explicit // i18n: allow markers with reasons (the ticketing URL :31, the brand name :79, the postal address :83, the editorial content block :323-324). [Rubric §20, Design System and Theming]: the page's scoped stylesheet is a single shared copy rendered by both heads, and an architecture fitness test embeds it and fails the build if it re-hardcodes the brand hex instead of using var(--mmca-primary) (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/BrandColorTokenTests.cs:12-17, MMCA.ADC.Architecture.Tests.csproj:11-13).

              • -
              • Walkthrough, in lifecycle order:
                  -
                • State (:45-55): a CancellationTokenSource, the one-shot _phaseTimer, the computed _startUtc/_endUtc, _phase, the nullable _event, the grouped _sponsorTiers (:53), _isLoading (starting true), and a _disposed guard the timer callback checks.
                • -
                • Derived display properties (:64-71): EventName, EventDescription, VenueAddress, and MapSearchUrl are each _event?.X ?? <fallback>, so the page is fully renderable before and without a successful fetch. MapSearchUrl builds a Google Maps search URL with Uri.EscapeDataString over the address (:70-71).
                • -
                • HeroTitleParts() (:78-90): splits the event name so the hero can accent the keyword between "Atlanta " and " Conference" (in "2026 Atlanta Developers Conference" it accents "Developers"). It uses IndexOf/LastIndexOf with StringComparison.Ordinal and falls back to rendering the whole name plain when the name does not match the brand shape, which is why an arbitrary event name never renders broken markup.
                • -
                • OnInitializedAsync (:92-114): creates the CTS, takes the prerender short-circuit described above, otherwise awaits LoadEventAsync() then LoadSponsorsAsync() in sequence (:108-109, the sponsor call needs the featured event id) and arms the phase timer (:113).
                • -
                • LoadEventAsync (:156-185): creates the named "APIClient" from IHttpClientFactory (:160), deserializes into ADCCollectionResult under the cancellation token (:161), and picks the event with CurrentEventSelector.SelectCurrentOrNext(...) passing four accessor lambdas plus DateTime.UtcNow (:165-170). The comment at :163-164 is the reason it is not a FirstOrDefault: the anonymous endpoint returns published events unordered, so a naive first-item pick would pin the oldest seeded event. Two catch arms are deliberately silent: OperationCanceledException means the component was disposed mid-load (:172), HttpRequestException means the API is unavailable and the fallback content stands (:176). The finally block always clears _isLoading and recomputes the countdown (:180-184), so no failure path leaves a spinner on screen.
                • -
                • LoadSponsorsAsync (:196-229): returns immediately when no event was featured (:198-201), then runs the same anonymous read path against sponsors and reduces the payload to the tier-grouped list described under ADCSponsorCollectionResult. Its two catch arms mirror LoadEventAsync and both leave _sponsorTiers empty, which is a supported render state rather than an error state.
                • -
                • UpdateCountdown (:231-261): converts the event's local start and end into UTC using TimeZoneInfo.FindSystemTimeZoneById(timeZoneId) with "America/New_York" as the default (:237, :244), calling CurrentEventSelector.ToUtc rather than ConvertTimeToUtc because, as the comment records (:241-243), the midnight end boundary does not exist in zones that transition at 00:00 and a raw conversion would throw out of the render path (MMCA.ADC.Conference.Shared/Events/CurrentEventSelector.cs:96). An unknown zone id falls back to treating the local values as UTC (:248-252), then _phase is assigned from the switch described under EventPhase.
                • -
                • Phase timing (:117-154): OnCountdownElapsedAsync is the EventCallback the HomeCountdown child raises at zero (HomeCountdown.razor:82), which recomputes the phase, re-arms, and calls InvokeAsync(StateHasChanged) (:117-122). ArmPhaseTimerForEventEnd returns unless the phase is Live and the remaining time is positive, then disposes any prior timer and schedules one callback at untilEnd with Timeout.InfiniteTimeSpan as the period, meaning fire once and never repeat (:128-143). OnEventEnded checks _disposed before re-rendering (:145-154).
                • -
                • FormatEventDate (:263-270): formats the date with a pattern read from a resource (L["Hero.DateFormat"]) against CultureInfo.CurrentCulture, so both the layout and the month names follow the selected language (ADR-027).
                • -
                • Dispose (:272-279): sets _disposed, cancels and disposes the CTS, and both stops (Change(-1, -1)) and disposes the phase timer. Stopping before disposing is what prevents a callback already in flight from touching a torn-down component.
                • +
                • Walkthrough, in lifecycle order:

                  +
                    +
                  • Constants and state (:20-70): the PreConferenceTicketingUrl constant placed first because SA1203 requires constants before fields (:24-25, :30-31), the shared ApiJsonOptions and EventStartTime (:34-35), the FallbackStartDate (:40), then a CancellationTokenSource, the one-shot _phaseTimer, the computed _startUtc/_endUtc, _phase, the nullable _event, the grouped _sponsorTiers (:68), _isLoading (starting true), and a _disposed guard the timer callback checks.
                  • +
                  • Derived display properties (:79-86): EventName, EventDescription, VenueAddress, and MapSearchUrl are each _event?.X ?? <fallback>, so the page is fully renderable before and without a successful fetch. MapSearchUrl builds a Google Maps search URL with Uri.EscapeDataString over the address (:85-86).
                  • +
                  • HeroTitleParts() (:93-105): splits the event name so the hero can accent the keyword between "Atlanta " and " Conference" (in "2026 Atlanta Developers Conference" it accents "Developers"). It uses IndexOf/LastIndexOf with StringComparison.Ordinal and falls back to rendering the whole name plain when the name does not match the brand shape (:102-104), which is why an arbitrary event name never renders broken markup.
                  • +
                  • OnInitializedAsync (:107-129): creates the CTS, takes the prerender short-circuit described above, otherwise awaits LoadEventAsync() then LoadSponsorsAsync() in sequence (:123-124, the sponsor call needs the featured event id) and arms the phase timer (:128).
                  • +
                  • LoadEventAsync (:171-200): creates the named "APIClient" from IHttpClientFactory (:175), deserializes into ADCCollectionResult under the cancellation token (:176), and picks the event with CurrentEventSelector.SelectCurrentOrNext(...) passing four accessor lambdas plus DateTime.UtcNow (:180-185). The comment at :178-179 is the reason it is not a FirstOrDefault: the anonymous endpoint returns published events unordered, so a naive first-item pick would pin the oldest seeded event. Two catch arms are deliberately silent: OperationCanceledException means the component was disposed mid-load (:187), HttpRequestException means the API is unavailable and the fallback content stands (:191). The finally block always clears _isLoading and recomputes the countdown (:195-199), so no failure path leaves a spinner on screen.
                  • +
                  • LoadSponsorsAsync (:211-244): returns immediately when no event was featured (:213-216), then runs the same anonymous read path against sponsors and reduces the payload to the tier-grouped list described under ADCSponsorCollectionResult. Its two catch arms mirror LoadEventAsync (:236, :240) and both leave _sponsorTiers empty, which is a supported render state rather than an error state.
                  • +
                  • UpdateCountdown (:246-276): converts the event's local start and end into UTC using TimeZoneInfo.FindSystemTimeZoneById(timeZoneId) with "America/New_York" as the default (:252, :259), calling CurrentEventSelector.ToUtc rather than ConvertTimeToUtc because, as the comment records (:256-258), the midnight end boundary does not exist in zones that transition at 00:00 and a raw conversion would throw out of the render path (MMCA.ADC.Conference.Shared/Events/CurrentEventSelector.cs:96-106). An unknown zone id falls back to treating the local values as UTC (:263-267), then _phase is assigned from the switch described under EventPhase.
                  • +
                  • Phase timing (:132-169): OnCountdownElapsedAsync is the EventCallback the HomeCountdown child raises at zero (HomeCountdown.razor:82), which recomputes the phase, re-arms, and calls InvokeAsync(StateHasChanged) (:132-137). ArmPhaseTimerForEventEnd returns unless the phase is Live and the remaining time is positive, then disposes any prior timer and schedules one callback at untilEnd with Timeout.InfiniteTimeSpan as the period, meaning fire once and never repeat (:143-158). OnEventEnded checks _disposed before re-rendering (:160-169).
                  • +
                  • FormatEventDate (:278-285): formats the date with a pattern read from a resource (L["Hero.DateFormat"]) against CultureInfo.CurrentCulture, so both the layout and the month names follow the selected language (ADR-027).
                  • +
                  • Dispose (:287-294): sets _disposed, cancels and disposes the CTS, and both stops (Change(-1, -1)) and disposes the phase timer. Stopping before disposing is what prevents a callback already in flight from touching a torn-down component.
                • -
                • Why it's built this way: the landing page is the app's most-hit surface and the post-login destination, so its correctness budget is dominated by two failure modes that have nothing to do with its content: a slow backend blocking the prerender, and a per-second render loop multiplied by every connected circuit. Both are solved structurally (skip the fetch, fence the tick) rather than by tuning, and every dynamic block has a defined empty state, so the page is never blank.
                • -
                • Where it's used: resolved as the home component by each head's ADCHomePageContent. The Web client points ComponentType straight at this shared component (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web.Client/Pages/ADCHomePageContent.cs:13, registered at .../MMCA.ADC.UI.Web.Client/Program.cs:49 and .../MMCA.ADC.UI.Web/Program.cs:60); the MAUI head points at a thin local wrapper page that renders <ADCHome /> with no parameters (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/Pages/ADCHomePageContent.cs:10, MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/Pages/ADCHome.razor:6). [Rubric §28, Front-End Testing]: the page has no bUnit test, but two suites hold it to account from outside, the brand-token fitness test above and the E2E pseudo-localization sentinel, which probes this page's Location.OpenInMaps resource precisely because that button is static markup rather than event-load-gated (MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/PseudoLocalizationTests.cs:46-50).
                • -
                • Caveats / not-in-source: the page's own countdown window is not identical to the selector's. UpdateCountdown starts the event at EventStartTime = 08:00 local (:20, :235), while CurrentEventSelector starts its live window at midnight local (MMCA.ADC.Conference.Shared/Events/CurrentEventSelector.cs:5-6). Both end at midnight after the last day. So between midnight and 08:00 on day one, the selector already treats the event as live while the hero still shows a countdown. Whether that is intended is not determinable from source. Also note the two hard-coded fallbacks used when no event loads: the date 2026-10-17, whose comment warns it must track the published event date or the hero date and countdown visibly jump once the real event arrives (:22-25), and the venue address (:68).
                • +
                • Why it's built this way: the landing page is the app's most-hit surface and the post-login destination, so its correctness budget is dominated by two failure modes that have nothing to do with its content: a slow backend blocking the prerender, and a per-second render loop multiplied by every connected circuit. Both are solved structurally (skip the fetch, fence the tick) rather than by tuning, and every dynamic block has a defined empty state, so the page is never blank. The two conditional calls to action follow the same discipline: the hero ticketing button (ADCHome.razor:67-80) and the sponsorship packet block (ADCHome.razor:307-329) each render only when the featured event publishes the corresponding URL, and hide entirely otherwise rather than offering a dead link.

                  +
                • +
                • Where it's used: resolved as the home component by each head's ADCHomePageContent. The Web client points ComponentType straight at this shared component (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web.Client/Pages/ADCHomePageContent.cs:13, registered at .../MMCA.ADC.UI.Web.Client/Program.cs:49 and .../MMCA.ADC.UI.Web/Program.cs:60); the MAUI head points at a thin local wrapper page that renders <ADCHome /> with no parameters (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/Pages/ADCHomePageContent.cs:10, MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/Pages/ADCHome.razor:6). [Rubric §28, Front-End Testing]: the page has no bUnit test, but two suites hold it to account from outside, the brand-token fitness test above and the E2E pseudo-localization sentinel, which probes this page's Location.OpenInMaps resource precisely because that button is static markup rather than event-load-gated (MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/PseudoLocalizationTests.cs:46-56).

                  +
                • +
                • Caveats / not-in-source: the page's own countdown window is not identical to the selector's. UpdateCountdown starts the event at EventStartTime = 08:00 local (:35, :250), while CurrentEventSelector starts its live window at midnight local (MMCA.ADC.Conference.Shared/Events/CurrentEventSelector.cs:69). Both end at midnight after the last day. So between midnight and 08:00 on day one, the selector already treats the event as live while the hero still shows a countdown. Whether that is intended is not determinable from source. Also note the two hard-coded fallbacks used when no event loads: the date 2026-10-17, whose comment warns it must track the published event date or the hero date and countdown visibly jump once the real event arrives (:37-40), and the venue address (:83).

                  +

                ScorePollSignal

                @@ -993,42 +1039,76 @@

                EventLookupService

                (EventLookupService.cs:20), and the same absence of memoization: every call re-fetches the full event collection.
              -

              ICategoryItemUIService

              +

              IActivityUIService

              -

              MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/ICategoryItemUIService.cs:9 · Level 3 · interface

              +

              MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/IActivityUIService.cs:9 · Level 3 · interface

                -
              • What it is: the UI-service contract for the categoryitems REST resource. It is an empty marker - interface, public interface ICategoryItemUIService : IEntityService<CategoryItemDTO, CategoryItemIdentifierType> - (ICategoryItemUIService.cs:9-11), that adds no members of its own.
              • +
              • What it is: the UI-service contract for the activities REST resource (the conference's social + and networking programme). It is an empty marker interface, + public interface IActivityUIService : IEntityService<ActivityDTO, ActivityIdentifierType> + (IActivityUIService.cs:9-11), that adds no members of its own.
              • Depends on: IEntityService<TEntityDTO, TIdentifierType> - (the shared CRUD contract, Level 2, imported from MMCA.Common.UI.Common.Interfaces at - ICategoryItemUIService.cs:2) and CategoryItemDTO - (the transported shape, Level 1). CategoryItemIdentifierType is the module id alias.
              • + (the shared CRUD contract, imported from MMCA.Common.UI.Common.Interfaces at + IActivityUIService.cs:2) and ActivityDTO (the + transported shape, from MMCA.ADC.Conference.Shared.Activities at IActivityUIService.cs:1). + ActivityIdentifierType is the module id alias, int + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:5).
              • Concept introduced, the per-entity marker UI-service interface. [Rubric §18, UI Architecture] - (assesses whether the front end talks to a typed service abstraction rather than raw HttpClient; + (assesses whether the front end talks to a typed service abstraction rather than a raw HttpClient; here every Blazor page injects an interface, never the concrete HTTP class). [Rubric §1, SOLID] - (the marker gives each aggregate its own injection point so a page depends only on the contract it - needs, even though the shape is inherited). The generic CRUD surface all comes from + (the marker gives each aggregate its own injection point, so a page depends only on the contract it + needs even though the shape is entirely inherited). The generic CRUD surface all comes from IEntityService<TEntityDTO, TIdentifierType>; see that type for the mechanism. There is a second, load-bearing reason for the body-less - specialization: registration is done by a Scrutor scan, not by hand. AddUIModule<ConferenceUIModule>() - scans the Conference UI assembly for every IEntityService<,> implementation and registers it - AsImplementedInterfaces() with a scoped lifetime + specialization: registration is done by a Scrutor assembly scan, not by hand. + AddUIModule<ConferenceUIModule>() scans the Conference UI assembly for every IEntityService<,> + implementation and registers it AsImplementedInterfaces() with a scoped lifetime (MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:155-159, called at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:23), so the named - marker is exactly what a page gets to inject.
              • -
              • Walkthrough: no members. The whole contract is "be an IEntityService bound to CategoryItemDTO - plus CategoryItemIdentifierType, under a name pages can inject". The doc comment - (ICategoryItemUIService.cs:6-8) states plainly that it "uses generic CRUD".
              • + marker is exactly what a page gets to inject. Every plain-CRUD sibling in this group repeats this + shape. +
              • Walkthrough: no members. The whole contract is "be an IEntityService bound to ActivityDTO + plus ActivityIdentifierType, under a name pages can inject". The doc comment + (IActivityUIService.cs:6-8) states plainly that it "uses generic CRUD".
              • Why it's built this way: a named per-entity interface (rather than injecting the open generic - directly) keeps the scan's AsImplementedInterfaces() registration unambiguous and lets a specific + directly) keeps the scan's AsImplementedInterfaces() registration unambiguous, and it lets one entity later grow an extra method without disturbing the others (exactly what IEventUIService, IRoomUIService, and ISpeakerUIService did).
              • +
              • Where it's used: implemented by ActivityService (Level 4); injected into + the organizer activity list, detail, and create pages + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Activity/ActivityList.razor.cs:24, + Pages/Activity/ActivityDetail.razor.cs:20, Pages/Activity/ActivityCreate.razor.cs:18) and into + the anonymous public activity page + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicActivityList.razor.cs:26). + Note that the same contract serves both audiences: the organizer list sits behind + [Authorize(Roles = "Organizer")] (Pages/Activity/ActivityList.razor:2) while the public page is + anonymous (Pages/Public/PublicActivityList.razor:1), and the server, not the client, is what scopes + non-privileged callers to published events (Pages/Public/PublicActivityList.razor.cs:11-18).
              • +
              +

              ICategoryItemUIService

              +
              +

              MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/ICategoryItemUIService.cs:9 · Level 3 · interface

              +
              +
                +
              • What it is: the UI-service contract for the categoryitems REST resource, an empty marker over + IEntityService<TEntityDTO, TIdentifierType> + bound to CategoryItemDTO and + CategoryItemIdentifierType (ICategoryItemUIService.cs:9-11).
              • +
              • Depends on: IEntityService<TEntityDTO, TIdentifierType> + (imported at ICategoryItemUIService.cs:2) and + CategoryItemDTO (imported at + ICategoryItemUIService.cs:1).
              • +
              • Concept: identical shape to IActivityUIService; see it for the + marker-interface and Scrutor-scan rationale. [Rubric §18, UI Architecture].
              • +
              • Walkthrough: no members. The doc comment (ICategoryItemUIService.cs:6-8) repeats the "uses + generic CRUD" formula.
              • Where it's used: implemented by CategoryItemService (Level 4); injected into the conference-category detail page, which edits the items belonging to a category - (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/ConferenceCategory/ConferenceCategoryDetail.razor.cs:16).
              • + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/ConferenceCategory/ConferenceCategoryDetail.razor.cs:16). + It is the one CRUD marker in this family with a single consumer: category items are only ever managed + from inside their parent category, never as a top-level list.

              IConferenceCategoryUIService

              @@ -1041,17 +1121,18 @@

              IConferenceCategoryUIService

              ConferenceCategoryIdentifierType (IConferenceCategoryUIService.cs:9-11).
            • Depends on: IEntityService<TEntityDTO, TIdentifierType> and ConferenceCategoryDTO.
            • -
            • Concept: identical shape to ICategoryItemUIService; see it for the +
            • Concept: identical shape to IActivityUIService; see it for the marker-interface and Scrutor-scan rationale. [Rubric §18, UI Architecture] and [Rubric §16, Maintainability] (a new aggregate resource costs one empty interface plus one thin class).
            • Walkthrough: no members (doc comment IConferenceCategoryUIService.cs:6-8).
            • Where it's used: implemented by ConferenceCategoryService; injected into the conference-category list, detail, and create pages - (Pages/ConferenceCategory/ConferenceCategoryList.razor.cs, - ConferenceCategoryDetail.razor.cs, ConferenceCategoryCreate.razor.cs) and into the speaker detail - page, which reads the category tree to tag a speaker - (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerDetail.razor.cs).
            • + (Pages/ConferenceCategory/ConferenceCategoryList.razor.cs:16, + Pages/ConferenceCategory/ConferenceCategoryDetail.razor.cs:15, + Pages/ConferenceCategory/ConferenceCategoryCreate.razor.cs:11) and into the speaker detail page, + which reads the category tree to tag a speaker + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerDetail.razor.cs:25).

            IEventUIService

            @@ -1064,43 +1145,43 @@

            IEventUIService

          • Depends on: IEntityService<TEntityDTO, TIdentifierType> bound to EventDTO, and RefreshFromSessionizeResultDTO (the - refresh outcome, Level 0). BCL Task, CancellationToken, byte[].
          • + refresh outcome). BCL Task, CancellationToken, byte[].
          • Concept introduced, extending the generic UI service with resource-specific verbs. [Rubric §9, API & Contract Design] (assesses whether non-CRUD state transitions get first-class, intention-revealing operations instead of being forced through a generic update). Publish and unpublish are lifecycle transitions on an event, and refresh triggers an external Sessionize sync, none of which is a CRUD Update, so they earn their own methods mapped to dedicated WebAPI endpoints - (the doc comment, IEventUIService.cs:6-9, says exactly this). The second concept is on the + (the doc comment, IEventUIService.cs:6-9, says exactly this). The second concept is in the signatures: both transitions take an optional byte[]? rowVersion (IEventUIService.cs:12,14), the optimistic-concurrency token the client echoes back from the EventDTO it acted on, so a publish decided against a stale - view surfaces as 409 Conflict rather than applying silently (the contract for that round-trip is - EventTransitionRequest, documented at - MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/EventTransitionRequest.cs:5-17, + view surfaces as 409 Conflict rather than applying silently. The contract for that round-trip is + EventTransitionRequest + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/EventTransitionRequest.cs:5-18, rationale in ADR-035). That - makes this contract a [Rubric §8, Data Architecture] touch point as well: concurrency control reaches - all the way up into the UI service signature instead of stopping at the database.
          • + makes this contract a [Rubric §8, Data Architecture] touch point as well: concurrency control + reaches all the way up into the UI service signature instead of stopping at the database.
          • Walkthrough: three declared members.
              -
            • PublishAsync(EventIdentifierType id, byte[]? rowVersion = null, CancellationToken) (line 12), - returns Task<bool>.
            • -
            • UnpublishAsync(EventIdentifierType id, byte[]? rowVersion = null, CancellationToken) (line 14), - the mirror transition, same shape.
            • -
            • RefreshFromSessionizeAsync(EventIdentifierType id, CancellationToken) (line 16), returns - Task<RefreshFromSessionizeResultDTO?> (the sync summary, nullable when the call yields no body). - It takes no rowVersion: a Sessionize pull is not a lifecycle transition on the event row.
            • +
            • PublishAsync(EventIdentifierType id, byte[]? rowVersion = null, CancellationToken) + (IEventUIService.cs:12), returns Task<bool>.
            • +
            • UnpublishAsync(EventIdentifierType id, byte[]? rowVersion = null, CancellationToken) + (IEventUIService.cs:14), the mirror transition, same shape.
            • +
            • RefreshFromSessionizeAsync(EventIdentifierType id, CancellationToken) (IEventUIService.cs:16), + returns Task<RefreshFromSessionizeResultDTO?> (the sync summary, nullable when the call yields no + body). It takes no rowVersion: a Sessionize pull is not a lifecycle transition on the event row.
          • Why it's built this way: the extra verbs live on the interface so the concrete EventService is the only place that knows the endpoint URLs; pages stay transport-agnostic. The rowVersion parameter is optional so a caller that has no token (or does not - care) still compiles and falls back to the server's fresh-load domain guard - (EventTransitionRequest.cs:10-11).
          • + care) still compiles and falls back to the server's fresh-load domain guard, which the request record + spells out (EventTransitionRequest.cs:10-11).
          • Where it's used: implemented by EventService (Level 4); injected into the event - list, detail, and create pages plus the public event browse pages - (Pages/Event/EventList.razor.cs, Pages/Event/EventDetail.razor.cs, Pages/Event/EventCreate.razor.cs, - Pages/Public/PublicEventList.razor.cs, Pages/Public/PublicEventDetail.razor.cs) and into the session - list pages that need the owning event (Pages/Session/SessionList.razor.cs, - Pages/Public/PublicSessionList.razor.cs).
          • + list, detail, and create pages (Pages/Event/EventList.razor.cs:21, + Pages/Event/EventDetail.razor.cs:19, Pages/Event/EventCreate.razor.cs:15), the public event browse + pages (Pages/Public/PublicEventList.razor.cs:34, Pages/Public/PublicEventDetail.razor.cs:18), and + the session list pages that need the owning event (Pages/Session/SessionList.razor.cs:24, + Pages/Public/PublicSessionList.razor.cs:32).

          IQuestionUIService

          @@ -1112,15 +1193,17 @@

          IQuestionUIService

          bound to QuestionDTO (IQuestionUIService.cs:9-11).
        • Depends on: IEntityService<TEntityDTO, TIdentifierType> and QuestionDTO.
        • -
        • Concept: same marker shape as ICategoryItemUIService; see there. +
        • Concept: same marker shape as IActivityUIService; see there. [Rubric §18, UI Architecture].
        • Walkthrough: no members (doc comment IQuestionUIService.cs:6-8).
        • Where it's used: injected into the question list, detail, and create pages - (Pages/Question/QuestionList.razor.cs, QuestionDetail.razor.cs, QuestionCreate.razor.cs), into - both organizer feedback pages, which need the question text to label the answers - (Pages/Feedback/OrganizerEventFeedback.razor.cs, Pages/Feedback/OrganizerSessionFeedback.razor.cs), - and into the speaker detail page (Pages/Speaker/SpeakerDetail.razor.cs). The concrete implementation - is a thin EntityServiceBase subclass picked up by the assembly scan.
        • + (Pages/Question/QuestionList.razor.cs:16, Pages/Question/QuestionDetail.razor.cs:15, + Pages/Question/QuestionCreate.razor.cs:11), into both organizer feedback pages, which need the + question text to label the answers (Pages/Feedback/OrganizerEventFeedback.razor.cs:17, + Pages/Feedback/OrganizerSessionFeedback.razor.cs:17), and into the speaker detail page + (Pages/Speaker/SpeakerDetail.razor.cs:27). The concrete implementation, + QuestionService, is a thin EntityServiceBase subclass picked up by the + assembly scan.

        IRoomUIService

        @@ -1130,22 +1213,24 @@

        IRoomUIService

      • What it is: the UI-service contract for the rooms resource. It extends the generic CRUD surface with a single specialized delete that also carries the owning event id (IRoomUIService.cs:9-13).
      • Depends on: IEntityService<TEntityDTO, TIdentifierType> - bound to RoomDTO. RoomIdentifierType and - EventIdentifierType id aliases.
      • + bound to RoomDTO (note that RoomDTO lives in the + MMCA.ADC.Conference.Shared.Events namespace, IRoomUIService.cs:1, because a room belongs to an + event). RoomIdentifierType and EventIdentifierType id aliases.
      • Concept: [Rubric §9, API & Contract Design] (assesses contracts that carry the parameters the server actually requires). A room is scoped to an event, so its delete needs the EventIdentifierType the WebAPI endpoint expects; the generic DeleteAsync(id) would omit it. The doc comment (IRoomUIService.cs:11) states the added overload "passes the required event ID to the - API". This is the UI-side counterpart to the child-scoped delete used by the join and - organizer-feedback services.
      • + API". This is the UI-side counterpart to the child-scoped delete used by the join services and by the + organizer-feedback services in this same part.
      • Walkthrough: one added member, - DeleteAsync(RoomIdentifierType roomId, EventIdentifierType eventId, CancellationToken) (line 12), - returning Task<bool>. It supplements, rather than replaces, the inherited single-argument delete.
      • -
      • Where it's used: injected into the room list, detail, and create pages - (Pages/Room/RoomList.razor.cs, RoomDetail.razor.cs, RoomCreate.razor.cs) and into the session - create/detail and public session detail pages, which render the room a session is scheduled in - (Pages/Session/SessionCreate.razor.cs, Pages/Session/SessionDetail.razor.cs, - Pages/Public/PublicSessionDetail.razor.cs).
      • + DeleteAsync(RoomIdentifierType roomId, EventIdentifierType eventId, CancellationToken) + (IRoomUIService.cs:12), returning Task<bool>. It supplements, rather than replaces, the inherited + single-argument delete: both overloads are visible on the interface. +
      • Where it's used: implemented by RoomService; injected into the room list, + detail, and create pages (Pages/Room/RoomList.razor.cs:17, Pages/Room/RoomDetail.razor.cs:16, + Pages/Room/RoomCreate.razor.cs:11) and into the session create/detail and public session detail + pages, which render the room a session is scheduled in (Pages/Session/SessionCreate.razor.cs:19, + Pages/Session/SessionDetail.razor.cs:27, Pages/Public/PublicSessionDetail.razor.cs:24).

      ISessionUIService

      @@ -1157,17 +1242,20 @@

      ISessionUIService

      bound to SessionDTO (ISessionUIService.cs:9-11).
    • Depends on: IEntityService<TEntityDTO, TIdentifierType> and SessionDTO.
    • -
    • Concept: same marker shape as ICategoryItemUIService. - [Rubric §18, UI Architecture]. Note that the personalized speaker-facing session reads live on a - separate contract, ISpeakerDashboardUIService, because they must - bypass the shared output cache: keeping them apart is what lets this contract stay cache-friendly.
    • +
    • Concept: same marker shape as IActivityUIService. + [Rubric §18, UI Architecture]. Worth pausing on what this contract does not carry: the + personalized speaker-facing session reads live on a separate contract, + ISpeakerDashboardUIService, because they must bypass the shared + output cache. Keeping them apart is what lets this contract stay cache-friendly.
    • Walkthrough: no members (doc comment ISessionUIService.cs:6-8).
    • -
    • Where it's used: injected into the session list, detail, and create pages - (Pages/Session/SessionList.razor.cs, SessionDetail.razor.cs, SessionCreate.razor.cs), the public - session pages (Pages/Public/PublicSessionList.razor.cs, PublicSessionDetail.razor.cs), the - organizer session-feedback page (Pages/Feedback/OrganizerSessionFeedback.razor.cs), and the speaker - detail / public speaker detail pages that list a speaker's sessions - (Pages/Speaker/SpeakerDetail.razor.cs, Pages/Public/PublicSpeakerDetail.razor.cs).
    • +
    • Where it's used: implemented by SessionService; injected into the session + list, detail, and create pages (Pages/Session/SessionList.razor.cs:23, + Pages/Session/SessionDetail.razor.cs:21, Pages/Session/SessionCreate.razor.cs:17), the public + session pages (Pages/Public/PublicSessionList.razor.cs:29, + Pages/Public/PublicSessionDetail.razor.cs:22), the organizer session-feedback page + (Pages/Feedback/OrganizerSessionFeedback.razor.cs:18), and the speaker detail / public speaker + detail pages that list a speaker's sessions (Pages/Speaker/SpeakerDetail.razor.cs:24, + Pages/Public/PublicSpeakerDetail.razor.cs:17).

    ISpeakerDashboardUIService

    @@ -1181,8 +1269,10 @@

    ISpeakerDashboardUIService

    it is its own read-only interface, and it imports no MMCA.Common.UI interface at all (ISpeakerDashboardUIService.cs:1-2).
  • Depends on: SessionDTO and - SessionFeedbackDTO. SpeakerIdentifierType and - SessionIdentifierType id aliases.
  • + SessionFeedbackDTO. SpeakerIdentifierType + (a Guid in this module, + MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:19) + and SessionIdentifierType id aliases.
  • Concept introduced, a cache-bypassing personalized read. [Rubric §23, Front-End Performance] and [Rubric §19, State Management] (assess how the front end balances shared caching against read-your-writes freshness for a personalized view). The doc comment on GetSpeakerSessionsAsync @@ -1192,28 +1282,32 @@

    ISpeakerDashboardUIService

    freshly assigned speaker seeing "no sessions". The contract, not just the implementation, is where that decision is written down.
  • Walkthrough: four read methods, all SpeakerIdentifierType-scoped.
      -
    • GetSpeakerSessionsAsync(speakerId, ct) (lines 17-19): returns Task<IReadOnlyList<SessionDTO>>, - the speaker's sessions, uncached.
    • -
    • GetSessionBookmarkCountAsync(speakerId, sessionId, ct) (lines 21-24): returns Task<int>, the - bookmark count for one of the speaker's sessions.
    • -
    • GetSessionBookmarkCountsAsync(speakerId, sessionIds, ct) (lines 31-34): returns +
    • GetSpeakerSessionsAsync(speakerId, ct) (ISpeakerDashboardUIService.cs:17-19): returns + Task<IReadOnlyList<SessionDTO>>, the speaker's sessions, uncached.
    • +
    • GetSessionBookmarkCountAsync(speakerId, sessionId, ct) (ISpeakerDashboardUIService.cs:21-24): + returns Task<int>, the bookmark count for one of the speaker's sessions.
    • +
    • GetSessionBookmarkCountsAsync(speakerId, sessionIds, ct) + (ISpeakerDashboardUIService.cs:31-34): returns Task<IReadOnlyDictionary<SessionIdentifierType, int>>, every requested session's active bookmark - count in a single request. The doc comment (lines 26-30) records that it replaces the dashboard's - per-session fan-out, that only sessions assigned to the speaker come back, and that sessions with no - bookmarks map to 0. That is the [Rubric §12, Performance & Scalability] point in one signature: an - N+1 of HTTP calls collapsed into one.
    • -
    • GetSessionFeedbackAsync(speakerId, sessionId, ct) (lines 36-39): returns - Task<SessionFeedbackDTO?>, nullable when no feedback exists.
    • + count in a single request. The doc comment (ISpeakerDashboardUIService.cs:26-30) records that it + replaces the dashboard's per-session fan-out, that only sessions assigned to the speaker come back, + and that sessions with no bookmarks map to 0. That is the [Rubric §12, Performance & Scalability] + point in one signature: an N+1 of HTTP calls collapsed into one. +
    • GetSessionFeedbackAsync(speakerId, sessionId, ct) (ISpeakerDashboardUIService.cs:36-39): + returns Task<SessionFeedbackDTO?>, nullable when no feedback exists.
  • Why it's built this way: keeping these on a dedicated interface (rather than folding them into ISessionUIService) isolates the cache-bypass semantics to the personalized - surface and keeps the generic session CRUD cache-friendly.
  • -
  • Where it's used: registered as ISpeakerDashboardUIService in the Conference UI DI + surface and keeps the generic session CRUD cache-friendly. It also keeps the speaker-scoped + authorization story simple: every method takes the speaker id explicitly, so the server has the + subject it needs to check ownership on every call.
  • +
  • Where it's used: implemented by SpeakerDashboardService and + registered explicitly in the Conference UI DI (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:32, an explicit - AddScoped because it is not an IEntityService<,> and the assembly scan would not find it) and + AddScoped because it is not an IEntityService<,> and the assembly scan would not find it); injected into the speaker dashboard page - (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerDashboard.razor.cs).
  • + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerDashboard.razor.cs:22).

    ISpeakerUIService

    @@ -1231,20 +1325,23 @@

    ISpeakerUIService

    operation, not a field edit, so it gets LinkUserAsync / UnlinkUserAsync. [Rubric §7, Microservices Readiness]: the UI issues one call against Conference, and the Identity side of the association is reconciled asynchronously by the SpeakerLinkedToUser / - SpeakerUnlinkedFromUser integration events, so this contract deliberately says nothing about - Identity. [Rubric §18, UI Architecture]. + SpeakerUnlinkedFromUser integration events + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/IntegrationEvents/SpeakerLinkedToUser.cs:20 + and .../SpeakerUnlinkedFromUser.cs:17, handled at + MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Speakers/IntegrationEventHandlers/SpeakerLinkedToUserHandler.cs:20), + so this contract deliberately says nothing about Identity. [Rubric §18, UI Architecture].
  • Walkthrough: two added members.
    • LinkUserAsync(SpeakerIdentifierType speakerId, UserIdentifierType userId, CancellationToken) - (line 11): returns Task<bool>.
    • -
    • UnlinkUserAsync(SpeakerIdentifierType speakerId, CancellationToken) (line 13): returns - Task<bool>; unlink needs only the speaker id.
    • + (ISpeakerUIService.cs:11): returns Task<bool>. +
    • UnlinkUserAsync(SpeakerIdentifierType speakerId, CancellationToken) (ISpeakerUIService.cs:13): + returns Task<bool>; unlink needs only the speaker id.
  • -
  • Where it's used: injected into the speaker list, detail, create, and dashboard pages - (Pages/Speaker/SpeakerList.razor.cs, SpeakerDetail.razor.cs, SpeakerCreate.razor.cs, - SpeakerDashboard.razor.cs) and the public speaker pages - (Pages/Public/PublicSpeakerList.razor.cs, PublicSpeakerDetail.razor.cs). The concrete - EntityServiceBase subclass maps the two verbs onto the speaker link/unlink endpoints.
  • +
  • Where it's used: implemented by SpeakerService; injected into the speaker + list, detail, create, and dashboard pages (Pages/Speaker/SpeakerList.razor.cs:24, + Pages/Speaker/SpeakerDetail.razor.cs:23, Pages/Speaker/SpeakerCreate.razor.cs:15, + Pages/Speaker/SpeakerDashboard.razor.cs:21) and the public speaker pages + (Pages/Public/PublicSpeakerList.razor.cs:44, Pages/Public/PublicSpeakerDetail.razor.cs:16).
  • ISponsorUIService

    @@ -1258,20 +1355,19 @@

    ISponsorUIService

  • Depends on: IEntityService<TEntityDTO, TIdentifierType> and SponsorDTO (from MMCA.ADC.Conference.Shared.Sponsors, ISponsorUIService.cs:1).
  • -
  • Concept: the same marker shape taught under ICategoryItemUIService; - the doc comment (ISponsorUIService.cs:6-8) repeats the "uses generic CRUD" formula verbatim. - [Rubric §16, Maintainability] is the point worth pausing on: sponsors were the newest Conference - aggregate to reach the UI, and adding the whole admin surface plus a public sponsor page cost exactly - one empty interface and one three-line class (SponsorService), because the CRUD - algorithm, the auth, the retry, and the error translation were already inherited. - [Rubric §18, UI Architecture].
  • +
  • Concept: the same marker shape taught under IActivityUIService; the doc + comment (ISponsorUIService.cs:6-8) repeats the "uses generic CRUD" formula verbatim. + [Rubric §16, Maintainability] is the point worth pausing on: the whole sponsor admin surface plus a + public sponsor page costs exactly one empty interface and one four-line class + (SponsorService), because the CRUD algorithm, the auth, the retry, and the error + translation are all inherited. [Rubric §18, UI Architecture].
  • Walkthrough: no members.
  • Why it's built this way: sponsor management is plain CRUD from the client's point of view, so the - contract adds nothing; the named marker exists so the Scrutor scan in + contract adds nothing; the named marker exists so the Scrutor scan inside AddUIModule<ConferenceUIModule>() can bind a concrete implementation to a name the pages inject (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:23).
  • Where it's used: implemented by SponsorService - (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/SponsorService.cs:12); injected + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/SponsorService.cs:10); injected into the sponsor list, detail, and create pages and the anonymous public sponsor list (Pages/Sponsor/SponsorList.razor.cs:24, Pages/Sponsor/SponsorDetail.razor.cs:22, Pages/Sponsor/SponsorCreate.razor.cs:20, Pages/Public/PublicSponsorList.razor.cs:25).
  • @@ -1307,23 +1403,26 @@

    OrganizerEventFeedbackService

  • Walkthrough
    • Endpoint (OrganizerFeedbackService.cs:19): the private const string "eventquestionanswers" resource root.
    • -
    • GetAllAnswersAsync(eventId, ct) (line 21): takes a client from +
    • GetAllAnswersAsync(eventId, ct) (OrganizerFeedbackService.cs:21): takes a client from CreateAuthenticatedClientAsync() (line 25), builds {Endpoint}/paged?filters[EventId].operator=equals&filters[EventId].value={eventId}&pageSize=500&includeChildren=false with string.Create(CultureInfo.InvariantCulture, ...) (lines 27-28, culture-invariant so the numeric id renders stably), runs the GET inside RetryPolicy.ExecuteAsync (lines 30-31), calls EnsureSuccessStatusCode() (line 33), deserializes a - PagedCollectionResult<EventQuestionAnswerDTO> - (lines 35-36) and returns its Items, an empty list when the body was null (line 38). The - filters[...] query grammar is the same dynamic-filter contract the Conference REST controllers - expose, so the client does not need a bespoke endpoint.
    • -
    • DeleteAnswerAsync(eventId, answerId, ct) (line 41): builds + PagedCollectionResult<T> of + EventQuestionAnswerDTO (lines 35-36), and + returns its Items, an empty list when the body was null (line 38). The filters[...] query + grammar is the same dynamic-filter contract the Conference REST controllers expose (see + ADR-034), so the client + needs no bespoke endpoint.
    • +
    • DeleteAnswerAsync(eventId, answerId, ct) (OrganizerFeedbackService.cs:41): builds {Endpoint}/{answerId}?eventId={eventId} (line 48, the event id is a required query argument, mirroring the child-scoped delete pattern), issues the DELETE through the retry policy (lines 49-50), and on a non-success status routes the response through - ServiceExceptionHelper.ThrowIfDomainExceptionAsync - (lines 52-53) so a domain error surfaces as a typed exception before the final - EnsureSuccessStatusCode() (line 55). It returns a bare Task: success is "did not throw".
    • + ServiceExceptionHelper's + ThrowIfDomainExceptionAsync (lines 52-53) so a domain error surfaces as a typed exception before + the final EnsureSuccessStatusCode() (line 55). It returns a bare Task: success is "did not + throw".
  • Why it's built this way: inheriting the authenticated base means token attachment and the Polly @@ -1362,34 +1461,29 @@

    OrganizerSessionFeedbackService

    - + - + - - - - - - - - + + + - - - + + +
    MemberType File:LineDiffers from the event siblingNotes (what differs)
    Endpoint constOrganizerFeedbackService.cs:66"sessionquestionanswers" (vs "eventquestionanswers")
    GetAllAnswersAsync(sessionId, ct)OrganizerFeedbackService.cs:68filters on SessionId; returns SessionQuestionAnswerDTOOrganizerEventFeedbackServiceMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/OrganizerFeedbackService.cs:15eventquestionanswers root (line 19); filters on EventId; delete scoped ?eventId= (line 48)
    DeleteAnswerAsync(sessionId, answerId, ct)OrganizerFeedbackService.cs:88scopes the delete with ?sessionId={sessionId} (line 95)OrganizerSessionFeedbackServiceMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/OrganizerFeedbackService.cs:62sessionquestionanswers root (line 66); filters on SessionId; delete scoped ?sessionId= (line 95)
  • -
  • Walkthrough: mechanically the same as the event service. The paged GET (line 68) uses the same - pageSize=500&includeChildren=false shape and culture-invariant URL build (lines 74-75), runs inside - RetryPolicy.ExecuteAsync (lines 77-78), and returns Items or an empty list (line 85); the DELETE - (line 88) routes non-success responses through - ServiceExceptionHelper.ThrowIfDomainExceptionAsync - (lines 99-100) before EnsureSuccessStatusCode() (line 102).

    +
  • Walkthrough: mechanically the same as the event service. The paged GET + (OrganizerFeedbackService.cs:68) uses the same pageSize=500&includeChildren=false shape and + culture-invariant URL build (lines 74-75), runs inside RetryPolicy.ExecuteAsync (lines 77-78), and + returns Items or an empty list (line 85); the DELETE (line 88) routes non-success responses through + ServiceExceptionHelper's + ThrowIfDomainExceptionAsync (lines 99-100) before EnsureSuccessStatusCode() (line 102).

  • Why it's built this way: two small parallel classes are cheaper to read than one generic service parameterized over "the parent key", and each one's URL shape stays literal and greppable.

    @@ -1416,9 +1510,9 @@

    SpeakerLookupService

  • Depends on: SpeakerInfo (the lightweight projection it emits), SpeakerDTO (the wire shape it reads), PagedCollectionResult<T>; BCL - IHttpClientFactory and System.Net.Http.Json. Note it takes only IHttpClientFactory and no token - storage (SpeakerLookupService.cs:11): this is an unauthenticated public read, and it does not derive - from AuthenticatedServiceBase.
  • + IHttpClientFactory and System.Net.Http.Json. Note that it takes only IHttpClientFactory and no + token storage (SpeakerLookupService.cs:11): this is an unauthenticated public read, and it does not + derive from AuthenticatedServiceBase.
  • Concept introduced, the client-side denormalizing lookup. [Rubric §23, Front-End Performance] (assesses avoiding N per-item round-trips). Session and event pages hold speaker ids but must show speaker names; rather than fetch each speaker individually, this service pulls the whole speaker set @@ -1426,8 +1520,8 @@

    SpeakerLookupService

    (SpeakerLookupService.cs:7-10) states that use directly. [Rubric §9, API & Contract Design] shows up in the query string: includeFKs=false&includeChildren=false asks the server for the flat rows only, so the bulk read stays cheap on both ends.
  • -
  • Walkthrough: one method, GetAllAsync(ct) (lines 14-15). It resolves the named "APIClient" - HttpClient from the factory (line 17), GETs +
  • Walkthrough: one method, GetAllAsync(ct) (SpeakerLookupService.cs:14-15). It resolves the + named "APIClient" HttpClient from the factory (line 17), GETs speakers?includeFKs=false&includeChildren=false&pageSize=10000 (a deliberately large page to pull every speaker in one request, lines 19-21), takes wrapper?.Items or an empty list (line 23), then loops building a Dictionary<SpeakerIdentifierType, SpeakerInfo> whose entries carry Id, @@ -1441,48 +1535,79 @@

    SpeakerLookupService

    "cross-module lookup services" (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:42) and injected into the session list, session detail, public session list, and public session detail pages - (Pages/Session/SessionList.razor.cs, Pages/Session/SessionDetail.razor.cs, - Pages/Public/PublicSessionList.razor.cs, Pages/Public/PublicSessionDetail.razor.cs).
  • + (Pages/Session/SessionList.razor.cs:25, Pages/Session/SessionDetail.razor.cs:23, + Pages/Public/PublicSessionList.razor.cs:33, Pages/Public/PublicSessionDetail.razor.cs:23).
  • Caveats / not-in-source: the pageSize=10000 ceiling (SpeakerLookupService.cs:20) assumes the conference never exceeds 10,000 speakers; beyond that the lookup would silently miss speakers. The dictionary is built fresh on every call (there is no memoization in this class), so a page that needs it twice pays for it twice.
  • -

    CategoryItemService

    +

    ActivityService

    -

    MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/CategoryItemService.cs:10 · Level 4 · class (sealed)

    +

    MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/ActivityService.cs:10 · Level 4 · class (sealed)

      -
    • What it is: the concrete HTTP service for the categoryitems resource, a body-less class that - inherits all CRUD from the shared base and binds the endpoint name (CategoryItemService.cs:10-14). - It implements ICategoryItemUIService.
    • +
    • What it is: the concrete HTTP service for the activities resource, a body-less class that + inherits all CRUD from the shared base and binds the endpoint name (ActivityService.cs:10-14). It + implements IActivityUIService.
    • Depends on: EntityServiceBase<TEntityDTO, TIdentifierType> - (its base), ITokenStorageService, - CategoryItemDTO; BCL IHttpClientFactory.
    • -
    • Concept introduced, the three-line concrete UI service (Template Method with a supplied endpoint). + (its base, from MMCA.Common.UI.Services at ActivityService.cs:2), + ITokenStorageService (from + MMCA.Common.UI.Services.Auth at ActivityService.cs:3), and + ActivityDTO; BCL IHttpClientFactory.
    • +
    • Concept introduced, the four-line concrete UI service (Template Method with a supplied endpoint). [Rubric §2, Design Patterns] (assesses whether a shared algorithm is factored once and specialized - by leaves; here the base owns the CRUD algorithm and the leaf supplies the resource name) and + by leaves; here the base owns the CRUD algorithm and the leaf supplies only the resource name) and [Rubric §16, Maintainability] (a new plain-CRUD resource costs one tiny class). The primary constructor forwards IHttpClientFactory and ITokenStorageService plus the literal - resource name "categoryitems" to - EntityServiceBase<CategoryItemDTO, CategoryItemIdentifierType> - (CategoryItemService.cs:10-12); the class body is empty (CategoryItemService.cs:13-14). Every CRUD - method, along with the auth, the Polly retry, the serialization, and the domain-error translation, - comes from the base, see + resource name "activities" to + EntityServiceBase<TEntityDTO, TIdentifierType> + closed over ActivityDTO and ActivityIdentifierType (ActivityService.cs:10-12); the class body is + empty (ActivityService.cs:13-14). Every CRUD method, along with the auth, the Polly retry, the + serialization, and the domain-error translation, comes from the base, see EntityServiceBase<TEntityDTO, TIdentifierType> - in Group 15.
    • + in Group 15. Every plain-CRUD concrete service in this group repeats this shape.
    • Walkthrough: no members. The whole class is the base call carrying the resource root - "categoryitems" (CategoryItemService.cs:12) and the declaration that it satisfies - ICategoryItemUIService (same line).
    • + "activities" (ActivityService.cs:12) and the declaration that it satisfies + IActivityUIService (same line). The doc comment (ActivityService.cs:7-9) + says only that it "provides standard CRUD".
    • Why it's built this way: the endpoint name is the only thing that varies for a plain CRUD - aggregate, so the concrete class carries exactly that and nothing else.
    • + aggregate, so the concrete class carries exactly that and nothing else. sealed + (ActivityService.cs:10) closes the leaf: specialization belongs on the interface or in the base, not + in a subclass of a subclass.
    • Where it's used: never named in DI by hand. Because it is an IEntityService<,> implementation in the Conference UI assembly, the Scrutor scan inside AddUIModule<ConferenceUIModule>() registers it AsImplementedInterfaces() with a scoped lifetime (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:23 calling MMCA.Common/Source/Presentation/MMCA.Common.UI/DependencyInjection.cs:155-159), which is what makes - ICategoryItemUIService resolvable in the conference-category detail page.
    • + IActivityUIService resolvable in ActivityList, + ActivityDetail, ActivityCreate, and + PublicActivityList. +
    +

    CategoryItemService

    +
    +

    MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/CategoryItemService.cs:10 · Level 4 · class (sealed)

    +
    +
      +
    • What it is: the concrete HTTP service for the categoryitems resource, structurally identical to + ActivityService but bound to + CategoryItemDTO and CategoryItemIdentifierType + (CategoryItemService.cs:10-14). It implements ICategoryItemUIService.
    • +
    • Depends on: EntityServiceBase<TEntityDTO, TIdentifierType> + (its base), ITokenStorageService, + CategoryItemDTO; BCL IHttpClientFactory.
    • +
    • Concept: identical to ActivityService; see it for the thin-class rationale. + The only differences are the resource root "categoryitems" (CategoryItemService.cs:12), the DTO + plus identifier alias, and the interface it satisfies. [Rubric §16, Maintainability].
    • +
    • Walkthrough: no members. The base call passes "categoryitems" alongside the factory and token + storage (CategoryItemService.cs:10-12), and the same line declares + ICategoryItemUIService.
    • +
    • Where it's used: picked up by the same assembly scan as its siblings + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:23) and resolved + through ICategoryItemUIService in + ConferenceCategoryDetail + (Pages/ConferenceCategory/ConferenceCategoryDetail.razor.cs:16).

    ConferenceCategoryService

    @@ -1497,119 +1622,22 @@

    ConferenceCategoryService

  • Depends on: EntityServiceBase<TEntityDTO, TIdentifierType>, ITokenStorageService, ConferenceCategoryDTO.
  • -
  • Concept: identical to CategoryItemService; see it for the thin-class - rationale. The only differences are the resource root "conferencecategories" +
  • Concept: identical to ActivityService; see it for the thin-class rationale. + The only differences are the resource root "conferencecategories" (ConferenceCategoryService.cs:12), the DTO plus identifier alias, and the interface it satisfies. - [Rubric §16, Maintainability].
  • + [Rubric §16, Maintainability]. Reading these three classes back to back is the clearest evidence of + what the shared base buys: three resources, twelve lines of code, zero duplicated HTTP handling.
  • Walkthrough: no members; the base call passes "conferencecategories" alongside the factory and token storage (ConferenceCategoryService.cs:10-12).
  • -
  • Where it's used: picked up by the same assembly scan as its sibling and resolved through - IConferenceCategoryUIService in the conference-category list, - detail, and create pages and the speaker detail page.
  • - -

    EventService

    -
    -

    MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/EventService.cs:13 · Level 4 · class (sealed)

    -
    -
      -
    • What it is: the concrete HTTP service for the events resource. It inherits generic CRUD from the - base and adds the three event-specific calls promised by IEventUIService: - publish, unpublish, and Sessionize refresh (EventService.cs:13-56).
    • -
    • Depends on: EntityServiceBase<TEntityDTO, TIdentifierType> - and its inherited Endpoint / SendRequestAsync members, - ITokenStorageService, - EventDTO, - EventTransitionRequest, - RefreshFromSessionizeResultDTO; BCL - System.Net.Http.Json, System.Globalization.
    • -
    • Concept introduced, adding action endpoints on top of the CRUD base via SendRequestAsync. - [Rubric §18, UI Architecture] and [Rubric §9, API & Contract Design]. Where the plain CRUD - services have empty bodies, this one implements three extra verbs by calling the inherited - SendRequestAsync<T> with a lambda that issues the actual HTTP call, so the concrete class writes - only URL plus verb plus body while the base owns auth, retry, and deserialization. The inherited - Endpoint (the resource root supplied to the base at EventService.cs:14-15) is reused to build the - action URLs. [Rubric §8, Data Architecture] also lands here: both transitions post an - EventTransitionRequest carrying the - optimistic-concurrency RowVersion the caller passed in - (EventService.cs:25, EventService.cs:40), so a transition decided against a stale view of the - event is rejected by the server instead of applied silently - (ADR-035).
    • -
    • Walkthrough
        -
      • Constructor (EventService.cs:13-15): forwards the factory, token storage, and "events" to - EntityServiceBase<EventDTO, EventIdentifierType>.
      • -
      • PublishAsync(id, rowVersion, ct) (EventService.cs:17-30): SendRequestAsync<object> posting a - new EventTransitionRequest { RowVersion = rowVersion } body via PostAsJsonAsync to - {Endpoint}/{id}/publish, with the URL built through - string.Create(CultureInfo.InvariantCulture, ...) (line 24) and expectContent: false (line 28) - because the endpoint returns no body; returns a constant true (line 29).
      • -
      • UnpublishAsync(id, rowVersion, ct) (EventService.cs:32-45): the mirror call to - {Endpoint}/{id}/unpublish (line 39), same body, same expectContent: false, same true.
      • -
      • RefreshFromSessionizeAsync(id, ct) (EventService.cs:47-55): an expression-bodied member that - POSTs to {Endpoint}/{id}/refresh with a null content (line 53) and, unlike the transition - pair, expects a body, so SendRequestAsync<RefreshFromSessionizeResultDTO> deserializes the sync - summary and returns it (nullable).
      • -
      -
    • -
    • Why it's built this way: publish, unpublish, and refresh are distinct server actions, not CRUD - updates, so they map to dedicated /{id}/action endpoints; routing them through the inherited - SendRequestAsync keeps the auth, retry, and domain-error behavior identical to the inherited CRUD - rather than growing a second, divergent HTTP path in this class.
    • -
    • Where it's used: resolved as IEventUIService through the Conference UI - assembly scan; injected into the event list/detail/create pages that expose the publish and - Sessionize-refresh buttons, the public event pages, and the session lists that need their owning - event.
    • -
    • Caveats / not-in-source: PublishAsync and UnpublishAsync return a constant true - (EventService.cs:29, EventService.cs:44); the bool carries no failure signal of its own, because - failures (including a 409 Conflict from a stale RowVersion) surface as exceptions thrown by the - base dispatch and are handled by the calling page.
    • -
    -

    EventSpeakerService

    -
    -

    MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Services · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/ChildEntityServices.cs:14 · Level 4 · class (sealed)

    -
    -
      -
    • What it is: the HTTP service for the EventSpeaker join entity, POST to add a speaker to an event - and DELETE to remove one (ChildEntityServices.cs:14-25). It implements - IEventSpeakerUIService and is the first of four structurally identical - join-entity services in this file (SessionSpeakerService, SessionCategoryItemService, and - SpeakerCategoryItemService at ChildEntityServices.cs:30, :46, and :62, documented with the - other Conference UI join services).
    • -
    • Depends on: ChildEntityServiceBase - (its base, which owns PostAsync and DeleteByIdAsync), - ITokenStorageService, - EventSpeakerDTO; BCL IHttpClientFactory, - System.Net.Http.Json, System.Globalization.
    • -
    • Concept introduced, the join-entity UI service. [Rubric §18, UI Architecture] (assesses a - consistent typed abstraction for many-to-many association edits). A join entity has no rich lifecycle - and no CRUD detail page; it is only ever created or removed, so it derives from the leaner - ChildEntityServiceBase rather than - EntityServiceBase<TEntityDTO, TIdentifierType>. - The primary constructor forwards the factory, token storage, and resource name "eventspeakers" to - the base (ChildEntityServices.cs:14-15), which centralizes auth, domain-error translation, and the - add/remove HTTP mechanics. Because it is not an IEntityService<,>, the assembly scan does not see - it, which is exactly why it (and its three siblings) are registered by hand.
    • -
    • Walkthrough
        -
      • AddAsync(eventId, speakerId, ct) (ChildEntityServices.cs:17-21): calls the base PostAsync with - an anonymous payload new { EventId = eventId, SpeakerId = speakerId } (line 19) and deserializes - the created EventSpeakerDTO from the response body - (line 20, nullable).
      • -
      • DeleteAsync(id, ct) (ChildEntityServices.cs:23-24): delegates to the base DeleteByIdAsync, - formatting the join id with CultureInfo.InvariantCulture so the URL segment is culture-stable, and - returns the base's bool (the base maps a 404 to false, an idempotent remove).
      • -
      -
    • -
    • Why it's built this way: all four join services in this file share the same add/remove contract, so - the base holds the HTTP and error handling and each subclass supplies only the resource name and a - strongly typed AddAsync overload with the correct id fields. The trailing comment - (ChildEntityServices.cs:75-76) records that the base was hoisted out of this file into the shared - MMCA.Common.UI.Services namespace, which is the [Rubric §16, Maintainability] payoff: the pattern - now belongs to the framework, not to ADC.
    • -
    • Where it's used: registered explicitly as IEventSpeakerUIService - (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/DependencyInjection.cs:26).
    • -
    • Caveats / not-in-source: no Blazor page or component in this repository injects - IEventSpeakerUIService today; the registration and the typed service exist - but the only references to the interface are its declaration, this implementation, and the DI line - above. Its three siblings in the same file are consumed by the session and speaker detail editors.
    • +
    • Where it's used: picked up by the same assembly scan as its siblings and resolved through + IConferenceCategoryUIService in + ConferenceCategoryList + (Pages/ConferenceCategory/ConferenceCategoryList.razor.cs:16), + ConferenceCategoryDetail + (Pages/ConferenceCategory/ConferenceCategoryDetail.razor.cs:15), + ConferenceCategoryCreate + (Pages/ConferenceCategory/ConferenceCategoryCreate.razor.cs:11), and + SpeakerDetail (Pages/Speaker/SpeakerDetail.razor.cs:25).

    ISessionSelectionUIService

    @@ -2116,133 +2144,311 @@

    SpeakerQr

    MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Speaker · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerQr.razor.cs:19 · Level 1 · class (Blazor code-behind)

      -
    • What it is: the speaker-facing side of the speaker QR code. It renders one full-screen code that resolves to the speaker's own public profile page, for holding up at the podium or parking on a booth screen (SpeakerQr.razor.cs:8-18).
    • -
    • Depends on: IPublicLinkBuilder (SpeakerQr.razor.cs:21) and ConferenceRoutePaths (:55); the cascading Task<AuthenticationState> (:23-24); MudBlazor BreadcrumbItem/Icons and the shared QrCodeImage component from MMCA.Common.UI (rendered at SpeakerQr.razor:26-30). No UI service, no HTTP client, no DTO.
    • +
    • What it is: the speaker-facing side of the speaker QR code. It renders one card-sized code that resolves to the speaker's own public profile page, for holding up at the podium or parking on a booth screen (SpeakerQr.razor.cs:8-18).
    • +
    • Depends on: IPublicLinkBuilder (SpeakerQr.razor.cs:21) and ConferenceRoutePaths (:55); the cascading Task<AuthenticationState> (:23-24); MudBlazor's BreadcrumbItem and Icons, plus the shared QrCodeImage component from MMCA.Common.UI and its QrErrorCorrectionLevel enum (rendered at SpeakerQr.razor:26-30). No UI service, no HTTP client, no DTO.
    • Concept introduced, the zero-fetch page and the absolute-link rule. This is the smallest page in the group and the clearest place to see two ideas.
        -
      1. Nothing is fetched. The identity comes from the speaker_id JWT claim (SpeakerQr.razor.cs:49-53) and the payload is composed locally, so the page renders identically on the SSR prerender pass and on the interactive pass. No CancellationTokenSource, no loading flag, and no IDisposable: there is no in-flight request to cancel. [Rubric §23, Front-End Performance & Rendering] (assesses how much work a view costs to paint): this one costs a claim read and a string build.
      2. -
      3. The payload must be an absolute public URL. LinkBuilder.BuildAbsolute(...) (:55, contract at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/IPublicLinkBuilder.cs:12) converts the relative route into a fully-qualified public URL. The class doc records why (SpeakerQr.razor.cs:15-16): the MAUI head serves the Blazor app from a WebView-internal origin, so a code built from the ambient base URI would scan to an address that exists for nobody but that device. [Rubric §22, Responsive & Cross-Browser] and [Rubric §7, Microservices Readiness]: the link is built against the public host, not the head the code happens to run in. - Claim-derived scoping is the same mechanism SpeakerDashboard uses; see that section for the security discussion. [Rubric §26, Front-End Security]: the speaker can only ever render their own code because the id is read from the validated token, never from a route parameter.
      4. +
      5. Nothing is fetched. The identity comes from the speaker_id JWT claim (SpeakerQr.razor.cs:49-53) and the payload is composed locally, so the page renders identically on the SSR prerender pass and on the interactive pass. No CancellationTokenSource, no loading flag, and no IDisposable: there is no in-flight request to cancel, which is why this class has none of the disposal plumbing every other page in this unit carries. [Rubric §23, Front-End Performance & Rendering] (assesses how much work a view costs to paint): this one costs a claim read and a string build.
      6. +
      7. The payload must be an absolute public URL. LinkBuilder.BuildAbsolute(...) (:55, contract at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/IPublicLinkBuilder.cs:12) converts the relative route into a fully-qualified public URL. The interface doc records why (IPublicLinkBuilder.cs:3-8, echoed on the page at SpeakerQr.razor.cs:15-16): web heads can derive an origin from the browser, but the MAUI head serves the Blazor app from the WebView's virtual host, so a code built from the ambient base URI would scan to an address that exists for nobody but that device. [Rubric §22, Responsive & Cross-Browser] and [Rubric §7, Microservices Readiness]: the link is built against the public site, not the head the code happens to run in, so the same page is correct on every head. + Claim-derived scoping is the same mechanism SpeakerDashboard uses; see that section for the fuller discussion. [Rubric §26, Front-End Security]: the speaker can only ever render their own code, because the id is read from the validated token and never from a route parameter.
    • Walkthrough
        -
      • Fields (SpeakerQr.razor.cs:26-28): the breadcrumb list, the nullable _payload (null keeps the code hidden), and _displayName.
      • +
      • Fields (SpeakerQr.razor.cs:26-28): the breadcrumb list, the nullable _payload (null keeps the code hidden and swaps in an explanatory alert), and _displayName.
      • OnInitializedAsync (:30-56): builds the two-item breadcrumb trail (:32-36), returns early when there is no cascading auth state (:38-41), reads state.User.Identity?.Name into _displayName so a scanner can see whose profile the code opens before opening it (:45-47), then requires a parsable speaker_id claim (:49-53) before building the payload (:55).
      • -
      • The markup passes the payload to QrCodeImage with a localized AltText and QrErrorCorrectionLevel.Medium (SpeakerQr.razor:26-30). [Rubric §21, Accessibility]: the image carries alt text rather than being a decorative canvas.
      • +
      • The markup renders QrCodeImage with the payload, a localized AltText, PixelsPerModule="14", and QrErrorCorrectionLevel.Medium (SpeakerQr.razor:26-30); the in-markup comment (SpeakerQr.razor:24-25) records the reasoning: readable from a few steps away, with enough error correction to survive screen glare. [Rubric §21, Accessibility]: the code carries alt text rather than being a decorative canvas.
      • +
      • The no-claim branch is not an empty card. The markup renders an informational alert (SpeakerQr.razor:11-18) because the nav entry is claim-gated but a bookmarked or typed URL still lands here. [Rubric §24, Forms, Validation & UX Safety]: the dead end is explained rather than rendered blank.
    • -
    • Why it's built this way: a speaker holding up a phone at a podium needs the code to appear instantly and to work when scanned by a stranger's camera; both requirements point at a locally-composed absolute URL and no network dependency at all.
    • -
    • Where it's used: the /speaker/qr route (SpeakerQr.razor:1), the speaker portal companion to SpeakerDashboard. The same target URL is offered from the reader's side by the QrCodeButton on the public profile (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSpeakerDetail.razor:44-45).
    • -
    • Caveats / not-in-source: the speaker_id claim is issued by the Identity service when an organizer links a User to a Speaker (see SpeakerDetail); this page only reads it.
    • +
    • Why it's built this way: a speaker holding up a phone at a podium needs the code to appear instantly and to work when scanned by a stranger's camera. Both requirements point at a locally composed absolute URL and no network dependency at all.
    • +
    • Where it's used: the /speaker/qr route, [Authorize] with no role requirement (SpeakerQr.razor:1-2), the speaker-portal companion to SpeakerDashboard. The same target URL is offered from the reader's side by the QrCodeButton on the public profile (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSpeakerDetail.razor:45-46).
    • +
    • Caveats / not-in-source: the speaker_id claim is issued when an organizer links a User to a Speaker (see SpeakerDetail); the claim's issuance lives in the Identity service, and this page only reads it.

    +

    SpeakerCreate

    +
    +

    MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Speaker · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerCreate.razor.cs:13 · Level 5 · class (Blazor code-behind)

    +
    +
      +
    • What it is: the organizer's speaker-creation form. It collects first and last name, bio, tagline, email, profile picture, and the four social links, packs them into a new SpeakerDTO, and posts it (SpeakerCreate.razor.cs:9-12).
    • +
    • Depends on: ISpeakerUIService (:15), SpeakerDTO (:69), ConferenceRoutePaths (:30,88,104), and ErrorMessages (:62); MudBlazor's MudForm and ISnackbar (:17,47), Blazor's NavigationManager (:16), and the shared UnsavedChangesGuard component (SpeakerCreate.razor:8).
    • +
    • Concept: the create-page pattern this group teaches once (validate before mutate, dirty tracking, cancel on disposal), here in its widest-form variant. [Rubric §24, Forms, Validation & UX Safety] (assesses validate-before-submit and unsaved-change protection): CreateSpeakerAsync calls await _form.ValidateAsync() and bails with a warning snackbar when !_form.IsValid (:59-64) before touching the service, and _isDirty (set by MarkDirty(), :50) is cleared the instant the save succeeds, before navigating (:86), so the guard cannot block its own redirect. The CancellationTokenSource (:19) is passed to the service call (:85) and cancelled in the standard dispose pattern (:106-128), with OperationCanceledException swallowed as the expected teardown outcome (:90-93). + The identifier detail is worth pausing on. Speaker is Guid-keyed, so this page mints a genuinely unique id client-side with Guid.NewGuid() (:71), while the int-keyed create forms in this same module send Id = default and let the database assign one (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Event/EventCreate.razor.cs:78, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/ConferenceCategory/ConferenceCategoryCreate.razor.cs:58). [Rubric §8, Data Architecture] (assesses a deliberate identity strategy): the per-entity identifier alias (ADR-048) keeps the key type out of the page's own logic, and either way the page reads created.Id back from the response (:88) rather than trusting what it sent.
    • +
    • Walkthrough: OnInitialized (:24-33) builds the Home / Speakers / Create breadcrumb trail; the private Title property (:20) pulls the page title from the localizer so PageTitle and the heading share one resource key ([Rubric §27, Internationalization]). CreateSpeakerAsync (:52-102) validates, sets IsSaving (:66, the flag the markup uses to disable the submit button), composes FullName from the two name fields (:74), builds the DTO with all optional profile and social fields (:69-83), calls SpeakerService.AddAsync (:85), snackbars, and navigates to ConferenceRoutePaths.SpeakerDetails(created.Id) (:87-88). A non-cancellation failure raises one error snackbar (:94-97) and the finally always clears IsSaving (:98-101), so a failed save leaves the form editable rather than stuck.
    • +
    • Why it's built this way: one create-form shape reused per entity keeps the flow uniform (validate, post, redirect to detail) while each page varies only in the fields it collects.
    • +
    • Where it's used: the /speakers/create route, restricted to the Organizer role (SpeakerCreate.razor:1-2), reached from SpeakerList's create button; it redirects to SpeakerDetail.
    • +
    • Caveats / not-in-source: whether the server honors or replaces the client-minted Guid is a server-side decision not visible here; the page uses the id from the response either way.
    • +
    +
    +

    SpeakerCategoryItemsPanel

    +
    +

    MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Speaker · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerCategoryItemsPanel.razor.cs:16 · Level 8 · class (Blazor code-behind)

    +
    +
      +
    • What it is: the "Additional Info" panel carved out of SpeakerDetail. It renders a speaker's category items grouped by category and hosts the add and remove chip actions (SpeakerCategoryItemsPanel.razor.cs:9-15).
    • +
    • Depends on: ISpeakerCategoryItemUIService (:18), SpeakerDTO (:22), SpeakerCategoryItemDTO (:42), CategoryItemInfo (:25), and the CategoryItem / ConferenceCategory / SpeakerCategoryItem identifier aliases (:25,28,34,81); MudBlazor's ISnackbar (:19).
    • +
    • Concept introduced, the container/presentational split with an EventCallback up-channel. The page (the container) passes down the Speaker plus the two lookups it already owns (:22-28), and the panel signals mutations back up through the Changed callback (:31). After an add or a remove the panel calls await Changed.InvokeAsync() (:73,87), and the page responds by reloading the speaker, so behavior is identical to the pre-split page (class doc, :12-14). [Rubric §18, UI Architecture & Component Design] (assesses cohesive, single-responsibility components): the split trims an already-large parent and gives this sub-view one job. [Rubric §19, State Management & Data Flow] (assesses where state lives and how it flows): the panel holds no source-of-truth state, only the transient _selectedCategoryItemId (:34); data flows down as parameters and mutations flow up through the callback, the canonical unidirectional Blazor pattern. + One localization detail follows from the split: the panel injects IStringLocalizer<SpeakerDetail>, not a localizer of its own (SpeakerCategoryItemsPanel.razor:2), so the extracted markup keeps using the parent page's resource files instead of forking a second .resx pair. [Rubric §27, Internationalization] and [Rubric §16, Maintainability].
    • +
    • Walkthrough
        +
      • GetCategoryTitle (:36-37) and GetCategoryItemName (:39-40): resolve ids to display names, falling back to the invariant-culture id when the lookup has no entry, so a missing lookup degrades to a number rather than an exception.
      • +
      • GetCategoryItemsGroupedByCategory (:42-50): filters the speaker's assigned items to those present in the lookup and groups them by their parent category id; GetAvailableCategoryItems (:52-59) builds the add dropdown by excluding already-assigned items, so the same item cannot be added twice from the UI.
      • +
      • AddCategoryItemAsync (:61-79): no-ops without a selection (:63-66), posts the item against the speaker id (:70), clears the selection, snackbars, and invokes Changed (:71-73); RemoveCategoryItemAsync (:81-93) deletes by the join-entity id (the SpeakerCategoryItem row, not the category item) and invokes Changed (:85-87). Both catch broadly and report through a snackbar (:75-78,89-92) rather than surfacing an exception into the render tree. [Rubric §29, Resilience & Business Continuity].
      • +
      • The panel owns its own CancellationTokenSource (:33), cancelled in the standard dispose pattern (:95-117), because unlike a purely presentational child it makes its own service calls.
      • +
      +
    • +
    • Why it's built this way: the speaker editor grew large enough that carving out a self-contained sub-view (owning its own service call, delegating state to the page) shrinks the parent and makes the panel independently testable, with no change in observable behavior.
    • +
    • Where it's used: rendered inside SpeakerDetail (SpeakerDetail.razor:181), which supplies Speaker, CategoryItems, CategoryTitles, and a Changed handler that reloads the speaker.
    • +
    +
    +

    SpeakerDetail

    +
    +

    MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Speaker · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerDetail.razor.cs:19 · Level 8 · class (Blazor code-behind)

    +
    +
      +
    • What it is: the full speaker console. Beyond load, inline edit, and delete it composes the category-item panel, resolves question-answer text, lists the speaker's sessions, and runs the link/unlink a User to this Speaker flow (SpeakerDetail.razor.cs:14-18).
    • +
    • Depends on: six UI clients, ISpeakerUIService, ISessionUIService, IConferenceCategoryUIService, ICategoryItemLookupService, IQuestionUIService, and IUserUIService (:23-28); SpeakerDTO, SessionDTO, UserListDTO, CategoryItemInfo, ConferenceRoutePaths (:48,359,361), ErrorMessages (:100,115,204,241,274), and the shared DeleteConfirmation component (:71). It hosts SpeakerCategoryItemsPanel.
    • +
    • Concept introduced, the cross-module composition page. One page composes data from Conference and Identity plus three lookups resolved into display names. [Rubric §7, Microservices Readiness] (assesses that cross-module access goes through abstractions rather than direct references): the Identity reach is IUserUIService, an HTTP client behind an interface, so the page is indifferent to Identity running as its own service behind the gateway. [Rubric §18, UI Architecture & Component Design] (a high dependency count is a cohesion signal worth watching): the page delegates its category-item sub-view to a child component and keeps the rest, so its remaining size comes from orchestrating six clients and three lookups rather than from bespoke mechanics. + Note the authorization shape, because it differs from its siblings: the route carries a bare [Authorize] (SpeakerDetail.razor:2), while SpeakerList and SpeakerCreate both require Roles = "Organizer". The page is reachable by any authenticated user who has the id, and the actual read and write authorization is enforced by the Conference API behind each service call. [Rubric §11, Security] (assesses that the server, not the route attribute, is the authorization boundary).
    • +
    • Walkthrough
        +
      • Load once per id: OnParametersSetAsync (:80-89) compares the route [Parameter] string Id (:32) against _loadedId (:53) so a re-render does not refetch.
      • +
      • LoadAsync (:91-121): GetByIdAsync(speakerId, true, ...) with children included (:97), a not-found snackbar through ErrorMessages (:100), then three lookups hydrated lazily with ??= so a reload does not re-fetch them: category items (:104), category titles (LoadCategoryTitlesAsync, :150-155), and question texts (LoadQuestionTextsAsync, :157-162). GetQuestionText (:164-165) resolves an answer's question id with the same invariant-culture id fallback the panel uses.
      • +
      • LoadSpeakerSessionsAsync (:131-148): a server-side SpeakerId equals filter (:133-136), sorted by StartsAt ascending, includeChildren: false, capped at MaxSpeakerSessions = 100 (:34-35). The remarks block (:126-130) records what this replaced: the page used to pull the entire session catalog with all child collections and filter it in memory on SessionSpeakers, even though it never renders those children. [Rubric §12, Performance & Scalability] (assesses moving work to where the data lives) and [Rubric §8, Data Architecture] (a bounded page size instead of an unbounded read).
      • +
      • Inline edit (:167-247): StartEditing seeds the _edit* shadow fields from the loaded record (:167-186) and CancelEditing simply discards them (:188-192), so the live Speaker object is never mutated until a validated save succeeds. SaveChangesAsync (:194-247) validates the MudForm first (:201-206), rebuilds the DTO preserving RowVersion (:214) and LinkedUserId (:226) so a profile edit cannot silently clear the organizer-managed link, updates, and re-fetches the record (:229-230). [Rubric §24, Forms, Validation & UX Safety] and [Rubric §8, Data Architecture]: the round-tripped RowVersion is the client half of optimistic concurrency.
      • +
      • Delete (:249-276): confirm through the shared DeleteConfirmation dialog with the speaker's own name in the prompt (:256), delete, then navigate back to the list (:264-266).
      • +
      • User link and unlink (:279-357): SearchUsersAsync is the notable one. GetPagedAsync ANDs its filters server-side (in-code comment, :288-290), so a single call with email, first name, and last name all set to the same term would return the empty intersection. The page instead fans out three parallel calls (:291-295), unions the results with DistinctBy(u => u.UserId) and takes 10 (:301-305). A cancellation returns an empty list rather than throwing into the autocomplete (:307-310). OnUserPickedAsync (:313-334) calls LinkUserAsync and reloads; UnlinkUserAsync (:336-357) clears the link. This is the flow that produces the speaker_id claim that SpeakerDashboard and SpeakerQr depend on.
      • +
      • Disposal (:363-385) is the standard cancel-on-disposal pattern over the page's CancellationTokenSource (:37); the unsaved-changes guard is wired from _isDirty (:70, SpeakerDetail.razor:11).
      • +
      +
    • +
    • Why it's built this way: an organizer needs one console to fully administer a speaker, including wiring them to a login account; composing the views here (and delegating the category panel) trades page breadth for a one-stop editor. The three-call user search is a deliberate workaround for AND-only server filtering.
    • +
    • Where it's used: the /speakers/{Id} route (SpeakerDetail.razor:1), reached from SpeakerList rows and from SpeakerCreate redirects; it hosts SpeakerCategoryItemsPanel (SpeakerDetail.razor:181) and routes onward to session details (:361).
    • +
    • Caveats / not-in-source: the AND-only semantics of GetPagedAsync are asserted by the in-code comment (:288-290); the filter behavior itself lives in the Identity API, not this page. Likewise the effective read/write authorization for a non-Organizer who reaches this route is enforced server-side and is not visible here.
    • +
    +
    +

    SpeakerDashboard

    +
    +

    MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Speaker · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerDashboard.razor.cs:19 · Level 9 · class (Blazor code-behind)

    +
    +
      +
    • What it is: the speaker's own self-service dashboard, not an organizer page. It reads the linked speaker from the speaker_id JWT claim, shows that speaker's profile and their sessions narrowed to the current or next event, with per-session bookmark counts and lazily loaded per-session feedback, and lets the speaker edit their own bio and social profile, BR-214 (SpeakerDashboard.razor.cs:11-18,44).
    • +
    • Depends on: ISpeakerUIService, ISpeakerDashboardUIService, IEventLookupService, and Blazor's AuthenticationStateProvider (:21-25); SpeakerDTO, SessionDTO, SessionFeedbackDTO (:40), EventInfo (:142), and CurrentEventSelector (:147-152); MudBlazor's ISnackbar.
    • +
    • Concept introduced, claim-driven identity scoping, plus prerender-safe loading and lazy expand. Three ideas converge here.
        +
      1. Claim-driven scoping. Instead of an id from the route, the page derives who you are from the token: OnInitializedAsync reads speaker_id from the auth state (:72-80) and falls into a "not linked" state (_hasSpeakerId = false, :78) when the claim is absent or unparsable, which the markup renders as an explanatory alert (SpeakerDashboard.razor:17-21). [Rubric §11, Security] and [Rubric §26, Front-End Security] (assess that scoping derives from trusted server-issued claims, not client-supplied ids): a speaker can only load their own dashboard. The class doc adds the corollary (:15-17): a speaker is not a privileged reader, so the server returns only publicly visible sessions (BR-49, accepted or unset), and a submission still under review does not appear here, which the empty-state copy names.
      2. +
      3. Prerender-safe loading. The method returns early when !RendererInfo.IsInteractive (:62-68), so the profile, the sessions, and the bookmark counts are not fetched twice per visit; the prerender pass paints the loading skeleton instead, and the in-code comment names the ADCHome page as the precedent (:62-64). [Rubric §23, Front-End Performance & Rendering].
      4. +
      5. Lazy expand. ToggleFeedbackAsync (:226-262) uses the HashSet.Add return value as the toggle itself (:228-232), fetches a session's feedback only the first time its panel opens, and caches it in _sessionFeedback (:234-237,244-248), so first paint never fans out one feedback call per session. A per-session _feedbackLoading set (:42,239,260) drives the spinner for just the row being expanded. [Rubric §19, State Management & Data Flow].
      6. +
      +
    • +
    • Walkthrough
        +
      • Load (:54-140): breadcrumbs (:56-60), prerender guard, claim read, GetByIdAsync(_speakerId, true, ...) (:86), then the speaker's sessions through DashboardService.GetSpeakerSessionsAsync, ordered by StartsAt (:95-96). The comment at :92-94 records why that read goes through the dashboard service: it bypasses the shared sessions output cache (ADR-040), so a just-made speaker assignment shows immediately instead of lagging behind a cached public list.
      • +
      • Narrowing (:98-109): ResolveCurrentEventAsync (:142-159) resolves the current or next event through CurrentEventSelector.SelectCurrentOrNext, passing the start, end, and time-zone accessors plus DateTime.UtcNow (:147-152). The page keeps both lists: _allSpeakerSessions and the filtered _speakerSessions (:36-37), falling back to the unfiltered list when no event resolves (:107-108). A failed lookup is swallowed and treated as "no event" (:154-158).
      • +
      • Bookmark counts (:111-126): one batched call, GetSessionBookmarkCountsAsync, fills every count, with GetValueOrDefault supplying zero for a session nobody bookmarked (:117-121). The comment (:111-113) records what it replaced: each count used to be its own cross-service hop (HTTP to Conference, then gRPC to Engagement). The call sits in its own best-effort catch that re-raises nothing (:123-126), so a failed count read never breaks the render. [Rubric §12, Performance & Scalability] and [Rubric §29, Resilience & Business Continuity].
      • +
      • Profile editing (:161-224): StartEditingProfile seeds six _edit* fields (:161-175) and CancelEditingProfile is a one-line discard (:177). SaveProfileAsync (:179-224) rebuilds a SpeakerDTO that preserves RowVersion, first, last, and full name, Email, ProfilePicture, and LinkedUserId from the loaded record (:189-205), so a self-edit can only change the six fields the speaker owns and cannot clear the organizer-managed ones. [Rubric §11, Security] and [Rubric §24, Forms, Validation & UX Safety].
      • +
      • Disposal (:264-286) is the standard cancel-on-disposal pattern over the CancellationTokenSource at :27.
      • +
      +
    • +
    • Why it's built this way: the speaker portal is a distinct actor view. Scoping by claim is the secure way to hand a speaker exactly their own data without an authorization argument on every call, and the batched counts plus the prerender skip keep a cross-service-heavy page responsive.
    • +
    • Where it's used: the /speaker/dashboard route, [Authorize] with no role requirement (SpeakerDashboard.razor:1-2), gated in practice on the speaker_id claim that appears once an organizer links a User to a Speaker in SpeakerDetail. SpeakerQr is its companion page.
    • +
    • Caveats / not-in-source: the output-cache bypass and the batched-endpoint rationale are documented by in-code comments (:92-94,111-113); the caching and batching behavior itself lives in the Conference service.
    • +
    +
    +

    SpeakerList

    +
    +

    MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Speaker · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerList.razor.cs:19 · Level 9 · class (Blazor code-behind)

    +
    +
      +
    • What it is: the organizer's speaker browse page: server-paged search with avatars, an event filter, delete-with-confirmation, and a card layout on mobile viewports instead of the data grid (SpeakerList.razor.cs:13-18).
    • +
    • Depends on: extends DataGridListPageBase<TDto> (:19, SpeakerList.razor:4); ISpeakerUIService and IEventLookupService (:24-25), SpeakerDTO, EventInfo (:39), CurrentEventSelector (:103-108), ListPageActions (:113,165), ErrorMessages (:171), ConferenceRoutePaths (:174-175), MobileInfiniteScrollList<TItem> (:33), and the shared DeleteConfirmation component (:34).
    • +
    • Concept: the same event-filtered list shape as PublicSpeakerList, with the audience logic removed. Every reader of this page is already an Organizer (SpeakerList.razor:2), so the filter choice is persisted unconditionally (:41-49) and the "all" sentinel is the only distinction that matters: it separates an explicit clear from no saved state, which is what triggers the computed default (in-code comment, :46). Reading the two pages side by side is the clearest way to see what the privileged/non-privileged split actually costs on the public one: a role check and a narrowed persistence rule. + Three mechanisms are worth naming.
        +
      1. Restore, then reconcile. RestoreFilters (:51-68) parses the saved search term and event id; ResolveDefaultEventFilter (:92-110) keeps a restored id only if it still exists in the loaded event set and otherwise falls back to CurrentEventSelector.SelectCurrentOrNext (:101-108), so a dangling id from a deleted event produces the current conference rather than an empty grid. [Rubric §19, State Management & Data Flow] and [Rubric §25, Navigation & Information Architecture].
      2. +
      3. A startup race guard. OnInitializedAsync assigns _eventsLoadTask before awaiting it (:70-76) and both LoadServerData (:128-140) and FetchMobilePage (:151-159) await that same task before applying filters, because the MudDataGrid's first ServerData call can run ahead of initialization completing. The in-code comments state the invariant twice (:72-73,130-131): the default event filter must be resolved before the first fetch, or the grid loads an unfiltered page.
      4. +
      5. A non-fatal lookup. LoadEventsAndResolveDefaultAsync (:78-90) swallows a failed event lookup (:84-87), leaving the picker hidden and the filter unset rather than failing the page. [Rubric §29, Resilience & Business Continuity]. + [Rubric §16, Maintainability] (assesses reuse of one tested shape rather than parallel implementations): paging, rows-per-page, scroll restoration, the IsLoading / LoadFailed flags, CancelLoading, and the mobile/desktop switch all live in the base (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:20,40,44,722), so this page supplies only its filters, its two fetch delegates, and its navigation.
      6. +
      +
    • +
    • Walkthrough
        +
      • ApplyFilters (:142-148): FullName contains plus the virtual EventId equals filter, which the speakers/paged endpoint intercepts and resolves through the EventSpeaker and SessionSpeaker joins, because a Speaker row has no EventId column (class doc, :15-17).
      • +
      • LoadServerData (:128-140) does not pass showCancelSnackbar: false, so the base's default of true applies (DataGridListPageBase.cs:433-438): an organizer page notifies on a cancelled fetch, where the public lists stay silent.
      • +
      • RetryLoadAsync (:31-32) re-runs the server fetch from the inline error state the base renders when LoadFailed is set, so a failed load offers a retry instead of a dead grid.
      • +
      • OnSearchChanged (:115-119) and OnEventFilterChanged (:121-126) both funnel through ReloadActiveLayoutAsync (:112-113), which delegates to ListPageActions.ReloadActiveLayoutAsync and reloads whichever of the grid or the infinite-scroll list is currently mounted. [Rubric §22, Responsive & Cross-Browser]: one filter change, two possible layouts, one code path.
      • +
      • FetchMobilePage (:151-159) builds the same filters and always sorts by FullName ascending, since the card list has no sortable headers.
      • +
      • DeleteSpeakerAsync (:164-172) delegates the whole confirm, delete, notify, reload cycle to ListPageActions.DeleteWithConfirmationAsync, passing the delete lambda and the localized messages; a Speaker is a top-level entity, so it deletes by a single id (contrast the child-entity list pages, which pass a parent id too).
      • +
      • NavigateToCreate and NavigateToDetails (:174-175) reach SpeakerCreate and SpeakerDetail; OnMobileCardClick (:161) reuses the same detail navigation.
      • +
      +
    • +
    • Why it's built this way: organizers work one conference at a time, so the list defaults to the current or next event; everything else is the shared base doing the paging, restoration, and layout switching.
    • +
    • Where it's used: the /speakers route, restricted to the Organizer role (SpeakerList.razor:1-2), the entry point for the whole speaker admin flow.
    • +
    • Caveats / not-in-source: the join-based resolution of the virtual EventId filter is asserted by the class doc comment (:15-17); the resolution itself lives in the Conference API.
    • +

    CachedSessionPage

    -

    MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSessionList.razor.cs:342 · Level 3 · record (private sealed, nested)

    +

    MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSessionList.razor.cs:366 · Level 3 · record (private sealed, nested)

      -
    • What it is: the serialization payload for the offline schedule snapshot: a List<SessionDTO> Items plus the int TotalItems count, declared as a private nested record inside PublicSessionList (PublicSessionList.razor.cs:342).
    • +
    • What it is: the serialization payload for the offline schedule snapshot, a List<SessionDTO> Items plus the int TotalItems count, declared as a private sealed record nested inside PublicSessionList (PublicSessionList.razor.cs:366).
    • Depends on: SessionDTO; persisted through ILocalCacheStore and gated by IConnectivityStatusService.
    • Concept introduced, the offline read snapshot (ADR-042 Wave 3). Conference day is exactly when the venue network is worst and the schedule matters most, so the page keeps the last good first page in device storage and replays it when a live fetch throws while offline. [Rubric §29, Resilience & Business Continuity] (assesses graceful degradation when a dependency is unreachable): the failure mode becomes stale-but-useful instead of an empty grid. [Rubric §23, Front-End Performance & Rendering] (assesses caching of read payloads): the snapshot is written on the success path and read only on the failure path, so it never adds latency to a healthy fetch.
    • -
    • Walkthrough: a one-line positional record (PublicSessionList.razor.cs:342). It is written after every successful page-1 fetch when the store reports itself available (:316-320), keyed by the constant ScheduleCacheKey = "conference.publicSessions.page1" (:42). It is read back only inside the exception filter when (!Connectivity.IsOnline && CacheStore.IsAvailable && page == 1) (:325-336); if no snapshot exists the original exception is rethrown (:328-331), and a successful replay sets _showingCachedData = true (:333, field at :340) so the markup can flag the view as cached.
    • +
    • Walkthrough: a one-line positional record (PublicSessionList.razor.cs:366). It is written after every successful page-1 fetch when the store reports itself available (:340-344), keyed by the constant ScheduleCacheKey = "conference.publicSessions.page1" (:42). It is read back only inside the exception filter when (!Connectivity.IsOnline && CacheStore.IsAvailable && page == 1) (:349); if no snapshot exists the original exception is rethrown (:352-355), and a successful replay sets _showingCachedData = true (:357, field at :364) and calls StateHasChanged() (:358) so the markup can flag the view as cached. The success path clears that flag (:346), so a recovered network drops the banner on the next fetch. The banner itself is a warning-coloured cloud-off chip (PublicSessionList.razor:23-29).
    • Why it's built this way: pairing the items with their total gives the grid's paging math a coherent shape to replay, and restricting the snapshot to page 1 keeps the stored payload bounded (page 1 is what an offline attendee lands on).
    • Where it's used: read and written exclusively by PublicSessionList's FetchSessionsAsync.

    +

    PublicScheduleRoomOptions

    +
    +

    MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicScheduleRoomOptions.cs:11 · Level 3 · class (internal static)

    +
    +
      +
    • What it is: the pure function behind the public schedule's Room picker. Given the events PublicSessionList has already loaded, it returns the ordered room options for the active event filter plus the room filter that survives that scoping (PublicScheduleRoomOptions.cs:21-42).
    • +
    • Depends on: RoomDTO and EventDTO from MMCA.ADC.Conference.Shared.Events (:1), plus the RoomIdentifierType and EventIdentifierType aliases; nothing else. No injected service, no fetch, no component base.
    • +
    • Concept introduced, the derived filter option set with a self-healing selection. Two ideas are worth extracting from a 30-line file.
        +
      1. Derive, do not fetch. The class doc states the rule (:5-10): the page already reads /events?includeChildren=true, so its rooms are in memory and narrowing the schedule by room costs zero extra round trips. The same doc records the security consequence that comes for free: that events read is published-only for non-privileged audiences server-side, so an unpublished event's rooms can never reach the picker. [Rubric §23, Front-End Performance & Rendering] (assesses avoidable network work per interaction) and [Rubric §26, Front-End Security] (assesses that a client-derived option set cannot widen what the server already scoped).
      2. +
      3. A selection that cannot go stale. The last block (:35-39) re-validates selectedRoomId against the freshly scoped list and returns null when the room is no longer offered. The comment names both ways that happens: the reader switched events, or a stale choice was restored from saved page state. Without it, an unreachable room id would filter every session out and the reader would see an empty schedule with no visible cause. [Rubric §19, State Management & Data Flow] (assesses that derived state is reconciled rather than left to drift) and [Rubric §24, Forms, Validation & UX Safety].
      4. +
      +
    • +
    • Walkthrough: Scope (:21-42) is the only member.
        +
      • Scoping (:26-28): a non-null eventId takes that event's Rooms (an unknown event id yields an empty list through the ?? [] fallback); a null id, which only a privileged reader viewing every event can produce, takes the union across all loaded events.
      • +
      • Shaping (:30-33): DistinctBy(r => r.Id) because the union can repeat a room, then OrderBy(Sort).ThenBy(Name, StringComparer.OrdinalIgnoreCase) so the picker order is the organizer's intended order with a deterministic case-insensitive tiebreak.
      • +
      • Reconciliation (:37-39) and the tuple return (:41), which the caller destructures straight into its two fields.
      • +
      +
    • +
    • Why it's built this way: keeping this out of the page makes it a plain static function over data, which is directly unit-testable without a renderer, and it keeps the page's own code down to one line (PublicSessionList.razor.cs:194-195). [Rubric §14, Testability] (assesses whether logic can be exercised without its host) and [Rubric §1, SOLID].
    • +
    • Where it's used: called only by PublicSessionList's RefreshRoomOptions, from the initial event load (PublicSessionList.razor.cs:190) and from every event-filter change (:255). The options it returns are passed down to PublicSessionListFilterBar's Rooms parameter.
    • +
    +
    +

    ConferenceCategoryCreate

    +
    +

    MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.ConferenceCategory · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/ConferenceCategory/ConferenceCategoryCreate.razor.cs:9 · Level 5 · class (Blazor code-behind)

    +
    +
      +
    • What it is: the organizer's category-creation form. It collects three fields (title, sort order, type), posts one ConferenceCategoryDTO through the UI service, and redirects to the detail page for the record it just made.
    • +
    • Depends on: IConferenceCategoryUIService (:11), ConferenceCategoryDTO (:58), ConferenceRoutePaths (:26,62,78), and ErrorMessages (:51); MudBlazor's MudForm, ISnackbar and BreadcrumbItem, plus NavigationManager and the page's IStringLocalizer (ConferenceCategoryCreate.razor:4).
    • +
    • Concept introduced, the create-page shape and its three safety rails. This is the smallest create form in the group, which makes it the clearest place to read the shape every other one repeats.
        +
      1. Validate before you mutate. CreateCategoryAsync calls await _form.ValidateAsync() and returns with a warning snackbar when !_form.IsValid (:48-53), before any service call. The server validates again; this pass exists to keep a round trip off the wire and to put the message next to the field. [Rubric §24, Forms, Validation & UX Safety] (assesses whether a form can submit itself into a predictable failure).
      2. +
      3. Dirty tracking that cannot block its own redirect. Every editable control calls MarkDirty() (:39) and the markup mounts the shared guard as <UnsavedChangesGuard IsDirty="_isDirty" IsDirtyAccessor="() => _isDirty" /> (ConferenceCategoryCreate.razor:8). The accessor is the load-bearing half: the guard prefers IsDirtyAccessor?.Invoke() over the parameter snapshot (MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/UnsavedChangesGuard.razor:33,35), because clearing the flag and calling NavigateTo without an intervening StateHasChanged() would otherwise still prompt. The page clears _isDirty on the success path before navigating, with the reason written on the line (:60).
      4. +
      5. Cancel on disposal. A private CancellationTokenSource (:15) is passed into the service call (:59) and cancelled in a full Dispose(bool) pattern (:82-102), with OperationCanceledException caught and ignored as the expected teardown outcome (:64-67). [Rubric §23, Front-End Performance & Rendering]: a form abandoned mid-post does not keep a response alive for a component that no longer exists. + Unlike the int-keyed create forms elsewhere in this module, this page sends Id = default (:58) and lets the server assign the key, then navigates on created.Id read back from the response (:62). [Rubric §8, Data Architecture] (assesses a deliberate identity strategy): the per-entity identifier alias (ADR-048) keeps the key type out of the page's own logic.
      6. +
      +
    • +
    • Walkthrough
        +
      • OnInitialized (:20-29) builds the Home / Categories / Create breadcrumb trail from localized resource strings, with the middle crumb pointing at ConferenceRoutePaths.ConferenceCategories (:26).
      • +
      • The field block carries a comment worth reading (:32-33): the backing field is _categoryTitle, not _title, so it does not collide with the localized Title page property that SonarAnalyzer S4275 would flag. This is the analyzers-as-errors baseline showing up in page code.
      • +
      • CreateCategoryAsync (:41-76): null-guard the form (:43-46), validate, set IsSaving (:55), build the DTO (:58), post (:59), clear the dirty flag, snackbar the success (:61), redirect to the detail route (:62); the finally always clears IsSaving (:72-75) so a failed save leaves an enabled button rather than a stuck spinner.
      • +
      • NavigateToList (:78) is the cancel action, and it routes through ConferenceRoutePaths rather than a literal. [Rubric §25, Navigation & Information Architecture]: every route in the module is a named constant in one file.
      • +
      +
    • +
    • Why it's built this way: one create shape repeated per entity keeps the organizer's mental model constant (fill, validate, save, land on the new record) while each page varies only in the fields it collects.
    • +
    • Where it's used: the /conferencecategories/create route, carrying [Authorize(Roles = "Organizer")] on the page itself (ConferenceCategoryCreate.razor:1-2). It is reached from ConferenceCategoryList's create button and redirects to ConferenceCategoryDetail.
    • +
    +

    PublicSessionListFilterBar

    MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSessionListFilterBar.razor.cs:15 · Level 5 · class (Blazor code-behind)

      -
    • What it is: the presentational filter bar for PublicSessionList: the privileged-reader Filter-by-Event picker (or the locked "Showing" chip for everyone else), the title search box, the All Sessions / My Schedule toggle, and the share-my-schedule action (PublicSessionListFilterBar.razor.cs:8-14).
    • -
    • Depends on: EventDTO; IScreenshotService, IShareService, and MudBlazor's ISnackbar (:17-19).
    • -
    • Concept introduced, the container/presentational split. The bar owns no filter state. Every value arrives as a [Parameter] and every change leaves through a matching EventCallback: IsPrivileged (:25), Events (:28), SelectedEventId / SelectedEventIdChanged (:31-34), SearchString / SearchStringChanged (:37-40), and ShowMyScheduleOnly / ShowMyScheduleOnlyChanged (:43-46). The page stays the single source of truth and the bar is a pure view over it. [Rubric §18, UI Architecture & Component Design] (assesses decomposition and separation of layout from behavior) and [Rubric §19, State Management & Data Flow] (assesses where mutable state lives): with no lifecycle of its own, the bar cannot drift from the data the grid actually fetched. Note the parameter name: it is IsPrivileged, not "is organizer", because the privileged read audience is a role set (ConferenceReadAudience) rather than one role.
    • +
    • What it is: the presentational filter bar for PublicSessionList: the privileged-reader Filter-by-Event picker (or the locked "Showing" chip for everyone else), the debounced title search box, the Room picker, the All Sessions / My Schedule toggle, and the share-my-schedule action (PublicSessionListFilterBar.razor.cs:8-14).
    • +
    • Depends on: EventDTO and RoomDTO (:2); IScreenshotService, IShareService, and MudBlazor's ISnackbar (:17-19). Its Rooms option set is produced by PublicScheduleRoomOptions.
    • +
    • Concept introduced, the container/presentational split. The bar owns no filter state. Every value arrives as a [Parameter] and every change leaves through a matching EventCallback: IsPrivileged (:25), Events (:28), SelectedEventId / SelectedEventIdChanged (:31,34), SearchString / SearchStringChanged (:37,40), Rooms (:46), SelectedRoomId / SelectedRoomIdChanged (:49,52), and ShowMyScheduleOnly / ShowMyScheduleOnlyChanged (:55,58). The page stays the single source of truth and the bar is a pure view over it, with no lifecycle method of its own. [Rubric §18, UI Architecture & Component Design] (assesses decomposition and separation of layout from behavior) and [Rubric §19, State Management & Data Flow] (assesses where mutable state lives): with nothing to initialize, the bar cannot drift from the data the grid actually fetched. + Two naming and behavior details reward a close read. The parameter is IsPrivileged, not "is organizer", because the privileged read audience is a role set (ConferenceReadAudience) rather than one role. And Rooms documents its own empty case (:42-45): an empty list hides the Room picker entirely, because an event with no rooms has nothing to narrow by. [Rubric §24, Forms, Validation & UX Safety]: a control with no meaningful options is removed rather than shown disabled.
    • Walkthrough
        -
      • GetSelectedEventName() (:48-49): resolves the chip label from the passed-in Events list, returning empty when nothing is selected.
      • -
      • ShareScheduleAsync() (:51-59): captures the current view to a file through IScreenshotService and hands it to IShareService; a null capture or a failed share raises one warning snackbar (:54-58). This is a native-head capability (ADR-042) that degrades quietly on the web.
      • +
      • GetSelectedEventName() (:60-61): resolves the chip label from the passed-in Events list, returning empty when nothing is selected.
      • +
      • ShareScheduleAsync() (:63-71): captures the current view to a file through IScreenshotService and hands it to IShareService as image/png (:65-67); a null capture or a failed share collapses into one warning snackbar (:69). This is a native-head capability (ADR-042) that degrades quietly on the web. [Rubric §29, Resilience & Business Continuity].
    • Why it's built this way: pushing all filter state to the page means the same chrome can sit above both the desktop grid and the mobile card list without either layout owning a second copy of the filters.
    • -
    • Where it's used: rendered by PublicSessionList; its callbacks land on that page's OnSearchChanged / OnEventFilterChanged / OnMyScheduleToggled handlers (PublicSessionList.razor.cs:229-245).
    • +
    • Where it's used: rendered once by PublicSessionList (PublicSessionList.razor:11-21); its callbacks land on that page's OnEventFilterChanged, OnSearchChanged, OnRoomFilterChanged and OnMyScheduleToggled handlers (PublicSessionList.razor.cs:246-269).

    -

    SpeakerCreate

    +

    ConferenceCategoryDetail

    -

    MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Speaker · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerCreate.razor.cs:13 · Level 5 · class (Blazor code-behind)

    +

    MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.ConferenceCategory · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/ConferenceCategory/ConferenceCategoryDetail.razor.cs:11 · Level 7 · class (Blazor code-behind)

      -
    • What it is: the organizer's speaker-creation form: first/last name, bio, tagline, email, profile picture, and the four social links, collected into a new SpeakerDTO and posted (SpeakerCreate.razor.cs:9-12).
    • -
    • Depends on: ISpeakerUIService (:15), SpeakerDTO, ConferenceRoutePaths (:30,88,104), and ErrorMessages (:62); MudBlazor (MudForm, ISnackbar, BreadcrumbItem) and NavigationManager.
    • -
    • Concept: the create-page pattern the group teaches once (validate before mutate, dirty tracking, cancel on disposal), here in its widest-form variant. [Rubric §24, Forms, Validation & UX Safety] (assesses validate-before-submit and unsaved-change protection): CreateSpeakerAsync calls await _form.ValidateAsync() and bails with a warning snackbar when !_form.IsValid (:59-64) before touching the service, and _isDirty (set by MarkDirty(), :50) is cleared the instant the save succeeds, before navigating (:86), so the unsaved-changes guard cannot block its own redirect. The CancellationTokenSource (:19) is passed to the service call (:85) and cancelled in the standard dispose pattern (:108-128), with OperationCanceledException swallowed as the expected teardown outcome (:90-93). - The identifier detail worth noting: Speaker is Guid-keyed, so the page mints a genuinely unique id client-side with Guid.NewGuid() (:71) rather than the random-int placeholder the int-keyed create forms use. [Rubric §8, Data Architecture] (assesses a deliberate identity strategy): the per-entity identifier alias (ADR-048) keeps the key type out of the page's own logic, and the page reads created.Id back from the response (:88) either way.
    • -
    • Walkthrough: OnInitialized (:24-33) builds the Home / Speakers / Create breadcrumb trail; CreateSpeakerAsync (:52-102) validates, sets IsSaving, composes FullName from the two name fields (:74), builds the DTO with all optional profile and social fields (:69-83), calls SpeakerService.AddAsync (:85), and navigates to ConferenceRoutePaths.SpeakerDetails(created.Id) (:88); the finally always clears IsSaving (:98-101).
    • -
    • Why it's built this way: one create-form shape reused per entity keeps the flow uniform (validate, post, redirect to detail) while each page varies only in the fields it collects.
    • -
    • Where it's used: the /speakers/create route (SpeakerCreate.razor:1), reached from SpeakerList's create button; it redirects to SpeakerDetail.
    • -
    • Caveats / not-in-source: whether the server honors or replaces the client-minted Guid is a server-side decision not visible here; the page uses the id from the response.
    • +
    • What it is: the organizer's category console. It loads one ConferenceCategoryDTO with its children, inline-edits the category itself, and runs a full add / edit / delete loop over its CategoryItemDTO rows on the same page.
    • +
    • Depends on: IConferenceCategoryUIService and ICategoryItemUIService (:15-16), ConferenceCategoryDTO and CategoryItemDTO, the CategoryItemIdentifierType alias (:60), DomainHelper's Id.Parse<T> extension (:76), ConferenceRoutePaths (:33,302), ErrorMessages (:80,89,127,147,180,205), and the shared DeleteConfirmation component twice over (:49,59).
    • +
    • Concept introduced, the parent-with-children editor, and shadow fields as the edit buffer. Two mechanisms carry this page.
        +
      1. Shadow fields. Entering edit mode copies the live record into _edit* fields (StartEditing, :97-109) and cancelling simply drops them (CancelEditing, :111-115). The loaded Category is never mutated, so an abandoned edit leaves nothing behind and the rendered values stay exactly what the server last returned. The item editor repeats the idea with _editingItemId / _editItemName / _editItemSort (:60-62, seeded at :232-238). [Rubric §19, State Management & Data Flow] (assesses where mutable state lives and how long it lives).
      2. +
      3. Refetch, do not patch. Every mutation is followed by Category = await CategoryService.GetByIdAsync(Category.Id, true, _cts.Token) (:136, :214, :260, :289). The page never edits its local child collection: the server's answer is the only rendering source. That costs one extra read per action and removes an entire class of drift between what was saved and what is shown. [Rubric §19, State Management & Data Flow] and [Rubric §8, Data Architecture]. + The save path also round-trips the concurrency token: the updated DTO carries RowVersion = Category.RowVersion (:134), which is the client half of the optimistic-concurrency contract in ADR-035. [Rubric §8, Data Architecture] (assesses how concurrent writes are reconciled): a stale editor loses the write instead of silently overwriting a newer one. Note that the item update path (:258) does not carry a RowVersion, so a category item is a last-writer-wins edit while the category itself is not.
      4. +
      +
    • +
    • Walkthrough
        +
      • OnInitialized (:27-36) builds the Home / Categories / Details breadcrumb trail.
      • +
      • OnParametersSetAsync (:64-95): the load-once-on-parameters guard compares the route Id against _loadedId (:66-71) so a re-render does not refetch, parses the id to ConferenceCategoryIdentifierType (:76), fetches with children (:77), and reports a null result as a not-found snackbar through ErrorMessages (:80). OperationCanceledException is swallowed as the expected teardown (or InteractiveAuto transition) outcome (:83-86) and the finally always clears IsLoading.
      • +
      • Category edit (:97-153): StartEditing / CancelEditing as above, then SaveChangesAsync (:117-153) validates the MudForm first (:124-129), rebuilds the DTO with the round-tripped RowVersion (:134), updates, refetches, and clears both _isDirty and _isEditing on success (:138-139).
      • +
      • Category delete (:155-182): confirm through the shared DeleteConfirmation dialog seeded with the category title (:162), delete, then navigate back to the list (:172).
      • +
      • Item CRUD (:184-300): StartAddingItem resets the new-item fields and closes any open row edit (:185-191), which is what keeps the two editors mutually exclusive; AddItemAsync (:195-230) validates its own separate MudForm (:202-207), posts a CategoryItemDTO stamped with the parent CategoryId (:212), and refetches. UpdateItemAsync (:242-276) is the one path that does not use a MudForm: it hand-checks string.IsNullOrWhiteSpace(_editItemName) and warns (:249-253), because the row editor is inline in the table rather than a form. DeleteItemAsync (:278-300) confirms through the second dialog instance (:280) and refetches.
      • +
      • Disposal (:306-326) is the standard cancel-on-disposal pattern over the CancellationTokenSource at :22; the markup mounts the unsaved-changes guard with the same accessor form the create page uses (ConferenceCategoryDetail.razor:9).
      • +
      +
    • +
    • Why it's built this way: a category is only meaningful together with its items (a topic list, a session-level list, a locality list), so editing them on two routes would be worse than a slightly larger page. Refetching after every mutation is the cheap way to keep a composite view coherent without a client-side store.
    • +
    • Where it's used: the /conferencecategories/{Id} route with [Authorize(Roles = "Organizer")] (ConferenceCategoryDetail.razor:1-2), reached from ConferenceCategoryList rows and ConferenceCategoryCreate redirects. The items it authors are what ICategoryItemLookupService resolves for the session and speaker pages, including PublicSessionDetail's category chips.

    -

    PublicEventList

    +

    ConferenceCategoryList

    -

    MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicEventList.razor.cs:17 · Level 7 · class (Blazor code-behind)

    +

    MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.ConferenceCategory · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/ConferenceCategory/ConferenceCategoryList.razor.cs:11 · Level 7 · class (Blazor code-behind)

      -
    • What it is: the anonymous-friendly event browse page. It lists published events for everyone; privileged readers (Organizer/ContentEditor) additionally see unpublished ones, because the server applies the published-event specification only to non-privileged callers, BR-108 (PublicEventList.razor.cs:11-16).
    • -
    • Depends on: extends DataGridListPageBase<TDto> (:17); IEventUIService (:21), EventDTO, ConferenceRoutePaths (:66), MobileInfiniteScrollList<TItem> (:29), and ListPageActions (:41). Server-side the audience split is enforced by PublishedEventSpecification.
    • -
    • Concept: the simplest instance of the ADC list-page pattern (introduced on the organizer list pages): the base owns paging, rows-per-page, scroll restoration, the loading and LoadFailed flags, and the mobile/desktop switch, so this page supplies only four things: the GridRef override (:25), SaveFilters / RestoreFilters for the search box (:32-36), a LoadServerData delegate that turns the search string into a Name contains server filter (:44-54), and the parallel FetchMobilePage for the infinite-scroll card list (:57-63). [Rubric §22, Responsive & Cross-Browser] (assesses a real mobile layout rather than a shrunk grid): the same service call backs both branches, selected by the base's IsMobile. [Rubric §23, Front-End Performance & Rendering]: search and paging are pushed to the server, so the client never materializes the full event table. [Rubric §25, Navigation & Information Architecture]: the search term is persisted through the base's filter contract, so a back-navigation returns the reader to the same view.
    • -
    • Walkthrough
        -
      • RetryLoadAsync (:28) re-runs the server fetch from the inline error state the base renders when LoadFailed is set: the failure path offers a retry instead of a dead grid. [Rubric §29, Resilience & Business Continuity].
      • -
      • OnSearchChanged (:38-42) stores the term then reloads whichever layout is active through ListPageActions.ReloadActiveLayoutAsync.
      • -
      • LoadServerData (:44-54) passes showCancelSnackbar: false, so a superseded fetch (the reader typed another character) is silent rather than raising a toast.
      • -
      • OnMobileCardClick (:65-66) routes to PublicEventDetail.
      • +
      • What it is: the organizer's category browse page: a server-paged grid with a single title search box, delete-with-confirmation, and a mobile card layout. It is a thin binding over the shared list-page base.
      • +
      • Depends on: extends DataGridListPageBase<TDto> closed over ConferenceCategoryDTO (:11); IConferenceCategoryUIService (:16), MobileInfiniteScrollList<TItem> (:24), ListPageActions (:35,68), ErrorMessages (:74), ConferenceRoutePaths (:64,77), and the shared DeleteConfirmation component (:25).
      • +
      • Concept introduced, the list page as a set of overrides. The base owns the machinery: LoadFailed, the abstract Title, the IsMobile switch, the mobile paging fields, the filter save/restore contract, and LoadServerDataAsync (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:40,41,44,47-50,108,111,121,434). This page supplies five things and nothing else.
          +
        • The captured grid reference, through the GridRef override (:19-20), which is how the base restores rows-per-page and the current page after a back-navigation.
        • +
        • SaveFilters / RestoreFilters for the one search term (:28-32). [Rubric §25, Navigation & Information Architecture]: a reader who opens a record and comes back finds the same view, not a reset grid.
        • +
        • LoadServerData (:43-52), which hands the base a fetch delegate and a filter builder that turns the search string into a server-side Title contains filter. [Rubric §12, Performance & Scalability] and [Rubric §23, Front-End Performance & Rendering]: search, sort and paging all execute where the data is, so the client never materializes a whole table.
        • +
        • FetchMobilePage (:55-61), the parallel path for the infinite-scroll card list, hard-sorted by Title ascending. [Rubric §22, Responsive & Cross-Browser] (assesses a genuine mobile layout rather than a shrunk grid): the same service call backs both branches, selected by the base's IsMobile.
        • +
        • RetryLoadAsync (:23), which re-runs the fetch from the inline error state the base renders when LoadFailed is set. [Rubric §29, Resilience & Business Continuity]: a failed load offers a retry instead of a dead grid. + Two details separate this page from its siblings. Both fetch paths pass includeChildren: true (:47,60), because both layouts render the child item count, which is the one place a list page in this module pays for children. And deletion is not reimplemented: ListPageActions.DeleteWithConfirmationAsync (:67-75) takes the dialog, the label to show, the delete call, the snackbar, the localized success text, an error formatter, and the reload callback. [Rubric §1, SOLID] and [Rubric §16, Maintainability] (assess whether repeated behavior has one implementation): confirm, delete, toast, reload lives in one helper for every list page in the app.
      • -
      • Why it's built this way: the public and organizer event lists differ only in audience and route, so the public one is a thin binding over the same shared base rather than a second grid implementation.
      • -
      • Where it's used: the /conference/events route (PublicEventList.razor:1); rows and cards navigate to PublicEventDetail.
      • +
      • Walkthrough: ReloadActiveLayoutAsync (:34-35) asks ListPageActions to reload whichever of the two layouts is live, and is the single reload entry point shared by search changes and post-delete refreshes; OnSearchChanged (:37-41) stores the term and calls it; LoadServerData (:43-52) and FetchMobilePage (:55-61) apply the same contains filter to the desktop and mobile paths; OnMobileCardClick (:63-64) and NavigateToCreate (:77) route through ConferenceRoutePaths. Note that LoadServerData does not pass showCancelSnackbar: false, so unlike the public lists the base's default cancel notification applies here.
      • +
      • Why it's built this way: several near-identical organizer browse surfaces are exactly the case a base class is for. Because each page is only its overrides, a change to paging, scroll restoration or the mobile switch lands in one place and every list inherits it.
      • +
      • Where it's used: the /conferencecategories route with [Authorize(Roles = "Organizer")] (ConferenceCategoryList.razor:1-2). Rows and cards navigate to ConferenceCategoryDetail; the create button opens ConferenceCategoryCreate.

      PublicSessionListView

      -

      MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSessionListView.razor.cs:21 · Level 7 · class (Blazor code-behind)

      +

      MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSessionListView.razor.cs:23 · Level 7 · class (Blazor code-behind)

        -
      • What it is: the presentational session-list view for PublicSessionList: the mobile infinite-scroll card list and the desktop server-paged data grid, including the inline bookmark stars and their toggle flow (PublicSessionListView.razor.cs:12-20).
      • -
      • Depends on: SessionDTO, ISessionBookmarkUIService (optional, :55), SpeakerInfo (:64), ConferenceRoutePaths (:174), MobileInfiniteScrollList<TItem> (:75), ListPageActions (:89), and IHapticFeedbackService (:24); MudBlazor grid types and NavigationManager.
      • -
      • Concept introduced, the presentational child that patches container-owned state in place. Like PublicSessionListFilterBar, the view owns no fetch or filter state: the page hands down its ServerData and FetchPage delegates (:70,73), its paging parameters (:37-43), the speaker and room lookups (:64,67), and the shared BookmarkedSessions dictionary (:61). The subtlety is that the view mutates that dictionary in place when a star is toggled (AddBookmarkAsync writes BookmarkedSessions[sessionId] = bookmark.Id at :152, RemoveBookmarkAsync removes at :137), so the page's "My Schedule" fetch, which reads the same dictionary to build its Id IN (...) filter, sees the change without a round trip. It also exposes the captured Grid reference (:85) and ReloadAsync() (:88-89) so the page's DataGridListPageBase<TDto> plumbing keeps restoring rows-per-page and current page unchanged. [Rubric §18, UI Architecture & Component Design] and [Rubric §19, State Management & Data Flow]: state has exactly one owner (the page) and one mutation point (this component).
      • +
      • What it is: the presentational session-list view for PublicSessionList: the mobile infinite-scroll card list and the desktop server-paged data grid, including the inline bookmark stars and their toggle flow (PublicSessionListView.razor.cs:12-22).
      • +
      • Depends on: SessionDTO, ISessionBookmarkUIService (optional, :57), SpeakerInfo (:66), ConferenceRoutePaths (:176), MobileInfiniteScrollList<TItem> (:77, rendered at PublicSessionListView.razor:6), ListPageActions (:91), and IHapticFeedbackService (:26); MudBlazor grid types and NavigationManager.
      • +
      • Concept introduced, the presentational child that patches container-owned state in place. Like PublicSessionListFilterBar, the view owns no fetch or filter state: the page hands down its ServerData and FetchPage delegates (:72,75), its paging parameters (:39-45), the speaker and room lookups (:66,69), and the shared BookmarkedSessions dictionary (:63). The subtlety is that the view mutates that dictionary in place when a star is toggled (AddBookmarkAsync writes BookmarkedSessions[sessionId] = bookmark.Id at :154, RemoveBookmarkAsync removes at :139), so the page's My Schedule fetch, which reads the same dictionary to build its Id IN (...) filter, sees the change without a round trip. The class doc names the sibling that uses the same pattern (:16-18). It also exposes the captured Grid reference (:87) and ReloadAsync() (:90-91) so the page's DataGridListPageBase<TDto> plumbing keeps restoring rows-per-page and current page unchanged. [Rubric §18, UI Architecture & Component Design] and [Rubric §19, State Management & Data Flow]: state has exactly one owner (the page) and one mutation point (this component). + The class doc also records a deliberate omission (:20-21): the list shows no track or category chips, because the detail page is where a session's categories are read and the list stays scannable on time, speakers, and room. [Rubric §25, Navigation & Information Architecture].
      • Walkthrough
          -
        • CanBookmark (:98-103): a session is bookmarkable only when the user is authenticated, the Engagement-owned service resolved, the session is not a service session, and its status is unset or "Accepted". The comment (:94-97) records that this literal mirrors SessionStatuses in Conference.Domain, which is the source of truth: the UI layer depends on Shared only, so the check is duplicated rather than referenced, precisely so the UI never shows a star the server would reject. [Rubric §11, Security] and [Rubric §24, Forms, Validation & UX Safety].
        • -
        • ToggleBookmarkAsync (:105-132): guards re-entry with a per-session HashSet (:78), fires Haptics.Click() (:111, a no-op off native heads), then adds or removes. The per-session guard is a fixed defect worth reading: the comment at :76-77 records that a single global in-flight flag made one slow toggle swallow every other star's click.
        • -
        • AddBookmarkAsync (:147-161): a 2xx whose body deserialized to null leaves the star unset, so the page reports a warning rather than a success toast that would contradict its own UI (:156-160).
        • -
        • RemoveBookmarkAsync (:134-145): removes the entry and, when the My Schedule view is active, reloads so the removed row disappears.
        • -
        • GetSpeakerList (:163-171) maps a session's SessionSpeakers to display names through the passed-in lookup; OnMobileCardClick (:173-174) routes to PublicSessionDetail.
        • +
        • IsBookmarked (:93-94) is a dictionary lookup, so star state costs nothing per row.
        • +
        • CanBookmark (:100-105): a session is bookmarkable only when the user is authenticated, the Engagement-owned service resolved, the session is not a service session, and its status is unset or "Accepted". The comment (:96-99) records that this literal mirrors SessionStatuses in Conference.Domain, which is the source of truth: the UI layer depends on Shared only, so the check is duplicated rather than referenced, precisely so the UI never shows a star the server would reject. [Rubric §11, Security] and [Rubric §24, Forms, Validation & UX Safety].
        • +
        • ToggleBookmarkAsync (:107-134): guards re-entry with a per-session HashSet whose Add doubles as the guard test (:109, field at :80), fires Haptics.Click() (:113, a no-op off native heads), then adds or removes, with a single error snackbar around both (:126-129) and removal from the guard set in the finally (:132). The per-session guard is a fixed defect worth reading: the comment at :78-79 records that a single global in-flight flag made one slow toggle swallow every other star's click.
        • +
        • AddBookmarkAsync (:149-163): a 2xx whose body deserialized to null leaves the star unset, so the page reports a warning rather than a success toast that would contradict its own UI (:157-162).
        • +
        • RemoveBookmarkAsync (:136-147): removes the entry and, when the My Schedule view is active, reloads so the removed row disappears (:143-146).
        • +
        • GetSpeakerList (:165-173) maps a session's SessionSpeakers to display names through the passed-in lookup, skipping ids the lookup does not know; OnMobileCardClick (:175-176) routes to PublicSessionDetail.
      • Why it's built this way: separating the grid and card layouts from the page's fetch-and-filter logic lets one bookmark implementation serve both, while the page remains the owner of every piece of state either layout renders.
      • -
      • Where it's used: rendered by PublicSessionList, which holds it as _view (PublicSessionList.razor.cs:44) and reads _view?.Grid for its GridRef override (:70).
      • +
      • Where it's used: rendered by PublicSessionList, which holds it as _view (PublicSessionList.razor.cs:44, captured at PublicSessionList.razor:31) and reads _view?.Grid for its GridRef override (:72) and _view?.ReloadAsync() for every filter change (:271).

      PublicEventDetail

      -

      MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicEventDetail.razor.cs:14 · Level 8 · class (Blazor code-behind)

      +

      MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicEventDetail.razor.cs:16 · Level 8 · class (Blazor code-behind)

        -
      • What it is: the read-only public view of one event: venue information, rooms, support contacts, and the conference-day conveniences (copy the Wi-Fi details, open directions, a distance-to-venue hint, a QR code for the page itself).
      • -
      • Depends on: IEventUIService (:16), EventDTO, ConferenceRoutePaths (:45,102,104), DomainHelper's Id.Parse<T> extension for the route string (:70), and four device-capability abstractions: IClipboardService, IMapNavigationService, IGeolocationService, IGeocodingService (:19-22). It also reads IConfiguration for the host-wide support address (:23,40-41).
      • -
      • Concept introduced, load-once-on-parameters plus best-effort progressive enhancement. Two mechanisms recur across every public detail page.
          -
        1. Load once per id. The route value arrives as [Parameter] string Id (:25), and OnParametersSetAsync compares it against _loadedId (:54-63) so a re-render does not refetch; the typed id is produced by Id.Parse<EventIdentifierType>() (:70).
        2. -
        3. Every capability is optional. TryComputeDistanceAsync (:141-163) returns early when geolocation or geocoding is unsupported or the venue address is blank, and again on any null result, so a denied permission or an offline geocoder simply leaves the hint off. The doc comment states the rule plainly: this must never block the page (:136-140). [Rubric §29, Resilience & Business Continuity] (assesses degradation when an optional dependency is absent) and [Rubric §26, Front-End Security] (a location read is soft and unblocking, never a gate on content). These come from the device-capability layer of ADR-042. - A third detail is a small but real configuration rule: a per-event OrganizerContactEmail wins over the host-wide Support:Email, and it is re-evaluated on every load so navigating between events never leaves the previous organizer's address on screen (:78-83). [Rubric §16, Maintainability]: a conference can publish its own contact without a redeploy.
        4. +
        5. What it is: the read-only public view of one event: venue information, rooms, support contacts, and the conference-day conveniences (copy the Wi-Fi details, open directions, a distance-to-venue hint, a QR code for the page itself). For a public visitor it is also the landing page of the whole conference, because PublicEventList redirects them here.
        6. +
        7. Depends on: IEventUIService (:18), EventDTO, ConferenceReadAudience (:62), ConferenceRoutePaths (:78,138,140,142), DomainHelper's Id.Parse<T> extension for the route string (:104), and four device-capability abstractions: IClipboardService, IMapNavigationService, IGeolocationService, IGeocodingService (:21-24). It also reads IConfiguration for the host-wide support contacts (:25,54-55).
        8. +
        9. Concept introduced, load-once-on-parameters, an audience-shaped breadcrumb trail, and best-effort progressive enhancement. Three mechanisms are worth extracting.
            +
          1. Load once per id. The route value arrives as [Parameter] string Id (:27), and OnParametersSetAsync compares it against _loadedId (:88-97) so a re-render does not refetch; the typed id is produced by Id.Parse<EventIdentifierType>() (:104).
          2. +
          3. The breadcrumb trail depends on the audience, and the audience is awaited first. OnInitializedAsync resolves privileged status from role membership before building the trail (:57-68), then adds the "Events" crumb only for a privileged reader (:76-79). The doc comment states both halves of the reasoning (:44-51): a public visitor was redirected to this page by the event list, so an Events crumb would bounce them straight back here, and the access token hydrates asynchronously from the HttpOnly cookie, so reading roles synchronously would render the wrong trail and correct it on the next render. A failed auth read is treated as non-privileged (:63-67). [Rubric §25, Navigation & Information Architecture] (assesses that navigation affordances lead somewhere the reader can actually use) and [Rubric §26, Front-End Security] (fail closed to the narrower audience).
          4. +
          5. Every capability is optional. TryComputeDistanceAsync (:179-201) returns early when geolocation or geocoding is unsupported or the venue address is blank (:181-184), and again on any null result (:186-196), so a denied permission or an offline geocoder simply leaves the hint off. The doc comment states the rule plainly: this must never block the page (:174-178). [Rubric §29, Resilience & Business Continuity] (assesses degradation when an optional dependency is absent) and [Rubric §26, Front-End Security] (a location read is soft and unblocking, never a gate on content). These come from the device-capability layer of ADR-042. + A fourth detail is a small but real configuration rule: a per-event OrganizerContactEmail wins over the host-wide Support:Email, and it is re-evaluated on every load so navigating between events never leaves the previous organizer's address on screen (:112-117). [Rubric §16, Maintainability]: a conference can publish its own contact without a redeploy.
        10. Walkthrough
            -
          • OnInitialized (:37-48): reads the configured support email and phone and builds the breadcrumb trail.
          • -
          • LoadEventAsync (:65-100): parses the id, fetches with children (GetByIdAsync(eventId, true, ...), :71), snackbars a not-found (:74), otherwise resolves the support address and kicks off the distance hint (:81-85); OperationCanceledException is swallowed as expected teardown and the finally always clears IsLoading.
          • -
          • CopyWifiAsync (:108-119): copies Event.WiFiInfo through the clipboard abstraction and reports success or failure with one snackbar.
          • -
          • OpenDirectionsAsync (:121-134): native heads launch the platform maps app, browsers open a maps site (:128-129); a false return raises a warning.
          • -
          • TryComputeDistanceAsync (:141-163): geocodes the venue, reads the current-or-last-known position, converts kilometres to miles with an explicit constant (:160-161), and calls StateHasChanged() because the value arrives after the render that requested it.
          • -
          • Navigation helpers (:102-106) route back to the list, on to the public schedule, and to the event feedback form.
          • +
          • LoadEventAsync (:99-134): parse the id, fetch with children (GetByIdAsync(eventId, true, ...), :105), snackbar a not-found (:108), otherwise resolve the support address and kick off the distance hint (:115-119); OperationCanceledException is swallowed as expected teardown and the finally always clears IsLoading.
          • +
          • CopyWifiAsync (:146-157): copies Event.WiFiInfo through the clipboard abstraction and reports success or failure with one snackbar whose severity flips on the result (:154-156).
          • +
          • OpenDirectionsAsync (:159-172): native heads launch the platform maps app, browsers open a maps site (:166-167); a false return raises a warning.
          • +
          • TryComputeDistanceAsync (:179-201): geocodes the venue, reads the current-or-last-known position, converts kilometres to miles with an explicit named constant (:198-199), and calls StateHasChanged() (:200) because the value arrives after the render that requested it.
          • +
          • Navigation helpers (:136-144) route back to the list (privileged readers only, per the comment at :136-137), on to the public schedule, on to the activities page, and to the event feedback form. Disposal (:205-225) is the standard cancel-on-disposal pattern over the CancellationTokenSource at :31.
        11. Why it's built this way: the public event page is the one an attendee opens while standing in the building, so its extras (Wi-Fi, directions, distance) are worth having and none of them is worth failing the page over.
        12. -
        13. Where it's used: the /conference/events/{Id} route (PublicEventDetail.razor:1), reached from PublicEventList; its markup also renders the QrCodeButton for this page's own public link (PublicEventDetail.razor:30-31).
        14. +
        15. Where it's used: the /conference/events/{Id} route (PublicEventDetail.razor:1), reached from PublicEventList either as a grid row (privileged) or as a replace: true redirect (everyone else); its markup also renders the QrCodeButton for this page's own public link (PublicEventDetail.razor:30).

      PublicSpeakerDetail

      @@ -2253,59 +2459,72 @@

      PublicSpeakerDetail

    • What it is: the public speaker profile: photo, bio, social links, and the sessions that speaker presents. Email is deliberately not rendered, BR-66 (PublicSpeakerDetail.razor.cs:10-13).
    • Depends on: ISpeakerUIService and ISessionUIService (:16-17), SpeakerDTO and SessionDTO, ConferenceRoutePaths (:38,130,132), and DomainHelper's Id.Parse<T> (:78).
    • Concept introduced, the prerender skip and the server-side filter that replaced an in-memory one.
        -
      1. Prerender skip. OnParametersSetAsync returns immediately when !RendererInfo.IsInteractive (:56-62): under InteractiveAuto the interactive instance re-runs the method, so without this guard every visit fetched the speaker and their sessions twice. The prerender pass renders the loading skeleton instead. [Rubric §23, Front-End Performance & Rendering] (assesses avoidable duplicate work per view).
      2. -
      3. Push the filter to the server. LoadSpeakerSessionsAsync (:111-128) sends a SpeakerId equals filter with includeChildren: false, sorted by StartsAt ascending, capped at MaxSpeakerSessions = 100 (:24). The remarks block (:105-110) records what this replaced: the page used to pull the entire session catalog with all child collections and filter it in memory on SessionSpeakers, so viewing one speaker cost a full-catalog read. Since the page never renders those children, they are gone from the request too. [Rubric §12, Performance & Scalability] (assesses moving work to where the data lives) and [Rubric §8, Data Architecture] (a bounded page size instead of an unbounded read). +
      4. Prerender skip. OnParametersSetAsync returns immediately when !RendererInfo.IsInteractive (:59-62): under InteractiveAuto the interactive instance re-runs the method, so without this guard every visit fetched the speaker and their sessions twice. The prerender pass renders the loading skeleton instead, and the comment names the sibling pages that use the same guard (:56-58). [Rubric §23, Front-End Performance & Rendering] (assesses avoidable duplicate work per view).
      5. +
      6. Push the filter to the server. LoadSpeakerSessionsAsync (:111-128) sends a SpeakerId equals filter with includeChildren: false, sorted by StartsAt ascending, capped at MaxSpeakerSessions = 100 (:23-24). The remarks block (:105-110) records what this replaced: the page used to pull the entire session catalog with all child collections and filter it in memory on SessionSpeakers, so viewing one speaker cost a full-catalog read. Since the page never renders those children, they are gone from the request too. [Rubric §12, Performance & Scalability] (assesses moving work to where the data lives) and [Rubric §8, Data Architecture] (a bounded page size instead of an unbounded read). [Rubric §30, Compliance, Privacy & Data Governance] (assesses deliberate handling of personal data): the speaker email exists on the DTO but is never rendered on the public page, and the class doc names the rule.
    • Walkthrough
        -
      • OnInitialized (:32-41) builds the Home / Speakers / Profile breadcrumbs; HasSocialLinks (:48-52) collapses the four optional link fields into a single render guard.
      • -
      • LoadSpeakerAsync (:73-100): parse the id, GetByIdAsync(speakerId, true, ...) (:79), snackbar and return on not-found (:81-84), then load the sessions; OperationCanceledException is swallowed and the finally clears IsLoading.
      • +
      • OnInitialized (:32-41) builds the Home / Speakers / Profile breadcrumbs; unlike PublicEventDetail it is synchronous, because the speaker list is reachable by every audience and the trail does not vary.
      • +
      • HasSocialLinks (:48-52) collapses the four optional link fields into a single render guard, so the social row is absent rather than empty when a speaker supplied none.
      • +
      • LoadSpeakerAsync (:73-100): parse the id, GetByIdAsync(speakerId, true, ...) (:79), snackbar and return on not-found (:80-84), then load the sessions (:86); OperationCanceledException is swallowed (:88-91) and the finally clears IsLoading.
      • Navigation (:130-132) routes to a session or back to the speaker list. Disposal (:136-156) is the standard cancel-on-disposal pattern over the page's CancellationTokenSource (:26).
    • Why it's built this way: a public profile is a read-only, cache-friendly page; keeping its fetches narrow (one speaker, that speaker's sessions, no children) is what makes it cheap enough to serve to an anonymous crowd.
    • -
    • Where it's used: the /conference/speakers/{Id} route (PublicSpeakerDetail.razor:1), reached from PublicSpeakerList and from session pages. Its markup renders the QrCodeButton for its own link (PublicSpeakerDetail.razor:44-45), the reader-facing counterpart of SpeakerQr.
    • +
    • Where it's used: the /conference/speakers/{Id} route (PublicSpeakerDetail.razor:1), reached from PublicSpeakerList cards and from session pages. Its markup renders the QrCodeButton for its own link (PublicSpeakerDetail.razor:45).

    -

    SpeakerCategoryItemsPanel

    +

    PublicActivityList

    -

    MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Speaker · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerCategoryItemsPanel.razor.cs:16 · Level 8 · class (Blazor code-behind)

    +

    MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicActivityList.razor.cs:19 · Level 9 · class (Blazor code-behind)

      -
    • What it is: the "Additional Info" panel carved out of SpeakerDetail. It renders a speaker's category items grouped by category and hosts the add/remove chip actions (SpeakerCategoryItemsPanel.razor.cs:9-15).
    • -
    • Depends on: ISpeakerCategoryItemUIService (:18), SpeakerDTO (:22), SpeakerCategoryItemDTO (:42), CategoryItemInfo (:25), and the CategoryItem / ConferenceCategory / SpeakerCategoryItem identifier aliases; MudBlazor's ISnackbar.
    • -
    • Concept introduced, the container/presentational split with an EventCallback up-channel. The page (the container) passes the Speaker plus the two lookups it already owns as parameters (:22-28), and the panel signals mutations back up through the Changed callback (:31). After an add or a remove the panel calls await Changed.InvokeAsync() (:73,87), which the page handles by reloading the speaker, so behavior is identical to the pre-split page. [Rubric §18, UI Architecture & Component Design] (assesses cohesive, single-responsibility components): the split trims an already-large parent and gives this sub-view one job. [Rubric §19, State Management & Data Flow] (assesses where state lives and how it flows): the panel holds no source-of-truth state (only the transient _selectedCategoryItemId, :34); data flows down as parameters and mutations flow up through the callback, the canonical unidirectional Blazor pattern.
    • +
    • What it is: the public social and networking programme. It lists the current (or next) event's activities (pre-conference party, coffee connect, after-party, closing ceremony) ordered by start time then display order, read-only and anonymous, BR-43 (PublicActivityList.razor.cs:11-18).
    • +
    • Depends on: IActivityUIService and IEventLookupService (:26-27), ActivityDTO, EventInfo (through the lookup), CurrentEventSelector (:53-58), and IMapNavigationService (:28); MudBlazor's ISnackbar and the page's IStringLocalizer.
    • +
    • Concept introduced, the bounded, deterministically ordered, single-shot read. This page is not a data grid, and reading it next to ConferenceCategoryList is the clearest way to see when the base class is the wrong tool. An activity programme is a handful of items with a fixed narrative order (chronological), so there is nothing to page, sort or search.
        +
      • Bounded read: MaxActivities = 200 with the reasoning written on the constant, a conference schedules a handful, not thousands (:21-22). [Rubric §12, Performance & Scalability] (assesses that unbounded reads are avoided by design, not by luck).
      • +
      • Deterministic order: the server is asked for StartTime ascending (:72-73), and the result is re-ordered in memory by StartTime, then SortOrder, then Name (:79-85). The comment (:76-78) explains the layering: start time is the programme order, sort order breaks ties between activities that start together, and name is the final tiebreak so the list never depends on insertion order.
      • +
      • Failure is non-fatal: OperationCanceledException is separated out as expected teardown (:87-90) and the broad catch (:91-94) deliberately leaves the page on its empty state rather than surfacing an error; the finally always clears _isLoading. [Rubric §29, Resilience & Business Continuity].
      • +
      • Culture-aware time rendering: FormatTimeRange (:39-43) formats start and end with CultureInfo.CurrentCulture and composes them through a localized Text.TimeRange resource, so both the times and the separator follow the viewer's culture. [Rubric §27, Internationalization] (assesses that formatting and phrasing are both localized, not just the strings). + The class doc also draws the domain line that shapes the whole page (:15-17): activities are not sessions. They carry no room and no speakers, and an activity with its own venue gets the same directions affordance the public event page uses for the conference venue.
      • +
      +
    • Walkthrough
        -
      • GetCategoryTitle / GetCategoryItemName (:36-40): resolve ids to display names with an invariant-culture id fallback, so a missing lookup entry degrades to a number rather than an exception.
      • -
      • GetCategoryItemsGroupedByCategory (:42-50): groups the speaker's assigned items by their parent category id for display; GetAvailableCategoryItems (:52-59) excludes already-assigned items from the add dropdown.
      • -
      • AddCategoryItemAsync (:61-79): posts the selected item, clears the selection, snackbars, and invokes Changed; RemoveCategoryItemAsync (:81-93) deletes by the join-entity id and invokes Changed. Both catch broadly and report through a snackbar rather than surfacing an exception.
      • -
      • The panel owns its own CancellationTokenSource (:33) cancelled in Dispose (:97-117), because it makes its own service calls.
      • +
      • OnInitializedAsync (:45-99): load the event lookup (:50), resolve the current or next event through CurrentEventSelector.SelectCurrentOrNext with the four accessors passed explicitly because the lookup returns EventInfo rather than EventDTO (:53-58), remember its id and name (:60-61), build an EventId equals filter when one resolved (:64-66), fetch one bounded page (:68-74), and materialize the ordered list (:79-85).
      • +
      • OpenDirectionsAsync (:101-118): does nothing for an activity with no venue address (:103-106), otherwise launches the platform maps app on native heads or a maps site in a browser (:109-112), labelling the pin with the venue name and falling back to the activity name when the venue is unnamed (:111); a false return raises one warning snackbar (:114-117).
      • +
      • Disposal (:122-142) is the standard cancel-on-disposal pattern over the CancellationTokenSource at :31.
    • -
    • Why it's built this way: the speaker editor grew large enough that carving out a self-contained sub-view (owning its own service call, delegating state to the page) shrinks the parent and makes the panel independently testable, with no change in observable behavior.
    • -
    • Where it's used: rendered inside SpeakerDetail, which supplies Speaker, CategoryItems, CategoryTitles, and a Changed handler that reloads the speaker.
    • +
    • Why it's built this way: a fixed-order programme wants a readable timeline, not sortable columns, and the read is small enough that one bounded call beats the machinery of server paging.
    • +
    • Where it's used: the /conference/activities route (PublicActivityList.razor:1), reached from PublicEventDetail's ViewActivities action (PublicEventDetail.razor.cs:142).
    • +
    • Caveats / not-in-source: the page relies on the server scoping non-privileged callers to published events (class doc, :13-15); that scoping is enforced in the Conference API, not here.

    -

    SpeakerDetail

    +

    PublicEventList

    -

    MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Speaker · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerDetail.razor.cs:19 · Level 8 · class (Blazor code-behind)

    +

    MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicEventList.razor.cs:30 · Level 9 · class (Blazor code-behind)

      -
    • What it is: the organizer's full speaker console. Beyond load / inline-edit / delete it composes the category-item panel, resolves question-answer text, lists the speaker's sessions, and runs the link/unlink a User to this Speaker flow (SpeakerDetail.razor.cs:14-18).
    • -
    • Depends on: ISpeakerUIService, ISessionUIService, IConferenceCategoryUIService, ICategoryItemLookupService, IQuestionUIService, and IUserUIService (:23-28); SpeakerDTO, SessionDTO, UserListDTO, CategoryItemInfo, ConferenceRoutePaths, ErrorMessages, and the shared DeleteConfirmation component (:71). It hosts SpeakerCategoryItemsPanel.
    • -
    • Concept introduced, the cross-module composition page. One page composes data from Conference and Identity plus three lookups resolved into display names. [Rubric §7, Microservices Readiness] (assesses that cross-module access goes through abstractions rather than direct references): the Identity reach is IUserUIService, an HTTP client behind an interface, so the page is indifferent to Identity running as its own service. [Rubric §18, UI Architecture & Component Design] (a high dependency count is a cohesion signal to watch): the page delegates its category-item sub-view to a child component and keeps the rest, so its remaining size comes from orchestrating six clients and three lookups rather than from bespoke mechanics.
    • +
    • What it is: the /conference/events route, where the audience decides whether a list is shown at all. A privileged reader (Organizer or ContentEditor) gets the full grid of published and unpublished events; every other visitor is redirected to the current or next event's detail page (PublicEventList.razor.cs:13-29).
    • +
    • Depends on: extends DataGridListPageBase<TDto> closed over EventDTO (:30); IEventUIService and IEventLookupService (:34-35), ConferenceReadAudience (:76), CurrentEventSelector (:104), EventInfo (:90), ConferenceRoutePaths (:116,159), MobileInfiniteScrollList<TItem> (:45, rendered at PublicEventList.razor:23), and ListPageActions (:134). Server-side the audience split is enforced by PublishedEventSpecification.
    • +
    • Concept introduced, the audience gate as a routing decision, and the three-state render. This page is the sharpest example in the group of a UI decision that has to wait for identity.
        +
      1. Resolve the audience before deciding anything. OnInitializedAsync awaits the cascading Task<AuthenticationState> and reads role membership first (:71-82). The comment (:68-70) and the class doc (:21-25) both name the failure this prevents: on all three heads (Blazor Server, WebAssembly, MAUI) the access token hydrates asynchronously from the HttpOnly cookie, so a synchronous role read would see an anonymous principal and bounce an organizer off their own list. A failed read is treated as non-privileged (:78-81). [Rubric §11, Security] and [Rubric §26, Front-End Security] (assess that an authorization-shaped branch reads a settled principal, and fails to the narrower audience).
      2. +
      3. Three states, and one of them renders nothing. _showGrid (:52) opens the search box and the layout switch for a privileged reader (:84-88); _showEmpty (:60) is set only when nothing is published anywhere, so there is no redirect target and the page has to stay and say so (:120-122); and the redirect path deliberately leaves both false (:111-118), so the page stays blank until the navigation takes effect instead of flashing a list on the way out. The field doc spells this out (:54-59). [Rubric §18, UI Architecture & Component Design] and [Rubric §22, Responsive & Cross-Browser] (assess that intermediate states are designed rather than incidental).
      4. +
      5. replace: true on the redirect. The comment (:113-115) records the exact bug it prevents: left in the history stack, Back from the event detail would land here and redirect straight forward again, trapping the visitor on the detail page. [Rubric §25, Navigation & Information Architecture] (assesses that the Back button keeps working). + The redirect target is computed with CurrentEventSelector.SelectCurrentOrNext, passing the four accessors explicitly because the lookup returns EventInfo rather than EventDTO, which the comment calls out (:100-109). It is the same live-window math every other landing surface uses, so a visitor always lands on the conference that is actually happening.
      6. +
      +
    • Walkthrough
        -
      • LoadAsync (:91-121): GetByIdAsync(speakerId, true, ...) (children included, :97), then lazily hydrate three lookups with ??= so a re-load does not re-fetch them: category items (:104), category titles (LoadCategoryTitlesAsync, :150-155), and question texts (LoadQuestionTextsAsync, :157-162). Errors report through ErrorMessages helpers (:100,115).
      • -
      • LoadSpeakerSessionsAsync (:131-148) uses the same server-side SpeakerId equals filter as the public page, capped at MaxSpeakerSessions = 100 (:35) with includeChildren: false; the remarks (:126-130) record that this replaced a full-catalog read filtered in memory. [Rubric §12, Performance & Scalability].
      • -
      • Inline edit (:167-247): StartEditing seeds the _edit* shadow fields (:167-186) and CancelEditing discards them (:188-192), so the live record is never mutated until a validated save succeeds. SaveChangesAsync validates the MudForm, rebuilds the DTO preserving RowVersion (:214) and LinkedUserId (:226) so a profile edit cannot clear the org-managed link, updates, and re-fetches. [Rubric §24, Forms, Validation & UX Safety] and [Rubric §8, Data Architecture] (the round-tripped RowVersion is the client half of optimistic concurrency).
      • -
      • Delete (:249-276): confirm through the shared DeleteConfirmation dialog, delete, then navigate back to the list.
      • -
      • User link/unlink (:279-357): SearchUsersAsync is the notable one. GetPagedAsync ANDs its filters server-side (in-code comment, :288-290), so a single call with email, first name, and last name all set to the same term would return the empty intersection. The page instead fans out three parallel calls (:291-295), unions them with DistinctBy(u => u.UserId) and takes 10 (:301-305). OnUserPickedAsync (:313-334) calls LinkUserAsync and reloads; UnlinkUserAsync (:336-357) clears it. This is the flow that produces the speaker_id claim SpeakerDashboard and SpeakerQr depend on.
      • +
      • GridRef (:41) exposes the captured grid so the base can restore rows-per-page and current page; RetryLoadAsync (:43-44) re-runs the fetch from the inline error state the base renders when LoadFailed is set. [Rubric §29, Resilience & Business Continuity].
      • +
      • SaveFilters / RestoreFilters (:125-129) persist the one search term; OnSearchChanged (:131-135) stores it and reloads whichever layout is active through ListPageActions.ReloadActiveLayoutAsync.
      • +
      • LoadServerData (:137-147) passes showCancelSnackbar: false, so a superseded fetch (the reader typed another character) is silent rather than raising a toast, and turns the search string into a Name contains server filter.
      • +
      • FetchMobilePage (:150-156) is the parallel infinite-scroll path, hard-sorted by Name ascending; OnMobileCardClick (:158-159) routes to PublicEventDetail.
    • -
    • Why it's built this way: an organizer needs one console to fully administer a speaker, including wiring them to a login account; composing the views here (and delegating the category panel) trades page breadth for a one-stop editor. The three-call user search is a deliberate workaround for AND-only server filtering.
    • -
    • Where it's used: the /speakers/{Id} route (SpeakerDetail.razor:1), reached from SpeakerList rows and SpeakerCreate redirects; it hosts SpeakerCategoryItemsPanel.
    • -
    • Caveats / not-in-source: the AND-only semantics of GetPagedAsync are asserted by the in-code comment; the filter behavior itself lives in the Identity API, not this page.
    • +
    • Why it's built this way: a public visitor cares about the conference that is running or coming up, not about a roster of past editions, while an organizer curating the catalog needs every row including the unpublished ones. One route serving both is cheaper than two, provided the audience is known before the branch is taken.
    • +
    • Where it's used: the /conference/events route (PublicEventList.razor:1). Every non-privileged arrival leaves immediately for PublicEventDetail; privileged rows and cards navigate to the same page.
    • +
    • Caveats / not-in-source: the published-only scoping of the underlying reads for non-privileged callers (BR-108) is enforced in the Conference API through PublishedEventSpecification, not on this page.

    PublicSessionDetail

    @@ -2314,43 +2533,47 @@

    PublicSessionDetail

    • What it is: the public read-only view of one session (speakers, categories, room and wayfinding) plus the contextual actions an authenticated attendee gets: the bookmark toggle, the feedback link, a listen-aloud button, and the Live entry point when the Engagement module is present.
    • -
    • Depends on: ISessionUIService, ISpeakerLookupService, IRoomUIService, ICategoryItemLookupService (:22-25); optionally ISessionBookmarkUIService and ISessionLiveUIService (:34,37); IHapticFeedbackService and ITextToSpeechService (:29-30); SessionDTO, RoomDTO, ConferenceRoutePaths, and DomainHelper's Id.Parse<T> (:109).
    • -
    • Concept introduced, optional cross-module services resolved through the container. Blazor's [Inject] has no optional mode (an unregistered service throws at render), so the two Engagement-owned services are resolved with ServiceProvider.GetService<T>() in OnInitialized and left null when that module is disabled (:32-37,52-53). Every use site then null-checks. [Rubric §7, Microservices Readiness] (assesses that a module can be switched off without breaking its consumers): the Conference page degrades to a plain read-only session view when Engagement is absent, rather than failing to render. [Rubric §3, Clean Architecture]: the dependency is on an interface owned by the other module's Shared/UI contract, never on its internals. - The page repeats the two mechanisms taught above: the prerender skip (:84-93, whose comment names the category-item read as the expensive duplicate) and load-once-on-parameters (:95-101). It also repeats the BR-49 status allow-list as IsStatusIneligible (:77-82), with the comment again pointing at SessionStatuses as the server-side source of truth.
    • +
    • Depends on: ISessionUIService, ISpeakerLookupService, IRoomUIService, ICategoryItemLookupService (:22-25); optionally ISessionBookmarkUIService and ISessionLiveUIService (:34,37); IHapticFeedbackService and ITextToSpeechService (:29-30); SessionDTO, RoomDTO, ConferenceRoutePaths (:57,236), and DomainHelper's Id.Parse<T> (:109).
    • +
    • Concept introduced, optional cross-module services resolved through the container. Blazor's [Inject] has no optional mode (an unregistered service throws at render), so the two Engagement-owned services are resolved with ServiceProvider.GetService<T>() in OnInitialized and left null when that module is disabled (:32-37,52-53). Every use site then null-checks. [Rubric §7, Microservices Readiness] (assesses that a module can be switched off without breaking its consumers): the Conference page degrades to a plain read-only session view when Engagement is absent, rather than failing to render. [Rubric §3, Clean Architecture]: the dependency is on an interface owned by the other module's Shared or UI contract, never on its internals. + The page repeats two mechanisms taught above. The prerender skip (:84-93) carries the most specific comment of the three that use it: it names the category-item read as the expensive duplicate, a full-table read per view. And load-once-on-parameters (:95-101) keeps a re-render from refetching. It also repeats the BR-49 status allow-list as IsStatusIneligible (:77-82), with the comment again pointing at SessionStatuses as the server-side source of truth and explaining that the UI layer depends on Shared only.
    • Walkthrough
        -
      • LoadSessionAsync (:104-134): fetch the session with children, then resolve speaker names (:136-142), category names (:144-154, prefixing the category title when present), the room including wayfinding info (:156-166, BR-94), and the caller's bookmark state (:168-189, keyed off the user_id claim).
      • -
      • ToggleBookmarkAsync (:191-234): a single _isTogglingBookmark re-entry guard (this page shows one session, so the per-session set the list view needs is unnecessary here), a haptic click, then delete-or-create with the same null-body warning path the list view uses (:218-223).
      • -
      • ToggleListenAsync (:243-266): text to speech over the description, where the same button stops playback (:250-254); SpeakAsync completes when playback finishes or StopAsync cancels it. [Rubric §21, Accessibility] (assesses alternative modalities for content) and ADR-042 Wave 3.
      • +
      • LoadSessionAsync (:104-134): fetch the session with children (:110), then resolve speaker names (:136-142), category names (:144-154, prefixing the category title when present so a chip reads "Level: Intermediate"), the room including wayfinding info (:156-166, BR-94, and skipped entirely for a session with no room), and the caller's bookmark state (:168-189, keyed off the user_id claim). Each resolver runs only after the session loaded, so a not-found short-circuits the whole chain (:111-115).
      • +
      • ToggleBookmarkAsync (:191-234): a single _isTogglingBookmark re-entry guard (this page shows one session, so the per-session set PublicSessionListView needs is unnecessary here), a haptic click (:197), then delete-or-create with the same null-body warning path the list view uses (:219-223).
      • +
      • ToggleListenAsync (:243-266): text to speech over the description, where the same button stops playback (:250-254); SpeakAsync completes when playback finishes or StopAsync cancels it, and the finally clears _isSpeaking either way. [Rubric §21, Accessibility] (assesses alternative modalities for content) and ADR-042 Wave 3.
      • Navigation (:236-238) returns to the schedule or opens the session feedback form; disposal (:270-290) is the standard cancel-on-disposal pattern.
    • Why it's built this way: this is the page an attendee opens in a hallway, so the expensive lookups are done once per id, the optional capabilities fail soft, and the actions (star, feedback, listen, Live) sit inline instead of on separate routes.
    • -
    • Where it's used: the /conference/sessions/{Id} route (PublicSessionDetail.razor:1), reached from PublicSessionListView rows and cards; its markup renders the QrCodeButton for its own public link (PublicSessionDetail.razor:41-42).
    • +
    • Where it's used: the /conference/sessions/{Id} route (PublicSessionDetail.razor:1), reached from PublicSessionListView rows and cards and from PublicSpeakerDetail; its markup renders the QrCodeButton for its own public link (PublicSessionDetail.razor:41).

    PublicSpeakerList

    -

    MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSpeakerList.razor.cs:27 · Level 9 · class (Blazor code-behind)

    +

    MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSpeakerList.razor.cs:35 · Level 9 · class (Blazor code-behind)

      -
    • What it is: the public speaker directory: photos and taglines, no emails (BR-66), read-only for everyone (BR-43). The server returns only speakers with a visible session in the listed event, or in any published event when no event filter is applied, BR-239 (PublicSpeakerList.razor.cs:15-26).
    • -
    • Depends on: extends DataGridListPageBase<TDto> (:27); ISpeakerUIService and IEventLookupService (:31-32), SpeakerDTO, EventInfo (:48), ConferenceReadAudience (:97), CurrentEventSelector (:131), ListPageActions (:146), MobileInfiniteScrollList<TItem> (:42), and ConferenceRoutePaths (:196).
    • -
    • Concept introduced, the audience-aware default filter (and why the default is not just a convenience). This page layers three things on the base list shape.
        -
      1. A persisted event filter with an "all" sentinel (:50-80): the sentinel distinguishes an explicit "show all events" from no saved state, which is what triggers the computed default. Crucially, the choice is persisted only for privileged readers (:56): everyone else is always locked to the computed event, so a privileged reader's shared URL cannot pin an attendee to a different or unpublished event.
      2. -
      3. A computed default via CurrentEventSelector.SelectCurrentOrNext (:117-138): a restored id that still exists wins for privileged readers, a dangling one falls back to the current-or-next event rather than rendering an empty grid.
      4. -
      5. A startup race guard: OnInitializedAsync assigns _eventsLoadTask before its first await (:82-88) and both LoadServerData (:161-174) and FetchMobilePage (:185-193) await that same task before applying filters, because the MudDataGrid's first ServerData call can run ahead of OnInitializedAsync completing. Without it the first fetch would apply an unresolved filter. - [Rubric §11, Security] and [Rubric §26, Front-End Security] (assess that a client-persisted preference cannot widen what a user sees): the privileged/non-privileged split is decided from role membership (:92-103) and the server independently scopes the underlying reads. [Rubric §19, State Management & Data Flow]: filter state is restored, defaulted, and reconciled against the live event set in exactly one method. [Rubric §25, Navigation & Information Architecture]: an attendee lands on the conference that is actually happening.
      6. +
      7. What it is: the public speaker directory, rendered as a photo-forward responsive card grid with infinite scroll, the same layout on desktop and mobile. Read-only for everyone (BR-43), no emails (BR-66), and the server returns only speakers with a visible session in the listed event, or in any published event when no event filter is applied, BR-239 (PublicSpeakerList.razor.cs:12-34).
      8. +
      9. Depends on: extends DataGridListPageBase<TDto> closed over SpeakerDTO (:35); ISpeakerUIService and IEventLookupService (:44-45), EventInfo (:54), ConferenceReadAudience (:122), CurrentEventSelector (:156), and InfiniteScrollSentinel (PublicSpeakerList.razor:144). Note what is absent: no MudDataGrid, no GridRef override, no MobileInfiniteScrollList<TItem>.
      10. +
      11. Concept introduced, borrowing a base class's plumbing for a layout it was not written for. A speaker is a face and a tagline, not a row of columns, so this page throws away the grid and keeps everything else. The class doc explains the trade (:23-30): paging keeps the page-based model of the base's mobile path (MobileItems, MobileCurrentPage, MobileTotalItems, MobilePageSize, declared at DataGridListPageBase.cs:47-50), which already owns the cancellation token, the loading and failure flags, and the saved-state plumbing, and layers infinite scroll on top by appending each fetched page to _loadedSpeakers (:56) rather than replacing, which is what the base's mobile path does on its own. [Rubric §16, Maintainability] and [Rubric §1, SOLID] (assess reuse of one tested mechanism rather than a parallel implementation). + Three correctness details make that borrowing safe, and they are the real lesson of this page.
          +
        1. A generation counter supersedes in-flight fetches. _generation (:66) is bumped by every reset (search, event filter, breakpoint change, retry) inside LoadSpeakersAsync (:186), and both LoadSpeakersAsync (:197-200) and LoadMoreSpeakersAsync (:229-232) discard their rows when a newer generation has taken over. Without it a slow page-1 fetch could append to the list a later query had already cleared. [Rubric §19, State Management & Data Flow].
        2. +
        3. The list is cleared before the await, not after. The comment (:190-191) states why: the grid, and with it the sentinel, is gone while page 1 is in flight, so a stray intersection-observer callback cannot ask for page 2 of the query being replaced. [Rubric §18, UI Architecture & Component Design].
        4. +
        5. The page number is committed only on success. LoadMoreSpeakersAsync saves previousPage, optimistically advances, and rolls back when LoadFailed is set (:221-241), so the retry button re-requests the same page instead of silently skipping it. [Rubric §29, Resilience & Business Continuity]. + Layered on top is the same audience-aware default filter PublicSessionList uses: an "all" sentinel distinguishes an explicit "show all events" from no saved state (:82-83), the choice is persisted only for privileged readers (:80), and ResolveDefaultEventFilter (:142-163) keeps a restored id that still exists for a privileged reader while locking everyone else to the computed current or next event. The comment (:144-146) states the security consequence, and the roles come from ConferenceReadAudience with a failed read treated as non-privileged (:117-128). [Rubric §11, Security] and [Rubric §26, Front-End Security] (assess that a client-persisted preference cannot widen what a user sees).
      12. Walkthrough
          -
        • LoadEventsAndResolveDefaultAsync (:90-115): reads role membership from the cascading auth state (failures are treated as non-privileged, :99-102), loads the event lookup (a failure leaves the picker hidden and the filter unset, :105-112), then resolves the default.
        • -
        • ApplyFilters (:176-182): emits FullName contains plus the virtual EventId equals filter that the speakers/paged endpoint resolves through the EventSpeaker/SessionSpeaker joins, since a Speaker row has no EventId column (class doc, :23-24).
        • -
        • GetSelectedEventName (:140-143) feeds the chip label; OnSearchChanged / OnEventFilterChanged (:148-159) reload whichever layout is active; RetryLoadAsync (:41) re-runs a failed fetch from the inline error state.
        • +
        • CardsPerPage = 12 (:37-38) is assigned to the base's MobilePageSize in the constructor (:40), with the reasoning on the constant: a multiple of 2, 3 and 4 so a full chunk fills whole rows at every breakpoint. [Rubric §22, Responsive & Cross-Browser].
        • +
        • HasMoreSpeakers (:72) is true exactly while pages remain unfetched, and that is exactly when the sentinel renders, so the trigger and the condition cannot disagree.
        • +
        • FetchCurrentPageAsync (:174-178) delegates to the base's LoadMobileDataAsync with a fixed FullName ascending sort; ApplyFilters (:272-278) emits FullName contains plus the virtual EventId equals filter that the speakers endpoint resolves through the EventSpeaker and SessionSpeaker joins, since a Speaker row has no EventId column (class doc, :21-22).
        • +
        • OnMobileDataRequestedAsync (:257) is the base's breakpoint-change hook (DataGridListPageBase.cs:720, called at :272), overridden here to restart the accumulation rather than fetch one replacement page.
        • +
        • RetryLoadAsync (:254), OnSearchChanged (:259-263) and OnEventFilterChanged (:265-270) all funnel through LoadSpeakersAsync, which is the single reset entry point; GetSelectedEventName (:165-168) feeds the chip label.
        • +
        • Initials (:281-288) builds the no-photo avatar text with spans, tolerating a blank first or last name; HasSocialLinks (:290-294) hides the social row when a speaker supplied none.
      13. -
      14. Why it's built this way: attendees browse "the speakers at this conference", not a lifetime roster, so the default filter is the primary behavior and the picker is the privileged exception.
      15. -
      16. Where it's used: the /conference/speakers route (PublicSpeakerList.razor:1); rows and cards navigate to PublicSpeakerDetail.
      17. -
      18. Caveats / not-in-source: the join-based resolution of the virtual EventId filter is asserted by the class doc comment; the resolution itself lives in the Conference API.
      19. +
      20. Why it's built this way: attendees browse "the speakers at this conference", not a lifetime roster, so the default filter is the primary behavior and the picker is the privileged exception; and a directory of faces reads better as an endless wall of cards than as a pager. The class doc adds one more deliberate omission (:31-33): category chips are absent because the paged endpoint is called with includeChildren=false, so asking for them would both enlarge the payload and change the URL the output-cache warmup pins (ADR-040).
      21. +
      22. Where it's used: the /conference/speakers route (PublicSpeakerList.razor:1); cards navigate to PublicSpeakerDetail.
      23. +
      24. Caveats / not-in-source: the join-based resolution of the virtual EventId filter is asserted by the class doc comment; the resolution itself lives in the Conference API. A restored mobile page number is deliberately ignored (class doc, :29-30), so a reader returning to this page starts at page 1 rather than at the scroll depth they left.

    PublicSponsorList

    @@ -2359,425 +2582,694 @@

    PublicSponsorList

    • What it is: the public sponsor and exhibitor page. It groups the current (or next) event's sponsors by tier, orders them within each tier, and renders them as logo cards. Read-only and anonymous, BR-43 (PublicSponsorList.razor.cs:10-17).
    • -
    • Depends on: ISponsorUIService and IEventLookupService (:25-26), SponsorDTO and SponsorTier, EventInfo (through the lookup), and CurrentEventSelector (:52-57); MudBlazor.
    • -
    • Concept introduced, the deterministic grouped read and the graceful empty state. Unlike the other public browse pages this one is not a data grid: the roster is small and needs a fixed visual hierarchy, so the page fetches one bounded page and shapes it in memory.
        +
      • Depends on: ISponsorUIService and IEventLookupService (:25-26), SponsorDTO and SponsorTier, EventInfo (through the lookup), and CurrentEventSelector (:52-57); MudBlazor and the page's IStringLocalizer.
      • +
      • Concept introduced, the deterministic grouped read and the graceful empty state. Like PublicActivityList, this page is not a data grid: the roster is small and needs a fixed visual hierarchy, so the page fetches one bounded page and shapes it in memory.
        • Bounded read: MaxSponsors = 200 with the reasoning stated on the constant, a conference sells dozens, not thousands (:20-21). [Rubric §12, Performance & Scalability] (assesses that unbounded reads are avoided by design, not by luck).
        • -
        • Deterministic order: sponsors are grouped by tier, tiers ordered ascending because that is package order (Platinum first), and each group ordered by Sort then Name (:76-86), so the strip does not depend on insertion order.
        • -
        • Empty state with no dead link: when the event has no sponsors the page falls back to the sponsorship-packet call to action, and when the event publishes no packet URL that call to action is hidden entirely rather than offering a dead link (:14-16, field at :32-36, assigned at :61). [Rubric §24, Forms, Validation & UX Safety] and [Rubric §25, Navigation & Information Architecture]: a missing value removes an affordance instead of producing a broken one.
        • -
        • Failure is non-fatal: the broad catch (:92-95) deliberately leaves the page on its call-to-action fallback rather than surfacing an error, and the finally always clears _isLoading. [Rubric §29, Resilience & Business Continuity].
        • +
        • Deterministic order: sponsors are grouped by tier, tiers ordered ascending because that is package order (Platinum first), and each group ordered by Sort then Name (:78-86), so the strip does not depend on insertion order. The comment states the rule (:76-77).
        • +
        • Empty state with no dead link: when the event has no sponsors the page falls back to the sponsorship-packet call to action, and when the event publishes no packet URL that call to action is hidden entirely rather than offering a dead link (class doc :14-16, field doc :32-36, assigned at :61). [Rubric §24, Forms, Validation & UX Safety] and [Rubric §25, Navigation & Information Architecture]: a missing value removes an affordance instead of producing a broken one.
        • +
        • Failure is non-fatal: OperationCanceledException is separated out as expected teardown or an InteractiveAuto render-mode transition (:88-91), and the broad catch (:92-95) deliberately leaves the page on its call-to-action fallback rather than surfacing an error; the finally always clears _isLoading. [Rubric §29, Resilience & Business Continuity].
      • -
      • Walkthrough: OnInitializedAsync (:44-100) loads the event lookup, resolves the current or next event with CurrentEventSelector (:52-57), remembers its name and sponsorship packet URL (:60-61), builds an EventId equals filter when an event resolved (:64-66), fetches one page sorted by Sort ascending (:68-74), and materializes _tiers (:78-86). TierLabel (:42) localizes each tier name through the page's IStringLocalizer, so the tier enum never reaches the screen untranslated ([Rubric §27, Internationalization]). Disposal (:104-124) is the standard cancel-on-disposal pattern over the CancellationTokenSource at :28.
      • +
      • Walkthrough: OnInitializedAsync (:44-100) loads the event lookup (:49), resolves the current or next event with CurrentEventSelector passing the four accessors explicitly (:52-57), remembers its name and sponsorship packet URL (:60-61), builds an EventId equals filter when an event resolved (:64-66), fetches one page sorted by Sort ascending (:68-74), and materializes _tiers as an ordered list of tier-to-sponsors pairs (:78-86). TierLabel (:42) localizes each tier name through the page's IStringLocalizer, so the tier enum never reaches the screen untranslated ([Rubric §27, Internationalization]). Disposal (:104-124) is the standard cancel-on-disposal pattern over the CancellationTokenSource at :28.
      • Why it's built this way: the sponsor page is a marketing surface with a fixed hierarchy, so it wants deterministic grouping rather than sortable columns, and it must look intentional on an event that has not sold a sponsorship yet.
      • -
      • Where it's used: the /conference/sponsors route (PublicSponsorList.razor:1). The roster it renders is authored by the organizer through SponsorList and SponsorDetail.
      • -
      • Caveats / not-in-source: the page relies on the server scoping non-privileged callers to published events (class doc, :13-15); that scoping is enforced in the Conference API, not here.
      • +
      • Where it's used: the /conference/sponsors route (PublicSponsorList.razor:1). The roster it renders is authored by the organizer through the sponsor admin pages in this module.
      • +
      • Caveats / not-in-source: the page relies on the server scoping non-privileged callers to published events (class doc, :12-14); that scoping is enforced in the Conference API, not here.

      -

      SpeakerDashboard

      +

      PublicSessionList

      -

      MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Speaker · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerDashboard.razor.cs:19 · Level 9 · class (Blazor code-behind)

      +

      MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSessionList.razor.cs:25 · Level 10 · class (Blazor code-behind)

        -
      • What it is: the speaker's own self-service dashboard (not an organizer page). It reads the linked speaker from the speaker_id JWT claim, shows that speaker's profile and their sessions narrowed to the current or next event, with per-session bookmark counts and lazily-loaded per-session feedback, and lets the speaker edit their own bio and social profile, BR-214 (SpeakerDashboard.razor.cs:11-18).
      • -
      • Depends on: ISpeakerUIService, ISpeakerDashboardUIService, IEventLookupService, and Blazor's AuthenticationStateProvider (:21-25); SpeakerDTO, SessionDTO, SessionFeedbackDTO, EventInfo, and CurrentEventSelector (:147).
      • -
      • Concept introduced, claim-driven identity scoping, plus prerender-safe loading and lazy expand. Three ideas converge here.
          -
        1. Claim-driven scoping. Instead of an id from the route, the page derives who you are from the token: OnInitializedAsync reads speaker_id from the auth state (:73-80) and falls into a "not linked" state when the claim is absent or unparsable. [Rubric §11, Security] and [Rubric §26, Front-End Security] (assess that authorization derives from trusted server-issued claims, not client-supplied ids): a speaker can only load their own dashboard. The class doc adds the corollary (:15-17): a speaker is not a privileged reader, so the server returns only publicly visible sessions and a submission still under review does not appear, which the empty-state copy names.
        2. -
        3. Prerender-safe loading. The method returns early when !RendererInfo.IsInteractive (:62-68), so the profile, sessions, and bookmark counts are not fetched twice per visit. [Rubric §23, Front-End Performance & Rendering].
        4. -
        5. Lazy expand. ToggleFeedbackAsync (:226-262) fetches a session's feedback only the first time its panel is expanded and caches it in _sessionFeedback (:234-237), so first paint never fans out one feedback call per session. [Rubric §19, State Management & Data Flow].
        6. +
        7. What it is: the public conference schedule and the most heavily-wired page in this unit. It is the container half of a three-part page (this class, PublicSessionListFilterBar, PublicSessionListView): it owns the events, room and speaker lookups, the event/room/search/My-Schedule filter state, the bookmark dictionary, the server-paged fetch, and the offline snapshot (PublicSessionList.razor.cs:16-24).
        8. +
        9. Depends on: extends DataGridListPageBase<TDto> closed over SessionDTO (:25); ISessionUIService, IEventUIService, ISpeakerLookupService (:29,32,33), the optional ISessionBookmarkUIService (:38), ILocalCacheStore and IConnectivityStatusService (:30-31); EventDTO, RoomDTO, SpeakerInfo (:54), ConferenceReadAudience (:161), CurrentEventDefaults (:210), PublicScheduleRoomOptions (:195), and the nested CachedSessionPage record (:366).
        10. +
        11. Concept introduced, the container page with two racing loads and a dual-branch fetch. Everything the sibling list pages do once, this page does twice and then adds a mode switch.
            +
          1. Two startup tasks, both awaited by the fetch path. OnInitializedAsync (:124-152) starts _bookmarkLoadTask (:140) and _eventsLoadTask (:144) before its first await. The comments (:134-143) name the exact failure each guards: the MudDataGrid's first ServerData call can run ahead of initialization, notably on in-app back-navigation where there is no SSR prerender to supply grid data, and a half-initialized _isAuthenticated == false would make the My Schedule branch silently fall through to fetching all sessions. LoadServerData (:273-285) awaits the events task and FetchSessionsAsync (:292-361) awaits the bookmark task. [Rubric §19, State Management & Data Flow].
          2. +
          3. Two fetch branches, both truly server-paged. In My Schedule mode with bookmarks present, the page adds an Id IN (...) server filter built from the bookmark dictionary keys (:320-328) and lets the server page; the comment (:317-319) records that this replaced pulling a 500-row page and paging in memory, which also reported a wrong total past 500. An empty bookmark set short-circuits to ([], 0) (:312-315) rather than issuing a query that would return the whole catalog. [Rubric §12, Performance & Scalability].
          4. +
          5. Audience-scoped filter persistence, and one filter that is safe for everyone. Only privileged readers persist an event choice (:83-89), and ResolveDefaultEventFilter (:197-212) locks everyone else to the computed current or next event via CurrentEventDefaults.SelectCurrentOrNext; the comment (:199-202) states the security consequence, that a shared privileged URL can never pin an attendee to a different or unpublished event. The room filter, by contrast, is persisted for every audience, and the comment says why in one line (:80): a room only narrows within the reader's own event, so it cannot widen anything. [Rubric §11, Security] and [Rubric §26, Front-End Security].
          6. +
          7. A deep link that beats saved state. [SupplyParameterFromQuery(Name = "mine")] (:67-68) carries the MAUI head's home-screen quick action into the My Schedule view, and OnInitializedAsync applies it after the base has restored saved page state so intent wins (:128-132). [Rubric §25, Navigation & Information Architecture] and ADR-042 Wave 2.
          8. +
          9. The offline snapshot taught at CachedSessionPage (:338-360). [Rubric §29, Resilience & Business Continuity].
        12. Walkthrough
            -
          • Load (:54-140): breadcrumbs, prerender guard, claim read, GetByIdAsync(_speakerId, true, ...) (:86), then the speaker's sessions through DashboardService.GetSpeakerSessionsAsync (:95-96). The comment at :92-94 records why that read goes through the dashboard service: it bypasses the shared sessions output cache (ADR-040), so a just-made speaker assignment shows immediately instead of lagging behind a cached public list.
          • -
          • Narrowing (:98-109): ResolveCurrentEventAsync (:142-159) resolves the current or next event through CurrentEventSelector and the page filters to it, falling back to all of the speaker's sessions when none resolves.
          • -
          • Bookmark counts (:111-126): one batched call, GetSessionBookmarkCountsAsync, fills every count. The comment (:111-113) records what it replaced: each count used to be its own cross-service hop (HTTP to Conference, gRPC to Engagement). The call sits in its own best-effort catch so a failed count read never breaks the render. [Rubric §12, Performance & Scalability] and [Rubric §29, Resilience & Business Continuity].
          • -
          • Profile editing (:161-224): StartEditingProfile seeds the _edit* fields (:161-175); SaveProfileAsync rebuilds a SpeakerDTO that preserves RowVersion, first/last/full name, Email, ProfilePicture, and LinkedUserId from the loaded record (:189-205), so a self-edit can only change the six fields the speaker owns and cannot clear the organizer-managed ones. [Rubric §11, Security] and [Rubric §24, Forms, Validation & UX Safety].
          • +
          • SaveFilters / RestoreFilters (:75-122): persist search, the My Schedule toggle, the room id, and (privileged only) the event id with the "all" sentinel. A restored room the resolved event does not offer is dropped downstream, which the comment points at (:111).
          • +
          • LoadEventsAndResolveDefaultAsync (:154-191): resolve privileged status from role membership with a failed read treated as non-privileged (:156-167), fetch events with children and flatten their rooms into _roomNames (:171-180), load the speaker lookup (:182), then resolve the default event (:189) and scope the room options (:190). One children-loaded events fetch plus one speaker lookup replace per-row enrichment calls. [Rubric §23, Front-End Performance & Rendering].
          • +
          • RefreshRoomOptions (:194-195) is a one-line delegation to PublicScheduleRoomOptions.Scope, destructuring straight into _rooms and _selectedRoomId; it runs after the initial load and again on every event-filter change (:255).
          • +
          • LoadBookmarkStateAsync (:219-243): reads the user_id claim and loads the bookmarked session ids into the dictionary the view patches in place; a failure is non-critical, so the stars do not appear but sessions still load (:239-242).
          • +
          • Filter handlers (:246-269) each update one field and call ReloadViewAsync (:271), which forwards to the view child's ReloadAsync() and no-ops when the child is not yet rendered.
          • +
          • ApplyAdditionalFilters (:368-386): Title contains, EventId equals, and RoomId equals. The comment on the room branch (:380-381) is worth reading against PublicSpeakerList: Session.RoomId is a real nullable column, so it rides the generic filter pipeline with no virtual-key interception in the controller, unlike the speaker page's EventId.
          • +
          • FetchMobilePage (:389-397) builds the same filters for the infinite-scroll list and reuses FetchSessionsAsync, so both layouts share one fetch implementation including its offline path.
          • +
          • The optional Engagement service is resolved with GetService (:126) for the same reason as on PublicSessionDetail: [Inject] has no optional mode.
        13. -
        14. Why it's built this way: the speaker portal is a distinct actor view; scoping by claim is the secure way to hand a speaker exactly their own data without an authorization argument on every call, and the batched counts plus the prerender skip keep a cross-service-heavy page responsive.
        15. -
        16. Where it's used: the /speaker/dashboard route (SpeakerDashboard.razor:1), gated on the speaker_id claim that appears when an organizer links a User to a Speaker in SpeakerDetail. SpeakerQr is its companion page.
        17. -
        18. Caveats / not-in-source: the output-cache bypass is documented by the in-code comment; the caching behavior itself lives in the Conference service.
        19. +
        20. Why it's built this way: this is the highest-traffic page of the conference, viewed on bad networks by both anonymous browsers and signed-in attendees managing a personal schedule. That drives every design decision visible here: server-side everything, one enrichment fetch, ordering guarantees around the grid's eager first call, an audience-locked event filter, a room filter derived from data already in hand, and a cached last-known-good first page.
        21. +
        22. Where it's used: the /conference/sessions route (PublicSessionList.razor:1), including the ?mine=true deep link; it renders PublicSessionListFilterBar (PublicSessionList.razor:11-21) and PublicSessionListView (:31) and routes onward to PublicSessionDetail.
      -
      -

      SpeakerList

      +

      EventCreate

      -

      MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Speaker · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speaker/SpeakerList.razor.cs:19 · Level 9 · class (Blazor code-behind)

      +

      MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Event · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Event/EventCreate.razor.cs:13 · Level 5 · class (Blazor code-behind)

        -
      • What it is: the organizer's speaker browse page: server-paged search with avatars, an event filter, delete-with-confirmation, and a mobile card layout (SpeakerList.razor.cs:13-18).
      • -
      • Depends on: extends DataGridListPageBase<TDto> (:19); ISpeakerUIService and IEventLookupService (:24-25), SpeakerDTO, EventInfo (:39), CurrentEventSelector (:103), ListPageActions (:113,165), ErrorMessages (:171), ConferenceRoutePaths (:174-175), MobileInfiniteScrollList<TItem> (:33), and the shared DeleteConfirmation component (:34).
      • -
      • Concept: the same event-filtered list shape as PublicSpeakerList, with the audience logic removed. Every reader of this page is already an organizer, so the filter choice is persisted unconditionally (:41-49) and the "all" sentinel is the only distinction that matters; ResolveDefaultEventFilter (:92-110) keeps a restored id that still exists and otherwise falls back to the current-or-next event; and the same startup race guard applies, with _eventsLoadTask started before the first await (:70-76) and awaited inside both LoadServerData (:128-140) and FetchMobilePage (:151-159). Reading the two pages side by side is the clearest way to see what the privileged/non-privileged split actually costs: one extra role check and one narrowed persistence rule. - [Rubric §19, State Management & Data Flow], [Rubric §25, Navigation & Information Architecture], and [Rubric §16, Maintainability] (assesses reuse of one tested shape rather than parallel implementations).
      • -
      • Walkthrough
          -
        • ApplyFilters (:142-148): FullName contains plus the same virtual EventId equals filter resolved server-side through the EventSpeaker/SessionSpeaker joins (class doc, :15-17).
        • -
        • LoadServerData (:128-140) does not pass showCancelSnackbar: false, unlike the public lists, so the base's default cancel notification applies here.
        • -
        • DeleteSpeakerAsync (:164-172) delegates the whole confirm, delete, notify, reload cycle to ListPageActions.DeleteWithConfirmationAsync, passing the delete lambda and the localized messages; the speaker is a top-level entity, so it deletes by a single id (contrast the child-entity list pages, which pass a parent id too).
        • -
        • NavigateToCreate / NavigateToDetails (:174-175) reach SpeakerCreate and SpeakerDetail; OnMobileCardClick (:161) reuses the same detail navigation.
        • -
        +
      • What it is: the organizer form that creates a conference event. It collects the name, description, + start and end dates, IANA time zone, Sessionize code, and the optional venue fields (address, map URL, + Wi-Fi, organizer contact, sponsorship packet, ticketing link), posts the record unpublished, and + redirects to the new event's detail page. It is the first page in this unit and the place where the + Conference create-form shape is taught.
      • +
      • Depends on: IEventUIService (injected at + .../Pages/Event/EventCreate.razor.cs:15), EventDTO, + ConferenceRoutePaths, and + ErrorMessages. Externals: Blazor ([Inject], + NavigationManager, OnInitialized), MudBlazor (MudForm, ISnackbar, BreadcrumbItem, + Icons.Material.Filled.Home), the shared UnsavedChangesGuard component from MMCA.Common.UI, and + the IStringLocalizer<EventCreate> the template injects as L + (.../Pages/Event/EventCreate.razor:4).
      • +
      • Concept introduced, the partial-class code-behind create form. Every Conference page is a .razor + template plus a .razor.cs partial holding the injected services, backing fields, and handlers. The + create leg layers four recurring mechanisms on that split, and every other create page in this unit + repeats them:
          +
        1. Cancel-on-disposal: a CancellationTokenSource _cts (line 19) is passed to every service call + and cancelled plus disposed through the standard Dispose(bool) pattern (lines 115-133), so an + in-flight save cannot resolve against a torn-down component.
        2. +
        3. Validate-then-submit: await _form.ValidateAsync() followed by an IsValid guard (lines 60-65) + with a hand-written cross-field check for the date range (lines 67-71), while the IsSaving flag + (line 36) disables the button for the round trip and is always cleared in finally (lines 107-110).
        4. +
        5. An unsaved-changes guard: _isDirty is set by MarkDirty() (line 53) and consumed by the + UnsavedChangesGuard component in the template (.../Pages/Event/EventCreate.razor:8); it is + cleared before the success redirect (line 95) so the guard does not block the page's own + navigation.
        6. +
        7. Two-tier failure handling: OperationCanceledException is swallowed as expected during + disposal or an InteractiveAuto render-mode transition (lines 99-102), everything else snackbars a + localized error (lines 103-106). ADR-056 (Website/docs-src/adr/056-blazor-render-mode-strategy.md) + is the record behind that first catch. + [Rubric §24, Forms, Validation & UX Safety] (assesses client validation, unsaved-change protection, + and safe submits): this page validates before posting, tracks dirty state, and guards navigation away. + [Rubric §18, UI Architecture & Component Design] (assesses logic separated from markup): the + code-behind keeps the template declarative. [Rubric §11, Security] (assesses authorization at the + boundary the user actually reaches): the route is organizer-only via + @attribute [Authorize(Roles = "Organizer")] (.../Pages/Event/EventCreate.razor:2). + [Rubric §27, Internationalization] (assesses externalized user-facing text): every label, breadcrumb, + and snackbar reads through L (for example L["Snackbar.Created"], line 96), per ADR-011 and ADR-027.
        8. +
      • -
      • Why it's built this way: organizers work one conference at a time, so the list defaults to the current or next event; everything else is the shared base doing the paging, restoration, and layout switching.
      • -
      • Where it's used: the /speakers route (SpeakerList.razor:1), the entry point for the whole speaker admin flow.
      • +
      • Walkthrough
          +
        • OnInitialized (lines 25-34) builds the three-item breadcrumb trail: Home, the events list, and a + disabled "Create" leaf (lines 28-33).
        • +
        • Backing fields (lines 38-49) hold the form values, with _timeZone seeded to "America/New_York" + (line 42), the conference's home zone, so the common case needs no edit.
        • +
        • CreateEventAsync (lines 55-111) validates, converts the two DateTime? pickers to DateOnly + (lines 81-82), builds the EventDTO with Id = default + (line 78) and IsPublished = false (line 91), posts with AddAsync (line 94), clears _isDirty, + snackbars success, and navigates to ConferenceRoutePaths.EventDetails(created.Id) (line 97) using + the id the server returned.
        • +
        • NavigateToList (line 113) is the cancel path back to /events.
        • +
        +
      • +
      • Why it's built this way: a new event starts unpublished so an organizer can fill in venue details + and refresh from Sessionize before anything is publicly visible; publishing is a separate deliberate + action on EventDetail. Posting Id = default hands identifier assignment to the + server rather than the browser, which is the opposite choice from the sibling create pages below.
      • +
      • Where it's used: the /events/create route (.../Pages/Event/EventCreate.razor:1), reached from + EventList's create button; on success it hands off to EventDetail.
      • +
      +

      OrganizerEventFeedback

      +
      +

      MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Feedback · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Feedback/OrganizerEventFeedback.razor.cs:14 · Level 5 · class (Blazor code-behind)

      +
      +
        +
      • What it is: the organizer's read-and-moderate view of event feedback. It loads every answer + submitted for one event, groups the answers under their question, averages the rating questions, and + lets the organizer delete an individual free-text answer.
      • +
      • Depends on: IOrganizerEventFeedbackUIService (line 16), + IQuestionUIService (line 17), and + IEventLookupService (line 18) returning EventInfo; the + QuestionDTO and + EventQuestionAnswerDTO shapes; + ConferenceRoutePaths; and + DomainHelper's Parse<T> string extension + (MMCA.Common.Shared.Extensions, line 5). Externals: Blazor [Parameter], MudBlazor (ISnackbar, + MudRating, MudCard), and the PageLoadingState / PageErrorState components from + MMCA.Common.UI.
      • +
      • Concept introduced, the inline page-level error state. Unlike the create and detail pages, which + snackbar their failures, this page keeps a _loadError string (line 29) and renders PageErrorState + instead of the body when the load failed (.../Pages/Feedback/OrganizerEventFeedback.razor:17-20). The + distinction is deliberate: a snackbar expires, and a feedback page that silently shows zero responses + after a failed fetch reads as "nobody answered". A missing event sets the same field with + L["Error.EventNotFound"] (line 56) rather than a generic message. + [Rubric §19, State Management & Data Flow] (assesses where view state lives and how failure is + represented): loading, error, empty, and populated are four distinct rendered states driven by + IsLoading (line 27), _loadError, and the two collections. + [Rubric §30, Compliance, Privacy & Data Governance] (assesses control over user-submitted content): + answer deletion is the organizer's moderation lever over free-text feedback (BR-53 in the type's own + doc comment, lines 10-13). + [Rubric §23, Front-End Performance & Rendering]: aggregation happens client-side over one bulk + answer fetch (line 70) rather than per-question round trips.
      • +
      • Walkthrough
          +
        • The route id arrives as [Parameter] public string EventId (line 21) and is converted to the typed + alias with EventId.Parse<EventIdentifierType>() (line 46), so the page compiles unchanged whichever + primitive the alias maps to (ADR-048, revisited in ADR-085).
        • +
        • OnInitializedAsync (lines 35-84) builds breadcrumbs (lines 37-42), resolves the event name from the + lookup and bails with Error.EventNotFound when the id is unknown (lines 49-58), fetches the + event-scoped questions with the server filter QuestionEntity equals "Event" sorted by Sort + (page 1, size 100, lines 61-66), then loads all answers for the event (line 70). The finally + always clears IsLoading (lines 80-83).
        • +
        • Rendering (.../Pages/Feedback/OrganizerEventFeedback.razor:37-84) pairs each question with + _answers.Where(a => a.QuestionId == question.Id) (line 37). A question whose QuestionType is + "Rating" (case-insensitive, line 55) parses the answer values to integers, drops the unparseable + ones, and renders a read-only MudRating at the rounded average plus the average to one decimal and + the ratings count (lines 57-69). Anything else renders each answer as pre-wrapped text with a delete + icon button carrying an aria-label (lines 73-83). [Rubric §21, Accessibility].
        • +
        • DeleteAnswerAsync (lines 86-102) deletes one answer, refetches the whole answer set (line 91), + and snackbars the outcome, so the page never hand-patches its local collection.
        • +
        +
      • +
      • Why it's built this way: the organizer needs one screen that answers "what did attendees say about + this event", and the aggregate/free-text split follows from the question model itself: ratings are only + meaningful in aggregate, free text is only meaningful individually (and is the only thing that can need + moderating).
      • +
      • Where it's used: the /events/{EventId}/feedback route + (.../Pages/Feedback/OrganizerEventFeedback.razor:1-2, organizer-only), reached from the "view + feedback" button on EventDetail (.../Pages/Event/EventDetail.razor:176). The + attendee-facing counterpart lives in the Engagement module + (EventFeedback).
      • +
      • Caveats / not-in-source: the questions fetch takes a single 100-row page (line 66); behavior beyond + 100 event questions is not handled in this file.
      • +
      +

      OrganizerSessionFeedback

      +
      +

      MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Feedback · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Feedback/OrganizerSessionFeedback.razor.cs:14 · Level 5 · class (Blazor code-behind)

      +
      +
        +
      • What it is: the session-scoped twin of OrganizerEventFeedback. Same + load-group-aggregate-moderate flow, one level down the hierarchy: answers for a single session instead + of a whole event.
      • +
      • Depends on: IOrganizerSessionFeedbackUIService (line 16), + IQuestionUIService (line 17), and ISessionUIService + (line 18) in place of the event lookup; the + SessionQuestionAnswerDTO and + QuestionDTO shapes; + ConferenceRoutePaths; and the same Parse<T> extension (line 5).
      • +
      • Concept introduced: none new. The page-level error state, the rating-versus-text rendering split, + and the refetch-after-delete rule are the ones taught in + OrganizerEventFeedback.
      • +
      • Walkthrough (only the differences from its twin):
          +
        • The route parameter is [Parameter] public string SessionId (line 21), parsed to + SessionIdentifierType (line 46).
        • +
        • The title comes from the session itself rather than a lookup dictionary: + SessionService.GetByIdAsync(_parsedSessionId, false, ...) with includeChildren: false (line 49), + since only session.Title is needed (line 56); a null result sets L["Error.SessionNotFound"] + (line 52).
        • +
        • The question filter is QuestionEntity equals "Session" (lines 59-62), the other half of the same + question table that the event page filters on "Event".
        • +
        • DeleteAnswerAsync (lines 84-100) takes a SessionQuestionAnswerIdentifierType and passes the + parsed session id alongside it (line 88).
        • +
        • The template links the session title back to the detail page + (.../Pages/Feedback/OrganizerSessionFeedback.razor:23) and ends with a back button to the same + route (line 92), where the event page instead links back to the event detail.
        • +
        +
      • +
      • Why it's built this way: session and event feedback are two instances of one questionnaire model, + so the two pages stay structurally identical rather than sharing a parameterized component; the cost is + duplication, the benefit is that each page's queries and route contract read literally.
      • +
      • Where it's used: the /sessions/{SessionId}/feedback route + (.../Pages/Feedback/OrganizerSessionFeedback.razor:1-2), reached from the "view feedback" button on + SessionDetail (.../Pages/Session/SessionDetail.razor:190). The attendee-facing + counterpart is SessionFeedback.
      • +
      +

      QuestionCreate

      +
      +

      MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Question · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Question/QuestionCreate.razor.cs:9 · Level 5 · class (Blazor code-behind)

      +
      +
        +
      • What it is: the organizer form that defines a feedback question: its text, which entity it attaches + to (Event or Session), its type (for example Rating), its sort order, and whether an answer is + required.
      • +
      • Depends on: IQuestionUIService (line 11), + QuestionDTO, + ConferenceRoutePaths, and + ErrorMessages. Externals: MudBlazor (MudForm, + ISnackbar), System.Security.Cryptography.RandomNumberGenerator, and the + IStringLocalizer<QuestionCreate> injected as L.
      • +
      • Concept introduced, the client-minted identifier. QuestionDTO.Id is a required non-nullable + alias over int, so the form has to put something there. This page fabricates a value with + RandomNumberGenerator.GetInt32(999_999_000, 999_999_999) (line 61), which is the reserved + user-created question range recorded on the domain side as QuestionInvariants.ManualIdRangeStart and + ManualIdRangeEnd + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:37,40), + the band that sits above every Sessionize-assigned id. The server does not trust it either way: + CreateQuestionHandler always allocates the next free id in that range and overwrites the request + ("Caller-provided IDs are ignored", + MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Questions/UseCases/Create/CreateQuestionHandler.cs:71-87). + The page is written to tolerate that: it navigates using created.Id from the response (line 71), not + the value it sent. Compare EventCreate, which posts Id = default instead, and + RoomCreate / SessionCreate, whose minted ids the server does + respect. + [Rubric §8, Data Architecture] (assesses who owns identifier assignment): identity here is + server-owned inside a reserved range, because the same table also holds rows imported from Sessionize + under externally assigned ids. + [Rubric §24, Forms, Validation & UX Safety]: the validate-then-submit, dirty-guard, and + cancel-on-disposal mechanics are the ones EventCreate introduces.
      • +
      • Walkthrough
          +
        • OnInitialized (lines 20-29) builds the Home / Questions / Create breadcrumb trail.
        • +
        • The backing fields (lines 32-36) default _questionEntity to "Session" and _questionType to + "Rating" (lines 33-34), the most common combination.
        • +
        • CreateQuestionAsync (lines 42-85) validates the form, builds the DTO with the minted id (lines + 59-67), posts with AddAsync (line 68), clears _isDirty (line 69), and navigates to + ConferenceRoutePaths.QuestionDetails(created.Id) (line 71).
        • +
        +
      • +
      • Why it's built this way: questions are the schema behind every feedback screen in both the + Conference and Engagement modules, so they are organizer-authored data rather than configuration; the + reserved id band keeps hand-authored questions from ever colliding with imported ones.
      • +
      • Where it's used: the /questions/create route (.../Pages/Question/QuestionCreate.razor:1), + reached from QuestionList; on success it hands off to + QuestionDetail. The questions it creates are what + OrganizerEventFeedback and + OrganizerSessionFeedback group their answers under.
      • +
      +

      RoomCreate

      +
      +

      MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Room · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Room/RoomCreate.razor.cs:9 · Level 5 · class (Blazor code-behind)

      +
      +
        +
      • What it is: the organizer form that adds a room to an event. It collects the owning event, name, + sort order, capacity, floor, location, and accessibility information, then redirects to the room's + detail page.
      • +
      • Depends on: IRoomUIService (line 11) and + IEventLookupService (line 12) returning EventInfo; + RoomDTO; ConferenceRoutePaths; + and ErrorMessages. Uses the Event and Room + identifier aliases.
      • +
      • Concept introduced, the single-option auto-select. A room is meaningless without a parent event, so + the page loads the event lookup in OnInitializedAsync and, when the lookup holds exactly one + event, preselects it (lines 46-50). ADC normally runs one conference at a time, so this removes a + mandatory click that has only one possible answer while still rendering a real picker when more than + one event exists. A lookup failure is non-fatal: it snackbars Snackbar.LoadEventsFailed (line 58) and + leaves the form usable. + [Rubric §24, Forms, Validation & UX Safety]: fewer required inputs with no loss of correctness, since + the field is still validated on submit. + This page also mints a client id, RandomNumberGenerator.GetInt32(100_000, int.MaxValue) (line 81), + which RoomService forwards as the API contract's RoomId field + (.../Services/RoomService.cs:17-33). Unlike the question path, AddRoomHandler treats a supplied id + as authoritative and only auto-allocates from the reserved range when RoomId is null + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/AddRoom/AddRoomHandler.cs:37-40,87-108), + so a room created here keeps the value the browser generated.
      • +
      • Walkthrough
          +
        • OnInitializedAsync (lines 35-60) builds the breadcrumbs (lines 37-42), fetches the event lookup + (line 46), applies the single-event preselect (lines 47-50), and splits cancellation from real + failures (lines 52-59).
        • +
        • CreateRoomAsync (lines 62-107) runs the standard validate-then-submit, builds the + RoomDTO with the minted id and the chosen EventId (lines + 79-89), posts it (line 90), clears _isDirty (line 91), and navigates to + ConferenceRoutePaths.RoomDetails(created.Id) (line 93).
        • +
        • NavigateToList (line 109) and the Dispose(bool) pair (lines 111-133) are identical to the other + create pages.
        • +
        +
      • +
      • Why it's built this way: rooms are per-event data that the Sessionize sync also writes, so the UI + create path has to coexist with imported rows; the event picker is mandatory because the server rejects + a room whose event does not exist, and the reserved id band is what keeps the two sources apart.
      • +
      • Where it's used: the /rooms/create route (.../Pages/Room/RoomCreate.razor:1), reached from + RoomList; on success it hands off to RoomDetail. Rooms created here are + what SessionCreate and SessionDetail offer in their room + pickers.
      • +
      • Caveats / not-in-source: the minted range 100_000 .. int.MaxValue sits below the reserved + RoomManualIdRangeStart of 999_999_000 + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:62,65), and + because the handler respects an explicit id, the value is persisted as sent. Whether a generated value + can collide with a Sessionize-assigned room id is a data question this file cannot answer.
      -
      -

      PublicSessionList

      +

      SessionCreate

      -

      MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Public · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSessionList.razor.cs:25 · Level 10 · class (Blazor code-behind)

      +

      MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Session · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Session/SessionCreate.razor.cs:15 · Level 5 · class (Blazor code-behind)

        -
      • What it is: the public conference schedule and the most heavily-wired page in this unit. It is the container half of a three-part page (this class, PublicSessionListFilterBar, PublicSessionListView): it owns the events and speaker lookups, the event/search/My-Schedule filter state, the bookmark dictionary, the server-paged fetch, and the offline snapshot (PublicSessionList.razor.cs:16-24).
      • -
      • Depends on: extends DataGridListPageBase<TDto> (:25); ISessionUIService, IEventUIService, ISpeakerLookupService (:29-33), the optional ISessionBookmarkUIService (:38), ILocalCacheStore and IConnectivityStatusService (:30-31); SessionDTO, EventDTO, SpeakerInfo, ConferenceReadAudience (:149), CurrentEventDefaults (:193), and the nested CachedSessionPage record (:342).
      • -
      • Concept introduced, the container page with two racing loads and a dual-branch fetch. Everything the sibling list pages do once, this page does twice and then adds a mode switch.
          -
        1. Two startup tasks, both awaited by the fetch path. OnInitializedAsync (:112-140) starts _bookmarkLoadTask and _eventsLoadTask before its first await. The comments (:122-131) name the exact failure each guards: the MudDataGrid's first ServerData call can run ahead of initialization, notably on in-app back-navigation where there is no SSR prerender to supply grid data, and a half-initialized _isAuthenticated == false would make the My Schedule branch silently fall through to fetching all sessions. LoadServerData (:249-261) awaits the events task and FetchSessionsAsync (:268-281) awaits the bookmark task.
        2. -
        3. Two fetch branches, both truly server-paged. In My Schedule mode with bookmarks present, the page adds an Id IN (...) server filter built from the bookmark dictionary keys (:296-305) and lets the server page; the comment (:293-295) records that this replaced pulling a 500-row page and paging in memory, which also reported a wrong total past 500. An empty bookmark set short-circuits to ([], 0) (:288-291). [Rubric §12, Performance & Scalability].
        4. -
        5. Audience-scoped filter persistence. As on PublicSpeakerList, only privileged readers persist an event choice (:73-85), and ResolveDefaultEventFilter (:180-195) locks everyone else to the computed current/next event via CurrentEventDefaults; the comment (:182-185) states the security consequence: a shared privileged URL can never pin an attendee to a different or unpublished event. [Rubric §11, Security] and [Rubric §26, Front-End Security].
        6. -
        7. A deep link that beats saved state. [SupplyParameterFromQuery(Name = "mine")] (:60-66) carries the MAUI head's home-screen quick action into the My Schedule view, and OnInitializedAsync applies it after the base has restored saved page state so intent wins (:116-120). [Rubric §25, Navigation & Information Architecture] and ADR-042 Wave 2.
        8. -
        9. The offline snapshot taught at CachedSessionPage (:314-336). [Rubric §29, Resilience & Business Continuity].
        10. -
        -
      • +
      • What it is: the organizer form that creates a session. It collects a title, description, owning + event, optional room, start/end date-and-time, and the "service session" flag, posts the new record, + and redirects to that session's detail page. It is the most mechanical of the create pages and the + clearest place to see the two things that make session editing awkward: a dependent lookup (rooms + belong to the chosen event) and split date/time pickers.
      • +
      • Depends on: ISessionUIService (the create client, injected at + .../Pages/Session/SessionCreate.razor.cs:17), IEventLookupService returning + EventInfo (line 18), IRoomUIService for the room dropdown (line + 19), SessionDTO, + RoomDTO, ConferenceRoutePaths, + and ErrorMessages. It uses the Event, Room, and + Session identifier aliases. Externals: Blazor ([Inject], NavigationManager), MudBlazor + (MudForm, ISnackbar, BreadcrumbItem), System.Security.Cryptography.RandomNumberGenerator, and + the IStringLocalizer<SessionCreate> injected by the template.
      • +
      • Concept introduced, the dependent lookup. The create-form shape itself is + EventCreate's; what is new here is that one field's options depend on another field's + value. LoadRoomsAsync (lines 81-96) fetches rooms filtered by the selected event, and + OnEventChangedAsync (lines 99-118) reloads them and clears the previous choice whenever the event + changes. The doc comment (lines 76-80) records why this is not cosmetic: BR-130 rejects a room from + another event server-side, so the dropdown must only ever offer rooms of the chosen event. + [Rubric §24, Forms, Validation & UX Safety]: the client is shaped so it cannot compose a request the + server will refuse. [Rubric §19, State Management & Data Flow]: _rooms is derived state, explicitly + invalidated when its input changes rather than left to go stale. + A second idea this page shows is the client-minted identifier: it fabricates + RandomNumberGenerator.GetInt32(100_000, int.MaxValue) (line 144) to satisfy the required + SessionDTO.Id, and because CreateSessionHandler only auto-allocates from the reserved range when + command.Id == default and otherwise respects an explicit id + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/Create/CreateSessionHandler.cs:76-95), + the generated value is what gets persisted. The page still reads created.Id back from the response + (line 157), so it works either way. Contrast EventCreate, which posts Id = default, + and QuestionCreate, whose id is always overwritten.
      • Walkthrough
          -
        • SaveFilters / RestoreFilters (:73-110): persist search, the My Schedule toggle, and (privileged only) the event id with the "all" sentinel.
        • -
        • LoadEventsAndResolveDefaultAsync (:142-178): resolves privileged status from role membership, fetches events with children and flattens their rooms into _roomNames (:159-168), loads the speaker lookup (:170), then resolves the default event. One children-loaded events fetch plus one speaker lookup replace per-row enrichment calls. [Rubric §23, Front-End Performance & Rendering].
        • -
        • LoadBookmarkStateAsync (:202-226): reads the user_id claim and loads the bookmarked session ids into the dictionary the view patches in place; a failure is non-critical (stars do not appear, sessions still load).
        • -
        • ApplyAdditionalFilters (:344-355): Title contains and EventId equals; FetchMobilePage (:358-366) builds the same filters for the infinite-scroll list and reuses FetchSessionsAsync, so both layouts share one fetch implementation including its offline path.
        • -
        • The optional Engagement service is resolved with GetService (:114) for the same reason as on PublicSessionDetail: [Inject] has no optional mode.
        • +
        • OnInitializedAsync (lines 47-74) builds the breadcrumb trail (lines 49-54), loads the event lookup + (line 58), auto-selects the only event when the lookup has exactly one entry (lines 59-62, the + same single-conference convenience as RoomCreate), then calls LoadRoomsAsync.
        • +
        • LoadRoomsAsync (lines 81-96): with no event chosen it clears _rooms and returns (lines 83-87); + otherwise it fetches up to 500 rooms filtered by EventId equals <selected> (lines 89-94).
        • +
        • OnEventChangedAsync (lines 99-118) marks the form dirty, clears the previously picked room + (line 104, with the in-code note that keeping it would have the server reject the save), and reloads + the room list.
        • +
        • CreateSessionAsync (lines 120-171) validates, then recombines the two picker pairs into + StartsAt/EndsAt only when both the date and the time part are set (lines 137-140), builds the + SessionDTO (lines 142-152), posts it with AddAsync + (line 154), clears _isDirty, snackbars success, and navigates to + ConferenceRoutePaths.SessionDetails(created.Id) (line 157). The finally always clears IsSaving.
      • -
      • Why it's built this way: this is the highest-traffic page of the conference, viewed on bad networks by both anonymous browsers and signed-in attendees managing a personal schedule. That drives every design decision visible here: server-side everything, one enrichment fetch, ordering guarantees around the grid's eager first call, an audience-locked event filter, and a cached last-known-good first page.
      • -
      • Where it's used: the /conference/sessions route (PublicSessionList.razor:1), including the ?mine=true deep link; it renders PublicSessionListFilterBar and PublicSessionListView and routes onward to PublicSessionDetail.
      • +
      • Why it's built this way: one create-form shape (validate, post, redirect to detail) is reused across + the Conference entities so behavior stays uniform; the split date/time editing exists because MudBlazor + has no single date-time picker, so the page composes two controls and recombines them defensively; the + event-scoped room reload keeps the client from ever offering a value the server will reject.
      • +
      • Where it's used: the /sessions/create route, reached from SessionList's create + button; on success it hands off to SessionDetail.
      -

      ConferenceCategoryCreate, QuestionCreate, RoomCreate

      +

      EventList

      -

      MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.{ConferenceCategory,Question,Room} · Level 5 · classes (Blazor code-behind)

      +

      MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Event · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Event/EventList.razor.cs:16 · Level 7 · class (Blazor code-behind)

      -
      - - - - - - - - - - - - - - - - - - - - - - -
      TypeFile:LineNotes (what differs)
      ConferenceCategoryCreateMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/ConferenceCategory/ConferenceCategoryCreate.razor.cs:9Three fields (title, sort, type). Posts Id = default (:58) and lets the server assign the key.
      QuestionCreateMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Question/QuestionCreate.razor.cs:9Adds the entity/type/required triple, defaulted to "Session" and "Rating" (:33-34). Mints a placeholder int id in a reserved high band (:61).
      RoomCreateMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Room/RoomCreate.razor.cs:9The only one with a prerequisite fetch: it loads the event lookup in OnInitializedAsync and auto-selects the event when exactly one exists (:46-50). Mints a placeholder int id (:81).
        -
      • What they are: the three narrow organizer create forms in this group. Each collects a handful of fields, posts one DTO through its UI service, and redirects to the detail page for the record it just made.
      • -
      • Depends on: IConferenceCategoryUIService (ConferenceCategoryCreate.razor.cs:11), IQuestionUIService (QuestionCreate.razor.cs:11), IRoomUIService plus IEventLookupService (RoomCreate.razor.cs:11-12); the matching DTOs ConferenceCategoryDTO, QuestionDTO, RoomDTO; EventInfo (RoomCreate.razor.cs:30); ConferenceRoutePaths and ErrorMessages; MudBlazor's MudForm and ISnackbar, plus NavigationManager.
      • -
      • Concept introduced, the create-page shape and its two safety rails. SpeakerCreate shows the same flow on the widest form; these three are the compact version, and together they make the shape easy to read.
          -
        1. Validate before you mutate. Each Create*Async calls await _form.ValidateAsync() and returns with a warning snackbar when !_form.IsValid, before any service call (ConferenceCategoryCreate.razor.cs:48-53, QuestionCreate.razor.cs:49-54, RoomCreate.razor.cs:69-74). The server validates again; this pass exists to keep a round trip off the wire and to put the message next to the field. [Rubric §24, Forms, Validation & UX Safety] (assesses whether a form can submit itself into a predictable failure).
        2. -
        3. Dirty tracking that cannot block its own redirect. Every editable control calls MarkDirty() (ConferenceCategoryCreate.razor.cs:39, QuestionCreate.razor.cs:40, RoomCreate.razor.cs:33) and the markup mounts the shared guard as <UnsavedChangesGuard IsDirty="_isDirty" IsDirtyAccessor="() => _isDirty" /> (ConferenceCategoryCreate.razor:8, QuestionCreate.razor:8, RoomCreate.razor:9). The accessor is the load-bearing half: the guard prefers IsDirtyAccessor?.Invoke() over the parameter snapshot (MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/UnsavedChangesGuard.razor:33-35), and its own doc comment records why (:28-32), because clearing the flag and calling NavigateTo without an intervening StateHasChanged() would otherwise still prompt. The pages clear _isDirty on the success path before navigating (ConferenceCategoryCreate.razor.cs:60, QuestionCreate.razor.cs:69, RoomCreate.razor.cs:91). - There is a third rail these pages share with every other page in the group: a private CancellationTokenSource (ConferenceCategoryCreate.razor.cs:15) passed into the service call and cancelled in a full Dispose(bool) pattern (:80-102), with OperationCanceledException caught and ignored as the expected teardown outcome (:64-67). [Rubric §23, Front-End Performance & Rendering]: a form abandoned mid-post does not keep a response alive for a component that no longer exists. - Two of the three mint their own primary key client-side, because Question and Room are int-keyed and the POST contract carries the id: QuestionCreate uses RandomNumberGenerator.GetInt32(999_999_000, 999_999_999) (:61) and RoomCreate uses RandomNumberGenerator.GetInt32(100_000, int.MaxValue) (:81); ConferenceCategoryCreate sends Id = default (:58). All three then navigate using created.Id from the response (ConferenceCategoryCreate.razor.cs:62, QuestionCreate.razor.cs:71, RoomCreate.razor.cs:93), so a server-assigned key wins regardless. [Rubric §8, Data Architecture] (assesses a deliberate identity strategy): the identifier alias (ADR-048) keeps the key type out of the page's own logic.
        4. +
        5. What it is: the organizer browse page for events: a server-paged, server-sorted grid on desktop, an + infinite-scroll card list on mobile, with a name search, a delete-with-confirmation action, and + navigation into create and detail. It is the simplest inheritor of the shared list base and the place + where the Conference list-page shape is taught.
        6. +
        7. Depends on: extends + DataGridListPageBase<TDto> over + EventDTO (line 16) and injects + IEventUIService (line 21). It uses + ListPageActions, + ErrorMessages, + ConferenceRoutePaths, and the + MobileInfiniteScrollList<TItem>, + DeleteConfirmation, and ListNoRecordsContent components from MMCA.Common.UI.
        8. +
        9. Concept introduced, the two-layout list page over one shared base. A Conference list page is + roughly forty lines because everything hard lives in + DataGridListPageBase<TDto>. The derived + page supplies five things and nothing else:
            +
          1. A grid reference: _dataGrid captured via @ref and surfaced through the overridden GridRef + (lines 24-25), which the base needs to restore rows-per-page after first render.
          2. +
          3. Filter persistence: SaveFilters / RestoreFilters (lines 33-37) write and read the page's own + search string, and the base persists them to the URL query string, an in-memory service, and + session storage, so filters survive navigation, refresh, and a shared link. + [Rubric §25, Navigation & Information Architecture].
          4. +
          5. The fetch delegates: LoadServerData (lines 48-57) hands the base a lambda that calls + EventService.GetPagedAsync and an additionalFilters callback that appends + Name contains <search> (lines 53-57). LoadServerDataAsync in the base + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:434-511) + owns cancellation-token resetting, the SSR pre-render hand-off, page-plus-one index conversion, + sort extraction, the IsLoading and LoadFailed flags, and uniform error snackbars.
          6. +
          7. A mobile fetch: FetchMobilePage (lines 60-66) repeats the same filter build for the + card list, which pages by "load more" instead of a pager and sorts by Name asc. + [Rubric §22, Responsive & Cross-Browser]: the page renders two genuinely different layouts off the + base's IsMobile flag rather than reflowing one grid.
          8. +
          9. Actions: DeleteEventAsync (lines 71-79) delegates the confirm, delete, snackbar, and reload + sequence to ListPageActions.DeleteWithConfirmationAsync, + and ReloadActiveLayoutAsync (lines 39-40) dispatches a refresh to whichever layout is live. + The failure path is worth noting: when a fetch fails the base sets LoadFailed, and the template feeds + it to ListNoRecordsContent with an OnRetry handler wired to RetryLoadAsync (line 28, + .../Pages/Event/EventList.razor:131), so a failed load renders an inline retry instead of an empty + list that looks like "no events". [Rubric §19, State Management & Data Flow]. + Data access itself follows ADR-094 (Website/docs-src/adr/094-client-entity-data-access.md): the page + never touches HttpClient, only the typed I*UIService client, and expresses filters as + operator-plus-value pairs the server model binder understands.
        10. -
        11. Walkthrough (using ConferenceCategoryCreate as the reference)
            -
          • OnInitialized (:20-29) builds the Home / Categories / Create breadcrumb trail from the localized resource strings; RoomCreate does the same inside OnInitializedAsync and then loads the event lookup (RoomCreate.razor.cs:37-59), reporting a lookup failure with one snackbar rather than blocking the form (:56-59).
          • -
          • CreateCategoryAsync (:41-76): null-guard the form, validate, set IsSaving, build the DTO (:58), post (:59), clear the dirty flag, snackbar, redirect to the detail route (:62); the finally always clears IsSaving (:72-75).
          • -
          • The field block carries a comment worth reading (:32-33): the backing field is _categoryTitle, not _title, so it does not collide with the localized Title page property that SonarAnalyzer S4275 would flag.
          • -
          • NavigateToList (:78) is the cancel action, and it routes through ConferenceRoutePaths rather than a literal. [Rubric §25, Navigation & Information Architecture]: every route in the module is a named constant in one file.
          • +
          • Walkthrough
              +
            • Title and EntityName (lines 18-19) come from the injected localizer; Title is the abstract + member the base uses in its error messages.
            • +
            • OnSearchChanged (lines 42-46) stores the text and reloads the active layout, so typing drives a + server round trip rather than a client-side filter over the current page.
            • +
            • OnMobileCardClick (line 68) and NavigateToDetails (lines 84-85) both route through + ConferenceRoutePaths.EventDetails(id), and NavigateToCreate (lines 81-82) opens + EventCreate.
          • -
          • Why they're built this way: one create shape repeated per entity keeps the organizer's mental model constant (fill, validate, save, land on the new record) while each page varies only in the fields it collects and whether it needs a lookup first.
          • -
          • Where they're used: the /conferencecategories/create, /questions/create, and /rooms/create routes, each carrying [Authorize(Roles = "Organizer")] on the page (ConferenceCategoryCreate.razor:1-2, QuestionCreate.razor:1-2, RoomCreate.razor:1-2). Each is reached from its list page's create button and redirects to ConferenceCategoryDetail, QuestionDetail, or RoomDetail.
          • -
          • Caveats / not-in-source: whether the API honors or replaces a client-minted id is decided in the Conference service, not here; the pages read the id back from the response either way.
          • +
          • Why it's built this way: nineteen list pages across the workspace share this base (ADR-056 records + the count and the render-mode strategy they run under), so browse behavior, state persistence, and + error handling stay identical everywhere and a new list page costs a few dozen lines.
          • +
          • Where it's used: the /events organizer route (.../Pages/Event/EventList.razor:1-2, + Authorize(Roles = "Organizer")); rows open EventDetail and the create button opens + EventCreate.
          -
          -

          EventCreate

          +

          QuestionList

          -

          MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Event · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Event/EventCreate.razor.cs:13 · Level 5 · class (Blazor code-behind)

          +

          MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Question · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Question/QuestionList.razor.cs:11 · Level 7 · class (Blazor code-behind)

            -
          • What it is: the organizer form that creates a conference. It collects the name, description, date range, time zone, Sessionize code, and the optional venue block (address, map URL, Wi-Fi, organizer contact, sponsorship packet URL), per its class doc (EventCreate.razor.cs:9-12).
          • -
          • Depends on: IEventUIService (:15), EventDTO, ConferenceRoutePaths (:31,95,111), and ErrorMessages (:62); MudBlazor and NavigationManager.
          • -
          • Concept: the create shape taught above, with two additions specific to an event.
              -
            1. A second validation gate the form cannot express. MudForm validates each field independently, so the page adds an explicit check that both ends of the date range are present before it builds the DTO (:66-70), with its own localized message. [Rubric §24, Forms, Validation & UX Safety] (assesses validation that spans fields, not just single inputs).
            2. -
            3. The page decides the initial lifecycle state. The DTO is posted with IsPublished = false (:89), so a new event always starts private and becomes visible only through the explicit publish action on EventDetail. [Rubric §11, Security] and [Rubric §26, Front-End Security] (assess a safe default): an event cannot leak to the public browse pages because someone saved a draft. - The time zone field is seeded with "America/New_York" (:42), the IANA zone the conference actually runs in, and the date pickers hand back DateTime? which the page narrows to DateOnly with DateOnly.FromDateTime(...) (:80-81) to match the DTO's calendar-day shape.
            4. -
            -
          • -
          • Walkthrough: OnInitialized (:25-34) builds the Home / Events / Create breadcrumbs; the field block (:38-50) is the widest in the group; CreateEventAsync (:54-109) validates the form (:59-64), enforces the date range (:66-70), composes the full EventDTO (:75-90), posts it (:92), clears _isDirty before navigating to ConferenceRoutePaths.EventDetails(created.Id) (:93-95), and always clears IsSaving in the finally (:105-108). Disposal (:113-131) is the standard cancel-on-disposal pattern over the CancellationTokenSource at :19.
          • -
          • Why it's built this way: an event is the root of every other Conference record (rooms, sessions, sponsors and feedback all hang off it), so the form is deliberately complete on the first save and deliberately unpublished until an organizer says otherwise.
          • -
          • Where it's used: the /events/create route with [Authorize(Roles = "Organizer")] (EventCreate.razor:1-2), reached from EventList; it redirects to EventDetail.
          • +
          • What it is: the organizer browse page for feedback questions. Structurally the twin of + EventList: same base class, same two layouts, same delete flow, with the search bound to + the question text instead of a name.
          • +
          • Depends on: extends + DataGridListPageBase<TDto> over + QuestionDTO (line 11) and injects + IQuestionUIService (line 16); plus + ListPageActions, + ErrorMessages, + ConferenceRoutePaths, and the same three shared components.
          • +
          • Concept introduced: none new. See EventList for the base-class contract (grid ref, + filter persistence, the two fetch delegates, and the shared delete action).
          • +
          • Walkthrough (only the differences from EventList):
              +
            • The search filter targets QuestionText contains <search> on both the desktop (lines 48-52) and + mobile (lines 57-60) paths, and the mobile fetch sorts by QuestionText asc (line 60).
            • +
            • DeleteQuestionAsync (lines 67-75) passes question.QuestionText as the confirmation label, so the + dialog names the question being removed.
            • +
            • There is no detail-navigation helper: the mobile card click navigates inline (lines 63-64) and the + grid rows link from the template.
            • +
            +
          • +
          • Why it's built this way: questions are low-volume reference data, so the page needs browse, search, + and delete but no filters or enrichment; keeping it on the same base means it inherits URL-persisted + paging, sorting, and the inline retry state for free.
          • +
          • Where it's used: the /questions organizer route (.../Pages/Question/QuestionList.razor:1); the + create button opens QuestionCreate (line 77) and rows open + QuestionDetail.
          -
          -

          OrganizerEventFeedback, OrganizerSessionFeedback

          +

          EventDetail

          -

          MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Feedback · Level 5 · classes (Blazor code-behind)

          +

          MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Event · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Event/EventDetail.razor.cs:15 · Level 8 · class (Blazor code-behind)

          -
          - - - - - - - - - - - - - - - - - -
          TypeFile:LineNotes (what differs)
          OrganizerEventFeedbackMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Feedback/OrganizerEventFeedback.razor.cs:14Route parameter EventId (:21). Resolves the heading through IEventLookupService (:49-58) and filters questions on QuestionEntity equals "Event" (:61-64).
          OrganizerSessionFeedbackMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Feedback/OrganizerSessionFeedback.razor.cs:14Route parameter SessionId (:21). Resolves the heading with a direct GetByIdAsync(..., includeChildren: false, ...) (:49) and filters on QuestionEntity equals "Session" (:59-62).
            -
          • What they are: the organizer's feedback readers. Each loads every answer for one event or one session, groups them under the question they answer, renders ratings as an average and free text verbatim, and offers per-answer deletion for moderation, BR-53 (OrganizerEventFeedback.razor.cs:10-13, OrganizerSessionFeedback.razor.cs:10-13).
          • -
          • Depends on: IOrganizerEventFeedbackUIService / IOrganizerSessionFeedbackUIService and IQuestionUIService (OrganizerEventFeedback.razor.cs:16-17, OrganizerSessionFeedback.razor.cs:16-17); IEventLookupService (OrganizerEventFeedback.razor.cs:18) and ISessionUIService (OrganizerSessionFeedback.razor.cs:18); QuestionDTO plus EventQuestionAnswerDTO / SessionQuestionAnswerDTO; DomainHelper's Id.Parse<T> extension (OrganizerEventFeedback.razor.cs:46); ConferenceRoutePaths (:40).
          • -
          • Concept introduced, the two-fetch join done in the page, and aggregation that lives in the markup. Feedback is stored as answer rows that carry a QuestionId and a free-form AnswerValue; the question text and its type live on a separate record. Neither page asks the API for a joined shape. Each fetches the questions for its entity kind (one page, size 100, sorted by Sort ascending: OrganizerEventFeedback.razor.cs:65-67) and the answers (:70), then the markup pairs them with _answers.Where(a => a.QuestionId == question.Id) per question (OrganizerEventFeedback.razor:39). - The aggregation is markup-level too, and the branch on question type is the interesting part (OrganizerEventFeedback.razor:55-84): a "Rating" question parses each AnswerValue to an int, drops the unparsable ones, and renders the average as a read-only MudRating plus a one-decimal number (:57-69); every other type renders each answer verbatim with a delete button next to it (:74-83). So moderation is offered exactly where it is meaningful, on free text, and a numeric rating cannot be individually removed from the UI. [Rubric §24, Forms, Validation & UX Safety] (assesses that an action is offered only where it applies) and [Rubric §30, Compliance, Privacy & Data Governance] (assesses deliberate handling of user-submitted content): the answers are unattributed on screen, and the only operation offered is removal. - [Rubric §12, Performance & Scalability]: the question fetch is bounded at 100 rows by an explicit page size, but GetAllAnswersAsync is unbounded by design, since the page's whole purpose is the full response set for one entity. [Rubric §21, Accessibility]: the delete control carries an explicit aria-label (OrganizerEventFeedback.razor:80) because its icon carries no text. [Rubric §27, Internationalization]: every label, including the composite "N responses" and "average X" strings, resolves through the page's IStringLocalizer (OrganizerEventFeedback.razor:34,46,67-68).
          • -
          • Walkthrough
              -
            • OnInitializedAsync (OrganizerEventFeedback.razor.cs:35-84): build the breadcrumbs, parse the route id (:46), resolve the display name and fail into _loadError when the entity is unknown (:50-58), load the questions, then the answers. Any other exception collapses to one _loadError string (:76-79) and the finally clears IsLoading (:80-83).
            • -
            • DeleteAnswerAsync (:86-102): delete the answer, then refetch the whole answer set rather than patching the local list (:90-91), so the counts and averages the markup computes can never drift from the server.
            • -
            • The load states are rendered by the shared PageLoadingState and PageErrorState components (OrganizerEventFeedback.razor:13-20), so a failure is an inline panel rather than a blank page. [Rubric §29, Resilience & Business Continuity].
            • -
            • The two files are otherwise byte-identical in markup apart from the route, the heading (the session page makes its title a link back to the session, OrganizerSessionFeedback.razor:23), and the back button target.
            • -
            -
          • -
          • Why they're built this way: an organizer reading feedback wants one page per subject with the numbers already summarized, and the summarizing is cheap over a single event's answers. Refetching after a delete keeps that arithmetic honest for the price of one extra call on a rare action.
          • -
          • Where they're used: the /events/{EventId}/feedback and /sessions/{SessionId}/feedback routes, both [Authorize(Roles = "Organizer")] (OrganizerEventFeedback.razor:1-2, OrganizerSessionFeedback.razor:1-2), reached from EventDetail and SessionDetail; the attendee-facing sides of the same data are the public feedback forms.
          • -
          • Caveats / not-in-source: the answers endpoints are scoped to organizers server-side; these pages assume that scoping and only enforce the role on the route.
          • -
          -
          -

          ConferenceCategoryDetail

          -
          -

          MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.ConferenceCategory · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/ConferenceCategory/ConferenceCategoryDetail.razor.cs:11 · Level 7 · class (Blazor code-behind)

          -
          -
            -
          • What it is: the organizer's category console. It loads one ConferenceCategoryDTO with its children, inline-edits the category itself, and runs a full add / edit / delete loop over its CategoryItemDTO rows on the same page.
          • -
          • Depends on: IConferenceCategoryUIService and ICategoryItemUIService (:15-16), ConferenceCategoryDTO and CategoryItemDTO, the CategoryItem identifier alias (:60), DomainHelper's Id.Parse<T> (:76), ConferenceRoutePaths (:33,302), ErrorMessages (:80,89,127,147,180), and the shared DeleteConfirmation component twice over (:49,59).
          • -
          • Concept introduced, the parent-with-children editor, and shadow fields as the edit buffer. Two mechanisms carry this page.
              -
            1. Shadow fields. Entering edit mode copies the live record into _edit* fields (StartEditing, :97-109) and cancelling simply drops them (CancelEditing, :111-115). The loaded Category is never mutated, so an abandoned edit leaves nothing behind and the rendered values stay exactly what the server last returned. The item editor repeats the same idea with _editingItemId / _editItemName / _editItemSort (:60-62, seeded at :232-238). [Rubric §19, State Management & Data Flow] (assesses where mutable state lives and how long it lives).
            2. -
            3. Refetch, do not patch. Every mutation is followed by Category = await CategoryService.GetByIdAsync(Category.Id, true, _cts.Token) (:136, :214, :260, :289). The page never edits its local child collection: the server's answer is the only rendering source. That costs one extra read per action and removes an entire class of drift between what was saved and what is shown. [Rubric §19, State Management & Data Flow] and [Rubric §8, Data Architecture]. - The save path also round-trips the concurrency token: the updated DTO carries RowVersion = Category.RowVersion (:134), which is the client half of the optimistic-concurrency contract in ADR-035. [Rubric §8, Data Architecture] (assesses how concurrent writes are reconciled): a stale editor loses the write instead of silently overwriting a newer one.
            4. +
            5. What it is: the organizer's event console. It loads one event by route id and offers four distinct + operations on it: inline edit, publish/unpublish, refresh from Sessionize, and delete. It is the + richest detail page in the Conference UI in terms of verbs, and the place where the detail-page shape + is taught.
            6. +
            7. Depends on: IEventUIService (line 19) for all four operations; + EventDTO, + RefreshFromSessionizeResultDTO, and + QuestionModerationDefault; + ConferenceRoutePaths, + ErrorMessages, + DomainHelper's Parse<T> extension (line 4), and + the DeleteConfirmation plus UnsavedChangesGuard components from MMCA.Common.UI.
            8. +
            9. Concept introduced, load-once-on-parameters plus shadow-field editing. Three mechanisms combine + here and recur in every detail page below:
                +
              1. Route id as a string: the id arrives as [Parameter] public string Id (line 23) and is + converted with Id.Parse<EventIdentifierType>() (line 92), so the page compiles unchanged whichever + primitive the alias maps to (ADR-048, ADR-085).
              2. +
              3. Load once per id: OnParametersSetAsync compares against _loadedId and returns early when the + id is unchanged (lines 75-85), so a re-render does not refetch.
              4. +
              5. Shadow fields: StartEditing copies the loaded record into the _edit* fields (lines 117-139) + and CancelEditing simply drops edit mode (lines 141-145), so the live Event object is never + mutated until a validated save succeeds. [Rubric §24, Forms, Validation & UX Safety]. + [Rubric §8, Data Architecture] (assesses a deliberate concurrency strategy): every mutating call + re-sends the loaded RowVersion, on save (line 173) and on publish/unpublish (lines 221, 249), which is + the client half of the optimistic-concurrency token described in + Website/docs-src/adr/035-optimistic-concurrency.md; the server rejects a stale token rather than + silently overwriting a concurrent edit. + [Rubric §6, CQRS & Event-Driven]: publish and unpublish are not IsPublished flag edits, they are + their own service operations (PublishAsync / UnpublishAsync, + .../Services/IEventUIService.cs:12,14), so the state transition stays a named use case; note that + SaveChangesAsync deliberately re-sends the existing IsPublished value (line 186) rather than + letting the edit form move it. + [Rubric §13, Observability & Operability]: the Sessionize refresh returns a + RefreshFromSessionizeResultDTO held in + _refreshResult (line 69) so the organizer sees what the import actually did.
            10. Walkthrough
                -
              • OnParametersSetAsync (:64-95): the load-once-on-parameters guard compares the route Id against _loadedId (:66-71) so a re-render does not refetch, parses the id to ConferenceCategoryIdentifierType (:76), fetches with children (:77), and reports a null result as a not-found snackbar through ErrorMessages (:80).
              • -
              • Category edit (:97-153): StartEditing / CancelEditing as above, then SaveChangesAsync (:117-153) validates the MudForm first (:124-129), rebuilds the DTO with the round-tripped RowVersion (:134), updates, refetches, and clears both _isDirty and _isEditing on success (:138-139).
              • -
              • Category delete (:155-182): confirm through the shared DeleteConfirmation dialog seeded with the category title (:162), delete, then navigate back to the list (:172).
              • -
              • Item CRUD (:184-300): StartAddingItem resets the new-item fields and closes any open row edit (:185-191); AddItemAsync (:195-230) validates its own separate MudForm (:202-207), posts a CategoryItemDTO stamped with the parent CategoryId (:212), and refetches. UpdateItemAsync (:242-276) is the one path that does not use a MudForm: it hand-checks string.IsNullOrWhiteSpace(_editItemName) and warns (:249-253), because the row editor is inline in the table rather than a form. DeleteItemAsync (:278-300) confirms through the second dialog instance (:280) and refetches.
              • -
              • Disposal (:304-326) is the standard cancel-on-disposal pattern over the CancellationTokenSource at :22; the markup mounts the unsaved-changes guard (ConferenceCategoryDetail.razor:9).
              • -
              -
            11. -
            12. Why it's built this way: a category is only meaningful together with its items (a topic list, a locality list), so editing them on two routes would be worse than a slightly larger page. Refetching after every mutation is the cheap way to keep a composite view coherent without a client-side store.
            13. -
            14. Where it's used: the /conferencecategories/{Id} route with [Authorize(Roles = "Organizer")] (ConferenceCategoryDetail.razor:1-2), reached from ConferenceCategoryList rows and ConferenceCategoryCreate redirects. The items it authors are what CategoryItemLookupService resolves for the session and speaker pages.
            15. -
          -
          -

          ConferenceCategoryList, EventList, QuestionList

          -
          -

          MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.{ConferenceCategory,Event,Question} · Level 7 · classes (Blazor code-behind)

          -
          -
          - - - - - - - - - - - - - - - - - - - - - - -
          TypeFile:LineNotes (what differs)
          ConferenceCategoryListMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/ConferenceCategory/ConferenceCategoryList.razor.cs:11Searches Title; the only one that fetches with includeChildren: true (:47,60), because both layouts render CategoryItems.Count (ConferenceCategoryList.razor:37,92).
          EventListMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Event/EventList.razor.cs:16Searches Name. Routes through a named NavigateToDetails(EventIdentifierType) helper (:84-85) shared by the grid rows and the mobile cards.
          QuestionListMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Question/QuestionList.razor.cs:11Searches QuestionText, and uses the same field as the delete-confirmation label (:70).
          -
            -
          • What they are: the three organizer browse pages with a single search box and no other filter. Each is a thin binding over the shared list-page base.
          • -
          • Depends on: all three extend DataGridListPageBase<TDto> (ConferenceCategoryList.razor.cs:11, EventList.razor.cs:16, QuestionList.razor.cs:11); their UI services IConferenceCategoryUIService, IEventUIService, IQuestionUIService; MobileInfiniteScrollList<TItem> (ConferenceCategoryList.razor.cs:24), ListPageActions (:35,68), ConferenceRoutePaths, ErrorMessages (:74), and the shared DeleteConfirmation component (:25).
          • -
          • Concept introduced, the list page as a set of overrides. The base owns the machinery: IsLoading, LoadFailed, the abstract Title, the IsMobile switch, the filter save/restore contract, and LoadServerDataAsync (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:31,40,41,44,108,111,121,434). Each page supplies five things and nothing else.
              -
            • The captured grid reference, through the GridRef override (ConferenceCategoryList.razor.cs:19-20), which is how the base restores rows-per-page and the current page after a back-navigation.
            • -
            • SaveFilters / RestoreFilters for the one search term (:28-32). [Rubric §25, Navigation & Information Architecture]: a reader who opens a record and comes back finds the same view, not a reset grid.
            • -
            • LoadServerData, which hands the base a fetch delegate and a filter builder that turns the search string into a server-side contains filter (:43-52). [Rubric §12, Performance & Scalability] and [Rubric §23, Front-End Performance & Rendering]: search, sort and paging all execute where the data is, so the client never materializes a whole table.
            • -
            • FetchMobilePage, the parallel path for the infinite-scroll card list, hard-sorted by the display column ascending (:55-61). [Rubric §22, Responsive & Cross-Browser] (assesses a genuine mobile layout rather than a shrunk grid): the same service call backs both branches, selected by the base's IsMobile.
            • -
            • RetryLoadAsync (:23), which re-runs the fetch from the inline error state the base renders when LoadFailed is set. [Rubric §29, Resilience & Business Continuity]: a failed load offers a retry instead of a dead grid. - Deletion is also shared, not reimplemented: ListPageActions.DeleteWithConfirmationAsync takes the dialog, the label to show, the delete call, the snackbar, the success text, an error formatter, and the reload callback (ConferenceCategoryList.razor.cs:67-75). [Rubric §1, SOLID] and [Rubric §16, Maintainability] (assess whether repeated behavior has one implementation): confirm, delete, toast, reload lives in one helper for every list page in the app.
            • -
            -
          • -
          • Walkthrough (using ConferenceCategoryList as the reference): OnSearchChanged (:37-41) stores the term and calls ReloadActiveLayoutAsync (:34-35), which asks ListPageActions to reload whichever of the two layouts is live; LoadServerData (:43-52) and FetchMobilePage (:55-61) apply the same contains filter to the desktop and mobile paths; OnMobileCardClick (:63-64) and NavigateToCreate (:77) route through ConferenceRoutePaths.
          • -
          • Why they're built this way: three near-identical browse surfaces are exactly the case a base class is for. Because each page is only its overrides, a change to paging, scroll restoration or the mobile switch lands in one place and every list inherits it.
          • -
          • Where they're used: the /conferencecategories, /events, and /questions routes, each [Authorize(Roles = "Organizer")] (ConferenceCategoryList.razor:1-2, EventList.razor:1-2, QuestionList.razor:1-2). Rows and cards navigate to the matching detail page; the create buttons open the matching create page. PublicEventList is the anonymous counterpart of EventList over the same service.
          • -
          -
          -

          EventDetail

          -
          -

          MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Event · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Event/EventDetail.razor.cs:15 · Level 8 · class (Blazor code-behind)

          -
          -
            -
          • What it is: the organizer's event console. Beyond load, inline edit and delete it owns the two operations that make an event more than a record: the publish/unpublish lifecycle switch and the Sessionize import (EventDetail.razor.cs:11-14).
          • -
          • Depends on: IEventUIService (:19), EventDTO, RefreshFromSessionizeResultDTO (:68), QuestionModerationDefault (:60), DomainHelper's Id.Parse<T> (:91), ConferenceRoutePaths (:37,348), ErrorMessages (:95,108,200,344), and the shared DeleteConfirmation component (:72).
          • -
          • Concept introduced, the state transition as its own endpoint, carrying the concurrency token. Publishing is not modelled as an edit of a boolean. PublishAsync and UnpublishAsync call dedicated service operations that take the id and the row version, PublishAsync(Event.Id, Event.RowVersion, _cts.Token) (:218, contract at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/IEventUIService.cs:12-14), and the edit path deliberately preserves the current flag instead of exposing it as a field (IsPublished = Event.IsPublished, :183). [Rubric §6, CQRS & Event-Driven] (assesses whether intent is expressed as a named operation rather than a field write): "publish this event" and "correct the venue address" are different commands with different authorization and different consequences. [Rubric §9, API & Contract Design]: the transition endpoint takes exactly the two things it needs, and the row version makes it safe to replay (ADR-035, which the EventTransitionRequest body documents on the server side). - The second idea is the long-running import with a structured result. RefreshFromSessionizeAsync (:264-317) is the only action in the group that reports on what it did rather than just succeeding: it returns a RefreshFromSessionizeResultDTO with six per-entity counts, a count of soft-deleted records it skipped (BR-136), and a list of non-fatal warnings (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/RefreshFromSessionizeResultDTO.cs:9-31), all of which the markup renders after the call (EventDetail.razor:210-224). [Rubric §13, Observability & Operability] (assesses whether an operator can see what an operation actually did): an import that quietly succeeds is indistinguishable from one that skipped half its input, so the page shows the counts. [Rubric §29, Resilience & Business Continuity]: warnings are non-fatal by design, so a duration violation on one session does not abort the import.
          • -
          • Walkthrough
              -
            • OnParametersSetAsync (:74-84) is the load-once-on-parameters guard, delegating to LoadEventAsync (:86-114), which parses the id (:91), fetches with children (:92), snackbars a not-found (:95), and copies the stored Sessionize code into the editable _sessionizeCode field (:99).
            • -
            • Inline edit (:116-206): StartEditing seeds twelve shadow fields including the question-moderation default (:123-136), CancelEditing drops them (:139-143), and SaveChangesAsync validates the form, re-checks the date range (:159-163) exactly as EventCreate does, rebuilds the DTO with the round-tripped RowVersion (:171), updates, refetches, and resyncs _sessionizeCode from the refetched record (:189).
            • -
            • PublishAsync / UnpublishAsync (:208-262): identical shape, each calling its own endpoint and then refetching so the rendered state comes from the server rather than from an assumption. The markup swaps the two buttons on Event.IsPublished (EventDetail.razor:149-163).
            • -
            • RefreshFromSessionizeAsync (:264-317): guards on a blank code (:266-269), clears the previous result (:272), and, when the code in the box differs from the stored one, saves the event first with an ordinal comparison (:276-298) so the import runs against the code the organizer just typed. Then it imports (:300), refetches the event (:301), and reports.
            • -
            • DeleteEventAsync (:319-346) confirms through the shared dialog and returns to the list; disposal (:350-372) is the standard cancel-on-disposal pattern over the CancellationTokenSource at :25.
            • -
            -
          • -
          • Why it's built this way: the ADC schedule is authored in Sessionize and mirrored here, so the console has to make the import auditable and has to keep publication a deliberate, separately-authorized act rather than a checkbox in a form full of venue text.
          • -
          • Where it's used: the /events/{Id} route with [Authorize(Roles = "Organizer")] (EventDetail.razor:1-2), reached from EventList rows and EventCreate redirects. Publishing is what makes the event visible to PublicEventList and PublicEventDetail; the feedback link opens OrganizerEventFeedback.
          • -
          • Caveats / not-in-source: what the import creates, updates or skips is decided by the Conference service; the page only displays the counts it is handed.
          • +
          • LoadEventAsync (lines 87-115): parse the id, GetByIdAsync(eventId, true, ...) so children arrive + with the record (line 93), snackbar ErrorMessages.NotFound when it is missing (line 96), otherwise + seed _sessionizeCode from the loaded event (line 100). The finally always clears IsLoading.
          • +
          • SaveChangesAsync (lines 147-209): validate the form, re-check the date range (lines 161-165), + rebuild the DTO from the shadow fields including RowVersion (line 173) and the edited + QuestionModerationDefault (line 187), UpdateAsync, then refetch the record (lines 190-191) so + the page shows server truth rather than the values it just sent.
          • +
          • PublishAsync / UnpublishAsync (lines 211-265) are the same shape: call the named operation with + the current RowVersion, refetch, snackbar. Each has its own failure message.
          • +
          • RefreshFromSessionizeAsync (lines 267-321) first persists a changed Sessionize code before + importing (lines 279-302), since the code the organizer just typed is what the import must use, then + calls RefreshFromSessionizeAsync, refetches the event, and reports completion (lines 304-307).
          • +
          • DeleteEventAsync (lines 323-350): confirm through _deleteConfirm.ShowAsync(Event.Name) + (line 330), delete, then navigate back to the list.
          • +
          +
        12. +
        13. Why it's built this way: an event is the root aggregate of the whole conference, so publishing, + importing, and editing are separate operations with separate audit meaning rather than one PUT; keeping + each as its own service call is also what lets the server enforce its own rules per transition.
        14. +
        15. Where it's used: the /events/{Id} organizer route (.../Pages/Event/EventDetail.razor:1-2), + reached from EventList rows and from EventCreate's success redirect. + Its "view feedback" button opens OrganizerEventFeedback + (.../Pages/Event/EventDetail.razor:176).
        16. +
        17. Caveats / not-in-source: what the Sessionize refresh imports, and how it reconciles existing rows, + is server-side (the sync strategies in the Application layer); this page only shows the returned + summary.
      -

      QuestionDetail

      MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Question · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Question/QuestionDetail.razor.cs:11 · Level 8 · class (Blazor code-behind)

        -
      • What it is: the organizer's editor for one feedback question: its text, its sort order, and whether an answer is required.
      • -
      • Depends on: IQuestionUIService (:15), QuestionDTO, DomainHelper's Id.Parse<T> (:63), ConferenceRoutePaths (:32,180), ErrorMessages (:67,76,114,143,176), and the shared DeleteConfirmation component (:48).
      • -
      • Concept: the detail-page shape taught on ConferenceCategoryDetail (load-once-on-parameters, shadow fields, validate before save, refetch after save, confirm before delete), in its smallest form. The detail worth naming here is which fields the editor deliberately does not offer. Only three shadow fields exist (:43-45), and the save path copies QuestionEntity and QuestionType straight off the loaded record (:126-127). A question's entity kind ("Event" or "Session") is what the feedback pages filter on (OrganizerEventFeedback.razor.cs:61-64) and its type is what decides whether answers are averaged or listed (OrganizerEventFeedback.razor:55), so changing either after answers exist would silently reinterpret data already collected. Fixing them at creation and preserving them on update is the guard. [Rubric §4, DDD] and [Rubric §8, Data Architecture] (assess whether the model protects an invariant that spans records rather than trusting the editor). - The update also round-trips RowVersion (:124), the client half of ADR-035. Note the load here uses GetByIdAsync(id, cancellationToken: _cts.Token) (:64), leaving includeChildren at its default of false (IEntityService<TEntityDTO, TIdentifierType>.GetByIdAsync, MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IEntityService.cs:38-41), because a question has no child collection this page renders.
      • -
      • Walkthrough: OnInitialized (:26-35) builds the Home / Questions / Details breadcrumbs; OnParametersSetAsync (:52-82) guards on _loadedId, parses, fetches, and snackbars a not-found (:67); StartEditing / CancelEditing (:84-102) seed and drop the three shadow fields; SaveChangesAsync (:104-149) validates the MudForm (:111-116), rebuilds the DTO preserving entity, type and row version (:121-130), updates, refetches (:132), and clears the edit flags; DeleteQuestionAsync (:151-178) confirms with the question text as the label (:158) and navigates back on success; disposal (:182-204) is the standard pattern over the CancellationTokenSource at :21.
      • -
      • Why it's built this way: questions are configuration that answers point at, so the editor is intentionally narrow: presentation attributes are editable, the two fields that give existing answers their meaning are not.
      • -
      • Where it's used: the /questions/{Id} route with [Authorize(Roles = "Organizer")] (QuestionDetail.razor:1-2), reached from QuestionList rows and QuestionCreate redirects. The records it edits drive both feedback readers, OrganizerEventFeedback and OrganizerSessionFeedback.
      • +
      • What it is: the organizer's view-edit-delete page for a single feedback question. It is the + smallest detail page in the group: three editable fields and no lookups.
      • +
      • Depends on: IQuestionUIService (line 15), + QuestionDTO, + ConferenceRoutePaths, + ErrorMessages, the Parse<T> extension from + DomainHelper (line 4), and the + DeleteConfirmation plus UnsavedChangesGuard components.
      • +
      • Concept introduced: none new. Route-id parsing, load-once-on-parameters, shadow-field editing, and + the RowVersion round-trip are all introduced in EventDetail.
      • +
      • Walkthrough
          +
        • OnParametersSetAsync (lines 52-82) does the load inline rather than in a helper: guard on + _loadedId, parse to QuestionIdentifierType (line 63), GetByIdAsync (line 64), snackbar + NotFound when absent (line 67).
        • +
        • StartEditing (lines 84-96) copies only QuestionText, Sort, and IsRequired into shadow fields; + QuestionEntity and QuestionType are not editable.
        • +
        • SaveChangesAsync (lines 104-149) rebuilds the DTO with RowVersion (line 123) and re-sends the + unchanged QuestionEntity and QuestionType (lines 126-127), updates, then refetches (line 132). + [Rubric §8, Data Architecture]: the immutable-after-create fields are round-tripped rather than + omitted, so the update contract stays a full replacement.
        • +
        • DeleteQuestionAsync (lines 151-178) confirms with the question text as the label (line 158) and + navigates back to the list on success.
        • +
        +
      • +
      • Why it's built this way: a question's entity and type determine how every existing answer was + captured, so changing them after answers exist would invalidate stored data; restricting the edit + surface to text, order, and required-ness is the simplest way to keep answers interpretable.
      • +
      • Where it's used: the /questions/{Id} organizer route + (.../Pages/Question/QuestionDetail.razor:1), reached from QuestionList and from + QuestionCreate's success redirect.
      -

      RoomDetail

      MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Room · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Room/RoomDetail.razor.cs:12 · Level 8 · class (Blazor code-behind)

        -
      • What it is: the organizer's editor for one room: name, sort order, capacity, floor, location, and the free-text accessibility note that the public session page surfaces as wayfinding.
      • -
      • Depends on: IRoomUIService and IEventLookupService (:16-17), RoomDTO, EventInfo (:54), DomainHelper's Id.Parse<T> (:69), ConferenceRoutePaths (:34,196), ErrorMessages (:73,85,129,159,192), and the shared DeleteConfirmation component (:53).
      • -
      • Concept: the same detail shape as QuestionDetail, with three points of its own.
          -
        1. The parent event is displayed, never edited. The page hydrates the event lookup once with ??= after a successful load (:77) purely so GetEventName can turn the foreign key into a name, falling back to the invariant-culture id when the lookup has no entry (:93-94, rendered at RoomDetail.razor:68). The save path copies EventId = Room.EventId (:140): a room cannot be moved between events from here. [Rubric §4, DDD] (assesses that a child stays inside its aggregate boundary).
        2. -
        3. No concurrency token. Unlike every other DTO edited in this group, RoomDTO carries no RowVersion property at all (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/RoomDTO.cs:8-33), so the update at :147 has nothing to round-trip and two organizers editing the same room concurrently resolve last-write-wins. [Rubric §8, Data Architecture]: this is the one place in the group where the ADR-035 round-trip is absent from the client contract.
        4. -
        5. Accessibility text is first-class data. AccessibilityInfo is an editable field (:50, RoomDetail.razor:37) rendered on the detail view only when non-blank (RoomDetail.razor:74-77). [Rubric §21, Accessibility] (assesses accommodations as content, not just markup): the note an organizer writes here is what an attendee reads on the public session page.
        6. -
        -
      • -
      • Walkthrough: OnInitialized (:28-37) builds the breadcrumbs; OnParametersSetAsync (:58-91) guards on _loadedId, parses to RoomIdentifierType (:69), fetches, returns early on a not-found after the snackbar (:71-75), then hydrates the event lookup (:77); StartEditing (:96-111) seeds six shadow fields; SaveChangesAsync (:119-165) validates, rebuilds the DTO with the preserved EventId (:140), updates and refetches; DeleteRoomAsync (:167-194) confirms and navigates back; disposal (:198-220) is the standard pattern over the CancellationTokenSource at :23.
      • -
      • Why it's built this way: rooms are venue facts owned by their event, so the editor keeps the parent fixed and spends its surface on the operational details (capacity, floor, wayfinding) that matter on conference day.
      • -
      • Where it's used: the /rooms/{Id} route with [Authorize(Roles = "Organizer")] (RoomDetail.razor:1-2), reached from RoomList rows and RoomCreate redirects. The rooms it edits are resolved for display by PublicSessionDetail.
      • -
      • Caveats / not-in-source: the delete here calls the base one-argument DeleteAsync(Room.Id, _cts.Token) (:182), not the ADC-specific overload that also sends the event id (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/IRoomUIService.cs:12, implemented as a ?eventId= query argument at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/RoomService.cs:35-43), while the API binds eventId as a non-nullable [FromQuery] parameter (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/RoomsController.cs:198-206). RoomList passes it (RoomList.razor.cs:165). What the service does with an unsupplied event id is decided in the Conference API and is not determinable from this layer.
      • +
      • What it is: the organizer's view-edit-delete page for one room, showing its parent event by name and + editing name, sort, capacity, floor, location, and accessibility information.
      • +
      • Depends on: IRoomUIService (line 16) and + IEventLookupService (line 17) returning EventInfo; + RoomDTO; ConferenceRoutePaths; + ErrorMessages; the Parse<T> extension (line 5); + and the DeleteConfirmation plus UnsavedChangesGuard components.
      • +
      • Concept introduced, lookup-with-id-fallback. GetEventName (lines 93-94) resolves the parent event + through the lookup dictionary and falls back to the invariant-culture id when the lookup misses, so a + dangling or not-yet-loaded reference degrades to a visible id rather than a blank cell. Every name + resolver in this unit follows the same rule (compare SessionDetail lines 139-154). + The lookup itself is hydrated lazily with ??= (line 77), so revisiting the page does not refetch it. + [Rubric §19, State Management & Data Flow]. + [Rubric §8, Data Architecture]: unlike the other detail pages, the update here carries no + RowVersion, because RoomDTO does not define one + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/RoomDTO.cs:8-32); rooms are + edited without an optimistic-concurrency token.
      • +
      • Walkthrough
          +
        • OnParametersSetAsync (lines 58-91): the _loadedId guard, parse to RoomIdentifierType (line 69), + GetByIdAsync with a NotFound snackbar and early return (lines 70-75), then the lazy event lookup + (line 77).
        • +
        • StartEditing / CancelEditing (lines 96-117) are the standard shadow-field pair.
        • +
        • SaveChangesAsync (lines 119-165) rebuilds the RoomDTO + with the unchanged EventId (line 140), so a room cannot be moved between events from this page, + calls UpdateAsync, and refetches (lines 147-148).
        • +
        • DeleteRoomAsync (lines 167-194) confirms on the room name (line 174) and calls + RoomService.DeleteAsync(Room.Id, _cts.Token) (line 182).
        • +
        +
      • +
      • Why it's built this way: rooms belong to exactly one event for their whole life (their identity is + shared with the Sessionize import), so the detail page treats the parent as read-only and offers only + the descriptive fields for editing.
      • +
      • Where it's used: the /rooms/{Id} organizer route (.../Pages/Room/RoomDetail.razor:1), reached + from RoomList rows and from RoomCreate's success redirect.
      • +
      • Caveats / not-in-source: the delete call binds the inherited single-id overload from + EntityServiceBase, which issues DELETE rooms/{id} with no query string + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/EntityServiceBase.cs:152-162), whereas + RoomList calls the room-specific overload that appends ?eventId={eventId} + (.../Services/RoomService.cs:35-43) for the [FromQuery] EventIdentifierType eventId parameter the + controller binds + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/RoomsController.cs:311-319). + What the API does with the absent parameter is a server-side outcome not determinable from this file.
      -

      RoomList

      MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Room · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Room/RoomList.razor.cs:12 · Level 9 · class (Blazor code-behind)

        -
      • What it is: the organizer's room browse page. It is the list-page shape taught on ConferenceCategoryList, EventList, QuestionList plus a persisted event filter that defaults to the conference actually happening.
      • -
      • Depends on: extends DataGridListPageBase<TDto> (:12); IRoomUIService and IEventLookupService (:17-18), RoomDTO, EventInfo (:32), CurrentEventSelector (:96), MobileInfiniteScrollList<TItem> (:26), ListPageActions (:109,162), ConferenceRoutePaths (:158,171), and ErrorMessages (:168).
      • -
      • Concept introduced, the defaulted filter and the startup race it has to survive. Three mechanisms layer on top of the base, and they are the same three PublicSpeakerList uses; reading them here on the simpler page is the easier introduction.
          -
        1. An "all" sentinel in the persisted filter (:34-61). SaveFilters writes the selected event id, or the literal "all" when the organizer explicitly cleared it (:40); RestoreFilters maps "all" back to a null selection with _eventFilterResolved = true (:50-54). The sentinel distinguishes "show every event" from "no saved state", and only the second triggers the computed default. The in-code comment states exactly that (:39).
        2. -
        3. A computed default (ResolveDefaultEventFilter, :85-103). A restored id that still exists wins; a dangling one falls through to CurrentEventSelector.SelectCurrentOrNext, which picks the in-progress or next event from the lookup's start/end dates and time zones against DateTime.UtcNow (:94-101). [Rubric §25, Navigation & Information Architecture]: an organizer lands on the conference they are working on rather than an empty or historical grid.
        4. -
        5. A startup race guard. OnInitializedAsync assigns _eventsLoadTask before awaiting it (:63-69) and both LoadServerData (:124-136) and FetchMobilePage (:147-155) await that same task before applying filters (:128-129, :149-150). The comments name the hazard (:65-66, :126-127): the MudDataGrid's first ServerData call can run ahead of OnInitializedAsync completing, and ApplyFilters runs inside LoadServerDataAsync, so without the guard the first fetch would apply an unresolved filter. [Rubric §19, State Management & Data Flow] (assesses ordering guarantees between initialization and the first render pass). - A fourth detail is the graceful lookup failure: a failed GetAllAsync is swallowed with a comment marking it non-critical (:71-83), leaving _events null so GetEventName falls back to the invariant-culture id (:105-106) and ResolveDefaultEventFilter leaves the filter unset instead of throwing. [Rubric §29, Resilience & Business Continuity]: losing the name lookup degrades the labels, not the page.
        6. -
        -
      • -
      • Walkthrough
          -
        • ApplyFilters (:138-144) is shared by both layouts and emits at most two server filters: Name contains for the search box and EventId equals for the selected event, formatted with CultureInfo.InvariantCulture (:143) so a localized thread culture cannot corrupt the wire value. [Rubric §27, Internationalization].
        • -
        • OnSearchChanged (:111-115) and OnEventFilterChanged (:117-122) both set state, mark the filter resolved, and reload whichever layout is active through ReloadActiveLayoutAsync (:108-109).
        • -
        • DeleteRoomAsync (:161-169) is the shared confirm-delete-toast-reload helper, and it is the one call site that supplies both arguments the rooms delete endpoint expects, room.Id and room.EventId (:165).
        • -
        • OnMobileCardClick (:157-158) and NavigateToCreate (:171) route through ConferenceRoutePaths.
        • -
        -
      • -
      • Why it's built this way: rooms only mean anything inside an event, so an unfiltered room list would be noise; defaulting to the current or next conference makes the common case zero-click while leaving the picker for the archive.
      • -
      • Where it's used: the /rooms route with [Authorize(Roles = "Organizer")] (RoomList.razor:1-2); rows and cards navigate to RoomDetail, and the create button opens RoomCreate.
      • -
      -

      SessionCreate

      -
      -

      MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Session · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Session/SessionCreate.razor.cs:15 · Level 5 · class (Blazor code-behind)

      -
      -
        -
      • What it is: the organizer form that creates a session. It collects a title, description, owning - event, optional room, start/end date-and-time, and the "service session" flag, posts the new record, - and redirects to that session's detail page. It is the create leg of the Conference CRUD triad and the - clearest place to see the two mechanics that make session editing awkward: a dependent lookup (rooms - belong to the chosen event) and split date/time pickers.
      • -
      • Depends on: ISessionUIService (the create client, injected at - .../Pages/Session/SessionCreate.razor.cs:17), IEventLookupService returning - EventInfo (line 18), IRoomUIService for the room dropdown (line 19), - SessionDTO, - RoomDTO, ConferenceRoutePaths, - and ErrorMessages. It uses the Event, Room, and - Session identifier aliases. Externals: Blazor ([Inject], NavigationManager), MudBlazor (MudForm, - ISnackbar, BreadcrumbItem), System.Security.Cryptography.RandomNumberGenerator, and the - IStringLocalizer<SessionCreate> injected by the template - (.../Pages/Session/SessionCreate.razor:6).
      • -
      • Concept introduced, the partial-class code-behind create form. Every Conference page is a .razor - template plus a .razor.cs partial holding the injected services, backing fields, and handlers. A - create form layers three recurring mechanisms on that split:
          -
        1. Cancel-on-disposal: a CancellationTokenSource _cts (line 23) passed to every call and cancelled - plus disposed in Dispose (lines 177-197), so an in-flight save cannot resolve against a torn-down - component.
        2. -
        3. Validate-then-submit: await _form.ValidateAsync() followed by an IsValid guard (lines - 127-132), with the IsSaving flag (line 28) disabling the button for the round trip.
        4. -
        5. An unsaved-changes guard: _isDirty set by MarkDirty() (lines 43-45) and consumed by the - shared UnsavedChangesGuard component in the template - (.../Pages/Session/SessionCreate.razor:10), cleared before the success redirect (line 155) so the - guard does not block it. - [Rubric §24, Forms, Validation & UX Safety] (assesses client validation, unsaved-change protection, - and safe submits): this page validates before posting, tracks dirty state, and guards navigation. - [Rubric §18, UI Architecture & Component Design] (assesses logic separated from markup): the - code-behind keeps the template declarative. [Rubric §11, Security]: the route is organizer-only - (@attribute [Authorize(Roles = "Organizer")], .../Pages/Session/SessionCreate.razor:2). - [Rubric §27, Internationalization] (assesses externalized user-facing text): every label, breadcrumb, - and snackbar reads through the injected IStringLocalizer (L["Snackbar.Created"], line 156). - A second idea this page shows is the client-minted identifier: because SessionIdentifierType is - int, the form fabricates a temporary id with - RandomNumberGenerator.GetInt32(100_000, int.MaxValue) (line 144) to satisfy the required - SessionDTO.Id, then reads created.Id back from the server response (line 157) so it tolerates the - server honoring or overwriting that value. Contrast - SponsorCreate, which posts Id = default instead.
        6. +
        7. What it is: the organizer browse page for rooms. It adds an event filter to the + EventList shape, defaults that filter to the current or next conference, and enriches + each row with the event's name.
        8. +
        9. Depends on: extends + DataGridListPageBase<TDto> over + RoomDTO (line 12) and injects + IRoomUIService and IEventLookupService (lines 17-18). It + uses CurrentEventSelector, + EventInfo, ListPageActions, + ErrorMessages, + ConferenceRoutePaths, and the shared list components.
        10. +
        11. Concept introduced, the defaulted filter and its startup race. An organizer almost always wants the + rooms of the conference that is running or coming up next, so the page computes that default instead of + showing every room ever created. Three parts make it work:
            +
          1. The default itself: ResolveDefaultEventFilter (lines 85-103) calls + CurrentEventSelector.SelectCurrentOrNext with accessor lambdas for start date, end date, and time + zone (lines 96-101), the same live-window math the backend uses. EventList's sibling + SessionList can use the + CurrentEventDefaults wrapper instead because + it holds EventDTOs; this page holds + EventInfo records, so it calls the generic selector directly.
          2. +
          3. A restored id wins, but only if it still exists: the guard at lines 88-92 keeps a restored + selection and falls back to the computed default when the saved id no longer resolves, so a stale + bookmark does not produce a silently empty grid. The "all" sentinel written by SaveFilters + (line 40) is what distinguishes "the user explicitly cleared the filter" from "there is no saved + state", which would otherwise both look like null. [Rubric §25, Navigation & Information Architecture].
          4. +
          5. The race guard: _eventsLoadTask is started in OnInitializedAsync (line 67) and awaited again + inside both LoadServerData (lines 128-129) and FetchMobilePage (lines 149-150), because the grid's + first ServerData call can run before initialization finishes and ApplyFilters executes inside + LoadServerDataAsync. Without the second await the first page would be fetched unfiltered. + [Rubric §19, State Management & Data Flow]. + The event lookup failure is non-fatal by design: the catch comment says name enrichment falls back to + ID display (lines 77-80), matching GetEventName's own fallback (lines 105-106).
        12. Walkthrough
            -
          • OnInitializedAsync (lines 47-74) builds the breadcrumb trail (lines 49-54), loads the event lookup - (line 58), auto-selects the only event when the lookup has exactly one entry (lines 59-62, the - single-conference convenience), then calls LoadRoomsAsync. OperationCanceledException is swallowed - as expected during disposal or an InteractiveAuto render-mode transition; any other failure snackbars - a lookup error.
          • -
          • LoadRoomsAsync (lines 81-96) is the dependent lookup: with no event chosen it clears _rooms - and returns (lines 83-87); otherwise it fetches up to 500 rooms filtered by - EventId equals <selected> (lines 89-94). The doc comment (lines 76-80) records why the filter is not - cosmetic: BR-130 rejects a room from another event server-side, so the dropdown must only ever offer - rooms of the chosen event.
          • -
          • OnEventChangedAsync (lines 99-118) marks the form dirty, clears the previously picked room - (line 104, with the in-code note that keeping it would have the server reject the save), and reloads - the room list.
          • -
          • CreateSessionAsync (lines 120-171) validates, then recombines the two picker pairs into - StartsAt/EndsAt only when both the date and the time part are set (lines 137-140), builds the - SessionDTO (lines 142-152), posts it with AddAsync - (line 154), clears _isDirty, snackbars success, and navigates to - ConferenceRoutePaths.SessionDetails(created.Id) (line 157). The finally always clears IsSaving.
          • +
          • SaveFilters / RestoreFilters (lines 34-61) persist the search string plus the event id, parsing + the sentinel and the integer id back out (lines 48-60).
          • +
          • ApplyFilters (lines 138-144) is shared by both fetch paths and emits Name contains <search> and + EventId equals <selected>.
          • +
          • OnSearchChanged and OnEventFilterChanged (lines 111-122) each update one filter, mark the filter + resolved, and reload the active layout through + ListPageActions.ReloadActiveLayoutAsync + (lines 108-109).
          • +
          • DeleteRoomAsync (lines 161-169) passes both ids to the room-specific delete overload (line 165), + the event-scoped call the API expects.
          • +
          • NavigateToCreate (line 171) opens RoomCreate.
        13. -
        14. Why it's built this way: one create-form shape (validate, post, redirect to detail) is reused across - the Conference entities so behavior stays uniform; the split date/time editing exists because MudBlazor - has no single date-time picker, so the page composes two controls and recombines them defensively; the - event-scoped room reload keeps the client from ever offering a value the server will reject.
        15. -
        16. Where it's used: the /sessions/create route, reached from SessionList's create - button; on success it hands off to SessionDetail.
        17. -
        18. Caveats / not-in-source: whether the API honors or replaces the client-minted id is a server-side - decision not visible here; the page reads created.Id from the response either way.
        19. +
        20. Why it's built this way: rooms accumulate across every conference the instance has ever hosted, so + an unfiltered list is close to useless on day one of an event; computing the default from the same + live-window rule the backend uses keeps the UI and the server agreeing on which conference is "now".
        21. +
        22. Where it's used: the /rooms organizer route (.../Pages/Room/RoomList.razor:1-2); rows open + RoomDetail and the create button opens RoomCreate.

      SessionDetail

      @@ -2799,29 +3291,24 @@

      SessionDetail

      ConferenceRoutePaths, ErrorMessages, the DeleteConfirmation component from MMCA.Common.UI, and DomainHelper's - Id.Parse<T> extension (MMCA.Common.Shared.Extensions, line 6). Uses the + Parse<T> extension (MMCA.Common.Shared.Extensions, line 6). Uses the Event/Room/Speaker/Session/SessionSpeaker/SessionCategoryItem/CategoryItem aliases.
    • -
    • Concept introduced, route-id parsing, load-once-on-parameters, shadow-field editing, and an - event-keyed lookup cache. Four mechanisms combine here:
        -
      1. Route id as a string: the id arrives as [Parameter] public string Id (line 31) and is converted - to the typed alias with Id.Parse<SessionIdentifierType>() (line 101), so the page compiles - unchanged whether the alias is int or Guid.
      2. -
      3. Load once per id: OnParametersSetAsync compares against _loadedId and returns early when the - id is unchanged (lines 85-94), so a re-render does not refetch.
      4. -
      5. Shadow fields: StartEditing copies the loaded record into _edit* fields (lines 156-176) and - CancelEditing simply drops edit mode (lines 178-182), so the live Session is never mutated until - a validated save succeeds. [Rubric §24, Forms, Validation & UX Safety].
      6. +
      7. Concept introduced, the event-keyed lookup cache and the join-collection editor. The route-id + parsing, load-once-on-parameters, and shadow-field mechanics are EventDetail's; two + things are new:
        1. An event-keyed room cache: the global lookups are hydrated once with ??= (lines 109-111), but rooms are per-event, so they are cached against _roomsForEventId and refetched when the session's event differs (lines 113-123). The in-code comment (lines 77-79) records the bug this prevents: - without the key, navigating to a session in another event renders the previous event's room names and - offers its rooms in the edit picker. [Rubric §19, State Management & Data Flow] (assesses where - view state lives and when it is invalidated). - [Rubric §8, Data Architecture] (assesses a deliberate concurrency strategy): the update DTO re-sends - the loaded RowVersion (line 209), the client half of the optimistic-concurrency token, so the server - can reject a stale concurrent edit. [Rubric §18, UI Architecture & Component Design]: the page's size - comes from breadth (two join collections plus four lookups), not from bespoke mechanics, since the - add/remove/available-items triple is one pattern applied twice.
        2. + without the key, navigating to a session in another event renders the previous event's room names + and offers its rooms in the edit picker. [Rubric §19, State Management & Data Flow] (assesses where + view state lives and when it is invalidated). +
        3. Join management as an add/remove/available triple: the same three-method pattern is applied + twice, once for speakers and once for category items, and each mutation is followed by a full + LoadAsync rather than a local patch. + [Rubric §8, Data Architecture]: the update DTO re-sends the loaded RowVersion (line 209), the client + half of the optimistic-concurrency token (ADR-035), so the server can reject a stale concurrent edit. + [Rubric §18, UI Architecture & Component Design]: the page's size comes from breadth (two join + collections plus four lookups), not from bespoke mechanics.
      8. Walkthrough
          @@ -2831,9 +3318,9 @@

          SessionDetail

          event's rooms into _roomNames and _editableRooms (lines 113-123). The finally always clears IsLoading.
        • Name resolution (lines 139-154): GetEventName, GetSpeakerName, GetCategoryItemDisplayName, and - GetRoomName each fall back to the invariant-culture id when the lookup misses, so a stale reference - degrades to an id rather than a blank cell; category items render as "{CategoryTitle}: {Name}" when - a title exists (lines 147, 150-151).
        • + GetRoomName each fall back to the id when the lookup misses, so a stale reference degrades to an id + rather than a blank cell; category items render as "{CategoryTitle}: {Name}" when a title exists + (lines 147, 150-151).
        • Edit and save (lines 156-240): StartEditing seeds the shadow fields including the split date/time pairs (lines 165-168); SaveChangesAsync validates, recombines date plus time only when both parts are set (lines 201-204), rebuilds the DTO with RowVersion (line 209) and the unchanged EventId @@ -2852,18 +3339,344 @@

          SessionDetail

          reusable detail-page scaffolding the other Conference detail pages use; reloading after each join mutation keeps the page a single source of truth instead of hand-patching local collections.
        • Where it's used: the /sessions/{Id} organizer route, reached from SessionList - rows and from SessionCreate's success redirect.
        • + rows and from SessionCreate's success redirect; its "view feedback" button opens + OrganizerSessionFeedback (.../Pages/Session/SessionDetail.razor:190).
        • Caveats / not-in-source: reads pass includeChildren: true so the join collections populate; how the GetAll path populates children is a server-side concern this page does not exercise.
        +

        SessionList

        +
        +

        MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Session · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Session/SessionList.razor.cs:18 · Level 10 · class (Blazor code-behind)

        +
        +
          +
        • What it is: the organizer browse page for sessions and the richest list in the Conference UI. It + carries three filters (free-text title search, session status, and event), enriches each row with room + and speaker names, and color-codes the Sessionize status. It sits at the top of the group's dependency + order because it transitively pulls in the most lookups and defaults.
        • +
        • Depends on: extends + DataGridListPageBase<TDto> (line 18) and + injects ISessionUIService, IEventUIService, and + ISpeakerLookupService (lines 23-25). It uses + SessionDTO, + EventDTO, SpeakerInfo, + CurrentEventDefaults (the EventDTO-typed + wrapper over CurrentEventSelector), + ConferenceRoutePaths, + ErrorMessages, + ListPageActions, and the + MobileInfiniteScrollList<TItem> plus + DeleteConfirmation components. Uses the Event/Room/Session/Speaker aliases.
        • +
        • Concept introduced, the multi-filter enriched list. SessionList layers three refinements on the + event-filtered shape RoomList, SponsorList, and + SpeakerList share:
            +
          1. A third filter: _searchString, _selectedStatus, and _selectedEventId persist together + (SaveFilters lines 44-53, RestoreFilters lines 55-73) and are emitted as Title contains, + Status equals, and EventId equals server filters (ApplyFilters, lines 208-216).
          2. +
          3. Enrichment from two bulk loads instead of per-row fetches: + LoadEventsAndResolveDefaultAsync fetches events with includeChildren: true (line 98) and folds + every event's rooms into one _roomNames dictionary (PopulateRoomNames, lines 124-138), while the + speaker lookup loaded in OnInitializedAsync (line 84) backs GetSpeakerList (lines 140-148), which + maps a row's SessionSpeakers to display names and skips ids the lookup does not know. Both loads + are wrapped in best-effort catches whose comments say the fallback is dash display, not a broken + page (lines 86-89, 102-105). The paged fetch itself also passes includeChildren: true (lines 193,
              +
            1. so each row arrives with its speaker joins.
            2. +
            +
          4. +
          5. Status color coding: GetStatusColor (lines 150-159) maps the Sessionize status strings + Accepted, Waitlisted, Accept_Queue, Nominated, Decline_Queue, and Declined to MudBlazor + colors, defaulting to Color.Default for anything else. + The startup race guard is the same one RoomList uses, with the clearest explanation in + this file: _eventsLoadTask is started before the first await (lines 77-80) and awaited inside both + LoadServerData (lines 187-188) and FetchMobilePage (lines 200-201), because ApplyFilters runs + inside LoadServerDataAsync, so the default event must be resolved before entering it, "not merely + before the fetch delegate runs" (in-code comment, lines 185-186). + [Rubric §18, UI Architecture & Component Design]: the status filter surfaces the program-committee + workflow inline instead of hiding it behind a separate screen. + [Rubric §23, Front-End Performance & Rendering]: one children-loaded events fetch plus one speaker + lookup replace what would otherwise be per-row enrichment calls. + [Rubric §25, Navigation & Information Architecture]: all three filters survive navigation through the + base class's persistence contract, with the same "all" sentinel and computed default.
          6. +
          +
        • +
        • Walkthrough
            +
          • OnInitializedAsync (lines 75-92): start the events task, load the speaker lookup (tolerating + failure), then await the events task.
          • +
          • ResolveDefaultEventFilter (lines 110-122): keep a restored id that still exists in _events, + otherwise take CurrentEventDefaults.SelectCurrentOrNext(_events, DateTime.UtcNow)?.Id (line 120).
          • +
          • OnSearchChanged, OnStatusChanged, and OnEventFilterChanged (lines 164-181) each update one + filter and reload whichever layout is active via + ListPageActions.ReloadActiveLayoutAsync + (lines 161-162).
          • +
          • LoadServerData (lines 183-195) and FetchMobilePage (lines 198-206) are the desktop and mobile + fetch paths over the same ApplyFilters.
          • +
          • DeleteSessionAsync (lines 221-229) delegates the confirm, delete, snackbar, and reload sequence to + ListPageActions.DeleteWithConfirmationAsync; NavigateToCreate and NavigateToDetails (lines + 231-232) route to SessionCreate and SessionDetail.
          • +
          +
        • +
        • Why it's built this way: sessions are the central editable entity of the program, so the list has to + answer "what is in this conference, in what state, presented by whom" at a glance; defaulting to the + active event and enriching from two bulk loads keeps that view both relevant and cheap.
        • +
        • Where it's used: the /sessions organizer route + (.../Pages/Session/SessionList.razor:1-2, Authorize(Roles = "Organizer")); rows open + SessionDetail and the create button opens SessionCreate.
        • +
        • Caveats / not-in-source: the page builds speaker names from its own lookup rather than trusting the + paged payload alone, so it degrades to a dash rather than a wrong name when a speaker id is unknown; + how the paged endpoint populates SessionSpeakers is a server-side concern outside this file.
        • +
        +

        ActivityCreate

        +
        +

        MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Activity · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Activity/ActivityCreate.razor.cs:16 · Level 9 · class (Blazor code-behind)

        +
        +
          +
        • What it is: the organizer form that schedules an activity, the non-session items on a conference + programme (registration, breaks, receptions, after-parties). It collects the name, the owning event, the + start and end of the window as separate date and time pickers, the display order used to break ties, + and an optional off-site venue. The event picker is required, because activities are scheduled per event + and the owning event cannot be changed afterwards (class doc, lines 10-14).
        • +
        • Depends on: IActivityUIService (line 18), + IEventLookupService returning EventInfo (line 19), + ActivityDTO, + CurrentEventSelector from + MMCA.ADC.Conference.Shared.Events, ConferenceRoutePaths, and + ErrorMessages. Externals: Blazor + ([Inject], NavigationManager), MudBlazor (MudForm, ISnackbar, BreadcrumbItem, + MudDatePicker / MudTimePicker), and the IStringLocalizer<ActivityCreate> injected by the template + (.../Pages/Activity/ActivityCreate.razor:5). The UnsavedChangesGuard component from MMCA.Common.UI + is wired in the markup (.../Pages/Activity/ActivityCreate.razor:9).
        • +
        • Concept introduced, the split date/time picker pair and its out-of-band validation. Every other + create form in this group binds one control per DTO property and lets MudForm validate it. An activity + carries two DateTime values that no single MudBlazor control captures well, so the page keeps four + backing fields (_startDate, _startTime, _endDate, _endTime, lines 75-78) and recombines them in + TryBuildSchedule (lines 116-144): a missing date or time yields Error.StartRequired or + Error.EndRequired, an end before the start yields Error.EndBeforeStart, and the message lands in + _scheduleError (lines 123, 129, 138) rather than in the form's own error list. The template renders + that string in its own MudAlert directly above the form's error summary + (.../Pages/Activity/ActivityCreate.razor:78-81, summary at :83-95), so a cross-field rule reads like + every other validation error to the user even though MudForm knows nothing about it. + [Rubric §24, Forms, Validation & UX Safety] assesses whether a form can express only legal input and + explains a rejection in place: the schedule rule runs before anything is posted (line 160), a failure + snackbars the specific message rather than a generic one (line 162), and _isDirty (lines 85, 88) drives + UnsavedChangesGuard so navigating away mid-edit prompts. + The page also uses the smart default the other event-scoped create forms share: rather than + auto-selecting an event only when exactly one exists, OnInitializedAsync seeds the picker with + CurrentEventSelector.SelectCurrentOrNext + evaluated over each event's start date, end date and IANA time zone against DateTime.UtcNow + (lines 49-56). The ??= means a value the organizer already picked is never overwritten. + One idea is genuinely new here: ApplyEventDateDefaults (lines 100-110) also seeds both date pickers + with the selected event's first day (info.StartDate.ToDateTime(TimeOnly.MinValue), line 107), so the + organizer normally only picks the times, and it re-runs on every event change through OnEventSelected + (lines 90-94). Again ??= (lines 108-109) protects dates the organizer already chose.
        • +
        • Walkthrough
            +
          • OnInitialized (lines 28-37) builds the three breadcrumbs synchronously (Home, Activities, Create), + the last one disabled: true so it renders as the current page.
          • +
          • OnInitializedAsync (lines 39-68) loads the event lookup through the cancellable _cts.Token + (line 45), resolves the default event (lines 49-56), then calls ApplyEventDateDefaults (line 58). + OperationCanceledException is swallowed as expected during disposal or an InteractiveAuto render + mode transition (in-code comment, lines 60-63; see + ADR-056); any other failure + is swallowed too, because the picker then renders empty and the required-field error guides the user + (comment, lines 65-67).
          • +
          • CreateActivityAsync (lines 146-200) awaits _form.ValidateAsync(), re-checks _eventId is null + (line 154), then runs TryBuildSchedule (line 160). Only then does it build the + ActivityDTO with Id = default (line 171), so the + server mints the key and the page reads it back from created.Id. It posts through AddAsync + (line 183), clears _isDirty before navigating (line 184), snackbars success, and routes to + ConferenceRoutePaths.ActivityDetails(created.Id) (line 186). IsSaving is cleared in the finally + (line 198), which is what re-enables the submit button after a failure.
          • +
          • NavigateToList (line 202) and the standard Dispose(bool) / Dispose pair (lines 206-226) close the + page out: the _cts is cancelled and disposed exactly once, guarded by _disposed.
          • +
          +
        • +
        • Why it's built this way: an activity belongs to exactly one event and its schedule is a window, not a + point, so the form's job is to make the window easy to enter and impossible to invert. Defaulting the + event and both dates from the selected conference removes the two most common clicks without hiding + either field, and keeping the cross-field check in the code-behind lets one message name the actual + problem (a missing part vs an inverted window) instead of a generic "invalid form".
        • +
        • Where it's used: the /activities/create organizer route + (.../Pages/Activity/ActivityCreate.razor:1-2, Authorize(Roles = "Organizer")), reached from + ActivityList's create button; on success it hands off to + ActivityDetail.
        • +
        • Caveats / not-in-source: TryBuildSchedule composes local DateTime values from the pickers and + does no time-zone conversion of its own, even though the event's TimeZone is available on + EventInfo. How the resulting StartTime and EndTime are interpreted downstream is + decided by the command handler, not by this page.
        • +
        +

        ActivityDetail

        +
        +

        MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Activity · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Activity/ActivityDetail.razor.cs:16 · Level 9 · class (Blazor code-behind)

        +
        +
          +
        • What it is: the organizer's activity record page: load one activity by route id, inline-edit every + field except the owning event, and delete with confirmation. The class doc (lines 11-15) states the + rule the page enforces: moving an activity between events is a create plus a delete, so the event is + displayed but never edited here.
        • +
        • Depends on: IActivityUIService (line 20), + IEventLookupService returning EventInfo (line 21), + ActivityDTO, + ConferenceRoutePaths, + ErrorMessages, and the DeleteConfirmation component + from MMCA.Common.UI (line 78). Externals: Blazor ([Parameter], NavigationManager), MudBlazor + (MudForm, ISnackbar), System.Globalization, and the IStringLocalizer<ActivityDetail> from the + template (.../Pages/Activity/ActivityDetail.razor:5).
        • +
        • Concept introduced, culture-formatted display on a shadow-field edit form. The page reuses the + detail-page shape SessionDetail teaches (load-once guard on _loadedId + lines 82-91, _edit* shadow fields lines 64-74, RowVersion round-trip line 210, confirm-then-delete + lines 242-269, cancel-on-disposal _cts at lines 27 and 275-295) and adds two wrinkles:
            +
          1. Two computed display properties. EventName (lines 33-36) resolves the activity's EventId + against the event lookup and falls back to the invariant-culture id when the lookup is unavailable, so + the read-only event line never renders blank. TimeRange (lines 39-45) formats the window as a single + localized string by passing the start ("f", full date and time) and the end ("t", short time) + rendered in CultureInfo.CurrentCulture into the Text.TimeRange resource. The joining sentence + lives in the .resx, not in the code, so a locale can reorder or reword the range. + [Rubric §27, Internationalization] assesses whether user-visible text and formats follow the request + culture rather than the server's: here both the values and the sentence that joins them do (see + ADR-027).
          2. +
          3. The immutable field on an editable record. StartEditing (lines 121-141) seeds shadow fields for + the name, sort order, the four date and time parts, the description and the three venue fields, but + not the event; the update DTO re-sends EventId = Activity.EventId unchanged (line 219). Making + the field un-editable in the form is what enforces the "a move is a create plus a delete" rule + client-side. [Rubric §24, Forms, Validation & UX Safety]. + The schedule rule from ActivityCreate reappears here as a second TryBuildSchedule + over the _edit* fields (lines 154-182), with the same three localized error keys, so the create and + edit paths validate a window identically. They are two copies of the same method rather than one shared + helper, which is the maintenance cost of keeping each page self-contained + ([Rubric §16, Maintainability]). + [Rubric §8, Data Architecture]: the update carries the loaded RowVersion (line 210), so a concurrent + edit is detected server-side rather than silently overwritten (see + ADR-035).
          4. +
          +
        • +
        • Walkthrough
            +
          • OnParametersSetAsync (lines 82-91) returns early when Id == _loadedId, otherwise records the id and + calls LoadAsync; that guard is what stops a re-render from re-fetching.
          • +
          • LoadAsync (lines 93-119) fetches with GetByIdAsync(Id, true, _cts.Token) (line 98), snackbars + ErrorMessages.NotFound and bails when the record is missing (lines 99-103), then hydrates the event + lookup lazily with ??= (line 105). Cancellation is swallowed; any other failure snackbars + ErrorMessages.LoadError; the finally clears IsLoading, which the template uses to switch between + PageLoadingState, PageErrorState and the record (.../Pages/Activity/ActivityDetail.razor:14-21).
          • +
          • StartEditing / CancelEditing (lines 121-148) enter and leave edit mode, clearing _scheduleError + and _isDirty on both paths, so neither a stale cross-field error nor the unsaved-changes guard can + fire after a cancel.
          • +
          • SaveChangesAsync (lines 184-240) validates the MudForm, runs TryBuildSchedule, rebuilds the + ActivityDTO from the shadow fields plus the preserved + Id, RowVersion and EventId (lines 207-220), calls UpdateAsync, then re-fetches the record + (line 223) so the page shows the server's version including the new RowVersion, and finally clears + _isDirty and exits edit mode.
          • +
          • DeleteActivityAsync (lines 242-269) confirms through _deleteConfirm.ShowAsync(Activity.Name) + (line 249), returns unless the answer is exactly true (line 250, so a dismissed dialog is not a + delete), deletes, snackbars, and navigates back to the list.
          • +
          +
        • +
        • Why it's built this way: an activity is a per-event programme item, so its name, window, order and + venue are freely editable while its owning event is not. Keeping that constraint in the form (no shadow + field, DTO re-sends the loaded value) means the page cannot even express the illegal update, and the + re-fetch after save keeps the concurrency token current for the next edit.
        • +
        • Where it's used: the /activities/{Id:int} organizer route + (.../Pages/Activity/ActivityDetail.razor:1-2), reached from ActivityList rows and + from ActivityCreate's success redirect. It edits the same aggregate the + Activity entity models.
        • +
        +

        ActivityList

        +
        +

        MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Activity · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Activity/ActivityList.razor.cs:19 · Level 9 · class (Blazor code-behind)

        +
        +
          +
        • What it is: the organizer browse page for activities: a server-paged MudDataGrid with a name search, + start-time, venue and display-order columns, an event filter, a mobile card layout, and + delete-with-confirmation.
        • +
        • Depends on: extends + DataGridListPageBase<TDto> closed over + ActivityDTO (line 19), and injects + IActivityUIService and IEventLookupService + (lines 24-25). It uses EventInfo, + CurrentEventSelector, + ConferenceRoutePaths, + ErrorMessages, + ListPageActions (the shared reload and + delete-with-confirmation helpers, lines 116 and 170), and the + MobileInfiniteScrollList<TItem> plus + DeleteConfirmation components from MMCA.Common.UI.
        • +
        • Concept introduced, a chronological mobile list over the same filter set. ActivityList follows the + event-filtered list shape that RoomList and SponsorList establish:
            +
          1. Persisted filters with an "all" sentinel (SaveFilters lines 44-52, RestoreFilters + lines 54-71): the sentinel distinguishes an explicit "show every event" from no saved state, which + is what lets the computed default apply on a first visit only.
          2. +
          3. A computed default: ResolveDefaultEventFilter (lines 95-113) keeps a restored id that still + exists in the lookup and otherwise falls back to + CurrentEventSelector.SelectCurrentOrNext + (lines 104-111), so a dangling saved id shows the current conference rather than an empty grid.
          4. +
          5. The startup race guard: OnInitializedAsync assigns _eventsLoadTask before its first await + (lines 73-79), and both LoadServerData (lines 135-136) and FetchMobilePage (lines 155-156) await + that task before applying filters, because the grid's first ServerData call can race ahead of + initialization and ApplyFilters runs inside the base class's LoadServerDataAsync (in-code + comment, lines 133-134). + What is specific to activities is the mobile sort: the desktop grid opens on name ascending + (.../Pages/Activity/ActivityList.razor:72) and lets the user re-sort any sortable column, but + FetchMobilePage pins "StartTime", "asc" (line 163) because, as the in-code comment says, the + programme reads chronologically (line 162). The two layouts therefore share filters and a data contract + but not ordering. + [Rubric §19, State Management & Data Flow] (assesses where view state lives and how it is restored): + filter state is saved, restored, defaulted and reconciled against the live event set in one method, while + the grid's page and page size live in the base class's CurrentPageState and RowsPerPageState + (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:57, + MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Common/DataGridListPageBase.cs:67). + [Rubric §23, Front-End Performance & Rendering] (assesses work pushed off the client): paging, + searching, sorting and filtering all happen server-side, the event lookup is fetched once and reused, and + the search box debounces at 300 ms (.../Pages/Activity/ActivityList.razor:23). + [Rubric §22, Responsive & Cross-Browser]: one DTO feeds both the desktop grid and the mobile + infinite-scroll list, switched on the base class's IsMobile + (.../Pages/Activity/ActivityList.razor:39-57), and the venue and sort columns carry + hide-below-desktop classes so the grid sheds columns before it scrolls + (.../Pages/Activity/ActivityList.razor:47-48, .../Pages/Activity/ActivityList.razor:60-61). + [Rubric §21, Accessibility]: the cancel-load button and both delete buttons carry localized + aria-labels (.../Pages/Activity/ActivityList.razor:13,54,104).
          6. +
          +
        • +
        • Walkthrough
            +
          • LoadEventsAndResolveDefaultAsync (lines 81-93) loads the lookup, swallows a failure as non-critical + (the picker stays hidden and the default filter stays unset, comment lines 88-90), then resolves the + default. The picker itself only renders when more than one event exists + (.../Pages/Activity/ActivityList.razor:25).
          • +
          • LoadServerData (lines 131-143) awaits the events task and delegates to the base's + LoadServerDataAsync, handing it the paged fetch delegate and ApplyFilters. + ApplyFilters (lines 145-151) emits Name contains and EventId equals server filters, adding each + only when set; EventId is a real Activity column + (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Activities/ActivityDTO.cs:43), so the + filter goes straight through the generic filter pipeline with no join-based resolution on the server + (class doc, lines 13-18).
          • +
          • FetchMobilePage (lines 154-164) is the parallel mobile path: same filters, fixed StartTime asc sort; + OnMobileCardClick (line 166) routes a card tap to the detail page.
          • +
          • OnSearchChanged and OnEventFilterChanged (lines 118-129) update state then call + ReloadActiveLayoutAsync, which routes to the grid or the infinite list through + ListPageActions (lines 115-116).
          • +
          • DeleteActivityAsync (lines 169-177) is the whole delete flow expressed as one call to + ListPageActions.DeleteWithConfirmationAsync, passing the dialog, the display name, the delete + delegate, the snackbar, the success message, an error formatter and the reload callback.
          • +
          • RetryLoadAsync (line 32) re-runs the grid fetch from the base class's inline error state + (LoadFailed, surfaced by ListNoRecordsContent at + .../Pages/Activity/ActivityList.razor:109), so a transient failure does not require a page reload.
          • +
          • FormatStartTime (line 42) renders the start as short date plus short time in + CultureInfo.CurrentCulture, and is shared by the grid cell and the mobile card.
          • +
          +
        • +
        • Why it's built this way: activities are browsed per conference, so the list defaults to the current or + next event exactly like the room and sponsor lists. Because EventId is a first-class column, the page + needs none of the virtual-filter machinery the speaker list requires, and the only real decision left is + which order each layout opens in: alphabetical where the user can re-sort, chronological where a thumb + scrolls a programme.
        • +
        • Where it's used: the /activities organizer route + (.../Pages/Activity/ActivityList.razor:1-2, Authorize(Roles = "Organizer")); the name cell links to + ActivityDetail and the create button to ActivityCreate.
        • +

        SponsorCreate

        MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Sponsor · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorCreate.razor.cs:15 · Level 9 · class (Blazor code-behind)

          -
        • What it is: the organizer form that creates a sponsorship record: name, tier, owning event, - branding links (logo, website, LinkedIn, X handle), sort order, and the optional expo-booth details. The - event picker is required, because sponsorships are sold per event and the owning event cannot be changed +
        • What it is: the organizer form that creates a sponsorship record: name, tier, owning event, branding + links (logo, website, LinkedIn, X handle), sort order, and the optional expo-booth details. The event + picker is required, because sponsorships are sold per event and the owning event cannot be changed afterwards (class doc, lines 10-14).
        • Depends on: ISponsorUIService (line 20), IEventLookupService returning EventInfo (line 21), @@ -2951,7 +3764,8 @@

          SponsorDetail

          lookup and falls back to the invariant-culture id. [Rubric §24, Forms, Validation & UX Safety]: making the field un-editable in the form is what enforces the "a move is a create plus a delete" rule client-side. [Rubric §8, Data Architecture]: the update also carries the loaded RowVersion - (line 163), so a concurrent edit is detectable server-side.
        • + (line 163), so a concurrent edit is detectable server-side (see + ADR-035).
    • Walkthrough
        @@ -3055,86 +3869,6 @@

        SponsorList

        (.../Pages/Sponsor/SponsorList.razor:1-2, Authorize(Roles = "Organizer")); rows navigate to SponsorDetail and the create button to SponsorCreate.
      -

      SessionList

      -
      -

      MMCA.ADC.Conference.UI · MMCA.ADC.Conference.UI.Pages.Session · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Session/SessionList.razor.cs:18 · Level 10 · class (Blazor code-behind)

      -
      -
        -
      • What it is: the organizer browse page for sessions and the richest list in the Conference UI. It - carries three filters (free-text title search, session status, and event), enriches each row with room - and speaker names, and color-codes the Sessionize status. It sits at the top of the group's dependency - order because it transitively pulls in the most lookups and defaults.
      • -
      • Depends on: extends - DataGridListPageBase<TDto> (line 18) and - injects ISessionUIService, IEventUIService, and - ISpeakerLookupService (lines 23-25). It uses - SessionDTO, - EventDTO, SpeakerInfo, - CurrentEventDefaults (the EventDTO-typed - wrapper over CurrentEventSelector), - ConferenceRoutePaths, - ErrorMessages, - ListPageActions, and the - MobileInfiniteScrollList<TItem> plus - DeleteConfirmation components. Uses the Event/Room/Session/Speaker aliases.
      • -
      • Concept introduced, the multi-filter enriched list. SessionList layers three refinements on the - event-filtered shape SponsorList, RoomList, and - SpeakerList share:
          -
        1. A third filter: _searchString, _selectedStatus, and _selectedEventId persist together - (SaveFilters lines 44-53, RestoreFilters lines 55-73) and are emitted as Title contains, - Status equals, and EventId equals server filters (ApplyFilters, lines 208-216).
        2. -
        3. Enrichment from two bulk loads instead of per-row fetches: - LoadEventsAndResolveDefaultAsync fetches events with includeChildren: true (line 98) and folds - every event's rooms into one _roomNames dictionary (PopulateRoomNames, lines 124-138), while the - speaker lookup loaded in OnInitializedAsync (line 84) backs GetSpeakerList (lines 140-148), which - maps a row's SessionSpeakers to display names and skips ids the lookup does not know. Both loads - are wrapped in best-effort catches whose comments say the fallback is dash display, not a broken - page (lines 86-89, 102-105). The paged fetch itself also passes includeChildren: true (lines 193,
            -
          1. so each row arrives with its speaker joins.
          2. -
          -
        4. -
        5. Status color coding: GetStatusColor (lines 150-159) maps the Sessionize status strings - Accepted, Waitlisted, Accept_Queue, Nominated, Decline_Queue, and Declined to MudBlazor - colors, defaulting to Color.Default for anything else. - The startup race guard is the same one the sibling lists use, with the clearest explanation in this - file: _eventsLoadTask is started before the first await (lines 77-80) and awaited inside both - LoadServerData (lines 187-188) and FetchMobilePage (lines 200-201), because ApplyFilters runs - inside LoadServerDataAsync, so the default event must be resolved before entering it, "not merely - before the fetch delegate runs" (in-code comment, lines 185-186). - [Rubric §18, UI Architecture & Component Design]: the status filter surfaces the program-committee - workflow inline instead of hiding it behind a separate screen. - [Rubric §23, Front-End Performance & Rendering]: one children-loaded events fetch plus one speaker - lookup replace what would otherwise be per-row enrichment calls. - [Rubric §25, Navigation & Information Architecture]: all three filters survive navigation through the - base class's persistence contract, with the same "all" sentinel and computed default.
        6. -
        -
      • -
      • Walkthrough
          -
        • OnInitializedAsync (lines 75-92): start the events task, load the speaker lookup (tolerating - failure), then await the events task.
        • -
        • ResolveDefaultEventFilter (lines 110-122): keep a restored id that still exists in _events, - otherwise take CurrentEventDefaults.SelectCurrentOrNext(_events, DateTime.UtcNow)?.Id (line 120).
        • -
        • OnSearchChanged, OnStatusChanged, and OnEventFilterChanged (lines 164-181) each update one - filter and reload whichever layout is active via - ListPageActions.ReloadActiveLayoutAsync - (lines 161-162).
        • -
        • LoadServerData (lines 183-195) and FetchMobilePage (lines 198-206) are the desktop and mobile - fetch paths over the same ApplyFilters.
        • -
        • DeleteSessionAsync (lines 221-229) delegates the confirm, delete, snackbar, and reload sequence to - ListPageActions.DeleteWithConfirmationAsync; NavigateToCreate and NavigateToDetails (lines - 231-232) route to SessionCreate and SessionDetail.
        • -
        -
      • -
      • Why it's built this way: sessions are the central editable entity of the program, so the list has to - answer "what is in this conference, in what state, presented by whom" at a glance; defaulting to the - active event and enriching from two bulk loads keeps that view both relevant and cheap.
      • -
      • Where it's used: the /sessions organizer route - (.../Pages/Session/SessionList.razor:1-2, Authorize(Roles = "Organizer")); rows open - SessionDetail and the create button opens SessionCreate.
      • -
      • Caveats / not-in-source: the page builds speaker names from its own lookup rather than trusting the - paged payload alone, so it degrades to a dash rather than a wrong name when a speaker id is unknown; - how the paged endpoint populates SessionSpeakers is a server-side concern outside this file.
      • -

      ⬅ ADC Conference - API, gRPC Contracts & Service HostIndexADC Engagement Module (Session Bookmarks) ➡

      @@ -3154,9 +3888,9 @@

      SessionList

    • Three feature areas that go beyond CRUD
    • Session-selection decision support, the asynchronous edge
    • Public versus authenticated rendering, and the device-capability path
    • -
    • Sponsors, a feature area in miniature
    • +
    • Sponsors and activities, two feature areas in miniature
    • The landing page
    • -
    • Routes and navigation
    • +
    • Routes, navigation, and localized strings
    • How it all plugs into the shell
    diff --git a/docs/onboarding/group-22-engagement-module.html b/docs/onboarding/group-22-engagement-module.html index 6ecc902..48bc9cd 100644 --- a/docs/onboarding/group-22-engagement-module.html +++ b/docs/onboarding/group-22-engagement-module.html @@ -2424,7 +2424,7 @@

    ManualCheckInHandler

    AttendeeCheckedIn

    -

    MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.CheckIns.IntegrationEvents · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/IntegrationEvents/AttendeeCheckedIn.cs:22 · Level 3 · record

    +

    MMCA.ADC.Engagement.Shared · MMCA.ADC.Engagement.Shared.CheckIns.IntegrationEvents · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/IntegrationEvents/AttendeeCheckedIn.cs:22 · 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.
    • @@ -2432,42 +2432,42 @@

      AttendeeCheckedIn

    • 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 two deliberate choices for that. First, Scope is a string (AttendeeCheckedIn.cs:24), not the CheckInScope enum: the doc comment (:10-13) 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 (:29), with the doc comment (:21) recording that placement so a consumer keeps deserializing payloads written before sponsor visits existed. [Rubric §6, CQRS & Event-Driven] covers the delivery half: this is an integration event, not a domain event, so it does not dispatch in process. It is captured into the outbox 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 (:23) is the attendee. Scope (:24) is one of the CheckInScopeNames string constants. EventId (:25) is set for every scope, which is what lets a consumer bucket any check-in by conference without a lookup. SessionId (:26) is nullable and set only for a Session scope. CheckedInByUserId (:27) records who performed the check-in, an organizer for a scan and the attendee themselves for a self-recorded visit (:19), which is what keeps self-recorded rows distinguishable downstream. CheckedInOn (:28) is the recorded instant. SponsorId (:29) is the trailing optional member.
    • Why it's built this way: the doc comment (:6-9) 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. 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:299), which the surrounding comment (:287-293) calls out as ADC's first broker self-consumption: the message leaves this process through the broker and comes back to it.
    • +
    • 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:305), which the surrounding comment (:293-299) calls out as ADC's first broker self-consumption: the message leaves this process through the broker and comes back to it.

    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

    +

    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 (:16-19). [Rubric §13, Observability & Operability] covers the diagnostics, and this is where the class has moved on from a hand-rolled catch: 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 (LiveChannelPublishQueue.cs:61-70). [Rubric §10, Cross-Cutting Concerns] 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).
    • +
    • 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 (:16-19). [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 (LiveChannelPublishQueue.cs:61-70). [Rubric §10, Cross-Cutting Concerns] 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, no longer partial: the logging it used to generate itself now lives inside BestEffort.
      • +
      • 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 (:23-27) 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:13-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 still separated from failure, but the split is now 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).
      • +
      • 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. This worker's job is only to be the one reader that gives that channel its ordering guarantee, and, since the move to BestEffort, 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 (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:263, ADR-012). Covered by LiveChannelPublishProcessorTests.
    • +
    • 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. 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.

    CheckInsController

    -

    MMCA.ADC.Engagement.API · MMCA.ADC.Engagement.API.Controllers · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/CheckInsController.cs:35 · Level 4 · class

    +

    MMCA.ADC.Engagement.API · MMCA.ADC.Engagement.API.Controllers · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/CheckInsController.cs:35 · Level 4 · 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:36-41), 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, AuthorizationPolicies 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(Policy = AuthorizationPolicies.RequireAuthenticated)] (:34) is the floor. 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 §10, Cross-Cutting Concerns] 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 now 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-71), so a replayed response says exactly what a re-executed one would; for sponsor visits it is AlreadyVisited (:124-126); for room check-in the comment adds the load-bearing extra clause, that the session is resolved server-side (:154-157), 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.
    • +
    • 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-71), so a replayed response says exactly what a re-executed one would; for sponsor visits it is AlreadyVisited (:124-126); for room check-in the comment adds the load-bearing extra clause, that the session is resolved server-side (:154-157), 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).
      • +
      • CheckInAsync (:80), POST: the organizer scan path, dispatching CheckInAttendeeRequest to CheckInAttendeeHandler. Declares 400, 403 and 404 alongside the 200 (:77-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).
      • @@ -2482,25 +2482,25 @@

        CheckInsController


        EventFeedbackSubmittedPointsHandler

        -

        MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/EventFeedbackSubmittedPointsHandler.cs:26 · Level 4 · class

        +

        MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/EventFeedbackSubmittedPointsHandler.cs:26 · 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 four handlers in this folder and the simplest of them.
        • +
        • 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: IIntegrationEventHandler<in TIntegrationEvent> (implemented over EventFeedbackSubmitted, :28), IPointsAwarder (resolved per event, :36), PointsSubjectKeys (:38) and PointsActivityType (:42). Externals: IServiceScopeFactory and a source-generated [LoggerMessage] (:51-52).
        • 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 §6, CQRS & Event-Driven] covers the delivery posture: at-least-once means redelivery is normal, so the handler is written to be safely repeatable rather than to guard against a second call. [Rubric §1, SOLID] shows up as the single-responsibility split: mapping lives here, idempotency and the per-rule kill switch live once in the awarder.
        • Walkthrough: HandleAsync (:31) null-guards the event (:33), opens one async DI scope (:35) and resolves the scoped IPointsAwarder from it (:36), because the handler itself is registered as a singleton (see below). It builds the subject key with PointsSubjectKeys.ForEvent(integrationEvent.EventId) (:38), which produces the invariant-culture event:{id} string (PointsSubjectKeys.cs:18-19), then awards PointsActivityType.EventFeedback at the event's own SubmittedOnUtc (:40-45). A failed award is logged at warning (:47-48) and not rethrown, 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).
        • 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:82), which reaches MMCA.Common's singleton registration for every IIntegrationEventHandler<> implementation (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:143-147); the singleton lifetime is exactly why the handler opens its own scope (:19-22). The Engagement service subscribes the matching consumer (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:301). Covered by EventFeedbackSubmittedPointsHandlerTests.
        • +
        • Where it's used: registered by the convention scan (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:87), which reaches MMCA.Common's singleton registration for every IIntegrationEventHandler<> implementation (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:151-155); the singleton lifetime is exactly why the handler opens its own scope (:19-22). The Engagement service subscribes the matching consumer (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:307). Covered by EventFeedbackSubmittedPointsHandlerTests.

        PointsController

        -

        MMCA.ADC.Engagement.API · MMCA.ADC.Engagement.API.Controllers · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/PointsController.cs:36 · Level 4 · class

        +

        MMCA.ADC.Engagement.API · MMCA.ADC.Engagement.API.Controllers · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/PointsController.cs:36 · Level 4 · 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, AuthorizationPolicies 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 (:25-28) 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 (:70-73), 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 (:117-118). [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 (:46-47).
        • +
        • 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 (:45-47).
        • 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).
          • @@ -2516,7 +2516,7 @@

            PointsController


            SessionFeedbackSubmittedPointsHandler

            -

            MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/SessionFeedbackSubmittedPointsHandler.cs:28 · Level 4 · class

            +

            MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/SessionFeedbackSubmittedPointsHandler.cs:28 · 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.
            • @@ -2524,29 +2524,53 @@

              SessionFeedbackSubmittedPointsHan
            • Concept: the thin award adapter is taught on EventFeedbackSubmittedPointsHandler; the same reasoning applies unchanged. [Rubric §16, Maintainability] 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 a shared base would add indirection to five lines of mapping.
            • Walkthrough of what differs: only two lines. The subject key is built with PointsSubjectKeys.ForSession(integrationEvent.SessionId) (:40), producing session:{id} (PointsSubjectKeys.cs:24-25), and the activity is PointsActivityType.SessionFeedback (:44). Everything else, the null guard (:35), the per-event scope (:37-38), the SubmittedOnUtc timestamp (:46) and the log-and-continue failure path (:49-50), 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:82, MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:143-147); the consumer is wired at MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:300. Covered by SessionFeedbackSubmittedPointsHandlerTests.
            • +
            • Where it's used: registered by the convention scan as a singleton (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:87, MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:151-155); the consumer is wired at MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:306. 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 is an integration-event handler and deliberately rethrows, 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 :22-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:143-148), 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 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:87), 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

            +

            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 §10, Cross-Cutting Concerns] 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:9-14). 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:269, and the reasoning at :253-263). [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:261-263).
            • -
            • 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:55 on create, :71 on reactivate, :86 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.
            • +
            • Concept introduced, evicting a cache you do not own. [Rubric §10, Cross-Cutting Concerns] 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:270, and the reasoning at :254-263). [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:262-263).
            • +
            • 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 (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:243, :251, :263). Nothing in the type system checks that agreement.
              • +
              • 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 (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:243, :252, :264). 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.
            • -
            • Where it's used: registered as a singleton by the convention scan (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:82, reaching MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:136-140). 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 and that registering only one is a silent no-op (Program.cs:266-268); 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.
            • +
            • Where it's used: registered as a singleton by the convention scan (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:87, reaching MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:143-148). 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 and that registering only one is a silent no-op (Program.cs:267-269); 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.

            MyPoints

            @@ -2595,34 +2619,14 @@

            AttendeeBadgeInvariants

          • 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 with Result.Combine 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 every caller as nameof(Create) (AttendeeBadge.cs:42), which is what puts the failing member into the error rather than a stack trace.
          • +
          • 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.

          -

          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, Result and Error (Error.Invariant, :88, :93, :104), plus CheckInScope (:45).
          • -
          • Concept introduced, the discriminated-shape invariant. [Rubric §4, DDD] 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 (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/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) 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) 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 four 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).
          • -
          -

          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

          +

          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.
          • @@ -2637,13 +2641,13 @@

            AttendeeBadge

        • 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:40) behind CheckInsController's my-badge endpoint; read back by CheckInAttendeeHandler to resolve a scanned credential to an attendee (.../CheckInAttendee/CheckInAttendeeHandler.cs:46-49). Persisted by AttendeeBadgeConfiguration (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/AttendeeBadgeConfiguration.cs:15) 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:75-77) because it has no navigation. Covered by AttendeeBadgeTests.
        • +
        • Where it's used: minted on first use by GetOrCreateMyBadgeHandler (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/GetOrCreateMyBadge/GetOrCreateMyBadgeHandler.cs:40) behind CheckInsController's my-badge endpoint; read back by CheckInAttendeeHandler to resolve a scanned credential to an attendee (.../CheckInAttendee/CheckInAttendeeHandler.cs:46). Persisted by AttendeeBadgeConfiguration (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/AttendeeBadgeConfiguration.cs:15) 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:80-82) 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.

        UserDeletedPointsHandler

        -

        MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/UserDeletedPointsHandler.cs:36 · Level 8 · class

        +

        MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/UserDeletedPointsHandler.cs:36 · 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.
        • @@ -2657,16 +2661,16 @@

          UserDeletedPointsHandler

      • 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:82, MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:143-147), with the consumer wired at MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:302; the surrounding comment (:283-285) records that this is the one consumer here that earns nothing. Covered by UserDeletedPointsHandlerTests.
      • +
      • Where it's used: registered as a singleton by the convention scan (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:87, MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:151-155), with the consumer wired at MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:308; the surrounding comment (:289-291) 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:30 · Level 10 · class

      +

      MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.Points.IntegrationEventHandlers · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/AttendeeCheckedInPointsHandler.cs:30 · 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 four adapters because the incoming scope is a string that has to be mapped.
      • +
      • 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: IIntegrationEventHandler<in TIntegrationEvent> over AttendeeCheckedIn (:32), IPointsAwarder (:46), CheckInScopeNames (:68, :75, :86), PointsActivityType and PointsSubjectKeys (:70-71, :78-79, :89-90). Externals: IServiceScopeFactory, StringComparison.Ordinal, source-generated [LoggerMessage] (:99-103).
      • 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
          @@ -2676,37 +2680,14 @@

          AttendeeCheckedInPointsHandler

        • The fall-through sets activity = default, an empty subject key, and returns false (:94-96), 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:287-293). 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 (:294-296): 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.
      • -
      • Where it's used: registered as a singleton by the convention scan (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/DependencyInjection.cs:82, MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:143-147), which is why it opens its own scope per event (:23-26); the consumer is wired at MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:299. Covered by AttendeeCheckedInPointsHandlerTests.
      • +
      • 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:293-299). 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 (:300-302): 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:87, MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:151-155), which is why it opens its own scope per event (:23-26); the consumer is wired at MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:305. 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.

      -

      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

      -
      -
        -
      • 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, DDD] 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. [Rubric §6, CQRS & Event-Driven] covers the event placement: AddDomainEvent is called inside the factory (:112), not by a handler, so the outbox captures the announcement in the same transaction as the row (ADR-003). [Rubric §30, Compliance, Privacy & 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).
      • -
      • 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) 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 by CheckInAttendeeHandler, ManualCheckInHandler, RecordSponsorVisitHandler and RecordRoomCheckInHandler; read by GetAttendanceStatsHandler. 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/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, 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).
      • -
      -

      BookmarksController

      -

      MMCA.ADC.Engagement.API · MMCA.ADC.Engagement.API.Controllers · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:33 · Level 11 · class

      +

      MMCA.ADC.Engagement.API · MMCA.ADC.Engagement.API.Controllers · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/Controllers/BookmarksController.cs:33 · 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).
      • @@ -2730,14 +2711,34 @@

        BookmarksController

      • 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.
      +

      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/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 / Result<T>. 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:13-16). That single choice is what lets GetLeaderboardHandler project the whole board out of the Engagement database (GetLeaderboardHandler.cs:39-41) 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 & Data Governance] is the other half, taught below on EraseDisplayName.
      • +
      • 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).
        • @@ -2748,12 +2749,12 @@

          LeaderboardOptIn

        • 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 & 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.
        • +
        • 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. 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:66-68).
      • -
      • Where it's used: created and reactivated by SetLeaderboardParticipationHandler (SetLeaderboardParticipationHandler.cs:86, :92); read by GetLeaderboardHandler (GetLeaderboardHandler.cs:39-41, ordering tie-break on DisplayName at :71); soft-deleted and erased by UserDeletedPointsHandler (UserDeletedPointsHandler.cs:65-81); 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 private ADC repo and is not part of this library, so the citation cannot be verified from published source.
      • +
      • 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:66-68).
      • +
      • Where it's used: reactivated and created by SetLeaderboardParticipationHandler (SetLeaderboardParticipationHandler.cs:86 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

      @@ -2762,8 +2763,8 @@

      LeaderboardOptInInvariants

    • 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 (see 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 two siblings in this unit (PointsEntryInvariants and UserSessionBookmarkInvariants) are the same shape with different rules.
    • +
    • 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).
      • @@ -2778,14 +2779,14 @@

        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 in 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 (CommonInvariants), Result / Error, PointsActivityType and PointsSubjectKeys from MMCA.ADC.Engagement.Shared (PointsEntryInvariants.cs:1), and the UserIdentifierType alias. Externals: System.Enum.
    • +
    • 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 & 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.
      • +
      • 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.
    • @@ -2798,7 +2799,7 @@

      UserSessionBookmarkInvariants

    • 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 (CommonInvariants, UserSessionBookmarkInvariants.cs:1), Result, and the UserIdentifierType / SessionIdentifierType aliases (solution-wide global using, see primer §2).
    • +
    • 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.".
      • @@ -2808,7 +2809,7 @@

        UserSessionBookmarkInvariants

      • 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 two 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.
      • +
      • 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.

      PointsEntry

      @@ -2817,11 +2818,11 @@

      PointsEntry

    • 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<T>.
    • +
    • 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 (:71-72). [Rubric §16, Maintainability] 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 & 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 & 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, 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.
    • +
    • 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 §16, Maintainability] 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).
      • @@ -2829,7 +2830,7 @@

        PointsEntry

      • 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 (such as SessionQuestionSubmittedPointsHandler) behind in ADC. The scan surfaces that feed it are described in ADR-072.
    • +
    • 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:53), 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.

    @@ -2840,15 +2841,15 @@

    UserSessionBookmark

    • 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 & 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. 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 decision half is BookmarkManagementDomainService.
    • +
    • 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-58): 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, ...) (:55).
      • -
      • Reactivate() (:66-74): calls the inherited Undelete() (:68) and, only if that succeeds, re-raises the same Added event (:71). 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() (:81-89): overrides the base soft-delete, calls base.Delete() first (:83) and, on success, raises UserSessionBookmarkChanged(DomainEntityState.Deleted, ...) (:86).
      • +
      • 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.
    • @@ -2863,7 +2864,7 @@

      BookmarkCountService

    • 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 & 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).
    • +
    • 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.
      • @@ -2880,38 +2881,14 @@

        IBookmarkManagementDomainService

        MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.Services · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/Services/IBookmarkManagementDomainService.cs:12 · 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.
    • -
    • Depends on: Result<T>, UserSessionBookmark, and the UserIdentifierType / SessionIdentifierType aliases (solution-wide global using, see primer §2). No EF, no repository, no CancellationToken.
    • +
    • 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:6-11).
    • +
    • 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> (IBookmarkManagementDomainService.cs: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:51-57). Everything the service needs is passed in, so it touches no repository and returns synchronously.
    • +
    • 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:51-57). 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:37), and injected by CreateBookmarkHandler (CreateBookmarkHandler.cs:21).

    -

    SessionQuestionSubmittedPointsHandler

    -
    -

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

    -
    -
      -
    • What it is: the award adapter for session Q and A. It listens for SessionQuestionChanged and awards PointsActivityType.QuestionAsked the first time an attendee asks a question in a given session.
    • -
    • Depends on: IDomainEventHandler<in TDomainEvent> (closed over SessionQuestionChanged), IPointsAwarder, PointsActivityType, PointsSubjectKeys, IUnitOfWork, SessionQuestion, and DomainEntityState. Externals: IServiceScopeFactory, ILogger<T> and the [LoggerMessage] source generator (which is why the class is partial, :89-93).
    • -
    • Concept introduced, the award adapter. [Rubric §6, CQRS & Event-Driven] assesses whether side effects hang off events instead of being wired into the originating use case. The Q and A use case knows nothing about points: it raises its own aggregate event, and this small class translates that event into an (activity, subjectKey) pair for IPointsAwarder. Every earn rule in the module has an adapter of this shape, which is what keeps the ledger conference-agnostic (IPointsAwarder.cs:10-17) while the ADC-specific translation stays in ADC.
    • -
    • Concept introduced, the deliberate at-most-once side effect. [Rubric §29, Resilience, Reliability & Business Continuity] assesses whether a delivery guarantee is chosen rather than inherited. This handler rides an in-process domain event, not an integration event through the outbox (:23-32). Dispatch happens after the question's transaction commits, so a crash in the window between commit and this handler loses one small award and nothing else: no question is lost and no total is corrupted. The stated trade-off is that a second outbox contract, a broker round trip and inbox dedup for five points buys durability the feature does not need, and that promoting it later is a one-file change on each side. Contrast UserDeletedPointsHandler, which is an integration-event handler because erasure is not a game.
    • -
    • Concept, the subject key as the anti-farming rule. The key is the session, never the question (:74, PointsSubjectKeys.ForSession), so an attendee who asks five questions in one session is awarded once. [Rubric §11, Security]: that limit is enforced by the ledger's unique index rather than by counting here (:19-21), so a concurrent double-submit cannot slip past a read-then-write check.
    • -
    • Walkthrough
        -
      • The primary constructor takes IServiceScopeFactory and ILogger<SessionQuestionSubmittedPointsHandler> (:39-41). Domain event handlers are registered as singletons by the framework's convention scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:118-123), which is why this one creates its own scope instead of injecting scoped services.
      • -
      • HandleAsync(domainEvent, cancellationToken) (:44): null-guards the event (:46), then returns immediately unless domainEvent.State == DomainEntityState.Added (:48-49). SessionQuestionChanged is also raised for moderation and deletion (SessionQuestionChanged.cs:7-12), so the state filter is what keeps a moderator approving a question from paying the asker twice.
      • -
      • await using var scope = scopeFactory.CreateAsyncScope() (:53), then IUnitOfWork (:54) and IPointsAwarder (:55) are resolved from it.
      • -
      • The asker is read back from the aggregate rather than carried on the event (:60-61): the event names the question and the session but not the user, and the comment (:57-59) states the reason, the question row is the only authority on whose question it is. A question removed between the commit and this dispatch simply returns (:63-67).
      • -
      • awarder.AwardAsync(question.UserId, PointsActivityType.QuestionAsked, PointsSubjectKeys.ForSession(domainEvent.SessionId), domainEvent.DateOccurred, cancellationToken) (:71-76). DateOccurred comes from BaseDomainEvent and 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 is injected here (:69-70).
      • -
      • A rejected award is logged at warning through the source-generated LogAwardRejected (:78-79, :89-90).
      • -
      • The catch (:82) swallows everything except OperationCanceledException behind an inline CA1031 suppression whose justification is written into the pragma (:81-83): the award is best-effort and must never fail the question that was already committed. [Rubric §13, Observability & Operability]: the swallow is not silent, LogAwardFailed records the exception against the question id (:85, :92-93).
      • -
      -
    • -
    • Why it's built this way: the handler is the boundary where "a question was asked" becomes "points were earned", and both of its guards exist so that the game can never damage the feature it decorates. The state filter keeps the ledger honest; the broad catch keeps a points outage from turning into a Q and A outage.
    • -
    • Where it's used: discovered and registered as a singleton by ScanModuleApplicationServices<TAssemblyMarker>() (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:119-123) and invoked by the framework's domain event dispatcher after SaveChangesAsync on the Q and A write path.
    • -
    -

    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)

    @@ -2919,10 +2896,10 @@

    UserSessionBookmarkDTOMapper

    • 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 & 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).
    • +
    • 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:22, used at :90) and GetUserBookmarksHandler (GetUserBookmarksHandler.cs:21); also resolved by the generic EntityQueryService<TEntity, TEntityDTO, TIdentifierType> registered for bookmarks (DependencyInjection.cs:41) behind BookmarksController. Unit-tested by UserSessionBookmarkDTOMapperTests.
    • +
    • Where it's used: auto-registered by the module's convention scan and injected directly by CreateBookmarkHandler (CreateBookmarkHandler.cs:22, 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:41) behind BookmarksController. Unit-tested by UserSessionBookmarkDTOMapperTests.

    BookmarkManagementDomainService

    @@ -2931,16 +2908,41 @@

    BookmarkManagementDomainService

    • 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<T>. No injected collaborators: the constructor is implicit (BookmarkManagementDomainService.cs:10).
    • -
    • 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. The same pairing appears one aggregate over on LeaderboardOptIn, where the reactivate branch lives in the handler instead.
    • +
    • Depends on: IBookmarkManagementDomainService, UserSessionBookmark, Result. No injected collaborators: the constructor is implicit (BookmarkManagementDomainService.cs:10).
    • +
    • 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:66-74). 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-58).
      • +
      • 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). Being state-free is what lets it be registered as a singleton (DependencyInjection.cs:37).
    • +
    • 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:37).
    • Where it's used: CreateBookmarkHandler calls it after querying for a soft-deleted match (CreateBookmarkHandler.cs:60), and only adds the returned entity to the repository when there was no prior row to revive (CreateBookmarkHandler.cs:65-68). 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:74-86).
    +
    +

    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/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 · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/EntityConfiguration/AttendeeBadgeConfiguration.cs:15 · Level 8 · class (internal sealed)

    diff --git a/docs/onboarding/group-23-engagement-live-layer.html b/docs/onboarding/group-23-engagement-live-layer.html index 5ffb06e..6a491d8 100644 --- a/docs/onboarding/group-23-engagement-live-layer.html +++ b/docs/onboarding/group-23-engagement-live-layer.html @@ -157,48 +157,54 @@

    23. ADC Engage Engagement (the bookmarks of Group 22) is that state changes must fan out to every open page in under a second, so the whole chapter is really about one transport decision: how a vote cast on one phone lights up the tally on two hundred others.

    -

    That transport is the SignalR hub-channel push introduced by ADR-039, and it is deliberately - the opposite of the durable notification pipeline (ADR-024) that the same hub also carries. A - durable notification writes a per-user inbox row and is worth finding minutes later; a live tally is - broadcast to whoever is looking right now and is worthless a second later, so it is never - persisted and carries no delivery guarantee. Everything in this chapter treats a channel event as a - cache-invalidation hint over fetchable state, not as the state itself: if a client connects late - and misses an event, its next fetch still shows the truth. That single design rule (ADR-039's +

    That transport is the SignalR hub-channel push introduced by + ADR-039, and it is + deliberately the opposite of the durable notification pipeline + (ADR-024) that the same hub also + carries. A durable notification writes a per-user inbox row and is worth finding minutes later; a + live tally is broadcast to whoever is looking right now and is worthless a second later, so it is + never persisted and carries no delivery guarantee. Everything in this chapter treats a channel event + as a cache-invalidation hint over fetchable state, not as the state itself: if a client connects + late and misses an event, its next fetch still shows the truth. That single design rule (ADR-039's "ephemeral means lossy") explains most of the code you will read here.

    The two aggregates and their invariants

    -

    Both aggregates are sealed AuditableAggregateRootEntity<TIdentifierType> - subclasses that follow the framework's factory-plus-Result discipline (primer §2). LivePoll - (MMCA.ADC.Engagement.Domain/LivePolls/LivePoll.cs:18) holds an - EventId, an optional SessionId (null for an event-wide poll, BR-230, LivePoll.cs:24), a - question, its authored LivePollOption children, and a strict lifecycle Status - (LivePollStatus): Draft to Open to Closed, no reopen (BR-221). Its - Create factory (LivePoll.cs:64) validates through LivePollInvariants - (2 to 10 unique options, question at most 200 characters, - MMCA.ADC.Engagement.Domain/LivePolls/LivePollInvariants.cs:12, :18, :21), and - Open/Close/Delete each guard the transition: an open poll cannot be deleted (BR-228, - LivePoll.cs:210-219), and a successful delete cascades a soft-delete over the options - (LivePoll.cs:225-232). SessionQuestion +

    Both aggregates are sealed + AuditableAggregateRootEntity<TIdentifierType> + subclasses that follow the framework's factory-plus-Result discipline (primer §2). + LivePoll (MMCA.ADC.Engagement.Domain/LivePolls/LivePoll.cs:18) holds an EventId, + an optional SessionId (null for an event-wide poll, BR-230, LivePoll.cs:24), a question, its + authored LivePollOption children, and a strict lifecycle Status + (LivePollStatus): Draft to Open to Closed, no reopen (BR-221). Its Create + factory (LivePoll.cs:64) validates through LivePollInvariants (2 to 10 + unique options, question at most 200 characters, + MMCA.ADC.Engagement.Domain/LivePolls/LivePollInvariants.cs:12, :18, :21, uniqueness compared + case-insensitively at :54), and Open/Close/Delete each guard the transition: an open poll + cannot be deleted (BR-228, LivePoll.cs:210-219), and a successful delete cascades a soft-delete + over the options (LivePoll.cs:225-232). SessionQuestion (MMCA.ADC.Engagement.Domain/SessionQuestions/SessionQuestion.cs:19) holds a SessionId, a denormalized EventId (deliberately not validated, since the disabled-stub fallback reports a - default, SessionQuestion.cs:24), the submitter's UserId (never exposed on a DTO, BR-238), the - text (at most 500 characters, + default, SessionQuestion.cs:24, :64-67), the submitter's UserId (never exposed on a DTO, + BR-238), the text (at most 500 characters, MMCA.ADC.Engagement.Domain/SessionQuestions/SessionQuestionInvariants.cs:12), a QuestionStatus (Pending/Approved/Dismissed), and an IsAnswered flag; - Approve (SessionQuestion.cs:117), Dismiss (:141), and MarkAnswered (:164) are the - moderation transitions (BR-234), each rejecting the no-op repeat.

    + Approve (SessionQuestion.cs:121), Dismiss (:145), and MarkAnswered (:168) are the + moderation transitions (BR-234), each rejecting the no-op repeat, and Create refuses any initial + status other than Pending or Approved (SessionQuestion.cs:92-99).

    The one design idea worth internalizing early is the live-window snapshot. When a poll is opened (LivePoll.Open, LivePoll.cs:108, stamping LiveWindowEndUtc at :129) or a question is - submitted (SessionQuestion.Create, SessionQuestion.cs:77), the event's live-window end is copied - onto the aggregate. From then on the aggregate can answer "is this vote still allowed?" - (CanAcceptVote, LivePoll.cs:167) or "is this upvote still allowed?" (CanAcceptUpvote, - SessionQuestion.cs:197) against its own snapshotted field, with no cross-service call per vote - (BR-224/BR-237). That matters because votes and upvotes are the high-frequency operations; paying a - gRPC hop on each one would not scale. And like the bookmark aggregate, both use a single - domain event carrying a DomainEntityState - discriminator, LivePollChanged and SessionQuestionChanged - (BR-60, raised at LivePoll.cs:94, :131, :154, :232), rather than separate per-transition - events. Those domain events are durable - BaseDomainEvents captured by the outbox (ADR-003).

    + submitted (SessionQuestion.Create, SessionQuestion.cs:77, taking the window end as a parameter at + :83), the event's live-window end is copied onto the aggregate. From then on the aggregate can + answer "is this vote still allowed?" (CanAcceptVote, LivePoll.cs:167) or "is this upvote still + allowed?" (CanAcceptUpvote, SessionQuestion.cs:201) against its own snapshotted field, with no + cross-service call per vote (BR-224/BR-237). That matters because votes and upvotes are the + high-frequency operations; paying a gRPC hop on each one would not scale. And like the bookmark + aggregate, both use a single domain event carrying a + DomainEntityState discriminator, + LivePollChanged and SessionQuestionChanged (BR-60, + raised at LivePoll.cs:94, :131, :154, :232), rather than separate per-transition events. + Those domain events are durable BaseDomainEvents + captured by the outbox + (ADR-003).

    A vote and an upvote are themselves small aggregates, LivePollVote and SessionQuestionUpvote, each with a "one active row per (poll/question, user)" rule enforced by a filtered unique index @@ -209,7 +215,9 @@

    The two aggregates and their in a user who changes their mind never piles up tombstones. ToggleUpvoteHandler does the mirror image and additionally refuses to let an author upvote their own question (BR-235, - MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/ToggleUpvote/ToggleUpvoteHandler.cs:41-48).

    + MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/ToggleUpvote/ToggleUpvoteHandler.cs:40-48). + Both tables are indexed for the way they are actually read: the vote table carries a second + (LivePollId, OptionId) index for the grouped tally (LivePollVoteConfiguration.cs:40).

    The write path, and where the realtime broadcast actually happens

    Each operation is a vertical slice under Application/{LivePolls|SessionQuestions}/UseCases/{Op}/, and every command handler shares the first two beats: mutate the aggregate through its business @@ -222,84 +230,124 @@

    The wr themselves. The vote and upvote aggregates raise LivePollVoteChanged and SessionQuestionUpvoteChanged, and the matching domain-event handlers, LivePollVoteChangedHandler - (MMCA.ADC.Engagement.Application/LivePolls/DomainEventHandlers/LivePollVoteChangedHandler.cs:31) + (MMCA.ADC.Engagement.Application/LivePolls/DomainEventHandlers/LivePollVoteChangedHandler.cs:38) and SessionQuestionUpvoteChangedHandler - (MMCA.ADC.Engagement.Application/SessionQuestions/DomainEventHandlers/SessionQuestionUpvoteChangedHandler.cs:32), - rebuild the fresh tally and hand a LiveChannelPublishWorkItem - to ILiveChannelPublishQueue - (LivePollVoteChangedHandler.cs:69-72, SessionQuestionUpvoteChangedHandler.cs:70-73). Both - in-code rationales are worth reading (LivePollVoteChangedHandler.cs:17-23, - SessionQuestionUpvoteChangedHandler.cs:17-24): domain-event dispatch inside a transactional - command is deferred until after the commit and dropped on rollback, so clients can no longer be told - about a vote that never persisted, and the request never awaits a gRPC publish, so a hung - Notification peer cannot add its latency to every upvote. Both handlers are singletons that open - their own DI scope (:43-45 and :44-45) and swallow-and-log any failure behind a justified - CA1031 suppression.

    -

    CloseLivePollHandler shows the same queue used directly from a - command handler (.../UseCases/Close/CloseLivePollHandler.cs:85-97): its EnqueueClosed serializes a - LivePollClosedPayload and hands it to Enqueue (:95-96), with no - rejection branch to write because the queue never refuses an item; the only log left on that path is - the Information "live poll closed" line emitted before the enqueue (:72, :99-100). The other - three poll and question command handlers enqueue the same way rather than awaiting the publish: - OpenLivePollHandler (.../UseCases/Open/OpenLivePollHandler.cs:100-112), + (MMCA.ADC.Engagement.Application/SessionQuestions/DomainEventHandlers/SessionQuestionUpvoteChangedHandler.cs:39), + rebuild the fresh tally and hand a + LiveChannelPublishWorkItem to + ILiveChannelPublishQueue + (LivePollVoteChangedHandler.cs:79-82, SessionQuestionUpvoteChangedHandler.cs:80-83). Both + in-code rationales are worth reading (LivePollVoteChangedHandler.cs:18-24, + SessionQuestionUpvoteChangedHandler.cs:18-25): domain-event dispatch inside a transactional command + is deferred until after the commit and dropped on rollback, so clients are never told about a vote + that never persisted, and the request never awaits a gRPC publish, so a hung Notification peer cannot + add its latency to every upvote. Both handlers are singletons that open their own DI scope + (LivePollVoteChangedHandler.cs:53, SessionQuestionUpvoteChangedHandler.cs:54), and neither + hand-rolls a catch: the whole body runs inside + BestEffort.ExecuteAsync + (LivePollVoteChangedHandler.cs:51, SessionQuestionUpvoteChangedHandler.cs:52), the framework + helper that turns a failed side effect into exactly one Warning plus one increment of + besteffort.dispatch.failed on the MMCA.Common.BestEffort meter while still rethrowing the + caller's own cancellation + (MMCA.Common/Source/Core/MMCA.Common.Application/Services/BestEffort.cs:19-22, :45, :59). A + broadcast path that has quietly stopped working is therefore countable, not just loggable.

    +

    CloseLivePollHandler shows the same queue used directly from a command + handler (.../UseCases/Close/CloseLivePollHandler.cs:85-97): its EnqueueClosed serializes a + LivePollClosedPayload and hands it to Enqueue (:95-96) with no guard + at all, because Enqueue cannot fail and the queue never refuses an item + (MMCA.ADC.Engagement.Application/Live/ILiveChannelPublishQueue.cs:24-30); the only log left on that + path is the Information "live poll closed" line emitted before the enqueue (:72, :99-100). + OpenLivePollHandler is identical in shape + (.../UseCases/Open/OpenLivePollHandler.cs:100-112). The two question command handlers add the + best-effort wrapper back, because their enqueue block also reads the database: SubmitQuestionHandler - (.../UseCases/Submit/SubmitQuestionHandler.cs:117-155), and + (.../UseCases/Submit/SubmitQuestionHandler.cs:130-160) and ModerateQuestionHandler - (.../UseCases/Moderate/ModerateQuestionHandler.cs:92-149) each resolve a channel key, serialize a - small payload record to JSON, and call ILiveChannelPublishQueue.Enqueue - (OpenLivePollHandler.cs:110-111, SubmitQuestionHandler.cs:130-131 and :145-146, - ModerateQuestionHandler.cs:125 and :139-140). The two question handlers still wrap that block in a - CA1031-suppressed swallow-and-log catch (SubmitQuestionHandler.cs:149-154, - ModerateQuestionHandler.cs:143-148), because their Pending branch reads a fresh count from the - database before enqueueing and that read must never fail the command. - CreateLivePollHandler broadcasts - nothing at all: a poll is created as Draft and there is nothing for an audience to see yet. - Channel keys come from the two contract classes shared by publisher and subscriber, - LivePollChannel (ForEvent gives event:1, ForSession gives session:123, - MMCA.ADC.Engagement.Shared/LivePolls/LivePollChannel.cs:24-30) and + (.../UseCases/Moderate/ModerateQuestionHandler.cs:104-156) resolve the session channel key, + serialize a small payload record to JSON, and enqueue, but their Pending branch first counts the + session's pending questions (SubmitQuestionHandler.cs:149-151, ModerateQuestionHandler.cs:144-146) + and that read must never fail a command that has already committed. Both route through + BestEffort.ExecuteAsync (SubmitQuestionHandler.cs:131, ModerateQuestionHandler.cs:136) and both + deliberately withhold the caller's cancellation token, so an abandoned request cannot turn a saved + question into a cancelled broadcast (SubmitQuestionHandler.cs:119-128, + ModerateQuestionHandler.cs:95-102). One detail in ModerateQuestionHandler is worth copying: the + action-to-payload switch is built outside the guard (:116-134) so an unknown moderation action + faults loudly as an ArgumentOutOfRangeException instead of being swallowed as a missed broadcast. + CreateLivePollHandler broadcasts nothing at all and takes no queue in its + constructor (.../UseCases/Create/CreateLivePollHandler.cs:20-24): a poll is created as Draft and + there is nothing for an audience to see yet. Channel keys come from the two contract classes shared + by publisher and subscriber, LivePollChannel (ForEvent gives event:1, + ForSession gives session:123, MMCA.ADC.Engagement.Shared/LivePolls/LivePollChannel.cs:24-30) and SessionQuestionChannel; questions reuse the session key, so a session's - polls and questions ride one channel.

    + polls and questions ride one channel + (MMCA.ADC.Engagement.Shared/SessionQuestions/SessionQuestionChannel.cs:6-8).

    Two rules govern what is allowed on the channel. First, broadcasts never carry per-user data (BR-229): the results broadcast is built with userId: null so MyVoteOptionId stays null - (LivePollVoteChangedHandler.cs:61-63), and the upvote broadcast carries only the fresh count - (SessionQuestionUpvoteChangedHandler.cs:65-73). Second, pending question content is never + (LivePollVoteChangedHandler.cs:71-73), and the upvote broadcast carries only the fresh count + (SessionQuestionUpvoteChangedHandler.cs:75-83). Second, pending question content is never broadcast (BR-238): full text rides the channel only on the approved payload, so when a pending question is submitted or leaves the queue the channel carries a question.pending-count-changed count instead, and moderators see the badge move without unmoderated text leaking - (SubmitQuestionHandler.cs:126-140, ModerateQuestionHandler.cs:123-140).

    -

    Three server-side guards round out the write path. Poll open/close and question moderation accept - the client's last-seen rowversion and stamp it back as the original (ADR-035), so a transition + (SubmitQuestionHandler.cs:147-158, ModerateQuestionHandler.cs:140-153).

    +

    Server-side guards round out the write path. Poll open/close and question moderation accept the + client's last-seen rowversion and stamp it back as the original + (ADR-035), so a transition decided against a stale view fails with 409 Conflict rather than silently applying - (OpenLivePollHandler.cs:46, CloseLivePollHandler.cs:45, ModerateQuestionHandler.cs:48). The - vote path adds an explicit TOCTOU re-check (CastVoteHandler.cs:108-122): a rowversion conflict - cannot catch a concurrent close, because a vote only touches the LivePollVote row and never the - poll row, so the handler re-reads the poll immediately before saving and documents the accepted - millisecond residue (CastVoteHandler.cs:98-107). And question submission carries a spam cap: a - user may hold at most ten open (non-dismissed) questions per session - (SessionQuestionInvariants.cs:19, enforced at SubmitQuestionHandler.cs:61-74), so an - auto-approving event default cannot be used to flood the channel.

    + (OpenLivePollHandler.cs:47, CloseLivePollHandler.cs:45, ModerateQuestionHandler.cs:53). The two + hot paths add an explicit TOCTOU re-check instead (CastVoteHandler.cs:98-122, + ToggleUpvoteHandler.cs:98-122): a rowversion conflict cannot catch a concurrent close or dismissal, + because a vote only touches the LivePollVote row and never the poll row, so the handler re-reads + the aggregate immediately before saving and documents the accepted millisecond residue + (CastVoteHandler.cs:98-107). Only the upvote-on path re-checks; clearing an upvote is + deliberately still allowed after a dismissal or after the window closes + (ToggleUpvoteHandler.cs:73-80). And question submission carries a spam cap: a user may hold at most + ten open (non-dismissed) questions per session (SessionQuestionInvariants.cs:21, enforced at + SubmitQuestionHandler.cs:72-83), so an auto-approving event default cannot be used to flood the + channel. Both the constant and its enforcement document themselves as a soft cap: the count and + the insert are not one atomic step, so concurrent submits from the same user can briefly exceed it + and moderation drains the overflow (SessionQuestionInvariants.cs:14-21, + SubmitQuestionHandler.cs:66-71).

    One WebSocket, one publisher port, and a cross-service ingress

    -

    The transport itself is framework-owned (ADR-039, Group 10). The single +

    The transport itself is framework-owned (ADR-039, Group 10). The single NotificationHub carries both durable notifications and channel events on one connection, and the application-layer port ILiveChannelPublisher keeps the handlers - transport-free, exactly the way IPushNotificationSender - does for the durable path. Which implementation resolves tells you the deployment topology, the same - "resolvable everywhere, active only where configured" convention as the rest of the framework: + transport-free, exactly the way + IPushNotificationSender does for the durable + path. Which implementation resolves tells you the deployment topology, the same "resolvable + everywhere, active only where configured" convention as the rest of the framework: SignalRLiveChannelPublisher group-sends - over the hub in a host that maps it; NullLiveChannelPublisher - is the no-op default. In ADC the twist is that the Engagement service does not map the hub (the - Notification service does), so Engagement's composition root replaces the registration with a - gRPC adapter, LiveChannelPublisherGrpcAdapter, - that forwards the pre-serialized JSON payload to the Notification service's + over the hub in a host that maps it; + NullLiveChannelPublisher is the no-op default. + In ADC the twist is that the Engagement service does not map the hub (the Notification service + does), so Engagement's composition root replaces the registration with a gRPC adapter, + LiveChannelPublisherGrpcAdapter, that + forwards the pre-serialized JSON payload to the Notification service's LiveChannelGrpcService ingress, which then does - the real group send (MMCA.ADC.Engagement.Service/Program.cs:188-197). This is exactly the "a host - that does not map the hub can replace the registration with its own transport" extension point - ADR-039 anticipates, and it rides the ADR-012 mixed-endpoint gRPC profile (the Notification service - serves a dedicated Http2-only endpoint for this ingress alongside its WebSocket endpoint). Because - the payload is an opaque string at every hop, no serializer dependency crosses the wire, and the - queue drain, LiveChannelPublishProcessor, - resolves the scoped adapter per item and logs-and-swallows every failure.

    + the real group send. The host calls one line for it (MMCA.ADC.Engagement.Service/Program.cs:269, + rationale at :259-268), and the extension behind that line does a Replace, not a TryAdd, so the + adapter beats the framework's Null default + (MMCA.ADC.Notification.Contracts/DependencyInjection.cs:42-51). This is exactly the "a host that + does not map the hub can replace the registration with its own transport" extension point ADR-039 + anticipates, and it rides the + ADR-012 mixed-endpoint gRPC + profile (the Notification service serves a dedicated Http2-only endpoint for this ingress alongside + its WebSocket endpoint). Because the payload is an opaque string at every hop, no serializer + dependency crosses the wire.

    +

    The queue between the handlers and that adapter is the part to understand before you trust the + latency story. LiveChannelPublishQueue is a bounded System.Threading.Channels channel of capacity + 1024 with FullMode = DropOldest and SingleReader = true + (MMCA.ADC.Engagement.Application/Live/LiveChannelPublishQueue.cs:18, :33-40): under sustained + backpressure the freshest broadcast wins, which is the right trade for ephemeral data, and every + discard is counted and logged as a Warning through the channel's itemDropped callback (:47, + :61-70), because TryWrite under DropOldest can never report the drop itself (:30-32). The + single reader is + LiveChannelPublishProcessor + (MMCA.ADC.Engagement.Infrastructure/Live/LiveChannelPublishProcessor.cs:30), a BackgroundService + that resolves the scoped publisher per item (:50-51) and wraps each publish in BestEffort keyed by + the event name (:45-46), so a peer that stops accepting broadcasts is visible on the same meter; a + shutdown mid-publish stops the drain quietly (:60-65). FIFO through one reader is what preserves + per-session event ordering (:13-14).

    On the browser side, NotificationHubService (Common, Group 15) owns the one connection and exposes JoinChannelAsync / LeaveChannelAsync / a multicast OnChannelEvent subscription, and it @@ -308,119 +356,153 @@

    One WebSoc SessionLive does exactly this (MMCA.ADC.Engagement.UI/Pages/SessionLive/SessionLive.razor.cs:117-132, teardown at :349-361). The join is deliberately not firstRender-gated: the first render fires at the first await in - OnInitializedAsync while the session is still null, so a firstRender-only join never attached; - the stored channel key doubles as the already-joined guard, and the RendererInfo.IsInteractive - check keeps the prerender pass and the bUnit suite from dialing the hub - (SessionLive.razor.cs:119-123, and the same comment on - Pages/HappeningNow/HappeningNow.razor.cs:111-118 and Pages/SessionLive/PresenterView.razor.cs:90-97).

    + OnInitializedAsync while the session is still null, so a firstRender-only join never attached; the + stored channel key doubles as the already-joined guard, and the RendererInfo.IsInteractive check + keeps the prerender pass and the bUnit suite from dialing the hub (SessionLive.razor.cs:119-123, + and the same comment on Pages/HappeningNow/HappeningNow.razor.cs:111-115 and + Pages/SessionLive/PresenterView.razor.cs:90-97). SessionLive and PresenterView also skip their + data loads entirely on the prerender pass, since the interactive instance re-runs + OnInitializedAsync and nothing here is cache-served for a logged-in user + (SessionLive.razor.cs:66-72, PresenterView.razor.cs:54-59); HappeningNow knowingly does not, + and says why in a NOTE at HappeningNow.razor.cs:71-72.

    The read path and how the UI reacts

    Reads do not go through the generic entity-query machinery; the live views need shaped projections. LivePollResultsBuilder (MMCA.ADC.Engagement.Application/LivePolls/Services/LivePollResultsBuilder.cs:12) computes each option's tally with a grouped COUNT pushed into SQL (one row per option instead of one row per vote, :42-47) and adds the caller's own MyVoteOptionId as a separate point read that broadcast - payloads skip entirely (:51-60); votes cast on an option later removed are excluded so the - per-option numbers still add up to the total (:31-37). - SessionQuestionViewBuilder + payloads skip entirely (:51-61); votes cast on an option later removed are excluded so the + per-option numbers still add up to the total (:31-37, :79-81), and the poll's concurrency token + travels back on the results DTO so a surface fed only by tallies can still issue an open or close + (:84-87). SessionQuestionViewBuilder (.../SessionQuestions/Services/SessionQuestionViewBuilder.cs:12) is its mirror for questions - (:39-46), adding per-caller MyUpvote/IsMine flags. Those two feed the query handlers behind - GET /livepolls/open, /livepolls/{id}/results, /sessionquestions, and + (:39-46), adding per-caller MyUpvote/IsMine flags (:48-58). Those two feed the query handlers + behind GET /livepolls/open, /livepolls/{id}/results, /sessionquestions, and /sessionquestions/moderation. GetOpenPollsHandler requires an explicit event or session scope (.../GetOpenPolls/GetOpenPollsHandler.cs:24-30) and excludes session-scoped - polls from the event-wide list (BR-230, :41); both question reads are bounded server-side at 200 - rows so a flooded session cannot produce an unbounded payload - (.../GetSessionQuestions/GetSessionQuestionsHandler.cs:22, - .../GetModerationQueue/GetModerationQueueHandler.cs:26), with the attendee view returning approved - questions most-upvoted-first followed by the caller's own non-approved ones (:39-49) and the - moderation view ordering Pending first. LivePollNavigationPopulator - loads a poll's Options on query-service paths EF cannot .Include() (ADR-002, - .../LivePolls/Services/LivePollNavigationPopulator.cs:11), the EF configurations + polls from the event-wide list (BR-230, :41). Both question reads are bounded server-side so a + flooded session cannot produce an unbounded payload, and the attendee read is the more interesting of + the two: GetSessionQuestionsHandler spends two separate budgets, + 200 approved questions ranked by upvote count in the database before the cap applies (a correlated + COUNT subquery over the upvote table, since question and upvote are separate aggregates with no + navigation between them, .../GetSessionQuestions/GetSessionQuestionsHandler.cs:32, :45-58) plus + 25 of the caller's own non-approved questions taken newest first (:35, :60-69), because one + shared budget filled by oldest id let a flood of low-value questions push both the most upvoted + question and the caller's own newest submission out of the payload (:17-24). The moderation read + caps at 200 and orders Pending first + (.../GetModerationQueue/GetModerationQueueHandler.cs:26, :46-47, :54). + LivePollNavigationPopulator loads a poll's Options on + query-service paths EF cannot .Include() + (ADR-002, + .../LivePolls/Services/LivePollNavigationPopulator.cs:11-22), the EF configurations (LivePollConfiguration and siblings) keep the Conference references as - scalar FK columns under database-per-service (ADR-006, + scalar FK columns under database-per-service + (ADR-006, .../EntityConfiguration/LivePollConfiguration.cs:10-14) and index the conference-day hot filter - (SessionId, Status) (:40), and entity-to-DTO mapping is a compile-time Mapperly mapper, - LivePollDTOMapper (ADR-001, .../LivePolls/DTOs/LivePollDTOMapper.cs:13).

    + (SessionId, Status) (:36-40), and entity-to-DTO mapping is a compile-time Mapperly mapper, + LivePollDTOMapper + (ADR-001, + .../LivePolls/DTOs/LivePollDTOMapper.cs:13).

    When a channel event arrives, the page decides between patch-in-place and reload, and this is - the chapter's key performance lesson. The two high-frequency tally events - (poll.results-changed, question.upvote-changed) already carry the fresh counts in their payload, - so the page patches its in-memory model and calls StateHasChanged with no HTTP refetch - (SessionLive.razor.cs:187-258), falling back to a targeted reload when the payload cannot be - applied. The comment there records why: reloading on every broadcast turned V voters times C - viewers into V*C authenticated refetches per hot poll, which collided with the per-user rate - limiter under burst voting (SessionLive.razor.cs:136-140). Structural events (opened, closed, - approved, answered, dismissed, pending-count-changed) are rarer and do trigger a targeted reload of - the affected list (:146-179). SessionLive itself is the container that owns the lists, the - channel subscription, and the shared saving flag, while the three sections render through the - presentational SessionLivePollPanel, + the chapter's key performance lesson. The two high-frequency tally events (poll.results-changed, + question.upvote-changed) already carry the fresh counts in their payload, so the page patches its + in-memory model and calls StateHasChanged with no HTTP refetch + (SessionLive.razor.cs:187-258), preserving this circuit's own vote marker across the patch because + the broadcast strips per-user data (:229), and falling back to a targeted reload when the payload + cannot be applied (:203-208). The comment there records why: reloading on every broadcast turned V + voters times C viewers into V times C authenticated refetches per hot poll, which collided with the + per-user rate limiter under burst voting (SessionLive.razor.cs:136-140). Structural events (opened, + closed, approved, answered, dismissed, pending-count-changed) are rarer and do trigger a targeted + reload of the affected list (:146-179), and a failed background refresh degrades to a snackbar + rather than crashing the page (:271-276). SessionLive itself is the container that owns the + lists, the channel subscription, and the shared saving flag, while the three sections render through + the presentational SessionLivePollPanel, SessionLiveQuestionPanel, and SessionLiveModerationPanel children (SessionLive.razor.cs:14-24). - Whether the layer is even active is decided by LiveEventService + The single session it renders is a point read through + ISessionLookupService rather than a full catalog fetch + (SessionLive.razor.cs:85-87, PresenterView.razor.cs:63-65). Whether the layer is even active is + decided by LiveEventService (MMCA.ADC.Engagement.UI/Services/LiveEventService.cs:14): it fetches the current-or-next published event through CurrentEventSelector and - computes its live window into a LiveEventContext with the same math the - backend enforces (:27-46), degrading to null on an API failure (:48-52) so the live surfaces - simply stay dormant rather than error; HappeningNow joins the event channel only while - IsLiveAt is true (LiveEventContext.cs:22, HappeningNow.razor.cs:120-128). The cross-module - ISessionLiveUIService / SessionLiveUIService - contract is what lets a Conference session page light up its "Live" button when Engagement is - deployed (MMCA.ADC.Engagement.UI/Services/SessionLiveUIService.cs:13-14).

    + computes its live window with the same math the backend enforces (:27-46), degrading to null on an + API failure (:48-52) so the live surfaces simply stay dormant rather than error; HappeningNow + joins the event channel only while IsLiveAt is true + (MMCA.ADC.Engagement.UI/Services/LiveEventContext.cs:22, HappeningNow.razor.cs:120-128). The + cross-module ISessionLiveUIService / + SessionLiveUIService contract is what lets a Conference session page light + up its "Live" button when Engagement is deployed + (MMCA.ADC.Engagement.UI/Services/SessionLiveUIService.cs:10-14).

    Authorization, feature gating, and the cross-service dependency on Conference

    Both controllers, LivePollsController - (MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:42) and + (MMCA.ADC.Engagement.API/Controllers/LivePollsController.cs:44) and SessionQuestionsController - (.../Controllers/SessionQuestionsController.cs:37), sit behind + (.../Controllers/SessionQuestionsController.cs:39), sit behind ApiControllerBase and are gated two ways: [Authorize(Policy = AuthorizationPolicies.RequireAuthenticated)] (AuthorizationPolicies, no anonymous participation) and a [FeatureGate] per feature (EngagementFeatures LivePolls / SessionQA) that makes the whole surface vanish (404) when toggled off - (LivePollsController.cs:40-41, SessionQuestionsController.cs:35-36). The finer + (LivePollsController.cs:42-43, SessionQuestionsController.cs:37-38). The finer authoring/moderation rights (BR-236) are enforced in the handlers, not by an attribute, through the shared LivePollAuthorization check (.../LivePolls/Services/LivePollAuthorization.cs:22-44): organizers and admins manage everything, and a speaker manages only content scoped to a session they are assigned to (matched against the - SessionLiveInfo.SpeakerIds list from - Conference). The organizer-only manage list and the delete endpoint additionally carry - [HasPermission(EngagementPermissions.LiveManage)] (ADR-020, LivePollsController.cs:120, :139). - Crucially, the caller's identity (user id, speaker_id claim, roles) is always bound from the token - via ICurrentUserService, never from the request body - (LivePollsController.cs:228-236). Lifecycle POSTs take an optional - LifecycleTransitionRequest body purely - to carry the rowversion (LivePollsController.cs:83).

    + SessionLiveInfo.SpeakerIds list from Conference). + The organizer-only manage list and the delete endpoint additionally carry + [HasPermission(EngagementPermissions.LiveManage)] + (ADR-020, + LivePollsController.cs:150, :169). Crucially, the caller's identity (user id, speaker_id claim, + roles) is always bound from the token via + ICurrentUserService, never from the request body + (LivePollsController.cs:262-271). Two Common API behaviors show up on these routes as well: every + mutating endpoint is marked [Idempotent] + (ADR-017) so a conference-day + retry over flaky wifi replays the first response instead of creating a second poll, question, or vote + (LivePollsController.cs:62, :92, :238, SessionQuestionsController.cs:54), and the two + lifecycle POSTs add [SupportsIfMatch] so + the same rowversion may instead be stated as an HTTP If-Match header, in which case a stale token + answers 412 rather than 409 (LivePollsController.cs:93, :128, rationale at :82-89). The optional + LifecycleTransitionRequest body exists + purely to carry that token (LivePollsController.cs:102).

    This makes the live layer dependent on Conference, the same modular-monolith boundary Group 22 - demonstrated (ADR-007/ADR-008). Engagement calls Conference's + demonstrated (ADR-007 / + ADR-008). Engagement + calls Conference's IEventLiveValidationService to fetch the live window, the session's assigned speakers, and the event's moderation default (in-process when - co-hosted, over gRPC when extracted: MMCA.ADC.Engagement.Service/Program.cs:183-186), and on the + co-hosted, over gRPC when extracted: MMCA.ADC.Engagement.Service/Program.cs:254-257), and on the client side the Conference session page reaches back through the Engagement UI's ISessionLiveUIService implementation for the Live route. The EngagementModule declares the dependency, and the same disabled-stub registrations keep every interface resolvable in a single-module service host, - which is why SessionQuestion.Create tolerates a default EventId (SessionQuestion.cs:24, :64-67). - The UI clients (LivePollUIService, SessionQuestionUIService) - extend Common's AuthenticatedServiceBase - and go back through the Gateway's public REST routes, not a back channel - (MMCA.ADC.Engagement.UI/Services/LivePollUIService.cs:14-18).

    + which is why SessionQuestion.Create tolerates a default EventId (SessionQuestion.cs:24, + :64-67). The UI clients (LivePollUIService, + SessionQuestionUIService) extend Common's + AuthenticatedServiceBase and go back + through the Gateway's public REST routes, not a back channel + (MMCA.ADC.Engagement.UI/Services/LivePollUIService.cs:15-19).

    Rubric lenses this chapter exercises. [Rubric §4, DDD] (two aggregates with lifecycle state machines, invariant guards, the live-window snapshot, and the single-event-with-state design); [Rubric §6, CQRS & Event-Driven] (command/query slices, durable domain events over the outbox, and - the separate ephemeral channel broadcast that two of those domain events now trigger); - [Rubric §7, Microservices Readiness] (the ILiveChannelPublisher port with a SignalR - implementation, a Null default, and a gRPC forwarding adapter, plus the Conference validation - boundary); [Rubric §12, Performance & Scalability] (per-vote checks against a snapshotted window - with no cross-service hop, grouped-COUNT tallies, bounded reads, an off-request-path publish queue, - and patch-in-place tally updates that avoid the V*C refetch storm against the rate limiter); - [Rubric §11, Security] (RequireAuthenticated plus feature gates plus handler-enforced - speaker-scoped rights plus HasPermission, identity from token, anonymous question display, the - open-question spam cap, and pending text kept off the channel, BR-238); [Rubric §9, API & Contract Design] (feature-gated, versioned REST endpoints returning Problem Details, with 409 on a stale - lifecycle transition); [Rubric §18/§19, UI Architecture / State Management] (three live surfaces - over one multicast hub subscription, a container page with presentational panels, patch-vs-reload - event handling, re-join on reconnect); [Rubric §29, Resilience] (post-commit best-effort - broadcasts that never fail the command and a UI that treats channel events as hints over fetchable - state, degrading to dormant on failure); and [Rubric §13, Observability] (every failed publish and - every broadcast discarded under backpressure is logged as a warning). Each is taught in full at the - relevant per-type section - below.

    + the separate ephemeral channel broadcast that two of those domain events trigger); [Rubric §7, Microservices Readiness] (the ILiveChannelPublisher port with a SignalR implementation, a Null + default, and a gRPC forwarding adapter swapped in by Replace, plus the Conference validation + boundary); [Rubric §8, Data Architecture] (filtered unique indexes behind the create-or-reactivate + rule, the (SessionId, Status) conference-day index, and cross-context references kept as scalar FK + columns); [Rubric §12, Performance & Scalability] (per-vote checks against a snapshotted window + with no cross-service hop, grouped-COUNT tallies, database-side ranking before a cap, a bounded + drop-oldest publish queue off the request path, and patch-in-place tally updates that avoid the + V-times-C refetch storm against the rate limiter); [Rubric §11, Security] (RequireAuthenticated plus + feature gates plus handler-enforced speaker-scoped rights plus HasPermission, identity from token, + anonymous question display, the open-question spam cap, and pending text kept off the channel, + BR-238); [Rubric §9, API & Contract Design] (feature-gated, versioned REST endpoints returning + Problem Details, idempotent mutations, and 409-or-412 on a stale lifecycle transition); [Rubric §18/§19, UI Architecture / State Management] (three live surfaces over one multicast hub + subscription, a container page with presentational panels, patch-vs-reload event handling, re-join on + reconnect, prerender-skipped loads); [Rubric §29, Resilience] (post-commit best-effort broadcasts + that never fail the command, a drain that swallows every publish failure, and a UI that treats + channel events as hints over fetchable state, degrading to dormant on failure); and [Rubric §13, Observability] (every failed broadcast counted on besteffort.dispatch.failed and every broadcast + discarded under backpressure logged with a running total). Each is taught in full at the relevant + per-type section below.

    CastVoteCommand

    MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.LivePolls.UseCases.CastVote · MMCA.ADC.Engagement.Application/LivePolls/UseCases/CastVote/CastVoteCommand.cs:11 · Level 0 · record

    @@ -1535,12 +1617,12 @@

    ModerateQuestionCommand

    MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.SessionQuestions.UseCases.Moderate · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/Moderate/ModerateQuestionCommand.cs:15 · Level 1 · record

      -
    • What it is: the CQRS command that carries one moderation action (approve / dismiss / mark-answered) against a single session question, together with the caller's identity as resolved at the API edge.
    • -
    • Depends on: ModerationAction (the action enum, same group) and the module identifier aliases SessionQuestionIdentifierType / SpeakerIdentifierType (Engagement/Conference Shared); dispatched to ICommandHandler<in TCommand, TResult>.
    • -
    • Concept introduced, identity-from-token commands. [Rubric §11, Security] assesses whether authorization inputs come from a trusted source rather than the request body; here the command records CallerSpeakerId and CallerIsOrganizer (ModerateQuestionCommand.cs:17-18) which the controller binds from JWT claims, never from client-supplied JSON, so an attacker cannot claim organizer rights by editing the payload. [Rubric §6, CQRS & Event-Driven] is the plain command-as-record shape.
    • -
    • Walkthrough: a sealed record with four positional members (ModerateQuestionCommand.cs:14-18): QuestionId (which question), Action (the ModerationAction to apply), CallerSpeakerId (nullable SpeakerIdentifierType?, present only for speakers), and CallerIsOrganizer (a bool set when the caller holds the Organizer or Admin role). The last two are the BR-236 rights inputs the handler checks.
    • -
    • Why it's built this way: keeping caller identity in the command (rather than reaching into HttpContext from the handler) keeps the Application layer host-agnostic and unit-testable, and makes the trust boundary explicit: the API edge is the only place that reads claims.
    • -
    • Where it's used: constructed by SessionQuestionsController's private ModerateAsync (SessionQuestionsController.cs:171) and handled by ModerateQuestionHandler.
    • +
    • What it is: the CQRS command that carries one moderation action (approve / dismiss / mark-answered) against a single session question, together with the caller's identity as resolved at the API edge and the client's last-seen concurrency token.
    • +
    • Depends on: ModerationAction (the action enum, same group) and the module identifier aliases SessionQuestionIdentifierType (Engagement Shared) / SpeakerIdentifierType (Conference Shared); dispatched through ICommandHandler<in TCommand, TResult>.
    • +
    • Concept introduced, identity-from-token commands. [Rubric §11, Security] assesses whether authorization inputs come from a trusted source rather than the request body. Here the command records CallerSpeakerId and CallerIsOrganizer (ModerateQuestionCommand.cs:18-19), which the controller binds from JWT claims, never from client-supplied JSON, so an attacker cannot claim organizer rights by editing the payload. The doc comment states the rule the pair encodes (BR-236: organizers and admins moderate everything, a session's assigned speakers moderate their own session's questions, ModerateQuestionCommand.cs:6-8). [Rubric §6, CQRS & Event-Driven] is the plain command-as-record shape.
    • +
    • Walkthrough: a sealed record with five positional members (ModerateQuestionCommand.cs:15-20): QuestionId (which question), Action (the ModerationAction to apply), CallerSpeakerId (nullable SpeakerIdentifierType?, present only for speakers), CallerIsOrganizer (a bool set when the caller holds the Organizer or Admin role), and RowVersion (a byte[]? defaulting to null, :20). That last member is the optimistic-concurrency token from ADR-035: passing null deliberately skips the stale-view check (:14), so a caller without a token can still moderate while the UI always sends one.
    • +
    • Why it's built this way: keeping caller identity in the command (rather than reaching into HttpContext from the handler) keeps the Application layer host-agnostic and unit-testable, and makes the trust boundary explicit, since the API edge is the only place that reads claims. Making RowVersion an optional trailing parameter lets the concurrency check be opt-in per call site without a second command type.
    • +
    • Where it's used: constructed by SessionQuestionsController's private ModerateAsync (SessionQuestionsController.cs:238), which the three moderation verbs delegate to with a fixed ModerationAction (SessionQuestionsController.cs:149,177,205); handled by ModerateQuestionHandler.

    LivePollChanged

    @@ -1549,181 +1631,213 @@

    LivePollChanged

    • What it is: the single domain event a LivePoll raises for its whole lifecycle: created, opened, closed, or soft-deleted.
    • Depends on: BaseDomainEvent (base), DomainEntityState (the change classifier), LivePollStatus (the lifecycle status), and the LivePollIdentifierType / EventIdentifierType aliases.
    • -
    • Concept introduced, one event carrying a state discriminator (BR-60). [Rubric §6, CQRS & Event-Driven] assesses whether events carry enough context to be acted on without a re-read. Rather than four separate Created / Opened / Closed / Deleted events, this codebase uses one event whose DomainEntityState says what kind of change happened and whose LivePollStatus says the resulting lifecycle state (doc comment, LivePollChanged.cs:7-11). A consumer switches on those two fields. This BR-60 convention is shared by all four live-layer events below, so learn it once here.
    • -
    • Walkthrough: a sealed record class with four positional members deriving from BaseDomainEvent (LivePollChanged.cs:17-22): State, PollId, EventId, Status. There is no behavior, an event is an immutable fact.
    • -
    • Why it's built this way: the base carries the event id and timestamp; collapsing the transition matrix into one typed record keeps the outbox schema and the handler set small while still letting handlers distinguish an open from a close (ADR-003 for the outbox that drains these; ADR-039 for the live-channel transport that rebroadcasts them).
    • -
    • Where it's used: raised inside LivePoll's Create / Open / Close / Delete (LivePoll.cs:94,131,154,232); drained by the outbox and rebroadcast onto the SignalR live channel.
    • +
    • Concept introduced, one event carrying a state discriminator (BR-60). [Rubric §6, CQRS & Event-Driven] assesses whether events carry enough context to be acted on without a re-read. Rather than four separate Created / Opened / Closed / Deleted events, this codebase raises one event whose DomainEntityState says what kind of change happened and whose LivePollStatus says the resulting lifecycle state (doc comment, LivePollChanged.cs:7-11). A consumer switches on those two fields. This BR-60 convention is shared by all four live-layer events below, so learn it once here.
    • +
    • Walkthrough: a sealed record class deriving from BaseDomainEvent with four positional members (LivePollChanged.cs:17-22): State (:18), PollId (:19), EventId (:20), Status (:21). There is no behavior; an event is an immutable fact.
    • +
    • Why it's built this way: the base carries the event identity and timestamp, and collapsing the transition matrix into one typed record keeps the outbox schema and the handler set small while still letting a handler distinguish an open from a close (ADR-003 for the outbox that drains domain events; ADR-039 for the live-channel transport).
    • +
    • Where it's used: raised inside LivePoll's Create / Open / Close / Delete (LivePoll.cs:94,131,154,232).
    • +
    • Caveats / not-in-source: unlike its three siblings, LivePollChanged has no IDomainEventHandler<LivePollChanged> implementation anywhere in the ADC source today. Poll lifecycle broadcasts are enqueued directly by the poll command handlers; the event is raised and dispatched, but nothing in-repo subscribes to it.

    LivePollVoteChanged

    -

    MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.LivePolls.DomainEvents · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/DomainEvents/LivePollVoteChanged.cs:15 · Level 2 · record

    +

    MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.LivePolls.DomainEvents · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/DomainEvents/LivePollVoteChanged.cs:21 · Level 2 · record

    • What it is: the single domain event a LivePollVote raises when a vote is cast, changed to another option, or soft-deleted.
    • Depends on: BaseDomainEvent, DomainEntityState, and the LivePollVoteIdentifierType / LivePollIdentifierType / LivePollOptionIdentifierType / UserIdentifierType aliases.
    • -
    • Concept reinforced, BR-60 single-event pattern (introduced at LivePollChanged). [Rubric §6, CQRS & Event-Driven]. Here the payload additionally carries the OptionId chosen after the change, so a downstream tally re-computation knows which option moved.
    • -
    • Walkthrough: a sealed record class : BaseDomainEvent with five positional members (LivePollVoteChanged.cs:15-21): State, VoteId, PollId, OptionId, UserId.
    • -
    • Why it's built this way: votes are high-frequency, so the event stays a thin id-only fact (no denormalized counts); consumers that need tallies recompute them via LivePollResultsBuilder.
    • -
    • Where it's used: raised inside LivePollVote's Create / ChangeOption / Reactivate / Delete (LivePollVote.cs:66,85,108,124).
    • +
    • Concept introduced, the zero-id trap on Added events. [Rubric §6, CQRS & Event-Driven] also covers whether a consumer can actually correlate an event back to its row. This entity's identity is database-generated ([IdValueGenerated], see LivePollVote), and the event is constructed before the INSERT runs and captured by value, so VoteId is zero for a brand-new vote and is never re-stamped afterwards (LivePollVoteChanged.cs:11-17). A reactivated vote does carry a real id, because that row already exists. The documented contract is therefore: correlate on PollId and UserId, which are both set before the event is raised, and never on VoteId. The same trap and the same workaround appear on SessionQuestionChanged and SessionQuestionUpvoteChanged.
    • +
    • Concept reinforced, BR-60 single-event pattern (introduced at LivePollChanged; restated at LivePollVoteChanged.cs:8). Here the payload additionally carries the OptionId chosen after the change, so a downstream tally recomputation knows which option moved.
    • +
    • Walkthrough: a sealed record class : BaseDomainEvent with five positional members (LivePollVoteChanged.cs:21-27): State (:22), VoteId (:23), PollId (:24), OptionId (:25), UserId (:26).
    • +
    • Why it's built this way: votes are high-frequency, so the event stays a thin id-only fact with no denormalized counts; consumers that need tallies recompute them through LivePollResultsBuilder.
    • +
    • Where it's used: raised inside LivePollVote's Create / ChangeOption / Reactivate / Delete (LivePollVote.cs:67,86,109,125); consumed by LivePollVoteChangedHandler, which rebuilds the tallies and enqueues a poll.results-changed broadcast (LivePollVoteChangedHandler.cs:16-17,41,47).

    SessionQuestionChanged

    -

    MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.SessionQuestions.DomainEvents · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/SessionQuestions/DomainEvents/SessionQuestionChanged.cs:17 · Level 2 · record

    +

    MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.SessionQuestions.DomainEvents · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/SessionQuestions/DomainEvents/SessionQuestionChanged.cs:30 · Level 2 · record

    • What it is: the single domain event a SessionQuestion raises when it is submitted, moderated, or soft-deleted.
    • -
    • Depends on: BaseDomainEvent, DomainEntityState, QuestionStatus, and the SessionQuestionIdentifierType / SessionIdentifierType aliases.
    • -
    • Concept reinforced, BR-60 single-event pattern (see LivePollChanged). [Rubric §6, CQRS & Event-Driven]. The Q&A analogue of LivePollChanged: QuestionStatus rides along so a handler can tell a Submitted question from an Approved / Dismissed / Answered one (doc comment, SessionQuestionChanged.cs:7-11).
    • -
    • Walkthrough: a sealed record class : BaseDomainEvent with four positional members (SessionQuestionChanged.cs:17-22): State, QuestionId, SessionId, Status.
    • -
    • Why it's built this way: identical rationale to LivePollChanged, a compact lifecycle fact instead of five per-transition event types.
    • -
    • Where it's used: raised inside the SessionQuestion aggregate on submit and each moderation transition.
    • +
    • Depends on: BaseDomainEvent, DomainEntityState, QuestionStatus, and the SessionQuestionIdentifierType / SessionIdentifierType / UserIdentifierType aliases.
    • +
    • Concept reinforced, BR-60 single-event pattern (see LivePollChanged). [Rubric §6, CQRS & Event-Driven]. The Q&A analogue of LivePollChanged: QuestionStatus rides along so a handler can tell a Submitted question from an Approved / Dismissed / Answered one (doc comment, SessionQuestionChanged.cs:8-11).
    • +
    • Concept reinforced, the zero-id trap (see LivePollVoteChanged). QuestionId is zero on the Added path because the identity is generated by the INSERT and the event is captured while the aggregate is still new (SessionQuestionChanged.cs:16-22). This is exactly why UserId is on the event at all: it is carried rather than read back from the row precisely because QuestionId is unusable on that path (:24-28). [Rubric §30, Compliance/Privacy/Data Governance] is worth noting here: the same doc comment states UserId is never surfaced on a DTO, because questions display anonymously (BR-238). The event is an internal correlation channel, not a projection source.
    • +
    • Walkthrough: a sealed record class : BaseDomainEvent with five positional members (SessionQuestionChanged.cs:30-36): State (:31), QuestionId (:32), SessionId (:33), UserId (:34), Status (:35).
    • +
    • Why it's built this way: identical rationale to LivePollChanged, a compact lifecycle fact instead of five per-transition event types, with the submitter id added as the only reliable correlation key on the create path.
    • +
    • Where it's used: raised inside SessionQuestion on create, on each moderation transition, and on delete (SessionQuestion.cs:109,134,158,190,234); consumed by SessionQuestionSubmittedPointsHandler, which must filter to the submission case because the same event also fires for moderation and deletion (SessionQuestionSubmittedPointsHandler.cs:15,53).

    SessionQuestionUpvoteChanged

    -

    MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.SessionQuestions.DomainEvents · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/SessionQuestions/DomainEvents/SessionQuestionUpvoteChanged.cs:14 · Level 2 · record

    +

    MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.SessionQuestions.DomainEvents · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/SessionQuestions/DomainEvents/SessionQuestionUpvoteChanged.cs:20 · Level 2 · record

    • What it is: the single domain event a SessionQuestionUpvote raises when an upvote is cast, reactivated, or removed (soft-deleted).
    • Depends on: BaseDomainEvent, DomainEntityState, and the SessionQuestionUpvoteIdentifierType / SessionQuestionIdentifierType / UserIdentifierType aliases.
    • -
    • Concept reinforced, BR-60 single-event pattern (see LivePollChanged). [Rubric §6, CQRS & Event-Driven]. The thinnest of the four: an upvote has only two meaningful states, so the doc comment notes Added covers cast/reactivated and Deleted covers un-upvoted (SessionQuestionUpvoteChanged.cs:10).
    • -
    • Walkthrough: a sealed record class : BaseDomainEvent with four positional members (SessionQuestionUpvoteChanged.cs:14-19): State, UpvoteId, QuestionId, UserId. No status field, upvotes have no lifecycle beyond active/removed.
    • -
    • Why it's built this way: same BR-60 economy as its siblings; because there is no status enum, the DomainEntityState alone fully describes the change.
    • -
    • Where it's used: raised inside the SessionQuestionUpvote aggregate on cast / reactivate / remove.
    • +
    • Concept reinforced, BR-60 single-event pattern (see LivePollChanged) plus the zero-id trap (see LivePollVoteChanged). [Rubric §6, CQRS & Event-Driven]. This is the thinnest of the four: an upvote has only two meaningful states, so the doc comment notes Added covers both cast and reactivated while Deleted covers un-upvoted (SessionQuestionUpvoteChanged.cs:10), and UpvoteId carries the same "zero on a brand-new row, real on a reactivation" caveat with QuestionId and UserId as the correlation keys (:11-17).
    • +
    • Walkthrough: a sealed record class : BaseDomainEvent with four positional members (SessionQuestionUpvoteChanged.cs:20-25): State (:21), UpvoteId (:22), QuestionId (:23), UserId (:24). No status field: upvotes have no lifecycle beyond active and removed.
    • +
    • Why it's built this way: same BR-60 economy as its siblings, and because there is no status enum the DomainEntityState alone fully describes the change.
    • +
    • Where it's used: raised inside SessionQuestionUpvote on create, reactivate, and delete (SessionQuestionUpvote.cs:60,76,91); consumed by SessionQuestionUpvoteChangedHandler.

    LivePollAuthorization

    MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.LivePolls.Services · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/Services/LivePollAuthorization.cs:12 · Level 3 · class (static, internal)

      -
    • What it is: the one shared rights check for the whole live layer: decides whether a caller may manage (author, open, close, moderate) content in a given scope.
    • +
    • What it is: the one shared rights check for the whole live layer. It decides whether a caller may manage (author, open, close, moderate) content in a given scope.
    • Depends on: SessionLiveInfo (the Conference-owned session snapshot it inspects), Result and Error.
    • -
    • Concept introduced, the BR-236 rights shape as one authorization gate. [Rubric §11, Security] assesses whether authorization is centralized and consistent rather than re-implemented per endpoint. Every live-layer mutation (poll create/open/close and question moderate) routes its rights decision through this single method, so the rule "organizers/admins do everything; a speaker manages only content scoped to a session they are assigned to" lives in exactly one place. [Rubric §1, SOLID] (single responsibility: authorization is not smeared across handlers). [Rubric §7, Microservices Readiness]: the speaker-assignment fact comes from the Conference service via SessionLiveInfo.SpeakerIds, so this check consumes a cross-service snapshot rather than reaching into another module's tables.
    • -
    • Walkthrough: one static method EnsureCanManage(bool callerIsOrganizer, SpeakerIdentifierType? callerSpeakerId, SessionLiveInfo? sessionInfo, string source) (LivePollAuthorization.cs:22-44). Order matters: an organizer/admin short-circuits to Result.Success() (:28-31); otherwise, if a session scope is supplied and the caller has a speaker id and that id is in sessionInfo.SpeakerIds (:33-35), success; anything else returns Error.Forbidden("LivePoll.NotAuthorized", …) (:40-43). Passing sessionInfo as null (event-wide scope) means only organizers/admins pass, exactly the intent for event-wide polls.
    • -
    • Why it's built this way: a pure static helper keeps the rule dependency-free and trivially unit-testable, and the explicit source parameter threads the calling handler name into the error for stack-free tracing (the codebase's invariant-error convention).
    • -
    • Where it's used: called by ModerateQuestionHandler (ModerateQuestionHandler.cs:55-56) and, per its doc comment, by the poll create/open/close handlers (the BR-236 shape referenced from LivePollsController).
    • +
    • Concept introduced, the BR-236 rights shape as one authorization gate. [Rubric §11, Security] assesses whether authorization is centralized and consistent rather than re-implemented per endpoint. Every live-layer mutation routes its rights decision through this single method, so the rule "organizers and admins manage everything; a speaker manages only content scoped to a session they are assigned to" lives in exactly one place (doc comment, LivePollAuthorization.cs:7-10). [Rubric §1, SOLID]: authorization is one responsibility, not smeared across five handlers. [Rubric §7, Microservices Readiness]: the speaker-assignment fact arrives as SessionLiveInfo.SpeakerIds from the Conference service, so this check consumes a cross-service snapshot rather than reaching into another module's tables.
    • +
    • Walkthrough: one static method, EnsureCanManage(bool callerIsOrganizer, SpeakerIdentifierType? callerSpeakerId, SessionLiveInfo? sessionInfo, string source) (LivePollAuthorization.cs:22-44). Order matters. An organizer or admin short-circuits to Result.Success() (:28-31). Otherwise, if a session scope is supplied and the caller has a speaker id and that id is in sessionInfo.SpeakerIds (:33-35), success. Anything else returns Error.Forbidden("LivePoll.NotAuthorized", …) carrying the caller-supplied source (:40-43). Passing sessionInfo as null (event-wide scope) means only organizers and admins pass, which is exactly the intent for event-wide polls (:15-16).
    • +
    • Why it's built this way: a pure static helper keeps the rule dependency-free and trivially unit-testable, and the explicit source parameter threads the calling handler name into the error, which is this codebase's convention for stack-free tracing.
    • +
    • Where it's used: five call sites across both live-layer verticals: CreateLivePollHandler (CreateLivePollHandler.cs:54,64), OpenLivePollHandler (OpenLivePollHandler.cs:58,68), CloseLivePollHandler (CloseLivePollHandler.cs:53,60), GetModerationQueueHandler (GetModerationQueueHandler.cs:37), and ModerateQuestionHandler (ModerateQuestionHandler.cs:59). The Q&A moderation queue is a read that still runs the check, which is the point of centralizing it: moderator-only reads and writes cannot drift apart.

    LivePollInvariants

    -

    MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.LivePolls · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/LivePollInvariants.cs:9 · Level 4 · class (static)

    +

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

      -
    • What it is: the invariant helper for LivePoll and LivePollOption: it owns the poll's field-length and option-count constants and the Result-returning checks that guard them (BR-220).
    • +
    • What it is: the invariant helper for LivePoll and LivePollOption. It owns the poll's field-length and option-count constants and the Result-returning checks that guard them (BR-220).
    • Depends on: CommonInvariants (for the shared EnsureIdIsNotDefault), Result, Error.
    • -
    • Concept reinforced, the shared-constants invariant class (introduced by AddressInvariants in Group 02). [Rubric §16, Maintainability & Evolvability] (one place to change a constraint) and [Rubric §4, DDD] (invariants owned by the domain). The four public constants, QuestionMaxLength = 200, OptionTextMaxLength = 100, MinOptions = 2, MaxOptions = 10 (LivePollInvariants.cs:12-21), are the single source of truth reused by the factory checks here and by the EF configuration and any validator.
    • -
    • Walkthrough: five static check methods, each returning Result. EnsureEventIdIsValid (:23) delegates to CommonInvariants.EnsureIdIsNotDefault; EnsureQuestionIsValid (:26) rejects empty or over-length questions; EnsureOptionTextIsValid (:35) does the same per option; EnsureOptionCountIsValid (:44) enforces the 2-10 range with a C# range pattern (count is < MinOptions or > MaxOptions); EnsureOptionTextsAreUnique (:53) groups the texts case-insensitively (StringComparer.OrdinalIgnoreCase) and fails if any group has a duplicate. Each failure carries a stable code (e.g. "LivePoll.Options.Duplicate") and the source for tracing.
    • +
    • Concept reinforced, the shared-constants invariant class (introduced in Group 02). [Rubric §16, Maintainability & Evolvability] (one place to change a constraint) and [Rubric §4, DDD] (invariants owned by the domain). The four public constants, QuestionMaxLength = 200 (LivePollInvariants.cs:12), OptionTextMaxLength = 100 (:15), MinOptions = 2 (:18), and MaxOptions = 10 (:21), are the single source of truth reused by the factory checks here and by anything else that needs the same numbers.
    • +
    • Walkthrough: five static check methods, each returning Result.
        +
      • EnsureEventIdIsValid (:23-24) delegates to CommonInvariants.EnsureIdIsNotDefault with the code "LivePoll.EventId.Invalid".
      • +
      • EnsureQuestionIsValid (:26-33) rejects whitespace-only or over-length questions and interpolates QuestionMaxLength into the message (:30), so the message can never disagree with the constant.
      • +
      • EnsureOptionTextIsValid (:35-42) does the same per option against OptionTextMaxLength.
      • +
      • EnsureOptionCountIsValid (:44-51) enforces the 2 to 10 range with a C# range pattern, count is < MinOptions or > MaxOptions (:45).
      • +
      • EnsureOptionTextsAreUnique (:53-60) groups the texts case-insensitively with StringComparer.OrdinalIgnoreCase and fails if any group has more than one member (:54), so "Yes" and "yes" cannot both be options on the same poll. + Each failure carries a stable code (for example "LivePoll.Options.Duplicate"), a source, and a target for tracing.
      • +
      +
    • Why it's built this way: separating the constants and checks from the entity lets EF configuration and validators reference LivePollInvariants.QuestionMaxLength without depending on the LivePoll type itself, keeping the constraint values in lockstep across layers.
    • -
    • Where it's used: combined via Result.Combine inside LivePoll.Create (LivePoll.cs:72-76) and LivePollOption.Create (LivePollOption.cs:45).
    • +
    • Where it's used: combined through Result.Combine inside LivePoll.Create (LivePoll.cs:72-76) and singly inside LivePollOption.Create (LivePollOption.cs:45).

    LivePollVoteInvariants

    -

    MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.LivePolls · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/LivePollVoteInvariants.cs:9 · Level 4 · class (static)

    +

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

    • What it is: the invariant helper for LivePollVote: three id-presence checks.
    • Depends on: CommonInvariants, Result.
    • -
    • Concept reinforced, the shared-constants invariant class (see LivePollInvariants). [Rubric §4, DDD]. This is the compact sibling: a vote has no free-text fields, so all three methods, EnsurePollIdIsValid (:11), EnsureOptionIdIsValid (:14), EnsureUserIdIsValid (:17), just delegate to CommonInvariants.EnsureIdIsNotDefault with a vote-specific error code.
    • -
    • Walkthrough: three one-line static methods returning Result; each rejects a default (zero/empty) identifier with a code like "LivePollVote.OptionId.Invalid".
    • -
    • Why it's built this way: even a trivial guard is expressed as a named invariant so the factory reads as a Result.Combine of intent, and every id-presence failure produces a consistent, traceable error.
    • -
    • Where it's used: combined inside LivePollVote.Create (LivePollVote.cs:54-57); EnsureOptionIdIsValid is also called on its own by ChangeOption and Reactivate (LivePollVote.cs:79,99).
    • +
    • Concept reinforced, the shared-constants invariant class (see LivePollInvariants). [Rubric §4, DDD]. This is the compact sibling: a vote has no free-text fields and no counts, so it declares no constants and all three methods just delegate to CommonInvariants.EnsureIdIsNotDefault with a vote-specific error code.
    • +
    • Walkthrough: three one-line static methods returning Result: EnsurePollIdIsValid (LivePollVoteInvariants.cs:11-12, code "LivePollVote.PollId.Invalid"), EnsureOptionIdIsValid (:14-15, code "LivePollVote.OptionId.Invalid"), and EnsureUserIdIsValid (:17-18, code "LivePollVote.UserId.Invalid"). Each rejects a default (zero or empty) identifier and passes nameof(...) as the error target.
    • +
    • Why it's built this way: even a trivial guard is expressed as a named invariant so the factory reads as a Result.Combine of intent rather than a stack of ifs, and every id-presence failure produces a consistent, traceable error code.
    • +
    • Where it's used: combined inside LivePollVote.Create (LivePollVote.cs:53-56); EnsureOptionIdIsValid is also called on its own by ChangeOption and Reactivate (LivePollVote.cs:80,100).

    LivePollVote

    -

    MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.LivePolls · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/LivePollVote.cs:19 · Level 5 · class (sealed aggregate root)

    +

    MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.LivePolls · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/LivePollVote.cs:19 · Level 7 · class (sealed aggregate root)

    • What it is: the aggregate root for one user's vote on a live poll. Deliberately a separate aggregate from LivePoll, not a child of it.
    • Depends on: AuditableAggregateRootEntity<TIdentifierType> (base), LivePollVoteChanged, LivePollVoteInvariants, DomainEntityState, IdValueGeneratedAttribute, Result.
    • -
    • Concept introduced, splitting a high-frequency child into its own aggregate for write scalability. [Rubric §12, Performance & Scalability] (assesses contention and change-tracker load) and [Rubric §4, DDD] (aggregate boundaries chosen for consistency, not convenience). The doc comment (LivePollVote.cs:10-18) states the reasoning explicitly: votes are high-frequency attendee writes, so folding them into the LivePoll aggregate would bloat the change tracker and make every vote contend on the poll row. Instead each vote is its own root, and "one active vote per (poll, user)" is enforced by a filtered unique index at the database (BR-225), not by loading sibling votes into memory. [Rubric §8, Data Architecture]: the reactivation-over-reinsert pattern (below) keeps that filtered index from accumulating soft-deleted duplicates.
    • +
    • Concept introduced, splitting a high-frequency child into its own aggregate for write scalability. [Rubric §12, Performance & Scalability] (which assesses contention and change-tracker load) and [Rubric §4, DDD] (aggregate boundaries chosen for consistency, not convenience). The doc comment states the reasoning explicitly (LivePollVote.cs:9-17): votes are high-frequency attendee writes, so folding them into the LivePoll aggregate would bloat the change tracker and make every vote contend on the poll row. Instead each vote is its own root, and "one active vote per (poll, user)" is enforced by a filtered unique index at the database (BR-225), not by loading sibling votes into memory. [Rubric §8, Data Architecture]: the reactivation-over-reinsert pattern below is what keeps that filtered index from tripping over soft-deleted duplicates (ADR-005 for the soft-delete model).
    • Walkthrough
        -
      • Marked [IdValueGenerated] (:19), so the database assigns the identity; the factory sets Id = default and lets SQL Server fill it.
      • -
      • Three private-set FK properties: LivePollId, OptionId, UserId (:23-29), plus the EF parameterless ctor (:32) and a private field ctor (:34).
      • -
      • Create(livePollId, optionId, userId) (:49): combines the three LivePollVoteInvariants id checks, constructs the vote with Id = default, and raises LivePollVoteChanged with DomainEntityState.Added (:66).
      • -
      • ChangeOption(optionId) (:77): the re-vote path while a poll is open (BR-225), validates the new option, reassigns OptionId, and raises the event with DomainEntityState.Updated.
      • -
      • Reactivate(optionId) (:97): the BR-135 pattern, validates the option, calls the base Undelete(), and on success reassigns the option and raises Added, so a user who un-votes then re-votes reuses the same soft-deleted row instead of inserting a new one.
      • -
      • Delete() (:119): overrides the base soft-delete and raises LivePollVoteChanged with DomainEntityState.Deleted; the row stays, IsDeleted flips.
      • +
      • Marked [IdValueGenerated] (:18), so the database assigns the identity; the factory sets Id = default and lets SQL Server fill it in.
      • +
      • Three FK properties with private setters: LivePollId (:22), OptionId (:25), UserId (:28), plus the EF parameterless constructor (:31) and a private field constructor (:33-38).
      • +
      • Create(livePollId, optionId, userId) (:48): combines the three LivePollVoteInvariants id checks (:53-56), constructs the vote with Id = default (:60-63), and raises LivePollVoteChanged with DomainEntityState.Added (:67). The comment immediately above that line (:65-66) is the source of the zero-id contract described at LivePollVoteChanged.
      • +
      • ChangeOption(optionId) (:78): the re-vote path while a poll is open (BR-225). It validates the new option (:80), reassigns OptionId (:84), and raises the event with DomainEntityState.Updated (:86). Note there is no lifecycle guard here: whether the poll is still open is checked by LivePoll.CanAcceptVote before this is called.
      • +
      • Reactivate(optionId) (:98): the BR-135 pattern. It validates the option (:100), calls the base Undelete() (:104), and only on success reassigns the option and raises Added (:106-110), so a user who un-votes and then re-votes reuses the same soft-deleted row instead of inserting a new one that would collide with the filtered unique index.
      • +
      • Delete() (:120): overrides the base soft-delete, calls base.Delete() first (:122), and raises LivePollVoteChanged with DomainEntityState.Deleted only when that succeeded (:124-125). The row stays; IsDeleted flips.
    • -
    • Why it's built this way: separating the write-hot vote from the read-hot poll is the central scalability decision of the poll subsystem; combined with the filtered unique index and reactivation, a poll can absorb a burst of conference-day votes without serializing them on one row.
    • -
    • Where it's used: written by the cast-vote handler; tallied (read-side) by LivePollResultsBuilder via a grouped COUNT.
    • +
    • Why it's built this way: separating the write-hot vote from the read-hot poll is the central scalability decision of the poll subsystem. Combined with the filtered unique index and reactivation, a poll can absorb a burst of conference-day votes without serializing them on one row.
    • +
    • Where it's used: created by CastVoteHandler (CastVoteHandler.cs:75); tallied on the read side by LivePollResultsBuilder through a grouped COUNT.

    LivePoll

    -

    MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.LivePolls · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/LivePoll.cs:18 · Level 6 · class (sealed aggregate root)

    +

    MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.LivePolls · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/LivePoll.cs:18 · Level 8 · class (sealed aggregate root)

      -
    • What it is: the aggregate root for a live poll: a question with 2-10 authored options and a strict Draft -> Open -> Closed lifecycle, scoped either to a whole event or to a single session.
    • -
    • Depends on: AuditableAggregateRootEntity<TIdentifierType>, LivePollOption (its child), LivePollChanged, LivePollInvariants, LivePollStatus, DomainEntityState, IdValueGeneratedAttribute, the [Navigation] marker (NavigationAttribute), Result.
    • -
    • Concept introduced, a lifecycle state machine with a snapshotted cross-service fact. [Rubric §4, DDD] (a root that guards its own transitions) and [Rubric §7, Microservices Readiness] (avoiding a synchronous cross-service call on the hot vote path). The lifecycle is enforced as explicit guarded transitions, and Open snapshots the event's live-window end onto the poll (LiveWindowEndUtc, LivePoll.cs:32-36) so later vote checks never need to call the Conference service again (BR-223/BR-224). [Rubric §8, Data Architecture]: the child options are held in an encapsulated list behind a read-only view.
    • +
    • What it is: the aggregate root for a live poll: a question with 2 to 10 authored options and a strict Draft -> Open -> Closed lifecycle, scoped either to a whole event or to a single session.
    • +
    • Depends on: AuditableAggregateRootEntity<TIdentifierType>, LivePollOption (its child), LivePollChanged, LivePollInvariants, LivePollStatus, DomainEntityState, IdValueGeneratedAttribute, the [Navigation] marker (NavigationAttribute), Result and Error.
    • +
    • Concept introduced, a lifecycle state machine with a snapshotted cross-service fact. [Rubric §4, DDD] (a root that guards its own transitions) and [Rubric §7, Microservices Readiness] (avoiding a synchronous cross-service call on the hot vote path). The lifecycle is enforced as explicit guarded transitions, and Open snapshots the event's live-window end onto the poll (LiveWindowEndUtc, LivePoll.cs:32-36) so later vote checks never call the Conference service again (BR-223/BR-224, doc comment :10-15). [Rubric §8, Data Architecture]: the child options are held in an encapsulated List<T> exposed only as a read-only view.
    • Walkthrough
        -
      • [IdValueGenerated] (:17); properties EventId, SessionId? (null = event-wide, BR-230), Question, Status, and LiveWindowEndUtc? all have private setters (:20-36). Options live in a private List<LivePollOption> _options exposed as a read-only [Navigation(IsCollection = true)] Options (:38-42).
      • -
      • Create(eventId, sessionId, question, optionTexts) (:64): null-checks the texts, combines four LivePollInvariants checks (event id, question, option count, option uniqueness), constructs the poll as Draft, then builds each LivePollOption in display order (:85-92), and raises LivePollChanged Added.
      • -
      • Open(nowUtc, liveWindowStartUtc, liveWindowEndUtc) (:108): rejects any non-Draft poll (LivePoll.InvalidTransition) and any attempt outside the live window (LivePoll.OutsideLiveWindow), then flips to Open and snapshots LiveWindowEndUtc (:128-129).
      • -
      • Close() (:141): Open-only, no reopen, flips to Closed.
      • -
      • CanAcceptVote(nowUtc, optionId) (:167): the guard the vote handler calls, requires Open status, nowUtc before the snapshotted window end, and the option to exist and be non-deleted on this poll (:187); returns a specific Error for each failure. This runs entirely against in-memory state, no cross-service call.
      • -
      • SetOptions(options) (:201): an internal hook that routes through the base SetItems, used only by the navigation populator to rehydrate the collection.
      • -
      • Delete() (:210): refuses to delete an Open poll (BR-228, LivePoll.DeleteWhileOpen), then soft-deletes the poll and cascade soft-deletes each non-deleted option before raising LivePollChanged Deleted.
      • +
      • [IdValueGenerated] (:17); the properties EventId (:21), SessionId? (:24, null means event-wide, BR-230), Question (:27), Status (:30), and LiveWindowEndUtc? (:36) all have private setters. Options live in a private List<LivePollOption> _options (:38) exposed as [Navigation(IsCollection = true)] IReadOnlyCollection<LivePollOption> Options => _options.AsReadOnly() (:41-42).
      • +
      • Create(eventId, sessionId, question, optionTexts) (:64): null-checks the texts (:70), combines four LivePollInvariants checks, event id, question, option count, and option uniqueness (:72-76), constructs the poll with Id = default and Status = Draft (private constructor at :47-53, object initializer at :80-83), then builds each LivePollOption in display order using the loop index as Sort (:85-92), and raises LivePollChanged Added (:94).
      • +
      • Open(nowUtc, liveWindowStartUtc, liveWindowEndUtc) (:108): rejects any non-Draft poll with "LivePoll.InvalidTransition" (:110-117) and any attempt outside the live window with "LivePoll.OutsideLiveWindow" (:119-126; note the end bound is exclusive, nowUtc >= liveWindowEndUtc fails), then flips Status to Open and snapshots LiveWindowEndUtc (:128-129) before raising Updated (:131).
      • +
      • Close() (:141): Open only, and no reopen path exists (:143-150); flips to Closed (:152) and raises Updated (:154).
      • +
      • CanAcceptVote(nowUtc, optionId) (:167): the guard the vote handler calls. It requires Open status (:169-176), requires nowUtc to be strictly before a snapshotted window end that is actually set (:178-185), and requires the option to exist, be non-deleted, and belong to this poll (:187-194). Each failure returns its own Error code. This runs entirely against in-memory state, with no cross-service call.
      • +
      • SetOptions(options) (:201-202): an internal hook that routes through the base SetItems, used only by LivePollNavigationPopulator to rehydrate the collection.
      • +
      • Delete() (:210): refuses to delete an Open poll (BR-228, "LivePoll.DeleteWhileOpen", :212-219), then soft-deletes the poll via the base (:221) and cascade soft-deletes each non-deleted option (:225-230) before raising LivePollChanged Deleted (:232).
    • -
    • Why it's built this way: snapshotting the live-window end at Open trades a tiny bit of staleness for removing a synchronous Conference call from every vote, and the explicit transition guards mean an invalid lifecycle move is impossible regardless of which handler calls in (ADR-007 for the gRPC boundary this snapshot sidesteps).
    • -
    • Where it's used: created/opened/closed/deleted by the poll handlers behind LivePollsController; its options rehydrated by LivePollNavigationPopulator; tallied by LivePollResultsBuilder.
    • +
    • Why it's built this way: snapshotting the live-window end at Open trades a small amount of staleness for removing a synchronous Conference call from every single vote (ADR-007 describes the gRPC boundary this sidesteps), and the explicit transition guards make an invalid lifecycle move impossible regardless of which handler calls in. Refusing to delete an open poll rather than silently closing it means a delete can never end a running vote behind the audience's back.
    • +
    • Where it's used: created, opened, closed, and deleted by CreateLivePollHandler, OpenLivePollHandler, and CloseLivePollHandler behind LivePollsController; its options rehydrated by LivePollNavigationPopulator; tallied by LivePollResultsBuilder.

    LivePollOption

    -

    MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.LivePolls · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/LivePollOption.cs:13 · Level 6 · class (sealed child entity)

    +

    MMCA.ADC.Engagement.Domain · MMCA.ADC.Engagement.Domain.LivePolls · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/LivePolls/LivePollOption.cs:13 · Level 8 · class (sealed child entity)

      -
    • What it is: a single answer option belonging to a LivePoll: display text plus a sort order, authored with the poll and immutable afterward.
    • +
    • What it is: a single answer option belonging to a LivePoll: display text plus a sort order, authored with the poll and immutable afterwards.
    • Depends on: AuditableBaseEntity<TIdentifierType> (note: a plain auditable child, not an aggregate root), LivePollInvariants, IdValueGeneratedAttribute, the [Navigation] marker (NavigationAttribute), Result.
    • -
    • Concept reinforced, the child entity inside an aggregate boundary. [Rubric §4, DDD]. Unlike LivePollVote, an option is a genuine child of the poll: it derives from AuditableBaseEntity<TIdentifierType> (no domain-event list of its own) and is only ever created and soft-deleted through its parent LivePoll. It carries a back-reference [Navigation] LivePoll? and an FK LivePollId (LivePollOption.cs:22-26).
    • -
    • Walkthrough: [IdValueGenerated] (:12); Text and Sort with private setters (:16-19); EF ctor and private field ctor (:29-35). The only factory, Create(text, sort) (:43), validates the text via LivePollInvariants.EnsureOptionTextIsValid and constructs the option with Id = default. There is no mutation method, immutability is enforced by omission (the doc comment, :9-10, says re-author the Draft poll instead).
    • -
    • Why it's built this way: modeling the option as an immutable child keeps the poll's consistency boundary simple, tally math only ever adds new options via re-authoring, never mutates an existing option's meaning under a live vote count.
    • -
    • Where it's used: built inside LivePoll.Create and rehydrated by LivePollNavigationPopulator; read by LivePollResultsBuilder to label each tally.
    • +
    • Concept reinforced, the child entity inside an aggregate boundary. [Rubric §4, DDD]. Unlike LivePollVote, an option is a genuine child of the poll: it derives from AuditableBaseEntity<TIdentifierType>, so it has no domain-event list of its own, and it is only ever created and soft-deleted through its parent LivePoll. Its changes are announced by the parent's LivePollChanged, which is the practical meaning of "inside the boundary".
    • +
    • Walkthrough: [IdValueGenerated] (:12); Text (:16) and Sort (:19) have private setters; the back-reference [Navigation] public LivePoll? LivePoll { get; set; } (:22-23) is a settable navigation because the populator assigns it, while the FK LivePollId (:26) is get-only and is written by EF Core. The EF parameterless constructor seeds Text to string.Empty to satisfy nullability (:29), and a private field constructor takes the two real values (:31-35). The only factory, Create(text, sort) (:43), validates through LivePollInvariants.EnsureOptionTextIsValid (:45) and constructs the option with Id = default (:49-52). There is no mutation method: immutability is enforced by omission, and the doc comment says to re-author the Draft poll instead (:8-10).
    • +
    • Why it's built this way: modeling the option as an immutable child keeps the poll's consistency boundary simple. Tally math only ever gains new options through re-authoring, so an existing option's meaning can never change under a live vote count.
    • +
    • Where it's used: built inside LivePoll.Create (LivePoll.cs:87) and cascade-deleted by LivePoll.Delete (LivePoll.cs:227); rehydrated by LivePollNavigationPopulator; its own back-reference filled by LivePollOptionNavigationPopulator; read by LivePollResultsBuilder to label and order each tally.
    -

    LivePollResultsBuilder

    +

    ModerateQuestionHandler

    -

    MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.LivePolls.Services · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/Services/LivePollResultsBuilder.cs:12 · Level 8 · class (sealed)

    +

    MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.SessionQuestions.UseCases.Moderate · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/Moderate/ModerateQuestionHandler.cs:23 · Level 8 · class (sealed partial)

      -
    • What it is: the shared read-side service that computes a poll's result tallies: per-option active-vote counts, the total, and optionally the caller's own vote.
    • -
    • Depends on: IUnitOfWork (for the read repository), IQueryableExecutor (async materialization), LivePoll / LivePollVote, and the result DTOs LivePollResultsDTO / LivePollOptionResultDTO.
    • -
    • Concept introduced, computing tallies with a grouped SQL COUNT instead of materializing votes. [Rubric §12, Performance & Scalability] (assesses whether hot read paths avoid loading whole tables). The comment at LivePollResultsBuilder.cs:31-33 states the intent: tallies come from a GroupBy(OptionId).Select(Count()) that returns one row per option, so a hot poll no longer re-materializes its entire vote table on every vote, results read, and open-polls listing. Centralizing this in one builder means every surface (CastVote, GetPollResults, GetOpenPolls) computes results identically.
    • -
    • Walkthrough: one method BuildAsync(poll, userId?, ct) (:22). It null-checks the poll, takes a no-tracking read repository for LivePollVote (:29), runs the grouped count over TableNoTracking filtered to this poll (:34-39) and folds it into a countsByOption dictionary (:41). The caller's own vote is a separate point read issued only when userId is non-null (broadcast payloads pass null and skip it, BR-229, :44-53). It then projects the poll's non-deleted options ordered by Sort into LivePollOptionResultDTOs, filling each VoteCount from the dictionary (:55-64), and returns a LivePollResultsDTO with poll id/question/status, TotalVotes (sum of the counts), the options, and MyVoteOptionId (:66-74).
    • -
    • Why it's built this way: the grouped count keeps the tally cost proportional to option count, not vote count; skipping the "my vote" read for broadcast payloads (which have no single caller) avoids a pointless query on the fan-out path.
    • -
    • Where it's used: injected into the cast-vote, poll-results, and open-polls handlers so all three return the same LivePollResultsDTO shape.
    • -
    • Caveats / not-in-source: Options must already be loaded on the passed LivePoll (via LivePollNavigationPopulator); the builder reads poll.Options directly and does not itself load them.
    • +
    • What it is: the command handler that applies a moderation transition to a SessionQuestion (BR-234), enforcing the BR-236 rights, then best-effort enqueues the matching live-channel event (BR-238) for the off-request-path drain worker.
    • +
    • Depends on: ICommandHandler<in TCommand, TResult>, IUnitOfWork, IEventLiveValidationService (the Conference gRPC boundary for session info), ILiveChannelPublishQueue (ModerateQuestionHandler.cs:26), LiveChannelPublishWorkItem, BestEffort, LivePollAuthorization, the SessionQuestionChannel event names, LivePollChannel for the channel key, the channel payload records (SessionQuestionApprovedPayload, SessionQuestionDismissedPayload, SessionQuestionAnsweredPayload, SessionQuestionPendingCountChangedPayload), plus System.Text.Json and ILogger.
    • +
    • Concept introduced, the best-effort side channel that can never fail the command (BR-238). [Rubric §29, Resilience & Business Continuity] and [Rubric §7, Microservices Readiness]: a downstream service being unreachable must not fail the local write. The mutation is committed first (:80); only then is the broadcast handed to the queue, and that work runs inside BestEffort.ExecuteAsync (:136) rather than a hand-rolled try/catch. Read the guard precisely: Enqueue is a void call that never rejects, so what the guard actually covers is the Pending-count follow-up's database read (:143-146, rationale at :89-102). [Rubric §13, Observability & Operability]: using the shared helper means a broadcast that has quietly stopped working increments besteffort.dispatch.failed on a meter, tagged with the low-cardinality operation constant "session-question-moderation-broadcast" (:30), instead of only producing a log line (BestEffort.cs:18-23). The caller's CancellationToken is deliberately not passed (:97-100), so the helper's token parameter falls back to its default (BestEffort.cs:45-49) and the broadcast outlives an abandoned request instead of turning a saved moderation into a cancelled one.
    • +
    • Walkthrough
        +
      • HandleAsync (:33): takes a tracked repository and loads the SessionQuestion by id (:37-42), returning Error.NotFound when it is missing (:44-48).
      • +
      • Stamps the client's last-seen rowversion back as the original (ADR-035, :50-53), so two moderators racing approve against dismiss surface as a 409 Conflict rather than the second decision silently applying. A null RowVersion skips the check.
      • +
      • Fetches the session's live info through IEventLiveValidationService (:55-57) and runs LivePollAuthorization.EnsureCanManage (:59-62); a rights failure short-circuits before any state change.
      • +
      • Captures wasPending before the transition (:64), then dispatches the action through a switch expression to the domain methods Approve() / Dismiss() / MarkAnswered() (:66-76); an unknown action becomes an invariant failure rather than a silent no-op.
      • +
      • Persists via SaveChangesAsync (:80), emits the source-generated moderation log (:82, declared at :158-159), then calls the private EnqueueModeratedAsync (:84).
      • +
      • EnqueueModeratedAsync (:104): resolves the session channel key with LivePollChannel.ForSession (:109), then builds the (eventName, payload) pair per action (:116-134). Only universally visible data rides the channel, and the Approve arm is the single place question content is broadcast (:118-122). This switch is built outside the best-effort guard on purpose (:111-115): its discard arm throws ArgumentOutOfRangeException (:133) because an unknown action is a programming error that must fault loudly, not a transient publish failure to be swallowed.
      • +
      • Inside the guard (:136-155) it enqueues the work item (:138), and when a Pending question left the queue on Approve or Dismiss it issues a fresh Pending-count read (:143-146) and enqueues a SessionQuestionPendingCountChangedPayload so moderators' badges update (:148-153).
      -

      ModerateQuestionHandler

      +
    • +
    • Why it's built this way: committing before enqueueing, plus a swallow-and-count guard, gives the live layer at-most-once broadcast semantics layered over a durably committed write, which is the correct trade for ephemeral UI signals that must never block a moderation. Queueing rather than awaiting the publish also keeps a hung Notification peer off the moderator's request path (ADR-039 for the channel transport).
    • +
    • Where it's used: registered for ModerateQuestionCommand and invoked by SessionQuestionsController's approve, dismiss, and mark-answered verbs (SessionQuestionsController.cs:41,238).
    • +
    • Caveats / not-in-source: the switch discard arm at :133 is unreachable in practice, since HandleAsync already applied a known action before enqueueing (the comment at :114-115 says as much).
    • +
    +

    LivePollResultsBuilder

    -

    MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.SessionQuestions.UseCases.Moderate · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/SessionQuestions/UseCases/Moderate/ModerateQuestionHandler.cs:22 · Level 8 · class (sealed partial)

    +

    MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.LivePolls.Services · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/Services/LivePollResultsBuilder.cs:12 · Level 9 · class (sealed)

      -
    • What it is: the command handler that applies a moderation transition to a SessionQuestion (BR-234), enforcing the BR-236 rights, then best-effort enqueues the matching live-channel event (BR-238) for the off-request-path drain worker.
    • -
    • Depends on: ICommandHandler<in TCommand, TResult>, IUnitOfWork, IEventLiveValidationService (the Conference gRPC boundary for session info), ILiveChannelPublishQueue (ModerateQuestionHandler.cs:25, the in-process broadcast queue a hosted drain forwards to the Notification gRPC ingress), LivePollAuthorization, the SessionQuestionChannel event names, the channel payload DTOs (SessionQuestionApprovedPayload and siblings), and ILogger.
    • -
    • Concept introduced, best-effort side-channel broadcast that never fails the command (BR-238). [Rubric §29, Resilience & Business Continuity] and [Rubric §7, Microservices Readiness] (a downstream service being unreachable must not fail the local write). The mutation is committed first via SaveChangesAsync; only then does the handler hand the broadcast to the queue, still inside a try/catch (Exception) that logs and swallows so a Notification outage cannot roll back a moderation (ModerateQuestionHandler.cs:92-149, with a justified #pragma warning disable CA1031 at :143). Read that catch precisely: Enqueue is a void call that never rejects (ILiveChannelPublishQueue, ILiveChannelPublishQueue.cs:30), so what it actually guards is the Pending-count follow-up's database read (:131-133, rationale at :85-91). [Rubric §13, Observability & Operability]: both the success and the swallowed-failure paths emit source-generated [LoggerMessage] logs (:151-155).
    • -
    • Walkthrough
        -
      • HandleAsync (:29): loads the tracked SessionQuestion by id (:34), returns Error.NotFound if missing (:40-44).
      • -
      • Stamps the client's last-seen rowversion back as the original (ADR-035, :46-49), so two moderators racing approve-vs-dismiss surface as 409 Conflict rather than the second decision silently applying.
      • -
      • Fetches the session's live info via IEventLiveValidationService (:51) and runs the LivePollAuthorization.EnsureCanManage rights check (:55-58); a rights failure short-circuits.
      • -
      • Captures wasPending before the transition (:60), then dispatches the action through a switch to the domain method Approve() / Dismiss() / MarkAnswered() (:62-72); an unknown action is an invariant failure.
      • -
      • Persists via SaveChangesAsync (:76), logs the moderation (:78), then calls the private EnqueueModeratedAsync (:80).
      • -
      • EnqueueModeratedAsync (:92): resolves the session channel key via LivePollChannel.ForSession (:100), then builds (eventName, payload) per action, only universally-visible data rides the channel, and the Approve arm is the single place question content is broadcast (:105-123). It hands that work item to ILiveChannelPublishQueue.Enqueue (:125), and when a Pending question left the queue on Approve/Dismiss it issues a fresh Pending-count read and enqueues a SessionQuestionPendingCountChangedPayload so moderators' badges update (:127-141).
      • +
      • What it is: the shared read-side service that computes a poll's result tallies: per-option active-vote counts, the total, the caller's own vote when there is a caller, and the poll's concurrency token.
      • +
      • Depends on: IUnitOfWork (for the read repository), IQueryableExecutor (async materialization without an EF dependency in the Application layer), LivePoll / LivePollVote, and the result DTOs LivePollResultsDTO / LivePollOptionResultDTO.
      • +
      • Concept introduced, computing tallies with a grouped SQL COUNT instead of materializing votes. [Rubric §12, Performance & Scalability] assesses whether hot read paths avoid loading whole tables. The comment at LivePollResultsBuilder.cs:39-41 states the intent: tallies come from a GroupBy(OptionId).Select(Count()) that returns one row per option rather than one row per vote, on a path that runs on every vote, every results read, and every open-polls listing. Centralizing this in one builder means all three surfaces compute results identically (:9-10).
      • +
      • Concept introduced, making the parts add up to the whole. [Rubric §9, API & Contract Design] covers whether a payload is internally consistent. The builder first computes activeOptionIds, the non-deleted options this poll still presents (:34-37), and restricts the count query to them (:44). Votes cast on an option that was later removed are therefore excluded from both the breakdown and the total, and TotalVotes is summed from the same projected list the client sees (:81), so a client computing percentages from the parts always reconciles with the total (comments at :31-33 and :79-80).
      • +
      • Walkthrough: one method, BuildAsync(poll, userId?, cancellationToken) (:22-25).
          +
        • Null-checks the poll (:27) and takes a no-tracking read repository for LivePollVote (:29).
        • +
        • Builds activeOptionIds from poll.Options (:34-37), then runs the grouped count over voteRepo.TableNoTracking filtered to this poll and those options (:42-47) and folds it into a countsByOption dictionary (:49).
        • +
        • The caller's own vote is a separate point read issued only when userId is non-null: broadcast payloads pass null and skip it entirely (BR-229, :51-61). It uses GetProjectedAsync to fetch just the OptionId (:56-59).
        • +
        • Projects the non-deleted options ordered by Sort into LivePollOptionResultDTOs, filling each VoteCount from the dictionary with GetValueOrDefault so an option with zero votes still appears (:63-72).
        • +
        • Returns a LivePollResultsDTO with poll id, question, status, TotalVotes, the options, MyVoteOptionId, and RowVersion (:74-88). That last line is deliberate: the concurrency token travels with the results so a surface fed only by results can still issue an open or close with a real token, and an unset token stays null rather than shipping an empty array that a caller would read as a token (:84-87).
      • -
      • Why it's built this way: committing before enqueueing, plus the swallow-and-log catch, gives the live layer at-most-once broadcast semantics layered over a durably-committed write, the correct trade for ephemeral UI signals that must never block a moderation; queueing rather than awaiting the publish also keeps a hung Notification peer off the moderator's request path (ADR-039 for the channel transport).
      • -
      • Where it's used: registered for ModerateQuestionCommand and invoked by SessionQuestionsController's approve/dismiss/answered verbs.
      • -
      • Caveats / not-in-source: the switch discard arm in EnqueueModeratedAsync throws ArgumentOutOfRangeException (:122) but is unreachable, the handler already applied a known action before enqueueing (noted in the comment, :102-104).
      • +
      • Why it's built this way: the grouped count keeps the tally cost proportional to option count rather than vote count; skipping the "my vote" read for broadcast payloads (which have no single caller) avoids a pointless query on the fan-out path.
      • +
      • Where it's used: registered as scoped in the module's DI (DependencyInjection.cs:68) and injected into CastVoteHandler (CastVoteHandler.cs:21), GetPollResultsHandler (GetPollResultsHandler.cs:15), and GetOpenPollsHandler (GetOpenPollsHandler.cs:17), and resolved out of a fresh scope by LivePollVoteChangedHandler for the results broadcast (LivePollVoteChangedHandler.cs:55).
      • +
      • Caveats / not-in-source: Options must already be loaded on the passed LivePoll (via LivePollNavigationPopulator or an explicit include). The builder reads poll.Options directly and does not load them itself; the XML doc says so at :15-16.

      LivePollNavigationPopulator

      MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.LivePolls.Services · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/Services/LivePollNavigationPopulator.cs:11 · Level 10 · class (sealed)

        -
      • What it is: the declarative navigation populator that manually loads a LivePoll's Options collection on query-service paths where EF Core .Include() is not applied.
      • +
      • What it is: the declarative navigation populator that loads a LivePoll's Options collection on query-service paths where EF Core .Include() is not applied.
      • Depends on: DeclarativeNavigationPopulator<TEntity> (base), ChildNavigationDescriptor<TEntity, TParentId, TChild, TChildId> (the descriptor), IUnitOfWork, LivePoll / LivePollOption.
      • -
      • Concept reinforced, declarative navigation population (ADR-002). [Rubric §2, Design Patterns]. The framework's entity-query path returns entities without EF Includes; a populator declares, in data, which child collections to rehydrate and how. This is the whole class: it subclasses DeclarativeNavigationPopulator<LivePoll> and passes exactly one ChildNavigationDescriptor for Options (LivePollNavigationPopulator.cs:11-23).
      • -
      • Walkthrough: a primary constructor takes IUnitOfWork and forwards a single-element descriptor array to the base (:13-22). The descriptor wires PropertyName = nameof(LivePoll.Options), ParentKeySelector = p => p.Id, ChildForeignKeySelector = child => child.LivePollId, and AssignAction = (p, options) => p.SetOptions(options), the last calling the aggregate's internal SetOptions so the collection is rehydrated through the root's own SetItems guard rather than by writing the backing field directly. The class body is empty; all behavior lives in the base.
      • -
      • Why it's built this way: expressing the load as a descriptor (not hand-written query code) keeps every populator uniform and lets the base handle batching and assignment; routing the assignment through SetOptions preserves the aggregate boundary even during rehydration.
      • -
      • Where it's used: resolved and run by the query-service pipeline before LivePollResultsBuilder reads poll.Options, and behind the poll read endpoints on LivePollsController.
      • +
      • Concept reinforced, declarative navigation population (ADR-002). [Rubric §2, Design Patterns]. The framework's entity-query path returns entities without EF includes; a populator declares, in data, which child collections to rehydrate and how. That is the whole class: it subclasses DeclarativeNavigationPopulator<LivePoll> and passes exactly one ChildNavigationDescriptor (LivePollNavigationPopulator.cs:11-23).
      • +
      • Walkthrough: a primary constructor takes IUnitOfWork and forwards a single-element descriptor array to the base (:11-22). The descriptor wires PropertyName = nameof(LivePoll.Options) (:17), ParentKeySelector = p => p.Id (:18), ChildForeignKeySelector = child => child.LivePollId (:19), and AssignAction = (p, options) => p.SetOptions(options) (:20). That last line calls the aggregate's internal SetOptions, so the collection is rehydrated through the root's own SetItems path rather than by writing the backing field directly. The class body is empty (:23-24); all behavior lives in the base.
      • +
      • Why it's built this way: expressing the load as a descriptor rather than hand-written query code keeps every populator uniform and lets the base own batching and assignment. Routing the assignment through SetOptions preserves the aggregate boundary even during rehydration.
      • +
      • Where it's used: registered as INavigationPopulator<LivePoll> in the module's DI (DependencyInjection.cs:59), so the query pipeline runs it before LivePollResultsBuilder reads poll.Options. Note the sibling registration one line on: LivePollVote gets a NullNavigationPopulator (DependencyInjection.cs:60), because a vote has nothing to rehydrate.
      • +
      +

      LivePollOptionNavigationPopulator

      +
      +

      MMCA.ADC.Engagement.Application · MMCA.ADC.Engagement.Application.LivePolls.Services · MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/LivePolls/Services/LivePollOptionNavigationPopulator.cs:11 · Level 10 · class (sealed)

      +
      +
        +
      • What it is: the mirror-image populator for LivePollOption: it fills the option's back-reference to its parent LivePoll when an option is queried on its own.
      • +
      • Depends on: DeclarativeNavigationPopulator<TEntity> (base), FKNavigationDescriptor<TEntity, TChild, TChildId> (the descriptor), IUnitOfWork, LivePoll / LivePollOption.
      • +
      • Concept reinforced, the two descriptor flavors (see LivePollNavigationPopulator and ADR-002). [Rubric §2, Design Patterns]. This pair is the clearest illustration of the difference anywhere in the module. A ChildNavigationDescriptor walks down from a parent key to many children, while an FKNavigationDescriptor walks up an FK to a single parent, which is why its AssignAction ends in FirstOrDefault().
      • +
      • Walkthrough: a primary constructor takes IUnitOfWork and forwards one FKNavigationDescriptor<LivePollOption, LivePoll, LivePollIdentifierType> to the base (LivePollOptionNavigationPopulator.cs:11-22). The descriptor sets PropertyName = nameof(LivePollOption.LivePoll) (:17), ParentKeySelector = e => e.LivePollId (:18, the FK on the option, which is the inversion relative to the child descriptor), ChildForeignKeySelector = child => child.Id (:19, the poll's own primary key), and AssignAction = (e, livePolls) => e.LivePoll = livePolls.FirstOrDefault() (:20). The class body is empty (:23-24).
      • +
      • Why it's built this way: the base loads parents in one batched query for a whole page of options rather than one query per option, so declaring the relationship in data is what removes the N+1 a naive lazy-loaded back-reference would create. It also explains why LivePollOption.LivePoll is a settable property while its FK LivePollId is get-only: the populator is the writer.
      • +
      • Where it's used: registered as INavigationPopulator<LivePollOption> in the module's DI (DependencyInjection.cs:64).

      SessionQuestionInvariants

      diff --git a/docs/onboarding/group-24-identity-module.html b/docs/onboarding/group-24-identity-module.html index 5cfb1e7..7c9f80b 100644 --- a/docs/onboarding/group-24-identity-module.html +++ b/docs/onboarding/group-24-identity-module.html @@ -151,14 +151,16 @@

      24. ADC Identi but it touches every layer end to end, so this chapter doubles as a compact tour of one full vertical slice built on the framework taught in groups 1 through 15. The single aggregate is User, and around it sit the credential and refresh-token lifecycle, the role vocabulary, - the change-password / change-preferences / avatar use cases, the two privacy use cases that make ADC - compliant (data-subject export and erasure), the persistence and EF configuration, the REST - controllers, the gRPC contract that lets a peer service ask Identity a question, the integration - events that keep the User-to-Speaker link consistent across the service split, and the Blazor profile - and user-list UI. The per-type sections follow; this overview shows how the pieces fit and how a - request flows through them.

      + the change-password / password-recovery / change-preferences / avatar use cases, the two privacy use + cases that make ADC compliant (data-subject export and erasure), the persistence and EF + configuration, the REST controllers, the gRPC contract that lets a peer service ask Identity a + question, the integration events that keep the User-to-Speaker link consistent across the service + split, and the Blazor profile and user-list UI. The per-type sections follow; this overview shows how + the pieces fit and how a request flows through them.

      Almost everything here is an instantiation of upstream framework machinery, cross-referenced rather - than re-taught: the Result pattern (G01), the + than re-taught (the conventions themselves are introduced once in the + primer): the + Result pattern (G01), the AuditableAggregateRootEntity<TIdentifierType> entity chain plus the IAnonymizable and PiiAttribute governance markers (G02), the outbox @@ -167,19 +169,22 @@

      24. ADC Identi (AuthenticationServiceBase<TUser>, RoleValue, HasPermissionAttribute, - SoftDeletedUserCache) from G08, and the hoisted user - use-case bases from G14 + SoftDeletedUserCache, + IPasswordResetTokenService) from G08, and the hoisted + user use-case bases from G14 (ChangePasswordHandlerBase<TUser, TCommand>, + ForgotPasswordHandlerBase<TUser, TCommand>, + ResetPasswordHandlerBase<TUser, TCommand>, GetUserPreferencesHandlerBase<TUser>, DeleteUserHandlerBase<TUser, TCommand>, ExportUserDataHandlerBase<TUser, TQuery>) alongside the IModule composition system. The lenses this chapter most strongly embodies are [Rubric §4, Domain-Driven Design] (a behavior-rich aggregate that guards its own invariants), [Rubric §11, Security] (credential handling, RS256 JWTs, - permission-based authorization, a fail-closed OAuth link gate), and [Rubric §30, Compliance / Privacy - / Data Governance] (the export and erasure flows). The // BR-NN markers quoted below are the - in-code business-requirement references, catalogued in the ADC business-requirements guide; the - privacy promises they implement live in MMCA.ADC/PRIVACY.md.

      + permission-based authorization, a fail-closed OAuth link gate, single-use reset tokens), and [Rubric + §30, Compliance / Privacy / Data Governance] (the export and erasure flows). The // BR-NN markers + quoted below are the in-code business-requirement references, catalogued in the ADC + business-requirements guide; the privacy promises they implement live in MMCA.ADC/PRIVACY.md.

      Projects, one bounded context

      The module is split along the standard Clean Architecture layering ([Rubric §3, Clean Architecture]), each project pinned by a trivial AssemblyReference / @@ -194,10 +199,14 @@

      Projects, one bounded context

      MMCA.ADC.Identity.Application holds the use-case handlers, the Mapperly-generated UserDTOMapper (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/DTOs/UserDTOMapper.cs:13-14, + which excludes PasswordHash, PasswordSalt, and RefreshToken from the UserDTO + projection, :10-11, ADR-001), the FluentValidation - validators, and the cross-module service implementations; its - DependencyInjection registers four services explicitly, including the - shared SoftDeletedUserValidator<TUser> + validators RegisterRequestValidator and + ChangePasswordRequestValidator, and the cross-module service + implementations; its DependencyInjection registers four services explicitly, + including the shared + SoftDeletedUserValidator<TUser> closed over User (MMCA.ADC.Identity.Application/DependencyInjection.cs:33-36), contributes the two export sections in the order they appear in the exported document (:42-43), and leaves handlers, mappers, validators, and domain-event handlers to ScanModuleApplicationServices<ClassReference>() @@ -210,23 +219,26 @@

      Projects, one bounded context

      (MMCA.ADC.Identity.Infrastructure/DependencyInjection.cs:20). MMCA.ADC.Identity.API holds the REST controllers, the IdentityModule descriptor (IdentityModule.cs:13), and the IdentityErrorResources anchor whose .resx siblings translate domain - error codes into the supported languages (IdentityErrorResources.cs:11, + error codes into the supported languages (IdentityErrorResources.cs:11, rationale at :3-9, ADR-027). MMCA.ADC.Identity.Shared is the contract package every other layer (including the WebAssembly - client) can reference without dragging in the domain: it carries the DTOs, the + client) can reference without dragging in the domain: it carries the DTOs + (UserDTO at UserDTO.cs:8, UserListDTO at UserListDTO.cs:7, + UserAvatarDTO at UserAvatarDTO.cs:6, and the export family headed by + UserDataExportSubjectDTO), the IAttendeeQueryService cross-module interface (MMCA.ADC.Identity.Shared/Users/IAttendeeQueryService.cs:8), the UserRegistered and UserDeleted integration events, and the IdentityPermissions / IdentitySettings constants (the latter carrying the BR-213 registration budget, MaxRegistrationsPerIpPerHour = 10, - IdentitySettings.cs:15). Three more projects sit outside the module folder: - MMCA.ADC.Identity.Contracts (the gRPC adapter), MMCA.ADC.Identity.Service (the extracted - process host), and MMCA.ADC.Identity.UI (the Blazor pages). The identifier alias for this - context is UserIdentifierType = int, a database-generated identity + IdentitySettings.cs:15). MMCA.ADC.Identity.UI sits in the same module folder and holds the + Blazor pages; two further projects live under MMCA.ADC/Source/Services/ instead, because they exist + only for the extracted topology: MMCA.ADC.Identity.Contracts (the gRPC adapter) and + MMCA.ADC.Identity.Service (the extracted process host). The identifier alias for this context is + UserIdentifierType = int, a database-generated identity (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/MMCA.ADC.Identity.GlobalUsings.IdentifierType.cs:2), while the cross-context LinkedSpeakerId uses SpeakerIdentifierType = System.Guid - (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:18, - ADR-048).

      + (ADR-048).

      User (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:33) is the only aggregate root in the module, and it carries more responsibility than most: it is the credential @@ -259,12 +271,12 @@

      Authentication: a address to a placeholder, so a real erased email is re-registrable by design (GDPR). The filter bypass exists so the two rows the unfiltered unique Email index would otherwise turn into a 500 (legacy rows soft-deleted without anonymization, and the placeholder addresses themselves) come back as a clean - conflict instead (:77-84).

      + conflict instead (:76-84).

      The HTTP surface is equally thin. AuthController (MMCA.ADC.Identity.API/Controllers/AuthController.cs:29) extends UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand> (G12), which supplies the login / register / refresh / revoke actions plus the three self-service - account actions (PUT password, PUT preferences, GET preferences, AuthController.cs:20-24); the + account actions (PUT password, PUT preferences, GET preferences, AuthController.cs:18-25); the ADC subclass adds only two overrides and two command factories. RegisterAsync (:52) captures the client IP for registration rate limiting (BR-213, :57) and carries the per-IP auth-ip fixed window (:48); LoginAsync (:76) re-declares the same window as a password-spray guard, because the per-email lockout alone cannot throttle one source spraying one password across many addresses - (:66-69). CreateChangePasswordCommand and CreateChangePreferencesCommand (:82, :87) are the - only wiring the base needs to dispatch ChangePasswordCommand and - ChangePreferencesCommand through the - G05 decorator pipeline, where the preferences write declares + (:65-68). CreateChangePasswordCommand and CreateChangePreferencesCommand (:82, :87) are the + only wiring the base needs to dispatch ChangePasswordCommand + (ChangePasswordCommand.cs:14) and ChangePreferencesCommand + (ChangePreferencesCommand.cs:14) through the + G05 decorator pipeline, where both writes declare ICacheInvalidating with a User-typed cache prefix - so a stale cached read cannot mask a preference change (ChangePreferencesCommand.cs:15, :18). - The three handlers on that path are all body-less subclasses of a G14 base whose only purpose is that - the source reported on every error stays the ADC class name, which clients match on: - ChangePasswordHandler (ChangePasswordHandler.cs:12-23, + so a stale cached read cannot mask the change (ChangePreferencesCommand.cs:15, :18). The three + handlers on that path are all body-less subclasses of a G14 base whose only purpose is that the + source reported on every error stays the ADC class name, which clients match on: + ChangePasswordHandler (ChangePasswordHandler.cs:12-17, ADR-032), ChangePreferencesHandler, and - GetUserPreferencesHandler (GetUserPreferencesHandler.cs:8-16). + GetUserPreferencesHandler (GetUserPreferencesHandler.cs:8-13). OAuthController (OAuthController.cs:20) is a body-less subclass of OAuthControllerBase (G12) that drives the Google/GitHub challenge, callback, complete, single-use-code-exchange flow, with the class-level routing and versioning attributes re-declared locally because they are not reliably inherited (:14-19); it is an ADC-only feature, since MMCA.Store uses local credentials only. UserClaimsController (UserClaimsController.cs:16) reflects the - authenticated JWT's claims back to the client. UsersController - (UsersController.cs:31) hosts the rest: the three avatar endpoints, the organizer user list, the - data export (:149), and the account delete (:171). Its list endpoint is gated by capability rather - than by role name, [HasPermission(IdentityPermissions.UsersRead)] (:125), and the + authenticated JWT's claims back to the client (:27-30). UsersController + (UsersController.cs:32) hosts the rest: the three avatar endpoints, the organizer user list, the + data export (:157), and the account delete (:179). Its list endpoint is gated by capability rather + than by role name, [HasPermission(IdentityPermissions.UsersRead)] (:133), and the identity:users:read grant (MMCA.ADC.Identity.Shared/Authorization/IdentityPermissions.cs:11) is handed to Organizer and Admin in AddModuleIdentityAPI (MMCA.ADC.Identity.API/DependencyInjection.cs:44-48, ADR-020). That list itself is served by GetUsersHandler - (MMCA.ADC.Identity.Application/Users/UseCases/GetUsers/GetUsersHandler.cs:16), which clamps the page - size at 500 through PagingMath before touching the - database (:28, BR-11) and pushes filtering, COUNT, ordering, OFFSET/FETCH paging, and the - projection into SQL (:34-57), so the credential columns are never materialized ([Rubric §12, - Performance and Scalability]). Its sort carries an Id tie-break (:93-95) that makes the ORDER BY - total: without it, rows sharing a sort key can repeat or vanish across pages, because OFFSET/FETCH has - no stable row order to page over.

      + (MMCA.ADC.Identity.Application/Users/UseCases/GetUsers/GetUsersHandler.cs:16) from a + GetUsersQuery carrying the filter, sort, and paging values (GetUsersQuery.cs:12); + the handler clamps the page size at 500 through + PagingMath before touching the database (:28, + BR-11) and pushes filtering, COUNT, ordering, OFFSET/FETCH paging, and the + UserListDTO projection into SQL (:34-57), so the credential columns are never + materialized ([Rubric §12, Performance and Scalability]). Its sort carries an Id tie-break (:91-95) + that makes the ORDER BY total: without it, rows sharing a sort key can repeat or vanish across pages, + because OFFSET/FETCH has no stable row order to page over.

      +

      Password recovery: the anonymous half of the credential lifecycle

      +

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

      +

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

      The privacy pair: export and erasure

      Two use cases make this module the codebase's clearest [Rubric §30, Compliance / Privacy / Data - Governance] story, and both are now thin ADC specializations of a G14 base. The erasure workflow lives - in DeleteUserHandlerBase<TUser, TCommand>; + Governance] story, and both are thin ADC specializations of a G14 base. The erasure workflow lives in + DeleteUserHandlerBase<TUser, TCommand>; DeleteUserHandler (MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs:28) keeps the class name so the reported error source stays stable for clients, and supplies the ADC-specific pieces - (:18-27). HasDeletePrivilege (:42) says the Organizer role bypasses the ownership rule, - delegating to UserRole.IsOrganizer so the claim's casing does not matter. OnAfterSoftDeleteAsync - (:46) does two things. It raises the cross-service UserDeleted integration event on - the aggregate (:62), so the outbox row is written by the very SaveChangesAsync that commits the - erasure: Engagement holds a DisplayName snapshot on its leaderboard opt-in, lives in its own process - with its own database, and never sees Identity's in-process domain event, so the fact and its - announcement must not be able to come apart (:56-61). Its payload is deliberately just the user id - and a timestamp, because carrying a name or email would publish onto a persistent broker the very - personal data the erasure exists to remove - (MMCA.ADC.Identity.Shared/Users/IntegrationEvents/UserDeleted.cs:16-20). It then queues two - after-commit actions: writing the shared + (:18-27) over a DeleteUserCommand that carries the target id plus the + caller's own id and role (DeleteUserCommand.cs:11-17). HasDeletePrivilege (:42) says the + Organizer role bypasses the ownership rule, delegating to UserRole.IsOrganizer so the claim's casing + does not matter. OnAfterSoftDeleteAsync (:46) does two things. It raises the cross-service + UserDeleted integration event on the aggregate (:62), so the outbox row is written + by the very SaveChangesAsync that commits the erasure: Engagement holds a DisplayName snapshot on + its leaderboard opt-in, lives in its own process with its own database, and never sees Identity's + in-process domain event, so the fact and its announcement must not be able to come apart (:56-61). + Its payload is deliberately just the user id and a timestamp, because carrying a name or email would + publish onto a persistent broker the very personal data the erasure exists to remove + (MMCA.ADC.Identity.Shared/Users/IntegrationEvents/UserDeleted.cs:16-20, record at :24-27). It then + queues two after-commit actions: writing the shared SoftDeletedUserCache marker so the API middleware rejects requests still carrying an already-issued access token for the erased account (:68-80, BR-133, ADR-047), and @@ -418,10 +477,11 @@

      The privacy pair: export and erasur (MMCA.ADC.Identity.Application/Users/UseCases/ExportUserData/ExportUserDataHandler.cs:30) inherits ExportUserDataHandlerBase<TUser, TQuery>, which owns the owner-or-privileged authorization, the account load, and the section fan-out; ADC - contributes exactly two things (:10-14): HasExportPrivilege, again UserRole.IsOrganizer (:38), - and BuildSubjectSnapshotAsync (:41), which projects the account's own portable fields into a - UserDataExportSubjectDTO (:48-74). Credentials are deliberately - excluded: no password hash, no salt, no refresh token, no provider key + contributes exactly two things (:10-14) on top of an ExportUserDataQuery + shaped like the delete command (ExportUserDataQuery.cs:12-15): HasExportPrivilege, again + UserRole.IsOrganizer (:38), and BuildSubjectSnapshotAsync (:41), which projects the account's + own portable fields into a UserDataExportSubjectDTO (:48-74). + Credentials are deliberately excluded: no password hash, no salt, no refresh token, no provider key (MMCA.ADC.Identity.Shared/Users/UserDataExportSubjectDTO.cs:3-7). One small correctness detail sits at :67-73: SQL Server hands audit timestamps back as Kind=Unspecified, so the handler re-stamps them UTC, which is the only reason the exported JSON carries the Z marker the DTO documents. The @@ -433,15 +493,21 @@

      The privacy pair: export and erasur EngagementUserDataExportSection (.../ExportUserData/EngagementUserDataExportSection.cs:19) reads bookmarks, submitted session questions, the points ledger, check-in history, and leaderboard participation through - IUserEngagementExportService (:30-31) + IUserEngagementExportService (:30-32) and shapes them into UserDataExportEngagementSectionDTO - (:34-68), turning enum values into their readable names because a data subject reads this document - (:49-51, :58-60); NotificationUserDataExportSection + (:34-68) over the per-row records UserDataExportBookmarkDTO, + UserDataExportSubmittedQuestionDTO, + UserDataExportPointsEntryDTO, and + UserDataExportCheckInDTO, turning enum values into their readable names + because a data subject reads this document (:49-51, :58-60); + NotificationUserDataExportSection (.../ExportUserData/NotificationUserDataExportSection.cs:18) does the same for inbox rows through - IUserNotificationExportService (:29-31). - Neither section catches transport failures (:12-15 in both files): that is the point. The base wraps - every section, so a peer that stays unreachable after the standard Polly resilience pipeline degrades - to Available = false and the export still succeeds, which is [Rubric §29, Resilience] and [Rubric §7, + IUserNotificationExportService + (:29-31), into UserDataExportNotificationSectionDTO and + its UserDataExportNotificationDTO items (:33-40). Neither section + catches transport failures (:12-15 in both files): that is the point. The base wraps every section, + so a peer that stays unreachable after the standard Polly resilience pipeline degrades to + Available = false and the export still succeeds, which is [Rubric §29, Resilience] and [Rubric §7, Microservices Readiness] applied to a compliance workflow.

      Avatars: the third mutating slice

      The avatar trio is a small but complete example of a file-handling slice ([Rubric §11, Security] at the @@ -449,33 +515,37 @@

      Avatars: the third mutating slice

      ADR-045). UsersController caps the multipart upload at 2 MB in two places, declaratively via [RequestSizeLimit(MaxAvatarBytes)] and imperatively via an explicit length check that returns an - Avatar.InvalidUpload validation error (UsersController.cs:41, :67, :78-84, BR-116a). + Avatar.InvalidUpload validation error (UsersController.cs:42, :75, :86-92, BR-116a). SetUserAvatarHandler - (MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarHandler.cs:16) never trusts - the client-declared content type: it sniffs magic bytes through the shared - ImageContentSniffer (:32), re-encodes to a - canonical 256x256 JPEG via IImageProcessor (:23, - :52), uploads under a randomized blob name through - IFileStorageService (:60-66), and only then - persists the new URL, deleting the replaced blob after the save so a failure leaks one orphaned - image rather than breaking a live avatar (:74-83). The random suffix means a replacement never - reuses the old URL, so stale caches self-resolve (:10-14). - RemoveUserAvatarHandler and - GetUserAvatarHandler are the trivial siblings on the same resource.

      + (MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarHandler.cs:16), handling a + SetUserAvatarCommand that carries the bytes as a ReadOnlyMemory<byte> + (SetUserAvatarCommand.cs:10), never trusts the client-declared content type: it sniffs magic bytes + through the shared ImageContentSniffer + (:32), re-encodes to a canonical 256x256 JPEG via + IImageProcessor (:23, :52), uploads under a + randomized blob name through + IFileStorageService (:64-66), and only then + persists the new URL, deleting the replaced blob after the save so a failure leaks one orphaned image + rather than breaking a live avatar (:74-82). The random suffix means a replacement never reuses the + old URL, so stale caches self-resolve (:10-14). The result travels back as a + UserAvatarDTO. RemoveUserAvatarHandler (over + RemoveUserAvatarCommand) and + GetUserAvatarHandler (over GetUserAvatarQuery) are + the trivial siblings on the same resource.

      Persistence, seeding, and the disabled stub

      ModuleApplicationDbContext (ModuleApplicationDbContext.cs:15) is the abstract, engine-agnostic context declaring the single Users set (:22); the concrete per-engine class (SQLServerDbContext today) inherits it, and the base ApplicationDbContext supplies audit stamping, soft-delete query filters, and outbox / domain-event dispatch via interceptors (:9-13, :20). - Identity owns its own ADC_Identity database with its own dbo.OutboxMessages, so it never races - another service's outbox (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:32, + Identity owns its own ADC_Identity database with its own outbox table, so it never races another + service's outbox (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:32, ADR-006). UserConfiguration (MMCA.ADC.Identity.Infrastructure/Persistence/EntityConfiguration/UserConfiguration.cs:12) extends EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>, maps the Email value object through the shared - EmailValueConverter (:20-24), mirrors the + EmailValueConverter (:21-22), mirrors the invariant length constants onto the columns, ignores the computed FullName and IsExternalLogin members (:112-113), and pins four indexes that encode business rules as schema ([Rubric §8, Data Architecture]): unique Email (:115), a filtered index on RefreshToken for the refresh lookup @@ -489,14 +559,14 @@

      Persistence, seeding, and the (MMCA.ADC.Identity.Infrastructure/Persistence/DbContexts/Seeding/IdentityModuleDbSeeder.cs:27), a subclass of IdentityModuleDbSeederBase<TUser> that contributes only the three-account list (:33-38), the existence predicate (:41) and ADC's - User.Create parameter order (:51); the check-then-insert idiom in the base is what makes the - seeder idempotent, and the deliberately weak development credentials are documented in its own - remarks (:21-25). Note the base's ShouldSeed is deliberately not overridden, so the - configuration gate has exactly one home (:17-19). When the Identity module is disabled in a host, - the IdentityModule descriptor registers the + User.Create parameter order (:51); the check-then-insert idiom in the base is what makes the seeder + idempotent, and the deliberately weak development credentials are documented in its own remarks + (:21-25). Note the base's ShouldSeed is deliberately not overridden, so the configuration gate + has exactly one home (:17-19). When the Identity module is disabled in a host, the + IdentityModule descriptor registers the DisabledAttendeeQueryService null-object stub through RegisterDisabledStubs (IdentityModule.cs:19-20), so a consumer that only needs the attendee list - still composes.

      + still composes (DisabledAttendeeQueryService.cs:10-11 returns an empty list).

      Crossing the service boundary: gRPC and integration events

      Identity talks to its peers two ways, and both live in Shared and Contracts so neither side reaches into the other's domain ([Rubric §7, Microservices Readiness]). Synchronously, the Notification @@ -523,7 +593,7 @@

      Crossing the host runs h2c-only for cross-service gRPC, with an optional HTTP/1.1-only health-probe listener, both configured by one call to KestrelEndpointExtensions.ConfigureEndpointsWithHealthProbe(HttpProtocols.Http2) - (Program.cs:81, rationale at :71-80, + (Program.cs:81, rationale at :70-80, ADR-012), and it primes its own request pipeline at startup: SelfHttpWarmupTask (MMCA.ADC.Identity.Service/SelfHttpWarmupTask.cs:23), a @@ -554,7 +624,7 @@

      Crossing the transient database fault lost the BR-209 back-link permanently, because the delivery had already been acked. Letting the exception through hands the decision to the delivery mechanism, which leaves the inbox row unprocessed so MassTransit redelivers and then dead-letters. The host registers both as - broker consumers (Program.cs:297-301). This event-carried link is what lets the bidirectional + broker consumers (Program.cs:299-300). This event-carried link is what lets the bidirectional User-to-Speaker relationship survive the service split ([Rubric §6, CQRS and Event-Driven], ADR-006 / ADR-008).

      @@ -572,28 +642,29 @@

      The UI edge

      authenticated user change their password, manage their avatar, and delete their account. It mirrors the server's 2 MB cap client-side before any upload starts (:25, :125-129), validates the new password inline for length and confirmation match so the form error summary carries the message rather than a - server round-trip (:43-49), and accepts an image from either a browser file input (:117) or, on - MAUI, the camera and gallery through + server round-trip (:40-49, [Rubric §24, Forms / Validation / UX Safety]), and accepts an image from + either a browser file input (:117) or, on MAUI, the camera and gallery through IMediaPickerService (:19, :86, :88). - It talks to the API through the IUserUIService abstraction implemented by - UserService (MMCA.ADC.Identity.UI/Services/UserService.cs:14), an + It talks to the API through the IUserUIService abstraction + (MMCA.ADC.Identity.UI/Services/IUserUIService.cs:11) implemented by UserService + (MMCA.ADC.Identity.UI/Services/UserService.cs:14), an AuthenticatedServiceBase subclass that attaches the bearer token and calls the REST users resource (:17), and which deliberately skips the - retry policy on the avatar upload because a picker stream is single-shot and cannot rewind - (:106-110, against the RetryPolicy.ExecuteAsync every other call uses, :49, :73, :88, - :127). UserList (MMCA.ADC.Identity.UI/Pages/User/UserList.razor.cs:16) is the - Organizer-only management grid: a + retry policy on the avatar upload because a picker stream is single-shot and cannot rewind (:106, + :111, against the RetryPolicy.ExecuteAsync every other call uses, :49, :73, :88, :127). + UserList (MMCA.ADC.Identity.UI/Pages/User/UserList.razor.cs:16) is the Organizer-only + management grid: a DataGridListPageBase<TDto> closed over UserListDTO with server-side filtering, sorting, and paging on a desktop data grid (:47-64), plus a MobileInfiniteScrollList<TItem> card layout on mobile viewports (:67-72), the two kept in sync by the shared - ListPageActions helper (:38-39, :76), which lives in Identity.UI because that + ListPageActions helper (:39, :76), which lives in Identity.UI because that project is the root of the ADC module-UI reference chain (MMCA.ADC.Identity.UI/Common/ListPageActions.cs:6-12). The UI targets WCAG 2.1 AA; the login and register flows are scanned by the shared MMCA.Common.Testing.E2E workflow bases and the profile page has its own axe-core scan in ADC's suite - (MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/AccessibilityTests.cs:360, with the rationale for - not inheriting the Common profile base at :362-363), all of it running in the deploy-gating chromium + (MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/AccessibilityTests.cs:366, with the rationale for + not inheriting the Common profile base at :368-369), all of it running in the deploy-gating chromium E2E leg ([Rubric §21, Accessibility], [Rubric §22, Responsive and Cross-Browser]).

      End-to-end: one registration

      To see the chapter cooperate, follow a new attendee signing up. AuthController @@ -633,9 +704,11 @@

      End-to-end: one registration

      ADR-029 (login protection), ADR-036 (external OAuth login), ADR-045 - (file storage and avatars), and + (file storage and avatars), ADR-047 - (soft-deleted session revocation) are the primary references.

      + (soft-deleted session revocation), and + ADR-091 (cache-backed + password reset) are the primary references.

      AssemblyReference

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

      @@ -1603,6 +1676,22 @@

      ChangePasswordRequestValidator

    • Why it's built this way: the current password is deliberately only checked for presence here, never for strength. Its correctness is a credential comparison against the stored hash, which lives in ChangePasswordHandler via ChangePasswordHandlerBase (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs), and an old account may legitimately hold a password that no longer meets today's rules. Applying strength rules to it would lock those users out of the very screen that would fix the problem (ADR-032).
    • Where it's used: resolved as IValidator<ChangePasswordRequest> by CommandRequestValidator<ChangePasswordCommand, ChangePasswordRequest>, which the Validating decorator runs before ChangePasswordHandler for the PUT auth/password endpoint on AuthController.
    +

    ForgotPasswordCommand

    +
    +

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

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

    HttpContextExternalLoginEmailVerifier

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

    @@ -1614,7 +1703,7 @@

    HttpContextExternalLoginEmailVeri
  • Walkthrough: a primary-constructor class taking one dependency (HttpContextExternalLoginEmailVerifier.cs:17-18) and exposing one method.
    • EmailVerifiedClaimType (HttpContextExternalLoginEmailVerifier.cs:21), the internal const string "email_verified". internal rather than private so the claim name is nameable from the test assembly instead of being re-typed as a literal.
    • IsCurrentExternalLoginEmailVerifiedAsync() (HttpContextExternalLoginEmailVerifier.cs:24-36). It reads httpContextAccessor.HttpContext and returns false when there is none (:26-30), so a call outside a request reports unverified instead of throwing.
    • -
    • It then re-authenticates the short-lived ExternalLogin cookie (:32, ExternalAuthExtensions.ExternalLoginScheme, the constant at MMCA.Common/Source/Presentation/MMCA.Common.API/Authentication/ExternalAuthExtensions.cs:27). That is the same principal OAuthControllerBase.CompleteAsync just authenticated (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:78), so the claim is read from the provider's own freshly minted principal, never from anything a client supplied.
    • +
    • It then re-authenticates the short-lived ExternalLogin cookie (:32, ExternalAuthExtensions.ExternalLoginScheme, the constant at MMCA.Common/Source/Presentation/MMCA.Common.API/Authentication/ExternalAuthExtensions.cs:27). That is the same principal OAuthControllerBase.CompleteAsync just authenticated (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/OAuthControllerBase.cs:79), so the claim is read from the provider's own freshly minted principal, never from anything a client supplied.
    • The tail collapses three failure modes into one expression (:33-35): a null principal, a missing claim, or a value that is not a parseable true all yield false, via authenticateResult.Principal?.FindFirst(...)?.Value plus bool.TryParse(claimValue, out var verified) && verified.
  • @@ -1646,10 +1735,10 @@

    ExportUserDataQuery

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

    SelfHttpWarmupTask

    @@ -1678,7 +1767,7 @@

    UserDeleted

  • Depends on: BaseDomainEvent, the UserIdentifierType alias.
  • Concept reinforced, the domain event as an internal fact. [Rubric §4, DDD] (assesses whether state changes that other code cares about are expressed as named domain facts instead of inferred from a database write) and [Rubric §6, CQRS & Event-Driven]. The distinction from an integration event is the one to keep straight, and this module makes it unusually concrete: there is a second UserDeleted record, in MMCA.ADC.Identity.Shared.Users.IntegrationEvents, carrying the same fact to other processes. A BaseDomainEvent such as this one is dispatched in-process by the framework's DomainEventDispatcher after SaveChangesAsync (deferred until after commit when a transaction is open), while a BaseIntegrationEvent leaves its outbox row unprocessed for the OutboxProcessor to publish to the broker (ADR-003). Same AddDomainEvent call at the aggregate, two very different delivery paths, chosen purely by base type.
  • Walkthrough: a one-line positional record, public sealed record class UserDeleted(UserIdentifierType UserId) : BaseDomainEvent (UserDeleted.cs:10-12). The id-only payload is deliberate: an in-process subscriber can load whatever else it needs from the same unit of work, and a fat payload would go stale between raise and dispatch.
  • -
  • Why it's built this way: raising the event inside User.Delete (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:370) and only when the base soft-delete actually succeeded (User.cs:367-369) means a second delete on an already-deleted account raises nothing, so subscribers cannot see a duplicate fact. Delete also revokes the refresh token first (User.cs:366), so outstanding sessions die whether or not anybody listens to the event.
  • +
  • Why it's built this way: raising the event inside User.Delete (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:370) and only when the base soft-delete actually succeeded (User.cs:367-371) means a second delete on an already-deleted account raises nothing, so subscribers cannot see a duplicate fact. Delete also revokes the refresh token first (User.cs:366), so outstanding sessions die whether or not anybody listens to the event.
  • Where it's used: raised by User.Delete (User.cs:364-374) and asserted by the domain tests (MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Domain.Tests/Users/UserInvariantsAndRoleTests.cs:255-263).
  • Caveats / not-in-source: no IDomainEventHandler<UserDeleted> exists anywhere in ADC source today. The doc comment names cascade cleanup and audit logging as the intended consumers (UserDeleted.cs:6-7), but that is an available extension point, not shipped behavior. The cross-process cleanup that is shipped travels on the Shared integration event of the same name, raised separately by DeleteUserHandler; the two records are not connected in code.
  • @@ -1692,7 +1781,7 @@

    UserPasswordChanged

  • Concept: structurally identical to the domain UserDeleted, and the same domain-event-versus-integration-event distinction applies. What is worth noticing is the omission: the payload is the id only (UserPasswordChanged.cs:9-11), never the new hash or salt. A security-relevant event that carried credential material would turn every future subscriber, and every log line that serialized it, into a leak ([Rubric §11, Security]).
  • Walkthrough: public sealed record class UserPasswordChanged(UserIdentifierType UserId) : BaseDomainEvent (UserPasswordChanged.cs:9-11).
  • Where it's used: raised by User.ChangePassword after the two credential invariants pass and the new hash and salt are assigned (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:329-332), and asserted by the domain tests (MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Domain.Tests/Users/UserTests.cs:200).
  • -
  • Caveats / not-in-source: like the domain UserDeleted, no handler subscribes to it in ADC source today. Session revocation on a password change is not driven from this event; refresh-token revocation is an explicit aggregate call (User.RevokeRefreshToken).
  • +
  • Caveats / not-in-source: like the domain UserDeleted, no handler subscribes to it in ADC source today. Session revocation on a password change is not driven from this event; refresh-token revocation is an explicit aggregate call (User.RevokeRefreshToken, User.cs:259).
  • NotificationUserDataExportSection

    @@ -1701,15 +1790,15 @@

    NotificationUserDataExportSection

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

    @@ -1721,7 +1810,7 @@

    UserDeleted

  • Concept introduced, the erasure announcement, and why its payload is nearly empty. [Rubric §30, Compliance, Privacy & Data Governance] (assesses whether an erasure reaches every store that holds the subject's personal data) and [Rubric §11, Security]. Two positional members only (UserDeleted.cs:24-27): UserId and DeletedOn. The doc comment gives the reason in one sentence (UserDeleted.cs:16-20): a downstream module reacting to an erasure already stores the scalar user id, and carrying a name or an email here would publish the very personal data the erasure exists to remove, onto a broker that persists messages. An erasure event that leaked PII would be self-defeating.
  • Walkthrough: the interesting mechanics are not in the record, they are at the raise site. DeleteUserHandler calls user.AddDomainEvent(new UserDeleted(command.UserId, timeProvider.GetUtcNow())) inside OnAfterSoftDeleteAsync, before the erasure is saved (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs:62), and the comment above it spells out what that buys (DeleteUserHandler.cs:56-61): the outbox row is written by the very SaveChangesAsync that commits the soft-delete and the anonymization, so an account that is erased has always produced exactly one event, and an erasure that rolls back produces none. Publishing after the commit instead would leave a crash window in which the account is gone and the published name is not. The timestamp comes from the injected TimeProvider, not DateTimeOffset.UtcNow, so the handler stays testable.
  • Why it's built this way: the doc comment (UserDeleted.cs:10-15) is explicit that this record does not replace the in-process domain UserDeleted: Engagement is a different process with its own database and never sees an Identity in-process dispatch. Two records carrying one fact is the honest modelling of a two-process reality, and the base type is the only thing that decides which path a raise takes (ADR-003).
  • -
  • Where it's used: consumed by Engagement's UserDeletedPointsHandler (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/UserDeletedPointsHandler.cs:36-38), which takes the account off the public leaderboard and overwrites the LeaderboardOptIn display name the attendee had published there, the one place Engagement holds a name rather than a scalar id (UserDeletedPointsHandler.cs:13-21). The subscription is wired in the Engagement service host with x.RegisterIntegrationEventConsumer<UserDeleted>() (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:302). That handler is idempotent in both directions and deliberately does not swallow exceptions, so a failed erasure is retried by the outbox and the broker instead of being acked away with a log line (UserDeletedPointsHandler.cs:22-28).
  • +
  • Where it's used: consumed by Engagement's UserDeletedPointsHandler (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/UserDeletedPointsHandler.cs:36-38), which takes the account off the public leaderboard and overwrites the LeaderboardOptIn display name the attendee had published there, the one place Engagement holds a name rather than a scalar id (UserDeletedPointsHandler.cs:13-21). The subscription is wired in the Engagement service host with x.RegisterIntegrationEventConsumer<UserDeleted>() (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:308). That handler is idempotent in both directions and deliberately does not swallow exceptions, so a failed erasure is retried by the outbox and the broker instead of being acked away with a log line (UserDeletedPointsHandler.cs:22-28).
  • UserRegistered

    @@ -1730,7 +1819,7 @@

    UserRegistered

    • What it is: the cross-service announcement that a new account exists. It carries the database-generated user id plus the identity fields another context needs to match on, and it is the event that drives the BR-207 speaker auto-link.
    • Depends on: BaseIntegrationEvent, the UserIdentifierType alias.
    • -
    • Concept introduced, the integration event as a published contract. [Rubric §6, CQRS & Event-Driven] (assesses whether contexts collaborate through facts rather than commands), [Rubric §7, Microservices Readiness] (assesses that the producer does not know its consumers), and [Rubric §9, API & Contract Design]. Three properties of this record make it a contract rather than an internal message. It lives in Shared, the assembly a consumer may reference without touching Identity's Domain or Application. It carries denormalized primitives (string Email, string Role) rather than the Email value object or UserRole, so a subscriber needs no Identity types to deserialize it. And it is a record with positional members, so adding an optional member later is an additive change consumers can ignore (ADR-010).
    • +
    • Concept introduced, the integration event as a published contract. [Rubric §6, CQRS & Event-Driven] (assesses whether contexts collaborate through facts rather than commands), [Rubric §7, Microservices Readiness] (assesses that the producer does not know its consumers), and [Rubric §9, API & Contract Design]. Three properties of this record make it a contract rather than an internal message. It lives in Shared, the assembly a consumer may reference without touching Identity's Domain or Application. It carries denormalized primitives (string Email, string FirstName, string LastName, string Role) rather than the Email value object or UserRole, so a subscriber needs no Identity types to deserialize it. And it is a record with positional members, so adding an optional member later is an additive change consumers can ignore (ADR-010).
    • Walkthrough: five positional members (UserRegistered.cs:23-29): UserId, Email, FirstName, LastName, Role. Email is the field the auto-link actually matches on; the two name fields exist so a subscriber can report candidates without a call back into Identity.
    • Why it's built this way: the ordering problem is the whole story. The id is a database-generated identity column, so the event cannot be raised inside User.Create (the factory remarks say so outright, MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:149-155): the id is still default at that point, and the outbox serializes the payload at capture time, so it would persist UserId = 0, which the cross-service consumer cannot resolve (AuthenticationService.cs:22-25). Instead AuthenticationService wraps the base registration workflow in a single transaction (AuthenticationService.cs:57-63), then in OnUserRegisteredAsync raises the event on the already-persisted aggregate and saves a second time (AuthenticationService.cs:112-117). The first save populates the real id; the second save writes the outbox row; both are inside one transaction, so the user and the event commit atomically. The remarks on that override (AuthenticationService.cs:103-111) name the consequence honestly: this is eventual consistency, and the token handed back to the just-registered user does not yet carry the speaker_id claim. The same raise happens for brand-new external OAuth users (AuthenticationService.cs:230-238).
    • Where it's used: consumed by Conference's UserRegisteredHandler, which runs the email-match speaker auto-link and publishes SpeakerLinkedToUser back so Identity can set User.LinkedSpeakerId; Identity registers the consumers for that return event in its own host (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:299-300).
    • @@ -1766,13 +1855,13 @@

      EngagementUserDataExportSection

    • Walkthrough: ExportAsync (EngagementUserDataExportSection.cs:26-71) makes a single peer call (:30-32) and then assembles one section DTO (:34-68).
      • Bookmarks (:36-40) and SubmittedQuestions (:41-46) project scalar ids plus CreatedOn. No session or question titles are pulled across: Engagement stores ids, and the export is truthful about what Engagement actually holds.
      • PointsEntries (:47-55) converts ActivityType with .ToString(), and the comment says why (:49-50): the export document is read by the data subject, so an activity travels as its readable name rather than an enum number. That is a real API-design decision, because it means renaming an enum member changes an externally visible document.
      • -
      • CheckIns (:56-65) applies the same rule to Scope (:57-59) and carries the nullable EventId, SessionId, and SponsorId alongside CheckedInOn, which is what makes the three check-in scopes distinguishable in the output.
      • +
      • CheckIns (:56-65) applies the same rule to Scope (:58-60) and carries the nullable EventId, SessionId, and SponsorId alongside CheckedInOn, which is what makes the three check-in scopes distinguishable in the output.
      • IsOnLeaderboard and LeaderboardDisplayName (:66-67) close the section. Those are the same two facts Engagement's UserDeletedPointsHandler erases on account deletion, which is the neat symmetry of this module: access and erasure operate on the identical set of data.
      • UserDataExportSectionResult.Complete(SectionName, data) (:70).
    • Why it's built this way: registration order is document order, and Engagement is registered first (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:38-43), with the comment above the two calls recording that this is deliberate rather than incidental. Stable ordering means an exported document can be diffed across runs.
    • -
    • Where it's used: the peer client is wired in the Identity service host with services.AddEngagementUserExportClient() (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:280), which resolves to the gRPC adapter outside the Engagement process (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Contracts/UserEngagementExportServiceGrpcAdapter.cs:12). The host comment states the coupling explicitly (Program.cs:276-279): both calls are best-effort consumers, so Identity's startup never waits on either peer. Covered by MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/EngagementUserDataExportSectionTests.cs.
    • +
    • Where it's used: the peer client is wired in the Identity service host with services.AddEngagementUserExportClient() (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:280), which resolves to the gRPC adapter outside the Engagement process (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Contracts/UserEngagementExportServiceGrpcAdapter.cs:18). The host comment states the coupling explicitly (Program.cs:276-279): both calls are best-effort consumers, so Identity's startup never waits on either peer. Covered by MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/EngagementUserDataExportSectionTests.cs.

    RegisterRequestValidator

    @@ -1799,18 +1888,40 @@

    ExportUserDataHandler

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

    ForgotPasswordHandler

    +
    +

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

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

    UserClaimsController

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

    @@ -1859,7 +1970,7 @@

    ListPageActions

  • Why it's built this way: passing the localized strings and the error mapper in as parameters keeps this class free of any resource dependency, so each page supplies its own translated text (ADR-027) while the flow itself stays identical everywhere.
  • -
  • Where it's used: twelve pages in current source. Identity's UserList uses both methods (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/User/UserList.razor.cs:39, :76-83); the Conference UI calls them from EventList, SessionList, SpeakerList, RoomList, QuestionList, ConferenceCategoryList, SponsorList, PublicEventList, PublicSessionListView, and PublicSpeakerList; and the Engagement UI calls them from AttendeeSearchPanel. That last caller is the proof the placement argument holds: Engagement.UI reaches this type transitively, without a direct reference to Identity.UI's pages.
  • +
  • Where it's used: twelve pages in current source. Identity's UserList uses both methods (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/User/UserList.razor.cs:38-39, :75-83); the Conference UI calls them from EventList, SessionList, SpeakerList, RoomList, QuestionList, ConferenceCategoryList, SponsorList, ActivityList, PublicEventList, and PublicSessionListView; and the Engagement UI calls them from AttendeeSearchPanel. That last caller is the proof the placement argument holds: Engagement.UI reaches this type transitively, without a direct reference to Identity.UI's pages.
  • Profile

    @@ -1928,7 +2039,7 @@

    User

    • What it is: the Identity aggregate root. One row per account, holding credentials, role, refresh-token lifecycle, profile fields, optional MAUI device metadata, external-login identifiers, UI preferences, the avatar URL, and the scalar link to a Conference Speaker.
    • Depends on: AuditableAggregateRootEntity<TIdentifierType>, IPasswordChangeableUser, IUserPreferences, IErasableUser (which extends IAnonymizable), IAuditedEntity, Email, UserRole, UserInvariants, UserPasswordChanged, UserDeleted, PiiAttribute, IdValueGeneratedAttribute, Result; externals: BCL only.
    • -
    • Concept introduced, the interface list as a workflow contract. [Rubric §4, DDD] assesses whether the aggregate is the consistency boundary and enforces its own invariants, and [Rubric §1, SOLID] assesses interface segregation: four narrow capability interfaces instead of one fat base (User.cs:33-34). The class remarks (User.cs:17-31) explain something genuinely easy to get wrong. The shared framework workflows are generic over capability interfaces: ChangePasswordHandlerBase<TUser, TCommand> constrains on IPasswordChangeableUser, and the erasure workflow constrains on IErasableUser. Listing IErasableUser on this type directly is load-bearing because Delete here hides the base soft-delete with new (User.cs:364); only re-declaring the interface on User re-maps the interface slot onto this type's own member, which is what keeps the refresh-token revocation inside the shared erasure path. Remove the interface from the declaration list and the code still compiles while quietly calling the base method instead.
        +
      • Concept introduced, the interface list as a workflow contract. [Rubric §4, DDD] assesses whether the aggregate is the consistency boundary and enforces its own invariants, and [Rubric §1, SOLID] assesses interface segregation: four narrow capability interfaces instead of one fat base (User.cs:33-34). The class remarks (User.cs:17-31) explain something genuinely easy to get wrong. The shared framework workflows are generic over capability interfaces: ChangePasswordHandlerBase<TUser, TCommand> constrains on IPasswordChangeableUser (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:28), and the erasure workflow constrains on IErasableUser. Listing IErasableUser on this type directly is load-bearing because Delete here hides the base soft-delete with new (User.cs:364); only re-declaring the interface on User re-maps the interface slot onto this type's own member, which is what keeps the refresh-token revocation inside the shared erasure path. Remove the interface from the declaration list and the code still compiles while quietly calling the base method instead.
        • IAuditedEntity (User.cs:34) is the one non-behavioural marker in the list, and the remarks justify it (User.cs:25-30): it opts the aggregate into a change history rather than just the last-writer audit fields. [Rubric §30, Compliance, Privacy & Data Governance]: account records are where a support or compliance question actually gets asked (who changed this attendee's email, when was this account raised to Organizer, who anonymized it), and LastModifiedOn/By alone answers only "who touched it last".
        • [IdValueGenerated] (User.cs:32) tells the persistence layer the id is database-generated. That single attribute is the root cause of the UserRegistered two-save dance: the id does not exist until after the insert, which the Create doc states outright (User.cs:149-155).
        @@ -1947,7 +2058,7 @@

        User

    • Why it's built this way: Delete and Anonymize are separate operations, and the doc on Anonymize (User.cs:376-386) says exactly why: the row survives so cross-context scalar references (bookmarks, notifications) and the audit trail do not break, which is the anonymize-in-place policy of ADR-005. It is also why a re-registration with an erased account's original email succeeds by design, a nuance AuthenticationService's EmailExistsAsync documents. The avatar comment (User.cs:105-109) draws the matching line for storage: the domain nulls the URL, and the use case, which knows the blob boundary, deletes the file (ADR-045).
    • -
    • Where it's used: persisted by UserConfiguration, projected by UserDTOMapper, driven by AuthenticationService and the Users use cases (ChangePasswordHandler, ChangePreferencesHandler, DeleteUserHandler, ExportUserDataHandler, SetUserAvatarHandler, RemoveUserAvatarHandler), mutated on the cross-context link by SpeakerLinkedToUserHandler and SpeakerUnlinkedFromUserHandler, and read by GetUsersHandler.
    • +
    • Where it's used: persisted by UserConfiguration, projected by UserDTOMapper, driven by AuthenticationService and the Users use cases (ChangePasswordHandler, ChangePreferencesHandler, DeleteUserHandler, ExportUserDataHandler, SetUserAvatarHandler, RemoveUserAvatarHandler, and the reset-password path behind PasswordResetController), mutated on the cross-context link by SpeakerLinkedToUserHandler and SpeakerUnlinkedFromUserHandler, and read by GetUsersHandler.
    • Caveats / not-in-source: Anonymize leaves Role, PreferredCulture, PreferredTheme, and LinkedSpeakerId untouched (User.cs:404-419), so an erased account keeps its role, its UI preferences, and any speaker link. Those are treated as non-identifying here; nothing in this file states that judgement, so it is a behavior to notice rather than a documented decision.

    UserList

    @@ -1983,7 +2094,7 @@

    ChangePasswordCommand

  • Depends on: ChangePasswordRequest; the UserIdentifierType alias (= int); User (only for typeof(User).FullName); and three framework markers, ICommandWithRequest<out TRequest>, ICacheInvalidating, and IUserScopedCommand<out TRequest>.
  • Concept introduced, three markers that each buy exactly one pipeline behavior. [Rubric §6, CQRS & Event-Driven] (a command is a named intention carrying exactly what the write needs) and [Rubric §2, Design Patterns]. The record declares no members beyond CachePrefix; everything else it does is expressed by which interfaces it lists (ChangePasswordCommand.cs:15).
    • ICommandWithRequest<ChangePasswordRequest> opts the command into automatic validation: the framework registers a CommandRequestValidator<TCommand, TRequest> that delegates to the registered IValidator<ChangePasswordRequest> through FluentValidation's SetValidator, with TryAdd semantics so an explicit command-level validator still wins (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/ICommandWithRequest.cs:5-11).
    • -
    • ICacheInvalidating gives the caching decorator a prefix to evict, $"{typeof(User).FullName}:" (ChangePasswordCommand.cs:18). Deriving it from the type rather than from a string literal keeps it in lockstep with the key the user cache actually uses: rename or move User and the prefix follows.
    • +
    • ICacheInvalidating gives the caching decorator a prefix to evict, $"{typeof(User).FullName}:" (ChangePasswordCommand.cs:18). Deriving it from the type rather than from a string literal keeps it in lockstep with the key the user cache actually uses: rename or move User and the prefix follows. ResetPasswordCommand carries the identical prefix for the same reason, and its doc says so explicitly (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ResetPassword/ResetPasswordCommand.cs:9-11, :18).
    • IUserScopedCommand<ChangePasswordRequest> is the view the shared handler base reads the command through, and it changes no pipeline behavior on its own. Its doc comment records why the two are separate rather than merged: the automatic-validation opt-in is a per-application decision, and ADC and Store agree on it for password change but disagree for preferences (MMCA.Common/Source/Core/MMCA.Common.Application/Users/IUserScopedCommand.cs:6-11).
  • @@ -2050,51 +2161,71 @@

    ChangePasswordHandler

    • What it is: ADC's change-password handler. The class body is empty: the whole workflow lives in the framework base ChangePasswordHandlerBase<TUser, TCommand>, and this type exists to bind the generic parameters and to keep the name.
    • Depends on: ChangePasswordHandlerBase<TUser, TCommand> (base), IUnitOfWork, IPasswordHasher, ILogger<T>, User, and ChangePasswordCommand.
    • -
    • Concept introduced, the name-preserving thin subclass. [Rubric §16, Maintainability] assesses de-duplication across applications, and [Rubric §9, API & Contract Design] covers why the class name survives the move. ADC and Store carried line-identical copies of this handler, so the workflow was hoisted into MMCA.Common (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:11-15). What could not be hoisted is the error payload: every failure the framework returns carries a source, the base defaults it to GetType().Name through a virtual HandlerName (ChangePasswordHandlerBase.cs:34-39), and clients match on the string ChangePasswordHandler. Keeping an empty subclass under the original name makes the hoist invisible on the wire, and the <remarks> says so outright (ChangePasswordHandler.cs:12-16). It is a small pattern with a large consequence: a refactor that would otherwise be a breaking API change becomes a no-op for consumers. The same shape recurs at ChangePreferencesHandler and GetUserPreferencesHandler.
    • +
    • Concept introduced, the name-preserving thin subclass. [Rubric §16, Maintainability] assesses de-duplication across applications, and [Rubric §9, API & Contract Design] covers why the class name survives the move. ADC and Store carried line-identical copies of this handler, so the workflow was hoisted into MMCA.Common (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:11-15). What could not be hoisted is the error payload: every failure the framework returns carries a source, the base defaults it to GetType().Name through a virtual HandlerName (ChangePasswordHandlerBase.cs:34-39), and clients match on the string ChangePasswordHandler. Keeping an empty subclass under the original name makes the hoist invisible on the wire, and the <remarks> says so outright (ChangePasswordHandler.cs:12-16). It is a small pattern with a large consequence: a refactor that would otherwise be a breaking API change becomes a no-op for consumers. The same shape recurs at ChangePreferencesHandler, GetUserPreferencesHandler, and (for the controller layer) at PasswordResetController.
    • Walkthrough: a primary constructor taking IUnitOfWork, IPasswordHasher, and ILogger<ChangePasswordHandler> and forwarding all three to the base (ChangePasswordHandler.cs:17-21), with an empty body (:22-23). The inherited workflow (ChangePasswordHandlerBase.cs:42-70) is: null-guard the command (:46); load the user through the mutating repository and return Error.NotFound tagged with HandlerName when absent (:48-53); verify the supplied current password with passwordHasher.VerifyPassword(command.Request.CurrentPassword, user.PasswordHash, user.PasswordSalt) and return Error.Unauthorized("Auth.InvalidCurrentPassword", ...) on a mismatch (:55-59); hash the new password into a fresh hash and salt pair (:61); call user.ChangePassword(newHash, newSalt) and, only when that succeeds, save and log (:62-67); return the aggregate's Result unchanged (:69).
    • Why it's built this way: [Rubric §11, Security]. Note the division of responsibility the base encodes. Proving knowledge of the current password is a cryptographic operation, so it happens where the stored hash and salt are in hand, not in a request validator (a validator can only check that a value is present). The domain then applies its own invariants on the new credential material, and nothing is persisted unless both gates pass. The generic constraint where TUser : AuditableAggregateRootEntity<UserIdentifierType>, IPasswordChangeableUser (ChangePasswordHandlerBase.cs:28) is what lets the base call ChangePassword on an application's aggregate without knowing the concrete type, and the hashing scheme itself is ADR-032.
    • Where it's used: resolved as ICommandHandler<ChangePasswordCommand, Result> and injected into AuthController (AuthController.cs:32), which exposes it as PUT /auth/password; the Profile page is the client.

    UsersController

    -

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

    +

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

    • What it is: the /users REST surface: the organizer user list, account delete, the GDPR data export, and the three avatar endpoints for the signed-in user. Six actions, each a thin adapter over one handler.
    • -
    • Depends on: ApiControllerBase, ICommandHandler<in TCommand, TResult> and IQueryHandler<in TQuery, TResult> closed over six use cases (UsersController.cs:32-37), ICurrentUserService, HasPermissionAttribute, IdentityPermissions, UserListDTO, UserAvatarDTO, UserDataExportDTO, PagedCollectionResult<T>; externals: ASP.NET Core MVC (IFormFile, [RequestSizeLimit], [Range]).
    • +
    • Depends on: ApiControllerBase, ICommandHandler<in TCommand, TResult> and IQueryHandler<in TQuery, TResult> closed over six use cases (UsersController.cs:33-38), ICurrentUserService (:39), HasPermissionAttribute, IdentityPermissions, IdempotentAttribute, UserListDTO, UserAvatarDTO, UserDataExportDTO, PagedCollectionResult<T>; externals: ASP.NET Core MVC (IFormFile, [RequestSizeLimit], [Range]).
    • Concept introduced, the controller as a translator between HTTP and the handler pipeline, and the two shapes of authorization. [Rubric §9, API & Contract Design], [Rubric §11, Security], [Rubric §5, Vertical Slice] (assesses whether each endpoint routes to its own use case rather than into a shared service). Every action follows the same four lines: read the caller from ICurrentUserService, build the command or query record, await handler.HandleAsync(...), then result.IsFailure ? HandleFailure(result.Errors) : Ok(...). The controller holds no business logic, which is why the decorator pipeline (logging, caching, validation, transaction) applies uniformly. The authorization split is the interesting part:
        -
      • Declarative, for a role-shaped rule: the list endpoint carries [HasPermission(IdentityPermissions.UsersRead)] (UsersController.cs:125), the permission-based check of ADR-020. A caller without that capability never reaches the handler.
      • -
      • In-handler, for an ownership-shaped rule: export and delete pass the caller's id and role into the query or command (UsersController.cs:162, :184) so the handler can apply owner-or-Organizer and, importantly, return 404 rather than 403 for a stranger's id, which avoids leaking whether that account exists (ADR-033).
      • +
      • Declarative, for a role-shaped rule: the list endpoint carries [HasPermission(IdentityPermissions.UsersRead)] (UsersController.cs:133, the constant is "identity:users:read" at MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Authorization/IdentityPermissions.cs:11), the permission-based check of ADR-020. A caller without that capability never reaches the handler.
      • +
      • In-handler, for an ownership-shaped rule: export and delete pass the caller's id and role into the query or command (UsersController.cs:170, :192) so the handler can apply owner-or-Organizer and, importantly, return 404 rather than 403 for a stranger's id, which avoids leaking whether that account exists (ADR-033).
    • -
    • Walkthrough: class-level [Authorize] (UsersController.cs:30) makes every action authenticated by default, and each action re-checks currentUserService.UserId is null and returns Unauthorized() for the "authenticated but no usable id" case.
        -
      • MaxAvatarBytes = 2 * 1024 * 1024 (UsersController.cs:41), the BR-116a ceiling.
      • -
      • GET me/avatar (UsersController.cs:44-59), resolving the subject from the token rather than a route parameter.
      • -
      • POST me/avatar (UsersController.cs:66-102) is the richest action. [RequestSizeLimit(MaxAvatarBytes)] (:67) rejects an oversized body at the pipeline before any of it is buffered; the inline guard then re-checks null, zero-length, and over-limit and returns a validation error (:78-84), so the limit is enforced twice for two different failure modes. The stream is copied into a right-sized MemoryStream (:86-93), with both await using scopes carrying ConfigureAwait(false), and the byte array is handed to SetUserAvatarCommand (:95-97). The action doc records what the handler then does (:61-65): sniff the real format, re-encode to 256x256 JPEG, return the public URL. Trusting a declared content type here would be an upload vulnerability.
      • -
      • DELETE me/avatar (UsersController.cs:105-120), documented idempotent, returning 204.
      • -
      • GET users (UsersController.cs:124-145): eight [FromQuery] parameters with [Range(1, int.MaxValue)] on both paging values (:132-133), so a pageNumber=0 is a model-binding 400 rather than a handler concern.
      • -
      • GET {userId}/export (UsersController.cs:149-168) and DELETE {userId} (UsersController.cs:171-190), the two ownership-checked actions, both declaring 403 and 404 in their ProducesResponseType set (:151-152, :173-174).
      • +
      • Walkthrough: class-level [Authorize] (UsersController.cs:31) makes every action authenticated by default, and each action re-checks currentUserService.UserId is null and returns Unauthorized() for the "authenticated but no usable id" case.
          +
        • MaxAvatarBytes = 2 * 1024 * 1024 (UsersController.cs:42), the BR-116a ceiling.
        • +
        • GET me/avatar (UsersController.cs:45-60), resolving the subject from the token rather than a route parameter.
        • +
        • POST me/avatar (UsersController.cs:73-110) is the richest action, and it carries three attributes worth reading together. [Idempotent] (:74) routes the request through the framework's Idempotency-Key filter (ADR-017); the action doc explains the judgement call (:66-71), which is that the upload replaces the caller's avatar rather than appending one, so replaying the stored URL for a repeated key is both safe and useful (it skips a second re-encode of identical bytes and stops a flaky mobile upload from looking like a failure the user has to redo). [RequestSizeLimit(MaxAvatarBytes)] (:75) rejects an oversized body at the pipeline before any of it is buffered; the inline guard then re-checks null, zero-length, and over-limit and returns a validation error (:86-92), so the limit is enforced twice for two different failure modes. The stream is copied into a right-sized MemoryStream (:94-101), with both await using scopes carrying ConfigureAwait(false), and the byte array is handed to SetUserAvatarCommand (:103-105). The action doc also records what the handler then does (:62-65): sniff the real format (jpeg/png/webp), re-encode to 256x256 JPEG, return the public URL. Trusting a declared content type here would be an upload vulnerability.
        • +
        • DELETE me/avatar (UsersController.cs:112-128), documented idempotent, returning 204.
        • +
        • GET users (UsersController.cs:130-153): eight [FromQuery] parameters with [Range(1, int.MaxValue)] on both paging values (:140-141), so a pageNumber=0 is a model-binding 400 rather than a handler concern.
        • +
        • GET {userId}/export (UsersController.cs:155-176) and DELETE {userId} (UsersController.cs:178-198), the two ownership-checked actions, both declaring 403 and 404 in their ProducesResponseType set (:159-160, :181-182).
      • Why it's built this way: injecting six separate closed handler interfaces rather than one "user service" is what keeps each endpoint on its own vertical slice and makes the decorator pipeline the single place where cross-cutting behavior lives (ADR-014). The consistent HandleFailure(result.Errors) tail means every failure becomes a ProblemDetails with the same shape, mapped once in ApiControllerBase.
      • -
      • Where it's used: mounted by the Identity service host and routed through the Gateway; consumed by UserService on the client side, and warmed at startup by SelfHttpWarmupTask, whose expected 401 comes from this class's [Authorize] plus [HasPermission] pair.
      • +
      • Where it's used: mounted by the Identity service host and routed through the Gateway; consumed by UserService on the client side, and warmed at startup by SelfHttpWarmupTask, which replays users?pageNumber=1&pageSize=10 against this host's own Kestrel endpoint (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/SelfHttpWarmupTask.cs:35) and treats the resulting 401 as the expected outcome (:45), because an unauthenticated self-request hits this class's [Authorize] plus [HasPermission] pair.
      • +
      +

      PasswordResetController

      +
      +

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

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

      AuthController

      -

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

      +

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

      • What it is: the /auth endpoint surface: login, register, refresh, revoke, change password, and get/set the stored culture and theme preferences. Most of it is inherited; this class overrides two actions and supplies the two command factories.
      • Depends on: UserAccountAuthControllerBase<TChangePasswordCommand, TChangePreferencesCommand> (which itself extends AuthControllerBase), IAuthenticationService, ICurrentUserService, ChangePasswordCommand, ChangePreferencesCommand, GetUserPreferencesQuery, AuthenticationResponse, RegisterRequest, LoginRequest; externals: ASP.NET Core rate limiting ([EnableRateLimiting]).
      • -
      • Concept introduced, the generic controller base parameterized by the app's command types. [Rubric §16, Maintainability], [Rubric §1, SOLID], [Rubric §11, Security]. The base owns the four token actions plus PUT password (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:86), PUT preferences (:112), and GET preferences (:138). It cannot own the command records, because ADC marks its change-password command ICacheInvalidating with a cache prefix built from ADC's own User type while Store does not; the shared handler base's remarks record that reason (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:16-21). The resolution is two abstract factory methods, which this class implements as one-liners (AuthController.cs:82-89): the shared workflow stays shared, and each app keeps its own command semantics.
      • +
      • Concept introduced, the generic controller base parameterized by the app's command types. [Rubric §16, Maintainability], [Rubric §1, SOLID], [Rubric §11, Security]. The base owns the four token actions plus PUT password (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/UserAccountAuthControllerBase.cs:86), PUT preferences (:112), and GET preferences (:138). It cannot own the command records, because ADC marks its change-password command ICacheInvalidating with a cache prefix built from ADC's own User type while Store does not; the shared handler base's remarks record that reason (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs:16-21). The resolution is two abstract factory methods, which this class implements as one-liners (AuthController.cs:82-89): the shared workflow stays shared, and each app keeps its own command semantics. PasswordResetController is the same pattern applied to the recovery pair.
      • Walkthrough: primary constructor forwarding five dependencies to the base (AuthController.cs:29-40), then four members.
        • RegisterAsync (AuthController.cs:52-63) is a genuine override rather than a pass-through. It reads HttpContext.Connection.RemoteIpAddress and passes it to AuthenticationService.RegisterAsync (:57-58), which is the BR-213 registration rate limiting: the Application layer cannot see the connection, so the IP has to be captured here and handed down. Success returns 201 Created explicitly rather than 200 (:62).
        • -
        • LoginAsync (AuthController.cs:76-79) overrides only to re-declare attributes, then calls base.LoginAsync. The reason is the attribute set: [EnableRateLimiting(WebApplicationBuilderExtensions.RateLimitPolicyAuthIp)] (:72, the constant resolves to "auth-ip" at MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:41) and the documented 429 (:75). The doc comment (:65-69) states the threat model precisely: the per-email lockout of BR-212 cannot stop one source spraying a single common password across many different emails, so a per-IP fixed window sits on top of it (ADR-019, ADR-029). Both anonymous endpoints, register and login, carry the same policy (:48, :72).
        • +
        • LoginAsync (AuthController.cs:76-79) overrides only to re-declare attributes, then calls base.LoginAsync. The reason is the attribute set: [EnableRateLimiting(WebApplicationBuilderExtensions.RateLimitPolicyAuthIp)] (:72, the constant resolves to "auth-ip" at MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.cs:46) and the documented 429 (:75). The doc comment (:65-69) states the threat model precisely: the per-email lockout of BR-212 cannot stop one source spraying a single common password across many different emails, so a per-IP fixed window sits on top of it (ADR-019, ADR-029). Both anonymous endpoints, register and login, carry the same policy (:48, :72).
        • CreateChangePasswordCommand and CreateChangePreferencesCommand (AuthController.cs:82-89), the two factory implementations that bind ADC's command records to the base's workflow.
      • Why it's built this way: [Route("[controller]")] (AuthController.cs:27) makes the prefix /auth, which is what the Gateway's route map fronts, and [ApiVersion("1.0")] (:28) keeps it on the header-based versioning scheme. Both anonymous actions are marked [AllowAnonymous] with the fully qualified attribute name (:47, :71) because the file's using set does not import the authorization namespace.
      • -
      • Where it's used: the entry point for every authenticated ADC client. The Blazor and MAUI clients call login/register/refresh through the Gateway; the Profile page uses PUT auth/password and the preferences pair; ChangePasswordRequestValidator guards the password action through the Validating decorator.
      • +
      • Where it's used: the entry point for every authenticated ADC client. The Blazor and MAUI clients call login/register/refresh through the Gateway; the Profile page uses PUT auth/password and the preferences pair; ChangePasswordRequestValidator guards the password action through the Validating decorator. Its anonymous sibling on the same /Auth prefix is PasswordResetController.

      ChangePreferencesCommand

      @@ -2103,7 +2234,7 @@

      ChangePreferencesCommand

      • What it is: the command that persists one user's culture and theme preferences, the write side of ADR-027 / ADR-028. It pairs the target UserId with the partial ChangePreferencesRequest and evicts the user cache so a preference change cannot be masked by a stale cached read.
      • Depends on: ChangePreferencesRequest; the UserIdentifierType alias (= int in this module); User (only for typeof(User).FullName); ICacheInvalidating and IUserScopedCommand<out TRequest>.
      • -
      • Concept reinforced, marker-driven pipeline behavior (introduced at ChangePasswordCommand). [Rubric §12, Performance & Scalability] assesses caching with correct invalidation, and [Rubric §6, CQRS & Event-Driven] assesses whether a command is a named intention carrying exactly what the write needs. The instructive detail is what this record does not implement: the declaration lists only ICacheInvalidating and IUserScopedCommand<ChangePreferencesRequest> (ChangePreferencesCommand.cs:15), with no ICommandWithRequest<out TRequest>, so no CommandRequestValidator is auto-registered and the payload is not run through FluentValidation at the edge. IUserScopedCommand<out TRequest>'s own <remarks> records why the two markers are separate rather than merged: the automatic-validation opt-in is a per-application decision, and ADC and Store agree on it for the password change but disagree for preferences (MMCA.Common/Source/Core/MMCA.Common.Application/Users/IUserScopedCommand.cs:6-11). Skipping edge validation is safe here only because the aggregate checks both values itself: User.UpdatePreferences combines the supported-culture allowlist and the light/dark rule through UserInvariants, and the shared handler base propagates that invariant failure as the command's failure.
      • +
      • Concept reinforced, marker-driven pipeline behavior (introduced at ChangePasswordCommand). [Rubric §12, Performance & Scalability] assesses caching with correct invalidation, and [Rubric §6, CQRS & Event-Driven] assesses whether a command is a named intention carrying exactly what the write needs. The instructive detail is what this record does not implement: the declaration lists only ICacheInvalidating and IUserScopedCommand<ChangePreferencesRequest> (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePreferences/ChangePreferencesCommand.cs:15), with no ICommandWithRequest<out TRequest>, so no CommandRequestValidator<TCommand, TRequest> is auto-registered and the payload is not run through FluentValidation at the edge. IUserScopedCommand<out TRequest>'s own <remarks> records why the two markers are separate rather than merged: the automatic-validation opt-in is a per-application decision, and ADC and Store agree on it for the password change but disagree for preferences (MMCA.Common/Source/Core/MMCA.Common.Application/Users/IUserScopedCommand.cs:6-11). Skipping edge validation is safe here only because the aggregate checks both values itself: User.UpdatePreferences combines the supported-culture allowlist and the light/dark rule through UserInvariants, and the shared handler base propagates that invariant failure as the command's failure.
      • Walkthrough: a two-parameter positional record (UserIdentifierType UserId, ChangePreferencesRequest Request) (ChangePreferencesCommand.cs:14), plus one computed member, CachePrefix => $"{typeof(User).FullName}:" (:18). Deriving the prefix from the type rather than from a string literal keeps it in lockstep with the key the user cache actually uses: rename or move User and the prefix follows.
      • Why it's built this way: the command record deliberately stays application-side rather than moving into the framework alongside its handler, because ADC marks it ICacheInvalidating with a prefix built from its own User type and Store does not, so a single shared record could not preserve both behaviors (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePreferences/ChangePreferencesHandlerBase.cs:16-20). Expressing eviction as an interface the command implements keeps cache management a decorator concern instead of handler boilerplate.
      • Where it's used: constructed by AuthController's CreateChangePreferencesCommand override (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AuthController.cs:87-89) for the profile page and the app-bar culture and theme switchers; handled by ChangePreferencesHandler.
      • @@ -2118,19 +2249,19 @@

        DeleteUserCommand

      • Concept introduced, the owner-or-privileged-role request shape. [Rubric §11, Security] assesses whether authorization decisions are made from data the caller cannot forge, and [Rubric §1, SOLID] covers why this is an interface rather than a naming convention. IUserOwnedRequest extends IUserScopedRequest with CurrentUserId and a nullable CurrentUserRole (MMCA.Common/Source/Core/MMCA.Common.Application/Users/IUserOwnedRequest.cs:8-15), which is exactly the triple the shared UserOwnershipRule needs to answer "may this caller act on that account". Both extra values are filled from the token by the controller, never from the body, so a client cannot claim a role it was not issued. Only two ADC use cases wear this shape, deletion and data export, and they are precisely the two that must let an Organizer act on someone else's row.
      • Walkthrough: a three-parameter positional record, UserId, CurrentUserId, CurrentUserRole (DeleteUserCommand.cs:11-14), plus the computed CachePrefix => $"{typeof(User).FullName}:" (:17). CurrentUserRole is string? because a token may carry no role claim at all, and the null case must resolve to "no privilege" rather than to an exception.
      • Why it's built this way: modelling the caller as part of the command, rather than reaching for an ambient HttpContext inside the handler, is what keeps the handler testable without a web host and keeps the Application layer free of ASP.NET types ([Rubric §3, Clean Architecture], [Rubric §14, Testability]).
      • -
      • Where it's used: constructed by UsersController's DeleteAsync from currentUserService.UserId and currentUserService.Role (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:183-185); handled by DeleteUserHandler.
      • +
      • Where it's used: constructed by UsersController's DeleteAsync from currentUserService.UserId and currentUserService.Role (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/UsersController.cs:183-192); handled by DeleteUserHandler.
      -

      ModuleApplicationDbContext

      +

      ResetPasswordCommand

      -

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

      +

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

        -
      • What it is: the Identity module's abstract EF Core context. It declares the module's one entity set, Users, and inherits everything else from the framework ApplicationDbContext.
      • -
      • Depends on: ApplicationDbContext (base), IEntityConfigurationAssemblyProvider, PhysicalDataSource, EF Core's DbContextOptions, IServiceProvider, and User.
      • -
      • Concept reinforced, one context class per engine, never one per module. [Rubric §8, Data Architecture] assesses how ownership of tables is expressed. The name can mislead on first reading: this type is not what gets instantiated. It is an abstract declaration of what Identity contributes to a context, and the concrete per-engine class (SQLServerDbContext in production today) inherits it and supplies the provider options (ADR-006, ADR-018). Every other module declares a same-named abstract class in its own namespace, and the doc comment (ModuleApplicationDbContext.cs:9-14) states the division of labour plainly: the base handles audit fields, soft deletes, and domain-event dispatch through EF interceptors, so a module context is a declaration of entity sets and nothing more.
      • -
      • Walkthrough: a primary constructor forwarding all four parameters straight to the base (ModuleApplicationDbContext.cs:15-20), then the single member internal DbSet<User> Users { get; set; } (:22). Note the accessibility: internal, not public. Application code reaches users through IUnitOfWork and the repositories, so nothing outside the Infrastructure assembly has a reason to touch the set directly. The mapping is not here either: it is discovered from the assembly named by IEntityConfigurationAssemblyProvider and supplied by UserConfiguration.
      • -
      • Why it's built this way: keeping the module's contribution abstract is what lets the same entity declarations be hosted by a SQL Server context in production and, with no code change, by a different engine's context. It is also what makes the "never split the context per module" rule enforceable: modules add abstract declarations, they never introduce a second concrete context class (ADR-006).
      • -
      • Where it's used: inherited by the concrete engine context the framework's physical context factory builds for the ADC_Identity database; that database also carries its own dbo.OutboxMessages table, so Identity's outbox never contends with another service's.
      • +
      • What it is: the second half of the forgot-password vertical (ADR-091). It carries the address, the single-use token from the reset email, and the new password, and it evicts the user cache because the credential the cached aggregate holds has just changed.
      • +
      • Depends on: ResetPasswordRequest (from MMCA.Common.Shared.Auth); ICommandWithRequest<out TRequest> and ICacheInvalidating; User, for typeof(User).FullName only.
      • +
      • Concept reinforced, the same two markers with the opposite validation decision. Put this record next to ChangePreferencesCommand above and the marker system explains itself. Both are one-line records with the same CachePrefix; the difference is that this one does implement ICommandWithRequest<ResetPasswordRequest> (ResetPasswordCommand.cs:15), which opts it into automatic CommandRequestValidator<TCommand, TRequest> registration, so the validating decorator runs ResetPasswordRequestValidator before the handler ever sees the command. [Rubric §11, Security]: that validator includes StrongPasswordRules<T> over NewPassword (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/ResetPasswordRequestValidator.cs:23), with the stated reason that a reset must not become a way around the complexity policy that registration and change-password enforce (:8-10). [Rubric §6, CQRS & Event-Driven]: notice there is no UserId parameter. The caller is anonymous at this point, so the account is not named by the request at all: it is recovered from the redeemed token inside the handler, which is what stops the endpoint from being usable to set an arbitrary account's password.
      • +
      • Walkthrough: a single-parameter positional record (ResetPasswordRequest Request) (ResetPasswordCommand.cs:14) implementing both markers (:15), plus CachePrefix => $"{typeof(User).FullName}:" (:18). The payload itself is a readonly record struct of three strings, Email, Token, NewPassword (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ResetPasswordRequest.cs:9-12), whose doc comment marks NewPassword as transmitted over TLS and never logged (:8).
      • +
      • Why it's built this way: the record stays application-side while its workflow lives in the framework, for the reason the base states in its own <remarks>: the shared handler reads the command only through ICommandWithRequest<ResetPasswordRequest>, so each application keeps its own record and its own cache-invalidation decision (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:23-26). That is the same hoist boundary the change-password vertical uses, and the summary on this record says so explicitly (ResetPasswordCommand.cs:9-11).
      • +
      • Where it's used: built by PasswordResetController's CreateResetPasswordCommand override, a single expression-bodied new(request) (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/PasswordResetController.cs:39), behind POST /Auth/reset-password; handled by ResetPasswordHandler.

      UserConfiguration

      @@ -2152,8 +2283,10 @@

      UserConfiguration

    • Indexes (:115-126): a unique index on Email, which is the BR-200 "email is the identity" rule enforced at the storage layer; a filtered index on RefreshToken with HasFilter("[RefreshToken] IS NOT NULL"), indexing only the rows that have an outstanding session; a unique filtered index on LinkedSpeakerId, which makes the User-to-Speaker link 1:1 while still allowing the many users who have no linked speaker; and a unique filtered composite on (LoginProvider, ProviderKey), so one external identity cannot be attached to two accounts.
    +
  • Concept introduced, what soft delete does to a unique index. [Rubric §8, Data Architecture]. Read the Email index again: it declares IsUnique() and no filter (:115), yet the row it guards is never physically deleted. A soft-deleted row still occupies its unique slot, so without help the address of a deleted account could never be reused. The help is a model-finalizing convention rather than a hand-written filter: SoftDeleteUniqueIndexConvention walks every soft-deletable entity type at model finalization and sets an IsDeleted = 0 filter on each unique index that does not already declare one (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/SoftDeleteUniqueIndexConvention.cs:36-55), building the predicate through the same SoftDeleteFilterSql.Build a hand-authored index would reach (:47), and no-opping for Cosmos (:33-34) (ADR-095). The rule that matters when reading this file is stated in the convention's own doc comment: hand-authored filters win, an index that already declares a filter is left untouched (:17-21, :53). So Email gains the deleted-row exclusion automatically, while the two indexes that spell their own HasFilter (LinkedSpeakerId, and the LoginProvider/ProviderKey composite) keep exactly the predicate written here and nothing more.
  • Why it's built this way: [Rubric §12, Performance & Scalability]: the three filtered indexes are all sparse-column cases where the vast majority of rows are null, so filtering keeps each index small and keeps writes to the common rows out of it entirely. [Rubric §11, Security]: the unique constraints on email and on the provider pair are the last line of defence behind the application-level uniqueness probes, so a race between two concurrent registrations fails at the database rather than producing two accounts.
  • Where it's used: discovered by assembly scan through IEntityConfigurationAssemblyProvider and applied when the concrete engine context builds the model declared by ModuleApplicationDbContext; the resulting schema is materialized by the per-service Identity migrations project.
  • +
  • Caveats / not-in-source: because the hand-written filters take precedence, the erasure path is what frees the two provider slots: User.Anonymize nulls LoginProvider and ProviderKey and rewrites the address to a per-id deleted-{Id}@anonymized.invalid placeholder (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:392-418). LinkedSpeakerId is not cleared there, so a soft-deleted user keeps its speaker link occupied in that unique index.
  • AttendeeQueryServiceGrpcAdapter

    @@ -2170,7 +2303,7 @@

    AttendeeQueryServiceGrpcAdapter

  • Why it's built this way: [Rubric §29, Resilience & Business Continuity]. Choosing a deadline shorter than the retry budget is the difference between "this dependency is slow" and "this request never returns". Letting transport failures surface rather than swallowing them means a broadcast that could not determine its audience fails visibly instead of quietly notifying nobody.
  • -
  • Where it's used: registered by DependencyInjection's AddIdentityAttendeeClient in this same project, which the Notification service host calls after module registration (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:210-218). Its server-side counterpart is AttendeesGrpcService.
  • +
  • Where it's used: registered by DependencyInjection's AddIdentityAttendeeClient in this same project, which the Notification service host calls after module registration (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:216-224). Its server-side counterpart is AttendeesGrpcService.
  • Caveats / not-in-source: the interface returns a plain list rather than a Result, so a transport fault reaches the caller as a raw RpcException after the Polly pipeline gives up; there is no Result.Failure translation on this path.
  • AttendeesGrpcService

    @@ -2211,17 +2344,17 @@

    DeleteUserHandler

  • What it is: ADC's account-deletion handler (UC-21). Unlike the two thin siblings it is not empty: it inherits the shared erasure workflow from DeleteUserHandlerBase<TUser, TCommand> and supplies the three genuinely ADC-specific pieces, the privileged role, the cross-service erasure announcement, and the post-commit tail.
  • Depends on: DeleteUserHandlerBase<TUser, TCommand> (base), IUnitOfWork, IFileStorageService, ICacheService, TimeProvider, SoftDeletedUserCache, UserRole, UserDeleted (the Identity.Shared integration event), SetUserAvatarHandler (for its TryGetBlobName helper), Result, and [LoggerMessage] logging.
  • Concept introduced (1), erasure as a fixed workflow with application-specific hooks. [Rubric §30, Compliance, Privacy & Data Governance] assesses whether a deletion request actually destroys personal data. The base (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:55-119) runs a fixed order: check ownership through UserOwnershipRule.CheckOwnership using the application's HasDeletePrivilege answer (:62-71); load the user, Error.NotFound when absent (:73-78); soft-delete (:88-93); run the application's tail (:95-100); anonymize (:103-107); save (:109); then run whatever post-commit actions the tail enqueued (:111-114) and log the erasure (:116). The two-step delete-then-anonymize is ADR-005's resolution of a real tension: the row must survive because other bounded contexts hold scalar UserId references and the audit trail depends on it, but the personal data must not survive, because the privacy promise is erasure. One detail in the base rewards a second read (:83-89): it dispatches through IErasableUser erasable = user; rather than calling user.Delete() directly, because member lookup on a type parameter prefers its class constraint, and ADC's User hides the base Delete() with public new Result Delete() (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:364). Interface dispatch is what guarantees the aggregate's own version (the one that revokes the refresh token first) actually runs.
  • -
  • Concept introduced (2), raising a cross-service integration event from inside the erasure. [Rubric §6, CQRS & Event-Driven] and [Rubric §30]. Personal data this account published into another service has to travel: Engagement holds a DisplayName snapshot on the leaderboard opt-in, Engagement is its own process with its own database, and the in-process domain event User.Delete() raises never reaches it. So the override calls user.AddDomainEvent(new UserDeleted(command.UserId, timeProvider.GetUtcNow())) (DeleteUserHandler.cs:62) on the aggregate, before the save. The comment (:56-61) states the invariant this buys: the outbox row is written by the very SaveChangesAsync that commits the erasure, so the fact and its announcement cannot come apart, whereas publishing after the commit would leave a crash window in which the account is gone and the published name is not. The event payload is deliberately just the id and a timestamp, because carrying a name or email would publish the very data the erasure exists to remove onto a broker that persists messages (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/IntegrationEvents/UserDeleted.cs:16-27).
  • +
  • Concept introduced (2), raising a cross-service integration event from inside the erasure. [Rubric §6, CQRS & Event-Driven] and [Rubric §30]. Personal data this account published into another service has to travel: Engagement holds a DisplayName snapshot on the leaderboard opt-in, Engagement is its own process with its own database, and the in-process domain event User.Delete() raises never reaches it. So the override calls user.AddDomainEvent(new UserDeleted(command.UserId, timeProvider.GetUtcNow())) (DeleteUserHandler.cs:62) on the aggregate, before the save. The comment (:56-61) states the invariant this buys: the outbox row is written by the very SaveChangesAsync that commits the erasure, so the fact and its announcement cannot come apart, whereas publishing after the commit would leave a crash window in which the account is gone and the published name is not. The event payload is deliberately just the id and a timestamp, because carrying a name or email would publish the very data the erasure exists to remove onto a broker that persists messages (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/IntegrationEvents/UserDeleted.cs:16-20).
  • Concept introduced (3), the post-commit action list. [Rubric §29, Resilience & Business Continuity]. The hook signature takes an ICollection<Func<CancellationToken, Task>> afterCommit (DeleteUserHandler.cs:46-50). Work that must not happen if the save fails is enqueued rather than run inline, which lets the override hand values it captured before anonymization into a post-commit closure without parking them in mutable handler state.
  • Walkthrough
    • Constructor and _logger field (:28-38): five dependencies, and the logger is then held explicitly in a field rather than captured from the primary constructor. The comment says why: the base also receives logger, and capturing the same parameter into this type's state would be the compiler error CS9107.
    • HasDeletePrivilege (:42-43): UserRole.IsOrganizer(currentUserRole), whose implementation is an OrdinalIgnoreCase comparison (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/UserRole.cs:76), with a <remarks> noting it is case-insensitive because a role claim may carry any casing. Store's equivalent answers with its Admin role; that one line is the entire difference in the authorization model between the two applications.
    • OnAfterSoftDeleteAsync (:46-88): it first captures the avatar blob name before anonymization clears the URL (:54), reusing SetUserAvatarHandler.TryGetBlobName (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/SetUserAvatar/SetUserAvatarHandler.cs:90). That ordering is the whole reason the hook runs where it does. It then raises the integration event (:62) and enqueues two post-commit actions in the order the pre-hoist handler ran them (:64): writing the shared soft-deleted marker through SoftDeletedUserCache.MarkDeletedAsync (:68-80), then deleting the avatar blob when there was one (:82-85). It returns Result.Success() (:87); returning a failure here would abort the erasure before anything is persisted.
    • -
    • Marker failure policy (:76-79, :90-93): the cache write is wrapped in its own try/catch that swallows everything except OperationCanceledException and logs a warning whose message spells out the consequence, that the deleted user's existing access token stays usable until it expires. The comment (:65-67) states the reasoning: the deletion is already committed by the time this runs, the marker only shortens the window in which an already-issued token keeps working, and a cache fault must not turn a successful erasure into a failure the caller would retry.
    • +
    • Marker failure policy (:76-79, :90-93): the cache write is wrapped in its own try/catch that swallows everything except OperationCanceledException and logs a warning whose message spells out the consequence, that the deleted user's existing access token stays usable until it expires. The comment (:65-67) states the reasoning: the deletion is already committed by the time this runs, the marker only shortens the window in which an already-issued token keeps working, and a cache fault must not turn a successful erasure into a failure the caller would retry. That is the shape ADR-096 later generalized into a policy, and this call site still spells the catch out by hand.
  • Why it's built this way: [Rubric §11, Security]. Access tokens are self-contained and valid until they expire, so deleting an account does not by itself stop a token already in the wild; the marker is what lets the shared SoftDeletedUserMiddleware reject those requests. Treating it as best-effort is the correct trade: erasure is the promise that must hold, and the token window is bounded anyway. [Rubric §30]: the avatar photo is personal data too (BR-116a), so the blob is deleted rather than merely unreferenced.
  • -
  • Where it's used: resolved as ICommandHandler<DeleteUserCommand, Result> and invoked by UsersController's DeleteAsync (UsersController.cs:183-185), which returns 204 on success; the callers are the Profile page's self-service deletion and the organizer UserList. The UserDeleted integration event it raises is consumed downstream by Engagement's UserDeletedPointsHandler.
  • +
  • Where it's used: resolved as ICommandHandler<DeleteUserCommand, Result> and invoked by UsersController's DeleteAsync (UsersController.cs:183-192), which returns 204 on success; the callers are the Profile page's self-service deletion and the organizer UserList. The UserDeleted integration event it raises is consumed downstream by Engagement's UserDeletedPointsHandler.
  • Caveats / not-in-source: the post-commit actions run sequentially inside the calling request (DeleteUserHandlerBase.cs:111-114), so a slow blob delete adds latency to the response; there is no background dispatch here.
  • GetUserPreferencesHandler

    @@ -2236,6 +2369,20 @@

    GetUserPreferencesHandler

  • Why it's built this way: the base's <remarks> (GetUserPreferencesHandlerBase.cs:15-19) records that the two application copies disagreed on the repository (ADC read, Store write) and that the read repository is the correct choice for a handler which never calls SaveChangesAsync, so Store gained a no-tracking read on adoption. That is the ordinary payoff of consolidating duplicated code: the merge forces a decision, and the better of the two behaviors wins for everyone.
  • Where it's used: resolved as IQueryHandler<GetUserPreferencesQuery, Result<UserPreferencesResponse>> and injected into AuthController (AuthController.cs:34) as GET /Auth/preferences; the response seeds the client's culture and theme at startup.
  • +

    ResetPasswordHandler

    +
    +

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

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

    DependencyInjection

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

    @@ -2247,22 +2394,34 @@

    DependencyInjection

    The consequence is an ordering rule, stated in the same comment (:33-37): call this from the host's Program.cs after ModuleLoader.DiscoverAndRegister(...), so the in-process or stub registration is in the container by the time Replace looks for it. Calling it earlier is not an error the compiler or the container reports; it simply leaves the wrong implementation in place.
  • Walkthrough: the method lives inside an extension(IServiceCollection services) block (:16), the workspace idiom for DI registration (see primer §4). AddIdentityAttendeeClient(string serviceName = "identity") (:41) does two things: AddTypedGrpcClient<AttendeeQueryService.AttendeeQueryServiceClient>(serviceName) (:43), which the framework wires to Aspire service discovery at http://{serviceName} over HTTP/2 cleartext with the standard JwtForwardingClientInterceptor and Polly resilience handler; and services.Replace(ServiceDescriptor.Scoped<IAttendeeQueryService, AttendeeQueryServiceGrpcAdapter>()) (:47), with the inline comment restating the Replace-not-TryAdd rule at the point of use (:45-46). It then returns services for chaining (:49). The serviceName default of "identity" matches the AppHost resource name, so the common case passes no argument.
  • Why it's built this way: keeping this helper in the .Contracts project rather than in the consuming service means the knowledge of "how you talk to Identity remotely" lives once, next to the .proto that defines the call, and every future consumer gets it with a project reference plus one line (ADR-007). The Scoped lifetime matches the in-process implementation it replaces, so swapping transports changes no lifetime assumption anywhere in the graph.
  • -
  • Where it's used: called by the Notification service host (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:218), whose surrounding comment (:210-217) repeats the Replace rationale at the call site; the matching AppHost wiring is noted at MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:212.
  • +
  • Where it's used: called by the Notification service host (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:224), whose surrounding comment (:216-223) repeats the Replace rationale at the call site; the matching AppHost wiring is noted at MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:213.
  • Caveats / not-in-source: this is the only public member of the class today, so the .Contracts project's DI surface is exactly this one call.
  • +

    ModuleApplicationDbContext

    +
    +

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

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

    IdentityModuleDbSeeder

    -

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

    +

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

    • What it is: the development and test seeder for Identity. It supplies three fixed accounts (one Organizer, two Attendees) to the framework's IdentityModuleDbSeederBase<TUser>, which owns the per-account idiom.
    • Depends on: IdentityModuleDbSeederBase<TUser> (base, itself a DbSeeder), IUnitOfWork, IPasswordHasher, SeedAccount, Email, User and UserRole, and Result in its generic form.
    • Concept introduced, the hoisted seeder with two typed hooks. [Rubric §17, DevOps] assesses repeatable environment setup, and [Rubric §16, Maintainability] covers the de-duplication. The five-step idiom (normalize the email, skip if it already exists, hash the password, build the aggregate, add, save) was written out five times across the two applications' Identity modules and now lives once in the base (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Seeding/IdentityModuleDbSeederBase.cs:92-113). Only two things could not be hoisted, and the base's <remarks> (:13-24) names both: CreateUser, because the two applications' User.Create(...) factories take the same values in different parameter orders and only the application can spell its own role vocabulary; and EmailExistsAsync, because the existence predicate must be written against the concrete User (never an interface member) so EF translates it byte-for-byte the way it did before the hoist. That second point is the general lesson for hoisting anything that ends up inside an expression tree: a where TUser : ISomething constraint would compile and then fail at query translation.
    • -
    • Concept introduced, seeding gated where the gate has one home. [Rubric §11, Security]. The base exposes a ShouldSeed opt-in that defaults to true (IdentityModuleDbSeederBase.cs:57), and this subclass deliberately does not override it (IdentityModuleDbSeeder.cs:17-19): ADC's Seeding:IncludeSampleUsers gate stays in IdentityModuleSeeder in the API layer, which reads the key with GetValue<bool> and returns before constructing this seeder at all (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/IdentityModuleSeeder.cs:28-35). One gate, one home. The class doc carries an explicit security notice (IdentityModuleDbSeeder.cs:21-25): the seed credentials ("Admin123!", "Password") are intentionally weak, exist only for local development convenience, and a deployed host must either disable seeding or supply environment-sourced secrets. The default of false when the configuration key is absent (IdentityModuleSeeder.cs:26-27) is what makes that safe by omission rather than by remembering.
    • +
    • Concept introduced, seeding gated where the gate has one home. [Rubric §11, Security]. The base exposes a ShouldSeed opt-in that defaults to true (IdentityModuleDbSeederBase.cs:57), and this subclass deliberately does not override it (IdentityModuleDbSeeder.cs:17-19): ADC's Seeding:IncludeSampleUsers gate stays in IdentityModuleSeeder in the API layer, which reads the key with GetValue<bool> and returns before constructing this seeder at all (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/IdentityModuleSeeder.cs:28-30). One gate, one home. The class doc carries an explicit security notice (IdentityModuleDbSeeder.cs:21-25): the seed credentials ("Admin123!", "Password") are intentionally weak, exist only for local development convenience, and a deployed host must either disable seeding or supply environment-sourced secrets. The default of false when the configuration key is absent (IdentityModuleSeeder.cs:26-27) is what makes that safe by omission rather than by remembering.
    • Walkthrough
      • Accounts (:33-38): a collection-expression IReadOnlyList<SeedAccount> with three entries, each (email, password, role, firstName, lastName). The roles come from UserRole's constants, not string literals.
      • EmailExistsAsync (:41-48): resolves the mutating repository through UnitOfWork.GetRepository<User, UserIdentifierType>() and calls ExistsAsync(u => u.Email == email, ...). The predicate compares the Email value object, which is why the base normalizes the raw string with Email.Create(account.Email).Value first (IdentityModuleDbSeederBase.cs:95): both sides of the comparison then go through the same value converter and the SQL matches.
      • -
      • CreateUser (:51-62): a null guard, then User.Create(email, firstName, lastName, passwordHash, passwordSalt, role) in ADC's parameter order, returning the aggregate's own Result<TUser>. A failure here is not thrown: the base skips that one account and moves on (IdentityModuleDbSeederBase.cs:104-108).
      • +
      • CreateUser (:51-62): a null guard, then User.Create(email, firstName, lastName, passwordHash, passwordSalt, role) in ADC's parameter order, returning the aggregate's own generic Result. A failure here is not thrown: the base skips that one account and moves on (IdentityModuleDbSeederBase.cs:104-108).
      • Idempotency and isolation come from the base loop: SeedAsync iterates the accounts (:67-70) and each account is saved individually (:110-112), so re-running against an already-seeded database is a no-op and one invalid account cannot roll back the others.
    • @@ -2284,6 +2443,7 @@

      IdentityModuleDbSeeder

    • Projects, one bounded context
    • The User aggregate: credentials, profile, and cross-context links in one root
    • Authentication: a thin subclass over the shared engine
    • +
    • Password recovery: the anonymous half of the credential lifecycle
    • The privacy pair: export and erasure
    • Avatars: the third mutating slice
    • Persistence, seeding, and the disabled stub
    • diff --git a/docs/onboarding/group-27-testing-infrastructure.html b/docs/onboarding/group-27-testing-infrastructure.html index dc5bac6..644c3fd 100644 --- a/docs/onboarding/group-27-testing-infrastructure.html +++ b/docs/onboarding/group-27-testing-infrastructure.html @@ -172,8 +172,8 @@

      27. Testing & Quality Infrastruc
    • Architecture fitness functions (IArchitectureMap, ArchitectureMapBase, Layer, LayerRef, ArchitectureAssert, RuleHelpers, - CrossEntityNavigationFinder, the twenty - ArchitectureRules partial files, and the thirty-six abstract *TestsBase + CrossEntityNavigationFinder, the twenty-two + ArchitectureRules partial files, and the thirty-eight abstract *TestsBase classes including RouteAuthorizationTestsBase, ModuleConformanceTestsBase<TModule> and BrandColorTokenTestsBase) turn architectural rules into @@ -189,7 +189,8 @@

      27. Testing & Quality Infrastruc PageExtensions, AxeOptions, AccessibilityViolationException, WebVitalsCollector, the reusable page objects - LoginPage / RegisterPage / ProfilePage, and + LoginPage / RegisterPage / ProfilePage / + ForgotPasswordPage / ResetPasswordPage, and the shipped workflow suites such as AuthorizationTestsBase) drive a real browser against a running app, asserting accessibility and performance alongside behavior.

    • @@ -199,6 +200,7 @@

      27. Testing & Quality Infrastruc ServiceInfoVersioningContractTestsBase<TFixture>, GracefulShutdownTestsBase<TEntryPoint>, DecoratorPipelineOrderTestsBase<TCommand, TCommandResult, TQuery, TQueryResult>, + MiddlewarePipelineOrderTestsBase, DependencyInjectionAssert, HandlerTestBase<THandler>) pin cross-cutting HTTP and pipeline guarantees so a refactor cannot silently drop them. @@ -214,7 +216,7 @@

      27. Testing & Quality Infrastruc ADR-058 for the runtime conformance suites that cover exactly what ADR-015 declared out of scope: "the tests assert structure / registration, not runtime behavior" - (Website/docs-src/adr/015-architecture-fitness-functions.md:57).

      + (Website/docs-src/adr/015-architecture-fitness-functions.md:61-62).

      Integration tests: a real host, a throwaway database, a per-test reset

      The integration tier boots the actual application, not a mock of it. The abstraction at its center is IIntegrationTestFixture @@ -251,10 +253,13 @@

      Int DataSources entry at localhost) does not load, leaving the resolver to collapse onto the overridden top-level connection string, a single-database monolith shape (:16-24). Server selection defaults to LocalDB but is overridable through SqlBaseEnvironmentVariable (:58, read at :69-70) - so CI can target a SQL service container. The fixture also exposes ConnectionString (:45) so - SQL-fidelity tests can read the raw tables, and Services (:52) so a cross-service test can - resolve a consumer-side handler out of the booted host. Because these fixtures need a reachable SQL - Server, the per-module *.Integration.slnf suites build in a headless sandbox but only run in CI.

      + so CI can target a SQL service container. Subclasses push their own host-specific settings (test JWT + key material, throttle lifts, faked gRPC edges) through the ConfigureTestEnvironment hook (:142, + invoked at :77), which routes them through the same restore bookkeeping. The fixture also exposes + ConnectionString (:45) so SQL-fidelity tests can read the raw tables, and Services (:52) so a + cross-service test can resolve a consumer-side handler out of the booted host. Because these fixtures + need a reachable SQL Server, the per-module *.Integration.slnf suites build in a headless sandbox + but only run in CI.

      One tier up sits CrossServiceFixtureBase (MMCA.Common.Testing/CrossServiceFixtureBase.cs:41), which boots several hosts in one process against a real Testcontainers SQL Server and a real Testcontainers RabbitMQ @@ -287,8 +292,10 @@

      Int RSA keypair (DefaultPublicKeyPem at :49, DefaultPrivateKeyPem at :68) under a fixed kid of mmca-test-key (:41), so integration tests exercise the exact JWKS/RS256 validation code path production runs (ADR-004); - the class remarks flag, correctly, that the committed keypair is insecure by design and must never be - used in a real deployment (:22-28). FeatureManagementTestExtensions + its ConfigureInProcessTokenValidation (:167) is what a test factory calls to re-point a host's + JwtBearerOptions at that committed key instead of a network authority. The class remarks flag, + correctly, that the committed keypair is insecure by design and must never be used in a real + deployment (:22-28). FeatureManagementTestExtensions (MMCA.Common.Testing/FeatureManagementTestExtensions.cs:10) adds a ConfigureTestFeatureFlags extension member (:21) that builds an in-memory FeatureManagement:* configuration (:24-32) so a test WebApplicationFactory can flip a gate without touching appsettings.json. @@ -324,11 +331,11 @@

      Architecture f deliberately includes optional layers (Ui, Grpc, Contracts, ServiceHost, IArchitectureMap.cs:16-19) that a repo simply omits, so a rule iterating them is vacuously satisfied with no compile dependency on an absent assembly (IArchitectureMap.cs:3-7).

      -

      The rule bodies are split across twenty ArchitectureRules partial files - (cancellation tokens, controllers, cycles, entities, events, governance, handlers, handler results, - idempotency, immutability, layers, localization, localized text, modules, naming, protos, purity, - slices, specifications, and transport; the partial type is declared in the first of them at - MMCA.Common.Testing.Architecture/ArchitectureRules.CancellationTokens.cs:5). The +

      The rule bodies are split across twenty-two ArchitectureRules partial files + (cancellation tokens, contracts, controllers, cycles, entities, events, governance, handlers, handler + results, idempotency, immutability, layers, localization, localized text, modules, naming, protos, + purity, slices, specifications, transport, and upcasters; the partial type is declared in the first + of them at MMCA.Common.Testing.Architecture/ArchitectureRules.CancellationTokens.cs:5). The aggregate-convention rules live inside ArchitectureRules.Entities.cs (for example DomainExposesAggregateRoots at MMCA.Common.Testing.Architecture/ArchitectureRules.Entities.cs:8, AggregateRootsHaveResultFactory at :19, and the generalized DomainFactoriesReturnResult at @@ -344,9 +351,10 @@

      Architecture f supplying its map. AggregateConventionTestsBase shows the shape in miniature: one abstract Map property and one [Fact] per rule (MMCA.Common.Testing.Architecture/Bases/AggregateConventionTestsBase.cs:12-24). The package ships - 104 test methods across 36 abstract *TestsBase classes, of which MMCA.Common's own build executes - 99 (MMCA.Common/FACTS.md:44-48, a generated and CI-gated count: read it there rather than - restating it elsewhere).

      + 110 test methods across 38 abstract *TestsBase classes, and MMCA.Common's own build executes + 129 of them (the methods of the bases its arch-tests subclass, plus its Common-only direct tests + such as FrameworkSanityTests and SpecificationFitnessTests), per MMCA.Common/FACTS.md:43-48: a + generated and CI-gated count, so read it there rather than restating it elsewhere.

      Failures report through ArchitectureAssert (MMCA.Common.Testing.Architecture/ArchitectureAssert.cs:8), which has two overloads: one lists the failing types from a NetArchTest TestResult (ArchitectureAssert.cs:11-23), the other lists a @@ -392,7 +400,7 @@

      Architecture f token (BrandColorTokenTestsBase.cs:15-16,41-49), with a non-empty check on the embedded list so the guard cannot pass vacuously (:27-28). DependencyVersionTestsBase (MMCA.Common.Testing.Architecture/Bases/DependencyVersionTestsBase.cs:15, [Rubric §32, Dependency - & Supply-Chain]) parses Directory.Packages.props and fails the build on two commercial-license + & Supply-Chain]) checks the repo's pinned package majors and fails the build on two commercial-license traps a blanket package bump would otherwise walk into unnoticed: MassTransit at major 9 (DependencyVersionTestsBase.cs:24-37, ADR-016) and @@ -434,9 +442,17 @@

      Architecture f .proto contract and diffs it against a committed snapshot, and it is explicitly consumer-facing, because MMCA.Common ships the gRPC plumbing rather than any contracts of its own (Bases/ProtoContractTestsBase.cs:3-11). Sibling bases pin integration-event contracts - (ADR-010), - data residency, forms conventions, localization resources, - concurrency, controller shape, + (ADR-010), the + one-upcaster-per-source-contract rule that keeps the upcast chain a function (so which contract a + handler receives cannot depend on DI registration order, + MMCA.Common.Testing.Architecture/ArchitectureRules.Upcasters.cs:7-12, + ADR-090), + service-contract purity so an extracted service's wire surface + carries only Shared and contract types and never the producer's internals + (MMCA.Common.Testing.Architecture/ArchitectureRules.Contracts.cs:13-18, + ADR-007), data residency, forms + conventions, localization resources, concurrency, + controller shape, state management, UI architecture, and framework-version consistency, so the governance-as-tests pattern spans much of the 34-category rubric.

      @@ -577,24 +593,35 @@

      (WebVitalsCollector.cs:104-108), and skipping the INP assertion when no interaction cleared the 16 ms threshold (:148-151), [Rubric §23, Front-End Performance] (the source tags it rubric §12). LCP and CLS are Chromium-only, so on Firefox and WebKit those fields stay 0 and the observers fail - silently rather than throwing (WebVitalsCollector.cs:14-16,22-25). The reusable identity page - objects LoginPage (MMCA.Common.Testing.E2E/PageObjects/LoginPage.cs:6), - RegisterPage (MMCA.Common.Testing.E2E/PageObjects/RegisterPage.cs:6), and - ProfilePage (MMCA.Common.Testing.E2E/PageObjects/ProfilePage.cs:6) wrap the - framework's real auth surfaces with role- and label-based locators (LoginPage.cs:12-18) and route - their own fills through the anti-race helper (LoginPage.cs:31-32, invoked at :25-26); downstream - apps add their own family, for example the 45 MMCA.ADC.E2E.Tests page objects covering events, - sessions, speakers, rooms, questions, feedback, sponsors, and the QR check-in and points surfaces. - Whole workflows ship too, not just page objects: six abstract suites under - MMCA.Common.Testing.E2E/Workflows/ (AuthorizationTestsBase, UserLoginTestsBase, - UserRegistrationTestsBase, LogoutTestsBase, ProfileManagementTestsBase, and + silently rather than throwing (WebVitalsCollector.cs:14-16,22-25).

      +

      Five reusable identity page objects ship with the package: LoginPage + (MMCA.Common.Testing.E2E/PageObjects/LoginPage.cs:6), RegisterPage + (MMCA.Common.Testing.E2E/PageObjects/RegisterPage.cs:6), ProfilePage + (MMCA.Common.Testing.E2E/PageObjects/ProfilePage.cs:6), + ForgotPasswordPage + (MMCA.Common.Testing.E2E/PageObjects/ForgotPasswordPage.cs:6), and + ResetPasswordPage + (MMCA.Common.Testing.E2E/PageObjects/ResetPasswordPage.cs:6). They wrap the framework's real auth + surfaces with role- and label-based locators (LoginPage.cs:12-18) and route their own fills through + the anti-race helper (LoginPage.cs:31-32, invoked at :25-26); downstream apps add their own + family, for example the 45 MMCA.ADC.E2E.Tests page objects covering events, sessions, speakers, + rooms, questions, feedback, sponsors, and the QR check-in and points surfaces. Whole workflows + ship too, not just page objects: seven abstract suites under MMCA.Common.Testing.E2E/Workflows/ + (AuthorizationTestsBase, UserLoginTestsBase, UserRegistrationTestsBase, LogoutTestsBase, + ProfileManagementTestsBase, PasswordResetTestsBase, and UserPreferencesTestsBase) are authored once and re-run per consumer. Their shape is the same supply-only-your-facts contract as the fitness bases: AuthorizationTestsBase (MMCA.Common.Testing.E2E/Workflows/Identity/AuthorizationTestsBase.cs:18) asks the subclass only for its route lists (ProtectedPaths :26, PublicPaths :29, optional AuthenticatedUserPath :35 and AdminPaths :44) and owns the assertions, including the non-empty guard that keeps the - anonymous-redirect check from passing vacuously (:49-50).

      + anonymous-redirect check from passing vacuously (:49-50). + PasswordResetTestsBase + (MMCA.Common.Testing.E2E/Workflows/Identity/PasswordResetTestsBase.cs:17) shows where the tier + draws its own boundary: it asserts the recovery flow is reachable from the login page (:24-41) and + that an unknown address produces the identical anti-enumeration confirmation (:43-58), but + deliberately does not consume a real reset token, because that token only reaches the user by email + and so belongs to an app-side integration test (:10-16).

      Component and E2E coverage of MMCA.Common's own UI needs a page to render, but the framework is not a runnable app. MMCA.Common.UI.Gallery is a deliberately backend-less Blazor host that renders the @@ -666,7 +693,7 @@

      Contract, pipeline, and benchmark IHost.StopAsync under a bounded token defaulting to 20 seconds (:28,:55-56), and asserts ApplicationStopping then ApplicationStopped fired (:58-61). The failure it catches, a hosted service that refuses to drain, is invisible in production until it wedges a rolling deploy.

      -

      Three bases guard the composition of the pipeline itself. +

      Four bases guard the composition of the pipelines themselves. DecoratorPipelineOrderTestsBase<TCommand, TCommandResult, TQuery, TQueryResult> (MMCA.Common.Testing/DecoratorPipelineOrderTestsBase.cs:38) is the opt-in fitness function for ADR-014: it builds a real @@ -682,6 +709,18 @@

      Contract, pipeline, and benchmark TryDecorate applies decorators in reverse registration order, an innocent-looking reorder of the AddApplicationDecorators() lines silently changes runtime behavior, and this base turns that into a test failure (see group 5). + MiddlewarePipelineOrderTestsBase + (MMCA.Common.Testing/MiddlewarePipelineOrderTestsBase.cs:29) is its counterpart on the HTTP edge: + it seeds MiddlewarePipelineBuilder.CreateDefault, applies the host's own Configure customization + when it has one (:35), and asserts the eighteen-step order from the exception handler down to the + controller endpoints (:38-58). Several adjacencies there are load-bearing (the pre-forwarded + capture immediately before UseForwardedHeaders, authentication immediately before tenant + resolution, authentication before the rate limiter per + ADR-019, forwarded headers before the + HTTPS redirect), and a reorder that breaks one of them fails at runtime looking like a configuration + bug: an unreachable jwks_uri, a tenant that never resolves, a per-user rate cap that never engages + (:13-18). Unlike the contract bases it needs no host at all, because the steps are pure data until + they are applied, so it runs in the fast unit tier (:24-27). DependencyInjectionAssert (MMCA.Common.Testing/DependencyInjectionAssert.cs:13) guards the other half of that composition: ReturnsSameCollection (:21) asserts a registration extension hands back the very @@ -693,7 +732,7 @@

      Contract, pipeline, and benchmark pre-configured to succeed (HandlerTestBase.cs:41-42,45), a NullLogger<THandler> (:48), and RegisterRepository<TEntity, TIdentifierType>() (:56) / RegisterReadRepository<TEntity, TIdentifierType>() (:72) helpers that wire a repository mock into the read and write accessors (the read-only variant exists for child entities, which expose no read-write repository).

      -

      A smaller fourth tier measures rather than asserts behavior. MMCA.Common.Benchmarks +

      A smaller final tier measures rather than asserts behavior. MMCA.Common.Benchmarks (BenchmarkDotNet) covers the per-request query pipeline, where the dynamic-LINQ predicate is re-parsed per call and the shaper reflects over DTO properties (MMCA.Common.Benchmarks/QueryPipelineBenchmarks.cs:9-17), and the specification hot path @@ -714,6 +753,44 @@

      Contract, pipeline, and benchmark tiers, which is the standing caveat in ADR-015 and ADR-058 alike: the framework ships the gate, a host gets it only once someone writes the subclass. Every remaining concrete test class is cataloged by project in the companion per-project test rollup for this chapter.

      +

      AbstractAnonymousFixtureControllerBase, AnonymousFixtureController, TypeLevelAnonymousFixtureController

      +
      +

      MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · (see per-type table) · Level 0 · class

      +
      +

      Three throwaway MVC controllers nested inside AnonymousEndpointTestsBaseTests (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs). Between them they cover every placement of [AllowAnonymous] the scan has to recognize: on an action of a concrete controller, on an action of an abstract base, and on the controller type itself. They are the input side of the anonymous-endpoint allow-list gate; the subclasses that consume them supply the expectations.

      +
        +
      • Depends on - Microsoft.AspNetCore.Mvc.ControllerBase and Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute (AnonymousEndpointTestsBaseTests.cs:1-:2). No state, no behavior: every action is => Ok().
      • +
      • Concept introduced - an allow-list is only as good as the identifier shapes it can be written in. AnonymousEndpointTestsBase turns each discovered [AllowAnonymous] into a string, and there are exactly two shapes: a type-level attribute becomes the type's FullName, and a method-level attribute becomes {declaring type FullName}.{method name} (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/AnonymousEndpointTestsBase.cs:133-:152). A repo's allow-list is hand-written against those strings, so if the emitter and the hand-written convention ever disagree the gate silently reports phantom offenders or, worse, accepts a stale entry. These three fixtures exist so both shapes are produced by a real scan and matched by a real allow-list in the framework's own CI. Two scoping decisions in the base are also on display here: ControllerBase, RouteAttribute and AllowAnonymousAttribute are all matched by full name through reflection (:32-:34) so the rule package carries no ASP.NET reference, and abstract controllers are deliberately included in the scan (IsController walks BaseType, :101-:112) because a framework base action is where the attribute is declared. [Rubric §11 - Security] assesses whether the authorization posture of the HTTP surface is deliberate; an ungated endpoint that nobody reviewed is the single cheapest way to lose an app. [Rubric §26 - Front-End Security] extends the same scan to routable Blazor components (IsRoutableComponent, :117-:118). [Rubric §14 - Testability] covers the fixtures themselves.
      • +
      • Walkthrough - the scan enumerates LoadableTypes of each target assembly, keeps controllers and routable components, and projects each survivor through AnonymousEndpointsOf, distinct and ordinal-ordered (AnonymousEndpointTestsBase.cs:125-:131). Methods are read with BindingFlags.DeclaredOnly and inherit: false (:142,:146), which is what decides where an inherited action gets reported (see InheritingFixtureController).
      • +
      +
      + + + + + + + + + + + + + + + + + + + + + + +
      TypeFile:LineThe identifier shape it produces
      AnonymousFixtureControllerMMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:74The ordinary case: a sealed ControllerBase whose PeekAsync carries [HttpGet] and [AllowAnonymous] (:78-:80). Emits the method-level identifier ...AnonymousFixtureController.PeekAsync, and is the name the drifted subclass's failure message must contain (:22).
      AbstractAnonymousFixtureControllerBaseMMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:84The declaration site: an abstract controller whose virtual InheritedAnonymousAsync carries [HttpGet("inherited")] and [AllowAnonymous] (:88-:90). Emits one identifier at the base, mirroring how the framework's own AuthControllerBase actions are declared once for every consumer that derives from them.
      TypeLevelAnonymousFixtureControllerMMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:98The other identifier shape: [AllowAnonymous] on the type (:97) with a body-less declaration and no actions at all. Emits the bare FullName, with no method suffix.
      +
        +
      • Why they're built this way - a gate whose fixtures only ever exercise one attribute placement would pass while being blind to the other, and the failure would land in a consumer repo rather than here. Nesting the fixtures inside the test class keeps them out of the assembly's public surface and, critically, out of the framework's real AnonymousEndpointTests scan, which targets the API and UI assemblies rather than this one. See ADR-015.
      • +
      • Where they're used - scanned by the four nested subclasses DriftedTests, StaleAllowListTests, EmptyScanTests and ConformantTests, and named in the assertions of AnonymousEndpointTestsBaseTests.
      • +

      AbstractFitnessControllerBase, IdempotentFitnessController, NonIdempotentFitnessController, UndeclaredFitnessController

      MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · (see per-type table) · Level 0 · class

      @@ -857,6 +934,17 @@

      InheritingFitnessController

    • Walkthrough - no members at all; the declaration ends at its semicolon (:81). Being concrete is what puts it into the rule's ConcreteClasses scan while its abstract base stays out.
    • Where it's used - asserted absent from the rule's failure message by Rule_AcceptsDirectAndInheritedAndOptedOutDeclarations (IdempotencyFitnessTests.cs:34-:36).
    +

    InheritingFixtureController

    +
    +

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:94 · Level 1 · class

    +
    +
      +
    • What it is - a body-less concrete controller deriving AbstractAnonymousFixtureControllerBase. It is the fixture for the ruling that an inherited [AllowAnonymous] is reported once, at the base that declares it, and never again on the derived type.
    • +
    • Depends on - AbstractAnonymousFixtureControllerBase (public sealed class InheritingFixtureController : AbstractAnonymousFixtureControllerBase;, AnonymousEndpointTestsBaseTests.cs:94).
    • +
    • Concept introduced - the same inheritance question, answered the opposite way from the idempotency gate. InheritingFitnessController exists because the idempotency rule reads attributes with inherit: true, so a base declaration covers every subclass. The anonymous-endpoint scan does the reverse: it reads methods with BindingFlags.DeclaredOnly and attributes with inherit: false (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/AnonymousEndpointTestsBase.cs:142,:146). Both choices are correct for their own rule, and the reason is who writes the allow-list. Idempotency intent is declared by the framework and must flow down to consumers, so inherited reads are what stop a wall of duplicate annotations. An anonymous endpoint must be reviewed, and if the scan reported the framework's base action once per derived controller, every consumer repo would have to re-approve the framework's four credential-exchange endpoints under its own type names, forever. The base's own comment states exactly that (:140-:141). [Rubric §11 - Security] is the property; [Rubric §33 - Developer Experience] is the reason for the shape, since a gate that demands the same approval in every downstream repo gets rubber-stamped rather than read.
    • +
    • Walkthrough - no members; the declaration ends at its semicolon (:94). Being concrete puts it in the scan (IsController walks the base chain, AnonymousEndpointTestsBase.cs:101-:112), and the DeclaredOnly filter is what keeps it silent: it declares no methods of its own, so AnonymousEndpointsOf yields nothing for it.
    • +
    • Where it's used - the subject of Base_DoesNotReport_AnInheritedAttributeOnTheDerivedController (AnonymousEndpointTestsBaseTests.cs:61-:71), which reads ConformantTests's scan output and asserts it does not contain {InheritingFixtureController.FullName}.InheritedAnonymousAsync (:69-:70). The inline comment there names the consequence being prevented (:64-:66).
    • +

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/NavigationContractTests.cs:17 · Level 1 · class

    @@ -904,6 +992,23 @@

    LeftService

  • Walkthrough - one member, public RightModel? Model { get; set; } (:9). Nullability is irrelevant to the rule (it reads the property type); the reference itself is the fixture.
  • Where it's used - referenced by AcyclicConsumer and scanned by NamespaceCycleFitnessTests.
  • +

    PasswordHashingFitnessTests

    +
    +

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/PasswordHashingFitnessTests.cs:15 · Level 2 · class

    +
    +
      +
    • What it is - a structural credential-storage gate asserted against compiled IL rather than source text: PasswordHasher must depend on Rfc2898DeriveBytes (a deliberately slow key-derivation function) and on CryptographicOperations (the constant-time comparison), so a rewrite cannot quietly swap either out.
    • +
    • Depends on - PasswordHasher as the assembly anchor and the type under scan (PasswordHashingFitnessTests.cs:1,:21), ArchitectureAssert (:37,:50), NetArchTest's Types / PredicateList query API (:56-:59), and externals xUnit plus AwesomeAssertions.
    • +
    • Concept introduced - asserting on a dependency edge instead of on an output value. A hashing routine can be verified two ways. A known-answer test pins the values (iteration count, salt length, digest length) and is what PasswordHasherSecurityTests does; this class pins the shape, which catches a class of change the value tests cannot. Swap PBKDF2 for a single SHA-512 pass and keep the same 32-byte output length and the known-answer pin can be regenerated by whoever made the change, but the Rfc2898DeriveBytes reference disappears from the IL and this test goes red. Same for replacing CryptographicOperations.FixedTimeEquals with SequenceEqual: identical behavior on every test input, and a timing side channel in production. The two because strings spell out the attacks precisely: commodity GPUs trying billions of candidates per second against a stolen table (:38-:39), and a short-circuiting comparison leaking the matching prefix length so a digest is recovered byte by byte (:51-:53). [Rubric §11 - Security] assesses whether credential storage resists offline cracking and side channels; this is the build-time half of that guarantee. [Rubric §14 - Testability] covers the technique, and [Rubric §32 - Dependency & Supply-Chain] applies at the edges, since the assertion is that a specific BCL cryptographic primitive is actually reached.
    • +
    • Walkthrough - two private const string fully-qualified type names, CryptographicOperationsType (:17) and Rfc2898DeriveBytesType (:19), keep the matched names in one place. Infrastructure (:21) anchors the scanned assembly through typeof(PasswordHasher).Assembly, and the private PasswordHasherTypes() helper (:56-:59) narrows it with Types.InAssembly(Infrastructure).That().HaveName(nameof(PasswordHasher)).
        +
      • ScannedPasswordHasherSet_IsNotEmpty (:23-:27): the non-vacuity guard, and the first fact for a reason. HaveName matches on the simple name, so a renamed or moved type would leave the predicate list empty and both dependency assertions would pass having inspected nothing. ContainSingle on the full name (:25-:26) closes that.
      • +
      • PasswordHasher_DependsOnASlowKeyDerivationFunction (:29-:40) and PasswordHasher_DependsOnConstantTimeComparison (:42-:54): each runs .Should().HaveDependencyOnAll(<type name>).GetResult() and routes the result through ArchitectureAssert.NoViolations. HaveDependencyOnAll is the positive form (the dependency must be present), which is the unusual direction for an architecture rule and the right one here.
      • +
      +
    • +
    • Why it's built this way - ADR-032 fixes the hashing scheme; the real implementation calls Rfc2898DeriveBytes.Pbkdf2 (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Services/PasswordHasher.cs:35) and CryptographicOperations.FixedTimeEquals (:58). Pinning the decision as a fitness function rather than a code-review convention is the ADR-015 approach, and it is the one that survives a refactor by someone who has not read the ADR.
    • +
    • Where it's used - an independent class in the Common architecture suite. It is the shape half of a pair; the parameter values are pinned by PasswordHasherSecurityTests in the Infrastructure unit-test project (MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Services/PasswordHasherSecurityTests.cs:18), which holds PBKDF2-HMAC-SHA512 at 600,000 iterations with a 32-byte salt and a 64-byte output as reflected private constants (:20-:22). The class doc here records the division of labour (PasswordHashingFitnessTests.cs:10-:13).
    • +
    • Caveats / not-in-source - HaveDependencyOnAll reports a reference in the compiled IL, not that the reference is on the hashing path. It cannot tell an actually-used PBKDF2 call from a dead one, which is why the value pins in the companion test remain load-bearing.
    • +

    AcyclicConsumer

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests.CycleFixtures.Acyclic · MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/CycleFixtures/Acyclic/AcyclicFixtures.cs:6 · Level 3 · class

    @@ -915,6 +1020,18 @@

    AcyclicConsumer

  • Walkthrough - one member, public LeftService? Service { get; set; } (:9).
  • Where it's used - asserted absent from the failure message by Rule_FlagsTwoNamespaceCycle_ButNotAcyclicNamespaces (NamespaceCycleFitnessTests.cs:23-:25).
  • +

    EmptyScanTests

    +
    +

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:117 · Level 3 · class

    +
    +
      +
    • What it is - the adversarial fixture for the anonymous-endpoint gate's non-vacuity guard: a subclass pointed at an assembly that contains no controllers and no routable components at all, so the scan finds nothing and the guard must fail.
    • +
    • Depends on - AnonymousEndpointTestsBase (private sealed class EmptyScanTests : AnonymousEndpointTestsBase, AnonymousEndpointTestsBaseTests.cs:117) and the MMCA.Common.Shared assembly reached through typeof(Shared.Abstractions.Result).Assembly (:121).
    • +
    • Concept introduced - guarding the guard: why a fitness function needs a floor. The allow-list assertion is offenders.Should().BeEmpty() (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/AnonymousEndpointTestsBase.cs:60-:62), and an empty scan produces zero offenders, so the gate passes loudest exactly when it has stopped looking. A renamed assembly, a moved anchor type, or a repo re-layout all produce that failure mode, and none of them is visible in a green run. The base therefore ships a third fact, ScannedEndpointSet_IsNotEmpty (:65-:76), that counts the discovered controller and routable-component types and requires at least MinimumScannedTypes, whose default is 1 (:51). This fixture is what proves the counter is real: point it at an assembly that genuinely has neither shape and the fact must throw. Compare the equivalent floors elsewhere in the suite: MinimumScannedTypes => 21 on AnonymousEndpointTests, MinimumBaseResources => 3 on LocalizationResourceTests, MinimumRoutes = 8 in NavigationContractTests. [Rubric §14 - Testability] assesses whether the guardrails are themselves trustworthy, and a vacuity floor is the cheapest thing that keeps one honest over a decade of refactors.
    • +
    • Walkthrough - two overrides and an inline comment that states why the chosen assembly works: the Shared package has neither controllers nor routable components (:119). TargetAssemblies returns that one assembly (:120-:121), and AllowedAnonymousEndpoints is empty (:123), which is irrelevant here because nothing is discovered to compare against. Being private and nested is what keeps xUnit from collecting its three inherited facts as deliberately-red tests of their own (class doc, :10-:11).
    • +
    • Where it's used - the subject of Base_Fails_WhenNothingWasScanned (AnonymousEndpointTestsBaseTests.cs:37-:43), which converts the inherited ScannedEndpointSet_IsNotEmpty into a delegate and asserts it throws (:40-:42).
    • +
    • Caveats / not-in-source - that fact asserts only that an exception is thrown, not what its message says. It proves the floor bites; it does not pin the wording.
    • +

    FakeDependentModule

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/ModuleConformanceTestsBaseTests.cs:31 · Level 3 · class

    @@ -946,16 +1063,157 @@

    FakeLeafModule

  • Why it's built this way - this is the exact shape the three byte-identical consumer {X}ModuleTests files collapse into (class doc, :12-:13), so the leaf path is the most-travelled one and the one whose silent breakage would be widest.
  • Where it's used - the TModule of FakeLeafModuleConformanceTests (:51), which in turn is driven directly by two of ModuleConformanceTestsBaseTests's facts (:112-:129).
  • +

    FixtureCompliantV1, FixtureCompliantV2, FixtureCompliantV3, FixtureContestedV1, FixtureContestedV2, FixtureContestedV3, FixtureBackwardsV1, FixtureBackwardsV2

    +
    +

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEvents · (see per-type table) · Level 3 · record

    +
    +

    Eight throwaway integration-event contracts in one file (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs), forming three groups: a compliant three-rung version ladder, a pair of rival successors to one contested source, and a backwards pair whose "successor" is older than its source. They are the data the two event-upcaster fitness rules are proven against.

    +
      +
    • Depends on - BaseIntegrationEvent (every one of them derives from it, EventUpcasterFixtures.cs:2). Each is a one-parameter positional record carrying a single string Sku.
    • +
    • Concept introduced - SchemaVersion as the ordering a fixture set has to make visible. BaseIntegrationEvent declares public virtual int SchemaVersion => 1 (MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/BaseIntegrationEvent.cs:32), so a contract that overrides nothing is version 1 and a successor states its own number (ADR-010). That default is what lets these fixtures be as small as they are: the V1 of each group is a bare record and only the successors carry an override. The rule reads the property without running a constructor, through RuntimeHelpers.GetUninitializedObject (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Upcasters.cs:103), which is why a get-only virtual returning a literal is the required shape and why no event needs a parameterless factory just to be inspected. [Rubric §6 - CQRS & Event-Driven] assesses whether event contracts are versioned and governed rather than edited in place; [Rubric §9 - API & Contract Design] applies because a published event is a wire contract; [Rubric §14 - Testability] covers the fixtures.
    • +
    • Walkthrough - the file's header doc records a subtlety worth internalizing (:9-:13): the contracts sit in a *.IntegrationEvents namespace so that the residency rule, which EventScopeFitnessTests exercises over this same assembly through a consumer-shaped map, stays satisfied. A fixture that broke a neighbouring rule to prove its own would be a poor fixture. They also never leave this test assembly, so no shipped event contract churns because of them.
    • +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    TypeFile:LineSchemaVersionRole
    FixtureCompliantV1MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:151 (inherited default)First rung of the compliant ladder, and the source of exactly one upcaster.
    FixtureCompliantV2MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:182 (:20)The middle rung: a target of one upcaster and the source of the next. That dual role is the whole point, since it is a chain rather than a duplicate claim.
    FixtureCompliantV3MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:243 (:26)Terminal contract of the ladder.
    FixtureContestedV1MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:301 (inherited default)The offending source: two upcasters both read it, which is what the unique-source rule must report.
    FixtureContestedV2MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:332 (:35)One of the two rival successors.
    FixtureContestedV3MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:393 (:41)The other rival successor. Both targets are legal on their own; the offence is the shared source.
    FixtureBackwardsV1MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:451 (inherited default)The older contract of the backwards pair, used as the upcaster's target, which is the offence.
    FixtureBackwardsV2MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:482 (:50)The newer contract, used as the upcaster's source.
    +
      +
    • Why they're built this way - three groups because the two rules have three distinct outcomes to prove between them (clean, duplicate claim, wrong direction), and because a chain has to be distinguishable from a duplicate claim. Nothing but Sku on any of them: the payload is irrelevant to both rules, and every byte of fixture that is not load-bearing is a byte that can mislead a future reader about what is being tested. See ADR-090.
    • +
    • Where they're used - the type arguments of the five fixture upcasters (FixtureCompliantV1ToV2Upcaster and family), and named by nameof in the assertions of EventUpcasterFitnessTests.
    • +
    +

    AnonymousEndpointTests

    +
    +

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTests.cs:14 · Level 4 · class

    +
    +
      +
    • What it is - the framework's own anonymous-endpoint allow-list: the live, reviewed statement of every controller action MMCA.Common ships without an authorization gate. Six entries today, all of them credential-exchange endpoints.
    • +
    • Depends on - AnonymousEndpointTestsBase (public sealed class AnonymousEndpointTests : AnonymousEndpointTestsBase, AnonymousEndpointTests.cs:14), ApiControllerBase as the anchor for the API assembly (:18), and UISharedAssemblyReference as the anchor for the shared UI assembly (:19).
    • +
    • Concept introduced - an allow-list as a review artifact, not a suppression list. The gate's value is not that it blocks [AllowAnonymous]; it is that adding one becomes a line in a test file with a comment next to it, landing in a diff that a human reads. Every entry here carries its justification inline, and each justification is the same argument: requiring a token would be circular, because minting or recovering the token is what the endpoint does. Login, register and refresh mint or rotate the token pair (:24-:28); the OAuth completion code is the caller's only credential at that point and is single-use, burned on first exchange (:29-:31); forgot-password and reset-password are for a caller who has lost the credential, are throttled by the same auth-ip rate-limit policy, and forgot-password always answers 202 so it reveals nothing about which addresses hold accounts (:32-:34). [Rubric §11 - Security] assesses the deliberateness of the authorization posture; note that the compensating control for every entry is rate limiting rather than authentication (ADR-029). [Rubric §9 - API & Contract Design] applies because the anonymous surface is part of the published contract, and [Rubric §34 - Architecture Governance & Documentation] because the reasoning lives in compiled code rather than a wiki page.
    • +
    • Walkthrough
        +
      • TargetAssemblies (:16-:20) names two assemblies by anchor type, so the scan covers both the controllers and the routable Blazor pages the framework ships.
      • +
      • AllowedAnonymousEndpoints (:22-:39) is the six-entry list. Two of them are worth reading closely: PasswordResetAuthControllerBase`2.ForgotPasswordAsync and its reset sibling (:37-:38) carry a backtick-2 arity suffix, because PasswordResetAuthControllerBase<TForgotPasswordCommand, TResetPasswordCommand> is generic over the app's command records and reflection renders a generic type's FullName that way. The comment above them says exactly that (:35-:36), which is the kind of note that saves the next person an hour.
      • +
      • MinimumScannedTypes => 21 (:43) raises the base's default floor of 1 to this repo's known count, described in the comment as a floor and not an equality: 12 API controller types plus the routable UI pages (:41-:42). Removing a scanned type is therefore a failure rather than a quietly smaller scan.
      • +
      +
    • +
    • Why it's built this way - the four endpoints that cannot require a token are exactly the ones an attacker reaches first, so the framework's position is that they must be enumerated, justified, and rate-limited rather than merely working. The base's own doc records the one thing this gate cannot see: minimal-API endpoints opt out through the .AllowAnonymous() builder call, which is endpoint metadata produced at map time and invisible to static reflection (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/AnonymousEndpointTestsBase.cs:18-:24). The framework's minimal-API anonymous surface (JWKS, OIDC discovery, app-association, session-cookie refresh, health) is deliberate and small, but it is not covered here.
    • +
    • Where it's used - collected and run by xUnit in the Common architecture suite; its three facts come entirely from the base. Consumers write their own subclass with their own list.
    • +
    • Caveats / not-in-source - the minimal-API blind spot above is stated in the base's documentation, and closing it would need an endpoint-metadata check over a built host. Nothing in this class compensates for it.
    • +
    +

    AnonymousEndpointTestsBaseTests

    +
    +

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:13 · Level 4 · class

    +
    +
      +
    • What it is - the meta-test for the shipped anonymous-endpoint base: five facts proving each of its three assertions fails on the drift it claims to catch, that both allow-list identifier shapes are accepted, and that an inherited attribute is not double-reported on the derived controller.
    • +
    • Depends on - AnonymousEndpointTestsBase (the type under test), its own nested fixtures (AnonymousFixtureController and family, InheritingFixtureController, DriftedTests, StaleAllowListTests, EmptyScanTests, ConformantTests), plus xUnit [Fact] and AwesomeAssertions' delegate assertions.
    • +
    • Concept introduced - cross-references the "test a shipped test base from the outside by invoking its facts as delegates" technique introduced by ModuleConformanceTestsBaseTests. What this class adds is a third assertion axis. The module base is proven by failing it three ways; this base also has to be proven to accept the two identifier shapes its allow-list is written in, because that is a data contract between the base's emitter and every consumer's hand-written list, and getting it wrong makes the gate fail in the one repo the framework's CI never runs. The class doc states both halves (:8-:11). [Rubric §14 - Testability] assesses whether guardrails are trustworthy; [Rubric §11 - Security] is the property at stake, since a broken anonymous-endpoint gate is a gate that stops noticing lost authorization; [Rubric §33 - Developer Experience] covers the failure mode, a break that would only appear downstream after a release (ADR-058).
    • +
    • Walkthrough
        +
      • Base_Fails_WhenAnAnonymousEndpointIsNotAllowListed (:15-:24): drives DriftedTests and asserts the thrown message names AnonymousFixtureController (:22), with the because stating the ruling, that the offender message must name the endpoint that lost its gate (:23).
      • +
      • Base_Fails_WhenTheAllowListHasAStaleEntry (:26-:35): drives StaleAllowListTests and asserts the message contains "NoLongerAnonymous" (:33), the type name from an entry that matches nothing.
      • +
      • Base_Fails_WhenNothingWasScanned (:37-:43): drives EmptyScanTests and asserts the non-vacuity fact throws (:42).
      • +
      • Base_Accepts_TypeLevelAndMethodLevelEntries (:45-:59): the positive case. It calls all three of ConformantTests's inherited facts inside one delegate (:50-:55) and asserts NotThrow (:57-:58). Running all three together matters: the allow-list is only correct if it is simultaneously complete (no offenders) and exact (no stale entries).
      • +
      • Base_DoesNotReport_AnInheritedAttributeOnTheDerivedController (:61-:71): reads the scan output through ConformantTests's AnonymousEndpointsForTest() and asserts the derived-controller identifier is absent (:69-:70). This is the only fact that inspects the emitted set rather than an assertion's outcome.
      • +
      +
    • +
    • Why it's built this way - the four drifted and conformant subclasses are all private, which is what keeps xUnit from collecting their inherited facts as deliberately-failing tests of their own (class doc, :10-:11). The base ships in the MMCA.Common.Testing.Architecture package, so proving it here is proving it before a release rather than after one.
    • +
    • Where it's used - an independent class in the Common architecture suite. The base it protects is bound for real by AnonymousEndpointTests here and by the equivalent subclass in each consumer repo.
    • +
    +

    ConformantTests

    +
    +

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:126 · Level 4 · class

    +
    +
      +
    • What it is - the positive fixture for the anonymous-endpoint gate: a subclass whose allow-list correctly names every anonymous endpoint in the fixture set, in both identifier shapes, so all three inherited facts must pass.
    • +
    • Depends on - AnonymousEndpointTestsBase (private sealed class ConformantTests : AnonymousEndpointTestsBase, AnonymousEndpointTestsBaseTests.cs:126) and the three fixture controllers it allow-lists.
    • +
    • Concept introduced - building allow-list entries with typeof(...).FullName and nameof(...) rather than string literals. Each of the three entries is interpolated from live metadata (:133-:135): $"{typeof(AnonymousFixtureController).FullName}.{nameof(AnonymousFixtureController.PeekAsync)}" for the method-level shape, the same construction against the abstract base for the inherited action, and a bare typeof(TypeLevelAnonymousFixtureController).FullName! for the type-level shape. Renaming any fixture is then a compile error instead of a silently-drifting test. That discipline is not available to a real consumer's list (the framework's endpoints are strings there, as in AnonymousEndpointTests), which is precisely why the stale-entry fact exists to catch what nameof would have caught. [Rubric §15 - Best Practices & Code Quality] and [Rubric §14 - Testability] apply.
    • +
    • Walkthrough - TargetAssemblies is this test assembly (:128-:129), so the scan sees the nested fixture controllers. AllowedAnonymousEndpoints (:131-:136) holds the three entries described above; note the abstract base is listed at its own name rather than the deriving controller's, which is the ruling InheritingFixtureController pins. MinimumScannedTypes is left at the base's default of 1, which the fixture set clears comfortably. One member is added beyond the base's surface: internal IReadOnlyCollection<string> AnonymousEndpointsForTest() => [.. AnonymousEndpoints()]; (:138), which materializes the base's protected enumeration so the enclosing test class can assert on the emitted set directly. The base exposes AnonymousEndpoints() as protected for exactly this kind of extension (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/AnonymousEndpointTestsBase.cs:120-:125).
    • +
    • Where it's used - Base_Accepts_TypeLevelAndMethodLevelEntries (AnonymousEndpointTestsBaseTests.cs:45-:59) runs its three inherited facts, and Base_DoesNotReport_AnInheritedAttributeOnTheDerivedController (:61-:71) reads its AnonymousEndpointsForTest() output.
    • +

    DriftedTests

    -

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/ModuleConformanceTestsBaseTests.cs:131 · Level 4 · class

    +

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · (see per-type table) · Level 4 · class

    +

    The name is used twice in this assembly, once per fitness base under proof, and both are the same idea: a private sealed subclass of a shipped *TestsBase whose configuration is deliberately wrong, so the base's assertions can be shown to actually fail on the drift they claim to catch. They are nested in different outer classes, so their full names differ and neither is visible outside its own file.

      -
    • What it is - the adversarial fixture: a conformance subclass whose three expectations are all deliberately wrong for the module it points at, so the base's assertions can be proven to actually fail on drift instead of passing regardless.
    • -
    • Depends on - ModuleConformanceTestsBase<TModule> (private sealed class DriftedTests : ModuleConformanceTestsBase<FakeDependentModule>, ModuleConformanceTestsBaseTests.cs:131) and FakeDependentModule (the module under test).
    • -
    • Concept introduced - negative fixtures, and hiding them from test discovery. A fitness base that asserts nothing passes everywhere; the only way to know an assertion bites is to feed it a case that must fail. But a public subclass of an xUnit base is itself collected: its inherited [Fact]s would run and report as three red tests. Declaring the drifted subclass private (nested inside ModuleConformanceTestsBaseTests) keeps xUnit from collecting it, while the enclosing class can still instantiate it and invoke the inherited methods directly as delegates. [Rubric §14 - Testability] assesses whether the guardrails themselves are trustworthy; this is the fixture that earns that trust. Compare NavigatingSpec, the same negative-fixture technique applied to a specification rule.
    • -
    • Walkthrough - three overrides, each wrong in a different way against FakeDependentModule's real declarations: ExpectedName => "NotTheDeclaredName" (:133) against the module's "FakeDependent"; ExpectedDependencies => ["FakeLeaf"] (:135) against the module's two-entry list, so one dependency is missing; and ExpectedRequiresDependencies => false (:137) against the module's true. AssertDisabledStubs is deliberately not overridden, so the fourth inherited fact stays vacuous here.
    • -
    • Where it's used - instantiated three times by ModuleConformanceTestsBaseTests (:91,:99,:107), once per assertion under proof.
    • +
    • Depends on - the base each one drifts from, plus the fixture it points at: ModuleConformanceTestsBase<TModule> with FakeDependentModule for one, AnonymousEndpointTestsBase with the fixture controllers for the other.
    • +
    • Concept introduced - negative fixtures, and hiding them from test discovery. A fitness base that asserts nothing passes everywhere; the only way to know an assertion bites is to feed it a case that must fail. But a public subclass of an xUnit base is itself collected, so its inherited [Fact]s would run and report as red tests of their own. Declaring the drifted subclass private and nested keeps xUnit from collecting it, while the enclosing class can still instantiate it and invoke the inherited methods directly as delegates (var assert = new DriftedTests().Module_ShouldDeclare_ExpectedName;, ModuleConformanceTestsBaseTests.cs:91). Both class docs record that reasoning in the same words (ModuleConformanceTestsBaseTests.cs:83-:84, AnonymousEndpointTestsBaseTests.cs:10-:11). [Rubric §14 - Testability] assesses whether the guardrails themselves are trustworthy; this is the fixture shape that earns that trust. Compare NavigatingSpec, the same negative-fixture technique applied to a specification rule.
    • +
    +
    + + + + + + + + + + + + + + + + + +
    TypeFile:LineWhat is deliberately wrong
    DriftedTests (in AnonymousEndpointTestsBaseTests)MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:100Points TargetAssemblies at this test assembly (:102-:103), so the scan finds the fixture controllers' [AllowAnonymous] attributes, and then supplies an empty AllowedAnonymousEndpoints (:105). Every discovered endpoint is therefore an offender, and the failure message must name AnonymousFixtureController.
    DriftedTests (in ModuleConformanceTestsBaseTests)MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/ModuleConformanceTestsBaseTests.cs:131Three overrides, each wrong in a different way against FakeDependentModule's real declarations: ExpectedName => "NotTheDeclaredName" (:133) against the module's "FakeDependent"; ExpectedDependencies => ["FakeLeaf"] (:135) against the module's two-entry list, so one dependency is missing; and ExpectedRequiresDependencies => false (:137) against the module's true. AssertDisabledStubs is deliberately not overridden, so the fourth inherited fact stays vacuous here.
    +
      +
    • Why they're built this way - one wrong value per assertion, and no more. If a single drifted subclass were wrong in three ways at once and the base only ever threw on the first, the other two assertions could rot undetected; the module-side fixture avoids that by being driven three separate times, once per fact.
    • +
    • Where they're used - the anonymous-endpoint one drives Base_Fails_WhenAnAnonymousEndpointIsNotAllowListed (AnonymousEndpointTestsBaseTests.cs:15-:24); the module one is instantiated three times by ModuleConformanceTestsBaseTests (:91,:99,:107), once per assertion under proof.

    FakeDependentModuleConformanceTests

    @@ -991,6 +1249,65 @@

    FitnessPrincipal

  • Why it's built this way - the fitness test must be non-vacuous: it needs a real cross-entity navigation to flag. A minimal principal with a single scalar is the smallest thing a specification can legally navigate into.
  • Where it's used - referenced by FitnessDependent and, through it, by NavigatingSpec and NavigatingQuerySpec; the whole fixture set drives SpecificationFitnessTests.
  • +

    FixtureCompliantV1ToV2Upcaster, FixtureCompliantV2ToV3Upcaster, FixtureContestedClaimUpcaster, FixtureRivalClaimUpcaster, FixtureBackwardsVersionUpcaster

    +
    +

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests.UpcasterFixtures.IntegrationEvents · (see per-type table) · Level 4 · class

    +
    +

    Five internal sealed upcasters over the eight fixture contracts, each a single expression-bodied Upcast that copies the one field across. Between them they are the truth table for the two event-upcaster fitness rules (ADR-090): two clean rungs of a chain, two rivals claiming one source, and one pointing backwards down the version ladder.

    +
      +
    • Depends on - IEventUpcaster in its two-parameter form (EventUpcasterFixtures.cs:1) and the fixture event contracts they are generic over.
    • +
    • Concept introduced - the upcast chain must be a function, and it must run forwards. When a retired contract arrives from the outbox, the consumer resolves the one upcaster registered for that type and replays the message as its successor. Two properties make that mechanical rather than lucky. First, at most one upcaster may read a given source contract: with two claimants, which one runs would depend on DI registration order, so EventUpcastersHaveUniqueSourceTypes groups by source and reports any group with more than one entry (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Upcasters.cs:14-:17). Second, the target must declare a strictly higher SchemaVersion than the source, because an upcaster that moves sideways or down is not producing a successor at all and the chain stops being a ladder anyone can reason about; EventUpcastersIncreaseSchemaVersion compares the two declared versions and flags targetVersion <= sourceVersion (:44-:48). Note what the second rule deliberately does not do: a missing or non-int SchemaVersion is skipped rather than reported (:37-:42), because that is the business of the separate IntegrationEventsDeclareSchemaVersion rule. One rule, one judgement. Both rules find their subjects by matching the interface on name and arity, "IEventUpcaster`2" (:81-:83), which is how the rule library stays free of a compile dependency on the framework's own Application package. [Rubric §6 - CQRS & Event-Driven] assesses whether asynchronous contracts evolve safely; [Rubric §9 - API & Contract Design] covers the versioning discipline; [Rubric §29 - Resilience & Business Continuity] is the operational consequence, since an outbox row written against a retired contract has to be replayable weeks later.
    • +
    • Walkthrough - every one of the five has the same body shape, public {Target} Upcast({Source} integrationEvent) => new(integrationEvent.Sku);, which is the typed overload declared by IEventUpcaster (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/IEventUpcaster.cs:82); SourceType and TargetType come free as default interface implementations off the generic arguments (:72,:75), which is exactly what the rules read.
    • +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    TypeFile:LineThe case it models
    FixtureCompliantV1ToV2UpcasterMMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:54Clean: the only claimant on FixtureCompliantV1, and its target declares version 2. Must be absent from both rules' reports.
    FixtureCompliantV2ToV3UpcasterMMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:60The second rung, and the sharper of the two clean cases: its source is the previous upcaster's target. That is a chain, not a duplicate claim, and a naive implementation that grouped on "types that appear in more than one upcaster" would wrongly flag it. It gets its own dedicated fact.
    FixtureContestedClaimUpcasterMMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:66Offender: reads FixtureContestedV1 and produces V2. Legal in isolation.
    FixtureRivalClaimUpcasterMMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:72The second claimant on the same FixtureContestedV1, producing V3 instead. The offence is the pair, so the rule's message must name the contested contract and both upcasters.
    FixtureBackwardsVersionUpcasterMMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/UpcasterFixtures/IntegrationEvents/EventUpcasterFixtures.cs:78Offender for the other rule: source FixtureBackwardsV2 (version 2), target FixtureBackwardsV1 (version 1). It is the only claimant on its source, so it passes the unique-source rule and fails the version rule, which is what keeps the two rules' proofs independent.
    +
      +
    • Why they're built this way - the two rules judge different things about the same set of types, so the fixture set is designed so that each offender trips exactly one of them. If the backwards upcaster also shared a source, a failure in the unique-source rule would mask a regression in the version rule. Being internal keeps them off the assembly's public surface while still visible to ConcreteClasses, which is what the rule enumerates (ArchitectureRules.Upcasters.cs:67).
    • +
    • Where they're used - reflected over by EventUpcasterFitnessTests through UpcasterTestMap, and named by nameof in its assertions.
    • +
    +

    StaleAllowListTests

    +
    +

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:108 · Level 4 · class

    +
    +
      +
    • What it is - the adversarial fixture for the stale-entry half of the anonymous-endpoint gate: a subclass whose allow-list names an endpoint that does not exist, so the base's AllowList_HasNoStaleEntries fact must report it rather than shrug.
    • +
    • Depends on - AnonymousEndpointTestsBase (private sealed class StaleAllowListTests : AnonymousEndpointTestsBase, AnonymousEndpointTestsBaseTests.cs:108).
    • +
    • Concept introduced - an allow-list rots in two directions, and only one of them is obvious. A missing entry fails loudly the moment a new [AllowAnonymous] lands. A stale entry fails silently and permanently: the endpoint gets renamed, re-gated with [Authorize], or deleted, and the list keeps granting a permission that is no longer being requested. Nothing breaks, so nobody looks, and the next endpoint that happens to match that identifier inherits an approval nobody granted it. The base therefore runs the comparison both ways, computing stale as the allow-list entries with no match in the scanned set (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/AnonymousEndpointTestsBase.cs:83-:89), with a because that spells out the consequence: an entry that no longer matches hides a renamed or re-gated endpoint behind a permission that is no longer being granted (:88). [Rubric §11 - Security] is the property; [Rubric §16 - Maintainability] is the mechanism, since a self-pruning list is one that stays readable.
    • +
    • Walkthrough - TargetAssemblies is this test assembly (:110-:111), so the fixture controllers are discovered normally. AllowedAnonymousEndpoints holds exactly one entry, "MMCA.Common.Architecture.Tests.NoLongerAnonymousController.ReadAsync" (:113-:114), naming a controller that has never existed. That makes the fixture fail both facts at once (every real fixture endpoint is now an unlisted offender as well), which is harmless because the fact under proof invokes only AllowList_HasNoStaleEntries as a delegate.
    • +
    • Where it's used - the subject of Base_Fails_WhenTheAllowListHasAStaleEntry (AnonymousEndpointTestsBaseTests.cs:26-:35), which asserts the message contains "NoLongerAnonymous" (:33), the distinctive fragment of the phantom identifier.
    • +

    CancellationTestMap

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/CancellationTokenFitnessTests.cs:63 · Level 5 · class

    @@ -1228,30 +1545,6 @@

    ScalarOnlySpec

  • Walkthrough - one overridden Criteria filtering on PrincipalId and Flag (:71). The test asserts the rule's exception message does not contain this type's name.
  • Where it's used - the "should not be flagged" input to SpecificationFitnessTests.
  • -

    CommonArchitectureMap

    -
    -

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/CommonArchitectureMap.cs:15 · Level 7 · class

    -
    -
      -
    • What it is - the architecture map for the MMCA.Common framework: it names each package's layer and pins the layer to a concrete assembly, so the shared rule library knows which assembly is Shared, Domain, Application, and so on for this repo.
    • -
    • Depends on - ArchitectureMapBase (internal sealed class CommonArchitectureMap : ArchitectureMapBase, CommonArchitectureMap.cs:15), the Layer enum, LayerRef, and one anchor type per package (Result, BaseEntity<>, DomainEventDispatcher, ApplicationDbContext, ApiControllerBase, ResultGrpcExtensions, UISharedAssemblyReference, :21-:27).
    • -
    • Concept introduced - the map as the single point of repo-specific truth for architecture rules. The rule bodies live once in MMCA.Common.Testing.Architecture and are parameterized by an IArchitectureMap; each repo supplies exactly one map so the same rules run identically across Common, Store, and ADC. Because Common is a module-less framework, every layer is registered as a framework layer via the Framework(...) helper rather than a module layer, and that distinction is load-bearing well beyond bookkeeping: several rules branch on map.ModuleNames.Count to decide whether they are judging a framework or a consumer (see EventScopeFitnessTests). [Rubric §3 - Clean Architecture] assesses whether layer boundaries are explicit and enforced; this map is the machine-readable statement of those boundaries.
    • -
    • Walkthrough - RepoToken => "MMCA.Common" (:17) identifies the repo and is what the source-scanning rules use to locate the repo root (they look for {RepoToken}.slnx). DefineLayers() (:19-:28) returns one Framework(Layer.X, anchorType.Assembly) entry per package, using a single anchor type to resolve each assembly (mirrors the old PackageAssemblies helper): Shared, Domain, Application, Infrastructure, Api, Grpc, and Ui (:21-:27). The doc comment (:8-:13) records a deliberate omission: MMCA.Common.UI.Maui (ADR-042) is absent because its four MAUI TFM assemblies cannot load in the ubuntu net10.0 test process, so its UI-plus-Shared boundary is enforced at compile time by EnforceUIMauiLayerBoundary in Source/Build/MMCA.Common.LayerEnforcement.targets and the windows build-maui CI job instead.
    • -
    • Why it's built this way - one map per repo keeps the rule bodies DRY and identical everywhere (see the "Architecture Enforcement" section in MMCA.Common/CLAUDE.md); anchoring by type keeps the assembly reference refactor-safe.
    • -
    • Where it's used - supplied as Map by every thin *ConventionTests subclass in this unit, used directly by EventScopeFitnessTests as the module-less contrast case (EventScopeFitnessTests.cs:39), and is the pattern the single-layer fitness maps (SpecTestMap, IdempotencyTestMap, CancellationTestMap, CycleTestMap) collapse.
    • -
    -

    FrameworkSanityTests

    -
    -

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/FrameworkSanityTests.cs:13 · Level 7 · class

    -
    -
      -
    • What it is - the home for the few architecture checks that are Common-only and do not generalize into the shared rule library: the MMCA.Common.Grpc transport boundary and the placement of the IMessageBus, IJwksProvider, and ILiveChannelPublisher abstractions.
    • -
    • Depends on - IMessageBus, IJwksProvider, ILiveChannelPublisher, and the NetArchTest Types query API routed through ArchitectureAssert.
    • -
    • Concept introduced - repo-specific sanity next to the shared library. Not every rule fits the parameterized base classes; some assert facts true only of the framework repo. Keeping them in one explicitly-named class documents the boundary between "shared rule applied here" and "Common-only invariant." [Rubric §7 - Microservices Readiness] (transport isolation) and [Rubric §3 - Clean Architecture] (abstraction placement) both apply: gRPC is pure transport and must not couple to Domain, Application, or Infrastructure, and the cross-cutting abstractions must sit in the layer their consumers depend on.
    • -
    • Walkthrough - three private static Assembly accessors anchor the Grpc, Application, and Infrastructure assemblies by an anchor type each (:15-:19). Three [Fact]s assert MMCA.Common.Grpc has no dependency on Domain, Application, or Infrastructure (:21-:34) via the AssertNoDependency helper (:51-:59), which runs a Types.InAssembly(...).ShouldNot().HaveDependencyOnAny(...) NetArchTest query and routes the result through ArchitectureAssert.NoViolations (:58). Three more [Fact]s assert placement by comparing the abstraction's declaring assembly against the anchored layer assembly: IMessageBus lives in Application (:36-:39), IJwksProvider in Infrastructure because it handles crypto and PEM material (:41-:44), and ILiveChannelPublisher in Application beside IPushNotificationSender (:46-:49).
    • -
    • Why it's built this way - the message-bus abstraction must stay in Application so application code depends on transport through it (extraction boundary, ADR-007); the JWKS provider is crypto and belongs in Infrastructure (ADR-004). These are load-bearing placements, so they get their own asserted facts.
    • -
    • Where it's used - an independent class in the Common architecture suite; it has no counterpart in Store or ADC because only Common owns the Grpc package and defines these abstractions.
    • -

    SpecificationFitnessTests

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/SpecificationFitnessTests.cs:13 · Level 7 · class

    @@ -1275,9 +1568,33 @@

    SpecTestMap

  • Walkthrough - RepoToken => "MMCA.Common" (:42) and a one-entry DefineLayers() (:44-:45) pointing at this assembly.
  • Where it's used - instantiated once per fact inside SpecificationFitnessTests (:18,:29).
  • +

    CommonArchitectureMap

    +
    +

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/CommonArchitectureMap.cs:15 · Level 12 · class

    +
    +
      +
    • What it is - the architecture map for the MMCA.Common framework: it names each package's layer and pins the layer to a concrete assembly, so the shared rule library knows which assembly is Shared, Domain, Application, and so on for this repo.
    • +
    • Depends on - ArchitectureMapBase (internal sealed class CommonArchitectureMap : ArchitectureMapBase, CommonArchitectureMap.cs:15), the Layer enum, LayerRef, and one anchor type per package (Result, BaseEntity<>, DomainEventDispatcher, ApplicationDbContext, ApiControllerBase, ResultGrpcExtensions, UISharedAssemblyReference, :21-:27).
    • +
    • Concept introduced - the map as the single point of repo-specific truth for architecture rules. The rule bodies live once in MMCA.Common.Testing.Architecture and are parameterized by an IArchitectureMap; each repo supplies exactly one map so the same rules run identically across Common, Store, and ADC. Because Common is a module-less framework, every layer is registered as a framework layer via the Framework(...) helper rather than a module layer, and that distinction is load-bearing well beyond bookkeeping: several rules branch on map.ModuleNames.Count to decide whether they are judging a framework or a consumer (see EventScopeFitnessTests). [Rubric §3 - Clean Architecture] assesses whether layer boundaries are explicit and enforced; this map is the machine-readable statement of those boundaries.
    • +
    • Walkthrough - RepoToken => "MMCA.Common" (:17) identifies the repo and is what the source-scanning rules use to locate the repo root (they look for {RepoToken}.slnx). DefineLayers() (:19-:28) returns one Framework(Layer.X, anchorType.Assembly) entry per package, using a single anchor type to resolve each assembly (mirrors the old PackageAssemblies helper): Shared, Domain, Application, Infrastructure, Api, Grpc, and Ui (:21-:27). The doc comment (:8-:13) records a deliberate omission: MMCA.Common.UI.Maui (ADR-042) is absent because its four MAUI TFM assemblies cannot load in the ubuntu net10.0 test process, so its UI-plus-Shared boundary is enforced at compile time by EnforceUIMauiLayerBoundary in Source/Build/MMCA.Common.LayerEnforcement.targets and the windows build-maui CI job instead.
    • +
    • Why it's built this way - one map per repo keeps the rule bodies DRY and identical everywhere (see the "Architecture Enforcement" section in MMCA.Common/CLAUDE.md); anchoring by type keeps the assembly reference refactor-safe.
    • +
    • Where it's used - supplied as Map by every thin *ConventionTests subclass in this unit, used directly by EventScopeFitnessTests as the module-less contrast case (EventScopeFitnessTests.cs:39), and is the pattern the single-layer fitness maps (SpecTestMap, IdempotencyTestMap, CancellationTestMap, CycleTestMap) collapse.
    • +
    +

    FrameworkSanityTests

    +
    +

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/FrameworkSanityTests.cs:13 · Level 12 · class

    +
    +
      +
    • What it is - the home for the few architecture checks that are Common-only and do not generalize into the shared rule library: the MMCA.Common.Grpc transport boundary and the placement of the IMessageBus, IJwksProvider, and ILiveChannelPublisher abstractions.
    • +
    • Depends on - IMessageBus, IJwksProvider, ILiveChannelPublisher, and the NetArchTest Types query API routed through ArchitectureAssert.
    • +
    • Concept introduced - repo-specific sanity next to the shared library. Not every rule fits the parameterized base classes; some assert facts true only of the framework repo. Keeping them in one explicitly-named class documents the boundary between "shared rule applied here" and "Common-only invariant." [Rubric §7 - Microservices Readiness] (transport isolation) and [Rubric §3 - Clean Architecture] (abstraction placement) both apply: gRPC is pure transport and must not couple to Domain, Application, or Infrastructure, and the cross-cutting abstractions must sit in the layer their consumers depend on.
    • +
    • Walkthrough - three private static Assembly accessors anchor the Grpc, Application, and Infrastructure assemblies by an anchor type each (:15-:19). Three [Fact]s assert MMCA.Common.Grpc has no dependency on Domain, Application, or Infrastructure (:21-:34) via the AssertNoDependency helper (:51-:59), which runs a Types.InAssembly(...).ShouldNot().HaveDependencyOnAny(...) NetArchTest query and routes the result through ArchitectureAssert.NoViolations (:58). Three more [Fact]s assert placement by comparing the abstraction's declaring assembly against the anchored layer assembly: IMessageBus lives in Application (:36-:39), IJwksProvider in Infrastructure because it handles crypto and PEM material (:41-:44), and ILiveChannelPublisher in Application beside IPushNotificationSender (:46-:49).
    • +
    • Why it's built this way - the message-bus abstraction must stay in Application so application code depends on transport through it (extraction boundary, ADR-007); the JWKS provider is crypto and belongs in Infrastructure (ADR-004). These are load-bearing placements, so they get their own asserted facts.
    • +
    • Where it's used - an independent class in the Common architecture suite; it has no counterpart in Store or ADC because only Common owns the Grpc package and defines these abstractions.
    • +

    AggregateConventionTests, CancellationTokenConventionTests, DomainPurityTests, EventVersioningConventionTests, HandlerResultConventionTests, IdempotencyConventionTests, LayerDependencyTests, LocalizedTextConventionTests, MicroserviceExtractionTests, NamespaceCycleTests, PiiConventionTests, RawQueryableConventionTests, SliceCohesionTests, StateManagementConventionTests, UIArchitectureConventionTests

    -

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · (see per-type table) · Level 8 · class

    +

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · (see per-type table) · Level 13 · class

    These fifteen sealed classes share one shape: each is a thin subclass of a shared *TestsBase rule from the MMCA.Common.Testing.Architecture package, supplying the repo's CommonArchitectureMap (and, for a few, one extra override) so the same rule body runs identically across MMCA.Common, MMCA.Store, and MMCA.ADC. This is the [Rubric §34 - Architecture Governance & Documentation] and [Rubric §14 - Testability] story: architecture conventions are executable and enforced in CI rather than left to review, and the rule logic lives in exactly one place (ADR-015). See the thin-subclass pattern introduced by DependencyVersionTests. The canonical body of each rule is the corresponding *TestsBase; these subclasses only wire in the map and any repo-specific floor or allowlist. Each fails the build-and-test CI job on violation, and a couple are deliberately vacuous today (they assert nothing until the framework grows a type that could break the convention, at which point they fire).

    Where a subclass carries an override beyond Map, that override is itself a documented architectural decision, not configuration: the exemption lists below (NamespaceCycleTests, CancellationTokenConventionTests, StateManagementConventionTests, RawQueryableConventionTests) all carry their justification in code, which is the point of putting the escape hatch in a compiled file rather than a wiki.

    @@ -1388,7 +1705,7 @@

    EventScopeFitnessTests

    -

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/EventScopeFitnessTests.cs:13 · Level 8 · class

    +

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/EventScopeFitnessTests.cs:13 · Level 13 · class

    • What it is - the ownership-scoping guard for the integration-event rules: three facts pinning that a consumer-shaped map neither snapshots nor polices the framework's own events, while the framework's module-less map still covers them at the source.
    • @@ -1403,9 +1720,27 @@

      EventScopeFitnessTests

    • Why it's built this way - a scoping fix is exactly the kind of change that silently over-corrects. Pinning both the exclusion and the retention, against a real framework event rather than a fixture one, is what keeps the fix from becoming a hole.
    • Where it's used - an independent class in the Common architecture suite; the rules it scopes are bound for real by EventVersioningConventionTests here and by the per-repo IntegrationEventContractTestsBase subclasses in the consumers.
    +

    EventUpcasterFitnessTests

    +
    +

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/EventUpcasterFitnessTests.cs:12 · Level 13 · class

    +
    +
      +
    • What it is - the meta-test for the two event-upcaster fitness rules: four facts proving the unique-source rule reports a contract claimed twice, that it leaves a legitimate chain alone, that the version rule reports an upcaster pointing at a lower SchemaVersion, and that both rules pass on a map owning no upcasters at all.
    • +
    • Depends on - ArchitectureRules (EventUpcastersHaveUniqueSourceTypes and EventUpcastersIncreaseSchemaVersion, EventUpcasterFitnessTests.cs:53,:55,:61,:68), the fixture contracts and fixture upcasters (:1), its nested UpcasterTestMap, CommonArchitectureMap (:51), plus xUnit and AwesomeAssertions.
    • +
    • Concept introduced - proving a rule is not vacuous when the framework itself has nothing to judge. MMCA.Common ships no upcaster of its own, so running either rule over the real CommonArchitectureMap proves only that it does not crash. That is a real property worth pinning (a rule that threw on an empty set would red every consumer that has not adopted upcasting yet), and the fourth fact pins exactly it. But the other three facts have to manufacture their subjects, which is what the UpcasterFixtures namespace and the private map are for. The pattern to take away: a fitness function for a consumer-facing convention is proven in the framework repo against fixtures, and only smoke-tested against the framework's own code. ProtoContractFitnessTests and ServiceContractPurityTests sit in the same position for their rules. [Rubric §6 - CQRS & Event-Driven] and [Rubric §9 - API & Contract Design] are the properties defended; [Rubric §14 - Testability] is the technique.
    • +
    • Walkthrough - two private helpers each run one rule against a fresh UpcasterTestMap and return the thrown message: RunUniqueSourceRule (:59-:64) and RunSchemaVersionRule (:66-:71). Because the fixtures always contain an offender for each rule, both helpers can assert Should().Throw<Exception>().Which.Message unconditionally and let the facts make positive and negative Contain assertions against the one string.
        +
      • UniqueSourceRule_FlagsTheContestedContract_ButNotTheCompliantLadder (:14-:25): asserts the message names the contested contract and both rival upcasters (:19-:21), which is what makes the failure actionable, and that the compliant first rung is absent (:22-:24).
      • +
      • UniqueSourceRule_DoesNotFlag_AnUpcasterWhoseSourceIsAnotherUpcastersTarget (:31-:33): the sharp one, given its own fact and its own doc comment (:27-:30). The compliant ladder's middle contract is both a target and a source; that is a chain, not a duplicate claim, and the rule must leave it alone.
      • +
      • SchemaVersionRule_FlagsTheBackwardsUpcaster_ButNotTheCompliantLadder (:35-:46): names the offender (:40) and, notably, pins the message text "must declare a HIGHER SchemaVersion" (:41-:43), because the wording is what tells a developer which direction is allowed. Both clean rungs are asserted absent (:44-:45).
      • +
      • BothRules_Pass_OnAMapThatOwnsNoUpcasters (:48-:57): runs both rules against the real framework map and asserts NotThrow, with the because stating that the framework ships no upcaster so the rule passes vacuously (:54).
      • +
      +
    • +
    • Why it's built this way - the two rules exist because an upcast chain that is not a function, or that runs backwards, produces a bug that only appears when an old outbox row is replayed, potentially long after the change that caused it (ADR-090, building on ADR-010). Catching it at build time is worth a fixture namespace.
    • +
    • Where it's used - an independent class in the Common architecture suite. The shipped binding of both rules is EventConventionTestsBase, whose two facts call them for every repo (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/EventConventionTestsBase.cs:23,:26), and which MMCA.Common activates through EventVersioningConventionTests.
    • +

    FakeConsumerMap

    -

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/EventScopeFitnessTests.cs:50 · Level 8 · class

    +

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/EventScopeFitnessTests.cs:50 · Level 13 · class

    • What it is - a consumer-shaped architecture map: the framework's Domain assembly registered as a framework layer plus one module layer, which is the minimum that makes a map module-bearing.
    • @@ -1414,6 +1749,30 @@

      FakeConsumerMap

    • Walkthrough - RepoToken => "MMCA.FakeConsumer" (:52), deliberately not MMCA.Common, so the derived module namespace looks like a consumer's. DefineLayers() (:54-:58) is a yield-based iterator returning two entries: Framework(Layer.Domain, typeof(BaseIntegrationEvent).Assembly) (:56), which brings the framework's real integration event into the map, and Module("Fake", Layer.Shared, typeof(EventScopeFitnessTests).Assembly) (:57), a stand-in module Shared layer that ships no integration events of its own (class doc, :46-:49). That combination is the exact situation the guard exists for: a consumer whose map can see a framework event but does not own it.
    • Where it's used - the input to the first two facts of EventScopeFitnessTests (:18,:31).
    +

    ServiceContractPurityTests

    +
    +

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/ServiceContractPurityTests.cs:11 · Level 13 · class

    +
    +
      +
    • What it is - the MMCA.Common binding of the [ServiceContract] purity rule: a two-line subclass that supplies the repo's map so any type marked as part of a published wire surface is checked for dependencies on the producing service's Domain, Application or Infrastructure.
    • +
    • Depends on - ServiceContractPurityTestsBase (public sealed class ServiceContractPurityTests : ServiceContractPurityTestsBase, ServiceContractPurityTests.cs:11), IArchitectureMap and CommonArchitectureMap (the single override, :13), and indirectly ServiceContractAttribute, which the rule matches by full name rather than by reference.
    • +
    • Concept introduced - a rule that is deliberately vacuous today, kept as a ratchet. Most gates in this chapter earn their place by failing on real code. This one asserts nothing in MMCA.Common, because the framework marks no type with [ServiceContract], and both the subclass doc (:9-:10) and the base's remarks (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/ServiceContractPurityTestsBase.cs:12-:17) say so plainly. The value is in the timing: the invariant is enforced from the first marked type onward, with no test for anyone to remember to write, at the moment when a contract package's shape is still cheap to change. Two design choices follow from that. It is attribute-driven rather than Layer.Contracts-driven, because no repo registers that layer today and a layer-iterating rule would pass vacuously forever (base remarks, :9-:11); and it scans every assembly the map registers, so a marked type is judged wherever it lives. A marked type sitting inside a Domain, Application or Infrastructure assembly then fails by construction, which the rule's own remarks call the intent: a published contract belongs in a *.Contracts or Shared assembly, not inside the service it describes (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Contracts.cs:26-:29). [Rubric §7 - Microservices Readiness] is the property: a contract that leaks a domain entity, a handler abstraction or a persistence type forces every consumer to take the producer's internals as a package dependency, which is what makes an extraction irreversible (:16-:18). [Rubric §9 - API & Contract Design] covers the wire surface itself, and [Rubric §34 - Architecture Governance & Documentation] the ratchet.
    • +
    • Walkthrough - one member, protected override IArchitectureMap Map { get; } = new CommonArchitectureMap(); (:13). Everything else is inherited: the base contributes a single [Fact], ServiceContracts_ShouldNotDependOn_ServiceInternals (ServiceContractPurityTestsBase.cs:24-:26), which calls ArchitectureRules.ServiceContractsDoNotDependOnServiceInternals(Map). The rule first derives the forbidden namespace set from the map's Domain, Application and Infrastructure layers (ArchitectureRules.Contracts.cs:57-:66) and returns immediately when that set is empty (:35-:38), then walks every layer, selects marked types through the Mono.Cecil custom rule CarriesServiceContractAttribute (:44,:69-:74), and asserts ShouldNot().HaveDependencyOnAny(forbidden) per layer with the layer's root namespace in the message (:42-:52).
    • +
    • Why it's built this way - matching the marker by its full-name string, "MMCA.Common.Shared.Abstractions.ServiceContractAttribute" (ArchitectureRules.Contracts.cs:10-:11), is the same zero-reference idiom the rest of the rule library uses: the testing package deliberately takes no compile dependency on the framework assemblies it inspects. The rule complements, and does not replace, the transport- and layer-purity rules that guard the same boundary from the layer side (ADR-007, ADR-015).
    • +
    • Where it's used - run by the MMCA.Common.Architecture.Tests suite in CI's build-and-test job. Its sibling in this chapter is ProtoContractFitnessTests, the other consumer-facing contract gate the framework exercises without owning any subject of its own.
    • +
    • Caveats / not-in-source - because the framework marks no type, this class asserts nothing today; there is no fixture proving the rule fires. That proof exists only in whichever repo first marks a type.
    • +
    +

    UpcasterTestMap

    +
    +

    MMCA.Common.Architecture.Tests · MMCA.Common.Architecture.Tests · MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/EventUpcasterFitnessTests.cs:74 · Level 13 · class

    +
    +
      +
    • What it is - a one-layer architecture map used only by EventUpcasterFitnessTests: it registers this test assembly as the map's single Application layer so the two upcaster rules see the fixture upcasters and nothing else.
    • +
    • Depends on - ArchitectureMapBase (private sealed class UpcasterTestMap : ArchitectureMapBase, EventUpcasterFitnessTests.cs:74), LayerRef, and the Layer enum.
    • +
    • Concept - cross-references the map concept from CommonArchitectureMap, with one detail specific to these rules. Both of them scope by ownership exactly as the integration-event rules do: EventUpcasters includes framework layers only when the map declares no modules (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Upcasters.cs:62-:64). Because this map is built entirely from Framework(...) entries, ModuleNames is empty and the single Application layer is scanned, which is what puts the fixtures in scope. Registering a module layer instead would silently exclude them and both rules would pass vacuously, so the choice is load-bearing rather than incidental. Compare FakeConsumerMap, which flips exactly that switch on purpose.
    • +
    • Walkthrough - RepoToken => "MMCA.Common" (:76) and a one-entry DefineLayers() returning Framework(Layer.Application, typeof(EventUpcasterFitnessTests).Assembly) (:78-:79). The doc comment states the intent in one line (:73). The layer's derived root namespace is unused by these rules, which enumerate ConcreteClasses across whole assemblies (ArchitectureRules.Upcasters.cs:67) rather than namespace-scoped subsets.
    • +
    • Where it's used - constructed on every call of the test class's two private rule helpers (EventUpcasterFitnessTests.cs:61,:68).
    • +

    CrossServiceDataSource

    MMCA.Common.Testing · MMCA.Common.Testing · MMCA.Common/Source/Hosting/MMCA.Common.Testing/CrossServiceFixtureBase.cs:15 · Level 0 · sealed record

    @@ -1422,26 +1781,27 @@

    CrossServiceDataSource

  • What it is: a two-field record naming one logical data source a cross-service fixture routes to its own physical database: the logical name the framework's DataSources configuration section keys on (normally the module name) and the database that name resolves to on the shared SQL Server container - (CrossServiceFixtureBase.cs:8-15).
  • + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/CrossServiceFixtureBase.cs:8-15).
  • Depends on: nothing. A positional sealed record of two strings, declared above CrossServiceFixtureBase in the same file.
  • Concept: it is the declarative half of database-per-service (ADR-006, taught in primer §2) expressed as test data. The doc's own examples are the shape to hold onto: LogicalName is Conference, DatabaseName is - ADC_Conference (CrossServiceFixtureBase.cs:13-14). [Rubric §8, Data Architecture] assesses whether - each service owns its own store; this record is how a test fixture states that ownership once and derives - everything else from it.
  • + ADC_Conference (MMCA.Common/Source/Hosting/MMCA.Common.Testing/CrossServiceFixtureBase.cs:13-14). + [Rubric §8, Data Architecture] assesses whether each service owns its own store; this record is how a + test fixture states that ownership once and derives everything else from it.
  • Walkthrough: two positional members, LogicalName and DatabaseName - (CrossServiceFixtureBase.cs:15), so it gets structural equality and immutability for free. The base - consumes each instance three ways: the database name drives the pre-create loop - (CrossServiceFixtureBase.cs:213), and the logical name drives both environment keys + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/CrossServiceFixtureBase.cs:15), so it gets structural + equality and immutability for free. The base consumes each instance three ways: the database name drives + the pre-create loop (CrossServiceFixtureBase.cs:213), and the logical name drives both environment keys SetNamedDataSource pushes, DataSources__{LogicalName}__SQLServerConnectionString and DataSources__{LogicalName}__SQLServerMigrationsAssembly (CrossServiceFixtureBase.cs:272-275).
  • Where it's used: as the DataSources list a subclass supplies (CrossServiceFixtureBase.cs:60); ADC - declares three (MMCA.ADC/Tests/Integration/MMCA.ADC.CrossService.IntegrationTests/Infrastructure/CrossServiceFixture.cs:61-66) - and Store its own set - (MMCA.Store/Tests/Integration/MMCA.Store.CrossService.IntegrationTests/Infrastructure/CrossServiceFixture.cs:29).
  • + declares three, Identity, Conference and Engagement + (MMCA.ADC/Tests/Integration/MMCA.ADC.CrossService.IntegrationTests/Infrastructure/CrossServiceFixture.cs:61-66), + and Store declares two, Catalog and Sales + (MMCA.Store/Tests/Integration/MMCA.Store.CrossService.IntegrationTests/Infrastructure/CrossServiceFixture.cs:56-60).

    DependencyInjectionAssert

    @@ -1452,20 +1812,23 @@

    DependencyInjectionAssert

    exposes. It proves a registration extension hands back the very IServiceCollection it was given, so a fluent chain stays intact.
  • Depends on: AwesomeAssertions and Microsoft.Extensions.DependencyInjection - (DependencyInjectionAssert.cs:1-2). No first-party dependency.
  • + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/DependencyInjectionAssert.cs:1-2). No first-party + dependency.
  • Concept introduced, the fluent-contract guard. The framework's registration methods are fluent by convention: hosts chain AddApplication().AddInfrastructure(...).AddAPI(...). An extension that returns a new collection silently drops every registration chained after it, and no other test catches that, - because the dropped services are simply absent rather than wrong (DependencyInjectionAssert.cs:6-11). + because the dropped services are simply absent rather than wrong + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/DependencyInjectionAssert.cs:6-11). [Rubric §14, Testability] assesses whether an invariant can be checked cheaply; this one turns an otherwise invisible composition failure into a one-line test. [Rubric §16, Maintainability] covers the convention itself: the return-the-same-collection contract is what lets host composition stay declarative.
  • Walkthrough
    • ReturnsSameCollection(Func<IServiceCollection, IServiceCollection> register) - (DependencyInjectionAssert.cs:21-32): null-guards the delegate (:23), creates the ServiceCollection - itself so the call site stays one line (:25, the doc shows the shape at :16-18), invokes the - registration under test (:27), and asserts result.Should().BeSameAs(services, ...) with a - because-reason that spells out the consequence of failing (:29-31).
    • + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/DependencyInjectionAssert.cs:21-32): null-guards the + delegate (:23), creates the ServiceCollection itself so the call site stays one line (:25, the doc + shows the shape at :16-18), invokes the registration under test (:27), and asserts + result.Should().BeSameAs(services, ...) with a because-reason that spells out the consequence of + failing (:29-31).
    • Reference equality is the whole assertion. It deliberately says nothing about what was registered; the per-module tests that call it assert their own service descriptors separately.
    @@ -1473,10 +1836,11 @@

    DependencyInjectionAssert

  • Why it's built this way: creating the collection inside the helper is what keeps adoption free. A module's DI test adds one line per registration extension rather than three lines of arrange plus an assertion nobody remembers to write.
  • -
  • Where it's used: across the module DI test classes in both apps, for example +
  • Where it's used: seven module DI test classes across the two apps, for example MMCA.Store/Tests/Modules/Sales/MMCA.Store.Sales.Infrastructure.Tests/DependencyInjectionTests.cs:29, MMCA.Store/Tests/Modules/Sales/MMCA.Store.Sales.API.Tests/DependencyInjectionTests.cs:63,68, MMCA.Store/Tests/Modules/Catalog/MMCA.Store.Catalog.API.Tests/DependencyInjectionTests.cs:68,73, + MMCA.Store/Tests/Modules/Identity/MMCA.Store.Identity.API.Tests/DependencyInjectionTests.cs:49,54, MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.API.Tests/DependencyInjectionTests.cs:26,31, and MMCA.ADC/Tests/Modules/Notification/MMCA.ADC.Notification.Application.Tests/DependencyInjectionTests.cs:35. MMCA.Common self-tests the helper, including that it fails for an extension returning a different @@ -1491,17 +1855,19 @@

    EntityBuilderBase<TBuilder, TEntit defaults for one entity type so a test only has to state the properties it actually cares about, then calls Build() to materialize the entity through its real domain factory.

  • Depends on: nothing first-party, and no BCL surface beyond object. Two type parameters and one - abstract method is the whole type (EntityBuilderBase.cs:9-18).
  • + abstract method is the whole type + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/Builders/EntityBuilderBase.cs:9-18).
  • Concept introduced, the Test Data Builder plus the self-referencing generic (CRTP). [Rubric §14, Testability] assesses how easily the code can be exercised in isolation; a builder base is a textbook §14 affordance, it removes the copy-pasted setup that otherwise bloats every arrange - step. The signature EntityBuilderBase<TBuilder, TEntity> where TBuilder : EntityBuilderBase<TBuilder, TEntity> (EntityBuilderBase.cs:9-10) is the curiously-recurring template pattern: a concrete builder - passes itself as TBuilder, so the WithX(...) methods a subclass adds can return the concrete - builder type and keep a fluent chain strongly typed without a cast.
  • + step. The signature EntityBuilderBase<TBuilder, TEntity> where TBuilder : EntityBuilderBase<TBuilder, TEntity> (MMCA.Common/Source/Hosting/MMCA.Common.Testing/Builders/EntityBuilderBase.cs:9-10) is the + curiously-recurring template pattern: a concrete builder passes itself as TBuilder, so the + WithX(...) methods a subclass adds can return the concrete builder type and keep a fluent chain + strongly typed without a cast.
  • Walkthrough
      -
    • Build() (EntityBuilderBase.cs:17): the single abstract member. The XML doc - (EntityBuilderBase.cs:12-15) records the contract, the subclass calls the entity's - Result-returning factory +
    • Build() (MMCA.Common/Source/Hosting/MMCA.Common.Testing/Builders/EntityBuilderBase.cs:17): the + single abstract member. The XML doc (EntityBuilderBase.cs:12-15) records the contract, the subclass + calls the entity's Result-returning factory (ADR-013) and throws if it failed, so a builder never yields a domain object that violated its invariants. The base deliberately owns no state and no default WithX helpers, those live on each concrete builder because defaults are @@ -1511,9 +1877,10 @@

      EntityBuilderBase<TBuilder, TEntit
    • Why it's built this way: keeping the base to one abstract method means it adds zero coupling and zero opinions beyond "a builder produces a TEntity". The CRTP is the only structural rule it enforces, and it exists purely so fluent chaining stays type-safe down in the subclasses.
    • -
    • Where it's used: the domain-test builders in both apps subclass it, eight today: - MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Domain.Tests/Builders/EventBuilder.cs:10, - .../Builders/SessionBuilder.cs:10, .../Builders/SpeakerBuilder.cs:10, +
    • Where it's used: the domain-test builders in both apps subclass it, ten today: + MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Domain.Tests/Builders/ActivityBuilder.cs:10, + .../Builders/EventBuilder.cs:10, .../Builders/SessionBuilder.cs:10, + .../Builders/SpeakerBuilder.cs:10, .../Builders/SponsorBuilder.cs:11, MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Domain.Tests/Builders/UserBuilder.cs:10, MMCA.Store/Tests/Modules/Catalog/MMCA.Store.Catalog.Domain.Tests/Builders/CategoryBuilder.cs:10, .../Builders/ProductBuilder.cs:10, @@ -1532,7 +1899,8 @@

      FeatureManagementTestExtensions

      assert both branches of a feature-gated command or query.
    • Depends on: BCL and NuGet only, IServiceCollection and IConfiguration from Microsoft.Extensions.* plus AddFeatureManagement from Microsoft.FeatureManagement - (FeatureManagementTestExtensions.cs:1-3). No first-party dependency.
    • + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/FeatureManagementTestExtensions.cs:1-3). No + first-party dependency.

    • Concept: this is the test-side counterpart to the framework's FeatureGateCommandDecorator<TCommand, TResult>, the outermost link in the CQRS pipeline (taught in @@ -1543,24 +1911,28 @@

      FeatureManagementTestExtensions

      concern, and this helper keeps its test-time configuration in one reusable place.
    • Walkthrough
      • The whole class body is a single C# preview extension(IServiceCollection services) block - (FeatureManagementTestExtensions.cs:12), the same extension-member style the framework uses for DI - registration (see primer §4), not a classic - this-parameter extension method.
      • + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/FeatureManagementTestExtensions.cs:12), the same + extension-member style the framework uses for DI registration (see + primer §4), not a classic this-parameter + extension method.
      • ConfigureTestFeatureFlags(Dictionary<string, bool> features) - (FeatureManagementTestExtensions.cs:21-35): projects each name-to-bool pair into an in-memory - configuration key under the FeatureManagement: section (:24-29), registers that IConfiguration - as a singleton (:31), calls AddFeatureManagement against the section (:32), and returns the - collection for chaining (:34).
      • + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/FeatureManagementTestExtensions.cs:21-35): projects + each name-to-bool pair into an in-memory configuration key under the FeatureManagement: section + (:24-29), registers that IConfiguration as a singleton (:31), calls AddFeatureManagement + against the section (:32), and returns the collection for chaining (:34).
    • Why it's built this way: pushing overrides through the real IConfiguration plus AddFeatureManagement path (rather than mocking an IFeatureManager) means the test exercises the same feature-evaluation code the production host runs, only the source of the flag value changes.
    • Where it's used: it is intended for a test WebApplicationFactory's ConfigureServices, and the - XML doc says exactly that (FeatureManagementTestExtensions.cs:14-18).
    • + XML doc says exactly that + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/FeatureManagementTestExtensions.cs:14-18).
    • Caveats / not-in-source: as of this pass no first-party caller exists. A workspace-wide search - finds ConfigureTestFeatureFlags only at its definition; every other hit is documentation. It ships in - the package as available capability, not as a technique any suite currently uses.
    • + finds ConfigureTestFeatureFlags only at its definition + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/FeatureManagementTestExtensions.cs:21); every other + hit is documentation. It ships in the package as available capability, not as a technique any suite + currently uses.

    IIntegrationTestFixture

    @@ -1579,11 +1951,11 @@

    IIntegrationTestFixture

    app-specific wiring in each repo, which is the whole premise of ADR-058.
  • Walkthrough
      -
    • CreateClient() (IIntegrationTestFixture.cs:11): returns an HttpClient configured for the - in-process test server.
    • -
    • ResetDatabaseAsync() (IIntegrationTestFixture.cs:19): resets the database between tests (the doc - names Respawn as the typical mechanism). The doc comment (IIntegrationTestFixture.cs:13-18) records - a load-bearing rule for the database-per-service topology +
    • CreateClient() (MMCA.Common/Source/Hosting/MMCA.Common.Testing/IIntegrationTestFixture.cs:11): + returns an HttpClient configured for the in-process test server.
    • +
    • ResetDatabaseAsync() (MMCA.Common/Source/Hosting/MMCA.Common.Testing/IIntegrationTestFixture.cs:19): + resets the database between tests (the doc names Respawn as the typical mechanism). The doc comment + (IIntegrationTestFixture.cs:13-18) records a load-bearing rule for the database-per-service topology (ADR-006): a host with multiple physical data sources must reset every relational source, and can enumerate them by resolving IEntityDataSourceRegistry and @@ -1595,9 +1967,11 @@

      IIntegrationTestFixture

      base's.
    • Where it's used: implemented by SqlServerIntegrationTestFixtureBase<TEntryPoint> - (SqlServerIntegrationTestFixtureBase.cs:27) and through it by every per-service fixture in both apps; - consumed as the TFixture constraint on IntegrationTestBase<TFixture> - (IntegrationTestBase.cs:14) and therefore by all three contract bases in this unit.
    • + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/SqlServerIntegrationTestFixtureBase.cs:27) and through + it by every per-service fixture in both apps; consumed as the TFixture constraint on + IntegrationTestBase<TFixture> + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/IntegrationTestBase.cs:14) and therefore by all three + contract bases in this unit.

    JwtTokenGenerator

    @@ -1608,13 +1982,15 @@

    JwtTokenGenerator

    matching switch that re-points a test host's Bearer scheme at the same committed key. Together they let a test call an authorized endpoint as any role or user without standing up the real login flow or a reachable JWKS endpoint. Each downstream project wraps the generator with role-specific convenience - methods (AdminToken, OrganizerToken, and so on, JwtTokenGenerator.cs:11-12).
  • + methods (AdminToken, OrganizerToken, and so on, + MMCA.Common/Source/Hosting/MMCA.Common.Testing/JwtTokenGenerator.cs:11-12).
  • Depends on: BCL and NuGet only, System.Globalization, System.IdentityModel.Tokens.Jwt, System.Security.Claims, System.Security.Cryptography (RSA), Microsoft.AspNetCore.Authentication.JwtBearer (for the options type the second member configures), and - Microsoft.IdentityModel.Tokens (JwtTokenGenerator.cs:1-6). The generated claim layout mirrors the - framework's ITokenService so downstream auth middleware cannot tell a - test token from a real one (JwtTokenGenerator.cs:99-102). The userId parameter is typed + Microsoft.IdentityModel.Tokens + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/JwtTokenGenerator.cs:1-6). The generated claim layout + mirrors the framework's ITokenService so downstream auth middleware + cannot tell a test token from a real one (JwtTokenGenerator.cs:99-102). The userId parameter is typed UserIdentifierType (JwtTokenGenerator.cs:114), the solution-wide identifier alias (ADR-048).
  • Concept introduced, exercising the real RS256 path in tests. [Rubric §11, Security] assesses @@ -1627,12 +2003,13 @@

    JwtTokenGenerator

    shortcut. [Rubric §14, Testability] covers the ergonomics: deterministic tokens with no per-run key generation, and a host that validates them with no network dependency at all.
  • Walkthrough
      -
    • Public constants (JwtTokenGenerator.cs:33-96): DefaultIssuer (https://localhost:6001, line 33), - DefaultKeyId (mmca-test-key, line 41, the kid the host advertises on its JWKS document), and - the paired DefaultPublicKeyPem (line 49) and DefaultPrivateKeyPem (line 68). The class doc records - the wiring contract: test host appsettings set Jwt:SigningAlgorithm=RS256, Jwt:RsaPublicKeyPem, - and Jwks:KeyId (JwtTokenGenerator.cs:18-20) so - RsaJwksProvider publishes a JWKS entry with the matching kid.
    • +
    • Public constants (MMCA.Common/Source/Hosting/MMCA.Common.Testing/JwtTokenGenerator.cs:33-96): + DefaultIssuer (https://localhost:6001, line 33), DefaultKeyId (mmca-test-key, line 41, the + kid the host advertises on its JWKS document), and the paired DefaultPublicKeyPem (line 49) and + DefaultPrivateKeyPem (line 68). The class doc records the wiring contract: test host appsettings set + Jwt:SigningAlgorithm=RS256, Jwt:RsaPublicKeyPem, and Jwks:KeyId + (JwtTokenGenerator.cs:18-20) so RsaJwksProvider publishes a JWKS + entry with the matching kid.
    • GenerateToken(...) (JwtTokenGenerator.cs:112-153): imports the PEM private key into RSAParameters inside a using so the RSA instance can be disposed without invalidating the key held by SigningCredentials (:121-131), assembles the standard claim set @@ -1654,19 +2031,26 @@

      JwtTokenGenerator

      fixture and a multi-host cross-service fixture share one committed keypair.
    • Where it's used: tokens are applied to a client through IntegrationTestBase<TFixture>'s SetBearerToken(...) - (IntegrationTestBase.cs:42-44) and wrapped by each app's role-specific token helpers. - ConfigureInProcessTokenValidation is called from a PostConfigure<JwtBearerOptions> in the test - factories of the non-Identity hosts, for example - MMCA.Store/Tests/Integration/MMCA.Store.Catalog.IntegrationTests/Infrastructure/CatalogTestWebApplicationFactory.cs:34, - MMCA.ADC/Tests/Integration/MMCA.ADC.Notification.IntegrationTests/Infrastructure/NotificationTestWebApplicationFactory.cs:44, - and both Store cross-service factories - (MMCA.Store/Tests/Integration/MMCA.Store.CrossService.IntegrationTests/Infrastructure/CatalogCrossServiceFactory.cs:44, - .../SalesCrossServiceFactory.cs:46). It is covered directly by + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/IntegrationTestBase.cs:42-44) and wrapped by each + app's role-specific token helpers. ConfigureInProcessTokenValidation is called from a + PostConfigure<JwtBearerOptions> in the test factories of the non-Identity hosts, in ADC + (MMCA.ADC/Tests/Integration/MMCA.ADC.Conference.IntegrationTests/Infrastructure/ConferenceTestWebApplicationFactory.cs:49,77, + .../MMCA.ADC.Engagement.IntegrationTests/Infrastructure/EngagementTestWebApplicationFactory.cs:56,86, + .../MMCA.ADC.Notification.IntegrationTests/Infrastructure/NotificationTestWebApplicationFactory.cs:50,69) + and in Store + (MMCA.Store/Tests/Integration/MMCA.Store.Catalog.IntegrationTests/Infrastructure/CatalogTestWebApplicationFactory.cs:40,43, + .../MMCA.Store.Sales.IntegrationTests/Infrastructure/SalesTestWebApplicationFactory.cs:47,57), plus + all four cross-service factories + (MMCA.ADC/Tests/Integration/MMCA.ADC.CrossService.IntegrationTests/Infrastructure/ConferenceCrossServiceFactory.cs:45, + .../EngagementCrossServiceFactory.cs:54, + MMCA.Store/Tests/Integration/MMCA.Store.CrossService.IntegrationTests/Infrastructure/CatalogCrossServiceFactory.cs:50, + .../SalesCrossServiceFactory.cs:52). It is covered directly by MMCA.Common/Tests/Hosting/MMCA.Common.Testing.Tests/JwtTokenGeneratorTests.cs:38-94.
    • -
    • Caveats / not-in-source: the class doc (JwtTokenGenerator.cs:22-28) carries an explicit security - warning, the embedded keypair is committed to the public git repo and is insecure by design, it exists - only to make integration tests deterministic. Production keys are provisioned via user-secrets or Azure - Key Vault per JwtSettings.RsaPrivateKeyPem and must never be this keypair.
    • +
    • Caveats / not-in-source: the class doc + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/JwtTokenGenerator.cs:22-28) carries an explicit + security warning, the embedded keypair is committed to the public git repo and is insecure by design, it + exists only to make integration tests deterministic. Production keys are provisioned via user-secrets or + Azure Key Vault per JwtSettings.RsaPrivateKeyPem and must never be this keypair.

    ProductionHostApplicationFactory<TEntryPoint>

    @@ -1677,12 +2061,15 @@

    ProductionHostApplicationFa pins the hosting environment to Production and hangs on to the started IHost, so a test can both exercise production-only middleware branches and drive the host's own lifetime.

  • Depends on: Microsoft.AspNetCore.Mvc.Testing's WebApplicationFactory<TEntryPoint> (extended, - ProductionHostApplicationFactory.cs:22) and Microsoft.Extensions.Hosting's IHost / IHostBuilder + MMCA.Common/Source/Hosting/MMCA.Common.Testing/ProductionHostApplicationFactory.cs:22) and + Microsoft.Extensions.Hosting's IHost / IHostBuilder (ProductionHostApplicationFactory.cs:1-2). No first-party dependency.
  • Concept introduced, the second boot path. The integration tier has two ways to get a running host: SqlServerIntegrationTestFixtureBase<TEntryPoint> for hosts that need a real database, and this one for hosts that do not (a YARP reverse-proxy gateway - is the usual case, ProductionHostApplicationFactory.cs:16-19). Both are named as the two paths in + is the usual case, + MMCA.Common/Source/Hosting/MMCA.Common.Testing/ProductionHostApplicationFactory.cs:16-19). Both are + named as the two paths in ADR-058. [Rubric §11, Security] is the reason Production is pinned: the restrictive CORS policy, HSTS emission, and other production-only middleware are branches a default Development boot skips @@ -1690,13 +2077,15 @@

    ProductionHostApplicationFa (ProductionHostApplicationFactory.cs:9-12). [Rubric §14, Testability] covers the second half, capturing the host is what makes a lifetime test possible at all.

  • Walkthrough
      -
    • StartedHost (ProductionHostApplicationFactory.cs:29): a public property with a private setter, - nullable because WebApplicationFactory builds its host lazily, so it stays null until the first - client is created (:25-28).
    • -
    • CreateHost(IHostBuilder builder) (ProductionHostApplicationFactory.cs:32-39): null-guards the - builder (:34), calls builder.UseEnvironment("Production") (:36), then assigns and returns - base.CreateHost(builder) (:37-38). Three lines of override, and the assignment is the entire - reason the class exists.
    • +
    • StartedHost + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/ProductionHostApplicationFactory.cs:29): a public + property with a private setter, nullable because WebApplicationFactory builds its host lazily, so it + stays null until the first client is created (:25-28).
    • +
    • CreateHost(IHostBuilder builder) + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/ProductionHostApplicationFactory.cs:32-39): + null-guards the builder (:34), calls builder.UseEnvironment("Production") (:36), then assigns and + returns base.CreateHost(builder) (:37-38). Three lines of override, and the assignment is the + entire reason the class exists.
  • Why it's built this way: IHost.StopAsync is not reachable through the WebApplicationFactory @@ -1705,9 +2094,10 @@

    ProductionHostApplicationFa used directly as an xUnit IClassFixture<...> with no subclass.

  • Where it's used: as the default factory of GracefulShutdownTestsBase<TEntryPoint> - (GracefulShutdownTestsBase.cs:31), and directly as the class fixture of both gateway security-header - tests (MMCA.Store/Tests/Hosts/MMCA.Store.Gateway.Tests/SecurityHeadersTests.cs:11-12, - MMCA.ADC/Tests/Hosts/MMCA.ADC.Gateway.Tests/SecurityHeadersTests.cs:11-12).
  • + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/GracefulShutdownTestsBase.cs:31), and directly as the + class fixture of both gateway security-header tests + (MMCA.Store/Tests/Hosts/MMCA.Store.Gateway.Tests/SecurityHeadersTests.cs:12, + MMCA.ADC/Tests/Hosts/MMCA.ADC.Gateway.Tests/SecurityHeadersTests.cs:12).
  • Caveats / not-in-source: the doc is explicit that a host which migrates or seeds on startup needs its own fixture (ProductionHostApplicationFactory.cs:16-19); this factory does nothing about a database.
  • @@ -1720,7 +2110,8 @@

    SecurityHeadersTestsBase

  • What it is: a one-test conformance base that asserts a booted host emits the hardened set of security response headers on every response, so a later pipeline refactor cannot silently drop them. Authored once, re-run as a thin subclass per host under test.
  • -
  • Depends on: AwesomeAssertions and Xunit (SecurityHeadersTestsBase.cs:1-2). It deliberately +
  • Depends on: AwesomeAssertions and Xunit + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/SecurityHeadersTestsBase.cs:1-2). It deliberately does not extend IntegrationTestBase<TFixture>: it needs only an HttpClient, so it takes one through an abstract factory rather than inheriting the SQL fixture machinery.
  • @@ -1731,20 +2122,22 @@

    SecurityHeadersTestsBase

    ADR-023) is expected to emit. [Rubric §14, Testability] covers the reusable-base shape.
  • Walkthrough
      -
    • ProbePath (SecurityHeadersTestsBase.cs:19): overridable, defaults to /alive because the - liveness endpoint always answers independent of any backend being reachable, so the header check is - never flaky for the wrong reason (rationale in the class doc, :12-14).
    • -
    • AliveResponse_CarriesHardenedSecurityHeaders (SecurityHeadersTestsBase.cs:21-36): the single +
    • ProbePath (MMCA.Common/Source/Hosting/MMCA.Common.Testing/SecurityHeadersTestsBase.cs:19): + overridable, defaults to /alive because the liveness endpoint always answers independent of any + backend being reachable, so the header check is never flaky for the wrong reason (rationale in the + class doc, :12-14).
    • +
    • AliveResponse_CarriesHardenedSecurityHeaders + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/SecurityHeadersTestsBase.cs:21-36): the single [Fact]. It GETs ProbePath (:26-27, threading TestContext.Current.CancellationToken) and asserts six headers (:29-35): X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin, a Permissions-Policy containing geolocation=(), a Content-Security-Policy containing frame-ancestors 'none', and (because the host under test boots in the Production environment) an HSTS Strict-Transport-Security header with a max-age=.
    • -
    • CreateClient() (SecurityHeadersTestsBase.cs:42): abstract, the subclass supplies it from its - WebApplicationFactory class fixture. Header(...) (:44-45) is the private helper that joins a - header's values or returns null when the header is absent, which is what makes a missing header fail - with a readable null-versus-expected message.
    • +
    • CreateClient() (MMCA.Common/Source/Hosting/MMCA.Common.Testing/SecurityHeadersTestsBase.cs:42): + abstract, the subclass supplies it from its WebApplicationFactory class fixture. Header(...) + (:44-45) is the private helper that joins a header's values or returns null when the header is + absent, which is what makes a missing header fail with a readable null-versus-expected message.
  • Why it's built this way: pinning literal header values (not just presence) turns "we harden @@ -1753,8 +2146,9 @@

    SecurityHeadersTestsBase

    which is why the two adopters pair it with ProductionHostApplicationFactory<TEntryPoint>.
  • Where it's used: both gateway hosts subclass it with a single CreateClient override, - MMCA.Store/Tests/Hosts/MMCA.Store.Gateway.Tests/SecurityHeadersTests.cs:11-12 and - MMCA.ADC/Tests/Hosts/MMCA.ADC.Gateway.Tests/SecurityHeadersTests.cs:11-12.
  • + MMCA.Store/Tests/Hosts/MMCA.Store.Gateway.Tests/SecurityHeadersTests.cs:12 and + MMCA.ADC/Tests/Hosts/MMCA.ADC.Gateway.Tests/SecurityHeadersTests.cs:12, each also taking a + ProductionHostApplicationFactory<Program> as its xUnit class fixture on the same line.

    TestPolling

    @@ -1767,17 +2161,18 @@

    TestPolling

    library, so the caller keeps ownership of the assertion.
  • Concept introduced, replacing the pre-assert sleep. Anything that travels the outbox to a broker and back, or any other eventually-consistent path, arrives at a time the test cannot know - (TestPolling.cs:3-8). A fixed Task.Delay before the assertion is both slow and flaky: too short and - the suite reds intermittently, too long and every green run pays the worst case. Polling returns as soon - as the condition holds and bounds the wait. [Rubric §14, Testability] assesses whether the suite is - deterministic; [Rubric §6, CQRS & Event-Driven] is why the problem exists at all, since the outbox - (ADR-003) is asynchronous by design and - offers no synchronous handle to await.
  • + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/TestPolling.cs:3-8). A fixed Task.Delay before the + assertion is both slow and flaky: too short and the suite reds intermittently, too long and every green + run pays the worst case. Polling returns as soon as the condition holds and bounds the wait. + [Rubric §14, Testability] assesses whether the suite is deterministic; [Rubric §6, CQRS & Event-Driven] is why the problem exists at all, since the outbox + (ADR-003) is asynchronous by design + and offers no synchronous handle to await.
  • Walkthrough
    • PollUntilAsync<T>(Func<Task<T>> probe, Func<T, bool> isSatisfied, TimeSpan? timeout = null, TimeSpan? interval = null) - (TestPolling.cs:22-41): null-guards both delegates (:28-29), computes a deadline from the - 60-second default budget (:31) and a 500 ms default interval (:32), probes once before the - loop (:33), then loops while the condition is unmet and the deadline has not passed (:34-38).
    • + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/TestPolling.cs:22-41): null-guards both delegates + (:28-29), computes a deadline from the 60-second default budget (:31) and a 500 ms default + interval (:32), probes once before the loop (:33), then loops while the condition is unmet and the + deadline has not passed (:34-38).
    • The return is the design decision worth noticing: it returns last unconditionally (:40) rather than throwing on timeout, so a timed-out poll still fails on the caller's real assertion message rather than on a bare timeout exception (the doc states exactly this at :11-14).
    • @@ -1794,7 +2189,7 @@

      TestPolling

      with call sites such as MMCA.Store/Tests/Integration/MMCA.Store.CrossService.IntegrationTests/CrossService/ProductVariantChangedRoundTripTests.cs:28,48,51. MMCA.Common covers the helper itself, including the null-argument guards - (MMCA.Common/Tests/Hosting/MMCA.Common.Testing.Tests/TestPollingTests.cs:18-63). + (MMCA.Common/Tests/Hosting/MMCA.Common.Testing.Tests/TestPollingTests.cs:14-63).

    CrossServiceFixtureBase

    @@ -1804,16 +2199,18 @@

    CrossServiceFixtureBase

  • What it is: the shared scaffolding for the cross-service real-broker integration tier. It boots several service hosts in ONE process against a real Testcontainers SQL Server and a real Testcontainers RabbitMQ, so the genuine outbox to broker to consumer round-trip (and any real cross-service gRPC read) - is exercised end to end rather than faked (CrossServiceFixtureBase.cs:17-25).
  • + is exercised end to end rather than faked + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/CrossServiceFixtureBase.cs:17-25).
  • Depends on: CrossServiceDataSource (the per-source declaration), plus Microsoft.Data.SqlClient, Testcontainers.MsSql, Testcontainers.RabbitMq, and xUnit's - IAsyncLifetime (CrossServiceFixtureBase.cs:1-4,41).
  • + IAsyncLifetime (MMCA.Common/Source/Hosting/MMCA.Common.Testing/CrossServiceFixtureBase.cs:1-4,41).
  • Concept introduced, the multi-host in-process topology and its configuration channel. Where SqlServerIntegrationTestFixtureBase<TEntryPoint> boots one host with the cross-service edges faked and no broker, this base owns a whole topology. Two mechanisms are load-bearing, and both are documented on the class. First, process environment - variables are the only override channel these hosts honour (CrossServiceFixtureBase.cs:26-39): each - host reads its connection string, MessageBus settings, and JWT settings from builder.Configuration at + variables are the only override channel these hosts honor + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/CrossServiceFixtureBase.cs:26-39): each host reads its + connection string, MessageBus settings, and JWT settings from builder.Configuration at configure-time, before builder.Build(), which is before WebApplicationFactory.ConfigureAppConfiguration deltas apply, so in-memory config would arrive too late. Second, because the one genuinely per-host key is the SQL connection string, hosts must boot @@ -1821,12 +2218,13 @@

    CrossServiceFixtureBase

    connection (the data-source resolver, the context factory, the outbox processor, and the MassTransit bus are all built during StartAsync). [Rubric §7, Microservices Readiness] assesses whether extracted services really do collaborate over their declared transports; [Rubric §6, CQRS & Event-Driven] covers - the outbox path (ADR-003); + the outbox path (ADR-003); [Rubric §8, Data Architecture] covers database-per-service (ADR-006); and [Rubric §14, Testability] covers shipping the whole topology as a reusable base.
  • Walkthrough
      -
    • State: the private DummyBearerAuthority constant (CrossServiceFixtureBase.cs:45), the +
    • State: the private DummyBearerAuthority constant + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/CrossServiceFixtureBase.cs:45), the original-environment snapshot map (:47), the two nullable containers (:49-50), and the public RabbitMqConnectionString (:53).
    • Subclass knobs: DataSources (:60, the logical sources in the order their databases are created), @@ -1877,73 +2275,90 @@

      CrossServiceFixtureBase

      MMCA.ADC/Tests/Integration/MMCA.ADC.CrossService.IntegrationTests/Infrastructure/CrossServiceFixture.cs:23 (three databases for three REST hosts at :61-66, migrations prefix MMCA.ADC.Migrations.SqlServer at :69) and - MMCA.Store/Tests/Integration/MMCA.Store.CrossService.IntegrationTests/Infrastructure/CrossServiceFixture.cs:29. - MMCA.Common covers the container-free half of the base through its own private FakeCrossServiceFixture - (MMCA.Common/Tests/Hosting/MMCA.Common.Testing.Tests/CrossServiceFixtureBaseTests.cs:13,106).
    • + MMCA.Store/Tests/Integration/MMCA.Store.CrossService.IntegrationTests/Infrastructure/CrossServiceFixture.cs:29 + (two databases at :56-60, prefix MMCA.Store.Migrations.SqlServer at :63). MMCA.Common covers the + container-free half of the base through its own private FakeCrossServiceFixture + (MMCA.Common/Tests/Hosting/MMCA.Common.Testing.Tests/CrossServiceFixtureBaseTests.cs:106).
    • Caveats / not-in-source: this tier needs a Docker daemon. Where each repo schedules it is a CI decision recorded outside this class; the base itself says nothing about scheduling.

    DecoratorPipelineOrderTestsBase<TCommand, TCommandResult, TQuery, TQueryResult>

    -

    MMCA.Common.Testing · MMCA.Common.Testing · MMCA.Common/Source/Hosting/MMCA.Common.Testing/DecoratorPipelineOrderTestsBase.cs:36 · Level 1 · class (abstract)

    +

    MMCA.Common.Testing · MMCA.Common.Testing · MMCA.Common/Source/Hosting/MMCA.Common.Testing/DecoratorPipelineOrderTestsBase.cs:38 · Level 1 · class (abstract)

      -
    • What it is: an opt-in conformance base that builds a real ServiceCollection through a repo's own +
    • What it is: an opt-in fitness function that builds a real ServiceCollection through a repo's own registration sequence, resolves the decorated command and query handlers out of the built provider, and asserts the runtime object graph nests the decorators in exactly the ADR-014 order.
    • Depends on: ICommandHandler<in TCommand, TResult> and IQueryHandler<in TQuery, TResult> from - MMCA.Common.Application.UseCases (DecoratorPipelineOrderTestsBase.cs:4), plus System.Reflection, - Microsoft.Extensions.DependencyInjection, AwesomeAssertions, and Xunit (:1-5).
    • + MMCA.Common.Application.UseCases + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/DecoratorPipelineOrderTestsBase.cs:4), plus + System.Reflection, Microsoft.Extensions.DependencyInjection, AwesomeAssertions, and Xunit + (:1-5).
    • Concept introduced, verifying a decorator chain by unwrapping the constructed graph. The decorator pipeline itself is taught in group-05; what is new here is how you prove it. Scrutor's TryDecorate applies decorators in reverse registration order, so the outermost decorator is the last one registered, and an innocent-looking reorder of the AddApplicationDecorators() lines (or a module scan that runs after it) silently changes runtime - behavior with no compile error (class doc, DecoratorPipelineOrderTestsBase.cs:15-18). Rather than + behavior with no compile error (class doc, + MMCA.Common/Source/Hosting/MMCA.Common.Testing/DecoratorPipelineOrderTestsBase.cs:16-19). Rather than inspecting the registration list, this base resolves the service and walks the real chain by reflection - (:27-30). [Rubric §6, CQRS & Event-Driven] assesses whether the command/query pipeline is coherent + (:29-32). [Rubric §6, CQRS & Event-Driven] assesses whether the command/query pipeline is coherent and intentional; [Rubric §2, Design Patterns] assesses correct application of the decorator pattern; [Rubric §14, Testability] covers turning an ordering convention into an executable check; and [Rubric §34, Architecture Governance & Documentation] covers the fact that a decision record is - enforced here rather than merely written down. It is also the one non-HTTP member of the + enforced here rather than merely written down + (ADR-015 is the general + case). It is also the one non-HTTP member of the ADR-058 conformance tier.
    • Walkthrough
        -
      • Four type parameters (DecoratorPipelineOrderTestsBase.cs:32-35): a representative command with its - TResult and a representative query with its TResult, each of which must have a concrete - registered handler.
      • -
      • ConfigureServices(IServiceCollection services) (DecoratorPipelineOrderTestsBase.cs:44): the one +
      • Four type parameters + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/DecoratorPipelineOrderTestsBase.cs:34-37): a + representative command with its TResult and a representative query with its TResult, each of which + must have a concrete registered handler.
      • +
      • ConfigureServices(IServiceCollection services) + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/DecoratorPipelineOrderTestsBase.cs:46): the one abstract member. The subclass registers test doubles for the decorator dependencies (IFeatureManager, + ICurrentUserService, + IPermissionRegistry, ICorrelationContext, ICacheService, IUnitOfWork, ILogger<>) and then runs the repo's - real registration sequence, module scans first and AddApplicationDecorators() last (doc :19-26).
      • -
      • ExpectedCommandDecorators (:47-54) pins, outermost first, + real registration sequence, module scans first and AddApplicationDecorators() last (doc :20-28).
      • +
      • ExpectedCommandDecorators (:49-58) pins seven links, outermost first: FeatureGateCommandDecorator<TCommand, TResult>, + AuthorizationCommandDecorator<TCommand, TResult>, LoggingCommandDecorator<TCommand, TResult>, CachingCommandDecorator<TCommand, TResult>, ValidatingCommandDecorator<TCommand, TResult>, + TimeoutCommandDecorator<TCommand, TResult>, TransactionalCommandDecorator<TCommand, TResult>. - ExpectedQueryDecorators (:57-62) pins FeatureGate, Logging, Caching, the query pipeline having - neither validation nor a transaction. Both are virtual, so a host with a deliberately different - chain can narrow them.
      • -
      • The two [Fact]s, CommandPipeline_NestsDecorators_InAdr014Order (:64-66) and - QueryPipeline_NestsDecorators_InAdr014Order (:68-70), each hand the closed handler interface and + ExpectedQueryDecorators (:61-68) pins five: + FeatureGateQueryDecorator<TQuery, TResult>, + AuthorizationQueryDecorator<TQuery, TResult>, + LoggingQueryDecorator<TQuery, TResult>, + CachingQueryDecorator<TQuery, TResult>, + TimeoutQueryDecorator<TQuery, TResult>, + the query pipeline having neither validation nor a transaction. Both lists are virtual, so a host + with a deliberately different chain can narrow them.
      • +
      • The two [Fact]s, CommandPipeline_NestsDecorators_InAdr014Order (:70-72) and + QueryPipeline_NestsDecorators_InAdr014Order (:74-76), each hand the closed handler interface and the expected list to AssertPipeline.
      • -
      • AssertPipeline (:72-91): builds the collection, builds a provider, opens a scope (handlers are - scoped, :77-78), resolves the outermost handler and asserts it is non-null with a message that - tells the subclass author what is missing (:80-82). It then unwraps the chain, maps each link to a +
      • AssertPipeline (:78-97): builds the collection, builds a provider, opens a scope (handlers are + scoped, :83-84), resolves the outermost handler and asserts it is non-null with a message that + tells the subclass author what is missing (:86-88). It then unwraps the chain, maps each link to a simple type name, and asserts every element except the last equals the expected decorator list in - order (:84-87), finally asserting the innermost element does not end in Decorator, that is, - it is the concrete handler (:89-90).
      • -
      • UnwrapChain (:98-118): walks outermost to innermost by reflecting over each object's instance + order (:90-93), finally asserting the innermost element does not end in Decorator, that is, + it is the concrete handler (:95-96).
      • +
      • UnwrapChain (:104-124): walks outermost to innermost by reflecting over each object's instance fields (public and non-public) and picking the first value that implements the same closed handler - interface and is not the object itself (:105-108), which is how it finds the compiler-generated - backing field holding the inner handler. SimpleTypeName (:120-125) strips the generic-arity + interface and is not the object itself (:111-114), which is how it finds the compiler-generated + backing field holding the inner handler. SimpleTypeName (:126-131) strips the generic-arity backtick suffix so a two-arity LoggingCommandDecorator compares as the plain name.
    • @@ -1951,12 +2366,15 @@

      GracefulShutdownTestsBase<TEntr then ApplicationStopped inside the timeout.
    • Depends on: ProductionHostApplicationFactory<TEntryPoint> - (GracefulShutdownTestsBase.cs:31), plus Microsoft.Extensions.Hosting's IHost / - IHostApplicationLifetime, Microsoft.Extensions.DependencyInjection, AwesomeAssertions, and Xunit - (:1-4).
    • + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/GracefulShutdownTestsBase.cs:31), plus + Microsoft.Extensions.Hosting's IHost / IHostApplicationLifetime, + Microsoft.Extensions.DependencyInjection, AwesomeAssertions, and Xunit (:1-4).
    • Concept introduced, the bounded-stop drain check. [Rubric §29, Resilience & Business Continuity] - (named in the class doc itself, GracefulShutdownTestsBase.cs:9) assesses whether the system survives - planned and unplanned interruption; a rolling deploy is the planned one. The failure this catches is a - hosted service (a warm-up runner, service discovery, proxy infrastructure) that refuses to drain, which - in production does not announce itself: it silently wedges a rolling deploy while the platform waits out - its termination grace period (:13-17). [Rubric §13, Observability & Operability] is the operational - half, lifetime events firing in order are what a platform's shutdown handling depends on. The - recovery-objective framing is + (named in the class doc itself, + MMCA.Common/Source/Hosting/MMCA.Common.Testing/GracefulShutdownTestsBase.cs:9) assesses whether the + system survives planned and unplanned interruption; a rolling deploy is the planned one. The failure this + catches is a hosted service (a warm-up runner, service discovery, proxy infrastructure) that refuses to + drain, which in production does not announce itself: it silently wedges a rolling deploy while the + platform waits out its termination grace period (:13-17). [Rubric §13, Observability & Operability] + is the operational half, lifetime events firing in order are what a platform's shutdown handling depends + on. The recovery-objective framing is ADR-009; the base itself is one of the suites recorded in ADR-058.
    • Walkthrough
        -
      • ShutdownTimeoutSeconds (GracefulShutdownTestsBase.cs:28): virtual, defaults to 20 seconds. - This number is the test: a host that drains slower than this fails.
      • +
      • ShutdownTimeoutSeconds + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/GracefulShutdownTestsBase.cs:28): virtual, defaults + to 20 seconds. This number is the test: a host that drains slower than this fails.
      • CreateFactory() (:31): virtual, returns a plain ProductionHostApplicationFactory<TEntryPoint>. The doc says to override it only when the host needs a fixture beyond a Production-pinned boot (:18-21).
      • @@ -2024,13 +2444,14 @@

        IntegrationTestBase<TFixture>

        client and lifecycle, typed request helpers, bearer-token management, and a thread-safe id counter, so a concrete test class is left with just its arrange/act/assert.
      • Depends on: IIntegrationTestFixture (the TFixture constraint, - IntegrationTestBase.cs:14), plus Xunit's IAsyncLifetime, System.Net.Http.Headers, and - System.Net.Http.Json (:1-3).
      • + MMCA.Common/Source/Hosting/MMCA.Common.Testing/IntegrationTestBase.cs:14), plus Xunit's + IAsyncLifetime, System.Net.Http.Headers, and System.Net.Http.Json (:1-3).
      • Concept introduced, the xUnit async test lifecycle and per-test isolation. [Rubric §14, Testability]: the base implements IAsyncLifetime so InitializeAsync runs before each test and DisposeAsync after, and it hangs the database reset off that hook so every test starts from a clean database, the single most important property for reliable integration tests.
      • Walkthrough
          -
        • Fields and properties: a static int _nextId = 1000 seed (IntegrationTestBase.cs:16), and the +
        • Fields and properties: a static int _nextId = 1000 seed + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/IntegrationTestBase.cs:16), and the Fixture / Client protected properties (:19-22).
        • Constructor (:24-28): stores the injected fixture and eagerly creates the HttpClient from it.
        • InitializeAsync (:31): a ValueTask that awaits Fixture.ResetDatabaseAsync() before each test. @@ -2066,9 +2487,9 @@

          SqlServerIntegrationTest Respawn, and drops the database on disposal. It is the concrete engine behind IIntegrationTestFixture for SQL Server hosts.

        • Depends on: IIntegrationTestFixture (implemented, - SqlServerIntegrationTestFixtureBase.cs:27), plus Microsoft.AspNetCore.Mvc.Testing - (WebApplicationFactory), Microsoft.Data.SqlClient, Respawn, and Xunit's IAsyncLifetime - (:1-4).
        • + MMCA.Common/Source/Hosting/MMCA.Common.Testing/SqlServerIntegrationTestFixtureBase.cs:27), plus + Microsoft.AspNetCore.Mvc.Testing (WebApplicationFactory), Microsoft.Data.SqlClient, Respawn, and + Xunit's IAsyncLifetime (:1-4).
        • Concept introduced, the disposable-database integration fixture and environment-variable overrides. [Rubric §14, Testability] and [Rubric §8, Data Architecture]: real integration coverage needs a real relational database, and this fixture makes that cheap and hermetic, a fresh GUID-named database @@ -2077,11 +2498,11 @@

          SqlServerIntegrationTest (ADR-006) is why the class doc stresses the DataSources collapse onto a single overridden connection string (:16-24).

        • Walkthrough
            -
          • State (SqlServerIntegrationTestFixtureBase.cs:30-45): the recorded original-environment map, the - server-base and database-name strings, the WebApplicationFactory, the Respawner, a - _databaseCreated flag, and the public Client / ConnectionString. ConnectionString (:45) is - exposed so SQL-fidelity tests can read raw tables (for example to assert an integration event landed - in the outbox).
          • +
          • State (MMCA.Common/Source/Hosting/MMCA.Common.Testing/SqlServerIntegrationTestFixtureBase.cs:30-45): + the recorded original-environment map, the server-base and database-name strings, the + WebApplicationFactory, the Respawner, a _databaseCreated flag, and the public Client / + ConnectionString. ConnectionString (:45) is exposed so SQL-fidelity tests can read raw tables + (for example to assert an integration event landed in the outbox).
          • Services (:52): the booted host's root service provider, exposed so cross-service tests can resolve a consumer-side integration-event handler or a repository and drive the flow directly against the real database.
          • @@ -2139,14 +2560,15 @@

            OpenApiContractTestsBase<TFixture&g resources, so an accidental controller or route removal fails CI instead of silently changing the published contract.
          • Depends on: IntegrationTestBase<TFixture> (inherited, - OpenApiContractTestsBase.cs:21), System.Net, System.Text.Json, AwesomeAssertions, and Xunit - (:1-4).
          • + MMCA.Common/Source/Hosting/MMCA.Common.Testing/OpenApiContractTestsBase.cs:21), System.Net, + System.Text.Json, AwesomeAssertions, and Xunit (:1-4).
          • Concept introduced, the contract guard on the live document. [Rubric §9, API & Contract Design] assesses whether the API surface is described and kept stable; the pattern across all three Level 2 - bases is a live-document guard with no committed snapshot (OpenApiContractTestsBase.cs:14-16), - the assertions run against the document the host actually serves, so new controllers can never leave a - stale snapshot behind and a removed one is caught immediately. This is one of the suites recorded - in ADR-058, and + bases is a live-document guard with no committed snapshot + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/OpenApiContractTestsBase.cs:14-16), the assertions run + against the document the host actually serves, so new controllers can never leave a stale snapshot behind + and a removed one is caught immediately. This is one of the suites recorded in + ADR-058, and the one with the widest adoption.
          • Walkthrough
            • Overridable and abstract knobs: OpenApiDocumentPath (:30, defaults to /openapi/v1.json), @@ -2187,10 +2609,12 @@

              ProblemDetailsContractTestsBase Details documents, machine-readable bodies carrying status, title, and a diagnostic extension, across both error-shaping paths the framework uses.

            • Depends on: IntegrationTestBase<TFixture> (inherited, - ProblemDetailsContractTestsBase.cs:21), System.Net, System.Net.Http.Json, System.Text.Json, - AwesomeAssertions, and Xunit (:1-5). Same live-guard shape as the OpenAPI base above.
            • + MMCA.Common/Source/Hosting/MMCA.Common.Testing/ProblemDetailsContractTestsBase.cs:21), System.Net, + System.Net.Http.Json, System.Text.Json, AwesomeAssertions, and Xunit (:1-5). Same live-guard + shape as the OpenAPI base above.
            • Concept: still [Rubric §9, API & Contract Design], here the pinned contract is the error - shape. The class covers the two distinct paths that produce errors (class doc, :10-18): ASP.NET + shape. The class covers the two distinct paths that produce errors (class doc, + MMCA.Common/Source/Hosting/MMCA.Common.Testing/ProblemDetailsContractTestsBase.cs:10-18): ASP.NET Core model validation (a 400 application/problem+json body) and the framework's HandleFailure Result-error mapping (see ApiControllerBase), which turns a @@ -2198,7 +2622,8 @@

              ProblemDetailsContractTestsBase Error not-found into a 404 problem (ADR-013 defines that edge contract).

            • Walkthrough
                -
              • Validation_400_HasProblemDetailsShape (ProblemDetailsContractTestsBase.cs:29-39): sends the +
              • Validation_400_HasProblemDetailsShape + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/ProblemDetailsContractTestsBase.cs:29-39): sends the subclass's validation probe, asserts the shared shape at 400, then checks the problem+json content type and the model-validation-only extensions type, traceId, and errors (:35-38).
              • NotFound_404_HasProblemDetailsShape (:41-47): sends the 404 probe and asserts the shared shape.
              • @@ -2215,14 +2640,18 @@

                ProblemDetailsContractTestsBase means a regression in either error channel breaks CI, and factoring the shape assertion into a shared static keeps every host's error contract identical while still letting a host with a reachable 409-conflict path layer its own test on top (:16-18). -
              • Where it's used: subclassed per host, three in ADC +
              • Where it's used: subclassed per host, four in ADC (MMCA.ADC/Tests/Integration/MMCA.ADC.Conference.IntegrationTests/Contract/ProblemDetailsContractTests.cs:20, .../MMCA.ADC.Engagement.IntegrationTests/Contract/ProblemDetailsContractTests.cs:17, - .../MMCA.ADC.Identity.IntegrationTests/Contract/ProblemDetailsContractTests.cs:17) and three in Store + .../MMCA.ADC.Identity.IntegrationTests/Contract/ProblemDetailsContractTests.cs:17, + .../MMCA.ADC.Notification.IntegrationTests/Contract/ProblemDetailsContractTests.cs:16) and three in + Store (MMCA.Store/Tests/Integration/MMCA.Store.Catalog.IntegrationTests/Contract/ProblemDetailsContractTests.cs:20, .../MMCA.Store.Identity.IntegrationTests/Contract/ProblemDetailsContractTests.cs:16, .../MMCA.Store.Sales.IntegrationTests/Contract/ProblemDetailsContractTests.cs:16). ADC Conference is - the one that adds a 409 stale-RowVersion conflict test on top of the inherited facts.
              • + the one that adds a 409 stale-RowVersion conflict test on top of the inherited facts + (.../MMCA.ADC.Conference.IntegrationTests/Contract/ProblemDetailsContractTests.cs:40-67, reusing + AssertProblemDetailsShapeAsync at :67).

              ServiceInfoVersioningContractTestsBase<TFixture>

              @@ -2234,8 +2663,8 @@

              ServiceInfoVersioningCon selected by the api-version header, and that the host reports supported and deprecated versions in response headers.

            • Depends on: IntegrationTestBase<TFixture> (inherited, - ServiceInfoVersioningContractTestsBase.cs:19), System.Net, System.Text.Json, - AwesomeAssertions, and Xunit (:1-4).
            • + MMCA.Common/Source/Hosting/MMCA.Common.Testing/ServiceInfoVersioningContractTestsBase.cs:19), + System.Net, System.Text.Json, AwesomeAssertions, and Xunit (:1-4).
            • Concept: [Rubric §9, API & Contract Design] again, the versioning axis (ADR-046). The class doc (:8-17) makes the point that without a second working version the whole versioning story would be @@ -2245,9 +2674,10 @@

              ServiceInfoVersioningCon test body is identical across repos; a subclass supplies only its fixture.

            • Walkthrough
              • ServiceInfo_V1_ReturnsMinimalShape_AndIsReportedDeprecated - (ServiceInfoVersioningContractTestsBase.cs:27-41): requests v1.0, asserts 200, checks - apiVersion == "1.0" and that the evolved supportedVersions list is absent in the v1 shape - (:35-36), then asserts an api-deprecated-versions response header contains 1.0 (:38-40).
              • + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/ServiceInfoVersioningContractTestsBase.cs:27-41): + requests v1.0, asserts 200, checks apiVersion == "1.0" and that the evolved supportedVersions list + is absent in the v1 shape (:35-36), then asserts an api-deprecated-versions response header + contains 1.0 (:38-40).
              • ServiceInfo_V2_ReturnsEvolvedShape_AndIsReportedSupported (:43-57): requests v2.0, asserts 200, checks apiVersion == "2.0" and that supportedVersions contains 2.0 (:50-52), then asserts an api-supported-versions header advertises 2.0 (:54-56).
              • @@ -2265,9 +2695,81 @@

                ServiceInfoVersioningCon
              • Caveats / not-in-source: adoption is per-repo partial, one host each in ADC and Store, not every extracted REST service.
              +

              MiddlewarePipelineOrderTestsBase

              +
              +

              MMCA.Common.Testing · MMCA.Common.Testing · MMCA.Common/Source/Hosting/MMCA.Common.Testing/MiddlewarePipelineOrderTestsBase.cs:29 · Level 11 · class (abstract)

              +
              +
                +
              • What it is: the HTTP-edge counterpart of + DecoratorPipelineOrderTestsBase<TCommand, TCommandResult, TQuery, TQueryResult>. + It seeds the framework's default middleware step list, applies the host's own customization if it has + one, and asserts the resulting step order is exactly the documented pipeline, plus that the + startup-validated adjacency invariants still hold.
              • +
              • Depends on: + MiddlewarePipelineBuilder and + MiddlewarePipelineStepNames from + MMCA.Common.API.Startup (MMCA.Common/Source/Hosting/MMCA.Common.Testing/MiddlewarePipelineOrderTestsBase.cs:2), + plus AwesomeAssertions and Xunit (:1,3). This is the reference that makes + MMCA.Common.Testing depend on MMCA.Common.API + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/MMCA.Common.Testing.csproj:44).
              • +
              • Concept introduced, order-as-data at the HTTP edge. In ASP.NET Core middleware order is behavior, + not style, and the failures it produces do not look like ordering bugs. The class doc names three + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/MiddlewarePipelineOrderTestsBase.cs:13-18): an + unreachable jwks_uri when the pre-forwarded capture drifts away from UseForwardedHeaders, a tenant + that never resolves when tenant resolution runs before authentication, and a per-user rate cap that + never engages when the limiter runs before authentication. What makes the check cheap is that + ADR-079 turned the + order into data: MiddlewarePipelineBuilder.CreateDefault() produces a list of named steps that are + inert until applied, so no WebApplication has to be built and the test runs in the fast unit tier with + no database and no host (:24-27). [Rubric §10, Cross-Cutting] assesses whether cross-cutting edge + concerns are composed deliberately; [Rubric §11, Security] is why authentication before the rate + limiter is load-bearing (ADR-019); + [Rubric §14, Testability] and + [Rubric §34, Architecture Governance & Documentation] cover the fitness-function form itself + (ADR-015).
              • +
              • Walkthrough
                  +
                • Configure (MMCA.Common/Source/Hosting/MMCA.Common.Testing/MiddlewarePipelineOrderTestsBase.cs:35): + virtual, defaults to null, meaning the host under test calls the zero-argument + UseCommonMiddlewarePipeline() overload. A host that customizes the pipeline overrides this with the + same Action<MiddlewarePipelineBuilder> its Program.cs passes (:20-23).
                • +
                • ExpectedStepNames (:38-58): the pinned order, outermost first, all eighteen steps named through + MiddlewarePipelineStepNames constants rather than string literals: ExceptionHandler, CorrelationId, + RequestLocalization, PreForwardedCapture, ForwardedHeaders, HttpsRedirection, ResponseCompression, + Routing, Cors, Authentication, TenantResolution, RateLimiting, SoftDeletedUserFilter, Authorization, + OutputCache, JwksEndpoint, OidcDiscoveryEndpoint, Controllers. It is virtual, so a host with a + deliberately different pipeline states its own order.
                • +
                • EdgePipeline_OrdersSteps_InDocumentedOrder (:60-67): the first [Fact]. It builds the seeded + builder and asserts builder.StepNames equals ExpectedStepNames, with a because-reason (:66) that + spells out the three adjacencies rather than just reporting a list mismatch.
                • +
                • EdgePipeline_SatisfiesLoadBearingInvariants (:69-77): the second [Fact]. It asserts + builder.Build() does not throw. Build() + (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/MiddlewarePipelineBuilder.cs:257-280) + re-checks four invariants at startup, PreForwardedCapture immediately before ForwardedHeaders + (:259-262), Authentication immediately before TenantResolution (:264-267), Authentication before + RateLimiting (:269-272), and ForwardedHeaders before HttpsRedirection (:274-277), so a pipeline + that fails here would have thrown while the host was starting.
                • +
                • CreateBuilder (MMCA.Common/Source/Hosting/MMCA.Common.Testing/MiddlewarePipelineOrderTestsBase.cs:79-84): + the two-line private helper both facts share, MiddlewarePipelineBuilder.CreateDefault() followed by + Configure?.Invoke(builder).
                • +
                +
              • +
              • Why it's built this way: the two facts are complementary rather than redundant. The first pins the + exact order, so any reorder (including one that still satisfies every adjacency) fails visibly; the + second re-runs the host's own startup validation in the unit tier, so an override that breaks an + adjacency fails in a test rather than at boot. Naming steps through MiddlewarePipelineStepNames + constants means a step rename is a compile error in the test rather than a silent string mismatch.
              • +
              • Where it's used: four subclasses, every one of them body-less because every host calls the + zero-argument overload, + MMCA.Common/Tests/Hosting/MMCA.Common.Testing.Tests/MiddlewarePipelineOrderTests.cs:10, + MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/MiddlewarePipelineOrderTests.cs:15, + MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/MiddlewarePipelineOrderTests.cs:15, and + MMCA.Helpdesk/Tests/Architecture/MMCA.Helpdesk.Architecture.Tests/MiddlewarePipelineOrderTests.cs:15. + The framework's own UseCommonMiddlewarePipeline doc points back at this base as the way to freeze the + order (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:42).
              • +

              HandlerTestBase<THandler>

              -

              MMCA.Common.Testing · MMCA.Common.Testing · MMCA.Common/Source/Hosting/MMCA.Common.Testing/HandlerTestBase.cs:38 · Level 9 · class (abstract)

              +

              MMCA.Common.Testing · MMCA.Common.Testing · MMCA.Common/Source/Hosting/MMCA.Common.Testing/HandlerTestBase.cs:38 · Level 14 · class (abstract)

              • What it is: the reusable Moq scaffold for command/query handler unit tests. It hands a derived @@ -2280,20 +2782,23 @@

                HandlerTestBase<THandler>

                and AuditableBaseEntity<TIdentifierType> (the two generic constraints), plus Moq, Microsoft.Extensions.Logging, and NullLogger<T> - (HandlerTestBase.cs:1-5).
              • + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/HandlerTestBase.cs:1-5).
              • Concept: the arrange-phase base class. Where IntegrationTestBase<TFixture> gives an end-to-end test a booted host, this gives an isolated unit test a mocked persistence boundary: no database, no host, no HTTP. The - class doc (HandlerTestBase.cs:10-12) frames it as the shared replacement for the per-test copy-paste - of Mock<IUnitOfWork> plus GetRepository wiring plus SaveChangesAsync setup. [Rubric §14, Testability] assesses whether the design permits fast isolated tests; the fact that handlers depend on - IUnitOfWork (an Application-layer abstraction) rather than a DbContext is what makes this scaffold - possible at all, which is [Rubric §3, Clean Architecture] paying off in the test tier + class doc (MMCA.Common/Source/Hosting/MMCA.Common.Testing/HandlerTestBase.cs:10-12) frames it as the + shared replacement for the per-test copy-paste of Mock<IUnitOfWork> plus GetRepository wiring plus + SaveChangesAsync setup. [Rubric §14, Testability] assesses whether the design permits fast isolated + tests; the fact that handlers depend on IUnitOfWork (an Application-layer abstraction) rather than a + DbContext is what makes this scaffold possible at all, which is [Rubric §3, Clean Architecture] + paying off in the test tier (ADR-055 records that contract). [Rubric §16, Maintainability] covers the deduplication itself.
              • Walkthrough
                  -
                • Constructor (HandlerTestBase.cs:41-42): a single expression-bodied statement that pre-configures - UnitOfWork.SaveChangesAsync(...) to return 1, the success path, so a happy-path test writes no - persistence setup at all. Failure-path tests override it with their own Setup (doc :32-35).
                • +
                • Constructor (MMCA.Common/Source/Hosting/MMCA.Common.Testing/HandlerTestBase.cs:41-42): a single + expression-bodied statement that pre-configures UnitOfWork.SaveChangesAsync(...) to return 1, the + success path, so a happy-path test writes no persistence setup at all. Failure-path tests override it + with their own Setup (doc :32-35).
                • UnitOfWork (:45): the Mock<IUnitOfWork> every registered repository is wired into, created by a property initializer so the constructor can configure it.
                • Logger (:48): NullLogger<THandler>.Instance, typed by the handler type parameter so it binds @@ -2312,14 +2817,37 @@

                  HandlerTestBase<THandler>

                  may read through GetReadRepository and write through GetRepository on the same aggregate; a test forced to register two mocks would have to keep their state in sync. Pre-succeeding SaveChangesAsync encodes the common case so only the interesting deviation appears in a test.
                • -
                • Where it's used: the base of handler unit-test classes across the framework and the downstream - application modules, 87 classes today, including the framework's own scaffold test - (MMCA.Common/Tests/Hosting/MMCA.Common.Testing.Tests/HandlerTestBaseTests.cs:12) and dozens of ADC - Application-tier classes (for example +
                • Where it's used: 116 test classes today, the framework's own scaffold test + (MMCA.Common/Tests/Hosting/MMCA.Common.Testing.Tests/HandlerTestBaseTests.cs:12) plus 115 ADC + Application-tier classes spread across all four modules (for example MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Categories/UseCases/CreateConferenceCategoryHandlerTests.cs:13, MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.Application.Tests/LivePolls/UseCases/CastVoteHandlerTests.cs:13, - MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/ChangePasswordHandlerTests.cs:12). - The class doc carries a worked CreateEventHandlerTests example (HandlerTestBase.cs:19-31).
                • + MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/UseCases/ChangePasswordHandlerTests.cs:12, + MMCA.ADC/Tests/Modules/Notification/MMCA.ADC.Notification.Application.Tests/UserNotificationExportServiceTests.cs). + The class doc carries a worked CreateEventHandlerTests example + (MMCA.Common/Source/Hosting/MMCA.Common.Testing/HandlerTestBase.cs:19-31). +
                • Caveats / not-in-source: adoption is uneven. MMCA.Store and MMCA.Helpdesk handler tests do not + subclass it at all: a workspace-wide search finds no HandlerTestBase reference under either repo's + Tests/ tree. Why those two arrange their handler tests by hand is not recorded in source.
                • +
                +

                AnonymousEndpointTestsBase

                +
                +

                MMCA.Common.Testing.Architecture · MMCA.Common.Testing.Architecture · MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/AnonymousEndpointTestsBase.cs:30 · Level 0 · abstract class

                +
                +
                  +
                • What it is: an allow-list gate for authorization opt-outs. Every [AllowAnonymous] in the assemblies a subclass names must appear in an explicit, reviewed list, so an endpoint cannot quietly lose its authorization gate.
                • +
                • Depends on: [Fact] (xUnit), AwesomeAssertions, RuleHelpers.LoadableTypes, and reflection over attribute instances matched by full name: AllowAnonymousAttribute, ControllerBase, and Blazor's RouteAttribute are three private full-name constants (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/AnonymousEndpointTestsBase.cs:32-34), not compile-time references.
                • +
                • Concept introduced, the two-directional allow-list gate. A one-directional "no anonymous endpoints" rule is unusable (login has to be anonymous) and a one-directional "these are allowed" rule rots. This base asserts both halves: no unlisted [AllowAnonymous] exists, and no list entry matches nothing. The second half is the subtle one, since a stale entry hides a renamed or re-gated endpoint behind a permission that is no longer being granted (line 88). [Rubric §11, Security] assesses whether authentication gates stay where they were put; [Rubric §14, Testability] assesses turning a review-only property into an executable one; [Rubric §34, Architecture Governance] applies because the exception list, with its per-entry justification comments, becomes the reviewed record of the anonymous surface.
                • +
                • Walkthrough
                    +
                  • The subclass supplies TargetAssemblies (line 37), AllowedAnonymousEndpoints (line 44, identified as the type's FullName for a type-level attribute and FullName.MethodName for a method-level one, lines 40-42), and a MinimumScannedTypes floor (line 51, default 1).
                  • +
                  • Three [Fact]s. AnonymousEndpoints_AreAllowListed (line 54) subtracts the allow-list from the discovered set and names what is left. ScannedEndpointSet_IsNotEmpty (line 66) is the non-vacuity guard: with no controllers and no routable components discovered, the first assertion passes without having looked at anything (comment, lines 68-69). AllowList_HasNoStaleEntries (line 79) runs the comparison the other way.
                  • +
                  • Two shapes are scanned, both by reflection: IsController (line 101) walks the base chain for ControllerBase, and IsRoutableComponent (line 117) looks for RouteAttribute on the type, combined in IsScannedEndpointType (line 95).
                  • +
                  • AnonymousEndpoints() (line 125) is protected rather than private so a subclass can build a richer report over the same data (doc, lines 120-123); it flattens the assemblies, filters to the scanned shapes, and returns a distinct, ordinally-ordered set.
                  • +
                  • AnonymousEndpointsOf (line 133) is the load-bearing detail: type-level attributes are read with inherit: false (line 135) and methods are enumerated DeclaredOnly (line 142), so a framework base action is reported once at its declaration site instead of once per derived controller in every consumer repo (comment, lines 140-141).
                  • +
                  +
                • +
                • Why it's built this way: the class doc is explicit about the limit (lines 18-24). Minimal-API endpoints opt out through the .AllowAnonymous() builder call, which produces endpoint metadata at map time and is invisible to static reflection, so the framework's own minimal-API anonymous surface (JWKS, OIDC discovery, app-association, session-cookie refresh, health) is outside this gate; catching it would need an endpoint-metadata check over a built host. Matching ASP.NET types by full name keeps the package free of an ASP.NET reference, the same stance the whole rule library takes (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/MMCA.Common.Testing.Architecture.csproj:22-29).
                • +
                • Where it's used: subclassed in all four repos, and the DeclaredOnly decision is what keeps each list local to what that repo declares. MMCA.Common's AnonymousEndpointTests lists six credential-exchange actions on AuthControllerBase, OAuthControllerBase and the generic PasswordResetAuthControllerBase (whose two entries carry the reflected generic-arity suffix, a detail the file comments call out at :35-36), with a floor of 21 (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTests.cs:14, list at :22-39, floor at :43). ADC's lists the two Identity credential actions plus the public conference-browse reads, calendar exports and bookmark counts, with a floor of 79 (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/AnonymousEndpointTests.cs:21, floor at :108). Store's covers the public storefront reads, product images, registration and the Stripe webhook, with a floor of 32 (MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/AnonymousEndpointTests.cs:10, floor at :55). Helpdesk's single entry is its whole TicketsController, because the seed ships without an Identity issuer so there is no token to require (MMCA.Helpdesk/Tests/Architecture/MMCA.Helpdesk.Architecture.Tests/AnonymousEndpointTests.cs:17, entry and reason at :26-30). MMCA.Common also carries the adversarial coverage for the base itself in AnonymousEndpointTestsBaseTests (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/AnonymousEndpointTestsBaseTests.cs:13), which proves each assertion fails on its own drift through private DriftedTests, StaleAllowListTests and EmptyScanTests subclasses (:100, :108, :117) and pins the inheritance behavior with a fixture base controller whose anonymous action must NOT be re-reported on the derived controller (:61-71).

                ArchitectureAssert

                @@ -2352,7 +2880,7 @@

                BrandColorTokenTestsBase

              • Resources are resolved from GetType().Assembly (line 33), that is, the subclass's assembly, which is what lets a package-shipped base read a consumer's stylesheet.
            • -
            • Why it's built this way: the doc (lines 3-12) explains the split. MMCA.Common's own BrandColorTokenTests guards the C#-to-CSS token definition (from BrandColors.Primary), while this base guards every downstream consumer of it, embedding the stylesheets as manifest resources so the package needs no file-system access into the consumer repo.
            • +
            • Why it's built this way: the doc (lines 3-12) explains the split. MMCA.Common's own BrandColorTokenTests guards the C#-to-CSS token definition (from BrandColors.Primary, MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Theme/BrandColorTokenTests.cs:14), while this base guards every downstream consumer of it, embedding the stylesheets as manifest resources so the package needs no file-system access into the consumer repo.
            • Where it's used: subclassed once per repo that ships a branded landing page, as BrandColorTokenTests in ADC (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/BrandColorTokenTests.cs:12) and Store (MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/BrandColorTokenTests.cs:10).

            CrossEntityNavigationFinder

            @@ -2382,7 +2910,7 @@

            Layer

          • Depends on: nothing; a plain enum.
          • Concept introduced: the Clean Architecture layer taxonomy made into a type. The layer flow itself is taught in primer §1; here it becomes an enum the rule library keys off, so a rule that iterates layers is written once against the enum rather than hard-coded per repo. [Rubric §3, Clean Architecture] assesses whether the layering is explicit and enforced; this enum is the shared alphabet.
          • Walkthrough: the doc (lines 3-8) notes that Ui, Grpc, Contracts, and ServiceHost are optional: a repo simply omits them from its map when absent, so a rule iterating them is vacuously satisfied with no compile dependency on the missing assembly. ArchitectureMapBase.Segment translates each member to its namespace segment, and two of those translations are not the identity mapping: Api becomes "API" and ServiceHost becomes "Service" (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureMapBase.cs:105-117).
          • -
          • Where it's used: carried by LayerRef, projected by IArchitectureMap.OfLayer, and threaded through nearly every method in ArchitectureRules.
          • +
          • Where it's used: carried by LayerRef, projected by IArchitectureMap.OfLayer, and threaded through nearly every method in ArchitectureRules. Contracts is the one member no repo registers today, which is exactly why ServiceContractPurityTestsBase is attribute-driven rather than layer-driven.

          ModuleConformanceTestsBase<TModule>

          @@ -2441,7 +2969,7 @@

          RouteAuthorizationTestsBase

          • What it is: an abstract test base that reflects over a UI assembly's routable Blazor pages and fails the build if a page the subclass marks as governed has lost its [Authorize(Roles = "...")] role gate.
          • Depends on: [Fact] (xUnit), AwesomeAssertions, RuleHelpers.LoadableTypes, and pure reflection over attribute instances matched by full name (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/RouteAuthorizationTestsBase.cs:24-25).
          • -
          • Concept introduced, the security-regression fitness function. [Rubric §11, Security] and [Rubric §25, Navigation & IA] assess whether protected routes stay protected; this base turns "the admin page must require the Organizer role" from a review checklist into a compiled assertion, so a page cannot silently regress from [Authorize(Roles=...)] to a bare [Authorize] reachable by any authenticated user.
          • +
          • Concept introduced, the security-regression fitness function. [Rubric §11, Security] and [Rubric §25, Navigation & IA] assess whether protected routes stay protected; this base turns "the admin page must require the Organizer role" from a review checklist into a compiled assertion, so a page cannot silently regress from [Authorize(Roles=...)] to a bare [Authorize] reachable by any authenticated user. It is the page-level counterpart to AnonymousEndpointTestsBase, which guards the opt-out side of the same question.
          • Walkthrough
            • The subclass supplies TargetAssembly (line 28), the exact RequiredRole (line 31), an IsGovernedPage strategy (line 40), and a MinimumGovernedPages non-vacuity floor (line 47, default 1).
            • GovernedPages_RequireDeclaredRole (line 50) collects pages that are routable, governed, and do not require the role, then asserts the offender set is empty, naming each offender's route templates (lines 52-60).
            • @@ -2467,7 +2995,7 @@

              RuleHelpers

          • Why it's built this way: every helper avoids a compile-time reference to the type it detects (base types matched by string prefix), which is what lets one rule body run identically across four repos that do not reference each other. The class carries a file-level [SuppressMessage] for CA1708 (lines 10-13): with multiple extension(T) blocks in one static class the analyzer flags the compiler-generated grouping members as case-colliding, a documented false positive.
          • -
          • Where it's used: throughout the ArchitectureRules partials, inside CrossEntityNavigationFinder, and directly by RouteAuthorizationTestsBase.
          • +
          • Where it's used: throughout the ArchitectureRules partials, inside CrossEntityNavigationFinder, and directly by RouteAuthorizationTestsBase and AnonymousEndpointTestsBase.
          • Caveats / not-in-source: the type is internal, so consumer repos cannot call these helpers directly; they reach the same behavior only through the public rules and bases. StateManagementConventionTestsBase is the one base that re-implements the tolerant type load privately rather than using this class.

          LayerRef

          @@ -2479,7 +3007,7 @@

          LayerRef

        • Depends on: Layer and System.Reflection.Assembly.
        • Concept introduced: the atomic unit of an architecture map. Module is the empty string for framework (MMCA.Common) layers that belong to no business module (lines 22-30), which is how the same record models both a module assembly (("Catalog", Application, ...)) and a shared framework assembly (("", Shared, ...)). Every projection and every isolation rule keys off that one convention.
        • Walkthrough: a four-parameter positional sealed record (line 31), so it gets structural equality and immutability for free; its members are set once at construction by the map's DefineLayers.
        • -
        • Where it's used: ArchitectureMapBase stores a lazy IReadOnlyList<LayerRef> and derives every projection from it; its Framework and Module factory helpers are what build these. The namespace-cycle rule takes a LayerRef directly, using its RootNamespace to decide which namespace node a type belongs to (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Cycles.cs:84).
        • +
        • Where it's used: ArchitectureMapBase stores a lazy IReadOnlyList<LayerRef> and derives every projection from it; its Framework and Module factory helpers are what build these. The namespace-cycle rule takes a LayerRef directly, using its RootNamespace to decide which namespace node a type belongs to (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Cycles.cs:84), and the [ServiceContract] purity rule iterates map.Layers directly rather than one projection (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Contracts.cs:40).

        ProtoScope

        @@ -2497,7 +3025,7 @@

        IArchitectureMap

        MMCA.Common.Testing.Architecture · MMCA.Common.Testing.Architecture · MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/IArchitectureMap.cs:39 · Level 2 · interface

          -
        • What it is: the single per-repo abstraction every architecture fitness function keys off. Each repo supplies one implementation declaring its layer and module assemblies; the shared rule library and abstract test bases consume only this interface, so a rule is written once and runs identically across MMCA.Common, MMCA.Store, MMCA.ADC, and MMCA.Helpdesk (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/IArchitectureMap.cs:33-38).
        • +
        • What it is: the single per-repo abstraction every architecture fitness function keys off. Each repo supplies one implementation declaring its layer and module assemblies; the shared rule library and abstract test bases consume only this interface, so a rule is written once and runs identically across every repo that supplies a map (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/IArchitectureMap.cs:33-38, whose doc names MMCA.Common, MMCA.Store and MMCA.ADC; MMCA.Helpdesk supplies a fourth map).
        • Depends on: LayerRef, Layer, System.Reflection.Assembly.
        • Concept introduced, the architecture map as the fitness-function extension point. This is a classic Dependency Inversion: the rules depend on an abstraction (the map), and each repo provides the concrete inventory of its assemblies. [Rubric §1, SOLID] (DIP) and [Rubric §7, Microservices Readiness] apply: the map also models the per-module layers a would-be extracted service owns, so the isolation rules can check module boundaries the same way in any repo.
        • Walkthrough: the interface exposes identity (RepoToken line 42, ModuleNames line 45), the raw Layers inventory (line 48), and the projections the rules lean on: OfLayer (all assemblies of a kind, line 51), the per-module ModuleDomain/ModuleApplication/ModuleShared (lines 54-60), Infrastructure()/Api() across framework plus modules (lines 63-66), the lookups For(module, layer) (line 69) and ModuleOf(assembly) (line 72), namespace derivation RootNamespace(module, layer) (line 75), and OtherModuleNamespaces (line 81), which returns the same-layer namespaces of every other module (the forbidden targets for a module-isolation rule, empty for framework layers and single-module repos).
        • @@ -2542,19 +3070,20 @@

          ArchitectureRules

          MMCA.Common.Testing.Architecture · MMCA.Common.Testing.Architecture · MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.CancellationTokens.cs:5 · Level 4 · static partial class

    • -
    • What it is: the reusable rule library: one large static partial class split across twenty ArchitectureRules.*.cs files, whose methods each assert one architectural invariant across every applicable assembly a map declares. A repo's test classes reduce to a sealed subclass of the matching *TestsBase supplying its own map.
    • -
    • Depends on: IArchitectureMap, Layer, ArchitectureAssert, RuleHelpers, NetArchTest (Types.InAssembly(...)), System.Xml.Linq for the props-file and .resx rules, source-generated System.Text.RegularExpressions for the proto and localized-text parsers, and, for the specification rule, System.Linq.Expressions plus CrossEntityNavigationFinder.
    • -
    • Concept introduced, the rule as a parameterized function. Each method takes an IArchitectureMap and does its own loop, so the *TestsBase classes are thin [Fact] shells that delegate. The partial is organized by concern across the files ArchitectureRules.{CancellationTokens, Controllers, Cycles, Entities, Events, Governance, HandlerResults, Handlers, Idempotency, Immutability, Layers, Localization, LocalizedText, Modules, Naming, Protos, Purity, Slices, Specifications, Transport}.cs. [Rubric §3, Clean Architecture], [Rubric §4, DDD], [Rubric §7, Microservices Readiness], and [Rubric §34, Architecture Governance] all apply: this is where the codebase's structural decisions become executable assertions.
    • -
    • Walkthrough: four representative shapes.
        +
      • What it is: the reusable rule library: one large static partial class split across twenty-two ArchitectureRules.*.cs files, whose methods each assert one architectural invariant across every applicable assembly a map declares. A repo's test classes reduce to a sealed subclass of the matching *TestsBase supplying its own map.
      • +
      • Depends on: IArchitectureMap, Layer, ArchitectureAssert, RuleHelpers, NetArchTest (Types.InAssembly(...), and Mono.Cecil.TypeDefinition for the one custom rule), System.Xml.Linq for the props-file and .resx rules, source-generated System.Text.RegularExpressions for the proto and localized-text parsers, and, for the specification rule, System.Linq.Expressions plus CrossEntityNavigationFinder.
      • +
      • Concept introduced, the rule as a parameterized function. Each method takes an IArchitectureMap and does its own loop, so the *TestsBase classes are thin [Fact] shells that delegate. The partial is organized by concern across the files ArchitectureRules.{CancellationTokens, Contracts, Controllers, Cycles, Entities, Events, Governance, HandlerResults, Handlers, Idempotency, Immutability, Layers, Localization, LocalizedText, Modules, Naming, Protos, Purity, Slices, Specifications, Transport, Upcasters}.cs. [Rubric §3, Clean Architecture], [Rubric §4, DDD], [Rubric §7, Microservices Readiness], and [Rubric §34, Architecture Governance] all apply: this is where the codebase's structural decisions become executable assertions.
      • +
      • Walkthrough: five representative shapes.
        • NetArchTest shape, ControllersDoNotDependOnInfrastructure (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Controllers.cs:6): loops the map's per-module API layer refs, computes the forbidden Infrastructure namespace via map.RootNamespace(...), runs Types.InAssembly(...).That().HaveNameEndingWith("Controller").ShouldNot().HaveDependencyOnAny(forbidden), and reports through ArchitectureAssert.NoViolations(result, ...).
        • Layer-flow shape, ArchitectureRules.Layers.cs: one public method per forbidden edge, DomainDoesNotDependOnApplication (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Layers.cs:12) through UiDoesNotDependOnInfrastructure (line 60), all delegating to the private LayerNotDependOnLayer (line 101), which loops every assembly of the from layer and asserts no dependency on the to layer's namespace. Two non-vacuity rules sit alongside them: LayerMapDeclaresLayers (line 72) and ModulesDeclareLayers (line 89).
        • Reflection shape, ControllersAreSealed (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Controllers.cs:37): enumerates map.Api().ConcreteClasses (the RuleHelpers extension property), filters non-sealed controllers via the private IsController (line 70, which matches on the Controller suffix or an MVC base type), and asserts the string offender list is empty. ControllersInheritApiControllerBase (line 54) is the same shape with a caller-supplied exempt set, accepting either ApiControllerBase or EntityControllerBase<TEntity, TEntityDTO, TIdentifierType> as the base.
        • +
        • Custom-rule shape, ServiceContractsDoNotDependOnServiceInternals (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Contracts.cs:32): the one rule that hands NetArchTest a MeetCustomRule predicate (line 44) reading Cecil metadata directly, because the selector is an attribute, not a name or a namespace.
        • Graph shape, NamespacesHaveNoDependencyCycles (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Cycles.cs:45): builds a namespace-to-namespace graph per layer assembly from signature-level references (BuildNamespaceGraph, line 84), finds every strongly connected component (FindNamespaceCycles, line 253), and reports the shortest cycle path plus any extra members of the component (lines 66-68). This is the largest single rule file in the library.
      • Why it's built this way: ADR-015 records the intent: the rule bodies live once here, and each repo's architecture test project is a set of sealed subclasses supplying its map, so all four repos enforce identical rules. The compile-time MMCA.Common/Source/Build/MMCA.Common.LayerEnforcement.targets guards the same layer flow at build time as a second, faster gate.
      • Where it's used: every *TestsBase in this group calls into it; those [Fact] methods are its public surface. A handful of rules are also called directly from MMCA.Common's own fitness self-tests, for example BuildProtoContract/AssertProtoContract from ProtoContractFitnessTests (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/ProtoContractFitnessTests.cs:14).
      • -
      • Caveats / not-in-source: the full method roster spans twenty partials; only the entry file and representative methods are cited here. The authoritative fitness-method and base-class counts are generated into MMCA.Common/FACTS.md:43-48 and CI-gated, so read them there rather than counting by hand.
      • +
      • Caveats / not-in-source: the full method roster spans twenty-two partials; only the entry file and representative methods are cited here. The authoritative fitness-method and base-class counts are generated into MMCA.Common/FACTS.md:43-48 and CI-gated, so read them there rather than counting by hand.

      DataResidencyTestsBase

      @@ -2729,13 +3258,13 @@

      EntityConventionTestsBase

    EventConventionTestsBase

    -

    MMCA.Common.Testing.Architecture · MMCA.Common.Testing.Architecture · MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/EventConventionTestsBase.cs:8 · Level 5 · abstract class

    +

    MMCA.Common.Testing.Architecture · MMCA.Common.Testing.Architecture · MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/EventConventionTestsBase.cs:9 · Level 5 · abstract class

      -
    • What it is: an integration-event convention base (the doc cites ADR-010): every concrete integration event inherits BaseIntegrationEvent, declares an int SchemaVersion, and lives in a *.IntegrationEvents namespace in the Shared layer.
    • -
    • Depends on: IArchitectureMap, ArchitectureRules (ArchitectureRules.Events.cs).
    • +
    • What it is: an integration-event convention base (the doc cites ADR-010): every concrete integration event inherits BaseIntegrationEvent, declares an int SchemaVersion, and lives in a *.IntegrationEvents namespace in the Shared layer. It also polices the upcasters that carry a retired contract forward.
    • +
    • Depends on: IArchitectureMap, ArchitectureRules (ArchitectureRules.Events.cs and ArchitectureRules.Upcasters.cs).
    • Concept: cross-references the delegating-base shape (AggregateConventionTestsBase). [Rubric §6, CQRS & Event-Driven] and [Rubric §9, API & Contract Design] assess versioned, discoverable cross-service event contracts. It pairs with IntegrationEventContractTestsBase, which freezes the exact shape.
    • -
    • Walkthrough: three [Fact]s: IntegrationEvents_ShouldDeclare_SchemaVersion (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/EventConventionTestsBase.cs:13), IntegrationEvents_ShouldInherit_BaseIntegrationEvent (line 16), IntegrationEvents_ShouldResideIn_SharedIntegrationEventsNamespace (line 19).
    • +
    • Walkthrough: five [Fact]s. The three schema rules come first: IntegrationEvents_ShouldDeclare_SchemaVersion (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/EventConventionTestsBase.cs:14), IntegrationEvents_ShouldInherit_BaseIntegrationEvent (line 17), IntegrationEvents_ShouldResideIn_SharedIntegrationEventsNamespace (line 20). Two upcaster rules follow (ADR-090, doc lines 6-7): EventUpcasters_ShouldHave_UniqueSourceTypes (line 23, delegating to ArchitectureRules.Upcasters.cs:12, because with two IEventUpcaster implementations reading one source contract the message a handler receives would depend on DI registration order) and EventUpcasters_ShouldIncrease_SchemaVersion (line 26, delegating to ArchitectureRules.Upcasters.cs:28, which skips a source or target whose SchemaVersion is missing or non-int, that being the first rule's business, lines 37-42). A repo with no upcasters passes both vacuously (doc, line 7).
    • Where it's used: subclassed in every repo that publishes integration events: Store (MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/EventConventionTests.cs:3), ADC (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/EventConventionTests.cs:3), Helpdesk (MMCA.Helpdesk/Tests/Architecture/MMCA.Helpdesk.Architecture.Tests/ArchitectureTests.cs:38), and MMCA.Common itself under the name EventVersioningConventionTests (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/EventVersioningConventionTests.cs:12).

    HandlerConventionTestsBase

    @@ -2841,7 +3370,7 @@

    MicroserviceExtractionTestsBase

    • What it is: a transport-boundary base for the modular-monolith to microservices path: MassTransit, gRPC, and Protobuf must never leak into Domain, Application, or Shared, so a module behaves identically in-process or extracted and the split stays reversible.
    • Depends on: IArchitectureMap, ArchitectureRules (ArchitectureRules.Transport.cs:19).
    • -
    • Concept: cross-references the delegating-base shape (AggregateConventionTestsBase); the extraction invariant (application and domain code talks to abstractions, transport choices live at the edges) is the ADR-006 / ADR-007 / ADR-008 story the doc cites (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/MicroserviceExtractionTestsBase.cs:3-7). [Rubric §7, Microservices Readiness] assesses exactly this reversibility.
    • +
    • Concept: cross-references the delegating-base shape (AggregateConventionTestsBase); the extraction invariant (application and domain code talks to abstractions, transport choices live at the edges) is the ADR-006 / ADR-007 / ADR-008 story the doc cites (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/MicroserviceExtractionTestsBase.cs:3-7). [Rubric §7, Microservices Readiness] assesses exactly this reversibility. ServiceContractPurityTestsBase guards the same boundary from the contract side.
    • Walkthrough: one [Fact] CoreLayers_ShouldNotDependOn_Transport (line 13) delegating to ArchitectureRules.TransportDoesNotLeakIntoCoreLayers(Map).
    • Where it's used: subclassed in all four repos (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/MicroserviceExtractionTests.cs:10, MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/MicroserviceExtractionTests.cs:3, MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/MicroserviceExtractionTests.cs:3, and Helpdesk at MMCA.Helpdesk/Tests/Architecture/MMCA.Helpdesk.Architecture.Tests/ArchitectureTests.cs:109).
    @@ -2870,7 +3399,7 @@

    NamespaceCycleTestsBase

  • The single [Fact] Namespaces_ShouldNotHave_DependencyCycles (line 29) forwards both to the rule, which builds a per-assembly namespace graph from base types, interfaces, field, property, method return and parameter types, and attribute types, with generic arguments and array or by-ref element types expanded (ArchitectureRules.Cycles.cs:25-28, :84, :174).
  • -
  • Caveats / not-in-source: the rule is signature-level reflection and blind to method bodies, because the package carries no IL or Roslyn dependency, so a green result means "no STRUCTURAL cycle", not "no coupling" (doc, lines 10-13; the rule's own statement of the limit at ArchitectureRules.Cycles.cs:30-37). Compiler-generated types are skipped deliberately so the answer stays a signature-level one.
  • +
  • Caveats / not-in-source: the rule is signature-level reflection and blind to method bodies, because the package carries no IL or Roslyn dependency, so a green result means "no STRUCTURAL cycle", not "no coupling" (doc, lines 10-13; the rule's own statement of the limit at ArchitectureRules.Cycles.cs:29-37). Compiler-generated types are skipped deliberately so the answer stays a signature-level one.
  • Where it's used: subclassed today only in MMCA.Common, as NamespaceCycleTests (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/NamespaceCycleTests.cs:9). Its AllowedCycleNamespaces records the single accepted tangle in the framework, MMCA.Common.Infrastructure to Settings to Persistence and back (:39-44), with each of the three edges justified in the doc comment above it (:13-38).
  • NamingConventionTestsBase

    @@ -2912,6 +3441,24 @@

    ProtoContractTestsBase

  • Why it's built this way: the remarks say the snapshot is meant to be regenerated deliberately by printing ArchitectureRules.BuildProtoContract(...) for the same files, as part of the commit that changes the contract, never edited to make a red test go green (Bases/ProtoContractTestsBase.cs:12-17). MMCA.Common ships no .proto of its own (it supplies the gRPC plumbing, not the contracts), so the framework does NOT subclass this (lines 9-11).
  • Where it's used: subclassed in the two repos with *.Contracts projects: ADC, pinning seven protos across four Contracts projects (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/ProtoContractTests.cs:3, file list at :9-18), and Store, pinning three (MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/ProtoContractTests.cs:9, file list at :13-18). MMCA.Common exercises the underlying rule instead, from fixture protos including a deliberately drifted copy, in ProtoContractFitnessTests (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/ProtoContractFitnessTests.cs:14).
  • +

    ServiceContractPurityTestsBase

    +
    +

    MMCA.Common.Testing.Architecture · MMCA.Common.Testing.Architecture · MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/ServiceContractPurityTestsBase.cs:20 · Level 5 · abstract class

    +
    +
      +
    • What it is: a one-rule delegating base asserting that every type marked with the framework's ServiceContractAttribute stays free of the producing service's Domain, Application, and Infrastructure, so a consumer can take the contract package without taking the producer's internals.
    • +
    • Depends on: IArchitectureMap and ArchitectureRules.ServiceContractsDoNotDependOnServiceInternals (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Contracts.cs:32).
    • +
    • Concept introduced, the attribute-driven ratchet. Two design choices are worth reading closely, and both are recorded in the base's remarks (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/ServiceContractPurityTestsBase.cs:8-18). First, the rule is attribute-driven rather than Layer.Contracts-driven, because no repo registers that layer in its map today, so a layer-iterating rule would pass vacuously forever; scanning every registered assembly for the marker enforces the invariant wherever the contract types live. Second, the base is honest about the vacuous case: a repo that has marked no type yet asserts nothing, and the value is the ratchet, the invariant bites from the first marked type onward with no test left to remember. [Rubric §7, Microservices Readiness] assesses whether an extraction stays reversible; a contract that leaks a domain entity, a handler abstraction, or a persistence type forces every consumer to depend on the producer's internals, which is what makes an extraction irreversible (ArchitectureRules.Contracts.cs:13-19). [Rubric §9, API & Contract Design] assesses the published surface itself (ADR-007).
    • +
    • Walkthrough
        +
      • The subclass supplies Map (line 22); the single [Fact] ServiceContracts_ShouldNotDependOn_ServiceInternals (line 25) forwards to the rule.
      • +
      • In the rule, ServiceInternalNamespaces (ArchitectureRules.Contracts.cs:57) collects the distinct, ordered root namespaces of every Domain, Application and Infrastructure ref in the map (lines 59-65) and the rule returns immediately when that set is empty (lines 35-38).
      • +
      • It then loops map.Layers and runs NetArchTest per assembly with MeetCustomRule(CarriesServiceContractAttribute) as the selector (lines 40-47). CarriesServiceContractAttribute (line 69) reads Mono.Cecil.TypeDefinition custom attributes and matches the constant ServiceContractAttributeFullName (line 10, "MMCA.Common.Shared.Abstractions.ServiceContractAttribute") by string, the same zero-reference stance the rest of the library takes.
      • +
      • Because the rule scans every registered assembly, a marked type that lives inside a Domain, Application or Infrastructure assembly fails by construction, and the remarks say that is the intent: a published contract belongs in a *.Contracts or Shared assembly (ArchitectureRules.Contracts.cs:25-29).
      • +
      +
    • +
    • Where it's used: subclassed once per repo, in all four: MMCA.Common (MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/ServiceContractPurityTests.cs:11), ADC (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/ServiceContractPurityTests.cs:9), Store (MMCA.Store/Tests/Architecture/MMCA.Store.Architecture.Tests/ServiceContractPurityTests.cs:9), and Helpdesk (MMCA.Helpdesk/Tests/Architecture/MMCA.Helpdesk.Architecture.Tests/ServiceContractPurityTests.cs:9); see ServiceContractPurityTests. It complements, and does not replace, the transport-purity rule behind MicroserviceExtractionTestsBase and the layer-purity rules behind LayerDependencyTestsBase, which guard the same boundary from the layer side (ADR-015).
    • +
    • Caveats / not-in-source: no first-party type in any of the four repos carries [ServiceContract] today (the attribute's own doc records that MMCA.Common applies it to no type, MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/ServiceContractAttribute.cs:10-12), so every one of the four subclasses currently passes without asserting anything. That is the documented ratchet state, not a gap in the rule.
    • +

    SharedLayerTestsBase

    MMCA.Common.Testing.Architecture · MMCA.Common.Testing.Architecture · MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/SharedLayerTestsBase.cs:7 · Level 5 · abstract class

    @@ -2982,7 +3529,7 @@

    AxeOptions

  • Why it's built this way: shipping the options in the package rather than re-declaring them per test guarantees every consumer scans the identical rule set; the narrowly scoped pager exception keeps one known third-party gap from forcing a blanket rule-disable across all scans.
  • -
  • Where it's used: passed to PageExtensions.AssertNoAccessibilityViolationsAsync through E2ETestBase.ScanAsync (strict Wcag21Aa, MMCA.Common.Testing.E2E/Infrastructure/E2ETestBase.cs:296) and .ScanGridAsync (the pager exception, :288), and directly by the *_ShouldHaveNoAccessibilityViolations facts on UserLoginTestsBase, UserRegistrationTestsBase, and ProfileManagementTestsBase.
  • +
  • Where it's used: passed to PageExtensions.AssertNoAccessibilityViolationsAsync through E2ETestBase.ScanAsync (strict Wcag21Aa, MMCA.Common.Testing.E2E/Infrastructure/E2ETestBase.cs:296) and .ScanGridAsync (the pager exception, :288), and directly by the *_ShouldHaveNoAccessibilityViolations facts on UserLoginTestsBase (MMCA.Common.Testing.E2E/Workflows/Identity/UserLoginTestsBase.cs:83), UserRegistrationTestsBase (:91), ProfileManagementTestsBase (:180), and PasswordResetTestsBase (MMCA.Common.Testing.E2E/Workflows/Identity/PasswordResetTestsBase.cs:88, :99).
  • E2ETestConfiguration

    @@ -3002,6 +3549,17 @@

    E2ETestConfiguration

  • Why it's built this way: separating AuthTimeout and AuthGraceTimeout from the general DefaultTimeout is deliberate. The auth round-trip (full sign-in plus forceLoad reload plus re-render) can spike past a normal action budget on a contended CI runner, so it is tuned independently rather than by inflating every timeout in the suite. The doc ties the grace window to the TD-06/07 contention cluster and names the rejected alternative, forcing WASM, which broke login (:30-36).
  • Where it's used: read throughout PlaywrightFixture (engine, headless, slow-mo) and E2ETestBase (base URL, timeouts, trace path, credentials).
  • +

    ForgotPasswordPage

    +
    +

    MMCA.Common.Testing.E2E · MMCA.Common.Testing.E2E.PageObjects · MMCA.Common.Testing.E2E/PageObjects/ForgotPasswordPage.cs:6 · Level 0 · sealed class

    +
    +
      +
    • What it is: the Page Object for the shared /forgot-password screen, the entry point of the password-recovery flow. It exposes the address field, the submit button, the confirmation alert, and the return-to-login link, plus a one-call RequestResetAsync action.
    • +
    • Depends on: Microsoft.Playwright (IPage, ILocator, AriaRole) and the PageExtensions helpers GotoAndWaitForBlazorAsync and FillAndVerifyAsync (MMCA.Common.Testing.E2E/PageObjects/ForgotPasswordPage.cs:1-2).
    • +
    • Concept: the Page Object Model taught in LoginPage. One locator carries a design decision rather than a selector detail: ConfirmationAlert targets the success alert unconditionally, and the inline comment states why, the page lands on the same success alert whether or not the address has an account (ForgotPasswordPage.cs:15-16). That is the anti-enumeration contract of ADR-091 expressed as a test affordance: there is deliberately no "unknown address" locator to assert on, because the UI must not render one. [Rubric §11, Security] assesses whether account enumeration is closed off; a Page Object that cannot express the enumerating assertion is a small structural guard on that. [Rubric §28, Front-End Testing] applies as with every Page Object here.
    • +
    • Walkthrough: a private IPage field set in the constructor (ForgotPasswordPage.cs:8-10); EmailField located by label and SubmitButton by its accessible name "Send a password reset link" (:12-13); ConfirmationAlert as MudBlazor's .mud-alert-text-success class (:16); BackToLoginLink located by link role, with the comment recording that "Back to Sign In" is a MudButton with Href and therefore renders as an <a> (:18-19). GotoAsync full-loads /forgot-password and waits for interactivity (:21-22). RequestResetAsync fills the address through PageExtensions.FillAndVerifyAsync and clicks submit (:24-28).
    • +
    • Where it's used: driven by PasswordResetTestsBase for the unknown-address confirmation fact and the a11y fact (MMCA.Common.Testing.E2E/Workflows/Identity/PasswordResetTestsBase.cs:47-57, :82-88), and by the framework's own gallery suite, which exercises it against the backend-less gallery host including the confirmation state's own separate scan (MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/ForgotPasswordPageE2ETests.cs:19-24, :32-37, :43-46, :54-60).
    • +

    LoginPage

    MMCA.Common.Testing.E2E · MMCA.Common.Testing.E2E.PageObjects · MMCA.Common.Testing.E2E/PageObjects/LoginPage.cs:6 · Level 0 · sealed class

    @@ -3011,7 +3569,8 @@

    LoginPage

  • Depends on: Microsoft.Playwright (IPage, ILocator, AriaRole) and the PageExtensions helpers GotoAndWaitForBlazorAsync and FillAndVerifyAsync (MMCA.Common.Testing.E2E/PageObjects/LoginPage.cs:1-2).
  • Concept introduced, the Page Object Model. A Page Object wraps one screen behind an intention-revealing API, locating controls by their accessible name (GetByLabel("Email"), GetByRole(AriaRole.Button, Name = "Sign in to your account")) rather than by brittle CSS. That keeps tests coupled to what a user sees, not to MudBlazor's internal class names, and it centralizes each selector in one place. [Rubric §28, Front-End Testing] assesses whether E2E tests are maintainable; the Page Object is the canonical pattern for that. [Rubric §21, Accessibility] applies indirectly: locating by role and label only works if the component renders proper accessible names, so the test style pressures accessible markup.
  • Walkthrough: a private IPage field set in the constructor (LoginPage.cs:8-10); locator properties for EmailField, PasswordField, LoginButton, the ErrorAlert (MudBlazor's .mud-alert-text-error class), and the CreateAccountLink, which the inline comment explains is a MudButton with Href and therefore renders as an <a> located by link role (:12-18). GotoAsync navigates through GotoAndWaitForBlazorAsync("/login") (:20-21); LoginAsync fills both fields through the shared FillFieldAsync and then clicks (:23-28). The private FillFieldAsync delegates to PageExtensions.FillAndVerifyAsync (:31-32), guarding the Blazor re-hydration race without a fixed delay.
  • -
  • Where it's used: instantiated by UserLoginTestsBase for the invalid-password, create-account-link, and accessibility facts.
  • +
  • Where it's used: instantiated by UserLoginTestsBase for the invalid-password, create-account-link, and accessibility facts, and by PasswordResetTestsBase to reach the login screen before probing the recovery entry point (MMCA.Common.Testing.E2E/Workflows/Identity/PasswordResetTestsBase.cs:28-29).
  • +
  • Caveats / not-in-source: the Page Object exposes no locator for the "Forgot your password?" link; the one test that asserts it locates it directly off the page (PasswordResetTestsBase.cs:31).
  • ProfilePage

    @@ -3021,8 +3580,8 @@

    ProfilePage

  • What it is: the Page Object for the authenticated /profile screen, exposing the name, address, and password sections' fields and buttons as named locators.
  • Depends on: Microsoft.Playwright and PageExtensions.BlazorNavigateAsync (MMCA.Common.Testing.E2E/PageObjects/ProfilePage.cs:1-2).
  • Concept: the Page Object Model taught in LoginPage. One difference is load-bearing: GotoAsync uses BlazorNavigateAsync("/profile"), client-side routing (ProfilePage.cs:34-35), not a full page load, because /profile is [Authorize] and server-side rendering cannot read the JWT from browser storage, so a full load would bounce to /login. [Rubric §28, Front-End Testing] and [Rubric §11, Security] both apply: exercising the authenticated page correctly requires respecting the client-token boundary.
  • -
  • Walkthrough: three grouped sets of locators. Name (FirstNameField, LastNameField, SaveNameButton, :13-15), address (AddressLine1Field through CountryField plus SaveAddressButton, :18-24), and password (CurrentPasswordField, NewPasswordField with Exact = true so it does not also match "Confirm New Password", ConfirmNewPasswordField, ChangePasswordButton, :27-30), plus a generic ErrorAlert located by the alert role (:32). This Page Object has no bulk action method: each fact drives the individual locators.
  • -
  • Where it's used: instantiated throughout ProfileManagementTestsBase, and directly by ADC's own ProfileManagementTests, which drives the same Page Object off E2ETestBase instead of the shared base (MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/Identity/ProfileManagementTests.cs:15).
  • +
  • Walkthrough: three commented locator groups. Name (FirstNameField, LastNameField, SaveNameButton, :12-15); address (six fields plus SaveAddressButton, :17-24); password (CurrentPasswordField, NewPasswordField located with Exact = true so it does not also match "Confirm New Password", ConfirmNewPasswordField, ChangePasswordButton, :26-30). ErrorAlert is located by ARIA alert role rather than a MudBlazor class (:32). GotoAsync is the client-side navigation described above (:34-35).
  • +
  • Where it's used: by ProfileManagementTestsBase for all six of its facts, and by ADC's own ProfileManagementTests, which drives the same Page Object without deriving from the shared base (MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/Identity/ProfileManagementTests.cs:30-40).
  • RegisterPage

    @@ -3035,6 +3594,18 @@

    RegisterPage

  • Walkthrough: locator properties for the five required fields plus RegisterButton and ErrorAlert (:12-18), the AlreadyHaveAccountLink sign-in link (:21), and the optional address panel and fields (:24-29). GotoAsync full-loads /register (:31-32); RegisterAsync fills the five required fields through the shared helper, reusing the same password for the confirm field, then clicks (:34-42); the private FillFieldAsync delegates to PageExtensions.FillAndVerifyAsync (:48-49).
  • Where it's used: instantiated by UserRegistrationTestsBase for all four of its facts.
  • +

    ResetPasswordPage

    +
    +

    MMCA.Common.Testing.E2E · MMCA.Common.Testing.E2E.PageObjects · MMCA.Common.Testing.E2E/PageObjects/ResetPasswordPage.cs:6 · Level 0 · sealed class

    +
    +
      +
    • What it is: the Page Object for the shared /reset-password screen, the redemption half of the recovery flow. It exposes the address, token, and new-password fields, both outcome alerts, the return-to-login link, and two navigation entry points: the bare page and the prefilled emailed-link form.
    • +
    • Depends on: Microsoft.Playwright and the PageExtensions helpers GotoAndWaitForBlazorAsync and FillAndVerifyAsync (MMCA.Common.Testing.E2E/PageObjects/ResetPasswordPage.cs:1-2).
    • +
    • Concept: the Page Object Model taught in LoginPage. What is worth teaching here is GotoWithLinkAsync, which reproduces the way a real user arrives: the emailed link carries the address and the token as query parameters, so both fields land prefilled and the test exercises the same route the mail does (ResetPasswordPage.cs:30-36). The fields stay editable, which the doc comment records is deliberate, so the raw token from the same email can also be typed by hand. [Rubric §24, Forms/Validation/UX Safety] assesses whether the recovery form's real arrival paths are covered; modelling both the bare and the linked entry is how this Page Object does it.
    • +
    • Walkthrough: a private IPage field set in the constructor (:8-10); EmailField and TokenField located by label (:12-13); NewPasswordField located with Exact = true, with the comment spelling out that "New Password" is a substring of "Confirm New Password" so the default substring match would resolve to both fields (:15-17), and ConfirmPasswordField beside it (:18). SubmitButton is located by the accessible name "Reset your password" (:20); ErrorAlert and SuccessAlert are the two MudBlazor alert classes (:21-22); GoToLoginLink is again a link-role locator over a MudButton with Href (:24-25). GotoAsync loads the bare page (:27-28); GotoWithLinkAsync builds /reset-password?email=...&token=... with Uri.EscapeDataString on both values (:34-36); ResetAsync fills all four fields through FillAndVerifyAsync and clicks submit (:38-45).
    • +
    • Where it's used: by PasswordResetTestsBase for the empty-form validation fact and the a11y fact (MMCA.Common.Testing.E2E/Workflows/Identity/PasswordResetTestsBase.cs:64-68, :95-99), and by the framework's gallery suite, which additionally asserts the query-string prefill round-trip (MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/ResetPasswordPageE2ETests.cs:19-27, :35-39, :45-48).
    • +
    • Caveats / not-in-source: no test in this package submits a genuine token. PasswordResetTestsBase's doc states why (the token only reaches the user by email, so consuming one is an app-side integration-test concern, PasswordResetTestsBase.cs:10-15), so ResetAsync and SuccessAlert are shipped affordances that the framework's own suites do not currently drive end to end.
    • +

    UserCredentials

    MMCA.Common.Testing.E2E · MMCA.Common.Testing.E2E.Infrastructure · MMCA.Common.Testing.E2E/Infrastructure/E2ETestConfiguration.cs:78 · Level 0 · nested static class

    @@ -3072,7 +3643,7 @@

    PageExtensions

  • Why it's built this way: the fill and click helpers exist because InteractiveAuto's prerender-then-hydrate model makes a bare fill or click a race on a fast host; auto-waiting assertions with a bounded re-type or re-click are strictly safer than fixed delays, since they succeed as soon as the value or effect appears. Two [SuppressMessage] attributes document analyzer false positives across the extension(T) boundary: CA1708 on the class, where the compiler-generated grouping members read as case-colliding (:15-18), and IDE0051 on CompactHtml, which the SDK 10.0.201+ analyzer cannot see being called from inside the extension block (:301-304).
  • -
  • Where it's used: throughout the Page Objects (LoginPage, ProfilePage, RegisterPage), inside E2ETestBase (FillFieldAsync, ScanAsync, ScanGridAsync, the navigation helpers), and directly by every workflow base in this group.
  • +
  • Where it's used: throughout the Page Objects (ForgotPasswordPage, LoginPage, ProfilePage, RegisterPage, ResetPasswordPage), inside E2ETestBase (FillFieldAsync, ScanAsync, ScanGridAsync, the navigation helpers), and directly by every workflow base in this group.
  • PlaywrightFixture

    @@ -3109,8 +3680,8 @@

    WebVitalsBudget

  • AssertWithinBudget(sample, label, path, writeLine = null) (:137) invokes the optional sink with that line (normally ITestOutputHelper.WriteLine, :141), then asserts LCP, FCP, TTFB, and CLS against their ceilings (:143-146). INP is asserted only when sample.Inp > 0 (:148-151), because no interaction clearing the collector's 16 ms event threshold leaves the sample at 0, and 0 must read as neither a pass-by-absence nor a failure. Failure text comes from the private Message helper, which names the metric, the measured value, the ceiling, and the page path (:154-157).
  • -
  • Why it's built this way: keeping the numbers consumer-side while shipping the assert body is what lets ADC and Store hold different calibrated budgets without either repo re-deriving the INP-zero rule or the message format. The 0-INP carve-out is the subtle one, and it is pinned by its own unit test rather than left to a comment (MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/WebVitalsBudgetTests.cs:58-64).
  • -
  • Where it's used: ADC holds one static default instance and takes the framework numbers as-is (MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/WebVitalsTests.cs:27, asserted at :79); Store constructs one per measurement (MMCA.Store/Tests/E2E/MMCA.Store.E2E.Tests/Workflows/WebVitalsTests.cs:68, :94). The framework's own gallery suite instead asserts against local constants tuned for the backend-less host (MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/WebVitalsE2ETests.cs:18-20,43-45), and WebVitalsBudgetTests covers the record's mechanics without starting a browser (WebVitalsBudgetTests.cs:12).
  • +
  • Why it's built this way: keeping the numbers consumer-side while shipping the assert body is what lets ADC and Store hold different calibrated budgets without either repo re-deriving the INP-zero rule or the message format. The 0-INP carve-out is the subtle one, and it is pinned by its own unit test rather than left to a comment (MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/WebVitalsBudgetTests.cs:59-64).
  • +
  • Where it's used: ADC holds one static default instance and takes the framework numbers as-is (MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/WebVitalsTests.cs:27, asserted at :86); Store constructs one per measurement (MMCA.Store/Tests/E2E/MMCA.Store.E2E.Tests/Workflows/WebVitalsTests.cs:68, :94). The framework's own gallery suite instead asserts against local constants tuned for the backend-less host (MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/WebVitalsE2ETests.cs:18-20,43-45), and WebVitalsBudgetTests covers the record's mechanics without starting a browser (WebVitalsBudgetTests.cs:12).
  • E2ETestCollection

    @@ -3122,7 +3693,7 @@

    E2ETestCollection

  • Concept introduced, the xUnit collection fixture binding. A collection fixture is instantiated once and shared by every test class that opts into the collection by name. This class carries a public const string Name = "E2E" (:50) used both in its own [CollectionDefinition(Name)] and in each test's [Collection(E2ETestCollection.Name)], so the string is declared once and cannot drift. [Rubric §14, Testability] assesses fixture design; a single named constant binding is the robust way to share a fixture.
  • Walkthrough: an otherwise empty class body carrying the collection definition and the Name constant (:47-51). It exists purely as an xUnit marker, and it lives in the same file as the fixture it binds.
  • Where it's used: referenced by E2ETestBase's [Collection(E2ETestCollection.Name)] attribute (MMCA.Common.Testing.E2E/Infrastructure/E2ETestBase.cs:7), so every workflow base and every consumer subclass inherits collection membership.
  • -
  • Caveats / not-in-source: xUnit collection definitions do not cross assembly boundaries, so each consumer E2E assembly re-declares its own identically named definition over the same fixture type (MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Infrastructure/E2ETestCollection.cs:7-11).
  • +
  • Caveats / not-in-source: xUnit collection definitions do not cross assembly boundaries, so each consumer E2E assembly re-declares its own identically named definition over the same fixture type, and says so in its doc comment (MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Infrastructure/E2ETestCollection.cs:3-11).
  • WebVitalsCollector

    @@ -3134,7 +3705,7 @@

    WebVitalsCollector

  • Concept introduced, in-browser performance measurement with no third-party JS. Rather than shipping an analytics SDK, it injects a small init script that installs PerformanceObservers for LCP, CLS, FCP, and INP, each wrapped in try/catch so an engine lacking an entry type leaves that metric at 0 instead of throwing, and accumulates into window.__vitals (:26-35). The type doc is explicit that this is the client-side analogue of a backend load test, not a cross-engine field measurement: LCP and CLS are Chromium-only, so on Firefox and WebKit those fields stay 0 and budget assertions pass (:9-18). [Rubric §23, Front-End Performance] and [Rubric §12, Performance & Scalability] assess whether user-centric performance is measured; observing the vitals APIs directly, with no network egress, is a self-contained way to do it. The same doc states the class is only the measurement infrastructure, that WebVitalsBudget is the shared assert mechanics, and that consumers own which pages carry a budget and what the numbers are (:16-18).
  • Walkthrough: InstallAsync registers the observers through AddInitScriptAsync so they are active on the next navigation (:40-44). CollectAsync evaluates a script that stamps TTFB from Navigation Timing and returns window.__vitals as JSON, deserialized into a WebVitalsSample (:47-57). WriteArtifactAsync resolves the output directory from WEB_VITALS_OUTPUT_DIR or falls back to artifacts/ under the current directory, creates it, wraps the sample in a WebVitalsArtifact, and writes web-vitals-{label}.json indented (:63-72).
  • Why it's built this way: the observers install before the document's own scripts (through AddInitScript) so early metrics such as FCP are not missed, and the per-observer try/catch is what makes the same code run green on all three engines despite the Chromium-only metrics. The init script is kept as one concatenated string rather than a raw literal to stay clear of the MA0136 analyzer (:22-25).
  • -
  • Where it's used: by the budget-asserting tests each repo owns, which install, navigate, collect, assert, and write the artifact for CI upload: the framework's own gallery suite (MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/WebVitalsE2ETests.cs:37-41), ADC (MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/WebVitalsTests.cs:58, :76-77), and Store (MMCA.Store/Tests/E2E/MMCA.Store.E2E.Tests/Workflows/WebVitalsTests.cs:73, :91-92).
  • +
  • Where it's used: by the budget-asserting tests each repo owns, which install, navigate, collect, assert, and write the artifact for CI upload: the framework's own gallery suite (MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/WebVitalsE2ETests.cs:37-41), ADC (MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/WebVitalsTests.cs:65, :83-84), and Store (MMCA.Store/Tests/E2E/MMCA.Store.E2E.Tests/Workflows/WebVitalsTests.cs:73, :91-92).
  • E2ETestBase

    @@ -3152,7 +3723,7 @@

    E2ETestBase

  • Why it's built this way: the auth helpers encode hard-won timing knowledge once (the forceLoad reload, the Server-versus-WASM hydration lag, the cookie-and-localStorage dual session store), so every consumer workflow inherits a deterministic sign-in instead of re-deriving the races. Clearing both token stores is essential: the Blazor Server host is cookie-only, so a localStorage clear alone would leave the next login authenticated as the wrong user (:99-104). The scan split lets grid pages accept the documented pager-combobox exception while every other page stays strict, and the grid wait keys off a data row rather than the loading bar hiding, which would resolve instantly before the transient unnamed progressbar even appears (:274-282).
  • -
  • Where it's used: the base class of all six workflow bases in this unit (AuthorizationTestsBase, LogoutTestsBase, ProfileManagementTestsBase, UserLoginTestsBase, UserPreferencesTestsBase, UserRegistrationTestsBase) and, through them and directly, every E2E test class in the ADC and Store suites (for example ADC's own ProfileManagementTests, which derives from this base rather than the shared profile workflow, MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/Identity/ProfileManagementTests.cs:8).
  • +
  • Where it's used: the base class of all seven workflow bases in this unit (AuthorizationTestsBase, LogoutTestsBase, PasswordResetTestsBase, ProfileManagementTestsBase, UserLoginTestsBase, UserPreferencesTestsBase, UserRegistrationTestsBase) and, through them and directly, every E2E test class in the ADC and Store suites (for example ADC's own ProfileManagementTests, which derives from this base rather than the shared profile workflow, MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/Identity/ProfileManagementTests.cs:8).
  • AuthorizationTestsBase

    @@ -3161,10 +3732,10 @@

    AuthorizationTestsBase

    • What it is: the reusable authorization workflow fitness base, authored once and re-run as a thin subclass per repo. It asserts that anonymous users are redirected off protected paths, that public paths stay reachable, that a registered non-admin can reach an authenticated page, and that a non-admin probing admin routes gets the Forbidden page.
    • Depends on: E2ETestBase, PageExtensions (GotoAndWaitForBlazorAsync, GotoProtectedAsync), AwesomeAssertions, and Microsoft.Playwright (MMCA.Common.Testing.E2E/Workflows/Identity/AuthorizationTestsBase.cs:1-6).
    • -
    • Concept introduced, the authored-once workflow fitness base. This is the pattern shared by all six bases in this unit: the framework owns the assertions and the SSR-versus-client-navigation mechanics, and each consumer supplies only its own route lists through abstract or virtual members, so identical security behavior is verified across repos without copying test bodies (:10-17). [Rubric §11, Security] assesses whether authorization is actually exercised; this base machine-checks both the anonymous-redirect and the authenticated-non-admin-escalation directions. [Rubric §25, Navigation & IA] applies because it pins which routes are public and which are gated.
    • +
    • Concept introduced, the authored-once workflow fitness base. This is the pattern shared by all seven bases in this unit: the framework owns the assertions and the SSR-versus-client-navigation mechanics, and each consumer supplies only its own route lists through abstract or virtual members, so identical security behavior is verified across repos without copying test bodies (:10-17). [Rubric §11, Security] assesses whether authorization is actually exercised; this base machine-checks both the anonymous-redirect and the authenticated-non-admin-escalation directions. [Rubric §25, Navigation & IA] applies because it pins which routes are public and which are gated.
    • Walkthrough: the subclass supplies ProtectedPaths and PublicPaths (abstract, :26, :29) and optionally AuthenticatedUserPath and AdminPaths (virtual, defaulting to null and an empty list, :35, :44). Four facts follow. AnonymousUser_ProtectedPages_ShouldRedirectToLogin asserts each protected path bounces to /login (:46-58). AnonymousUser_PublicPages_ShouldBeAccessible asserts each public path stays put (:60-72). RegisteredUser_AuthenticatedPage_ShouldBeAccessible registers a non-admin, then client-navigates through GotoProtectedAsync because SSR cannot read the JWT, passing vacuously when no path is declared (:74-93). RegisteredUser_AdminPages_ShouldBeForbidden registers a non-admin, then asserts each admin path renders the shared Forbidden page, matching h1[role='alert'] containing "Access Denied", with the comment noting that role denial is not a redirect so the page content is the only reliable signal (:95-120).
    • Why it's built this way: the two optional members use a no-dynamic-skip convention (an app with no such page simply passes) because the shipped library deliberately does not reference xunit.v3.assert for a declared skip (:77-78, :98-99). The non-empty assertions on ProtectedPaths and PublicPaths (:49-50, :63-64) are non-vacuity guards: a repo that declares no paths fails rather than passing silently.
    • -
    • Where it's used: subclassed in both consumer E2E suites with that app's route lists (MMCA.Store/Tests/E2E/MMCA.Store.E2E.Tests/Workflows/Identity/AuthorizationTests.cs:11-21, MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/Identity/AuthorizationTests.cs:11-22); Store's subclass also adds one app-specific fact of its own, an anonymous order-detail deep link that must leak no order content (AuthorizationTests.cs:23-37).
    • +
    • Where it's used: subclassed in both consumer E2E suites with that app's route lists (MMCA.Store/Tests/E2E/MMCA.Store.E2E.Tests/Workflows/Identity/AuthorizationTests.cs:11-21, MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/Identity/AuthorizationTests.cs:11-39, whose twelve-entry AdminPaths carries a comment explaining which authenticated-but-not-Organizer routes are deliberately excluded, :21-24); Store's subclass also adds one app-specific fact of its own, an anonymous order-detail deep link that must leak no order content (AuthorizationTests.cs:23-37).

    LogoutTestsBase

    @@ -3178,6 +3749,24 @@

    LogoutTestsBase

  • Why it's built this way: waiting for the cookie-clear response is the fix for a real full-speed race. At speed the test otherwise reaches /profile before the DELETE finishes, so the HttpOnly cookie is still present and SSR re-authenticates. The bounded re-request loop converges deterministically where any slowdown (slow-mo, or even trace capture) would have hidden the race entirely (:42-46, :57-63).
  • Where it's used: subclassed in both consumer E2E suites (MMCA.Store/Tests/E2E/MMCA.Store.E2E.Tests/Workflows/Identity/LogoutTests.cs:5, MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/Identity/LogoutTests.cs:5).
  • +

    PasswordResetTestsBase

    +
    +

    MMCA.Common.Testing.E2E · MMCA.Common.Testing.E2E.Workflows.Identity · MMCA.Common.Testing.E2E/Workflows/Identity/PasswordResetTestsBase.cs:17 · Level 4 · abstract class

    +
    +
      +
    • What it is: the reusable password-recovery workflow base covering the shared /forgot-password and /reset-password pages: the entry point is reachable from the login screen, an unknown address gets the same confirmation as a known one, the reset form's client-side validation blocks an empty submit, and both pages are accessibility-clean.
    • +
    • Depends on: E2ETestBase, LoginPage, ForgotPasswordPage, ResetPasswordPage, PageExtensions, AxeOptions, and Microsoft.Playwright (MMCA.Common.Testing.E2E/Workflows/Identity/PasswordResetTestsBase.cs:1-6).
    • +
    • Concept introduced, drawing the E2E boundary around what a browser can honestly observe. The type doc states outright that the real token round-trip is deliberately not exercised here: the token only reaches the user by email, so redeeming one is an app-side integration-test concern, and what E2E owns is the reachability of the flow, the anti-enumeration confirmation, client-side validation, and WCAG 2.1 AA conformance of both pages (:10-16). That is a scope decision worth internalizing, a browser test that cannot reach the mailbox should assert the contract it can see rather than fake the one it cannot. [Rubric §11, Security] assesses whether the recovery flow leaks account existence; the unknown-address fact is the machine check on ADR-091's anti-enumeration rule. [Rubric §24, Forms/Validation/UX Safety] covers the empty-submit validation, [Rubric §21, Accessibility] the two scans, and [Rubric §25, Navigation & IA] the entry-point fact, since a locked-out user has no other way in.
    • +
    • Walkthrough: five facts, no abstract members, so a consumer subclass is a single line.
        +
      • LoginPage_ForgotPasswordLink_NavigatesToForgotPasswordPage (:25-41) opens the LoginPage, asserts the "Forgot your password?" link is visible at all (the comment notes a user locked out of their account has no other entry point, :33-34), clicks it, and asserts the URL ends at /forgot-password (:40).
      • +
      • ForgotPassword_WithUnknownEmail_ShowsTheSameConfirmation (:44-58) submits a unknown-{UniqueId()}@test.com address that certainly has no account, then asserts the positive path exactly: the success confirmation appears (:55), the URL stays on /forgot-password (:56), and the back-to-login link is visible (:57). There is no error alert and no navigation to distinguish it from a real address, which is the whole point.
      • +
      • ResetPassword_WithEmptyForm_ShowsClientValidationErrors (:61-76) clicks submit on an untouched form; DataAnnotations block OnValidSubmit so nothing is sent, and the fact asserts the field-level texts "Email is required" and "Reset token is required" plus staying on /reset-password (:73-75).
      • +
      • ForgotPasswordPage_ShouldHaveNoAccessibilityViolations (:79-89) and ResetPasswordPage_ShouldHaveNoAccessibilityViolations (:92-100) each load their page and scan with AxeOptions.Wcag21Aa.
      • +
      +
    • +
    • Why it's built this way: the validation fact asserts the field-level text rather than a page-level alert, and the source gives the reason, those messages are present in both render modes (Server prerender and WebAssembly) while a page-level alert is not, the same reasoning as the mismatched-password registration test (:70-72, and UserRegistrationTestsBase.Register_WithMismatchedPasswords_ShouldShowError). The a11y scans use the strict Wcag21Aa preset explicitly rather than an unscoped RunAxe, keeping this workflow on the same documented target as the rest of Identity (:85-88).
    • +
    • Where it's used: subclassed with no additions in both consumer E2E suites (MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/Identity/PasswordResetTests.cs:5, MMCA.Store/Tests/E2E/MMCA.Store.E2E.Tests/Workflows/Identity/PasswordResetTests.cs:5). The two Page Objects it drives are additionally exercised against the framework's own backend-less gallery host by ForgotPasswordPageE2ETests and ResetPasswordPageE2ETests (MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/ForgotPasswordPageE2ETests.cs:9, MMCA.Common/Tests/Presentation/MMCA.Common.UI.E2E.Tests/ResetPasswordPageE2ETests.cs:9).
    • +

    ProfileManagementTestsBase

    MMCA.Common.Testing.E2E · MMCA.Common.Testing.E2E.Workflows.Identity · MMCA.Common.Testing.E2E/Workflows/Identity/ProfileManagementTestsBase.cs:11 · Level 4 · abstract class

    @@ -3188,7 +3777,7 @@

    ProfileManagementTestsBase

  • Concept: the authored-once workflow base taught in AuthorizationTestsBase, here driving a ProfilePage. [Rubric §24, Forms/Validation/UX Safety] assesses whether edit-and-persist journeys work end to end; [Rubric §21, Accessibility] applies through the a11y fact.
  • Walkthrough: one virtual switch, ProfileSupportsEmailChange, off by default (:24). Six facts follow. ChangeName_ShouldUpdateProfileName clears and fills both name fields, saves, re-navigates, and asserts the values persisted (:26-53); ChangeAddress_ShouldUpdateProfileAddress does the same for the five address fields and asserts on line 1 (:55-78). Both use Playwright's plain FillAsync rather than the re-hydration-safe helper, since the profile page is reached by client-side navigation on an already interactive runtime. ChangePassword_WithValidCurrentPassword_ShouldSucceed fills the three password fields through the shared FillFieldAsync, waits for the "Password changed successfully." snackbar, then signs out and logs back in with the new password, waiting for the logout forceLoad's /login URL rather than LoadState.Load so it does not race the in-flight navigation (:80-110). ChangeEmail_ShouldUpdateEmail is opt-in and returns immediately unless ProfileSupportsEmailChange is overridden true (:112-146). ProfilePage_ShouldLoadWithUserData asserts the form is pre-filled from registration (:148-166). ProfilePage_ShouldHaveNoAccessibilityViolations scans with AxeOptions.Wcag21Aa (:168-181).
  • Why it's built this way: the email-change fact is a declared opt-in rather than a DOM probe because the previous probing version passed vacuously when the field was absent, reporting coverage for a journey the app does not offer; overriding the flag makes a missing field fail loud (:18-23, :129-131). The logout-then-login URL wait is called out in the source as the one remaining sign-out-then-login site still on the racy pattern, fixed to match UserLoginTestsBase (:100-105).
  • -
  • Where it's used: subclassed only by Store, with no additions and no override of ProfileSupportsEmailChange (MMCA.Store/Tests/E2E/MMCA.Store.E2E.Tests/Workflows/Identity/ProfileManagementTests.cs:5), so the email-change fact passes without exercising a journey Store offers. ADC does not subclass this base: its profile page supports only password change and account deletion, so MMCA.ADC.E2E.Tests writes its own ProfileManagementTests directly on E2ETestBase with a password-change fact and a /profile/claims fact (MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/Identity/ProfileManagementTests.cs:8, :10-38, :40-52).
  • +
  • Where it's used: subclassed only by Store, with no additions and no override of ProfileSupportsEmailChange (MMCA.Store/Tests/E2E/MMCA.Store.E2E.Tests/Workflows/Identity/ProfileManagementTests.cs:5), so the email-change fact passes without exercising a journey Store offers. ADC does not subclass this base: its profile page supports the avatar photo, password change, and account deletion but no name or address editing, so MMCA.ADC.E2E.Tests writes its own ProfileManagementTests directly on E2ETestBase with a password-change fact mirroring this one, a /profile/claims fact, and an avatar upload-replace-remove round trip that builds its two PNGs from base64 constants so the test needs no fixture file on disk (MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/Identity/ProfileManagementTests.cs:8, :26, :56, :70, :13-17).
  • UserLoginTestsBase

    @@ -3606,7 +4195,7 @@

    BrandColorTokenTests

  • What it is - the ADC end of the brand-token drift guard. It is a five-line subclass of the shared BrandColorTokenTestsBase that names one embedded stylesheet, ADCHome.Shared.razor.css (MMCA.ADC.Architecture.Tests/BrandColorTokenTests.cs:14-:17); the rule body itself lives in MMCA.Common.
  • Depends on - BrandColorTokenTestsBase from the MMCA.Common.Testing.Architecture package (referenced at MMCA.ADC.Architecture.Tests/MMCA.ADC.Architecture.Tests.csproj:41), plus the EmbeddedResource item that maps the conference landing page's scoped stylesheet into this assembly under that logical name (MMCA.ADC.Architecture.Tests/MMCA.ADC.Architecture.Tests.csproj:11-:13). Externals: xUnit v3, AwesomeAssertions, and NetArchTest (MMCA.ADC.Architecture.Tests.csproj:25-:27), the last two reachable everywhere in the assembly through the global usings (MMCA.ADC.Architecture.Tests/GlobalUsings.cs:1-:5).
  • Concept introduced, the thin-subclass fitness function. Every type in this unit follows one shape, so learn it once here. A fitness function is an executable test that asserts an architectural property instead of a behavior. MMCA keeps the property's logic in exactly one place, an abstract *TestsBase in the shared MMCA.Common.Testing.Architecture package, and each repo derives a sealed subclass that supplies only its own identity: which assemblies to scan, which floors and allowlists apply, which files to read. xUnit discovers [Fact]s on inherited members, so the subclass needs no test method of its own; deriving the class is what makes the rule run in this repo (ADR-015). [Rubric §34 - Architecture Governance & Documentation] assesses whether architectural decisions are recorded and enforced rather than trusted to reviewers; here the decision is enforced by a build that goes red. [Rubric §20 - Design System & Theming] assesses whether a design system has one source of truth for its tokens; this rule is what stops a host copy of the landing page from re-hardcoding the brand hex.
  • -
  • Walkthrough - one member. EmbeddedCssLogicalNames (BrandColorTokenTests.cs:14-:17) is a collection expression with a single entry, "ADCHome.Shared.razor.css". That string is not a file path: it is the LogicalName the csproj assigns when it embeds Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.css as a manifest resource (MMCA.ADC.Architecture.Tests.csproj:11-:13), which is how a test assembly reads a file from a project it does not reference. The inherited fact LandingPageCss_SourcesBrandColorFromToken_NotHardcodedHex (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/BrandColorTokenTestsBase.cs:25) then loads each named resource (:56-:63, throwing a clear error if the embed is missing), requires the text to contain var(--mmca-primary) (:41-:44), and requires it not to contain the literal #1565C0 in any casing (:46-:49). Both constants are declared once in the base (:15-:16).
  • +
  • Walkthrough - one member. EmbeddedCssLogicalNames (BrandColorTokenTests.cs:14-:17) is a collection expression with a single entry, "ADCHome.Shared.razor.css". That string is not a file path: it is the LogicalName the csproj assigns when it embeds Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.css as a manifest resource (MMCA.ADC.Architecture.Tests.csproj:11-:13), which is how a test assembly reads a file from a project it does not reference. The inherited fact LandingPageCss_SourcesBrandColorFromToken_NotHardcodedHex (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/BrandColorTokenTestsBase.cs:25) then loads each named resource, requires the text to contain var(--mmca-primary), and requires it not to contain the literal #1565C0 in any casing. Both constants are declared once in the base (:15-:16), and the abstract hook this class implements is declared at :22.
  • Why it's built this way - the class comment records the split (BrandColorTokenTests.cs:3-:11): MMCA.Common's own BrandColorTokenTests guards the C#-to-CSS token definition, and this one guards the ADC consumer of it. Embedding the stylesheet rather than reading it off disk means the guard travels with the compiled test assembly and cannot be defeated by a runner whose working directory differs.
  • Where it's used - the whole project is inside ADC's CI solution filter (MMCA.ADC/MMCA.ADC.CI.slnf:58), which the build-and-test job restores, builds, and tests on every PR and every push to main (MMCA.ADC/.github/workflows/deploy.yml:124, :199, :205, :219).
  • Caveats / not-in-source - the guard only covers stylesheets that are both embedded and listed. ADC lists exactly one, so a second landing-page stylesheet added later is invisible to the rule until someone adds it to both places.
  • @@ -3619,25 +4208,78 @@

    ObservabilityConventionTests

  • What it is - the SLO alert-to-runbook pairing gate for ADC, and the shortest type in this unit: a bodyless class declaration, public sealed class ObservabilityConventionTests : ObservabilityConventionTestsBase; (MMCA.ADC.Architecture.Tests/ObservabilityConventionTests.cs:7). It overrides nothing at all.
  • Depends on - ObservabilityConventionTestsBase, plus two EmbeddedResource entries in the csproj that supply the files the base reads: infra/main.bicep under the logical name infra.main.bicep and infra/OPERATIONS.md under infra.OPERATIONS.md (MMCA.ADC.Architecture.Tests/MMCA.ADC.Architecture.Tests.csproj:17-:22).
  • Concept introduced, identity by inheritance alone. This is the thin-subclass pattern from BrandColorTokenTests reduced to its limit. The base defaults ResourceAssembly to GetType().Assembly (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/ObservabilityConventionTestsBase.cs:51), so the derived type is the configuration: deriving in this assembly is what points the rule at ADC's embedded bicep and runbook. The file comment states exactly that ("this repo supplies only its identity", ObservabilityConventionTests.cs:3-:6). [Rubric §13 - Observability & Operability] assesses whether the system can be operated under failure, which means alerts that lead somewhere; this pairs each provisioned alert with a runbook section at build time instead of at 3am.
  • -
  • Walkthrough - no members. Everything runs from the base's three inherited facts: SloAlertSpecs_AreDiscovered_GateIsNotVacuous (ObservabilityConventionTestsBase.cs:54) enforces the non-vacuity floor of MinimumAlertSpecs, defaulted to 3 and not overridden here (:39); EveryProvisionedSloAlert_HasASeverityCorrectRunbookSection (:64) walks the alerts declared in the embedded bicep and requires a matching, severity-correct section in the embedded runbook; and EveryRunbookAlertSection_MapsToAProvisionedAlert (:92) closes the other direction, failing on an orphan runbook section for an alert that no longer exists. The resource names the base reads default to infra.main.bicep and infra.OPERATIONS.md (:42, :45), which is why the csproj logical names must match exactly.
  • +
  • Walkthrough - no members. Everything runs from the base's three inherited facts: SloAlertSpecs_AreDiscovered_GateIsNotVacuous (ObservabilityConventionTestsBase.cs:54) enforces the non-vacuity floor of MinimumAlertSpecs, defaulted to 3 and not overridden here (:39); EveryProvisionedSloAlert_HasASeverityCorrectRunbookSection (:64) walks the alerts declared in the embedded bicep and requires a matching, severity-correct section in the embedded runbook; and EveryRunbookAlertSection_MapsToAProvisionedAlert (:92) closes the other direction, failing on an orphan runbook section for an alert that no longer exists. Alerts are recognised by the -alert- infix in their resource name (:32). The resource names the base reads default to infra.main.bicep and infra.OPERATIONS.md (:42, :45), which is why the csproj logical names must match exactly.
  • Why it's built this way - alert definitions live in infrastructure-as-code and the response procedure lives in a Markdown runbook; nothing in either file references the other, so the pairing is exactly the kind of invariant that decays silently. Embedding both into the test assembly turns the pairing into a compile-and-run artifact.
  • Where it's used - runs with the rest of the suite in the build-and-test job (MMCA.ADC/.github/workflows/deploy.yml:124).
  • +

    AnonymousEndpointTests

    +
    +

    MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/AnonymousEndpointTests.cs:21 · Level 6 · class (public, sealed)

    +
    +
      +
    • What it is - the security gate that no endpoint loses its authorization unnoticed: every [AllowAnonymous] reachable in ADC's three module API assemblies and three module UI assemblies must appear as a reviewed line in this file (MMCA.ADC.Architecture.Tests/AnonymousEndpointTests.cs:23-:31, :33-:103). It is the largest subclass in the unit, 48 allowlist entries long.
    • +
    • Depends on - AnonymousEndpointTestsBase, System.Reflection.Assembly (global-used at MMCA.ADC.Architecture.Tests/GlobalUsings.cs:1), and, as type references pinning the three API assemblies, IdentityModule, ConferenceModule, and EngagementModule (:25-:27). The three UI assemblies are loaded by name (:28-:30). Note it does not take the map: it names its own assembly set.
    • +
    • Concept introduced, the reviewed-allowlist gate. The rules elsewhere in this unit assert a structural property. This one asserts a review property: the set of anonymous endpoints is not wrong, it is simply not allowed to change silently. The base makes that stick in three directions at once (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/AnonymousEndpointTestsBase.cs:53-:90): AnonymousEndpoints_AreAllowListed fails on an unlisted [AllowAnonymous] (:54), ScannedEndpointSet_IsNotEmpty fails when fewer than MinimumScannedTypes endpoint types were discovered at all (:66), and AllowList_HasNoStaleEntries fails on a listed entry that no longer matches anything (:79), which is what stops the list from silently accumulating names for endpoints that were renamed or re-gated. [Rubric §11 - Security] assesses whether the authorization posture is deliberate and verifiable; an allowlist that must be edited, in a file a reviewer reads, is the mechanism. [Rubric §26 - Front-End Security] applies too, because routable Blazor components are scanned alongside controllers.
    • +
    • Walkthrough - three members.
        +
      • TargetAssemblies (:23-:31) names six assemblies: the Identity, Conference, and Engagement API assemblies by anchor type, and the matching three UI assemblies via Assembly.Load. The base scans each for the two shapes it understands, MVC controllers (any type whose base chain reaches ControllerBase, AnonymousEndpointTestsBase.cs:101-:112) and routable Blazor components (any type carrying a RouteAttribute, :117-:118), both matched by attribute full name so the rule library keeps no ASP.NET reference (:26-:28, :32-:34).
      • +
      • AllowedAnonymousEndpoints (:33-:103) is the reviewed list, grouped by justification rather than alphabetically. Two Identity credential-exchange actions on AuthController, LoginAsync and RegisterAsync, because requiring a token to mint one would be circular; they are throttled by the auth-ip rate-limit policy instead (:35-:39). Then the Conference public-browse reads: the GetAllAsync / GetAllForLookupAsync / GetByIdAsync triple on thirteen agenda controllers (ActivitiesController, EventsController, SessionsController, SpeakersController, SponsorsController, and their category/lookup siblings, :45-:83), because the conference website is readable without an account and is output-cached per ADR-040; the comment is careful to note that every create/update/delete on those same controllers stays behind the class-level [HasPermission] (:43-:44). Then three smaller families with their own reasons: the Now/Next wayfinding reads (:85-:88), the ICS calendar exports a calendar client fetches without a bearer token (:90-:93), and the aggregate bookmark counts behind the popularity badge, counts only and never a per-user list (:95-:98). Last, the type-level entry for ServiceInfoController, which must answer before a caller has a token to negotiate with (:100-:102). Type-level and method-level attributes use different identifier shapes, a bare FullName versus FullName.MethodName (AnonymousEndpointTestsBase.cs:39-:44), which is why that last entry has no method suffix.
      • +
      • MinimumScannedTypes => 79 (:108) raises the base floor of 1 (AnonymousEndpointTestsBase.cs:51) to the exact count of controller and routable-component types across the six assemblies today, so a renamed assembly or a dropped surface is a failure rather than a quietly smaller scan (:105-:107).
      • +
      +
    • +
    • Why it's built this way - the class comment explains what the absences mean, which is the part a reader cannot infer. The two password-recovery actions on PasswordResetController are anonymous for the same circularity reason as login, but ADC does not override them, so the framework base owns their allowlist entries and none appear here (:11-:15); the same is true of refresh (:37). The base reads attributes with DeclaredOnly and inherit: false precisely so an inherited framework action is reported once at its declaration site rather than once per derived controller in every consumer (AnonymousEndpointTestsBase.cs:140-:142). Notification is absent because that module ships no controller and no routable component, hosting only the SignalR hub, so it contributes nothing to the scan (:16-:19).
    • +
    • Caveats / not-in-source - the base states its own blind spot: minimal-API endpoints opt out through the .AllowAnonymous() builder call, which produces endpoint metadata at map time and is invisible to static reflection, so the framework's own small anonymous minimal-API surface (JWKS, OIDC discovery, app-association, session-cookie refresh, health) is not covered here (AnonymousEndpointTestsBase.cs:18-:24). Nothing recomputes the 79 floor, so it is a lower bound a human maintains.
    • +
    +

    ProtoContractTests

    +
    +

    MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/ProtoContractTests.cs:3 · Level 6 · class (public, sealed)

    +
    +
      +
    • What it is - the frozen wire contract for ADC's synchronous cross-service API. It names the seven .proto files the four *.Contracts projects compile and commits a 75-line snapshot of everything they declare, so a renumbered field or a renamed rpc fails the build (MMCA.ADC.Architecture.Tests/ProtoContractTests.cs:9-:18, :20-:97).
    • +
    • Depends on - ProtoContractTestsBase. Nothing else: the rule reads .proto files off disk from the repo root, so this class takes no map and the csproj needs no reference to the *.Contracts projects.
    • +
    • Concept introduced, pinning a contract that no compiler checks. A .proto file is a published contract between processes that are built separately, so nothing in a single repo's build notices when it changes incompatibly. The rule library rebuilds the live contract by parsing the files and diffs it against the committed list, reporting each side separately as "present but NOT frozen" and "frozen but NOT present" (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Protos.cs:67-:77). What gets pinned is exactly the wire surface: the package, every rpc with its request/response types and streaming flags, every message field with its declared type, label, and field number, and every enum value with its number (ArchitectureRules.Protos.cs:20-:25). What is deliberately not pinned is syntax, import, and option lines including csharp_namespace, because none of them changes a byte on the wire and failing on them is the fastest way to teach a team to update a snapshot without reading it (:26-:31). [Rubric §9 - API & Contract Design] assesses contract governance across the whole surface, not just REST. [Rubric §7 - Microservices Readiness]: this list is the synchronous coupling between ADC's four services, in the same way IntegrationEventContractTests is the asynchronous one (ADR-007).
    • +
    • Walkthrough - three members, all implementing abstract hooks.
        +
      • SolutionFileName => "MMCA.ADC.slnx" (:5) implements ProtoContractTestsBase.SolutionFileName (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/ProtoContractTestsBase.cs:22). The rule resolves the repo root from it via ArchitectureMapBase.FindRepoRoot (ArchitectureRules.Protos.cs:45), so the files are read from the working tree regardless of the runner's working directory.
      • +
      • ProtoFiles (:9-:18) lists seven repo-root-relative paths, and the comment states the scope rule: every .proto compiled by the four *.Contracts projects (:7-:8). Two from Conference (event_live_validation, session_bookmark_validation), two from Engagement (bookmark_count, user_engagement_export), one from Identity (attendee_query), and two from Notification (live_channel, user_notification_export). That is every .proto file in the repository today.
      • +
      • FrozenProtoContracts (:20-:97) is the snapshot: 63 message ... lines, one per field, each ending in = <number> : <type>, and 12 service ... lines, one per rpc. The entries are sorted, which is what makes a regenerated snapshot diff line by line, and the base's <remarks> explains how to regenerate it (print ArchitectureRules.BuildProtoContract(...) and paste, ProtoContractTestsBase.cs:12-:17). Reading the list is the fastest way to see what ADC's services actually say to each other: live-window validation and current-room lookup from Conference, bookmark counts and the GDPR engagement export from Engagement, the attendee user-id list from Identity, and channel push plus the notification export from Notification.
      • +
      • The single inherited fact is ProtoContracts_ShouldMatch_TheFrozenSnapshot (ProtoContractTestsBase.cs:33).
      • +
      +
    • +
    • Why it's built this way - the base is explicit that this is consumer-facing only: MMCA.Common ships the gRPC plumbing but no .proto of its own, so the framework does not subclass it, and a repo with a *.Contracts project does (ProtoContractTestsBase.cs:8-:11). Note also that Notification's protos are pinned here even though the Notification module is absent from AdcArchitectureMap: this rule works from file paths, not from mapped assemblies, so the thin module is covered for free.
    • +
    • Caveats / not-in-source - the file list is hand-maintained, so a brand-new .proto added to a *.Contracts project is not pinned until someone lists it here. Nothing asserts that ProtoFiles covers every .proto in the tree.
    • +

    TranslationCompletenessTests

    -

    MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/TranslationCompletenessTests.cs:12 · Level 5 · class (public, sealed)

    +

    MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/TranslationCompletenessTests.cs:12 · Level 6 · class (public, sealed)

    • What it is - the internationalization completeness gate: every base *.resx under Source/ must have a complete, non-empty Spanish .es.resx sibling, so adding an English key without its translation fails CI instead of shipping a half-translated UI (MMCA.ADC.Architecture.Tests/TranslationCompletenessTests.cs:3-:11).
    • Depends on - LocalizationResourceTestsBase. Note the deliberate name divergence: the ADC subclass is named for what it guarantees (translation completeness), not for the base it derives from.
    • -
    • Concept introduced, the non-vacuity floor. A convention scan that discovers nothing passes trivially, which is the failure mode that makes fitness functions untrustworthy over time. The MMCA bases answer it with a minimum-count floor that the subclass raises to the repo's real magnitude, so a broken scan root (a moved directory, a renamed convention, a case-sensitivity slip on the Ubuntu runner) fails loudly instead of going green while checking zero files. You will see this floor again in FormsConventionTests and LocalizedTextConventionTests. [Rubric §27 - i18n] assesses whether localization is enforced rather than aspirational; the gate is the enforcement, and ADR-027 (which supersedes the single-locale ADR-011) is the decision it executes.
    • +
    • Concept introduced, the non-vacuity floor. A convention scan that discovers nothing passes trivially, which is the failure mode that makes fitness functions untrustworthy over time. The MMCA bases answer it with a minimum-count floor that the subclass raises to the repo's real magnitude, so a broken scan root (a moved directory, a renamed convention, a case-sensitivity slip on the Ubuntu runner) fails loudly instead of going green while checking zero files. You have already seen the floor in AnonymousEndpointTests, and you will see it again in FormsConventionTests and LocalizedTextConventionTests. [Rubric §27 - i18n] assesses whether localization is enforced rather than aspirational; the gate is the enforcement, and ADR-027 (which supersedes the single-locale ADR-011) is the decision it executes.
    • Walkthrough - two members. RequiredCultures => ["es"] (TranslationCompletenessTests.cs:14) implements the base's abstract culture list (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/LocalizationResourceTestsBase.cs:13), so Spanish is the one culture ADC contractually completes. MinimumBaseResources => 40 (TranslationCompletenessTests.cs:16) raises the base's default of 0 (LocalizationResourceTestsBase.cs:21), which would otherwise let an empty scan pass. The inherited fact is Translations_AreComplete_ForEveryRequiredCulture (LocalizationResourceTestsBase.cs:24).
    • Why it's built this way - the class comment justifies the floor from the repo's real shape: ADC has 40 or more localized resource sets across the three module UIs, the UI hosts' landing page, the nav-item module descriptors, and the API error-resource sets, so a near-zero discovery count means the scan path is wrong (TranslationCompletenessTests.cs:8-:10).
    • Caveats / not-in-source - the floor is a lower bound stated in the subclass, not a count computed from the tree, so it stays correct only as long as someone raises it when the resource set grows materially.
    +

    DecoratorPipelineOrderTests

    +
    +

    MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/DecoratorPipelineOrderTests.cs:27 · Level 9 · class (public, sealed)

    +
    +
      +
    • What it is - the one type in this unit that builds a real DI container instead of reading metadata. It asserts that ADC's genuine registration sequence produces the ADR-014 decorator nesting at runtime, exercised against a real Identity command/query pair (MMCA.ADC.Architecture.Tests/DecoratorPipelineOrderTests.cs:17-:28).
    • +
    • Depends on - DecoratorPipelineOrderTestsBase<TCommand, TCommandResult, TQuery, TQueryResult> from the MMCA.Common.Testing package (MMCA.ADC.Architecture.Tests.csproj:43), closed over ChangePreferencesCommand / Result and GetUserPreferencesQuery / Result<UserPreferencesResponse> (DecoratorPipelineOrderTests.cs:28). Externals: Microsoft.Extensions.DependencyInjection, Microsoft.FeatureManagement, NullLogger<>, and Moq (:1-:13).
    • +
    • Concept introduced, an object-graph assertion. Scrutor's TryDecorate applies decorators in reverse registration order, so the last decorator registered becomes the outermost wrapper. That makes an innocent-looking reorder of the AddApplicationDecorators() lines, or a module handler scan that runs after it instead of before, a silent change in runtime behavior: the code still compiles, the container still resolves, and the pipeline quietly runs validation after the transaction opens. The base turns that into a test failure by resolving the handler and walking the constructed graph via reflection over each decorator's private inner-handler field, so it verifies the objects that actually exist rather than the registration list (MMCA.Common/Source/Hosting/MMCA.Common.Testing/DecoratorPipelineOrderTestsBase.cs:29-:32, :99-:124). [Rubric §2 - Design Patterns] assesses whether patterns are applied deliberately and correctly; the decorator chain is the framework's central pattern and this is the only test that proves its composition. [Rubric §14 - Testability]: the fact that a production registration sequence can be replayed in a bare ServiceCollection with seven mocked dependencies is itself the evidence that the composition root is not entangled with hosting.
    • +
    • Walkthrough - one member, ConfigureServices(IServiceCollection) (DecoratorPipelineOrderTests.cs:30), which implements the base's single abstract hook (DecoratorPipelineOrderTestsBase.cs:46) and reads in two halves.
        +
      • Test doubles for the decorator constructor dependencies (:33-:39): Mock.Of<IFeatureManager>(), Mock.Of<ICurrentUserService>(), Mock.Of<IPermissionRegistry>(), Mock.Of<ICorrelationContext>(), and Mock.Of<ICacheService>() as singletons, a scoped IUnitOfWork factory, and the open generic ILogger<> mapped to NullLogger<>. These exist only so the decorators can be constructed; the test never invokes a handler. The base names the same seven dependencies as the contract for a subclass (DecoratorPipelineOrderTestsBase.cs:22-:25).
      • +
      • The real registration sequence (:43-:45): AddApplication(), then ScanModuleApplicationServices<MMCA.ADC.Identity.Application.ClassReference>(), then AddApplicationDecorators() last. The comment states the load-bearing constraint plainly (:41-:42): TryDecorate can only wrap handlers already registered.
      • +
      • The two inherited facts then assert the chains. CommandPipeline_NestsDecorators_InAdr014Order (DecoratorPipelineOrderTestsBase.cs:71) expects FeatureGate, Authorization, Logging, Caching, Validating, Timeout, Transactional, then the concrete handler (:49-:58); QueryPipeline_NestsDecorators_InAdr014Order (:75) expects FeatureGate, Authorization, Logging, Caching, Timeout, then the handler (:61-:68). Both are asserted by AssertPipeline (:78-:97), which compares every element except the last against the expected list (:92-:93) and then requires the innermost element not to end in "Decorator" (:95-:96), so a truncated chain cannot pass. ADC overrides neither expected list, so it accepts the framework default order as its contract.
      • +
      +
    • +
    • Why it's built this way - the pair was chosen for realism rather than convenience. ChangePreferencesCommand and its handler are shipped ADC Identity use cases, while GetUserPreferencesQuery is declared in MMCA.Common.Application and its concrete GetUserPreferencesHandler lives in ADC on top of Common's GetUserPreferencesHandlerBase<TUser>. So the scan-then-decorate ordering is exercised across the framework and app boundary rather than against a fixture, which is exactly what the base asks a subclass to supply (DecoratorPipelineOrderTestsBase.cs:26-:27).
    • +
    • Where it's used - an independent class in ADC's architecture suite; nothing consumes it.
    • +
    • Caveats / not-in-source - the chain is unwrapped by reading compiler-generated private fields (DecoratorPipelineOrderTestsBase.cs:104-:124), so a future decorator that stores its inner handler somewhere other than a field (a property-only or captured-closure design) would be invisible to the walk. The base flags the reflection strategy explicitly (:29-:32).
    • +

    AdcArchitectureMap

    -

    MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/AdcArchitectureMap.cs:8 · Level 9 · class (internal, sealed)

    +

    MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/AdcArchitectureMap.cs:8 · Level 12 · class (internal, sealed)

    • What it is - the single declaration of what "the ADC architecture" is, in assembly terms: five MMCA.Common framework layers plus the Identity, Conference, and Engagement modules at six layers each, 23 entries in all (MMCA.ADC.Architecture.Tests/AdcArchitectureMap.cs:12-:44). Every map-driven rule in this unit scans exactly the assemblies listed here.
    • @@ -3651,41 +4293,40 @@

      AdcArchitectureMap

  • Why it's built this way - centralizing every namespace and assembly string in one file also fixes Ubuntu CI case sensitivity in one place, which the base states as an explicit goal (ArchitectureMapBase.cs:7-:9). Compare CommonArchitectureMap, the same abstraction for a repo with no business modules.
  • -
  • Where it's used - instantiated as a field initializer by every map-driven subclass in this unit (25 of the 30 types here, all of them at Level 10), for example ConcurrencyConventionTests.cs:5. It is internal, so it never leaves this assembly.
  • -
  • Caveats / not-in-source - the thin Notification module (API plus Application only) is deliberately absent from the map, so the module-shaped rules do not cover it; RawQueryableConventionTests is the one rule that re-adds Notification by hand, and it says why (RawQueryableConventionTests.cs:16-:20). Nothing in this repository asserts that the map lists every module that exists, so a fourth mapped module would have to be added here by a human.
  • +
  • Where it's used - instantiated as a field initializer by every map-driven subclass in this unit (27 of the 35 types here, all of them at Level 13), for example ConcurrencyConventionTests.cs:5. It is internal, so it never leaves this assembly. The eight non-map types are the two embedded-resource guards, AnonymousEndpointTests, ProtoContractTests, TranslationCompletenessTests, the two pipeline-order tests, and the map itself.
  • +
  • Caveats / not-in-source - the thin Notification module (API plus Application only) is deliberately absent from the map, so the module-shaped rules do not cover it. Two rules re-add it by hand and each says why: RawQueryableConventionTests appends its Application directory (RawQueryableConventionTests.cs:16-:20), and ProtoContractTests pins its protos by path. Nothing in this repository asserts that the map lists every module that exists, so a fourth mapped module would have to be added here by a human.
  • -

    DecoratorPipelineOrderTests

    +

    MiddlewarePipelineOrderTests

    -

    MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/DecoratorPipelineOrderTests.cs:26 · Level 9 · class (public, sealed)

    +

    MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/MiddlewarePipelineOrderTests.cs:15 · Level 12 · class (public, sealed)

      -
    • What it is - the one type in this unit that builds a real DI container instead of reading metadata. It asserts that ADC's genuine registration sequence produces the ADR-014 decorator nesting at runtime, exercised against a real Identity command/query pair (MMCA.ADC.Architecture.Tests/DecoratorPipelineOrderTests.cs:17-:27).
    • -
    • Depends on - DecoratorPipelineOrderTestsBase<TCommand, TCommandResult, TQuery, TQueryResult> from the MMCA.Common.Testing package (MMCA.ADC.Architecture.Tests.csproj:43), closed over ChangePreferencesCommand / Result and GetUserPreferencesQuery / Result<UserPreferencesResponse> (DecoratorPipelineOrderTests.cs:27). Externals: Microsoft.Extensions.DependencyInjection, Microsoft.FeatureManagement, NullLogger<>, and Moq (:1-:13).
    • -
    • Concept introduced, an object-graph assertion. Scrutor's TryDecorate applies decorators in reverse registration order, so the last decorator registered becomes the outermost wrapper. That makes an innocent-looking reorder of the AddApplicationDecorators() lines, or a module handler scan that runs after it instead of before, a silent change in runtime behavior: the code still compiles, the container still resolves, and the pipeline quietly runs validation after the transaction opens. The base turns that into a test failure by resolving the handler and walking the constructed graph via reflection over each decorator's private inner-handler field, so it verifies the objects that actually exist rather than the registration list (MMCA.Common/Source/Hosting/MMCA.Common.Testing/DecoratorPipelineOrderTestsBase.cs:27-:30, :98-:118). [Rubric §2 - Design Patterns] assesses whether patterns are applied deliberately and correctly; the decorator chain is the framework's central pattern and this is the only test that proves its composition. [Rubric §14 - Testability]: the fact that a production registration sequence can be replayed in a bare ServiceCollection with five mocked dependencies is itself the evidence that the composition root is not entangled with hosting.
    • -
    • Walkthrough - one member, ConfigureServices(IServiceCollection) (DecoratorPipelineOrderTests.cs:29), which implements the base's single abstract hook (DecoratorPipelineOrderTestsBase.cs:44) and reads in two halves.
        -
      • Test doubles for the decorator constructor dependencies (:32-:36): Mock.Of<IFeatureManager>(), Mock.Of<ICorrelationContext>(), and Mock.Of<ICacheService>() as singletons, a scoped IUnitOfWork factory, and the open generic ILogger<> mapped to NullLogger<>. These exist only so the decorators can be constructed; the test never invokes a handler.
      • -
      • The real registration sequence (:40-:42): AddApplication(), then ScanModuleApplicationServices<MMCA.ADC.Identity.Application.ClassReference>(), then AddApplicationDecorators() last. The comment states the load-bearing constraint plainly (:38-:39): TryDecorate can only wrap handlers already registered.
      • -
      • The two inherited facts then assert the chains. CommandPipeline_NestsDecorators_InAdr014Order (DecoratorPipelineOrderTestsBase.cs:65) expects FeatureGate, Logging, Caching, Validating, Transactional, then the concrete handler; QueryPipeline_NestsDecorators_InAdr014Order (:69) expects FeatureGate, Logging, Caching, then the handler (:47-:62). Both also assert the innermost element does not end in "Decorator" (:89-:90), so a truncated chain cannot pass.
      • +
      • What it is - the HTTP-edge counterpart of DecoratorPipelineOrderTests, and, like ObservabilityConventionTests, a bodyless declaration: public sealed class MiddlewarePipelineOrderTests : MiddlewarePipelineOrderTestsBase; (MMCA.ADC.Architecture.Tests/MiddlewarePipelineOrderTests.cs:15). Deriving it is the whole assertion.
      • +
      • Depends on - MiddlewarePipelineOrderTestsBase from the MMCA.Common.Testing package (using MMCA.Common.Testing;, :1), and transitively on MiddlewarePipelineBuilder and MiddlewarePipelineStepNames.
      • +
      • Concept introduced, an empty subclass as a conformance claim. The class comment states what the emptiness means (:5-:14): every ADC REST and gRPC service host calls the zero-argument UseCommonMiddlewarePipeline(), so the framework's default step order is ADC's contract and the base needs no overrides. The two hooks that exist are the escape hatch a host would use if it customized the pipeline: Configure, which defaults to null (MMCA.Common/Source/Hosting/MMCA.Common.Testing/MiddlewarePipelineOrderTestsBase.cs:35), and ExpectedStepNames (:38-:58). Leaving both alone is a positive statement, not an omission. [Rubric §10 - Cross-Cutting] assesses whether cross-cutting behavior is applied uniformly rather than per host; [Rubric §11 - Security] is the sharp edge, because the ordering invariants below are authentication and rate-limiting invariants.
      • +
      • Walkthrough - no members. Two inherited facts run against a builder seeded from MiddlewarePipelineBuilder.CreateDefault() (MiddlewarePipelineOrderTestsBase.cs:79-:84).
          +
        • EdgePipeline_OrdersSteps_InDocumentedOrder (:61) compares builder.StepNames against the 18-step default sequence, outermost first: exception handler, correlation id, request localization, pre-forwarded capture, forwarded headers, HTTPS redirection, response compression, routing, CORS, authentication, tenant resolution, rate limiting, soft-deleted-user filter, authorization, output cache, JWKS endpoint, OIDC discovery endpoint, controllers (:40-:57).
        • +
        • EdgePipeline_SatisfiesLoadBearingInvariants (:70) calls Build() and requires it not to throw, because Build() re-checks the load-bearing adjacencies at startup, so a pipeline that failed here would have thrown while the host was starting (:73-:76).
        • +
        • Four adjacencies are named as load-bearing in both the base and the ADC comment: the pre-forwarded capture immediately before the forwarded-headers rewrite (or jwks_uri stops being reachable), authentication immediately before tenant resolution (so the claim strategy sees HttpContext.User), authentication before the rate limiter per ADR-019 (so the per-user cap engages), and forwarded headers before the HTTPS redirect (MiddlewarePipelineOrderTests.cs:9-:13, MiddlewarePipelineOrderTestsBase.cs:66).
      • -
      • Why it's built this way - the pair was chosen for realism rather than convenience: ChangePreferencesCommand and GetUserPreferencesQuery are shipped Identity use cases, and the query's handler lives in MMCA.Common while the command's lives in ADC, so the scan-then-decorate ordering is exercised across the framework and app boundary rather than against a fixture.
      • -
      • Where it's used - an independent class in ADC's architecture suite; nothing consumes it.
      • -
      • Caveats / not-in-source - the chain is unwrapped by reading compiler-generated private fields, so a future decorator that stores its inner handler somewhere other than a field (a property-only or captured-closure design) would be invisible to the walk. The base flags the reflection strategy explicitly (DecoratorPipelineOrderTestsBase.cs:93-:97).
      • +
      • Why it's built this way - a reorder here fails at runtime in ways that look like configuration bugs: an unreachable jwks_uri, a tenant that never resolves, a per-user rate cap that never engages (MiddlewarePipelineOrderTestsBase.cs:16-:18). Making it a red test in the consumer repo is what turns a framework-side reorder into a build failure rather than a silent production behavior change (ADR-079). No WebApplication is built: the steps are pure data until they are applied, so this runs in the fast unit tier with no database and no host (:24-:27).
      • +
      • Caveats / not-in-source - the test asserts the framework default, seeded from CreateDefault(). It does not read any ADC Program.cs, so the claim that every ADC host calls the zero-argument overload is asserted by the comment, not by this test. A host that started passing a customization would silently fall outside this gate unless someone also overrode Configure here.

      ConcurrencyConventionTests

      -

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/ConcurrencyConventionTests.cs:3 · Level 10 · class (public, sealed)

      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/ConcurrencyConventionTests.cs:3 · Level 13 · class (public, sealed)

        -
      • What it is - the guard that every update request participates in optimistic concurrency. It is also the plainest example of the Level 10 shape in this unit: a sealed class whose entire body is one line supplying the map (MMCA.ADC.Architecture.Tests/ConcurrencyConventionTests.cs:5).
      • +
      • What it is - the guard that every update request participates in optimistic concurrency. It is also the plainest example of the Level 13 shape in this unit: a sealed class whose entire body is one line supplying the map (MMCA.ADC.Architecture.Tests/ConcurrencyConventionTests.cs:5).
      • Depends on - ConcurrencyConventionTestsBase and AdcArchitectureMap.
      • -
      • Concept introduced, the map-only subclass. Seventeen types in this unit are exactly this: protected override IArchitectureMap Map { get; } = new AdcArchitectureMap(); and nothing else. Note the property is an auto-property with an initializer, not an expression body, so each class constructs its map once per test-class instance rather than per fact. Everything else (the rule bodies, the [Fact] attributes, the failure messages) is inherited, which is precisely the point: MMCA.Common, MMCA.Store, and MMCA.ADC run byte-identical rule logic and differ only in what they point it at. The sections below for the other map-only subclasses do not repeat this explanation; they name the base and list what it asserts. [Rubric §16 - Maintainability] assesses duplication and change cost: a new rule ships to all three repos by adding a base and one derived line per repo.
      • -
      • Walkthrough - one member, Map (:5). The inherited fact is UpdateRequests_ShouldImplement_IConcurrencyAware (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/ConcurrencyConventionTestsBase.cs:13), which delegates to ArchitectureRules.UpdateRequestsAreConcurrencyAware(Map). [Rubric §8 - Data Architecture]: an update request that does not carry a row version cannot detect a lost update, so this rule keeps IConcurrencyAware from being optional in practice.
      • +
      • Concept introduced, the map-only subclass. Nineteen of the 27 map-driven types in this unit are exactly this: protected override IArchitectureMap Map { get; } = new AdcArchitectureMap(); and nothing else. Note the property is an auto-property with an initializer, not an expression body, so each class constructs its map once per test-class instance rather than per fact. Everything else (the rule bodies, the [Fact] attributes, the failure messages) is inherited, which is precisely the point: MMCA.Common, MMCA.Store, and MMCA.ADC run byte-identical rule logic and differ only in what they point it at. The sections below for the other map-only subclasses do not repeat this explanation; they name the base and list what it asserts. [Rubric §16 - Maintainability] assesses duplication and change cost: a new rule ships to all three repos by adding a base and one derived line per repo.
      • +
      • Walkthrough - one member, Map (:5), implementing the base's abstract property (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/ConcurrencyConventionTestsBase.cs:10). The inherited fact is UpdateRequests_ShouldImplement_IConcurrencyAware (:13), which delegates to ArchitectureRules.UpdateRequestsAreConcurrencyAware(Map). [Rubric §8 - Data Architecture]: an update request that does not carry a row version cannot detect a lost update, so this rule keeps IConcurrencyAware from being optional in practice.
      • Where it's used - runs with the whole suite in the build-and-test job (MMCA.ADC/.github/workflows/deploy.yml:124, :219). The same is true of every remaining type in this unit and is not repeated below.

      ConstructorDependencyCountTests

      -

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/ConstructorDependencyCountTests.cs:17 · Level 10 · class (public, sealed)

      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/ConstructorDependencyCountTests.cs:17 · Level 13 · class (public, sealed)

      • What it is - a single-responsibility ceiling: no service in a mapped module Application assembly may take more than seven constructor dependencies (MMCA.ADC.Architecture.Tests/ConstructorDependencyCountTests.cs:19-:21).
      • @@ -3697,7 +4338,7 @@

        ConstructorDependencyCountTests

      ControllerConventionTests

      -

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/ControllerConventionTests.cs:3 · Level 10 · class (public, sealed)

      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/ControllerConventionTests.cs:3 · Level 13 · class (public, sealed)

      • What it is - the API-layer convention guard, with a two-entry exemption list for the controllers that legitimately do not route through the framework's base controller (MMCA.ADC.Architecture.Tests/ControllerConventionTests.cs:11-:15).
      • @@ -3708,17 +4349,17 @@

        ControllerConventionTests

      DataResidencyTests

      -

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/DataResidencyTests.cs:12 · Level 10 · class (public, sealed)

      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/DataResidencyTests.cs:12 · Level 13 · class (public, sealed)

      • What it is - a compliance drift guard: the data-residency statement published in ADC's PRIVACY.md must match the Azure region where personal data is actually provisioned, parsed out of the deployment workflow (MMCA.ADC.Architecture.Tests/DataResidencyTests.cs:3-:11).
      • Depends on - DataResidencyTestsBase, AdcArchitectureMap, System.IO.File/Path, and AwesomeAssertions (used directly inside the override, :26).
      • -
      • Concept introduced, a test as the join between a document and an infrastructure fact. Most of the rules in this unit compare code to code. This one compares prose to infrastructure: it reads the deployed region out of the source of truth (SQL_LOCATION="${SQL_LOCATION_OVERRIDE:-westus2}", MMCA.ADC/.github/workflows/deploy.yml:949) and then requires PRIVACY.md to say the same thing, comparing whitespace-insensitively and case-insensitively so "West US 2" matches the westus2 region token (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/DataResidencyTestsBase.cs:55-:58). [Rubric §30 - Compliance/Privacy/Data Governance] assesses whether privacy claims are true and stay true; a policy that names a region the data never lived in is a compliance defect that no code review would catch, and this is the mechanism that closes it.
      • +
      • Concept introduced, a test as the join between a document and an infrastructure fact. Most of the rules in this unit compare code to code. This one compares prose to infrastructure: it reads the deployed region out of the source of truth (SQL_LOCATION="${SQL_LOCATION_OVERRIDE:-westus2}", MMCA.ADC/.github/workflows/deploy.yml:949) and then requires PRIVACY.md to say the same thing, comparing whitespace-insensitively and case-insensitively so "West US 2" matches the westus2 region token. [Rubric §30 - Compliance/Privacy/Data Governance] assesses whether privacy claims are true and stay true; a policy that names a region the data never lived in is a compliance defect that no code review would catch, and this is the mechanism that closes it.
      • Walkthrough - three members.
          -
        • Map (:14) exists only so the base can resolve the repo root through ArchitectureMapBase.FindRepoRoot($"{Map.RepoToken}.slnx") (DataResidencyTestsBase.cs:28); no assembly scanning happens in this rule.
        • -
        • ForbiddenResidencyClaims => ["central United States"] (:16) overrides the base's empty default (DataResidencyTestsBase.cs:23) and blocks a specific stale statement from returning, one that once contradicted the deployed region (DataResidencyTests.cs:9-:10).
        • -
        • ExtractDeployedRegion(string repoRoot) (:20-:31) implements the base's abstract hook (DataResidencyTestsBase.cs:53). It reads .github/workflows/deploy.yml (:22), locates the literal marker SQL_LOCATION_OVERRIDE:- with an ordinal IndexOf (:24-:25), asserts the marker exists with a because explaining what the workflow must declare (:26-:27), then takes the alphanumeric run that follows as the region (:29-:30). Assert-then-parse rather than return-empty is exactly what the base asks implementations to do (DataResidencyTestsBase.cs:47-:52).
        • -
        • The inherited fact PrivacyPolicy_DataStorageRegion_MatchesDeployedRegion (DataResidencyTestsBase.cs:26) then asserts the normalized policy contains the normalized region (:37) and contains none of the forbidden claims (:40-:44).
        • +
        • Map (:14) exists only so the base can resolve the repo root through ArchitectureMapBase.FindRepoRoot($"{Map.RepoToken}.slnx"); no assembly scanning happens in this rule.
        • +
        • ForbiddenResidencyClaims => ["central United States"] (:16) overrides the base's empty default (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/DataResidencyTestsBase.cs:23) and blocks a specific stale statement from returning, one that once contradicted the deployed region (DataResidencyTests.cs:9-:10).
        • +
        • ExtractDeployedRegion(string repoRoot) (:20-:31) implements the base's abstract hook (DataResidencyTestsBase.cs:53). It reads .github/workflows/deploy.yml (:22), locates the literal marker SQL_LOCATION_OVERRIDE:- with an ordinal IndexOf (:24-:25), asserts the marker exists with a because explaining what the workflow must declare (:26-:27), then takes the alphanumeric run that follows as the region (:29-:30). Assert-then-parse rather than return-empty is what the base asks implementations to do.
        • +
        • The inherited fact PrivacyPolicy_DataStorageRegion_MatchesDeployedRegion (DataResidencyTestsBase.cs:26) then asserts the normalized policy contains the normalized region and contains none of the forbidden claims.
      • Why it's built this way - the account data and session bookmarks live in the Azure SQL database, and the QiMata Sponsorship subscription forces that SQL server into a different region from the Container Apps (DataResidencyTests.cs:5-:9), so "where the app runs" is genuinely not "where the personal data sits". Parsing the SQL region default rather than the app region encodes that distinction.
      • @@ -3726,7 +4367,7 @@

        DataResidencyTests

      DomainPurityTests

      -

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/DomainPurityTests.cs:3 · Level 10 · class (public, sealed)

      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/DomainPurityTests.cs:3 · Level 13 · class (public, sealed)

      • What it is - the Clean Architecture purity guard, plus one repo-specific addition: RabbitMQ is added to the forbidden-dependency list for Domain and Shared (MMCA.ADC.Architecture.Tests/DomainPurityTests.cs:9).
      • @@ -3736,7 +4377,7 @@

        DomainPurityTests

      EntityConventionTests

      -

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/EntityConventionTests.cs:3 · Level 10 · class (public, sealed)

      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/EntityConventionTests.cs:3 · Level 13 · class (public, sealed)

      • What it is - the DDD entity-shape guard for ADC's three module domains: aggregate roots exist, each has a Result-returning static factory and no public constructor, domain entities are sealed and live in the Domain layer, and DTOs or requests do not.
      • @@ -3745,24 +4386,24 @@

        EntityConventionTests

      EventConventionTests

      -

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/EventConventionTests.cs:3 · Level 10 · class (public, sealed)

      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/EventConventionTests.cs:3 · Level 13 · class (public, sealed)

        -
      • What it is - the integration-event shape guard: every integration event declares a schema version, inherits the framework's base integration event, and lives in an *.IntegrationEvents namespace under Shared.
      • +
      • What it is - the integration-event shape guard: every integration event declares a schema version, inherits the framework's base integration event, and lives in an *.IntegrationEvents namespace under Shared; and every event upcaster is unique and moves the version forward.
      • Depends on - EventConventionTestsBase and AdcArchitectureMap. Map-only shape (ConcurrencyConventionTests).
      • -
      • Walkthrough - one member, Map (:5). Three inherited facts (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/EventConventionTestsBase.cs:13, :16, :19) enforce SchemaVersion, base-type inheritance, and namespace placement (ADR-010). [Rubric §6 - CQRS & Event-Driven] assesses the discipline around asynchronous contracts; this rule handles the shape, while IntegrationEventContractTests freezes the content.
      • +
      • Walkthrough - one member, Map (:5). Five inherited facts (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/EventConventionTestsBase.cs:14, :17, :20, :23, :26). The first three enforce SchemaVersion, base-type inheritance, and namespace placement (ADR-010). The last two guard the upcaster machinery that ADR-010 depends on: EventUpcasters_ShouldHave_UniqueSourceTypes (:23), so two upcasters cannot claim the same source shape, and EventUpcasters_ShouldIncrease_SchemaVersion (:26), so an upcaster cannot map an event onto the same or an earlier version. [Rubric §6 - CQRS & Event-Driven] assesses the discipline around asynchronous contracts; this rule handles the shape, while IntegrationEventContractTests freezes the content.

      FormsConventionTests

      -

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/FormsConventionTests.cs:14 · Level 10 · class (public, sealed)

      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/FormsConventionTests.cs:14 · Level 13 · class (public, sealed)

      • What it is - the UX-safety guard over ADC's admin forms. It configures the shared rule for the six Conference create forms and adds a hand-written fact for the Identity Profile form, which by design does not match the shared rule's glob (MMCA.ADC.Architecture.Tests/FormsConventionTests.cs:3-:13).
      • Depends on - FormsConventionTestsBase, AdcArchitectureMap, ArchitectureMapBase (called statically for the repo root, :34), System.IO, and AwesomeAssertions.
      • -
      • Concept introduced, extending a rule instead of replacing it, and covering what it cannot reach. Two mechanisms appear here for the first time in this unit. First, RequiredMarkers is overridden by spreading the base list and appending to it (.. base.RequiredMarkers, :26), so ADC inherits the six framework markers (UnsavedChangesGuard, IsDirtyAccessor, _isDirty, <MudForm, Required="true", RequiredError; MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/FormsConventionTestsBase.cs:27-:35) and adds two of its own without restating them. Second, when a real surface falls outside the shared rule's reach, the subclass writes the missing coverage itself rather than loosening the shared rule. [Rubric §24 - Forms/Validation/UX Safety] assesses whether users are protected from losing work and from unclear validation; both halves here are that protection made executable.
      • +
      • Concept introduced, extending a rule instead of replacing it, and covering what it cannot reach. Two mechanisms appear here for the first time in this unit. First, RequiredMarkers is overridden by spreading the base list and appending to it (.. base.RequiredMarkers, :26), so ADC inherits the framework markers (UnsavedChangesGuard, IsDirtyAccessor, _isDirty, <MudForm, Required="true", RequiredError; MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/FormsConventionTestsBase.cs:27-:35) and adds two of its own without restating them. Second, when a real surface falls outside the shared rule's reach, the subclass writes the missing coverage itself rather than loosening the shared rule. [Rubric §24 - Forms/Validation/UX Safety] assesses whether users are protected from losing work and from unclear validation; both halves here are that protection made executable.
      • Walkthrough
        • Map (:16) and MinimumCreateForms => 6 (:18), raising the base floor of 1 (FormsConventionTestsBase.cs:24) to ADC's real count: Event, Session, Room, Question, Speaker, and ConferenceCategory (FormsConventionTests.cs:5-:6).
        • -
        • RequiredMarkers (:24-:29) appends two literals to the inherited set: the per-form <MudAlert Severity="Severity.Error" error summary and the localized heading key Validation.CorrectFollowing.
        • +
        • RequiredMarkers (:24-:29) appends two literals to the inherited set: the per-form <MudAlert Severity="Severity.Error" error summary and the localized heading key Validation.CorrectFollowing. The inherited fact that consumes them is AdminCreateForms_KeepUnsavedChangesGuardAndValidation (FormsConventionTestsBase.cs:38).
        • ProfileForm_KeepsErrorSummaryAndPasswordValidation (:31-:62) is the only hand-written [Fact] in this unit. It resolves the repo root from the map's token (:34), builds the path to Source/Modules/Identity/MMCA.ADC.Identity.UI/Pages/Profile/Profile.razor (:35-:36), and asserts the file exists first, with a because explaining that a form that is not discovered is a convention that is not verified (:38-:39). It then requires four markers (:43-:49): the error summary, Errors.Length: > 0 (the summary rendering from the live MudForm error list), and the ValidateNewPassword / ValidateConfirmPassword client-side wiring; it reports every missing marker at once rather than failing on the first (:51-:56). Finally it counts occurrences of Required="true" and RequiredError, requiring at least three of each so all three password fields stay required and keep a user-facing message (:58-:61), using the local CountOccurrences helper (:64-:75).
      • @@ -3771,7 +4412,7 @@

        FormsConventionTests

      FrameworkVersionConsistencyTests

      -

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/FrameworkVersionConsistencyTests.cs:9 · Level 10 · class (public, sealed)

      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/FrameworkVersionConsistencyTests.cs:9 · Level 13 · class (public, sealed)

      • What it is - the lockstep-versioning gate: every MMCA.Common.* package pinned in ADC's Directory.Packages.props must carry one and the same version, so a partial sweep fails CI instead of producing a subtly mismatched framework surface at runtime (MMCA.ADC.Architecture.Tests/FrameworkVersionConsistencyTests.cs:3-:8).
      • @@ -3781,7 +4422,7 @@

        FrameworkVersionConsistencyTests

      HandlerConventionTests

      -

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/HandlerConventionTests.cs:3 · Level 10 · class (public, sealed)

      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/HandlerConventionTests.cs:3 · Level 13 · class (public, sealed)

      • What it is - the CQRS handler placement and composition guard: handlers and validators live in the Application layer, handlers do not inject other handlers, application services do not inject handlers, domain event handlers are sealed and live in Application, and application services respect a constructor-arity limit.
      • @@ -3790,17 +4431,29 @@

        HandlerConventionTests

      HandlerResultConventionTests

      -

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/HandlerResultConventionTests.cs:8 · Level 10 · class (public, sealed)

      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/HandlerResultConventionTests.cs:8 · Level 13 · class (public, sealed)

      • What it is - the gate that turns a runtime constraint into a build-time one: every ADC command and query handler's TResult must be Result or Result<T> (MMCA.ADC.Architecture.Tests/HandlerResultConventionTests.cs:3-:7).
      • Depends on - HandlerResultConventionTestsBase and AdcArchitectureMap. Map-only shape (ConcurrencyConventionTests).
      • -
      • Concept introduced - shifting a failure left. The decorator pipeline can short-circuit (a feature flag off, a validation failure, a cache hit), and to do that it must manufacture a failed result of the handler's TResult; the comment names the mechanism, ResultFailureFactory (:5-:6). A handler returning a bare DTO therefore compiles and registers cleanly and only explodes the first time a short-circuit fires in production. [Rubric §6 - CQRS & Event-Driven] and [Rubric §14 - Testability]: an invariant the type system cannot express is exactly what a fitness function is for.
      • +
      • Concept introduced - shifting a failure left. The decorator pipeline can short-circuit (a feature flag off, an authorization denial, a validation failure, a cache hit), and to do that it must manufacture a failed result of the handler's TResult; the comment names the mechanism, ResultFailureFactory (:5-:6). A handler returning a bare DTO therefore compiles and registers cleanly and only explodes the first time a short-circuit fires in production. [Rubric §6 - CQRS & Event-Driven] and [Rubric §14 - Testability]: an invariant the type system cannot express is exactly what a fitness function is for.
      • Walkthrough - one member, Map (:10). Three inherited facts (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/HandlerResultConventionTestsBase.cs:21, :24, :27): the Application layers declare at least one handler (a non-vacuity check), command handlers return result types, and query handlers do too. Opt-in from v1.120.0 (HandlerResultConventionTests.cs:3).
      +

      IdempotencyConventionTests

      +
      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/IdempotencyConventionTests.cs:3 · Level 13 · class (public, sealed)

      +
      +
        +
      • What it is - the rule that every POST action in ADC's API layer states, in code, whether a retried request replays the original response or deliberately does not. Map-only shape (ConcurrencyConventionTests): the body is one line (MMCA.ADC.Architecture.Tests/IdempotencyConventionTests.cs:5).
      • +
      • Depends on - IdempotencyConventionTestsBase and AdcArchitectureMap, and, by attribute name, IdempotentAttribute and NonIdempotentAttribute.
      • +
      • Concept introduced, forcing a decision rather than a default. POST is the one verb HTTP does not define as idempotent, so a client retrying a timed-out POST cannot know whether the first attempt landed. The framework's answer is the Idempotency-Key filter, and it costs an existing client nothing, because the filter no-ops for a request that carries no key header. That makes the only failure mode worth gating an omission nobody noticed: an action that should replay but silently does not. The rule therefore does not demand [Idempotent]; it demands that the author write down which of the two applies, with [NonIdempotent("why")] carrying its own justification string (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Idempotency.cs:14-:31, :57-:60). [Rubric §9 - API & Contract Design] assesses retry semantics as part of the contract; [Rubric §29 - Resilience & Business Continuity]: a retry that double-writes is precisely the failure a resilience policy creates when the endpoint has not thought about it.
      • +
      • Walkthrough - one member, Map (:5), implementing the base's abstract property (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/IdempotencyConventionTestsBase.cs:12). One inherited fact, PostActions_ShouldDeclare_IdempotencyIntent (:15), delegating to ArchitectureRules.PostActionsDeclareIdempotencyIntent(Map) (ArchitectureRules.Idempotency.cs:43-:60), which walks the concrete controllers in every Layer.Api assembly the map declares (:47-:52). Attributes are read with inherit: true and abstract controller types are skipped, so an ADC controller that inherits a POST action from AuthControllerBase or AggregateRootEntityControllerBase already satisfies the rule through that base rather than needing its own attribute (:32-:37).
      • +
      • Why it's built this way - the base states the subclassing rule: derive it in a repo whose map declares an Api layer, and a repo with no API layer simply does not subclass (IdempotencyConventionTestsBase.cs:6-:8). ADC declares an Api layer for all three mapped modules, so the gate is live across the whole REST surface.
      • +
      • Caveats / not-in-source - detection is by attribute type name, keeping the rule library free of an ASP.NET reference, and only [HttpPost] is recognised: an action routed through [AcceptVerbs("POST")] or a conventional route is out of scope (ArchitectureRules.Idempotency.cs:38-:42). The Notification module's API assembly is not in the map, so its POST actions, if any, are outside this gate.
      • +

      ImmutabilityTests

      -

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/ImmutabilityTests.cs:3 · Level 10 · class (public, sealed)

      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/ImmutabilityTests.cs:3 · Level 13 · class (public, sealed)

      • What it is - the immutability guard across five categories of type: DTOs, commands and queries, domain events, integration events, and value objects (the last also required to be sealed and to live in Shared).
      • @@ -3809,19 +4462,19 @@

        ImmutabilityTests

      IntegrationEventContractTests

      -

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/IntegrationEventContractTests.cs:3 · Level 10 · class (public, sealed)

      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/IntegrationEventContractTests.cs:3 · Level 13 · class (public, sealed)

        -
      • What it is - the frozen wire contract for ADC's cross-service asynchronous API. It commits a seven-line snapshot of every integration event's full name and property shape, and the build fails if the live contract differs (MMCA.ADC.Architecture.Tests/IntegrationEventContractTests.cs:9-:20).
      • +
      • What it is - the frozen wire contract for ADC's cross-service asynchronous API. It commits a seven-line snapshot of every integration event's full name and property shape, and the build fails if the live contract differs (MMCA.ADC.Architecture.Tests/IntegrationEventContractTests.cs:9-:20).
      • Depends on - IntegrationEventContractTestsBase and AdcArchitectureMap.
      • -
      • Concept introduced, the approval snapshot. The rules above check shape rules; this one checks identity. A consumer in another service deserializes by shape, so a renamed, removed, or retyped property (or a brand-new event shipped without a consumer) breaks the contract at runtime with no compile error anywhere. The base rebuilds the live contract from the map and asserts sequence equality against the committed list (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/IntegrationEventContractTestsBase.cs:21-:28), so any change surfaces as a diff in this file that a reviewer must consciously accept. [Rubric §9 - API & Contract Design] assesses contract governance, and the asynchronous contract is as much an API as the REST surface; [Rubric §7 - Microservices Readiness]: with all four ADC modules already running as separate services, this list is the actual coupling between them.
      • -
      • Walkthrough - two members. Map (:5), and ExpectedContract (:9-:20), a collection expression of seven strings in FullName { Prop:Type, ... } form: EventFeedbackSubmitted and SessionFeedbackSubmitted from Conference, SpeakerLinkedToUser and SpeakerUnlinkedFromUser from Conference (the pair Identity consumes to set and clear User.LinkedSpeakerId), AttendeeCheckedIn from Engagement, and UserDeleted plus UserRegistered from Identity. The properties are listed in sorted order, which is how a rebuilt contract stays comparable line by line.
      • +
      • Concept introduced, the approval snapshot. The rules above check shape rules; this one checks identity. A consumer in another service deserializes by shape, so a renamed, removed, or retyped property (or a brand-new event shipped without a consumer) breaks the contract at runtime with no compile error anywhere. The base rebuilds the live contract from the map and asserts sequence equality against the committed list (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/IntegrationEventContractTestsBase.cs:19-:29), so any change surfaces as a diff in this file that a reviewer must consciously accept. [Rubric §9 - API & Contract Design] assesses contract governance, and the asynchronous contract is as much an API as the REST surface; [Rubric §7 - Microservices Readiness]: with all four ADC modules already running as separate services, this list is the actual coupling between them. Its synchronous twin is ProtoContractTests.
      • +
      • Walkthrough - two members. Map (:5), and ExpectedContract (:9-:20), a collection expression of seven strings in FullName { Prop:Type, ... } form: EventFeedbackSubmitted and SessionFeedbackSubmitted from Conference, SpeakerLinkedToUser and SpeakerUnlinkedFromUser from Conference (the pair Identity consumes to set and clear the linked-speaker reference on the user), AttendeeCheckedIn from Engagement, and UserDeleted plus UserRegistered from Identity. The properties are listed in sorted order, which is how a rebuilt contract stays comparable line by line.
      • Why it's built this way - the comment states the rule of engagement (:7-:8): update the snapshot deliberately, and version the event or coordinate the consumer rollout in the same commit. The AttendeeCheckedIn entry carries its own inline justification (:15-:16): SponsorId is additive, optional, defaults to null, and is declared last precisely so a payload written before the sponsor scope existed still deserializes (confirmed in the event itself, MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/IntegrationEvents/AttendeeCheckedIn.cs:21, :29).
      • -
      • Caveats / not-in-source - the snapshot proves that the shape has not changed, not that any consumer actually handles it. Consumer-side behavior is exercised by the cross-service Testcontainers tier, not here.
      • +
      • Caveats / not-in-source - the snapshot proves that the shape has not changed, not that any consumer actually handles it. Consumer-side behavior is exercised by the cross-service Testcontainers tier, not here. The contract is rebuilt from mapped assemblies, so an integration event declared in the unmapped Notification module would not appear.

      LayerDependencyTests

      -

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/LayerDependencyTests.cs:3 · Level 10 · class (public, sealed)

      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/LayerDependencyTests.cs:3 · Level 13 · class (public, sealed)

      • What it is - the Clean Architecture layer-flow guard, and the highest-fact-count rule in the unit: fifteen inherited facts covering which layer may reference which.
      • @@ -3830,7 +4483,7 @@

        LayerDependencyTests

      LocalizedTextConventionTests

      -

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/LocalizedTextConventionTests.cs:14 · Level 10 · class (public, sealed)

      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/LocalizedTextConventionTests.cs:14 · Level 13 · class (public, sealed)

      • What it is - the companion to TranslationCompletenessTests. Where that one asks "is every key translated", this one asks "does every user-visible string go through a key at all": no hard-coded literals in .razor or .razor.cs under Source/ (MMCA.ADC.Architecture.Tests/LocalizedTextConventionTests.cs:3-:13).
      • @@ -3840,7 +4493,7 @@

        LocalizedTextConventionTests

      MicroserviceExtractionTests

      -

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/MicroserviceExtractionTests.cs:3 · Level 10 · class (public, sealed)

      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/MicroserviceExtractionTests.cs:3 · Level 13 · class (public, sealed)

      • What it is - the single-fact guard that transport never leaks into the core layers, so a module behaves identically in-process or extracted.
      • @@ -3849,7 +4502,7 @@

        MicroserviceExtractionTests

      ModuleIsolationTests

      -

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/ModuleIsolationTests.cs:3 · Level 10 · class (public, sealed)

      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/ModuleIsolationTests.cs:3 · Level 13 · class (public, sealed)

      • What it is - the guard that Identity, Conference, and Engagement do not reach into each other: module Domains, Applications, Infrastructures, and APIs are each isolated from their siblings, and neither Domain nor Application may reach another module's Infrastructure.
      • @@ -3858,7 +4511,7 @@

        ModuleIsolationTests

      NamingConventionTests

      -

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/NamingConventionTests.cs:3 · Level 10 · class (public, sealed)

      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/NamingConventionTests.cs:3 · Level 13 · class (public, sealed)

      • What it is - the ten-fact naming and sealing guard: handler, command, query, validator, DTO, specification, repository, and EF configuration suffixes, plus domain events sealed in a *.DomainEvents namespace and invariant classes static.
      • @@ -3867,7 +4520,7 @@

        NamingConventionTests

      PiiConventionTests

      -

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/PiiConventionTests.cs:3 · Level 10 · class (public, sealed)

      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/PiiConventionTests.cs:3 · Level 13 · class (public, sealed)

      • What it is - the privacy structural guard: every domain entity declaring a PiiAttribute-marked property must implement IAnonymizable, so an entity that holds personal data always has an erasure path.
      • @@ -3876,7 +4529,7 @@

        PiiConventionTests

      RawQueryableConventionTests

      -

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/RawQueryableConventionTests.cs:11 · Level 10 · class (public, sealed)

      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/RawQueryableConventionTests.cs:11 · Level 13 · class (public, sealed)

      • What it is - the rule that Application-layer code must not use the repository's raw IQueryable surfaces (Table / TableNoTracking*), carrying an eight-file allowlist that pins ADC's existing deliberate uses (MMCA.ADC.Architecture.Tests/RawQueryableConventionTests.cs:3-:9, :33-:52).
      • @@ -3891,9 +4544,20 @@

        RawQueryableConventionTests

      • Caveats / not-in-source - AllowedFiles matches by file name, not by path, so two files with the same name in different modules would both be exempted. Nothing here enforces the "shrink it over time" discipline the comment asks for.
      +

      ServiceContractPurityTests

      +
      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/ServiceContractPurityTests.cs:9 · Level 13 · class (public, sealed)

      +
      +
        +
      • What it is - the purity rule for the published gRPC wire surface: a type marked ServiceContractAttribute must not depend on the producing service's Domain, Application, or Infrastructure (MMCA.ADC.Architecture.Tests/ServiceContractPurityTests.cs:3-:8). Map-only shape (ConcurrencyConventionTests).
      • +
      • Depends on - ServiceContractPurityTestsBase and AdcArchitectureMap.
      • +
      • Concept introduced, the attribute-driven ratchet, and the honest vacuous pass. The other purity rules in this unit iterate layers. This one cannot: no repo registers Layer.Contracts in its map today, so a layer-iterating rule would pass vacuously forever without anyone noticing (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/ServiceContractPurityTestsBase.cs:8-:11). Instead it scans every assembly the map registers for types carrying the marker, wherever they live, and enforces the invariant from the first marked type onward. The base is explicit that a repo which marks no type yet passes without asserting anything, and that this is the deliberate trade: the value is the ratchet, an invariant already wired up with no test left to remember to write (:12-:18). [Rubric §7 - Microservices Readiness] assesses whether a service can be consumed without its internals: a contract that leaks a domain entity or a persistence type forces every consumer to take the producer as a package dependency, which is what makes an extraction irreversible (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Contracts.cs:14-:18). [Rubric §9 - API & Contract Design] covers the same boundary from the contract side.
      • +
      • Walkthrough - one member, Map (:11), implementing the base's abstract property (ServiceContractPurityTestsBase.cs:22). One inherited fact, ServiceContracts_ShouldNotDependOn_ServiceInternals (:25), delegating to ArchitectureRules.ServiceContractsDoNotDependOnServiceInternals(Map) (ArchitectureRules.Contracts.cs:32-:54). The rule computes the forbidden internal namespaces from the map, returns immediately if that set is empty (:34-:38), and otherwise runs a NetArchTest query per mapped assembly against the types carrying the marker (:40-:53). The marker is matched by full name, MMCA.Common.Shared.Abstractions.ServiceContractAttribute (:10-:11), keeping the rule library free of a framework reference.
      • +
      • Caveats / not-in-source - ADC declares no [ServiceContract] type in Source/ today, and the four *.Contracts projects are not in AdcArchitectureMap, so this rule currently passes without inspecting anything in this repo. That is the base's documented vacuous case, not a defect, but it does mean the enforcement here is latent: it starts biting the day someone marks a type in a mapped assembly. ADC's actual gRPC contract governance today runs through ProtoContractTests, which is not vacuous.
      • +

      SharedLayerTests

      -

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/SharedLayerTests.cs:3 · Level 10 · class (public, sealed)

      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/SharedLayerTests.cs:3 · Level 13 · class (public, sealed)

      • What it is - the guard on the Shared layer, the one layer other modules are allowed to reference: a module's Shared project must not depend on that module's own internal layers, must not reach sibling modules, and must stay free of EF Core.
      • @@ -3902,7 +4566,7 @@

        SharedLayerTests

      SliceCohesionTests

      -

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/SliceCohesionTests.cs:8 · Level 10 · class (public, sealed)

      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/SliceCohesionTests.cs:8 · Level 13 · class (public, sealed)

      • What it is - the vertical-slice cohesion rule: every module's Application/{Aggregate}/UseCases/{Operation}/ slice keeps its command or query, its handler, and its validator in one namespace, and the build fails if a handler is stranded from its contract (MMCA.ADC.Architecture.Tests/SliceCohesionTests.cs:3-:7).
      • @@ -3911,7 +4575,7 @@

        SliceCohesionTests

      SpecificationConventionTests

      -

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/SpecificationConventionTests.cs:8 · Level 10 · class (public, sealed)

      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/SpecificationConventionTests.cs:8 · Level 13 · class (public, sealed)

      • What it is - the cross-source specification guard: no specification may filter by navigating to another entity, because such a filter would not translate if that entity later moved to a different data source. The stated alternative is CrossSourceSpecification (MMCA.ADC.Architecture.Tests/SpecificationConventionTests.cs:3-:7).
      • @@ -3921,7 +4585,7 @@

        SpecificationConventionTests

      StateManagementConventionTests

      -

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/StateManagementConventionTests.cs:9 · Level 10 · class (public, sealed)

      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/StateManagementConventionTests.cs:9 · Level 13 · class (public, sealed)

      • What it is - the Blazor state-ownership guard: the Identity, Conference, and Engagement UI assemblies carry no mutable static state, and stateful UI services stay scoped (MMCA.ADC.Architecture.Tests/StateManagementConventionTests.cs:3-:8).
      • @@ -3931,7 +4595,7 @@

        StateManagementConventionTests

      UIArchitectureConventionTests

      -

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/UIArchitectureConventionTests.cs:10 · Level 10 · class (public, sealed)

      +

      MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests · MMCA.ADC.Architecture.Tests/UIArchitectureConventionTests.cs:10 · Level 13 · class (public, sealed)

      • What it is - the container/presentational split enforced mechanically: every code-behind under Source/ (module UI and UI hosts alike) stays within the 400-line convention cap, and inline @code blocks stay small (MMCA.ADC.Architecture.Tests/UIArchitectureConventionTests.cs:3-:9).
      • diff --git a/docs/onboarding/index.html b/docs/onboarding/index.html index ae5fe45..9040a20 100644 --- a/docs/onboarding/index.html +++ b/docs/onboarding/index.html @@ -205,9 +205,9 @@

        How the inventory & level

        The type inventory and dependency graph are produced mechanically by a small Roslyn syntactic parser (Tools/invtool/), so the type list, namespaces, and file:line are exact and reproducible. Edges are resolved by namespace-aware name matching; ~96% bind by namespace visibility, the rest by a - globally-unique-name fallback (493 edges), and 29 references are dropped as ambiguous. The functional grouping is + globally-unique-name fallback (540 edges), and 29 references are dropped as ambiguous. The functional grouping is then applied mechanically (Tools/invtool/classify.ps100-group-taxonomy.md), so every one of the - 3,465 distinct type nodes maps to exactly one group with no silent drops. See the + 3,668 distinct type nodes maps to exactly one group with no silent drops. See the manifest's accuracy note for residual caveats.


        Chapters

        @@ -225,7 +225,7 @@

        Front matter

        00-inventory.md - Phase 0, every in-scope type (3,465 distinct), mechanically extracted, with file:line + Phase 0, every in-scope type (3,668 distinct), mechanically extracted, with file:line 00-dependency-manifest.md @@ -261,7 +261,7 @@

        Group chapters (primary axis)

        3 Querying: Specifications, Filtering & the Entity Query Service - 37 + 38 Specification pattern (incl. cross-source), dynamic filter/sort/page (incl. IN-list value parsing), the generic query pipeline @@ -273,7 +273,7 @@

        Group chapters (primary axis)

        5 CQRS: Commands, Queries & the Decorator Pipeline - 36 + 38 Handler abstraction + the logging/transaction/caching/feature-gate/idempotency decorators (incl. the cache-stampede lock) @@ -285,13 +285,13 @@

        Group chapters (primary axis)

        7 Persistence & EF Core - 116 + 118 SQLServerDbContext over abstract ApplicationDbContext, interceptors (incl. the deferred-dispatch record), repositories, engine-aware entity config, data-source routing, conventions (incl. the soft-delete unique-index convention), factories, managed file storage + image processing (ADR-045), native-push device registrar (ADR-044) 8 Authentication & Authorization - 69 + 77 JWT/JWKS dual-fetch, the shared AuthenticationServiceBase<TUser> login/refresh workflow (IAuthUser), current-user/claims, password hashing, cookie sessions, role policies + the permission-based authorization mechanism (registry, [HasPermission]), the RoleValue base, the external-auth-broker contract (ADR-042/043) @@ -315,7 +315,7 @@

        Group chapters (primary axis)

        12 API Hosting, Middleware, Idempotency & DTO/Contract Mapping - 74 + 79 Controller bases (incl. OAuth + service-info + API versioning, ADR-046), middleware (incl. soft-deleted-user revocation, ADR-047), startup, model binders, JSON converters, feature mgmt, idempotency, mapping, edge error localization (i18n), authenticated output caching (ADR-040), app-association/deep-link endpoints (ADR-043) @@ -327,13 +327,13 @@

        Group chapters (primary axis)

        14 Module System, Composition & Configuration - 68 + 70 IModule + Kahn-ordered loader, DI composition roots, data-source attributes, options binding 15 Common UI Framework - 89 + 91 Reusable MudBlazor building blocks: data-grid list page base, theme, common pages/services, i18n culture bootstrap + day/dark ThemeService, user-preference readers/writers, pseudo-localization gate, OAuth UI settings + token storage (Web/WASM), hub-channel subscriptions (ADR-039) @@ -345,31 +345,31 @@

        Group chapters (primary axis)

        17 ADC Conference, Domain Model & Module Contracts - 96 + 100 Event/Session/Speaker/Category/Question aggregates + domain events + invariants + Shared contracts (incl. ConferencePermissions, the current/next-event selector + live-validation contracts) 18 ADC Conference, Application & Use Cases - 252 + 285 Conference CQRS handlers, validators (incl. the per-field session validation-rule family), DTOs, specs, Sessionize import, decision-support analytics, batch bookmark-count query, event-filtering-by-role handlers, calendar (.ics) export slice (ADR-042) 19 ADC Conference, Infrastructure & Persistence - 32 + 33 Conference DbContext registration, EF configs, seeding, infra services 20 ADC Conference, API, gRPC Contracts & Service Host - 42 + 43 REST controllers, .Contracts gRPC, the extractable service host (incl. its Kestrel config), the gRPC adapters (incl. cross-service live-validation), localized error resources 21 ADC Conference, UI - 97 + 106 Conference Blazor pages + UI services, the single canonical ADC Home page + its view models, the AI-scoring poll recovery tracker, calendar/QR export UI, OfflineBanner, PresenterLayout on Common theme providers @@ -381,13 +381,13 @@

        Group chapters (primary axis)

        23 ADC Engagement Live Layer (Real-Time Polls & Session Q&A) - 94 + 95 Event-wide live polls with voting + moderated per-session Q&A with upvoting, over the ADR-039 hub-channel transport and the cross-service gRPC live-channel adapter (HappeningNow / SessionLive / PresenterView) 24 ADC Identity Module (Users, Profiles, GDPR Export/Erasure) - 83 + 88 The Identity bounded context end-to-end (incl. IdentityPermissions, user culture/theme preferences, the ADR-045 user-avatar photo slice end to end, the external-login email verifier + extractable-host Kestrel config; AuthenticationService now extends Common's shared base) @@ -405,7 +405,7 @@

        Group chapters (primary axis)

        27 Testing & Quality Infrastructure - 1780 + 1,907 All test projects + reusable Testing/Testing.E2E/Testing.UI/Testing.Architecture bases (incl. the handler + decorator-pipeline test bases, the shared production-host/graceful-shutdown bases, the observability-convention fitness base, and the Gallery auth stubs) + architecture-fitness tests + Gallery + the BenchmarkDotNet perf-smoke suite

    diff --git a/sitemap.xml b/sitemap.xml index 6aff4a8..a45579c 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -522,27 +522,27 @@ https://ivanball.github.io/docs/onboarding/index.html - 2026-08-19 + 2026-08-23 0.8 https://ivanball.github.io/docs/onboarding/00-dependency-manifest.html - 2026-08-19 + 2026-08-23 0.6 https://ivanball.github.io/docs/onboarding/00-group-taxonomy.html - 2026-08-19 + 2026-08-23 0.6 https://ivanball.github.io/docs/onboarding/00-inventory.html - 2026-08-19 + 2026-08-23 0.6 https://ivanball.github.io/docs/onboarding/00-primer.html - 2026-08-19 + 2026-08-23 0.6 @@ -557,7 +557,7 @@ https://ivanball.github.io/docs/onboarding/group-03-querying-specifications.html - 2026-08-19 + 2026-08-23 0.6 @@ -567,7 +567,7 @@ https://ivanball.github.io/docs/onboarding/group-05-cqrs-pipeline.html - 2026-08-19 + 2026-08-23 0.6 @@ -577,12 +577,12 @@ https://ivanball.github.io/docs/onboarding/group-07-persistence-ef-core.html - 2026-08-19 + 2026-08-23 0.6 https://ivanball.github.io/docs/onboarding/group-08-auth.html - 2026-08-15 + 2026-08-23 0.6 @@ -602,7 +602,7 @@ https://ivanball.github.io/docs/onboarding/group-12-api-hosting-mapping.html - 2026-08-19 + 2026-08-23 0.6 @@ -612,12 +612,12 @@ https://ivanball.github.io/docs/onboarding/group-14-module-system-composition.html - 2026-08-19 + 2026-08-23 0.6 https://ivanball.github.io/docs/onboarding/group-15-common-ui-framework.html - 2026-08-15 + 2026-08-23 0.6 @@ -627,42 +627,42 @@ https://ivanball.github.io/docs/onboarding/group-17-conference-domain.html - 2026-08-15 + 2026-08-23 0.6 https://ivanball.github.io/docs/onboarding/group-18-conference-application.html - 2026-08-15 + 2026-08-23 0.6 https://ivanball.github.io/docs/onboarding/group-19-conference-infrastructure.html - 2026-08-19 + 2026-08-23 0.6 https://ivanball.github.io/docs/onboarding/group-20-conference-api-grpc.html - 2026-08-15 + 2026-08-23 0.6 https://ivanball.github.io/docs/onboarding/group-21-conference-ui.html - 2026-08-15 + 2026-08-23 0.6 https://ivanball.github.io/docs/onboarding/group-22-engagement-module.html - 2026-08-19 + 2026-08-23 0.6 https://ivanball.github.io/docs/onboarding/group-23-engagement-live-layer.html - 2026-08-02 + 2026-08-23 0.6 https://ivanball.github.io/docs/onboarding/group-24-identity-module.html - 2026-08-15 + 2026-08-23 0.6 @@ -677,7 +677,7 @@ https://ivanball.github.io/docs/onboarding/group-27-testing-infrastructure.html - 2026-08-19 + 2026-08-23 0.6 @@ -692,7 +692,7 @@ https://ivanball.github.io/docs/onboarding/devops-iac.html - 2026-08-15 + 2026-08-23 0.6 @@ -707,7 +707,7 @@ https://ivanball.github.io/docs/onboarding/99-coverage-audit.html - 2026-08-19 + 2026-08-23 0.6